diff --git a/.github/workflows/next-deploy.yml b/.github/workflows/next-deploy.yml index b3e7c1d2..f6915839 100644 --- a/.github/workflows/next-deploy.yml +++ b/.github/workflows/next-deploy.yml @@ -3,7 +3,7 @@ env: VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} ALIASES: ${{ vars.ALIASES }} - MAIN_MENU_LINKS: ${{ secrets.MAIN_MENU_LINKS }} + MAIN_MENU_LINKS: ${{ vars.MAIN_MENU_LINKS }} on: push: branches: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cc06bfb0..9472118b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,7 +2,7 @@ name: Release env: VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} - MAIN_MENU_LINKS: ${{ secrets.MAIN_MENU_LINKS }} + MAIN_MENU_LINKS: ${{ vars.MAIN_MENU_LINKS }} on: push: branches: [release] diff --git a/README.MD b/README.MD index dcfe3fd4..b21e8c8a 100644 --- a/README.MD +++ b/README.MD @@ -141,7 +141,31 @@ Single player specific: - `?singleplayer=1` - Create empty world on load. Nothing will be saved - `?version=` - Set the version for the singleplayer world (when used with `?singleplayer=1`) - `?noSave=true` - Disable auto save on unload / disconnect / export whenever a world is loaded. Only manual save with `/save` command will work. -- `?map=` - Load the map from ZIP. You can use any url, but it must be CORS enabled. +- `?map=` - Load the map from ZIP. You can use any url, but it must be **CORS enabled**. +- `?mapDir=` - Load the map from a file descriptor. It's recommended and the fastest way to load world but requires additional setup. The file must be in the following format: + +```json +{ + "baseUrl": "", + "index": { + "level.dat": null, + "region": { + "r.-1.-1.mca": null, + "r.-1.0.mca": null, + "r.0.-1.mca": null, + "r.0.0.mca": null, + } + } +} +``` + +Note that `mapDir` also accepts base64 encoded JSON like so: +`?mapDir=data:application/json;base64,...` where `...` is the base64 encoded JSON of the index file. +In this case you must use `?mapDirBaseUrl` to specify the base URL to fetch the files from index. + +- `?mapDirBaseUrl` - See above. + + General: diff --git a/package.json b/package.json index 7b27333e..f8eb87eb 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "esbuild-plugin-polyfill-node": "^0.3.0", "express": "^4.18.2", "filesize": "^10.0.12", - "flying-squid": "npm:@zardoy/flying-squid@^0.0.34", + "flying-squid": "npm:@zardoy/flying-squid@^0.0.35", "fs-extra": "^11.1.1", "google-drive-browserfs": "github:zardoy/browserfs#google-drive", "jszip": "^3.10.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 486eebe6..4cab8b08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,8 +117,8 @@ importers: specifier: ^10.0.12 version: 10.0.12 flying-squid: - specifier: npm:@zardoy/flying-squid@^0.0.34 - version: '@zardoy/flying-squid@0.0.34(encoding@0.1.13)' + specifier: npm:@zardoy/flying-squid@^0.0.35 + version: '@zardoy/flying-squid@0.0.35(encoding@0.1.13)' fs-extra: specifier: ^11.1.1 version: 11.1.1 @@ -3386,8 +3386,8 @@ packages: resolution: {integrity: sha512-6xm38yGVIa6mKm/DUCF2zFFJhERh/QWp1ufm4cNUvxsONBmfPg8uZ9pZBdOmF6qFGr/HlT6ABBkCSx/dlEtvWg==} engines: {node: '>=12 <14 || 14.2 - 14.9 || >14.10.0'} - '@zardoy/flying-squid@0.0.34': - resolution: {integrity: sha512-1q9AE4GfmRQhKnSJ3QJtLZIznjJ/IcvwjjKKBS/LrxzaN+qsa3RI2H68OOULj5r/tiGU9DQNncW3CpMlezH6gA==} + '@zardoy/flying-squid@0.0.35': + resolution: {integrity: sha512-6cZdDi7yaqxh6KbOPhDueipcr9DBgJ3mJY+/QwAjaSzhP//5n1BLjyVGlx2Ncs/6Vns2grTOmeuDhJjMbVgjQg==} engines: {node: '>=8'} hasBin: true @@ -12923,7 +12923,7 @@ snapshots: '@types/emscripten': 1.39.8 tslib: 1.14.1 - '@zardoy/flying-squid@0.0.34(encoding@0.1.13)': + '@zardoy/flying-squid@0.0.35(encoding@0.1.13)': dependencies: '@tootallnate/once': 2.0.0 change-case: 4.1.2 diff --git a/prismarine-viewer/viewer/lib/entities.js b/prismarine-viewer/viewer/lib/entities.js index 8d8630ac..57a45a44 100644 --- a/prismarine-viewer/viewer/lib/entities.js +++ b/prismarine-viewer/viewer/lib/entities.js @@ -15,7 +15,7 @@ import { WalkingGeneralSwing } from './entity/animations' import externalTexturesJson from './entity/externalTextures.json' import { disposeObject } from './threeJsUtils' -export const TWEEN_DURATION = 50 // todo should be 100 +export const TWEEN_DURATION = 120 /** * @param {string} username diff --git a/prismarine-viewer/viewer/lib/mesher/models.ts b/prismarine-viewer/viewer/lib/mesher/models.ts index f20fd1f2..c4249ac4 100644 --- a/prismarine-viewer/viewer/lib/mesher/models.ts +++ b/prismarine-viewer/viewer/lib/mesher/models.ts @@ -354,6 +354,8 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO: } } +const makeLooseObj = (obj: Record) => obj + const invisibleBlocks = new Set(['air', 'cave_air', 'void_air', 'barrier']) const isBlockWaterlogged = (block: Block) => block.getProperties().waterlogged === true || block.getProperties().waterlogged === 'true' @@ -363,7 +365,7 @@ let erroredBlockModel: BlockModelPartsResolved export function getSectionGeometry (sx, sy, sz, world: World) { let delayedRender = [] as Array<() => void> - const attr = { + const attr = makeLooseObj({ sx: sx + 8, sy: sy + 8, sz: sz + 8, @@ -379,14 +381,24 @@ export function getSectionGeometry (sx, sy, sz, world: World) { tiles: {}, // todo this can be removed here signs: {}, + highestBlocks: {}, hadErrors: false - } as Record + } as Record) const cursor = new Vec3(0, 0, 0) for (cursor.y = sy; cursor.y < sy + 16; cursor.y++) { for (cursor.z = sz; cursor.z < sz + 16; cursor.z++) { for (cursor.x = sx; cursor.x < sx + 16; cursor.x++) { const block = world.getBlock(cursor)! + if (!invisibleBlocks.has(block.name)) { + const highest = attr.highestBlocks[`${cursor.x},${cursor.z}`] + if (!highest || highest.y < cursor.y) { + attr.highestBlocks[`${cursor.x},${cursor.z}`] = { + y: cursor.y, + name: block.name + } + } + } if (invisibleBlocks.has(block.name)) continue if (block.name.includes('_sign') || block.name === 'sign') { const key = `${cursor.x},${cursor.y},${cursor.z}` diff --git a/prismarine-viewer/viewer/lib/viewer.ts b/prismarine-viewer/viewer/lib/viewer.ts index d52f79b7..7893bccc 100644 --- a/prismarine-viewer/viewer/lib/viewer.ts +++ b/prismarine-viewer/viewer/lib/viewer.ts @@ -170,6 +170,8 @@ export class Viewer { }) } + addChunksBatchWaitTime = 200 + // todo type listen (emitter: EventEmitter) { emitter.on('entity', (e) => { @@ -180,9 +182,26 @@ export class Viewer { // this.updatePrimitive(p) }) + let currentLoadChunkBatch = null as { + timeout + data + } | null emitter.on('loadChunk', ({ x, z, chunk, worldConfig, isLightUpdate }) => { this.world.worldConfig = worldConfig - this.addColumn(x, z, chunk, isLightUpdate) + if (!currentLoadChunkBatch) { + // add a setting to use debounce instead + currentLoadChunkBatch = { + data: [], + timeout: setTimeout(() => { + for (const args of currentLoadChunkBatch!.data) { + //@ts-expect-error + this.addColumn(...args) + } + currentLoadChunkBatch = null + }, this.addChunksBatchWaitTime) + } + } + currentLoadChunkBatch.data.push([x, z, chunk, isLightUpdate]) }) // todo remove and use other architecture instead so data flow is clear emitter.on('blockEntities', (blockEntities) => { diff --git a/prismarine-viewer/viewer/lib/worldDataEmitter.ts b/prismarine-viewer/viewer/lib/worldDataEmitter.ts index 9e36a89a..ea956b81 100644 --- a/prismarine-viewer/viewer/lib/worldDataEmitter.ts +++ b/prismarine-viewer/viewer/lib/worldDataEmitter.ts @@ -19,6 +19,7 @@ export class WorldDataEmitter extends EventEmitter { private readonly lastPos: Vec3 private eventListeners: Record = {} private readonly emitter: WorldDataEmitter + keepChunksDistance = 0 constructor (public world: typeof __type_bot['world'], public viewDistance: number, position: Vec3 = new Vec3(0, 0, 0)) { super() @@ -150,6 +151,9 @@ export class WorldDataEmitter extends EventEmitter { } } + // debugGotChunkLatency = [] as number[] + // lastTime = 0 + async loadChunk (pos: ChunkPos, isLightUpdate = false) { const [botX, botZ] = chunkPos(this.lastPos) const dx = Math.abs(botX - Math.floor(pos.x / 16)) @@ -158,6 +162,9 @@ export class WorldDataEmitter extends EventEmitter { // eslint-disable-next-line @typescript-eslint/await-thenable -- todo allow to use async world provider but not sure if needed const column = await this.world.getColumnAt(pos['y'] ? pos as Vec3 : new Vec3(pos.x, 0, pos.z)) if (column) { + // const latency = Math.floor(performance.now() - this.lastTime) + // this.debugGotChunkLatency.push(latency) + // this.lastTime = performance.now() // todo optimize toJson data, make it clear why it is used const chunk = column.toJson() // TODO: blockEntities @@ -191,14 +198,14 @@ export class WorldDataEmitter extends EventEmitter { const [botX, botZ] = chunkPos(pos) if (lastX !== botX || lastZ !== botZ || force) { this.emitter.emit('chunkPosUpdate', { pos }) - const newView = new ViewRect(botX, botZ, this.viewDistance) + const newViewToUnload = new ViewRect(botX, botZ, this.viewDistance + this.keepChunksDistance) const chunksToUnload: Vec3[] = [] for (const coords of Object.keys(this.loadedChunks)) { const x = parseInt(coords.split(',')[0], 10) const z = parseInt(coords.split(',')[1], 10) const p = new Vec3(x, 0, z) const [chunkX, chunkZ] = chunkPos(p) - if (!newView.contains(chunkX, chunkZ)) { + if (!newViewToUnload.contains(chunkX, chunkZ)) { chunksToUnload.push(p) } } diff --git a/prismarine-viewer/viewer/lib/worldrendererCommon.ts b/prismarine-viewer/viewer/lib/worldrendererCommon.ts index fbc6db46..204ad65f 100644 --- a/prismarine-viewer/viewer/lib/worldrendererCommon.ts +++ b/prismarine-viewer/viewer/lib/worldrendererCommon.ts @@ -75,6 +75,7 @@ export abstract class WorldRendererCommon handleResize = () => { } mesherConfig = defaultMesherConfig camera: THREE.PerspectiveCamera + highestBlocks: Record = {} blockstatesModels: any customBlockStates: Record | undefined customModels: Record | undefined @@ -109,7 +110,15 @@ export abstract class WorldRendererCommon const handleMessage = (data) => { if (!this.active) return this.handleWorkerMessage(data) - if (data.type === 'sectionFinished') { + if (data.type === 'geometry') { + for (const key in data.geometry.highestBlocks) { + const highest = data.geometry.highestBlocks[key] + if (!this.highestBlocks[key] || this.highestBlocks[key].y < highest.y) { + this.highestBlocks[key] = highest + } + } + } + if (data.type === 'sectionFinished') { // on after load & unload section if (!this.sectionsOutstanding.get(data.key)) throw new Error(`sectionFinished event for non-outstanding section ${data.key}`) this.sectionsOutstanding.set(data.key, this.sectionsOutstanding.get(data.key)! - 1) if (this.sectionsOutstanding.get(data.key) === 0) this.sectionsOutstanding.delete(data.key) @@ -302,6 +311,19 @@ export abstract class WorldRendererCommon } this.allChunksFinished = Object.keys(this.finishedChunks).length === this.chunksLength delete this.finishedChunks[`${x},${z}`] + for (let y = this.worldConfig.minY; y < this.worldConfig.worldHeight; y += 16) { + this.setSectionDirty(new Vec3(x, y, z), false) + } + // remove from highestBlocks + const startX = Math.floor(x / 16) * 16 + const startZ = Math.floor(z / 16) * 16 + const endX = Math.ceil((x + 1) / 16) * 16 + const endZ = Math.ceil((z + 1) / 16) * 16 + for (let x = startX; x < endX; x += 16) { + for (let z = startZ; z < endZ; z += 16) { + delete this.highestBlocks[`${x},${z}`] + } + } } setBlockStateId (pos: Vec3, stateId: number) { @@ -320,7 +342,7 @@ export abstract class WorldRendererCommon queueAwaited = false messagesQueue = {} as { [workerIndex: string]: any[] } - setSectionDirty (pos: Vec3, value = true) { + setSectionDirty (pos: Vec3, value = true) { // value false is used for unloading chunks if (this.viewDistance === -1) throw new Error('viewDistance not set') this.allChunksFinished = false const distance = this.getDistance(pos) diff --git a/prismarine-viewer/viewer/lib/worldrendererThree.ts b/prismarine-viewer/viewer/lib/worldrendererThree.ts index 72c7d242..6c14e243 100644 --- a/prismarine-viewer/viewer/lib/worldrendererThree.ts +++ b/prismarine-viewer/viewer/lib/worldrendererThree.ts @@ -66,6 +66,7 @@ export class WorldRendererThree extends WorldRendererCommon { } } + // debugRecomputedDeletedObjects = 0 handleWorkerMessage (data: any): void { if (data.type !== 'geometry') return let object: THREE.Object3D = this.sectionObjects[data.key] @@ -78,6 +79,10 @@ export class WorldRendererThree extends WorldRendererCommon { const chunkCoords = data.key.split(',') if (!this.loadedChunks[chunkCoords[0] + ',' + chunkCoords[2]] || !data.geometry.positions.length || !this.active) return + // if (object) { + // this.debugRecomputedDeletedObjects++ + // } + // if (!this.initialChunksLoad && this.enableChunksLoadDelay) { // const newPromise = new Promise(resolve => { // if (this.droppedFpsPercentage > 0.5) { diff --git a/rsbuild.config.ts b/rsbuild.config.ts index bb5750f7..422af603 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -49,11 +49,12 @@ export default defineConfig({ 'minecraft-protocol$': 'minecraft-protocol/src/index.js', 'buffer$': 'buffer', // avoid bundling, not used on client side - 'prismarine-auth': './src/shims/empty.ts', + 'prismarine-auth': './src/shims/prismarineAuthReplacement.ts', perf_hooks: './src/shims/perf_hooks_replacement.js', crypto: './src/shims/crypto.js', dns: './src/shims/dns.js', yggdrasil: './src/shims/yggdrasilReplacement.ts', + 'three$': 'three/src/Three.js' }, entry: { index: './src/index.ts', @@ -182,9 +183,10 @@ export default defineConfig({ ] } }, - performance: { - // bundleAnalyze: { - // analyzerMode: 'json', - // }, - }, + // performance: { + // bundleAnalyze: { + // analyzerMode: 'json', + // reportFilename: 'report.json', + // }, + // }, }) diff --git a/server.js b/server.js index 7adae4bb..dd1ccaa1 100644 --- a/server.js +++ b/server.js @@ -17,6 +17,7 @@ const app = express() const isProd = process.argv.includes('--prod') app.use(compression()) +// app.use(cors()) app.use(netApi({ allowOrigin: '*' })) if (!isProd) { app.use('/sounds', express.static(path.join(__dirname, './generated/sounds/'))) diff --git a/src/browserfs.ts b/src/browserfs.ts index 16691986..82d24058 100644 --- a/src/browserfs.ts +++ b/src/browserfs.ts @@ -233,6 +233,7 @@ export const mountGoogleDriveFolder = async (readonly: boolean, rootId: string) fsState.isReadonly = readonly fsState.syncFs = false fsState.inMemorySave = false + fsState.remoteBackend = true return true } @@ -313,6 +314,7 @@ export const openWorldDirectory = async (dragndropHandle?: FileSystemDirectoryHa fsState.isReadonly = !writeAccess fsState.syncFs = false fsState.inMemorySave = false + fsState.remoteBackend = false await loadSave() } @@ -352,7 +354,33 @@ export const possiblyCleanHandle = (callback = () => { }) => { } } -export const copyFilesAsyncWithProgress = async (pathSrc: string, pathDest: string, throwRootNotExist = true) => { +const readdirSafe = async (path: string) => { + try { + return await fs.promises.readdir(path) + } catch (err) { + return null + } +} + +export const collectFilesToCopy = async (basePath: string, safe = false): Promise => { + const result: string[] = [] + const countFiles = async (relPath: string) => { + const resolvedPath = join(basePath, relPath) + const files = relPath === '.' && !safe ? await fs.promises.readdir(resolvedPath) : await readdirSafe(resolvedPath) + if (!files) return null + await Promise.all(files.map(async file => { + const res = await countFiles(join(relPath, file)) + if (res === null) { + // is file + result.push(join(relPath, file)) + } + })) + } + await countFiles('.') + return result +} + +export const copyFilesAsyncWithProgress = async (pathSrc: string, pathDest: string, throwRootNotExist = true, addMsg = '') => { const stat = await existsViaStats(pathSrc) if (!stat) { if (throwRootNotExist) throw new Error(`Cannot copy. Source directory ${pathSrc} does not exist`) @@ -387,7 +415,7 @@ export const copyFilesAsyncWithProgress = async (pathSrc: string, pathDest: stri let copied = 0 await copyFilesAsync(pathSrc, pathDest, (name) => { copied++ - setLoadingScreenStatus(`Copying files (${copied}/${filesCount}): ${name}`) + setLoadingScreenStatus(`Copying files${addMsg} (${copied}/${filesCount}): ${name}`) }) } finally { setLoadingScreenStatus(undefined) @@ -402,6 +430,19 @@ export const existsViaStats = async (path: string) => { } } +export const fileExistsAsyncOptimized = async (path: string) => { + try { + await fs.promises.readdir(path) + } catch (err) { + if (err.code === 'ENOTDIR') return true + // eslint-disable-next-line sonarjs/prefer-single-boolean-return + if (err.code === 'ENOENT') return false + // throw err + return false + } + return true +} + export const copyFilesAsync = async (pathSrc: string, pathDest: string, fileCopied?: (name) => void) => { // query: can't use fs.copy! use fs.promises.writeFile and readFile const files = await fs.promises.readdir(pathSrc) @@ -433,6 +474,45 @@ export const copyFilesAsync = async (pathSrc: string, pathDest: string, fileCopi })) } +export const openWorldFromHttpDir = async (fileDescriptorUrl: string/* | undefined */, baseUrl = fileDescriptorUrl.split('/').slice(0, -1).join('/')) => { + // todo try go guess mode + let index + const file = await fetch(fileDescriptorUrl).then(async a => a.json()) + if (file.baseUrl) { + baseUrl = new URL(file.baseUrl, baseUrl).toString() + index = file.index + } else { + index = file + } + if (!index) throw new Error(`The provided mapDir file is not valid descriptor file! ${fileDescriptorUrl}`) + await new Promise(async resolve => { + browserfs.configure({ + fs: 'MountableFileSystem', + options: { + ...defaultMountablePoints, + '/world': { + fs: 'HTTPRequest', + options: { + index, + baseUrl + } + } + }, + }, (e) => { + if (e) throw e + resolve() + }) + }) + + fsState.saveLoaded = false + fsState.isReadonly = true + fsState.syncFs = false + fsState.inMemorySave = false + fsState.remoteBackend = true + + await loadSave() +} + // todo rename method const openWorldZipInner = async (file: File | ArrayBuffer, name = file['name']) => { await new Promise(async resolve => { @@ -459,6 +539,7 @@ const openWorldZipInner = async (file: File | ArrayBuffer, name = file['name']) fsState.isReadonly = true fsState.syncFs = true fsState.inMemorySave = false + fsState.remoteBackend = false if (fs.existsSync('/world/level.dat')) { await loadSave() diff --git a/src/downloadAndOpenFile.ts b/src/downloadAndOpenFile.ts index 7ae8547f..49fc5c35 100644 --- a/src/downloadAndOpenFile.ts +++ b/src/downloadAndOpenFile.ts @@ -1,5 +1,5 @@ import prettyBytes from 'pretty-bytes' -import { openWorldZip } from './browserfs' +import { openWorldFromHttpDir, openWorldZip } from './browserfs' import { getResourcePackNames, installTexturePack, resourcePackState, updateTexturePackInstalledState } from './resourcePack' import { setLoadingScreenStatus } from './utils' @@ -9,6 +9,17 @@ export const getFixedFilesize = (bytes: number) => { const inner = async () => { const qs = new URLSearchParams(window.location.search) + const mapUrlDir = qs.get('mapDir') + const mapUrlDirGuess = qs.get('mapDirGuess') + const mapUrlDirBaseUrl = qs.get('mapDirBaseUrl') + if (mapUrlDir) { + await openWorldFromHttpDir(mapUrlDir, mapUrlDirBaseUrl ?? undefined) + return true + } + if (mapUrlDirGuess) { + // await openWorldFromHttpDir(undefined, mapUrlDirGuess) + return true + } let mapUrl = qs.get('map') const texturepack = qs.get('texturepack') // fixme diff --git a/src/globalState.ts b/src/globalState.ts index 3e6c06e8..b0a447f2 100644 --- a/src/globalState.ts +++ b/src/globalState.ts @@ -35,24 +35,11 @@ subscribe(activeModalStack, () => { } }) -export const customDisplayManageKeyword = 'custom' - -const defaultModalActions = { - show (modal: Modal) { - if (modal.elem) modal.elem.style.display = 'block' - }, - hide (modal: Modal) { - if (modal.elem) modal.elem.style.display = 'none' - } -} - /** * @returns true if operation was successful */ const showModalInner = (modal: Modal) => { const cancel = modal.elem?.show?.() - if (cancel && cancel !== customDisplayManageKeyword) return false - if (cancel !== 'custom') defaultModalActions.show(modal) return true } @@ -60,7 +47,6 @@ export const showModal = (elem: /* (HTMLElement & Record) | */{ re const resolved = elem const curModal = activeModalStack.at(-1) if (/* elem === curModal?.elem || */(elem.reactType && elem.reactType === curModal?.reactType) || !showModalInner(resolved)) return - if (curModal) defaultModalActions.hide(curModal) activeModalStack.push(resolved) } @@ -71,21 +57,21 @@ export const showModal = (elem: /* (HTMLElement & Record) | */{ re export const hideModal = (modal = activeModalStack.at(-1), data: any = undefined, options: { force?: boolean; restorePrevious?: boolean } = {}) => { const { force = false, restorePrevious = true } = options if (!modal) return - let cancel - if (modal.elem) { - cancel = modal.elem.hide?.(data) - } else if (modal.reactType) { - cancel = notHideableModalsWithoutForce.has(modal.reactType) ? !force : undefined - } - if (force && cancel !== customDisplayManageKeyword) { + let cancel = notHideableModalsWithoutForce.has(modal.reactType) ? !force : undefined + if (force) { cancel = undefined } - if (!cancel || cancel === customDisplayManageKeyword) { - if (cancel !== customDisplayManageKeyword) defaultModalActions.hide(modal) - activeModalStack.pop() + if (!cancel) { + const lastModal = activeModalStack.at(-1) + for (let i = activeModalStack.length - 1; i >= 0; i--) { + if (activeModalStack[i].reactType === modal.reactType) { + activeModalStack.splice(i, 1) + break + } + } const newModal = activeModalStack.at(-1) - if (newModal && restorePrevious) { + if (newModal && lastModal !== newModal && restorePrevious) { // would be great to ignore cancel I guess? showModalInner(newModal) } diff --git a/src/index.ts b/src/index.ts index 39626d54..c9040e35 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,7 +24,7 @@ import './reactUi' import { contro, onBotCreate } from './controls' import './dragndrop' import { possiblyCleanHandle, resetStateAfterDisconnect } from './browserfs' -import { watchOptionsAfterViewerInit } from './watchOptions' +import { watchOptionsAfterViewerInit, watchOptionsAfterWorldViewInit } from './watchOptions' import downloadAndOpenFile from './downloadAndOpenFile' import fs from 'fs' @@ -98,7 +98,6 @@ import { signInMessageState } from './react/SignInMessageProvider' import { updateAuthenticatedAccountData, updateLoadedServerData } from './react/ServersListProvider' import { versionToNumber } from 'prismarine-viewer/viewer/prepare/utils' import packetsPatcher from './packetsPatcher' -import blockstatesModels from 'mc-assets/dist/blockStatesModels.json' import { mainMenuState } from './react/MainMenuRenderApp' import { ItemsRenderer } from 'mc-assets/dist/itemsRenderer' import './mobileShim' @@ -410,7 +409,7 @@ async function connect (connectOptions: ConnectOptions) { throw err } } - viewer.world.blockstatesModels = blockstatesModels + viewer.world.blockstatesModels = await import('mc-assets/dist/blockStatesModels.json') viewer.setVersion(version, options.useVersionsTextures === 'latest' ? version : options.useVersionsTextures) } @@ -473,6 +472,7 @@ async function connect (connectOptions: ConnectOptions) { setCacheResult (result) { newTokensCacheResult = result }, + connectingServer: server.host }) : undefined bot = mineflayer.createBot({ @@ -495,7 +495,7 @@ async function connect (connectOptions: ConnectOptions) { signInMessageState.link = data.verification_uri signInMessageState.expiresOn = Date.now() + data.expires_in * 1000 }, - sessionServer: authData?.sessionEndpoint, + sessionServer: authData?.sessionEndpoint?.toString(), auth: connectOptions.authenticatedAccount ? async (client, options) => { authData!.setOnMsaCodeCallback(options.onMsaCode) //@ts-expect-error @@ -690,6 +690,7 @@ async function connect (connectOptions: ConnectOptions) { const center = bot.entity.position const worldView = window.worldView = new WorldDataEmitter(bot.world, renderDistance, center) + watchOptionsAfterWorldViewInit() bot.on('physicsTick', () => updateCursor()) diff --git a/src/inventoryWindows.ts b/src/inventoryWindows.ts index 6ef8ebf7..247fad67 100644 --- a/src/inventoryWindows.ts +++ b/src/inventoryWindows.ts @@ -166,7 +166,7 @@ const renderSlot = (slot: RenderSlot, skipBlock = false): { itemTexture = itemsRenderer.getItemTexture(itemName) ?? itemsRenderer.getItemTexture('item/missing_texture')! } catch (err) { itemTexture = itemsRenderer.getItemTexture('block/errored')! - inGameError(err) + inGameError(`Failed to render item ${itemName} on ${bot.version} (resourcepack: ${options.enabledResourcepack}): ${err.message}`) } if ('type' in itemTexture) { // is item diff --git a/src/loadSave.ts b/src/loadSave.ts index 48054094..6c7da6bb 100644 --- a/src/loadSave.ts +++ b/src/loadSave.ts @@ -21,6 +21,7 @@ export const fsState = proxy({ saveLoaded: false, openReadOperations: 0, openWriteOperations: 0, + remoteBackend: false }) const PROPOSE_BACKUP = true diff --git a/src/microsoftAuthflow.ts b/src/microsoftAuthflow.ts index 99308f68..18b3eb0b 100644 --- a/src/microsoftAuthflow.ts +++ b/src/microsoftAuthflow.ts @@ -10,12 +10,12 @@ export const getProxyDetails = async (proxyBaseUrl: string) => { return result } -export default async ({ tokenCaches, proxyBaseUrl, setProgressText = (text) => { }, setCacheResult }) => { +export default async ({ tokenCaches, proxyBaseUrl, setProgressText = (text) => { }, setCacheResult, connectingServer }) => { let onMsaCodeCallback // const authEndpoint = 'http://localhost:3000/' // const sessionEndpoint = 'http://localhost:3000/session' - let authEndpoint = '' - let sessionEndpoint = '' + let authEndpoint: URL | undefined + let sessionEndpoint: URL | undefined const result = await getProxyDetails(proxyBaseUrl) try { @@ -29,7 +29,6 @@ export default async ({ tokenCaches, proxyBaseUrl, setProgressText = (text) => { } const authFlow = { async getMinecraftJavaToken () { - setProgressText('Authenticating with Microsoft account') let result = null await fetch(authEndpoint, { @@ -37,10 +36,14 @@ export default async ({ tokenCaches, proxyBaseUrl, setProgressText = (text) => { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify(tokenCaches), + body: JSON.stringify({ + ...tokenCaches, + // important to set this param and not fake it as auth server might reject the request otherwise + connectingServer + }), }).then(async response => { if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}: ${await response.text()}`) + throw new Error(`Auth server error (${response.status}): ${await response.text()}`) } const reader = response.body!.getReader() @@ -158,8 +161,9 @@ function pemToArrayBuffer (pem) { } const urlWithBase = (url: string, base: string) => { + if (!base.startsWith('http')) base = `https://${base}` const urlObj = new URL(url, base) base = base.replace(/^https?:\/\//, '') urlObj.host = base.includes(':') ? base : `${base}:${isPageSecure() ? '443' : '80'}` - return urlObj.toString() + return urlObj } diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index bcf3054f..644ec66c 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -83,6 +83,11 @@ export const guiOptionsScheme: { }, starfieldRendering: {}, renderEntities: {}, + keepChunksDistance: { + max: 5, + unit: '', + tooltip: 'Additional distance to keep the chunks loading before unloading them by marking them as too far', + }, }, ], main: [ diff --git a/src/optionsStorage.ts b/src/optionsStorage.ts index 990dd31e..cac8c9f8 100644 --- a/src/optionsStorage.ts +++ b/src/optionsStorage.ts @@ -7,6 +7,7 @@ import { omitObj } from '@zardoy/utils' const defaultOptions = { renderDistance: 3, + keepChunksDistance: 1, multiplayerRenderDistance: 3, closeConfirmation: true, autoFullScreen: false, diff --git a/src/react/AddServerOrConnect.tsx b/src/react/AddServerOrConnect.tsx index a933a1d7..06f4fcd7 100644 --- a/src/react/AddServerOrConnect.tsx +++ b/src/react/AddServerOrConnect.tsx @@ -101,14 +101,13 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ }}> { return { value: v, label: v } }) ?? []} onChange={(value) => { setVersionOverride(value) }} - // inputProps={{ - // placeholder: 'Optional, but recommended to specify', - // disabled: lockConnect && qsParamVersion !== null - // }} + placeholder="Optional, but recommended to specify" + disabled={lockConnect && qsParamVersion !== null} /> diff --git a/src/react/CreateWorld.tsx b/src/react/CreateWorld.tsx index 698d5d09..0ff34a39 100644 --- a/src/react/CreateWorld.tsx +++ b/src/react/CreateWorld.tsx @@ -81,7 +81,7 @@ export default ({ cancelClick, createClick, customizeClick, versions, defaultVer -
Note: store important saves in folders on the drive!
+
Note: save important worlds in folders on your hard drive!
{quota}
} diff --git a/src/react/DiscordButton.tsx b/src/react/DiscordButton.tsx index 0e3a22b5..99208c45 100644 --- a/src/react/DiscordButton.tsx +++ b/src/react/DiscordButton.tsx @@ -8,7 +8,7 @@ export const DiscordButton = () => { const links: DropdownButtonItem[] = [ { text: 'Support Official Server (mcraft.fun)', - clickHandler: () => openURL('https://discord.gg/JCPnD4Qh') + clickHandler: () => openURL('https://discord.gg/xzGRhxtRUt') }, { text: 'Community Server (PrismarineJS)', diff --git a/src/react/KeybindingsScreen.module.css b/src/react/KeybindingsScreen.module.css index e8a9f69b..102974ce 100644 --- a/src/react/KeybindingsScreen.module.css +++ b/src/react/KeybindingsScreen.module.css @@ -49,6 +49,7 @@ .undo-keyboard, .undo-gamepad { aspect-ratio: 1; + min-width: 20px; } .button { diff --git a/src/react/MainMenu.tsx b/src/react/MainMenu.tsx index 231e29fb..45bc4296 100644 --- a/src/react/MainMenu.tsx +++ b/src/react/MainMenu.tsx @@ -40,7 +40,7 @@ export default ({ }: Props) => { if (!bottomRightLinks?.trim()) bottomRightLinks = undefined // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - const linksParsed = bottomRightLinks?.split(';').map(l => { + const linksParsed = bottomRightLinks?.split(/;|\n/g).map(l => { const parts = l.split(':') return [parts[0], parts.slice(1).join(':')] }) as Array<[string, string]> | undefined diff --git a/src/react/PauseScreen.tsx b/src/react/PauseScreen.tsx index 560412de..9f25810d 100644 --- a/src/react/PauseScreen.tsx +++ b/src/react/PauseScreen.tsx @@ -1,4 +1,5 @@ import { join } from 'path' +import fs from 'fs' import { useEffect } from 'react' import { useSnapshot } from 'valtio' import { usedServerPathsV1 } from 'flying-squid/dist/lib/modules/world' @@ -14,13 +15,14 @@ import { fsState } from '../loadSave' import { disconnect } from '../flyingSquidUtils' import { pointerLock, setLoadingScreenStatus } from '../utils' import { closeWan, openToWanAndCopyJoinLink, getJoinLink } from '../localServerMultiplayer' -import { copyFilesAsyncWithProgress, mkdirRecursive, uniqueFileNameFromWorldName } from '../browserfs' +import { collectFilesToCopy, fileExistsAsyncOptimized, mkdirRecursive, uniqueFileNameFromWorldName } from '../browserfs' 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' export const saveToBrowserMemory = async () => { setLoadingScreenStatus('Saving world') @@ -29,12 +31,46 @@ export const saveToBrowserMemory = async () => { const { worldFolder } = localServer.options const saveRootPath = await uniqueFileNameFromWorldName(worldFolder.split('/').pop(), `/data/worlds`) await mkdirRecursive(saveRootPath) - for (const copyPath of [...usedServerPathsV1, 'icon.png']) { - const srcPath = join(worldFolder, copyPath) - const savePath = join(saveRootPath, copyPath) + const allRootPaths = [...usedServerPathsV1] + const allFilesToCopy = [] as string[] + for (const dirBase of allRootPaths) { // eslint-disable-next-line no-await-in-loop - await copyFilesAsyncWithProgress(srcPath, savePath, false) + 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))) } + const pathsSplit = allFilesToCopy.reduce((acc, cur, i) => { + if (i % 15 === 0) { + acc.push([]) + } + acc.at(-1)!.push(cur) + return acc + // eslint-disable-next-line @typescript-eslint/prefer-reduce-type-parameter + }, [] as string[][]) + let copied = 0 + const upProgress = () => { + copied++ + const action = fsState.remoteBackend ? 'Downloading & copying' : 'Copying' + setLoadingScreenStatus(`${action} files (${copied}/${allFilesToCopy.length})`) + } + for (const copyPaths of pathsSplit) { + // eslint-disable-next-line no-await-in-loop + await Promise.all(copyPaths.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)) + upProgress() + })) + } + return saveRootPath } catch (err) { void showOptionsModal(`Error while saving the world: ${err.message}`, []) @@ -100,7 +136,14 @@ export default () => { } const action = await showOptionsModal('World actions...', ['Save to browser memory']) if (action === 'Save to browser memory') { - await saveToBrowserMemory() + 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 } } diff --git a/src/react/Select.stories.tsx b/src/react/Select.stories.tsx index 19ada27a..4461b83d 100644 --- a/src/react/Select.stories.tsx +++ b/src/react/Select.stories.tsx @@ -14,11 +14,9 @@ export const Primary: Story = { args: { initialOptions: [{ value: '1', label: 'option 1' }, { value: '2', label: 'option 2' }, { value: '3', label: 'option 3' },], updateOptions (options) {}, - processInput (input) { + getCssOnInput (input) { console.log('input:', input) if (input === 'option 3') return { border: '1px solid yellow' } as CSSProperties }, - iconInput: 'user', - iconOption: 'user' }, } diff --git a/src/react/Select.tsx b/src/react/Select.tsx index f4285330..8d79fe54 100644 --- a/src/react/Select.tsx +++ b/src/react/Select.tsx @@ -13,25 +13,23 @@ export interface OptionStorage { interface Props { initialOptions: OptionStorage[] updateOptions: (options: string) => void - processInput?: (input: string) => CSSProperties | undefined - processOption?: (option: string) => string + getCssOnInput?: (input: string) => CSSProperties | undefined onValueChange?: (newVal: string) => void defaultValue?: { value: string, label: string } - iconInput?: string placeholder?: string - iconOption?: string containerStyle?: CSSProperties - inputProps?: React.ComponentProps + disabled?: boolean } export default ({ initialOptions, updateOptions, - processInput, + getCssOnInput, onValueChange, defaultValue, containerStyle, - placeholder + placeholder, + disabled }: Props) => { const [inputValue, setInputValue] = useState(defaultValue?.label ?? '') const [currValue, setCurrValue] = useState(defaultValue?.label ?? '') @@ -40,7 +38,6 @@ export default ({ return { return 'Use "' + value + '"' }} + isDisabled={disabled} placeholder={placeholder ?? ''} onChange={(e, action) => { console.log('value:', e?.value) @@ -56,7 +54,7 @@ export default ({ setInputValue(e?.label) onValueChange?.(e?.value ?? '') updateOptions?.(e?.value ?? '') - setInputStyle(processInput?.(e?.value ?? '') ?? {}) + setInputStyle(getCssOnInput?.(e?.value ?? '') ?? {}) }} onInputChange={(e) => { setIsFirstClick(false) @@ -72,6 +70,7 @@ export default ({ onMenuOpen={() => { setIsFirstClick(true) }} + menuPortalTarget={document.body} classNames={{ control (state) { return styles.container @@ -84,7 +83,8 @@ export default ({ } }} styles={{ - container (base, state) { return { ...base, position: 'relative' } }, + menuPortal (base, state) { return { ...base, zIndex: 10, transform: 'scale(var(--guiScale))', transformOrigin: 'top left' } }, + container (base, state) { return { ...base, position: 'relative', zIndex: 10 } }, control (base, state) { return { ...containerStyle, ...inputStyle } }, menu (base, state) { return { position: 'absolute', zIndex: 10 } }, option (base, state) { @@ -106,4 +106,3 @@ export default ({ }} /> } - diff --git a/src/react/SelectGameVersion.tsx b/src/react/SelectGameVersion.tsx index 4944faa5..33addbd2 100644 --- a/src/react/SelectGameVersion.tsx +++ b/src/react/SelectGameVersion.tsx @@ -1,19 +1,16 @@ import React, { CSSProperties } from 'react' import Select from './Select' -import Input from './Input' type Version = { value: string, label: string } export default ( { versions, selected, onChange, updateOptions, containerStyle }: - { - versions: Version[], - selected?: Version, - inputProps?: React.ComponentProps, - onChange?: (newValue: string) => void, - updateOptions?: (newSel: string) => void, - containerStyle?: CSSProperties - } + { + versions: Version[], + selected?: Version, + onChange?: (newValue: string) => void, + updateOptions?: (newSel: string) => void, + } & Pick, 'containerStyle' | 'placeholder' | 'disabled'> ) => { return