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
This commit is contained in:
Vitaly 2025-02-15 05:14:36 +03:00 committed by GitHub
commit 65af9a73c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 2141 additions and 556 deletions

View file

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

72
pnpm-lock.yaml generated
View file

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

View file

@ -28,6 +28,7 @@ const buildOptions = {
'debugger'
] : [],
sourcemap: 'linked',
target: watch ? undefined : ['ios14'],
write: false,
metafile: true,
outdir: path.join(__dirname, './dist'),

View file

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

View file

@ -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<string, ParamMeta>
private _visible = false // Default to not visible
private readonly initialValues: Record<string, any> = {} // Store initial values
private initialized = false
constructor (id: string, target: any, params?: string[], paramsMeta?: Record<string, ParamMeta>) {
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
}
}

View file

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

View file

@ -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<Pick<ItemSelector['properties'], 'minecraft:using_item' | 'minecraft:use_duration' | 'minecraft:use_cycle' | 'minecraft:display_context'>>
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<PlayerStateEvents>
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<PlayerStateEvents>
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)
}
}

View file

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

View file

@ -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<number, string>
itemFrameMaps = {} as Record<number, Array<THREE.Mesh<THREE.PlaneGeometry, THREE.MeshLambertMaterial>>>
getItemUv: undefined | ((item: Record<string, any>) => {
getItemUv: undefined | ((item: Record<string, any>, specificProps: ItemSpecificContextProperties) => {
texture: THREE.Texture;
u: number;
v: number;
@ -229,6 +230,20 @@ export class Entities extends EventEmitter {
modelName: string
})
get entitiesByName (): Record<string, SceneEntity[]> {
const byName: Record<string, SceneEntity[]> = {}
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<string, { skinUrl?: string, capeUrl?: string }>
// 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]

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,169 @@
import * as tweenJs from '@tweenjs/tween.js'
import { AnimationController } from './animationController'
export type StateProperties = Record<string, number>
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<string, number>
public currentStateName = ''
public transitioningToStateName = ''
constructor (
public getState: StateGetterFn,
public setState: StateSetterFn,
speeds?: Partial<Record<string, number>>
) {
// 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<StateProperties>): 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<StateProperties>,
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<StateProperties>,
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
}
}

View file

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

View file

@ -0,0 +1,23 @@
import { subscribeKey } from 'valtio/utils'
// eslint-disable-next-line max-params
export function watchProperty<T extends Record<string, any>, K> (asyncGetter: (value: T[keyof T]) => Promise<K>, 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)
}

View file

@ -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<HTMLImageElement> {
const img = new Image()
img.src = imageUrl
await new Promise<void>(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 }
}

View file

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

View file

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

View file

@ -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<BotEvents>
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 }) => {

View file

@ -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<WorkerSend = any, WorkerReceive = any>
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<WorkerSend = any, WorkerReceive = any>
}
}
onHandItemSwitch (item: HandItemBlock | undefined, isLeftHand: boolean): void { }
changeHandSwingingState (isAnimationPlaying: boolean, isLeftHand: boolean): void { }
abstract handleWorkerMessage (data: WorkerReceive): void

View file

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

View file

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

View file

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

View file

@ -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', () => {

View file

@ -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<string, any> } & { reactType: string })
type ContextMenuItem = { callback; label }

View file

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

View file

@ -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<string, { slice, path }> & { 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')!

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <Screen title={qsParamIp ? 'Connect to Server' : title} backdrop>
<form
style={{
@ -139,9 +141,13 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
}}
>
<div style={{
display: 'grid',
display: smallWidth ? 'flex' : 'grid',
gap: 3,
gridTemplateColumns: smallWidth ? '1fr' : '1fr 1fr'
...(smallWidth ? {
flexDirection: 'column',
} : {
gridTemplateColumns: '1fr 1fr'
})
}}
>
{!lockConnect && <>
@ -219,17 +225,29 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
{!lockConnect && <>
<ButtonWrapper onClick={() => {
onBack()
}}>Cancel</ButtonWrapper>
<ButtonWrapper type='submit'>Save</ButtonWrapper>
}}>
Cancel
</ButtonWrapper>
<ButtonWrapper type='submit'>
{displayConnectButton ? 'Save' : <strong>Save</strong>}
</ButtonWrapper>
</>}
{qsParamIp && <div style={{ gridColumn: smallWidth ? '' : 'span 2', display: 'flex', justifyContent: 'center' }}>
<ButtonWrapper
data-test-id='connect-qs'
onClick={() => {
onQsConnect?.(commonUseOptions)
}}
><strong>Connect</strong></ButtonWrapper>
</div>}
{displayConnectButton && (
<div style={{
gridColumn: smallWidth ? '' : 'span 2',
display: 'flex',
justifyContent: 'center'
}}>
<ButtonWrapper
data-test-id='connect-qs'
onClick={() => {
onQsConnect?.(commonUseOptions)
}}
>
<strong>Connect</strong>
</ButtonWrapper>
</div>
)}
</div>
</form>
</Screen>

View file

@ -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<Block | null>(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

View file

@ -13,6 +13,7 @@ function InnerSearch () {
margin: 'auto',
zIndex: 11,
width: 'min-content',
transform: 'scale(1.5)'
}}
>
<Input

View file

@ -8,17 +8,24 @@ const componentActive = proxy({
enabled: false
})
subscribe(activeModalStack, () => {
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 () => {

View file

@ -6,10 +6,10 @@ import SignEditor, { ResultType } from './SignEditor'
const isWysiwyg = async () => {
const items = await bot.tabComplete('/', true, true)
const commands = new Set<string>(['data'])
const items = await bot.tabComplete('/data ', true, true)
const commands = new Set<string>(['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
}
}

30
src/react/UIProvider.tsx Normal file
View file

@ -0,0 +1,30 @@
import { createContext, useContext, useEffect, useState } from 'react'
import { useMedia } from 'react-use'
export const ScaleContext = createContext<number>(1)
export const useScale = () => useContext(ScaleContext)
export const UIProvider = ({ children, scale = 1 }) => {
return (
<ScaleContext.Provider value={scale}>
{children}
</ScaleContext.Provider>
)
}
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)
}

View file

@ -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<string>()
export const watchedModalsFromHooks = proxy({
value: new Set<string>()
})
// 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)
}
}, [])

View file

@ -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(<PerComponentErrorBoundary>{children}</PerComponentErrorBoundary>, to)
@ -175,45 +177,47 @@ const WidgetDisplay = ({ name, Component }) => {
}
const App = () => {
return <div>
<ButtonAppProvider>
<RobustPortal to={document.body}>
<div className='overlay-bottom-scaled'>
<InGameComponent>
<HeldMapUi />
</InGameComponent>
</div>
<div />
</RobustPortal>
<EnterFullscreenButton />
<InGameUi />
<RobustPortal to={document.querySelector('#ui-root')}>
<AllWidgets />
<SingleplayerProvider />
<CreateWorldProvider />
<AppStatusProvider />
<KeybindingsScreenProvider />
<SelectOption />
<ServersListProvider />
<OptionsRenderApp />
<MainMenuRenderApp />
<NotificationProvider />
<TouchAreasControlsProvider />
<SignInMessageProvider />
<NoModalFoundProvider />
{/* <GameHud>
</GameHud> */}
</RobustPortal>
<RobustPortal to={document.body}>
{/* todo correct mounting! */}
<div className='overlay-top-scaled'>
<GamepadUiCursor />
</div>
<div />
<DebugEdges />
</RobustPortal>
</ButtonAppProvider>
</div>
const scale = useAppScale()
return (
<UIProvider scale={scale}>
<div>
<ButtonAppProvider>
<RobustPortal to={document.body}>
<div className='overlay-bottom-scaled'>
<InGameComponent>
<HeldMapUi />
</InGameComponent>
</div>
<div />
</RobustPortal>
<EnterFullscreenButton />
<InGameUi />
<RobustPortal to={document.querySelector('#ui-root')}>
<AllWidgets />
<SingleplayerProvider />
<CreateWorldProvider />
<AppStatusProvider />
<KeybindingsScreenProvider />
<SelectOption />
<ServersListProvider />
<OptionsRenderApp />
<MainMenuRenderApp />
<NotificationProvider />
<TouchAreasControlsProvider />
<SignInMessageProvider />
<NoModalFoundProvider />
</RobustPortal>
<RobustPortal to={document.body}>
<div className='overlay-top-scaled'>
<GamepadUiCursor />
</div>
<div />
<DebugEdges />
</RobustPortal>
</ButtonAppProvider>
</div>
</UIProvider>
)
}
const PerComponentErrorBoundary = ({ children }) => {

View file

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

View file

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

View file

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

View file

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

View file

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