diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bf75f68..ce88481a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,25 +40,25 @@ jobs: # if: ${{ github.event.pull_request.base.ref == 'release' }} # env: # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - dedupe-check: - runs-on: ubuntu-latest - if: github.event.pull_request.head.ref == 'next' - steps: - - name: Checkout repository - uses: actions/checkout@v2 + # dedupe-check: + # runs-on: ubuntu-latest + # if: github.event.pull_request.head.ref == 'next' + # steps: + # - name: Checkout repository + # uses: actions/checkout@v2 - - name: Install pnpm - run: npm install -g pnpm@9.0.4 - - - name: Run pnpm dedupe - run: pnpm dedupe + # - name: Install pnpm + # run: npm install -g pnpm@9.0.4 - - name: Check for changes - run: | - if ! git diff --exit-code --quiet pnpm-lock.yaml; then - echo "pnpm dedupe introduced changes:" - git diff --color=always pnpm-lock.yaml - exit 1 - else - echo "No changes detected after pnpm dedupe in pnpm-lock.yaml" - fi \ No newline at end of file + # - name: Run pnpm dedupe + # run: pnpm dedupe + + # - name: Check for changes + # run: | + # if ! git diff --exit-code --quiet pnpm-lock.yaml; then + # echo "pnpm dedupe introduced changes:" + # git diff --color=always pnpm-lock.yaml + # exit 1 + # else + # echo "No changes detected after pnpm dedupe in pnpm-lock.yaml" + # fi diff --git a/Dockerfile b/Dockerfile index 484d158e..be9d7815 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,9 +6,14 @@ WORKDIR /app COPY . /app # install pnpm RUN npm i -g pnpm@9.0.4 +# Build arguments +ARG DOWNLOAD_SOUNDS=false +ARG DISABLE_SERVICE_WORKER=false # TODO need flat --no-root-optional RUN node ./scripts/dockerPrepare.mjs RUN pnpm i +# Download sounds if flag is enabled +RUN if [ "$DOWNLOAD_SOUNDS" = "true" ] ; then node scripts/downloadSoundsMap.mjs ; fi # TODO for development # EXPOSE 9090 @@ -17,7 +22,9 @@ RUN pnpm i # ENTRYPOINT ["pnpm", "run", "run-all"] # only for prod -RUN GITHUB_REPOSITORY=zardoy/minecraft-web-client pnpm run build +RUN GITHUB_REPOSITORY=zardoy/minecraft-web-client \ + DISABLE_SERVICE_WORKER=$DISABLE_SERVICE_WORKER \ + pnpm run build # ---- Run Stage ---- FROM node:18-alpine @@ -31,5 +38,5 @@ RUN npm i -g pnpm@9.0.4 RUN npm init -yp RUN pnpm i express github:zardoy/prismarinejs-net-browserify compression cors EXPOSE 8080 -VOLUME /app/dist +VOLUME /app/public ENTRYPOINT ["node", "server.js", "--prod"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e040cd3..491cd559 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,7 +147,7 @@ importers: version: 2.0.4 net-browserify: specifier: github:zardoy/prismarinejs-net-browserify - version: https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/ab3721ca833308a0be099d14ea0053fbd8459ace + version: https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/624cc67c16f5e8b23b772e7eaabae16ba84b8590 node-gzip: specifier: ^1.1.2 version: 1.1.2 @@ -162,7 +162,7 @@ importers: version: 6.1.1 prismarine-provider-anvil: specifier: github:zardoy/prismarine-provider-anvil#everything - version: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/a3a7d031069373cc3e0cd05e54512dd9461ca34b(minecraft-data@3.83.1) + version: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.83.1) prosemirror-example-setup: specifier: ^1.2.2 version: 1.2.2 @@ -6633,8 +6633,8 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - net-browserify@https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/ab3721ca833308a0be099d14ea0053fbd8459ace: - resolution: {tarball: https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/ab3721ca833308a0be099d14ea0053fbd8459ace} + net-browserify@https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/624cc67c16f5e8b23b772e7eaabae16ba84b8590: + resolution: {tarball: https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/624cc67c16f5e8b23b772e7eaabae16ba84b8590} version: 0.2.4 nice-try@1.0.5: @@ -7188,8 +7188,8 @@ packages: resolution: {tarball: https://codeload.github.com/zardoy/prismarine-physics/tar.gz/353e25b800149393f40539ec381218be44cbb03b} version: 1.9.0 - prismarine-provider-anvil@https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/a3a7d031069373cc3e0cd05e54512dd9461ca34b: - resolution: {tarball: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/a3a7d031069373cc3e0cd05e54512dd9461ca34b} + prismarine-provider-anvil@https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7: + resolution: {tarball: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7} version: 2.8.0 prismarine-realms@1.3.2: @@ -12791,7 +12791,7 @@ snapshots: prismarine-entity: 2.3.1 prismarine-item: 1.16.0 prismarine-nbt: 2.5.0 - prismarine-provider-anvil: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/a3a7d031069373cc3e0cd05e54512dd9461ca34b(minecraft-data@3.83.1) + prismarine-provider-anvil: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.83.1) prismarine-windows: 2.9.0 prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c rambda: 9.2.0 @@ -17224,7 +17224,7 @@ snapshots: neo-async@2.6.2: {} - net-browserify@https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/ab3721ca833308a0be099d14ea0053fbd8459ace: + net-browserify@https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/624cc67c16f5e8b23b772e7eaabae16ba84b8590: dependencies: body-parser: 1.20.2 express: 4.18.2 @@ -17869,7 +17869,7 @@ snapshots: prismarine-nbt: 2.5.0 vec3: 0.1.8 - prismarine-provider-anvil@https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/a3a7d031069373cc3e0cd05e54512dd9461ca34b(minecraft-data@3.83.1): + prismarine-provider-anvil@https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.83.1): dependencies: prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/e68e9a423b5b1907535878fb636f12c28a1a9374(minecraft-data@3.83.1) diff --git a/prismarine-viewer/viewer/lib/entities.ts b/prismarine-viewer/viewer/lib/entities.ts index 5e7a96bf..f5f4b21f 100644 --- a/prismarine-viewer/viewer/lib/entities.ts +++ b/prismarine-viewer/viewer/lib/entities.ts @@ -20,6 +20,7 @@ import { getMesh } from './entity/EntityMesh' import { WalkingGeneralSwing } from './entity/animations' import { disposeObject } from './threeJsUtils' import { armorModels } from './entity/objModels' +import { Viewer } from './viewer' const { loadTexture } = globalThis.isElectron ? require('./utils.electron.js') : require('./utils') export const TWEEN_DURATION = 120 @@ -163,12 +164,12 @@ const nametags = {} const isFirstUpperCase = (str) => str.charAt(0) === str.charAt(0).toUpperCase() -function getEntityMesh (entity, scene, options, overrides) { +function getEntityMesh (entity, world, options, overrides) { if (entity.name) { try { // https://github.com/PrismarineJS/prismarine-viewer/pull/410 const entityName = (isFirstUpperCase(entity.name) ? snakeCase(entity.name) : entity.name).toLowerCase() - const e = new Entity.EntityMesh('1.16.4', entityName, scene, overrides) + const e = new Entity.EntityMesh('1.16.4', entityName, world, overrides) if (e.mesh) { addNametag(entity, options, e.mesh) @@ -211,6 +212,8 @@ export class Entities extends EventEmitter { clock = new THREE.Clock() rendering = true itemsTexture: THREE.Texture | null = null + cachedMapsImages = {} as Record + itemFrameMaps = {} as Record>> getItemUv: undefined | ((idOrName: number | string) => { texture: THREE.Texture; u: number; @@ -220,7 +223,7 @@ export class Entities extends EventEmitter { size?: number; }) - constructor (public scene: THREE.Scene) { + constructor (public viewer: Viewer) { super() this.entitiesOptions = {} this.debugMode = 'none' @@ -229,7 +232,7 @@ export class Entities extends EventEmitter { clear () { for (const mesh of Object.values(this.entities)) { - this.scene.remove(mesh) + this.viewer.scene.remove(mesh) disposeObject(mesh) } this.entities = {} @@ -251,9 +254,9 @@ export class Entities extends EventEmitter { this.rendering = rendering for (const ent of entity ? [entity] : Object.values(this.entities)) { if (rendering) { - if (!this.scene.children.includes(ent)) this.scene.add(ent) + if (!this.viewer.scene.children.includes(ent)) this.viewer.scene.add(ent) } else { - this.scene.remove(ent) + this.viewer.scene.remove(ent) } } } @@ -417,6 +420,7 @@ export class Entities extends EventEmitter { } getItemMesh (item) { + // TODO: Render proper model (especially for blocks) instead of flat texture const textureUv = this.getItemUv?.(item.itemId ?? item.blockId) if (textureUv) { // todo use geometry buffer uv instead! @@ -470,9 +474,13 @@ export class Entities extends EventEmitter { update (entity: import('prismarine-entity').Entity & { delete?; pos, name }, overrides) { const isPlayerModel = entity.name === 'player' - if (entity.name === 'zombie' || entity.name === 'zombie_villager' || entity.name === 'husk') { + if (entity.name === 'zombie_villager' || entity.name === 'husk') { overrides.texture = `textures/1.16.4/entity/${entity.name === 'zombie_villager' ? 'zombie_villager/zombie_villager.png' : `zombie/${entity.name}.png`}` } + if (entity.name === 'glow_item_frame') { + if (!overrides.textures) overrides.textures = [] + overrides.textures['background'] = 'block:glow_item_frame' + } // this can be undefined in case where packet entity_destroy was sent twice (so it was already deleted) let e = this.entities[entity.id] @@ -480,7 +488,7 @@ export class Entities extends EventEmitter { if (!e) return if (e.additionalCleanup) e.additionalCleanup() this.emit('remove', entity) - this.scene.remove(e) + this.viewer.scene.remove(e) disposeObject(e) // todo dispose textures as well ? delete this.entities[entity.id] @@ -551,7 +559,7 @@ export class Entities extends EventEmitter { //@ts-expect-error playerObject.animation.isMoving = false } else { - mesh = getEntityMesh(entity, this.scene, this.entitiesOptions, overrides) + mesh = getEntityMesh(entity, this.viewer.world, this.entitiesOptions, overrides) } if (!mesh) return mesh.name = 'mesh' @@ -570,7 +578,7 @@ export class Entities extends EventEmitter { group.add(mesh) group.add(boxHelper) boxHelper.visible = false - this.scene.add(group) + this.viewer.scene.add(group) e = group this.entities[entity.id] = e @@ -694,31 +702,51 @@ export class Entities extends EventEmitter { } // todo handle map, map_chunks events - // if (entity.name === 'item_frame' || entity.name === 'glow_item_frame') { - // const example = { - // "present": true, - // "itemId": 847, - // "itemCount": 1, - // "nbtData": { - // "type": "compound", - // "name": "", - // "value": { - // "map": { - // "type": "int", - // "value": 2146483444 - // }, - // "interactiveboard": { - // "type": "byte", - // "value": 1 - // } - // } - // } - // } - // const item = entity.metadata?.[8] - // if (item.nbtData) { - // const nbt = nbt.simplify(item.nbtData) - // } - // } + let itemFrameMeta = getSpecificEntityMetadata('item_frame', entity) + if (!itemFrameMeta) { + itemFrameMeta = getSpecificEntityMetadata('glow_item_frame', entity) + } + if (itemFrameMeta) { + // TODO: fix type + // todo! fix errors in mc-data (no entities data prior 1.18.2) + const item = (itemFrameMeta?.item ?? entity.metadata?.[8]) as any as { itemId, blockId, components, nbtData: { value: { map: { value: number } } } } + mesh.scale.set(1, 1, 1) + e.rotation.x = -entity.pitch + e.children.find(c => { + if (c.name.startsWith('map_')) { + disposeObject(c) + const existingMapNumber = parseInt(c.name.split('_')[1], 10) + this.itemFrameMaps[existingMapNumber] = this.itemFrameMaps[existingMapNumber]?.filter(mesh => mesh !== c) + if (c instanceof THREE.Mesh) { + c.material?.map?.dispose() + } + return true + } else if (c.name === 'item') { + disposeObject(c) + return true + } + return false + })?.removeFromParent() + if (item && (item.itemId ?? item.blockId ?? 0) !== 0) { + const rotation = (itemFrameMeta.rotation as any as number) ?? 0 + const mapNumber = item.nbtData?.value?.map?.value ?? item.components?.find(x => x.type === 'map_id')?.data + if (mapNumber) { + // TODO: Use proper larger item frame model when a map exists + mesh.scale.set(16 / 12, 16 / 12, 1) + this.addMapModel(e, mapNumber, rotation) + } else { + const itemMesh = this.getItemMesh(item) + if (itemMesh) { + itemMesh.mesh.position.set(0, 0, 0.43) + itemMesh.mesh.scale.set(0.5, 0.5, 0.5) + itemMesh.mesh.rotateY(Math.PI) + itemMesh.mesh.rotateZ(rotation * Math.PI / 4) + itemMesh.mesh.name = 'item' + e.add(itemMesh.mesh) + } + } + } + } if (entity.username) { e.username = entity.username @@ -741,6 +769,74 @@ export class Entities extends EventEmitter { } } + updateMap (mapNumber: string | number, data: string) { + this.cachedMapsImages[mapNumber] = data + let itemFrameMeshes = this.itemFrameMaps[mapNumber] + if (!itemFrameMeshes) return + itemFrameMeshes = itemFrameMeshes.filter(mesh => mesh.parent) + this.itemFrameMaps[mapNumber] = itemFrameMeshes + if (itemFrameMeshes) { + for (const mesh of itemFrameMeshes) { + mesh.material.map = this.loadMap(data) + mesh.material.needsUpdate = true + mesh.visible = true + } + } + } + + addMapModel (entityMesh: THREE.Object3D, mapNumber: number, rotation: number) { + const imageData = this.cachedMapsImages?.[mapNumber] + let texture: THREE.Texture | null = null + if (imageData) { + texture = this.loadMap(imageData) + } + const parameters = { + transparent: true, + alphaTest: 0.1, + } + if (texture) { + parameters['map'] = texture + } + const material = new THREE.MeshLambertMaterial(parameters) + + const mapMesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material) + + mapMesh.rotation.set(0, Math.PI, 0) + entityMesh.add(mapMesh) + let isInvisible = false + entityMesh.traverseVisible(c => { + if (c.name === 'geometry_frame') { + isInvisible = false + } + }) + if (isInvisible) { + mapMesh.position.set(0, 0, 0.499) + } else { + mapMesh.position.set(0, 0, 0.437) + } + mapMesh.rotateZ(Math.PI * 2 - rotation * Math.PI / 2) + mapMesh.name = `map_${mapNumber}` + + if (!texture) { + mapMesh.visible = false + } + + if (!this.itemFrameMaps[mapNumber]) { + this.itemFrameMaps[mapNumber] = [] + } + this.itemFrameMaps[mapNumber].push(mapMesh) + } + + loadMap (data: any) { + const texture = new THREE.TextureLoader().load(data) + if (texture) { + texture.magFilter = THREE.NearestFilter + texture.minFilter = THREE.NearestFilter + texture.needsUpdate = true + } + return texture + } + handleDamageEvent (entityId, damageAmount) { const entityMesh = this.entities[entityId]?.children.find(c => c.name === 'mesh') if (entityMesh) { @@ -808,7 +904,7 @@ function addArmorModel (entityMesh: THREE.Object3D, slotType: string, item: Item material.map = texture }) } else { - mesh = getMesh(texturePath, armorModels.armorModel[slotType]) + mesh = getMesh(viewer.world, texturePath, armorModels.armorModel[slotType]) mesh.name = meshName material = mesh.material material.side = THREE.DoubleSide diff --git a/prismarine-viewer/viewer/lib/entity/EntityMesh.js b/prismarine-viewer/viewer/lib/entity/EntityMesh.js index 69dd95d6..9033489a 100644 --- a/prismarine-viewer/viewer/lib/entity/EntityMesh.js +++ b/prismarine-viewer/viewer/lib/entity/EntityMesh.js @@ -94,7 +94,7 @@ function dot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] } -function addCube(attr, boneId, bone, cube, texWidth = 64, texHeight = 64, mirror = false) { +function addCube(attr, boneId, bone, cube, sameTextureForAllFaces = false, texWidth = 64, texHeight = 64, mirror = false) { const cubeRotation = new THREE.Euler(0, 0, 0) if (cube.rotation) { cubeRotation.x = -cube.rotation[0] * Math.PI / 180 @@ -107,8 +107,15 @@ function addCube(attr, boneId, bone, cube, texWidth = 64, texHeight = 64, mirror const eastOrWest = dir[0] !== 0 const faceUvs = [] for (const pos of corners) { - const u = (cube.uv[0] + dot(pos[3] ? u1 : u0, cube.size)) / texWidth - const v = (cube.uv[1] + dot(pos[4] ? v1 : v0, cube.size)) / texHeight + let u + let v + if (sameTextureForAllFaces) { + u = (cube.uv[0] + pos[3] * cube.size[0]) / texWidth + v = (cube.uv[1] + pos[4] * cube.size[1]) / texHeight + } else { + u = (cube.uv[0] + dot(pos[3] ? u1 : u0, cube.size)) / texWidth + v = (cube.uv[1] + dot(pos[4] ? v1 : v0, cube.size)) / texHeight + } const posX = eastOrWest && mirror ? pos[0] ^ 1 : pos[0] const posY = pos[1] @@ -148,7 +155,23 @@ function addCube(attr, boneId, bone, cube, texWidth = 64, texHeight = 64, mirror } } -export function getMesh(texture, jsonModel, overrides = {}) { +export function getMesh(worldRenderer, texture, jsonModel, overrides = {}) { + let textureWidth = jsonModel.texturewidth ?? 64 + let textureHeight = jsonModel.textureheight ?? 64 + let textureOffset + const useBlockTexture = texture.startsWith('block:') + if (useBlockTexture) { + const blockName = texture.slice(6) + const textureInfo = worldRenderer.blocksAtlasParser.getTextureInfo(blockName) + if (textureInfo) { + textureWidth = worldRenderer.material.map.image.width + textureHeight = worldRenderer.material.map.image.height + textureOffset = [textureInfo.u, textureInfo.v] + } else { + console.error(`Unknown block ${blockName}`) + } + } + const bones = {} const geoData = { @@ -186,7 +209,7 @@ export function getMesh(texture, jsonModel, overrides = {}) { if (jsonBone.cubes) { for (const cube of jsonBone.cubes) { - addCube(geoData, i, bone, cube, jsonModel.texturewidth, jsonModel.textureheight, jsonBone.mirror) + addCube(geoData, i, bone, cube, useBlockTexture, textureWidth, textureHeight, jsonBone.mirror) } } i++ @@ -215,18 +238,25 @@ export function getMesh(texture, jsonModel, overrides = {}) { mesh.bind(skeleton) mesh.scale.set(1 / 16, 1 / 16, 1 / 16) - loadTexture(texture, texture => { - if (material.map) { - // texture is already loaded - return - } - texture.magFilter = THREE.NearestFilter - texture.minFilter = THREE.NearestFilter - texture.flipY = false - texture.wrapS = THREE.RepeatWrapping - texture.wrapT = THREE.RepeatWrapping + if (textureOffset) { + texture = worldRenderer.material.map.clone() + texture.offset.set(textureOffset[0], textureOffset[1]) + texture.needsUpdate = true material.map = texture - }) + } else { + loadTexture(texture.endsWith('.png') || texture.startsWith('data:image/') ? texture : texture + '.png', texture => { + if (material.map) { + // texture is already loaded + return + } + texture.magFilter = THREE.NearestFilter + texture.minFilter = THREE.NearestFilter + texture.flipY = false + texture.wrapS = THREE.RepeatWrapping + texture.wrapT = THREE.RepeatWrapping + material.map = texture + }) + } return mesh } @@ -252,6 +282,7 @@ export const temporaryMap = { 'hopper_minecart': 'minecart', 'command_block_minecart': 'minecart', 'tnt_minecart': 'minecart', + 'glow_item_frame': 'item_frame', 'glow_squid': 'squid', 'trader_llama': 'llama', 'chest_boat': 'boat', @@ -321,7 +352,7 @@ const offsetEntity = { // eslint-disable-next-line @typescript-eslint/no-extraneous-class export class EntityMesh { - constructor(version, type, scene, /** @type {{textures?, rotation?: Record}} */overrides = {}) { + constructor(version, type, worldRenderer, /** @type {{textures?, rotation?: Record}} */overrides = {}) { const originalType = type const mappedValue = temporaryMap[type] if (mappedValue) type = mappedValue @@ -388,7 +419,7 @@ export class EntityMesh { const texture = overrides.textures?.[name] ?? e.textures[name] if (!texture) continue // console.log(JSON.stringify(jsonModel, null, 2)) - const mesh = getMesh(texture + '.png', jsonModel, overrides) + const mesh = getMesh(worldRenderer, texture, jsonModel, overrides) mesh.name = `geometry_${name}` this.mesh.add(mesh) diff --git a/prismarine-viewer/viewer/lib/entity/entities.json b/prismarine-viewer/viewer/lib/entity/entities.json index 9824d418..4436a44b 100644 --- a/prismarine-viewer/viewer/lib/entity/entities.json +++ b/prismarine-viewer/viewer/lib/entity/entities.json @@ -7838,6 +7838,53 @@ } } }, + "item_frame": { + "identifier": "minecraft:item_frame", + "materials": {"default": "item_frame"}, + "textures": { + "background": "block:item_frame", + "frame": "block:oak_planks" + }, + "geometry": { + "background": { + "bones": [ + { + "name": "base" + }, + { + "name": "background", + "parent": "base", + "rotation": [0, 180, 0], + "pivot": [0, 0, 0], + "cubes": [ + {"origin": [-5, -5, -8], "size": [10, 10, 0.5], "uv": [3, 3]} + ] + } + ], + "texturewidth": 16, + "textureheight": 16 + }, + "frame": { + "bones": [ + { + "name": "frame", + "parent": "base", + "rotation": [0, 180, 0], + "pivot": [0, 0, 0], + "cubes": [ + {"origin": [-6, -6, -8], "size": [12, 1, 1], "uv": [2, 2]}, + {"origin": [-6, 5, -8], "size": [12, 1, 1], "uv": [2, 13]}, + {"origin": [-6, -5, -8], "size": [1, 10, 1], "uv": [2, 3]}, + {"origin": [5, -5, -8], "size": [1, 10, 1], "uv": [13, 3]} + ] + } + ], + "texturewidth": 16, + "textureheight": 16 + } + }, + "render_controllers": ["controller.render.item_frame"] + }, "leash_knot": { "identifier": "minecraft:leash_knot", "materials": {"default": "leash_knot"}, @@ -7847,7 +7894,8 @@ "bones": [ { "name": "knot", - "cubes": [{"origin": [-3, 2, -3], "size": [6, 8, 6]}] + "rotation": [0, 180, 0], + "cubes": [{"origin": [5, 6, 5], "size": [6, 8, 6], "uv": [0, 0]}] } ], "texturewidth": 32, diff --git a/prismarine-viewer/viewer/lib/viewer.ts b/prismarine-viewer/viewer/lib/viewer.ts index c7dd7fe5..82c3e661 100644 --- a/prismarine-viewer/viewer/lib/viewer.ts +++ b/prismarine-viewer/viewer/lib/viewer.ts @@ -48,7 +48,7 @@ export class Viewer { this.threeJsWorld = new WorldRendererThree(this.scene, this.renderer, worldConfig) this.setWorld() this.resetScene() - this.entities = new Entities(this.scene) + this.entities = new Entities(this) // this.primitives = new Primitives(this.scene, this.camera) this.domElement = renderer.domElement diff --git a/prismarine-viewer/viewer/lib/worldDataEmitter.ts b/prismarine-viewer/viewer/lib/worldDataEmitter.ts index 61d5a503..e556f7a3 100644 --- a/prismarine-viewer/viewer/lib/worldDataEmitter.ts +++ b/prismarine-viewer/viewer/lib/worldDataEmitter.ts @@ -75,6 +75,10 @@ export class WorldDataEmitter extends EventEmitter { this.eventListeners = { // 'move': botPosition, entitySpawn (e: any) { + if (e.name === 'item_frame' || e.name === 'glow_item_frame') { + // Item frames use block positions in the protocol, not their center. Fix that. + e.position.translate(0.5, 0.5, 0.5) + } emitEntity(e) }, entityUpdate (e: any) { diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 933a6d02..c84c3c92 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -20,6 +20,7 @@ const execAsync = promisify(childProcess.exec) const buildingVersion = new Date().toISOString().split(':')[0] const dev = process.env.NODE_ENV === 'development' +const disableServiceWorker = process.env.DISABLE_SERVICE_WORKER === 'true' let releaseTag let releaseChangelog @@ -59,6 +60,7 @@ const appConfig = defineConfig({ 'process.env.DEPS_VERSIONS': JSON.stringify({}), 'process.env.RELEASE_TAG': JSON.stringify(releaseTag), 'process.env.RELEASE_CHANGELOG': JSON.stringify(releaseChangelog), + 'process.env.DISABLE_SERVICE_WORKER': JSON.stringify(disableServiceWorker), }, }, server: { @@ -103,6 +105,9 @@ const appConfig = defineConfig({ configJson.defaultProxy = ':8080' } fs.writeFileSync('./dist/config.json', JSON.stringify({ ...configJson, ...configLocalJson }), 'utf8') + if (fs.existsSync('./generated/sounds.js')) { + fs.copyFileSync('./generated/sounds.js', './dist/sounds.js') + } // childProcess.execSync('./scripts/prepareSounds.mjs', { stdio: 'inherit' }) // childProcess.execSync('tsx ./scripts/genMcDataTypes.ts', { stdio: 'inherit' }) // childProcess.execSync('tsx ./scripts/genPixelartTypes.ts', { stdio: 'inherit' }) @@ -121,15 +126,17 @@ const appConfig = defineConfig({ prep() }) build.onAfterBuild(async () => { - const { count, size, warnings } = await generateSW({ - // dontCacheBustURLsMatching: [new RegExp('...')], - globDirectory: 'dist', - skipWaiting: true, - clientsClaim: true, - additionalManifestEntries: getSwAdditionalEntries(), - globPatterns: [], - swDest: './dist/service-worker.js', - }) + if (!disableServiceWorker) { + const { count, size, warnings } = await generateSW({ + // dontCacheBustURLsMatching: [new RegExp('...')], + globDirectory: 'dist', + skipWaiting: true, + clientsClaim: true, + additionalManifestEntries: getSwAdditionalEntries(), + globPatterns: [], + swDest: './dist/service-worker.js', + }) + } }) } build.onBeforeStartDevServer(() => prep()) diff --git a/scripts/downloadSoundsMap.mjs b/scripts/downloadSoundsMap.mjs index 3c335f8f..f5791768 100644 --- a/scripts/downloadSoundsMap.mjs +++ b/scripts/downloadSoundsMap.mjs @@ -1,9 +1,12 @@ import fs from 'fs' -const url = 'https://github.com/zardoy/minecraft-web-client/raw/sounds-generated/sounds.js' -const savePath = 'dist/sounds.js' +const url = 'https://github.com/zardoy/minecraft-web-client/raw/sounds-generated/sounds-v2.js' fetch(url).then(res => res.text()).then(data => { - fs.writeFileSync(savePath, data, 'utf8') + if (fs.existsSync('./dist')) { + fs.writeFileSync('./dist/sounds.js', data, 'utf8') + } + fs.mkdirSync('./generated', { recursive: true }) + fs.writeFileSync('./generated/sounds.js', data, 'utf8') if (fs.existsSync('.vercel/output/static/')) { fs.writeFileSync('.vercel/output/static/sounds.js', data, 'utf8') } diff --git a/scripts/prepareSounds.mjs b/scripts/prepareSounds.mjs index 8f3e5bef..f9b8cd60 100644 --- a/scripts/prepareSounds.mjs +++ b/scripts/prepareSounds.mjs @@ -10,26 +10,31 @@ import { build } from 'esbuild' const __dirname = path.dirname(fileURLToPath(new URL(import.meta.url))) -const targetedVersions = ['1.20.1', '1.19.2', '1.18.2', '1.17.1', '1.16.5', '1.15.2', '1.14.4', '1.13.2', '1.12.2', '1.11.2', '1.10.2', '1.9.4', '1.8.9'] +const targetedVersions = ['1.21.1', '1.20.6', '1.20.1', '1.19.2', '1.18.2', '1.17.1', '1.16.5', '1.15.2', '1.14.4', '1.13.2', '1.12.2', '1.11.2', '1.10.2', '1.9.4', '1.8.9'] /** @type {{name, size, hash}[]} */ let prevSounds = null const burgerDataUrl = (version) => `https://raw.githubusercontent.com/Pokechu22/Burger/gh-pages/${version}.json` const burgerDataPath = './generated/burger.json' +const EXISTING_CACHE_PATH = './generated/existing-sounds-cache.json' // const perVersionData: Record { +const downloadAllSoundsAndCreateMap = async () => { + let existingSoundsCache = {} + try { + existingSoundsCache = JSON.parse(await fs.promises.readFile(EXISTING_CACHE_PATH, 'utf8')) + } catch (err) {} const { versions } = await getVersionList() const lastVersion = versions.filter(version => !version.id.includes('w'))[0] // if (lastVersion.id !== targetedVersions[0]) throw new Error('last version is not the same as targetedVersions[0], update') - for (const targetedVersion of targetedVersions) { - const versionData = versions.find(x => x.id === targetedVersion) - if (!versionData) throw new Error('no version data for ' + targetedVersion) - console.log('Getting assets for version', targetedVersion) + for (const version of targetedVersions) { + const versionData = versions.find(x => x.id === version) + if (!versionData) throw new Error('no version data for ' + version) + console.log('Getting assets for version', version) const { assetIndex } = await fetch(versionData.url).then((r) => r.json()) /** @type {{objects: {[a: string]: { size, hash }}}} */ const index = await fetch(assetIndex.url).then((r) => r.json()) @@ -45,26 +50,30 @@ const downloadAllSounds = async () => { const changedSize = soundAssets.filter(x => prevSoundNames.has(x.name) && prevSounds.find(y => y.name === x.name).size !== x.size) console.log('changed size', changedSize.map(x => ({ name: x.name, prev: prevSounds.find(y => y.name === x.name).size, curr: x.size }))) if (addedSounds.length || changedSize.length) { - soundsPathVersionsRemap[targetedVersion] = [...addedSounds, ...changedSize].map(x => x.name.replace('minecraft/sounds/', '').replace('.ogg', '')) + soundsPathVersionsRemap[version] = [...addedSounds, ...changedSize].map(x => x.name.replace('minecraft/sounds/', '').replace('.ogg', '')) } if (addedSounds.length) { - console.log('downloading new sounds for version', targetedVersion) - downloadSounds(addedSounds, targetedVersion + '/') + console.log('downloading new sounds for version', version) + downloadSounds(version, addedSounds, version + '/') } if (changedSize.length) { - console.log('downloading changed sounds for version', targetedVersion) - downloadSounds(changedSize, targetedVersion + '/') + console.log('downloading changed sounds for version', version) + downloadSounds(version, changedSize, version + '/') } } else { - console.log('downloading sounds for version', targetedVersion) - downloadSounds(soundAssets) + console.log('downloading sounds for version', version) + downloadSounds(version, soundAssets) } prevSounds = soundAssets } async function downloadSound({ name, hash, size }, namePath, log) { + const cached = + !!namePath.replace('.ogg', '.mp3').split('/').reduce((acc, cur) => acc?.[cur], existingSoundsCache.sounds) || + !!namePath.replace('.ogg', '.ogg').split('/').reduce((acc, cur) => acc?.[cur], existingSoundsCache.sounds) const savePath = path.resolve(`generated/sounds/${namePath}`) - if (fs.existsSync(savePath)) { + if (cached || fs.existsSync(savePath)) { // console.log('skipped', name) + existingSoundsCache.sounds[namePath] = true return } log() @@ -86,7 +95,12 @@ const downloadAllSounds = async () => { } writer.close() } - async function downloadSounds(assets, addPath = '') { + async function downloadSounds(version, assets, addPath = '') { + if (addPath && existingSoundsCache.sounds[version]) { + console.log('using existing sounds for version', version) + return + } + console.log(version, 'have to download', assets.length, 'sounds') for (let i = 0; i < assets.length; i += 5) { await Promise.all(assets.slice(i, i + 5).map((asset, j) => downloadSound(asset, `${addPath}${asset.name}`, () => { console.log('downloading', addPath, asset.name, i + j, '/', assets.length) @@ -95,6 +109,7 @@ const downloadAllSounds = async () => { } fs.writeFileSync('./generated/soundsPathVersionsRemap.json', JSON.stringify(soundsPathVersionsRemap), 'utf8') + fs.writeFileSync(EXISTING_CACHE_PATH, JSON.stringify(existingSoundsCache), 'utf8') } const lightpackOverrideSounds = { @@ -106,7 +121,8 @@ const lightpackOverrideSounds = { // this is not done yet, will be used to select only sounds for bundle (most important ones) const isSoundWhitelisted = (name) => name.startsWith('random/') || name.startsWith('note/') || name.endsWith('/say1') || name.endsWith('/death') || (name.startsWith('mob/') && name.endsWith('/step1')) || name.endsWith('/swoop1') || /* name.endsWith('/break1') || */ name.endsWith('dig/stone1') -const ffmpeg = 'C:/Users/Vitaly/Documents/LosslessCut-win-x64/resources/ffmpeg.exe' // will be ffmpeg-static +// const ffmpeg = 'C:/Users/Vitaly/Documents/LosslessCut-win-x64/resources/ffmpeg.exe' // can be ffmpeg-static +const ffmpegExec = 'ffmpeg' const maintainBitrate = true const scanFilesDeep = async (root, onOggFile) => { @@ -127,7 +143,7 @@ const convertSounds = async () => { }) const convertSound = async (i) => { - const proc = promisify(exec)(`${ffmpeg} -i "${toConvert[i]}" -y -codec:a libmp3lame ${maintainBitrate ? '-qscale:a 2' : ''} "${toConvert[i].replace('.ogg', '.mp3')}"`) + const proc = promisify(exec)(`${ffmpegExec} -i "${toConvert[i]}" -y -codec:a libmp3lame ${maintainBitrate ? '-qscale:a 2' : ''} "${toConvert[i].replace('.ogg', '.mp3')}"`) // pipe stdout to the console proc.child.stdout.pipe(process.stdout) await proc @@ -147,8 +163,8 @@ const getSoundsMap = (burgerData) => { } const writeSoundsMap = async () => { - // const burgerData = await fetch(burgerDataUrl(targetedVersions[0])).then((r) => r.json()) - // fs.writeFileSync(burgerDataPath, JSON.stringify(burgerData[0].sounds), 'utf8') + const burgerData = await fetch(burgerDataUrl(targetedVersions[0])).then((r) => r.json()) + fs.writeFileSync(burgerDataPath, JSON.stringify(burgerData[0].sounds), 'utf8') const allSoundsMapOutput = {} let prevMap @@ -174,16 +190,22 @@ const writeSoundsMap = async () => { // const includeSound = isSoundWhitelisted(firstName) // if (!includeSound) continue const mostUsedSound = sounds.sort((a, b) => b.weight - a.weight)[0] - const targetSound = sounds[0] // outputMap[id] = { subtitle, sounds: mostUsedSound } // outputMap[id] = { subtitle, sounds } - const soundFilePath = `generated/sounds/minecraft/sounds/${targetSound.name}.mp3` + // const soundFilePath = `generated/sounds/minecraft/sounds/${targetSound.name}.mp3` // if (!fs.existsSync(soundFilePath)) { // console.warn('no sound file', targetSound.name) // continue // } + let outputUseSoundLine = [] + const minWeight = sounds.reduce((acc, cur) => cur.weight ? Math.min(acc, cur.weight) : acc, sounds[0].weight ?? 1) + if (isNaN(minWeight)) debugger + for (const sound of sounds) { + if (sound.weight && isNaN(sound.weight)) debugger + outputUseSoundLine.push(`${sound.volume ?? 1};${sound.name};${sound.weight ?? minWeight}`) + } const key = `${id};${name}` - outputIdMap[key] = `${targetSound.volume ?? 1};${targetSound.name}` + outputIdMap[key] = outputUseSoundLine.join(',') if (prevMap && prevMap[key]) { keysStats.same++ } else { @@ -221,7 +243,7 @@ const makeSoundsBundle = async () => { const allSoundsMeta = { format: 'mp3', - baseUrl: 'https://raw.githubusercontent.com/zardoy/minecraft-web-client/sounds-generated/sounds/' + baseUrl: `https://raw.githubusercontent.com/${process.env.REPO_SLUG}/sounds/sounds/` } await build({ @@ -235,9 +257,25 @@ const makeSoundsBundle = async () => { }, metafile: true, }) + // copy also to generated/sounds.js + fs.copyFileSync('./dist/sounds.js', './generated/sounds.js') } -// downloadAllSounds() -// convertSounds() -// writeSoundsMap() -// makeSoundsBundle() +const action = process.argv[2] +if (action) { + const execFn = { + download: downloadAllSoundsAndCreateMap, + convert: convertSounds, + write: writeSoundsMap, + bundle: makeSoundsBundle, + }[action] + + if (execFn) { + execFn() + } +} else { + // downloadAllSoundsAndCreateMap() + // convertSounds() + // writeSoundsMap() + makeSoundsBundle() +} diff --git a/scripts/uploadSoundFiles.ts b/scripts/uploadSoundFiles.ts new file mode 100644 index 00000000..e8677c87 --- /dev/null +++ b/scripts/uploadSoundFiles.ts @@ -0,0 +1,109 @@ +import fetch from 'node-fetch'; +import * as fs from 'fs'; +import * as path from 'path'; +import { glob } from 'glob'; + +// Git details +const REPO_SLUG = process.env.REPO_SLUG; +const owner = REPO_SLUG.split('/')[0]; +const repo = REPO_SLUG.split('/')[1]; +const branch = "sounds"; + +// GitHub token for authentication +const token = process.env.GITHUB_TOKEN; + +// GitHub API endpoint +const baseUrl = `https://api.github.com/repos/${owner}/${repo}/contents`; + +const headers = { + Authorization: `token ${token}`, + 'Content-Type': 'application/json' +}; + +async function getShaForExistingFile(repoFilePath: string): Promise { + const url = `${baseUrl}/${repoFilePath}?ref=${branch}`; + const response = await fetch(url, { headers }); + if (response.status === 404) { + return null; // File does not exist + } + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.statusText}`); + } + const data = await response.json(); + return data.sha; +} + +async function uploadFiles() { + const commitMessage = "Upload multiple files via script"; + const committer = { + name: "GitHub", + email: "noreply@github.com" + }; + + const filesToUpload = glob.sync("generated/sounds/**/*.mp3").map(localPath => { + const repoPath = localPath.replace(/^generated\//, ''); + return { localPath, repoPath }; + }); + + const files = await Promise.all(filesToUpload.map(async file => { + const content = fs.readFileSync(file.localPath, 'base64'); + const sha = await getShaForExistingFile(file.repoPath); + return { + path: file.repoPath, + mode: "100644", + type: "blob", + sha: sha || undefined, + content: content + }; + })); + + const treeResponse = await fetch(`${baseUrl}/git/trees`, { + method: 'POST', + headers: headers, + body: JSON.stringify({ + base_tree: null, + tree: files + }) + }); + + if (!treeResponse.ok) { + throw new Error(`Failed to create tree: ${treeResponse.statusText}`); + } + + const treeData = await treeResponse.json(); + + const commitResponse = await fetch(`${baseUrl}/git/commits`, { + method: 'POST', + headers: headers, + body: JSON.stringify({ + message: commitMessage, + tree: treeData.sha, + parents: [branch], + committer: committer + }) + }); + + if (!commitResponse.ok) { + throw new Error(`Failed to create commit: ${commitResponse.statusText}`); + } + + const commitData = await commitResponse.json(); + + const updateRefResponse = await fetch(`${baseUrl}/git/refs/heads/${branch}`, { + method: 'PATCH', + headers: headers, + body: JSON.stringify({ + sha: commitData.sha + }) + }); + + if (!updateRefResponse.ok) { + throw new Error(`Failed to update ref: ${updateRefResponse.statusText}`); + } + + console.log("Files uploaded successfully"); +} + +uploadFiles().catch(error => { + console.error("Error uploading files:", error); +}); diff --git a/scripts/uploadSounds.ts b/scripts/uploadSounds.ts new file mode 100644 index 00000000..b0e9ecd7 --- /dev/null +++ b/scripts/uploadSounds.ts @@ -0,0 +1,67 @@ +import fs from 'fs' + +// GitHub details +const owner = "zardoy"; +const repo = "minecraft-web-client"; +const branch = "sounds-generated"; +const filePath = "dist/sounds.js"; // Local file path +const repoFilePath = "sounds-v2.js"; // Path in the repo + +// GitHub token for authentication +const token = process.env.GITHUB_TOKEN; + +// GitHub API endpoint +const baseUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${repoFilePath}`; + +const headers = { + Authorization: `token ${token}`, + 'Content-Type': 'application/json' +}; + +async function getShaForExistingFile(): Promise { + const url = `${baseUrl}?ref=${branch}`; + const response = await fetch(url, { headers }); + if (response.status === 404) { + return null; // File does not exist + } + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.statusText}`); + } + const data = await response.json(); + return data.sha; +} + +async function uploadFile() { + const content = fs.readFileSync(filePath, 'utf8'); + const base64Content = Buffer.from(content).toString('base64'); + const sha = await getShaForExistingFile(); + console.log('got sha') + + const body = { + message: "Update sounds.js", + content: base64Content, + branch: branch, + committer: { + name: "GitHub", + email: "noreply@github.com" + }, + sha: sha || undefined + }; + + const response = await fetch(baseUrl, { + method: 'PUT', + headers: headers, + body: JSON.stringify(body) + }); + + if (!response.ok) { + throw new Error(`Failed to upload file: ${response.statusText}`); + } + + const responseData = await response.json(); + console.log("File uploaded successfully:", responseData); +} + +uploadFile().catch(error => { + console.error("Error uploading file:", error); +}); diff --git a/server.js b/server.js index 4e541486..2dbb05b3 100644 --- a/server.js +++ b/server.js @@ -26,6 +26,7 @@ if (!isProd) { app.get('/config.json', (req, res, next) => { // read original file config let config = {} + let publicConfig = {} try { config = require('./config.json') } catch { @@ -33,9 +34,13 @@ app.get('/config.json', (req, res, next) => { config = require('./dist/config.json') } catch { } } + try { + publicConfig = require('./public/config.json') + } catch { } res.json({ ...config, 'defaultProxy': '', // use current url (this server) + ...publicConfig, }) }) if (isProd) { @@ -45,6 +50,11 @@ if (isProd) { res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp') next() }) + + // First serve from the override directory (volume mount) + app.use(express.static(path.join(__dirname, './public'))) + + // Then fallback to the original dist directory app.use(express.static(path.join(__dirname, './dist'))) } diff --git a/src/api/mcStatusApi.ts b/src/api/mcStatusApi.ts new file mode 100644 index 00000000..2a440f2b --- /dev/null +++ b/src/api/mcStatusApi.ts @@ -0,0 +1,54 @@ +export const isServerValid = (ip: string) => { + const isInLocalNetwork = ip.startsWith('192.168.') || + ip.startsWith('10.') || + ip.startsWith('172.') || + ip.startsWith('127.') || + ip.startsWith('localhost') || + ip.startsWith(':') + const VALID_IP_OR_DOMAIN = ip.includes('.') + + return !isInLocalNetwork && VALID_IP_OR_DOMAIN +} + +export async function fetchServerStatus (ip: string, signal?: AbortSignal) { + if (!isServerValid(ip)) return + + const response = await fetch(`https://api.mcstatus.io/v2/status/java/${ip}`, { signal }) + const data: ServerResponse = await response.json() + const versionClean = data.version?.name_raw.replace(/^[^\d.]+/, '') + + return { + formattedText: data.motd?.raw ?? '', + textNameRight: data.online ? + `${versionClean} ${data.players?.online ?? '??'}/${data.players?.max ?? '??'}` : + '', + icon: data.icon, + offline: !data.online, + raw: data + } +} + +export type ServerResponse = { + online: boolean + version?: { + name_raw: string + } + // display tooltip + players?: { + online: number + max: number + list: Array<{ + name_raw: string + name_clean: string + }> + } + icon?: string + motd?: { + raw: string + } + // todo circle error icon + mods?: Array<{ name: string, version: string }> + // todo display via hammer icon + software?: string + plugins?: Array<{ name, version }> +} diff --git a/src/appParams.ts b/src/appParams.ts new file mode 100644 index 00000000..e862e301 --- /dev/null +++ b/src/appParams.ts @@ -0,0 +1,78 @@ +const qsParams = new URLSearchParams(window.location.search) + +export type AppQsParams = { + // AddServerOrConnect.tsx params + ip?: string + name?: string + version?: string + proxy?: string + username?: string + lockConnect?: string + autoConnect?: string + // googledrive.ts params + state?: string + // ServersListProvider.tsx params + serversList?: string + // Map and texture params + texturepack?: string + map?: string + mapDirBaseUrl?: string + mapDirGuess?: string + // Singleplayer params + singleplayer?: string + sp?: string + loadSave?: string + // Server params + reconnect?: string + server?: string + // Peer connection params + connectPeer?: string + peerVersion?: string + // UI params + modal?: string + viewerConnect?: string + // Map version param + mapVersion?: string + // Command params + command?: string + // Misc params + suggest_save?: string + scene?: string +} + +export type AppQsParamsArray = { + mapDir?: string[] + setting?: string[] + serverSetting?: string[] + command?: string[] +} + +type AppQsParamsArrayTransformed = { + [k in keyof AppQsParamsArray]: string[] +} + +export const appQueryParams = new Proxy({} as AppQsParams, { + get (target, property) { + if (typeof property !== 'string') { + return null + } + return qsParams.get(property) + }, +}) + +export const appQueryParamsArray = new Proxy({} as AppQsParamsArrayTransformed, { + get (target, property) { + if (typeof property !== 'string') { + return null + } + return qsParams.getAll(property) + }, +}) + +// Helper function to check if a specific query parameter exists +export const hasQueryParam = (param: keyof AppQsParams) => qsParams.has(param) + +// Helper function to get all query parameters as a URLSearchParams object +export const getRawQueryParams = () => qsParams; + +(globalThis as any).debugQueryParams = Object.fromEntries(qsParams.entries()) diff --git a/src/basicSounds.ts b/src/basicSounds.ts index 48bdcac6..6c2b5f4f 100644 --- a/src/basicSounds.ts +++ b/src/basicSounds.ts @@ -1,3 +1,4 @@ +import { subscribeKey } from 'valtio/utils' import { options } from './optionsStorage' import { isCypress } from './standaloneUtils' import { reportWarningOnce } from './utils' @@ -5,9 +6,14 @@ import { reportWarningOnce } from './utils' let audioContext: AudioContext const sounds: Record = {} +// Track currently playing sounds and their gain nodes +const activeSounds: Array<{ source: AudioBufferSourceNode; gainNode: GainNode; volumeMultiplier: number }> = [] +window.activeSounds = activeSounds + // load as many resources on page load as possible instead on demand as user can disable internet connection after he thinks the page is loaded const loadingSounds = [] as string[] const convertedSounds = [] as string[] + export async function loadSound (path: string, contents = path) { if (loadingSounds.includes(path)) return true loadingSounds.push(path) @@ -24,15 +30,15 @@ export async function loadSound (path: string, contents = path) { loadingSounds.splice(loadingSounds.indexOf(path), 1) } -export const loadOrPlaySound = async (url, soundVolume = 1) => { +export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = 500) => { const soundBuffer = sounds[url] if (!soundBuffer) { const start = Date.now() const cancelled = await loadSound(url) - if (cancelled || Date.now() - start > 500) return + if (cancelled || Date.now() - start > loadTimeout) return } - await playSound(url) + return playSound(url, soundVolume) } export async function playSound (url, soundVolume = 1) { @@ -49,6 +55,7 @@ export async function playSound (url, soundVolume = 1) { for (const [soundName, sound] of Object.entries(sounds)) { if (convertedSounds.includes(soundName)) continue + // eslint-disable-next-line no-await-in-loop sounds[soundName] = await audioContext.decodeAudioData(sound) convertedSounds.push(soundName) } @@ -66,4 +73,51 @@ export async function playSound (url, soundVolume = 1) { gainNode.connect(audioContext.destination) gainNode.gain.value = volume source.start(0) + + // Add to active sounds + activeSounds.push({ source, gainNode, volumeMultiplier: soundVolume }) + + const callbacks = [] as Array<() => void> + source.onended = () => { + // Remove from active sounds when finished + const index = activeSounds.findIndex(s => s.source === source) + if (index !== -1) activeSounds.splice(index, 1) + + for (const callback of callbacks) { + callback() + } + callbacks.length = 0 + } + + return { + onEnded (callback: () => void) { + callbacks.push(callback) + }, + } } + +export function stopAllSounds () { + for (const { source } of activeSounds) { + try { + source.stop() + } catch (err) { + console.warn('Failed to stop sound:', err) + } + } + activeSounds.length = 0 +} + +export function changeVolumeOfCurrentlyPlayingSounds (newVolume: number) { + const normalizedVolume = newVolume / 100 + for (const { gainNode, volumeMultiplier } of activeSounds) { + try { + gainNode.gain.value = normalizedVolume * volumeMultiplier + } catch (err) { + console.warn('Failed to change sound volume:', err) + } + } +} + +subscribeKey(options, 'volume', () => { + changeVolumeOfCurrentlyPlayingSounds(options.volume) +}) diff --git a/src/cameraRotationControls.ts b/src/cameraRotationControls.ts new file mode 100644 index 00000000..151bb841 --- /dev/null +++ b/src/cameraRotationControls.ts @@ -0,0 +1,80 @@ +import { contro } from './controls' +import { activeModalStack, isGameActive, miscUiState, showModal } from './globalState' +import { options } from './optionsStorage' +import { hideNotification, notificationProxy } from './react/NotificationProvider' +import { pointerLock } from './utils' +import worldInteractions from './worldInteractions' + +let lastMouseMove: number + +export const updateCursor = () => { + worldInteractions.update() +} + +export type CameraMoveEvent = { + movementX: number + movementY: number + type: string + stopPropagation?: () => void +} + +export function onCameraMove (e: MouseEvent | CameraMoveEvent) { + if (!isGameActive(true)) return + if (e.type === 'mousemove' && !document.pointerLockElement) return + e.stopPropagation?.() + const now = performance.now() + // todo: limit camera movement for now to avoid unexpected jumps + if (now - lastMouseMove < 4) return + lastMouseMove = now + let { mouseSensX, mouseSensY } = options + if (mouseSensY === -1) mouseSensY = mouseSensX + moveCameraRawHandler({ + x: e.movementX * mouseSensX * 0.0001, + y: e.movementY * mouseSensY * 0.0001 + }) + updateCursor() +} + +export const moveCameraRawHandler = ({ x, y }: { x: number; y: number }) => { + const maxPitch = 0.5 * Math.PI + const minPitch = -0.5 * Math.PI + + viewer.world.lastCamUpdate = Date.now() + if (!bot?.entity) return + const pitch = bot.entity.pitch - y + void bot.look(bot.entity.yaw - x, Math.max(minPitch, Math.min(maxPitch, pitch)), true) +} + + +window.addEventListener('mousemove', (e: MouseEvent) => { + onCameraMove(e) +}, { capture: true }) + +export const onControInit = () => { + contro.on('stickMovement', ({ stick, vector }) => { + if (!isGameActive(true)) return + if (stick !== 'right') return + let { x, z } = vector + if (Math.abs(x) < 0.18) x = 0 + if (Math.abs(z) < 0.18) z = 0 + onCameraMove({ + movementX: x * 10, + movementY: z * 10, + type: 'stickMovement', + stopPropagation () {} + } as CameraMoveEvent) + miscUiState.usingGamepadInput = true + }) +} + +function pointerLockChangeCallback () { + if (notificationProxy.id === 'pointerlockchange') { + hideNotification() + } + if (viewer.renderer.xr.isPresenting) return // todo + if (!pointerLock.hasPointerLock && activeModalStack.length === 0) { + showModal({ reactType: 'pause-screen' }) + } +} + +document.addEventListener('pointerlockchange', pointerLockChangeCallback, false) diff --git a/src/connect.ts b/src/connect.ts index b7023880..e45769f7 100644 --- a/src/connect.ts +++ b/src/connect.ts @@ -2,7 +2,7 @@ import { versionsByMinecraftVersion } from 'minecraft-data' import minecraftInitialDataJson from '../generated/minecraft-initial-data.json' import { AuthenticatedAccount } from './react/ServersListProvider' import { setLoadingScreenStatus } from './utils' -import { downloadSoundsIfNeeded } from './soundSystem' +import { downloadSoundsIfNeeded } from './sounds/botSoundSystem' import { miscUiState } from './globalState' export type ConnectOptions = { diff --git a/src/controls.ts b/src/controls.ts index 507630bc..cc643bcc 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -7,7 +7,7 @@ import { ControMax } from 'contro-max/build/controMax' import { CommandEventArgument, SchemaCommandInput } from 'contro-max/build/types' import { stringStartsWith } from 'contro-max/build/stringUtils' import { UserOverrideCommand, UserOverridesConfig } from 'contro-max/build/types/store' -import { isGameActive, showModal, gameAdditionalState, activeModalStack, hideCurrentModal, miscUiState, loadedGameState, hideModal } from './globalState' +import { isGameActive, showModal, gameAdditionalState, activeModalStack, hideCurrentModal, miscUiState, hideModal, hideAllModals } from './globalState' import { goFullscreen, pointerLock, reloadChunks } from './utils' import { options } from './optionsStorage' import { openPlayerInventory } from './inventoryWindows' @@ -19,9 +19,10 @@ import { showOptionsModal } from './react/SelectOption' import widgets from './react/widgets' import { getItemFromBlock } from './chatUtils' import { gamepadUiCursorState, moveGamepadCursorByPx } from './react/GamepadUiCursor' -import { completeTexturePackInstall, resourcePackState } from './resourcePack' +import { completeTexturePackInstall, copyServerResourcePackToRegular, resourcePackState } from './resourcePack' import { showNotification } from './react/NotificationProvider' import { lastConnectOptions } from './react/AppStatusProvider' +import { onCameraMove, onControInit } from './cameraRotationControls' export const customKeymaps = proxy(JSON.parse(localStorage.keymap || '{}')) as UserOverridesConfig @@ -50,7 +51,11 @@ export const contro = new ControMax({ command: ['Slash'], swapHands: ['KeyF'], zoom: ['KeyC'], - selectItem: ['KeyH'] // default will be removed + selectItem: ['KeyH'], // default will be removed + rotateCameraLeft: ['ArrowLeft'], + rotateCameraRight: ['ArrowRight'], + rotateCameraUp: ['ArrowUp'], + rotateCameraDown: ['ArrowDown'] }, ui: { toggleFullscreen: ['F11'], @@ -92,6 +97,8 @@ export const contro = new ControMax({ window.controMax = contro export type Command = CommandEventArgument['command'] +onControInit() + updateBinds(customKeymaps) const updateDoPreventDefault = () => { @@ -245,6 +252,73 @@ const inModalCommand = (command: Command, pressed: boolean) => { } } +// Camera rotation controls +const cameraRotationControls = { + activeDirections: new Set<'left' | 'right' | 'up' | 'down'>(), + interval: null as ReturnType | null, + config: { + speed: 1, // movement per interval + interval: 5 // ms between movements + }, + movements: { + left: { movementX: -0.5, movementY: 0 }, + right: { movementX: 0.5, movementY: 0 }, + up: { movementX: 0, movementY: -0.5 }, + down: { movementX: 0, movementY: 0.5 } + }, + updateMovement () { + if (cameraRotationControls.activeDirections.size === 0) { + if (cameraRotationControls.interval) { + clearInterval(cameraRotationControls.interval) + cameraRotationControls.interval = null + } + return + } + + if (!cameraRotationControls.interval) { + cameraRotationControls.interval = setInterval(() => { + // Combine all active movements + const movement = { movementX: 0, movementY: 0 } + for (const direction of cameraRotationControls.activeDirections) { + movement.movementX += cameraRotationControls.movements[direction].movementX + movement.movementY += cameraRotationControls.movements[direction].movementY + } + + onCameraMove({ + ...movement, + type: 'keyboardRotation', + stopPropagation () {} + }) + }, cameraRotationControls.config.interval) + } + }, + start (direction: 'left' | 'right' | 'up' | 'down') { + cameraRotationControls.activeDirections.add(direction) + cameraRotationControls.updateMovement() + }, + stop (direction: 'left' | 'right' | 'up' | 'down') { + cameraRotationControls.activeDirections.delete(direction) + cameraRotationControls.updateMovement() + }, + handleCommand (command: string, pressed: boolean) { + const directionMap = { + 'general.rotateCameraLeft': 'left', + 'general.rotateCameraRight': 'right', + 'general.rotateCameraUp': 'up', + 'general.rotateCameraDown': 'down' + } as const + + const direction = directionMap[command] + if (direction) { + if (pressed) cameraRotationControls.start(direction) + else cameraRotationControls.stop(direction) + return true + } + return false + } +} +window.cameraRotationControls = cameraRotationControls + const setSneaking = (state: boolean) => { gameAdditionalState.isSneaking = state bot.setControlState('sneak', state) @@ -275,7 +349,6 @@ const onTriggerOrReleased = (command: Command, pressed: boolean) => { } else if (pressed) { setSneaking(!gameAdditionalState.isSneaking) } - break case 'general.attackDestroy': document.dispatchEvent(new MouseEvent(pressed ? 'mousedown' : 'mouseup', { button: 0 })) @@ -286,6 +359,12 @@ const onTriggerOrReleased = (command: Command, pressed: boolean) => { case 'general.zoom': gameAdditionalState.isZooming = pressed break + case 'general.rotateCameraLeft': + case 'general.rotateCameraRight': + case 'general.rotateCameraUp': + case 'general.rotateCameraDown': + cameraRotationControls.handleCommand(command, pressed) + break } } } @@ -370,6 +449,12 @@ contro.on('trigger', ({ command }) => { case 'general.toggleSneakOrDown': case 'general.sprint': case 'general.attackDestroy': + case 'general.rotateCameraLeft': + case 'general.rotateCameraRight': + case 'general.rotateCameraUp': + case 'general.rotateCameraDown': + // no-op + break case 'general.swapHands': { bot._client.write('entity_action', { entityId: bot.entity.id, @@ -450,7 +535,12 @@ contro.on('release', ({ command }) => { // hard-coded keybindings -export const f3Keybinds = [ +export const f3Keybinds: Array<{ + key?: string, + action: () => void, + mobileTitle: string + enabled?: () => boolean +}> = [ { key: 'KeyA', action () { @@ -496,9 +586,9 @@ export const f3Keybinds = [ key: 'KeyT', async action () { // TODO! - if (resourcePackState.resourcePackInstalled || loadedGameState.usingServerResourcePack) { + if (resourcePackState.resourcePackInstalled || gameAdditionalState.usingServerResourcePack) { showNotification('Reloading textures...') - await completeTexturePackInstall('default', 'default', loadedGameState.usingServerResourcePack) + await completeTexturePackInstall('default', 'default', gameAdditionalState.usingServerResourcePack) } }, mobileTitle: 'Reload Textures' @@ -539,7 +629,15 @@ export const f3Keybinds = [ const proxyPing = await bot['pingProxy']() void showOptionsModal(`${username}: last known total latency (ping): ${playerPing}. Connected to ${lastConnectOptions.value?.proxy} with current ping ${proxyPing}. Player UUID: ${uuid}`, []) }, - mobileTitle: 'Show Proxy & Ping Details' + mobileTitle: 'Show Proxy & Ping Details', + enabled: () => !!lastConnectOptions.value?.proxy + }, + { + action () { + void copyServerResourcePackToRegular() + }, + mobileTitle: 'Copy Server Resource Pack', + enabled: () => !!gameAdditionalState.usingServerResourcePack } ] @@ -548,7 +646,7 @@ document.addEventListener('keydown', (e) => { if (!isGameActive(false)) return if (hardcodedPressedKeys.has('F3')) { const keybind = f3Keybinds.find((v) => v.key === e.code) - if (keybind) { + if (keybind && (keybind.enabled?.() ?? true)) { keybind.action() e.stopPropagation() } @@ -740,19 +838,12 @@ window.addEventListener('keydown', (e) => { if (activeModalStack.length) { const hideAll = e.ctrlKey || e.metaKey if (hideAll) { - while (activeModalStack.length > 0) { - hideCurrentModal(undefined, () => { - if (!activeModalStack.length) { - pointerLock.justHitEscape = true - } - }) - } + hideAllModals() } else { - hideCurrentModal(undefined, () => { - if (!activeModalStack.length) { - pointerLock.justHitEscape = true - } - }) + hideCurrentModal() + } + if (activeModalStack.length === 0) { + pointerLock.justHitEscape = true } } else if (pointerLock.hasPointerLock) { document.exitPointerLock?.() diff --git a/src/downloadAndOpenFile.ts b/src/downloadAndOpenFile.ts index 6e59ea02..d4356ca2 100644 --- a/src/downloadAndOpenFile.ts +++ b/src/downloadAndOpenFile.ts @@ -2,16 +2,16 @@ import prettyBytes from 'pretty-bytes' import { openWorldFromHttpDir, openWorldZip } from './browserfs' import { getResourcePackNames, installTexturePack, resourcePackState, updateTexturePackInstalledState } from './resourcePack' import { setLoadingScreenStatus } from './utils' +import { appQueryParams, appQueryParamsArray } from './appParams' export const getFixedFilesize = (bytes: number) => { return prettyBytes(bytes, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } const inner = async () => { - const qs = new URLSearchParams(window.location.search) - const mapUrlDir = qs.getAll('mapDir') - const mapUrlDirGuess = qs.get('mapDirGuess') - const mapUrlDirBaseUrl = qs.get('mapDirBaseUrl') + const mapUrlDir = appQueryParamsArray.mapDir ?? [] + const mapUrlDirGuess = appQueryParams.mapDirGuess + const mapUrlDirBaseUrl = appQueryParams.mapDirBaseUrl if (mapUrlDir.length) { await openWorldFromHttpDir(mapUrlDir, mapUrlDirBaseUrl ?? undefined) return true @@ -20,8 +20,8 @@ const inner = async () => { // await openWorldFromHttpDir(undefined, mapUrlDirGuess) return true } - let mapUrl = qs.get('map') - const texturepack = qs.get('texturepack') + let mapUrl = appQueryParams.map + const { texturepack } = appQueryParams // fixme if (texturepack) mapUrl = texturepack if (!mapUrl) return false diff --git a/src/globalState.ts b/src/globalState.ts index fbaabe81..fa76db75 100644 --- a/src/globalState.ts +++ b/src/globalState.ts @@ -86,10 +86,21 @@ export const hideCurrentModal = (_data?, onHide?: () => void) => { } } +export const hideAllModals = () => { + while (activeModalStack.length > 0) { + if (!hideModal()) break + } + return activeModalStack.length === 0 +} + export const openOptionsMenu = (group: OptionsGroupType) => { showModal({ reactType: `options-${group}` }) } +subscribe(activeModalStack, () => { + document.body.style.setProperty('--has-modals-z', activeModalStack.length ? '-1' : null) +}) + // --- export const currentContextMenu = proxy({ items: [] as ContextMenuItem[] | null, x: 0, y: 0 }) @@ -139,12 +150,6 @@ export const miscUiState = proxy({ displaySearchInput: false, }) -export const loadedGameState = proxy({ - username: '', - serverIp: '' as string | null, - usingServerResourcePack: false, -}) - export const isGameActive = (foregroundCheck: boolean) => { if (foregroundCheck && activeModalStack.length) return false return miscUiState.gameLoaded @@ -158,7 +163,9 @@ export const gameAdditionalState = proxy({ isSprinting: false, isSneaking: false, isZooming: false, - warps: [] as WorldWarp[] + warps: [] as WorldWarp[], + + usingServerResourcePack: false, }) window.gameAdditionalState = gameAdditionalState diff --git a/src/googledrive.ts b/src/googledrive.ts index 3846add3..578ecb00 100644 --- a/src/googledrive.ts +++ b/src/googledrive.ts @@ -6,6 +6,7 @@ import { loadGoogleDriveApi, loadInMemorySave } from './react/SingleplayerProvid import { setLoadingScreenStatus } from './utils' import { mountGoogleDriveFolder } from './browserfs' import { showOptionsModal } from './react/SelectOption' +import { appQueryParams } from './appParams' const CLIENT_ID = '137156026346-igv2gkjsj2hlid92rs3q7cjjnc77s132.apps.googleusercontent.com' // const CLIENT_ID = process.env.GOOGLE_CLIENT_ID @@ -45,7 +46,7 @@ export const useGoogleLogIn = () => { } export const possiblyHandleStateVariable = async () => { - const stateParam = new URLSearchParams(window.location.search).get('state') + const stateParam = appQueryParams.state if (!stateParam) return setLoadingScreenStatus('Opening world in read only mode, waiting for login...') await loadGoogleDriveApi() diff --git a/src/index.ts b/src/index.ts index b27c6bbe..36ced199 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,12 +7,10 @@ import './entities' import './globalDomListeners' import './mineflayer/maps' import './mineflayer/cameraShake' -import initCollisionShapes from './getCollisionInteractionShapes' import { onGameLoad } from './inventoryWindows' -import { supportedVersions } from 'minecraft-protocol' +import initCollisionShapes from './getCollisionInteractionShapes' import protocolMicrosoftAuth from 'minecraft-protocol/src/client/microsoftAuth' import microsoftAuthflow from './microsoftAuthflow' -import nbt from 'prismarine-nbt' import 'core-js/features/array/at' import 'core-js/features/promise/with-resolvers' @@ -24,7 +22,7 @@ import PrismarineItem from 'prismarine-item' import { options, watchValue } from './optionsStorage' import './reactUi' -import { contro, lockUrl, onBotCreate } from './controls' +import { lockUrl, onBotCreate } from './controls' import './dragndrop' import { possiblyCleanHandle, resetStateAfterDisconnect } from './browserfs' import { watchOptionsAfterViewerInit, watchOptionsAfterWorldViewInit } from './watchOptions' @@ -40,7 +38,7 @@ import { Vec3 } from 'vec3' import worldInteractions from './worldInteractions' import * as THREE from 'three' -import MinecraftData, { versionsByMinecraftVersion } from 'minecraft-data' +import MinecraftData from 'minecraft-data' import debug from 'debug' import { defaultsDeep } from 'lodash-es' import initializePacketsReplay from './packetsReplay' @@ -53,16 +51,12 @@ import { hideModal, insertActiveModalStack, isGameActive, - loadedGameState, miscUiState, showModal } from './globalState' - import { - pointerLock, - toMajorVersion, - setLoadingScreenStatus + pointerLock, setLoadingScreenStatus } from './utils' import { isCypress } from './standaloneUtils' @@ -77,7 +71,6 @@ import dayCycle from './dayCycle' import { onAppLoad, resourcepackReload } from './resourcePack' import { ConnectPeerOptions, connectToPeer } from './localServerMultiplayer' import CustomChannelClient from './customClient' -import { loadScript } from 'prismarine-viewer/viewer/lib/utils' import { registerServiceWorker } from './serviceWorker' import { appStatusState, lastConnectOptions } from './react/AppStatusProvider' @@ -85,9 +78,7 @@ import { fsState } from './loadSave' import { watchFov } from './rendererUtils' import { loadInMemorySave } from './react/SingleplayerProvider' -import { downloadSoundsIfNeeded } from './soundSystem' import { ua } from './react/utils' -import { handleMovementStickDelta, joystickPointer } from './react/TouchAreasControls' import { possiblyHandleStateVariable } from './googledrive' import flyingSquidEvents from './flyingSquidEvents' import { hideNotification, notificationProxy, showNotification } from './react/NotificationProvider' @@ -106,6 +97,8 @@ import { ItemsRenderer } from 'mc-assets/dist/itemsRenderer' import './mobileShim' import { parseFormattedMessagePacket } from './botUtils' import { getViewerVersionData, getWsProtocolStream } from './viewerConnector' +import { appQueryParams, appQueryParamsArray } from './appParams' +import { updateCursor } from './cameraRotationControls' window.debug = debug window.THREE = THREE @@ -201,44 +194,13 @@ viewer.entities.entitiesOptions = { } watchOptionsAfterViewerInit() -let mouseMovePostHandle = (e) => { } -let lastMouseMove: number -const updateCursor = () => { - worldInteractions.update() -} -function onCameraMove (e) { - if (e.type !== 'touchmove' && !pointerLock.hasPointerLock) return - e.stopPropagation?.() - const now = performance.now() - // todo: limit camera movement for now to avoid unexpected jumps - if (now - lastMouseMove < 4) return - lastMouseMove = now - let { mouseSensX, mouseSensY } = options - if (mouseSensY === -1) mouseSensY = mouseSensX - mouseMovePostHandle({ - x: e.movementX * mouseSensX * 0.0001, - y: e.movementY * mouseSensY * 0.0001 - }) - updateCursor() -} -window.addEventListener('mousemove', onCameraMove, { capture: true }) -contro.on('stickMovement', ({ stick, vector }) => { - if (!isGameActive(true)) return - if (stick !== 'right') return - let { x, z } = vector - if (Math.abs(x) < 0.18) x = 0 - if (Math.abs(z) < 0.18) z = 0 - onCameraMove({ movementX: x * 10, movementY: z * 10, type: 'touchmove' }) - miscUiState.usingGamepadInput = true -}) - function hideCurrentScreens () { activeModalStacks['main-menu'] = [...activeModalStack] insertActiveModalStack('', []) } const loadSingleplayer = (serverOverrides = {}, flattenedServerOverrides = {}) => { - const serverSettingsQsRaw = new URLSearchParams(window.location.search).getAll('serverSetting') + const serverSettingsQsRaw = appQueryParamsArray.serverSetting ?? [] const serverSettingsQs = serverSettingsQsRaw.map(x => x.split(':')).reduce>((acc, [key, value]) => { acc[key] = JSON.parse(value) return acc @@ -286,7 +248,7 @@ const cleanConnectIp = (host: string | undefined, defaultPort: string | undefine } } -async function connect (connectOptions: ConnectOptions) { +export async function connect (connectOptions: ConnectOptions) { if (miscUiState.gameLoaded) return miscUiState.hasErrors = false lastConnectOptions.value = connectOptions @@ -301,6 +263,10 @@ async function connect (connectOptions: ConnectOptions) { if (connectOptions.proxy?.startsWith(':')) { connectOptions.proxy = `${location.protocol}//${location.hostname}${connectOptions.proxy}` } + if (connectOptions.proxy && location.port !== '80' && location.port !== '443' && !/:\d+$/.test(connectOptions.proxy)) { + const https = connectOptions.proxy.startsWith('https://') || location.protocol === 'https:' + connectOptions.proxy = `${connectOptions.proxy}:${https ? 443 : 80}` + } const proxy = cleanConnectIp(connectOptions.proxy, undefined) let { username } = connectOptions @@ -702,7 +668,7 @@ async function connect (connectOptions: ConnectOptions) { setLoadingScreenStatus('Placing blocks (starting viewer)') localStorage.lastConnectOptions = JSON.stringify(connectOptions) connectOptions.onSuccessfulPlay?.() - if (process.env.NODE_ENV === 'development' && !localStorage.lockUrl && new URLSearchParams(location.search).size === 0) { + if (process.env.NODE_ENV === 'development' && !localStorage.lockUrl && !Object.keys(window.debugQueryParams).length) { lockUrl() } updateDataAfterJoin() @@ -746,154 +712,11 @@ async function connect (connectOptions: ConnectOptions) { setLoadingScreenStatus('Setting callbacks') - const maxPitch = 0.5 * Math.PI - const minPitch = -0.5 * Math.PI - mouseMovePostHandle = ({ x, y }) => { - viewer.world.lastCamUpdate = Date.now() - const pitch = bot.entity.pitch - y - void bot.look(bot.entity.yaw - x, Math.max(minPitch, Math.min(maxPitch, pitch)), true) - } - - function changeCallback () { - if (notificationProxy.id === 'pointerlockchange') { - hideNotification() - } - if (renderer.xr.isPresenting) return // todo - if (!pointerLock.hasPointerLock && activeModalStack.length === 0) { - showModal({ reactType: 'pause-screen' }) - } - } - - registerListener(document, 'pointerlockchange', changeCallback, false) - - const cameraControlEl = document.querySelector('#ui-root') - - /** after what time of holding the finger start breaking the block */ - const touchStartBreakingBlockMs = 500 - let virtualClickActive = false - let virtualClickTimeout - let screenTouches = 0 - let capturedPointer: { id; x; y; sourceX; sourceY; activateCameraMove; time } | undefined - registerListener(document, 'pointerdown', (e) => { - const usingJoystick = options.touchControlsType === 'joystick-buttons' - const clickedEl = e.composedPath()[0] - if (!isGameActive(true) || !miscUiState.currentTouch || clickedEl !== cameraControlEl || e.pointerId === undefined) { - return - } - screenTouches++ - if (screenTouches === 3) { - // todo needs fixing! - // window.dispatchEvent(new MouseEvent('mousedown', { button: 1 })) - } - if (usingJoystick) { - if (!joystickPointer.pointer && e.clientX < window.innerWidth / 2) { - joystickPointer.pointer = { - pointerId: e.pointerId, - x: e.clientX, - y: e.clientY - } - return - } - } - if (capturedPointer) { - return - } - cameraControlEl.setPointerCapture(e.pointerId) - capturedPointer = { - id: e.pointerId, - x: e.clientX, - y: e.clientY, - sourceX: e.clientX, - sourceY: e.clientY, - activateCameraMove: false, - time: Date.now() - } - if (options.touchControlsType !== 'joystick-buttons') { - virtualClickTimeout ??= setTimeout(() => { - virtualClickActive = true - document.dispatchEvent(new MouseEvent('mousedown', { button: 0 })) - }, touchStartBreakingBlockMs) - } - }) - registerListener(document, 'pointermove', (e) => { - if (e.pointerId === undefined) return - const supportsPressure = (e as any).pressure !== undefined && (e as any).pressure !== 0 && (e as any).pressure !== 0.5 && (e as any).pressure !== 1 && (e.pointerType === 'touch' || e.pointerType === 'pen') - if (e.pointerId === joystickPointer.pointer?.pointerId) { - handleMovementStickDelta(e) - if (supportsPressure && (e as any).pressure > 0.5) { - bot.setControlState('sprint', true) - // todo - } - return - } - if (e.pointerId !== capturedPointer?.id) return - window.scrollTo(0, 0) - e.preventDefault() - e.stopPropagation() - - const allowedJitter = 1.1 - if (supportsPressure) { - bot.setControlState('jump', (e as any).pressure > 0.5) - } - const xDiff = Math.abs(e.pageX - capturedPointer.sourceX) > allowedJitter - const yDiff = Math.abs(e.pageY - capturedPointer.sourceY) > allowedJitter - if (!capturedPointer.activateCameraMove && (xDiff || yDiff)) capturedPointer.activateCameraMove = true - if (capturedPointer.activateCameraMove) { - clearTimeout(virtualClickTimeout) - } - onCameraMove({ movementX: e.pageX - capturedPointer.x, movementY: e.pageY - capturedPointer.y, type: 'touchmove' }) - capturedPointer.x = e.pageX - capturedPointer.y = e.pageY - }, { passive: false }) - - const pointerUpHandler = (e: PointerEvent) => { - if (e.pointerId === undefined) return - if (e.pointerId === joystickPointer.pointer?.pointerId) { - handleMovementStickDelta() - joystickPointer.pointer = null - return - } - if (e.pointerId !== capturedPointer?.id) return - clearTimeout(virtualClickTimeout) - virtualClickTimeout = undefined - - if (options.touchControlsType !== 'joystick-buttons') { - if (virtualClickActive) { - // button 0 is left click - document.dispatchEvent(new MouseEvent('mouseup', { button: 0 })) - virtualClickActive = false - } else if (!capturedPointer.activateCameraMove && (Date.now() - capturedPointer.time < touchStartBreakingBlockMs)) { - document.dispatchEvent(new MouseEvent('mousedown', { button: 2 })) - worldInteractions.update() - document.dispatchEvent(new MouseEvent('mouseup', { button: 2 })) - } - } - capturedPointer = undefined - screenTouches-- - } - registerListener(document, 'pointerup', pointerUpHandler) - registerListener(document, 'pointercancel', pointerUpHandler) - registerListener(document, 'lostpointercapture', pointerUpHandler) - - registerListener(document, 'contextmenu', (e) => e.preventDefault(), false) - - registerListener(document, 'blur', (e) => { - bot.clearControlStates() - }, false) - - console.log('Done!') - - // todo - onGameLoad(async () => { - loadedGameState.serverIp = server.host ?? null - loadedGameState.username = username - }) + onGameLoad(() => {}) if (appStatusState.isError) return setTimeout(() => { - // todo - const qs = new URLSearchParams(window.location.search) - if (qs.get('suggest_save')) { + if (appQueryParams.suggest_save) { showNotification('Suggestion', 'Save the world to keep your progress!', false, undefined, async () => { const savePath = await saveToBrowserMemory() if (!savePath) return @@ -912,7 +735,7 @@ async function connect (connectOptions: ConnectOptions) { // todo might not emit as servers simply don't send chunk if it's empty if (!viewer.world.allChunksFinished || done) return done = true - console.log('All done and ready! In', (Date.now() - start) / 1000, 's') + console.log('All chunks done and ready! Time from renderer open to ready', (Date.now() - start) / 1000, 's') viewer.render() // ensure the last state is rendered document.dispatchEvent(new Event('cypress-world-ready')) }) @@ -925,8 +748,8 @@ async function connect (connectOptions: ConnectOptions) { if (!connectOptions.ignoreQs) { // todo cleanup customEvents.on('gameLoaded', () => { - const qs = new URLSearchParams(window.location.search) - for (let command of qs.getAll('command')) { + const commands = appQueryParamsArray.command ?? [] + for (let command of commands) { if (!command.startsWith('/')) command = `/${command}` bot.chat(command) } @@ -937,17 +760,14 @@ async function connect (connectOptions: ConnectOptions) { listenGlobalEvents() watchValue(miscUiState, async s => { if (s.appLoaded) { // fs ready - const qs = new URLSearchParams(window.location.search) - const moreServerOptions = {} as Record - if (qs.has('version')) moreServerOptions.version = qs.get('version') - if (qs.get('singleplayer') === '1' || qs.get('sp') === '1') { + if (appQueryParams.singleplayer === '1' || appQueryParams.sp === '1') { loadSingleplayer({}, { worldFolder: undefined, - ...moreServerOptions + ...appQueryParams.version ? { version: appQueryParams.version } : {} }) } - if (qs.get('loadSave')) { - const savePath = `/data/worlds/${qs.get('loadSave')}` + if (appQueryParams.loadSave) { + const savePath = `/data/worlds/${appQueryParams.loadSave}` try { await fs.promises.stat(savePath) } catch (err) { @@ -1003,22 +823,19 @@ void window.fetch('config.json').then(async res => res.json()).then(c => c, (err // qs open actions downloadAndOpenFile().then((downloadAction) => { if (downloadAction) return - const qs = new URLSearchParams(window.location.search) - if (qs.get('reconnect') && process.env.NODE_ENV === 'development') { - const ip = qs.get('ip') + if (appQueryParams.reconnect && process.env.NODE_ENV === 'development') { const lastConnect = JSON.parse(localStorage.lastConnectOptions ?? {}) void connect({ - botVersion: qs.get('version') ?? undefined, - ...lastConnect, // todo mixing is not good idea - ip: ip || undefined + botVersion: appQueryParams.version ?? undefined, + ...lastConnect, + ip: appQueryParams.ip || undefined }) return } - if (qs.get('ip') || qs.get('proxy')) { - const waitAppConfigLoad = !qs.get('proxy') + if (appQueryParams.ip || appQueryParams.proxy) { + const waitAppConfigLoad = !appQueryParams.proxy const openServerEditor = () => { hideModal() - // show server editor for connect or save showModal({ reactType: 'editServer' }) } showModal({ reactType: 'empty' }) @@ -1040,12 +857,12 @@ downloadAndOpenFile().then((downloadAction) => { void Promise.resolve().then(() => { // try to connect to peer - const peerId = qs.get('connectPeer') + const peerId = appQueryParams.connectPeer const peerOptions = {} as ConnectPeerOptions - if (qs.get('server')) { - peerOptions.server = qs.get('server')! + if (appQueryParams.server) { + peerOptions.server = appQueryParams.server } - const version = qs.get('peerVersion') + const version = appQueryParams.peerVersion if (peerId) { let username: string | null = options.guestUsername if (options.askGuestName) username = prompt('Enter your username', username) @@ -1060,11 +877,11 @@ downloadAndOpenFile().then((downloadAction) => { } }) - if (qs.get('serversList')) { + if (appQueryParams.serversList) { showModal({ reactType: 'serversList' }) } - const viewerWsConnect = qs.get('viewerConnect') + const viewerWsConnect = appQueryParams.viewerConnect if (viewerWsConnect) { void connect({ username: `viewer-${Math.random().toString(36).slice(2, 10)}`, @@ -1072,8 +889,8 @@ downloadAndOpenFile().then((downloadAction) => { }) } - if (qs.get('modal')) { - const modals = qs.get('modal')!.split(',') + if (appQueryParams.modal) { + const modals = appQueryParams.modal.split(',') for (const modal of modals) { showModal({ reactType: modal }) } diff --git a/src/loadSave.ts b/src/loadSave.ts index 6c7da6bb..1f683775 100644 --- a/src/loadSave.ts +++ b/src/loadSave.ts @@ -11,6 +11,7 @@ import { isMajorVersionGreater } from './utils' import { activeModalStacks, insertActiveModalStack, miscUiState } from './globalState' import supportedVersions from './supportedVersions.mjs' +import { appQueryParams } from './appParams' // todo include name of opened handle (zip)! // additional fs metadata @@ -84,8 +85,7 @@ export const loadSave = async (root = '/world') => { let version: string | undefined | null let isFlat = false if (levelDat) { - const qs = new URLSearchParams(window.location.search) - version = qs.get('mapVersion') ?? levelDat.Version?.Name + version = appQueryParams.mapVersion ?? levelDat.Version?.Name if (!version) { // const newVersion = disablePrompts ? '1.8.8' : prompt(`In 1.8 and before world save doesn't contain version info, please enter version you want to use to load the world.\nSupported versions ${supportedVersions.join(', ')}`, '1.8.8') // if (!newVersion) return diff --git a/src/mineflayer/maps.ts b/src/mineflayer/maps.ts index 75169a9f..c5d4f716 100644 --- a/src/mineflayer/maps.ts +++ b/src/mineflayer/maps.ts @@ -16,5 +16,8 @@ setImageConverter((buf: Uint8Array) => { customEvents.on('mineflayerBotCreated', () => { bot.on('login', () => { bot.loadPlugin(mapDownloader) + bot.mapDownloader.on('new_map', ({ png, id }) => { + viewer.entities.updateMap(id, png) + }) }) }) diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index ef7fc1c6..2ee07d5c 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -2,7 +2,7 @@ import { useRef, useState } from 'react' import { useSnapshot } from 'valtio' import { openURL } from 'prismarine-viewer/viewer/lib/simpleUtils' import { noCase } from 'change-case' -import { loadedGameState, miscUiState, openOptionsMenu, showModal } from './globalState' +import { gameAdditionalState, miscUiState, openOptionsMenu, showModal } from './globalState' import { AppOptions, options } from './optionsStorage' import Button from './react/Button' import { OptionMeta, OptionSlider } from './react/OptionsItems' @@ -157,7 +157,7 @@ export const guiOptionsScheme: { { custom () { const { resourcePackInstalled } = useSnapshot(resourcePackState) - const { usingServerResourcePack } = useSnapshot(loadedGameState) + const { usingServerResourcePack } = useSnapshot(gameAdditionalState) const { enabledResourcepack } = useSnapshot(options) return }) satisfies FC diff --git a/src/react/ChatProvider.tsx b/src/react/ChatProvider.tsx index 66d7673f..7caf6955 100644 --- a/src/react/ChatProvider.tsx +++ b/src/react/ChatProvider.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useSnapshot } from 'valtio' import { formatMessage } from '../chatUtils' import { getBuiltinCommandsList, tryHandleBuiltinCommand } from '../builtinCommands' -import { hideCurrentModal, loadedGameState, miscUiState } from '../globalState' +import { hideCurrentModal, miscUiState } from '../globalState' import { options } from '../optionsStorage' import Chat, { Message, fadeMessage } from './Chat' import { useIsModalActive } from './utilsApp' @@ -54,7 +54,7 @@ export default () => { updateLoadedServerData((server) => { server.autoLogin ??= {} const password = message.split(' ')[1] - server.autoLogin[loadedGameState.username] = password + server.autoLogin[bot.player.username] = password return server }) hideNotification() diff --git a/src/react/GameInteractionOverlay.tsx b/src/react/GameInteractionOverlay.tsx new file mode 100644 index 00000000..f6522e62 --- /dev/null +++ b/src/react/GameInteractionOverlay.tsx @@ -0,0 +1,187 @@ +import { useRef } from 'react' +import { useSnapshot } from 'valtio' +import { useUtilsEffect } from '@zardoy/react-util' +import { options } from '../optionsStorage' +import { activeModalStack, isGameActive, miscUiState } from '../globalState' +import worldInteractions from '../worldInteractions' +import { onCameraMove, CameraMoveEvent } from '../cameraRotationControls' +import { handleMovementStickDelta, joystickPointer } from './TouchAreasControls' + +/** after what time of holding the finger start breaking the block */ +const touchStartBreakingBlockMs = 500 + +function GameInteractionOverlayInner ({ zIndex }: { zIndex: number }) { + const overlayRef = useRef(null) + + useUtilsEffect(({ signal }) => { + if (!overlayRef.current) return + + const cameraControlEl = overlayRef.current + let virtualClickActive = false + let virtualClickTimeout: NodeJS.Timeout | undefined + let screenTouches = 0 + let capturedPointer: { + id: number; + x: number; + y: number; + sourceX: number; + sourceY: number; + activateCameraMove: boolean; + time: number + } | undefined + + const pointerDownHandler = (e: PointerEvent) => { + const clickedEl = e.composedPath()[0] + if (!isGameActive(true) || clickedEl !== cameraControlEl || e.pointerId === undefined) { + return + } + screenTouches++ + if (screenTouches === 3) { + // todo maybe mouse wheel click? + } + const usingModernMovement = options.touchMovementType === 'modern' + if (usingModernMovement) { + if (!joystickPointer.pointer && e.clientX < window.innerWidth / 2) { + cameraControlEl.setPointerCapture(e.pointerId) + joystickPointer.pointer = { + pointerId: e.pointerId, + x: e.clientX, + y: e.clientY + } + return + } + } + if (capturedPointer) { + return + } + cameraControlEl.setPointerCapture(e.pointerId) + capturedPointer = { + id: e.pointerId, + x: e.clientX, + y: e.clientY, + sourceX: e.clientX, + sourceY: e.clientY, + activateCameraMove: false, + time: Date.now() + } + if (options.touchInteractionType === 'classic') { + virtualClickTimeout ??= setTimeout(() => { + virtualClickActive = true + document.dispatchEvent(new MouseEvent('mousedown', { button: 0 })) + }, touchStartBreakingBlockMs) + } + } + + const pointerMoveHandler = (e: PointerEvent) => { + if (e.pointerId === undefined) return + const supportsPressure = (e as any).pressure !== undefined && + (e as any).pressure !== 0 && + (e as any).pressure !== 0.5 && + (e as any).pressure !== 1 && + (e.pointerType === 'touch' || e.pointerType === 'pen') + + if (e.pointerId === joystickPointer.pointer?.pointerId) { + handleMovementStickDelta(e) + if (supportsPressure && (e as any).pressure > 0.5) { + bot.setControlState('sprint', true) + } + return + } + if (e.pointerId !== capturedPointer?.id) return + // window.scrollTo(0, 0) + e.preventDefault() + e.stopPropagation() + + const allowedJitter = 1.1 + if (supportsPressure) { + bot.setControlState('jump', (e as any).pressure > 0.5) + } + const xDiff = Math.abs(e.pageX - capturedPointer.sourceX) > allowedJitter + const yDiff = Math.abs(e.pageY - capturedPointer.sourceY) > allowedJitter + if (!capturedPointer.activateCameraMove && (xDiff || yDiff)) { + capturedPointer.activateCameraMove = true + } + if (capturedPointer.activateCameraMove) { + clearTimeout(virtualClickTimeout) + } + + onCameraMove({ + movementX: e.pageX - capturedPointer.x, + movementY: e.pageY - capturedPointer.y, + type: 'touchmove', + stopPropagation: () => e.stopPropagation() + } as CameraMoveEvent) + capturedPointer.x = e.pageX + capturedPointer.y = e.pageY + } + + const pointerUpHandler = (e: PointerEvent) => { + if (e.pointerId === undefined) return + if (e.pointerId === joystickPointer.pointer?.pointerId) { + handleMovementStickDelta() + joystickPointer.pointer = null + return + } + if (e.pointerId !== capturedPointer?.id) return + clearTimeout(virtualClickTimeout) + virtualClickTimeout = undefined + + if (virtualClickActive) { + // button 0 is left click + document.dispatchEvent(new MouseEvent('mouseup', { button: 0 })) + virtualClickActive = false + } else if (!capturedPointer.activateCameraMove && (Date.now() - capturedPointer.time < touchStartBreakingBlockMs)) { + document.dispatchEvent(new MouseEvent('mousedown', { button: 2 })) + worldInteractions.update() + document.dispatchEvent(new MouseEvent('mouseup', { button: 2 })) + } + + capturedPointer = undefined + screenTouches-- + } + + const contextMenuHandler = (e: Event) => { + e.preventDefault() + } + + const blurHandler = () => { + bot.clearControlStates() + } + + cameraControlEl.addEventListener('pointerdown', pointerDownHandler, { signal }) + cameraControlEl.addEventListener('pointermove', pointerMoveHandler, { signal }) + cameraControlEl.addEventListener('pointerup', pointerUpHandler, { signal }) + cameraControlEl.addEventListener('pointercancel', pointerUpHandler, { signal }) + cameraControlEl.addEventListener('lostpointercapture', pointerUpHandler, { signal }) + cameraControlEl.addEventListener('contextmenu', contextMenuHandler, { signal }) + window.addEventListener('blur', blurHandler, { signal }) + }, []) + + return ( + + ) +} + +const OverlayElement = ({ divRef, zIndex }: { divRef: React.RefObject, zIndex: number }) => { + return
+} + +export default function GameInteractionOverlay ({ zIndex }: { zIndex: number }) { + const modalStack = useSnapshot(activeModalStack) + const { currentTouch } = useSnapshot(miscUiState) + if (modalStack.length > 0 || !currentTouch) return null + return +} diff --git a/src/react/HeldMapUi.tsx b/src/react/HeldMapUi.tsx index b4eaea60..4fadf64f 100644 --- a/src/react/HeldMapUi.tsx +++ b/src/react/HeldMapUi.tsx @@ -19,7 +19,7 @@ export default () => { updateHeldMap() }) - bot.on('new_map', () => { + bot.on('new_map', ({ id }) => { // total maps: Object.keys(bot.mapDownloader.maps).length updateHeldMap() }) diff --git a/src/react/IndicatorEffects.css b/src/react/IndicatorEffects.css index 7ddad807..dda3e87e 100644 --- a/src/react/IndicatorEffects.css +++ b/src/react/IndicatorEffects.css @@ -1,6 +1,6 @@ .effectsScreen-container { position: fixed; - top: 6%; + top: max(6%, 30px); left: 0px; z-index: -2; pointer-events: none; diff --git a/src/react/Input.tsx b/src/react/Input.tsx index 72baef53..f265eb5f 100644 --- a/src/react/Input.tsx +++ b/src/react/Input.tsx @@ -1,4 +1,4 @@ -import React, { CSSProperties, useEffect, useRef, useState } from 'react' +import React, { CSSProperties, useEffect, useMemo, useRef, useState } from 'react' import { isMobile } from 'prismarine-viewer/viewer/lib/simpleUtils' import styles from './input.module.css' @@ -28,6 +28,10 @@ export default ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue, }, []) + useEffect(() => { + setValidationStyle(validateInput?.(value as any) ?? {}) + }, [value, validateInput]) + return
{ - setValidationStyle(validateInput?.(e.target.value) ?? {}) setValue(e.target.value) inputProps.onChange?.(e) }} diff --git a/src/react/MinimapProvider.tsx b/src/react/MinimapProvider.tsx index 4b877732..44ee57b8 100644 --- a/src/react/MinimapProvider.tsx +++ b/src/react/MinimapProvider.tsx @@ -15,11 +15,12 @@ import { useSnapshot } from 'valtio' import BlockData from '../../prismarine-viewer/viewer/lib/moreBlockDataGenerated.json' import preflatMap from '../preflatMap.json' import { contro } from '../controls' -import { gameAdditionalState, showModal, hideModal, miscUiState, loadedGameState, activeModalStack } from '../globalState' +import { gameAdditionalState, showModal, hideModal, miscUiState, activeModalStack } from '../globalState' import { options } from '../optionsStorage' import Minimap, { DisplayMode } from './Minimap' import { ChunkInfo, DrawerAdapter, MapUpdates, MinimapDrawer } from './MinimapDrawer' import { useIsModalActive } from './utilsApp' +import { lastConnectOptions } from './AppStatusProvider' const getBlockKey = (x: number, z: number) => { return `${x},${z}` @@ -167,9 +168,9 @@ export class DrawerAdapterImpl extends TypedEventEmitter implements // type suppressed until server is updated. It works fine void (localServer as any).setWarp(warp, remove) } else if (remove) { - localStorage.removeItem(`warps: ${loadedGameState.username} ${loadedGameState.serverIp}`) + localStorage.removeItem(`warps: ${bot.player.username} ${lastConnectOptions.value!.server}`) } else { - localStorage.setItem(`warps: ${loadedGameState.username} ${loadedGameState.serverIp}`, JSON.stringify(this.warps)) + localStorage.setItem(`warps: ${bot.player.username} ${lastConnectOptions.value!.server}`, JSON.stringify(this.warps)) } this.emit('updateWarps') } diff --git a/src/react/MobileTopButtons.module.css b/src/react/MobileTopButtons.module.css index 239518bf..d1692f8b 100644 --- a/src/react/MobileTopButtons.module.css +++ b/src/react/MobileTopButtons.module.css @@ -6,7 +6,7 @@ left: 50%; transform: translate(-50%); gap: 0 5px; - z-index: -1; + z-index: var(--has-modals-z, 7); } .pause-btn, diff --git a/src/react/MobileTopButtons.tsx b/src/react/MobileTopButtons.tsx index ffeb7adf..f686d8af 100644 --- a/src/react/MobileTopButtons.tsx +++ b/src/react/MobileTopButtons.tsx @@ -21,7 +21,7 @@ export default () => { }, []) const onLongPress = async () => { - const select = await showOptionsModal('', f3Keybinds.filter(f3Keybind => f3Keybind.mobileTitle).map(f3Keybind => f3Keybind.mobileTitle)) + const select = await showOptionsModal('', f3Keybinds.filter(f3Keybind => f3Keybind.mobileTitle && (f3Keybind.enabled?.() ?? true)).map(f3Keybind => f3Keybind.mobileTitle)) if (!select) return const f3Keybind = f3Keybinds.find(f3Keybind => f3Keybind.mobileTitle === select) if (f3Keybind) f3Keybind.action() diff --git a/src/react/OptionsItems.tsx b/src/react/OptionsItems.tsx index bc431bd2..9879aeb4 100644 --- a/src/react/OptionsItems.tsx +++ b/src/react/OptionsItems.tsx @@ -3,11 +3,12 @@ import { noCase } from 'change-case' import { titleCase } from 'title-case' import { useMemo } from 'react' import { options, qsOptions } from '../optionsStorage' -import { miscUiState } from '../globalState' +import { hideAllModals, miscUiState } from '../globalState' import Button from './Button' import Slider from './Slider' import Screen from './Screen' import { showOptionsModal } from './SelectOption' +import PixelartIcon, { pixelartIcons } from './PixelartIcon' type GeneralItem = { id?: string @@ -188,10 +189,18 @@ interface Props { } export default ({ items, title, backButtonAction }: Props) => { + const { currentTouch } = useSnapshot(miscUiState) + return
+ {currentTouch && ( +
+
+ )} {items.map((element, i) => { // make sure its unique! return diff --git a/src/react/PauseScreen.tsx b/src/react/PauseScreen.tsx index 75b94872..29fb9604 100644 --- a/src/react/PauseScreen.tsx +++ b/src/react/PauseScreen.tsx @@ -19,6 +19,7 @@ import { disconnect } from '../flyingSquidUtils' import { pointerLock, setLoadingScreenStatus } from '../utils' import { closeWan, openToWanAndCopyJoinLink, getJoinLink } from '../localServerMultiplayer' import { collectFilesToCopy, fileExistsAsyncOptimized, mkdirRecursive, uniqueFileNameFromWorldName } from '../browserfs' +import { appQueryParams } from '../appParams' import { useIsModalActive } from './utilsApp' import { showOptionsModal } from './SelectOption' import Button from './Button' @@ -86,7 +87,7 @@ export const saveToBrowserMemory = async () => { const srcPath = join(worldFolder, copyPath) const savePath = join(saveRootPath, copyPath) await mkdirRecursive(savePath) - await fs.promises.writeFile(savePath, await fs.promises.readFile(srcPath)) + await fs.promises.writeFile(savePath, await fs.promises.readFile(srcPath) as any) upProgress(totalSIze) if (isRegionFiles) { const regionFile = copyPath.split('/').at(-1)! @@ -146,8 +147,7 @@ const splitByCopySize = (files: string[], copySize = 15) => { } export default () => { - const qsParams = new URLSearchParams(window.location.search) - const lockConnect = qsParams?.get('lockConnect') === 'true' + const lockConnect = appQueryParams.lockConnect === 'true' const isModalActive = useIsModalActive('pause-screen') const fsStateSnap = useSnapshot(fsState) const activeModalStackSnap = useSnapshot(activeModalStack) diff --git a/src/react/PlayerListOverlayProvider.tsx b/src/react/PlayerListOverlayProvider.tsx index 0fb1665c..50f2295c 100644 --- a/src/react/PlayerListOverlayProvider.tsx +++ b/src/react/PlayerListOverlayProvider.tsx @@ -1,15 +1,16 @@ import { useSnapshot } from 'valtio' import { useState, useEffect, useMemo } from 'react' -import { isGameActive, loadedGameState } from '../globalState' +import { isGameActive } from '../globalState' import PlayerListOverlay from './PlayerListOverlay' import './PlayerListOverlay.css' +import { lastConnectOptions } from './AppStatusProvider' const MAX_ROWS_PER_COL = 10 type Players = typeof bot.players export default () => { - const { serverIp } = useSnapshot(loadedGameState) + const serverIp = lastConnectOptions.value?.server const [clientId, setClientId] = useState('') const [players, setPlayers] = useState({}) const [isOpen, setIsOpen] = useState(false) diff --git a/src/react/ServersListProvider.tsx b/src/react/ServersListProvider.tsx index fa9242a2..01af0bb0 100644 --- a/src/react/ServersListProvider.tsx +++ b/src/react/ServersListProvider.tsx @@ -4,6 +4,8 @@ import { useSnapshot } from 'valtio' import { ConnectOptions } from '../connect' import { activeModalStack, hideCurrentModal, miscUiState, showModal } from '../globalState' import supportedVersions from '../supportedVersions.mjs' +import { appQueryParams } from '../appParams' +import { fetchServerStatus, isServerValid } from '../api/mcStatusApi' import ServersList from './ServersList' import AddServerOrConnect, { BaseServerInfo } from './AddServerOrConnect' import { useDidUpdateEffect } from './utils' @@ -11,42 +13,18 @@ import { useIsModalActive } from './utilsApp' import { showOptionsModal } from './SelectOption' import { useCopyKeybinding } from './simpleHooks' -interface StoreServerItem extends BaseServerInfo { +export interface StoreServerItem extends BaseServerInfo { lastJoined?: number description?: string optionsOverride?: Record autoLogin?: Record } -type ServerResponse = { - online: boolean - version?: { - name_raw: string - } - // display tooltip - players?: { - online: number - max: number - list: Array<{ - name_raw: string - name_clean: string - }> - } - icon?: string - motd?: { - raw: string - } - // todo circle error icon - mods?: Array<{ name, version }> - // todo display via hammer icon - software?: string - plugins?: Array<{ name, version }> -} - type AdditionalDisplayData = { formattedText: string textNameRight: string icon?: string + offline?: boolean } export interface AuthenticatedAccount { @@ -93,8 +71,8 @@ const getInitialServersList = () => { return servers } -const serversListQs = new URLSearchParams(window.location.search).get('serversList') -const proxyQs = new URLSearchParams(window.location.search).get('proxy') +const serversListQs = appQueryParams.serversList +const proxyQs = appQueryParams.proxy const setNewServersList = (serversList: StoreServerItem[], force = false) => { if (serversListQs && !force) return @@ -138,6 +116,9 @@ export const updateAuthenticatedAccountData = (callback: (data: AuthenticatedAcc // todo move to base const normalizeIp = (ip: string) => ip.replace(/https?:\/\//, '').replace(/\/(:|$)/, '') +const FETCH_DELAY = 100 // ms between each request +const MAX_CONCURRENT_REQUESTS = 10 + const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersList?: string[] }) => { const [proxies, setProxies] = useState(localStorage['proxies'] ? JSON.parse(localStorage['proxies']) : getInitialProxies()) const [selectedProxy, setSelectedProxy] = useState(proxyQs ?? localStorage.getItem('selectedProxy') ?? proxies?.[0] ?? '') @@ -196,36 +177,59 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL return serversList.map((server, index) => ({ ...server, index })).sort((a, b) => (b.lastJoined ?? 0) - (a.lastJoined ?? 0)) }, [serversList]) - useUtilsEffect(({ signal }) => { - const update = async () => { - for (const server of serversListSorted) { - const isInLocalNetwork = server.ip.startsWith('192.168.') || server.ip.startsWith('10.') || server.ip.startsWith('172.') || server.ip.startsWith('127.') || server.ip.startsWith('localhost') - if (isInLocalNetwork || signal.aborted) continue - // eslint-disable-next-line no-await-in-loop - await fetch(`https://api.mcstatus.io/v2/status/java/${server.ip}`, { - // TODO: bounty for this who fix it - // signal - }).then(async r => r.json()).then((data: ServerResponse) => { - const versionClean = data.version?.name_raw.replace(/^[^\d.]+/, '') - if (!versionClean) return - setAdditionalData(old => { - return ({ - ...old, - [server.ip]: { - formattedText: data.motd?.raw ?? '', - textNameRight: `${versionClean} ${data.players?.online ?? '??'}/${data.players?.max ?? '??'}`, - icon: data.icon, - } - }) - }) - }) - } - } - void update().catch((err) => {}) - }, [serversListSorted]) - const isEditScreenModal = useIsModalActive('editServer') + useUtilsEffect(({ signal }) => { + if (isEditScreenModal) return + const update = async () => { + const queue = serversListSorted + .map(server => { + if (!isServerValid(server.ip) || signal.aborted) return null + + return server + }) + .filter(x => x !== null) + + const activeRequests = new Set>() + + let lastRequestStart = 0 + for (const server of queue) { + // Wait if at concurrency limit + if (activeRequests.size >= MAX_CONCURRENT_REQUESTS) { + // eslint-disable-next-line no-await-in-loop + await Promise.race(activeRequests) + } + + // Create and track new request + // eslint-disable-next-line @typescript-eslint/no-loop-func + const request = new Promise(resolve => { + setTimeout(async () => { + try { + lastRequestStart = Date.now() + if (signal.aborted) return + const data = await fetchServerStatus(server.ip/* , signal */) // DONT ADD SIGNAL IT WILL CRUSH JS RUNTIME + if (data) { + setAdditionalData(old => ({ + ...old, + [server.ip]: data + })) + } + } finally { + activeRequests.delete(request) + resolve() + } + }, lastRequestStart ? Math.max(0, FETCH_DELAY - (Date.now() - lastRequestStart)) : 0) + }) + + activeRequests.add(request) + } + + await Promise.all(activeRequests) + } + + void update() + }, [serversListSorted, isEditScreenModal]) + useDidUpdateEffect(() => { if (serverEditScreen && !isEditScreenModal) { showModal({ reactType: 'editServer' }) @@ -394,10 +398,10 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL name: server.index.toString(), title: server.name || server.ip, detail: (server.versionOverride ?? '') + ' ' + (server.usernameOverride ?? ''), - // lastPlayed: server.lastJoined, formattedTextOverride: additional?.formattedText, worldNameRight: additional?.textNameRight ?? '', iconSrc: additional?.icon, + offline: additional?.offline } })} initialProxies={{ diff --git a/src/react/Singleplayer.tsx b/src/react/Singleplayer.tsx index 79c08ab8..d8291d90 100644 --- a/src/react/Singleplayer.tsx +++ b/src/react/Singleplayer.tsx @@ -12,6 +12,7 @@ import Button from './Button' import Tabs from './Tabs' import MessageFormattedString from './MessageFormattedString' import { useIsSmallWidth } from './simpleHooks' +import PixelartIcon from './PixelartIcon' export interface WorldProps { name: string @@ -26,9 +27,10 @@ export interface WorldProps { onFocus?: (name: string) => void onInteraction?(interaction: 'enter' | 'space') elemRef?: React.Ref + offline?: boolean } -const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction, iconSrc, formattedTextOverride, worldNameRight, elemRef }: WorldProps & { ref?: React.Ref }) => { +const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction, iconSrc, formattedTextOverride, worldNameRight, elemRef, offline }: WorldProps & { ref?: React.Ref }) => { const timeRelativeFormatted = useMemo(() => { if (!lastPlayed) return '' const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) @@ -60,7 +62,14 @@ const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus,
{title}
-
{worldNameRight}
+
+ {offline ? ( + + + Offline + + ) : worldNameRight} +
{formattedTextOverride ?
diff --git a/src/react/SoundMuffler.tsx b/src/react/SoundMuffler.tsx index d8571353..0151b97b 100644 --- a/src/react/SoundMuffler.tsx +++ b/src/react/SoundMuffler.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { useSnapshot } from 'valtio' import { hideCurrentModal } from '../globalState' -import { lastPlayedSounds } from '../soundSystem' +import { lastPlayedSounds } from '../sounds/botSoundSystem' import { options } from '../optionsStorage' import Button from './Button' import Screen from './Screen' diff --git a/src/react/TouchAreasControls.tsx b/src/react/TouchAreasControls.tsx index 58ea51aa..981ebebb 100644 --- a/src/react/TouchAreasControls.tsx +++ b/src/react/TouchAreasControls.tsx @@ -2,6 +2,7 @@ import { CSSProperties, PointerEvent, useEffect, useRef } from 'react' import { proxy, ref, useSnapshot } from 'valtio' import { contro } from '../controls' import worldInteractions from '../worldInteractions' +import { options } from '../optionsStorage' import PixelartIcon from './PixelartIcon' import Button from './Button' @@ -9,13 +10,6 @@ export type ButtonName = 'action' | 'sneak' | 'break' | 'jump' type ButtonsPositions = Record -interface Props { - touchActive: boolean - setupActive: boolean - buttonsPositions: ButtonsPositions - closeButtonsSetup: (newPositions?: ButtonsPositions) => void -} - const getCurrentAppScaling = () => { // body has css property --guiScale const guiScale = getComputedStyle(document.body).getPropertyValue('--guiScale') @@ -51,15 +45,23 @@ export const handleMovementStickDelta = (e?: { clientX, clientY }) => { }) } -export default ({ touchActive, setupActive, buttonsPositions, closeButtonsSetup }: Props) => { +type Props = { + setupActive: boolean + closeButtonsSetup: (newPositions?: ButtonsPositions) => void + foregroundGameActive: boolean +} + +const Z_INDEX_INTERACTIBLE = 8 + +export default ({ setupActive, closeButtonsSetup, foregroundGameActive }: Props) => { const bot = window.bot as typeof __type_bot | undefined - if (setupActive) touchActive = true + const { touchControlsPositions, touchMovementType, touchInteractionType } = useSnapshot(options) + const buttonsPositions = touchControlsPositions as ButtonsPositions const joystickOuter = useRef(null) const joystickInner = useRef(null) const { pointer } = useSnapshot(joystickPointer) - // const { isFlying, isSneaking } = useSnapshot(gameAdditionalState) const newButtonPositions = { ...buttonsPositions } const buttonProps = (name: ButtonName) => { @@ -146,6 +148,7 @@ export default ({ touchActive, setupActive, buttonsPositions, closeButtonsSetup justifyContent: 'center', alignItems: 'center', transition: 'background 0.1s', + zIndex: Z_INDEX_INTERACTIBLE, } satisfies CSSProperties, onPointerDown (e: PType) { const elem = e.currentTarget as HTMLElement @@ -162,8 +165,8 @@ export default ({ touchActive, setupActive, buttonsPositions, closeButtonsSetup const elem = e.currentTarget as HTMLElement const size = 32 const scale = getCurrentAppScaling() - const xPerc = (e.clientX - size / 4 / scale) / window.innerWidth * 100 - const yPerc = (e.clientY - size / 4 / scale) / window.innerHeight * 100 + const xPerc = (e.clientX - (size * scale) / 2) / window.innerWidth * 100 + const yPerc = (e.clientY - (size * scale) / 2) / window.innerHeight * 100 elem.style.left = `${xPerc}%` elem.style.top = `${yPerc}%` newButtonPositions[name] = [xPerc, yPerc] @@ -178,55 +181,65 @@ export default ({ touchActive, setupActive, buttonsPositions, closeButtonsSetup useEffect(() => { joystickPointer.joystickInner = joystickInner.current && ref(joystickInner.current) // todo antipattern - }, [touchActive]) + }, [foregroundGameActive]) - if (!touchActive) return null + if (!foregroundGameActive && !setupActive) return null return
-
+ {touchMovementType === 'modern' && (
-
-
- -
-
- -
-
- -
-
- -
+ > +
+
+ )} + {touchMovementType === 'modern' && ( + <> +
+ +
+
+ +
+ + )} + {touchInteractionType === 'buttons' && ( + <> +
+ +
+
+ +
+ + )} {setupActive &&
{ const usingTouch = useUsingTouch() const hasModals = useSnapshot(activeModalStack).length !== 0 const setupActive = useIsModalActive('touch-buttons-setup') - const { touchControlsPositions, touchControlsType } = useSnapshot(options) return { if (newPositions) { options.touchControlsPositions = newPositions @@ -21,5 +19,4 @@ export default () => { hideModal() }} /> - } diff --git a/src/react/TouchControls.tsx b/src/react/TouchControls.tsx index 1b97ffd8..0bf35730 100644 --- a/src/react/TouchControls.tsx +++ b/src/react/TouchControls.tsx @@ -49,9 +49,9 @@ export default () => { const usingTouch = useUsingTouch() const { usingGamepadInput } = useSnapshot(miscUiState) const modals = useSnapshot(activeModalStack) - const { touchControlsType } = useSnapshot(options) + const { touchMovementType } = useSnapshot(options) - if (!usingTouch || usingGamepadInput || touchControlsType !== 'classic') return null + if (!usingTouch || usingGamepadInput || touchMovementType !== 'classic') return null return (
{ return useMedia(SMALL_SCREEN_MEDIA.replace('@media ', '')) } +export const usePassesWindowDimensions = (minWidth: number | null = null, minHeight: number | null = null) => { + let media = '(' + if (minWidth !== null) { + media += `min-width: ${minWidth}px, ` + } + if (minHeight !== null) { + media += `min-height: ${minHeight}px, ` + } + media += ')' + return useMedia(media) +} + export const useCopyKeybinding = (getCopyText: () => string | undefined) => { useUtilsEffect(({ signal }) => { addEventListener('keydown', (e) => { diff --git a/src/reactUi.tsx b/src/reactUi.tsx index 3be75524..54da8bd0 100644 --- a/src/reactUi.tsx +++ b/src/reactUi.tsx @@ -47,6 +47,7 @@ import { options } from './optionsStorage' import BossBarOverlayProvider from './react/BossBarOverlayProvider' import DebugEdges from './react/DebugEdges' import LibraryVersions from './react/components/LibraryVersions' +import GameInteractionOverlay from './react/GameInteractionOverlay' const RobustPortal = ({ children, to }) => { return createPortal({children}, to) @@ -117,6 +118,7 @@ const InGameUi = () => { {/* apply scaling */}
+ {!disabledUiParts.includes('death-screen') && } {!disabledUiParts.includes('debug-overlay') && } {!disabledUiParts.includes('mobile-top-buttons') && } diff --git a/src/resourcePack.ts b/src/resourcePack.ts index d7bbdc47..e6c835e7 100644 --- a/src/resourcePack.ts +++ b/src/resourcePack.ts @@ -4,14 +4,14 @@ import fs from 'fs' import JSZip from 'jszip' import { proxy, subscribe } from 'valtio' import { WorldRendererThree } from 'prismarine-viewer/viewer/lib/worldrendererThree' -import { mkdirRecursive, removeFileRecursiveAsync } from './browserfs' +import { collectFilesToCopy, copyFilesAsyncWithProgress, mkdirRecursive, removeFileRecursiveAsync } from './browserfs' import { setLoadingScreenStatus } from './utils' import { showNotification } from './react/NotificationProvider' import { options } from './optionsStorage' import { showOptionsModal } from './react/SelectOption' import { appStatusState } from './react/AppStatusProvider' import { appReplacableResources, resourcesContentOriginal } from './generated/resources' -import { loadedGameState, miscUiState } from './globalState' +import { gameAdditionalState, miscUiState } from './globalState' import { watchUnloadForCleanup } from './gameUnload' export const resourcePackState = proxy({ @@ -32,7 +32,7 @@ const texturePackBasePath = '/data/resourcePacks/' export const uninstallTexturePack = async (name = 'default') => { if (await existsAsync('/resourcepack/pack.mcmeta')) { await removeFileRecursiveAsync('/resourcepack') - loadedGameState.usingServerResourcePack = false + gameAdditionalState.usingServerResourcePack = false } const basePath = texturePackBasePath + name if (!(await existsAsync(basePath))) return @@ -113,7 +113,7 @@ export const installTexturePack = async (file: File | ArrayBuffer, displayName = done++ upStatus() })) - console.log('done') + console.log('resource pack install done') await completeTexturePackInstall(displayName, name, isServer) } @@ -129,7 +129,7 @@ export const completeTexturePackInstall = async (displayName: string | undefined showNotification('Texturepack installed & enabled') await updateTexturePackInstalledState() if (isServer) { - loadedGameState.usingServerResourcePack = true + gameAdditionalState.usingServerResourcePack = true } else { options.enabledResourcepack = name } @@ -157,7 +157,7 @@ const getSizeFromImage = async (filePath: string) => { return probeImg.width } -export const getActiveTexturepackBasePath = async () => { +export const getActiveResourcepackBasePath = async () => { if (await existsAsync('/resourcepack/pack.mcmeta')) { return '/resourcepack' } @@ -198,7 +198,7 @@ const getFilesMapFromDir = async (dir: string) => { } export const getResourcepackTiles = async (type: 'blocks' | 'items', existingTextures: string[]) => { - const basePath = await getActiveTexturepackBasePath() + const basePath = await getActiveResourcepackBasePath() if (!basePath) return let firstTextureSize: number | undefined const namespaces = await fs.promises.readdir(join(basePath, 'assets')) @@ -282,7 +282,7 @@ const prepareBlockstatesAndModels = async () => { viewer.world.customBlockStates = {} viewer.world.customModels = {} const usedTextures = new Set() - const basePath = await getActiveTexturepackBasePath() + const basePath = await getActiveResourcepackBasePath() if (!basePath) return if (appStatusState.status) { setLoadingScreenStatus('Reading resource pack blockstates and models') @@ -361,6 +361,7 @@ export const onAppLoad = () => { customEvents.on('mineflayerBotCreated', () => { // todo also handle resourcePack const handleResourcePackRequest = async (packet) => { + console.log('Received resource pack request', packet) if (options.serverResourcePacks === 'never') return const promptMessagePacket = ('promptMessage' in packet && packet.promptMessage) ? packet.promptMessage : undefined const promptMessageText = promptMessagePacket ? '' : 'Do you want to use server resource pack?' @@ -397,7 +398,7 @@ export const onAppLoad = () => { } const updateAllReplacableTextures = async () => { - const basePath = await getActiveTexturepackBasePath() + const basePath = await getActiveResourcepackBasePath() const setCustomCss = async (path: string | null, varName: string, repeat = 1) => { if (path && await existsAsync(path)) { const contents = await fs.promises.readFile(path, 'base64') @@ -462,3 +463,29 @@ const updateTextures = async () => { export const resourcepackReload = async (version) => { await updateTextures() } + +export const copyServerResourcePackToRegular = async (name = 'default') => { + // Check if server resource pack exists + if (!(await existsAsync('/resourcepack/pack.mcmeta'))) { + throw new Error('No server resource pack is currently installed') + } + + // Get display name from server resource pack if available + let displayName + try { + displayName = await fs.promises.readFile('/resourcepack/name.txt', 'utf8') + } catch { + displayName = 'Server Resource Pack' + } + + // Copy all files from server resource pack to regular location + const destPath = texturePackBasePath + name + await mkdirRecursive(destPath) + + setLoadingScreenStatus('Copying server resource pack to regular location') + await copyFilesAsyncWithProgress('/resourcepack', destPath, true, ' (server -> regular)') + + // Complete the installation + await completeTexturePackInstall(displayName, name, false) + showNotification('Server resource pack copied to regular location') +} diff --git a/src/serviceWorker.ts b/src/serviceWorker.ts index c64ba0f6..1cec6863 100644 --- a/src/serviceWorker.ts +++ b/src/serviceWorker.ts @@ -2,6 +2,7 @@ import { isCypress } from './standaloneUtils' // might not resolve at all export const registerServiceWorker = async () => { + if (process.env.DISABLE_SERVICE_WORKER) return if (!('serviceWorker' in navigator)) return if (!isCypress() && process.env.NODE_ENV !== 'development') { return new Promise(resolve => { diff --git a/src/soundSystem.ts b/src/sounds/botSoundSystem.ts similarity index 51% rename from src/soundSystem.ts rename to src/sounds/botSoundSystem.ts index d0caf01f..4bf014ac 100644 --- a/src/soundSystem.ts +++ b/src/sounds/botSoundSystem.ts @@ -1,50 +1,51 @@ -import { subscribeKey } from 'valtio/utils' import { Vec3 } from 'vec3' -import { versionToMajor, versionToNumber, versionsMapToMajor } from 'prismarine-viewer/viewer/prepare/utils' +import { versionToNumber } from 'prismarine-viewer/viewer/prepare/utils' import { loadScript } from 'prismarine-viewer/viewer/lib/utils' import type { Block } from 'prismarine-block' -import { miscUiState } from './globalState' -import { options } from './optionsStorage' -import { loadOrPlaySound } from './basicSounds' -import { showNotification } from './react/NotificationProvider' +import { subscribeKey } from 'valtio/utils' +import { miscUiState } from '../globalState' +import { options } from '../optionsStorage' +import { loadOrPlaySound } from '../basicSounds' +import { getActiveResourcepackBasePath, resourcePackState } from '../resourcePack' +import { createSoundMap, SoundMap } from './soundsMap' +import { musicSystem } from './musicSystem' -const globalObject = window as { - allSoundsMap?: Record>, - allSoundsVersionedMap?: Record, +let soundMap: SoundMap | undefined + +const updateResourcePack = async () => { + if (!soundMap) return + soundMap.activeResourcePackBasePath = await getActiveResourcepackBasePath() ?? undefined } +let musicInterval: ReturnType | null = null + subscribeKey(miscUiState, 'gameLoaded', async () => { - if (!miscUiState.gameLoaded) return - const soundsLegacyMap = window.allSoundsVersionedMap as Record - const { allSoundsMap } = globalObject - const allSoundsMeta = window.allSoundsMeta as { format: string, baseUrl: string } - if (!allSoundsMap) { + if (!miscUiState.gameLoaded || !loadedData.sounds) { + stopMusicSystem() + soundMap?.quit() return } - const allSoundsMajor = versionsMapToMajor(allSoundsMap) - const soundsMap = allSoundsMajor[versionToMajor(bot.version)] ?? Object.values(allSoundsMajor)[0] - - if (!soundsMap || !miscUiState.gameLoaded || !loadedData.sounds) { - return - } - - // const soundsPerId = Object.fromEntries(Object.entries(soundsMap).map(([id, sound]) => [+id.split(';')[0], sound])) - const soundsPerName = Object.fromEntries(Object.entries(soundsMap).map(([id, sound]) => [id.split(';')[1], sound])) + console.log(`Loading sounds for version ${bot.version}. Resourcepack state: ${JSON.stringify(resourcePackState)}`) + soundMap = createSoundMap(bot.version) ?? undefined + if (!soundMap) return + void updateResourcePack() + startMusicSystem() const playGeneralSound = async (soundKey: string, position?: Vec3, volume = 1, pitch?: number) => { - if (!options.volume) return - const soundStaticData = soundsPerName[soundKey]?.split(';') - if (!soundStaticData) return - const soundVolume = +soundStaticData[0]! - const soundPath = soundStaticData[1]! - const versionedSound = getVersionedSound(bot.version, soundPath, Object.entries(soundsLegacyMap)) - // todo test versionedSound - const url = allSoundsMeta.baseUrl.replace(/\/$/, '') + (versionedSound ? `/${versionedSound}` : '') + '/minecraft/sounds/' + soundPath + '.' + allSoundsMeta.format - const isMuted = options.mutedSounds.includes(soundKey) || options.mutedSounds.includes(soundPath) || options.volume === 0 + if (!options.volume || !soundMap) return + const soundData = await soundMap.getSoundUrl(soundKey, volume) + if (!soundData) return + + const isMuted = options.mutedSounds.includes(soundKey) || options.volume === 0 if (position) { if (!isMuted) { - viewer.playSound(position, url, soundVolume * Math.max(Math.min(volume, 1), 0) * (options.volume / 100), Math.max(Math.min(pitch ?? 1, 2), 0.5)) + viewer.playSound( + position, + soundData.url, + soundData.volume * (options.volume / 100), + Math.max(Math.min(pitch ?? 1, 2), 0.5) + ) } if (getDistance(bot.entity.position, position) < 4 * 16) { lastPlayedSounds.lastServerPlayed[soundKey] ??= { count: 0, last: 0 } @@ -53,7 +54,7 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { } } else { if (!isMuted) { - await loadOrPlaySound(url, volume) + await loadOrPlaySound(soundData.url, volume) } lastPlayedSounds.lastClientPlayed.push(soundKey) if (lastPlayedSounds.lastClientPlayed.length > 10) { @@ -61,84 +62,72 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { } } } + + const musicStartCheck = async (force = false) => { + if (!soundMap) return + // 20% chance to start music + if (Math.random() > 0.2 && !force && !options.enableMusic) return + + const musicKeys = ['music.game'] + if (bot.game.gameMode === 'creative') { + musicKeys.push('music.creative') + } + const randomMusicKey = musicKeys[Math.floor(Math.random() * musicKeys.length)] + const soundData = await soundMap.getSoundUrl(randomMusicKey) + if (!soundData) return + await musicSystem.playMusic(soundData.url, soundData.volume) + } + + function startMusicSystem () { + if (musicInterval) return + musicInterval = setInterval(() => { + void musicStartCheck() + }, 10_000) + } + + window.forceStartMusic = () => { + void musicStartCheck(true) + } + + + function stopMusicSystem () { + if (musicInterval) { + clearInterval(musicInterval) + musicInterval = null + } + } + const playHardcodedSound = async (soundKey: string, position?: Vec3, volume = 1, pitch?: number) => { await playGeneralSound(soundKey, position, volume, pitch) } + bot.on('soundEffectHeard', async (soundId, position, volume, pitch) => { await playHardcodedSound(soundId, position, volume, pitch) }) + bot.on('hardcodedSoundEffectHeard', async (soundIdNum, soundCategory, position, volume, pitch) => { const fixOffset = versionToNumber('1.20.4') === versionToNumber(bot.version) ? -1 : 0 const soundKey = loadedData.sounds[soundIdNum + fixOffset]?.name if (soundKey === undefined) return await playGeneralSound(soundKey, position, volume, pitch) }) - // workaround as mineflayer doesn't support soundEvent + bot._client.on('sound_effect', async (packet) => { const soundResource = packet['soundEvent']?.resource as string | undefined if (packet.soundId !== 0 || !soundResource) return const pos = new Vec3(packet.x / 8, packet.y / 8, packet.z / 8) await playHardcodedSound(soundResource.replace('minecraft:', ''), pos, packet.volume, packet.pitch) }) + bot.on('entityHurt', async (entity) => { if (entity.id === bot.entity.id) { await playHardcodedSound('entity.player.hurt') } }) - const useBlockSound = (blockName: string, category: string, fallback: string) => { - blockName = { - // todo somehow generated, not full - grass_block: 'grass', - tall_grass: 'grass', - fern: 'grass', - large_fern: 'grass', - dead_bush: 'grass', - seagrass: 'grass', - tall_seagrass: 'grass', - kelp: 'grass', - kelp_plant: 'grass', - sugar_cane: 'grass', - bamboo: 'grass', - vine: 'grass', - nether_sprouts: 'grass', - nether_wart: 'grass', - twisting_vines: 'grass', - weeping_vines: 'grass', - - cobblestone: 'stone', - stone_bricks: 'stone', - mossy_stone_bricks: 'stone', - cracked_stone_bricks: 'stone', - chiseled_stone_bricks: 'stone', - stone_brick_slab: 'stone', - stone_brick_stairs: 'stone', - stone_brick_wall: 'stone', - polished_granite: 'stone', - }[blockName] ?? blockName - const key = 'block.' + blockName + '.' + category - return soundsPerName[key] ? key : fallback - } - - const getStepSound = (blockUnder: Block) => { - // const soundsMap = globalObject.allSoundsMap?.[bot.version] - // if (!soundsMap) return - // let soundResult = 'block.stone.step' - // for (const x of Object.keys(soundsMap).map(n => n.split(';')[1])) { - // const match = /block\.(.+)\.step/.exec(x) - // const block = match?.[1] - // if (!block) continue - // if (loadedData.blocksByName[block]?.name === blockUnder.name) { - // soundResult = x - // break - // } - // } - return useBlockSound(blockUnder.name, 'step', 'block.stone.step') - } - let lastStepSound = 0 const movementHappening = async () => { - if (!bot.player) return // no info yet + if (!bot.player || !soundMap) return // no info yet const VELOCITY_THRESHOLD = 0.1 const { x, z, y } = bot.player.entity.velocity if (bot.entity.onGround && Math.abs(x) < VELOCITY_THRESHOLD && (Math.abs(z) > VELOCITY_THRESHOLD || Math.abs(y) > VELOCITY_THRESHOLD)) { @@ -146,9 +135,9 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { if (Date.now() - lastStepSound > 300) { const blockUnder = bot.world.getBlock(bot.entity.position.offset(0, -1, 0)) if (blockUnder) { - const stepSound = getStepSound(blockUnder) + const stepSound = soundMap.getStepSound(blockUnder.name) if (stepSound) { - await playHardcodedSound(stepSound, undefined, 0.6)// todo not sure why 0.6 + await playHardcodedSound(stepSound, undefined, 0.6) lastStepSound = Date.now() } } @@ -157,8 +146,8 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { } const playBlockBreak = async (blockName: string, position?: Vec3) => { - const sound = useBlockSound(blockName, 'break', 'block.stone.break') - + if (!soundMap) return + const sound = soundMap.getBreakSound(blockName) await playHardcodedSound(sound, position, 0.6, 1) } @@ -200,8 +189,8 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { if (effectId === 1010) { console.log('play record', data) } - // todo add support for all current world events }) + let diggingBlock: Block | null = null customEvents.on('digStart', () => { diggingBlock = bot.blockAtCursor(5) @@ -214,40 +203,14 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { } registerEvents() - - // 1.20+ soundEffectHeard is broken atm - // bot._client.on('packet', (data, { name }, buffer) => { - // if (name === 'sound_effect') { - // console.log(data, buffer) - // } - // }) }) -// todo -// const music = { -// activated: false, -// playing: '', -// activate () { -// const gameMusic = Object.entries(globalObject.allSoundsMap?.[bot.version] ?? {}).find(([id, sound]) => sound.includes('music.game')) -// if (!gameMusic) return -// const soundPath = gameMusic[0].split(';')[1] -// const next = () => {} -// } -// } - -const getVersionedSound = (version: string, item: string, itemsMapSortedEntries: Array<[string, string[]]>) => { - const verNumber = versionToNumber(version) - for (const [itemsVer, items] of itemsMapSortedEntries) { - // 1.18 < 1.18.1 - // 1.13 < 1.13.2 - if (items.includes(item) && verNumber <= versionToNumber(itemsVer)) { - return itemsVer - } - } -} +subscribeKey(resourcePackState, 'resourcePackInstalled', async () => { + await updateResourcePack() +}) export const downloadSoundsIfNeeded = async () => { - if (!globalObject.allSoundsMap) { + if (!window.allSoundsMap) { try { await loadScript('./sounds.js') } catch (err) { diff --git a/src/sounds/musicSystem.ts b/src/sounds/musicSystem.ts new file mode 100644 index 00000000..ecabf43e --- /dev/null +++ b/src/sounds/musicSystem.ts @@ -0,0 +1,33 @@ +import { loadOrPlaySound } from '../basicSounds' +import { options } from '../optionsStorage' + +class MusicSystem { + private currentMusic: string | null = null + + async playMusic (url: string, musicVolume = 1) { + if (!options.enableMusic || this.currentMusic) return + + try { + const { onEnded } = await loadOrPlaySound(url, 0.5 * musicVolume, 5000) ?? {} + + if (!onEnded) return + + this.currentMusic = url + + onEnded(() => { + this.currentMusic = null + }) + } catch (err) { + console.warn('Failed to play music:', err) + this.currentMusic = null + } + } + + stopMusic () { + if (this.currentMusic) { + this.currentMusic = null + } + } +} + +export const musicSystem = new MusicSystem() diff --git a/src/sounds/soundsMap.ts b/src/sounds/soundsMap.ts new file mode 100644 index 00000000..94c5a4d8 --- /dev/null +++ b/src/sounds/soundsMap.ts @@ -0,0 +1,347 @@ +import fs from 'fs' +import path from 'path' +import { versionsMapToMajor, versionToMajor, versionToNumber } from 'prismarine-viewer/viewer/prepare/utils' + +import { stopAllSounds } from '../basicSounds' +import { musicSystem } from './musicSystem' + +interface SoundMeta { + format: string + baseUrl: string +} + +interface SoundData { + volume: number + path: string +} + +interface SoundMapData { + allSoundsMap: Record> + soundsLegacyMap: Record + soundsMeta: SoundMeta +} + +interface BlockSoundMap { + [blockName: string]: string +} + +interface SoundEntry { + file: string + weight: number + volume: number +} + +export class SoundMap { + private readonly soundsPerName: Record + private readonly existingResourcePackPaths: Set + public activeResourcePackBasePath: string | undefined + + constructor ( + private readonly soundData: SoundMapData, + private readonly version: string + ) { + const allSoundsMajor = versionsMapToMajor(soundData.allSoundsMap) + const soundsMap = allSoundsMajor[versionToMajor(version)] ?? Object.values(allSoundsMajor)[0] + this.soundsPerName = Object.fromEntries( + Object.entries(soundsMap).map(([id, soundsStr]) => { + const sounds = soundsStr.split(',').map(s => { + const [volume, name, weight] = s.split(';') + if (isNaN(Number(volume))) throw new Error('volume is not a number') + if (isNaN(Number(weight))) { + // debugger + throw new TypeError('weight is not a number') + } + return { + file: name, + weight: Number(weight), + volume: Number(volume) + } + }) + return [id.split(';')[1], sounds] + }) + ) + } + + async updateExistingResourcePackPaths () { + if (!this.activeResourcePackBasePath) return + // todo support sounds.js from resource pack + const soundsBasePath = path.join(this.activeResourcePackBasePath, 'assets/minecraft/sounds') + // scan recursively for sounds files + const scan = async (dir: string) => { + const entries = await fs.promises.readdir(dir, { withFileTypes: true }) + for (const entry of entries) { + const entryPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + await scan(entryPath) + } else if (entry.isFile() && entry.name.endsWith('.ogg')) { + const relativePath = path.relative(soundsBasePath, entryPath) + this.existingResourcePackPaths.add(relativePath) + } + } + } + + await scan(soundsBasePath) + } + + async getSoundUrl (soundKey: string, volume = 1): Promise<{ url: string; volume: number } | undefined> { + const sounds = this.soundsPerName[soundKey] + if (!sounds?.length) return undefined + + // Pick a random sound based on weights + const totalWeight = sounds.reduce((sum, s) => sum + s.weight, 0) + let random = Math.random() * totalWeight + const sound = sounds.find(s => { + random -= s.weight + return random <= 0 + }) ?? sounds[0] + + const versionedSound = this.getVersionedSound(sound.file) + + let url = this.soundData.soundsMeta.baseUrl.replace(/\/$/, '') + + (versionedSound ? `/${versionedSound}` : '') + + '/minecraft/sounds/' + + sound.file + + '.' + + this.soundData.soundsMeta.format + + // Try loading from resource pack file first + if (this.activeResourcePackBasePath) { + const tryFormat = async (format: string) => { + try { + const resourcePackPath = path.join(this.activeResourcePackBasePath!, `/assets/minecraft/sounds/${sound.file}.${format}`) + const fileData = await fs.promises.readFile(resourcePackPath) + url = `data:audio/${format};base64,${fileData.toString('base64')}` + return true + } catch (err) { + } + } + const success = await tryFormat(this.soundData.soundsMeta.format) + if (!success && this.soundData.soundsMeta.format !== 'ogg') { + await tryFormat('ogg') + } + } + + return { + url, + volume: sound.volume * Math.max(Math.min(volume, 1), 0) + } + } + + private getVersionedSound (item: string): string | undefined { + const verNumber = versionToNumber(this.version) + const entries = Object.entries(this.soundData.soundsLegacyMap) + for (const [itemsVer, items] of entries) { + if (items.includes(item) && verNumber <= versionToNumber(itemsVer)) { + return itemsVer + } + } + return undefined + } + + getBlockSound (blockName: string, category: string, fallback: string): string { + const mappedName = blockSoundAliases[blockName] ?? blockName + const key = `block.${mappedName}.${category}` + return this.soundsPerName[key] ? key : fallback + } + + getStepSound (blockName: string): string { + return this.getBlockSound(blockName, 'step', 'block.stone.step') + } + + getBreakSound (blockName: string): string { + return this.getBlockSound(blockName, 'break', 'block.stone.break') + } + + quit () { + musicSystem.stopMusic() + stopAllSounds() + } +} + +export function createSoundMap (version: string): SoundMap | null { + const globalObject = window as { + allSoundsMap?: Record>, + allSoundsVersionedMap?: Record, + allSoundsMeta?: { format: string, baseUrl: string } + } + if (!globalObject.allSoundsMap) return null + return new SoundMap({ + allSoundsMap: globalObject.allSoundsMap, + soundsLegacyMap: globalObject.allSoundsVersionedMap ?? {}, + soundsMeta: globalObject.allSoundsMeta! + }, version) +} + +// Block name mappings for sound effects +const blockSoundAliases: BlockSoundMap = { + // Grass-like blocks + grass_block: 'grass', + tall_grass: 'grass', + fern: 'grass', + large_fern: 'grass', + dead_bush: 'grass', + seagrass: 'grass', + tall_seagrass: 'grass', + kelp: 'grass', + kelp_plant: 'grass', + sugar_cane: 'grass', + bamboo: 'grass', + vine: 'grass', + nether_sprouts: 'grass', + nether_wart: 'grass', + twisting_vines: 'grass', + weeping_vines: 'grass', + sweet_berry_bush: 'grass', + glow_lichen: 'grass', + moss_carpet: 'grass', + moss_block: 'grass', + hanging_roots: 'grass', + spore_blossom: 'grass', + small_dripleaf: 'grass', + big_dripleaf: 'grass', + flowering_azalea: 'grass', + azalea: 'grass', + azalea_leaves: 'grass', + flowering_azalea_leaves: 'grass', + + // Stone-like blocks + cobblestone: 'stone', + stone_bricks: 'stone', + mossy_stone_bricks: 'stone', + cracked_stone_bricks: 'stone', + chiseled_stone_bricks: 'stone', + stone_brick_slab: 'stone', + stone_brick_stairs: 'stone', + stone_brick_wall: 'stone', + polished_granite: 'stone', + granite: 'stone', + andesite: 'stone', + diorite: 'stone', + polished_andesite: 'stone', + polished_diorite: 'stone', + deepslate: 'deepslate', + cobbled_deepslate: 'deepslate', + polished_deepslate: 'deepslate', + deepslate_bricks: 'deepslate_bricks', + deepslate_tiles: 'deepslate_tiles', + calcite: 'stone', + tuff: 'stone', + smooth_stone: 'stone', + smooth_sandstone: 'stone', + smooth_quartz: 'stone', + smooth_red_sandstone: 'stone', + + // Wood-like blocks + oak_planks: 'wood', + spruce_planks: 'wood', + birch_planks: 'wood', + jungle_planks: 'wood', + acacia_planks: 'wood', + dark_oak_planks: 'wood', + crimson_planks: 'wood', + warped_planks: 'wood', + oak_log: 'wood', + spruce_log: 'wood', + birch_log: 'wood', + jungle_log: 'wood', + acacia_log: 'wood', + dark_oak_log: 'wood', + crimson_stem: 'stem', + warped_stem: 'stem', + + // Metal blocks + iron_block: 'metal', + gold_block: 'metal', + copper_block: 'copper', + exposed_copper: 'copper', + weathered_copper: 'copper', + oxidized_copper: 'copper', + netherite_block: 'netherite_block', + ancient_debris: 'ancient_debris', + lodestone: 'lodestone', + chain: 'chain', + anvil: 'anvil', + chipped_anvil: 'anvil', + damaged_anvil: 'anvil', + + // Glass blocks + glass: 'glass', + glass_pane: 'glass', + white_stained_glass: 'glass', + orange_stained_glass: 'glass', + magenta_stained_glass: 'glass', + light_blue_stained_glass: 'glass', + yellow_stained_glass: 'glass', + lime_stained_glass: 'glass', + pink_stained_glass: 'glass', + gray_stained_glass: 'glass', + light_gray_stained_glass: 'glass', + cyan_stained_glass: 'glass', + purple_stained_glass: 'glass', + blue_stained_glass: 'glass', + brown_stained_glass: 'glass', + green_stained_glass: 'glass', + red_stained_glass: 'glass', + black_stained_glass: 'glass', + tinted_glass: 'glass', + + // Wool blocks + white_wool: 'wool', + orange_wool: 'wool', + magenta_wool: 'wool', + light_blue_wool: 'wool', + yellow_wool: 'wool', + lime_wool: 'wool', + pink_wool: 'wool', + gray_wool: 'wool', + light_gray_wool: 'wool', + cyan_wool: 'wool', + purple_wool: 'wool', + blue_wool: 'wool', + brown_wool: 'wool', + green_wool: 'wool', + red_wool: 'wool', + black_wool: 'wool', + + // Nether blocks + netherrack: 'netherrack', + nether_bricks: 'nether_bricks', + red_nether_bricks: 'nether_bricks', + nether_wart_block: 'wart_block', + warped_wart_block: 'wart_block', + soul_sand: 'soul_sand', + soul_soil: 'soul_soil', + basalt: 'basalt', + polished_basalt: 'basalt', + blackstone: 'gilded_blackstone', + gilded_blackstone: 'gilded_blackstone', + + // Amethyst blocks + amethyst_block: 'amethyst_block', + amethyst_cluster: 'amethyst_cluster', + large_amethyst_bud: 'large_amethyst_bud', + medium_amethyst_bud: 'medium_amethyst_bud', + small_amethyst_bud: 'small_amethyst_bud', + + // Miscellaneous + sand: 'sand', + red_sand: 'sand', + gravel: 'gravel', + snow: 'snow', + snow_block: 'snow', + powder_snow: 'powder_snow', + ice: 'glass', + packed_ice: 'glass', + blue_ice: 'glass', + slime_block: 'slime_block', + honey_block: 'honey_block', + scaffolding: 'scaffolding', + ladder: 'ladder', + lantern: 'lantern', + soul_lantern: 'lantern', + pointed_dripstone: 'pointed_dripstone', + dripstone_block: 'dripstone_block', + rooted_dirt: 'rooted_dirt', + sculk_sensor: 'sculk_sensor', + shroomlight: 'shroomlight' +} diff --git a/src/sounds/testSounds.ts b/src/sounds/testSounds.ts new file mode 100644 index 00000000..1f493549 --- /dev/null +++ b/src/sounds/testSounds.ts @@ -0,0 +1,8 @@ +import { createSoundMap } from './soundsMap' + +//@ts-expect-error +globalThis.window = {} +require('../../generated/sounds.js') + +const soundMap = createSoundMap('1.20.1') +console.log(soundMap?.getSoundUrl('ambient.cave')) diff --git a/src/worldInteractions.ts b/src/worldInteractions.ts index 1134d7f8..b1811569 100644 --- a/src/worldInteractions.ts +++ b/src/worldInteractions.ts @@ -6,6 +6,7 @@ import * as THREE from 'three' import { Vec3 } from 'vec3' import { LineMaterial } from 'three-stdlib' import { Entity } from 'prismarine-entity' +import { subscribeKey } from 'valtio/utils' import destroyStage0 from '../assets/destroy_stage_0.png' import destroyStage1 from '../assets/destroy_stage_1.png' import destroyStage2 from '../assets/destroy_stage_2.png' @@ -149,7 +150,16 @@ class WorldInteraction { const inCreative = bot.game.gameMode === 'creative' const pixelRatio = viewer.renderer.getPixelRatio() viewer.world.threejsCursorLineMaterial = new LineMaterial({ - color: inCreative ? 0x40_80_ff : 0x00_00_00, + color: (() => { + switch (options.highlightBlockColor) { + case 'blue': + return 0x40_80_ff + case 'classic': + return 0x00_00_00 + default: + return inCreative ? 0x40_80_ff : 0x00_00_00 + } + })(), linewidth: Math.max(pixelRatio * 0.7, 1) * 2, // dashed: true, // dashSize: 5, @@ -158,6 +168,8 @@ class WorldInteraction { upLineMaterial() // todo use gamemode update only bot.on('game', upLineMaterial) + // Update material when highlight color setting changes + subscribeKey(options, 'highlightBlockColor', upLineMaterial) } activateEntity (entity: Entity) { @@ -189,9 +201,18 @@ class WorldInteraction { } } + beforeUpdateChecks () { + if (!document.hasFocus()) { + // deactive all buttson + this.buttons.fill(false) + } + } + // todo this shouldnt be done in the render loop, migrate the code to dom events to avoid delays on lags update () { + this.beforeUpdateChecks() const inSpectator = bot.game.gameMode === 'spectator' + const inAdventure = bot.game.gameMode === 'adventure' const entity = getEntityCursor() let cursorBlock = inSpectator && !options.showCursorBlockInSpectator ? null : bot.blockAtCursor(5) if (entity) { @@ -199,7 +220,7 @@ class WorldInteraction { } let cursorBlockDiggable = cursorBlock - if (cursorBlock && !bot.canDigBlock(cursorBlock) && bot.game.gameMode !== 'creative') cursorBlockDiggable = null + if (cursorBlock && (!bot.canDigBlock(cursorBlock) || inAdventure) && bot.game.gameMode !== 'creative') cursorBlockDiggable = null const cursorChanged = cursorBlock && viewer.world.cursorBlock ? !viewer.world.cursorBlock.equals(cursorBlock.position) : viewer.world.cursorBlock !== cursorBlock @@ -393,6 +414,43 @@ const getDataFromShape = (shape) => { return { position, width, height, depth } } +// Blocks that can be interacted with in adventure mode +const activatableBlockPatterns = [ + // Containers + /^(chest|barrel|hopper|dispenser|dropper)$/, + /^.*shulker_box$/, + /^.*(furnace|smoker)$/, + /^(brewing_stand|beacon)$/, + // Crafting + /^.*table$/, + /^(grindstone|stonecutter|loom)$/, + /^.*anvil$/, + // Redstone + /^(lever|repeater|comparator|daylight_detector|observer|note_block|jukebox|bell)$/, + // Buttons + /^.*button$/, + // Doors and trapdoors + /^.*door$/, + /^.*trapdoor$/, + // Functional blocks + /^(enchanting_table|lectern|composter|respawn_anchor|lodestone|conduit)$/, + /^.*bee.*$/, + // Beds + /^.*bed$/, + // Misc + /^(cake|decorated_pot|crafter|trial_spawner|vault)$/ +] + +function isBlockActivatable (blockName: string) { + return activatableBlockPatterns.some(pattern => pattern.test(blockName)) +} + +function isLookingAtActivatableBlock () { + const cursorBlock = bot.blockAtCursor(5) + if (!cursorBlock) return false + return isBlockActivatable(cursorBlock.name) +} + export const getEntityCursor = () => { const entity = bot.nearestEntity((e) => { if (e.position.distanceTo(bot.entity.position) <= (bot.game.gameMode === 'creative' ? 5 : 3)) {