This commit is contained in:
Vitaly 2025-01-28 19:36:42 +03:00 committed by GitHub
commit 17df58de1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1651 additions and 2046 deletions

View file

@ -40,3 +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
- name: Install pnpm
run: npm install -g pnpm@9.0.4
- name: Run pnpm dedupe
run: pnpm dedupe
- name: Check for changes
run: |
if ! git diff --exit-code --quiet pnpm-lock.yaml; then
echo "pnpm dedupe introduced changes:"
git diff --color=always pnpm-lock.yaml
exit 1
else
echo "No changes detected after pnpm dedupe in pnpm-lock.yaml"
fi

View file

@ -43,3 +43,26 @@ jobs:
for alias in $(echo ${{ secrets.TEST_PREVIEW_DOMAIN }} | tr "," "\n"); do
vercel alias set ${{ steps.deploy.outputs.stdout }} $alias --token=${{ secrets.VERCEL_TOKEN }} --scope=zaro
done
- name: Create Release Pull Request
uses: actions/github-script@v6
with:
script: |
const { data: pulls } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
head: `${context.repo.owner}:next`,
base: 'release',
state: 'open'
});
if (pulls.length === 0) {
await github.rest.pulls.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: 'Release',
head: 'next',
base: 'release',
body: 'PR was created automatically by the release workflow, hope you release it as soon as possible!',
});
}

1
.gitignore vendored
View file

@ -18,5 +18,6 @@ out
generated
storybook-static
server-jar
config.local.json
src/react/npmReactComponents.ts

View file

@ -31,4 +31,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
ENTRYPOINT ["node", "server.js", "--prod"]

View file

@ -16,6 +16,7 @@
"prod-start": "node server.js --prod",
"test-mc-server": "tsx cypress/minecraft-server.mjs",
"lint": "eslint \"{src,cypress,prismarine-viewer}/**/*.{ts,js,jsx,tsx}\"",
"lint-fix": "pnpm lint --fix",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build && node scripts/build.js moveStorybookFiles",
"start-experiments": "vite --config experiments/vite.config.ts --host",
@ -73,7 +74,7 @@
"google-drive-browserfs": "github:zardoy/browserfs#google-drive",
"jszip": "^3.10.1",
"lodash-es": "^4.17.21",
"minecraft-data": "3.80.0",
"minecraft-data": "3.83.1",
"minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master",
"mineflayer-item-map-downloader": "github:zardoy/mineflayer-item-map-downloader",
"mojangson": "^2.0.4",
@ -142,7 +143,7 @@
"http-browserify": "^1.7.0",
"http-server": "^14.1.1",
"https-browserify": "^1.0.0",
"mc-assets": "^0.2.26",
"mc-assets": "^0.2.28",
"minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next",
"mineflayer": "github:zardoy/mineflayer",
"mineflayer-pathfinder": "^2.4.4",
@ -174,7 +175,7 @@
"diamond-square": "github:zardoy/diamond-square",
"prismarine-block": "github:zardoy/prismarine-block#next-era",
"prismarine-world": "github:zardoy/prismarine-world#next-era",
"minecraft-data": "3.80.0",
"minecraft-data": "3.83.1",
"prismarine-provider-anvil": "github:zardoy/prismarine-provider-anvil#everything",
"prismarine-physics": "github:zardoy/prismarine-physics",
"minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master",
@ -189,7 +190,7 @@
"three@0.154.0": "patches/three@0.154.0.patch",
"pixelarticons@1.8.1": "patches/pixelarticons@1.8.1.patch",
"mineflayer-item-map-downloader@1.2.0": "patches/mineflayer-item-map-downloader@1.2.0.patch",
"minecraft-protocol@1.51.0": "patches/minecraft-protocol@1.49.0.patch"
"minecraft-protocol@1.54.0": "patches/minecraft-protocol@1.54.0.patch"
}
},
"packageManager": "pnpm@9.0.4"

View file

@ -95,10 +95,10 @@ index b9d21bab9faccd5dbf1975fc423fc55c73e906c5..99ffd76527b410e3a393181beb260108
function onJoinServerResponse (err) {
diff --git a/src/client.js b/src/client.js
index 5b63c295080f62ca54928660cdfa134214002fa1..d2d24bff963d8ed7747ccb48f8c278950ba7f396 100644
index 74749698f8cee05b5dc749c271544f78d06645b0..e77e0a3f41c1ee780c3abbd54b0801d248c2a07c 100644
--- a/src/client.js
+++ b/src/client.js
@@ -88,10 +88,12 @@ class Client extends EventEmitter {
@@ -89,10 +89,12 @@ class Client extends EventEmitter {
parsed.metadata.name = parsed.data.name
parsed.data = parsed.data.params
parsed.metadata.state = state
@ -115,7 +115,7 @@ index 5b63c295080f62ca54928660cdfa134214002fa1..d2d24bff963d8ed7747ccb48f8c27895
}
if (this._hasBundlePacket && parsed.metadata.name === 'bundle_delimiter') {
if (this._mcBundle.length) { // End bundle
@@ -109,7 +111,13 @@ class Client extends EventEmitter {
@@ -110,7 +112,13 @@ class Client extends EventEmitter {
this._hasBundlePacket = false
}
} else {
@ -130,7 +130,7 @@ index 5b63c295080f62ca54928660cdfa134214002fa1..d2d24bff963d8ed7747ccb48f8c27895
}
})
}
@@ -167,7 +175,10 @@ class Client extends EventEmitter {
@@ -168,7 +176,10 @@ class Client extends EventEmitter {
}
const onFatalError = (err) => {
@ -142,7 +142,7 @@ index 5b63c295080f62ca54928660cdfa134214002fa1..d2d24bff963d8ed7747ccb48f8c27895
endSocket()
}
@@ -196,6 +207,8 @@ class Client extends EventEmitter {
@@ -197,6 +208,8 @@ class Client extends EventEmitter {
serializer -> framer -> socket -> splitter -> deserializer */
if (this.serializer) {
this.serializer.end()
@ -151,7 +151,7 @@ index 5b63c295080f62ca54928660cdfa134214002fa1..d2d24bff963d8ed7747ccb48f8c27895
} else {
if (this.socket) this.socket.end()
}
@@ -237,8 +250,11 @@ class Client extends EventEmitter {
@@ -238,8 +251,11 @@ class Client extends EventEmitter {
write (name, params) {
if (!this.serializer.writable) { return }
@ -166,10 +166,10 @@ index 5b63c295080f62ca54928660cdfa134214002fa1..d2d24bff963d8ed7747ccb48f8c27895
}
diff --git a/src/index.d.ts b/src/index.d.ts
index 423085259176a10c9dfeb617f00975df077d02be..ae0558b7c53ab24474b22240e8a2f4e4cde02f19 100644
index e61d5403bab46251d35b22a2ea30eb09b2746a26..84f597427893671eeac231b11e6e42aa815601df 100644
--- a/src/index.d.ts
+++ b/src/index.d.ts
@@ -134,6 +134,7 @@ declare module 'minecraft-protocol' {
@@ -135,6 +135,7 @@ declare module 'minecraft-protocol' {
sessionServer?: string
keepAlive?: boolean
closeTimeout?: number
@ -177,7 +177,7 @@ index 423085259176a10c9dfeb617f00975df077d02be..ae0558b7c53ab24474b22240e8a2f4e4
noPongTimeout?: number
checkTimeoutInterval?: number
version?: string
@@ -154,6 +155,8 @@ declare module 'minecraft-protocol' {
@@ -155,6 +156,8 @@ declare module 'minecraft-protocol' {
disableChatSigning?: boolean
/** Pass custom client implementation if needed. */
Client?: Client

View file

@ -0,0 +1,188 @@
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
+++ 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 {

2775
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -295,7 +295,7 @@ class MainScene extends BasePlaygroundScene {
}
}
worldView!.setBlockStateId(this.targetPos, block.stateId!)
worldView!.setBlockStateId(this.targetPos, block.stateId)
console.log('up stateId', block.stateId)
this.params.metadata = block.metadata
this.metadataGui.updateDisplay()

View file

@ -276,8 +276,20 @@ export class Entities extends EventEmitter {
// fixme workaround
defaultSteveTexture
usernamePerSkinUrlsCache = {} as Record<string, { skinUrl?: string, capeUrl?: string }>
// true means use default skin url
updatePlayerSkin (entityId: string | number, username: string | undefined, skinUrl: string | true, capeUrl: string | true | undefined = undefined) {
if (username) {
if (typeof skinUrl === 'string' || typeof capeUrl === 'string') this.usernamePerSkinUrlsCache[username] = {}
if (typeof skinUrl === 'string') this.usernamePerSkinUrlsCache[username].skinUrl = skinUrl
if (typeof capeUrl === 'string') this.usernamePerSkinUrlsCache[username].capeUrl = capeUrl
if (skinUrl === true) {
skinUrl = this.usernamePerSkinUrlsCache[username]?.skinUrl ?? skinUrl
}
capeUrl ??= this.usernamePerSkinUrlsCache[username]?.capeUrl
}
let playerObject = this.getPlayerObject(entityId)
if (!playerObject) return
// const username = this.entities[entityId].username

View file

@ -17,7 +17,7 @@ const addPositions = [
test('Known blocks are not rendered', () => {
const { mesherWorld, getGeometry, pos, mcData } = setup(lastVersion, addPositions as any)
const ignoreAsExpected = new Set([...INVISIBLE_BLOCKS, 'water', 'lava', 'moving_piston', 'light'])
const ignoreAsExpected = new Set([...INVISIBLE_BLOCKS, 'water', 'lava'])
let time = 0
let times = 0
@ -49,8 +49,6 @@ test('Known blocks are not rendered', () => {
// TODO resolve creaking_heart issue (1.21.3)
expect(missingBlocks).toMatchInlineSnapshot(`
{
"bubble_column": true,
"creaking_heart": true,
"end_gateway": true,
"end_portal": true,
"structure_void": true,

View file

@ -10,6 +10,14 @@ import { INVISIBLE_BLOCKS } from './worldConstants'
const ignoreAoBlocks = Object.keys(moreBlockDataGeneratedJson.noOcclusions)
const ALWAYS_WATERLOGGED = new Set([
'seagrass',
'tall_seagrass',
'kelp',
'kelp_plant',
'bubble_column'
])
function columnKey (x, z) {
return `${x},${z}`
}
@ -160,6 +168,12 @@ export class World {
if (block.models === undefined && blockProvider) {
if (!attr) throw new Error('attr is required')
const props = block.getProperties()
// Patch waterlogged property for ocean plants
if (ALWAYS_WATERLOGGED.has(block.name)) {
props.waterlogged = 'true'
}
try {
// fixme
if (this.preflat) {
@ -194,6 +208,7 @@ export class World {
if (block.name === 'flowing_water') block.name = 'water'
if (block.name === 'flowing_lava') block.name = 'lava'
if (block.name === 'bubble_column') block.name = 'water' // TODO need to distinguish between water and bubble column
// block.position = loc // it overrides position of all currently loaded blocks
block.biome = this.biomeCache[column.getBiome(locInChunk)] ?? this.biomeCache[1] ?? this.biomeCache[0]
if (block.name === 'redstone_ore') block.transparent = false

View file

@ -1 +1 @@
export const INVISIBLE_BLOCKS = new Set(['air', 'void_air', 'cave_air', 'barrier'])
export const INVISIBLE_BLOCKS = new Set(['air', 'void_air', 'cave_air', 'barrier', 'light', 'moving_piston'])

View file

@ -159,7 +159,7 @@ export class Viewer {
}))
}
setFirstPersonCamera (pos: Vec3 | null, yaw: number, pitch: number, roll = 0) {
setFirstPersonCamera (pos: Vec3 | null, yaw: number, pitch: number) {
const cam = this.cameraObjectOverride || this.camera
let yOffset = this.getMineflayerBot()?.entity?.eyeHeight ?? this.playerHeight
if (this.isSneaking) yOffset -= 0.3

View file

@ -54,6 +54,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
worldConfig = { minY: 0, worldHeight: 256 }
// todo need to cleanup
material = new THREE.MeshLambertMaterial({ vertexColors: true, transparent: true, alphaTest: 0.1 })
cameraRoll = 0
@worldCleanup()
active = false
@ -324,7 +325,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
Object.assign(blockTexturesChanges, christmasPack)
}
const customBlockTextures = Object.keys(this.customTextures.blocks?.textures ?? {}).filter(x => x.includes('/'))
const customBlockTextures = Object.keys(this.customTextures.blocks?.textures ?? {})
const { atlas: blocksAtlas, canvas: blocksCanvas } = await blocksAssetsParser.makeNewAtlas(this.texturesVersion ?? this.version ?? 'latest', (textureName) => {
const texture = this.customTextures?.blocks?.textures[textureName]
return blockTexturesChanges[textureName] ?? texture

View file

@ -232,7 +232,7 @@ export class WorldRendererThree extends WorldRendererCommon {
if (pos) {
new tweenJs.Tween(this.camera.position).to({ x: pos.x, y: pos.y, z: pos.z }, 50).start()
}
this.camera.rotation.set(pitch, yaw, 0, 'ZYX')
this.camera.rotation.set(pitch, yaw, this.cameraRoll, 'ZYX')
}
render () {

View file

@ -95,10 +95,14 @@ const appConfig = defineConfig({
fs.copyFileSync('./assets/release.json', './dist/release.json')
}
const configJson = JSON.parse(fs.readFileSync('./config.json', 'utf8'))
let configLocalJson = {}
try {
configLocalJson = JSON.parse(fs.readFileSync('./config.local.json', 'utf8'))
} catch (err) {}
if (dev) {
configJson.defaultProxy = ':8080'
}
fs.writeFileSync('./dist/config.json', JSON.stringify(configJson), 'utf8')
fs.writeFileSync('./dist/config.json', JSON.stringify({ ...configJson, ...configLocalJson }), 'utf8')
// childProcess.execSync('./scripts/prepareSounds.mjs', { stdio: 'inherit' })
// childProcess.execSync('tsx ./scripts/genMcDataTypes.ts', { stdio: 'inherit' })
// childProcess.execSync('tsx ./scripts/genPixelartTypes.ts', { stdio: 'inherit' })

View file

@ -21,6 +21,15 @@ export const displayClientChat = (text: string) => {
}
export const parseFormattedMessagePacket = (arg) => {
// if (typeof arg === 'string') {
// try {
// arg = JSON.parse(arg)
// return {
// formatted: arg,
// plain: ''
// }
// } catch {}
// }
if (typeof arg === 'object') {
try {
return {

View file

@ -313,9 +313,11 @@ export function lockUrl () {
newQs = `reconnect=1`
} else if (lastConnectOptions.value?.server) {
const qs = new URLSearchParams()
const { server, botVersion } = lastConnectOptions.value
qs.set('server', server)
const { server, botVersion, proxy, username } = lastConnectOptions.value
qs.set('ip', server)
if (botVersion) qs.set('version', botVersion)
if (proxy) qs.set('proxy', proxy)
if (username) qs.set('username', username)
newQs = String(qs.toString())
}
@ -736,11 +738,22 @@ addEventListener('mousedown', async (e) => {
window.addEventListener('keydown', (e) => {
if (e.code !== 'Escape') return
if (activeModalStack.length) {
hideCurrentModal(undefined, () => {
if (!activeModalStack.length) {
pointerLock.justHitEscape = true
const hideAll = e.ctrlKey || e.metaKey
if (hideAll) {
while (activeModalStack.length > 0) {
hideCurrentModal(undefined, () => {
if (!activeModalStack.length) {
pointerLock.justHitEscape = true
}
})
}
})
} else {
hideCurrentModal(undefined, () => {
if (!activeModalStack.length) {
pointerLock.justHitEscape = true
}
})
}
} else if (pointerLock.hasPointerLock) {
document.exitPointerLock?.()
if (options.autoExitFullscreen) {

View file

@ -153,4 +153,32 @@ customEvents.on('gameLoaded', () => {
watchValue(options, o => {
viewer.entities.setDebugMode(o.showChunkBorders ? 'basic' : 'none')
})
// Texture override from packet properties
bot._client.on('player_info', (packet) => {
for (const player of packet.data) {
const textureProperty = player.properties?.find(prop => prop.name === 'textures')
if (textureProperty) {
try {
const textureData = JSON.parse(Buffer.from(textureProperty.value, 'base64').toString())
const skinUrl = textureData.textures?.SKIN?.url
const capeUrl = textureData.textures?.CAPE?.url
// Find entity with matching UUID and update skin
let entityId = ''
for (const [entId, entity] of Object.entries(bot.entities)) {
if (entity.uuid === player.UUID) {
entityId = entId
break
}
}
// even if not found, still record to cache
viewer.entities.updatePlayerSkin(entityId, player.name, skinUrl, capeUrl)
} catch (err) {
console.error('Error decoding player texture:', err)
}
}
}
})
})

View file

@ -6,14 +6,7 @@ export interface ClientOnMap {
} | /** 1.12.2 */ {
keepAliveId: bigint;
};
login: /** 1.7 */ {
entityId: number;
gameMode: number;
dimension: number;
difficulty: number;
maxPlayers: number;
levelType: string;
} | /** 1.8 */ {
login:/** 1.8 */ {
entityId: number;
gameMode: number;
dimension: number;
@ -148,9 +141,7 @@ export interface ClientOnMap {
entityId: number;
equipments: any;
};
spawn_position: /** 1.7 */ {
location: any;
} | /** 1.8 */ {
spawn_position:/** 1.8 */ {
location: { x: number, y: number, z: number };
} | /** 1.17 */ {
location: { x: number, y: number, z: number };
@ -215,14 +206,7 @@ export interface ClientOnMap {
death: any;
portalCooldown: number;
};
position: /** 1.7 */ {
x: number;
y: number;
z: number;
yaw: number;
pitch: number;
onGround: boolean;
} | /** 1.8 */ {
position: /** 1.8 */ {
x: number;
y: number;
z: number;
@ -905,11 +889,7 @@ export interface ClientOnMap {
statistics: /** 1.7 */ {
entries: any;
};
player_info: /** 1.7 */ {
playerName: string;
online: boolean;
ping: number;
} | /** 1.8 */ {
player_info: /** 1.8 */ {
action: number;
data: any;
};
@ -926,22 +906,13 @@ export interface ClientOnMap {
length: number;
matches: any;
};
scoreboard_objective: /** 1.7 */ {
name: string;
displayText: string;
action: number;
} | /** 1.8 */ {
scoreboard_objective:/** 1.8 */ {
name: string;
action: number;
displayText: any;
type: any;
};
scoreboard_score: /** 1.7 */ {
itemName: string;
action: number;
scoreName: any;
value: any;
} | /** 1.8 */ {
scoreboard_score:/** 1.8 */ {
itemName: string;
action: number;
scoreName: string;

View file

@ -114,6 +114,9 @@ export type AppConfig = {
peerJsServerFallback?: string
promoteServers?: Array<{ ip, description, version? }>
mapsProvider?: string
defaultSettings?: Record<string, any>
allowAutoConnect?: boolean
}
export const miscUiState = proxy({

1
src/globals.d.ts vendored
View file

@ -26,6 +26,7 @@ declare const customEvents: import('typed-emitter').default<{
mineflayerBotCreated (): void
search (q: string): void
activateItem (item: Item, slot: number, offhand: boolean): void
hurtAnimation (): void
}>
declare const beforeRenderFrame: Array<() => void>

View file

@ -5,6 +5,8 @@ import './globals'
import './devtools'
import './entities'
import './globalDomListeners'
import './mineflayer/maps'
import './mineflayer/cameraShake'
import initCollisionShapes from './getCollisionInteractionShapes'
import { onGameLoad } from './inventoryWindows'
import { supportedVersions } from 'minecraft-protocol'
@ -650,6 +652,8 @@ async function connect (connectOptions: ConnectOptions) {
destroyAll()
})
// bot.emit('kicked', '{"translate":"disconnect.genericReason","with":["Internal Exception: io.netty.handler.codec.EncoderException: com.viaversion.viaversion.exception.InformativeException: Please report this on the Via support Discord or open an issue on the relevant GitHub repository\\nPacket Type: SYSTEM_CHAT, Index: 1, Type: TagType, Data: [], Packet ID: 103, Source 0: com.viaversion.viabackwards.protocol.v1_20_3to1_20_2.Protocol1_20_3To1_20_2$$Lambda/0x00007f9930f63080"]}', false)
const packetBeforePlay = (_, __, ___, fullBuffer) => {
lastPacket = fullBuffer.toString()
}
@ -1004,6 +1008,7 @@ downloadAndOpenFile().then((downloadAction) => {
const ip = qs.get('ip')
const lastConnect = JSON.parse(localStorage.lastConnectOptions ?? {})
void connect({
botVersion: qs.get('version') ?? undefined,
...lastConnect, // todo mixing is not good idea
ip: ip || undefined
})

View file

@ -45,9 +45,13 @@ export const onGameLoad = (onLoad) => {
if (!viewer.world.itemsAtlasParser) return
itemsRenderer = new ItemsRenderer(bot.version, viewer.world.blockstatesModels, viewer.world.itemsAtlasParser, viewer.world.blocksAtlasParser)
globalThis.itemsRenderer = itemsRenderer
if (allImagesLoadedState.value) return
onLoad?.()
allImagesLoadedState.value = true
if (!allImagesLoadedState.value) {
onLoad?.()
}
allImagesLoadedState.value = false
setTimeout(() => {
allImagesLoadedState.value = true
}, 0)
}
viewer.world.renderUpdateEmitter.on('textureDownloaded', checkIfLoaded)
checkIfLoaded()
@ -190,7 +194,7 @@ const renderSlot = (slot: RenderSlot, skipBlock = false): {
itemTexture = itemsRenderer.getItemTexture(itemName) ?? itemsRenderer.getItemTexture('item/missing_texture')!
} catch (err) {
itemTexture = itemsRenderer.getItemTexture('block/errored')!
inGameError(`Failed to render item ${itemName} on ${bot.version} (resourcepack: ${options.enabledResourcepack}): ${err.message}`)
inGameError(`Failed to render item ${itemName} on ${bot.version} (resourcepack: ${options.enabledResourcepack}): ${err.stack}`)
}
if ('type' in itemTexture) {
// is item

View file

@ -0,0 +1,107 @@
import * as THREE from 'three'
class CameraShake {
private rollAngle = 0
private get damageRollAmount () { return 5 }
private get damageAnimDuration () { return 200 }
private rollAnimation?: { startTime: number, startRoll: number, targetRoll: number, duration: number, returnToZero?: boolean }
constructor () {
this.rollAngle = 0
}
shakeFromDamage () {
// Add roll animation
const startRoll = this.rollAngle
const targetRoll = startRoll + (Math.random() < 0.5 ? -1 : 1) * this.damageRollAmount
this.rollAnimation = {
startTime: performance.now(),
startRoll,
targetRoll,
duration: this.damageAnimDuration / 2
}
}
update () {
// Update roll animation
if (this.rollAnimation) {
const now = performance.now()
const elapsed = now - this.rollAnimation.startTime
const progress = Math.min(elapsed / this.rollAnimation.duration, 1)
if (this.rollAnimation.returnToZero) {
// Ease back to zero
this.rollAngle = this.rollAnimation.startRoll * (1 - this.easeInOut(progress))
if (progress === 1) {
this.rollAnimation = undefined
}
} else {
// Initial roll
this.rollAngle = this.rollAnimation.startRoll + (this.rollAnimation.targetRoll - this.rollAnimation.startRoll) * this.easeOut(progress)
if (progress === 1) {
// Start return to zero animation
this.rollAnimation = {
startTime: now,
startRoll: this.rollAngle,
targetRoll: 0,
duration: this.damageAnimDuration / 2,
returnToZero: true
}
}
}
}
// Apply roll in camera's local space to maintain consistent left/right roll
const { camera } = viewer
const rollQuat = new THREE.Quaternion()
rollQuat.setFromAxisAngle(new THREE.Vector3(0, 0, 1), THREE.MathUtils.degToRad(this.rollAngle))
// Get camera's current rotation
const camQuat = new THREE.Quaternion()
camera.getWorldQuaternion(camQuat)
// Apply roll after camera rotation
const finalQuat = camQuat.multiply(rollQuat)
camera.setRotationFromQuaternion(finalQuat)
}
private easeOut (t: number): number {
return 1 - (1 - t) * (1 - t)
}
private easeInOut (t: number): number {
return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2
}
}
let cameraShake: CameraShake
customEvents.on('mineflayerBotCreated', () => {
if (!cameraShake) {
cameraShake = new CameraShake()
beforeRenderFrame.push(() => {
cameraShake.update()
})
}
customEvents.on('hurtAnimation', () => {
cameraShake.shakeFromDamage()
})
bot._client.on('hurt_animation', () => {
customEvents.emit('hurtAnimation')
})
bot.on('entityHurt', ({ id }) => {
if (id === bot.entity.id) {
customEvents.emit('hurtAnimation')
}
})
let { health } = bot
bot.on('health', () => {
if (bot.health < health) {
customEvents.emit('hurtAnimation')
}
health = bot.health
})
})

20
src/mineflayer/maps.ts Normal file
View file

@ -0,0 +1,20 @@
import { mapDownloader } from 'mineflayer-item-map-downloader'
import { setImageConverter } from 'mineflayer-item-map-downloader/lib/util'
setImageConverter((buf: Uint8Array) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')!
canvas.width = 128
canvas.height = 128
const imageData = ctx.createImageData(canvas.width, canvas.height)
imageData.data.set(buf)
ctx.putImageData(imageData, 0, 0)
// data url
return canvas.toDataURL('image/png')
})
customEvents.on('mineflayerBotCreated', () => {
bot.on('login', () => {
bot.loadPlugin(mapDownloader)
})
})

View file

@ -438,21 +438,30 @@ export const guiOptionsScheme: {
onClick={() => {
packetsReplaceSessionState.active = !active
}}
>{active ? 'Disable' : 'Enable'} Packets Replay</Button>
>{active ? 'Stop' : 'Start'} Packets Replay Logging</Button>
},
},
{
custom () {
const { active } = useSnapshot(packetsReplaceSessionState)
const { active, hasRecordedPackets } = useSnapshot(packetsReplaceSessionState)
return <Button
disabled={!active}
disabled={!hasRecordedPackets}
inScreen
onClick={() => {
void downloadPacketsReplay()
}}
>Download Packets Replay</Button>
},
}
},
{
packetsLoggerPreset: {
text: 'Packets Logger Preset',
values: [
['all', 'All'],
['no-buffers', 'No Buffers']
],
},
},
],
}
export type OptionsGroupType = 'main' | 'render' | 'interface' | 'controls' | 'sound' | 'advanced' | 'VR'

View file

@ -49,6 +49,7 @@ const defaultOptions = {
useVersionsTextures: 'latest',
serverResourcePacks: 'prompt' as 'prompt' | 'always' | 'never',
handDisplay: false,
packetsLoggerPreset: 'all' as 'all' | 'no-buffers',
// antiAliasing: false,
@ -93,7 +94,7 @@ const defaultOptions = {
minimapOptimizations: true,
displayBossBars: false, // boss bar overlay was removed for some reason, enable safely
disabledUiParts: [] as string[],
neighborChunkUpdates: true
neighborChunkUpdates: true,
}
function getDefaultTouchControlsPositions () {

View file

@ -1,25 +1,60 @@
import { proxy } from 'valtio'
import { PacketsLogger } from './packetsReplayBase'
import { options } from './optionsStorage'
export const packetsReplaceSessionState = proxy({
active: false,
hasRecordedPackets: false
})
const replayLogger = new PacketsLogger()
export const replayLogger = new PacketsLogger()
const isBufferData = (data: any): boolean => {
if (Buffer.isBuffer(data) || data instanceof Uint8Array) return true
if (typeof data === 'object' && data !== null) {
return Object.values(data).some(value => isBufferData(value))
}
return false
}
const processPacketData = (data: any): any => {
if (options.packetsLoggerPreset === 'no-buffers') {
if (Buffer.isBuffer(data)) {
return '[buffer]'
}
if (typeof data === 'object' && data !== null) {
const processed = {}
for (const [key, value] of Object.entries(data)) {
processed[key] = isBufferData(value) ? '[buffer]' : value
}
return processed
}
}
return data
}
export default () => {
customEvents.on('mineflayerBotCreated', () => {
replayLogger.contents = ''
bot._client.on('packet', (data, { name, state }) => {
packetsReplaceSessionState.hasRecordedPackets = false
const handleServerPacket = (data, { name, state = bot._client.state }) => {
if (!packetsReplaceSessionState.active) {
return
}
replayLogger.log(true, { name, state }, data)
replayLogger.log(true, { name, state }, processPacketData(data))
packetsReplaceSessionState.hasRecordedPackets = true
}
bot._client.on('packet', handleServerPacket)
bot._client.on('packet_name' as any, (name, data) => {
handleServerPacket(data, { name })
})
bot._client.on('writePacket' as any, (name, data) => {
if (!packetsReplaceSessionState.active) {
return
}
replayLogger.log(false, { name, state: bot._client.state }, data)
replayLogger.log(false, { name, state: bot._client.state }, processPacketData(data))
packetsReplaceSessionState.hasRecordedPackets = true
})
})
}
@ -30,3 +65,4 @@ export const downloadPacketsReplay = async () => {
a.download = `packets-replay-${new Date().toISOString()}.txt`
a.click()
}
globalThis.downloadPacketsReplay = downloadPacketsReplay

View file

@ -1,4 +1,4 @@
import React from 'react'
import React, { useEffect } from 'react'
import Screen from './Screen'
import Input from './Input'
import Button from './Button'
@ -26,11 +26,12 @@ interface Props {
accounts?: string[]
authenticatedAccounts?: number
versions?: string[]
allowAutoConnect?: boolean
}
const ELEMENTS_WIDTH = 190
export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQs, onQsConnect, placeholders, accounts, versions, authenticatedAccounts }: Props) => {
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')
@ -40,7 +41,7 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
const qsParamLockConnect = qsParams?.get('lockConnect')
const qsIpParts = qsParamIp?.split(':')
const ipParts = initialData?.ip.split(':')
const ipParts = initialData?.ip ? initialData?.ip.split(':') : undefined
const [serverName, setServerName] = React.useState(initialData?.name ?? qsParamName ?? '')
const [serverIp, setServerIp] = React.useState(ipParts?.[0] ?? qsIpParts?.[0] ?? '')
@ -69,6 +70,12 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
authenticatedAccountOverride,
}
useEffect(() => {
if (qsParams?.get('autoConnect') === 'true' && qsParams?.get('ip') && allowAutoConnect) {
onQsConnect?.(commonUseOptions)
}
}, [])
return <Screen title={qsParamIp ? 'Connect to Server' : title} backdrop>
<form
style={{

View file

@ -5,7 +5,7 @@ import { resetLocalStorageWorld } from '../browserfs'
import { fsState } from '../loadSave'
import { guessProblem } from '../errorLoadingScreenHelpers'
import { ConnectOptions } from '../connect'
import { downloadPacketsReplay, packetsReplaceSessionState } from '../packetsReplay'
import { downloadPacketsReplay, packetsReplaceSessionState, replayLogger } from '../packetsReplay'
import { getProxyDetails } from '../microsoftAuthflow'
import AppStatus from './AppStatus'
import DiveTransition from './DiveTransition'
@ -121,7 +121,7 @@ export default () => {
<>
{displayAuthButton && <Button label='Authenticate' onClick={authReconnectAction} />}
{displayVpnButton && <PossiblyVpnBypassProxyButton reconnect={reconnect} />}
{replayActive && <Button label='Download Packets Replay' onClick={downloadPacketsReplay} />}
{replayActive && <Button label={`Download Packets Replay ${replayLogger.contents.split('\n').length}L`} onClick={downloadPacketsReplay} />}
</>
}
>

View file

@ -8,6 +8,7 @@ import PixelartIcon from './PixelartIcon'
interface Props extends React.ComponentProps<'button'> {
label?: string
postLabel?: React.ReactNode
icon?: string
children?: React.ReactNode
inScreen?: boolean
@ -22,7 +23,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', ...args }) => {
export default (({ label, icon, children, inScreen, rootRef, type = 'button', postLabel, ...args }) => {
const ctx = useContext(ButtonContext)
const onClick = (e) => {
@ -42,6 +43,7 @@ export default (({ label, icon, children, inScreen, rootRef, type = 'button', ..
<button ref={rootRef} {...args} className={classNames(buttonCss.button, args.className)} onClick={onClick} type={type}>
{icon && <PixelartIcon className={buttonCss.icon} iconName={icon} />}
{label}
{postLabel}
{children}
</button>
</SharedHudVars>

View file

@ -1,31 +1,16 @@
import { useEffect, useState } from 'react'
import { mapDownloader } from 'mineflayer-item-map-downloader/'
import { setImageConverter } from 'mineflayer-item-map-downloader/lib/util'
export default () => {
const [dataUrl, setDataUrl] = useState<string | null | true>(null) // true means loading
useEffect(() => {
bot.loadPlugin(mapDownloader)
setImageConverter((buf: Uint8Array) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')!
canvas.width = 128
canvas.height = 128
const imageData = ctx.createImageData(canvas.width, canvas.height)
imageData.data.set(buf)
ctx.putImageData(imageData, 0, 0)
// data url
return canvas.toDataURL('image/png')
})
// TODO delete maps!
const updateHeldMap = () => {
setDataUrl(null)
if (!bot.heldItem || !['filled_map', 'map'].includes(bot.heldItem.name)) return
const item = bot.heldItem
if (!item || !['filled_map', 'map'].includes(item.name)) return
// setDataUrl(true)
const mapNumber = (bot.heldItem?.nbt?.value as any)?.map?.value
const mapNumber = ((item?.nbt?.value as any)?.map?.value) ?? (item['components']?.find(x => x.type === 'map_id')?.data)
// if (!mapNumber) return
setDataUrl(bot.mapDownloader.maps?.[mapNumber] as unknown as string)
}
@ -38,6 +23,8 @@ export default () => {
// total maps: Object.keys(bot.mapDownloader.maps).length
updateHeldMap()
})
updateHeldMap()
}, [])
return dataUrl && dataUrl !== true ? <div style={{

View file

@ -63,6 +63,8 @@ export const OptionButton = ({ item }: { item: Extract<OptionMeta, { type: 'togg
const valuesTitlesMap = useMemo(() => {
if (!item.values) {
return {
// true: <span style={{ color: 'lime' }}>ON</span>,
// false: <span style={{ color: 'red' }}>OFF</span>,
true: 'ON',
false: 'OFF',
}
@ -82,7 +84,9 @@ export const OptionButton = ({ item }: { item: Extract<OptionMeta, { type: 'togg
return <Button
data-setting={item.id}
label={`${item.text}: ${valuesTitlesMap[optionValue]}`}
onClick={async () => {
// label={`${item.text}:`}
// postLabel={valuesTitlesMap[optionValue]}
onClick={async (event) => {
if (disabledReason) {
await showOptionsModal(`The option is unavailable. ${disabledReason}`, [])
return
@ -106,7 +110,10 @@ export const OptionButton = ({ item }: { item: Extract<OptionMeta, { type: 'togg
if (currentIndex === -1) {
options[item.id!] = getOptionValue(values[0])
} else {
options[item.id!] = getOptionValue(values[(currentIndex + 1) % values.length])
const nextIndex = event.shiftKey
? (currentIndex - 1 + values.length) % values.length
: (currentIndex + 1) % values.length
options[item.id!] = getOptionValue(values[nextIndex])
}
} else {
options[item.id!] = !options[item.id!]

View file

@ -145,7 +145,7 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
const [defaultUsername, _setDefaultUsername] = useState(localStorage['username'] ?? (`mcrafter${Math.floor(Math.random() * 1000)}`))
const [authenticatedAccounts, _setAuthenticatedAccounts] = useState<AuthenticatedAccount[]>(JSON.parse(localStorage['authenticatedAccounts'] || '[]'))
const [quickConnectIp, setQuickConnectIp] = useState('')
const [selectedIndex, setSelectedIndex] = useState(-1)
const [selectedIndex, setSelectedIndex] = useState(0)
const setAuthenticatedAccounts = (newState: typeof authenticatedAccounts) => {
_setAuthenticatedAccounts(newState)
@ -252,6 +252,7 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
})
const editModalJsx = isEditScreenModal ? <AddServerOrConnect
allowAutoConnect={miscUiState.appConfig?.allowAutoConnect}
placeholders={{
proxyOverride: selectedProxy,
usernameOverride: defaultUsername,
@ -412,6 +413,7 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
onRowSelect={(_, i) => {
setSelectedIndex(i)
}}
selectedRow={selectedIndex}
/>
return <>
{serversListJsx}

View file

@ -25,9 +25,10 @@ export interface WorldProps {
worldNameRight?: string
onFocus?: (name: string) => void
onInteraction?(interaction: 'enter' | 'space')
elemRef?: React.Ref<HTMLDivElement>
}
const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction, iconSrc, formattedTextOverride, worldNameRight }: WorldProps) => {
const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction, iconSrc, formattedTextOverride, worldNameRight, elemRef }: WorldProps & { ref?: React.Ref<HTMLDivElement> }) => {
const timeRelativeFormatted = useMemo(() => {
if (!lastPlayed) return ''
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
@ -47,6 +48,7 @@ const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus,
}, [size])
return <div
ref={elemRef}
className={classNames(styles.world_root, isFocused ? styles.world_focused : undefined)} tabIndex={0} onFocus={() => onFocus?.(name)} onKeyDown={(e) => {
if (e.code === 'Enter' || e.code === 'Space') {
e.preventDefault()
@ -92,6 +94,7 @@ interface Props {
onGeneralAction (action: 'cancel' | 'create'): void
onRowSelect? (name: string, index: number): void
defaultSelectedRow?: number
selectedRow?: number
listStyle?: React.CSSProperties
setListHovered?: (hovered: boolean) => void
secondRowStyles?: React.CSSProperties
@ -116,6 +119,7 @@ export default ({
hidden,
onRowSelect,
defaultSelectedRow,
selectedRow,
listStyle,
setListHovered,
secondRowStyles,
@ -123,9 +127,10 @@ export default ({
}: Props) => {
const containerRef = useRef<any>()
const firstButton = useRef<HTMLButtonElement>(null)
const worldRefs = useRef<Record<string, HTMLDivElement | null>>({})
useTypedEventListener(window, 'keydown', (e) => {
if ((e.code === 'ArrowDown' || e.code === 'ArrowUp') && e.ctrlKey) {
if ((e.code === 'ArrowDown' || e.code === 'ArrowUp')) {
e.preventDefault()
const dir = e.code === 'ArrowDown' ? 1 : -1
const elements = focusable(containerRef.current)
@ -137,12 +142,21 @@ export default ({
})
const [search, setSearch] = useState('')
const [focusedWorld, setFocusedWorld] = useState(defaultSelectedRow ? worldData?.[defaultSelectedRow]?.name ?? '' : '')
const [focusedWorld, setFocusedWorld] = useState(defaultSelectedRow === undefined ? '' : worldData?.[defaultSelectedRow]?.name ?? '')
useEffect(() => {
setFocusedWorld('')
}, [activeProvider])
useEffect(() => {
if (selectedRow === undefined) return
const worldName = worldData?.[selectedRow]?.name
setFocusedWorld(worldName ?? '')
if (worldName) {
worldRefs.current[worldName]?.focus()
}
}, [selectedRow, worldData?.[selectedRow as any]?.name])
const onRowSelectHandler = (name: string, index: number) => {
onRowSelect?.(name, index)
setFocusedWorld(name)
@ -186,7 +200,14 @@ export default ({
worldData
? worldData.filter(data => data.title.toLowerCase().includes(search.toLowerCase())).map(({ name, size, detail, ...rest }, index) => (
<World
{...rest} size={size} name={name} onFocus={row => onRowSelectHandler(row, index)} isFocused={focusedWorld === name} key={name} onInteraction={(interaction) => {
{...rest}
size={size}
name={name}
elemRef={el => { worldRefs.current[name] = el }}
onFocus={row => onRowSelectHandler(row, index)}
isFocused={focusedWorld === name}
key={name}
onInteraction={(interaction) => {
if (interaction === 'enter') onWorldAction('load', name)
else if (interaction === 'space') firstButton.current?.focus()
}}

View file

@ -5,15 +5,15 @@ import { options } from './optionsStorage'
export const watchFov = () => {
const updateFov = () => {
if (!bot) return
let fovSetting = gameAdditionalState.isZooming ? 30 : options.fov
let fov = gameAdditionalState.isZooming ? 30 : options.fov
// todo check values and add transition
if (bot.controlState.sprint && !bot.controlState.sneak) {
fovSetting += 5
fov += 5
}
if (gameAdditionalState.isFlying) {
fovSetting += 5
fov += 5
}
viewer.camera.fov = fovSetting
viewer.camera.fov = fov
viewer.camera.updateProjectionMatrix()
}
updateFov()

View file

@ -1,7 +1,9 @@
/* eslint-disable no-await-in-loop */
import { join, dirname, basename } from 'path'
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 { setLoadingScreenStatus } from './utils'
import { showNotification } from './react/NotificationProvider'
@ -9,7 +11,8 @@ import { options } from './optionsStorage'
import { showOptionsModal } from './react/SelectOption'
import { appStatusState } from './react/AppStatusProvider'
import { appReplacableResources, resourcesContentOriginal } from './generated/resources'
import { loadedGameState } from './globalState'
import { loadedGameState, miscUiState } from './globalState'
import { watchUnloadForCleanup } from './gameUnload'
export const resourcePackState = proxy({
resourcePackInstalled: false,
@ -169,64 +172,106 @@ export const getActiveTexturepackBasePath = async () => {
return null
}
const isDirSafe = async (filePath: string) => {
try {
return await fs.promises.stat(filePath).then(stat => stat.isDirectory()).catch(() => false)
} catch (err) {
return false
}
}
const getFilesMapFromDir = async (dir: string) => {
const files = [] as string[]
const scan = async (dir) => {
const dirFiles = await fs.promises.readdir(dir)
for (const file of dirFiles) {
const filePath = join(dir, file)
if (await isDirSafe(filePath)) {
await scan(filePath)
} else {
files.push(filePath)
}
}
}
await scan(dir)
return files
}
export const getResourcepackTiles = async (type: 'blocks' | 'items', existingTextures: string[]) => {
const basePath = await getActiveTexturepackBasePath()
if (!basePath) return
const texturesCommonBasePath = `${basePath}/assets/minecraft/textures`
let texturesBasePath = `${texturesCommonBasePath}/${type === 'blocks' ? 'block' : 'item'}`
const texturesBasePathAlt = `${texturesCommonBasePath}/${type === 'blocks' ? 'blocks' : 'items'}`
if (!(await existsAsync(texturesBasePath))) {
if (await existsAsync(texturesBasePathAlt)) {
texturesBasePath = texturesBasePathAlt
}
}
const allInterestedPaths = existingTextures.map(tex => {
if (tex.includes('/')) {
return join(`${texturesCommonBasePath}/${tex}`)
}
return join(texturesBasePath, tex)
})
const allInterestedPathsPerDir = new Map<string, string[]>()
for (const path of allInterestedPaths) {
const dir = dirname(path)
if (!allInterestedPathsPerDir.has(dir)) {
allInterestedPathsPerDir.set(dir, [])
}
const file = basename(path)
allInterestedPathsPerDir.get(dir)!.push(file)
}
// filter out by readdir each dir
const allInterestedImages = [] as string[]
for (const [dir, paths] of allInterestedPathsPerDir) {
if (!await existsAsync(dir)) {
continue
}
const dirImages = (await fs.promises.readdir(dir)).filter(f => f.endsWith('.png')).map(f => f.replace('.png', ''))
allInterestedImages.push(...dirImages.filter(image => paths.includes(image)).map(image => `${dir}/${image}`))
}
if (allInterestedImages.length === 0) {
return
}
let firstTextureSize: number | undefined
const namespaces = await fs.promises.readdir(join(basePath, 'assets'))
if (appStatusState.status) {
setLoadingScreenStatus(`Generating atlas texture for ${type}`)
}
const textures = {} as Record<string, HTMLImageElement>
for (const namespace of namespaces) {
const texturesCommonBasePath = `${basePath}/assets/${namespace}/textures`
const isMinecraftNamespace = namespace === 'minecraft'
let texturesBasePath = `${texturesCommonBasePath}/${type === 'blocks' ? 'block' : 'item'}`
const texturesBasePathAlt = `${texturesCommonBasePath}/${type === 'blocks' ? 'blocks' : 'items'}`
if (!(await existsAsync(texturesBasePath))) {
if (await existsAsync(texturesBasePathAlt)) {
texturesBasePath = texturesBasePathAlt
}
}
const allInterestedPaths = new Set(
existingTextures
.filter(tex => (isMinecraftNamespace && !tex.includes(':')) || (tex.includes(':') && tex.split(':')[0] === namespace))
.map(tex => {
tex = tex.split(':')[1] ?? tex
if (tex.includes('/')) {
return join(`${texturesCommonBasePath}/${tex}`)
}
return join(texturesBasePath, tex)
})
)
// add all files from texturesCommonBasePath
// if (!isMinecraftNamespace) {
// const commonBasePathFiles = await getFilesMapFromDir(texturesCommonBasePath)
// for (const file of commonBasePathFiles) {
// allInterestedPaths.add(file)
// }
// }
const allInterestedPathsPerDir = new Map<string, string[]>()
for (const path of allInterestedPaths) {
const dir = dirname(path)
if (!allInterestedPathsPerDir.has(dir)) {
allInterestedPathsPerDir.set(dir, [])
}
const file = basename(path)
allInterestedPathsPerDir.get(dir)!.push(file)
}
// filter out by readdir each dir
const allInterestedImages = [] as string[]
for (const [dir, paths] of allInterestedPathsPerDir) {
if (!await existsAsync(dir)) {
continue
}
const dirImages = (await fs.promises.readdir(dir)).filter(f => f.endsWith('.png')).map(f => f.replace('.png', ''))
allInterestedImages.push(...dirImages.filter(image => paths.includes(image)).map(image => `${dir}/${image}`))
}
const firstImageFile = allInterestedImages[0]!
if (allInterestedImages.length === 0) {
continue
}
let firstTextureSize: number | undefined
try {
// todo compare sizes from atlas
firstTextureSize = await getSizeFromImage(`${firstImageFile}.png`)
} catch (err) { }
const textures = Object.fromEntries(await Promise.all(allInterestedImages.map(async (image) => {
const imagePath = `${image}.png`
const contents = await fs.promises.readFile(imagePath, 'base64')
const img = await getLoadedImage(`data:image/png;base64,${contents}`)
const imageRelative = image.replace(`${texturesBasePath}/`, '').replace(`${texturesCommonBasePath}/`, '')
return [imageRelative, img]
})))
const firstImageFile = allInterestedImages[0]!
try {
// todo check all sizes from atlas
firstTextureSize ??= await getSizeFromImage(`${firstImageFile}.png`)
} catch (err) { }
const newTextures = Object.fromEntries(await Promise.all(allInterestedImages.map(async (image) => {
const imagePath = `${image}.png`
const contents = await fs.promises.readFile(imagePath, 'base64')
const img = await getLoadedImage(`data:image/png;base64,${contents}`)
const imageRelative = image.replace(`${texturesBasePath}/`, '').replace(`${texturesCommonBasePath}/`, '')
const textureName = isMinecraftNamespace ? imageRelative : `${namespace}:${imageRelative}`
return [textureName, img]
})))
Object.assign(textures, newTextures) as any
}
return {
firstTextureSize,
textures
@ -234,8 +279,9 @@ export const getResourcepackTiles = async (type: 'blocks' | 'items', existingTex
}
const prepareBlockstatesAndModels = async () => {
viewer.world.customBlockStates = undefined
viewer.world.customModels = undefined
viewer.world.customBlockStates = {}
viewer.world.customModels = {}
const usedTextures = new Set<string>()
const basePath = await getActiveTexturepackBasePath()
if (!basePath) return
if (appStatusState.status) {
@ -256,16 +302,25 @@ const prepareBlockstatesAndModels = async () => {
if (type === 'models') {
name = `block/${name}`
}
if (namespaceDir !== 'minecraft') {
name = `${namespaceDir}:${name}`
const parsed = JSON.parse(contents)
if (namespaceDir === 'minecraft') {
jsons[name] = parsed
}
jsons[`${namespaceDir}:${name}`] = parsed
if (type === 'models') {
for (let texturePath of Object.values(parsed.textures ?? {})) {
if (typeof texturePath !== 'string') continue
if (texturePath.startsWith('#')) continue
if (!texturePath.includes(':')) texturePath = `minecraft:${texturePath}`
usedTextures.add(texturePath as string)
}
}
jsons[name] = JSON.parse(contents)
}
}))
return jsons
}
viewer.world.customBlockStates = await getAllJson(blockstatesPath, 'blockstates')
viewer.world.customModels = await getAllJson(modelsPath, 'models')
Object.assign(viewer.world.customBlockStates!, await getAllJson(blockstatesPath, 'blockstates'))
Object.assign(viewer.world.customModels!, await getAllJson(modelsPath, 'models'))
}
try {
const assetsDirs = await fs.promises.readdir(join(basePath, 'assets'))
@ -277,6 +332,7 @@ const prepareBlockstatesAndModels = async () => {
viewer.world.customBlockStates = undefined
viewer.world.customModels = undefined
}
return { usedTextures }
}
const downloadAndUseResourcePack = async (url: string): Promise<void> => {
@ -290,6 +346,17 @@ const downloadAndUseResourcePack = async (url: string): Promise<void> => {
})
}
const waitForGameEvent = async () => {
if (miscUiState.gameLoaded) return
await new Promise<void>(resolve => {
const listener = () => resolve()
customEvents.once('gameLoaded', listener)
watchUnloadForCleanup(() => {
customEvents.removeListener('gameLoaded', listener)
})
})
}
export const onAppLoad = () => {
customEvents.on('mineflayerBotCreated', () => {
// todo also handle resourcePack
@ -307,8 +374,12 @@ export const onAppLoad = () => {
minecraftJsonMessage: promptMessagePacket,
})
if (!choice) return
await new Promise(resolve => {
setTimeout(resolve, 500)
})
console.log('accepting resource pack')
bot.acceptResourcePack()
if (choice === 'Download & Install (recommended)') {
if (choice === true || choice === 'Download & Install (recommended)') {
await downloadAndUseResourcePack(packet.url).catch((err) => {
console.error(err)
showNotification('Failed to download resource pack: ' + err.message)
@ -349,10 +420,10 @@ const updateAllReplacableTextures = async () => {
for (const [key, { cssVar, cssVarRepeat, resourcePackPath }] of vars) {
const resPath = `${basePath}/assets/${resourcePackPath}`
if (cssVar) {
// eslint-disable-next-line no-await-in-loop
await setCustomCss(resPath, cssVar, cssVarRepeat ?? 1)
} else {
// eslint-disable-next-line no-await-in-loop
await setCustomPicture(key, resPath)
}
}
@ -363,10 +434,10 @@ const repeatArr = (arr, i) => Array.from({ length: i }, () => arr)
const updateTextures = async () => {
const blocksFiles = Object.keys(viewer.world.blocksAtlases.latest.textures)
const itemsFiles = Object.keys(viewer.world.itemsAtlases.latest.textures)
const blocksData = await getResourcepackTiles('blocks', blocksFiles)
const { usedTextures: extraBlockTextures = new Set<string>() } = await prepareBlockstatesAndModels() ?? {}
const blocksData = await getResourcepackTiles('blocks', [...blocksFiles, ...extraBlockTextures])
const itemsData = await getResourcepackTiles('items', itemsFiles)
await updateAllReplacableTextures()
await prepareBlockstatesAndModels()
viewer.world.customTextures = {}
if (blocksData) {
viewer.world.customTextures.blocks = {
@ -382,6 +453,9 @@ const updateTextures = async () => {
}
if (viewer.world.active) {
await viewer.world.updateTexturesData()
if (viewer.world instanceof WorldRendererThree) {
viewer.world.rerenderAllChunks?.()
}
}
}

View file

@ -1,5 +1,8 @@
import { supportedVersions } from 'minecraft-data'
import { supportedVersions, postNettyVersionsByProtocolVersion } from 'minecraft-data'
const ignoredVersionsRegex = /(^0\.30c$)|w|-pre|-rc/
export const ignoredVersionsRegex = /(^0\.30c$)|w|-pre|-rc/
export default supportedVersions.pc.filter(x => x !== '1.7').filter(v => !ignoredVersionsRegex.test(v))
/** @type {string[]} */
const versionsFromProtocol = Object.values(postNettyVersionsByProtocolVersion.pc).flat().filter(x => !ignoredVersionsRegex.test(x.minecraftVersion)).map(x => x.minecraftVersion)
export default versionsFromProtocol.filter(x => x !== '1.7' && !x.startsWith('1.7.'))