Compare commits

..

1 commit

Author SHA1 Message Date
Cursor Agent
b9a9dfcf3e Add VR HUD with performance, position, and health information 2025-06-05 14:12:23 +00:00
171 changed files with 2923 additions and 9364 deletions

View file

@ -1,18 +0,0 @@
---
description: Restricts usage of the global Mineflayer `bot` variable to only src/ files; prohibits usage in renderer/. Specifies correct usage of player state and appViewer globals.
globs: src/**/*.ts,renderer/**/*.ts
alwaysApply: false
---
Ask AI
- The global variable `bot` refers to the Mineflayer bot instance.
- You may use `bot` directly in any file under the `src/` directory (e.g., `src/mineflayer/playerState.ts`).
- Do **not** use `bot` directly in any file under the `renderer/` directory or its subfolders (e.g., `renderer/viewer/three/worldrendererThree.ts`).
- In renderer code, all bot/player state and events must be accessed via explicit interfaces, state managers, or passed-in objects, never by referencing `bot` directly.
- In renderer code (such as in `WorldRendererThree`), use the `playerState` property (e.g., `worldRenderer.playerState.gameMode`) to access player state. The implementation for `playerState` lives in `src/mineflayer/playerState.ts`.
- In `src/` code, you may use the global variable `appViewer` from `src/appViewer.ts` directly. Do **not** import `appViewer` or use `window.appViewer`; use the global `appViewer` variable as-is.
- Some other global variables that can be used without window prefixes are listed in src/globals.d.ts
Rationale: This ensures a clean separation between the Mineflayer logic (server-side/game logic) and the renderer (client-side/view logic), making the renderer portable and testable, and maintains proper usage of global state.
For more general project contributing guides see CONTRIBUTING.md on like how to setup the project. Use pnpm tsc if needed to validate result with typechecking the whole project.

View file

@ -23,7 +23,6 @@
// ],
"@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: 22
node-version: 18
cache: "pnpm"
- name: Move Cypress to dependencies
run: |

View file

@ -23,8 +23,6 @@ jobs:
- name: Build single-file version - minecraft.html
run: pnpm build-single-file && mv dist/single/index.html minecraft.html
env:
LOCAL_CONFIG_FILE: config.mcraft-only.json
- name: Upload artifact
uses: actions/upload-artifact@v4

View file

@ -23,8 +23,6 @@ jobs:
- name: Build project
run: pnpm build
env:
LOCAL_CONFIG_FILE: config.mcraft-only.json
- name: Bundle server.js
run: |

View file

@ -33,7 +33,7 @@ jobs:
cd package
zip -r ../self-host.zip .
- run: pnpm build-playground
# - run: pnpm build-storybook
- run: pnpm build-storybook
- run: pnpm test-unit
- run: pnpm lint

View file

@ -36,7 +36,7 @@ jobs:
run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
env:
CONFIG_JSON_SOURCE: BUNDLED
LOCAL_CONFIG_FILE: config.mcraft-only.json
- run: pnpm build-storybook
- name: Copy playground files
run: |
mkdir -p .vercel/output/static/playground

View file

@ -78,7 +78,7 @@ jobs:
run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
env:
CONFIG_JSON_SOURCE: BUNDLED
LOCAL_CONFIG_FILE: config.mcraft-only.json
- run: pnpm build-storybook
- name: Copy playground files
run: |
mkdir -p .vercel/output/static/playground

View file

@ -34,40 +34,12 @@ jobs:
- run: vercel build --token=${{ secrets.VERCEL_TOKEN }} --prod
env:
CONFIG_JSON_SOURCE: BUNDLED
LOCAL_CONFIG_FILE: config.mcraft-only.json
- run: pnpm build-storybook
- name: Copy playground files
run: |
mkdir -p .vercel/output/static/playground
pnpm build-playground
cp -r renderer/dist/* .vercel/output/static/playground/
# publish to github
- run: cp vercel.json .vercel/output/static/vercel.json
- uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
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>
sed -i 's/<title>Minecraft Web Client<\/title>/<title>Minecraft Web Client — Free Online Browser Version<\/title>/' .vercel/output/static/index.html
- name: Deploy Project to Vercel
uses: mathiasvr/command-output@v2.0.0
with:
@ -81,6 +53,13 @@ jobs:
for alias in $(echo ${{ steps.alias.outputs.alias }} | tr "," "\n"); do
vercel alias set ${{ steps.deploy.outputs.stdout }} $alias --token=${{ secrets.VERCEL_TOKEN }} --scope=zaro
done
# publish to github
- run: cp vercel.json .vercel/output/static/vercel.json
- uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: .vercel/output/static
force_orphan: true
- name: Build single-file version - minecraft.html
run: pnpm build-single-file && mv dist/single/index.html minecraft.html

1
.gitignore vendored
View file

@ -19,6 +19,5 @@ generated
storybook-static
server-jar
config.local.json
logs/
src/react/npmReactComponents.ts

View file

@ -177,13 +177,8 @@ New React components, improve UI (including mobile support).
## Updating Dependencies
1. Use `pnpm update-git-deps` to check and update git dependencies (like mineflayer fork, prismarine packages etc). The script will:
- Show which git dependencies have updates available
- Ask if you want to update them
- Skip dependencies listed in `pnpm.updateConfig.ignoreDependencies`
1. Ensure mineflayer fork is up to date with the latest version of mineflayer original repo
2. Update PrismarineJS dependencies to the latest version: `minecraft-data` (be sure to replace the version twice in the package.json), `mineflayer`, `minecraft-protocol`, `prismarine-block`, `prismarine-chunk`, `prismarine-item`, ...
3. If `minecraft-protocol` patch fails, do this:
1. Remove the patch from `patchedDependencies` in `package.json`
2. Run `pnpm patch minecraft-protocol`, open patch directory

View file

@ -6,13 +6,9 @@ Minecraft **clone** rewritten in TypeScript using the best modern web technologi
You can try this out at [mcraft.fun](https://mcraft.fun/), [pcm.gg](https://pcm.gg) (short link), [mcon.vercel.app](https://mcon.vercel.app/) or the GitHub pages deploy. Every commit from the default (`develop`) branch is deployed to [s.mcraft.fun](https://s.mcraft.fun/) and [s.pcm.gg](https://s.pcm.gg/) - so it's usually newer, but might be less stable.
> For Turkey/Russia use [ru.mcraft.fun](https://ru.mcraft.fun/) (since Cloudflare is blocked)
Don't confuse with [Eaglercraft](https://git.eaglercraft.rip/eaglercraft/eaglercraft-1.8) which is a REAL vanilla Minecraft Java edition port to the web (but with its own limitations). Eaglercraft is a fully playable solution, but this project is more in position of a "technical demo" to show how it's possible to build games for web at scale entirely with the JS ecosystem. Have fun!
Don't confuse with [Eaglercraft](https://git.eaglercraft.rip/eaglercraft/eaglercraft-1.8) which is a REAL vanilla Minecraft Java edition port to the web (but with its own limitations). Eaglercraft is a fully playable solution, meanwhile this project is aimed for *device-compatiiblity* and better performance so it feels portable, flexible and lightweight. It's also a very strong example on how to build true HTML games for the web at scale entirely with the JS ecosystem. Have fun!
For building the project yourself / contributing, see [Development, Debugging & Contributing](#development-debugging--contributing). For reference at what and how web technologies / frameworks are used, see [TECH.md](./TECH.md) (also for comparison with Eaglercraft).
> **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)
For building the project yourself / contributing, see [Development, Debugging & Contributing](#development-debugging--contributing). For reference at what and how web technologies / frameworks are used, see [TECH.md](./TECH.md).
### Big Features
@ -32,7 +28,7 @@ For building the project yourself / contributing, see [Development, Debugging &
- Support for custom rendering 3D engines. Modular architecture.
- even even more!
All components that are in [Storybook](https://minimap.mcraft.fun/storybook/) are published as npm module and can be used in other projects: [`minecraft-react`](https://npmjs.com/minecraft-react)
All components that are in [Storybook](https://mcraft.fun/storybook) are published as npm module and can be used in other projects: [`minecraft-react`](https://npmjs.com/minecraft-react)
### Recommended Settings
@ -44,19 +40,15 @@ All components that are in [Storybook](https://minimap.mcraft.fun/storybook/) ar
### Browser Notes
This project is tested with BrowserStack. Special thanks to [BrowserStack](https://www.browserstack.com/) for providing testing infrastructure!
Howerver, it's known that these browsers have issues:
These browsers have issues with capturing pointer:
**Opera Mini**: Disable *mouse gestures* in browsre settings to avoid opening new tab on right click hold
**Vivaldi**: Disable Controls -> *Raw Input* in game settings if experiencing issues
### Versions Support
Server versions 1.8 - 1.21.5 are supported.
Server versions 1.8 - 1.21.4 are supported.
First class versions (most of the features are tested on these versions):
- 1.19.4
- 1.21.4
@ -78,8 +70,6 @@ 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
@ -127,12 +117,12 @@ 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:
- If you type `debugToggle`, press enter in console - It will enables all debug messages! Warning: this will start all packets spam.
- `localStorage.debug = '*'` - 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.
- `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.
- `viewer` - Three.js viewer instance, basically does all the rendering.
- `viewer.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.
- `localServer`/`server` - Only for singleplayer mode/host. Flying Squid server instance, see it's documentation for more.
@ -141,7 +131,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 `world.getCameraPosition()` 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 `viewer.camera.position` to see the camera position and so on.
<img src="./docs-assets/watch-expr.png" alt="Watch expression" width="480"/>
@ -178,7 +168,6 @@ 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:
@ -235,4 +224,3 @@ 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

@ -1,2 +0,0 @@
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

@ -1,237 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Input Debugger</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
background: #f0f0f0;
}
.key-container {
display: grid;
grid-template-columns: repeat(3, 60px);
gap: 5px;
margin: 20px 0;
}
.key {
width: 60px;
height: 60px;
border: 2px solid #333;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
background: white;
position: relative;
user-select: none;
}
.key.pressed {
background: #90EE90;
}
.key .duration {
position: absolute;
bottom: 2px;
font-size: 10px;
}
.key .count {
position: absolute;
top: 2px;
right: 2px;
font-size: 10px;
}
.controls {
margin: 20px 0;
padding: 10px;
background: white;
border-radius: 5px;
}
.wasd-container {
position: relative;
width: 190px;
height: 130px;
}
#KeyW {
position: absolute;
left: 65px;
top: 0;
}
#KeyA {
position: absolute;
left: 0;
top: 65px;
}
#KeyS {
position: absolute;
left: 65px;
top: 65px;
}
#KeyD {
position: absolute;
left: 130px;
top: 65px;
}
.space-container {
margin-top: 20px;
}
#Space {
width: 190px;
}
</style>
</head>
<body>
<div class="controls">
<label>
<input type="checkbox" id="repeatMode"> Use keydown repeat mode (auto key-up after 150ms of no repeat)
</label>
</div>
<div class="wasd-container">
<div id="KeyW" class="key" data-code="KeyW">W</div>
<div id="KeyA" class="key" data-code="KeyA">A</div>
<div id="KeyS" class="key" data-code="KeyS">S</div>
<div id="KeyD" class="key" data-code="KeyD">D</div>
</div>
<div class="key-container">
<div id="ControlLeft" class="key" data-code="ControlLeft">Ctrl</div>
</div>
<div class="space-container">
<div id="Space" class="key" data-code="Space">Space</div>
</div>
<script>
const keys = {};
const keyStats = {};
const pressStartTimes = {};
const keyTimeouts = {};
function initKeyStats(code) {
if (!keyStats[code]) {
keyStats[code] = {
pressCount: 0,
duration: 0,
startTime: 0
};
}
}
function updateKeyVisuals(code) {
const element = document.getElementById(code);
if (!element) return;
const stats = keyStats[code];
if (keys[code]) {
element.classList.add('pressed');
const currentDuration = ((Date.now() - stats.startTime) / 1000).toFixed(1);
element.innerHTML = `${element.getAttribute('data-code').replace('Key', '').replace('Left', '')}<span class="duration">${currentDuration}s</span><span class="count">${stats.pressCount}</span>`;
} else {
element.classList.remove('pressed');
element.innerHTML = `${element.getAttribute('data-code').replace('Key', '').replace('Left', '')}<span class="count">${stats.pressCount}</span>`;
}
}
function releaseKey(code) {
keys[code] = false;
if (pressStartTimes[code]) {
keyStats[code].duration += (Date.now() - pressStartTimes[code]) / 1000;
delete pressStartTimes[code];
}
updateKeyVisuals(code);
}
function handleKeyDown(event) {
const code = event.code;
const isRepeatMode = document.getElementById('repeatMode').checked;
initKeyStats(code);
// Clear any existing timeout for this key
if (keyTimeouts[code]) {
clearTimeout(keyTimeouts[code]);
delete keyTimeouts[code];
}
if (isRepeatMode) {
// In repeat mode, always handle the keydown
if (!keys[code] || event.repeat) {
keys[code] = true;
if (!event.repeat) {
// Only increment count on initial press, not repeats
keyStats[code].pressCount++;
keyStats[code].startTime = Date.now();
pressStartTimes[code] = Date.now();
}
}
// Set timeout to release key if no repeat events come
keyTimeouts[code] = setTimeout(() => {
releaseKey(code);
}, 150);
} else {
// In normal mode, only handle keydown if key is not already pressed
if (!keys[code]) {
keys[code] = true;
keyStats[code].pressCount++;
keyStats[code].startTime = Date.now();
pressStartTimes[code] = Date.now();
}
}
updateKeyVisuals(code);
event.preventDefault();
}
function handleKeyUp(event) {
const code = event.code;
const isRepeatMode = document.getElementById('repeatMode').checked;
if (!isRepeatMode) {
releaseKey(code);
}
event.preventDefault();
}
// Initialize all monitored keys
const monitoredKeys = ['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ControlLeft', 'Space'];
monitoredKeys.forEach(code => {
initKeyStats(code);
const element = document.getElementById(code);
if (element) {
element.innerHTML = `${element.getAttribute('data-code').replace('Key', '').replace('Left', '')}<span class="count">0</span>`;
}
});
// Start visual updates
setInterval(() => {
monitoredKeys.forEach(code => {
if (keys[code]) {
updateKeyVisuals(code);
}
});
}, 100);
// Event listeners
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('keyup', handleKeyUp);
// Handle mode changes
document.getElementById('repeatMode').addEventListener('change', () => {
// Release all keys when switching modes
monitoredKeys.forEach(code => {
if (keys[code]) {
releaseKey(code);
}
if (keyTimeouts[code]) {
clearTimeout(keyTimeouts[code]);
delete keyTimeouts[code];
}
});
});
</script>
</body>
</html>

View file

@ -3,17 +3,12 @@
"defaultHost": "<from-proxy>",
"defaultProxy": "https://proxy.mcraft.fun",
"mapsProvider": "https://maps.mcraft.fun/",
"skinTexturesProxy": "",
"peerJsServer": "",
"peerJsServerFallback": "https://p2p.mcraft.fun",
"promoteServers": [
{
"ip": "wss://play.mcraft.fun"
},
{
"ip": "wss://play.webmc.fun",
"name": "WebMC"
},
{
"ip": "wss://ws.fuchsmc.net"
},
@ -21,8 +16,8 @@
"ip": "wss://play2.mcraft.fun"
},
{
"ip": "wss://play-creative.mcraft.fun",
"description": "Might be available soon, stay tuned!"
"ip": "wss://mcraft.ryzyn.xyz",
"version": "1.19.4"
},
{
"ip": "kaboom.pw",
@ -43,7 +38,6 @@
}
]
],
"defaultUsername": "mcrafter{0-9999}",
"mobileButtons": [
{
"action": "general.drop",

View file

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

View file

@ -1,13 +0,0 @@
<!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>

View file

@ -1,108 +0,0 @@
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

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

View file

@ -1,67 +0,0 @@
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,60 +1,101 @@
import * as THREE from 'three'
import * as tweenJs from '@tweenjs/tween.js'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import * as THREE from 'three';
import Jimp from 'jimp';
// Create scene, camera and renderer
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
camera.position.set(0, 0, 5)
const renderer = new THREE.WebGLRenderer()
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)
// Position camera
camera.position.z = 5
const controls = new OrbitControls(camera, renderer.domElement)
// Create a canvas with some content
const canvas = document.createElement('canvas')
canvas.width = 256
canvas.height = 256
const ctx = canvas.getContext('2d')
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 })
const cube = new THREE.Mesh(geometry, material)
cube.position.set(0.5, 0.5, 0.5);
const group = new THREE.Group()
group.add(cube)
group.position.set(-0.5, -0.5, -0.5);
const outerGroup = new THREE.Group()
outerGroup.add(group)
outerGroup.scale.set(0.2, 0.2, 0.2)
outerGroup.position.set(1, 1, 0)
scene.add(outerGroup)
scene.background = new THREE.Color(0x444444)
// const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({ color: 0x00_00_ff, transparent: true, opacity: 0.5 }))
// mesh.position.set(0.5, 1, 0.5)
// const group = new THREE.Group()
// group.add(mesh)
// group.position.set(-0.5, -1, -0.5)
// const outerGroup = new THREE.Group()
// outerGroup.add(group)
// // outerGroup.position.set(this.camera.position.x, this.camera.position.y, this.camera.position.z)
// scene.add(outerGroup)
// Draw something on the canvas
ctx.fillStyle = '#444444'
// ctx.fillRect(0, 0, 256, 256)
ctx.fillStyle = 'red'
ctx.font = '48px Arial'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText('Hello!', 128, 128)
new tweenJs.Tween(group.rotation).to({ z: THREE.MathUtils.degToRad(90) }, 1000).yoyo(true).repeat(Infinity).start()
// Create bitmap and texture
async function createTexturedBox() {
const canvas2 = new OffscreenCanvas(256, 256)
const ctx2 = canvas2.getContext('2d')!
ctx2.drawImage(canvas, 0, 0)
const texture = new THREE.Texture(canvas2)
texture.magFilter = THREE.NearestFilter
texture.minFilter = THREE.NearestFilter
texture.needsUpdate = true
texture.flipY = false
// Create box with texture
const geometry = new THREE.BoxGeometry(2, 2, 2)
const material = new THREE.MeshBasicMaterial({
map: texture,
side: THREE.DoubleSide,
premultipliedAlpha: false,
})
const cube = new THREE.Mesh(geometry, material)
scene.add(cube)
}
// Create the textured box
createTexturedBox()
// Animation loop
function animate() {
requestAnimationFrame(animate)
renderer.render(scene, camera)
const tweenGroup = new tweenJs.Group()
function animate () {
tweenGroup.update()
requestAnimationFrame(animate)
// cube.rotation.x += 0.01
// cube.rotation.y += 0.01
renderer.render(scene, camera)
}
animate()
// let animation
window.animate = () => {
// new Tween.Tween(group.position).to({ y: group.position.y - 1}, 1000 * 0.35/2).yoyo(true).repeat(1).start()
new tweenJs.Tween(group.rotation, tweenGroup).to({ z: THREE.MathUtils.degToRad(90) }, 1000 * 0.35 / 2).yoyo(true).repeat(Infinity).start().onRepeat(() => {
console.log('done')
})
}
window.stop = () => {
tweenGroup.removeAll()
}
function createGeometryFromImage() {
return new Promise<THREE.ShapeGeometry>((resolve, reject) => {
const img = new Image();
img.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAABEElEQVQ4jWNkIAPw2Zv9J0cfXPOSvx/+L/n74T+HqsJ/JlI1T9u3i6H91B7ybdY+vgZuO1majV+fppFmPnuz/+ihy2dv9t/49Wm8mlECkV1FHh5FfPZm/1XXTGX4cechA4eKPMNVq1CGH7cfMBJ0rlxX+X8OVYX/xq9P/5frKifoZ0Z0AwS8HRkYGBgYvt+8xyDXUUbQZgwJPnuz/+wq8gw/7zxk+PXsFUFno0h6mon+l5fgZFhwnYmBTUqMgYGBgaAhLMiaHQyFGOZvf8Lw49FXRgYGhv8MDAwwg/7jMoQFFury/C8Y5m9/wnADohnZVryJhoWBARJ9Cw69gtmMAgiFAcuvZ68Yfj17hU8NXgAATdKfkzbQhBEAAAAASUVORK5CYII='
console.log('img.complete', img.complete)
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const context = canvas.getContext('2d');
context.drawImage(img, 0, 0, img.width, img.height);
const imgData = context.getImageData(0, 0, img.width, img.height);
const shape = new THREE.Shape();
for (let y = 0; y < img.height; y++) {
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const alpha = imgData.data[index + 3];
if (alpha !== 0) {
shape.lineTo(x, y);
}
}
}
const geometry = new THREE.ShapeGeometry(shape);
resolve(geometry);
};
img.onerror = reject;
});
}
// Usage:
const shapeGeomtry = createGeometryFromImage().then(geometry => {
const material = new THREE.MeshBasicMaterial({ color: 0xffffff });
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
})

View file

@ -27,7 +27,6 @@
<div style="font-size: var(--font-size);color: rgb(176, 176, 176);margin-top: 3px;text-align: center" class="subtitle">A true Minecraft client in your browser!</div>
<!-- small text pre -->
<div style="font-size: calc(var(--font-size) * 0.6);color: rgb(150, 150, 150);margin-top: 3px;text-align: center;white-space: pre-line;" class="advanced-info"></div>
<div style="font-size: calc(var(--font-size) * 0.6);color: rgb(255, 100, 100);margin-top: 10px;text-align: center;display: none;" class="ios-warning">Only iOS 15+ is supported due to performance optimizations</div>
</div>
</div>
`
@ -37,13 +36,6 @@
if (!window.pageLoaded) {
document.documentElement.appendChild(loadingDivElem)
}
// iOS version detection
const getIOSVersion = () => {
const match = navigator.userAgent.match(/OS (\d+)_(\d+)_?(\d+)?/);
return match ? parseInt(match[1], 10) : null;
}
// load error handling
const onError = (errorOrMessage, log = false) => {
let message = errorOrMessage instanceof Error ? (errorOrMessage.stack ?? errorOrMessage.message) : errorOrMessage
@ -54,23 +46,12 @@
const [errorMessage, ...errorStack] = message.split('\n')
document.querySelector('.initial-loader').querySelector('.subtitle').textContent = errorMessage
document.querySelector('.initial-loader').querySelector('.advanced-info').textContent = errorStack.join('\n')
// Show iOS warning if applicable
const iosVersion = getIOSVersion();
if (iosVersion !== null && iosVersion < 15) {
document.querySelector('.initial-loader').querySelector('.ios-warning').style.display = 'block';
}
if (window.navigator.maxTouchPoints > 1) window.location.hash = '#dev' // show eruda
// unregister all sw
if (window.navigator.serviceWorker && document.querySelector('.initial-loader').style.opacity !== 0) {
console.log('got worker')
if (window.navigator.serviceWorker) {
window.navigator.serviceWorker.getRegistrations().then(registrations => {
registrations.forEach(registration => {
console.log('got registration')
registration.unregister().then(() => {
console.log('worker unregistered')
})
registration.unregister()
})
})
}

View file

@ -7,7 +7,6 @@
"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,9 +31,7 @@
"run-playground": "run-p watch-mesher watch-other-workers watch-playground",
"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",
"request-data": "tsx scripts/requestData.ts"
"watch-playground": "rsbuild dev --config renderer/rsbuild.config.ts"
},
"keywords": [
"prismarine",
@ -54,7 +51,6 @@
"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",
@ -80,14 +76,14 @@
"esbuild-plugin-polyfill-node": "^0.3.0",
"express": "^4.18.2",
"filesize": "^10.0.12",
"flying-squid": "npm:@zardoy/flying-squid@^0.0.104",
"flying-squid": "npm:@zardoy/flying-squid@^0.0.59",
"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.98.0",
"minecraft-data": "3.89.0",
"minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master",
"mineflayer-item-map-downloader": "github:zardoy/mineflayer-item-map-downloader",
"mojangson": "^2.0.4",
@ -154,10 +150,11 @@
"http-browserify": "^1.7.0",
"http-server": "^14.1.1",
"https-browserify": "^1.0.0",
"mc-assets": "^0.2.62",
"mc-assets": "^0.2.54",
"minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next",
"mineflayer": "github:zardoy/mineflayer#gen-the-master",
"mineflayer-mouse": "^0.1.21",
"mineflayer-mouse": "^0.1.10",
"mineflayer-pathfinder": "^2.4.4",
"npm-run-all": "^4.1.5",
"os-browserify": "^0.3.0",
"path-browserify": "^1.0.1",
@ -197,7 +194,6 @@
},
"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",
@ -205,7 +201,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.98.0",
"minecraft-data": "3.89.0",
"prismarine-provider-anvil": "github:zardoy/prismarine-provider-anvil#everything",
"prismarine-physics": "github:zardoy/prismarine-physics",
"minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master",
@ -214,10 +210,7 @@
"prismarine-item": "latest"
},
"updateConfig": {
"ignoreDependencies": [
"browserfs",
"google-drive-browserfs"
]
"ignoreDependencies": []
},
"patchedDependencies": {
"pixelarticons@1.8.1": "patches/pixelarticons@1.8.1.patch",
@ -234,9 +227,7 @@
"cypress",
"esbuild",
"fsevents"
],
"ignorePatchFailures": false,
"allowUnusedPatches": false
]
},
"packageManager": "pnpm@10.8.0+sha512.0e82714d1b5b43c74610193cb20734897c1d00de89d0e18420aebc5977fa13d780a9cb05734624e81ebd81cc876cd464794850641c48b9544326b5622ca29971"
}

View file

@ -1,26 +1,29 @@
diff --git a/README.md b/README.md
deleted file mode 100644
index fbcaa43667323a58b8110a4495938c2c6d2d6f83..0000000000000000000000000000000000000000
diff --git a/src/client/chat.js b/src/client/chat.js
index 0021870994fc59a82f0ac8aba0a65a8be43ef2f4..a53fceb843105ea2a1d88722b3fc7c3b43cb102a 100644
index f14269bea055d4329cd729271e7406ec4b344de7..00f5482eb6e3c911381ca9a728b1b4aae0d1d337 100644
--- a/src/client/chat.js
+++ b/src/client/chat.js
@@ -116,7 +116,7 @@ module.exports = function (client, options) {
for (const player of packet.data) {
if (player.chatSession) {
client._players[player.uuid] = {
- publicKey: crypto.createPublicKey({ key: player.chatSession.publicKey.keyBytes, format: 'der', type: 'spki' }),
+ // publicKey: crypto.createPublicKey({ key: player.chatSession.publicKey.keyBytes, format: 'der', type: 'spki' }),
publicKeyDER: player.chatSession.publicKey.keyBytes,
sessionUuid: player.chatSession.uuid
}
@@ -126,7 +126,7 @@ module.exports = function (client, options) {
if (player.crypto) {
client._players[player.uuid] = {
- publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }),
+ // publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }),
publicKeyDER: player.crypto.publicKey,
signature: player.crypto.signature,
displayName: player.displayName || player.name
@@ -196,7 +196,7 @@ module.exports = function (client, options) {
@@ -111,7 +111,7 @@ module.exports = function (client, options) {
for (const player of packet.data) {
if (!player.chatSession) continue
client._players[player.UUID] = {
- publicKey: crypto.createPublicKey({ key: player.chatSession.publicKey.keyBytes, format: 'der', type: 'spki' }),
+ // publicKey: crypto.createPublicKey({ key: player.chatSession.publicKey.keyBytes, format: 'der', type: 'spki' }),
publicKeyDER: player.chatSession.publicKey.keyBytes,
sessionUuid: player.chatSession.uuid
}
@@ -127,7 +127,7 @@ module.exports = function (client, options) {
for (const player of packet.data) {
if (player.crypto) {
client._players[player.UUID] = {
- publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }),
+ // publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }),
publicKeyDER: player.crypto.publicKey,
signature: player.crypto.signature,
displayName: player.displayName || player.name
@@ -198,7 +198,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 +31,8 @@ index 0021870994fc59a82f0ac8aba0a65a8be43ef2f4..a53fceb843105ea2a1d88722b3fc7c3b
+ 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', {
globalIndex: packet.globalIndex,
@@ -362,7 +362,7 @@ module.exports = function (client, options) {
plainMessage: packet.plainMessage,
@@ -363,7 +363,7 @@ module.exports = function (client, options) {
}
}
@ -38,16 +41,16 @@ index 0021870994fc59a82f0ac8aba0a65a8be43ef2f4..a53fceb843105ea2a1d88722b3fc7c3b
options.timestamp = options.timestamp || BigInt(Date.now())
options.salt = options.salt || 1n
@@ -407,7 +407,7 @@ module.exports = function (client, options) {
@@ -405,7 +405,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
@@ -422,7 +422,7 @@ module.exports = function (client, options) {
})
@@ -419,7 +419,7 @@ module.exports = function (client, options) {
message,
timestamp: options.timestamp,
salt: options.salt,
@ -57,7 +60,7 @@ index 0021870994fc59a82f0ac8aba0a65a8be43ef2f4..a53fceb843105ea2a1d88722b3fc7c3b
previousMessages: client._lastSeenMessages.map((e) => ({
messageSender: e.sender,
diff --git a/src/client/encrypt.js b/src/client/encrypt.js
index 63cc2bd9615100bd2fd63dfe14c094aa6b8cd1c9..36df57d1196af9761d920fa285ac48f85410eaef 100644
index b9d21bab9faccd5dbf1975fc423fc55c73e906c5..99ffd76527b410e3a393181beb260108f4c63536 100644
--- a/src/client/encrypt.js
+++ b/src/client/encrypt.js
@@ -25,7 +25,11 @@ module.exports = function (client, options) {
@ -73,24 +76,41 @@ index 63cc2bd9615100bd2fd63dfe14c094aa6b8cd1c9..36df57d1196af9761d920fa285ac48f8
}
function onJoinServerResponse (err) {
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
}
diff --git a/src/client/play.js b/src/client/play.js
index 6e06dc15291b38e1eeeec8d7102187b2a23d70a3..f67454942db9276cbb9eab99c281cfe182cb8a1f 100644
--- a/src/client/play.js
+++ b/src/client/play.js
@@ -53,7 +53,7 @@ module.exports = function (client, options) {
client.write('configuration_acknowledged', {})
}
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 e369e77d055ba919e8f9da7b8e8b5dc879c74cf4..54bb9e6644388e9b6bd42b3012951875989cdf0c 100644
index 74749698f8cee05b5dc749c271544f78d06645b0..e77e0a3f41c1ee780c3abbd54b0801d248c2a07c 100644
--- a/src/client.js
+++ b/src/client.js
@@ -111,7 +111,13 @@ class Client extends EventEmitter {
@@ -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 {
this._hasBundlePacket = false
}
} else {
@ -105,7 +125,7 @@ index e369e77d055ba919e8f9da7b8e8b5dc879c74cf4..54bb9e6644388e9b6bd42b3012951875
}
})
}
@@ -169,7 +175,10 @@ class Client extends EventEmitter {
@@ -168,7 +176,10 @@ class Client extends EventEmitter {
}
const onFatalError = (err) => {
@ -117,21 +137,25 @@ index e369e77d055ba919e8f9da7b8e8b5dc879c74cf4..54bb9e6644388e9b6bd42b3012951875
endSocket()
}
@@ -198,6 +207,10 @@ class Client extends EventEmitter {
@@ -197,6 +208,8 @@ class Client extends EventEmitter {
serializer -> framer -> socket -> splitter -> deserializer */
if (this.serializer) {
this.serializer.end()
+ setTimeout(() => {
+ this.socket?.end()
+ this.socket?.emit('end')
+ }, 2000) // allow the serializer to finish writing
+ this.socket?.end()
+ this.socket?.emit('end')
} else {
if (this.socket) this.socket.end()
}
@@ -243,6 +256,7 @@ class Client extends EventEmitter {
debug('writing packet ' + this.state + '.' + name)
debug(params)
}
@@ -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)
+ }
+ this.emit('writePacket', name, params)
this.serializer.write({ name, params })
}

View file

@ -1,5 +1,5 @@
diff --git a/fonts/pixelart-icons-font.css b/fonts/pixelart-icons-font.css
index 3b2ebe839370d96bf93ef5ca94a827f07e49378d..4f8d76be2ca6e4ddc43c68d0a6f0f69979165ab4 100644
index 3b2ebe839370d96bf93ef5ca94a827f07e49378d..103ab4d6b9f3b5c9f41d1407e3cbf4ac392fbf41 100644
--- a/fonts/pixelart-icons-font.css
+++ b/fonts/pixelart-icons-font.css
@@ -1,16 +1,13 @@
@ -10,11 +10,10 @@ index 3b2ebe839370d96bf93ef5ca94a827f07e49378d..4f8d76be2ca6e4ddc43c68d0a6f0f699
+ src:
url("pixelart-icons-font.woff2?t=1711815892278") format("woff2"),
url("pixelart-icons-font.woff?t=1711815892278") format("woff"),
- url('pixelart-icons-font.ttf?t=1711815892278') format('truetype'), /* chrome, firefox, opera, Safari, Android, iOS 4.2+*/
url('pixelart-icons-font.ttf?t=1711815892278') format('truetype'), /* chrome, firefox, opera, Safari, Android, iOS 4.2+*/
- url('pixelart-icons-font.svg?t=1711815892278#pixelart-icons-font') format('svg'); /* iOS 4.1- */
+ url('pixelart-icons-font.ttf?t=1711815892278') format('truetype'); /* chrome, firefox, opera, Safari, Android, iOS 4.2+*/
}
[class^="pixelart-icons-font-"], [class*=" pixelart-icons-font-"] {
font-family: 'pixelart-icons-font' !important;
- font-size:24px;

686
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -35,10 +35,6 @@ const buildOptions = {
define: {
'process.env.BROWSER': '"true"',
},
loader: {
'.png': 'dataurl',
'.obj': 'text'
},
plugins: [
...mesherSharedPlugins,
{

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) => Promise<void>, chunkSize = 1) => {
export const delayedIterator = async <T> (arr: T[], delay: number, exec: (item: T, index: number) => 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)
})
}
await exec(arr[i], i)
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|prismarine-physics/, (resource) => {
appendPlugins(new rspack.NormalModuleReplacementPlugin(/data/, (resource) => {
let absolute: string
const request = resource.request.replaceAll('\\', '/')
absolute = path.join(resource.context, request).replaceAll('\\', '/')
if (request.includes('minecraft-data/data/pc/1.') || request.includes('prismarine-physics')) {
console.log('Error: incompatible resource', request, 'from', resource.contextInfo.issuer)
if (request.includes('minecraft-data/data/pc/1.')) {
console.log('Error: incompatible resource', request, resource.contextInfo.issuer)
process.exit(1)
// throw new Error(`${resource.request} was requested by ${resource.contextInfo.issuer}`)
}

View file

@ -1,27 +1,16 @@
import { proxy } from 'valtio'
import { NonReactiveState, RendererReactiveState } from '../../src/appViewer'
import { RendererReactiveState } from '../../src/appViewer'
export const getDefaultRendererState = (): {
reactive: RendererReactiveState
nonReactive: NonReactiveState
} => {
export const getDefaultRendererState = (): RendererReactiveState => {
return {
reactive: proxy({
world: {
chunksLoaded: new Set(),
heightmaps: new Map(),
allChunksLoaded: true,
mesherWork: false,
intersectMedia: null
},
renderer: '',
preventEscapeMenu: false
}),
nonReactive: {
world: {
chunksLoaded: new Set(),
chunksTotalNumber: 0,
}
}
world: {
chunksLoaded: new Set(),
heightmaps: new Map(),
chunksTotalNumber: 0,
allChunksLoaded: true,
mesherWork: false,
intersectMedia: null
},
renderer: '',
preventEscapeMenu: false
}
}

View file

@ -1,87 +1,125 @@
import { EventEmitter } from 'events'
import { Vec3 } from 'vec3'
import TypedEmitter from 'typed-emitter'
import { ItemSelector } from 'mc-assets/dist/itemDefinitions'
import { GameMode, Team } from 'mineflayer'
import { proxy } from 'valtio'
import type { HandItemBlock } from '../three/holdingBlock'
import { proxy, ref } from 'valtio'
import { GameMode } from 'mineflayer'
import { HandItemBlock } from '../three/holdingBlock'
export type MovementState = 'NOT_MOVING' | 'WALKING' | 'SPRINTING' | 'SNEAKING'
export type ItemSpecificContextProperties = Partial<Pick<ItemSelector['properties'], 'minecraft:using_item' | 'minecraft:use_duration' | 'minecraft:use_cycle' | 'minecraft:display_context'>>
export type CameraPerspective = 'first_person' | 'third_person_back' | 'third_person_front'
export type PlayerStateEvents = {
heldItemChanged: (item: HandItemBlock | undefined, isLeftHand: boolean) => void
}
export type BlockShape = { position: any; width: any; height: any; depth: any; }
export type BlocksShapes = BlockShape[]
// edit src/mineflayer/playerState.ts for implementation of player state from mineflayer
export const getInitialPlayerState = () => proxy({
playerSkin: undefined as string | undefined,
inWater: false,
waterBreathing: false,
backgroundColor: [0, 0, 0] as [number, number, number],
ambientLight: 0,
directionalLight: 0,
eyeHeight: 0,
gameMode: undefined as GameMode | undefined,
lookingAtBlock: undefined as {
x: number
y: number
z: number
face?: number
shapes: BlocksShapes
} | undefined,
diggingBlock: undefined as {
x: number
y: number
z: number
stage: number
face?: number
mergedShape: BlockShape | undefined
} | undefined,
movementState: 'NOT_MOVING' as MovementState,
onGround: true,
sneaking: false,
flying: false,
sprinting: false,
itemUsageTicks: 0,
username: '',
onlineMode: false,
lightingDisabled: false,
shouldHideHand: false,
heldItemMain: undefined as HandItemBlock | undefined,
heldItemOff: undefined as HandItemBlock | undefined,
perspective: 'first_person' as CameraPerspective,
onFire: false,
export interface IPlayerState {
getEyeHeight(): number
getMovementState(): MovementState
getVelocity(): Vec3
isOnGround(): boolean
isSneaking(): boolean
isFlying(): boolean
isSprinting (): boolean
getItemUsageTicks?(): number
getPosition(): Vec3
// isUsingItem?(): boolean
getHeldItem?(isLeftHand: boolean): HandItemBlock | undefined
username?: string
onlineMode?: boolean
lightingDisabled?: boolean
shouldHideHand?: boolean
cameraSpectatingEntity: undefined as number | undefined,
events: TypedEmitter<PlayerStateEvents>
team: undefined as Team | undefined,
})
export const getPlayerStateUtils = (reactive: PlayerStateReactive) => ({
isSpectator () {
return reactive.gameMode === 'spectator'
},
isSpectatingEntity () {
return reactive.cameraSpectatingEntity !== undefined && reactive.gameMode === 'spectator'
},
isThirdPerson () {
if ((this as PlayerStateUtils).isSpectatingEntity()) return false
return reactive.perspective === 'third_person_back' || reactive.perspective === 'third_person_front'
}
})
export const getInitialPlayerStateRenderer = () => ({
reactive: getInitialPlayerState()
})
export type PlayerStateReactive = ReturnType<typeof getInitialPlayerState>
export type PlayerStateUtils = ReturnType<typeof getPlayerStateUtils>
export type PlayerStateRenderer = PlayerStateReactive
export const getItemSelector = (playerState: PlayerStateRenderer, specificProperties: ItemSpecificContextProperties, item?: import('prismarine-item').Item) => {
return {
...specificProperties,
'minecraft:date': new Date(),
// "minecraft:context_dimension": bot.entityp,
// 'minecraft:time': bot.time.timeOfDay / 24_000,
reactive: {
playerSkin: string | undefined
inWater: boolean
waterBreathing: boolean
backgroundColor: [number, number, number]
ambientLight: number
directionalLight: number
gameMode?: GameMode
lookingAtBlock?: {
x: number
y: number
z: number
face?: number
shapes: BlocksShapes
}
diggingBlock?: {
x: number
y: number
z: number
stage: number
face?: number
mergedShape?: BlockShape
}
}
}
export class BasePlayerState implements IPlayerState {
reactive = proxy({
playerSkin: undefined as string | undefined,
inWater: false,
waterBreathing: false,
backgroundColor: ref([0, 0, 0]) as [number, number, number],
ambientLight: 0,
directionalLight: 0,
})
protected movementState: MovementState = 'NOT_MOVING'
protected velocity = new Vec3(0, 0, 0)
protected onGround = true
protected sneaking = false
protected flying = false
protected sprinting = false
readonly events = new EventEmitter() as TypedEmitter<PlayerStateEvents>
getEyeHeight (): number {
return 1.62
}
getMovementState (): MovementState {
return this.movementState
}
getVelocity (): Vec3 {
return this.velocity
}
isOnGround (): boolean {
return this.onGround
}
isSneaking (): boolean {
return this.sneaking
}
isFlying (): boolean {
return this.flying
}
isSprinting (): boolean {
return this.sprinting
}
getPosition (): Vec3 {
return new Vec3(0, 0, 0)
}
// For testing purposes
setState (state: Partial<{
movementState: MovementState
velocity: Vec3
onGround: boolean
sneaking: boolean
flying: boolean
sprinting: boolean
}>) {
Object.assign(this, state)
}
}

View file

@ -1,55 +0,0 @@
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

@ -9,6 +9,11 @@ import { makeTextureAtlas } from 'mc-assets/dist/atlasCreator'
import { proxy, ref } from 'valtio'
import { getItemDefinition } from 'mc-assets/dist/itemDefinitions'
export const activeGuiAtlas = proxy({
atlas: null as null | { json, image },
version: 0
})
export const getNonFullBlocksModels = () => {
let version = appViewer.resourcesManager.currentResources!.version ?? 'latest'
if (versionToNumber(version) < versionToNumber('1.13')) version = '1.13'
@ -117,18 +122,18 @@ const RENDER_SIZE = 64
const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isItems = false) => {
const { currentResources } = appViewer.resourcesManager
const imgBitmap = isItems ? currentResources!.itemsAtlasImage : currentResources!.blocksAtlasImage
const img = await getLoadedImage(isItems ? currentResources!.itemsAtlasParser.latestImage : currentResources!.blocksAtlasParser.latestImage)
const canvasTemp = document.createElement('canvas')
canvasTemp.width = imgBitmap.width
canvasTemp.height = imgBitmap.height
canvasTemp.width = img.width
canvasTemp.height = img.height
canvasTemp.style.imageRendering = 'pixelated'
const ctx = canvasTemp.getContext('2d')!
ctx.imageSmoothingEnabled = false
ctx.drawImage(imgBitmap, 0, 0)
ctx.drawImage(img, 0, 0)
const atlasParser = isItems ? appViewer.resourcesManager.itemsAtlasParser : appViewer.resourcesManager.blocksAtlasParser
const atlasParser = isItems ? currentResources!.itemsAtlasParser : currentResources!.blocksAtlasParser
const textureAtlas = new TextureAtlas(
ctx.getImageData(0, 0, imgBitmap.width, imgBitmap.height),
ctx.getImageData(0, 0, img.width, img.height),
Object.fromEntries(Object.entries(atlasParser.atlas.latest.textures).map(([key, value]) => {
return [key, [
value.u,
@ -238,9 +243,6 @@ const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isIt
return images
}
/**
* @mainThread
*/
const generateAtlas = async (images: Record<string, HTMLImageElement>) => {
const atlas = makeTextureAtlas({
input: Object.keys(images),
@ -258,9 +260,9 @@ const generateAtlas = async (images: Record<string, HTMLImageElement>) => {
// a.download = 'blocks_atlas.png'
// a.click()
appViewer.resourcesManager.currentResources!.guiAtlas = {
activeGuiAtlas.atlas = {
json: atlas.json,
image: await createImageBitmap(atlas.canvas),
image: ref(await getLoadedImage(atlas.canvas.toDataURL())),
}
return atlas
@ -277,6 +279,6 @@ export const generateGuiAtlas = async () => {
const itemImages = await generateItemsGui(itemsModelsResolved, true)
console.timeEnd('generate items gui atlas')
await generateAtlas({ ...blockImages, ...itemImages })
appViewer.resourcesManager.currentResources!.guiAtlasVersion++
activeGuiAtlas.version++
// await generateAtlas(blockImages)
}

View file

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

View file

@ -77,7 +77,6 @@ const handleMessage = data => {
if (data.type === 'mcData') {
globalVar.mcData = data.mcData
globalVar.loadedData = data.mcData
}
if (data.config) {
@ -139,7 +138,6 @@ const handleMessage = data => {
dirtySections = new Map()
// todo also remove cached
globalVar.mcData = null
globalVar.loadedData = null
allDataReady = false
break

View file

@ -132,7 +132,7 @@ const getVec = (v: Vec3, dir: Vec3) => {
return v.plus(dir)
}
function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, type: number, biome: string, water: boolean, attr: MesherGeometryOutput, isRealWater: boolean) {
function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, type: number, biome: string, water: boolean, attr: Record<string, any>, isRealWater: boolean) {
const heights: number[] = []
for (let z = -1; z <= 1; z++) {
for (let x = -1; x <= 1; x++) {
@ -192,14 +192,13 @@ function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, typ
for (const pos of corners) {
const height = cornerHeights[pos[2] * 2 + pos[0]]
const OFFSET = 0.0001
attr.t_positions!.push(
(pos[0] ? 1 - OFFSET : OFFSET) + (cursor.x & 15) - 8,
(pos[1] ? height - OFFSET : OFFSET) + (cursor.y & 15) - 8,
(pos[2] ? 1 - OFFSET : OFFSET) + (cursor.z & 15) - 8
attr.t_positions.push(
(pos[0] ? 0.999 : 0.001) + (cursor.x & 15) - 8,
(pos[1] ? height - 0.001 : 0.001) + (cursor.y & 15) - 8,
(pos[2] ? 0.999 : 0.001) + (cursor.z & 15) - 8
)
attr.t_normals!.push(...dir)
attr.t_uvs!.push(pos[3] * su + u, pos[4] * sv * (pos[1] ? 1 : height) + v)
attr.t_normals.push(...dir)
attr.t_uvs.push(pos[3] * su + u, pos[4] * sv * (pos[1] ? 1 : height) + v)
let cornerLightResult = baseLight
if (world.config.smoothLighting) {
@ -224,7 +223,7 @@ function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, typ
}
// Apply light value to tint
attr.t_colors!.push(tint[0] * cornerLightResult, tint[1] * cornerLightResult, tint[2] * cornerLightResult)
attr.t_colors.push(tint[0] * cornerLightResult, tint[1] * cornerLightResult, tint[2] * cornerLightResult)
}
}
}
@ -336,7 +335,7 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO:
let localShift = null as any
if (element.rotation && !needTiles) {
// Rescale support for block model rotations
// todo do we support rescale?
localMatrix = buildRotationMatrix(
element.rotation.axis,
element.rotation.angle
@ -349,37 +348,6 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO:
element.rotation.origin
)
)
// Apply rescale if specified
if (element.rotation.rescale) {
const FIT_TO_BLOCK_SCALE_MULTIPLIER = 2 - Math.sqrt(2)
const angleRad = element.rotation.angle * Math.PI / 180
const scale = Math.abs(Math.sin(angleRad)) * FIT_TO_BLOCK_SCALE_MULTIPLIER
// Get axis vector components (1 for the rotation axis, 0 for others)
const axisX = element.rotation.axis === 'x' ? 1 : 0
const axisY = element.rotation.axis === 'y' ? 1 : 0
const axisZ = element.rotation.axis === 'z' ? 1 : 0
// Create scale matrix: scale = (1 - axisComponent) * scaleFactor + 1
const scaleMatrix = [
[(1 - axisX) * scale + 1, 0, 0],
[0, (1 - axisY) * scale + 1, 0],
[0, 0, (1 - axisZ) * scale + 1]
]
// Apply scaling to the transformation matrix
localMatrix = matmulmat3(localMatrix, scaleMatrix)
// Recalculate shift with the new matrix
localShift = vecsub3(
element.rotation.origin,
matmul3(
localMatrix,
element.rotation.origin
)
)
}
}
const aos: number[] = []
@ -519,7 +487,7 @@ const isBlockWaterlogged = (block: Block) => {
}
let unknownBlockModel: BlockModelPartsResolved
export function getSectionGeometry (sx: number, sy: number, sz: number, world: World) {
export function getSectionGeometry (sx, sy, sz, world: World) {
let delayedRender = [] as Array<() => void>
const attr: MesherGeometryOutput = {
@ -542,6 +510,7 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W
heads: {},
signs: {},
// isFull: true,
highestBlocks: new Map(),
hadErrors: false,
blocksCount: 0
}
@ -551,6 +520,12 @@ 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

@ -9,7 +9,7 @@ export const defaultMesherConfig = {
skyLight: 15,
smoothLighting: true,
outputFormat: 'threeJs' as 'threeJs' | 'webgpu',
// textureSize: 1024, // for testing
textureSize: 1024, // for testing
debugModelVariant: undefined as undefined | number[],
clipWorldBelowY: undefined as undefined | number,
disableSignsMapsSupport: false
@ -42,6 +42,7 @@ 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

@ -49,6 +49,8 @@ test('Known blocks are not rendered', () => {
// TODO resolve creaking_heart issue (1.21.3)
expect(missingBlocks).toMatchInlineSnapshot(`
{
"end_gateway": true,
"end_portal": true,
"structure_void": true,
}
`)

View file

@ -0,0 +1,11 @@
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,3 +1,27 @@
import * as THREE from 'three'
let textureCache: Record<string, THREE.Texture> = {}
let imagesPromises: Record<string, Promise<THREE.Texture>> = {}
export async function loadTexture (texture: string, cb: (texture: THREE.Texture) => void, onLoad?: () => void): Promise<void> {
const cached = textureCache[texture]
if (!cached) {
const { promise, resolve } = Promise.withResolvers<THREE.Texture>()
textureCache[texture] = new THREE.TextureLoader().load(texture, resolve)
imagesPromises[texture] = promise
}
cb(textureCache[texture])
void imagesPromises[texture].then(() => {
onLoad?.()
})
}
export const clearTextureCache = () => {
textureCache = {}
imagesPromises = {}
}
export const loadScript = async function (scriptSrc: string, highPriority = true): Promise<HTMLScriptElement> {
const existingScript = document.querySelector<HTMLScriptElement>(`script[src="${scriptSrc}"]`)
if (existingScript) {
@ -25,33 +49,3 @@ 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,7 +1,19 @@
import { loadSkinToCanvas } from 'skinview-utils'
import { createCanvas, loadImageFromUrl } from '../utils'
import * as THREE from 'three'
import stevePng from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png'
export { default as stevePngUrl } from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png'
// eslint-disable-next-line unicorn/prefer-export-from
export const stevePngUrl = stevePng
export const steveTexture = new THREE.TextureLoader().loadAsync(stevePng)
export async function loadImageFromUrl (imageUrl: string): Promise<HTMLImageElement> {
const img = new Image()
img.src = imageUrl
await new Promise<void>(resolve => {
img.onload = () => resolve()
})
return img
}
const config = {
apiEnabled: true,
@ -40,13 +52,13 @@ export const parseSkinTexturesValue = (value: string) => {
return decodedData.textures?.SKIN?.url
}
export async function loadSkinImage (skinUrl: string): Promise<{ canvas: OffscreenCanvas, image: ImageBitmap }> {
export async function loadSkinImage (skinUrl: string): Promise<{ canvas: HTMLCanvasElement, image: HTMLImageElement }> {
if (!skinUrl.startsWith('data:')) {
skinUrl = await fetchAndConvertBase64Skin(skinUrl.replace('http://', 'https://'))
}
const image = await loadImageFromUrl(skinUrl)
const skinCanvas = createCanvas(64, 64)
const skinCanvas = document.createElement('canvas')
loadSkinToCanvas(skinCanvas, image)
return { canvas: skinCanvas, image }
}

View file

@ -1,20 +1,9 @@
import { proxy, getVersion, subscribe } from 'valtio'
export function createWorkerProxy<T extends Record<string, (...args: any[]) => void | Promise<any>>> (handlers: T, channel?: MessagePort): { __workerProxy: T } {
export function createWorkerProxy<T extends Record<string, (...args: any[]) => void>> (handlers: T, channel?: MessagePort): { __workerProxy: T } {
const target = channel ?? globalThis
target.addEventListener('message', (event: any) => {
const { type, args, msgId } = event.data
const { type, args } = event.data
if (handlers[type]) {
const result = handlers[type](...args)
if (result instanceof Promise) {
void result.then((result) => {
target.postMessage({
type: 'result',
msgId,
args: [result]
})
})
}
handlers[type](...args)
}
})
return null as any
@ -34,7 +23,6 @@ export function createWorkerProxy<T extends Record<string, (...args: any[]) => v
export const useWorkerProxy = <T extends { __workerProxy: Record<string, (...args: any[]) => void> }> (worker: Worker | MessagePort, autoTransfer = true): T['__workerProxy'] & {
transfer: (...args: Transferable[]) => T['__workerProxy']
} => {
let messageId = 0
// in main thread
return new Proxy({} as any, {
get (target, prop) {
@ -53,30 +41,11 @@ export const useWorkerProxy = <T extends { __workerProxy: Record<string, (...arg
}
}
return (...args: any[]) => {
const msgId = messageId++
const transfer = autoTransfer ? args.filter(arg => {
return arg instanceof ArrayBuffer || arg instanceof MessagePort
|| (typeof ImageBitmap !== 'undefined' && arg instanceof ImageBitmap)
|| (typeof OffscreenCanvas !== 'undefined' && arg instanceof OffscreenCanvas)
|| (typeof ImageData !== 'undefined' && arg instanceof ImageData)
}) : []
const transfer = autoTransfer ? args.filter(arg => arg instanceof ArrayBuffer || arg instanceof MessagePort || arg instanceof ImageBitmap || arg instanceof OffscreenCanvas || arg instanceof ImageData) : []
worker.postMessage({
type: prop,
msgId,
args,
}, transfer)
return {
// eslint-disable-next-line unicorn/no-thenable
then (onfulfilled: (value: any) => void) {
const handler = ({ data }: MessageEvent): void => {
if (data.type === 'result' && data.msgId === msgId) {
onfulfilled(data.args[0])
worker.removeEventListener('message', handler as EventListener)
}
}
worker.addEventListener('message', handler as EventListener)
}
}
}, transfer as any[])
}
}
})

View file

@ -7,8 +7,9 @@ import { Vec3 } from 'vec3'
import { BotEvents } from 'mineflayer'
import { proxy } from 'valtio'
import TypedEmitter from 'typed-emitter'
import { Biome } from 'minecraft-data'
import { getItemFromBlock } from '../../../src/chatUtils'
import { delayedIterator } from '../../playground/shared'
import { playerState } from '../../../src/mineflayer/playerState'
import { chunkPos } from './simpleUtils'
export type ChunkPosKey = string // like '16,16'
@ -19,34 +20,24 @@ export type WorldDataEmitterEvents = {
blockUpdate: (data: { pos: Vec3, stateId: number }) => void
entity: (data: any) => void
entityMoved: (data: any) => void
playerEntity: (data: any) => void
time: (data: number) => void
renderDistance: (viewDistance: number) => void
blockEntities: (data: Record<string, any> | { blockEntities: Record<string, any> }) => void
listening: () => void
markAsLoaded: (data: { x: number, z: number }) => void
unloadChunk: (data: { x: number, z: number }) => void
loadChunk: (data: { x: number, z: number, chunk: string, blockEntities: any, worldConfig: any, isLightUpdate: boolean }) => void
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>) {
static readonly restorerName = 'WorldDataEmitterWorker'
}
/**
* Usually connects to mineflayer bot and emits world data (chunks, entities)
* It's up to the consumer to serialize the data if needed
*/
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
@ -66,6 +57,11 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
/* config */ isPlayground = false
/* config */ allowPositionUpdate = true
public reactive = proxy({
cursorBlock: null as Vec3 | null,
cursorBlockBreakingStage: null as number | null,
})
constructor (public world: typeof __type_bot['world'], public viewDistance: number, position: Vec3 = new Vec3(0, 0, 0)) {
// eslint-disable-next-line constructor-super
super()
@ -102,20 +98,13 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
})
const emitEntity = (e, name = 'entity') => {
if (!e) return
if (e === bot.entity) {
if (name === 'entity') {
this.emitter.emit('playerEntity', e)
}
return
}
if (!e || e === bot.entity) return
if (!e.name) return // mineflayer received update for not spawned entity
e.objectData = entitiesObjectData.get(e.id)
this.emitter.emit(name as any, {
...e,
pos: e.position,
username: e.username,
team: bot.teamMap[e.username] || bot.teamMap[e.uuid],
// set debugTree (obj) {
// e.debugTree = obj
// }
@ -144,19 +133,12 @@ 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)
@ -174,11 +156,9 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
// when dimension might change
login: () => {
void this.updatePosition(bot.entity.position, true)
this.emitter.emit('playerEntity', bot.entity)
},
respawn: () => {
void this.updatePosition(bot.entity.position, true)
this.emitter.emit('playerEntity', bot.entity)
this.emitter.emit('onWorldSwitch')
},
} satisfies Partial<BotEvents>
@ -191,6 +171,22 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
}
})
this.emitter.on('listening', () => {
this.emitter.emit('blockEntities', new Proxy({}, {
get (_target, posKey, receiver) {
if (typeof posKey !== 'string') return
const [x, y, z] = posKey.split(',').map(Number)
return bot.world.getBlock(new Vec3(x, y, z))?.entity
},
}))
this.emitter.emit('renderDistance', this.viewDistance)
this.emitter.emit('time', bot.time.timeOfDay)
})
// node.js stream data event pattern
if (this.emitter.listenerCount('blockEntities')) {
this.emitter.emit('listening')
}
for (const [evt, listener] of Object.entries(this.eventListeners)) {
bot.on(evt as any, listener)
}
@ -204,16 +200,8 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
console.error('error processing entity', err)
}
}
}
emitterGotConnected () {
this.emitter.emit('blockEntities', new Proxy({}, {
get (_target, posKey, receiver) {
if (typeof posKey !== 'string') return
const [x, y, z] = posKey.split(',').map(Number)
return bot.world.getBlock(new Vec3(x, y, z))?.entity
},
}))
void this.init(bot.entity.position)
}
removeListenersFromBot (bot: import('mineflayer').Bot) {
@ -225,71 +213,38 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
async init (pos: Vec3) {
this.updateViewDistance(this.viewDistance)
this.emitter.emit('chunkPosUpdate', { pos })
if (bot?.time?.timeOfDay) {
this.emitter.emit('time', bot.time.timeOfDay)
}
if (bot?.entity) {
this.emitter.emit('playerEntity', bot.entity)
}
this.emitterGotConnected()
const [botX, botZ] = chunkPos(pos)
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, pos)
await this._loadChunks(positions)
}
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
async _loadChunks (positions: Vec3[], sliceSize = 5) {
// 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) => {
if (!continueLoading || this.loadedChunks[`${pos.x},${pos.z}`]) return
const promise = (async () => {
if (!continueLoading || this.loadedChunks[`${pos.x},${pos.z}`]) return
// 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()
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)
})
if (this.panicTimeout) clearTimeout(this.panicTimeout)
this.inLoading = false
this.gotPanicLastTime = false
this.chunkReceiveTimes = []
this.lastChunkReceiveTime = 0
await Promise.all(promises)
}
readdDebug () {
@ -363,37 +318,8 @@ 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) {
@ -411,6 +337,7 @@ 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)
}
@ -422,7 +349,7 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
return undefined!
}).filter(a => !!a)
this.lastPos.update(pos)
void this._loadChunks(positions, pos)
void this._loadChunks(positions)
} else {
this.emitter.emit('chunkPosUpdate', { pos }) // todo-low
this.lastPos.update(pos)

View file

@ -1,22 +1,24 @@
/* eslint-disable guard-for-in */
import { EventEmitter } from 'events'
import { Vec3 } from 'vec3'
import * as THREE from 'three'
import mcDataRaw from 'minecraft-data/data.js' // note: using alias
import TypedEmitter from 'typed-emitter'
import { ItemsRenderer } from 'mc-assets/dist/itemsRenderer'
import { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider'
import { generateSpiralMatrix } from 'flying-squid/dist/utils'
import { subscribeKey } from 'valtio/utils'
import { proxy } from 'valtio'
import { dynamicMcDataFiles } from '../../buildMesherConfig.mjs'
import type { ResourcesManagerTransferred } from '../../../src/resourcesManager'
import { toMajorVersion } from '../../../src/utils'
import { ResourcesManager } from '../../../src/resourcesManager'
import { DisplayWorldOptions, GraphicsInitOptions, RendererReactiveState } from '../../../src/appViewer'
import { SoundSystem } from '../three/threeJsSound'
import { buildCleanupDecorator } from './cleanupDecorator'
import { HighestBlockInfo, CustomBlockModels, BlockStateModelInfo, getBlockAssetsCacheKey, MesherConfig, MesherMainEvent } from './mesher/shared'
import { HighestBlockInfo, MesherGeometryOutput, CustomBlockModels, BlockStateModelInfo, getBlockAssetsCacheKey, MesherConfig, MesherMainEvent } from './mesher/shared'
import { chunkPos } from './simpleUtils'
import { addNewStat, removeAllStats, updatePanesVisibility, updateStatText } from './ui/newStats'
import { WorldDataEmitterWorker } from './worldDataEmitter'
import { getPlayerStateUtils, PlayerStateReactive, PlayerStateRenderer, PlayerStateUtils } from './basePlayerState'
import { addNewStat, removeAllStats, removeStat, updatePanesVisibility, updateStatText } from './ui/newStats'
import { WorldDataEmitter } from './worldDataEmitter'
import { IPlayerState } from './basePlayerState'
import { MesherLogReader } from './mesherlogReader'
import { setSkinsConfig } from './utils/skins'
@ -24,53 +26,31 @@ function mod (x, n) {
return ((x % n) + n) % n
}
const toMajorVersion = version => {
const [a, b] = (String(version)).split('.')
return `${a}.${b}`
}
export const worldCleanup = buildCleanupDecorator('resetWorld')
export const defaultWorldRendererConfig = {
// Debug settings
showChunkBorders: false,
enableDebugOverlay: false,
// Performance settings
mesherWorkers: 4,
addChunksBatchWaitTime: 200,
_experimentalSmoothChunkLoading: true,
_renderByChunks: false,
// Rendering engine settings
dayCycle: true,
isPlayground: false,
renderEars: true,
// game renderer setting actually
showHand: false,
viewBobbing: false,
extraBlockRenderers: true,
clipWorldBelowY: undefined as number | undefined,
smoothLighting: true,
enableLighting: true,
starfield: true,
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
addChunksBatchWaitTime: 200,
vrSupport: true,
vrPageGameRendering: true,
// World settings
clipWorldBelowY: undefined as number | undefined,
isPlayground: false
renderEntities: true,
fov: 75,
fetchPlayerSkins: true,
highlightBlockColor: 'blue',
foreground: true,
_experimentalSmoothChunkLoading: true,
_renderByChunks: false
}
export type WorldRendererConfig = typeof defaultWorldRendererConfig
@ -80,17 +60,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
worldReadyPromise = this.worldReadyResolvers.promise
timeOfTheDay = 0
worldSizeParams = { minY: 0, worldHeight: 256 }
reactiveDebugParams = proxy({
stopRendering: false,
chunksRenderAboveOverride: undefined as number | undefined,
chunksRenderAboveEnabled: false,
chunksRenderBelowOverride: undefined as number | undefined,
chunksRenderBelowEnabled: false,
chunksRenderDistanceOverride: undefined as number | undefined,
chunksRenderDistanceEnabled: false,
disableEntities: false,
// disableParticles: false
})
active = false
@ -121,7 +90,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
}>
customTexturesDataUrl = undefined as string | undefined
workers: any[] = []
viewerChunkPosition?: Vec3
viewerPosition?: Vec3
lastCamUpdate = 0
droppedFpsPercentage = 0
initialChunkLoadWasStartedIn: number | undefined
@ -137,6 +106,7 @@ 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
@ -170,8 +140,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
abstract changeBackgroundColor (color: [number, number, number]): void
worldRendererConfig: WorldRendererConfig
playerStateReactive: PlayerStateReactive
playerStateUtils: PlayerStateUtils
playerState: IPlayerState
reactiveState: RendererReactiveState
mesherLogReader: MesherLogReader | undefined
forceCallFromMesherReplayer = false
@ -187,7 +156,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
}
currentRenderedFrames = 0
fpsAverage = 0
lastFps = 0
fpsWorst = undefined as number | undefined
fpsSamples = 0
mainThreadRendering = true
@ -203,11 +171,10 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
return (this.initOptions.config.statsVisible ?? 0) > 1
}
constructor (public readonly resourcesManager: ResourcesManagerTransferred, public displayOptions: DisplayWorldOptions, public initOptions: GraphicsInitOptions) {
constructor (public readonly resourcesManager: ResourcesManager, public displayOptions: DisplayWorldOptions, public initOptions: GraphicsInitOptions) {
this.snapshotInitialValues()
this.worldRendererConfig = displayOptions.inWorldRenderingConfig
this.playerStateReactive = displayOptions.playerStateReactive
this.playerStateUtils = getPlayerStateUtils(this.playerStateReactive)
this.playerState = displayOptions.playerState
this.reactiveState = displayOptions.rendererState
// this.mesherLogReader = new MesherLogReader(this)
this.renderUpdateEmitter.on('update', () => {
@ -241,7 +208,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
} else {
this.fpsWorst = Math.min(this.fpsWorst, this.currentRenderedFrames)
}
this.lastFps = this.currentRenderedFrames
this.currentRenderedFrames = 0
}
@ -252,11 +218,15 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
async init () {
if (this.active) throw new Error('WorldRendererCommon is already initialized')
await this.resourcesManager.loadMcData(this.version)
if (!this.resourcesManager.currentResources) {
await this.resourcesManager.updateAssetsData({ })
}
await Promise.all([
this.resetWorkers(),
(async () => {
if (this.resourcesManager.currentResources?.allReady) {
if (this.resourcesManager.currentResources) {
await this.updateAssetsData()
}
})()
@ -308,23 +278,36 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
initWorkers (numWorkers = this.worldRendererConfig.mesherWorkers) {
// init workers
for (let i = 0; i < numWorkers + 1; i++) {
const worker = initMesherWorker((data) => {
// Node environment needs an absolute path, but browser needs the url of the file
const workerName = 'mesher.js'
// eslint-disable-next-line node/no-path-concat
const src = typeof window === 'undefined' ? `${__dirname}/${workerName}` : workerName
let worker: any
if (process.env.SINGLE_FILE_BUILD) {
const workerCode = document.getElementById('mesher-worker-code')!.textContent!
const blob = new Blob([workerCode], { type: 'text/javascript' })
worker = new Worker(window.URL.createObjectURL(blob))
} else {
worker = new Worker(src)
}
worker.onmessage = ({ data }) => {
if (Array.isArray(data)) {
this.messageQueue.push(...data)
} else {
this.messageQueue.push(data)
}
void this.processMessageQueue('worker')
})
}
if (worker.on) worker.on('message', (data) => { worker.onmessage({ data }) })
this.workers.push(worker)
}
}
onReactivePlayerStateUpdated<T extends keyof PlayerStateReactive>(key: T, callback: (value: PlayerStateReactive[T]) => void, initial = true) {
if (initial) {
callback(this.playerStateReactive[key])
}
subscribeKey(this.playerStateReactive, key, callback)
onReactiveValueUpdated<T extends keyof typeof this.displayOptions.playerState.reactive>(key: T, callback: (value: typeof this.displayOptions.playerState.reactive[T]) => void) {
callback(this.displayOptions.playerState.reactive[key])
subscribeKey(this.displayOptions.playerState.reactive, key, callback)
}
onReactiveConfigUpdated<T extends keyof typeof this.worldRendererConfig>(key: T, callback: (value: typeof this.worldRendererConfig[T]) => void) {
@ -332,13 +315,8 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
subscribeKey(this.worldRendererConfig, key, callback)
}
onReactiveDebugUpdated<T extends keyof typeof this.reactiveDebugParams>(key: T, callback: (value: typeof this.reactiveDebugParams[T]) => void) {
callback(this.reactiveDebugParams[key])
subscribeKey(this.reactiveDebugParams, key, callback)
}
watchReactivePlayerState () {
this.onReactivePlayerStateUpdated('backgroundColor', (value) => {
this.onReactiveValueUpdated('backgroundColor', (value) => {
this.changeBackgroundColor(value)
})
}
@ -402,6 +380,8 @@ 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])))
}
@ -468,7 +448,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
}
if (data.type === 'heightmap') {
this.reactiveState.world.heightmaps.set(data.key, new Uint8Array(data.heightmap))
appViewer.rendererState.world.heightmaps.set(data.key, new Uint8Array(data.heightmap))
}
}
@ -510,12 +490,8 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
timeUpdated? (newTime: number): void
biomeUpdated? (biome: any): void
biomeReset? (): void
updateViewerPosition (pos: Vec3) {
this.viewerChunkPosition = pos
this.viewerPosition = pos
for (const [key, value] of Object.entries(this.loadedChunks)) {
if (!value) continue
this.updatePosDataChunk?.(key)
@ -529,7 +505,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
}
getDistance (posAbsolute: Vec3) {
const [botX, botZ] = chunkPos(this.viewerChunkPosition!)
const [botX, botZ] = chunkPos(this.viewerPosition!)
const dx = Math.abs(botX - Math.floor(posAbsolute.x / 16))
const dz = Math.abs(botZ - Math.floor(posAbsolute.z / 16))
return [dx, dz] as [number, number]
@ -549,7 +525,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
this.resetWorld()
// for workers in single file build
if (typeof document !== 'undefined' && document?.readyState === 'loading') {
if (document?.readyState === 'loading') {
await new Promise(resolve => {
document.addEventListener('DOMContentLoaded', resolve)
})
@ -581,7 +557,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
skyLight,
smoothLighting: this.worldRendererConfig.smoothLighting,
outputFormat: this.outputFormat,
// textureSize: this.resourcesManager.currentResources!.blocksAtlasParser.atlas.latest.width,
textureSize: this.resourcesManager.currentResources!.blocksAtlasParser.atlas.latest.width,
debugModelVariant: undefined,
clipWorldBelowY: this.worldRendererConfig.clipWorldBelowY,
disableSignsMapsSupport: !this.worldRendererConfig.extraBlockRenderers,
@ -606,7 +582,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
}
async updateAssetsData () {
const resources = this.resourcesManager.currentResources
const resources = this.resourcesManager.currentResources!
if (this.workers.length === 0) throw new Error('workers not initialized yet')
for (const [i, worker] of this.workers.entries()) {
@ -616,7 +592,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
type: 'mesherData',
workerIndex: i,
blocksAtlas: {
latest: resources.blocksAtlasJson
latest: resources.blocksAtlasParser.atlas.latest
},
blockstatesModels,
config: this.getMesherConfig(),
@ -703,6 +679,7 @@ 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}`)
@ -736,11 +713,9 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
updateEntity (e: any, isUpdate = false) { }
abstract updatePlayerEntity? (e: any): void
lightUpdate (chunkX: number, chunkZ: number) { }
connect (worldView: WorldDataEmitterWorker) {
connect (worldView: WorldDataEmitter) {
const worldEmitter = worldView
worldEmitter.on('entity', (e) => {
@ -749,9 +724,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
worldEmitter.on('entityMoved', (e) => {
this.updateEntity(e, true)
})
worldEmitter.on('playerEntity', (e) => {
this.updatePlayerEntity?.(e)
})
let currentLoadChunkBatch = null as {
timeout
@ -822,22 +794,16 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
})
worldEmitter.on('onWorldSwitch', () => {
for (const fn of this.onWorldSwitched) {
try {
fn()
} catch (e) {
setTimeout(() => {
console.log('[Renderer Backend] Error in onWorldSwitched:')
throw e
}, 0)
}
}
for (const fn of this.onWorldSwitched) fn()
})
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
@ -847,13 +813,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
// }
})
worldEmitter.on('biomeUpdate', ({ biome }) => {
this.biomeUpdated?.(biome)
})
worldEmitter.on('biomeReset', () => {
this.biomeReset?.()
})
worldEmitter.emit('listening')
}
setBlockStateIdInner (pos: Vec3, stateId: number | undefined, needAoRecalculation = true) {
@ -1051,37 +1011,3 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
removeAllStats()
}
}
export const initMesherWorker = (onGotMessage: (data: any) => void) => {
// Node environment needs an absolute path, but browser needs the url of the file
const workerName = 'mesher.js'
let worker: any
if (process.env.SINGLE_FILE_BUILD) {
const workerCode = document.getElementById('mesher-worker-code')!.textContent!
const blob = new Blob([workerCode], { type: 'text/javascript' })
worker = new Worker(window.URL.createObjectURL(blob))
} else {
worker = new Worker(workerName)
}
worker.onmessage = ({ data }) => {
onGotMessage(data)
}
if (worker.on) worker.on('message', (data) => { worker.onmessage({ data }) })
return worker
}
export const meshersSendMcData = (workers: Worker[], version: string, addData = {} as Record<string, any>) => {
const allMcData = mcDataRaw.pc[version] ?? mcDataRaw.pc[toMajorVersion(version)]
const mcData = {
version: JSON.parse(JSON.stringify(allMcData.version))
}
for (const key of dynamicMcDataFiles) {
mcData[key] = allMcData[key]
}
for (const worker of workers) {
worker.postMessage({ type: 'mcData', mcData, ...addData })
}
}

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,40 +32,29 @@ const parseSafe = (text: string, task: string) => {
}
}
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) }
) => {
export const renderSign = (blockEntity: SignBlockEntity, PrismarineChat: typeof ChatMessage, ctxHook = (ctx) => { }) => {
// 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
// todo the text should be clipped based on it's render width (needs investigate)
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
}
const texts = 'front_text' in blockEntity ? /* > 1.20 */ blockEntity.front_text.messages : [
blockEntity.Text1,
@ -73,144 +62,78 @@ export const renderSign = (
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
renderComponent(text, PrismarineChat, canvas, fontSize, defaultColor, fontSize * (lineNum + 1) + (isHanging ? 0 : -8))
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?
}
}
// 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,14 +21,9 @@ const blockEntity = {
await document.fonts.load('1em mojangles')
const canvas = renderSign(blockEntity, false, PrismarineChat, (ctx) => {
const canvas = renderSign(blockEntity, 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, true, PrismarineChat)
renderSign(entity, PrismarineChat)
return ctxTexts.map(({ text, y }) => [y / 64, text])
}
@ -37,6 +37,10 @@ test('sign renderer', () => {
} as any
expect(render(blockEntity)).toMatchInlineSnapshot(`
[
[
1,
"",
],
[
1,
"Minecraft ",

View file

@ -1,16 +1,16 @@
import { BlockModel } from 'mc-assets/dist/types'
import { ItemSpecificContextProperties, PlayerStateRenderer } from 'renderer/viewer/lib/basePlayerState'
import { ItemSpecificContextProperties } from 'renderer/viewer/lib/basePlayerState'
import { renderSlot } from '../../../src/inventoryWindows'
import { GeneralInputItem, getItemModelName } from '../../../src/mineflayer/items'
import { ResourcesManager, ResourcesManagerTransferred } from '../../../src/resourcesManager'
import { renderSlot } from './renderSlot'
import { ResourcesManager } from '../../../src/resourcesManager'
export const getItemUv = (item: Record<string, any>, specificProps: ItemSpecificContextProperties, resourcesManager: ResourcesManagerTransferred, playerState: PlayerStateRenderer): {
export const getItemUv = (item: Record<string, any>, specificProps: ItemSpecificContextProperties, resourcesManager: ResourcesManager): {
u: number
v: number
su: number
sv: number
renderInfo?: ReturnType<typeof renderSlot>
// texture: ImageBitmap
texture: HTMLImageElement
modelName: string
} | {
resolvedModel: BlockModel
@ -30,11 +30,11 @@ export const getItemUv = (item: Record<string, any>, specificProps: ItemSpecific
const model = getItemModelName({
...item,
name,
} as GeneralInputItem, specificProps, resourcesManager, playerState)
} as GeneralInputItem, specificProps, resourcesManager)
const renderInfo = renderSlot({
modelName: model,
}, resourcesManager, false, true)
}, false, true)
if (!renderInfo) throw new Error(`Failed to get render info for item ${name}`)
@ -53,7 +53,7 @@ export const getItemUv = (item: Record<string, any>, specificProps: ItemSpecific
return {
u, v, su, sv,
renderInfo,
// texture: img,
texture: img,
modelName: renderInfo.modelName!
}
}
@ -67,7 +67,7 @@ export const getItemUv = (item: Record<string, any>, specificProps: ItemSpecific
v: 0,
su: 16 / resources.blocksAtlasImage.width,
sv: 16 / resources.blocksAtlasImage.width,
// texture: resources.blocksAtlasImage,
texture: resources.blocksAtlasImage,
modelName: 'missing'
}
}

View file

@ -21,10 +21,6 @@ export class CameraShake {
this.update()
}
getBaseRotation () {
return { pitch: this.basePitch, yaw: this.baseYaw }
}
shakeFromDamage (yaw?: number) {
// Add roll animation
const startRoll = this.rollAngle
@ -39,11 +35,6 @@ export class CameraShake {
}
update () {
if (this.worldRenderer.playerStateUtils.isSpectatingEntity()) {
// Remove any shaking when spectating
this.rollAngle = 0
this.rollAnimation = undefined
}
// Update roll animation
if (this.rollAnimation) {
const now = performance.now()
@ -72,7 +63,7 @@ export class CameraShake {
}
}
const camera = this.worldRenderer.cameraObject
const camera = this.worldRenderer.cameraGroupVr || this.worldRenderer.camera
if (this.worldRenderer.cameraGroupVr) {
// For VR camera, only apply yaw rotation
@ -80,12 +71,8 @@ export class CameraShake {
camera.setRotationFromQuaternion(yawQuat)
} else {
// For regular camera, apply all rotations
// 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 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)
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)
@ -100,21 +87,4 @@ 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

@ -6,20 +6,15 @@ import { GraphicsBackendConfig, GraphicsInitOptions } from '../../../src/appView
import { WorldRendererConfig } from '../lib/worldrendererCommon'
export class DocumentRenderer {
canvas: HTMLCanvasElement | OffscreenCanvas
readonly canvas = document.createElement('canvas')
readonly renderer: THREE.WebGLRenderer
private animationFrameId?: number
private timeoutId?: number
private lastRenderTime = 0
private previousCanvasWidth = 0
private previousCanvasHeight = 0
private currentWidth = 0
private currentHeight = 0
private previousWindowWidth = window.innerWidth
private previousWindowHeight = window.innerHeight
private renderedFps = 0
private fpsInterval: any
private readonly stats: TopRightStats | undefined
private readonly stats: TopRightStats
private paused = false
disconnected = false
preRender = () => { }
@ -31,16 +26,9 @@ export class DocumentRenderer {
onRender = [] as Array<(sizeChanged: boolean) => void>
inWorldRenderingConfig: WorldRendererConfig | undefined
constructor (initOptions: GraphicsInitOptions, public externalCanvas?: OffscreenCanvas) {
constructor (initOptions: GraphicsInitOptions) {
this.config = initOptions.config
// Handle canvas creation/transfer based on context
if (externalCanvas) {
this.canvas = externalCanvas
} else {
this.addToPage()
}
try {
this.renderer = new THREE.WebGLRenderer({
canvas: this.canvas,
@ -49,25 +37,17 @@ export class DocumentRenderer {
powerPreference: this.config.powerPreference
})
} catch (err) {
initOptions.callbacks.displayCriticalError(new Error(`Failed to create WebGL context, not possible to render (restart browser): ${err.message}`))
initOptions.displayCriticalError(new Error(`Failed to create WebGL context, not possible to render (restart browser): ${err.message}`))
throw err
}
this.renderer.outputColorSpace = THREE.LinearSRGBColorSpace
if (!externalCanvas) {
this.updatePixelRatio()
}
this.sizeUpdated()
// Initialize previous dimensions
this.previousCanvasWidth = this.canvas.width
this.previousCanvasHeight = this.canvas.height
this.updatePixelRatio()
this.updateSize()
this.addToPage()
const supportsWebGL2 = 'WebGL2RenderingContext' in window
// Only initialize stats and DOM-related features in main thread
if (!externalCanvas && supportsWebGL2) {
this.stats = new TopRightStats(this.canvas as HTMLCanvasElement, this.config.statsVisible)
this.setupFpsTracking()
}
this.stats = new TopRightStats(this.canvas, this.config.statsVisible)
this.setupFpsTracking()
this.startRenderLoop()
}
@ -79,33 +59,15 @@ export class DocumentRenderer {
this.renderer.setPixelRatio(pixelRatio)
}
sizeUpdated () {
this.renderer.setSize(this.currentWidth, this.currentHeight, false)
updateSize () {
this.renderer.setSize(window.innerWidth, window.innerHeight)
}
private addToPage () {
this.canvas = addCanvasToPage()
this.updateCanvasSize()
}
updateSizeExternal (newWidth: number, newHeight: number, pixelRatio: number) {
this.currentWidth = newWidth
this.currentHeight = newHeight
this.renderer.setPixelRatio(pixelRatio)
this.sizeUpdated()
}
private updateCanvasSize () {
if (!this.externalCanvas) {
const innnerWidth = window.innerWidth
const innnerHeight = window.innerHeight
if (this.currentWidth !== innnerWidth) {
this.currentWidth = innnerWidth
}
if (this.currentHeight !== innnerHeight) {
this.currentHeight = innnerHeight
}
}
this.canvas.id = 'viewer-canvas'
this.canvas.style.width = '100%'
this.canvas.style.height = '100%'
document.body.appendChild(this.canvas)
}
private setupFpsTracking () {
@ -119,15 +81,20 @@ export class DocumentRenderer {
}, 1000)
}
// private handleResize () {
// const width = window.innerWidth
// const height = window.innerHeight
// viewer.camera.aspect = width / height
// viewer.camera.updateProjectionMatrix()
// this.renderer.setSize(width, height)
// viewer.world.handleResize()
// }
private startRenderLoop () {
const animate = () => {
if (this.disconnected) return
if (this.config.timeoutRendering) {
this.timeoutId = setTimeout(animate, this.config.fpsLimit ? 1000 / this.config.fpsLimit : 0) as unknown as number
} else {
this.animationFrameId = requestAnimationFrame(animate)
}
this.animationFrameId = requestAnimationFrame(animate)
if (this.paused || (this.renderer.xr.isPresenting && !this.inWorldRenderingConfig?.vrPageGameRendering)) return
@ -145,19 +112,18 @@ export class DocumentRenderer {
}
let sizeChanged = false
this.updateCanvasSize()
if (this.previousCanvasWidth !== this.currentWidth || this.previousCanvasHeight !== this.currentHeight) {
this.previousCanvasWidth = this.currentWidth
this.previousCanvasHeight = this.currentHeight
this.sizeUpdated()
if (this.previousWindowWidth !== window.innerWidth || this.previousWindowHeight !== window.innerHeight) {
this.previousWindowWidth = window.innerWidth
this.previousWindowHeight = window.innerHeight
this.updateSize()
sizeChanged = true
}
this.frameRender(sizeChanged)
// Update stats visibility each frame (main thread only)
// Update stats visibility each frame
if (this.config.statsVisible !== undefined) {
this.stats?.setVisibility(this.config.statsVisible)
this.stats.setVisibility(this.config.statsVisible)
}
}
@ -166,16 +132,16 @@ export class DocumentRenderer {
frameRender (sizeChanged: boolean) {
this.preRender()
this.stats?.markStart()
this.stats.markStart()
tween.update()
if (!globalThis.freezeRender) {
if (!window.freezeRender) {
this.render(sizeChanged)
}
for (const fn of this.onRender) {
fn(sizeChanged)
}
this.renderedFps++
this.stats?.markEnd()
this.stats.markEnd()
this.postRender()
}
@ -188,15 +154,10 @@ export class DocumentRenderer {
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId)
}
if (this.timeoutId) {
clearTimeout(this.timeoutId)
}
if (this.canvas instanceof HTMLCanvasElement) {
this.canvas.remove()
}
clearInterval(this.fpsInterval)
this.stats?.dispose()
this.canvas.remove()
this.renderer.dispose()
clearInterval(this.fpsInterval)
this.stats.dispose()
}
}
@ -289,40 +250,3 @@ class TopRightStats {
this.statsGl.container.remove()
}
}
const addCanvasToPage = () => {
const canvas = document.createElement('canvas')
canvas.id = 'viewer-canvas'
document.body.appendChild(canvas)
return canvas
}
export const addCanvasForWorker = () => {
const canvas = addCanvasToPage()
const transferred = canvas.transferControlToOffscreen()
let removed = false
let onSizeChanged = (w, h) => { }
let oldSize = { width: 0, height: 0 }
const checkSize = () => {
if (removed) return
if (oldSize.width !== window.innerWidth || oldSize.height !== window.innerHeight) {
onSizeChanged(window.innerWidth, window.innerHeight)
oldSize = { width: window.innerWidth, height: window.innerHeight }
}
requestAnimationFrame(checkSize)
}
requestAnimationFrame(checkSize)
return {
canvas: transferred,
destroy () {
removed = true
canvas.remove()
},
onSizeChanged (cb: (width: number, height: number) => void) {
onSizeChanged = cb
},
get size () {
return { width: window.innerWidth, height: window.innerHeight }
}
}
}

View file

@ -1,10 +1,11 @@
//@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 { PlayerAnimation, PlayerObject } from 'skinview3d'
import { inferModelType, loadCapeToCanvas, loadEarsToCanvasFromSkin } from 'skinview-utils'
import { PlayerObject, PlayerAnimation } from 'skinview3d'
import { loadSkinToCanvas, loadEarsToCanvasFromSkin, inferModelType, loadCapeToCanvas, loadImage } from 'skinview-utils'
// todo replace with url
import { degreesToRadians } from '@nxg-org/mineflayer-tracker/lib/mathUtils'
import { NameTagObject } from 'skinview3d/libs/nametag'
@ -12,28 +13,29 @@ 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 { Team } from 'mineflayer'
import PrismarineChatLoader from 'prismarine-chat'
import { Vec3 } from 'vec3'
import { EntityMetadataVersions } from '../../../src/mcDataTypes'
import { ItemSpecificContextProperties } from '../lib/basePlayerState'
import { loadSkinFromUsername, loadSkinImage, stevePngUrl } from '../lib/utils/skins'
import { renderComponent } from '../sign-renderer'
import { createCanvas } from '../lib/utils'
import { PlayerObjectType } from '../lib/createPlayerObject'
import { loadSkinImage, loadSkinFromUsername, stevePngUrl, steveTexture } from '../lib/utils/skins'
import { loadTexture } from '../lib/utils'
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, loadTexture, loadThreeJsTextureFromUrl } from './threeJsUtils'
import { armorModel, armorTextures, elytraTexture } from './entity/armorModels'
import { disposeObject } from './threeJsUtils'
import { armorModel, armorTextures } from './entity/armorModels'
import { WorldRendererThree } from './worldrendererThree'
export const steveTexture = loadThreeJsTextureFromUrl(stevePngUrl)
export const TWEEN_DURATION = 120
type PlayerObjectType = PlayerObject & {
animation?: PlayerAnimation
realPlayerUuid: string
realUsername: string
}
function convert2sComplementToHex (complement: number) {
if (complement < 0) {
complement = (0xFF_FF_FF_FF + complement + 1) >>> 0
@ -93,11 +95,8 @@ function getUsernameTexture ({
username,
nameTagBackgroundColor = 'rgba(0, 0, 0, 0.3)',
nameTagTextOpacity = 255
}: any, { fontFamily = 'mojangles' }: any, version: string) {
const canvas = createCanvas(64, 64)
const PrismarineChat = PrismarineChatLoader(version)
}: any, { fontFamily = 'sans-serif' }: any) {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get 2d context')
@ -105,39 +104,38 @@ function getUsernameTexture ({
const padding = 5
ctx.font = `${fontSize}px ${fontFamily}`
const plainLines = String(typeof username === 'string' ? username : new PrismarineChat(username).toString()).split('\n')
const lines = String(username).split('\n')
let textWidth = 0
for (const line of plainLines) {
for (const line of lines) {
const width = ctx.measureText(line).width + padding * 2
if (width > textWidth) textWidth = width
}
canvas.width = textWidth
canvas.height = (fontSize + padding) * plainLines.length
canvas.height = (fontSize + padding) * lines.length
ctx.fillStyle = nameTagBackgroundColor
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.globalAlpha = nameTagTextOpacity / 255
renderComponent(username, PrismarineChat, canvas, fontSize, 'white', -padding + fontSize)
ctx.globalAlpha = 1
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)
}
return canvas
}
const addNametag = (entity, options: { fontFamily: string }, mesh, version: string) => {
for (const c of mesh.children) {
if (c.name === 'nametag') {
c.removeFromParent()
}
}
const addNametag = (entity, options, mesh) => {
if (entity.username !== undefined) {
const canvas = getUsernameTexture(entity, options, version)
if (mesh.children.some(c => c.name === 'nametag')) return // todo update
const canvas = getUsernameTexture(entity, options)
const tex = new THREE.Texture(canvas)
tex.needsUpdate = true
let nameTag: THREE.Object3D
let nameTag
if (entity.nameTagFixed) {
const geometry = new THREE.PlaneGeometry()
const material = new THREE.MeshBasicMaterial({ map: tex })
@ -167,7 +165,6 @@ const addNametag = (entity, options: { fontFamily: string }, mesh, version: stri
nameTag.name = 'nametag'
mesh.add(nameTag)
return nameTag
}
}
@ -176,7 +173,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, options: { fontFamily: string }, overrides) {
function getEntityMesh (entity: import('prismarine-entity').Entity & { delete?: any; pos: any; name: any }, world: WorldRendererThree | undefined, options: { fontFamily: string }, overrides) {
if (entity.name) {
try {
// https://github.com/PrismarineJS/prismarine-viewer/pull/410
@ -184,7 +181,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, world.version)
addNametag(entity, options, e.mesh)
return e.mesh
}
} catch (err) {
@ -202,7 +199,7 @@ function getEntityMesh (entity: import('prismarine-entity').Entity & { delete?:
addNametag({
username: entity.name,
height: entity.height,
}, options, cube, world.version)
}, options, cube)
}
return cube
}
@ -212,12 +209,10 @@ export type SceneEntity = THREE.Object3D & {
username?: string
uuid?: string
additionalCleanup?: () => void
originalEntity: import('prismarine-entity').Entity & { delete?; pos?, name, team?: Team }
}
export class Entities {
entities = {} as Record<string, SceneEntity>
playerEntity: SceneEntity | null = null // Special entity for the player in third person
entitiesOptions = {
fontFamily: 'mojangles'
}
@ -242,50 +237,9 @@ export class Entities {
return Object.values(this.entities).filter(entity => entity.visible).length
}
getDebugString (): string {
const totalEntities = Object.keys(this.entities).length
const visibleEntities = this.entitiesRenderingCount
const playerEntities = Object.values(this.entities).filter(entity => entity.playerObject)
const visiblePlayerEntities = playerEntities.filter(entity => entity.visible)
return `${visibleEntities}/${totalEntities} ${visiblePlayerEntities.length}/${playerEntities.length}`
}
constructor (public worldRenderer: WorldRendererThree) {
this.debugMode = 'none'
this.onSkinUpdate = () => { }
this.watchResourcesUpdates()
}
handlePlayerEntity (playerData: SceneEntity['originalEntity']) {
// Create player entity if it doesn't exist
if (!this.playerEntity) {
// Create the player entity similar to how normal entities are created
const group = new THREE.Group() as unknown as SceneEntity
group.originalEntity = { ...playerData, name: 'player' } as SceneEntity['originalEntity']
const wrapper = new THREE.Group()
const playerObject = this.setupPlayerObject(playerData, wrapper, {})
group.playerObject = playerObject
group.add(wrapper)
group.name = 'player_entity'
this.playerEntity = group
this.worldRenderer.scene.add(group)
void this.updatePlayerSkin(playerData.id, playerData.username, playerData.uuid ?? undefined, stevePngUrl)
}
// Update position and rotation
if (playerData.position) {
this.playerEntity.position.set(playerData.position.x, playerData.position.y, playerData.position.z)
}
if (playerData.yaw !== undefined) {
this.playerEntity.rotation.y = playerData.yaw
}
this.updateEntityEquipment(this.playerEntity, playerData)
}
clear () {
@ -294,27 +248,6 @@ export class Entities {
disposeObject(mesh)
}
this.entities = {}
// Clean up player entity
if (this.playerEntity) {
this.worldRenderer.scene.remove(this.playerEntity)
disposeObject(this.playerEntity)
this.playerEntity = null
}
}
reloadEntities () {
for (const entity of Object.values(this.entities)) {
// update all entities textures like held items, armour, etc
// todo update entity textures itself
this.update({ ...entity.originalEntity, delete: true, } as SceneEntity['originalEntity'], {})
this.update(entity.originalEntity, {})
}
}
watchResourcesUpdates () {
this.worldRenderer.resourcesManager.on('assetsTexturesUpdated', () => this.reloadEntities())
this.worldRenderer.resourcesManager.on('assetsInventoryReady', () => this.reloadEntities())
}
setDebugMode (mode: string, entity: THREE.Object3D | null = null) {
@ -347,12 +280,11 @@ export class Entities {
}
const dt = this.clock.getDelta()
const botPos = this.worldRenderer.viewerChunkPosition
const VISIBLE_DISTANCE = 10 * 10
const botPos = this.worldRenderer.viewerPosition
const VISIBLE_DISTANCE = 8 * 8
// Update regular entities
for (const [entityId, entity] of [...Object.entries(this.entities), ['player_entity', this.playerEntity] as [string, SceneEntity | null]]) {
if (!entity) continue
for (const entityId of Object.keys(this.entities)) {
const entity = this.entities[entityId]
const { playerObject } = entity
// Update animations
@ -360,6 +292,9 @@ export class Entities {
playerObject.animation.update(playerObject, dt)
}
// Update armor positions
this.syncArmorPositions(entity)
// Update visibility based on distance and chunk load status
if (botPos && entity.position) {
const dx = entity.position.x - botPos.x
@ -367,37 +302,16 @@ export class Entities {
const dz = entity.position.z - botPos.z
const distanceSquared = dx * dx + dy * dy + dz * dz
// Entity is visible if within 20 blocks OR in a finished chunk
entity.visible = !!(distanceSquared < VISIBLE_DISTANCE || this.worldRenderer.shouldObjectVisible(entity))
// Get chunk coordinates
const chunkX = Math.floor(entity.position.x / 16) * 16
const chunkZ = Math.floor(entity.position.z / 16) * 16
const chunkKey = `${chunkX},${chunkZ}`
// Entity is visible if within 16 blocks OR in a finished chunk
entity.visible = !!(distanceSquared < VISIBLE_DISTANCE || this.worldRenderer.finishedChunks[chunkKey])
this.maybeRenderPlayerSkin(entityId)
}
if (entity.visible) {
// Update armor positions
this.syncArmorPositions(entity)
}
if (entityId === 'player_entity') {
entity.visible = this.worldRenderer.playerStateUtils.isThirdPerson()
if (entity.visible) {
// sync
const yOffset = this.worldRenderer.playerStateReactive.eyeHeight
const pos = this.worldRenderer.cameraObject.position.clone().add(new THREE.Vector3(0, -yOffset, 0))
entity.position.set(pos.x, pos.y, pos.z)
const rotation = this.worldRenderer.cameraShake.getBaseRotation()
entity.rotation.set(0, rotation.yaw, 0)
// Sync head rotation
entity.traverse((c) => {
if (c.name === 'head') {
c.rotation.set(-rotation.pitch, 0, 0)
}
})
}
}
}
}
@ -475,7 +389,6 @@ export class Entities {
}
getPlayerObject (entityId: string | number) {
if (this.playerEntity?.originalEntity.id === entityId) return this.playerEntity?.playerObject
const playerObject = this.entities[entityId]?.playerObject
return playerObject
}
@ -488,13 +401,8 @@ export class Entities {
.some(channel => channel !== 0)
}
// 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
@ -549,16 +457,16 @@ export class Entities {
if (!playerObject) return
try {
let playerCustomSkinImage: ImageBitmap | undefined
let playerCustomSkinImage: HTMLImageElement | undefined
playerObject = this.getPlayerObject(entityId)
if (!playerObject) return
let skinTexture: THREE.Texture
let skinCanvas: OffscreenCanvas
let skinCanvas: HTMLCanvasElement
if (skinUrl === stevePngUrl) {
skinTexture = await steveTexture
const canvas = createCanvas(64, 64)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Failed to get context')
ctx.drawImage(skinTexture.image, 0, 0)
@ -632,70 +540,22 @@ export class Entities {
}
}
debugSwingArm () {
const playerObject = Object.values(this.entities).find(entity => entity.playerObject?.animation instanceof WalkingGeneralSwing)
if (!playerObject) return
(playerObject.playerObject!.animation as WalkingGeneralSwing).swingArm()
}
playAnimation (entityPlayerId, animation: 'walking' | 'running' | 'oneSwing' | 'idle' | 'crouch' | 'crouchWalking') {
// TODO CLEANUP!
// Handle special player entity ID for bot entity in third person
if (entityPlayerId === 'player_entity' && this.playerEntity?.playerObject) {
const { playerObject } = this.playerEntity
if (animation === 'oneSwing') {
if (!(playerObject.animation instanceof WalkingGeneralSwing)) throw new Error('Expected WalkingGeneralSwing')
playerObject.animation.swingArm()
return
}
if (playerObject.animation instanceof WalkingGeneralSwing) {
playerObject.animation.switchAnimationCallback = () => {
if (!(playerObject.animation instanceof WalkingGeneralSwing)) throw new Error('Expected WalkingGeneralSwing')
playerObject.animation.isMoving = animation === 'walking' || animation === 'running' || animation === 'crouchWalking'
playerObject.animation.isRunning = animation === 'running'
playerObject.animation.isCrouched = animation === 'crouch' || animation === 'crouchWalking'
}
}
return
}
// Handle regular entities
const playerObject = this.getPlayerObject(entityPlayerId)
if (playerObject) {
if (animation === 'oneSwing') {
if (!(playerObject.animation instanceof WalkingGeneralSwing)) throw new Error('Expected WalkingGeneralSwing')
playerObject.animation.swingArm()
return
}
if (!playerObject) return
if (playerObject.animation instanceof WalkingGeneralSwing) {
playerObject.animation.switchAnimationCallback = () => {
if (!(playerObject.animation instanceof WalkingGeneralSwing)) throw new Error('Expected WalkingGeneralSwing')
playerObject.animation.isMoving = animation === 'walking' || animation === 'running' || animation === 'crouchWalking'
playerObject.animation.isRunning = animation === 'running'
playerObject.animation.isCrouched = animation === 'crouch' || animation === 'crouchWalking'
}
}
if (animation === 'oneSwing') {
if (!(playerObject.animation instanceof WalkingGeneralSwing)) throw new Error('Expected WalkingGeneralSwing')
playerObject.animation.swingArm()
return
}
// Handle player entity (for third person view) - fallback for backwards compatibility
if (this.playerEntity?.playerObject) {
const { playerObject: playerEntityObject } = this.playerEntity
if (animation === 'oneSwing') {
if (!(playerEntityObject.animation instanceof WalkingGeneralSwing)) throw new Error('Expected WalkingGeneralSwing')
playerEntityObject.animation.swingArm()
return
}
if (playerEntityObject.animation instanceof WalkingGeneralSwing) {
playerEntityObject.animation.switchAnimationCallback = () => {
if (!(playerEntityObject.animation instanceof WalkingGeneralSwing)) throw new Error('Expected WalkingGeneralSwing')
playerEntityObject.animation.isMoving = animation === 'walking' || animation === 'running' || animation === 'crouchWalking'
playerEntityObject.animation.isRunning = animation === 'running'
playerEntityObject.animation.isCrouched = animation === 'crouch' || animation === 'crouchWalking'
}
if (playerObject.animation instanceof WalkingGeneralSwing) {
playerObject.animation.switchAnimationCallback = () => {
if (!(playerObject.animation instanceof WalkingGeneralSwing)) throw new Error('Expected WalkingGeneralSwing')
playerObject.animation.isMoving = animation === 'walking' || animation === 'running' || animation === 'crouchWalking'
playerObject.animation.isRunning = animation === 'running'
playerObject.animation.isCrouched = animation === 'crouch' || animation === 'crouchWalking'
}
}
}
@ -718,13 +578,13 @@ export class Entities {
return typeof component === 'string' ? component : component.text ?? ''
}
getItemMesh (item, specificProps: ItemSpecificContextProperties, faceCamera = false, previousModel?: string) {
getItemMesh (item, specificProps: ItemSpecificContextProperties, 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
if (textureUv && 'resolvedModel' in textureUv) {
const mesh = getBlockMeshFromModel(this.worldRenderer.material, textureUv.resolvedModel, textureUv.modelName, this.worldRenderer.resourcesManager.currentResources.worldBlockProvider!)
const mesh = getBlockMeshFromModel(this.worldRenderer.material, textureUv.resolvedModel, textureUv.modelName, this.worldRenderer.resourcesManager.currentResources!.worldBlockProvider)
let SCALE = 1
if (specificProps['minecraft:display_context'] === 'ground') {
SCALE = 0.5
@ -737,41 +597,60 @@ export class Entities {
return {
mesh: outerGroup,
isBlock: true,
itemsTexture: null,
itemsTextureFlipped: null,
modelName: textureUv.modelName,
}
}
// Render proper 3D model for items
// TODO: Render proper model (especially for blocks) instead of flat texture
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 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 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 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
}
result.mesh.scale.set(SCALE, SCALE, SCALE)
mesh.scale.set(SCALE, SCALE, SCALE)
return {
mesh: result.mesh,
mesh,
isBlock: false,
itemsTexture,
itemsTextureFlipped,
modelName: textureUv.modelName,
cleanup: result.cleanup
}
}
}
@ -786,7 +665,9 @@ export class Entities {
}
}
update (entity: SceneEntity['originalEntity'], overrides) {
update (entity: import('prismarine-entity').Entity & { delete?; pos, name }, 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`}`
@ -797,7 +678,6 @@ 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
@ -813,27 +693,24 @@ export class Entities {
return
}
let mesh: THREE.Object3D | undefined
let mesh
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' || 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 }
const group = new THREE.Group()
if (entity.name === 'item' || entity.name === 'tnt' || entity.name === 'falling_block') {
const item = entity.name === 'tnt'
? { name: 'tnt' }
: 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' || entity.type === 'projectile') {
if (entity.name === 'item') {
mesh.scale.set(0.5, 0.5, 0.5)
mesh.position.set(0, entity.name === 'item' ? 0.2 : 0.1, 0)
mesh.position.set(0, 0.2, 0)
} else {
mesh.scale.set(2, 2, 2)
mesh.position.set(0, 0.5, 0)
@ -841,11 +718,11 @@ 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
mesh.rotation.y += delta
}
}
@ -869,35 +746,59 @@ export class Entities {
// }
// }
//@ts-expect-error
group.additionalCleanup = () => {
// important: avoid texture memory leak and gpu slowdown
if (object.cleanup) {
object.cleanup()
}
object.itemsTexture?.dispose()
object.itemsTextureFlipped?.dispose()
}
}
}
} else if (isPlayerModel) {
// CREATE NEW PLAYER ENTITY
const wrapper = new THREE.Group()
const playerObject = this.setupPlayerObject(entity, wrapper, overrides)
group.playerObject = playerObject
mesh = wrapper
const playerObject = new PlayerObject() as PlayerObjectType
playerObject.realPlayerUuid = entity.uuid ?? ''
playerObject.realUsername = entity.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
}
})
//@ts-expect-error
wrapper.add(playerObject)
const scale = 1 / 16
wrapper.scale.set(scale, scale, scale)
if (entity.username) {
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)
}
// 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
//@ts-expect-error
wrapper.add(nameTag)
}
//@ts-expect-error
group.playerObject = playerObject
wrapper.rotation.set(0, Math.PI, 0)
mesh = wrapper
playerObject.animation = new WalkingGeneralSwing()
//@ts-expect-error
playerObject.animation.isMoving = false
} else {
mesh = getEntityMesh(entity, this.worldRenderer, this.entitiesOptions, { ...overrides, customModel: entity['customModel'] })
mesh = getEntityMesh(entity, this.worldRenderer, this.entitiesOptions, overrides)
}
if (!mesh) return
mesh.name = 'mesh'
// set initial position so there are no weird jumps update after
const pos = entity.pos ?? entity.position
group.position.set(pos.x, pos.y, pos.z)
group.position.set(entity.pos.x, entity.pos.y, entity.pos.z)
// todo use width and height instead
const boxHelper = new THREE.BoxHelper(
@ -929,13 +830,23 @@ export class Entities {
mesh = e.children.find(c => c.name === 'mesh')
}
// Update equipment
this.updateEntityEquipment(e, entity)
// check if entity has armor
if (entity.equipment) {
const isPlayer = entity.type === 'player'
this.addItemModel(e, isPlayer ? 'right' : 'left', entity.equipment[0], isPlayer)
this.addItemModel(e, isPlayer ? 'left' : 'right', entity.equipment[1], isPlayer)
addArmorModel(this.worldRenderer, e, 'feet', entity.equipment[2])
addArmorModel(this.worldRenderer, e, 'legs', entity.equipment[3], 2)
addArmorModel(this.worldRenderer, e, 'chest', entity.equipment[4])
addArmorModel(this.worldRenderer, e, 'head', entity.equipment[5])
}
const meta = getGeneralEntitiesMetadata(entity)
const isInvisible = ((entity.metadata?.[0] ?? 0) as unknown as number) & 0x20 || (this.worldRenderer.playerStateReactive.cameraSpectatingEntity === entity.id && this.worldRenderer.playerStateUtils.isSpectator())
for (const child of mesh!.children ?? []) {
//@ts-expect-error
// set visibility
const isInvisible = entity.metadata?.[0] & 0x20
for (const child of mesh.children ?? []) {
if (child.name !== 'nametag') {
child.visible = !isInvisible
}
@ -950,22 +861,21 @@ export class Entities {
// entity specific meta
const textDisplayMeta = getSpecificEntityMetadata('text_display', entity)
const displayTextRaw = textDisplayMeta?.text || meta.custom_name_visible && meta.custom_name
if (entity.name !== 'player' && displayTextRaw) {
const displayText = this.parseEntityLabel(displayTextRaw)
if (entity.name !== 'player' && displayText) {
const nameTagFixed = textDisplayMeta && (textDisplayMeta.billboard_render_constraints === 'fixed' || !textDisplayMeta.billboard_render_constraints)
const nameTagBackgroundColor = (textDisplayMeta && (parseInt(textDisplayMeta.style_flags, 10) & 0x04) === 0) ? toRgba(textDisplayMeta.background_color) : undefined
const nameTagBackgroundColor = textDisplayMeta && toRgba(textDisplayMeta.background_color)
let nameTagTextOpacity: any
if (textDisplayMeta?.text_opacity) {
const rawOpacity = parseInt(textDisplayMeta?.text_opacity, 10)
nameTagTextOpacity = rawOpacity > 0 ? rawOpacity : 256 - rawOpacity
}
addNametag(
{ ...entity, username: typeof displayTextRaw === 'string' ? mojangson.simplify(mojangson.parse(displayTextRaw)) : nbt.simplify(displayTextRaw),
nameTagBackgroundColor, nameTagTextOpacity, nameTagFixed,
{ ...entity, username: displayText, 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,
this.worldRenderer.version
mesh
)
}
@ -975,8 +885,8 @@ export class Entities {
const hasArms = (parseInt(armorStandMeta.client_flags, 10) & 0x04) !== 0
const hasBasePlate = (parseInt(armorStandMeta.client_flags, 10) & 0x08) === 0
const isMarker = (parseInt(armorStandMeta.client_flags, 10) & 0x10) !== 0
mesh!.castShadow = !isMarker
mesh!.receiveShadow = !isMarker
mesh.castShadow = !isMarker
mesh.receiveShadow = !isMarker
if (isSmall) {
e.scale.set(0.5, 0.5, 0.5)
} else {
@ -1045,9 +955,7 @@ export class Entities {
// TODO: fix type
// todo! fix errors in mc-data (no entities data prior 1.18.2)
const item = (itemFrameMeta?.item ?? entity.metadata?.[8]) as any as { itemId, blockId, components, nbtData: { value: { map: { value: number } } } }
mesh!.scale.set(1, 1, 1)
mesh!.position.set(0, 0, -0.5)
mesh.scale.set(1, 1, 1)
e.rotation.x = -entity.pitch
e.children.find(c => {
if (c.name.startsWith('map_')) {
@ -1064,33 +972,25 @@ export class Entities {
}
return false
})?.removeFromParent()
if (item && (item.itemId ?? item.blockId ?? 0) !== 0) {
// Get rotation from metadata, default to 0 if not present
// Rotation is stored in 45° increments (0-7) for items, 90° increments (0-3) for maps
const rotation = (itemFrameMeta.rotation as any as number) ?? 0
const mapNumber = item.nbtData?.value?.map?.value ?? item.components?.find(x => x.type === 'map_id')?.data
if (mapNumber) {
// TODO: Use proper larger item frame model when a map exists
mesh!.scale.set(16 / 12, 16 / 12, 1)
// Handle map rotation (4 possibilities, 90° increments)
mesh.scale.set(16 / 12, 16 / 12, 1)
this.addMapModel(e, mapNumber, rotation)
} else {
// Handle regular item rotation (8 possibilities, 45° increments)
const itemMesh = this.getItemMesh(item, {
'minecraft:display_context': 'fixed',
})
if (itemMesh) {
itemMesh.mesh.position.set(0, 0, -0.05)
// itemMesh.mesh.position.set(0, 0, 0.43)
itemMesh.mesh.position.set(0, 0, 0.43)
if (itemMesh.isBlock) {
itemMesh.mesh.scale.set(0.25, 0.25, 0.25)
} else {
itemMesh.mesh.scale.set(0.5, 0.5, 0.5)
}
// Rotate 180° around Y axis first
itemMesh.mesh.rotateY(Math.PI)
// Then apply the 45° increment rotation
itemMesh.mesh.rotateZ(-rotation * Math.PI / 4)
itemMesh.mesh.name = 'item'
e.add(itemMesh.mesh)
@ -1099,11 +999,17 @@ export class Entities {
}
}
if (entity.username !== undefined) {
if (entity.username) {
e.username = entity.username
}
this.updateNameTagVisibility(e)
if (entity.type === 'player' && entity.equipment && e.playerObject) {
const { playerObject } = e
playerObject.backEquipment = entity.equipment.some((item) => item?.name === 'elytra') ? 'elytra' : 'cape'
if (playerObject.cape.map === null) {
playerObject.cape.visible = false
}
}
this.updateEntityPosition(entity, justAdded, overrides)
}
@ -1134,20 +1040,17 @@ export class Entities {
loadedSkinEntityIds = new Set<string>()
maybeRenderPlayerSkin (entityId: string) {
let mesh = this.entities[entityId]
if (entityId === 'player_entity') {
mesh = this.playerEntity!
entityId = this.playerEntity?.originalEntity.id as any
}
const mesh = this.entities[entityId]
if (!mesh) return
if (!mesh.playerObject) return
if (!mesh.visible) return
const MAX_DISTANCE_SKIN_LOAD = 128
const cameraPos = this.worldRenderer.cameraObject.position
const cameraPos = this.worldRenderer.camera.position
const distance = mesh.position.distanceTo(cameraPos)
if (distance < MAX_DISTANCE_SKIN_LOAD && distance < (this.worldRenderer.viewDistance * 16)) {
if (this.loadedSkinEntityIds.has(String(entityId))) return
if (this.loadedSkinEntityIds.has(entityId)) return
this.loadedSkinEntityIds.add(entityId)
void this.updatePlayerSkin(entityId, mesh.playerObject.realUsername, mesh.playerObject.realPlayerUuid, true, true)
}
}
@ -1172,20 +1075,6 @@ export class Entities {
}
}
updateNameTagVisibility (entity: SceneEntity) {
const playerTeam = this.worldRenderer.playerStateReactive.team
const entityTeam = entity.originalEntity.team
const nameTagVisibility = entityTeam?.nameTagVisibility || 'always'
const showNameTag = nameTagVisibility === 'always' ||
(nameTagVisibility === 'hideForOwnTeam' && entityTeam?.team !== playerTeam?.team) ||
(nameTagVisibility === 'hideForOtherTeams' && (entityTeam?.team === playerTeam?.team || playerTeam === undefined))
entity.traverse(c => {
if (c.name === 'nametag') {
c.visible = showNameTag
}
})
}
addMapModel (entityMesh: THREE.Object3D, mapNumber: number, rotation: number) {
const imageData = this.cachedMapsImages?.[mapNumber]
let texture: THREE.Texture | null = null
@ -1216,7 +1105,6 @@ export class Entities {
} else {
mapMesh.position.set(0, 0, 0.437)
}
// Apply 90° increment rotation for maps (0-3)
mapMesh.rotateZ(Math.PI * 2 - rotation * Math.PI / 2)
mapMesh.name = `map_${mapNumber}`
@ -1262,9 +1150,8 @@ export class Entities {
const group = new THREE.Object3D()
group['additionalCleanup'] = () => {
// important: avoid texture memory leak and gpu slowdown
if (itemObject.cleanup) {
itemObject.cleanup()
}
itemObject.itemsTexture?.dispose()
itemObject.itemsTextureFlipped?.dispose()
}
const itemMesh = itemObject.mesh
group.rotation.z = -Math.PI / 16
@ -1311,63 +1198,13 @@ export class Entities {
}
}
raycastSceneDebug () {
raycastScene () {
// return any object from scene. raycast from camera
const raycaster = new THREE.Raycaster()
raycaster.setFromCamera(new THREE.Vector2(0, 0), this.worldRenderer.camera)
const intersects = raycaster.intersectObjects(this.worldRenderer.scene.children)
return intersects[0]?.object
}
private setupPlayerObject (entity: SceneEntity['originalEntity'], wrapper: THREE.Group, overrides: { texture?: string }): PlayerObjectType {
const playerObject = new PlayerObject() as PlayerObjectType
playerObject.realPlayerUuid = entity.uuid ?? ''
playerObject.realUsername = entity.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 = 1 / 16
wrapper.scale.set(scale, scale, scale)
wrapper.rotation.set(0, Math.PI, 0)
// Set up animation
playerObject.animation = new WalkingGeneralSwing()
//@ts-expect-error
playerObject.animation.isMoving = false
return playerObject
}
private updateEntityEquipment (entityMesh: SceneEntity, entity: SceneEntity['originalEntity']) {
if (!entityMesh || !entity.equipment) return
const isPlayer = entity.type === 'player'
this.addItemModel(entityMesh, isPlayer ? 'right' : 'left', entity.equipment[0], isPlayer)
this.addItemModel(entityMesh, isPlayer ? 'left' : 'right', entity.equipment[1], isPlayer)
addArmorModel(this.worldRenderer, entityMesh, 'feet', entity.equipment[2])
addArmorModel(this.worldRenderer, entityMesh, 'legs', entity.equipment[3], 2)
addArmorModel(this.worldRenderer, entityMesh, 'chest', entity.equipment[4])
addArmorModel(this.worldRenderer, entityMesh, 'head', entity.equipment[5])
// Update player-specific equipment
if (isPlayer && entityMesh.playerObject) {
const { playerObject } = entityMesh
playerObject.backEquipment = entity.equipment.some((item) => item?.name === 'elytra') ? 'elytra' : 'cape'
if (playerObject.backEquipment === 'elytra') {
void this.loadAndApplyCape(entity.id, elytraTexture)
}
if (playerObject.cape.map === null) {
playerObject.cape.visible = false
}
}
}
}
function getGeneralEntitiesMetadata (entity: { name; metadata }): Partial<UnionToIntersection<EntityMetadataVersions[keyof EntityMetadataVersions]>> {
@ -1408,11 +1245,6 @@ function addArmorModel (worldRenderer: WorldRendererThree, entityMesh: THREE.Obj
if (textureData) {
const decodedData = JSON.parse(Buffer.from(textureData, 'base64').toString())
texturePath = decodedData.textures?.SKIN?.url
const { skinTexturesProxy } = this.worldRenderer.worldRendererConfig
if (skinTexturesProxy) {
texturePath = texturePath?.replace('http://textures.minecraft.net/', skinTexturesProxy)
.replace('https://textures.minecraft.net/', skinTexturesProxy)
}
}
} catch (err) {
console.error('Error decoding player head texture:', err)
@ -1425,7 +1257,7 @@ function addArmorModel (worldRenderer: WorldRendererThree, entityMesh: THREE.Obj
if (!texturePath) {
// TODO: Support mirroring on certain parts of the model
const armorTextureName = `${armorMaterial}_layer_${layer}${overlay ? '_overlay' : ''}`
texturePath = worldRenderer.resourcesManager.currentResources.customTextures.armor?.textures[armorTextureName]?.src ?? armorTextures[armorTextureName]
texturePath = worldRenderer.resourcesManager.currentResources!.customTextures.armor?.textures[armorTextureName]?.src ?? armorTextures[armorTextureName]
}
if (!texturePath || !armorModel[slotType]) {
removeArmorModel(entityMesh, slotType)

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 '../threeJsUtils'
import { loadTexture } from '../../lib/utils'
import { WorldRendererThree } from '../worldrendererThree'
import entities from './entities.json'
import { externalModels } from './objModels'
@ -238,11 +238,10 @@ export function getMesh (
if (useBlockTexture) {
if (!worldRenderer) throw new Error('worldRenderer is required for block textures')
const blockName = texture.slice(6)
const textureInfo = worldRenderer.resourcesManager.currentResources.blocksAtlasJson.textures[blockName]
const textureInfo = worldRenderer.resourcesManager.currentResources!.blocksAtlasParser.getTextureInfo(blockName)
if (textureInfo) {
textureWidth = blocksTexture?.image.width ?? textureWidth
textureHeight = blocksTexture?.image.height ?? textureHeight
// todo support su/sv
textureOffset = [textureInfo.u, textureInfo.v]
} else {
console.error(`Unknown block ${blockName}`)
@ -547,4 +546,4 @@ export class EntityMesh {
}
}
}
globalThis.EntityMesh = EntityMesh
window.EntityMesh = EntityMesh

View file

@ -14,7 +14,6 @@ import { default as netheriteLayer1 } from 'mc-assets/dist/other-textures/latest
import { default as netheriteLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/netherite_layer_2.png'
import { default as turtleLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/turtle_layer_1.png'
export { default as elytraTexture } from 'mc-assets/dist/other-textures/latest/entity/elytra.png'
export { default as armorModel } from './armorModels.json'
export const armorTextures = {

View file

@ -5,7 +5,6 @@ import { ProgressReporter } from '../../../src/core/progressReporter'
import { showNotification } from '../../../src/react/NotificationProvider'
import { displayEntitiesDebugList } from '../../playground/allEntitiesDebug'
import supportedVersions from '../../../src/supportedVersions.mjs'
import { ResourcesManager } from '../../../src/resourcesManager'
import { WorldRendererThree } from './worldrendererThree'
import { DocumentRenderer } from './documentRenderer'
import { PanoramaRenderer } from './panorama'
@ -13,7 +12,7 @@ import { initVR } from './world/vr'
// https://discourse.threejs.org/t/updates-to-color-management-in-three-js-r152/50791
THREE.ColorManagement.enabled = false
globalThis.THREE = THREE
window.THREE = THREE
const getBackendMethods = (worldRenderer: WorldRendererThree) => {
return {
@ -25,7 +24,7 @@ const getBackendMethods = (worldRenderer: WorldRendererThree) => {
updatePlayerSkin: worldRenderer.entities.updatePlayerSkin.bind(worldRenderer.entities),
changeHandSwingingState: worldRenderer.changeHandSwingingState.bind(worldRenderer),
getHighestBlocks: worldRenderer.getHighestBlocks.bind(worldRenderer),
reloadWorld: worldRenderer.reloadWorld.bind(worldRenderer),
rerenderAllChunks: worldRenderer.rerenderAllChunks.bind(worldRenderer),
addMedia: worldRenderer.media.addMedia.bind(worldRenderer.media),
destroyMedia: worldRenderer.media.destroyMedia.bind(worldRenderer.media),
@ -44,12 +43,6 @@ 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)
}
}
@ -64,27 +57,31 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO
let worldRenderer: WorldRendererThree | null = null
const startPanorama = async () => {
if (!documentRenderer) throw new Error('Document renderer not initialized')
if (worldRenderer) return
const qs = new URLSearchParams(location.search)
const qs = new URLSearchParams(window.location.search)
if (qs.get('debugEntities')) {
const fullResourceManager = initOptions.resourcesManager as ResourcesManager
fullResourceManager.currentConfig = { version: qs.get('version') || supportedVersions.at(-1)!, noInventoryGui: true }
await fullResourceManager.updateAssetsData({ })
initOptions.resourcesManager.currentConfig = { version: qs.get('version') || supportedVersions.at(-1)!, noInventoryGui: true }
await initOptions.resourcesManager.updateAssetsData({ })
displayEntitiesDebugList(fullResourceManager.currentConfig.version)
displayEntitiesDebugList(initOptions.resourcesManager.currentConfig.version)
return
}
if (!panoramaRenderer) {
panoramaRenderer = new PanoramaRenderer(documentRenderer, initOptions, !!process.env.SINGLE_FILE_BUILD_MODE)
globalThis.panoramaRenderer = panoramaRenderer
window.panoramaRenderer = panoramaRenderer
callModsMethod('panoramaCreated', panoramaRenderer)
await panoramaRenderer.start()
callModsMethod('panoramaReady', panoramaRenderer)
}
}
let version = ''
const prepareResources = async (ver: string, progressReporter: ProgressReporter): Promise<void> => {
version = ver
await initOptions.resourcesManager.updateAssetsData({ })
}
const startWorld = async (displayOptions: DisplayWorldOptions) => {
if (panoramaRenderer) {
panoramaRenderer.dispose()
@ -127,9 +124,6 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO
if (worldRenderer) worldRenderer.renderingActive = rendering
},
getDebugOverlay: () => ({
get entitiesString () {
return worldRenderer?.entities.getDebugString()
},
}),
updateCamera (pos: Vec3 | null, yaw: number, pitch: number) {
worldRenderer?.setFirstPersonCamera(pos, yaw, pitch)

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 { MovementState, PlayerStateRenderer } from '../lib/basePlayerState'
import { getMyHand } from '../lib/hand'
import { IPlayerState, MovementState } 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'
@ -116,20 +116,16 @@ export default class HoldingBlock {
offHandModeLegacy = false
swingAnimator: HandSwingAnimator | undefined
playerState: IPlayerState
config: WorldRendererConfig
constructor (public worldRenderer: WorldRendererThree, public offHand = false) {
this.initCameraGroup()
this.worldRenderer.onReactivePlayerStateUpdated('heldItemMain', () => {
if (!this.offHand) {
this.updateItem()
}
}, false)
this.worldRenderer.onReactivePlayerStateUpdated('heldItemOff', () => {
if (this.offHand) {
this.updateItem()
}
}, false)
this.playerState = worldRenderer.displayOptions.playerState
this.playerState.events.on('heldItemChanged', (_, isOffHand) => {
if (this.offHand !== isOffHand) return
this.updateItem()
})
this.config = worldRenderer.displayOptions.inWorldRenderingConfig
this.offHandDisplay = this.offHand
@ -138,21 +134,17 @@ export default class HoldingBlock {
// load default hand
void getMyHand().then((hand) => {
this.playerHand = hand
// trigger update
this.updateItem()
}).then(() => {
// now watch over the player skin
watchProperty(
async () => {
return getMyHand(this.worldRenderer.playerStateReactive.playerSkin, this.worldRenderer.playerStateReactive.onlineMode ? this.worldRenderer.playerStateReactive.username : undefined)
return getMyHand(this.playerState.reactive.playerSkin, this.playerState.onlineMode ? this.playerState.username : undefined)
},
this.worldRenderer.playerStateReactive,
this.playerState.reactive,
'playerSkin',
(newHand) => {
if (newHand) {
this.playerHand = newHand
// trigger update
this.updateItem()
}
},
(oldHand) => {
@ -164,8 +156,8 @@ export default class HoldingBlock {
}
updateItem () {
if (!this.ready) return
const item = this.offHand ? this.worldRenderer.playerStateReactive.heldItemOff : this.worldRenderer.playerStateReactive.heldItemMain
if (!this.ready || !this.playerState.getHeldItem) return
const item = this.playerState.getHeldItem(this.offHand)
if (item) {
void this.setNewItem(item)
} else if (this.offHand) {
@ -355,9 +347,9 @@ export default class HoldingBlock {
itemId: handItem.id,
}, {
'minecraft:display_context': 'firstperson',
'minecraft:use_duration': this.worldRenderer.playerStateReactive.itemUsageTicks,
'minecraft:using_item': !!this.worldRenderer.playerStateReactive.itemUsageTicks,
}, false, this.lastItemModelName)
'minecraft:use_duration': this.playerState.getItemUsageTicks?.(),
'minecraft:using_item': !!this.playerState.getItemUsageTicks?.(),
}, this.lastItemModelName)
if (result) {
const { mesh: itemMesh, isBlock, modelName } = result
if (isBlock) {
@ -473,7 +465,7 @@ export default class HoldingBlock {
this.swingAnimator = new HandSwingAnimator(this.holdingBlockInnerGroup)
this.swingAnimator.type = result.type
if (this.config.viewBobbing) {
this.idleAnimator = new HandIdleAnimator(this.holdingBlockInnerGroup, this.worldRenderer.playerStateReactive)
this.idleAnimator = new HandIdleAnimator(this.holdingBlockInnerGroup, this.playerState)
}
}
@ -554,7 +546,7 @@ class HandIdleAnimator {
private readonly debugGui: DebugGui
constructor (public handMesh: THREE.Object3D, public playerState: PlayerStateRenderer) {
constructor (public handMesh: THREE.Object3D, public playerState: IPlayerState) {
this.handMesh = handMesh
this.globalTime = 0
this.currentState = 'NOT_MOVING'
@ -708,7 +700,7 @@ class HandIdleAnimator {
// Check for state changes from player state
if (this.playerState) {
const newState = this.playerState.movementState
const newState = this.playerState.getMovementState()
if (newState !== this.targetState) {
this.setState(newState)
}

View file

@ -1,427 +0,0 @@
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

@ -6,14 +6,11 @@ import * as tweenJs from '@tweenjs/tween.js'
import type { GraphicsInitOptions } from '../../../src/appViewer'
import { WorldDataEmitter } from '../lib/worldDataEmitter'
import { defaultWorldRendererConfig, WorldRendererCommon } from '../lib/worldrendererCommon'
import { BasePlayerState } from '../lib/basePlayerState'
import { getDefaultRendererState } from '../baseGraphicsBackend'
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)
@ -51,7 +48,7 @@ export class PanoramaRenderer {
this.directionalLight.castShadow = true
this.scene.add(this.directionalLight)
this.camera = new THREE.PerspectiveCamera(85, this.documentRenderer.canvas.width / this.documentRenderer.canvas.height, 0.05, 1000)
this.camera = new THREE.PerspectiveCamera(85, window.innerWidth / window.innerHeight, 0.05, 1000)
this.camera.position.set(0, 0, 0)
this.camera.rotation.set(0, 0, 0)
}
@ -66,57 +63,47 @@ export class PanoramaRenderer {
this.documentRenderer.render = (sizeChanged = false) => {
if (sizeChanged) {
this.camera.aspect = this.documentRenderer.canvas.width / this.documentRenderer.canvas.height
this.camera.aspect = window.innerWidth / window.innerHeight
this.camera.updateProjectionMatrix()
}
this.documentRenderer.renderer.render(this.scene, this.camera)
}
}
async debugImageInFrontOfCamera () {
const image = await loadThreeJsTextureFromUrl(join('background', 'panorama_0.png'))
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1000, 1000), new THREE.MeshBasicMaterial({ map: image }))
mesh.position.set(0, 0, -500)
mesh.rotation.set(0, 0, 0)
this.scene.add(mesh)
}
addClassicPanorama () {
const panorGeo = new THREE.BoxGeometry(1000, 1000, 1000)
const loader = new THREE.TextureLoader()
const panorMaterials = [] as THREE.MeshBasicMaterial[]
const fadeInDuration = 200
// void this.debugImageInFrontOfCamera()
for (const file of panoramaFiles) {
const load = async () => {
const { texture } = loadThreeJsTextureFromUrlSync(join('background', file))
// Instead of using repeat/offset to flip, we'll use the texture matrix
texture.matrixAutoUpdate = false
texture.matrix.set(
-1, 0, 1, 0, 1, 0, 0, 0, 1
)
texture.wrapS = THREE.ClampToEdgeWrapping
texture.wrapT = THREE.ClampToEdgeWrapping
texture.minFilter = THREE.LinearFilter
texture.magFilter = THREE.LinearFilter
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
opacity: 0 // Start with 0 opacity
})
// eslint-disable-next-line prefer-const
let material: THREE.MeshBasicMaterial
const texture = loader.load(join('background', file), () => {
// Start fade-in when texture is loaded
this.startTimes.set(material, Date.now())
panorMaterials.push(material)
}
})
void load()
// Instead of using repeat/offset to flip, we'll use the texture matrix
texture.matrixAutoUpdate = false
texture.matrix.set(
-1, 0, 1, 0, 1, 0, 0, 0, 1
)
texture.wrapS = THREE.ClampToEdgeWrapping
texture.wrapT = THREE.ClampToEdgeWrapping
texture.minFilter = THREE.LinearFilter
texture.magFilter = THREE.LinearFilter
material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
opacity: 0 // Start with 0 opacity
})
panorMaterials.push(material)
}
const panoramaBox = new THREE.Mesh(panorGeo, panorMaterials)
@ -157,10 +144,9 @@ export class PanoramaRenderer {
}
async worldBlocksPanorama () {
const version = PANORAMA_VERSION
const fullResourceManager = this.options.resourcesManager as ResourcesManager
fullResourceManager.currentConfig = { version, noInventoryGui: true, }
await fullResourceManager.updateAssetsData({ })
const version = '1.21.4'
this.options.resourcesManager.currentConfig = { version, noInventoryGui: true, }
await this.options.resourcesManager.updateAssetsData({ })
if (this.abortController.signal.aborted) return
console.time('load panorama scene')
const world = getSyncWorld(version)
@ -198,9 +184,9 @@ export class PanoramaRenderer {
version,
worldView,
inWorldRenderingConfig: defaultWorldRendererConfig,
playerStateReactive: getInitialPlayerStateRenderer().reactive,
rendererState: getDefaultRendererState().reactive,
nonReactiveState: getDefaultRendererState().nonReactive
playerState: new BasePlayerState(),
rendererState: getDefaultRendererState(),
nonReactiveState: getDefaultRendererState()
}
)
if (this.worldRenderer instanceof WorldRendererThree) {

View file

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

View file

@ -1,82 +0,0 @@
import { getRenamedData } from 'flying-squid/dist/blockRenames'
import { BlockModel } from 'mc-assets'
import { versionToNumber } from 'mc-assets/dist/utils'
import type { ResourcesManagerCommon } from '../../../src/resourcesManager'
export type ResolvedItemModelRender = {
modelName: string,
originalItemName?: string
}
export const renderSlot = (model: ResolvedItemModelRender, resourcesManager: ResourcesManagerCommon, debugIsQuickbar = false, fullBlockModelSupport = false): {
texture: string,
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]
// #region normalize item name
if (versionToNumber(bot.version) < versionToNumber('1.13')) itemModelName = getRenamedData(isItem ? 'items' : 'blocks', itemModelName, bot.version, '1.13.1') as string
// #endregion
let itemTexture
if (!fullBlockModelSupport) {
const atlas = resourcesManager.currentResources?.guiAtlas?.json
// todo atlas holds all rendered blocks, not all possibly rendered item/block models, need to request this on demand instead (this is how vanilla works)
const tryGetAtlasTexture = (name?: string) => name && atlas?.textures[name.replace('minecraft:', '').replace('block/', '').replace('blocks/', '').replace('item/', '').replace('items/', '').replace('_inventory', '')]
const item = tryGetAtlasTexture(itemModelName) ?? tryGetAtlasTexture(model.originalItemName)
if (item) {
const x = item.u * atlas.width
const y = item.v * atlas.height
return {
texture: 'gui',
slice: [x, y, atlas.tileSize, atlas.tileSize],
scale: 0.25,
blockData: null,
modelName: null
}
}
}
const blockToTopTexture = (r) => r.top ?? r
try {
if (!appViewer.resourcesManager.currentResources?.itemsRenderer) throw new Error('Items renderer is not available')
itemTexture =
appViewer.resourcesManager.currentResources.itemsRenderer.getItemTexture(itemModelName, {}, false, fullBlockModelSupport)
?? (model.originalItemName ? appViewer.resourcesManager.currentResources.itemsRenderer.getItemTexture(model.originalItemName, {}, false, fullBlockModelSupport) : undefined)
?? appViewer.resourcesManager.currentResources.itemsRenderer.getItemTexture('item/missing_texture')!
} catch (err) {
// get resourcepack from resource manager
reportError?.(`Failed to render item ${itemModelName} (original: ${model.originalItemName}) on ${bot.version} (resourcepack: TODO!): ${err.stack}`)
itemTexture = blockToTopTexture(appViewer.resourcesManager.currentResources!.itemsRenderer.getItemTexture('errored')!)
}
itemTexture ??= blockToTopTexture(appViewer.resourcesManager.currentResources!.itemsRenderer.getItemTexture('unknown')!)
if ('type' in itemTexture) {
// is item
return {
texture: itemTexture.type,
slice: itemTexture.slice,
modelName: itemModelName,
blockData: null,
scale: null
}
} else {
// is block
return {
texture: 'blocks',
blockData: itemTexture,
modelName: itemModelName,
slice: null,
scale: null
}
}
}

View file

@ -1,406 +0,0 @@
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, timeout?: number) => void
playSound: (position: { x: number, y: number, z: number }, path: string, volume?: number, pitch?: number) => void
destroy: () => void
}
@ -10,17 +10,7 @@ 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 () {
@ -29,63 +19,41 @@ 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, timeout = 500) {
playSound (position: { x: number, y: number, z: number }, path: string, volume = 1, pitch = 1) {
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 > timeout) {
console.warn('Ignored playing sound', path, 'due to timeout:', timeout, 'ms <', Date.now() - start, 'ms')
return
}
if (Date.now() - start > 500) return
// play
sound.setBuffer(buffer)
sound.setRefDistance(20)
sound.setVolume(volume * this.baseVolume)
sound.setVolume(volume)
sound.setPlaybackRate(pitch) // set the pitch
this.worldRenderer.scene.add(sound)
// set sound position
sound.position.set(position.x, position.y, position.z)
sound.onEnded = () => {
this.worldRenderer.scene.remove(sound)
if (sound.source) {
sound.disconnect()
}
sound.disconnect()
this.activeSounds.delete(sound)
this.soundVolumes.delete(sound)
audioLoader.manager.itemEnd(path)
}
sound.play()
})
}
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()
// Stop and clean up all active sounds
for (const sound of this.activeSounds) {
sound.stop()
sound.disconnect()
}
// Remove and cleanup audio listener
if (this.audioListener) {
this.audioListener.removeFromParent()

View file

@ -1,6 +1,4 @@
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
@ -18,56 +16,3 @@ 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

@ -1,418 +0,0 @@
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

@ -1,140 +0,0 @@
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,9 +1,10 @@
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'
@ -28,24 +29,24 @@ export class CursorBlock {
}
cursorLineMaterial: LineMaterial
interactionLines: null | { blockPos: Vec3, mesh: THREE.Group, shapePositions: BlocksShapes | undefined } = null
interactionLines: null | { blockPos: Vec3, mesh: THREE.Group } = null
prevColor: string | undefined
blockBreakMesh: THREE.Mesh
breakTextures: THREE.Texture[] = []
constructor (public readonly worldRenderer: WorldRendererThree) {
// Initialize break mesh and textures
const loader = new THREE.TextureLoader()
const destroyStagesImages = [
destroyStage0, destroyStage1, destroyStage2, destroyStage3, destroyStage4,
destroyStage5, destroyStage6, destroyStage7, destroyStage8, destroyStage9
]
for (let i = 0; i < 10; i++) {
void loadThreeJsTextureFromUrl(destroyStagesImages[i]).then((texture) => {
texture.magFilter = THREE.NearestFilter
texture.minFilter = THREE.NearestFilter
this.breakTextures.push(texture)
})
const texture = loader.load(destroyStagesImages[i])
texture.magFilter = THREE.NearestFilter
texture.minFilter = THREE.NearestFilter
this.breakTextures.push(texture)
}
const breakMaterial = new THREE.MeshBasicMaterial({
@ -59,26 +60,18 @@ export class CursorBlock {
this.blockBreakMesh.name = 'blockBreakMesh'
this.worldRenderer.scene.add(this.blockBreakMesh)
this.worldRenderer.onReactivePlayerStateUpdated('gameMode', () => {
subscribeKey(this.worldRenderer.playerState.reactive, '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)
}
})
this.updateLineMaterial()
}
// Update functions
updateLineMaterial () {
const inCreative = this.worldRenderer.playerStateReactive.gameMode === 'creative'
const inCreative = this.worldRenderer.displayOptions.playerState.reactive.gameMode === 'creative'
const pixelRatio = this.worldRenderer.renderer.getPixelRatio()
if (this.cursorLineMaterial) {
this.cursorLineMaterial.dispose()
}
this.cursorLineMaterial = new LineMaterial({
color: (() => {
switch (this.worldRenderer.worldRendererConfig.highlightBlockColor) {
@ -125,8 +118,8 @@ export class CursorBlock {
}
}
setHighlightCursorBlock (blockPos: Vec3 | null, shapePositions?: BlocksShapes, force = false): void {
if (blockPos && this.interactionLines && blockPos.equals(this.interactionLines.blockPos) && !force) {
setHighlightCursorBlock (blockPos: Vec3 | null, shapePositions?: BlocksShapes): void {
if (blockPos && this.interactionLines && blockPos.equals(this.interactionLines.blockPos)) {
return
}
if (this.interactionLines !== null) {
@ -150,7 +143,7 @@ export class CursorBlock {
}
this.worldRenderer.scene.add(group)
group.visible = !this.cursorLinesHidden
this.interactionLines = { blockPos, mesh: group, shapePositions }
this.interactionLines = { blockPos, mesh: group }
}
render () {

View file

@ -5,6 +5,7 @@ import { buttonMap as standardButtonsMap } from 'contro-max/build/gamepad'
import * as THREE from 'three'
import { WorldRendererThree } from '../worldrendererThree'
import { DocumentRenderer } from '../documentRenderer'
import { VRHud } from './vrHud'
export async function initVR (worldRenderer: WorldRendererThree, documentRenderer: DocumentRenderer) {
if (!('xr' in navigator) || !worldRenderer.worldRendererConfig.vrSupport) return
@ -15,6 +16,9 @@ export async function initVR (worldRenderer: WorldRendererThree, documentRendere
enableVr()
// Create VR HUD
const vrHud = new VRHud(worldRenderer)
const vrButtonContainer = createVrButtonContainer(renderer)
const updateVrButtons = () => {
const newHidden = !worldRenderer.worldRendererConfig.vrSupport || !worldRenderer.worldRendererConfig.foreground
@ -37,6 +41,9 @@ export async function initVR (worldRenderer: WorldRendererThree, documentRendere
worldRenderer.reactiveState.preventEscapeMenu = false
worldRenderer.scene.remove(user)
vrButtonContainer.hidden = true
// Detach HUD when VR is disabled
vrHud.detachFromVRCamera(user)
vrHud.setVisible(false)
}
function createVrButtonContainer (renderer) {
@ -102,7 +109,7 @@ export async function initVR (worldRenderer: WorldRendererThree, documentRendere
// hack for vr camera
const user = new THREE.Group()
user.name = 'vr-camera-container'
user.add(worldRenderer.camera)
worldRenderer.scene.add(user)
const controllerModelFactory = new XRControllerModelFactory(new GLTFLoader())
const controller1 = renderer.xr.getControllerGrip(0)
@ -199,18 +206,28 @@ export async function initVR (worldRenderer: WorldRendererThree, documentRendere
// bot.entity.yaw = Math.atan2(-d.x, -d.z)
// bot.entity.pitch = Math.asin(d.y)
// Update VR HUD
vrHud.update()
documentRenderer.frameRender(false)
})
renderer.xr.addEventListener('sessionstart', () => {
user.add(worldRenderer.camera)
worldRenderer.cameraGroupVr = user
// Attach HUD to VR camera when session starts
vrHud.attachToVRCamera(user)
vrHud.setVisible(true)
})
renderer.xr.addEventListener('sessionend', () => {
worldRenderer.cameraGroupVr = undefined
user.remove(worldRenderer.camera)
// Detach HUD when session ends
vrHud.detachFromVRCamera(user)
vrHud.setVisible(false)
})
worldRenderer.abortController.signal.addEventListener('abort', disableVr)
worldRenderer.abortController.signal.addEventListener('abort', () => {
disableVr()
vrHud.dispose()
})
}
const xrStandardRightButtonsMap = [

View file

@ -0,0 +1,129 @@
import * as THREE from 'three'
import { WorldRendererThree } from '../worldrendererThree'
export class VRHud {
private hudMesh: THREE.Mesh
private hudCanvas: HTMLCanvasElement
private hudContext: CanvasRenderingContext2D
private hudTexture: THREE.CanvasTexture
private hudGroup: THREE.Group
constructor(private worldRenderer: WorldRendererThree) {
// Create canvas for HUD
this.hudCanvas = document.createElement('canvas')
this.hudCanvas.width = 1024
this.hudCanvas.height = 512
this.hudContext = this.hudCanvas.getContext('2d')!
// Create texture from canvas
this.hudTexture = new THREE.CanvasTexture(this.hudCanvas)
this.hudTexture.minFilter = THREE.LinearFilter
this.hudTexture.magFilter = THREE.LinearFilter
// Create HUD geometry - a plane that will display our canvas
// Adjusted size for better VR viewing
const hudGeometry = new THREE.PlaneGeometry(3, 1.5)
const hudMaterial = new THREE.MeshBasicMaterial({
map: this.hudTexture,
transparent: true,
opacity: 0.8,
side: THREE.DoubleSide,
depthTest: false,
depthWrite: false
})
this.hudMesh = new THREE.Mesh(hudGeometry, hudMaterial)
this.hudMesh.renderOrder = 1000 // Render on top
// Create a group to hold the HUD
this.hudGroup = new THREE.Group()
this.hudGroup.add(this.hudMesh)
// Position the HUD in front of the camera
// Slightly lower and further for comfortable VR viewing
this.hudMesh.position.set(0, -0.3, -2.5)
// Initial render to show something
this.update()
}
attachToVRCamera(vrCameraGroup: THREE.Object3D) {
// Add HUD to the VR camera group so it follows the player's view
vrCameraGroup.add(this.hudGroup)
}
detachFromVRCamera(vrCameraGroup: THREE.Object3D) {
vrCameraGroup.remove(this.hudGroup)
}
update() {
// Get player data
const bot = (window as any).bot
const playerState = this.worldRenderer.playerState
// Clear canvas
this.hudContext.clearRect(0, 0, this.hudCanvas.width, this.hudCanvas.height)
// Set up text styling
this.hudContext.fillStyle = 'white'
this.hudContext.strokeStyle = 'black'
this.hudContext.lineWidth = 3
this.hudContext.font = 'bold 32px Arial'
this.hudContext.textAlign = 'left'
this.hudContext.textBaseline = 'top'
// Top left - FPS and Ping
const fps = Math.round(1000 / this.worldRenderer.renderTimeAvg) || 0
const ping = bot?._client?.latency || 0
this.drawText(`FPS: ${fps}`, 50, 50)
this.drawText(`Ping: ${ping}ms`, 50, 90)
// Top right - Velocity and Coords
this.hudContext.textAlign = 'right'
const velocity = playerState.getVelocity()
const position = playerState.getPosition()
const vel = Math.sqrt(velocity.x ** 2 + velocity.z ** 2).toFixed(2)
this.drawText(`Vel: ${vel} m/s`, this.hudCanvas.width - 50, 50)
this.drawText(`X: ${position.x.toFixed(1)}`, this.hudCanvas.width - 50, 90)
this.drawText(`Y: ${position.y.toFixed(1)}`, this.hudCanvas.width - 50, 130)
this.drawText(`Z: ${position.z.toFixed(1)}`, this.hudCanvas.width - 50, 170)
// Bottom left - Health
this.hudContext.textAlign = 'left'
this.hudContext.textBaseline = 'bottom'
const health = bot?.health || 10
const maxHealth = 20
const hearts = health / 2
const maxHearts = maxHealth / 2
this.drawText(`HP: ${hearts}/${maxHearts}`, 50, this.hudCanvas.height - 50)
// Bottom right - Game mode
this.hudContext.textAlign = 'right'
const gameMode = playerState.reactive.gameMode || 'survival'
this.drawText(`Mode: ${gameMode}`, this.hudCanvas.width - 50, this.hudCanvas.height - 50)
// Update texture
this.hudTexture.needsUpdate = true
}
private drawText(text: string, x: number, y: number) {
// Draw text with outline for better visibility
this.hudContext.strokeText(text, x, y)
this.hudContext.fillText(text, x, y)
}
setVisible(visible: boolean) {
this.hudMesh.visible = visible
}
dispose() {
this.hudTexture.dispose()
this.hudMesh.geometry.dispose()
;(this.hudMesh.material as THREE.Material).dispose()
this.hudCanvas.remove()
}
}

View file

@ -3,20 +3,21 @@ 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 { subscribeKey } from 'valtio/utils'
import { renderSign } from '../sign-renderer'
import { DisplayWorldOptions, GraphicsInitOptions } from '../../../src/appViewer'
import { DisplayWorldOptions, GraphicsInitOptions, RendererReactiveState } from '../../../src/appViewer'
import { chunkPos, sectionPos } from '../lib/simpleUtils'
import { WorldRendererCommon } from '../lib/worldrendererCommon'
import { addNewStat } from '../lib/ui/newStats'
import { addNewStat, removeAllStats } 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 { getMyHand } from './hand'
import { sendVideoPlay, sendVideoStop } from '../../../src/customChannels'
import HoldingBlock from './holdingBlock'
import { getMesh } from './entity/EntityMesh'
import { armorModel } from './entity/armorModels'
import { disposeObject, loadThreeJsTextureFromBitmap } from './threeJsUtils'
import { disposeObject } from './threeJsUtils'
import { CursorBlock } from './world/cursorBlock'
import { getItemUv } from './appShared'
import { Entities } from './entities'
@ -24,8 +25,6 @@ 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
@ -45,13 +44,11 @@ export class WorldRendererThree extends WorldRendererCommon {
cameraGroupVr?: THREE.Object3D
material = new THREE.MeshLambertMaterial({ vertexColors: true, transparent: true, alphaTest: 0.1 })
itemsTexture: THREE.Texture
cursorBlock: CursorBlock
cursorBlock = new CursorBlock(this)
onRender: Array<() => void> = []
cameraShake: CameraShake
cameraContainer: THREE.Object3D
media: ThreeJsMedia
waitingChunksToDisplay = {} as { [chunkKey: string]: SectionKey[] }
waypoints: WaypointsRenderer
camera: THREE.PerspectiveCamera
renderTimeAvg = 0
sectionsOffsetsAnimations = {} as {
@ -72,11 +69,6 @@ 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 }>
get tilesRendered () {
return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj as any).tilesCount, 0)
@ -90,17 +82,11 @@ export class WorldRendererThree extends WorldRendererCommon {
if (!initOptions.resourcesManager) throw new Error('resourcesManager is required')
super(initOptions.resourcesManager, displayOptions, initOptions)
this.renderer = renderer
displayOptions.rendererState.renderer = WorldRendererThree.getRendererInfo(renderer) ?? '...'
this.starField = new StarField(this)
this.cursorBlock = new CursorBlock(this)
this.starField = new StarField(this.scene)
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()
@ -108,8 +94,6 @@ 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),
// })
@ -121,7 +105,7 @@ export class WorldRendererThree extends WorldRendererCommon {
}
get cameraObject () {
return this.cameraGroupVr ?? this.cameraContainer
return this.cameraGroupVr || this.camera
}
worldSwitchActions () {
@ -130,8 +114,6 @@ export class WorldRendererThree extends WorldRendererCommon {
this.protocolCustomBlocks.clear()
// Reset section animations
this.sectionsOffsetsAnimations = {}
// Clear waypoints
this.waypoints.clear()
})
}
@ -152,10 +134,6 @@ export class WorldRendererThree extends WorldRendererCommon {
}
}
updatePlayerEntity (e: any) {
this.entities.handlePlayerEntity(e)
}
resetScene () {
this.scene.matrixAutoUpdate = false // for perf
this.scene.background = new THREE.Color(this.initOptions.config.sceneBackground)
@ -166,39 +144,27 @@ export class WorldRendererThree extends WorldRendererCommon {
const size = this.renderer.getSize(new THREE.Vector2())
this.camera = new THREE.PerspectiveCamera(75, size.x / size.y, 0.1, 1000)
this.cameraContainer = new THREE.Object3D()
this.cameraContainer.add(this.camera)
this.scene.add(this.cameraContainer)
}
override watchReactivePlayerState () {
super.watchReactivePlayerState()
this.onReactivePlayerStateUpdated('inWater', (value) => {
this.skyboxRenderer.updateWaterState(value, this.playerStateReactive.waterBreathing)
this.onReactiveValueUpdated('inWater', (value) => {
this.scene.fog = value ? new THREE.Fog(0x00_00_ff, 0.1, this.displayOptions.playerState.reactive.waterBreathing ? 100 : 20) : null
})
this.onReactivePlayerStateUpdated('waterBreathing', (value) => {
this.skyboxRenderer.updateWaterState(this.playerStateReactive.inWater, value)
})
this.onReactivePlayerStateUpdated('ambientLight', (value) => {
this.onReactiveValueUpdated('ambientLight', (value) => {
if (!value) return
this.ambientLight.intensity = value
})
this.onReactivePlayerStateUpdated('directionalLight', (value) => {
this.onReactiveValueUpdated('directionalLight', (value) => {
if (!value) return
this.directionalLight.intensity = value
})
this.onReactivePlayerStateUpdated('lookingAtBlock', (value) => {
this.onReactiveValueUpdated('lookingAtBlock', (value) => {
this.cursorBlock.setHighlightCursorBlock(value ? new Vec3(value.x, value.y, value.z) : null, value?.shapes)
})
this.onReactivePlayerStateUpdated('diggingBlock', (value) => {
this.onReactiveValueUpdated('diggingBlock', (value) => {
this.cursorBlock.updateBreakAnimation(value ? { x: value.x, y: value.y, z: value.z } : undefined, value?.stage ?? null, value?.mergedShape)
})
this.onReactivePlayerStateUpdated('perspective', (value) => {
// Update camera perspective when it changes
const vecPos = new Vec3(this.cameraObject.position.x, this.cameraObject.position.y, this.cameraObject.position.z)
this.updateCamera(vecPos, this.cameraShake.getBaseRotation().yaw, this.cameraShake.getBaseRotation().pitch)
// todo also update camera when block within camera was changed
})
}
override watchReactiveConfig () {
@ -206,9 +172,6 @@ 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) {
@ -221,18 +184,20 @@ export class WorldRendererThree extends WorldRendererCommon {
}
async updateAssetsData (): Promise<void> {
const resources = this.resourcesManager.currentResources
const resources = this.resourcesManager.currentResources!
const oldTexture = this.material.map
const oldItemsTexture = this.itemsTexture
const texture = loadThreeJsTextureFromBitmap(resources.blocksAtlasImage)
texture.needsUpdate = true
const texture = await new THREE.TextureLoader().loadAsync(resources.blocksAtlasParser.latestImage)
texture.magFilter = THREE.NearestFilter
texture.minFilter = THREE.NearestFilter
texture.flipY = false
this.material.map = texture
const itemsTexture = loadThreeJsTextureFromBitmap(resources.itemsAtlasImage)
itemsTexture.needsUpdate = true
const itemsTexture = await new THREE.TextureLoader().loadAsync(resources.itemsAtlasParser.latestImage)
itemsTexture.magFilter = THREE.NearestFilter
itemsTexture.minFilter = THREE.NearestFilter
itemsTexture.flipY = false
this.itemsTexture = itemsTexture
@ -271,23 +236,10 @@ 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) {
return getItemUv(item, specificProps, this.resourcesManager, this.playerStateReactive)
return getItemUv(item, specificProps, this.resourcesManager)
}
async demoModel () {
@ -349,11 +301,10 @@ export class WorldRendererThree extends WorldRendererCommon {
section.renderOrder = 500 - chunkDistance
}
override updateViewerPosition (pos: Vec3): void {
this.viewerChunkPosition = pos
}
cameraSectionPositionUpdate () {
updateViewerPosition (pos: Vec3): void {
this.viewerPosition = pos
const cameraPos = this.cameraObject.position.toArray().map(x => Math.floor(x / 16)) as [number, number, number]
this.cameraSectionPos = new Vec3(...cameraPos)
// eslint-disable-next-line guard-for-in
for (const key in this.sectionObjects) {
const value = this.sectionObjects[key]
@ -457,7 +408,7 @@ export class WorldRendererThree extends WorldRendererCommon {
this.scene.add(object)
}
getSignTexture (position: Vec3, blockEntity, isHanging, backSide = false) {
getSignTexture (position: Vec3, blockEntity, backSide = false) {
const chunk = chunkPos(position)
let textures = this.chunkTextures.get(`${chunk[0]},${chunk[1]}`)
if (!textures) {
@ -469,7 +420,7 @@ export class WorldRendererThree extends WorldRendererCommon {
if (textures[texturekey]) return textures[texturekey]
const PrismarineChat = PrismarineChatLoader(this.version)
const canvas = renderSign(blockEntity, isHanging, PrismarineChat)
const canvas = renderSign(blockEntity, PrismarineChat)
if (!canvas) return
const tex = new THREE.Texture(canvas)
tex.magFilter = THREE.NearestFilter
@ -479,149 +430,13 @@ export class WorldRendererThree extends WorldRendererCommon {
return tex
}
getCameraPosition () {
const worldPos = new THREE.Vector3()
this.camera.getWorldPosition(worldPos)
return worldPos
}
getSectionCameraPosition () {
const pos = this.getCameraPosition()
return new Vec3(
Math.floor(pos.x / 16),
Math.floor(pos.y / 16),
Math.floor(pos.z / 16)
)
}
updateCameraSectionPos () {
const newSectionPos = this.getSectionCameraPosition()
if (!this.cameraSectionPos.equals(newSectionPos)) {
this.cameraSectionPos = newSectionPos
this.cameraSectionPositionUpdate()
}
}
setFirstPersonCamera (pos: Vec3 | null, yaw: number, pitch: number) {
const yOffset = this.playerStateReactive.eyeHeight
const yOffset = this.displayOptions.playerState.getEyeHeight()
this.updateCamera(pos?.offset(0, yOffset, 0) ?? null, yaw, pitch)
this.media.tryIntersectMedia()
this.updateCameraSectionPos()
}
getThirdPersonCamera (pos: THREE.Vector3 | null, yaw: number, pitch: number) {
pos ??= this.cameraObject.position
// Calculate camera offset based on perspective
const isBack = this.playerStateReactive.perspective === 'third_person_back'
const distance = 4 // Default third person distance
// Calculate direction vector using proper world orientation
// We need to get the camera's current look direction and use that for positioning
// Create a direction vector that represents where the camera is looking
// This matches the Three.js camera coordinate system
const direction = new THREE.Vector3(0, 0, -1) // Forward direction in camera space
// Apply the same rotation that's applied to the camera container
const pitchQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), pitch)
const yawQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), yaw)
const finalQuat = new THREE.Quaternion().multiplyQuaternions(yawQuat, pitchQuat)
// Transform the direction vector by the camera's rotation
direction.applyQuaternion(finalQuat)
// For back view, we want the camera behind the player (opposite to view direction)
// For front view, we want the camera in front of the player (same as view direction)
if (isBack) {
direction.multiplyScalar(-1)
}
// Create debug visualization if advanced stats are enabled
if (this.DEBUG_RAYCAST) {
this.debugRaycast(pos, direction, distance)
}
// Perform raycast to avoid camera going through blocks
const raycaster = new THREE.Raycaster()
raycaster.set(pos, direction)
raycaster.far = distance // Limit raycast distance
// Filter to only nearby chunks for performance
const nearbyChunks = Object.values(this.sectionObjects)
.filter(obj => obj.name === 'chunk' && obj.visible)
.filter(obj => {
// Get the mesh child which has the actual geometry
const mesh = obj.children.find(child => child.name === 'mesh')
if (!mesh) return false
// Check distance from player position to chunk
const chunkWorldPos = new THREE.Vector3()
mesh.getWorldPosition(chunkWorldPos)
const distance = pos.distanceTo(chunkWorldPos)
return distance < 80 // Only check chunks within 80 blocks
})
// Get all mesh children for raycasting
const meshes: THREE.Object3D[] = []
for (const chunk of nearbyChunks) {
const mesh = chunk.children.find(child => child.name === 'mesh')
if (mesh) meshes.push(mesh)
}
const intersects = raycaster.intersectObjects(meshes, false)
let finalDistance = distance
if (intersects.length > 0) {
// Use intersection distance minus a small offset to prevent clipping
finalDistance = Math.max(0.5, intersects[0].distance - 0.2)
}
const finalPos = new Vec3(
pos.x + direction.x * finalDistance,
pos.y + direction.y * finalDistance,
pos.z + direction.z * finalDistance
)
return finalPos
}
private debugRaycastHelper?: THREE.ArrowHelper
private debugHitPoint?: THREE.Mesh
private debugRaycast (pos: THREE.Vector3, direction: THREE.Vector3, distance: number) {
// Remove existing debug objects
if (this.debugRaycastHelper) {
this.scene.remove(this.debugRaycastHelper)
this.debugRaycastHelper = undefined
}
if (this.debugHitPoint) {
this.scene.remove(this.debugHitPoint)
this.debugHitPoint = undefined
}
// Create raycast arrow
this.debugRaycastHelper = new THREE.ArrowHelper(
direction.clone().normalize(),
pos,
distance,
0xff_00_00, // Red color
distance * 0.1,
distance * 0.05
)
this.scene.add(this.debugRaycastHelper)
// Create hit point indicator
const hitGeometry = new THREE.SphereGeometry(0.2, 8, 8)
const hitMaterial = new THREE.MeshBasicMaterial({ color: 0x00_ff_00 })
this.debugHitPoint = new THREE.Mesh(hitGeometry, hitMaterial)
this.debugHitPoint.position.copy(pos).add(direction.clone().multiplyScalar(distance))
this.scene.add(this.debugHitPoint)
}
prevFramePerspective = null as string | null
updateCamera (pos: Vec3 | null, yaw: number, pitch: number): void {
// if (this.freeFlyMode) {
// pos = this.freeFlyState.position
@ -634,133 +449,32 @@ export class WorldRendererThree extends WorldRendererCommon {
pos.y -= this.camera.position.y // Fix Y position of camera in world
}
this.currentPosTween?.stop()
this.currentPosTween = new tweenJs.Tween(this.cameraObject.position).to({ x: pos.x, y: pos.y, z: pos.z }, this.playerStateUtils.isSpectatingEntity() ? 150 : 50).start()
new tweenJs.Tween(this.cameraObject.position).to({ x: pos.x, y: pos.y, z: pos.z }, 50).start()
// this.freeFlyState.position = pos
}
if (this.playerStateUtils.isSpectatingEntity()) {
const rotation = this.cameraShake.getBaseRotation()
// wrap in the correct direction
let yawOffset = 0
const halfPi = Math.PI / 2
if (rotation.yaw < halfPi && yaw > Math.PI + halfPi) {
yawOffset = -Math.PI * 2
} else if (yaw < halfPi && rotation.yaw > Math.PI + halfPi) {
yawOffset = Math.PI * 2
}
this.currentRotTween?.stop()
this.currentRotTween = new tweenJs.Tween(rotation).to({ pitch, yaw: yaw + yawOffset }, 100)
.onUpdate(params => this.cameraShake.setBaseRotation(params.pitch, params.yaw - yawOffset)).start()
} else {
this.currentRotTween?.stop()
this.cameraShake.setBaseRotation(pitch, yaw)
const { perspective } = this.playerStateReactive
if (perspective === 'third_person_back' || perspective === 'third_person_front') {
// Use getThirdPersonCamera for proper raycasting with max distance of 4
const currentCameraPos = this.cameraObject.position
const thirdPersonPos = this.getThirdPersonCamera(
new THREE.Vector3(currentCameraPos.x, currentCameraPos.y, currentCameraPos.z),
yaw,
pitch
)
const distance = currentCameraPos.distanceTo(new THREE.Vector3(thirdPersonPos.x, thirdPersonPos.y, thirdPersonPos.z))
// Apply Z offset based on perspective and calculated distance
const zOffset = perspective === 'third_person_back' ? distance : -distance
this.camera.position.set(0, 0, zOffset)
if (perspective === 'third_person_front') {
// Flip camera view 180 degrees around Y axis for front view
this.camera.rotation.set(0, Math.PI, 0)
} else {
this.camera.rotation.set(0, 0, 0)
}
} else {
this.camera.position.set(0, 0, 0)
this.camera.rotation.set(0, 0, 0)
// remove any debug raycasting
if (this.debugRaycastHelper) {
this.scene.remove(this.debugRaycastHelper)
this.debugRaycastHelper = undefined
}
if (this.debugHitPoint) {
this.scene.remove(this.debugHitPoint)
this.debugHitPoint = undefined
}
}
}
this.updateCameraSectionPos()
}
debugChunksVisibilityOverride () {
const { chunksRenderAboveOverride, chunksRenderBelowOverride, chunksRenderDistanceOverride, chunksRenderAboveEnabled, chunksRenderBelowEnabled, chunksRenderDistanceEnabled } = this.reactiveDebugParams
const baseY = this.cameraSectionPos.y * 16
if (
this.displayOptions.inWorldRenderingConfig.enableDebugOverlay &&
chunksRenderAboveOverride !== undefined ||
chunksRenderBelowOverride !== undefined ||
chunksRenderDistanceOverride !== undefined
) {
for (const [key, object] of Object.entries(this.sectionObjects)) {
const [x, y, z] = key.split(',').map(Number)
const isVisible =
// eslint-disable-next-line no-constant-binary-expression, sonarjs/no-redundant-boolean
(chunksRenderAboveEnabled && chunksRenderAboveOverride !== undefined) ? y <= (baseY + chunksRenderAboveOverride) : true &&
// eslint-disable-next-line @stylistic/indent-binary-ops, no-constant-binary-expression, sonarjs/no-redundant-boolean
(chunksRenderBelowEnabled && chunksRenderBelowOverride !== undefined) ? y >= (baseY - chunksRenderBelowOverride) : true &&
// eslint-disable-next-line @stylistic/indent-binary-ops
(chunksRenderDistanceEnabled && chunksRenderDistanceOverride !== undefined) ? Math.abs(y - baseY) <= chunksRenderDistanceOverride : true
object.visible = isVisible
}
} else {
for (const object of Object.values(this.sectionObjects)) {
object.visible = true
}
}
this.cameraShake.setBaseRotation(pitch, yaw)
}
render (sizeChanged = false) {
if (this.reactiveDebugParams.stopRendering) return
this.debugChunksVisibilityOverride()
const start = performance.now()
this.lastRendered = performance.now()
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())
this.camera.aspect = size.width / size.height
this.camera.aspect = window.innerWidth / window.innerHeight
this.camera.fov = this.displayOptions.inWorldRenderingConfig.fov
this.camera.updateProjectionMatrix()
}
if (!this.reactiveDebugParams.disableEntities) {
this.entities.render()
}
this.entities.render()
// eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
const cam = this.cameraGroupVr instanceof THREE.Group ? this.cameraGroupVr.children.find(child => child instanceof THREE.PerspectiveCamera) as THREE.PerspectiveCamera : this.camera
this.renderer.render(this.scene, cam)
if (
this.displayOptions.inWorldRenderingConfig.showHand &&
this.playerStateReactive.gameMode !== 'spectator' &&
this.playerStateReactive.perspective === 'first_person' &&
// !this.freeFlyMode &&
!this.renderer.xr.isPresenting
) {
if (this.displayOptions.inWorldRenderingConfig.showHand && !this.playerState.shouldHideHand /* && !this.freeFlyMode */ && !this.renderer.xr.isPresenting) {
this.holdingBlock.render(this.camera, this.renderer, this.ambientLight, this.directionalLight)
this.holdingBlockLeft.render(this.camera, this.renderer, this.ambientLight, this.directionalLight)
}
@ -773,8 +487,6 @@ export class WorldRendererThree extends WorldRendererCommon {
fountain.render()
}
this.waypoints.render()
for (const onRender of this.onRender) {
onRender()
}
@ -787,22 +499,12 @@ export class WorldRendererThree extends WorldRendererCommon {
}
renderHead (position: Vec3, rotation: number, isWall: boolean, blockEntity) {
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
const textures = blockEntity.SkullOwner?.Properties?.textures[0]
if (!textures) return
try {
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)
.replace('https://textures.minecraft.net/', skinTexturesProxy)
}
const textureData = JSON.parse(Buffer.from(textures.Value, 'base64').toString())
const skinUrl = textureData.textures?.SKIN?.url
const mesh = getMesh(this, skinUrl, armorModel.head)
const group = new THREE.Group()
@ -826,7 +528,7 @@ export class WorldRendererThree extends WorldRendererCommon {
}
renderSign (position: Vec3, rotation: number, isWall: boolean, isHanging: boolean, blockEntity) {
const tex = this.getSignTexture(position, blockEntity, isHanging)
const tex = this.getSignTexture(position, blockEntity)
if (!tex) return
@ -897,16 +599,6 @@ export class WorldRendererThree extends WorldRendererCommon {
for (const mesh of Object.values(this.sectionObjects)) {
this.scene.remove(mesh)
}
// Clean up debug objects
if (this.debugRaycastHelper) {
this.scene.remove(this.debugRaycastHelper)
this.debugRaycastHelper = undefined
}
if (this.debugHitPoint) {
this.scene.remove(this.debugHitPoint)
this.debugHitPoint = undefined
}
}
getLoadedChunksRelative (pos: Vec3, includeY = false) {
@ -982,19 +674,6 @@ export class WorldRendererThree extends WorldRendererCommon {
destroy (): void {
super.destroy()
this.skyboxRenderer.dispose()
}
shouldObjectVisible (object: THREE.Object3D) {
// Get chunk coordinates
const chunkX = Math.floor(object.position.x / 16) * 16
const chunkZ = Math.floor(object.position.z / 16) * 16
const sectionY = Math.floor(object.position.y / 16) * 16
const chunkKey = `${chunkX},${chunkZ}`
const sectionKey = `${chunkX},${sectionY},${chunkZ}`
return !!this.finishedChunks[chunkKey] || !!this.sectionObjects[sectionKey]
}
updateSectionOffsets () {
@ -1043,10 +722,6 @@ export class WorldRendererThree extends WorldRendererCommon {
}
}
}
reloadWorld () {
this.entities.reloadEntities()
}
}
class StarField {
@ -1063,16 +738,7 @@ 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
})
constructor (private readonly scene: THREE.Scene) {
}
addToScene () {
@ -1083,6 +749,7 @@ class StarField {
const count = 7000
const factor = 7
const saturation = 10
const speed = 0.2
const geometry = new THREE.BufferGeometry()
@ -1113,8 +780,13 @@ class StarField {
// Create points and add them to the scene
this.points = new THREE.Points(geometry, material)
this.worldRenderer.scene.add(this.points)
this.scene.add(this.points)
const clock = new THREE.Clock()
this.points.onBeforeRender = (renderer, scene, camera) => {
this.points?.position.copy?.(camera.position)
material.uniforms.time.value = clock.getElapsedTime() * speed
}
this.points.renderOrder = -1
}
@ -1122,7 +794,7 @@ class StarField {
if (this.points) {
this.points.geometry.dispose();
(this.points.material as THREE.Material).dispose()
this.worldRenderer.scene.remove(this.points)
this.scene.remove(this.points)
this.points = undefined
}

View file

@ -1,4 +1,3 @@
/// <reference types="./src/env" />
import { defineConfig, mergeRsbuildConfig, RsbuildPluginAPI } from '@rsbuild/core'
import { pluginReact } from '@rsbuild/plugin-react'
import { pluginTypedCSSModules } from '@rsbuild/plugin-typed-css-modules'
@ -15,7 +14,6 @@ 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'
@ -50,7 +48,7 @@ if (fs.existsSync('./assets/release.json')) {
const configJson = JSON.parse(fs.readFileSync('./config.json', 'utf8'))
try {
Object.assign(configJson, JSON.parse(fs.readFileSync(process.env.LOCAL_CONFIG_FILE || './config.local.json', 'utf8')))
Object.assign(configJson, JSON.parse(fs.readFileSync('./config.local.json', 'utf8')))
} catch (err) {}
if (dev) {
configJson.defaultProxy = ':8080'
@ -60,8 +58,6 @@ 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: {
@ -115,22 +111,6 @@ const appConfig = defineConfig({
js: 'source-map',
css: true,
},
minify: {
// js: false,
jsOptions: {
minimizerOptions: {
mangle: {
safari10: true,
keep_classnames: true,
keep_fnames: true,
keep_private_props: true,
},
compress: {
unused: true,
},
},
},
},
distPath: SINGLE_FILE_BUILD ? {
html: './single',
} : undefined,
@ -139,13 +119,6 @@ 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',
@ -161,15 +134,12 @@ 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.ALWAYS_MINIMAL_SERVER_UI': JSON.stringify(process.env.ALWAYS_MINIMAL_SERVER_UI),
'process.env.DEPS_VERSIONS': JSON.stringify({}),
'process.env.RELEASE_TAG': JSON.stringify(releaseTag),
'process.env.RELEASE_LINK': JSON.stringify(releaseLink),
'process.env.RELEASE_CHANGELOG': JSON.stringify(releaseChangelog),
'process.env.DISABLE_SERVICE_WORKER': JSON.stringify(disableServiceWorker),
'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: {
@ -197,21 +167,20 @@ 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 || process.env.ALWAYS_COMPRESS_LARGE_DATA === 'true')
genLargeDataAliases(SINGLE_FILE_BUILD)
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')
fs.copyFileSync('./assets/playground.html', './dist/playground.html')
fs.copyFileSync('./assets/manifest.json', './dist/manifest.json')
fs.copyFileSync('./assets/config.html', './dist/config.html')
fs.copyFileSync('./assets/debug-inputs.html', './dist/debug-inputs.html')
fs.copyFileSync('./assets/loading-bg.jpg', './dist/loading-bg.jpg')
if (fs.existsSync('./assets/release.json')) {
fs.copyFileSync('./assets/release.json', './dist/release.json')
}
if (configSource === 'REMOTE') {
fs.writeFileSync('./dist/config.json', JSON.stringify(configJson, undefined, 2), 'utf8')
fs.writeFileSync('./dist/config.json', JSON.stringify(configJson), 'utf8')
}
if (fs.existsSync('./generated/sounds.js')) {
fs.copyFileSync('./generated/sounds.js', './dist/sounds.js')
@ -227,12 +196,6 @@ 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) {
@ -240,10 +203,6 @@ 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,8 +16,7 @@ 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)) {
const chunkName = module === 'mcData' ? 'mc-data' : 'mc-assets';
let importCode = `(await import(/* webpackChunkName: "${chunkName}" */ '${isCompressed ? compressed : raw}')).default`;
let importCode = `(await import('${isCompressed ? compressed : raw}')).default`;
if (isCompressed) {
importCode = `JSON.parse(decompressFromBase64(${importCode}))`
}
@ -31,8 +30,6 @@ 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}`
}
let versions = {}
const versions = {}
const dataTypes = new Set()
for (const [version, dataSet] of Object.entries(dataPaths.pc)) {
@ -42,31 +42,6 @@ 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
@ -82,27 +57,22 @@ const dataTypeBundling2 = {
}
}
const dataTypeBundling = {
language: process.env.SKIP_MC_DATA_LANGUAGE === 'true' ? {
raw: {}
} : {
language: {
ignoreRemoved: true,
ignoreChanges: true
},
blocks: {
arrKey: 'name',
processData(current, prev, _, version) {
processData (current, prev) {
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,
@ -166,9 +136,7 @@ const dataTypeBundling = {
blockLoot: {
arrKey: 'block'
},
recipes: process.env.SKIP_MC_DATA_RECIPES === 'true' ? {
raw: {}
} : {
recipes: {
raw: true
// processData: processRecipes
},
@ -182,7 +150,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')
@ -274,39 +242,30 @@ for (const [i, [version, dataSet]] of versionsArr.reverse().entries()) {
for (const [dataType, dataPath] of Object.entries(dataSet)) {
const config = dataTypeBundling[dataType]
if (!config) continue
const ignoreCollisionShapes = dataType === 'blockCollisionShapes' && versionToNumber(version) >= versionToNumber('1.13')
if (dataType === 'blockCollisionShapes' && versionToNumber(version) >= versionToNumber('1.13')) {
// contents += ` get ${dataType} () { return window.globalGetCollisionShapes?.("${version}") },\n`
continue
}
let injectCode = ''
const getRealData = (type) => {
const getData = (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 = getRealData(dataType)
const dataRaw = getData(dataType)
let rawData = dataRaw
if (config.raw) {
rawDataVersions[dataType] ??= {}
rawDataVersions[dataType][version] = rawData
if (config.raw === true) {
rawData = dataRaw
} else {
rawData = config.raw
}
if (ignoreCollisionShapes && dataType === 'blockCollisionShapes') {
rawData = {
blocks: {},
shapes: {}
}
}
rawData = dataRaw
} else {
if (!diffSources[dataType]) {
diffSources[dataType] = new JsonOptimizer(config.arrKey, config.ignoreChanges, config.ignoreRemoved)
}
try {
config.processData?.(dataRaw, previousData[dataType], getRealData, version)
config.processData?.(dataRaw, previousData[dataType], getData, version)
diffSources[dataType].recordDiff(version, dataRaw)
injectCode = `restoreDiff(sources, ${JSON.stringify(dataType)}, ${JSON.stringify(version)})`
} catch (err) {
@ -338,16 +297,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'
@ -371,7 +330,6 @@ 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,

View file

@ -1,137 +0,0 @@
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()

View file

@ -1,42 +0,0 @@
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)

View file

@ -1,160 +0,0 @@
import fs from 'fs'
import path from 'path'
import yaml from 'yaml'
import { execSync } from 'child_process'
import { createInterface } from 'readline'
interface LockfilePackage {
specifier: string
version: string
}
interface Lockfile {
importers: {
'.': {
dependencies?: Record<string, LockfilePackage>
devDependencies?: Record<string, LockfilePackage>
}
}
}
interface PackageJson {
pnpm?: {
updateConfig?: {
ignoreDependencies?: string[]
}
}
}
async function prompt(question: string): Promise<string> {
const rl = createInterface({
input: process.stdin,
output: process.stdout
})
return new Promise(resolve => {
rl.question(question, answer => {
rl.close()
resolve(answer.toLowerCase().trim())
})
})
}
async function getLatestCommit(owner: string, repo: string): Promise<string> {
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/commits/HEAD`)
if (!response.ok) {
throw new Error(`Failed to fetch latest commit: ${response.statusText}`)
}
const data = await response.json()
return data.sha
}
function extractGitInfo(specifier: string): { owner: string; repo: string; branch: string } | null {
const match = specifier.match(/github:([^/]+)\/([^#]+)(?:#(.+))?/)
if (!match) return null
return {
owner: match[1],
repo: match[2],
branch: match[3] || 'master'
}
}
function extractCommitHash(version: string): string | null {
const match = version.match(/https:\/\/codeload\.github\.com\/[^/]+\/[^/]+\/tar\.gz\/([a-f0-9]+)/)
return match ? match[1] : null
}
function getIgnoredDependencies(): string[] {
try {
const packageJsonPath = path.join(process.cwd(), 'package.json')
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as PackageJson
return packageJson.pnpm?.updateConfig?.ignoreDependencies || []
} catch (error) {
console.warn('Failed to read package.json for ignored dependencies:', error)
return []
}
}
async function main() {
const lockfilePath = path.join(process.cwd(), 'pnpm-lock.yaml')
const lockfileContent = fs.readFileSync(lockfilePath, 'utf8')
const lockfile = yaml.parse(lockfileContent) as Lockfile
const ignoredDependencies = new Set(getIgnoredDependencies())
console.log('Ignoring dependencies:', Array.from(ignoredDependencies).join(', ') || 'none')
const dependencies = {
...lockfile.importers['.'].dependencies,
...lockfile.importers['.'].devDependencies
}
const updates: Array<{
name: string
currentHash: string
latestHash: string
gitInfo: ReturnType<typeof extractGitInfo>
}> = []
console.log('\nChecking git dependencies...')
for (const [name, pkg] of Object.entries(dependencies)) {
if (ignoredDependencies.has(name)) {
console.log(`Skipping ignored dependency: ${name}`)
continue
}
if (!pkg.specifier.startsWith('github:')) continue
const gitInfo = extractGitInfo(pkg.specifier)
if (!gitInfo) continue
const currentHash = extractCommitHash(pkg.version)
if (!currentHash) continue
try {
process.stdout.write(`Checking ${name}... `)
const latestHash = await getLatestCommit(gitInfo.owner, gitInfo.repo)
if (currentHash !== latestHash) {
console.log('update available')
updates.push({ name, currentHash, latestHash, gitInfo })
} else {
console.log('up to date')
}
} catch (error) {
console.log('failed')
console.error(`Error checking ${name}:`, error)
}
}
if (updates.length === 0) {
console.log('\nAll git dependencies are up to date!')
return
}
console.log('\nThe following git dependencies can be updated:')
for (const update of updates) {
console.log(`\n${update.name}:`)
console.log(` Current: ${update.currentHash}`)
console.log(` Latest: ${update.latestHash}`)
console.log(` Repo: ${update.gitInfo!.owner}/${update.gitInfo!.repo}`)
}
const answer = await prompt('\nWould you like to update these dependencies? (y/N): ')
if (answer === 'y' || answer === 'yes') {
let newLockfileContent = lockfileContent
for (const update of updates) {
newLockfileContent = newLockfileContent.replace(
new RegExp(update.currentHash, 'g'),
update.latestHash
)
}
fs.writeFileSync(lockfilePath, newLockfileContent)
console.log('\nUpdated pnpm-lock.yaml with new commit hashes')
// console.log('Running pnpm install to apply changes...')
// execSync('pnpm install', { stdio: 'inherit' })
console.log('Done!')
} else {
console.log('\nNo changes were made.')
}
}
main().catch(console.error)

View file

@ -1,45 +0,0 @@
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,7 +24,6 @@ export type MobileButtonConfig = {
readonly icon?: string
readonly action?: ActionType
readonly actionHold?: ActionType | ActionHoldConfig
readonly iconStyle?: React.CSSProperties
}
export type AppConfig = {
@ -35,7 +34,7 @@ export type AppConfig = {
// defaultVersion?: string
peerJsServer?: string
peerJsServerFallback?: string
promoteServers?: Array<{ ip, description, name?, version?, }>
promoteServers?: Array<{ ip, description, version? }>
mapsProvider?: string
appParams?: Record<string, any> // query string params
@ -54,15 +53,9 @@ export type AppConfig = {
displayLanguageSelector?: boolean
supportedLanguages?: string[]
showModsButton?: boolean
defaultUsername?: string
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 {
@ -74,7 +67,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 in appConfig.defaultSettings && !qsOptions[key]) {
if (appConfig.defaultSettings?.[key] && !qsOptions[key]) {
options[key] = appConfig.defaultSettings[key]
}
} else {
@ -82,16 +75,13 @@ 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))
updateBinds(customKeymaps)
}
appViewer?.appConfigUdpate()
setStorageDataOnAppConfigLoad(appConfig)
setStorageDataOnAppConfigLoad()
}
export const isBundledConfigUsed = !!process.env.INLINED_APP_CONFIG

View file

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

View file

@ -1,10 +1,8 @@
import { resetStateAfterDisconnect } from './browserfs'
import { hideModal, activeModalStack, showModal, miscUiState } from './globalState'
import { appStatusState, resetAppStatusState } from './react/AppStatusProvider'
let ourLastStatus: string | undefined = ''
export const setLoadingScreenStatus = function (status: string | undefined | null, isError = false, hideDots = false, fromFlyingSquid = false, minecraftJsonMessage?: Record<string, any>) {
if (typeof status === 'string') status = window.translateText?.(status) ?? status
// null can come from flying squid, should restore our last status
if (status === null) {
status = ourLastStatus
@ -26,6 +24,7 @@ export const setLoadingScreenStatus = function (status: string | undefined | nul
}
showModal({ reactType: 'app-status' })
if (appStatusState.isError) {
miscUiState.gameLoaded = false
return
}
appStatusState.hideDots = hideDots
@ -33,9 +32,5 @@ export const setLoadingScreenStatus = function (status: string | undefined | nul
appStatusState.lastStatus = isError ? appStatusState.status : ''
appStatusState.status = status
appStatusState.minecraftJsonMessage = minecraftJsonMessage ?? null
if (isError && miscUiState.gameLoaded) {
resetStateAfterDisconnect()
}
}
globalThis.setLoadingScreenStatus = setLoadingScreenStatus

View file

@ -1,30 +1,25 @@
import { WorldDataEmitter, WorldDataEmitterWorker } from 'renderer/viewer/lib/worldDataEmitter'
import { getInitialPlayerState, PlayerStateRenderer, PlayerStateReactive } from 'renderer/viewer/lib/basePlayerState'
import { WorldDataEmitter } from 'renderer/viewer/lib/worldDataEmitter'
import { BasePlayerState, IPlayerState } from 'renderer/viewer/lib/basePlayerState'
import { subscribeKey } from 'valtio/utils'
import { defaultWorldRendererConfig, WorldRendererConfig } from 'renderer/viewer/lib/worldrendererCommon'
import { Vec3 } from 'vec3'
import { SoundSystem } from 'renderer/viewer/three/threeJsSound'
import { proxy, subscribe } from 'valtio'
import { proxy } 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'
import { activeModalStack, miscUiState } from './globalState'
import { options } from './optionsStorage'
import { ResourcesManager, ResourcesManagerTransferred } from './resourcesManager'
import { ResourcesManager } from './resourcesManager'
import { watchOptionsAfterWorldViewInit } from './watchOptions'
import { loadMinecraftData } from './connect'
import { reloadChunks } from './utils'
import { displayClientChat } from './botUtils'
export interface RendererReactiveState {
world: {
chunksLoaded: Set<string>
// chunksTotalNumber: number
heightmaps: Map<string, Uint8Array>
chunksTotalNumber: number
allChunksLoaded: boolean
mesherWork: boolean
intersectMedia: { id: string, x: number, y: number } | null
@ -36,6 +31,9 @@ export interface NonReactiveState {
world: {
chunksLoaded: Set<string>
chunksTotalNumber: number
allChunksLoaded: boolean
mesherWork: boolean
intersectMedia: { id: string, x: number, y: number } | null
}
}
@ -44,39 +42,33 @@ export interface GraphicsBackendConfig {
powerPreference?: 'high-performance' | 'low-power'
statsVisible?: number
sceneBackground: string
timeoutRendering?: boolean
}
const defaultGraphicsBackendConfig: GraphicsBackendConfig = {
fpsLimit: undefined,
powerPreference: undefined,
sceneBackground: 'lightblue',
timeoutRendering: false
sceneBackground: 'lightblue'
}
export interface GraphicsInitOptions<S = any> {
resourcesManager: ResourcesManagerTransferred
resourcesManager: ResourcesManager
config: GraphicsBackendConfig
rendererSpecificSettings: S
callbacks: {
displayCriticalError: (error: Error) => void
setRendererSpecificSettings: (key: string, value: any) => void
fireCustomEvent: (eventName: string, ...args: any[]) => void
}
displayCriticalError: (error: Error) => void
setRendererSpecificSettings: (key: string, value: any) => void
}
export interface DisplayWorldOptions {
version: string
worldView: WorldDataEmitterWorker
worldView: WorldDataEmitter
inWorldRenderingConfig: WorldRendererConfig
playerStateReactive: PlayerStateReactive
playerState: IPlayerState
rendererState: RendererReactiveState
nonReactiveState: NonReactiveState
}
export type GraphicsBackendLoader = ((options: GraphicsInitOptions) => MaybePromise<GraphicsBackend>) & {
export type GraphicsBackendLoader = ((options: GraphicsInitOptions) => GraphicsBackend) & {
id: string
}
@ -116,8 +108,8 @@ export class AppViewer {
inWorldRenderingConfig: WorldRendererConfig = proxy(defaultWorldRendererConfig)
lastCamUpdate = 0
playerState = playerState
rendererState = getDefaultRendererState().reactive
nonReactiveState: NonReactiveState = getDefaultRendererState().nonReactive
rendererState = proxy(getDefaultRendererState())
nonReactiveState: NonReactiveState = getDefaultRendererState()
worldReady: Promise<void>
private resolveWorldReady: () => void
@ -141,24 +133,19 @@ export class AppViewer {
rendererSpecificSettings[key.slice(rendererSettingsKey.length + 1)] = options[key]
}
}
const loaderOptions: GraphicsInitOptions = { // todo!
resourcesManager: this.resourcesManager as ResourcesManagerTransferred,
const loaderOptions: GraphicsInitOptions = {
resourcesManager: this.resourcesManager,
config: this.config,
callbacks: {
displayCriticalError (error) {
console.error(error)
setLoadingScreenStatus(error.message, true)
},
setRendererSpecificSettings (key: string, value: any) {
options[`${rendererSettingsKey}.${key}`] = value
},
fireCustomEvent (eventName, ...args) {
// this.callbacks.fireCustomEvent(eventName, ...args)
}
displayCriticalError (error) {
console.error(error)
setLoadingScreenStatus(error.message, true)
},
rendererSpecificSettings,
setRendererSpecificSettings (key: string, value: any) {
options[`${rendererSettingsKey}.${key}`] = value
}
}
this.backend = await loader(loaderOptions)
this.backend = loader(loaderOptions)
// if (this.resourcesManager.currentResources) {
// void this.prepareResources(this.resourcesManager.currentResources.version, createNotificationProgressReporter())
@ -166,20 +153,12 @@ export class AppViewer {
// Execute queued action if exists
if (this.currentState) {
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())
}
const { method, args } = this.currentState
this.backend[method](...args)
if (method === 'startWorld') {
// void this.worldView!.init(args[0].playerState.getPosition())
}
}
// todo
modalStackUpdateChecks()
}
async startWithBot () {
@ -188,33 +167,19 @@ export class AppViewer {
this.worldView!.listenToBot(bot)
}
appConfigUdpate () {
if (miscUiState.appConfig) {
this.inWorldRenderingConfig.skinTexturesProxy = miscUiState.appConfig.skinTexturesProxy
}
}
async startWorld (world, renderDistance: number, playerStateSend: PlayerStateRenderer = this.playerState.reactive) {
async startWorld (world, renderDistance: number, playerStateSend: IPlayerState = this.playerState) {
if (this.currentDisplay === 'world') throw new Error('World already started')
this.currentDisplay = 'world'
const startPosition = bot.entity?.position ?? new Vec3(0, 64, 0)
const startPosition = playerStateSend.getPosition()
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()
const displayWorldOptions: DisplayWorldOptions = {
version: this.resourcesManager.currentConfig!.version,
worldView: this.worldView,
inWorldRenderingConfig: this.inWorldRenderingConfig,
playerStateReactive: playerStateSend,
playerState: playerStateSend,
rendererState: this.rendererState,
nonReactiveState: this.nonReactiveState
}
@ -240,16 +205,10 @@ export class AppViewer {
startPanorama () {
if (this.currentDisplay === 'menu') return
this.currentDisplay = 'menu'
if (options.disableAssets) return
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()
}
if (this.backend) {
this.backend.startPanorama()
}
this.currentState = { method: 'startPanorama', args: [] }
}
@ -279,8 +238,7 @@ export class AppViewer {
const { promise, resolve } = Promise.withResolvers<void>()
this.worldReady = promise
this.resolveWorldReady = resolve
this.rendererState = proxy(getDefaultRendererState().reactive)
this.nonReactiveState = getDefaultRendererState().nonReactive
this.rendererState = proxy(getDefaultRendererState())
// this.queuedDisplay = undefined
}
@ -301,7 +259,6 @@ export class AppViewer {
}
}
// do not import this. Use global appViewer instead (without window prefix).
export const appViewer = new AppViewer()
window.appViewer = appViewer
@ -309,46 +266,34 @@ const initialMenuStart = async () => {
if (appViewer.currentDisplay === 'world') {
appViewer.resetBackend(true)
}
const demo = new URLSearchParams(window.location.search).get('demo')
if (!demo) {
appViewer.startPanorama()
return
}
appViewer.startPanorama()
// const version = '1.18.2'
const version = '1.21.4'
const { loadMinecraftData } = await import('./connect')
const { getSyncWorld } = await import('../renderer/playground/shared')
await loadMinecraftData(version)
const world = getSyncWorld(version)
world.setBlockStateId(new Vec3(0, 64, 0), loadedData.blocksByName.water.defaultState)
world.setBlockStateId(new Vec3(1, 64, 0), loadedData.blocksByName.water.defaultState)
world.setBlockStateId(new Vec3(1, 64, 1), loadedData.blocksByName.water.defaultState)
world.setBlockStateId(new Vec3(0, 64, 1), loadedData.blocksByName.water.defaultState)
world.setBlockStateId(new Vec3(-1, 64, -1), loadedData.blocksByName.water.defaultState)
world.setBlockStateId(new Vec3(-1, 64, 0), loadedData.blocksByName.water.defaultState)
world.setBlockStateId(new Vec3(0, 64, -1), loadedData.blocksByName.water.defaultState)
appViewer.resourcesManager.currentConfig = { version }
appViewer.playerState.reactive = getInitialPlayerState()
await appViewer.resourcesManager.updateAssetsData({})
await appViewer.startWorld(world, 3)
appViewer.backend!.updateCamera(new Vec3(0, 65.7, 0), 0, -Math.PI / 2) // Y+1 and pitch = PI/2 to look down
void appViewer.worldView!.init(new Vec3(0, 64, 0))
// const version = '1.21.4'
// await appViewer.resourcesManager.loadMcData(version)
// const world = getSyncWorld(version)
// world.setBlockStateId(new Vec3(0, 64, 0), loadedData.blocksByName.water.defaultState)
// appViewer.resourcesManager.currentConfig = { version }
// await appViewer.resourcesManager.updateAssetsData({})
// appViewer.playerState = new BasePlayerState() as any
// await appViewer.startWorld(world, 3)
// appViewer.backend?.updateCamera(new Vec3(0, 64, 2), 0, 0)
// void appViewer.worldView!.init(new Vec3(0, 64, 0))
}
window.initialMenuStart = initialMenuStart
const hasAppStatus = () => activeModalStack.some(m => m.reactType === 'app-status')
const modalStackUpdateChecks = () => {
// maybe start panorama
if (!miscUiState.gameLoaded && !hasAppStatus()) {
if (activeModalStack.length === 0 && !miscUiState.gameLoaded) {
void initialMenuStart()
}
if (appViewer.backend) {
appViewer.backend.setRendering(!hasAppStatus())
const hasAppStatus = activeModalStack.some(m => m.reactType === 'app-status')
appViewer.backend.setRendering(!hasAppStatus)
}
appViewer.inWorldRenderingConfig.foreground = activeModalStack.length === 0
}
subscribe(activeModalStack, modalStackUpdateChecks)
subscribeKey(activeModalStack, 'length', modalStackUpdateChecks)
modalStackUpdateChecks()

View file

@ -7,12 +7,7 @@ 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;
isMusic: boolean;
}> = []
const activeSounds: Array<{ source: AudioBufferSourceNode; gainNode: GainNode; volumeMultiplier: number }> = []
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
@ -48,7 +43,7 @@ export async function loadSound (path: string, contents = path) {
}
}
export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = options.remoteSoundsLoadTimeout, loop = false, isMusic = false) => {
export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = 500) => {
const soundBuffer = sounds[url]
if (!soundBuffer) {
const start = Date.now()
@ -56,11 +51,11 @@ export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = option
if (cancelled || Date.now() - start > loadTimeout) return
}
return playSound(url, soundVolume, loop, isMusic)
return playSound(url, soundVolume)
}
export async function playSound (url, soundVolume = 1, loop = false, isMusic = false) {
const volume = soundVolume * (options.volume / 100) * (isMusic ? options.musicVolume / 100 : 1)
export async function playSound (url, soundVolume = 1) {
const volume = soundVolume * (options.volume / 100)
if (!volume) return
@ -80,14 +75,13 @@ export async function playSound (url, soundVolume = 1, loop = false, isMusic = f
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, isMusic })
activeSounds.push({ source, gainNode, volumeMultiplier: soundVolume })
const callbacks = [] as Array<() => void>
source.onended = () => {
@ -105,17 +99,6 @@ export async function playSound (url, soundVolume = 1, loop = false, isMusic = f
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,
}
}
@ -130,24 +113,11 @@ export function stopAllSounds () {
activeSounds.length = 0
}
export function stopSound (url: string) {
const soundIndex = activeSounds.findIndex(s => s.source.buffer === sounds[url])
if (soundIndex !== -1) {
const { source } = activeSounds[soundIndex]
try {
source.stop()
} catch (err) {
console.warn('Failed to stop sound:', err)
}
activeSounds.splice(soundIndex, 1)
}
}
export function changeVolumeOfCurrentlyPlayingSounds (newVolume: number, newMusicVolume: number) {
export function changeVolumeOfCurrentlyPlayingSounds (newVolume: number) {
const normalizedVolume = newVolume / 100
for (const { gainNode, volumeMultiplier, isMusic } of activeSounds) {
for (const { gainNode, volumeMultiplier } of activeSounds) {
try {
gainNode.gain.value = normalizedVolume * volumeMultiplier * (isMusic ? newMusicVolume / 100 : 1)
gainNode.gain.value = normalizedVolume * volumeMultiplier
} catch (err) {
console.warn('Failed to change sound volume:', err)
}
@ -155,9 +125,5 @@ export function changeVolumeOfCurrentlyPlayingSounds (newVolume: number, newMusi
}
subscribeKey(options, 'volume', () => {
changeVolumeOfCurrentlyPlayingSounds(options.volume, options.musicVolume)
})
subscribeKey(options, 'musicVolume', () => {
changeVolumeOfCurrentlyPlayingSounds(options.volume, options.musicVolume)
changeVolumeOfCurrentlyPlayingSounds(options.volume)
})

View file

@ -263,7 +263,7 @@ export const mountGoogleDriveFolder = async (readonly: boolean, rootId: string)
return true
}
export async function removeFileRecursiveAsync (path, removeDirectoryItself = true) {
export async function removeFileRecursiveAsync (path) {
const errors = [] as Array<[string, Error]>
try {
const files = await fs.promises.readdir(path)
@ -282,9 +282,7 @@ export async function removeFileRecursiveAsync (path, removeDirectoryItself = tr
}))
// After removing all files/directories, remove the current directory
if (removeDirectoryItself) {
await fs.promises.rmdir(path)
}
await fs.promises.rmdir(path)
} catch (error) {
errors.push([path, error])
}

View file

@ -18,7 +18,6 @@ export function onCameraMove (e: MouseEvent | CameraMoveEvent) {
if (!isGameActive(true)) return
if (e.type === 'mousemove' && !document.pointerLockElement) return
e.stopPropagation?.()
if (appViewer.playerState.utils.isSpectatingEntity()) return
const now = performance.now()
// todo: limit camera movement for now to avoid unexpected jumps
if (now - lastMouseMove < 4 && !options.preciseMouseInput) return
@ -33,6 +32,7 @@ export function onCameraMove (e: MouseEvent | CameraMoveEvent) {
updateMotion()
}
export const moveCameraRawHandler = ({ x, y }: { x: number; y: number }) => {
const maxPitch = 0.5 * Math.PI
const minPitch = -0.5 * Math.PI

View file

@ -4,10 +4,6 @@ import { fromFormattedString, TextComponent } from '@xmcl/text-component'
import type { IndexedData } from 'minecraft-data'
import { versionToNumber } from 'renderer/viewer/common/utils'
export interface MessageFormatOptions {
doShadow?: boolean
}
export type MessageFormatPart = Pick<TextComponent, 'hoverEvent' | 'clickEvent'> & {
text: string
color?: string
@ -118,14 +114,6 @@ 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,6 +3,7 @@
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'
@ -64,6 +65,7 @@ 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

@ -29,7 +29,6 @@ import { appStorage } from './react/appStorageProvider'
import { switchGameMode } from './packetsReplay/replayPackets'
import { tabListState } from './react/PlayerListOverlayProvider'
import { type ActionType, type ActionHoldConfig, type CustomAction } from './appConfig'
import { playerState } from './mineflayer/playerState'
export const customKeymaps = proxy(appStorage.keybindings)
subscribe(customKeymaps, () => {
@ -71,7 +70,6 @@ export const contro = new ControMax({
// client side
zoom: ['KeyC'],
viewerConsole: ['Backquote'],
togglePerspective: ['F5'],
},
ui: {
toggleFullscreen: ['F11'],
@ -116,10 +114,6 @@ 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)
@ -137,14 +131,7 @@ const setSprinting = (state: boolean) => {
gameAdditionalState.isSprinting = state
}
const isSpectatingEntity = () => {
return appViewer.playerState.utils.isSpectatingEntity()
}
contro.on('movementUpdate', ({ vector, soleVector, gamepadIndex }) => {
// Don't allow movement while spectating an entity
if (isSpectatingEntity()) return
if (gamepadIndex !== undefined && gamepadUiCursorState.display) {
const deadzone = 0.1 // TODO make deadzone configurable
if (Math.abs(soleVector.x) < deadzone && Math.abs(soleVector.z) < deadzone) {
@ -353,9 +340,6 @@ const cameraRotationControls = {
cameraRotationControls.updateMovement()
},
handleCommand (command: string, pressed: boolean) {
// Don't allow movement while spectating an entity
if (isSpectatingEntity()) return
const directionMap = {
'general.rotateCameraLeft': 'left',
'general.rotateCameraRight': 'right',
@ -377,7 +361,6 @@ window.cameraRotationControls = cameraRotationControls
const setSneaking = (state: boolean) => {
gameAdditionalState.isSneaking = state
bot.setControlState('sneak', state)
}
const onTriggerOrReleased = (command: Command, pressed: boolean) => {
@ -388,7 +371,6 @@ const onTriggerOrReleased = (command: Command, pressed: boolean) => {
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
switch (command) {
case 'general.jump':
if (isSpectatingEntity()) break
// if (viewer.world.freeFlyMode) {
// const moveSpeed = 0.5
// viewer.world.freeFlyState.position.add(new Vec3(0, pressed ? moveSpeed : 0, 0))
@ -445,28 +427,6 @@ const onTriggerOrReleased = (command: Command, pressed: boolean) => {
case 'general.playersList':
tabListState.isOpen = pressed
break
case 'general.viewerConsole':
if (lastConnectOptions.value?.viewerWsConnect) {
showModal({ reactType: 'console' })
}
break
case 'general.togglePerspective':
if (pressed) {
const currentPerspective = playerState.reactive.perspective
// eslint-disable-next-line sonarjs/no-nested-switch
switch (currentPerspective) {
case 'first_person':
playerState.reactive.perspective = 'third_person_back'
break
case 'third_person_back':
playerState.reactive.perspective = 'third_person_front'
break
case 'third_person_front':
playerState.reactive.perspective = 'first_person'
break
}
}
break
}
} else if (stringStartsWith(command, 'ui')) {
switch (command) {
@ -548,8 +508,6 @@ const customCommandsHandler = ({ command }) => {
contro.on('trigger', customCommandsHandler)
contro.on('trigger', ({ command }) => {
if (isCommandDisabled(command)) return
const willContinue = !isGameActive(true)
alwaysPressedHandledCommand(command)
if (willContinue) return
@ -584,19 +542,13 @@ contro.on('trigger', ({ command }) => {
case 'general.debugOverlay':
case 'general.debugOverlayHelpMenu':
case 'general.playersList':
case 'general.togglePerspective':
// no-op
break
case 'general.swapHands': {
if (isSpectatingEntity()) break
bot._client.write('block_dig', {
'status': 6,
'location': {
'x': 0,
'z': 0,
'y': 0
},
'face': 0,
bot._client.write('entity_action', {
entityId: bot.entity.id,
actionId: 6,
jumpBoost: 0
})
break
}
@ -604,13 +556,11 @@ contro.on('trigger', ({ command }) => {
// handled in onTriggerOrReleased
break
case 'general.inventory':
if (isSpectatingEntity()) break
document.exitPointerLock?.()
openPlayerInventory()
break
case 'general.drop': {
if (isSpectatingEntity()) break
// protocol 1.9+
// if (bot.heldItem/* && ctrl */) bot.tossStack(bot.heldItem)
bot._client.write('block_dig', {
'status': 4,
'location': {
@ -643,15 +593,12 @@ contro.on('trigger', ({ command }) => {
showModal({ reactType: 'chat' })
break
case 'general.selectItem':
if (isSpectatingEntity()) break
void selectItem()
break
case 'general.nextHotbarSlot':
if (isSpectatingEntity()) break
cycleHotbarSlot(1)
break
case 'general.prevHotbarSlot':
if (isSpectatingEntity()) break
cycleHotbarSlot(-1)
break
case 'general.zoom':
@ -683,8 +630,6 @@ contro.on('trigger', ({ command }) => {
})
contro.on('release', ({ command }) => {
if (isCommandDisabled(command)) return
inModalCommand(command, false)
onTriggerOrReleased(command, false)
})
@ -716,9 +661,6 @@ export const f3Keybinds: Array<{
localServer.players[0].world.columns = {}
}
void reloadChunks()
if (appViewer.backend?.backendMethods && typeof appViewer.backend.backendMethods.reloadWorld === 'function') {
appViewer.backend.backendMethods.reloadWorld()
}
},
mobileTitle: 'Reload chunks',
},
@ -736,12 +678,6 @@ export const f3Keybinds: Array<{
},
mobileTitle: 'Show Chunks Debug',
},
{
action () {
showModal({ reactType: 'renderer-debug' })
},
mobileTitle: 'Renderer Debug Menu',
},
{
key: 'KeyY',
async action () {
@ -818,11 +754,6 @@ 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')) {
@ -992,17 +923,14 @@ export function updateBinds (commands: any) {
}
export const onF3LongPress = async () => {
const actions = f3Keybinds.filter(f3Keybind => {
const select = await showOptionsModal('', f3Keybinds.filter(f3Keybind => {
return f3Keybind.mobileTitle && (f3Keybind.enabled?.() ?? true)
})
const actionNames = actions.map(f3Keybind => {
}).map(f3Keybind => {
return `${f3Keybind.mobileTitle}${f3Keybind.key ? ` (F3+${f3Keybind.key})` : ''}`
})
const select = await showOptionsModal('', actionNames)
}))
if (!select) return
const actionIndex = actionNames.indexOf(select)
const f3Keybind = actions[actionIndex]!
void f3Keybind.action()
const f3Keybind = f3Keybinds.find(f3Keybind => f3Keybind.mobileTitle === select)
if (f3Keybind) void f3Keybind.action()
}
export const handleMobileButtonCustomAction = (action: CustomAction) => {
@ -1012,16 +940,9 @@ 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,

View file

@ -1,106 +0,0 @@
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,219 +0,0 @@
import { appStorage } from '../react/appStorageProvider'
import { getChangedSettings, options } from '../optionsStorage'
import { customKeymaps } from '../controls'
import { showInputsModal } from '../react/SelectOption'
interface ExportedFile {
_about: string
options?: Record<string, any>
keybindings?: Record<string, any>
servers?: any[]
username?: string
proxy?: string
proxies?: string[]
accountTokens?: any[]
}
export const importData = async () => {
try {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json'
input.click()
const file = await new Promise<File>((resolve) => {
input.onchange = () => {
if (!input.files?.[0]) return
resolve(input.files[0])
}
})
const text = await file.text()
const data = JSON.parse(text)
if (!data._about?.includes('Minecraft Web Client')) {
const doContinue = confirm('This file does not appear to be a Minecraft Web Client profile. Continue anyway?')
if (!doContinue) return
}
// Build available data types for selection
const availableData: Record<keyof Omit<ExportedFile, '_about'>, { present: boolean, description: string }> = {
options: { present: !!data.options, description: 'Game settings and preferences' },
keybindings: { present: !!data.keybindings, description: 'Custom key mappings' },
servers: { present: !!data.servers, description: 'Saved server list' },
username: { present: !!data.username, description: 'Username' },
proxy: { present: !!data.proxy, description: 'Selected proxy server' },
proxies: { present: !!data.proxies, description: 'Global proxies list' },
accountTokens: { present: !!data.accountTokens, description: 'Account authentication tokens' },
}
// Filter to only present data types
const presentTypes = Object.fromEntries(Object.entries(availableData)
.filter(([_, info]) => info.present)
.map<any>(([key, info]) => [key, info]))
if (Object.keys(presentTypes).length === 0) {
alert('No compatible data found in the imported file.')
return
}
const importChoices = await showInputsModal('Select Data to Import', {
mergeData: {
type: 'checkbox',
label: 'Merge with existing data (uncheck to remove old data)',
defaultValue: true,
},
...Object.fromEntries(Object.entries(presentTypes).map(([key, info]) => [key, {
type: 'checkbox',
label: info.description,
defaultValue: true,
}]))
}) as { mergeData: boolean } & Record<keyof ExportedFile, boolean>
if (!importChoices) return
const importedTypes: string[] = []
const shouldMerge = importChoices.mergeData
if (importChoices.options && data.options) {
if (shouldMerge) {
Object.assign(options, data.options)
} else {
for (const key of Object.keys(options)) {
if (key in data.options) {
options[key as any] = data.options[key]
}
}
}
importedTypes.push('settings')
}
if (importChoices.keybindings && data.keybindings) {
if (shouldMerge) {
Object.assign(customKeymaps, data.keybindings)
} else {
for (const key of Object.keys(customKeymaps)) delete customKeymaps[key]
Object.assign(customKeymaps, data.keybindings)
}
importedTypes.push('keybindings')
}
if (importChoices.servers && data.servers) {
if (shouldMerge && appStorage.serversList) {
// Merge by IP, update existing entries and add new ones
const existingIps = new Set(appStorage.serversList.map(s => s.ip))
const newServers = data.servers.filter(s => !existingIps.has(s.ip))
appStorage.serversList = [...appStorage.serversList, ...newServers]
} else {
appStorage.serversList = data.servers
}
importedTypes.push('servers')
}
if (importChoices.username && data.username) {
appStorage.username = data.username
importedTypes.push('username')
}
if ((importChoices.proxy && data.proxy) || (importChoices.proxies && data.proxies)) {
if (!appStorage.proxiesData) {
appStorage.proxiesData = { proxies: [], selected: '' }
}
if (importChoices.proxies && data.proxies) {
if (shouldMerge) {
// Merge unique proxies
const uniqueProxies = new Set([...appStorage.proxiesData.proxies, ...data.proxies])
appStorage.proxiesData.proxies = [...uniqueProxies]
} else {
appStorage.proxiesData.proxies = data.proxies
}
importedTypes.push('proxies list')
}
if (importChoices.proxy && data.proxy) {
appStorage.proxiesData.selected = data.proxy
importedTypes.push('selected proxy')
}
}
if (importChoices.accountTokens && data.accountTokens) {
if (shouldMerge && appStorage.authenticatedAccounts) {
// Merge by unique identifier (assuming accounts have some unique ID or username)
const existingAccounts = new Set(appStorage.authenticatedAccounts.map(a => a.username))
const newAccounts = data.accountTokens.filter(a => !existingAccounts.has(a.username))
appStorage.authenticatedAccounts = [...appStorage.authenticatedAccounts, ...newAccounts]
} else {
appStorage.authenticatedAccounts = data.accountTokens
}
importedTypes.push('account tokens')
}
alert(`Profile imported successfully! Imported data: ${importedTypes.join(', ')}.\nYou may need to reload the page for some changes to take effect.`)
} catch (err) {
console.error('Failed to import profile:', err)
alert('Failed to import profile: ' + (err.message || err))
}
}
export const exportData = async () => {
const data = await showInputsModal('Export Profile', {
profileName: {
type: 'text',
},
exportSettings: {
type: 'checkbox',
defaultValue: true,
},
exportKeybindings: {
type: 'checkbox',
defaultValue: true,
},
exportServers: {
type: 'checkbox',
defaultValue: true,
},
saveUsernameAndProxy: {
type: 'checkbox',
defaultValue: true,
},
exportGlobalProxiesList: {
type: 'checkbox',
defaultValue: false,
},
exportAccountTokens: {
type: 'checkbox',
defaultValue: false,
},
})
const fileName = `${data.profileName ? `${data.profileName}-` : ''}web-client-profile.json`
const json: ExportedFile = {
_about: 'Minecraft Web Client (mcraft.fun) Profile',
...data.exportSettings ? {
options: getChangedSettings(),
} : {},
...data.exportKeybindings ? {
keybindings: customKeymaps,
} : {},
...data.exportServers ? {
servers: appStorage.serversList,
} : {},
...data.saveUsernameAndProxy ? {
username: appStorage.username,
proxy: appStorage.proxiesData?.selected,
} : {},
...data.exportGlobalProxiesList ? {
proxies: appStorage.proxiesData?.proxies,
} : {},
...data.exportAccountTokens ? {
accountTokens: appStorage.authenticatedAccounts,
} : {},
}
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
URL.revokeObjectURL(url)
}

View file

@ -2,20 +2,19 @@ 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
bot.once('login', () => {
registerBlockModelsChannel()
registerMediaChannels()
registerSectionAnimationChannels()
registeredJeiChannel()
registerBlockInteractionsCustomizationChannel()
registerWaypointChannels()
registerIdeChannels()
await new Promise(resolve => {
bot.once('login', () => {
resolve(true)
})
})
registerBlockModelsChannel()
registerMediaChannels()
registerSectionAnimationChannels()
registeredJeiChannel()
})
}
@ -33,95 +32,6 @@ 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,7 +1,6 @@
//@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')
@ -52,20 +51,8 @@ class CustomChannelClient extends EventEmitter {
this.emit('state', newProperty, oldProperty)
}
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
end(reason) {
this._endReason = reason
this.emit('end', this._endReason) // still emits on server side only, doesn't send anything to our client
}

46
src/dayCycle.ts Normal file
View file

@ -0,0 +1,46 @@
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

@ -1,159 +0,0 @@
export const defaultOptions = {
renderDistance: 3,
keepChunksDistance: 1,
multiplayerRenderDistance: 3,
closeConfirmation: true,
autoFullScreen: false,
mouseRawInput: true,
autoExitFullscreen: false,
localUsername: 'wanderer',
mouseSensX: 50,
mouseSensY: -1,
chatWidth: 320,
chatHeight: 180,
chatScale: 100,
chatOpacity: 100,
chatOpacityOpened: 100,
messagesLimit: 200,
volume: 50,
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,
touchButtonsOpacity: 80,
touchButtonsPosition: 12,
touchControlsPositions: getDefaultTouchControlsPositions(),
touchControlsSize: getTouchControlsSize(),
touchMovementType: 'modern' as 'modern' | 'classic',
touchInteractionType: 'classic' as 'classic' | 'buttons',
gpuPreference: 'default' as 'default' | 'high-performance' | 'low-power',
backgroundRendering: '20fps' as 'full' | '20fps' | '5fps',
/** @unstable */
disableAssets: false,
/** @unstable */
debugLogNotFrequentPackets: false,
unimplementedContainers: false,
dayCycleAndLighting: true,
loadPlayerSkins: true,
renderEars: true,
lowMemoryMode: false,
starfieldRendering: true,
defaultSkybox: true,
enabledResourcepack: null as string | null,
useVersionsTextures: 'latest',
serverResourcePacks: 'prompt' as 'prompt' | 'always' | 'never',
showHand: true,
viewBobbing: true,
displayRecordButton: true,
packetsLoggerPreset: 'all' as 'all' | 'no-buffers',
serversAutoVersionSelect: 'auto' as 'auto' | 'latest' | '1.20.4' | string,
customChannels: false,
remoteContentNotSameOrigin: false as boolean | string[],
packetsRecordingAutoStart: false,
language: 'auto',
preciseMouseInput: false,
// todo ui setting, maybe enable by default?
waitForChunksRender: false as 'sp-only' | boolean,
jeiEnabled: true as boolean | Array<'creative' | 'survival' | 'adventure' | 'spectator'>,
modsSupport: false,
modsAutoUpdate: 'check' as 'check' | 'never' | 'always',
modsUpdatePeriodCheck: 24, // hours
preventBackgroundTimeoutKick: false,
preventSleep: false,
debugContro: false,
debugChatScroll: false,
chatVanillaRestrictions: true,
debugResponseTimeIndicator: false,
chatPingExtension: true,
// antiAliasing: false,
topRightTimeDisplay: 'only-fullscreen' as 'only-fullscreen' | 'always' | 'never',
clipWorldBelowY: undefined as undefined | number, // will be removed
disableSignsMapsSupport: false,
singleplayerAutoSave: false,
showChunkBorders: false, // todo rename option
frameLimit: false as number | false,
alwaysBackupWorldBeforeLoading: undefined as boolean | undefined | null,
alwaysShowMobileControls: false,
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,
errorReporting: true,
/** Actually might be useful */
showCursorBlockInSpectator: false,
renderEntities: true,
smoothLighting: true,
newVersionsLighting: false,
chatSelect: true,
autoJump: 'auto' as 'auto' | 'always' | 'never',
autoParkour: false,
vrSupport: true, // doesn't directly affect the VR mode, should only disable the button which is annoying to android users
vrPageGameRendering: false,
renderDebug: 'basic' as 'none' | 'advanced' | 'basic',
rendererPerfDebugOverlay: false,
// advanced bot options
autoRespawn: false,
mutedSounds: [] as string[],
plugins: [] as Array<{ enabled: boolean, name: string, description: string, script: string }>,
/** Wether to popup sign editor on server action */
autoSignEditor: true,
wysiwygSignEditor: 'auto' as 'auto' | 'always' | 'never',
showMinimap: 'never' as 'always' | 'singleplayer' | 'never',
minimapOptimizations: true,
displayBossBars: true,
disabledUiParts: [] as string[],
neighborChunkUpdates: true,
highlightBlockColor: 'auto' as 'auto' | 'blue' | 'classic',
activeRenderer: 'threejs',
rendererSharedOptions: {
_experimentalSmoothChunkLoading: true,
_renderByChunks: false
}
}
function getDefaultTouchControlsPositions () {
return {
action: [
70,
76
],
sneak: [
84,
76
],
break: [
70,
57
],
jump: [
84,
57
],
} as Record<string, [number, number]>
}
function getTouchControlsSize () {
return {
joystick: 55,
action: 36,
break: 36,
jump: 36,
sneak: 36,
}
}

View file

@ -5,17 +5,6 @@ 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)
@ -220,105 +209,3 @@ 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,12 +11,6 @@ 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,7 +3,6 @@ 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'
@ -13,9 +12,6 @@ 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) => {
@ -49,34 +45,6 @@ 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

@ -4,9 +4,8 @@ import tracker from '@nxg-org/mineflayer-tracker'
import { loader as autoJumpPlugin } from '@nxg-org/mineflayer-auto-jump'
import { subscribeKey } from 'valtio/utils'
import { getThreeJsRendererMethods } from 'renderer/viewer/three/threeJsMethods'
import { Team } from 'mineflayer'
import { options, watchValue } from './optionsStorage'
import { gameAdditionalState, miscUiState } from './globalState'
import { miscUiState } from './globalState'
import { EntityStatus } from './mineflayer/entityStatus'
@ -44,7 +43,7 @@ customEvents.on('gameLoaded', () => {
updateAutoJump()
const playerPerAnimation = {} as Record<string, string>
const checkEntityData = (e: Entity) => {
const entityData = (e: Entity) => {
if (!e.username) return
window.debugEntityMetadata ??= {}
window.debugEntityMetadata[e.username] = e
@ -53,13 +52,6 @@ customEvents.on('gameLoaded', () => {
}
}
const trackBotEntity = () => {
// Always track the bot entity for animations
if (bot.entity) {
bot.tracker.trackEntity(bot.entity)
}
}
let lastCall = 0
bot.on('physicsTick', () => {
// throttle, tps: 6
@ -72,7 +64,7 @@ customEvents.on('gameLoaded', () => {
const speed = info.avgVel
const WALKING_SPEED = 0.03
const SPRINTING_SPEED = 0.18
const isCrouched = e === bot.entity ? gameAdditionalState.isSneaking : e['crouching']
const isCrouched = e['crouching']
const isWalking = Math.abs(speed.x) > WALKING_SPEED || Math.abs(speed.z) > WALKING_SPEED
const isSprinting = Math.abs(speed.x) > SPRINTING_SPEED || Math.abs(speed.z) > SPRINTING_SPEED
@ -81,12 +73,7 @@ customEvents.on('gameLoaded', () => {
: isWalking ? (isSprinting ? 'running' : 'walking')
: 'idle'
if (newAnimation !== playerPerAnimation[id]) {
// Handle bot entity animation specially (for player entity in third person)
if (e === bot.entity) {
getThreeJsRendererMethods()?.playEntityAnimation('player_entity', newAnimation)
} else {
getThreeJsRendererMethods()?.playEntityAnimation(e.id, newAnimation)
}
getThreeJsRendererMethods()?.playEntityAnimation(e.id, newAnimation)
playerPerAnimation[id] = newAnimation
}
}
@ -96,25 +83,6 @@ customEvents.on('gameLoaded', () => {
getThreeJsRendererMethods()?.playEntityAnimation(e.id, 'oneSwing')
})
bot.on('botArmSwingStart', (hand) => {
if (hand === 'right') {
getThreeJsRendererMethods()?.playEntityAnimation('player_entity', 'oneSwing')
}
})
bot.inventory.on('updateSlot', (slot) => {
if (slot === 5 || slot === 6 || slot === 7 || slot === 8) {
const item = bot.inventory.slots[slot]!
bot.entity.equipment[slot - 3] = item
appViewer.worldView?.emit('playerEntity', bot.entity)
}
})
bot.on('heldItemChanged', () => {
const item = bot.inventory.slots[bot.quickBarSlot + 36]!
bot.entity.equipment[0] = item
appViewer.worldView?.emit('playerEntity', bot.entity)
})
bot._client.on('damage_event', (data) => {
const { entityId, sourceTypeId: damage } = data
getThreeJsRendererMethods()?.damageEntity(entityId, damage)
@ -126,243 +94,60 @@ 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) => {
if (bot.game.gameMode !== 'spectator') return
bot.entity.position = entity.position.clone()
void bot.look(entity.yaw, entity.pitch, true)
bot.entity.yaw = entity.yaw
bot.entity.pitch = entity.pitch
}
bot.on('entityGone', (entity) => {
bot.tracker.stopTrackingEntity(entity, true)
})
bot.on('entityMoved', (e) => {
checkEntityData(e)
if (appViewer.playerState.reactive.cameraSpectatingEntity === e.id) {
updateCamera(e)
}
entityData(e)
})
bot._client.on('entity_velocity', (packet) => {
const e = bot.entities[packet.entityId]
if (!e) return
checkEntityData(e)
entityData(e)
})
for (const entity of Object.values(bot.entities)) {
if (entity !== bot.entity) {
checkEntityData(entity)
entityData(entity)
}
}
// Track bot entity initially
trackBotEntity()
bot.on('entitySpawn', (e) => {
checkEntityData(e)
if (appViewer.playerState.reactive.cameraSpectatingEntity === e.id) {
updateCamera(e)
}
})
bot.on('entityUpdate', checkEntityData)
bot.on('entityEquip', checkEntityData)
// Re-track bot entity after login
bot.on('login', () => {
setTimeout(() => {
trackBotEntity()
}) // Small delay to ensure bot.entity is properly set
})
bot._client.on('camera', (packet) => {
if (bot.player.entity.id === packet.cameraId) {
if (appViewer.playerState.utils.isSpectatingEntity() && appViewer.playerState.reactive.cameraSpectatingEntity) {
const entity = bot.entities[appViewer.playerState.reactive.cameraSpectatingEntity]
appViewer.playerState.reactive.cameraSpectatingEntity = undefined
if (entity) {
// do a force entity update
bot.emit('entityUpdate', entity)
}
}
} else if (appViewer.playerState.reactive.gameMode === 'spectator') {
const entity = bot.entities[packet.cameraId]
appViewer.playerState.reactive.cameraSpectatingEntity = packet.cameraId
if (entity) {
updateCamera(entity)
// do a force entity update
bot.emit('entityUpdate', entity)
}
}
})
const applySkinTexturesProxy = (url: string | undefined) => {
const { appConfig } = miscUiState
if (appConfig?.skinTexturesProxy) {
return url?.replace('http://textures.minecraft.net/', appConfig.skinTexturesProxy)
.replace('https://textures.minecraft.net/', appConfig.skinTexturesProxy)
}
return url
}
bot.on('entitySpawn', entityData)
bot.on('entityUpdate', entityData)
bot.on('entityEquip', entityData)
// Texture override from packet properties
const updateSkin = (player: import('mineflayer').Player) => {
if (!player.uuid || !player.username || !player.skinData) return
try {
const skinUrl = applySkinTexturesProxy(player.skinData.url)
const capeUrl = applySkinTexturesProxy((player.skinData as any).capeUrl)
// Find entity with matching UUID and update skin
let entityId = ''
for (const [entId, entity] of Object.entries(bot.entities)) {
if (entity.uuid === player.uuid) {
entityId = entId
break
}
bot._client.on('player_info', (packet) => {
for (const playerEntry of packet.data) {
if (!playerEntry.player && !playerEntry.properties) continue
let textureProperty = playerEntry.properties?.find(prop => prop?.name === 'textures')
if (!textureProperty) {
textureProperty = playerEntry.player?.properties?.find(prop => prop?.key === 'textures')
}
// even if not found, still record to cache
void getThreeJsRendererMethods()!.updatePlayerSkin(entityId, player.username, player.uuid, skinUrl ?? true, capeUrl)
} catch (err) {
reportError(new Error('Error applying skin texture:', { cause: err }))
}
}
if (textureProperty) {
try {
const textureData = JSON.parse(Buffer.from(textureProperty.value, 'base64').toString())
const skinUrl = textureData.textures?.SKIN?.url
const capeUrl = textureData.textures?.CAPE?.url
bot.on('playerJoined', updateSkin)
bot.on('playerUpdated', updateSkin)
for (const entity of Object.values(bot.players)) {
updateSkin(entity)
}
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)) {
const entityTeam = entity.type === 'player' && entity.username ? bot.teamMap[entity.username] : entity.uuid ? bot.teamMap[entity.uuid] : undefined
if ((entityTeam?.nameTagVisibility === 'hideForOwnTeam' && entityTeam.name === team.name)
|| (entityTeam?.nameTagVisibility === 'hideForOtherTeams' && entityTeam.name !== team.name)) {
bot.emit('entityUpdate', entity)
}
}
}
const doEntitiesNeedUpdating = (team: Team) => {
return team.nameTagVisibility === 'never'
|| (team.nameTagVisibility === 'hideForOtherTeams' && appViewer.playerState.reactive.team?.team !== team.team)
|| (team.nameTagVisibility === 'hideForOwnTeam' && appViewer.playerState.reactive.team?.team === team.team)
}
bot.on('teamMemberAdded', (team: Team, members: string[]) => {
if (members.includes(bot.username) && appViewer.playerState.reactive.team?.team !== team.team) {
appViewer.playerState.reactive.team = team
// Player was added to a team, need to check if any entities need updating
updateEntityNameTags(team)
} else if (doEntitiesNeedUpdating(team)) {
// Need to update all entities that were added
for (const entity of Object.values(bot.entities)) {
if (entity.type === 'player' && entity.username && members.includes(entity.username) || entity.uuid && members.includes(entity.uuid)) {
bot.emit('entityUpdate', entity)
// Find entity with matching UUID and update skin
let entityId = ''
for (const [entId, entity] of Object.entries(bot.entities)) {
if (entity.uuid === playerEntry.uuid) {
entityId = entId
break
}
}
// even if not found, still record to cache
void getThreeJsRendererMethods()?.updatePlayerSkin(entityId, playerEntry.player?.name, playerEntry.uuid, skinUrl, capeUrl)
} catch (err) {
console.error('Error decoding player texture:', err)
}
}
}
})
bot.on('teamMemberRemoved', (team: Team, members: string[]) => {
if (members.includes(bot.username) && appViewer.playerState.reactive.team?.team === team.team) {
appViewer.playerState.reactive.team = undefined
// Player was removed from a team, need to check if any entities need updating
updateEntityNameTags(team)
} else if (doEntitiesNeedUpdating(team)) {
// Need to update all entities that were removed
for (const entity of Object.values(bot.entities)) {
if (entity.type === 'player' && entity.username && members.includes(entity.username) || entity.uuid && members.includes(entity.uuid)) {
bot.emit('entityUpdate', entity)
}
}
}
})
bot.on('teamRemoved', (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)
}
})
})
// 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
}
}

37
src/env.d.ts vendored
View file

@ -1,37 +0,0 @@
declare namespace NodeJS {
interface ProcessEnv {
// Build configuration
NODE_ENV: 'development' | 'production'
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
// Build internals
GITHUB_REPOSITORY?: string
VERCEL_GIT_REPO_OWNER?: string
VERCEL_GIT_REPO_SLUG?: string
// UI
MAIN_MENU_LINKS?: string
ALWAYS_MINIMAL_SERVER_UI?: 'true' | 'false'
// App features
ENABLE_COOKIE_STORAGE?: string
COOKIE_STORAGE_PREFIX?: string
// Build info. Release information
RELEASE_TAG?: string
RELEASE_LINK?: string
RELEASE_CHANGELOG?: string
// Build info
INLINED_APP_CONFIG?: string
GITHUB_URL?: string
}
}

View file

@ -46,8 +46,6 @@ export const showModal = (elem: /* (HTMLElement & Record<string, any>) | */{ re
activeModalStack.push(resolved)
}
window.showModal = showModal
/**
*
* @returns true if previous modal was restored

View file

@ -5,8 +5,7 @@ window.bot = undefined
window.THREE = undefined
window.localServer = undefined
window.worldView = undefined
window.viewer = undefined // legacy
window.appViewer = undefined
window.viewer = undefined
window.loadedData = undefined
window.customEvents = new EventEmitter()
window.customEvents.setMaxListeners(10_000)

View file

@ -29,7 +29,7 @@ import './reactUi'
import { lockUrl, onBotCreate } from './controls'
import './dragndrop'
import { possiblyCleanHandle } from './browserfs'
import downloadAndOpenFile, { isInterestedInDownload } from './downloadAndOpenFile'
import downloadAndOpenFile from './downloadAndOpenFile'
import fs from 'fs'
import net, { Socket } from 'net'
@ -56,12 +56,13 @@ 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, quickDevReconnect } from './react/AppStatusProvider'
import { appStatusState, lastConnectOptions } from './react/AppStatusProvider'
import { fsState } from './loadSave'
import { watchFov } from './rendererUtils'
@ -73,7 +74,7 @@ import { showNotification } from './react/NotificationProvider'
import { saveToBrowserMemory } from './react/PauseScreen'
import './devReload'
import './water'
import { ConnectOptions, getVersionAutoSelect, downloadOtherGameData, downloadAllMinecraftData, loadMinecraftData } from './connect'
import { ConnectOptions, getVersionAutoSelect, downloadOtherGameData, downloadAllMinecraftData } from './connect'
import { ref, subscribe } from 'valtio'
import { signInMessageState } from './react/SignInMessageProvider'
import { findServerPassword, updateAuthenticatedAccountData, updateLoadedServerData, updateServerConnectionHistory } from './react/serversStorage'
@ -96,7 +97,6 @@ import { registerOpenBenchmarkListener } from './benchmark'
import { tryHandleBuiltinCommand } from './builtinCommands'
import { loadingTimerState } from './react/LoadingTimer'
import { loadPluginsIntoWorld } from './react/CreateWorldProvider'
import { getCurrentProxy, getCurrentUsername } from './react/ServersList'
window.debug = debug
window.beforeRenderFrame = []
@ -166,7 +166,6 @@ export async function connect (connectOptions: ConnectOptions) {
})
}
appStatusState.showReconnect = false
loadingTimerState.loading = true
loadingTimerState.start = Date.now()
miscUiState.hasErrors = false
@ -210,17 +209,12 @@ export async function connect (connectOptions: ConnectOptions) {
let ended = false
let bot!: typeof __type_bot
let hadConnected = false
const destroyAll = (wasKicked = false) => {
if (ended) return
loadingTimerState.loading = false
const { alwaysReconnect } = appQueryParams
if ((!wasKicked && miscUiState.appConfig?.allowAutoConnect && appQueryParams.autoConnect && hadConnected) || (alwaysReconnect)) {
if (alwaysReconnect === 'quick' || alwaysReconnect === 'fast') {
quickDevReconnect()
} else {
location.reload()
}
const hadConnected = !!bot
if (!wasKicked && miscUiState.appConfig?.allowAutoConnect && appQueryParams.autoConnect && hadConnected) {
location.reload()
}
errorAbortController.abort()
ended = true
@ -235,12 +229,8 @@ export async function connect (connectOptions: ConnectOptions) {
bot.emit('end', '')
bot.removeAllListeners()
bot._client.removeAllListeners()
bot._client = {
//@ts-expect-error
write (packetName) {
console.warn('Tried to write packet', packetName, 'after bot was destroyed')
}
}
//@ts-expect-error TODO?
bot._client = undefined
//@ts-expect-error
window.bot = bot = undefined
}
@ -286,10 +276,6 @@ 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
@ -304,7 +290,7 @@ export async function connect (connectOptions: ConnectOptions) {
if (connectOptions.server && !connectOptions.viewerWsConnect && !parsedServer.isWebSocket) {
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') ?? ''}` }, artificialDelay: appQueryParams.addPing ? Number(appQueryParams.addPing) : undefined })
net['setProxy']({ hostname: proxy.host, port: proxy.port, headers: { Authorization: `Bearer ${new URLSearchParams(location.search).get('token') ?? ''}` } })
}
const renderDistance = singleplayer ? renderDistanceSingleplayer : multiplayerRenderDistance
@ -345,7 +331,6 @@ export async function connect (connectOptions: ConnectOptions) {
await progress.executeWithMessage(
'Processing downloaded Minecraft data',
async () => {
await loadMinecraftData(version)
await appViewer.resourcesManager.loadSourceData(version)
}
)
@ -463,20 +448,17 @@ export async function connect (connectOptions: ConnectOptions) {
let newTokensCacheResult = null as any
const cachedTokens = typeof connectOptions.authenticatedAccount === 'object' ? connectOptions.authenticatedAccount.cachedTokens : {}
let authData: Awaited<ReturnType<typeof microsoftAuthflow>> | undefined
if (connectOptions.authenticatedAccount) {
authData = await microsoftAuthflow({
tokenCaches: cachedTokens,
proxyBaseUrl: connectOptions.proxy,
setProgressText (text) {
progress.setMessage(text)
},
setCacheResult (result) {
newTokensCacheResult = result
},
connectingServer: server.host
})
}
const authData = connectOptions.authenticatedAccount ? await microsoftAuthflow({
tokenCaches: cachedTokens,
proxyBaseUrl: connectOptions.proxy,
setProgressText (text) {
progress.setMessage(text)
},
setCacheResult (result) {
newTokensCacheResult = result
},
connectingServer: server.host
}) : undefined
if (p2pMultiplayer) {
clientDataStream = await connectToPeer(connectOptions.peerId!, connectOptions.peerOptions)
@ -587,7 +569,6 @@ export async function connect (connectOptions: ConnectOptions) {
// "mapDownloader-saveInternal": false, // do not save into memory, todo must be implemeneted as we do really care of ram
}) as unknown as typeof __type_bot
window.bot = bot
if (connectOptions.viewerWsConnect) {
void onBotCreatedViewerHandler()
}
@ -710,7 +691,6 @@ export async function connect (connectOptions: ConnectOptions) {
onBotCreate()
bot.once('login', () => {
errorAbortController.abort()
loadingTimerState.networkOnlyStart = 0
progress.setMessage('Loading world')
})
@ -728,7 +708,7 @@ export async function connect (connectOptions: ConnectOptions) {
resolve()
unsub()
} else {
const perc = Math.round(appViewer.rendererState.world.chunksLoaded.size / appViewer.nonReactiveState.world.chunksTotalNumber * 100)
const perc = Math.round(appViewer.rendererState.world.chunksLoaded.size / appViewer.rendererState.world.chunksTotalNumber * 100)
progress?.reportProgress('chunks', perc / 100)
}
})
@ -747,12 +727,9 @@ export async function connect (connectOptions: ConnectOptions) {
})
await appViewer.resourcesManager.promiseAssetsReady
}
errorAbortController.abort()
if (appStatusState.isError) return
if (!appViewer.resourcesManager.currentResources?.itemsRenderer) {
await appViewer.resourcesManager.updateAssetsData({})
}
const loadWorldStart = Date.now()
console.log('try to focus window')
window.focus?.()
@ -764,7 +741,7 @@ export async function connect (connectOptions: ConnectOptions) {
try {
if (p2pConnectTimeout) clearTimeout(p2pConnectTimeout)
playerState.reactive.onlineMode = !!connectOptions.authenticatedAccount
playerState.onlineMode = !!connectOptions.authenticatedAccount
progress.setMessage('Placing blocks (starting viewer)')
if (!connectOptions.worldStateFileContents || connectOptions.worldStateFileContents.length < 3 * 1024 * 1024) {
@ -788,11 +765,9 @@ export async function connect (connectOptions: ConnectOptions) {
console.log('bot spawned - starting viewer')
await appViewer.startWorld(bot.world, renderDistance)
appViewer.worldView!.listenToBot(bot)
if (appViewer.backend) {
void appViewer.worldView!.init(bot.entity.position)
}
initMotionTracking()
dayCycle()
// Bot position callback
const botPosition = () => {
@ -844,32 +819,11 @@ export async function connect (connectOptions: ConnectOptions) {
miscUiState.gameLoaded = true
miscUiState.loadedServerIndex = connectOptions.serverIndex ?? ''
customEvents.emit('gameLoaded')
// Test iOS Safari crash by creating memory pressure
if (appQueryParams.testIosCrash) {
setTimeout(() => {
console.log('Starting iOS crash test with memory pressure...')
// eslint-disable-next-line sonarjs/no-unused-collection
const arrays: number[][] = []
try {
// Create large arrays until we run out of memory
// eslint-disable-next-line no-constant-condition
while (true) {
const arr = Array.from({ length: 1024 * 1024 }).fill(0).map((_, i) => i)
arrays.push(arr)
}
} catch (e) {
console.error('Memory allocation failed:', e)
}
}, 1000)
}
progress.end()
setLoadingScreenStatus(undefined)
} catch (err) {
handleError(err)
}
hadConnected = true
}
// don't use spawn event, player can be dead
bot.once(spawnEarlier ? 'forcedMove' : 'health', displayWorld)
@ -893,7 +847,37 @@ 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
@ -929,148 +913,90 @@ document.body.addEventListener('touchstart', (e) => {
}, { passive: false })
// #endregion
// 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 () => {
// 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 ?? {})
void connect({
botVersion: appQueryParams.version ?? undefined,
...lastConnect,
ip: appQueryParams.ip || undefined
})
})
}
if (appQueryParams.singleplayer === '1' || appQueryParams.sp === '1') {
return waitForConfigFsLoad(async () => {
loadSingleplayer({}, {
worldFolder: undefined,
...appQueryParams.version ? { version: appQueryParams.version } : {}
})
})
}
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`)
}
return
}
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
if (appQueryParams.ip || appQueryParams.proxy) {
const waitAppConfigLoad = !appQueryParams.proxy
const openServerEditor = () => {
hideModal()
if (appQueryParams.onlyConnect) {
showModal({ reactType: 'only-connect-server' })
} else {
showModal({ reactType: 'editServer' })
}
}
setLoadingScreenStatus(undefined)
if (appQueryParams.onlyConnect || process.env.ALWAYS_MINIMAL_SERVER_UI === 'true') {
showModal({ reactType: 'only-connect-server' })
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 {
showModal({ reactType: 'editServer' })
openServerEditor()
}
}
// 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
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
})
}
})
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 })
if (appQueryParams.serversList) {
showModal({ reactType: 'serversList' })
}
return
}
if (appQueryParams.serversList && !miscUiState.appConfig?.appParams?.serversList) {
// open UI only if it's in URL
showModal({ reactType: 'serversList' })
}
const viewerWsConnect = appQueryParams.viewerConnect
if (viewerWsConnect) {
void connect({
username: `viewer-${Math.random().toString(36).slice(2, 10)}`,
viewerWsConnect,
})
}
if (isInterestedInDownload()) {
void downloadAndOpenFile()
}
void possiblyHandleStateVariable()
}
try {
maybeEnterGame()
} catch (err) {
console.error(err)
alert(`Something went wrong: ${err}`)
if (appQueryParams.modal) {
const modals = appQueryParams.modal.split(',')
for (const modal of modals) {
showModal({ reactType: modal })
}
}
}, (err) => {
console.error(err)
alert(`Something went wrong: ${err}`)
})
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
@ -1081,5 +1007,6 @@ if (initialLoader) {
}
window.pageLoaded = true
void possiblyHandleStateVariable()
appViewer.waitBackendLoadPromises.push(appStartup())
registerOpenBenchmarkListener()

View file

@ -9,10 +9,8 @@ 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 { activeGuiAtlas } from 'renderer/viewer/lib/guiRenderer'
import Generic95 from '../assets/generic_95.png'
import { appReplacableResources } from './generated/resources'
import { activeModalStack, hideCurrentModal, hideModal, miscUiState, showModal } from './globalState'
@ -23,10 +21,8 @@ import { currentScaling } from './scaleInterface'
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 loadedImagesCache = new Map<string, HTMLImageElement>()
const cleanLoadedImagesCache = () => {
loadedImagesCache.delete('blocks')
loadedImagesCache.delete('items')
@ -42,34 +38,6 @@ 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
@ -87,23 +55,12 @@ 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, maybeParseNbtJson(win.title))
openWindow(implementedWindow)
} else if (options.unimplementedContainers) {
openWindow('ChestWin', maybeParseNbtJson(win.title))
openWindow('ChestWin')
} else {
// todo format
displayClientChat(`[client error] cannot open unimplemented window ${win.id} (${win.type}). Slots: ${win.slots.map(item => getItemName(item)).filter(Boolean).join(', ')}`)
@ -174,12 +131,11 @@ export const onGameLoad = () => {
}
}
const getImageSrc = (path): string | HTMLImageElement | ImageBitmap => {
const getImageSrc = (path): string | HTMLImageElement => {
switch (path) {
case 'gui/container/inventory': return appReplacableResources.latest_gui_container_inventory.content
case 'blocks': return appViewer.resourcesManager.blocksAtlasParser.latestImage
case 'items': return appViewer.resourcesManager.itemsAtlasParser.latestImage
case 'gui': return appViewer.resourcesManager.currentResources!.guiAtlas!.image
case 'blocks': return appViewer.resourcesManager.currentResources!.blocksAtlasParser.latestImage
case 'items': return appViewer.resourcesManager.currentResources!.itemsAtlasParser.latestImage
case 'gui/container/dispenser': return appReplacableResources.latest_gui_container_dispenser.content
case 'gui/container/furnace': return appReplacableResources.latest_gui_container_furnace.content
case 'gui/container/crafting_table': return appReplacableResources.latest_gui_container_crafting_table.content
@ -202,20 +158,12 @@ const getImage = ({ path = undefined as string | undefined, texture = undefined
if (image) {
return image
}
if (!path && !texture) {
throw new Error('Either pass path or texture')
}
if (!path && !texture) throw new Error('Either pass path or texture')
const loadPath = (blockData ? 'blocks' : path ?? texture)!
if (loadedImagesCache.has(loadPath)) {
onLoad()
} else {
const imageSrc = getImageSrc(loadPath)
if (imageSrc instanceof ImageBitmap) {
onLoad()
loadedImagesCache.set(loadPath, imageSrc)
return imageSrc
}
let image: HTMLImageElement
if (imageSrc instanceof Image) {
image = imageSrc
@ -229,6 +177,79 @@ const getImage = ({ path = undefined as string | undefined, texture = undefined
return loadedImagesCache.get(loadPath)
}
export type ResolvedItemModelRender = {
modelName: string,
originalItemName?: string
}
export const renderSlot = (model: ResolvedItemModelRender, debugIsQuickbar = false, fullBlockModelSupport = false): {
texture: string,
blockData?: Record<string, { slice, path }> & { resolvedModel: BlockModel },
scale?: number,
slice?: number[],
modelName?: string,
image?: HTMLImageElement
} | undefined => {
let itemModelName = model.modelName
const isItem = loadedData.itemsByName[itemModelName]
// #region normalize item name
if (versionToNumber(bot.version) < versionToNumber('1.13')) itemModelName = getRenamedData(isItem ? 'items' : 'blocks', itemModelName, bot.version, '1.13.1') as string
// #endregion
let itemTexture
if (!fullBlockModelSupport) {
const atlas = activeGuiAtlas.atlas?.json
// todo atlas holds all rendered blocks, not all possibly rendered item/block models, need to request this on demand instead (this is how vanilla works)
const tryGetAtlasTexture = (name?: string) => name && atlas?.textures[name.replace('minecraft:', '').replace('block/', '').replace('blocks/', '').replace('item/', '').replace('items/', '').replace('_inventory', '')]
const item = tryGetAtlasTexture(itemModelName) ?? tryGetAtlasTexture(model.originalItemName)
if (item) {
const x = item.u * atlas.width
const y = item.v * atlas.height
return {
texture: 'gui',
image: activeGuiAtlas.atlas!.image,
slice: [x, y, atlas.tileSize, atlas.tileSize],
scale: 0.25,
}
}
}
const blockToTopTexture = (r) => r.top ?? r
try {
assertDefined(appViewer.resourcesManager.currentResources?.itemsRenderer)
itemTexture =
appViewer.resourcesManager.currentResources.itemsRenderer.getItemTexture(itemModelName, {}, false, fullBlockModelSupport)
?? (model.originalItemName ? appViewer.resourcesManager.currentResources.itemsRenderer.getItemTexture(model.originalItemName, {}, false, fullBlockModelSupport) : undefined)
?? appViewer.resourcesManager.currentResources.itemsRenderer.getItemTexture('item/missing_texture')!
} catch (err) {
inGameError(`Failed to render item ${itemModelName} (original: ${model.originalItemName}) on ${bot.version} (resourcepack: ${options.enabledResourcepack}): ${err.stack}`)
itemTexture = blockToTopTexture(appViewer.resourcesManager.currentResources!.itemsRenderer.getItemTexture('errored')!)
}
itemTexture ??= blockToTopTexture(appViewer.resourcesManager.currentResources!.itemsRenderer.getItemTexture('unknown')!)
if ('type' in itemTexture) {
// is item
return {
texture: itemTexture.type,
slice: itemTexture.slice,
modelName: itemModelName
}
} else {
// is block
return {
texture: 'blocks',
blockData: itemTexture,
modelName: itemModelName
}
}
}
const getItemName = (slot: Item | RenderItem | null) => {
const parsed = getItemNameRaw(slot, appViewer.resourcesManager)
if (!parsed) return
@ -248,15 +269,10 @@ const itemToVisualKey = (slot: RenderItem | Item | null) => {
slot['metadata'],
slot.nbt ? JSON.stringify(slot.nbt) : '',
slot['components'] ? JSON.stringify(slot['components']) : '',
appViewer.resourcesManager.currentResources!.guiAtlasVersion,
activeGuiAtlas.version,
].join('|')
return keys
}
const validateSlot = (slot: any, index: number) => {
if (!slot.texture) {
throw new Error(`Slot has no texture: ${index} ${slot.name}`)
}
}
const mapSlots = (slots: Array<RenderItem | Item | null>, isJei = false) => {
const newSlots = slots.map((slot, i) => {
if (!slot) return null
@ -266,7 +282,6 @@ const mapSlots = (slots: Array<RenderItem | Item | null>, isJei = false) => {
const newKey = itemToVisualKey(slot)
slot['cacheKey'] = i + '|' + newKey
if (oldKey && oldKey === newKey) {
validateSlot(lastMappedSlots[i], i)
return lastMappedSlots[i]
}
}
@ -274,8 +289,8 @@ const mapSlots = (slots: Array<RenderItem | Item | null>, isJei = false) => {
try {
if (slot.durabilityUsed && slot.maxDurability) slot.durabilityUsed = Math.min(slot.durabilityUsed, slot.maxDurability)
const debugIsQuickbar = !isJei && i === bot.inventory.hotbarStart + bot.quickBarSlot
const modelName = getItemModelName(slot, { 'minecraft:display_context': 'gui', }, appViewer.resourcesManager, appViewer.playerState.reactive)
const slotCustomProps = renderSlot({ modelName, originalItemName: slot.name }, appViewer.resourcesManager, debugIsQuickbar)
const modelName = getItemModelName(slot, { 'minecraft:display_context': 'gui', }, appViewer.resourcesManager)
const slotCustomProps = renderSlot({ modelName, originalItemName: slot.name }, debugIsQuickbar)
const itemCustomName = getItemName(slot)
Object.assign(slot, { ...slotCustomProps, displayName: itemCustomName ?? slot.displayName })
//@ts-expect-error
@ -285,13 +300,12 @@ const mapSlots = (slots: Array<RenderItem | Item | null>, isJei = false) => {
const { icon, ...rest } = slot
return rest
}
validateSlot(slot, i)
} catch (err) {
inGameError(err)
}
return slot
})
lastMappedSlots = JSON.parse(JSON.stringify(newSlots))
lastMappedSlots = newSlots
return newSlots
}
@ -300,7 +314,6 @@ 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) => {
@ -327,7 +340,6 @@ const implementedContainersGuiMap = {
'minecraft:generic_3x3': 'DropDispenseWin',
'minecraft:furnace': 'FurnaceWin',
'minecraft:smoker': 'FurnaceWin',
'minecraft:shulker_box': 'ChestWin',
'minecraft:blast_furnace': 'FurnaceWin',
'minecraft:crafting': 'CraftingWin',
'minecraft:crafting3x3': 'CraftingWin', // todo different result slot
@ -397,7 +409,7 @@ const upWindowItemsLocal = () => {
}
let skipClosePacketSending = false
const openWindow = (type: string | undefined, title: string | any = undefined) => {
const openWindow = (type: string | 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) {
@ -418,20 +430,15 @@ const openWindow = (type: string | undefined, title: string | any = undefined) =
lastWindow.destroy()
lastWindow = null as any
lastWindowType = null
window.inventory = null
window.lastWindow = lastWindow
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 ?
@ -470,7 +477,6 @@ const openWindow = (type: string | undefined, title: string | any = undefined) =
const isRightClick = type === 'rightclick'
const isLeftClick = type === 'leftclick'
if (isLeftClick || isRightClick) {
modelViewerState.model = undefined
inv.canvasManager.children[0].showRecipesOrUsages(isLeftClick, item)
}
} else {
@ -502,7 +508,6 @@ const openWindow = (type: string | undefined, title: string | any = 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])
}
}
@ -571,7 +576,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!
@ -599,7 +604,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,
@ -644,7 +649,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))) {

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