diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3eb1fc74..ce88481a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - uses: cypress-io/github-action@v5 with: install: false - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: failure() with: name: cypress-images @@ -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/TECH.md b/TECH.md index 3ea76719..c7f4ef4e 100644 --- a/TECH.md +++ b/TECH.md @@ -3,35 +3,35 @@ This project uses proxies so you can connect to almost any vanilla server. Though proxies have some limitations such as increased latency and servers will complain about using VPN (though we have a workaround for that, but ping will be much higher). This client generally has better performance but some features reproduction might be inaccurate eg its less stable and more buggy in some cases. -| Feature | This project | Eaglercraft | Description | -| --------------------------------- | ------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| General | | | | -| Mobile Support (touch) | ✅(+) | ✅ | | -| Gamepad Support | ✅ | ❌ | | -| A11Y | ✅ | ❌ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page (but maybe it's not needed) | -| Game Features | | | | -| Servers Support (quality) | ❌ | ✅ | Eaglercraft is vanilla Minecraft, while this project tries to emulate original game behavior at protocol level (Mineflayer is used) | -| Servers Support (any version, ip) | ✅ | ❌ | We support almost all Minecraft versions, only important if you connect to a server where you need new content like blocks or if you play with friends. And you can connect to almost any server using proxy servers! | -| Singleplayer Survival Features | ❌ | ✅ | Just like Eaglercraft this project can generate and save worlds, but generator is simple and only a few survival features are supported (look here for [supported features list](https://github.com/zardoy/space-squid)) | -| Singleplayer Maps | ✅ | ✅ | We support any version, but adventure maps won't work, but simple parkour and build maps might be interesting to explore... | -| Singleplayer Maps World Streaming | ✅ | ❌ | Thanks to Browserfs, saves can be loaded to local singleplayer server using multiple ways: from local folder, server directory (not zip), dropbox or other cloud *backend* etc... | -| P2P Multiplayer | ✅ | ✅ | A way to connect to other browser running the project. But it's almost useless here since many survival features are not implemented. Maybe only to build / explore maps together... | -| Voice Chat | ❌ | ✅ | Eaglercraft has custom WebRTC voice chat implementation, though it could also be easily implemented there | -| Online Servers | ✅ | ❌ | We have custom implementation (including integration on proxy side) for joining to servers | -| Plugin Features | ✅ | ❌ | We have Mineflayer plugins support, like Auto Jump & Auto Parkour was added here that way | -| Direct Connection | ❌ | ✅ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page | -| Mods | ❌(roadmap) | ❌ | This project will support mods for singleplayer. In theory its possible to implement support for modded servers on protocol level (including all needed mods) | -| Video Recording | ❌ | ✅ | Don't feel needed | -| Metaverse Features | ❌(roadmap) | ❌ | Iframes, video streams inside of game world (custom protocol channel) | -| Sounds | ✅ | ✅ | | -| Resource Packs | ✅(--) | ✅ | This project has very limited support for them (only textures images are loadable for now) | -| Assets Compressing & Splitting | ✅ | ❌ | We have advanced Minecraft data processing and good code chunk splitting so the web app will open much faster and use less memory | -| Graphics | | | | -| Fancy Graphics | ❌ | ✅ | While Eaglercraft has top-level shaders we don't even support lighting | -| Fast & Efficient Graphics | ❌(+) | ❌ | Feels like no one needs to have 64 rendering distance work smoothly | -| VR | ✅ | ❌ | Feels like not needed feature. UI is missing in this project since DOM can't be rendered in VR so Eaglercraft could be better in that aspect | -| AR | ❌ | ❌ | Would be the most useless feature | -| Minimap & Waypoints | ✅(-) | ❌ | We have buggy minimap, which can be enabled in settings and full map is opened by pressing `M` key | +| Feature | This project | Eaglercraft | Description | +| --------------------------------- | ----------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| General | | | | +| Mobile Support (touch) | ✅(+) | ✅ | | +| Gamepad Support | ✅ | ❌ | | +| A11Y | ✅ | ❌ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page (but maybe it's not needed) | +| Game Features | | | | +| Servers Support (quality) | ❌ | ✅ | Eaglercraft is vanilla Minecraft, while this project tries to emulate original game behavior at protocol level (Mineflayer is used) | +| Servers Support (any version, ip) | ✅ | ❌ | We support almost all Minecraft versions, only important if you connect to a server where you need new content like blocks or if you play with friends. And you can connect to almost any server using proxy servers! | +| Singleplayer Survival Features | ❌ | ✅ | Just like Eaglercraft this project can generate and save worlds, but generator is simple and only a few survival features are supported (look here for [supported features list](https://github.com/zardoy/space-squid)) | +| Singleplayer Maps | ✅ | ✅ | We support any version, but adventure maps won't work, but simple parkour and build maps might be interesting to explore... | +| Singleplayer Maps World Streaming | ✅ | ❌ | Thanks to Browserfs, saves can be loaded to local singleplayer server using multiple ways: from local folder, server directory (not zip), dropbox or other cloud *backend* etc... | +| P2P Multiplayer | ✅ | ✅ | A way to connect to other browser running the project. But it's almost useless here since many survival features are not implemented. Maybe only to build / explore maps together... | +| Voice Chat | ❌ | ✅ | Eaglercraft has custom WebRTC voice chat implementation, though it could also be easily implemented there | +| Online Servers | ✅ | ❌ | We have custom implementation (including integration on proxy side) for joining to servers | +| Plugin Features | ✅ | ❌ | We have Mineflayer plugins support, like Auto Jump & Auto Parkour was added here that way | +| Direct Connection | ✅ | ✅ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page | +| Moding | ❌(roadmap, client-side) | ❌ | This project will support mods for singleplayer. In theory its possible to implement support for modded servers on protocol level (including all needed mods) | +| Video Recording | ❌ | ✅ | Don't feel needed | +| Metaverse Features | ❌(roadmap) | ❌ | Iframes, video streams inside of game world (custom protocol channel) | +| Sounds | ✅ | ✅ | | +| Resource Packs | ✅(+extras) | ✅ | This project has very limited support for them (only textures images are loadable for now) | +| Assets Compressing & Splitting | ✅ | ❌ | We have advanced Minecraft data processing and good code chunk splitting so the web app will open much faster and use less memory | +| Graphics | | | | +| Fancy Graphics | ❌ | ✅ | While Eaglercraft has top-level shaders we don't even support lighting | +| Fast & Efficient Graphics | ❌(+) | ❌ | Feels like no one needs to have 64 rendering distance work smoothly | +| VR | ✅ | ❌ | Feels like not needed feature. UI is missing in this project since DOM can't be rendered in VR so Eaglercraft could be better in that aspect | +| AR | ❌ | ❌ | Would be the most useless feature | +| Minimap & Waypoints | ✅(-) | ❌ | We have buggy minimap, which can be enabled in settings and full map is opened by pressing `M` key | Features available to only this project: @@ -52,6 +52,6 @@ TODO | API | Usage & Description | | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | | `Crypto` API | Used to make chat features work when joining online servers with authentication. | -| `requestPointerLock({ unadjustedMovement: true })` API | Required for games. Disables system mouse acceleration (important for Mac users). Aka mouse raw input | +| `requestPointerLock({ unadjustedMovement: true })` API | Required for games. Disables system mouse acceleration (important for Mac users). Aka mouse raw input | | `navigator.keyboard.lock()` | (only in Chromium browsers) When entering fullscreen it allows to use any key combination like ctrl+w in the game | | `navigator.keyboard.getLayoutMap()` | (only in Chromium browsers) To display the right keyboard symbol for the key keybinding on different keyboard layouts (e.g. QWERTY vs AZERTY) | diff --git a/config.json b/config.json index 7813b591..58b35a75 100644 --- a/config.json +++ b/config.json @@ -6,20 +6,13 @@ "peerJsServer": "", "peerJsServerFallback": "https://p2p.mcraft.fun", "promoteServers": [ + { + "ip": "ws://play.mcraft.fun" + }, { "ip": "kaboom.pw", - "version": "1.18.2", - "description": "Chaos and destruction server. Free for everyone." - }, - { - "ip": "play.applemc.fun", - "version": "1.18.2", - "description": "Very nice server. Try it now!" - }, - { - "ip": "sus.shhnowisnottheti.me", - "version": "1.18.2", - "description": "Creative, your own 'boxes' (islands)" + "version": "1.20.3", + "description": "Very nice a polite server. Must try for everyone!" } ] } diff --git a/patches/minecraft-protocol@1.53.0.patch b/patches/minecraft-protocol@1.53.0.patch deleted file mode 100644 index 243e0bd7..00000000 --- a/patches/minecraft-protocol@1.53.0.patch +++ /dev/null @@ -1,188 +0,0 @@ -diff --git a/src/client/autoVersion.js b/src/client/autoVersion.js -index c437ecf3a0e4ab5758a48538c714b7e9651bb5da..d9c9895ae8614550aa09ad60a396ac32ffdf1287 100644 ---- a/src/client/autoVersion.js -+++ b/src/client/autoVersion.js -@@ -9,7 +9,7 @@ module.exports = function (client, options) { - client.wait_connect = true // don't let src/client/setProtocol proceed on socket 'connect' until 'connect_allowed' - debug('pinging', options.host) - // TODO: use 0xfe ping instead for better compatibility/performance? https://github.com/deathcap/node-minecraft-ping -- ping(options, function (err, response) { -+ ping(options, async function (err, response) { - if (err) { return client.emit('error', err) } - debug('ping response', response) - // TODO: could also use ping pre-connect to save description, type, max players, etc. -@@ -40,6 +40,7 @@ module.exports = function (client, options) { - - // Reinitialize client object with new version TODO: move out of its constructor? - client.version = minecraftVersion -+ await options.versionSelectedHook?.(client) - client.state = states.HANDSHAKING - - // Let other plugins such as Forge/FML (modinfo) respond to the ping response -diff --git a/src/client/chat.js b/src/client/chat.js -index f14269bea055d4329cd729271e7406ec4b344de7..00f5482eb6e3c911381ca9a728b1b4aae0d1d337 100644 ---- a/src/client/chat.js -+++ b/src/client/chat.js -@@ -111,7 +111,7 @@ module.exports = function (client, options) { - for (const player of packet.data) { - if (!player.chatSession) continue - client._players[player.UUID] = { -- publicKey: crypto.createPublicKey({ key: player.chatSession.publicKey.keyBytes, format: 'der', type: 'spki' }), -+ // publicKey: crypto.createPublicKey({ key: player.chatSession.publicKey.keyBytes, format: 'der', type: 'spki' }), - publicKeyDER: player.chatSession.publicKey.keyBytes, - sessionUuid: player.chatSession.uuid - } -@@ -127,7 +127,7 @@ module.exports = function (client, options) { - for (const player of packet.data) { - if (player.crypto) { - client._players[player.UUID] = { -- publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }), -+ // publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }), - publicKeyDER: player.crypto.publicKey, - signature: player.crypto.signature, - displayName: player.displayName || player.name -@@ -198,7 +198,7 @@ module.exports = function (client, options) { - if (mcData.supportFeature('useChatSessions')) { - const tsDelta = BigInt(Date.now()) - packet.timestamp - const expired = !packet.timestamp || tsDelta > messageExpireTime || tsDelta < 0 -- const verified = !packet.unsignedChatContent && updateAndValidateSession(packet.senderUuid, packet.plainMessage, packet.signature, packet.index, packet.previousMessages, packet.salt, packet.timestamp) && !expired -+ const verified = false && !packet.unsignedChatContent && updateAndValidateSession(packet.senderUuid, packet.plainMessage, packet.signature, packet.index, packet.previousMessages, packet.salt, packet.timestamp) && !expired - if (verified) client._signatureCache.push(packet.signature) - client.emit('playerChat', { - plainMessage: packet.plainMessage, -@@ -363,7 +363,7 @@ module.exports = function (client, options) { - } - } - -- client._signedChat = (message, options = {}) => { -+ client._signedChat = async (message, options = {}) => { - options.timestamp = options.timestamp || BigInt(Date.now()) - options.salt = options.salt || 1n - -@@ -405,7 +405,7 @@ module.exports = function (client, options) { - message, - timestamp: options.timestamp, - salt: options.salt, -- signature: (client.profileKeys && client._session) ? client.signMessage(message, options.timestamp, options.salt, undefined, acknowledgements) : undefined, -+ signature: (client.profileKeys && client._session) ? await client.signMessage(message, options.timestamp, options.salt, undefined, acknowledgements) : undefined, - offset: client._lastSeenMessages.pending, - acknowledged - }) -@@ -419,7 +419,7 @@ module.exports = function (client, options) { - message, - timestamp: options.timestamp, - salt: options.salt, -- signature: client.profileKeys ? client.signMessage(message, options.timestamp, options.salt, options.preview) : Buffer.alloc(0), -+ signature: client.profileKeys ? await client.signMessage(message, options.timestamp, options.salt, options.preview) : Buffer.alloc(0), - signedPreview: options.didPreview, - previousMessages: client._lastSeenMessages.map((e) => ({ - messageSender: e.sender, -diff --git a/src/client/encrypt.js b/src/client/encrypt.js -index b9d21bab9faccd5dbf1975fc423fc55c73e906c5..99ffd76527b410e3a393181beb260108f4c63536 100644 ---- a/src/client/encrypt.js -+++ b/src/client/encrypt.js -@@ -25,7 +25,11 @@ module.exports = function (client, options) { - if (packet.serverId !== '-') { - debug('This server appears to be an online server and you are providing no password, the authentication will probably fail') - } -- sendEncryptionKeyResponse() -+ client.end('This server appears to be an online server and you are providing no authentication. Try authenticating first.') -+ // sendEncryptionKeyResponse() -+ // client.once('set_compression', () => { -+ // clearTimeout(loginTimeout) -+ // }) - } - - function onJoinServerResponse (err) { -diff --git a/src/client.js b/src/client.js -index 74749698f8cee05b5dc749c271544f78d06645b0..e77e0a3f41c1ee780c3abbd54b0801d248c2a07c 100644 ---- a/src/client.js -+++ b/src/client.js -@@ -89,10 +89,12 @@ class Client extends EventEmitter { - parsed.metadata.name = parsed.data.name - parsed.data = parsed.data.params - parsed.metadata.state = state -- debug('read packet ' + state + '.' + parsed.metadata.name) -- if (debug.enabled) { -- const s = JSON.stringify(parsed.data, null, 2) -- debug(s && s.length > 10000 ? parsed.data : s) -+ if (!globalThis.excludeCommunicationDebugEvents?.includes(parsed.metadata.name)) { -+ debug('read packet ' + state + '.' + parsed.metadata.name) -+ if (debug.enabled) { -+ const s = JSON.stringify(parsed.data, null, 2) -+ debug(s && s.length > 10000 ? parsed.data : s) -+ } - } - if (this._hasBundlePacket && parsed.metadata.name === 'bundle_delimiter') { - if (this._mcBundle.length) { // End bundle -@@ -110,7 +112,13 @@ class Client extends EventEmitter { - this._hasBundlePacket = false - } - } else { -- emitPacket(parsed) -+ try { -+ emitPacket(parsed) -+ } catch (err) { -+ console.log('Client incorrectly handled packet ' + parsed.metadata.name) -+ console.error(err) -+ // todo investigate why it doesn't close the stream even if unhandled there -+ } - } - }) - } -@@ -168,7 +176,10 @@ class Client extends EventEmitter { - } - - const onFatalError = (err) => { -- this.emit('error', err) -+ // todo find out what is trying to write after client disconnect -+ if(err.code !== 'ECONNABORTED') { -+ this.emit('error', err) -+ } - endSocket() - } - -@@ -197,6 +208,8 @@ class Client extends EventEmitter { - serializer -> framer -> socket -> splitter -> deserializer */ - if (this.serializer) { - this.serializer.end() -+ this.socket?.end() -+ this.socket?.emit('end') - } else { - if (this.socket) this.socket.end() - } -@@ -238,8 +251,11 @@ class Client extends EventEmitter { - - write (name, params) { - if (!this.serializer.writable) { return } -- debug('writing packet ' + this.state + '.' + name) -- debug(params) -+ if (!globalThis.excludeCommunicationDebugEvents?.includes(name)) { -+ debug(`[${this.state}] from ${this.isServer ? 'server' : 'client'}: ` + name) -+ debug(params) -+ } -+ this.emit('writePacket', name, params) - this.serializer.write({ name, params }) - } - -diff --git a/src/index.d.ts b/src/index.d.ts -index e61d5403bab46251d35b22a2ea30eb09b2746a26..84f597427893671eeac231b11e6e42aa815601df 100644 ---- a/src/index.d.ts -+++ b/src/index.d.ts -@@ -135,6 +135,7 @@ declare module 'minecraft-protocol' { - sessionServer?: string - keepAlive?: boolean - closeTimeout?: number -+ closeTimeout?: number - noPongTimeout?: number - checkTimeoutInterval?: number - version?: string -@@ -155,6 +156,8 @@ declare module 'minecraft-protocol' { - disableChatSigning?: boolean - /** Pass custom client implementation if needed. */ - Client?: Client -+ /** Can be used to prepare mc data on autoVersion (client.version has selected version) */ -+ versionSelectedHook?: (client: Client) => Promise | void - } - - export class Server extends EventEmitter { diff --git a/patches/minecraft-protocol@1.54.0.patch b/patches/minecraft-protocol@1.54.0.patch index 32371450..29111f69 100644 --- a/patches/minecraft-protocol@1.54.0.patch +++ b/patches/minecraft-protocol@1.54.0.patch @@ -1,24 +1,3 @@ -diff --git a/src/client/autoVersion.js b/src/client/autoVersion.js -index 3fe1552672e4c0dd1b14b3b56950c3d7eaf3537b..6eb615e5827279c328d5547b5911626693252da4 100644 ---- a/src/client/autoVersion.js -+++ b/src/client/autoVersion.js -@@ -9,7 +9,7 @@ module.exports = function (client, options) { - client.wait_connect = true // don't let src/client/setProtocol proceed on socket 'connect' until 'connect_allowed' - debug('pinging', options.host) - // TODO: use 0xfe ping instead for better compatibility/performance? https://github.com/deathcap/node-minecraft-ping -- ping(options, function (err, response) { -+ ping(options, async function (err, response) { - if (err) { return client.emit('error', err) } - debug('ping response', response) - // TODO: could also use ping pre-connect to save description, type, max players, etc. -@@ -40,6 +40,7 @@ module.exports = function (client, options) { - - // Reinitialize client object with new version TODO: move out of its constructor? - client.version = minecraftVersion -+ await options.versionSelectedHook?.(client) - client.state = states.HANDSHAKING - - // Let other plugins such as Forge/FML (modinfo) respond to the ping response diff --git a/src/client/chat.js b/src/client/chat.js index f14269bea055d4329cd729271e7406ec4b344de7..00f5482eb6e3c911381ca9a728b1b4aae0d1d337 100644 --- a/src/client/chat.js @@ -165,24 +144,3 @@ index 74749698f8cee05b5dc749c271544f78d06645b0..e77e0a3f41c1ee780c3abbd54b0801d2 this.serializer.write({ name, params }) } -diff --git a/src/index.d.ts b/src/index.d.ts -index e61d5403bab46251d35b22a2ea30eb09b2746a26..84f597427893671eeac231b11e6e42aa815601df 100644 ---- a/src/index.d.ts -+++ b/src/index.d.ts -@@ -135,6 +135,7 @@ declare module 'minecraft-protocol' { - sessionServer?: string - keepAlive?: boolean - closeTimeout?: number -+ closeTimeout?: number - noPongTimeout?: number - checkTimeoutInterval?: number - version?: string -@@ -155,6 +156,8 @@ declare module 'minecraft-protocol' { - disableChatSigning?: boolean - /** Pass custom client implementation if needed. */ - Client?: Client -+ /** Can be used to prepare mc data on autoVersion (client.version has selected version) */ -+ versionSelectedHook?: (client: Client) => Promise | void - } - - export class Server extends EventEmitter { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a3a5ef1..b6d75211 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,7 +21,7 @@ overrides: patchedDependencies: minecraft-protocol@1.54.0: - hash: 3wm2z233n46lqi64rbxem4nyv4 + hash: dkeyukcqlupmk563gwxsmjr3yu path: patches/minecraft-protocol@1.54.0.patch mineflayer-item-map-downloader@1.2.0: hash: bck55yjvd4wrgz46x7o4vfur5q @@ -138,7 +138,7 @@ importers: version: 3.83.1 minecraft-protocol: specifier: github:PrismarineJS/node-minecraft-protocol#master - version: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/e9eb551ba30ec2e742c49e6927be6402b413bb76(patch_hash=3wm2z233n46lqi64rbxem4nyv4)(encoding@0.1.13) + version: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/e9eb551ba30ec2e742c49e6927be6402b413bb76(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13) mineflayer-item-map-downloader: specifier: github:zardoy/mineflayer-item-map-downloader version: https://codeload.github.com/zardoy/mineflayer-item-map-downloader/tar.gz/a8d210ecdcf78dd082fa149a96e1612cc9747824(patch_hash=bck55yjvd4wrgz46x7o4vfur5q)(encoding@0.1.13) @@ -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: @@ -7185,8 +7185,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: @@ -12780,7 +12780,7 @@ snapshots: flatmap: 0.0.3 long: 5.2.3 minecraft-data: 3.83.1 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/e9eb551ba30ec2e742c49e6927be6402b413bb76(patch_hash=3wm2z233n46lqi64rbxem4nyv4)(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/e9eb551ba30ec2e742c49e6927be6402b413bb76(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13) mkdirp: 2.1.6 node-gzip: 1.1.2 node-rsa: 1.1.1 @@ -12788,7 +12788,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 @@ -16937,7 +16937,7 @@ snapshots: - '@types/react' - react - minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/e9eb551ba30ec2e742c49e6927be6402b413bb76(patch_hash=3wm2z233n46lqi64rbxem4nyv4)(encoding@0.1.13): + minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/e9eb551ba30ec2e742c49e6927be6402b413bb76(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13): dependencies: '@types/node-rsa': 1.1.4 '@types/readable-stream': 4.0.12 @@ -17007,7 +17007,7 @@ snapshots: mineflayer@4.25.0(encoding@0.1.13): dependencies: minecraft-data: 3.83.1 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/e9eb551ba30ec2e742c49e6927be6402b413bb76(patch_hash=3wm2z233n46lqi64rbxem4nyv4)(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/e9eb551ba30ec2e742c49e6927be6402b413bb76(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13) prismarine-biome: 1.3.0(minecraft-data@3.83.1)(prismarine-registry@1.11.0) prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) prismarine-chat: 1.10.1 @@ -17030,7 +17030,7 @@ snapshots: mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/54f8c2282d822ad02967a197bda36302a4e7b4a5(encoding@0.1.13): dependencies: minecraft-data: 3.83.1 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/e9eb551ba30ec2e742c49e6927be6402b413bb76(patch_hash=3wm2z233n46lqi64rbxem4nyv4)(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/e9eb551ba30ec2e742c49e6927be6402b413bb76(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13) prismarine-biome: 1.3.0(minecraft-data@3.83.1)(prismarine-registry@1.11.0) prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) prismarine-chat: 1.10.1 @@ -17220,7 +17220,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 @@ -17858,7 +17858,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/makeOptimizedMcData.mjs b/scripts/makeOptimizedMcData.mjs index 74477d7f..ebc97b59 100644 --- a/scripts/makeOptimizedMcData.mjs +++ b/scripts/makeOptimizedMcData.mjs @@ -242,4 +242,4 @@ const initialMcData = { } } -fs.writeFileSync('./generated/minecraft-initial-data.json', JSON.stringify(initialMcData), 'utf8') +// fs.writeFileSync('./generated/minecraft-initial-data.json', JSON.stringify(initialMcData), '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..0b6bd6d8 --- /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 + noPacketsValidation?: 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..a7313f36 100644 --- a/src/connect.ts +++ b/src/connect.ts @@ -1,9 +1,9 @@ -import { versionsByMinecraftVersion } from 'minecraft-data' -import minecraftInitialDataJson from '../generated/minecraft-initial-data.json' +// 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 { miscUiState } from './globalState' +import { downloadSoundsIfNeeded } from './sounds/botSoundSystem' +import { options } from './optionsStorage' +import supportedVersions from './supportedVersions.mjs' export type ConnectOptions = { server?: string @@ -24,21 +24,43 @@ export type ConnectOptions = { viewerWsConnect?: string } -export const downloadNeededDataOnConnect = async (version: string) => { - // todo expose cache - const initialDataVersion = Object.keys(minecraftInitialDataJson)[0]! - if (version === initialDataVersion) { - // ignore cache hit - versionsByMinecraftVersion.pc[initialDataVersion]!.dataVersion!++ +export const getVersionAutoSelect = (autoVersionSelect = options.serversAutoVersionSelect) => { + if (autoVersionSelect === 'auto') { + return '1.20.4' } - setLoadingScreenStatus(`Loading data for ${version}`) - if (!document.fonts.check('1em mojangles')) { + if (autoVersionSelect === 'latest') { + return supportedVersions.at(-1)! + } + return autoVersionSelect +} + +export const downloadMcDataOnConnect = async (version: string) => { + // setLoadingScreenStatus(`Loading data for ${version}`) + // // todo expose cache + // // const initialDataVersion = Object.keys(minecraftInitialDataJson)[0]! + // // if (version === initialDataVersion) { + // // // ignore cache hit + // // versionsByMinecraftVersion.pc[initialDataVersion]!.dataVersion!++ + // // } + + // await window._MC_DATA_RESOLVER.promise // ensure data is loaded + // miscUiState.loadedDataVersion = version +} + +export const downloadAllMinecraftData = async () => { + await window._LOAD_MC_DATA() +} + +const loadFonts = async () => { + const FONT_FAMILY = 'mojangles' + if (!document.fonts.check(`1em ${FONT_FAMILY}`)) { // todo instead re-render signs on load - await document.fonts.load('1em mojangles').catch(() => { + await document.fonts.load(`1em ${FONT_FAMILY}`).catch(() => { console.error('Failed to load font, signs wont be rendered correctly') }) } - await window._MC_DATA_RESOLVER.promise // ensure data is loaded - await downloadSoundsIfNeeded() - miscUiState.loadedDataVersion = version +} + +export const downloadOtherGameData = async () => { + await Promise.all([loadFonts(), downloadSoundsIfNeeded()]) } diff --git a/src/controls.ts b/src/controls.ts index 559379a3..0352a7e5 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..f5596308 100644 --- a/src/globalState.ts +++ b/src/globalState.ts @@ -1,8 +1,7 @@ //@ts-check import { proxy, ref, subscribe } from 'valtio' -import { WorldWarp } from 'flying-squid/dist/lib/modules/warps' -import { pointerLock } from './utils' +import type { WorldWarp } from 'flying-squid/dist/lib/modules/warps' import type { OptionsGroupType } from './optionsGuiScheme' // todo: refactor structure with support of hideNext=false @@ -26,16 +25,6 @@ export const activeModalStacks: Record = {} window.activeModalStack = activeModalStack -subscribe(activeModalStack, () => { - if (activeModalStack.length === 0) { - if (isGameActive(false)) { - void pointerLock.requestPointerLock() - } - } else { - document.exitPointerLock?.() - } -}) - /** * @returns true if operation was successful */ @@ -86,10 +75,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 +139,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,9 +152,9 @@ export const gameAdditionalState = proxy({ isSprinting: false, isSneaking: false, isZooming: false, - warps: [] as WorldWarp[] + warps: [] as WorldWarp[], + + usingServerResourcePack: false, }) window.gameAdditionalState = gameAdditionalState - -// todo restore auto-save on interval for player data! (or implement it in flying squid since there is already auto-save for world) 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/importsWorkaround.js b/src/importsWorkaround.js index 21bc4585..231654ca 100644 --- a/src/importsWorkaround.js +++ b/src/importsWorkaround.js @@ -1,4 +1,6 @@ // workaround for mineflayer +globalThis.window ??= globalThis +globalThis.localStorage ??= {} process.versions.node = '18.0.0' if (!navigator.getGamepads) { diff --git a/src/index.ts b/src/index.ts index b68f3d2d..496ccd36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,14 +5,14 @@ import './globals' import './devtools' import './entities' import './globalDomListeners' +import { getServerInfo } from './mineflayer/mc-protocol' import './mineflayer/maps' import './mineflayer/cameraShake' -import initCollisionShapes from './getCollisionInteractionShapes' +import './shims/patchShims' 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 +24,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 +40,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 +53,12 @@ import { hideModal, insertActiveModalStack, isGameActive, - loadedGameState, miscUiState, showModal } from './globalState' - import { - pointerLock, - toMajorVersion, - setLoadingScreenStatus + pointerLock, setLoadingScreenStatus } from './utils' import { isCypress } from './standaloneUtils' @@ -74,10 +70,9 @@ import { startLocalServer, unsupportedLocalServerFeatures } from './createLocalS import defaultServerOptions from './defaultLocalServerOptions' import dayCycle from './dayCycle' -import { onAppLoad, resourcepackReload } from './resourcePack' +import { onAppLoad, resourcepackReload, resourcePackState } 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 +80,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' @@ -95,7 +88,7 @@ import { saveToBrowserMemory } from './react/PauseScreen' import { ViewerWrapper } from 'prismarine-viewer/viewer/lib/viewerWrapper' import './devReload' import './water' -import { ConnectOptions, downloadNeededDataOnConnect } from './connect' +import { ConnectOptions, downloadMcDataOnConnect, getVersionAutoSelect, downloadOtherGameData, downloadAllMinecraftData } from './connect' import { ref, subscribe } from 'valtio' import { signInMessageState } from './react/SignInMessageProvider' import { updateAuthenticatedAccountData, updateLoadedServerData } from './react/ServersListProvider' @@ -106,6 +99,10 @@ import { ItemsRenderer } from 'mc-assets/dist/itemsRenderer' import './mobileShim' import { parseFormattedMessagePacket } from './botUtils' import { getViewerVersionData, getWsProtocolStream } from './viewerConnector' +import { getWebsocketStream } from './mineflayer/websocket-core' +import { appQueryParams, appQueryParamsArray } from './appParams' +import { updateCursor } from './cameraRotationControls' +import { pingServerVersion } from './mineflayer/minecraft-protocol-extra' window.debug = debug window.THREE = THREE @@ -201,44 +198,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 +252,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 @@ -297,17 +263,26 @@ async function connect (connectOptions: ConnectOptions) { miscUiState.singleplayer = singleplayer miscUiState.flyingSquid = singleplayer || p2pMultiplayer const { renderDistance: renderDistanceSingleplayer, multiplayerRenderDistance } = options - const server = cleanConnectIp(connectOptions.server, '25565') + const isWebSocket = connectOptions.server?.startsWith('ws://') || connectOptions.server?.startsWith('wss://') + const server = isWebSocket ? { host: connectOptions.server, port: undefined } : cleanConnectIp(connectOptions.server, '25565') 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 - console.log(`connecting to ${server.host}:${server.port} with ${username}`) + if (connectOptions.server) { + console.log(`connecting to ${server.host}:${server.port}`) + } + console.log('using player username', username) hideCurrentScreens() - setLoadingScreenStatus('Logging in') + const loggingInMsg = connectOptions.server ? 'Connecting to server' : 'Logging in' + setLoadingScreenStatus(loggingInMsg) let ended = false let bot!: typeof __type_bot @@ -379,9 +354,10 @@ async function connect (connectOptions: ConnectOptions) { signal: errorAbortController.signal }) - if (proxy && !connectOptions.viewerWsConnect) { - console.log(`using proxy ${proxy.host}:${proxy.port || location.port}`) + let clientDataStream + if (proxy && !connectOptions.viewerWsConnect && !isWebSocket) { + console.log(`using proxy ${proxy.host}:${proxy.port || location.port}`) net['setProxy']({ hostname: proxy.host, port: proxy.port }) } @@ -391,15 +367,24 @@ async function connect (connectOptions: ConnectOptions) { try { const serverOptions = defaultsDeep({}, connectOptions.serverOverrides ?? {}, options.localServerOptions, defaultServerOptions) Object.assign(serverOptions, connectOptions.serverOverridesFlat ?? {}) - window._LOAD_MC_DATA() // start loading data (if not loaded yet) + setLoadingScreenStatus('Downloading minecraft data') + await Promise.all([ + downloadAllMinecraftData(), // download mc data before we can use minecraft-data at all + downloadOtherGameData() + ]) + setLoadingScreenStatus(loggingInMsg) + let dataDownloaded = false const downloadMcData = async (version: string) => { + if (dataDownloaded) return + dataDownloaded = true if (connectOptions.authenticatedAccount && (versionToNumber(version) < versionToNumber('1.19.4') || versionToNumber(version) >= versionToNumber('1.21'))) { // todo support it (just need to fix .export crash) throw new Error('Microsoft authentication is only supported on 1.19.4 - 1.20.6 (at least for now)') } - await downloadNeededDataOnConnect(version) + await downloadMcDataOnConnect(version) try { + // TODO! reload only after login packet (delay viewer display) so no unecessary reload after server one is isntalled await resourcepackReload(version) } catch (err) { console.error(err) @@ -408,14 +393,13 @@ async function connect (connectOptions: ConnectOptions) { throw err } } + setLoadingScreenStatus('Loading minecraft assets') viewer.world.blockstatesModels = await import('mc-assets/dist/blockStatesModels.json') void viewer.setVersion(version, options.useVersionsTextures === 'latest' ? version : options.useVersionsTextures) + miscUiState.loadedDataVersion = version } - const downloadVersion = connectOptions.botVersion || (singleplayer ? serverOptions.version : undefined) - if (downloadVersion) { - await downloadMcData(downloadVersion) - } + let finalVersion = connectOptions.botVersion || (singleplayer ? serverOptions.version : undefined) if (singleplayer) { // SINGLEPLAYER EXPLAINER: @@ -455,11 +439,23 @@ async function connect (connectOptions: ConnectOptions) { initialLoadingText = 'Local server is still starting' } else if (p2pMultiplayer) { initialLoadingText = 'Connecting to peer' + } else if (connectOptions.server) { + if (!finalVersion) { + const versionAutoSelect = getVersionAutoSelect() + setLoadingScreenStatus(`Fetching server version. Preffered: ${versionAutoSelect}`) + const autoVersionSelect = await getServerInfo(server.host!, server.port ? Number(server.port) : undefined, versionAutoSelect) + finalVersion = autoVersionSelect.version + } + initialLoadingText = `Connecting to server ${server.host} with version ${finalVersion}` } else { - initialLoadingText = 'Connecting to server' + initialLoadingText = 'We have no idea what to do' } setLoadingScreenStatus(initialLoadingText) + if (isWebSocket) { + clientDataStream = (await getWebsocketStream(server.host!)).mineflayerStream + } + let newTokensCacheResult = null as any const cachedTokens = typeof connectOptions.authenticatedAccount === 'object' ? connectOptions.authenticatedAccount.cachedTokens : {} const authData = connectOptions.authenticatedAccount ? await microsoftAuthflow({ @@ -474,7 +470,6 @@ async function connect (connectOptions: ConnectOptions) { connectingServer: server.host }) : undefined - let clientDataStream if (p2pMultiplayer) { clientDataStream = await connectToPeer(connectOptions.peerId!, connectOptions.peerOptions) } @@ -482,16 +477,21 @@ async function connect (connectOptions: ConnectOptions) { const { version, time } = await getViewerVersionData(connectOptions.viewerWsConnect) console.log('Latency:', Date.now() - time, 'ms') // const version = '1.21.1' - connectOptions.botVersion = version + finalVersion = version await downloadMcData(version) setLoadingScreenStatus(`Connecting to WebSocket server ${connectOptions.viewerWsConnect}`) clientDataStream = await getWsProtocolStream(connectOptions.viewerWsConnect) } + if (finalVersion) { + // ensure data is downloaded + await downloadMcData(finalVersion) + } + bot = mineflayer.createBot({ host: server.host, port: server.port ? +server.port : undefined, - version: connectOptions.botVersion || false, + version: finalVersion || false, ...clientDataStream ? { stream: clientDataStream, } : {}, @@ -557,10 +557,6 @@ async function connect (connectOptions: ConnectOptions) { closeTimeout: 240 * 1000, respawn: options.autoRespawn, maxCatchupTicks: 0, - async versionSelectedHook (client) { - await downloadMcData(client.version) - setLoadingScreenStatus(initialLoadingText) - }, 'mapDownloader-saveToFile': false, // "mapDownloader-saveInternal": false, // do not save into memory, todo must be implemeneted as we do really care of ram }) as unknown as typeof __type_bot @@ -578,7 +574,7 @@ async function connect (connectOptions: ConnectOptions) { bot.emit('inject_allowed') bot._client.emit('connect') - } else if (connectOptions.viewerWsConnect) { + } else if (clientDataStream) { // bot.emit('inject_allowed') bot._client.emit('connect') } else { @@ -668,6 +664,9 @@ async function connect (connectOptions: ConnectOptions) { bot.on('end', (endReason) => { if (ended) return console.log('disconnected for', endReason) + if (endReason === 'socketClosed') { + endReason = 'Connection with server lost' + } setLoadingScreenStatus(`You have been disconnected from the server. End reason: ${endReason}`, true) onPossibleErrorDisconnect() destroyAll() @@ -684,7 +683,21 @@ async function connect (connectOptions: ConnectOptions) { const spawnEarlier = !singleplayer && !p2pMultiplayer // don't use spawn event, player can be dead - bot.once(spawnEarlier ? 'forcedMove' : 'health', () => { + bot.once(spawnEarlier ? 'forcedMove' : 'health', async () => { + if (resourcePackState.isServerInstalling) { + setLoadingScreenStatus('Downloading resource pack') + await new Promise(resolve => { + subscribe(resourcePackState, () => { + if (!resourcePackState.isServerDownloading) { + setLoadingScreenStatus('Installing resource pack') + } + if (!resourcePackState.isServerInstalling) { + resolve() + } + }) + }) + } + window.focus?.() errorAbortController.abort() const mcData = MinecraftData(bot.version) window.PrismarineBlock = PrismarineBlock(mcData.version.minecraftVersion!) @@ -701,7 +714,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() @@ -745,155 +758,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() - bot.entity.pitch -= y - bot.entity.pitch = Math.max(minPitch, Math.min(maxPitch, bot.entity.pitch)) - bot.entity.yaw -= x - } - - 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 +781,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 +794,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 +806,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 +869,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 +903,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 +923,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 +935,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/mineflayer/mc-protocol.ts b/src/mineflayer/mc-protocol.ts new file mode 100644 index 00000000..0fc09470 --- /dev/null +++ b/src/mineflayer/mc-protocol.ts @@ -0,0 +1,32 @@ +import { Client } from 'minecraft-protocol' +import { appQueryParams } from '../appParams' +import { downloadAllMinecraftData, getVersionAutoSelect } from '../connect' +import { pingServerVersion, validatePacket } from './minecraft-protocol-extra' +import { getWebsocketStream } from './websocket-core' + +customEvents.on('mineflayerBotCreated', () => { + // todo move more code here + if (!appQueryParams.noPacketsValidation) { + (bot._client as unknown as Client).on('packet', (data, packetMeta, buffer, fullBuffer) => { + validatePacket(packetMeta.name, data, fullBuffer, true) + }); + (bot._client as unknown as Client).on('writePacket', (name, params) => { + validatePacket(name, params, Buffer.alloc(0), false) + }) + } +}) + + +export const getServerInfo = async (ip: string, port?: number, preferredVersion = getVersionAutoSelect(), ping = false) => { + await downloadAllMinecraftData() + const isWebSocket = ip.startsWith('ws://') || ip.startsWith('wss://') + let stream + if (isWebSocket) { + stream = (await getWebsocketStream(ip)).mineflayerStream + } + return pingServerVersion(ip, port, { + ...(stream ? { stream } : {}), + ...(ping ? { noPongTimeout: 3000 } : {}), + ...(preferredVersion ? { version: preferredVersion } : {}), + }) +} diff --git a/src/mineflayer/minecraft-protocol-extra.ts b/src/mineflayer/minecraft-protocol-extra.ts new file mode 100644 index 00000000..8cc26078 --- /dev/null +++ b/src/mineflayer/minecraft-protocol-extra.ts @@ -0,0 +1,105 @@ +import EventEmitter from 'events' +import clientAutoVersion from 'minecraft-protocol/src/client/autoVersion' + +export const pingServerVersion = async (ip: string, port?: number, mergeOptions: Record = {}) => { + const fakeClient = new EventEmitter() as any + fakeClient.on('error', (err) => { + throw new Error(err.message ?? err) + }) + const options = { + host: ip, + port, + noPongTimeout: Infinity, // disable timeout + ...mergeOptions, + } + let latency = 0 + let fullInfo = null + fakeClient.autoVersionHooks = [(res) => { + latency = res.latency + fullInfo = res + }] + + // TODO! use client.socket.destroy() instead of client.end() for faster cleanup + await clientAutoVersion(fakeClient, options) + + await new Promise((resolve, reject) => { + fakeClient.once('connect_allowed', resolve) + }) + return { + version: fakeClient.version, + latency, + fullInfo, + } +} + +const MAX_PACKET_SIZE = 2_097_152 // 2mb +const CHAT_MAX_PACKET_DEPTH = 30 + +const CHAT_VALIDATE_PACKETS = new Set([ + 'chat', + 'system_chat', + 'player_chat', + 'profileless_chat', + 'kick_disconnect', + 'resource_pack_send', + 'action_bar', + 'set_title_text', + 'set_title_subtitle', + 'title', + 'death_combat_event', + 'server_data', + 'scoreboard_objective', + 'scoreboard_team', + 'playerlist_header', + 'boss_bar' +]) + +export const validatePacket = (name: string, data: any, fullBuffer: Buffer, isFromServer: boolean) => { + // todo find out why chat is so slow with react + if (!isFromServer) return + + if (fullBuffer.length > MAX_PACKET_SIZE) { + console.groupCollapsed(`Packet ${name} is too large: ${fullBuffer.length} bytes`) + console.log(data) + console.groupEnd() + throw new Error(`Packet ${name} is too large: ${fullBuffer.length} bytes`) + } + + if (CHAT_VALIDATE_PACKETS.has(name)) { + // todo count total number of objects instead of max depth + const maxDepth = getObjectMaxDepth(data) + if (maxDepth > CHAT_MAX_PACKET_DEPTH) { + console.groupCollapsed(`Packet ${name} have too many nested objects: ${maxDepth}`) + console.log(data) + console.groupEnd() + throw new Error(`Packet ${name} have too many nested objects: ${maxDepth}`) + } + } +} + +function getObjectMaxDepth (obj: unknown, currentDepth = 0): number { + // Base case: null or primitive types have depth 0 + if (obj === null || typeof obj !== 'object' || obj instanceof Buffer) { + return currentDepth + } + + // Handle arrays and objects + let maxDepth = currentDepth + + if (Array.isArray(obj)) { + // For arrays, check each element + for (const item of obj) { + const depth = getObjectMaxDepth(item, currentDepth + 1) + maxDepth = Math.max(maxDepth, depth) + } + } else { + // For objects, check each value + // eslint-disable-next-line guard-for-in + for (const key in obj) { + const depth = getObjectMaxDepth(obj[key], currentDepth + 1) + maxDepth = Math.max(maxDepth, depth) + } + } + + return maxDepth +} diff --git a/src/mineflayer/websocket-core.ts b/src/mineflayer/websocket-core.ts new file mode 100644 index 00000000..69886897 --- /dev/null +++ b/src/mineflayer/websocket-core.ts @@ -0,0 +1,53 @@ +import { Duplex } from 'stream' + +class CustomDuplex extends Duplex { + constructor (options, public writeAction) { + super(options) + } + + override _read () {} + + override _write (chunk, encoding, callback) { + this.writeAction(chunk) + callback() + } +} + +export const getWebsocketStream = async (host: string) => { + const baseProtocol = location.protocol === 'https:' ? 'wss' : host.startsWith('ws://') ? 'ws' : 'wss' + const hostClean = host.replace('ws://', '').replace('wss://', '') + const ws = new WebSocket(`${baseProtocol}://${hostClean}`) + const clientDuplex = new CustomDuplex(undefined, data => { + ws.send(data) + }) + + ws.addEventListener('message', async message => { + let { data } = message + if (data instanceof Blob) { + data = await data.arrayBuffer() + } + clientDuplex.push(Buffer.from(data)) + }) + + ws.addEventListener('close', () => { + console.log('ws closed') + clientDuplex.end() + }) + + ws.addEventListener('error', err => { + console.log('ws error', err) + }) + + await new Promise((resolve, reject) => { + ws.addEventListener('open', resolve) + ws.addEventListener('error', err => { + console.log('ws error', err) + reject(err) + }) + }) + + return { + mineflayerStream: clientDuplex, + ws, + } +} diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index ef7fc1c6..c3ede407 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' @@ -12,6 +12,8 @@ import { openFilePicker, resetLocalStorageWithoutWorld } from './browserfs' import { completeTexturePackInstall, getResourcePackNames, resourcePackState, uninstallTexturePack } from './resourcePack' import { downloadPacketsReplay, packetsReplaceSessionState } from './packetsReplay' import { showOptionsModal } from './react/SelectOption' +import supportedVersions from './supportedVersions.mjs' +import { getVersionAutoSelect } from './connect' export const guiOptionsScheme: { [t in OptionsGroupType]: Array<{ [K in keyof AppOptions]?: Partial> } & { custom? }> @@ -157,7 +159,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 892360ce..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' @@ -21,6 +21,7 @@ export default () => { useEffect(() => { bot.addListener('message', (jsonMsg, position) => { + if (position === 'game_info') return // ignore action bar messages, they are handled by the TitleProvider const parts = formatMessage(jsonMsg) setMessages(m => { @@ -53,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..9aa15025 --- /dev/null +++ b/src/react/GameInteractionOverlay.tsx @@ -0,0 +1,198 @@ +import { useRef } from 'react' +import { subscribe, 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 { pointerLock } from '../utils' +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 +} + +subscribe(activeModalStack, () => { + if (activeModalStack.length === 0) { + if (isGameActive(false)) { + void pointerLock.requestPointerLock() + } + } else { + document.exitPointerLock?.() + } +}) 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..916ec0af 100644 --- a/src/react/ServersListProvider.tsx +++ b/src/react/ServersListProvider.tsx @@ -1,9 +1,13 @@ import { useEffect, useMemo, useState } from 'react' import { useUtilsEffect } from '@zardoy/react-util' import { useSnapshot } from 'valtio' -import { ConnectOptions } from '../connect' +import { ConnectOptions, downloadAllMinecraftData, getVersionAutoSelect } 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 { pingServerVersion } from '../mineflayer/minecraft-protocol-extra' +import { getServerInfo } from '../mineflayer/mc-protocol' import ServersList from './ServersList' import AddServerOrConnect, { BaseServerInfo } from './AddServerOrConnect' import { useDidUpdateEffect } from './utils' @@ -11,42 +15,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 +73,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 +118,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 +179,70 @@ 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 isWebSocket = server.ip.startsWith('ws://') || server.ip.startsWith('wss://') + let data + if (isWebSocket) { + const pingResult = await getServerInfo(server.ip, undefined, undefined, true) + data = { + formattedText: `${pingResult.version} server with a direct websocket connection`, + textNameRight: `ws ${pingResult.latency}ms`, + offline: false + } + } else { + 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 +411,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..5effc269 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,19 @@ const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus,
{title}
-
{worldNameRight}
+
+ {offline ? ( + + + Offline + + ) : worldNameRight?.startsWith('ws') ? ( + + + {worldNameRight.slice(3)} + + ) : 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/Title.stories.tsx b/src/react/Title.stories.tsx index 0bfedae4..5e30d7a2 100644 --- a/src/react/Title.stories.tsx +++ b/src/react/Title.stories.tsx @@ -23,9 +23,9 @@ export const Primary: Story = { text: 'Action bar text' }, transitionTimes: { - fadeIn: 2500, - stay: 17_500, - fadeOut: 5000 + fadeIn: 500, + stay: 3500, + fadeOut: 1000 } } } diff --git a/src/react/Title.tsx b/src/react/Title.tsx index d8971915..fd53e1c0 100644 --- a/src/react/Title.tsx +++ b/src/react/Title.tsx @@ -29,7 +29,8 @@ const Title = ({ const [mounted, setMounted] = useState(false) const [useEnterTransition, setUseEnterTransition] = useState(true) - const defaultDuration = 500 + const defaultFadeIn = 500 + const defaultFadeOut = 1000 const startStyle = { opacity: 1, transition: `${transitionTimes.fadeIn}ms ease-in-out all` } @@ -54,10 +55,10 @@ const Title = ({
= { 'text': '' } -const defaultTimings: AnimationTimes = { fadeIn: 400, stay: 3800, fadeOut: 800 } +const defaultTimings: AnimationTimes = { fadeIn: 500, stay: 3500, fadeOut: 1000 } const ticksToMs = (ticks: AnimationTimes) => { ticks.fadeIn *= 50 @@ -14,6 +16,20 @@ const ticksToMs = (ticks: AnimationTimes) => { return ticks } +const getComponent = (input: string | any) => { + if (typeof input === 'string') { + // raw json is sent + return mojangson.simplify(mojangson.parse(input)) + } else if (input.type === 'string') { + // this is used for simple chat components without any special properties + return { 'text': input.value } + } else if (input.type === 'compound') { + // this is used for complex chat components with special properties + return nbt.simplify(input) + } + return input +} + export default () => { const [title, setTitle] = useState>(defaultText) const [subtitle, setSubtitle] = useState>(defaultText) @@ -25,14 +41,14 @@ export default () => { useMemo(() => { // todo move to mineflayer bot._client.on('set_title_text', (packet) => { - setTitle(JSON.parse(packet.text)) + setTitle(getComponent(packet.text)) setOpenTitle(true) }) bot._client.on('set_title_subtitle', (packet) => { - setSubtitle(JSON.parse(packet.text)) + setSubtitle(getComponent(packet.text)) }) bot._client.on('action_bar', (packet) => { - setActionBar(JSON.parse(packet.text)) + setActionBar(getComponent(packet.text)) setOpenActionBar(true) }) bot._client.on('set_title_time', (packet) => { @@ -51,6 +67,7 @@ export default () => { bot.on('actionBar', (packet) => { + setAnimTimes({ fadeIn: 0, stay: 2000, fadeOut: 1000 }) setActionBar(packet) setOpenActionBar(true) }) 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 b432c58d..aa2f8fe4 100644 --- a/src/reactUi.tsx +++ b/src/reactUi.tsx @@ -46,6 +46,7 @@ import BookProvider from './react/BookProvider' import { options } from './optionsStorage' import BossBarOverlayProvider from './react/BossBarOverlayProvider' import DebugEdges from './react/DebugEdges' +import GameInteractionOverlay from './react/GameInteractionOverlay' const RobustPortal = ({ children, to }) => { return createPortal({children}, to) @@ -116,6 +117,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..7adcdb99 100644 --- a/src/resourcePack.ts +++ b/src/resourcePack.ts @@ -4,18 +4,20 @@ 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({ resourcePackInstalled: false, + isServerDownloading: false, + isServerInstalling: false }) const getLoadedImage = async (url: string) => { @@ -32,7 +34,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 +115,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 +131,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 +159,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 +200,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 +284,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') @@ -336,14 +338,27 @@ const prepareBlockstatesAndModels = async () => { } const downloadAndUseResourcePack = async (url: string): Promise => { - console.log('Downloading server resource pack', url) - const response = await fetch(url) - const resourcePackData = await response.arrayBuffer() - showNotification('Installing resource pack...') - installTexturePack(resourcePackData, undefined, undefined, true).catch((err) => { - console.error(err) - showNotification('Failed to install resource pack: ' + err.message) - }) + try { + resourcePackState.isServerInstalling = true + resourcePackState.isServerDownloading = true + console.log('Downloading server resource pack', url) + const response = await fetch(url).catch((err) => { + console.log(`Ensure server on ${url} support CORS which is not required for regular client, but is required for the web client`) + console.error(err) + showNotification('Failed to download resource pack: ' + err.message) + }) + if (!response) return + resourcePackState.isServerDownloading = false + const resourcePackData = await response.arrayBuffer() + showNotification('Installing resource pack...') + await installTexturePack(resourcePackData, undefined, undefined, true).catch((err) => { + console.error(err) + showNotification('Failed to install resource pack: ' + err.message) + }) + } finally { + resourcePackState.isServerInstalling = false + resourcePackState.isServerDownloading = false + } } const waitForGameEvent = async () => { @@ -361,6 +376,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 +413,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 +478,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/shims/minecraftData.ts b/src/shims/minecraftData.ts index 6edb5f48..09124771 100644 --- a/src/shims/minecraftData.ts +++ b/src/shims/minecraftData.ts @@ -1,6 +1,6 @@ import { versionToNumber } from 'prismarine-viewer/viewer/prepare/utils' import JsonOptimizer from '../optimizeJson' -import minecraftInitialDataJson from '../../generated/minecraft-initial-data.json' +// import minecraftInitialDataJson from '../../generated/minecraft-initial-data.json' import { toMajorVersion } from '../utils' const customResolver = () => { @@ -30,9 +30,9 @@ const cacheTtl = 30 * 1000 const cache = new Map() const cacheTime = new Map() const possiblyGetFromCache = (version: string) => { - if (minecraftInitialDataJson[version] && !optimizedDataResolver.resolvedData) { - return minecraftInitialDataJson[version] - } + // if (minecraftInitialDataJson[version] && !optimizedDataResolver.resolvedData) { + // return minecraftInitialDataJson[version] + // } if (cache.has(version)) { return cache.get(version) } diff --git a/src/shims/patchShims.ts b/src/shims/patchShims.ts new file mode 100644 index 00000000..1890edf6 --- /dev/null +++ b/src/shims/patchShims.ts @@ -0,0 +1,10 @@ +import { EventEmitter } from 'events' + +const oldEmit = EventEmitter.prototype.emit +EventEmitter.prototype.emit = function (...args) { + if (args[0] === 'error' && !this._events.error) { + console.log('Unhandled error event', args.slice(1)) + args[1] = { message: String(args[1]) } + } + return oldEmit.apply(this, args) +} 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)) {