From 9069d9c1472cc688a5ed0c2b9f7059f4326b027c Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sat, 12 Apr 2025 17:58:39 +0300 Subject: [PATCH 001/296] Try fix auto jump --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ce04e875..f5d0576a 100644 --- a/package.json +++ b/package.json @@ -195,7 +195,7 @@ }, "pnpm": { "overrides": { - "@nxg-org/mineflayer-physics-util": "1.8.6", + "@nxg-org/mineflayer-physics-util": "1.5.8", "buffer": "^6.0.3", "vec3": "0.1.10", "three": "0.154.0", From 7a53d4de63d6e8c44ee17ae2fdde8d077490f375 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Mon, 14 Apr 2025 17:06:13 +0300 Subject: [PATCH 002/296] fix(feedback-controls): prevent default action for side mouse buttons to avoid page leave annoying modal on accidental clicks --- src/controls.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/controls.ts b/src/controls.ts index 0633ea8e..c8ff2279 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -761,6 +761,11 @@ const selectItem = async () => { } addEventListener('mousedown', async (e) => { + // always prevent default for side buttons (back / forward navigation) + if (e.button === 3 || e.button === 4) { + e.preventDefault() + } + if ((e.target as HTMLElement).matches?.('#VRButton')) return if (!isInRealGameSession() && !(e.target as HTMLElement).id.includes('ui-root')) return void pointerLock.requestPointerLock() From c5e8fcb90c8ca23b77ea9704c6ee9b91676d72bc Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Mon, 14 Apr 2025 17:21:22 +0300 Subject: [PATCH 003/296] feat: add controls debug interface! Debug happening actions in your game & keyboard buttons!!!! --- src/controls.ts | 1 + src/optionsGuiScheme.tsx | 5 +++ src/optionsStorage.ts | 1 + src/react/ControDebug.tsx | 71 +++++++++++++++++++++++++++++++++++++++ src/reactUi.tsx | 2 ++ 5 files changed, 80 insertions(+) create mode 100644 src/react/ControDebug.tsx diff --git a/src/controls.ts b/src/controls.ts index c8ff2279..0716d68d 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -177,6 +177,7 @@ contro.on('movementUpdate', ({ vector, soleVector, gamepadIndex }) => { if (action) { void contro.emit('trigger', { command: 'general.forward' } as any) } else { + void contro.emit('release', { command: 'general.forward' } as any) setSprinting(false) } } diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index ef8d9a8e..55d107ce 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -569,6 +569,11 @@ export const guiOptionsScheme: { ], }, }, + { + debugContro: { + text: 'Debug Controls', + }, + } ], 'export-import': [ { diff --git a/src/optionsStorage.ts b/src/optionsStorage.ts index 4d76ba0c..a69d4e16 100644 --- a/src/optionsStorage.ts +++ b/src/optionsStorage.ts @@ -66,6 +66,7 @@ const defaultOptions = { jeiEnabled: true as boolean | Array<'creative' | 'survival' | 'adventure' | 'spectator'>, preventBackgroundTimeoutKick: false, preventSleep: false, + debugContro: false, // antiAliasing: false, diff --git a/src/react/ControDebug.tsx b/src/react/ControDebug.tsx new file mode 100644 index 00000000..9c5f272c --- /dev/null +++ b/src/react/ControDebug.tsx @@ -0,0 +1,71 @@ +import { useEffect, useState } from 'react' +import { options } from '../optionsStorage' +import { contro } from '../controls' + +export default () => { + const [pressedKeys, setPressedKeys] = useState>(new Set()) + const [actions, setActions] = useState([]) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + setPressedKeys(prev => new Set([...prev, e.code])) + } + + const handleKeyUp = (e: KeyboardEvent) => { + setPressedKeys(prev => { + const newSet = new Set(prev) + newSet.delete(e.code) + return newSet + }) + } + + const handleBlur = () => { + setPressedKeys(new Set()) + } + + const handleControTrigger = ({ command }) => { + setActions(prev => [...prev, command]) + } + + const handleControReleased = ({ command }) => { + setActions(prev => prev.filter(action => action !== command)) + } + + window.addEventListener('keydown', handleKeyDown) + window.addEventListener('keyup', handleKeyUp) + window.addEventListener('blur', handleBlur) + + contro.on('trigger', handleControTrigger) + contro.on('release', handleControReleased) + + return () => { + window.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('keyup', handleKeyUp) + window.removeEventListener('blur', handleBlur) + contro.off('trigger', handleControTrigger) + contro.off('released', handleControReleased) + } + }, []) + + if (!options.debugContro) return null + + return ( +
+
Keys: {[...pressedKeys].join(', ')}
+
Actions: {actions.join(', ')}
+
+ ) +} diff --git a/src/reactUi.tsx b/src/reactUi.tsx index 15c09939..bbfbc2ee 100644 --- a/src/reactUi.tsx +++ b/src/reactUi.tsx @@ -54,6 +54,7 @@ import { useAppScale } from './scaleInterface' import PacketsReplayProvider from './react/PacketsReplayProvider' import TouchInteractionHint from './react/TouchInteractionHint' import { ua } from './react/utils' +import ControDebug from './react/ControDebug' const isFirefox = ua.getBrowser().name === 'Firefox' if (isFirefox) { @@ -208,6 +209,7 @@ const App = () => { +
From a541e82e04ccd7aece66417b58bfbe92afbfa5a7 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Tue, 15 Apr 2025 02:29:09 +0300 Subject: [PATCH 004/296] fix: add freezeSettings param --- src/appParams.ts | 1 + src/optionsStorage.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/appParams.ts b/src/appParams.ts index 994a5e16..4112a99c 100644 --- a/src/appParams.ts +++ b/src/appParams.ts @@ -42,6 +42,7 @@ export type AppQsParams = { suggest_save?: string noPacketsValidation?: string testCrashApp?: string + freezeSettings?: string // Replay params replayFilter?: string diff --git a/src/optionsStorage.ts b/src/optionsStorage.ts index a69d4e16..c8e6298e 100644 --- a/src/optionsStorage.ts +++ b/src/optionsStorage.ts @@ -1,7 +1,7 @@ import { proxy, subscribe } from 'valtio/vanilla' import { subscribeKey } from 'valtio/utils' import { omitObj } from '@zardoy/utils' -import { appQueryParamsArray } from './appParams' +import { appQueryParams, appQueryParamsArray } from './appParams' import type { AppConfig } from './appConfig' import { appStorage } from './react/appStorageProvider' @@ -236,6 +236,7 @@ Object.defineProperty(window, 'debugChangedOptions', { }) subscribe(options, (ops) => { + if (appQueryParams.freezeSettings === 'true') return for (const op of ops) { const [type, path, value] = op // let patch From 6a8de1fdfb78f8ce9c89d4e5e07e8800cdd152b3 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 16 Apr 2025 03:22:25 +0300 Subject: [PATCH 005/296] fix: sometimes auto login suggestion was not visible due to overlapping notification issue --- src/react/ChatProvider.tsx | 3 ++- src/utils.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/react/ChatProvider.tsx b/src/react/ChatProvider.tsx index 0b913875..4376006d 100644 --- a/src/react/ChatProvider.tsx +++ b/src/react/ChatProvider.tsx @@ -7,7 +7,7 @@ import { options } from '../optionsStorage' import { viewerVersionState } from '../viewerConnector' import Chat, { Message, fadeMessage } from './Chat' import { useIsModalActive } from './utilsApp' -import { hideNotification, showNotification } from './NotificationProvider' +import { hideNotification, notificationProxy, showNotification } from './NotificationProvider' import { updateLoadedServerData } from './serversStorage' import { lastConnectOptions } from './AppStatusProvider' @@ -65,6 +65,7 @@ export default () => { }) hideNotification() }) + notificationProxy.id = 'auto-login' const listener = () => { hideNotification() } diff --git a/src/utils.ts b/src/utils.ts index 0df6d6d2..93e2eb7b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -39,6 +39,7 @@ export const pointerLock = { void goFullscreen() } const displayBrowserProblem = () => { + if (notificationProxy.id === 'auto-login') return // prevent notification hide showNotification('Browser Delay Limitation', navigator['keyboard'] ? 'Click on screen, enable Auto Fullscreen or F11' : 'Click on screen or use fullscreen in Chrome') notificationProxy.id = 'pointerlockchange' } From f5ed17d2fbcce45fb6683872f561597dd8467c35 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 16 Apr 2025 03:23:41 +0300 Subject: [PATCH 006/296] fix(critical-regression): FIX broken inventory! There was a huge regression with a month-old inventory update which was breaking it in some ways --- pnpm-lock.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 388994fe..f3059dac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -341,7 +341,7 @@ importers: version: 0.2.52 minecraft-inventory-gui: specifier: github:zardoy/minecraft-inventory-gui#next - version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/f57dd78ca8e3b7cdd724d4272d8cbf6743b0cf00(@types/react@18.3.18)(react@18.3.1) + version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/98bc5bb8ee6da8b4b771c05b404cee796318ccd4(@types/react@18.3.18)(react@18.3.1) mineflayer: specifier: github:GenerelSchwerz/mineflayer version: https://codeload.github.com/GenerelSchwerz/mineflayer/tar.gz/d459d2ed76a997af1a7c94718ed7d5dee4478b8a(encoding@0.1.13) @@ -6673,8 +6673,8 @@ packages: minecraft-folder-path@1.2.0: resolution: {integrity: sha512-qaUSbKWoOsH9brn0JQuBhxNAzTDMwrOXorwuRxdJKKKDYvZhtml+6GVCUrY5HRiEsieBEjCUnhVpDuQiKsiFaw==} - minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/f57dd78ca8e3b7cdd724d4272d8cbf6743b0cf00: - resolution: {tarball: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/f57dd78ca8e3b7cdd724d4272d8cbf6743b0cf00} + minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/98bc5bb8ee6da8b4b771c05b404cee796318ccd4: + resolution: {tarball: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/98bc5bb8ee6da8b4b771c05b404cee796318ccd4} version: 1.0.1 minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284: @@ -17309,7 +17309,7 @@ snapshots: minecraft-folder-path@1.2.0: {} - minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/f57dd78ca8e3b7cdd724d4272d8cbf6743b0cf00(@types/react@18.3.18)(react@18.3.1): + minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/98bc5bb8ee6da8b4b771c05b404cee796318ccd4(@types/react@18.3.18)(react@18.3.1): dependencies: valtio: 1.13.2(@types/react@18.3.18)(react@18.3.1) transitivePeerDependencies: From 143d4a3bb3d7b18ac9ceb36eb0dc5a3336d65e26 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 16 Apr 2025 14:34:51 +0300 Subject: [PATCH 007/296] fix: fix double chests. fix inventory crashing when it doesnt know the texture to render --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- src/inventoryWindows.ts | 6 +++++- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index ce04e875..ee07230d 100644 --- a/package.json +++ b/package.json @@ -151,7 +151,7 @@ "http-browserify": "^1.7.0", "http-server": "^14.1.1", "https-browserify": "^1.0.0", - "mc-assets": "^0.2.52", + "mc-assets": "^0.2.53", "minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next", "mineflayer": "github:GenerelSchwerz/mineflayer", "mineflayer-mouse": "^0.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3059dac..d7b1da61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -337,8 +337,8 @@ importers: specifier: ^1.0.0 version: 1.0.0 mc-assets: - specifier: ^0.2.52 - version: 0.2.52 + specifier: ^0.2.53 + version: 0.2.53 minecraft-inventory-gui: specifier: github:zardoy/minecraft-inventory-gui#next version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/98bc5bb8ee6da8b4b771c05b404cee796318ccd4(@types/react@18.3.18)(react@18.3.1) @@ -6455,8 +6455,8 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - mc-assets@0.2.52: - resolution: {integrity: sha512-6mUI63fcUIjB0Ghjls7bLMnse2XUgvhPajsFkRQf10PcXYbfS/OAnX51X8sNx2pzfoHSlA81U7v+v906YwoAUw==} + mc-assets@0.2.53: + resolution: {integrity: sha512-Ucsu2pDLr/cs8bxbxU9KTszdf/vPTLphYgEHUEWxuYlMkPQUCpsQwkn3YgyykJ7RXaca7zZGlZXaTPXBAqJT6A==} engines: {node: '>=18.0.0'} mcraft-fun-mineflayer@0.1.14: @@ -16988,7 +16988,7 @@ snapshots: math-intrinsics@1.1.0: {} - mc-assets@0.2.52: + mc-assets@0.2.53: dependencies: maxrects-packer: '@zardoy/maxrects-packer@2.7.4' zod: 3.24.2 diff --git a/src/inventoryWindows.ts b/src/inventoryWindows.ts index 40b0e4d7..2906177b 100644 --- a/src/inventoryWindows.ts +++ b/src/inventoryWindows.ts @@ -216,6 +216,8 @@ export const renderSlot = (model: ResolvedItemModelRender, debugIsQuickbar = fal } } + const blockToTopTexture = (r) => r.top ?? r + try { assertDefined(appViewer.resourcesManager.currentResources?.itemsRenderer) itemTexture = @@ -224,9 +226,11 @@ export const renderSlot = (model: ResolvedItemModelRender, debugIsQuickbar = fal ?? appViewer.resourcesManager.currentResources.itemsRenderer.getItemTexture('item/missing_texture')! } catch (err) { inGameError(`Failed to render item ${itemModelName} (original: ${originalItemName}) on ${bot.version} (resourcepack: ${options.enabledResourcepack}): ${err.stack}`) - itemTexture = appViewer.resourcesManager.currentResources!.itemsRenderer.getItemTexture('block/errored')! + itemTexture = blockToTopTexture(appViewer.resourcesManager.currentResources!.itemsRenderer.getItemTexture('errored')!) } + itemTexture ??= blockToTopTexture(appViewer.resourcesManager.currentResources!.itemsRenderer.getItemTexture('unknown')!) + if ('type' in itemTexture) { // is item From 73ccb48d02eeaa2962ac43d07bcd3227edd6a94d Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 16 Apr 2025 18:24:19 +0300 Subject: [PATCH 008/296] feat: Add F3+H chunks debug screen! not really useful for now since chunks not visible bug was not fixed yet --- renderer/viewer/lib/worldDataEmitter.ts | 8 +- src/controls.ts | 7 ++ src/react/ChunksDebug.tsx | 130 ++++++++++++++++++++++++ src/react/ChunksDebugScreen.tsx | 100 ++++++++++++++++++ src/react/DebugOverlay.tsx | 1 + src/reactUi.tsx | 3 + 6 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 src/react/ChunksDebug.tsx create mode 100644 src/react/ChunksDebugScreen.tsx diff --git a/renderer/viewer/lib/worldDataEmitter.ts b/renderer/viewer/lib/worldDataEmitter.ts index c9329d01..5e28c007 100644 --- a/renderer/viewer/lib/worldDataEmitter.ts +++ b/renderer/viewer/lib/worldDataEmitter.ts @@ -37,8 +37,8 @@ export type WorldDataEmitterEvents = { * It's up to the consumer to serialize the data if needed */ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter) { - private loadedChunks: Record - private readonly lastPos: Vec3 + loadedChunks: Record + readonly lastPos: Vec3 private eventListeners: Record = {} private readonly emitter: WorldDataEmitter @@ -140,7 +140,7 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter { const chunkPos = new Vec3(chunkX * 16, 0, chunkZ * 16) - if (!this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`]) { + if (!this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`] && this.loadedChunks[`${chunkX},${chunkZ}`]) { void this.loadChunk(chunkPos, true) } }) @@ -242,6 +242,8 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter = { + 'server-waiting': 'gray', + 'order-queued': 'darkorange', + 'client-waiting': 'yellow', + 'client-processing': 'yellow', + 'done-empty': 'darkgreen', + 'done': 'limegreen', +} + +export default ({ + chunks, + playerChunk, + maxDistance, + tileSize = 16, + fontSize = 5, +}: { + chunks: ChunkDebug[] + playerChunk: { x: number, z: number } + maxDistance: number, + tileSize?: number + fontSize?: number +}) => { + const [selectedChunk, setSelectedChunk] = useState(null) + const [showSidebar, setShowSidebar] = useState(false) + + // Calculate grid dimensions based on maxDistance + const gridSize = maxDistance * 2 + 1 + const centerIndex = maxDistance + + // Process chunks to get only the last one for each position and within maxDistance + const processedChunks = chunks.reduce>((acc, chunk) => { + const relX = Math.floor((chunk.x - playerChunk.x) / 16) + const relZ = Math.floor((chunk.z - playerChunk.z) / 16) + + // Skip chunks outside maxDistance + if (Math.abs(relX) > maxDistance || Math.abs(relZ) > maxDistance) return acc + + const key = `${chunk.x},${chunk.z}` + acc[key] = { + ...chunk, + relX, + relZ, + displayLines: [`${relX},${relZ} (${chunk.x},${chunk.z})`, ...chunk.lines] + } + return acc + }, {}) + + return ( +
+
+ {Array.from({ length: gridSize * gridSize }).map((_, i) => { + const relX = -maxDistance + (i % gridSize) + const relZ = -maxDistance + Math.floor(i / gridSize) + const x = playerChunk.x + relX * 16 + const z = playerChunk.z + relZ * 16 + const chunk = processedChunks[`${x},${z}`] + + return ( +
{ + if (chunk) { + setSelectedChunk(chunk) + setShowSidebar(true) + } + }} + style={{ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + background: chunk ? stateColors[chunk.state] : 'black', + color: 'white', + fontSize: `${fontSize}px`, + cursor: chunk ? 'pointer' : 'default', + position: 'relative', + width: `${tileSize}px`, + height: `${tileSize}px`, + // pre-wrap + whiteSpace: 'pre', + }} + > + {relX}, {relZ}{'\n'} + {chunk?.lines.join('\n')} +
+ ) + })} +
+ + {showSidebar && selectedChunk && ( +
+ {selectedChunk.displayLines.map((line, i) => ( +
+ {line} +
+ ))} +
+
Sidebar Info:
+ {selectedChunk.sidebarLines.map((line, i) => ( +
{line}
+ ))} +
+
+ )} +
+ ) +} diff --git a/src/react/ChunksDebugScreen.tsx b/src/react/ChunksDebugScreen.tsx new file mode 100644 index 00000000..c65eee16 --- /dev/null +++ b/src/react/ChunksDebugScreen.tsx @@ -0,0 +1,100 @@ +import { useEffect, useState } from 'react' +import { useUtilsEffect } from '@zardoy/react-util' +import { WorldRendererCommon } from 'renderer/viewer/lib/worldrendererCommon' +import { WorldRendererThree } from 'renderer/viewer/three/worldrendererThree' +import Screen from './Screen' +import ChunksDebug, { ChunkDebug } from './ChunksDebug' +import { useIsModalActive } from './utilsApp' + +const Inner = () => { + const [playerX, setPlayerX] = useState(Math.floor(worldView!.lastPos.x / 16) * 16) + const [playerZ, setPlayerZ] = useState(Math.floor(worldView!.lastPos.z / 16) * 16) + const [update, setUpdate] = useState(0) + + useUtilsEffect(({ interval }) => { + interval( + 500, + () => { + setPlayerX(Math.floor(worldView!.lastPos.x / 16) * 16) + setPlayerZ(Math.floor(worldView!.lastPos.z / 16) * 16) + setUpdate(u => u + 1) + } + ) + }, []) + + const chunksWaitingServer = Object.keys(worldView!.waitingSpiralChunksLoad).map((x): ChunkDebug => ({ + x: Number(x.split(',')[0]), + z: Number(x.split(',')[1]), + state: 'server-waiting', + lines: [], + sidebarLines: [], + })) + + const world = globalThis.world as WorldRendererThree + + const loadedSectionsChunks = Object.fromEntries(Object.keys(world.sectionObjects).map(sectionPos => { + const [x, y, z] = sectionPos.split(',').map(Number) + return [`${x},${z}`, true] + })) + + const chunksWaitingClient = Object.keys(worldView!.loadedChunks).map((x): ChunkDebug => ({ + x: Number(x.split(',')[0]), + z: Number(x.split(',')[1]), + state: 'client-waiting', + lines: [], + sidebarLines: [], + })) + + const clientProcessingChunks = Object.keys(world.loadedChunks).map((x): ChunkDebug => ({ + x: Number(x.split(',')[0]), + z: Number(x.split(',')[1]), + state: 'client-processing', + lines: [], + sidebarLines: [], + })) + + const chunksDoneEmpty = Object.keys(world.finishedChunks) + .filter(chunkPos => !loadedSectionsChunks[chunkPos]) + .map((x): ChunkDebug => ({ + x: Number(x.split(',')[0]), + z: Number(x.split(',')[1]), + state: 'done-empty', + lines: [], + sidebarLines: [], + })) + + const chunksDone = Object.keys(world.finishedChunks).map((x): ChunkDebug => ({ + x: Number(x.split(',')[0]), + z: Number(x.split(',')[1]), + state: 'done', + lines: [], + sidebarLines: [], + })) + + const allChunks = [ + ...chunksWaitingServer, + ...chunksWaitingClient, + ...clientProcessingChunks, + ...chunksDone, + ...chunksDoneEmpty, + ] + return + + +} + +export default () => { + const isActive = useIsModalActive('chunks-debug') + if (!isActive) return null + + return +} diff --git a/src/react/DebugOverlay.tsx b/src/react/DebugOverlay.tsx index af0d6675..610bcea9 100644 --- a/src/react/DebugOverlay.tsx +++ b/src/react/DebugOverlay.tsx @@ -180,6 +180,7 @@ export default () => {

XYZ: {pos.x.toFixed(3)} / {pos.y.toFixed(3)} / {pos.z.toFixed(3)}

Chunk: {Math.floor(pos.x % 16)} ~ {Math.floor(pos.z % 16)} in {Math.floor(pos.x / 16)} ~ {Math.floor(pos.z / 16)}

+

Section: {Math.floor(pos.x / 16) * 16}, {Math.floor(pos.y / 16) * 16}, {Math.floor(pos.z / 16) * 16}

Packets: {packetsString}

Client TPS: {clientTps}

Facing (viewer): {bot.entity.yaw.toFixed(3)} {bot.entity.pitch.toFixed(3)}

diff --git a/src/reactUi.tsx b/src/reactUi.tsx index bbfbc2ee..93db7706 100644 --- a/src/reactUi.tsx +++ b/src/reactUi.tsx @@ -55,6 +55,8 @@ import PacketsReplayProvider from './react/PacketsReplayProvider' import TouchInteractionHint from './react/TouchInteractionHint' import { ua } from './react/utils' import ControDebug from './react/ControDebug' +import ChunksDebug from './react/ChunksDebug' +import ChunksDebugScreen from './react/ChunksDebugScreen' const isFirefox = ua.getBrowser().name === 'Firefox' if (isFirefox) { @@ -157,6 +159,7 @@ const InGameUi = () => { {!disabledUiParts.includes('crosshair') && } {!disabledUiParts.includes('books') && } {!disabledUiParts.includes('bossbars') && displayBossBars && } +
From e8b1f190a7d50c0ebe42b1b973a6887076ff1f46 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Thu, 17 Apr 2025 19:58:03 +0300 Subject: [PATCH 009/296] add more debug into to f3+h --- src/react/ChunksDebug.tsx | 4 +-- src/react/ChunksDebugScreen.tsx | 54 ++++++++++++--------------------- 2 files changed, 21 insertions(+), 37 deletions(-) diff --git a/src/react/ChunksDebug.tsx b/src/react/ChunksDebug.tsx index d3eb52a7..7b42cf4d 100644 --- a/src/react/ChunksDebug.tsx +++ b/src/react/ChunksDebug.tsx @@ -111,13 +111,13 @@ export default ({
{showSidebar && selectedChunk && ( -
+
{selectedChunk.displayLines.map((line, i) => (
{line}
))} -
+
Sidebar Info:
{selectedChunk.sidebarLines.map((line, i) => (
{line}
diff --git a/src/react/ChunksDebugScreen.tsx b/src/react/ChunksDebugScreen.tsx index c65eee16..80e926d8 100644 --- a/src/react/ChunksDebugScreen.tsx +++ b/src/react/ChunksDebugScreen.tsx @@ -22,13 +22,21 @@ const Inner = () => { ) }, []) - const chunksWaitingServer = Object.keys(worldView!.waitingSpiralChunksLoad).map((x): ChunkDebug => ({ - x: Number(x.split(',')[0]), - z: Number(x.split(',')[1]), - state: 'server-waiting', - lines: [], - sidebarLines: [], - })) + const mapChunk = (key: string, state: ChunkDebug['state']): ChunkDebug => { + const chunk = worldView!.debugChunksInfo[key] + return { + x: Number(key.split(',')[0]), + z: Number(key.split(',')[1]), + state, + lines: [String(chunk?.loads.length ?? 0)], + sidebarLines: [ + `loads: ${chunk.loads.map(l => `${l.reason} ${l.dataLength} ${l.time}`).join('\n')}`, + // `blockUpdates: ${chunk.blockUpdates}`, + ], + } + } + + const chunksWaitingServer = Object.keys(worldView!.waitingSpiralChunksLoad).map(key => mapChunk(key, 'server-waiting')) const world = globalThis.world as WorldRendererThree @@ -37,39 +45,15 @@ const Inner = () => { return [`${x},${z}`, true] })) - const chunksWaitingClient = Object.keys(worldView!.loadedChunks).map((x): ChunkDebug => ({ - x: Number(x.split(',')[0]), - z: Number(x.split(',')[1]), - state: 'client-waiting', - lines: [], - sidebarLines: [], - })) + const chunksWaitingClient = Object.keys(worldView!.loadedChunks).map(key => mapChunk(key, 'client-waiting')) - const clientProcessingChunks = Object.keys(world.loadedChunks).map((x): ChunkDebug => ({ - x: Number(x.split(',')[0]), - z: Number(x.split(',')[1]), - state: 'client-processing', - lines: [], - sidebarLines: [], - })) + const clientProcessingChunks = Object.keys(world.loadedChunks).map(key => mapChunk(key, 'client-processing')) const chunksDoneEmpty = Object.keys(world.finishedChunks) .filter(chunkPos => !loadedSectionsChunks[chunkPos]) - .map((x): ChunkDebug => ({ - x: Number(x.split(',')[0]), - z: Number(x.split(',')[1]), - state: 'done-empty', - lines: [], - sidebarLines: [], - })) + .map(key => mapChunk(key, 'done-empty')) - const chunksDone = Object.keys(world.finishedChunks).map((x): ChunkDebug => ({ - x: Number(x.split(',')[0]), - z: Number(x.split(',')[1]), - state: 'done', - lines: [], - sidebarLines: [], - })) + const chunksDone = Object.keys(world.finishedChunks).map(key => mapChunk(key, 'done')) const allChunks = [ ...chunksWaitingServer, From 1582e16d3b72ee9d35f6af98fb6938f89b99e28f Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Thu, 17 Apr 2025 19:58:30 +0300 Subject: [PATCH 010/296] fix critical regression that led to not loading chunks twice that was a valid behavior before --- renderer/viewer/lib/worldDataEmitter.ts | 36 +++++++++++++++++++------ 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/renderer/viewer/lib/worldDataEmitter.ts b/renderer/viewer/lib/worldDataEmitter.ts index 5e28c007..7dc49ac3 100644 --- a/renderer/viewer/lib/worldDataEmitter.ts +++ b/renderer/viewer/lib/worldDataEmitter.ts @@ -41,6 +41,14 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter = {} private readonly emitter: WorldDataEmitter + debugChunksInfo: Record + // blockUpdates: number + }> = {} waitingSpiralChunksLoad = {} as Record void> @@ -66,12 +74,12 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter | void if (val) throw new Error('setBlockStateId returned promise (not supported)') - const chunkX = Math.floor(position.x / 16) - const chunkZ = Math.floor(position.z / 16) - if (!this.loadedChunks[`${chunkX},${chunkZ}`] && !this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`]) { - void this.loadChunk({ x: chunkX, z: chunkZ }) - return - } + // const chunkX = Math.floor(position.x / 16) + // const chunkZ = Math.floor(position.z / 16) + // if (!this.loadedChunks[`${chunkX},${chunkZ}`] && !this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`]) { + // void this.loadChunk({ x: chunkX, z: chunkZ }) + // return + // } this.emit('blockUpdate', { pos: position, stateId }) } @@ -117,6 +125,8 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter { @@ -141,7 +151,7 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter { const chunkPos = new Vec3(chunkX * 16, 0, chunkZ * 16) if (!this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`] && this.loadedChunks[`${chunkX},${chunkZ}`]) { - void this.loadChunk(chunkPos, true) + void this.loadChunk(chunkPos, true, 'update_light') } }) @@ -240,7 +250,7 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter TypedEmitter TypedEmitter Date: Thu, 17 Apr 2025 20:51:06 +0300 Subject: [PATCH 011/296] sync for changes between forks fix: sometimes autlogin save prompt is not displayed fix: add connect server only ui add some other components for future usage fix: make some fields custommization in main menu from config.json fix: adjust logic on player disconnect in some rare cases --- config.json | 2 + src/appConfig.ts | 2 + src/appParams.ts | 2 + src/cameraRotationControls.ts | 5 +- src/controls.ts | 21 ++++++-- src/core/progressReporter.ts | 5 +- src/customChannels.ts | 4 -- src/globalState.ts | 14 ++++-- src/index.ts | 34 +++++++++++-- src/react/Chat.tsx | 6 +-- src/react/ConnectOnlyServerUi.tsx | 80 ++++++++++++++++++++++++++++++ src/react/MainMenu.tsx | 8 ++- src/react/NotificationProvider.tsx | 14 ++++-- src/react/VoiceMicrophone.tsx | 77 ++++++++++++++++++++++++++++ src/reactUi.tsx | 4 ++ src/screens.css | 1 + src/shims/patchShims.ts | 2 + 17 files changed, 246 insertions(+), 35 deletions(-) create mode 100644 src/react/ConnectOnlyServerUi.tsx create mode 100644 src/react/VoiceMicrophone.tsx diff --git a/config.json b/config.json index 2ede8070..ea27ca5a 100644 --- a/config.json +++ b/config.json @@ -25,6 +25,8 @@ "description": "Very nice a polite server. Must try for everyone!" } ], + "rightSideText": "A Minecraft client clone in the browser!", + "splashText": "Gen is cooking!", "pauseLinks": [ [ { diff --git a/src/appConfig.ts b/src/appConfig.ts index b8f83ad1..c5b61b69 100644 --- a/src/appConfig.ts +++ b/src/appConfig.ts @@ -15,11 +15,13 @@ export type AppConfig = { mapsProvider?: string appParams?: Record // query string params + rightSideText?: string defaultSettings?: Record forceSettings?: Record // hideSettings?: Record allowAutoConnect?: boolean + splashText?: string pauseLinks?: Array>> } diff --git a/src/appParams.ts b/src/appParams.ts index 4112a99c..aec6fd0b 100644 --- a/src/appParams.ts +++ b/src/appParams.ts @@ -42,6 +42,8 @@ export type AppQsParams = { suggest_save?: string noPacketsValidation?: string testCrashApp?: string + onlyConnect?: string + connectText?: string freezeSettings?: string // Replay params diff --git a/src/cameraRotationControls.ts b/src/cameraRotationControls.ts index 8b21e53d..3368666f 100644 --- a/src/cameraRotationControls.ts +++ b/src/cameraRotationControls.ts @@ -74,9 +74,8 @@ export const onControInit = () => { } function pointerLockChangeCallback () { - if (notificationProxy.id === 'pointerlockchange') { - hideNotification() - } + hideNotification('pointerlockchange') + if (appViewer.rendererState.preventEscapeMenu) return if (!pointerLock.hasPointerLock && activeModalStack.length === 0 && miscUiState.gameLoaded) { showModal({ reactType: 'pause-screen' }) diff --git a/src/controls.ts b/src/controls.ts index 83a5f4eb..f8160cfd 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -41,36 +41,43 @@ const controlOptions = { export const contro = new ControMax({ commands: { general: { + // movement jump: ['Space', 'A'], inventory: ['KeyE', 'X'], drop: ['KeyQ', 'B'], sneak: ['ShiftLeft'], toggleSneakOrDown: [null, 'Right Stick'], sprint: ['ControlLeft', 'Left Stick'], + // game interactions nextHotbarSlot: [null, 'Right Bumper'], prevHotbarSlot: [null, 'Left Bumper'], attackDestroy: [null, 'Right Trigger'], interactPlace: [null, 'Left Trigger'], - chat: [['KeyT', 'Enter']], - command: ['Slash'], swapHands: ['KeyF'], - zoom: ['KeyC'], selectItem: ['KeyH'], // default will be removed rotateCameraLeft: [null], rotateCameraRight: [null], rotateCameraUp: [null], rotateCameraDown: [null], - viewerConsole: ['Backquote'] + // ui? + chat: [['KeyT', 'Enter']], + command: ['Slash'], + // client side + zoom: ['KeyC'], + viewerConsole: ['Backquote'], }, ui: { toggleFullscreen: ['F11'], back: [null/* 'Escape' */, 'B'], - toggleMap: ['KeyM'], + toggleMap: ['KeyJ'], leftClick: [null, 'A'], rightClick: [null, 'Y'], speedupCursor: [null, 'Left Stick'], pauseMenu: [null, 'Start'] }, + communication: { + toggleMicrophone: ['KeyK'], + }, advanced: { lockUrl: ['KeyY'], }, @@ -550,6 +557,10 @@ contro.on('trigger', ({ command }) => { } } + if (command === 'communication.toggleMicrophone') { + // toggleMicrophoneMuted() + } + if (command === 'ui.pauseMenu') { showModal({ reactType: 'pause-screen' }) } diff --git a/src/core/progressReporter.ts b/src/core/progressReporter.ts index f4e4e701..a222272d 100644 --- a/src/core/progressReporter.ts +++ b/src/core/progressReporter.ts @@ -163,15 +163,16 @@ export const createFullScreenProgressReporter = (): ProgressReporter => { } export const createNotificationProgressReporter = (endMessage?: string): ProgressReporter => { + const id = `progress-reporter-${Math.random().toString(36).slice(2)}` return createProgressReporter({ setMessage (message: string) { - showNotification(`${message}...`, '', false, '', undefined, true) + showNotification(`${message}...`, '', false, '', undefined, true, id) }, end () { if (endMessage) { showNotification(endMessage, '', false, '', undefined, true) } else { - hideNotification() + hideNotification(id) } }, diff --git a/src/customChannels.ts b/src/customChannels.ts index 7cdd6c32..57c057d5 100644 --- a/src/customChannels.ts +++ b/src/customChannels.ts @@ -218,10 +218,6 @@ const registerMediaChannels = () => { { name: 'z', type: 'f32' }, { name: 'width', type: 'f32' }, { name: 'height', type: 'f32' }, - // N, 0 - // W, 3 - // S, 2 - // E, 1 { name: 'rotation', type: 'i16' }, // 0: 0° - towards positive z, 1: 90° - positive x, 2: 180° - negative z, 3: 270° - negative x (3-6 is same but double side) { name: 'source', type: ['pstring', { countType: 'i16' }] }, { name: 'loop', type: 'bool' }, diff --git a/src/globalState.ts b/src/globalState.ts index 2d77b720..bd845195 100644 --- a/src/globalState.ts +++ b/src/globalState.ts @@ -8,7 +8,11 @@ import { AppConfig } from './appConfig' // todo: refactor structure with support of hideNext=false -export const notHideableModalsWithoutForce = new Set(['app-status']) +export const notHideableModalsWithoutForce = new Set([ + 'app-status', + 'divkit:nonclosable', + 'only-connect-server', +]) type Modal = ({ elem?: HTMLElement & Record } & { reactType: string }) @@ -35,10 +39,10 @@ const showModalInner = (modal: Modal) => { return true } -export const showModal = (elem: /* (HTMLElement & Record) | */{ reactType: string }) => { - const resolved = elem +export const showModal = (elem: /* (HTMLElement & Record) | */{ reactType: string } | string) => { + const resolved = typeof elem === 'string' ? { reactType: elem } : elem const curModal = activeModalStack.at(-1) - if (/* elem === curModal?.elem || */(elem.reactType && elem.reactType === curModal?.reactType) || !showModalInner(resolved)) return + if ((resolved.reactType && resolved.reactType === curModal?.reactType) || !showModalInner(resolved)) return activeModalStack.push(resolved) } @@ -49,7 +53,7 @@ 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 = notHideableModalsWithoutForce.has(modal.reactType) ? !force : undefined + let cancel = [...notHideableModalsWithoutForce].some(m => modal.reactType.startsWith(m)) ? !force : undefined if (force) { cancel = undefined } diff --git a/src/index.ts b/src/index.ts index 42346606..3da14257 100644 --- a/src/index.ts +++ b/src/index.ts @@ -97,6 +97,7 @@ import { createConsoleLogProgressReporter, createFullScreenProgressReporter, Pro import { appViewer } from './appViewer' import './appViewerLoad' import { registerOpenBenchmarkListener } from './benchmark' +import { tryHandleBuiltinCommand } from './builtinCommands' window.debug = debug window.beforeRenderFrame = [] @@ -208,8 +209,12 @@ export async function connect (connectOptions: ConnectOptions) { let ended = false let bot!: typeof __type_bot - const destroyAll = () => { + const destroyAll = (wasKicked = false) => { if (ended) return + const hadConnected = !!bot + if (!wasKicked && miscUiState.appConfig?.allowAutoConnect && appQueryParams.autoConnect && hadConnected) { + location.reload() + } errorAbortController.abort() ended = true progress.end() @@ -251,6 +256,10 @@ export async function connect (connectOptions: ConnectOptions) { if (isCypress()) throw err miscUiState.hasErrors = true if (miscUiState.gameLoaded) return + // close all modals + for (const modal of activeModalStack) { + hideModal(modal) + } setLoadingScreenStatus(`Error encountered. ${err}`, true) appStatusState.showReconnect = true @@ -280,7 +289,7 @@ export async function connect (connectOptions: ConnectOptions) { if (connectOptions.server && !connectOptions.viewerWsConnect && !parsedServer.isWebSocket) { console.log(`using proxy ${proxy.host}:${proxy.port || location.port}`) - net['setProxy']({ hostname: proxy.host, port: proxy.port }) + net['setProxy']({ hostname: proxy.host, port: proxy.port, headers: { Authorization: `Bearer ${new URLSearchParams(location.search).get('token') ?? ''}` } }) } const renderDistance = singleplayer ? renderDistanceSingleplayer : multiplayerRenderDistance @@ -622,9 +631,13 @@ export async function connect (connectOptions: ConnectOptions) { bot.on('kicked', (kickReason) => { console.log('You were kicked!', kickReason) const { formatted: kickReasonFormatted, plain: kickReasonString } = parseFormattedMessagePacket(kickReason) + // close all modals + for (const modal of activeModalStack) { + hideModal(modal) + } setLoadingScreenStatus(`The Minecraft server kicked you. Kick reason: ${kickReasonString}`, true, undefined, undefined, kickReasonFormatted) appStatusState.showReconnect = true - destroyAll() + destroyAll(true) }) const packetBeforePlay = (_, __, ___, fullBuffer) => { @@ -644,6 +657,10 @@ export async function connect (connectOptions: ConnectOptions) { if (endReason === 'socketClosed') { endReason = lastKnownKickReason ?? 'Connection with proxy server lost' } + // close all modals + for (const modal of activeModalStack) { + hideModal(modal) + } setLoadingScreenStatus(`You have been disconnected from the server. End reason:\n${endReason}`, true) appStatusState.showReconnect = true onPossibleErrorDisconnect() @@ -799,7 +816,10 @@ export async function connect (connectOptions: ConnectOptions) { const commands = appQueryParamsArray.command ?? [] for (let command of commands) { if (!command.startsWith('/')) command = `/${command}` - bot.chat(command) + const builtinHandled = tryHandleBuiltinCommand(command) + if (!builtinHandled) { + bot.chat(command) + } } }) } @@ -888,7 +908,11 @@ if (!reconnectOptions) { const waitAppConfigLoad = !appQueryParams.proxy const openServerEditor = () => { hideModal() - showModal({ reactType: 'editServer' }) + if (appQueryParams.onlyConnect) { + showModal({ reactType: 'only-connect-server' }) + } else { + showModal({ reactType: 'editServer' }) + } } showModal({ reactType: 'empty' }) if (waitAppConfigLoad) { diff --git a/src/react/Chat.tsx b/src/react/Chat.tsx index 78c69bec..0a2843c8 100644 --- a/src/react/Chat.tsx +++ b/src/react/Chat.tsx @@ -283,10 +283,8 @@ export default ({ const message = chatInput.current.value if (message) { setSendHistory([...sendHistoryRef.current, message]) - const result = sendMessage?.(message) - if (result !== false) { - onClose?.() - } + onClose?.() + sendMessage?.(message) // Always scroll to bottom after sending a message scrollToBottom() } diff --git a/src/react/ConnectOnlyServerUi.tsx b/src/react/ConnectOnlyServerUi.tsx new file mode 100644 index 00000000..213fa19b --- /dev/null +++ b/src/react/ConnectOnlyServerUi.tsx @@ -0,0 +1,80 @@ +import { useMemo } from 'react' +import { useSnapshot } from 'valtio' +import { appQueryParams } from '../appParams' +import { ConnectOptions } from '../connect' +import { lastConnectOptions } from './AppStatusProvider' +import PixelartIcon, { pixelartIcons } from './PixelartIcon' +import { useIsModalActive } from './utilsApp' +import Button from './Button' + +const VERTICAL_LAYOUT = false + +export default () => { + const { ip, version, proxy, username, connectText } = appQueryParams + const isModalActive = useIsModalActive('only-connect-server') + + if (!isModalActive) return null + + const handleConnect = () => { + const connectOptions: ConnectOptions = { + username: username || '', + server: ip, + proxy, + botVersion: version, + } + window.dispatchEvent(new CustomEvent('connect', { detail: connectOptions })) + } + + return ( +
+
+
+
+ + {ip} + {proxy && ({proxy})} +
+
+ + {username} + {version && ({version})} +
+
+ +
+
+ ) +} diff --git a/src/react/MainMenu.tsx b/src/react/MainMenu.tsx index 85c5367e..7ed257c0 100644 --- a/src/react/MainMenu.tsx +++ b/src/react/MainMenu.tsx @@ -1,7 +1,9 @@ import React from 'react' import { openURL } from 'renderer/viewer/lib/simpleUtils' +import { useSnapshot } from 'valtio' import { haveDirectoryPicker } from '../utils' import { ConnectOptions } from '../connect' +import { miscUiState } from '../globalState' import styles from './mainMenu.module.css' import Button from './Button' import ButtonWithTooltip from './ButtonWithTooltip' @@ -44,6 +46,8 @@ export default ({ bottomRightLinks, singleplayerAvailable = true }: Props) => { + const { appConfig } = useSnapshot(miscUiState) + if (!bottomRightLinks?.trim()) bottomRightLinks = undefined // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion const linksParsed = bottomRightLinks?.split(/;|\n/g).map(l => { @@ -89,7 +93,7 @@ export default ({
- Prismarine is a beautiful block + {appConfig?.splashText}
@@ -177,7 +181,7 @@ export default ({
})}
- A Minecraft client clone in the browser! + {appConfig?.rightSideText}
diff --git a/src/react/NotificationProvider.tsx b/src/react/NotificationProvider.tsx index 6460c0e9..8a8e4df6 100644 --- a/src/react/NotificationProvider.tsx +++ b/src/react/NotificationProvider.tsx @@ -25,7 +25,8 @@ export const showNotification = ( isError = false, icon = '', action = undefined as (() => void) | undefined, - autoHide = true + autoHide = true, + id = '' ) => { notificationProxy.message = message notificationProxy.subMessage = subMessage @@ -34,11 +35,14 @@ export const showNotification = ( notificationProxy.open = true notificationProxy.autoHide = autoHide notificationProxy.action = action + notificationProxy.id = id } globalThis.showNotification = showNotification -export const hideNotification = () => { - // openNotification('') // reset - notificationProxy.open = false +export const hideNotification = (id?: string) => { + if (id === undefined || notificationProxy.id === id) { + // openNotification('') // reset + notificationProxy.open = false + } } export default () => { @@ -47,7 +51,7 @@ export default () => { useEffect(() => { if (autoHide && open) { setTimeout(() => { - hideNotification() + hideNotification(notificationProxy.id) }, 7000) } }, [autoHide, open]) diff --git a/src/react/VoiceMicrophone.tsx b/src/react/VoiceMicrophone.tsx new file mode 100644 index 00000000..af2c3fc2 --- /dev/null +++ b/src/react/VoiceMicrophone.tsx @@ -0,0 +1,77 @@ +import { proxy, useSnapshot } from 'valtio' +import PixelartIcon, { pixelartIcons } from './PixelartIcon' + +export const voiceChatStatus = proxy({ + active: false, + muted: false, + hasInputVoice: false, + isErrored: false, + isConnected: false, + isAlone: false, + + isSharingScreen: false, +}) + +window.voiceChatStatus = voiceChatStatus + +const Icon = () => { + return + + + +} + +export default () => { + const SIZE = 48 + const { active, muted, hasInputVoice, isSharingScreen, isConnected, isErrored, isAlone } = useSnapshot(voiceChatStatus) + if (!active) return null + + const getRingColor = () => { + if (isErrored) return 'rgba(214, 4, 4, 0.5)' // red with opacity + if (isConnected) { + if (isAlone) return 'rgba(183, 255, 0, 0.5)' // lime yellow + return 'rgba(50, 205, 50, 0.5)' // green with opacity + } + return 'rgba(128, 128, 128, 0.5)' // gray with opacity + } + + return ( +
{ + // toggleMicrophoneMuted() + }} + > +
+ +
+ {/* stop sharing screen */} + {isSharingScreen &&
+ Stop Sharing Screen +
} +
+ ) +} diff --git a/src/reactUi.tsx b/src/reactUi.tsx index 93db7706..04a02ec8 100644 --- a/src/reactUi.tsx +++ b/src/reactUi.tsx @@ -54,6 +54,8 @@ import { useAppScale } from './scaleInterface' import PacketsReplayProvider from './react/PacketsReplayProvider' import TouchInteractionHint from './react/TouchInteractionHint' import { ua } from './react/utils' +import VoiceMicrophone from './react/VoiceMicrophone' +import ConnectOnlyServerUi from './react/ConnectOnlyServerUi' import ControDebug from './react/ControDebug' import ChunksDebug from './react/ChunksDebug' import ChunksDebugScreen from './react/ChunksDebugScreen' @@ -159,6 +161,7 @@ const InGameUi = () => { {!disabledUiParts.includes('crosshair') && } {!disabledUiParts.includes('books') && } {!disabledUiParts.includes('bossbars') && displayBossBars && } +
@@ -227,6 +230,7 @@ const App = () => { + diff --git a/src/screens.css b/src/screens.css index 289fe129..8d9c2b75 100644 --- a/src/screens.css +++ b/src/screens.css @@ -17,6 +17,7 @@ inset: 0; height: 100dvh; background: rgba(0, 0, 0, 0.75); + z-index: 12; } .fullscreen { diff --git a/src/shims/patchShims.ts b/src/shims/patchShims.ts index 1890edf6..4a374b7e 100644 --- a/src/shims/patchShims.ts +++ b/src/shims/patchShims.ts @@ -1,5 +1,7 @@ import { EventEmitter } from 'events' +EventEmitter.defaultMaxListeners = 200 + const oldEmit = EventEmitter.prototype.emit EventEmitter.prototype.emit = function (...args) { if (args[0] === 'error' && !this._events.error) { From a7c35df95984702d3b88fca48412b1f9889271a6 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 18 Apr 2025 17:46:10 +0300 Subject: [PATCH 012/296] fix: fix movement sound --- src/sounds/botSoundSystem.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/sounds/botSoundSystem.ts b/src/sounds/botSoundSystem.ts index 882fd2b7..33ad42e0 100644 --- a/src/sounds/botSoundSystem.ts +++ b/src/sounds/botSoundSystem.ts @@ -134,10 +134,12 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { const movementHappening = async () => { if (!bot.entity || !soundMap) return // no info yet const VELOCITY_THRESHOLD = 0.1 + const RUN_THRESHOLD = 0.15 const { x, z, y } = bot.entity.velocity - if (bot.entity.onGround && Math.abs(x) < VELOCITY_THRESHOLD && (Math.abs(z) > VELOCITY_THRESHOLD || Math.abs(y) > VELOCITY_THRESHOLD)) { + if (bot.entity.onGround && (Math.abs(x) > VELOCITY_THRESHOLD || Math.abs(z) > VELOCITY_THRESHOLD)) { + const isRunning = (Math.abs(x) > RUN_THRESHOLD || Math.abs(z) > RUN_THRESHOLD) // movement happening - if (Date.now() - lastStepSound > 300) { + if (Date.now() - lastStepSound > (isRunning ? 100 : 300)) { const blockUnder = bot.world.getBlock(bot.entity.position.offset(0, -1, 0)) if (blockUnder) { const stepSound = soundMap.getStepSound(blockUnder.name) From 9646fbbc0f3d655606035daa8c0575109969c354 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 18 Apr 2025 23:42:51 +0300 Subject: [PATCH 013/296] fix(regression): hotbar switch on mobile was broken --- src/react/HotbarRenderApp.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/react/HotbarRenderApp.tsx b/src/react/HotbarRenderApp.tsx index e1eeb47a..84dc11e2 100644 --- a/src/react/HotbarRenderApp.tsx +++ b/src/react/HotbarRenderApp.tsx @@ -81,6 +81,9 @@ const HotbarInner = () => { const controller = new AbortController() const inv = openItemsCanvas('HotbarWin', { + _client: { + write () {} + }, clickWindow (slot, mouseButton, mode) { if (mouseButton === 1) { console.log('right click') From dbfd2b23f62b82cc6892dc898f60f949613a5f7c Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 18 Apr 2025 23:43:26 +0300 Subject: [PATCH 014/296] up physics --- package.json | 2 +- pnpm-lock.yaml | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index ee07230d..2a92c0a1 100644 --- a/package.json +++ b/package.json @@ -195,7 +195,7 @@ }, "pnpm": { "overrides": { - "@nxg-org/mineflayer-physics-util": "1.8.6", + "@nxg-org/mineflayer-physics-util": "1.8.7", "buffer": "^6.0.3", "vec3": "0.1.10", "three": "0.154.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7b1da61..120e5646 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - '@nxg-org/mineflayer-physics-util': 1.8.6 + '@nxg-org/mineflayer-physics-util': 1.8.7 buffer: ^6.0.3 vec3: 0.1.10 three: 0.154.0 @@ -2025,8 +2025,8 @@ packages: '@nxg-org/mineflayer-auto-jump@0.7.12': resolution: {integrity: sha512-F5vX/lerlWx/5HVlkDNbvrtQ19PL6iG8i4ItPTIRtjGiFzusDefP7DI226zSFR8Wlaw45qHv0jn814p/4/qVdQ==} - '@nxg-org/mineflayer-physics-util@1.8.6': - resolution: {integrity: sha512-eRn9e9OMvl1+kEfwPPshAl1A5MX0eDWaI7WVRf7ht9qo9N3fKiw+mM/AGPuhVjEr16zUls77P6Sn9cVZJuUdlw==} + '@nxg-org/mineflayer-physics-util@1.8.7': + resolution: {integrity: sha512-wtLYvHqoEFr/j0ny2lyogwjbMvwpFuG2aWI8sI14+EAiGFRpL5+cog2ujSDsnRTZruO7tUXMTiPc1kebjXwfJg==} '@nxg-org/mineflayer-tracker@1.2.1': resolution: {integrity: sha512-SI1ffF8zvg3/ZNE021Ja2W0FZPN+WbQDZf8yFqOcXtPRXAtM9W6HvoACdzXep8BZid7WYgYLIgjKpB+9RqvCNQ==} @@ -11353,10 +11353,10 @@ snapshots: '@nxg-org/mineflayer-auto-jump@0.7.12': dependencies: - '@nxg-org/mineflayer-physics-util': 1.8.6 + '@nxg-org/mineflayer-physics-util': 1.8.7 strict-event-emitter-types: 2.0.0 - '@nxg-org/mineflayer-physics-util@1.8.6': + '@nxg-org/mineflayer-physics-util@1.8.7': dependencies: '@nxg-org/mineflayer-util-plugin': 1.8.4 @@ -17437,7 +17437,7 @@ snapshots: mineflayer@https://codeload.github.com/GenerelSchwerz/mineflayer/tar.gz/d459d2ed76a997af1a7c94718ed7d5dee4478b8a(encoding@0.1.13): dependencies: - '@nxg-org/mineflayer-physics-util': 1.8.6 + '@nxg-org/mineflayer-physics-util': 1.8.7 minecraft-data: 3.83.1 minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284(patch_hash=3a55a278c417cc34ff3172cd1de8e22852935cba0586875cbd0635f1ffdaa5ab)(encoding@0.1.13) prismarine-biome: 1.3.0(minecraft-data@3.83.1)(prismarine-registry@1.11.0) From c3112794c070615d76d19e9351ed96149c78f901 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sat, 19 Apr 2025 00:07:41 +0300 Subject: [PATCH 015/296] try to fix scroll chat bug --- src/react/hooks/useScrollBehavior.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/react/hooks/useScrollBehavior.ts b/src/react/hooks/useScrollBehavior.ts index c7c4499a..3b9499a6 100644 --- a/src/react/hooks/useScrollBehavior.ts +++ b/src/react/hooks/useScrollBehavior.ts @@ -23,6 +23,13 @@ export const useScrollBehavior = ( const scrollToBottom = () => { if (elementRef.current) { elementRef.current.scrollTop = elementRef.current.scrollHeight + setTimeout(() => { + if (!elementRef.current) return + elementRef.current.scrollTo({ + top: elementRef.current.scrollHeight, + behavior: 'instant' + }) + }, 0) } } From 193c748feb6f4487c260b6f99ea1d0de3c667c92 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sat, 19 Apr 2025 00:43:20 +0300 Subject: [PATCH 016/296] fix: add chunks a little faster on low tier devices: use indicies --- renderer/viewer/lib/mesher/models.ts | 40 ++++++++++++++------- renderer/viewer/lib/mesher/shared.ts | 3 +- renderer/viewer/three/worldrendererThree.ts | 2 +- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/renderer/viewer/lib/mesher/models.ts b/renderer/viewer/lib/mesher/models.ts index 66b1fe58..1fc5acda 100644 --- a/renderer/viewer/lib/mesher/models.ts +++ b/renderer/viewer/lib/mesher/models.ts @@ -423,13 +423,19 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO: if (!needTiles) { if (doAO && aos[0] + aos[3] >= aos[1] + aos[2]) { - attr.indices.push( - ndx, ndx + 3, ndx + 2, ndx, ndx + 1, ndx + 3 - ) + attr.indices[attr.indicesCount++] = ndx + attr.indices[attr.indicesCount++] = ndx + 3 + attr.indices[attr.indicesCount++] = ndx + 2 + attr.indices[attr.indicesCount++] = ndx + attr.indices[attr.indicesCount++] = ndx + 1 + attr.indices[attr.indicesCount++] = ndx + 3 } else { - attr.indices.push( - ndx, ndx + 1, ndx + 2, ndx + 2, ndx + 1, ndx + 3 - ) + attr.indices[attr.indicesCount++] = ndx + attr.indices[attr.indicesCount++] = ndx + 1 + attr.indices[attr.indicesCount++] = ndx + 2 + attr.indices[attr.indicesCount++] = ndx + 2 + attr.indices[attr.indicesCount++] = ndx + 1 + attr.indices[attr.indicesCount++] = ndx + 3 } } } @@ -462,7 +468,8 @@ export function getSectionGeometry (sx, sy, sz, world: World) { t_normals: [], t_colors: [], t_uvs: [], - indices: [], + indices: new Uint32Array(442_368), // Maximum possible indices + indicesCount: 0, // Track current index position tiles: {}, // todo this can be removed here heads: {}, @@ -605,12 +612,19 @@ export function getSectionGeometry (sx, sy, sz, world: World) { let ndx = attr.positions.length / 3 for (let i = 0; i < attr.t_positions!.length / 12; i++) { - attr.indices.push( - ndx, ndx + 1, ndx + 2, ndx + 2, ndx + 1, ndx + 3, - // eslint-disable-next-line @stylistic/function-call-argument-newline - // back face - ndx, ndx + 2, ndx + 1, ndx + 2, ndx + 3, ndx + 1 - ) + attr.indices[attr.indicesCount++] = ndx + attr.indices[attr.indicesCount++] = ndx + 1 + attr.indices[attr.indicesCount++] = ndx + 2 + attr.indices[attr.indicesCount++] = ndx + 2 + attr.indices[attr.indicesCount++] = ndx + 1 + attr.indices[attr.indicesCount++] = ndx + 3 + // back face + attr.indices[attr.indicesCount++] = ndx + attr.indices[attr.indicesCount++] = ndx + 2 + attr.indices[attr.indicesCount++] = ndx + 1 + attr.indices[attr.indicesCount++] = ndx + 2 + attr.indices[attr.indicesCount++] = ndx + 3 + attr.indices[attr.indicesCount++] = ndx + 1 ndx += 4 } diff --git a/renderer/viewer/lib/mesher/shared.ts b/renderer/viewer/lib/mesher/shared.ts index 0e36c73b..1ebbb087 100644 --- a/renderer/viewer/lib/mesher/shared.ts +++ b/renderer/viewer/lib/mesher/shared.ts @@ -33,7 +33,8 @@ export type MesherGeometryOutput = { t_colors?: number[], t_uvs?: number[], - indices: number[], + indices: Uint32Array, + indicesCount: number, tiles: Record, heads: Record, signs: Record, diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index 482b0255..3d03dc47 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -333,7 +333,7 @@ export class WorldRendererThree extends WorldRendererCommon { geometry.setAttribute('normal', new THREE.BufferAttribute(data.geometry.normals, 3)) geometry.setAttribute('color', new THREE.BufferAttribute(data.geometry.colors, 3)) geometry.setAttribute('uv', new THREE.BufferAttribute(data.geometry.uvs, 2)) - geometry.setIndex(data.geometry.indices) + geometry.index = new THREE.Uint16BufferAttribute(data.geometry.indices, 1) const mesh = new THREE.Mesh(geometry, this.material) mesh.position.set(data.geometry.sx, data.geometry.sy, data.geometry.sz) From a0bfa275afc9b5427ed082be7e01e86dc8fe1b88 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sat, 19 Apr 2025 00:43:31 +0300 Subject: [PATCH 017/296] lets be safer and use 32array --- renderer/viewer/lib/mesher/models.ts | 1 + renderer/viewer/lib/mesher/shared.ts | 1 + renderer/viewer/three/worldrendererThree.ts | 6 +++++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/renderer/viewer/lib/mesher/models.ts b/renderer/viewer/lib/mesher/models.ts index 1fc5acda..c5d34e9d 100644 --- a/renderer/viewer/lib/mesher/models.ts +++ b/renderer/viewer/lib/mesher/models.ts @@ -470,6 +470,7 @@ export function getSectionGeometry (sx, sy, sz, world: World) { t_uvs: [], indices: new Uint32Array(442_368), // Maximum possible indices indicesCount: 0, // Track current index position + using32Array: true, tiles: {}, // todo this can be removed here heads: {}, diff --git a/renderer/viewer/lib/mesher/shared.ts b/renderer/viewer/lib/mesher/shared.ts index 1ebbb087..ea56e016 100644 --- a/renderer/viewer/lib/mesher/shared.ts +++ b/renderer/viewer/lib/mesher/shared.ts @@ -35,6 +35,7 @@ export type MesherGeometryOutput = { indices: Uint32Array, indicesCount: number, + using32Array: boolean, tiles: Record, heads: Record, signs: Record, diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index 3d03dc47..bb4c1805 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -333,7 +333,11 @@ export class WorldRendererThree extends WorldRendererCommon { geometry.setAttribute('normal', new THREE.BufferAttribute(data.geometry.normals, 3)) geometry.setAttribute('color', new THREE.BufferAttribute(data.geometry.colors, 3)) geometry.setAttribute('uv', new THREE.BufferAttribute(data.geometry.uvs, 2)) - geometry.index = new THREE.Uint16BufferAttribute(data.geometry.indices, 1) + if (data.geometry.using32Array) { + geometry.index = new THREE.Uint32BufferAttribute(data.geometry.indices, 1) + } else { + geometry.index = new THREE.Uint16BufferAttribute(data.geometry.indices, 1) + } const mesh = new THREE.Mesh(geometry, this.material) mesh.position.set(data.geometry.sx, data.geometry.sy, data.geometry.sz) From 2b881ea5ba135f488c8533bed4a449ba67dfc59d Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Mon, 21 Apr 2025 16:06:01 +0300 Subject: [PATCH 018/296] fix: disable physics for viewer --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index 3da14257..ecc824b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -543,6 +543,7 @@ export async function connect (connectOptions: ConnectOptions) { window.bot = bot if (connectOptions.viewerWsConnect) { void handleCustomChannel() + bot.physicsEnabled = false } customEvents.emit('mineflayerBotCreated') if (singleplayer || p2pMultiplayer || localReplaySession) { From 71f63a3be0bd73cf6cfc120d8c31110d9b48881b Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Tue, 22 Apr 2025 19:01:04 +0300 Subject: [PATCH 019/296] sync fork: add loading timer for debug, better connecting messages, better recording panel --- src/appConfig.ts | 8 ++ src/entities.ts | 3 +- src/index.ts | 15 ++- src/mineflayer/entityStatus.ts | 70 ++++++++++++++ src/mineflayer/mc-protocol.ts | 14 ++- src/mineflayer/plugins/packetsRecording.ts | 17 ++++ src/optionsStorage.ts | 12 ++- src/packetsReplay/packetsReplayLegacy.ts | 2 +- src/react/AppStatus.tsx | 103 +++++++++++---------- src/react/AppStatusProvider.tsx | 3 +- src/react/DebugOverlay.tsx | 6 +- src/react/HotbarRenderApp.tsx | 3 - src/react/LoadingTimer.tsx | 50 ++++++++++ src/react/PauseScreen.tsx | 2 + src/react/ReplayPanel.tsx | 20 +++- src/react/ServersListProvider.tsx | 38 ++++++++ src/react/components/replay/PacketList.tsx | 13 +-- src/react/state/packetsReplayState.ts | 1 + src/scaleInterface.ts | 1 + src/screens.css | 16 ++++ src/shims/dns.js | 10 +- 21 files changed, 335 insertions(+), 72 deletions(-) create mode 100644 src/mineflayer/entityStatus.ts create mode 100644 src/react/LoadingTimer.tsx diff --git a/src/appConfig.ts b/src/appConfig.ts index c5b61b69..531c258d 100644 --- a/src/appConfig.ts +++ b/src/appConfig.ts @@ -1,7 +1,9 @@ +import { defaultsDeep } from 'lodash' import { disabledSettings, options, qsOptions } from './optionsStorage' import { miscUiState } from './globalState' import { setLoadingScreenStatus } from './appStatus' import { setStorageDataOnAppConfigLoad } from './react/appStorageProvider' +import { customKeymaps, updateBinds } from './controls' export type AppConfig = { // defaultHost?: string @@ -23,6 +25,7 @@ export type AppConfig = { allowAutoConnect?: boolean splashText?: string pauseLinks?: Array>> + keybindings?: Record } export const loadAppConfig = (appConfig: AppConfig) => { @@ -46,6 +49,11 @@ export const loadAppConfig = (appConfig: AppConfig) => { } } + if (appConfig.keybindings) { + Object.assign(customKeymaps, defaultsDeep(appConfig.keybindings, customKeymaps)) + updateBinds(customKeymaps) + } + setStorageDataOnAppConfigLoad() } diff --git a/src/entities.ts b/src/entities.ts index 9ba31f2e..68e18df3 100644 --- a/src/entities.ts +++ b/src/entities.ts @@ -6,6 +6,7 @@ import { subscribeKey } from 'valtio/utils' import { getThreeJsRendererMethods } from 'renderer/viewer/three/threeJsMethods' import { options, watchValue } from './optionsStorage' import { miscUiState } from './globalState' +import { EntityStatus } from './mineflayer/entityStatus' const updateAutoJump = () => { @@ -85,7 +86,7 @@ customEvents.on('gameLoaded', () => { bot._client.on('entity_status', (data) => { if (versionToNumber(bot.version) >= versionToNumber('1.19.4')) return const { entityId, entityStatus } = data - if (entityStatus === 2) { + if (entityStatus === EntityStatus.HURT) { getThreeJsRendererMethods()?.damageEntity(entityId, entityStatus) } }) diff --git a/src/index.ts b/src/index.ts index ecc824b4..a94aa35d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -93,11 +93,12 @@ import ping from './mineflayer/plugins/ping' import mouse from './mineflayer/plugins/mouse' import { startLocalReplayServer } from './packetsReplay/replayPackets' import { localRelayServerPlugin } from './mineflayer/plugins/packetsRecording' -import { createConsoleLogProgressReporter, createFullScreenProgressReporter, ProgressReporter } from './core/progressReporter' +import { createConsoleLogProgressReporter, createFullScreenProgressReporter, createWrappedProgressReporter, ProgressReporter } from './core/progressReporter' import { appViewer } from './appViewer' import './appViewerLoad' import { registerOpenBenchmarkListener } from './benchmark' import { tryHandleBuiltinCommand } from './builtinCommands' +import { loadingTimerState } from './react/LoadingTimer' window.debug = debug window.beforeRenderFrame = [] @@ -168,6 +169,8 @@ export async function connect (connectOptions: ConnectOptions) { }) } + loadingTimerState.loading = true + loadingTimerState.start = Date.now() miscUiState.hasErrors = false lastConnectOptions.value = connectOptions @@ -211,6 +214,7 @@ export async function connect (connectOptions: ConnectOptions) { let bot!: typeof __type_bot const destroyAll = (wasKicked = false) => { if (ended) return + loadingTimerState.loading = false const hadConnected = !!bot if (!wasKicked && miscUiState.appConfig?.allowAutoConnect && appQueryParams.autoConnect && hadConnected) { location.reload() @@ -302,10 +306,12 @@ export async function connect (connectOptions: ConnectOptions) { Object.assign(serverOptions, connectOptions.serverOverridesFlat ?? {}) await progress.executeWithMessage('Downloading minecraft data', 'download-mcdata', async () => { + loadingTimerState.networkOnlyStart = Date.now() await Promise.all([ downloadAllMinecraftData(), downloadOtherGameData() ]) + loadingTimerState.networkOnlyStart = 0 }) let dataDownloaded = false @@ -401,8 +407,10 @@ export async function connect (connectOptions: ConnectOptions) { } else if (connectOptions.server) { if (!finalVersion) { const versionAutoSelect = getVersionAutoSelect() - setLoadingScreenStatus(`Fetching server version. Preffered: ${versionAutoSelect}`) + const wrapped = createWrappedProgressReporter(progress, `Fetching server version. Preffered: ${versionAutoSelect}`) + loadingTimerState.networkOnlyStart = Date.now() const autoVersionSelect = await getServerInfo(server.host, server.port ? Number(server.port) : undefined, versionAutoSelect) + wrapped.end() finalVersion = autoVersionSelect.version } initialLoadingText = `Connecting to server ${server.host}:${server.port ?? 25_565} with version ${finalVersion}` @@ -416,6 +424,7 @@ export async function connect (connectOptions: ConnectOptions) { setLoadingScreenStatus(initialLoadingText) if (parsedServer.isWebSocket) { + loadingTimerState.networkOnlyStart = Date.now() clientDataStream = (await getWebsocketStream(server.host)).mineflayerStream } @@ -459,6 +468,7 @@ export async function connect (connectOptions: ConnectOptions) { if (finalVersion) { // ensure data is downloaded + loadingTimerState.networkOnlyStart ??= Date.now() await downloadMcData(finalVersion) } @@ -672,6 +682,7 @@ export async function connect (connectOptions: ConnectOptions) { onBotCreate() bot.once('login', () => { + loadingTimerState.networkOnlyStart = 0 setLoadingScreenStatus('Loading world') }) diff --git a/src/mineflayer/entityStatus.ts b/src/mineflayer/entityStatus.ts new file mode 100644 index 00000000..e13784bc --- /dev/null +++ b/src/mineflayer/entityStatus.ts @@ -0,0 +1,70 @@ +export const EntityStatus = { + JUMP: 1, + HURT: 2, // legacy + DEATH: 3, + START_ATTACKING: 4, + STOP_ATTACKING: 5, + TAMING_FAILED: 6, + TAMING_SUCCEEDED: 7, + SHAKE_WETNESS: 8, + USE_ITEM_COMPLETE: 9, + EAT_GRASS: 10, + OFFER_FLOWER: 11, + LOVE_HEARTS: 12, + VILLAGER_ANGRY: 13, + VILLAGER_HAPPY: 14, + WITCH_HAT_MAGIC: 15, + ZOMBIE_CONVERTING: 16, + FIREWORKS_EXPLODE: 17, + IN_LOVE_HEARTS: 18, + SQUID_ANIM_SYNCH: 19, + SILVERFISH_MERGE_ANIM: 20, + GUARDIAN_ATTACK_SOUND: 21, + REDUCED_DEBUG_INFO: 22, + FULL_DEBUG_INFO: 23, + PERMISSION_LEVEL_ALL: 24, + PERMISSION_LEVEL_MODERATORS: 25, + PERMISSION_LEVEL_GAMEMASTERS: 26, + PERMISSION_LEVEL_ADMINS: 27, + PERMISSION_LEVEL_OWNERS: 28, + ATTACK_BLOCKED: 29, + SHIELD_DISABLED: 30, + FISHING_ROD_REEL_IN: 31, + ARMORSTAND_WOBBLE: 32, + THORNED: 33, // legacy + STOP_OFFER_FLOWER: 34, + TALISMAN_ACTIVATE: 35, // legacy + DROWNED: 36, // legacy + BURNED: 37, // legacy + DOLPHIN_LOOKING_FOR_TREASURE: 38, + RAVAGER_STUNNED: 39, + TRUSTING_FAILED: 40, + TRUSTING_SUCCEEDED: 41, + VILLAGER_SWEAT: 42, + BAD_OMEN_TRIGGERED: 43, // legacy + POKED: 44, // legacy + FOX_EAT: 45, + TELEPORT: 46, + MAINHAND_BREAK: 47, + OFFHAND_BREAK: 48, + HEAD_BREAK: 49, + CHEST_BREAK: 50, + LEGS_BREAK: 51, + FEET_BREAK: 52, + HONEY_SLIDE: 53, + HONEY_JUMP: 54, + SWAP_HANDS: 55, + CANCEL_SHAKE_WETNESS: 56, + FROZEN: 57, // legacy + START_RAM: 58, + END_RAM: 59, + POOF: 60, + TENDRILS_SHIVER: 61, + SONIC_CHARGE: 62, + SNIFFER_DIGGING_SOUND: 63, + ARMADILLO_PEEK: 64, + BODY_BREAK: 65, + SHAKE: 66 +} as const + +export type EntityStatusName = keyof typeof EntityStatus diff --git a/src/mineflayer/mc-protocol.ts b/src/mineflayer/mc-protocol.ts index 63a90fa4..2376cd03 100644 --- a/src/mineflayer/mc-protocol.ts +++ b/src/mineflayer/mc-protocol.ts @@ -2,6 +2,7 @@ import { Client } from 'minecraft-protocol' import { appQueryParams } from '../appParams' import { downloadAllMinecraftData, getVersionAutoSelect } from '../connect' import { gameAdditionalState } from '../globalState' +import { ProgressReporter } from '../core/progressReporter' import { pingServerVersion, validatePacket } from './minecraft-protocol-extra' import { getWebsocketStream } from './websocket-core' @@ -34,16 +35,27 @@ setInterval(() => { }, 1000) -export const getServerInfo = async (ip: string, port?: number, preferredVersion = getVersionAutoSelect(), ping = false) => { +export const getServerInfo = async (ip: string, port?: number, preferredVersion = getVersionAutoSelect(), ping = false, progressReporter?: ProgressReporter) => { await downloadAllMinecraftData() const isWebSocket = ip.startsWith('ws://') || ip.startsWith('wss://') let stream if (isWebSocket) { + progressReporter?.setMessage('Connecting to WebSocket server') stream = (await getWebsocketStream(ip)).mineflayerStream + progressReporter?.setMessage('WebSocket connected. Ping packet sent, waiting for response') + } + window.setLoadingMessage = (message?: string) => { + if (message === undefined) { + progressReporter?.endStage('dns') + } else { + progressReporter?.beginStage('dns', message) + } } return pingServerVersion(ip, port, { ...(stream ? { stream } : {}), ...(ping ? { noPongTimeout: 3000 } : {}), ...(preferredVersion ? { version: preferredVersion } : {}), + }).finally(() => { + window.setLoadingMessage = undefined }) } diff --git a/src/mineflayer/plugins/packetsRecording.ts b/src/mineflayer/plugins/packetsRecording.ts index 53a63bd8..b9ba028c 100644 --- a/src/mineflayer/plugins/packetsRecording.ts +++ b/src/mineflayer/plugins/packetsRecording.ts @@ -72,6 +72,7 @@ export const localRelayServerPlugin = (bot: Bot) => { position: position++, timestamp: Date.now(), }) + packetsReplayState.progress.current++ } }) bot._client.on('packet', (data, { name }) => { @@ -86,8 +87,22 @@ export const localRelayServerPlugin = (bot: Bot) => { position: position++, timestamp: Date.now(), }) + packetsReplayState.progress.total++ } }) + const oldWriteChannel = bot._client.writeChannel.bind(bot._client) + bot._client.writeChannel = (channel, params) => { + packetsReplayState.packetsPlayback.push({ + name: channel, + data: params, + isFromClient: true, + isUpcoming: false, + position: position++, + timestamp: Date.now(), + isCustomChannel: true, + }) + oldWriteChannel(channel, params) + } upPacketsReplayPanel() } @@ -95,6 +110,8 @@ export const localRelayServerPlugin = (bot: Bot) => { const upPacketsReplayPanel = () => { if (packetsRecordingState.active && bot) { packetsReplayState.isOpen = true + packetsReplayState.isMinimized = true + packetsReplayState.isRecording = true packetsReplayState.replayName = 'Recording all packets for ' + bot.username } } diff --git a/src/optionsStorage.ts b/src/optionsStorage.ts index c8e6298e..3cc91517 100644 --- a/src/optionsStorage.ts +++ b/src/optionsStorage.ts @@ -59,10 +59,11 @@ const defaultOptions = { serversAutoVersionSelect: 'auto' as 'auto' | 'latest' | '1.20.4' | string, customChannels: false, remoteContentNotSameOrigin: false as boolean | string[], - packetsReplayAutoStart: false, + packetsRecordingAutoStart: false, + locale: 'auto', preciseMouseInput: false, // todo ui setting, maybe enable by default? - waitForChunksRender: 'sp-only' as 'sp-only' | boolean, + waitForChunksRender: false as 'sp-only' | boolean, jeiEnabled: true as boolean | Array<'creative' | 'survival' | 'adventure' | 'spectator'>, preventBackgroundTimeoutKick: false, preventSleep: false, @@ -293,3 +294,10 @@ export const useOptionValue = (setting, valueCallback) => { valueCallback(setting) subscribe(setting, valueCallback) } + +export const getLocale = () => { + if (options.locale === 'auto') { + return navigator.language + } + return options.locale +} diff --git a/src/packetsReplay/packetsReplayLegacy.ts b/src/packetsReplay/packetsReplayLegacy.ts index dc9b7a2d..a9cc71ec 100644 --- a/src/packetsReplay/packetsReplayLegacy.ts +++ b/src/packetsReplay/packetsReplayLegacy.ts @@ -3,7 +3,7 @@ import { PacketsLogger } from 'mcraft-fun-mineflayer/build/packetsLogger' import { options } from '../optionsStorage' export const packetsRecordingState = proxy({ - active: options.packetsReplayAutoStart, + active: options.packetsRecordingAutoStart, hasRecordedPackets: false }) diff --git a/src/react/AppStatus.tsx b/src/react/AppStatus.tsx index c083e445..b8c7dbde 100644 --- a/src/react/AppStatus.tsx +++ b/src/react/AppStatus.tsx @@ -4,6 +4,7 @@ import styles from './appStatus.module.css' import Button from './Button' import Screen from './Screen' import LoadingChunks from './LoadingChunks' +import LoadingTimer from './LoadingTimer' export default ({ status, @@ -37,57 +38,61 @@ export default ({ void statusRunner() }, []) + const lockConnect = appQueryParams.lockConnect === 'true' return ( - - - {status} - - -

{description}

-

{lastStatus ? `Last status: ${lastStatus}` : lastStatus}

- - } - backdrop='dirt' - > - {isError && ( - <> - {showReconnect && onReconnect && } - {actionsSlot} - - {backAction && } + {actionsSlot} + {!lockConnect && } + {backAction && )} +
} diff --git a/src/react/ReplayPanel.tsx b/src/react/ReplayPanel.tsx index 3b709882..ca7b2aec 100644 --- a/src/react/ReplayPanel.tsx +++ b/src/react/ReplayPanel.tsx @@ -43,7 +43,7 @@ export default function ReplayPanel ({ style }: Props) { const [filter, setFilter] = useState(defaultFilter) - const { isMinimized } = useSnapshot(packetsReplayState) + const { isMinimized, isRecording } = useSnapshot(packetsReplayState) const { filtered: filteredPackets, hiddenCount } = filterPackets(packets.slice(-500), filter) useEffect(() => { @@ -70,7 +70,9 @@ export default function ReplayPanel ({ }} > - {isPlaying ? ( + {isRecording ? ( + + ) : isPlaying ? ( ) : ( @@ -137,7 +139,9 @@ export default function ReplayPanel ({ -
Integrated server emulation. Testing client...
+
+ {isRecording ? 'Recording packets...' : 'Integrated server emulation. Testing client...'} +
-
+
{playPauseButton}
@@ -228,4 +239,5 @@ export interface PacketData { actualVersion?: any position: number timestamp: number + isCustomChannel?: boolean } diff --git a/src/react/ServersListProvider.tsx b/src/react/ServersListProvider.tsx index db1d7d77..cd44a27a 100644 --- a/src/react/ServersListProvider.tsx +++ b/src/react/ServersListProvider.tsx @@ -16,6 +16,11 @@ import { showOptionsModal } from './SelectOption' import { useCopyKeybinding } from './simpleHooks' import { AuthenticatedAccount, getInitialServersList, getServerConnectionHistory, setNewServersList } from './serversStorage' import { appStorage, StoreServerItem } from './appStorageProvider' +import Button from './Button' +import { pixelartIcons } from './PixelartIcon' +import { showNotification } from './NotificationProvider' + +const EXPLICIT_SHARE_SERVER_MODE = false if (appQueryParams.lockConnect) { notHideableModalsWithoutForce.add('editServer') @@ -350,6 +355,27 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL }} worldData={serversListSorted.map(server => { const additional = additionalServerData[server.ip] + const handleShare = async () => { + try { + const qs = new URLSearchParams() + qs.set('ip', server.ip) + if (server.proxyOverride) qs.set('proxy', server.proxyOverride) + if (server.versionOverride) qs.set('version', server.versionOverride) + qs.set('username', server.usernameOverride ?? '') + const shareUrl = `${window.location.origin}${window.location.pathname}?${qs.toString()}` + await navigator.clipboard.writeText(shareUrl) + const MESSAGE = 'Server link copied to clipboard' + if (EXPLICIT_SHARE_SERVER_MODE) { + await showOptionsModal(MESSAGE, []) + } else { + showNotification(MESSAGE) + } + } catch (err) { + console.error(err) + showNotification('Failed to copy server link to clipboard') + } + } + return { name: server.index.toString(), title: server.name || server.ip, @@ -359,6 +385,16 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL worldNameRightGrayed: additional?.textNameRightGrayed ?? '', iconSrc: additional?.icon, offline: additional?.offline, + afterTitleUi: ( + + + + + +
+ + + diff --git a/renderer/viewer/three/entity/EntityMesh.ts b/renderer/viewer/three/entity/EntityMesh.ts index af6cc576..488a0f86 100644 --- a/renderer/viewer/three/entity/EntityMesh.ts +++ b/renderer/viewer/three/entity/EntityMesh.ts @@ -551,3 +551,4 @@ export class EntityMesh { } } } +window.EntityMesh = EntityMesh diff --git a/renderer/viewer/three/graphicsBackend.ts b/renderer/viewer/three/graphicsBackend.ts index b0fe5f28..14534d40 100644 --- a/renderer/viewer/three/graphicsBackend.ts +++ b/renderer/viewer/three/graphicsBackend.ts @@ -1,8 +1,8 @@ import * as THREE from 'three' import { Vec3 } from 'vec3' -import { proxy } from 'valtio' -import { GraphicsBackendLoader, GraphicsBackend, GraphicsInitOptions, DisplayWorldOptions, RendererReactiveState } from '../../../src/appViewer' +import { GraphicsBackendLoader, GraphicsBackend, GraphicsInitOptions, DisplayWorldOptions } from '../../../src/appViewer' import { ProgressReporter } from '../../../src/core/progressReporter' +import { showNotification } from '../../../src/react/NotificationProvider' import { WorldRendererThree } from './worldrendererThree' import { DocumentRenderer } from './documentRenderer' import { PanoramaRenderer } from './panorama' @@ -53,12 +53,14 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO let panoramaRenderer: PanoramaRenderer | null = null let worldRenderer: WorldRendererThree | null = null - const startPanorama = () => { + const startPanorama = async () => { if (worldRenderer) return if (!panoramaRenderer) { panoramaRenderer = new PanoramaRenderer(documentRenderer, initOptions, !!process.env.SINGLE_FILE_BUILD_MODE) - void panoramaRenderer.start() window.panoramaRenderer = panoramaRenderer + callModsMethod('panoramaCreated', panoramaRenderer) + await panoramaRenderer.start() + callModsMethod('panoramaReady', panoramaRenderer) } } @@ -79,6 +81,7 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO worldRenderer?.render(sizeChanged) } window.world = worldRenderer + callModsMethod('worldReady', worldRenderer) } const disconnect = () => { @@ -120,8 +123,24 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO } } + globalThis.threeJsBackend = backend + globalThis.resourcesManager = initOptions.resourcesManager + callModsMethod('default', backend) + return backend } +const callModsMethod = (method: string, ...args: any[]) => { + for (const mod of Object.values((window.loadedMods ?? {}) as Record)) { + try { + mod.threeJsBackendModule?.[method]?.(...args) + } catch (err) { + const errorMessage = `[mod three.js] Error calling ${method} on ${mod.name}: ${err}` + showNotification(errorMessage, 'error') + throw new Error(errorMessage) + } + } +} + createGraphicsBackend.id = 'threejs' export default createGraphicsBackend diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 36ccb9b0..b1567c6e 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -173,6 +173,7 @@ const appConfig = defineConfig({ fs.copyFileSync('./assets/favicon.png', './dist/favicon.png') fs.copyFileSync('./assets/playground.html', './dist/playground.html') fs.copyFileSync('./assets/manifest.json', './dist/manifest.json') + fs.copyFileSync('./assets/config.html', './dist/config.html') fs.copyFileSync('./assets/loading-bg.jpg', './dist/loading-bg.jpg') if (fs.existsSync('./assets/release.json')) { fs.copyFileSync('./assets/release.json', './dist/release.json') diff --git a/src/appConfig.ts b/src/appConfig.ts index 497b95ec..156c5974 100644 --- a/src/appConfig.ts +++ b/src/appConfig.ts @@ -29,6 +29,7 @@ export type AppConfig = { defaultLanguage?: string displayLanguageSelector?: boolean supportedLanguages?: string[] + showModsButton?: boolean } export const loadAppConfig = (appConfig: AppConfig) => { diff --git a/src/appViewer.ts b/src/appViewer.ts index 0f29b9a6..ca62bd1b 100644 --- a/src/appViewer.ts +++ b/src/appViewer.ts @@ -89,6 +89,8 @@ export interface GraphicsBackend { } export class AppViewer { + waitBackendLoadPromises = [] as Array> + resourcesManager = new ResourcesManager() worldView: WorldDataEmitter | undefined readonly config: GraphicsBackendConfig = { @@ -114,11 +116,14 @@ export class AppViewer { this.disconnectBackend() } - loadBackend (loader: GraphicsBackendLoader) { + async loadBackend (loader: GraphicsBackendLoader) { if (this.backend) { this.disconnectBackend() } + await Promise.all(this.waitBackendLoadPromises) + this.waitBackendLoadPromises = [] + this.backendLoader = loader const rendererSpecificSettings = {} as Record const rendererSettingsKey = `renderer.${this.backendLoader?.id}` diff --git a/src/appViewerLoad.ts b/src/appViewerLoad.ts index 96e3bf03..53260662 100644 --- a/src/appViewerLoad.ts +++ b/src/appViewerLoad.ts @@ -9,25 +9,27 @@ import { showNotification } from './react/NotificationProvider' const backends = [ createGraphicsBackend, ] -const loadBackend = () => { +const loadBackend = async () => { let backend = backends.find(backend => backend.id === options.activeRenderer) if (!backend) { showNotification(`No backend found for renderer ${options.activeRenderer}`, `Falling back to ${backends[0].id}`, true) backend = backends[0] } - appViewer.loadBackend(backend) + await appViewer.loadBackend(backend) } window.loadBackend = loadBackend if (process.env.SINGLE_FILE_BUILD_MODE) { const unsub = subscribeKey(miscUiState, 'fsReady', () => { if (miscUiState.fsReady) { // don't do it earlier to load fs and display menu faster - loadBackend() + void loadBackend() unsub() } }) } else { - loadBackend() + setTimeout(() => { + void loadBackend() + }) } const animLoop = () => { @@ -40,10 +42,10 @@ watchOptionsAfterViewerInit() // reset backend when renderer changes -subscribeKey(options, 'activeRenderer', () => { +subscribeKey(options, 'activeRenderer', async () => { if (appViewer.currentDisplay === 'world' && bot) { appViewer.resetBackend(true) - loadBackend() + await loadBackend() void appViewer.startWithBot() } }) diff --git a/src/clientMods.ts b/src/clientMods.ts new file mode 100644 index 00000000..a6242d1a --- /dev/null +++ b/src/clientMods.ts @@ -0,0 +1,582 @@ +/* eslint-disable no-await-in-loop */ +import { openDB } from 'idb' +import * as React from 'react' +import * as valtio from 'valtio' +import * as valtioUtils from 'valtio/utils' +import { gt } from 'semver' +import { proxy } from 'valtio' +import { options } from './optionsStorage' +import { appStorage } from './react/appStorageProvider' +import { showInputsModal, showOptionsModal } from './react/SelectOption' +import { ProgressReporter } from './core/progressReporter' + +let sillyProtection = false +const protectRuntime = () => { + if (sillyProtection) return + sillyProtection = true + const sensetiveKeys = new Set(['authenticatedAccounts', 'serversList', 'username']) + const proxy = new Proxy(window.localStorage, { + get (target, prop) { + if (typeof prop === 'string') { + if (sensetiveKeys.has(prop)) { + console.warn(`Access to sensitive key "${prop}" was blocked`) + return null + } + if (prop === 'getItem') { + return (key: string) => { + if (sensetiveKeys.has(key)) { + console.warn(`Access to sensitive key "${key}" via getItem was blocked`) + return null + } + return target.getItem(key) + } + } + if (prop === 'setItem') { + return (key: string, value: string) => { + if (sensetiveKeys.has(key)) { + console.warn(`Attempt to set sensitive key "${key}" via setItem was blocked`) + return + } + target.setItem(key, value) + } + } + if (prop === 'removeItem') { + return (key: string) => { + if (sensetiveKeys.has(key)) { + console.warn(`Attempt to delete sensitive key "${key}" via removeItem was blocked`) + return + } + target.removeItem(key) + } + } + if (prop === 'clear') { + console.warn('Attempt to clear localStorage was blocked') + return () => {} + } + } + return Reflect.get(target, prop) + }, + set (target, prop, value) { + if (typeof prop === 'string' && sensetiveKeys.has(prop)) { + console.warn(`Attempt to set sensitive key "${prop}" was blocked`) + return false + } + return Reflect.set(target, prop, value) + }, + deleteProperty (target, prop) { + if (typeof prop === 'string' && sensetiveKeys.has(prop)) { + console.warn(`Attempt to delete sensitive key "${prop}" was blocked`) + return false + } + return Reflect.deleteProperty(target, prop) + } + }) + Object.defineProperty(window, 'localStorage', { + value: proxy, + writable: false, + configurable: false, + }) +} + +// #region Database +const dbPromise = openDB('mods-db', 1, { + upgrade (db) { + db.createObjectStore('mods', { + keyPath: 'name', + }) + db.createObjectStore('repositories', { + keyPath: 'url', + }) + }, +}) + +// mcraft-repo.json +export interface McraftRepoFile { + packages: ClientModDefinition[] + /** @default true */ + prefix?: string | boolean + name?: string // display name + description?: string + mirrorUrls?: string[] + autoUpdateOverride?: boolean + lastUpdated?: number +} +export interface Repository extends McraftRepoFile { + url: string +} + +export interface ClientMod { + name: string; // unique identifier like owner.name + version: string + enabled?: boolean + + scriptMainUnstable?: string; + serverPlugin?: string + // serverPlugins?: string[] + // mesherThread?: string + stylesGlobal?: string + threeJsBackend?: string // three.js + // stylesLocal?: string + + requiresNetwork?: boolean + fullyOffline?: boolean + description?: string + author?: string + section?: string + autoUpdateOverride?: boolean + lastUpdated?: number + wasModifiedLocally?: boolean + // todo depends, hashsum +} + +const cleanupFetchedModData = (mod: ClientModDefinition | Record) => { + delete mod['enabled'] + delete mod['repo'] + delete mod['autoUpdateOverride'] + delete mod['lastUpdated'] + delete mod['wasModifiedLocally'] + return mod +} + +export type ClientModDefinition = Omit & { + scriptMainUnstable?: boolean + stylesGlobal?: boolean + serverPlugin?: boolean + threeJsBackend?: boolean +} + +export async function saveClientModData (data: ClientMod) { + const db = await dbPromise + data.lastUpdated = Date.now() + await db.put('mods', data) + modsReactiveUpdater.counter++ +} + +async function getPlugin (name: string) { + const db = await dbPromise + return db.get('mods', name) as Promise +} + +async function getAllMods () { + const db = await dbPromise + return db.getAll('mods') as Promise +} + +async function deletePlugin (name) { + const db = await dbPromise + await db.delete('mods', name) + modsReactiveUpdater.counter++ +} + +async function removeAllMods () { + const db = await dbPromise + await db.clear('mods') + modsReactiveUpdater.counter++ +} + +// --- + +async function saveRepository (data: Repository) { + const db = await dbPromise + data.lastUpdated = Date.now() + await db.put('repositories', data) +} + +async function getRepository (url: string) { + const db = await dbPromise + return db.get('repositories', url) as Promise +} + +async function getAllRepositories () { + const db = await dbPromise + return db.getAll('repositories') as Promise +} +window.getAllRepositories = getAllRepositories + +async function deleteRepository (url) { + const db = await dbPromise + await db.delete('repositories', url) +} + +// --- + +// #endregion + +window.mcraft = { + version: process.env.RELEASE_TAG, + build: process.env.BUILD_VERSION, + ui: {}, + React, + valtio: { + ...valtio, + ...valtioUtils, + }, + // openDB +} + +const activateMod = async (mod: ClientMod, reason: string) => { + if (mod.enabled === false) return false + protectRuntime() + console.debug(`Activating mod ${mod.name} (${reason})...`) + window.loadedMods ??= {} + if (window.loadedMods[mod.name]) { + console.warn(`Mod is ${mod.name} already loaded, skipping activation...`) + return false + } + if (mod.stylesGlobal) { + const style = document.createElement('style') + style.textContent = mod.stylesGlobal + style.id = `mod-${mod.name}` + document.head.appendChild(style) + } + if (mod.scriptMainUnstable) { + const blob = new Blob([mod.scriptMainUnstable], { type: 'text/javascript' }) + const url = URL.createObjectURL(blob) + // eslint-disable-next-line no-useless-catch + try { + const module = await import(/* webpackIgnore: true */ url) + module.default?.(structuredClone(mod)) + window.loadedMods[mod.name] ??= {} + window.loadedMods[mod.name].mainUnstableModule = module + } catch (e) { + throw e + } + URL.revokeObjectURL(url) + } + if (mod.threeJsBackend) { + const blob = new Blob([mod.threeJsBackend], { type: 'text/javascript' }) + const url = URL.createObjectURL(blob) + // eslint-disable-next-line no-useless-catch + try { + const module = await import(/* webpackIgnore: true */ url) + // todo + window.loadedMods[mod.name] ??= {} + // for accessing global world var + window.loadedMods[mod.name].threeJsBackendModule = module + } catch (e) { + throw e + } + URL.revokeObjectURL(url) + } + mod.enabled = true + return true +} + +export const appStartup = async () => { + void checkModsUpdates() + + const mods = await getAllMods() + for (const mod of mods) { + await activateMod(mod, 'autostart').catch(e => { + modsErrors[mod.name] ??= [] + modsErrors[mod.name].push(`startup: ${String(e)}`) + console.error(`Error activating mod on startup ${mod.name}:`, e) + }) + } +} + +export const modsUpdateStatus = proxy({} as Record) +export const modsWaitingReloadStatus = proxy({} as Record) +export const modsErrors = proxy({} as Record) + +const normalizeRepoUrl = (url: string) => { + if (url.startsWith('https://')) return url + if (url.startsWith('http://')) return url + if (url.startsWith('//')) return `https:${url}` + return `https://raw.githubusercontent.com/${url}/master` +} + +const installOrUpdateMod = async (repo: Repository, mod: ClientModDefinition, activate = true, progress?: ProgressReporter) => { + // eslint-disable-next-line no-useless-catch + try { + const fetchData = async (urls: string[]) => { + const errored = [] as string[] + // eslint-disable-next-line no-unreachable-loop + for (const urlTemplate of urls) { + const modNameOnly = mod.name.split('.').pop() + const modFolder = repo.prefix === false ? modNameOnly : typeof repo.prefix === 'string' ? `${repo.prefix}/${modNameOnly}` : mod.name + const url = new URL(`${modFolder}/${urlTemplate}`, normalizeRepoUrl(repo.url).replace(/\/$/, '') + '/').href + // eslint-disable-next-line no-useless-catch + try { + const response = await fetch(url) + if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`) + return await response.text() + } catch (e) { + // errored.push(String(e)) + throw e + } + } + console.warn(`[${mod.name}] Error installing component of ${urls[0]}: ${errored.join(', ')}`) + return undefined + } + if (mod.stylesGlobal) { + await progress?.executeWithMessage( + `Downloading ${mod.name} styles`, + async () => { + mod.stylesGlobal = await fetchData(['global.css']) as any + } + ) + } + if (mod.scriptMainUnstable) { + await progress?.executeWithMessage( + `Downloading ${mod.name} script`, + async () => { + mod.scriptMainUnstable = await fetchData(['mainUnstable.js']) as any + } + ) + } + if (mod.threeJsBackend) { + await progress?.executeWithMessage( + `Downloading ${mod.name} three.js backend`, + async () => { + mod.threeJsBackend = await fetchData(['three.js']) as any + } + ) + } + if (mod.serverPlugin) { + if (mod.name.endsWith('.disabled')) throw new Error(`Mod name ${mod.name} can't end with .disabled`) + await progress?.executeWithMessage( + `Downloading ${mod.name} server plugin`, + async () => { + mod.serverPlugin = await fetchData(['serverPlugin.js']) as any + } + ) + } + if (activate) { + // todo try to de-activate mod if it's already loaded + if (window.loadedMods?.[mod.name]) { + modsWaitingReloadStatus[mod.name] = true + } else { + await activateMod(mod as ClientMod, 'install') + } + } + await saveClientModData(mod as ClientMod) + delete modsUpdateStatus[mod.name] + } catch (e) { + // console.error(`Error installing mod ${mod.name}:`, e) + throw e + } +} + +const checkRepositoryUpdates = async (repo: Repository) => { + for (const mod of repo.packages) { + + const modExisting = await getPlugin(mod.name) + if (modExisting?.version && gt(mod.version, modExisting.version)) { + modsUpdateStatus[mod.name] = [modExisting.version, mod.version] + if (options.modsAutoUpdate === 'always' && (!repo.autoUpdateOverride && !modExisting.autoUpdateOverride)) { + void installOrUpdateMod(repo, mod).catch(e => { + console.error(`Error updating mod ${mod.name}:`, e) + }) + } + } + } + +} + +export const fetchRepository = async (urlOriginal: string, url: string, hasMirrors = false) => { + const fetchUrl = normalizeRepoUrl(url).replace(/\/$/, '') + '/mcraft-repo.json' + try { + const response = await fetch(fetchUrl).then(async res => res.json()) + if (!response.packages) throw new Error(`No packages field in the response json of the repository: ${fetchUrl}`) + response.autoUpdateOverride = (await getRepository(urlOriginal))?.autoUpdateOverride + response.url = urlOriginal + void saveRepository(response) + modsReactiveUpdater.counter++ + return true + } catch (e) { + console.warn(`Error fetching repository (trying other mirrors) ${url}:`, e) + return false + } +} + +export const fetchAllRepositories = async () => { + const repositories = await getAllRepositories() + await Promise.all(repositories.map(async (repo) => { + const allUrls = [repo.url, ...(repo.mirrorUrls || [])] + for (const [i, url] of allUrls.entries()) { + const isLast = i === allUrls.length - 1 + + if (await fetchRepository(repo.url, url, !isLast)) break + } + })) + appStorage.modsAutoUpdateLastCheck = Date.now() +} + +const checkModsUpdates = async () => { + await autoRefreshModRepositories() + for (const repo of await getAllRepositories()) { + + await checkRepositoryUpdates(repo) + } +} + +const autoRefreshModRepositories = async () => { + if (options.modsAutoUpdate === 'never') return + const lastCheck = appStorage.modsAutoUpdateLastCheck + if (lastCheck && Date.now() - lastCheck < 1000 * 60 * 60 * options.modsUpdatePeriodCheck) return + await fetchAllRepositories() + // todo think of not updating check timestamp on offline access +} + +export const installModByName = async (repoUrl: string, name: string, progress?: ProgressReporter) => { + progress?.beginStage('main', `Installing ${name}`) + const repo = await getRepository(repoUrl) + if (!repo) throw new Error(`Repository ${repoUrl} not found`) + const mod = repo.packages.find(m => m.name === name) + if (!mod) throw new Error(`Mod ${name} not found in repository ${repoUrl}`) + await installOrUpdateMod(repo, mod, undefined, progress) + progress?.endStage('main') +} + +export const uninstallModAction = async (name: string) => { + const choice = await showOptionsModal(`Uninstall mod ${name}?`, ['Yes']) + if (!choice) return + await deletePlugin(name) + window.loadedMods ??= {} + if (window.loadedMods[name]) { + // window.loadedMods[name].default?.(null) + delete window.loadedMods[name] + modsWaitingReloadStatus[name] = true + } + // Clear any errors associated with the mod + delete modsErrors[name] +} + +export const setEnabledModAction = async (name: string, newEnabled: boolean) => { + const mod = await getPlugin(name) + if (!mod) throw new Error(`Mod ${name} not found`) + if (newEnabled) { + mod.enabled = true + if (!window.loadedMods?.[mod.name]) { + await activateMod(mod, 'manual') + } + } else { + // todo deactivate mod + mod.enabled = false + if (window.loadedMods?.[mod.name]) { + if (window.loadedMods[mod.name]?.threeJsBackendModule) { + window.loadedMods[mod.name].threeJsBackendModule.deactivate() + delete window.loadedMods[mod.name].threeJsBackendModule + } + if (window.loadedMods[mod.name]?.mainUnstableModule) { + window.loadedMods[mod.name].mainUnstableModule.deactivate() + delete window.loadedMods[mod.name].mainUnstableModule + } + + if (Object.keys(window.loadedMods[mod.name]).length === 0) { + delete window.loadedMods[mod.name] + } + } + } + await saveClientModData(mod) +} + +export const modsReactiveUpdater = proxy({ + counter: 0 +}) + +export const getAllModsDisplayList = async () => { + const repos = await getAllRepositories() + const installedMods = await getAllMods() + const modsWithoutRepos = installedMods.filter(mod => !repos.some(repo => repo.packages.some(m => m.name === mod.name))) + const mapMods = (mapMods: ClientMod[]) => mapMods.map(mod => ({ + ...mod, + installed: installedMods.find(m => m.name === mod.name), + activated: !!window.loadedMods?.[mod.name], + installedVersion: installedMods.find(m => m.name === mod.name)?.version, + canBeActivated: mod.scriptMainUnstable || mod.stylesGlobal, + })) + return { + repos: repos.map(repo => ({ + ...repo, + packages: mapMods(repo.packages as ClientMod[]), + })), + modsWithoutRepos: mapMods(modsWithoutRepos), + } +} + +export const removeRepositoryAction = async (url: string) => { + // todo remove mods + const choice = await showOptionsModal('Remove repository? Installed mods wont be automatically removed.', ['Yes']) + if (!choice) return + await deleteRepository(url) + modsReactiveUpdater.counter++ +} + +export const selectAndRemoveRepository = async () => { + const repos = await getAllRepositories() + const choice = await showOptionsModal('Select repository to remove', repos.map(repo => repo.url)) + if (!choice) return + await removeRepositoryAction(choice) +} + +export const addRepositoryAction = async () => { + const { url } = await showInputsModal('Add repository', { + url: { + type: 'text', + label: 'Repository URL or slug', + placeholder: 'github-owner/repo-name', + }, + }) + if (!url) return + await fetchRepository(url, url) +} + +export const getServerPlugin = async (plugin: string) => { + const mod = await getPlugin(plugin) + if (!mod) return null + if (mod.serverPlugin) { + return { + content: mod.serverPlugin, + version: mod.version + } + } + return null +} + +export const getAvailableServerPlugins = async () => { + const mods = await getAllMods() + return mods.filter(mod => mod.serverPlugin) +} + +window.inspectInstalledMods = getAllMods + +type ModifiableField = { + field: string + label: string + language: string + getContent?: () => string +} + +// --- + +export const getAllModsModifiableFields = () => { + const fields: ModifiableField[] = [ + { + field: 'scriptMainUnstable', + label: 'Main Thread Script (unstable)', + language: 'js' + }, + { + field: 'stylesGlobal', + label: 'Global CSS Styles', + language: 'css' + }, + { + field: 'threeJsBackend', + label: 'Three.js Renderer Backend Thread', + language: 'js' + }, + { + field: 'serverPlugin', + label: 'Built-in server plugin', + language: 'js' + } + ] + return fields +} + +export const getModModifiableFields = (mod: ClientMod): ModifiableField[] => { + return getAllModsModifiableFields().filter(field => mod[field.field]) +} diff --git a/src/core/progressReporter.ts b/src/core/progressReporter.ts index a222272d..c76bfb0b 100644 --- a/src/core/progressReporter.ts +++ b/src/core/progressReporter.ts @@ -1,6 +1,7 @@ import { setLoadingScreenStatus } from '../appStatus' import { appStatusState } from '../react/AppStatusProvider' import { hideNotification, showNotification } from '../react/NotificationProvider' +import { pixelartIcons } from '../react/PixelartIcon' export interface ProgressReporter { currentMessage: string | undefined @@ -170,7 +171,7 @@ export const createNotificationProgressReporter = (endMessage?: string): Progres }, end () { if (endMessage) { - showNotification(endMessage, '', false, '', undefined, true) + showNotification(endMessage, '', false, pixelartIcons.check, undefined, true) } else { hideNotification(id) } diff --git a/src/defaultLocalServerOptions.js b/src/defaultLocalServerOptions.js index cd949567..2acd3bce 100644 --- a/src/defaultLocalServerOptions.js +++ b/src/defaultLocalServerOptions.js @@ -8,6 +8,7 @@ module.exports = { 'gameMode': 0, 'difficulty': 0, 'worldFolder': 'world', + 'pluginsFolder': true, // todo set sid, disable entities auto-spawn 'generation': { // grass_field diff --git a/src/index.ts b/src/index.ts index 434c47eb..8678c26e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -81,6 +81,7 @@ import { updateAuthenticatedAccountData, updateLoadedServerData, updateServerCon import { mainMenuState } from './react/MainMenuRenderApp' import './mobileShim' import { parseFormattedMessagePacket } from './botUtils' +import { appStartup } from './clientMods' import { getViewerVersionData, getWsProtocolStream, onBotCreatedViewerHandler } from './viewerConnector' import { getWebsocketStream } from './mineflayer/websocket-core' import { appQueryParams, appQueryParamsArray } from './appParams' @@ -95,6 +96,7 @@ import './appViewerLoad' import { registerOpenBenchmarkListener } from './benchmark' import { tryHandleBuiltinCommand } from './builtinCommands' import { loadingTimerState } from './react/LoadingTimer' +import { loadPluginsIntoWorld } from './react/CreateWorldProvider' window.debug = debug window.beforeRenderFrame = [] @@ -369,6 +371,16 @@ export async function connect (connectOptions: ConnectOptions) { // Client (class) of flying-squid (in server/login.js of mc-protocol): onLogin handler: skip most logic & go to loginClient() which assigns uuid and sends 'success' back to client (onLogin handler) and emits 'login' on the server (login.js in flying-squid handler) // flying-squid: 'login' -> player.login -> now sends 'login' event to the client (handled in many plugins in mineflayer) -> then 'update_health' is sent which emits 'spawn' in mineflayer + const serverPlugins = new URLSearchParams(location.search).getAll('serverPlugin') + if (serverPlugins.length > 0 && !serverOptions.worldFolder) { + console.log('Placing server plugins', serverPlugins) + + serverOptions.worldFolder ??= '/temp' + await loadPluginsIntoWorld('/temp', serverPlugins) + + console.log('Server plugins placed') + } + localServer = window.localServer = window.server = startLocalServer(serverOptions) connectOptions?.connectEvents?.serverCreated?.() // todo need just to call quit if started @@ -669,7 +681,7 @@ export async function connect (connectOptions: ConnectOptions) { bot.once('login', () => { loadingTimerState.networkOnlyStart = 0 - setLoadingScreenStatus('Loading world') + progress.setMessage('Loading world') }) let worldWasReady = false @@ -984,4 +996,5 @@ if (initialLoader) { window.pageLoaded = true void possiblyHandleStateVariable() +appViewer.waitBackendLoadPromises.push(appStartup()) registerOpenBenchmarkListener() diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index 975c0e5b..20514489 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -14,6 +14,7 @@ import { openFilePicker, resetLocalStorage } from './browserfs' import { completeResourcepackPackInstall, getResourcePackNames, resourcepackReload, resourcePackState, uninstallResourcePack } from './resourcePack' import { downloadPacketsReplay, packetsRecordingState } from './packetsReplay/packetsReplayLegacy' import { showInputsModal, showOptionsModal } from './react/SelectOption' +import { modsUpdateStatus } from './clientMods' import supportedVersions from './supportedVersions.mjs' import { getVersionAutoSelect } from './connect' import { createNotificationProgressReporter } from './core/progressReporter' @@ -227,6 +228,15 @@ export const guiOptionsScheme: { return + +
+ +
Default and other world types are WIP
@@ -80,7 +115,11 @@ export default ({ cancelClick, createClick, customizeClick, versions, defaultVer }} >Cancel - +
Note: save important worlds in folders on your hard drive!
{quota}
diff --git a/src/react/CreateWorldProvider.tsx b/src/react/CreateWorldProvider.tsx index b01f129c..6872474d 100644 --- a/src/react/CreateWorldProvider.tsx +++ b/src/react/CreateWorldProvider.tsx @@ -1,7 +1,10 @@ +import fs from 'fs' +import path from 'path' import { hideCurrentModal, showModal } from '../globalState' import defaultLocalServerOptions from '../defaultLocalServerOptions' import { mkdirRecursive, uniqueFileNameFromWorldName } from '../browserfs' import supportedVersions from '../supportedVersions.mjs' +import { getServerPlugin } from '../clientMods' import CreateWorld, { WorldCustomize, creatingWorldState } from './CreateWorld' import { getWorldsPath } from './SingleplayerProvider' import { useIsModalActive } from './utilsApp' @@ -14,7 +17,7 @@ export default () => { const versions = Object.values(versionsPerMinor).map(x => { return { version: x, - label: x === defaultLocalServerOptions.version ? `${x} (available offline)` : x + label: x === defaultLocalServerOptions.version ? `${x} (default)` : x } }) return { }} createClick={async () => { // create new world - const { title, type, version, gameMode } = creatingWorldState + const { title, type, version, gameMode, plugins } = creatingWorldState // todo display path in ui + disable if exist const savePath = await uniqueFileNameFromWorldName(title, getWorldsPath()) await mkdirRecursive(savePath) + await loadPluginsIntoWorld(savePath, plugins) let generation if (type === 'flat') { generation = { @@ -68,3 +72,16 @@ export default () => { } return null } + +export const loadPluginsIntoWorld = async (worldPath: string, plugins: string[]) => { + for (const plugin of plugins) { + // eslint-disable-next-line no-await-in-loop + const { content, version } = await getServerPlugin(plugin) ?? {} + if (content) { + // eslint-disable-next-line no-await-in-loop + await mkdirRecursive(path.join(worldPath, 'plugins')) + // eslint-disable-next-line no-await-in-loop + await fs.promises.writeFile(path.join(worldPath, 'plugins', `${plugin}-${version}.js`), content) + } + } +} diff --git a/src/react/Input.tsx b/src/react/Input.tsx index 169e880d..9b36c5ce 100644 --- a/src/react/Input.tsx +++ b/src/react/Input.tsx @@ -35,7 +35,6 @@ const Input = ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue, w return
{ setValue(e.target.value) diff --git a/src/react/ModsPage.tsx b/src/react/ModsPage.tsx new file mode 100644 index 00000000..6f849fbd --- /dev/null +++ b/src/react/ModsPage.tsx @@ -0,0 +1,483 @@ +import { useEffect, useState, useMemo, useRef } from 'react' +import { useSnapshot } from 'valtio' +import { openURL } from 'renderer/viewer/lib/simpleUtils' +import { addRepositoryAction, setEnabledModAction, getAllModsDisplayList, installModByName, selectAndRemoveRepository, uninstallModAction, fetchAllRepositories, modsReactiveUpdater, modsErrors, fetchRepository, getModModifiableFields, saveClientModData, getAllModsModifiableFields } from '../clientMods' +import { createNotificationProgressReporter, ProgressReporter } from '../core/progressReporter' +import { hideModal } from '../globalState' +import { useIsModalActive } from './utilsApp' +import Input from './Input' +import Button from './Button' +import styles from './mods.module.css' +import { showOptionsModal, showInputsModal } from './SelectOption' +import Screen from './Screen' +import PixelartIcon, { pixelartIcons } from './PixelartIcon' +import { showNotification } from './NotificationProvider' +import { usePassesScaledDimensions } from './UIProvider' +import { appStorage } from './appStorageProvider' + +type ModsData = Awaited> + +const ModListItem = ({ + mod, + onClick, + hasError +}: { + mod: ModsData['repos'][0]['packages'][0], + onClick: () => void, + hasError: boolean +}) => ( +
+
+ {mod.name} + {mod.installedVersion && mod.installedVersion !== mod.version && ( + + )} +
+
+ {mod.description} + {mod.author && ` • By ${mod.author}`} + {mod.version && ` • v${mod.version}`} + {mod.serverPlugin && ` • World plugin`} +
+
+) + +const ModSidebar = ({ mod }: { mod: (ModsData['repos'][0]['packages'][0] & { repo?: string }) | null }) => { + const errors = useSnapshot(modsErrors) + const [editingField, setEditingField] = useState<{ name: string, content: string, language: string } | null>(null) + + const handleAction = async (action: () => Promise, errorMessage: string, progress?: ProgressReporter) => { + try { + await action() + progress?.end() + } catch (error) { + console.error(error) + progress?.end() + showNotification(errorMessage, error.message, true) + } + } + + if (!mod) { + return
Select a mod to view details
+ } + + const modifiableFields = mod.installed ? getModModifiableFields(mod.installed) : [] + + const handleSaveField = async (newContents: string) => { + if (!editingField) return + try { + mod[editingField.name] = newContents + mod.wasModifiedLocally = true + await saveClientModData(mod) + setEditingField(null) + showNotification('Success', 'Contents saved successfully') + } catch (error) { + showNotification('Error', 'Failed to save contents: ' + error.message, true) + } + } + + if (editingField) { + return ( + { + if (newContents === undefined) { + setEditingField(null) + return + } + void handleSaveField(newContents) + }} + /> + ) + } + + return ( + <> +
+
+ {mod.name} {mod.installed?.wasModifiedLocally ? '(modified)' : ''} +
+
+ {mod.description} +
+
+ {mod.author && `Author: ${mod.author}\n`} + {mod.version && `Version: ${mod.version}\n`} + {mod.installedVersion && mod.installedVersion !== mod.version && `Installed version: ${mod.installedVersion}\n`} + {mod.section && `Section: ${mod.section}\n`} +
+ {errors[mod.name]?.length > 0 && ( +
+
    + {errors[mod.name].map((error, i) => ( +
  • {error}
  • + ))} +
+
+ )} +
+
+ {mod.installed ? ( + <> + {mod.activated ? ( +
+ + ) +} + +const EditingCodeWindow = ({ + contents, + language, + onClose +}: { + contents: string, + language: string, + onClose: (newContents?: string) => void +}) => { + const ref = useRef(null) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + e.stopImmediatePropagation() + } + } + window.addEventListener('keydown', handleKeyDown, { capture: true }) + return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) + }, []) + + return +
+