From 7a83a2a65702e1ec76f60d673f8753688fe05c06 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Mon, 14 Jul 2025 00:13:51 +0300 Subject: [PATCH 01/14] fix(important): fix all known issues wiht panorama crashing whole game in single file build (minecraft.html) --- renderer/viewer/baseGraphicsBackend.ts | 5 ++-- renderer/viewer/lib/worldDataEmitter.ts | 2 +- renderer/viewer/three/panorama.ts | 3 +- renderer/viewer/three/panoramaShared.ts | 1 + src/appViewer.ts | 37 +++++++++++++++++-------- src/shims/minecraftData.ts | 13 +++++++-- 6 files changed, 43 insertions(+), 18 deletions(-) create mode 100644 renderer/viewer/three/panoramaShared.ts diff --git a/renderer/viewer/baseGraphicsBackend.ts b/renderer/viewer/baseGraphicsBackend.ts index 3cd227de8..486c930f6 100644 --- a/renderer/viewer/baseGraphicsBackend.ts +++ b/renderer/viewer/baseGraphicsBackend.ts @@ -1,3 +1,4 @@ +import { proxy } from 'valtio' import { NonReactiveState, RendererReactiveState } from '../../src/appViewer' export const getDefaultRendererState = (): { @@ -5,7 +6,7 @@ export const getDefaultRendererState = (): { nonReactive: NonReactiveState } => { return { - reactive: { + reactive: proxy({ world: { chunksLoaded: new Set(), heightmaps: new Map(), @@ -15,7 +16,7 @@ export const getDefaultRendererState = (): { }, renderer: '', preventEscapeMenu: false - }, + }), nonReactive: { world: { chunksLoaded: new Set(), diff --git a/renderer/viewer/lib/worldDataEmitter.ts b/renderer/viewer/lib/worldDataEmitter.ts index e1ac2f248..e9153af74 100644 --- a/renderer/viewer/lib/worldDataEmitter.ts +++ b/renderer/viewer/lib/worldDataEmitter.ts @@ -210,7 +210,7 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter private resolveWorldReady: () => void @@ -162,11 +164,15 @@ export class AppViewer { // Execute queued action if exists if (this.currentState) { - const { method, args } = this.currentState - this.backend[method](...args) - if (method === 'startWorld') { - void this.worldView!.init(bot.entity.position) - // void this.worldView!.init(args[0].playerState.getPosition()) + if (this.currentState.method === 'startPanorama') { + this.startPanorama() + } else { + const { method, args } = this.currentState + this.backend[method](...args) + if (method === 'startWorld') { + void this.worldView!.init(bot.entity.position) + // void this.worldView!.init(args[0].playerState.getPosition()) + } } } @@ -225,10 +231,16 @@ export class AppViewer { startPanorama () { if (this.currentDisplay === 'menu') return - this.currentDisplay = 'menu' if (options.disableAssets) return - if (this.backend) { - this.backend.startPanorama() + if (this.backend && !hasAppStatus()) { + this.currentDisplay = 'menu' + if (process.env.SINGLE_FILE_BUILD_MODE) { + void loadMinecraftData(PANORAMA_VERSION).then(() => { + this.backend?.startPanorama() + }) + } else { + this.backend.startPanorama() + } } this.currentState = { method: 'startPanorama', args: [] } } @@ -316,15 +328,16 @@ const initialMenuStart = async () => { } window.initialMenuStart = initialMenuStart +const hasAppStatus = () => activeModalStack.some(m => m.reactType === 'app-status') + const modalStackUpdateChecks = () => { // maybe start panorama - if (!miscUiState.gameLoaded) { + if (!miscUiState.gameLoaded && !hasAppStatus()) { void initialMenuStart() } if (appViewer.backend) { - const hasAppStatus = activeModalStack.some(m => m.reactType === 'app-status') - appViewer.backend.setRendering(!hasAppStatus) + appViewer.backend.setRendering(!hasAppStatus()) } appViewer.inWorldRenderingConfig.foreground = activeModalStack.length === 0 diff --git a/src/shims/minecraftData.ts b/src/shims/minecraftData.ts index 989aa698f..33dff5fef 100644 --- a/src/shims/minecraftData.ts +++ b/src/shims/minecraftData.ts @@ -19,11 +19,20 @@ const customResolver = () => { } } +let dataStatus = 'not-called' + const optimizedDataResolver = customResolver() window._MC_DATA_RESOLVER = optimizedDataResolver window._LOAD_MC_DATA = async () => { if (optimizedDataResolver.resolvedData) return - optimizedDataResolver.resolve(await importLargeData('mcData')) + dataStatus = 'loading' + try { + optimizedDataResolver.resolve(await importLargeData('mcData')) + dataStatus = 'ready' + } catch (e) { + dataStatus = 'error' + throw e + } } // 30 seconds @@ -39,7 +48,7 @@ const possiblyGetFromCache = (version: string) => { } const inner = () => { if (!optimizedDataResolver.resolvedData) { - throw new Error(`Data for ${version} is not ready yet`) + throw new Error(`Minecraft data are not ready yet. Ensure you await window._LOAD_MC_DATA() before using it. Status: ${dataStatus}`) } const dataTypes = Object.keys(optimizedDataResolver.resolvedData) const allRestored = {} From b2f2d85e4f146fadd24a1697db293380389439b6 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Mon, 14 Jul 2025 00:18:42 +0300 Subject: [PATCH 02/14] feat(setting): add a way to specify default perspective view --- src/defaultOptions.ts | 1 + src/mineflayer/playerState.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/defaultOptions.ts b/src/defaultOptions.ts index d2d510ec5..9650ffcd6 100644 --- a/src/defaultOptions.ts +++ b/src/defaultOptions.ts @@ -19,6 +19,7 @@ export const defaultOptions = { enableMusic: false, // fov: 70, fov: 75, + defaultPerspective: 'first_person' as 'first_person' | 'third_person_back' | 'third_person_front', guiScale: 3, autoRequestCompletions: true, touchButtonsSize: 40, diff --git a/src/mineflayer/playerState.ts b/src/mineflayer/playerState.ts index b8919a145..33f7af772 100644 --- a/src/mineflayer/playerState.ts +++ b/src/mineflayer/playerState.ts @@ -3,6 +3,7 @@ import { getInitialPlayerState, getPlayerStateUtils, PlayerStateReactive, Player import { subscribe } from 'valtio' import { subscribeKey } from 'valtio/utils' import { gameAdditionalState } from '../globalState' +import { options } from '../optionsStorage' /** * can be used only in main thread. Mainly for more convenient reactive state updates. @@ -42,6 +43,7 @@ export class PlayerStateControllerMain { private botCreated () { console.log('bot created & plugins injected') this.reactive = getInitialPlayerState() + this.reactive.perspective = options.defaultPerspective this.utils = getPlayerStateUtils(this.reactive) this.onBotCreatedOrGameJoined() From 52c0c75ccfc78da777a65da7f8c1ae22bc6b9f41 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Wed, 16 Jul 2025 12:09:34 +0300 Subject: [PATCH 03/14] docs: update readme --- README.MD | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.MD b/README.MD index 61a5b7333..e9127a736 100644 --- a/README.MD +++ b/README.MD @@ -14,7 +14,6 @@ For building the project yourself / contributing, see [Development, Debugging & > **Note**: You can deploy it on your own server in less than a minute using a one-liner script from [Minecraft Everywhere repo](https://github.com/zardoy/minecraft-everywhere) - ### Big Features - Official Mineflayer [plugin integration](https://github.com/zardoy/mcraft-fun-mineflayer-plugin)! View / Control your bot remotely. @@ -57,6 +56,7 @@ Howerver, it's known that these browsers have issues: Server versions 1.8 - 1.21.4 are supported. First class versions (most of the features are tested on these versions): + - 1.19.4 - 1.21.4 @@ -125,11 +125,11 @@ There is world renderer playground ([link](https://mcon.vercel.app/playground/)) However, there are many things that can be done in online production version (like debugging actual source code). Also you can access some global variables in the console and there are a few useful examples: -- `localStorage.debug = '*'` - Enables all debug messages! Warning: this will start all packets spam. +- If you type `debugToggle`, press enter in console - It will enables all debug messages! Warning: this will start all packets spam. Instead I recommend setting `options.debugLogNotFrequentPackets`. Also you can use `debugTopPackets` (with JSON.stringify) to see what packets were received/sent by name - `bot` - Mineflayer bot instance. See Mineflayer documentation for more. -- `viewer` - Three.js viewer instance, basically does all the rendering. +- `world` - Three.js world instance, basically does all the rendering (part of renderer backend). - `world.sectionObjects` - Object with all active chunk sections (geometries) in the world. Each chunk section is a Three.js mesh or group. - `debugSceneChunks` - The same as above, but relative to current bot position (e.g. 0,0 is the current chunk). - `debugChangedOptions` - See what options are changed. Don't change options here. @@ -139,7 +139,7 @@ Instead I recommend setting `options.debugLogNotFrequentPackets`. Also you can u - `nbt.simplify(someNbt)` - Simplifies nbt data, so it's easier to read. -The most useful thing in devtools is the watch expression. You can add any expression there and it will be re-evaluated in real time. For example, you can add `camera.position` to see the camera position and so on. +The most useful thing in devtools is the watch expression. You can add any expression there and it will be re-evaluated in real time. For example, you can add `world.getCameraPosition()` to see the camera position and so on. Watch expression From e9c7840dae6aad023dd3a9c0363f459503c82c30 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 16 Jul 2025 16:18:15 +0300 Subject: [PATCH 04/14] feat(mobile): fix annoying issues with box and foods usage on screen hold --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- src/react/GameInteractionOverlay.tsx | 19 +++++++++++++++---- src/react/TouchAreasControls.tsx | 11 +++++++---- 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index fe4adb162..257fff7ce 100644 --- a/package.json +++ b/package.json @@ -156,7 +156,7 @@ "mc-assets": "^0.2.62", "minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next", "mineflayer": "github:zardoy/mineflayer#gen-the-master", - "mineflayer-mouse": "^0.1.11", + "mineflayer-mouse": "^0.1.14", "mineflayer-pathfinder": "^2.4.4", "npm-run-all": "^4.1.5", "os-browserify": "^0.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c37e0510b..1e6fddf3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -343,8 +343,8 @@ importers: specifier: github:zardoy/mineflayer#gen-the-master version: https://codeload.github.com/zardoy/mineflayer/tar.gz/3daf1f4bdc6afad0dedd87b879875f3dbb7b0980(encoding@0.1.13) mineflayer-mouse: - specifier: ^0.1.11 - version: 0.1.11 + specifier: ^0.1.14 + version: 0.1.14 mineflayer-pathfinder: specifier: ^2.4.4 version: 2.4.5 @@ -6667,8 +6667,8 @@ packages: resolution: {tarball: https://codeload.github.com/zardoy/mineflayer-item-map-downloader/tar.gz/a8d210ecdcf78dd082fa149a96e1612cc9747824} version: 1.2.0 - mineflayer-mouse@0.1.11: - resolution: {integrity: sha512-BL47pXZ1+92BA/7ym6KaJctEHKnL0up+tpuagVwSKJvAgibeqWQJJwDlNUWkOLvpnruRKDxMR5OB1hUXFoDNSg==} + mineflayer-mouse@0.1.14: + resolution: {integrity: sha512-DjytRMlRLxR44GqZ6udMgbMO4At7Ura5TQC80exRhzkfptyCGLTWzXaf0oeXSNYkNMnaaEv4XP/9YRwuvL+rsQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} mineflayer-pathfinder@2.4.5: @@ -17337,7 +17337,7 @@ snapshots: - encoding - supports-color - mineflayer-mouse@0.1.11: + mineflayer-mouse@0.1.14: dependencies: change-case: 5.4.4 debug: 4.4.1 diff --git a/src/react/GameInteractionOverlay.tsx b/src/react/GameInteractionOverlay.tsx index 63cee5863..268162ec6 100644 --- a/src/react/GameInteractionOverlay.tsx +++ b/src/react/GameInteractionOverlay.tsx @@ -2,6 +2,7 @@ import { useRef, useEffect } from 'react' import { subscribe, useSnapshot } from 'valtio' import { useUtilsEffect } from '@zardoy/react-util' import { getThreeJsRendererMethods } from 'renderer/viewer/three/threeJsMethods' +import { isItemActivatableMobile } from 'mineflayer-mouse/dist/activatableItemsMobile' import { options } from '../optionsStorage' import { activeModalStack, isGameActive, miscUiState } from '../globalState' import { onCameraMove, CameraMoveEvent } from '../cameraRotationControls' @@ -77,7 +78,10 @@ function GameInteractionOverlayInner ({ if (options.touchInteractionType === 'classic') { virtualClickTimeout ??= setTimeout(() => { virtualClickActive = true - document.dispatchEvent(new MouseEvent('mousedown', { button: 0 })) + // If held item is activatable, use right click instead of left + const heldItemName = bot?.heldItem?.name + const isOnlyActivatable = heldItemName && isItemActivatableMobile(heldItemName, loadedData) + document.dispatchEvent(new MouseEvent('mousedown', { button: isOnlyActivatable ? 2 : 0 })) }, touchStartBreakingBlockMs) } } @@ -150,16 +154,23 @@ function GameInteractionOverlayInner ({ if (virtualClickActive) { // button 0 is left click - document.dispatchEvent(new MouseEvent('mouseup', { button: 0 })) + // If held item is activatable, use right click instead of left + const heldItemName = bot?.heldItem?.name + const isOnlyActivatable = heldItemName && isItemActivatableMobile(heldItemName, loadedData) + document.dispatchEvent(new MouseEvent('mouseup', { button: isOnlyActivatable ? 2 : 0 })) virtualClickActive = false } else if (!capturedPointer.active.activateCameraMove && (Date.now() - capturedPointer.active.time < touchStartBreakingBlockMs)) { // single click action const MOUSE_BUTTON_RIGHT = 2 const MOUSE_BUTTON_LEFT = 0 + const heldItemName = bot?.heldItem?.name + const isOnlyActivatable = heldItemName && isItemActivatableMobile(heldItemName, loadedData) const gonnaAttack = !!bot.mouse.getCursorState().entity || !!videoCursorInteraction() - document.dispatchEvent(new MouseEvent('mousedown', { button: gonnaAttack ? MOUSE_BUTTON_LEFT : MOUSE_BUTTON_RIGHT })) + // If not attacking entity and item is activatable, use right click for breaking + const useButton = !gonnaAttack && isOnlyActivatable ? MOUSE_BUTTON_RIGHT : (gonnaAttack ? MOUSE_BUTTON_LEFT : MOUSE_BUTTON_RIGHT) + document.dispatchEvent(new MouseEvent('mousedown', { button: useButton })) bot.mouse.update() - document.dispatchEvent(new MouseEvent('mouseup', { button: gonnaAttack ? MOUSE_BUTTON_LEFT : MOUSE_BUTTON_RIGHT })) + document.dispatchEvent(new MouseEvent('mouseup', { button: useButton })) } if (screenTouches > 0) { diff --git a/src/react/TouchAreasControls.tsx b/src/react/TouchAreasControls.tsx index ec9d201b4..469097bd2 100644 --- a/src/react/TouchAreasControls.tsx +++ b/src/react/TouchAreasControls.tsx @@ -1,5 +1,6 @@ import { CSSProperties, PointerEvent, useEffect, useRef, useState } from 'react' import { proxy, ref, useSnapshot } from 'valtio' +import activatableItemsMobile from 'mineflayer-mouse/dist/activatableItemsMobile' import { contro } from '../controls' import { options } from '../optionsStorage' import PixelartIcon from './PixelartIcon' @@ -72,10 +73,12 @@ export default ({ setupActive, closeButtonsSetup, foregroundGameActive }: Props) break: false, jump: bot?.getControlState('jump'), }[name] + const RIGHT_MOUSE_BUTTON = 2 + const LEFT_MOUSE_BUTTON = 0 const holdDown = { action () { if (!bot) return - document.dispatchEvent(new MouseEvent('mousedown', { button: 2 })) + document.dispatchEvent(new MouseEvent('mousedown', { button: RIGHT_MOUSE_BUTTON })) bot.mouse.update() }, sneak () { @@ -87,7 +90,7 @@ export default ({ setupActive, closeButtonsSetup, foregroundGameActive }: Props) }, break () { if (!bot) return - document.dispatchEvent(new MouseEvent('mousedown', { button: 0 })) + document.dispatchEvent(new MouseEvent('mousedown', { button: LEFT_MOUSE_BUTTON })) bot.mouse.update() active = true }, @@ -101,7 +104,7 @@ export default ({ setupActive, closeButtonsSetup, foregroundGameActive }: Props) } const holdUp = { action () { - document.dispatchEvent(new MouseEvent('mouseup', { button: 2 })) + document.dispatchEvent(new MouseEvent('mouseup', { button: RIGHT_MOUSE_BUTTON })) }, sneak () { void contro.emit('release', { @@ -112,7 +115,7 @@ export default ({ setupActive, closeButtonsSetup, foregroundGameActive }: Props) }, break () { if (!bot) return - document.dispatchEvent(new MouseEvent('mouseup', { button: 0 })) + document.dispatchEvent(new MouseEvent('mouseup', { button: LEFT_MOUSE_BUTTON })) bot.mouse.update() active = false }, From 5bd33a546a25b54015ac4b88c40b406ebc9246e6 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Fri, 18 Jul 2025 04:39:05 +0300 Subject: [PATCH 05/14] More build configs & optimise reconnect and immediate game enter (#398) feat(custom-builds): Add a way to bundle only specific minecraft version data, this does not affect assets though env: MIN_MC_VERSION MAX_MC_VERSION new SKIP_MC_DATA_RECIPES - if recipes are not used in game fix: refactor QS params handling to ensure panorama & main menu never loaded when immedieate game enter action is expected (eg ?autoConnect=1) --- rsbuild.config.ts | 11 +- scripts/genLargeDataAliases.ts | 5 +- scripts/makeOptimizedMcData.mjs | 80 ++++++++--- src/downloadAndOpenFile.ts | 6 + src/env.d.ts | 20 ++- src/index.ts | 232 +++++++++++++++++------------- src/inventoryWindows.ts | 6 +- src/react/AddServerOrConnect.tsx | 14 +- src/react/ServersListProvider.tsx | 24 ++-- 9 files changed, 246 insertions(+), 152 deletions(-) diff --git a/rsbuild.config.ts b/rsbuild.config.ts index e264f6b73..42d6867b3 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -139,6 +139,13 @@ const appConfig = defineConfig({ // 50kb limit for data uri dataUriLimit: SINGLE_FILE_BUILD ? 1 * 1024 * 1024 * 1024 : 50 * 1024 }, + performance: { + // prefetch: { + // include(filename) { + // return filename.includes('mc-data') || filename.includes('mc-assets') + // }, + // }, + }, source: { entry: { index: './src/index.ts', @@ -154,7 +161,7 @@ const appConfig = defineConfig({ 'process.platform': '"browser"', 'process.env.GITHUB_URL': JSON.stringify(`https://github.com/${process.env.GITHUB_REPOSITORY || `${process.env.VERCEL_GIT_REPO_OWNER}/${process.env.VERCEL_GIT_REPO_SLUG}` || githubRepositoryFallback}`), - 'process.env.DEPS_VERSIONS': JSON.stringify({}), + 'process.env.ALWAYS_MINIMAL_SERVER_UI': JSON.stringify(process.env.ALWAYS_MINIMAL_SERVER_UI), 'process.env.RELEASE_TAG': JSON.stringify(releaseTag), 'process.env.RELEASE_LINK': JSON.stringify(releaseLink), 'process.env.RELEASE_CHANGELOG': JSON.stringify(releaseChangelog), @@ -190,7 +197,7 @@ const appConfig = defineConfig({ childProcess.execSync('tsx ./scripts/optimizeBlockCollisions.ts', { stdio: 'inherit' }) } // childProcess.execSync(['tsx', './scripts/genLargeDataAliases.ts', ...(SINGLE_FILE_BUILD ? ['--compressed'] : [])].join(' '), { stdio: 'inherit' }) - genLargeDataAliases(SINGLE_FILE_BUILD) + genLargeDataAliases(SINGLE_FILE_BUILD || process.env.ALWAYS_COMPRESS_LARGE_DATA === 'true') fsExtra.copySync('./node_modules/mc-assets/dist/other-textures/latest/entity', './dist/textures/entity') fsExtra.copySync('./assets/background', './dist/background') fs.copyFileSync('./assets/favicon.png', './dist/favicon.png') diff --git a/scripts/genLargeDataAliases.ts b/scripts/genLargeDataAliases.ts index 0cf206df0..2372dbfd8 100644 --- a/scripts/genLargeDataAliases.ts +++ b/scripts/genLargeDataAliases.ts @@ -16,7 +16,8 @@ export const genLargeDataAliases = async (isCompressed: boolean) => { let str = `${decoderCode}\nexport const importLargeData = async (mod: ${Object.keys(modules).map(x => `'${x}'`).join(' | ')}) => {\n` for (const [module, { compressed, raw }] of Object.entries(modules)) { - let importCode = `(await import('${isCompressed ? compressed : raw}')).default`; + const chunkName = module === 'mcData' ? 'mc-data' : 'mc-assets'; + let importCode = `(await import(/* webpackChunkName: "${chunkName}" */ '${isCompressed ? compressed : raw}')).default`; if (isCompressed) { importCode = `JSON.parse(decompressFromBase64(${importCode}))` } @@ -30,6 +31,8 @@ export const genLargeDataAliases = async (isCompressed: boolean) => { const decoderCode = /* ts */ ` import pako from 'pako'; +globalThis.pako = { inflate: pako.inflate.bind(pako) } + function decompressFromBase64(input) { console.time('decompressFromBase64') // Decode the Base64 string diff --git a/scripts/makeOptimizedMcData.mjs b/scripts/makeOptimizedMcData.mjs index 05948cf27..0b5752d89 100644 --- a/scripts/makeOptimizedMcData.mjs +++ b/scripts/makeOptimizedMcData.mjs @@ -6,8 +6,8 @@ import { dirname } from 'node:path' import supportedVersions from '../src/supportedVersions.mjs' import { gzipSizeFromFileSync } from 'gzip-size' import fs from 'fs' -import {default as _JsonOptimizer} from '../src/optimizeJson' -import { gzipSync } from 'zlib'; +import { default as _JsonOptimizer } from '../src/optimizeJson' +import { gzipSync } from 'zlib' import MinecraftData from 'minecraft-data' import MCProtocol from 'minecraft-protocol' @@ -21,12 +21,12 @@ const require = Module.createRequire(import.meta.url) const dataPaths = require('minecraft-data/minecraft-data/data/dataPaths.json') -function toMajor (version) { +function toMajor(version) { const [a, b] = (version + '').split('.') return `${a}.${b}` } -const versions = {} +let versions = {} const dataTypes = new Set() for (const [version, dataSet] of Object.entries(dataPaths.pc)) { @@ -42,6 +42,31 @@ const versionToNumber = (ver) => { return +`${x.padStart(2, '0')}${y.padStart(2, '0')}${z.padStart(2, '0')}` } +// Version clipping support +const minVersion = process.env.MIN_MC_VERSION +const maxVersion = process.env.MAX_MC_VERSION + +// Filter versions based on MIN_VERSION and MAX_VERSION if provided +if (minVersion || maxVersion) { + const filteredVersions = {} + const minVersionNum = minVersion ? versionToNumber(minVersion) : 0 + const maxVersionNum = maxVersion ? versionToNumber(maxVersion) : Infinity + + for (const [version, dataSet] of Object.entries(versions)) { + const versionNum = versionToNumber(version) + if (versionNum >= minVersionNum && versionNum <= maxVersionNum) { + filteredVersions[version] = dataSet + } + } + + versions = filteredVersions + + console.log(`Version clipping applied: ${minVersion || 'none'} to ${maxVersion || 'none'}`) + console.log(`Processing ${Object.keys(versions).length} versions:`, Object.keys(versions).sort((a, b) => versionToNumber(a) - versionToNumber(b))) +} + +console.log('Bundling version range:', Object.keys(versions)[0], 'to', Object.keys(versions).at(-1)) + // if not included here (even as {}) will not be bundled & accessible! // const compressedOutput = !!process.env.SINGLE_FILE_BUILD const compressedOutput = true @@ -57,18 +82,20 @@ const dataTypeBundling2 = { } } const dataTypeBundling = { - language: { + language: process.env.SKIP_MC_DATA_LANGUAGE === 'true' ? { + raw: {} + } : { ignoreRemoved: true, ignoreChanges: true }, blocks: { arrKey: 'name', - processData (current, prev) { + processData(current, prev) { for (const block of current) { if (block.transparent) { const forceOpaque = block.name.includes('shulker_box') || block.name.match(/^double_.+_slab\d?$/) || ['melon_block', 'lit_pumpkin', 'lit_redstone_ore', 'lit_furnace'].includes(block.name) - const prevBlock = prev?.find(x => x.name === block.name); + const prevBlock = prev?.find(x => x.name === block.name) if (forceOpaque || (prevBlock && !prevBlock.transparent)) { block.transparent = false } @@ -136,7 +163,9 @@ const dataTypeBundling = { blockLoot: { arrKey: 'block' }, - recipes: { + recipes: process.env.SKIP_MC_DATA_RECIPES === 'true' ? { + raw: {} + } : { raw: true // processData: processRecipes }, @@ -150,7 +179,7 @@ const dataTypeBundling = { // } } -function processRecipes (current, prev, getData, version) { +function processRecipes(current, prev, getData, version) { // can require the same multiple times per different versions if (current._proccessed) return const items = getData('items') @@ -242,30 +271,39 @@ for (const [i, [version, dataSet]] of versionsArr.reverse().entries()) { for (const [dataType, dataPath] of Object.entries(dataSet)) { const config = dataTypeBundling[dataType] if (!config) continue - if (dataType === 'blockCollisionShapes' && versionToNumber(version) >= versionToNumber('1.13')) { - // contents += ` get ${dataType} () { return window.globalGetCollisionShapes?.("${version}") },\n` - continue - } + const ignoreCollisionShapes = dataType === 'blockCollisionShapes' && versionToNumber(version) >= versionToNumber('1.13') + let injectCode = '' - const getData = (type) => { + const getRealData = (type) => { const loc = `minecraft-data/data/${dataSet[type]}/` const dataPathAbsolute = require.resolve(`minecraft-data/${loc}${type}`) // const data = fs.readFileSync(dataPathAbsolute, 'utf8') const dataRaw = require(dataPathAbsolute) return dataRaw } - const dataRaw = getData(dataType) + const dataRaw = getRealData(dataType) let rawData = dataRaw if (config.raw) { rawDataVersions[dataType] ??= {} rawDataVersions[dataType][version] = rawData - rawData = dataRaw + if (config.raw === true) { + rawData = dataRaw + } else { + rawData = config.raw + } + + if (ignoreCollisionShapes && dataType === 'blockCollisionShapes') { + rawData = { + blocks: {}, + shapes: {} + } + } } else { if (!diffSources[dataType]) { diffSources[dataType] = new JsonOptimizer(config.arrKey, config.ignoreChanges, config.ignoreRemoved) } try { - config.processData?.(dataRaw, previousData[dataType], getData, version) + config.processData?.(dataRaw, previousData[dataType], getRealData, version) diffSources[dataType].recordDiff(version, dataRaw) injectCode = `restoreDiff(sources, ${JSON.stringify(dataType)}, ${JSON.stringify(version)})` } catch (err) { @@ -297,16 +335,16 @@ console.log('total size (mb)', totalSize / 1024 / 1024) console.log( 'size per data type (mb, %)', Object.fromEntries(Object.entries(sizePerDataType).map(([dataType, size]) => { - return [dataType, [size / 1024 / 1024, Math.round(size / totalSize * 100)]]; + return [dataType, [size / 1024 / 1024, Math.round(size / totalSize * 100)]] }).sort((a, b) => { //@ts-ignore - return b[1][1] - a[1][1]; + return b[1][1] - a[1][1] })) ) function compressToBase64(input) { - const buffer = gzipSync(input); - return buffer.toString('base64'); + const buffer = gzipSync(input) + return buffer.toString('base64') } const filePath = './generated/minecraft-data-optimized.json' diff --git a/src/downloadAndOpenFile.ts b/src/downloadAndOpenFile.ts index 1e703369d..1ff318ff8 100644 --- a/src/downloadAndOpenFile.ts +++ b/src/downloadAndOpenFile.ts @@ -11,6 +11,12 @@ export const getFixedFilesize = (bytes: number) => { return prettyBytes(bytes, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } +export const isInterestedInDownload = () => { + const { map, texturepack, replayFileUrl } = appQueryParams + const { mapDir } = appQueryParamsArray + return !!map || !!texturepack || !!replayFileUrl || !!mapDir +} + const inner = async () => { const { map, texturepack, replayFileUrl } = appQueryParams const { mapDir } = appQueryParamsArray diff --git a/src/env.d.ts b/src/env.d.ts index 9b3e9774d..e565fcec3 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -2,30 +2,36 @@ declare namespace NodeJS { interface ProcessEnv { // Build configuration NODE_ENV: 'development' | 'production' - SINGLE_FILE_BUILD?: string + MIN_MC_VERSION?: string + MAX_MC_VERSION?: string + ALWAYS_COMPRESS_LARGE_DATA?: 'true' | 'false' + SINGLE_FILE_BUILD?: 'true' | 'false' WS_PORT?: string - DISABLE_SERVICE_WORKER?: string + DISABLE_SERVICE_WORKER?: 'true' | 'false' CONFIG_JSON_SOURCE?: 'BUNDLED' | 'REMOTE' LOCAL_CONFIG_FILE?: string BUILD_VERSION?: string - // GitHub and Vercel related + // Build internals GITHUB_REPOSITORY?: string VERCEL_GIT_REPO_OWNER?: string VERCEL_GIT_REPO_SLUG?: string - // UI and Features + // UI MAIN_MENU_LINKS?: string + ALWAYS_MINIMAL_SERVER_UI?: 'true' | 'false' + + // App features ENABLE_COOKIE_STORAGE?: string COOKIE_STORAGE_PREFIX?: string - // Release information + // Build info. Release information RELEASE_TAG?: string RELEASE_LINK?: string RELEASE_CHANGELOG?: string - // Other configurations - DEPS_VERSIONS?: string + // Build info INLINED_APP_CONFIG?: string + GITHUB_URL?: string } } diff --git a/src/index.ts b/src/index.ts index 185caab6f..7a553ca7a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,7 +29,7 @@ import './reactUi' import { lockUrl, onBotCreate } from './controls' import './dragndrop' import { possiblyCleanHandle } from './browserfs' -import downloadAndOpenFile from './downloadAndOpenFile' +import downloadAndOpenFile, { isInterestedInDownload } from './downloadAndOpenFile' import fs from 'fs' import net, { Socket } from 'net' @@ -97,6 +97,7 @@ import { registerOpenBenchmarkListener } from './benchmark' import { tryHandleBuiltinCommand } from './builtinCommands' import { loadingTimerState } from './react/LoadingTimer' import { loadPluginsIntoWorld } from './react/CreateWorldProvider' +import { getCurrentProxy, getCurrentUsername } from './react/ServersList' window.debug = debug window.beforeRenderFrame = [] @@ -166,6 +167,7 @@ export async function connect (connectOptions: ConnectOptions) { }) } + appStatusState.showReconnect = false loadingTimerState.loading = true loadingTimerState.start = Date.now() miscUiState.hasErrors = false @@ -880,37 +882,7 @@ export async function connect (connectOptions: ConnectOptions) { } } -const reconnectOptions = sessionStorage.getItem('reconnectOptions') ? JSON.parse(sessionStorage.getItem('reconnectOptions')!) : undefined - listenGlobalEvents() -const unsubscribe = subscribe(miscUiState, async () => { - if (miscUiState.fsReady && miscUiState.appConfig) { - unsubscribe() - if (reconnectOptions) { - sessionStorage.removeItem('reconnectOptions') - if (Date.now() - reconnectOptions.timestamp < 1000 * 60 * 2) { - void connect(reconnectOptions.value) - } - } else { - if (appQueryParams.singleplayer === '1' || appQueryParams.sp === '1') { - loadSingleplayer({}, { - worldFolder: undefined, - ...appQueryParams.version ? { version: appQueryParams.version } : {} - }) - } - if (appQueryParams.loadSave) { - const savePath = `/data/worlds/${appQueryParams.loadSave}` - try { - await fs.promises.stat(savePath) - } catch (err) { - alert(`Save ${savePath} not found`) - return - } - await loadInMemorySave(savePath) - } - } - } -}) // #region fire click event on touch as we disable default behaviors let activeTouch: { touch: Touch, elem: HTMLElement, start: number } | undefined @@ -946,90 +918,153 @@ document.body.addEventListener('touchstart', (e) => { }, { passive: false }) // #endregion -// qs open actions -if (!reconnectOptions) { - downloadAndOpenFile().then((downloadAction) => { - if (downloadAction) return - if (appQueryParams.reconnect && process.env.NODE_ENV === 'development') { - const lastConnect = JSON.parse(localStorage.lastConnectOptions ?? {}) +// immediate game enter actions: reconnect or URL QS +const maybeEnterGame = () => { + const waitForConfigFsLoad = (fn: () => void) => { + let unsubscribe: () => void | undefined + const checkDone = () => { + if (miscUiState.fsReady && miscUiState.appConfig) { + fn() + unsubscribe?.() + return true + } + return false + } + + if (!checkDone()) { + const text = miscUiState.appConfig ? 'Loading' : 'Loading config' + setLoadingScreenStatus(text) + unsubscribe = subscribe(miscUiState, checkDone) + } + } + + const reconnectOptions = sessionStorage.getItem('reconnectOptions') ? JSON.parse(sessionStorage.getItem('reconnectOptions')!) : undefined + + if (reconnectOptions) { + sessionStorage.removeItem('reconnectOptions') + if (Date.now() - reconnectOptions.timestamp < 1000 * 60 * 2) { + return waitForConfigFsLoad(async () => { + void connect(reconnectOptions.value) + }) + } + } + + if (appQueryParams.reconnect && process.env.NODE_ENV === 'development') { + const lastConnect = JSON.parse(localStorage.lastConnectOptions ?? {}) + return waitForConfigFsLoad(async () => { void connect({ botVersion: appQueryParams.version ?? undefined, ...lastConnect, ip: appQueryParams.ip || undefined }) - return - } - if (appQueryParams.ip || appQueryParams.proxy) { - const waitAppConfigLoad = !appQueryParams.proxy - const openServerEditor = () => { - hideModal() - if (appQueryParams.onlyConnect) { - showModal({ reactType: 'only-connect-server' }) - } else { - showModal({ reactType: 'editServer' }) - } - } - showModal({ reactType: 'empty' }) - if (waitAppConfigLoad) { - const unsubscribe = subscribe(miscUiState, checkCanDisplay) - checkCanDisplay() - // eslint-disable-next-line no-inner-declarations - function checkCanDisplay () { - if (miscUiState.appConfig) { - unsubscribe() - openServerEditor() - return true - } - } - } else { - openServerEditor() + }) + } + + if (appQueryParams.singleplayer === '1' || appQueryParams.sp === '1') { + return waitForConfigFsLoad(async () => { + loadSingleplayer({}, { + worldFolder: undefined, + ...appQueryParams.version ? { version: appQueryParams.version } : {} + }) + }) + } + if (appQueryParams.loadSave) { + const enterSave = async () => { + const savePath = `/data/worlds/${appQueryParams.loadSave}` + try { + await fs.promises.stat(savePath) + await loadInMemorySave(savePath) + } catch (err) { + alert(`Save ${savePath} not found`) } } + return waitForConfigFsLoad(enterSave) + } - void Promise.resolve().then(() => { - // try to connect to peer - const peerId = appQueryParams.connectPeer - const peerOptions = {} as ConnectPeerOptions - if (appQueryParams.server) { - peerOptions.server = appQueryParams.server - } - const version = appQueryParams.peerVersion - if (peerId) { - let username: string | null = options.guestUsername - if (options.askGuestName) username = prompt('Enter your username', username) - if (!username) return - options.guestUsername = username + if (appQueryParams.ip || appQueryParams.proxy) { + const waitAppConfigLoad = !appQueryParams.proxy + const openServerAction = () => { + if (appQueryParams.autoConnect && miscUiState.appConfig?.allowAutoConnect) { void connect({ - username, - botVersion: version || undefined, - peerId, - peerOptions + server: appQueryParams.ip, + proxy: getCurrentProxy(), + botVersion: appQueryParams.version ?? undefined, + username: getCurrentUsername()!, }) + return } - }) - if (appQueryParams.serversList && !appQueryParams.ip) { - showModal({ reactType: 'serversList' }) + setLoadingScreenStatus(undefined) + if (appQueryParams.onlyConnect || process.env.ALWAYS_MINIMAL_SERVER_UI === 'true') { + showModal({ reactType: 'only-connect-server' }) + } else { + showModal({ reactType: 'editServer' }) + } } - const viewerWsConnect = appQueryParams.viewerConnect - if (viewerWsConnect) { - void connect({ - username: `viewer-${Math.random().toString(36).slice(2, 10)}`, - viewerWsConnect, - }) + // showModal({ reactType: 'empty' }) + if (waitAppConfigLoad) { + return waitForConfigFsLoad(openServerAction) } + openServerAction() + return + } - if (appQueryParams.modal) { - const modals = appQueryParams.modal.split(',') - for (const modal of modals) { - showModal({ reactType: modal }) - } + if (appQueryParams.connectPeer) { + // try to connect to peer + const peerId = appQueryParams.connectPeer + const peerOptions = {} as ConnectPeerOptions + if (appQueryParams.server) { + peerOptions.server = appQueryParams.server } - }, (err) => { - console.error(err) - alert(`Something went wrong: ${err}`) - }) + const version = appQueryParams.peerVersion + let username: string | null = options.guestUsername + if (options.askGuestName) username = prompt('Enter your username to connect to peer', username) + if (!username) return + options.guestUsername = username + void connect({ + username, + botVersion: version || undefined, + peerId, + peerOptions + }) + return + + } + + if (appQueryParams.viewerConnect) { + void connect({ + username: `viewer-${Math.random().toString(36).slice(2, 10)}`, + viewerWsConnect: appQueryParams.viewerConnect, + }) + return + } + + if (appQueryParams.modal) { + const modals = appQueryParams.modal.split(',') + for (const modal of modals) { + showModal({ reactType: modal }) + } + return + } + + if (appQueryParams.serversList && !miscUiState.appConfig?.appParams?.serversList) { + // open UI only if it's in URL + showModal({ reactType: 'serversList' }) + } + + if (isInterestedInDownload()) { + void downloadAndOpenFile() + } + + void possiblyHandleStateVariable() +} + +try { + maybeEnterGame() +} catch (err) { + console.error(err) + alert(`Something went wrong: ${err}`) } // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion @@ -1040,6 +1075,5 @@ if (initialLoader) { } window.pageLoaded = true -void possiblyHandleStateVariable() appViewer.waitBackendLoadPromises.push(appStartup()) registerOpenBenchmarkListener() diff --git a/src/inventoryWindows.ts b/src/inventoryWindows.ts index c9f60d592..1e17db145 100644 --- a/src/inventoryWindows.ts +++ b/src/inventoryWindows.ts @@ -522,7 +522,7 @@ const getResultingRecipe = (slots: Array, gridRows: number) => { type Result = RecipeItem | undefined let shapelessResult: Result let shapeResult: Result - outer: for (const [id, recipeVariants] of Object.entries(loadedData.recipes)) { + outer: for (const [id, recipeVariants] of Object.entries(loadedData.recipes ?? {})) { for (const recipeVariant of recipeVariants) { if ('inShape' in recipeVariant && equals(currentShape, recipeVariant.inShape as number[][])) { shapeResult = recipeVariant.result! @@ -550,7 +550,7 @@ const getAllItemRecipes = (itemName: string) => { const item = loadedData.itemsByName[itemName] if (!item) return const itemId = item.id - const recipes = loadedData.recipes[itemId] + const recipes = loadedData.recipes?.[itemId] if (!recipes) return const results = [] as Array<{ result: Item, @@ -595,7 +595,7 @@ const getAllItemUsages = (itemName: string) => { if (!item) return const foundRecipeIds = [] as string[] - for (const [id, recipes] of Object.entries(loadedData.recipes)) { + for (const [id, recipes] of Object.entries(loadedData.recipes ?? {})) { for (const recipe of recipes) { if ('inShape' in recipe) { if (recipe.inShape.some(row => row.includes(item.id))) { diff --git a/src/react/AddServerOrConnect.tsx b/src/react/AddServerOrConnect.tsx index 08e4d69e0..d478b3e76 100644 --- a/src/react/AddServerOrConnect.tsx +++ b/src/react/AddServerOrConnect.tsx @@ -29,10 +29,9 @@ interface Props { accounts?: string[] authenticatedAccounts?: number versions?: string[] - allowAutoConnect?: boolean } -export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQs, onQsConnect, placeholders, accounts, versions, allowAutoConnect }: Props) => { +export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQs, onQsConnect, placeholders, accounts, versions }: Props) => { const isSmallHeight = !usePassesScaledDimensions(null, 350) const qsParamName = parseQs ? appQueryParams.name : undefined const qsParamIp = parseQs ? appQueryParams.ip : undefined @@ -40,7 +39,6 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ const qsParamProxy = parseQs ? appQueryParams.proxy : undefined const qsParamUsername = parseQs ? appQueryParams.username : undefined const qsParamLockConnect = parseQs ? appQueryParams.lockConnect : undefined - const qsParamAutoConnect = parseQs ? appQueryParams.autoConnect : undefined const parsedQsIp = parseServerAddress(qsParamIp) const parsedInitialIp = parseServerAddress(initialData?.ip) @@ -118,12 +116,6 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ } } - useEffect(() => { - if (qsParamAutoConnect && qsParamIp && qsParamVersion && allowAutoConnect) { - onQsConnect?.(commonUseOptions) - } - }, []) - const displayConnectButton = qsParamIp const serverExamples = ['example.com:25565', 'play.hypixel.net', 'ws://play.pcm.gg'] // pick random example @@ -231,7 +223,7 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ Cancel - {displayConnectButton ? 'Save' : Save} + {displayConnectButton ? translate('Save') : {translate('Save')}} } {displayConnectButton && ( @@ -246,7 +238,7 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ onQsConnect?.(commonUseOptions) }} > - Connect + {translate('Connect')} )} diff --git a/src/react/ServersListProvider.tsx b/src/react/ServersListProvider.tsx index d95d4e9e2..2509db74d 100644 --- a/src/react/ServersListProvider.tsx +++ b/src/react/ServersListProvider.tsx @@ -156,13 +156,22 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL const isWebSocket = server.ip.startsWith('ws://') || server.ip.startsWith('wss://') let data if (isWebSocket) { - const pingResult = await getServerInfo(server.ip, undefined, undefined, true) - console.log('pingResult.fullInfo.description', pingResult.fullInfo.description) - data = { - formattedText: pingResult.fullInfo.description, - textNameRight: `ws ${pingResult.latency}ms`, - textNameRightGrayed: `${pingResult.fullInfo.players?.online ?? '??'}/${pingResult.fullInfo.players?.max ?? '??'}`, - offline: false + try { + const pingResult = await getServerInfo(server.ip, undefined, undefined, true) + console.log('pingResult.fullInfo.description', pingResult.fullInfo.description) + data = { + formattedText: pingResult.fullInfo.description, + textNameRight: `ws ${pingResult.latency}ms`, + textNameRightGrayed: `${pingResult.fullInfo.players?.online ?? '??'}/${pingResult.fullInfo.players?.max ?? '??'}`, + offline: false + } + } catch (err) { + data = { + formattedText: 'Failed to connect', + textNameRight: '', + textNameRightGrayed: '', + offline: true + } } } else { data = await fetchServerStatus(server.ip, /* signal */undefined, server.versionOverride) // DONT ADD SIGNAL IT WILL CRUSH JS RUNTIME @@ -217,7 +226,6 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL }) const editModalJsx = isEditScreenModal ? Date: Fri, 18 Jul 2025 02:55:29 +0000 Subject: [PATCH 06/14] fix: Effects and Game Indicators overlay toggles didn't work (#397) --- src/react/IndicatorEffectsProvider.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/react/IndicatorEffectsProvider.tsx b/src/react/IndicatorEffectsProvider.tsx index c479d7e13..df52296ab 100644 --- a/src/react/IndicatorEffectsProvider.tsx +++ b/src/react/IndicatorEffectsProvider.tsx @@ -5,7 +5,6 @@ import { Effect } from 'mineflayer' import { inGameError } from '../utils' import { fsState } from '../loadSave' import { gameAdditionalState, miscUiState } from '../globalState' -import { options } from '../optionsStorage' import IndicatorEffects, { EffectType, defaultIndicatorsState } from './IndicatorEffects' import { images } from './effectsImages' @@ -67,7 +66,6 @@ export default ({ displayEffects = true, displayIndicators = true }: { displayEf const { mesherWork } = useSnapshot(appViewer.rendererState).world const { hasErrors } = useSnapshot(miscUiState) - const { disabledUiParts } = useSnapshot(options) const { isReadonly, openReadOperations, openWriteOperations } = useSnapshot(fsState) const { noConnection, poorConnection } = useSnapshot(gameAdditionalState) const allIndicators: typeof defaultIndicatorsState = { @@ -122,7 +120,7 @@ export default ({ displayEffects = true, displayIndicators = true }: { displayEf return } From c360115f6017e5e0a37a5ad787fb54b10d122d2b Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 18 Jul 2025 07:46:25 +0300 Subject: [PATCH 07/14] fix: fix rare ios safari bug where hotbar would not be visible due to unclear fixed&bottom:0 css using --- src/react/HotbarRenderApp.tsx | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/react/HotbarRenderApp.tsx b/src/react/HotbarRenderApp.tsx index ed1f42e67..6b6e3207b 100644 --- a/src/react/HotbarRenderApp.tsx +++ b/src/react/HotbarRenderApp.tsx @@ -200,17 +200,28 @@ const HotbarInner = () => {
+ }}> +
+
} From 45408476a539302c098a0969012662b16430225c Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 18 Jul 2025 07:53:47 +0300 Subject: [PATCH 08/14] fix(appStorage): Fix that settings were not possible to save on vercel domains, use robust self-checking mechanism to ensure user data never lost when cookies storage enabled! --- src/react/appStorageProvider.ts | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/react/appStorageProvider.ts b/src/react/appStorageProvider.ts index bce6fecaf..fd469186d 100644 --- a/src/react/appStorageProvider.ts +++ b/src/react/appStorageProvider.ts @@ -91,6 +91,14 @@ const setCookieValue = (key: string, value: string): boolean => { } document.cookie = cookie + + // Verify the cookie was actually saved by reading it back + const savedValue = getCookieValue(key) + if (savedValue !== value) { + console.warn(`Cookie verification failed for key '${key}'. Expected: ${value}, Got: ${savedValue}`) + return false + } + return true } catch (error) { console.error(`Failed to set cookie for key '${key}':`, error) @@ -229,12 +237,19 @@ export const getRandomUsername = (appConfig: AppConfig) => { export const appStorage = proxy({ ...defaultStorageData }) +// Track if cookies failed in this session +let cookiesFailedThisSession = false + // Check if cookie storage should be used (will be set by options) const shouldUseCookieStorage = () => { - const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent) + // If cookies failed this session, don't try again + if (cookiesFailedThisSession) { + return false + } + const isSecureCookiesAvailable = () => { // either https or localhost - return window.location.protocol === 'https:' || (window.location.hostname === 'localhost' && !isSafari) + return window.location.protocol === 'https:' || (window.location.hostname === 'localhost') } if (!isSecureCookiesAvailable()) { return false @@ -345,8 +360,10 @@ const saveKey = (key: keyof StorageData) => { // Remove from localStorage if cookie save was successful markLocalStorageAsMigrated(key) } else { - // Disabling for now so no confusing conflicts modal after page reload - // useLocalStorage = true + // Cookie save failed, disable cookies for this session and fallback to localStorage + console.warn(`Cookie save failed for key '${key}', disabling cookies for this session`) + cookiesFailedThisSession = true + useLocalStorage = true } } } From de9bfba3a8924578a76c049c5e7e355735501f19 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 18 Jul 2025 08:02:13 +0300 Subject: [PATCH 09/14] allow auto connect on mcraft for last integrations --- config.mcraft-only.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config.mcraft-only.json b/config.mcraft-only.json index 7d9a7b596..52a3aa2ce 100644 --- a/config.mcraft-only.json +++ b/config.mcraft-only.json @@ -1,4 +1,5 @@ { "alwaysReconnectButton": true, - "reportBugButtonWithReconnect": true + "reportBugButtonWithReconnect": true, + "allowAutoConnect": true } From 0dca8bbbe5848812697e959c796b0bc6367db6d5 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 18 Jul 2025 08:32:32 +0300 Subject: [PATCH 10/14] fix(important): F3 actions didn't work on mobile at all like chunks reload --- src/controls.ts | 18 ++++++--- src/react/OptionsItems.tsx | 81 +++++++++++++++++++++++++++----------- 2 files changed, 70 insertions(+), 29 deletions(-) diff --git a/src/controls.ts b/src/controls.ts index 9430f9c18..db6a6fc66 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -818,6 +818,11 @@ export const f3Keybinds: Array<{ } ] +export const reloadChunksAction = () => { + const action = f3Keybinds.find(f3Keybind => f3Keybind.key === 'KeyA') + void action!.action() +} + document.addEventListener('keydown', (e) => { if (!isGameActive(false)) return if (contro.pressedKeys.has('F3')) { @@ -987,14 +992,17 @@ export function updateBinds (commands: any) { } export const onF3LongPress = async () => { - const select = await showOptionsModal('', f3Keybinds.filter(f3Keybind => { + const actions = f3Keybinds.filter(f3Keybind => { return f3Keybind.mobileTitle && (f3Keybind.enabled?.() ?? true) - }).map(f3Keybind => { + }) + const actionNames = actions.map(f3Keybind => { return `${f3Keybind.mobileTitle}${f3Keybind.key ? ` (F3+${f3Keybind.key})` : ''}` - })) + }) + const select = await showOptionsModal('', actionNames) if (!select) return - const f3Keybind = f3Keybinds.find(f3Keybind => f3Keybind.mobileTitle === select) - if (f3Keybind) void f3Keybind.action() + const actionIndex = actionNames.indexOf(select) + const f3Keybind = actions[actionIndex]! + void f3Keybind.action() } export const handleMobileButtonCustomAction = (action: CustomAction) => { diff --git a/src/react/OptionsItems.tsx b/src/react/OptionsItems.tsx index 32f99b9e2..ccc04bcba 100644 --- a/src/react/OptionsItems.tsx +++ b/src/react/OptionsItems.tsx @@ -4,11 +4,13 @@ import { titleCase } from 'title-case' import { useMemo } from 'react' import { disabledSettings, options, qsOptions } from '../optionsStorage' import { hideAllModals, miscUiState } from '../globalState' +import { reloadChunksAction } from '../controls' import Button from './Button' import Slider from './Slider' import Screen from './Screen' import { showOptionsModal } from './SelectOption' import PixelartIcon, { pixelartIcons } from './PixelartIcon' +import { reconnectReload } from './AppStatusProvider' type GeneralItem = { id?: string @@ -18,7 +20,8 @@ type GeneralItem = { tooltip?: string // description?: string enableWarning?: string - willHaveNoEffect?: boolean + requiresRestart?: boolean + requiresChunksReload?: boolean values?: Array disableIf?: [option: keyof typeof options, value: any] } @@ -56,7 +59,14 @@ const useCommonComponentsProps = (item: OptionMeta) => { } } -export const OptionButton = ({ item }: { item: Extract }) => { +const ignoreReloadWarningsCache = new Set() + +export const OptionButton = ({ item, onClick, valueText, cacheKey }: { + item: Extract, + onClick?: () => void, + valueText?: string, + cacheKey?: string, +}) => { const { disabledBecauseOfSetting } = useCommonComponentsProps(item) const optionValue = useSnapshot(options)[item.id!] @@ -84,40 +94,63 @@ export const OptionButton = ({ item }: { item: Extract { if (disabledReason) { - await showOptionsModal(`The option is unavailable. ${disabledReason}`, []) + await showOptionsModal(`${translate('The option is not available')}: ${disabledReason}`, []) return } if (item.enableWarning && !options[item.id!]) { const result = await showOptionsModal(item.enableWarning, ['Enable']) if (!result) return } - const { values } = item - if (values) { - const getOptionValue = (arrItem) => { - if (typeof arrItem === 'string') { - return arrItem + onClick?.() + if (item.id) { + const { values } = item + if (values) { + const getOptionValue = (arrItem) => { + if (typeof arrItem === 'string') { + return arrItem + } else { + return arrItem[0] + } + } + const currentIndex = values.findIndex((value) => { + return getOptionValue(value) === optionValue + }) + if (currentIndex === -1) { + options[item.id] = getOptionValue(values[0]) } else { - return arrItem[0] + const nextIndex = event.shiftKey + ? (currentIndex - 1 + values.length) % values.length + : (currentIndex + 1) % values.length + options[item.id] = getOptionValue(values[nextIndex]) } - } - const currentIndex = values.findIndex((value) => { - return getOptionValue(value) === optionValue - }) - if (currentIndex === -1) { - options[item.id!] = getOptionValue(values[0]) } else { - const nextIndex = event.shiftKey - ? (currentIndex - 1 + values.length) % values.length - : (currentIndex + 1) % values.length - options[item.id!] = getOptionValue(values[nextIndex]) + options[item.id] = !options[item.id] + } + } + + const toCacheKey = cacheKey ?? item.id ?? '' + if (toCacheKey && !ignoreReloadWarningsCache.has(toCacheKey)) { + ignoreReloadWarningsCache.add(toCacheKey) + + if (item.requiresRestart) { + const result = await showOptionsModal(translate('The option requires a restart to take effect'), ['Restart', 'I will do it later'], { + cancel: false, + }) + if (result) { + reconnectReload() + } + } + if (item.requiresChunksReload) { + const result = await showOptionsModal(translate('The option requires a chunks reload to take effect'), ['Reload', 'I will do it later'], { + cancel: false, + }) + if (result) { + reloadChunksAction() + } } - } else { - options[item.id!] = !options[item.id!] } }} title={disabledReason ? `${disabledReason} | ${item.tooltip}` : item.tooltip} From b6d4728c447592d1a01303448d41adc83822b973 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 18 Jul 2025 09:44:50 +0300 Subject: [PATCH 11/14] display disconnect always last --- renderer/viewer/three/documentRenderer.ts | 3 ++- src/devtools.ts | 1 - src/react/PauseScreen.tsx | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/renderer/viewer/three/documentRenderer.ts b/renderer/viewer/three/documentRenderer.ts index 8ff31e695..a5dc060d4 100644 --- a/renderer/viewer/three/documentRenderer.ts +++ b/renderer/viewer/three/documentRenderer.ts @@ -61,8 +61,9 @@ export class DocumentRenderer { this.previousCanvasWidth = this.canvas.width this.previousCanvasHeight = this.canvas.height + const supportsWebGL2 = 'WebGL2RenderingContext' in window // Only initialize stats and DOM-related features in main thread - if (!externalCanvas) { + if (!externalCanvas && supportsWebGL2) { this.stats = new TopRightStats(this.canvas as HTMLCanvasElement, this.config.statsVisible) this.setupFpsTracking() } diff --git a/src/devtools.ts b/src/devtools.ts index 8890fdeae..6c47f73d9 100644 --- a/src/devtools.ts +++ b/src/devtools.ts @@ -255,7 +255,6 @@ function connectWebSocket () { const wsUrl = getWebSocketUrl() if (!wsUrl) { - console.log('WebSocket server not configured') return } diff --git a/src/react/PauseScreen.tsx b/src/react/PauseScreen.tsx index 3036aa4b0..856ac9328 100644 --- a/src/react/PauseScreen.tsx +++ b/src/react/PauseScreen.tsx @@ -289,11 +289,6 @@ export default () => { />
) : null} - {!lockConnect && <> - - } {(noConnection || appConfig?.alwaysReconnectButton) && (
)} + {!lockConnect && <> + + } From a49877870325b010160c3e9e3dfca5719b8552f8 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 18 Jul 2025 09:56:50 +0300 Subject: [PATCH 12/14] always wait for config load so autoConnect works on remote config --- src/index.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index 7a553ca7a..8de988941 100644 --- a/src/index.ts +++ b/src/index.ts @@ -982,7 +982,6 @@ const maybeEnterGame = () => { } if (appQueryParams.ip || appQueryParams.proxy) { - const waitAppConfigLoad = !appQueryParams.proxy const openServerAction = () => { if (appQueryParams.autoConnect && miscUiState.appConfig?.allowAutoConnect) { void connect({ @@ -1003,11 +1002,7 @@ const maybeEnterGame = () => { } // showModal({ reactType: 'empty' }) - if (waitAppConfigLoad) { - return waitForConfigFsLoad(openServerAction) - } - openServerAction() - return + return waitForConfigFsLoad(openServerAction) } if (appQueryParams.connectPeer) { From 4d7e3df8597fffea6a66f7a6df4810dae1d8cd96 Mon Sep 17 00:00:00 2001 From: Max Lee Date: Fri, 18 Jul 2025 11:18:05 +0000 Subject: [PATCH 13/14] feat: Item projectiles support (#395) --- pnpm-lock.yaml | 14 +++--- renderer/viewer/three/entities.ts | 72 ++++++++++++++++----------- renderer/viewer/three/holdingBlock.ts | 2 +- 3 files changed, 50 insertions(+), 38 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e6fddf3a..52c2ff30e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,7 +136,7 @@ importers: version: 4.17.21 mcraft-fun-mineflayer: specifier: ^0.1.23 - version: 0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/3daf1f4bdc6afad0dedd87b879875f3dbb7b0980(encoding@0.1.13)) + version: 0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/26c04c26f8eed2499a7c56eb4664649f3f54b767(encoding@0.1.13)) minecraft-data: specifier: 3.92.0 version: 3.92.0 @@ -341,7 +341,7 @@ importers: version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/89c33d396f3fde4804c71f4be3c203ade1833b41(@types/react@18.3.18)(react@18.3.1) mineflayer: specifier: github:zardoy/mineflayer#gen-the-master - version: https://codeload.github.com/zardoy/mineflayer/tar.gz/3daf1f4bdc6afad0dedd87b879875f3dbb7b0980(encoding@0.1.13) + version: https://codeload.github.com/zardoy/mineflayer/tar.gz/26c04c26f8eed2499a7c56eb4664649f3f54b767(encoding@0.1.13) mineflayer-mouse: specifier: ^0.1.14 version: 0.1.14 @@ -6678,8 +6678,8 @@ packages: resolution: {integrity: sha512-GtW4hkijyZbSu5LKYYD89xZu+XY7OoP7IkrCnNEn6EdPm0+vr2THoJgFGKrlze9/81+T+P3E4qvJXNFiU/zeJg==} engines: {node: '>=22'} - mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/3daf1f4bdc6afad0dedd87b879875f3dbb7b0980: - resolution: {tarball: https://codeload.github.com/zardoy/mineflayer/tar.gz/3daf1f4bdc6afad0dedd87b879875f3dbb7b0980} + mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/26c04c26f8eed2499a7c56eb4664649f3f54b767: + resolution: {tarball: https://codeload.github.com/zardoy/mineflayer/tar.gz/26c04c26f8eed2499a7c56eb4664649f3f54b767} version: 4.30.0 engines: {node: '>=22'} @@ -16956,12 +16956,12 @@ snapshots: maxrects-packer: '@zardoy/maxrects-packer@2.7.4' zod: 3.24.2 - mcraft-fun-mineflayer@0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/3daf1f4bdc6afad0dedd87b879875f3dbb7b0980(encoding@0.1.13)): + mcraft-fun-mineflayer@0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/26c04c26f8eed2499a7c56eb4664649f3f54b767(encoding@0.1.13)): dependencies: '@zardoy/flying-squid': 0.0.49(encoding@0.1.13) exit-hook: 2.2.1 minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/6c2204a813690ead420e2b8c7f0ef32ca357d176(patch_hash=a8726e6981ddc3486262d981d1e2030f379901c055ac9c4bf3036b4149e860e0)(encoding@0.1.13) - mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/3daf1f4bdc6afad0dedd87b879875f3dbb7b0980(encoding@0.1.13) + mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/26c04c26f8eed2499a7c56eb4664649f3f54b767(encoding@0.1.13) prismarine-item: 1.16.0 ws: 8.18.1 transitivePeerDependencies: @@ -17379,7 +17379,7 @@ snapshots: - encoding - supports-color - mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/3daf1f4bdc6afad0dedd87b879875f3dbb7b0980(encoding@0.1.13): + mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/26c04c26f8eed2499a7c56eb4664649f3f54b767(encoding@0.1.13): dependencies: '@nxg-org/mineflayer-physics-util': 1.8.10 minecraft-data: 3.92.0 diff --git a/renderer/viewer/three/entities.ts b/renderer/viewer/three/entities.ts index 51292d2fa..6c6f8900b 100644 --- a/renderer/viewer/three/entities.ts +++ b/renderer/viewer/three/entities.ts @@ -717,7 +717,7 @@ export class Entities { return typeof component === 'string' ? component : component.text ?? '' } - getItemMesh (item, specificProps: ItemSpecificContextProperties, previousModel?: string) { + getItemMesh (item, specificProps: ItemSpecificContextProperties, faceCamera = false, previousModel?: string) { if (!item.nbt && item.nbtData) item.nbt = item.nbtData const textureUv = this.worldRenderer.getItemRenderData(item, specificProps) if (previousModel && previousModel === textureUv?.modelName) return undefined @@ -757,26 +757,37 @@ export class Entities { itemsTexture.needsUpdate = true itemsTexture.magFilter = THREE.NearestFilter itemsTexture.minFilter = THREE.NearestFilter - const itemsTextureFlipped = itemsTexture.clone() - itemsTextureFlipped.repeat.x *= -1 - itemsTextureFlipped.needsUpdate = true - itemsTextureFlipped.offset.set(u + (sizeX), 1 - v - sizeY) - const material = new THREE.MeshStandardMaterial({ - map: itemsTexture, - transparent: true, - alphaTest: 0.1, - }) - const materialFlipped = new THREE.MeshStandardMaterial({ - map: itemsTextureFlipped, - transparent: true, - alphaTest: 0.1, - }) - const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 0), [ - // top left and right bottom are black box materials others are transparent - new THREE.MeshBasicMaterial({ color: 0x00_00_00 }), new THREE.MeshBasicMaterial({ color: 0x00_00_00 }), - new THREE.MeshBasicMaterial({ color: 0x00_00_00 }), new THREE.MeshBasicMaterial({ color: 0x00_00_00 }), - material, materialFlipped, - ]) + let mesh: THREE.Object3D + let itemsTextureFlipped: THREE.Texture | undefined + if (faceCamera) { + const spriteMat = new THREE.SpriteMaterial({ + map: itemsTexture, + transparent: true, + alphaTest: 0.1, + }) + mesh = new THREE.Sprite(spriteMat) + } else { + itemsTextureFlipped = itemsTexture.clone() + itemsTextureFlipped.repeat.x *= -1 + itemsTextureFlipped.needsUpdate = true + itemsTextureFlipped.offset.set(u + (sizeX), 1 - v - sizeY) + const material = new THREE.MeshStandardMaterial({ + map: itemsTexture, + transparent: true, + alphaTest: 0.1, + }) + const materialFlipped = new THREE.MeshStandardMaterial({ + map: itemsTextureFlipped, + transparent: true, + alphaTest: 0.1, + }) + mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 0), [ + // top left and right bottom are black box materials others are transparent + new THREE.MeshBasicMaterial({ color: 0x00_00_00 }), new THREE.MeshBasicMaterial({ color: 0x00_00_00 }), + new THREE.MeshBasicMaterial({ color: 0x00_00_00 }), new THREE.MeshBasicMaterial({ color: 0x00_00_00 }), + material, materialFlipped, + ]) + } let SCALE = 1 if (specificProps['minecraft:display_context'] === 'ground') { SCALE = 0.5 @@ -805,8 +816,6 @@ export class Entities { } update (entity: SceneEntity['originalEntity'], overrides) { - const justAdded = !this.entities[entity.id] - const isPlayerModel = entity.name === 'player' if (entity.name === 'zombie_villager' || entity.name === 'husk') { overrides.texture = `textures/1.16.4/entity/${entity.name === 'zombie_villager' ? 'zombie_villager/zombie_villager.png' : `zombie/${entity.name}.png`}` @@ -817,6 +826,7 @@ export class Entities { } // this can be undefined in case where packet entity_destroy was sent twice (so it was already deleted) let e = this.entities[entity.id] + const justAdded = !e if (entity.delete) { if (!e) return @@ -836,21 +846,23 @@ export class Entities { if (e === undefined) { const group = new THREE.Group() as unknown as SceneEntity group.originalEntity = entity - if (entity.name === 'item' || entity.name === 'tnt' || entity.name === 'falling_block') { - const item = entity.name === 'tnt' - ? { name: 'tnt' } + if (entity.name === 'item' || entity.name === 'tnt' || entity.name === 'falling_block' || entity.name === 'snowball' + || entity.name === 'egg' || entity.name === 'ender_pearl' || entity.name === 'experience_bottle' + || entity.name === 'splash_potion' || entity.name === 'lingering_potion') { + const item = entity.name === 'tnt' || entity.type === 'projectile' + ? { name: entity.name } : entity.name === 'falling_block' ? { blockState: entity['objectData'] } : entity.metadata?.find((m: any) => typeof m === 'object' && m?.itemCount) if (item) { const object = this.getItemMesh(item, { 'minecraft:display_context': 'ground', - }) + }, entity.type === 'projectile') if (object) { mesh = object.mesh - if (entity.name === 'item') { + if (entity.name === 'item' || entity.type === 'projectile') { mesh.scale.set(0.5, 0.5, 0.5) - mesh.position.set(0, 0.2, 0) + mesh.position.set(0, entity.name === 'item' ? 0.2 : 0.1, 0) } else { mesh.scale.set(2, 2, 2) mesh.position.set(0, 0.5, 0) @@ -858,8 +870,8 @@ export class Entities { // set faces // mesh.position.set(targetPos.x + 0.5 + 2, targetPos.y + 0.5, targetPos.z + 0.5) // viewer.scene.add(mesh) - const clock = new THREE.Clock() if (entity.name === 'item') { + const clock = new THREE.Clock() mesh.onBeforeRender = () => { const delta = clock.getDelta() mesh!.rotation.y += delta diff --git a/renderer/viewer/three/holdingBlock.ts b/renderer/viewer/three/holdingBlock.ts index c8a563865..f9d00f0e0 100644 --- a/renderer/viewer/three/holdingBlock.ts +++ b/renderer/viewer/three/holdingBlock.ts @@ -357,7 +357,7 @@ export default class HoldingBlock { 'minecraft:display_context': 'firstperson', 'minecraft:use_duration': this.worldRenderer.playerStateReactive.itemUsageTicks, 'minecraft:using_item': !!this.worldRenderer.playerStateReactive.itemUsageTicks, - }, this.lastItemModelName) + }, false, this.lastItemModelName) if (result) { const { mesh: itemMesh, isBlock, modelName } = result if (isBlock) { From b9c8ade9bf05b3eea2842bcfe2e03bb7428cfafb Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sun, 20 Jul 2025 10:06:57 +0300 Subject: [PATCH 14/14] fix: fix chat was crashing sometimes --- src/index.ts | 4 ++++ src/react/Chat.tsx | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 8de988941..d28261f4f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -278,6 +278,10 @@ export async function connect (connectOptions: ConnectOptions) { return } } + if (e.reason?.stack?.includes('chrome-extension://')) { + // ignore issues caused by chrome extension + return + } handleError(e.reason) }, { signal: errorAbortController.signal diff --git a/src/react/Chat.tsx b/src/react/Chat.tsx index a45d7a698..20d77a103 100644 --- a/src/react/Chat.tsx +++ b/src/react/Chat.tsx @@ -45,7 +45,7 @@ const MessageLine = ({ message, currentPlayerName, chatOpened }: { message: Mess return
  • val).map(([name]) => name).join(' ')} data-time={message.timestamp ? new Date(message.timestamp).toLocaleString('en-US', { hour12: false }) : undefined}> {message.parts.map((msg, i) => { // Check if this is a text part that might contain a mention - if (msg.text && currentPlayerName) { + if (typeof msg.text === 'string' && currentPlayerName) { const parts = msg.text.split(new RegExp(`(@${currentPlayerName})`, 'i')) if (parts.length > 1) { return parts.map((txtPart, j) => {