diff --git a/.cursor/rules/vars-usage.mdc b/.cursor/rules/vars-usage.mdc index 7120e7ae..233e0aba 100644 --- a/.cursor/rules/vars-usage.mdc +++ b/.cursor/rules/vars-usage.mdc @@ -14,3 +14,5 @@ Ask AI - 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. diff --git a/.eslintrc.json b/.eslintrc.json index 3552f6a7..63f6749a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -23,6 +23,7 @@ // ], "@stylistic/arrow-spacing": "error", "@stylistic/block-spacing": "error", + "@typescript-eslint/no-this-alias": "off", "@stylistic/brace-style": [ "error", "1tbs", diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index f913b9b6..e80b7100 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -26,7 +26,7 @@ jobs: uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 22 cache: "pnpm" - name: Move Cypress to dependencies run: | diff --git a/.github/workflows/build-single-file.yml b/.github/workflows/build-single-file.yml index 93b1b77f..5f9800db 100644 --- a/.github/workflows/build-single-file.yml +++ b/.github/workflows/build-single-file.yml @@ -23,6 +23,8 @@ 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 diff --git a/.github/workflows/build-zip.yml b/.github/workflows/build-zip.yml index cc472476..76ca65ca 100644 --- a/.github/workflows/build-zip.yml +++ b/.github/workflows/build-zip.yml @@ -23,6 +23,8 @@ jobs: - name: Build project run: pnpm build + env: + LOCAL_CONFIG_FILE: config.mcraft-only.json - name: Bundle server.js run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc344aad..8fc56ea9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/next-deploy.yml b/.github/workflows/next-deploy.yml index 943727eb..75b39f6c 100644 --- a/.github/workflows/next-deploy.yml +++ b/.github/workflows/next-deploy.yml @@ -36,7 +36,7 @@ jobs: run: vercel build --token=${{ secrets.VERCEL_TOKEN }} env: CONFIG_JSON_SOURCE: BUNDLED - - run: pnpm build-storybook + LOCAL_CONFIG_FILE: config.mcraft-only.json - name: Copy playground files run: | mkdir -p .vercel/output/static/playground diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index f35e6a00..89fd6698 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -78,7 +78,7 @@ jobs: run: vercel build --token=${{ secrets.VERCEL_TOKEN }} env: CONFIG_JSON_SOURCE: BUNDLED - - run: pnpm build-storybook + LOCAL_CONFIG_FILE: config.mcraft-only.json - name: Copy playground files run: | mkdir -p .vercel/output/static/playground diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 14c4b471..3e8c4136 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: - run: vercel build --token=${{ secrets.VERCEL_TOKEN }} --prod env: CONFIG_JSON_SOURCE: BUNDLED - - run: pnpm build-storybook + LOCAL_CONFIG_FILE: config.mcraft-only.json - name: Copy playground files run: | mkdir -p .vercel/output/static/playground @@ -48,12 +48,26 @@ jobs: 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 Minecraft Web Client to Minecraft Web Client — Free Online Browser Version sed -i 's/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: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 06df61fa..a5a3482d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -177,8 +177,13 @@ New React components, improve UI (including mobile support). ## Updating Dependencies -1. Ensure mineflayer fork is up to date with the latest version of mineflayer original repo +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` + 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 diff --git a/README.MD b/README.MD index 4769192a..018784e3 100644 --- a/README.MD +++ b/README.MD @@ -6,9 +6,13 @@ 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. -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! +> For Turkey/Russia use [ru.mcraft.fun](https://ru.mcraft.fun/) (since Cloudflare is blocked) -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). +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) ### Big Features @@ -28,7 +32,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://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://minimap.mcraft.fun/storybook/) are published as npm module and can be used in other projects: [`minecraft-react`](https://npmjs.com/minecraft-react) ### Recommended Settings @@ -40,15 +44,19 @@ All components that are in [Storybook](https://mcraft.fun/storybook) are publish ### Browser Notes -These browsers have issues with capturing pointer: +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: **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.4 are supported. +Server versions 1.8 - 1.21.5 are supported. First class versions (most of the features are tested on these versions): + - 1.19.4 - 1.21.4 @@ -70,6 +78,8 @@ There is a builtin proxy, but you can also host your one! Just clone the repo, r [![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?name=minecraft-web-client&type=git&repository=zardoy%2Fminecraft-web-client&branch=next&builder=dockerfile&env%5B%5D=&ports=8080%3Bhttp%3B%2F) +> **Note**: If you want to make **your own** Minecraft server accessible to web clients (without our proxies), you can use [mwc-proxy](https://github.com/zardoy/mwc-proxy) - a lightweight JS WebSocket proxy that runs on the same server as your Minecraft server, allowing players to connect directly via `wss://play.example.com`. `?client_mcraft` is added to the URL, so the proxy will know that it's this client. + Proxy servers are used to connect to Minecraft servers which use TCP protocol. When you connect connect to a server with a proxy, websocket connection is created between you (browser client) and the proxy server located in Europe, then the proxy connects to the Minecraft server and sends the data to the client (you) without any packet deserialization to avoid any additional delays. That said all the Minecraft protocol packets are processed by the client, right in your browser. ```mermaid @@ -117,12 +127,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: -- `localStorage.debug = '*'` - Enables all debug messages! Warning: this will start all packets spam. +- If you type `debugToggle`, press enter in console - It will enables all debug messages! Warning: this will start all packets spam. Instead I recommend setting `options.debugLogNotFrequentPackets`. Also you can use `debugTopPackets` (with JSON.stringify) to see what packets were received/sent by name - `bot` - Mineflayer bot instance. See Mineflayer documentation for more. -- `viewer` - Three.js viewer instance, basically does all the rendering. -- `viewer.world.sectionObjects` - Object with all active chunk sections (geometries) in the world. Each chunk section is a Three.js mesh or group. +- `world` - Three.js world instance, basically does all the rendering (part of renderer backend). +- `world.sectionObjects` - Object with all active chunk sections (geometries) in the world. Each chunk section is a Three.js mesh or group. - `debugSceneChunks` - The same as above, but relative to current bot position (e.g. 0,0 is the current chunk). - `debugChangedOptions` - See what options are changed. Don't change options here. - `localServer`/`server` - Only for singleplayer mode/host. Flying Squid server instance, see it's documentation for more. @@ -131,7 +141,7 @@ Instead I recommend setting `options.debugLogNotFrequentPackets`. Also you can u - `nbt.simplify(someNbt)` - Simplifies nbt data, so it's easier to read. -The most useful thing in devtools is the watch expression. You can add any expression there and it will be re-evaluated in real time. For example, you can add `viewer.camera.position` to see the camera position and so on. +The most useful thing in devtools is the watch expression. You can add any expression there and it will be re-evaluated in real time. For example, you can add `world.getCameraPosition()` to see the camera position and so on. <img src="./docs-assets/watch-expr.png" alt="Watch expression" width="480"/> @@ -168,6 +178,7 @@ Server specific: - `?lockConnect=true` - Only works then `ip` parameter is set. Disables cancel/save buttons and all inputs in the connect screen already set as parameters. Useful for integrates iframes. - `?autoConnect=true` - Only works then `ip` and `version` parameters are set and `allowAutoConnect` is `true` in config.json! Directly connects to the specified server. Useful for integrates iframes. - `?serversList=<list_or_url>` - `<list_or_url>` can be a list of servers in the format `ip:version,ip` or a url to a json file with the same format (array) or a txt file with line-delimited list of server IPs. +- `?addPing=<ping>` - Add a latency to both sides of the connection. Useful for testing ping issues. For example `?addPing=100` will add 200ms to your ping. Single player specific: @@ -224,3 +235,4 @@ Only during development: - [https://github.com/ClassiCube/ClassiCube](ClassiCube - Better C# Rewrite) [DEMO](https://www.classicube.net/server/play/?warned=true) - [https://m.eaglercraft.com/](EaglerCraft) - Eaglercraft runnable on mobile (real Minecraft in the browser) +- [js-minecraft](https://github.com/LabyStudio/js-minecraft) - An insanely well done clone from the graphical side that inspired many features here diff --git a/assets/customTextures/readme.md b/assets/customTextures/readme.md new file mode 100644 index 00000000..e2a78c20 --- /dev/null +++ b/assets/customTextures/readme.md @@ -0,0 +1,2 @@ +here you can place custom textures for bundled files (blocks/items) e.g. blocks/stone.png +get file names from here (blocks/items) https://zardoy.github.io/mc-assets/ diff --git a/assets/debug-inputs.html b/assets/debug-inputs.html new file mode 100644 index 00000000..584fe4d7 --- /dev/null +++ b/assets/debug-inputs.html @@ -0,0 +1,237 @@ +<!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 + + + +
+ +
+ +
+
W
+
A
+
S
+
D
+
+ +
+
Ctrl
+
+ +
+
Space
+
+ + + + diff --git a/config.json b/config.json index fe26a9e5..2bfa9cfe 100644 --- a/config.json +++ b/config.json @@ -3,15 +3,27 @@ "defaultHost": "", "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" + }, { "ip": "wss://play2.mcraft.fun" }, + { + "ip": "wss://play-creative.mcraft.fun", + "description": "Might be available soon, stay tuned!" + }, { "ip": "kaboom.pw", "version": "1.20.3", diff --git a/config.mcraft-only.json b/config.mcraft-only.json new file mode 100644 index 00000000..52a3aa2c --- /dev/null +++ b/config.mcraft-only.json @@ -0,0 +1,5 @@ +{ + "alwaysReconnectButton": true, + "reportBugButtonWithReconnect": true, + "allowAutoConnect": true +} diff --git a/experiments/three-item.html b/experiments/three-item.html new file mode 100644 index 00000000..70155c50 --- /dev/null +++ b/experiments/three-item.html @@ -0,0 +1,13 @@ + + + + Minecraft Item Viewer + + + + + + diff --git a/experiments/three-item.ts b/experiments/three-item.ts new file mode 100644 index 00000000..b9d492fe --- /dev/null +++ b/experiments/three-item.ts @@ -0,0 +1,108 @@ +import * as THREE from 'three' +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' +import itemsAtlas from 'mc-assets/dist/itemsAtlasLegacy.png' +import { createItemMeshFromCanvas, createItemMesh } from '../renderer/viewer/three/itemMesh' + +// Create scene, camera and renderer +const scene = new THREE.Scene() +const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000) +const renderer = new THREE.WebGLRenderer({ antialias: true }) +renderer.setSize(window.innerWidth, window.innerHeight) +document.body.appendChild(renderer.domElement) + +// Setup camera and controls +camera.position.set(0, 0, 3) +const controls = new OrbitControls(camera, renderer.domElement) +controls.enableDamping = true + +// Background and lights +scene.background = new THREE.Color(0x333333) +const ambientLight = new THREE.AmbientLight(0xffffff, 0.7) +scene.add(ambientLight) + +// Animation loop +function animate () { + requestAnimationFrame(animate) + controls.update() + renderer.render(scene, camera) +} + +async function setupItemMesh () { + try { + const loader = new THREE.TextureLoader() + const atlasTexture = await loader.loadAsync(itemsAtlas) + + // Pixel-art configuration + atlasTexture.magFilter = THREE.NearestFilter + atlasTexture.minFilter = THREE.NearestFilter + atlasTexture.generateMipmaps = false + atlasTexture.wrapS = atlasTexture.wrapT = THREE.ClampToEdgeWrapping + + // Extract the tile at x=2, y=0 (16x16) + const tileSize = 16 + const tileX = 2 + const tileY = 0 + + const canvas = document.createElement('canvas') + canvas.width = tileSize + canvas.height = tileSize + const ctx = canvas.getContext('2d')! + + ctx.imageSmoothingEnabled = false + ctx.drawImage( + atlasTexture.image, + tileX * tileSize, + tileY * tileSize, + tileSize, + tileSize, + 0, + 0, + tileSize, + tileSize + ) + + // Test both approaches - working manual extraction: + const meshOld = createItemMeshFromCanvas(canvas, { depth: 0.1 }) + meshOld.position.x = -1 + meshOld.rotation.x = -Math.PI / 12 + meshOld.rotation.y = Math.PI / 12 + scene.add(meshOld) + + // And new unified function: + const atlasWidth = atlasTexture.image.width + const atlasHeight = atlasTexture.image.height + const u = (tileX * tileSize) / atlasWidth + const v = (tileY * tileSize) / atlasHeight + const sizeX = tileSize / atlasWidth + const sizeY = tileSize / atlasHeight + + console.log('Debug texture coords:', {u, v, sizeX, sizeY, atlasWidth, atlasHeight}) + + const resultNew = createItemMesh(atlasTexture, { + u, v, sizeX, sizeY + }, { + faceCamera: false, + use3D: true, + depth: 0.1 + }) + + resultNew.mesh.position.x = 1 + resultNew.mesh.rotation.x = -Math.PI / 12 + resultNew.mesh.rotation.y = Math.PI / 12 + scene.add(resultNew.mesh) + + animate() + } catch (err) { + console.error('Failed to create item mesh:', err) + } +} + +// Handle window resize +window.addEventListener('resize', () => { + camera.aspect = window.innerWidth / window.innerHeight + camera.updateProjectionMatrix() + renderer.setSize(window.innerWidth, window.innerHeight) +}) + +// Start +setupItemMesh() diff --git a/experiments/three-labels.html b/experiments/three-labels.html new file mode 100644 index 00000000..2b25bc23 --- /dev/null +++ b/experiments/three-labels.html @@ -0,0 +1,5 @@ + + diff --git a/experiments/three-labels.ts b/experiments/three-labels.ts new file mode 100644 index 00000000..b69dc95b --- /dev/null +++ b/experiments/three-labels.ts @@ -0,0 +1,67 @@ +import * as THREE from 'three' +import { FirstPersonControls } from 'three/addons/controls/FirstPersonControls.js' +import { createWaypointSprite, WAYPOINT_CONFIG } from '../renderer/viewer/three/waypointSprite' + +// Create scene, camera and renderer +const scene = new THREE.Scene() +const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000) +const renderer = new THREE.WebGLRenderer({ antialias: true }) +renderer.setSize(window.innerWidth, window.innerHeight) +document.body.appendChild(renderer.domElement) + +// Add FirstPersonControls +const controls = new FirstPersonControls(camera, renderer.domElement) +controls.lookSpeed = 0.1 +controls.movementSpeed = 10 +controls.lookVertical = true +controls.constrainVertical = true +controls.verticalMin = 0.1 +controls.verticalMax = Math.PI - 0.1 + +// Position camera +camera.position.y = 1.6 // Typical eye height +camera.lookAt(0, 1.6, -1) + +// Create a helper grid and axes +const grid = new THREE.GridHelper(20, 20) +scene.add(grid) +const axes = new THREE.AxesHelper(5) +scene.add(axes) + +// Create waypoint sprite via utility +const waypoint = createWaypointSprite({ + position: new THREE.Vector3(0, 0, -5), + color: 0xff0000, + label: 'Target', +}) +scene.add(waypoint.group) + +// Use built-in offscreen arrow from utils +waypoint.enableOffscreenArrow(true) +waypoint.setArrowParent(scene) + +// Animation loop +function animate() { + requestAnimationFrame(animate) + + const delta = Math.min(clock.getDelta(), 0.1) + controls.update(delta) + + // Unified camera update (size, distance text, arrow, visibility) + const sizeVec = renderer.getSize(new THREE.Vector2()) + waypoint.updateForCamera(camera.position, camera, sizeVec.width, sizeVec.height) + + renderer.render(scene, camera) +} + +// Handle window resize +window.addEventListener('resize', () => { + camera.aspect = window.innerWidth / window.innerHeight + camera.updateProjectionMatrix() + renderer.setSize(window.innerWidth, window.innerHeight) +}) + +// Add clock for controls +const clock = new THREE.Clock() + +animate() diff --git a/experiments/three.ts b/experiments/three.ts index 7a629a13..21142b5f 100644 --- a/experiments/three.ts +++ b/experiments/three.ts @@ -1,101 +1,60 @@ 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) -const controls = new OrbitControls(camera, renderer.domElement) +// Position camera +camera.position.z = 5 -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) +// Create a canvas with some content +const canvas = document.createElement('canvas') +canvas.width = 256 +canvas.height = 256 +const ctx = canvas.getContext('2d') -// 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) +scene.background = new THREE.Color(0x444444) - new tweenJs.Tween(group.rotation).to({ z: THREE.MathUtils.degToRad(90) }, 1000).yoyo(true).repeat(Infinity).start() +// 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) -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) +// 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) } 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((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); -}) diff --git a/index.html b/index.html index 5a6318ec..b2fa3dbd 100644 --- a/index.html +++ b/index.html @@ -27,6 +27,7 @@
A true Minecraft client in your browser!
+ ` @@ -36,6 +37,13 @@ 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 @@ -46,12 +54,23 @@ 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) { + if (window.navigator.serviceWorker && document.querySelector('.initial-loader').style.opacity !== 0) { + console.log('got worker') window.navigator.serviceWorker.getRegistrations().then(registrations => { registrations.forEach(registration => { - registration.unregister() + console.log('got registration') + registration.unregister().then(() => { + console.log('worker unregistered') + }) }) }) } diff --git a/package.json b/package.json index 3559169d..ff673726 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev-proxy": "node server.js", "start": "run-p dev-proxy dev-rsbuild watch-mesher", "start2": "run-p dev-rsbuild watch-mesher", + "start-metrics": "ENABLE_METRICS=true rsbuild dev", "build": "pnpm build-other-workers && rsbuild build", "build-analyze": "BUNDLE_ANALYZE=true rsbuild build && pnpm build-other-workers", "build-single-file": "SINGLE_FILE_BUILD=true rsbuild build", @@ -31,7 +32,9 @@ "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" + "watch-playground": "rsbuild dev --config renderer/rsbuild.config.ts", + "update-git-deps": "tsx scripts/updateGitDeps.ts", + "request-data": "tsx scripts/requestData.ts" }, "keywords": [ "prismarine", @@ -51,6 +54,7 @@ "dependencies": { "@dimaka/interface": "0.0.3-alpha.0", "@floating-ui/react": "^0.26.1", + "@monaco-editor/react": "^4.7.0", "@nxg-org/mineflayer-auto-jump": "^0.7.18", "@nxg-org/mineflayer-tracker": "1.3.0", "@react-oauth/google": "^0.12.1", @@ -76,14 +80,14 @@ "esbuild-plugin-polyfill-node": "^0.3.0", "express": "^4.18.2", "filesize": "^10.0.12", - "flying-squid": "npm:@zardoy/flying-squid@^0.0.59", + "flying-squid": "npm:@zardoy/flying-squid@^0.0.104", "framer-motion": "^12.9.2", "fs-extra": "^11.1.1", "google-drive-browserfs": "github:zardoy/browserfs#google-drive", "jszip": "^3.10.1", "lodash-es": "^4.17.21", "mcraft-fun-mineflayer": "^0.1.23", - "minecraft-data": "3.89.0", + "minecraft-data": "3.98.0", "minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master", "mineflayer-item-map-downloader": "github:zardoy/mineflayer-item-map-downloader", "mojangson": "^2.0.4", @@ -150,11 +154,10 @@ "http-browserify": "^1.7.0", "http-server": "^14.1.1", "https-browserify": "^1.0.0", - "mc-assets": "^0.2.59", + "mc-assets": "^0.2.62", "minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next", "mineflayer": "github:zardoy/mineflayer#gen-the-master", - "mineflayer-mouse": "^0.1.10", - "mineflayer-pathfinder": "^2.4.4", + "mineflayer-mouse": "^0.1.21", "npm-run-all": "^4.1.5", "os-browserify": "^0.3.0", "path-browserify": "^1.0.1", @@ -194,6 +197,7 @@ }, "pnpm": { "overrides": { + "mineflayer": "github:zardoy/mineflayer#gen-the-master", "@nxg-org/mineflayer-physics-util": "1.8.10", "buffer": "^6.0.3", "vec3": "0.1.10", @@ -201,7 +205,7 @@ "diamond-square": "github:zardoy/diamond-square", "prismarine-block": "github:zardoy/prismarine-block#next-era", "prismarine-world": "github:zardoy/prismarine-world#next-era", - "minecraft-data": "3.89.0", + "minecraft-data": "3.98.0", "prismarine-provider-anvil": "github:zardoy/prismarine-provider-anvil#everything", "prismarine-physics": "github:zardoy/prismarine-physics", "minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master", @@ -210,7 +214,10 @@ "prismarine-item": "latest" }, "updateConfig": { - "ignoreDependencies": [] + "ignoreDependencies": [ + "browserfs", + "google-drive-browserfs" + ] }, "patchedDependencies": { "pixelarticons@1.8.1": "patches/pixelarticons@1.8.1.patch", @@ -227,7 +234,9 @@ "cypress", "esbuild", "fsevents" - ] + ], + "ignorePatchFailures": false, + "allowUnusedPatches": false }, "packageManager": "pnpm@10.8.0+sha512.0e82714d1b5b43c74610193cb20734897c1d00de89d0e18420aebc5977fa13d780a9cb05734624e81ebd81cc876cd464794850641c48b9544326b5622ca29971" } diff --git a/patches/minecraft-protocol.patch b/patches/minecraft-protocol.patch index efc79176..e29f87d9 100644 --- a/patches/minecraft-protocol.patch +++ b/patches/minecraft-protocol.patch @@ -1,29 +1,26 @@ -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 f14269bea055d4329cd729271e7406ec4b344de7..00f5482eb6e3c911381ca9a728b1b4aae0d1d337 100644 +index 0021870994fc59a82f0ac8aba0a65a8be43ef2f4..a53fceb843105ea2a1d88722b3fc7c3b43cb102a 100644 --- a/src/client/chat.js +++ b/src/client/chat.js -@@ -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) { +@@ -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) { if (mcData.supportFeature('useChatSessions')) { const tsDelta = BigInt(Date.now()) - packet.timestamp const expired = !packet.timestamp || tsDelta > messageExpireTime || tsDelta < 0 @@ -31,8 +28,8 @@ index f14269bea055d4329cd729271e7406ec4b344de7..00f5482eb6e3c911381ca9a728b1b4aa + const verified = false && !packet.unsignedChatContent && updateAndValidateSession(packet.senderUuid, packet.plainMessage, packet.signature, packet.index, packet.previousMessages, packet.salt, packet.timestamp) && !expired if (verified) client._signatureCache.push(packet.signature) client.emit('playerChat', { - plainMessage: packet.plainMessage, -@@ -363,7 +363,7 @@ module.exports = function (client, options) { + globalIndex: packet.globalIndex, +@@ -362,7 +362,7 @@ module.exports = function (client, options) { } } @@ -41,16 +38,16 @@ index f14269bea055d4329cd729271e7406ec4b344de7..00f5482eb6e3c911381ca9a728b1b4aa options.timestamp = options.timestamp || BigInt(Date.now()) options.salt = options.salt || 1n -@@ -405,7 +405,7 @@ module.exports = function (client, options) { +@@ -407,7 +407,7 @@ module.exports = function (client, options) { message, timestamp: options.timestamp, salt: options.salt, - signature: (client.profileKeys && client._session) ? client.signMessage(message, options.timestamp, options.salt, undefined, acknowledgements) : undefined, + signature: (client.profileKeys && client._session) ? await client.signMessage(message, options.timestamp, options.salt, undefined, acknowledgements) : undefined, offset: client._lastSeenMessages.pending, + checksum: computeChatChecksum(client._lastSeenMessages), // 1.21.5+ acknowledged - }) -@@ -419,7 +419,7 @@ module.exports = function (client, options) { +@@ -422,7 +422,7 @@ module.exports = function (client, options) { message, timestamp: options.timestamp, salt: options.salt, @@ -60,7 +57,7 @@ index f14269bea055d4329cd729271e7406ec4b344de7..00f5482eb6e3c911381ca9a728b1b4aa previousMessages: client._lastSeenMessages.map((e) => ({ messageSender: e.sender, diff --git a/src/client/encrypt.js b/src/client/encrypt.js -index b9d21bab9faccd5dbf1975fc423fc55c73e906c5..99ffd76527b410e3a393181beb260108f4c63536 100644 +index 63cc2bd9615100bd2fd63dfe14c094aa6b8cd1c9..36df57d1196af9761d920fa285ac48f85410eaef 100644 --- a/src/client/encrypt.js +++ b/src/client/encrypt.js @@ -25,7 +25,11 @@ module.exports = function (client, options) { @@ -76,41 +73,24 @@ index b9d21bab9faccd5dbf1975fc423fc55c73e906c5..99ffd76527b410e3a393181beb260108 } function onJoinServerResponse (err) { -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', {}) +diff --git a/src/client/pluginChannels.js b/src/client/pluginChannels.js +index 671eb452f31e6b5fcd57d715f1009d010160c65f..7f69f511c8fb97d431ec5125c851b49be8e2ab76 100644 +--- a/src/client/pluginChannels.js ++++ b/src/client/pluginChannels.js +@@ -57,7 +57,7 @@ module.exports = function (client, options) { + try { + packet.data = proto.parsePacketBuffer(channel, packet.data).data + } catch (error) { +- client.emit('error', error) ++ client.emit('error', error, { customPayload: packet }) + return + } } - client.state = states.CONFIGURATION -- client.on('select_known_packs', () => { -+ client.once('select_known_packs', () => { - client.write('select_known_packs', { packs: [] }) - }) - // Server should send finish_configuration on its own right after sending the client a dimension codec diff --git a/src/client.js b/src/client.js -index 74749698f8cee05b5dc749c271544f78d06645b0..e77e0a3f41c1ee780c3abbd54b0801d248c2a07c 100644 +index e369e77d055ba919e8f9da7b8e8b5dc879c74cf4..54bb9e6644388e9b6bd42b3012951875989cdf0c 100644 --- a/src/client.js +++ b/src/client.js -@@ -89,10 +89,12 @@ class Client extends EventEmitter { - parsed.metadata.name = parsed.data.name - parsed.data = parsed.data.params - parsed.metadata.state = state -- debug('read packet ' + state + '.' + parsed.metadata.name) -- if (debug.enabled) { -- const s = JSON.stringify(parsed.data, null, 2) -- debug(s && s.length > 10000 ? parsed.data : s) -+ if (!globalThis.excludeCommunicationDebugEvents?.includes(parsed.metadata.name)) { -+ debug('read packet ' + state + '.' + parsed.metadata.name) -+ if (debug.enabled) { -+ const s = JSON.stringify(parsed.data, null, 2) -+ debug(s && s.length > 10000 ? parsed.data : s) -+ } - } - if (this._hasBundlePacket && parsed.metadata.name === 'bundle_delimiter') { - if (this._mcBundle.length) { // End bundle -@@ -110,7 +112,13 @@ class Client extends EventEmitter { +@@ -111,7 +111,13 @@ class Client extends EventEmitter { this._hasBundlePacket = false } } else { @@ -125,7 +105,7 @@ index 74749698f8cee05b5dc749c271544f78d06645b0..e77e0a3f41c1ee780c3abbd54b0801d2 } }) } -@@ -168,7 +176,10 @@ class Client extends EventEmitter { +@@ -169,7 +175,10 @@ class Client extends EventEmitter { } const onFatalError = (err) => { @@ -137,25 +117,21 @@ index 74749698f8cee05b5dc749c271544f78d06645b0..e77e0a3f41c1ee780c3abbd54b0801d2 endSocket() } -@@ -197,6 +208,8 @@ class Client extends EventEmitter { +@@ -198,6 +207,10 @@ class Client extends EventEmitter { serializer -> framer -> socket -> splitter -> deserializer */ if (this.serializer) { this.serializer.end() -+ this.socket?.end() -+ this.socket?.emit('end') ++ setTimeout(() => { ++ this.socket?.end() ++ this.socket?.emit('end') ++ }, 2000) // allow the serializer to finish writing } else { if (this.socket) this.socket.end() } -@@ -238,8 +251,11 @@ class Client extends EventEmitter { - - write (name, params) { - if (!this.serializer.writable) { return } -- debug('writing packet ' + this.state + '.' + name) -- debug(params) -+ if (!globalThis.excludeCommunicationDebugEvents?.includes(name)) { -+ debug(`[${this.state}] from ${this.isServer ? 'server' : 'client'}: ` + name) -+ debug(params) -+ } +@@ -243,6 +256,7 @@ class Client extends EventEmitter { + debug('writing packet ' + this.state + '.' + name) + debug(params) + } + this.emit('writePacket', name, params) this.serializer.write({ name, params }) } diff --git a/patches/pixelarticons@1.8.1.patch b/patches/pixelarticons@1.8.1.patch index 10044536..b65b6f2b 100644 --- a/patches/pixelarticons@1.8.1.patch +++ b/patches/pixelarticons@1.8.1.patch @@ -1,5 +1,5 @@ diff --git a/fonts/pixelart-icons-font.css b/fonts/pixelart-icons-font.css -index 3b2ebe839370d96bf93ef5ca94a827f07e49378d..103ab4d6b9f3b5c9f41d1407e3cbf4ac392fbf41 100644 +index 3b2ebe839370d96bf93ef5ca94a827f07e49378d..4f8d76be2ca6e4ddc43c68d0a6f0f69979165ab4 100644 --- a/fonts/pixelart-icons-font.css +++ b/fonts/pixelart-icons-font.css @@ -1,16 +1,13 @@ @@ -10,10 +10,11 @@ index 3b2ebe839370d96bf93ef5ca94a827f07e49378d..103ab4d6b9f3b5c9f41d1407e3cbf4ac + 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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60c25f84..5bcd74a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: + mineflayer: github:zardoy/mineflayer#gen-the-master '@nxg-org/mineflayer-physics-util': 1.8.10 buffer: ^6.0.3 vec3: 0.1.10 @@ -12,7 +13,7 @@ overrides: diamond-square: github:zardoy/diamond-square prismarine-block: github:zardoy/prismarine-block#next-era prismarine-world: github:zardoy/prismarine-world#next-era - minecraft-data: 3.89.0 + minecraft-data: 3.98.0 prismarine-provider-anvil: github:zardoy/prismarine-provider-anvil#everything prismarine-physics: github:zardoy/prismarine-physics minecraft-protocol: github:PrismarineJS/node-minecraft-protocol#master @@ -22,13 +23,13 @@ overrides: patchedDependencies: minecraft-protocol: - hash: 1546deaf50efae3d6564fcc9f08da99d3ae8096ac98f420b87b48b8c9501fd37 + hash: 4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b path: patches/minecraft-protocol.patch mineflayer-item-map-downloader@1.2.0: hash: a731ebbace2d8790c973ab3a5ba33494a6e9658533a9710dd8ba36f86db061ad path: patches/mineflayer-item-map-downloader@1.2.0.patch pixelarticons@1.8.1: - hash: d6a3d784047beba873565d1198bed425d9eb2de942e3fc8edac55f25473e4325 + hash: 533230072bc402f425c86abd3d0356fe087b14cab2a254d93f419b083f2d8dfa path: patches/pixelarticons@1.8.1.patch importers: @@ -41,6 +42,9 @@ importers: '@floating-ui/react': specifier: ^0.26.1 version: 0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@monaco-editor/react': + specifier: ^4.7.0 + version: 4.7.0(monaco-editor@0.52.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@nxg-org/mineflayer-auto-jump': specifier: ^0.7.18 version: 0.7.18 @@ -117,8 +121,8 @@ importers: specifier: ^10.0.12 version: 10.1.6 flying-squid: - specifier: npm:@zardoy/flying-squid@^0.0.59 - version: '@zardoy/flying-squid@0.0.59(encoding@0.1.13)' + specifier: npm:@zardoy/flying-squid@^0.0.104 + version: '@zardoy/flying-squid@0.0.104(encoding@0.1.13)' framer-motion: specifier: ^12.9.2 version: 12.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -136,13 +140,13 @@ importers: version: 4.17.21 mcraft-fun-mineflayer: specifier: ^0.1.23 - version: 0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/45f29f9fd1ad6d43d76bbd0b196f2ed4e675772b(encoding@0.1.13)) + version: 0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(encoding@0.1.13)) minecraft-data: - specifier: 3.89.0 - version: 3.89.0 + specifier: 3.98.0 + version: 3.98.0 minecraft-protocol: specifier: github:PrismarineJS/node-minecraft-protocol#master - version: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284(patch_hash=1546deaf50efae3d6564fcc9f08da99d3ae8096ac98f420b87b48b8c9501fd37)(encoding@0.1.13) + version: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) mineflayer-item-map-downloader: specifier: github:zardoy/mineflayer-item-map-downloader version: https://codeload.github.com/zardoy/mineflayer-item-map-downloader/tar.gz/a8d210ecdcf78dd082fa149a96e1612cc9747824(patch_hash=a731ebbace2d8790c973ab3a5ba33494a6e9658533a9710dd8ba36f86db061ad)(encoding@0.1.13) @@ -151,7 +155,7 @@ importers: version: 2.0.4 net-browserify: specifier: github:zardoy/prismarinejs-net-browserify - version: https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/64e351867c5711de8a183464284a8eb7d77d5f39 + version: https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/e754999ffdea67853bc9b10553b5e9908b40f618 node-gzip: specifier: ^1.1.2 version: 1.1.2 @@ -160,13 +164,13 @@ importers: version: 1.5.4 pixelarticons: specifier: ^1.8.1 - version: 1.8.1(patch_hash=d6a3d784047beba873565d1198bed425d9eb2de942e3fc8edac55f25473e4325) + version: 1.8.1(patch_hash=533230072bc402f425c86abd3d0356fe087b14cab2a254d93f419b083f2d8dfa) pretty-bytes: specifier: ^6.1.1 version: 6.1.1 prismarine-provider-anvil: specifier: github:zardoy/prismarine-provider-anvil#everything - version: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.89.0) + version: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.98.0) prosemirror-example-setup: specifier: ^1.2.2 version: 1.2.3 @@ -334,20 +338,17 @@ importers: specifier: ^1.0.0 version: 1.0.0 mc-assets: - specifier: ^0.2.59 - version: 0.2.59 + specifier: ^0.2.62 + version: 0.2.62 minecraft-inventory-gui: specifier: github:zardoy/minecraft-inventory-gui#next - version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/f425e6a2db796d42cd2c09cf715dad841dec643d(@types/react@18.3.18)(react@18.3.1) + version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/89c33d396f3fde4804c71f4be3c203ade1833b41(@types/react@18.3.18)(react@18.3.1) mineflayer: specifier: github:zardoy/mineflayer#gen-the-master - version: https://codeload.github.com/zardoy/mineflayer/tar.gz/45f29f9fd1ad6d43d76bbd0b196f2ed4e675772b(encoding@0.1.13) + version: https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(encoding@0.1.13) mineflayer-mouse: - specifier: ^0.1.10 - version: 0.1.10(@types/debug@4.1.12)(@types/node@22.13.9)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0) - mineflayer-pathfinder: - specifier: ^2.4.4 - version: 2.4.5 + specifier: ^0.1.21 + version: 0.1.21 npm-run-all: specifier: ^4.1.5 version: 4.1.5 @@ -435,7 +436,7 @@ importers: version: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-chunk: specifier: github:zardoy/prismarine-chunk#master - version: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.89.0) + version: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.98.0) prismarine-schematic: specifier: ^1.2.0 version: 1.2.3 @@ -1991,6 +1992,16 @@ packages: '@module-federation/webpack-bundler-runtime@0.11.2': resolution: {integrity: sha512-WdwIE6QF+MKs/PdVu0cKPETF743JB9PZ62/qf7Uo3gU4fjsUMc37RnbJZ/qB60EaHHfjwp1v6NnhZw1r4eVsnw==} + '@monaco-editor/loader@1.5.0': + resolution: {integrity: sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==} + + '@monaco-editor/react@4.7.0': + resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} + peerDependencies: + monaco-editor: '>= 0.25.0 < 1' + react: ^18.2.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@msgpack/msgpack@2.8.0': resolution: {integrity: sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==} engines: {node: '>= 10'} @@ -3308,47 +3319,18 @@ packages: '@vitest/expect@0.34.6': resolution: {integrity: sha512-QUzKpUQRc1qC7qdGo7rMK3AkETI7w18gTCUrsNnyjjJKYiuUB9+TQK3QnR1unhCnWRC0AbKv2omLGQDF/mIjOw==} - '@vitest/expect@3.0.8': - resolution: {integrity: sha512-Xu6TTIavTvSSS6LZaA3EebWFr6tsoXPetOWNMOlc7LO88QVVBwq2oQWBoDiLCN6YTvNYsGSjqOO8CAdjom5DCQ==} - - '@vitest/mocker@3.0.8': - resolution: {integrity: sha512-n3LjS7fcW1BCoF+zWZxG7/5XvuYH+lsFg+BDwwAz0arIwHQJFUEsKBQ0BLU49fCxuM/2HSeBPHQD8WjgrxMfow==} - peerDependencies: - msw: ^2.4.9 - vite: ^5.0.0 || ^6.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/pretty-format@3.0.8': - resolution: {integrity: sha512-BNqwbEyitFhzYMYHUVbIvepOyeQOSFA/NeJMIP9enMntkkxLgOcgABH6fjyXG85ipTgvero6noreavGIqfJcIg==} - '@vitest/runner@0.34.6': resolution: {integrity: sha512-1CUQgtJSLF47NnhN+F9X2ycxUP0kLHQ/JWvNHbeBfwW8CzEGgeskzNnHDyv1ieKTltuR6sdIHV+nmR6kPxQqzQ==} - '@vitest/runner@3.0.8': - resolution: {integrity: sha512-c7UUw6gEcOzI8fih+uaAXS5DwjlBaCJUo7KJ4VvJcjL95+DSR1kova2hFuRt3w41KZEFcOEiq098KkyrjXeM5w==} - '@vitest/snapshot@0.34.6': resolution: {integrity: sha512-B3OZqYn6k4VaN011D+ve+AA4whM4QkcwcrwaKwAbyyvS/NB1hCWjFIBQxAQQSQir9/RtyAAGuq+4RJmbn2dH4w==} - '@vitest/snapshot@3.0.8': - resolution: {integrity: sha512-x8IlMGSEMugakInj44nUrLSILh/zy1f2/BgH0UeHpNyOocG18M9CWVIFBaXPt8TrqVZWmcPjwfG/ht5tnpba8A==} - '@vitest/spy@0.34.6': resolution: {integrity: sha512-xaCvneSaeBw/cz8ySmF7ZwGvL0lBjfvqc1LpQ/vcdHEvpLn3Ff1vAvjw+CoGn0802l++5L/pxb7whwcWAw+DUQ==} - '@vitest/spy@3.0.8': - resolution: {integrity: sha512-MR+PzJa+22vFKYb934CejhR4BeRpMSoxkvNoDit68GQxRLSf11aT6CTj3XaqUU9rxgWJFnqicN/wxw6yBRkI1Q==} - '@vitest/utils@0.34.6': resolution: {integrity: sha512-IG5aDD8S6zlvloDsnzHw0Ut5xczlF+kv2BOTo+iXfPr54Yhi5qbVOgGB1hZaVq4iJ4C/MZ2J0y15IlsV/ZcI0A==} - '@vitest/utils@3.0.8': - resolution: {integrity: sha512-nkBC3aEhfX2PdtQI/QwAWp8qZWwzASsU4Npbcd5RdMPBSSLCpkZp52P3xku3s3uA0HIEhGvEcF8rNkBsz9dQ4Q==} - '@xboxreplay/errors@0.1.0': resolution: {integrity: sha512-Tgz1d/OIPDWPeyOvuL5+aai5VCcqObhPnlI3skQuf80GVF3k1I0lPCnGC+8Cm5PV9aLBT5m8qPcJoIUQ2U4y9g==} @@ -3405,13 +3387,13 @@ packages: resolution: {integrity: sha512-6xm38yGVIa6mKm/DUCF2zFFJhERh/QWp1ufm4cNUvxsONBmfPg8uZ9pZBdOmF6qFGr/HlT6ABBkCSx/dlEtvWg==} engines: {node: '>=12 <14 || 14.2 - 14.9 || >14.10.0'} - '@zardoy/flying-squid@0.0.49': - resolution: {integrity: sha512-Kt4wr5/R+44tcLU9gjuNG2an9weWeKEpIoKXfsgJN2GGQqdnbd5nBpxfGDdgZ9aMdFugsVW8BsyPZNhj9vbMXA==} + '@zardoy/flying-squid@0.0.104': + resolution: {integrity: sha512-jGhQ7fn7o8UN+mUwZbt9674D37YLuBi+Au4TwKcopCA6huIQdHTFNl2e+0ZSTI5mnhN+NpyVoR3vmtH6L58vHQ==} engines: {node: '>=8'} hasBin: true - '@zardoy/flying-squid@0.0.59': - resolution: {integrity: sha512-Ztrmv127csGovqJEWEtT19y1wGEB5tIVfneQ3+p/TirP/bTGYpLlW+Ns4sSAc4KrewUP9PW/6L0AtB69CWhQFQ==} + '@zardoy/flying-squid@0.0.49': + resolution: {integrity: sha512-Kt4wr5/R+44tcLU9gjuNG2an9weWeKEpIoKXfsgJN2GGQqdnbd5nBpxfGDdgZ9aMdFugsVW8BsyPZNhj9vbMXA==} engines: {node: '>=8'} hasBin: true @@ -3672,10 +3654,6 @@ packages: assertion-error@1.1.0: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} - assign-symbols@1.0.0: resolution: {integrity: sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==} engines: {node: '>=0.10.0'} @@ -4012,10 +3990,6 @@ packages: resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} engines: {node: '>=4'} - chai@5.2.0: - resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} - engines: {node: '>=12'} - chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -4043,10 +4017,6 @@ packages: check-error@1.0.3: resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} - check-error@2.1.1: - resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} - engines: {node: '>= 16'} - check-more-types@2.24.0: resolution: {integrity: sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==} engines: {node: '>= 0.8.0'} @@ -4472,10 +4442,6 @@ packages: resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} engines: {node: '>=6'} - deep-eql@5.0.2: - resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} - engines: {node: '>=6'} - deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -4796,9 +4762,6 @@ packages: es-module-lexer@0.9.3: resolution: {integrity: sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==} - es-module-lexer@1.6.0: - resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==} - es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -5045,9 +5008,6 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -5103,10 +5063,6 @@ packages: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} - expect-type@1.2.0: - resolution: {integrity: sha512-80F22aiJ3GLyVnS/B3HzgR6RelZVumzj9jkL0Rhz4h0xYbNW9PjlQz5h3J/SShErbXBc295vseR4/MIbVmUbeA==} - engines: {node: '>=12.0.0'} - exponential-backoff@3.1.2: resolution: {integrity: sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==} @@ -6403,9 +6359,6 @@ packages: loupe@2.3.7: resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} - loupe@3.1.3: - resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} - lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -6487,10 +6440,16 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - mc-assets@0.2.59: - resolution: {integrity: sha512-HGdy6v09X5nks8+NuwrL3KQ763D+eWFeSpWLXx3+doWz6hSEeLjgBPOrB1stvQOjPDiQCzsIv5gaRB5sl6ng1A==} + mc-assets@0.2.62: + resolution: {integrity: sha512-RYZeD1+joNlPuUpi+tIWkbP0ieVJr+R6IFkI6/8juhSxx9zE4osoSmteybrfspGm8A6u+YbbY1epqRKEMwVR6Q==} engines: {node: '>=18.0.0'} + mc-bridge@0.1.3: + resolution: {integrity: sha512-H9jPt2xEU77itC27dSz3qazHYqN9qVsv4HgMPozg7RqQ1uwgXmEa+ojKIlDtXf/TLJsG6Kv4EbzGa8a1Wh72uA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + minecraft-data: 3.98.0 + mcraft-fun-mineflayer@0.1.23: resolution: {integrity: sha512-qmI1rQQ0Ro5zJdi99rClWLF+mS9JZffgNX2vyWWesS3Hsk3Xblp/8swYTJKHSaFpNgzkVfXV92fEIrBqeH6iKA==} version: 0.1.23 @@ -6699,19 +6658,19 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} - minecraft-data@3.89.0: - resolution: {integrity: sha512-v6dUr1M7Pjc6N4ujanrBZu3IP4/HbSBpxSSXNbK6HVFVJqfaqKSMXN57G/JAlDcwqXYsVd9H4tbKFHCO+VmQpg==} + minecraft-data@3.98.0: + resolution: {integrity: sha512-JAPqJ/TZoxMUlAPPdWUh1v5wdqvYGFSZ4rW9bUtmaKBkGpomDSjw4V02ocBqbxKJvcTtmc5nM/LfN9/0DDqHrQ==} minecraft-folder-path@1.2.0: resolution: {integrity: sha512-qaUSbKWoOsH9brn0JQuBhxNAzTDMwrOXorwuRxdJKKKDYvZhtml+6GVCUrY5HRiEsieBEjCUnhVpDuQiKsiFaw==} - minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/f425e6a2db796d42cd2c09cf715dad841dec643d: - resolution: {tarball: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/f425e6a2db796d42cd2c09cf715dad841dec643d} + minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/89c33d396f3fde4804c71f4be3c203ade1833b41: + resolution: {tarball: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/89c33d396f3fde4804c71f4be3c203ade1833b41} version: 1.0.1 - minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284: - resolution: {tarball: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284} - version: 1.57.0 + minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9: + resolution: {tarball: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9} + version: 1.62.0 engines: {node: '>=22'} minecraft-wrap@1.6.0: @@ -6725,20 +6684,13 @@ packages: resolution: {tarball: https://codeload.github.com/zardoy/mineflayer-item-map-downloader/tar.gz/a8d210ecdcf78dd082fa149a96e1612cc9747824} version: 1.2.0 - mineflayer-mouse@0.1.10: - resolution: {integrity: sha512-bxrBzOVQX2Ok5KOiJMaoOLYaHMDecMkJpLn5oGIdyLRqiTF3WAnZc9oYQq+/f3YNA88W1vBIjai3v3vZe6wyaQ==} + mineflayer-mouse@0.1.21: + resolution: {integrity: sha512-1XTVuw3twIrEcqQ1QRSB8NcStIUEZ+tbxiAG6rOrN/9M4thhtlS5PTJzFdmdrcYgWEBLvuOdJszaKE5zFfiXhg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - mineflayer-pathfinder@2.4.5: - resolution: {integrity: sha512-Jh3JnUgRLwhMh2Dugo4SPza68C41y+NPP5sdsgxRu35ydndo70i1JJGxauVWbXrpNwIxYNztUw78aFyb7icw8g==} - - mineflayer@4.27.0: - resolution: {integrity: sha512-3bxph4jfbkBh5HpeouorxzrfSLNV+i+1gugNJ2jf52HW+rt+tW7eiiFPxrJEsOVkPT/3O/dEIW7j93LRlojMkQ==} - engines: {node: '>=22'} - - mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/45f29f9fd1ad6d43d76bbd0b196f2ed4e675772b: - resolution: {tarball: https://codeload.github.com/zardoy/mineflayer/tar.gz/45f29f9fd1ad6d43d76bbd0b196f2ed4e675772b} - version: 4.27.0 + mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659: + resolution: {tarball: https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659} + version: 8.0.0 engines: {node: '>=22'} minimalistic-assert@1.0.1: @@ -6836,6 +6788,9 @@ packages: mojangson@2.0.4: resolution: {integrity: sha512-HYmhgDjr1gzF7trGgvcC/huIg2L8FsVbi/KacRe6r1AswbboGVZDS47SOZlomPuMWvZLas8m9vuHHucdZMwTmQ==} + monaco-editor@0.52.2: + resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==} + moo@0.5.2: resolution: {integrity: sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==} @@ -6900,8 +6855,8 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - net-browserify@https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/64e351867c5711de8a183464284a8eb7d77d5f39: - resolution: {tarball: https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/64e351867c5711de8a183464284a8eb7d77d5f39} + net-browserify@https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/e754999ffdea67853bc9b10553b5e9908b40f618: + resolution: {tarball: https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/e754999ffdea67853bc9b10553b5e9908b40f618} version: 0.2.4 nice-try@1.0.5: @@ -7271,10 +7226,6 @@ packages: pathval@1.1.1: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} - pathval@2.0.0: - resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} - engines: {node: '>= 14.16'} - pause-stream@0.0.11: resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} @@ -7436,7 +7387,7 @@ packages: prismarine-biome@1.3.0: resolution: {integrity: sha512-GY6nZxq93mTErT7jD7jt8YS1aPrOakbJHh39seYsJFXvueIOdHAmW16kYQVrTVMW5MlWLQVxV/EquRwOgr4MnQ==} peerDependencies: - minecraft-data: 3.89.0 + minecraft-data: 3.98.0 prismarine-registry: ^1.1.0 prismarine-block@https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9: @@ -7454,8 +7405,8 @@ packages: prismarine-entity@2.5.0: resolution: {integrity: sha512-nRPCawUwf9r3iKqi4I7mZRlir1Ix+DffWYdWq6p/KNnmiXve+xHE5zv8XCdhZlUmOshugHv5ONl9o6ORAkCNIA==} - prismarine-item@1.16.0: - resolution: {integrity: sha512-88Tz+/6HquYIsDuseae5G3IbqLeMews2L+ba2gX+p6K6soU9nuFhCfbwN56QuB7d/jZFcWrCYAPE5+UhwWh67w==} + prismarine-item@1.17.0: + resolution: {integrity: sha512-wN1OjP+f+Uvtjo3KzeCkVSy96CqZ8yG7cvuvlGwcYupQ6ct7LtNkubHp0AHuLMJ0vbbfAC0oZ2bWOgI1DYp8WA==} prismarine-nbt@2.7.0: resolution: {integrity: sha512-Du9OLQAcCj3y29YtewOJbbV4ARaSUEJiTguw0PPQbPBy83f+eCyDRkyBpnXTi/KPyEpgYCzsjGzElevLpFoYGQ==} @@ -8386,6 +8337,7 @@ packages: source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions sourcemap-codec@1.4.8: resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} @@ -8443,6 +8395,9 @@ packages: stacktrace-js@2.0.2: resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==} + state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + static-extend@0.1.2: resolution: {integrity: sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==} engines: {node: '>=0.10.0'} @@ -8716,22 +8671,10 @@ packages: resolution: {integrity: sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww==} engines: {node: '>=14.0.0'} - tinypool@1.0.2: - resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} - engines: {node: ^18.0.0 || >=20.0.0} - - tinyrainbow@2.0.0: - resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} - engines: {node: '>=14.0.0'} - tinyspy@2.2.1: resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} engines: {node: '>=14.0.0'} - tinyspy@3.0.2: - resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} - engines: {node: '>=14.0.0'} - title-case@3.0.3: resolution: {integrity: sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==} @@ -9218,11 +9161,6 @@ packages: engines: {node: '>=v14.18.0'} hasBin: true - vite-node@3.0.8: - resolution: {integrity: sha512-6PhR4H9VGlcwXZ+KWCdMqbtG649xCPZqfI9j2PsK1FcXgEzro5bGHcVKFCTqPLaNKZES8Evqv4LwvZARsq5qlg==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - vite@4.5.9: resolution: {integrity: sha512-qK9W4xjgD3gXbC0NmdNFFnVFLMWSNiR3swj957yutwzzN16xF/E7nmtAyp1rT9hviDroQANjE4HK3H4WqWdFtw==} engines: {node: ^14.18.0 || >=16.0.0} @@ -9322,34 +9260,6 @@ packages: webdriverio: optional: true - vitest@3.0.8: - resolution: {integrity: sha512-dfqAsNqRGUc8hB9OVR2P0w8PZPEckti2+5rdZip0WIz9WW0MnImJ8XiR61QhqLa92EQzKP2uPkzenKOAHyEIbA==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@types/debug': ^4.1.12 - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.0.8 - '@vitest/ui': 3.0.8 - happy-dom: '*' - jsdom: '*' - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@types/debug': - optional: true - '@types/node': - optional: true - '@vitest/browser': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - vm-browserify@1.1.2: resolution: {integrity: sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==} @@ -9775,7 +9685,7 @@ snapshots: '@babel/core': 7.26.9 '@babel/helper-compilation-targets': 7.26.5 '@babel/helper-plugin-utils': 7.26.5 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1 lodash.debounce: 4.0.8 resolve: 1.22.10 transitivePeerDependencies: @@ -10400,7 +10310,7 @@ snapshots: '@babel/parser': 7.26.9 '@babel/template': 7.26.9 '@babel/types': 7.26.9 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -11369,6 +11279,17 @@ snapshots: '@module-federation/runtime': 0.11.2 '@module-federation/sdk': 0.11.2 + '@monaco-editor/loader@1.5.0': + dependencies: + state-local: 1.0.7 + + '@monaco-editor/react@4.7.0(monaco-editor@0.52.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@monaco-editor/loader': 1.5.0 + monaco-editor: 0.52.2 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@msgpack/msgpack@2.8.0': {} '@ndelangen/get-tarball@3.0.9': @@ -11422,10 +11343,10 @@ snapshots: '@nxg-org/mineflayer-trajectories@1.2.0(encoding@0.1.13)': dependencies: '@nxg-org/mineflayer-util-plugin': 1.8.4 - minecraft-data: 3.89.0 - mineflayer: 4.27.0(encoding@0.1.13) + minecraft-data: 3.98.0 + mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(encoding@0.1.13) prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 - prismarine-item: 1.16.0 + prismarine-item: 1.17.0 prismarine-physics: https://codeload.github.com/zardoy/prismarine-physics/tar.gz/353e25b800149393f40539ec381218be44cbb03b vec3: 0.1.10 transitivePeerDependencies: @@ -12970,7 +12891,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 6.1.0(typescript@5.5.4) '@typescript-eslint/utils': 6.1.0(eslint@8.57.1)(typescript@5.5.4) - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1 eslint: 8.57.1 ts-api-utils: 1.4.3(typescript@5.5.4) optionalDependencies: @@ -12988,7 +12909,7 @@ snapshots: dependencies: '@typescript-eslint/types': 6.1.0 '@typescript-eslint/visitor-keys': 6.1.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1 globby: 11.1.0 is-glob: 4.0.3 semver: 7.7.1 @@ -13002,7 +12923,7 @@ snapshots: dependencies: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 @@ -13017,7 +12938,7 @@ snapshots: dependencies: '@typescript-eslint/types': 8.26.0 '@typescript-eslint/visitor-keys': 8.26.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1 fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 @@ -13086,68 +13007,28 @@ snapshots: '@vitest/utils': 0.34.6 chai: 4.5.0 - '@vitest/expect@3.0.8': - dependencies: - '@vitest/spy': 3.0.8 - '@vitest/utils': 3.0.8 - chai: 5.2.0 - tinyrainbow: 2.0.0 - - '@vitest/mocker@3.0.8(vite@6.2.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0))': - dependencies: - '@vitest/spy': 3.0.8 - estree-walker: 3.0.3 - magic-string: 0.30.17 - optionalDependencies: - vite: 6.2.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0) - - '@vitest/pretty-format@3.0.8': - dependencies: - tinyrainbow: 2.0.0 - '@vitest/runner@0.34.6': dependencies: '@vitest/utils': 0.34.6 p-limit: 4.0.0 pathe: 1.1.2 - '@vitest/runner@3.0.8': - dependencies: - '@vitest/utils': 3.0.8 - pathe: 2.0.3 - '@vitest/snapshot@0.34.6': dependencies: magic-string: 0.30.17 pathe: 1.1.2 pretty-format: 29.7.0 - '@vitest/snapshot@3.0.8': - dependencies: - '@vitest/pretty-format': 3.0.8 - magic-string: 0.30.17 - pathe: 2.0.3 - '@vitest/spy@0.34.6': dependencies: tinyspy: 2.2.1 - '@vitest/spy@3.0.8': - dependencies: - tinyspy: 3.0.2 - '@vitest/utils@0.34.6': dependencies: diff-sequences: 29.6.3 loupe: 2.3.7 pretty-format: 29.7.0 - '@vitest/utils@3.0.8': - dependencies: - '@vitest/pretty-format': 3.0.8 - loupe: 3.1.3 - tinyrainbow: 2.0.0 - '@xboxreplay/errors@0.1.0': {} '@xboxreplay/xboxlive-auth@3.3.3(debug@4.4.0)': @@ -13213,7 +13094,7 @@ snapshots: '@types/emscripten': 1.40.0 tslib: 1.14.1 - '@zardoy/flying-squid@0.0.49(encoding@0.1.13)': + '@zardoy/flying-squid@0.0.104(encoding@0.1.13)': dependencies: '@tootallnate/once': 2.0.0 chalk: 5.4.1 @@ -13223,16 +13104,18 @@ snapshots: exit-hook: 2.2.1 flatmap: 0.0.3 long: 5.3.1 - minecraft-data: 3.89.0 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284(patch_hash=1546deaf50efae3d6564fcc9f08da99d3ae8096ac98f420b87b48b8c9501fd37)(encoding@0.1.13) + mc-bridge: 0.1.3(minecraft-data@3.98.0) + minecraft-data: 3.98.0 + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) mkdirp: 2.1.6 node-gzip: 1.1.2 node-rsa: 1.1.1 - prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.89.0) + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 + prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.98.0) prismarine-entity: 2.5.0 - prismarine-item: 1.16.0 + prismarine-item: 1.17.0 prismarine-nbt: 2.7.0 - prismarine-provider-anvil: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.89.0) + prismarine-provider-anvil: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.98.0) prismarine-windows: 2.9.0 prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c rambda: 9.4.2 @@ -13249,7 +13132,7 @@ snapshots: - encoding - supports-color - '@zardoy/flying-squid@0.0.59(encoding@0.1.13)': + '@zardoy/flying-squid@0.0.49(encoding@0.1.13)': dependencies: '@tootallnate/once': 2.0.0 chalk: 5.4.1 @@ -13259,16 +13142,16 @@ snapshots: exit-hook: 2.2.1 flatmap: 0.0.3 long: 5.3.1 - minecraft-data: 3.89.0 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284(patch_hash=1546deaf50efae3d6564fcc9f08da99d3ae8096ac98f420b87b48b8c9501fd37)(encoding@0.1.13) + minecraft-data: 3.98.0 + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) mkdirp: 2.1.6 node-gzip: 1.1.2 node-rsa: 1.1.1 - prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.89.0) + prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.98.0) prismarine-entity: 2.5.0 - prismarine-item: 1.16.0 + prismarine-item: 1.17.0 prismarine-nbt: 2.7.0 - prismarine-provider-anvil: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.89.0) + prismarine-provider-anvil: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.98.0) prismarine-windows: 2.9.0 prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c rambda: 9.4.2 @@ -13346,7 +13229,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1 transitivePeerDependencies: - supports-color optional: true @@ -13562,8 +13445,6 @@ snapshots: assertion-error@1.1.0: {} - assertion-error@2.0.1: {} - assign-symbols@1.0.0: {} ast-types@0.16.1: @@ -13995,14 +13876,6 @@ snapshots: pathval: 1.1.1 type-detect: 4.1.0 - chai@5.2.0: - dependencies: - assertion-error: 2.0.1 - check-error: 2.1.1 - deep-eql: 5.0.2 - loupe: 3.1.3 - pathval: 2.0.0 - chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -14041,8 +13914,6 @@ snapshots: dependencies: get-func-name: 2.0.2 - check-error@2.1.1: {} - check-more-types@2.24.0: optional: true @@ -14557,8 +14428,6 @@ snapshots: dependencies: type-detect: 4.1.0 - deep-eql@5.0.2: {} - deep-extend@0.6.0: {} deep-is@0.1.4: {} @@ -14663,7 +14532,7 @@ snapshots: detect-port@1.6.1: dependencies: address: 1.2.2 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1 transitivePeerDependencies: - supports-color @@ -14673,8 +14542,8 @@ snapshots: diamond-square@https://codeload.github.com/zardoy/diamond-square/tar.gz/cfaad2d1d5909fdfa63c8cc7bc05fb5e87782d71: dependencies: - minecraft-data: 3.89.0 - prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.89.0) + minecraft-data: 3.98.0 + prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.98.0) prismarine-registry: 1.11.0 random-seed: 0.3.0 vec3: 0.1.10 @@ -15056,8 +14925,6 @@ snapshots: es-module-lexer@0.9.3: {} - es-module-lexer@1.6.0: {} - es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -15440,10 +15307,6 @@ snapshots: estree-walker@2.0.2: {} - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.6 - esutils@2.0.3: {} etag@1.8.1: {} @@ -15521,8 +15384,6 @@ snapshots: expand-template@2.0.3: {} - expect-type@1.2.0: {} - exponential-backoff@3.1.2: optional: true @@ -16278,14 +16139,14 @@ snapshots: https-proxy-agent@4.0.0: dependencies: agent-base: 5.1.1 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1 transitivePeerDependencies: - supports-color https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1 transitivePeerDependencies: - supports-color optional: true @@ -17024,8 +16885,6 @@ snapshots: dependencies: get-func-name: 2.0.2 - loupe@3.1.3: {} - lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -17122,18 +16981,22 @@ snapshots: math-intrinsics@1.1.0: {} - mc-assets@0.2.59: + mc-assets@0.2.62: dependencies: maxrects-packer: '@zardoy/maxrects-packer@2.7.4' zod: 3.24.2 - mcraft-fun-mineflayer@0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/45f29f9fd1ad6d43d76bbd0b196f2ed4e675772b(encoding@0.1.13)): + mc-bridge@0.1.3(minecraft-data@3.98.0): + dependencies: + minecraft-data: 3.98.0 + + mcraft-fun-mineflayer@0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(encoding@0.1.13)): dependencies: '@zardoy/flying-squid': 0.0.49(encoding@0.1.13) exit-hook: 2.2.1 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284(patch_hash=1546deaf50efae3d6564fcc9f08da99d3ae8096ac98f420b87b48b8c9501fd37)(encoding@0.1.13) - mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/45f29f9fd1ad6d43d76bbd0b196f2ed4e675772b(encoding@0.1.13) - prismarine-item: 1.16.0 + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) + mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(encoding@0.1.13) + prismarine-item: 1.17.0 ws: 8.18.1 transitivePeerDependencies: - bufferutil @@ -17362,7 +17225,7 @@ snapshots: micromark@4.0.2: dependencies: '@types/debug': 4.1.12 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1 decode-named-character-reference: 1.1.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -17439,18 +17302,18 @@ snapshots: min-indent@1.0.1: {} - minecraft-data@3.89.0: {} + minecraft-data@3.98.0: {} minecraft-folder-path@1.2.0: {} - minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/f425e6a2db796d42cd2c09cf715dad841dec643d(@types/react@18.3.18)(react@18.3.1): + minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/89c33d396f3fde4804c71f4be3c203ade1833b41(@types/react@18.3.18)(react@18.3.1): dependencies: valtio: 1.13.2(@types/react@18.3.18)(react@18.3.1) transitivePeerDependencies: - '@types/react' - react - minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284(patch_hash=1546deaf50efae3d6564fcc9f08da99d3ae8096ac98f420b87b48b8c9501fd37)(encoding@0.1.13): + minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13): dependencies: '@types/node-rsa': 1.1.4 '@types/readable-stream': 4.0.18 @@ -17459,7 +17322,7 @@ snapshots: debug: 4.4.0(supports-color@8.1.1) endian-toggle: 0.0.0 lodash.merge: 4.6.2 - minecraft-data: 3.89.0 + minecraft-data: 3.98.0 minecraft-folder-path: 1.2.0 node-fetch: 2.7.0(encoding@0.1.13) node-rsa: 0.4.2 @@ -17502,84 +17365,32 @@ snapshots: mineflayer-item-map-downloader@https://codeload.github.com/zardoy/mineflayer-item-map-downloader/tar.gz/a8d210ecdcf78dd082fa149a96e1612cc9747824(patch_hash=a731ebbace2d8790c973ab3a5ba33494a6e9658533a9710dd8ba36f86db061ad)(encoding@0.1.13): dependencies: - mineflayer: 4.27.0(encoding@0.1.13) + mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(encoding@0.1.13) sharp: 0.30.7 transitivePeerDependencies: - encoding - supports-color - mineflayer-mouse@0.1.10(@types/debug@4.1.12)(@types/node@22.13.9)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0): + mineflayer-mouse@0.1.21: dependencies: change-case: 5.4.4 - debug: 4.4.0(supports-color@8.1.1) - prismarine-item: 1.16.0 + debug: 4.4.1 + prismarine-item: 1.17.0 prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c - vitest: 3.0.8(@types/debug@4.1.12)(@types/node@22.13.9)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0) transitivePeerDependencies: - - '@edge-runtime/vm' - - '@types/debug' - - '@types/node' - - '@vitest/browser' - - '@vitest/ui' - - happy-dom - - jiti - - jsdom - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - - mineflayer-pathfinder@2.4.5: - dependencies: - minecraft-data: 3.89.0 - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 - prismarine-entity: 2.5.0 - prismarine-item: 1.16.0 - prismarine-nbt: 2.7.0 - prismarine-physics: https://codeload.github.com/zardoy/prismarine-physics/tar.gz/353e25b800149393f40539ec381218be44cbb03b - vec3: 0.1.10 - - mineflayer@4.27.0(encoding@0.1.13): - dependencies: - minecraft-data: 3.89.0 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284(patch_hash=1546deaf50efae3d6564fcc9f08da99d3ae8096ac98f420b87b48b8c9501fd37)(encoding@0.1.13) - prismarine-biome: 1.3.0(minecraft-data@3.89.0)(prismarine-registry@1.11.0) - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 - prismarine-chat: 1.11.0 - prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.89.0) - prismarine-entity: 2.5.0 - prismarine-item: 1.16.0 - prismarine-nbt: 2.7.0 - prismarine-physics: https://codeload.github.com/zardoy/prismarine-physics/tar.gz/353e25b800149393f40539ec381218be44cbb03b - prismarine-recipe: 1.3.1(prismarine-registry@1.11.0) - prismarine-registry: 1.11.0 - prismarine-windows: 2.9.0 - prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c - protodef: 1.18.0 - typed-emitter: 1.4.0 - vec3: 0.1.10 - transitivePeerDependencies: - - encoding - supports-color - mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/45f29f9fd1ad6d43d76bbd0b196f2ed4e675772b(encoding@0.1.13): + mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(encoding@0.1.13): dependencies: '@nxg-org/mineflayer-physics-util': 1.8.10 - minecraft-data: 3.89.0 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284(patch_hash=1546deaf50efae3d6564fcc9f08da99d3ae8096ac98f420b87b48b8c9501fd37)(encoding@0.1.13) - prismarine-biome: 1.3.0(minecraft-data@3.89.0)(prismarine-registry@1.11.0) + minecraft-data: 3.98.0 + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) + prismarine-biome: 1.3.0(minecraft-data@3.98.0)(prismarine-registry@1.11.0) prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-chat: 1.11.0 - prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.89.0) + prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.98.0) prismarine-entity: 2.5.0 - prismarine-item: 1.16.0 + prismarine-item: 1.17.0 prismarine-nbt: 2.7.0 prismarine-physics: https://codeload.github.com/zardoy/prismarine-physics/tar.gz/353e25b800149393f40539ec381218be44cbb03b prismarine-recipe: 1.3.1(prismarine-registry@1.11.0) @@ -17692,6 +17503,8 @@ snapshots: dependencies: nearley: 2.20.1 + monaco-editor@0.52.2: {} + moo@0.5.2: {} morgan@1.10.0: @@ -17773,7 +17586,7 @@ snapshots: neo-async@2.6.2: {} - net-browserify@https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/64e351867c5711de8a183464284a8eb7d77d5f39: + net-browserify@https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/e754999ffdea67853bc9b10553b5e9908b40f618: dependencies: body-parser: 1.20.3 express: 4.21.2 @@ -18202,8 +18015,6 @@ snapshots: pathval@1.1.1: {} - pathval@2.0.0: {} - pause-stream@0.0.11: dependencies: through: 2.3.8 @@ -18263,7 +18074,7 @@ snapshots: pirates@4.0.6: {} - pixelarticons@1.8.1(patch_hash=d6a3d784047beba873565d1198bed425d9eb2de942e3fc8edac55f25473e4325): {} + pixelarticons@1.8.1(patch_hash=533230072bc402f425c86abd3d0356fe087b14cab2a254d93f419b083f2d8dfa): {} pixelmatch@4.0.2: dependencies: @@ -18363,17 +18174,17 @@ snapshots: transitivePeerDependencies: - supports-color - prismarine-biome@1.3.0(minecraft-data@3.89.0)(prismarine-registry@1.11.0): + prismarine-biome@1.3.0(minecraft-data@3.98.0)(prismarine-registry@1.11.0): dependencies: - minecraft-data: 3.89.0 + minecraft-data: 3.98.0 prismarine-registry: 1.11.0 prismarine-block@https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9: dependencies: - minecraft-data: 3.89.0 - prismarine-biome: 1.3.0(minecraft-data@3.89.0)(prismarine-registry@1.11.0) + minecraft-data: 3.98.0 + prismarine-biome: 1.3.0(minecraft-data@3.98.0)(prismarine-registry@1.11.0) prismarine-chat: 1.11.0 - prismarine-item: 1.16.0 + prismarine-item: 1.17.0 prismarine-nbt: 2.7.0 prismarine-registry: 1.11.0 @@ -18383,9 +18194,9 @@ snapshots: prismarine-nbt: 2.7.0 prismarine-registry: 1.11.0 - prismarine-chunk@https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.89.0): + prismarine-chunk@https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.98.0): dependencies: - prismarine-biome: 1.3.0(minecraft-data@3.89.0)(prismarine-registry@1.11.0) + prismarine-biome: 1.3.0(minecraft-data@3.98.0)(prismarine-registry@1.11.0) prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-nbt: 2.7.0 prismarine-registry: 1.11.0 @@ -18399,11 +18210,11 @@ snapshots: prismarine-entity@2.5.0: dependencies: prismarine-chat: 1.11.0 - prismarine-item: 1.16.0 + prismarine-item: 1.17.0 prismarine-registry: 1.11.0 vec3: 0.1.10 - prismarine-item@1.16.0: + prismarine-item@1.17.0: dependencies: prismarine-nbt: 2.7.0 prismarine-registry: 1.11.0 @@ -18414,14 +18225,14 @@ snapshots: prismarine-physics@https://codeload.github.com/zardoy/prismarine-physics/tar.gz/353e25b800149393f40539ec381218be44cbb03b: dependencies: - minecraft-data: 3.89.0 + minecraft-data: 3.98.0 prismarine-nbt: 2.7.0 vec3: 0.1.10 - prismarine-provider-anvil@https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.89.0): + prismarine-provider-anvil@https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.98.0): dependencies: prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 - prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.89.0) + prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.98.0) prismarine-nbt: 2.7.0 prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c uint4: 0.1.2 @@ -18443,13 +18254,13 @@ snapshots: prismarine-registry@1.11.0: dependencies: - minecraft-data: 3.89.0 + minecraft-data: 3.98.0 prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-nbt: 2.7.0 prismarine-schematic@1.2.3: dependencies: - minecraft-data: 3.89.0 + minecraft-data: 3.98.0 prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-nbt: 2.7.0 prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c @@ -18457,7 +18268,7 @@ snapshots: prismarine-windows@2.9.0: dependencies: - prismarine-item: 1.16.0 + prismarine-item: 1.17.0 prismarine-registry: 1.11.0 typed-emitter: 2.1.0 @@ -18648,7 +18459,7 @@ snapshots: puppeteer-core@2.1.1: dependencies: '@types/mime-types': 2.1.4 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1 extract-zip: 1.7.0 https-proxy-agent: 4.0.0 mime: 2.6.0 @@ -19750,6 +19561,8 @@ snapshots: stack-generator: 2.0.10 stacktrace-gps: 3.1.2 + state-local@1.0.7: {} + static-extend@0.1.2: dependencies: define-property: 0.2.5 @@ -20064,14 +19877,8 @@ snapshots: tinypool@0.7.0: {} - tinypool@1.0.2: {} - - tinyrainbow@2.0.0: {} - tinyspy@2.2.1: {} - tinyspy@3.0.2: {} - title-case@3.0.3: dependencies: tslib: 2.8.1 @@ -20560,27 +20367,6 @@ snapshots: - supports-color - terser - vite-node@3.0.8(@types/node@22.13.9)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0): - dependencies: - cac: 6.7.14 - debug: 4.4.1 - es-module-lexer: 1.6.0 - pathe: 2.0.3 - vite: 6.2.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0) - transitivePeerDependencies: - - '@types/node' - - jiti - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - vite@4.5.9(@types/node@22.13.9)(terser@5.39.0): dependencies: esbuild: 0.18.20 @@ -20639,45 +20425,6 @@ snapshots: - supports-color - terser - vitest@3.0.8(@types/debug@4.1.12)(@types/node@22.13.9)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0): - dependencies: - '@vitest/expect': 3.0.8 - '@vitest/mocker': 3.0.8(vite@6.2.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0)) - '@vitest/pretty-format': 3.0.8 - '@vitest/runner': 3.0.8 - '@vitest/snapshot': 3.0.8 - '@vitest/spy': 3.0.8 - '@vitest/utils': 3.0.8 - chai: 5.2.0 - debug: 4.4.1 - expect-type: 1.2.0 - magic-string: 0.30.17 - pathe: 2.0.3 - std-env: 3.8.1 - tinybench: 2.9.0 - tinyexec: 0.3.2 - tinypool: 1.0.2 - tinyrainbow: 2.0.0 - vite: 6.2.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0) - vite-node: 3.0.8(@types/node@22.13.9)(jiti@2.4.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/debug': 4.1.12 - '@types/node': 22.13.9 - transitivePeerDependencies: - - jiti - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - vm-browserify@1.1.2: {} w3c-keyname@2.2.8: {} diff --git a/renderer/playground/shared.ts b/renderer/playground/shared.ts index ba58a57f..9d12fae9 100644 --- a/renderer/playground/shared.ts +++ b/renderer/playground/shared.ts @@ -65,7 +65,7 @@ function getAllMethods (obj) { return [...methods] as string[] } -export const delayedIterator = async (arr: T[], delay: number, exec: (item: T, index: number) => void, chunkSize = 1) => { +export const delayedIterator = async (arr: T[], delay: number, exec: (item: T, index: number) => Promise, 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 (arr: T[], delay: number, exec: (item: setTimeout(resolve, delay) }) } - exec(arr[i], i) + await exec(arr[i], i) } } diff --git a/renderer/rsbuildSharedConfig.ts b/renderer/rsbuildSharedConfig.ts index 57091d99..45da30b1 100644 --- a/renderer/rsbuildSharedConfig.ts +++ b/renderer/rsbuildSharedConfig.ts @@ -73,12 +73,12 @@ export const appAndRendererSharedConfig = () => defineConfig({ }) export const rspackViewerConfig = (config, { appendPlugins, addRules, rspack }: ModifyRspackConfigUtils) => { - appendPlugins(new rspack.NormalModuleReplacementPlugin(/data/, (resource) => { + appendPlugins(new rspack.NormalModuleReplacementPlugin(/data|prismarine-physics/, (resource) => { let absolute: string const request = resource.request.replaceAll('\\', '/') absolute = path.join(resource.context, request).replaceAll('\\', '/') - if (request.includes('minecraft-data/data/pc/1.')) { - console.log('Error: incompatible resource', request, resource.contextInfo.issuer) + if (request.includes('minecraft-data/data/pc/1.') || request.includes('prismarine-physics')) { + console.log('Error: incompatible resource', request, 'from', resource.contextInfo.issuer) process.exit(1) // throw new Error(`${resource.request} was requested by ${resource.contextInfo.issuer}`) } diff --git a/renderer/viewer/baseGraphicsBackend.ts b/renderer/viewer/baseGraphicsBackend.ts index 3cd227de..486c930f 100644 --- a/renderer/viewer/baseGraphicsBackend.ts +++ b/renderer/viewer/baseGraphicsBackend.ts @@ -1,3 +1,4 @@ +import { proxy } from 'valtio' import { NonReactiveState, RendererReactiveState } from '../../src/appViewer' export const getDefaultRendererState = (): { @@ -5,7 +6,7 @@ export const getDefaultRendererState = (): { nonReactive: NonReactiveState } => { return { - reactive: { + reactive: proxy({ world: { chunksLoaded: new Set(), heightmaps: new Map(), @@ -15,7 +16,7 @@ export const getDefaultRendererState = (): { }, renderer: '', preventEscapeMenu: false - }, + }), nonReactive: { world: { chunksLoaded: new Set(), diff --git a/renderer/viewer/lib/basePlayerState.ts b/renderer/viewer/lib/basePlayerState.ts index fe6e39c4..9cf1350a 100644 --- a/renderer/viewer/lib/basePlayerState.ts +++ b/renderer/viewer/lib/basePlayerState.ts @@ -1,11 +1,11 @@ import { ItemSelector } from 'mc-assets/dist/itemDefinitions' -import { GameMode } from 'mineflayer' +import { GameMode, Team } from 'mineflayer' import { proxy } from 'valtio' import type { HandItemBlock } from '../three/holdingBlock' export type MovementState = 'NOT_MOVING' | 'WALKING' | 'SPRINTING' | 'SNEAKING' export type ItemSpecificContextProperties = Partial> - +export type CameraPerspective = 'first_person' | 'third_person_back' | 'third_person_front' export type BlockShape = { position: any; width: any; height: any; depth: any; } export type BlocksShapes = BlockShape[] @@ -47,6 +47,25 @@ export const getInitialPlayerState = () => proxy({ shouldHideHand: false, heldItemMain: undefined as HandItemBlock | undefined, heldItemOff: undefined as HandItemBlock | undefined, + perspective: 'first_person' as CameraPerspective, + onFire: false, + + cameraSpectatingEntity: undefined as number | undefined, + + 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 = () => ({ @@ -54,10 +73,9 @@ export const getInitialPlayerStateRenderer = () => ({ }) export type PlayerStateReactive = ReturnType +export type PlayerStateUtils = ReturnType -export interface PlayerStateRenderer { - reactive: PlayerStateReactive -} +export type PlayerStateRenderer = PlayerStateReactive export const getItemSelector = (playerState: PlayerStateRenderer, specificProperties: ItemSpecificContextProperties, item?: import('prismarine-item').Item) => { return { diff --git a/renderer/viewer/lib/createPlayerObject.ts b/renderer/viewer/lib/createPlayerObject.ts new file mode 100644 index 00000000..836c8062 --- /dev/null +++ b/renderer/viewer/lib/createPlayerObject.ts @@ -0,0 +1,55 @@ +import { PlayerObject, PlayerAnimation } from 'skinview3d' +import * as THREE from 'three' +import { WalkingGeneralSwing } from '../three/entity/animations' +import { loadSkinImage, stevePngUrl } from './utils/skins' + +export type PlayerObjectType = PlayerObject & { + animation?: PlayerAnimation + realPlayerUuid: string + realUsername: string +} + +export function createPlayerObject (options: { + username?: string + uuid?: string + scale?: number +}): { + playerObject: PlayerObjectType + wrapper: THREE.Group + } { + const wrapper = new THREE.Group() + const playerObject = new PlayerObject() as PlayerObjectType + + playerObject.realPlayerUuid = options.uuid ?? '' + playerObject.realUsername = options.username ?? '' + playerObject.position.set(0, 16, 0) + + // fix issues with starfield + playerObject.traverse((obj) => { + if (obj instanceof THREE.Mesh && obj.material instanceof THREE.MeshStandardMaterial) { + obj.material.transparent = true + } + }) + + wrapper.add(playerObject as any) + const scale = options.scale ?? (1 / 16) + wrapper.scale.set(scale, scale, scale) + wrapper.rotation.set(0, Math.PI, 0) + + // Set up animation + playerObject.animation = new WalkingGeneralSwing() + ;(playerObject.animation as WalkingGeneralSwing).isMoving = false + playerObject.animation.update(playerObject, 0) + + return { playerObject, wrapper } +} + +export const applySkinToPlayerObject = async (playerObject: PlayerObjectType, skinUrl: string) => { + return loadSkinImage(skinUrl || stevePngUrl).then(({ canvas }) => { + const skinTexture = new THREE.CanvasTexture(canvas) + skinTexture.magFilter = THREE.NearestFilter + skinTexture.minFilter = THREE.NearestFilter + skinTexture.needsUpdate = true + playerObject.skin.map = skinTexture as any + }).catch(console.error) +} diff --git a/renderer/viewer/lib/mesher/models.ts b/renderer/viewer/lib/mesher/models.ts index 3658d120..aca47e15 100644 --- a/renderer/viewer/lib/mesher/models.ts +++ b/renderer/viewer/lib/mesher/models.ts @@ -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: Record, isRealWater: boolean) { +function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, type: number, biome: string, water: boolean, attr: MesherGeometryOutput, isRealWater: boolean) { const heights: number[] = [] for (let z = -1; z <= 1; z++) { for (let x = -1; x <= 1; x++) { @@ -192,13 +192,14 @@ function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, typ for (const pos of corners) { const height = cornerHeights[pos[2] * 2 + pos[0]] - 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 + 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_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) { @@ -223,7 +224,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) } } } @@ -335,7 +336,7 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO: let localShift = null as any if (element.rotation && !needTiles) { - // todo do we support rescale? + // Rescale support for block model rotations localMatrix = buildRotationMatrix( element.rotation.axis, element.rotation.angle @@ -348,6 +349,37 @@ 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[] = [] @@ -487,7 +519,7 @@ const isBlockWaterlogged = (block: Block) => { } let unknownBlockModel: BlockModelPartsResolved -export function getSectionGeometry (sx, sy, sz, world: World) { +export function getSectionGeometry (sx: number, sy: number, sz: number, world: World) { let delayedRender = [] as Array<() => void> const attr: MesherGeometryOutput = { @@ -510,7 +542,6 @@ export function getSectionGeometry (sx, sy, sz, world: World) { heads: {}, signs: {}, // isFull: true, - highestBlocks: new Map(), hadErrors: false, blocksCount: 0 } @@ -520,12 +551,6 @@ export function getSectionGeometry (sx, sy, sz, world: World) { 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}` diff --git a/renderer/viewer/lib/mesher/shared.ts b/renderer/viewer/lib/mesher/shared.ts index 53e0c534..230db6b9 100644 --- a/renderer/viewer/lib/mesher/shared.ts +++ b/renderer/viewer/lib/mesher/shared.ts @@ -42,7 +42,6 @@ export type MesherGeometryOutput = { heads: Record, signs: Record, // isFull: boolean - highestBlocks: Map hadErrors: boolean blocksCount: number customBlockModels?: CustomBlockModels diff --git a/renderer/viewer/lib/renderUtils.js b/renderer/viewer/lib/renderUtils.js deleted file mode 100644 index 14176561..00000000 --- a/renderer/viewer/lib/renderUtils.js +++ /dev/null @@ -1,11 +0,0 @@ -import { fromFormattedString } from '@xmcl/text-component' - -export const formattedStringToSimpleString = (str) => { - const result = fromFormattedString(str) - str = result.text - // todo recursive - for (const extra of result.extra) { - str += extra.text - } - return str -} diff --git a/renderer/viewer/lib/utils.ts b/renderer/viewer/lib/utils.ts index 5c9c00f1..f471aa9d 100644 --- a/renderer/viewer/lib/utils.ts +++ b/renderer/viewer/lib/utils.ts @@ -1,30 +1,3 @@ -import * as THREE from 'three' -import { loadThreeJsTextureFromUrl, loadThreeJsTextureFromUrlSync } from './utils/skins' - -let textureCache: Record = {} -let imagesPromises: Record> = {} - -export async function loadTexture (texture: string, cb: (texture: THREE.Texture) => void, onLoad?: () => void): Promise { - const cached = textureCache[texture] - if (!cached) { - const { promise, resolve } = Promise.withResolvers() - const t = loadThreeJsTextureFromUrlSync(texture) - textureCache[texture] = t.texture - void t.promise.then(resolve) - imagesPromises[texture] = promise - } - - cb(textureCache[texture]) - void imagesPromises[texture].then(() => { - onLoad?.() - }) -} - -export const clearTextureCache = () => { - textureCache = {} - imagesPromises = {} -} - export const loadScript = async function (scriptSrc: string, highPriority = true): Promise { const existingScript = document.querySelector(`script[src="${scriptSrc}"]`) if (existingScript) { @@ -52,3 +25,33 @@ export const loadScript = async function (scriptSrc: string, highPriority = true document.head.appendChild(scriptElement) }) } + +const detectFullOffscreenCanvasSupport = () => { + if (typeof OffscreenCanvas === 'undefined') return false + try { + const canvas = new OffscreenCanvas(1, 1) + // Try to get a WebGL context - this will fail on iOS where only 2D is supported (iOS 16) + const gl = canvas.getContext('webgl2') || canvas.getContext('webgl') + return gl !== null + } catch (e) { + return false + } +} + +const hasFullOffscreenCanvasSupport = detectFullOffscreenCanvasSupport() + +export const createCanvas = (width: number, height: number): OffscreenCanvas => { + if (hasFullOffscreenCanvasSupport) { + return new OffscreenCanvas(width, height) + } + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + return canvas as unknown as OffscreenCanvas // todo-low +} + +export async function loadImageFromUrl (imageUrl: string): Promise { + const response = await fetch(imageUrl) + const blob = await response.blob() + return createImageBitmap(blob) +} diff --git a/renderer/viewer/lib/utils/skins.ts b/renderer/viewer/lib/utils/skins.ts index e0c8c32e..3163702c 100644 --- a/renderer/viewer/lib/utils/skins.ts +++ b/renderer/viewer/lib/utils/skins.ts @@ -1,44 +1,7 @@ import { loadSkinToCanvas } from 'skinview-utils' -import * as THREE from 'three' -import stevePng from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png' -import { getLoadedImage } from 'mc-assets/dist/utils' +import { createCanvas, loadImageFromUrl } from '../utils' -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 = new OffscreenCanvas(image.width, image.height) - const ctx = canvas.getContext('2d')! - ctx.drawImage(image, 0, 0) - const texture = new THREE.Texture(canvas) - texture.magFilter = THREE.NearestFilter - texture.minFilter = THREE.NearestFilter - return texture -} - -export const stevePngUrl = stevePng -export const steveTexture = loadThreeJsTextureFromUrl(stevePngUrl) - - -export async function loadImageFromUrl (imageUrl: string): Promise { - const response = await fetch(imageUrl) - const blob = await response.blob() - return createImageBitmap(blob) -} +export { default as stevePngUrl } from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png' const config = { apiEnabled: true, @@ -83,7 +46,7 @@ export async function loadSkinImage (skinUrl: string): Promise<{ canvas: Offscre } const image = await loadImageFromUrl(skinUrl) - const skinCanvas = new OffscreenCanvas(64, 64) + const skinCanvas = createCanvas(64, 64) loadSkinToCanvas(skinCanvas, image) return { canvas: skinCanvas, image } } diff --git a/renderer/viewer/lib/worldDataEmitter.ts b/renderer/viewer/lib/worldDataEmitter.ts index 86f372d1..dfbdb35c 100644 --- a/renderer/viewer/lib/worldDataEmitter.ts +++ b/renderer/viewer/lib/worldDataEmitter.ts @@ -7,6 +7,7 @@ import { Vec3 } from 'vec3' import { BotEvents } from 'mineflayer' import { proxy } from 'valtio' import TypedEmitter from 'typed-emitter' +import { Biome } from 'minecraft-data' import { delayedIterator } from '../../playground/shared' import { chunkPos } from './simpleUtils' @@ -18,6 +19,7 @@ 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 | { blockEntities: Record }) => void @@ -27,6 +29,8 @@ export type WorldDataEmitterEvents = { updateLight: (data: { pos: Vec3 }) => void onWorldSwitch: () => void end: () => void + biomeUpdate: (data: { biome: Biome }) => void + biomeReset: () => void } export class WorldDataEmitterWorker extends (EventEmitter as new () => TypedEmitter) { @@ -34,7 +38,15 @@ export class WorldDataEmitterWorker extends (EventEmitter as new () => TypedEmit } export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter) { + spiralNumber = 0 + gotPanicLastTime = false + panicChunksReload = () => {} loadedChunks: Record + private inLoading = false + private chunkReceiveTimes: number[] = [] + private lastChunkReceiveTime = 0 + public lastChunkReceiveTimeAvg = 0 + private panicTimeout?: NodeJS.Timeout readonly lastPos: Vec3 private eventListeners: Record = {} private readonly emitter: WorldDataEmitter @@ -90,13 +102,20 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter { - if (!e || e === bot.entity) return + if (!e) return + if (e === bot.entity) { + if (name === 'entity') { + this.emitter.emit('playerEntity', e) + } + 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 // } @@ -125,12 +144,19 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter { + 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) @@ -148,9 +174,11 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter { 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 @@ -197,40 +225,71 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter new Vec3((botX + x) * 16, 0, (botZ + z) * 16)) this.lastPos.update(pos) - await this._loadChunks(positions) + await this._loadChunks(positions, pos) } - async _loadChunks (positions: Vec3[], sliceSize = 5) { + chunkProgress () { + if (this.panicTimeout) clearTimeout(this.panicTimeout) + if (this.chunkReceiveTimes.length >= 5) { + const avgReceiveTime = this.chunkReceiveTimes.reduce((a, b) => a + b, 0) / this.chunkReceiveTimes.length + this.lastChunkReceiveTimeAvg = avgReceiveTime + const timeoutDelay = avgReceiveTime * 2 + 1000 // 2x average + 1 second + + // Clear any existing timeout + if (this.panicTimeout) clearTimeout(this.panicTimeout) + + // Set new timeout for panic reload + this.panicTimeout = setTimeout(() => { + if (!this.gotPanicLastTime && this.inLoading) { + console.warn('Chunk loading seems stuck, triggering panic reload') + this.gotPanicLastTime = true + this.panicChunksReload() + } + }, timeoutDelay) + } + } + + async _loadChunks (positions: Vec3[], centerPos: Vec3) { + this.spiralNumber++ + const { spiralNumber } = this // stop loading previous chunks for (const pos of Object.keys(this.waitingSpiralChunksLoad)) { this.waitingSpiralChunksLoad[pos](false) delete this.waitingSpiralChunksLoad[pos] } - const promises = [] as Array> let continueLoading = true + this.inLoading = true await delayedIterator(positions, this.addWaitTime, async (pos) => { - const promise = (async () => { - if (!continueLoading || this.loadedChunks[`${pos.x},${pos.z}`]) return + if (!continueLoading || this.loadedChunks[`${pos.x},${pos.z}`]) return - if (!this.world.getColumnAt(pos)) { - continueLoading = await new Promise(resolve => { - this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`] = resolve - }) - } - if (!continueLoading) return - await this.loadChunk(pos) - })() - promises.push(promise) + // Wait for chunk to be available from server + if (!this.world.getColumnAt(pos)) { + continueLoading = await new Promise(resolve => { + this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`] = resolve + }) + } + if (!continueLoading) return + await this.loadChunk(pos, undefined, `spiral ${spiralNumber} from ${centerPos.x},${centerPos.z}`) + this.chunkProgress() }) - await Promise.all(promises) + if (this.panicTimeout) clearTimeout(this.panicTimeout) + this.inLoading = false + this.gotPanicLastTime = false + this.chunkReceiveTimes = [] + this.lastChunkReceiveTime = 0 } readdDebug () { @@ -304,8 +363,37 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter TypedEmitter TypedEmitter !!a) this.lastPos.update(pos) - void this._loadChunks(positions) + void this._loadChunks(positions, pos) } else { this.emitter.emit('chunkPosUpdate', { pos }) // todo-low this.lastPos.update(pos) diff --git a/renderer/viewer/lib/worldrendererCommon.ts b/renderer/viewer/lib/worldrendererCommon.ts index 1f0df0bd..4140e3fa 100644 --- a/renderer/viewer/lib/worldrendererCommon.ts +++ b/renderer/viewer/lib/worldrendererCommon.ts @@ -16,7 +16,7 @@ import { HighestBlockInfo, CustomBlockModels, BlockStateModelInfo, getBlockAsset import { chunkPos } from './simpleUtils' import { addNewStat, removeAllStats, updatePanesVisibility, updateStatText } from './ui/newStats' import { WorldDataEmitterWorker } from './worldDataEmitter' -import { PlayerStateRenderer } from './basePlayerState' +import { getPlayerStateUtils, PlayerStateReactive, PlayerStateRenderer, PlayerStateUtils } from './basePlayerState' import { MesherLogReader } from './mesherlogReader' import { setSkinsConfig } from './utils/skins' @@ -32,30 +32,45 @@ const toMajorVersion = version => { export const worldCleanup = buildCleanupDecorator('resetWorld') export const defaultWorldRendererConfig = { + // Debug settings showChunkBorders: false, + enableDebugOverlay: false, + + // Performance settings mesherWorkers: 4, - isPlayground: false, - renderEars: true, - // game renderer setting actually - showHand: false, - viewBobbing: false, - extraBlockRenderers: true, - clipWorldBelowY: undefined as number | undefined, + addChunksBatchWaitTime: 200, + _experimentalSmoothChunkLoading: true, + _renderByChunks: false, + + // Rendering engine settings + dayCycle: true, smoothLighting: true, enableLighting: true, starfield: true, - addChunksBatchWaitTime: 200, + defaultSkybox: true, + renderEntities: true, + extraBlockRenderers: true, + foreground: true, + fov: 75, + volume: 1, + + // Camera visual related settings + showHand: false, + viewBobbing: false, + renderEars: true, + highlightBlockColor: 'blue', + + // Player models + fetchPlayerSkins: true, + skinTexturesProxy: undefined as string | undefined, + + // VR settings vrSupport: true, vrPageGameRendering: true, - renderEntities: true, - fov: 75, - fetchPlayerSkins: true, - highlightBlockColor: 'blue', - foreground: true, - enableDebugOverlay: false, - _experimentalSmoothChunkLoading: true, - _renderByChunks: false, - volume: 1 + + // World settings + clipWorldBelowY: undefined as number | undefined, + isPlayground: false } export type WorldRendererConfig = typeof defaultWorldRendererConfig @@ -106,7 +121,7 @@ export abstract class WorldRendererCommon }> customTexturesDataUrl = undefined as string | undefined workers: any[] = [] - viewerPosition?: Vec3 + viewerChunkPosition?: Vec3 lastCamUpdate = 0 droppedFpsPercentage = 0 initialChunkLoadWasStartedIn: number | undefined @@ -122,7 +137,6 @@ export abstract class WorldRendererCommon handleResize = () => { } highestBlocksByChunks = new Map() - highestBlocksBySections = new Map() blockEntities = {} workersProcessAverageTime = 0 @@ -156,7 +170,8 @@ export abstract class WorldRendererCommon abstract changeBackgroundColor (color: [number, number, number]): void worldRendererConfig: WorldRendererConfig - playerState: PlayerStateRenderer + playerStateReactive: PlayerStateReactive + playerStateUtils: PlayerStateUtils reactiveState: RendererReactiveState mesherLogReader: MesherLogReader | undefined forceCallFromMesherReplayer = false @@ -191,7 +206,8 @@ export abstract class WorldRendererCommon constructor (public readonly resourcesManager: ResourcesManagerTransferred, public displayOptions: DisplayWorldOptions, public initOptions: GraphicsInitOptions) { this.snapshotInitialValues() this.worldRendererConfig = displayOptions.inWorldRenderingConfig - this.playerState = displayOptions.playerState + this.playerStateReactive = displayOptions.playerStateReactive + this.playerStateUtils = getPlayerStateUtils(this.playerStateReactive) this.reactiveState = displayOptions.rendererState // this.mesherLogReader = new MesherLogReader(this) this.renderUpdateEmitter.on('update', () => { @@ -304,11 +320,11 @@ export abstract class WorldRendererCommon } } - onReactivePlayerStateUpdated(key: T, callback: (value: typeof this.displayOptions.playerState.reactive[T]) => void, initial = true) { + onReactivePlayerStateUpdated(key: T, callback: (value: PlayerStateReactive[T]) => void, initial = true) { if (initial) { - callback(this.displayOptions.playerState.reactive[key]) + callback(this.playerStateReactive[key]) } - subscribeKey(this.displayOptions.playerState.reactive, key, callback) + subscribeKey(this.playerStateReactive, key, callback) } onReactiveConfigUpdated(key: T, callback: (value: typeof this.worldRendererConfig[T]) => void) { @@ -386,8 +402,6 @@ export abstract class WorldRendererCommon 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]))) } @@ -496,8 +510,12 @@ export abstract class WorldRendererCommon timeUpdated? (newTime: number): void + biomeUpdated? (biome: any): void + + biomeReset? (): void + updateViewerPosition (pos: Vec3) { - this.viewerPosition = pos + this.viewerChunkPosition = pos for (const [key, value] of Object.entries(this.loadedChunks)) { if (!value) continue this.updatePosDataChunk?.(key) @@ -511,7 +529,7 @@ export abstract class WorldRendererCommon } getDistance (posAbsolute: Vec3) { - const [botX, botZ] = chunkPos(this.viewerPosition!) + const [botX, botZ] = chunkPos(this.viewerChunkPosition!) 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] @@ -685,7 +703,6 @@ export abstract class WorldRendererCommon 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}`) @@ -719,6 +736,8 @@ export abstract class WorldRendererCommon updateEntity (e: any, isUpdate = false) { } + abstract updatePlayerEntity? (e: any): void + lightUpdate (chunkX: number, chunkZ: number) { } connect (worldView: WorldDataEmitterWorker) { @@ -730,6 +749,9 @@ export abstract class WorldRendererCommon worldEmitter.on('entityMoved', (e) => { this.updateEntity(e, true) }) + worldEmitter.on('playerEntity', (e) => { + this.updatePlayerEntity?.(e) + }) let currentLoadChunkBatch = null as { timeout @@ -813,12 +835,9 @@ export abstract class WorldRendererCommon }) 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 @@ -827,6 +846,14 @@ export abstract class WorldRendererCommon // (this).rerenderAllChunks?.() // } }) + + worldEmitter.on('biomeUpdate', ({ biome }) => { + this.biomeUpdated?.(biome) + }) + + worldEmitter.on('biomeReset', () => { + this.biomeReset?.() + }) } setBlockStateIdInner (pos: Vec3, stateId: number | undefined, needAoRecalculation = true) { diff --git a/renderer/viewer/sign-renderer/index.ts b/renderer/viewer/sign-renderer/index.ts index a1e4331f..f14b9b4c 100644 --- a/renderer/viewer/sign-renderer/index.ts +++ b/renderer/viewer/sign-renderer/index.ts @@ -1,5 +1,5 @@ -import { fromFormattedString, render, RenderNode, TextComponent } from '@xmcl/text-component' import type { ChatMessage } from 'prismarine-chat' +import { createCanvas } from '../lib/utils' type SignBlockEntity = { Color?: string @@ -32,29 +32,40 @@ const parseSafe = (text: string, task: string) => { } } -export const renderSign = (blockEntity: SignBlockEntity, PrismarineChat: typeof ChatMessage, ctxHook = (ctx) => { }) => { +const LEGACY_COLORS = { + black: '#000000', + dark_blue: '#0000AA', + dark_green: '#00AA00', + dark_aqua: '#00AAAA', + dark_red: '#AA0000', + dark_purple: '#AA00AA', + gold: '#FFAA00', + gray: '#AAAAAA', + dark_gray: '#555555', + blue: '#5555FF', + green: '#55FF55', + aqua: '#55FFFF', + red: '#FF5555', + light_purple: '#FF55FF', + yellow: '#FFFF55', + white: '#FFFFFF', +} + +export const renderSign = ( + blockEntity: SignBlockEntity, + isHanging: boolean, + PrismarineChat: typeof ChatMessage, + ctxHook = (ctx) => { }, + canvasCreator = (width, height): OffscreenCanvas => { return createCanvas(width, height) } +) => { // todo don't use texture rendering, investigate the font rendering when possible // or increase factor when needed const factor = 40 + const fontSize = 1.6 * factor const signboardY = [16, 9] const heightOffset = signboardY[0] - signboardY[1] const heightScalar = heightOffset / 16 - - let canvas: HTMLCanvasElement | undefined - let _ctx: CanvasRenderingContext2D | null = null - const getCtx = () => { - if (_ctx) return _ctx - canvas = document.createElement('canvas') - - canvas.width = 16 * factor - canvas.height = heightOffset * factor - - _ctx = canvas.getContext('2d')! - _ctx.imageSmoothingEnabled = false - - ctxHook(_ctx) - return _ctx - } + // todo the text should be clipped based on it's render width (needs investigate) const texts = 'front_text' in blockEntity ? /* > 1.20 */ blockEntity.front_text.messages : [ blockEntity.Text1, @@ -62,78 +73,144 @@ export const renderSign = (blockEntity: SignBlockEntity, PrismarineChat: typeof blockEntity.Text3, blockEntity.Text4 ] + + if (!texts.some((text) => text !== 'null')) { + return undefined + } + + const canvas = canvasCreator(16 * factor, heightOffset * factor) + + const _ctx = canvas.getContext('2d')! + + ctxHook(_ctx) const defaultColor = ('front_text' in blockEntity ? blockEntity.front_text.color : blockEntity.Color) || 'black' for (const [lineNum, text] of texts.slice(0, 4).entries()) { - // todo: in pre flatenning it seems the format was not json if (text === 'null') continue - const parsed = text?.startsWith('{') || text?.startsWith('"') ? parseSafe(text ?? '""', 'sign text') : text - if (!parsed || (typeof parsed !== 'object' && typeof parsed !== 'string')) continue - // todo fix type - const message = typeof parsed === 'string' ? fromFormattedString(parsed) : new PrismarineChat(parsed) as never - const patchExtra = ({ extra }: TextComponent) => { - if (!extra) return - for (const child of extra) { - if (child.color) { - child.color = child.color === 'dark_green' ? child.color.toUpperCase() : child.color.toLowerCase() - } - patchExtra(child) - } - } - patchExtra(message) - const rendered = render(message) - - const toRenderCanvas: Array<{ - fontStyle: string - fillStyle: string - underlineStyle: boolean - strikeStyle: boolean - text: string - }> = [] - let plainText = '' - // todo the text should be clipped based on it's render width (needs investigate) - const MAX_LENGTH = 50 // avoid abusing the signboard - const renderText = (node: RenderNode) => { - const { component } = node - let { text } = component - if (plainText.length + text.length > MAX_LENGTH) { - text = text.slice(0, MAX_LENGTH - plainText.length) - if (!text) return false - } - plainText += text - toRenderCanvas.push({ - fontStyle: `${component.bold ? 'bold' : ''} ${component.italic ? 'italic' : ''}`, - fillStyle: node.style['color'] || defaultColor, - underlineStyle: component.underlined ?? false, - strikeStyle: component.strikethrough ?? false, - text - }) - for (const child of node.children) { - const stop = renderText(child) === false - if (stop) return false - } - } - - renderText(rendered) - - // skip rendering empty lines (and possible signs) - if (!plainText.trim()) continue - - const ctx = getCtx() - const fontSize = 1.6 * factor - ctx.font = `${fontSize}px mojangles` - const textWidth = ctx.measureText(plainText).width - - let renderedWidth = 0 - for (const { fillStyle, fontStyle, strikeStyle, text, underlineStyle } of toRenderCanvas) { - // todo strikeStyle, underlineStyle - ctx.fillStyle = fillStyle - ctx.font = `${fontStyle} ${fontSize}px mojangles` - ctx.fillText(text, (canvas!.width - textWidth) / 2 + renderedWidth, fontSize * (lineNum + 1)) - renderedWidth += ctx.measureText(text).width // todo isn't the font is monospace? - } + renderComponent(text, PrismarineChat, canvas, fontSize, defaultColor, fontSize * (lineNum + 1) + (isHanging ? 0 : -8)) } - // ctx.fillStyle = 'red' - // ctx.fillRect(0, 0, canvas.width, canvas.height) - return canvas } + +export const renderComponent = ( + text: JsonEncodedType | string | undefined, + PrismarineChat: typeof ChatMessage, + canvas: OffscreenCanvas, + fontSize: number, + defaultColor: string, + offset = 0 +) => { + // todo: in pre flatenning it seems the format was not json + const parsed = typeof text === 'string' && (text?.startsWith('{') || text?.startsWith('"')) ? parseSafe(text ?? '""', 'sign text') : text + if (!parsed || (typeof parsed !== 'object' && typeof parsed !== 'string')) return + // todo fix type + + const ctx = canvas.getContext('2d')! + if (!ctx) throw new Error('Could not get 2d context') + ctx.imageSmoothingEnabled = false + ctx.font = `${fontSize}px mojangles` + + type Formatting = { + color: string | undefined + underlined: boolean | undefined + strikethrough: boolean | undefined + bold: boolean | undefined + italic: boolean | undefined + } + + type Message = ChatMessage & Formatting & { text: string } + + const message = new PrismarineChat(parsed) as Message + + const toRenderCanvas: Array<{ + fontStyle: string + fillStyle: string + underlineStyle: boolean + strikeStyle: boolean + offset: number + text: string + }> = [] + let visibleFormatting = false + let plainText = '' + let textOffset = offset + const textWidths: number[] = [] + + const renderText = (component: Message, parentFormatting?: Formatting | undefined) => { + const { text } = component + const formatting = { + color: component.color ?? parentFormatting?.color, + underlined: component.underlined ?? parentFormatting?.underlined, + strikethrough: component.strikethrough ?? parentFormatting?.strikethrough, + bold: component.bold ?? parentFormatting?.bold, + italic: component.italic ?? parentFormatting?.italic + } + visibleFormatting = visibleFormatting || formatting.underlined || formatting.strikethrough || false + if (text?.includes('\n')) { + for (const line of text.split('\n')) { + addTextPart(line, formatting) + textOffset += fontSize + plainText = '' + } + } else if (text) { + addTextPart(text, formatting) + } + if (component.extra) { + for (const child of component.extra) { + renderText(child as Message, formatting) + } + } + } + + const addTextPart = (text: string, formatting: Formatting) => { + plainText += text + textWidths[textOffset] = ctx.measureText(plainText).width + let color = formatting.color ?? defaultColor + if (!color.startsWith('#')) { + color = LEGACY_COLORS[color.toLowerCase()] || color + } + toRenderCanvas.push({ + fontStyle: `${formatting.bold ? 'bold' : ''} ${formatting.italic ? 'italic' : ''}`, + fillStyle: color, + underlineStyle: formatting.underlined ?? false, + strikeStyle: formatting.strikethrough ?? false, + offset: textOffset, + text + }) + } + + renderText(message) + + // skip rendering empty lines + if (!visibleFormatting && !message.toString().trim()) return + + let renderedWidth = 0 + let previousOffsetY = 0 + for (const { fillStyle, fontStyle, underlineStyle, strikeStyle, offset: offsetY, text } of toRenderCanvas) { + if (previousOffsetY !== offsetY) { + renderedWidth = 0 + } + previousOffsetY = offsetY + ctx.fillStyle = fillStyle + ctx.textRendering = 'optimizeLegibility' + ctx.font = `${fontStyle} ${fontSize}px mojangles` + const textWidth = textWidths[offsetY] ?? ctx.measureText(text).width + const offsetX = (canvas.width - textWidth) / 2 + renderedWidth + ctx.fillText(text, offsetX, offsetY) + if (strikeStyle) { + ctx.lineWidth = fontSize / 8 + ctx.strokeStyle = fillStyle + ctx.beginPath() + ctx.moveTo(offsetX, offsetY - ctx.lineWidth * 2.5) + ctx.lineTo(offsetX + ctx.measureText(text).width, offsetY - ctx.lineWidth * 2.5) + ctx.stroke() + } + if (underlineStyle) { + ctx.lineWidth = fontSize / 8 + ctx.strokeStyle = fillStyle + ctx.beginPath() + ctx.moveTo(offsetX, offsetY + ctx.lineWidth) + ctx.lineTo(offsetX + ctx.measureText(text).width, offsetY + ctx.lineWidth) + ctx.stroke() + } + renderedWidth += ctx.measureText(text).width + } +} diff --git a/renderer/viewer/sign-renderer/playground.ts b/renderer/viewer/sign-renderer/playground.ts index 92ff5d03..a7438092 100644 --- a/renderer/viewer/sign-renderer/playground.ts +++ b/renderer/viewer/sign-renderer/playground.ts @@ -21,9 +21,14 @@ const blockEntity = { await document.fonts.load('1em mojangles') -const canvas = renderSign(blockEntity, PrismarineChat, (ctx) => { +const canvas = renderSign(blockEntity, false, PrismarineChat, (ctx) => { ctx.drawImage(img, 0, 0, ctx.canvas.width, ctx.canvas.height) -}) +}, (width, height) => { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + return canvas as unknown as OffscreenCanvas +}) as unknown as HTMLCanvasElement if (canvas) { canvas.style.imageRendering = 'pixelated' diff --git a/renderer/viewer/sign-renderer/tests.test.ts b/renderer/viewer/sign-renderer/tests.test.ts index b8fc94fc..ab268849 100644 --- a/renderer/viewer/sign-renderer/tests.test.ts +++ b/renderer/viewer/sign-renderer/tests.test.ts @@ -22,7 +22,7 @@ global.document = { const render = (entity) => { ctxTexts = [] - renderSign(entity, PrismarineChat) + renderSign(entity, true, PrismarineChat) return ctxTexts.map(({ text, y }) => [y / 64, text]) } @@ -37,10 +37,6 @@ test('sign renderer', () => { } as any expect(render(blockEntity)).toMatchInlineSnapshot(` [ - [ - 1, - "", - ], [ 1, "Minecraft ", diff --git a/renderer/viewer/three/cameraShake.ts b/renderer/viewer/three/cameraShake.ts index 6fe483cc..7b159509 100644 --- a/renderer/viewer/three/cameraShake.ts +++ b/renderer/viewer/three/cameraShake.ts @@ -21,6 +21,10 @@ export class CameraShake { this.update() } + getBaseRotation () { + return { pitch: this.basePitch, yaw: this.baseYaw } + } + shakeFromDamage (yaw?: number) { // Add roll animation const startRoll = this.rollAngle @@ -35,6 +39,11 @@ 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() @@ -63,7 +72,7 @@ export class CameraShake { } } - const camera = this.worldRenderer.cameraGroupVr || this.worldRenderer.camera + const camera = this.worldRenderer.cameraObject if (this.worldRenderer.cameraGroupVr) { // For VR camera, only apply yaw rotation @@ -71,8 +80,12 @@ export class CameraShake { camera.setRotationFromQuaternion(yawQuat) } else { // For regular camera, apply all rotations - const pitchQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), this.basePitch) - const yawQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), this.baseYaw) + // Add tiny offsets to prevent z-fighting at ideal angles (90, 180, 270 degrees) + const pitchOffset = this.addAntiZfightingOffset(this.basePitch) + const yawOffset = this.addAntiZfightingOffset(this.baseYaw) + + const pitchQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), pitchOffset) + const yawQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), yawOffset) const rollQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), THREE.MathUtils.degToRad(this.rollAngle)) // Combine rotations in the correct order: pitch -> yaw -> roll const finalQuat = yawQuat.multiply(pitchQuat).multiply(rollQuat) @@ -87,4 +100,21 @@ export class CameraShake { private easeInOut (t: number): number { return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2 } + + private addAntiZfightingOffset (angle: number): number { + const offset = 0.001 // Very small offset in radians (about 0.057 degrees) + + // Check if the angle is close to ideal angles (0, π/2, π, 3π/2) + const normalizedAngle = ((angle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2) + const tolerance = 0.01 // Tolerance for considering an angle "ideal" + + if (Math.abs(normalizedAngle) < tolerance || + Math.abs(normalizedAngle - Math.PI / 2) < tolerance || + Math.abs(normalizedAngle - Math.PI) < tolerance || + Math.abs(normalizedAngle - 3 * Math.PI / 2) < tolerance) { + return angle + offset + } + + return angle + } } diff --git a/renderer/viewer/three/documentRenderer.ts b/renderer/viewer/three/documentRenderer.ts index 8ff31e69..a5dc060d 100644 --- a/renderer/viewer/three/documentRenderer.ts +++ b/renderer/viewer/three/documentRenderer.ts @@ -61,8 +61,9 @@ export class DocumentRenderer { this.previousCanvasWidth = this.canvas.width this.previousCanvasHeight = this.canvas.height + const supportsWebGL2 = 'WebGL2RenderingContext' in window // Only initialize stats and DOM-related features in main thread - if (!externalCanvas) { + if (!externalCanvas && supportsWebGL2) { this.stats = new TopRightStats(this.canvas as HTMLCanvasElement, this.config.statsVisible) this.setupFpsTracking() } diff --git a/renderer/viewer/three/entities.ts b/renderer/viewer/three/entities.ts index 0e8e6384..fad30182 100644 --- a/renderer/viewer/three/entities.ts +++ b/renderer/viewer/three/entities.ts @@ -1,11 +1,10 @@ //@ts-check -import EventEmitter from 'events' import { UnionToIntersection } from 'type-fest' import nbt from 'prismarine-nbt' import * as TWEEN from '@tweenjs/tween.js' import * as THREE from 'three' -import { PlayerObject, PlayerAnimation } from 'skinview3d' -import { loadSkinToCanvas, loadEarsToCanvasFromSkin, inferModelType, loadCapeToCanvas, loadImage } from 'skinview-utils' +import { PlayerAnimation, PlayerObject } from 'skinview3d' +import { inferModelType, loadCapeToCanvas, loadEarsToCanvasFromSkin } from 'skinview-utils' // todo replace with url import { degreesToRadians } from '@nxg-org/mineflayer-tracker/lib/mathUtils' import { NameTagObject } from 'skinview3d/libs/nametag' @@ -13,28 +12,27 @@ import { flat, fromFormattedString } from '@xmcl/text-component' import mojangson from 'mojangson' import { snakeCase } from 'change-case' import { Item } from 'prismarine-item' -import { BlockModel } from 'mc-assets' import { isEntityAttackable } from 'mineflayer-mouse/dist/attackableEntity' -import { Vec3 } from 'vec3' +import { Team } from 'mineflayer' +import PrismarineChatLoader from 'prismarine-chat' import { EntityMetadataVersions } from '../../../src/mcDataTypes' import { ItemSpecificContextProperties } from '../lib/basePlayerState' -import { loadSkinImage, loadSkinFromUsername, stevePngUrl, steveTexture } from '../lib/utils/skins' -import { loadTexture } from '../lib/utils' +import { loadSkinFromUsername, loadSkinImage, stevePngUrl } from '../lib/utils/skins' +import { renderComponent } from '../sign-renderer' +import { createCanvas } from '../lib/utils' +import { PlayerObjectType } from '../lib/createPlayerObject' import { getBlockMeshFromModel } from './holdingBlock' +import { createItemMesh } from './itemMesh' import * as Entity from './entity/EntityMesh' import { getMesh } from './entity/EntityMesh' import { WalkingGeneralSwing } from './entity/animations' -import { disposeObject } from './threeJsUtils' -import { armorModel, armorTextures } from './entity/armorModels' +import { disposeObject, loadTexture, loadThreeJsTextureFromUrl } from './threeJsUtils' +import { armorModel, armorTextures, elytraTexture } from './entity/armorModels' import { WorldRendererThree } from './worldrendererThree' -export const TWEEN_DURATION = 120 +export const steveTexture = loadThreeJsTextureFromUrl(stevePngUrl) -type PlayerObjectType = PlayerObject & { - animation?: PlayerAnimation - realPlayerUuid: string - realUsername: string -} +export const TWEEN_DURATION = 120 function convert2sComplementToHex (complement: number) { if (complement < 0) { @@ -95,8 +93,11 @@ function getUsernameTexture ({ username, nameTagBackgroundColor = 'rgba(0, 0, 0, 0.3)', nameTagTextOpacity = 255 -}: any, { fontFamily = 'sans-serif' }: any) { - const canvas = new OffscreenCanvas(64, 64) +}: any, { fontFamily = 'mojangles' }: any, version: string) { + const canvas = createCanvas(64, 64) + + const PrismarineChat = PrismarineChatLoader(version) + const ctx = canvas.getContext('2d') if (!ctx) throw new Error('Could not get 2d context') @@ -104,38 +105,39 @@ function getUsernameTexture ({ const padding = 5 ctx.font = `${fontSize}px ${fontFamily}` - const lines = String(username).split('\n') - + const plainLines = String(typeof username === 'string' ? username : new PrismarineChat(username).toString()).split('\n') let textWidth = 0 - for (const line of lines) { + for (const line of plainLines) { const width = ctx.measureText(line).width + padding * 2 if (width > textWidth) textWidth = width } canvas.width = textWidth - canvas.height = (fontSize + padding) * lines.length + canvas.height = (fontSize + padding) * plainLines.length ctx.fillStyle = nameTagBackgroundColor ctx.fillRect(0, 0, canvas.width, canvas.height) - ctx.font = `${fontSize}px ${fontFamily}` - ctx.fillStyle = `rgba(255, 255, 255, ${nameTagTextOpacity / 255})` - let i = 0 - for (const line of lines) { - i++ - ctx.fillText(line, (textWidth - ctx.measureText(line).width) / 2, -padding + fontSize * i) - } + ctx.globalAlpha = nameTagTextOpacity / 255 + + renderComponent(username, PrismarineChat, canvas, fontSize, 'white', -padding + fontSize) + + ctx.globalAlpha = 1 return canvas } -const addNametag = (entity, options, mesh) => { +const addNametag = (entity, options: { fontFamily: string }, mesh, version: string) => { + for (const c of mesh.children) { + if (c.name === 'nametag') { + c.removeFromParent() + } + } if (entity.username !== undefined) { - if (mesh.children.some(c => c.name === 'nametag')) return // todo update - const canvas = getUsernameTexture(entity, options) + const canvas = getUsernameTexture(entity, options, version) const tex = new THREE.Texture(canvas) tex.needsUpdate = true - let nameTag + let nameTag: THREE.Object3D if (entity.nameTagFixed) { const geometry = new THREE.PlaneGeometry() const material = new THREE.MeshBasicMaterial({ map: tex }) @@ -165,6 +167,7 @@ const addNametag = (entity, options, mesh) => { nameTag.name = 'nametag' mesh.add(nameTag) + return nameTag } } @@ -173,7 +176,7 @@ const nametags = {} const isFirstUpperCase = (str) => str.charAt(0) === str.charAt(0).toUpperCase() -function getEntityMesh (entity: import('prismarine-entity').Entity & { delete?: any; pos?: any; name?: any }, world: WorldRendererThree | undefined, options: { fontFamily: string }, overrides) { +function getEntityMesh (entity: import('prismarine-entity').Entity & { delete?: any; pos?: any; name?: any }, world: WorldRendererThree, options: { fontFamily: string }, overrides) { if (entity.name) { try { // https://github.com/PrismarineJS/prismarine-viewer/pull/410 @@ -181,7 +184,7 @@ function getEntityMesh (entity: import('prismarine-entity').Entity & { delete?: const e = new Entity.EntityMesh('1.16.4', entityName, world, overrides) if (e.mesh) { - addNametag(entity, options, e.mesh) + addNametag(entity, options, e.mesh, world.version) return e.mesh } } catch (err) { @@ -199,7 +202,7 @@ function getEntityMesh (entity: import('prismarine-entity').Entity & { delete?: addNametag({ username: entity.name, height: entity.height, - }, options, cube) + }, options, cube, world.version) } return cube } @@ -209,11 +212,12 @@ export type SceneEntity = THREE.Object3D & { username?: string uuid?: string additionalCleanup?: () => void - originalEntity: import('prismarine-entity').Entity & { delete?; pos?, name } + originalEntity: import('prismarine-entity').Entity & { delete?; pos?, name, team?: Team } } export class Entities { entities = {} as Record + playerEntity: SceneEntity | null = null // Special entity for the player in third person entitiesOptions = { fontFamily: 'mojangles' } @@ -254,12 +258,49 @@ export class Entities { 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 () { for (const mesh of Object.values(this.entities)) { this.worldRenderer.scene.remove(mesh) disposeObject(mesh) } this.entities = {} + + // Clean up player entity + if (this.playerEntity) { + this.worldRenderer.scene.remove(this.playerEntity) + disposeObject(this.playerEntity) + this.playerEntity = null + } } reloadEntities () { @@ -306,11 +347,12 @@ export class Entities { } const dt = this.clock.getDelta() - const botPos = this.worldRenderer.viewerPosition + const botPos = this.worldRenderer.viewerChunkPosition const VISIBLE_DISTANCE = 10 * 10 - for (const entityId of Object.keys(this.entities)) { - const entity = this.entities[entityId] + // Update regular entities + for (const [entityId, entity] of [...Object.entries(this.entities), ['player_entity', this.playerEntity] as [string, SceneEntity | null]]) { + if (!entity) continue const { playerObject } = entity // Update animations @@ -318,9 +360,6 @@ 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 @@ -333,6 +372,32 @@ export class Entities { 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) + } + }) + } + } } } @@ -410,6 +475,7 @@ 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 } @@ -422,8 +488,13 @@ 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 @@ -487,7 +558,7 @@ export class Entities { let skinCanvas: OffscreenCanvas if (skinUrl === stevePngUrl) { skinTexture = await steveTexture - const canvas = new OffscreenCanvas(64, 64) + const canvas = createCanvas(64, 64) const ctx = canvas.getContext('2d') if (!ctx) throw new Error('Failed to get context') ctx.drawImage(skinTexture.image, 0, 0) @@ -568,21 +639,63 @@ export class Entities { } playAnimation (entityPlayerId, animation: 'walking' | 'running' | 'oneSwing' | 'idle' | 'crouch' | 'crouchWalking') { - const playerObject = this.getPlayerObject(entityPlayerId) - if (!playerObject) return + // 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 (animation === 'oneSwing') { - if (!(playerObject.animation instanceof WalkingGeneralSwing)) throw new Error('Expected WalkingGeneralSwing') - playerObject.animation.swingArm() + 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 } - if (playerObject.animation instanceof WalkingGeneralSwing) { - playerObject.animation.switchAnimationCallback = () => { + // 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.isMoving = animation === 'walking' || animation === 'running' || animation === 'crouchWalking' - playerObject.animation.isRunning = animation === 'running' - playerObject.animation.isCrouched = animation === 'crouch' || animation === 'crouchWalking' + 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 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' + } } } } @@ -605,7 +718,7 @@ export class Entities { return typeof component === 'string' ? component : component.text ?? '' } - getItemMesh (item, specificProps: ItemSpecificContextProperties, previousModel?: string) { + getItemMesh (item, specificProps: ItemSpecificContextProperties, faceCamera = false, previousModel?: string) { if (!item.nbt && item.nbtData) item.nbt = item.nbtData const textureUv = this.worldRenderer.getItemRenderData(item, specificProps) if (previousModel && previousModel === textureUv?.modelName) return undefined @@ -624,60 +737,41 @@ export class Entities { return { mesh: outerGroup, isBlock: true, - itemsTexture: null, - itemsTextureFlipped: null, modelName: textureUv.modelName, } } - // TODO: Render proper model (especially for blocks) instead of flat texture + // Render proper 3D model for items if (textureUv) { const textureThree = textureUv.renderInfo?.texture === 'blocks' ? this.worldRenderer.material.map! : this.worldRenderer.itemsTexture - // todo use geometry buffer uv instead! const { u, v, su, sv } = textureUv - const size = undefined - const itemsTexture = textureThree.clone() - itemsTexture.flipY = true - const sizeY = (sv ?? size)! - const sizeX = (su ?? size)! - itemsTexture.offset.set(u, 1 - v - sizeY) - itemsTexture.repeat.set(sizeX, sizeY) - itemsTexture.needsUpdate = true - itemsTexture.magFilter = THREE.NearestFilter - itemsTexture.minFilter = THREE.NearestFilter - const itemsTextureFlipped = itemsTexture.clone() - itemsTextureFlipped.repeat.x *= -1 - itemsTextureFlipped.needsUpdate = true - itemsTextureFlipped.offset.set(u + (sizeX), 1 - v - sizeY) - const material = new THREE.MeshStandardMaterial({ - map: itemsTexture, - transparent: true, - alphaTest: 0.1, + const sizeX = su ?? 1 // su is actually width + const sizeY = sv ?? 1 // sv is actually height + + // Use the new unified item mesh function + const result = createItemMesh(textureThree, { + u, + v, + sizeX, + sizeY + }, { + faceCamera, + use3D: !faceCamera, // Only use 3D for non-camera-facing items }) - const materialFlipped = new THREE.MeshStandardMaterial({ - map: itemsTextureFlipped, - transparent: true, - alphaTest: 0.1, - }) - const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 0), [ - // top left and right bottom are black box materials others are transparent - new THREE.MeshBasicMaterial({ color: 0x00_00_00 }), new THREE.MeshBasicMaterial({ color: 0x00_00_00 }), - new THREE.MeshBasicMaterial({ color: 0x00_00_00 }), new THREE.MeshBasicMaterial({ color: 0x00_00_00 }), - material, materialFlipped, - ]) + let SCALE = 1 if (specificProps['minecraft:display_context'] === 'ground') { SCALE = 0.5 } else if (specificProps['minecraft:display_context'] === 'thirdperson') { SCALE = 6 } - mesh.scale.set(SCALE, SCALE, SCALE) + result.mesh.scale.set(SCALE, SCALE, SCALE) + return { - mesh, + mesh: result.mesh, isBlock: false, - itemsTexture, - itemsTextureFlipped, modelName: textureUv.modelName, + cleanup: result.cleanup } } } @@ -693,8 +787,6 @@ export class Entities { } update (entity: SceneEntity['originalEntity'], overrides) { - const justAdded = !this.entities[entity.id] - const isPlayerModel = entity.name === 'player' if (entity.name === 'zombie_villager' || entity.name === 'husk') { overrides.texture = `textures/1.16.4/entity/${entity.name === 'zombie_villager' ? 'zombie_villager/zombie_villager.png' : `zombie/${entity.name}.png`}` @@ -705,6 +797,7 @@ export class Entities { } // this can be undefined in case where packet entity_destroy was sent twice (so it was already deleted) let e = this.entities[entity.id] + const justAdded = !e if (entity.delete) { if (!e) return @@ -724,21 +817,23 @@ export class Entities { if (e === undefined) { const group = new THREE.Group() as unknown as SceneEntity group.originalEntity = entity - if (entity.name === 'item' || entity.name === 'tnt' || entity.name === 'falling_block') { - const item = entity.name === 'tnt' - ? { name: 'tnt' } + if (entity.name === 'item' || entity.name === 'tnt' || entity.name === 'falling_block' || entity.name === 'snowball' + || entity.name === 'egg' || entity.name === 'ender_pearl' || entity.name === 'experience_bottle' + || entity.name === 'splash_potion' || entity.name === 'lingering_potion') { + const item = entity.name === 'tnt' || entity.type === 'projectile' + ? { name: entity.name } : entity.name === 'falling_block' ? { blockState: entity['objectData'] } : entity.metadata?.find((m: any) => typeof m === 'object' && m?.itemCount) if (item) { const object = this.getItemMesh(item, { 'minecraft:display_context': 'ground', - }) + }, entity.type === 'projectile') if (object) { mesh = object.mesh - if (entity.name === 'item') { + if (entity.name === 'item' || entity.type === 'projectile') { mesh.scale.set(0.5, 0.5, 0.5) - mesh.position.set(0, 0.2, 0) + mesh.position.set(0, entity.name === 'item' ? 0.2 : 0.1, 0) } else { mesh.scale.set(2, 2, 2) mesh.position.set(0, 0.5, 0) @@ -746,8 +841,8 @@ export class Entities { // set faces // mesh.position.set(targetPos.x + 0.5 + 2, targetPos.y + 0.5, targetPos.z + 0.5) // viewer.scene.add(mesh) - const clock = new THREE.Clock() if (entity.name === 'item') { + const clock = new THREE.Clock() mesh.onBeforeRender = () => { const delta = clock.getDelta() mesh!.rotation.y += delta @@ -776,50 +871,27 @@ export class Entities { group.additionalCleanup = () => { // important: avoid texture memory leak and gpu slowdown - object.itemsTexture?.dispose() - object.itemsTextureFlipped?.dispose() + if (object.cleanup) { + object.cleanup() + } } } } } else if (isPlayerModel) { - // CREATE NEW PLAYER ENTITY const wrapper = new THREE.Group() - 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) + const playerObject = this.setupPlayerObject(entity, wrapper, overrides) + group.playerObject = playerObject + mesh = wrapper if (entity.username) { - // todo proper colors - const nameTag = new NameTagObject(fromFormattedString(entity.username).text, { - font: `48px ${this.entitiesOptions.fontFamily}`, - }) - nameTag.position.y = playerObject.position.y + playerObject.scale.y * 16 + 3 - nameTag.renderOrder = 1000 - - //@ts-expect-error - wrapper.add(nameTag) + const nametag = addNametag(entity, { fontFamily: 'mojangles' }, wrapper, this.worldRenderer.version) + if (nametag) { + nametag.position.y = playerObject.position.y + playerObject.scale.y * 16 + 3 + nametag.scale.multiplyScalar(12) + } } - - 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) + mesh = getEntityMesh(entity, this.worldRenderer, this.entitiesOptions, { ...overrides, customModel: entity['customModel'] }) } if (!mesh) return mesh.name = 'mesh' @@ -857,22 +929,12 @@ export class Entities { mesh = e.children.find(c => c.name === 'mesh') } - // 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]) - } + // Update equipment + this.updateEntityEquipment(e, entity) const meta = getGeneralEntitiesMetadata(entity) - //@ts-expect-error - // set visibility - const isInvisible = entity.metadata?.[0] & 0x20 + 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 ?? []) { if (child.name !== 'nametag') { child.visible = !isInvisible @@ -888,21 +950,22 @@ export class Entities { // entity specific meta const textDisplayMeta = getSpecificEntityMetadata('text_display', entity) const displayTextRaw = textDisplayMeta?.text || meta.custom_name_visible && meta.custom_name - const displayText = this.parseEntityLabel(displayTextRaw) - if (entity.name !== 'player' && displayText) { + if (entity.name !== 'player' && displayTextRaw) { const nameTagFixed = textDisplayMeta && (textDisplayMeta.billboard_render_constraints === 'fixed' || !textDisplayMeta.billboard_render_constraints) - const nameTagBackgroundColor = textDisplayMeta && toRgba(textDisplayMeta.background_color) + const nameTagBackgroundColor = (textDisplayMeta && (parseInt(textDisplayMeta.style_flags, 10) & 0x04) === 0) ? toRgba(textDisplayMeta.background_color) : undefined let nameTagTextOpacity: any if (textDisplayMeta?.text_opacity) { const rawOpacity = parseInt(textDisplayMeta?.text_opacity, 10) nameTagTextOpacity = rawOpacity > 0 ? rawOpacity : 256 - rawOpacity } addNametag( - { ...entity, username: displayText, nameTagBackgroundColor, nameTagTextOpacity, nameTagFixed, + { ...entity, username: typeof displayTextRaw === 'string' ? mojangson.simplify(mojangson.parse(displayTextRaw)) : nbt.simplify(displayTextRaw), + nameTagBackgroundColor, nameTagTextOpacity, nameTagFixed, nameTagScale: textDisplayMeta?.scale, nameTagTranslation: textDisplayMeta && (textDisplayMeta.translation || new THREE.Vector3(0, 0, 0)), nameTagRotationLeft: toQuaternion(textDisplayMeta?.left_rotation), nameTagRotationRight: toQuaternion(textDisplayMeta?.right_rotation) }, this.entitiesOptions, - mesh + mesh, + this.worldRenderer.version ) } @@ -1036,17 +1099,11 @@ export class Entities { } } - if (entity.username) { + if (entity.username !== undefined) { e.username = entity.username } - 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.updateNameTagVisibility(e) this.updateEntityPosition(entity, justAdded, overrides) } @@ -1077,17 +1134,20 @@ export class Entities { loadedSkinEntityIds = new Set() maybeRenderPlayerSkin (entityId: string) { - const mesh = this.entities[entityId] + let mesh = this.entities[entityId] + if (entityId === 'player_entity') { + mesh = this.playerEntity! + entityId = this.playerEntity?.originalEntity.id as any + } if (!mesh) return if (!mesh.playerObject) return if (!mesh.visible) return const MAX_DISTANCE_SKIN_LOAD = 128 - const cameraPos = this.worldRenderer.camera.position + const cameraPos = this.worldRenderer.cameraObject.position const distance = mesh.position.distanceTo(cameraPos) if (distance < MAX_DISTANCE_SKIN_LOAD && distance < (this.worldRenderer.viewDistance * 16)) { - if (this.loadedSkinEntityIds.has(entityId)) return - this.loadedSkinEntityIds.add(entityId) + if (this.loadedSkinEntityIds.has(String(entityId))) return void this.updatePlayerSkin(entityId, mesh.playerObject.realUsername, mesh.playerObject.realPlayerUuid, true, true) } } @@ -1112,6 +1172,20 @@ 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 @@ -1188,8 +1262,9 @@ export class Entities { const group = new THREE.Object3D() group['additionalCleanup'] = () => { // important: avoid texture memory leak and gpu slowdown - itemObject.itemsTexture?.dispose() - itemObject.itemsTextureFlipped?.dispose() + if (itemObject.cleanup) { + itemObject.cleanup() + } } const itemMesh = itemObject.mesh group.rotation.z = -Math.PI / 16 @@ -1236,13 +1311,63 @@ export class Entities { } } - raycastScene () { + raycastSceneDebug () { // 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> { @@ -1283,6 +1408,11 @@ 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) diff --git a/renderer/viewer/three/entity/EntityMesh.ts b/renderer/viewer/three/entity/EntityMesh.ts index 454bf35c..229da6d5 100644 --- a/renderer/viewer/three/entity/EntityMesh.ts +++ b/renderer/viewer/three/entity/EntityMesh.ts @@ -6,7 +6,7 @@ import ocelotPng from '../../../../node_modules/mc-assets/dist/other-textures/la import arrowTexture from '../../../../node_modules/mc-assets/dist/other-textures/1.21.2/entity/projectiles/arrow.png' import spectralArrowTexture from '../../../../node_modules/mc-assets/dist/other-textures/1.21.2/entity/projectiles/spectral_arrow.png' import tippedArrowTexture from '../../../../node_modules/mc-assets/dist/other-textures/1.21.2/entity/projectiles/tipped_arrow.png' -import { loadTexture } from '../../lib/utils' +import { loadTexture } from '../threeJsUtils' import { WorldRendererThree } from '../worldrendererThree' import entities from './entities.json' import { externalModels } from './objModels' diff --git a/renderer/viewer/three/entity/armorModels.ts b/renderer/viewer/three/entity/armorModels.ts index 3a87f8db..3681344c 100644 --- a/renderer/viewer/three/entity/armorModels.ts +++ b/renderer/viewer/three/entity/armorModels.ts @@ -14,6 +14,7 @@ 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 = { diff --git a/renderer/viewer/three/graphicsBackend.ts b/renderer/viewer/three/graphicsBackend.ts index 5ea89b34..04cb00ca 100644 --- a/renderer/viewer/three/graphicsBackend.ts +++ b/renderer/viewer/three/graphicsBackend.ts @@ -44,6 +44,12 @@ const getBackendMethods = (worldRenderer: WorldRendererThree) => { shakeFromDamage: worldRenderer.cameraShake.shakeFromDamage.bind(worldRenderer.cameraShake), onPageInteraction: worldRenderer.media.onPageInteraction.bind(worldRenderer.media), downloadMesherLog: worldRenderer.downloadMesherLog.bind(worldRenderer), + + addWaypoint: worldRenderer.waypoints.addWaypoint.bind(worldRenderer.waypoints), + removeWaypoint: worldRenderer.waypoints.removeWaypoint.bind(worldRenderer.waypoints), + + // New method for updating skybox + setSkyboxImage: worldRenderer.skyboxRenderer.setSkyboxImage.bind(worldRenderer.skyboxRenderer) } } diff --git a/renderer/viewer/lib/hand.ts b/renderer/viewer/three/hand.ts similarity index 95% rename from renderer/viewer/lib/hand.ts rename to renderer/viewer/three/hand.ts index 8bfa051b..2bd3832b 100644 --- a/renderer/viewer/lib/hand.ts +++ b/renderer/viewer/three/hand.ts @@ -1,6 +1,7 @@ import * as THREE from 'three' -import { loadSkinToCanvas } from 'skinview-utils' -import { loadSkinFromUsername, loadSkinImage, steveTexture } from './utils/skins' +import { loadSkinFromUsername, loadSkinImage } from '../lib/utils/skins' +import { steveTexture } from './entities' + export const getMyHand = async (image?: string, userName?: string) => { let newMap: THREE.Texture diff --git a/renderer/viewer/three/holdingBlock.ts b/renderer/viewer/three/holdingBlock.ts index 422076f0..f9d00f0e 100644 --- a/renderer/viewer/three/holdingBlock.ts +++ b/renderer/viewer/three/holdingBlock.ts @@ -4,12 +4,12 @@ import PrismarineItem from 'prismarine-item' import worldBlockProvider, { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider' import { BlockModel } from 'mc-assets' import { getThreeBlockModelGroup, renderBlockThree, setBlockPosition } from '../lib/mesher/standaloneRenderer' -import { getMyHand } from '../lib/hand' import { MovementState, PlayerStateRenderer } from '../lib/basePlayerState' import { DebugGui } from '../lib/DebugGui' import { SmoothSwitcher } from '../lib/smoothSwitcher' import { watchProperty } from '../lib/utils/proxy' import { WorldRendererConfig } from '../lib/worldrendererCommon' +import { getMyHand } from './hand' import { WorldRendererThree } from './worldrendererThree' import { disposeObject } from './threeJsUtils' @@ -116,12 +116,10 @@ export default class HoldingBlock { offHandModeLegacy = false swingAnimator: HandSwingAnimator | undefined - playerState: PlayerStateRenderer config: WorldRendererConfig constructor (public worldRenderer: WorldRendererThree, public offHand = false) { this.initCameraGroup() - this.playerState = worldRenderer.displayOptions.playerState this.worldRenderer.onReactivePlayerStateUpdated('heldItemMain', () => { if (!this.offHand) { this.updateItem() @@ -146,9 +144,9 @@ export default class HoldingBlock { // now watch over the player skin watchProperty( async () => { - return getMyHand(this.playerState.reactive.playerSkin, this.playerState.reactive.onlineMode ? this.playerState.reactive.username : undefined) + return getMyHand(this.worldRenderer.playerStateReactive.playerSkin, this.worldRenderer.playerStateReactive.onlineMode ? this.worldRenderer.playerStateReactive.username : undefined) }, - this.playerState.reactive, + this.worldRenderer.playerStateReactive, 'playerSkin', (newHand) => { if (newHand) { @@ -167,7 +165,7 @@ export default class HoldingBlock { updateItem () { if (!this.ready) return - const item = this.offHand ? this.playerState.reactive.heldItemOff : this.playerState.reactive.heldItemMain + const item = this.offHand ? this.worldRenderer.playerStateReactive.heldItemOff : this.worldRenderer.playerStateReactive.heldItemMain if (item) { void this.setNewItem(item) } else if (this.offHand) { @@ -357,9 +355,9 @@ export default class HoldingBlock { itemId: handItem.id, }, { 'minecraft:display_context': 'firstperson', - 'minecraft:use_duration': this.playerState.reactive.itemUsageTicks, - 'minecraft:using_item': !!this.playerState.reactive.itemUsageTicks, - }, this.lastItemModelName) + 'minecraft:use_duration': this.worldRenderer.playerStateReactive.itemUsageTicks, + 'minecraft:using_item': !!this.worldRenderer.playerStateReactive.itemUsageTicks, + }, false, this.lastItemModelName) if (result) { const { mesh: itemMesh, isBlock, modelName } = result if (isBlock) { @@ -475,7 +473,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.playerState) + this.idleAnimator = new HandIdleAnimator(this.holdingBlockInnerGroup, this.worldRenderer.playerStateReactive) } } @@ -710,7 +708,7 @@ class HandIdleAnimator { // Check for state changes from player state if (this.playerState) { - const newState = this.playerState.reactive.movementState + const newState = this.playerState.movementState if (newState !== this.targetState) { this.setState(newState) } diff --git a/renderer/viewer/three/itemMesh.ts b/renderer/viewer/three/itemMesh.ts new file mode 100644 index 00000000..3fa069b9 --- /dev/null +++ b/renderer/viewer/three/itemMesh.ts @@ -0,0 +1,427 @@ +import * as THREE from 'three' + +export interface Create3DItemMeshOptions { + depth: number + pixelSize?: number +} + +export interface Create3DItemMeshResult { + geometry: THREE.BufferGeometry + totalVertices: number + totalTriangles: number +} + +/** + * Creates a 3D item geometry with front/back faces and connecting edges + * from a canvas containing the item texture + */ +export function create3DItemMesh ( + canvas: HTMLCanvasElement, + options: Create3DItemMeshOptions +): Create3DItemMeshResult { + const { depth, pixelSize } = options + + // Validate canvas dimensions + if (canvas.width <= 0 || canvas.height <= 0) { + throw new Error(`Invalid canvas dimensions: ${canvas.width}x${canvas.height}`) + } + + const ctx = canvas.getContext('2d')! + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height) + const { data } = imageData + + const w = canvas.width + const h = canvas.height + const halfDepth = depth / 2 + const actualPixelSize = pixelSize ?? (1 / Math.max(w, h)) + + // Find opaque pixels + const isOpaque = (x: number, y: number) => { + if (x < 0 || y < 0 || x >= w || y >= h) return false + const i = (y * w + x) * 4 + return data[i + 3] > 128 // alpha > 128 + } + + const vertices: number[] = [] + const indices: number[] = [] + const uvs: number[] = [] + const normals: number[] = [] + + let vertexIndex = 0 + + // Helper to add a vertex + const addVertex = (x: number, y: number, z: number, u: number, v: number, nx: number, ny: number, nz: number) => { + vertices.push(x, y, z) + uvs.push(u, v) + normals.push(nx, ny, nz) + return vertexIndex++ + } + + // Helper to add a quad (two triangles) + const addQuad = (v0: number, v1: number, v2: number, v3: number) => { + indices.push(v0, v1, v2, v0, v2, v3) + } + + // Convert pixel coordinates to world coordinates + const pixelToWorld = (px: number, py: number) => { + const x = (px / w - 0.5) * actualPixelSize * w + const y = -(py / h - 0.5) * actualPixelSize * h + return { x, y } + } + + // Create a grid of vertices for front and back faces + const frontVertices: Array> = Array.from({ length: h + 1 }, () => Array.from({ length: w + 1 }, () => null)) + const backVertices: Array> = 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) +} diff --git a/renderer/viewer/three/panorama.ts b/renderer/viewer/three/panorama.ts index 58a0fffa..254b980c 100644 --- a/renderer/viewer/three/panorama.ts +++ b/renderer/viewer/three/panorama.ts @@ -7,12 +7,13 @@ import type { GraphicsInitOptions } from '../../../src/appViewer' import { WorldDataEmitter } from '../lib/worldDataEmitter' import { defaultWorldRendererConfig, WorldRendererCommon } from '../lib/worldrendererCommon' import { getDefaultRendererState } from '../baseGraphicsBackend' -import { loadThreeJsTextureFromUrl, loadThreeJsTextureFromUrlSync } from '../lib/utils/skins' import { ResourcesManager } from '../../../src/resourcesManager' import { getInitialPlayerStateRenderer } from '../lib/basePlayerState' +import { loadThreeJsTextureFromUrl, loadThreeJsTextureFromUrlSync } from './threeJsUtils' import { WorldRendererThree } from './worldrendererThree' import { EntityMesh } from './entity/EntityMesh' import { DocumentRenderer } from './documentRenderer' +import { PANORAMA_VERSION } from './panoramaShared' const panoramaFiles = [ 'panorama_3.png', // right (+x) @@ -156,7 +157,7 @@ export class PanoramaRenderer { } async worldBlocksPanorama () { - const version = '1.21.4' + const version = PANORAMA_VERSION const fullResourceManager = this.options.resourcesManager as ResourcesManager fullResourceManager.currentConfig = { version, noInventoryGui: true, } await fullResourceManager.updateAssetsData({ }) @@ -197,7 +198,7 @@ export class PanoramaRenderer { version, worldView, inWorldRenderingConfig: defaultWorldRendererConfig, - playerState: getInitialPlayerStateRenderer(), + playerStateReactive: getInitialPlayerStateRenderer().reactive, rendererState: getDefaultRendererState().reactive, nonReactiveState: getDefaultRendererState().nonReactive } diff --git a/renderer/viewer/three/panoramaShared.ts b/renderer/viewer/three/panoramaShared.ts new file mode 100644 index 00000000..ad80367f --- /dev/null +++ b/renderer/viewer/three/panoramaShared.ts @@ -0,0 +1 @@ +export const PANORAMA_VERSION = '1.21.4' diff --git a/renderer/viewer/three/renderSlot.ts b/renderer/viewer/three/renderSlot.ts index fd1eae91..321633eb 100644 --- a/renderer/viewer/three/renderSlot.ts +++ b/renderer/viewer/three/renderSlot.ts @@ -10,12 +10,11 @@ export type ResolvedItemModelRender = { export const renderSlot = (model: ResolvedItemModelRender, resourcesManager: ResourcesManagerCommon, debugIsQuickbar = false, fullBlockModelSupport = false): { texture: string, - blockData?: Record & { resolvedModel: BlockModel }, - scale?: number, - slice?: number[], - modelName?: string, - image?: ImageBitmap -} | undefined => { + blockData: Record & { resolvedModel: BlockModel } | null, + scale: number | null, + slice: number[] | null, + modelName: string | null, +} => { let itemModelName = model.modelName const isItem = loadedData.itemsByName[itemModelName] @@ -36,9 +35,10 @@ export const renderSlot = (model: ResolvedItemModelRender, resourcesManager: Res const y = item.v * atlas.height return { texture: 'gui', - image: resourcesManager.currentResources!.guiAtlas!.image, slice: [x, y, atlas.tileSize, atlas.tileSize], scale: 0.25, + blockData: null, + modelName: null } } } @@ -65,14 +65,18 @@ export const renderSlot = (model: ResolvedItemModelRender, resourcesManager: Res return { texture: itemTexture.type, slice: itemTexture.slice, - modelName: itemModelName + modelName: itemModelName, + blockData: null, + scale: null } } else { // is block return { texture: 'blocks', blockData: itemTexture, - modelName: itemModelName + modelName: itemModelName, + slice: null, + scale: null } } } diff --git a/renderer/viewer/three/skyboxRenderer.ts b/renderer/viewer/three/skyboxRenderer.ts new file mode 100644 index 00000000..fb9edae6 --- /dev/null +++ b/renderer/viewer/three/skyboxRenderer.ts @@ -0,0 +1,406 @@ +import * as THREE from 'three' +import { DebugGui } from '../lib/DebugGui' + +export const DEFAULT_TEMPERATURE = 0.75 + +export class SkyboxRenderer { + private texture: THREE.Texture | null = null + private mesh: THREE.Mesh | 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) + } + } +} diff --git a/renderer/viewer/three/threeJsSound.ts b/renderer/viewer/three/threeJsSound.ts index 627cabf8..699bb2cc 100644 --- a/renderer/viewer/three/threeJsSound.ts +++ b/renderer/viewer/three/threeJsSound.ts @@ -2,7 +2,7 @@ import * as THREE from 'three' import { WorldRendererThree } from './worldrendererThree' export interface SoundSystem { - playSound: (position: { x: number, y: number, z: number }, path: string, volume?: number, pitch?: number) => void + playSound: (position: { x: number, y: number, z: number }, path: string, volume?: number, pitch?: number, timeout?: number) => void destroy: () => void } @@ -10,7 +10,17 @@ export class ThreeJsSound implements SoundSystem { audioListener: THREE.AudioListener | undefined private readonly activeSounds = new Set() private readonly audioContext: AudioContext | undefined + private readonly soundVolumes = new Map() + baseVolume = 1 + constructor (public worldRenderer: WorldRendererThree) { + worldRenderer.onWorldSwitched.push(() => { + this.stopAll() + }) + + worldRenderer.onReactiveConfigUpdated('volume', (volume) => { + this.changeVolume(volume) + }) } initAudioListener () { @@ -19,41 +29,63 @@ export class ThreeJsSound implements SoundSystem { this.worldRenderer.camera.add(this.audioListener) } - playSound (position: { x: number, y: number, z: number }, path: string, volume = 1, pitch = 1) { + playSound (position: { x: number, y: number, z: number }, path: string, volume = 1, pitch = 1, timeout = 500) { this.initAudioListener() const sound = new THREE.PositionalAudio(this.audioListener!) this.activeSounds.add(sound) + this.soundVolumes.set(sound, volume) const audioLoader = new THREE.AudioLoader() const start = Date.now() void audioLoader.loadAsync(path).then((buffer) => { - if (Date.now() - start > 500) return + if (Date.now() - start > timeout) { + console.warn('Ignored playing sound', path, 'due to timeout:', timeout, 'ms <', Date.now() - start, 'ms') + return + } // play sound.setBuffer(buffer) sound.setRefDistance(20) - sound.setVolume(volume) + sound.setVolume(volume * this.baseVolume) sound.setPlaybackRate(pitch) // set the pitch this.worldRenderer.scene.add(sound) // set sound position sound.position.set(position.x, position.y, position.z) sound.onEnded = () => { this.worldRenderer.scene.remove(sound) - sound.disconnect() + if (sound.source) { + sound.disconnect() + } this.activeSounds.delete(sound) + this.soundVolumes.delete(sound) audioLoader.manager.itemEnd(path) } sound.play() }) } - destroy () { - // Stop and clean up all active sounds + stopAll () { for (const sound of this.activeSounds) { + if (!sound) continue sound.stop() - sound.disconnect() + if (sound.source) { + sound.disconnect() + } + this.worldRenderer.scene.remove(sound) } + this.activeSounds.clear() + this.soundVolumes.clear() + } + changeVolume (volume: number) { + this.baseVolume = volume + for (const [sound, individualVolume] of this.soundVolumes) { + sound.setVolume(individualVolume * this.baseVolume) + } + } + + destroy () { + this.stopAll() // Remove and cleanup audio listener if (this.audioListener) { this.audioListener.removeFromParent() diff --git a/renderer/viewer/three/threeJsUtils.ts b/renderer/viewer/three/threeJsUtils.ts index 5ae3b24f..cbef9065 100644 --- a/renderer/viewer/three/threeJsUtils.ts +++ b/renderer/viewer/three/threeJsUtils.ts @@ -1,4 +1,6 @@ import * as THREE from 'three' +import { getLoadedImage } from 'mc-assets/dist/utils' +import { createCanvas } from '../lib/utils' export const disposeObject = (obj: THREE.Object3D, cleanTextures = false) => { // not cleaning texture there as it might be used by other objects, but would be good to also do that @@ -16,3 +18,56 @@ export const disposeObject = (obj: THREE.Object3D, cleanTextures = false) => { } } } + +let textureCache: Record = {} +let imagesPromises: Record> = {} + +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 { + const cached = textureCache[texture] + if (!cached) { + const { promise, resolve } = Promise.withResolvers() + 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 = {} +} + diff --git a/renderer/viewer/three/waypointSprite.ts b/renderer/viewer/three/waypointSprite.ts new file mode 100644 index 00000000..6a30e6db --- /dev/null +++ b/renderer/viewer/three/waypointSprite.ts @@ -0,0 +1,418 @@ +import * as THREE from 'three' + +// Centralized visual configuration (in screen pixels) +export const WAYPOINT_CONFIG = { + // Target size in screen pixels (this controls the final sprite size) + TARGET_SCREEN_PX: 150, + // Canvas size for internal rendering (keep power of 2 for textures) + CANVAS_SIZE: 256, + // Relative positions in canvas (0-1) + LAYOUT: { + DOT_Y: 0.3, + NAME_Y: 0.45, + DISTANCE_Y: 0.55, + }, + // Multiplier for canvas internal resolution to keep text crisp + CANVAS_SCALE: 2, + ARROW: { + enabledDefault: false, + pixelSize: 50, + paddingPx: 50, + }, +} + +export type WaypointSprite = { + group: THREE.Group + sprite: THREE.Sprite + // Offscreen arrow controls + enableOffscreenArrow: (enabled: boolean) => void + setArrowParent: (parent: THREE.Object3D | null) => void + // Convenience combined updater + updateForCamera: ( + cameraPosition: THREE.Vector3, + camera: THREE.PerspectiveCamera, + viewportWidthPx: number, + viewportHeightPx: number + ) => boolean + // Utilities + setColor: (color: number) => void + setLabel: (label?: string) => void + updateDistanceText: (label: string, distanceText: string) => void + setVisible: (visible: boolean) => void + setPosition: (x: number, y: number, z: number) => void + dispose: () => void +} + +export function createWaypointSprite (options: { + position: THREE.Vector3 | { x: number, y: number, z: number }, + color?: number, + label?: string, + depthTest?: boolean, + // Y offset in world units used by updateScaleWorld only (screen-pixel API ignores this) + labelYOffset?: number, + metadata?: any, +}): WaypointSprite { + const color = options.color ?? 0xFF_00_00 + const depthTest = options.depthTest ?? false + const labelYOffset = options.labelYOffset ?? 1.5 + + // Build combined sprite + const sprite = createCombinedSprite(color, options.label ?? '', '0m', depthTest) + sprite.renderOrder = 10 + let currentLabel = options.label ?? '' + + // Offscreen arrow (detached by default) + let arrowSprite: THREE.Sprite | undefined + let arrowParent: THREE.Object3D | null = null + let arrowEnabled = WAYPOINT_CONFIG.ARROW.enabledDefault + + // Group for easy add/remove + const group = new THREE.Group() + group.add(sprite) + + // Initial position + const { x, y, z } = options.position + group.position.set(x, y, z) + + function setColor (newColor: number) { + const canvas = drawCombinedCanvas(newColor, currentLabel, '0m') + const texture = new THREE.CanvasTexture(canvas) + const mat = sprite.material + mat.map?.dispose() + mat.map = texture + mat.needsUpdate = true + } + + function setLabel (newLabel?: string) { + currentLabel = newLabel ?? '' + const canvas = drawCombinedCanvas(color, currentLabel, '0m') + const texture = new THREE.CanvasTexture(canvas) + const mat = sprite.material + mat.map?.dispose() + mat.map = texture + mat.needsUpdate = true + } + + function updateDistanceText (label: string, distanceText: string) { + const canvas = drawCombinedCanvas(color, label, distanceText) + const texture = new THREE.CanvasTexture(canvas) + const mat = sprite.material + mat.map?.dispose() + mat.map = texture + mat.needsUpdate = true + } + + function setVisible (visible: boolean) { + sprite.visible = visible + } + + function setPosition (nx: number, ny: number, nz: number) { + group.position.set(nx, ny, nz) + } + + // Keep constant pixel size on screen using global config + function updateScaleScreenPixels ( + cameraPosition: THREE.Vector3, + cameraFov: number, + distance: number, + viewportHeightPx: number + ) { + const vFovRad = cameraFov * Math.PI / 180 + const worldUnitsPerScreenHeightAtDist = Math.tan(vFovRad / 2) * 2 * distance + // Use configured target screen size + const scale = worldUnitsPerScreenHeightAtDist * (WAYPOINT_CONFIG.TARGET_SCREEN_PX / viewportHeightPx) + sprite.scale.set(scale, scale, 1) + } + + function ensureArrow () { + if (arrowSprite) return + const size = 128 + const canvas = document.createElement('canvas') + canvas.width = size + canvas.height = size + const ctx = canvas.getContext('2d')! + ctx.clearRect(0, 0, size, size) + + // Draw arrow shape + ctx.beginPath() + ctx.moveTo(size * 0.15, size * 0.5) + ctx.lineTo(size * 0.85, size * 0.5) + ctx.lineTo(size * 0.5, size * 0.15) + ctx.closePath() + + // Use waypoint color for arrow + const colorHex = `#${color.toString(16).padStart(6, '0')}` + ctx.lineWidth = 6 + ctx.strokeStyle = 'black' + ctx.stroke() + ctx.fillStyle = colorHex + ctx.fill() + + const texture = new THREE.CanvasTexture(canvas) + const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false, depthWrite: false }) + arrowSprite = new THREE.Sprite(material) + arrowSprite.renderOrder = 12 + arrowSprite.visible = false + if (arrowParent) arrowParent.add(arrowSprite) + } + + function enableOffscreenArrow (enabled: boolean) { + arrowEnabled = enabled + if (!enabled && arrowSprite) arrowSprite.visible = false + } + + function setArrowParent (parent: THREE.Object3D | null) { + if (arrowSprite?.parent) arrowSprite.parent.remove(arrowSprite) + arrowParent = parent + if (arrowSprite && parent) parent.add(arrowSprite) + } + + function updateOffscreenArrow ( + camera: THREE.PerspectiveCamera, + viewportWidthPx: number, + viewportHeightPx: number + ): boolean { + if (!arrowEnabled) return true + ensureArrow() + if (!arrowSprite) return true + + // Check if onlyLeftRight is enabled in metadata + const onlyLeftRight = options.metadata?.onlyLeftRight === true + + // Build camera basis using camera.up to respect custom orientations + const forward = new THREE.Vector3() + camera.getWorldDirection(forward) // camera look direction + const upWorld = camera.up.clone().normalize() + const right = new THREE.Vector3().copy(forward).cross(upWorld).normalize() + const upCam = new THREE.Vector3().copy(right).cross(forward).normalize() + + // Vector from camera to waypoint + const camPos = new THREE.Vector3().setFromMatrixPosition(camera.matrixWorld) + const toWp = new THREE.Vector3(group.position.x, group.position.y, group.position.z).sub(camPos) + + // Components in camera basis + const z = toWp.dot(forward) + const x = toWp.dot(right) + const y = toWp.dot(upCam) + + const aspect = viewportWidthPx / viewportHeightPx + const vFovRad = camera.fov * Math.PI / 180 + const hFovRad = 2 * Math.atan(Math.tan(vFovRad / 2) * aspect) + + // Determine if waypoint is inside view frustum using angular checks + const thetaX = Math.atan2(x, z) + const thetaY = Math.atan2(y, z) + const visible = z > 0 && Math.abs(thetaX) <= hFovRad / 2 && Math.abs(thetaY) <= vFovRad / 2 + if (visible) { + arrowSprite.visible = false + return true + } + + // Direction on screen in normalized frustum units + let rx = thetaX / (hFovRad / 2) + let ry = thetaY / (vFovRad / 2) + + // If behind the camera, snap to dominant axis to avoid confusing directions + if (z <= 0) { + if (Math.abs(rx) > Math.abs(ry)) { + rx = Math.sign(rx) + ry = 0 + } else { + rx = 0 + ry = Math.sign(ry) + } + } + + // Apply onlyLeftRight logic - restrict arrows to left/right edges only + if (onlyLeftRight) { + // Force the arrow to appear only on left or right edges + if (Math.abs(rx) > Math.abs(ry)) { + // Horizontal direction is dominant, keep it + ry = 0 + } else { + // Vertical direction is dominant, but we want only left/right + // So choose left or right based on the sign of rx + rx = rx >= 0 ? 1 : -1 + ry = 0 + } + } + + // Place on the rectangle border [-1,1]x[-1,1] + const s = Math.max(Math.abs(rx), Math.abs(ry)) || 1 + let ndcX = rx / s + let ndcY = ry / s + + // Apply padding in pixel space by clamping + const padding = WAYPOINT_CONFIG.ARROW.paddingPx + const pxX = ((ndcX + 1) * 0.5) * viewportWidthPx + const pxY = ((1 - ndcY) * 0.5) * viewportHeightPx + const clampedPxX = Math.min(Math.max(pxX, padding), viewportWidthPx - padding) + const clampedPxY = Math.min(Math.max(pxY, padding), viewportHeightPx - padding) + ndcX = (clampedPxX / viewportWidthPx) * 2 - 1 + ndcY = -(clampedPxY / viewportHeightPx) * 2 + 1 + + // Compute world position at a fixed distance in front of the camera using camera basis + const placeDist = Math.max(2, camera.near * 4) + const halfPlaneHeight = Math.tan(vFovRad / 2) * placeDist + const halfPlaneWidth = halfPlaneHeight * aspect + const pos = camPos.clone() + .add(forward.clone().multiplyScalar(placeDist)) + .add(right.clone().multiplyScalar(ndcX * halfPlaneWidth)) + .add(upCam.clone().multiplyScalar(ndcY * halfPlaneHeight)) + + // Update arrow sprite + arrowSprite.visible = true + arrowSprite.position.copy(pos) + + // Angle for rotation relative to screen right/up (derived from camera up vector) + const angle = Math.atan2(ry, rx) + arrowSprite.material.rotation = angle - Math.PI / 2 + + // Constant pixel size for arrow (use fixed placement distance) + const worldUnitsPerScreenHeightAtDist = Math.tan(vFovRad / 2) * 2 * placeDist + const sPx = worldUnitsPerScreenHeightAtDist * (WAYPOINT_CONFIG.ARROW.pixelSize / viewportHeightPx) + arrowSprite.scale.set(sPx, sPx, 1) + return false + } + + function computeDistance (cameraPosition: THREE.Vector3): number { + return cameraPosition.distanceTo(group.position) + } + + function updateForCamera ( + cameraPosition: THREE.Vector3, + camera: THREE.PerspectiveCamera, + viewportWidthPx: number, + viewportHeightPx: number + ): boolean { + const distance = computeDistance(cameraPosition) + // Keep constant pixel size + updateScaleScreenPixels(cameraPosition, camera.fov, distance, viewportHeightPx) + // Update text + updateDistanceText(currentLabel, `${Math.round(distance)}m`) + // Update arrow and visibility + const onScreen = updateOffscreenArrow(camera, viewportWidthPx, viewportHeightPx) + setVisible(onScreen) + return onScreen + } + + function dispose () { + const mat = sprite.material + mat.map?.dispose() + mat.dispose() + if (arrowSprite) { + const am = arrowSprite.material + am.map?.dispose() + am.dispose() + } + } + + return { + group, + sprite, + enableOffscreenArrow, + setArrowParent, + updateForCamera, + setColor, + setLabel, + updateDistanceText, + setVisible, + setPosition, + dispose, + } +} + +// Internal helpers +function drawCombinedCanvas (color: number, id: string, distance: string): HTMLCanvasElement { + const scale = WAYPOINT_CONFIG.CANVAS_SCALE * (globalThis.devicePixelRatio || 1) + const size = WAYPOINT_CONFIG.CANVAS_SIZE * scale + const canvas = document.createElement('canvas') + canvas.width = size + canvas.height = size + const ctx = canvas.getContext('2d')! + + // Clear canvas + ctx.clearRect(0, 0, size, size) + + // Draw dot + const centerX = size / 2 + const dotY = Math.round(size * WAYPOINT_CONFIG.LAYOUT.DOT_Y) + const radius = Math.round(size * 0.05) // Dot takes up ~12% of canvas height + const borderWidth = Math.max(2, Math.round(4 * scale)) + + // Outer border (black) + ctx.beginPath() + ctx.arc(centerX, dotY, radius + borderWidth, 0, Math.PI * 2) + ctx.fillStyle = 'black' + ctx.fill() + + // Inner circle (colored) + ctx.beginPath() + ctx.arc(centerX, dotY, radius, 0, Math.PI * 2) + ctx.fillStyle = `#${color.toString(16).padStart(6, '0')}` + ctx.fill() + + // Text properties + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + // Title + const nameFontPx = Math.round(size * 0.08) // ~8% of canvas height + const distanceFontPx = Math.round(size * 0.06) // ~6% of canvas height + ctx.font = `bold ${nameFontPx}px mojangles` + ctx.lineWidth = Math.max(2, Math.round(3 * scale)) + const nameY = Math.round(size * WAYPOINT_CONFIG.LAYOUT.NAME_Y) + + ctx.strokeStyle = 'black' + ctx.strokeText(id, centerX, nameY) + ctx.fillStyle = 'white' + ctx.fillText(id, centerX, nameY) + + // Distance + ctx.font = `bold ${distanceFontPx}px mojangles` + ctx.lineWidth = Math.max(2, Math.round(2 * scale)) + const distanceY = Math.round(size * WAYPOINT_CONFIG.LAYOUT.DISTANCE_Y) + + ctx.strokeStyle = 'black' + ctx.strokeText(distance, centerX, distanceY) + ctx.fillStyle = '#CCCCCC' + ctx.fillText(distance, centerX, distanceY) + + return canvas +} + +function createCombinedSprite (color: number, id: string, distance: string, depthTest: boolean): THREE.Sprite { + const canvas = drawCombinedCanvas(color, id, distance) + const texture = new THREE.CanvasTexture(canvas) + texture.anisotropy = 1 + texture.magFilter = THREE.LinearFilter + texture.minFilter = THREE.LinearFilter + const material = new THREE.SpriteMaterial({ + map: texture, + transparent: true, + opacity: 1, + depthTest, + depthWrite: false, + }) + const sprite = new THREE.Sprite(material) + sprite.position.set(0, 0, 0) + return sprite +} + +export const WaypointHelpers = { + // World-scale constant size helper + computeWorldScale (distance: number, fixedReference = 10) { + return Math.max(0.0001, distance / fixedReference) + }, + // Screen-pixel constant size helper + computeScreenPixelScale ( + camera: THREE.PerspectiveCamera, + distance: number, + pixelSize: number, + viewportHeightPx: number + ) { + const vFovRad = camera.fov * Math.PI / 180 + const worldUnitsPerScreenHeightAtDist = Math.tan(vFovRad / 2) * 2 * distance + return worldUnitsPerScreenHeightAtDist * (pixelSize / viewportHeightPx) + } +} diff --git a/renderer/viewer/three/waypoints.ts b/renderer/viewer/three/waypoints.ts new file mode 100644 index 00000000..256ca6df --- /dev/null +++ b/renderer/viewer/three/waypoints.ts @@ -0,0 +1,140 @@ +import * as THREE from 'three' +import { WorldRendererThree } from './worldrendererThree' +import { createWaypointSprite, type WaypointSprite } from './waypointSprite' + +interface Waypoint { + id: string + x: number + y: number + z: number + minDistance: number + color: number + label?: string + sprite: WaypointSprite +} + +interface WaypointOptions { + color?: number + label?: string + minDistance?: number + metadata?: any +} + +export class WaypointsRenderer { + private readonly waypoints = new Map() + 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) + } + } +} diff --git a/renderer/viewer/three/world/cursorBlock.ts b/renderer/viewer/three/world/cursorBlock.ts index 495a2d55..a03a6999 100644 --- a/renderer/viewer/three/world/cursorBlock.ts +++ b/renderer/viewer/three/world/cursorBlock.ts @@ -1,10 +1,9 @@ import * as THREE from 'three' import { LineMaterial, LineSegmentsGeometry, Wireframe } from 'three-stdlib' import { Vec3 } from 'vec3' -import { subscribeKey } from 'valtio/utils' -import { Block } from 'prismarine-block' import { BlockShape, BlocksShapes } from 'renderer/viewer/lib/basePlayerState' import { WorldRendererThree } from '../worldrendererThree' +import { loadThreeJsTextureFromUrl } from '../threeJsUtils' import destroyStage0 from '../../../../assets/destroy_stage_0.png' import destroyStage1 from '../../../../assets/destroy_stage_1.png' import destroyStage2 from '../../../../assets/destroy_stage_2.png' @@ -15,7 +14,6 @@ import destroyStage6 from '../../../../assets/destroy_stage_6.png' import destroyStage7 from '../../../../assets/destroy_stage_7.png' import destroyStage8 from '../../../../assets/destroy_stage_8.png' import destroyStage9 from '../../../../assets/destroy_stage_9.png' -import { loadThreeJsTextureFromUrl } from '../../lib/utils/skins' export class CursorBlock { _cursorLinesHidden = false @@ -30,7 +28,7 @@ export class CursorBlock { } cursorLineMaterial: LineMaterial - interactionLines: null | { blockPos: Vec3, mesh: THREE.Group } = null + interactionLines: null | { blockPos: Vec3, mesh: THREE.Group, shapePositions: BlocksShapes | undefined } = null prevColor: string | undefined blockBreakMesh: THREE.Mesh breakTextures: THREE.Texture[] = [] @@ -61,18 +59,26 @@ export class CursorBlock { this.blockBreakMesh.name = 'blockBreakMesh' this.worldRenderer.scene.add(this.blockBreakMesh) - subscribeKey(this.worldRenderer.playerState.reactive, 'gameMode', () => { + this.worldRenderer.onReactivePlayerStateUpdated('gameMode', () => { this.updateLineMaterial() }) - - this.updateLineMaterial() + // todo figure out why otherwise fog from skybox breaks it + setTimeout(() => { + this.updateLineMaterial() + if (this.interactionLines) { + this.setHighlightCursorBlock(this.interactionLines.blockPos, this.interactionLines.shapePositions, true) + } + }) } // Update functions updateLineMaterial () { - const inCreative = this.worldRenderer.displayOptions.playerState.reactive.gameMode === 'creative' + const inCreative = this.worldRenderer.playerStateReactive.gameMode === 'creative' const pixelRatio = this.worldRenderer.renderer.getPixelRatio() + if (this.cursorLineMaterial) { + this.cursorLineMaterial.dispose() + } this.cursorLineMaterial = new LineMaterial({ color: (() => { switch (this.worldRenderer.worldRendererConfig.highlightBlockColor) { @@ -119,8 +125,8 @@ export class CursorBlock { } } - setHighlightCursorBlock (blockPos: Vec3 | null, shapePositions?: BlocksShapes): void { - if (blockPos && this.interactionLines && blockPos.equals(this.interactionLines.blockPos)) { + setHighlightCursorBlock (blockPos: Vec3 | null, shapePositions?: BlocksShapes, force = false): void { + if (blockPos && this.interactionLines && blockPos.equals(this.interactionLines.blockPos) && !force) { return } if (this.interactionLines !== null) { @@ -144,7 +150,7 @@ export class CursorBlock { } this.worldRenderer.scene.add(group) group.visible = !this.cursorLinesHidden - this.interactionLines = { blockPos, mesh: group } + this.interactionLines = { blockPos, mesh: group, shapePositions } } render () { diff --git a/renderer/viewer/three/world/vr.ts b/renderer/viewer/three/world/vr.ts index c2665585..ecf1b299 100644 --- a/renderer/viewer/three/world/vr.ts +++ b/renderer/viewer/three/world/vr.ts @@ -102,7 +102,7 @@ export async function initVR (worldRenderer: WorldRendererThree, documentRendere // hack for vr camera const user = new THREE.Group() - user.add(worldRenderer.camera) + user.name = 'vr-camera-container' worldRenderer.scene.add(user) const controllerModelFactory = new XRControllerModelFactory(new GLTFLoader()) const controller1 = renderer.xr.getControllerGrip(0) @@ -202,10 +202,12 @@ export async function initVR (worldRenderer: WorldRendererThree, documentRendere documentRenderer.frameRender(false) }) renderer.xr.addEventListener('sessionstart', () => { + user.add(worldRenderer.camera) worldRenderer.cameraGroupVr = user }) renderer.xr.addEventListener('sessionend', () => { worldRenderer.cameraGroupVr = undefined + user.remove(worldRenderer.camera) }) worldRenderer.abortController.signal.addEventListener('abort', disableVr) diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index 78381a29..1b4e6152 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -3,6 +3,7 @@ import { Vec3 } from 'vec3' import nbt from 'prismarine-nbt' import PrismarineChatLoader from 'prismarine-chat' import * as tweenJs from '@tweenjs/tween.js' +import { Biome } from 'minecraft-data' import { renderSign } from '../sign-renderer' import { DisplayWorldOptions, GraphicsInitOptions } from '../../../src/appViewer' import { chunkPos, sectionPos } from '../lib/simpleUtils' @@ -10,13 +11,12 @@ import { WorldRendererCommon } from '../lib/worldrendererCommon' import { addNewStat } from '../lib/ui/newStats' import { MesherGeometryOutput } from '../lib/mesher/shared' import { ItemSpecificContextProperties } from '../lib/basePlayerState' -import { getMyHand } from '../lib/hand' import { setBlockPosition } from '../lib/mesher/standaloneRenderer' -import { loadThreeJsTextureFromBitmap } from '../lib/utils/skins' +import { getMyHand } from './hand' import HoldingBlock from './holdingBlock' import { getMesh } from './entity/EntityMesh' import { armorModel } from './entity/armorModels' -import { disposeObject } from './threeJsUtils' +import { disposeObject, loadThreeJsTextureFromBitmap } from './threeJsUtils' import { CursorBlock } from './world/cursorBlock' import { getItemUv } from './appShared' import { Entities } from './entities' @@ -24,6 +24,8 @@ import { ThreeJsSound } from './threeJsSound' import { CameraShake } from './cameraShake' import { ThreeJsMedia } from './threeJsMedia' import { Fountain } from './threeJsParticles' +import { WaypointsRenderer } from './waypoints' +import { DEFAULT_TEMPERATURE, SkyboxRenderer } from './skyboxRenderer' type SectionKey = string @@ -46,8 +48,10 @@ export class WorldRendererThree extends WorldRendererCommon { cursorBlock: CursorBlock onRender: Array<() => void> = [] cameraShake: CameraShake + cameraContainer: THREE.Object3D media: ThreeJsMedia waitingChunksToDisplay = {} as { [chunkKey: string]: SectionKey[] } + waypoints: WaypointsRenderer camera: THREE.PerspectiveCamera renderTimeAvg = 0 sectionsOffsetsAnimations = {} as { @@ -68,6 +72,11 @@ export class WorldRendererThree extends WorldRendererCommon { } } fountains: Fountain[] = [] + DEBUG_RAYCAST = false + skyboxRenderer: SkyboxRenderer + + private currentPosTween?: tweenJs.Tween + private currentRotTween?: tweenJs.Tween<{ pitch: number, yaw: number }> get tilesRendered () { return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj as any).tilesCount, 0) @@ -83,11 +92,15 @@ export class WorldRendererThree extends WorldRendererCommon { this.renderer = renderer displayOptions.rendererState.renderer = WorldRendererThree.getRendererInfo(renderer) ?? '...' - this.starField = new StarField(this.scene) + this.starField = new StarField(this) this.cursorBlock = new CursorBlock(this) 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() @@ -95,6 +108,8 @@ export class WorldRendererThree extends WorldRendererCommon { this.soundSystem = new ThreeJsSound(this) this.cameraShake = new CameraShake(this, this.onRender) this.media = new ThreeJsMedia(this) + this.waypoints = new WaypointsRenderer(this) + // this.fountain = new Fountain(this.scene, this.scene, { // position: new THREE.Vector3(0, 10, 0), // }) @@ -106,7 +121,7 @@ export class WorldRendererThree extends WorldRendererCommon { } get cameraObject () { - return this.cameraGroupVr || this.camera + return this.cameraGroupVr ?? this.cameraContainer } worldSwitchActions () { @@ -115,6 +130,8 @@ export class WorldRendererThree extends WorldRendererCommon { this.protocolCustomBlocks.clear() // Reset section animations this.sectionsOffsetsAnimations = {} + // Clear waypoints + this.waypoints.clear() }) } @@ -135,6 +152,10 @@ 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) @@ -145,12 +166,18 @@ 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.scene.fog = value ? new THREE.Fog(0x00_00_ff, 0.1, this.displayOptions.playerState.reactive.waterBreathing ? 100 : 20) : null + this.skyboxRenderer.updateWaterState(value, this.playerStateReactive.waterBreathing) + }) + this.onReactivePlayerStateUpdated('waterBreathing', (value) => { + this.skyboxRenderer.updateWaterState(this.playerStateReactive.inWater, value) }) this.onReactivePlayerStateUpdated('ambientLight', (value) => { if (!value) return @@ -166,6 +193,12 @@ export class WorldRendererThree extends WorldRendererCommon { this.onReactivePlayerStateUpdated('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 () { @@ -173,6 +206,9 @@ export class WorldRendererThree extends WorldRendererCommon { this.onReactiveConfigUpdated('showChunkBorders', (value) => { this.updateShowChunksBorder(value) }) + this.onReactiveConfigUpdated('defaultSkybox', (value) => { + this.skyboxRenderer.updateDefaultSkybox(value) + }) } changeHandSwingingState (isAnimationPlaying: boolean, isLeft = false) { @@ -235,10 +271,23 @@ 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, specificProps: ItemSpecificContextProperties) { - return getItemUv(item, specificProps, this.resourcesManager, this.playerState) + return getItemUv(item, specificProps, this.resourcesManager, this.playerStateReactive) } async demoModel () { @@ -300,10 +349,11 @@ export class WorldRendererThree extends WorldRendererCommon { section.renderOrder = 500 - chunkDistance } - 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) + override updateViewerPosition (pos: Vec3): void { + this.viewerChunkPosition = pos + } + + cameraSectionPositionUpdate () { // eslint-disable-next-line guard-for-in for (const key in this.sectionObjects) { const value = this.sectionObjects[key] @@ -407,7 +457,7 @@ export class WorldRendererThree extends WorldRendererCommon { this.scene.add(object) } - getSignTexture (position: Vec3, blockEntity, backSide = false) { + getSignTexture (position: Vec3, blockEntity, isHanging, backSide = false) { const chunk = chunkPos(position) let textures = this.chunkTextures.get(`${chunk[0]},${chunk[1]}`) if (!textures) { @@ -419,7 +469,7 @@ export class WorldRendererThree extends WorldRendererCommon { if (textures[texturekey]) return textures[texturekey] const PrismarineChat = PrismarineChatLoader(this.version) - const canvas = renderSign(blockEntity, PrismarineChat) + const canvas = renderSign(blockEntity, isHanging, PrismarineChat) if (!canvas) return const tex = new THREE.Texture(canvas) tex.magFilter = THREE.NearestFilter @@ -429,13 +479,149 @@ 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.displayOptions.playerState.reactive.eyeHeight + const yOffset = this.playerStateReactive.eyeHeight 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 @@ -448,10 +634,66 @@ export class WorldRendererThree extends WorldRendererCommon { pos.y -= this.camera.position.y // Fix Y position of camera in world } - new tweenJs.Tween(this.cameraObject.position).to({ x: pos.x, y: pos.y, z: pos.z }, 50).start() + 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() // this.freeFlyState.position = pos } - this.cameraShake.setBaseRotation(pitch, yaw) + + 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 () { @@ -492,6 +734,10 @@ export class WorldRendererThree extends WorldRendererCommon { this.cursorBlock.render() this.updateSectionOffsets() + // Update skybox position to follow camera + const cameraPos = this.getCameraPosition() + this.skyboxRenderer.update(cameraPos, this.viewDistance) + const sizeOrFovChanged = sizeChanged || this.displayOptions.inWorldRenderingConfig.fov !== this.camera.fov if (sizeOrFovChanged) { const size = this.renderer.getSize(new THREE.Vector2()) @@ -508,7 +754,13 @@ export class WorldRendererThree extends WorldRendererCommon { 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.playerState.reactive.gameMode !== 'spectator' /* && !this.freeFlyMode */ && !this.renderer.xr.isPresenting) { + if ( + this.displayOptions.inWorldRenderingConfig.showHand && + this.playerStateReactive.gameMode !== 'spectator' && + this.playerStateReactive.perspective === 'first_person' && + // !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) } @@ -521,6 +773,8 @@ export class WorldRendererThree extends WorldRendererCommon { fountain.render() } + this.waypoints.render() + for (const onRender of this.onRender) { onRender() } @@ -533,12 +787,22 @@ export class WorldRendererThree extends WorldRendererCommon { } renderHead (position: Vec3, rotation: number, isWall: boolean, blockEntity) { - const textures = blockEntity.SkullOwner?.Properties?.textures[0] - if (!textures) return + let textureData: string + if (blockEntity.SkullOwner) { + textureData = blockEntity.SkullOwner.Properties?.textures?.[0]?.Value + } else { + textureData = blockEntity.profile?.properties?.find(p => p.name === 'textures')?.value + } + if (!textureData) return try { - const textureData = JSON.parse(Buffer.from(textures.Value, 'base64').toString()) - const skinUrl = textureData.textures?.SKIN?.url + const decodedData = JSON.parse(Buffer.from(textureData, 'base64').toString()) + let skinUrl = decodedData.textures?.SKIN?.url + const { skinTexturesProxy } = this.worldRendererConfig + if (skinTexturesProxy) { + skinUrl = skinUrl?.replace('http://textures.minecraft.net/', skinTexturesProxy) + .replace('https://textures.minecraft.net/', skinTexturesProxy) + } const mesh = getMesh(this, skinUrl, armorModel.head) const group = new THREE.Group() @@ -562,7 +826,7 @@ export class WorldRendererThree extends WorldRendererCommon { } renderSign (position: Vec3, rotation: number, isWall: boolean, isHanging: boolean, blockEntity) { - const tex = this.getSignTexture(position, blockEntity) + const tex = this.getSignTexture(position, blockEntity, isHanging) if (!tex) return @@ -633,6 +897,16 @@ 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) { @@ -708,6 +982,7 @@ export class WorldRendererThree extends WorldRendererCommon { destroy (): void { super.destroy() + this.skyboxRenderer.dispose() } shouldObjectVisible (object: THREE.Object3D) { @@ -788,7 +1063,16 @@ class StarField { } } - constructor (private readonly scene: THREE.Scene) { + constructor ( + private readonly worldRenderer: WorldRendererThree + ) { + const clock = new THREE.Clock() + const speed = 0.2 + this.worldRenderer.onRender.push(() => { + if (!this.points) return + this.points.position.copy(this.worldRenderer.getCameraPosition()); + (this.points.material as StarfieldMaterial).uniforms.time.value = clock.getElapsedTime() * speed + }) } addToScene () { @@ -799,7 +1083,6 @@ class StarField { const count = 7000 const factor = 7 const saturation = 10 - const speed = 0.2 const geometry = new THREE.BufferGeometry() @@ -830,13 +1113,8 @@ class StarField { // Create points and add them to the scene this.points = new THREE.Points(geometry, material) - this.scene.add(this.points) + this.worldRenderer.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 } @@ -844,7 +1122,7 @@ class StarField { if (this.points) { this.points.geometry.dispose(); (this.points.material as THREE.Material).dispose() - this.scene.remove(this.points) + this.worldRenderer.scene.remove(this.points) this.points = undefined } diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 5e76646e..6cd6b2ed 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -1,3 +1,4 @@ +/// import { defineConfig, mergeRsbuildConfig, RsbuildPluginAPI } from '@rsbuild/core' import { pluginReact } from '@rsbuild/plugin-react' import { pluginTypedCSSModules } from '@rsbuild/plugin-typed-css-modules' @@ -14,6 +15,7 @@ import { appAndRendererSharedConfig } from './renderer/rsbuildSharedConfig' import { genLargeDataAliases } from './scripts/genLargeDataAliases' import sharp from 'sharp' import supportedVersions from './src/supportedVersions.mjs' +import { startWsServer } from './scripts/wsServer' const SINGLE_FILE_BUILD = process.env.SINGLE_FILE_BUILD === 'true' @@ -48,7 +50,7 @@ if (fs.existsSync('./assets/release.json')) { const configJson = JSON.parse(fs.readFileSync('./config.json', 'utf8')) try { - Object.assign(configJson, JSON.parse(fs.readFileSync('./config.local.json', 'utf8'))) + Object.assign(configJson, JSON.parse(fs.readFileSync(process.env.LOCAL_CONFIG_FILE || './config.local.json', 'utf8'))) } catch (err) {} if (dev) { configJson.defaultProxy = ':8080' @@ -58,6 +60,8 @@ const configSource = (SINGLE_FILE_BUILD ? 'BUNDLED' : (process.env.CONFIG_JSON_S const faviconPath = 'favicon.png' +const enableMetrics = process.env.ENABLE_METRICS === 'true' + // base options are in ./renderer/rsbuildSharedConfig.ts const appConfig = defineConfig({ html: { @@ -111,6 +115,22 @@ 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, @@ -119,6 +139,13 @@ const appConfig = defineConfig({ // 50kb limit for data uri dataUriLimit: SINGLE_FILE_BUILD ? 1 * 1024 * 1024 * 1024 : 50 * 1024 }, + performance: { + // prefetch: { + // include(filename) { + // return filename.includes('mc-data') || filename.includes('mc-assets') + // }, + // }, + }, source: { entry: { index: './src/index.ts', @@ -134,12 +161,15 @@ const appConfig = defineConfig({ 'process.platform': '"browser"', 'process.env.GITHUB_URL': JSON.stringify(`https://github.com/${process.env.GITHUB_REPOSITORY || `${process.env.VERCEL_GIT_REPO_OWNER}/${process.env.VERCEL_GIT_REPO_SLUG}` || githubRepositoryFallback}`), - 'process.env.DEPS_VERSIONS': JSON.stringify({}), + 'process.env.ALWAYS_MINIMAL_SERVER_UI': JSON.stringify(process.env.ALWAYS_MINIMAL_SERVER_UI), 'process.env.RELEASE_TAG': JSON.stringify(releaseTag), 'process.env.RELEASE_LINK': JSON.stringify(releaseLink), 'process.env.RELEASE_CHANGELOG': JSON.stringify(releaseChangelog), '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: { @@ -167,20 +197,21 @@ const appConfig = defineConfig({ childProcess.execSync('tsx ./scripts/optimizeBlockCollisions.ts', { stdio: 'inherit' }) } // childProcess.execSync(['tsx', './scripts/genLargeDataAliases.ts', ...(SINGLE_FILE_BUILD ? ['--compressed'] : [])].join(' '), { stdio: 'inherit' }) - genLargeDataAliases(SINGLE_FILE_BUILD) + genLargeDataAliases(SINGLE_FILE_BUILD || process.env.ALWAYS_COMPRESS_LARGE_DATA === 'true') fsExtra.copySync('./node_modules/mc-assets/dist/other-textures/latest/entity', './dist/textures/entity') fsExtra.copySync('./assets/background', './dist/background') fs.copyFileSync('./assets/favicon.png', './dist/favicon.png') 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), 'utf8') + fs.writeFileSync('./dist/config.json', JSON.stringify(configJson, undefined, 2), 'utf8') } if (fs.existsSync('./generated/sounds.js')) { fs.copyFileSync('./generated/sounds.js', './dist/sounds.js') @@ -196,6 +227,12 @@ const appConfig = defineConfig({ await execAsync('pnpm run build-mesher') } fs.writeFileSync('./dist/version.txt', buildingVersion, 'utf-8') + + // Start WebSocket server in development + if (dev && enableMetrics) { + await startWsServer(8081, false) + } + console.timeEnd('total-prep') } if (!dev) { @@ -203,6 +240,10 @@ const appConfig = defineConfig({ prep() }) build.onAfterBuild(async () => { + if (fs.readdirSync('./assets/customTextures').length > 0) { + childProcess.execSync('tsx ./scripts/patchAssets.ts', { stdio: 'inherit' }) + } + if (SINGLE_FILE_BUILD) { // check that only index.html is in the dist/single folder const singleBuildFiles = fs.readdirSync('./dist/single') diff --git a/scripts/genLargeDataAliases.ts b/scripts/genLargeDataAliases.ts index 0cf206df..2372dbfd 100644 --- a/scripts/genLargeDataAliases.ts +++ b/scripts/genLargeDataAliases.ts @@ -16,7 +16,8 @@ export const genLargeDataAliases = async (isCompressed: boolean) => { let str = `${decoderCode}\nexport const importLargeData = async (mod: ${Object.keys(modules).map(x => `'${x}'`).join(' | ')}) => {\n` for (const [module, { compressed, raw }] of Object.entries(modules)) { - let importCode = `(await import('${isCompressed ? compressed : raw}')).default`; + const chunkName = module === 'mcData' ? 'mc-data' : 'mc-assets'; + let importCode = `(await import(/* webpackChunkName: "${chunkName}" */ '${isCompressed ? compressed : raw}')).default`; if (isCompressed) { importCode = `JSON.parse(decompressFromBase64(${importCode}))` } @@ -30,6 +31,8 @@ export const genLargeDataAliases = async (isCompressed: boolean) => { const decoderCode = /* ts */ ` import pako from 'pako'; +globalThis.pako = { inflate: pako.inflate.bind(pako) } + function decompressFromBase64(input) { console.time('decompressFromBase64') // Decode the Base64 string diff --git a/scripts/makeOptimizedMcData.mjs b/scripts/makeOptimizedMcData.mjs index 05948cf2..a572d067 100644 --- a/scripts/makeOptimizedMcData.mjs +++ b/scripts/makeOptimizedMcData.mjs @@ -6,8 +6,8 @@ import { dirname } from 'node:path' import supportedVersions from '../src/supportedVersions.mjs' import { gzipSizeFromFileSync } from 'gzip-size' import fs from 'fs' -import {default as _JsonOptimizer} from '../src/optimizeJson' -import { gzipSync } from 'zlib'; +import { default as _JsonOptimizer } from '../src/optimizeJson' +import { gzipSync } from 'zlib' import MinecraftData from 'minecraft-data' import MCProtocol from 'minecraft-protocol' @@ -21,12 +21,12 @@ const require = Module.createRequire(import.meta.url) const dataPaths = require('minecraft-data/minecraft-data/data/dataPaths.json') -function toMajor (version) { +function toMajor(version) { const [a, b] = (version + '').split('.') return `${a}.${b}` } -const versions = {} +let versions = {} const dataTypes = new Set() for (const [version, dataSet] of Object.entries(dataPaths.pc)) { @@ -42,6 +42,31 @@ const versionToNumber = (ver) => { return +`${x.padStart(2, '0')}${y.padStart(2, '0')}${z.padStart(2, '0')}` } +// Version clipping support +const minVersion = process.env.MIN_MC_VERSION +const maxVersion = process.env.MAX_MC_VERSION + +// Filter versions based on MIN_VERSION and MAX_VERSION if provided +if (minVersion || maxVersion) { + const filteredVersions = {} + const minVersionNum = minVersion ? versionToNumber(minVersion) : 0 + const maxVersionNum = maxVersion ? versionToNumber(maxVersion) : Infinity + + for (const [version, dataSet] of Object.entries(versions)) { + const versionNum = versionToNumber(version) + if (versionNum >= minVersionNum && versionNum <= maxVersionNum) { + filteredVersions[version] = dataSet + } + } + + versions = filteredVersions + + console.log(`Version clipping applied: ${minVersion || 'none'} to ${maxVersion || 'none'}`) + console.log(`Processing ${Object.keys(versions).length} versions:`, Object.keys(versions).sort((a, b) => versionToNumber(a) - versionToNumber(b))) +} + +console.log('Bundling version range:', Object.keys(versions)[0], 'to', Object.keys(versions).at(-1)) + // if not included here (even as {}) will not be bundled & accessible! // const compressedOutput = !!process.env.SINGLE_FILE_BUILD const compressedOutput = true @@ -57,22 +82,27 @@ const dataTypeBundling2 = { } } const dataTypeBundling = { - language: { + language: process.env.SKIP_MC_DATA_LANGUAGE === 'true' ? { + raw: {} + } : { ignoreRemoved: true, ignoreChanges: true }, blocks: { arrKey: 'name', - processData (current, prev) { + processData(current, prev, _, version) { for (const block of current) { + const prevBlock = prev?.find(x => x.name === block.name) if (block.transparent) { const forceOpaque = block.name.includes('shulker_box') || block.name.match(/^double_.+_slab\d?$/) || ['melon_block', 'lit_pumpkin', 'lit_redstone_ore', 'lit_furnace'].includes(block.name) - const prevBlock = prev?.find(x => x.name === block.name); if (forceOpaque || (prevBlock && !prevBlock.transparent)) { block.transparent = false } } + if (block.hardness === 0 && prevBlock && prevBlock.hardness > 0) { + block.hardness = prevBlock.hardness + } } } // ignoreRemoved: true, @@ -136,7 +166,9 @@ const dataTypeBundling = { blockLoot: { arrKey: 'block' }, - recipes: { + recipes: process.env.SKIP_MC_DATA_RECIPES === 'true' ? { + raw: {} + } : { raw: true // processData: processRecipes }, @@ -150,7 +182,7 @@ const dataTypeBundling = { // } } -function processRecipes (current, prev, getData, version) { +function processRecipes(current, prev, getData, version) { // can require the same multiple times per different versions if (current._proccessed) return const items = getData('items') @@ -242,30 +274,39 @@ for (const [i, [version, dataSet]] of versionsArr.reverse().entries()) { for (const [dataType, dataPath] of Object.entries(dataSet)) { const config = dataTypeBundling[dataType] if (!config) continue - if (dataType === 'blockCollisionShapes' && versionToNumber(version) >= versionToNumber('1.13')) { - // contents += ` get ${dataType} () { return window.globalGetCollisionShapes?.("${version}") },\n` - continue - } + const ignoreCollisionShapes = dataType === 'blockCollisionShapes' && versionToNumber(version) >= versionToNumber('1.13') + let injectCode = '' - const getData = (type) => { + const getRealData = (type) => { const loc = `minecraft-data/data/${dataSet[type]}/` const dataPathAbsolute = require.resolve(`minecraft-data/${loc}${type}`) // const data = fs.readFileSync(dataPathAbsolute, 'utf8') const dataRaw = require(dataPathAbsolute) return dataRaw } - const dataRaw = getData(dataType) + const dataRaw = getRealData(dataType) let rawData = dataRaw if (config.raw) { rawDataVersions[dataType] ??= {} rawDataVersions[dataType][version] = rawData - rawData = dataRaw + if (config.raw === true) { + rawData = dataRaw + } else { + rawData = config.raw + } + + if (ignoreCollisionShapes && dataType === 'blockCollisionShapes') { + rawData = { + blocks: {}, + shapes: {} + } + } } else { if (!diffSources[dataType]) { diffSources[dataType] = new JsonOptimizer(config.arrKey, config.ignoreChanges, config.ignoreRemoved) } try { - config.processData?.(dataRaw, previousData[dataType], getData, version) + config.processData?.(dataRaw, previousData[dataType], getRealData, version) diffSources[dataType].recordDiff(version, dataRaw) injectCode = `restoreDiff(sources, ${JSON.stringify(dataType)}, ${JSON.stringify(version)})` } catch (err) { @@ -297,16 +338,16 @@ console.log('total size (mb)', totalSize / 1024 / 1024) console.log( 'size per data type (mb, %)', Object.fromEntries(Object.entries(sizePerDataType).map(([dataType, size]) => { - return [dataType, [size / 1024 / 1024, Math.round(size / totalSize * 100)]]; + return [dataType, [size / 1024 / 1024, Math.round(size / totalSize * 100)]] }).sort((a, b) => { //@ts-ignore - return b[1][1] - a[1][1]; + return b[1][1] - a[1][1] })) ) function compressToBase64(input) { - const buffer = gzipSync(input); - return buffer.toString('base64'); + const buffer = gzipSync(input) + return buffer.toString('base64') } const filePath = './generated/minecraft-data-optimized.json' @@ -330,6 +371,7 @@ console.log('size', fs.lstatSync(filePath).size / 1000 / 1000, gzipSizeFromFileS const { defaultVersion } = MCProtocol const data = MinecraftData(defaultVersion) +console.log('defaultVersion', defaultVersion, !!data) const initialMcData = { [defaultVersion]: { version: data.version, diff --git a/scripts/patchAssets.ts b/scripts/patchAssets.ts new file mode 100644 index 00000000..99994f5f --- /dev/null +++ b/scripts/patchAssets.ts @@ -0,0 +1,137 @@ +import blocksAtlas from 'mc-assets/dist/blocksAtlases.json' +import itemsAtlas from 'mc-assets/dist/itemsAtlases.json' +import * as fs from 'fs' +import * as path from 'path' +import sharp from 'sharp' + +interface AtlasFile { + latest: { + suSv: number + tileSize: number + width: number + height: number + textures: { + [key: string]: { + u: number + v: number + su: number + sv: number + tileIndex: number + } + } + } +} + +async function patchTextureAtlas( + atlasType: 'blocks' | 'items', + atlasData: AtlasFile, + customTexturesDir: string, + distDir: string +) { + // Check if custom textures directory exists and has files + if (!fs.existsSync(customTexturesDir) || fs.readdirSync(customTexturesDir).length === 0) { + return + } + + // Find the latest atlas file + const atlasFiles = fs.readdirSync(distDir) + .filter(file => file.startsWith(`${atlasType}AtlasLatest`) && file.endsWith('.png')) + .sort() + + if (atlasFiles.length === 0) { + console.log(`No ${atlasType}AtlasLatest.png found in ${distDir}`) + return + } + + const latestAtlasFile = atlasFiles[atlasFiles.length - 1] + const atlasPath = path.join(distDir, latestAtlasFile) + console.log(`Patching ${atlasPath}`) + + // Get atlas dimensions + const atlasMetadata = await sharp(atlasPath).metadata() + if (!atlasMetadata.width || !atlasMetadata.height) { + throw new Error(`Failed to get atlas dimensions for ${atlasPath}`) + } + + // Process each custom texture + const customTextureFiles = fs.readdirSync(customTexturesDir) + .filter(file => file.endsWith('.png')) + + if (customTextureFiles.length === 0) return + + // Prepare composite operations + const composites: sharp.OverlayOptions[] = [] + + for (const textureFile of customTextureFiles) { + const textureName = path.basename(textureFile, '.png') + + if (atlasData.latest.textures[textureName]) { + const textureData = atlasData.latest.textures[textureName] + const customTexturePath = path.join(customTexturesDir, textureFile) + + try { + // Convert UV coordinates to pixel coordinates + const x = Math.round(textureData.u * atlasMetadata.width) + const y = Math.round(textureData.v * atlasMetadata.height) + const width = Math.round((textureData.su ?? atlasData.latest.suSv) * atlasMetadata.width) + const height = Math.round((textureData.sv ?? atlasData.latest.suSv) * atlasMetadata.height) + + // Resize custom texture to match atlas dimensions and add to composite operations + const resizedTextureBuffer = await sharp(customTexturePath) + .resize(width, height, { + fit: 'fill', + kernel: 'nearest' // Preserve pixel art quality + }) + .png() + .toBuffer() + + composites.push({ + input: resizedTextureBuffer, + left: x, + top: y, + blend: 'over' + }) + + console.log(`Prepared ${textureName} at (${x}, ${y}) with size (${width}, ${height})`) + } catch (error) { + console.error(`Failed to prepare ${textureName}:`, error) + } + } else { + console.warn(`Texture ${textureName} not found in ${atlasType} atlas`) + } + } + + if (composites.length > 0) { + // Apply all patches at once using Sharp's composite + await sharp(atlasPath) + .composite(composites) + .png() + .toFile(atlasPath + '.tmp') + + // Replace original with patched version + fs.renameSync(atlasPath + '.tmp', atlasPath) + console.log(`Saved patched ${atlasType} atlas to ${atlasPath}`) + } +} + +async function main() { + const customBlocksDir = './assets/customTextures/blocks' + const customItemsDir = './assets/customTextures/items' + const distDir = './dist/static/image' + + try { + // Patch blocks atlas + await patchTextureAtlas('blocks', blocksAtlas as unknown as AtlasFile, customBlocksDir, distDir) + + // Patch items atlas + await patchTextureAtlas('items', itemsAtlas as unknown as AtlasFile, customItemsDir, distDir) + + console.log('Texture atlas patching completed!') + } catch (error) { + console.error('Failed to patch texture atlases:', error) + process.exit(1) + } +} + +// Run the script +main() diff --git a/scripts/requestData.ts b/scripts/requestData.ts new file mode 100644 index 00000000..dc866a1b --- /dev/null +++ b/scripts/requestData.ts @@ -0,0 +1,42 @@ +import WebSocket from 'ws' + +function formatBytes(bytes: number) { + return `${(bytes).toFixed(2)} MB` +} + +function formatTime(ms: number) { + return `${(ms / 1000).toFixed(2)}s` +} + +const ws = new WebSocket('ws://localhost:8081') + +ws.on('open', () => { + console.log('Connected to metrics server, waiting for metrics...') +}) + +ws.on('message', (data) => { + try { + const metrics = JSON.parse(data.toString()) + console.log('\nPerformance Metrics:') + console.log('------------------') + console.log(`Load Time: ${formatTime(metrics.loadTime)}`) + console.log(`Memory Usage: ${formatBytes(metrics.memoryUsage)}`) + console.log(`Timestamp: ${new Date(metrics.timestamp).toLocaleString()}`) + if (!process.argv.includes('-f')) { // follow mode + process.exit(0) + } + } catch (error) { + console.error('Error parsing metrics:', error) + } +}) + +ws.on('error', (error) => { + console.error('WebSocket error:', error) + process.exit(1) +}) + +// Exit if no metrics received after 5 seconds +setTimeout(() => { + console.error('Timeout waiting for metrics') + process.exit(1) +}, 5000) diff --git a/scripts/updateGitDeps.ts b/scripts/updateGitDeps.ts new file mode 100644 index 00000000..797aea8f --- /dev/null +++ b/scripts/updateGitDeps.ts @@ -0,0 +1,160 @@ +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 + devDependencies?: Record + } + } +} + +interface PackageJson { + pnpm?: { + updateConfig?: { + ignoreDependencies?: string[] + } + } +} + +async function prompt(question: string): Promise { + 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 { + 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 + }> = [] + + 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) diff --git a/scripts/wsServer.ts b/scripts/wsServer.ts new file mode 100644 index 00000000..43035f52 --- /dev/null +++ b/scripts/wsServer.ts @@ -0,0 +1,45 @@ +import {WebSocketServer} from 'ws' + +export function startWsServer(port: number = 8081, tryOtherPort: boolean = true): Promise { + 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) + }) +} diff --git a/src/appConfig.ts b/src/appConfig.ts index 261ec7d3..c29d74e8 100644 --- a/src/appConfig.ts +++ b/src/appConfig.ts @@ -24,6 +24,7 @@ export type MobileButtonConfig = { readonly icon?: string readonly action?: ActionType readonly actionHold?: ActionType | ActionHoldConfig + readonly iconStyle?: React.CSSProperties } export type AppConfig = { @@ -34,7 +35,7 @@ export type AppConfig = { // defaultVersion?: string peerJsServer?: string peerJsServerFallback?: string - promoteServers?: Array<{ ip, description, version? }> + promoteServers?: Array<{ ip, description, name?, version?, }> mapsProvider?: string appParams?: Record // query string params @@ -54,9 +55,14 @@ export type AppConfig = { 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 { @@ -68,7 +74,7 @@ export const loadAppConfig = (appConfig: AppConfig) => { if (value) { disabledSettings.value.add(key) // since the setting is forced, we need to set it to that value - if (appConfig.defaultSettings?.[key] && !qsOptions[key]) { + if (appConfig.defaultSettings && key in appConfig.defaultSettings && !qsOptions[key]) { options[key] = appConfig.defaultSettings[key] } } else { @@ -76,12 +82,15 @@ 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) } diff --git a/src/appParams.ts b/src/appParams.ts index aec6fd0b..4c8ca186 100644 --- a/src/appParams.ts +++ b/src/appParams.ts @@ -12,6 +12,7 @@ export type AppQsParams = { username?: string lockConnect?: string autoConnect?: string + alwaysReconnect?: string // googledrive.ts params state?: string // ServersListProvider.tsx params @@ -45,6 +46,8 @@ export type AppQsParams = { onlyConnect?: string connectText?: string freezeSettings?: string + testIosCrash?: string + addPing?: string // Replay params replayFilter?: string diff --git a/src/appStatus.ts b/src/appStatus.ts index 054975f3..101714f5 100644 --- a/src/appStatus.ts +++ b/src/appStatus.ts @@ -1,3 +1,4 @@ +import { resetStateAfterDisconnect } from './browserfs' import { hideModal, activeModalStack, showModal, miscUiState } from './globalState' import { appStatusState, resetAppStatusState } from './react/AppStatusProvider' @@ -25,7 +26,6 @@ export const setLoadingScreenStatus = function (status: string | undefined | nul } showModal({ reactType: 'app-status' }) if (appStatusState.isError) { - miscUiState.gameLoaded = false return } appStatusState.hideDots = hideDots @@ -33,5 +33,9 @@ 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 diff --git a/src/appViewer.ts b/src/appViewer.ts index 5ac0beaf..628d11b4 100644 --- a/src/appViewer.ts +++ b/src/appViewer.ts @@ -1,5 +1,5 @@ import { WorldDataEmitter, WorldDataEmitterWorker } from 'renderer/viewer/lib/worldDataEmitter' -import { PlayerStateRenderer } from 'renderer/viewer/lib/basePlayerState' +import { getInitialPlayerState, PlayerStateRenderer, PlayerStateReactive } from 'renderer/viewer/lib/basePlayerState' import { subscribeKey } from 'valtio/utils' import { defaultWorldRendererConfig, WorldRendererConfig } from 'renderer/viewer/lib/worldrendererCommon' import { Vec3 } from 'vec3' @@ -8,6 +8,7 @@ import { proxy, subscribe } from 'valtio' import { getDefaultRendererState } from 'renderer/viewer/baseGraphicsBackend' import { getSyncWorld } from 'renderer/playground/shared' import { MaybePromise } from 'contro-max/build/types/store' +import { PANORAMA_VERSION } from 'renderer/viewer/three/panoramaShared' import { playerState } from './mineflayer/playerState' import { createNotificationProgressReporter, ProgressReporter } from './core/progressReporter' import { setLoadingScreenStatus } from './appStatus' @@ -15,6 +16,9 @@ import { activeModalStack, miscUiState } from './globalState' import { options } from './optionsStorage' import { ResourcesManager, ResourcesManagerTransferred } from './resourcesManager' import { watchOptionsAfterWorldViewInit } from './watchOptions' +import { loadMinecraftData } from './connect' +import { reloadChunks } from './utils' +import { displayClientChat } from './botUtils' export interface RendererReactiveState { world: { @@ -67,7 +71,7 @@ export interface DisplayWorldOptions { version: string worldView: WorldDataEmitterWorker inWorldRenderingConfig: WorldRendererConfig - playerState: PlayerStateRenderer + playerStateReactive: PlayerStateReactive rendererState: RendererReactiveState nonReactiveState: NonReactiveState } @@ -112,7 +116,7 @@ export class AppViewer { inWorldRenderingConfig: WorldRendererConfig = proxy(defaultWorldRendererConfig) lastCamUpdate = 0 playerState = playerState - rendererState = proxy(getDefaultRendererState().reactive) + rendererState = getDefaultRendererState().reactive nonReactiveState: NonReactiveState = getDefaultRendererState().nonReactive worldReady: Promise private resolveWorldReady: () => void @@ -162,11 +166,15 @@ export class AppViewer { // Execute queued action if exists if (this.currentState) { - const { method, args } = this.currentState - this.backend[method](...args) - if (method === 'startWorld') { - void this.worldView!.init(bot.entity.position) - // void this.worldView!.init(args[0].playerState.getPosition()) + if (this.currentState.method === 'startPanorama') { + this.startPanorama() + } else { + const { method, args } = this.currentState + this.backend[method](...args) + if (method === 'startWorld') { + void this.worldView!.init(bot.entity.position) + // void this.worldView!.init(args[0].playerState.getPosition()) + } } } @@ -180,19 +188,33 @@ export class AppViewer { this.worldView!.listenToBot(bot) } - async startWorld (world, renderDistance: number, playerStateSend: PlayerStateRenderer = this.playerState) { + appConfigUdpate () { + if (miscUiState.appConfig) { + this.inWorldRenderingConfig.skinTexturesProxy = miscUiState.appConfig.skinTexturesProxy + } + } + + async startWorld (world, renderDistance: number, playerStateSend: PlayerStateRenderer = this.playerState.reactive) { if (this.currentDisplay === 'world') throw new Error('World already started') this.currentDisplay = 'world' const startPosition = bot.entity?.position ?? new Vec3(0, 64, 0) this.worldView = new WorldDataEmitter(world, renderDistance, startPosition) + this.worldView.panicChunksReload = () => { + if (!options.experimentalClientSelfReload) return + if (process.env.NODE_ENV === 'development') { + displayClientChat(`[client] client panicked due to too long loading time. Soft reloading chunks...`) + } + void reloadChunks() + } window.worldView = this.worldView watchOptionsAfterWorldViewInit(this.worldView) + this.appConfigUdpate() const displayWorldOptions: DisplayWorldOptions = { version: this.resourcesManager.currentConfig!.version, worldView: this.worldView, inWorldRenderingConfig: this.inWorldRenderingConfig, - playerState: playerStateSend, + playerStateReactive: playerStateSend, rendererState: this.rendererState, nonReactiveState: this.nonReactiveState } @@ -218,10 +240,16 @@ export class AppViewer { startPanorama () { if (this.currentDisplay === 'menu') return - this.currentDisplay = 'menu' if (options.disableAssets) return - if (this.backend) { - this.backend.startPanorama() + if (this.backend && !hasAppStatus()) { + this.currentDisplay = 'menu' + if (process.env.SINGLE_FILE_BUILD_MODE) { + void loadMinecraftData(PANORAMA_VERSION).then(() => { + this.backend?.startPanorama() + }) + } else { + this.backend.startPanorama() + } } this.currentState = { method: 'startPanorama', args: [] } } @@ -281,31 +309,44 @@ const initialMenuStart = async () => { if (appViewer.currentDisplay === 'world') { appViewer.resetBackend(true) } - appViewer.startPanorama() + const demo = new URLSearchParams(window.location.search).get('demo') + if (!demo) { + appViewer.startPanorama() + return + } // const version = '1.18.2' - // 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)) + 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)) } window.initialMenuStart = initialMenuStart +const hasAppStatus = () => activeModalStack.some(m => m.reactType === 'app-status') + const modalStackUpdateChecks = () => { // maybe start panorama - if (!miscUiState.gameLoaded) { + if (!miscUiState.gameLoaded && !hasAppStatus()) { void initialMenuStart() } if (appViewer.backend) { - const hasAppStatus = activeModalStack.some(m => m.reactType === 'app-status') - appViewer.backend.setRendering(!hasAppStatus) + appViewer.backend.setRendering(!hasAppStatus()) } appViewer.inWorldRenderingConfig.foreground = activeModalStack.length === 0 diff --git a/src/basicSounds.ts b/src/basicSounds.ts index 40428c6b..54af0d35 100644 --- a/src/basicSounds.ts +++ b/src/basicSounds.ts @@ -7,7 +7,12 @@ let audioContext: AudioContext const sounds: Record = {} // Track currently playing sounds and their gain nodes -const activeSounds: Array<{ source: AudioBufferSourceNode; gainNode: GainNode; volumeMultiplier: number }> = [] +const activeSounds: Array<{ + source: AudioBufferSourceNode; + gainNode: GainNode; + volumeMultiplier: number; + isMusic: boolean; +}> = [] window.activeSounds = activeSounds // load as many resources on page load as possible instead on demand as user can disable internet connection after he thinks the page is loaded @@ -43,7 +48,7 @@ export async function loadSound (path: string, contents = path) { } } -export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = 500) => { +export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = options.remoteSoundsLoadTimeout, loop = false, isMusic = false) => { const soundBuffer = sounds[url] if (!soundBuffer) { const start = Date.now() @@ -51,11 +56,11 @@ export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = 500) = if (cancelled || Date.now() - start > loadTimeout) return } - return playSound(url, soundVolume) + return playSound(url, soundVolume, loop, isMusic) } -export async function playSound (url, soundVolume = 1) { - const volume = soundVolume * (options.volume / 100) +export async function playSound (url, soundVolume = 1, loop = false, isMusic = false) { + const volume = soundVolume * (options.volume / 100) * (isMusic ? options.musicVolume / 100 : 1) if (!volume) return @@ -75,13 +80,14 @@ export async function playSound (url, soundVolume = 1) { const gainNode = audioContext.createGain() const source = audioContext.createBufferSource() source.buffer = soundBuffer + source.loop = loop source.connect(gainNode) gainNode.connect(audioContext.destination) gainNode.gain.value = volume source.start(0) // Add to active sounds - activeSounds.push({ source, gainNode, volumeMultiplier: soundVolume }) + activeSounds.push({ source, gainNode, volumeMultiplier: soundVolume, isMusic }) const callbacks = [] as Array<() => void> source.onended = () => { @@ -99,6 +105,17 @@ export async function playSound (url, soundVolume = 1) { onEnded (callback: () => void) { callbacks.push(callback) }, + stop () { + try { + source.stop() + // Remove from active sounds + const index = activeSounds.findIndex(s => s.source === source) + if (index !== -1) activeSounds.splice(index, 1) + } catch (err) { + console.warn('Failed to stop sound:', err) + } + }, + gainNode, } } @@ -113,11 +130,24 @@ export function stopAllSounds () { activeSounds.length = 0 } -export function changeVolumeOfCurrentlyPlayingSounds (newVolume: number) { - const normalizedVolume = newVolume / 100 - for (const { gainNode, volumeMultiplier } of activeSounds) { +export function stopSound (url: string) { + const soundIndex = activeSounds.findIndex(s => s.source.buffer === sounds[url]) + if (soundIndex !== -1) { + const { source } = activeSounds[soundIndex] try { - gainNode.gain.value = normalizedVolume * volumeMultiplier + source.stop() + } catch (err) { + console.warn('Failed to stop sound:', err) + } + activeSounds.splice(soundIndex, 1) + } +} + +export function changeVolumeOfCurrentlyPlayingSounds (newVolume: number, newMusicVolume: number) { + const normalizedVolume = newVolume / 100 + for (const { gainNode, volumeMultiplier, isMusic } of activeSounds) { + try { + gainNode.gain.value = normalizedVolume * volumeMultiplier * (isMusic ? newMusicVolume / 100 : 1) } catch (err) { console.warn('Failed to change sound volume:', err) } @@ -125,5 +155,9 @@ export function changeVolumeOfCurrentlyPlayingSounds (newVolume: number) { } subscribeKey(options, 'volume', () => { - changeVolumeOfCurrentlyPlayingSounds(options.volume) + changeVolumeOfCurrentlyPlayingSounds(options.volume, options.musicVolume) +}) + +subscribeKey(options, 'musicVolume', () => { + changeVolumeOfCurrentlyPlayingSounds(options.volume, options.musicVolume) }) diff --git a/src/cameraRotationControls.ts b/src/cameraRotationControls.ts index 849e5940..679a3a44 100644 --- a/src/cameraRotationControls.ts +++ b/src/cameraRotationControls.ts @@ -18,6 +18,7 @@ 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 @@ -32,7 +33,6 @@ 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 diff --git a/src/chatUtils.ts b/src/chatUtils.ts index 384dbdae..849d5847 100644 --- a/src/chatUtils.ts +++ b/src/chatUtils.ts @@ -4,6 +4,10 @@ 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 & { text: string color?: string @@ -114,6 +118,14 @@ export const formatMessage = (message: MessageInput, mcData: IndexedData = globa return msglist } +export const messageToString = (message: MessageInput | string) => { + if (typeof message === 'string') { + return message + } + const msglist = formatMessage(message) + return msglist.map(msg => msg.text).join('') +} + const blockToItemRemaps = { water: 'water_bucket', lava: 'lava_bucket', diff --git a/src/connect.ts b/src/connect.ts index 914303c6..cb6b8f65 100644 --- a/src/connect.ts +++ b/src/connect.ts @@ -3,7 +3,6 @@ import MinecraftData from 'minecraft-data' import PrismarineBlock from 'prismarine-block' import PrismarineItem from 'prismarine-item' -import pathfinder from 'mineflayer-pathfinder' import { miscUiState } from './globalState' import supportedVersions from './supportedVersions.mjs' import { options } from './optionsStorage' @@ -65,7 +64,6 @@ export const loadMinecraftData = async (version: string) => { window.PrismarineItem = PrismarineItem(mcData.version.minecraftVersion!) window.loadedData = mcData window.mcData = mcData - window.pathfinder = pathfinder miscUiState.loadedDataVersion = version } diff --git a/src/controls.ts b/src/controls.ts index 94364f2d..db6a6fc6 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -29,6 +29,7 @@ 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, () => { @@ -70,6 +71,7 @@ export const contro = new ControMax({ // client side zoom: ['KeyC'], viewerConsole: ['Backquote'], + togglePerspective: ['F5'], }, ui: { toggleFullscreen: ['F11'], @@ -114,6 +116,10 @@ export const contro = new ControMax({ window.controMax = contro export type Command = CommandEventArgument['command'] +export const isCommandDisabled = (command: Command) => { + return miscUiState.appConfig?.disabledCommands?.includes(command) +} + onControInit() updateBinds(customKeymaps) @@ -131,7 +137,14 @@ 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) { @@ -340,6 +353,9 @@ 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', @@ -361,6 +377,7 @@ window.cameraRotationControls = cameraRotationControls const setSneaking = (state: boolean) => { gameAdditionalState.isSneaking = state bot.setControlState('sneak', state) + } const onTriggerOrReleased = (command: Command, pressed: boolean) => { @@ -371,6 +388,7 @@ 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)) @@ -427,6 +445,28 @@ 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) { @@ -508,6 +548,8 @@ const customCommandsHandler = ({ command }) => { contro.on('trigger', customCommandsHandler) contro.on('trigger', ({ command }) => { + if (isCommandDisabled(command)) return + const willContinue = !isGameActive(true) alwaysPressedHandledCommand(command) if (willContinue) return @@ -542,13 +584,19 @@ contro.on('trigger', ({ command }) => { case 'general.debugOverlay': case 'general.debugOverlayHelpMenu': case 'general.playersList': + case 'general.togglePerspective': // no-op break case 'general.swapHands': { - bot._client.write('entity_action', { - entityId: bot.entity.id, - actionId: 6, - jumpBoost: 0 + if (isSpectatingEntity()) break + bot._client.write('block_dig', { + 'status': 6, + 'location': { + 'x': 0, + 'z': 0, + 'y': 0 + }, + 'face': 0, }) break } @@ -556,11 +604,13 @@ contro.on('trigger', ({ command }) => { // handled in onTriggerOrReleased break case 'general.inventory': + if (isSpectatingEntity()) break document.exitPointerLock?.() openPlayerInventory() break case 'general.drop': { - // if (bot.heldItem/* && ctrl */) bot.tossStack(bot.heldItem) + if (isSpectatingEntity()) break + // protocol 1.9+ bot._client.write('block_dig', { 'status': 4, 'location': { @@ -593,12 +643,15 @@ 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': @@ -630,6 +683,8 @@ contro.on('trigger', ({ command }) => { }) contro.on('release', ({ command }) => { + if (isCommandDisabled(command)) return + inModalCommand(command, false) onTriggerOrReleased(command, false) }) @@ -763,6 +818,11 @@ export const f3Keybinds: Array<{ } ] +export const reloadChunksAction = () => { + const action = f3Keybinds.find(f3Keybind => f3Keybind.key === 'KeyA') + void action!.action() +} + document.addEventListener('keydown', (e) => { if (!isGameActive(false)) return if (contro.pressedKeys.has('F3')) { @@ -932,14 +992,17 @@ export function updateBinds (commands: any) { } export const onF3LongPress = async () => { - const select = await showOptionsModal('', f3Keybinds.filter(f3Keybind => { + const actions = f3Keybinds.filter(f3Keybind => { return f3Keybind.mobileTitle && (f3Keybind.enabled?.() ?? true) - }).map(f3Keybind => { + }) + const actionNames = actions.map(f3Keybind => { return `${f3Keybind.mobileTitle}${f3Keybind.key ? ` (F3+${f3Keybind.key})` : ''}` - })) + }) + const select = await showOptionsModal('', actionNames) if (!select) return - const f3Keybind = f3Keybinds.find(f3Keybind => f3Keybind.mobileTitle === select) - if (f3Keybind) void f3Keybind.action() + const actionIndex = actionNames.indexOf(select) + const f3Keybind = actions[actionIndex]! + void f3Keybind.action() } export const handleMobileButtonCustomAction = (action: CustomAction) => { @@ -949,9 +1012,16 @@ export const handleMobileButtonCustomAction = (action: CustomAction) => { } } +export const triggerCommand = (command: Command, isDown: boolean) => { + handleMobileButtonActionCommand(command, isDown) +} + export const handleMobileButtonActionCommand = (command: ActionType | ActionHoldConfig, isDown: boolean) => { const commandValue = typeof command === 'string' ? command : 'command' in command ? command.command : command + // Check if command is disabled before proceeding + if (typeof commandValue === 'string' && isCommandDisabled(commandValue as Command)) return + if (typeof commandValue === 'string' && !stringStartsWith(commandValue, 'custom')) { const event: CommandEventArgument = { command: commandValue as Command, diff --git a/src/core/ideChannels.ts b/src/core/ideChannels.ts new file mode 100644 index 00000000..a9c517f7 --- /dev/null +++ b/src/core/ideChannels.ts @@ -0,0 +1,106 @@ +import { proxy } from 'valtio' + +export const ideState = proxy({ + id: '', + contents: '', + line: 0, + column: 0, + language: 'typescript', + title: '', +}) +globalThis.ideState = ideState + +export const registerIdeChannels = () => { + registerIdeOpenChannel() + registerIdeSaveChannel() +} + +const registerIdeOpenChannel = () => { + const CHANNEL_NAME = 'minecraft-web-client:ide-open' + + const packetStructure = [ + 'container', + [ + { + name: 'id', + type: ['pstring', { countType: 'i16' }] + }, + { + name: 'language', + type: ['pstring', { countType: 'i16' }] + }, + { + name: 'contents', + type: ['pstring', { countType: 'i16' }] + }, + { + name: 'line', + type: 'i32' + }, + { + name: 'column', + type: 'i32' + }, + { + name: 'title', + type: ['pstring', { countType: 'i16' }] + } + ] + ] + + bot._client.registerChannel(CHANNEL_NAME, packetStructure, true) + + bot._client.on(CHANNEL_NAME as any, (data) => { + const { id, language, contents, line, column, title } = data + + ideState.contents = contents + ideState.line = line + ideState.column = column + ideState.id = id + ideState.language = language || 'typescript' + ideState.title = title + }) + + console.debug(`registered custom channel ${CHANNEL_NAME} channel`) +} +const IDE_SAVE_CHANNEL_NAME = 'minecraft-web-client:ide-save' +const registerIdeSaveChannel = () => { + + const packetStructure = [ + 'container', + [ + { + name: 'id', + type: ['pstring', { countType: 'i16' }] + }, + { + name: 'contents', + type: ['pstring', { countType: 'i16' }] + }, + { + name: 'language', + type: ['pstring', { countType: 'i16' }] + }, + { + name: 'line', + type: 'i32' + }, + { + name: 'column', + type: 'i32' + }, + ] + ] + bot._client.registerChannel(IDE_SAVE_CHANNEL_NAME, packetStructure, true) +} + +export const saveIde = () => { + bot._client.writeChannel(IDE_SAVE_CHANNEL_NAME, { + id: ideState.id, + contents: ideState.contents, + language: ideState.language, + // todo: reflect updated + line: ideState.line, + column: ideState.column, + }) +} diff --git a/src/core/importExport.ts b/src/core/importExport.ts new file mode 100644 index 00000000..b3e26347 --- /dev/null +++ b/src/core/importExport.ts @@ -0,0 +1,219 @@ +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 + keybindings?: Record + 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((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, { 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(([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 + + 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) +} diff --git a/src/customChannels.ts b/src/customChannels.ts index 57c057d5..506ea776 100644 --- a/src/customChannels.ts +++ b/src/customChannels.ts @@ -2,19 +2,20 @@ import PItem from 'prismarine-item' import { getThreeJsRendererMethods } from 'renderer/viewer/three/threeJsMethods' import { options } from './optionsStorage' import { jeiCustomCategories } from './inventoryWindows' +import { registerIdeChannels } from './core/ideChannels' export default () => { customEvents.on('mineflayerBotCreated', async () => { if (!options.customChannels) return - await new Promise(resolve => { - bot.once('login', () => { - resolve(true) - }) + bot.once('login', () => { + registerBlockModelsChannel() + registerMediaChannels() + registerSectionAnimationChannels() + registeredJeiChannel() + registerBlockInteractionsCustomizationChannel() + registerWaypointChannels() + registerIdeChannels() }) - registerBlockModelsChannel() - registerMediaChannels() - registerSectionAnimationChannels() - registeredJeiChannel() }) } @@ -32,6 +33,95 @@ const registerChannel = (channelName: string, packetStructure: any[], handler: ( console.debug(`registered custom channel ${channelName} channel`) } +const registerBlockInteractionsCustomizationChannel = () => { + const CHANNEL_NAME = 'minecraft-web-client:block-interactions-customization' + const packetStructure = [ + 'container', + [ + { + name: 'newConfiguration', + type: ['pstring', { countType: 'i16' }] + }, + ] + ] + + registerChannel(CHANNEL_NAME, packetStructure, (data) => { + const config = JSON.parse(data.newConfiguration) + bot.mouse.setConfigFromPacket(config) + }, true) +} + +const registerWaypointChannels = () => { + const packetStructure = [ + 'container', + [ + { + name: 'id', + type: ['pstring', { countType: 'i16' }] + }, + { + name: 'x', + type: 'f32' + }, + { + name: 'y', + type: 'f32' + }, + { + name: 'z', + type: 'f32' + }, + { + name: 'minDistance', + type: 'i32' + }, + { + name: 'label', + type: ['pstring', { countType: 'i16' }] + }, + { + name: 'color', + type: 'i32' + }, + { + name: 'metadataJson', + type: ['pstring', { countType: 'i16' }] + } + ] + ] + + registerChannel('minecraft-web-client:waypoint-add', packetStructure, (data) => { + // Parse metadata if provided + let metadata: any = {} + if (data.metadataJson && data.metadataJson.trim() !== '') { + try { + metadata = JSON.parse(data.metadataJson) + } catch (error) { + console.warn('Failed to parse waypoint metadataJson:', error) + } + } + + getThreeJsRendererMethods()?.addWaypoint(data.id, data.x, data.y, data.z, { + minDistance: data.minDistance, + label: data.label || undefined, + color: data.color || undefined, + metadata + }) + }) + + registerChannel('minecraft-web-client:waypoint-delete', [ + 'container', + [ + { + name: 'id', + type: ['pstring', { countType: 'i16' }] + } + ] + ], (data) => { + getThreeJsRendererMethods()?.removeWaypoint(data.id) + }) +} + const registerBlockModelsChannel = () => { const CHANNEL_NAME = 'minecraft-web-client:blockmodels' diff --git a/src/customClient.js b/src/customClient.js index b6c85fcc..b1a99904 100644 --- a/src/customClient.js +++ b/src/customClient.js @@ -1,6 +1,7 @@ +//@ts-check +import * as nbt from 'prismarine-nbt' import { options } from './optionsStorage' -//@ts-check const { EventEmitter } = require('events') const debug = require('debug')('minecraft-protocol') const states = require('minecraft-protocol/src/states') @@ -51,8 +52,20 @@ class CustomChannelClient extends EventEmitter { this.emit('state', newProperty, oldProperty) } - end(reason) { - this._endReason = reason + end(endReason, fullReason) { + // eslint-disable-next-line unicorn/no-this-assignment + const client = this + if (client.state === states.PLAY) { + fullReason ||= loadedData.supportFeature('chatPacketsUseNbtComponents') + ? nbt.comp({ text: nbt.string(endReason) }) + : JSON.stringify({ text: endReason }) + client.write('kick_disconnect', { reason: fullReason }) + } else if (client.state === states.LOGIN) { + fullReason ||= JSON.stringify({ text: endReason }) + client.write('disconnect', { reason: fullReason }) + } + + this._endReason = endReason this.emit('end', this._endReason) // still emits on server side only, doesn't send anything to our client } diff --git a/src/dayCycle.ts b/src/dayCycle.ts deleted file mode 100644 index 50e63a21..00000000 --- a/src/dayCycle.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { options } from './optionsStorage' -import { assertDefined } from './utils' -import { updateBackground } from './water' - -export default () => { - const timeUpdated = () => { - // 0 morning - const dayTotal = 24_000 - const evening = 11_500 - const night = 13_500 - const morningStart = 23_000 - const morningEnd = 23_961 - const timeProgress = options.dayCycleAndLighting ? bot.time.timeOfDay : 0 - - // todo check actual colors - const dayColorRainy = { r: 111 / 255, g: 156 / 255, b: 236 / 255 } - // todo yes, we should make animations (and rain) - // eslint-disable-next-line unicorn/numeric-separators-style - const dayColor = bot.isRaining ? dayColorRainy : { r: 0.6784313725490196, g: 0.8470588235294118, b: 0.9019607843137255 } // lightblue - // let newColor = dayColor - let int = 1 - if (timeProgress < evening) { - // stay dayily - } else if (timeProgress < night) { - const progressNorm = timeProgress - evening - const progressMax = night - evening - int = 1 - progressNorm / progressMax - } else if (timeProgress < morningStart) { - int = 0 - } else if (timeProgress < morningEnd) { - const progressNorm = timeProgress - morningStart - const progressMax = night - morningEnd - int = progressNorm / progressMax - } - // todo need to think wisely how to set these values & also move directional light around! - const colorInt = Math.max(int, 0.1) - updateBackground({ r: dayColor.r * colorInt, g: dayColor.g * colorInt, b: dayColor.b * colorInt }) - if (!options.newVersionsLighting && bot.supportFeature('blockStateId')) { - appViewer.playerState.reactive.ambientLight = Math.max(int, 0.25) - appViewer.playerState.reactive.directionalLight = Math.min(int, 0.5) - } - } - - bot.on('time', timeUpdated) - timeUpdated() -} diff --git a/src/defaultOptions.ts b/src/defaultOptions.ts new file mode 100644 index 00000000..48c1cfad --- /dev/null +++ b/src/defaultOptions.ts @@ -0,0 +1,159 @@ +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 +} + +function getTouchControlsSize () { + return { + joystick: 55, + action: 36, + break: 36, + jump: 36, + sneak: 36, + } +} diff --git a/src/devtools.ts b/src/devtools.ts index b9267127..1f8ef8e8 100644 --- a/src/devtools.ts +++ b/src/devtools.ts @@ -5,6 +5,17 @@ import { WorldRendererThree } from 'renderer/viewer/three/worldrendererThree' import { enable, disable, enabled } from 'debug' import { Vec3 } from 'vec3' +customEvents.on('mineflayerBotCreated', () => { + window.debugServerPacketNames = Object.fromEntries(Object.keys(loadedData.protocol.play.toClient.types).map(name => { + name = name.replace('packet_', '') + return [name, name] + })) + window.debugClientPacketNames = Object.fromEntries(Object.keys(loadedData.protocol.play.toServer.types).map(name => { + name = name.replace('packet_', '') + return [name, name] + })) +}) + window.Vec3 = Vec3 window.cursorBlockRel = (x = 0, y = 0, z = 0) => { const newPos = bot.blockAtCursor(5)?.position.offset(x, y, z) @@ -209,3 +220,105 @@ setInterval(() => { }, 1000) // --- + +// Add type declaration for performance.memory +declare global { + interface Performance { + memory?: { + usedJSHeapSize: number + totalJSHeapSize: number + jsHeapSizeLimit: number + } + } +} + +// Performance metrics WebSocket client +let ws: WebSocket | null = null +let wsReconnectTimeout: NodeJS.Timeout | null = null +let metricsInterval: NodeJS.Timeout | null = null + +// Start collecting metrics immediately +const startTime = performance.now() + +function collectAndSendMetrics () { + if (!ws || ws.readyState !== WebSocket.OPEN) return + + const metrics = { + loadTime: performance.now() - startTime, + memoryUsage: (performance.memory?.usedJSHeapSize ?? 0) / 1024 / 1024, + timestamp: Date.now() + } + + ws.send(JSON.stringify(metrics)) +} + +function getWebSocketUrl () { + const wsPort = process.env.WS_PORT + if (!wsPort) return null + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const { hostname } = window.location + return `${protocol}//${hostname}:${wsPort}` +} + +function connectWebSocket () { + if (ws) return + + const wsUrl = getWebSocketUrl() + if (!wsUrl) { + return + } + + ws = new WebSocket(wsUrl) + + ws.onopen = () => { + console.log('Connected to metrics server') + if (wsReconnectTimeout) { + clearTimeout(wsReconnectTimeout) + wsReconnectTimeout = null + } + + // Start sending metrics immediately after connection + collectAndSendMetrics() + + // Clear existing interval if any + if (metricsInterval) { + clearInterval(metricsInterval) + } + + // Set new interval + metricsInterval = setInterval(collectAndSendMetrics, 500) + } + + ws.onclose = () => { + console.log('Disconnected from metrics server') + ws = null + + // Clear metrics interval + if (metricsInterval) { + clearInterval(metricsInterval) + metricsInterval = null + } + + // Try to reconnect after 3 seconds + wsReconnectTimeout = setTimeout(connectWebSocket, 3000) + } + + ws.onerror = (error) => { + console.error('WebSocket error:', error) + } +} + +// Connect immediately +connectWebSocket() + +// Add command to request current metrics +window.requestMetrics = () => { + const metrics = { + loadTime: performance.now() - startTime, + memoryUsage: (performance.memory?.usedJSHeapSize ?? 0) / 1024 / 1024, + timestamp: Date.now() + } + console.log('Current metrics:', metrics) + return metrics +} diff --git a/src/downloadAndOpenFile.ts b/src/downloadAndOpenFile.ts index 1e703369..1ff318ff 100644 --- a/src/downloadAndOpenFile.ts +++ b/src/downloadAndOpenFile.ts @@ -11,6 +11,12 @@ export const getFixedFilesize = (bytes: number) => { return prettyBytes(bytes, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } +export const isInterestedInDownload = () => { + const { map, texturepack, replayFileUrl } = appQueryParams + const { mapDir } = appQueryParamsArray + return !!map || !!texturepack || !!replayFileUrl || !!mapDir +} + const inner = async () => { const { map, texturepack, replayFileUrl } = appQueryParams const { mapDir } = appQueryParamsArray diff --git a/src/dragndrop.ts b/src/dragndrop.ts index 6be90551..5a16bc05 100644 --- a/src/dragndrop.ts +++ b/src/dragndrop.ts @@ -3,6 +3,7 @@ import fs from 'fs' import * as nbt from 'prismarine-nbt' import RegionFile from 'prismarine-provider-anvil/src/region' import { versions } from 'minecraft-data' +import { getThreeJsRendererMethods } from 'renderer/viewer/three/threeJsMethods' import { openWorldDirectory, openWorldZip } from './browserfs' import { isGameActive } from './globalState' import { showNotification } from './react/NotificationProvider' @@ -12,6 +13,9 @@ const parseNbt = promisify(nbt.parse) const simplifyNbt = nbt.simplify window.nbt = nbt +// Supported image types for skybox +const VALID_IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp'] + // todo display drop zone for (const event of ['drag', 'dragstart', 'dragend', 'dragover', 'dragenter', 'dragleave', 'drop']) { window.addEventListener(event, (e: any) => { @@ -45,6 +49,34 @@ window.addEventListener('drop', async e => { }) async function handleDroppedFile (file: File) { + // Check for image files first when game is active + if (isGameActive(false) && VALID_IMAGE_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext))) { + try { + // Convert image to base64 + const reader = new FileReader() + const base64Promise = new Promise((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 diff --git a/src/entities.ts b/src/entities.ts index 8ee36431..674f91ef 100644 --- a/src/entities.ts +++ b/src/entities.ts @@ -4,8 +4,9 @@ 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 { miscUiState } from './globalState' +import { gameAdditionalState, miscUiState } from './globalState' import { EntityStatus } from './mineflayer/entityStatus' @@ -43,7 +44,7 @@ customEvents.on('gameLoaded', () => { updateAutoJump() const playerPerAnimation = {} as Record - const entityData = (e: Entity) => { + const checkEntityData = (e: Entity) => { if (!e.username) return window.debugEntityMetadata ??= {} window.debugEntityMetadata[e.username] = e @@ -52,6 +53,13 @@ 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 @@ -64,7 +72,7 @@ customEvents.on('gameLoaded', () => { const speed = info.avgVel const WALKING_SPEED = 0.03 const SPRINTING_SPEED = 0.18 - const isCrouched = e['crouching'] + const isCrouched = e === bot.entity ? gameAdditionalState.isSneaking : 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 @@ -73,7 +81,12 @@ customEvents.on('gameLoaded', () => { : isWalking ? (isSprinting ? 'running' : 'walking') : 'idle' if (newAnimation !== playerPerAnimation[id]) { - getThreeJsRendererMethods()?.playEntityAnimation(e.id, newAnimation) + // 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) + } playerPerAnimation[id] = newAnimation } } @@ -83,6 +96,25 @@ 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) @@ -94,60 +126,243 @@ 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) => { - entityData(e) + checkEntityData(e) + if (appViewer.playerState.reactive.cameraSpectatingEntity === e.id) { + updateCamera(e) + } }) bot._client.on('entity_velocity', (packet) => { const e = bot.entities[packet.entityId] if (!e) return - entityData(e) + checkEntityData(e) }) for (const entity of Object.values(bot.entities)) { if (entity !== bot.entity) { - entityData(entity) + checkEntityData(entity) } } - bot.on('entitySpawn', entityData) - bot.on('entityUpdate', entityData) - bot.on('entityEquip', entityData) + // 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 + } // Texture override from packet properties - 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') - } - 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 + const updateSkin = (player: import('mineflayer').Player) => { + if (!player.uuid || !player.username || !player.skinData) return - // 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) + 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 + } + } + // 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 })) + } + } + + 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) } } } - }) + + 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 + } +} diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 00000000..e565fcec --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,37 @@ +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 + } +} diff --git a/src/globalState.ts b/src/globalState.ts index 671d7907..b8982de7 100644 --- a/src/globalState.ts +++ b/src/globalState.ts @@ -46,6 +46,8 @@ export const showModal = (elem: /* (HTMLElement & Record) | */{ re activeModalStack.push(resolved) } +window.showModal = showModal + /** * * @returns true if previous modal was restored diff --git a/src/globals.js b/src/globals.js index f9125c8c..11351555 100644 --- a/src/globals.js +++ b/src/globals.js @@ -5,7 +5,8 @@ window.bot = undefined window.THREE = undefined window.localServer = undefined window.worldView = undefined -window.viewer = undefined +window.viewer = undefined // legacy +window.appViewer = undefined window.loadedData = undefined window.customEvents = new EventEmitter() window.customEvents.setMaxListeners(10_000) diff --git a/src/index.ts b/src/index.ts index 483abd74..7764188f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,7 +29,7 @@ import './reactUi' import { lockUrl, onBotCreate } from './controls' import './dragndrop' import { possiblyCleanHandle } from './browserfs' -import downloadAndOpenFile from './downloadAndOpenFile' +import downloadAndOpenFile, { isInterestedInDownload } from './downloadAndOpenFile' import fs from 'fs' import net, { Socket } from 'net' @@ -56,13 +56,12 @@ import { isCypress } from './standaloneUtils' import { startLocalServer, unsupportedLocalServerFeatures } from './createLocalServer' import defaultServerOptions from './defaultLocalServerOptions' -import dayCycle from './dayCycle' import { onAppLoad, resourcepackReload, resourcePackState } from './resourcePack' import { ConnectPeerOptions, connectToPeer } from './localServerMultiplayer' import CustomChannelClient from './customClient' import { registerServiceWorker } from './serviceWorker' -import { appStatusState, lastConnectOptions } from './react/AppStatusProvider' +import { appStatusState, lastConnectOptions, quickDevReconnect } from './react/AppStatusProvider' import { fsState } from './loadSave' import { watchFov } from './rendererUtils' @@ -97,6 +96,7 @@ 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,6 +166,7 @@ export async function connect (connectOptions: ConnectOptions) { }) } + appStatusState.showReconnect = false loadingTimerState.loading = true loadingTimerState.start = Date.now() miscUiState.hasErrors = false @@ -209,12 +210,17 @@ 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 hadConnected = !!bot - if (!wasKicked && miscUiState.appConfig?.allowAutoConnect && appQueryParams.autoConnect && hadConnected) { - location.reload() + const { alwaysReconnect } = appQueryParams + if ((!wasKicked && miscUiState.appConfig?.allowAutoConnect && appQueryParams.autoConnect && hadConnected) || (alwaysReconnect)) { + if (alwaysReconnect === 'quick' || alwaysReconnect === 'fast') { + quickDevReconnect() + } else { + location.reload() + } } errorAbortController.abort() ended = true @@ -229,8 +235,12 @@ export async function connect (connectOptions: ConnectOptions) { bot.emit('end', '') bot.removeAllListeners() bot._client.removeAllListeners() - //@ts-expect-error TODO? - bot._client = undefined + bot._client = { + //@ts-expect-error + write (packetName) { + console.warn('Tried to write packet', packetName, 'after bot was destroyed') + } + } //@ts-expect-error window.bot = bot = undefined } @@ -276,6 +286,10 @@ export async function connect (connectOptions: ConnectOptions) { return } } + if (e.reason?.stack?.includes('chrome-extension://')) { + // ignore issues caused by chrome extension + return + } handleError(e.reason) }, { signal: errorAbortController.signal @@ -290,7 +304,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') ?? ''}` } }) + net['setProxy']({ hostname: proxy.host, port: proxy.port, headers: { Authorization: `Bearer ${new URLSearchParams(location.search).get('token') ?? ''}` }, artificialDelay: appQueryParams.addPing ? Number(appQueryParams.addPing) : undefined }) } const renderDistance = singleplayer ? renderDistanceSingleplayer : multiplayerRenderDistance @@ -779,7 +793,6 @@ export async function connect (connectOptions: ConnectOptions) { } initMotionTracking() - dayCycle() // Bot position callback const botPosition = () => { @@ -831,11 +844,32 @@ 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) @@ -859,37 +893,7 @@ export async function connect (connectOptions: ConnectOptions) { } } -const reconnectOptions = sessionStorage.getItem('reconnectOptions') ? JSON.parse(sessionStorage.getItem('reconnectOptions')!) : undefined - listenGlobalEvents() -const unsubscribe = subscribe(miscUiState, async () => { - if (miscUiState.fsReady && miscUiState.appConfig) { - unsubscribe() - if (reconnectOptions) { - sessionStorage.removeItem('reconnectOptions') - if (Date.now() - reconnectOptions.timestamp < 1000 * 60 * 2) { - void connect(reconnectOptions.value) - } - } else { - if (appQueryParams.singleplayer === '1' || appQueryParams.sp === '1') { - loadSingleplayer({}, { - worldFolder: undefined, - ...appQueryParams.version ? { version: appQueryParams.version } : {} - }) - } - if (appQueryParams.loadSave) { - const savePath = `/data/worlds/${appQueryParams.loadSave}` - try { - await fs.promises.stat(savePath) - } catch (err) { - alert(`Save ${savePath} not found`) - return - } - await loadInMemorySave(savePath) - } - } - } -}) // #region fire click event on touch as we disable default behaviors let activeTouch: { touch: Touch, elem: HTMLElement, start: number } | undefined @@ -925,90 +929,148 @@ document.body.addEventListener('touchstart', (e) => { }, { passive: false }) // #endregion -// qs open actions -if (!reconnectOptions) { - downloadAndOpenFile().then((downloadAction) => { - if (downloadAction) return - if (appQueryParams.reconnect && process.env.NODE_ENV === 'development') { - const lastConnect = JSON.parse(localStorage.lastConnectOptions ?? {}) +// immediate game enter actions: reconnect or URL QS +const maybeEnterGame = () => { + const waitForConfigFsLoad = (fn: () => void) => { + let unsubscribe: () => void | undefined + const checkDone = () => { + if (miscUiState.fsReady && miscUiState.appConfig) { + fn() + unsubscribe?.() + return true + } + return false + } + + if (!checkDone()) { + const text = miscUiState.appConfig ? 'Loading' : 'Loading config' + setLoadingScreenStatus(text) + unsubscribe = subscribe(miscUiState, checkDone) + } + } + + const reconnectOptions = sessionStorage.getItem('reconnectOptions') ? JSON.parse(sessionStorage.getItem('reconnectOptions')!) : undefined + + if (reconnectOptions) { + sessionStorage.removeItem('reconnectOptions') + if (Date.now() - reconnectOptions.timestamp < 1000 * 60 * 2) { + return waitForConfigFsLoad(async () => { + void connect(reconnectOptions.value) + }) + } + } + + if (appQueryParams.reconnect && localStorage.lastConnectOptions && process.env.NODE_ENV === 'development') { + const lastConnect = JSON.parse(localStorage.lastConnectOptions ?? {}) + return waitForConfigFsLoad(async () => { void connect({ botVersion: appQueryParams.version ?? undefined, ...lastConnect, ip: appQueryParams.ip || undefined }) - return - } - if (appQueryParams.ip || appQueryParams.proxy) { - const waitAppConfigLoad = !appQueryParams.proxy - const openServerEditor = () => { - hideModal() - if (appQueryParams.onlyConnect) { - showModal({ reactType: 'only-connect-server' }) - } else { - showModal({ reactType: 'editServer' }) - } - } - showModal({ reactType: 'empty' }) - if (waitAppConfigLoad) { - const unsubscribe = subscribe(miscUiState, checkCanDisplay) - checkCanDisplay() - // eslint-disable-next-line no-inner-declarations - function checkCanDisplay () { - if (miscUiState.appConfig) { - unsubscribe() - openServerEditor() - return true - } - } - } else { - openServerEditor() - } - } - - void Promise.resolve().then(() => { - // try to connect to peer - const peerId = appQueryParams.connectPeer - const peerOptions = {} as ConnectPeerOptions - if (appQueryParams.server) { - peerOptions.server = appQueryParams.server - } - const version = appQueryParams.peerVersion - if (peerId) { - let username: string | null = options.guestUsername - if (options.askGuestName) username = prompt('Enter your username', username) - if (!username) return - options.guestUsername = username - void connect({ - username, - botVersion: version || undefined, - peerId, - peerOptions - }) - } }) + } - if (appQueryParams.serversList && !appQueryParams.ip) { - showModal({ reactType: 'serversList' }) - } - - const viewerWsConnect = appQueryParams.viewerConnect - if (viewerWsConnect) { - void connect({ - username: `viewer-${Math.random().toString(36).slice(2, 10)}`, - viewerWsConnect, + if (appQueryParams.singleplayer === '1' || appQueryParams.sp === '1') { + return waitForConfigFsLoad(async () => { + loadSingleplayer({}, { + worldFolder: undefined, + ...appQueryParams.version ? { version: appQueryParams.version } : {} }) - } - - if (appQueryParams.modal) { - const modals = appQueryParams.modal.split(',') - for (const modal of modals) { - showModal({ reactType: modal }) + }) + } + if (appQueryParams.loadSave) { + const enterSave = async () => { + const savePath = `/data/worlds/${appQueryParams.loadSave}` + try { + await fs.promises.stat(savePath) + await loadInMemorySave(savePath) + } catch (err) { + alert(`Save ${savePath} not found`) } } - }, (err) => { - console.error(err) - alert(`Something went wrong: ${err}`) - }) + return waitForConfigFsLoad(enterSave) + } + + if (appQueryParams.ip || appQueryParams.proxy) { + const openServerAction = () => { + if (appQueryParams.autoConnect && miscUiState.appConfig?.allowAutoConnect) { + void connect({ + server: appQueryParams.ip, + proxy: getCurrentProxy(), + botVersion: appQueryParams.version ?? undefined, + username: getCurrentUsername()!, + }) + return + } + + setLoadingScreenStatus(undefined) + if (appQueryParams.onlyConnect || process.env.ALWAYS_MINIMAL_SERVER_UI === 'true') { + showModal({ reactType: 'only-connect-server' }) + } else { + showModal({ reactType: 'editServer' }) + } + } + + // showModal({ reactType: 'empty' }) + return waitForConfigFsLoad(openServerAction) + } + + if (appQueryParams.connectPeer) { + // try to connect to peer + const peerId = appQueryParams.connectPeer + const peerOptions = {} as ConnectPeerOptions + if (appQueryParams.server) { + peerOptions.server = appQueryParams.server + } + const version = appQueryParams.peerVersion + let username: string | null = options.guestUsername + if (options.askGuestName) username = prompt('Enter your username to connect to peer', username) + if (!username) return + options.guestUsername = username + void connect({ + username, + botVersion: version || undefined, + peerId, + peerOptions + }) + return + + } + + if (appQueryParams.viewerConnect) { + void connect({ + username: `viewer-${Math.random().toString(36).slice(2, 10)}`, + viewerWsConnect: appQueryParams.viewerConnect, + }) + return + } + + if (appQueryParams.modal) { + const modals = appQueryParams.modal.split(',') + for (const modal of modals) { + showModal({ reactType: modal }) + } + return + } + + if (appQueryParams.serversList && !miscUiState.appConfig?.appParams?.serversList) { + // open UI only if it's in URL + showModal({ reactType: 'serversList' }) + } + + if (isInterestedInDownload()) { + void downloadAndOpenFile() + } + + void possiblyHandleStateVariable() +} + +try { + maybeEnterGame() +} catch (err) { + console.error(err) + alert(`Something went wrong: ${err}`) } // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion @@ -1019,6 +1081,5 @@ if (initialLoader) { } window.pageLoaded = true -void possiblyHandleStateVariable() appViewer.waitBackendLoadPromises.push(appStartup()) registerOpenBenchmarkListener() diff --git a/src/inventoryWindows.ts b/src/inventoryWindows.ts index d03b9fa4..d40260df 100644 --- a/src/inventoryWindows.ts +++ b/src/inventoryWindows.ts @@ -9,8 +9,10 @@ import PItem, { Item } from 'prismarine-item' import { versionToNumber } from 'renderer/viewer/common/utils' import { getRenamedData } from 'flying-squid/dist/blockRenames' import PrismarineChatLoader from 'prismarine-chat' +import * as nbt from 'prismarine-nbt' import { BlockModel } from 'mc-assets' import { renderSlot } from 'renderer/viewer/three/renderSlot' +import { loadSkinFromUsername } from 'renderer/viewer/lib/utils/skins' import Generic95 from '../assets/generic_95.png' import { appReplacableResources } from './generated/resources' import { activeModalStack, hideCurrentModal, hideModal, miscUiState, showModal } from './globalState' @@ -22,8 +24,9 @@ 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() +const loadedImagesCache = new Map() const cleanLoadedImagesCache = () => { loadedImagesCache.delete('blocks') loadedImagesCache.delete('items') @@ -39,6 +42,34 @@ export const jeiCustomCategories = proxy({ value: [] as Array<{ id: string, categoryTitle: string, items: any[] }> }) +let remotePlayerSkin: string | undefined | Promise + +export const showInventoryPlayer = () => { + modelViewerState.model = { + positioning: { + windowWidth: 176, + windowHeight: 166, + x: 25, + y: 8, + width: 50, + height: 70, + scaled: true, + onlyInitialScale: true, + followCursor: true, + }, + // models: ['https://bucket.mcraft.fun/sitarbuckss.glb'], + // debug: true, + steveModelSkin: appViewer.playerState.reactive.playerSkin ?? (typeof remotePlayerSkin === 'string' ? remotePlayerSkin : ''), + } + if (remotePlayerSkin === undefined && !appViewer.playerState.reactive.playerSkin) { + remotePlayerSkin = loadSkinFromUsername(bot.username, 'skin').then(a => { + setTimeout(() => { showInventoryPlayer() }, 0) // todo patch instead and make reactive + remotePlayerSkin = a ?? '' + return remotePlayerSkin + }) + } +} + export const onGameLoad = () => { version = bot.version @@ -56,12 +87,23 @@ export const onGameLoad = () => { return type } + const maybeParseNbtJson = (data: any) => { + if (typeof data === 'string') { + try { + data = JSON.parse(data) + } catch (err) { + // ignore + } + } + return nbt.simplify(data) ?? data + } + bot.on('windowOpen', (win) => { const implementedWindow = implementedContainersGuiMap[mapWindowType(win.type as string, win.inventoryStart)] if (implementedWindow) { - openWindow(implementedWindow) + openWindow(implementedWindow, maybeParseNbtJson(win.title)) } else if (options.unimplementedContainers) { - openWindow('ChestWin') + openWindow('ChestWin', maybeParseNbtJson(win.title)) } else { // todo format displayClientChat(`[client error] cannot open unimplemented window ${win.id} (${win.type}). Slots: ${win.slots.map(item => getItemName(item)).filter(Boolean).join(', ')}`) @@ -132,11 +174,12 @@ export const onGameLoad = () => { } } -const getImageSrc = (path): string | HTMLImageElement => { +const getImageSrc = (path): string | HTMLImageElement | ImageBitmap => { 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 '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 @@ -159,12 +202,20 @@ 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 @@ -201,6 +252,11 @@ const itemToVisualKey = (slot: RenderItem | Item | null) => { ].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, isJei = false) => { const newSlots = slots.map((slot, i) => { if (!slot) return null @@ -210,6 +266,7 @@ const mapSlots = (slots: Array, isJei = false) => { const newKey = itemToVisualKey(slot) slot['cacheKey'] = i + '|' + newKey if (oldKey && oldKey === newKey) { + validateSlot(lastMappedSlots[i], i) return lastMappedSlots[i] } } @@ -217,7 +274,7 @@ const mapSlots = (slots: Array, 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, playerState) + const modelName = getItemModelName(slot, { 'minecraft:display_context': 'gui', }, appViewer.resourcesManager, appViewer.playerState.reactive) const slotCustomProps = renderSlot({ modelName, originalItemName: slot.name }, appViewer.resourcesManager, debugIsQuickbar) const itemCustomName = getItemName(slot) Object.assign(slot, { ...slotCustomProps, displayName: itemCustomName ?? slot.displayName }) @@ -228,12 +285,13 @@ const mapSlots = (slots: Array, isJei = false) => { const { icon, ...rest } = slot return rest } + validateSlot(slot, i) } catch (err) { inGameError(err) } return slot }) - lastMappedSlots = newSlots + lastMappedSlots = JSON.parse(JSON.stringify(newSlots)) return newSlots } @@ -242,6 +300,7 @@ export const upInventoryItems = (isInventory: boolean, invWindow = lastWindow) = // inv.pwindow.inv.slots[2].blockData = getBlockData('dirt') const customSlots = mapSlots((isInventory ? bot.inventory : bot.currentWindow)!.slots) invWindow.pwindow.setSlots(customSlots) + return customSlots } export const onModalClose = (callback: () => any) => { @@ -268,6 +327,7 @@ 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 @@ -337,7 +397,7 @@ const upWindowItemsLocal = () => { } let skipClosePacketSending = false -const openWindow = (type: string | undefined) => { +const openWindow = (type: string | undefined, title: string | any = undefined) => { // if (activeModalStack.some(x => x.reactType?.includes?.('player_win:'))) { if (activeModalStack.length) { // game is not in foreground, don't close current modal if (type) { @@ -358,15 +418,20 @@ const openWindow = (type: string | undefined) => { lastWindow.destroy() lastWindow = null as any lastWindowType = null - window.lastWindow = lastWindow + window.inventory = null 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 - const title = bot.currentWindow?.title + window.inventory = inv const PrismarineChat = PrismarineChatLoader(bot.version) try { inv.canvasManager.children[0].customTitleText = title ? @@ -405,6 +470,7 @@ const openWindow = (type: string | undefined) => { const isRightClick = type === 'rightclick' const isLeftClick = type === 'leftclick' if (isLeftClick || isRightClick) { + modelViewerState.model = undefined inv.canvasManager.children[0].showRecipesOrUsages(isLeftClick, item) } } else { @@ -436,6 +502,7 @@ const openWindow = (type: string | undefined) => { if (freeSlot === null) return void bot.creative.setInventorySlot(freeSlot, item) } else { + modelViewerState.model = undefined inv.canvasManager.children[0].showRecipesOrUsages(!isRightclick, mapSlots([item], true)[0]) } } @@ -504,7 +571,7 @@ const getResultingRecipe = (slots: Array, 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! @@ -532,7 +599,7 @@ const getAllItemRecipes = (itemName: string) => { const item = loadedData.itemsByName[itemName] if (!item) return const itemId = item.id - const recipes = loadedData.recipes[itemId] + const recipes = loadedData.recipes?.[itemId] if (!recipes) return const results = [] as Array<{ result: Item, @@ -577,7 +644,7 @@ const getAllItemUsages = (itemName: string) => { if (!item) return const foundRecipeIds = [] as string[] - for (const [id, recipes] of Object.entries(loadedData.recipes)) { + for (const [id, recipes] of Object.entries(loadedData.recipes ?? {})) { for (const recipe of recipes) { if ('inShape' in recipe) { if (recipe.inShape.some(row => row.includes(item.id))) { diff --git a/src/loadSave.ts b/src/loadSave.ts index 7c9f7277..f1676cff 100644 --- a/src/loadSave.ts +++ b/src/loadSave.ts @@ -85,7 +85,6 @@ export const loadSave = async (root = '/world', connectOptions?: Partial { onMsaCodeCallback(json) // this.codeCallback(json) } - if (json.error) throw new Error(json.error) + if (json.error) throw new Error(`Auth server error: ${json.error}`) if (json.token) result = json if (json.newCache) setCacheResult(json.newCache) } diff --git a/src/mineflayer/items.ts b/src/mineflayer/items.ts index 495c4f39..48d0dfe0 100644 --- a/src/mineflayer/items.ts +++ b/src/mineflayer/items.ts @@ -58,9 +58,17 @@ export const getItemMetadata = (item: GeneralInputItem, resourcesManager: Resour } if (customModelDataDefinitions) { const customModelDataComponent: any = componentMap.get('custom_model_data') - if (customModelDataComponent?.data && typeof customModelDataComponent.data === 'number') { - const customModelData = customModelDataComponent.data - if (customModelDataDefinitions[customModelData]) { + if (customModelDataComponent?.data) { + let customModelData: number | undefined + if (typeof customModelDataComponent.data === 'number') { + customModelData = customModelDataComponent.data + } else if (typeof customModelDataComponent.data === 'object' + && 'floats' in customModelDataComponent.data + && Array.isArray(customModelDataComponent.data.floats) + && customModelDataComponent.data.floats.length > 0) { + customModelData = customModelDataComponent.data.floats[0] + } + if (customModelData && customModelDataDefinitions[customModelData]) { customModel = customModelDataDefinitions[customModelData] } } diff --git a/src/mineflayer/mc-protocol.ts b/src/mineflayer/mc-protocol.ts index 2376cd03..cd21d01f 100644 --- a/src/mineflayer/mc-protocol.ts +++ b/src/mineflayer/mc-protocol.ts @@ -1,13 +1,46 @@ +import net from 'net' import { Client } from 'minecraft-protocol' import { appQueryParams } from '../appParams' import { downloadAllMinecraftData, getVersionAutoSelect } from '../connect' import { gameAdditionalState } from '../globalState' import { ProgressReporter } from '../core/progressReporter' +import { parseServerAddress } from '../parseServerAddress' +import { getCurrentProxy } from '../react/ServersList' import { pingServerVersion, validatePacket } from './minecraft-protocol-extra' import { getWebsocketStream } from './websocket-core' let lastPacketTime = 0 customEvents.on('mineflayerBotCreated', () => { + // const oldParsePacketBuffer = bot._client.deserializer.parsePacketBuffer + // try { + // const parsed = oldParsePacketBuffer(buffer) + // } catch (err) { + // debugger + // reportError(new Error(`Error parsing packet ${buffer.subarray(0, 30).toString('hex')}`, { cause: err })) + // throw err + // } + // } + class MinecraftProtocolError extends Error { + constructor (message: string, cause?: Error, public data?: any) { + if (data?.customPayload) { + message += ` (Custom payload: ${data.customPayload.channel})` + } + super(message, { cause }) + this.name = 'MinecraftProtocolError' + } + } + + const onClientError = (err, data) => { + const error = new MinecraftProtocolError(`Minecraft protocol client error: ${err.message}`, err, data) + reportError(error) + } + if (typeof bot._client['_events'].error === 'function') { + // dont report to bot for more explicit error + bot._client['_events'].error = onClientError + } else { + bot._client.on('error' as any, onClientError) + } + // todo move more code here if (!appQueryParams.noPacketsValidation) { (bot._client as unknown as Client).on('packet', (data, packetMeta, buffer, fullBuffer) => { @@ -35,7 +68,7 @@ setInterval(() => { }, 1000) -export const getServerInfo = async (ip: string, port?: number, preferredVersion = getVersionAutoSelect(), ping = false, progressReporter?: ProgressReporter) => { +export const getServerInfo = async (ip: string, port?: number, preferredVersion = getVersionAutoSelect(), ping = false, progressReporter?: ProgressReporter, setProxyParams?: ProxyParams) => { await downloadAllMinecraftData() const isWebSocket = ip.startsWith('ws://') || ip.startsWith('wss://') let stream @@ -43,6 +76,8 @@ export const getServerInfo = async (ip: string, port?: number, preferredVersion progressReporter?.setMessage('Connecting to WebSocket server') stream = (await getWebsocketStream(ip)).mineflayerStream progressReporter?.setMessage('WebSocket connected. Ping packet sent, waiting for response') + } else if (setProxyParams) { + setProxy(setProxyParams) } window.setLoadingMessage = (message?: string) => { if (message === undefined) { @@ -59,3 +94,46 @@ export const getServerInfo = async (ip: string, port?: number, preferredVersion window.setLoadingMessage = undefined }) } + +globalThis.debugTestPing = async (ip: string) => { + const parsed = parseServerAddress(ip, false) + const result = await getServerInfo(parsed.host, parsed.port ? Number(parsed.port) : undefined, undefined, true, undefined, { address: getCurrentProxy(), }) + console.log('result', result) + return result +} + +export const getDefaultProxyParams = () => { + return { + headers: { + Authorization: `Bearer ${new URLSearchParams(location.search).get('token') ?? ''}` + } + } +} + +export type ProxyParams = { + address?: string + headers?: Record +} + +export const setProxy = (proxyParams: ProxyParams) => { + if (proxyParams.address?.startsWith(':')) { + proxyParams.address = `${location.protocol}//${location.hostname}${proxyParams.address}` + } + if (proxyParams.address && location.port !== '80' && location.port !== '443' && !/:\d+$/.test(proxyParams.address)) { + const https = proxyParams.address.startsWith('https://') || location.protocol === 'https:' + proxyParams.address = `${proxyParams.address}:${https ? 443 : 80}` + } + + const parsedProxy = parseServerAddress(proxyParams.address, false) + const proxy = { host: parsedProxy.host, port: parsedProxy.port } + proxyParams.headers ??= getDefaultProxyParams().headers + net['setProxy']({ + hostname: proxy.host, + port: proxy.port, + headers: proxyParams.headers, + artificialDelay: appQueryParams.addPing ? Number(appQueryParams.addPing) : undefined + }) + return { + proxy + } +} diff --git a/src/mineflayer/minecraft-protocol-extra.ts b/src/mineflayer/minecraft-protocol-extra.ts index f3cf11e3..65260979 100644 --- a/src/mineflayer/minecraft-protocol-extra.ts +++ b/src/mineflayer/minecraft-protocol-extra.ts @@ -31,7 +31,9 @@ export const pingServerVersion = async (ip: string, port?: number, mergeOptions: }) if (mergeOptions.stream) { mergeOptions.stream.on('end', (err) => { - reject(new Error('Connection closed')) + setTimeout(() => { + reject(new Error('Connection closed. Please report if you see this but the server is actually fine.')) + }) }) } }) diff --git a/src/mineflayer/playerState.ts b/src/mineflayer/playerState.ts index f80a6971..33f7af77 100644 --- a/src/mineflayer/playerState.ts +++ b/src/mineflayer/playerState.ts @@ -1,14 +1,15 @@ import { HandItemBlock } from 'renderer/viewer/three/holdingBlock' -import { getInitialPlayerState, PlayerStateRenderer } from 'renderer/viewer/lib/basePlayerState' +import { getInitialPlayerState, getPlayerStateUtils, PlayerStateReactive, PlayerStateRenderer, PlayerStateUtils } from 'renderer/viewer/lib/basePlayerState' import { subscribe } from 'valtio' import { subscribeKey } from 'valtio/utils' import { gameAdditionalState } from '../globalState' +import { options } from '../optionsStorage' /** * can be used only in main thread. Mainly for more convenient reactive state updates. * In renderer/ directory, use PlayerStateControllerRenderer type or worldRenderer.playerState. */ -export class PlayerStateControllerMain implements PlayerStateRenderer { +export class PlayerStateControllerMain { disableStateUpdates = false private timeOffGround = 0 @@ -18,7 +19,8 @@ export class PlayerStateControllerMain implements PlayerStateRenderer { private isUsingItem = false ready = false - reactive: PlayerStateRenderer['reactive'] + reactive: PlayerStateReactive + utils: PlayerStateUtils constructor () { customEvents.on('mineflayerBotCreated', () => { @@ -41,6 +43,8 @@ export class PlayerStateControllerMain implements PlayerStateRenderer { private botCreated () { console.log('bot created & plugins injected') this.reactive = getInitialPlayerState() + this.reactive.perspective = options.defaultPerspective + this.utils = getPlayerStateUtils(this.reactive) this.onBotCreatedOrGameJoined() const handleDimensionData = (data) => { @@ -97,6 +101,10 @@ export class PlayerStateControllerMain implements PlayerStateRenderer { }) this.reactive.gameMode = bot.game?.gameMode + customEvents.on('gameLoaded', () => { + this.reactive.team = bot.teamMap[bot.username] + }) + this.watchReactive() } diff --git a/src/mineflayer/plugins/mouse.ts b/src/mineflayer/plugins/mouse.ts index fc1ce0fd..14e19345 100644 --- a/src/mineflayer/plugins/mouse.ts +++ b/src/mineflayer/plugins/mouse.ts @@ -110,7 +110,7 @@ const domListeners = (bot: Bot) => { }, { signal: abortController.signal }) bot.mouse.beforeUpdateChecks = () => { - if (!document.hasFocus()) { + if (!document.hasFocus() || !isGameActive(true)) { // deactive all buttons bot.mouse.buttons.fill(false) } diff --git a/src/mineflayer/websocket-core.ts b/src/mineflayer/websocket-core.ts index d24bd6be..f8163102 100644 --- a/src/mineflayer/websocket-core.ts +++ b/src/mineflayer/websocket-core.ts @@ -15,13 +15,18 @@ class CustomDuplex extends Duplex { } export const getWebsocketStream = async (host: string) => { - const baseProtocol = location.protocol === 'https:' ? 'wss' : host.startsWith('ws://') ? 'ws' : 'wss' + const baseProtocol = host.startsWith('ws://') ? 'ws' : 'wss' const hostClean = host.replace('ws://', '').replace('wss://', '') - const ws = new WebSocket(`${baseProtocol}://${hostClean}`) + const hostURL = new URL(`${baseProtocol}://${hostClean}`) + const hostParams = hostURL.searchParams + hostParams.append('client_mcraft', '') + const ws = new WebSocket(`${baseProtocol}://${hostURL.host}${hostURL.pathname}?${hostParams.toString()}`) const clientDuplex = new CustomDuplex(undefined, data => { ws.send(data) }) + clientDuplex.on('error', () => {}) + ws.addEventListener('message', async message => { let { data } = message if (data instanceof Blob) { diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index 3409dc76..0cb0fe1e 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -20,6 +20,7 @@ import { getVersionAutoSelect } from './connect' import { createNotificationProgressReporter } from './core/progressReporter' import { customKeymaps } from './controls' import { appStorage } from './react/appStorageProvider' +import { exportData, importData } from './core/importExport' export const guiOptionsScheme: { [t in OptionsGroupType]: Array<{ [K in keyof AppOptions]?: Partial> } & { custom? }> @@ -479,6 +480,24 @@ export const guiOptionsScheme: { ], sound: [ { volume: {} }, + { + custom () { + return { + options.musicVolume = value + }} + item={{ + type: 'slider', + id: 'musicVolume', + text: 'Music Volume', + min: 0, + max: 100, + unit: '%', + }} + /> + }, + }, { custom () { return } }, @@ -646,53 +686,7 @@ export const guiOptionsScheme: { custom () { return } }, diff --git a/src/optionsStorage.ts b/src/optionsStorage.ts index 882610f8..22d5ef26 100644 --- a/src/optionsStorage.ts +++ b/src/optionsStorage.ts @@ -5,161 +5,10 @@ import { appQueryParams, appQueryParamsArray } from './appParams' import type { AppConfig } from './appConfig' import { appStorage } from './react/appStorageProvider' import { miscUiState } from './globalState' +import { defaultOptions } from './defaultOptions' const isDev = process.env.NODE_ENV === 'development' const initialAppConfig = process.env?.INLINED_APP_CONFIG as AppConfig ?? {} -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: false, - // fov: 70, - fov: 75, - 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, - 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: [], - preventDevReloadWhilePlaying: false, - numWorkers: 4, - localServerOptions: { - gameMode: 1 - } as any, - preferLoadReadonly: false, - 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 -} - -function getTouchControlsSize () { - return { - joystick: 55, - action: 36, - break: 36, - jump: 36, - sneak: 36, - } -} // const qsOptionsRaw = new URLSearchParams(location.search).getAll('setting') const qsOptionsRaw = appQueryParamsArray.setting ?? [] @@ -191,15 +40,15 @@ const migrateOptions = (options: Partial>) => { return options } const migrateOptionsLocalStorage = () => { - if (Object.keys(appStorage.options).length) { - for (const key of Object.keys(appStorage.options)) { + if (Object.keys(appStorage['options'] ?? {}).length) { + for (const key of Object.keys(appStorage['options'])) { if (!(key in defaultOptions)) continue // drop unknown options const defaultValue = defaultOptions[key] - if (JSON.stringify(defaultValue) !== JSON.stringify(appStorage.options[key])) { - appStorage.changedSettings[key] = appStorage.options[key] + if (JSON.stringify(defaultValue) !== JSON.stringify(appStorage['options'][key])) { + appStorage.changedSettings[key] = appStorage['options'][key] } } - appStorage.options = {} + delete appStorage['options'] } } @@ -311,3 +160,5 @@ export const getAppLanguage = () => { } return options.language } + +export { defaultOptions } from './defaultOptions' diff --git a/src/packetsReplay/replayPackets.ts b/src/packetsReplay/replayPackets.ts index d0d95da8..54b3d652 100644 --- a/src/packetsReplay/replayPackets.ts +++ b/src/packetsReplay/replayPackets.ts @@ -59,6 +59,7 @@ export const startLocalReplayServer = (contents: string) => { const server = createServer({ Server: LocalServer as any, version: header.minecraftVersion, + keepAlive: false, 'online-mode': false }) diff --git a/src/react/AddServerOrConnect.tsx b/src/react/AddServerOrConnect.tsx index 08e4d69e..36fd5264 100644 --- a/src/react/AddServerOrConnect.tsx +++ b/src/react/AddServerOrConnect.tsx @@ -29,10 +29,9 @@ interface Props { accounts?: string[] authenticatedAccounts?: number versions?: string[] - allowAutoConnect?: boolean } -export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQs, onQsConnect, placeholders, accounts, versions, allowAutoConnect }: Props) => { +export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQs, onQsConnect, placeholders, accounts, versions }: Props) => { const isSmallHeight = !usePassesScaledDimensions(null, 350) const qsParamName = parseQs ? appQueryParams.name : undefined const qsParamIp = parseQs ? appQueryParams.ip : undefined @@ -40,7 +39,6 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ const qsParamProxy = parseQs ? appQueryParams.proxy : undefined const qsParamUsername = parseQs ? appQueryParams.username : undefined const qsParamLockConnect = parseQs ? appQueryParams.lockConnect : undefined - const qsParamAutoConnect = parseQs ? appQueryParams.autoConnect : undefined const parsedQsIp = parseServerAddress(qsParamIp) const parsedInitialIp = parseServerAddress(initialData?.ip) @@ -118,14 +116,8 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ } } - useEffect(() => { - if (qsParamAutoConnect && qsParamIp && qsParamVersion && allowAutoConnect) { - onQsConnect?.(commonUseOptions) - } - }, []) - const displayConnectButton = qsParamIp - const serverExamples = ['example.com:25565', 'play.hypixel.net', 'ws://play.pcm.gg'] + const serverExamples = ['example.com:25565', 'play.hypixel.net', 'ws://play.pcm.gg', 'wss://play.webmc.fun'] // pick random example const example = serverExamples[Math.floor(Math.random() * serverExamples.length)] @@ -231,7 +223,7 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ Cancel - {displayConnectButton ? 'Save' : Save} + {displayConnectButton ? translate('Save') : {translate('Save')}} } {displayConnectButton && ( @@ -246,7 +238,7 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ onQsConnect?.(commonUseOptions) }} > - Connect + {translate('Connect')} )} diff --git a/src/react/AppStatusProvider.tsx b/src/react/AppStatusProvider.tsx index 8bfed645..9c7b34ac 100644 --- a/src/react/AppStatusProvider.tsx +++ b/src/react/AppStatusProvider.tsx @@ -54,6 +54,17 @@ export const reconnectReload = () => { } } +export const quickDevReconnect = () => { + if (!lastConnectOptions.value) { + return + } + + resetAppStatusState() + window.dispatchEvent(new window.CustomEvent('connect', { + detail: lastConnectOptions.value + })) +} + export default () => { const lastState = useRef(JSON.parse(JSON.stringify(appStatusState))) const currentState = useSnapshot(appStatusState) @@ -105,13 +116,6 @@ export default () => { } }, [isOpen]) - const reconnect = () => { - resetAppStatusState() - window.dispatchEvent(new window.CustomEvent('connect', { - detail: lastConnectOptions.value - })) - } - useEffect(() => { const controller = new AbortController() window.addEventListener('keyup', (e) => { @@ -119,7 +123,7 @@ export default () => { if (activeModalStack.at(-1)?.reactType !== 'app-status') return // todo do only if reconnect is possible if (e.code !== 'KeyR' || !lastConnectOptions.value) return - reconnect() + quickDevReconnect() }, { signal: controller.signal }) @@ -127,6 +131,7 @@ export default () => { }, []) const displayAuthButton = status.includes('This server appears to be an online server and you are providing no authentication.') + || JSON.stringify(minecraftJsonMessage ?? {}).toLowerCase().includes('authenticate') const hasVpnText = (text: string) => text.includes('VPN') || text.includes('Proxy') const displayVpnButton = hasVpnText(status) || (minecraftJsonMessage && hasVpnText(JSON.stringify(minecraftJsonMessage))) const authReconnectAction = async () => { @@ -139,7 +144,7 @@ export default () => { const account = await showOptionsModal('Choose account to connect with', [...accounts.map(account => account.username), 'Use other account']) if (!account) return lastConnectOptions.value!.authenticatedAccount = accounts.find(acc => acc.username === account) || true - reconnect() + quickDevReconnect() } const lastAutoCapturedPackets = getLastAutoCapturedPackets() @@ -183,7 +188,7 @@ export default () => { actionsSlot={ <> {displayAuthButton && - diff --git a/src/react/Chat.tsx b/src/react/Chat.tsx index 41a6cb6f..d927a558 100644 --- a/src/react/Chat.tsx +++ b/src/react/Chat.tsx @@ -11,22 +11,41 @@ import { useScrollBehavior } from './hooks/useScrollBehavior' export type Message = { parts: MessageFormatPart[], id: number - fading?: boolean - faded?: boolean + timestamp?: number } -const MessageLine = ({ message, currentPlayerName }: { message: Message, currentPlayerName?: string }) => { +const MessageLine = ({ message, currentPlayerName, chatOpened }: { message: Message, currentPlayerName?: string, chatOpened?: boolean }) => { + const [fadeState, setFadeState] = useState<'visible' | 'fading' | 'faded'>('visible') + + useEffect(() => { + if (window.debugStopChatFade) return + // Start fading after 5 seconds + const fadeTimeout = setTimeout(() => { + setFadeState('fading') + }, 5000) + + // Remove after fade animation (3s) completes + const removeTimeout = setTimeout(() => { + setFadeState('faded') + }, 8000) + + // Cleanup timeouts if component unmounts + return () => { + clearTimeout(fadeTimeout) + clearTimeout(removeTimeout) + } + }, []) // Empty deps array since we only want this to run once when message is added + const classes = { - 'chat-message-fadeout': message.fading, - 'chat-message-fade': message.fading, - 'chat-message-faded': message.faded, - 'chat-message': true + 'chat-message': true, + 'chat-message-fading': !chatOpened && fadeState === 'fading', + 'chat-message-faded': !chatOpened && fadeState === 'faded' } - return
  • val).map(([name]) => name).join(' ')}> + return
  • val).map(([name]) => name).join(' ')} data-time={message.timestamp ? new Date(message.timestamp).toLocaleString('en-US', { hour12: false }) : undefined}> {message.parts.map((msg, i) => { // Check if this is a text part that might contain a mention - if (msg.text && currentPlayerName) { + if (typeof msg.text === 'string' && currentPlayerName) { const parts = msg.text.split(new RegExp(`(@${currentPlayerName})`, 'i')) if (parts.length > 1) { return parts.map((txtPart, j) => { @@ -70,17 +89,6 @@ export const chatInputValueGlobal = proxy({ value: '' }) -export const fadeMessage = (message: Message, initialTimeout: boolean, requestUpdate: () => void) => { - setTimeout(() => { - message.fading = true - requestUpdate() - setTimeout(() => { - message.faded = true - requestUpdate() - }, 3000) - }, initialTimeout ? 5000 : 0) -} - export default ({ messages, opacity = 1, @@ -104,7 +112,9 @@ export default ({ const sendHistoryRef = useRef(JSON.parse(window.sessionStorage.chatHistory || '[]')) const [isInputFocused, setIsInputFocused] = useState(false) - const spellCheckEnabled = false + const [spellCheckEnabled, setSpellCheckEnabled] = useState(false) + const [preservedInputValue, setPreservedInputValue] = useState('') + const [inputKey, setInputKey] = useState(0) const pingHistoryRef = useRef(JSON.parse(window.localStorage.pingHistory || '[]')) const [completePadText, setCompletePadText] = useState('') @@ -115,7 +125,9 @@ export default ({ const chatInput = useRef(null!) const chatMessages = useRef(null) const chatHistoryPos = useRef(sendHistoryRef.current.length) + const commandHistoryPos = useRef(0) const inputCurrentlyEnteredValue = useRef('') + const commandHistoryRef = useRef(sendHistoryRef.current.filter((msg: string) => msg.startsWith('/'))) const { scrollToBottom, isAtBottom, wasAtBottom, currentlyAtBottom } = useScrollBehavior(chatMessages, { messages, opened }) const [rightNowAtBottom, setRightNowAtBottom] = useState(false) @@ -132,6 +144,9 @@ export default ({ sendHistoryRef.current = newHistory window.sessionStorage.chatHistory = JSON.stringify(newHistory) chatHistoryPos.current = newHistory.length + // Update command history (only messages starting with /) + commandHistoryRef.current = newHistory.filter((msg: string) => msg.startsWith('/')) + commandHistoryPos.current = commandHistoryRef.current.length } const acceptComplete = (item: string) => { @@ -170,6 +185,21 @@ export default ({ updateInputValue(sendHistoryRef.current[chatHistoryPos.current] || inputCurrentlyEnteredValue.current || '') } + const handleCommandArrowUp = () => { + if (commandHistoryPos.current === 0 || commandHistoryRef.current.length === 0) return + if (commandHistoryPos.current === commandHistoryRef.current.length) { // started navigating command history + inputCurrentlyEnteredValue.current = chatInput.current.value + } + commandHistoryPos.current-- + updateInputValue(commandHistoryRef.current[commandHistoryPos.current] || '') + } + + const handleCommandArrowDown = () => { + if (commandHistoryPos.current === commandHistoryRef.current.length) return + commandHistoryPos.current++ + updateInputValue(commandHistoryRef.current[commandHistoryPos.current] || inputCurrentlyEnteredValue.current || '') + } + const auxInputFocus = (direction: 'up' | 'down') => { chatInput.current.focus() if (direction === 'up') { @@ -193,6 +223,7 @@ export default ({ updateInputValue(chatInputValueGlobal.value) chatInputValueGlobal.value = '' chatHistoryPos.current = sendHistoryRef.current.length + commandHistoryPos.current = commandHistoryRef.current.length if (!usingTouch) { chatInput.current.focus() } @@ -229,12 +260,16 @@ export default ({ if (opened) { completeRequestValue.current = '' resetCompletionItems() + } else { + setPreservedInputValue('') } }, [opened]) const onMainInputChange = () => { const lastWord = chatInput.current.value.slice(0, chatInput.current.selectionEnd ?? chatInput.current.value.length).split(' ').at(-1)! - if (lastWord.startsWith('@') && getPingComplete) { + const isCommand = chatInput.current.value.startsWith('/') + + if (lastWord.startsWith('@') && getPingComplete && !isCommand) { setCompletePadText(lastWord) void fetchPingCompletions(true, lastWord.slice(1)) return @@ -314,10 +349,33 @@ export default ({ return completeValue } + const handleSlashCommand = () => { + remountInput('/') + } + + const handleAcceptFirstCompletion = () => { + if (completionItems.length > 0) { + acceptComplete(completionItems[0]) + } + } + + const remountInput = (newValue?: string) => { + if (newValue !== undefined) { + setPreservedInputValue(newValue) + } + setInputKey(k => k + 1) + } + + useEffect(() => { + if (preservedInputValue && chatInput.current) { + chatInput.current.focus() + } + }, [inputKey]) // Changed from spellCheckEnabled to inputKey + return ( <>
    @@ -372,14 +430,59 @@ export default ({
    )} {messages.map((m) => ( - + ))} || undefined} - )} +
    +
    + {items.map((element, i) => { // make sure its unique! return diff --git a/src/react/OverlayModelViewer.tsx b/src/react/OverlayModelViewer.tsx new file mode 100644 index 00000000..e48a2f0b --- /dev/null +++ b/src/react/OverlayModelViewer.tsx @@ -0,0 +1,554 @@ +import { proxy, useSnapshot, subscribe } from 'valtio' +import { useEffect, useMemo, useRef } from 'react' +import * as THREE from 'three' +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' +import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader' +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' +import { applySkinToPlayerObject, createPlayerObject, PlayerObjectType } from '../../renderer/viewer/lib/createPlayerObject' +import { currentScaling } from '../scaleInterface' +import { activeModalStack } from '../globalState' + +THREE.ColorManagement.enabled = false + +export const modelViewerState = proxy({ + model: undefined as undefined | { + models?: string[] // Array of model URLs (URL itself is the cache key) + steveModelSkin?: string + debug?: boolean + // absolute positioning + positioning: { + windowWidth: number + windowHeight: number + x: number + y: number + width: number + height: number + scaled?: boolean + onlyInitialScale?: boolean + followCursor?: boolean + } + modelCustomization?: { [modelUrl: string]: { color?: string, opacity?: number, metalness?: number, roughness?: number } } + resetRotationOnReleae?: boolean + continiousRender?: boolean + alwaysRender?: boolean + } +}) +globalThis.modelViewerState = modelViewerState + +// Global debug function to get camera and model values +globalThis.getModelViewerValues = () => { + const scene = globalThis.sceneRef?.current + if (!scene) return null + + const { camera, playerObject } = scene + if (!playerObject) return null + + const wrapper = playerObject.parent + if (!wrapper) return null + + const box = new THREE.Box3().setFromObject(wrapper) + const size = box.getSize(new THREE.Vector3()) + const center = box.getCenter(new THREE.Vector3()) + + return { + camera: { + position: camera.position.clone(), + fov: camera.fov, + aspect: camera.aspect + }, + model: { + position: wrapper.position.clone(), + rotation: wrapper.rotation.clone(), + scale: wrapper.scale.clone(), + size, + center + }, + cursor: { + position: globalThis.cursorPosition || { x: 0, y: 0 }, + normalized: globalThis.cursorPosition ? { + x: globalThis.cursorPosition.x * 2 - 1, + y: globalThis.cursorPosition.y * 2 - 1 + } : { x: 0, y: 0 } + }, + visibleArea: { + height: 2 * Math.tan(camera.fov * Math.PI / 180 / 2) * camera.position.z, + width: 2 * Math.tan(camera.fov * Math.PI / 180 / 2) * camera.position.z * camera.aspect + } + } +} + +subscribe(activeModalStack, () => { + if (!modelViewerState.model || !modelViewerState.model?.alwaysRender) { + return + } + if (activeModalStack.length === 0) { + modelViewerState.model = undefined + } +}) + +export default () => { + const { model } = useSnapshot(modelViewerState) + const containerRef = useRef(null) + const sceneRef = useRef<{ + scene: THREE.Scene + camera: THREE.PerspectiveCamera + renderer: THREE.WebGLRenderer + controls: OrbitControls + playerObject?: PlayerObjectType + dispose: () => void + }>() + const initialScale = useMemo(() => { + return currentScaling.scale + }, []) + globalThis.sceneRef = sceneRef + + // Cursor following state + const cursorPosition = useRef({ x: 0, y: 0 }) + const isFollowingCursor = useRef(false) + + // Model management state + const loadedModels = useRef>(new Map()) + const modelLoaders = useRef>(new Map()) + + // Model management functions + const loadModel = (modelUrl: string) => { + if (loadedModels.current.has(modelUrl)) return // Already loaded + + const isGLTF = modelUrl.toLowerCase().endsWith('.gltf') || modelUrl.toLowerCase().endsWith('.glb') + const loader = isGLTF ? new GLTFLoader() : new OBJLoader() + modelLoaders.current.set(modelUrl, loader) + + const onLoad = (object: THREE.Object3D) => { + // Apply customization if available and enable shadows + const customization = model?.modelCustomization?.[modelUrl] + object.traverse((child) => { + if (child instanceof THREE.Mesh) { + // Enable shadow casting and receiving for all meshes + child.castShadow = true + child.receiveShadow = true + + if (child.material && customization) { + const material = child.material as THREE.MeshStandardMaterial + if (customization.color) { + material.color.setHex(parseInt(customization.color.replace('#', ''), 16)) + } + if (customization.opacity !== undefined) { + material.opacity = customization.opacity + material.transparent = customization.opacity < 1 + } + if (customization.metalness !== undefined) { + material.metalness = customization.metalness + } + if (customization.roughness !== undefined) { + material.roughness = customization.roughness + } + } + } + }) + + // Center and scale model + const box = new THREE.Box3().setFromObject(object) + const center = box.getCenter(new THREE.Vector3()) + const size = box.getSize(new THREE.Vector3()) + const maxDim = Math.max(size.x, size.y, size.z) + const scale = 2 / maxDim + object.scale.setScalar(scale) + object.position.sub(center.multiplyScalar(scale)) + + // Store the model using URL as key + loadedModels.current.set(modelUrl, object) + sceneRef.current?.scene.add(object) + + // Trigger render + if (sceneRef.current) { + setTimeout(() => { + const render = () => sceneRef.current?.renderer.render(sceneRef.current.scene, sceneRef.current.camera) + render() + }, 0) + } + } + + if (isGLTF) { + (loader as GLTFLoader).load(modelUrl, (gltf) => { + onLoad(gltf.scene) + }) + } else { + (loader as OBJLoader).load(modelUrl, onLoad) + } + } + + const removeModel = (modelUrl: string) => { + const model = loadedModels.current.get(modelUrl) + if (model) { + sceneRef.current?.scene.remove(model) + model.traverse((child) => { + if (child instanceof THREE.Mesh) { + if (child.material) { + if (Array.isArray(child.material)) { + for (const mat of child.material) { + mat.dispose() + } + } else { + child.material.dispose() + } + } + if (child.geometry) { + child.geometry.dispose() + } + } + }) + loadedModels.current.delete(modelUrl) + } + modelLoaders.current.delete(modelUrl) + } + + // Subscribe to model changes + useEffect(() => { + if (!modelViewerState.model?.models) return + + const modelsChanged = () => { + const currentModels = modelViewerState.model?.models || [] + const currentModelUrls = new Set(currentModels) + const loadedModelUrls = new Set(loadedModels.current.keys()) + + // Remove models that are no longer in the state + for (const modelUrl of loadedModelUrls) { + if (!currentModelUrls.has(modelUrl)) { + removeModel(modelUrl) + } + } + + // Add new models + for (const modelUrl of currentModels) { + if (!loadedModelUrls.has(modelUrl)) { + loadModel(modelUrl) + } + } + } + const unsubscribe = subscribe(modelViewerState.model.models, modelsChanged) + + let unmounted = false + setTimeout(() => { + if (unmounted) return + modelsChanged() + }) + + return () => { + unmounted = true + unsubscribe?.() + } + }, [model?.models]) + + useEffect(() => { + if (!model || !containerRef.current) return + + // Setup scene + const scene = new THREE.Scene() + scene.background = null // Transparent background + + // Setup camera with optimal settings for player model viewing + const camera = new THREE.PerspectiveCamera( + 50, // Reduced FOV for better model viewing + model.positioning.width / model.positioning.height, + 0.1, + 1000 + ) + camera.position.set(0, 0, 3) // Position camera to view player model optimally + + // Setup renderer with pixel density awareness + const renderer = new THREE.WebGLRenderer({ alpha: true }) + let scale = window.devicePixelRatio || 1 + if (modelViewerState.model?.positioning.scaled) { + scale *= currentScaling.scale + } + renderer.setPixelRatio(scale) + renderer.setSize(model.positioning.width, model.positioning.height) + + // Enable shadow rendering for depth and realism + renderer.shadowMap.enabled = true + renderer.shadowMap.type = THREE.PCFSoftShadowMap // Soft shadows for better quality + renderer.shadowMap.autoUpdate = true + + containerRef.current.appendChild(renderer.domElement) + + // Setup controls + const controls = new OrbitControls(camera, renderer.domElement) + // controls.enableZoom = false + // controls.enablePan = false + controls.minPolarAngle = Math.PI / 2 // Lock vertical rotation + controls.maxPolarAngle = Math.PI / 2 + controls.enableDamping = true + controls.dampingFactor = 0.05 + + // Add ambient light for overall illumination + const ambientLight = new THREE.AmbientLight(0xff_ff_ff, 0.4) // Reduced intensity to allow shadows + scene.add(ambientLight) + + // Add directional light for shadows and depth (similar to Minecraft inventory lighting) + const directionalLight = new THREE.DirectionalLight(0xff_ff_ff, 0.6) + directionalLight.position.set(2, 2, 2) // Position light from top-right-front + directionalLight.target.position.set(0, 0, 0) // Point towards center of scene + + // Configure shadow properties for optimal quality + directionalLight.castShadow = true + directionalLight.shadow.mapSize.width = 2048 // High resolution shadow map + directionalLight.shadow.mapSize.height = 2048 + directionalLight.shadow.camera.near = 0.1 + directionalLight.shadow.camera.far = 10 + directionalLight.shadow.camera.left = -3 + directionalLight.shadow.camera.right = 3 + directionalLight.shadow.camera.top = 3 + directionalLight.shadow.camera.bottom = -3 + directionalLight.shadow.bias = -0.0001 // Reduce shadow acne + + scene.add(directionalLight) + scene.add(directionalLight.target) + + // Cursor following function + const updatePlayerLookAt = () => { + if (!isFollowingCursor.current || !sceneRef.current?.playerObject) return + + const { playerObject } = sceneRef.current + const { x, y } = cursorPosition.current + + // Convert 0-1 cursor position to normalized coordinates (-1 to 1) + const normalizedX = x * 2 - 1 + const normalizedY = y * 2 - 1 // Inverted: top of screen = negative pitch, bottom = positive pitch + + // Calculate head rotation based on cursor position + // Limit head movement to realistic angles + const maxHeadYaw = Math.PI / 3 // 60 degrees + const maxHeadPitch = Math.PI / 4 // 45 degrees + + const headYaw = normalizedX * maxHeadYaw + const headPitch = normalizedY * maxHeadPitch + + // Apply head rotation with smooth interpolation + const lerpFactor = 0.1 // Smooth interpolation factor + playerObject.skin.head.rotation.y = THREE.MathUtils.lerp( + playerObject.skin.head.rotation.y, + headYaw, + lerpFactor + ) + playerObject.skin.head.rotation.x = THREE.MathUtils.lerp( + playerObject.skin.head.rotation.x, + headPitch, + lerpFactor + ) + + // Apply slight body rotation for more natural movement + const bodyYaw = headYaw * 0.3 // Body follows head but with less rotation + playerObject.rotation.y = THREE.MathUtils.lerp( + playerObject.rotation.y, + bodyYaw, + lerpFactor * 0.5 // Slower body movement + ) + + render() + } + + // Render function + const render = () => { + renderer.render(scene, camera) + } + + // Setup animation/render strategy + if (model.continiousRender) { + // Continuous animation loop + const animate = () => { + requestAnimationFrame(animate) + render() + } + animate() + } else { + // Render only on camera movement + controls.addEventListener('change', render) + // Initial render + render() + // Render after model loads + if (model.steveModelSkin !== undefined) { + // Create player model + const { playerObject, wrapper } = createPlayerObject({ + scale: 1 // Start with base scale, will adjust below + }) + + // Enable shadows for player object + wrapper.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.castShadow = true + child.receiveShadow = true + } + }) + + // Calculate proper scale and positioning for camera view + const box = new THREE.Box3().setFromObject(wrapper) + const size = box.getSize(new THREE.Vector3()) + const center = box.getCenter(new THREE.Vector3()) + + // Calculate scale to fit within camera view (considering FOV and distance) + const cameraDistance = camera.position.z + const fov = camera.fov * Math.PI / 180 // Convert to radians + const visibleHeight = 2 * Math.tan(fov / 2) * cameraDistance + const visibleWidth = visibleHeight * (model.positioning.width / model.positioning.height) + + const scaleFactor = Math.min( + (visibleHeight) / size.y, + (visibleWidth) / size.x + ) + + wrapper.scale.multiplyScalar(scaleFactor) + + // Center the player object + wrapper.position.sub(center.multiplyScalar(scaleFactor)) + + // Rotate to face camera (remove the default 180° rotation) + wrapper.rotation.set(0, 0, 0) + + scene.add(wrapper) + sceneRef.current = { + ...sceneRef.current!, + playerObject + } + + void applySkinToPlayerObject(playerObject, model.steveModelSkin).then(() => { + setTimeout(render, 0) + }) + + // Set up cursor following if enabled + if (model.positioning.followCursor) { + isFollowingCursor.current = true + } + } + } + + // Window cursor tracking for followCursor + let lastCursorUpdate = 0 + let waitingRender = false + const handleWindowPointerMove = (event: PointerEvent) => { + if (!model.positioning.followCursor) return + + // Track cursor position as 0-1 across the entire window + const newPosition = { + x: event.clientX / window.innerWidth, + y: event.clientY / window.innerHeight + } + cursorPosition.current = newPosition + globalThis.cursorPosition = newPosition // Expose for debug + lastCursorUpdate = Date.now() + updatePlayerLookAt() + if (!waitingRender) { + requestAnimationFrame(() => { + render() + waitingRender = false + }) + waitingRender = true + } + } + + // Add window event listeners + if (model.positioning.followCursor) { + window.addEventListener('pointermove', handleWindowPointerMove) + isFollowingCursor.current = true + } + + // Store refs for cleanup + sceneRef.current = { + ...sceneRef.current!, + scene, + camera, + renderer, + controls, + dispose () { + if (!model.continiousRender) { + controls.removeEventListener('change', render) + } + if (model.positioning.followCursor) { + window.removeEventListener('pointermove', handleWindowPointerMove) + } + + // Clean up loaded models + for (const [modelUrl, model] of loadedModels.current) { + scene.remove(model) + model.traverse((child) => { + if (child instanceof THREE.Mesh) { + if (child.material) { + if (Array.isArray(child.material)) { + for (const mat of child.material) { + mat.dispose() + } + } else { + child.material.dispose() + } + } + if (child.geometry) { + child.geometry.dispose() + } + } + }) + } + loadedModels.current.clear() + modelLoaders.current.clear() + + const playerObject = sceneRef.current?.playerObject + if (playerObject?.skin.map) { + (playerObject.skin.map as unknown as THREE.Texture).dispose() + } + renderer.dispose() + renderer.domElement?.remove() + } + } + + return () => { + sceneRef.current?.dispose() + } + }, [model]) + + if (!model) return null + + const { x, y, width, height, scaled, onlyInitialScale } = model.positioning + const { windowWidth } = model.positioning + const { windowHeight } = model.positioning + const scaleValue = onlyInitialScale ? initialScale : 'var(--guiScale)' + + return ( +
    +
    +
    +
    +
    + ) +} diff --git a/src/react/PauseScreen.tsx b/src/react/PauseScreen.tsx index 22e00ded..856ac932 100644 --- a/src/react/PauseScreen.tsx +++ b/src/react/PauseScreen.tsx @@ -32,7 +32,7 @@ import Screen from './Screen' import styles from './PauseScreen.module.css' import { DiscordButton } from './DiscordButton' import { showNotification } from './NotificationProvider' -import { appStatusState, reconnectReload } from './AppStatusProvider' +import { appStatusState, lastConnectOptions, reconnectReload } from './AppStatusProvider' import NetworkStatus from './NetworkStatus' import PauseLinkButtons from './PauseLinkButtons' import { pixelartIcons } from './PixelartIcon' @@ -163,6 +163,7 @@ export default () => { const { noConnection } = useSnapshot(gameAdditionalState) const { active: packetsReplaceActive, hasRecordedPackets: packetsReplaceHasRecordedPackets } = useSnapshot(packetsRecordingState) const { displayRecordButton: displayPacketsButtons } = useSnapshot(options) + const { appConfig } = useSnapshot(miscUiState) const handlePointerLockChange = () => { if (!pointerLock.hasPointerLock && activeModalStack.length === 0) { @@ -264,7 +265,7 @@ export default () => {
    - + {singleplayer ? (
    ) : null} + {(noConnection || appConfig?.alwaysReconnectButton) && ( +
    + + {appConfig?.reportBugButtonWithReconnect && ( +
    + )} {!lockConnect && <> } - {noConnection && ( - - )}
    diff --git a/src/react/PlayerListOverlayProvider.tsx b/src/react/PlayerListOverlayProvider.tsx index 3ff69c41..4d8a8ed7 100644 --- a/src/react/PlayerListOverlayProvider.tsx +++ b/src/react/PlayerListOverlayProvider.tsx @@ -5,6 +5,7 @@ import PlayerListOverlay from './PlayerListOverlay' import './PlayerListOverlay.css' import { lastConnectOptions } from './AppStatusProvider' +const MAX_COLUMNS = 4 const MAX_ROWS_PER_COL = 10 type Players = typeof bot.players @@ -56,21 +57,24 @@ export default () => { } }, [serverIp]) - const playersArray = Object.values(players).sort((a, b) => { if (a.username > b.username) return 1 if (a.username < b.username) return -1 return 0 }) + + // Calculate optimal column distribution + const totalPlayers = playersArray.length + const numColumns = Math.min(MAX_COLUMNS, Math.ceil(totalPlayers / MAX_ROWS_PER_COL)) + const playersPerColumn = Math.ceil(totalPlayers / numColumns) + const lists = [] as Array - let tempList = [] as typeof playersArray - for (let i = 0; i < playersArray.length; i++) { - tempList.push(playersArray[i]) - - if ((i + 1) % MAX_ROWS_PER_COL === 0 || i + 1 === playersArray.length) { - lists.push([...tempList]) - tempList = [] + for (let i = 0; i < numColumns; i++) { + const startIdx = i * playersPerColumn + const endIdx = Math.min(startIdx + playersPerColumn, totalPlayers) + if (startIdx < totalPlayers) { + lists.push(playersArray.slice(startIdx, endIdx)) } } diff --git a/src/react/Scoreboard.css b/src/react/Scoreboard.css index b2bb8521..38ec426f 100644 --- a/src/react/Scoreboard.css +++ b/src/react/Scoreboard.css @@ -1,6 +1,6 @@ .scoreboard-container { z-index: -2; - pointer-events: none; + /* pointer-events: none; */ white-space: nowrap; position: fixed; right: 0px; diff --git a/src/react/ServersListProvider.tsx b/src/react/ServersListProvider.tsx index d95d4e9e..42ef2aaa 100644 --- a/src/react/ServersListProvider.tsx +++ b/src/react/ServersListProvider.tsx @@ -1,9 +1,11 @@ import { useEffect, useMemo, useState } from 'react' import { useUtilsEffect } from '@zardoy/react-util' import { useSnapshot } from 'valtio' +import { supportedVersions } from 'minecraft-protocol' +import { versionToNumber } from 'mc-assets/dist/utils' import { ConnectOptions } from '../connect' import { activeModalStack, hideCurrentModal, miscUiState, notHideableModalsWithoutForce, showModal } from '../globalState' -import supportedVersions from '../supportedVersions.mjs' +import appSupportedVersions from '../supportedVersions.mjs' import { appQueryParams } from '../appParams' import { fetchServerStatus, isServerValid } from '../api/mcStatusApi' import { getServerInfo } from '../mineflayer/mc-protocol' @@ -20,6 +22,10 @@ import Button from './Button' import { pixelartIcons } from './PixelartIcon' import { showNotification } from './NotificationProvider' +const firstProtocolVersion = versionToNumber(supportedVersions[0]) +const lastProtocolVersion = versionToNumber(supportedVersions.at(-1)!) +const protocolSupportedVersions = appSupportedVersions.filter(v => versionToNumber(v) >= firstProtocolVersion && versionToNumber(v) <= lastProtocolVersion) + const EXPLICIT_SHARE_SERVER_MODE = false if (appQueryParams.lockConnect) { @@ -113,6 +119,7 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL ...serversListProvided, ...(customServersList ? [] : (miscUiState.appConfig?.promoteServers ?? [])).map((server): StoreServerItem => ({ ip: server.ip, + name: server.name, versionOverride: server.version, description: server.description, isRecommended: true @@ -156,13 +163,23 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL const isWebSocket = server.ip.startsWith('ws://') || server.ip.startsWith('wss://') let data if (isWebSocket) { - const pingResult = await getServerInfo(server.ip, undefined, undefined, true) - console.log('pingResult.fullInfo.description', pingResult.fullInfo.description) - data = { - formattedText: pingResult.fullInfo.description, - textNameRight: `ws ${pingResult.latency}ms`, - textNameRightGrayed: `${pingResult.fullInfo.players?.online ?? '??'}/${pingResult.fullInfo.players?.max ?? '??'}`, - offline: false + try { + const pingResult = await getServerInfo(server.ip, undefined, undefined, true) + console.log('pingResult.fullInfo.description', pingResult.fullInfo.description) + data = { + formattedText: pingResult.fullInfo.description, + icon: pingResult.fullInfo.favicon, + textNameRight: `ws ${pingResult.latency}ms`, + textNameRightGrayed: `${pingResult.fullInfo.players?.online ?? '??'}/${pingResult.fullInfo.players?.max ?? '??'}`, + offline: false + } + } catch (err) { + data = { + formattedText: 'Failed to connect', + textNameRight: '', + textNameRightGrayed: '', + offline: true + } } } else { data = await fetchServerStatus(server.ip, /* signal */undefined, server.versionOverride) // DONT ADD SIGNAL IT WILL CRUSH JS RUNTIME @@ -217,7 +234,6 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL }) const editModalJsx = isEditScreenModal ? : null const serversListJsx = { min?: number; max?: number; disabledReason?: string; + throttle?: number | false; // milliseconds, default 100, false to disable updateValue?: (value: number) => void; updateOnDragEnd?: boolean; @@ -26,15 +27,24 @@ const Slider: React.FC = ({ min = 0, max = 100, disabledReason, + throttle = 0, updateOnDragEnd = false, updateValue, ...divProps }) => { + label = translate(label) + disabledReason = translate(disabledReason) + valueDisplay = typeof valueDisplay === 'string' ? translate(valueDisplay) : valueDisplay + const [value, setValue] = useState(valueProp) const getRatio = (v = value) => Math.max(Math.min((v - min) / (max - min), 1), 0) const [ratio, setRatio] = useState(getRatio()) + // Throttling refs + const timeoutRef = useRef(null) + const lastValueRef = useRef(valueProp) + useEffect(() => { setValue(valueProp) }, [valueProp]) @@ -42,14 +52,52 @@ const Slider: React.FC = ({ setRatio(getRatio()) }, [value, min, max]) - const fireValueUpdate = (dragEnd: boolean, v = value) => { + const throttledUpdateValue = useCallback((newValue: number, dragEnd: boolean) => { if (updateOnDragEnd !== dragEnd) return - updateValue?.(v) + if (!updateValue) return + + lastValueRef.current = newValue + + if (!throttle) { + // No throttling + updateValue(newValue) + return + } + + // Clear existing timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + + // Set new timeout + timeoutRef.current = setTimeout(() => { + updateValue(lastValueRef.current) + timeoutRef.current = null + }, throttle) + }, [updateValue, updateOnDragEnd, throttle]) + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + // Fire the last value immediately on cleanup + if (updateValue && lastValueRef.current !== undefined) { + updateValue(lastValueRef.current) + } + } + } + }, [updateValue]) + + const fireValueUpdate = (dragEnd: boolean, v = value) => { + throttledUpdateValue(v, dragEnd) } + const labelText = `${label}: ${valueDisplay ?? value} ${unit}` + return ( -
    +
    17 ? 'settings-text-container-long' : ''}`} style={{ width }} {...divProps}> = ({
    diff --git a/src/react/StorageConflictModal.tsx b/src/react/StorageConflictModal.tsx new file mode 100644 index 00000000..e1d3299d --- /dev/null +++ b/src/react/StorageConflictModal.tsx @@ -0,0 +1,103 @@ +import { useSnapshot } from 'valtio' +import { activeModalStack, hideCurrentModal } from '../globalState' +import { resolveStorageConflicts, getStorageConflicts } from './appStorageProvider' +import { useIsModalActive } from './utilsApp' +import Screen from './Screen' +import Button from './Button' + +const formatTimestamp = (timestamp?: number) => { + if (!timestamp) return 'Unknown time' + return new Date(timestamp).toLocaleString() +} + +export default () => { + const isModalActive = useIsModalActive('storage-conflict') + const conflicts = getStorageConflicts() + + if (!isModalActive/* || conflicts.length === 0 */) return null + + const clampText = (text: string) => { + if (typeof text !== 'string') text = JSON.stringify(text) + return text.length > 30 ? text.slice(0, 30) + '...' : text + } + + const conflictText = conflicts.map(conflict => { + const localTime = formatTimestamp(conflict.localStorageTimestamp) + const cookieTime = formatTimestamp(conflict.cookieTimestamp) + return `${conflict.key}: LocalStorage (${localTime}, ${clampText(conflict.localStorageValue)}) vs Cookie (${cookieTime}, ${clampText(conflict.cookieValue)})` + }).join('\n') + + return ( +
    +
    +
    + Data Conflict Found +
    + +
    + You have conflicting data between localStorage (old) and cookies (new, domain-synced) for the following settings: + {'\n\n'} + {conflictText} + {'\n\n'} + Please choose which version to keep: +
    + +
    +
    { + resolveStorageConflicts(true) // Use localStorage + hideCurrentModal() + }} + style={{ + border: '1px solid #654321', + padding: '8px 16px', + cursor: 'pointer' + }} + > + Use Local Storage & Disable Cookie Sync +
    + +
    { + resolveStorageConflicts(false) // Use cookies + hideCurrentModal() + }} + style={{ + border: '1px solid #654321', + padding: '8px 16px', + cursor: 'pointer' + }} + > + Use Cookie Data & Remove Local Data +
    +
    +
    +
    + ) +} diff --git a/src/react/TouchAreasControls.tsx b/src/react/TouchAreasControls.tsx index ec9d201b..469097bd 100644 --- a/src/react/TouchAreasControls.tsx +++ b/src/react/TouchAreasControls.tsx @@ -1,5 +1,6 @@ import { CSSProperties, PointerEvent, useEffect, useRef, useState } from 'react' import { proxy, ref, useSnapshot } from 'valtio' +import activatableItemsMobile from 'mineflayer-mouse/dist/activatableItemsMobile' import { contro } from '../controls' import { options } from '../optionsStorage' import PixelartIcon from './PixelartIcon' @@ -72,10 +73,12 @@ export default ({ setupActive, closeButtonsSetup, foregroundGameActive }: Props) break: false, jump: bot?.getControlState('jump'), }[name] + const RIGHT_MOUSE_BUTTON = 2 + const LEFT_MOUSE_BUTTON = 0 const holdDown = { action () { if (!bot) return - document.dispatchEvent(new MouseEvent('mousedown', { button: 2 })) + document.dispatchEvent(new MouseEvent('mousedown', { button: RIGHT_MOUSE_BUTTON })) bot.mouse.update() }, sneak () { @@ -87,7 +90,7 @@ export default ({ setupActive, closeButtonsSetup, foregroundGameActive }: Props) }, break () { if (!bot) return - document.dispatchEvent(new MouseEvent('mousedown', { button: 0 })) + document.dispatchEvent(new MouseEvent('mousedown', { button: LEFT_MOUSE_BUTTON })) bot.mouse.update() active = true }, @@ -101,7 +104,7 @@ export default ({ setupActive, closeButtonsSetup, foregroundGameActive }: Props) } const holdUp = { action () { - document.dispatchEvent(new MouseEvent('mouseup', { button: 2 })) + document.dispatchEvent(new MouseEvent('mouseup', { button: RIGHT_MOUSE_BUTTON })) }, sneak () { void contro.emit('release', { @@ -112,7 +115,7 @@ export default ({ setupActive, closeButtonsSetup, foregroundGameActive }: Props) }, break () { if (!bot) return - document.dispatchEvent(new MouseEvent('mouseup', { button: 0 })) + document.dispatchEvent(new MouseEvent('mouseup', { button: LEFT_MOUSE_BUTTON })) bot.mouse.update() active = false }, diff --git a/src/react/appStorageProvider.ts b/src/react/appStorageProvider.ts index 499ec71c..fd469186 100644 --- a/src/react/appStorageProvider.ts +++ b/src/react/appStorageProvider.ts @@ -8,7 +8,9 @@ import type { BaseServerInfo } from './AddServerOrConnect' // when opening html file locally in browser, localStorage is shared between all ever opened html files, so we try to avoid conflicts const localStoragePrefix = process.env?.SINGLE_FILE_BUILD ? 'minecraft-web-client:' : '' +const cookiePrefix = process.env.COOKIE_STORAGE_PREFIX || '' const { localStorage } = window +const migrateRemoveLocalStorage = false export interface SavedProxiesData { proxies: string[] @@ -31,12 +33,19 @@ export interface StoreServerItem extends BaseServerInfo { isRecommended?: boolean } +interface StorageConflict { + key: string + localStorageValue: any + localStorageTimestamp?: number + cookieValue: any + cookieTimestamp?: number +} + type StorageData = { + cookieStorage: boolean | { ignoreKeys: Array } customCommands: Record | undefined username: string | undefined keybindings: UserOverridesConfig | undefined - /** @deprecated */ - options: any changedSettings: any proxiesData: SavedProxiesData | undefined serversHistory: ServerHistoryEntry[] @@ -46,10 +55,130 @@ type StorageData = { firstModsPageVisit: boolean } +const cookieStoreKeys: Array = [ + 'customCommands', + 'username', + 'keybindings', + 'changedSettings', + 'serversList', +] + const oldKeysAliases: Partial> = { serversHistory: 'serverConnectionHistory', } +// Cookie storage functions +const getCookieValue = (key: string): string | null => { + const cookie = document.cookie.split(';').find(c => c.trimStart().startsWith(`${cookiePrefix}${key}=`)) + if (cookie) { + return decodeURIComponent(cookie.split('=')[1]) + } + return null +} + +const topLevelDomain = window.location.hostname.split('.').slice(-2).join('.') +const cookieBase = `; Domain=.${topLevelDomain}; Path=/; SameSite=Strict; Secure` + +const setCookieValue = (key: string, value: string): boolean => { + try { + const cookieKey = `${cookiePrefix}${key}` + let cookie = `${cookieKey}=${encodeURIComponent(value)}` + cookie += `${cookieBase}; Max-Age=2147483647` + + // Test if cookie exceeds size limit + if (cookie.length > 4096) { + throw new Error(`Cookie size limit exceeded for key '${key}'. Cookie size: ${cookie.length} bytes, limit: 4096 bytes.`) + } + + document.cookie = cookie + + // Verify the cookie was actually saved by reading it back + const savedValue = getCookieValue(key) + if (savedValue !== value) { + console.warn(`Cookie verification failed for key '${key}'. Expected: ${value}, Got: ${savedValue}`) + return false + } + + return true + } catch (error) { + console.error(`Failed to set cookie for key '${key}':`, error) + window.showNotification(`Failed to save data to cookies: ${error.message}`, 'Consider switching to localStorage in advanced settings.', true) + return false + } +} + +const deleteCookie = (key: string) => { + const cookieKey = `${cookiePrefix}${key}` + document.cookie = `${cookieKey}=; ${cookieBase}; expires=Thu, 01 Jan 1970 00:00:00 UTC;` +} + +// Storage conflict detection and resolution +let storageConflicts: StorageConflict[] = [] + +const detectStorageConflicts = (): StorageConflict[] => { + const conflicts: StorageConflict[] = [] + + for (const key of cookieStoreKeys) { + const localStorageKey = `${localStoragePrefix}${key}` + const localStorageValue = localStorage.getItem(localStorageKey) + const cookieValue = getCookieValue(key) + + if (localStorageValue && cookieValue) { + try { + const localParsed = JSON.parse(localStorageValue) + const cookieParsed = JSON.parse(cookieValue) + + if (localStorage.getItem(`${localStorageKey}:migrated`)) { + continue + } + + // Extract timestamps if they exist + const localTimestamp = localParsed?.timestamp + const cookieTimestamp = cookieParsed?.timestamp + + // Compare the actual data (excluding timestamp) + const localData = localTimestamp ? { ...localParsed } : localParsed + const cookieData = cookieTimestamp ? { ...cookieParsed } : cookieParsed + delete localData.timestamp + delete cookieData.timestamp + + const isDataEmpty = (data: any) => { + if (typeof data === 'object' && data !== null) { + return Object.keys(data).length === 0 + } + return !data && data !== 0 && data !== false + } + + if (JSON.stringify(localData) !== JSON.stringify(cookieData) && !isDataEmpty(localData) && !isDataEmpty(cookieData)) { + conflicts.push({ + key, + localStorageValue: localData, + localStorageTimestamp: localTimestamp, + cookieValue: (typeof cookieData === 'object' && cookieData !== null && 'data' in cookieData) ? cookieData.data : cookieData, + cookieTimestamp + }) + } + } catch (e) { + console.error(`Failed to parse storage values for conflict detection on key '${key}':`, e, localStorageValue, cookieValue) + } + } + } + + return conflicts +} + +const showStorageConflictModal = () => { + // Import showModal dynamically to avoid circular dependency + const showModal = (window as any).showModal || ((modal: any) => { + console.error('Modal system not available:', modal) + console.warn('Storage conflicts detected but modal system not available:', storageConflicts) + }) + + setTimeout(() => { + showModal({ reactType: 'storage-conflict', conflicts: storageConflicts }) + }, 100) +} + const migrateLegacyData = () => { const proxies = localStorage.getItem('proxies') const selectedProxy = localStorage.getItem('selectedProxy') @@ -75,10 +204,10 @@ const migrateLegacyData = () => { } const defaultStorageData: StorageData = { + cookieStorage: !!process.env.ENABLE_COOKIE_STORAGE && !process.env?.SINGLE_FILE_BUILD, customCommands: undefined, username: undefined, keybindings: undefined, - options: {}, changedSettings: {}, proxiesData: undefined, serversHistory: [], @@ -108,29 +237,144 @@ export const getRandomUsername = (appConfig: AppConfig) => { export const appStorage = proxy({ ...defaultStorageData }) -// Restore data from localStorage -for (const key of Object.keys(defaultStorageData)) { - const prefixedKey = `${localStoragePrefix}${key}` - const aliasedKey = oldKeysAliases[key] - const storedValue = localStorage.getItem(prefixedKey) ?? (aliasedKey ? localStorage.getItem(aliasedKey) : undefined) - if (storedValue) { - try { - const parsed = JSON.parse(storedValue) - // appStorage[key] = parsed && typeof parsed === 'object' ? ref(parsed) : parsed - appStorage[key] = parsed - } catch (e) { - console.error(`Failed to parse stored value for ${key}:`, e) +// Track if cookies failed in this session +let cookiesFailedThisSession = false + +// Check if cookie storage should be used (will be set by options) +const shouldUseCookieStorage = () => { + // If cookies failed this session, don't try again + if (cookiesFailedThisSession) { + return false + } + + const isSecureCookiesAvailable = () => { + // either https or localhost + return window.location.protocol === 'https:' || (window.location.hostname === 'localhost') + } + if (!isSecureCookiesAvailable()) { + return false + } + + const localStorageValue = localStorage.getItem(`${localStoragePrefix}cookieStorage`) + if (localStorageValue === null) { + return appStorage.cookieStorage === true + } + return localStorageValue === 'true' +} + +// Restore data from storage with conflict detection +const restoreStorageData = () => { + const useCookieStorage = shouldUseCookieStorage() + + if (useCookieStorage) { + // Detect conflicts first + storageConflicts = detectStorageConflicts() + + if (storageConflicts.length > 0) { + // Show conflict resolution modal + showStorageConflictModal() + return // Don't restore data until conflict is resolved + } + } + + for (const key of Object.keys(defaultStorageData)) { + const typedKey = key + const prefixedKey = `${localStoragePrefix}${key}` + const aliasedKey = oldKeysAliases[typedKey] + + let storedValue: string | null = null + let cookieValueCanBeUsed = false + let usingLocalStorageValue = false + + // Try cookie storage first if enabled and key is in cookieStoreKeys + if (useCookieStorage && cookieStoreKeys.includes(typedKey)) { + storedValue = getCookieValue(key) + cookieValueCanBeUsed = true + } + + // Fallback to localStorage if no cookie value found + if (storedValue === null) { + storedValue = localStorage.getItem(prefixedKey) ?? (aliasedKey ? localStorage.getItem(aliasedKey) : null) + usingLocalStorageValue = true + } + + if (storedValue) { + try { + let parsed = JSON.parse(storedValue) + + // Handle timestamped data + if (parsed && typeof parsed === 'object' && parsed.timestamp) { + delete parsed.timestamp + // If it was a wrapped primitive, unwrap it + if ('data' in parsed && Object.keys(parsed).length === 1) { + parsed = parsed.data + } + } + + appStorage[typedKey] = parsed + + if (usingLocalStorageValue && cookieValueCanBeUsed) { + // migrate localStorage to cookie + saveKey(key) + markLocalStorageAsMigrated(key) + } + } catch (e) { + console.error(`Failed to parse stored value for ${key}:`, e) + } } } } +const markLocalStorageAsMigrated = (key: keyof StorageData) => { + const localStorageKey = `${localStoragePrefix}${key}` + if (migrateRemoveLocalStorage) { + localStorage.removeItem(localStorageKey) + return + } + + localStorage.setItem(`${localStorageKey}:migrated`, 'true') +} + const saveKey = (key: keyof StorageData) => { + const useCookieStorage = shouldUseCookieStorage() const prefixedKey = `${localStoragePrefix}${key}` const value = appStorage[key] - if (value === undefined) { - localStorage.removeItem(prefixedKey) - } else { - localStorage.setItem(prefixedKey, JSON.stringify(value)) + + const dataToSave = value === undefined ? undefined : ( + value && typeof value === 'object' && !Array.isArray(value) + ? { ...value, timestamp: Date.now() } + : { data: value, timestamp: Date.now() } + ) + + const serialized = dataToSave === undefined ? undefined : JSON.stringify(dataToSave) + + let useLocalStorage = true + // Save to cookie if enabled and key is in cookieStoreKeys + if (useCookieStorage && cookieStoreKeys.includes(key)) { + useLocalStorage = false + if (serialized === undefined) { + deleteCookie(key) + } else { + const success = setCookieValue(key, serialized) + if (success) { + // Remove from localStorage if cookie save was successful + markLocalStorageAsMigrated(key) + } else { + // Cookie save failed, disable cookies for this session and fallback to localStorage + console.warn(`Cookie save failed for key '${key}', disabling cookies for this session`) + cookiesFailedThisSession = true + useLocalStorage = true + } + } + } + + if (useLocalStorage) { + // Save to localStorage + if (value === undefined) { + localStorage.removeItem(prefixedKey) + } else { + localStorage.setItem(prefixedKey, JSON.stringify(value)) + } } } @@ -141,7 +385,6 @@ subscribe(appStorage, (ops) => { saveKey(key as keyof StorageData) } }) -// Subscribe to changes and save to localStorage export const resetAppStorage = () => { for (const key of Object.keys(appStorage)) { @@ -153,6 +396,44 @@ export const resetAppStorage = () => { localStorage.removeItem(key) } } + + if (!shouldUseCookieStorage()) return + const shouldContinue = window.confirm(`Removing all synced cookies will remove all data from all ${topLevelDomain} subdomains websites. Continue?`) + if (!shouldContinue) return + + // Clear cookies + for (const key of cookieStoreKeys) { + deleteCookie(key) + } } +// Export functions for conflict resolution +export const resolveStorageConflicts = (useLocalStorage: boolean) => { + if (useLocalStorage) { + // Disable cookie storage and use localStorage data + appStorage.cookieStorage = false + } else { + // Remove localStorage data and continue using cookie storage + for (const conflict of storageConflicts) { + const prefixedKey = `${localStoragePrefix}${conflict.key}` + localStorage.removeItem(prefixedKey) + } + } + + // forcefully set data again + for (const conflict of storageConflicts) { + appStorage[conflict.key] = useLocalStorage ? conflict.localStorageValue : conflict.cookieValue + saveKey(conflict.key as keyof StorageData) + } + + // Clear conflicts and restore data + storageConflicts = [] + restoreStorageData() +} + +export const getStorageConflicts = () => storageConflicts + migrateLegacyData() + +// Restore data after checking for conflicts +restoreStorageData() diff --git a/src/reactUi.tsx b/src/reactUi.tsx index 138696b0..6339686e 100644 --- a/src/reactUi.tsx +++ b/src/reactUi.tsx @@ -65,6 +65,10 @@ import RendererDebugMenu from './react/RendererDebugMenu' import CreditsAboutModal from './react/CreditsAboutModal' import GlobalOverlayHints from './react/GlobalOverlayHints' import FullscreenTime from './react/FullscreenTime' +import StorageConflictModal from './react/StorageConflictModal' +import FireRenderer from './react/FireRenderer' +import MonacoEditor from './react/MonacoEditor' +import OverlayModelViewer from './react/OverlayModelViewer' const isFirefox = ua.getBrowser().name === 'Firefox' if (isFirefox) { @@ -170,6 +174,7 @@ const InGameUi = () => { + {!disabledUiParts.includes('fire') && }
    @@ -228,6 +233,7 @@ const App = () => {
    + @@ -244,7 +250,6 @@ const App = () => { - @@ -255,6 +260,8 @@ const App = () => {
    + + @@ -276,13 +283,17 @@ const PerComponentErrorBoundary = ({ children }) => { ) } -renderToDom(, { - strictMode: false, - selector: '#react-root', -}) +if (!new URLSearchParams(window.location.search).get('no-ui')) { + renderToDom(, { + strictMode: false, + selector: '#react-root', + }) +} disableReactProfiling() function disableReactProfiling () { + if (window.reactPerfPatchApplied) return + window.reactPerfPatchApplied = true //@ts-expect-error window.performance.markOrig = window.performance.mark //@ts-expect-error diff --git a/src/resourcePack.ts b/src/resourcePack.ts index e0f0fca4..ea6c73fd 100644 --- a/src/resourcePack.ts +++ b/src/resourcePack.ts @@ -486,17 +486,6 @@ const downloadAndUseResourcePack = async (url: string, progressReporter: Progres } } -const waitForGameEvent = async () => { - if (miscUiState.gameLoaded) return - await new Promise(resolve => { - const listener = () => resolve() - customEvents.once('gameLoaded', listener) - watchUnloadForCleanup(() => { - customEvents.removeListener('gameLoaded', listener) - }) - }) -} - export const onAppLoad = () => { customEvents.on('mineflayerBotCreated', () => { // todo also handle resourcePack diff --git a/src/screens.css b/src/screens.css index f0040e2d..e503c305 100644 --- a/src/screens.css +++ b/src/screens.css @@ -26,6 +26,10 @@ display: flex; justify-content: center; z-index: 12; + /* Account for GUI scaling */ + width: calc(100dvw / var(--guiScale, 1)); + height: calc(100dvh / var(--guiScale, 1)); + overflow: hidden; } .screen-content { diff --git a/src/shims/minecraftData.ts b/src/shims/minecraftData.ts index 989aa698..33dff5fe 100644 --- a/src/shims/minecraftData.ts +++ b/src/shims/minecraftData.ts @@ -19,11 +19,20 @@ const customResolver = () => { } } +let dataStatus = 'not-called' + const optimizedDataResolver = customResolver() window._MC_DATA_RESOLVER = optimizedDataResolver window._LOAD_MC_DATA = async () => { if (optimizedDataResolver.resolvedData) return - optimizedDataResolver.resolve(await importLargeData('mcData')) + dataStatus = 'loading' + try { + optimizedDataResolver.resolve(await importLargeData('mcData')) + dataStatus = 'ready' + } catch (e) { + dataStatus = 'error' + throw e + } } // 30 seconds @@ -39,7 +48,7 @@ const possiblyGetFromCache = (version: string) => { } const inner = () => { if (!optimizedDataResolver.resolvedData) { - throw new Error(`Data for ${version} is not ready yet`) + throw new Error(`Minecraft data are not ready yet. Ensure you await window._LOAD_MC_DATA() before using it. Status: ${dataStatus}`) } const dataTypes = Object.keys(optimizedDataResolver.resolvedData) const allRestored = {} diff --git a/src/sounds/botSoundSystem.ts b/src/sounds/botSoundSystem.ts index 225aa345..cb237072 100644 --- a/src/sounds/botSoundSystem.ts +++ b/src/sounds/botSoundSystem.ts @@ -11,6 +11,7 @@ import { showNotification } from '../react/NotificationProvider' import { pixelartIcons } from '../react/PixelartIcon' import { createSoundMap, SoundMap } from './soundsMap' import { musicSystem } from './musicSystem' +import './customSoundSystem' let soundMap: SoundMap | undefined @@ -50,8 +51,9 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { appViewer.backend?.soundSystem?.playSound( position, soundData.url, - soundData.volume * (options.volume / 100), - Math.max(Math.min(pitch ?? 1, 2), 0.5) + soundData.volume, + Math.max(Math.min(pitch ?? 1, 2), 0.5), + soundData.timeout ?? options.remoteSoundsLoadTimeout ) } if (getDistance(bot.entity.position, position) < 4 * 16) { @@ -71,7 +73,7 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { } const musicStartCheck = async (force = false) => { - if (!soundMap) return + if (!soundMap || !bot) return // 20% chance to start music if (Math.random() > 0.2 && !force && !options.enableMusic) return @@ -81,7 +83,7 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { } const randomMusicKey = musicKeys[Math.floor(Math.random() * musicKeys.length)] const soundData = await soundMap.getSoundUrl(randomMusicKey) - if (!soundData) return + if (!soundData || !soundMap) return await musicSystem.playMusic(soundData.url, soundData.volume) } @@ -109,6 +111,9 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { } bot.on('soundEffectHeard', async (soundId, position, volume, pitch) => { + if (/^https?:/.test(soundId.replace('minecraft:', ''))) { + return + } await playHardcodedSound(soundId, position, volume, pitch) }) @@ -136,6 +141,7 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { let lastStepSound = 0 const movementHappening = async () => { if (!bot.entity || !soundMap) return // no info yet + if (appViewer.playerState.reactive.gameMode === 'spectator') return // Don't play step sounds in spectator mode const VELOCITY_THRESHOLD = 0.1 const RUN_THRESHOLD = 0.15 const { x, z, y } = bot.entity.velocity diff --git a/src/sounds/customSoundSystem.ts b/src/sounds/customSoundSystem.ts new file mode 100644 index 00000000..1880aa70 --- /dev/null +++ b/src/sounds/customSoundSystem.ts @@ -0,0 +1,46 @@ +import { loadOrPlaySound, stopAllSounds, stopSound } from '../basicSounds' +import { options } from '../optionsStorage' + +const customSoundSystem = () => { + bot._client.on('named_sound_effect', packet => { + if (!options.remoteSoundsSupport) return + let { soundName } = packet + let metadata = {} as { loadTimeout?: number, loop?: boolean } + + // Extract JSON metadata from parentheses at the end + const jsonMatch = /\(({.*})\)$/.exec(soundName) + if (jsonMatch) { + try { + metadata = JSON.parse(jsonMatch[1]) + soundName = soundName.slice(0, -jsonMatch[0].length) + } catch (e) { + console.warn('Failed to parse sound metadata:', jsonMatch[1]) + } + } + + if (/^https?:/.test(soundName.replace('minecraft:', ''))) { + const { loadTimeout, loop } = metadata + void loadOrPlaySound(soundName, packet.volume, loadTimeout, loop) + } + }) + + bot._client.on('stop_sound', packet => { + const { flags, source, sound } = packet + + if (flags === 0) { + // Stop all sounds + stopAllSounds() + } else if (sound) { + // Stop specific sound by name + stopSound(sound) + } + }) + + bot.on('end', () => { + stopAllSounds() + }) +} + +customEvents.on('mineflayerBotCreated', () => { + customSoundSystem() +}) diff --git a/src/sounds/musicSystem.ts b/src/sounds/musicSystem.ts index ecabf43e..8fad4c74 100644 --- a/src/sounds/musicSystem.ts +++ b/src/sounds/musicSystem.ts @@ -5,10 +5,10 @@ class MusicSystem { private currentMusic: string | null = null async playMusic (url: string, musicVolume = 1) { - if (!options.enableMusic || this.currentMusic) return + if (!options.enableMusic || this.currentMusic || options.musicVolume === 0) return try { - const { onEnded } = await loadOrPlaySound(url, 0.5 * musicVolume, 5000) ?? {} + const { onEnded } = await loadOrPlaySound(url, musicVolume, 5000, undefined, true) ?? {} if (!onEnded) return diff --git a/src/sounds/soundsMap.ts b/src/sounds/soundsMap.ts index 1b0a0178..47028971 100644 --- a/src/sounds/soundsMap.ts +++ b/src/sounds/soundsMap.ts @@ -35,6 +35,7 @@ interface ResourcePackSoundEntry { name: string stream?: boolean volume?: number + timeout?: number } interface ResourcePackSound { @@ -140,7 +141,7 @@ export class SoundMap { await scan(soundsBasePath) } - async getSoundUrl (soundKey: string, volume = 1): Promise<{ url: string; volume: number } | undefined> { + async getSoundUrl (soundKey: string, volume = 1): Promise<{ url: string; volume: number, timeout?: number } | undefined> { // First check resource pack sounds.json if (this.activeResourcePackSoundsJson && soundKey in this.activeResourcePackSoundsJson) { const rpSound = this.activeResourcePackSoundsJson[soundKey] @@ -151,6 +152,13 @@ export class SoundMap { if (this.activeResourcePackBasePath) { const tryFormat = async (format: string) => { try { + if (sound.name.startsWith('http://') || sound.name.startsWith('https://')) { + return { + url: sound.name, + volume: soundVolume * Math.max(Math.min(volume, 1), 0), + timeout: sound.timeout + } + } const resourcePackPath = path.join(this.activeResourcePackBasePath!, `/assets/minecraft/sounds/${sound.name}.${format}`) const fileData = await fs.promises.readFile(resourcePackPath) return { diff --git a/src/utils.ts b/src/utils.ts index d48fbbc3..3ccc7fc4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -163,7 +163,7 @@ export const reloadChunks = async () => { } export const openGithub = (addUrl = '') => { - window.open(`${process.env.GITHUB_URL}${addUrl}`, '_blank') + window.open(`${process.env.GITHUB_URL?.replace(/\/$/, '')}${addUrl}`, '_blank') } export const resolveTimeout = async (promise, timeout = 10_000) => { diff --git a/src/watchOptions.ts b/src/watchOptions.ts index 478da4fb..de7d30d3 100644 --- a/src/watchOptions.ts +++ b/src/watchOptions.ts @@ -3,6 +3,7 @@ import { subscribeKey } from 'valtio/utils' import { isMobile } from 'renderer/viewer/lib/simpleUtils' import { WorldDataEmitter } from 'renderer/viewer/lib/worldDataEmitter' +import { setSkinsConfig } from 'renderer/viewer/lib/utils/skins' import { options, watchValue } from './optionsStorage' import { reloadChunks } from './utils' import { miscUiState } from './globalState' @@ -80,6 +81,10 @@ export const watchOptionsAfterViewerInit = () => { updateFpsLimit(o) }) + watchValue(options, o => { + appViewer.inWorldRenderingConfig.volume = Math.max(o.volume / 100, 0) + }) + watchValue(options, o => { appViewer.inWorldRenderingConfig.vrSupport = o.vrSupport appViewer.inWorldRenderingConfig.vrPageGameRendering = o.vrPageGameRendering @@ -93,6 +98,8 @@ export const watchOptionsAfterViewerInit = () => { appViewer.inWorldRenderingConfig.highlightBlockColor = o.highlightBlockColor appViewer.inWorldRenderingConfig._experimentalSmoothChunkLoading = o.rendererSharedOptions._experimentalSmoothChunkLoading appViewer.inWorldRenderingConfig._renderByChunks = o.rendererSharedOptions._renderByChunks + + setSkinsConfig({ apiEnabled: o.loadPlayerSkins }) }) appViewer.inWorldRenderingConfig.smoothLighting = options.smoothLighting @@ -112,6 +119,10 @@ export const watchOptionsAfterViewerInit = () => { appViewer.inWorldRenderingConfig.starfield = o.starfieldRendering }) + watchValue(options, o => { + appViewer.inWorldRenderingConfig.defaultSkybox = o.defaultSkybox + }) + watchValue(options, o => { // appViewer.inWorldRenderingConfig.neighborChunkUpdates = o.neighborChunkUpdates }) @@ -124,5 +135,6 @@ export const watchOptionsAfterWorldViewInit = (worldView: WorldDataEmitter) => { appViewer.inWorldRenderingConfig.renderEars = o.renderEars appViewer.inWorldRenderingConfig.showHand = o.showHand appViewer.inWorldRenderingConfig.viewBobbing = o.viewBobbing + appViewer.inWorldRenderingConfig.dayCycle = o.dayCycleAndLighting }) }