diff --git a/package.json b/package.json index 250c8522..58ffa652 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "mojangson": "^2.0.4", "net-browserify": "github:zardoy/prismarinejs-net-browserify", "node-gzip": "^1.1.2", - "mcraft-fun-mineflayer": "^0.1.3", + "mcraft-fun-mineflayer": "^0.1.4", "peerjs": "^1.5.0", "pixelarticons": "^1.8.1", "pretty-bytes": "^6.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44894157..6886b974 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,8 +135,8 @@ importers: specifier: ^4.17.21 version: 4.17.21 mcraft-fun-mineflayer: - specifier: ^0.1.3 - version: 0.1.3(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/748163e536abe94f3dc8ada7a542bcd689bbbf49(encoding@0.1.13)) + specifier: ^0.1.4 + version: 0.1.4(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/748163e536abe94f3dc8ada7a542bcd689bbbf49(encoding@0.1.13)) minecraft-data: specifier: 3.83.1 version: 3.83.1 @@ -6252,9 +6252,9 @@ packages: resolution: {integrity: sha512-JBi9frIACmzmpKL38YudZJpml+tWP3UuCeb8ko5iJRHpmCmmChE+X3xzVEbEYnYBI2dMiO7915/5eYnKUVys3Q==} engines: {node: '>=18.0.0'} - mcraft-fun-mineflayer@0.1.3: - resolution: {integrity: sha512-3Xds5XBLwYHFgH09RS9fHNK7NJI2ZStXiYvU/9FVUMd9TOwcMwLorn1kNYOw022/0nGi3hibT3ldj6vLCjASIg==} - version: 0.1.3 + mcraft-fun-mineflayer@0.1.4: + resolution: {integrity: sha512-h6Dmnj/1No1aZO9FG14GTCZ80xPHVnkhByUFKIZr0PV2Hto7PoG4hBG8fa1fifwQIgLwBa+bURPyBw3KzzFPhw==} + version: 0.1.4 engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} peerDependencies: '@roamhq/wrtc': '*' @@ -16691,7 +16691,7 @@ snapshots: apl-image-packer: 1.1.0 zod: 3.24.1 - mcraft-fun-mineflayer@0.1.3(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/748163e536abe94f3dc8ada7a542bcd689bbbf49(encoding@0.1.13)): + mcraft-fun-mineflayer@0.1.4(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/748163e536abe94f3dc8ada7a542bcd689bbbf49(encoding@0.1.13)): dependencies: '@zardoy/flying-squid': 0.0.49(encoding@0.1.13) exit-hook: 2.2.1 diff --git a/renderer/viewer/lib/worldrendererCommon.ts b/renderer/viewer/lib/worldrendererCommon.ts index c590cc1a..3ca100a2 100644 --- a/renderer/viewer/lib/worldrendererCommon.ts +++ b/renderer/viewer/lib/worldrendererCommon.ts @@ -149,6 +149,14 @@ export abstract class WorldRendererCommon lastChunkDistance = 0 debugStopGeometryUpdate = false + @worldCleanup() + freeFlyMode = false + @worldCleanup() + freeFlyState = { + yaw: 0, + pitch: 0, + position: new Vec3(0, 0, 0) + } @worldCleanup() itemsRenderer: ItemsRenderer | undefined diff --git a/renderer/viewer/lib/worldrendererThree.ts b/renderer/viewer/lib/worldrendererThree.ts index 4ff56cdb..697717c9 100644 --- a/renderer/viewer/lib/worldrendererThree.ts +++ b/renderer/viewer/lib/worldrendererThree.ts @@ -223,8 +223,15 @@ export class WorldRendererThree extends WorldRendererCommon { } updateCamera (pos: Vec3 | null, yaw: number, pitch: number): void { + if (this.freeFlyMode) { + pos = this.freeFlyState.position + pitch = this.freeFlyState.pitch + yaw = this.freeFlyState.yaw + } + if (pos) { new tweenJs.Tween(this.camera.position).to({ x: pos.x, y: pos.y, z: pos.z }, 50).start() + this.freeFlyState.position = pos } this.camera.rotation.set(pitch, yaw, this.cameraRoll, 'ZYX') } @@ -234,7 +241,7 @@ export class WorldRendererThree extends WorldRendererCommon { // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style const cam = this.camera instanceof THREE.Group ? this.camera.children.find(child => child instanceof THREE.PerspectiveCamera) as THREE.PerspectiveCamera : this.camera this.renderer.render(this.scene, cam) - if (this.config.showHand) { + if (this.config.showHand && !this.freeFlyMode) { this.holdingBlock.render(this.camera, this.renderer, viewer.ambientLight, viewer.directionalLight) this.holdingBlockLeft.render(this.camera, this.renderer, viewer.ambientLight, viewer.directionalLight) } diff --git a/src/appParams.ts b/src/appParams.ts index 413b6c2d..6e9a2d50 100644 --- a/src/appParams.ts +++ b/src/appParams.ts @@ -45,6 +45,9 @@ export type AppQsParams = { replaySpeed?: string replayFileUrl?: string replayValidateClient?: string + replayStopOnError?: string + replaySkipMissingOnTimeout?: string + replayPacketsSenderDelay?: string } export type AppQsParamsArray = { @@ -76,6 +79,16 @@ export const appQueryParamsArray = new Proxy({} as AppQsParamsArrayTransformed, }, }) +export function updateQsParam (name: keyof AppQsParams, value: string | undefined) { + const url = new URL(window.location.href) + if (value) { + url.searchParams.set(name, value) + } else { + url.searchParams.delete(name) + } + window.history.replaceState({}, '', url.toString()) +} + // Helper function to check if a specific query parameter exists export const hasQueryParam = (param: keyof AppQsParams) => qsParams.has(param) diff --git a/src/cameraRotationControls.ts b/src/cameraRotationControls.ts index 31c20654..b9bc5fe9 100644 --- a/src/cameraRotationControls.ts +++ b/src/cameraRotationControls.ts @@ -48,6 +48,14 @@ export const moveCameraRawHandler = ({ x, y }: { x: number; y: number }) => { const minPitch = -0.5 * Math.PI viewer.world.lastCamUpdate = Date.now() + + if (viewer.world.freeFlyMode) { + // Update freeFlyState directly + viewer.world.freeFlyState.yaw = (viewer.world.freeFlyState.yaw - x) % (2 * Math.PI) + viewer.world.freeFlyState.pitch = Math.max(minPitch, Math.min(maxPitch, viewer.world.freeFlyState.pitch - y)) + return + } + 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) diff --git a/src/controls.ts b/src/controls.ts index 965a2a67..be5028aa 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -2,6 +2,7 @@ import { Vec3 } from 'vec3' import { proxy, subscribe } from 'valtio' +import * as THREE from 'three' import { ControMax } from 'contro-max/build/controMax' import { CommandEventArgument, SchemaCommandInput } from 'contro-max/build/types' @@ -127,6 +128,23 @@ contro.on('movementUpdate', ({ vector, soleVector, gamepadIndex }) => { } miscUiState.usingGamepadInput = gamepadIndex !== undefined if (!bot || !isGameActive(false)) return + + if (viewer.world.freeFlyMode) { + // Create movement vector from input + const direction = new THREE.Vector3(0, 0, 0) + if (vector.z !== undefined) direction.z = vector.z + if (vector.x !== undefined) direction.x = vector.x + + // Apply camera rotation to movement direction + direction.applyQuaternion(viewer.camera.quaternion) + + // Update freeFlyState position with normalized direction + const moveSpeed = 1 + direction.multiplyScalar(moveSpeed) + viewer.world.freeFlyState.position.add(new Vec3(direction.x, direction.y, direction.z)) + return + } + // gamepadIndex will be used for splitscreen in future const coordToAction = [ ['z', -1, 'forward'], @@ -333,10 +351,20 @@ const onTriggerOrReleased = (command: Command, pressed: boolean) => { // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check switch (command) { case 'general.jump': - bot.setControlState('jump', pressed) + if (viewer.world.freeFlyMode) { + const moveSpeed = 0.5 + viewer.world.freeFlyState.position.add(new Vec3(0, pressed ? moveSpeed : 0, 0)) + } else { + bot.setControlState('jump', pressed) + } break case 'general.sneak': - setSneaking(pressed) + if (viewer.world.freeFlyMode) { + const moveSpeed = 0.5 + viewer.world.freeFlyState.position.add(new Vec3(0, pressed ? -moveSpeed : 0, 0)) + } else { + setSneaking(pressed) + } break case 'general.sprint': // todo add setting to change behavior diff --git a/src/index.ts b/src/index.ts index 74c82479..b3c04d8c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -776,7 +776,9 @@ export async function connect (connectOptions: ConnectOptions) { playerState.onlineMode = !!connectOptions.authenticatedAccount setLoadingScreenStatus('Placing blocks (starting viewer)') - localStorage.lastConnectOptions = JSON.stringify(connectOptions) + if (connectOptions.worldStateFileContents && connectOptions.worldStateFileContents.length < 3 * 1024 * 1024) { + localStorage.lastConnectOptions = JSON.stringify(connectOptions) + } connectOptions.onSuccessfulPlay?.() if (process.env.NODE_ENV === 'development' && !localStorage.lockUrl && !Object.keys(window.debugQueryParams).length) { lockUrl() diff --git a/src/packetsReplay/replayPackets.ts b/src/packetsReplay/replayPackets.ts index 9ce88a1c..3713d9a2 100644 --- a/src/packetsReplay/replayPackets.ts +++ b/src/packetsReplay/replayPackets.ts @@ -2,6 +2,7 @@ import { createServer, ServerClient } from 'minecraft-protocol' import { parseReplayContents } from 'mcraft-fun-mineflayer/build/packetsLogger' import { WorldStateHeader, PACKETS_REPLAY_FILE_EXTENSION, WORLD_STATE_FILE_EXTENSION } from 'mcraft-fun-mineflayer/build/worldState' +import MinecraftData from 'minecraft-data' import { LocalServer } from '../customServer' import { UserError } from '../mineflayer/userError' import { packetsReplayState } from '../react/state/packetsReplayState' @@ -24,7 +25,6 @@ interface OpenFileOptions { export function openFile ({ contents, filename = 'unnamed', filesize }: OpenFileOptions) { packetsReplayState.replayName = `${filename} (${getFixedFilesize(filesize ?? contents.length)})` - packetsReplayState.isOpen = true packetsReplayState.isPlaying = false const connectOptions = { @@ -72,11 +72,12 @@ export const startLocalReplayServer = (contents: string) => { 'online-mode': false }) - server.on('login', async client => { + const data = MinecraftData(def.minecraftVersion) + server.on(data.supportFeature('hasConfigurationState') ? 'playerJoin' : 'login' as any, async client => { await mainPacketsReplayer( client, replayData, - appQueryParams.replayValidateClient === 'true' ? true : undefined + packetsReplayState.customButtons.validateClientPackets.state ? true : undefined ) }) @@ -122,6 +123,8 @@ const IGNORE_SERVER_PACKETS = new Set([ 'kick_disconnect', ]) +const ADDITIONAL_DELAY = 500 + const mainPacketsReplayer = async (client: ServerClient, replayData: ReturnType, ignoreClientPacketsWait: string[] | true = []) => { const writePacket = (name: string, data: any) => { data = restoreData(data) @@ -139,49 +142,107 @@ const mainPacketsReplayer = async (client: ServerClient, replayData: ReturnType< expectedPacketReceived (name, params) { console.log('expectedPacketReceived', name, params) addPacketToReplayer(name, params, true, true) + }, + unexpectedPacketsLimit: 15, + onUnexpectedPacketsLimitReached () { + addPacketToReplayer('...', {}, true) } }) - bot._client.on('writePacket' as any, (name, params) => { - console.log('writePacket', name, params) - clientsPacketsWaiter.addPacket(name, params) - }) - console.log('start replaying!') - for (const [i, packet] of playPackets.entries()) { - if (packet.isFromServer) { - writePacket(packet.name, packet.params) - addPacketToReplayer(packet.name, packet.params, false) - await new Promise(resolve => { - setTimeout(resolve, packet.diff * packetsReplayState.speed) - }) - } else if (ignoreClientPacketsWait !== true && !ignoreClientPacketsWait.includes(packet.name)) { - clientPackets.push({ name: packet.name, params: packet.params }) - if (playPackets[i + 1]?.isFromServer) { - // eslint-disable-next-line @typescript-eslint/no-loop-func - clientPackets = clientPackets.filter((p, index) => { - return !FLATTEN_CLIENT_PACKETS.has(p.name) || index === clientPackets.findIndex(clientPacket => clientPacket.name === p.name) - }) - for (const packet of clientPackets) { - packetsReplayState.packetsPlayback.push({ - name: packet.name, - data: packet.params, - isFromClient: true, - position: positions.client++, - timestamp: Date.now(), - isUpcoming: true, - }) - } + // Patch console.error to detect errors + const originalConsoleError = console.error + let lastSentPacket: { name: string, params: any } | null = null + console.error = (...args) => { + if (lastSentPacket) { + console.log('Got error after packet', lastSentPacket.name, lastSentPacket.params) + } + originalConsoleError.apply(console, args) + if (packetsReplayState.customButtons.stopOnError.state) { + packetsReplayState.isPlaying = false + throw new Error('Replay stopped due to error: ' + args.join(' ')) + } + } - await clientsPacketsWaiter.waitForPackets(clientPackets.map(p => p.name)) - clientPackets = [] + const playServerPacket = (name: string, params: any) => { + try { + writePacket(name, params) + addPacketToReplayer(name, params, false) + lastSentPacket = { name, params } + } catch (err) { + console.error('Error processing packet:', err) + if (packetsReplayState.customButtons.stopOnError.state) { + packetsReplayState.isPlaying = false } } } + + try { + bot.on('error', (err) => { + console.error('Mineflayer error:', err) + }) + + client.on('writePacket' as any, (name, params) => { + console.log('writePacket', name, params) + clientsPacketsWaiter.addPacket(name, params) + }) + + console.log('start replaying!') + for (const [i, packet] of playPackets.entries()) { + if (!packetsReplayState.isPlaying) { + await new Promise(resolve => { + const interval = setInterval(() => { + if (packetsReplayState.isPlaying) { + clearInterval(interval) + resolve() + } + }, 100) + }) + } + + if (packet.isFromServer) { + playServerPacket(packet.name, packet.params) + await new Promise(resolve => { + setTimeout(resolve, packet.diff * packetsReplayState.speed + ADDITIONAL_DELAY * (packetsReplayState.customButtons.packetsSenderDelay.state ? 1 : 0)) + }) + } else if (ignoreClientPacketsWait !== true && !ignoreClientPacketsWait.includes(packet.name)) { + clientPackets.push({ name: packet.name, params: packet.params }) + if (playPackets[i + 1]?.isFromServer) { + // eslint-disable-next-line @typescript-eslint/no-loop-func + clientPackets = clientPackets.filter((p, index) => { + return !FLATTEN_CLIENT_PACKETS.has(p.name) || index === clientPackets.findIndex(clientPacket => clientPacket.name === p.name) + }) + for (const packet of clientPackets) { + packetsReplayState.packetsPlayback.push({ + name: packet.name, + data: packet.params, + isFromClient: true, + position: positions.client++, + timestamp: Date.now(), + isUpcoming: true, + }) + } + + await Promise.race([ + clientsPacketsWaiter.waitForPackets(clientPackets.map(p => p.name)), + ...(packetsReplayState.customButtons.skipMissingOnTimeout.state ? [new Promise(resolve => { + setTimeout(resolve, 1000) + })] : []) + ]) + clientPackets = [] + } + } + } + } finally { + // Restore original console.error + console.error = originalConsoleError + } } interface PacketsWaiterOptions { unexpectedPacketReceived?: (name: string, params: any) => void expectedPacketReceived?: (name: string, params: any) => void + onUnexpectedPacketsLimitReached?: () => void + unexpectedPacketsLimit?: number } interface PacketsWaiter { @@ -193,13 +254,19 @@ const createPacketsWaiter = (options: PacketsWaiterOptions = {}): PacketsWaiter let packetHandler: ((data: any, name: string) => void) | null = null const queuedPackets: Array<{ name: string, params: any }> = [] let isWaiting = false - + let unexpectedPacketsCount = 0 const handlePacket = (data: any, name: string, waitingPackets: string[], resolve: () => void) => { if (waitingPackets.includes(name)) { waitingPackets.splice(waitingPackets.indexOf(name), 1) options.expectedPacketReceived?.(name, data) } else { - options.unexpectedPacketReceived?.(name, data) + if (options.unexpectedPacketsLimit && unexpectedPacketsCount < options.unexpectedPacketsLimit) { + options.unexpectedPacketReceived?.(name, data) + } + if (options.onUnexpectedPacketsLimitReached && unexpectedPacketsCount === options.unexpectedPacketsLimit) { + options.onUnexpectedPacketsLimitReached?.() + } + unexpectedPacketsCount++ } if (waitingPackets.length === 0) { @@ -220,6 +287,7 @@ const createPacketsWaiter = (options: PacketsWaiterOptions = {}): PacketsWaiter if (isWaiting) { throw new Error('Already waiting for packets') } + unexpectedPacketsCount = 0 isWaiting = true try { @@ -253,6 +321,7 @@ const isArrayEqual = (a: any[], b: any[]) => { } const restoreData = (json: any) => { + if (!json) return json const keys = Object.keys(json) if (isArrayEqual(keys.sort(), ['data', 'type'].sort())) { diff --git a/src/react/BossBarOverlay.stories.tsx b/src/react/BossBarOverlay.stories.tsx index c5699b58..ed88a8ce 100644 --- a/src/react/BossBarOverlay.stories.tsx +++ b/src/react/BossBarOverlay.stories.tsx @@ -27,7 +27,8 @@ export const Primary: Story = { _title: { text: 'Boss', translate: 'entity.minecraft.ender_dragon' }, _color: 'red', _dividers: 2, - _health: 100 + _health: 100, + lastUpdated: 0 } } } diff --git a/src/react/PacketsReplayProvider.tsx b/src/react/PacketsReplayProvider.tsx index 81ae8562..cb307ed0 100644 --- a/src/react/PacketsReplayProvider.tsx +++ b/src/react/PacketsReplayProvider.tsx @@ -1,20 +1,10 @@ import { useSnapshot } from 'valtio' import { useMemo } from 'react' -import { appQueryParams } from '../appParams' +import { appQueryParams, updateQsParam } from '../appParams' import { miscUiState } from '../globalState' -import { packetsReplayState } from './state/packetsReplayState' +import { onChangeButtonState, packetsReplayState } from './state/packetsReplayState' import ReplayPanel from './ReplayPanel' -function updateQsParam (name: string, value: string | undefined) { - const url = new URL(window.location.href) - if (value) { - url.searchParams.set(name, value) - } else { - url.searchParams.delete(name) - } - window.history.replaceState({}, '', url.toString()) -} - export default function PacketsReplayProvider () { const state = useSnapshot(packetsReplayState) const { gameLoaded } = useSnapshot(miscUiState) @@ -58,7 +48,7 @@ export default function PacketsReplayProvider () { updateQsParam('replayFilter', filter) }} onCustomButtonToggle={(button) => { - packetsReplayState.customButtons[button] = !state.customButtons[button] + onChangeButtonState(button as keyof typeof packetsReplayState.customButtons, !state.customButtons[button].state) }} /> diff --git a/src/react/ReplayPanel.tsx b/src/react/ReplayPanel.tsx index 51771a5b..c9d5439b 100644 --- a/src/react/ReplayPanel.tsx +++ b/src/react/ReplayPanel.tsx @@ -12,12 +12,12 @@ interface Props { progress: { current: number; total: number } speed: number defaultFilter?: string - customButtons: { button1: boolean; button2: boolean } + customButtons: Readonly> onPlayPause?: (isPlaying: boolean) => void onRestart?: () => void onSpeedChange?: (speed: number) => void onFilterChange: (filter: string) => void - onCustomButtonToggle: (button: 'button1' | 'button2') => void + onCustomButtonToggle: (buttonId: string) => void clientPacketsAutocomplete: string[] serverPacketsAutocomplete: string[] } @@ -139,22 +139,24 @@ export default function ReplayPanel ({ }} /> - {[1, 2].map(num => ( + {Object.entries(customButtons).map(([buttonId, { state, label, tooltip }]) => ( ))} diff --git a/src/react/components/replay/PacketList.tsx b/src/react/components/replay/PacketList.tsx index c481ad52..484c42c9 100644 --- a/src/react/components/replay/PacketList.tsx +++ b/src/react/components/replay/PacketList.tsx @@ -1,12 +1,17 @@ import { useRef, useState } from 'react' import { PacketData } from '../../ReplayPanel' import { useScrollBehavior } from '../../hooks/useScrollBehavior' +import { ClientOnMap } from '../../../generatedServerPackets' import { DARK_COLORS } from './constants' const formatters: Record string> = { position: (data) => `x:${data.x.toFixed(2)} y:${data.y.toFixed(2)} z:${data.z.toFixed(2)}`, - chat: (data) => data.message, - // Add more formatters as needed + // chat: (data) => data, + map_chunk (data: ClientOnMap['map_chunk'] | any) { + const sizeOfChunk = data.chunkData?.length + const blockEntitiesCount = data.blockEntities?.length + return `x:${data.x} z:${data.z} C:${sizeOfChunk} E:${blockEntitiesCount}` + }, } const getPacketIcon = (name: string): string => { diff --git a/src/react/state/packetsReplayState.ts b/src/react/state/packetsReplayState.ts index 102a0ba1..79b0640a 100644 --- a/src/react/state/packetsReplayState.ts +++ b/src/react/state/packetsReplayState.ts @@ -1,6 +1,6 @@ import { proxy } from 'valtio' import type { PacketData } from '../ReplayPanel' -import { appQueryParams } from '../../appParams' +import { appQueryParams, updateQsParam } from '../../appParams' export const packetsReplayState = proxy({ packetsPlayback: [] as PacketData[], @@ -13,7 +13,47 @@ export const packetsReplayState = proxy({ }, speed: appQueryParams.replaySpeed ? parseFloat(appQueryParams.replaySpeed) : 1, customButtons: { - button1: false, - button2: false + validateClientPackets: { + state: appQueryParams.replayValidateClient === 'true', + label: 'C', + tooltip: 'Validate client packets' + }, + stopOnError: { + state: appQueryParams.replayStopOnError === 'true', + label: 'E', + tooltip: 'Stop the replay when an error occurs' + }, + skipMissingOnTimeout: { + state: appQueryParams.replaySkipMissingOnTimeout === 'true', + label: 'M', + tooltip: 'Skip missing packets on timeout' + }, + packetsSenderDelay: { + state: appQueryParams.replayPacketsSenderDelay === 'true', + label: 'D', + tooltip: 'Send packets with an additional delay' + } } }) + +export const onChangeButtonState = (button: keyof typeof packetsReplayState.customButtons, state: boolean) => { + packetsReplayState.customButtons[button].state = state + switch (button) { + case 'validateClientPackets': { + updateQsParam('replayValidateClient', String(state)) + break + } + case 'stopOnError': { + updateQsParam('replayStopOnError', String(state)) + break + } + case 'skipMissingOnTimeout': { + updateQsParam('replaySkipMissingOnTimeout', String(state)) + break + } + case 'packetsSenderDelay': { + updateQsParam('replayPacketsSenderDelay', String(state)) + break + } + } +}