pages235/src/react/PauseScreen.tsx
Vitaly Turovsky 2f93c08b1e fix lint
2025-07-04 18:05:26 +03:00

349 lines
14 KiB
TypeScript

import { join } from 'path'
import fs from 'fs'
import { useEffect } from 'react'
import { subscribe, useSnapshot } from 'valtio'
import { usedServerPathsV1 } from 'flying-squid/dist/lib/modules/world'
import { openURL } from 'renderer/viewer/lib/simpleUtils'
import { Vec3 } from 'vec3'
import { generateSpiralMatrix } from 'flying-squid/dist/utils'
import { subscribeKey } from 'valtio/utils'
import { ErrorBoundary } from '@zardoy/react-util'
import {
activeModalStack,
showModal,
hideModal,
miscUiState,
openOptionsMenu,
gameAdditionalState
} from '../globalState'
import { fsState } from '../loadSave'
import { disconnect } from '../flyingSquidUtils'
import { openGithub, pointerLock } from '../utils'
import { setLoadingScreenStatus } from '../appStatus'
import { closeWan, openToWanAndCopyJoinLink, getJoinLink } from '../localServerMultiplayer'
import { collectFilesToCopy, fileExistsAsyncOptimized, mkdirRecursive, uniqueFileNameFromWorldName } from '../browserfs'
import { appQueryParams } from '../appParams'
import { downloadPacketsReplay, packetsRecordingState } from '../packetsReplay/packetsReplayLegacy'
import { options } from '../optionsStorage'
import { useIsModalActive } from './utilsApp'
import { showOptionsModal } from './SelectOption'
import Button from './Button'
import Screen from './Screen'
import styles from './PauseScreen.module.css'
import { DiscordButton } from './DiscordButton'
import { showNotification } from './NotificationProvider'
import { appStatusState, lastConnectOptions, reconnectReload } from './AppStatusProvider'
import NetworkStatus from './NetworkStatus'
import PauseLinkButtons from './PauseLinkButtons'
import { pixelartIcons } from './PixelartIcon'
import LoadingTimer from './LoadingTimer'
const waitForPotentialRender = async () => {
return new Promise<void>(resolve => {
requestAnimationFrame(() => requestAnimationFrame(resolve as any))
})
}
export const saveToBrowserMemory = async () => {
setLoadingScreenStatus('Saving world')
try {
await new Promise<void>(resolve => {
subscribeKey(appStatusState, 'isDisplaying', () => {
if (appStatusState.isDisplaying) {
resolve()
}
})
})
const worldFolder = fsState.inMemorySavePath
const saveRootPath = await uniqueFileNameFromWorldName(worldFolder.split('/').pop()!, `/data/worlds`)
await mkdirRecursive(saveRootPath)
console.log('made world folder', saveRootPath)
const allRootPaths = [...usedServerPathsV1]
const allFilesToCopy = [] as string[]
for (const dirBase of allRootPaths) {
// eslint-disable-next-line no-await-in-loop
if (dirBase.includes('.') && await fileExistsAsyncOptimized(join(worldFolder, dirBase))) {
allFilesToCopy.push(dirBase)
continue
}
// eslint-disable-next-line no-await-in-loop
let res = await collectFilesToCopy(join(worldFolder, dirBase), true)
if (dirBase === 'region') {
res = res.filter(x => x.endsWith('.mca'))
}
allFilesToCopy.push(...res.map(x => join(dirBase, x)))
}
console.log('paths collected')
const pathsSplitBasic = allFilesToCopy.filter(path => {
if (!path.startsWith('region/')) return true
const [x, z] = path.split('/').at(-1)!.split('.').slice(1, 3).map(Number)
return Math.abs(x) > 50 || Math.abs(z) > 50 // HACK: otherwise it's too big and we can't handle it in visual display
})
let copied = 0
let isRegionFiles = false
const upProgress = (totalSize: number) => {
copied++
let action = fsState.remoteBackend ? 'Downloading & copying' : 'Copying'
action += isRegionFiles ? ' region files (world chunks)' : ' basic save files'
setLoadingScreenStatus(`${action} files (${copied}/${totalSize})`)
}
const copyFiles = async (copyPaths: string[][]) => {
const totalSIze = copyPaths.flat().length
for (const copyFileGroup of copyPaths) {
// eslint-disable-next-line no-await-in-loop, @typescript-eslint/no-loop-func
await Promise.all(copyFileGroup.map(async (copyPath) => {
const srcPath = join(worldFolder, copyPath)
const savePath = join(saveRootPath, copyPath)
await mkdirRecursive(savePath)
await fs.promises.writeFile(savePath, await fs.promises.readFile(srcPath) as any)
upProgress(totalSIze)
if (isRegionFiles) {
const regionFile = copyPath.split('/').at(-1)!
appStatusState.loadingChunksData![regionFile] = 'done'
}
}))
// eslint-disable-next-line no-await-in-loop
await waitForPotentialRender()
}
}
// basic save files
await copyFiles(splitByCopySize(pathsSplitBasic))
setLoadingScreenStatus('Preparing world chunks copying')
await waitForPotentialRender()
// region files
isRegionFiles = true
copied = 0
const regionFiles = allFilesToCopy.filter(x => !pathsSplitBasic.includes(x))
const regionFilesNumbers = regionFiles.map(x => x.split('/').at(-1)!.split('.').slice(1, 3).map(Number))
const xMin = Math.min(...regionFilesNumbers.flatMap(x => x[0]))
const zMin = Math.min(...regionFilesNumbers.flatMap(x => x[1]))
const xMax = Math.max(...regionFilesNumbers.flatMap(x => x[0]))
const zMax = Math.max(...regionFilesNumbers.flatMap(x => x[1]))
const playerPosRegion = bot.entity.position.divide(new Vec3(32 * 16, 32 * 16, 32 * 16)).floored()
const maxDistantRegion = Math.max(
Math.abs(playerPosRegion.x - xMin),
Math.abs(playerPosRegion.z - zMin),
Math.abs(playerPosRegion.x - xMax),
Math.abs(playerPosRegion.z - zMax)
)
const spiral = generateSpiralMatrix(maxDistantRegion)
const filesWithSpiral = spiral.filter(x => allFilesToCopy.includes(`region/r.${x[0]}.${x[1]}.mca`)).map(x => `region/r.${x[0]}.${x[1]}.mca`)
if (filesWithSpiral.length !== regionFiles.length) throw new Error('Something went wrong with region files')
appStatusState.loadingChunksData = Object.fromEntries(regionFiles.map(x => [x.split('/').at(-1)!, 'loading']))
appStatusState.loadingChunksDataPlayerChunk = { x: playerPosRegion.x, z: playerPosRegion.z }
await copyFiles(splitByCopySize(filesWithSpiral, 10))
return saveRootPath
} catch (err) {
console.error(err)
void showOptionsModal(`Error while saving the world: ${err.message}`, [])
} finally {
setLoadingScreenStatus(undefined)
}
}
const splitByCopySize = (files: string[], copySize = 15) => {
return files.reduce<string[][]>((acc, cur, i) => {
if (i % copySize === 0) {
acc.push([])
}
acc.at(-1)!.push(cur)
return acc
}, [])
}
export default () => {
const lockConnect = appQueryParams.lockConnect === 'true'
const isModalActive = useIsModalActive('pause-screen')
const fsStateSnap = useSnapshot(fsState)
const activeModalStackSnap = useSnapshot(activeModalStack)
const { singleplayer, wanOpened, wanOpening } = useSnapshot(miscUiState)
const { noConnection } = useSnapshot(gameAdditionalState)
const { active: packetsReplaceActive, hasRecordedPackets: packetsReplaceHasRecordedPackets } = useSnapshot(packetsRecordingState)
const { displayRecordButton: displayPacketsButtons } = useSnapshot(options)
const { appConfig } = useSnapshot(miscUiState)
const handlePointerLockChange = () => {
if (!pointerLock.hasPointerLock && activeModalStack.length === 0) {
showModal({ reactType: 'pause-screen' })
}
}
useEffect(() => {
document.addEventListener('pointerlockchange', handlePointerLockChange)
return () => {
document.removeEventListener('pointerlockchange', handlePointerLockChange)
}
}, [])
const onReturnPress = () => {
hideModal({ reactType: 'pause-screen' })
}
const clickWebShareButton = async () => {
if (!wanOpened) return
try {
const url = getJoinLink()
const shareData = { url }
await navigator.share?.(shareData)
} catch (err) {
console.log(`Error: ${err}`)
}
}
const clickJoinLinkButton = async (qr = false) => {
if (!qr && wanOpened) {
closeWan()
return
}
if (!wanOpened || !qr) {
await openToWanAndCopyJoinLink((err) => {
if (!miscUiState.wanOpening) return
alert(`Something went wrong: ${err}`)
}, !qr)
}
if (qr) {
const joinLink = getJoinLink()
miscUiState.currentDisplayQr = joinLink ?? null
}
}
const openWorldActions = async () => {
if (fsStateSnap.inMemorySave || !singleplayer) {
return showOptionsModal('World actions...', [])
}
const action = await showOptionsModal('World actions...', ['Save to browser memory'])
if (action === 'Save to browser memory') {
const path = await saveToBrowserMemory()
if (!path) return
const saveName = path.split('/').at(-1)
showNotification(`World saved to ${saveName}`, 'Load it to keep your progress!')
// fsState.inMemorySave = true
// fsState.syncFs = false
// fsState.isReadonly = false
// fsState.remoteBackend = false
}
}
if (!isModalActive) return null
return <Screen title='Game Menu'>
<div style={{ position: 'fixed', top: '5px', left: 'calc(env(safe-area-inset-left) + 5px)', display: 'flex', flexDirection: 'column', gap: '5px' }}>
<Button
icon="pixelarticons:folder"
onClick={async () => openWorldActions()}
/>
{displayPacketsButtons && (
<>
<Button
icon={packetsReplaceActive ? 'pixelarticons:debug-stop' : 'pixelarticons:circle'}
onClick={() => {
packetsRecordingState.active = !packetsRecordingState.active
}}
/>
{packetsReplaceHasRecordedPackets && (
<Button
icon={pixelartIcons['briefcase-download']}
onClick={async () => downloadPacketsReplay()}
/>
)}
<Button
icon={pixelartIcons['download']}
onClick={async () => bot.downloadCurrentWorldState()}
/>
</>
)}
</div>
<ErrorBoundary renderError={() => <div>error</div>}>
<div style={{ position: 'fixed', top: '5px', left: 'calc(env(safe-area-inset-left) + 35px)' }}>
<NetworkStatus />
</div>
</ErrorBoundary>
<div className={styles.pause_container}>
<Button className="button" style={{ width: '204px' }} onClick={onReturnPress}>Back to Game</Button>
<PauseLinkButtons />
<Button className="button" style={{ width: '204px' }} onClick={() => openOptionsMenu('main')}>Options...</Button>
{singleplayer ? (
<div className={styles.row}>
<Button className="button" style={{ width: '170px' }} onClick={async () => clickJoinLinkButton()}>
{wanOpening ? 'Opening, wait...' : wanOpened ? 'Close Wan' : 'Copy Join Link'}
</Button>
{(navigator.share as typeof navigator.share | undefined) ? (
<Button
title="Share Join Link"
className="button"
icon="pixelarticons:arrow-up"
style={{ width: '20px' }}
onClick={async () => clickWebShareButton()}
/>
) : null}
<Button
title='Display QR for the Join Link'
className="button"
icon="pixelarticons:dice"
style={{ width: '20px' }}
onClick={async () => clickJoinLinkButton(true)}
/>
</div>
) : null}
{!lockConnect && <>
<Button className="button" style={{ width: '204px' }} onClick={disconnect}>
{fsState.inMemorySave && !fsState.syncFs && !fsState.isReadonly ? 'Save & Quit' : 'Disconnect & Reset'}
</Button>
</>}
{(noConnection || appConfig?.alwaysReconnectButton) && (
<div className={styles.row}>
<Button className="button" style={{ width: appConfig?.reportBugButtonWithReconnect ? '98px' : '204px' }} onClick={reconnectReload}>
Reconnect
</Button>
{appConfig?.reportBugButtonWithReconnect && (
<Button
label="Report Problem"
className="button"
style={{ width: '98px' }}
onClick={async () => {
const platform = (navigator as any).userAgentData?.platform ?? navigator.platform
const body = `Version: ${window.location.hostname}\nServer: ${lastConnectOptions.value?.server ?? '<not a server>'}\nPlatform: ${platform}\nWebsite: ${window.location.href}`
const currentHost = window.location.hostname
const options = [
'GitHub (please use it if you can)',
'Email',
...((currentHost === 'mcraft.fun' || currentHost === 'ru.mcraft.fun') ? ['Try Beta Version'] : []),
// 'Use previous versions of client'
]
const action = await showOptionsModal('Report client issue', options)
if (!action) return
switch (action) {
case 'GitHub (please use it if you can)':
openGithub(`/issues/new?body=${encodeURIComponent(body)}&title=${encodeURIComponent('[Bug Report] <describe your issue here>')}&labels=bug`)
break
case 'Email': {
window.location.href = `mailto:support@mcraft.fun?body=${encodeURIComponent(body)}`
break
}
case 'Try Beta Version': {
if (currentHost === 'mcraft.fun') {
window.location.href = 'https://s.mcraft.fun'
} else if (currentHost === 'ru.mcraft.fun') {
window.location.href = 'https://s.pcm.gg'
}
break
}
case 'Use previous versions of client':
// TODO: Implement versions screen
void showOptionsModal('Previous versions', [])
break
}
}}
/>
)}
</div>
)}
</div>
<LoadingTimer />
</Screen>
}