Compare commits

..

5 commits

Author SHA1 Message Date
Vitaly Turovsky
425cf520bc strict comment check 2025-04-10 18:52:50 +03:00
Vitaly Turovsky
cdd0f6c072 add builds 2025-04-10 18:45:50 +03:00
Vitaly Turovsky
c918249897 file protocol support 2025-04-10 18:38:33 +03:00
Vitaly Turovsky
1e622d68b6 bench clean 2025-04-10 18:26:06 +03:00
Vitaly Turovsky
80e5943c88 fix: update rsbuild and pnpm to latest version to resolve long-standing annoying issues and fix critical code loss issue!
closes #264
2025-04-10 18:25:56 +03:00
248 changed files with 3730 additions and 14769 deletions

View file

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

View file

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

View file

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

View file

@ -23,8 +23,6 @@ jobs:
- name: Build single-file version - minecraft.html - name: Build single-file version - minecraft.html
run: pnpm build-single-file && mv dist/single/index.html 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 - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View file

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

View file

@ -33,7 +33,7 @@ jobs:
cd package cd package
zip -r ../self-host.zip . zip -r ../self-host.zip .
- run: pnpm build-playground - run: pnpm build-playground
# - run: pnpm build-storybook - run: pnpm build-storybook
- run: pnpm test-unit - run: pnpm test-unit
- run: pnpm lint - run: pnpm lint
@ -124,35 +124,35 @@ jobs:
# content['${{ github.event.pull_request.base.ref }}'] = stats; # content['${{ github.event.pull_request.base.ref }}'] = stats;
# await updateGistContent(content); # await updateGistContent(content);
# - name: Update PR Description - name: Update PR Description
# uses: actions/github-script@v6 uses: actions/github-script@v6
# with: with:
# script: | script: |
# const { data: pr } = await github.rest.pulls.get({ const { data: pr } = await github.rest.pulls.get({
# owner: context.repo.owner, owner: context.repo.owner,
# repo: context.repo.repo, repo: context.repo.repo,
# pull_number: context.issue.number pull_number: context.issue.number
# }); });
# let body = pr.body || ''; let body = pr.body || '';
# const statsMarker = '### Bundle Size'; const statsMarker = '### Bundle Size';
# const comparison = '${{ steps.compare.outputs.stats }}'; const comparison = '${{ steps.compare.outputs.stats }}';
# if (body.includes(statsMarker)) { if (body.includes(statsMarker)) {
# body = body.replace( body = body.replace(
# new RegExp(`${statsMarker}[^\n]*\n[^\n]*`), new RegExp(`${statsMarker}[^\n]*\n[^\n]*`),
# `${statsMarker}\n${comparison}` `${statsMarker}\n${comparison}`
# ); );
# } else { } else {
# body += `\n\n${statsMarker}\n${comparison}`; body += `\n\n${statsMarker}\n${comparison}`;
# } }
# await github.rest.pulls.update({ await github.rest.pulls.update({
# owner: context.repo.owner, owner: context.repo.owner,
# repo: context.repo.repo, repo: context.repo.repo,
# pull_number: context.issue.number, pull_number: context.issue.number,
# body body
# }); });
# dedupe-check: # dedupe-check:
# runs-on: ubuntu-latest # runs-on: ubuntu-latest
# if: github.event.pull_request.head.ref == 'next' # if: github.event.pull_request.head.ref == 'next'

View file

@ -30,18 +30,18 @@ jobs:
- name: Write Release Info - name: Write Release Info
run: | run: |
echo "{\"latestTag\": \"$(git rev-parse --short $GITHUB_SHA)\", \"isCommit\": true}" > assets/release.json echo "{\"latestTag\": \"$(git rev-parse --short $GITHUB_SHA)\", \"isCommit\": true}" > assets/release.json
- name: Download Generated Sounds map
run: node scripts/downloadSoundsMap.mjs
- name: Build Project Artifacts - name: Build Project Artifacts
run: vercel build --token=${{ secrets.VERCEL_TOKEN }} run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
env: env:
CONFIG_JSON_SOURCE: BUNDLED CONFIG_JSON_SOURCE: BUNDLED
LOCAL_CONFIG_FILE: config.mcraft-only.json - run: pnpm build-storybook
- name: Copy playground files - name: Copy playground files
run: | run: |
mkdir -p .vercel/output/static/playground mkdir -p .vercel/output/static/playground
pnpm build-playground pnpm build-playground
cp -r renderer/dist/* .vercel/output/static/playground/ cp -r renderer/dist/* .vercel/output/static/playground/
- name: Download Generated Sounds map
run: node scripts/downloadSoundsMap.mjs
- name: Deploy Project Artifacts to Vercel - name: Deploy Project Artifacts to Vercel
uses: mathiasvr/command-output@v2.0.0 uses: mathiasvr/command-output@v2.0.0
with: with:

View file

@ -1,4 +1,4 @@
name: Vercel PR Deploy (Preview) name: Vercel Deploy Preview
env: env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
@ -72,13 +72,11 @@ jobs:
- name: Write Release Info - name: Write Release Info
run: | run: |
echo "{\"latestTag\": \"$(git rev-parse --short ${{ github.event.pull_request.head.sha }})\", \"isCommit\": true}" > assets/release.json echo "{\"latestTag\": \"$(git rev-parse --short ${{ github.event.pull_request.head.sha }})\", \"isCommit\": true}" > assets/release.json
- name: Download Generated Sounds map
run: node scripts/downloadSoundsMap.mjs
- name: Build Project Artifacts - name: Build Project Artifacts
run: vercel build --token=${{ secrets.VERCEL_TOKEN }} run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
env: env:
CONFIG_JSON_SOURCE: BUNDLED CONFIG_JSON_SOURCE: BUNDLED
LOCAL_CONFIG_FILE: config.mcraft-only.json - run: pnpm build-storybook
- name: Copy playground files - name: Copy playground files
run: | run: |
mkdir -p .vercel/output/static/playground mkdir -p .vercel/output/static/playground
@ -92,6 +90,8 @@ jobs:
run: | run: |
mkdir -p .vercel/output/static/commit mkdir -p .vercel/output/static/commit
echo "<meta http-equiv='refresh' content='0;url=https://github.com/${{ github.repository }}/pull/${{ github.event.issue.number || github.event.pull_request.number }}/commits/${{ github.event.pull_request.head.sha }}'>" > .vercel/output/static/commit/index.html echo "<meta http-equiv='refresh' content='0;url=https://github.com/${{ github.repository }}/pull/${{ github.event.issue.number || github.event.pull_request.number }}/commits/${{ github.event.pull_request.head.sha }}'>" > .vercel/output/static/commit/index.html
- name: Download Generated Sounds map
run: node scripts/downloadSoundsMap.mjs
- name: Deploy Project Artifacts to Vercel - name: Deploy Project Artifacts to Vercel
uses: mathiasvr/command-output@v2.0.0 uses: mathiasvr/command-output@v2.0.0
with: with:

View file

@ -29,18 +29,22 @@ jobs:
run: pnpx zardoy-release empty --skip-github --output-file assets/release.json run: pnpx zardoy-release empty --skip-github --output-file assets/release.json
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Download Generated Sounds map
run: node scripts/downloadSoundsMap.mjs
- run: vercel build --token=${{ secrets.VERCEL_TOKEN }} --prod - run: vercel build --token=${{ secrets.VERCEL_TOKEN }} --prod
env: env:
CONFIG_JSON_SOURCE: BUNDLED CONFIG_JSON_SOURCE: BUNDLED
LOCAL_CONFIG_FILE: config.mcraft-only.json - run: pnpm build-storybook
- name: Copy playground files - name: Copy playground files
run: | run: |
mkdir -p .vercel/output/static/playground mkdir -p .vercel/output/static/playground
pnpm build-playground pnpm build-playground
cp -r renderer/dist/* .vercel/output/static/playground/ cp -r renderer/dist/* .vercel/output/static/playground/
- name: Download Generated Sounds map
run: node scripts/downloadSoundsMap.mjs
- name: Deploy Project to Vercel
uses: mathiasvr/command-output@v2.0.0
with:
run: vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }} --prod
id: deploy
# publish to github # publish to github
- run: cp vercel.json .vercel/output/static/vercel.json - run: cp vercel.json .vercel/output/static/vercel.json
- uses: peaceiris/actions-gh-pages@v3 - uses: peaceiris/actions-gh-pages@v3
@ -49,39 +53,6 @@ jobs:
publish_dir: .vercel/output/static publish_dir: .vercel/output/static
force_orphan: true force_orphan: true
# Create CNAME file for custom domain
- name: Create CNAME file
run: echo "github.mcraft.fun" > .vercel/output/static/CNAME
- name: Deploy to mwc-mcraft-pages repository
uses: peaceiris/actions-gh-pages@v3
with:
personal_token: ${{ secrets.MCW_MCRAFT_PAGE_DEPLOY_TOKEN }}
external_repository: ${{ github.repository_owner }}/mwc-mcraft-pages
publish_dir: .vercel/output/static
publish_branch: main
destination_dir: docs
force_orphan: true
- name: Change index.html title
run: |
# change <title>Minecraft Web Client</title> to <title>Minecraft Web Client — Free Online Browser Version</title>
sed -i 's/<title>Minecraft Web Client<\/title>/<title>Minecraft Web Client — Free Online Browser Version<\/title>/' .vercel/output/static/index.html
- name: Deploy Project to Vercel
uses: mathiasvr/command-output@v2.0.0
with:
run: vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }} --prod
id: deploy
- name: Get releasing alias
run: node scripts/githubActions.mjs getReleasingAlias
id: alias
- name: Set deployment alias
run: |
for alias in $(echo ${{ steps.alias.outputs.alias }} | tr "," "\n"); do
vercel alias set ${{ steps.deploy.outputs.stdout }} $alias --token=${{ secrets.VERCEL_TOKEN }} --scope=zaro
done
- name: Build single-file version - minecraft.html - name: Build single-file version - minecraft.html
run: pnpm build-single-file && mv dist/single/index.html minecraft.html run: pnpm build-single-file && mv dist/single/index.html minecraft.html
- name: Build self-host version - name: Build self-host version
@ -99,7 +70,7 @@ jobs:
zip -r ../self-host.zip . zip -r ../self-host.zip .
- run: | - run: |
pnpx zardoy-release node --footer "This release URL: https://$(echo ${{ steps.alias.outputs.alias }} | cut -d',' -f1) (Vercel URL: ${{ steps.deploy.outputs.stdout }})" pnpx zardoy-release node --footer "This release URL: ${{ steps.deploy.outputs.stdout }}"
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# has possible output: tag # has possible output: tag

1
.gitignore vendored
View file

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

View file

@ -177,13 +177,8 @@ New React components, improve UI (including mobile support).
## Updating Dependencies ## Updating Dependencies
1. Use `pnpm update-git-deps` to check and update git dependencies (like mineflayer fork, prismarine packages etc). The script will: 1. Ensure mineflayer fork is up to date with the latest version of mineflayer original repo
- 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`, ... 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: 3. If `minecraft-protocol` patch fails, do this:
1. Remove the patch from `patchedDependencies` in `package.json` 1. Remove the patch from `patchedDependencies` in `package.json`
2. Run `pnpm patch minecraft-protocol`, open patch directory 2. Run `pnpm patch minecraft-protocol`, open patch directory

View file

@ -35,7 +35,7 @@ WORKDIR /app
COPY --from=build /app/dist /app/dist COPY --from=build /app/dist /app/dist
COPY server.js /app/server.js COPY server.js /app/server.js
# Install express # Install express
RUN npm i -g pnpm@10.8.0 RUN npm corepack enable
RUN npm init -yp RUN npm init -yp
RUN pnpm i express github:zardoy/prismarinejs-net-browserify compression cors RUN pnpm i express github:zardoy/prismarinejs-net-browserify compression cors
EXPOSE 8080 EXPOSE 8080

View file

@ -6,17 +6,12 @@ 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. You can try this out at [mcraft.fun](https://mcraft.fun/), [pcm.gg](https://pcm.gg) (short link), [mcon.vercel.app](https://mcon.vercel.app/) or the GitHub pages deploy. Every commit from the default (`develop`) branch is deployed to [s.mcraft.fun](https://s.mcraft.fun/) and [s.pcm.gg](https://s.pcm.gg/) - so it's usually newer, but might be less stable.
> For Turkey/Russia use [ru.mcraft.fun](https://ru.mcraft.fun/) (since Cloudflare is blocked) Don't confuse with [Eaglercraft](https://git.eaglercraft.rip/eaglercraft/eaglercraft-1.8) which is a REAL vanilla Minecraft Java edition port to the web (but with its own limitations). Eaglercraft is a fully playable solution, but this project is more in position of a "technical demo" to show how it's possible to build games for web at scale entirely with the JS ecosystem. Have fun!
Don't confuse with [Eaglercraft](https://git.eaglercraft.rip/eaglercraft/eaglercraft-1.8) which is a REAL vanilla Minecraft Java edition port to the web (but with its own limitations). Eaglercraft is a fully playable solution, meanwhile this project is aimed for *device-compatiiblity* and better performance so it feels portable, flexible and lightweight. It's also a very strong example on how to build true HTML games for the web at scale entirely with the JS ecosystem. Have fun! For building the project yourself / contributing, see [Development, Debugging & Contributing](#development-debugging--contributing). For reference at what and how web technologies / frameworks are used, see [TECH.md](./TECH.md).
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 ### Big Features
- Official Mineflayer [plugin integration](https://github.com/zardoy/mcraft-fun-mineflayer-plugin)! View / Control your bot remotely.
- Open any zip world file or even folder in read-write mode! - Open any zip world file or even folder in read-write mode!
- Connect to Java servers running in both offline (cracked) and online mode* (it's possible because of proxy servers, see below) - Connect to Java servers running in both offline (cracked) and online mode* (it's possible because of proxy servers, see below)
- Integrated JS server clone capable of opening Java world saves in any way (folders, zip, web chunks streaming, etc) - Integrated JS server clone capable of opening Java world saves in any way (folders, zip, web chunks streaming, etc)
@ -32,31 +27,26 @@ For building the project yourself / contributing, see [Development, Debugging &
- Support for custom rendering 3D engines. Modular architecture. - Support for custom rendering 3D engines. Modular architecture.
- even even more! - even even more!
All components that are in [Storybook](https://minimap.mcraft.fun/storybook/) are published as npm module and can be used in other projects: [`minecraft-react`](https://npmjs.com/minecraft-react) All components that are in [Storybook](https://mcraft.fun/storybook) are published as npm module and can be used in other projects: [`minecraft-react`](https://npmjs.com/minecraft-react)
### Recommended Settings ### Recommended Settings
- Controls -> **Touch Controls Type** -> **Joystick** - Controls -> **Touch Controls Type** -> **Joystick**
- Controls -> **Auto Full Screen** -> **On** - To avoid ctrl+w issue - Controls -> **Auto Full Screen** -> **On** - To avoid ctrl+w issue
- Interface -> **Enable Minimap** -> **Always** - To enable useful minimap (why not?)
- Controls -> **Raw Input** -> **On** - This will make the controls more precise (UPD: already enabled by default) - Controls -> **Raw Input** -> **On** - This will make the controls more precise (UPD: already enabled by default)
- Interface -> **Chat Select** -> **On** - To select chat messages (UPD: already enabled by default) - Interface -> **Chat Select** -> **On** - To select chat messages (UPD: already enabled by default)
### Browser Notes ### Browser Notes
This project is tested with BrowserStack. Special thanks to [BrowserStack](https://www.browserstack.com/) for providing testing infrastructure! These browsers have issues with capturing pointer:
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 **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 **Vivaldi**: Disable Controls -> *Raw Input* in game settings if experiencing issues
### Versions Support ### Versions Support
Server versions 1.8 - 1.21.5 are supported. Server versions 1.8 - 1.21.4 are supported.
First class versions (most of the features are tested on these versions): First class versions (most of the features are tested on these versions):
- 1.19.4 - 1.19.4
- 1.21.4 - 1.21.4
@ -78,8 +68,6 @@ There is a builtin proxy, but you can also host your one! Just clone the repo, r
[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?name=minecraft-web-client&type=git&repository=zardoy%2Fminecraft-web-client&branch=next&builder=dockerfile&env%5B%5D=&ports=8080%3Bhttp%3B%2F) [![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. 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 ```mermaid
@ -127,12 +115,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: However, there are many things that can be done in online production version (like debugging actual source code). Also you can access some global variables in the console and there are a few useful examples:
- If you type `debugToggle`, press enter in console - It will enables all debug messages! Warning: this will start all packets spam. - `localStorage.debug = '*'` - Enables all debug messages! Warning: this will start all packets spam.
Instead I recommend setting `options.debugLogNotFrequentPackets`. Also you can use `debugTopPackets` (with JSON.stringify) to see what packets were received/sent by name 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. - `bot` - Mineflayer bot instance. See Mineflayer documentation for more.
- `world` - Three.js world instance, basically does all the rendering (part of renderer backend). - `viewer` - Three.js viewer instance, basically does all the rendering.
- `world.sectionObjects` - Object with all active chunk sections (geometries) in the world. Each chunk section is a Three.js mesh or group. - `viewer.world.sectionObjects` - Object with all active chunk sections (geometries) in the world. Each chunk section is a Three.js mesh or group.
- `debugSceneChunks` - The same as above, but relative to current bot position (e.g. 0,0 is the current chunk). - `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. - `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. - `localServer`/`server` - Only for singleplayer mode/host. Flying Squid server instance, see it's documentation for more.
@ -141,7 +129,7 @@ Instead I recommend setting `options.debugLogNotFrequentPackets`. Also you can u
- `nbt.simplify(someNbt)` - Simplifies nbt data, so it's easier to read. - `nbt.simplify(someNbt)` - Simplifies nbt data, so it's easier to read.
The most useful thing in devtools is the watch expression. You can add any expression there and it will be re-evaluated in real time. For example, you can add `world.getCameraPosition()` to see the camera position and so on. The most useful thing in devtools is the watch expression. You can add any expression there and it will be re-evaluated in real time. For example, you can add `viewer.camera.position` to see the camera position and so on.
<img src="./docs-assets/watch-expr.png" alt="Watch expression" width="480"/> <img src="./docs-assets/watch-expr.png" alt="Watch expression" width="480"/>
@ -178,7 +166,6 @@ Server specific:
- `?lockConnect=true` - Only works then `ip` parameter is set. Disables cancel/save buttons and all inputs in the connect screen already set as parameters. Useful for integrates iframes. - `?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. - `?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. - `?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: Single player specific:
@ -235,4 +222,3 @@ Only during development:
- [https://github.com/ClassiCube/ClassiCube](ClassiCube - Better C# Rewrite) [DEMO](https://www.classicube.net/server/play/?warned=true) - [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) - [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

13
TECH.md
View file

@ -10,27 +10,26 @@ This client generally has better performance but some features reproduction migh
| Gamepad Support | ✅ | ❌ | | | Gamepad Support | ✅ | ❌ | |
| A11Y | ✅ | ❌ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page (but maybe it's not needed) | | A11Y | ✅ | ❌ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page (but maybe it's not needed) |
| Game Features | | | | | Game Features | | | |
| Servers Support (quality) | ❌(+) | ✅ | Eaglercraft is vanilla Minecraft, while this project tries to emulate original game behavior at protocol level (Mineflayer is used) | | Servers Support (quality) | ❌ | ✅ | Eaglercraft is vanilla Minecraft, while this project tries to emulate original game behavior at protocol level (Mineflayer is used) |
| Servers Support (any version, ip) | ✅ | ❌ | We support almost all Minecraft versions, only important if you connect to a server where you need new content like blocks or if you play with friends. And you can connect to almost any server using proxy servers! | | Servers Support (any version, ip) | ✅ | ❌ | We support almost all Minecraft versions, only important if you connect to a server where you need new content like blocks or if you play with friends. And you can connect to almost any server using proxy servers! |
| Servers Support (online mode) | ✅ | ❌ | Join to online servers like Hypixel using your Microsoft account without additional proxies |
| Singleplayer Survival Features | ❌ | ✅ | Just like Eaglercraft this project can generate and save worlds, but generator is simple and only a few survival features are supported (look here for [supported features list](https://github.com/zardoy/space-squid)) | | Singleplayer Survival Features | ❌ | ✅ | Just like Eaglercraft this project can generate and save worlds, but generator is simple and only a few survival features are supported (look here for [supported features list](https://github.com/zardoy/space-squid)) |
| Singleplayer Maps | ✅ | ✅ | We support any version, but adventure maps won't work, but simple parkour and build maps might be interesting to explore... | | Singleplayer Maps | ✅ | ✅ | We support any version, but adventure maps won't work, but simple parkour and build maps might be interesting to explore... |
| Singleplayer Maps World Streaming | ✅ | ❌ | Thanks to Browserfs, saves can be loaded to local singleplayer server using multiple ways: from local folder, server directory (not zip), dropbox or other cloud *backend* etc... | | Singleplayer Maps World Streaming | ✅ | ❌ | Thanks to Browserfs, saves can be loaded to local singleplayer server using multiple ways: from local folder, server directory (not zip), dropbox or other cloud *backend* etc... |
| P2P Multiplayer | ✅ | ✅ | A way to connect to other browser running the project. But it's almost useless here since many survival features are not implemented. Maybe only to build / explore maps together... | | P2P Multiplayer | ✅ | ✅ | A way to connect to other browser running the project. But it's almost useless here since many survival features are not implemented. Maybe only to build / explore maps together... |
| Voice Chat | ❌(+) | ✅ | Eaglercraft has custom WebRTC voice chat implementation, though it could also be easily implemented there | | Voice Chat | ❌ | ✅ | Eaglercraft has custom WebRTC voice chat implementation, though it could also be easily implemented there |
| Online Servers | ✅ | ❌ | We have custom implementation (including integration on proxy side) for joining to servers | | Online Servers | ✅ | ❌ | We have custom implementation (including integration on proxy side) for joining to servers |
| Plugin Features | ✅ | ❌ | We have Mineflayer plugins support, like Auto Jump & Auto Parkour was added here that way | | Plugin Features | ✅ | ❌ | We have Mineflayer plugins support, like Auto Jump & Auto Parkour was added here that way |
| Direct Connection | ✅ | ✅ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page | | Direct Connection | ✅ | ✅ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page |
| Moding | ✅(own js mods) | ❌ | This project will support mods for singleplayer. In theory its possible to implement support for modded servers on protocol level (including all needed mods) | | Moding | ❌(roadmap, client-side) | ❌ | This project will support mods for singleplayer. In theory its possible to implement support for modded servers on protocol level (including all needed mods) |
| Video Recording | ❌ | ✅ | Doesn't feel needed | | Video Recording | ❌ | ✅ | Don't feel needed |
| Metaverse Features | ✅(50%) | ❌ | We have videos / images support inside world, but not iframes (custom protocol channel) | | Metaverse Features | ❌(roadmap) | ❌ | Iframes, video streams inside of game world (custom protocol channel) |
| Sounds | ✅ | ✅ | | | Sounds | ✅ | ✅ | |
| Resource Packs | ✅(+extras) | ✅ | This project has very limited support for them (only textures images are loadable for now) | | Resource Packs | ✅(+extras) | ✅ | This project has very limited support for them (only textures images are loadable for now) |
| Assets Compressing & Splitting | ✅ | ❌ | We have advanced Minecraft data processing and good code chunk splitting so the web app will open much faster and use less memory | | Assets Compressing & Splitting | ✅ | ❌ | We have advanced Minecraft data processing and good code chunk splitting so the web app will open much faster and use less memory |
| Graphics | | | | | Graphics | | | |
| Fancy Graphics | ❌ | ✅ | While Eaglercraft has top-level shaders we don't even support lighting | | Fancy Graphics | ❌ | ✅ | While Eaglercraft has top-level shaders we don't even support lighting |
| Fast & Efficient Graphics | ❌(+) | ❌ | Feels like no one needs to have 64 rendering distance work smoothly | | Fast & Efficient Graphics | ❌(+) | ❌ | Feels like no one needs to have 64 rendering distance work smoothly |
| VR | ✅(-) | ❌ | Feels like not needed feature. UI is missing in this project since DOM can't be rendered in VR so Eaglercraft could be better in that aspect | | VR | ✅ | ❌ | Feels like not needed feature. UI is missing in this project since DOM can't be rendered in VR so Eaglercraft could be better in that aspect |
| AR | ❌ | ❌ | Would be the most useless feature | | AR | ❌ | ❌ | Would be the most useless feature |
| Minimap & Waypoints | ✅(-) | ❌ | We have buggy minimap, which can be enabled in settings and full map is opened by pressing `M` key | | Minimap & Waypoints | ✅(-) | ❌ | We have buggy minimap, which can be enabled in settings and full map is opened by pressing `M` key |

View file

@ -1,39 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Configure client</title>
<script>
function removeSettings() {
if (confirm('Are you sure you want to RESET ALL SETTINGS?')) {
localStorage.setItem('options', '{}');
location.reload();
}
}
function removeAllData() {
localStorage.removeItem('serversList')
localStorage.removeItem('serversHistory')
localStorage.removeItem('authenticatedAccounts')
localStorage.removeItem('modsAutoUpdateLastCheck')
localStorage.removeItem('firstModsPageVisit')
localStorage.removeItem('proxiesData')
localStorage.removeItem('keybindings')
localStorage.removeItem('username')
localStorage.removeItem('customCommands')
localStorage.removeItem('options')
}
</script>
</head>
<body>
<div style="display: flex;gap: 10px;">
<button onclick="removeSettings()">Reset all settings</button>
<button onclick="removeAllData()">Remove all user data (but not mods or worlds)</button>
<!-- <button>Remove all user data (worlds, resourcepacks)</button> -->
<!-- <button>Remove all mods</button> -->
<!-- <button>Remove all mod repositories</button> -->
</div>
<input />
</body>
</html>

View file

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

View file

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

View file

@ -3,17 +3,12 @@
"defaultHost": "<from-proxy>", "defaultHost": "<from-proxy>",
"defaultProxy": "https://proxy.mcraft.fun", "defaultProxy": "https://proxy.mcraft.fun",
"mapsProvider": "https://maps.mcraft.fun/", "mapsProvider": "https://maps.mcraft.fun/",
"skinTexturesProxy": "",
"peerJsServer": "", "peerJsServer": "",
"peerJsServerFallback": "https://p2p.mcraft.fun", "peerJsServerFallback": "https://p2p.mcraft.fun",
"promoteServers": [ "promoteServers": [
{ {
"ip": "wss://play.mcraft.fun" "ip": "wss://play.mcraft.fun"
}, },
{
"ip": "wss://play.webmc.fun",
"name": "WebMC"
},
{ {
"ip": "wss://ws.fuchsmc.net" "ip": "wss://ws.fuchsmc.net"
}, },
@ -21,8 +16,8 @@
"ip": "wss://play2.mcraft.fun" "ip": "wss://play2.mcraft.fun"
}, },
{ {
"ip": "wss://play-creative.mcraft.fun", "ip": "wss://mcraft.ryzyn.xyz",
"description": "Might be available soon, stay tuned!" "version": "1.19.4"
}, },
{ {
"ip": "kaboom.pw", "ip": "kaboom.pw",
@ -30,9 +25,6 @@
"description": "Very nice a polite server. Must try for everyone!" "description": "Very nice a polite server. Must try for everyone!"
} }
], ],
"rightSideText": "A Minecraft client clone in the browser!",
"splashText": "The sunset is coming!",
"splashTextFallback": "Welcome!",
"pauseLinks": [ "pauseLinks": [
[ [
{ {
@ -42,39 +34,5 @@
"type": "discord" "type": "discord"
} }
] ]
],
"defaultUsername": "mcrafter{0-9999}",
"mobileButtons": [
{
"action": "general.drop",
"actionHold": "general.dropStack",
"label": "Q"
},
{
"action": "general.selectItem",
"actionHold": "",
"label": "S"
},
{
"action": "general.debugOverlay",
"actionHold": "general.debugOverlayHelpMenu",
"label": "F3"
},
{
"action": "general.playersList",
"actionHold": "",
"icon": "pixelarticons:users",
"label": "TAB"
},
{
"action": "general.chat",
"actionHold": "",
"label": ""
},
{
"action": "ui.pauseMenu",
"actionHold": "",
"label": ""
}
] ]
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -27,7 +27,6 @@
<div style="font-size: var(--font-size);color: rgb(176, 176, 176);margin-top: 3px;text-align: center" class="subtitle">A true Minecraft client in your browser!</div> <div style="font-size: var(--font-size);color: rgb(176, 176, 176);margin-top: 3px;text-align: center" class="subtitle">A true Minecraft client in your browser!</div>
<!-- small text pre --> <!-- small text pre -->
<div style="font-size: calc(var(--font-size) * 0.6);color: rgb(150, 150, 150);margin-top: 3px;text-align: center;white-space: pre-line;" class="advanced-info"></div> <div style="font-size: calc(var(--font-size) * 0.6);color: rgb(150, 150, 150);margin-top: 3px;text-align: center;white-space: pre-line;" class="advanced-info"></div>
<div style="font-size: calc(var(--font-size) * 0.6);color: rgb(255, 100, 100);margin-top: 10px;text-align: center;display: none;" class="ios-warning">Only iOS 15+ is supported due to performance optimizations</div>
</div> </div>
</div> </div>
` `
@ -37,13 +36,6 @@
if (!window.pageLoaded) { if (!window.pageLoaded) {
document.documentElement.appendChild(loadingDivElem) 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 // load error handling
const onError = (errorOrMessage, log = false) => { const onError = (errorOrMessage, log = false) => {
let message = errorOrMessage instanceof Error ? (errorOrMessage.stack ?? errorOrMessage.message) : errorOrMessage let message = errorOrMessage instanceof Error ? (errorOrMessage.stack ?? errorOrMessage.message) : errorOrMessage
@ -54,23 +46,12 @@
const [errorMessage, ...errorStack] = message.split('\n') const [errorMessage, ...errorStack] = message.split('\n')
document.querySelector('.initial-loader').querySelector('.subtitle').textContent = errorMessage document.querySelector('.initial-loader').querySelector('.subtitle').textContent = errorMessage
document.querySelector('.initial-loader').querySelector('.advanced-info').textContent = errorStack.join('\n') 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 if (window.navigator.maxTouchPoints > 1) window.location.hash = '#dev' // show eruda
// unregister all sw // unregister all sw
if (window.navigator.serviceWorker && document.querySelector('.initial-loader').style.opacity !== 0) { if (window.navigator.serviceWorker) {
console.log('got worker')
window.navigator.serviceWorker.getRegistrations().then(registrations => { window.navigator.serviceWorker.getRegistrations().then(registrations => {
registrations.forEach(registration => { registrations.forEach(registration => {
console.log('got registration') registration.unregister()
registration.unregister().then(() => {
console.log('worker unregistered')
})
}) })
}) })
} }
@ -149,7 +130,7 @@
</script> --> </script> -->
<title>Minecraft Web Client</title> <title>Minecraft Web Client</title>
<!-- <link rel="canonical" href="https://mcraft.fun"> --> <!-- <link rel="canonical" href="https://mcraft.fun"> -->
<meta name="description" content="Minecraft Java Edition Client in Browser — Full Multiplayer Support, Server Connect, Offline Play — Join real Minecraft servers"> <meta name="description" content="Minecraft web client running in your browser">
<meta name="keywords" content="Play, Minecraft, Online, Web, Java, Server, Single player, Javascript, PrismarineJS, Voxel, WebGL, Three.js"> <meta name="keywords" content="Play, Minecraft, Online, Web, Java, Server, Single player, Javascript, PrismarineJS, Voxel, WebGL, Three.js">
<meta name="date" content="2024-07-11" scheme="YYYY-MM-DD"> <meta name="date" content="2024-07-11" scheme="YYYY-MM-DD">
<meta name="language" content="English"> <meta name="language" content="English">

View file

@ -7,7 +7,6 @@
"dev-proxy": "node server.js", "dev-proxy": "node server.js",
"start": "run-p dev-proxy dev-rsbuild watch-mesher", "start": "run-p dev-proxy dev-rsbuild watch-mesher",
"start2": "run-p 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": "pnpm build-other-workers && rsbuild build",
"build-analyze": "BUNDLE_ANALYZE=true rsbuild build && pnpm build-other-workers", "build-analyze": "BUNDLE_ANALYZE=true rsbuild build && pnpm build-other-workers",
"build-single-file": "SINGLE_FILE_BUILD=true rsbuild build", "build-single-file": "SINGLE_FILE_BUILD=true rsbuild build",
@ -32,9 +31,7 @@
"run-playground": "run-p watch-mesher watch-other-workers watch-playground", "run-playground": "run-p watch-mesher watch-other-workers watch-playground",
"run-all": "run-p start run-playground", "run-all": "run-p start run-playground",
"build-playground": "rsbuild build --config renderer/rsbuild.config.ts", "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": [ "keywords": [
"prismarine", "prismarine",
@ -54,9 +51,8 @@
"dependencies": { "dependencies": {
"@dimaka/interface": "0.0.3-alpha.0", "@dimaka/interface": "0.0.3-alpha.0",
"@floating-ui/react": "^0.26.1", "@floating-ui/react": "^0.26.1",
"@monaco-editor/react": "^4.7.0", "@nxg-org/mineflayer-auto-jump": "^0.7.12",
"@nxg-org/mineflayer-auto-jump": "^0.7.18", "@nxg-org/mineflayer-tracker": "1.2.1",
"@nxg-org/mineflayer-tracker": "1.3.0",
"@react-oauth/google": "^0.12.1", "@react-oauth/google": "^0.12.1",
"@stylistic/eslint-plugin": "^2.6.1", "@stylistic/eslint-plugin": "^2.6.1",
"@types/gapi": "^0.0.47", "@types/gapi": "^0.0.47",
@ -80,14 +76,13 @@
"esbuild-plugin-polyfill-node": "^0.3.0", "esbuild-plugin-polyfill-node": "^0.3.0",
"express": "^4.18.2", "express": "^4.18.2",
"filesize": "^10.0.12", "filesize": "^10.0.12",
"flying-squid": "npm:@zardoy/flying-squid@^0.0.104", "flying-squid": "npm:@zardoy/flying-squid@^0.0.58",
"framer-motion": "^12.9.2",
"fs-extra": "^11.1.1", "fs-extra": "^11.1.1",
"google-drive-browserfs": "github:zardoy/browserfs#google-drive", "google-drive-browserfs": "github:zardoy/browserfs#google-drive",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"mcraft-fun-mineflayer": "^0.1.23", "mcraft-fun-mineflayer": "^0.1.14",
"minecraft-data": "3.98.0", "minecraft-data": "3.83.1",
"minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master", "minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master",
"mineflayer-item-map-downloader": "github:zardoy/mineflayer-item-map-downloader", "mineflayer-item-map-downloader": "github:zardoy/mineflayer-item-map-downloader",
"mojangson": "^2.0.4", "mojangson": "^2.0.4",
@ -106,6 +101,7 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-select": "^5.8.0", "react-select": "^5.8.0",
"react-transition-group": "^4.4.5",
"react-zoom-pan-pinch": "3.4.4", "react-zoom-pan-pinch": "3.4.4",
"remark": "^15.0.1", "remark": "^15.0.1",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
@ -135,6 +131,7 @@
"@storybook/react-vite": "^7.4.6", "@storybook/react-vite": "^7.4.6",
"@types/diff-match-patch": "^1.0.36", "@types/diff-match-patch": "^1.0.36",
"@types/lodash-es": "^4.17.9", "@types/lodash-es": "^4.17.9",
"@types/react-transition-group": "^4.4.7",
"@types/stats.js": "^0.17.1", "@types/stats.js": "^0.17.1",
"@types/three": "0.154.0", "@types/three": "0.154.0",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",
@ -144,7 +141,7 @@
"browserify-zlib": "^0.2.0", "browserify-zlib": "^0.2.0",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"constants-browserify": "^1.0.0", "constants-browserify": "^1.0.0",
"contro-max": "^0.1.9", "contro-max": "^0.1.8",
"crypto-browserify": "^3.12.0", "crypto-browserify": "^3.12.0",
"cypress-esbuild-preprocessor": "^1.0.2", "cypress-esbuild-preprocessor": "^1.0.2",
"eslint": "^8.50.0", "eslint": "^8.50.0",
@ -154,10 +151,11 @@
"http-browserify": "^1.7.0", "http-browserify": "^1.7.0",
"http-server": "^14.1.1", "http-server": "^14.1.1",
"https-browserify": "^1.0.0", "https-browserify": "^1.0.0",
"mc-assets": "^0.2.62", "mc-assets": "^0.2.52",
"minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next", "minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next",
"mineflayer": "github:zardoy/mineflayer#gen-the-master", "mineflayer": "github:GenerelSchwerz/mineflayer",
"mineflayer-mouse": "^0.1.21", "mineflayer-mouse": "^0.1.7",
"mineflayer-pathfinder": "^2.4.4",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"os-browserify": "^0.3.0", "os-browserify": "^0.3.0",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
@ -197,15 +195,14 @@
}, },
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"mineflayer": "github:zardoy/mineflayer#gen-the-master", "@nxg-org/mineflayer-physics-util": "1.8.6",
"@nxg-org/mineflayer-physics-util": "1.8.10",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"vec3": "0.1.10", "vec3": "0.1.10",
"three": "0.154.0", "three": "0.154.0",
"diamond-square": "github:zardoy/diamond-square", "diamond-square": "github:zardoy/diamond-square",
"prismarine-block": "github:zardoy/prismarine-block#next-era", "prismarine-block": "github:zardoy/prismarine-block#next-era",
"prismarine-world": "github:zardoy/prismarine-world#next-era", "prismarine-world": "github:zardoy/prismarine-world#next-era",
"minecraft-data": "3.98.0", "minecraft-data": "3.83.1",
"prismarine-provider-anvil": "github:zardoy/prismarine-provider-anvil#everything", "prismarine-provider-anvil": "github:zardoy/prismarine-provider-anvil#everything",
"prismarine-physics": "github:zardoy/prismarine-physics", "prismarine-physics": "github:zardoy/prismarine-physics",
"minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master", "minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master",
@ -214,10 +211,7 @@
"prismarine-item": "latest" "prismarine-item": "latest"
}, },
"updateConfig": { "updateConfig": {
"ignoreDependencies": [ "ignoreDependencies": []
"browserfs",
"google-drive-browserfs"
]
}, },
"patchedDependencies": { "patchedDependencies": {
"pixelarticons@1.8.1": "patches/pixelarticons@1.8.1.patch", "pixelarticons@1.8.1": "patches/pixelarticons@1.8.1.patch",
@ -227,16 +221,14 @@
"ignoredBuiltDependencies": [ "ignoredBuiltDependencies": [
"canvas", "canvas",
"core-js", "core-js",
"gl" "gl",
"sharp"
], ],
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"sharp",
"cypress", "cypress",
"esbuild", "esbuild",
"fsevents" "fsevents"
], ]
"ignorePatchFailures": false,
"allowUnusedPatches": false
}, },
"packageManager": "pnpm@10.8.0+sha512.0e82714d1b5b43c74610193cb20734897c1d00de89d0e18420aebc5977fa13d780a9cb05734624e81ebd81cc876cd464794850641c48b9544326b5622ca29971" "packageManager": "pnpm@10.8.0+sha512.0e82714d1b5b43c74610193cb20734897c1d00de89d0e18420aebc5977fa13d780a9cb05734624e81ebd81cc876cd464794850641c48b9544326b5622ca29971"
} }

View file

@ -1,26 +1,26 @@
diff --git a/src/client/chat.js b/src/client/chat.js diff --git a/src/client/chat.js b/src/client/chat.js
index 0021870994fc59a82f0ac8aba0a65a8be43ef2f4..a53fceb843105ea2a1d88722b3fc7c3b43cb102a 100644 index f14269bea055d4329cd729271e7406ec4b344de7..00f5482eb6e3c911381ca9a728b1b4aae0d1d337 100644
--- a/src/client/chat.js --- a/src/client/chat.js
+++ b/src/client/chat.js +++ b/src/client/chat.js
@@ -116,7 +116,7 @@ module.exports = function (client, options) { @@ -111,7 +111,7 @@ module.exports = function (client, options) {
for (const player of packet.data) { for (const player of packet.data) {
if (player.chatSession) { if (!player.chatSession) continue
client._players[player.uuid] = { 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' }),
+ // 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, publicKeyDER: player.chatSession.publicKey.keyBytes,
sessionUuid: player.chatSession.uuid sessionUuid: player.chatSession.uuid
} }
@@ -126,7 +126,7 @@ module.exports = function (client, options) { @@ -127,7 +127,7 @@ module.exports = function (client, options) {
for (const player of packet.data) {
if (player.crypto) { if (player.crypto) {
client._players[player.uuid] = { 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' }),
+ // 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, publicKeyDER: player.crypto.publicKey,
signature: player.crypto.signature, signature: player.crypto.signature,
displayName: player.displayName || player.name displayName: player.displayName || player.name
@@ -196,7 +196,7 @@ module.exports = function (client, options) { @@ -198,7 +198,7 @@ module.exports = function (client, options) {
if (mcData.supportFeature('useChatSessions')) { if (mcData.supportFeature('useChatSessions')) {
const tsDelta = BigInt(Date.now()) - packet.timestamp const tsDelta = BigInt(Date.now()) - packet.timestamp
const expired = !packet.timestamp || tsDelta > messageExpireTime || tsDelta < 0 const expired = !packet.timestamp || tsDelta > messageExpireTime || tsDelta < 0
@ -28,8 +28,8 @@ index 0021870994fc59a82f0ac8aba0a65a8be43ef2f4..a53fceb843105ea2a1d88722b3fc7c3b
+ const verified = false && !packet.unsignedChatContent && updateAndValidateSession(packet.senderUuid, packet.plainMessage, packet.signature, packet.index, packet.previousMessages, packet.salt, packet.timestamp) && !expired + 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) if (verified) client._signatureCache.push(packet.signature)
client.emit('playerChat', { client.emit('playerChat', {
globalIndex: packet.globalIndex, plainMessage: packet.plainMessage,
@@ -362,7 +362,7 @@ module.exports = function (client, options) { @@ -363,7 +363,7 @@ module.exports = function (client, options) {
} }
} }
@ -38,16 +38,16 @@ index 0021870994fc59a82f0ac8aba0a65a8be43ef2f4..a53fceb843105ea2a1d88722b3fc7c3b
options.timestamp = options.timestamp || BigInt(Date.now()) options.timestamp = options.timestamp || BigInt(Date.now())
options.salt = options.salt || 1n options.salt = options.salt || 1n
@@ -407,7 +407,7 @@ module.exports = function (client, options) { @@ -405,7 +405,7 @@ module.exports = function (client, options) {
message, message,
timestamp: options.timestamp, timestamp: options.timestamp,
salt: options.salt, salt: options.salt,
- signature: (client.profileKeys && client._session) ? client.signMessage(message, options.timestamp, options.salt, undefined, acknowledgements) : undefined, - 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, + signature: (client.profileKeys && client._session) ? await client.signMessage(message, options.timestamp, options.salt, undefined, acknowledgements) : undefined,
offset: client._lastSeenMessages.pending, offset: client._lastSeenMessages.pending,
checksum: computeChatChecksum(client._lastSeenMessages), // 1.21.5+
acknowledged acknowledged
@@ -422,7 +422,7 @@ module.exports = function (client, options) { })
@@ -419,7 +419,7 @@ module.exports = function (client, options) {
message, message,
timestamp: options.timestamp, timestamp: options.timestamp,
salt: options.salt, salt: options.salt,
@ -57,7 +57,7 @@ index 0021870994fc59a82f0ac8aba0a65a8be43ef2f4..a53fceb843105ea2a1d88722b3fc7c3b
previousMessages: client._lastSeenMessages.map((e) => ({ previousMessages: client._lastSeenMessages.map((e) => ({
messageSender: e.sender, messageSender: e.sender,
diff --git a/src/client/encrypt.js b/src/client/encrypt.js diff --git a/src/client/encrypt.js b/src/client/encrypt.js
index 63cc2bd9615100bd2fd63dfe14c094aa6b8cd1c9..36df57d1196af9761d920fa285ac48f85410eaef 100644 index b9d21bab9faccd5dbf1975fc423fc55c73e906c5..99ffd76527b410e3a393181beb260108f4c63536 100644
--- a/src/client/encrypt.js --- a/src/client/encrypt.js
+++ b/src/client/encrypt.js +++ b/src/client/encrypt.js
@@ -25,7 +25,11 @@ module.exports = function (client, options) { @@ -25,7 +25,11 @@ module.exports = function (client, options) {
@ -73,24 +73,28 @@ index 63cc2bd9615100bd2fd63dfe14c094aa6b8cd1c9..36df57d1196af9761d920fa285ac48f8
} }
function onJoinServerResponse (err) { function onJoinServerResponse (err) {
diff --git a/src/client/pluginChannels.js b/src/client/pluginChannels.js
index 671eb452f31e6b5fcd57d715f1009d010160c65f..7f69f511c8fb97d431ec5125c851b49be8e2ab76 100644
--- a/src/client/pluginChannels.js
+++ b/src/client/pluginChannels.js
@@ -57,7 +57,7 @@ module.exports = function (client, options) {
try {
packet.data = proto.parsePacketBuffer(channel, packet.data).data
} catch (error) {
- client.emit('error', error)
+ client.emit('error', error, { customPayload: packet })
return
}
}
diff --git a/src/client.js b/src/client.js diff --git a/src/client.js b/src/client.js
index e369e77d055ba919e8f9da7b8e8b5dc879c74cf4..54bb9e6644388e9b6bd42b3012951875989cdf0c 100644 index 74749698f8cee05b5dc749c271544f78d06645b0..e77e0a3f41c1ee780c3abbd54b0801d248c2a07c 100644
--- a/src/client.js --- a/src/client.js
+++ b/src/client.js +++ b/src/client.js
@@ -111,7 +111,13 @@ class Client extends EventEmitter { @@ -89,10 +89,12 @@ class Client extends EventEmitter {
parsed.metadata.name = parsed.data.name
parsed.data = parsed.data.params
parsed.metadata.state = state
- debug('read packet ' + state + '.' + parsed.metadata.name)
- if (debug.enabled) {
- const s = JSON.stringify(parsed.data, null, 2)
- debug(s && s.length > 10000 ? parsed.data : s)
+ if (!globalThis.excludeCommunicationDebugEvents?.includes(parsed.metadata.name)) {
+ debug('read packet ' + state + '.' + parsed.metadata.name)
+ if (debug.enabled) {
+ const s = JSON.stringify(parsed.data, null, 2)
+ debug(s && s.length > 10000 ? parsed.data : s)
+ }
}
if (this._hasBundlePacket && parsed.metadata.name === 'bundle_delimiter') {
if (this._mcBundle.length) { // End bundle
@@ -110,7 +112,13 @@ class Client extends EventEmitter {
this._hasBundlePacket = false this._hasBundlePacket = false
} }
} else { } else {
@ -105,7 +109,7 @@ index e369e77d055ba919e8f9da7b8e8b5dc879c74cf4..54bb9e6644388e9b6bd42b3012951875
} }
}) })
} }
@@ -169,7 +175,10 @@ class Client extends EventEmitter { @@ -168,7 +176,10 @@ class Client extends EventEmitter {
} }
const onFatalError = (err) => { const onFatalError = (err) => {
@ -117,21 +121,25 @@ index e369e77d055ba919e8f9da7b8e8b5dc879c74cf4..54bb9e6644388e9b6bd42b3012951875
endSocket() endSocket()
} }
@@ -198,6 +207,10 @@ class Client extends EventEmitter { @@ -197,6 +208,8 @@ class Client extends EventEmitter {
serializer -> framer -> socket -> splitter -> deserializer */ serializer -> framer -> socket -> splitter -> deserializer */
if (this.serializer) { if (this.serializer) {
this.serializer.end() this.serializer.end()
+ setTimeout(() => { + this.socket?.end()
+ this.socket?.end() + this.socket?.emit('end')
+ this.socket?.emit('end')
+ }, 2000) // allow the serializer to finish writing
} else { } else {
if (this.socket) this.socket.end() if (this.socket) this.socket.end()
} }
@@ -243,6 +256,7 @@ class Client extends EventEmitter { @@ -238,8 +251,11 @@ class Client extends EventEmitter {
debug('writing packet ' + this.state + '.' + name)
debug(params) write (name, params) {
} if (!this.serializer.writable) { return }
- debug('writing packet ' + this.state + '.' + name)
- debug(params)
+ if (!globalThis.excludeCommunicationDebugEvents?.includes(name)) {
+ debug(`[${this.state}] from ${this.isServer ? 'server' : 'client'}: ` + name)
+ debug(params)
+ }
+ this.emit('writePacket', name, params) + this.emit('writePacket', name, params)
this.serializer.write({ name, params }) this.serializer.write({ name, params })
} }

View file

@ -1,5 +1,5 @@
diff --git a/fonts/pixelart-icons-font.css b/fonts/pixelart-icons-font.css diff --git a/fonts/pixelart-icons-font.css b/fonts/pixelart-icons-font.css
index 3b2ebe839370d96bf93ef5ca94a827f07e49378d..4f8d76be2ca6e4ddc43c68d0a6f0f69979165ab4 100644 index 3b2ebe839370d96bf93ef5ca94a827f07e49378d..103ab4d6b9f3b5c9f41d1407e3cbf4ac392fbf41 100644
--- a/fonts/pixelart-icons-font.css --- a/fonts/pixelart-icons-font.css
+++ b/fonts/pixelart-icons-font.css +++ b/fonts/pixelart-icons-font.css
@@ -1,16 +1,13 @@ @@ -1,16 +1,13 @@
@ -10,11 +10,10 @@ index 3b2ebe839370d96bf93ef5ca94a827f07e49378d..4f8d76be2ca6e4ddc43c68d0a6f0f699
+ src: + src:
url("pixelart-icons-font.woff2?t=1711815892278") format("woff2"), url("pixelart-icons-font.woff2?t=1711815892278") format("woff2"),
url("pixelart-icons-font.woff?t=1711815892278") format("woff"), 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.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-"] { [class^="pixelart-icons-font-"], [class*=" pixelart-icons-font-"] {
font-family: 'pixelart-icons-font' !important; font-family: 'pixelart-icons-font' !important;
- font-size:24px; - font-size:24px;

810
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,6 @@
import * as THREE from 'three' import * as THREE from 'three'
import { loadSkinFromUsername, loadSkinImage } from '../lib/utils/skins' import { loadSkinToCanvas } from 'skinview-utils'
import { steveTexture } from './entities' import { getLookupUrl, loadSkinImage, steveTexture } from './utils/skins'
export const getMyHand = async (image?: string, userName?: string) => { export const getMyHand = async (image?: string, userName?: string) => {
let newMap: THREE.Texture let newMap: THREE.Texture
@ -9,10 +8,7 @@ export const getMyHand = async (image?: string, userName?: string) => {
newMap = await steveTexture newMap = await steveTexture
} else { } else {
if (!image) { if (!image) {
image = await loadSkinFromUsername(userName!, 'skin') image = getLookupUrl(userName!, 'skin')
}
if (!image) {
return
} }
const { canvas } = await loadSkinImage(image) const { canvas } = await loadSkinImage(image)
newMap = new THREE.CanvasTexture(canvas) newMap = new THREE.CanvasTexture(canvas)

View file

@ -2,7 +2,6 @@ import { Vec3 } from 'vec3'
import { World } from './world' import { World } from './world'
import { getSectionGeometry, setBlockStatesData as setMesherData } from './models' import { getSectionGeometry, setBlockStatesData as setMesherData } from './models'
import { BlockStateModelInfo } from './shared' import { BlockStateModelInfo } from './shared'
import { INVISIBLE_BLOCKS } from './worldConstants'
globalThis.structuredClone ??= (value) => JSON.parse(JSON.stringify(value)) globalThis.structuredClone ??= (value) => JSON.parse(JSON.stringify(value))
@ -77,7 +76,6 @@ const handleMessage = data => {
if (data.type === 'mcData') { if (data.type === 'mcData') {
globalVar.mcData = data.mcData globalVar.mcData = data.mcData
globalVar.loadedData = data.mcData
} }
if (data.config) { if (data.config) {
@ -139,7 +137,6 @@ const handleMessage = data => {
dirtySections = new Map() dirtySections = new Map()
// todo also remove cached // todo also remove cached
globalVar.mcData = null globalVar.mcData = null
globalVar.loadedData = null
allDataReady = false allDataReady = false
break break
@ -151,30 +148,6 @@ const handleMessage = data => {
global.postMessage({ type: 'customBlockModel', chunkKey, customBlockModel }) global.postMessage({ type: 'customBlockModel', chunkKey, customBlockModel })
break break
} }
case 'getHeightmap': {
const heightmap = new Uint8Array(256)
const blockPos = new Vec3(0, 0, 0)
for (let z = 0; z < 16; z++) {
for (let x = 0; x < 16; x++) {
const blockX = x + data.x
const blockZ = z + data.z
blockPos.x = blockX
blockPos.z = blockZ
blockPos.y = world.config.worldMaxY
let block = world.getBlock(blockPos)
while (block && INVISIBLE_BLOCKS.has(block.name) && blockPos.y > world.config.worldMinY) {
blockPos.y -= 1
block = world.getBlock(blockPos)
}
const index = z * 16 + x
heightmap[index] = block ? blockPos.y : 0
}
}
postMessage({ type: 'heightmap', key: `${Math.floor(data.x / 16)},${Math.floor(data.z / 16)}`, heightmap })
break
}
// No default // No default
} }
} }

View file

@ -103,8 +103,8 @@ function tintToGl (tint) {
return [r / 255, g / 255, b / 255] return [r / 255, g / 255, b / 255]
} }
function getLiquidRenderHeight (world: World, block: WorldBlock | null, type: number, pos: Vec3, isWater: boolean, isRealWater: boolean) { function getLiquidRenderHeight (world: World, block: WorldBlock | null, type: number, pos: Vec3, isRealWater: boolean) {
if ((isWater && !isRealWater) || (block && isBlockWaterlogged(block))) return 8 / 9 if (!isRealWater || (block && isBlockWaterlogged(block))) return 8 / 9
if (!block || block.type !== type) return 1 / 9 if (!block || block.type !== type) return 1 / 9
if (block.metadata === 0) { // source block if (block.metadata === 0) { // source block
const blockAbove = world.getBlock(pos.offset(0, 1, 0)) const blockAbove = world.getBlock(pos.offset(0, 1, 0))
@ -125,19 +125,12 @@ const isCube = (block: Block) => {
})) }))
} }
const getVec = (v: Vec3, dir: Vec3) => { function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, type: number, biome: string, water: boolean, attr: Record<string, any>, isRealWater: boolean) {
for (const coord of ['x', 'y', 'z']) {
if (Math.abs(dir[coord]) > 0) v[coord] = 0
}
return v.plus(dir)
}
function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, type: number, biome: string, water: boolean, attr: MesherGeometryOutput, isRealWater: boolean) {
const heights: number[] = [] const heights: number[] = []
for (let z = -1; z <= 1; z++) { for (let z = -1; z <= 1; z++) {
for (let x = -1; x <= 1; x++) { for (let x = -1; x <= 1; x++) {
const pos = cursor.offset(x, 0, z) const pos = cursor.offset(x, 0, z)
heights.push(getLiquidRenderHeight(world, world.getBlock(pos), type, pos, water, isRealWater)) heights.push(getLiquidRenderHeight(world, world.getBlock(pos), type, pos, isRealWater))
} }
} }
const cornerHeights = [ const cornerHeights = [
@ -149,7 +142,7 @@ function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, typ
// eslint-disable-next-line guard-for-in // eslint-disable-next-line guard-for-in
for (const face in elemFaces) { for (const face in elemFaces) {
const { dir, corners, mask1, mask2 } = elemFaces[face] const { dir, corners } = elemFaces[face]
const isUp = dir[1] === 1 const isUp = dir[1] === 1
const neighborPos = cursor.offset(...dir as [number, number, number]) const neighborPos = cursor.offset(...dir as [number, number, number])
@ -187,44 +180,16 @@ function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, typ
const { su } = texture const { su } = texture
const { sv } = texture const { sv } = texture
// Get base light value for the face
const baseLight = world.getLight(neighborPos, undefined, undefined, water ? 'water' : 'lava') / 15
for (const pos of corners) { for (const pos of corners) {
const height = cornerHeights[pos[2] * 2 + pos[0]] const height = cornerHeights[pos[2] * 2 + pos[0]]
const OFFSET = 0.0001 attr.t_positions.push(
attr.t_positions!.push( (pos[0] ? 0.999 : 0.001) + (cursor.x & 15) - 8,
(pos[0] ? 1 - OFFSET : OFFSET) + (cursor.x & 15) - 8, (pos[1] ? height - 0.001 : 0.001) + (cursor.y & 15) - 8,
(pos[1] ? height - OFFSET : OFFSET) + (cursor.y & 15) - 8, (pos[2] ? 0.999 : 0.001) + (cursor.z & 15) - 8
(pos[2] ? 1 - OFFSET : OFFSET) + (cursor.z & 15) - 8
) )
attr.t_normals!.push(...dir) attr.t_normals.push(...dir)
attr.t_uvs!.push(pos[3] * su + u, pos[4] * sv * (pos[1] ? 1 : height) + v) attr.t_uvs.push(pos[3] * su + u, pos[4] * sv * (pos[1] ? 1 : height) + v)
attr.t_colors.push(tint[0], tint[1], tint[2])
let cornerLightResult = baseLight
if (world.config.smoothLighting) {
const dx = pos[0] * 2 - 1
const dy = pos[1] * 2 - 1
const dz = pos[2] * 2 - 1
const cornerDir: [number, number, number] = [dx, dy, dz]
const side1Dir: [number, number, number] = [dx * mask1[0], dy * mask1[1], dz * mask1[2]]
const side2Dir: [number, number, number] = [dx * mask2[0], dy * mask2[1], dz * mask2[2]]
const dirVec = new Vec3(...dir as [number, number, number])
const side1LightDir = getVec(new Vec3(...side1Dir), dirVec)
const side1Light = world.getLight(cursor.plus(side1LightDir)) / 15
const side2DirLight = getVec(new Vec3(...side2Dir), dirVec)
const side2Light = world.getLight(cursor.plus(side2DirLight)) / 15
const cornerLightDir = getVec(new Vec3(...cornerDir), dirVec)
const cornerLight = world.getLight(cursor.plus(cornerLightDir)) / 15
// interpolate
const lights = [side1Light, side2Light, cornerLight, baseLight]
cornerLightResult = lights.reduce((acc, cur) => acc + cur, 0) / lights.length
}
// Apply light value to tint
attr.t_colors!.push(tint[0] * cornerLightResult, tint[1] * cornerLightResult, tint[2] * cornerLightResult)
} }
} }
} }
@ -336,7 +301,7 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO:
let localShift = null as any let localShift = null as any
if (element.rotation && !needTiles) { if (element.rotation && !needTiles) {
// Rescale support for block model rotations // todo do we support rescale?
localMatrix = buildRotationMatrix( localMatrix = buildRotationMatrix(
element.rotation.axis, element.rotation.axis,
element.rotation.angle element.rotation.angle
@ -349,37 +314,6 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO:
element.rotation.origin 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[] = [] const aos: number[] = []
@ -489,19 +423,13 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO:
if (!needTiles) { if (!needTiles) {
if (doAO && aos[0] + aos[3] >= aos[1] + aos[2]) { if (doAO && aos[0] + aos[3] >= aos[1] + aos[2]) {
attr.indices[attr.indicesCount++] = ndx attr.indices.push(
attr.indices[attr.indicesCount++] = ndx + 3 ndx, ndx + 3, ndx + 2, ndx, ndx + 1, ndx + 3
attr.indices[attr.indicesCount++] = ndx + 2 )
attr.indices[attr.indicesCount++] = ndx
attr.indices[attr.indicesCount++] = ndx + 1
attr.indices[attr.indicesCount++] = ndx + 3
} else { } else {
attr.indices[attr.indicesCount++] = ndx attr.indices.push(
attr.indices[attr.indicesCount++] = ndx + 1 ndx, ndx + 1, ndx + 2, ndx + 2, ndx + 1, ndx + 3
attr.indices[attr.indicesCount++] = ndx + 2 )
attr.indices[attr.indicesCount++] = ndx + 2
attr.indices[attr.indicesCount++] = ndx + 1
attr.indices[attr.indicesCount++] = ndx + 3
} }
} }
} }
@ -519,7 +447,7 @@ const isBlockWaterlogged = (block: Block) => {
} }
let unknownBlockModel: BlockModelPartsResolved let unknownBlockModel: BlockModelPartsResolved
export function getSectionGeometry (sx: number, sy: number, sz: number, world: World) { export function getSectionGeometry (sx, sy, sz, world: World) {
let delayedRender = [] as Array<() => void> let delayedRender = [] as Array<() => void>
const attr: MesherGeometryOutput = { const attr: MesherGeometryOutput = {
@ -535,13 +463,12 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W
t_colors: [], t_colors: [],
t_uvs: [], t_uvs: [],
indices: [], indices: [],
indicesCount: 0, // Track current index position
using32Array: true,
tiles: {}, tiles: {},
// todo this can be removed here // todo this can be removed here
heads: {}, heads: {},
signs: {}, signs: {},
// isFull: true, // isFull: true,
highestBlocks: {},
hadErrors: false, hadErrors: false,
blocksCount: 0 blocksCount: 0
} }
@ -551,6 +478,12 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W
for (cursor.z = sz; cursor.z < sz + 16; cursor.z++) { for (cursor.z = sz; cursor.z < sz + 16; cursor.z++) {
for (cursor.x = sx; cursor.x < sx + 16; cursor.x++) { for (cursor.x = sx; cursor.x < sx + 16; cursor.x++) {
let block = world.getBlock(cursor, blockProvider, attr)! let block = world.getBlock(cursor, blockProvider, attr)!
if (!INVISIBLE_BLOCKS.has(block.name)) {
const highest = attr.highestBlocks[`${cursor.x},${cursor.z}`]
if (!highest || highest.y < cursor.y) {
attr.highestBlocks[`${cursor.x},${cursor.z}`] = { y: cursor.y, stateId: block.stateId, biomeId: block.biome.id }
}
}
if (INVISIBLE_BLOCKS.has(block.name)) continue if (INVISIBLE_BLOCKS.has(block.name)) continue
if ((block.name.includes('_sign') || block.name === 'sign') && !world.config.disableSignsMapsSupport) { if ((block.name.includes('_sign') || block.name === 'sign') && !world.config.disableSignsMapsSupport) {
const key = `${cursor.x},${cursor.y},${cursor.z}` const key = `${cursor.x},${cursor.y},${cursor.z}`
@ -672,19 +605,12 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W
let ndx = attr.positions.length / 3 let ndx = attr.positions.length / 3
for (let i = 0; i < attr.t_positions!.length / 12; i++) { for (let i = 0; i < attr.t_positions!.length / 12; i++) {
attr.indices[attr.indicesCount++] = ndx attr.indices.push(
attr.indices[attr.indicesCount++] = ndx + 1 ndx, ndx + 1, ndx + 2, ndx + 2, ndx + 1, ndx + 3,
attr.indices[attr.indicesCount++] = ndx + 2 // eslint-disable-next-line @stylistic/function-call-argument-newline
attr.indices[attr.indicesCount++] = ndx + 2 // back face
attr.indices[attr.indicesCount++] = ndx + 1 ndx, ndx + 2, ndx + 1, ndx + 2, ndx + 3, ndx + 1
attr.indices[attr.indicesCount++] = ndx + 3 )
// back face
attr.indices[attr.indicesCount++] = ndx
attr.indices[attr.indicesCount++] = ndx + 2
attr.indices[attr.indicesCount++] = ndx + 1
attr.indices[attr.indicesCount++] = ndx + 2
attr.indices[attr.indicesCount++] = ndx + 3
attr.indices[attr.indicesCount++] = ndx + 1
ndx += 4 ndx += 4
} }
@ -702,12 +628,6 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W
attr.normals = new Float32Array(attr.normals) as any attr.normals = new Float32Array(attr.normals) as any
attr.colors = new Float32Array(attr.colors) as any attr.colors = new Float32Array(attr.colors) as any
attr.uvs = new Float32Array(attr.uvs) as any attr.uvs = new Float32Array(attr.uvs) as any
attr.using32Array = arrayNeedsUint32(attr.indices)
if (attr.using32Array) {
attr.indices = new Uint32Array(attr.indices)
} else {
attr.indices = new Uint16Array(attr.indices)
}
if (needTiles) { if (needTiles) {
delete attr.positions delete attr.positions
@ -719,21 +639,6 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W
return attr return attr
} }
// copied from three.js
function arrayNeedsUint32 (array) {
// assumes larger values usually on last
for (let i = array.length - 1; i >= 0; -- i) {
if (array[i] >= 65_535) return true // account for PRIMITIVE_RESTART_FIXED_INDEX, #24565
}
return false
}
export const setBlockStatesData = (blockstatesModels, blocksAtlas: any, _needTiles = false, useUnknownBlockModel = true, version = 'latest') => { export const setBlockStatesData = (blockstatesModels, blocksAtlas: any, _needTiles = false, useUnknownBlockModel = true, version = 'latest') => {
blockProvider = worldBlockProvider(blockstatesModels, blocksAtlas, version) blockProvider = worldBlockProvider(blockstatesModels, blocksAtlas, version)
globalThis.blockProvider = blockProvider globalThis.blockProvider = blockProvider

View file

@ -3,13 +3,11 @@ import { BlockType } from '../../../playground/shared'
// only here for easier testing // only here for easier testing
export const defaultMesherConfig = { export const defaultMesherConfig = {
version: '', version: '',
worldMaxY: 256,
worldMinY: 0,
enableLighting: true, enableLighting: true,
skyLight: 15, skyLight: 15,
smoothLighting: true, smoothLighting: true,
outputFormat: 'threeJs' as 'threeJs' | 'webgpu', outputFormat: 'threeJs' as 'threeJs' | 'webgpu',
// textureSize: 1024, // for testing textureSize: 1024, // for testing
debugModelVariant: undefined as undefined | number[], debugModelVariant: undefined as undefined | number[],
clipWorldBelowY: undefined as undefined | number, clipWorldBelowY: undefined as undefined | number,
disableSignsMapsSupport: false disableSignsMapsSupport: false
@ -35,27 +33,17 @@ export type MesherGeometryOutput = {
t_colors?: number[], t_colors?: number[],
t_uvs?: number[], t_uvs?: number[],
indices: Uint32Array | Uint16Array | number[], indices: number[],
indicesCount: number,
using32Array: boolean,
tiles: Record<string, BlockType>, tiles: Record<string, BlockType>,
heads: Record<string, any>, heads: Record<string, any>,
signs: Record<string, any>, signs: Record<string, any>,
// isFull: boolean // isFull: boolean
highestBlocks: Record<string, HighestBlockInfo>
hadErrors: boolean hadErrors: boolean
blocksCount: number blocksCount: number
customBlockModels?: CustomBlockModels customBlockModels?: CustomBlockModels
} }
export interface MesherMainEvents {
geometry: { type: 'geometry'; key: string; geometry: MesherGeometryOutput; workerIndex: number };
sectionFinished: { type: 'sectionFinished'; key: string; workerIndex: number; processTime?: number };
blockStateModelInfo: { type: 'blockStateModelInfo'; info: Record<string, BlockStateModelInfo> };
heightmap: { type: 'heightmap'; key: string; heightmap: Uint8Array };
}
export type MesherMainEvent = MesherMainEvents[keyof MesherMainEvents]
export type HighestBlockInfo = { y: number, stateId: number | undefined, biomeId: number | undefined } export type HighestBlockInfo = { y: number, stateId: number | undefined, biomeId: number | undefined }
export type BlockStateModelInfo = { export type BlockStateModelInfo = {

View file

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

View file

@ -1,131 +0,0 @@
/* eslint-disable no-await-in-loop */
import { Vec3 } from 'vec3'
// import log from '../../../../../Downloads/mesher (2).log'
import { WorldRendererCommon } from './worldrendererCommon'
const log = ''
export class MesherLogReader {
chunksToReceive: Array<{
x: number
z: number
chunkLength: number
}> = []
messagesQueue: Array<{
fromWorker: boolean
workerIndex: number
message: any
}> = []
sectionFinishedToReceive = null as {
messagesLeft: string[]
resolve: () => void
} | null
replayStarted = false
constructor (private readonly worldRenderer: WorldRendererCommon) {
this.parseMesherLog()
}
chunkReceived (x: number, z: number, chunkLength: number) {
// remove existing chunks with same x and z
const existingChunkIndex = this.chunksToReceive.findIndex(chunk => chunk.x === x && chunk.z === z)
if (existingChunkIndex === -1) {
// console.error('Chunk not found', x, z)
} else {
// warn if chunkLength is different
if (this.chunksToReceive[existingChunkIndex].chunkLength !== chunkLength) {
// console.warn('Chunk length mismatch', x, z, this.chunksToReceive[existingChunkIndex].chunkLength, chunkLength)
}
// remove chunk
this.chunksToReceive = this.chunksToReceive.filter((chunk, index) => chunk.x !== x || chunk.z !== z)
}
this.maybeStartReplay()
}
async maybeStartReplay () {
if (this.chunksToReceive.length !== 0 || this.replayStarted) return
const lines = log.split('\n')
console.log('starting replay')
this.replayStarted = true
const waitForWorkersMessages = async () => {
if (!this.sectionFinishedToReceive) return
await new Promise<void>(resolve => {
this.sectionFinishedToReceive!.resolve = resolve
})
}
for (const line of lines) {
if (line.includes('dispatchMessages dirty')) {
await waitForWorkersMessages()
this.worldRenderer.stopMesherMessagesProcessing = true
const message = JSON.parse(line.slice(line.indexOf('{'), line.lastIndexOf('}') + 1))
if (!message.value) continue
const index = line.split(' ')[1]
const type = line.split(' ')[3]
// console.log('sending message', message.x, message.y, message.z)
this.worldRenderer.forceCallFromMesherReplayer = true
this.worldRenderer.setSectionDirty(new Vec3(message.x, message.y, message.z), message.value)
this.worldRenderer.forceCallFromMesherReplayer = false
}
if (line.includes('-> blockUpdate')) {
await waitForWorkersMessages()
this.worldRenderer.stopMesherMessagesProcessing = true
const message = JSON.parse(line.slice(line.indexOf('{'), line.lastIndexOf('}') + 1))
this.worldRenderer.forceCallFromMesherReplayer = true
this.worldRenderer.setBlockStateIdInner(new Vec3(message.pos.x, message.pos.y, message.pos.z), message.stateId)
this.worldRenderer.forceCallFromMesherReplayer = false
}
if (line.includes(' sectionFinished ')) {
if (!this.sectionFinishedToReceive) {
console.log('starting worker message processing validating')
this.worldRenderer.stopMesherMessagesProcessing = false
this.sectionFinishedToReceive = {
messagesLeft: [],
resolve: () => {
this.sectionFinishedToReceive = null
}
}
}
const parts = line.split(' ')
const coordsPart = parts.find(part => part.split(',').length === 3)
if (!coordsPart) throw new Error(`no coords part found ${line}`)
const [x, y, z] = coordsPart.split(',').map(Number)
this.sectionFinishedToReceive.messagesLeft.push(`${x},${y},${z}`)
}
}
}
workerMessageReceived (type: string, message: any) {
if (type === 'sectionFinished') {
const { key } = message
if (!this.sectionFinishedToReceive) {
console.warn(`received sectionFinished message but no sectionFinishedToReceive ${key}`)
return
}
const idx = this.sectionFinishedToReceive.messagesLeft.indexOf(key)
if (idx === -1) {
console.warn(`received sectionFinished message for non-outstanding section ${key}`)
return
}
this.sectionFinishedToReceive.messagesLeft.splice(idx, 1)
if (this.sectionFinishedToReceive.messagesLeft.length === 0) {
this.sectionFinishedToReceive.resolve()
}
}
}
parseMesherLog () {
const lines = log.split('\n')
for (const line of lines) {
if (line.startsWith('-> chunk')) {
const chunk = JSON.parse(line.slice('-> chunk'.length))
this.chunksToReceive.push(chunk)
continue
}
}
}
}

View file

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

View file

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

View file

@ -1,59 +1,27 @@
import { loadSkinToCanvas } from 'skinview-utils' import { loadSkinToCanvas } from 'skinview-utils'
import { createCanvas, loadImageFromUrl } from '../utils' import * as THREE from 'three'
import stevePng from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png'
export { default as stevePngUrl } from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png' // eslint-disable-next-line unicorn/prefer-export-from
export const stevePngUrl = stevePng
export const steveTexture = new THREE.TextureLoader().loadAsync(stevePng)
const config = { export async function loadImageFromUrl (imageUrl: string): Promise<HTMLImageElement> {
apiEnabled: true, const img = new Image()
img.src = imageUrl
await new Promise<void>(resolve => {
img.onload = () => resolve()
})
return img
} }
export const setSkinsConfig = (newConfig: Partial<typeof config>) => { export function getLookupUrl (username: string, type: 'skin' | 'cape'): string {
Object.assign(config, newConfig) return `https://mulv.tycrek.dev/api/lookup?username=${username}&type=${type}`
} }
export async function loadSkinFromUsername (username: string, type: 'skin' | 'cape'): Promise<string | undefined> { export async function loadSkinImage (skinUrl: string): Promise<{ canvas: HTMLCanvasElement, image: HTMLImageElement }> {
if (!config.apiEnabled) return
if (type === 'cape') return
const url = `https://playerdb.co/api/player/minecraft/${username}`
const response = await fetch(url)
if (!response.ok) return
const data: {
data: {
player: {
skin_texture: string
}
}
} = await response.json()
return data.data.player.skin_texture
}
export const parseSkinTexturesValue = (value: string) => {
const decodedData: {
textures: {
SKIN: {
url: string
}
}
} = JSON.parse(Buffer.from(value, 'base64').toString())
return decodedData.textures?.SKIN?.url
}
export async function loadSkinImage (skinUrl: string): Promise<{ canvas: OffscreenCanvas, image: ImageBitmap }> {
if (!skinUrl.startsWith('data:')) {
skinUrl = await fetchAndConvertBase64Skin(skinUrl.replace('http://', 'https://'))
}
const image = await loadImageFromUrl(skinUrl) const image = await loadImageFromUrl(skinUrl)
const skinCanvas = createCanvas(64, 64) const skinCanvas = document.createElement('canvas')
loadSkinToCanvas(skinCanvas, image) loadSkinToCanvas(skinCanvas, image)
return { canvas: skinCanvas, image } return { canvas: skinCanvas, image }
} }
const fetchAndConvertBase64Skin = async (skinUrl: string) => {
const response = await fetch(skinUrl, { })
const arrayBuffer = await response.arrayBuffer()
const base64 = Buffer.from(arrayBuffer).toString('base64')
return `data:image/png;base64,${base64}`
}

View file

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

View file

@ -7,65 +7,50 @@ import { Vec3 } from 'vec3'
import { BotEvents } from 'mineflayer' import { BotEvents } from 'mineflayer'
import { proxy } from 'valtio' import { proxy } from 'valtio'
import TypedEmitter from 'typed-emitter' import TypedEmitter from 'typed-emitter'
import { Biome } from 'minecraft-data' import { getItemFromBlock } from '../../../src/chatUtils'
import { delayedIterator } from '../../playground/shared' import { delayedIterator } from '../../playground/shared'
import { playerState } from '../../../src/mineflayer/playerState'
import { chunkPos } from './simpleUtils' import { chunkPos } from './simpleUtils'
export type ChunkPosKey = string // like '16,16' export type ChunkPosKey = string
type ChunkPos = { x: number, z: number } // like { x: 16, z: 16 } type ChunkPos = { x: number, z: number }
export type WorldDataEmitterEvents = { export type WorldDataEmitterEvents = {
chunkPosUpdate: (data: { pos: Vec3 }) => void chunkPosUpdate: (data: { pos: Vec3 }) => void
blockUpdate: (data: { pos: Vec3, stateId: number }) => void blockUpdate: (data: { pos: Vec3, stateId: number }) => void
entity: (data: any) => void entity: (data: any) => void
entityMoved: (data: any) => void entityMoved: (data: any) => void
playerEntity: (data: any) => void
time: (data: number) => void time: (data: number) => void
renderDistance: (viewDistance: number) => void renderDistance: (viewDistance: number) => void
blockEntities: (data: Record<string, any> | { blockEntities: Record<string, any> }) => void blockEntities: (data: Record<string, any> | { blockEntities: Record<string, any> }) => void
listening: () => void
markAsLoaded: (data: { x: number, z: number }) => void markAsLoaded: (data: { x: number, z: number }) => void
unloadChunk: (data: { x: number, z: number }) => void unloadChunk: (data: { x: number, z: number }) => void
loadChunk: (data: { x: number, z: number, chunk: string, blockEntities: any, worldConfig: any, isLightUpdate: boolean }) => void loadChunk: (data: { x: number, z: number, chunk: string, blockEntities: any, worldConfig: any, isLightUpdate: boolean }) => void
updateLight: (data: { pos: Vec3 }) => void updateLight: (data: { pos: Vec3 }) => void
onWorldSwitch: () => void onWorldSwitch: () => void
end: () => void end: () => void
biomeUpdate: (data: { biome: Biome }) => void
biomeReset: () => void
}
export class WorldDataEmitterWorker extends (EventEmitter as new () => TypedEmitter<WorldDataEmitterEvents>) {
static readonly restorerName = 'WorldDataEmitterWorker'
} }
/**
* Usually connects to mineflayer bot and emits world data (chunks, entities)
* It's up to the consumer to serialize the data if needed
*/
export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<WorldDataEmitterEvents>) { export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<WorldDataEmitterEvents>) {
spiralNumber = 0 private loadedChunks: Record<ChunkPosKey, boolean>
gotPanicLastTime = false private readonly lastPos: Vec3
panicChunksReload = () => {}
loadedChunks: Record<ChunkPosKey, boolean>
private inLoading = false
private chunkReceiveTimes: number[] = []
private lastChunkReceiveTime = 0
public lastChunkReceiveTimeAvg = 0
private panicTimeout?: NodeJS.Timeout
readonly lastPos: Vec3
private eventListeners: Record<string, any> = {} private eventListeners: Record<string, any> = {}
private readonly emitter: WorldDataEmitter private readonly emitter: WorldDataEmitter
debugChunksInfo: Record<ChunkPosKey, {
loads: Array<{
dataLength: number
reason: string
time: number
}>
// blockUpdates: number
}> = {}
waitingSpiralChunksLoad = {} as Record<ChunkPosKey, (value: boolean) => void>
addWaitTime = 1 addWaitTime = 1
/* config */ keepChunksDistance = 0 /* config */ keepChunksDistance = 0
/* config */ isPlayground = false /* config */ isPlayground = false
/* config */ allowPositionUpdate = true /* config */ allowPositionUpdate = true
public reactive = proxy({
cursorBlock: null as Vec3 | null,
cursorBlockBreakingStage: null as number | null,
})
constructor (public world: typeof __type_bot['world'], public viewDistance: number, position: Vec3 = new Vec3(0, 0, 0)) { constructor (public world: typeof __type_bot['world'], public viewDistance: number, position: Vec3 = new Vec3(0, 0, 0)) {
// eslint-disable-next-line constructor-super // eslint-disable-next-line constructor-super
super() super()
@ -78,12 +63,12 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
setBlockStateId (position: Vec3, stateId: number) { setBlockStateId (position: Vec3, stateId: number) {
const val = this.world.setBlockStateId(position, stateId) as Promise<void> | void const val = this.world.setBlockStateId(position, stateId) as Promise<void> | void
if (val) throw new Error('setBlockStateId returned promise (not supported)') if (val) throw new Error('setBlockStateId returned promise (not supported)')
// const chunkX = Math.floor(position.x / 16) const chunkX = Math.floor(position.x / 16)
// const chunkZ = Math.floor(position.z / 16) const chunkZ = Math.floor(position.z / 16)
// if (!this.loadedChunks[`${chunkX},${chunkZ}`] && !this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`]) { if (!this.loadedChunks[`${chunkX},${chunkZ}`]) {
// void this.loadChunk({ x: chunkX, z: chunkZ }) void this.loadChunk({ x: chunkX, z: chunkZ })
// return return
// } }
this.emit('blockUpdate', { pos: position, stateId }) this.emit('blockUpdate', { pos: position, stateId })
} }
@ -94,28 +79,13 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
} }
listenToBot (bot: typeof __type_bot) { listenToBot (bot: typeof __type_bot) {
const entitiesObjectData = new Map<string, number>()
bot._client.prependListener('spawn_entity', (data) => {
if (data.objectData && data.entityId !== undefined) {
entitiesObjectData.set(data.entityId, data.objectData)
}
})
const emitEntity = (e, name = 'entity') => { const emitEntity = (e, name = 'entity') => {
if (!e) return if (!e || e === bot.entity) 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 if (!e.name) return // mineflayer received update for not spawned entity
e.objectData = entitiesObjectData.get(e.id)
this.emitter.emit(name as any, { this.emitter.emit(name as any, {
...e, ...e,
pos: e.position, pos: e.position,
username: e.username, username: e.username,
team: bot.teamMap[e.username] || bot.teamMap[e.uuid],
// set debugTree (obj) { // set debugTree (obj) {
// e.debugTree = obj // e.debugTree = obj
// } // }
@ -134,9 +104,6 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
entityUpdate (e: any) { entityUpdate (e: any) {
emitEntity(e) emitEntity(e)
}, },
entityEquip (e: any) {
emitEntity(e)
},
entityMoved (e: any) { entityMoved (e: any) {
emitEntity(e, 'entityMoved') emitEntity(e, 'entityMoved')
}, },
@ -144,19 +111,7 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
this.emitter.emit('entity', { id: e.id, delete: true }) this.emitter.emit('entity', { id: e.id, delete: true })
}, },
chunkColumnLoad: (pos: Vec3) => { chunkColumnLoad: (pos: Vec3) => {
const now = performance.now() void this.loadChunk(pos)
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) => { chunkColumnUnload: (pos: Vec3) => {
this.unloadChunk(pos) this.unloadChunk(pos)
@ -168,29 +123,36 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
time: () => { time: () => {
this.emitter.emit('time', bot.time.timeOfDay) this.emitter.emit('time', bot.time.timeOfDay)
}, },
respawn: () => {
this.emitter.emit('onWorldSwitch')
},
end: () => { end: () => {
this.emitter.emit('end') this.emitter.emit('end')
}, },
// when dimension might change
login: () => {
void this.updatePosition(bot.entity.position, true)
this.emitter.emit('playerEntity', bot.entity)
},
respawn: () => {
void this.updatePosition(bot.entity.position, true)
this.emitter.emit('playerEntity', bot.entity)
this.emitter.emit('onWorldSwitch')
},
} satisfies Partial<BotEvents> } satisfies Partial<BotEvents>
bot._client.on('update_light', ({ chunkX, chunkZ }) => { bot._client.on('update_light', ({ chunkX, chunkZ }) => {
const chunkPos = new Vec3(chunkX * 16, 0, chunkZ * 16) const chunkPos = new Vec3(chunkX * 16, 0, chunkZ * 16)
if (!this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`] && this.loadedChunks[`${chunkX},${chunkZ}`]) { void this.loadChunk(chunkPos, true)
void this.loadChunk(chunkPos, true, 'update_light')
}
}) })
this.emitter.on('listening', () => {
this.emitter.emit('blockEntities', new Proxy({}, {
get (_target, posKey, receiver) {
if (typeof posKey !== 'string') return
const [x, y, z] = posKey.split(',').map(Number)
return bot.world.getBlock(new Vec3(x, y, z))?.entity
},
}))
this.emitter.emit('renderDistance', this.viewDistance)
this.emitter.emit('time', bot.time.timeOfDay)
})
// node.js stream data event pattern
if (this.emitter.listenerCount('blockEntities')) {
this.emitter.emit('listening')
}
for (const [evt, listener] of Object.entries(this.eventListeners)) { for (const [evt, listener] of Object.entries(this.eventListeners)) {
bot.on(evt as any, listener) bot.on(evt as any, listener)
} }
@ -204,16 +166,8 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
console.error('error processing entity', err) console.error('error processing entity', err)
} }
} }
}
emitterGotConnected () { void this.init(bot.entity.position)
this.emitter.emit('blockEntities', new Proxy({}, {
get (_target, posKey, receiver) {
if (typeof posKey !== 'string') return
const [x, y, z] = posKey.split(',').map(Number)
return bot.world.getBlock(new Vec3(x, y, z))?.entity
},
}))
} }
removeListenersFromBot (bot: import('mineflayer').Bot) { removeListenersFromBot (bot: import('mineflayer').Bot) {
@ -225,71 +179,20 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
async init (pos: Vec3) { async init (pos: Vec3) {
this.updateViewDistance(this.viewDistance) this.updateViewDistance(this.viewDistance)
this.emitter.emit('chunkPosUpdate', { pos }) this.emitter.emit('chunkPosUpdate', { pos })
if (bot?.time?.timeOfDay) {
this.emitter.emit('time', bot.time.timeOfDay)
}
if (bot?.entity) {
this.emitter.emit('playerEntity', bot.entity)
}
this.emitterGotConnected()
const [botX, botZ] = chunkPos(pos) const [botX, botZ] = chunkPos(pos)
const positions = generateSpiralMatrix(this.viewDistance).map(([x, z]) => new Vec3((botX + x) * 16, 0, (botZ + z) * 16)) const positions = generateSpiralMatrix(this.viewDistance).map(([x, z]) => new Vec3((botX + x) * 16, 0, (botZ + z) * 16))
this.lastPos.update(pos) this.lastPos.update(pos)
await this._loadChunks(positions, pos) await this._loadChunks(positions)
} }
chunkProgress () { async _loadChunks (positions: Vec3[], sliceSize = 5) {
if (this.panicTimeout) clearTimeout(this.panicTimeout) const promises = [] as Array<Promise<void>>
if (this.chunkReceiveTimes.length >= 5) { await delayedIterator(positions, this.addWaitTime, (pos) => {
const avgReceiveTime = this.chunkReceiveTimes.reduce((a, b) => a + b, 0) / this.chunkReceiveTimes.length promises.push(this.loadChunk(pos))
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]
}
let continueLoading = true
this.inLoading = true
await delayedIterator(positions, this.addWaitTime, async (pos) => {
if (!continueLoading || this.loadedChunks[`${pos.x},${pos.z}`]) return
// Wait for chunk to be available from server
if (!this.world.getColumnAt(pos)) {
continueLoading = await new Promise<boolean>(resolve => {
this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`] = resolve
})
}
if (!continueLoading) return
await this.loadChunk(pos, undefined, `spiral ${spiralNumber} from ${centerPos.x},${centerPos.z}`)
this.chunkProgress()
}) })
if (this.panicTimeout) clearTimeout(this.panicTimeout) await Promise.all(promises)
this.inLoading = false
this.gotPanicLastTime = false
this.chunkReceiveTimes = []
this.lastChunkReceiveTime = 0
} }
readdDebug () { readdDebug () {
@ -311,9 +214,8 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
// debugGotChunkLatency = [] as number[] // debugGotChunkLatency = [] as number[]
// lastTime = 0 // lastTime = 0
async loadChunk (pos: ChunkPos, isLightUpdate = false, reason = 'spiral') { async loadChunk (pos: ChunkPos, isLightUpdate = false) {
const [botX, botZ] = chunkPos(this.lastPos) const [botX, botZ] = chunkPos(this.lastPos)
const dx = Math.abs(botX - Math.floor(pos.x / 16)) const dx = Math.abs(botX - Math.floor(pos.x / 16))
const dz = Math.abs(botZ - Math.floor(pos.z / 16)) const dz = Math.abs(botZ - Math.floor(pos.z / 16))
if (dx <= this.viewDistance && dz <= this.viewDistance) { if (dx <= this.viewDistance && dz <= this.viewDistance) {
@ -333,15 +235,6 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
//@ts-expect-error //@ts-expect-error
this.emitter.emit('loadChunk', { x: pos.x, z: pos.z, chunk, blockEntities: column.blockEntities, worldConfig, isLightUpdate }) this.emitter.emit('loadChunk', { x: pos.x, z: pos.z, chunk, blockEntities: column.blockEntities, worldConfig, isLightUpdate })
this.loadedChunks[`${pos.x},${pos.z}`] = true this.loadedChunks[`${pos.x},${pos.z}`] = true
this.debugChunksInfo[`${pos.x},${pos.z}`] ??= {
loads: []
}
this.debugChunksInfo[`${pos.x},${pos.z}`].loads.push({
dataLength: chunk.length,
reason,
time: Date.now(),
})
} else if (this.isPlayground) { // don't allow in real worlds pre-flag chunks as loaded to avoid race condition when the chunk might still be loading. In playground it's assumed we always pre-load all chunks first } else if (this.isPlayground) { // don't allow in real worlds pre-flag chunks as loaded to avoid race condition when the chunk might still be loading. In playground it's assumed we always pre-load all chunks first
this.emitter.emit('markAsLoaded', { x: pos.x, z: pos.z }) this.emitter.emit('markAsLoaded', { x: pos.x, z: pos.z })
} }
@ -360,46 +253,14 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
unloadChunk (pos: ChunkPos) { unloadChunk (pos: ChunkPos) {
this.emitter.emit('unloadChunk', { x: pos.x, z: pos.z }) this.emitter.emit('unloadChunk', { x: pos.x, z: pos.z })
delete this.loadedChunks[`${pos.x},${pos.z}`] delete this.loadedChunks[`${pos.x},${pos.z}`]
delete this.debugChunksInfo[`${pos.x},${pos.z}`]
} }
lastBiomeId: number | null = null
udpateBiome (pos: Vec3) {
try {
const biomeId = this.world.getBiome(pos)
if (biomeId !== this.lastBiomeId) {
this.lastBiomeId = biomeId
const biomeData = loadedData.biomes[biomeId]
if (biomeData) {
this.emitter.emit('biomeUpdate', {
biome: biomeData
})
} else {
// unknown biome
this.emitter.emit('biomeReset')
}
}
} catch (e) {
console.error('error updating biome', e)
}
}
lastPosCheck: Vec3 | null = null
async updatePosition (pos: Vec3, force = false) { async updatePosition (pos: Vec3, force = false) {
if (!this.allowPositionUpdate) return if (!this.allowPositionUpdate) return
const posFloored = pos.floored()
if (!force && this.lastPosCheck && this.lastPosCheck.equals(posFloored)) return
this.lastPosCheck = posFloored
this.udpateBiome(pos)
const [lastX, lastZ] = chunkPos(this.lastPos) const [lastX, lastZ] = chunkPos(this.lastPos)
const [botX, botZ] = chunkPos(pos) const [botX, botZ] = chunkPos(pos)
if (lastX !== botX || lastZ !== botZ || force) { if (lastX !== botX || lastZ !== botZ || force) {
this.emitter.emit('chunkPosUpdate', { pos }) this.emitter.emit('chunkPosUpdate', { pos })
// unload chunks that are no longer in view
const newViewToUnload = new ViewRect(botX, botZ, this.viewDistance + this.keepChunksDistance) const newViewToUnload = new ViewRect(botX, botZ, this.viewDistance + this.keepChunksDistance)
const chunksToUnload: Vec3[] = [] const chunksToUnload: Vec3[] = []
for (const coords of Object.keys(this.loadedChunks)) { for (const coords of Object.keys(this.loadedChunks)) {
@ -411,18 +272,17 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
chunksToUnload.push(p) chunksToUnload.push(p)
} }
} }
console.log('unloading', chunksToUnload.length, 'total now', Object.keys(this.loadedChunks).length)
for (const p of chunksToUnload) { for (const p of chunksToUnload) {
this.unloadChunk(p) this.unloadChunk(p)
} }
// load new chunks
const positions = generateSpiralMatrix(this.viewDistance).map(([x, z]) => { const positions = generateSpiralMatrix(this.viewDistance).map(([x, z]) => {
const pos = new Vec3((botX + x) * 16, 0, (botZ + z) * 16) const pos = new Vec3((botX + x) * 16, 0, (botZ + z) * 16)
if (!this.loadedChunks[`${pos.x},${pos.z}`]) return pos if (!this.loadedChunks[`${pos.x},${pos.z}`]) return pos
return undefined! return undefined!
}).filter(a => !!a) }).filter(a => !!a)
this.lastPos.update(pos) this.lastPos.update(pos)
void this._loadChunks(positions, pos) void this._loadChunks(positions)
} else { } else {
this.emitter.emit('chunkPosUpdate', { pos }) // todo-low this.emitter.emit('chunkPosUpdate', { pos }) // todo-low
this.lastPos.update(pos) this.lastPos.update(pos)

View file

@ -1,96 +1,60 @@
/* eslint-disable guard-for-in */ /* eslint-disable guard-for-in */
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import { Vec3 } from 'vec3' import { Vec3 } from 'vec3'
import * as THREE from 'three'
import mcDataRaw from 'minecraft-data/data.js' // note: using alias import mcDataRaw from 'minecraft-data/data.js' // note: using alias
import TypedEmitter from 'typed-emitter' import TypedEmitter from 'typed-emitter'
import { ItemsRenderer } from 'mc-assets/dist/itemsRenderer'
import { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider' import { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider'
import { generateSpiralMatrix } from 'flying-squid/dist/utils' import { generateSpiralMatrix } from 'flying-squid/dist/utils'
import { subscribeKey } from 'valtio/utils' import { subscribeKey } from 'valtio/utils'
import { proxy } from 'valtio'
import { dynamicMcDataFiles } from '../../buildMesherConfig.mjs' import { dynamicMcDataFiles } from '../../buildMesherConfig.mjs'
import type { ResourcesManagerTransferred } from '../../../src/resourcesManager' import { toMajorVersion } from '../../../src/utils'
import { ResourcesManager } from '../../../src/resourcesManager'
import { DisplayWorldOptions, GraphicsInitOptions, RendererReactiveState } from '../../../src/appViewer' import { DisplayWorldOptions, GraphicsInitOptions, RendererReactiveState } from '../../../src/appViewer'
import { SoundSystem } from '../three/threeJsSound' import { SoundSystem } from '../three/threeJsSound'
import { buildCleanupDecorator } from './cleanupDecorator' import { buildCleanupDecorator } from './cleanupDecorator'
import { HighestBlockInfo, CustomBlockModels, BlockStateModelInfo, getBlockAssetsCacheKey, MesherConfig, MesherMainEvent } from './mesher/shared' import { HighestBlockInfo, MesherGeometryOutput, CustomBlockModels, BlockStateModelInfo, getBlockAssetsCacheKey, MesherConfig } from './mesher/shared'
import { chunkPos } from './simpleUtils' import { chunkPos } from './simpleUtils'
import { addNewStat, removeAllStats, updatePanesVisibility, updateStatText } from './ui/newStats' import { addNewStat, removeAllStats, removeStat, updatePanesVisibility, updateStatText } from './ui/newStats'
import { WorldDataEmitterWorker } from './worldDataEmitter' import { WorldDataEmitter } from './worldDataEmitter'
import { getPlayerStateUtils, PlayerStateReactive, PlayerStateRenderer, PlayerStateUtils } from './basePlayerState' import { IPlayerState } from './basePlayerState'
import { MesherLogReader } from './mesherlogReader'
import { setSkinsConfig } from './utils/skins'
function mod (x, n) { function mod (x, n) {
return ((x % n) + n) % n return ((x % n) + n) % n
} }
const toMajorVersion = version => {
const [a, b] = (String(version)).split('.')
return `${a}.${b}`
}
export const worldCleanup = buildCleanupDecorator('resetWorld') export const worldCleanup = buildCleanupDecorator('resetWorld')
export const defaultWorldRendererConfig = { export const defaultWorldRendererConfig = {
// Debug settings
showChunkBorders: false, showChunkBorders: false,
enableDebugOverlay: false,
// Performance settings
mesherWorkers: 4, mesherWorkers: 4,
addChunksBatchWaitTime: 200, isPlayground: false,
_experimentalSmoothChunkLoading: true, renderEars: true,
_renderByChunks: false, // game renderer setting actually
showHand: false,
// Rendering engine settings viewBobbing: false,
dayCycle: true, extraBlockRenderers: true,
clipWorldBelowY: undefined as number | undefined,
smoothLighting: true, smoothLighting: true,
enableLighting: true, enableLighting: true,
starfield: true, starfield: true,
defaultSkybox: true, addChunksBatchWaitTime: 200,
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, vrSupport: true,
vrPageGameRendering: true, renderEntities: true,
fov: 75,
// World settings fetchPlayerSkins: true,
clipWorldBelowY: undefined as number | undefined, highlightBlockColor: 'blue',
isPlayground: false foreground: true,
_experimentalSmoothChunkLoading: true,
_renderByChunks: false
} }
export type WorldRendererConfig = typeof defaultWorldRendererConfig export type WorldRendererConfig = typeof defaultWorldRendererConfig
export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any> { export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any> {
worldReadyResolvers = Promise.withResolvers<void>()
worldReadyPromise = this.worldReadyResolvers.promise
timeOfTheDay = 0 timeOfTheDay = 0
worldSizeParams = { minY: 0, worldHeight: 256 } worldSizeParams = { minY: 0, worldHeight: 256 }
reactiveDebugParams = proxy({
stopRendering: false,
chunksRenderAboveOverride: undefined as number | undefined,
chunksRenderAboveEnabled: false,
chunksRenderBelowOverride: undefined as number | undefined,
chunksRenderBelowEnabled: false,
chunksRenderDistanceOverride: undefined as number | undefined,
chunksRenderDistanceEnabled: false,
disableEntities: false,
// disableParticles: false
})
active = false active = false
@ -117,11 +81,10 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
dirty (pos: Vec3, value: boolean): void dirty (pos: Vec3, value: boolean): void
update (/* pos: Vec3, value: boolean */): void update (/* pos: Vec3, value: boolean */): void
chunkFinished (key: string): void chunkFinished (key: string): void
heightmap (key: string, heightmap: Uint8Array): void
}> }>
customTexturesDataUrl = undefined as string | undefined customTexturesDataUrl = undefined as string | undefined
workers: any[] = [] workers: any[] = []
viewerChunkPosition?: Vec3 viewerPosition?: Vec3
lastCamUpdate = 0 lastCamUpdate = 0
droppedFpsPercentage = 0 droppedFpsPercentage = 0
initialChunkLoadWasStartedIn: number | undefined initialChunkLoadWasStartedIn: number | undefined
@ -136,7 +99,8 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
ONMESSAGE_TIME_LIMIT = 30 // ms ONMESSAGE_TIME_LIMIT = 30 // ms
handleResize = () => { } handleResize = () => { }
highestBlocksByChunks = new Map<string, { [chunkKey: string]: HighestBlockInfo }>() highestBlocksByChunks = {} as Record<string, { [chunkKey: string]: HighestBlockInfo }>
highestBlocksBySections = {} as Record<string, { [sectionKey: string]: HighestBlockInfo }>
blockEntities = {} blockEntities = {}
workersProcessAverageTime = 0 workersProcessAverageTime = 0
@ -170,30 +134,24 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
abstract changeBackgroundColor (color: [number, number, number]): void abstract changeBackgroundColor (color: [number, number, number]): void
worldRendererConfig: WorldRendererConfig worldRendererConfig: WorldRendererConfig
playerStateReactive: PlayerStateReactive playerState: IPlayerState
playerStateUtils: PlayerStateUtils
reactiveState: RendererReactiveState reactiveState: RendererReactiveState
mesherLogReader: MesherLogReader | undefined
forceCallFromMesherReplayer = false
stopMesherMessagesProcessing = false
abortController = new AbortController() abortController = new AbortController()
lastRendered = 0 lastRendered = 0
renderingActive = true renderingActive = true
geometryReceiveCountPerSec = 0 geometryReceiveCountPerSec = 0
mesherLogger = { workerLogger = {
contents: [] as string[], contents: [] as string[],
active: new URL(location.href).searchParams.get('mesherlog') === 'true' active: new URL(location.href).searchParams.get('mesherlog') === 'true'
} }
currentRenderedFrames = 0 currentRenderedFrames = 0
fpsAverage = 0 fpsAverage = 0
lastFps = 0
fpsWorst = undefined as number | undefined fpsWorst = undefined as number | undefined
fpsSamples = 0 fpsSamples = 0
mainThreadRendering = true mainThreadRendering = true
backendInfoReport = '-' backendInfoReport = '-'
chunksFullInfo = '-' chunksFullInfo = '-'
workerCustomHandleTime = 0
get version () { get version () {
return this.displayOptions.version return this.displayOptions.version
@ -203,13 +161,13 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
return (this.initOptions.config.statsVisible ?? 0) > 1 return (this.initOptions.config.statsVisible ?? 0) > 1
} }
constructor (public readonly resourcesManager: ResourcesManagerTransferred, public displayOptions: DisplayWorldOptions, public initOptions: GraphicsInitOptions) { constructor (public readonly resourcesManager: ResourcesManager, public displayOptions: DisplayWorldOptions, public initOptions: GraphicsInitOptions) {
// this.initWorkers(1) // preload script on page load
this.snapshotInitialValues() this.snapshotInitialValues()
this.worldRendererConfig = displayOptions.inWorldRenderingConfig this.worldRendererConfig = displayOptions.inWorldRenderingConfig
this.playerStateReactive = displayOptions.playerStateReactive this.playerState = displayOptions.playerState
this.playerStateUtils = getPlayerStateUtils(this.playerStateReactive)
this.reactiveState = displayOptions.rendererState this.reactiveState = displayOptions.rendererState
// this.mesherLogReader = new MesherLogReader(this)
this.renderUpdateEmitter.on('update', () => { this.renderUpdateEmitter.on('update', () => {
const loadedChunks = Object.keys(this.finishedChunks).length const loadedChunks = Object.keys(this.finishedChunks).length
updateStatText('loaded-chunks', `${loadedChunks}/${this.chunksLength} chunks (${this.lastChunkDistance}/${this.viewDistance})`) updateStatText('loaded-chunks', `${loadedChunks}/${this.chunksLength} chunks (${this.lastChunkDistance}/${this.viewDistance})`)
@ -219,7 +177,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
this.connect(this.displayOptions.worldView) this.connect(this.displayOptions.worldView)
const interval = setInterval(() => { setInterval(() => {
this.geometryReceiveCountPerSec = Object.values(this.geometryReceiveCount).reduce((acc, curr) => acc + curr, 0) this.geometryReceiveCountPerSec = Object.values(this.geometryReceiveCount).reduce((acc, curr) => acc + curr, 0)
this.geometryReceiveCount = {} this.geometryReceiveCount = {}
updatePanesVisibility(this.displayAdvancedStats) updatePanesVisibility(this.displayAdvancedStats)
@ -228,9 +186,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
this.fpsUpdate() this.fpsUpdate()
} }
}, 500) }, 500)
this.abortController.signal.addEventListener('abort', () => {
clearInterval(interval)
})
} }
fpsUpdate () { fpsUpdate () {
@ -241,35 +196,26 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
} else { } else {
this.fpsWorst = Math.min(this.fpsWorst, this.currentRenderedFrames) this.fpsWorst = Math.min(this.fpsWorst, this.currentRenderedFrames)
} }
this.lastFps = this.currentRenderedFrames
this.currentRenderedFrames = 0 this.currentRenderedFrames = 0
} }
logWorkerWork (message: string | (() => string)) { logWorkerWork (message: string | (() => string)) {
if (!this.mesherLogger.active) return if (!this.workerLogger.active) return
this.mesherLogger.contents.push(typeof message === 'function' ? message() : message) this.workerLogger.contents.push(typeof message === 'function' ? message() : message)
} }
async init () { init () {
if (this.active) throw new Error('WorldRendererCommon is already initialized') if (this.active) throw new Error('WorldRendererCommon is already initialized')
await Promise.all([
this.resetWorkers(),
(async () => {
if (this.resourcesManager.currentResources?.allReady) {
await this.updateAssetsData()
}
})()
])
this.resourcesManager.on('assetsTexturesUpdated', async () => {
if (!this.active) return
await this.updateAssetsData()
})
this.watchReactivePlayerState() this.watchReactivePlayerState()
this.watchReactiveConfig() void this.setVersion(this.version).then(() => {
this.worldReadyResolvers.resolve() this.resourcesManager.on('assetsTexturesUpdated', () => {
if (!this.active) return
void this.updateAssetsData()
})
if (this.resourcesManager.currentResources) {
void this.updateAssetsData()
}
})
} }
snapshotInitialValues () { } snapshotInitialValues () { }
@ -279,7 +225,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
} }
async getHighestBlocks (chunkKey: string) { async getHighestBlocks (chunkKey: string) {
return this.highestBlocksByChunks.get(chunkKey) return this.highestBlocksByChunks[chunkKey]
} }
updateCustomBlock (chunkKey: string, blockPos: string, model: string) { updateCustomBlock (chunkKey: string, blockPos: string, model: string) {
@ -308,51 +254,48 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
initWorkers (numWorkers = this.worldRendererConfig.mesherWorkers) { initWorkers (numWorkers = this.worldRendererConfig.mesherWorkers) {
// init workers // init workers
for (let i = 0; i < numWorkers + 1; i++) { for (let i = 0; i < numWorkers + 1; i++) {
const worker = initMesherWorker((data) => { // Node environment needs an absolute path, but browser needs the url of the file
const workerName = 'mesher.js'
// eslint-disable-next-line node/no-path-concat
const src = typeof window === 'undefined' ? `${__dirname}/${workerName}` : workerName
let worker: any
if (process.env.SINGLE_FILE_BUILD) {
const workerCode = document.getElementById('mesher-worker-code')!.textContent!
const blob = new Blob([workerCode], { type: 'text/javascript' })
worker = new Worker(window.URL.createObjectURL(blob))
} else {
worker = new Worker(src)
}
worker.onmessage = ({ data }) => {
if (Array.isArray(data)) { if (Array.isArray(data)) {
this.messageQueue.push(...data) this.messageQueue.push(...data)
} else { } else {
this.messageQueue.push(data) this.messageQueue.push(data)
} }
void this.processMessageQueue('worker') void this.processMessageQueue('worker')
}) }
if (worker.on) worker.on('message', (data) => { worker.onmessage({ data }) })
this.workers.push(worker) this.workers.push(worker)
} }
} }
onReactivePlayerStateUpdated<T extends keyof PlayerStateReactive>(key: T, callback: (value: PlayerStateReactive[T]) => void, initial = true) { onReactiveValueUpdated<T extends keyof typeof this.displayOptions.playerState.reactive>(key: T, callback: (value: typeof this.displayOptions.playerState.reactive[T]) => void) {
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<T extends keyof typeof this.worldRendererConfig>(key: T, callback: (value: typeof this.worldRendererConfig[T]) => void) {
callback(this.worldRendererConfig[key])
subscribeKey(this.worldRendererConfig, key, callback)
}
onReactiveDebugUpdated<T extends keyof typeof this.reactiveDebugParams>(key: T, callback: (value: typeof this.reactiveDebugParams[T]) => void) {
callback(this.reactiveDebugParams[key])
subscribeKey(this.reactiveDebugParams, key, callback)
} }
watchReactivePlayerState () { watchReactivePlayerState () {
this.onReactivePlayerStateUpdated('backgroundColor', (value) => { this.onReactiveValueUpdated('backgroundColor', (value) => {
this.changeBackgroundColor(value) this.changeBackgroundColor(value)
}) })
} }
watchReactiveConfig () {
this.onReactiveConfigUpdated('fetchPlayerSkins', (value) => {
setSkinsConfig({ apiEnabled: value })
})
}
async processMessageQueue (source: string) { async processMessageQueue (source: string) {
if (this.isProcessingQueue || this.messageQueue.length === 0) return if (this.isProcessingQueue || this.messageQueue.length === 0) return
this.logWorkerWork(`# ${source} processing queue`) this.logWorkerWork(`# ${source} processing queue`)
if (this.lastRendered && performance.now() - this.lastRendered > this.ONMESSAGE_TIME_LIMIT && this.worldRendererConfig._experimentalSmoothChunkLoading && this.renderingActive) { if (this.lastRendered && performance.now() - this.lastRendered > 30 && this.worldRendererConfig._experimentalSmoothChunkLoading && this.renderingActive) {
const start = performance.now() const start = performance.now()
await new Promise(resolve => { await new Promise(resolve => {
requestAnimationFrame(resolve) requestAnimationFrame(resolve)
@ -365,15 +308,12 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
let processedCount = 0 let processedCount = 0
while (this.messageQueue.length > 0) { while (this.messageQueue.length > 0) {
const processingStopped = this.stopMesherMessagesProcessing const data = this.messageQueue.shift()!
if (!processingStopped) { this.handleMessage(data)
const data = this.messageQueue.shift()! processedCount++
this.handleMessage(data)
processedCount++
}
// Check if we've exceeded the time limit // Check if we've exceeded the time limit
if (processingStopped || (performance.now() - startTime > this.ONMESSAGE_TIME_LIMIT && this.renderingActive && this.worldRendererConfig._experimentalSmoothChunkLoading)) { if (performance.now() - startTime > this.ONMESSAGE_TIME_LIMIT && this.renderingActive && this.worldRendererConfig._experimentalSmoothChunkLoading) {
// If we have more messages and exceeded time limit, schedule next batch // If we have more messages and exceeded time limit, schedule next batch
if (this.messageQueue.length > 0) { if (this.messageQueue.length > 0) {
requestAnimationFrame(async () => { requestAnimationFrame(async () => {
@ -389,24 +329,22 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
this.isProcessingQueue = false this.isProcessingQueue = false
} }
handleMessage (rawData: any) { handleMessage (data) {
const data = rawData as MesherMainEvent
if (!this.active) return if (!this.active) return
this.mesherLogReader?.workerMessageReceived(data.type, data)
if (data.type !== 'geometry' || !this.debugStopGeometryUpdate) { if (data.type !== 'geometry' || !this.debugStopGeometryUpdate) {
const start = performance.now() this.handleWorkerMessage(data)
this.handleWorkerMessage(data as WorkerReceive)
this.workerCustomHandleTime += performance.now() - start
} }
if (data.type === 'geometry') { if (data.type === 'geometry') {
this.logWorkerWork(() => `-> ${data.workerIndex} geometry ${data.key} ${JSON.stringify({ dataSize: JSON.stringify(data).length })}`) this.logWorkerWork(() => `-> ${data.workerIndex} geometry ${data.key} ${JSON.stringify({ dataSize: JSON.stringify(data).length })}`)
this.geometryReceiveCount[data.workerIndex] ??= 0 this.geometryReceiveCount[data.workerIndex] ??= 0
this.geometryReceiveCount[data.workerIndex]++ this.geometryReceiveCount[data.workerIndex]++
const geometry = data.geometry as MesherGeometryOutput
this.highestBlocksBySections[data.key] = geometry.highestBlocks
const chunkCoords = data.key.split(',').map(Number) const chunkCoords = data.key.split(',').map(Number)
this.lastChunkDistance = Math.max(...this.getDistance(new Vec3(chunkCoords[0], 0, chunkCoords[2]))) this.lastChunkDistance = Math.max(...this.getDistance(new Vec3(chunkCoords[0], 0, chunkCoords[2])))
} }
if (data.type === 'sectionFinished') { // on after load & unload section if (data.type === 'sectionFinished') { // on after load & unload section
this.logWorkerWork(`<- ${data.workerIndex} sectionFinished ${data.key} ${JSON.stringify({ processTime: data.processTime })}`) this.logWorkerWork(`-> ${data.workerIndex} sectionFinished ${data.key} ${JSON.stringify({ processTime: data.processTime })}`)
if (!this.sectionsWaiting.has(data.key)) throw new Error(`sectionFinished event for non-outstanding section ${data.key}`) if (!this.sectionsWaiting.has(data.key)) throw new Error(`sectionFinished event for non-outstanding section ${data.key}`)
this.sectionsWaiting.set(data.key, this.sectionsWaiting.get(data.key)! - 1) this.sectionsWaiting.set(data.key, this.sectionsWaiting.get(data.key)! - 1)
if (this.sectionsWaiting.get(data.key) === 0) { if (this.sectionsWaiting.get(data.key) === 0) {
@ -427,7 +365,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
if (loaded) { if (loaded) {
// CHUNK FINISHED // CHUNK FINISHED
this.finishedChunks[chunkKey] = true this.finishedChunks[chunkKey] = true
this.reactiveState.world.chunksLoaded.add(`${Math.floor(chunkCoords[0] / 16)},${Math.floor(chunkCoords[2] / 16)}`)
this.renderUpdateEmitter.emit(`chunkFinished`, `${chunkCoords[0]},${chunkCoords[2]}`) this.renderUpdateEmitter.emit(`chunkFinished`, `${chunkCoords[0]},${chunkCoords[2]}`)
this.checkAllFinished() this.checkAllFinished()
// merge highest blocks by sections into highest blocks by chunks // merge highest blocks by sections into highest blocks by chunks
@ -466,15 +403,11 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
this.blockStateModelInfo.set(cacheKey, info) this.blockStateModelInfo.set(cacheKey, info)
} }
} }
if (data.type === 'heightmap') {
this.reactiveState.world.heightmaps.set(data.key, new Uint8Array(data.heightmap))
}
} }
downloadMesherLog () { downloadMesherLog () {
const a = document.createElement('a') const a = document.createElement('a')
a.href = 'data:text/plain;charset=utf-8,' + encodeURIComponent(this.mesherLogger.contents.join('\n')) a.href = 'data:text/plain;charset=utf-8,' + encodeURIComponent(this.workerLogger.contents.join('\n'))
a.download = 'mesher.log' a.download = 'mesher.log'
a.click() a.click()
} }
@ -510,12 +443,8 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
timeUpdated? (newTime: number): void timeUpdated? (newTime: number): void
biomeUpdated? (biome: any): void
biomeReset? (): void
updateViewerPosition (pos: Vec3) { updateViewerPosition (pos: Vec3) {
this.viewerChunkPosition = pos this.viewerPosition = pos
for (const [key, value] of Object.entries(this.loadedChunks)) { for (const [key, value] of Object.entries(this.loadedChunks)) {
if (!value) continue if (!value) continue
this.updatePosDataChunk?.(key) this.updatePosDataChunk?.(key)
@ -529,7 +458,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
} }
getDistance (posAbsolute: Vec3) { getDistance (posAbsolute: Vec3) {
const [botX, botZ] = chunkPos(this.viewerChunkPosition!) const [botX, botZ] = chunkPos(this.viewerPosition!)
const dx = Math.abs(botX - Math.floor(posAbsolute.x / 16)) const dx = Math.abs(botX - Math.floor(posAbsolute.x / 16))
const dz = Math.abs(botZ - Math.floor(posAbsolute.z / 16)) const dz = Math.abs(botZ - Math.floor(posAbsolute.z / 16))
return [dx, dz] as [number, number] return [dx, dz] as [number, number]
@ -545,11 +474,12 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
this.workers = [] this.workers = []
} }
async resetWorkers () { // new game load happens here
async setVersion (version: string) {
this.resetWorld() this.resetWorld()
// for workers in single file build // for workers in single file build
if (typeof document !== 'undefined' && document?.readyState === 'loading') { if (document?.readyState === 'loading') {
await new Promise(resolve => { await new Promise(resolve => {
document.addEventListener('DOMContentLoaded', resolve) document.addEventListener('DOMContentLoaded', resolve)
}) })
@ -558,7 +488,11 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
this.initWorkers() this.initWorkers()
this.active = true this.active = true
await this.resourcesManager.loadMcData(version)
this.sendMesherMcData() this.sendMesherMcData()
if (!this.resourcesManager.currentResources) {
await this.resourcesManager.updateAssetsData({ })
}
} }
getMesherConfig (): MesherConfig { getMesherConfig (): MesherConfig {
@ -581,12 +515,10 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
skyLight, skyLight,
smoothLighting: this.worldRendererConfig.smoothLighting, smoothLighting: this.worldRendererConfig.smoothLighting,
outputFormat: this.outputFormat, outputFormat: this.outputFormat,
// textureSize: this.resourcesManager.currentResources!.blocksAtlasParser.atlas.latest.width, textureSize: this.resourcesManager.currentResources!.blocksAtlasParser.atlas.latest.width,
debugModelVariant: undefined, debugModelVariant: undefined,
clipWorldBelowY: this.worldRendererConfig.clipWorldBelowY, clipWorldBelowY: this.worldRendererConfig.clipWorldBelowY,
disableSignsMapsSupport: !this.worldRendererConfig.extraBlockRenderers, disableSignsMapsSupport: !this.worldRendererConfig.extraBlockRenderers
worldMinY: this.worldMinYRender,
worldMaxY: this.worldMinYRender + this.worldSizeParams.worldHeight,
} }
} }
@ -606,7 +538,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
} }
async updateAssetsData () { async updateAssetsData () {
const resources = this.resourcesManager.currentResources const resources = this.resourcesManager.currentResources!
if (this.workers.length === 0) throw new Error('workers not initialized yet') if (this.workers.length === 0) throw new Error('workers not initialized yet')
for (const [i, worker] of this.workers.entries()) { for (const [i, worker] of this.workers.entries()) {
@ -616,7 +548,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
type: 'mesherData', type: 'mesherData',
workerIndex: i, workerIndex: i,
blocksAtlas: { blocksAtlas: {
latest: resources.blocksAtlasJson latest: resources.blocksAtlasParser.atlas.latest
}, },
blockstatesModels, blockstatesModels,
config: this.getMesherConfig(), config: this.getMesherConfig(),
@ -633,7 +565,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
updateChunksStats () { updateChunksStats () {
const loadedChunks = Object.keys(this.finishedChunks) const loadedChunks = Object.keys(this.finishedChunks)
this.displayOptions.nonReactiveState.world.chunksLoaded = new Set(loadedChunks) this.displayOptions.nonReactiveState.world.chunksLoaded = loadedChunks
this.displayOptions.nonReactiveState.world.chunksTotalNumber = this.chunksLength this.displayOptions.nonReactiveState.world.chunksTotalNumber = this.chunksLength
this.reactiveState.world.allChunksLoaded = this.allChunksFinished this.reactiveState.world.allChunksLoaded = this.allChunksFinished
@ -662,13 +594,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
customBlockModels: customBlockModels || undefined customBlockModels: customBlockModels || undefined
}) })
} }
this.workers[0].postMessage({ this.logWorkerWork(`-> chunk ${JSON.stringify({ x, z, chunkLength: chunk.length, customBlockModelsLength: customBlockModels ? Object.keys(customBlockModels).length : 0 })}`)
type: 'getHeightmap',
x,
z,
})
this.logWorkerWork(() => `-> chunk ${JSON.stringify({ x, z, chunkLength: chunk.length, customBlockModelsLength: customBlockModels ? Object.keys(customBlockModels).length : 0 })}`)
this.mesherLogReader?.chunkReceived(x, z, chunk.length)
for (let y = this.worldMinYRender; y < this.worldSizeParams.worldHeight; y += 16) { for (let y = this.worldMinYRender; y < this.worldSizeParams.worldHeight; y += 16) {
const loc = new Vec3(x, y, z) const loc = new Vec3(x, y, z)
this.setSectionDirty(loc) this.setSectionDirty(loc)
@ -703,15 +629,15 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
for (let y = this.worldSizeParams.minY; y < this.worldSizeParams.worldHeight; y += 16) { for (let y = this.worldSizeParams.minY; y < this.worldSizeParams.worldHeight; y += 16) {
this.setSectionDirty(new Vec3(x, y, z), false) this.setSectionDirty(new Vec3(x, y, z), false)
delete this.finishedSections[`${x},${y},${z}`] delete this.finishedSections[`${x},${y},${z}`]
delete this.highestBlocksBySections[`${x},${y},${z}`]
} }
this.highestBlocksByChunks.delete(`${x},${z}`) delete this.highestBlocksByChunks[`${x},${z}`]
this.updateChunksStats() this.updateChunksStats()
if (Object.keys(this.loadedChunks).length === 0) { if (Object.keys(this.loadedChunks).length === 0) {
this.mesherLogger.contents = [] this.workerLogger.contents = []
this.logWorkerWork('# all chunks unloaded. New log started') this.logWorkerWork('# all chunks unloaded. New log started')
void this.mesherLogReader?.maybeStartReplay()
} }
} }
@ -736,11 +662,9 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
updateEntity (e: any, isUpdate = false) { } updateEntity (e: any, isUpdate = false) { }
abstract updatePlayerEntity? (e: any): void
lightUpdate (chunkX: number, chunkZ: number) { } lightUpdate (chunkX: number, chunkZ: number) { }
connect (worldView: WorldDataEmitterWorker) { connect (worldView: WorldDataEmitter) {
const worldEmitter = worldView const worldEmitter = worldView
worldEmitter.on('entity', (e) => { worldEmitter.on('entity', (e) => {
@ -749,9 +673,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
worldEmitter.on('entityMoved', (e) => { worldEmitter.on('entityMoved', (e) => {
this.updateEntity(e, true) this.updateEntity(e, true)
}) })
worldEmitter.on('playerEntity', (e) => {
this.updatePlayerEntity?.(e)
})
let currentLoadChunkBatch = null as { let currentLoadChunkBatch = null as {
timeout timeout
@ -822,22 +743,16 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
}) })
worldEmitter.on('onWorldSwitch', () => { worldEmitter.on('onWorldSwitch', () => {
for (const fn of this.onWorldSwitched) { for (const fn of this.onWorldSwitched) fn()
try {
fn()
} catch (e) {
setTimeout(() => {
console.log('[Renderer Backend] Error in onWorldSwitched:')
throw e
}, 0)
}
}
}) })
worldEmitter.on('time', (timeOfDay) => { worldEmitter.on('time', (timeOfDay) => {
if (!this.worldRendererConfig.dayCycle) return
this.timeUpdated?.(timeOfDay) 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 this.timeOfTheDay = timeOfDay
// if (this.worldRendererConfig.skyLight === skyLight) return // if (this.worldRendererConfig.skyLight === skyLight) return
@ -847,13 +762,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
// } // }
}) })
worldEmitter.on('biomeUpdate', ({ biome }) => { worldEmitter.emit('listening')
this.biomeUpdated?.(biome)
})
worldEmitter.on('biomeReset', () => {
this.biomeReset?.()
})
} }
setBlockStateIdInner (pos: Vec3, stateId: number | undefined, needAoRecalculation = true) { setBlockStateIdInner (pos: Vec3, stateId: number | undefined, needAoRecalculation = true) {
@ -939,8 +848,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
} }
setSectionDirty (pos: Vec3, value = true, useChangeWorker = false) { // value false is used for unloading chunks setSectionDirty (pos: Vec3, value = true, useChangeWorker = false) { // value false is used for unloading chunks
if (!this.forceCallFromMesherReplayer && this.mesherLogReader) return
if (this.viewDistance === -1) throw new Error('viewDistance not set') if (this.viewDistance === -1) throw new Error('viewDistance not set')
this.reactiveState.world.mesherWork = true this.reactiveState.world.mesherWork = true
const distance = this.getDistance(pos) const distance = this.getDistance(pos)
@ -952,30 +859,19 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
// Dispatch sections to workers based on position // Dispatch sections to workers based on position
// This guarantees uniformity accross workers and that a given section // This guarantees uniformity accross workers and that a given section
// is always dispatched to the same worker // is always dispatched to the same worker
const hash = this.getWorkerNumber(pos, useChangeWorker && this.mesherLogger.active) const hash = this.getWorkerNumber(pos, useChangeWorker)
this.sectionsWaiting.set(key, (this.sectionsWaiting.get(key) ?? 0) + 1) this.sectionsWaiting.set(key, (this.sectionsWaiting.get(key) ?? 0) + 1)
if (this.forceCallFromMesherReplayer) { this.toWorkerMessagesQueue[hash] ??= []
this.workers[hash].postMessage({ this.toWorkerMessagesQueue[hash].push({
type: 'dirty', // this.workers[hash].postMessage({
x: pos.x, type: 'dirty',
y: pos.y, x: pos.x,
z: pos.z, y: pos.y,
value, z: pos.z,
config: this.getMesherConfig(), value,
}) config: this.getMesherConfig(),
} else { })
this.toWorkerMessagesQueue[hash] ??= [] this.dispatchMessages()
this.toWorkerMessagesQueue[hash].push({
// this.workers[hash].postMessage({
type: 'dirty',
x: pos.x,
y: pos.y,
z: pos.z,
value,
config: this.getMesherConfig(),
})
this.dispatchMessages()
}
} }
dispatchMessages () { dispatchMessages () {
@ -1047,41 +943,8 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
this.active = false this.active = false
this.renderUpdateEmitter.removeAllListeners() this.renderUpdateEmitter.removeAllListeners()
this.displayOptions.worldView.removeAllListeners() // todo
this.abortController.abort() this.abortController.abort()
removeAllStats() removeAllStats()
} }
} }
export const initMesherWorker = (onGotMessage: (data: any) => void) => {
// Node environment needs an absolute path, but browser needs the url of the file
const workerName = 'mesher.js'
let worker: any
if (process.env.SINGLE_FILE_BUILD) {
const workerCode = document.getElementById('mesher-worker-code')!.textContent!
const blob = new Blob([workerCode], { type: 'text/javascript' })
worker = new Worker(window.URL.createObjectURL(blob))
} else {
worker = new Worker(workerName)
}
worker.onmessage = ({ data }) => {
onGotMessage(data)
}
if (worker.on) worker.on('message', (data) => { worker.onmessage({ data }) })
return worker
}
export const meshersSendMcData = (workers: Worker[], version: string, addData = {} as Record<string, any>) => {
const allMcData = mcDataRaw.pc[version] ?? mcDataRaw.pc[toMajorVersion(version)]
const mcData = {
version: JSON.parse(JSON.stringify(allMcData.version))
}
for (const key of dynamicMcDataFiles) {
mcData[key] = allMcData[key]
}
for (const worker of workers) {
worker.postMessage({ type: 'mcData', mcData, ...addData })
}
}

View file

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

View file

@ -21,14 +21,9 @@ const blockEntity = {
await document.fonts.load('1em mojangles') await document.fonts.load('1em mojangles')
const canvas = renderSign(blockEntity, false, PrismarineChat, (ctx) => { const canvas = renderSign(blockEntity, PrismarineChat, (ctx) => {
ctx.drawImage(img, 0, 0, ctx.canvas.width, ctx.canvas.height) 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) { if (canvas) {
canvas.style.imageRendering = 'pixelated' canvas.style.imageRendering = 'pixelated'

View file

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

View file

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

View file

@ -1,5 +1,4 @@
import * as THREE from 'three' import * as THREE from 'three'
import { WorldRendererThree } from './worldrendererThree'
export class CameraShake { export class CameraShake {
private rollAngle = 0 private rollAngle = 0
@ -9,7 +8,7 @@ export class CameraShake {
private basePitch = 0 private basePitch = 0
private baseYaw = 0 private baseYaw = 0
constructor (public worldRenderer: WorldRendererThree, public onRenderCallbacks: Array<() => void>) { constructor (public camera: THREE.Camera, public onRenderCallbacks: Array<() => void>) {
onRenderCallbacks.push(() => { onRenderCallbacks.push(() => {
this.update() this.update()
}) })
@ -21,10 +20,6 @@ export class CameraShake {
this.update() this.update()
} }
getBaseRotation () {
return { pitch: this.basePitch, yaw: this.baseYaw }
}
shakeFromDamage (yaw?: number) { shakeFromDamage (yaw?: number) {
// Add roll animation // Add roll animation
const startRoll = this.rollAngle const startRoll = this.rollAngle
@ -39,11 +34,6 @@ export class CameraShake {
} }
update () { update () {
if (this.worldRenderer.playerStateUtils.isSpectatingEntity()) {
// Remove any shaking when spectating
this.rollAngle = 0
this.rollAnimation = undefined
}
// Update roll animation // Update roll animation
if (this.rollAnimation) { if (this.rollAnimation) {
const now = performance.now() const now = performance.now()
@ -72,25 +62,14 @@ export class CameraShake {
} }
} }
const camera = this.worldRenderer.cameraObject // Create rotation quaternions
const pitchQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), this.basePitch)
const yawQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), this.baseYaw)
const rollQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), THREE.MathUtils.degToRad(this.rollAngle))
if (this.worldRenderer.cameraGroupVr) { // Combine rotations in the correct order: pitch -> yaw -> roll
// For VR camera, only apply yaw rotation const finalQuat = yawQuat.multiply(pitchQuat).multiply(rollQuat)
const yawQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), this.baseYaw) this.camera.setRotationFromQuaternion(finalQuat)
camera.setRotationFromQuaternion(yawQuat)
} else {
// For regular camera, apply all rotations
// Add tiny offsets to prevent z-fighting at ideal angles (90, 180, 270 degrees)
const pitchOffset = this.addAntiZfightingOffset(this.basePitch)
const yawOffset = this.addAntiZfightingOffset(this.baseYaw)
const pitchQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), pitchOffset)
const yawQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), yawOffset)
const 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)
camera.setRotationFromQuaternion(finalQuat)
}
} }
private easeOut (t: number): number { private easeOut (t: number): number {
@ -100,21 +79,4 @@ export class CameraShake {
private easeInOut (t: number): number { private easeInOut (t: number): number {
return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2 return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2
} }
private addAntiZfightingOffset (angle: number): number {
const offset = 0.001 // Very small offset in radians (about 0.057 degrees)
// Check if the angle is close to ideal angles (0, π/2, π, 3π/2)
const normalizedAngle = ((angle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)
const tolerance = 0.01 // Tolerance for considering an angle "ideal"
if (Math.abs(normalizedAngle) < tolerance ||
Math.abs(normalizedAngle - Math.PI / 2) < tolerance ||
Math.abs(normalizedAngle - Math.PI) < tolerance ||
Math.abs(normalizedAngle - 3 * Math.PI / 2) < tolerance) {
return angle + offset
}
return angle
}
} }

View file

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

File diff suppressed because it is too large Load diff

View file

@ -2,11 +2,10 @@ import * as THREE from 'three'
import { OBJLoader } from 'three-stdlib' import { OBJLoader } from 'three-stdlib'
import huskPng from 'mc-assets/dist/other-textures/latest/entity/zombie/husk.png' import huskPng from 'mc-assets/dist/other-textures/latest/entity/zombie/husk.png'
import { Vec3 } from 'vec3' import { Vec3 } from 'vec3'
import ocelotPng from '../../../../node_modules/mc-assets/dist/other-textures/latest/entity/cat/ocelot.png'
import arrowTexture from '../../../../node_modules/mc-assets/dist/other-textures/1.21.2/entity/projectiles/arrow.png' 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 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 tippedArrowTexture from '../../../../node_modules/mc-assets/dist/other-textures/1.21.2/entity/projectiles/tipped_arrow.png'
import { loadTexture } from '../threeJsUtils' import { loadTexture } from '../../lib/utils'
import { WorldRendererThree } from '../worldrendererThree' import { WorldRendererThree } from '../worldrendererThree'
import entities from './entities.json' import entities from './entities.json'
import { externalModels } from './objModels' import { externalModels } from './objModels'
@ -238,11 +237,10 @@ export function getMesh (
if (useBlockTexture) { if (useBlockTexture) {
if (!worldRenderer) throw new Error('worldRenderer is required for block textures') if (!worldRenderer) throw new Error('worldRenderer is required for block textures')
const blockName = texture.slice(6) const blockName = texture.slice(6)
const textureInfo = worldRenderer.resourcesManager.currentResources.blocksAtlasJson.textures[blockName] const textureInfo = worldRenderer.resourcesManager.currentResources!.blocksAtlasParser.getTextureInfo(blockName)
if (textureInfo) { if (textureInfo) {
textureWidth = blocksTexture?.image.width ?? textureWidth textureWidth = blocksTexture?.image.width ?? textureWidth
textureHeight = blocksTexture?.image.height ?? textureHeight textureHeight = blocksTexture?.image.height ?? textureHeight
// todo support su/sv
textureOffset = [textureInfo.u, textureInfo.v] textureOffset = [textureInfo.u, textureInfo.v]
} else { } else {
console.error(`Unknown block ${blockName}`) console.error(`Unknown block ${blockName}`)
@ -458,7 +456,7 @@ export class EntityMesh {
'skeleton_horse': `textures/${version}/entity/horse/horse_skeleton.png`, 'skeleton_horse': `textures/${version}/entity/horse/horse_skeleton.png`,
'donkey': `textures/${version}/entity/horse/donkey.png`, 'donkey': `textures/${version}/entity/horse/donkey.png`,
'mule': `textures/${version}/entity/horse/mule.png`, 'mule': `textures/${version}/entity/horse/mule.png`,
'ocelot': ocelotPng, 'ocelot': `textures/${version}/entity/cat/ocelot.png`,
'arrow': arrowTexture, 'arrow': arrowTexture,
'spectral_arrow': spectralArrowTexture, 'spectral_arrow': spectralArrowTexture,
'tipped_arrow': tippedArrowTexture 'tipped_arrow': tippedArrowTexture
@ -529,6 +527,12 @@ export class EntityMesh {
debugFlags) debugFlags)
mesh.name = `geometry_${name}` mesh.name = `geometry_${name}`
this.mesh.add(mesh) this.mesh.add(mesh)
const skeletonHelper = new THREE.SkeletonHelper(mesh)
//@ts-expect-error
skeletonHelper.material.linewidth = 2
skeletonHelper.visible = false
this.mesh.add(skeletonHelper)
} }
debugFlags.type = 'bedrock' debugFlags.type = 'bedrock'
} }
@ -547,4 +551,3 @@ export class EntityMesh {
} }
} }
} }
globalThis.EntityMesh = EntityMesh

View file

@ -1,4 +1,3 @@
//@ts-check
import { PlayerAnimation } from 'skinview3d' import { PlayerAnimation } from 'skinview3d'
export class WalkingGeneralSwing extends PlayerAnimation { export class WalkingGeneralSwing extends PlayerAnimation {
@ -7,7 +6,6 @@ export class WalkingGeneralSwing extends PlayerAnimation {
isRunning = false isRunning = false
isMoving = true isMoving = true
isCrouched = false
_startArmSwing _startArmSwing
@ -17,7 +15,7 @@ export class WalkingGeneralSwing extends PlayerAnimation {
animate(player) { animate(player) {
// Multiply by animation's natural speed // Multiply by animation's natural speed
let t = 0 let t
const updateT = () => { const updateT = () => {
if (!this.isMoving) { if (!this.isMoving) {
t = 0 t = 0
@ -32,8 +30,6 @@ export class WalkingGeneralSwing extends PlayerAnimation {
updateT() updateT()
let reset = false let reset = false
croughAnimation(player, this.isCrouched)
if ((this.isRunning ? Math.cos(t) : Math.sin(t)) < 0.01) { if ((this.isRunning ? Math.cos(t) : Math.sin(t)) < 0.01) {
if (this.switchAnimationCallback) { if (this.switchAnimationCallback) {
reset = true reset = true
@ -54,12 +50,11 @@ export class WalkingGeneralSwing extends PlayerAnimation {
if (this._startArmSwing) { if (this._startArmSwing) {
const tHand = (this.progress - this._startArmSwing) * 18 + Math.PI * 0.5 const tHand = (this.progress - this._startArmSwing) * 18 + Math.PI * 0.5
// player.skin.rightArm.rotation.x = Math.cos(tHand) * 1.5 player.skin.rightArm.rotation.x = Math.cos(tHand) * 1.5
// const basicArmRotationZ = Math.PI * 0.1 const basicArmRotationZ = Math.PI * 0.1
// player.skin.rightArm.rotation.z = Math.cos(t + Math.PI) * 0.3 - basicArmRotationZ player.skin.rightArm.rotation.z = Math.cos(t + Math.PI) * 0.3 - basicArmRotationZ
HitAnimation.animate((this.progress - this._startArmSwing), player, this.isMoving)
if (tHand > Math.PI + Math.PI) { if (tHand > Math.PI + Math.PI * 0.5) {
this._startArmSwing = null this._startArmSwing = null
player.skin.rightArm.rotation.z = 0 player.skin.rightArm.rotation.z = 0
} }
@ -106,66 +101,3 @@ export class WalkingGeneralSwing extends PlayerAnimation {
} }
} }
} }
const HitAnimation = {
animate(progress, player, isMovingOrRunning) {
const t = progress * 18
player.skin.rightArm.rotation.x = -0.453_786_055_2 * 2 + 2 * Math.sin(t + Math.PI) * 0.3
if (!isMovingOrRunning) {
const basicArmRotationZ = 0.01 * Math.PI + 0.06
player.skin.rightArm.rotation.z = -Math.cos(t) * 0.403 + basicArmRotationZ
player.skin.body.rotation.y = -Math.cos(t) * 0.06
player.skin.leftArm.rotation.x = Math.sin(t + Math.PI) * 0.077
player.skin.leftArm.rotation.z = -Math.cos(t) * 0.015 + 0.13 - 0.05
player.skin.leftArm.position.z = Math.cos(t) * 0.3
player.skin.leftArm.position.x = 5 - Math.cos(t) * 0.05
}
},
}
const croughAnimation = (player, isCrouched) => {
const erp = 0
// let pr = this.progress * 8;
let pr = isCrouched ? 1 : 0
const showProgress = false
if (showProgress) {
pr = Math.floor(pr)
}
player.skin.body.rotation.x = 0.453_786_055_2 * Math.abs(Math.sin((pr * Math.PI) / 2))
player.skin.body.position.z =
1.325_618_1 * Math.abs(Math.sin((pr * Math.PI) / 2)) - 3.450_031_037_7 * Math.abs(Math.sin((pr * Math.PI) / 2))
player.skin.body.position.y = -6 - 2.103_677_462 * Math.abs(Math.sin((pr * Math.PI) / 2))
player.cape.position.y = 8 - 1.851_236_166_577_372 * Math.abs(Math.sin((pr * Math.PI) / 2))
player.cape.rotation.x = (10.8 * Math.PI) / 180 + 0.294_220_265_771 * Math.abs(Math.sin((pr * Math.PI) / 2))
player.cape.position.z =
-2 + 3.786_619_432 * Math.abs(Math.sin((pr * Math.PI) / 2)) - 3.450_031_037_7 * Math.abs(Math.sin((pr * Math.PI) / 2))
player.elytra.position.x = player.cape.position.x
player.elytra.position.y = player.cape.position.y
player.elytra.position.z = player.cape.position.z
player.elytra.rotation.x = player.cape.rotation.x - (10.8 * Math.PI) / 180
// const pr1 = this.progress / this.speed;
const pr1 = 1
if (Math.abs(Math.sin((pr * Math.PI) / 2)) === 1) {
player.elytra.leftWing.rotation.z =
0.261_799_44 + 0.458_200_6 * Math.abs(Math.sin((Math.min(pr1 - erp, 1) * Math.PI) / 2))
player.elytra.updateRightWing()
} else if (isCrouched !== undefined) {
player.elytra.leftWing.rotation.z =
0.72 - 0.458_200_6 * Math.abs(Math.sin((Math.min(pr1 - erp, 1) * Math.PI) / 2))
player.elytra.updateRightWing()
}
player.skin.head.position.y = -3.618_325_234_674 * Math.abs(Math.sin((pr * Math.PI) / 2))
player.skin.leftArm.position.z =
3.618_325_234_674 * Math.abs(Math.sin((pr * Math.PI) / 2)) - 3.450_031_037_7 * Math.abs(Math.sin((pr * Math.PI) / 2))
player.skin.rightArm.position.z = player.skin.leftArm.position.z
player.skin.leftArm.rotation.x = 0.410_367_746_202 * Math.abs(Math.sin((pr * Math.PI) / 2))
player.skin.rightArm.rotation.x = player.skin.leftArm.rotation.x
player.skin.leftArm.rotation.z = 0.1
player.skin.rightArm.rotation.z = -player.skin.leftArm.rotation.z
player.skin.leftArm.position.y = -2 - 2.539_433_18 * Math.abs(Math.sin((pr * Math.PI) / 2))
player.skin.rightArm.position.y = player.skin.leftArm.position.y
player.skin.rightLeg.position.z = -3.450_031_037_7 * Math.abs(Math.sin((pr * Math.PI) / 2))
player.skin.leftLeg.position.z = player.skin.rightLeg.position.z
}

View file

@ -14,7 +14,6 @@ import { default as netheriteLayer1 } from 'mc-assets/dist/other-textures/latest
import { default as netheriteLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/netherite_layer_2.png' import { default as 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' 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 { default as armorModel } from './armorModels.json'
export const armorTextures = { export const armorTextures = {

View file

@ -1,19 +1,15 @@
import * as THREE from 'three' import * as THREE from 'three'
import { Vec3 } from 'vec3' import { Vec3 } from 'vec3'
import { GraphicsBackendLoader, GraphicsBackend, GraphicsInitOptions, DisplayWorldOptions } from '../../../src/appViewer' import { proxy } from 'valtio'
import { GraphicsBackendLoader, GraphicsBackend, GraphicsInitOptions, DisplayWorldOptions, RendererReactiveState } from '../../../src/appViewer'
import { ProgressReporter } from '../../../src/core/progressReporter' import { ProgressReporter } from '../../../src/core/progressReporter'
import { showNotification } from '../../../src/react/NotificationProvider'
import { displayEntitiesDebugList } from '../../playground/allEntitiesDebug'
import supportedVersions from '../../../src/supportedVersions.mjs'
import { ResourcesManager } from '../../../src/resourcesManager'
import { WorldRendererThree } from './worldrendererThree' import { WorldRendererThree } from './worldrendererThree'
import { DocumentRenderer } from './documentRenderer' import { DocumentRenderer } from './documentRenderer'
import { PanoramaRenderer } from './panorama' import { PanoramaRenderer } from './panorama'
import { initVR } from './world/vr'
// https://discourse.threejs.org/t/updates-to-color-management-in-three-js-r152/50791 // https://discourse.threejs.org/t/updates-to-color-management-in-three-js-r152/50791
THREE.ColorManagement.enabled = false THREE.ColorManagement.enabled = false
globalThis.THREE = THREE window.THREE = THREE
const getBackendMethods = (worldRenderer: WorldRendererThree) => { const getBackendMethods = (worldRenderer: WorldRendererThree) => {
return { return {
@ -25,7 +21,7 @@ const getBackendMethods = (worldRenderer: WorldRendererThree) => {
updatePlayerSkin: worldRenderer.entities.updatePlayerSkin.bind(worldRenderer.entities), updatePlayerSkin: worldRenderer.entities.updatePlayerSkin.bind(worldRenderer.entities),
changeHandSwingingState: worldRenderer.changeHandSwingingState.bind(worldRenderer), changeHandSwingingState: worldRenderer.changeHandSwingingState.bind(worldRenderer),
getHighestBlocks: worldRenderer.getHighestBlocks.bind(worldRenderer), getHighestBlocks: worldRenderer.getHighestBlocks.bind(worldRenderer),
reloadWorld: worldRenderer.reloadWorld.bind(worldRenderer), rerenderAllChunks: worldRenderer.rerenderAllChunks.bind(worldRenderer),
addMedia: worldRenderer.media.addMedia.bind(worldRenderer.media), addMedia: worldRenderer.media.addMedia.bind(worldRenderer.media),
destroyMedia: worldRenderer.media.destroyMedia.bind(worldRenderer.media), destroyMedia: worldRenderer.media.destroyMedia.bind(worldRenderer.media),
@ -44,12 +40,6 @@ const getBackendMethods = (worldRenderer: WorldRendererThree) => {
shakeFromDamage: worldRenderer.cameraShake.shakeFromDamage.bind(worldRenderer.cameraShake), shakeFromDamage: worldRenderer.cameraShake.shakeFromDamage.bind(worldRenderer.cameraShake),
onPageInteraction: worldRenderer.media.onPageInteraction.bind(worldRenderer.media), onPageInteraction: worldRenderer.media.onPageInteraction.bind(worldRenderer.media),
downloadMesherLog: worldRenderer.downloadMesherLog.bind(worldRenderer), 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)
} }
} }
@ -63,42 +53,31 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO
let panoramaRenderer: PanoramaRenderer | null = null let panoramaRenderer: PanoramaRenderer | null = null
let worldRenderer: WorldRendererThree | null = null let worldRenderer: WorldRendererThree | null = null
const startPanorama = async () => { const startPanorama = () => {
if (!documentRenderer) throw new Error('Document renderer not initialized')
if (worldRenderer) return if (worldRenderer) return
const qs = new URLSearchParams(location.search)
if (qs.get('debugEntities')) {
const fullResourceManager = initOptions.resourcesManager as ResourcesManager
fullResourceManager.currentConfig = { version: qs.get('version') || supportedVersions.at(-1)!, noInventoryGui: true }
await fullResourceManager.updateAssetsData({ })
displayEntitiesDebugList(fullResourceManager.currentConfig.version)
return
}
if (!panoramaRenderer) { if (!panoramaRenderer) {
panoramaRenderer = new PanoramaRenderer(documentRenderer, initOptions, !!process.env.SINGLE_FILE_BUILD_MODE) panoramaRenderer = new PanoramaRenderer(documentRenderer, initOptions, !!process.env.SINGLE_FILE_BUILD_MODE)
globalThis.panoramaRenderer = panoramaRenderer void panoramaRenderer.start()
callModsMethod('panoramaCreated', panoramaRenderer) window.panoramaRenderer = panoramaRenderer
await panoramaRenderer.start()
callModsMethod('panoramaReady', panoramaRenderer)
} }
} }
const startWorld = async (displayOptions: DisplayWorldOptions) => { let version = ''
const prepareResources = async (ver: string, progressReporter: ProgressReporter): Promise<void> => {
version = ver
await initOptions.resourcesManager.updateAssetsData({ })
}
const startWorld = (displayOptions: DisplayWorldOptions) => {
if (panoramaRenderer) { if (panoramaRenderer) {
panoramaRenderer.dispose() panoramaRenderer.dispose()
panoramaRenderer = null panoramaRenderer = null
} }
worldRenderer = new WorldRendererThree(documentRenderer.renderer, initOptions, displayOptions) worldRenderer = new WorldRendererThree(documentRenderer.renderer, initOptions, displayOptions)
void initVR(worldRenderer, documentRenderer)
await worldRenderer.worldReadyPromise
documentRenderer.render = (sizeChanged: boolean) => { documentRenderer.render = (sizeChanged: boolean) => {
worldRenderer?.render(sizeChanged) worldRenderer?.render(sizeChanged)
} }
documentRenderer.inWorldRenderingConfig = displayOptions.inWorldRenderingConfig
window.world = worldRenderer window.world = worldRenderer
callModsMethod('worldReady', worldRenderer)
} }
const disconnect = () => { const disconnect = () => {
@ -127,9 +106,6 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO
if (worldRenderer) worldRenderer.renderingActive = rendering if (worldRenderer) worldRenderer.renderingActive = rendering
}, },
getDebugOverlay: () => ({ getDebugOverlay: () => ({
get entitiesString () {
return worldRenderer?.entities.getDebugString()
},
}), }),
updateCamera (pos: Vec3 | null, yaw: number, pitch: number) { updateCamera (pos: Vec3 | null, yaw: number, pitch: number) {
worldRenderer?.setFirstPersonCamera(pos, yaw, pitch) worldRenderer?.setFirstPersonCamera(pos, yaw, pitch)
@ -143,24 +119,8 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO
} }
} }
globalThis.threeJsBackend = backend
globalThis.resourcesManager = initOptions.resourcesManager
callModsMethod('default', backend)
return backend return backend
} }
const callModsMethod = (method: string, ...args: any[]) => {
for (const mod of Object.values((window.loadedMods ?? {}) as Record<string, any>)) {
try {
mod.threeJsBackendModule?.[method]?.(...args)
} catch (err) {
const errorMessage = `[mod three.js] Error calling ${method} on ${mod.name}: ${err}`
showNotification(errorMessage, 'error')
throw new Error(errorMessage)
}
}
}
createGraphicsBackend.id = 'threejs' createGraphicsBackend.id = 'threejs'
export default createGraphicsBackend export default createGraphicsBackend

View file

@ -1,15 +1,14 @@
import * as THREE from 'three' import * as THREE from 'three'
import * as tweenJs from '@tweenjs/tween.js' import * as tweenJs from '@tweenjs/tween.js'
import PrismarineItem from 'prismarine-item'
import worldBlockProvider, { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider' import worldBlockProvider, { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider'
import { BlockModel } from 'mc-assets' import { BlockModel } from 'mc-assets'
import { getThreeBlockModelGroup, renderBlockThree, setBlockPosition } from '../lib/mesher/standaloneRenderer' import { getThreeBlockModelGroup, renderBlockThree, setBlockPosition } from '../lib/mesher/standaloneRenderer'
import { MovementState, PlayerStateRenderer } from '../lib/basePlayerState' import { getMyHand } from '../lib/hand'
import { IPlayerState, MovementState } from '../lib/basePlayerState'
import { DebugGui } from '../lib/DebugGui' import { DebugGui } from '../lib/DebugGui'
import { SmoothSwitcher } from '../lib/smoothSwitcher' import { SmoothSwitcher } from '../lib/smoothSwitcher'
import { watchProperty } from '../lib/utils/proxy' import { watchProperty } from '../lib/utils/proxy'
import { WorldRendererConfig } from '../lib/worldrendererCommon' import { WorldRendererConfig } from '../lib/worldrendererCommon'
import { getMyHand } from './hand'
import { WorldRendererThree } from './worldrendererThree' import { WorldRendererThree } from './worldrendererThree'
import { disposeObject } from './threeJsUtils' import { disposeObject } from './threeJsUtils'
@ -116,56 +115,41 @@ export default class HoldingBlock {
offHandModeLegacy = false offHandModeLegacy = false
swingAnimator: HandSwingAnimator | undefined swingAnimator: HandSwingAnimator | undefined
playerState: IPlayerState
config: WorldRendererConfig config: WorldRendererConfig
constructor (public worldRenderer: WorldRendererThree, public offHand = false) { constructor (public worldRenderer: WorldRendererThree, public offHand = false) {
this.initCameraGroup() this.initCameraGroup()
this.worldRenderer.onReactivePlayerStateUpdated('heldItemMain', () => { this.playerState = worldRenderer.displayOptions.playerState
if (!this.offHand) { this.playerState.events.on('heldItemChanged', (_, isOffHand) => {
this.updateItem() if (this.offHand !== isOffHand) return
} this.updateItem()
}, false) })
this.worldRenderer.onReactivePlayerStateUpdated('heldItemOff', () => {
if (this.offHand) {
this.updateItem()
}
}, false)
this.config = worldRenderer.displayOptions.inWorldRenderingConfig this.config = worldRenderer.displayOptions.inWorldRenderingConfig
this.offHandDisplay = this.offHand this.offHandDisplay = this.offHand
// this.offHandDisplay = true // this.offHandDisplay = true
if (!this.offHand) { if (!this.offHand) {
// load default hand // watch over my hand
void getMyHand().then((hand) => { watchProperty(
this.playerHand = hand async () => {
// trigger update return getMyHand(this.playerState.reactive.playerSkin, this.playerState.onlineMode ? this.playerState.username : undefined)
this.updateItem() },
}).then(() => { this.playerState.reactive,
// now watch over the player skin 'playerSkin',
watchProperty( (newHand) => {
async () => { this.playerHand = newHand
return getMyHand(this.worldRenderer.playerStateReactive.playerSkin, this.worldRenderer.playerStateReactive.onlineMode ? this.worldRenderer.playerStateReactive.username : undefined) },
}, (oldHand) => {
this.worldRenderer.playerStateReactive, disposeObject(oldHand, true)
'playerSkin', }
(newHand) => { )
if (newHand) {
this.playerHand = newHand
// trigger update
this.updateItem()
}
},
(oldHand) => {
disposeObject(oldHand!, true)
}
)
})
} }
} }
updateItem () { updateItem () {
if (!this.ready) return if (!this.ready || !this.playerState.getHeldItem) return
const item = this.offHand ? this.worldRenderer.playerStateReactive.heldItemOff : this.worldRenderer.playerStateReactive.heldItemMain const item = this.playerState.getHeldItem(this.offHand)
if (item) { if (item) {
void this.setNewItem(item) void this.setNewItem(item)
} else if (this.offHand) { } else if (this.offHand) {
@ -302,7 +286,6 @@ export default class HoldingBlock {
} }
isDifferentItem (block: HandItemBlock | undefined) { isDifferentItem (block: HandItemBlock | undefined) {
const Item = PrismarineItem(this.worldRenderer.version)
if (!this.lastHeldItem) { if (!this.lastHeldItem) {
return true return true
} }
@ -310,7 +293,7 @@ export default class HoldingBlock {
return true return true
} }
// eslint-disable-next-line sonarjs/prefer-single-boolean-return // eslint-disable-next-line sonarjs/prefer-single-boolean-return
if (!Item.equal(this.lastHeldItem.fullItem, block?.fullItem ?? {}) || JSON.stringify(this.lastHeldItem.fullItem.components) !== JSON.stringify(block?.fullItem?.components)) { if (JSON.stringify(this.lastHeldItem.fullItem) !== JSON.stringify(block?.fullItem ?? '{}')) {
return true return true
} }
@ -355,9 +338,9 @@ export default class HoldingBlock {
itemId: handItem.id, itemId: handItem.id,
}, { }, {
'minecraft:display_context': 'firstperson', 'minecraft:display_context': 'firstperson',
'minecraft:use_duration': this.worldRenderer.playerStateReactive.itemUsageTicks, 'minecraft:use_duration': this.playerState.getItemUsageTicks?.(),
'minecraft:using_item': !!this.worldRenderer.playerStateReactive.itemUsageTicks, 'minecraft:using_item': !!this.playerState.getItemUsageTicks?.(),
}, false, this.lastItemModelName) }, this.lastItemModelName)
if (result) { if (result) {
const { mesh: itemMesh, isBlock, modelName } = result const { mesh: itemMesh, isBlock, modelName } = result
if (isBlock) { if (isBlock) {
@ -473,7 +456,7 @@ export default class HoldingBlock {
this.swingAnimator = new HandSwingAnimator(this.holdingBlockInnerGroup) this.swingAnimator = new HandSwingAnimator(this.holdingBlockInnerGroup)
this.swingAnimator.type = result.type this.swingAnimator.type = result.type
if (this.config.viewBobbing) { if (this.config.viewBobbing) {
this.idleAnimator = new HandIdleAnimator(this.holdingBlockInnerGroup, this.worldRenderer.playerStateReactive) this.idleAnimator = new HandIdleAnimator(this.holdingBlockInnerGroup, this.playerState)
} }
} }
@ -554,7 +537,7 @@ class HandIdleAnimator {
private readonly debugGui: DebugGui private readonly debugGui: DebugGui
constructor (public handMesh: THREE.Object3D, public playerState: PlayerStateRenderer) { constructor (public handMesh: THREE.Object3D, public playerState: IPlayerState) {
this.handMesh = handMesh this.handMesh = handMesh
this.globalTime = 0 this.globalTime = 0
this.currentState = 'NOT_MOVING' this.currentState = 'NOT_MOVING'
@ -708,7 +691,7 @@ class HandIdleAnimator {
// Check for state changes from player state // Check for state changes from player state
if (this.playerState) { if (this.playerState) {
const newState = this.playerState.movementState const newState = this.playerState.getMovementState()
if (newState !== this.targetState) { if (newState !== this.targetState) {
this.setState(newState) this.setState(newState)
} }

View file

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

View file

@ -6,14 +6,11 @@ import * as tweenJs from '@tweenjs/tween.js'
import type { GraphicsInitOptions } from '../../../src/appViewer' import type { GraphicsInitOptions } from '../../../src/appViewer'
import { WorldDataEmitter } from '../lib/worldDataEmitter' import { WorldDataEmitter } from '../lib/worldDataEmitter'
import { defaultWorldRendererConfig, WorldRendererCommon } from '../lib/worldrendererCommon' import { defaultWorldRendererConfig, WorldRendererCommon } from '../lib/worldrendererCommon'
import { BasePlayerState } from '../lib/basePlayerState'
import { getDefaultRendererState } from '../baseGraphicsBackend' import { getDefaultRendererState } from '../baseGraphicsBackend'
import { ResourcesManager } from '../../../src/resourcesManager'
import { getInitialPlayerStateRenderer } from '../lib/basePlayerState'
import { loadThreeJsTextureFromUrl, loadThreeJsTextureFromUrlSync } from './threeJsUtils'
import { WorldRendererThree } from './worldrendererThree' import { WorldRendererThree } from './worldrendererThree'
import { EntityMesh } from './entity/EntityMesh' import { EntityMesh } from './entity/EntityMesh'
import { DocumentRenderer } from './documentRenderer' import { DocumentRenderer } from './documentRenderer'
import { PANORAMA_VERSION } from './panoramaShared'
const panoramaFiles = [ const panoramaFiles = [
'panorama_3.png', // right (+x) 'panorama_3.png', // right (+x)
@ -34,12 +31,10 @@ export class PanoramaRenderer {
private readonly abortController = new AbortController() private readonly abortController = new AbortController()
private worldRenderer: WorldRendererCommon | WorldRendererThree | undefined private worldRenderer: WorldRendererCommon | WorldRendererThree | undefined
public WorldRendererClass = WorldRendererThree public WorldRendererClass = WorldRendererThree
public startTimes = new Map<THREE.MeshBasicMaterial, number>()
constructor (private readonly documentRenderer: DocumentRenderer, private readonly options: GraphicsInitOptions, private readonly doWorldBlocksPanorama = false) { constructor (private readonly documentRenderer: DocumentRenderer, private readonly options: GraphicsInitOptions, private readonly doWorldBlocksPanorama = false) {
this.scene = new THREE.Scene() this.scene = new THREE.Scene()
// #324568 this.scene.background = new THREE.Color(this.options.config.sceneBackground)
this.scene.background = new THREE.Color(0x32_45_68)
// Add ambient light // Add ambient light
this.ambientLight = new THREE.AmbientLight(0xcc_cc_cc) this.ambientLight = new THREE.AmbientLight(0xcc_cc_cc)
@ -51,7 +46,7 @@ export class PanoramaRenderer {
this.directionalLight.castShadow = true this.directionalLight.castShadow = true
this.scene.add(this.directionalLight) this.scene.add(this.directionalLight)
this.camera = new THREE.PerspectiveCamera(85, this.documentRenderer.canvas.width / this.documentRenderer.canvas.height, 0.05, 1000) this.camera = new THREE.PerspectiveCamera(85, window.innerWidth / window.innerHeight, 0.05, 1000)
this.camera.position.set(0, 0, 0) this.camera.position.set(0, 0, 0)
this.camera.rotation.set(0, 0, 0) this.camera.rotation.set(0, 0, 0)
} }
@ -66,57 +61,38 @@ export class PanoramaRenderer {
this.documentRenderer.render = (sizeChanged = false) => { this.documentRenderer.render = (sizeChanged = false) => {
if (sizeChanged) { if (sizeChanged) {
this.camera.aspect = this.documentRenderer.canvas.width / this.documentRenderer.canvas.height this.camera.aspect = window.innerWidth / window.innerHeight
this.camera.updateProjectionMatrix() this.camera.updateProjectionMatrix()
} }
this.documentRenderer.renderer.render(this.scene, this.camera) this.documentRenderer.renderer.render(this.scene, this.camera)
} }
} }
async debugImageInFrontOfCamera () {
const image = await loadThreeJsTextureFromUrl(join('background', 'panorama_0.png'))
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1000, 1000), new THREE.MeshBasicMaterial({ map: image }))
mesh.position.set(0, 0, -500)
mesh.rotation.set(0, 0, 0)
this.scene.add(mesh)
}
addClassicPanorama () { addClassicPanorama () {
const panorGeo = new THREE.BoxGeometry(1000, 1000, 1000) const panorGeo = new THREE.BoxGeometry(1000, 1000, 1000)
const loader = new THREE.TextureLoader()
const panorMaterials = [] as THREE.MeshBasicMaterial[] const panorMaterials = [] as THREE.MeshBasicMaterial[]
const fadeInDuration = 200
// void this.debugImageInFrontOfCamera()
for (const file of panoramaFiles) { for (const file of panoramaFiles) {
const load = async () => { const texture = loader.load(join('background', file))
const { texture } = loadThreeJsTextureFromUrlSync(join('background', file))
// Instead of using repeat/offset to flip, we'll use the texture matrix // Instead of using repeat/offset to flip, we'll use the texture matrix
texture.matrixAutoUpdate = false texture.matrixAutoUpdate = false
texture.matrix.set( texture.matrix.set(
-1, 0, 1, 0, 1, 0, 0, 0, 1 -1, 0, 1, 0, 1, 0, 0, 0, 1
) )
texture.wrapS = THREE.ClampToEdgeWrapping texture.wrapS = THREE.ClampToEdgeWrapping // Changed from RepeatWrapping
texture.wrapT = THREE.ClampToEdgeWrapping texture.wrapT = THREE.ClampToEdgeWrapping // Changed from RepeatWrapping
texture.minFilter = THREE.LinearFilter texture.minFilter = THREE.LinearFilter
texture.magFilter = THREE.LinearFilter texture.magFilter = THREE.LinearFilter
const material = new THREE.MeshBasicMaterial({ panorMaterials.push(new THREE.MeshBasicMaterial({
map: texture, map: texture,
transparent: true, transparent: true,
side: THREE.DoubleSide, side: THREE.DoubleSide,
depthWrite: false, depthWrite: false,
opacity: 0 // Start with 0 opacity }))
})
// Start fade-in when texture is loaded
this.startTimes.set(material, Date.now())
panorMaterials.push(material)
}
void load()
} }
const panoramaBox = new THREE.Mesh(panorGeo, panorMaterials) const panoramaBox = new THREE.Mesh(panorGeo, panorMaterials)
@ -124,16 +100,6 @@ export class PanoramaRenderer {
this.time += 0.01 this.time += 0.01
panoramaBox.rotation.y = Math.PI + this.time * 0.01 panoramaBox.rotation.y = Math.PI + this.time * 0.01
panoramaBox.rotation.z = Math.sin(-this.time * 0.001) * 0.001 panoramaBox.rotation.z = Math.sin(-this.time * 0.001) * 0.001
// Time-based fade in animation for each material
for (const material of panorMaterials) {
const startTime = this.startTimes.get(material)
if (startTime) {
const elapsed = Date.now() - startTime
const progress = Math.min(1, elapsed / fadeInDuration)
material.opacity = progress
}
}
} }
const group = new THREE.Object3D() const group = new THREE.Object3D()
@ -157,10 +123,9 @@ export class PanoramaRenderer {
} }
async worldBlocksPanorama () { async worldBlocksPanorama () {
const version = PANORAMA_VERSION const version = '1.21.4'
const fullResourceManager = this.options.resourcesManager as ResourcesManager this.options.resourcesManager.currentConfig = { version, noInventoryGui: true, }
fullResourceManager.currentConfig = { version, noInventoryGui: true, } await this.options.resourcesManager.updateAssetsData({ })
await fullResourceManager.updateAssetsData({ })
if (this.abortController.signal.aborted) return if (this.abortController.signal.aborted) return
console.time('load panorama scene') console.time('load panorama scene')
const world = getSyncWorld(version) const world = getSyncWorld(version)
@ -198,9 +163,9 @@ export class PanoramaRenderer {
version, version,
worldView, worldView,
inWorldRenderingConfig: defaultWorldRendererConfig, inWorldRenderingConfig: defaultWorldRendererConfig,
playerStateReactive: getInitialPlayerStateRenderer().reactive, playerState: new BasePlayerState(),
rendererState: getDefaultRendererState().reactive, rendererState: getDefaultRendererState(),
nonReactiveState: getDefaultRendererState().nonReactive nonReactiveState: getDefaultRendererState()
} }
) )
if (this.worldRenderer instanceof WorldRendererThree) { if (this.worldRenderer instanceof WorldRendererThree) {

View file

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

View file

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

View file

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

View file

@ -16,8 +16,6 @@ interface MediaProperties {
loop?: boolean loop?: boolean
volume?: number volume?: number
autoPlay?: boolean autoPlay?: boolean
allowLighting?: boolean
} }
export class ThreeJsMedia { export class ThreeJsMedia {
@ -35,10 +33,6 @@ export class ThreeJsMedia {
this.worldRenderer.onWorldSwitched.push(() => { this.worldRenderer.onWorldSwitched.push(() => {
this.onWorldGone() this.onWorldGone()
}) })
this.worldRenderer.onRender.push(() => {
this.render()
})
} }
onWorldGone () { onWorldGone () {
@ -218,8 +212,7 @@ export class ThreeJsMedia {
const geometry = new THREE.PlaneGeometry(1, 1) const geometry = new THREE.PlaneGeometry(1, 1)
// Create material with initial properties using background texture // Create material with initial properties using background texture
const MaterialClass = props.allowLighting ? THREE.MeshLambertMaterial : THREE.MeshBasicMaterial const material = new THREE.MeshLambertMaterial({
const material = new MaterialClass({
map: backgroundTexture, map: backgroundTexture,
transparent: true, transparent: true,
side: props.doubleSide ? THREE.DoubleSide : THREE.FrontSide, side: props.doubleSide ? THREE.DoubleSide : THREE.FrontSide,
@ -308,18 +301,6 @@ export class ThreeJsMedia {
return id return id
} }
render () {
for (const [id, videoData] of this.customMedia.entries()) {
const chunkX = Math.floor(videoData.props.position.x / 16) * 16
const chunkZ = Math.floor(videoData.props.position.z / 16) * 16
const sectionY = Math.floor(videoData.props.position.y / 16) * 16
const chunkKey = `${chunkX},${chunkZ}`
const sectionKey = `${chunkX},${sectionY},${chunkZ}`
videoData.mesh.visible = !!this.worldRenderer.sectionObjects[sectionKey] || !!this.worldRenderer.finishedChunks[chunkKey]
}
}
setVideoPlaying (id: string, playing: boolean) { setVideoPlaying (id: string, playing: boolean) {
const videoData = this.customMedia.get(id) const videoData = this.customMedia.get(id)
if (videoData?.video) { if (videoData?.video) {
@ -547,13 +528,9 @@ export class ThreeJsMedia {
console.log('Exact test mesh added with dimensions:', width, height, 'and rotation:', rotation) console.log('Exact test mesh added with dimensions:', width, height, 'and rotation:', rotation)
} }
lastCheck = 0
THROTTLE_TIME = 100
tryIntersectMedia () { tryIntersectMedia () {
// hack: need to optimize this by pulling only in distance of interaction instead and throttle // hack: need to optimize this by pulling only in distance of interaction instead (or throttle)!
if (this.customMedia.size === 0) return if (this.customMedia.size === 0) return
if (Date.now() - this.lastCheck < this.THROTTLE_TIME) return
this.lastCheck = Date.now()
const { camera, scene } = this.worldRenderer const { camera, scene } = this.worldRenderer
const raycaster = new THREE.Raycaster() const raycaster = new THREE.Raycaster()

View file

@ -1,160 +0,0 @@
import * as THREE from 'three'
interface ParticleMesh extends THREE.Mesh {
velocity: THREE.Vector3;
}
interface ParticleConfig {
fountainHeight: number;
resetHeight: number;
xVelocityRange: number;
zVelocityRange: number;
particleCount: number;
particleRadiusRange: { min: number; max: number };
yVelocityRange: { min: number; max: number };
}
export interface FountainOptions {
position?: { x: number, y: number, z: number }
particleConfig?: Partial<ParticleConfig>;
}
export class Fountain {
private readonly particles: ParticleMesh[] = []
private readonly config: { particleConfig: ParticleConfig }
private readonly position: THREE.Vector3
container: THREE.Object3D | undefined
constructor (public sectionId: string, options: FountainOptions = {}) {
this.position = options.position ? new THREE.Vector3(options.position.x, options.position.y, options.position.z) : new THREE.Vector3(0, 0, 0)
this.config = this.createConfig(options.particleConfig)
}
private createConfig (
particleConfigOverride?: Partial<ParticleConfig>
): { particleConfig: ParticleConfig } {
const particleConfig: ParticleConfig = {
fountainHeight: 10,
resetHeight: 0,
xVelocityRange: 0.4,
zVelocityRange: 0.4,
particleCount: 400,
particleRadiusRange: { min: 0.1, max: 0.6 },
yVelocityRange: { min: 0.1, max: 2 },
...particleConfigOverride
}
return { particleConfig }
}
createParticles (container: THREE.Object3D): void {
this.container = container
const colorStart = new THREE.Color(0xff_ff_00)
const colorEnd = new THREE.Color(0xff_a5_00)
for (let i = 0; i < this.config.particleConfig.particleCount; i++) {
const radius = Math.random() *
(this.config.particleConfig.particleRadiusRange.max - this.config.particleConfig.particleRadiusRange.min) +
this.config.particleConfig.particleRadiusRange.min
const geometry = new THREE.SphereGeometry(radius)
const material = new THREE.MeshBasicMaterial({
color: colorStart.clone().lerp(colorEnd, Math.random())
})
const mesh = new THREE.Mesh(geometry, material)
const particle = mesh as unknown as ParticleMesh
particle.position.set(
this.position.x + (Math.random() - 0.5) * this.config.particleConfig.xVelocityRange * 2,
this.position.y + this.config.particleConfig.fountainHeight,
this.position.z + (Math.random() - 0.5) * this.config.particleConfig.zVelocityRange * 2
)
particle.velocity = new THREE.Vector3(
(Math.random() - 0.5) * this.config.particleConfig.xVelocityRange,
-Math.random() * this.config.particleConfig.yVelocityRange.max,
(Math.random() - 0.5) * this.config.particleConfig.zVelocityRange
)
this.particles.push(particle)
this.container.add(particle)
// this.container.onBeforeRender = () => {
// this.render()
// }
}
}
render (): void {
for (const particle of this.particles) {
particle.velocity.y -= 0.01 + Math.random() * 0.1
particle.position.add(particle.velocity)
if (particle.position.y < this.position.y + this.config.particleConfig.resetHeight) {
particle.position.set(
this.position.x + (Math.random() - 0.5) * this.config.particleConfig.xVelocityRange * 2,
this.position.y + this.config.particleConfig.fountainHeight,
this.position.z + (Math.random() - 0.5) * this.config.particleConfig.zVelocityRange * 2
)
particle.velocity.set(
(Math.random() - 0.5) * this.config.particleConfig.xVelocityRange,
-Math.random() * this.config.particleConfig.yVelocityRange.max,
(Math.random() - 0.5) * this.config.particleConfig.zVelocityRange
)
}
}
}
private updateParticleCount (newCount: number): void {
if (newCount !== this.config.particleConfig.particleCount) {
this.config.particleConfig.particleCount = newCount
const currentCount = this.particles.length
if (newCount > currentCount) {
this.addParticles(newCount - currentCount)
} else if (newCount < currentCount) {
this.removeParticles(currentCount - newCount)
}
}
}
private addParticles (count: number): void {
const geometry = new THREE.SphereGeometry(0.1)
const material = new THREE.MeshBasicMaterial({ color: 0x00_ff_00 })
for (let i = 0; i < count; i++) {
const mesh = new THREE.Mesh(geometry, material)
const particle = mesh as unknown as ParticleMesh
particle.position.copy(this.position)
particle.velocity = new THREE.Vector3(
Math.random() * this.config.particleConfig.xVelocityRange -
this.config.particleConfig.xVelocityRange / 2,
Math.random() * 2,
Math.random() * this.config.particleConfig.zVelocityRange -
this.config.particleConfig.zVelocityRange / 2
)
this.particles.push(particle)
this.container!.add(particle)
}
}
private removeParticles (count: number): void {
for (let i = 0; i < count; i++) {
const particle = this.particles.pop()
if (particle) {
this.container!.remove(particle)
}
}
}
public dispose (): void {
for (const particle of this.particles) {
particle.geometry.dispose()
if (Array.isArray(particle.material)) {
for (const material of particle.material) material.dispose()
} else {
particle.material.dispose()
}
}
}
}

View file

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

View file

@ -1,6 +1,4 @@
import * as THREE from 'three' 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) => { 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 // not cleaning texture there as it might be used by other objects, but would be good to also do that
@ -18,56 +16,3 @@ export const disposeObject = (obj: THREE.Object3D, cleanTextures = false) => {
} }
} }
} }
let textureCache: Record<string, THREE.Texture> = {}
let imagesPromises: Record<string, Promise<THREE.Texture>> = {}
export const loadThreeJsTextureFromUrlSync = (imageUrl: string) => {
const texture = new THREE.Texture()
const promise = getLoadedImage(imageUrl).then(image => {
texture.image = image
texture.needsUpdate = true
return texture
})
return {
texture,
promise
}
}
export const loadThreeJsTextureFromUrl = async (imageUrl: string) => {
const loaded = new THREE.TextureLoader().loadAsync(imageUrl)
return loaded
}
export const loadThreeJsTextureFromBitmap = (image: ImageBitmap) => {
const canvas = createCanvas(image.width, image.height)
const ctx = canvas.getContext('2d')!
ctx.drawImage(image, 0, 0)
const texture = new THREE.Texture(canvas)
texture.magFilter = THREE.NearestFilter
texture.minFilter = THREE.NearestFilter
return texture
}
export async function loadTexture (texture: string, cb: (texture: THREE.Texture) => void, onLoad?: () => void): Promise<void> {
const cached = textureCache[texture]
if (!cached) {
const { promise, resolve } = Promise.withResolvers<THREE.Texture>()
const t = loadThreeJsTextureFromUrlSync(texture)
textureCache[texture] = t.texture
void t.promise.then(resolve)
imagesPromises[texture] = promise
}
cb(textureCache[texture])
void imagesPromises[texture].then(() => {
onLoad?.()
})
}
export const clearTextureCache = () => {
textureCache = {}
imagesPromises = {}
}

View file

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

View file

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

View file

@ -1,9 +1,10 @@
import * as THREE from 'three' import * as THREE from 'three'
import { LineMaterial, LineSegmentsGeometry, Wireframe } from 'three-stdlib' import { LineMaterial, LineSegmentsGeometry, Wireframe } from 'three-stdlib'
import { Vec3 } from 'vec3' import { Vec3 } from 'vec3'
import { subscribeKey } from 'valtio/utils'
import { Block } from 'prismarine-block'
import { BlockShape, BlocksShapes } from 'renderer/viewer/lib/basePlayerState' import { BlockShape, BlocksShapes } from 'renderer/viewer/lib/basePlayerState'
import { WorldRendererThree } from '../worldrendererThree' import { WorldRendererThree } from '../worldrendererThree'
import { loadThreeJsTextureFromUrl } from '../threeJsUtils'
import destroyStage0 from '../../../../assets/destroy_stage_0.png' import destroyStage0 from '../../../../assets/destroy_stage_0.png'
import destroyStage1 from '../../../../assets/destroy_stage_1.png' import destroyStage1 from '../../../../assets/destroy_stage_1.png'
import destroyStage2 from '../../../../assets/destroy_stage_2.png' import destroyStage2 from '../../../../assets/destroy_stage_2.png'
@ -28,24 +29,24 @@ export class CursorBlock {
} }
cursorLineMaterial: LineMaterial cursorLineMaterial: LineMaterial
interactionLines: null | { blockPos: Vec3, mesh: THREE.Group, shapePositions: BlocksShapes | undefined } = null interactionLines: null | { blockPos: Vec3, mesh: THREE.Group } = null
prevColor: string | undefined prevColor: string | undefined
blockBreakMesh: THREE.Mesh blockBreakMesh: THREE.Mesh
breakTextures: THREE.Texture[] = [] breakTextures: THREE.Texture[] = []
constructor (public readonly worldRenderer: WorldRendererThree) { constructor (public readonly worldRenderer: WorldRendererThree) {
// Initialize break mesh and textures // Initialize break mesh and textures
const loader = new THREE.TextureLoader()
const destroyStagesImages = [ const destroyStagesImages = [
destroyStage0, destroyStage1, destroyStage2, destroyStage3, destroyStage4, destroyStage0, destroyStage1, destroyStage2, destroyStage3, destroyStage4,
destroyStage5, destroyStage6, destroyStage7, destroyStage8, destroyStage9 destroyStage5, destroyStage6, destroyStage7, destroyStage8, destroyStage9
] ]
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
void loadThreeJsTextureFromUrl(destroyStagesImages[i]).then((texture) => { const texture = loader.load(destroyStagesImages[i])
texture.magFilter = THREE.NearestFilter texture.magFilter = THREE.NearestFilter
texture.minFilter = THREE.NearestFilter texture.minFilter = THREE.NearestFilter
this.breakTextures.push(texture) this.breakTextures.push(texture)
})
} }
const breakMaterial = new THREE.MeshBasicMaterial({ const breakMaterial = new THREE.MeshBasicMaterial({
@ -59,26 +60,18 @@ export class CursorBlock {
this.blockBreakMesh.name = 'blockBreakMesh' this.blockBreakMesh.name = 'blockBreakMesh'
this.worldRenderer.scene.add(this.blockBreakMesh) this.worldRenderer.scene.add(this.blockBreakMesh)
this.worldRenderer.onReactivePlayerStateUpdated('gameMode', () => { subscribeKey(this.worldRenderer.playerState.reactive, 'gameMode', () => {
this.updateLineMaterial() this.updateLineMaterial()
}) })
// todo figure out why otherwise fog from skybox breaks it
setTimeout(() => { this.updateLineMaterial()
this.updateLineMaterial()
if (this.interactionLines) {
this.setHighlightCursorBlock(this.interactionLines.blockPos, this.interactionLines.shapePositions, true)
}
})
} }
// Update functions // Update functions
updateLineMaterial () { updateLineMaterial () {
const inCreative = this.worldRenderer.playerStateReactive.gameMode === 'creative' const inCreative = this.worldRenderer.displayOptions.playerState.reactive.gameMode === 'creative'
const pixelRatio = this.worldRenderer.renderer.getPixelRatio() const pixelRatio = this.worldRenderer.renderer.getPixelRatio()
if (this.cursorLineMaterial) {
this.cursorLineMaterial.dispose()
}
this.cursorLineMaterial = new LineMaterial({ this.cursorLineMaterial = new LineMaterial({
color: (() => { color: (() => {
switch (this.worldRenderer.worldRendererConfig.highlightBlockColor) { switch (this.worldRenderer.worldRendererConfig.highlightBlockColor) {
@ -125,8 +118,8 @@ export class CursorBlock {
} }
} }
setHighlightCursorBlock (blockPos: Vec3 | null, shapePositions?: BlocksShapes, force = false): void { setHighlightCursorBlock (blockPos: Vec3 | null, shapePositions?: BlocksShapes): void {
if (blockPos && this.interactionLines && blockPos.equals(this.interactionLines.blockPos) && !force) { if (blockPos && this.interactionLines && blockPos.equals(this.interactionLines.blockPos)) {
return return
} }
if (this.interactionLines !== null) { if (this.interactionLines !== null) {
@ -150,7 +143,7 @@ export class CursorBlock {
} }
this.worldRenderer.scene.add(group) this.worldRenderer.scene.add(group)
group.visible = !this.cursorLinesHidden group.visible = !this.cursorLinesHidden
this.interactionLines = { blockPos, mesh: group, shapePositions } this.interactionLines = { blockPos, mesh: group }
} }
render () { render () {

View file

@ -4,9 +4,8 @@ import { XRControllerModelFactory } from 'three/examples/jsm/webxr/XRControllerM
import { buttonMap as standardButtonsMap } from 'contro-max/build/gamepad' import { buttonMap as standardButtonsMap } from 'contro-max/build/gamepad'
import * as THREE from 'three' import * as THREE from 'three'
import { WorldRendererThree } from '../worldrendererThree' import { WorldRendererThree } from '../worldrendererThree'
import { DocumentRenderer } from '../documentRenderer'
export async function initVR (worldRenderer: WorldRendererThree, documentRenderer: DocumentRenderer) { export async function initVR (worldRenderer: WorldRendererThree) {
if (!('xr' in navigator) || !worldRenderer.worldRendererConfig.vrSupport) return if (!('xr' in navigator) || !worldRenderer.worldRendererConfig.vrSupport) return
const { renderer } = worldRenderer const { renderer } = worldRenderer
@ -27,13 +26,12 @@ export async function initVR (worldRenderer: WorldRendererThree, documentRendere
function enableVr () { function enableVr () {
renderer.xr.enabled = true renderer.xr.enabled = true
// renderer.xr.setReferenceSpaceType('local-floor')
worldRenderer.reactiveState.preventEscapeMenu = true worldRenderer.reactiveState.preventEscapeMenu = true
} }
function disableVr () { function disableVr () {
renderer.xr.enabled = false renderer.xr.enabled = false
worldRenderer.cameraGroupVr = undefined worldRenderer.cameraObjectOverride = undefined
worldRenderer.reactiveState.preventEscapeMenu = false worldRenderer.reactiveState.preventEscapeMenu = false
worldRenderer.scene.remove(user) worldRenderer.scene.remove(user)
vrButtonContainer.hidden = true vrButtonContainer.hidden = true
@ -102,7 +100,7 @@ export async function initVR (worldRenderer: WorldRendererThree, documentRendere
// hack for vr camera // hack for vr camera
const user = new THREE.Group() const user = new THREE.Group()
user.name = 'vr-camera-container' user.add(worldRenderer.camera)
worldRenderer.scene.add(user) worldRenderer.scene.add(user)
const controllerModelFactory = new XRControllerModelFactory(new GLTFLoader()) const controllerModelFactory = new XRControllerModelFactory(new GLTFLoader())
const controller1 = renderer.xr.getControllerGrip(0) const controller1 = renderer.xr.getControllerGrip(0)
@ -191,7 +189,7 @@ export async function initVR (worldRenderer: WorldRendererThree, documentRendere
} }
// appViewer.backend?.updateCamera(null, yawOffset, 0) // appViewer.backend?.updateCamera(null, yawOffset, 0)
// worldRenderer.updateCamera(null, bot.entity.yaw, bot.entity.pitch) worldRenderer.updateCamera(null, bot.entity.yaw, bot.entity.pitch)
// todo restore this logic (need to preserve ability to move camera) // todo restore this logic (need to preserve ability to move camera)
// const xrCamera = renderer.xr.getCamera() // const xrCamera = renderer.xr.getCamera()
@ -199,15 +197,16 @@ export async function initVR (worldRenderer: WorldRendererThree, documentRendere
// bot.entity.yaw = Math.atan2(-d.x, -d.z) // bot.entity.yaw = Math.atan2(-d.x, -d.z)
// bot.entity.pitch = Math.asin(d.y) // bot.entity.pitch = Math.asin(d.y)
documentRenderer.frameRender(false) // todo ?
// bot.physics.stepHeight = 1
worldRenderer.render()
}) })
renderer.xr.addEventListener('sessionstart', () => { renderer.xr.addEventListener('sessionstart', () => {
user.add(worldRenderer.camera) worldRenderer.cameraObjectOverride = user
worldRenderer.cameraGroupVr = user
}) })
renderer.xr.addEventListener('sessionend', () => { renderer.xr.addEventListener('sessionend', () => {
worldRenderer.cameraGroupVr = undefined worldRenderer.cameraObjectOverride = undefined
user.remove(worldRenderer.camera)
}) })
worldRenderer.abortController.signal.addEventListener('abort', disableVr) worldRenderer.abortController.signal.addEventListener('abort', disableVr)

View file

@ -3,35 +3,34 @@ import { Vec3 } from 'vec3'
import nbt from 'prismarine-nbt' import nbt from 'prismarine-nbt'
import PrismarineChatLoader from 'prismarine-chat' import PrismarineChatLoader from 'prismarine-chat'
import * as tweenJs from '@tweenjs/tween.js' import * as tweenJs from '@tweenjs/tween.js'
import { Biome } from 'minecraft-data' import { subscribeKey } from 'valtio/utils'
import { renderSign } from '../sign-renderer' import { renderSign } from '../sign-renderer'
import { DisplayWorldOptions, GraphicsInitOptions } from '../../../src/appViewer' import { DisplayWorldOptions, GraphicsInitOptions, RendererReactiveState } from '../../../src/appViewer'
import { chunkPos, sectionPos } from '../lib/simpleUtils' import { chunkPos, sectionPos } from '../lib/simpleUtils'
import { WorldRendererCommon } from '../lib/worldrendererCommon' import { WorldRendererCommon } from '../lib/worldrendererCommon'
import { addNewStat } from '../lib/ui/newStats' import { addNewStat, removeAllStats } from '../lib/ui/newStats'
import { MesherGeometryOutput } from '../lib/mesher/shared' import { MesherGeometryOutput } from '../lib/mesher/shared'
import { ItemSpecificContextProperties } from '../lib/basePlayerState' import { ItemSpecificContextProperties } from '../lib/basePlayerState'
import { getMyHand } from '../lib/hand'
import { setBlockPosition } from '../lib/mesher/standaloneRenderer' import { setBlockPosition } from '../lib/mesher/standaloneRenderer'
import { getMyHand } from './hand' import { sendVideoPlay, sendVideoStop } from '../../../src/customChannels'
import HoldingBlock from './holdingBlock' import HoldingBlock from './holdingBlock'
import { getMesh } from './entity/EntityMesh' import { getMesh } from './entity/EntityMesh'
import { armorModel } from './entity/armorModels' import { armorModel } from './entity/armorModels'
import { disposeObject, loadThreeJsTextureFromBitmap } from './threeJsUtils' import { disposeObject } from './threeJsUtils'
import { CursorBlock } from './world/cursorBlock' import { CursorBlock } from './world/cursorBlock'
import { getItemUv } from './appShared' import { getItemUv } from './appShared'
import { initVR } from './world/vr'
import { Entities } from './entities' import { Entities } from './entities'
import { ThreeJsSound } from './threeJsSound' import { ThreeJsSound } from './threeJsSound'
import { CameraShake } from './cameraShake' import { CameraShake } from './cameraShake'
import { ThreeJsMedia } from './threeJsMedia' import { ThreeJsMedia } from './threeJsMedia'
import { Fountain } from './threeJsParticles'
import { WaypointsRenderer } from './waypoints'
import { DEFAULT_TEMPERATURE, SkyboxRenderer } from './skyboxRenderer'
type SectionKey = string type SectionKey = string
export class WorldRendererThree extends WorldRendererCommon { export class WorldRendererThree extends WorldRendererCommon {
outputFormat = 'threeJs' as const outputFormat = 'threeJs' as const
sectionObjects: Record<string, THREE.Object3D & { foutain?: boolean }> = {} sectionObjects: Record<string, THREE.Object3D> = {}
chunkTextures = new Map<string, { [pos: string]: THREE.Texture }>() chunkTextures = new Map<string, { [pos: string]: THREE.Texture }>()
signsCache = new Map<string, any>() signsCache = new Map<string, any>()
starField: StarField starField: StarField
@ -42,16 +41,14 @@ export class WorldRendererThree extends WorldRendererCommon {
ambientLight = new THREE.AmbientLight(0xcc_cc_cc) ambientLight = new THREE.AmbientLight(0xcc_cc_cc)
directionalLight = new THREE.DirectionalLight(0xff_ff_ff, 0.5) directionalLight = new THREE.DirectionalLight(0xff_ff_ff, 0.5)
entities = new Entities(this) entities = new Entities(this)
cameraGroupVr?: THREE.Object3D cameraObjectOverride?: THREE.Object3D // for xr
material = new THREE.MeshLambertMaterial({ vertexColors: true, transparent: true, alphaTest: 0.1 }) material = new THREE.MeshLambertMaterial({ vertexColors: true, transparent: true, alphaTest: 0.1 })
itemsTexture: THREE.Texture itemsTexture: THREE.Texture
cursorBlock: CursorBlock cursorBlock = new CursorBlock(this)
onRender: Array<() => void> = [] onRender: Array<() => void> = []
cameraShake: CameraShake cameraShake: CameraShake
cameraContainer: THREE.Object3D
media: ThreeJsMedia media: ThreeJsMedia
waitingChunksToDisplay = {} as { [chunkKey: string]: SectionKey[] } waitingChunksToDisplay = {} as { [chunkKey: string]: SectionKey[] }
waypoints: WaypointsRenderer
camera: THREE.PerspectiveCamera camera: THREE.PerspectiveCamera
renderTimeAvg = 0 renderTimeAvg = 0
sectionsOffsetsAnimations = {} as { sectionsOffsetsAnimations = {} as {
@ -71,12 +68,6 @@ export class WorldRendererThree extends WorldRendererCommon {
limitZ?: number, limitZ?: number,
} }
} }
fountains: Fountain[] = []
DEBUG_RAYCAST = false
skyboxRenderer: SkyboxRenderer
private currentPosTween?: tweenJs.Tween<THREE.Vector3>
private currentRotTween?: tweenJs.Tween<{ pitch: number, yaw: number }>
get tilesRendered () { get tilesRendered () {
return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj as any).tilesCount, 0) return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj as any).tilesCount, 0)
@ -90,29 +81,19 @@ export class WorldRendererThree extends WorldRendererCommon {
if (!initOptions.resourcesManager) throw new Error('resourcesManager is required') if (!initOptions.resourcesManager) throw new Error('resourcesManager is required')
super(initOptions.resourcesManager, displayOptions, initOptions) super(initOptions.resourcesManager, displayOptions, initOptions)
this.renderer = renderer
displayOptions.rendererState.renderer = WorldRendererThree.getRendererInfo(renderer) ?? '...' displayOptions.rendererState.renderer = WorldRendererThree.getRendererInfo(renderer) ?? '...'
this.starField = new StarField(this) this.starField = new StarField(this.scene)
this.cursorBlock = new CursorBlock(this)
this.holdingBlock = new HoldingBlock(this) this.holdingBlock = new HoldingBlock(this)
this.holdingBlockLeft = new HoldingBlock(this, true) 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.addDebugOverlay()
this.resetScene() this.resetScene()
void this.init() this.init()
void initVR(this)
this.soundSystem = new ThreeJsSound(this) this.soundSystem = new ThreeJsSound(this)
this.cameraShake = new CameraShake(this, this.onRender) this.cameraShake = new CameraShake(this.camera, this.onRender)
this.media = new ThreeJsMedia(this) 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),
// })
this.renderUpdateEmitter.on('chunkFinished', (chunkKey: string) => { this.renderUpdateEmitter.on('chunkFinished', (chunkKey: string) => {
this.finishChunk(chunkKey) this.finishChunk(chunkKey)
@ -120,18 +101,12 @@ export class WorldRendererThree extends WorldRendererCommon {
this.worldSwitchActions() this.worldSwitchActions()
} }
get cameraObject () {
return this.cameraGroupVr ?? this.cameraContainer
}
worldSwitchActions () { worldSwitchActions () {
this.onWorldSwitched.push(() => { this.onWorldSwitched.push(() => {
// clear custom blocks // clear custom blocks
this.protocolCustomBlocks.clear() this.protocolCustomBlocks.clear()
// Reset section animations // Reset section animations
this.sectionsOffsetsAnimations = {} this.sectionsOffsetsAnimations = {}
// Clear waypoints
this.waypoints.clear()
}) })
} }
@ -152,10 +127,6 @@ export class WorldRendererThree extends WorldRendererCommon {
} }
} }
updatePlayerEntity (e: any) {
this.entities.handlePlayerEntity(e)
}
resetScene () { resetScene () {
this.scene.matrixAutoUpdate = false // for perf this.scene.matrixAutoUpdate = false // for perf
this.scene.background = new THREE.Color(this.initOptions.config.sceneBackground) this.scene.background = new THREE.Color(this.initOptions.config.sceneBackground)
@ -166,49 +137,27 @@ export class WorldRendererThree extends WorldRendererCommon {
const size = this.renderer.getSize(new THREE.Vector2()) const size = this.renderer.getSize(new THREE.Vector2())
this.camera = new THREE.PerspectiveCamera(75, size.x / size.y, 0.1, 1000) 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 () { override watchReactivePlayerState () {
super.watchReactivePlayerState() super.watchReactivePlayerState()
this.onReactivePlayerStateUpdated('inWater', (value) => { this.onReactiveValueUpdated('inWater', (value) => {
this.skyboxRenderer.updateWaterState(value, this.playerStateReactive.waterBreathing) this.scene.fog = value ? new THREE.Fog(0x00_00_ff, 0.1, this.displayOptions.playerState.reactive.waterBreathing ? 100 : 20) : null
}) })
this.onReactivePlayerStateUpdated('waterBreathing', (value) => { this.onReactiveValueUpdated('ambientLight', (value) => {
this.skyboxRenderer.updateWaterState(this.playerStateReactive.inWater, value)
})
this.onReactivePlayerStateUpdated('ambientLight', (value) => {
if (!value) return if (!value) return
this.ambientLight.intensity = value this.ambientLight.intensity = value
}) })
this.onReactivePlayerStateUpdated('directionalLight', (value) => { this.onReactiveValueUpdated('directionalLight', (value) => {
if (!value) return if (!value) return
this.directionalLight.intensity = value this.directionalLight.intensity = value
}) })
this.onReactivePlayerStateUpdated('lookingAtBlock', (value) => { this.onReactiveValueUpdated('lookingAtBlock', (value) => {
this.cursorBlock.setHighlightCursorBlock(value ? new Vec3(value.x, value.y, value.z) : null, value?.shapes) this.cursorBlock.setHighlightCursorBlock(value ? new Vec3(value.x, value.y, value.z) : null, value?.shapes)
}) })
this.onReactivePlayerStateUpdated('diggingBlock', (value) => { this.onReactiveValueUpdated('diggingBlock', (value) => {
this.cursorBlock.updateBreakAnimation(value ? { x: value.x, y: value.y, z: value.z } : undefined, value?.stage ?? null, value?.mergedShape) this.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 () {
super.watchReactiveConfig()
this.onReactiveConfigUpdated('showChunkBorders', (value) => {
this.updateShowChunksBorder(value)
})
this.onReactiveConfigUpdated('defaultSkybox', (value) => {
this.skyboxRenderer.updateDefaultSkybox(value)
})
} }
changeHandSwingingState (isAnimationPlaying: boolean, isLeft = false) { changeHandSwingingState (isAnimationPlaying: boolean, isLeft = false) {
@ -221,18 +170,20 @@ export class WorldRendererThree extends WorldRendererCommon {
} }
async updateAssetsData (): Promise<void> { async updateAssetsData (): Promise<void> {
const resources = this.resourcesManager.currentResources const resources = this.resourcesManager.currentResources!
const oldTexture = this.material.map const oldTexture = this.material.map
const oldItemsTexture = this.itemsTexture const oldItemsTexture = this.itemsTexture
const texture = loadThreeJsTextureFromBitmap(resources.blocksAtlasImage) const texture = await new THREE.TextureLoader().loadAsync(resources.blocksAtlasParser.latestImage)
texture.needsUpdate = true texture.magFilter = THREE.NearestFilter
texture.minFilter = THREE.NearestFilter
texture.flipY = false texture.flipY = false
this.material.map = texture this.material.map = texture
const itemsTexture = loadThreeJsTextureFromBitmap(resources.itemsAtlasImage) const itemsTexture = await new THREE.TextureLoader().loadAsync(resources.itemsAtlasParser.latestImage)
itemsTexture.needsUpdate = true itemsTexture.magFilter = THREE.NearestFilter
itemsTexture.minFilter = THREE.NearestFilter
itemsTexture.flipY = false itemsTexture.flipY = false
this.itemsTexture = itemsTexture this.itemsTexture = itemsTexture
@ -271,30 +222,17 @@ export class WorldRendererThree extends WorldRendererCommon {
} else { } else {
this.starField.remove() this.starField.remove()
} }
this.skyboxRenderer.updateTime(newTime)
}
biomeUpdated (biome: Biome): void {
if (biome?.temperature !== undefined) {
this.skyboxRenderer.updateTemperature(biome.temperature)
}
}
biomeReset (): void {
// Reset to default temperature when biome is unknown
this.skyboxRenderer.updateTemperature(DEFAULT_TEMPERATURE)
} }
getItemRenderData (item: Record<string, any>, specificProps: ItemSpecificContextProperties) { getItemRenderData (item: Record<string, any>, specificProps: ItemSpecificContextProperties) {
return getItemUv(item, specificProps, this.resourcesManager, this.playerStateReactive) return getItemUv(item, specificProps, this.resourcesManager)
} }
async demoModel () { async demoModel () {
//@ts-expect-error //@ts-expect-error
const pos = cursorBlockRel(0, 1, 0).position const pos = cursorBlockRel(0, 1, 0).position
const mesh = (await getMyHand())! const mesh = await getMyHand()
// mesh.rotation.y = THREE.MathUtils.degToRad(90) // mesh.rotation.y = THREE.MathUtils.degToRad(90)
setBlockPosition(mesh, pos) setBlockPosition(mesh, pos)
const helper = new THREE.BoxHelper(mesh, 0xff_ff_00) const helper = new THREE.BoxHelper(mesh, 0xff_ff_00)
@ -349,11 +287,10 @@ export class WorldRendererThree extends WorldRendererCommon {
section.renderOrder = 500 - chunkDistance section.renderOrder = 500 - chunkDistance
} }
override updateViewerPosition (pos: Vec3): void { updateViewerPosition (pos: Vec3): void {
this.viewerChunkPosition = pos this.viewerPosition = pos
} const cameraPos = this.camera.position.toArray().map(x => Math.floor(x / 16)) as [number, number, number]
this.cameraSectionPos = new Vec3(...cameraPos)
cameraSectionPositionUpdate () {
// eslint-disable-next-line guard-for-in // eslint-disable-next-line guard-for-in
for (const key in this.sectionObjects) { for (const key in this.sectionObjects) {
const value = this.sectionObjects[key] const value = this.sectionObjects[key]
@ -396,7 +333,7 @@ export class WorldRendererThree extends WorldRendererCommon {
geometry.setAttribute('normal', new THREE.BufferAttribute(data.geometry.normals, 3)) geometry.setAttribute('normal', new THREE.BufferAttribute(data.geometry.normals, 3))
geometry.setAttribute('color', new THREE.BufferAttribute(data.geometry.colors, 3)) geometry.setAttribute('color', new THREE.BufferAttribute(data.geometry.colors, 3))
geometry.setAttribute('uv', new THREE.BufferAttribute(data.geometry.uvs, 2)) geometry.setAttribute('uv', new THREE.BufferAttribute(data.geometry.uvs, 2))
geometry.index = new THREE.BufferAttribute(data.geometry.indices as Uint32Array | Uint16Array, 1) geometry.setIndex(data.geometry.indices)
const mesh = new THREE.Mesh(geometry, this.material) const mesh = new THREE.Mesh(geometry, this.material)
mesh.position.set(data.geometry.sx, data.geometry.sy, data.geometry.sz) mesh.position.set(data.geometry.sx, data.geometry.sy, data.geometry.sz)
@ -457,7 +394,7 @@ export class WorldRendererThree extends WorldRendererCommon {
this.scene.add(object) this.scene.add(object)
} }
getSignTexture (position: Vec3, blockEntity, isHanging, backSide = false) { getSignTexture (position: Vec3, blockEntity, backSide = false) {
const chunk = chunkPos(position) const chunk = chunkPos(position)
let textures = this.chunkTextures.get(`${chunk[0]},${chunk[1]}`) let textures = this.chunkTextures.get(`${chunk[0]},${chunk[1]}`)
if (!textures) { if (!textures) {
@ -469,7 +406,7 @@ export class WorldRendererThree extends WorldRendererCommon {
if (textures[texturekey]) return textures[texturekey] if (textures[texturekey]) return textures[texturekey]
const PrismarineChat = PrismarineChatLoader(this.version) const PrismarineChat = PrismarineChatLoader(this.version)
const canvas = renderSign(blockEntity, isHanging, PrismarineChat) const canvas = renderSign(blockEntity, PrismarineChat)
if (!canvas) return if (!canvas) return
const tex = new THREE.Texture(canvas) const tex = new THREE.Texture(canvas)
tex.magFilter = THREE.NearestFilter tex.magFilter = THREE.NearestFilter
@ -479,149 +416,15 @@ export class WorldRendererThree extends WorldRendererCommon {
return tex 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) { setFirstPersonCamera (pos: Vec3 | null, yaw: number, pitch: number) {
const yOffset = this.playerStateReactive.eyeHeight const cam = this.cameraObjectOverride || this.camera
const yOffset = this.displayOptions.playerState.getEyeHeight()
this.camera = cam as THREE.PerspectiveCamera
this.updateCamera(pos?.offset(0, yOffset, 0) ?? null, yaw, pitch) this.updateCamera(pos?.offset(0, yOffset, 0) ?? null, yaw, pitch)
this.media.tryIntersectMedia() 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 { updateCamera (pos: Vec3 | null, yaw: number, pitch: number): void {
// if (this.freeFlyMode) { // if (this.freeFlyMode) {
// pos = this.freeFlyState.position // pos = this.freeFlyState.position
@ -630,151 +433,37 @@ export class WorldRendererThree extends WorldRendererCommon {
// } // }
if (pos) { if (pos) {
if (this.renderer.xr.isPresenting) { new tweenJs.Tween(this.camera.position).to({ x: pos.x, y: pos.y, z: pos.z }, 50).start()
pos.y -= this.camera.position.y // Fix Y position of camera in world
}
this.currentPosTween?.stop()
this.currentPosTween = new tweenJs.Tween(this.cameraObject.position).to({ x: pos.x, y: pos.y, z: pos.z }, this.playerStateUtils.isSpectatingEntity() ? 150 : 50).start()
// this.freeFlyState.position = pos // 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 () {
const { chunksRenderAboveOverride, chunksRenderBelowOverride, chunksRenderDistanceOverride, chunksRenderAboveEnabled, chunksRenderBelowEnabled, chunksRenderDistanceEnabled } = this.reactiveDebugParams
const baseY = this.cameraSectionPos.y * 16
if (
this.displayOptions.inWorldRenderingConfig.enableDebugOverlay &&
chunksRenderAboveOverride !== undefined ||
chunksRenderBelowOverride !== undefined ||
chunksRenderDistanceOverride !== undefined
) {
for (const [key, object] of Object.entries(this.sectionObjects)) {
const [x, y, z] = key.split(',').map(Number)
const isVisible =
// eslint-disable-next-line no-constant-binary-expression, sonarjs/no-redundant-boolean
(chunksRenderAboveEnabled && chunksRenderAboveOverride !== undefined) ? y <= (baseY + chunksRenderAboveOverride) : true &&
// eslint-disable-next-line @stylistic/indent-binary-ops, no-constant-binary-expression, sonarjs/no-redundant-boolean
(chunksRenderBelowEnabled && chunksRenderBelowOverride !== undefined) ? y >= (baseY - chunksRenderBelowOverride) : true &&
// eslint-disable-next-line @stylistic/indent-binary-ops
(chunksRenderDistanceEnabled && chunksRenderDistanceOverride !== undefined) ? Math.abs(y - baseY) <= chunksRenderDistanceOverride : true
object.visible = isVisible
}
} else {
for (const object of Object.values(this.sectionObjects)) {
object.visible = true
}
}
} }
render (sizeChanged = false) { render (sizeChanged = false) {
if (this.reactiveDebugParams.stopRendering) return
this.debugChunksVisibilityOverride()
const start = performance.now() const start = performance.now()
this.lastRendered = performance.now() this.lastRendered = performance.now()
this.cursorBlock.render() this.cursorBlock.render()
this.updateSectionOffsets()
// Update skybox position to follow camera this.updateSectionOffsets()
const cameraPos = this.getCameraPosition()
this.skyboxRenderer.update(cameraPos, this.viewDistance)
const sizeOrFovChanged = sizeChanged || this.displayOptions.inWorldRenderingConfig.fov !== this.camera.fov const sizeOrFovChanged = sizeChanged || this.displayOptions.inWorldRenderingConfig.fov !== this.camera.fov
if (sizeOrFovChanged) { if (sizeOrFovChanged) {
const size = this.renderer.getSize(new THREE.Vector2()) this.camera.aspect = window.innerWidth / window.innerHeight
this.camera.aspect = size.width / size.height
this.camera.fov = this.displayOptions.inWorldRenderingConfig.fov this.camera.fov = this.displayOptions.inWorldRenderingConfig.fov
this.camera.updateProjectionMatrix() this.camera.updateProjectionMatrix()
} }
if (!this.reactiveDebugParams.disableEntities) { this.entities.render()
this.entities.render()
}
// eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
const cam = this.cameraGroupVr instanceof THREE.Group ? this.cameraGroupVr.children.find(child => child instanceof THREE.PerspectiveCamera) as THREE.PerspectiveCamera : this.camera const cam = this.camera instanceof THREE.Group ? this.camera.children.find(child => child instanceof THREE.PerspectiveCamera) as THREE.PerspectiveCamera : this.camera
this.renderer.render(this.scene, cam) this.renderer.render(this.scene, cam)
if ( if (this.displayOptions.inWorldRenderingConfig.showHand/* && !this.freeFlyMode */) {
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.holdingBlock.render(this.camera, this.renderer, this.ambientLight, this.directionalLight)
this.holdingBlockLeft.render(this.camera, this.renderer, this.ambientLight, this.directionalLight) this.holdingBlockLeft.render(this.camera, this.renderer, this.ambientLight, this.directionalLight)
} }
for (const fountain of this.fountains) {
if (this.sectionObjects[fountain.sectionId] && !this.sectionObjects[fountain.sectionId].foutain) {
fountain.createParticles(this.sectionObjects[fountain.sectionId])
this.sectionObjects[fountain.sectionId].foutain = true
}
fountain.render()
}
this.waypoints.render()
for (const onRender of this.onRender) { for (const onRender of this.onRender) {
onRender() onRender()
} }
@ -787,22 +476,12 @@ export class WorldRendererThree extends WorldRendererCommon {
} }
renderHead (position: Vec3, rotation: number, isWall: boolean, blockEntity) { renderHead (position: Vec3, rotation: number, isWall: boolean, blockEntity) {
let textureData: string const textures = blockEntity.SkullOwner?.Properties?.textures[0]
if (blockEntity.SkullOwner) { if (!textures) return
textureData = blockEntity.SkullOwner.Properties?.textures?.[0]?.Value
} else {
textureData = blockEntity.profile?.properties?.find(p => p.name === 'textures')?.value
}
if (!textureData) return
try { try {
const decodedData = JSON.parse(Buffer.from(textureData, 'base64').toString()) const textureData = JSON.parse(Buffer.from(textures.Value, 'base64').toString())
let skinUrl = decodedData.textures?.SKIN?.url const skinUrl = textureData.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 mesh = getMesh(this, skinUrl, armorModel.head)
const group = new THREE.Group() const group = new THREE.Group()
@ -826,7 +505,7 @@ export class WorldRendererThree extends WorldRendererCommon {
} }
renderSign (position: Vec3, rotation: number, isWall: boolean, isHanging: boolean, blockEntity) { renderSign (position: Vec3, rotation: number, isWall: boolean, isHanging: boolean, blockEntity) {
const tex = this.getSignTexture(position, blockEntity, isHanging) const tex = this.getSignTexture(position, blockEntity)
if (!tex) return if (!tex) return
@ -882,6 +561,7 @@ export class WorldRendererThree extends WorldRendererCommon {
} }
updateShowChunksBorder (value: boolean) { updateShowChunksBorder (value: boolean) {
this.displayOptions.inWorldRenderingConfig.showChunkBorders = value
for (const object of Object.values(this.sectionObjects)) { for (const object of Object.values(this.sectionObjects)) {
for (const child of object.children) { for (const child of object.children) {
if (child.name === 'helper') { if (child.name === 'helper') {
@ -897,16 +577,6 @@ export class WorldRendererThree extends WorldRendererCommon {
for (const mesh of Object.values(this.sectionObjects)) { for (const mesh of Object.values(this.sectionObjects)) {
this.scene.remove(mesh) 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) { getLoadedChunksRelative (pos: Vec3, includeY = false) {
@ -982,19 +652,6 @@ export class WorldRendererThree extends WorldRendererCommon {
destroy (): void { destroy (): void {
super.destroy() super.destroy()
this.skyboxRenderer.dispose()
}
shouldObjectVisible (object: THREE.Object3D) {
// Get chunk coordinates
const chunkX = Math.floor(object.position.x / 16) * 16
const chunkZ = Math.floor(object.position.z / 16) * 16
const sectionY = Math.floor(object.position.y / 16) * 16
const chunkKey = `${chunkX},${chunkZ}`
const sectionKey = `${chunkX},${sectionY},${chunkZ}`
return !!this.finishedChunks[chunkKey] || !!this.sectionObjects[sectionKey]
} }
updateSectionOffsets () { updateSectionOffsets () {
@ -1043,10 +700,6 @@ export class WorldRendererThree extends WorldRendererCommon {
} }
} }
} }
reloadWorld () {
this.entities.reloadEntities()
}
} }
class StarField { class StarField {
@ -1063,16 +716,7 @@ class StarField {
} }
} }
constructor ( constructor (private readonly scene: THREE.Scene) {
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 () { addToScene () {
@ -1083,6 +727,7 @@ class StarField {
const count = 7000 const count = 7000
const factor = 7 const factor = 7
const saturation = 10 const saturation = 10
const speed = 0.2
const geometry = new THREE.BufferGeometry() const geometry = new THREE.BufferGeometry()
@ -1113,8 +758,13 @@ class StarField {
// Create points and add them to the scene // Create points and add them to the scene
this.points = new THREE.Points(geometry, material) this.points = new THREE.Points(geometry, material)
this.worldRenderer.scene.add(this.points) this.scene.add(this.points)
const clock = new THREE.Clock()
this.points.onBeforeRender = (renderer, scene, camera) => {
this.points?.position.copy?.(camera.position)
material.uniforms.time.value = clock.getElapsedTime() * speed
}
this.points.renderOrder = -1 this.points.renderOrder = -1
} }
@ -1122,7 +772,7 @@ class StarField {
if (this.points) { if (this.points) {
this.points.geometry.dispose(); this.points.geometry.dispose();
(this.points.material as THREE.Material).dispose() (this.points.material as THREE.Material).dispose()
this.worldRenderer.scene.remove(this.points) this.scene.remove(this.points)
this.points = undefined this.points = undefined
} }

View file

@ -1,4 +1,3 @@
/// <reference types="./src/env" />
import { defineConfig, mergeRsbuildConfig, RsbuildPluginAPI } from '@rsbuild/core' import { defineConfig, mergeRsbuildConfig, RsbuildPluginAPI } from '@rsbuild/core'
import { pluginReact } from '@rsbuild/plugin-react' import { pluginReact } from '@rsbuild/plugin-react'
import { pluginTypedCSSModules } from '@rsbuild/plugin-typed-css-modules' import { pluginTypedCSSModules } from '@rsbuild/plugin-typed-css-modules'
@ -15,7 +14,6 @@ import { appAndRendererSharedConfig } from './renderer/rsbuildSharedConfig'
import { genLargeDataAliases } from './scripts/genLargeDataAliases' import { genLargeDataAliases } from './scripts/genLargeDataAliases'
import sharp from 'sharp' import sharp from 'sharp'
import supportedVersions from './src/supportedVersions.mjs' import supportedVersions from './src/supportedVersions.mjs'
import { startWsServer } from './scripts/wsServer'
const SINGLE_FILE_BUILD = process.env.SINGLE_FILE_BUILD === 'true' const SINGLE_FILE_BUILD = process.env.SINGLE_FILE_BUILD === 'true'
@ -50,7 +48,7 @@ if (fs.existsSync('./assets/release.json')) {
const configJson = JSON.parse(fs.readFileSync('./config.json', 'utf8')) const configJson = JSON.parse(fs.readFileSync('./config.json', 'utf8'))
try { try {
Object.assign(configJson, JSON.parse(fs.readFileSync(process.env.LOCAL_CONFIG_FILE || './config.local.json', 'utf8'))) Object.assign(configJson, JSON.parse(fs.readFileSync('./config.local.json', 'utf8')))
} catch (err) {} } catch (err) {}
if (dev) { if (dev) {
configJson.defaultProxy = ':8080' configJson.defaultProxy = ':8080'
@ -60,8 +58,6 @@ const configSource = (SINGLE_FILE_BUILD ? 'BUNDLED' : (process.env.CONFIG_JSON_S
const faviconPath = 'favicon.png' const faviconPath = 'favicon.png'
const enableMetrics = process.env.ENABLE_METRICS === 'true'
// base options are in ./renderer/rsbuildSharedConfig.ts // base options are in ./renderer/rsbuildSharedConfig.ts
const appConfig = defineConfig({ const appConfig = defineConfig({
html: { html: {
@ -73,7 +69,7 @@ const appConfig = defineConfig({
tag: 'link', tag: 'link',
attrs: { attrs: {
rel: 'manifest', rel: 'manifest',
crossorigin: 'anonymous', crossorigin: 'use-credentials',
href: 'manifest.json' href: 'manifest.json'
}, },
} }
@ -115,22 +111,6 @@ const appConfig = defineConfig({
js: 'source-map', js: 'source-map',
css: true, 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 ? { distPath: SINGLE_FILE_BUILD ? {
html: './single', html: './single',
} : undefined, } : undefined,
@ -139,13 +119,6 @@ const appConfig = defineConfig({
// 50kb limit for data uri // 50kb limit for data uri
dataUriLimit: SINGLE_FILE_BUILD ? 1 * 1024 * 1024 * 1024 : 50 * 1024 dataUriLimit: SINGLE_FILE_BUILD ? 1 * 1024 * 1024 * 1024 : 50 * 1024
}, },
performance: {
// prefetch: {
// include(filename) {
// return filename.includes('mc-data') || filename.includes('mc-assets')
// },
// },
},
source: { source: {
entry: { entry: {
index: './src/index.ts', index: './src/index.ts',
@ -161,15 +134,12 @@ const appConfig = defineConfig({
'process.platform': '"browser"', 'process.platform': '"browser"',
'process.env.GITHUB_URL': '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}`), JSON.stringify(`https://github.com/${process.env.GITHUB_REPOSITORY || `${process.env.VERCEL_GIT_REPO_OWNER}/${process.env.VERCEL_GIT_REPO_SLUG}` || githubRepositoryFallback}`),
'process.env.ALWAYS_MINIMAL_SERVER_UI': JSON.stringify(process.env.ALWAYS_MINIMAL_SERVER_UI), 'process.env.DEPS_VERSIONS': JSON.stringify({}),
'process.env.RELEASE_TAG': JSON.stringify(releaseTag), 'process.env.RELEASE_TAG': JSON.stringify(releaseTag),
'process.env.RELEASE_LINK': JSON.stringify(releaseLink), 'process.env.RELEASE_LINK': JSON.stringify(releaseLink),
'process.env.RELEASE_CHANGELOG': JSON.stringify(releaseChangelog), 'process.env.RELEASE_CHANGELOG': JSON.stringify(releaseChangelog),
'process.env.DISABLE_SERVICE_WORKER': JSON.stringify(disableServiceWorker), 'process.env.DISABLE_SERVICE_WORKER': JSON.stringify(disableServiceWorker),
'process.env.INLINED_APP_CONFIG': JSON.stringify(configSource === 'BUNDLED' ? configJson : null), '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: { server: {
@ -197,21 +167,19 @@ const appConfig = defineConfig({
childProcess.execSync('tsx ./scripts/optimizeBlockCollisions.ts', { stdio: 'inherit' }) childProcess.execSync('tsx ./scripts/optimizeBlockCollisions.ts', { stdio: 'inherit' })
} }
// childProcess.execSync(['tsx', './scripts/genLargeDataAliases.ts', ...(SINGLE_FILE_BUILD ? ['--compressed'] : [])].join(' '), { stdio: 'inherit' }) // childProcess.execSync(['tsx', './scripts/genLargeDataAliases.ts', ...(SINGLE_FILE_BUILD ? ['--compressed'] : [])].join(' '), { stdio: 'inherit' })
genLargeDataAliases(SINGLE_FILE_BUILD || process.env.ALWAYS_COMPRESS_LARGE_DATA === 'true') genLargeDataAliases(SINGLE_FILE_BUILD)
fsExtra.copySync('./node_modules/mc-assets/dist/other-textures/latest/entity', './dist/textures/entity') fsExtra.copySync('./node_modules/mc-assets/dist/other-textures/latest/entity', './dist/textures/entity')
fsExtra.copySync('./assets/background', './dist/background') fsExtra.copySync('./assets/background', './dist/background')
fs.copyFileSync('./assets/favicon.png', './dist/favicon.png') fs.copyFileSync('./assets/favicon.png', './dist/favicon.png')
fs.copyFileSync('./assets/playground.html', './dist/playground.html') fs.copyFileSync('./assets/playground.html', './dist/playground.html')
fs.copyFileSync('./assets/manifest.json', './dist/manifest.json') 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') fs.copyFileSync('./assets/loading-bg.jpg', './dist/loading-bg.jpg')
if (fs.existsSync('./assets/release.json')) { if (fs.existsSync('./assets/release.json')) {
fs.copyFileSync('./assets/release.json', './dist/release.json') fs.copyFileSync('./assets/release.json', './dist/release.json')
} }
if (configSource === 'REMOTE') { if (configSource === 'REMOTE') {
fs.writeFileSync('./dist/config.json', JSON.stringify(configJson, undefined, 2), 'utf8') fs.writeFileSync('./dist/config.json', JSON.stringify(configJson), 'utf8')
} }
if (fs.existsSync('./generated/sounds.js')) { if (fs.existsSync('./generated/sounds.js')) {
fs.copyFileSync('./generated/sounds.js', './dist/sounds.js') fs.copyFileSync('./generated/sounds.js', './dist/sounds.js')
@ -227,12 +195,6 @@ const appConfig = defineConfig({
await execAsync('pnpm run build-mesher') await execAsync('pnpm run build-mesher')
} }
fs.writeFileSync('./dist/version.txt', buildingVersion, 'utf-8') fs.writeFileSync('./dist/version.txt', buildingVersion, 'utf-8')
// Start WebSocket server in development
if (dev && enableMetrics) {
await startWsServer(8081, false)
}
console.timeEnd('total-prep') console.timeEnd('total-prep')
} }
if (!dev) { if (!dev) {
@ -240,10 +202,6 @@ const appConfig = defineConfig({
prep() prep()
}) })
build.onAfterBuild(async () => { build.onAfterBuild(async () => {
if (fs.readdirSync('./assets/customTextures').length > 0) {
childProcess.execSync('tsx ./scripts/patchAssets.ts', { stdio: 'inherit' })
}
if (SINGLE_FILE_BUILD) { if (SINGLE_FILE_BUILD) {
// check that only index.html is in the dist/single folder // check that only index.html is in the dist/single folder
const singleBuildFiles = fs.readdirSync('./dist/single') const singleBuildFiles = fs.readdirSync('./dist/single')

View file

@ -55,7 +55,6 @@ exports.getSwAdditionalEntries = () => {
'manifest.json', 'manifest.json',
'worldSaveWorker.js', 'worldSaveWorker.js',
`textures/entity/squid/squid.png`, `textures/entity/squid/squid.png`,
'sounds.js',
// everything but not .map // everything but not .map
'static/**/!(*.map)', 'static/**/!(*.map)',
] ]

View file

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

View file

@ -15,17 +15,6 @@ const fns = {
// set github output // set github output
setOutput('alias', alias[1]) setOutput('alias', alias[1])
} }
},
getReleasingAlias() {
const final = (ver) => `${ver}.mcraft.fun,${ver}.pcm.gg`
const releaseJson = JSON.parse(fs.readFileSync('./assets/release.json', 'utf8'))
const tag = releaseJson.latestTag
const [major, minor, patch] = tag.replace('v', '').split('.')
if (major === '0' && minor === '1') {
setOutput('alias', final(`v${patch}`))
} else {
setOutput('alias', final(tag))
}
} }
} }

View file

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

View file

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

View file

@ -11,12 +11,7 @@ import supportedVersions from '../src/supportedVersions.mjs'
const __dirname = path.dirname(fileURLToPath(new URL(import.meta.url))) const __dirname = path.dirname(fileURLToPath(new URL(import.meta.url)))
export const versionToNumber = (ver) => { const targetedVersions = supportedVersions.reverse()
const [x, y = '0', z = '0'] = ver.split('.')
return +`${x.padStart(2, '0')}${y.padStart(2, '0')}${z.padStart(2, '0')}`
}
const targetedVersions = [...supportedVersions].sort((a, b) => versionToNumber(b) - versionToNumber(a))
/** @type {{name, size, hash}[]} */ /** @type {{name, size, hash}[]} */
let prevSounds = null let prevSounds = null
@ -178,36 +173,13 @@ const writeSoundsMap = async () => {
// todo REMAP ONLY IDS. Do diffs, as mostly only ids are changed between versions // todo REMAP ONLY IDS. Do diffs, as mostly only ids are changed between versions
// const localTargetedVersions = targetedVersions.slice(0, 2) // const localTargetedVersions = targetedVersions.slice(0, 2)
let lastMappingsJson
const localTargetedVersions = targetedVersions const localTargetedVersions = targetedVersions
for (const targetedVersion of [...localTargetedVersions].reverse()) { for (const targetedVersion of localTargetedVersions) {
console.log('Processing version', targetedVersion)
const burgerData = await fetch(burgerDataUrl(targetedVersion)).then((r) => r.json()).catch((err) => { const burgerData = await fetch(burgerDataUrl(targetedVersion)).then((r) => r.json()).catch((err) => {
// console.error('error fetching burger data', targetedVersion, err) console.error('error fetching burger data', targetedVersion, err)
return null return null
}) })
/** @type {{sounds: string[]}} */ if (!burgerData) continue
const mappingJson = await fetch(`https://raw.githubusercontent.com/ViaVersion/Mappings/7a45c1f9dbc1f1fdadacfecdb205ba84e55766fc/mappings/mapping-${targetedVersion}.json`).then(async (r) => {
return r.json()
// lastMappingsJson = r.status === 404 ? lastMappingsJson : (await r.json())
// if (r.status === 404) {
// console.warn('using prev mappings json for ' + targetedVersion)
// }
// return lastMappingsJson
}).catch((err) => {
// console.error('error fetching mapping json', targetedVersion, err)
return null
})
// if (!mappingJson) throw new Error('no initial mapping json for ' + targetedVersion)
if (burgerData && !mappingJson) {
console.warn('has burger but no mapping json for ' + targetedVersion)
continue
}
if (!mappingJson || !burgerData) {
console.warn('no mapping json or burger data for ' + targetedVersion)
continue
}
const allSoundsMap = getSoundsMap(burgerData) const allSoundsMap = getSoundsMap(burgerData)
// console.log(Object.keys(sounds).length, 'ids') // console.log(Object.keys(sounds).length, 'ids')
const outputIdMap = {} const outputIdMap = {}
@ -218,7 +190,7 @@ const writeSoundsMap = async () => {
new: 0, new: 0,
same: 0 same: 0
} }
for (const { _id, subtitle, sounds, name } of Object.values(allSoundsMap)) { for (const { id, subtitle, sounds, name } of Object.values(allSoundsMap)) {
if (!sounds?.length /* && !subtitle */) continue if (!sounds?.length /* && !subtitle */) continue
const firstName = sounds[0].name const firstName = sounds[0].name
// const includeSound = isSoundWhitelisted(firstName) // const includeSound = isSoundWhitelisted(firstName)
@ -238,11 +210,6 @@ const writeSoundsMap = async () => {
if (sound.weight && isNaN(sound.weight)) debugger if (sound.weight && isNaN(sound.weight)) debugger
outputUseSoundLine.push(`${sound.volume ?? 1};${sound.name};${sound.weight ?? minWeight}`) outputUseSoundLine.push(`${sound.volume ?? 1};${sound.name};${sound.weight ?? minWeight}`)
} }
const id = mappingJson.sounds.findIndex(x => x === name)
if (id === -1) {
console.warn('no id for sound', name, targetedVersion)
continue
}
const key = `${id};${name}` const key = `${id};${name}`
outputIdMap[key] = outputUseSoundLine.join(',') outputIdMap[key] = outputUseSoundLine.join(',')
if (prevMap && prevMap[key]) { if (prevMap && prevMap[key]) {
@ -316,6 +283,6 @@ if (action) {
} else { } else {
// downloadAllSoundsAndCreateMap() // downloadAllSoundsAndCreateMap()
// convertSounds() // convertSounds()
writeSoundsMap() // writeSoundsMap()
// makeSoundsBundle() makeSoundsBundle()
} }

View file

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

View file

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

View file

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

View file

@ -16,23 +16,9 @@ try {
const app = express() const app = express()
const isProd = process.argv.includes('--prod') || process.env.NODE_ENV === 'production' const isProd = process.argv.includes('--prod') || process.env.NODE_ENV === 'production'
const timeoutIndex = process.argv.indexOf('--timeout')
let timeout = timeoutIndex > -1 && timeoutIndex + 1 < process.argv.length
? parseInt(process.argv[timeoutIndex + 1])
: process.env.TIMEOUT
? parseInt(process.env.TIMEOUT)
: 10000
if (isNaN(timeout) || timeout < 0) {
console.warn('Invalid timeout value provided, using default of 10000ms')
timeout = 10000
}
app.use(compression()) app.use(compression())
app.use(cors()) app.use(cors())
app.use(netApi({ app.use(netApi({ allowOrigin: '*' }))
allowOrigin: '*',
log: process.argv.includes('--log') || process.env.LOG === 'true',
timeout
}))
if (!isProd) { if (!isProd) {
app.use('/sounds', express.static(path.join(__dirname, './generated/sounds/'))) app.use('/sounds', express.static(path.join(__dirname, './generated/sounds/')))
} }

View file

@ -1,31 +1,7 @@
import { defaultsDeep } from 'lodash'
import { disabledSettings, options, qsOptions } from './optionsStorage' import { disabledSettings, options, qsOptions } from './optionsStorage'
import { miscUiState } from './globalState' import { miscUiState } from './globalState'
import { setLoadingScreenStatus } from './appStatus' import { setLoadingScreenStatus } from './appStatus'
import { setStorageDataOnAppConfigLoad } from './react/appStorageProvider' import { setStorageDataOnAppConfigLoad } from './react/appStorageProvider'
import { customKeymaps, updateBinds } from './controls'
export type CustomAction = {
readonly type: string
readonly input: readonly any[]
}
export type ActionType = string | CustomAction
export type ActionHoldConfig = {
readonly command: ActionType
readonly longPressAction?: ActionType
readonly duration?: number
readonly threshold?: number
}
export type MobileButtonConfig = {
readonly label?: string
readonly icon?: string
readonly action?: ActionType
readonly actionHold?: ActionType | ActionHoldConfig
readonly iconStyle?: React.CSSProperties
}
export type AppConfig = { export type AppConfig = {
// defaultHost?: string // defaultHost?: string
@ -35,34 +11,19 @@ export type AppConfig = {
// defaultVersion?: string // defaultVersion?: string
peerJsServer?: string peerJsServer?: string
peerJsServerFallback?: string peerJsServerFallback?: string
promoteServers?: Array<{ ip, description, name?, version?, }> promoteServers?: Array<{ ip, description, version? }>
mapsProvider?: string mapsProvider?: string
appParams?: Record<string, any> // query string params appParams?: Record<string, any> // query string params
rightSideText?: string
defaultSettings?: Record<string, any> defaultSettings?: Record<string, any>
forceSettings?: Record<string, boolean> forceSettings?: Record<string, boolean>
// hideSettings?: Record<string, boolean> // hideSettings?: Record<string, boolean>
allowAutoConnect?: boolean allowAutoConnect?: boolean
splashText?: string
splashTextFallback?: string
pauseLinks?: Array<Array<Record<string, any>>> pauseLinks?: Array<Array<Record<string, any>>>
mobileButtons?: MobileButtonConfig[]
keybindings?: Record<string, any>
defaultLanguage?: string
displayLanguageSelector?: boolean
supportedLanguages?: string[]
showModsButton?: boolean
defaultUsername?: string
skinTexturesProxy?: string
alwaysReconnectButton?: boolean
reportBugButtonWithReconnect?: boolean
disabledCommands?: string[] // Array of command IDs to disable (e.g. ['general.jump', 'general.chat'])
} }
export const loadAppConfig = (appConfig: AppConfig) => { export const loadAppConfig = (appConfig: AppConfig) => {
if (miscUiState.appConfig) { if (miscUiState.appConfig) {
Object.assign(miscUiState.appConfig, appConfig) Object.assign(miscUiState.appConfig, appConfig)
} else { } else {
@ -74,7 +35,7 @@ export const loadAppConfig = (appConfig: AppConfig) => {
if (value) { if (value) {
disabledSettings.value.add(key) disabledSettings.value.add(key)
// since the setting is forced, we need to set it to that value // since the setting is forced, we need to set it to that value
if (appConfig.defaultSettings && key in appConfig.defaultSettings && !qsOptions[key]) { if (appConfig.defaultSettings?.[key] && !qsOptions[key]) {
options[key] = appConfig.defaultSettings[key] options[key] = appConfig.defaultSettings[key]
} }
} else { } else {
@ -82,16 +43,8 @@ export const loadAppConfig = (appConfig: AppConfig) => {
} }
} }
} }
// todo apply defaultSettings to defaults even if not forced in case of remote config
if (appConfig.keybindings) { setStorageDataOnAppConfigLoad()
Object.assign(customKeymaps, defaultsDeep(appConfig.keybindings, customKeymaps))
updateBinds(customKeymaps)
}
appViewer?.appConfigUdpate()
setStorageDataOnAppConfigLoad(appConfig)
} }
export const isBundledConfigUsed = !!process.env.INLINED_APP_CONFIG export const isBundledConfigUsed = !!process.env.INLINED_APP_CONFIG

View file

@ -12,7 +12,6 @@ export type AppQsParams = {
username?: string username?: string
lockConnect?: string lockConnect?: string
autoConnect?: string autoConnect?: string
alwaysReconnect?: string
// googledrive.ts params // googledrive.ts params
state?: string state?: string
// ServersListProvider.tsx params // ServersListProvider.tsx params
@ -43,11 +42,6 @@ export type AppQsParams = {
suggest_save?: string suggest_save?: string
noPacketsValidation?: string noPacketsValidation?: string
testCrashApp?: string testCrashApp?: string
onlyConnect?: string
connectText?: string
freezeSettings?: string
testIosCrash?: string
addPing?: string
// Replay params // Replay params
replayFilter?: string replayFilter?: string

View file

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

View file

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

View file

@ -9,27 +9,25 @@ import { showNotification } from './react/NotificationProvider'
const backends = [ const backends = [
createGraphicsBackend, createGraphicsBackend,
] ]
const loadBackend = async () => { const loadBackend = () => {
let backend = backends.find(backend => backend.id === options.activeRenderer) let backend = backends.find(backend => backend.id === options.activeRenderer)
if (!backend) { if (!backend) {
showNotification(`No backend found for renderer ${options.activeRenderer}`, `Falling back to ${backends[0].id}`, true) showNotification(`No backend found for renderer ${options.activeRenderer}`, `Falling back to ${backends[0].id}`, true)
backend = backends[0] backend = backends[0]
} }
await appViewer.loadBackend(backend) appViewer.loadBackend(backend)
} }
window.loadBackend = loadBackend window.loadBackend = loadBackend
if (process.env.SINGLE_FILE_BUILD_MODE) { if (process.env.SINGLE_FILE_BUILD_MODE) {
const unsub = subscribeKey(miscUiState, 'fsReady', () => { const unsub = subscribeKey(miscUiState, 'fsReady', () => {
if (miscUiState.fsReady) { if (miscUiState.fsReady) {
// don't do it earlier to load fs and display menu faster // don't do it earlier to load fs and display menu faster
void loadBackend() loadBackend()
unsub() unsub()
} }
}) })
} else { } else {
setTimeout(() => { loadBackend()
void loadBackend()
})
} }
const animLoop = () => { const animLoop = () => {
@ -42,10 +40,10 @@ watchOptionsAfterViewerInit()
// reset backend when renderer changes // reset backend when renderer changes
subscribeKey(options, 'activeRenderer', async () => { subscribeKey(options, 'activeRenderer', () => {
if (appViewer.currentDisplay === 'world' && bot) { if (appViewer.currentDisplay === 'world' && bot) {
appViewer.resetBackend(true) appViewer.resetBackend(true)
await loadBackend() loadBackend()
void appViewer.startWithBot() void appViewer.startWithBot()
} }
}) })

View file

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

View file

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

View file

@ -18,7 +18,6 @@ export function onCameraMove (e: MouseEvent | CameraMoveEvent) {
if (!isGameActive(true)) return if (!isGameActive(true)) return
if (e.type === 'mousemove' && !document.pointerLockElement) return if (e.type === 'mousemove' && !document.pointerLockElement) return
e.stopPropagation?.() e.stopPropagation?.()
if (appViewer.playerState.utils.isSpectatingEntity()) return
const now = performance.now() const now = performance.now()
// todo: limit camera movement for now to avoid unexpected jumps // todo: limit camera movement for now to avoid unexpected jumps
if (now - lastMouseMove < 4 && !options.preciseMouseInput) return if (now - lastMouseMove < 4 && !options.preciseMouseInput) return
@ -33,6 +32,7 @@ export function onCameraMove (e: MouseEvent | CameraMoveEvent) {
updateMotion() updateMotion()
} }
export const moveCameraRawHandler = ({ x, y }: { x: number; y: number }) => { export const moveCameraRawHandler = ({ x, y }: { x: number; y: number }) => {
const maxPitch = 0.5 * Math.PI const maxPitch = 0.5 * Math.PI
const minPitch = -0.5 * Math.PI const minPitch = -0.5 * Math.PI
@ -74,6 +74,9 @@ export const onControInit = () => {
} }
function pointerLockChangeCallback () { function pointerLockChangeCallback () {
if (notificationProxy.id === 'pointerlockchange') {
hideNotification()
}
if (appViewer.rendererState.preventEscapeMenu) return if (appViewer.rendererState.preventEscapeMenu) return
if (!pointerLock.hasPointerLock && activeModalStack.length === 0 && miscUiState.gameLoaded) { if (!pointerLock.hasPointerLock && activeModalStack.length === 0 && miscUiState.gameLoaded) {
showModal({ reactType: 'pause-screen' }) showModal({ reactType: 'pause-screen' })

View file

@ -1,6 +1,6 @@
import { test, expect } from 'vitest' import { test, expect } from 'vitest'
import mcData from 'minecraft-data' import mcData from 'minecraft-data'
import { formatMessage, isAllowedChatCharacter, isStringAllowed } from './chatUtils' import { formatMessage } from './chatUtils'
//@ts-expect-error //@ts-expect-error
globalThis.loadedData ??= mcData('1.20.1') globalThis.loadedData ??= mcData('1.20.1')
@ -64,21 +64,3 @@ test('formatMessage', () => {
] ]
`) `)
}) })
test('isAllowedChatCharacter', () => {
expect(isAllowedChatCharacter('a')).toBe(true)
expect(isAllowedChatCharacter('a')).toBe(true)
expect(isAllowedChatCharacter('§')).toBe(false)
expect(isAllowedChatCharacter(' ')).toBe(true)
expect(isStringAllowed('a§b')).toMatchObject({
valid: false,
clean: 'ab',
invalid: ['§']
})
expect(isStringAllowed('aツ')).toMatchObject({
valid: true,
})
expect(isStringAllowed('a🟢')).toMatchObject({
valid: true,
})
})

View file

@ -4,10 +4,6 @@ import { fromFormattedString, TextComponent } from '@xmcl/text-component'
import type { IndexedData } from 'minecraft-data' import type { IndexedData } from 'minecraft-data'
import { versionToNumber } from 'renderer/viewer/common/utils' import { versionToNumber } from 'renderer/viewer/common/utils'
export interface MessageFormatOptions {
doShadow?: boolean
}
export type MessageFormatPart = Pick<TextComponent, 'hoverEvent' | 'clickEvent'> & { export type MessageFormatPart = Pick<TextComponent, 'hoverEvent' | 'clickEvent'> & {
text: string text: string
color?: string color?: string
@ -93,10 +89,7 @@ export const formatMessage = (message: MessageInput, mcData: IndexedData = globa
} }
if (msg.extra) { if (msg.extra) {
for (let ex of msg.extra) { for (const ex of msg.extra) {
if (typeof ex === 'string') {
ex = { text: ex }
}
readMsg({ ...styles, ...ex }) readMsg({ ...styles, ...ex })
} }
} }
@ -118,14 +111,6 @@ export const formatMessage = (message: MessageInput, mcData: IndexedData = globa
return msglist 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 = { const blockToItemRemaps = {
water: 'water_bucket', water: 'water_bucket',
lava: 'lava_bucket', lava: 'lava_bucket',
@ -137,37 +122,3 @@ export const getItemFromBlock = (block: import('prismarine-block').Block) => {
const item = global.loadedData.itemsByName[blockToItemRemaps[block.name] ?? block.name] const item = global.loadedData.itemsByName[blockToItemRemaps[block.name] ?? block.name]
return item return item
} }
export function isAllowedChatCharacter (char: string): boolean {
// if (char.length !== 1) {
// throw new Error('Input must be a single character')
// }
const charCode = char.codePointAt(0)!
return charCode !== 167 && charCode >= 32 && charCode !== 127
}
export const isStringAllowed = (str: string) => {
const invalidChars = new Set<string>()
for (const [i, char] of [...str].entries()) {
const isSurrogatePair = str.codePointAt(i) !== str['charCodeAt'](i)
if (isSurrogatePair) continue
if (!isAllowedChatCharacter(char)) {
invalidChars.add(char)
}
}
const valid = invalidChars.size === 0
if (valid) {
return {
valid: true
}
}
return {
valid,
clean: [...str].filter(c => !invalidChars.has(c)).join(''),
invalid: [...invalidChars]
}
}

View file

@ -1,637 +0,0 @@
/* eslint-disable no-await-in-loop */
import { openDB } from 'idb'
import * as React from 'react'
import * as valtio from 'valtio'
import * as valtioUtils from 'valtio/utils'
import { gt } from 'semver'
import { proxy } from 'valtio'
import { options } from './optionsStorage'
import { appStorage } from './react/appStorageProvider'
import { showInputsModal, showOptionsModal } from './react/SelectOption'
import { ProgressReporter } from './core/progressReporter'
import { showNotification } from './react/NotificationProvider'
let sillyProtection = false
const protectRuntime = () => {
if (sillyProtection) return
sillyProtection = true
const sensetiveKeys = new Set(['authenticatedAccounts', 'serversList', 'username'])
const proxy = new Proxy(window.localStorage, {
get (target, prop) {
if (typeof prop === 'string') {
if (sensetiveKeys.has(prop)) {
console.warn(`Access to sensitive key "${prop}" was blocked`)
return null
}
if (prop === 'getItem') {
return (key: string) => {
if (sensetiveKeys.has(key)) {
console.warn(`Access to sensitive key "${key}" via getItem was blocked`)
return null
}
return target.getItem(key)
}
}
if (prop === 'setItem') {
return (key: string, value: string) => {
if (sensetiveKeys.has(key)) {
console.warn(`Attempt to set sensitive key "${key}" via setItem was blocked`)
return
}
target.setItem(key, value)
}
}
if (prop === 'removeItem') {
return (key: string) => {
if (sensetiveKeys.has(key)) {
console.warn(`Attempt to delete sensitive key "${key}" via removeItem was blocked`)
return
}
target.removeItem(key)
}
}
if (prop === 'clear') {
console.warn('Attempt to clear localStorage was blocked')
return () => {}
}
}
return Reflect.get(target, prop)
},
set (target, prop, value) {
if (typeof prop === 'string' && sensetiveKeys.has(prop)) {
console.warn(`Attempt to set sensitive key "${prop}" was blocked`)
return false
}
return Reflect.set(target, prop, value)
},
deleteProperty (target, prop) {
if (typeof prop === 'string' && sensetiveKeys.has(prop)) {
console.warn(`Attempt to delete sensitive key "${prop}" was blocked`)
return false
}
return Reflect.deleteProperty(target, prop)
}
})
Object.defineProperty(window, 'localStorage', {
value: proxy,
writable: false,
configurable: false,
})
}
// #region Database
const dbPromise = openDB('mods-db', 1, {
upgrade (db) {
db.createObjectStore('mods', {
keyPath: 'name',
})
db.createObjectStore('repositories', {
keyPath: 'url',
})
},
})
export interface ModSetting {
label?: string
type: 'toggle' | 'choice' | 'input' | 'slider'
hidden?: boolean
values?: string[]
inputType?: string
hint?: string
default?: any
}
export interface ModSettingsDict {
[settingId: string]: ModSetting
}
export interface ModAction {
method?: string
label?: string
/** @default false */
gameGlobal?: boolean
/** @default false */
onlyForeground?: boolean
}
// mcraft-repo.json
export interface McraftRepoFile {
packages: ClientModDefinition[]
/** @default true */
prefix?: string | boolean
name?: string // display name
description?: string
mirrorUrls?: string[]
autoUpdateOverride?: boolean
lastUpdated?: number
}
export interface Repository extends McraftRepoFile {
url: string
}
export interface ClientMod {
name: string; // unique identifier like owner.name
version: string
enabled?: boolean
scriptMainUnstable?: string;
serverPlugin?: string
// serverPlugins?: string[]
// mesherThread?: string
stylesGlobal?: string
threeJsBackend?: string // three.js
// stylesLocal?: string
requiresNetwork?: boolean
fullyOffline?: boolean
description?: string
author?: string
section?: string
autoUpdateOverride?: boolean
lastUpdated?: number
wasModifiedLocally?: boolean
// todo depends, hashsum
settings?: ModSettingsDict
actionsMain?: Record<string, ModAction>
}
const cleanupFetchedModData = (mod: ClientModDefinition | Record<string, any>) => {
delete mod['enabled']
delete mod['repo']
delete mod['autoUpdateOverride']
delete mod['lastUpdated']
delete mod['wasModifiedLocally']
return mod
}
export type ClientModDefinition = Omit<ClientMod, 'enabled' | 'wasModifiedLocally'> & {
scriptMainUnstable?: boolean
stylesGlobal?: boolean
serverPlugin?: boolean
threeJsBackend?: boolean
}
export async function saveClientModData (data: ClientMod) {
const db = await dbPromise
data.lastUpdated = Date.now()
await db.put('mods', data)
modsReactiveUpdater.counter++
}
async function getPlugin (name: string) {
const db = await dbPromise
return db.get('mods', name) as Promise<ClientMod | undefined>
}
export async function getAllMods () {
const db = await dbPromise
return db.getAll('mods') as Promise<ClientMod[]>
}
async function deletePlugin (name) {
const db = await dbPromise
await db.delete('mods', name)
modsReactiveUpdater.counter++
}
async function removeAllMods () {
const db = await dbPromise
await db.clear('mods')
modsReactiveUpdater.counter++
}
// ---
async function saveRepository (data: Repository) {
const db = await dbPromise
data.lastUpdated = Date.now()
await db.put('repositories', data)
}
async function getRepository (url: string) {
const db = await dbPromise
return db.get('repositories', url) as Promise<Repository | undefined>
}
async function getAllRepositories () {
const db = await dbPromise
return db.getAll('repositories') as Promise<Repository[]>
}
window.getAllRepositories = getAllRepositories
async function deleteRepository (url) {
const db = await dbPromise
await db.delete('repositories', url)
}
// ---
// #endregion
window.mcraft = {
version: process.env.RELEASE_TAG,
build: process.env.BUILD_VERSION,
ui: {},
React,
valtio: {
...valtio,
...valtioUtils,
},
// openDB
}
const activateMod = async (mod: ClientMod, reason: string) => {
if (mod.enabled === false) return false
protectRuntime()
console.debug(`Activating mod ${mod.name} (${reason})...`)
window.loadedMods ??= {}
if (window.loadedMods[mod.name]) {
console.warn(`Mod is ${mod.name} already loaded, skipping activation...`)
return false
}
if (mod.stylesGlobal) {
const style = document.createElement('style')
style.textContent = mod.stylesGlobal
style.id = `mod-${mod.name}`
document.head.appendChild(style)
}
if (mod.scriptMainUnstable) {
const blob = new Blob([mod.scriptMainUnstable], { type: 'text/javascript' })
const url = URL.createObjectURL(blob)
// eslint-disable-next-line no-useless-catch
try {
const module = await import(/* webpackIgnore: true */ url)
module.default?.(structuredClone(mod), { settings: getModSettingsProxy(mod) })
window.loadedMods[mod.name] ??= {}
window.loadedMods[mod.name].mainUnstableModule = module
} catch (e) {
throw e
}
URL.revokeObjectURL(url)
}
if (mod.threeJsBackend) {
const blob = new Blob([mod.threeJsBackend], { type: 'text/javascript' })
const url = URL.createObjectURL(blob)
// eslint-disable-next-line no-useless-catch
try {
const module = await import(/* webpackIgnore: true */ url)
// todo
window.loadedMods[mod.name] ??= {}
// for accessing global world var
window.loadedMods[mod.name].threeJsBackendModule = module
} catch (e) {
throw e
}
URL.revokeObjectURL(url)
}
mod.enabled = true
return true
}
export const appStartup = async () => {
void checkModsUpdates()
const mods = await getAllMods()
for (const mod of mods) {
await activateMod(mod, 'autostart').catch(e => {
modsErrors[mod.name] ??= []
modsErrors[mod.name].push(`startup: ${String(e)}`)
console.error(`Error activating mod on startup ${mod.name}:`, e)
})
}
}
export const modsUpdateStatus = proxy({} as Record<string, [string, string]>)
export const modsWaitingReloadStatus = proxy({} as Record<string, boolean>)
export const modsErrors = proxy({} as Record<string, string[]>)
const normalizeRepoUrl = (url: string) => {
if (url.startsWith('https://')) return url
if (url.startsWith('http://')) return url
if (url.startsWith('//')) return `https:${url}`
return `https://raw.githubusercontent.com/${url}/master`
}
const installOrUpdateMod = async (repo: Repository, mod: ClientModDefinition, activate = true, progress?: ProgressReporter) => {
// eslint-disable-next-line no-useless-catch
try {
const fetchData = async (urls: string[]) => {
const errored = [] as string[]
// eslint-disable-next-line no-unreachable-loop
for (const urlTemplate of urls) {
const modNameOnly = mod.name.split('.').pop()
const modFolder = repo.prefix === false ? modNameOnly : typeof repo.prefix === 'string' ? `${repo.prefix}/${modNameOnly}` : mod.name
const url = new URL(`${modFolder}/${urlTemplate}`, normalizeRepoUrl(repo.url).replace(/\/$/, '') + '/').href
// eslint-disable-next-line no-useless-catch
try {
const response = await fetch(url)
if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`)
return await response.text()
} catch (e) {
// errored.push(String(e))
throw e
}
}
console.warn(`[${mod.name}] Error installing component of ${urls[0]}: ${errored.join(', ')}`)
return undefined
}
if (mod.stylesGlobal) {
await progress?.executeWithMessage(
`Downloading ${mod.name} styles`,
async () => {
mod.stylesGlobal = await fetchData(['global.css']) as any
}
)
}
if (mod.scriptMainUnstable) {
await progress?.executeWithMessage(
`Downloading ${mod.name} script`,
async () => {
mod.scriptMainUnstable = await fetchData(['mainUnstable.js']) as any
}
)
}
if (mod.threeJsBackend) {
await progress?.executeWithMessage(
`Downloading ${mod.name} three.js backend`,
async () => {
mod.threeJsBackend = await fetchData(['three.js']) as any
}
)
}
if (mod.serverPlugin) {
if (mod.name.endsWith('.disabled')) throw new Error(`Mod name ${mod.name} can't end with .disabled`)
await progress?.executeWithMessage(
`Downloading ${mod.name} server plugin`,
async () => {
mod.serverPlugin = await fetchData(['serverPlugin.js']) as any
}
)
}
if (activate) {
// todo try to de-activate mod if it's already loaded
if (window.loadedMods?.[mod.name]) {
modsWaitingReloadStatus[mod.name] = true
} else {
await activateMod(mod as ClientMod, 'install')
}
}
await saveClientModData(mod as ClientMod)
delete modsUpdateStatus[mod.name]
} catch (e) {
// console.error(`Error installing mod ${mod.name}:`, e)
throw e
}
}
const checkRepositoryUpdates = async (repo: Repository) => {
for (const mod of repo.packages) {
const modExisting = await getPlugin(mod.name)
if (modExisting?.version && gt(mod.version, modExisting.version)) {
modsUpdateStatus[mod.name] = [modExisting.version, mod.version]
if (options.modsAutoUpdate === 'always' && (!repo.autoUpdateOverride && !modExisting.autoUpdateOverride)) {
void installOrUpdateMod(repo, mod).catch(e => {
console.error(`Error updating mod ${mod.name}:`, e)
})
}
}
}
}
export const fetchRepository = async (urlOriginal: string, url: string, hasMirrors = false) => {
const fetchUrl = normalizeRepoUrl(url).replace(/\/$/, '') + '/mcraft-repo.json'
try {
const response = await fetch(fetchUrl).then(async res => res.json())
if (!response.packages) throw new Error(`No packages field in the response json of the repository: ${fetchUrl}`)
response.autoUpdateOverride = (await getRepository(urlOriginal))?.autoUpdateOverride
response.url = urlOriginal
void saveRepository(response)
modsReactiveUpdater.counter++
return true
} catch (e) {
console.warn(`Error fetching repository (trying other mirrors) ${url}:`, e)
return false
}
}
export const fetchAllRepositories = async () => {
const repositories = await getAllRepositories()
await Promise.all(repositories.map(async (repo) => {
const allUrls = [repo.url, ...(repo.mirrorUrls || [])]
for (const [i, url] of allUrls.entries()) {
const isLast = i === allUrls.length - 1
if (await fetchRepository(repo.url, url, !isLast)) break
}
}))
appStorage.modsAutoUpdateLastCheck = Date.now()
}
const checkModsUpdates = async () => {
await autoRefreshModRepositories()
for (const repo of await getAllRepositories()) {
await checkRepositoryUpdates(repo)
}
}
const autoRefreshModRepositories = async () => {
if (options.modsAutoUpdate === 'never') return
const lastCheck = appStorage.modsAutoUpdateLastCheck
if (lastCheck && Date.now() - lastCheck < 1000 * 60 * 60 * options.modsUpdatePeriodCheck) return
await fetchAllRepositories()
// todo think of not updating check timestamp on offline access
}
export const installModByName = async (repoUrl: string, name: string, progress?: ProgressReporter) => {
progress?.beginStage('main', `Installing ${name}`)
const repo = await getRepository(repoUrl)
if (!repo) throw new Error(`Repository ${repoUrl} not found`)
const mod = repo.packages.find(m => m.name === name)
if (!mod) throw new Error(`Mod ${name} not found in repository ${repoUrl}`)
await installOrUpdateMod(repo, mod, undefined, progress)
progress?.endStage('main')
}
export const uninstallModAction = async (name: string) => {
const choice = await showOptionsModal(`Uninstall mod ${name}?`, ['Yes'])
if (!choice) return
await deletePlugin(name)
window.loadedMods ??= {}
if (window.loadedMods[name]) {
// window.loadedMods[name].default?.(null)
delete window.loadedMods[name]
modsWaitingReloadStatus[name] = true
}
// Clear any errors associated with the mod
delete modsErrors[name]
}
export const setEnabledModAction = async (name: string, newEnabled: boolean) => {
const mod = await getPlugin(name)
if (!mod) throw new Error(`Mod ${name} not found`)
if (newEnabled) {
mod.enabled = true
if (!window.loadedMods?.[mod.name]) {
await activateMod(mod, 'manual')
}
} else {
// todo deactivate mod
mod.enabled = false
if (window.loadedMods?.[mod.name]) {
if (window.loadedMods[mod.name]?.threeJsBackendModule) {
window.loadedMods[mod.name].threeJsBackendModule.deactivate()
delete window.loadedMods[mod.name].threeJsBackendModule
}
if (window.loadedMods[mod.name]?.mainUnstableModule) {
window.loadedMods[mod.name].mainUnstableModule.deactivate()
delete window.loadedMods[mod.name].mainUnstableModule
}
if (Object.keys(window.loadedMods[mod.name]).length === 0) {
delete window.loadedMods[mod.name]
}
}
}
await saveClientModData(mod)
}
export const modsReactiveUpdater = proxy({
counter: 0
})
export const getAllModsDisplayList = async () => {
const repos = await getAllRepositories()
const installedMods = await getAllMods()
const modsWithoutRepos = installedMods.filter(mod => !repos.some(repo => repo.packages.some(m => m.name === mod.name)))
const mapMods = (mapMods: ClientMod[]) => mapMods.map(mod => ({
...mod,
installed: installedMods.find(m => m.name === mod.name),
activated: !!window.loadedMods?.[mod.name],
installedVersion: installedMods.find(m => m.name === mod.name)?.version,
canBeActivated: mod.scriptMainUnstable || mod.stylesGlobal,
}))
return {
repos: repos.map(repo => ({
...repo,
packages: mapMods(repo.packages as ClientMod[]),
})),
modsWithoutRepos: mapMods(modsWithoutRepos),
}
}
export const removeRepositoryAction = async (url: string) => {
// todo remove mods
const choice = await showOptionsModal('Remove repository? Installed mods wont be automatically removed.', ['Yes'])
if (!choice) return
await deleteRepository(url)
modsReactiveUpdater.counter++
}
export const selectAndRemoveRepository = async () => {
const repos = await getAllRepositories()
const choice = await showOptionsModal('Select repository to remove', repos.map(repo => repo.url))
if (!choice) return
await removeRepositoryAction(choice)
}
export const addRepositoryAction = async () => {
const { url } = await showInputsModal('Add repository', {
url: {
type: 'text',
label: 'Repository URL or slug',
placeholder: 'github-owner/repo-name',
},
})
if (!url) return
await fetchRepository(url, url)
}
export const getServerPlugin = async (plugin: string) => {
const mod = await getPlugin(plugin)
if (!mod) return null
if (mod.serverPlugin) {
return {
content: mod.serverPlugin,
version: mod.version
}
}
return null
}
export const getAvailableServerPlugins = async () => {
const mods = await getAllMods()
return mods.filter(mod => mod.serverPlugin)
}
window.inspectInstalledMods = getAllMods
type ModifiableField = {
field: string
label: string
language: string
getContent?: () => string
}
// ---
export const getAllModsModifiableFields = () => {
const fields: ModifiableField[] = [
{
field: 'scriptMainUnstable',
label: 'Main Thread Script (unstable)',
language: 'js'
},
{
field: 'stylesGlobal',
label: 'Global CSS Styles',
language: 'css'
},
{
field: 'threeJsBackend',
label: 'Three.js Renderer Backend Thread',
language: 'js'
},
{
field: 'serverPlugin',
label: 'Built-in server plugin',
language: 'js'
}
]
return fields
}
export const getModModifiableFields = (mod: ClientMod): ModifiableField[] => {
return getAllModsModifiableFields().filter(field => mod[field.field])
}
export const getModSettingsProxy = (mod: ClientMod) => {
if (!mod.settings) return valtio.proxy({})
const proxy = valtio.proxy({})
for (const [key, setting] of Object.entries(mod.settings)) {
proxy[key] = options[`mod-${mod.name}-${key}`] ?? setting.default
}
valtio.subscribe(proxy, (ops) => {
for (const op of ops) {
const [type, path, value] = op
const key = path[0] as string
options[`mod-${mod.name}-${key}`] = value
}
})
return proxy
}
export const callMethodAction = async (modName: string, type: 'main', method: string) => {
try {
const mod = window.loadedMods?.[modName]
await mod[method]()
} catch (err) {
showNotification(`Failed to execute ${method}`, `Problem in ${type} js script of ${modName}`, true)
}
}

View file

@ -3,6 +3,7 @@
import MinecraftData from 'minecraft-data' import MinecraftData from 'minecraft-data'
import PrismarineBlock from 'prismarine-block' import PrismarineBlock from 'prismarine-block'
import PrismarineItem from 'prismarine-item' import PrismarineItem from 'prismarine-item'
import pathfinder from 'mineflayer-pathfinder'
import { miscUiState } from './globalState' import { miscUiState } from './globalState'
import supportedVersions from './supportedVersions.mjs' import supportedVersions from './supportedVersions.mjs'
import { options } from './optionsStorage' import { options } from './optionsStorage'
@ -20,6 +21,7 @@ export type ConnectOptions = {
peerId?: string peerId?: string
ignoreQs?: boolean ignoreQs?: boolean
onSuccessfulPlay?: () => void onSuccessfulPlay?: () => void
autoLoginPassword?: string
serverIndex?: string serverIndex?: string
authenticatedAccount?: AuthenticatedAccount | true authenticatedAccount?: AuthenticatedAccount | true
peerOptions?: any peerOptions?: any
@ -64,15 +66,12 @@ export const loadMinecraftData = async (version: string) => {
window.PrismarineItem = PrismarineItem(mcData.version.minecraftVersion!) window.PrismarineItem = PrismarineItem(mcData.version.minecraftVersion!)
window.loadedData = mcData window.loadedData = mcData
window.mcData = mcData window.mcData = mcData
window.pathfinder = pathfinder
miscUiState.loadedDataVersion = version miscUiState.loadedDataVersion = version
} }
export type AssetDownloadReporter = (asset: string, isDone: boolean) => void export const downloadAllMinecraftData = async () => {
export const downloadAllMinecraftData = async (reporter?: AssetDownloadReporter) => {
reporter?.('mc-data', false)
await window._LOAD_MC_DATA() await window._LOAD_MC_DATA()
reporter?.('mc-data', true)
} }
const loadFonts = async () => { const loadFonts = async () => {
@ -85,12 +84,6 @@ const loadFonts = async () => {
} }
} }
export const downloadOtherGameData = async (reporter?: AssetDownloadReporter) => { export const downloadOtherGameData = async () => {
reporter?.('fonts', false) await Promise.all([loadFonts(), downloadSoundsIfNeeded()])
reporter?.('sounds', false)
await Promise.all([
loadFonts().then(() => reporter?.('fonts', true)),
downloadSoundsIfNeeded().then(() => reporter?.('sounds', true))
])
} }

View file

@ -27,9 +27,7 @@ import { onCameraMove, onControInit } from './cameraRotationControls'
import { createNotificationProgressReporter } from './core/progressReporter' import { createNotificationProgressReporter } from './core/progressReporter'
import { appStorage } from './react/appStorageProvider' import { appStorage } from './react/appStorageProvider'
import { switchGameMode } from './packetsReplay/replayPackets' 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) export const customKeymaps = proxy(appStorage.keybindings)
subscribe(customKeymaps, () => { subscribe(customKeymaps, () => {
@ -43,48 +41,36 @@ const controlOptions = {
export const contro = new ControMax({ export const contro = new ControMax({
commands: { commands: {
general: { general: {
// movement
jump: ['Space', 'A'], jump: ['Space', 'A'],
inventory: ['KeyE', 'X'], inventory: ['KeyE', 'X'],
drop: ['KeyQ', 'B'], drop: ['KeyQ', 'B'],
dropStack: [null],
sneak: ['ShiftLeft'], sneak: ['ShiftLeft'],
toggleSneakOrDown: [null, 'Right Stick'], toggleSneakOrDown: [null, 'Right Stick'],
sprint: ['ControlLeft', 'Left Stick'], sprint: ['ControlLeft', 'Left Stick'],
// game interactions
nextHotbarSlot: [null, 'Right Bumper'], nextHotbarSlot: [null, 'Right Bumper'],
prevHotbarSlot: [null, 'Left Bumper'], prevHotbarSlot: [null, 'Left Bumper'],
attackDestroy: [null, 'Right Trigger'], attackDestroy: [null, 'Right Trigger'],
interactPlace: [null, 'Left Trigger'], interactPlace: [null, 'Left Trigger'],
chat: [['KeyT', 'Enter']],
command: ['Slash'],
swapHands: ['KeyF'], swapHands: ['KeyF'],
selectItem: ['KeyH'], zoom: ['KeyC'],
selectItem: ['KeyH'], // default will be removed
rotateCameraLeft: [null], rotateCameraLeft: [null],
rotateCameraRight: [null], rotateCameraRight: [null],
rotateCameraUp: [null], rotateCameraUp: [null],
rotateCameraDown: [null], rotateCameraDown: [null],
// ui? viewerConsole: ['Backquote']
chat: [['KeyT', 'Enter']],
command: ['Slash'],
playersList: ['Tab'],
debugOverlay: ['F3'],
debugOverlayHelpMenu: [null],
// client side
zoom: ['KeyC'],
viewerConsole: ['Backquote'],
togglePerspective: ['F5'],
}, },
ui: { ui: {
toggleFullscreen: ['F11'], toggleFullscreen: ['F11'],
back: [null/* 'Escape' */, 'B'], back: [null/* 'Escape' */, 'B'],
toggleMap: ['KeyJ'], toggleMap: ['KeyM'],
leftClick: [null, 'A'], leftClick: [null, 'A'],
rightClick: [null, 'Y'], rightClick: [null, 'Y'],
speedupCursor: [null, 'Left Stick'], speedupCursor: [null, 'Left Stick'],
pauseMenu: [null, 'Start'] pauseMenu: [null, 'Start']
}, },
communication: {
toggleMicrophone: ['KeyM'],
},
advanced: { advanced: {
lockUrl: ['KeyY'], lockUrl: ['KeyY'],
}, },
@ -116,10 +102,6 @@ export const contro = new ControMax({
window.controMax = contro window.controMax = contro
export type Command = CommandEventArgument<typeof contro['_commandsRaw']>['command'] export type Command = CommandEventArgument<typeof contro['_commandsRaw']>['command']
export const isCommandDisabled = (command: Command) => {
return miscUiState.appConfig?.disabledCommands?.includes(command)
}
onControInit() onControInit()
updateBinds(customKeymaps) updateBinds(customKeymaps)
@ -137,14 +119,7 @@ const setSprinting = (state: boolean) => {
gameAdditionalState.isSprinting = state gameAdditionalState.isSprinting = state
} }
const isSpectatingEntity = () => {
return appViewer.playerState.utils.isSpectatingEntity()
}
contro.on('movementUpdate', ({ vector, soleVector, gamepadIndex }) => { contro.on('movementUpdate', ({ vector, soleVector, gamepadIndex }) => {
// Don't allow movement while spectating an entity
if (isSpectatingEntity()) return
if (gamepadIndex !== undefined && gamepadUiCursorState.display) { if (gamepadIndex !== undefined && gamepadUiCursorState.display) {
const deadzone = 0.1 // TODO make deadzone configurable const deadzone = 0.1 // TODO make deadzone configurable
if (Math.abs(soleVector.x) < deadzone && Math.abs(soleVector.z) < deadzone) { if (Math.abs(soleVector.x) < deadzone && Math.abs(soleVector.z) < deadzone) {
@ -202,7 +177,6 @@ contro.on('movementUpdate', ({ vector, soleVector, gamepadIndex }) => {
if (action) { if (action) {
void contro.emit('trigger', { command: 'general.forward' } as any) void contro.emit('trigger', { command: 'general.forward' } as any)
} else { } else {
void contro.emit('release', { command: 'general.forward' } as any)
setSprinting(false) setSprinting(false)
} }
} }
@ -253,10 +227,6 @@ const inModalCommand = (command: Command, pressed: boolean) => {
if (command === 'ui.back') { if (command === 'ui.back') {
hideCurrentModal() hideCurrentModal()
} }
if (command === 'ui.pauseMenu') {
// hide all modals
hideAllModals()
}
if (command === 'ui.leftClick' || command === 'ui.rightClick') { if (command === 'ui.leftClick' || command === 'ui.rightClick') {
// in percent // in percent
const { x, y } = gamepadUiCursorState const { x, y } = gamepadUiCursorState
@ -353,9 +323,6 @@ const cameraRotationControls = {
cameraRotationControls.updateMovement() cameraRotationControls.updateMovement()
}, },
handleCommand (command: string, pressed: boolean) { handleCommand (command: string, pressed: boolean) {
// Don't allow movement while spectating an entity
if (isSpectatingEntity()) return
const directionMap = { const directionMap = {
'general.rotateCameraLeft': 'left', 'general.rotateCameraLeft': 'left',
'general.rotateCameraRight': 'right', 'general.rotateCameraRight': 'right',
@ -377,7 +344,6 @@ window.cameraRotationControls = cameraRotationControls
const setSneaking = (state: boolean) => { const setSneaking = (state: boolean) => {
gameAdditionalState.isSneaking = state gameAdditionalState.isSneaking = state
bot.setControlState('sneak', state) bot.setControlState('sneak', state)
} }
const onTriggerOrReleased = (command: Command, pressed: boolean) => { const onTriggerOrReleased = (command: Command, pressed: boolean) => {
@ -388,7 +354,6 @@ const onTriggerOrReleased = (command: Command, pressed: boolean) => {
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
switch (command) { switch (command) {
case 'general.jump': case 'general.jump':
if (isSpectatingEntity()) break
// if (viewer.world.freeFlyMode) { // if (viewer.world.freeFlyMode) {
// const moveSpeed = 0.5 // const moveSpeed = 0.5
// viewer.world.freeFlyState.position.add(new Vec3(0, pressed ? moveSpeed : 0, 0)) // viewer.world.freeFlyState.position.add(new Vec3(0, pressed ? moveSpeed : 0, 0))
@ -426,67 +391,12 @@ const onTriggerOrReleased = (command: Command, pressed: boolean) => {
case 'general.zoom': case 'general.zoom':
gameAdditionalState.isZooming = pressed gameAdditionalState.isZooming = pressed
break break
case 'general.debugOverlay':
if (pressed) {
miscUiState.showDebugHud = !miscUiState.showDebugHud
}
break
case 'general.debugOverlayHelpMenu':
if (pressed) {
void onF3LongPress()
}
break
case 'general.rotateCameraLeft': case 'general.rotateCameraLeft':
case 'general.rotateCameraRight': case 'general.rotateCameraRight':
case 'general.rotateCameraUp': case 'general.rotateCameraUp':
case 'general.rotateCameraDown': case 'general.rotateCameraDown':
cameraRotationControls.handleCommand(command, pressed) cameraRotationControls.handleCommand(command, pressed)
break break
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) {
case 'ui.pauseMenu':
if (pressed) {
if (activeModalStack.length) {
hideCurrentModal()
} else {
showModal({ reactType: 'pause-screen' })
}
}
break
case 'ui.back':
case 'ui.toggleFullscreen':
case 'ui.toggleMap':
case 'ui.leftClick':
case 'ui.rightClick':
case 'ui.speedupCursor':
// These are handled elsewhere
break
} }
} }
} }
@ -503,9 +413,6 @@ const alwaysPressedHandledCommand = (command: Command) => {
if (command === 'advanced.lockUrl') { if (command === 'advanced.lockUrl') {
lockUrl() lockUrl()
} }
if (command === 'communication.toggleMicrophone') {
toggleMicrophoneMuted?.()
}
} }
export function lockUrl () { export function lockUrl () {
@ -548,8 +455,6 @@ const customCommandsHandler = ({ command }) => {
contro.on('trigger', customCommandsHandler) contro.on('trigger', customCommandsHandler)
contro.on('trigger', ({ command }) => { contro.on('trigger', ({ command }) => {
if (isCommandDisabled(command)) return
const willContinue = !isGameActive(true) const willContinue = !isGameActive(true)
alwaysPressedHandledCommand(command) alwaysPressedHandledCommand(command)
if (willContinue) return if (willContinue) return
@ -581,22 +486,13 @@ contro.on('trigger', ({ command }) => {
case 'general.rotateCameraRight': case 'general.rotateCameraRight':
case 'general.rotateCameraUp': case 'general.rotateCameraUp':
case 'general.rotateCameraDown': case 'general.rotateCameraDown':
case 'general.debugOverlay':
case 'general.debugOverlayHelpMenu':
case 'general.playersList':
case 'general.togglePerspective':
// no-op // no-op
break break
case 'general.swapHands': { case 'general.swapHands': {
if (isSpectatingEntity()) break bot._client.write('entity_action', {
bot._client.write('block_dig', { entityId: bot.entity.id,
'status': 6, actionId: 6,
'location': { jumpBoost: 0
'x': 0,
'z': 0,
'y': 0
},
'face': 0,
}) })
break break
} }
@ -604,13 +500,11 @@ contro.on('trigger', ({ command }) => {
// handled in onTriggerOrReleased // handled in onTriggerOrReleased
break break
case 'general.inventory': case 'general.inventory':
if (isSpectatingEntity()) break
document.exitPointerLock?.() document.exitPointerLock?.()
openPlayerInventory() openPlayerInventory()
break break
case 'general.drop': { case 'general.drop': {
if (isSpectatingEntity()) break // if (bot.heldItem/* && ctrl */) bot.tossStack(bot.heldItem)
// protocol 1.9+
bot._client.write('block_dig', { bot._client.write('block_dig', {
'status': 4, 'status': 4,
'location': { 'location': {
@ -629,12 +523,6 @@ contro.on('trigger', ({ command }) => {
} }
break break
} }
case 'general.dropStack': {
if (bot.heldItem) {
void bot.tossStack(bot.heldItem)
}
break
}
case 'general.chat': case 'general.chat':
showModal({ reactType: 'chat' }) showModal({ reactType: 'chat' })
break break
@ -643,15 +531,12 @@ contro.on('trigger', ({ command }) => {
showModal({ reactType: 'chat' }) showModal({ reactType: 'chat' })
break break
case 'general.selectItem': case 'general.selectItem':
if (isSpectatingEntity()) break
void selectItem() void selectItem()
break break
case 'general.nextHotbarSlot': case 'general.nextHotbarSlot':
if (isSpectatingEntity()) break
cycleHotbarSlot(1) cycleHotbarSlot(1)
break break
case 'general.prevHotbarSlot': case 'general.prevHotbarSlot':
if (isSpectatingEntity()) break
cycleHotbarSlot(-1) cycleHotbarSlot(-1)
break break
case 'general.zoom': case 'general.zoom':
@ -664,6 +549,10 @@ contro.on('trigger', ({ command }) => {
} }
} }
if (command === 'ui.pauseMenu') {
showModal({ reactType: 'pause-screen' })
}
if (command === 'ui.toggleFullscreen') { if (command === 'ui.toggleFullscreen') {
void goFullscreen(true) void goFullscreen(true)
} }
@ -683,8 +572,6 @@ contro.on('trigger', ({ command }) => {
}) })
contro.on('release', ({ command }) => { contro.on('release', ({ command }) => {
if (isCommandDisabled(command)) return
inModalCommand(command, false) inModalCommand(command, false)
onTriggerOrReleased(command, false) onTriggerOrReleased(command, false)
}) })
@ -716,9 +603,6 @@ export const f3Keybinds: Array<{
localServer.players[0].world.columns = {} localServer.players[0].world.columns = {}
} }
void reloadChunks() void reloadChunks()
if (appViewer.backend?.backendMethods && typeof appViewer.backend.backendMethods.reloadWorld === 'function') {
appViewer.backend.backendMethods.reloadWorld()
}
}, },
mobileTitle: 'Reload chunks', mobileTitle: 'Reload chunks',
}, },
@ -729,19 +613,6 @@ export const f3Keybinds: Array<{
}, },
mobileTitle: 'Toggle chunk borders', mobileTitle: 'Toggle chunk borders',
}, },
{
key: 'KeyH',
action () {
showModal({ reactType: 'chunks-debug' })
},
mobileTitle: 'Show Chunks Debug',
},
{
action () {
showModal({ reactType: 'renderer-debug' })
},
mobileTitle: 'Renderer Debug Menu',
},
{ {
key: 'KeyY', key: 'KeyY',
async action () { async action () {
@ -818,23 +689,30 @@ export const f3Keybinds: Array<{
} }
] ]
export const reloadChunksAction = () => { const hardcodedPressedKeys = new Set<string>()
const action = f3Keybinds.find(f3Keybind => f3Keybind.key === 'KeyA')
void action!.action()
}
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (!isGameActive(false)) return if (!isGameActive(false)) return
if (contro.pressedKeys.has('F3')) { if (hardcodedPressedKeys.has('F3')) {
const keybind = f3Keybinds.find((v) => v.key === e.code) const keybind = f3Keybinds.find((v) => v.key === e.code)
if (keybind && (keybind.enabled?.() ?? true)) { if (keybind && (keybind.enabled?.() ?? true)) {
void keybind.action() void keybind.action()
e.stopPropagation() e.stopPropagation()
} }
return
} }
hardcodedPressedKeys.add(e.code)
}, { }, {
capture: true, capture: true,
}) })
document.addEventListener('keyup', (e) => {
hardcodedPressedKeys.delete(e.code)
})
document.addEventListener('visibilitychange', (e) => {
if (document.visibilityState === 'hidden') {
hardcodedPressedKeys.clear()
}
})
const isFlying = () => (bot.entity as any).flying const isFlying = () => (bot.entity as any).flying
@ -883,11 +761,6 @@ const selectItem = async () => {
} }
addEventListener('mousedown', async (e) => { addEventListener('mousedown', async (e) => {
// always prevent default for side buttons (back / forward navigation)
if (e.button === 3 || e.button === 4) {
e.preventDefault()
}
if ((e.target as HTMLElement).matches?.('#VRButton')) return if ((e.target as HTMLElement).matches?.('#VRButton')) return
if (!isInRealGameSession() && !(e.target as HTMLElement).id.includes('ui-root')) return if (!isInRealGameSession() && !(e.target as HTMLElement).id.includes('ui-root')) return
void pointerLock.requestPointerLock() void pointerLock.requestPointerLock()
@ -990,62 +863,3 @@ export function updateBinds (commands: any) {
})) }))
} }
} }
export const onF3LongPress = async () => {
const actions = f3Keybinds.filter(f3Keybind => {
return f3Keybind.mobileTitle && (f3Keybind.enabled?.() ?? true)
})
const actionNames = actions.map(f3Keybind => {
return `${f3Keybind.mobileTitle}${f3Keybind.key ? ` (F3+${f3Keybind.key})` : ''}`
})
const select = await showOptionsModal('', actionNames)
if (!select) return
const actionIndex = actionNames.indexOf(select)
const f3Keybind = actions[actionIndex]!
void f3Keybind.action()
}
export const handleMobileButtonCustomAction = (action: CustomAction) => {
const handler = customCommandsConfig[action.type]?.handler
if (handler) {
handler([...action.input])
}
}
export const triggerCommand = (command: Command, isDown: boolean) => {
handleMobileButtonActionCommand(command, isDown)
}
export const handleMobileButtonActionCommand = (command: ActionType | ActionHoldConfig, isDown: boolean) => {
const commandValue = typeof command === 'string' ? command : 'command' in command ? command.command : command
// Check if command is disabled before proceeding
if (typeof commandValue === 'string' && isCommandDisabled(commandValue as Command)) return
if (typeof commandValue === 'string' && !stringStartsWith(commandValue, 'custom')) {
const event: CommandEventArgument<typeof contro['_commandsRaw']> = {
command: commandValue as Command,
schema: {
keys: [],
gamepad: []
}
}
if (isDown) {
contro.emit('trigger', event)
} else {
contro.emit('release', event)
}
} else if (typeof commandValue === 'object') {
if (isDown) {
handleMobileButtonCustomAction(commandValue)
}
}
}
export const handleMobileButtonLongPress = (actionHold: ActionHoldConfig) => {
if (typeof actionHold.longPressAction === 'string' && actionHold.longPressAction === 'general.debugOverlayHelpMenu') {
void onF3LongPress()
} else if (actionHold.longPressAction) {
handleMobileButtonActionCommand(actionHold.longPressAction, true)
}
}

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