Compare commits

..

89 commits

Author SHA1 Message Date
Vitaly Turovsky
253e094c74 add zardoy/mwc-proxy repo ref 2025-10-11 02:25:14 +03:00
Vitaly Turovsky
fef94f03fb feat: add support for alt+arrows navigation to navigate between commands only 2025-10-11 02:25:06 +03:00
Vitaly Turovsky
e9f91f8ecd feat: enable music by default, add slider for controlling its volume 2025-10-11 02:24:51 +03:00
Colbster937
634df8d03d
Add WebMC & WS changes (#431)
Co-authored-by: Colbster937 <96893162+colbychittenden@users.noreply.github.com>
2025-10-11 01:52:06 +03:00
Vitaly Turovsky
a88c8b5470 possible fix for rare edgecase where skins from server were not applied. Cause: renderer due to rare circumnstances could be loaded AFTER gameLoaded which is fired only when starting rendering 3d world. classic no existing data handling issue
why not mineflayerBotCreated? because getThreeJsRendererMethods not available at that time so would make things only much complex
2025-09-30 09:38:37 +03:00
Vitaly Turovsky
f51254d97a fix: dont stop local replay server with keep alive connection error 2025-09-30 07:20:30 +03:00
Vitaly Turovsky
05cd560d6b add shadow and directional light for player in inventory (model viewer) 2025-09-29 02:01:04 +03:00
Vitaly Turovsky
b239636356 feat: add debugServerPacketNames and debugClientPacketNames for quick access of names with intellisense of packets for current protocol. Should be used with window.inspectPacket in console 2025-09-28 22:04:17 +03:00
Vitaly Turovsky
4f421ae45f respect loadPlayerSkins option for inventory skin 2025-09-28 21:59:00 +03:00
Vitaly
3b94889bed
feat: make arrows colorful and metadata (#430)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-09-20 02:57:59 +03:00
Vitaly
636a7fdb54
feat: improve fog a little (#427) 2025-09-19 05:42:22 +03:00
Vitaly Turovsky
c930365e32 fix sometimes inventory player should not be rendered 2025-09-18 07:49:44 +03:00
Vitaly Turovsky
852dd737ae fix: fix some UI like error screen was not visible fully (buttons were clipped behind the screen) on larger scale on large screens 2025-09-11 22:24:04 +03:00
Vitaly Turovsky
06dc3cb033 feat: Add saveLoginPassword option to control password saving behavior in browser for offline auth on servers 2025-09-08 05:38:16 +03:00
Vitaly Turovsky
c4097975bf add a way to disable sky box for old behavior (not tested) 2025-09-08 05:29:34 +03:00
Vitaly Turovsky
1525fac2a1 fix: some visual camera world view issues (visible lines between blocks) 2025-09-08 05:22:24 +03:00
Vitaly Turovsky
f24cb49a87 up lockfile 2025-09-08 04:55:43 +03:00
Vitaly Turovsky
0b1183f541 up minecraft-data 2025-09-08 04:36:09 +03:00
Vitaly Turovsky
739a6fad24 fix lockfile 2025-09-08 04:34:17 +03:00
Vitaly Turovsky
7f7a14ac65 feat: Add overlay model viewer. Already integrated into inventory to display player! 2025-09-08 04:19:38 +03:00
Vitaly
265d02d18d up protocol for 1.21.8 2025-09-07 18:23:13 +00:00
Vitaly
b2e36840b9
feat: brand new default skybox with fog, better daycycle and colors (#425)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-05 05:02:54 +03:00
Vitaly
7043bf49f3
fix: adding support for newer skin profile data structure in player heads (#424) 2025-09-04 21:55:34 +03:00
Vitaly
528d8f516b
Update worldrendererThree.ts 2025-09-04 21:55:02 +03:00
Kesuaheli
70534d8b5a
fix: adding support for newer skin profile data structure in player heads 2025-09-04 12:51:56 +02:00
Vitaly Turovsky
9d54c70fb7 use node 22 2025-09-02 19:05:18 +03:00
Vitaly Turovsky
7e3ba8bece up integrated server for the latest fixes and better stability 2025-09-02 19:02:30 +03:00
Vitaly
513201be87 up browserify 2025-09-01 08:56:08 +03:00
Vitaly Turovsky
cb82188272 add addPing query param for testing 2025-08-31 19:31:26 +03:00
Vitaly Turovsky
d0d5234ba4 fix: stop right click emulation once window got opened eg chest 2025-08-31 18:31:49 +03:00
Vitaly Turovsky
e81d608554 fix cname 2025-08-27 19:52:09 +03:00
Vitaly Turovsky
1f240d8c20 up mouse allowing to disable positive break block 2025-08-27 19:50:53 +03:00
Vitaly Turovsky
2a1746eb7a [skip ci] fix repository name 2025-08-27 13:33:39 +03:00
Vitaly Turovsky
9718610131 ci: add deployment step for mcw-mcraft-page repository in GitHub Actions 2025-08-27 12:08:20 +03:00
Vitaly Turovsky
8f62fbd4da fix: window title sometimes was not showing up on old versions 2025-08-26 13:50:36 +03:00
Vitaly Turovsky
bc2972fe99 fix registering custom channels too late (a few ms diff) 2025-08-24 19:53:44 +03:00
Vitaly
a12c61bc6c
add simple monaco (#418) 2025-08-21 13:21:02 +03:00
Vitaly Turovsky
6e0d54ea17 up mc-protocol patch 2025-08-20 20:45:02 +03:00
Vitaly Turovsky
72e9e656cc new! helpful errors on custom channels payloads! 2025-08-20 20:42:40 +03:00
Vitaly Turovsky
4a5f2e799c disable experimentalClientSelfReload by default until it's reworked with more fine-tuned checks against server connection 2025-08-20 20:02:57 +03:00
Vitaly
a8fa3d47d1 up protocol & mineflayer for 1.21.6 2025-08-19 12:49:33 +03:00
Vitaly Turovsky
9a84a7acfb do less annoying logging 2025-08-18 11:37:20 +03:00
Vitaly Turovsky
d6eb1601e9 disable remote sounds by default 2025-08-16 09:16:29 +03:00
Vitaly Turovsky
e1293b6cb3 - Introduced a patchAssets script to apply custom textures to the blocks and items atlases.
- Enhanced the ThreeJsSound class to support sound playback timeout and volume adjustments.
- Added a custom sound system to handle named sound effects with metadata.
2025-08-16 09:15:37 +03:00
Vitaly Turovsky
cc4f705aea feat: new experimental chunk loading logic by forcing into spiral queue
feat: add highly experimental logic to try to self-restore any issues with chunks loading by automating f3+a action. write debug info into chat for now. can be disabled
feat: rework chunks debug screen showing actually useful information now
2025-08-15 07:46:52 +03:00
Vitaly Turovsky
54c114a702 feat(big): items are now rendered in 3d not in 2d and it makes insanely huge difference on the game visuals 2025-08-15 05:26:11 +03:00
Vitaly Turovsky
65575e2665 rm faulty mineflayer ver, fix kickin in singleplayer 2025-08-15 01:33:37 +03:00
Vitaly
1ddaa79162
rm not tested pathfinder (#415) 2025-08-15 01:12:24 +03:00
Vitaly Turovsky
e2b141cca0 fix a few packet specific errors 2025-08-14 04:51:37 +03:00
Vitaly
15e3325971 add param for testing for immediate reconnect after kick or error (warning: will cause infinite reload loop) 2025-08-14 01:25:24 +03:00
Vitaly Turovsky
60fc5ef315 feat: add skybox renderer: test it by dragging an image window into window, fix waypoint block pos 2025-08-13 19:19:46 +03:00
Vitaly
8827aab981 dont add test waypoints on dev 2025-08-12 06:27:42 +03:00
Vitaly
0a474e6780 feat: add custom experimental waypints impl 2025-08-12 06:27:06 +03:00
Vitaly Turovsky
cdd8c31a0e fix: fix player colored username rendering, fix sometimes skin was overriden 2025-08-11 21:21:44 +03:00
Vitaly Turovsky
e7c358d3fc feat: add minecraft-web-client:block-interactions-customization 2025-08-11 03:12:05 +03:00
Vitaly Turovsky
fb395041b9 fix: fix on 1.18.2 many blocks like mushrom blocks, fence gates, deepslate, basalt, copper stuff like ore, infested stone, cakes and tinted glass was resulting in instant breaking on the client
dev: add debugTestPing
2025-08-11 01:39:08 +03:00
Vitaly Turovsky
353ba2ecb3 fix: some blocks textures were not update in hotbar after texturepack change 2025-08-08 21:52:55 +03:00
Vitaly Turovsky
53cbff7699 dont conflict fire with chat 2025-08-08 18:37:10 +03:00
Vitaly Turovsky
caf4695637 feat: silly player on fire renderer effect 2025-08-08 18:33:20 +03:00
Vitaly
167b49da08
fix: fix cannot write after stream was destroyed message (#413) 2025-08-08 02:07:52 +03:00
Vitaly Turovsky
d7bd26b6b5 up protocol patch 2025-08-06 01:47:09 +03:00
Vitaly Turovsky
d41527edc8 manually fix lockfile because of silly pnpm dep resolution 2025-08-03 03:33:37 +03:00
Vitaly Turovsky
24ab260e8e fix: up protocol to support 1.21.5 2025-08-03 03:20:38 +03:00
Vitaly
c4b284b9b7 fix: fix supported versions display in server menu 2025-08-02 21:34:33 +03:00
Kesu
67855ae25a
fix: fix some window titles (#401) 2025-07-27 15:24:26 +02:00
Vitaly Turovsky
b9c8ade9bf fix: fix chat was crashing sometimes 2025-07-20 10:06:57 +03:00
Max Lee
4d7e3df859
feat: Item projectiles support (#395) 2025-07-18 14:18:05 +03:00
Vitaly Turovsky
a498778703 always wait for config load so autoConnect works on remote config 2025-07-18 09:56:50 +03:00
Vitaly Turovsky
b6d4728c44 display disconnect always last 2025-07-18 09:44:50 +03:00
Vitaly Turovsky
0dca8bbbe5 fix(important): F3 actions didn't work on mobile at all like chunks reload 2025-07-18 08:32:32 +03:00
Vitaly Turovsky
de9bfba3a8 allow auto connect on mcraft for last integrations 2025-07-18 08:02:13 +03:00
Vitaly Turovsky
45408476a5 fix(appStorage): Fix that settings were not possible to save on vercel domains, use robust self-checking mechanism to ensure user data never lost when cookies storage enabled! 2025-07-18 07:53:47 +03:00
Vitaly Turovsky
c360115f60 fix: fix rare ios safari bug where hotbar would not be visible due to unclear fixed&bottom:0 css using 2025-07-18 07:46:25 +03:00
Max Lee
a8635e9e2f
fix: Effects and Game Indicators overlay toggles didn't work (#397) 2025-07-18 05:55:29 +03:00
Vitaly
5bd33a546a
More build configs & optimise reconnect and immediate game enter (#398)
feat(custom-builds): Add a way to bundle only specific minecraft version data, this does not affect assets though
env:
MIN_MC_VERSION
MAX_MC_VERSION
new SKIP_MC_DATA_RECIPES - if recipes are not used in game
fix: refactor QS params handling to ensure panorama & main menu never loaded when immedieate game enter action is expected (eg ?autoConnect=1)
2025-07-18 04:39:05 +03:00
Vitaly Turovsky
e9c7840dae feat(mobile): fix annoying issues with box and foods usage on screen hold 2025-07-16 16:18:15 +03:00
Vitaly
52c0c75ccf docs: update readme 2025-07-16 12:09:34 +03:00
Vitaly Turovsky
b2f2d85e4f feat(setting): add a way to specify default perspective view 2025-07-14 00:18:42 +03:00
Vitaly Turovsky
7a83a2a657 fix(important): fix all known issues wiht panorama crashing whole game in single file build (minecraft.html) 2025-07-14 00:13:51 +03:00
Vitaly Turovsky
64da602294 add creative server 2025-07-12 05:38:24 +03:00
Max Lee
a09cd7d3ed
fix: Nametag & sign text fixes (#391) 2025-07-11 17:56:37 +03:00
Max Lee
39aca1735e
fix: custom item model data on 1.21.4+ (#392) 2025-07-11 17:13:24 +03:00
Vitaly Turovsky
e9320c68d2 fix metrics port 2025-07-09 17:13:36 +03:00
Vitaly Turovsky
95cc0e6c74 reduce ram usage by 15% 2025-07-09 16:33:50 +03:00
Vitaly
826b24d9e2
Metrics server (#390) 2025-07-09 16:10:50 +03:00
Vitaly Turovsky
16609aa010 fix falsey settings apply 2025-07-08 16:35:05 +03:00
Vitaly Turovsky
09b0e2e493 add a way to disable some parts of bars ui via config and select them via devtool elem select 2025-07-08 15:42:06 +03:00
Vitaly Turovsky
c844b99cf2 fix(regression): fix chat completions were not visible on pc 2025-07-08 15:15:54 +03:00
Vitaly Turovsky
089f2224e2 fix(mobile): drop stack on hotbar hold
feat(config): add powerful way to disable some actions in the client entirely (eg opening inventory or dropping items)
2025-07-08 15:12:23 +03:00
120 changed files with 5055 additions and 1458 deletions

View file

@ -23,6 +23,7 @@
// ],
"@stylistic/arrow-spacing": "error",
"@stylistic/block-spacing": "error",
"@typescript-eslint/no-this-alias": "off",
"@stylistic/brace-style": [
"error",
"1tbs",

View file

@ -26,7 +26,7 @@ jobs:
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 18
node-version: 22
cache: "pnpm"
- name: Move Cypress to dependencies
run: |

View file

@ -49,6 +49,20 @@ jobs:
publish_dir: .vercel/output/static
force_orphan: true
# Create CNAME file for custom domain
- name: Create CNAME file
run: echo "github.mcraft.fun" > .vercel/output/static/CNAME
- name: Deploy to mwc-mcraft-pages repository
uses: peaceiris/actions-gh-pages@v3
with:
personal_token: ${{ secrets.MCW_MCRAFT_PAGE_DEPLOY_TOKEN }}
external_repository: ${{ github.repository_owner }}/mwc-mcraft-pages
publish_dir: .vercel/output/static
publish_branch: main
destination_dir: docs
force_orphan: true
- name: Change index.html title
run: |
# change <title>Minecraft Web Client</title> to <title>Minecraft Web Client — Free Online Browser Version</title>

View file

@ -14,7 +14,6 @@ For building the project yourself / contributing, see [Development, Debugging &
> **Note**: You can deploy it on your own server in less than a minute using a one-liner script from [Minecraft Everywhere repo](https://github.com/zardoy/minecraft-everywhere)
### Big Features
- Official Mineflayer [plugin integration](https://github.com/zardoy/mcraft-fun-mineflayer-plugin)! View / Control your bot remotely.
@ -55,8 +54,9 @@ Howerver, it's known that these browsers have issues:
### Versions Support
Server versions 1.8 - 1.21.4 are supported.
Server versions 1.8 - 1.21.5 are supported.
First class versions (most of the features are tested on these versions):
- 1.19.4
- 1.21.4
@ -78,6 +78,8 @@ There is a builtin proxy, but you can also host your one! Just clone the repo, r
[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?name=minecraft-web-client&type=git&repository=zardoy%2Fminecraft-web-client&branch=next&builder=dockerfile&env%5B%5D=&ports=8080%3Bhttp%3B%2F)
> **Note**: If you want to make **your own** Minecraft server accessible to web clients (without our proxies), you can use [mwc-proxy](https://github.com/zardoy/mwc-proxy) - a lightweight JS WebSocket proxy that runs on the same server as your Minecraft server, allowing players to connect directly via `wss://play.example.com`. `?client_mcraft` is added to the URL, so the proxy will know that it's this client.
Proxy servers are used to connect to Minecraft servers which use TCP protocol. When you connect connect to a server with a proxy, websocket connection is created between you (browser client) and the proxy server located in Europe, then the proxy connects to the Minecraft server and sends the data to the client (you) without any packet deserialization to avoid any additional delays. That said all the Minecraft protocol packets are processed by the client, right in your browser.
```mermaid
@ -125,11 +127,11 @@ There is world renderer playground ([link](https://mcon.vercel.app/playground/))
However, there are many things that can be done in online production version (like debugging actual source code). Also you can access some global variables in the console and there are a few useful examples:
- `localStorage.debug = '*'` - Enables all debug messages! Warning: this will start all packets spam.
- If you type `debugToggle`, press enter in console - It will enables all debug messages! Warning: this will start all packets spam.
Instead I recommend setting `options.debugLogNotFrequentPackets`. Also you can use `debugTopPackets` (with JSON.stringify) to see what packets were received/sent by name
- `bot` - Mineflayer bot instance. See Mineflayer documentation for more.
- `viewer` - Three.js viewer instance, basically does all the rendering.
- `world` - Three.js world instance, basically does all the rendering (part of renderer backend).
- `world.sectionObjects` - Object with all active chunk sections (geometries) in the world. Each chunk section is a Three.js mesh or group.
- `debugSceneChunks` - The same as above, but relative to current bot position (e.g. 0,0 is the current chunk).
- `debugChangedOptions` - See what options are changed. Don't change options here.
@ -139,7 +141,7 @@ Instead I recommend setting `options.debugLogNotFrequentPackets`. Also you can u
- `nbt.simplify(someNbt)` - Simplifies nbt data, so it's easier to read.
The most useful thing in devtools is the watch expression. You can add any expression there and it will be re-evaluated in real time. For example, you can add `camera.position` to see the camera position and so on.
The most useful thing in devtools is the watch expression. You can add any expression there and it will be re-evaluated in real time. For example, you can add `world.getCameraPosition()` to see the camera position and so on.
<img src="./docs-assets/watch-expr.png" alt="Watch expression" width="480"/>
@ -176,6 +178,7 @@ Server specific:
- `?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.
- `?addPing=<ping>` - Add a latency to both sides of the connection. Useful for testing ping issues. For example `?addPing=100` will add 200ms to your ping.
Single player specific:
@ -232,3 +235,4 @@ Only during development:
- [https://github.com/ClassiCube/ClassiCube](ClassiCube - Better C# Rewrite) [DEMO](https://www.classicube.net/server/play/?warned=true)
- [https://m.eaglercraft.com/](EaglerCraft) - Eaglercraft runnable on mobile (real Minecraft in the browser)
- [js-minecraft](https://github.com/LabyStudio/js-minecraft) - An insanely well done clone from the graphical side that inspired many features here

View file

@ -0,0 +1,2 @@
here you can place custom textures for bundled files (blocks/items) e.g. blocks/stone.png
get file names from here (blocks/items) https://zardoy.github.io/mc-assets/

View file

@ -10,12 +10,20 @@
{
"ip": "wss://play.mcraft.fun"
},
{
"ip": "wss://play.webmc.fun",
"name": "WebMC"
},
{
"ip": "wss://ws.fuchsmc.net"
},
{
"ip": "wss://play2.mcraft.fun"
},
{
"ip": "wss://play-creative.mcraft.fun",
"description": "Might be available soon, stay tuned!"
},
{
"ip": "kaboom.pw",
"version": "1.20.3",

View file

@ -1,4 +1,5 @@
{
"alwaysReconnectButton": true,
"reportBugButtonWithReconnect": true
"reportBugButtonWithReconnect": true,
"allowAutoConnect": true
}

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<title>Minecraft Item Viewer</title>
<style>
body { margin: 0; overflow: hidden; }
canvas { display: block; }
</style>
</head>
<body>
<script type="module" src="./three-item.ts"></script>
</body>
</html>

108
experiments/three-item.ts Normal file
View file

@ -0,0 +1,108 @@
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import itemsAtlas from 'mc-assets/dist/itemsAtlasLegacy.png'
import { createItemMeshFromCanvas, createItemMesh } from '../renderer/viewer/three/itemMesh'
// Create scene, camera and renderer
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)
// Setup camera and controls
camera.position.set(0, 0, 3)
const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
// Background and lights
scene.background = new THREE.Color(0x333333)
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7)
scene.add(ambientLight)
// Animation loop
function animate () {
requestAnimationFrame(animate)
controls.update()
renderer.render(scene, camera)
}
async function setupItemMesh () {
try {
const loader = new THREE.TextureLoader()
const atlasTexture = await loader.loadAsync(itemsAtlas)
// Pixel-art configuration
atlasTexture.magFilter = THREE.NearestFilter
atlasTexture.minFilter = THREE.NearestFilter
atlasTexture.generateMipmaps = false
atlasTexture.wrapS = atlasTexture.wrapT = THREE.ClampToEdgeWrapping
// Extract the tile at x=2, y=0 (16x16)
const tileSize = 16
const tileX = 2
const tileY = 0
const canvas = document.createElement('canvas')
canvas.width = tileSize
canvas.height = tileSize
const ctx = canvas.getContext('2d')!
ctx.imageSmoothingEnabled = false
ctx.drawImage(
atlasTexture.image,
tileX * tileSize,
tileY * tileSize,
tileSize,
tileSize,
0,
0,
tileSize,
tileSize
)
// Test both approaches - working manual extraction:
const meshOld = createItemMeshFromCanvas(canvas, { depth: 0.1 })
meshOld.position.x = -1
meshOld.rotation.x = -Math.PI / 12
meshOld.rotation.y = Math.PI / 12
scene.add(meshOld)
// And new unified function:
const atlasWidth = atlasTexture.image.width
const atlasHeight = atlasTexture.image.height
const u = (tileX * tileSize) / atlasWidth
const v = (tileY * tileSize) / atlasHeight
const sizeX = tileSize / atlasWidth
const sizeY = tileSize / atlasHeight
console.log('Debug texture coords:', {u, v, sizeX, sizeY, atlasWidth, atlasHeight})
const resultNew = createItemMesh(atlasTexture, {
u, v, sizeX, sizeY
}, {
faceCamera: false,
use3D: true,
depth: 0.1
})
resultNew.mesh.position.x = 1
resultNew.mesh.rotation.x = -Math.PI / 12
resultNew.mesh.rotation.y = Math.PI / 12
scene.add(resultNew.mesh)
animate()
} catch (err) {
console.error('Failed to create item mesh:', err)
}
}
// Handle window resize
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
})
// Start
setupItemMesh()

View file

@ -0,0 +1,5 @@
<script type="module" src="three-labels.ts"></script>
<style>
body { margin: 0; }
canvas { display: block; }
</style>

View file

@ -0,0 +1,67 @@
import * as THREE from 'three'
import { FirstPersonControls } from 'three/addons/controls/FirstPersonControls.js'
import { createWaypointSprite, WAYPOINT_CONFIG } from '../renderer/viewer/three/waypointSprite'
// Create scene, camera and renderer
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)
// Add FirstPersonControls
const controls = new FirstPersonControls(camera, renderer.domElement)
controls.lookSpeed = 0.1
controls.movementSpeed = 10
controls.lookVertical = true
controls.constrainVertical = true
controls.verticalMin = 0.1
controls.verticalMax = Math.PI - 0.1
// Position camera
camera.position.y = 1.6 // Typical eye height
camera.lookAt(0, 1.6, -1)
// Create a helper grid and axes
const grid = new THREE.GridHelper(20, 20)
scene.add(grid)
const axes = new THREE.AxesHelper(5)
scene.add(axes)
// Create waypoint sprite via utility
const waypoint = createWaypointSprite({
position: new THREE.Vector3(0, 0, -5),
color: 0xff0000,
label: 'Target',
})
scene.add(waypoint.group)
// Use built-in offscreen arrow from utils
waypoint.enableOffscreenArrow(true)
waypoint.setArrowParent(scene)
// Animation loop
function animate() {
requestAnimationFrame(animate)
const delta = Math.min(clock.getDelta(), 0.1)
controls.update(delta)
// Unified camera update (size, distance text, arrow, visibility)
const sizeVec = renderer.getSize(new THREE.Vector2())
waypoint.updateForCamera(camera.position, camera, sizeVec.width, sizeVec.height)
renderer.render(scene, camera)
}
// Handle window resize
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
})
// Add clock for controls
const clock = new THREE.Clock()
animate()

View file

@ -1,5 +1,4 @@
import * as THREE from 'three'
import { loadThreeJsTextureFromBitmap } from '../renderer/viewer/lib/utils/skins'
// Create scene, camera and renderer
const scene = new THREE.Scene()

View file

@ -7,6 +7,7 @@
"dev-proxy": "node server.js",
"start": "run-p dev-proxy dev-rsbuild watch-mesher",
"start2": "run-p dev-rsbuild watch-mesher",
"start-metrics": "ENABLE_METRICS=true rsbuild dev",
"build": "pnpm build-other-workers && rsbuild build",
"build-analyze": "BUNDLE_ANALYZE=true rsbuild build && pnpm build-other-workers",
"build-single-file": "SINGLE_FILE_BUILD=true rsbuild build",
@ -32,7 +33,8 @@
"run-all": "run-p start run-playground",
"build-playground": "rsbuild build --config renderer/rsbuild.config.ts",
"watch-playground": "rsbuild dev --config renderer/rsbuild.config.ts",
"update-git-deps": "tsx scripts/updateGitDeps.ts"
"update-git-deps": "tsx scripts/updateGitDeps.ts",
"request-data": "tsx scripts/requestData.ts"
},
"keywords": [
"prismarine",
@ -52,6 +54,7 @@
"dependencies": {
"@dimaka/interface": "0.0.3-alpha.0",
"@floating-ui/react": "^0.26.1",
"@monaco-editor/react": "^4.7.0",
"@nxg-org/mineflayer-auto-jump": "^0.7.18",
"@nxg-org/mineflayer-tracker": "1.3.0",
"@react-oauth/google": "^0.12.1",
@ -77,14 +80,14 @@
"esbuild-plugin-polyfill-node": "^0.3.0",
"express": "^4.18.2",
"filesize": "^10.0.12",
"flying-squid": "npm:@zardoy/flying-squid@^0.0.62",
"flying-squid": "npm:@zardoy/flying-squid@^0.0.104",
"framer-motion": "^12.9.2",
"fs-extra": "^11.1.1",
"google-drive-browserfs": "github:zardoy/browserfs#google-drive",
"jszip": "^3.10.1",
"lodash-es": "^4.17.21",
"mcraft-fun-mineflayer": "^0.1.23",
"minecraft-data": "3.92.0",
"minecraft-data": "3.98.0",
"minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master",
"mineflayer-item-map-downloader": "github:zardoy/mineflayer-item-map-downloader",
"mojangson": "^2.0.4",
@ -154,8 +157,7 @@
"mc-assets": "^0.2.62",
"minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next",
"mineflayer": "github:zardoy/mineflayer#gen-the-master",
"mineflayer-mouse": "^0.1.11",
"mineflayer-pathfinder": "^2.4.4",
"mineflayer-mouse": "^0.1.21",
"npm-run-all": "^4.1.5",
"os-browserify": "^0.3.0",
"path-browserify": "^1.0.1",
@ -195,6 +197,7 @@
},
"pnpm": {
"overrides": {
"mineflayer": "github:zardoy/mineflayer#gen-the-master",
"@nxg-org/mineflayer-physics-util": "1.8.10",
"buffer": "^6.0.3",
"vec3": "0.1.10",
@ -202,7 +205,7 @@
"diamond-square": "github:zardoy/diamond-square",
"prismarine-block": "github:zardoy/prismarine-block#next-era",
"prismarine-world": "github:zardoy/prismarine-world#next-era",
"minecraft-data": "3.92.0",
"minecraft-data": "3.98.0",
"prismarine-provider-anvil": "github:zardoy/prismarine-provider-anvil#everything",
"prismarine-physics": "github:zardoy/prismarine-physics",
"minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master",

View file

@ -1,8 +1,8 @@
diff --git a/src/client/chat.js b/src/client/chat.js
index 8d0869b150681574ad19292a026cce9f67a137ee..2efa2e6600f017b566155974cb9fb1856fa582f9 100644
index 0021870994fc59a82f0ac8aba0a65a8be43ef2f4..a53fceb843105ea2a1d88722b3fc7c3b43cb102a 100644
--- a/src/client/chat.js
+++ b/src/client/chat.js
@@ -109,7 +109,7 @@ module.exports = function (client, options) {
@@ -116,7 +116,7 @@ module.exports = function (client, options) {
for (const player of packet.data) {
if (player.chatSession) {
client._players[player.uuid] = {
@ -11,7 +11,7 @@ index 8d0869b150681574ad19292a026cce9f67a137ee..2efa2e6600f017b566155974cb9fb185
publicKeyDER: player.chatSession.publicKey.keyBytes,
sessionUuid: player.chatSession.uuid
}
@@ -119,7 +119,7 @@ module.exports = function (client, options) {
@@ -126,7 +126,7 @@ module.exports = function (client, options) {
if (player.crypto) {
client._players[player.uuid] = {
@ -20,7 +20,7 @@ index 8d0869b150681574ad19292a026cce9f67a137ee..2efa2e6600f017b566155974cb9fb185
publicKeyDER: player.crypto.publicKey,
signature: player.crypto.signature,
displayName: player.displayName || player.name
@@ -189,7 +189,7 @@ module.exports = function (client, options) {
@@ -196,7 +196,7 @@ module.exports = function (client, options) {
if (mcData.supportFeature('useChatSessions')) {
const tsDelta = BigInt(Date.now()) - packet.timestamp
const expired = !packet.timestamp || tsDelta > messageExpireTime || tsDelta < 0
@ -28,8 +28,8 @@ index 8d0869b150681574ad19292a026cce9f67a137ee..2efa2e6600f017b566155974cb9fb185
+ const verified = false && !packet.unsignedChatContent && updateAndValidateSession(packet.senderUuid, packet.plainMessage, packet.signature, packet.index, packet.previousMessages, packet.salt, packet.timestamp) && !expired
if (verified) client._signatureCache.push(packet.signature)
client.emit('playerChat', {
plainMessage: packet.plainMessage,
@@ -354,7 +354,7 @@ module.exports = function (client, options) {
globalIndex: packet.globalIndex,
@@ -362,7 +362,7 @@ module.exports = function (client, options) {
}
}
@ -38,16 +38,16 @@ index 8d0869b150681574ad19292a026cce9f67a137ee..2efa2e6600f017b566155974cb9fb185
options.timestamp = options.timestamp || BigInt(Date.now())
options.salt = options.salt || 1n
@@ -396,7 +396,7 @@ module.exports = function (client, options) {
@@ -407,7 +407,7 @@ module.exports = function (client, options) {
message,
timestamp: options.timestamp,
salt: options.salt,
- signature: (client.profileKeys && client._session) ? client.signMessage(message, options.timestamp, options.salt, undefined, acknowledgements) : undefined,
+ signature: (client.profileKeys && client._session) ? await client.signMessage(message, options.timestamp, options.salt, undefined, acknowledgements) : undefined,
offset: client._lastSeenMessages.pending,
checksum: computeChatChecksum(client._lastSeenMessages), // 1.21.5+
acknowledged
})
@@ -410,7 +410,7 @@ module.exports = function (client, options) {
@@ -422,7 +422,7 @@ module.exports = function (client, options) {
message,
timestamp: options.timestamp,
salt: options.salt,
@ -57,7 +57,7 @@ index 8d0869b150681574ad19292a026cce9f67a137ee..2efa2e6600f017b566155974cb9fb185
previousMessages: client._lastSeenMessages.map((e) => ({
messageSender: e.sender,
diff --git a/src/client/encrypt.js b/src/client/encrypt.js
index b9d21bab9faccd5dbf1975fc423fc55c73e906c5..99ffd76527b410e3a393181beb260108f4c63536 100644
index 63cc2bd9615100bd2fd63dfe14c094aa6b8cd1c9..36df57d1196af9761d920fa285ac48f85410eaef 100644
--- a/src/client/encrypt.js
+++ b/src/client/encrypt.js
@@ -25,7 +25,11 @@ module.exports = function (client, options) {
@ -73,41 +73,24 @@ index b9d21bab9faccd5dbf1975fc423fc55c73e906c5..99ffd76527b410e3a393181beb260108
}
function onJoinServerResponse (err) {
diff --git a/src/client/play.js b/src/client/play.js
index 559607f34e9a5b2b7809423f8ca4cd6746b60225..4dc1c3139438cc2729b05c57e57bd00252728f8a 100644
--- a/src/client/play.js
+++ b/src/client/play.js
@@ -53,7 +53,7 @@ module.exports = function (client, options) {
client.write('configuration_acknowledged', {})
diff --git a/src/client/pluginChannels.js b/src/client/pluginChannels.js
index 671eb452f31e6b5fcd57d715f1009d010160c65f..7f69f511c8fb97d431ec5125c851b49be8e2ab76 100644
--- a/src/client/pluginChannels.js
+++ b/src/client/pluginChannels.js
@@ -57,7 +57,7 @@ module.exports = function (client, options) {
try {
packet.data = proto.parsePacketBuffer(channel, packet.data).data
} catch (error) {
- client.emit('error', error)
+ client.emit('error', error, { customPayload: packet })
return
}
}
client.state = states.CONFIGURATION
- client.on('select_known_packs', () => {
+ client.once('select_known_packs', () => {
client.write('select_known_packs', { packs: [] })
})
// Server should send finish_configuration on its own right after sending the client a dimension codec
diff --git a/src/client.js b/src/client.js
index 5c7a62b013daa69be91ec9e763b1f48ffe96ffa6..174d42a77740a937afcb106e1f39a9ee824a24b9 100644
index e369e77d055ba919e8f9da7b8e8b5dc879c74cf4..54bb9e6644388e9b6bd42b3012951875989cdf0c 100644
--- a/src/client.js
+++ b/src/client.js
@@ -89,10 +89,12 @@ class Client extends EventEmitter {
parsed.metadata.name = parsed.data.name
parsed.data = parsed.data.params
parsed.metadata.state = state
- debug('read packet ' + state + '.' + parsed.metadata.name)
- if (debug.enabled) {
- const s = JSON.stringify(parsed.data, null, 2)
- debug(s && s.length > 10000 ? parsed.data : s)
+ if (!globalThis.excludeCommunicationDebugEvents?.includes(parsed.metadata.name)) {
+ debug('read packet ' + state + '.' + parsed.metadata.name)
+ if (debug.enabled) {
+ const s = JSON.stringify(parsed.data, null, 2)
+ debug(s && s.length > 10000 ? parsed.data : s)
+ }
}
if (this._hasBundlePacket && parsed.metadata.name === 'bundle_delimiter') {
if (this._mcBundle.length) { // End bundle
@@ -110,7 +112,13 @@ class Client extends EventEmitter {
@@ -111,7 +111,13 @@ class Client extends EventEmitter {
this._hasBundlePacket = false
}
} else {
@ -122,7 +105,7 @@ index 5c7a62b013daa69be91ec9e763b1f48ffe96ffa6..174d42a77740a937afcb106e1f39a9ee
}
})
}
@@ -168,7 +176,10 @@ class Client extends EventEmitter {
@@ -169,7 +175,10 @@ class Client extends EventEmitter {
}
const onFatalError = (err) => {
@ -134,25 +117,21 @@ index 5c7a62b013daa69be91ec9e763b1f48ffe96ffa6..174d42a77740a937afcb106e1f39a9ee
endSocket()
}
@@ -197,6 +208,8 @@ class Client extends EventEmitter {
@@ -198,6 +207,10 @@ class Client extends EventEmitter {
serializer -> framer -> socket -> splitter -> deserializer */
if (this.serializer) {
this.serializer.end()
+ this.socket?.end()
+ this.socket?.emit('end')
+ setTimeout(() => {
+ this.socket?.end()
+ this.socket?.emit('end')
+ }, 2000) // allow the serializer to finish writing
} else {
if (this.socket) this.socket.end()
}
@@ -238,8 +251,11 @@ class Client extends EventEmitter {
write (name, params) {
if (!this.serializer.writable) { return }
- debug('writing packet ' + this.state + '.' + name)
- debug(params)
+ if (!globalThis.excludeCommunicationDebugEvents?.includes(name)) {
+ debug(`[${this.state}] from ${this.isServer ? 'server' : 'client'}: ` + name)
+ debug(params)
+ }
@@ -243,6 +256,7 @@ class Client extends EventEmitter {
debug('writing packet ' + this.state + '.' + name)
debug(params)
}
+ this.emit('writePacket', name, params)
this.serializer.write({ name, params })
}

275
pnpm-lock.yaml generated
View file

@ -5,6 +5,7 @@ settings:
excludeLinksFromLockfile: false
overrides:
mineflayer: github:zardoy/mineflayer#gen-the-master
'@nxg-org/mineflayer-physics-util': 1.8.10
buffer: ^6.0.3
vec3: 0.1.10
@ -12,7 +13,7 @@ overrides:
diamond-square: github:zardoy/diamond-square
prismarine-block: github:zardoy/prismarine-block#next-era
prismarine-world: github:zardoy/prismarine-world#next-era
minecraft-data: 3.92.0
minecraft-data: 3.98.0
prismarine-provider-anvil: github:zardoy/prismarine-provider-anvil#everything
prismarine-physics: github:zardoy/prismarine-physics
minecraft-protocol: github:PrismarineJS/node-minecraft-protocol#master
@ -22,7 +23,7 @@ overrides:
patchedDependencies:
minecraft-protocol:
hash: a8726e6981ddc3486262d981d1e2030f379901c055ac9c4bf3036b4149e860e0
hash: 4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b
path: patches/minecraft-protocol.patch
mineflayer-item-map-downloader@1.2.0:
hash: a731ebbace2d8790c973ab3a5ba33494a6e9658533a9710dd8ba36f86db061ad
@ -41,6 +42,9 @@ importers:
'@floating-ui/react':
specifier: ^0.26.1
version: 0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@monaco-editor/react':
specifier: ^4.7.0
version: 4.7.0(monaco-editor@0.52.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@nxg-org/mineflayer-auto-jump':
specifier: ^0.7.18
version: 0.7.18
@ -117,8 +121,8 @@ importers:
specifier: ^10.0.12
version: 10.1.6
flying-squid:
specifier: npm:@zardoy/flying-squid@^0.0.62
version: '@zardoy/flying-squid@0.0.62(encoding@0.1.13)'
specifier: npm:@zardoy/flying-squid@^0.0.104
version: '@zardoy/flying-squid@0.0.104(encoding@0.1.13)'
framer-motion:
specifier: ^12.9.2
version: 12.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -136,13 +140,13 @@ importers:
version: 4.17.21
mcraft-fun-mineflayer:
specifier: ^0.1.23
version: 0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/3daf1f4bdc6afad0dedd87b879875f3dbb7b0980(encoding@0.1.13))
version: 0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(encoding@0.1.13))
minecraft-data:
specifier: 3.92.0
version: 3.92.0
specifier: 3.98.0
version: 3.98.0
minecraft-protocol:
specifier: github:PrismarineJS/node-minecraft-protocol#master
version: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/6c2204a813690ead420e2b8c7f0ef32ca357d176(patch_hash=a8726e6981ddc3486262d981d1e2030f379901c055ac9c4bf3036b4149e860e0)(encoding@0.1.13)
version: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13)
mineflayer-item-map-downloader:
specifier: github:zardoy/mineflayer-item-map-downloader
version: https://codeload.github.com/zardoy/mineflayer-item-map-downloader/tar.gz/a8d210ecdcf78dd082fa149a96e1612cc9747824(patch_hash=a731ebbace2d8790c973ab3a5ba33494a6e9658533a9710dd8ba36f86db061ad)(encoding@0.1.13)
@ -151,7 +155,7 @@ importers:
version: 2.0.4
net-browserify:
specifier: github:zardoy/prismarinejs-net-browserify
version: https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/17fb901e8ea480a52c8fd46373695be172be8aa5
version: https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/e754999ffdea67853bc9b10553b5e9908b40f618
node-gzip:
specifier: ^1.1.2
version: 1.1.2
@ -166,7 +170,7 @@ importers:
version: 6.1.1
prismarine-provider-anvil:
specifier: github:zardoy/prismarine-provider-anvil#everything
version: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.92.0)
version: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.98.0)
prosemirror-example-setup:
specifier: ^1.2.2
version: 1.2.3
@ -341,13 +345,10 @@ importers:
version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/89c33d396f3fde4804c71f4be3c203ade1833b41(@types/react@18.3.18)(react@18.3.1)
mineflayer:
specifier: github:zardoy/mineflayer#gen-the-master
version: https://codeload.github.com/zardoy/mineflayer/tar.gz/3daf1f4bdc6afad0dedd87b879875f3dbb7b0980(encoding@0.1.13)
version: https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(encoding@0.1.13)
mineflayer-mouse:
specifier: ^0.1.11
version: 0.1.11
mineflayer-pathfinder:
specifier: ^2.4.4
version: 2.4.5
specifier: ^0.1.21
version: 0.1.21
npm-run-all:
specifier: ^4.1.5
version: 4.1.5
@ -435,7 +436,7 @@ importers:
version: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9
prismarine-chunk:
specifier: github:zardoy/prismarine-chunk#master
version: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.92.0)
version: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.98.0)
prismarine-schematic:
specifier: ^1.2.0
version: 1.2.3
@ -1991,6 +1992,16 @@ packages:
'@module-federation/webpack-bundler-runtime@0.11.2':
resolution: {integrity: sha512-WdwIE6QF+MKs/PdVu0cKPETF743JB9PZ62/qf7Uo3gU4fjsUMc37RnbJZ/qB60EaHHfjwp1v6NnhZw1r4eVsnw==}
'@monaco-editor/loader@1.5.0':
resolution: {integrity: sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==}
'@monaco-editor/react@4.7.0':
resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==}
peerDependencies:
monaco-editor: '>= 0.25.0 < 1'
react: ^18.2.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@msgpack/msgpack@2.8.0':
resolution: {integrity: sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==}
engines: {node: '>= 10'}
@ -3376,13 +3387,13 @@ packages:
resolution: {integrity: sha512-6xm38yGVIa6mKm/DUCF2zFFJhERh/QWp1ufm4cNUvxsONBmfPg8uZ9pZBdOmF6qFGr/HlT6ABBkCSx/dlEtvWg==}
engines: {node: '>=12 <14 || 14.2 - 14.9 || >14.10.0'}
'@zardoy/flying-squid@0.0.49':
resolution: {integrity: sha512-Kt4wr5/R+44tcLU9gjuNG2an9weWeKEpIoKXfsgJN2GGQqdnbd5nBpxfGDdgZ9aMdFugsVW8BsyPZNhj9vbMXA==}
'@zardoy/flying-squid@0.0.104':
resolution: {integrity: sha512-jGhQ7fn7o8UN+mUwZbt9674D37YLuBi+Au4TwKcopCA6huIQdHTFNl2e+0ZSTI5mnhN+NpyVoR3vmtH6L58vHQ==}
engines: {node: '>=8'}
hasBin: true
'@zardoy/flying-squid@0.0.62':
resolution: {integrity: sha512-M6icydO/yrmwevBhmgKcqEPC63AhWfU/Es9N/uadVrmKaxGm2FQMMLcybbutRYm1xZ6qsdxDUOUZnN56PsVwfQ==}
'@zardoy/flying-squid@0.0.49':
resolution: {integrity: sha512-Kt4wr5/R+44tcLU9gjuNG2an9weWeKEpIoKXfsgJN2GGQqdnbd5nBpxfGDdgZ9aMdFugsVW8BsyPZNhj9vbMXA==}
engines: {node: '>=8'}
hasBin: true
@ -6433,6 +6444,12 @@ packages:
resolution: {integrity: sha512-RYZeD1+joNlPuUpi+tIWkbP0ieVJr+R6IFkI6/8juhSxx9zE4osoSmteybrfspGm8A6u+YbbY1epqRKEMwVR6Q==}
engines: {node: '>=18.0.0'}
mc-bridge@0.1.3:
resolution: {integrity: sha512-H9jPt2xEU77itC27dSz3qazHYqN9qVsv4HgMPozg7RqQ1uwgXmEa+ojKIlDtXf/TLJsG6Kv4EbzGa8a1Wh72uA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
peerDependencies:
minecraft-data: 3.98.0
mcraft-fun-mineflayer@0.1.23:
resolution: {integrity: sha512-qmI1rQQ0Ro5zJdi99rClWLF+mS9JZffgNX2vyWWesS3Hsk3Xblp/8swYTJKHSaFpNgzkVfXV92fEIrBqeH6iKA==}
version: 0.1.23
@ -6641,8 +6658,8 @@ packages:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
minecraft-data@3.92.0:
resolution: {integrity: sha512-CGfO50svzm+pSRa4Mbq4owsmRKbPCNkSZ3MCOyH+epC7yNjh+PUhPQFHWq72O51qsY7pAB5qM/bJn1ncwG1J5g==}
minecraft-data@3.98.0:
resolution: {integrity: sha512-JAPqJ/TZoxMUlAPPdWUh1v5wdqvYGFSZ4rW9bUtmaKBkGpomDSjw4V02ocBqbxKJvcTtmc5nM/LfN9/0DDqHrQ==}
minecraft-folder-path@1.2.0:
resolution: {integrity: sha512-qaUSbKWoOsH9brn0JQuBhxNAzTDMwrOXorwuRxdJKKKDYvZhtml+6GVCUrY5HRiEsieBEjCUnhVpDuQiKsiFaw==}
@ -6651,9 +6668,9 @@ packages:
resolution: {tarball: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/89c33d396f3fde4804c71f4be3c203ade1833b41}
version: 1.0.1
minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/6c2204a813690ead420e2b8c7f0ef32ca357d176:
resolution: {tarball: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/6c2204a813690ead420e2b8c7f0ef32ca357d176}
version: 1.58.0
minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9:
resolution: {tarball: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9}
version: 1.62.0
engines: {node: '>=22'}
minecraft-wrap@1.6.0:
@ -6667,20 +6684,13 @@ packages:
resolution: {tarball: https://codeload.github.com/zardoy/mineflayer-item-map-downloader/tar.gz/a8d210ecdcf78dd082fa149a96e1612cc9747824}
version: 1.2.0
mineflayer-mouse@0.1.11:
resolution: {integrity: sha512-BL47pXZ1+92BA/7ym6KaJctEHKnL0up+tpuagVwSKJvAgibeqWQJJwDlNUWkOLvpnruRKDxMR5OB1hUXFoDNSg==}
mineflayer-mouse@0.1.21:
resolution: {integrity: sha512-1XTVuw3twIrEcqQ1QRSB8NcStIUEZ+tbxiAG6rOrN/9M4thhtlS5PTJzFdmdrcYgWEBLvuOdJszaKE5zFfiXhg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
mineflayer-pathfinder@2.4.5:
resolution: {integrity: sha512-Jh3JnUgRLwhMh2Dugo4SPza68C41y+NPP5sdsgxRu35ydndo70i1JJGxauVWbXrpNwIxYNztUw78aFyb7icw8g==}
mineflayer@4.30.0:
resolution: {integrity: sha512-GtW4hkijyZbSu5LKYYD89xZu+XY7OoP7IkrCnNEn6EdPm0+vr2THoJgFGKrlze9/81+T+P3E4qvJXNFiU/zeJg==}
engines: {node: '>=22'}
mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/3daf1f4bdc6afad0dedd87b879875f3dbb7b0980:
resolution: {tarball: https://codeload.github.com/zardoy/mineflayer/tar.gz/3daf1f4bdc6afad0dedd87b879875f3dbb7b0980}
version: 4.30.0
mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659:
resolution: {tarball: https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659}
version: 8.0.0
engines: {node: '>=22'}
minimalistic-assert@1.0.1:
@ -6778,6 +6788,9 @@ packages:
mojangson@2.0.4:
resolution: {integrity: sha512-HYmhgDjr1gzF7trGgvcC/huIg2L8FsVbi/KacRe6r1AswbboGVZDS47SOZlomPuMWvZLas8m9vuHHucdZMwTmQ==}
monaco-editor@0.52.2:
resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==}
moo@0.5.2:
resolution: {integrity: sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==}
@ -6842,8 +6855,8 @@ packages:
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
net-browserify@https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/17fb901e8ea480a52c8fd46373695be172be8aa5:
resolution: {tarball: https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/17fb901e8ea480a52c8fd46373695be172be8aa5}
net-browserify@https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/e754999ffdea67853bc9b10553b5e9908b40f618:
resolution: {tarball: https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/e754999ffdea67853bc9b10553b5e9908b40f618}
version: 0.2.4
nice-try@1.0.5:
@ -7374,7 +7387,7 @@ packages:
prismarine-biome@1.3.0:
resolution: {integrity: sha512-GY6nZxq93mTErT7jD7jt8YS1aPrOakbJHh39seYsJFXvueIOdHAmW16kYQVrTVMW5MlWLQVxV/EquRwOgr4MnQ==}
peerDependencies:
minecraft-data: 3.92.0
minecraft-data: 3.98.0
prismarine-registry: ^1.1.0
prismarine-block@https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9:
@ -7392,8 +7405,8 @@ packages:
prismarine-entity@2.5.0:
resolution: {integrity: sha512-nRPCawUwf9r3iKqi4I7mZRlir1Ix+DffWYdWq6p/KNnmiXve+xHE5zv8XCdhZlUmOshugHv5ONl9o6ORAkCNIA==}
prismarine-item@1.16.0:
resolution: {integrity: sha512-88Tz+/6HquYIsDuseae5G3IbqLeMews2L+ba2gX+p6K6soU9nuFhCfbwN56QuB7d/jZFcWrCYAPE5+UhwWh67w==}
prismarine-item@1.17.0:
resolution: {integrity: sha512-wN1OjP+f+Uvtjo3KzeCkVSy96CqZ8yG7cvuvlGwcYupQ6ct7LtNkubHp0AHuLMJ0vbbfAC0oZ2bWOgI1DYp8WA==}
prismarine-nbt@2.7.0:
resolution: {integrity: sha512-Du9OLQAcCj3y29YtewOJbbV4ARaSUEJiTguw0PPQbPBy83f+eCyDRkyBpnXTi/KPyEpgYCzsjGzElevLpFoYGQ==}
@ -8324,6 +8337,7 @@ packages:
source-map@0.8.0-beta.0:
resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==}
engines: {node: '>= 8'}
deprecated: The work that was done in this beta branch won't be included in future versions
sourcemap-codec@1.4.8:
resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
@ -8381,6 +8395,9 @@ packages:
stacktrace-js@2.0.2:
resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==}
state-local@1.0.7:
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
static-extend@0.1.2:
resolution: {integrity: sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==}
engines: {node: '>=0.10.0'}
@ -9668,7 +9685,7 @@ snapshots:
'@babel/core': 7.26.9
'@babel/helper-compilation-targets': 7.26.5
'@babel/helper-plugin-utils': 7.26.5
debug: 4.4.0(supports-color@8.1.1)
debug: 4.4.1
lodash.debounce: 4.0.8
resolve: 1.22.10
transitivePeerDependencies:
@ -10293,7 +10310,7 @@ snapshots:
'@babel/parser': 7.26.9
'@babel/template': 7.26.9
'@babel/types': 7.26.9
debug: 4.4.0(supports-color@8.1.1)
debug: 4.4.1
globals: 11.12.0
transitivePeerDependencies:
- supports-color
@ -11262,6 +11279,17 @@ snapshots:
'@module-federation/runtime': 0.11.2
'@module-federation/sdk': 0.11.2
'@monaco-editor/loader@1.5.0':
dependencies:
state-local: 1.0.7
'@monaco-editor/react@4.7.0(monaco-editor@0.52.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@monaco-editor/loader': 1.5.0
monaco-editor: 0.52.2
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@msgpack/msgpack@2.8.0': {}
'@ndelangen/get-tarball@3.0.9':
@ -11315,10 +11343,10 @@ snapshots:
'@nxg-org/mineflayer-trajectories@1.2.0(encoding@0.1.13)':
dependencies:
'@nxg-org/mineflayer-util-plugin': 1.8.4
minecraft-data: 3.92.0
mineflayer: 4.30.0(encoding@0.1.13)
minecraft-data: 3.98.0
mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(encoding@0.1.13)
prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9
prismarine-item: 1.16.0
prismarine-item: 1.17.0
prismarine-physics: https://codeload.github.com/zardoy/prismarine-physics/tar.gz/353e25b800149393f40539ec381218be44cbb03b
vec3: 0.1.10
transitivePeerDependencies:
@ -12863,7 +12891,7 @@ snapshots:
dependencies:
'@typescript-eslint/typescript-estree': 6.1.0(typescript@5.5.4)
'@typescript-eslint/utils': 6.1.0(eslint@8.57.1)(typescript@5.5.4)
debug: 4.4.0(supports-color@8.1.1)
debug: 4.4.1
eslint: 8.57.1
ts-api-utils: 1.4.3(typescript@5.5.4)
optionalDependencies:
@ -12881,7 +12909,7 @@ snapshots:
dependencies:
'@typescript-eslint/types': 6.1.0
'@typescript-eslint/visitor-keys': 6.1.0
debug: 4.4.0(supports-color@8.1.1)
debug: 4.4.1
globby: 11.1.0
is-glob: 4.0.3
semver: 7.7.1
@ -12895,7 +12923,7 @@ snapshots:
dependencies:
'@typescript-eslint/types': 6.21.0
'@typescript-eslint/visitor-keys': 6.21.0
debug: 4.4.0(supports-color@8.1.1)
debug: 4.4.1
globby: 11.1.0
is-glob: 4.0.3
minimatch: 9.0.3
@ -12910,7 +12938,7 @@ snapshots:
dependencies:
'@typescript-eslint/types': 8.26.0
'@typescript-eslint/visitor-keys': 8.26.0
debug: 4.4.0(supports-color@8.1.1)
debug: 4.4.1
fast-glob: 3.3.3
is-glob: 4.0.3
minimatch: 9.0.5
@ -13066,7 +13094,7 @@ snapshots:
'@types/emscripten': 1.40.0
tslib: 1.14.1
'@zardoy/flying-squid@0.0.49(encoding@0.1.13)':
'@zardoy/flying-squid@0.0.104(encoding@0.1.13)':
dependencies:
'@tootallnate/once': 2.0.0
chalk: 5.4.1
@ -13076,16 +13104,18 @@ snapshots:
exit-hook: 2.2.1
flatmap: 0.0.3
long: 5.3.1
minecraft-data: 3.92.0
minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/6c2204a813690ead420e2b8c7f0ef32ca357d176(patch_hash=a8726e6981ddc3486262d981d1e2030f379901c055ac9c4bf3036b4149e860e0)(encoding@0.1.13)
mc-bridge: 0.1.3(minecraft-data@3.98.0)
minecraft-data: 3.98.0
minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13)
mkdirp: 2.1.6
node-gzip: 1.1.2
node-rsa: 1.1.1
prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.92.0)
prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9
prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.98.0)
prismarine-entity: 2.5.0
prismarine-item: 1.16.0
prismarine-item: 1.17.0
prismarine-nbt: 2.7.0
prismarine-provider-anvil: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.92.0)
prismarine-provider-anvil: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.98.0)
prismarine-windows: 2.9.0
prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c
rambda: 9.4.2
@ -13102,7 +13132,7 @@ snapshots:
- encoding
- supports-color
'@zardoy/flying-squid@0.0.62(encoding@0.1.13)':
'@zardoy/flying-squid@0.0.49(encoding@0.1.13)':
dependencies:
'@tootallnate/once': 2.0.0
chalk: 5.4.1
@ -13112,16 +13142,16 @@ snapshots:
exit-hook: 2.2.1
flatmap: 0.0.3
long: 5.3.1
minecraft-data: 3.92.0
minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/6c2204a813690ead420e2b8c7f0ef32ca357d176(patch_hash=a8726e6981ddc3486262d981d1e2030f379901c055ac9c4bf3036b4149e860e0)(encoding@0.1.13)
minecraft-data: 3.98.0
minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13)
mkdirp: 2.1.6
node-gzip: 1.1.2
node-rsa: 1.1.1
prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.92.0)
prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.98.0)
prismarine-entity: 2.5.0
prismarine-item: 1.16.0
prismarine-item: 1.17.0
prismarine-nbt: 2.7.0
prismarine-provider-anvil: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.92.0)
prismarine-provider-anvil: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.98.0)
prismarine-windows: 2.9.0
prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c
rambda: 9.4.2
@ -14502,7 +14532,7 @@ snapshots:
detect-port@1.6.1:
dependencies:
address: 1.2.2
debug: 4.4.0(supports-color@8.1.1)
debug: 4.4.1
transitivePeerDependencies:
- supports-color
@ -14512,8 +14542,8 @@ snapshots:
diamond-square@https://codeload.github.com/zardoy/diamond-square/tar.gz/cfaad2d1d5909fdfa63c8cc7bc05fb5e87782d71:
dependencies:
minecraft-data: 3.92.0
prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.92.0)
minecraft-data: 3.98.0
prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.98.0)
prismarine-registry: 1.11.0
random-seed: 0.3.0
vec3: 0.1.10
@ -16109,7 +16139,7 @@ snapshots:
https-proxy-agent@4.0.0:
dependencies:
agent-base: 5.1.1
debug: 4.4.0(supports-color@8.1.1)
debug: 4.4.1
transitivePeerDependencies:
- supports-color
@ -16956,13 +16986,17 @@ snapshots:
maxrects-packer: '@zardoy/maxrects-packer@2.7.4'
zod: 3.24.2
mcraft-fun-mineflayer@0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/3daf1f4bdc6afad0dedd87b879875f3dbb7b0980(encoding@0.1.13)):
mc-bridge@0.1.3(minecraft-data@3.98.0):
dependencies:
minecraft-data: 3.98.0
mcraft-fun-mineflayer@0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(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/6c2204a813690ead420e2b8c7f0ef32ca357d176(patch_hash=a8726e6981ddc3486262d981d1e2030f379901c055ac9c4bf3036b4149e860e0)(encoding@0.1.13)
mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/3daf1f4bdc6afad0dedd87b879875f3dbb7b0980(encoding@0.1.13)
prismarine-item: 1.16.0
minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13)
mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(encoding@0.1.13)
prismarine-item: 1.17.0
ws: 8.18.1
transitivePeerDependencies:
- bufferutil
@ -17191,7 +17225,7 @@ snapshots:
micromark@4.0.2:
dependencies:
'@types/debug': 4.1.12
debug: 4.4.0(supports-color@8.1.1)
debug: 4.4.1
decode-named-character-reference: 1.1.0
devlop: 1.1.0
micromark-core-commonmark: 2.0.3
@ -17268,7 +17302,7 @@ snapshots:
min-indent@1.0.1: {}
minecraft-data@3.92.0: {}
minecraft-data@3.98.0: {}
minecraft-folder-path@1.2.0: {}
@ -17279,7 +17313,7 @@ snapshots:
- '@types/react'
- react
minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/6c2204a813690ead420e2b8c7f0ef32ca357d176(patch_hash=a8726e6981ddc3486262d981d1e2030f379901c055ac9c4bf3036b4149e860e0)(encoding@0.1.13):
minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13):
dependencies:
'@types/node-rsa': 1.1.4
'@types/readable-stream': 4.0.18
@ -17288,7 +17322,7 @@ snapshots:
debug: 4.4.0(supports-color@8.1.1)
endian-toggle: 0.0.0
lodash.merge: 4.6.2
minecraft-data: 3.92.0
minecraft-data: 3.98.0
minecraft-folder-path: 1.2.0
node-fetch: 2.7.0(encoding@0.1.13)
node-rsa: 0.4.2
@ -17331,65 +17365,32 @@ snapshots:
mineflayer-item-map-downloader@https://codeload.github.com/zardoy/mineflayer-item-map-downloader/tar.gz/a8d210ecdcf78dd082fa149a96e1612cc9747824(patch_hash=a731ebbace2d8790c973ab3a5ba33494a6e9658533a9710dd8ba36f86db061ad)(encoding@0.1.13):
dependencies:
mineflayer: 4.30.0(encoding@0.1.13)
mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(encoding@0.1.13)
sharp: 0.30.7
transitivePeerDependencies:
- encoding
- supports-color
mineflayer-mouse@0.1.11:
mineflayer-mouse@0.1.21:
dependencies:
change-case: 5.4.4
debug: 4.4.1
prismarine-item: 1.16.0
prismarine-item: 1.17.0
prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c
transitivePeerDependencies:
- supports-color
mineflayer-pathfinder@2.4.5:
dependencies:
minecraft-data: 3.92.0
prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9
prismarine-entity: 2.5.0
prismarine-item: 1.16.0
prismarine-nbt: 2.7.0
prismarine-physics: https://codeload.github.com/zardoy/prismarine-physics/tar.gz/353e25b800149393f40539ec381218be44cbb03b
vec3: 0.1.10
mineflayer@4.30.0(encoding@0.1.13):
dependencies:
minecraft-data: 3.92.0
minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/6c2204a813690ead420e2b8c7f0ef32ca357d176(patch_hash=a8726e6981ddc3486262d981d1e2030f379901c055ac9c4bf3036b4149e860e0)(encoding@0.1.13)
prismarine-biome: 1.3.0(minecraft-data@3.92.0)(prismarine-registry@1.11.0)
prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9
prismarine-chat: 1.11.0
prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.92.0)
prismarine-entity: 2.5.0
prismarine-item: 1.16.0
prismarine-nbt: 2.7.0
prismarine-physics: https://codeload.github.com/zardoy/prismarine-physics/tar.gz/353e25b800149393f40539ec381218be44cbb03b
prismarine-recipe: 1.3.1(prismarine-registry@1.11.0)
prismarine-registry: 1.11.0
prismarine-windows: 2.9.0
prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c
protodef: 1.18.0
typed-emitter: 1.4.0
vec3: 0.1.10
transitivePeerDependencies:
- encoding
- supports-color
mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/3daf1f4bdc6afad0dedd87b879875f3dbb7b0980(encoding@0.1.13):
mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(encoding@0.1.13):
dependencies:
'@nxg-org/mineflayer-physics-util': 1.8.10
minecraft-data: 3.92.0
minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/6c2204a813690ead420e2b8c7f0ef32ca357d176(patch_hash=a8726e6981ddc3486262d981d1e2030f379901c055ac9c4bf3036b4149e860e0)(encoding@0.1.13)
prismarine-biome: 1.3.0(minecraft-data@3.92.0)(prismarine-registry@1.11.0)
minecraft-data: 3.98.0
minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13)
prismarine-biome: 1.3.0(minecraft-data@3.98.0)(prismarine-registry@1.11.0)
prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9
prismarine-chat: 1.11.0
prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.92.0)
prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.98.0)
prismarine-entity: 2.5.0
prismarine-item: 1.16.0
prismarine-item: 1.17.0
prismarine-nbt: 2.7.0
prismarine-physics: https://codeload.github.com/zardoy/prismarine-physics/tar.gz/353e25b800149393f40539ec381218be44cbb03b
prismarine-recipe: 1.3.1(prismarine-registry@1.11.0)
@ -17502,6 +17503,8 @@ snapshots:
dependencies:
nearley: 2.20.1
monaco-editor@0.52.2: {}
moo@0.5.2: {}
morgan@1.10.0:
@ -17583,7 +17586,7 @@ snapshots:
neo-async@2.6.2: {}
net-browserify@https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/17fb901e8ea480a52c8fd46373695be172be8aa5:
net-browserify@https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/e754999ffdea67853bc9b10553b5e9908b40f618:
dependencies:
body-parser: 1.20.3
express: 4.21.2
@ -18171,17 +18174,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
prismarine-biome@1.3.0(minecraft-data@3.92.0)(prismarine-registry@1.11.0):
prismarine-biome@1.3.0(minecraft-data@3.98.0)(prismarine-registry@1.11.0):
dependencies:
minecraft-data: 3.92.0
minecraft-data: 3.98.0
prismarine-registry: 1.11.0
prismarine-block@https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9:
dependencies:
minecraft-data: 3.92.0
prismarine-biome: 1.3.0(minecraft-data@3.92.0)(prismarine-registry@1.11.0)
minecraft-data: 3.98.0
prismarine-biome: 1.3.0(minecraft-data@3.98.0)(prismarine-registry@1.11.0)
prismarine-chat: 1.11.0
prismarine-item: 1.16.0
prismarine-item: 1.17.0
prismarine-nbt: 2.7.0
prismarine-registry: 1.11.0
@ -18191,9 +18194,9 @@ snapshots:
prismarine-nbt: 2.7.0
prismarine-registry: 1.11.0
prismarine-chunk@https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.92.0):
prismarine-chunk@https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.98.0):
dependencies:
prismarine-biome: 1.3.0(minecraft-data@3.92.0)(prismarine-registry@1.11.0)
prismarine-biome: 1.3.0(minecraft-data@3.98.0)(prismarine-registry@1.11.0)
prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9
prismarine-nbt: 2.7.0
prismarine-registry: 1.11.0
@ -18207,11 +18210,11 @@ snapshots:
prismarine-entity@2.5.0:
dependencies:
prismarine-chat: 1.11.0
prismarine-item: 1.16.0
prismarine-item: 1.17.0
prismarine-registry: 1.11.0
vec3: 0.1.10
prismarine-item@1.16.0:
prismarine-item@1.17.0:
dependencies:
prismarine-nbt: 2.7.0
prismarine-registry: 1.11.0
@ -18222,14 +18225,14 @@ snapshots:
prismarine-physics@https://codeload.github.com/zardoy/prismarine-physics/tar.gz/353e25b800149393f40539ec381218be44cbb03b:
dependencies:
minecraft-data: 3.92.0
minecraft-data: 3.98.0
prismarine-nbt: 2.7.0
vec3: 0.1.10
prismarine-provider-anvil@https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.92.0):
prismarine-provider-anvil@https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.98.0):
dependencies:
prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9
prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.92.0)
prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.98.0)
prismarine-nbt: 2.7.0
prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c
uint4: 0.1.2
@ -18251,13 +18254,13 @@ snapshots:
prismarine-registry@1.11.0:
dependencies:
minecraft-data: 3.92.0
minecraft-data: 3.98.0
prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9
prismarine-nbt: 2.7.0
prismarine-schematic@1.2.3:
dependencies:
minecraft-data: 3.92.0
minecraft-data: 3.98.0
prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9
prismarine-nbt: 2.7.0
prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c
@ -18265,7 +18268,7 @@ snapshots:
prismarine-windows@2.9.0:
dependencies:
prismarine-item: 1.16.0
prismarine-item: 1.17.0
prismarine-registry: 1.11.0
typed-emitter: 2.1.0
@ -18456,7 +18459,7 @@ snapshots:
puppeteer-core@2.1.1:
dependencies:
'@types/mime-types': 2.1.4
debug: 4.4.0(supports-color@8.1.1)
debug: 4.4.1
extract-zip: 1.7.0
https-proxy-agent: 4.0.0
mime: 2.6.0
@ -19558,6 +19561,8 @@ snapshots:
stack-generator: 2.0.10
stacktrace-gps: 3.1.2
state-local@1.0.7: {}
static-extend@0.1.2:
dependencies:
define-property: 0.2.5

View file

@ -65,7 +65,7 @@ function getAllMethods (obj) {
return [...methods] as string[]
}
export const delayedIterator = async <T> (arr: T[], delay: number, exec: (item: T, index: number) => void, chunkSize = 1) => {
export const delayedIterator = async <T> (arr: T[], delay: number, exec: (item: T, index: number) => Promise<void>, chunkSize = 1) => {
// if delay is 0 then don't use setTimeout
for (let i = 0; i < arr.length; i += chunkSize) {
if (delay) {
@ -74,6 +74,6 @@ export const delayedIterator = async <T> (arr: T[], delay: number, exec: (item:
setTimeout(resolve, delay)
})
}
exec(arr[i], i)
await exec(arr[i], i)
}
}

View file

@ -73,12 +73,12 @@ export const appAndRendererSharedConfig = () => defineConfig({
})
export const rspackViewerConfig = (config, { appendPlugins, addRules, rspack }: ModifyRspackConfigUtils) => {
appendPlugins(new rspack.NormalModuleReplacementPlugin(/data/, (resource) => {
appendPlugins(new rspack.NormalModuleReplacementPlugin(/data|prismarine-physics/, (resource) => {
let absolute: string
const request = resource.request.replaceAll('\\', '/')
absolute = path.join(resource.context, request).replaceAll('\\', '/')
if (request.includes('minecraft-data/data/pc/1.')) {
console.log('Error: incompatible resource', request, resource.contextInfo.issuer)
if (request.includes('minecraft-data/data/pc/1.') || request.includes('prismarine-physics')) {
console.log('Error: incompatible resource', request, 'from', resource.contextInfo.issuer)
process.exit(1)
// throw new Error(`${resource.request} was requested by ${resource.contextInfo.issuer}`)
}

View file

@ -1,3 +1,4 @@
import { proxy } from 'valtio'
import { NonReactiveState, RendererReactiveState } from '../../src/appViewer'
export const getDefaultRendererState = (): {
@ -5,7 +6,7 @@ export const getDefaultRendererState = (): {
nonReactive: NonReactiveState
} => {
return {
reactive: {
reactive: proxy({
world: {
chunksLoaded: new Set(),
heightmaps: new Map(),
@ -15,7 +16,7 @@ export const getDefaultRendererState = (): {
},
renderer: '',
preventEscapeMenu: false
},
}),
nonReactive: {
world: {
chunksLoaded: new Set(),

View file

@ -48,6 +48,7 @@ export const getInitialPlayerState = () => proxy({
heldItemMain: undefined as HandItemBlock | undefined,
heldItemOff: undefined as HandItemBlock | undefined,
perspective: 'first_person' as CameraPerspective,
onFire: false,
cameraSpectatingEntity: undefined as number | undefined,

View file

@ -0,0 +1,55 @@
import { PlayerObject, PlayerAnimation } from 'skinview3d'
import * as THREE from 'three'
import { WalkingGeneralSwing } from '../three/entity/animations'
import { loadSkinImage, stevePngUrl } from './utils/skins'
export type PlayerObjectType = PlayerObject & {
animation?: PlayerAnimation
realPlayerUuid: string
realUsername: string
}
export function createPlayerObject (options: {
username?: string
uuid?: string
scale?: number
}): {
playerObject: PlayerObjectType
wrapper: THREE.Group
} {
const wrapper = new THREE.Group()
const playerObject = new PlayerObject() as PlayerObjectType
playerObject.realPlayerUuid = options.uuid ?? ''
playerObject.realUsername = options.username ?? ''
playerObject.position.set(0, 16, 0)
// fix issues with starfield
playerObject.traverse((obj) => {
if (obj instanceof THREE.Mesh && obj.material instanceof THREE.MeshStandardMaterial) {
obj.material.transparent = true
}
})
wrapper.add(playerObject as any)
const scale = options.scale ?? (1 / 16)
wrapper.scale.set(scale, scale, scale)
wrapper.rotation.set(0, Math.PI, 0)
// Set up animation
playerObject.animation = new WalkingGeneralSwing()
;(playerObject.animation as WalkingGeneralSwing).isMoving = false
playerObject.animation.update(playerObject, 0)
return { playerObject, wrapper }
}
export const applySkinToPlayerObject = async (playerObject: PlayerObjectType, skinUrl: string) => {
return loadSkinImage(skinUrl || stevePngUrl).then(({ canvas }) => {
const skinTexture = new THREE.CanvasTexture(canvas)
skinTexture.magFilter = THREE.NearestFilter
skinTexture.minFilter = THREE.NearestFilter
skinTexture.needsUpdate = true
playerObject.skin.map = skinTexture as any
}).catch(console.error)
}

View file

@ -542,7 +542,6 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W
heads: {},
signs: {},
// isFull: true,
highestBlocks: new Map(),
hadErrors: false,
blocksCount: 0
}
@ -552,12 +551,6 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W
for (cursor.z = sz; cursor.z < sz + 16; cursor.z++) {
for (cursor.x = sx; cursor.x < sx + 16; cursor.x++) {
let block = world.getBlock(cursor, blockProvider, attr)!
if (!INVISIBLE_BLOCKS.has(block.name)) {
const highest = attr.highestBlocks.get(`${cursor.x},${cursor.z}`)
if (!highest || highest.y < cursor.y) {
attr.highestBlocks.set(`${cursor.x},${cursor.z}`, { y: cursor.y, stateId: block.stateId, biomeId: block.biome.id })
}
}
if (INVISIBLE_BLOCKS.has(block.name)) continue
if ((block.name.includes('_sign') || block.name === 'sign') && !world.config.disableSignsMapsSupport) {
const key = `${cursor.x},${cursor.y},${cursor.z}`

View file

@ -42,7 +42,6 @@ export type MesherGeometryOutput = {
heads: Record<string, any>,
signs: Record<string, any>,
// isFull: boolean
highestBlocks: Map<string, HighestBlockInfo>
hadErrors: boolean
blocksCount: number
customBlockModels?: CustomBlockModels

View file

@ -1,11 +0,0 @@
import { fromFormattedString } from '@xmcl/text-component'
export const formattedStringToSimpleString = (str) => {
const result = fromFormattedString(str)
str = result.text
// todo recursive
for (const extra of result.extra) {
str += extra.text
}
return str
}

View file

@ -1,30 +1,3 @@
import * as THREE from 'three'
import { loadThreeJsTextureFromUrl, loadThreeJsTextureFromUrlSync } from './utils/skins'
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>()
const t = loadThreeJsTextureFromUrlSync(texture)
textureCache[texture] = t.texture
void t.promise.then(resolve)
imagesPromises[texture] = promise
}
cb(textureCache[texture])
void imagesPromises[texture].then(() => {
onLoad?.()
})
}
export const clearTextureCache = () => {
textureCache = {}
imagesPromises = {}
}
export const loadScript = async function (scriptSrc: string, highPriority = true): Promise<HTMLScriptElement> {
const existingScript = document.querySelector<HTMLScriptElement>(`script[src="${scriptSrc}"]`)
if (existingScript) {
@ -52,3 +25,33 @@ export const loadScript = async function (scriptSrc: string, highPriority = true
document.head.appendChild(scriptElement)
})
}
const detectFullOffscreenCanvasSupport = () => {
if (typeof OffscreenCanvas === 'undefined') return false
try {
const canvas = new OffscreenCanvas(1, 1)
// Try to get a WebGL context - this will fail on iOS where only 2D is supported (iOS 16)
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl')
return gl !== null
} catch (e) {
return false
}
}
const hasFullOffscreenCanvasSupport = detectFullOffscreenCanvasSupport()
export const createCanvas = (width: number, height: number): OffscreenCanvas => {
if (hasFullOffscreenCanvasSupport) {
return new OffscreenCanvas(width, height)
}
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
return canvas as unknown as OffscreenCanvas // todo-low
}
export async function loadImageFromUrl (imageUrl: string): Promise<ImageBitmap> {
const response = await fetch(imageUrl)
const blob = await response.blob()
return createImageBitmap(blob)
}

View file

@ -1,68 +1,7 @@
import { loadSkinToCanvas } from 'skinview-utils'
import * as THREE from 'three'
import stevePng from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png'
import { getLoadedImage } from 'mc-assets/dist/utils'
import { createCanvas, loadImageFromUrl } from '../utils'
const detectFullOffscreenCanvasSupport = () => {
if (typeof OffscreenCanvas === 'undefined') return false
try {
const canvas = new OffscreenCanvas(1, 1)
// Try to get a WebGL context - this will fail on iOS where only 2D is supported (iOS 16)
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl')
return gl !== null
} catch (e) {
return false
}
}
const hasFullOffscreenCanvasSupport = detectFullOffscreenCanvasSupport()
export const loadThreeJsTextureFromUrlSync = (imageUrl: string) => {
const texture = new THREE.Texture()
const promise = getLoadedImage(imageUrl).then(image => {
texture.image = image
texture.needsUpdate = true
return texture
})
return {
texture,
promise
}
}
export const createCanvas = (width: number, height: number): OffscreenCanvas => {
if (hasFullOffscreenCanvasSupport) {
return new OffscreenCanvas(width, height)
}
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
return canvas as unknown as OffscreenCanvas // todo-low
}
export const loadThreeJsTextureFromUrl = async (imageUrl: string) => {
const loaded = new THREE.TextureLoader().loadAsync(imageUrl)
return loaded
}
export const loadThreeJsTextureFromBitmap = (image: ImageBitmap) => {
const canvas = createCanvas(image.width, image.height)
const ctx = canvas.getContext('2d')!
ctx.drawImage(image, 0, 0)
const texture = new THREE.Texture(canvas)
texture.magFilter = THREE.NearestFilter
texture.minFilter = THREE.NearestFilter
return texture
}
export const stevePngUrl = stevePng
export const steveTexture = loadThreeJsTextureFromUrl(stevePngUrl)
export async function loadImageFromUrl (imageUrl: string): Promise<ImageBitmap> {
const response = await fetch(imageUrl)
const blob = await response.blob()
return createImageBitmap(blob)
}
export { default as stevePngUrl } from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png'
const config = {
apiEnabled: true,

View file

@ -7,6 +7,7 @@ import { Vec3 } from 'vec3'
import { BotEvents } from 'mineflayer'
import { proxy } from 'valtio'
import TypedEmitter from 'typed-emitter'
import { Biome } from 'minecraft-data'
import { delayedIterator } from '../../playground/shared'
import { chunkPos } from './simpleUtils'
@ -28,6 +29,8 @@ export type WorldDataEmitterEvents = {
updateLight: (data: { pos: Vec3 }) => void
onWorldSwitch: () => void
end: () => void
biomeUpdate: (data: { biome: Biome }) => void
biomeReset: () => void
}
export class WorldDataEmitterWorker extends (EventEmitter as new () => TypedEmitter<WorldDataEmitterEvents>) {
@ -35,7 +38,15 @@ export class WorldDataEmitterWorker extends (EventEmitter as new () => TypedEmit
}
export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<WorldDataEmitterEvents>) {
spiralNumber = 0
gotPanicLastTime = false
panicChunksReload = () => {}
loadedChunks: Record<ChunkPosKey, boolean>
private inLoading = false
private chunkReceiveTimes: number[] = []
private lastChunkReceiveTime = 0
public lastChunkReceiveTimeAvg = 0
private panicTimeout?: NodeJS.Timeout
readonly lastPos: Vec3
private eventListeners: Record<string, any> = {}
private readonly emitter: WorldDataEmitter
@ -133,12 +144,19 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
this.emitter.emit('entity', { id: e.id, delete: true })
},
chunkColumnLoad: (pos: Vec3) => {
const now = performance.now()
if (this.lastChunkReceiveTime) {
this.chunkReceiveTimes.push(now - this.lastChunkReceiveTime)
}
this.lastChunkReceiveTime = now
if (this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`]) {
this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`](true)
delete this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`]
} else if (this.loadedChunks[`${pos.x},${pos.z}`]) {
void this.loadChunk(pos, false, 'Received another chunkColumnLoad event while already loaded')
}
this.chunkProgress()
},
chunkColumnUnload: (pos: Vec3) => {
this.unloadChunk(pos)
@ -210,7 +228,7 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
if (bot?.time?.timeOfDay) {
this.emitter.emit('time', bot.time.timeOfDay)
}
if (bot.entity) {
if (bot?.entity) {
this.emitter.emit('playerEntity', bot.entity)
}
this.emitterGotConnected()
@ -219,33 +237,59 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
const positions = generateSpiralMatrix(this.viewDistance).map(([x, z]) => new Vec3((botX + x) * 16, 0, (botZ + z) * 16))
this.lastPos.update(pos)
await this._loadChunks(positions)
await this._loadChunks(positions, pos)
}
async _loadChunks (positions: Vec3[], sliceSize = 5) {
chunkProgress () {
if (this.panicTimeout) clearTimeout(this.panicTimeout)
if (this.chunkReceiveTimes.length >= 5) {
const avgReceiveTime = this.chunkReceiveTimes.reduce((a, b) => a + b, 0) / this.chunkReceiveTimes.length
this.lastChunkReceiveTimeAvg = avgReceiveTime
const timeoutDelay = avgReceiveTime * 2 + 1000 // 2x average + 1 second
// Clear any existing timeout
if (this.panicTimeout) clearTimeout(this.panicTimeout)
// Set new timeout for panic reload
this.panicTimeout = setTimeout(() => {
if (!this.gotPanicLastTime && this.inLoading) {
console.warn('Chunk loading seems stuck, triggering panic reload')
this.gotPanicLastTime = true
this.panicChunksReload()
}
}, timeoutDelay)
}
}
async _loadChunks (positions: Vec3[], centerPos: Vec3) {
this.spiralNumber++
const { spiralNumber } = this
// stop loading previous chunks
for (const pos of Object.keys(this.waitingSpiralChunksLoad)) {
this.waitingSpiralChunksLoad[pos](false)
delete this.waitingSpiralChunksLoad[pos]
}
const promises = [] as Array<Promise<void>>
let continueLoading = true
this.inLoading = true
await delayedIterator(positions, this.addWaitTime, async (pos) => {
const promise = (async () => {
if (!continueLoading || this.loadedChunks[`${pos.x},${pos.z}`]) return
if (!continueLoading || this.loadedChunks[`${pos.x},${pos.z}`]) return
if (!this.world.getColumnAt(pos)) {
continueLoading = await new Promise<boolean>(resolve => {
this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`] = resolve
})
}
if (!continueLoading) return
await this.loadChunk(pos)
})()
promises.push(promise)
// Wait for chunk to be available from server
if (!this.world.getColumnAt(pos)) {
continueLoading = await new Promise<boolean>(resolve => {
this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`] = resolve
})
}
if (!continueLoading) return
await this.loadChunk(pos, undefined, `spiral ${spiralNumber} from ${centerPos.x},${centerPos.z}`)
this.chunkProgress()
})
await Promise.all(promises)
if (this.panicTimeout) clearTimeout(this.panicTimeout)
this.inLoading = false
this.gotPanicLastTime = false
this.chunkReceiveTimes = []
this.lastChunkReceiveTime = 0
}
readdDebug () {
@ -319,8 +363,37 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
delete this.debugChunksInfo[`${pos.x},${pos.z}`]
}
lastBiomeId: number | null = null
udpateBiome (pos: Vec3) {
try {
const biomeId = this.world.getBiome(pos)
if (biomeId !== this.lastBiomeId) {
this.lastBiomeId = biomeId
const biomeData = loadedData.biomes[biomeId]
if (biomeData) {
this.emitter.emit('biomeUpdate', {
biome: biomeData
})
} else {
// unknown biome
this.emitter.emit('biomeReset')
}
}
} catch (e) {
console.error('error updating biome', e)
}
}
lastPosCheck: Vec3 | null = null
async updatePosition (pos: Vec3, force = false) {
if (!this.allowPositionUpdate) return
const posFloored = pos.floored()
if (!force && this.lastPosCheck && this.lastPosCheck.equals(posFloored)) return
this.lastPosCheck = posFloored
this.udpateBiome(pos)
const [lastX, lastZ] = chunkPos(this.lastPos)
const [botX, botZ] = chunkPos(pos)
if (lastX !== botX || lastZ !== botZ || force) {
@ -338,7 +411,6 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
chunksToUnload.push(p)
}
}
console.log('unloading', chunksToUnload.length, 'total now', Object.keys(this.loadedChunks).length)
for (const p of chunksToUnload) {
this.unloadChunk(p)
}
@ -350,7 +422,7 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
return undefined!
}).filter(a => !!a)
this.lastPos.update(pos)
void this._loadChunks(positions)
void this._loadChunks(positions, pos)
} else {
this.emitter.emit('chunkPosUpdate', { pos }) // todo-low
this.lastPos.update(pos)

View file

@ -32,31 +32,45 @@ const toMajorVersion = version => {
export const worldCleanup = buildCleanupDecorator('resetWorld')
export const defaultWorldRendererConfig = {
// Debug settings
showChunkBorders: false,
enableDebugOverlay: false,
// Performance settings
mesherWorkers: 4,
isPlayground: false,
renderEars: true,
skinTexturesProxy: undefined as string | undefined,
// game renderer setting actually
showHand: false,
viewBobbing: false,
extraBlockRenderers: true,
clipWorldBelowY: undefined as number | undefined,
addChunksBatchWaitTime: 200,
_experimentalSmoothChunkLoading: true,
_renderByChunks: false,
// Rendering engine settings
dayCycle: true,
smoothLighting: true,
enableLighting: true,
starfield: true,
addChunksBatchWaitTime: 200,
defaultSkybox: true,
renderEntities: true,
extraBlockRenderers: true,
foreground: true,
fov: 75,
volume: 1,
// Camera visual related settings
showHand: false,
viewBobbing: false,
renderEars: true,
highlightBlockColor: 'blue',
// Player models
fetchPlayerSkins: true,
skinTexturesProxy: undefined as string | undefined,
// VR settings
vrSupport: true,
vrPageGameRendering: true,
renderEntities: true,
fov: 75,
fetchPlayerSkins: true,
highlightBlockColor: 'blue',
foreground: true,
enableDebugOverlay: false,
_experimentalSmoothChunkLoading: true,
_renderByChunks: false,
volume: 1
// World settings
clipWorldBelowY: undefined as number | undefined,
isPlayground: false
}
export type WorldRendererConfig = typeof defaultWorldRendererConfig
@ -123,7 +137,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
handleResize = () => { }
highestBlocksByChunks = new Map<string, { [chunkKey: string]: HighestBlockInfo }>()
highestBlocksBySections = new Map<string, { [sectionKey: string]: HighestBlockInfo }>()
blockEntities = {}
workersProcessAverageTime = 0
@ -389,8 +402,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
this.logWorkerWork(() => `-> ${data.workerIndex} geometry ${data.key} ${JSON.stringify({ dataSize: JSON.stringify(data).length })}`)
this.geometryReceiveCount[data.workerIndex] ??= 0
this.geometryReceiveCount[data.workerIndex]++
const { geometry } = data
this.highestBlocksBySections[data.key] = geometry.highestBlocks
const chunkCoords = data.key.split(',').map(Number)
this.lastChunkDistance = Math.max(...this.getDistance(new Vec3(chunkCoords[0], 0, chunkCoords[2])))
}
@ -499,6 +510,10 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
timeUpdated? (newTime: number): void
biomeUpdated? (biome: any): void
biomeReset? (): void
updateViewerPosition (pos: Vec3) {
this.viewerChunkPosition = pos
for (const [key, value] of Object.entries(this.loadedChunks)) {
@ -688,7 +703,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
for (let y = this.worldSizeParams.minY; y < this.worldSizeParams.worldHeight; y += 16) {
this.setSectionDirty(new Vec3(x, y, z), false)
delete this.finishedSections[`${x},${y},${z}`]
this.highestBlocksBySections.delete(`${x},${y},${z}`)
}
this.highestBlocksByChunks.delete(`${x},${z}`)
@ -821,12 +835,9 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
})
worldEmitter.on('time', (timeOfDay) => {
if (!this.worldRendererConfig.dayCycle) return
this.timeUpdated?.(timeOfDay)
if (timeOfDay < 0 || timeOfDay > 24_000) {
throw new Error('Invalid time of day. It should be between 0 and 24000.')
}
this.timeOfTheDay = timeOfDay
// if (this.worldRendererConfig.skyLight === skyLight) return
@ -835,6 +846,14 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
// (this).rerenderAllChunks?.()
// }
})
worldEmitter.on('biomeUpdate', ({ biome }) => {
this.biomeUpdated?.(biome)
})
worldEmitter.on('biomeReset', () => {
this.biomeReset?.()
})
}
setBlockStateIdInner (pos: Vec3, stateId: number | undefined, needAoRecalculation = true) {

View file

@ -1,5 +1,5 @@
import { fromFormattedString, render, RenderNode, TextComponent } from '@xmcl/text-component'
import type { ChatMessage } from 'prismarine-chat'
import { createCanvas } from '../lib/utils'
type SignBlockEntity = {
Color?: string
@ -32,29 +32,40 @@ const parseSafe = (text: string, task: string) => {
}
}
export const renderSign = (blockEntity: SignBlockEntity, PrismarineChat: typeof ChatMessage, ctxHook = (ctx) => { }) => {
const LEGACY_COLORS = {
black: '#000000',
dark_blue: '#0000AA',
dark_green: '#00AA00',
dark_aqua: '#00AAAA',
dark_red: '#AA0000',
dark_purple: '#AA00AA',
gold: '#FFAA00',
gray: '#AAAAAA',
dark_gray: '#555555',
blue: '#5555FF',
green: '#55FF55',
aqua: '#55FFFF',
red: '#FF5555',
light_purple: '#FF55FF',
yellow: '#FFFF55',
white: '#FFFFFF',
}
export const renderSign = (
blockEntity: SignBlockEntity,
isHanging: boolean,
PrismarineChat: typeof ChatMessage,
ctxHook = (ctx) => { },
canvasCreator = (width, height): OffscreenCanvas => { return createCanvas(width, height) }
) => {
// todo don't use texture rendering, investigate the font rendering when possible
// or increase factor when needed
const factor = 40
const fontSize = 1.6 * factor
const signboardY = [16, 9]
const heightOffset = signboardY[0] - signboardY[1]
const heightScalar = heightOffset / 16
let canvas: HTMLCanvasElement | undefined
let _ctx: CanvasRenderingContext2D | null = null
const getCtx = () => {
if (_ctx) return _ctx
canvas = document.createElement('canvas')
canvas.width = 16 * factor
canvas.height = heightOffset * factor
_ctx = canvas.getContext('2d')!
_ctx.imageSmoothingEnabled = false
ctxHook(_ctx)
return _ctx
}
// todo the text should be clipped based on it's render width (needs investigate)
const texts = 'front_text' in blockEntity ? /* > 1.20 */ blockEntity.front_text.messages : [
blockEntity.Text1,
@ -62,78 +73,144 @@ export const renderSign = (blockEntity: SignBlockEntity, PrismarineChat: typeof
blockEntity.Text3,
blockEntity.Text4
]
if (!texts.some((text) => text !== 'null')) {
return undefined
}
const canvas = canvasCreator(16 * factor, heightOffset * factor)
const _ctx = canvas.getContext('2d')!
ctxHook(_ctx)
const defaultColor = ('front_text' in blockEntity ? blockEntity.front_text.color : blockEntity.Color) || 'black'
for (const [lineNum, text] of texts.slice(0, 4).entries()) {
// todo: in pre flatenning it seems the format was not json
if (text === 'null') continue
const parsed = text?.startsWith('{') || text?.startsWith('"') ? parseSafe(text ?? '""', 'sign text') : text
if (!parsed || (typeof parsed !== 'object' && typeof parsed !== 'string')) continue
// todo fix type
const message = typeof parsed === 'string' ? fromFormattedString(parsed) : new PrismarineChat(parsed) as never
const patchExtra = ({ extra }: TextComponent) => {
if (!extra) return
for (const child of extra) {
if (child.color) {
child.color = child.color === 'dark_green' ? child.color.toUpperCase() : child.color.toLowerCase()
}
patchExtra(child)
}
}
patchExtra(message)
const rendered = render(message)
const toRenderCanvas: Array<{
fontStyle: string
fillStyle: string
underlineStyle: boolean
strikeStyle: boolean
text: string
}> = []
let plainText = ''
// todo the text should be clipped based on it's render width (needs investigate)
const MAX_LENGTH = 50 // avoid abusing the signboard
const renderText = (node: RenderNode) => {
const { component } = node
let { text } = component
if (plainText.length + text.length > MAX_LENGTH) {
text = text.slice(0, MAX_LENGTH - plainText.length)
if (!text) return false
}
plainText += text
toRenderCanvas.push({
fontStyle: `${component.bold ? 'bold' : ''} ${component.italic ? 'italic' : ''}`,
fillStyle: node.style['color'] || defaultColor,
underlineStyle: component.underlined ?? false,
strikeStyle: component.strikethrough ?? false,
text
})
for (const child of node.children) {
const stop = renderText(child) === false
if (stop) return false
}
}
renderText(rendered)
// skip rendering empty lines (and possible signs)
if (!plainText.trim()) continue
const ctx = getCtx()
const fontSize = 1.6 * factor
ctx.font = `${fontSize}px mojangles`
const textWidth = ctx.measureText(plainText).width
let renderedWidth = 0
for (const { fillStyle, fontStyle, strikeStyle, text, underlineStyle } of toRenderCanvas) {
// todo strikeStyle, underlineStyle
ctx.fillStyle = fillStyle
ctx.font = `${fontStyle} ${fontSize}px mojangles`
ctx.fillText(text, (canvas!.width - textWidth) / 2 + renderedWidth, fontSize * (lineNum + 1))
renderedWidth += ctx.measureText(text).width // todo isn't the font is monospace?
}
renderComponent(text, PrismarineChat, canvas, fontSize, defaultColor, fontSize * (lineNum + 1) + (isHanging ? 0 : -8))
}
// ctx.fillStyle = 'red'
// ctx.fillRect(0, 0, canvas.width, canvas.height)
return canvas
}
export const renderComponent = (
text: JsonEncodedType | string | undefined,
PrismarineChat: typeof ChatMessage,
canvas: OffscreenCanvas,
fontSize: number,
defaultColor: string,
offset = 0
) => {
// todo: in pre flatenning it seems the format was not json
const parsed = typeof text === 'string' && (text?.startsWith('{') || text?.startsWith('"')) ? parseSafe(text ?? '""', 'sign text') : text
if (!parsed || (typeof parsed !== 'object' && typeof parsed !== 'string')) return
// todo fix type
const ctx = canvas.getContext('2d')!
if (!ctx) throw new Error('Could not get 2d context')
ctx.imageSmoothingEnabled = false
ctx.font = `${fontSize}px mojangles`
type Formatting = {
color: string | undefined
underlined: boolean | undefined
strikethrough: boolean | undefined
bold: boolean | undefined
italic: boolean | undefined
}
type Message = ChatMessage & Formatting & { text: string }
const message = new PrismarineChat(parsed) as Message
const toRenderCanvas: Array<{
fontStyle: string
fillStyle: string
underlineStyle: boolean
strikeStyle: boolean
offset: number
text: string
}> = []
let visibleFormatting = false
let plainText = ''
let textOffset = offset
const textWidths: number[] = []
const renderText = (component: Message, parentFormatting?: Formatting | undefined) => {
const { text } = component
const formatting = {
color: component.color ?? parentFormatting?.color,
underlined: component.underlined ?? parentFormatting?.underlined,
strikethrough: component.strikethrough ?? parentFormatting?.strikethrough,
bold: component.bold ?? parentFormatting?.bold,
italic: component.italic ?? parentFormatting?.italic
}
visibleFormatting = visibleFormatting || formatting.underlined || formatting.strikethrough || false
if (text?.includes('\n')) {
for (const line of text.split('\n')) {
addTextPart(line, formatting)
textOffset += fontSize
plainText = ''
}
} else if (text) {
addTextPart(text, formatting)
}
if (component.extra) {
for (const child of component.extra) {
renderText(child as Message, formatting)
}
}
}
const addTextPart = (text: string, formatting: Formatting) => {
plainText += text
textWidths[textOffset] = ctx.measureText(plainText).width
let color = formatting.color ?? defaultColor
if (!color.startsWith('#')) {
color = LEGACY_COLORS[color.toLowerCase()] || color
}
toRenderCanvas.push({
fontStyle: `${formatting.bold ? 'bold' : ''} ${formatting.italic ? 'italic' : ''}`,
fillStyle: color,
underlineStyle: formatting.underlined ?? false,
strikeStyle: formatting.strikethrough ?? false,
offset: textOffset,
text
})
}
renderText(message)
// skip rendering empty lines
if (!visibleFormatting && !message.toString().trim()) return
let renderedWidth = 0
let previousOffsetY = 0
for (const { fillStyle, fontStyle, underlineStyle, strikeStyle, offset: offsetY, text } of toRenderCanvas) {
if (previousOffsetY !== offsetY) {
renderedWidth = 0
}
previousOffsetY = offsetY
ctx.fillStyle = fillStyle
ctx.textRendering = 'optimizeLegibility'
ctx.font = `${fontStyle} ${fontSize}px mojangles`
const textWidth = textWidths[offsetY] ?? ctx.measureText(text).width
const offsetX = (canvas.width - textWidth) / 2 + renderedWidth
ctx.fillText(text, offsetX, offsetY)
if (strikeStyle) {
ctx.lineWidth = fontSize / 8
ctx.strokeStyle = fillStyle
ctx.beginPath()
ctx.moveTo(offsetX, offsetY - ctx.lineWidth * 2.5)
ctx.lineTo(offsetX + ctx.measureText(text).width, offsetY - ctx.lineWidth * 2.5)
ctx.stroke()
}
if (underlineStyle) {
ctx.lineWidth = fontSize / 8
ctx.strokeStyle = fillStyle
ctx.beginPath()
ctx.moveTo(offsetX, offsetY + ctx.lineWidth)
ctx.lineTo(offsetX + ctx.measureText(text).width, offsetY + ctx.lineWidth)
ctx.stroke()
}
renderedWidth += ctx.measureText(text).width
}
}

View file

@ -21,9 +21,14 @@ const blockEntity = {
await document.fonts.load('1em mojangles')
const canvas = renderSign(blockEntity, PrismarineChat, (ctx) => {
const canvas = renderSign(blockEntity, false, PrismarineChat, (ctx) => {
ctx.drawImage(img, 0, 0, ctx.canvas.width, ctx.canvas.height)
})
}, (width, height) => {
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
return canvas as unknown as OffscreenCanvas
}) as unknown as HTMLCanvasElement
if (canvas) {
canvas.style.imageRendering = 'pixelated'

View file

@ -22,7 +22,7 @@ global.document = {
const render = (entity) => {
ctxTexts = []
renderSign(entity, PrismarineChat)
renderSign(entity, true, PrismarineChat)
return ctxTexts.map(({ text, y }) => [y / 64, text])
}
@ -37,10 +37,6 @@ test('sign renderer', () => {
} as any
expect(render(blockEntity)).toMatchInlineSnapshot(`
[
[
1,
"",
],
[
1,
"Minecraft ",

View file

@ -80,8 +80,12 @@ export class CameraShake {
camera.setRotationFromQuaternion(yawQuat)
} else {
// For regular camera, apply all rotations
const pitchQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), this.basePitch)
const yawQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), this.baseYaw)
// Add tiny offsets to prevent z-fighting at ideal angles (90, 180, 270 degrees)
const pitchOffset = this.addAntiZfightingOffset(this.basePitch)
const yawOffset = this.addAntiZfightingOffset(this.baseYaw)
const pitchQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), pitchOffset)
const yawQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), yawOffset)
const rollQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), THREE.MathUtils.degToRad(this.rollAngle))
// Combine rotations in the correct order: pitch -> yaw -> roll
const finalQuat = yawQuat.multiply(pitchQuat).multiply(rollQuat)
@ -96,4 +100,21 @@ export class CameraShake {
private easeInOut (t: number): number {
return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2
}
private addAntiZfightingOffset (angle: number): number {
const offset = 0.001 // Very small offset in radians (about 0.057 degrees)
// Check if the angle is close to ideal angles (0, π/2, π, 3π/2)
const normalizedAngle = ((angle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)
const tolerance = 0.01 // Tolerance for considering an angle "ideal"
if (Math.abs(normalizedAngle) < tolerance ||
Math.abs(normalizedAngle - Math.PI / 2) < tolerance ||
Math.abs(normalizedAngle - Math.PI) < tolerance ||
Math.abs(normalizedAngle - 3 * Math.PI / 2) < tolerance) {
return angle + offset
}
return angle
}
}

View file

@ -61,8 +61,9 @@ export class DocumentRenderer {
this.previousCanvasWidth = this.canvas.width
this.previousCanvasHeight = this.canvas.height
const supportsWebGL2 = 'WebGL2RenderingContext' in window
// Only initialize stats and DOM-related features in main thread
if (!externalCanvas) {
if (!externalCanvas && supportsWebGL2) {
this.stats = new TopRightStats(this.canvas as HTMLCanvasElement, this.config.statsVisible)
this.setupFpsTracking()
}

View file

@ -1,11 +1,10 @@
//@ts-check
import EventEmitter from 'events'
import { UnionToIntersection } from 'type-fest'
import nbt from 'prismarine-nbt'
import * as TWEEN from '@tweenjs/tween.js'
import * as THREE from 'three'
import { PlayerObject, PlayerAnimation } from 'skinview3d'
import { loadSkinToCanvas, loadEarsToCanvasFromSkin, inferModelType, loadCapeToCanvas, loadImage } from 'skinview-utils'
import { PlayerAnimation, PlayerObject } from 'skinview3d'
import { inferModelType, loadCapeToCanvas, loadEarsToCanvasFromSkin } from 'skinview-utils'
// todo replace with url
import { degreesToRadians } from '@nxg-org/mineflayer-tracker/lib/mathUtils'
import { NameTagObject } from 'skinview3d/libs/nametag'
@ -13,29 +12,27 @@ import { flat, fromFormattedString } from '@xmcl/text-component'
import mojangson from 'mojangson'
import { snakeCase } from 'change-case'
import { Item } from 'prismarine-item'
import { BlockModel } from 'mc-assets'
import { isEntityAttackable } from 'mineflayer-mouse/dist/attackableEntity'
import { Vec3 } from 'vec3'
import { Team } from 'mineflayer'
import PrismarineChatLoader from 'prismarine-chat'
import { EntityMetadataVersions } from '../../../src/mcDataTypes'
import { ItemSpecificContextProperties } from '../lib/basePlayerState'
import { loadSkinImage, loadSkinFromUsername, stevePngUrl, steveTexture, createCanvas } from '../lib/utils/skins'
import { loadTexture } from '../lib/utils'
import { loadSkinFromUsername, loadSkinImage, stevePngUrl } from '../lib/utils/skins'
import { renderComponent } from '../sign-renderer'
import { createCanvas } from '../lib/utils'
import { PlayerObjectType } from '../lib/createPlayerObject'
import { getBlockMeshFromModel } from './holdingBlock'
import { createItemMesh } from './itemMesh'
import * as Entity from './entity/EntityMesh'
import { getMesh } from './entity/EntityMesh'
import { WalkingGeneralSwing } from './entity/animations'
import { disposeObject } from './threeJsUtils'
import { armorModel, elytraTexture, armorTextures } from './entity/armorModels'
import { disposeObject, loadTexture, loadThreeJsTextureFromUrl } from './threeJsUtils'
import { armorModel, armorTextures, elytraTexture } from './entity/armorModels'
import { WorldRendererThree } from './worldrendererThree'
export const TWEEN_DURATION = 120
export const steveTexture = loadThreeJsTextureFromUrl(stevePngUrl)
type PlayerObjectType = PlayerObject & {
animation?: PlayerAnimation
realPlayerUuid: string
realUsername: string
}
export const TWEEN_DURATION = 120
function convert2sComplementToHex (complement: number) {
if (complement < 0) {
@ -96,8 +93,11 @@ function getUsernameTexture ({
username,
nameTagBackgroundColor = 'rgba(0, 0, 0, 0.3)',
nameTagTextOpacity = 255
}: any, { fontFamily = 'sans-serif' }: any) {
}: any, { fontFamily = 'mojangles' }: any, version: string) {
const canvas = createCanvas(64, 64)
const PrismarineChat = PrismarineChatLoader(version)
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get 2d context')
@ -105,38 +105,39 @@ function getUsernameTexture ({
const padding = 5
ctx.font = `${fontSize}px ${fontFamily}`
const lines = String(username).split('\n')
const plainLines = String(typeof username === 'string' ? username : new PrismarineChat(username).toString()).split('\n')
let textWidth = 0
for (const line of lines) {
for (const line of plainLines) {
const width = ctx.measureText(line).width + padding * 2
if (width > textWidth) textWidth = width
}
canvas.width = textWidth
canvas.height = (fontSize + padding) * lines.length
canvas.height = (fontSize + padding) * plainLines.length
ctx.fillStyle = nameTagBackgroundColor
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.font = `${fontSize}px ${fontFamily}`
ctx.fillStyle = `rgba(255, 255, 255, ${nameTagTextOpacity / 255})`
let i = 0
for (const line of lines) {
i++
ctx.fillText(line, (textWidth - ctx.measureText(line).width) / 2, -padding + fontSize * i)
}
ctx.globalAlpha = nameTagTextOpacity / 255
renderComponent(username, PrismarineChat, canvas, fontSize, 'white', -padding + fontSize)
ctx.globalAlpha = 1
return canvas
}
const addNametag = (entity, options, mesh) => {
const addNametag = (entity, options: { fontFamily: string }, mesh, version: string) => {
for (const c of mesh.children) {
if (c.name === 'nametag') {
c.removeFromParent()
}
}
if (entity.username !== undefined) {
if (mesh.children.some(c => c.name === 'nametag')) return // todo update
const canvas = getUsernameTexture(entity, options)
const canvas = getUsernameTexture(entity, options, version)
const tex = new THREE.Texture(canvas)
tex.needsUpdate = true
let nameTag
let nameTag: THREE.Object3D
if (entity.nameTagFixed) {
const geometry = new THREE.PlaneGeometry()
const material = new THREE.MeshBasicMaterial({ map: tex })
@ -166,6 +167,7 @@ const addNametag = (entity, options, mesh) => {
nameTag.name = 'nametag'
mesh.add(nameTag)
return nameTag
}
}
@ -174,7 +176,7 @@ const nametags = {}
const isFirstUpperCase = (str) => str.charAt(0) === str.charAt(0).toUpperCase()
function getEntityMesh (entity: import('prismarine-entity').Entity & { delete?: any; pos?: any; name?: any }, world: WorldRendererThree | undefined, options: { fontFamily: string }, overrides) {
function getEntityMesh (entity: import('prismarine-entity').Entity & { delete?: any; pos?: any; name?: any }, world: WorldRendererThree, options: { fontFamily: string }, overrides) {
if (entity.name) {
try {
// https://github.com/PrismarineJS/prismarine-viewer/pull/410
@ -182,7 +184,7 @@ function getEntityMesh (entity: import('prismarine-entity').Entity & { delete?:
const e = new Entity.EntityMesh('1.16.4', entityName, world, overrides)
if (e.mesh) {
addNametag(entity, options, e.mesh)
addNametag(entity, options, e.mesh, world.version)
return e.mesh
}
} catch (err) {
@ -200,7 +202,7 @@ function getEntityMesh (entity: import('prismarine-entity').Entity & { delete?:
addNametag({
username: entity.name,
height: entity.height,
}, options, cube)
}, options, cube, world.version)
}
return cube
}
@ -489,6 +491,10 @@ export class Entities {
// todo true/undefined doesnt reset the skin to the default one
// eslint-disable-next-line max-params
async updatePlayerSkin (entityId: string | number, username: string | undefined, uuidCache: string | undefined, skinUrl: string | true, capeUrl: string | true | undefined = undefined) {
const isCustomSkin = skinUrl !== stevePngUrl
if (isCustomSkin) {
this.loadedSkinEntityIds.add(String(entityId))
}
if (uuidCache) {
if (typeof skinUrl === 'string' || typeof capeUrl === 'string') this.uuidPerSkinUrlsCache[uuidCache] = {}
if (typeof skinUrl === 'string') this.uuidPerSkinUrlsCache[uuidCache].skinUrl = skinUrl
@ -712,7 +718,7 @@ export class Entities {
return typeof component === 'string' ? component : component.text ?? ''
}
getItemMesh (item, specificProps: ItemSpecificContextProperties, previousModel?: string) {
getItemMesh (item, specificProps: ItemSpecificContextProperties, faceCamera = false, previousModel?: string) {
if (!item.nbt && item.nbtData) item.nbt = item.nbtData
const textureUv = this.worldRenderer.getItemRenderData(item, specificProps)
if (previousModel && previousModel === textureUv?.modelName) return undefined
@ -731,60 +737,41 @@ export class Entities {
return {
mesh: outerGroup,
isBlock: true,
itemsTexture: null,
itemsTextureFlipped: null,
modelName: textureUv.modelName,
}
}
// TODO: Render proper model (especially for blocks) instead of flat texture
// Render proper 3D model for items
if (textureUv) {
const textureThree = textureUv.renderInfo?.texture === 'blocks' ? this.worldRenderer.material.map! : this.worldRenderer.itemsTexture
// todo use geometry buffer uv instead!
const { u, v, su, sv } = textureUv
const size = undefined
const itemsTexture = textureThree.clone()
itemsTexture.flipY = true
const sizeY = (sv ?? size)!
const sizeX = (su ?? size)!
itemsTexture.offset.set(u, 1 - v - sizeY)
itemsTexture.repeat.set(sizeX, sizeY)
itemsTexture.needsUpdate = true
itemsTexture.magFilter = THREE.NearestFilter
itemsTexture.minFilter = THREE.NearestFilter
const itemsTextureFlipped = itemsTexture.clone()
itemsTextureFlipped.repeat.x *= -1
itemsTextureFlipped.needsUpdate = true
itemsTextureFlipped.offset.set(u + (sizeX), 1 - v - sizeY)
const material = new THREE.MeshStandardMaterial({
map: itemsTexture,
transparent: true,
alphaTest: 0.1,
const sizeX = su ?? 1 // su is actually width
const sizeY = sv ?? 1 // sv is actually height
// Use the new unified item mesh function
const result = createItemMesh(textureThree, {
u,
v,
sizeX,
sizeY
}, {
faceCamera,
use3D: !faceCamera, // Only use 3D for non-camera-facing items
})
const materialFlipped = new THREE.MeshStandardMaterial({
map: itemsTextureFlipped,
transparent: true,
alphaTest: 0.1,
})
const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 0), [
// top left and right bottom are black box materials others are transparent
new THREE.MeshBasicMaterial({ color: 0x00_00_00 }), new THREE.MeshBasicMaterial({ color: 0x00_00_00 }),
new THREE.MeshBasicMaterial({ color: 0x00_00_00 }), new THREE.MeshBasicMaterial({ color: 0x00_00_00 }),
material, materialFlipped,
])
let SCALE = 1
if (specificProps['minecraft:display_context'] === 'ground') {
SCALE = 0.5
} else if (specificProps['minecraft:display_context'] === 'thirdperson') {
SCALE = 6
}
mesh.scale.set(SCALE, SCALE, SCALE)
result.mesh.scale.set(SCALE, SCALE, SCALE)
return {
mesh,
mesh: result.mesh,
isBlock: false,
itemsTexture,
itemsTextureFlipped,
modelName: textureUv.modelName,
cleanup: result.cleanup
}
}
}
@ -800,8 +787,6 @@ export class Entities {
}
update (entity: SceneEntity['originalEntity'], overrides) {
const justAdded = !this.entities[entity.id]
const isPlayerModel = entity.name === 'player'
if (entity.name === 'zombie_villager' || entity.name === 'husk') {
overrides.texture = `textures/1.16.4/entity/${entity.name === 'zombie_villager' ? 'zombie_villager/zombie_villager.png' : `zombie/${entity.name}.png`}`
@ -812,6 +797,7 @@ export class Entities {
}
// this can be undefined in case where packet entity_destroy was sent twice (so it was already deleted)
let e = this.entities[entity.id]
const justAdded = !e
if (entity.delete) {
if (!e) return
@ -831,21 +817,23 @@ export class Entities {
if (e === undefined) {
const group = new THREE.Group() as unknown as SceneEntity
group.originalEntity = entity
if (entity.name === 'item' || entity.name === 'tnt' || entity.name === 'falling_block') {
const item = entity.name === 'tnt'
? { name: 'tnt' }
if (entity.name === 'item' || entity.name === 'tnt' || entity.name === 'falling_block' || entity.name === 'snowball'
|| entity.name === 'egg' || entity.name === 'ender_pearl' || entity.name === 'experience_bottle'
|| entity.name === 'splash_potion' || entity.name === 'lingering_potion') {
const item = entity.name === 'tnt' || entity.type === 'projectile'
? { name: entity.name }
: entity.name === 'falling_block'
? { blockState: entity['objectData'] }
: entity.metadata?.find((m: any) => typeof m === 'object' && m?.itemCount)
if (item) {
const object = this.getItemMesh(item, {
'minecraft:display_context': 'ground',
})
}, entity.type === 'projectile')
if (object) {
mesh = object.mesh
if (entity.name === 'item') {
if (entity.name === 'item' || entity.type === 'projectile') {
mesh.scale.set(0.5, 0.5, 0.5)
mesh.position.set(0, 0.2, 0)
mesh.position.set(0, entity.name === 'item' ? 0.2 : 0.1, 0)
} else {
mesh.scale.set(2, 2, 2)
mesh.position.set(0, 0.5, 0)
@ -853,8 +841,8 @@ export class Entities {
// set faces
// mesh.position.set(targetPos.x + 0.5 + 2, targetPos.y + 0.5, targetPos.z + 0.5)
// viewer.scene.add(mesh)
const clock = new THREE.Clock()
if (entity.name === 'item') {
const clock = new THREE.Clock()
mesh.onBeforeRender = () => {
const delta = clock.getDelta()
mesh!.rotation.y += delta
@ -883,8 +871,9 @@ export class Entities {
group.additionalCleanup = () => {
// important: avoid texture memory leak and gpu slowdown
object.itemsTexture?.dispose()
object.itemsTextureFlipped?.dispose()
if (object.cleanup) {
object.cleanup()
}
}
}
}
@ -895,20 +884,14 @@ export class Entities {
mesh = wrapper
if (entity.username) {
// todo proper colors
const nameTag = new NameTagObject(fromFormattedString(entity.username).text, {
font: `48px ${this.entitiesOptions.fontFamily}`,
})
nameTag.position.y = playerObject.position.y + playerObject.scale.y * 16 + 3
nameTag.renderOrder = 1000
nameTag.name = 'nametag'
//@ts-expect-error
wrapper.add(nameTag)
const nametag = addNametag(entity, { fontFamily: 'mojangles' }, wrapper, this.worldRenderer.version)
if (nametag) {
nametag.position.y = playerObject.position.y + playerObject.scale.y * 16 + 3
nametag.scale.multiplyScalar(12)
}
}
} else {
mesh = getEntityMesh(entity, this.worldRenderer, this.entitiesOptions, overrides)
mesh = getEntityMesh(entity, this.worldRenderer, this.entitiesOptions, { ...overrides, customModel: entity['customModel'] })
}
if (!mesh) return
mesh.name = 'mesh'
@ -967,21 +950,22 @@ export class Entities {
// 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)
if (entity.name !== 'player' && displayText) {
if (entity.name !== 'player' && displayTextRaw) {
const nameTagFixed = textDisplayMeta && (textDisplayMeta.billboard_render_constraints === 'fixed' || !textDisplayMeta.billboard_render_constraints)
const nameTagBackgroundColor = textDisplayMeta && toRgba(textDisplayMeta.background_color)
const nameTagBackgroundColor = (textDisplayMeta && (parseInt(textDisplayMeta.style_flags, 10) & 0x04) === 0) ? toRgba(textDisplayMeta.background_color) : undefined
let nameTagTextOpacity: any
if (textDisplayMeta?.text_opacity) {
const rawOpacity = parseInt(textDisplayMeta?.text_opacity, 10)
nameTagTextOpacity = rawOpacity > 0 ? rawOpacity : 256 - rawOpacity
}
addNametag(
{ ...entity, username: displayText, nameTagBackgroundColor, nameTagTextOpacity, nameTagFixed,
{ ...entity, username: typeof displayTextRaw === 'string' ? mojangson.simplify(mojangson.parse(displayTextRaw)) : nbt.simplify(displayTextRaw),
nameTagBackgroundColor, nameTagTextOpacity, nameTagFixed,
nameTagScale: textDisplayMeta?.scale, nameTagTranslation: textDisplayMeta && (textDisplayMeta.translation || new THREE.Vector3(0, 0, 0)),
nameTagRotationLeft: toQuaternion(textDisplayMeta?.left_rotation), nameTagRotationRight: toQuaternion(textDisplayMeta?.right_rotation) },
this.entitiesOptions,
mesh
mesh,
this.worldRenderer.version
)
}
@ -1163,8 +1147,7 @@ export class Entities {
const cameraPos = this.worldRenderer.cameraObject.position
const distance = mesh.position.distanceTo(cameraPos)
if (distance < MAX_DISTANCE_SKIN_LOAD && distance < (this.worldRenderer.viewDistance * 16)) {
if (this.loadedSkinEntityIds.has(entityId)) return
this.loadedSkinEntityIds.add(entityId)
if (this.loadedSkinEntityIds.has(String(entityId))) return
void this.updatePlayerSkin(entityId, mesh.playerObject.realUsername, mesh.playerObject.realPlayerUuid, true, true)
}
}
@ -1279,8 +1262,9 @@ export class Entities {
const group = new THREE.Object3D()
group['additionalCleanup'] = () => {
// important: avoid texture memory leak and gpu slowdown
itemObject.itemsTexture?.dispose()
itemObject.itemsTextureFlipped?.dispose()
if (itemObject.cleanup) {
itemObject.cleanup()
}
}
const itemMesh = itemObject.mesh
group.rotation.z = -Math.PI / 16

View file

@ -6,7 +6,7 @@ import ocelotPng from '../../../../node_modules/mc-assets/dist/other-textures/la
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 { loadTexture } from '../../lib/utils'
import { loadTexture } from '../threeJsUtils'
import { WorldRendererThree } from '../worldrendererThree'
import entities from './entities.json'
import { externalModels } from './objModels'

View file

@ -44,6 +44,12 @@ const getBackendMethods = (worldRenderer: WorldRendererThree) => {
shakeFromDamage: worldRenderer.cameraShake.shakeFromDamage.bind(worldRenderer.cameraShake),
onPageInteraction: worldRenderer.media.onPageInteraction.bind(worldRenderer.media),
downloadMesherLog: worldRenderer.downloadMesherLog.bind(worldRenderer),
addWaypoint: worldRenderer.waypoints.addWaypoint.bind(worldRenderer.waypoints),
removeWaypoint: worldRenderer.waypoints.removeWaypoint.bind(worldRenderer.waypoints),
// New method for updating skybox
setSkyboxImage: worldRenderer.skyboxRenderer.setSkyboxImage.bind(worldRenderer.skyboxRenderer)
}
}

View file

@ -1,6 +1,7 @@
import * as THREE from 'three'
import { loadSkinToCanvas } from 'skinview-utils'
import { loadSkinFromUsername, loadSkinImage, steveTexture } from './utils/skins'
import { loadSkinFromUsername, loadSkinImage } from '../lib/utils/skins'
import { steveTexture } from './entities'
export const getMyHand = async (image?: string, userName?: string) => {
let newMap: THREE.Texture

View file

@ -4,12 +4,12 @@ import PrismarineItem from 'prismarine-item'
import worldBlockProvider, { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider'
import { BlockModel } from 'mc-assets'
import { getThreeBlockModelGroup, renderBlockThree, setBlockPosition } from '../lib/mesher/standaloneRenderer'
import { getMyHand } from '../lib/hand'
import { MovementState, PlayerStateRenderer } from '../lib/basePlayerState'
import { DebugGui } from '../lib/DebugGui'
import { SmoothSwitcher } from '../lib/smoothSwitcher'
import { watchProperty } from '../lib/utils/proxy'
import { WorldRendererConfig } from '../lib/worldrendererCommon'
import { getMyHand } from './hand'
import { WorldRendererThree } from './worldrendererThree'
import { disposeObject } from './threeJsUtils'
@ -357,7 +357,7 @@ export default class HoldingBlock {
'minecraft:display_context': 'firstperson',
'minecraft:use_duration': this.worldRenderer.playerStateReactive.itemUsageTicks,
'minecraft:using_item': !!this.worldRenderer.playerStateReactive.itemUsageTicks,
}, this.lastItemModelName)
}, false, this.lastItemModelName)
if (result) {
const { mesh: itemMesh, isBlock, modelName } = result
if (isBlock) {

View file

@ -0,0 +1,427 @@
import * as THREE from 'three'
export interface Create3DItemMeshOptions {
depth: number
pixelSize?: number
}
export interface Create3DItemMeshResult {
geometry: THREE.BufferGeometry
totalVertices: number
totalTriangles: number
}
/**
* Creates a 3D item geometry with front/back faces and connecting edges
* from a canvas containing the item texture
*/
export function create3DItemMesh (
canvas: HTMLCanvasElement,
options: Create3DItemMeshOptions
): Create3DItemMeshResult {
const { depth, pixelSize } = options
// Validate canvas dimensions
if (canvas.width <= 0 || canvas.height <= 0) {
throw new Error(`Invalid canvas dimensions: ${canvas.width}x${canvas.height}`)
}
const ctx = canvas.getContext('2d')!
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const { data } = imageData
const w = canvas.width
const h = canvas.height
const halfDepth = depth / 2
const actualPixelSize = pixelSize ?? (1 / Math.max(w, h))
// Find opaque pixels
const isOpaque = (x: number, y: number) => {
if (x < 0 || y < 0 || x >= w || y >= h) return false
const i = (y * w + x) * 4
return data[i + 3] > 128 // alpha > 128
}
const vertices: number[] = []
const indices: number[] = []
const uvs: number[] = []
const normals: number[] = []
let vertexIndex = 0
// Helper to add a vertex
const addVertex = (x: number, y: number, z: number, u: number, v: number, nx: number, ny: number, nz: number) => {
vertices.push(x, y, z)
uvs.push(u, v)
normals.push(nx, ny, nz)
return vertexIndex++
}
// Helper to add a quad (two triangles)
const addQuad = (v0: number, v1: number, v2: number, v3: number) => {
indices.push(v0, v1, v2, v0, v2, v3)
}
// Convert pixel coordinates to world coordinates
const pixelToWorld = (px: number, py: number) => {
const x = (px / w - 0.5) * actualPixelSize * w
const y = -(py / h - 0.5) * actualPixelSize * h
return { x, y }
}
// Create a grid of vertices for front and back faces
const frontVertices: Array<Array<number | null>> = Array.from({ length: h + 1 }, () => Array.from({ length: w + 1 }, () => null))
const backVertices: Array<Array<number | null>> = Array.from({ length: h + 1 }, () => Array.from({ length: w + 1 }, () => null))
// Create vertices at pixel corners
for (let py = 0; py <= h; py++) {
for (let px = 0; px <= w; px++) {
const { x, y } = pixelToWorld(px - 0.5, py - 0.5)
// UV coordinates should map to the texture space of the extracted tile
const u = px / w
const v = py / h
// Check if this vertex is needed for any face or edge
let needVertex = false
// Check all 4 adjacent pixels to see if any are opaque
const adjacentPixels = [
[px - 1, py - 1], // top-left pixel
[px, py - 1], // top-right pixel
[px - 1, py], // bottom-left pixel
[px, py] // bottom-right pixel
]
for (const [adjX, adjY] of adjacentPixels) {
if (isOpaque(adjX, adjY)) {
needVertex = true
break
}
}
if (needVertex) {
frontVertices[py][px] = addVertex(x, y, halfDepth, u, v, 0, 0, 1)
backVertices[py][px] = addVertex(x, y, -halfDepth, u, v, 0, 0, -1)
}
}
}
// Create front and back faces
for (let py = 0; py < h; py++) {
for (let px = 0; px < w; px++) {
if (!isOpaque(px, py)) continue
const v00 = frontVertices[py][px]
const v10 = frontVertices[py][px + 1]
const v11 = frontVertices[py + 1][px + 1]
const v01 = frontVertices[py + 1][px]
const b00 = backVertices[py][px]
const b10 = backVertices[py][px + 1]
const b11 = backVertices[py + 1][px + 1]
const b01 = backVertices[py + 1][px]
if (v00 !== null && v10 !== null && v11 !== null && v01 !== null) {
// Front face
addQuad(v00, v10, v11, v01)
}
if (b00 !== null && b10 !== null && b11 !== null && b01 !== null) {
// Back face (reversed winding)
addQuad(b10, b00, b01, b11)
}
}
}
// Create edge faces for each side of the pixel with proper UVs
for (let py = 0; py < h; py++) {
for (let px = 0; px < w; px++) {
if (!isOpaque(px, py)) continue
const pixelU = (px + 0.5) / w // Center of current pixel
const pixelV = (py + 0.5) / h
// Left edge (x = px)
if (!isOpaque(px - 1, py)) {
const f0 = frontVertices[py][px]
const f1 = frontVertices[py + 1][px]
const b0 = backVertices[py][px]
const b1 = backVertices[py + 1][px]
if (f0 !== null && f1 !== null && b0 !== null && b1 !== null) {
// Create new vertices for edge with current pixel's UV
const ef0 = addVertex(vertices[f0 * 3], vertices[f0 * 3 + 1], vertices[f0 * 3 + 2], pixelU, pixelV, -1, 0, 0)
const ef1 = addVertex(vertices[f1 * 3], vertices[f1 * 3 + 1], vertices[f1 * 3 + 2], pixelU, pixelV, -1, 0, 0)
const eb1 = addVertex(vertices[b1 * 3], vertices[b1 * 3 + 1], vertices[b1 * 3 + 2], pixelU, pixelV, -1, 0, 0)
const eb0 = addVertex(vertices[b0 * 3], vertices[b0 * 3 + 1], vertices[b0 * 3 + 2], pixelU, pixelV, -1, 0, 0)
addQuad(ef0, ef1, eb1, eb0)
}
}
// Right edge (x = px + 1)
if (!isOpaque(px + 1, py)) {
const f0 = frontVertices[py + 1][px + 1]
const f1 = frontVertices[py][px + 1]
const b0 = backVertices[py + 1][px + 1]
const b1 = backVertices[py][px + 1]
if (f0 !== null && f1 !== null && b0 !== null && b1 !== null) {
const ef0 = addVertex(vertices[f0 * 3], vertices[f0 * 3 + 1], vertices[f0 * 3 + 2], pixelU, pixelV, 1, 0, 0)
const ef1 = addVertex(vertices[f1 * 3], vertices[f1 * 3 + 1], vertices[f1 * 3 + 2], pixelU, pixelV, 1, 0, 0)
const eb1 = addVertex(vertices[b1 * 3], vertices[b1 * 3 + 1], vertices[b1 * 3 + 2], pixelU, pixelV, 1, 0, 0)
const eb0 = addVertex(vertices[b0 * 3], vertices[b0 * 3 + 1], vertices[b0 * 3 + 2], pixelU, pixelV, 1, 0, 0)
addQuad(ef0, ef1, eb1, eb0)
}
}
// Top edge (y = py)
if (!isOpaque(px, py - 1)) {
const f0 = frontVertices[py][px]
const f1 = frontVertices[py][px + 1]
const b0 = backVertices[py][px]
const b1 = backVertices[py][px + 1]
if (f0 !== null && f1 !== null && b0 !== null && b1 !== null) {
const ef0 = addVertex(vertices[f0 * 3], vertices[f0 * 3 + 1], vertices[f0 * 3 + 2], pixelU, pixelV, 0, -1, 0)
const ef1 = addVertex(vertices[f1 * 3], vertices[f1 * 3 + 1], vertices[f1 * 3 + 2], pixelU, pixelV, 0, -1, 0)
const eb1 = addVertex(vertices[b1 * 3], vertices[b1 * 3 + 1], vertices[b1 * 3 + 2], pixelU, pixelV, 0, -1, 0)
const eb0 = addVertex(vertices[b0 * 3], vertices[b0 * 3 + 1], vertices[b0 * 3 + 2], pixelU, pixelV, 0, -1, 0)
addQuad(ef0, ef1, eb1, eb0)
}
}
// Bottom edge (y = py + 1)
if (!isOpaque(px, py + 1)) {
const f0 = frontVertices[py + 1][px + 1]
const f1 = frontVertices[py + 1][px]
const b0 = backVertices[py + 1][px + 1]
const b1 = backVertices[py + 1][px]
if (f0 !== null && f1 !== null && b0 !== null && b1 !== null) {
const ef0 = addVertex(vertices[f0 * 3], vertices[f0 * 3 + 1], vertices[f0 * 3 + 2], pixelU, pixelV, 0, 1, 0)
const ef1 = addVertex(vertices[f1 * 3], vertices[f1 * 3 + 1], vertices[f1 * 3 + 2], pixelU, pixelV, 0, 1, 0)
const eb1 = addVertex(vertices[b1 * 3], vertices[b1 * 3 + 1], vertices[b1 * 3 + 2], pixelU, pixelV, 0, 1, 0)
const eb0 = addVertex(vertices[b0 * 3], vertices[b0 * 3 + 1], vertices[b0 * 3 + 2], pixelU, pixelV, 0, 1, 0)
addQuad(ef0, ef1, eb1, eb0)
}
}
}
}
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3))
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
geometry.setIndex(indices)
// Compute normals properly
geometry.computeVertexNormals()
return {
geometry,
totalVertices: vertexIndex,
totalTriangles: indices.length / 3
}
}
export interface ItemTextureInfo {
u: number
v: number
sizeX: number
sizeY: number
}
export interface ItemMeshResult {
mesh: THREE.Object3D
itemsTexture?: THREE.Texture
itemsTextureFlipped?: THREE.Texture
cleanup?: () => void
}
/**
* Extracts item texture region to a canvas
*/
export function extractItemTextureToCanvas (
sourceTexture: THREE.Texture,
textureInfo: ItemTextureInfo
): HTMLCanvasElement {
const { u, v, sizeX, sizeY } = textureInfo
// Calculate canvas size - fix the calculation
const canvasWidth = Math.max(1, Math.floor(sizeX * sourceTexture.image.width))
const canvasHeight = Math.max(1, Math.floor(sizeY * sourceTexture.image.height))
const canvas = document.createElement('canvas')
canvas.width = canvasWidth
canvas.height = canvasHeight
const ctx = canvas.getContext('2d')!
ctx.imageSmoothingEnabled = false
// Draw the item texture region to canvas
ctx.drawImage(
sourceTexture.image,
u * sourceTexture.image.width,
v * sourceTexture.image.height,
sizeX * sourceTexture.image.width,
sizeY * sourceTexture.image.height,
0,
0,
canvas.width,
canvas.height
)
return canvas
}
/**
* Creates either a 2D or 3D item mesh based on parameters
*/
export function createItemMesh (
sourceTexture: THREE.Texture,
textureInfo: ItemTextureInfo,
options: {
faceCamera?: boolean
use3D?: boolean
depth?: number
} = {}
): ItemMeshResult {
const { faceCamera = false, use3D = true, depth = 0.04 } = options
const { u, v, sizeX, sizeY } = textureInfo
if (faceCamera) {
// Create sprite for camera-facing items
const itemsTexture = sourceTexture.clone()
itemsTexture.flipY = true
itemsTexture.offset.set(u, 1 - v - sizeY)
itemsTexture.repeat.set(sizeX, sizeY)
itemsTexture.needsUpdate = true
itemsTexture.magFilter = THREE.NearestFilter
itemsTexture.minFilter = THREE.NearestFilter
const spriteMat = new THREE.SpriteMaterial({
map: itemsTexture,
transparent: true,
alphaTest: 0.1,
})
const mesh = new THREE.Sprite(spriteMat)
return {
mesh,
itemsTexture,
cleanup () {
itemsTexture.dispose()
}
}
}
if (use3D) {
// Try to create 3D mesh
try {
const canvas = extractItemTextureToCanvas(sourceTexture, textureInfo)
const { geometry } = create3DItemMesh(canvas, { depth })
// Create texture from canvas for the 3D mesh
const itemsTexture = new THREE.CanvasTexture(canvas)
itemsTexture.magFilter = THREE.NearestFilter
itemsTexture.minFilter = THREE.NearestFilter
itemsTexture.wrapS = itemsTexture.wrapT = THREE.ClampToEdgeWrapping
itemsTexture.flipY = false
itemsTexture.needsUpdate = true
const material = new THREE.MeshStandardMaterial({
map: itemsTexture,
side: THREE.DoubleSide,
transparent: true,
alphaTest: 0.1,
})
const mesh = new THREE.Mesh(geometry, material)
return {
mesh,
itemsTexture,
cleanup () {
itemsTexture.dispose()
geometry.dispose()
if (material.map) material.map.dispose()
material.dispose()
}
}
} catch (error) {
console.warn('Failed to create 3D item mesh, falling back to 2D:', error)
// Fall through to 2D rendering
}
}
// Fallback to 2D flat rendering
const itemsTexture = sourceTexture.clone()
itemsTexture.flipY = true
itemsTexture.offset.set(u, 1 - v - sizeY)
itemsTexture.repeat.set(sizeX, sizeY)
itemsTexture.needsUpdate = true
itemsTexture.magFilter = THREE.NearestFilter
itemsTexture.minFilter = THREE.NearestFilter
const itemsTextureFlipped = itemsTexture.clone()
itemsTextureFlipped.repeat.x *= -1
itemsTextureFlipped.needsUpdate = true
itemsTextureFlipped.offset.set(u + sizeX, 1 - v - sizeY)
const material = new THREE.MeshStandardMaterial({
map: itemsTexture,
transparent: true,
alphaTest: 0.1,
})
const materialFlipped = new THREE.MeshStandardMaterial({
map: itemsTextureFlipped,
transparent: true,
alphaTest: 0.1,
})
const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 0), [
new THREE.MeshBasicMaterial({ color: 0x00_00_00 }), new THREE.MeshBasicMaterial({ color: 0x00_00_00 }),
new THREE.MeshBasicMaterial({ color: 0x00_00_00 }), new THREE.MeshBasicMaterial({ color: 0x00_00_00 }),
material, materialFlipped,
])
return {
mesh,
itemsTexture,
itemsTextureFlipped,
cleanup () {
itemsTexture.dispose()
itemsTextureFlipped.dispose()
material.dispose()
materialFlipped.dispose()
}
}
}
/**
* Creates a complete 3D item mesh from a canvas texture
*/
export function createItemMeshFromCanvas (
canvas: HTMLCanvasElement,
options: Create3DItemMeshOptions
): THREE.Mesh {
const { geometry } = create3DItemMesh(canvas, options)
// Base color texture for the item
const colorTexture = new THREE.CanvasTexture(canvas)
colorTexture.magFilter = THREE.NearestFilter
colorTexture.minFilter = THREE.NearestFilter
colorTexture.wrapS = colorTexture.wrapT = THREE.ClampToEdgeWrapping
colorTexture.flipY = false // Important for canvas textures
colorTexture.needsUpdate = true
// Material - no transparency, no alpha test needed for edges
const material = new THREE.MeshBasicMaterial({
map: colorTexture,
side: THREE.DoubleSide,
transparent: true,
alphaTest: 0.1
})
return new THREE.Mesh(geometry, material)
}

View file

@ -7,12 +7,13 @@ import type { GraphicsInitOptions } from '../../../src/appViewer'
import { WorldDataEmitter } from '../lib/worldDataEmitter'
import { defaultWorldRendererConfig, WorldRendererCommon } from '../lib/worldrendererCommon'
import { getDefaultRendererState } from '../baseGraphicsBackend'
import { loadThreeJsTextureFromUrl, loadThreeJsTextureFromUrlSync } from '../lib/utils/skins'
import { ResourcesManager } from '../../../src/resourcesManager'
import { getInitialPlayerStateRenderer } from '../lib/basePlayerState'
import { loadThreeJsTextureFromUrl, loadThreeJsTextureFromUrlSync } from './threeJsUtils'
import { WorldRendererThree } from './worldrendererThree'
import { EntityMesh } from './entity/EntityMesh'
import { DocumentRenderer } from './documentRenderer'
import { PANORAMA_VERSION } from './panoramaShared'
const panoramaFiles = [
'panorama_3.png', // right (+x)
@ -156,7 +157,7 @@ export class PanoramaRenderer {
}
async worldBlocksPanorama () {
const version = '1.21.4'
const version = PANORAMA_VERSION
const fullResourceManager = this.options.resourcesManager as ResourcesManager
fullResourceManager.currentConfig = { version, noInventoryGui: true, }
await fullResourceManager.updateAssetsData({ })

View file

@ -0,0 +1 @@
export const PANORAMA_VERSION = '1.21.4'

View file

@ -10,11 +10,11 @@ export type ResolvedItemModelRender = {
export const renderSlot = (model: ResolvedItemModelRender, resourcesManager: ResourcesManagerCommon, debugIsQuickbar = false, fullBlockModelSupport = false): {
texture: string,
blockData?: Record<string, { slice, path }> & { resolvedModel: BlockModel },
scale?: number,
slice?: number[],
modelName?: string,
} | undefined => {
blockData: Record<string, { slice, path }> & { resolvedModel: BlockModel } | null,
scale: number | null,
slice: number[] | null,
modelName: string | null,
} => {
let itemModelName = model.modelName
const isItem = loadedData.itemsByName[itemModelName]
@ -37,6 +37,8 @@ export const renderSlot = (model: ResolvedItemModelRender, resourcesManager: Res
texture: 'gui',
slice: [x, y, atlas.tileSize, atlas.tileSize],
scale: 0.25,
blockData: null,
modelName: null
}
}
}
@ -63,14 +65,18 @@ export const renderSlot = (model: ResolvedItemModelRender, resourcesManager: Res
return {
texture: itemTexture.type,
slice: itemTexture.slice,
modelName: itemModelName
modelName: itemModelName,
blockData: null,
scale: null
}
} else {
// is block
return {
texture: 'blocks',
blockData: itemTexture,
modelName: itemModelName
modelName: itemModelName,
slice: null,
scale: null
}
}
}

View file

@ -0,0 +1,406 @@
import * as THREE from 'three'
import { DebugGui } from '../lib/DebugGui'
export const DEFAULT_TEMPERATURE = 0.75
export class SkyboxRenderer {
private texture: THREE.Texture | null = null
private mesh: THREE.Mesh<THREE.SphereGeometry, THREE.MeshBasicMaterial> | null = null
private skyMesh: THREE.Mesh | null = null
private voidMesh: THREE.Mesh | null = null
// World state
private worldTime = 0
private partialTicks = 0
private viewDistance = 4
private temperature = DEFAULT_TEMPERATURE
private inWater = false
private waterBreathing = false
private fogBrightness = 0
private prevFogBrightness = 0
private readonly fogOrangeness = 0 // Debug property to control sky color orangeness
private readonly distanceFactor = 2.7
private readonly brightnessAtPosition = 1
debugGui: DebugGui
constructor (private readonly scene: THREE.Scene, public defaultSkybox: boolean, public initialImage: string | null) {
this.debugGui = new DebugGui('skybox_renderer', this, [
'temperature',
'worldTime',
'inWater',
'waterBreathing',
'fogOrangeness',
'brightnessAtPosition',
'distanceFactor'
], {
brightnessAtPosition: { min: 0, max: 1, step: 0.01 },
temperature: { min: 0, max: 1, step: 0.01 },
worldTime: { min: 0, max: 24_000, step: 1 },
fogOrangeness: { min: -1, max: 1, step: 0.01 },
distanceFactor: { min: 0, max: 5, step: 0.01 },
})
if (!initialImage) {
this.createGradientSky()
}
// this.debugGui.activate()
}
async init () {
if (this.initialImage) {
await this.setSkyboxImage(this.initialImage)
}
}
async setSkyboxImage (imageUrl: string) {
// Dispose old textures if they exist
if (this.texture) {
this.texture.dispose()
}
// Load the equirectangular texture
const textureLoader = new THREE.TextureLoader()
this.texture = await new Promise((resolve) => {
textureLoader.load(
imageUrl,
(texture) => {
texture.mapping = THREE.EquirectangularReflectionMapping
texture.encoding = THREE.sRGBEncoding
// Keep pixelated look
texture.minFilter = THREE.NearestFilter
texture.magFilter = THREE.NearestFilter
texture.needsUpdate = true
resolve(texture)
}
)
})
// Create or update the skybox
if (this.mesh) {
// Just update the texture on the existing material
this.mesh.material.map = this.texture
this.mesh.material.needsUpdate = true
} else {
// Create a large sphere geometry for the skybox
const geometry = new THREE.SphereGeometry(500, 60, 40)
// Flip the geometry inside out
geometry.scale(-1, 1, 1)
// Create material using the loaded texture
const material = new THREE.MeshBasicMaterial({
map: this.texture,
side: THREE.FrontSide // Changed to FrontSide since we're flipping the geometry
})
// Create and add the skybox mesh
this.mesh = new THREE.Mesh(geometry, material)
this.scene.add(this.mesh)
}
}
update (cameraPosition: THREE.Vector3, newViewDistance: number) {
if (newViewDistance !== this.viewDistance) {
this.viewDistance = newViewDistance
this.updateSkyColors()
}
if (this.mesh) {
// Update skybox position
this.mesh.position.copy(cameraPosition)
} else if (this.skyMesh) {
// Update gradient sky position
this.skyMesh.position.copy(cameraPosition)
this.voidMesh?.position.copy(cameraPosition)
this.updateSkyColors() // Update colors based on time of day
}
}
// Update world time
updateTime (timeOfDay: number, partialTicks = 0) {
if (this.debugGui.visible) return
this.worldTime = timeOfDay
this.partialTicks = partialTicks
this.updateSkyColors()
}
// Update view distance
updateViewDistance (viewDistance: number) {
this.viewDistance = viewDistance
this.updateSkyColors()
}
// Update temperature (for biome support)
updateTemperature (temperature: number) {
if (this.debugGui.visible) return
this.temperature = temperature
this.updateSkyColors()
}
// Update water state
updateWaterState (inWater: boolean, waterBreathing: boolean) {
if (this.debugGui.visible) return
this.inWater = inWater
this.waterBreathing = waterBreathing
this.updateSkyColors()
}
// Update default skybox setting
updateDefaultSkybox (defaultSkybox: boolean) {
if (this.debugGui.visible) return
this.defaultSkybox = defaultSkybox
this.updateSkyColors()
}
private createGradientSky () {
const size = 64
const scale = 256 / size + 2
{
const geometry = new THREE.PlaneGeometry(size * scale * 2, size * scale * 2)
geometry.rotateX(-Math.PI / 2)
geometry.translate(0, 16, 0)
const material = new THREE.MeshBasicMaterial({
color: 0xff_ff_ff,
side: THREE.DoubleSide,
depthTest: false
})
this.skyMesh = new THREE.Mesh(geometry, material)
this.scene.add(this.skyMesh)
}
{
const geometry = new THREE.PlaneGeometry(size * scale * 2, size * scale * 2)
geometry.rotateX(-Math.PI / 2)
geometry.translate(0, -16, 0)
const material = new THREE.MeshBasicMaterial({
color: 0xff_ff_ff,
side: THREE.DoubleSide,
depthTest: false
})
this.voidMesh = new THREE.Mesh(geometry, material)
this.scene.add(this.voidMesh)
}
this.updateSkyColors()
}
private getFogColor (partialTicks = 0): THREE.Vector3 {
const angle = this.getCelestialAngle(partialTicks)
let rotation = Math.cos(angle * Math.PI * 2) * 2 + 0.5
rotation = Math.max(0, Math.min(1, rotation))
let x = 0.752_941_2
let y = 0.847_058_83
let z = 1
x *= (rotation * 0.94 + 0.06)
y *= (rotation * 0.94 + 0.06)
z *= (rotation * 0.91 + 0.09)
return new THREE.Vector3(x, y, z)
}
private getSkyColor (x = 0, z = 0, partialTicks = 0): THREE.Vector3 {
const angle = this.getCelestialAngle(partialTicks)
let brightness = Math.cos(angle * 3.141_593 * 2) * 2 + 0.5
if (brightness < 0) brightness = 0
if (brightness > 1) brightness = 1
const temperature = this.getTemperature(x, z)
const rgb = this.getSkyColorByTemp(temperature)
const red = ((rgb >> 16) & 0xff) / 255
const green = ((rgb >> 8) & 0xff) / 255
const blue = (rgb & 0xff) / 255
return new THREE.Vector3(
red * brightness,
green * brightness,
blue * brightness
)
}
private calculateCelestialAngle (time: number, partialTicks: number): number {
const modTime = (time % 24_000)
let angle = (modTime + partialTicks) / 24_000 - 0.25
if (angle < 0) {
angle++
}
if (angle > 1) {
angle--
}
angle = 1 - ((Math.cos(angle * Math.PI) + 1) / 2)
angle += (angle - angle) / 3
return angle
}
private getCelestialAngle (partialTicks: number): number {
return this.calculateCelestialAngle(this.worldTime, partialTicks)
}
private getTemperature (x: number, z: number): number {
return this.temperature
}
private getSkyColorByTemp (temperature: number): number {
temperature /= 3
if (temperature < -1) temperature = -1
if (temperature > 1) temperature = 1
// Apply debug fog orangeness to hue - positive values make it more orange, negative make it less orange
const baseHue = 0.622_222_2 - temperature * 0.05
// Orange is around hue 0.08-0.15, so we need to shift from blue-purple (0.62) toward orange
// Use a more dramatic shift and also increase saturation for more noticeable effect
const orangeHue = 0.12 // Orange hue value
const hue = this.fogOrangeness > 0
? baseHue + (orangeHue - baseHue) * this.fogOrangeness * 0.8 // Blend toward orange
: baseHue + this.fogOrangeness * 0.1 // Subtle shift for negative values
const saturation = 0.5 + temperature * 0.1 + Math.abs(this.fogOrangeness) * 0.3 // Increase saturation with orangeness
const brightness = 1
return this.hsbToRgb(hue, saturation, brightness)
}
private hsbToRgb (hue: number, saturation: number, brightness: number): number {
let r = 0; let g = 0; let b = 0
if (saturation === 0) {
r = g = b = Math.floor(brightness * 255 + 0.5)
} else {
const h = (hue - Math.floor(hue)) * 6
const f = h - Math.floor(h)
const p = brightness * (1 - saturation)
const q = brightness * (1 - saturation * f)
const t = brightness * (1 - (saturation * (1 - f)))
switch (Math.floor(h)) {
case 0:
r = Math.floor(brightness * 255 + 0.5)
g = Math.floor(t * 255 + 0.5)
b = Math.floor(p * 255 + 0.5)
break
case 1:
r = Math.floor(q * 255 + 0.5)
g = Math.floor(brightness * 255 + 0.5)
b = Math.floor(p * 255 + 0.5)
break
case 2:
r = Math.floor(p * 255 + 0.5)
g = Math.floor(brightness * 255 + 0.5)
b = Math.floor(t * 255 + 0.5)
break
case 3:
r = Math.floor(p * 255 + 0.5)
g = Math.floor(q * 255 + 0.5)
b = Math.floor(brightness * 255 + 0.5)
break
case 4:
r = Math.floor(t * 255 + 0.5)
g = Math.floor(p * 255 + 0.5)
b = Math.floor(brightness * 255 + 0.5)
break
case 5:
r = Math.floor(brightness * 255 + 0.5)
g = Math.floor(p * 255 + 0.5)
b = Math.floor(q * 255 + 0.5)
break
}
}
return 0xff_00_00_00 | (r << 16) | (g << 8) | (Math.trunc(b))
}
private updateSkyColors () {
if (!this.skyMesh || !this.voidMesh) return
// If default skybox is disabled, hide the skybox meshes
if (!this.defaultSkybox) {
this.skyMesh.visible = false
this.voidMesh.visible = false
if (this.mesh) {
this.mesh.visible = false
}
return
}
// Show skybox meshes when default skybox is enabled
this.skyMesh.visible = true
this.voidMesh.visible = true
if (this.mesh) {
this.mesh.visible = true
}
// Update fog brightness with smooth transition
this.prevFogBrightness = this.fogBrightness
const renderDistance = this.viewDistance / 32
const targetBrightness = this.brightnessAtPosition * (1 - renderDistance) + renderDistance
this.fogBrightness += (targetBrightness - this.fogBrightness) * 0.1
// Handle water fog
if (this.inWater) {
const waterViewDistance = this.waterBreathing ? 100 : 5
this.scene.fog = new THREE.Fog(new THREE.Color(0, 0, 1), 0.0025, waterViewDistance)
this.scene.background = new THREE.Color(0, 0, 1)
// Update sky and void colors for underwater effect
;(this.skyMesh.material as THREE.MeshBasicMaterial).color.set(new THREE.Color(0, 0, 1))
;(this.voidMesh.material as THREE.MeshBasicMaterial).color.set(new THREE.Color(0, 0, 0.6))
return
}
// Normal sky colors
const viewDistance = this.viewDistance * 16
const viewFactor = 1 - (0.25 + 0.75 * this.viewDistance / 32) ** 0.25
const angle = this.getCelestialAngle(this.partialTicks)
const skyColor = this.getSkyColor(0, 0, this.partialTicks)
const fogColor = this.getFogColor(this.partialTicks)
const brightness = Math.cos(angle * Math.PI * 2) * 2 + 0.5
const clampedBrightness = Math.max(0, Math.min(1, brightness))
// Interpolate fog brightness
const interpolatedBrightness = this.prevFogBrightness + (this.fogBrightness - this.prevFogBrightness) * this.partialTicks
const red = (fogColor.x + (skyColor.x - fogColor.x) * viewFactor) * clampedBrightness * interpolatedBrightness
const green = (fogColor.y + (skyColor.y - fogColor.y) * viewFactor) * clampedBrightness * interpolatedBrightness
const blue = (fogColor.z + (skyColor.z - fogColor.z) * viewFactor) * clampedBrightness * interpolatedBrightness
this.scene.background = new THREE.Color(red, green, blue)
this.scene.fog = new THREE.Fog(new THREE.Color(red, green, blue), 0.0025, viewDistance * this.distanceFactor)
;(this.skyMesh.material as THREE.MeshBasicMaterial).color.set(new THREE.Color(skyColor.x, skyColor.y, skyColor.z))
;(this.voidMesh.material as THREE.MeshBasicMaterial).color.set(new THREE.Color(
skyColor.x * 0.2 + 0.04,
skyColor.y * 0.2 + 0.04,
skyColor.z * 0.6 + 0.1
))
}
dispose () {
if (this.texture) {
this.texture.dispose()
}
if (this.mesh) {
this.mesh.geometry.dispose()
;(this.mesh.material as THREE.Material).dispose()
this.scene.remove(this.mesh)
}
if (this.skyMesh) {
this.skyMesh.geometry.dispose()
;(this.skyMesh.material as THREE.Material).dispose()
this.scene.remove(this.skyMesh)
}
if (this.voidMesh) {
this.voidMesh.geometry.dispose()
;(this.voidMesh.material as THREE.Material).dispose()
this.scene.remove(this.voidMesh)
}
}
}

View file

@ -2,7 +2,7 @@ import * as THREE from 'three'
import { WorldRendererThree } from './worldrendererThree'
export interface SoundSystem {
playSound: (position: { x: number, y: number, z: number }, path: string, volume?: number, pitch?: number) => void
playSound: (position: { x: number, y: number, z: number }, path: string, volume?: number, pitch?: number, timeout?: number) => void
destroy: () => void
}
@ -10,7 +10,17 @@ export class ThreeJsSound implements SoundSystem {
audioListener: THREE.AudioListener | undefined
private readonly activeSounds = new Set<THREE.PositionalAudio>()
private readonly audioContext: AudioContext | undefined
private readonly soundVolumes = new Map<THREE.PositionalAudio, number>()
baseVolume = 1
constructor (public worldRenderer: WorldRendererThree) {
worldRenderer.onWorldSwitched.push(() => {
this.stopAll()
})
worldRenderer.onReactiveConfigUpdated('volume', (volume) => {
this.changeVolume(volume)
})
}
initAudioListener () {
@ -19,20 +29,24 @@ export class ThreeJsSound implements SoundSystem {
this.worldRenderer.camera.add(this.audioListener)
}
playSound (position: { x: number, y: number, z: number }, path: string, volume = 1, pitch = 1) {
playSound (position: { x: number, y: number, z: number }, path: string, volume = 1, pitch = 1, timeout = 500) {
this.initAudioListener()
const sound = new THREE.PositionalAudio(this.audioListener!)
this.activeSounds.add(sound)
this.soundVolumes.set(sound, volume)
const audioLoader = new THREE.AudioLoader()
const start = Date.now()
void audioLoader.loadAsync(path).then((buffer) => {
if (Date.now() - start > 500) return
if (Date.now() - start > timeout) {
console.warn('Ignored playing sound', path, 'due to timeout:', timeout, 'ms <', Date.now() - start, 'ms')
return
}
// play
sound.setBuffer(buffer)
sound.setRefDistance(20)
sound.setVolume(volume)
sound.setVolume(volume * this.baseVolume)
sound.setPlaybackRate(pitch) // set the pitch
this.worldRenderer.scene.add(sound)
// set sound position
@ -43,21 +57,35 @@ export class ThreeJsSound implements SoundSystem {
sound.disconnect()
}
this.activeSounds.delete(sound)
this.soundVolumes.delete(sound)
audioLoader.manager.itemEnd(path)
}
sound.play()
})
}
destroy () {
// Stop and clean up all active sounds
stopAll () {
for (const sound of this.activeSounds) {
if (!sound) continue
sound.stop()
if (sound.source) {
sound.disconnect()
}
this.worldRenderer.scene.remove(sound)
}
this.activeSounds.clear()
this.soundVolumes.clear()
}
changeVolume (volume: number) {
this.baseVolume = volume
for (const [sound, individualVolume] of this.soundVolumes) {
sound.setVolume(individualVolume * this.baseVolume)
}
}
destroy () {
this.stopAll()
// Remove and cleanup audio listener
if (this.audioListener) {
this.audioListener.removeFromParent()

View file

@ -1,4 +1,6 @@
import * as THREE from 'three'
import { getLoadedImage } from 'mc-assets/dist/utils'
import { createCanvas } from '../lib/utils'
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
@ -16,3 +18,56 @@ export const disposeObject = (obj: THREE.Object3D, cleanTextures = false) => {
}
}
}
let textureCache: Record<string, THREE.Texture> = {}
let imagesPromises: Record<string, Promise<THREE.Texture>> = {}
export const loadThreeJsTextureFromUrlSync = (imageUrl: string) => {
const texture = new THREE.Texture()
const promise = getLoadedImage(imageUrl).then(image => {
texture.image = image
texture.needsUpdate = true
return texture
})
return {
texture,
promise
}
}
export const loadThreeJsTextureFromUrl = async (imageUrl: string) => {
const loaded = new THREE.TextureLoader().loadAsync(imageUrl)
return loaded
}
export const loadThreeJsTextureFromBitmap = (image: ImageBitmap) => {
const canvas = createCanvas(image.width, image.height)
const ctx = canvas.getContext('2d')!
ctx.drawImage(image, 0, 0)
const texture = new THREE.Texture(canvas)
texture.magFilter = THREE.NearestFilter
texture.minFilter = THREE.NearestFilter
return 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>()
const t = loadThreeJsTextureFromUrlSync(texture)
textureCache[texture] = t.texture
void t.promise.then(resolve)
imagesPromises[texture] = promise
}
cb(textureCache[texture])
void imagesPromises[texture].then(() => {
onLoad?.()
})
}
export const clearTextureCache = () => {
textureCache = {}
imagesPromises = {}
}

View file

@ -0,0 +1,418 @@
import * as THREE from 'three'
// Centralized visual configuration (in screen pixels)
export const WAYPOINT_CONFIG = {
// Target size in screen pixels (this controls the final sprite size)
TARGET_SCREEN_PX: 150,
// Canvas size for internal rendering (keep power of 2 for textures)
CANVAS_SIZE: 256,
// Relative positions in canvas (0-1)
LAYOUT: {
DOT_Y: 0.3,
NAME_Y: 0.45,
DISTANCE_Y: 0.55,
},
// Multiplier for canvas internal resolution to keep text crisp
CANVAS_SCALE: 2,
ARROW: {
enabledDefault: false,
pixelSize: 50,
paddingPx: 50,
},
}
export type WaypointSprite = {
group: THREE.Group
sprite: THREE.Sprite
// Offscreen arrow controls
enableOffscreenArrow: (enabled: boolean) => void
setArrowParent: (parent: THREE.Object3D | null) => void
// Convenience combined updater
updateForCamera: (
cameraPosition: THREE.Vector3,
camera: THREE.PerspectiveCamera,
viewportWidthPx: number,
viewportHeightPx: number
) => boolean
// Utilities
setColor: (color: number) => void
setLabel: (label?: string) => void
updateDistanceText: (label: string, distanceText: string) => void
setVisible: (visible: boolean) => void
setPosition: (x: number, y: number, z: number) => void
dispose: () => void
}
export function createWaypointSprite (options: {
position: THREE.Vector3 | { x: number, y: number, z: number },
color?: number,
label?: string,
depthTest?: boolean,
// Y offset in world units used by updateScaleWorld only (screen-pixel API ignores this)
labelYOffset?: number,
metadata?: any,
}): WaypointSprite {
const color = options.color ?? 0xFF_00_00
const depthTest = options.depthTest ?? false
const labelYOffset = options.labelYOffset ?? 1.5
// Build combined sprite
const sprite = createCombinedSprite(color, options.label ?? '', '0m', depthTest)
sprite.renderOrder = 10
let currentLabel = options.label ?? ''
// Offscreen arrow (detached by default)
let arrowSprite: THREE.Sprite | undefined
let arrowParent: THREE.Object3D | null = null
let arrowEnabled = WAYPOINT_CONFIG.ARROW.enabledDefault
// Group for easy add/remove
const group = new THREE.Group()
group.add(sprite)
// Initial position
const { x, y, z } = options.position
group.position.set(x, y, z)
function setColor (newColor: number) {
const canvas = drawCombinedCanvas(newColor, currentLabel, '0m')
const texture = new THREE.CanvasTexture(canvas)
const mat = sprite.material
mat.map?.dispose()
mat.map = texture
mat.needsUpdate = true
}
function setLabel (newLabel?: string) {
currentLabel = newLabel ?? ''
const canvas = drawCombinedCanvas(color, currentLabel, '0m')
const texture = new THREE.CanvasTexture(canvas)
const mat = sprite.material
mat.map?.dispose()
mat.map = texture
mat.needsUpdate = true
}
function updateDistanceText (label: string, distanceText: string) {
const canvas = drawCombinedCanvas(color, label, distanceText)
const texture = new THREE.CanvasTexture(canvas)
const mat = sprite.material
mat.map?.dispose()
mat.map = texture
mat.needsUpdate = true
}
function setVisible (visible: boolean) {
sprite.visible = visible
}
function setPosition (nx: number, ny: number, nz: number) {
group.position.set(nx, ny, nz)
}
// Keep constant pixel size on screen using global config
function updateScaleScreenPixels (
cameraPosition: THREE.Vector3,
cameraFov: number,
distance: number,
viewportHeightPx: number
) {
const vFovRad = cameraFov * Math.PI / 180
const worldUnitsPerScreenHeightAtDist = Math.tan(vFovRad / 2) * 2 * distance
// Use configured target screen size
const scale = worldUnitsPerScreenHeightAtDist * (WAYPOINT_CONFIG.TARGET_SCREEN_PX / viewportHeightPx)
sprite.scale.set(scale, scale, 1)
}
function ensureArrow () {
if (arrowSprite) return
const size = 128
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const ctx = canvas.getContext('2d')!
ctx.clearRect(0, 0, size, size)
// Draw arrow shape
ctx.beginPath()
ctx.moveTo(size * 0.15, size * 0.5)
ctx.lineTo(size * 0.85, size * 0.5)
ctx.lineTo(size * 0.5, size * 0.15)
ctx.closePath()
// Use waypoint color for arrow
const colorHex = `#${color.toString(16).padStart(6, '0')}`
ctx.lineWidth = 6
ctx.strokeStyle = 'black'
ctx.stroke()
ctx.fillStyle = colorHex
ctx.fill()
const texture = new THREE.CanvasTexture(canvas)
const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false, depthWrite: false })
arrowSprite = new THREE.Sprite(material)
arrowSprite.renderOrder = 12
arrowSprite.visible = false
if (arrowParent) arrowParent.add(arrowSprite)
}
function enableOffscreenArrow (enabled: boolean) {
arrowEnabled = enabled
if (!enabled && arrowSprite) arrowSprite.visible = false
}
function setArrowParent (parent: THREE.Object3D | null) {
if (arrowSprite?.parent) arrowSprite.parent.remove(arrowSprite)
arrowParent = parent
if (arrowSprite && parent) parent.add(arrowSprite)
}
function updateOffscreenArrow (
camera: THREE.PerspectiveCamera,
viewportWidthPx: number,
viewportHeightPx: number
): boolean {
if (!arrowEnabled) return true
ensureArrow()
if (!arrowSprite) return true
// Check if onlyLeftRight is enabled in metadata
const onlyLeftRight = options.metadata?.onlyLeftRight === true
// Build camera basis using camera.up to respect custom orientations
const forward = new THREE.Vector3()
camera.getWorldDirection(forward) // camera look direction
const upWorld = camera.up.clone().normalize()
const right = new THREE.Vector3().copy(forward).cross(upWorld).normalize()
const upCam = new THREE.Vector3().copy(right).cross(forward).normalize()
// Vector from camera to waypoint
const camPos = new THREE.Vector3().setFromMatrixPosition(camera.matrixWorld)
const toWp = new THREE.Vector3(group.position.x, group.position.y, group.position.z).sub(camPos)
// Components in camera basis
const z = toWp.dot(forward)
const x = toWp.dot(right)
const y = toWp.dot(upCam)
const aspect = viewportWidthPx / viewportHeightPx
const vFovRad = camera.fov * Math.PI / 180
const hFovRad = 2 * Math.atan(Math.tan(vFovRad / 2) * aspect)
// Determine if waypoint is inside view frustum using angular checks
const thetaX = Math.atan2(x, z)
const thetaY = Math.atan2(y, z)
const visible = z > 0 && Math.abs(thetaX) <= hFovRad / 2 && Math.abs(thetaY) <= vFovRad / 2
if (visible) {
arrowSprite.visible = false
return true
}
// Direction on screen in normalized frustum units
let rx = thetaX / (hFovRad / 2)
let ry = thetaY / (vFovRad / 2)
// If behind the camera, snap to dominant axis to avoid confusing directions
if (z <= 0) {
if (Math.abs(rx) > Math.abs(ry)) {
rx = Math.sign(rx)
ry = 0
} else {
rx = 0
ry = Math.sign(ry)
}
}
// Apply onlyLeftRight logic - restrict arrows to left/right edges only
if (onlyLeftRight) {
// Force the arrow to appear only on left or right edges
if (Math.abs(rx) > Math.abs(ry)) {
// Horizontal direction is dominant, keep it
ry = 0
} else {
// Vertical direction is dominant, but we want only left/right
// So choose left or right based on the sign of rx
rx = rx >= 0 ? 1 : -1
ry = 0
}
}
// Place on the rectangle border [-1,1]x[-1,1]
const s = Math.max(Math.abs(rx), Math.abs(ry)) || 1
let ndcX = rx / s
let ndcY = ry / s
// Apply padding in pixel space by clamping
const padding = WAYPOINT_CONFIG.ARROW.paddingPx
const pxX = ((ndcX + 1) * 0.5) * viewportWidthPx
const pxY = ((1 - ndcY) * 0.5) * viewportHeightPx
const clampedPxX = Math.min(Math.max(pxX, padding), viewportWidthPx - padding)
const clampedPxY = Math.min(Math.max(pxY, padding), viewportHeightPx - padding)
ndcX = (clampedPxX / viewportWidthPx) * 2 - 1
ndcY = -(clampedPxY / viewportHeightPx) * 2 + 1
// Compute world position at a fixed distance in front of the camera using camera basis
const placeDist = Math.max(2, camera.near * 4)
const halfPlaneHeight = Math.tan(vFovRad / 2) * placeDist
const halfPlaneWidth = halfPlaneHeight * aspect
const pos = camPos.clone()
.add(forward.clone().multiplyScalar(placeDist))
.add(right.clone().multiplyScalar(ndcX * halfPlaneWidth))
.add(upCam.clone().multiplyScalar(ndcY * halfPlaneHeight))
// Update arrow sprite
arrowSprite.visible = true
arrowSprite.position.copy(pos)
// Angle for rotation relative to screen right/up (derived from camera up vector)
const angle = Math.atan2(ry, rx)
arrowSprite.material.rotation = angle - Math.PI / 2
// Constant pixel size for arrow (use fixed placement distance)
const worldUnitsPerScreenHeightAtDist = Math.tan(vFovRad / 2) * 2 * placeDist
const sPx = worldUnitsPerScreenHeightAtDist * (WAYPOINT_CONFIG.ARROW.pixelSize / viewportHeightPx)
arrowSprite.scale.set(sPx, sPx, 1)
return false
}
function computeDistance (cameraPosition: THREE.Vector3): number {
return cameraPosition.distanceTo(group.position)
}
function updateForCamera (
cameraPosition: THREE.Vector3,
camera: THREE.PerspectiveCamera,
viewportWidthPx: number,
viewportHeightPx: number
): boolean {
const distance = computeDistance(cameraPosition)
// Keep constant pixel size
updateScaleScreenPixels(cameraPosition, camera.fov, distance, viewportHeightPx)
// Update text
updateDistanceText(currentLabel, `${Math.round(distance)}m`)
// Update arrow and visibility
const onScreen = updateOffscreenArrow(camera, viewportWidthPx, viewportHeightPx)
setVisible(onScreen)
return onScreen
}
function dispose () {
const mat = sprite.material
mat.map?.dispose()
mat.dispose()
if (arrowSprite) {
const am = arrowSprite.material
am.map?.dispose()
am.dispose()
}
}
return {
group,
sprite,
enableOffscreenArrow,
setArrowParent,
updateForCamera,
setColor,
setLabel,
updateDistanceText,
setVisible,
setPosition,
dispose,
}
}
// Internal helpers
function drawCombinedCanvas (color: number, id: string, distance: string): HTMLCanvasElement {
const scale = WAYPOINT_CONFIG.CANVAS_SCALE * (globalThis.devicePixelRatio || 1)
const size = WAYPOINT_CONFIG.CANVAS_SIZE * scale
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const ctx = canvas.getContext('2d')!
// Clear canvas
ctx.clearRect(0, 0, size, size)
// Draw dot
const centerX = size / 2
const dotY = Math.round(size * WAYPOINT_CONFIG.LAYOUT.DOT_Y)
const radius = Math.round(size * 0.05) // Dot takes up ~12% of canvas height
const borderWidth = Math.max(2, Math.round(4 * scale))
// Outer border (black)
ctx.beginPath()
ctx.arc(centerX, dotY, radius + borderWidth, 0, Math.PI * 2)
ctx.fillStyle = 'black'
ctx.fill()
// Inner circle (colored)
ctx.beginPath()
ctx.arc(centerX, dotY, radius, 0, Math.PI * 2)
ctx.fillStyle = `#${color.toString(16).padStart(6, '0')}`
ctx.fill()
// Text properties
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
// Title
const nameFontPx = Math.round(size * 0.08) // ~8% of canvas height
const distanceFontPx = Math.round(size * 0.06) // ~6% of canvas height
ctx.font = `bold ${nameFontPx}px mojangles`
ctx.lineWidth = Math.max(2, Math.round(3 * scale))
const nameY = Math.round(size * WAYPOINT_CONFIG.LAYOUT.NAME_Y)
ctx.strokeStyle = 'black'
ctx.strokeText(id, centerX, nameY)
ctx.fillStyle = 'white'
ctx.fillText(id, centerX, nameY)
// Distance
ctx.font = `bold ${distanceFontPx}px mojangles`
ctx.lineWidth = Math.max(2, Math.round(2 * scale))
const distanceY = Math.round(size * WAYPOINT_CONFIG.LAYOUT.DISTANCE_Y)
ctx.strokeStyle = 'black'
ctx.strokeText(distance, centerX, distanceY)
ctx.fillStyle = '#CCCCCC'
ctx.fillText(distance, centerX, distanceY)
return canvas
}
function createCombinedSprite (color: number, id: string, distance: string, depthTest: boolean): THREE.Sprite {
const canvas = drawCombinedCanvas(color, id, distance)
const texture = new THREE.CanvasTexture(canvas)
texture.anisotropy = 1
texture.magFilter = THREE.LinearFilter
texture.minFilter = THREE.LinearFilter
const material = new THREE.SpriteMaterial({
map: texture,
transparent: true,
opacity: 1,
depthTest,
depthWrite: false,
})
const sprite = new THREE.Sprite(material)
sprite.position.set(0, 0, 0)
return sprite
}
export const WaypointHelpers = {
// World-scale constant size helper
computeWorldScale (distance: number, fixedReference = 10) {
return Math.max(0.0001, distance / fixedReference)
},
// Screen-pixel constant size helper
computeScreenPixelScale (
camera: THREE.PerspectiveCamera,
distance: number,
pixelSize: number,
viewportHeightPx: number
) {
const vFovRad = camera.fov * Math.PI / 180
const worldUnitsPerScreenHeightAtDist = Math.tan(vFovRad / 2) * 2 * distance
return worldUnitsPerScreenHeightAtDist * (pixelSize / viewportHeightPx)
}
}

View file

@ -0,0 +1,140 @@
import * as THREE from 'three'
import { WorldRendererThree } from './worldrendererThree'
import { createWaypointSprite, type WaypointSprite } from './waypointSprite'
interface Waypoint {
id: string
x: number
y: number
z: number
minDistance: number
color: number
label?: string
sprite: WaypointSprite
}
interface WaypointOptions {
color?: number
label?: string
minDistance?: number
metadata?: any
}
export class WaypointsRenderer {
private readonly waypoints = new Map<string, Waypoint>()
private readonly waypointScene = new THREE.Scene()
constructor (
private readonly worldRenderer: WorldRendererThree
) {
}
private updateWaypoints () {
const playerPos = this.worldRenderer.cameraObject.position
const sizeVec = this.worldRenderer.renderer.getSize(new THREE.Vector2())
for (const waypoint of this.waypoints.values()) {
const waypointPos = new THREE.Vector3(waypoint.x, waypoint.y, waypoint.z)
const distance = playerPos.distanceTo(waypointPos)
const visible = !waypoint.minDistance || distance >= waypoint.minDistance
waypoint.sprite.setVisible(visible)
if (visible) {
// Update position
waypoint.sprite.setPosition(waypoint.x, waypoint.y, waypoint.z)
// Ensure camera-based update each frame
waypoint.sprite.updateForCamera(this.worldRenderer.getCameraPosition(), this.worldRenderer.camera, sizeVec.width, sizeVec.height)
}
}
}
render () {
if (this.waypoints.size === 0) return
// Update waypoint scaling
this.updateWaypoints()
// Render waypoints scene with the world camera
this.worldRenderer.renderer.render(this.waypointScene, this.worldRenderer.camera)
}
// Removed sprite/label texture creation. Use utils/waypointSprite.ts
addWaypoint (
id: string,
x: number,
y: number,
z: number,
options: WaypointOptions = {}
) {
// Remove existing waypoint if it exists
this.removeWaypoint(id)
const color = options.color ?? 0xFF_00_00
const { label, metadata } = options
const minDistance = options.minDistance ?? 0
const sprite = createWaypointSprite({
position: new THREE.Vector3(x, y, z),
color,
label: (label || id),
metadata,
})
sprite.enableOffscreenArrow(true)
sprite.setArrowParent(this.waypointScene)
this.waypointScene.add(sprite.group)
this.waypoints.set(id, {
id, x: x + 0.5, y: y + 0.5, z: z + 0.5, minDistance,
color, label,
sprite,
})
}
removeWaypoint (id: string) {
const waypoint = this.waypoints.get(id)
if (waypoint) {
this.waypointScene.remove(waypoint.sprite.group)
waypoint.sprite.dispose()
this.waypoints.delete(id)
}
}
clear () {
for (const id of this.waypoints.keys()) {
this.removeWaypoint(id)
}
}
testWaypoint () {
this.addWaypoint('Test Point', 0, 70, 0, { color: 0x00_FF_00, label: 'Test Point' })
this.addWaypoint('Spawn', 0, 64, 0, { color: 0xFF_FF_00, label: 'Spawn' })
this.addWaypoint('Far Point', 100, 70, 100, { color: 0x00_00_FF, label: 'Far Point' })
}
getWaypoint (id: string): Waypoint | undefined {
return this.waypoints.get(id)
}
getAllWaypoints (): Waypoint[] {
return [...this.waypoints.values()]
}
setWaypointColor (id: string, color: number) {
const waypoint = this.waypoints.get(id)
if (waypoint) {
waypoint.sprite.setColor(color)
waypoint.color = color
}
}
setWaypointLabel (id: string, label?: string) {
const waypoint = this.waypoints.get(id)
if (waypoint) {
waypoint.label = label
waypoint.sprite.setLabel(label)
}
}
}

View file

@ -1,10 +1,9 @@
import * as THREE from 'three'
import { LineMaterial, LineSegmentsGeometry, Wireframe } from 'three-stdlib'
import { Vec3 } from 'vec3'
import { subscribeKey } from 'valtio/utils'
import { Block } from 'prismarine-block'
import { BlockShape, BlocksShapes } from 'renderer/viewer/lib/basePlayerState'
import { WorldRendererThree } from '../worldrendererThree'
import { loadThreeJsTextureFromUrl } from '../threeJsUtils'
import destroyStage0 from '../../../../assets/destroy_stage_0.png'
import destroyStage1 from '../../../../assets/destroy_stage_1.png'
import destroyStage2 from '../../../../assets/destroy_stage_2.png'
@ -15,7 +14,6 @@ import destroyStage6 from '../../../../assets/destroy_stage_6.png'
import destroyStage7 from '../../../../assets/destroy_stage_7.png'
import destroyStage8 from '../../../../assets/destroy_stage_8.png'
import destroyStage9 from '../../../../assets/destroy_stage_9.png'
import { loadThreeJsTextureFromUrl } from '../../lib/utils/skins'
export class CursorBlock {
_cursorLinesHidden = false
@ -30,7 +28,7 @@ export class CursorBlock {
}
cursorLineMaterial: LineMaterial
interactionLines: null | { blockPos: Vec3, mesh: THREE.Group } = null
interactionLines: null | { blockPos: Vec3, mesh: THREE.Group, shapePositions: BlocksShapes | undefined } = null
prevColor: string | undefined
blockBreakMesh: THREE.Mesh
breakTextures: THREE.Texture[] = []
@ -64,6 +62,13 @@ export class CursorBlock {
this.worldRenderer.onReactivePlayerStateUpdated('gameMode', () => {
this.updateLineMaterial()
})
// todo figure out why otherwise fog from skybox breaks it
setTimeout(() => {
this.updateLineMaterial()
if (this.interactionLines) {
this.setHighlightCursorBlock(this.interactionLines.blockPos, this.interactionLines.shapePositions, true)
}
})
}
// Update functions
@ -71,6 +76,9 @@ export class CursorBlock {
const inCreative = this.worldRenderer.playerStateReactive.gameMode === 'creative'
const pixelRatio = this.worldRenderer.renderer.getPixelRatio()
if (this.cursorLineMaterial) {
this.cursorLineMaterial.dispose()
}
this.cursorLineMaterial = new LineMaterial({
color: (() => {
switch (this.worldRenderer.worldRendererConfig.highlightBlockColor) {
@ -117,8 +125,8 @@ export class CursorBlock {
}
}
setHighlightCursorBlock (blockPos: Vec3 | null, shapePositions?: BlocksShapes): void {
if (blockPos && this.interactionLines && blockPos.equals(this.interactionLines.blockPos)) {
setHighlightCursorBlock (blockPos: Vec3 | null, shapePositions?: BlocksShapes, force = false): void {
if (blockPos && this.interactionLines && blockPos.equals(this.interactionLines.blockPos) && !force) {
return
}
if (this.interactionLines !== null) {
@ -142,7 +150,7 @@ export class CursorBlock {
}
this.worldRenderer.scene.add(group)
group.visible = !this.cursorLinesHidden
this.interactionLines = { blockPos, mesh: group }
this.interactionLines = { blockPos, mesh: group, shapePositions }
}
render () {

View file

@ -3,6 +3,7 @@ import { Vec3 } from 'vec3'
import nbt from 'prismarine-nbt'
import PrismarineChatLoader from 'prismarine-chat'
import * as tweenJs from '@tweenjs/tween.js'
import { Biome } from 'minecraft-data'
import { renderSign } from '../sign-renderer'
import { DisplayWorldOptions, GraphicsInitOptions } from '../../../src/appViewer'
import { chunkPos, sectionPos } from '../lib/simpleUtils'
@ -10,13 +11,12 @@ import { WorldRendererCommon } from '../lib/worldrendererCommon'
import { addNewStat } from '../lib/ui/newStats'
import { MesherGeometryOutput } from '../lib/mesher/shared'
import { ItemSpecificContextProperties } from '../lib/basePlayerState'
import { getMyHand } from '../lib/hand'
import { setBlockPosition } from '../lib/mesher/standaloneRenderer'
import { loadThreeJsTextureFromBitmap } from '../lib/utils/skins'
import { getMyHand } from './hand'
import HoldingBlock from './holdingBlock'
import { getMesh } from './entity/EntityMesh'
import { armorModel } from './entity/armorModels'
import { disposeObject } from './threeJsUtils'
import { disposeObject, loadThreeJsTextureFromBitmap } from './threeJsUtils'
import { CursorBlock } from './world/cursorBlock'
import { getItemUv } from './appShared'
import { Entities } from './entities'
@ -24,6 +24,8 @@ import { ThreeJsSound } from './threeJsSound'
import { CameraShake } from './cameraShake'
import { ThreeJsMedia } from './threeJsMedia'
import { Fountain } from './threeJsParticles'
import { WaypointsRenderer } from './waypoints'
import { DEFAULT_TEMPERATURE, SkyboxRenderer } from './skyboxRenderer'
type SectionKey = string
@ -49,6 +51,7 @@ export class WorldRendererThree extends WorldRendererCommon {
cameraContainer: THREE.Object3D
media: ThreeJsMedia
waitingChunksToDisplay = {} as { [chunkKey: string]: SectionKey[] }
waypoints: WaypointsRenderer
camera: THREE.PerspectiveCamera
renderTimeAvg = 0
sectionsOffsetsAnimations = {} as {
@ -70,6 +73,7 @@ export class WorldRendererThree extends WorldRendererCommon {
}
fountains: Fountain[] = []
DEBUG_RAYCAST = false
skyboxRenderer: SkyboxRenderer
private currentPosTween?: tweenJs.Tween<THREE.Vector3>
private currentRotTween?: tweenJs.Tween<{ pitch: number, yaw: number }>
@ -93,6 +97,10 @@ export class WorldRendererThree extends WorldRendererCommon {
this.holdingBlock = new HoldingBlock(this)
this.holdingBlockLeft = new HoldingBlock(this, true)
// Initialize skybox renderer
this.skyboxRenderer = new SkyboxRenderer(this.scene, this.worldRendererConfig.defaultSkybox, null)
void this.skyboxRenderer.init()
this.addDebugOverlay()
this.resetScene()
void this.init()
@ -100,6 +108,8 @@ export class WorldRendererThree extends WorldRendererCommon {
this.soundSystem = new ThreeJsSound(this)
this.cameraShake = new CameraShake(this, this.onRender)
this.media = new ThreeJsMedia(this)
this.waypoints = new WaypointsRenderer(this)
// this.fountain = new Fountain(this.scene, this.scene, {
// position: new THREE.Vector3(0, 10, 0),
// })
@ -120,6 +130,8 @@ export class WorldRendererThree extends WorldRendererCommon {
this.protocolCustomBlocks.clear()
// Reset section animations
this.sectionsOffsetsAnimations = {}
// Clear waypoints
this.waypoints.clear()
})
}
@ -162,7 +174,10 @@ export class WorldRendererThree extends WorldRendererCommon {
override watchReactivePlayerState () {
super.watchReactivePlayerState()
this.onReactivePlayerStateUpdated('inWater', (value) => {
this.scene.fog = value ? new THREE.Fog(0x00_00_ff, 0.1, this.playerStateReactive.waterBreathing ? 100 : 20) : null
this.skyboxRenderer.updateWaterState(value, this.playerStateReactive.waterBreathing)
})
this.onReactivePlayerStateUpdated('waterBreathing', (value) => {
this.skyboxRenderer.updateWaterState(this.playerStateReactive.inWater, value)
})
this.onReactivePlayerStateUpdated('ambientLight', (value) => {
if (!value) return
@ -191,6 +206,9 @@ export class WorldRendererThree extends WorldRendererCommon {
this.onReactiveConfigUpdated('showChunkBorders', (value) => {
this.updateShowChunksBorder(value)
})
this.onReactiveConfigUpdated('defaultSkybox', (value) => {
this.skyboxRenderer.updateDefaultSkybox(value)
})
}
changeHandSwingingState (isAnimationPlaying: boolean, isLeft = false) {
@ -253,6 +271,19 @@ export class WorldRendererThree extends WorldRendererCommon {
} else {
this.starField.remove()
}
this.skyboxRenderer.updateTime(newTime)
}
biomeUpdated (biome: Biome): void {
if (biome?.temperature !== undefined) {
this.skyboxRenderer.updateTemperature(biome.temperature)
}
}
biomeReset (): void {
// Reset to default temperature when biome is unknown
this.skyboxRenderer.updateTemperature(DEFAULT_TEMPERATURE)
}
getItemRenderData (item: Record<string, any>, specificProps: ItemSpecificContextProperties) {
@ -426,7 +457,7 @@ export class WorldRendererThree extends WorldRendererCommon {
this.scene.add(object)
}
getSignTexture (position: Vec3, blockEntity, backSide = false) {
getSignTexture (position: Vec3, blockEntity, isHanging, backSide = false) {
const chunk = chunkPos(position)
let textures = this.chunkTextures.get(`${chunk[0]},${chunk[1]}`)
if (!textures) {
@ -438,7 +469,7 @@ export class WorldRendererThree extends WorldRendererCommon {
if (textures[texturekey]) return textures[texturekey]
const PrismarineChat = PrismarineChatLoader(this.version)
const canvas = renderSign(blockEntity, PrismarineChat)
const canvas = renderSign(blockEntity, isHanging, PrismarineChat)
if (!canvas) return
const tex = new THREE.Texture(canvas)
tex.magFilter = THREE.NearestFilter
@ -454,7 +485,7 @@ export class WorldRendererThree extends WorldRendererCommon {
return worldPos
}
getWorldCameraPosition () {
getSectionCameraPosition () {
const pos = this.getCameraPosition()
return new Vec3(
Math.floor(pos.x / 16),
@ -464,7 +495,7 @@ export class WorldRendererThree extends WorldRendererCommon {
}
updateCameraSectionPos () {
const newSectionPos = this.getWorldCameraPosition()
const newSectionPos = this.getSectionCameraPosition()
if (!this.cameraSectionPos.equals(newSectionPos)) {
this.cameraSectionPos = newSectionPos
this.cameraSectionPositionUpdate()
@ -703,6 +734,10 @@ export class WorldRendererThree extends WorldRendererCommon {
this.cursorBlock.render()
this.updateSectionOffsets()
// Update skybox position to follow camera
const cameraPos = this.getCameraPosition()
this.skyboxRenderer.update(cameraPos, this.viewDistance)
const sizeOrFovChanged = sizeChanged || this.displayOptions.inWorldRenderingConfig.fov !== this.camera.fov
if (sizeOrFovChanged) {
const size = this.renderer.getSize(new THREE.Vector2())
@ -738,6 +773,8 @@ export class WorldRendererThree extends WorldRendererCommon {
fountain.render()
}
this.waypoints.render()
for (const onRender of this.onRender) {
onRender()
}
@ -750,12 +787,17 @@ export class WorldRendererThree extends WorldRendererCommon {
}
renderHead (position: Vec3, rotation: number, isWall: boolean, blockEntity) {
const textures = blockEntity.SkullOwner?.Properties?.textures[0]
if (!textures) return
let textureData: string
if (blockEntity.SkullOwner) {
textureData = blockEntity.SkullOwner.Properties?.textures?.[0]?.Value
} else {
textureData = blockEntity.profile?.properties?.find(p => p.name === 'textures')?.value
}
if (!textureData) return
try {
const textureData = JSON.parse(Buffer.from(textures.Value, 'base64').toString())
let skinUrl = textureData.textures?.SKIN?.url
const decodedData = JSON.parse(Buffer.from(textureData, 'base64').toString())
let skinUrl = decodedData.textures?.SKIN?.url
const { skinTexturesProxy } = this.worldRendererConfig
if (skinTexturesProxy) {
skinUrl = skinUrl?.replace('http://textures.minecraft.net/', skinTexturesProxy)
@ -784,7 +826,7 @@ export class WorldRendererThree extends WorldRendererCommon {
}
renderSign (position: Vec3, rotation: number, isWall: boolean, isHanging: boolean, blockEntity) {
const tex = this.getSignTexture(position, blockEntity)
const tex = this.getSignTexture(position, blockEntity, isHanging)
if (!tex) return
@ -940,6 +982,7 @@ export class WorldRendererThree extends WorldRendererCommon {
destroy (): void {
super.destroy()
this.skyboxRenderer.dispose()
}
shouldObjectVisible (object: THREE.Object3D) {
@ -1023,6 +1066,13 @@ class StarField {
constructor (
private readonly worldRenderer: WorldRendererThree
) {
const clock = new THREE.Clock()
const speed = 0.2
this.worldRenderer.onRender.push(() => {
if (!this.points) return
this.points.position.copy(this.worldRenderer.getCameraPosition());
(this.points.material as StarfieldMaterial).uniforms.time.value = clock.getElapsedTime() * speed
})
}
addToScene () {
@ -1033,7 +1083,6 @@ class StarField {
const count = 7000
const factor = 7
const saturation = 10
const speed = 0.2
const geometry = new THREE.BufferGeometry()
@ -1066,11 +1115,6 @@ class StarField {
this.points = new THREE.Points(geometry, material)
this.worldRenderer.scene.add(this.points)
const clock = new THREE.Clock()
this.points.onBeforeRender = (renderer, scene, camera) => {
this.points?.position.copy?.(this.worldRenderer.getCameraPosition())
material.uniforms.time.value = clock.getElapsedTime() * speed
}
this.points.renderOrder = -1
}

View file

@ -15,6 +15,7 @@ import { appAndRendererSharedConfig } from './renderer/rsbuildSharedConfig'
import { genLargeDataAliases } from './scripts/genLargeDataAliases'
import sharp from 'sharp'
import supportedVersions from './src/supportedVersions.mjs'
import { startWsServer } from './scripts/wsServer'
const SINGLE_FILE_BUILD = process.env.SINGLE_FILE_BUILD === 'true'
@ -59,6 +60,8 @@ const configSource = (SINGLE_FILE_BUILD ? 'BUNDLED' : (process.env.CONFIG_JSON_S
const faviconPath = 'favicon.png'
const enableMetrics = process.env.ENABLE_METRICS === 'true'
// base options are in ./renderer/rsbuildSharedConfig.ts
const appConfig = defineConfig({
html: {
@ -136,6 +139,13 @@ const appConfig = defineConfig({
// 50kb limit for data uri
dataUriLimit: SINGLE_FILE_BUILD ? 1 * 1024 * 1024 * 1024 : 50 * 1024
},
performance: {
// prefetch: {
// include(filename) {
// return filename.includes('mc-data') || filename.includes('mc-assets')
// },
// },
},
source: {
entry: {
index: './src/index.ts',
@ -151,7 +161,7 @@ const appConfig = defineConfig({
'process.platform': '"browser"',
'process.env.GITHUB_URL':
JSON.stringify(`https://github.com/${process.env.GITHUB_REPOSITORY || `${process.env.VERCEL_GIT_REPO_OWNER}/${process.env.VERCEL_GIT_REPO_SLUG}` || githubRepositoryFallback}`),
'process.env.DEPS_VERSIONS': JSON.stringify({}),
'process.env.ALWAYS_MINIMAL_SERVER_UI': JSON.stringify(process.env.ALWAYS_MINIMAL_SERVER_UI),
'process.env.RELEASE_TAG': JSON.stringify(releaseTag),
'process.env.RELEASE_LINK': JSON.stringify(releaseLink),
'process.env.RELEASE_CHANGELOG': JSON.stringify(releaseChangelog),
@ -159,6 +169,7 @@ const appConfig = defineConfig({
'process.env.INLINED_APP_CONFIG': JSON.stringify(configSource === 'BUNDLED' ? configJson : null),
'process.env.ENABLE_COOKIE_STORAGE': JSON.stringify(process.env.ENABLE_COOKIE_STORAGE || true),
'process.env.COOKIE_STORAGE_PREFIX': JSON.stringify(process.env.COOKIE_STORAGE_PREFIX || ''),
'process.env.WS_PORT': JSON.stringify(enableMetrics ? 8081 : false),
},
},
server: {
@ -186,7 +197,7 @@ const appConfig = defineConfig({
childProcess.execSync('tsx ./scripts/optimizeBlockCollisions.ts', { stdio: 'inherit' })
}
// childProcess.execSync(['tsx', './scripts/genLargeDataAliases.ts', ...(SINGLE_FILE_BUILD ? ['--compressed'] : [])].join(' '), { stdio: 'inherit' })
genLargeDataAliases(SINGLE_FILE_BUILD)
genLargeDataAliases(SINGLE_FILE_BUILD || process.env.ALWAYS_COMPRESS_LARGE_DATA === 'true')
fsExtra.copySync('./node_modules/mc-assets/dist/other-textures/latest/entity', './dist/textures/entity')
fsExtra.copySync('./assets/background', './dist/background')
fs.copyFileSync('./assets/favicon.png', './dist/favicon.png')
@ -216,6 +227,12 @@ const appConfig = defineConfig({
await execAsync('pnpm run build-mesher')
}
fs.writeFileSync('./dist/version.txt', buildingVersion, 'utf-8')
// Start WebSocket server in development
if (dev && enableMetrics) {
await startWsServer(8081, false)
}
console.timeEnd('total-prep')
}
if (!dev) {
@ -223,6 +240,10 @@ const appConfig = defineConfig({
prep()
})
build.onAfterBuild(async () => {
if (fs.readdirSync('./assets/customTextures').length > 0) {
childProcess.execSync('tsx ./scripts/patchAssets.ts', { stdio: 'inherit' })
}
if (SINGLE_FILE_BUILD) {
// check that only index.html is in the dist/single folder
const singleBuildFiles = fs.readdirSync('./dist/single')

View file

@ -16,7 +16,8 @@ export const genLargeDataAliases = async (isCompressed: boolean) => {
let str = `${decoderCode}\nexport const importLargeData = async (mod: ${Object.keys(modules).map(x => `'${x}'`).join(' | ')}) => {\n`
for (const [module, { compressed, raw }] of Object.entries(modules)) {
let importCode = `(await import('${isCompressed ? compressed : raw}')).default`;
const chunkName = module === 'mcData' ? 'mc-data' : 'mc-assets';
let importCode = `(await import(/* webpackChunkName: "${chunkName}" */ '${isCompressed ? compressed : raw}')).default`;
if (isCompressed) {
importCode = `JSON.parse(decompressFromBase64(${importCode}))`
}
@ -30,6 +31,8 @@ export const genLargeDataAliases = async (isCompressed: boolean) => {
const decoderCode = /* ts */ `
import pako from 'pako';
globalThis.pako = { inflate: pako.inflate.bind(pako) }
function decompressFromBase64(input) {
console.time('decompressFromBase64')
// Decode the Base64 string

View file

@ -6,8 +6,8 @@ import { dirname } from 'node:path'
import supportedVersions from '../src/supportedVersions.mjs'
import { gzipSizeFromFileSync } from 'gzip-size'
import fs from 'fs'
import {default as _JsonOptimizer} from '../src/optimizeJson'
import { gzipSync } from 'zlib';
import { default as _JsonOptimizer } from '../src/optimizeJson'
import { gzipSync } from 'zlib'
import MinecraftData from 'minecraft-data'
import MCProtocol from 'minecraft-protocol'
@ -21,12 +21,12 @@ const require = Module.createRequire(import.meta.url)
const dataPaths = require('minecraft-data/minecraft-data/data/dataPaths.json')
function toMajor (version) {
function toMajor(version) {
const [a, b] = (version + '').split('.')
return `${a}.${b}`
}
const versions = {}
let versions = {}
const dataTypes = new Set()
for (const [version, dataSet] of Object.entries(dataPaths.pc)) {
@ -42,6 +42,31 @@ const versionToNumber = (ver) => {
return +`${x.padStart(2, '0')}${y.padStart(2, '0')}${z.padStart(2, '0')}`
}
// Version clipping support
const minVersion = process.env.MIN_MC_VERSION
const maxVersion = process.env.MAX_MC_VERSION
// Filter versions based on MIN_VERSION and MAX_VERSION if provided
if (minVersion || maxVersion) {
const filteredVersions = {}
const minVersionNum = minVersion ? versionToNumber(minVersion) : 0
const maxVersionNum = maxVersion ? versionToNumber(maxVersion) : Infinity
for (const [version, dataSet] of Object.entries(versions)) {
const versionNum = versionToNumber(version)
if (versionNum >= minVersionNum && versionNum <= maxVersionNum) {
filteredVersions[version] = dataSet
}
}
versions = filteredVersions
console.log(`Version clipping applied: ${minVersion || 'none'} to ${maxVersion || 'none'}`)
console.log(`Processing ${Object.keys(versions).length} versions:`, Object.keys(versions).sort((a, b) => versionToNumber(a) - versionToNumber(b)))
}
console.log('Bundling version range:', Object.keys(versions)[0], 'to', Object.keys(versions).at(-1))
// if not included here (even as {}) will not be bundled & accessible!
// const compressedOutput = !!process.env.SINGLE_FILE_BUILD
const compressedOutput = true
@ -57,22 +82,27 @@ const dataTypeBundling2 = {
}
}
const dataTypeBundling = {
language: {
language: process.env.SKIP_MC_DATA_LANGUAGE === 'true' ? {
raw: {}
} : {
ignoreRemoved: true,
ignoreChanges: true
},
blocks: {
arrKey: 'name',
processData (current, prev) {
processData(current, prev, _, version) {
for (const block of current) {
const prevBlock = prev?.find(x => x.name === block.name)
if (block.transparent) {
const forceOpaque = block.name.includes('shulker_box') || block.name.match(/^double_.+_slab\d?$/) || ['melon_block', 'lit_pumpkin', 'lit_redstone_ore', 'lit_furnace'].includes(block.name)
const prevBlock = prev?.find(x => x.name === block.name);
if (forceOpaque || (prevBlock && !prevBlock.transparent)) {
block.transparent = false
}
}
if (block.hardness === 0 && prevBlock && prevBlock.hardness > 0) {
block.hardness = prevBlock.hardness
}
}
}
// ignoreRemoved: true,
@ -136,7 +166,9 @@ const dataTypeBundling = {
blockLoot: {
arrKey: 'block'
},
recipes: {
recipes: process.env.SKIP_MC_DATA_RECIPES === 'true' ? {
raw: {}
} : {
raw: true
// processData: processRecipes
},
@ -150,7 +182,7 @@ const dataTypeBundling = {
// }
}
function processRecipes (current, prev, getData, version) {
function processRecipes(current, prev, getData, version) {
// can require the same multiple times per different versions
if (current._proccessed) return
const items = getData('items')
@ -242,30 +274,39 @@ for (const [i, [version, dataSet]] of versionsArr.reverse().entries()) {
for (const [dataType, dataPath] of Object.entries(dataSet)) {
const config = dataTypeBundling[dataType]
if (!config) continue
if (dataType === 'blockCollisionShapes' && versionToNumber(version) >= versionToNumber('1.13')) {
// contents += ` get ${dataType} () { return window.globalGetCollisionShapes?.("${version}") },\n`
continue
}
const ignoreCollisionShapes = dataType === 'blockCollisionShapes' && versionToNumber(version) >= versionToNumber('1.13')
let injectCode = ''
const getData = (type) => {
const getRealData = (type) => {
const loc = `minecraft-data/data/${dataSet[type]}/`
const dataPathAbsolute = require.resolve(`minecraft-data/${loc}${type}`)
// const data = fs.readFileSync(dataPathAbsolute, 'utf8')
const dataRaw = require(dataPathAbsolute)
return dataRaw
}
const dataRaw = getData(dataType)
const dataRaw = getRealData(dataType)
let rawData = dataRaw
if (config.raw) {
rawDataVersions[dataType] ??= {}
rawDataVersions[dataType][version] = rawData
rawData = dataRaw
if (config.raw === true) {
rawData = dataRaw
} else {
rawData = config.raw
}
if (ignoreCollisionShapes && dataType === 'blockCollisionShapes') {
rawData = {
blocks: {},
shapes: {}
}
}
} else {
if (!diffSources[dataType]) {
diffSources[dataType] = new JsonOptimizer(config.arrKey, config.ignoreChanges, config.ignoreRemoved)
}
try {
config.processData?.(dataRaw, previousData[dataType], getData, version)
config.processData?.(dataRaw, previousData[dataType], getRealData, version)
diffSources[dataType].recordDiff(version, dataRaw)
injectCode = `restoreDiff(sources, ${JSON.stringify(dataType)}, ${JSON.stringify(version)})`
} catch (err) {
@ -297,16 +338,16 @@ console.log('total size (mb)', totalSize / 1024 / 1024)
console.log(
'size per data type (mb, %)',
Object.fromEntries(Object.entries(sizePerDataType).map(([dataType, size]) => {
return [dataType, [size / 1024 / 1024, Math.round(size / totalSize * 100)]];
return [dataType, [size / 1024 / 1024, Math.round(size / totalSize * 100)]]
}).sort((a, b) => {
//@ts-ignore
return b[1][1] - a[1][1];
return b[1][1] - a[1][1]
}))
)
function compressToBase64(input) {
const buffer = gzipSync(input);
return buffer.toString('base64');
const buffer = gzipSync(input)
return buffer.toString('base64')
}
const filePath = './generated/minecraft-data-optimized.json'
@ -330,6 +371,7 @@ console.log('size', fs.lstatSync(filePath).size / 1000 / 1000, gzipSizeFromFileS
const { defaultVersion } = MCProtocol
const data = MinecraftData(defaultVersion)
console.log('defaultVersion', defaultVersion, !!data)
const initialMcData = {
[defaultVersion]: {
version: data.version,

137
scripts/patchAssets.ts Normal file
View file

@ -0,0 +1,137 @@
import blocksAtlas from 'mc-assets/dist/blocksAtlases.json'
import itemsAtlas from 'mc-assets/dist/itemsAtlases.json'
import * as fs from 'fs'
import * as path from 'path'
import sharp from 'sharp'
interface AtlasFile {
latest: {
suSv: number
tileSize: number
width: number
height: number
textures: {
[key: string]: {
u: number
v: number
su: number
sv: number
tileIndex: number
}
}
}
}
async function patchTextureAtlas(
atlasType: 'blocks' | 'items',
atlasData: AtlasFile,
customTexturesDir: string,
distDir: string
) {
// Check if custom textures directory exists and has files
if (!fs.existsSync(customTexturesDir) || fs.readdirSync(customTexturesDir).length === 0) {
return
}
// Find the latest atlas file
const atlasFiles = fs.readdirSync(distDir)
.filter(file => file.startsWith(`${atlasType}AtlasLatest`) && file.endsWith('.png'))
.sort()
if (atlasFiles.length === 0) {
console.log(`No ${atlasType}AtlasLatest.png found in ${distDir}`)
return
}
const latestAtlasFile = atlasFiles[atlasFiles.length - 1]
const atlasPath = path.join(distDir, latestAtlasFile)
console.log(`Patching ${atlasPath}`)
// Get atlas dimensions
const atlasMetadata = await sharp(atlasPath).metadata()
if (!atlasMetadata.width || !atlasMetadata.height) {
throw new Error(`Failed to get atlas dimensions for ${atlasPath}`)
}
// Process each custom texture
const customTextureFiles = fs.readdirSync(customTexturesDir)
.filter(file => file.endsWith('.png'))
if (customTextureFiles.length === 0) return
// Prepare composite operations
const composites: sharp.OverlayOptions[] = []
for (const textureFile of customTextureFiles) {
const textureName = path.basename(textureFile, '.png')
if (atlasData.latest.textures[textureName]) {
const textureData = atlasData.latest.textures[textureName]
const customTexturePath = path.join(customTexturesDir, textureFile)
try {
// Convert UV coordinates to pixel coordinates
const x = Math.round(textureData.u * atlasMetadata.width)
const y = Math.round(textureData.v * atlasMetadata.height)
const width = Math.round((textureData.su ?? atlasData.latest.suSv) * atlasMetadata.width)
const height = Math.round((textureData.sv ?? atlasData.latest.suSv) * atlasMetadata.height)
// Resize custom texture to match atlas dimensions and add to composite operations
const resizedTextureBuffer = await sharp(customTexturePath)
.resize(width, height, {
fit: 'fill',
kernel: 'nearest' // Preserve pixel art quality
})
.png()
.toBuffer()
composites.push({
input: resizedTextureBuffer,
left: x,
top: y,
blend: 'over'
})
console.log(`Prepared ${textureName} at (${x}, ${y}) with size (${width}, ${height})`)
} catch (error) {
console.error(`Failed to prepare ${textureName}:`, error)
}
} else {
console.warn(`Texture ${textureName} not found in ${atlasType} atlas`)
}
}
if (composites.length > 0) {
// Apply all patches at once using Sharp's composite
await sharp(atlasPath)
.composite(composites)
.png()
.toFile(atlasPath + '.tmp')
// Replace original with patched version
fs.renameSync(atlasPath + '.tmp', atlasPath)
console.log(`Saved patched ${atlasType} atlas to ${atlasPath}`)
}
}
async function main() {
const customBlocksDir = './assets/customTextures/blocks'
const customItemsDir = './assets/customTextures/items'
const distDir = './dist/static/image'
try {
// Patch blocks atlas
await patchTextureAtlas('blocks', blocksAtlas as unknown as AtlasFile, customBlocksDir, distDir)
// Patch items atlas
await patchTextureAtlas('items', itemsAtlas as unknown as AtlasFile, customItemsDir, distDir)
console.log('Texture atlas patching completed!')
} catch (error) {
console.error('Failed to patch texture atlases:', error)
process.exit(1)
}
}
// Run the script
main()

42
scripts/requestData.ts Normal file
View file

@ -0,0 +1,42 @@
import WebSocket from 'ws'
function formatBytes(bytes: number) {
return `${(bytes).toFixed(2)} MB`
}
function formatTime(ms: number) {
return `${(ms / 1000).toFixed(2)}s`
}
const ws = new WebSocket('ws://localhost:8081')
ws.on('open', () => {
console.log('Connected to metrics server, waiting for metrics...')
})
ws.on('message', (data) => {
try {
const metrics = JSON.parse(data.toString())
console.log('\nPerformance Metrics:')
console.log('------------------')
console.log(`Load Time: ${formatTime(metrics.loadTime)}`)
console.log(`Memory Usage: ${formatBytes(metrics.memoryUsage)}`)
console.log(`Timestamp: ${new Date(metrics.timestamp).toLocaleString()}`)
if (!process.argv.includes('-f')) { // follow mode
process.exit(0)
}
} catch (error) {
console.error('Error parsing metrics:', error)
}
})
ws.on('error', (error) => {
console.error('WebSocket error:', error)
process.exit(1)
})
// Exit if no metrics received after 5 seconds
setTimeout(() => {
console.error('Timeout waiting for metrics')
process.exit(1)
}, 5000)

45
scripts/wsServer.ts Normal file
View file

@ -0,0 +1,45 @@
import {WebSocketServer} from 'ws'
export function startWsServer(port: number = 8081, tryOtherPort: boolean = true): Promise<number> {
return new Promise((resolve, reject) => {
const tryPort = (currentPort: number) => {
const wss = new WebSocketServer({ port: currentPort })
.on('listening', () => {
console.log(`WebSocket server started on port ${currentPort}`)
resolve(currentPort)
})
.on('error', (err: any) => {
if (err.code === 'EADDRINUSE' && tryOtherPort) {
console.log(`Port ${currentPort} in use, trying ${currentPort + 1}`)
wss.close()
tryPort(currentPort + 1)
} else {
reject(err)
}
})
wss.on('connection', (ws) => {
console.log('Client connected')
ws.on('message', (message) => {
try {
// Simply relay the message to all connected clients except sender
wss.clients.forEach(client => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(message.toString())
}
})
} catch (error) {
console.error('Error processing message:', error)
}
})
ws.on('close', () => {
console.log('Client disconnected')
})
})
}
tryPort(port)
})
}

View file

@ -24,6 +24,7 @@ export type MobileButtonConfig = {
readonly icon?: string
readonly action?: ActionType
readonly actionHold?: ActionType | ActionHoldConfig
readonly iconStyle?: React.CSSProperties
}
export type AppConfig = {
@ -34,7 +35,7 @@ export type AppConfig = {
// defaultVersion?: string
peerJsServer?: string
peerJsServerFallback?: string
promoteServers?: Array<{ ip, description, version? }>
promoteServers?: Array<{ ip, description, name?, version?, }>
mapsProvider?: string
appParams?: Record<string, any> // query string params
@ -57,9 +58,11 @@ export type AppConfig = {
skinTexturesProxy?: string
alwaysReconnectButton?: boolean
reportBugButtonWithReconnect?: boolean
disabledCommands?: string[] // Array of command IDs to disable (e.g. ['general.jump', 'general.chat'])
}
export const loadAppConfig = (appConfig: AppConfig) => {
if (miscUiState.appConfig) {
Object.assign(miscUiState.appConfig, appConfig)
} else {
@ -71,7 +74,7 @@ export const loadAppConfig = (appConfig: AppConfig) => {
if (value) {
disabledSettings.value.add(key)
// since the setting is forced, we need to set it to that value
if (appConfig.defaultSettings?.[key] && !qsOptions[key]) {
if (appConfig.defaultSettings && key in appConfig.defaultSettings && !qsOptions[key]) {
options[key] = appConfig.defaultSettings[key]
}
} else {
@ -79,6 +82,7 @@ export const loadAppConfig = (appConfig: AppConfig) => {
}
}
}
// todo apply defaultSettings to defaults even if not forced in case of remote config
if (appConfig.keybindings) {
Object.assign(customKeymaps, defaultsDeep(appConfig.keybindings, customKeymaps))

View file

@ -12,6 +12,7 @@ export type AppQsParams = {
username?: string
lockConnect?: string
autoConnect?: string
alwaysReconnect?: string
// googledrive.ts params
state?: string
// ServersListProvider.tsx params
@ -46,6 +47,7 @@ export type AppQsParams = {
connectText?: string
freezeSettings?: string
testIosCrash?: string
addPing?: string
// Replay params
replayFilter?: string

View file

@ -8,6 +8,7 @@ import { proxy, subscribe } from 'valtio'
import { getDefaultRendererState } from 'renderer/viewer/baseGraphicsBackend'
import { getSyncWorld } from 'renderer/playground/shared'
import { MaybePromise } from 'contro-max/build/types/store'
import { PANORAMA_VERSION } from 'renderer/viewer/three/panoramaShared'
import { playerState } from './mineflayer/playerState'
import { createNotificationProgressReporter, ProgressReporter } from './core/progressReporter'
import { setLoadingScreenStatus } from './appStatus'
@ -15,6 +16,9 @@ import { activeModalStack, miscUiState } from './globalState'
import { options } from './optionsStorage'
import { ResourcesManager, ResourcesManagerTransferred } from './resourcesManager'
import { watchOptionsAfterWorldViewInit } from './watchOptions'
import { loadMinecraftData } from './connect'
import { reloadChunks } from './utils'
import { displayClientChat } from './botUtils'
export interface RendererReactiveState {
world: {
@ -112,7 +116,7 @@ export class AppViewer {
inWorldRenderingConfig: WorldRendererConfig = proxy(defaultWorldRendererConfig)
lastCamUpdate = 0
playerState = playerState
rendererState = proxy(getDefaultRendererState().reactive)
rendererState = getDefaultRendererState().reactive
nonReactiveState: NonReactiveState = getDefaultRendererState().nonReactive
worldReady: Promise<void>
private resolveWorldReady: () => void
@ -162,11 +166,15 @@ export class AppViewer {
// Execute queued action if exists
if (this.currentState) {
const { method, args } = this.currentState
this.backend[method](...args)
if (method === 'startWorld') {
void this.worldView!.init(bot.entity.position)
// void this.worldView!.init(args[0].playerState.getPosition())
if (this.currentState.method === 'startPanorama') {
this.startPanorama()
} else {
const { method, args } = this.currentState
this.backend[method](...args)
if (method === 'startWorld') {
void this.worldView!.init(bot.entity.position)
// void this.worldView!.init(args[0].playerState.getPosition())
}
}
}
@ -191,6 +199,13 @@ export class AppViewer {
this.currentDisplay = 'world'
const startPosition = bot.entity?.position ?? new Vec3(0, 64, 0)
this.worldView = new WorldDataEmitter(world, renderDistance, startPosition)
this.worldView.panicChunksReload = () => {
if (!options.experimentalClientSelfReload) return
if (process.env.NODE_ENV === 'development') {
displayClientChat(`[client] client panicked due to too long loading time. Soft reloading chunks...`)
}
void reloadChunks()
}
window.worldView = this.worldView
watchOptionsAfterWorldViewInit(this.worldView)
this.appConfigUdpate()
@ -225,10 +240,16 @@ export class AppViewer {
startPanorama () {
if (this.currentDisplay === 'menu') return
this.currentDisplay = 'menu'
if (options.disableAssets) return
if (this.backend) {
this.backend.startPanorama()
if (this.backend && !hasAppStatus()) {
this.currentDisplay = 'menu'
if (process.env.SINGLE_FILE_BUILD_MODE) {
void loadMinecraftData(PANORAMA_VERSION).then(() => {
this.backend?.startPanorama()
})
} else {
this.backend.startPanorama()
}
}
this.currentState = { method: 'startPanorama', args: [] }
}
@ -316,15 +337,16 @@ const initialMenuStart = async () => {
}
window.initialMenuStart = initialMenuStart
const hasAppStatus = () => activeModalStack.some(m => m.reactType === 'app-status')
const modalStackUpdateChecks = () => {
// maybe start panorama
if (!miscUiState.gameLoaded) {
if (!miscUiState.gameLoaded && !hasAppStatus()) {
void initialMenuStart()
}
if (appViewer.backend) {
const hasAppStatus = activeModalStack.some(m => m.reactType === 'app-status')
appViewer.backend.setRendering(!hasAppStatus)
appViewer.backend.setRendering(!hasAppStatus())
}
appViewer.inWorldRenderingConfig.foreground = activeModalStack.length === 0

View file

@ -7,7 +7,12 @@ let audioContext: AudioContext
const sounds: Record<string, any> = {}
// Track currently playing sounds and their gain nodes
const activeSounds: Array<{ source: AudioBufferSourceNode; gainNode: GainNode; volumeMultiplier: number }> = []
const activeSounds: Array<{
source: AudioBufferSourceNode;
gainNode: GainNode;
volumeMultiplier: number;
isMusic: boolean;
}> = []
window.activeSounds = activeSounds
// load as many resources on page load as possible instead on demand as user can disable internet connection after he thinks the page is loaded
@ -43,7 +48,7 @@ export async function loadSound (path: string, contents = path) {
}
}
export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = 500) => {
export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = options.remoteSoundsLoadTimeout, loop = false, isMusic = false) => {
const soundBuffer = sounds[url]
if (!soundBuffer) {
const start = Date.now()
@ -51,11 +56,11 @@ export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = 500) =
if (cancelled || Date.now() - start > loadTimeout) return
}
return playSound(url, soundVolume)
return playSound(url, soundVolume, loop, isMusic)
}
export async function playSound (url, soundVolume = 1) {
const volume = soundVolume * (options.volume / 100)
export async function playSound (url, soundVolume = 1, loop = false, isMusic = false) {
const volume = soundVolume * (options.volume / 100) * (isMusic ? options.musicVolume / 100 : 1)
if (!volume) return
@ -75,13 +80,14 @@ export async function playSound (url, soundVolume = 1) {
const gainNode = audioContext.createGain()
const source = audioContext.createBufferSource()
source.buffer = soundBuffer
source.loop = loop
source.connect(gainNode)
gainNode.connect(audioContext.destination)
gainNode.gain.value = volume
source.start(0)
// Add to active sounds
activeSounds.push({ source, gainNode, volumeMultiplier: soundVolume })
activeSounds.push({ source, gainNode, volumeMultiplier: soundVolume, isMusic })
const callbacks = [] as Array<() => void>
source.onended = () => {
@ -99,6 +105,17 @@ export async function playSound (url, soundVolume = 1) {
onEnded (callback: () => void) {
callbacks.push(callback)
},
stop () {
try {
source.stop()
// Remove from active sounds
const index = activeSounds.findIndex(s => s.source === source)
if (index !== -1) activeSounds.splice(index, 1)
} catch (err) {
console.warn('Failed to stop sound:', err)
}
},
gainNode,
}
}
@ -113,11 +130,24 @@ export function stopAllSounds () {
activeSounds.length = 0
}
export function changeVolumeOfCurrentlyPlayingSounds (newVolume: number) {
const normalizedVolume = newVolume / 100
for (const { gainNode, volumeMultiplier } of activeSounds) {
export function stopSound (url: string) {
const soundIndex = activeSounds.findIndex(s => s.source.buffer === sounds[url])
if (soundIndex !== -1) {
const { source } = activeSounds[soundIndex]
try {
gainNode.gain.value = normalizedVolume * volumeMultiplier
source.stop()
} catch (err) {
console.warn('Failed to stop sound:', err)
}
activeSounds.splice(soundIndex, 1)
}
}
export function changeVolumeOfCurrentlyPlayingSounds (newVolume: number, newMusicVolume: number) {
const normalizedVolume = newVolume / 100
for (const { gainNode, volumeMultiplier, isMusic } of activeSounds) {
try {
gainNode.gain.value = normalizedVolume * volumeMultiplier * (isMusic ? newMusicVolume / 100 : 1)
} catch (err) {
console.warn('Failed to change sound volume:', err)
}
@ -125,5 +155,9 @@ export function changeVolumeOfCurrentlyPlayingSounds (newVolume: number) {
}
subscribeKey(options, 'volume', () => {
changeVolumeOfCurrentlyPlayingSounds(options.volume)
changeVolumeOfCurrentlyPlayingSounds(options.volume, options.musicVolume)
})
subscribeKey(options, 'musicVolume', () => {
changeVolumeOfCurrentlyPlayingSounds(options.volume, options.musicVolume)
})

View file

@ -118,6 +118,14 @@ export const formatMessage = (message: MessageInput, mcData: IndexedData = globa
return msglist
}
export const messageToString = (message: MessageInput | string) => {
if (typeof message === 'string') {
return message
}
const msglist = formatMessage(message)
return msglist.map(msg => msg.text).join('')
}
const blockToItemRemaps = {
water: 'water_bucket',
lava: 'lava_bucket',

View file

@ -3,7 +3,6 @@
import MinecraftData from 'minecraft-data'
import PrismarineBlock from 'prismarine-block'
import PrismarineItem from 'prismarine-item'
import pathfinder from 'mineflayer-pathfinder'
import { miscUiState } from './globalState'
import supportedVersions from './supportedVersions.mjs'
import { options } from './optionsStorage'
@ -65,7 +64,6 @@ export const loadMinecraftData = async (version: string) => {
window.PrismarineItem = PrismarineItem(mcData.version.minecraftVersion!)
window.loadedData = mcData
window.mcData = mcData
window.pathfinder = pathfinder
miscUiState.loadedDataVersion = version
}

View file

@ -116,6 +116,10 @@ export const contro = new ControMax({
window.controMax = contro
export type Command = CommandEventArgument<typeof contro['_commandsRaw']>['command']
export const isCommandDisabled = (command: Command) => {
return miscUiState.appConfig?.disabledCommands?.includes(command)
}
onControInit()
updateBinds(customKeymaps)
@ -544,6 +548,8 @@ const customCommandsHandler = ({ command }) => {
contro.on('trigger', customCommandsHandler)
contro.on('trigger', ({ command }) => {
if (isCommandDisabled(command)) return
const willContinue = !isGameActive(true)
alwaysPressedHandledCommand(command)
if (willContinue) return
@ -677,6 +683,8 @@ contro.on('trigger', ({ command }) => {
})
contro.on('release', ({ command }) => {
if (isCommandDisabled(command)) return
inModalCommand(command, false)
onTriggerOrReleased(command, false)
})
@ -810,6 +818,11 @@ export const f3Keybinds: Array<{
}
]
export const reloadChunksAction = () => {
const action = f3Keybinds.find(f3Keybind => f3Keybind.key === 'KeyA')
void action!.action()
}
document.addEventListener('keydown', (e) => {
if (!isGameActive(false)) return
if (contro.pressedKeys.has('F3')) {
@ -979,14 +992,17 @@ export function updateBinds (commands: any) {
}
export const onF3LongPress = async () => {
const select = await showOptionsModal('', f3Keybinds.filter(f3Keybind => {
const actions = f3Keybinds.filter(f3Keybind => {
return f3Keybind.mobileTitle && (f3Keybind.enabled?.() ?? true)
}).map(f3Keybind => {
})
const actionNames = actions.map(f3Keybind => {
return `${f3Keybind.mobileTitle}${f3Keybind.key ? ` (F3+${f3Keybind.key})` : ''}`
}))
})
const select = await showOptionsModal('', actionNames)
if (!select) return
const f3Keybind = f3Keybinds.find(f3Keybind => f3Keybind.mobileTitle === select)
if (f3Keybind) void f3Keybind.action()
const actionIndex = actionNames.indexOf(select)
const f3Keybind = actions[actionIndex]!
void f3Keybind.action()
}
export const handleMobileButtonCustomAction = (action: CustomAction) => {
@ -996,9 +1012,16 @@ export const handleMobileButtonCustomAction = (action: CustomAction) => {
}
}
export const triggerCommand = (command: Command, isDown: boolean) => {
handleMobileButtonActionCommand(command, isDown)
}
export const handleMobileButtonActionCommand = (command: ActionType | ActionHoldConfig, isDown: boolean) => {
const commandValue = typeof command === 'string' ? command : 'command' in command ? command.command : command
// Check if command is disabled before proceeding
if (typeof commandValue === 'string' && isCommandDisabled(commandValue as Command)) return
if (typeof commandValue === 'string' && !stringStartsWith(commandValue, 'custom')) {
const event: CommandEventArgument<typeof contro['_commandsRaw']> = {
command: commandValue as Command,

106
src/core/ideChannels.ts Normal file
View file

@ -0,0 +1,106 @@
import { proxy } from 'valtio'
export const ideState = proxy({
id: '',
contents: '',
line: 0,
column: 0,
language: 'typescript',
title: '',
})
globalThis.ideState = ideState
export const registerIdeChannels = () => {
registerIdeOpenChannel()
registerIdeSaveChannel()
}
const registerIdeOpenChannel = () => {
const CHANNEL_NAME = 'minecraft-web-client:ide-open'
const packetStructure = [
'container',
[
{
name: 'id',
type: ['pstring', { countType: 'i16' }]
},
{
name: 'language',
type: ['pstring', { countType: 'i16' }]
},
{
name: 'contents',
type: ['pstring', { countType: 'i16' }]
},
{
name: 'line',
type: 'i32'
},
{
name: 'column',
type: 'i32'
},
{
name: 'title',
type: ['pstring', { countType: 'i16' }]
}
]
]
bot._client.registerChannel(CHANNEL_NAME, packetStructure, true)
bot._client.on(CHANNEL_NAME as any, (data) => {
const { id, language, contents, line, column, title } = data
ideState.contents = contents
ideState.line = line
ideState.column = column
ideState.id = id
ideState.language = language || 'typescript'
ideState.title = title
})
console.debug(`registered custom channel ${CHANNEL_NAME} channel`)
}
const IDE_SAVE_CHANNEL_NAME = 'minecraft-web-client:ide-save'
const registerIdeSaveChannel = () => {
const packetStructure = [
'container',
[
{
name: 'id',
type: ['pstring', { countType: 'i16' }]
},
{
name: 'contents',
type: ['pstring', { countType: 'i16' }]
},
{
name: 'language',
type: ['pstring', { countType: 'i16' }]
},
{
name: 'line',
type: 'i32'
},
{
name: 'column',
type: 'i32'
},
]
]
bot._client.registerChannel(IDE_SAVE_CHANNEL_NAME, packetStructure, true)
}
export const saveIde = () => {
bot._client.writeChannel(IDE_SAVE_CHANNEL_NAME, {
id: ideState.id,
contents: ideState.contents,
language: ideState.language,
// todo: reflect updated
line: ideState.line,
column: ideState.column,
})
}

View file

@ -1,121 +0,0 @@
class LatencyMonitor {
private ws: WebSocket | null = null
private isConnected = false
constructor (public serverUrl: string) {
}
async connect () {
return new Promise<void>((resolve, reject) => {
// Convert http(s):// to ws(s)://
let wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/vm/net/ping'
if (!wsUrl.startsWith('ws')) {
wsUrl = 'wss://' + wsUrl
}
this.ws = new WebSocket(wsUrl)
this.ws.onopen = () => {
this.isConnected = true
resolve()
}
this.ws.onerror = (error) => {
reject(error)
}
})
}
async measureLatency (): Promise<{
roundTripTime: number;
serverProcessingTime: number;
networkLatency: number;
}> {
return new Promise((resolve, reject) => {
if (!this.isConnected) {
reject(new Error('Not connected'))
return
}
const pingId = Date.now().toString()
const startTime = performance.now()
const handler = (event: MessageEvent) => {
if (typeof event.data === 'string' && event.data.startsWith('pong:')) {
const [_, receivedPingId, serverProcessingTime] = event.data.split(':')
if (receivedPingId === pingId) {
this.ws?.removeEventListener('message', handler)
const roundTripTime = performance.now() - startTime
resolve({
roundTripTime,
serverProcessingTime: parseFloat(serverProcessingTime),
networkLatency: roundTripTime - parseFloat(serverProcessingTime)
})
}
}
}
this.ws?.addEventListener('message', handler)
this.ws?.send('ping:' + pingId)
})
}
disconnect () {
if (this.ws) {
this.ws.close()
this.isConnected = false
}
}
}
export async function pingProxyServer (serverUrl: string, abortSignal?: AbortSignal) {
try {
const monitor = new LatencyMonitor(serverUrl)
if (abortSignal) {
abortSignal.addEventListener('abort', () => {
monitor.disconnect()
})
}
await monitor.connect()
const latency = await monitor.measureLatency()
monitor.disconnect()
return {
success: true,
latency: Math.round(latency.networkLatency)
}
} catch (err) {
let msg = String(err)
if (err instanceof Event && err.type === 'error') {
msg = 'Connection error'
}
return {
success: false,
error: msg
}
}
}
export async function monitorLatency () {
const monitor = new LatencyMonitor('https://your-server.com')
try {
await monitor.connect()
// Single measurement
const latency = await monitor.measureLatency()
// Or continuous monitoring
setInterval(async () => {
try {
const latency = await monitor.measureLatency()
console.log('Current latency:', latency)
} catch (error) {
console.error('Error measuring latency:', error)
}
}, 5000) // Check every 5 seconds
} catch (error) {
console.error('Failed to connect:', error)
}
}

View file

@ -1,91 +0,0 @@
import { proxy } from 'valtio'
import { appStorage } from '../react/appStorageProvider'
import { pingProxyServer } from './pingProxy'
export interface ProxyPingState {
selectedProxy: string | null
proxyStatus: Record<string, {
status: 'checking' | 'success' | 'error'
latency?: number
error?: string
}>
checkStarted: boolean
}
export const proxyPingState = proxy<ProxyPingState>({
selectedProxy: null,
proxyStatus: {},
checkStarted: false
})
let currentPingAbortController: AbortController | null = null
export async function selectBestProxy (proxies: string[]): Promise<string | null> {
if (proxyPingState.checkStarted) {
cancelProxyPinging()
}
proxyPingState.checkStarted = true
// Cancel any ongoing pings
if (currentPingAbortController) {
currentPingAbortController.abort()
}
currentPingAbortController = new AbortController()
const abortController = currentPingAbortController // Store in local const to satisfy TypeScript
// Reset ping states
for (const proxy of proxies) {
proxyPingState.proxyStatus[proxy] = { status: 'checking' }
}
try {
// Create a promise for each proxy
const pingPromises = proxies.map(async (proxy) => {
if (proxy.startsWith(':')) {
proxy = `${location.protocol}//${location.hostname}${proxy}`
}
try {
const result = await pingProxyServer(proxy, abortController.signal)
if (result.success) {
proxyPingState.proxyStatus[proxy] = { status: 'success', latency: result.latency }
return { proxy, latency: result.latency }
} else {
proxyPingState.proxyStatus[proxy] = { status: 'error', error: result.error }
return null
}
} catch (err) {
proxyPingState.proxyStatus[proxy] = { status: 'error', error: String(err) }
return null
}
})
// Use Promise.race to get the first successful response
const results = await Promise.race([
// Wait for first successful ping
Promise.any(pingPromises.map(async p => p.then(r => r && { type: 'success' as const, data: r }))),
// Or wait for all to fail
Promise.all(pingPromises).then(results => {
if (results.every(r => r === null)) {
return { type: 'all-failed' as const }
}
return null
})
])
if (!results || results.type === 'all-failed') {
return null
}
return results.type === 'success' ? results.data.proxy : null
} finally {
currentPingAbortController = null
proxyPingState.checkStarted = false
}
}
export function cancelProxyPinging () {
if (currentPingAbortController) {
currentPingAbortController.abort()
currentPingAbortController = null
}
}

View file

@ -2,19 +2,20 @@ import PItem from 'prismarine-item'
import { getThreeJsRendererMethods } from 'renderer/viewer/three/threeJsMethods'
import { options } from './optionsStorage'
import { jeiCustomCategories } from './inventoryWindows'
import { registerIdeChannels } from './core/ideChannels'
export default () => {
customEvents.on('mineflayerBotCreated', async () => {
if (!options.customChannels) return
await new Promise(resolve => {
bot.once('login', () => {
resolve(true)
})
bot.once('login', () => {
registerBlockModelsChannel()
registerMediaChannels()
registerSectionAnimationChannels()
registeredJeiChannel()
registerBlockInteractionsCustomizationChannel()
registerWaypointChannels()
registerIdeChannels()
})
registerBlockModelsChannel()
registerMediaChannels()
registerSectionAnimationChannels()
registeredJeiChannel()
})
}
@ -32,6 +33,95 @@ const registerChannel = (channelName: string, packetStructure: any[], handler: (
console.debug(`registered custom channel ${channelName} channel`)
}
const registerBlockInteractionsCustomizationChannel = () => {
const CHANNEL_NAME = 'minecraft-web-client:block-interactions-customization'
const packetStructure = [
'container',
[
{
name: 'newConfiguration',
type: ['pstring', { countType: 'i16' }]
},
]
]
registerChannel(CHANNEL_NAME, packetStructure, (data) => {
const config = JSON.parse(data.newConfiguration)
bot.mouse.setConfigFromPacket(config)
}, true)
}
const registerWaypointChannels = () => {
const packetStructure = [
'container',
[
{
name: 'id',
type: ['pstring', { countType: 'i16' }]
},
{
name: 'x',
type: 'f32'
},
{
name: 'y',
type: 'f32'
},
{
name: 'z',
type: 'f32'
},
{
name: 'minDistance',
type: 'i32'
},
{
name: 'label',
type: ['pstring', { countType: 'i16' }]
},
{
name: 'color',
type: 'i32'
},
{
name: 'metadataJson',
type: ['pstring', { countType: 'i16' }]
}
]
]
registerChannel('minecraft-web-client:waypoint-add', packetStructure, (data) => {
// Parse metadata if provided
let metadata: any = {}
if (data.metadataJson && data.metadataJson.trim() !== '') {
try {
metadata = JSON.parse(data.metadataJson)
} catch (error) {
console.warn('Failed to parse waypoint metadataJson:', error)
}
}
getThreeJsRendererMethods()?.addWaypoint(data.id, data.x, data.y, data.z, {
minDistance: data.minDistance,
label: data.label || undefined,
color: data.color || undefined,
metadata
})
})
registerChannel('minecraft-web-client:waypoint-delete', [
'container',
[
{
name: 'id',
type: ['pstring', { countType: 'i16' }]
}
]
], (data) => {
getThreeJsRendererMethods()?.removeWaypoint(data.id)
})
}
const registerBlockModelsChannel = () => {
const CHANNEL_NAME = 'minecraft-web-client:blockmodels'

View file

@ -1,6 +1,7 @@
//@ts-check
import * as nbt from 'prismarine-nbt'
import { options } from './optionsStorage'
//@ts-check
const { EventEmitter } = require('events')
const debug = require('debug')('minecraft-protocol')
const states = require('minecraft-protocol/src/states')
@ -51,8 +52,20 @@ class CustomChannelClient extends EventEmitter {
this.emit('state', newProperty, oldProperty)
}
end(reason) {
this._endReason = reason
end(endReason, fullReason) {
// eslint-disable-next-line unicorn/no-this-assignment
const client = this
if (client.state === states.PLAY) {
fullReason ||= loadedData.supportFeature('chatPacketsUseNbtComponents')
? nbt.comp({ text: nbt.string(endReason) })
: JSON.stringify({ text: endReason })
client.write('kick_disconnect', { reason: fullReason })
} else if (client.state === states.LOGIN) {
fullReason ||= JSON.stringify({ text: endReason })
client.write('disconnect', { reason: fullReason })
}
this._endReason = endReason
this.emit('end', this._endReason) // still emits on server side only, doesn't send anything to our client
}

View file

@ -1,46 +0,0 @@
import { options } from './optionsStorage'
import { assertDefined } from './utils'
import { updateBackground } from './water'
export default () => {
const timeUpdated = () => {
// 0 morning
const dayTotal = 24_000
const evening = 11_500
const night = 13_500
const morningStart = 23_000
const morningEnd = 23_961
const timeProgress = options.dayCycleAndLighting ? bot.time.timeOfDay : 0
// todo check actual colors
const dayColorRainy = { r: 111 / 255, g: 156 / 255, b: 236 / 255 }
// todo yes, we should make animations (and rain)
// eslint-disable-next-line unicorn/numeric-separators-style
const dayColor = bot.isRaining ? dayColorRainy : { r: 0.6784313725490196, g: 0.8470588235294118, b: 0.9019607843137255 } // lightblue
// let newColor = dayColor
let int = 1
if (timeProgress < evening) {
// stay dayily
} else if (timeProgress < night) {
const progressNorm = timeProgress - evening
const progressMax = night - evening
int = 1 - progressNorm / progressMax
} else if (timeProgress < morningStart) {
int = 0
} else if (timeProgress < morningEnd) {
const progressNorm = timeProgress - morningStart
const progressMax = night - morningEnd
int = progressNorm / progressMax
}
// todo need to think wisely how to set these values & also move directional light around!
const colorInt = Math.max(int, 0.1)
updateBackground({ r: dayColor.r * colorInt, g: dayColor.g * colorInt, b: dayColor.b * colorInt })
if (!options.newVersionsLighting && bot.supportFeature('blockStateId')) {
appViewer.playerState.reactive.ambientLight = Math.max(int, 0.25)
appViewer.playerState.reactive.directionalLight = Math.min(int, 0.5)
}
}
bot.on('time', timeUpdated)
timeUpdated()
}

View file

@ -16,9 +16,11 @@ export const defaultOptions = {
chatOpacityOpened: 100,
messagesLimit: 200,
volume: 50,
enableMusic: false,
enableMusic: true,
musicVolume: 50,
// fov: 70,
fov: 75,
defaultPerspective: 'first_person' as 'first_person' | 'third_person_back' | 'third_person_front',
guiScale: 3,
autoRequestCompletions: true,
touchButtonsSize: 40,
@ -40,6 +42,7 @@ export const defaultOptions = {
renderEars: true,
lowMemoryMode: false,
starfieldRendering: true,
defaultSkybox: true,
enabledResourcepack: null as string | null,
useVersionsTextures: 'latest',
serverResourcePacks: 'prompt' as 'prompt' | 'always' | 'never',
@ -76,13 +79,17 @@ export const defaultOptions = {
frameLimit: false as number | false,
alwaysBackupWorldBeforeLoading: undefined as boolean | undefined | null,
alwaysShowMobileControls: false,
excludeCommunicationDebugEvents: [],
excludeCommunicationDebugEvents: [] as string[],
preventDevReloadWhilePlaying: false,
numWorkers: 4,
localServerOptions: {
gameMode: 1
} as any,
saveLoginPassword: 'prompt' as 'prompt' | 'never' | 'always',
preferLoadReadonly: false,
experimentalClientSelfReload: false,
remoteSoundsSupport: false,
remoteSoundsLoadTimeout: 500,
disableLoadPrompts: false,
guestUsername: 'guest',
askGuestName: true,

View file

@ -5,6 +5,17 @@ import { WorldRendererThree } from 'renderer/viewer/three/worldrendererThree'
import { enable, disable, enabled } from 'debug'
import { Vec3 } from 'vec3'
customEvents.on('mineflayerBotCreated', () => {
window.debugServerPacketNames = Object.fromEntries(Object.keys(loadedData.protocol.play.toClient.types).map(name => {
name = name.replace('packet_', '')
return [name, name]
}))
window.debugClientPacketNames = Object.fromEntries(Object.keys(loadedData.protocol.play.toServer.types).map(name => {
name = name.replace('packet_', '')
return [name, name]
}))
})
window.Vec3 = Vec3
window.cursorBlockRel = (x = 0, y = 0, z = 0) => {
const newPos = bot.blockAtCursor(5)?.position.offset(x, y, z)
@ -209,3 +220,105 @@ setInterval(() => {
}, 1000)
// ---
// Add type declaration for performance.memory
declare global {
interface Performance {
memory?: {
usedJSHeapSize: number
totalJSHeapSize: number
jsHeapSizeLimit: number
}
}
}
// Performance metrics WebSocket client
let ws: WebSocket | null = null
let wsReconnectTimeout: NodeJS.Timeout | null = null
let metricsInterval: NodeJS.Timeout | null = null
// Start collecting metrics immediately
const startTime = performance.now()
function collectAndSendMetrics () {
if (!ws || ws.readyState !== WebSocket.OPEN) return
const metrics = {
loadTime: performance.now() - startTime,
memoryUsage: (performance.memory?.usedJSHeapSize ?? 0) / 1024 / 1024,
timestamp: Date.now()
}
ws.send(JSON.stringify(metrics))
}
function getWebSocketUrl () {
const wsPort = process.env.WS_PORT
if (!wsPort) return null
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const { hostname } = window.location
return `${protocol}//${hostname}:${wsPort}`
}
function connectWebSocket () {
if (ws) return
const wsUrl = getWebSocketUrl()
if (!wsUrl) {
return
}
ws = new WebSocket(wsUrl)
ws.onopen = () => {
console.log('Connected to metrics server')
if (wsReconnectTimeout) {
clearTimeout(wsReconnectTimeout)
wsReconnectTimeout = null
}
// Start sending metrics immediately after connection
collectAndSendMetrics()
// Clear existing interval if any
if (metricsInterval) {
clearInterval(metricsInterval)
}
// Set new interval
metricsInterval = setInterval(collectAndSendMetrics, 500)
}
ws.onclose = () => {
console.log('Disconnected from metrics server')
ws = null
// Clear metrics interval
if (metricsInterval) {
clearInterval(metricsInterval)
metricsInterval = null
}
// Try to reconnect after 3 seconds
wsReconnectTimeout = setTimeout(connectWebSocket, 3000)
}
ws.onerror = (error) => {
console.error('WebSocket error:', error)
}
}
// Connect immediately
connectWebSocket()
// Add command to request current metrics
window.requestMetrics = () => {
const metrics = {
loadTime: performance.now() - startTime,
memoryUsage: (performance.memory?.usedJSHeapSize ?? 0) / 1024 / 1024,
timestamp: Date.now()
}
console.log('Current metrics:', metrics)
return metrics
}

View file

@ -11,6 +11,12 @@ export const getFixedFilesize = (bytes: number) => {
return prettyBytes(bytes, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export const isInterestedInDownload = () => {
const { map, texturepack, replayFileUrl } = appQueryParams
const { mapDir } = appQueryParamsArray
return !!map || !!texturepack || !!replayFileUrl || !!mapDir
}
const inner = async () => {
const { map, texturepack, replayFileUrl } = appQueryParams
const { mapDir } = appQueryParamsArray

View file

@ -3,6 +3,7 @@ import fs from 'fs'
import * as nbt from 'prismarine-nbt'
import RegionFile from 'prismarine-provider-anvil/src/region'
import { versions } from 'minecraft-data'
import { getThreeJsRendererMethods } from 'renderer/viewer/three/threeJsMethods'
import { openWorldDirectory, openWorldZip } from './browserfs'
import { isGameActive } from './globalState'
import { showNotification } from './react/NotificationProvider'
@ -12,6 +13,9 @@ const parseNbt = promisify(nbt.parse)
const simplifyNbt = nbt.simplify
window.nbt = nbt
// Supported image types for skybox
const VALID_IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp']
// todo display drop zone
for (const event of ['drag', 'dragstart', 'dragend', 'dragover', 'dragenter', 'dragleave', 'drop']) {
window.addEventListener(event, (e: any) => {
@ -45,6 +49,34 @@ window.addEventListener('drop', async e => {
})
async function handleDroppedFile (file: File) {
// Check for image files first when game is active
if (isGameActive(false) && VALID_IMAGE_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext))) {
try {
// Convert image to base64
const reader = new FileReader()
const base64Promise = new Promise<string>((resolve, reject) => {
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
})
reader.readAsDataURL(file)
const base64Image = await base64Promise
// Get ThreeJS backend methods and update skybox
const setSkyboxImage = getThreeJsRendererMethods()?.setSkyboxImage
if (setSkyboxImage) {
await setSkyboxImage(base64Image)
showNotification('Skybox updated successfully')
} else {
showNotification('Cannot update skybox - renderer does not support it')
}
return
} catch (err) {
console.error('Failed to update skybox:', err)
showNotification('Failed to update skybox', 'error')
return
}
}
if (file.name.endsWith('.zip')) {
void openWorldZip(file)
return

View file

@ -126,6 +126,28 @@ customEvents.on('gameLoaded', () => {
if (entityStatus === EntityStatus.HURT) {
getThreeJsRendererMethods()?.damageEntity(entityId, entityStatus)
}
if (entityStatus === EntityStatus.BURNED) {
updateEntityStates(entityId, true, true)
}
})
// on fire events
bot._client.on('entity_metadata', (data) => {
if (data.entityId !== bot.entity.id) return
handleEntityMetadata(data)
})
bot.on('end', () => {
if (onFireTimeout) {
clearTimeout(onFireTimeout)
}
})
bot.on('respawn', () => {
if (onFireTimeout) {
clearTimeout(onFireTimeout)
}
})
const updateCamera = (entity: Entity) => {
@ -224,22 +246,29 @@ customEvents.on('gameLoaded', () => {
}
}
// even if not found, still record to cache
void getThreeJsRendererMethods()?.updatePlayerSkin(entityId, player.username, player.uuid, skinUrl ?? true, capeUrl)
void getThreeJsRendererMethods()!.updatePlayerSkin(entityId, player.username, player.uuid, skinUrl ?? true, capeUrl)
} catch (err) {
console.error('Error decoding player texture:', err)
reportError(new Error('Error applying skin texture:', { cause: err }))
}
}
bot.on('playerJoined', updateSkin)
bot.on('playerUpdated', updateSkin)
for (const entity of Object.values(bot.players)) {
updateSkin(entity)
}
bot.on('teamUpdated', (team: Team) => {
const teamUpdated = (team: Team) => {
for (const entity of Object.values(bot.entities)) {
if (entity.type === 'player' && entity.username && team.members.includes(entity.username) || entity.uuid && team.members.includes(entity.uuid)) {
bot.emit('entityUpdate', entity)
}
}
})
}
bot.on('teamUpdated', teamUpdated)
for (const team of Object.values(bot.teams)) {
teamUpdated(team)
}
const updateEntityNameTags = (team: Team) => {
for (const entity of Object.values(bot.entities)) {
@ -288,7 +317,7 @@ customEvents.on('gameLoaded', () => {
})
bot.on('teamRemoved', (team: Team) => {
if (appViewer.playerState.reactive.team?.team === team.team) {
if (appViewer.playerState.reactive.team?.team === team?.team) {
appViewer.playerState.reactive.team = undefined
// Player's team was removed, need to update all entities that are in a team
updateEntityNameTags(team)
@ -296,3 +325,44 @@ customEvents.on('gameLoaded', () => {
})
})
// Constants
const SHARED_FLAGS_KEY = 0
const ENTITY_FLAGS = {
ON_FIRE: 0x01, // Bit 0
SNEAKING: 0x02, // Bit 1
SPRINTING: 0x08, // Bit 3
SWIMMING: 0x10, // Bit 4
INVISIBLE: 0x20, // Bit 5
GLOWING: 0x40, // Bit 6
FALL_FLYING: 0x80 // Bit 7 (elytra flying)
}
let onFireTimeout: NodeJS.Timeout | undefined
const updateEntityStates = (entityId: number, onFire: boolean, timeout?: boolean) => {
if (entityId !== bot.entity.id) return
appViewer.playerState.reactive.onFire = onFire
if (onFireTimeout) {
clearTimeout(onFireTimeout)
}
if (timeout) {
onFireTimeout = setTimeout(() => {
updateEntityStates(entityId, false, false)
}, 5000)
}
}
// Process entity metadata packet
function handleEntityMetadata (packet: { entityId: number, metadata: Array<{ key: number, type: string, value: number }> }) {
const { entityId, metadata } = packet
// Find shared flags in metadata
const flagsData = metadata.find(meta => meta.key === SHARED_FLAGS_KEY &&
meta.type === 'byte')
// Update fire state if flags were found
if (flagsData) {
const wasOnFire = appViewer.playerState.reactive.onFire
appViewer.playerState.reactive.onFire = (flagsData.value & ENTITY_FLAGS.ON_FIRE) !== 0
}
}

21
src/env.d.ts vendored
View file

@ -2,29 +2,36 @@ declare namespace NodeJS {
interface ProcessEnv {
// Build configuration
NODE_ENV: 'development' | 'production'
SINGLE_FILE_BUILD?: string
DISABLE_SERVICE_WORKER?: string
MIN_MC_VERSION?: string
MAX_MC_VERSION?: string
ALWAYS_COMPRESS_LARGE_DATA?: 'true' | 'false'
SINGLE_FILE_BUILD?: 'true' | 'false'
WS_PORT?: string
DISABLE_SERVICE_WORKER?: 'true' | 'false'
CONFIG_JSON_SOURCE?: 'BUNDLED' | 'REMOTE'
LOCAL_CONFIG_FILE?: string
BUILD_VERSION?: string
// GitHub and Vercel related
// Build internals
GITHUB_REPOSITORY?: string
VERCEL_GIT_REPO_OWNER?: string
VERCEL_GIT_REPO_SLUG?: string
// UI and Features
// UI
MAIN_MENU_LINKS?: string
ALWAYS_MINIMAL_SERVER_UI?: 'true' | 'false'
// App features
ENABLE_COOKIE_STORAGE?: string
COOKIE_STORAGE_PREFIX?: string
// Release information
// Build info. Release information
RELEASE_TAG?: string
RELEASE_LINK?: string
RELEASE_CHANGELOG?: string
// Other configurations
DEPS_VERSIONS?: string
// Build info
INLINED_APP_CONFIG?: string
GITHUB_URL?: string
}
}

View file

@ -29,7 +29,7 @@ import './reactUi'
import { lockUrl, onBotCreate } from './controls'
import './dragndrop'
import { possiblyCleanHandle } from './browserfs'
import downloadAndOpenFile from './downloadAndOpenFile'
import downloadAndOpenFile, { isInterestedInDownload } from './downloadAndOpenFile'
import fs from 'fs'
import net, { Socket } from 'net'
@ -56,13 +56,12 @@ import { isCypress } from './standaloneUtils'
import { startLocalServer, unsupportedLocalServerFeatures } from './createLocalServer'
import defaultServerOptions from './defaultLocalServerOptions'
import dayCycle from './dayCycle'
import { onAppLoad, resourcepackReload, resourcePackState } from './resourcePack'
import { ConnectPeerOptions, connectToPeer } from './localServerMultiplayer'
import CustomChannelClient from './customClient'
import { registerServiceWorker } from './serviceWorker'
import { appStatusState, lastConnectOptions } from './react/AppStatusProvider'
import { appStatusState, lastConnectOptions, quickDevReconnect } from './react/AppStatusProvider'
import { fsState } from './loadSave'
import { watchFov } from './rendererUtils'
@ -97,8 +96,7 @@ import { registerOpenBenchmarkListener } from './benchmark'
import { tryHandleBuiltinCommand } from './builtinCommands'
import { loadingTimerState } from './react/LoadingTimer'
import { loadPluginsIntoWorld } from './react/CreateWorldProvider'
import { appStorage } from './react/appStorageProvider'
import { selectBestProxy } from './core/proxyAutoSelect'
import { getCurrentProxy, getCurrentUsername } from './react/ServersList'
window.debug = debug
window.beforeRenderFrame = []
@ -168,6 +166,7 @@ export async function connect (connectOptions: ConnectOptions) {
})
}
appStatusState.showReconnect = false
loadingTimerState.loading = true
loadingTimerState.start = Date.now()
miscUiState.hasErrors = false
@ -195,6 +194,8 @@ export async function connect (connectOptions: ConnectOptions) {
const https = connectOptions.proxy.startsWith('https://') || location.protocol === 'https:'
connectOptions.proxy = `${connectOptions.proxy}:${https ? 443 : 80}`
}
const parsedProxy = parseServerAddress(connectOptions.proxy, false)
const proxy = { host: parsedProxy.host, port: parsedProxy.port }
let { username } = connectOptions
if (connectOptions.server) {
@ -213,8 +214,13 @@ export async function connect (connectOptions: ConnectOptions) {
const destroyAll = (wasKicked = false) => {
if (ended) return
loadingTimerState.loading = false
if (!wasKicked && miscUiState.appConfig?.allowAutoConnect && appQueryParams.autoConnect && hadConnected) {
location.reload()
const { alwaysReconnect } = appQueryParams
if ((!wasKicked && miscUiState.appConfig?.allowAutoConnect && appQueryParams.autoConnect && hadConnected) || (alwaysReconnect)) {
if (alwaysReconnect === 'quick' || alwaysReconnect === 'fast') {
quickDevReconnect()
} else {
location.reload()
}
}
errorAbortController.abort()
ended = true
@ -229,8 +235,12 @@ export async function connect (connectOptions: ConnectOptions) {
bot.emit('end', '')
bot.removeAllListeners()
bot._client.removeAllListeners()
//@ts-expect-error TODO?
bot._client = undefined
bot._client = {
//@ts-expect-error
write (packetName) {
console.warn('Tried to write packet', packetName, 'after bot was destroyed')
}
}
//@ts-expect-error
window.bot = bot = undefined
}
@ -276,6 +286,10 @@ export async function connect (connectOptions: ConnectOptions) {
return
}
}
if (e.reason?.stack?.includes('chrome-extension://')) {
// ignore issues caused by chrome extension
return
}
handleError(e.reason)
}, {
signal: errorAbortController.signal
@ -289,28 +303,8 @@ export async function connect (connectOptions: ConnectOptions) {
let clientDataStream: Duplex | undefined
if (connectOptions.server && !connectOptions.viewerWsConnect && !parsedServer.isWebSocket) {
if (appStorage.proxiesData?.isAutoSelect && appStorage.proxiesData.proxies.length > 0) {
setLoadingScreenStatus('Selecting best proxy...')
const bestProxy = await selectBestProxy(appStorage.proxiesData.proxies)
if (bestProxy) {
connectOptions.proxy = bestProxy
} else {
let message = 'Failed to find a working proxy.'
if (navigator.onLine) {
message += '\n\nPlease check your internet connection and try again.'
} else {
message += '\nWe tried these proxies but none of them worked, try opening any of these urls in your browser:'
message += `\n${appStorage.proxiesData.proxies.join(', ')}`
}
setLoadingScreenStatus(message, true)
return
}
}
const parsedProxy = parseServerAddress(connectOptions.proxy, false)
const proxy = { host: parsedProxy.host, port: parsedProxy.port }
console.log(`using proxy ${proxy.host}:${proxy.port || location.port}`)
net['setProxy']({ hostname: proxy.host, port: proxy.port, headers: { Authorization: `Bearer ${new URLSearchParams(location.search).get('token') ?? ''}` } })
net['setProxy']({ hostname: proxy.host, port: proxy.port, headers: { Authorization: `Bearer ${new URLSearchParams(location.search).get('token') ?? ''}` }, artificialDelay: appQueryParams.addPing ? Number(appQueryParams.addPing) : undefined })
}
const renderDistance = singleplayer ? renderDistanceSingleplayer : multiplayerRenderDistance
@ -799,7 +793,6 @@ export async function connect (connectOptions: ConnectOptions) {
}
initMotionTracking()
dayCycle()
// Bot position callback
const botPosition = () => {
@ -900,37 +893,7 @@ export async function connect (connectOptions: ConnectOptions) {
}
}
const reconnectOptions = sessionStorage.getItem('reconnectOptions') ? JSON.parse(sessionStorage.getItem('reconnectOptions')!) : undefined
listenGlobalEvents()
const unsubscribe = subscribe(miscUiState, async () => {
if (miscUiState.fsReady && miscUiState.appConfig) {
unsubscribe()
if (reconnectOptions) {
sessionStorage.removeItem('reconnectOptions')
if (Date.now() - reconnectOptions.timestamp < 1000 * 60 * 2) {
void connect(reconnectOptions.value)
}
} else {
if (appQueryParams.singleplayer === '1' || appQueryParams.sp === '1') {
loadSingleplayer({}, {
worldFolder: undefined,
...appQueryParams.version ? { version: appQueryParams.version } : {}
})
}
if (appQueryParams.loadSave) {
const savePath = `/data/worlds/${appQueryParams.loadSave}`
try {
await fs.promises.stat(savePath)
} catch (err) {
alert(`Save ${savePath} not found`)
return
}
await loadInMemorySave(savePath)
}
}
}
})
// #region fire click event on touch as we disable default behaviors
let activeTouch: { touch: Touch, elem: HTMLElement, start: number } | undefined
@ -966,90 +929,148 @@ document.body.addEventListener('touchstart', (e) => {
}, { passive: false })
// #endregion
// qs open actions
if (!reconnectOptions) {
downloadAndOpenFile().then((downloadAction) => {
if (downloadAction) return
if (appQueryParams.reconnect && process.env.NODE_ENV === 'development') {
const lastConnect = JSON.parse(localStorage.lastConnectOptions ?? {})
// immediate game enter actions: reconnect or URL QS
const maybeEnterGame = () => {
const waitForConfigFsLoad = (fn: () => void) => {
let unsubscribe: () => void | undefined
const checkDone = () => {
if (miscUiState.fsReady && miscUiState.appConfig) {
fn()
unsubscribe?.()
return true
}
return false
}
if (!checkDone()) {
const text = miscUiState.appConfig ? 'Loading' : 'Loading config'
setLoadingScreenStatus(text)
unsubscribe = subscribe(miscUiState, checkDone)
}
}
const reconnectOptions = sessionStorage.getItem('reconnectOptions') ? JSON.parse(sessionStorage.getItem('reconnectOptions')!) : undefined
if (reconnectOptions) {
sessionStorage.removeItem('reconnectOptions')
if (Date.now() - reconnectOptions.timestamp < 1000 * 60 * 2) {
return waitForConfigFsLoad(async () => {
void connect(reconnectOptions.value)
})
}
}
if (appQueryParams.reconnect && localStorage.lastConnectOptions && process.env.NODE_ENV === 'development') {
const lastConnect = JSON.parse(localStorage.lastConnectOptions ?? {})
return waitForConfigFsLoad(async () => {
void connect({
botVersion: appQueryParams.version ?? undefined,
...lastConnect,
ip: appQueryParams.ip || undefined
})
return
}
if (appQueryParams.ip || appQueryParams.proxy) {
const waitAppConfigLoad = !appQueryParams.proxy
const openServerEditor = () => {
hideModal()
if (appQueryParams.onlyConnect) {
showModal({ reactType: 'only-connect-server' })
} else {
showModal({ reactType: 'editServer' })
}
}
showModal({ reactType: 'empty' })
if (waitAppConfigLoad) {
const unsubscribe = subscribe(miscUiState, checkCanDisplay)
checkCanDisplay()
// eslint-disable-next-line no-inner-declarations
function checkCanDisplay () {
if (miscUiState.appConfig) {
unsubscribe()
openServerEditor()
return true
}
}
} else {
openServerEditor()
}
}
void Promise.resolve().then(() => {
// try to connect to peer
const peerId = appQueryParams.connectPeer
const peerOptions = {} as ConnectPeerOptions
if (appQueryParams.server) {
peerOptions.server = appQueryParams.server
}
const version = appQueryParams.peerVersion
if (peerId) {
let username: string | null = options.guestUsername
if (options.askGuestName) username = prompt('Enter your username', username)
if (!username) return
options.guestUsername = username
void connect({
username,
botVersion: version || undefined,
peerId,
peerOptions
})
}
})
}
if (appQueryParams.serversList && !appQueryParams.ip) {
showModal({ reactType: 'serversList' })
}
const viewerWsConnect = appQueryParams.viewerConnect
if (viewerWsConnect) {
void connect({
username: `viewer-${Math.random().toString(36).slice(2, 10)}`,
viewerWsConnect,
if (appQueryParams.singleplayer === '1' || appQueryParams.sp === '1') {
return waitForConfigFsLoad(async () => {
loadSingleplayer({}, {
worldFolder: undefined,
...appQueryParams.version ? { version: appQueryParams.version } : {}
})
}
if (appQueryParams.modal) {
const modals = appQueryParams.modal.split(',')
for (const modal of modals) {
showModal({ reactType: modal })
})
}
if (appQueryParams.loadSave) {
const enterSave = async () => {
const savePath = `/data/worlds/${appQueryParams.loadSave}`
try {
await fs.promises.stat(savePath)
await loadInMemorySave(savePath)
} catch (err) {
alert(`Save ${savePath} not found`)
}
}
}, (err) => {
console.error(err)
alert(`Something went wrong: ${err}`)
})
return waitForConfigFsLoad(enterSave)
}
if (appQueryParams.ip || appQueryParams.proxy) {
const openServerAction = () => {
if (appQueryParams.autoConnect && miscUiState.appConfig?.allowAutoConnect) {
void connect({
server: appQueryParams.ip,
proxy: getCurrentProxy(),
botVersion: appQueryParams.version ?? undefined,
username: getCurrentUsername()!,
})
return
}
setLoadingScreenStatus(undefined)
if (appQueryParams.onlyConnect || process.env.ALWAYS_MINIMAL_SERVER_UI === 'true') {
showModal({ reactType: 'only-connect-server' })
} else {
showModal({ reactType: 'editServer' })
}
}
// showModal({ reactType: 'empty' })
return waitForConfigFsLoad(openServerAction)
}
if (appQueryParams.connectPeer) {
// try to connect to peer
const peerId = appQueryParams.connectPeer
const peerOptions = {} as ConnectPeerOptions
if (appQueryParams.server) {
peerOptions.server = appQueryParams.server
}
const version = appQueryParams.peerVersion
let username: string | null = options.guestUsername
if (options.askGuestName) username = prompt('Enter your username to connect to peer', username)
if (!username) return
options.guestUsername = username
void connect({
username,
botVersion: version || undefined,
peerId,
peerOptions
})
return
}
if (appQueryParams.viewerConnect) {
void connect({
username: `viewer-${Math.random().toString(36).slice(2, 10)}`,
viewerWsConnect: appQueryParams.viewerConnect,
})
return
}
if (appQueryParams.modal) {
const modals = appQueryParams.modal.split(',')
for (const modal of modals) {
showModal({ reactType: modal })
}
return
}
if (appQueryParams.serversList && !miscUiState.appConfig?.appParams?.serversList) {
// open UI only if it's in URL
showModal({ reactType: 'serversList' })
}
if (isInterestedInDownload()) {
void downloadAndOpenFile()
}
void possiblyHandleStateVariable()
}
try {
maybeEnterGame()
} catch (err) {
console.error(err)
alert(`Something went wrong: ${err}`)
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
@ -1060,6 +1081,5 @@ if (initialLoader) {
}
window.pageLoaded = true
void possiblyHandleStateVariable()
appViewer.waitBackendLoadPromises.push(appStartup())
registerOpenBenchmarkListener()

View file

@ -9,8 +9,10 @@ import PItem, { Item } from 'prismarine-item'
import { versionToNumber } from 'renderer/viewer/common/utils'
import { getRenamedData } from 'flying-squid/dist/blockRenames'
import PrismarineChatLoader from 'prismarine-chat'
import * as nbt from 'prismarine-nbt'
import { BlockModel } from 'mc-assets'
import { renderSlot } from 'renderer/viewer/three/renderSlot'
import { loadSkinFromUsername } from 'renderer/viewer/lib/utils/skins'
import Generic95 from '../assets/generic_95.png'
import { appReplacableResources } from './generated/resources'
import { activeModalStack, hideCurrentModal, hideModal, miscUiState, showModal } from './globalState'
@ -22,6 +24,7 @@ import { getItemDescription } from './itemsDescriptions'
import { MessageFormatPart } from './chatUtils'
import { GeneralInputItem, getItemMetadata, getItemModelName, getItemNameRaw, RenderItem } from './mineflayer/items'
import { playerState } from './mineflayer/playerState'
import { modelViewerState } from './react/OverlayModelViewer'
const loadedImagesCache = new Map<string, HTMLImageElement | ImageBitmap>()
const cleanLoadedImagesCache = () => {
@ -39,6 +42,34 @@ export const jeiCustomCategories = proxy({
value: [] as Array<{ id: string, categoryTitle: string, items: any[] }>
})
let remotePlayerSkin: string | undefined | Promise<string>
export const showInventoryPlayer = () => {
modelViewerState.model = {
positioning: {
windowWidth: 176,
windowHeight: 166,
x: 25,
y: 8,
width: 50,
height: 70,
scaled: true,
onlyInitialScale: true,
followCursor: true,
},
// models: ['https://bucket.mcraft.fun/sitarbuckss.glb'],
// debug: true,
steveModelSkin: appViewer.playerState.reactive.playerSkin ?? (typeof remotePlayerSkin === 'string' ? remotePlayerSkin : ''),
}
if (remotePlayerSkin === undefined && !appViewer.playerState.reactive.playerSkin) {
remotePlayerSkin = loadSkinFromUsername(bot.username, 'skin').then(a => {
setTimeout(() => { showInventoryPlayer() }, 0) // todo patch instead and make reactive
remotePlayerSkin = a ?? ''
return remotePlayerSkin
})
}
}
export const onGameLoad = () => {
version = bot.version
@ -56,12 +87,23 @@ export const onGameLoad = () => {
return type
}
const maybeParseNbtJson = (data: any) => {
if (typeof data === 'string') {
try {
data = JSON.parse(data)
} catch (err) {
// ignore
}
}
return nbt.simplify(data) ?? data
}
bot.on('windowOpen', (win) => {
const implementedWindow = implementedContainersGuiMap[mapWindowType(win.type as string, win.inventoryStart)]
if (implementedWindow) {
openWindow(implementedWindow)
openWindow(implementedWindow, maybeParseNbtJson(win.title))
} else if (options.unimplementedContainers) {
openWindow('ChestWin')
openWindow('ChestWin', maybeParseNbtJson(win.title))
} else {
// todo format
displayClientChat(`[client error] cannot open unimplemented window ${win.id} (${win.type}). Slots: ${win.slots.map(item => getItemName(item)).filter(Boolean).join(', ')}`)
@ -258,6 +300,7 @@ export const upInventoryItems = (isInventory: boolean, invWindow = lastWindow) =
// inv.pwindow.inv.slots[2].blockData = getBlockData('dirt')
const customSlots = mapSlots((isInventory ? bot.inventory : bot.currentWindow)!.slots)
invWindow.pwindow.setSlots(customSlots)
return customSlots
}
export const onModalClose = (callback: () => any) => {
@ -354,7 +397,7 @@ const upWindowItemsLocal = () => {
}
let skipClosePacketSending = false
const openWindow = (type: string | undefined) => {
const openWindow = (type: string | undefined, title: string | any = undefined) => {
// if (activeModalStack.some(x => x.reactType?.includes?.('player_win:'))) {
if (activeModalStack.length) { // game is not in foreground, don't close current modal
if (type) {
@ -379,12 +422,16 @@ const openWindow = (type: string | undefined) => {
miscUiState.displaySearchInput = false
destroyFn()
skipClosePacketSending = false
modelViewerState.model = undefined
})
if (type === undefined) {
showInventoryPlayer()
}
cleanLoadedImagesCache()
const inv = openItemsCanvas(type)
inv.canvasManager.children[0].mobileHelpers = miscUiState.currentTouch
window.inventory = inv
const title = bot.currentWindow?.title
const PrismarineChat = PrismarineChatLoader(bot.version)
try {
inv.canvasManager.children[0].customTitleText = title ?
@ -423,6 +470,7 @@ const openWindow = (type: string | undefined) => {
const isRightClick = type === 'rightclick'
const isLeftClick = type === 'leftclick'
if (isLeftClick || isRightClick) {
modelViewerState.model = undefined
inv.canvasManager.children[0].showRecipesOrUsages(isLeftClick, item)
}
} else {
@ -454,6 +502,7 @@ const openWindow = (type: string | undefined) => {
if (freeSlot === null) return
void bot.creative.setInventorySlot(freeSlot, item)
} else {
modelViewerState.model = undefined
inv.canvasManager.children[0].showRecipesOrUsages(!isRightclick, mapSlots([item], true)[0])
}
}
@ -522,7 +571,7 @@ const getResultingRecipe = (slots: Array<Item | null>, gridRows: number) => {
type Result = RecipeItem | undefined
let shapelessResult: Result
let shapeResult: Result
outer: for (const [id, recipeVariants] of Object.entries(loadedData.recipes)) {
outer: for (const [id, recipeVariants] of Object.entries(loadedData.recipes ?? {})) {
for (const recipeVariant of recipeVariants) {
if ('inShape' in recipeVariant && equals(currentShape, recipeVariant.inShape as number[][])) {
shapeResult = recipeVariant.result!
@ -550,7 +599,7 @@ const getAllItemRecipes = (itemName: string) => {
const item = loadedData.itemsByName[itemName]
if (!item) return
const itemId = item.id
const recipes = loadedData.recipes[itemId]
const recipes = loadedData.recipes?.[itemId]
if (!recipes) return
const results = [] as Array<{
result: Item,
@ -595,7 +644,7 @@ const getAllItemUsages = (itemName: string) => {
if (!item) return
const foundRecipeIds = [] as string[]
for (const [id, recipes] of Object.entries(loadedData.recipes)) {
for (const [id, recipes] of Object.entries(loadedData.recipes ?? {})) {
for (const recipe of recipes) {
if ('inShape' in recipe) {
if (recipe.inShape.some(row => row.includes(item.id))) {

View file

@ -58,9 +58,17 @@ export const getItemMetadata = (item: GeneralInputItem, resourcesManager: Resour
}
if (customModelDataDefinitions) {
const customModelDataComponent: any = componentMap.get('custom_model_data')
if (customModelDataComponent?.data && typeof customModelDataComponent.data === 'number') {
const customModelData = customModelDataComponent.data
if (customModelDataDefinitions[customModelData]) {
if (customModelDataComponent?.data) {
let customModelData: number | undefined
if (typeof customModelDataComponent.data === 'number') {
customModelData = customModelDataComponent.data
} else if (typeof customModelDataComponent.data === 'object'
&& 'floats' in customModelDataComponent.data
&& Array.isArray(customModelDataComponent.data.floats)
&& customModelDataComponent.data.floats.length > 0) {
customModelData = customModelDataComponent.data.floats[0]
}
if (customModelData && customModelDataDefinitions[customModelData]) {
customModel = customModelDataDefinitions[customModelData]
}
}

View file

@ -1,13 +1,46 @@
import net from 'net'
import { Client } from 'minecraft-protocol'
import { appQueryParams } from '../appParams'
import { downloadAllMinecraftData, getVersionAutoSelect } from '../connect'
import { gameAdditionalState } from '../globalState'
import { ProgressReporter } from '../core/progressReporter'
import { parseServerAddress } from '../parseServerAddress'
import { getCurrentProxy } from '../react/ServersList'
import { pingServerVersion, validatePacket } from './minecraft-protocol-extra'
import { getWebsocketStream } from './websocket-core'
let lastPacketTime = 0
customEvents.on('mineflayerBotCreated', () => {
// const oldParsePacketBuffer = bot._client.deserializer.parsePacketBuffer
// try {
// const parsed = oldParsePacketBuffer(buffer)
// } catch (err) {
// debugger
// reportError(new Error(`Error parsing packet ${buffer.subarray(0, 30).toString('hex')}`, { cause: err }))
// throw err
// }
// }
class MinecraftProtocolError extends Error {
constructor (message: string, cause?: Error, public data?: any) {
if (data?.customPayload) {
message += ` (Custom payload: ${data.customPayload.channel})`
}
super(message, { cause })
this.name = 'MinecraftProtocolError'
}
}
const onClientError = (err, data) => {
const error = new MinecraftProtocolError(`Minecraft protocol client error: ${err.message}`, err, data)
reportError(error)
}
if (typeof bot._client['_events'].error === 'function') {
// dont report to bot for more explicit error
bot._client['_events'].error = onClientError
} else {
bot._client.on('error' as any, onClientError)
}
// todo move more code here
if (!appQueryParams.noPacketsValidation) {
(bot._client as unknown as Client).on('packet', (data, packetMeta, buffer, fullBuffer) => {
@ -35,7 +68,7 @@ setInterval(() => {
}, 1000)
export const getServerInfo = async (ip: string, port?: number, preferredVersion = getVersionAutoSelect(), ping = false, progressReporter?: ProgressReporter) => {
export const getServerInfo = async (ip: string, port?: number, preferredVersion = getVersionAutoSelect(), ping = false, progressReporter?: ProgressReporter, setProxyParams?: ProxyParams) => {
await downloadAllMinecraftData()
const isWebSocket = ip.startsWith('ws://') || ip.startsWith('wss://')
let stream
@ -43,6 +76,8 @@ export const getServerInfo = async (ip: string, port?: number, preferredVersion
progressReporter?.setMessage('Connecting to WebSocket server')
stream = (await getWebsocketStream(ip)).mineflayerStream
progressReporter?.setMessage('WebSocket connected. Ping packet sent, waiting for response')
} else if (setProxyParams) {
setProxy(setProxyParams)
}
window.setLoadingMessage = (message?: string) => {
if (message === undefined) {
@ -59,3 +94,46 @@ export const getServerInfo = async (ip: string, port?: number, preferredVersion
window.setLoadingMessage = undefined
})
}
globalThis.debugTestPing = async (ip: string) => {
const parsed = parseServerAddress(ip, false)
const result = await getServerInfo(parsed.host, parsed.port ? Number(parsed.port) : undefined, undefined, true, undefined, { address: getCurrentProxy(), })
console.log('result', result)
return result
}
export const getDefaultProxyParams = () => {
return {
headers: {
Authorization: `Bearer ${new URLSearchParams(location.search).get('token') ?? ''}`
}
}
}
export type ProxyParams = {
address?: string
headers?: Record<string, string>
}
export const setProxy = (proxyParams: ProxyParams) => {
if (proxyParams.address?.startsWith(':')) {
proxyParams.address = `${location.protocol}//${location.hostname}${proxyParams.address}`
}
if (proxyParams.address && location.port !== '80' && location.port !== '443' && !/:\d+$/.test(proxyParams.address)) {
const https = proxyParams.address.startsWith('https://') || location.protocol === 'https:'
proxyParams.address = `${proxyParams.address}:${https ? 443 : 80}`
}
const parsedProxy = parseServerAddress(proxyParams.address, false)
const proxy = { host: parsedProxy.host, port: parsedProxy.port }
proxyParams.headers ??= getDefaultProxyParams().headers
net['setProxy']({
hostname: proxy.host,
port: proxy.port,
headers: proxyParams.headers,
artificialDelay: appQueryParams.addPing ? Number(appQueryParams.addPing) : undefined
})
return {
proxy
}
}

View file

@ -3,6 +3,7 @@ import { getInitialPlayerState, getPlayerStateUtils, PlayerStateReactive, Player
import { subscribe } from 'valtio'
import { subscribeKey } from 'valtio/utils'
import { gameAdditionalState } from '../globalState'
import { options } from '../optionsStorage'
/**
* can be used only in main thread. Mainly for more convenient reactive state updates.
@ -42,6 +43,7 @@ export class PlayerStateControllerMain {
private botCreated () {
console.log('bot created & plugins injected')
this.reactive = getInitialPlayerState()
this.reactive.perspective = options.defaultPerspective
this.utils = getPlayerStateUtils(this.reactive)
this.onBotCreatedOrGameJoined()

View file

@ -110,7 +110,7 @@ const domListeners = (bot: Bot) => {
}, { signal: abortController.signal })
bot.mouse.beforeUpdateChecks = () => {
if (!document.hasFocus()) {
if (!document.hasFocus() || !isGameActive(true)) {
// deactive all buttons
bot.mouse.buttons.fill(false)
}

View file

@ -15,9 +15,12 @@ class CustomDuplex extends Duplex {
}
export const getWebsocketStream = async (host: string) => {
const baseProtocol = location.protocol === 'https:' ? 'wss' : host.startsWith('ws://') ? 'ws' : 'wss'
const baseProtocol = host.startsWith('ws://') ? 'ws' : 'wss'
const hostClean = host.replace('ws://', '').replace('wss://', '')
const ws = new WebSocket(`${baseProtocol}://${hostClean}`)
const hostURL = new URL(`${baseProtocol}://${hostClean}`)
const hostParams = hostURL.searchParams
hostParams.append('client_mcraft', '')
const ws = new WebSocket(`${baseProtocol}://${hostURL.host}${hostURL.pathname}?${hostParams.toString()}`)
const clientDuplex = new CustomDuplex(undefined, data => {
ws.send(data)
})

View file

@ -480,6 +480,24 @@ export const guiOptionsScheme: {
],
sound: [
{ volume: {} },
{
custom () {
return <OptionSlider
valueOverride={options.enableMusic ? undefined : 0}
onChange={(value) => {
options.musicVolume = value
}}
item={{
type: 'slider',
id: 'musicVolume',
text: 'Music Volume',
min: 0,
max: 100,
unit: '%',
}}
/>
},
},
{
custom () {
return <Button label='Sound Muffler' onClick={() => showModal({ reactType: 'sound-muffler' })} inScreen />
@ -550,6 +568,16 @@ export const guiOptionsScheme: {
return <Category>Server Connection</Category>
},
},
{
saveLoginPassword: {
tooltip: 'Controls whether to save login passwords for servers in this browser memory.',
values: [
'prompt',
'always',
'never'
]
},
},
{
custom () {
const { serversAutoVersionSelect } = useSnapshot(options)

View file

@ -59,6 +59,7 @@ export const startLocalReplayServer = (contents: string) => {
const server = createServer({
Server: LocalServer as any,
version: header.minecraftVersion,
keepAlive: false,
'online-mode': false
})

View file

@ -29,10 +29,9 @@ interface Props {
accounts?: string[]
authenticatedAccounts?: number
versions?: string[]
allowAutoConnect?: boolean
}
export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQs, onQsConnect, placeholders, accounts, versions, allowAutoConnect }: Props) => {
export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQs, onQsConnect, placeholders, accounts, versions }: Props) => {
const isSmallHeight = !usePassesScaledDimensions(null, 350)
const qsParamName = parseQs ? appQueryParams.name : undefined
const qsParamIp = parseQs ? appQueryParams.ip : undefined
@ -40,7 +39,6 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
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)
@ -118,14 +116,8 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
}
}
useEffect(() => {
if (qsParamAutoConnect && qsParamIp && qsParamVersion && allowAutoConnect) {
onQsConnect?.(commonUseOptions)
}
}, [])
const displayConnectButton = qsParamIp
const serverExamples = ['example.com:25565', 'play.hypixel.net', 'ws://play.pcm.gg']
const serverExamples = ['example.com:25565', 'play.hypixel.net', 'ws://play.pcm.gg', 'wss://play.webmc.fun']
// pick random example
const example = serverExamples[Math.floor(Math.random() * serverExamples.length)]
@ -231,7 +223,7 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
Cancel
</ButtonWrapper>
<ButtonWrapper type='submit'>
{displayConnectButton ? 'Save' : <strong>Save</strong>}
{displayConnectButton ? translate('Save') : <strong>{translate('Save')}</strong>}
</ButtonWrapper>
</>}
{displayConnectButton && (
@ -246,7 +238,7 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
onQsConnect?.(commonUseOptions)
}}
>
<strong>Connect</strong>
<strong>{translate('Connect')}</strong>
</ButtonWrapper>
</div>
)}

View file

@ -54,6 +54,17 @@ export const reconnectReload = () => {
}
}
export const quickDevReconnect = () => {
if (!lastConnectOptions.value) {
return
}
resetAppStatusState()
window.dispatchEvent(new window.CustomEvent('connect', {
detail: lastConnectOptions.value
}))
}
export default () => {
const lastState = useRef(JSON.parse(JSON.stringify(appStatusState)))
const currentState = useSnapshot(appStatusState)
@ -105,13 +116,6 @@ export default () => {
}
}, [isOpen])
const reconnect = () => {
resetAppStatusState()
window.dispatchEvent(new window.CustomEvent('connect', {
detail: lastConnectOptions.value
}))
}
useEffect(() => {
const controller = new AbortController()
window.addEventListener('keyup', (e) => {
@ -119,7 +123,7 @@ export default () => {
if (activeModalStack.at(-1)?.reactType !== 'app-status') return
// todo do only if reconnect is possible
if (e.code !== 'KeyR' || !lastConnectOptions.value) return
reconnect()
quickDevReconnect()
}, {
signal: controller.signal
})
@ -140,7 +144,7 @@ export default () => {
const account = await showOptionsModal('Choose account to connect with', [...accounts.map(account => account.username), 'Use other account'])
if (!account) return
lastConnectOptions.value!.authenticatedAccount = accounts.find(acc => acc.username === account) || true
reconnect()
quickDevReconnect()
}
const lastAutoCapturedPackets = getLastAutoCapturedPackets()
@ -184,7 +188,7 @@ export default () => {
actionsSlot={
<>
{displayAuthButton && <Button label='Authenticate' onClick={authReconnectAction} />}
{displayVpnButton && <PossiblyVpnBypassProxyButton reconnect={reconnect} />}
{displayVpnButton && <PossiblyVpnBypassProxyButton reconnect={quickDevReconnect} />}
{replayActive && <Button label={`Download Packets Replay ${replayLogger?.contents.split('\n').length}L`} onClick={downloadPacketsReplay} />}
{wasDisconnected && lastAutoCapturedPackets && <Button label={`Inspect Last ${lastAutoCapturedPackets} Packets`} onClick={() => downloadAutoCapturedPackets()} />}
</>

View file

@ -8,7 +8,6 @@
--offset: calc(-1 * 25px);
--bg-x: calc(-1 * 16px);
--bg-y: calc(-1 * 9px);
pointer-events: none;
image-rendering: pixelated;
}

View file

@ -9,7 +9,6 @@
--offset: calc(-1 * 16px);
--bg-x: calc(-1 * 16px);
--bg-y: calc(-1 * 18px);
pointer-events: none;
image-rendering: pixelated;
}

View file

@ -9,7 +9,7 @@ div.chat-wrapper {
/* Only apply overflow hidden when not in mobile mode */
div.chat-wrapper:not(.display-mobile):not(.input-mobile) {
overflow: hidden;
/* overflow: hidden; */
}
.chat-messages-wrapper {

View file

@ -45,7 +45,7 @@ const MessageLine = ({ message, currentPlayerName, chatOpened }: { message: Mess
return <li className={Object.entries(classes).filter(([, val]) => val).map(([name]) => name).join(' ')} data-time={message.timestamp ? new Date(message.timestamp).toLocaleString('en-US', { hour12: false }) : undefined}>
{message.parts.map((msg, i) => {
// Check if this is a text part that might contain a mention
if (msg.text && currentPlayerName) {
if (typeof msg.text === 'string' && currentPlayerName) {
const parts = msg.text.split(new RegExp(`(@${currentPlayerName})`, 'i'))
if (parts.length > 1) {
return parts.map((txtPart, j) => {
@ -125,7 +125,9 @@ export default ({
const chatInput = useRef<HTMLInputElement>(null!)
const chatMessages = useRef<HTMLDivElement>(null)
const chatHistoryPos = useRef(sendHistoryRef.current.length)
const commandHistoryPos = useRef(0)
const inputCurrentlyEnteredValue = useRef('')
const commandHistoryRef = useRef(sendHistoryRef.current.filter((msg: string) => msg.startsWith('/')))
const { scrollToBottom, isAtBottom, wasAtBottom, currentlyAtBottom } = useScrollBehavior(chatMessages, { messages, opened })
const [rightNowAtBottom, setRightNowAtBottom] = useState(false)
@ -142,6 +144,9 @@ export default ({
sendHistoryRef.current = newHistory
window.sessionStorage.chatHistory = JSON.stringify(newHistory)
chatHistoryPos.current = newHistory.length
// Update command history (only messages starting with /)
commandHistoryRef.current = newHistory.filter((msg: string) => msg.startsWith('/'))
commandHistoryPos.current = commandHistoryRef.current.length
}
const acceptComplete = (item: string) => {
@ -180,6 +185,21 @@ export default ({
updateInputValue(sendHistoryRef.current[chatHistoryPos.current] || inputCurrentlyEnteredValue.current || '')
}
const handleCommandArrowUp = () => {
if (commandHistoryPos.current === 0 || commandHistoryRef.current.length === 0) return
if (commandHistoryPos.current === commandHistoryRef.current.length) { // started navigating command history
inputCurrentlyEnteredValue.current = chatInput.current.value
}
commandHistoryPos.current--
updateInputValue(commandHistoryRef.current[commandHistoryPos.current] || '')
}
const handleCommandArrowDown = () => {
if (commandHistoryPos.current === commandHistoryRef.current.length) return
commandHistoryPos.current++
updateInputValue(commandHistoryRef.current[commandHistoryPos.current] || inputCurrentlyEnteredValue.current || '')
}
const auxInputFocus = (direction: 'up' | 'down') => {
chatInput.current.focus()
if (direction === 'up') {
@ -203,6 +223,7 @@ export default ({
updateInputValue(chatInputValueGlobal.value)
chatInputValueGlobal.value = ''
chatHistoryPos.current = sendHistoryRef.current.length
commandHistoryPos.current = commandHistoryRef.current.length
if (!usingTouch) {
chatInput.current.focus()
}
@ -524,9 +545,19 @@ export default ({
onBlur={() => setIsInputFocused(false)}
onKeyDown={(e) => {
if (e.code === 'ArrowUp') {
handleArrowUp()
if (e.altKey) {
handleCommandArrowUp()
e.preventDefault()
} else {
handleArrowUp()
}
} else if (e.code === 'ArrowDown') {
handleArrowDown()
if (e.altKey) {
handleCommandArrowDown()
e.preventDefault()
} else {
handleArrowDown()
}
}
if (e.code === 'Tab') {
if (completionItemsSource.length) {

View file

@ -73,16 +73,28 @@ export default () => {
}
const builtinHandled = tryHandleBuiltinCommand(message)
if (getServerIndex() !== undefined && (message.startsWith('/login') || message.startsWith('/register'))) {
showNotification('Click here to save your password in browser for auto-login', undefined, false, undefined, () => {
if (getServerIndex() !== undefined && (message.startsWith('/login') || message.startsWith('/register')) && options.saveLoginPassword !== 'never') {
const savePassword = () => {
let hadPassword = false
updateLoadedServerData((server) => {
server.autoLogin ??= {}
const password = message.split(' ')[1]
hadPassword = !!server.autoLogin[bot.username]
server.autoLogin[bot.username] = password
return { ...server }
})
hideNotification()
})
if (options.saveLoginPassword === 'always') {
const message = hadPassword ? 'Password updated in browser for auto-login' : 'Password saved in browser for auto-login'
showNotification(message, undefined, false, undefined)
} else {
hideNotification()
}
}
if (options.saveLoginPassword === 'prompt') {
showNotification('Click here to save your password in browser for auto-login', undefined, false, undefined, savePassword)
} else {
savePassword()
}
notificationProxy.id = 'auto-login'
const listener = () => {
hideNotification()

View file

@ -98,13 +98,16 @@ export default ({
cursor: chunk ? 'pointer' : 'default',
position: 'relative',
width: `${tileSize}px`,
flexDirection: 'column',
height: `${tileSize}px`,
padding: 1,
// pre-wrap
whiteSpace: 'pre',
}}
>
{relX}, {relZ}{'\n'}
{chunk?.lines.join('\n')}
{chunk?.lines[0]}{'\n'}
<span style={{ fontSize: `${fontSize * 0.8}px` }}>{chunk?.lines[1]}</span>
</div>
)
})}

View file

@ -2,6 +2,8 @@ import { useEffect, useState } from 'react'
import { useUtilsEffect } from '@zardoy/react-util'
import { WorldRendererCommon } from 'renderer/viewer/lib/worldrendererCommon'
import { WorldRendererThree } from 'renderer/viewer/three/worldrendererThree'
import { Vec3 } from 'vec3'
import { generateSpiralMatrix } from 'flying-squid/dist/utils'
import Screen from './Screen'
import ChunksDebug, { ChunkDebug } from './ChunksDebug'
import { useIsModalActive } from './utilsApp'
@ -12,6 +14,10 @@ const Inner = () => {
const [update, setUpdate] = useState(0)
useUtilsEffect(({ interval }) => {
const up = () => {
// setUpdate(u => u + 1)
}
bot.on('chunkColumnLoad', up)
interval(
500,
() => {
@ -20,17 +26,48 @@ const Inner = () => {
setUpdate(u => u + 1)
}
)
return () => {
bot.removeListener('chunkColumnLoad', up)
}
}, [])
// Track first load time for all chunks
const allLoadTimes = Object.values(worldView!.debugChunksInfo)
.map(chunk => chunk?.loads[0]?.time ?? Infinity)
.filter(time => time !== Infinity)
.sort((a, b) => a - b)
const allSpiralChunks = Object.fromEntries(generateSpiralMatrix(worldView!.viewDistance).map(pos => [`${pos[0]},${pos[1]}`, pos]))
const mapChunk = (key: string, state: ChunkDebug['state']): ChunkDebug => {
const x = Number(key.split(',')[0])
const z = Number(key.split(',')[1])
const chunkX = Math.floor(x / 16)
const chunkZ = Math.floor(z / 16)
delete allSpiralChunks[`${chunkX},${chunkZ}`]
const chunk = worldView!.debugChunksInfo[key]
const firstLoadTime = chunk?.loads[0]?.time
const loadIndex = firstLoadTime ? allLoadTimes.indexOf(firstLoadTime) + 1 : 0
// const timeSinceFirstLoad = firstLoadTime ? firstLoadTime - allLoadTimes[0] : 0
const timeSinceFirstLoad = firstLoadTime ? firstLoadTime - allLoadTimes[0] : 0
let line = ''
let line2 = ''
if (loadIndex) {
line = `${loadIndex}`
line2 = `${timeSinceFirstLoad}ms`
}
if (chunk?.loads.length > 1) {
line += ` - ${chunk.loads.length}`
}
return {
x: Number(key.split(',')[0]),
z: Number(key.split(',')[1]),
x,
z,
state,
lines: [String(chunk?.loads.length ?? 0)],
lines: [line, line2],
sidebarLines: [
`loads: ${chunk.loads?.map(l => `${l.reason} ${l.dataLength} ${l.time}`).join('\n')}`,
`loads: ${chunk?.loads?.map(l => `${l.reason} ${l.dataLength} ${l.time}`).join('\n')}`,
// `blockUpdates: ${chunk.blockUpdates}`,
],
}
@ -55,14 +92,22 @@ const Inner = () => {
const chunksDone = Object.keys(world.finishedChunks).map(key => mapChunk(key, 'done'))
const chunksWaitingOrder = Object.values(allSpiralChunks).map(([x, z]) => {
const pos = new Vec3(x * 16, 0, z * 16)
if (bot.world.getColumnAt(pos) === null) return null
return mapChunk(`${pos.x},${pos.z}`, 'order-queued')
}).filter(a => !!a)
const allChunks = [
...chunksWaitingServer,
...chunksWaitingClient,
...clientProcessingChunks,
...chunksDone,
...chunksDoneEmpty,
...chunksWaitingOrder,
]
return <Screen title="Chunks Debug">
return <Screen title={`Chunks Debug (avg: ${worldView!.lastChunkReceiveTimeAvg.toFixed(1)}ms)`}>
<ChunksDebug
chunks={allChunks}
playerChunk={{

153
src/react/FireRenderer.tsx Normal file
View file

@ -0,0 +1,153 @@
/* eslint-disable no-await-in-loop */
import { useSnapshot } from 'valtio'
import { useEffect, useState } from 'react'
import { getLoadedImage } from 'mc-assets/dist/utils'
import { createCanvas } from 'renderer/viewer/lib/utils'
const TEXTURE_UPDATE_INTERVAL = 100 // 5 times per second
export default () => {
const { onFire, perspective } = useSnapshot(appViewer.playerState.reactive)
const [fireTextures, setFireTextures] = useState<string[]>([])
const [currentTextureIndex, setCurrentTextureIndex] = useState(0)
useEffect(() => {
let animationFrameId: number
let lastTextureUpdate = 0
const updateTexture = (timestamp: number) => {
if (onFire && fireTextures.length > 0) {
if (timestamp - lastTextureUpdate >= TEXTURE_UPDATE_INTERVAL) {
setCurrentTextureIndex(prev => (prev + 1) % fireTextures.length)
lastTextureUpdate = timestamp
}
}
animationFrameId = requestAnimationFrame(updateTexture)
}
animationFrameId = requestAnimationFrame(updateTexture)
return () => cancelAnimationFrame(animationFrameId)
}, [onFire, fireTextures])
useEffect(() => {
const loadTextures = async () => {
const fireImageUrls: string[] = []
const { resourcesManager } = appViewer
const { blocksAtlasParser } = resourcesManager
if (!blocksAtlasParser?.atlas?.latest) {
console.warn('FireRenderer: Blocks atlas parser not available')
return
}
const keys = Object.keys(blocksAtlasParser.atlas.latest.textures).filter(key => /^fire_\d+$/.exec(key))
for (const key of keys) {
const textureInfo = blocksAtlasParser.getTextureInfo(key) as { u: number, v: number, width?: number, height?: number }
if (textureInfo) {
const defaultSize = blocksAtlasParser.atlas.latest.tileSize
const imageWidth = blocksAtlasParser.atlas.latest.width
const imageHeight = blocksAtlasParser.atlas.latest.height
const textureWidth = textureInfo.width ?? defaultSize
const textureHeight = textureInfo.height ?? defaultSize
// Create a temporary canvas for the full texture
const tempCanvas = createCanvas(textureWidth, textureHeight)
const tempCtx = tempCanvas.getContext('2d')
if (tempCtx && blocksAtlasParser.latestImage) {
const image = await getLoadedImage(blocksAtlasParser.latestImage)
tempCtx.drawImage(
image,
textureInfo.u * imageWidth,
textureInfo.v * imageHeight,
textureWidth,
textureHeight,
0,
0,
textureWidth,
textureHeight
)
// Create final canvas with only top 20% of the texture
const finalHeight = Math.ceil(textureHeight * 0.4)
const canvas = createCanvas(textureWidth, finalHeight)
const ctx = canvas.getContext('2d')
if (ctx) {
// Draw only the top portion
ctx.drawImage(
tempCanvas,
0,
0, // Start from top
textureWidth,
finalHeight,
0,
0,
textureWidth,
finalHeight
)
const blob = await canvas.convertToBlob()
const url = URL.createObjectURL(blob)
fireImageUrls.push(url)
}
}
}
}
setFireTextures(fireImageUrls)
}
// Load textures initially
if (appViewer.resourcesManager.currentResources) {
void loadTextures()
}
// Set up listener for texture updates
const onAssetsUpdated = () => {
void loadTextures()
}
appViewer.resourcesManager.on('assetsTexturesUpdated', onAssetsUpdated)
// Cleanup
return () => {
appViewer.resourcesManager.off('assetsTexturesUpdated', onAssetsUpdated)
// Cleanup texture URLs
for (const url of fireTextures) URL.revokeObjectURL(url)
}
}, [])
if (!onFire || fireTextures.length === 0 || perspective !== 'first_person') return null
return (
<div
className='fire-renderer-container'
style={{
position: 'fixed',
left: 0,
right: 0,
bottom: 0,
height: '20dvh',
pointerEvents: 'none',
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-end',
overflow: 'hidden',
zIndex: -1
}}
>
<div
style={{
position: 'absolute',
width: '100%',
height: '100%',
backgroundImage: `url(${fireTextures[currentTextureIndex]})`,
backgroundSize: '50% 100%',
backgroundPosition: 'center',
backgroundRepeat: 'repeat-x',
opacity: 0.7,
filter: 'brightness(1.2) contrast(1.2)',
mixBlendMode: 'screen'
}}
/>
</div>
)
}

View file

@ -11,7 +11,6 @@
--offset: calc(-1 * (52px + (9px * (4 * var(--kind) + var(--lightened) * 2))));
--bg-x: calc(-1 * (16px + 9px * var(--lightened)));
--bg-y: calc(-1 * 27px);
pointer-events: none;
image-rendering: pixelated;
}

View file

@ -2,6 +2,7 @@ import { useRef, useEffect } from 'react'
import { subscribe, useSnapshot } from 'valtio'
import { useUtilsEffect } from '@zardoy/react-util'
import { getThreeJsRendererMethods } from 'renderer/viewer/three/threeJsMethods'
import { isItemActivatableMobile } from 'mineflayer-mouse/dist/activatableItemsMobile'
import { options } from '../optionsStorage'
import { activeModalStack, isGameActive, miscUiState } from '../globalState'
import { onCameraMove, CameraMoveEvent } from '../cameraRotationControls'
@ -77,7 +78,10 @@ function GameInteractionOverlayInner ({
if (options.touchInteractionType === 'classic') {
virtualClickTimeout ??= setTimeout(() => {
virtualClickActive = true
document.dispatchEvent(new MouseEvent('mousedown', { button: 0 }))
// If held item is activatable, use right click instead of left
const heldItemName = bot?.heldItem?.name
const isOnlyActivatable = heldItemName && isItemActivatableMobile(heldItemName, loadedData)
document.dispatchEvent(new MouseEvent('mousedown', { button: isOnlyActivatable ? 2 : 0 }))
}, touchStartBreakingBlockMs)
}
}
@ -150,16 +154,23 @@ function GameInteractionOverlayInner ({
if (virtualClickActive) {
// button 0 is left click
document.dispatchEvent(new MouseEvent('mouseup', { button: 0 }))
// If held item is activatable, use right click instead of left
const heldItemName = bot?.heldItem?.name
const isOnlyActivatable = heldItemName && isItemActivatableMobile(heldItemName, loadedData)
document.dispatchEvent(new MouseEvent('mouseup', { button: isOnlyActivatable ? 2 : 0 }))
virtualClickActive = false
} else if (!capturedPointer.active.activateCameraMove && (Date.now() - capturedPointer.active.time < touchStartBreakingBlockMs)) {
// single click action
const MOUSE_BUTTON_RIGHT = 2
const MOUSE_BUTTON_LEFT = 0
const heldItemName = bot?.heldItem?.name
const isOnlyActivatable = heldItemName && isItemActivatableMobile(heldItemName, loadedData)
const gonnaAttack = !!bot.mouse.getCursorState().entity || !!videoCursorInteraction()
document.dispatchEvent(new MouseEvent('mousedown', { button: gonnaAttack ? MOUSE_BUTTON_LEFT : MOUSE_BUTTON_RIGHT }))
// If not attacking entity and item is activatable, use right click for breaking
const useButton = !gonnaAttack && isOnlyActivatable ? MOUSE_BUTTON_RIGHT : (gonnaAttack ? MOUSE_BUTTON_LEFT : MOUSE_BUTTON_RIGHT)
document.dispatchEvent(new MouseEvent('mousedown', { button: useButton }))
bot.mouse.update()
document.dispatchEvent(new MouseEvent('mouseup', { button: gonnaAttack ? MOUSE_BUTTON_LEFT : MOUSE_BUTTON_RIGHT }))
document.dispatchEvent(new MouseEvent('mouseup', { button: useButton }))
}
if (screenTouches > 0) {

View file

@ -11,7 +11,6 @@
--offset: calc(-1 * (52px + (9px * (4 * var(--kind) + var(--lightened) * 2)) ));
--bg-x: calc(-1 * (16px + 9px * var(--lightened)));
--bg-y: calc(-1 * var(--hardcore) * 45px);
pointer-events: none;
image-rendering: pixelated;
}

View file

@ -2,15 +2,15 @@ import { useEffect, useRef, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { createPortal } from 'react-dom'
import { subscribe, useSnapshot } from 'valtio'
import { openItemsCanvas, openPlayerInventory, upInventoryItems } from '../inventoryWindows'
import { openItemsCanvas, upInventoryItems } from '../inventoryWindows'
import { activeModalStack, isGameActive, miscUiState } from '../globalState'
import { currentScaling } from '../scaleInterface'
import { watchUnloadForCleanup } from '../gameUnload'
import { getItemNameRaw } from '../mineflayer/items'
import { isInRealGameSession } from '../utils'
import { triggerCommand } from '../controls'
import MessageFormattedString from './MessageFormattedString'
import SharedHudVars from './SharedHudVars'
import { packetsReplayState } from './state/packetsReplayState'
const ItemName = ({ itemKey }: { itemKey: string }) => {
@ -75,6 +75,8 @@ const HotbarInner = () => {
const container = useRef<HTMLDivElement>(null!)
const [itemKey, setItemKey] = useState('')
const hasModals = useSnapshot(activeModalStack).length
const { currentTouch, appConfig } = useSnapshot(miscUiState)
const mobileOpenInventory = currentTouch && !appConfig?.disabledCommands?.includes('general.inventory')
useEffect(() => {
const controller = new AbortController()
@ -105,7 +107,7 @@ const HotbarInner = () => {
canvasManager.setScale(currentScaling.scale)
canvasManager.windowHeight = 25 * canvasManager.scale
canvasManager.windowWidth = (210 - (inv.inventory.supportsOffhand ? 0 : 25) + (miscUiState.currentTouch ? 28 : 0)) * canvasManager.scale
canvasManager.windowWidth = (210 - (inv.inventory.supportsOffhand ? 0 : 25) + (mobileOpenInventory ? 28 : 0)) * canvasManager.scale
}
setSize()
watchUnloadForCleanup(subscribe(currentScaling, setSize))
@ -113,17 +115,19 @@ const HotbarInner = () => {
container.current.appendChild(inv.canvas)
const upHotbarItems = () => {
if (!appViewer.resourcesManager?.itemsAtlasParser) return
upInventoryItems(true, inv)
globalThis.debugHotbarItems = upInventoryItems(true, inv)
}
canvasManager.canvas.onclick = (e) => {
if (!isGameActive(true)) return
const pos = inv.canvasManager.getMousePos(inv.canvas, e)
if (canvasManager.canvas.width - pos.x < 35 * inv.canvasManager.scale) {
openPlayerInventory()
if (canvasManager.canvas.width - pos.x < 35 * inv.canvasManager.scale && mobileOpenInventory) {
triggerCommand('general.inventory', true)
triggerCommand('general.inventory', false)
}
}
globalThis.debugUpHotbarItems = upHotbarItems
upHotbarItems()
bot.inventory.on('updateSlot', upHotbarItems)
appViewer.resourcesManager.on('assetsTexturesUpdated', upHotbarItems)
@ -180,17 +184,8 @@ const HotbarInner = () => {
})
document.addEventListener('touchend', (e) => {
if (touchStart && (e.target as HTMLElement).closest('.hotbar') && Date.now() - touchStart > 700) {
// drop item
bot._client.write('block_dig', {
'status': 4,
'location': {
'x': 0,
'z': 0,
'y': 0
},
'face': 0,
sequence: 0
})
triggerCommand('general.dropStack', true)
triggerCommand('general.dropStack', false)
}
touchStart = 0
})
@ -206,17 +201,28 @@ const HotbarInner = () => {
<ItemName itemKey={itemKey} />
<Portal>
<div
className='hotbar' ref={container} style={{
className='hotbar-fullscreen-container'
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
width: '100dvw',
height: '100dvh',
zIndex: hasModals ? 1 : 8,
display: 'flex',
justifyContent: 'center',
zIndex: hasModals ? 1 : 8,
pointerEvents: 'none',
bottom: 'var(--hud-bottom-raw)'
}}
/>
}}>
<div
className='hotbar'
ref={container}
style={{
position: 'absolute',
pointerEvents: 'none',
bottom: 'var(--hud-bottom-raw)'
}}
/>
</div>
</Portal>
</SharedHudVars>
}

View file

@ -1,5 +1,7 @@
import { useRef, useState, useMemo } from 'react'
import { GameMode } from 'mineflayer'
import { useSnapshot } from 'valtio'
import { options } from '../optionsStorage'
import { armor } from './armorValues'
import HealthBar from './HealthBar'
import FoodBar from './FoodBar'
@ -8,6 +10,8 @@ import BreathBar from './BreathBar'
import './HealthBar.css'
export default () => {
const { disabledUiParts } = useSnapshot(options)
const [damaged, setDamaged] = useState(false)
const [healthValue, setHealthValue] = useState(bot.health)
const [food, setFood] = useState(bot.food)
@ -91,7 +95,7 @@ export default () => {
}, [])
return <div className='hud-bars-container'>
<HealthBar
{!disabledUiParts.includes('health-bar') && <HealthBar
gameMode={gameMode}
isHardcore={isHardcore}
damaged={damaged}
@ -102,12 +106,12 @@ export default () => {
setEffectToAdd(null)
setEffectToRemove(null)
}}
/>
<ArmorBar
/>}
{!disabledUiParts.includes('armor-bar') && <ArmorBar
armorValue={armorValue}
style={gameMode !== 'survival' && gameMode !== 'adventure' ? { display: 'none' } : { display: 'flex' }}
/>
<FoodBar
/>}
{!disabledUiParts.includes('food-bar') && <FoodBar
gameMode={gameMode}
food={food}
effectToAdd={effectToAdd}
@ -116,9 +120,9 @@ export default () => {
setEffectToAdd(null)
setEffectToRemove(null)
}}
/>
<BreathBar
/>}
{!disabledUiParts.includes('breath-bar') && <BreathBar
oxygen={gameMode !== 'survival' && gameMode !== 'adventure' ? 0 : oxygen}
/>
/>}
</div>
}

View file

@ -5,7 +5,6 @@ import { Effect } from 'mineflayer'
import { inGameError } from '../utils'
import { fsState } from '../loadSave'
import { gameAdditionalState, miscUiState } from '../globalState'
import { options } from '../optionsStorage'
import IndicatorEffects, { EffectType, defaultIndicatorsState } from './IndicatorEffects'
import { images } from './effectsImages'
@ -67,7 +66,6 @@ export default ({ displayEffects = true, displayIndicators = true }: { displayEf
const { mesherWork } = useSnapshot(appViewer.rendererState).world
const { hasErrors } = useSnapshot(miscUiState)
const { disabledUiParts } = useSnapshot(options)
const { isReadonly, openReadOperations, openWriteOperations } = useSnapshot(fsState)
const { noConnection, poorConnection } = useSnapshot(gameAdditionalState)
const allIndicators: typeof defaultIndicatorsState = {
@ -122,7 +120,7 @@ export default ({ displayEffects = true, displayIndicators = true }: { displayEf
return <IndicatorEffects
indicators={allIndicators}
effects={effects}
displayIndicators
displayEffects
displayIndicators={displayIndicators}
displayEffects={displayEffects}
/>
}

View file

@ -0,0 +1,58 @@
.monaco-editor-container {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 16px;
background-color: rgba(0, 0, 0, 0.5);
}
.monaco-editor-title {
font-size: 20px;
font-weight: bold;
color: #fff;
margin-bottom: 8px;
}
.monaco-editor-wrapper {
position: relative;
width: 100%;
height: 100%;
max-width: 80vw;
max-height: 80vh;
border: 3px solid #000;
background-color: #000;
padding: 3px;
box-shadow: inset 0 0 0 1px #fff, inset 0 0 0 2px #000;
}
.monaco-editor-close {
position: fixed;
top: 16px;
left: 16px;
z-index: 1001;
cursor: pointer;
padding: 8px;
}
@media (max-width: 768px) {
.monaco-editor-container {
padding: 0;
}
.monaco-editor-wrapper {
max-width: 100%;
max-height: 100%;
border-radius: 0;
}
.monaco-editor-close {
top: 8px;
left: 8px;
}
.monaco-editor-title {
/* todo: make it work on mobile */
display: none;
}
}

View file

@ -0,0 +1,73 @@
import { proxy, useSnapshot } from 'valtio'
import { useEffect } from 'react'
import { Editor } from '@monaco-editor/react'
import PixelartIcon, { pixelartIcons } from '../react/PixelartIcon'
import { useIsModalActive } from '../react/utilsApp'
import { showNotification } from '../react/NotificationProvider'
import { hideModal, showModal } from '../globalState'
import { ideState, saveIde } from '../core/ideChannels'
import './MonacoEditor.css'
export default () => {
const { contents, line, column, id, language, title } = useSnapshot(ideState)
const isModalActive = useIsModalActive('monaco-editor')
const bodyFont = getComputedStyle(document.body).fontFamily
useEffect(() => {
if (id && !isModalActive) {
showModal({ reactType: 'monaco-editor' })
}
if (!id && isModalActive) {
hideModal()
}
}, [id])
useEffect(() => {
if (!isModalActive && id) {
try {
saveIde()
} catch (err) {
reportError(err)
showNotification('Failed to save the editor', 'Please try again', true)
}
ideState.id = ''
ideState.contents = ''
}
}, [isModalActive])
if (!isModalActive) return null
return <div className="monaco-editor-container">
<div className="monaco-editor-close">
<PixelartIcon
iconName={pixelartIcons.close}
width={26}
onClick={() => {
hideModal()
}}
/>
</div>
<div className="monaco-editor-title">
{title}
</div>
<div className="monaco-editor-wrapper">
<Editor
height="100%"
width="100%"
language={language}
theme='vs-dark'
line={line}
onChange={(value) => {
ideState.contents = value ?? ''
}}
value={contents}
options={{
fontFamily: bodyFont,
minimap: {
enabled: true,
},
}}
/>
</div>
</div>
}

Some files were not shown because too many files have changed in this diff Show more