diff --git a/README.MD b/README.MD index ef65ca01..aa36f7e8 100644 --- a/README.MD +++ b/README.MD @@ -12,6 +12,7 @@ For building the project yourself / contributing, see [Development, Debugging & ### Big Features +- Official Mineflayer [plugin integration](https://github.com/zardoy/mcraft-fun-mineflayer-plugin)! View / Control your bot remotely. - Open any zip world file or even folder in read-write mode! - Connect to Java servers running in both offline (cracked) and online mode* (it's possible because of proxy servers, see below) - Integrated JS server clone capable of opening Java world saves in any way (folders, zip, web chunks streaming, etc) diff --git a/assets/config.html b/assets/config.html new file mode 100644 index 00000000..9bd2dd8e --- /dev/null +++ b/assets/config.html @@ -0,0 +1,39 @@ + + + + + + Configure client + + + +
+ + + + + +
+ + + 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/package.json b/package.json index 4b7dbe9d..58de8151 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "google-drive-browserfs": "github:zardoy/browserfs#google-drive", "jszip": "^3.10.1", "lodash-es": "^4.17.21", - "mcraft-fun-mineflayer": "^0.1.14", + "mcraft-fun-mineflayer": "^0.1.21", "minecraft-data": "3.83.1", "minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master", "mineflayer-item-map-downloader": "github:zardoy/mineflayer-item-map-downloader", @@ -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", @@ -196,7 +196,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 be633285..db081014 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 @@ -132,8 +132,8 @@ importers: specifier: ^4.17.21 version: 4.17.21 mcraft-fun-mineflayer: - specifier: ^0.1.14 - version: 0.1.14(encoding@0.1.13)(mineflayer@https://codeload.github.com/GenerelSchwerz/mineflayer/tar.gz/d459d2ed76a997af1a7c94718ed7d5dee4478b8a(encoding@0.1.13)) + specifier: ^0.1.21 + version: 0.1.21(encoding@0.1.13)(mineflayer@https://codeload.github.com/GenerelSchwerz/mineflayer/tar.gz/d459d2ed76a997af1a7c94718ed7d5dee4478b8a(encoding@0.1.13)) minecraft-data: specifier: 3.83.1 version: 3.83.1 @@ -337,11 +337,11 @@ 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/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) minecraft-lighting: specifier: file:../minecraft-lighting version: file:../minecraft-lighting @@ -2028,8 +2028,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==} @@ -6458,13 +6458,13 @@ 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: - resolution: {integrity: sha512-q/qXQaNbkGJIvXjRvudUT7/k0EsJgphFcvYjrSRWYyGDJeb61MKRVqq1hhMjqx7UK7FMfBKvjfPSxq/QlAP7WQ==} - version: 0.1.14 + mcraft-fun-mineflayer@0.1.21: + resolution: {integrity: sha512-FtzebYMvLvunApQy9ilF1RGqiX01DJn8y7q4xAONiIhBrIT7BrHK3O63IA50YgklldvdgVxn7s3m4QANvsH2JA==} + version: 0.1.21 engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} peerDependencies: '@roamhq/wrtc': '*' @@ -6676,8 +6676,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-lighting@file:../minecraft-lighting: @@ -11359,10 +11359,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 @@ -16994,12 +16994,12 @@ 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 - mcraft-fun-mineflayer@0.1.14(encoding@0.1.13)(mineflayer@https://codeload.github.com/GenerelSchwerz/mineflayer/tar.gz/d459d2ed76a997af1a7c94718ed7d5dee4478b8a(encoding@0.1.13)): + mcraft-fun-mineflayer@0.1.21(encoding@0.1.13)(mineflayer@https://codeload.github.com/GenerelSchwerz/mineflayer/tar.gz/d459d2ed76a997af1a7c94718ed7d5dee4478b8a(encoding@0.1.13)): dependencies: '@zardoy/flying-squid': 0.0.49(encoding@0.1.13) exit-hook: 2.2.1 @@ -17315,7 +17315,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: @@ -17447,7 +17447,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) diff --git a/renderer/rsbuildSharedConfig.ts b/renderer/rsbuildSharedConfig.ts index 57eea041..57091d99 100644 --- a/renderer/rsbuildSharedConfig.ts +++ b/renderer/rsbuildSharedConfig.ts @@ -108,6 +108,10 @@ export const rspackViewerConfig = (config, { appendPlugins, addRules, rspack }: { test: /\.txt$/, type: 'asset/source', + }, + { + test: /\.log$/, + type: 'asset/source', } ]) config.ignoreWarnings = [ diff --git a/renderer/viewer/lib/mesher/models.ts b/renderer/viewer/lib/mesher/models.ts index 66b1fe58..fc57d2b3 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 } } } @@ -463,6 +469,8 @@ export function getSectionGeometry (sx, sy, sz, world: World) { t_colors: [], t_uvs: [], indices: [], + indicesCount: 0, // Track current index position + using32Array: true, tiles: {}, // todo this can be removed here heads: {}, @@ -605,12 +613,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 } @@ -628,6 +643,12 @@ export function getSectionGeometry (sx, sy, sz, world: World) { attr.normals = new Float32Array(attr.normals) as any attr.colors = new Float32Array(attr.colors) as any attr.uvs = new Float32Array(attr.uvs) as any + attr.using32Array = arrayNeedsUint32(attr.indices) + if (attr.using32Array) { + attr.indices = new Uint32Array(attr.indices) + } else { + attr.indices = new Uint16Array(attr.indices) + } if (needTiles) { delete attr.positions @@ -639,6 +660,21 @@ export function getSectionGeometry (sx, sy, sz, world: World) { return attr } +// copied from three.js +function arrayNeedsUint32 (array) { + + // assumes larger values usually on last + + for (let i = array.length - 1; i >= 0; -- i) { + + if (array[i] >= 65_535) return true // account for PRIMITIVE_RESTART_FIXED_INDEX, #24565 + + } + + return false + +} + export const setBlockStatesData = (blockstatesModels, blocksAtlas: any, _needTiles = false, useUnknownBlockModel = true, version = 'latest') => { blockProvider = worldBlockProvider(blockstatesModels, blocksAtlas, version) globalThis.blockProvider = blockProvider diff --git a/renderer/viewer/lib/mesher/shared.ts b/renderer/viewer/lib/mesher/shared.ts index 0e36c73b..eb1346f4 100644 --- a/renderer/viewer/lib/mesher/shared.ts +++ b/renderer/viewer/lib/mesher/shared.ts @@ -33,7 +33,9 @@ export type MesherGeometryOutput = { t_colors?: number[], t_uvs?: number[], - indices: number[], + indices: Uint32Array | Uint16Array | number[], + indicesCount: number, + using32Array: boolean, tiles: Record, heads: Record, signs: Record, diff --git a/renderer/viewer/lib/mesherlogReader.ts b/renderer/viewer/lib/mesherlogReader.ts new file mode 100644 index 00000000..0f1e74c0 --- /dev/null +++ b/renderer/viewer/lib/mesherlogReader.ts @@ -0,0 +1,131 @@ +/* eslint-disable no-await-in-loop */ +import { Vec3 } from 'vec3' + +// import log from '../../../../../Downloads/mesher (2).log' +import { WorldRendererCommon } from './worldrendererCommon' +const log = '' + + +export class MesherLogReader { + chunksToReceive: Array<{ + x: number + z: number + chunkLength: number + }> = [] + messagesQueue: Array<{ + fromWorker: boolean + workerIndex: number + message: any + }> = [] + + sectionFinishedToReceive = null as { + messagesLeft: string[] + resolve: () => void + } | null + replayStarted = false + + constructor (private readonly worldRenderer: WorldRendererCommon) { + this.parseMesherLog() + } + + chunkReceived (x: number, z: number, chunkLength: number) { + // remove existing chunks with same x and z + const existingChunkIndex = this.chunksToReceive.findIndex(chunk => chunk.x === x && chunk.z === z) + if (existingChunkIndex === -1) { + // console.error('Chunk not found', x, z) + } else { + // warn if chunkLength is different + if (this.chunksToReceive[existingChunkIndex].chunkLength !== chunkLength) { + // console.warn('Chunk length mismatch', x, z, this.chunksToReceive[existingChunkIndex].chunkLength, chunkLength) + } + // remove chunk + this.chunksToReceive = this.chunksToReceive.filter((chunk, index) => chunk.x !== x || chunk.z !== z) + } + this.maybeStartReplay() + } + + async maybeStartReplay () { + if (this.chunksToReceive.length !== 0 || this.replayStarted) return + const lines = log.split('\n') + console.log('starting replay') + this.replayStarted = true + const waitForWorkersMessages = async () => { + if (!this.sectionFinishedToReceive) return + await new Promise(resolve => { + this.sectionFinishedToReceive!.resolve = resolve + }) + } + + for (const line of lines) { + if (line.includes('dispatchMessages dirty')) { + await waitForWorkersMessages() + this.worldRenderer.stopMesherMessagesProcessing = true + const message = JSON.parse(line.slice(line.indexOf('{'), line.lastIndexOf('}') + 1)) + if (!message.value) continue + const index = line.split(' ')[1] + const type = line.split(' ')[3] + // console.log('sending message', message.x, message.y, message.z) + this.worldRenderer.forceCallFromMesherReplayer = true + this.worldRenderer.setSectionDirty(new Vec3(message.x, message.y, message.z), message.value) + this.worldRenderer.forceCallFromMesherReplayer = false + } + if (line.includes('-> blockUpdate')) { + await waitForWorkersMessages() + this.worldRenderer.stopMesherMessagesProcessing = true + const message = JSON.parse(line.slice(line.indexOf('{'), line.lastIndexOf('}') + 1)) + this.worldRenderer.forceCallFromMesherReplayer = true + this.worldRenderer.setBlockStateIdInner(new Vec3(message.pos.x, message.pos.y, message.pos.z), message.stateId) + this.worldRenderer.forceCallFromMesherReplayer = false + } + + if (line.includes(' sectionFinished ')) { + if (!this.sectionFinishedToReceive) { + console.log('starting worker message processing validating') + this.worldRenderer.stopMesherMessagesProcessing = false + this.sectionFinishedToReceive = { + messagesLeft: [], + resolve: () => { + this.sectionFinishedToReceive = null + } + } + } + const parts = line.split(' ') + const coordsPart = parts.find(part => part.split(',').length === 3) + if (!coordsPart) throw new Error(`no coords part found ${line}`) + const [x, y, z] = coordsPart.split(',').map(Number) + this.sectionFinishedToReceive.messagesLeft.push(`${x},${y},${z}`) + } + } + } + + workerMessageReceived (type: string, message: any) { + if (type === 'sectionFinished') { + const { key } = message + if (!this.sectionFinishedToReceive) { + console.warn(`received sectionFinished message but no sectionFinishedToReceive ${key}`) + return + } + + const idx = this.sectionFinishedToReceive.messagesLeft.indexOf(key) + if (idx === -1) { + console.warn(`received sectionFinished message for non-outstanding section ${key}`) + return + } + this.sectionFinishedToReceive.messagesLeft.splice(idx, 1) + if (this.sectionFinishedToReceive.messagesLeft.length === 0) { + this.sectionFinishedToReceive.resolve() + } + } + } + + parseMesherLog () { + const lines = log.split('\n') + for (const line of lines) { + if (line.startsWith('-> chunk')) { + const chunk = JSON.parse(line.slice('-> chunk'.length)) + this.chunksToReceive.push(chunk) + continue + } + } + } +} diff --git a/renderer/viewer/lib/ui/newStats.ts b/renderer/viewer/lib/ui/newStats.ts index 7af5e897..4a1b0a0f 100644 --- a/renderer/viewer/lib/ui/newStats.ts +++ b/renderer/viewer/lib/ui/newStats.ts @@ -26,7 +26,7 @@ export const addNewStat = (id: string, width = 80, x = rightOffset, y = lastY) = return { updateText (text: string) { - if (pane.innerText === text || pane.style.display === 'none') return + if (pane.innerText === text) return pane.innerText = text }, setVisibility (visible: boolean) { diff --git a/renderer/viewer/lib/worldDataEmitter.ts b/renderer/viewer/lib/worldDataEmitter.ts index 7a853eed..34755c2a 100644 --- a/renderer/viewer/lib/worldDataEmitter.ts +++ b/renderer/viewer/lib/worldDataEmitter.ts @@ -36,10 +36,18 @@ 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 + debugChunksInfo: Record + // blockUpdates: number + }> = {} waitingSpiralChunksLoad = {} as Record void> @@ -65,12 +73,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 + // } updateBlockLight(position.x, position.y, position.z, stateId) this.emit('blockUpdate', { pos: position, stateId }) @@ -117,6 +125,8 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter { @@ -140,8 +150,8 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter { const chunkPos = new Vec3(chunkX * 16, 0, chunkZ * 16) - if (!this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`]) { - void this.loadChunk(chunkPos, true) + if (!this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`] && this.loadedChunks[`${chunkX},${chunkZ}`]) { + void this.loadChunk(chunkPos, true, 'update_light') } }) @@ -240,10 +250,11 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter TypedEmitter TypedEmitter { + worldReadyResolvers = Promise.withResolvers() + worldReadyPromise = this.worldReadyResolvers.promise timeOfTheDay = 0 worldSizeParams = { minY: 0, worldHeight: 256 } @@ -137,12 +140,15 @@ export abstract class WorldRendererCommon worldRendererConfig: WorldRendererConfig playerState: IPlayerState reactiveState: RendererReactiveState + mesherLogReader: MesherLogReader | undefined + forceCallFromMesherReplayer = false + stopMesherMessagesProcessing = false abortController = new AbortController() lastRendered = 0 renderingActive = true geometryReceiveCountPerSec = 0 - workerLogger = { + mesherLogger = { contents: [] as string[], active: new URL(location.href).searchParams.get('mesherlog') === 'true' } @@ -153,6 +159,7 @@ export abstract class WorldRendererCommon mainThreadRendering = true backendInfoReport = '-' chunksFullInfo = '-' + workerCustomHandleTime = 0 get version () { return this.displayOptions.version @@ -170,7 +177,7 @@ export abstract class WorldRendererCommon this.worldRendererConfig = displayOptions.inWorldRenderingConfig this.playerState = displayOptions.playerState this.reactiveState = displayOptions.rendererState - + // this.mesherLogReader = new MesherLogReader(this) this.renderUpdateEmitter.on('update', () => { const loadedChunks = Object.keys(this.finishedChunks).length updateStatText('loaded-chunks', `${loadedChunks}/${this.chunksLength} chunks (${this.lastChunkDistance}/${this.viewDistance})`) @@ -203,22 +210,33 @@ export abstract class WorldRendererCommon } logWorkerWork (message: string | (() => string)) { - if (!this.workerLogger.active) return - this.workerLogger.contents.push(typeof message === 'function' ? message() : message) + if (!this.mesherLogger.active) return + this.mesherLogger.contents.push(typeof message === 'function' ? message() : message) } - init () { + async init () { if (this.active) throw new Error('WorldRendererCommon is already initialized') - this.watchReactivePlayerState() - void this.setVersion(this.version).then(() => { - this.resourcesManager.on('assetsTexturesUpdated', () => { - if (!this.active) return - void this.updateAssetsData() - }) - if (this.resourcesManager.currentResources) { - void this.updateAssetsData() - } + await this.resourcesManager.loadMcData(this.version) + if (!this.resourcesManager.currentResources) { + await this.resourcesManager.updateAssetsData({ }) + } + + await Promise.all([ + this.resetWorkers(), + (async () => { + if (this.resourcesManager.currentResources) { + await this.updateAssetsData() + } + })() + ]) + + this.resourcesManager.on('assetsTexturesUpdated', async () => { + if (!this.active) return + await this.updateAssetsData() }) + + this.watchReactivePlayerState() + this.worldReadyResolvers.resolve() } snapshotInitialValues () { } @@ -298,7 +316,7 @@ export abstract class WorldRendererCommon async processMessageQueue (source: string) { if (this.isProcessingQueue || this.messageQueue.length === 0) return this.logWorkerWork(`# ${source} processing queue`) - if (this.lastRendered && performance.now() - this.lastRendered > 30 && this.worldRendererConfig._experimentalSmoothChunkLoading && this.renderingActive) { + if (this.lastRendered && performance.now() - this.lastRendered > this.ONMESSAGE_TIME_LIMIT && this.worldRendererConfig._experimentalSmoothChunkLoading && this.renderingActive) { const start = performance.now() await new Promise(resolve => { requestAnimationFrame(resolve) @@ -311,12 +329,15 @@ export abstract class WorldRendererCommon let processedCount = 0 while (this.messageQueue.length > 0) { - const data = this.messageQueue.shift()! - this.handleMessage(data) - processedCount++ + const processingStopped = this.stopMesherMessagesProcessing + if (!processingStopped) { + const data = this.messageQueue.shift()! + this.handleMessage(data) + processedCount++ + } // Check if we've exceeded the time limit - if (performance.now() - startTime > this.ONMESSAGE_TIME_LIMIT && this.renderingActive && this.worldRendererConfig._experimentalSmoothChunkLoading) { + if (processingStopped || (performance.now() - startTime > this.ONMESSAGE_TIME_LIMIT && this.renderingActive && this.worldRendererConfig._experimentalSmoothChunkLoading)) { // If we have more messages and exceeded time limit, schedule next batch if (this.messageQueue.length > 0) { requestAnimationFrame(async () => { @@ -334,8 +355,11 @@ export abstract class WorldRendererCommon handleMessage (data) { if (!this.active) return + this.mesherLogReader?.workerMessageReceived(data.type, data) if (data.type !== 'geometry' || !this.debugStopGeometryUpdate) { + const start = performance.now() this.handleWorkerMessage(data) + this.workerCustomHandleTime += performance.now() - start } if (data.type === 'geometry') { this.logWorkerWork(() => `-> ${data.workerIndex} geometry ${data.key} ${JSON.stringify({ dataSize: JSON.stringify(data).length })}`) @@ -347,7 +371,7 @@ export abstract class WorldRendererCommon this.lastChunkDistance = Math.max(...this.getDistance(new Vec3(chunkCoords[0], 0, chunkCoords[2]))) } if (data.type === 'sectionFinished') { // on after load & unload section - this.logWorkerWork(`-> ${data.workerIndex} sectionFinished ${data.key} ${JSON.stringify({ processTime: data.processTime })}`) + this.logWorkerWork(`<- ${data.workerIndex} sectionFinished ${data.key} ${JSON.stringify({ processTime: data.processTime })}`) if (!this.sectionsWaiting.has(data.key)) throw new Error(`sectionFinished event for non-outstanding section ${data.key}`) this.sectionsWaiting.set(data.key, this.sectionsWaiting.get(data.key)! - 1) if (this.sectionsWaiting.get(data.key) === 0) { @@ -410,7 +434,7 @@ export abstract class WorldRendererCommon downloadMesherLog () { const a = document.createElement('a') - a.href = 'data:text/plain;charset=utf-8,' + encodeURIComponent(this.workerLogger.contents.join('\n')) + a.href = 'data:text/plain;charset=utf-8,' + encodeURIComponent(this.mesherLogger.contents.join('\n')) a.download = 'mesher.log' a.click() } @@ -477,8 +501,7 @@ export abstract class WorldRendererCommon this.workers = [] } - // new game load happens here - async setVersion (version: string) { + async resetWorkers () { this.resetWorld() // for workers in single file build @@ -491,11 +514,7 @@ export abstract class WorldRendererCommon this.initWorkers() this.active = true - await this.resourcesManager.loadMcData(version) this.sendMesherMcData() - if (!this.resourcesManager.currentResources) { - await this.resourcesManager.updateAssetsData({ }) - } } getMesherConfig (): MesherConfig { @@ -598,7 +617,8 @@ export abstract class WorldRendererCommon lightData: getLightEngineSafe()?.worldLightHolder.dumpChunk(x, z) }) } - this.logWorkerWork(`-> chunk ${JSON.stringify({ x, z, chunkLength: chunk.length, customBlockModelsLength: customBlockModels ? Object.keys(customBlockModels).length : 0 })}`) + this.logWorkerWork(() => `-> chunk ${JSON.stringify({ x, z, chunkLength: chunk.length, customBlockModelsLength: customBlockModels ? Object.keys(customBlockModels).length : 0 })}`) + this.mesherLogReader?.chunkReceived(x, z, chunk.length) for (let y = this.worldMinYRender; y < this.worldSizeParams.worldHeight; y += 16) { const loc = new Vec3(x, y, z) this.setSectionDirty(loc) @@ -640,8 +660,9 @@ export abstract class WorldRendererCommon this.updateChunksStats() if (Object.keys(this.loadedChunks).length === 0) { - this.workerLogger.contents = [] + this.mesherLogger.contents = [] this.logWorkerWork('# all chunks unloaded. New log started') + void this.mesherLogReader?.maybeStartReplay() } } @@ -852,6 +873,8 @@ export abstract class WorldRendererCommon } setSectionDirty (pos: Vec3, value = true, useChangeWorker = false) { // value false is used for unloading chunks + if (!this.forceCallFromMesherReplayer && this.mesherLogReader) return + if (this.viewDistance === -1) throw new Error('viewDistance not set') this.reactiveState.world.mesherWork = true const distance = this.getDistance(pos) @@ -863,19 +886,30 @@ export abstract class WorldRendererCommon // Dispatch sections to workers based on position // This guarantees uniformity accross workers and that a given section // is always dispatched to the same worker - const hash = this.getWorkerNumber(pos, useChangeWorker) + const hash = this.getWorkerNumber(pos, useChangeWorker && this.mesherLogger.active) this.sectionsWaiting.set(key, (this.sectionsWaiting.get(key) ?? 0) + 1) - this.toWorkerMessagesQueue[hash] ??= [] - this.toWorkerMessagesQueue[hash].push({ - // this.workers[hash].postMessage({ - type: 'dirty', - x: pos.x, - y: pos.y, - z: pos.z, - value, - config: this.getMesherConfig(), - }) - this.dispatchMessages() + if (this.forceCallFromMesherReplayer) { + this.workers[hash].postMessage({ + type: 'dirty', + x: pos.x, + y: pos.y, + z: pos.z, + value, + config: this.getMesherConfig(), + }) + } else { + this.toWorkerMessagesQueue[hash] ??= [] + this.toWorkerMessagesQueue[hash].push({ + // this.workers[hash].postMessage({ + type: 'dirty', + x: pos.x, + y: pos.y, + z: pos.z, + value, + config: this.getMesherConfig(), + }) + this.dispatchMessages() + } } dispatchMessages () { diff --git a/renderer/viewer/three/documentRenderer.ts b/renderer/viewer/three/documentRenderer.ts index dbea1205..46672b74 100644 --- a/renderer/viewer/three/documentRenderer.ts +++ b/renderer/viewer/three/documentRenderer.ts @@ -120,7 +120,9 @@ export class DocumentRenderer { this.preRender() this.stats.markStart() tween.update() - this.render(sizeChanged) + if (!window.freezeRender) { + this.render(sizeChanged) + } for (const fn of this.onRender) { fn(sizeChanged) } @@ -189,6 +191,10 @@ class TopRightStats { const hasRamPanel = this.stats2.dom.children.length === 3 this.addStat(this.stats.dom) + if (process.env.NODE_ENV === 'development' && document.exitPointerLock) { + this.stats.dom.style.top = '' + this.stats.dom.style.bottom = '0' + } if (hasRamPanel) { this.addStat(this.stats2.dom) } 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 f8cf14a4..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) } } @@ -68,16 +70,18 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO await initOptions.resourcesManager.updateAssetsData({ }) } - const startWorld = (displayOptions: DisplayWorldOptions) => { + const startWorld = async (displayOptions: DisplayWorldOptions) => { if (panoramaRenderer) { panoramaRenderer.dispose() panoramaRenderer = null } worldRenderer = new WorldRendererThree(documentRenderer.renderer, initOptions, displayOptions) + await worldRenderer.worldReadyPromise documentRenderer.render = (sizeChanged: boolean) => { worldRenderer?.render(sizeChanged) } window.world = worldRenderer + callModsMethod('worldReady', worldRenderer) } const disconnect = () => { @@ -119,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/renderer/viewer/three/threeJsMedia.ts b/renderer/viewer/three/threeJsMedia.ts index b6e3bd34..582273d1 100644 --- a/renderer/viewer/three/threeJsMedia.ts +++ b/renderer/viewer/three/threeJsMedia.ts @@ -35,6 +35,10 @@ export class ThreeJsMedia { this.worldRenderer.onWorldSwitched.push(() => { this.onWorldGone() }) + + this.worldRenderer.onRender.push(() => { + this.render() + }) } onWorldGone () { @@ -304,6 +308,18 @@ export class ThreeJsMedia { return id } + render () { + for (const [id, videoData] of this.customMedia.entries()) { + const chunkX = Math.floor(videoData.props.position.x / 16) * 16 + const chunkZ = Math.floor(videoData.props.position.z / 16) * 16 + const sectionY = Math.floor(videoData.props.position.y / 16) * 16 + + const chunkKey = `${chunkX},${chunkZ}` + const sectionKey = `${chunkX},${sectionY},${chunkZ}` + videoData.mesh.visible = !!this.worldRenderer.sectionObjects[sectionKey] || !!this.worldRenderer.finishedChunks[chunkKey] + } + } + setVideoPlaying (id: string, playing: boolean) { const videoData = this.customMedia.get(id) if (videoData?.video) { @@ -531,9 +547,13 @@ export class ThreeJsMedia { console.log('Exact test mesh added with dimensions:', width, height, 'and rotation:', rotation) } + lastCheck = 0 + THROTTLE_TIME = 100 tryIntersectMedia () { - // hack: need to optimize this by pulling only in distance of interaction instead (or throttle)! + // hack: need to optimize this by pulling only in distance of interaction instead and throttle if (this.customMedia.size === 0) return + if (Date.now() - this.lastCheck < this.THROTTLE_TIME) return + this.lastCheck = Date.now() const { camera, scene } = this.worldRenderer const raycaster = new THREE.Raycaster() diff --git a/renderer/viewer/three/threeJsParticles.ts b/renderer/viewer/three/threeJsParticles.ts new file mode 100644 index 00000000..993f2b62 --- /dev/null +++ b/renderer/viewer/three/threeJsParticles.ts @@ -0,0 +1,160 @@ +import * as THREE from 'three' + +interface ParticleMesh extends THREE.Mesh { + velocity: THREE.Vector3; +} + +interface ParticleConfig { + fountainHeight: number; + resetHeight: number; + xVelocityRange: number; + zVelocityRange: number; + particleCount: number; + particleRadiusRange: { min: number; max: number }; + yVelocityRange: { min: number; max: number }; +} + +export interface FountainOptions { + position?: { x: number, y: number, z: number } + particleConfig?: Partial; +} + +export class Fountain { + private readonly particles: ParticleMesh[] = [] + private readonly config: { particleConfig: ParticleConfig } + private readonly position: THREE.Vector3 + container: THREE.Object3D | undefined + + constructor (public sectionId: string, options: FountainOptions = {}) { + this.position = options.position ? new THREE.Vector3(options.position.x, options.position.y, options.position.z) : new THREE.Vector3(0, 0, 0) + this.config = this.createConfig(options.particleConfig) + } + + private createConfig ( + particleConfigOverride?: Partial + ): { particleConfig: ParticleConfig } { + const particleConfig: ParticleConfig = { + fountainHeight: 10, + resetHeight: 0, + xVelocityRange: 0.4, + zVelocityRange: 0.4, + particleCount: 400, + particleRadiusRange: { min: 0.1, max: 0.6 }, + yVelocityRange: { min: 0.1, max: 2 }, + ...particleConfigOverride + } + + return { particleConfig } + } + + + createParticles (container: THREE.Object3D): void { + this.container = container + const colorStart = new THREE.Color(0xff_ff_00) + const colorEnd = new THREE.Color(0xff_a5_00) + + for (let i = 0; i < this.config.particleConfig.particleCount; i++) { + const radius = Math.random() * + (this.config.particleConfig.particleRadiusRange.max - this.config.particleConfig.particleRadiusRange.min) + + this.config.particleConfig.particleRadiusRange.min + const geometry = new THREE.SphereGeometry(radius) + const material = new THREE.MeshBasicMaterial({ + color: colorStart.clone().lerp(colorEnd, Math.random()) + }) + const mesh = new THREE.Mesh(geometry, material) + const particle = mesh as unknown as ParticleMesh + + particle.position.set( + this.position.x + (Math.random() - 0.5) * this.config.particleConfig.xVelocityRange * 2, + this.position.y + this.config.particleConfig.fountainHeight, + this.position.z + (Math.random() - 0.5) * this.config.particleConfig.zVelocityRange * 2 + ) + + particle.velocity = new THREE.Vector3( + (Math.random() - 0.5) * this.config.particleConfig.xVelocityRange, + -Math.random() * this.config.particleConfig.yVelocityRange.max, + (Math.random() - 0.5) * this.config.particleConfig.zVelocityRange + ) + + this.particles.push(particle) + this.container.add(particle) + + // this.container.onBeforeRender = () => { + // this.render() + // } + } + } + + render (): void { + for (const particle of this.particles) { + particle.velocity.y -= 0.01 + Math.random() * 0.1 + particle.position.add(particle.velocity) + + if (particle.position.y < this.position.y + this.config.particleConfig.resetHeight) { + particle.position.set( + this.position.x + (Math.random() - 0.5) * this.config.particleConfig.xVelocityRange * 2, + this.position.y + this.config.particleConfig.fountainHeight, + this.position.z + (Math.random() - 0.5) * this.config.particleConfig.zVelocityRange * 2 + ) + particle.velocity.set( + (Math.random() - 0.5) * this.config.particleConfig.xVelocityRange, + -Math.random() * this.config.particleConfig.yVelocityRange.max, + (Math.random() - 0.5) * this.config.particleConfig.zVelocityRange + ) + } + } + } + + private updateParticleCount (newCount: number): void { + if (newCount !== this.config.particleConfig.particleCount) { + this.config.particleConfig.particleCount = newCount + const currentCount = this.particles.length + + if (newCount > currentCount) { + this.addParticles(newCount - currentCount) + } else if (newCount < currentCount) { + this.removeParticles(currentCount - newCount) + } + } + } + + private addParticles (count: number): void { + const geometry = new THREE.SphereGeometry(0.1) + const material = new THREE.MeshBasicMaterial({ color: 0x00_ff_00 }) + + for (let i = 0; i < count; i++) { + const mesh = new THREE.Mesh(geometry, material) + const particle = mesh as unknown as ParticleMesh + particle.position.copy(this.position) + particle.velocity = new THREE.Vector3( + Math.random() * this.config.particleConfig.xVelocityRange - + this.config.particleConfig.xVelocityRange / 2, + Math.random() * 2, + Math.random() * this.config.particleConfig.zVelocityRange - + this.config.particleConfig.zVelocityRange / 2 + ) + this.particles.push(particle) + this.container!.add(particle) + } + } + + private removeParticles (count: number): void { + for (let i = 0; i < count; i++) { + const particle = this.particles.pop() + if (particle) { + this.container!.remove(particle) + } + } + } + + public dispose (): void { + for (const particle of this.particles) { + particle.geometry.dispose() + if (Array.isArray(particle.material)) { + for (const material of particle.material) material.dispose() + } else { + particle.material.dispose() + } + } + } +} diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index 482b0255..14270367 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -25,12 +25,13 @@ import { Entities } from './entities' import { ThreeJsSound } from './threeJsSound' import { CameraShake } from './cameraShake' import { ThreeJsMedia } from './threeJsMedia' +import { Fountain } from './threeJsParticles' type SectionKey = string export class WorldRendererThree extends WorldRendererCommon { outputFormat = 'threeJs' as const - sectionObjects: Record = {} + sectionObjects: Record = {} chunkTextures = new Map() signsCache = new Map() starField: StarField @@ -68,6 +69,7 @@ export class WorldRendererThree extends WorldRendererCommon { limitZ?: number, } } + fountains: Fountain[] = [] get tilesRendered () { return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj as any).tilesCount, 0) @@ -88,12 +90,15 @@ export class WorldRendererThree extends WorldRendererCommon { this.addDebugOverlay() this.resetScene() - this.init() + void this.init() void initVR(this) this.soundSystem = new ThreeJsSound(this) this.cameraShake = new CameraShake(this.camera, this.onRender) this.media = new ThreeJsMedia(this) + // this.fountain = new Fountain(this.scene, this.scene, { + // position: new THREE.Vector3(0, 10, 0), + // }) this.renderUpdateEmitter.on('chunkFinished', (chunkKey: string) => { this.finishChunk(chunkKey) @@ -333,7 +338,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.BufferAttribute(data.geometry.indices as Uint32Array | Uint16Array, 1) const mesh = new THREE.Mesh(geometry, this.material) mesh.position.set(data.geometry.sx, data.geometry.sy, data.geometry.sz) @@ -443,7 +448,6 @@ export class WorldRendererThree extends WorldRendererCommon { const start = performance.now() this.lastRendered = performance.now() this.cursorBlock.render() - this.updateSectionOffsets() const sizeOrFovChanged = sizeChanged || this.displayOptions.inWorldRenderingConfig.fov !== this.camera.fov @@ -464,6 +468,14 @@ export class WorldRendererThree extends WorldRendererCommon { this.holdingBlockLeft.render(this.camera, this.renderer, this.ambientLight, this.directionalLight) } + for (const fountain of this.fountains) { + if (this.sectionObjects[fountain.sectionId] && !this.sectionObjects[fountain.sectionId].foutain) { + fountain.createParticles(this.sectionObjects[fountain.sectionId]) + this.sectionObjects[fountain.sectionId].foutain = true + } + fountain.render() + } + for (const onRender of this.onRender) { onRender() } 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/scripts/prepareSounds.mjs b/scripts/prepareSounds.mjs index 7ff614a2..02026a04 100644 --- a/scripts/prepareSounds.mjs +++ b/scripts/prepareSounds.mjs @@ -11,7 +11,12 @@ import supportedVersions from '../src/supportedVersions.mjs' const __dirname = path.dirname(fileURLToPath(new URL(import.meta.url))) -const targetedVersions = supportedVersions.reverse() +export const versionToNumber = (ver) => { + const [x, y = '0', z = '0'] = ver.split('.') + return +`${x.padStart(2, '0')}${y.padStart(2, '0')}${z.padStart(2, '0')}` +} + +const targetedVersions = [...supportedVersions].sort((a, b) => versionToNumber(b) - versionToNumber(a)) /** @type {{name, size, hash}[]} */ let prevSounds = null @@ -173,13 +178,36 @@ const writeSoundsMap = async () => { // todo REMAP ONLY IDS. Do diffs, as mostly only ids are changed between versions // const localTargetedVersions = targetedVersions.slice(0, 2) + let lastMappingsJson const localTargetedVersions = targetedVersions - for (const targetedVersion of localTargetedVersions) { + for (const targetedVersion of [...localTargetedVersions].reverse()) { + console.log('Processing version', targetedVersion) + const burgerData = await fetch(burgerDataUrl(targetedVersion)).then((r) => r.json()).catch((err) => { - console.error('error fetching burger data', targetedVersion, err) + // console.error('error fetching burger data', targetedVersion, err) return null }) - if (!burgerData) continue + /** @type {{sounds: string[]}} */ + const mappingJson = await fetch(`https://raw.githubusercontent.com/ViaVersion/Mappings/7a45c1f9dbc1f1fdadacfecdb205ba84e55766fc/mappings/mapping-${targetedVersion}.json`).then(async (r) => { + return r.json() + // lastMappingsJson = r.status === 404 ? lastMappingsJson : (await r.json()) + // if (r.status === 404) { + // console.warn('using prev mappings json for ' + targetedVersion) + // } + // return lastMappingsJson + }).catch((err) => { + // console.error('error fetching mapping json', targetedVersion, err) + return null + }) + // if (!mappingJson) throw new Error('no initial mapping json for ' + targetedVersion) + if (burgerData && !mappingJson) { + console.warn('has burger but no mapping json for ' + targetedVersion) + continue + } + if (!mappingJson || !burgerData) { + console.warn('no mapping json or burger data for ' + targetedVersion) + continue + } const allSoundsMap = getSoundsMap(burgerData) // console.log(Object.keys(sounds).length, 'ids') const outputIdMap = {} @@ -190,7 +218,7 @@ const writeSoundsMap = async () => { new: 0, same: 0 } - for (const { id, subtitle, sounds, name } of Object.values(allSoundsMap)) { + for (const { _id, subtitle, sounds, name } of Object.values(allSoundsMap)) { if (!sounds?.length /* && !subtitle */) continue const firstName = sounds[0].name // const includeSound = isSoundWhitelisted(firstName) @@ -210,6 +238,11 @@ const writeSoundsMap = async () => { if (sound.weight && isNaN(sound.weight)) debugger outputUseSoundLine.push(`${sound.volume ?? 1};${sound.name};${sound.weight ?? minWeight}`) } + const id = mappingJson.sounds.findIndex(x => x === name) + if (id === -1) { + console.warn('no id for sound', name, targetedVersion) + continue + } const key = `${id};${name}` outputIdMap[key] = outputUseSoundLine.join(',') if (prevMap && prevMap[key]) { @@ -283,6 +316,6 @@ if (action) { } else { // downloadAllSoundsAndCreateMap() // convertSounds() - // writeSoundsMap() - makeSoundsBundle() + writeSoundsMap() + // makeSoundsBundle() } diff --git a/src/appConfig.ts b/src/appConfig.ts index b8f83ad1..156c5974 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 @@ -15,12 +17,19 @@ export type AppConfig = { mapsProvider?: string appParams?: Record // query string params + rightSideText?: string defaultSettings?: Record forceSettings?: Record // hideSettings?: Record allowAutoConnect?: boolean + splashText?: string pauseLinks?: Array>> + keybindings?: Record + defaultLanguage?: string + displayLanguageSelector?: boolean + supportedLanguages?: string[] + showModsButton?: boolean } export const loadAppConfig = (appConfig: AppConfig) => { @@ -44,6 +53,11 @@ export const loadAppConfig = (appConfig: AppConfig) => { } } + if (appConfig.keybindings) { + Object.assign(customKeymaps, defaultsDeep(appConfig.keybindings, customKeymaps)) + updateBinds(customKeymaps) + } + setStorageDataOnAppConfigLoad() } diff --git a/src/appParams.ts b/src/appParams.ts index 994a5e16..aec6fd0b 100644 --- a/src/appParams.ts +++ b/src/appParams.ts @@ -42,6 +42,9 @@ export type AppQsParams = { suggest_save?: string noPacketsValidation?: string testCrashApp?: string + onlyConnect?: string + connectText?: string + freezeSettings?: string // Replay params replayFilter?: string 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/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/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/controls.ts b/src/controls.ts index 0633ea8e..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'], }, @@ -177,6 +184,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) } } @@ -549,6 +557,10 @@ contro.on('trigger', ({ command }) => { } } + if (command === 'communication.toggleMicrophone') { + // toggleMicrophoneMuted() + } + if (command === 'ui.pauseMenu') { showModal({ reactType: 'pause-screen' }) } @@ -613,6 +625,13 @@ export const f3Keybinds: Array<{ }, mobileTitle: 'Toggle chunk borders', }, + { + key: 'KeyH', + action () { + showModal({ reactType: 'chunks-debug' }) + }, + mobileTitle: 'Show Chunks Debug', + }, { key: 'KeyY', async action () { @@ -761,6 +780,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() diff --git a/src/core/progressReporter.ts b/src/core/progressReporter.ts index f4e4e701..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 @@ -163,15 +164,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) + showNotification(endMessage, '', false, pixelartIcons.check, 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/defaultLocalServerOptions.js b/src/defaultLocalServerOptions.js index cd949567..3b93910d 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 @@ -33,6 +34,6 @@ module.exports = { keepAlive: false, 'everybody-op': true, 'max-entities': 100, - 'version': '1.18.2', + 'version': '1.18', versionMajor: '1.18' } 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/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..8678c26e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import './mineflayer/java-tester/index' import './external' import './appConfig' import './mineflayer/timers' +import './mineflayer/plugins' import { getServerInfo } from './mineflayer/mc-protocol' import { onGameLoad } from './inventoryWindows' import initCollisionShapes from './getCollisionInteractionShapes' @@ -27,8 +28,7 @@ import { options } from './optionsStorage' import './reactUi' import { lockUrl, onBotCreate } from './controls' import './dragndrop' -import { possiblyCleanHandle, resetStateAfterDisconnect } from './browserfs' -import { watchOptionsAfterViewerInit, watchOptionsAfterWorldViewInit } from './watchOptions' +import { possiblyCleanHandle } from './browserfs' import downloadAndOpenFile from './downloadAndOpenFile' import fs from 'fs' @@ -74,29 +74,29 @@ import { showNotification } from './react/NotificationProvider' import { saveToBrowserMemory } from './react/PauseScreen' import './devReload' import './water' -import { ConnectOptions, loadMinecraftData, getVersionAutoSelect, downloadOtherGameData, downloadAllMinecraftData } from './connect' +import { ConnectOptions, getVersionAutoSelect, downloadOtherGameData, downloadAllMinecraftData } from './connect' import { ref, subscribe } from 'valtio' import { signInMessageState } from './react/SignInMessageProvider' import { updateAuthenticatedAccountData, updateLoadedServerData, updateServerConnectionHistory } from './react/serversStorage' -import packetsPatcher from './mineflayer/plugins/packetsPatcher' import { mainMenuState } from './react/MainMenuRenderApp' import './mobileShim' import { parseFormattedMessagePacket } from './botUtils' -import { getViewerVersionData, getWsProtocolStream, handleCustomChannel } from './viewerConnector' +import { appStartup } from './clientMods' +import { getViewerVersionData, getWsProtocolStream, onBotCreatedViewerHandler } from './viewerConnector' import { getWebsocketStream } from './mineflayer/websocket-core' import { appQueryParams, appQueryParamsArray } from './appParams' import { playerState } from './mineflayer/playerState' import { states } from 'minecraft-protocol' import { initMotionTracking } from './react/uiMotion' import { UserError } from './mineflayer/userError' -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 { 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' +import { loadPluginsIntoWorld } from './react/CreateWorldProvider' window.debug = debug window.beforeRenderFrame = [] @@ -109,7 +109,6 @@ void registerServiceWorker().then(() => { watchFov() initCollisionShapes() initializePacketsReplay() -packetsPatcher() onAppLoad() customChannels() @@ -167,6 +166,8 @@ export async function connect (connectOptions: ConnectOptions) { }) } + loadingTimerState.loading = true + loadingTimerState.start = Date.now() miscUiState.hasErrors = false lastConnectOptions.value = connectOptions @@ -208,8 +209,13 @@ export async function connect (connectOptions: ConnectOptions) { let ended = false let bot!: typeof __type_bot - const destroyAll = () => { + const destroyAll = (wasKicked = false) => { if (ended) return + loadingTimerState.loading = false + const hadConnected = !!bot + if (!wasKicked && miscUiState.appConfig?.allowAutoConnect && appQueryParams.autoConnect && hadConnected) { + location.reload() + } errorAbortController.abort() ended = true progress.end() @@ -251,6 +257,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 +290,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 @@ -293,10 +303,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 @@ -359,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 @@ -392,8 +414,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}` @@ -407,6 +431,7 @@ export async function connect (connectOptions: ConnectOptions) { setLoadingScreenStatus(initialLoadingText) if (parsedServer.isWebSocket) { + loadingTimerState.networkOnlyStart = Date.now() clientDataStream = (await getWebsocketStream(server.host)).mineflayerStream } @@ -450,6 +475,7 @@ export async function connect (connectOptions: ConnectOptions) { if (finalVersion) { // ensure data is downloaded + loadingTimerState.networkOnlyStart ??= Date.now() await downloadMcData(finalVersion) } @@ -533,7 +559,7 @@ export async function connect (connectOptions: ConnectOptions) { }) as unknown as typeof __type_bot window.bot = bot if (connectOptions.viewerWsConnect) { - void handleCustomChannel() + void onBotCreatedViewerHandler() } customEvents.emit('mineflayerBotCreated') if (singleplayer || p2pMultiplayer || localReplaySession) { @@ -603,14 +629,6 @@ export async function connect (connectOptions: ConnectOptions) { } if (!bot) return - if (connectOptions.server) { - bot.loadPlugin(ping) - } - bot.loadPlugin(mouse) - if (!localReplaySession) { - bot.loadPlugin(localRelayServerPlugin) - } - const p2pConnectTimeout = p2pMultiplayer ? setTimeout(() => { throw new UserError('Spawn timeout. There might be error on the other side, check console.') }, 20_000) : undefined // bot.on('inject_allowed', () => { @@ -622,9 +640,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 +666,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() @@ -654,7 +680,8 @@ export async function connect (connectOptions: ConnectOptions) { onBotCreate() bot.once('login', () => { - setLoadingScreenStatus('Loading world') + loadingTimerState.networkOnlyStart = 0 + progress.setMessage('Loading world') }) let worldWasReady = false @@ -799,7 +826,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 +918,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) { @@ -962,4 +996,5 @@ if (initialLoader) { window.pageLoaded = true void possiblyHandleStateVariable() +appViewer.waitBackendLoadPromises.push(appStartup()) registerOpenBenchmarkListener() 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 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/index.ts b/src/mineflayer/plugins/index.ts new file mode 100644 index 00000000..6ac11376 --- /dev/null +++ b/src/mineflayer/plugins/index.ts @@ -0,0 +1,21 @@ +import { lastConnectOptions } from '../../react/AppStatusProvider' +import mouse from './mouse' +import packetsPatcher from './packetsPatcher' +import { localRelayServerPlugin } from './packetsRecording' +import ping from './ping' +import webFeatures from './webFeatures' + +// register +webFeatures() +packetsPatcher() + + +customEvents.on('mineflayerBotCreated', () => { + if (lastConnectOptions.value!.server) { + bot.loadPlugin(ping) + } + bot.loadPlugin(mouse) + if (!lastConnectOptions.value!.worldStateFileContents) { + bot.loadPlugin(localRelayServerPlugin) + } +}) 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/mineflayer/plugins/webFeatures.ts b/src/mineflayer/plugins/webFeatures.ts new file mode 100644 index 00000000..c56d7d66 --- /dev/null +++ b/src/mineflayer/plugins/webFeatures.ts @@ -0,0 +1,12 @@ +import { Bot } from 'mineflayer' +import { getAppLanguage } from '../../optionsStorage' + +export default () => { + customEvents.on('mineflayerBotCreated', () => { + bot.loadPlugin(plugin) + }) +} + +const plugin = (bot: Bot) => { + bot.settings['locale'] = getAppLanguage() +} diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index ef8d9a8e..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,10 +228,31 @@ export const guiOptionsScheme: { return } - {actionsSlot} - - {backAction && } + {actionsSlot} + {!lockConnect && } + {backAction && + + + ) +} 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/react/CreateWorld.tsx b/src/react/CreateWorld.tsx index a936594b..32e67ddb 100644 --- a/src/react/CreateWorld.tsx +++ b/src/react/CreateWorld.tsx @@ -1,11 +1,14 @@ import { useEffect, useState } from 'react' import { proxy, useSnapshot } from 'valtio' import { filesize } from 'filesize' +import { getAvailableServerPlugins } from '../clientMods' +import { showModal } from '../globalState' import Input from './Input' import Screen from './Screen' import Button from './Button' import SelectGameVersion from './SelectGameVersion' import styles from './createWorld.module.css' +import { InputOption, showInputsModal, showOptionsModal } from './SelectOption' // const worldTypes = ['default', 'flat', 'largeBiomes', 'amplified', 'customized', 'buffet', 'debug_all_block_states'] const worldTypes = ['default', 'flat'/* , 'void' */] @@ -15,13 +18,14 @@ export const creatingWorldState = proxy({ title: '', type: worldTypes[0], gameMode: gameModes[0], - version: '' + version: '', + plugins: [] as string[] }) export default ({ cancelClick, createClick, customizeClick, versions, defaultVersion }) => { const [quota, setQuota] = useState('') - const { title, type, version, gameMode } = useSnapshot(creatingWorldState) + const { title, type, version, gameMode, plugins } = useSnapshot(creatingWorldState) useEffect(() => { creatingWorldState.version = defaultVersion void navigator.storage?.estimate?.().then(({ quota, usage }) => { @@ -69,7 +73,38 @@ export default ({ cancelClick, createClick, customizeClick, versions, defaultVer creatingWorldState.gameMode = gameModes[index === gameModes.length - 1 ? 0 : index + 1] }} > - Gamemode: {gameMode} + Game Mode: {gameMode} + + +
+ +
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/DebugOverlay.tsx b/src/react/DebugOverlay.tsx index af0d6675..a7eb068f 100644 --- a/src/react/DebugOverlay.tsx +++ b/src/react/DebugOverlay.tsx @@ -39,6 +39,7 @@ export default () => { const [cursorBlock, setCursorBlock] = useState(null) const [blockInfo, setBlockInfo] = useState<{ customBlockName?: string, modelInfo?: BlockStateModelInfo } | null>(null) const [clientTps, setClientTps] = useState(0) + const [serverTps, setServerTps] = useState(null as null | { value: number, frozen: boolean }) const minecraftYaw = useRef(0) const minecraftQuad = useRef(0) const rendererDevice = appViewer.rendererState.renderer ?? 'No render backend' @@ -155,6 +156,9 @@ export default () => { sent.current.count++ managePackets('sent', name, data) }) + bot._client.on('set_ticking_state' as any, (data) => { + setServerTps({ value: data.tick_rate, frozen: data.is_frozen }) + }) return () => { document.removeEventListener('keydown', handleF3) @@ -180,8 +184,9 @@ 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}

+

Client TPS: {clientTps} {serverTps ? `Server TPS: ${serverTps.value} ${serverTps.frozen ? '(frozen)' : ''}` : ''}

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

Facing (minecraft): {quadsDescription[minecraftQuad.current]} ({minecraftYaw.current.toFixed(1)} {(bot.entity.pitch * -180 / Math.PI).toFixed(1)})

Light: {blockL} ({skyL} sky)

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/LoadingTimer.tsx b/src/react/LoadingTimer.tsx new file mode 100644 index 00000000..c0ef19d1 --- /dev/null +++ b/src/react/LoadingTimer.tsx @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { proxy, useSnapshot } from 'valtio' + +export const loadingTimerState = proxy({ + start: 0, + loading: false, + total: '0.00', + + networkOnlyStart: 0, + networkTimeTotal: '0.0' +}) + +customEvents.on('gameLoaded', () => { + loadingTimerState.loading = false +}) + +export default () => { + // const time = useSnapshot(timerState).start + const { networkTimeTotal, total } = useSnapshot(loadingTimerState) + + useEffect(() => { + const interval = setInterval(() => { + if (!loadingTimerState.loading) return + if (loadingTimerState.networkOnlyStart) { + loadingTimerState.networkTimeTotal = ((Date.now() - loadingTimerState.networkOnlyStart) / 1000).toFixed(2) + } else { + loadingTimerState.total = ((Date.now() - loadingTimerState.start) / 1000).toFixed(2) + } + }, 100) + return () => clearInterval(interval) + }, []) + + return +
+ {total}/{networkTimeTotal} +
+
+} + +const Portal = ({ children, to = document.body }) => { + return createPortal(children, to) +} 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/MineflayerPluginConsole.tsx b/src/react/MineflayerPluginConsole.tsx index e681f882..2e1b6c1a 100644 --- a/src/react/MineflayerPluginConsole.tsx +++ b/src/react/MineflayerPluginConsole.tsx @@ -17,6 +17,7 @@ export const mineflayerConsoleState = proxy({ messages: [] as ConsoleMessage[], replEnabled: false, consoleEnabled: false, + takeoverMode: false }) const MessageLine = ({ message }: { message: ConsoleMessage }) => { 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 +
+