Release (#256)
This commit is contained in:
commit
169bde30b2
75 changed files with 2584 additions and 1063 deletions
42
.github/workflows/ci.yml
vendored
42
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
11
Dockerfile
11
Dockerfile
|
|
@ -6,9 +6,14 @@ WORKDIR /app
|
|||
COPY . /app
|
||||
# install pnpm
|
||||
RUN npm i -g pnpm@9.0.4
|
||||
# Build arguments
|
||||
ARG DOWNLOAD_SOUNDS=false
|
||||
ARG DISABLE_SERVICE_WORKER=false
|
||||
# TODO need flat --no-root-optional
|
||||
RUN node ./scripts/dockerPrepare.mjs
|
||||
RUN pnpm i
|
||||
# Download sounds if flag is enabled
|
||||
RUN if [ "$DOWNLOAD_SOUNDS" = "true" ] ; then node scripts/downloadSoundsMap.mjs ; fi
|
||||
|
||||
# TODO for development
|
||||
# EXPOSE 9090
|
||||
|
|
@ -17,7 +22,9 @@ RUN pnpm i
|
|||
# ENTRYPOINT ["pnpm", "run", "run-all"]
|
||||
|
||||
# only for prod
|
||||
RUN GITHUB_REPOSITORY=zardoy/minecraft-web-client pnpm run build
|
||||
RUN GITHUB_REPOSITORY=zardoy/minecraft-web-client \
|
||||
DISABLE_SERVICE_WORKER=$DISABLE_SERVICE_WORKER \
|
||||
pnpm run build
|
||||
|
||||
# ---- Run Stage ----
|
||||
FROM node:18-alpine
|
||||
|
|
@ -31,5 +38,5 @@ RUN npm i -g pnpm@9.0.4
|
|||
RUN npm init -yp
|
||||
RUN pnpm i express github:zardoy/prismarinejs-net-browserify compression cors
|
||||
EXPOSE 8080
|
||||
VOLUME /app/dist
|
||||
VOLUME /app/public
|
||||
ENTRYPOINT ["node", "server.js", "--prod"]
|
||||
|
|
|
|||
60
TECH.md
60
TECH.md
|
|
@ -3,35 +3,35 @@
|
|||
This project uses proxies so you can connect to almost any vanilla server. Though proxies have some limitations such as increased latency and servers will complain about using VPN (though we have a workaround for that, but ping will be much higher).
|
||||
This client generally has better performance but some features reproduction might be inaccurate eg its less stable and more buggy in some cases.
|
||||
|
||||
| Feature | This project | Eaglercraft | Description |
|
||||
| --------------------------------- | ------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| General | | | |
|
||||
| Mobile Support (touch) | ✅(+) | ✅ | |
|
||||
| Gamepad Support | ✅ | ❌ | |
|
||||
| A11Y | ✅ | ❌ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page (but maybe it's not needed) |
|
||||
| Game Features | | | |
|
||||
| Servers Support (quality) | ❌ | ✅ | Eaglercraft is vanilla Minecraft, while this project tries to emulate original game behavior at protocol level (Mineflayer is used) |
|
||||
| Servers Support (any version, ip) | ✅ | ❌ | We support almost all Minecraft versions, only important if you connect to a server where you need new content like blocks or if you play with friends. And you can connect to almost any server using proxy servers! |
|
||||
| Singleplayer Survival Features | ❌ | ✅ | Just like Eaglercraft this project can generate and save worlds, but generator is simple and only a few survival features are supported (look here for [supported features list](https://github.com/zardoy/space-squid)) |
|
||||
| Singleplayer Maps | ✅ | ✅ | We support any version, but adventure maps won't work, but simple parkour and build maps might be interesting to explore... |
|
||||
| Singleplayer Maps World Streaming | ✅ | ❌ | Thanks to Browserfs, saves can be loaded to local singleplayer server using multiple ways: from local folder, server directory (not zip), dropbox or other cloud *backend* etc... |
|
||||
| P2P Multiplayer | ✅ | ✅ | A way to connect to other browser running the project. But it's almost useless here since many survival features are not implemented. Maybe only to build / explore maps together... |
|
||||
| Voice Chat | ❌ | ✅ | Eaglercraft has custom WebRTC voice chat implementation, though it could also be easily implemented there |
|
||||
| Online Servers | ✅ | ❌ | We have custom implementation (including integration on proxy side) for joining to servers |
|
||||
| Plugin Features | ✅ | ❌ | We have Mineflayer plugins support, like Auto Jump & Auto Parkour was added here that way |
|
||||
| Direct Connection | ❌ | ✅ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page |
|
||||
| Mods | ❌(roadmap) | ❌ | This project will support mods for singleplayer. In theory its possible to implement support for modded servers on protocol level (including all needed mods) |
|
||||
| Video Recording | ❌ | ✅ | Don't feel needed |
|
||||
| Metaverse Features | ❌(roadmap) | ❌ | Iframes, video streams inside of game world (custom protocol channel) |
|
||||
| Sounds | ✅ | ✅ | |
|
||||
| Resource Packs | ✅(--) | ✅ | This project has very limited support for them (only textures images are loadable for now) |
|
||||
| Assets Compressing & Splitting | ✅ | ❌ | We have advanced Minecraft data processing and good code chunk splitting so the web app will open much faster and use less memory |
|
||||
| Graphics | | | |
|
||||
| Fancy Graphics | ❌ | ✅ | While Eaglercraft has top-level shaders we don't even support lighting |
|
||||
| Fast & Efficient Graphics | ❌(+) | ❌ | Feels like no one needs to have 64 rendering distance work smoothly |
|
||||
| VR | ✅ | ❌ | Feels like not needed feature. UI is missing in this project since DOM can't be rendered in VR so Eaglercraft could be better in that aspect |
|
||||
| AR | ❌ | ❌ | Would be the most useless feature |
|
||||
| Minimap & Waypoints | ✅(-) | ❌ | We have buggy minimap, which can be enabled in settings and full map is opened by pressing `M` key |
|
||||
| Feature | This project | Eaglercraft | Description |
|
||||
| --------------------------------- | ----------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| General | | | |
|
||||
| Mobile Support (touch) | ✅(+) | ✅ | |
|
||||
| Gamepad Support | ✅ | ❌ | |
|
||||
| A11Y | ✅ | ❌ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page (but maybe it's not needed) |
|
||||
| Game Features | | | |
|
||||
| Servers Support (quality) | ❌ | ✅ | Eaglercraft is vanilla Minecraft, while this project tries to emulate original game behavior at protocol level (Mineflayer is used) |
|
||||
| Servers Support (any version, ip) | ✅ | ❌ | We support almost all Minecraft versions, only important if you connect to a server where you need new content like blocks or if you play with friends. And you can connect to almost any server using proxy servers! |
|
||||
| Singleplayer Survival Features | ❌ | ✅ | Just like Eaglercraft this project can generate and save worlds, but generator is simple and only a few survival features are supported (look here for [supported features list](https://github.com/zardoy/space-squid)) |
|
||||
| Singleplayer Maps | ✅ | ✅ | We support any version, but adventure maps won't work, but simple parkour and build maps might be interesting to explore... |
|
||||
| Singleplayer Maps World Streaming | ✅ | ❌ | Thanks to Browserfs, saves can be loaded to local singleplayer server using multiple ways: from local folder, server directory (not zip), dropbox or other cloud *backend* etc... |
|
||||
| P2P Multiplayer | ✅ | ✅ | A way to connect to other browser running the project. But it's almost useless here since many survival features are not implemented. Maybe only to build / explore maps together... |
|
||||
| Voice Chat | ❌ | ✅ | Eaglercraft has custom WebRTC voice chat implementation, though it could also be easily implemented there |
|
||||
| Online Servers | ✅ | ❌ | We have custom implementation (including integration on proxy side) for joining to servers |
|
||||
| Plugin Features | ✅ | ❌ | We have Mineflayer plugins support, like Auto Jump & Auto Parkour was added here that way |
|
||||
| Direct Connection | ✅ | ✅ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page |
|
||||
| Moding | ❌(roadmap, client-side) | ❌ | This project will support mods for singleplayer. In theory its possible to implement support for modded servers on protocol level (including all needed mods) |
|
||||
| Video Recording | ❌ | ✅ | Don't feel needed |
|
||||
| Metaverse Features | ❌(roadmap) | ❌ | Iframes, video streams inside of game world (custom protocol channel) |
|
||||
| Sounds | ✅ | ✅ | |
|
||||
| Resource Packs | ✅(+extras) | ✅ | This project has very limited support for them (only textures images are loadable for now) |
|
||||
| Assets Compressing & Splitting | ✅ | ❌ | We have advanced Minecraft data processing and good code chunk splitting so the web app will open much faster and use less memory |
|
||||
| Graphics | | | |
|
||||
| Fancy Graphics | ❌ | ✅ | While Eaglercraft has top-level shaders we don't even support lighting |
|
||||
| Fast & Efficient Graphics | ❌(+) | ❌ | Feels like no one needs to have 64 rendering distance work smoothly |
|
||||
| VR | ✅ | ❌ | Feels like not needed feature. UI is missing in this project since DOM can't be rendered in VR so Eaglercraft could be better in that aspect |
|
||||
| AR | ❌ | ❌ | Would be the most useless feature |
|
||||
| Minimap & Waypoints | ✅(-) | ❌ | We have buggy minimap, which can be enabled in settings and full map is opened by pressing `M` key |
|
||||
|
||||
Features available to only this project:
|
||||
|
||||
|
|
@ -52,6 +52,6 @@ TODO
|
|||
| API | Usage & Description |
|
||||
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Crypto` API | Used to make chat features work when joining online servers with authentication. |
|
||||
| `requestPointerLock({ unadjustedMovement: true })` API | Required for games. Disables system mouse acceleration (important for Mac users). Aka mouse raw input |
|
||||
| `requestPointerLock({ unadjustedMovement: true })` API | Required for games. Disables system mouse acceleration (important for Mac users). Aka mouse raw input |
|
||||
| `navigator.keyboard.lock()` | (only in Chromium browsers) When entering fullscreen it allows to use any key combination like ctrl+w in the game |
|
||||
| `navigator.keyboard.getLayoutMap()` | (only in Chromium browsers) To display the right keyboard symbol for the key keybinding on different keyboard layouts (e.g. QWERTY vs AZERTY) |
|
||||
|
|
|
|||
17
config.json
17
config.json
|
|
@ -6,20 +6,13 @@
|
|||
"peerJsServer": "",
|
||||
"peerJsServerFallback": "https://p2p.mcraft.fun",
|
||||
"promoteServers": [
|
||||
{
|
||||
"ip": "ws://play.mcraft.fun"
|
||||
},
|
||||
{
|
||||
"ip": "kaboom.pw",
|
||||
"version": "1.18.2",
|
||||
"description": "Chaos and destruction server. Free for everyone."
|
||||
},
|
||||
{
|
||||
"ip": "play.applemc.fun",
|
||||
"version": "1.18.2",
|
||||
"description": "Very nice server. Try it now!"
|
||||
},
|
||||
{
|
||||
"ip": "sus.shhnowisnottheti.me",
|
||||
"version": "1.18.2",
|
||||
"description": "Creative, your own 'boxes' (islands)"
|
||||
"version": "1.20.3",
|
||||
"description": "Very nice a polite server. Must try for everyone!"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -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
30
pnpm-lock.yaml
generated
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
109
scripts/uploadSoundFiles.ts
Normal 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
67
scripts/uploadSounds.ts
Normal 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);
|
||||
});
|
||||
10
server.js
10
server.js
|
|
@ -26,6 +26,7 @@ if (!isProd) {
|
|||
app.get('/config.json', (req, res, next) => {
|
||||
// read original file config
|
||||
let config = {}
|
||||
let publicConfig = {}
|
||||
try {
|
||||
config = require('./config.json')
|
||||
} catch {
|
||||
|
|
@ -33,9 +34,13 @@ app.get('/config.json', (req, res, next) => {
|
|||
config = require('./dist/config.json')
|
||||
} catch { }
|
||||
}
|
||||
try {
|
||||
publicConfig = require('./public/config.json')
|
||||
} catch { }
|
||||
res.json({
|
||||
...config,
|
||||
'defaultProxy': '', // use current url (this server)
|
||||
...publicConfig,
|
||||
})
|
||||
})
|
||||
if (isProd) {
|
||||
|
|
@ -45,6 +50,11 @@ if (isProd) {
|
|||
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp')
|
||||
next()
|
||||
})
|
||||
|
||||
// First serve from the override directory (volume mount)
|
||||
app.use(express.static(path.join(__dirname, './public')))
|
||||
|
||||
// Then fallback to the original dist directory
|
||||
app.use(express.static(path.join(__dirname, './dist')))
|
||||
}
|
||||
|
||||
|
|
|
|||
54
src/api/mcStatusApi.ts
Normal file
54
src/api/mcStatusApi.ts
Normal 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
78
src/appParams.ts
Normal 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())
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
80
src/cameraRotationControls.ts
Normal file
80
src/cameraRotationControls.ts
Normal 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)
|
||||
|
|
@ -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()])
|
||||
}
|
||||
|
|
|
|||
133
src/controls.ts
133
src/controls.ts
|
|
@ -7,7 +7,7 @@ import { ControMax } from 'contro-max/build/controMax'
|
|||
import { CommandEventArgument, SchemaCommandInput } from 'contro-max/build/types'
|
||||
import { stringStartsWith } from 'contro-max/build/stringUtils'
|
||||
import { UserOverrideCommand, UserOverridesConfig } from 'contro-max/build/types/store'
|
||||
import { isGameActive, showModal, gameAdditionalState, activeModalStack, hideCurrentModal, miscUiState, loadedGameState, hideModal } from './globalState'
|
||||
import { isGameActive, showModal, gameAdditionalState, activeModalStack, hideCurrentModal, miscUiState, hideModal, hideAllModals } from './globalState'
|
||||
import { goFullscreen, pointerLock, reloadChunks } from './utils'
|
||||
import { options } from './optionsStorage'
|
||||
import { openPlayerInventory } from './inventoryWindows'
|
||||
|
|
@ -19,9 +19,10 @@ import { showOptionsModal } from './react/SelectOption'
|
|||
import widgets from './react/widgets'
|
||||
import { getItemFromBlock } from './chatUtils'
|
||||
import { gamepadUiCursorState, moveGamepadCursorByPx } from './react/GamepadUiCursor'
|
||||
import { completeTexturePackInstall, resourcePackState } from './resourcePack'
|
||||
import { completeTexturePackInstall, copyServerResourcePackToRegular, resourcePackState } from './resourcePack'
|
||||
import { showNotification } from './react/NotificationProvider'
|
||||
import { lastConnectOptions } from './react/AppStatusProvider'
|
||||
import { onCameraMove, onControInit } from './cameraRotationControls'
|
||||
|
||||
|
||||
export const customKeymaps = proxy(JSON.parse(localStorage.keymap || '{}')) as UserOverridesConfig
|
||||
|
|
@ -50,7 +51,11 @@ export const contro = new ControMax({
|
|||
command: ['Slash'],
|
||||
swapHands: ['KeyF'],
|
||||
zoom: ['KeyC'],
|
||||
selectItem: ['KeyH'] // default will be removed
|
||||
selectItem: ['KeyH'], // default will be removed
|
||||
rotateCameraLeft: ['ArrowLeft'],
|
||||
rotateCameraRight: ['ArrowRight'],
|
||||
rotateCameraUp: ['ArrowUp'],
|
||||
rotateCameraDown: ['ArrowDown']
|
||||
},
|
||||
ui: {
|
||||
toggleFullscreen: ['F11'],
|
||||
|
|
@ -92,6 +97,8 @@ export const contro = new ControMax({
|
|||
window.controMax = contro
|
||||
export type Command = CommandEventArgument<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?.()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
// workaround for mineflayer
|
||||
globalThis.window ??= globalThis
|
||||
globalThis.localStorage ??= {}
|
||||
process.versions.node = '18.0.0'
|
||||
|
||||
if (!navigator.getGamepads) {
|
||||
|
|
|
|||
349
src/index.ts
349
src/index.ts
|
|
@ -5,14 +5,14 @@ import './globals'
|
|||
import './devtools'
|
||||
import './entities'
|
||||
import './globalDomListeners'
|
||||
import { getServerInfo } from './mineflayer/mc-protocol'
|
||||
import './mineflayer/maps'
|
||||
import './mineflayer/cameraShake'
|
||||
import initCollisionShapes from './getCollisionInteractionShapes'
|
||||
import './shims/patchShims'
|
||||
import { onGameLoad } from './inventoryWindows'
|
||||
import { supportedVersions } from 'minecraft-protocol'
|
||||
import initCollisionShapes from './getCollisionInteractionShapes'
|
||||
import protocolMicrosoftAuth from 'minecraft-protocol/src/client/microsoftAuth'
|
||||
import microsoftAuthflow from './microsoftAuthflow'
|
||||
import nbt from 'prismarine-nbt'
|
||||
|
||||
import 'core-js/features/array/at'
|
||||
import 'core-js/features/promise/with-resolvers'
|
||||
|
|
@ -24,7 +24,7 @@ import PrismarineItem from 'prismarine-item'
|
|||
|
||||
import { options, watchValue } from './optionsStorage'
|
||||
import './reactUi'
|
||||
import { contro, lockUrl, onBotCreate } from './controls'
|
||||
import { lockUrl, onBotCreate } from './controls'
|
||||
import './dragndrop'
|
||||
import { possiblyCleanHandle, resetStateAfterDisconnect } from './browserfs'
|
||||
import { watchOptionsAfterViewerInit, watchOptionsAfterWorldViewInit } from './watchOptions'
|
||||
|
|
@ -40,7 +40,7 @@ import { Vec3 } from 'vec3'
|
|||
import worldInteractions from './worldInteractions'
|
||||
|
||||
import * as THREE from 'three'
|
||||
import MinecraftData, { versionsByMinecraftVersion } from 'minecraft-data'
|
||||
import MinecraftData from 'minecraft-data'
|
||||
import debug from 'debug'
|
||||
import { defaultsDeep } from 'lodash-es'
|
||||
import initializePacketsReplay from './packetsReplay'
|
||||
|
|
@ -53,16 +53,12 @@ import {
|
|||
hideModal,
|
||||
insertActiveModalStack,
|
||||
isGameActive,
|
||||
loadedGameState,
|
||||
miscUiState,
|
||||
showModal
|
||||
} from './globalState'
|
||||
|
||||
|
||||
import {
|
||||
pointerLock,
|
||||
toMajorVersion,
|
||||
setLoadingScreenStatus
|
||||
pointerLock, setLoadingScreenStatus
|
||||
} from './utils'
|
||||
import { isCypress } from './standaloneUtils'
|
||||
|
||||
|
|
@ -74,10 +70,9 @@ import { startLocalServer, unsupportedLocalServerFeatures } from './createLocalS
|
|||
import defaultServerOptions from './defaultLocalServerOptions'
|
||||
import dayCycle from './dayCycle'
|
||||
|
||||
import { onAppLoad, resourcepackReload } from './resourcePack'
|
||||
import { onAppLoad, resourcepackReload, resourcePackState } from './resourcePack'
|
||||
import { ConnectPeerOptions, connectToPeer } from './localServerMultiplayer'
|
||||
import CustomChannelClient from './customClient'
|
||||
import { loadScript } from 'prismarine-viewer/viewer/lib/utils'
|
||||
import { registerServiceWorker } from './serviceWorker'
|
||||
import { appStatusState, lastConnectOptions } from './react/AppStatusProvider'
|
||||
|
||||
|
|
@ -85,9 +80,7 @@ import { fsState } from './loadSave'
|
|||
import { watchFov } from './rendererUtils'
|
||||
import { loadInMemorySave } from './react/SingleplayerProvider'
|
||||
|
||||
import { downloadSoundsIfNeeded } from './soundSystem'
|
||||
import { ua } from './react/utils'
|
||||
import { handleMovementStickDelta, joystickPointer } from './react/TouchAreasControls'
|
||||
import { possiblyHandleStateVariable } from './googledrive'
|
||||
import flyingSquidEvents from './flyingSquidEvents'
|
||||
import { hideNotification, notificationProxy, showNotification } from './react/NotificationProvider'
|
||||
|
|
@ -95,7 +88,7 @@ import { saveToBrowserMemory } from './react/PauseScreen'
|
|||
import { ViewerWrapper } from 'prismarine-viewer/viewer/lib/viewerWrapper'
|
||||
import './devReload'
|
||||
import './water'
|
||||
import { ConnectOptions, downloadNeededDataOnConnect } from './connect'
|
||||
import { ConnectOptions, downloadMcDataOnConnect, getVersionAutoSelect, downloadOtherGameData, downloadAllMinecraftData } from './connect'
|
||||
import { ref, subscribe } from 'valtio'
|
||||
import { signInMessageState } from './react/SignInMessageProvider'
|
||||
import { updateAuthenticatedAccountData, updateLoadedServerData } from './react/ServersListProvider'
|
||||
|
|
@ -106,6 +99,10 @@ import { ItemsRenderer } from 'mc-assets/dist/itemsRenderer'
|
|||
import './mobileShim'
|
||||
import { parseFormattedMessagePacket } from './botUtils'
|
||||
import { getViewerVersionData, getWsProtocolStream } from './viewerConnector'
|
||||
import { getWebsocketStream } from './mineflayer/websocket-core'
|
||||
import { appQueryParams, appQueryParamsArray } from './appParams'
|
||||
import { updateCursor } from './cameraRotationControls'
|
||||
import { pingServerVersion } from './mineflayer/minecraft-protocol-extra'
|
||||
|
||||
window.debug = debug
|
||||
window.THREE = THREE
|
||||
|
|
@ -201,44 +198,13 @@ viewer.entities.entitiesOptions = {
|
|||
}
|
||||
watchOptionsAfterViewerInit()
|
||||
|
||||
let mouseMovePostHandle = (e) => { }
|
||||
let lastMouseMove: number
|
||||
const updateCursor = () => {
|
||||
worldInteractions.update()
|
||||
}
|
||||
function onCameraMove (e) {
|
||||
if (e.type !== 'touchmove' && !pointerLock.hasPointerLock) return
|
||||
e.stopPropagation?.()
|
||||
const now = performance.now()
|
||||
// todo: limit camera movement for now to avoid unexpected jumps
|
||||
if (now - lastMouseMove < 4) return
|
||||
lastMouseMove = now
|
||||
let { mouseSensX, mouseSensY } = options
|
||||
if (mouseSensY === -1) mouseSensY = mouseSensX
|
||||
mouseMovePostHandle({
|
||||
x: e.movementX * mouseSensX * 0.0001,
|
||||
y: e.movementY * mouseSensY * 0.0001
|
||||
})
|
||||
updateCursor()
|
||||
}
|
||||
window.addEventListener('mousemove', onCameraMove, { capture: true })
|
||||
contro.on('stickMovement', ({ stick, vector }) => {
|
||||
if (!isGameActive(true)) return
|
||||
if (stick !== 'right') return
|
||||
let { x, z } = vector
|
||||
if (Math.abs(x) < 0.18) x = 0
|
||||
if (Math.abs(z) < 0.18) z = 0
|
||||
onCameraMove({ movementX: x * 10, movementY: z * 10, type: 'touchmove' })
|
||||
miscUiState.usingGamepadInput = true
|
||||
})
|
||||
|
||||
function hideCurrentScreens () {
|
||||
activeModalStacks['main-menu'] = [...activeModalStack]
|
||||
insertActiveModalStack('', [])
|
||||
}
|
||||
|
||||
const loadSingleplayer = (serverOverrides = {}, flattenedServerOverrides = {}) => {
|
||||
const serverSettingsQsRaw = new URLSearchParams(window.location.search).getAll('serverSetting')
|
||||
const serverSettingsQsRaw = appQueryParamsArray.serverSetting ?? []
|
||||
const serverSettingsQs = serverSettingsQsRaw.map(x => x.split(':')).reduce<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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
32
src/mineflayer/mc-protocol.ts
Normal file
32
src/mineflayer/mc-protocol.ts
Normal 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 } : {}),
|
||||
})
|
||||
}
|
||||
105
src/mineflayer/minecraft-protocol-extra.ts
Normal file
105
src/mineflayer/minecraft-protocol-extra.ts
Normal 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
|
||||
}
|
||||
53
src/mineflayer/websocket-core.ts
Normal file
53
src/mineflayer/websocket-core.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>)}
|
||||
|
|
|
|||
|
|
@ -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)" />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
198
src/react/GameInteractionOverlay.tsx
Normal file
198
src/react/GameInteractionOverlay.tsx
Normal 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?.()
|
||||
}
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
.effectsScreen-container {
|
||||
position: fixed;
|
||||
top: 6%;
|
||||
top: max(6%, 30px);
|
||||
left: 0px;
|
||||
z-index: -2;
|
||||
pointer-events: none;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
left: 50%;
|
||||
transform: translate(-50%);
|
||||
gap: 0 5px;
|
||||
z-index: -1;
|
||||
z-index: var(--has-modals-z, 7);
|
||||
}
|
||||
|
||||
.pause-btn,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}}
|
||||
/>
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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
10
src/shims/patchShims.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
33
src/sounds/musicSystem.ts
Normal 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
347
src/sounds/soundsMap.ts
Normal 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
8
src/sounds/testSounds.ts
Normal 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'))
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue