This commit is contained in:
Vitaly 2025-02-19 17:01:03 +03:00 committed by GitHub
commit d60f9fc55f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
91 changed files with 7363 additions and 21569 deletions

View file

@ -14,13 +14,14 @@ For building the project yourself / contributing, see [Development, Debugging &
- Open any zip world file or even folder in read-write mode!
- Connect to Java servers running in both offline (cracked) and online mode* (it's possible because of proxy servers, see below)
- Integrated JS server capable of opening Java world saves in any way (folders, zip, web streaming, etc)
- Integrated JS server clone capable of opening Java world saves in any way (folders, zip, web chunks streaming, etc)
- Singleplayer mode with simple world generations!
- Works offline
- First-class touch (mobile) & controller support
- First-class keybindings configuration
- Advanced Resource pack support: Custom GUI, all textures. Server resource packs are supported with proper CORS configuration.
- Builtin JEI with recipes & descriptions for every item (JEI is creative inventory replacement)
- Builtin JEI with recipes & descriptions for almost every item (JEI is creative inventory replacement)
- Custom protocol channel extensions (eg for custom block models in the world)
- Play with friends over internet! (P2P is powered by Peer.js discovery servers)
- ~~Google Drive support for reading / saving worlds back to the cloud~~
- even even more!
@ -152,6 +153,7 @@ Server specific:
- `?proxy=<proxy_address>` - Set the proxy server address to use for the server
- `?username=<username>` - Set the username for the server
- `?lockConnect=true` - Only works then `ip` parameter is set. Disables cancel/save buttons and all inputs in the connect screen already set as parameters. Useful for integrates iframes.
- `?autoConnect=true` - Only works then `ip` and `version` parameters are set and `allowAutoConnect` is `true` in config.json! Directly connects to the specified server. Useful for integrates iframes.
- `?serversList=<list_or_url>` - `<list_or_url>` can be a list of servers in the format `ip:version,ip` or a url to a json file with the same format (array) or a txt file with line-delimited list of server IPs.
Single player specific:

View file

@ -9,6 +9,9 @@
{
"ip": "ws://play.mcraft.fun"
},
{
"ip": "ws://play2.mcraft.fun"
},
{
"ip": "kaboom.pw",
"version": "1.20.3",

View file

@ -54,8 +54,8 @@
})
})
}
window.lastError = errorOrMessage instanceof Error ? errorOrMessage : new Error(errorOrMessage)
}
window.lastError = errorOrMessage instanceof Error ? errorOrMessage : new Error(errorOrMessage)
}
window.addEventListener('unhandledrejection', (e) => onError(e.reason, true))
window.addEventListener('error', (e) => onError(e.error ?? e.message))

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

@ -4,7 +4,7 @@ import * as scenes from './scenes'
const qsScene = new URLSearchParams(window.location.search).get('scene')
const Scene: typeof BasePlaygroundScene = qsScene ? scenes[qsScene] : scenes.main
playgroundGlobalUiState.scenes = ['main', 'railsCobweb', 'floorRandom', 'lightingStarfield', 'transparencyIssue', 'entities', 'frequentUpdates', 'slabsOptimization']
playgroundGlobalUiState.scenes = ['main', 'railsCobweb', 'floorRandom', 'lightingStarfield', 'transparencyIssue', 'entities', 'frequentUpdates', 'slabsOptimization', 'allEntities']
playgroundGlobalUiState.selected = qsScene ?? 'main'
const scene = new Scene()

View file

@ -0,0 +1,165 @@
import { BasePlaygroundScene } from '../baseScene'
import { EntityDebugFlags, EntityMesh, rendererSpecialHandled } from '../../viewer/lib/entity/EntityMesh'
export default class AllEntities extends BasePlaygroundScene {
continuousRender = false
enableCameraControls = false
async initData () {
await super.initData()
// Create results container
const container = document.createElement('div')
container.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-height: 90vh;
overflow-y: auto;
background: rgba(0,0,0,0.8);
color: white;
padding: 20px;
border-radius: 8px;
font-family: monospace;
min-width: 400px;
`
document.body.appendChild(container)
// Add title
const title = document.createElement('h2')
title.textContent = 'Minecraft Entity Support'
title.style.cssText = 'margin-top: 0; text-align: center;'
container.appendChild(title)
// Test entities
const results: Array<{
entity: string;
supported: boolean;
type?: 'obj' | 'bedrock';
mappedFrom?: string;
textureMap?: boolean;
errors?: string[];
}> = []
const { mcData } = window
const entityNames = Object.keys(mcData.entitiesArray.reduce((acc, entity) => {
acc[entity.name] = true
return acc
}, {}))
// Add loading indicator
const loading = document.createElement('div')
loading.textContent = 'Testing entities...'
loading.style.textAlign = 'center'
container.appendChild(loading)
for (const entity of entityNames) {
const debugFlags: EntityDebugFlags = {}
try {
// eslint-disable-next-line no-new
new EntityMesh(this.version, entity, viewer.world, {}, debugFlags)
results.push({
entity,
supported: !!debugFlags.type || rendererSpecialHandled.includes(entity),
type: debugFlags.type,
mappedFrom: debugFlags.tempMap,
textureMap: debugFlags.textureMap,
errors: debugFlags.errors
})
} catch (e) {
console.error(e)
results.push({
entity,
supported: false,
mappedFrom: debugFlags.tempMap
})
}
}
// Remove loading indicator
loading.remove()
const createSection = (title: string, items: any[], filter: (item: any) => boolean) => {
const section = document.createElement('div')
section.style.marginBottom = '20px'
const sectionTitle = document.createElement('h3')
sectionTitle.textContent = title
sectionTitle.style.textAlign = 'center'
section.appendChild(sectionTitle)
const list = document.createElement('ul')
list.style.cssText = 'padding-left: 20px; list-style-type: none; margin: 0;'
const filteredItems = items.filter(filter)
for (const item of filteredItems) {
const listItem = document.createElement('li')
listItem.style.cssText = 'line-height: 1.4; margin: 8px 0;'
const entityName = document.createElement('strong')
entityName.style.cssText = 'user-select: text;-webkit-user-select: text;'
entityName.textContent = item.entity
listItem.appendChild(entityName)
let text = ''
if (item.mappedFrom) {
text += ` -> ${item.mappedFrom}`
}
if (item.type) {
text += ` - ${item.type}`
}
if (item.textureMap) {
text += ' ⚠️'
}
if (item.errors) {
text += ' ❌'
}
listItem.appendChild(document.createTextNode(text))
list.appendChild(listItem)
}
section.appendChild(list)
return { section, count: filteredItems.length }
}
// Sort results - bedrock first
results.sort((a, b) => {
if (a.type === 'bedrock' && b.type !== 'bedrock') return -1
if (a.type !== 'bedrock' && b.type === 'bedrock') return 1
return a.entity.localeCompare(b.entity)
})
// Add sections
const sections = [
{
title: '❌ Unsupported Entities',
filter: (r: any) => !r.supported && !r.mappedFrom
},
{
title: '⚠️ Partially Supported Entities',
filter: (r: any) => r.mappedFrom
},
{
title: '✅ Supported Entities',
filter: (r: any) => r.supported && !r.mappedFrom
}
]
for (const { title, filter } of sections) {
const { section, count } = createSection(title, results, filter)
if (count > 0) {
container.appendChild(section)
}
}
// log object with errors per entity
const errors = results.filter(r => r.errors).map(r => ({
entity: r.entity,
errors: r.errors
}))
console.log(errors)
}
}

View file

@ -8,3 +8,4 @@ export { default as rotationIssue } from './rotationIssue'
export { default as entities } from './entities'
export { default as frequentUpdates } from './frequentUpdates'
export { default as slabsOptimization } from './slabsOptimization'
export { default as allEntities } from './allEntities'

View file

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

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,88 @@
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 {
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'
@ -20,10 +19,12 @@ import * as Entity from './entity/EntityMesh'
import { getMesh } from './entity/EntityMesh'
import { WalkingGeneralSwing } from './entity/animations'
import { disposeObject } from './threeJsUtils'
import { armorModels } from './entity/objModels'
import { armorModel, armorTextures } from './entity/armorModels'
import { Viewer } from './viewer'
import { getBlockMeshFromModel } from './holdingBlock'
const { loadTexture } = globalThis.isElectron ? require('./utils.electron.js') : require('./utils')
import { ItemSpecificContextProperties } from './basePlayerState'
import { loadSkinImage, getLookupUrl, stevePngUrl, steveTexture } from './utils/skins'
import { loadTexture } from './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,75 +339,26 @@ 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')
const renderEars = this.viewer.world.config.renderEars || username === 'deadmau5'
void this.loadAndApplySkin(entityId, skinUrl, renderEars).then(() => {
if (capeUrl) {
if (capeUrl === true && username) {
capeUrl = getLookupUrl(username, 'cape')
}
if (typeof capeUrl === 'string') {
void this.loadAndApplyCape(entityId, capeUrl)
}
}
skinTexture.magFilter = THREE.NearestFilter
skinTexture.minFilter = THREE.NearestFilter
skinTexture.needsUpdate = true
playerObject.skin.map = skinTexture
playerObject.skin.modelType = inferModelType(skinTexture.image)
const earsCanvas = document.createElement('canvas')
loadEarsToCanvasFromSkin(earsCanvas, image)
if (isCanvasBlank(earsCanvas)) {
playerObject.ears.map = null
playerObject.ears.visible = false
} else {
const earsTexture = new THREE.CanvasTexture(earsCanvas)
earsTexture.magFilter = THREE.NearestFilter
earsTexture.minFilter = THREE.NearestFilter
earsTexture.needsUpdate = true
//@ts-expect-error
playerObject.ears.map = earsTexture
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
@ -401,11 +370,93 @@ export class Entities extends EventEmitter {
}
playerObject.cape.map = null
}
}
function isCanvasBlank (canvas) {
return !canvas.getContext('2d')
.getImageData(0, 0, canvas.width, canvas.height).data
.some(channel => channel !== 0)
private async loadAndApplySkin (entityId: string | number, skinUrl: string, renderEars: boolean) {
let playerObject = this.getPlayerObject(entityId)
if (!playerObject) return
try {
let playerCustomSkinImage: HTMLImageElement | undefined
playerObject = this.getPlayerObject(entityId)
if (!playerObject) return
let skinTexture: THREE.Texture
let skinCanvas: HTMLCanvasElement
if (skinUrl === stevePngUrl) {
skinTexture = await steveTexture
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Failed to get context')
ctx.drawImage(skinTexture.image, 0, 0)
skinCanvas = canvas
} else {
const { canvas, image } = await loadSkinImage(skinUrl)
playerCustomSkinImage = image
skinTexture = new THREE.CanvasTexture(canvas)
skinCanvas = canvas
}
skinTexture.magFilter = THREE.NearestFilter
skinTexture.minFilter = THREE.NearestFilter
skinTexture.needsUpdate = true
playerObject.skin.map = skinTexture as any
playerObject.skin.modelType = inferModelType(skinCanvas)
let earsCanvas: HTMLCanvasElement | undefined
if (!playerCustomSkinImage) {
renderEars = false
} else if (renderEars) {
earsCanvas = document.createElement('canvas')
loadEarsToCanvasFromSkin(earsCanvas, playerCustomSkinImage)
renderEars = !this.isCanvasBlank(earsCanvas)
}
if (renderEars) {
const earsTexture = new THREE.CanvasTexture(earsCanvas!)
earsTexture.magFilter = THREE.NearestFilter
earsTexture.minFilter = THREE.NearestFilter
earsTexture.needsUpdate = true
//@ts-expect-error
playerObject.ears.map = earsTexture
playerObject.ears.visible = true
} else {
playerObject.ears.map = null
playerObject.ears.visible = false
}
this.onSkinUpdate?.()
} catch (error) {
console.error('Error loading skin:', error)
}
}
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 +498,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 +598,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 +680,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)
@ -659,7 +714,13 @@ export class Entities extends EventEmitter {
}
}
// ---
// not player
// set baby size
if (meta.baby) {
e.scale.set(0.5, 0.5, 0.5)
} else {
e.scale.set(1, 1, 1)
}
// entity specific meta
const textDisplayMeta = getSpecificEntityMetadata('text_display', entity)
const displayTextRaw = textDisplayMeta?.text || meta.custom_name_visible && meta.custom_name
const displayText = this.parseEntityLabel(displayTextRaw)
@ -781,7 +842,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)
@ -849,7 +912,7 @@ export class Entities extends EventEmitter {
mapMesh.rotation.set(0, Math.PI, 0)
entityMesh.add(mapMesh)
let isInvisible = false
let isInvisible = true
entityMesh.traverseVisible(c => {
if (c.name === 'geometry_frame') {
isInvisible = false
@ -945,16 +1008,16 @@ 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]
if (!texturePath) {
// TODO: Support resource pack
// TODO: Support mirroring on certain parts of the model
texturePath = armorModels[`${armorMaterial}Layer${layer}${overlay ? 'Overlay' : ''}`]
const armorTextureName = `${armorMaterial}_layer_${layer}${overlay ? '_overlay' : ''}`
texturePath = viewer.world.customTextures.armor?.textures[armorTextureName]?.src ?? armorTextures[armorTextureName]
}
if (!texturePath || !armorModels.armorModel[slotType]) {
if (!texturePath || !armorModel[slotType]) {
removeArmorModel(entityMesh, slotType)
return
}
@ -973,7 +1036,7 @@ function addArmorModel (entityMesh: THREE.Object3D, slotType: string, item: Item
material.map = texture
})
} else {
mesh = getMesh(viewer.world, texturePath, armorModels.armorModel[slotType])
mesh = getMesh(viewer.world, texturePath, armorModel[slotType])
mesh.name = meshName
material = mesh.material
if (!isPlayerHead) {
@ -991,6 +1054,8 @@ function addArmorModel (entityMesh: THREE.Object3D, slotType: string, item: Item
material.color.setHex(0xB5_6D_51) // default brown color
}
addArmorModel(entityMesh, slotType, item, layer, true)
} else {
material.color.setHex(0xFF_FF_FF)
}
const group = new THREE.Object3D()
group.name = `armor_${slotType}${overlay ? '_overlay' : ''}`

View file

@ -2,13 +2,14 @@ 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 arrowTexture from '../../../../node_modules/mc-assets/dist/other-textures/1.21.2/entity/projectiles/arrow.png'
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 { loadTexture } from '../utils'
import entities from './entities.json'
import { externalModels } from './objModels'
import externalTexturesJson from './externalTextures.json'
// import { loadTexture } from globalThis.isElectron ? '../utils.electron.js' : '../utils';
const { loadTexture } = globalThis.isElectron ? require('../utils.electron.js') : require('../utils')
interface ElemFace {
dir: [number, number, number]
@ -150,7 +151,8 @@ function addCube (
sameTextureForAllFaces = false,
texWidth = 64,
texHeight = 64,
mirror = false
mirror = false,
errors: string[] = []
): void {
const cubeRotation = new THREE.Euler(0, 0, 0)
if (cube.rotation) {
@ -173,6 +175,14 @@ function addCube (
u = (cube.uv[0] + dot(pos[3] ? u1 : u0, cube.size)) / texWidth
v = (cube.uv[1] + dot(pos[4] ? v1 : v0, cube.size)) / texHeight
}
// if (isNaN(u) || isNaN(v)) {
// errors.push(`NaN u: ${u}, v: ${v}`)
// continue
// }
// if (u < 0 || u > 1 || v < 0 || v > 1) {
// errors.push(`u: ${u}, v: ${v} out of range`)
// continue
// }
const posX = eastOrWest && mirror ? pos[0] ^ 1 : pos[0]
const posY = pos[1]
@ -213,22 +223,24 @@ function addCube (
}
export function getMesh (
worldRenderer: WorldRendererCommon,
worldRenderer: WorldRendererCommon | undefined,
texture: string,
jsonModel: JsonModel,
overrides: EntityOverrides = {}
overrides: EntityOverrides = {},
debugFlags: EntityDebugFlags = {}
): THREE.SkinnedMesh {
let textureWidth = jsonModel.texturewidth ?? 64
let textureHeight = jsonModel.textureheight ?? 64
let textureOffset
let textureOffset: number[] | undefined
const useBlockTexture = texture.startsWith('block:')
const blocksTexture = worldRenderer.material.map!
const blocksTexture = worldRenderer?.material.map
if (useBlockTexture) {
if (!worldRenderer) throw new Error('worldRenderer is required for block textures')
const blockName = texture.slice(6)
const textureInfo = worldRenderer.blocksAtlasParser!.getTextureInfo(blockName)
if (textureInfo) {
textureWidth = blocksTexture.image.width
textureHeight = blocksTexture.image.height
textureWidth = blocksTexture!.image.width
textureHeight = blocksTexture!.image.height
textureOffset = [textureInfo.u, textureInfo.v]
} else {
console.error(`Unknown block ${blockName}`)
@ -272,7 +284,12 @@ export function getMesh (
if (jsonBone.cubes) {
for (const cube of jsonBone.cubes) {
addCube(geoData, i, bone, cube, useBlockTexture, textureWidth, textureHeight, jsonBone.mirror)
const errors: string[] = []
addCube(geoData, i, bone, cube, useBlockTexture, textureWidth, textureHeight, jsonBone.mirror, errors)
if (errors.length) {
debugFlags.errors ??= []
debugFlags.errors.push(...errors.map(error => `Bone ${jsonBone.name}: ${error}`))
}
}
}
i++
@ -305,12 +322,12 @@ export function getMesh (
if (textureOffset) {
// todo(memory) dont clone
const loadedTexture = blocksTexture.clone()
const loadedTexture = blocksTexture!.clone()
loadedTexture.offset.set(textureOffset[0], textureOffset[1])
loadedTexture.needsUpdate = true
material.map = loadedTexture
} else {
loadTexture(texture, loadedTexture => {
void loadTexture(texture, loadedTexture => {
if (material.map) {
// texture is already loaded
return
@ -321,54 +338,65 @@ export function getMesh (
loadedTexture.wrapS = THREE.RepeatWrapping
loadedTexture.wrapT = THREE.RepeatWrapping
material.map = loadedTexture
const actualWidth = loadedTexture.image?.width
}, () => {
// This callback runs after the texture is fully loaded
const actualWidth = material.map!.image.width
if (actualWidth && textureWidth !== actualWidth) {
loadedTexture.repeat.x = textureWidth / actualWidth
material.map!.repeat.x = textureWidth / actualWidth
}
const actualHeight = loadedTexture.image?.height
const actualHeight = material.map!.image.height
if (actualHeight && textureHeight !== actualHeight) {
loadedTexture.repeat.y = textureHeight / actualHeight
material.map!.repeat.y = textureHeight / actualHeight
}
material.needsUpdate = true
})
}
return mesh
}
export const knownNotHandled: string[] = [
'area_effect_cloud', 'block_display',
'chest_boat', 'end_crystal',
'falling_block', 'furnace_minecart',
'giant', 'glow_item_frame',
'glow_squid', 'illusioner',
'interaction', 'item',
'item_display', 'item_frame',
'lightning_bolt', 'marker',
'painting', 'spawner_minecart',
'spectral_arrow', 'tnt',
'trader_llama', 'zombie_horse'
export const rendererSpecialHandled = ['item_frame', 'item', 'player']
type EntityMapping = {
pattern: string | RegExp
target: string
}
const temporaryMappings: EntityMapping[] = [
// Exact matches
{ pattern: 'furnace_minecart', target: 'minecart' },
{ pattern: 'spawner_minecart', target: 'minecart' },
{ pattern: 'chest_minecart', target: 'minecart' },
{ pattern: 'hopper_minecart', target: 'minecart' },
{ pattern: 'command_block_minecart', target: 'minecart' },
{ pattern: 'tnt_minecart', target: 'minecart' },
{ pattern: 'glow_item_frame', target: 'item_frame' },
{ pattern: 'glow_squid', target: 'squid' },
{ pattern: 'trader_llama', target: 'llama' },
{ pattern: 'chest_boat', target: 'boat' },
{ pattern: 'spectral_arrow', target: 'arrow' },
{ pattern: 'husk', target: 'zombie' },
{ pattern: 'zombie_horse', target: 'horse' },
{ pattern: 'donkey', target: 'horse' },
{ pattern: 'skeleton_horse', target: 'horse' },
{ pattern: 'mule', target: 'horse' },
{ pattern: 'ocelot', target: 'cat' },
// Regex patterns
{ pattern: /_minecraft$/, target: 'minecraft' },
{ pattern: /_boat$/, target: 'boat' },
{ pattern: /_raft$/, target: 'boat' },
{ pattern: /_horse$/, target: 'horse' },
{ pattern: /_zombie$/, target: 'zombie' },
{ pattern: /_arrow$/, target: 'zombie' },
]
export const temporaryMap: Record<string, string> = {
'furnace_minecart': 'minecart',
'spawner_minecart': 'minecart',
'chest_minecart': 'minecart',
'hopper_minecart': 'minecart',
'command_block_minecart': 'minecart',
'tnt_minecart': 'minecart',
'glow_item_frame': 'item_frame',
'glow_squid': 'squid',
'trader_llama': 'llama',
'chest_boat': 'boat',
'spectral_arrow': 'arrow',
'husk': 'zombie',
'zombie_horse': 'horse',
'donkey': 'horse',
'skeleton_horse': 'horse',
'mule': 'horse',
'ocelot': 'cat',
// 'falling_block': 'block',
// 'lightning_bolt': 'lightning',
function getEntityMapping (type: string): string | undefined {
for (const mapping of temporaryMappings) {
if (typeof mapping.pattern === 'string') {
if (mapping.pattern === type) return mapping.target
} else if (mapping.pattern.test(type)) { return mapping.target }
}
return undefined
}
const getEntity = (name: string) => {
@ -377,13 +405,15 @@ const getEntity = (name: string) => {
const scaleEntity: Record<string, number> = {
zombie: 1.85,
husk: 1.85
husk: 1.85,
arrow: 0.0025
}
const offsetEntity: Record<string, Vec3> = {
zombie: new Vec3(0, 1, 0),
husk: new Vec3(0, 1, 0),
boat: new Vec3(0, -1, 0),
arrow: new Vec3(0, -0.9, 0)
}
interface EntityGeometry {
@ -393,39 +423,51 @@ interface EntityGeometry {
}>;
}
export type EntityDebugFlags = {
type?: 'obj' | 'bedrock'
tempMap?: string
textureMap?: boolean
errors?: string[]
isHardcodedTexture?: boolean
}
export class EntityMesh {
mesh: THREE.Object3D
constructor (
version: string,
type: string,
worldRenderer: WorldRendererCommon,
overrides: EntityOverrides = {}
worldRenderer?: WorldRendererCommon,
overrides: EntityOverrides = {},
debugFlags: EntityDebugFlags = {}
) {
const originalType = type
const mappedValue = temporaryMap[type]
if (mappedValue) type = mappedValue
const mappedValue = getEntityMapping(type)
if (mappedValue) {
type = mappedValue
debugFlags.tempMap = mappedValue
}
if (externalModels[type]) {
const objLoader = new OBJLoader()
let texturePath = externalTexturesJson[type]
if (originalType === 'zombie_horse') {
texturePath = `textures/${version}/entity/horse/horse_zombie.png`
const texturePathMap = {
'zombie_horse': `textures/${version}/entity/horse/horse_zombie.png`,
'husk': huskPng,
'skeleton_horse': `textures/${version}/entity/horse/horse_skeleton.png`,
'donkey': `textures/${version}/entity/horse/donkey.png`,
'mule': `textures/${version}/entity/horse/mule.png`,
'ocelot': `textures/${version}/entity/cat/ocelot.png`,
'arrow': arrowTexture,
'spectral_arrow': spectralArrowTexture,
'tipped_arrow': tippedArrowTexture
}
if (originalType === 'husk') {
texturePath = huskPng
const tempTextureMap = texturePathMap[originalType] || texturePathMap[type]
if (tempTextureMap) {
debugFlags.textureMap = true
}
if (originalType === 'skeleton_horse') {
texturePath = `textures/${version}/entity/horse/horse_skeleton.png`
}
if (originalType === 'donkey') {
texturePath = `textures/${version}/entity/horse/donkey.png`
}
if (originalType === 'mule') {
texturePath = `textures/${version}/entity/horse/mule.png`
}
if (originalType === 'ocelot') {
texturePath = `textures/${version}/entity/cat/ocelot.png`
const texturePath = tempTextureMap || externalTexturesJson[type]
if (externalTexturesJson[type]) {
debugFlags.isHardcodedTexture = true
}
if (!texturePath) throw new Error(`No texture for ${type}`)
const texture = new THREE.TextureLoader().load(texturePath)
@ -437,7 +479,7 @@ export class EntityMesh {
alphaTest: 0.1
})
const obj = objLoader.parse(externalModels[type])
const scale = scaleEntity[originalType]
const scale = scaleEntity[originalType] || scaleEntity[type]
if (scale) obj.scale.set(scale, scale, scale)
const offset = offsetEntity[originalType]
if (offset) obj.position.set(offset.x, offset.y, offset.z)
@ -454,13 +496,22 @@ export class EntityMesh {
}
})
this.mesh = obj
debugFlags.type = 'obj'
return
}
if (originalType === 'arrow') {
// overrides.textures = {
// 'default': testArrow,
// ...overrides.textures,
// }
}
const e = getEntity(type)
if (!e) {
if (knownNotHandled.includes(type)) return
throw new Error(`Unknown entity ${type}`)
// if (knownNotHandled.includes(type)) return
// throw new Error(`Unknown entity ${type}`)
return
}
this.mesh = new THREE.Object3D()
@ -472,7 +523,8 @@ export class EntityMesh {
texture.endsWith('.png') || texture.startsWith('data:image/') || texture.startsWith('block:')
? texture : texture + '.png',
jsonModel,
overrides)
overrides,
debugFlags)
mesh.name = `geometry_${name}`
this.mesh.add(mesh)
@ -482,10 +534,11 @@ export class EntityMesh {
skeletonHelper.visible = false
this.mesh.add(skeletonHelper)
}
debugFlags.type = 'bedrock'
}
static getStaticData (name: string): { boneNames: string[] } {
name = temporaryMap[name] || name
name = getEntityMapping(name) || name
if (externalModels[name]) {
return {
boneNames: [] // todo

View file

@ -1,36 +1,35 @@
/*
* prismarine-web-client - prismarine-web-client
* Copyright (C) 2024 Max Lee aka Phoenix616 (mail@moep.tv)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// TODO: replace with load from resource pack
export { default as chainmailLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/chainmail_layer_1.png'
export { default as chainmailLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/chainmail_layer_2.png'
export { default as diamondLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/diamond_layer_1.png'
export { default as diamondLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/diamond_layer_2.png'
export { default as goldenLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/gold_layer_1.png'
export { default as goldenLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/gold_layer_2.png'
export { default as ironLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/iron_layer_1.png'
export { default as ironLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/iron_layer_2.png'
export { default as leatherLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/leather_layer_1.png'
export { default as leatherLayer1Overlay } from 'mc-assets/dist/other-textures/latest/models/armor/leather_layer_1_overlay.png'
export { default as leatherLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/leather_layer_2.png'
export { default as leatherLayer2Overlay } from 'mc-assets/dist/other-textures/latest/models/armor/leather_layer_2_overlay.png'
export { default as netheriteLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/netherite_layer_1.png'
export { default as netheriteLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/netherite_layer_2.png'
export { default as turtleLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/turtle_layer_1.png'
import { default as chainmailLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/chainmail_layer_1.png'
import { default as chainmailLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/chainmail_layer_2.png'
import { default as diamondLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/diamond_layer_1.png'
import { default as diamondLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/diamond_layer_2.png'
import { default as goldenLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/gold_layer_1.png'
import { default as goldenLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/gold_layer_2.png'
import { default as ironLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/iron_layer_1.png'
import { default as ironLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/iron_layer_2.png'
import { default as leatherLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/leather_layer_1.png'
import { default as leatherLayer1Overlay } from 'mc-assets/dist/other-textures/latest/models/armor/leather_layer_1_overlay.png'
import { default as leatherLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/leather_layer_2.png'
import { default as leatherLayer2Overlay } from 'mc-assets/dist/other-textures/latest/models/armor/leather_layer_2_overlay.png'
import { default as netheriteLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/netherite_layer_1.png'
import { default as netheriteLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/netherite_layer_2.png'
import { default as turtleLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/turtle_layer_1.png'
export { default as armorModel } from './armorModels.json'
export const armorTextures = {
'leather_layer_1': leatherLayer1,
'leather_layer_1_overlay': leatherLayer1Overlay,
'leather_layer_2': leatherLayer2,
'leather_layer_2_overlay': leatherLayer2Overlay,
'chainmail_layer_1': chainmailLayer1,
'chainmail_layer_2': chainmailLayer2,
'iron_layer_1': ironLayer1,
'iron_layer_2': ironLayer2,
'diamond_layer_1': diamondLayer1,
'diamond_layer_2': diamondLayer2,
'golden_layer_1': goldenLayer1,
'golden_layer_2': goldenLayer2,
'netherite_layer_1': netheriteLayer1,
'netherite_layer_2': netheriteLayer2,
'turtle_layer_1': turtleLayer1
}

File diff suppressed because it is too large Load diff

View file

@ -22,7 +22,8 @@ export { default as parrot } from './models/parrot.obj'
export { default as piglin } from './models/piglin.obj'
export { default as pillager } from './models/pillager.obj'
export { default as rabbit } from './models/rabbit.obj'
// export { default as sheep } from './models/sheep.obj'
export { default as sheep } from './models/sheep.obj'
export { default as arrow } from './models/arrow.obj'
export { default as shulker } from './models/shulker.obj'
export { default as sniffer } from './models/sniffer.obj'
export { default as spider } from './models/spider.obj'

View file

@ -0,0 +1,60 @@
# Aspose.3D Wavefront OBJ Exporter
# Copyright 2004-2024 Aspose Pty Ltd.
# File created: 02/12/2025 20:01:28
mtllib material.lib
g Arrow
#
# object Arrow
#
v -160 8.146034E-06 50
v 160 8.146034E-06 50
v -160 -8.146034E-06 -50
v 160 -8.146034E-06 -50
v -160 -50 1.1920929E-05
v 160 -50 1.1920929E-05
v -160 50 -1.1920929E-05
v 160 50 -1.1920929E-05
v -140 -49.999992 50.000008
v -140 50.000008 49.999992
v -140 -50.000008 -49.999992
v -140 49.999992 -50.000008
# 12 vertices
vn 0 1 -1.6292068E-07
vn 0 1 -1.6292068E-07
vn 0 1 -1.6292068E-07
vn 0 1 -1.6292068E-07
vn 0 3.1391647E-07 1
vn 0 3.1391647E-07 1
vn 0 3.1391647E-07 1
vn 0 3.1391647E-07 1
vn -1 0 0
vn -1 0 0
vn -1 0 0
vn -1 0 0
# 12 vertex normals
vt 0 0.84375 0
vt 0.5 1 0
vt 0.5 1 0
vt 0.5 0.84375 0
vt 0 1 0
vt 0.15625 0.84375 0
vt 0.15625 0.6875 0
vt 0 0.84375 0
vt 0.5 0.84375 0
vt 0 1 0
vt 0 0.6875 0
vt 0 0.84375 0
# 12 texture coords
usemtl Arrow
s 1
f 1/1/1 2/9/2 4/2/3 3/10/4
f 5/8/5 6/4/6 8/3/7 7/5/8
f 9/11/9 10/7/10 12/6/11 11/12/12
#3 polygons

View file

@ -1,2 +1 @@
export * as externalModels from './exportedModels'
export * as armorModels from './armorModels'

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
@ -85,6 +87,7 @@ const handleMessage = data => {
world ??= new World(data.config.version)
world.config = { ...world.config, ...data.config }
globalThis.world = world
globalThis.Vec3 = Vec3
}
switch (data.type) {
@ -103,19 +106,26 @@ const handleMessage = data => {
}
case 'chunk': {
world.addColumn(data.x, data.z, data.chunk)
if (data.customBlockModels) {
const chunkKey = `${data.x},${data.z}`
world.customBlockModels.set(chunkKey, data.customBlockModels)
}
break
}
case 'unloadChunk': {
world.removeColumn(data.x, data.z)
world.customBlockModels.delete(`${data.x},${data.z}`)
if (Object.keys(world.columns).length === 0) softCleanup()
break
}
case 'blockUpdate': {
const loc = new Vec3(data.pos.x, data.pos.y, data.pos.z).floored()
world.setBlockStateId(loc, data.stateId)
const chunkKey = `${Math.floor(loc.x / 16) * 16},${Math.floor(loc.z / 16) * 16}`
if (data.customBlockModels) {
world.customBlockModels.set(chunkKey, data.customBlockModels)
}
break
}
case 'reset': {

View file

@ -12,6 +12,10 @@ export const defaultMesherConfig = {
disableSignsMapsSupport: false
}
export type CustomBlockModels = {
[blockPosKey: string]: string // blockPosKey is "x,y,z" -> model name
}
export type MesherConfig = typeof defaultMesherConfig
export type MesherGeometryOutput = {
@ -36,6 +40,7 @@ export type MesherGeometryOutput = {
highestBlocks: Map<string, HighestBlockInfo>
hadErrors: boolean
blocksCount: number
customBlockModels?: CustomBlockModels
}
export type HighestBlockInfo = { y: number, stateId: number | undefined, biomeId: number | undefined }

View file

@ -5,7 +5,7 @@ import { Vec3 } from 'vec3'
import { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider'
import moreBlockDataGeneratedJson from '../moreBlockDataGenerated.json'
import legacyJson from '../../../../src/preflatMap.json'
import { defaultMesherConfig } from './shared'
import { defaultMesherConfig, CustomBlockModels } from './shared'
import { INVISIBLE_BLOCKS } from './worldConstants'
const ignoreAoBlocks = Object.keys(moreBlockDataGeneratedJson.noOcclusions)
@ -48,6 +48,7 @@ export class World {
biomeCache: { [id: number]: mcData.Biome }
preflat: boolean
erroredBlockModel?: BlockModelPartsResolved
customBlockModels = new Map<string, CustomBlockModels>() // chunkKey -> blockModels
constructor (version) {
this.Chunk = Chunks(version) as any
@ -126,6 +127,8 @@ export class World {
// for easier testing
if (!(pos instanceof Vec3)) pos = new Vec3(...pos as [number, number, number])
const key = columnKey(Math.floor(pos.x / 16) * 16, Math.floor(pos.z / 16) * 16)
const blockPosKey = `${pos.x},${pos.y},${pos.z}`
const modelOverride = this.customBlockModels.get(key)?.[blockPosKey]
const column = this.columns[key]
// null column means chunk not loaded
@ -135,10 +138,15 @@ export class World {
const locInChunk = posInChunk(loc)
const stateId = column.getBlockStateId(locInChunk)
if (!this.blockCache[stateId]) {
const cacheKey = modelOverride ? `${stateId}:${modelOverride}` : stateId
if (!this.blockCache[cacheKey]) {
const b = column.getBlock(locInChunk) as unknown as WorldBlock
if (modelOverride) {
b.name = modelOverride
}
b.isCube = isCube(b.shapes)
this.blockCache[stateId] = b
this.blockCache[cacheKey] = b
Object.defineProperty(b, 'position', {
get () {
throw new Error('position is not reliable, use pos parameter instead of block.position')
@ -163,7 +171,7 @@ export class World {
}
}
const block = this.blockCache[stateId]
const block = this.blockCache[cacheKey]
if (block.models === undefined && blockProvider) {
if (!attr) throw new Error('attr is required')
@ -188,10 +196,11 @@ export class World {
}
}
const useFallbackModel = this.preflat || modelOverride
block.models = blockProvider.getAllResolvedModels0_1({
name: block.name,
properties: props,
}, this.preflat)! // fixme! this is a hack (also need a setting for all versions)
}, useFallbackModel)! // fixme! this is a hack (also need a setting for all versions)
if (!block.models!.length) {
if (block.name !== 'water' && block.name !== 'lava' && !INVISIBLE_BLOCKS.has(block.name)) {
console.debug('[mesher] block to render not found', block.name, props)

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

@ -1,17 +0,0 @@
const path = require('path')
const THREE = require('three')
const textureCache = {}
function loadTexture(texture, cb) {
if (!textureCache[texture]) {
const url = path.resolve(__dirname, '../../public/' + texture)
textureCache[texture] = new THREE.TextureLoader().load(url)
}
cb(textureCache[texture])
}
function loadJSON(json, cb) {
cb(require(path.resolve(__dirname, '../../public/' + json)))
}
module.exports = { loadTexture, loadJSON }

View file

@ -1,57 +0,0 @@
function safeRequire(path) {
try {
return require(path)
} catch (e) {
return {}
}
}
const { loadImage } = safeRequire('node-canvas-webgl/lib')
const path = require('path')
const THREE = require('three')
const textureCache = {}
// todo not ideal, export different functions for browser and node
export function loadTexture(texture, cb) {
if (process.platform === 'browser') {
return require('./utils.web').loadTexture(texture, cb)
}
if (textureCache[texture]) {
cb(textureCache[texture])
} else {
loadImage(path.resolve(__dirname, '../../public/' + texture)).then(image => {
textureCache[texture] = new THREE.CanvasTexture(image)
cb(textureCache[texture])
})
}
}
export function loadJSON(json, cb) {
if (process.platform === 'browser') {
return require('./utils.web').loadJSON(json, cb)
}
cb(require(path.resolve(__dirname, '../../public/' + json)))
}
export const loadScript = async function (/** @type {string} */scriptSrc) {
if (document.querySelector(`script[src="${scriptSrc}"]`)) {
return
}
return new Promise((resolve, reject) => {
const scriptElement = document.createElement('script')
scriptElement.src = scriptSrc
scriptElement.async = true
scriptElement.addEventListener('load', () => {
resolve(scriptElement)
})
scriptElement.onerror = (error) => {
reject(new Error(error.message))
scriptElement.remove()
}
document.head.appendChild(scriptElement)
})
}

View file

@ -0,0 +1,47 @@
import * as THREE from 'three'
let textureCache: Record<string, THREE.Texture> = {}
let imagesPromises: Record<string, Promise<THREE.Texture>> = {}
export async function loadTexture (texture: string, cb: (texture: THREE.Texture) => void, onLoad?: () => void): Promise<void> {
const cached = textureCache[texture]
if (!cached) {
const { promise, resolve } = Promise.withResolvers<THREE.Texture>()
textureCache[texture] = new THREE.TextureLoader().load(texture, resolve)
imagesPromises[texture] = promise
}
cb(textureCache[texture])
void imagesPromises[texture].then(() => {
onLoad?.()
})
}
export const clearTextureCache = () => {
textureCache = {}
imagesPromises = {}
}
export const loadScript = async function (scriptSrc: string): Promise<HTMLScriptElement> {
const existingScript = document.querySelector<HTMLScriptElement>(`script[src="${scriptSrc}"]`)
if (existingScript) {
return existingScript
}
return new Promise((resolve, reject) => {
const scriptElement = document.createElement('script')
scriptElement.src = scriptSrc
scriptElement.async = true
scriptElement.addEventListener('load', () => {
resolve(scriptElement)
})
scriptElement.onerror = (error) => {
reject(new Error(typeof error === 'string' ? error : (error as any).message))
scriptElement.remove()
}
document.head.appendChild(scriptElement)
})
}

View file

@ -1,29 +0,0 @@
/* global XMLHttpRequest */
const THREE = require('three')
const textureCache = {}
function loadTexture(texture, cb, onLoad) {
const cached = textureCache[texture]
if (!cached) {
textureCache[texture] = new THREE.TextureLoader().load(texture, onLoad)
}
cb(textureCache[texture])
if (cached) onLoad?.()
}
function loadJSON(url, callback) {
const xhr = new XMLHttpRequest()
xhr.open('GET', url, true)
xhr.responseType = 'json'
xhr.onload = function () {
const { status } = xhr
if (status === 200) {
callback(xhr.response)
} else {
throw new Error(url + ' not found')
}
}
xhr.send()
}
module.exports = { loadTexture, loadJSON }

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
}
@ -114,7 +114,7 @@ export class Viewer {
})
}
if (!this.world.loadedChunks[`${sectionX},${sectionZ}`]) {
console.debug('[should be unreachable] setBlockStateId called for unloaded chunk', pos)
// console.debug('[should be unreachable] setBlockStateId called for unloaded chunk', pos)
}
this.world.setBlockStateId(pos, stateId)
}
@ -124,7 +124,6 @@ export class Viewer {
async demoModel () {
//@ts-expect-error
const pos = cursorBlockRel(0, 1, 0).position
const blockProvider = worldBlockProvider(this.world.blockstatesModels, this.world.blocksAtlasParser!.atlas, 'latest')
const mesh = await getMyHand()
// mesh.rotation.y = THREE.MathUtils.degToRad(90)
@ -139,7 +138,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 +160,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,15 +9,17 @@ 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 worldBlockProvider, { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider'
import { dynamicMcDataFiles } from '../../buildMesherConfig.mjs'
import { toMajorVersion } from '../../../src/utils'
import { buildCleanupDecorator } from './cleanupDecorator'
import { defaultMesherConfig, HighestBlockInfo, MesherGeometryOutput } from './mesher/shared'
import { defaultMesherConfig, HighestBlockInfo, MesherGeometryOutput, CustomBlockModels } from './mesher/shared'
import { chunkPos } from './simpleUtils'
import { HandItemBlock } from './holdingBlock'
import { updateStatText } from './ui/newStats'
@ -33,8 +35,10 @@ export const defaultWorldRendererConfig = {
showChunkBorders: false,
numWorkers: 4,
isPlayground: false,
renderEars: true,
// game renderer setting actually
displayHand: false
showHand: false,
viewBobbing: false
}
export type WorldRendererConfig = typeof defaultWorldRendererConfig
@ -119,12 +123,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
@ -145,7 +152,10 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
@worldCleanup()
itemsRenderer: ItemsRenderer | undefined
customBlockModels = new Map<string, CustomBlockModels>()
abstract outputFormat: 'threeJs' | 'webgpu'
worldBlockProvider: WorldBlockProvider
abstract changeBackgroundColor (color: [number, number, number]): void
@ -179,12 +189,18 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
this.geometryReceiveCount[data.workerIndex] ??= 0
this.geometryReceiveCount[data.workerIndex]++
const geometry = data.geometry as MesherGeometryOutput
for (const key in geometry.highestBlocks) {
const highest = geometry.highestBlocks[key]
if (!this.highestBlocks[key] || this.highestBlocks[key].y < highest.y) {
this.highestBlocks[key] = highest
for (const [key, highest] of geometry.highestBlocks.entries()) {
const currHighest = this.highestBlocks.get(key)
if (!currHighest || currHighest.y < highest.y) {
this.highestBlocks.set(key, highest)
}
}
// for (const key in geometry.highestBlocks) {
// const highest = geometry.highestBlocks[key]
// if (!this.highestBlocks[key] || this.highestBlocks[key].y < highest.y) {
// this.highestBlocks[key] = highest
// }
// }
const chunkCoords = data.key.split(',').map(Number)
this.lastChunkDistance = Math.max(...this.getDistance(new Vec3(chunkCoords[0], 0, chunkCoords[2])))
}
@ -204,6 +220,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
return x === chunkCoords[0] && z === chunkCoords[2]
})) {
this.finishedChunks[`${chunkCoords[0]},${chunkCoords[2]}`] = true
this.renderUpdateEmitter.emit(`chunkFinished`, `${chunkCoords[0] / 16},${chunkCoords[2] / 16}`)
}
}
this.checkAllFinished()
@ -240,7 +257,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
@ -304,7 +320,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
this.mesherConfig.version = this.version!
this.sendMesherMcData()
await this.updateTexturesData()
await this.updateAssetsData()
}
sendMesherMcData () {
@ -321,7 +337,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
}
}
async updateTexturesData (resourcePackUpdate = false, prioritizeBlockTextures?: string[]) {
async updateAssetsData (resourcePackUpdate = false, prioritizeBlockTextures?: string[]) {
const blocksAssetsParser = new AtlasParser(this.sourceData.blocksAtlases, blocksAtlasLatest, blocksAtlasLegacy)
const itemsAssetsParser = new AtlasParser(this.sourceData.itemsAtlases, itemsAtlasLatest, itemsAtlasLegacy)
@ -345,6 +361,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
this.itemsAtlasParser = new AtlasParser({ latest: itemsAtlas }, itemsCanvas.toDataURL())
this.itemsRenderer = new ItemsRenderer(this.version!, this.blockstatesModels, this.itemsAtlasParser, this.blocksAtlasParser)
this.worldBlockProvider = worldBlockProvider(this.blockstatesModels, this.blocksAtlasParser.atlas, 'latest')
const texture = await new THREE.TextureLoader().loadAsync(this.blocksAtlasParser.latestImage)
texture.magFilter = THREE.NearestFilter
@ -398,9 +415,18 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
this.initialChunkLoadWasStartedIn ??= Date.now()
this.loadedChunks[`${x},${z}`] = true
this.updateChunksStatsText()
const chunkKey = `${x},${z}`
const customBlockModels = this.customBlockModels.get(chunkKey)
for (const worker of this.workers) {
// todo optimize
worker.postMessage({ type: 'chunk', x, z, chunk })
worker.postMessage({
type: 'chunk',
x,
z,
chunk,
customBlockModels: customBlockModels || undefined
})
}
for (let y = this.worldMinYRender; y < this.worldConfig.worldHeight; y += 16) {
const loc = new Vec3(x, y, z)
@ -450,8 +476,17 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
setBlockStateId (pos: Vec3, stateId: number) {
const needAoRecalculation = true
const chunkKey = `${Math.floor(pos.x / 16) * 16},${Math.floor(pos.z / 16) * 16}`
const blockPosKey = `${pos.x},${pos.y},${pos.z}`
const customBlockModels = this.customBlockModels.get(chunkKey) || {}
for (const worker of this.workers) {
worker.postMessage({ type: 'blockUpdate', pos, stateId })
worker.postMessage({
type: 'blockUpdate',
pos,
stateId,
customBlockModels
})
}
this.setSectionDirty(pos, true, true)
if (this.neighborChunkUpdates) {

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

@ -11,24 +11,6 @@ const mapIncludeDefined = (props) => {
}
}
/*
TODO (nbt)
{
"extra": [
{
"italic": 0,
"underlined": 0,
"bold": 0,
"color": "aqua",
"obfuscated": 0,
"strikethrough": 0,
"text": "minecraft:lift"
}
],
"text": ""
}
*/
test('formatMessage', () => {
const result = formatMessage({
'json': {

View file

@ -524,13 +524,18 @@ contro.on('trigger', ({ command }) => {
if (command === 'ui.toggleFullscreen') {
void goFullscreen(true)
}
})
if (command === 'ui.toggleMap') {
if (activeModalStack.at(-1)?.reactType === 'full-map') {
hideModal({ reactType: 'full-map' })
} else {
showModal({ reactType: 'full-map' })
}
// show-hide Fullmap
contro.on('trigger', ({ command }) => {
if (command !== 'ui.toggleMap') return
const isActive = isGameActive(true)
if (activeModalStack.at(-1)?.reactType === 'full-map') {
miscUiState.displayFullmap = false
hideModal({ reactType: 'full-map' })
} else if (isActive && !activeModalStack.length) {
miscUiState.displayFullmap = true
showModal({ reactType: 'full-map' })
}
})

75
src/customChannels.ts Normal file
View file

@ -0,0 +1,75 @@
import { Vec3 } from 'vec3'
import { options } from './optionsStorage'
customEvents.on('mineflayerBotCreated', async () => {
if (!options.customChannels) return
await new Promise(resolve => {
bot.once('login', () => {
resolve(true)
})
})
const CHANNEL_NAME = 'minecraft-web-client:blockmodels'
const packetStructure = [
'container',
[
{
name: 'worldName', // currently not used
type: ['pstring', { countType: 'i16' }]
},
{
name: 'x',
type: 'i32'
},
{
name: 'y',
type: 'i32'
},
{
name: 'z',
type: 'i32'
},
{
name: 'model',
type: ['pstring', { countType: 'i16' }]
}
]
]
bot._client.registerChannel(CHANNEL_NAME, packetStructure, true)
bot._client.on(CHANNEL_NAME as any, (data) => {
const { worldName, x, y, z, model } = data
console.debug('Received model data:', { worldName, x, y, z, model })
if (viewer?.world) {
const chunkX = Math.floor(x / 16) * 16
const chunkZ = Math.floor(z / 16) * 16
const chunkKey = `${chunkX},${chunkZ}`
const blockPosKey = `${x},${y},${z}`
const chunkModels = viewer.world.customBlockModels.get(chunkKey) || {}
if (model) {
chunkModels[blockPosKey] = model
} else {
delete chunkModels[blockPosKey]
}
if (Object.keys(chunkModels).length > 0) {
viewer.world.customBlockModels.set(chunkKey, chunkModels)
} else {
viewer.world.customBlockModels.delete(chunkKey)
}
// Trigger update
const block = worldView!.world.getBlock(new Vec3(x, y, z))
if (block) {
worldView!.world.setBlockStateId(new Vec3(x, y, z), block.stateId)
}
}
})
console.debug(`registered custom channel ${CHANNEL_NAME} channel`)
})

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', () => {
@ -133,3 +146,17 @@ Object.defineProperty(window, 'debugToggle', {
console.log('Enabled debug for', v)
}
})
customEvents.on('gameLoaded', () => {
window.holdingBlock = (viewer.world as WorldRendererThree).holdingBlock
})
window.clearStorage = (...keysToKeep: string[]) => {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key && !keysToKeep.includes(key)) {
localStorage.removeItem(key)
}
}
return `Cleared ${localStorage.length - keysToKeep.length} items from localStorage. Kept: ${keysToKeep.join(', ')}`
}

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 }
@ -137,6 +142,7 @@ export const miscUiState = proxy({
usingGamepadInput: false,
appConfig: null as AppConfig | null,
displaySearchInput: false,
displayFullmap: false
})
export const isGameActive = (foregroundCheck: boolean) => {

View file

@ -5,6 +5,7 @@ import './testCrasher'
import './globals'
import './devtools'
import './entities'
import './customChannels'
import './globalDomListeners'
import './mineflayer/maps'
import './mineflayer/cameraShake'
@ -66,6 +67,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,9 +106,11 @@ 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'
import ping from './mineflayer/plugins/ping'
window.debug = debug
window.THREE = THREE
@ -161,21 +165,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 +421,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)
@ -630,22 +644,6 @@ export async function connect (connectOptions: ConnectOptions) {
})
})
})
let i = 0
//@ts-expect-error
bot.pingProxy = async () => {
const curI = ++i
return new Promise(resolve => {
//@ts-expect-error
bot._client.socket._ws.send(`ping:${curI}`)
const date = Date.now()
const onPong = (received) => {
if (received !== curI.toString()) return
bot._client.socket.off('pong' as any, onPong)
resolve(Date.now() - date)
}
bot._client.socket.on('pong' as any, onPong)
})
}
}
// socket setup actually can be delayed because of dns lookup
if (bot._client.socket) {
@ -663,6 +661,10 @@ export async function connect (connectOptions: ConnectOptions) {
} catch (err) {
handleError(err)
}
if (connectOptions.server) {
bot.loadPlugin(ping)
}
if (!bot) return
const p2pConnectTimeout = p2pMultiplayer ? setTimeout(() => { throw new UserError('Spawn timeout. There might be error on the other side, check console.') }, 20_000) : undefined
@ -712,6 +714,13 @@ export async function connect (connectOptions: ConnectOptions) {
worldInteractions.initBot()
setLoadingScreenStatus('Loading world')
const mcData = MinecraftData(bot.version)
window.PrismarineBlock = PrismarineBlock(mcData.version.minecraftVersion!)
window.PrismarineItem = PrismarineItem(mcData.version.minecraftVersion!)
window.loadedData = mcData
window.Vec3 = Vec3
window.pathfinder = pathfinder
})
const spawnEarlier = !singleplayer && !p2pMultiplayer
@ -732,18 +741,14 @@ export async function connect (connectOptions: ConnectOptions) {
}
window.focus?.()
errorAbortController.abort()
const mcData = MinecraftData(bot.version)
window.PrismarineBlock = PrismarineBlock(mcData.version.minecraftVersion!)
window.PrismarineItem = PrismarineItem(mcData.version.minecraftVersion!)
window.loadedData = mcData
window.Vec3 = Vec3
window.pathfinder = pathfinder
miscUiState.gameLoaded = true
miscUiState.loadedServerIndex = connectOptions.serverIndex ?? ''
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 +1002,4 @@ if (initialLoader) {
}
window.pageLoaded = true
if (!reconnectOptions) {
void possiblyHandleStateVariable()
}
void possiblyHandleStateVariable()

File diff suppressed because it is too large Load diff

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

@ -38,7 +38,7 @@ export const getItemMetadata = (item: GeneralInputItem) => {
const customTextComponent = componentMap.get('custom_name') || componentMap.get('item_name')
if (customTextComponent) {
customText = customTextComponent.data.value
customText = nbt.simplify(customTextComponent.data)
}
const customModelComponent = componentMap.get('item_model')
if (customModelComponent) {
@ -70,6 +70,9 @@ export const getItemNameRaw = (item: Pick<import('prismarine-item').Item, 'nbt'>
const { customText } = getItemMetadata(item as any)
if (!customText) return
try {
if (typeof customText === 'object') {
return customText
}
const parsed = customText.startsWith('{') && customText.endsWith('}') ? mojangson.simplify(mojangson.parse(customText)) : fromFormattedString(customText)
if (parsed.extra) {
return parsed as Record<string, any>
@ -78,7 +81,7 @@ export const getItemNameRaw = (item: Pick<import('prismarine-item').Item, 'nbt'>
}
} catch (err) {
return {
text: customText
text: JSON.stringify(customText)
}
}
}

View file

@ -18,6 +18,8 @@ const writeCmd = (cmd: string) => {
bot.chat(cmd)
}
let msg = 0
const LIMIT_MSG = 100
export const javaServerTester = {
itemCustomLore () {
const cmd = customStickNbt({
@ -50,5 +52,17 @@ export const javaServerTester = {
custom_name: [{ translate: 'item.diamond.name' }]
})
writeCmd(cmd)
},
spamChat () {
for (let i = msg; i < msg + LIMIT_MSG; i++) {
bot.chat('Hello, world, ' + i)
}
msg += LIMIT_MSG
},
spamChatComplexMessage () {
for (let i = msg; i < msg + LIMIT_MSG; i++) {
bot.chat('/tell @a ' + i)
}
}
}

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

@ -0,0 +1,42 @@
import { versionToNumber } from 'renderer/viewer/prepare/utils'
export default () => {
let i = 0
bot.pingProxy = async () => {
const curI = ++i
return new Promise(resolve => {
//@ts-expect-error
bot._client.socket._ws.send(`ping:${curI}`)
const date = Date.now()
const onPong = (received) => {
if (received !== curI.toString()) return
bot._client.socket.off('pong' as any, onPong)
resolve(Date.now() - date)
}
bot._client.socket.on('pong' as any, onPong)
})
}
let pingId = 0
bot.pingServer = async () => {
if (versionToNumber(bot.version) < versionToNumber('1.20.2')) return bot.player.ping
return new Promise<number>((resolve) => {
const curId = pingId++
bot._client.write('ping_request', { id: BigInt(curId) })
const date = Date.now()
const onPong = (data: { id: bigint }) => {
if (BigInt(data.id) !== BigInt(curId)) return
bot._client.off('ping_response' as any, onPong)
resolve(Date.now() - date)
}
bot._client.on('ping_response' as any, onPong)
})
}
}
declare module 'mineflayer' {
interface Bot {
pingProxy: () => Promise<number>
pingServer: () => Promise<number | undefined>
}
}

View file

@ -91,7 +91,9 @@ export const guiOptionsScheme: {
unit: '',
tooltip: 'Additional distance to keep the chunks loading before unloading them by marking them as too far',
},
handDisplay: {},
renderEars: {
tooltip: 'Enable rendering Deadmau5 ears for all players if their skin contains textures for it',
},
renderDebug: {
values: [
'advanced',
@ -248,6 +250,12 @@ export const guiOptionsScheme: {
['classic', 'Classic']
],
},
showHand: {
text: 'Show Hand',
},
viewBobbing: {
text: 'View Bobbing',
},
},
{
custom () {

View file

@ -46,14 +46,17 @@ const defaultOptions = {
unimplementedContainers: false,
dayCycleAndLighting: true,
loadPlayerSkins: true,
renderEars: true,
lowMemoryMode: false,
starfieldRendering: true,
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,
customChannels: false,
// antiAliasing: false,

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,13 +35,14 @@ 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
const qsParamProxy = parseQs ? appQueryParams.proxy : undefined
const qsParamUsername = parseQs ? appQueryParams.username : undefined
const qsParamLockConnect = parseQs ? appQueryParams.lockConnect : undefined
const qsParamAutoConnect = parseQs ? appQueryParams.autoConnect : undefined
const parsedQsIp = parseServerAddress(qsParamIp)
const parsedInitialIp = parseServerAddress(initialData?.ip)
@ -54,7 +55,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)
@ -121,11 +122,13 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
}
useEffect(() => {
if (qsParamIp && qsParamVersion && allowAutoConnect) {
if (qsParamAutoConnect && qsParamIp && qsParamVersion && allowAutoConnect) {
onQsConnect?.(commonUseOptions)
}
}, [])
const displayConnectButton = qsParamIp
return <Screen title={qsParamIp ? 'Connect to Server' : title} backdrop>
<form
style={{
@ -139,9 +142,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 +226,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

@ -36,6 +36,7 @@ export const resetAppStatusState = () => {
export const lastConnectOptions = {
value: null as ConnectOptions | null
}
globalThis.lastConnectOptions = lastConnectOptions
const saveReconnectOptions = (options: ConnectOptions) => {
sessionStorage.setItem('reconnectOptions', JSON.stringify({
@ -44,6 +45,13 @@ const saveReconnectOptions = (options: ConnectOptions) => {
}))
}
export const reconnectReload = () => {
if (lastConnectOptions.value) {
saveReconnectOptions(lastConnectOptions.value)
window.location.reload()
}
}
export default () => {
const { isError, lastStatus, maybeRecoverable, status, hideDots, descriptionHint, loadingChunksData, loadingChunksDataPlayerChunk, minecraftJsonMessage, showReconnect } = useSnapshot(appStatusState)
const { active: replayActive } = useSnapshot(packetsReplaceSessionState)
@ -70,13 +78,6 @@ export default () => {
}))
}
const reconnectReload = () => {
if (lastConnectOptions.value) {
saveReconnectOptions(lastConnectOptions.value)
window.location.reload()
}
}
useEffect(() => {
const controller = new AbortController()
window.addEventListener('keyup', (e) => {

View file

@ -55,7 +55,8 @@ div.chat-wrapper {
transform: none;
top: 100%;
padding-left: calc(env(safe-area-inset-left) / 2);
margin-top: 20px;
margin-top: 14px;
margin-left: 20px;
/* input height */
}

View file

@ -6,6 +6,7 @@ import './Chat.css'
import { isIos, reactKeyForMessage } from './utils'
import Button from './Button'
import { pixelartIcons } from './PixelartIcon'
import { useScrollBehavior } from './hooks/useScrollBehavior'
export type Message = {
parts: MessageFormatPart[],
@ -69,6 +70,7 @@ export default ({
placeholder
}: Props) => {
const sendHistoryRef = useRef(JSON.parse(window.sessionStorage.chatHistory || '[]'))
const [isInputFocused, setIsInputFocused] = useState(false)
const [completePadText, setCompletePadText] = useState('')
const completeRequestValue = useRef('')
@ -77,10 +79,11 @@ export default ({
const chatInput = useRef<HTMLInputElement>(null!)
const chatMessages = useRef<HTMLDivElement>(null)
const openedChatWasAtBottom = useRef(false)
const chatHistoryPos = useRef(sendHistoryRef.current.length)
const inputCurrentlyEnteredValue = useRef('')
const { scrollToBottom } = useScrollBehavior(chatMessages, { messages, opened })
const setSendHistory = (newHistory: string[]) => {
sendHistoryRef.current = newHistory
window.sessionStorage.chatHistory = JSON.stringify(newHistory)
@ -104,6 +107,11 @@ export default ({
}, 0)
}
const auxInputFocus = (fireKey: string) => {
chatInput.current.focus()
chatInput.current.dispatchEvent(new KeyboardEvent('keydown', { code: fireKey, bubbles: true }))
}
useEffect(() => {
// todo focus input on any keypress except tab
}, [])
@ -120,16 +128,31 @@ export default ({
if (!usingTouch) {
chatInput.current.focus()
}
const unsubscribe = subscribe(chatInputValueGlobal, () => {
// Add keyboard event listener for letter keys and paste
const handleKeyDown = (e: KeyboardEvent) => {
// Check if it's a single character key (works with any layout) without modifiers except shift
const isSingleChar = e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey
// Check if it's paste command
const isPaste = e.code === 'KeyV' && (e.ctrlKey || e.metaKey)
if ((isSingleChar || isPaste) && document.activeElement !== chatInput.current) {
chatInput.current.focus()
}
}
window.addEventListener('keydown', handleKeyDown)
const unsubscribeValtio = subscribe(chatInputValueGlobal, () => {
if (!chatInputValueGlobal.value) return
updateInputValue(chatInputValueGlobal.value)
chatInputValueGlobal.value = ''
chatInput.current.focus()
})
return unsubscribe
}
if (!opened && chatMessages.current) {
chatMessages.current.scrollTop = chatMessages.current.scrollHeight
return () => {
window.removeEventListener('keydown', handleKeyDown)
unsubscribeValtio()
}
}
}, [opened])
@ -140,59 +163,6 @@ export default ({
}
}, [opened])
useEffect(() => {
if ((!opened || (opened && openedChatWasAtBottom.current)) && chatMessages.current) {
openedChatWasAtBottom.current = false
// stay at bottom on messages changes
chatMessages.current.scrollTop = chatMessages.current.scrollHeight
}
}, [messages])
useMemo(() => {
if ((opened && chatMessages.current)) {
const wasAtBottom = chatMessages.current.scrollTop === chatMessages.current.scrollHeight - chatMessages.current.clientHeight
openedChatWasAtBottom.current = wasAtBottom
// console.log(wasAtBottom, chatMessages.current.scrollTop, chatMessages.current.scrollHeight - chatMessages.current.clientHeight)
}
}, [messages])
const auxInputFocus = (fireKey: string) => {
chatInput.current.focus()
chatInput.current.dispatchEvent(new KeyboardEvent('keydown', { code: fireKey, bubbles: true }))
}
const getDefaultCompleteValue = () => {
const raw = chatInput.current.value
return raw.slice(0, chatInput.current.selectionEnd ?? raw.length)
}
const getCompleteValue = (value = getDefaultCompleteValue()) => {
const valueParts = value.split(' ')
const lastLength = valueParts.at(-1)!.length
const completeValue = lastLength ? value.slice(0, -lastLength) : value
if (valueParts.length === 1 && value.startsWith('/')) return '/'
return completeValue
}
const fetchCompletions = async (implicit: boolean, inputValue = chatInput.current.value) => {
const completeValue = getCompleteValue(inputValue)
completeRequestValue.current = completeValue
resetCompletionItems()
const newItems = await fetchCompletionItems?.(implicit ? 'implicit' : 'explicit', completeValue, inputValue) ?? []
if (completeValue !== completeRequestValue.current) return
setCompletionItemsSource(newItems)
updateFilteredCompleteItems(newItems)
}
const updateFilteredCompleteItems = (sourceItems: string[]) => {
const newCompleteItems = sourceItems.filter(item => {
// this regex is imporatnt is it controls the word matching
const compareableParts = item.split(/[[\]{},_:]/)
const lastWord = chatInput.current.value.slice(0, chatInput.current.selectionEnd ?? chatInput.current.value.length).split(' ').at(-1)!
return [item, ...compareableParts].some(compareablePart => compareablePart.startsWith(lastWord))
})
setCompletionItems(newCompleteItems)
}
const onMainInputChange = () => {
const completeValue = getCompleteValue()
setCompletePadText(completeValue === '/' ? '' : completeValue)
@ -212,6 +182,40 @@ export default ({
// }
}
const fetchCompletions = async (implicit: boolean, inputValue = chatInput.current.value) => {
const completeValue = getCompleteValue(inputValue)
completeRequestValue.current = completeValue
resetCompletionItems()
const newItems = await fetchCompletionItems?.(implicit ? 'implicit' : 'explicit', completeValue, inputValue) ?? []
if (completeValue !== completeRequestValue.current) return
setCompletionItemsSource(newItems)
updateFilteredCompleteItems(newItems)
}
const updateFilteredCompleteItems = (sourceItems: string[] | Array<{ match: string, toolip: string }>) => {
const newCompleteItems = sourceItems
.map(item => (typeof item === 'string' ? item : item.match))
.filter(item => {
// this regex is imporatnt is it controls the word matching
const compareableParts = item.split(/[[\]{},_:]/)
const lastWord = chatInput.current.value.slice(0, chatInput.current.selectionEnd ?? chatInput.current.value.length).split(' ').at(-1)!
return [item, ...compareableParts].some(compareablePart => compareablePart.startsWith(lastWord))
})
setCompletionItems(newCompleteItems)
}
const getDefaultCompleteValue = () => {
const raw = chatInput.current.value
return raw.slice(0, chatInput.current.selectionEnd ?? raw.length)
}
const getCompleteValue = (value = getDefaultCompleteValue()) => {
const valueParts = value.split(' ')
const lastLength = valueParts.at(-1)!.length
const completeValue = lastLength ? value.slice(0, -lastLength) : value
if (valueParts.length === 1 && value.startsWith('/')) return '/'
return completeValue
}
return (
<>
<div
@ -230,12 +234,20 @@ export default ({
{/* close button */}
{usingTouch && <Button icon={pixelartIcons.close} onClick={() => onClose?.()} />}
<div className="chat-input">
{completionItems?.length ? (
{isInputFocused && completionItems?.length ? (
<div className="chat-completions">
<div className="chat-completions-pad-text">{completePadText}</div>
<div className="chat-completions-items">
{completionItems.map((item) => (
<div key={item} onClick={() => acceptComplete(item)}>{item}</div>
<div
key={item}
onMouseDown={(e) => {
e.preventDefault() // Prevent blur before click
acceptComplete(item)
}}
>
{item}
</div>
))}
</div>
</div>
@ -249,6 +261,8 @@ export default ({
if (result !== false) {
onClose?.()
}
// Always scroll to bottom after sending a message
scrollToBottom()
}
}}
>
@ -274,6 +288,8 @@ export default ({
onChange={onMainInputChange}
disabled={!!inputDisabled}
placeholder={inputDisabled || placeholder}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
onKeyDown={(e) => {
if (e.code === 'ArrowUp') {
if (chatHistoryPos.current === 0) return

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

@ -2,23 +2,21 @@ import { Vec3 } from 'vec3'
import { useRef, useEffect, useState, CSSProperties, Dispatch, SetStateAction } from 'react'
import { WorldWarp } from 'flying-squid/dist/lib/modules/warps'
import { TransformWrapper, TransformComponent, ReactZoomPanPinchRef } from 'react-zoom-pan-pinch'
import { MinimapDrawer, DrawerAdapter, ChunkInfo } from './MinimapDrawer'
import { DrawerAdapter } from './MinimapDrawer'
import Button from './Button'
import Input from './Input'
import './Fullmap.css'
type FullmapProps = {
toggleFullMap: () => void,
adapter: DrawerAdapter,
drawer: MinimapDrawer | null,
canvasRef: any
toggleFullMap?: () => void,
}
export default ({ toggleFullMap, adapter }: FullmapProps) => {
export default ({ adapter, toggleFullMap }: FullmapProps) => {
const [grid, setGrid] = useState(() => new Set<string>())
const zoomRef = useRef<ReactZoomPanPinchRef>(null)
const redrawCell = useRef(false)
const [redraw, setRedraw] = useState<Set<string> | null>(null)
const [lastWarpPos, setLastWarpPos] = useState({ x: 0, y: 0, z: 0 })
const stateRef = useRef({ scale: 1, positionX: 0, positionY: 0 })
const cells = useRef({ columns: 0, rows: 0 })
@ -73,7 +71,7 @@ export default ({ toggleFullMap, adapter }: FullmapProps) => {
zIndex: '-1'
}}
onClick={toggleFullMap}
/>
> </div>
: <Button
icon="close-box"
onClick={toggleFullMap}
@ -129,7 +127,7 @@ export default ({ toggleFullMap, adapter }: FullmapProps) => {
worldZ={playerChunkTop + y / 4 - offsetY}
setIsWarpInfoOpened={setIsWarpInfoOpened}
setLastWarpPos={setLastWarpPos}
redraw={redrawCell.current}
redraw={redraw}
setInitWarp={setInitWarp}
setWarpPreview={setWarpPreview}
/>
@ -156,9 +154,7 @@ export default ({ toggleFullMap, adapter }: FullmapProps) => {
adapter={adapter}
warpPos={lastWarpPos}
setIsWarpInfoOpened={setIsWarpInfoOpened}
afterWarpIsSet={() => {
redrawCell.current = !redrawCell.current
}}
setRedraw={setRedraw}
initWarp={initWarp}
setInitWarp={setInitWarp}
toggleFullMap={toggleFullMap}
@ -179,16 +175,14 @@ const MapChunk = (
worldZ: number,
setIsWarpInfoOpened: (x: boolean) => void,
setLastWarpPos: (obj: { x: number, y: number, z: number }) => void,
redraw?: boolean
redraw?: Set<string> | null
setInitWarp?: (warp: WorldWarp | undefined) => void
setWarpPreview?: (warpInfo) => void
}
) => {
const containerRef = useRef(null)
const drawerRef = useRef<MinimapDrawer | null>(null)
const touchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const [isCanvas, setIsCanvas] = useState(false)
const longPress = (e) => {
touchTimer.current = setTimeout(() => {
@ -202,8 +196,8 @@ const MapChunk = (
}
const handleClick = (e: MouseEvent | TouchEvent) => {
// console.log('click:', e)
if (!drawerRef.current) return
if (!adapter.mapDrawer) return
console.log('click:', e)
let clientX: number
let clientY: number
if ('buttons' in e && e.button === 2) {
@ -217,9 +211,9 @@ const MapChunk = (
const mapX = Math.floor(x + worldX)
const mapZ = Math.floor(z + worldZ)
const y = adapter.getHighestBlockY(mapX, mapZ)
drawerRef.current.setWarpPosOnClick(new Vec3(mapX, y, mapZ))
setLastWarpPos(drawerRef.current.lastWarpPos)
const { lastWarpPos } = drawerRef.current
adapter.mapDrawer.setWarpPosOnClick(new Vec3(mapX, y, mapZ))
setLastWarpPos(adapter.mapDrawer.lastWarpPos)
const { lastWarpPos } = adapter.mapDrawer
const initWarp = adapter.warps.find(warp => Math.hypot(lastWarpPos.x - warp.x, lastWarpPos.z - warp.z) < 2)
setInitWarp?.(initWarp)
setIsWarpInfoOpened(true)
@ -227,7 +221,7 @@ const MapChunk = (
const getXZ = (clientX: number, clientY: number) => {
const rect = canvasRef.current!.getBoundingClientRect()
const factor = scale * (drawerRef.current?.mapPixel ?? 1)
const factor = scale * (adapter.mapDrawer.mapPixel ?? 1)
const x = (clientX - rect.left) / factor
const y = (clientY - rect.top) / factor
return [x, y]
@ -241,34 +235,18 @@ const MapChunk = (
)
}
const handleRedraw = (key?: string, chunk?: ChunkInfo) => {
const handleRedraw = (key?: string) => {
if (key !== `${worldX / 16},${worldZ / 16}`) return
adapter.mapDrawer.canvas = canvasRef.current!
adapter.mapDrawer.full = true
// console.log('handle redraw:', key)
// if (chunk) {
// drawerRef.current?.chunksStore.set(key, chunk)
// }
if (!adapter.chunksStore.has(key)) {
adapter.chunksStore.set(key, 'requested')
void adapter.loadChunk(key)
return
}
console.log('[mapChunk] update', key, `${worldX / 16},${worldZ / 16}`)
const timeout = setTimeout(() => {
const center = new Vec3(worldX + 8, 0, worldZ + 8)
drawerRef.current!.lastBotPos = center
drawerRef.current?.drawChunk(key)
// drawerRef.current?.drawWarps(center)
// drawerRef.current?.drawPlayerPos(center.x, center.z)
if (canvasRef.current) void adapter.drawChunkOnCanvas(`${worldX / 16},${worldZ / 16}`, canvasRef.current)
clearTimeout(timeout)
}, 100)
}
useEffect(() => {
// if (canvasRef.current && !drawerRef.current) {
// drawerRef.current = adapter.mapDrawer
// } else if (canvasRef.current && drawerRef.current) {
// }
if (canvasRef.current) void adapter.drawChunkOnCanvas(`${worldX / 16},${worldZ / 16}`, canvasRef.current)
}, [canvasRef.current])
@ -286,29 +264,15 @@ const MapChunk = (
canvasRef.current?.removeEventListener('touchmove', cancel)
canvasRef.current?.removeEventListener('mousemove', handleMouseMove)
}
}, [canvasRef.current, scale])
}, [canvasRef.current])
useEffect(() => {
// handleRedraw()
}, [drawerRef.current, redraw])
useEffect(() => {
const intersectionObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setIsCanvas(true)
}
if (redraw) {
for (const key of redraw) {
handleRedraw(key)
}
})
intersectionObserver.observe(containerRef.current!)
// adapter.on('chunkReady', handleRedraw)
return () => {
intersectionObserver.disconnect()
// adapter.off('chunkReady', handleRedraw)
}
}, [])
}, [redraw])
return <div
ref={containerRef}
@ -334,7 +298,7 @@ const MapChunk = (
}
const WarpInfo = (
{ adapter, warpPos, setIsWarpInfoOpened, afterWarpIsSet, initWarp, toggleFullMap }:
{ adapter, warpPos, setIsWarpInfoOpened, afterWarpIsSet, initWarp, toggleFullMap, setRedraw }:
{
adapter: DrawerAdapter,
warpPos: { x: number, y: number, z: number },
@ -342,7 +306,8 @@ const WarpInfo = (
afterWarpIsSet?: () => void
initWarp?: WorldWarp,
setInitWarp?: React.Dispatch<React.SetStateAction<WorldWarp | undefined>>,
toggleFullMap?: ({ command }: { command: string }) => void
toggleFullMap?: () => void,
setRedraw?: React.Dispatch<React.SetStateAction<Set<string> | null>>
}
) => {
const [warp, setWarp] = useState<WorldWarp>(initWarp ?? {
@ -365,14 +330,14 @@ const WarpInfo = (
}
const updateChunk = () => {
const redraw = new Set<string>()
for (let i = -1; i < 2; i += 1) {
for (let j = -1; j < 2; j += 1) {
adapter.emit(
'chunkReady',
`${Math.floor(warp.x / 16) + j},${Math.floor(warp.z / 16) + i}`
)
redraw.add(`${Math.floor(warp.x / 16) + j},${Math.floor(warp.z / 16) + i}`)
}
}
setRedraw?.(redraw)
console.log('[warpInfo] update', redraw)
}
const tpNow = () => {
@ -380,7 +345,7 @@ const WarpInfo = (
}
const quickTp = () => {
toggleFullMap?.({ command: 'ui.toggleMap' })
toggleFullMap?.()
adapter.quickTp?.(warp.x, warp.z)
}
@ -486,7 +451,8 @@ const WarpInfo = (
}}
>Cancel</Button>
<Button
onClick={() => {
onClick={(e) => {
e.preventDefault()
adapter.setWarp({ ...warp })
console.log(adapter.warps)
setIsWarpInfoOpened(false)

View file

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

View file

@ -12,16 +12,6 @@ export default ({ message, fallbackColor, className }: {
}) => {
const messageJson = useMemo(() => {
if (!message) return null
// const transformIfNbt = (x) => {
// if (typeof x === 'object' && x?.type) return nbt.simplify(x) as Record<string, any>
// // if (Array.isArray(x)) return x.map(transformIfNbt)
// // if (typeof x === 'object') return Object.fromEntries(Object.entries(x).map(([k, v]) => [k, transformIfNbt(v)]))
// return x
// }
// if (typeof message === 'object' && message.text?.text?.type) {
// message.text.text = transformIfNbt(message.text.text)
// message.text.extra = transformIfNbt(message.text.extra)
// }
try {
const texts = formatMessage(typeof message === 'string' ? fromFormattedString(message) : message)
return texts.map(text => {

View file

@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react'
import './MineflayerPluginConsole.css'
import { miscUiState } from '../globalState'
import { useIsModalActive } from './utilsApp'
import { useScrollBehavior } from './hooks/useScrollBehavior'
export type ConsoleMessage = {
text: string
@ -36,11 +37,14 @@ export default () => {
const consoleInput = useRef<HTMLInputElement>(null!)
const consoleMessages = useRef<HTMLDivElement>(null)
const { scrollToBottom } = useScrollBehavior(consoleMessages, { messages, opened })
// Add useEffect to focus input when opened
useEffect(() => {
if (consoleMessages.current) {
consoleMessages.current.scrollTop = consoleMessages.current.scrollHeight
if (opened && replEnabled) {
consoleInput.current?.focus()
}
}, [messages])
}, [opened, replEnabled])
const updateInputValue = (newValue: string) => {
consoleInput.current.value = newValue
@ -78,6 +82,8 @@ export default () => {
if (code) {
onExecute?.(code)
updateInputValue('')
// Scroll to bottom after sending command
scrollToBottom()
}
}}
>

View file

@ -1,13 +1,50 @@
import { proxy, useSnapshot } from 'valtio'
import { useEffect, useRef } from 'react'
import { useEffect, useRef, Fragment } from 'react'
import type { UIDefinition } from 'mcraft-fun-mineflayer/build/customChannel'
import MessageFormattedString from './MessageFormattedString'
import { useUiMotion } from './uiMotion'
import PixelartIcon from './PixelartIcon'
export const mineflayerPluginHudState = proxy({
ui: [] as Array<UIDefinition & { id: string }>,
})
type TextPart = { type: 'text'; content: string } | { type: 'icon'; iconName: string }
const parseTextWithIcons = (text: string): TextPart[] => {
const parts: TextPart[] = []
let currentText = ''
let i = 0
while (i < text.length) {
if (text[i] === '{' && text.slice(i, i + 6) === '{icon:') {
// If we have accumulated text before the icon, add it
if (currentText) {
parts.push({ type: 'text', content: currentText })
currentText = ''
}
// Find the end of the icon placeholder
const endBrace = text.indexOf('}', i)
if (endBrace !== -1) {
const iconName = text.slice(i + 6, endBrace)
parts.push({ type: 'icon', iconName })
i = endBrace + 1
continue
}
}
currentText += text[i]
i++
}
// Add any remaining text
if (currentText) {
parts.push({ type: 'text', content: currentText })
}
return parts
}
const TextElement = ({ text, x, y, motion = true, formatted = true, css = '', onTab = false }: UIDefinition & { type: 'text' }) => {
const motionRef = useRef<HTMLDivElement>(null)
const innerRef = useRef<HTMLDivElement>(null)
@ -21,6 +58,8 @@ const TextElement = ({ text, x, y, motion = true, formatted = true, css = '', on
if (onTab && !document.hidden) return null
const parts = parseTextWithIcons(text)
return (
<div
ref={motionRef}
@ -29,10 +68,21 @@ const TextElement = ({ text, x, y, motion = true, formatted = true, css = '', on
left: x,
top: y,
transition: motion ? 'transform 0.1s ease-out' : 'none',
display: 'flex',
alignItems: 'center',
gap: '2px'
}}
>
<div ref={innerRef}>
{formatted ? <MessageFormattedString message={text} /> : text}
{parts.map((part, index) => (
<Fragment key={index}>
{part.type === 'text' ? (
formatted ? <MessageFormattedString message={part.content} /> : part.content
) : (
<PixelartIcon iconName={part.iconName} width={12} styles={{ display: 'inline-block' }} />
)}
</Fragment>
))}
</div>
</div>
)
@ -58,7 +108,7 @@ export default () => {
const { ui } = useSnapshot(mineflayerPluginHudState)
return (
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none' }}>
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none' }} className='mineflayer-plugin-hud'>
{ui.map((element, index) => {
if (element.type === 'lil') return null // Handled elsewhere
if (element.type === 'text') return <TextElement key={index} {...element} />

View file

@ -1,74 +0,0 @@
import { Vec3 } from 'vec3'
import type { Meta, StoryObj } from '@storybook/react'
import { WorldWarp } from 'flying-squid/dist/lib/modules/warps'
import { TypedEventEmitter } from 'contro-max/build/typedEventEmitter'
import { useEffect } from 'react'
import Minimap from './Minimap'
import { DrawerAdapter, MapUpdates } from './MinimapDrawer'
const meta: Meta<typeof Minimap> = {
component: Minimap,
decorators: [
(Story, context) => {
useEffect(() => {
console.log('map updated')
adapter.emit('updateMap')
}, [context.args['fullMap']])
return <div> <Story /> </div>
}
]
}
export default meta
type Story = StoryObj<typeof Minimap>
class DrawerAdapterImpl extends TypedEventEmitter<MapUpdates> {
playerPosition: Vec3
yaw: number
warps: WorldWarp[]
chunksStore: any = {}
full: boolean
constructor (pos?: Vec3, warps?: WorldWarp[]) {
super()
this.playerPosition = pos ?? new Vec3(0, 0, 0)
this.warps = warps ?? [] as WorldWarp[]
}
async getHighestBlockColor (x: number, z: number) {
console.log('got color')
return 'green'
}
getHighestBlockY (x: number, z: number) {
return 0
}
setWarp (warp: WorldWarp, remove?: boolean): void {
const index = this.warps.findIndex(w => w.name === warp.name)
if (index === -1) {
this.warps.push(warp)
} else {
this.warps[index] = warp
}
this.emit('updateWarps')
}
clearChunksStore (x: number, z: number) { }
async loadChunk (key: string) {}
}
const adapter = new DrawerAdapterImpl() as any
export const Primary: Story = {
args: {
adapter,
fullMap: false
},
}

View file

@ -1,5 +1,6 @@
import { useRef, useEffect, useState } from 'react'
import { MinimapDrawer, DrawerAdapter, ChunkInfo } from './MinimapDrawer'
import { miscUiState } from '../globalState'
import { DrawerAdapter } from './MinimapDrawer'
import Fullmap from './Fullmap'
@ -13,36 +14,31 @@ export default (
showFullmap: string,
singleplayer: boolean,
fullMap?: boolean,
toggleFullMap?: ({ command }: { command: string }) => void
toggleFullMap?: () => void
displayMode?: DisplayMode
}
) => {
const full = useRef(false)
const canvasTick = useRef(0)
const canvasRef = useRef<HTMLCanvasElement>(null)
const warpsAndPartsCanvasRef = useRef<HTMLCanvasElement>(null)
const playerPosCanvasRef = useRef<HTMLCanvasElement>(null)
const warpsDrawerRef = useRef<MinimapDrawer | null>(null)
const drawerRef = useRef<MinimapDrawer | null>(null)
const playerPosDrawerRef = useRef<MinimapDrawer | null>(null)
const [position, setPosition] = useState({ x: 0, y: 0, z: 0 })
const updateMap = () => {
setPosition({ x: adapter.playerPosition.x, y: adapter.playerPosition.y, z: adapter.playerPosition.z })
if (drawerRef.current) {
if (adapter.mapDrawer) {
if (!full.current) {
rotateMap()
drawerRef.current.draw(adapter.playerPosition)
drawerRef.current.drawPlayerPos()
drawerRef.current.drawWarps()
adapter.mapDrawer.draw(adapter.playerPosition)
adapter.mapDrawer.drawPlayerPos()
adapter.mapDrawer.drawWarps()
}
if (canvasTick.current % 300 === 0 && !fullMap) {
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
drawerRef.current?.clearChunksStore()
adapter.mapDrawer?.clearChunksStore()
})
} else {
drawerRef.current.clearChunksStore()
adapter.mapDrawer.clearChunksStore()
}
canvasTick.current = 0
}
@ -53,42 +49,17 @@ export default (
const updateWarps = () => { }
const rotateMap = () => {
if (!drawerRef.current) return
drawerRef.current.canvas.style.transform = `rotate(${adapter.yaw}rad)`
if (!warpsDrawerRef.current) return
warpsDrawerRef.current.canvas.style.transform = `rotate(${adapter.yaw}rad)`
}
const updateChunkOnMap = (key: string, chunk: ChunkInfo) => {
adapter.chunksStore.set(key, chunk)
if (!adapter.mapDrawer) return
adapter.mapDrawer.canvas.style.transform = `rotate(${adapter.yaw}rad)`
adapter.mapDrawer.yaw = adapter.yaw
}
useEffect(() => {
if (canvasRef.current && !drawerRef.current) {
drawerRef.current = adapter.mapDrawer
drawerRef.current.canvas = canvasRef.current
// drawerRef.current.adapter.on('chunkReady', updateChunkOnMap)
} else if (canvasRef.current && drawerRef.current) {
drawerRef.current.canvas = canvasRef.current
if (canvasRef.current && adapter.mapDrawer && !miscUiState.displayFullmap) {
adapter.mapDrawer.canvas = canvasRef.current
adapter.mapDrawer.full = false
}
}, [canvasRef.current])
// useEffect(() => {
// if (warpsAndPartsCanvasRef.current && !warpsDrawerRef.current) {
// warpsDrawerRef.current = new MinimapDrawer(warpsAndPartsCanvasRef.current, adapter)
// } else if (warpsAndPartsCanvasRef.current && warpsDrawerRef.current) {
// warpsDrawerRef.current.canvas = warpsAndPartsCanvasRef.current
// }
// }, [warpsAndPartsCanvasRef.current])
// useEffect(() => {
// if (playerPosCanvasRef.current && !playerPosDrawerRef.current) {
// playerPosDrawerRef.current = new MinimapDrawer(playerPosCanvasRef.current, adapter)
// } else if (playerPosCanvasRef.current && playerPosDrawerRef.current) {
// playerPosDrawerRef.current.canvas = playerPosCanvasRef.current
// }
// }, [playerPosCanvasRef.current])
}, [canvasRef.current, miscUiState.displayFullmap])
useEffect(() => {
adapter.on('updateMap', updateMap)
@ -100,24 +71,12 @@ export default (
}
}, [adapter])
useEffect(() => {
return () => {
// if (drawerRef.current) drawerRef.current.adapter.off('chunkReady', updateChunkOnMap)
}
}, [])
const displayFullmap = fullMap && displayMode !== 'minimapOnly' && (showFullmap === 'singleplayer' && singleplayer || showFullmap === 'always')
const displayMini = displayMode !== 'fullmapOnly' && (showMinimap === 'singleplayer' && singleplayer || showMinimap === 'always')
return displayFullmap
return fullMap && displayMode !== 'minimapOnly' && (showFullmap === 'singleplayer' && singleplayer || showFullmap === 'always')
? <Fullmap
toggleFullMap={() => {
toggleFullMap?.({ command: 'ui.toggleMap' })
}}
toggleFullMap={toggleFullMap}
adapter={adapter}
drawer={drawerRef.current}
canvasRef={canvasRef}
/>
: displayMini
: displayMode !== 'fullmapOnly' && (showMinimap === 'singleplayer' && singleplayer || showMinimap === 'always')
? <div
className='minimap'
style={{
@ -128,7 +87,7 @@ export default (
textAlign: 'center',
}}
onClick={() => {
toggleFullMap?.({ command: 'ui.toggleMap' })
toggleFullMap?.()
}}
>
<canvas
@ -141,28 +100,6 @@ export default (
height={80}
ref={canvasRef}
/>
<canvas
style={{
transition: '0.5s',
transitionTimingFunction: 'ease-out',
position: 'absolute',
left: '0px'
}}
width={80}
height={80}
ref={warpsAndPartsCanvasRef}
/>
<canvas
style={{
transition: '0.5s',
transitionTimingFunction: 'ease-out',
position: 'absolute',
left: '0px'
}}
width={80}
height={80}
ref={playerPosCanvasRef}
/>
<div
style={{
fontSize: '0.5em',

View file

@ -43,12 +43,20 @@ export class MinimapDrawer {
lastWarpPos: Vec3
mapPixel: number
yaw: number
chunksStore = new Map<string, undefined | null | 'requested' | ChunkInfo >()
loadingChunksQueue: undefined | Set<string>
warps: WorldWarp[]
loadChunk: undefined | ((key: string) => Promise<void>)
_full = false
constructor (
public loadChunk: undefined | ((key: string) => Promise<void>),
public warps: WorldWarp[],
public loadingChunksQueue: undefined | Set<string>,
public chunksStore: Map<string, undefined | null | 'requested' | ChunkInfo >
) {
this.loadChunk = loadChunk
this.warps = warps
this.loadingChunksQueue = loadingChunksQueue
this.chunksStore = chunksStore
}
setMapPixel () {
if (this.full) {
this.radius = Math.floor(Math.min(this.canvas.width, this.canvas.height) / 2)
@ -101,6 +109,7 @@ export class MinimapDrawer {
if (!this.chunksStore.has(key) && !this.loadingChunksQueue?.has(key)) {
void this.loadChunk?.(key)
}
// case when chunk is not present is handled in drawChunk
this.drawChunk(key)
}
if (!this.full) this.drawPartsOfWorld()
@ -131,6 +140,7 @@ export class MinimapDrawer {
const chunkCanvasX = Math.floor((chunkWorldX - this.lastBotPos.x) * this.mapPixel + this.canvasWidthCenterX)
const chunkCanvasY = Math.floor((chunkWorldZ - this.lastBotPos.z) * this.mapPixel + this.canvasWidthCenterY)
const chunk = chunkInfo ?? this.chunksStore.get(key)
// if chunk is not ready then draw waiting color (grey) or none (half transparent black)
if (typeof chunk !== 'object') {
const chunkSize = this.mapPixel * 16
this.ctx.fillStyle = chunk === 'requested' ? 'rgb(200, 200, 200)' : 'rgba(0, 0, 0, 0.5)'
@ -149,10 +159,10 @@ export class MinimapDrawer {
}
drawPixel (pixelX: number, pixelY: number, color: string) {
// if (!this.full && Math.hypot(pixelX - this.canvasWidthCenterX, pixelY - this.canvasWidthCenterY) > this.radius) {
// this.ctx.clearRect(pixelX, pixelY, this.mapPixel, this.mapPixel)
// return
// }
if (!this.full && Math.hypot(pixelX - this.canvasWidthCenterX, pixelY - this.canvasWidthCenterY) > this.radius) {
this.ctx.clearRect(pixelX, pixelY, this.mapPixel, this.mapPixel)
return
}
this.ctx.fillStyle = color
this.ctx.fillRect(
pixelX,
@ -177,15 +187,13 @@ export class MinimapDrawer {
drawWarps (centerPos?: Vec3) {
for (const warp of this.warps) {
// if (!full) {
// const distance = this.getDistance(
// centerPos?.x ?? this.adapter.playerPosition.x,
// centerPos?.z ?? this.adapter.playerPosition.z,
// warp.x,
// warp.z
// )
// if (distance > this.mapSize) continue
// }
if (!this.full) {
const distance = Math.hypot(
centerPos?.x ?? this.lastBotPos.x - warp.x,
centerPos?.z ?? this.lastBotPos.z - warp.z
)
if (distance > this.mapSize) continue
}
const offset = this.full ? 0 : this.radius * 0.1
const z = Math.floor(
(this.mapSize / 2 - (centerPos?.z ?? this.lastBotPos.z) + warp.z) * this.mapPixel
@ -288,8 +296,8 @@ export class MinimapDrawer {
drawPlayerPos (canvasWorldCenterX?: number, canvasWorldCenterZ?: number, disableTurn?: boolean) {
this.ctx.setTransform(1, 0, 0, 1, 0, 0)
const x = (this.lastBotPos.x - (canvasWorldCenterX ?? this.lastBotPos.x)) * this.mapPixel
const z = (this.lastBotPos.z - (canvasWorldCenterZ ?? this.lastBotPos.z)) * this.mapPixel
const x = (this.lastBotPos.x - (canvasWorldCenterX ?? this.lastBotPos.x)) * this.mapPixel - (this.full ? 30 : 0)
const z = (this.lastBotPos.z - (canvasWorldCenterZ ?? this.lastBotPos.z)) * this.mapPixel - (this.full ? 30 : 0)
const center = this.mapSize / 2 * this.mapPixel + (this.full ? 0 : this.radius * 0.1)
this.ctx.translate(center + x, center + z)
if (!disableTurn) this.ctx.rotate(-this.yaw)

View file

@ -1,5 +1,4 @@
import { useEffect, useState } from 'react'
import { versions } from 'minecraft-data'
import { useEffect } from 'react'
import { simplify } from 'prismarine-nbt'
import RegionFile from 'prismarine-provider-anvil/src/region'
import { Vec3 } from 'vec3'
@ -15,17 +14,13 @@ import { useSnapshot } from 'valtio'
import BlockData from '../../renderer/viewer/lib/moreBlockDataGenerated.json'
import preflatMap from '../preflatMap.json'
import { contro } from '../controls'
import { gameAdditionalState, showModal, hideModal, miscUiState, activeModalStack } from '../globalState'
import { gameAdditionalState, miscUiState } from '../globalState'
import { options } from '../optionsStorage'
import Minimap, { DisplayMode } from './Minimap'
import { ChunkInfo, DrawerAdapter, MapUpdates, MinimapDrawer } from './MinimapDrawer'
import { useIsModalActive } from './utilsApp'
import { lastConnectOptions } from './AppStatusProvider'
const getBlockKey = (x: number, z: number) => {
return `${x},${z}`
}
const findHeightMap = (obj: PCChunk): number[] | undefined => {
function search (obj: any): any | undefined {
for (const key in obj) {
@ -43,49 +38,45 @@ const findHeightMap = (obj: PCChunk): number[] | undefined => {
export class DrawerAdapterImpl extends TypedEventEmitter<MapUpdates> implements DrawerAdapter {
playerPosition: Vec3
yaw: number
mapDrawer = new MinimapDrawer()
warps: WorldWarp[]
world: string
warps: WorldWarp[] = gameAdditionalState.warps
chunksStore = new Map<string, undefined | null | 'requested' | ChunkInfo >()
loadingChunksQueue = new Set<string>()
loadChunk: (key: string) => Promise<void> = this.loadChunkMinimap
mapDrawer = new MinimapDrawer(this.loadChunk, this.warps, this.loadingChunksQueue, this.chunksStore)
currChunk: PCChunk | undefined
currChunkPos: { x: number, z: number } = { x: 0, z: 0 }
isOldVersion: boolean
blockData: any
blockData: Map<string | string[], string>
heightMap: Record<string, number> = {}
regions = new Map<string, RegionFile>()
chunksHeightmaps: Record<string, any> = {}
loadChunk: (key: string) => Promise<void>
loadChunkFullmap: ((key: string) => Promise<ChunkInfo | null | undefined>) | undefined
_full: boolean
loadChunkFullmap: (key: string) => Promise<ChunkInfo | null | undefined>
_full = false
isBuiltinHeightmapAvailable = false
constructor (pos?: Vec3) {
super()
this.full = false
this.playerPosition = pos ?? new Vec3(0, 0, 0)
this.warps = gameAdditionalState.warps
this.mapDrawer.warps = this.warps
this.mapDrawer.loadChunk = this.loadChunk
this.mapDrawer.loadingChunksQueue = this.loadingChunksQueue
this.mapDrawer.chunksStore = this.chunksStore
// check if should use heightmap
// check if should use heightmap.
// As there is no simple way to check if heightmap is present in region file, making an attempt to load one
if (localServer) {
const chunkX = Math.floor(this.playerPosition.x / 16)
const chunkZ = Math.floor(this.playerPosition.z / 16)
const regionX = Math.floor(chunkX / 32)
const regionZ = Math.floor(chunkZ / 32)
const regionKey = `${regionX},${regionZ}`
const worldFolder = this.getSingleplayerRootPath()
if (worldFolder && options.minimapOptimizations) {
const { worldFolder } = localServer.options
if (worldFolder) {
const path = `${worldFolder}/region/r.${regionX}.${regionZ}.mca`
const region = new RegionFile(path)
void region.initialize()
this.regions.set(regionKey, region)
const readX = chunkX % 32
const readZ = chunkZ % 32
void this.regions.get(regionKey)!.read(readX, readZ).then((rawChunk) => {
const readX = chunkX % 32 < 0 ? 32 + chunkX % 32 : chunkX % 32
const readZ = chunkZ % 32 < 0 ? 32 + chunkZ % 32 : chunkZ % 32
console.log('heightmap check begun', readX, readZ)
void this.regions.get(regionKey)?.read(readX, readZ)?.then((rawChunk) => {
const chunk = simplify(rawChunk as any)
const heightmap = findHeightMap(chunk)
if (heightmap) {
@ -95,44 +86,35 @@ export class DrawerAdapterImpl extends TypedEventEmitter<MapUpdates> implements
} else {
this.isBuiltinHeightmapAvailable = false
this.loadChunkFullmap = this.loadChunkNoRegion
console.log('[minimap] not using heightmap')
console.log('dont use heightmap')
}
}).catch(err => {
console.error(err)
this.isBuiltinHeightmapAvailable = false
this.loadChunkFullmap = this.loadChunkFromViewer
})
} else {
this.isBuiltinHeightmapAvailable = false
this.loadChunkFullmap = this.loadChunkFromViewer
this.loadChunkFullmap = this.loadChunkNoRegion
console.log('dont use heightmap')
}
} else {
this.isBuiltinHeightmapAvailable = false
this.loadChunkFullmap = this.loadChunkFromViewer
}
// if (localServer) {
// this.overwriteWarps(localServer.warps)
// this.on('cellReady', (key: string) => {
// if (this.loadingChunksQueue.size === 0) return
// const [x, z] = this.loadingChunksQueue.values().next().value.split(',').map(Number)
// this.loadChunk(x, z)
// this.loadingChunksQueue.delete(`${x},${z}`)
// })
// } else {
// const storageWarps = localStorage.getItem(`warps: ${loadedGameState.username} ${loadedGameState.serverIp ?? ''}`)
// this.overwriteWarps(JSON.parse(storageWarps ?? '[]'))
// }
if (localServer) {
this.overwriteWarps(localServer.warps)
} else {
const storageWarps = localStorage.getItem(`warps: ${lastConnectOptions.value?.server ?? 'server'} ${lastConnectOptions.value?.username ?? 'username'}`)
this.overwriteWarps(JSON.parse(storageWarps ?? '[]'))
}
this.isOldVersion = versionToNumber(bot.version) < versionToNumber('1.13')
this.blockData = {}
this.blockData = new Map<string, string>()
for (const blockKey of Object.keys(BlockData.colors)) {
const renamedKey = getRenamedData('blocks', blockKey, '1.20.2', bot.version)
this.blockData[renamedKey as string] = BlockData.colors[blockKey]
this.blockData.set(renamedKey, BlockData.colors[blockKey])
}
viewer.world?.renderUpdateEmitter.on('chunkFinished', (key) => {
if (!this.loadingChunksQueue.has(key)) return
void this.loadChunk(key)
this.loadingChunksQueue.delete(key)
void this.loadChunk(key)
})
}
@ -141,9 +123,11 @@ export class DrawerAdapterImpl extends TypedEventEmitter<MapUpdates> implements
}
set full (full: boolean) {
console.log('this is minimap')
this.loadChunk = this.loadChunkMinimap
this.mapDrawer.loadChunk = this.loadChunk
if (!full) {
console.log('this is minimap')
this.loadChunk = this.loadChunkMinimap
this.mapDrawer.loadChunk = this.loadChunk
}
this._full = full
}
@ -157,7 +141,7 @@ export class DrawerAdapterImpl extends TypedEventEmitter<MapUpdates> implements
setWarp (warp: WorldWarp, remove?: boolean): void {
this.world = bot.game.dimension
const index = this.warps.findIndex(w => w.name === warp.name)
if (index === -1) {
if (!remove && index === -1) {
this.warps.push(warp)
} else if (remove && index !== -1) {
this.warps.splice(index, 1)
@ -207,29 +191,29 @@ export class DrawerAdapterImpl extends TypedEventEmitter<MapUpdates> implements
if (viewer.world.finishedChunks[`${chunkWorldX},${chunkWorldZ}`]) {
const heightmap = new Uint8Array(256)
const colors = Array.from({ length: 256 }).fill('') as string[]
// avoid creating new object every time
const blockPos = new Vec3(0, 0, 0)
// filling up colors and heightmap
for (let z = 0; z < 16; z += 1) {
for (let x = 0; x < 16; x += 1) {
const blockX = chunkWorldX + x
const blockZ = chunkWorldZ + z
const hBlock = viewer.world.highestBlocks.get(`${blockX},${blockZ}`)
const block = bot.world.getBlock(new Vec3(blockX, hBlock?.y ?? 0, blockZ))
// const block = Block.fromStateId(hBlock?.stateId ?? -1, hBlock?.biomeId ?? -1)
blockPos.x = blockX; blockPos.z = blockZ; blockPos.y = hBlock?.y ?? 0
let block = bot.world.getBlock(blockPos)
while (block?.name.includes('air')) {
blockPos.y -= 1
block = bot.world.getBlock(blockPos)
}
const index = z * 16 + x
// blocks which are not set are shown as half transparent
if (!block || !hBlock) {
console.warn(`[loadChunk] ${chunkX}, ${chunkZ}, ${chunkWorldX + x}, ${chunkWorldZ + z}`)
heightmap[index] = 0
colors[index] = 'rgba(0, 0, 0, 0.5)'
continue
}
heightmap[index] = hBlock.y
let color: string
if (this.isOldVersion) {
color = BlockData.colors[preflatMap.blocks[`${block.type}:${block.metadata}`]?.replaceAll(/\[.*?]/g, '')]
?? 'rgb(0, 0, 255)'
} else {
color = this.blockData[block.name] ?? 'rgb(0, 255, 0)'
}
colors[index] = color
heightmap[index] = block.position.y
colors[index] = this.setColor(block)
}
}
const chunk = { heightmap, colors }
@ -255,15 +239,19 @@ export class DrawerAdapterImpl extends TypedEventEmitter<MapUpdates> implements
const blockX = chunkWorldX + x
const blockZ = chunkWorldZ + z
const blockY = this.getHighestBlockY(blockX, blockZ, chunkInfo)
const block = chunkInfo.getBlock(new Vec3(blockX & 15, blockY, blockZ & 15))
const blockPos = new Vec3(blockX & 15, blockY, blockZ & 15)
let block = chunkInfo.getBlock(blockPos)
while (block?.name.includes('air')) {
blockPos.y -= 1
block = chunkInfo.getBlock(blockPos)
}
if (!block) {
console.warn(`[cannot get the block] ${chunkX}, ${chunkZ}, ${chunkWorldX + x}, ${chunkWorldZ + z}`)
return null
}
const index = z * 16 + x
heightmap[index] = blockY
const color = this.isOldVersion ? BlockData.colors[preflatMap.blocks[`${block.type}:${block.metadata}`]?.replaceAll(/\[.*?]/g, '')] ?? 'rgb(0, 0, 255)' : this.blockData[block.name] ?? 'rgb(0, 255, 0)'
colors[index] = color
heightmap[index] = blockPos.y
colors[index] = this.setColor(block)
}
}
const chunk: ChunkInfo = { heightmap, colors }
@ -288,13 +276,17 @@ export class DrawerAdapterImpl extends TypedEventEmitter<MapUpdates> implements
heightmap[index] -= 1
if (heightmap[index] < 0) heightmap[index] = 0
const blockY = heightmap[index]
const block = chunkInfo.getBlock(new Vec3(blockX & 15, blockY, blockZ & 15))
const blockPos = new Vec3(blockX & 15, blockY, blockZ & 15)
let block = chunkInfo.getBlock(blockPos)
while (block?.name.includes('air')) {
blockPos.y -= 1
block = chunkInfo.getBlock(blockPos)
}
if (!block) {
console.warn(`[cannot get the block] ${chunkX}, ${chunkZ}, ${chunkWorldX + x}, ${chunkWorldZ + z}`)
return null
}
const color = this.isOldVersion ? BlockData.colors[preflatMap.blocks[`${block.type}:${block.metadata}`]?.replaceAll(/\[.*?]/g, '')] ?? 'rgb(0, 0, 255)' : this.blockData[block.name] ?? 'rgb(0, 255, 0)'
colors[index] = color
colors[index] = this.setColor(block)
}
}
const chunk: ChunkInfo = { heightmap, colors }
@ -302,17 +294,12 @@ export class DrawerAdapterImpl extends TypedEventEmitter<MapUpdates> implements
return chunk
}
getSingleplayerRootPath (): string | undefined {
return localServer!.options.worldFolder
}
async getChunkHeightMapFromRegion (chunkX: number, chunkZ: number, cb?: (hm: number[]) => void) {
const regionX = Math.floor(chunkX / 32)
const regionZ = Math.floor(chunkZ / 32)
const regionKey = `${regionX},${regionZ}`
if (!this.regions.has(regionKey)) {
const worldFolder = this.getSingleplayerRootPath()
if (!worldFolder) return
const { worldFolder } = localServer!.options
const path = `${worldFolder}/region/r.${regionX}.${regionZ}.mca`
const region = new RegionFile(path)
await region.initialize()
@ -350,8 +337,7 @@ export class DrawerAdapterImpl extends TypedEventEmitter<MapUpdates> implements
continue
}
heightmap[index] = hBlock.y
const color = this.isOldVersion ? BlockData.colors[preflatMap.blocks[`${block.type}:${block.metadata}`]?.replaceAll(/\[.*?]/g, '')] ?? 'rgb(0, 0, 255)' : this.blockData[block.name] ?? 'rgb(0, 255, 0)'
colors[index] = color
colors[index] = this.setColor(block)
}
}
const chunk = { heightmap, colors }
@ -456,6 +442,32 @@ export class DrawerAdapterImpl extends TypedEventEmitter<MapUpdates> implements
}
}
setColor (block: Block) {
let color: string
if (this.isOldVersion) {
color = BlockData.colors[preflatMap.blocks[`${block.type}:${block.metadata}`]?.replaceAll(/\[.*?]/g, '')]
?? 'rgb(0, 0, 255)'
} else {
color = this.blockData.get(block.name) ?? 'rgb(0, 255, 0)'
}
if (color === 'rgb(0, 255, 0)' || color === 'rgb(0, 0, 255)') {
// this should never happen
// console.warn('[MinimapProvider] did not find block name,', block.name)
// hack to find close color. Problem with colors should be fixed differently in the future
const blockNamePieces = block.name.split('_')
const keys = [...this.blockData.keys()]
for (const piece of blockNamePieces) {
const match = keys.find(x => x.includes(piece))
if (match) {
color = this.blockData.get(match) ?? 'rgb(255, 0, 0)'
break
}
}
}
return color
}
quickTp (x: number, z: number) {
const y = this.getHighestBlockY(x, z)
bot.chat(`/tp ${x} ${y + 20} ${z}`)
@ -467,25 +479,6 @@ export class DrawerAdapterImpl extends TypedEventEmitter<MapUpdates> implements
}
async drawChunkOnCanvas (key: string, canvas: HTMLCanvasElement) {
// console.log('chunk', key, 'on canvas')
if (!this.loadChunkFullmap) {
// wait for it to be available
await new Promise(resolve => {
const interval = setInterval(() => {
if (this.loadChunkFullmap) {
clearInterval(interval)
resolve(undefined)
}
}, 100)
setTimeout(() => {
clearInterval(interval)
resolve(undefined)
}, 10_000)
})
if (!this.loadChunkFullmap) {
throw new Error('loadChunkFullmap not available')
}
}
const chunk = await this.loadChunkFullmap(key)
const [worldX, worldZ] = key.split(',').map(x => Number(x) * 16)
const center = new Vec3(worldX + 8, 0, worldZ + 8)
@ -493,6 +486,10 @@ export class DrawerAdapterImpl extends TypedEventEmitter<MapUpdates> implements
this.mapDrawer.canvas = canvas
this.mapDrawer.full = true
this.mapDrawer.drawChunk(key, chunk)
this.mapDrawer.drawWarps(center)
this.mapDrawer.lastBotPos = this.playerPosition
this.mapDrawer.yaw = this.yaw
this.mapDrawer.drawPlayerPos(worldX, worldZ)
}
}
@ -501,7 +498,7 @@ const Inner = (
{
adapter: DrawerAdapterImpl
displayMode?: DisplayMode,
toggleFullMap?: ({ command }: { command?: string }) => void
toggleFullMap?: () => void
}
) => {
@ -515,7 +512,7 @@ const Inner = (
}
const updateMap = () => {
if (!adapter) return
if (!adapter || miscUiState.displayFullmap) return
adapter.playerPosition = bot.entity.position
adapter.yaw = bot.entity.yaw
adapter.emit('updateMap')
@ -544,51 +541,13 @@ const Inner = (
</div>
}
export default ({ displayMode }: { displayMode?: DisplayMode }) => {
const [adapter] = useState(() => new DrawerAdapterImpl(bot.entity.position))
export default ({ adapter, displayMode }: { adapter: DrawerAdapterImpl, displayMode?: DisplayMode }) => {
const { showMinimap } = useSnapshot(options)
const fullMapOpened = useIsModalActive('full-map')
const readChunksHeightMaps = async () => {
const { worldFolder } = localServer!.options
const path = `${worldFolder}/region/r.0.0.mca`
const region = new RegionFile(path)
await region.initialize()
const chunks: Record<string, any> = {}
console.log('Reading chunks...')
console.log(chunks)
let versionDetected = false
for (const [i, _] of Array.from({ length: 32 }).entries()) {
for (const [k, _] of Array.from({ length: 32 }).entries()) {
// todo, may use faster reading, but features is not commonly used
// eslint-disable-next-line no-await-in-loop
const nbt = await region.read(i, k)
chunks[`${i},${k}`] = nbt
if (nbt && !versionDetected) {
const simplified = simplify(nbt)
const version = versions.pc.find(x => x['dataVersion'] === simplified.DataVersion)?.minecraftVersion
console.log('Detected version', version ?? 'unknown')
versionDetected = true
}
}
}
Object.defineProperty(chunks, 'simplified', {
get () {
const mapped = {}
for (const [i, _] of Array.from({ length: 32 }).entries()) {
for (const [k, _] of Array.from({ length: 32 }).entries()) {
const key = `${i},${k}`
const chunk = chunks[key]
if (!chunk) continue
mapped[key] = simplify(chunk)
}
}
return mapped
},
})
console.log('Done!', chunks)
const toggleFullMap = () => {
void contro.emit('trigger', { command: 'ui.toggleMap', schema: null as any })
}
if (
@ -599,13 +558,5 @@ export default ({ displayMode }: { displayMode?: DisplayMode }) => {
return null
}
const toggleFullMap = () => {
if (activeModalStack.at(-1)?.reactType === 'full-map') {
hideModal({ reactType: 'full-map' })
} else {
showModal({ reactType: 'full-map' })
}
}
return <Inner adapter={adapter} displayMode={displayMode} toggleFullMap={toggleFullMap} />
}

View file

@ -0,0 +1,50 @@
.container {
scale: 0.8;
transform-origin: left;
display: grid;
grid-template-columns: auto auto auto auto auto;
gap: 2px 4px;
font-size: 8px;
color: #fff;
opacity: 0.8;
align-items: center;
justify-content: center;
text-align: center;
justify-items: center;
}
.container.websocket {
grid-template-columns: auto auto auto;
}
.iconRow {
display: block;
}
.arrowRow {
scale: 1.5 1;
}
.dataRow {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 70px;
font-size: 5px;
}
.ping {
font-size: 6px;
}
/* .dataRow > span:nth-child(3) {
max-width: 120px;
} */
.totalRow {
grid-column: span 3;
font-size: 7px;
}
.stale {
color: #ff4444;
}

14
src/react/NetworkStatus.module.css.d.ts vendored Normal file
View file

@ -0,0 +1,14 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
arrowRow: string;
container: string;
dataRow: string;
iconRow: string;
ping: string;
stale: string;
totalRow: string;
websocket: string;
}
declare const cssExports: CssExports;
export default cssExports;

102
src/react/NetworkStatus.tsx Normal file
View file

@ -0,0 +1,102 @@
import { useEffect, useMemo, useState, useRef } from 'react'
import { parseServerAddress } from '../parseServerAddress'
import { lastConnectOptions } from './AppStatusProvider'
import PixelartIcon, { pixelartIcons } from './PixelartIcon'
import styles from './NetworkStatus.module.css'
export default () => {
const [proxyPing, setProxyPing] = useState<number | null>(null)
const [serverPing, setServerPing] = useState<number | null>(null)
const [isProxyStale, setIsProxyStale] = useState(false)
const [isServerStale, setIsServerStale] = useState(false)
const proxyTimeoutRef = useRef<NodeJS.Timeout>()
const serverTimeoutRef = useRef<NodeJS.Timeout>()
const isWebSocket = useMemo(() => parseServerAddress(lastConnectOptions.value?.server).isWebSocket, [lastConnectOptions.value?.server])
const serverIp = useMemo(() => lastConnectOptions.value?.server, [])
const setProxyPingWithTimeout = (ping: number | null) => {
setProxyPing(ping)
setIsProxyStale(false)
if (proxyTimeoutRef.current) clearTimeout(proxyTimeoutRef.current)
proxyTimeoutRef.current = setTimeout(() => setIsProxyStale(true), 1000)
}
const setServerPingWithTimeout = (ping: number | null) => {
setServerPing(ping)
setIsServerStale(false)
if (serverTimeoutRef.current) clearTimeout(serverTimeoutRef.current)
serverTimeoutRef.current = setTimeout(() => setIsServerStale(true), 1000)
}
useEffect(() => {
if (!serverIp) return
const updatePing = async () => {
const updateServerPing = async () => {
const ping = await bot.pingServer()
if (ping) {
setServerPingWithTimeout(ping)
}
}
const updateProxyPing = async () => {
if (!isWebSocket) {
const ping = await bot.pingProxy()
setProxyPingWithTimeout(ping)
}
}
try {
await Promise.all([updateServerPing(), updateProxyPing()])
} catch (err) {
console.error('Failed to ping:', err)
}
}
void updatePing()
const interval = setInterval(updatePing, 1000)
return () => {
clearInterval(interval)
if (proxyTimeoutRef.current) clearTimeout(proxyTimeoutRef.current)
if (serverTimeoutRef.current) clearTimeout(serverTimeoutRef.current)
}
}, [])
if (!serverIp) return null
const { username } = bot.player
const { proxy: proxyUrl } = lastConnectOptions.value!
const pingTotal = serverPing
const ICON_SIZE = 18
return (
<div className={`${styles.container} ${isWebSocket ? styles.websocket : ''}`}>
<PixelartIcon className={styles.iconRow} iconName={pixelartIcons.user} width={ICON_SIZE} />
{!isWebSocket && (
<>
<PixelartIcon className={`${styles.iconRow} ${styles.arrowRow}`} iconName={pixelartIcons['arrow-right']} width={16} />
<PixelartIcon className={styles.iconRow} iconName={pixelartIcons.server} width={ICON_SIZE} />
</>
)}
<PixelartIcon className={`${styles.iconRow} ${styles.arrowRow}`} iconName={pixelartIcons['arrow-right']} width={16} />
<PixelartIcon className={styles.iconRow} iconName={pixelartIcons['list-box']} width={ICON_SIZE} />
<span className={styles.dataRow}>{username}</span>
{!isWebSocket && (
<>
<span className={`${styles.dataRow} ${styles.ping} ${isProxyStale ? styles.stale : ''}`}>{proxyPing}ms</span>
<span className={styles.dataRow}>{proxyUrl}</span>
</>
)}
<span className={`${styles.dataRow} ${styles.ping} ${isServerStale ? styles.stale : ''}`}>
{isWebSocket ? (pingTotal || '?') : (pingTotal ? pingTotal - (proxyPing ?? 0) : '...')}ms
</span>
<span className={styles.dataRow}>{serverIp}</span>
<span className={styles.totalRow}>Ping: {pingTotal || '?'}ms</span>
</div>
)
}

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

@ -12,7 +12,8 @@ import {
showModal,
hideModal,
miscUiState,
openOptionsMenu
openOptionsMenu,
gameAdditionalState
} from '../globalState'
import { fsState } from '../loadSave'
import { disconnect } from '../flyingSquidUtils'
@ -28,7 +29,8 @@ import Screen from './Screen'
import styles from './PauseScreen.module.css'
import { DiscordButton } from './DiscordButton'
import { showNotification } from './NotificationProvider'
import { appStatusState } from './AppStatusProvider'
import { appStatusState, reconnectReload } from './AppStatusProvider'
import NetworkStatus from './NetworkStatus'
const waitForPotentialRender = async () => {
return new Promise<void>(resolve => {
@ -153,6 +155,7 @@ export default () => {
const fsStateSnap = useSnapshot(fsState)
const activeModalStackSnap = useSnapshot(activeModalStack)
const { singleplayer, wanOpened, wanOpening } = useSnapshot(miscUiState)
const { noConnection } = useSnapshot(gameAdditionalState)
const handlePointerLockChange = () => {
if (!pointerLock.hasPointerLock && activeModalStack.length === 0) {
@ -224,6 +227,9 @@ export default () => {
style={{ position: 'fixed', top: '5px', left: 'calc(env(safe-area-inset-left) + 5px)' }}
onClick={async () => openWorldActions()}
/>
<div style={{ position: 'fixed', top: '5px', left: 'calc(env(safe-area-inset-left) + 35px)' }}>
<NetworkStatus />
</div>
<div className={styles.pause_container}>
<Button className="button" style={{ width: '204px' }} onClick={onReturnPress}>Back to Game</Button>
<div className={styles.row}>
@ -259,6 +265,11 @@ export default () => {
{localServer && !fsState.syncFs && !fsState.isReadonly ? 'Save & Quit' : 'Disconnect & Reset'}
</Button>
</>}
{noConnection && (
<Button className="button" style={{ width: '204px' }} onClick={reconnectReload}>
Reconnect
</Button>
)}
</div>
</Screen>
}

View file

@ -8,7 +8,7 @@ export default ({
className = undefined as undefined | string,
onClick = () => { }
}) => {
if (width !== undefined) styles = { width, height: width, ...styles }
if (width !== undefined) styles = { width, height: width, fontSize: width, ...styles }
iconName = iconName.replace('pixelarticons:', '')
return <div

View file

@ -53,6 +53,13 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
const [quickConnectIp, setQuickConnectIp] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
// Save username to localStorage when component mounts if it doesn't exist
useEffect(() => {
if (!localStorage['username']) {
localStorage.setItem('username', defaultUsername)
}
}, [])
const setAuthenticatedAccounts = (newState: typeof authenticatedAccounts) => {
_setAuthenticatedAccounts(newState)
localStorage.setItem('authenticatedAccounts', JSON.stringify(newState))

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

@ -0,0 +1,62 @@
import { RefObject, useEffect, useLayoutEffect, useRef } from 'react'
import { pixelartIcons } from '../PixelartIcon'
export const useScrollBehavior = (
elementRef: RefObject<HTMLElement>,
{
messages,
opened
}: {
messages: readonly any[],
opened?: boolean
}
) => {
const openedWasAtBottom = useRef(true) // before new messages
const isAtBottom = () => {
if (!elementRef.current) return true
const { scrollTop, scrollHeight, clientHeight } = elementRef.current
const distanceFromBottom = Math.abs(scrollHeight - clientHeight - scrollTop)
return distanceFromBottom < 1
}
const scrollToBottom = () => {
if (elementRef.current) {
elementRef.current.scrollTop = elementRef.current.scrollHeight
}
}
// Handle scroll position tracking
useEffect(() => {
const element = elementRef.current
if (!element) return
const handleScroll = () => {
openedWasAtBottom.current = isAtBottom()
}
element.addEventListener('scroll', handleScroll)
return () => element.removeEventListener('scroll', handleScroll)
}, [])
// Handle opened state changes
useLayoutEffect(() => {
if (opened) {
openedWasAtBottom.current = true
} else {
scrollToBottom()
}
}, [opened])
// Handle messages changes
useLayoutEffect(() => {
if ((!opened || (opened && openedWasAtBottom.current)) && elementRef.current) {
scrollToBottom()
}
}, [messages])
return {
scrollToBottom,
isAtBottom
}
}

View file

@ -7,8 +7,7 @@ export const motionState = proxy({
})
const MOTION_DAMPING = 0.92
// const MAX_MOTION_OFFSET = 30
const MAX_MOTION_OFFSET = 350
const MAX_MOTION_OFFSET = 100
const motionVelocity = { x: 0, y: 0 }
let lastUpdate = performance.now()
let lastYaw = 0
@ -39,13 +38,13 @@ export function updateMotion () {
// Calculate motion contribution
const velocityContribution = {
x: -velocityVector.x * 200,
y: -velocityVector.z * 200
x: -velocityVector.x * 150,
y: -velocityVector.z * 150
}
// Combine camera and velocity effects
motionVelocity.x += (yawDiff * 400 + velocityContribution.x) * deltaTime
motionVelocity.y += (pitchDiff * 400 + velocityContribution.y) * deltaTime
motionVelocity.x += (yawDiff * 300 + velocityContribution.x) * deltaTime
motionVelocity.y += (pitchDiff * 300 + velocityContribution.y) * deltaTime
// Apply damping
motionVelocity.x *= MOTION_DAMPING

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

@ -19,7 +19,7 @@ import ScoreboardProvider from './react/ScoreboardProvider'
import SignEditorProvider from './react/SignEditorProvider'
import IndicatorEffectsProvider from './react/IndicatorEffectsProvider'
import PlayerListOverlayProvider from './react/PlayerListOverlayProvider'
import MinimapProvider from './react/MinimapProvider'
import MinimapProvider, { DrawerAdapterImpl } from './react/MinimapProvider'
import HudBarsProvider from './react/HudBarsProvider'
import XPBarProvider from './react/XPBarProvider'
import DebugOverlay from './react/DebugOverlay'
@ -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)
@ -104,17 +106,22 @@ const InGameComponent = ({ children }) => {
return children
}
// for Fullmap and Minimap in InGameUi
let adapter: DrawerAdapterImpl
const InGameUi = () => {
const { gameLoaded, showUI: showUIRaw } = useSnapshot(miscUiState)
const { disabledUiParts, displayBossBars, showMinimap } = useSnapshot(options)
const modalsSnapshot = useSnapshot(activeModalStack)
const hasModals = modalsSnapshot.length > 0
const showUI = showUIRaw || hasModals
const displayFullmap = modalsSnapshot.some(modal => modal.reactType === 'full-map')
const displayFullmap = modalsSnapshot.some(modal => modal.reactType === 'full-map') || true
// bot can't be used here
if (!gameLoaded || !bot || disabledUiParts.includes('*')) return
if (!adapter) adapter = new DrawerAdapterImpl(bot.entity.position)
return <>
<RobustPortal to={document.querySelector('#ui-root')}>
{/* apply scaling */}
@ -126,7 +133,7 @@ const InGameUi = () => {
{!disabledUiParts.includes('players-list') && <PlayerListOverlayProvider />}
{!disabledUiParts.includes('chat') && <ChatProvider />}
<SoundMuffler />
{showMinimap !== 'never' && <MinimapProvider displayMode='minimapOnly' />}
{showMinimap !== 'never' && <MinimapProvider adapter={adapter} displayMode='minimapOnly' />}
{!disabledUiParts.includes('title') && <TitleProvider />}
{!disabledUiParts.includes('scoreboard') && <ScoreboardProvider />}
{!disabledUiParts.includes('effects-indicators') && <IndicatorEffectsProvider />}
@ -150,7 +157,7 @@ const InGameUi = () => {
<DisplayQr />
</PerComponentErrorBoundary>
<RobustPortal to={document.body}>
{displayFullmap && <MinimapProvider displayMode='fullmapOnly' />}
{displayFullmap && <MinimapProvider adapter={adapter} displayMode='fullmapOnly' />}
{/* because of z-index */}
{showUI && <TouchControls />}
<GlobalSearchInput />
@ -170,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

@ -4,6 +4,7 @@ import fs from 'fs'
import JSZip from 'jszip'
import { proxy, subscribe } from 'valtio'
import { WorldRendererThree } from 'renderer/viewer/lib/worldrendererThree'
import { armorTextures } from 'renderer/viewer/lib/entity/armorModels'
import { collectFilesToCopy, copyFilesAsyncWithProgress, mkdirRecursive, removeFileRecursiveAsync } from './browserfs'
import { setLoadingScreenStatus } from './appStatus'
import { showNotification } from './react/NotificationProvider'
@ -203,7 +204,7 @@ const getFilesMapFromDir = async (dir: string) => {
return files
}
export const getResourcepackTiles = async (type: 'blocks' | 'items', existingTextures: string[]) => {
export const getResourcepackTiles = async (type: 'blocks' | 'items' | 'armor', existingTextures: string[]) => {
const basePath = await getActiveResourcepackBasePath()
if (!basePath) return
let firstTextureSize: number | undefined
@ -212,11 +213,25 @@ export const getResourcepackTiles = async (type: 'blocks' | 'items', existingTex
setLoadingScreenStatus(`Generating atlas texture for ${type}`)
}
const textures = {} as Record<string, HTMLImageElement>
let path
switch (type) {
case 'blocks':
path = 'block'
break
case 'items':
path = 'item'
break
case 'armor':
path = 'models/armor'
break
default:
throw new Error('Invalid type')
}
for (const namespace of namespaces) {
const texturesCommonBasePath = `${basePath}/assets/${namespace}/textures`
const isMinecraftNamespace = namespace === 'minecraft'
let texturesBasePath = `${texturesCommonBasePath}/${type === 'blocks' ? 'block' : 'item'}`
const texturesBasePathAlt = `${texturesCommonBasePath}/${type === 'blocks' ? 'blocks' : 'items'}`
let texturesBasePath = `${texturesCommonBasePath}/${path}`
const texturesBasePathAlt = `${texturesCommonBasePath}/${path}s`
if (!(await existsAsync(texturesBasePath))) {
if (await existsAsync(texturesBasePathAlt)) {
texturesBasePath = texturesBasePathAlt
@ -465,9 +480,11 @@ const repeatArr = (arr, i) => Array.from({ length: i }, () => arr)
const updateTextures = async () => {
const origBlocksFiles = Object.keys(viewer.world.sourceData.blocksAtlases.latest.textures)
const origItemsFiles = Object.keys(viewer.world.sourceData.itemsAtlases.latest.textures)
const origArmorFiles = Object.keys(armorTextures)
const { usedTextures: extraBlockTextures = new Set<string>() } = await prepareBlockstatesAndModels() ?? {}
const blocksData = await getResourcepackTiles('blocks', [...origBlocksFiles, ...extraBlockTextures])
const itemsData = await getResourcepackTiles('items', origItemsFiles)
const armorData = await getResourcepackTiles('armor', origArmorFiles)
await updateAllReplacableTextures()
viewer.world.customTextures = {}
if (blocksData) {
@ -482,8 +499,14 @@ const updateTextures = async () => {
textures: itemsData.textures
}
}
if (armorData) {
viewer.world.customTextures.armor = {
tileSize: armorData.firstTextureSize,
textures: armorData.textures
}
}
if (viewer.world.active) {
await viewer.world.updateTexturesData()
await viewer.world.updateAssetsData()
if (viewer.world instanceof WorldRendererThree) {
viewer.world.rerenderAllChunks?.()
}

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

@ -126,11 +126,7 @@ export const getWsProtocolStream = async (url: string) => {
const CHANNEL_NAME = 'minecraft-web-client:data'
export const handleCustomChannel = async () => {
await new Promise(resolve => {
bot._client.once('login', resolve)
})
bot._client.registerChannel(CHANNEL_NAME, ['string', []], true)
bot._client.registerChannel(CHANNEL_NAME, ['string', []])
const toCleanup = [] as Array<() => void>
subscribe(activeModalStack, () => {
if (activeModalStack.length === 1 && activeModalStack[0].reactType === 'main-menu') {
@ -193,6 +189,11 @@ export const handleCustomChannel = async () => {
}
break
}
// todo
case 'kick' as any: {
bot.emit('end', (data as any).reason)
break
}
case 'ui': {
const { update } = data
if (update.data === null) {

View file

@ -96,6 +96,8 @@ export const watchOptionsAfterWorldViewInit = () => {
watchValue(options, o => {
if (!worldView) return
worldView.keepChunksDistance = o.keepChunksDistance
viewer.world.config.displayHand = o.handDisplay
viewer.world.config.renderEars = o.renderEars
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)
@ -165,15 +166,39 @@ class WorldInteraction {
}
})
bot.on('blockBreakProgressObserved', (block: Block, destroyStage: number) => {
if (this.cursorBlock?.position.equals(block.position)) {
this.setBreakState(block, destroyStage)
//@ts-expect-error mineflayer types are wrong
bot.on('blockBreakProgressObserved', (block: Block, destroyStage: number, entity: Entity) => {
if (this.cursorBlock?.position.equals(block.position) && entity.id === bot.entity.id) {
if (!this.buttons[0]) {
// Simulate left mouse button press
this.buttons[0] = true
this.update()
}
// this.setBreakState(block, destroyStage)
}
})
bot.on('blockBreakProgressEnd', (block: Block) => {
if (this.currentBreakBlock?.block.position.equals(block.position)) {
this.stopBreakAnimation()
//@ts-expect-error mineflayer types are wrong
bot.on('blockBreakProgressEnd', (block: Block, entity: Entity) => {
if (this.currentBreakBlock?.block.position.equals(block.position) && entity.id === bot.entity.id) {
if (!this.buttons[0]) {
// Simulate left mouse button press
this.buttons[0] = false
this.update()
}
// this.stopBreakAnimation()
}
})
// Handle acknowledge_player_digging packet
bot._client.on('acknowledge_player_digging', (data: { location: { x: number, y: number, z: number }, block: number, status: number, successful: boolean } | { sequenceId: number }) => {
if ('location' in data && !data.successful) {
const packetPos = new Vec3(data.location.x, data.location.y, data.location.z)
if (this.cursorBlock?.position.equals(packetPos)) {
this.buttons[0] = false
this.update()
this.stopBreakAnimation()
}
}
})
@ -320,6 +345,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
}
@ -331,6 +357,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()
// }
}