diff --git a/.github/workflows/next-deploy.yml b/.github/workflows/next-deploy.yml index 042302a4..943727eb 100644 --- a/.github/workflows/next-deploy.yml +++ b/.github/workflows/next-deploy.yml @@ -30,6 +30,8 @@ jobs: - name: Write Release Info run: | echo "{\"latestTag\": \"$(git rev-parse --short $GITHUB_SHA)\", \"isCommit\": true}" > assets/release.json + - name: Download Generated Sounds map + run: node scripts/downloadSoundsMap.mjs - name: Build Project Artifacts run: vercel build --token=${{ secrets.VERCEL_TOKEN }} env: @@ -40,8 +42,6 @@ jobs: mkdir -p .vercel/output/static/playground pnpm build-playground cp -r renderer/dist/* .vercel/output/static/playground/ - - name: Download Generated Sounds map - run: node scripts/downloadSoundsMap.mjs - name: Deploy Project Artifacts to Vercel uses: mathiasvr/command-output@v2.0.0 with: diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 4b755f7b..b4908d8d 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -72,6 +72,8 @@ jobs: - name: Write Release Info run: | echo "{\"latestTag\": \"$(git rev-parse --short ${{ github.event.pull_request.head.sha }})\", \"isCommit\": true}" > assets/release.json + - name: Download Generated Sounds map + run: node scripts/downloadSoundsMap.mjs - name: Build Project Artifacts run: vercel build --token=${{ secrets.VERCEL_TOKEN }} env: @@ -90,8 +92,6 @@ jobs: run: | mkdir -p .vercel/output/static/commit echo "" > .vercel/output/static/commit/index.html - - name: Download Generated Sounds map - run: node scripts/downloadSoundsMap.mjs - name: Deploy Project Artifacts to Vercel uses: mathiasvr/command-output@v2.0.0 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 986bf6cc..1294f9ff 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -29,6 +29,8 @@ jobs: run: pnpx zardoy-release empty --skip-github --output-file assets/release.json env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Download Generated Sounds map + run: node scripts/downloadSoundsMap.mjs - run: vercel build --token=${{ secrets.VERCEL_TOKEN }} --prod env: CONFIG_JSON_SOURCE: BUNDLED @@ -38,8 +40,6 @@ jobs: mkdir -p .vercel/output/static/playground pnpm build-playground cp -r renderer/dist/* .vercel/output/static/playground/ - - name: Download Generated Sounds map - run: node scripts/downloadSoundsMap.mjs - name: Deploy Project to Vercel uses: mathiasvr/command-output@v2.0.0 with: 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/package.json b/package.json index ee07230d..9d776082 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", @@ -195,7 +195,7 @@ }, "pnpm": { "overrides": { - "@nxg-org/mineflayer-physics-util": "1.8.6", + "@nxg-org/mineflayer-physics-util": "1.8.7", "buffer": "^6.0.3", "vec3": "0.1.10", "three": "0.154.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7b1da61..faf6991a 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 @@ -2025,8 +2025,8 @@ packages: '@nxg-org/mineflayer-auto-jump@0.7.12': resolution: {integrity: sha512-F5vX/lerlWx/5HVlkDNbvrtQ19PL6iG8i4ItPTIRtjGiFzusDefP7DI226zSFR8Wlaw45qHv0jn814p/4/qVdQ==} - '@nxg-org/mineflayer-physics-util@1.8.6': - resolution: {integrity: sha512-eRn9e9OMvl1+kEfwPPshAl1A5MX0eDWaI7WVRf7ht9qo9N3fKiw+mM/AGPuhVjEr16zUls77P6Sn9cVZJuUdlw==} + '@nxg-org/mineflayer-physics-util@1.8.7': + resolution: {integrity: sha512-wtLYvHqoEFr/j0ny2lyogwjbMvwpFuG2aWI8sI14+EAiGFRpL5+cog2ujSDsnRTZruO7tUXMTiPc1kebjXwfJg==} '@nxg-org/mineflayer-tracker@1.2.1': resolution: {integrity: sha512-SI1ffF8zvg3/ZNE021Ja2W0FZPN+WbQDZf8yFqOcXtPRXAtM9W6HvoACdzXep8BZid7WYgYLIgjKpB+9RqvCNQ==} @@ -6459,9 +6459,9 @@ packages: 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': '*' @@ -11353,10 +11353,10 @@ snapshots: '@nxg-org/mineflayer-auto-jump@0.7.12': dependencies: - '@nxg-org/mineflayer-physics-util': 1.8.6 + '@nxg-org/mineflayer-physics-util': 1.8.7 strict-event-emitter-types: 2.0.0 - '@nxg-org/mineflayer-physics-util@1.8.6': + '@nxg-org/mineflayer-physics-util@1.8.7': dependencies: '@nxg-org/mineflayer-util-plugin': 1.8.4 @@ -16993,7 +16993,7 @@ snapshots: 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 @@ -17437,7 +17437,7 @@ snapshots: mineflayer@https://codeload.github.com/GenerelSchwerz/mineflayer/tar.gz/d459d2ed76a997af1a7c94718ed7d5dee4478b8a(encoding@0.1.13): dependencies: - '@nxg-org/mineflayer-physics-util': 1.8.6 + '@nxg-org/mineflayer-physics-util': 1.8.7 minecraft-data: 3.83.1 minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284(patch_hash=3a55a278c417cc34ff3172cd1de8e22852935cba0586875cbd0635f1ffdaa5ab)(encoding@0.1.13) prismarine-biome: 1.3.0(minecraft-data@3.83.1)(prismarine-registry@1.11.0) 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/utils.ts b/renderer/viewer/lib/utils.ts index 240dfa7f..a1574b5c 100644 --- a/renderer/viewer/lib/utils.ts +++ b/renderer/viewer/lib/utils.ts @@ -22,7 +22,7 @@ export const clearTextureCache = () => { imagesPromises = {} } -export const loadScript = async function (scriptSrc: string): Promise { +export const loadScript = async function (scriptSrc: string, highPriority = true): Promise { const existingScript = document.querySelector(`script[src="${scriptSrc}"]`) if (existingScript) { return existingScript @@ -31,6 +31,10 @@ export const loadScript = async function (scriptSrc: string): Promise { const scriptElement = document.createElement('script') scriptElement.src = scriptSrc + + if (highPriority) { + scriptElement.fetchPriority = 'high' + } scriptElement.async = true scriptElement.addEventListener('load', () => { diff --git a/renderer/viewer/lib/worldDataEmitter.ts b/renderer/viewer/lib/worldDataEmitter.ts index 7dc49ac3..56c71d40 100644 --- a/renderer/viewer/lib/worldDataEmitter.ts +++ b/renderer/viewer/lib/worldDataEmitter.ts @@ -252,7 +252,6 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter TypedEmitter { + worldReadyResolvers = Promise.withResolvers() + worldReadyPromise = this.worldReadyResolvers.promise timeOfTheDay = 0 worldSizeParams = { minY: 0, worldHeight: 256 } @@ -136,12 +139,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' } @@ -152,6 +158,7 @@ export abstract class WorldRendererCommon mainThreadRendering = true backendInfoReport = '-' chunksFullInfo = '-' + workerCustomHandleTime = 0 get version () { return this.displayOptions.version @@ -167,7 +174,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})`) @@ -200,22 +207,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 () { } @@ -295,7 +313,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) @@ -308,12 +326,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 () => { @@ -331,8 +352,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 })}`) @@ -344,7 +368,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) { @@ -407,7 +431,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() } @@ -474,8 +498,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 @@ -488,11 +511,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 { @@ -594,7 +613,8 @@ export abstract class WorldRendererCommon customBlockModels: customBlockModels || undefined }) } - 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) @@ -636,8 +656,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() } } @@ -848,6 +869,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) @@ -859,19 +882,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..5e76646e 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -69,7 +69,7 @@ const appConfig = defineConfig({ tag: 'link', attrs: { rel: 'manifest', - crossorigin: 'use-credentials', + crossorigin: 'anonymous', href: 'manifest.json' }, } @@ -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/build.js b/scripts/build.js index 028a9fdc..2d5e0a2e 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -55,6 +55,7 @@ exports.getSwAdditionalEntries = () => { 'manifest.json', 'worldSaveWorker.js', `textures/entity/squid/squid.png`, + 'sounds.js', // everything but not .map 'static/**/!(*.map)', ] 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 c5b61b69..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 @@ -23,6 +25,11 @@ export type AppConfig = { allowAutoConnect?: boolean splashText?: string pauseLinks?: Array>> + keybindings?: Record + defaultLanguage?: string + displayLanguageSelector?: boolean + supportedLanguages?: string[] + showModsButton?: boolean } export const loadAppConfig = (appConfig: AppConfig) => { @@ -46,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/appViewer.ts b/src/appViewer.ts index 0f29b9a6..ca62bd1b 100644 --- a/src/appViewer.ts +++ b/src/appViewer.ts @@ -89,6 +89,8 @@ export interface GraphicsBackend { } export class AppViewer { + waitBackendLoadPromises = [] as Array> + resourcesManager = new ResourcesManager() worldView: WorldDataEmitter | undefined readonly config: GraphicsBackendConfig = { @@ -114,11 +116,14 @@ export class AppViewer { this.disconnectBackend() } - loadBackend (loader: GraphicsBackendLoader) { + async loadBackend (loader: GraphicsBackendLoader) { if (this.backend) { this.disconnectBackend() } + await Promise.all(this.waitBackendLoadPromises) + this.waitBackendLoadPromises = [] + this.backendLoader = loader const rendererSpecificSettings = {} as Record const rendererSettingsKey = `renderer.${this.backendLoader?.id}` diff --git a/src/appViewerLoad.ts b/src/appViewerLoad.ts index 96e3bf03..53260662 100644 --- a/src/appViewerLoad.ts +++ b/src/appViewerLoad.ts @@ -9,25 +9,27 @@ import { showNotification } from './react/NotificationProvider' const backends = [ createGraphicsBackend, ] -const loadBackend = () => { +const loadBackend = async () => { let backend = backends.find(backend => backend.id === options.activeRenderer) if (!backend) { showNotification(`No backend found for renderer ${options.activeRenderer}`, `Falling back to ${backends[0].id}`, true) backend = backends[0] } - appViewer.loadBackend(backend) + await appViewer.loadBackend(backend) } window.loadBackend = loadBackend if (process.env.SINGLE_FILE_BUILD_MODE) { const unsub = subscribeKey(miscUiState, 'fsReady', () => { if (miscUiState.fsReady) { // don't do it earlier to load fs and display menu faster - loadBackend() + void loadBackend() unsub() } }) } else { - loadBackend() + setTimeout(() => { + void loadBackend() + }) } const animLoop = () => { @@ -40,10 +42,10 @@ watchOptionsAfterViewerInit() // reset backend when renderer changes -subscribeKey(options, 'activeRenderer', () => { +subscribeKey(options, 'activeRenderer', async () => { if (appViewer.currentDisplay === 'world' && bot) { appViewer.resetBackend(true) - loadBackend() + await loadBackend() void appViewer.startWithBot() } }) diff --git a/src/clientMods.ts b/src/clientMods.ts new file mode 100644 index 00000000..a6242d1a --- /dev/null +++ b/src/clientMods.ts @@ -0,0 +1,582 @@ +/* eslint-disable no-await-in-loop */ +import { openDB } from 'idb' +import * as React from 'react' +import * as valtio from 'valtio' +import * as valtioUtils from 'valtio/utils' +import { gt } from 'semver' +import { proxy } from 'valtio' +import { options } from './optionsStorage' +import { appStorage } from './react/appStorageProvider' +import { showInputsModal, showOptionsModal } from './react/SelectOption' +import { ProgressReporter } from './core/progressReporter' + +let sillyProtection = false +const protectRuntime = () => { + if (sillyProtection) return + sillyProtection = true + const sensetiveKeys = new Set(['authenticatedAccounts', 'serversList', 'username']) + const proxy = new Proxy(window.localStorage, { + get (target, prop) { + if (typeof prop === 'string') { + if (sensetiveKeys.has(prop)) { + console.warn(`Access to sensitive key "${prop}" was blocked`) + return null + } + if (prop === 'getItem') { + return (key: string) => { + if (sensetiveKeys.has(key)) { + console.warn(`Access to sensitive key "${key}" via getItem was blocked`) + return null + } + return target.getItem(key) + } + } + if (prop === 'setItem') { + return (key: string, value: string) => { + if (sensetiveKeys.has(key)) { + console.warn(`Attempt to set sensitive key "${key}" via setItem was blocked`) + return + } + target.setItem(key, value) + } + } + if (prop === 'removeItem') { + return (key: string) => { + if (sensetiveKeys.has(key)) { + console.warn(`Attempt to delete sensitive key "${key}" via removeItem was blocked`) + return + } + target.removeItem(key) + } + } + if (prop === 'clear') { + console.warn('Attempt to clear localStorage was blocked') + return () => {} + } + } + return Reflect.get(target, prop) + }, + set (target, prop, value) { + if (typeof prop === 'string' && sensetiveKeys.has(prop)) { + console.warn(`Attempt to set sensitive key "${prop}" was blocked`) + return false + } + return Reflect.set(target, prop, value) + }, + deleteProperty (target, prop) { + if (typeof prop === 'string' && sensetiveKeys.has(prop)) { + console.warn(`Attempt to delete sensitive key "${prop}" was blocked`) + return false + } + return Reflect.deleteProperty(target, prop) + } + }) + Object.defineProperty(window, 'localStorage', { + value: proxy, + writable: false, + configurable: false, + }) +} + +// #region Database +const dbPromise = openDB('mods-db', 1, { + upgrade (db) { + db.createObjectStore('mods', { + keyPath: 'name', + }) + db.createObjectStore('repositories', { + keyPath: 'url', + }) + }, +}) + +// mcraft-repo.json +export interface McraftRepoFile { + packages: ClientModDefinition[] + /** @default true */ + prefix?: string | boolean + name?: string // display name + description?: string + mirrorUrls?: string[] + autoUpdateOverride?: boolean + lastUpdated?: number +} +export interface Repository extends McraftRepoFile { + url: string +} + +export interface ClientMod { + name: string; // unique identifier like owner.name + version: string + enabled?: boolean + + scriptMainUnstable?: string; + serverPlugin?: string + // serverPlugins?: string[] + // mesherThread?: string + stylesGlobal?: string + threeJsBackend?: string // three.js + // stylesLocal?: string + + requiresNetwork?: boolean + fullyOffline?: boolean + description?: string + author?: string + section?: string + autoUpdateOverride?: boolean + lastUpdated?: number + wasModifiedLocally?: boolean + // todo depends, hashsum +} + +const cleanupFetchedModData = (mod: ClientModDefinition | Record) => { + delete mod['enabled'] + delete mod['repo'] + delete mod['autoUpdateOverride'] + delete mod['lastUpdated'] + delete mod['wasModifiedLocally'] + return mod +} + +export type ClientModDefinition = Omit & { + scriptMainUnstable?: boolean + stylesGlobal?: boolean + serverPlugin?: boolean + threeJsBackend?: boolean +} + +export async function saveClientModData (data: ClientMod) { + const db = await dbPromise + data.lastUpdated = Date.now() + await db.put('mods', data) + modsReactiveUpdater.counter++ +} + +async function getPlugin (name: string) { + const db = await dbPromise + return db.get('mods', name) as Promise +} + +async function getAllMods () { + const db = await dbPromise + return db.getAll('mods') as Promise +} + +async function deletePlugin (name) { + const db = await dbPromise + await db.delete('mods', name) + modsReactiveUpdater.counter++ +} + +async function removeAllMods () { + const db = await dbPromise + await db.clear('mods') + modsReactiveUpdater.counter++ +} + +// --- + +async function saveRepository (data: Repository) { + const db = await dbPromise + data.lastUpdated = Date.now() + await db.put('repositories', data) +} + +async function getRepository (url: string) { + const db = await dbPromise + return db.get('repositories', url) as Promise +} + +async function getAllRepositories () { + const db = await dbPromise + return db.getAll('repositories') as Promise +} +window.getAllRepositories = getAllRepositories + +async function deleteRepository (url) { + const db = await dbPromise + await db.delete('repositories', url) +} + +// --- + +// #endregion + +window.mcraft = { + version: process.env.RELEASE_TAG, + build: process.env.BUILD_VERSION, + ui: {}, + React, + valtio: { + ...valtio, + ...valtioUtils, + }, + // openDB +} + +const activateMod = async (mod: ClientMod, reason: string) => { + if (mod.enabled === false) return false + protectRuntime() + console.debug(`Activating mod ${mod.name} (${reason})...`) + window.loadedMods ??= {} + if (window.loadedMods[mod.name]) { + console.warn(`Mod is ${mod.name} already loaded, skipping activation...`) + return false + } + if (mod.stylesGlobal) { + const style = document.createElement('style') + style.textContent = mod.stylesGlobal + style.id = `mod-${mod.name}` + document.head.appendChild(style) + } + if (mod.scriptMainUnstable) { + const blob = new Blob([mod.scriptMainUnstable], { type: 'text/javascript' }) + const url = URL.createObjectURL(blob) + // eslint-disable-next-line no-useless-catch + try { + const module = await import(/* webpackIgnore: true */ url) + module.default?.(structuredClone(mod)) + window.loadedMods[mod.name] ??= {} + window.loadedMods[mod.name].mainUnstableModule = module + } catch (e) { + throw e + } + URL.revokeObjectURL(url) + } + if (mod.threeJsBackend) { + const blob = new Blob([mod.threeJsBackend], { type: 'text/javascript' }) + const url = URL.createObjectURL(blob) + // eslint-disable-next-line no-useless-catch + try { + const module = await import(/* webpackIgnore: true */ url) + // todo + window.loadedMods[mod.name] ??= {} + // for accessing global world var + window.loadedMods[mod.name].threeJsBackendModule = module + } catch (e) { + throw e + } + URL.revokeObjectURL(url) + } + mod.enabled = true + return true +} + +export const appStartup = async () => { + void checkModsUpdates() + + const mods = await getAllMods() + for (const mod of mods) { + await activateMod(mod, 'autostart').catch(e => { + modsErrors[mod.name] ??= [] + modsErrors[mod.name].push(`startup: ${String(e)}`) + console.error(`Error activating mod on startup ${mod.name}:`, e) + }) + } +} + +export const modsUpdateStatus = proxy({} as Record) +export const modsWaitingReloadStatus = proxy({} as Record) +export const modsErrors = proxy({} as Record) + +const normalizeRepoUrl = (url: string) => { + if (url.startsWith('https://')) return url + if (url.startsWith('http://')) return url + if (url.startsWith('//')) return `https:${url}` + return `https://raw.githubusercontent.com/${url}/master` +} + +const installOrUpdateMod = async (repo: Repository, mod: ClientModDefinition, activate = true, progress?: ProgressReporter) => { + // eslint-disable-next-line no-useless-catch + try { + const fetchData = async (urls: string[]) => { + const errored = [] as string[] + // eslint-disable-next-line no-unreachable-loop + for (const urlTemplate of urls) { + const modNameOnly = mod.name.split('.').pop() + const modFolder = repo.prefix === false ? modNameOnly : typeof repo.prefix === 'string' ? `${repo.prefix}/${modNameOnly}` : mod.name + const url = new URL(`${modFolder}/${urlTemplate}`, normalizeRepoUrl(repo.url).replace(/\/$/, '') + '/').href + // eslint-disable-next-line no-useless-catch + try { + const response = await fetch(url) + if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`) + return await response.text() + } catch (e) { + // errored.push(String(e)) + throw e + } + } + console.warn(`[${mod.name}] Error installing component of ${urls[0]}: ${errored.join(', ')}`) + return undefined + } + if (mod.stylesGlobal) { + await progress?.executeWithMessage( + `Downloading ${mod.name} styles`, + async () => { + mod.stylesGlobal = await fetchData(['global.css']) as any + } + ) + } + if (mod.scriptMainUnstable) { + await progress?.executeWithMessage( + `Downloading ${mod.name} script`, + async () => { + mod.scriptMainUnstable = await fetchData(['mainUnstable.js']) as any + } + ) + } + if (mod.threeJsBackend) { + await progress?.executeWithMessage( + `Downloading ${mod.name} three.js backend`, + async () => { + mod.threeJsBackend = await fetchData(['three.js']) as any + } + ) + } + if (mod.serverPlugin) { + if (mod.name.endsWith('.disabled')) throw new Error(`Mod name ${mod.name} can't end with .disabled`) + await progress?.executeWithMessage( + `Downloading ${mod.name} server plugin`, + async () => { + mod.serverPlugin = await fetchData(['serverPlugin.js']) as any + } + ) + } + if (activate) { + // todo try to de-activate mod if it's already loaded + if (window.loadedMods?.[mod.name]) { + modsWaitingReloadStatus[mod.name] = true + } else { + await activateMod(mod as ClientMod, 'install') + } + } + await saveClientModData(mod as ClientMod) + delete modsUpdateStatus[mod.name] + } catch (e) { + // console.error(`Error installing mod ${mod.name}:`, e) + throw e + } +} + +const checkRepositoryUpdates = async (repo: Repository) => { + for (const mod of repo.packages) { + + const modExisting = await getPlugin(mod.name) + if (modExisting?.version && gt(mod.version, modExisting.version)) { + modsUpdateStatus[mod.name] = [modExisting.version, mod.version] + if (options.modsAutoUpdate === 'always' && (!repo.autoUpdateOverride && !modExisting.autoUpdateOverride)) { + void installOrUpdateMod(repo, mod).catch(e => { + console.error(`Error updating mod ${mod.name}:`, e) + }) + } + } + } + +} + +export const fetchRepository = async (urlOriginal: string, url: string, hasMirrors = false) => { + const fetchUrl = normalizeRepoUrl(url).replace(/\/$/, '') + '/mcraft-repo.json' + try { + const response = await fetch(fetchUrl).then(async res => res.json()) + if (!response.packages) throw new Error(`No packages field in the response json of the repository: ${fetchUrl}`) + response.autoUpdateOverride = (await getRepository(urlOriginal))?.autoUpdateOverride + response.url = urlOriginal + void saveRepository(response) + modsReactiveUpdater.counter++ + return true + } catch (e) { + console.warn(`Error fetching repository (trying other mirrors) ${url}:`, e) + return false + } +} + +export const fetchAllRepositories = async () => { + const repositories = await getAllRepositories() + await Promise.all(repositories.map(async (repo) => { + const allUrls = [repo.url, ...(repo.mirrorUrls || [])] + for (const [i, url] of allUrls.entries()) { + const isLast = i === allUrls.length - 1 + + if (await fetchRepository(repo.url, url, !isLast)) break + } + })) + appStorage.modsAutoUpdateLastCheck = Date.now() +} + +const checkModsUpdates = async () => { + await autoRefreshModRepositories() + for (const repo of await getAllRepositories()) { + + await checkRepositoryUpdates(repo) + } +} + +const autoRefreshModRepositories = async () => { + if (options.modsAutoUpdate === 'never') return + const lastCheck = appStorage.modsAutoUpdateLastCheck + if (lastCheck && Date.now() - lastCheck < 1000 * 60 * 60 * options.modsUpdatePeriodCheck) return + await fetchAllRepositories() + // todo think of not updating check timestamp on offline access +} + +export const installModByName = async (repoUrl: string, name: string, progress?: ProgressReporter) => { + progress?.beginStage('main', `Installing ${name}`) + const repo = await getRepository(repoUrl) + if (!repo) throw new Error(`Repository ${repoUrl} not found`) + const mod = repo.packages.find(m => m.name === name) + if (!mod) throw new Error(`Mod ${name} not found in repository ${repoUrl}`) + await installOrUpdateMod(repo, mod, undefined, progress) + progress?.endStage('main') +} + +export const uninstallModAction = async (name: string) => { + const choice = await showOptionsModal(`Uninstall mod ${name}?`, ['Yes']) + if (!choice) return + await deletePlugin(name) + window.loadedMods ??= {} + if (window.loadedMods[name]) { + // window.loadedMods[name].default?.(null) + delete window.loadedMods[name] + modsWaitingReloadStatus[name] = true + } + // Clear any errors associated with the mod + delete modsErrors[name] +} + +export const setEnabledModAction = async (name: string, newEnabled: boolean) => { + const mod = await getPlugin(name) + if (!mod) throw new Error(`Mod ${name} not found`) + if (newEnabled) { + mod.enabled = true + if (!window.loadedMods?.[mod.name]) { + await activateMod(mod, 'manual') + } + } else { + // todo deactivate mod + mod.enabled = false + if (window.loadedMods?.[mod.name]) { + if (window.loadedMods[mod.name]?.threeJsBackendModule) { + window.loadedMods[mod.name].threeJsBackendModule.deactivate() + delete window.loadedMods[mod.name].threeJsBackendModule + } + if (window.loadedMods[mod.name]?.mainUnstableModule) { + window.loadedMods[mod.name].mainUnstableModule.deactivate() + delete window.loadedMods[mod.name].mainUnstableModule + } + + if (Object.keys(window.loadedMods[mod.name]).length === 0) { + delete window.loadedMods[mod.name] + } + } + } + await saveClientModData(mod) +} + +export const modsReactiveUpdater = proxy({ + counter: 0 +}) + +export const getAllModsDisplayList = async () => { + const repos = await getAllRepositories() + const installedMods = await getAllMods() + const modsWithoutRepos = installedMods.filter(mod => !repos.some(repo => repo.packages.some(m => m.name === mod.name))) + const mapMods = (mapMods: ClientMod[]) => mapMods.map(mod => ({ + ...mod, + installed: installedMods.find(m => m.name === mod.name), + activated: !!window.loadedMods?.[mod.name], + installedVersion: installedMods.find(m => m.name === mod.name)?.version, + canBeActivated: mod.scriptMainUnstable || mod.stylesGlobal, + })) + return { + repos: repos.map(repo => ({ + ...repo, + packages: mapMods(repo.packages as ClientMod[]), + })), + modsWithoutRepos: mapMods(modsWithoutRepos), + } +} + +export const removeRepositoryAction = async (url: string) => { + // todo remove mods + const choice = await showOptionsModal('Remove repository? Installed mods wont be automatically removed.', ['Yes']) + if (!choice) return + await deleteRepository(url) + modsReactiveUpdater.counter++ +} + +export const selectAndRemoveRepository = async () => { + const repos = await getAllRepositories() + const choice = await showOptionsModal('Select repository to remove', repos.map(repo => repo.url)) + if (!choice) return + await removeRepositoryAction(choice) +} + +export const addRepositoryAction = async () => { + const { url } = await showInputsModal('Add repository', { + url: { + type: 'text', + label: 'Repository URL or slug', + placeholder: 'github-owner/repo-name', + }, + }) + if (!url) return + await fetchRepository(url, url) +} + +export const getServerPlugin = async (plugin: string) => { + const mod = await getPlugin(plugin) + if (!mod) return null + if (mod.serverPlugin) { + return { + content: mod.serverPlugin, + version: mod.version + } + } + return null +} + +export const getAvailableServerPlugins = async () => { + const mods = await getAllMods() + return mods.filter(mod => mod.serverPlugin) +} + +window.inspectInstalledMods = getAllMods + +type ModifiableField = { + field: string + label: string + language: string + getContent?: () => string +} + +// --- + +export const getAllModsModifiableFields = () => { + const fields: ModifiableField[] = [ + { + field: 'scriptMainUnstable', + label: 'Main Thread Script (unstable)', + language: 'js' + }, + { + field: 'stylesGlobal', + label: 'Global CSS Styles', + language: 'css' + }, + { + field: 'threeJsBackend', + label: 'Three.js Renderer Backend Thread', + language: 'js' + }, + { + field: 'serverPlugin', + label: 'Built-in server plugin', + language: 'js' + } + ] + return fields +} + +export const getModModifiableFields = (mod: ClientMod): ModifiableField[] => { + return getAllModsModifiableFields().filter(field => mod[field.field]) +} diff --git a/src/connect.ts b/src/connect.ts index 134b86da..b68e4325 100644 --- a/src/connect.ts +++ b/src/connect.ts @@ -70,8 +70,12 @@ export const loadMinecraftData = async (version: string) => { miscUiState.loadedDataVersion = version } -export const downloadAllMinecraftData = async () => { +export type AssetDownloadReporter = (asset: string, isDone: boolean) => void + +export const downloadAllMinecraftData = async (reporter?: AssetDownloadReporter) => { + reporter?.('mc-data', false) await window._LOAD_MC_DATA() + reporter?.('mc-data', true) } const loadFonts = async () => { @@ -84,6 +88,12 @@ const loadFonts = async () => { } } -export const downloadOtherGameData = async () => { - await Promise.all([loadFonts(), downloadSoundsIfNeeded()]) +export const downloadOtherGameData = async (reporter?: AssetDownloadReporter) => { + reporter?.('fonts', false) + reporter?.('sounds', false) + + await Promise.all([ + loadFonts().then(() => reporter?.('fonts', true)), + downloadSoundsIfNeeded().then(() => reporter?.('sounds', true)) + ]) } diff --git a/src/core/progressReporter.ts b/src/core/progressReporter.ts index a222272d..c76bfb0b 100644 --- a/src/core/progressReporter.ts +++ b/src/core/progressReporter.ts @@ -1,6 +1,7 @@ import { setLoadingScreenStatus } from '../appStatus' import { appStatusState } from '../react/AppStatusProvider' import { hideNotification, showNotification } from '../react/NotificationProvider' +import { pixelartIcons } from '../react/PixelartIcon' export interface ProgressReporter { currentMessage: string | undefined @@ -170,7 +171,7 @@ export const createNotificationProgressReporter = (endMessage?: string): Progres }, end () { if (endMessage) { - showNotification(endMessage, '', false, '', undefined, true) + showNotification(endMessage, '', false, pixelartIcons.check, undefined, true) } else { hideNotification(id) } diff --git a/src/defaultLocalServerOptions.js b/src/defaultLocalServerOptions.js index cd949567..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/index.ts b/src/index.ts index 3da14257..3c897619 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,30 +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 = [] @@ -110,7 +109,6 @@ void registerServiceWorker().then(() => { watchFov() initCollisionShapes() initializePacketsReplay() -packetsPatcher() onAppLoad() customChannels() @@ -168,6 +166,8 @@ export async function connect (connectOptions: ConnectOptions) { }) } + loadingTimerState.loading = true + loadingTimerState.start = Date.now() miscUiState.hasErrors = false lastConnectOptions.value = connectOptions @@ -211,6 +211,7 @@ export async function connect (connectOptions: ConnectOptions) { let bot!: typeof __type_bot const destroyAll = (wasKicked = false) => { if (ended) return + loadingTimerState.loading = false const hadConnected = !!bot if (!wasKicked && miscUiState.appConfig?.allowAutoConnect && appQueryParams.autoConnect && hadConnected) { location.reload() @@ -301,11 +302,24 @@ export async function connect (connectOptions: ConnectOptions) { const serverOptions = defaultsDeep({}, connectOptions.serverOverrides ?? {}, options.localServerOptions, defaultServerOptions) Object.assign(serverOptions, connectOptions.serverOverridesFlat ?? {}) - await progress.executeWithMessage('Downloading minecraft data', 'download-mcdata', async () => { + await progress.executeWithMessage('Downloading Minecraft data', 'download-mcdata', async () => { + loadingTimerState.networkOnlyStart = Date.now() + + let downloadingAssets = [] as string[] + const reportAssetDownload = (asset: string, isDone: boolean) => { + if (isDone) { + downloadingAssets = downloadingAssets.filter(a => a !== asset) + } else { + downloadingAssets.push(asset) + } + progress.setSubStage('download-mcdata', `(${downloadingAssets.join(', ')})`) + } + await Promise.all([ - downloadAllMinecraftData(), - downloadOtherGameData() + downloadAllMinecraftData(reportAssetDownload), + downloadOtherGameData(reportAssetDownload) ]) + loadingTimerState.networkOnlyStart = 0 }) let dataDownloaded = false @@ -315,7 +329,7 @@ export async function connect (connectOptions: ConnectOptions) { appViewer.resourcesManager.currentConfig = { version, texturesVersion: options.useVersionsTextures || undefined } await progress.executeWithMessage( - 'Loading minecraft data', + 'Processing downloaded Minecraft data', async () => { await appViewer.resourcesManager.loadSourceData(version) } @@ -368,6 +382,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 @@ -401,8 +425,10 @@ export async function connect (connectOptions: ConnectOptions) { } else if (connectOptions.server) { if (!finalVersion) { const versionAutoSelect = getVersionAutoSelect() - setLoadingScreenStatus(`Fetching server version. Preffered: ${versionAutoSelect}`) + const wrapped = createWrappedProgressReporter(progress, `Fetching server version. Preffered: ${versionAutoSelect}`) + loadingTimerState.networkOnlyStart = Date.now() const autoVersionSelect = await getServerInfo(server.host, server.port ? Number(server.port) : undefined, versionAutoSelect) + wrapped.end() finalVersion = autoVersionSelect.version } initialLoadingText = `Connecting to server ${server.host}:${server.port ?? 25_565} with version ${finalVersion}` @@ -416,6 +442,7 @@ export async function connect (connectOptions: ConnectOptions) { setLoadingScreenStatus(initialLoadingText) if (parsedServer.isWebSocket) { + loadingTimerState.networkOnlyStart = Date.now() clientDataStream = (await getWebsocketStream(server.host)).mineflayerStream } @@ -459,6 +486,7 @@ export async function connect (connectOptions: ConnectOptions) { if (finalVersion) { // ensure data is downloaded + loadingTimerState.networkOnlyStart ??= Date.now() await downloadMcData(finalVersion) } @@ -542,7 +570,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) { @@ -612,14 +640,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', () => { @@ -671,7 +691,8 @@ export async function connect (connectOptions: ConnectOptions) { onBotCreate() bot.once('login', () => { - setLoadingScreenStatus('Loading world') + loadingTimerState.networkOnlyStart = 0 + progress.setMessage('Loading world') }) let worldWasReady = false @@ -986,4 +1007,5 @@ if (initialLoader) { window.pageLoaded = true void possiblyHandleStateVariable() +appViewer.waitBackendLoadPromises.push(appStartup()) registerOpenBenchmarkListener() 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 55d107ce..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 && + +
+ +
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 610bcea9..aabd50e7 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) @@ -182,7 +186,7 @@ export default () => {

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)

@@ -267,14 +271,17 @@ const hardcodedListOfDebugPacketsToIgnore = { 'playerlist_header', 'scoreboard_objective', 'scoreboard_score', - 'entity_status' + 'entity_status', + 'set_ticking_state', + 'ping_response' ], sent: [ 'pong', 'position', 'look', 'keep_alive', - 'position_look' + 'position_look', + 'ping_request' ] } 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/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 +
+