This commit is contained in:
Vitaly 2025-02-05 04:42:11 +03:00 committed by GitHub
commit 169bde30b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 2584 additions and 1063 deletions

View file

@ -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
# - 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

View file

@ -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"]

60
TECH.md
View file

@ -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) |

View file

@ -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!"
}
]
}

View file

@ -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> | void
}
export class Server extends EventEmitter {

View file

@ -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> | void
}
export class Server extends EventEmitter {

30
pnpm-lock.yaml generated
View file

@ -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)

View file

@ -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<number, string>
itemFrameMaps = {} as Record<number, Array<THREE.Mesh<THREE.PlaneGeometry, THREE.MeshLambertMaterial>>>
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

View file

@ -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<string, {x,y,z}>}} */overrides = {}) {
constructor(version, type, worldRenderer, /** @type {{textures?, rotation?: Record<string, {x,y,z}>}} */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)

View file

@ -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,

View file

@ -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

View file

@ -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) {

View file

@ -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())

View file

@ -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')
}

View file

@ -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')

View file

@ -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<string, { removed: string[],
const soundsPathVersionsRemap = {}
const downloadAllSounds = async () => {
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()
}

109
scripts/uploadSoundFiles.ts Normal file
View file

@ -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<string | null> {
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);
});

67
scripts/uploadSounds.ts Normal file
View file

@ -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<string | null> {
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);
});

View file

@ -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')))
}

54
src/api/mcStatusApi.ts Normal file
View file

@ -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 }>
}

78
src/appParams.ts Normal file
View file

@ -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<AppQsParams>({} 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())

View file

@ -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<string, any> = {}
// 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)
})

View file

@ -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)

View file

@ -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()])
}

View file

@ -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<typeof contro['_commandsRaw']>['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<typeof setInterval> | 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?.()

View file

@ -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

View file

@ -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<string, Modal[]> = {}
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)

View file

@ -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()

View file

@ -1,4 +1,6 @@
// workaround for mineflayer
globalThis.window ??= globalThis
globalThis.localStorage ??= {}
process.versions.node = '18.0.0'
if (!navigator.getGamepads) {

View file

@ -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<Record<string, string>>((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<void>(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<string, any>
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 })
}

View file

@ -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

View file

@ -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)
})
})
})

View file

@ -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 } : {}),
})
}

View file

@ -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<string, any> = {}) => {
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<void>((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
}

View file

@ -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,
}
}

View file

@ -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<OptionMeta<AppOptions[K]>> } & { 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 <Button
label={`Resource Pack: ${usingServerResourcePack ? 'SERVER ON' : resourcePackInstalled ? enabledResourcepack ? 'ON' : 'OFF' : 'NO'}`} inScreen onClick={async () => {
@ -233,6 +235,19 @@ export const guiOptionsScheme: {
chatSelect: {
},
},
{
custom () {
return <Category>World</Category>
},
highlightBlockColor: {
text: 'Block Highlight Color',
values: [
['auto', 'Auto'],
['blue', 'Blue'],
['classic', 'Classic']
],
},
},
{
custom () {
return <Category>Sign Editor</Category>
@ -342,33 +357,38 @@ export const guiOptionsScheme: {
touchButtonsSize: {
min: 40,
disableIf: [
'touchControlsType',
'joystick-buttons'
'touchMovementType',
'modern'
],
},
touchButtonsOpacity: {
min: 10,
max: 90,
disableIf: [
'touchControlsType',
'joystick-buttons'
'touchMovementType',
'modern'
],
},
touchButtonsPosition: {
max: 80,
disableIf: [
'touchControlsType',
'joystick-buttons'
'touchMovementType',
'modern'
],
},
touchControlsType: {
values: [['classic', 'Classic'], ['joystick-buttons', 'New']],
touchMovementType: {
text: 'Movement Controls',
values: [['modern', 'Modern'], ['classic', 'Classic']],
},
touchInteractionType: {
text: 'Interaction Controls',
values: [['classic', 'Classic'], ['buttons', 'Buttons']],
},
},
{
custom () {
const { touchControlsType } = useSnapshot(options)
return <Button label='Setup Touch Buttons' onClick={() => showModal({ reactType: 'touch-buttons-setup' })} inScreen disabled={touchControlsType !== 'joystick-buttons'} />
const { touchInteractionType, touchMovementType } = useSnapshot(options)
return <Button label='Setup Touch Buttons' onClick={() => showModal({ reactType: 'touch-buttons-setup' })} inScreen disabled={touchInteractionType === 'classic' && touchMovementType === 'classic'} />
},
},
{
@ -462,6 +482,34 @@ export const guiOptionsScheme: {
],
},
},
{
custom () {
const { serversAutoVersionSelect } = useSnapshot(options)
const allVersions = [...supportedVersions, 'latest', 'auto']
const currentIndex = allVersions.indexOf(serversAutoVersionSelect)
const getDisplayValue = (version: string) => {
const versionAutoSelect = getVersionAutoSelect(version)
if (version === 'latest') return `latest (${versionAutoSelect})`
if (version === 'auto') return `auto (${versionAutoSelect})`
return version
}
return <div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Slider
style={{ width: 150 }}
label='Server Version'
value={currentIndex}
min={0}
max={allVersions.length - 1}
valueDisplay={getDisplayValue(serversAutoVersionSelect)}
updateValue={(newVal) => {
options.serversAutoVersionSelect = allVersions[newVal]
}}
/>
</div>
},
},
],
}
export type OptionsGroupType = 'main' | 'render' | 'interface' | 'controls' | 'sound' | 'advanced' | 'VR'

View file

@ -4,6 +4,7 @@ import { proxy, subscribe } from 'valtio/vanilla'
// weird webpack configuration bug: it cant import valtio/utils in this file
import { subscribeKey } from 'valtio/utils'
import { omitObj } from '@zardoy/utils'
import { appQueryParamsArray } from './appParams'
const isDev = process.env.NODE_ENV === 'development'
const defaultOptions = {
@ -25,6 +26,7 @@ const defaultOptions = {
chatOpacityOpened: 100,
messagesLimit: 200,
volume: 50,
enableMusic: false,
// fov: 70,
fov: 75,
guiScale: 3,
@ -33,7 +35,8 @@ const defaultOptions = {
touchButtonsOpacity: 80,
touchButtonsPosition: 12,
touchControlsPositions: getDefaultTouchControlsPositions(),
touchControlsType: 'classic' as 'classic' | 'joystick-buttons',
touchMovementType: 'modern' as 'modern' | 'classic',
touchInteractionType: 'classic' as 'classic' | 'buttons',
gpuPreference: 'default' as 'default' | 'high-performance' | 'low-power',
backgroundRendering: '20fps' as 'full' | '20fps' | '5fps',
/** @unstable */
@ -50,6 +53,7 @@ const defaultOptions = {
serverResourcePacks: 'prompt' as 'prompt' | 'always' | 'never',
handDisplay: false,
packetsLoggerPreset: 'all' as 'all' | 'no-buffers',
serversAutoVersionSelect: 'auto' as 'auto' | 'latest' | '1.20.4' | string,
// antiAliasing: false,
@ -81,7 +85,6 @@ const defaultOptions = {
autoParkour: false,
vrSupport: true, // doesn't directly affect the VR mode, should only disable the button which is annoying to android users
renderDebug: (isDev ? 'advanced' : 'basic') as 'none' | 'advanced' | 'basic',
autoVersionSelect: '1.20.4',
// advanced bot options
autoRespawn: false,
@ -92,9 +95,10 @@ const defaultOptions = {
wysiwygSignEditor: 'auto' as 'auto' | 'always' | 'never',
showMinimap: 'never' as 'always' | 'singleplayer' | 'never',
minimapOptimizations: true,
displayBossBars: false, // boss bar overlay was removed for some reason, enable safely
displayBossBars: true,
disabledUiParts: [] as string[],
neighborChunkUpdates: true,
highlightBlockColor: 'auto' as 'auto' | 'blue' | 'classic',
}
function getDefaultTouchControlsPositions () {
@ -118,7 +122,8 @@ function getDefaultTouchControlsPositions () {
} as Record<string, [number, number]>
}
const qsOptionsRaw = new URLSearchParams(location.search).getAll('setting')
// const qsOptionsRaw = new URLSearchParams(location.search).getAll('setting')
const qsOptionsRaw = appQueryParamsArray.setting ?? []
export const qsOptions = Object.fromEntries(qsOptionsRaw.map(o => {
const [key, value] = o.split(':')
return [key, JSON.parse(value)]
@ -135,6 +140,9 @@ const migrateOptions = (options: Partial<AppOptions & Record<string, any>>) => {
if (options.touchControlsPositions?.jump === undefined) {
options.touchControlsPositions!.jump = defaultOptions.touchControlsPositions.jump
}
if (options.touchControlsType === 'joystick-buttons') {
options.touchInteractionType = 'buttons'
}
return options
}

View file

@ -1,9 +1,11 @@
import React, { useEffect } from 'react'
import { appQueryParams } from '../appParams'
import { fetchServerStatus, isServerValid } from '../api/mcStatusApi'
import Screen from './Screen'
import Input from './Input'
import Button from './Button'
import SelectGameVersion from './SelectGameVersion'
import { useIsSmallWidth } from './simpleHooks'
import { useIsSmallWidth, usePassesWindowDimensions } from './simpleHooks'
export interface BaseServerInfo {
ip: string
@ -32,13 +34,13 @@ interface Props {
const ELEMENTS_WIDTH = 190
export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQs, onQsConnect, placeholders, accounts, versions, allowAutoConnect }: Props) => {
const qsParams = parseQs ? new URLSearchParams(window.location.search) : undefined
const qsParamName = qsParams?.get('name')
const qsParamIp = qsParams?.get('ip')
const qsParamVersion = qsParams?.get('version')
const qsParamProxy = qsParams?.get('proxy')
const qsParamUsername = qsParams?.get('username')
const qsParamLockConnect = qsParams?.get('lockConnect')
const isSmallHeight = !usePassesWindowDimensions(null, 350)
const qsParamName = parseQs ? appQueryParams.name : undefined
const qsParamIp = parseQs ? appQueryParams.ip : undefined
const qsParamVersion = parseQs ? appQueryParams.version : undefined
const qsParamProxy = parseQs ? appQueryParams.proxy : undefined
const qsParamUsername = parseQs ? appQueryParams.username : undefined
const qsParamLockConnect = parseQs ? appQueryParams.lockConnect : undefined
const qsIpParts = qsParamIp?.split(':')
const ipParts = initialData?.ip ? initialData?.ip.split(':') : undefined
@ -70,8 +72,55 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
authenticatedAccountOverride,
}
const [fetchedServerInfoIp, setFetchedServerInfoIp] = React.useState<string | undefined>(undefined)
const [serverOnline, setServerOnline] = React.useState(null as boolean | null)
const [onlinePlayersList, setOnlinePlayersList] = React.useState<string[]>([])
useEffect(() => {
if (qsParams?.get('autoConnect') === 'true' && qsParams?.get('ip') && allowAutoConnect) {
const controller = new AbortController()
const checkServer = async () => {
if (!qsParamIp || !isServerValid(qsParamIp)) return
try {
const status = await fetchServerStatus(qsParamIp)
if (!status) return
setServerOnline(status.raw.online)
setOnlinePlayersList(status.raw.players?.list.map(p => p.name_raw) ?? [])
setFetchedServerInfoIp(qsParamIp)
} catch (err) {
console.error('Failed to fetch server status:', err)
}
}
void checkServer()
return () => controller.abort()
}, [qsParamIp])
const validateUsername = (username: string) => {
if (!username) return undefined
if (onlinePlayersList.includes(username)) {
return { border: 'red solid 1px' }
}
const MINECRAFT_USERNAME_REGEX = /^\w{3,16}$/
if (!MINECRAFT_USERNAME_REGEX.test(username)) {
return { border: 'red solid 1px' }
}
return undefined
}
const validateServerIp = () => {
if (!serverIp) return undefined
if (serverOnline) {
return { border: 'lightgreen solid 1px' }
} else {
return { border: 'red solid 1px' }
}
}
useEffect(() => {
if (qsParamIp && qsParamVersion && allowAutoConnect) {
onQsConnect?.(commonUseOptions)
}
}, [])
@ -99,9 +148,19 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
<InputWithLabel label="Server Name" value={serverName} onChange={({ target: { value } }) => setServerName(value)} placeholder='Defaults to IP' />
</div>
</>}
<InputWithLabel required label="Server IP" value={serverIp} disabled={lockConnect && qsIpParts?.[0] !== null} onChange={({ target: { value } }) => setServerIp(value)} />
<InputWithLabel
required
label="Server IP"
value={serverIp}
disabled={lockConnect && qsIpParts?.[0] !== null}
onChange={({ target: { value } }) => {
setServerIp(value)
setServerOnline(false)
}}
validateInput={serverOnline === null || fetchedServerInfoIp !== serverIp ? undefined : validateServerIp}
/>
<InputWithLabel label="Server Port" value={serverPort} disabled={lockConnect && qsIpParts?.[1] !== null} onChange={({ target: { value } }) => setServerPort(value)} placeholder='25565' />
<div style={{ gridColumn: smallWidth ? '' : 'span 2' }}>Overrides:</div>
{isSmallHeight ? <div style={{ gridColumn: 'span 2', marginTop: 10, }} /> : <div style={{ gridColumn: smallWidth ? '' : 'span 2' }}>Overrides:</div>}
<div style={{
display: 'flex',
flexDirection: 'column',
@ -114,12 +173,25 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
setVersionOverride(value)
}}
placeholder="Optional, but recommended to specify"
disabled={lockConnect && qsParamVersion !== null}
disabled={lockConnect}
/>
</div>
<InputWithLabel label="Proxy Override" value={proxyOverride} disabled={lockConnect && qsParamProxy !== null} onChange={({ target: { value } }) => setProxyOverride(value)} placeholder={placeholders?.proxyOverride} />
<InputWithLabel label="Username Override" value={usernameOverride} disabled={!noAccountSelected || lockConnect && qsParamUsername !== null} onChange={({ target: { value } }) => setUsernameOverride(value)} placeholder={placeholders?.usernameOverride} />
<InputWithLabel
label="Proxy Override"
value={proxyOverride}
disabled={lockConnect && (qsParamProxy !== null || !!placeholders?.proxyOverride) || serverIp.startsWith('ws://') || serverIp.startsWith('wss://')}
onChange={({ target: { value } }) => setProxyOverride(value)}
placeholder={serverIp.startsWith('ws://') || serverIp.startsWith('wss://') ? 'Not needed for websocket servers' : placeholders?.proxyOverride}
/>
<InputWithLabel
label="Username Override"
value={usernameOverride}
disabled={!noAccountSelected || (lockConnect && qsParamUsername !== null)}
onChange={({ target: { value } }) => setUsernameOverride(value)}
placeholder={placeholders?.usernameOverride}
validateInput={!serverOnline || fetchedServerInfoIp !== serverIp ? undefined : validateUsername}
/>
<label style={{
display: 'flex',
flexDirection: 'column',
@ -135,6 +207,7 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
fontSize: 13,
}}
defaultValue={initialAccount === true ? -2 : initialAccount === undefined ? -1 : (fallbackIfNotFound((accounts ?? []).indexOf(initialAccount)) ?? -2)}
disabled={lockConnect && qsParamUsername !== null}
>
<option value={-1}>Offline Account (Username)</option>
{accounts?.map((account, i) => <option key={i} value={i}>{account} (Logged In)</option>)}

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { appQueryParams } from '../appParams'
import styles from './appStatus.module.css'
import Button from './Button'
import Screen from './Screen'
@ -15,6 +16,7 @@ export default ({
children
}) => {
const [loadingDotIndex, setLoadingDotIndex] = useState(0)
const lockConnect = appQueryParams.lockConnect === 'true'
useEffect(() => {
const statusRunner = async () => {
@ -65,7 +67,7 @@ export default ({
>
{isError && (
<>
{backAction && <Button label="Back" onClick={backAction} />}
{!lockConnect && backAction && <Button label="Back" onClick={backAction} />}
{actionsSlot}
<Button onClick={() => window.location.reload()} label="Reset App (recommended)" />
</>

View file

@ -4,7 +4,7 @@ import { activeModalStack, activeModalStacks, hideModal, insertActiveModalStack,
import { resetLocalStorageWorld } from '../browserfs'
import { fsState } from '../loadSave'
import { guessProblem } from '../errorLoadingScreenHelpers'
import { ConnectOptions } from '../connect'
import type { ConnectOptions } from '../connect'
import { downloadPacketsReplay, packetsReplaceSessionState, replayLogger } from '../packetsReplay'
import { getProxyDetails } from '../microsoftAuthflow'
import AppStatus from './AppStatus'

View file

@ -1,11 +1,17 @@
.bossBars {
display: flex;
flex-direction: column;
gap: 5px;
gap: 3px;
position: absolute;
top: 9px;
width: 100%;
top: 1px;
left: 50%;
transform: translate(-50%);
pointer-events: none;
}
.bossBars.mobile {
top: 18px;
}
.bossbar-container {
@ -14,8 +20,12 @@
align-items: center;
}
.bossbar-title {
font-size: 7px;
font-size: 10px;
text-align: center;
color: #fff;
margin-bottom: -1px;
white-space: nowrap;
overflow: hidden;
}
.bossbar {
background-image: var(--bars-gui-atlas);

View file

@ -5,29 +5,25 @@ import './BossBarOverlay.css'
const colors = ['pink', 'blue', 'red', 'green', 'yellow', 'purple', 'white']
const divs = [0, 6, 10, 12, 20]
const translations = {
'entity.minecraft.ender_dragon': 'Ender Dragon',
'entity.minecraft.wither': 'Wither'
}
export type BossBarType = BossBarTypeRaw & {
// todo why not use public properties?
title: { text: string, translate: string },
_title: { text: string, translate: string },
title: string | Record<string, any> | null,
_title: string | Record<string, any> | null,
_color: string,
_dividers: number,
_health: number
}
export default ({ bar }: { bar: BossBarType }) => {
const [title, setTitle] = useState('')
const [title, setTitle] = useState({})
const [bossBarStyles, setBossBarStyles] = useState<{ [key: string]: string | number }>({})
const [fillStyles, setFillStyles] = useState<{ [key: string]: string | number }>({})
const [div1Styles, setDiv1Styles] = useState<{ [key: string]: string | number }>({})
const [div2Styles, setDiv2Styles] = useState<{ [key: string]: string | number }>({})
useEffect(() => {
setTitle(bar._title.text ? bar.title.text : translations[bar.title.translate] || 'Unknown Entity')
setTitle(bar._title ?? bar.title)
setBossBarStyles(prevStyles => ({
...prevStyles,
backgroundPositionY: `-${colors.indexOf(bar._color) * 10}px`

View file

@ -1,27 +1,40 @@
import { useState, useEffect } from 'react'
import BossBar, { BossBarType } from './BossBarOverlay'
import { useSnapshot } from 'valtio'
import { miscUiState } from '../globalState'
import './BossBarOverlay.css'
import BossBar, { BossBarType } from './BossBarOverlay'
export default () => {
const { currentTouch } = useSnapshot(miscUiState)
const [bossBars, setBossBars] = useState(new Map<string, BossBarType>())
const addBossBar = (bossBar: BossBarType) => {
setBossBars(prevBossBars => new Map(prevBossBars.set(bossBar.entityUUID, bossBar)))
}
const removeBossBar = (bossBar: BossBarType) => {
setBossBars(prevBossBars => {
const newBossBars = new Map(prevBossBars)
newBossBars.delete(bossBar.entityUUID)
return newBossBars
})
}
useEffect(() => {
bot.on('bossBarCreated', (bossBar) => {
setBossBars(prevBossBars => new Map(prevBossBars.set(bossBar.entityUUID, bossBar as any)))
addBossBar(bossBar as BossBarType)
})
bot.on('bossBarUpdated', (bossBar) => {
setBossBars(prevBossBars => new Map(prevBossBars.set(bossBar.entityUUID, bossBar as BossBarType)))
removeBossBar(bossBar as BossBarType)
setTimeout(() => addBossBar(bossBar as BossBarType), 1)
})
bot.on('bossBarDeleted', (bossBar) => {
const newBossBars = new Map(bossBars)
newBossBars.delete(bossBar.entityUUID)
setBossBars(newBossBars)
removeBossBar(bossBar as BossBarType)
})
}, [])
return (
<div className="bossBars" id="bossBars">
<div className={`bossBars ${currentTouch ? 'mobile' : ''}`} id="bossBars">
{[...bossBars.values()].map(bar => (
<BossBar key={bar.entityUUID} bar={bar} />
))}

View file

@ -13,6 +13,7 @@ interface Props extends React.ComponentProps<'button'> {
children?: React.ReactNode
inScreen?: boolean
rootRef?: Ref<HTMLButtonElement>
overlayColor?: string
}
const ButtonContext = createContext({
@ -23,7 +24,7 @@ export const ButtonProvider: FC<{ children, onClick }> = ({ children, onClick })
return <ButtonContext.Provider value={{ onClick }}>{children}</ButtonContext.Provider>
}
export default (({ label, icon, children, inScreen, rootRef, type = 'button', postLabel, ...args }) => {
export default (({ label, icon, children, inScreen, rootRef, type = 'button', postLabel, overlayColor, ...args }) => {
const ctx = useContext(ButtonContext)
const onClick = (e) => {
@ -45,6 +46,13 @@ export default (({ label, icon, children, inScreen, rootRef, type = 'button', po
{label}
{postLabel}
{children}
{overlayColor && <div style={{
position: 'absolute',
inset: 0,
backgroundColor: overlayColor,
opacity: 0.5,
pointerEvents: 'none'
}} />}
</button>
</SharedHudVars>
}) satisfies FC<Props>

View file

@ -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()

View file

@ -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<HTMLDivElement>(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 (
<OverlayElement divRef={overlayRef} zIndex={zIndex} />
)
}
const OverlayElement = ({ divRef, zIndex }: { divRef: React.RefObject<HTMLDivElement>, zIndex: number }) => {
return <div
className='game-interaction-overlay'
ref={divRef}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex,
touchAction: 'none',
userSelect: 'none'
}}
/>
}
export default function GameInteractionOverlay ({ zIndex }: { zIndex: number }) {
const modalStack = useSnapshot(activeModalStack)
const { currentTouch } = useSnapshot(miscUiState)
if (modalStack.length > 0 || !currentTouch) return null
return <GameInteractionOverlayInner zIndex={zIndex} />
}
subscribe(activeModalStack, () => {
if (activeModalStack.length === 0) {
if (isGameActive(false)) {
void pointerLock.requestPointerLock()
}
} else {
document.exitPointerLock?.()
}
})

View file

@ -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()
})

View file

@ -1,6 +1,6 @@
.effectsScreen-container {
position: fixed;
top: 6%;
top: max(6%, 30px);
left: 0px;
z-index: -2;
pointer-events: none;

View file

@ -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 <div id='input-container' className={styles.container} style={rootStyles}>
<input
ref={ref}
@ -41,7 +45,6 @@ export default ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue,
{...inputProps}
value={value}
onChange={(e) => {
setValidationStyle(validateInput?.(e.target.value) ?? {})
setValue(e.target.value)
inputProps.onChange?.(e)
}}

View file

@ -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<MapUpdates> 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')
}

View file

@ -6,7 +6,7 @@
left: 50%;
transform: translate(-50%);
gap: 0 5px;
z-index: -1;
z-index: var(--has-modals-z, 7);
}
.pause-btn,

View file

@ -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()

View file

@ -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<T extends string | number | boolean> = {
id?: string
@ -188,10 +189,18 @@ interface Props {
}
export default ({ items, title, backButtonAction }: Props) => {
const { currentTouch } = useSnapshot(miscUiState)
return <Screen
title={title}
>
<div className='screen-items'>
{currentTouch && (
<div style={{ position: 'fixed', marginLeft: '-30px', display: 'flex', flexDirection: 'column', gap: 1, }}>
<Button icon={pixelartIcons['close']} onClick={hideAllModals} style={{ color: '#ff5d5d', }} />
<Button icon={pixelartIcons['chevron-left']} onClick={backButtonAction} style={{ color: 'yellow', }} />
</div>
)}
{items.map((element, i) => {
// make sure its unique!
return <RenderOption key={element.id ?? `${title}-${i}`} item={element} />

View file

@ -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)

View file

@ -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<Players>({})
const [isOpen, setIsOpen] = useState(false)

View file

@ -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<string, any>
autoLogin?: Record<string, string>
}
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<readonly string[]>(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<Promise<void>>()
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<void>(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={{

View file

@ -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<HTMLDivElement>
offline?: boolean
}
const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction, iconSrc, formattedTextOverride, worldNameRight, elemRef }: WorldProps & { ref?: React.Ref<HTMLDivElement> }) => {
const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction, iconSrc, formattedTextOverride, worldNameRight, elemRef, offline }: WorldProps & { ref?: React.Ref<HTMLDivElement> }) => {
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,
<div className={styles.world_info}>
<div className={styles.world_title}>
<div>{title}</div>
<div className={styles.world_title_right}>{worldNameRight}</div>
<div className={styles.world_title_right}>
{offline ? (
<span style={{ color: 'red', display: 'flex', alignItems: 'center', gap: 4 }}>
<PixelartIcon iconName="signal-off" width={12} />
Offline
</span>
) : worldNameRight?.startsWith('ws') ? (
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<PixelartIcon iconName="cellular-signal-3" width={12} />
{worldNameRight.slice(3)}
</span>
) : worldNameRight}
</div>
</div>
{formattedTextOverride ? <div className={styles.world_info_formatted}>
<MessageFormattedString message={formattedTextOverride} />

View file

@ -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'

View file

@ -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
}
}
}

View file

@ -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 = ({
<div className='title-container'>
<Transition
in={openTitle}
timeout={transitionTimes ? {
enter: transitionTimes.fadeIn,
exit: transitionTimes.fadeOut,
} : defaultDuration}
timeout={{
enter: transitionTimes?.fadeIn ?? defaultFadeIn,
exit: transitionTimes?.fadeOut ?? defaultFadeOut,
}}
mountOnEnter
unmountOnExit
enter={useEnterTransition}
@ -83,10 +84,10 @@ const Title = ({
</Transition>
<Transition
in={openActionBar}
timeout={transitionTimes ? {
enter: transitionTimes.fadeIn,
exit: transitionTimes.fadeOut,
} : defaultDuration}
timeout={{
enter: transitionTimes?.fadeIn ?? defaultFadeIn,
exit: transitionTimes?.fadeOut ?? defaultFadeOut,
}}
mountOnEnter
unmountOnExit
// enter={useEnterTransition}

View file

@ -1,11 +1,13 @@
import { useEffect, useMemo, useState } from 'react'
import mojangson from 'mojangson'
import nbt from 'prismarine-nbt'
import type { ClientOnMap } from '../generatedServerPackets'
import Title from './Title'
import type { AnimationTimes } from './Title'
const defaultText: Record<string, any> = { '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<string | Record<string, any>>(defaultText)
const [subtitle, setSubtitle] = useState<string | Record<string, any>>(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)
})

View file

@ -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<ButtonName, [number, number]>
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<HTMLDivElement>(null)
const joystickInner = useRef<HTMLDivElement>(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 <div>
<div
className='movement_joystick_outer'
ref={joystickOuter}
style={{
display: pointer ? 'flex' : 'none',
borderRadius: '50%',
width: 50,
height: 50,
border: '2px solid rgba(0, 0, 0, 0.5)',
backgroundColor: 'rgba(255, 255, div, 0.5)',
position: 'fixed',
justifyContent: 'center',
alignItems: 'center',
translate: '-50% -50%',
...pointer ? {
left: `${pointer.x / window.innerWidth * 100}%`,
top: `${pointer.y / window.innerHeight * 100}%`
} : {}
}}
>
{touchMovementType === 'modern' && (
<div
className='movement_joystick_inner'
className='movement_joystick_outer'
ref={joystickOuter}
style={{
display: pointer ? 'flex' : 'none',
borderRadius: '50%',
width: 20,
height: 20,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
position: 'absolute',
width: 50,
height: 50,
border: '2px solid rgba(0, 0, 0, 0.5)',
backgroundColor: 'rgba(255, 255, div, 0.5)',
position: 'fixed',
justifyContent: 'center',
alignItems: 'center',
translate: '-50% -50%',
...pointer ? {
left: `${pointer.x / window.innerWidth * 100}%`,
top: `${pointer.y / window.innerHeight * 100}%`
} : {}
}}
ref={joystickInner}
/>
</div>
<div {...buttonProps('action')}>
<PixelartIcon iconName='circle' />
</div>
<div {...buttonProps('sneak')}>
<PixelartIcon iconName='arrow-down' />
</div>
<div {...buttonProps('jump')}>
<PixelartIcon iconName='arrow-up' />
</div>
<div {...buttonProps('break')}>
<MineIcon />
</div>
>
<div
className='movement_joystick_inner'
style={{
borderRadius: '50%',
width: 20,
height: 20,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
position: 'absolute',
}}
ref={joystickInner}
/>
</div>
)}
{touchMovementType === 'modern' && (
<>
<div {...buttonProps('sneak')}>
<PixelartIcon iconName='arrow-down' />
</div>
<div {...buttonProps('jump')}>
<PixelartIcon iconName='arrow-up' />
</div>
</>
)}
{touchInteractionType === 'buttons' && (
<>
<div {...buttonProps('action')}>
<PixelartIcon iconName='circle' />
</div>
<div {...buttonProps('break')}>
<MineIcon />
</div>
</>
)}
{setupActive && <div style={{
position: 'fixed',
bottom: 0,

View file

@ -8,12 +8,10 @@ export default () => {
const usingTouch = useUsingTouch()
const hasModals = useSnapshot(activeModalStack).length !== 0
const setupActive = useIsModalActive('touch-buttons-setup')
const { touchControlsPositions, touchControlsType } = useSnapshot(options)
return <TouchAreasControls
touchActive={!!bot && !!usingTouch && !hasModals && touchControlsType === 'joystick-buttons'}
foregroundGameActive={!!bot && !!usingTouch && !hasModals}
setupActive={setupActive}
buttonsPositions={touchControlsPositions as any}
closeButtonsSetup={(newPositions) => {
if (newPositions) {
options.touchControlsPositions = newPositions
@ -21,5 +19,4 @@ export default () => {
hideModal()
}}
/>
}

View file

@ -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 (
<div
style={{ zIndex: modals.length ? 7 : 8 }}

View file

@ -7,6 +7,18 @@ export const useIsSmallWidth = () => {
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) => {

View file

@ -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(<PerComponentErrorBoundary>{children}</PerComponentErrorBoundary>, to)
@ -116,6 +117,7 @@ const InGameUi = () => {
<RobustPortal to={document.querySelector('#ui-root')}>
{/* apply scaling */}
<div style={{ display: showUI ? 'block' : 'none' }}>
<GameInteractionOverlay zIndex={7} />
{!disabledUiParts.includes('death-screen') && <DeathScreenProvider />}
{!disabledUiParts.includes('debug-overlay') && <DebugOverlay />}
{!disabledUiParts.includes('mobile-top-buttons') && <MobileTopButtons />}

View file

@ -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<string>()
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<void> => {
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')
}

View file

@ -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<void>(resolve => {

View file

@ -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<string, any>()
const cacheTime = new Map<string, number>()
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)
}

10
src/shims/patchShims.ts Normal file
View file

@ -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)
}

View file

@ -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<string, Record<string, string>>,
allSoundsVersionedMap?: Record<string, string[]>,
let soundMap: SoundMap | undefined
const updateResourcePack = async () => {
if (!soundMap) return
soundMap.activeResourcePackBasePath = await getActiveResourcepackBasePath() ?? undefined
}
let musicInterval: ReturnType<typeof setInterval> | null = null
subscribeKey(miscUiState, 'gameLoaded', async () => {
if (!miscUiState.gameLoaded) return
const soundsLegacyMap = window.allSoundsVersionedMap as Record<string, string[]>
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) {

33
src/sounds/musicSystem.ts Normal file
View file

@ -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()

347
src/sounds/soundsMap.ts Normal file
View file

@ -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<string, Record<string, string>>
soundsLegacyMap: Record<string, string[]>
soundsMeta: SoundMeta
}
interface BlockSoundMap {
[blockName: string]: string
}
interface SoundEntry {
file: string
weight: number
volume: number
}
export class SoundMap {
private readonly soundsPerName: Record<string, SoundEntry[]>
private readonly existingResourcePackPaths: Set<string>
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<string, Record<string, string>>,
allSoundsVersionedMap?: Record<string, string[]>,
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'
}

8
src/sounds/testSounds.ts Normal file
View file

@ -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'))

View file

@ -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)) {