From 65af9a73c2b743d2958d2583bd509bec97c14897 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sat, 15 Feb 2025 05:14:36 +0300 Subject: [PATCH] feat: rework hand! enable by default, fix bow anim (#261) * refactor swing animation to controller * idle animator!!!! * implelment state switch transition * a huge fix for UI server edit! * adjust ui scaling so main menu elements clip less * view bobbing, new config name, ws: * EXTREMELY important fixes to entities rendering * a lot of fixes, add dns resolve fallback * improve f3 E, fix modal not found edge case * set correctly target for old browsers, should fix ios 14 crash * unecessary big refactor, to fix ts err * fix isWysiwyg check * fix entities rendering count --- package.json | 28 +- pnpm-lock.yaml | 72 +- renderer/buildMesherWorker.mjs | 1 + renderer/rsbuild.config.ts | 21 +- renderer/viewer/lib/DebugGui.ts | 174 ++++ renderer/viewer/lib/animationController.ts | 75 ++ renderer/viewer/lib/basePlayerState.ts | 94 +++ renderer/viewer/lib/cameraBobbing.ts | 94 +++ renderer/viewer/lib/entities.ts | 195 +++-- renderer/viewer/lib/entity/EntityMesh.ts | 9 + renderer/viewer/lib/hand.ts | 29 +- renderer/viewer/lib/holdingBlock.ts | 940 +++++++++++++++------ renderer/viewer/lib/mesher/mesher.ts | 2 + renderer/viewer/lib/smoothSwitcher.ts | 169 ++++ renderer/viewer/lib/threeJsUtils.ts | 9 +- renderer/viewer/lib/utils/proxy.ts | 23 + renderer/viewer/lib/utils/skins.ts | 27 + renderer/viewer/lib/viewer.ts | 39 +- renderer/viewer/lib/viewerWrapper.ts | 2 +- renderer/viewer/lib/worldDataEmitter.ts | 31 +- renderer/viewer/lib/worldrendererCommon.ts | 11 +- renderer/viewer/lib/worldrendererThree.ts | 38 +- src/api/mcStatusApi.ts | 10 + src/builtinCommands.ts | 15 +- src/devtools.ts | 13 + src/globalState.ts | 5 + src/index.ts | 32 +- src/inventoryWindows.ts | 4 +- src/mineflayer/playerState.ts | 200 +++++ src/optionsGuiScheme.tsx | 7 +- src/optionsStorage.ts | 3 +- src/parseServerAddress.ts | 2 + src/react/AddServerOrConnect.tsx | 48 +- src/react/DebugOverlay.tsx | 4 +- src/react/GlobalSearchInput.tsx | 1 + src/react/NoModalFoundProvider.tsx | 13 +- src/react/SignEditorProvider.tsx | 6 +- src/react/UIProvider.tsx | 30 + src/react/utilsApp.ts | 10 +- src/reactUi.tsx | 82 +- src/rendererUtils.ts | 113 ++- src/scaleInterface.ts | 14 +- src/shims/dns.js | 18 + src/watchOptions.ts | 3 +- src/worldInteractions.ts | 3 + 45 files changed, 2152 insertions(+), 567 deletions(-) create mode 100644 renderer/viewer/lib/DebugGui.ts create mode 100644 renderer/viewer/lib/animationController.ts create mode 100644 renderer/viewer/lib/basePlayerState.ts create mode 100644 renderer/viewer/lib/cameraBobbing.ts create mode 100644 renderer/viewer/lib/smoothSwitcher.ts create mode 100644 renderer/viewer/lib/utils/proxy.ts create mode 100644 renderer/viewer/lib/utils/skins.ts create mode 100644 src/mineflayer/playerState.ts create mode 100644 src/react/UIProvider.tsx diff --git a/package.json b/package.json index 171da2f3..79e2a9ba 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "compression": "^1.7.4", "cors": "^2.8.5", "debug": "^4.3.4", + "deepslate": "^0.23.5", "diff-match-patch": "^1.0.5", "eruda": "^3.0.1", "esbuild": "^0.19.3", @@ -144,7 +145,7 @@ "http-browserify": "^1.7.0", "http-server": "^14.1.1", "https-browserify": "^1.0.0", - "mc-assets": "^0.2.31", + "mc-assets": "^0.2.34", "minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next", "mineflayer": "github:zardoy/mineflayer", "mineflayer-pathfinder": "^2.4.4", @@ -168,15 +169,22 @@ "cypress-plugin-snapshots": "^1.4.4", "systeminformation": "^5.21.22" }, - "browserslist": [ - "iOS >= 14", - "Android >= 13", - "Chrome >= 103", - "not dead", - "not ie <= 11", - "not op_mini all", - "> 0.5%" - ], + "browserslist": { + "production": [ + "iOS >= 14", + "Android >= 13", + "Chrome >= 103", + "not dead", + "not ie <= 11", + "not op_mini all", + "> 0.5%" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, "pnpm": { "overrides": { "buffer": "^6.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a49ff0cb..7540c5bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,6 +97,9 @@ importers: debug: specifier: ^4.3.4 version: 4.4.0(supports-color@8.1.1) + deepslate: + specifier: ^0.23.5 + version: 0.23.5 diff-match-patch: specifier: ^1.0.5 version: 1.0.5 @@ -132,7 +135,7 @@ importers: version: 4.17.21 mcraft-fun-mineflayer: specifier: 0.0.3 - version: 0.0.3(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/54f8c2282d822ad02967a197bda36302a4e7b4a5(encoding@0.1.13)) + version: 0.0.3(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/4fa1af9964cab91315d8d1ae02615f3039638828(encoding@0.1.13)) minecraft-data: specifier: 3.83.1 version: 3.83.1 @@ -346,14 +349,14 @@ importers: specifier: ^1.0.0 version: 1.0.0 mc-assets: - specifier: ^0.2.31 - version: 0.2.31 + specifier: ^0.2.34 + version: 0.2.34 minecraft-inventory-gui: specifier: github:zardoy/minecraft-inventory-gui#next version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/75e940a4cd50d89e0ba03db3733d5d704917a3c8(@types/react@18.2.20)(react@18.2.0) mineflayer: specifier: github:zardoy/mineflayer - version: https://codeload.github.com/zardoy/mineflayer/tar.gz/54f8c2282d822ad02967a197bda36302a4e7b4a5(encoding@0.1.13) + version: https://codeload.github.com/zardoy/mineflayer/tar.gz/4fa1af9964cab91315d8d1ae02615f3039638828(encoding@0.1.13) mineflayer-pathfinder: specifier: ^2.4.4 version: 2.4.4 @@ -3791,6 +3794,9 @@ packages: character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + charenc@0.0.2: + resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} + check-error@1.0.3: resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} @@ -4065,6 +4071,9 @@ packages: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} + crypt@0.0.2: + resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} + crypto-browserify@3.12.0: resolution: {integrity: sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==} @@ -4218,6 +4227,9 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + deepslate@0.23.5: + resolution: {integrity: sha512-FjBBbuPUI1Y/dXtUc4WiCJSA7s7yRAXepD7qWRF6wX5m/q7AVRauMEShu8lphRvqCtJyxcYFZmISwX5OOH/tWw==} + default-browser-id@3.0.0: resolution: {integrity: sha512-OZ1y3y0SqSICtE8DE4S8YOE9UZOJ8wO16fKWVP5J1Qz42kV9jcnMVFrEE/noXb/ss3Q4pZIH79kxofzyNNtUNA==} engines: {node: '>=12'} @@ -5183,6 +5195,9 @@ packages: github-slugger@1.5.0: resolution: {integrity: sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==} + gl-matrix@3.4.3: + resolution: {integrity: sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA==} + gl@6.0.2: resolution: {integrity: sha512-yBbfpChOtFvg5D+KtMaBFvj6yt3vUnheNAH+UrQH2TfDB8kr0tERdL0Tjhe0W7xJ6jR6ftQBluTZR9jXUnKe8g==} engines: {node: '>=14.0.0'} @@ -6228,8 +6243,8 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - mc-assets@0.2.31: - resolution: {integrity: sha512-+WAU9+iRbBgWWgsrQ52j0Xqq+Qs7UbjlVtZq1NCTqpIAvaEKNCwM7u2qFV5hoj4RpBAsVmkgLissdjLddE8WaA==} + mc-assets@0.2.34: + resolution: {integrity: sha512-BvE2mVs9XETLFb+FN1Zbc4mJ+CvZqgxVd3kxhERp1QljudDmMsWsMcK2EUTrevuE6a7L3F2kx8XC1vVA79i/ow==} engines: {node: '>=18.0.0'} mcraft-fun-mineflayer@0.0.3: @@ -6251,6 +6266,9 @@ packages: md5.js@1.3.5: resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==} + md5@2.3.0: + resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==} + mdast-util-definitions@4.0.0: resolution: {integrity: sha512-k8AJ6aNnUkB7IE+5azR9h81O5EQ/cTDXtWdMq9Kk5KcEW/8ritU5CeLg/9HhOC++nALHBlaogJ5jz0Ybk3kPMQ==} @@ -6462,8 +6480,8 @@ packages: resolution: {integrity: sha512-q7cmpZFaSI6sodcMJxc2GkV8IO84HbsUP+xNipGKfGg+FMISKabzdJ838Axb60qRtZrp6ny7LluQE7lesHvvxQ==} engines: {node: '>=18'} - mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/54f8c2282d822ad02967a197bda36302a4e7b4a5: - resolution: {tarball: https://codeload.github.com/zardoy/mineflayer/tar.gz/54f8c2282d822ad02967a197bda36302a4e7b4a5} + mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/4fa1af9964cab91315d8d1ae02615f3039638828: + resolution: {tarball: https://codeload.github.com/zardoy/mineflayer/tar.gz/4fa1af9964cab91315d8d1ae02615f3039638828} version: 4.25.0 engines: {node: '>=18'} @@ -6874,6 +6892,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + param-case@3.0.4: resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} @@ -9258,6 +9279,9 @@ packages: resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==} engines: {node: '>=12.20'} + zod@3.24.1: + resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} + zustand@3.6.5: resolution: {integrity: sha512-/WfLJuXiEJimt61KGMHebrFBwckkCHGhAgVXTgPQHl6IMzjqm6MREb1OnDSnCRiSmRdhgdFCctceg6tSm79hiw==} engines: {node: '>=12.7.0'} @@ -13604,6 +13628,8 @@ snapshots: character-entities@2.0.2: {} + charenc@0.0.2: {} + check-error@1.0.3: dependencies: get-func-name: 2.0.2 @@ -13924,6 +13950,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crypt@0.0.2: {} + crypto-browserify@3.12.0: dependencies: browserify-cipher: 1.0.1 @@ -14125,6 +14153,12 @@ snapshots: deepmerge@4.3.1: {} + deepslate@0.23.5: + dependencies: + gl-matrix: 3.4.3 + md5: 2.3.0 + pako: 2.1.0 + default-browser-id@3.0.0: dependencies: bplist-parser: 0.2.0 @@ -15462,6 +15496,8 @@ snapshots: github-slugger@1.5.0: {} + gl-matrix@3.4.3: {} + gl@6.0.2: dependencies: bindings: 1.5.0 @@ -16643,14 +16679,16 @@ snapshots: math-intrinsics@1.1.0: {} - mc-assets@0.2.31: {} + mc-assets@0.2.34: + dependencies: + zod: 3.24.1 - mcraft-fun-mineflayer@0.0.3(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/54f8c2282d822ad02967a197bda36302a4e7b4a5(encoding@0.1.13)): + mcraft-fun-mineflayer@0.0.3(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/4fa1af9964cab91315d8d1ae02615f3039638828(encoding@0.1.13)): dependencies: '@zardoy/flying-squid': 0.0.49(encoding@0.1.13) exit-hook: 2.2.1 minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/e9eb551ba30ec2e742c49e6927be6402b413bb76(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13) - mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/54f8c2282d822ad02967a197bda36302a4e7b4a5(encoding@0.1.13) + mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/4fa1af9964cab91315d8d1ae02615f3039638828(encoding@0.1.13) prismarine-item: 1.16.0 ws: 8.18.0 transitivePeerDependencies: @@ -16667,6 +16705,12 @@ snapshots: inherits: 2.0.4 safe-buffer: 5.2.1 + md5@2.3.0: + dependencies: + charenc: 0.0.2 + crypt: 0.0.2 + is-buffer: 1.1.6 + mdast-util-definitions@4.0.0: dependencies: unist-util-visit: 2.0.3 @@ -17042,7 +17086,7 @@ snapshots: - encoding - supports-color - mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/54f8c2282d822ad02967a197bda36302a4e7b4a5(encoding@0.1.13): + mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/4fa1af9964cab91315d8d1ae02615f3039638828(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=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13) @@ -17556,6 +17600,8 @@ snapshots: pako@1.0.11: {} + pako@2.1.0: {} + param-case@3.0.4: dependencies: dot-case: 3.0.4 @@ -20407,6 +20453,8 @@ snapshots: yocto-queue@1.0.0: {} + zod@3.24.1: {} + zustand@3.6.5(react@18.2.0): optionalDependencies: react: 18.2.0 diff --git a/renderer/buildMesherWorker.mjs b/renderer/buildMesherWorker.mjs index 03b952b4..2f9a4a02 100644 --- a/renderer/buildMesherWorker.mjs +++ b/renderer/buildMesherWorker.mjs @@ -28,6 +28,7 @@ const buildOptions = { 'debugger' ] : [], sourcemap: 'linked', + target: watch ? undefined : ['ios14'], write: false, metafile: true, outdir: path.join(__dirname, './dist'), diff --git a/renderer/rsbuild.config.ts b/renderer/rsbuild.config.ts index d1cd681c..2b40e79c 100644 --- a/renderer/rsbuild.config.ts +++ b/renderer/rsbuild.config.ts @@ -1,14 +1,19 @@ -import { defineConfig, mergeRsbuildConfig } from '@rsbuild/core'; +import { defineConfig, mergeRsbuildConfig, RsbuildPluginAPI } from '@rsbuild/core'; import supportedVersions from '../src/supportedVersions.mjs' import childProcess from 'child_process' import path, { dirname, join } from 'path' import { pluginReact } from '@rsbuild/plugin-react'; import { pluginNodePolyfill } from '@rsbuild/plugin-node-polyfill'; import fs from 'fs' +import fsExtra from 'fs-extra' import { appAndRendererSharedConfig, rspackViewerConfig } from './rsbuildSharedConfig'; const mcDataPath = join(__dirname, '../generated/minecraft-data-optimized.json') +// if (!fs.existsSync('./playground/textures')) { +// fsExtra.copySync('node_modules/mc-assets/dist/other-textures/latest/entity', './playground/textures/entity') +// } + if (!fs.existsSync(mcDataPath)) { childProcess.execSync('tsx ./scripts/makeOptimizedMcData.mjs', { stdio: 'inherit', cwd: path.join(__dirname, '..') }) } @@ -36,5 +41,19 @@ export default mergeRsbuildConfig( 'globalThis.includedVersions': JSON.stringify(supportedVersions), }, }, + plugins: [ + { + name: 'test', + setup (build: RsbuildPluginAPI) { + const prep = async () => { + fsExtra.copySync(join(__dirname, '../node_modules/mc-assets/dist/other-textures/latest/entity'), join(__dirname, './dist/textures/entity')) + } + build.onBeforeBuild(async () => { + await prep() + }) + build.onBeforeStartDevServer(() => prep()) + }, + }, + ], }) ) diff --git a/renderer/viewer/lib/DebugGui.ts b/renderer/viewer/lib/DebugGui.ts new file mode 100644 index 00000000..f296c873 --- /dev/null +++ b/renderer/viewer/lib/DebugGui.ts @@ -0,0 +1,174 @@ +// eslint-disable-next-line import/no-named-as-default +import GUI from 'lil-gui' + +export interface ParamMeta { + min?: number + max?: number + step?: number +} + +export class DebugGui { + private gui: GUI + private readonly storageKey: string + private target: any + private readonly params: string[] + private readonly paramsMeta: Record + private _visible = false // Default to not visible + private readonly initialValues: Record = {} // Store initial values + private initialized = false + + constructor (id: string, target: any, params?: string[], paramsMeta?: Record) { + this.gui = new GUI() + this.storageKey = `debug_params_${id}` + this.target = target + this.paramsMeta = paramsMeta ?? {} + this.params = params ?? Object.keys(target) + + // Store initial values + for (const param of this.params) { + this.initialValues[param] = target[param] + } + + // Hide by default + this.gui.domElement.style.display = 'none' + } + + // Initialize and show the GUI + activate () { + if (!this.initialized) { + this.loadSavedValues() + this.setupControls() + this.initialized = true + } + this.show() + return this + } + + // Getter for visibility + get visible (): boolean { + return this._visible + } + + // Setter for visibility + set visible (value: boolean) { + this._visible = value + this.gui.domElement.style.display = value ? 'block' : 'none' + this.saveVisibility() + } + + private loadSavedValues () { + try { + const saved = localStorage.getItem(this.storageKey) + if (saved) { + const values = JSON.parse(saved) + // Apply saved values to target + for (const param of this.params) { + if (param in values) { + const value = values[param] + if (value !== null) { + this.target[param] = value + } + } + } + } + } catch (e) { + console.warn('Failed to load debug values:', e) + } + } + + private saveValues (deleteKey = false) { + try { + const values = {} + for (const param of this.params) { + values[param] = this.target[param] + } + if (deleteKey) { + localStorage.removeItem(this.storageKey) + } else { + localStorage.setItem(this.storageKey, JSON.stringify(values)) + } + } catch (e) { + console.warn('Failed to save debug values:', e) + } + } + + private saveVisibility () { + try { + localStorage.setItem(`${this.storageKey}_visible`, this._visible.toString()) + } catch (e) { + console.warn('Failed to save debug visibility:', e) + } + } + + private setupControls () { + // Add visibility toggle at the top + this.gui.add(this, 'visible').name('Show Controls') + this.gui.add({ resetAll: () => { + for (const param of this.params) { + this.target[param] = this.initialValues[param] + } + this.saveValues(true) + this.gui.destroy() + this.gui = new GUI() + this.setupControls() + } }, 'resetAll').name('Reset All Parameters') + + for (const param of this.params) { + const value = this.target[param] + const meta = this.paramsMeta[param] ?? {} + + if (typeof value === 'number') { + // For numbers, use meta values or calculate reasonable defaults + const min = meta.min ?? value - Math.abs(value * 2) + const max = meta.max ?? value + Math.abs(value * 2) + const step = meta.step ?? Math.abs(value) / 100 + + this.gui.add(this.target, param, min, max, step) + .onChange(() => this.saveValues()) + } else if (typeof value === 'boolean') { + // For booleans, create a checkbox + this.gui.add(this.target, param) + .onChange(() => this.saveValues()) + } else if (typeof value === 'string' && ['x', 'y', 'z'].includes(param)) { + // Special case for xyz coordinates + const min = meta.min ?? -10 + const max = meta.max ?? 10 + const step = meta.step ?? 0.1 + + this.gui.add(this.target, param, min, max, step) + .onChange(() => this.saveValues()) + } else if (Array.isArray(value)) { + // For arrays, create a dropdown + this.gui.add(this.target, param, value) + .onChange(() => this.saveValues()) + } + } + } + + // Method to manually trigger save + save () { + this.saveValues() + this.saveVisibility() + } + + // Method to destroy the GUI and clean up + destroy () { + this.saveVisibility() + this.gui.destroy() + } + + // Toggle visibility + toggle () { + this.visible = !this.visible + } + + // Show the GUI + show () { + this.visible = true + } + + // Hide the GUI + hide () { + this.visible = false + } +} diff --git a/renderer/viewer/lib/animationController.ts b/renderer/viewer/lib/animationController.ts new file mode 100644 index 00000000..bfccc72f --- /dev/null +++ b/renderer/viewer/lib/animationController.ts @@ -0,0 +1,75 @@ +import * as tweenJs from '@tweenjs/tween.js' + +export class AnimationController { + private currentAnimation: tweenJs.Group | null = null + private isAnimating = false + private cancelRequested = false + private completionCallbacks: Array<() => void> = [] + + /** Main method */ + async startAnimation (createAnimation: () => tweenJs.Group): Promise { + if (this.isAnimating) { + await this.cancelCurrentAnimation() + } + + return new Promise((resolve) => { + this.isAnimating = true + this.cancelRequested = false + this.currentAnimation = createAnimation() + + this.completionCallbacks.push(() => { + this.isAnimating = false + this.currentAnimation = null + resolve() + }) + }) + } + + /** Main method */ + async cancelCurrentAnimation (): Promise { + if (!this.isAnimating) return + + return new Promise((resolve) => { + this.cancelRequested = true + this.completionCallbacks.push(() => { + resolve() + }) + }) + } + + animationCycleFinish () { + if (this.cancelRequested) this.forceFinish() + } + + forceFinish () { + if (!this.isAnimating) return + + if (this.currentAnimation) { + for (const tween of this.currentAnimation.getAll()) tween.stop() + this.currentAnimation.removeAll() + this.currentAnimation = null + } + + this.isAnimating = false + this.cancelRequested = false + + const callbacks = [...this.completionCallbacks] + this.completionCallbacks = [] + for (const cb of callbacks) cb() + } + + /** Required method */ + update () { + if (this.currentAnimation) { + this.currentAnimation.update() + } + } + + get isActive () { + return this.isAnimating + } + + get shouldCancel () { + return this.cancelRequested + } +} diff --git a/renderer/viewer/lib/basePlayerState.ts b/renderer/viewer/lib/basePlayerState.ts new file mode 100644 index 00000000..ac36ffec --- /dev/null +++ b/renderer/viewer/lib/basePlayerState.ts @@ -0,0 +1,94 @@ +import { EventEmitter } from 'events' +import { Vec3 } from 'vec3' +import TypedEmitter from 'typed-emitter' +import { ItemSelector } from 'mc-assets/dist/itemDefinitions' +import { proxy } from 'valtio' +import { HandItemBlock } from './holdingBlock' + +export type MovementState = 'NOT_MOVING' | 'WALKING' | 'SPRINTING' | 'SNEAKING' +export type ItemSpecificContextProperties = Partial> + + +export type PlayerStateEvents = { + heldItemChanged: (item: HandItemBlock | undefined, isLeftHand: boolean) => void +} + +export interface IPlayerState { + getEyeHeight(): number + getMovementState(): MovementState + getVelocity(): Vec3 + isOnGround(): boolean + isSneaking(): boolean + isFlying(): boolean + isSprinting (): boolean + getItemUsageTicks?(): number + // isUsingItem?(): boolean + getHeldItem?(isLeftHand: boolean): HandItemBlock | undefined + username?: string + onlineMode?: boolean + + events: TypedEmitter + + reactive: { + playerSkin: string | undefined + } +} + +export class BasePlayerState implements IPlayerState { + getItemUsageTicks? (): number { + throw new Error('Method not implemented.') + } + getHeldItem? (isLeftHand: boolean): HandItemBlock | undefined { + throw new Error('Method not implemented.') + } + reactive = proxy({ + playerSkin: undefined + }) + protected movementState: MovementState = 'NOT_MOVING' + protected velocity = new Vec3(0, 0, 0) + protected onGround = true + protected sneaking = false + protected flying = false + protected sprinting = false + readonly events = new EventEmitter() as TypedEmitter + + getEyeHeight (): number { + return 1.62 + } + + getMovementState (): MovementState { + return this.movementState + } + + getVelocity (): Vec3 { + return this.velocity + } + + isOnGround (): boolean { + return this.onGround + } + + isSneaking (): boolean { + return this.sneaking + } + + isFlying (): boolean { + return this.flying + } + + isSprinting (): boolean { + return this.sprinting + } + + // For testing purposes + setState (state: Partial<{ + movementState: MovementState + velocity: Vec3 + onGround: boolean + sneaking: boolean + flying: boolean + sprinting: boolean + }>) { + Object.assign(this, state) + } +} diff --git a/renderer/viewer/lib/cameraBobbing.ts b/renderer/viewer/lib/cameraBobbing.ts new file mode 100644 index 00000000..6bf32c76 --- /dev/null +++ b/renderer/viewer/lib/cameraBobbing.ts @@ -0,0 +1,94 @@ +export class CameraBobbing { + private walkDistance = 0 + private prevWalkDistance = 0 + private bobAmount = 0 + private prevBobAmount = 0 + private readonly gameTimer = new GameTimer() + + // eslint-disable-next-line max-params + constructor ( + private readonly BOB_FREQUENCY: number = Math.PI, // How fast the bob cycles + private readonly BOB_BASE_AMPLITUDE: number = 0.5, // Base amplitude of the bob + private readonly VERTICAL_MULTIPLIER: number = 1, // Vertical movement multiplier + private readonly ROTATION_MULTIPLIER_Z: number = 3, // Roll rotation multiplier + private readonly ROTATION_MULTIPLIER_X: number = 5 // Pitch rotation multiplier + ) {} + + // Call this when player is moving + public updateWalkDistance (distance: number): void { + this.prevWalkDistance = this.walkDistance + this.walkDistance = distance + } + + // Call this when player is moving to update bob amount + public updateBobAmount (isMoving: boolean): void { + const targetBob = isMoving ? 1 : 0 + this.prevBobAmount = this.bobAmount + + // Update timing + const ticks = this.gameTimer.update() + const deltaTime = ticks / 20 // Convert ticks to seconds assuming 20 TPS + + // Smooth transition for bob amount + const bobDelta = (targetBob - this.bobAmount) * Math.min(1, deltaTime * 10) + this.bobAmount += bobDelta + } + + // Call this in your render/animation loop + public getBobbing (): { position: { x: number, y: number }, rotation: { x: number, z: number } } { + // Interpolate walk distance + const walkDist = this.prevWalkDistance + + (this.walkDistance - this.prevWalkDistance) * this.gameTimer.partialTick + + // Interpolate bob amount + const bob = this.prevBobAmount + + (this.bobAmount - this.prevBobAmount) * this.gameTimer.partialTick + + // Calculate total distance for bob cycle + const totalDist = -(walkDist * this.BOB_FREQUENCY) + + // Calculate offsets + const xOffset = Math.sin(totalDist) * bob * this.BOB_BASE_AMPLITUDE + const yOffset = -Math.abs(Math.cos(totalDist) * bob) * this.VERTICAL_MULTIPLIER + + // Calculate rotations (in radians) + const zRot = (Math.sin(totalDist) * bob * this.ROTATION_MULTIPLIER_Z) * (Math.PI / 180) + const xRot = (Math.abs(Math.cos(totalDist - 0.2) * bob) * this.ROTATION_MULTIPLIER_X) * (Math.PI / 180) + + return { + position: { x: xOffset, y: yOffset }, + rotation: { x: xRot, z: zRot } + } + } +} + +class GameTimer { + private readonly msPerTick: number + private lastMs: number + public partialTick = 0 + + constructor (tickRate = 20) { + this.msPerTick = 1000 / tickRate + this.lastMs = performance.now() + } + + update (): number { + const currentMs = performance.now() + const deltaSinceLastTick = currentMs - this.lastMs + + // Calculate how much of a tick has passed + const tickDelta = deltaSinceLastTick / this.msPerTick + this.lastMs = currentMs + + // Add to accumulated partial ticks + this.partialTick += tickDelta + + // Get whole number of ticks that should occur + const wholeTicks = Math.floor(this.partialTick) + + // Keep the remainder as the new partial tick + this.partialTick -= wholeTicks + + return wholeTicks + } +} diff --git a/renderer/viewer/lib/entities.ts b/renderer/viewer/lib/entities.ts index 72969840..dc918173 100644 --- a/renderer/viewer/lib/entities.ts +++ b/renderer/viewer/lib/entities.ts @@ -7,7 +7,6 @@ import * as THREE from 'three' import { PlayerObject, PlayerAnimation } from 'skinview3d' import { loadSkinToCanvas, loadEarsToCanvasFromSkin, inferModelType, loadCapeToCanvas, loadImage } from 'skinview-utils' // todo replace with url -import stevePng from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png' import { degreesToRadians } from '@nxg-org/mineflayer-tracker/lib/mathUtils' import { NameTagObject } from 'skinview3d/libs/nametag' import { flat, fromFormattedString } from '@xmcl/text-component' @@ -23,6 +22,8 @@ import { disposeObject } from './threeJsUtils' import { armorModel, armorTextures } from './entity/armorModels' import { Viewer } from './viewer' import { getBlockMeshFromModel } from './holdingBlock' +import { ItemSpecificContextProperties } from './basePlayerState' +import { loadSkinImage, getLookupUrl, stevePngUrl, steveTexture } from './utils/skins' const { loadTexture } = globalThis.isElectron ? require('./utils.electron.js') : require('./utils') export const TWEEN_DURATION = 120 @@ -217,7 +218,7 @@ export class Entities extends EventEmitter { itemsTexture: THREE.Texture | null = null cachedMapsImages = {} as Record itemFrameMaps = {} as Record>> - getItemUv: undefined | ((item: Record) => { + getItemUv: undefined | ((item: Record, specificProps: ItemSpecificContextProperties) => { texture: THREE.Texture; u: number; v: number; @@ -229,6 +230,20 @@ export class Entities extends EventEmitter { modelName: string }) + get entitiesByName (): Record { + const byName: Record = {} + for (const entity of Object.values(this.entities)) { + if (!entity['realName']) continue + byName[entity['realName']] = byName[entity['realName']] || [] + byName[entity['realName']].push(entity) + } + return byName + } + + get entitiesRenderingCount (): number { + return Object.values(this.entities).filter(entity => entity.visible).length + } + constructor (public viewer: Viewer) { super() this.entitiesOptions = {} @@ -289,12 +304,12 @@ export class Entities extends EventEmitter { const distanceSquared = dx * dx + dy * dy + dz * dz // Get chunk coordinates - const chunkX = Math.floor(entity.position.x / 16) - const chunkZ = Math.floor(entity.position.z / 16) + const chunkX = Math.floor(entity.position.x / 16) * 16 + const chunkZ = Math.floor(entity.position.z / 16) * 16 const chunkKey = `${chunkX},${chunkZ}` // Entity is visible if within 16 blocks OR in a finished chunk - entity.visible = distanceSquared < VISIBLE_DISTANCE || this.viewer.world.finishedChunks[chunkKey] + entity.visible = !!(distanceSquared < VISIBLE_DISTANCE || this.viewer.world.finishedChunks[chunkKey]) } } } @@ -304,12 +319,15 @@ export class Entities extends EventEmitter { return playerObject } - // fixme workaround - defaultSteveTexture - uuidPerSkinUrlsCache = {} as Record - // true means use default skin url + private isCanvasBlank (canvas: HTMLCanvasElement): boolean { + return !canvas.getContext('2d') + ?.getImageData(0, 0, canvas.width, canvas.height).data + .some(channel => channel !== 0) + } + + // eslint-disable-next-line max-params updatePlayerSkin (entityId: string | number, username: string | undefined, uuid: string | undefined, skinUrl: string | true, capeUrl: string | true | undefined = undefined) { if (uuid) { if (typeof skinUrl === 'string' || typeof capeUrl === 'string') this.uuidPerSkinUrlsCache[uuid] = {} @@ -321,38 +339,68 @@ export class Entities extends EventEmitter { capeUrl ??= this.uuidPerSkinUrlsCache[uuid]?.capeUrl } - let playerObject = this.getPlayerObject(entityId) + const playerObject = this.getPlayerObject(entityId) if (!playerObject) return - // const username = this.entities[entityId].username - // or https://mulv.vercel.app/ + if (skinUrl === true) { - skinUrl = `https://mulv.tycrek.dev/api/lookup?username=${username}&type=skin` if (!username) return + skinUrl = getLookupUrl(username, 'skin') } - loadImage(skinUrl).then(image => { - playerObject = this.getPlayerObject(entityId) - if (!playerObject) return - /** @type {THREE.CanvasTexture} */ - let skinTexture - if (skinUrl === stevePng && this.defaultSteveTexture) { - skinTexture = this.defaultSteveTexture - } else { - const skinCanvas = document.createElement('canvas') - loadSkinToCanvas(skinCanvas, image) - skinTexture = new THREE.CanvasTexture(skinCanvas) - if (skinUrl === stevePng) { - this.defaultSteveTexture = skinTexture + + if (typeof skinUrl !== 'string') throw new Error('Invalid skin url') + void this.loadAndApplySkin(entityId, skinUrl).then(() => { + if (capeUrl) { + if (capeUrl === true && username) { + capeUrl = getLookupUrl(username, 'cape') + } + if (typeof capeUrl === 'string') { + void this.loadAndApplyCape(entityId, capeUrl) } } + }) + + + playerObject.cape.visible = false + if (!capeUrl) { + playerObject.backEquipment = null + playerObject.elytra.map = null + if (playerObject.cape.map) { + playerObject.cape.map.dispose() + } + playerObject.cape.map = null + } + } + + private async loadAndApplySkin (entityId: string | number, skinUrl: string) { + let playerObject = this.getPlayerObject(entityId) + if (!playerObject) return + + try { + let playerCustomSkinImage: HTMLImageElement | undefined + + playerObject = this.getPlayerObject(entityId) + if (!playerObject) return + + let skinTexture: THREE.Texture + if (skinUrl === stevePngUrl) { + skinTexture = await steveTexture + } else { + const { canvas: skinCanvas, image } = await loadSkinImage(skinUrl) + playerCustomSkinImage = image + skinTexture = new THREE.CanvasTexture(skinCanvas) + } + skinTexture.magFilter = THREE.NearestFilter skinTexture.minFilter = THREE.NearestFilter skinTexture.needsUpdate = true - playerObject.skin.map = skinTexture + playerObject.skin.map = skinTexture as any playerObject.skin.modelType = inferModelType(skinTexture.image) const earsCanvas = document.createElement('canvas') - loadEarsToCanvasFromSkin(earsCanvas, image) - if (isCanvasBlank(earsCanvas)) { + if (playerCustomSkinImage) { + loadEarsToCanvasFromSkin(earsCanvas, playerCustomSkinImage) + } + if (this.isCanvasBlank(earsCanvas)) { playerObject.ears.map = null playerObject.ears.visible = false } else { @@ -365,47 +413,38 @@ export class Entities extends EventEmitter { playerObject.ears.visible = true } this.onSkinUpdate?.() - if (capeUrl) { - if (capeUrl === true) capeUrl = `https://mulv.tycrek.dev/api/lookup?username=${username}&type=cape` - loadImage(capeUrl).then(capeImage => { - playerObject = this.getPlayerObject(entityId) - if (!playerObject) return - const capeCanvas = document.createElement('canvas') - loadCapeToCanvas(capeCanvas, capeImage) - - const capeTexture = new THREE.CanvasTexture(capeCanvas) - capeTexture.magFilter = THREE.NearestFilter - capeTexture.minFilter = THREE.NearestFilter - capeTexture.needsUpdate = true - //@ts-expect-error - playerObject.cape.map = capeTexture - playerObject.cape.visible = true - //@ts-expect-error - playerObject.elytra.map = capeTexture - this.onSkinUpdate?.() - - if (!playerObject.backEquipment) { - playerObject.backEquipment = 'cape' - } - }, () => { }) - } - }, () => { }) - - - playerObject.cape.visible = false - if (!capeUrl) { - playerObject.backEquipment = null - playerObject.elytra.map = null - if (playerObject.cape.map) { - playerObject.cape.map.dispose() - } - playerObject.cape.map = null + } catch (error) { + console.error('Error loading skin:', error) } + } - function isCanvasBlank (canvas) { - return !canvas.getContext('2d') - .getImageData(0, 0, canvas.width, canvas.height).data - .some(channel => channel !== 0) + private async loadAndApplyCape (entityId: string | number, capeUrl: string) { + let playerObject = this.getPlayerObject(entityId) + if (!playerObject) return + + try { + const { canvas: capeCanvas, image: capeImage } = await loadSkinImage(capeUrl) + + playerObject = this.getPlayerObject(entityId) + if (!playerObject) return + + loadCapeToCanvas(capeCanvas, capeImage) + const capeTexture = new THREE.CanvasTexture(capeCanvas) + capeTexture.magFilter = THREE.NearestFilter + capeTexture.minFilter = THREE.NearestFilter + capeTexture.needsUpdate = true + //@ts-expect-error + playerObject.cape.map = capeTexture + playerObject.cape.visible = true + //@ts-expect-error + playerObject.elytra.map = capeTexture + this.onSkinUpdate?.() + + if (!playerObject.backEquipment) { + playerObject.backEquipment = 'cape' + } + } catch (error) { + console.error('Error loading cape:', error) } } @@ -447,11 +486,11 @@ export class Entities extends EventEmitter { return typeof component === 'string' ? component : component.text ?? '' } - getItemMesh (item, isDropped = false) { - const textureUv = this.getItemUv?.(item) + getItemMesh (item, specificProps: ItemSpecificContextProperties) { + const textureUv = this.getItemUv?.(item, specificProps) if (textureUv && 'resolvedModel' in textureUv) { const mesh = getBlockMeshFromModel(this.viewer.world.material, textureUv.resolvedModel, textureUv.modelName) - if (isDropped) { + if (specificProps['minecraft:display_context'] === 'ground') { const SCALE = 0.5 mesh.scale.set(SCALE, SCALE, SCALE) mesh.position.set(0, 0.2, 0) @@ -547,7 +586,9 @@ export class Entities extends EventEmitter { if (entity.name === 'item') { const item = entity.metadata?.find((m: any) => typeof m === 'object' && m?.itemCount) if (item) { - const object = this.getItemMesh(item, true) + const object = this.getItemMesh(item, { + 'minecraft:display_context': 'ground', + }) if (object) { mesh = object.mesh mesh.scale.set(0.5, 0.5, 0.5) @@ -627,12 +668,14 @@ export class Entities extends EventEmitter { this.viewer.scene.add(group) e = group + e.name = 'entity' + e['realName'] = entity.name this.entities[entity.id] = e this.emit('add', entity) if (isPlayerModel) { - this.updatePlayerSkin(entity.id, entity.username, entity.uuid, overrides?.texture || stevePng) + this.updatePlayerSkin(entity.id, entity.username, entity.uuid, overrides?.texture || stevePngUrl) } this.setDebugMode(this.debugMode, group) this.setRendering(this.rendering, group) @@ -781,7 +824,9 @@ export class Entities extends EventEmitter { mesh.scale.set(16 / 12, 16 / 12, 1) this.addMapModel(e, mapNumber, rotation) } else { - const itemMesh = this.getItemMesh(item) + const itemMesh = this.getItemMesh(item, { + 'minecraft:display_context': 'fixed', + }) if (itemMesh) { itemMesh.mesh.position.set(0, 0, 0.43) itemMesh.mesh.scale.set(0.5, 0.5, 0.5) @@ -945,7 +990,7 @@ function addArmorModel (entityMesh: THREE.Object3D, slotType: string, item: Item console.error('Error decoding player head texture:', err) } } else { - texturePath = stevePng + texturePath = stevePngUrl } } const armorMaterial = itemParts[0] diff --git a/renderer/viewer/lib/entity/EntityMesh.ts b/renderer/viewer/lib/entity/EntityMesh.ts index 37a77e3d..1f38f471 100644 --- a/renderer/viewer/lib/entity/EntityMesh.ts +++ b/renderer/viewer/lib/entity/EntityMesh.ts @@ -2,6 +2,8 @@ import * as THREE from 'three' import { OBJLoader } from 'three-stdlib' import huskPng from 'mc-assets/dist/other-textures/latest/entity/zombie/husk.png' import { Vec3 } from 'vec3' +import spectralArrowTexture from '../../../../node_modules/mc-assets/dist/other-textures/1.21.2/entity/projectiles/spectral_arrow.png' +import tippedArrowTexture from '../../../../node_modules/mc-assets/dist/other-textures/1.21.2/entity/projectiles/tipped_arrow.png' import { WorldRendererCommon } from '../worldrendererCommon' import entities from './entities.json' import { externalModels } from './objModels' @@ -457,6 +459,13 @@ export class EntityMesh { return } + // if (originalType === 'arrow') { + // overrides.textures = { + // 'default': arrowTexture, + // ...overrides.textures, + // } + // } + const e = getEntity(type) if (!e) { if (knownNotHandled.includes(type)) return diff --git a/renderer/viewer/lib/hand.ts b/renderer/viewer/lib/hand.ts index 6c9aacc5..3743aca9 100644 --- a/renderer/viewer/lib/hand.ts +++ b/renderer/viewer/lib/hand.ts @@ -1,32 +1,21 @@ import * as THREE from 'three' import { loadSkinToCanvas } from 'skinview-utils' -import stevePng from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png' +import { getLookupUrl, loadSkinImage, steveTexture } from './utils/skins' -let steveTexture: THREE.Texture -export const getMyHand = async (image?: string) => { +export const getMyHand = async (image?: string, userName?: string) => { let newMap: THREE.Texture - if (!image && steveTexture) { - newMap = steveTexture + if (!image && !userName) { + newMap = await steveTexture } else { - image ??= stevePng - const skinCanvas = document.createElement('canvas') - const img = new Image() - img.src = image - await new Promise(resolve => { - img.onload = () => { - resolve() - } - }) - loadSkinToCanvas(skinCanvas, img) - newMap = new THREE.CanvasTexture(skinCanvas) - // newMap.flipY = false - newMap.magFilter = THREE.NearestFilter - newMap.minFilter = THREE.NearestFilter if (!image) { - steveTexture = newMap + image = getLookupUrl(userName!, 'skin') } + const { canvas } = await loadSkinImage(image) + newMap = new THREE.CanvasTexture(canvas) } + newMap.magFilter = THREE.NearestFilter + newMap.minFilter = THREE.NearestFilter // right arm const box = new THREE.BoxGeometry() const material = new THREE.MeshStandardMaterial() diff --git a/renderer/viewer/lib/holdingBlock.ts b/renderer/viewer/lib/holdingBlock.ts index 641a2813..2acab1a5 100644 --- a/renderer/viewer/lib/holdingBlock.ts +++ b/renderer/viewer/lib/holdingBlock.ts @@ -1,10 +1,15 @@ import * as THREE from 'three' import * as tweenJs from '@tweenjs/tween.js' import worldBlockProvider from 'mc-assets/dist/worldBlockProvider' -import { GUI } from 'lil-gui' import { BlockModel } from 'mc-assets' import { getThreeBlockModelGroup, renderBlockThree, setBlockPosition } from './mesher/standaloneRenderer' import { getMyHand } from './hand' +import { IPlayerState, MovementState } from './basePlayerState' +import { DebugGui } from './DebugGui' +import { SmoothSwitcher } from './smoothSwitcher' +import { watchProperty } from './utils/proxy' +import { disposeObject } from './threeJsUtils' +import { WorldRendererConfig } from './worldrendererCommon' export type HandItemBlock = { name? @@ -14,13 +19,83 @@ export type HandItemBlock = { id?: number } +const rotationPositionData = { + itemRight: { + 'rotation': [ + 0, + -90, + 25 + ], + 'translation': [ + 1.13, + 3.2, + 1.13 + ], + 'scale': [ + 0.68, + 0.68, + 0.68 + ] + }, + itemLeft: { + 'rotation': [ + 0, + 90, + -25 + ], + 'translation': [ + 1.13, + 3.2, + 1.13 + ], + 'scale': [ + 0.68, + 0.68, + 0.68 + ] + }, + blockRight: { + 'rotation': [ + 0, + 45, + 0 + ], + 'translation': [ + 0, + 0, + 0 + ], + 'scale': [ + 0.4, + 0.4, + 0.4 + ] + }, + blockLeft: { + 'rotation': [ + 0, + 225, + 0 + ], + 'translation': [ + 0, + 0, + 0 + ], + 'scale': [ + 0.4, + 0.4, + 0.4 + ] + } +} + export default class HoldingBlock { // TODO refactor with the tree builder for better visual understanding holdingBlock: THREE.Object3D | undefined = undefined - swingAnimation: tweenJs.Group | undefined = undefined blockSwapAnimation: { - tween: tweenJs.Group - hidden: boolean + switcher: SmoothSwitcher + // hidden: boolean } | undefined = undefined cameraGroup = new THREE.Mesh() objectOuterGroup = new THREE.Group() // 3 @@ -29,15 +104,55 @@ export default class HoldingBlock { camera = new THREE.PerspectiveCamera(75, 1, 0.1, 100) stopUpdate = false lastHeldItem: HandItemBlock | undefined - toBeRenderedItem: HandItemBlock | undefined isSwinging = false nextIterStopCallbacks: Array<() => void> | undefined - rightSide = true + idleAnimator: HandIdleAnimator | undefined + ready = false + lastUpdate = 0 + playerHand: THREE.Object3D | undefined + offHandDisplay = false + offHandModeLegacy = false - debug = {} as Record + swingAnimator: HandSwingAnimator | undefined - constructor () { + constructor (public playerState: IPlayerState, public config: WorldRendererConfig, public offHand = false) { this.initCameraGroup() + + this.playerState.events.on('heldItemChanged', (_, isOffHand) => { + if (this.offHand !== isOffHand) return + this.updateItem() + }) + + this.offHandDisplay = this.offHand + // this.offHandDisplay = true + if (!this.offHand) { + // watch over my hand + watchProperty( + async () => { + return getMyHand(this.playerState.reactive.playerSkin, this.playerState.onlineMode ? this.playerState.username : undefined) + }, + this.playerState.reactive, + 'playerSkin', + (newHand) => { + this.playerHand = newHand + }, + (oldHand) => { + disposeObject(oldHand, true) + } + ) + } + } + + updateItem () { + if (!this.ready || !this.playerState.getHeldItem) return + const item = this.playerState.getHeldItem(this.offHand) + if (item) { + void this.setNewItem(item) + } else if (!this.offHand) { + void this.setNewItem({ + type: 'hand', + }) + } } initCameraGroup () { @@ -45,117 +160,28 @@ export default class HoldingBlock { } startSwing () { - this.nextIterStopCallbacks = undefined // forget about cancelling - if (this.isSwinging) return - this.swingAnimation = new tweenJs.Group() - this.isSwinging = true - const cube = this.cameraGroup.children[0] - if (cube) { - // const DURATION = 1000 * 0.35 / 2 - const DURATION = 1000 * 0.35 / 3 - // const DURATION = 1000 - const { position, rotation, object } = this.getFinalSwingPositionRotation() - const initialPos = { - x: object.position.x, - y: object.position.y, - z: object.position.z - } - const initialRot = { - x: object.rotation.x, - y: object.rotation.y, - z: object.rotation.z - } - const mainAnim = new tweenJs.Tween(object.position, this.swingAnimation).to(position, DURATION).yoyo(true).repeat(Infinity).start() - let i = 0 - mainAnim.onRepeat(() => { - i++ - if (this.nextIterStopCallbacks && i % 2 === 0) { - for (const callback of this.nextIterStopCallbacks) { - callback() - } - this.nextIterStopCallbacks = undefined - this.isSwinging = false - this.swingAnimation!.removeAll() - this.swingAnimation = undefined - // todo refactor to be more generic for animations - object.position.set(initialPos.x, initialPos.y, initialPos.z) - // object.rotation.set(initialRot.x, initialRot.y, initialRot.z) - Object.assign(object.rotation, initialRot) - } - }) - - new tweenJs.Tween(object.rotation, this.swingAnimation).to(rotation, DURATION).yoyo(true).repeat(Infinity).start() - } + this.swingAnimator?.startSwing() } - getFinalSwingPositionRotation (origPosition?: THREE.Vector3) { - const object = this.objectInnerGroup - if (this.lastHeldItem?.type === 'block') { - origPosition ??= object.position - return { - position: { y: origPosition.y - this.objectInnerGroup.scale.y / 2 }, - rotation: { z: THREE.MathUtils.degToRad(90), x: -THREE.MathUtils.degToRad(90) }, - object - } - } - if (this.lastHeldItem?.type === 'item') { - const object = this.holdingBlockInnerGroup - origPosition ??= object.position - return { - position: { - y: origPosition.y - object.scale.y * 2, - // z: origPosition.z - window.zFinal, - // x: origPosition.x - window.xFinal, - }, - // rotation: { z: THREE.MathUtils.degToRad(90), x: -THREE.MathUtils.degToRad(90) } - rotation: { - // z: THREE.MathUtils.degToRad(window.zRotationFinal ?? 0), - // x: THREE.MathUtils.degToRad(window.xRotationFinal ?? 0), - // y: THREE.MathUtils.degToRad(window.yRotationFinal ?? 0), - x: THREE.MathUtils.degToRad(-120) - }, - object - } - } - if (this.lastHeldItem?.type === 'hand') { - const object = this.holdingBlockInnerGroup - origPosition ??= object.position - return { - position: { - y: origPosition.y - (window.yFinal ?? 0.15), - z: origPosition.z - window.zFinal, - x: origPosition.x - window.xFinal, - }, - rotation: { - x: THREE.MathUtils.degToRad(window.xRotationFinal || -14.7), - y: THREE.MathUtils.degToRad(window.yRotationFinal || 33.95), - z: THREE.MathUtils.degToRad(window.zRotationFinal || -28), - }, - object - } - } - return { - position: {}, - rotation: {}, - object - } - } - - async stopSwing () { - if (!this.isSwinging) return - // might never resolve! - /* return */void new Promise((resolve) => { - this.nextIterStopCallbacks ??= [] - this.nextIterStopCallbacks.push(() => { - resolve() - }) - }) + stopSwing () { + this.swingAnimator?.stopSwing() } render (originalCamera: THREE.PerspectiveCamera, renderer: THREE.WebGLRenderer, ambientLight: THREE.AmbientLight, directionalLight: THREE.DirectionalLight) { if (!this.lastHeldItem) return - this.swingAnimation?.update() - this.blockSwapAnimation?.tween.update() + const now = performance.now() + if (this.lastUpdate && now - this.lastUpdate > 50) { // one tick + void this.replaceItemModel(this.lastHeldItem) + } + + // Only update idle animation if not swinging + if (this.swingAnimator?.isCurrentlySwinging() || this.swingAnimator?.debugParams.animationStage) { + this.swingAnimator?.update() + } else { + this.idleAnimator?.update() + } + + this.blockSwapAnimation?.switcher.update() const scene = new THREE.Scene() scene.add(this.cameraGroup) @@ -169,18 +195,30 @@ export default class HoldingBlock { const viewerSize = renderer.getSize(new THREE.Vector2()) const minSize = Math.min(viewerSize.width, viewerSize.height) + const x = viewerSize.width - minSize + + // Mirror the scene for offhand by scaling + const { offHandDisplay } = this + if (offHandDisplay) { + this.cameraGroup.scale.x = -1 + } renderer.autoClear = false renderer.clearDepth() - if (this.rightSide) { + if (this.offHandDisplay) { + renderer.setViewport(0, 0, minSize, minSize) + } else { const x = viewerSize.width - minSize // if (x) x -= x / 4 renderer.setViewport(x, 0, minSize, minSize) - } else { - renderer.setViewport(0, 0, minSize, minSize) } renderer.render(scene, this.camera) renderer.setViewport(0, 0, viewerSize.width, viewerSize.height) + + // Reset the mirroring after rendering + if (offHandDisplay) { + this.cameraGroup.scale.x = 1 + } } // worldTest () { @@ -197,30 +235,46 @@ export default class HoldingBlock { // new tweenJs.Tween(group.rotation).to({ z: THREE.MathUtils.degToRad(90) }, 1000).yoyo(true).repeat(Infinity).start() // } - async playBlockSwapAnimation () { - // if (this.blockSwapAnimation) return + async playBlockSwapAnimation (forceState?: 'appeared' | 'disappeared') { this.blockSwapAnimation ??= { - tween: new tweenJs.Group(), - hidden: false - } - const DURATION = 1000 * 0.35 / 2 - const tween = new tweenJs.Tween(this.objectInnerGroup.position, this.blockSwapAnimation.tween).to({ - y: this.objectInnerGroup.position.y + (this.objectInnerGroup.scale.y * 1.5 * (this.blockSwapAnimation.hidden ? 1 : -1)) - }, DURATION).start() - return new Promise((resolve) => { - tween.onComplete(() => { - if (this.blockSwapAnimation!.hidden) { - this.blockSwapAnimation = undefined - } else { - this.blockSwapAnimation!.hidden = !this.blockSwapAnimation!.hidden + switcher: new SmoothSwitcher( + () => ({ + y: this.objectInnerGroup.position.y + }), + (property, value) => { + if (property === 'y') this.objectInnerGroup.position.y = value + }, + { + y: 16 // units per second } - resolve() - }) + ) + } + + const newState = this.blockSwapAnimation.switcher.currentStateName === 'disappeared' ? 'appeared' : 'disappeared' + if (forceState && newState !== forceState) throw new Error(`forceState does not match current state ${newState} !== ${forceState}`) + + const targetY = this.objectInnerGroup.position.y + (this.objectInnerGroup.scale.y * 1.5 * (newState === 'appeared' ? 1 : -1)) + + if (newState === this.blockSwapAnimation.switcher.transitioningToStateName) { + return false + } + + return new Promise((resolve) => { + this.blockSwapAnimation!.switcher.transitionTo( + { y: targetY }, + newState, + () => { + resolve(true) + }, + () => { + resolve(false) + } + ) }) } isDifferentItem (block: HandItemBlock | undefined) { - return this.lastHeldItem && (this.lastHeldItem.name !== block?.name || JSON.stringify(this.lastHeldItem.properties) !== JSON.stringify(block?.properties ?? '{}')) + return !this.lastHeldItem || (this.lastHeldItem.name !== block?.name || JSON.stringify(this.lastHeldItem.fullItem) !== JSON.stringify(block?.fullItem ?? '{}')) } updateCameraGroup () { @@ -237,7 +291,7 @@ export default class HoldingBlock { // Adjust the position based on the aspect ratio const { position, scale: scaleData } = this.getHandHeld3d() const distance = -position.z - const side = this.rightSide ? 1 : -1 + const side = this.offHandModeLegacy ? -1 : 1 this.objectOuterGroup.position.set( distance * position.x * aspect * side, distance * position.y, @@ -249,27 +303,19 @@ export default class HoldingBlock { this.objectOuterGroup.scale.set(scale, scale, scale) } - async initHandObject (handItem?: HandItemBlock) { - let animatingCurrent = false - if (!this.swingAnimation && !this.blockSwapAnimation && this.isDifferentItem(handItem)) { - animatingCurrent = true - await this.playBlockSwapAnimation() - this.holdingBlock?.removeFromParent() - this.holdingBlock = undefined - } - this.lastHeldItem = handItem - if (!handItem) { - this.holdingBlock?.removeFromParent() - this.holdingBlock = undefined - this.swingAnimation = undefined - this.blockSwapAnimation = undefined - return - } - let blockInner + private async createItemModel (handItem: HandItemBlock): Promise<{ model: THREE.Object3D; type: 'hand' | 'block' | 'item' } | undefined> { + this.lastUpdate = performance.now() + if (!handItem || (handItem.type === 'hand' && !this.playerHand)) return undefined + + let blockInner: THREE.Object3D if (handItem.type === 'item' || handItem.type === 'block') { const { mesh: itemMesh, isBlock } = viewer.entities.getItemMesh({ ...handItem.fullItem, itemId: handItem.id, + }, { + 'minecraft:display_context': 'firstperson', + 'minecraft:use_duration': this.playerState.getItemUsageTicks?.(), + 'minecraft:using_item': !!this.playerState.getItemUsageTicks?.(), })! if (isBlock) { blockInner = itemMesh @@ -280,20 +326,73 @@ export default class HoldingBlock { handItem.type = 'item' } } else { - blockInner = await getMyHand() + blockInner = this.playerHand! } blockInner.name = 'holdingBlock' + + const rotationDeg = this.getHandHeld3d().rotation + blockInner.rotation.x = THREE.MathUtils.degToRad(rotationDeg.x) + blockInner.rotation.y = THREE.MathUtils.degToRad(rotationDeg.y) + blockInner.rotation.z = THREE.MathUtils.degToRad(rotationDeg.z) + + return { model: blockInner, type: handItem.type } + } + + async replaceItemModel (handItem?: HandItemBlock): Promise { + if (!handItem) { + this.holdingBlock?.removeFromParent() + this.holdingBlock = undefined + this.swingAnimator?.stopSwing() + this.swingAnimator = undefined + this.idleAnimator = undefined + return + } + + const result = await this.createItemModel(handItem) + if (!result) return + + // Update the model without changing the group structure + this.holdingBlock?.removeFromParent() + this.holdingBlock = result.model + this.holdingBlockInnerGroup.add(result.model) + + + } + + async setNewItem (handItem?: HandItemBlock) { + if (!this.isDifferentItem(handItem)) return + let playAppearAnimation = false + if (this.holdingBlock) { + // play disappear animation + playAppearAnimation = true + const result = await this.playBlockSwapAnimation() + if (!result) return + this.holdingBlock?.removeFromParent() + this.holdingBlock = undefined + } + + this.lastHeldItem = handItem + if (!handItem) { + this.swingAnimator?.stopSwing() + this.swingAnimator = undefined + this.idleAnimator = undefined + this.blockSwapAnimation = undefined + return + } + + const result = await this.createItemModel(handItem) + if (!result) return + const blockOuterGroup = new THREE.Group() this.holdingBlockInnerGroup.removeFromParent() this.holdingBlockInnerGroup = new THREE.Group() - this.holdingBlockInnerGroup.add(blockInner) + this.holdingBlockInnerGroup.add(result.model) blockOuterGroup.add(this.holdingBlockInnerGroup) - this.holdingBlock = blockInner + this.holdingBlock = result.model this.objectInnerGroup = new THREE.Group() this.objectInnerGroup.add(blockOuterGroup) this.objectInnerGroup.position.set(-0.5, -0.5, -0.5) - // todo cleanup - if (animatingCurrent) { + if (playAppearAnimation) { this.objectInnerGroup.position.y -= this.objectInnerGroup.scale.y * 1.5 } Object.assign(blockOuterGroup.position, { x: 0.5, y: 0.5, z: 0.5 }) @@ -303,116 +402,466 @@ export default class HoldingBlock { this.cameraGroup.add(this.objectOuterGroup) const rotationDeg = this.getHandHeld3d().rotation - let origPosition - const setRotation = () => { - const final = this.getFinalSwingPositionRotation(origPosition) - origPosition ??= final.object.position.clone() - if (this.debug.displayFinal) { - Object.assign(final.object.position, final.position) - Object.assign(final.object.rotation, final.rotation) - } else if (this.debug.displayFinal === false) { - final.object.rotation.set(0, 0, 0) - } + this.objectOuterGroup.rotation.y = THREE.MathUtils.degToRad(rotationDeg.yOuter) - this.holdingBlock!.rotation.x = THREE.MathUtils.degToRad(rotationDeg.x) - this.holdingBlock!.rotation.y = THREE.MathUtils.degToRad(rotationDeg.y) - this.holdingBlock!.rotation.z = THREE.MathUtils.degToRad(rotationDeg.z) - this.objectOuterGroup.rotation.y = THREE.MathUtils.degToRad(rotationDeg.yOuter) + if (playAppearAnimation) { + await this.playBlockSwapAnimation('appeared') } - // const gui = new GUI() - // gui.add(rotationDeg, 'x', -180, 180, 0.1) - // gui.add(rotationDeg, 'y', -180, 180, 0.1) - // gui.add(rotationDeg, 'z', -180, 180, 0.1) - // gui.add(rotationDeg, 'yOuter', -180, 180, 0.1) - // Object.assign(window, { xFinal: 0, yFinal: 0, zFinal: 0, xRotationFinal: 0, yRotationFinal: 0, zRotationFinal: 0, displayFinal: true }) - // gui.add(window, 'xFinal', -10, 10, 0.05) - // gui.add(window, 'yFinal', -10, 10, 0.05) - // gui.add(window, 'zFinal', -10, 10, 0.05) - // gui.add(window, 'xRotationFinal', -180, 180, 0.05) - // gui.add(window, 'yRotationFinal', -180, 180, 0.05) - // gui.add(window, 'zRotationFinal', -180, 180, 0.05) - // gui.add(window, 'displayFinal') - // gui.onChange(setRotation) - setRotation() - if (animatingCurrent) { - await this.playBlockSwapAnimation() + this.swingAnimator = new HandSwingAnimator(this.holdingBlockInnerGroup) + this.swingAnimator.type = result.type + if (this.config.viewBobbing) { + this.idleAnimator = new HandIdleAnimator(this.holdingBlockInnerGroup, this.playerState) } } getHandHeld3d () { const type = this.lastHeldItem?.type ?? 'hand' - const { debug } = this + const side = this.offHandModeLegacy ? 'Left' : 'Right' - let scale = type === 'item' ? 0.68 : 0.45 - - const position = { - x: debug.x ?? 0.4, - y: debug.y ?? -0.7, + let scale = 0.8 * 1.15 // default scale for hand + let position = { + x: 0.4, + y: -0.7, z: -0.45 } + let rotation = { + x: -32.4, + y: 42.8, + z: -41.3, + yOuter: 0 + } if (type === 'item') { - position.x = -0.05 - // position.y -= 3.2 / 10 - // position.z += 1.13 / 10 - } - - if (type === 'hand') { - // position.x = viewer.camera.aspect > 1 ? 0.7 : 1.1 - position.y = -0.8 - scale = 0.8 - } - - const rotations = { - block: { - x: 0, - y: -45 + 90, - z: 0, - yOuter: 0 - }, - // hand: { - // x: 166.7, - // // y: -180, - // y: -165.2, - // // z: -156.3, - // z: -134.2, - // yOuter: -81.1 - // }, - hand: { - x: -32.4, - // y: 25.1 - y: 42.8, - z: -41.3, - yOuter: 0 - }, - // item: { - // x: -174, - // y: 47.3, - // z: -134.2, - // yOuter: -41.2 - // } - item: { - // x: -174, - // y: 47.3, - // z: -134.2, - // yOuter: -41.2 - x: 0, - // y: -90, // todo thats the correct one but we don't make it look too cheap because of no depth - y: -70, - z: window.z ?? 25, + const itemData = rotationPositionData[`item${side}`] + position = { + x: -0.05, + y: -0.7, + z: -0.45 + } + rotation = { + x: itemData.rotation[0], + y: itemData.rotation[1], + z: itemData.rotation[2], yOuter: 0 } + scale = itemData.scale[0] * 1.15 + } else if (type === 'block') { + const blockData = rotationPositionData[`block${side}`] + position = { + x: 0.4, + y: -0.7, + z: -0.45 + } + rotation = { + x: blockData.rotation[0], + y: blockData.rotation[1], + z: blockData.rotation[2], + yOuter: 0 + } + scale = blockData.scale[0] * 1.15 } return { - rotation: rotations[type], + rotation, position, scale } } } +class HandIdleAnimator { + globalTime = 0 + lastTime = 0 + currentState: MovementState + targetState: MovementState + defaultPosition: { x: number; y: number; z: number; rotationX: number; rotationY: number; rotationZ: number } + private readonly idleOffset = { y: 0, rotationZ: 0 } + private readonly tween = new tweenJs.Group() + private idleTween: tweenJs.Tween<{ y: number; rotationZ: number }> | null = null + private readonly stateSwitcher: SmoothSwitcher + + // Debug parameters + private readonly debugParams = { + // Transition durations for different state changes + walkingSpeed: 8, + sprintingSpeed: 16, + walkingAmplitude: { x: 1 / 30, y: 1 / 10, rotationZ: 0.25 }, + sprintingAmplitude: { x: 1 / 30, y: 1 / 10, rotationZ: 0.4 } + } + + private readonly debugGui: DebugGui + + constructor (public handMesh: THREE.Object3D, public playerState: IPlayerState) { + this.handMesh = handMesh + this.globalTime = 0 + this.currentState = 'NOT_MOVING' + this.targetState = 'NOT_MOVING' + + this.defaultPosition = { + x: handMesh.position.x, + y: handMesh.position.y, + z: handMesh.position.z, + rotationX: handMesh.rotation.x, + rotationY: handMesh.rotation.y, + rotationZ: handMesh.rotation.z + } + + // Initialize state switcher with appropriate speeds + this.stateSwitcher = new SmoothSwitcher( + () => { + return { + x: this.handMesh.position.x, + y: this.handMesh.position.y, + z: this.handMesh.position.z, + rotationX: this.handMesh.rotation.x, + rotationY: this.handMesh.rotation.y, + rotationZ: this.handMesh.rotation.z + } + }, + (property, value) => { + switch (property) { + case 'x': this.handMesh.position.x = value; break + case 'y': this.handMesh.position.y = value; break + case 'z': this.handMesh.position.z = value; break + case 'rotationX': this.handMesh.rotation.x = value; break + case 'rotationY': this.handMesh.rotation.y = value; break + case 'rotationZ': this.handMesh.rotation.z = value; break + } + }, + { + x: 2, // units per second + y: 2, + z: 2, + rotation: Math.PI // radians per second + } + ) + + // Initialize debug GUI + this.debugGui = new DebugGui('idle_animator', this.debugParams) + // this.debugGui.activate() + } + + private startIdleAnimation () { + if (this.idleTween) { + this.idleTween.stop() + } + + // Start from current position for smooth transition + this.idleOffset.y = this.handMesh.position.y - this.defaultPosition.y + this.idleOffset.rotationZ = this.handMesh.rotation.z - this.defaultPosition.rotationZ + + this.idleTween = new tweenJs.Tween(this.idleOffset, this.tween) + .to({ + y: 0.05, + rotationZ: 0.05 + }, 3000) + .easing(tweenJs.Easing.Sinusoidal.InOut) + .yoyo(true) + .repeat(Infinity) + .start() + } + + private stopIdleAnimation () { + if (this.idleTween) { + this.idleTween.stop() + this.idleOffset.y = 0 + this.idleOffset.rotationZ = 0 + } + } + + private getStateTransform (state: MovementState, time: number) { + switch (state) { + case 'NOT_MOVING': + case 'SNEAKING': + return { + x: this.defaultPosition.x, + y: this.defaultPosition.y, + z: this.defaultPosition.z, + rotationX: this.defaultPosition.rotationX, + rotationY: this.defaultPosition.rotationY, + rotationZ: this.defaultPosition.rotationZ + } + case 'WALKING': + case 'SPRINTING': { + const speed = state === 'SPRINTING' ? this.debugParams.sprintingSpeed : this.debugParams.walkingSpeed + const amplitude = state === 'SPRINTING' ? this.debugParams.sprintingAmplitude : this.debugParams.walkingAmplitude + + return { + x: this.defaultPosition.x + Math.sin(time * speed) * amplitude.x, + y: this.defaultPosition.y - Math.abs(Math.cos(time * speed)) * amplitude.y, + z: this.defaultPosition.z, + rotationX: this.defaultPosition.rotationX, + rotationY: this.defaultPosition.rotationY, + // rotationZ: this.defaultPosition.rotationZ + Math.sin(time * speed) * amplitude.rotationZ + rotationZ: this.defaultPosition.rotationZ + } + } + } + } + + setState (newState: MovementState) { + if (newState === this.targetState) return + + this.targetState = newState + const noTransition = false + if (this.currentState !== newState) { + // Stop idle animation during state transitions + this.stopIdleAnimation() + + // Calculate new state transform + if (!noTransition) { + // this.globalTime = 0 + const stateTransform = this.getStateTransform(newState, this.globalTime) + + // Start transition to new state + this.stateSwitcher.transitionTo(stateTransform, newState) + // this.updated = false + } + this.currentState = newState + } + } + + updated = false + update () { + this.stateSwitcher.update() + + const now = performance.now() + const deltaTime = (now - this.lastTime) / 1000 + this.lastTime = now + + // Update global time based on current state + if (!this.stateSwitcher.isTransitioning) { + switch (this.currentState) { + case 'NOT_MOVING': + case 'SNEAKING': + this.globalTime = Math.PI / 4 + break + case 'SPRINTING': + case 'WALKING': + this.globalTime += deltaTime + break + } + } + + // Check for state changes from player state + if (this.playerState) { + const newState = this.playerState.getMovementState() + if (newState !== this.targetState) { + this.setState(newState) + } + } + + // If we're not transitioning between states and in a stable state that should have idle animation + if (!this.stateSwitcher.isTransitioning && + (this.currentState === 'NOT_MOVING' || this.currentState === 'SNEAKING')) { + // Start idle animation if not already running + if (!this.idleTween?.isPlaying()) { + this.startIdleAnimation() + } + // Update idle animation + this.tween.update() + + // Apply idle offsets + this.handMesh.position.y = this.defaultPosition.y + this.idleOffset.y + this.handMesh.rotation.z = this.defaultPosition.rotationZ + this.idleOffset.rotationZ + } + + // If we're in a movement state and not transitioning, update the movement animation + if (!this.stateSwitcher.isTransitioning && + (this.currentState === 'WALKING' || this.currentState === 'SPRINTING')) { + const stateTransform = this.getStateTransform(this.currentState, this.globalTime) + Object.assign(this.handMesh.position, stateTransform) + Object.assign(this.handMesh.rotation, { + x: stateTransform.rotationX, + y: stateTransform.rotationY, + z: stateTransform.rotationZ + }) + // this.stateSwitcher.transitionTo(stateTransform, this.currentState) + } + } + + getCurrentState () { + return this.currentState + } + + destroy () { + this.stopIdleAnimation() + this.stateSwitcher.forceFinish() + } +} + +class HandSwingAnimator { + private readonly PI = Math.PI + private animationTimer = 0 + private lastTime = 0 + private isAnimating = false + private stopRequested = false + private readonly originalRotation: THREE.Euler + private readonly originalPosition: THREE.Vector3 + private readonly originalScale: THREE.Vector3 + + readonly debugParams = { + // Animation timing + animationTime: 250, + animationStage: 0, + useClassicSwing: true, + + // Item/Block animation parameters + itemSwingXPosScale: -0.8, + itemSwingYPosScale: 0.2, + itemSwingZPosScale: -0.2, + itemHeightScale: -0.6, + itemPreswingRotY: 45, + itemSwingXRotAmount: -30, + itemSwingYRotAmount: -35, + itemSwingZRotAmount: -5, + + // Hand/Arm animation parameters + armSwingXPosScale: -0.3, + armSwingYPosScale: 0.4, + armSwingZPosScale: -0.4, + armSwingYRotAmount: 70, + armSwingZRotAmount: -20, + armHeightScale: -0.6 + } + + private readonly debugGui: DebugGui + + public type: 'hand' | 'block' | 'item' = 'hand' + + constructor (public handMesh: THREE.Object3D) { + this.handMesh = handMesh + // Store initial transforms + this.originalRotation = handMesh.rotation.clone() + this.originalPosition = handMesh.position.clone() + this.originalScale = handMesh.scale.clone() + + // Initialize debug GUI + this.debugGui = new DebugGui('hand_animator', this.debugParams, undefined, { + animationStage: { + min: 0, + max: 1, + step: 0.01 + }, + // Add ranges for all animation parameters + itemSwingXPosScale: { min: -2, max: 2, step: 0.1 }, + itemSwingYPosScale: { min: -2, max: 2, step: 0.1 }, + itemSwingZPosScale: { min: -2, max: 2, step: 0.1 }, + itemHeightScale: { min: -2, max: 2, step: 0.1 }, + itemPreswingRotY: { min: -180, max: 180, step: 5 }, + itemSwingXRotAmount: { min: -180, max: 180, step: 5 }, + itemSwingYRotAmount: { min: -180, max: 180, step: 5 }, + itemSwingZRotAmount: { min: -180, max: 180, step: 5 }, + armSwingXPosScale: { min: -2, max: 2, step: 0.1 }, + armSwingYPosScale: { min: -2, max: 2, step: 0.1 }, + armSwingZPosScale: { min: -2, max: 2, step: 0.1 }, + armSwingYRotAmount: { min: -180, max: 180, step: 5 }, + armSwingZRotAmount: { min: -180, max: 180, step: 5 }, + armHeightScale: { min: -2, max: 2, step: 0.1 } + }) + // this.debugGui.activate() + } + + update () { + if (!this.isAnimating && !this.debugParams.animationStage) { + // If not animating, ensure we're at original position + this.handMesh.rotation.copy(this.originalRotation) + this.handMesh.position.copy(this.originalPosition) + this.handMesh.scale.copy(this.originalScale) + return + } + + const now = performance.now() + const deltaTime = (now - this.lastTime) / 1000 + this.lastTime = now + + // Update animation progress + this.animationTimer += deltaTime * 1000 // Convert to ms + + // Calculate animation stage (0 to 1) + const stage = this.debugParams.animationStage || Math.min(this.animationTimer / this.debugParams.animationTime, 1) + + if (stage >= 1) { + // Animation complete + if (this.stopRequested) { + // If stop was requested, actually stop now that we've completed a swing + this.isAnimating = false + this.stopRequested = false + this.animationTimer = 0 + this.handMesh.rotation.copy(this.originalRotation) + this.handMesh.position.copy(this.originalPosition) + this.handMesh.scale.copy(this.originalScale) + return + } + // Otherwise reset timer and continue + this.animationTimer = 0 + return + } + + // Start from original transforms + this.handMesh.rotation.copy(this.originalRotation) + this.handMesh.position.copy(this.originalPosition) + this.handMesh.scale.copy(this.originalScale) + + // Calculate swing progress + const swingProgress = stage + const sqrtProgress = Math.sqrt(swingProgress) + const sinProgress = Math.sin(swingProgress * this.PI) + const sinSqrtProgress = Math.sin(sqrtProgress * this.PI) + + if (this.type === 'hand') { + // Hand animation + const xOffset = this.debugParams.armSwingXPosScale * sinSqrtProgress + const yOffset = this.debugParams.armSwingYPosScale * Math.sin(sqrtProgress * this.PI * 2) + const zOffset = this.debugParams.armSwingZPosScale * sinProgress + + this.handMesh.position.x += xOffset + this.handMesh.position.y += yOffset + this.debugParams.armHeightScale * swingProgress + this.handMesh.position.z += zOffset + + // Rotations + this.handMesh.rotation.y += THREE.MathUtils.degToRad(this.debugParams.armSwingYRotAmount * sinSqrtProgress) + this.handMesh.rotation.z += THREE.MathUtils.degToRad(this.debugParams.armSwingZRotAmount * sinProgress) + } else { + // Item/Block animation + const xOffset = this.debugParams.itemSwingXPosScale * sinSqrtProgress + const yOffset = this.debugParams.itemSwingYPosScale * Math.sin(sqrtProgress * this.PI * 2) + const zOffset = this.debugParams.itemSwingZPosScale * sinProgress + + this.handMesh.position.x += xOffset + this.handMesh.position.y += yOffset + this.debugParams.itemHeightScale * swingProgress + this.handMesh.position.z += zOffset + + // Pre-swing rotation + this.handMesh.rotation.y += THREE.MathUtils.degToRad(this.debugParams.itemPreswingRotY) + + // Swing rotations + this.handMesh.rotation.x += THREE.MathUtils.degToRad(this.debugParams.itemSwingXRotAmount * sinProgress) + this.handMesh.rotation.y += THREE.MathUtils.degToRad(this.debugParams.itemSwingYRotAmount * sinSqrtProgress) + this.handMesh.rotation.z += THREE.MathUtils.degToRad(this.debugParams.itemSwingZRotAmount * sinProgress) + } + } + + startSwing () { + this.stopRequested = false + if (this.isAnimating) return + + this.isAnimating = true + this.animationTimer = 0 + this.lastTime = performance.now() + } + + stopSwing () { + if (!this.isAnimating) return + this.stopRequested = true + } + + isCurrentlySwinging () { + return this.isAnimating + } +} + export const getBlockMeshFromModel = (material: THREE.Material, model: BlockModel, name: string) => { const blockProvider = worldBlockProvider(viewer.world.blockstatesModels, viewer.world.blocksAtlasParser!.atlas, 'latest') const worldRenderModel = blockProvider.transformModel(model, { @@ -421,3 +870,8 @@ export const getBlockMeshFromModel = (material: THREE.Material, model: BlockMode }) return getThreeBlockModelGroup(material, [[worldRenderModel]], undefined, 'plains', loadedData) } + +setTimeout(() => { + //@ts-expect-error + window.holdingBlock = viewer.world.holdingBlock +}) diff --git a/renderer/viewer/lib/mesher/mesher.ts b/renderer/viewer/lib/mesher/mesher.ts index ac9e69e8..ed81bb84 100644 --- a/renderer/viewer/lib/mesher/mesher.ts +++ b/renderer/viewer/lib/mesher/mesher.ts @@ -2,6 +2,8 @@ import { Vec3 } from 'vec3' import { World } from './world' import { getSectionGeometry, setBlockStatesData as setMesherData } from './models' +globalThis.structuredClone ??= (value) => JSON.parse(JSON.stringify(value)) + if (module.require) { // If we are in a node environement, we need to fake some env variables const r = module.require diff --git a/renderer/viewer/lib/smoothSwitcher.ts b/renderer/viewer/lib/smoothSwitcher.ts new file mode 100644 index 00000000..925affec --- /dev/null +++ b/renderer/viewer/lib/smoothSwitcher.ts @@ -0,0 +1,169 @@ +import * as tweenJs from '@tweenjs/tween.js' +import { AnimationController } from './animationController' + +export type StateProperties = Record +export type StateGetterFn = () => StateProperties +export type StateSetterFn = (property: string, value: number) => void + +// Speed in units per second for each property type +const DEFAULT_SPEEDS = { + x: 3000, // pixels/units per second + y: 3000, + z: 3000, + rotation: Math.PI, // radians per second + scale: 1, // scale units per second + default: 3000 // default speed for unknown properties +} + +export class SmoothSwitcher { + private readonly animationController = new AnimationController() + // private readonly currentState: StateProperties = {} + private readonly defaultState: StateProperties + private readonly speeds: Record + public currentStateName = '' + public transitioningToStateName = '' + + constructor ( + public getState: StateGetterFn, + public setState: StateSetterFn, + speeds?: Partial> + ) { + + // Initialize speeds with defaults and overrides + this.speeds = { ...DEFAULT_SPEEDS } + if (speeds) { + Object.assign(this.speeds, speeds) + } + + // Store initial values + this.defaultState = this.getState() + } + + /** + * Calculate transition duration based on the largest property change + */ + private calculateDuration (newState: Partial): number { + let maxDuration = 0 + const currentState = this.getState() + + for (const [key, targetValue] of Object.entries(newState)) { + const currentValue = currentState[key] + const diff = Math.abs(targetValue! - currentValue) + const speed = this.getPropertySpeed(key) + const duration = (diff / speed) * 1000 // Convert to milliseconds + + maxDuration = Math.max(maxDuration, duration) + } + + // Ensure minimum duration of 50ms and maximum of 2000ms + return Math.min(Math.max(maxDuration, 200), 2000) + } + + private getPropertySpeed (property: string): number { + // Check for specific property speed + if (property in this.speeds) { + return this.speeds[property] + } + + // Check for property type (rotation, scale, etc.) + if (property.toLowerCase().includes('rotation')) return this.speeds.rotation + if (property.toLowerCase().includes('scale')) return this.speeds.scale + if (property.toLowerCase() === 'x' || property.toLowerCase() === 'y' || property.toLowerCase() === 'z') { + return this.speeds[property] + } + + return this.speeds.default + } + + /** + * Start a transition to a new state + * @param newState Partial state - only need to specify properties that change + * @param easing Easing function to use + */ + startTransition ( + newState: Partial, + stateName?: string, + onEnd?: () => void, + easing: (amount: number) => number = tweenJs.Easing.Linear.None, + onCancelled?: () => void + ): void { + if (this.isTransitioning) { + onCancelled?.() + this.animationController.forceFinish() + } + + this.transitioningToStateName = stateName ?? '' + const state = this.getState() + + const duration = this.calculateDuration(newState) + // console.log('duration', duration, JSON.stringify(state), JSON.stringify(newState)) + + void this.animationController.startAnimation(() => { + const group = new tweenJs.Group() + new tweenJs.Tween(state, group) + .to(newState, duration) + .easing(easing) + .onUpdate((obj) => { + for (const key of Object.keys(obj)) { + this.setState(key, obj[key]) + } + }) + .onComplete(() => { + this.animationController.forceFinish() + this.currentStateName = this.transitioningToStateName + this.transitioningToStateName = '' + onEnd?.() + }) + .start() + return group + }) + } + + /** + * Reset to default state + */ + reset (): void { + this.startTransition(this.defaultState) + } + + + /** + * Update the animation (should be called in your render/update loop) + */ + update (): void { + this.animationController.update() + } + + /** + * Force finish the current transition + */ + forceFinish (): void { + this.animationController.forceFinish() + } + + /** + * Start a new transition to the specified state + */ + transitionTo ( + newState: Partial, + stateName?: string, + onEnd?: () => void, + onCancelled?: () => void + ): void { + this.startTransition(newState, stateName, onEnd, tweenJs.Easing.Linear.None, onCancelled) + } + + /** + * Get the current value of a property + */ + getCurrentValue (property: string): number { + return this.getState()[property] + } + + /** + * Check if currently transitioning + */ + get isTransitioning (): boolean { + return this.animationController.isActive + } +} diff --git a/renderer/viewer/lib/threeJsUtils.ts b/renderer/viewer/lib/threeJsUtils.ts index 6ca39d6a..5ae3b24f 100644 --- a/renderer/viewer/lib/threeJsUtils.ts +++ b/renderer/viewer/lib/threeJsUtils.ts @@ -1,6 +1,6 @@ import * as THREE from 'three' -export const disposeObject = (obj: THREE.Object3D) => { +export const disposeObject = (obj: THREE.Object3D, cleanTextures = false) => { // not cleaning texture there as it might be used by other objects, but would be good to also do that if (obj instanceof THREE.Mesh) { obj.geometry?.dispose?.() @@ -8,6 +8,11 @@ export const disposeObject = (obj: THREE.Object3D) => { } if (obj.children) { // eslint-disable-next-line unicorn/no-array-for-each - obj.children.forEach(disposeObject) + obj.children.forEach(child => disposeObject(child, cleanTextures)) + } + if (cleanTextures) { + if (obj instanceof THREE.Mesh) { + obj.material?.map?.dispose?.() + } } } diff --git a/renderer/viewer/lib/utils/proxy.ts b/renderer/viewer/lib/utils/proxy.ts new file mode 100644 index 00000000..d30ceb7e --- /dev/null +++ b/renderer/viewer/lib/utils/proxy.ts @@ -0,0 +1,23 @@ +import { subscribeKey } from 'valtio/utils' + +// eslint-disable-next-line max-params +export function watchProperty, K> (asyncGetter: (value: T[keyof T]) => Promise, valtioProxy: T, key: keyof T, readySetter: (res: K) => void, cleanup?: (res: K) => void) { + let i = 0 + let lastRes: K | undefined + const request = async () => { + const req = ++i + const res = await asyncGetter(valtioProxy[key]) + if (req === i) { + if (lastRes) { + cleanup?.(lastRes) + } + readySetter(res) + lastRes = res + } else { + // rejected + cleanup?.(res) + } + } + void request() + return subscribeKey(valtioProxy, key, request) +} diff --git a/renderer/viewer/lib/utils/skins.ts b/renderer/viewer/lib/utils/skins.ts new file mode 100644 index 00000000..385c16c1 --- /dev/null +++ b/renderer/viewer/lib/utils/skins.ts @@ -0,0 +1,27 @@ +import { loadSkinToCanvas } from 'skinview-utils' +import * as THREE from 'three' +import stevePng from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png' + +// eslint-disable-next-line unicorn/prefer-export-from +export const stevePngUrl = stevePng +export const steveTexture = new THREE.TextureLoader().loadAsync(stevePng) + +export async function loadImageFromUrl (imageUrl: string): Promise { + const img = new Image() + img.src = imageUrl + await new Promise(resolve => { + img.onload = () => resolve() + }) + return img +} + +export function getLookupUrl (username: string, type: 'skin' | 'cape'): string { + return `https://mulv.tycrek.dev/api/lookup?username=${username}&type=${type}` +} + +export async function loadSkinImage (skinUrl: string): Promise<{ canvas: HTMLCanvasElement, image: HTMLImageElement }> { + const image = await loadImageFromUrl(skinUrl) + const skinCanvas = document.createElement('canvas') + loadSkinToCanvas(skinCanvas, image) + return { canvas: skinCanvas, image } +} diff --git a/renderer/viewer/lib/viewer.ts b/renderer/viewer/lib/viewer.ts index 0f7b4850..beacaf77 100644 --- a/renderer/viewer/lib/viewer.ts +++ b/renderer/viewer/lib/viewer.ts @@ -3,7 +3,6 @@ import * as THREE from 'three' import { Vec3 } from 'vec3' import { generateSpiralMatrix } from 'flying-squid/dist/utils' import worldBlockProvider from 'mc-assets/dist/worldBlockProvider' -import stevePng from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png' import { Entities } from './entities' import { Primitives } from './primitives' import { WorldRendererThree } from './worldrendererThree' @@ -11,6 +10,8 @@ import { WorldRendererCommon, WorldRendererConfig, defaultWorldRendererConfig } import { getThreeBlockModelGroup, renderBlockThree, setBlockPosition } from './mesher/standaloneRenderer' import { addNewStat } from './ui/newStats' import { getMyHand } from './hand' +import { IPlayerState, BasePlayerState } from './basePlayerState' +import { CameraBobbing } from './cameraBobbing' export class Viewer { scene: THREE.Scene @@ -21,14 +22,12 @@ export class Viewer { // primitives: Primitives domElement: HTMLCanvasElement playerHeight = 1.62 - isSneaking = false threeJsWorld: WorldRendererThree cameraObjectOverride?: THREE.Object3D // for xr audioListener: THREE.AudioListener renderingUntilNoUpdates = false processEntityOverrides = (e, overrides) => overrides - - getMineflayerBot (): void | Record {} // to be overridden + private readonly cameraBobbing: CameraBobbing get camera () { return this.world.camera @@ -38,18 +37,19 @@ export class Viewer { this.world.camera = camera } - constructor (public renderer: THREE.WebGLRenderer, worldConfig = defaultWorldRendererConfig) { + constructor (public renderer: THREE.WebGLRenderer, worldConfig = defaultWorldRendererConfig, public playerState: IPlayerState = new BasePlayerState()) { // https://discourse.threejs.org/t/updates-to-color-management-in-three-js-r152/50791 THREE.ColorManagement.enabled = false renderer.outputColorSpace = THREE.LinearSRGBColorSpace this.scene = new THREE.Scene() this.scene.matrixAutoUpdate = false // for perf - this.threeJsWorld = new WorldRendererThree(this.scene, this.renderer, worldConfig) + this.threeJsWorld = new WorldRendererThree(this.scene, this.renderer, worldConfig, this.playerState) this.setWorld() this.resetScene() this.entities = new Entities(this) // this.primitives = new Primitives(this.scene, this.camera) + this.cameraBobbing = new CameraBobbing() this.domElement = renderer.domElement } @@ -139,7 +139,7 @@ export class Viewer { const pos = cursorBlockRel(0, 1, 0).position const { mesh } = this.entities.getItemMesh({ itemId: 541, - })! + }, {})! mesh.position.set(pos.x + 0.5, pos.y + 0.5, pos.z + 0.5) // mesh.scale.set(0.5, 0.5, 0.5) const helper = new THREE.BoxHelper(mesh, 0xff_ff_00) @@ -161,12 +161,31 @@ export class Viewer { 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 + const yOffset = this.playerState.getEyeHeight() + // if (this.playerState.isSneaking()) yOffset -= 0.3 this.world.camera = cam as THREE.PerspectiveCamera - this.world.updateCamera(pos?.offset(0, yOffset, 0) ?? null, yaw, pitch) + + // // Update camera bobbing based on movement state + // const velocity = this.playerState.getVelocity() + // const movementState = this.playerState.getMovementState() + // const isMoving = movementState === 'SPRINTING' || movementState === 'WALKING' + // const speed = Math.hypot(velocity.x, velocity.z) + + // // Update bobbing state + // this.cameraBobbing.updateWalkDistance(speed) + // this.cameraBobbing.updateBobAmount(isMoving) + + // // Get bobbing offsets + // const bobbing = isMoving ? this.cameraBobbing.getBobbing() : { position: { x: 0, y: 0 }, rotation: { x: 0, z: 0 } } + + // // Apply camera position with bobbing + // const finalPos = pos ? pos.offset(bobbing.position.x, yOffset + bobbing.position.y, 0) : null + // this.world.updateCamera(finalPos, yaw + bobbing.rotation.x, pitch) + + // // Apply roll rotation separately since updateCamera doesn't handle it + // this.camera.rotation.z = bobbing.rotation.z } playSound (position: Vec3, path: string, volume = 1, pitch = 1) { diff --git a/renderer/viewer/lib/viewerWrapper.ts b/renderer/viewer/lib/viewerWrapper.ts index 52e244fe..2e08da1d 100644 --- a/renderer/viewer/lib/viewerWrapper.ts +++ b/renderer/viewer/lib/viewerWrapper.ts @@ -79,7 +79,7 @@ export class ViewerWrapper { if (this.globalObject.stopLoop) return for (const fn of beforeRenderFrame) fn() this.globalObject.requestAnimationFrame(this.render.bind(this)) - if (this.globalObject.stopRender || this.renderer?.xr.isPresenting || (this.stopRenderOnBlur && !this.windowFocused)) return + if (!viewer || this.globalObject.stopRender || this.renderer?.xr.isPresenting || (this.stopRenderOnBlur && !this.windowFocused)) return const renderInterval = (this.windowFocused ? this.renderInterval : this.renderIntervalUnfocused) ?? this.renderInterval if (renderInterval) { this.delta += time - this.lastTime diff --git a/renderer/viewer/lib/worldDataEmitter.ts b/renderer/viewer/lib/worldDataEmitter.ts index fee47de2..7c2be715 100644 --- a/renderer/viewer/lib/worldDataEmitter.ts +++ b/renderer/viewer/lib/worldDataEmitter.ts @@ -7,6 +7,7 @@ import { Vec3 } from 'vec3' import { BotEvents } from 'mineflayer' import { getItemFromBlock } from '../../../src/chatUtils' import { delayedIterator } from '../../playground/shared' +import { playerState } from '../../../src/mineflayer/playerState' import { chunkPos } from './simpleUtils' export type ChunkPosKey = string @@ -102,36 +103,8 @@ export class WorldDataEmitter extends EventEmitter { }, time: () => { this.emitter.emit('time', bot.time.timeOfDay) - }, - heldItemChanged () { - handChanged(false) - }, + } } satisfies Partial - const handChanged = (isLeftHand: boolean) => { - const newItem = isLeftHand ? bot.inventory.slots[45] : bot.heldItem - if (!newItem) { - viewer.world.onHandItemSwitch(undefined, isLeftHand) - return - } - const block = loadedData.blocksByName[newItem.name] - // todo clean types - const blockProperties = block ? new window.PrismarineBlock(block.id, 'void', newItem.metadata).getProperties() : {} - // todo item props - viewer.world.onHandItemSwitch({ - name: newItem.name, - properties: blockProperties, - id: newItem.type, - type: block ? 'block' : 'item', - fullItem: newItem, - }, isLeftHand) - } - bot.inventory.on('updateSlot', (index) => { - if (index === 45) { - handChanged(true) - } - }) - handChanged(false) - handChanged(true) bot._client.on('update_light', ({ chunkX, chunkZ }) => { diff --git a/renderer/viewer/lib/worldrendererCommon.ts b/renderer/viewer/lib/worldrendererCommon.ts index b276241a..7841016d 100644 --- a/renderer/viewer/lib/worldrendererCommon.ts +++ b/renderer/viewer/lib/worldrendererCommon.ts @@ -9,11 +9,12 @@ import blocksAtlasLegacy from 'mc-assets/dist/blocksAtlasLegacy.png' import itemsAtlases from 'mc-assets/dist/itemsAtlases.json' import itemsAtlasLatest from 'mc-assets/dist/itemsAtlasLatest.png' import itemsAtlasLegacy from 'mc-assets/dist/itemsAtlasLegacy.png' -import { AtlasParser } from 'mc-assets' +import { AtlasParser, getLoadedItemDefinitionsStore } from 'mc-assets' import TypedEmitter from 'typed-emitter' import { LineMaterial } from 'three-stdlib' import christmasPack from 'mc-assets/dist/textureReplacements/christmas' import { ItemsRenderer } from 'mc-assets/dist/itemsRenderer' +import itemDefinitionsJson from 'mc-assets/dist/itemDefinitions.json' import { dynamicMcDataFiles } from '../../buildMesherConfig.mjs' import { toMajorVersion } from '../../../src/utils' import { buildCleanupDecorator } from './cleanupDecorator' @@ -34,7 +35,8 @@ export const defaultWorldRendererConfig = { numWorkers: 4, isPlayground: false, // game renderer setting actually - displayHand: false + showHand: false, + viewBobbing: false } export type WorldRendererConfig = typeof defaultWorldRendererConfig @@ -119,13 +121,15 @@ export abstract class WorldRendererCommon sourceData = { blocksAtlases, - itemsAtlases + itemsAtlases, + itemDefinitionsJson } customTextures: { items?: CustomTexturesData blocks?: CustomTexturesData armor?: CustomTexturesData } = {} + itemsDefinitionsStore = getLoadedItemDefinitionsStore(this.sourceData.itemDefinitionsJson) workersProcessAverageTime = 0 workersProcessAverageTimeCount = 0 maxWorkersProcessTime = 0 @@ -248,7 +252,6 @@ export abstract class WorldRendererCommon } } - onHandItemSwitch (item: HandItemBlock | undefined, isLeftHand: boolean): void { } changeHandSwingingState (isAnimationPlaying: boolean, isLeftHand: boolean): void { } abstract handleWorkerMessage (data: WorkerReceive): void diff --git a/renderer/viewer/lib/worldrendererThree.ts b/renderer/viewer/lib/worldrendererThree.ts index b0efca7d..4ff56cdb 100644 --- a/renderer/viewer/lib/worldrendererThree.ts +++ b/renderer/viewer/lib/worldrendererThree.ts @@ -12,6 +12,7 @@ import { disposeObject } from './threeJsUtils' import HoldingBlock, { HandItemBlock } from './holdingBlock' import { addNewStat } from './ui/newStats' import { MesherGeometryOutput } from './mesher/shared' +import { IPlayerState } from './basePlayerState' import { getMesh } from './entity/EntityMesh' import { armorModel } from './entity/armorModels' @@ -36,48 +37,29 @@ export class WorldRendererThree extends WorldRendererCommon { return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj as any).blocksCount, 0) } - constructor (public scene: THREE.Scene, public renderer: THREE.WebGLRenderer, public config: WorldRendererConfig) { + constructor (public scene: THREE.Scene, public renderer: THREE.WebGLRenderer, public config: WorldRendererConfig, public playerState: IPlayerState) { super(config) this.rendererDevice = `${WorldRendererThree.getRendererInfo(this.renderer)} powered by three.js r${THREE.REVISION}` this.starField = new StarField(scene) - this.holdingBlock = new HoldingBlock() - this.holdingBlockLeft = new HoldingBlock() - this.holdingBlockLeft.rightSide = false + this.holdingBlock = new HoldingBlock(playerState, this.config) + this.holdingBlockLeft = new HoldingBlock(playerState, this.config, true) this.renderUpdateEmitter.on('itemsTextureDownloaded', () => { - if (this.holdingBlock.toBeRenderedItem) { - this.onHandItemSwitch(this.holdingBlock.toBeRenderedItem) - this.holdingBlock.toBeRenderedItem = undefined - } - if (this.holdingBlockLeft.toBeRenderedItem) { - this.onHandItemSwitch(this.holdingBlock.toBeRenderedItem, true) - this.holdingBlockLeft.toBeRenderedItem = undefined - } + this.holdingBlock.ready = true + this.holdingBlock.updateItem() + this.holdingBlockLeft.ready = true + this.holdingBlockLeft.updateItem() }) this.addDebugOverlay() } - onHandItemSwitch (item: HandItemBlock | undefined, isLeft = false) { - if (!isLeft) { - item ??= { - type: 'hand', - } - } - const holdingBlock = isLeft ? this.holdingBlockLeft : this.holdingBlock - if (!this.currentTextureImage) { - holdingBlock.toBeRenderedItem = item - return - } - void holdingBlock.initHandObject(item) - } - changeHandSwingingState (isAnimationPlaying: boolean, isLeft = false) { const holdingBlock = isLeft ? this.holdingBlockLeft : this.holdingBlock if (isAnimationPlaying) { holdingBlock.startSwing() } else { - void holdingBlock.stopSwing() + holdingBlock.stopSwing() } } @@ -252,7 +234,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.displayHand) { + if (this.config.showHand) { 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/api/mcStatusApi.ts b/src/api/mcStatusApi.ts index 2a440f2b..20d27813 100644 --- a/src/api/mcStatusApi.ts +++ b/src/api/mcStatusApi.ts @@ -1,3 +1,8 @@ +globalThis.resolveDnsFallback = async (hostname: string) => { + const response = await fetchServerStatus(hostname) + return response?.raw.srv_record ?? undefined +} + export const isServerValid = (ip: string) => { const isInLocalNetwork = ip.startsWith('192.168.') || ip.startsWith('10.') || @@ -51,4 +56,9 @@ export type ServerResponse = { // todo display via hammer icon software?: string plugins?: Array<{ name, version }> + // port?: number + srv_record?: { + host: string + port: number + } } diff --git a/src/builtinCommands.ts b/src/builtinCommands.ts index 772b3745..4bc21a73 100644 --- a/src/builtinCommands.ts +++ b/src/builtinCommands.ts @@ -112,15 +112,20 @@ const commands: Array<{ { command: ['/pos'], async invoke ([type]) { - let pos: string | undefined + let pos: { x: number, y: number, z: number } | undefined if (type === 'block') { - pos = window.cursorBlockRel()?.position?.toString().slice(1, -1) + const blockPos = window.cursorBlockRel()?.position + if (blockPos) { + pos = { x: blockPos.x, y: blockPos.y, z: blockPos.z } + } } else { - pos = bot.entity.position.toString().slice(1, -1) + const playerPos = bot.entity.position + pos = { x: playerPos.x, y: playerPos.y, z: playerPos.z } } if (!pos) return - await navigator.clipboard.writeText(pos) - writeText(`Copied position to clipboard: ${pos}`) + const formatted = `${pos.x.toFixed(2)} ${pos.y.toFixed(2)} ${pos.z.toFixed(2)}` + await navigator.clipboard.writeText(formatted) + writeText(`Copied position to clipboard: ${formatted}`) } } ] diff --git a/src/devtools.ts b/src/devtools.ts index 6f54eaf1..6624dda8 100644 --- a/src/devtools.ts +++ b/src/devtools.ts @@ -24,6 +24,19 @@ Object.defineProperty(window, 'debugSceneChunks', { }, }) +window.chunkKey = (xRel = 0, zRel = 0) => { + const pos = bot.entity.position + return `${(Math.floor(pos.x / 16) + xRel) * 16},${(Math.floor(pos.z / 16) + zRel) * 16}` +} + +window.sectionKey = (xRel = 0, yRel = 0, zRel = 0) => { + const pos = bot.entity.position + return `${(Math.floor(pos.x / 16) + xRel) * 16},${(Math.floor(pos.y / 16) + yRel) * 16},${(Math.floor(pos.z / 16) + zRel) * 16}` +} + +window.keys = (obj) => Object.keys(obj) +window.values = (obj) => Object.values(obj) + window.len = (obj) => Object.keys(obj).length customEvents.on('gameLoaded', () => { diff --git a/src/globalState.ts b/src/globalState.ts index 992503ef..412dd31b 100644 --- a/src/globalState.ts +++ b/src/globalState.ts @@ -3,11 +3,16 @@ import { proxy, ref, subscribe } from 'valtio' import type { WorldWarp } from 'flying-squid/dist/lib/modules/warps' import type { OptionsGroupType } from './optionsGuiScheme' +import { appQueryParams } from './appParams' // todo: refactor structure with support of hideNext=false const notHideableModalsWithoutForce = new Set(['app-status']) +if (appQueryParams.lockConnect) { + notHideableModalsWithoutForce.add('editServer') +} + type Modal = ({ elem?: HTMLElement & Record } & { reactType: string }) type ContextMenuItem = { callback; label } diff --git a/src/index.ts b/src/index.ts index 778cb026..975be6c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,6 +66,7 @@ import { isCypress } from './standaloneUtils' import { removePanorama } from './panorama' +import { getItemDefinition } from 'mc-assets/dist/itemDefinitions' import { startLocalServer, unsupportedLocalServerFeatures } from './createLocalServer' import defaultServerOptions from './defaultLocalServerOptions' @@ -104,6 +105,7 @@ import { getWebsocketStream } from './mineflayer/websocket-core' import { appQueryParams, appQueryParamsArray } from './appParams' import { updateCursor } from './cameraRotationControls' import { pingServerVersion } from './mineflayer/minecraft-protocol-extra' +import { playerState, PlayerStateManager } from './mineflayer/playerState' import { states } from 'minecraft-protocol' import { initMotionTracking } from './react/uiMotion' import { UserError } from './mineflayer/userError' @@ -161,21 +163,29 @@ if (isIphone) { if (appQueryParams.testCrashApp === '2') throw new Error('test') // Create viewer -const viewer: import('renderer/viewer/lib/viewer').Viewer = new Viewer(renderer) +const viewer: import('renderer/viewer/lib/viewer').Viewer = new Viewer(renderer, undefined, playerState) window.viewer = viewer -viewer.getMineflayerBot = () => bot // todo unify -viewer.entities.getItemUv = (item) => { +viewer.entities.getItemUv = (item, specificProps) => { const idOrName = item.itemId ?? item.blockId try { const name = typeof idOrName === 'number' ? loadedData.items[idOrName]?.name : idOrName if (!name) throw new Error(`Item not found: ${idOrName}`) - const renderInfo = renderSlot({ - name, - nbt: null, - ...item + const itemSelector = playerState.getItemSelector({ + ...specificProps }) + const model = getItemDefinition(viewer.world.itemsDefinitionsStore, { + name, + version: viewer.world.texturesVersion!, + properties: itemSelector + })?.model ?? name + + const renderInfo = renderSlot({ + ...item, + nbt: null, + name: model, + }, false, true) if (!renderInfo) throw new Error(`Failed to get render info for item ${name}`) @@ -409,10 +419,12 @@ export async function connect (connectOptions: ConnectOptions) { throw err } } + const oldStatus = appStatusState.status 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 + setLoadingScreenStatus(oldStatus) } let finalVersion = connectOptions.botVersion || (singleplayer ? serverOptions.version : undefined) @@ -744,6 +756,8 @@ export async function connect (connectOptions: ConnectOptions) { customEvents.emit('gameLoaded') if (p2pConnectTimeout) clearTimeout(p2pConnectTimeout) + playerState.onlineMode = !!connectOptions.authenticatedAccount + setLoadingScreenStatus('Placing blocks (starting viewer)') localStorage.lastConnectOptions = JSON.stringify(connectOptions) connectOptions.onSuccessfulPlay?.() @@ -997,6 +1011,4 @@ if (initialLoader) { } window.pageLoaded = true -if (!reconnectOptions) { - void possiblyHandleStateVariable() -} +void possiblyHandleStateVariable() diff --git a/src/inventoryWindows.ts b/src/inventoryWindows.ts index efda5392..bdcdef3c 100644 --- a/src/inventoryWindows.ts +++ b/src/inventoryWindows.ts @@ -174,7 +174,7 @@ const getImage = ({ path = undefined as string | undefined, texture = undefined return loadedImagesCache.get(loadPath) } -export const renderSlot = (slot: GeneralInputItem, debugIsQuickbar = false): { +export const renderSlot = (slot: GeneralInputItem, debugIsQuickbar = false, fullBlockModelSupport = false): { texture: string, blockData?: Record & { resolvedModel: BlockModel }, scale?: number, @@ -198,7 +198,7 @@ export const renderSlot = (slot: GeneralInputItem, debugIsQuickbar = false): { let itemTexture try { assertDefined(viewer.world.itemsRenderer) - itemTexture = viewer.world.itemsRenderer.getItemTexture(itemModelName) ?? viewer.world.itemsRenderer.getItemTexture('item/missing_texture')! + itemTexture = viewer.world.itemsRenderer.getItemTexture(itemModelName, {}, false, fullBlockModelSupport) ?? viewer.world.itemsRenderer.getItemTexture('item/missing_texture')! } catch (err) { inGameError(`Failed to render item ${itemModelName} (original: ${originalItemName}) on ${bot.version} (resourcepack: ${options.enabledResourcepack}): ${err.stack}`) itemTexture = viewer.world.itemsRenderer!.getItemTexture('block/errored')! diff --git a/src/mineflayer/playerState.ts b/src/mineflayer/playerState.ts new file mode 100644 index 00000000..793f306f --- /dev/null +++ b/src/mineflayer/playerState.ts @@ -0,0 +1,200 @@ +import { EventEmitter } from 'events' +import { Vec3 } from 'vec3' +import { IPlayerState, ItemSpecificContextProperties, MovementState, PlayerStateEvents } from 'renderer/viewer/lib/basePlayerState' +import { HandItemBlock } from 'renderer/viewer/lib/holdingBlock' +import TypedEmitter from 'typed-emitter' +import { ItemSelector } from 'mc-assets/dist/itemDefinitions' +import { proxy } from 'valtio' +import { gameAdditionalState } from '../globalState' + +export class PlayerStateManager implements IPlayerState { + disableStateUpdates = false + private static instance: PlayerStateManager + readonly events = new EventEmitter() as TypedEmitter + + // Movement and physics state + private lastVelocity = new Vec3(0, 0, 0) + private movementState: MovementState = 'NOT_MOVING' + private timeOffGround = 0 + private lastUpdateTime = performance.now() + + // Held item state + private heldItem?: HandItemBlock + private offHandItem?: HandItemBlock + private itemUsageTicks = 0 + private isUsingItem = false + private ready = false + onlineMode = false + get username () { + return bot.player?.username ?? '' + } + + reactive = proxy({ + playerSkin: undefined as string | undefined, + }) + + static getInstance (): PlayerStateManager { + if (!this.instance) { + this.instance = new PlayerStateManager() + } + return this.instance + } + + private constructor () { + this.updateState = this.updateState.bind(this) + customEvents.on('mineflayerBotCreated', () => { + this.ready = false + bot.on('inject_allowed', () => { + if (this.ready) return + this.ready = true + this.botCreated() + }) + }) + } + + private botCreated () { + // Movement tracking + bot.on('move', this.updateState) + + // Item tracking + bot.on('heldItemChanged', () => { + return this.updateHeldItem(false) + }) + bot.inventory.on('updateSlot', (index) => { + if (index === 45) this.updateHeldItem(true) + }) + bot.on('physicsTick', () => { + if (this.isUsingItem) this.itemUsageTicks++ + }) + + // Initial held items setup + this.updateHeldItem(false) + this.updateHeldItem(true) + } + + // #region Movement and Physics State + private updateState () { + if (!bot.player?.entity || this.disableStateUpdates) return + + const { velocity } = bot.player.entity + const isOnGround = bot.entity.onGround + const VELOCITY_THRESHOLD = 0.01 + const SPRINTING_VELOCITY = 0.15 + const OFF_GROUND_THRESHOLD = 0 // ms before switching to SNEAKING when off ground + + const now = performance.now() + const deltaTime = now - this.lastUpdateTime + this.lastUpdateTime = now + + this.lastVelocity = velocity + + // Update time off ground + if (isOnGround) { + this.timeOffGround = 0 + } else { + this.timeOffGround += deltaTime + } + + if (this.isSneaking() || this.isFlying() || (this.timeOffGround > OFF_GROUND_THRESHOLD)) { + this.movementState = 'SNEAKING' + } else if (Math.abs(velocity.x) > VELOCITY_THRESHOLD || Math.abs(velocity.z) > VELOCITY_THRESHOLD) { + this.movementState = Math.abs(velocity.x) > SPRINTING_VELOCITY || Math.abs(velocity.z) > SPRINTING_VELOCITY + ? 'SPRINTING' + : 'WALKING' + } else { + this.movementState = 'NOT_MOVING' + } + } + + getMovementState (): MovementState { + return this.movementState + } + + getVelocity (): Vec3 { + return this.lastVelocity + } + + getEyeHeight (): number { + return bot.controlState.sneak ? 1.27 : bot.entity?.['eyeHeight'] ?? 1.62 + } + + isOnGround (): boolean { + return bot?.entity?.onGround ?? true + } + + isSneaking (): boolean { + return gameAdditionalState.isSneaking + } + + isFlying (): boolean { + return gameAdditionalState.isFlying + } + + isSprinting (): boolean { + return gameAdditionalState.isSprinting + } + // #endregion + + // #region Held Item State + private updateHeldItem (isLeftHand: boolean) { + const newItem = isLeftHand ? bot.inventory.slots[45] : bot.heldItem + if (!newItem) { + if (isLeftHand) { + this.offHandItem = undefined + } else { + this.heldItem = undefined + } + this.events.emit('heldItemChanged', undefined, isLeftHand) + return + } + + const block = loadedData.blocksByName[newItem.name] + const blockProperties = block ? new window.PrismarineBlock(block.id, 'void', newItem.metadata).getProperties() : {} + const item: HandItemBlock = { + name: newItem.name, + properties: blockProperties, + id: newItem.type, + type: block ? 'block' : 'item', + fullItem: newItem, + } + + if (isLeftHand) { + this.offHandItem = item + } else { + this.heldItem = item + } + this.events.emit('heldItemChanged', item, isLeftHand) + } + + startUsingItem () { + if (this.isUsingItem) return + this.isUsingItem = true + this.itemUsageTicks = 0 + } + + stopUsingItem () { + this.isUsingItem = false + this.itemUsageTicks = 0 + } + + getItemUsageTicks (): number { + return this.itemUsageTicks + } + + getHeldItem (isLeftHand = false): HandItemBlock | undefined { + return isLeftHand ? this.offHandItem : this.heldItem + } + + getItemSelector (specificProperties: ItemSpecificContextProperties, item?: import('prismarine-item').Item): ItemSelector['properties'] { + return { + ...specificProperties, + 'minecraft:date': new Date(), + // "minecraft:context_dimension": bot.entityp, + 'minecraft:time': bot.time.timeOfDay / 24_000, + } + } + // #endregion +} + +export const playerState = PlayerStateManager.getInstance() +window.playerState = playerState diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index 9ea25920..31e84f58 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -91,7 +91,6 @@ export const guiOptionsScheme: { unit: '', tooltip: 'Additional distance to keep the chunks loading before unloading them by marking them as too far', }, - handDisplay: {}, renderDebug: { values: [ 'advanced', @@ -248,6 +247,12 @@ export const guiOptionsScheme: { ['classic', 'Classic'] ], }, + showHand: { + text: 'Show Hand', + }, + viewBobbing: { + text: 'View Bobbing', + }, }, { custom () { diff --git a/src/optionsStorage.ts b/src/optionsStorage.ts index 2228993f..ff29f981 100644 --- a/src/optionsStorage.ts +++ b/src/optionsStorage.ts @@ -51,7 +51,8 @@ const defaultOptions = { enabledResourcepack: null as string | null, useVersionsTextures: 'latest', serverResourcePacks: 'prompt' as 'prompt' | 'always' | 'never', - handDisplay: false, + showHand: true, + viewBobbing: true, packetsLoggerPreset: 'all' as 'all' | 'no-buffers', serversAutoVersionSelect: 'auto' as 'auto' | 'latest' | '1.20.4' | string, diff --git a/src/parseServerAddress.ts b/src/parseServerAddress.ts index 17331252..acedf70a 100644 --- a/src/parseServerAddress.ts +++ b/src/parseServerAddress.ts @@ -5,6 +5,8 @@ export const parseServerAddress = (address: string | undefined, removeHttp = tru return { host: '', isWebSocket: false, serverIpFull: '' } } + if (/^ws:[^/]/.test(address)) address = address.replace('ws:', 'ws://') + if (/^wss:[^/]/.test(address)) address = address.replace('wss:', 'wss://') const isWebSocket = address.startsWith('ws://') || address.startsWith('wss://') if (isWebSocket) { return { host: address, isWebSocket: true, serverIpFull: address } diff --git a/src/react/AddServerOrConnect.tsx b/src/react/AddServerOrConnect.tsx index 46964a90..4e62e31e 100644 --- a/src/react/AddServerOrConnect.tsx +++ b/src/react/AddServerOrConnect.tsx @@ -6,7 +6,7 @@ import Screen from './Screen' import Input from './Input' import Button from './Button' import SelectGameVersion from './SelectGameVersion' -import { useIsSmallWidth, usePassesWindowDimensions } from './simpleHooks' +import { usePassesScaledDimensions } from './UIProvider' export interface BaseServerInfo { ip: string @@ -35,7 +35,7 @@ interface Props { const ELEMENTS_WIDTH = 190 export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQs, onQsConnect, placeholders, accounts, versions, allowAutoConnect }: Props) => { - const isSmallHeight = !usePassesWindowDimensions(null, 350) + const isSmallHeight = !usePassesScaledDimensions(null, 350) const qsParamName = parseQs ? appQueryParams.name : undefined const qsParamIp = parseQs ? appQueryParams.ip : undefined const qsParamVersion = parseQs ? appQueryParams.version : undefined @@ -54,7 +54,7 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ const [usernameOverride, setUsernameOverride] = React.useState(initialData?.usernameOverride ?? qsParamUsername ?? '') const lockConnect = qsParamLockConnect === 'true' - const smallWidth = useIsSmallWidth() + const smallWidth = !usePassesScaledDimensions(400) const initialAccount = initialData?.authenticatedAccountOverride const [accountIndex, setAccountIndex] = React.useState(initialAccount === true ? -2 : initialAccount ? (accounts?.includes(initialAccount) ? accounts.indexOf(initialAccount) : -2) : -1) @@ -126,6 +126,8 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ } }, []) + const displayConnectButton = qsParamIp + return
{!lockConnect && <> @@ -219,17 +225,29 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ {!lockConnect && <> { onBack() - }}>Cancel - Save + }}> + Cancel + + + {displayConnectButton ? 'Save' : Save} + } - {qsParamIp &&
- { - onQsConnect?.(commonUseOptions) - }} - >Connect -
} + {displayConnectButton && ( +
+ { + onQsConnect?.(commonUseOptions) + }} + > + Connect + +
+ )}
diff --git a/src/react/DebugOverlay.tsx b/src/react/DebugOverlay.tsx index 54d8cb40..61bec590 100644 --- a/src/react/DebugOverlay.tsx +++ b/src/react/DebugOverlay.tsx @@ -33,7 +33,7 @@ export default () => { const [blockL, setBlockL] = useState(0) const [biomeId, setBiomeId] = useState(0) const [day, setDay] = useState(0) - const [entitiesCount, setEntitiesCount] = useState(0) + const [entitiesCount, setEntitiesCount] = useState('0') const [dimension, setDimension] = useState('') const [cursorBlock, setCursorBlock] = useState(null) const minecraftYaw = useRef(0) @@ -106,7 +106,7 @@ export default () => { setDimension(bot.game.dimension) setDay(bot.time.day) setCursorBlock(bot.blockAtCursor(5)) - setEntitiesCount(Object.values(bot.entities).length) + setEntitiesCount(`${viewer.entities.entitiesRenderingCount} (${Object.values(bot.entities).length})`) }, 100) // @ts-expect-error diff --git a/src/react/GlobalSearchInput.tsx b/src/react/GlobalSearchInput.tsx index 05b102ac..96ac4e48 100644 --- a/src/react/GlobalSearchInput.tsx +++ b/src/react/GlobalSearchInput.tsx @@ -13,6 +13,7 @@ function InnerSearch () { margin: 'auto', zIndex: 11, width: 'min-content', + transform: 'scale(1.5)' }} > { +const checkModalAvailability = () => { const last = activeModalStack.at(-1) let withWildCardModal = false - for (const modal of watchedModalsFromHooks) { + for (const modal of watchedModalsFromHooks.value) { if (modal.endsWith('*') && last?.reactType.startsWith(modal.slice(0, -1))) { withWildCardModal = true break } } - componentActive.enabled = !!last && !hardcodedKnownModals.some(x => last.reactType.startsWith(x)) && !watchedModalsFromHooks.has(last.reactType) && !withWildCardModal + componentActive.enabled = !!last && !hardcodedKnownModals.some(x => last.reactType.startsWith(x)) && !watchedModalsFromHooks.value.has(last.reactType) && !withWildCardModal +} + +subscribe(activeModalStack, () => { + checkModalAvailability() +}) +subscribe(watchedModalsFromHooks, () => { + checkModalAvailability() }) export default () => { diff --git a/src/react/SignEditorProvider.tsx b/src/react/SignEditorProvider.tsx index fbf30667..676c7ff5 100644 --- a/src/react/SignEditorProvider.tsx +++ b/src/react/SignEditorProvider.tsx @@ -6,10 +6,10 @@ import SignEditor, { ResultType } from './SignEditor' const isWysiwyg = async () => { - const items = await bot.tabComplete('/', true, true) - const commands = new Set(['data']) + const items = await bot.tabComplete('/data ', true, true) + const commands = new Set(['merge']) for (const item of items) { - if (commands.has(item.match as unknown as string)) { + if (commands.has((item.match ?? item) as unknown as string)) { return true } } diff --git a/src/react/UIProvider.tsx b/src/react/UIProvider.tsx new file mode 100644 index 00000000..31d038a0 --- /dev/null +++ b/src/react/UIProvider.tsx @@ -0,0 +1,30 @@ +import { createContext, useContext, useEffect, useState } from 'react' +import { useMedia } from 'react-use' + +export const ScaleContext = createContext(1) + +export const useScale = () => useContext(ScaleContext) + +export const UIProvider = ({ children, scale = 1 }) => { + return ( + + {children} + + ) +} + + +export const usePassesScaledDimensions = (minWidth: number | null = null, minHeight: number | null = null) => { + const scale = useScale() + const conditions: string[] = [] + + if (minWidth !== null) { + conditions.push(`(min-width: ${minWidth * scale}px)`) + } + if (minHeight !== null) { + conditions.push(`(min-height: ${minHeight * scale}px)`) + } + + const media = conditions.join(' and ') || 'all' + return useMedia(media) +} diff --git a/src/react/utilsApp.ts b/src/react/utilsApp.ts index 445007ec..859cd10b 100644 --- a/src/react/utilsApp.ts +++ b/src/react/utilsApp.ts @@ -1,9 +1,11 @@ -import { useSnapshot } from 'valtio' +import { proxy, useSnapshot } from 'valtio' import { useEffect, useMemo } from 'react' import { useMedia } from 'react-use' import { activeModalStack, miscUiState } from '../globalState' -export const watchedModalsFromHooks = new Set() +export const watchedModalsFromHooks = proxy({ + value: new Set() +}) // todo should not be there export const hardcodedKnownModals = [ 'player_win:', @@ -15,12 +17,12 @@ export const useUsingTouch = () => { } export const useIsModalActive = (modal: string, useIncludes = false) => { useMemo(() => { - watchedModalsFromHooks.add(modal) + watchedModalsFromHooks.value.add(modal) }, []) useEffect(() => { // watchedModalsFromHooks.add(modal) return () => { - watchedModalsFromHooks.delete(modal) + watchedModalsFromHooks.value.delete(modal) } }, []) diff --git a/src/reactUi.tsx b/src/reactUi.tsx index 46a7ad81..fc508a3a 100644 --- a/src/reactUi.tsx +++ b/src/reactUi.tsx @@ -49,6 +49,8 @@ import DebugEdges from './react/DebugEdges' import GameInteractionOverlay from './react/GameInteractionOverlay' import MineflayerPluginHud from './react/MineflayerPluginHud' import MineflayerPluginConsole from './react/MineflayerPluginConsole' +import { UIProvider } from './react/UIProvider' +import { useAppScale } from './scaleInterface' const RobustPortal = ({ children, to }) => { return createPortal({children}, to) @@ -175,45 +177,47 @@ const WidgetDisplay = ({ name, Component }) => { } const App = () => { - return
- - -
- - - -
-
- - - - - - - - - - - - - - - - - - {/* - */} - - - {/* todo correct mounting! */} -
- -
-
- - - -
+ const scale = useAppScale() + return ( + +
+ + +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + +
+ + ) } const PerComponentErrorBoundary = ({ children }) => { diff --git a/src/rendererUtils.ts b/src/rendererUtils.ts index 0b78835e..12081649 100644 --- a/src/rendererUtils.ts +++ b/src/rendererUtils.ts @@ -1,67 +1,96 @@ import { subscribeKey } from 'valtio/utils' import { gameAdditionalState } from './globalState' import { options } from './optionsStorage' +import { playerState } from './mineflayer/playerState' let currentFov = 0 let targetFov = 0 let lastUpdateTime = 0 const FOV_TRANSITION_DURATION = 200 // milliseconds +// TODO: These should be configured based on your game's settings +const BASE_MOVEMENT_SPEED = 0.1 // Default walking speed in Minecraft +const FOV_EFFECT_SCALE = 1 // Equivalent to Minecraft's FOV Effects slider + const updateFovAnimation = () => { - if (currentFov === targetFov) return + if (!bot) return - const now = performance.now() - const elapsed = now - lastUpdateTime - const progress = Math.min(elapsed / FOV_TRANSITION_DURATION, 1) + // Calculate base FOV modifier + let fovModifier = 1 - // Smooth easing function - const easeOutCubic = (t: number) => 1 - (1 - t) ** 3 - - currentFov += (targetFov - currentFov) * easeOutCubic(progress) - - if (Math.abs(currentFov - targetFov) < 0.01) { - currentFov = targetFov + // Flying modifier + if (gameAdditionalState.isFlying) { + fovModifier *= 1.05 } - viewer.camera.fov = currentFov - viewer.camera.updateProjectionMatrix() + // Movement speed modifier + // TODO: Get actual movement speed attribute value + const movementSpeedAttr = (bot.entity?.attributes?.['generic.movement_speed'] || bot.entity?.attributes?.['minecraft:movement_speed'] || bot.entity?.attributes?.['movement_speed'] || bot.entity?.attributes?.['minecraft:movementSpeed'])?.value || BASE_MOVEMENT_SPEED + let currentSpeed = BASE_MOVEMENT_SPEED + // todo + if (bot.controlState?.sprint && !bot.controlState?.sneak) { + currentSpeed *= 1.3 + } + fovModifier *= (currentSpeed / movementSpeedAttr + 1) / 2 + + // Validate fov modifier + if (Math.abs(BASE_MOVEMENT_SPEED) < Number.EPSILON || isNaN(fovModifier) || !isFinite(fovModifier)) { + fovModifier = 1 + } + + // Item usage modifier + if (playerState.getHeldItem()) { + const heldItem = playerState.getHeldItem() + if (heldItem?.name === 'bow' && playerState.getItemUsageTicks() > 0) { + const ticksUsingItem = playerState.getItemUsageTicks() + let usageProgress = ticksUsingItem / 20 + if (usageProgress > 1) { + usageProgress = 1 + } else { + usageProgress *= usageProgress + } + fovModifier *= 1 - usageProgress * 0.15 + } + // TODO: Add spyglass/scope check here if needed + } + + // Apply FOV effect scale + fovModifier = 1 + (fovModifier - 1) * FOV_EFFECT_SCALE + + // Calculate target FOV + const baseFov = gameAdditionalState.isZooming ? 30 : options.fov + targetFov = baseFov * fovModifier + + // Smooth transition + const now = performance.now() + if (currentFov !== targetFov) { + const elapsed = now - lastUpdateTime + const progress = Math.min(elapsed / FOV_TRANSITION_DURATION, 1) + const easeOutCubic = (t: number) => 1 - (1 - t) ** 3 + + currentFov += (targetFov - currentFov) * easeOutCubic(progress) + + if (Math.abs(currentFov - targetFov) < 0.01) { + currentFov = targetFov + } + + viewer.camera.fov = currentFov + viewer.camera.updateProjectionMatrix() + } + lastUpdateTime = now } export const watchFov = () => { - const updateFov = () => { - if (!bot) return - let fov = gameAdditionalState.isZooming ? 30 : options.fov - - if (bot.controlState.sprint && !bot.controlState.sneak) { - fov += 5 - } - if (gameAdditionalState.isFlying) { - fov += 5 - } - - if (targetFov !== fov) { - targetFov = fov - lastUpdateTime = performance.now() - } - } - - customEvents.on('gameLoaded', () => { - updateFov() - }) - - updateFov() - - // Add FOV animation to render loop + // Initial FOV setup if (!beforeRenderFrame.includes(updateFovAnimation)) { beforeRenderFrame.push(updateFovAnimation) } - subscribeKey(options, 'fov', updateFov) - subscribeKey(gameAdditionalState, 'isFlying', updateFov) - subscribeKey(gameAdditionalState, 'isSprinting', updateFov) - subscribeKey(gameAdditionalState, 'isZooming', updateFov) + customEvents.on('gameLoaded', () => { + updateFovAnimation() + }) + subscribeKey(gameAdditionalState, 'isSneaking', () => { - viewer.isSneaking = gameAdditionalState.isSneaking viewer.setFirstPersonCamera(bot.entity.position, bot.entity.yaw, bot.entity.pitch) }) } diff --git a/src/scaleInterface.ts b/src/scaleInterface.ts index c7e08622..28fcc690 100644 --- a/src/scaleInterface.ts +++ b/src/scaleInterface.ts @@ -1,6 +1,8 @@ -import { proxy } from 'valtio' +import { proxy, useSnapshot } from 'valtio' import { subscribeKey } from 'valtio/utils' +import { useMedia } from 'react-use' import { options, watchValue } from './optionsStorage' +import { useScale } from './react/UIProvider' export const currentScaling = proxy({ scale: 1, @@ -9,11 +11,11 @@ window.currentScaling = currentScaling const setScale = () => { const scaleValues = [ - { maxWidth: 971, maxHeight: null, scale: 2 }, + { maxWidth: 980, maxHeight: null, scale: 2 }, { maxWidth: null, maxHeight: 390, scale: 1.5 }, // todo allow to set the scaling at 360-400 (dynamic scaling setting) - { maxWidth: 590, maxHeight: null, scale: 1 }, + { maxWidth: 620, maxHeight: null, scale: 1 }, - { maxWidth: 590, minHeight: 240, scale: 1.4 }, + { maxWidth: 620, minHeight: 240, scale: 1.4 }, ] const { innerWidth, innerHeight } = window @@ -35,3 +37,7 @@ watchValue(currentScaling, (c) => { document.documentElement.style.setProperty('--guiScale', String(c.scale)) }) window.addEventListener('resize', setScale) + +export const useAppScale = () => { + return useSnapshot(currentScaling).scale +} diff --git a/src/shims/dns.js b/src/shims/dns.js index 22160ff3..a2bcc34c 100644 --- a/src/shims/dns.js +++ b/src/shims/dns.js @@ -9,6 +9,24 @@ module.exports.resolveSrv = function (hostname, callback) { Http.responseType = 'json' Http.send() + const minecraftServerHostname = hostname.startsWith('_minecraft._tcp.') ? hostname.slice('_minecraft._tcp.'.length) : null + if (minecraftServerHostname) { + Http.onerror = async function () { + try { + if (!globalThis.resolveDnsFallback) return + const result = await globalThis.resolveDnsFallback(minecraftServerHostname) + callback(null, result ? [{ + priority: 0, + weight: 0, + port: result.port, + name: result.host + }] : []) + } catch (err) { + callback(err) + } + } + } + Http.onload = function () { const { response } = Http if (response.Status === 3) { diff --git a/src/watchOptions.ts b/src/watchOptions.ts index e248c459..2afb011e 100644 --- a/src/watchOptions.ts +++ b/src/watchOptions.ts @@ -96,6 +96,7 @@ export const watchOptionsAfterWorldViewInit = () => { watchValue(options, o => { if (!worldView) return worldView.keepChunksDistance = o.keepChunksDistance - viewer.world.config.displayHand = o.handDisplay + viewer.world.config.showHand = o.showHand + viewer.world.config.viewBobbing = o.viewBobbing }) } diff --git a/src/worldInteractions.ts b/src/worldInteractions.ts index 80143303..a3210231 100644 --- a/src/worldInteractions.ts +++ b/src/worldInteractions.ts @@ -25,6 +25,7 @@ import { options } from './optionsStorage' import { itemBeingUsed } from './react/Crosshair' import { isCypress } from './standaloneUtils' import { displayClientChat } from './botUtils' +import { playerState } from './mineflayer/playerState' function getViewDirection (pitch, yaw) { const csPitch = Math.cos(pitch) @@ -332,6 +333,7 @@ class WorldInteraction { if (item) { customEvents.emit('activateItem', item, offhand ? 45 : bot.quickBarSlot, offhand) } + playerState.startUsingItem() itemBeingUsed.name = (offhand ? bot.inventory.slots[45]?.name : bot.heldItem?.name) ?? null itemBeingUsed.hand = offhand ? 1 : 0 } @@ -343,6 +345,7 @@ class WorldInteraction { // "only foods and bow can be deactivated" - not true, shields also can be deactivated and client always sends this // if (bot.heldItem && (loadedData.foodsArray.map((f) => f.name).includes(bot.heldItem.name) || bot.heldItem.name === 'bow')) { bot.deactivateItem() + playerState.stopUsingItem() // } }