Compare commits

..

3 commits

Author SHA1 Message Date
Vitaly Turovsky
e51e0e926a so damn clean cursor impl 2025-03-14 04:58:21 +03:00
Vitaly Turovsky
1ee52f7faa Merge branch 'next' into pr/zardoy/309 2025-03-14 04:58:13 +03:00
Vitaly Turovsky
a7bf484880 use proxy to download server resourece pack on any server from any not supporting cors url 2024-12-20 23:18:53 +03:00
345 changed files with 11865 additions and 25974 deletions

View file

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

View file

@ -23,7 +23,6 @@
// ],
"@stylistic/arrow-spacing": "error",
"@stylistic/block-spacing": "error",
"@typescript-eslint/no-this-alias": "off",
"@stylistic/brace-style": [
"error",
"1tbs",
@ -103,7 +102,6 @@
// "@stylistic/multiline-ternary": "error", // not needed
// "@stylistic/newline-per-chained-call": "error", // not sure if needed
"@stylistic/new-parens": "error",
"@typescript-eslint/class-literal-property-style": "off",
"@stylistic/no-confusing-arrow": "error",
"@stylistic/wrap-iife": "error",
"@stylistic/space-before-blocks": "error",

View file

@ -1,59 +0,0 @@
name: Benchmark
on:
issue_comment:
types: [created]
push:
branches:
- perf-test
jobs:
deploy:
runs-on: ubuntu-latest
if: >-
(github.event_name == 'push' && github.ref == 'refs/heads/perf-test') ||
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request != '' &&
(startsWith(github.event.comment.body, '/benchmark'))
)
permissions:
pull-requests: write
steps:
- run: lscpu
- name: Checkout
uses: actions/checkout@v2
- name: Setup pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- name: Move Cypress to dependencies
run: |
jq '.dependencies.cypress = .optionalDependencies.cypress | del(.optionalDependencies.cypress)' package.json > package.json.tmp
mv package.json.tmp package.json
- run: pnpm install --no-frozen-lockfile
- run: pnpm build
- run: nohup pnpm prod-start &
- run: pnpm test:benchmark
id: benchmark
continue-on-error: true
# read benchmark results from stdout
- run: |
if [ -f benchmark.txt ]; then
# Format the benchmark results for GitHub comment
BENCHMARK_RESULT=$(cat benchmark.txt | sed 's/^/- /')
echo "BENCHMARK_RESULT<<EOF" >> $GITHUB_ENV
echo "$BENCHMARK_RESULT" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
else
echo "BENCHMARK_RESULT=Benchmark failed to run or produce results" >> $GITHUB_ENV
fi
- uses: mshick/add-pr-comment@v2
with:
allow-repeats: true
message: |
Benchmark result: ${{ env.BENCHMARK_RESULT }}

View file

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

View file

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

View file

@ -20,56 +20,11 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
- run: pnpm install
- run: pnpm build-single-file
- name: Store minecraft.html size
run: |
SIZE_BYTES=$(du -s dist/single/minecraft.html 2>/dev/null | cut -f1)
echo "SIZE_BYTES=$SIZE_BYTES" >> $GITHUB_ENV
- run: pnpm check-build
- name: Create zip package for size comparison
run: |
mkdir -p package
cp -r dist package/
cd package
zip -r ../self-host.zip .
- run: pnpm build-playground
# - run: pnpm build-storybook
- run: pnpm build-storybook
- run: pnpm test-unit
- run: pnpm lint
- name: Parse Bundle Stats
run: |
GZIP_BYTES=$(du -s self-host.zip 2>/dev/null | cut -f1)
SIZE=$(echo "scale=2; $SIZE_BYTES/1024/1024" | bc)
GZIP_SIZE=$(echo "scale=2; $GZIP_BYTES/1024/1024" | bc)
echo "{\"total\": ${SIZE}, \"gzipped\": ${GZIP_SIZE}}" > /tmp/bundle-stats.json
# - name: Compare Bundle Stats
# id: compare
# uses: actions/github-script@v6
# env:
# GITHUB_TOKEN: ${{ secrets.GIST_TOKEN }}
# with:
# script: |
# const gistId = '${{ secrets.BUNDLE_STATS_GIST_ID }}';
# async function getGistContent() {
# const { data } = await github.rest.gists.get({
# gist_id: gistId,
# headers: {
# authorization: `token ${process.env.GITHUB_TOKEN}`
# }
# });
# return JSON.parse(data.files['bundle-stats.json'].content || '{}');
# }
# const content = await getGistContent();
# const baseStats = content['${{ github.event.pull_request.base.ref }}'];
# const newStats = require('/tmp/bundle-stats.json');
# const comparison = `minecraft.html (normal build gzip)\n${baseStats.total}MB (${baseStats.gzipped}MB compressed) -> ${newStats.total}MB (${newStats.gzipped}MB compressed)`;
# core.setOutput('stats', comparison);
# - run: pnpm tsx scripts/buildNpmReact.ts
- run: nohup pnpm prod-start &
- run: nohup pnpm test-mc-server &
@ -85,74 +40,6 @@ jobs:
# if: ${{ github.event.pull_request.base.ref == 'release' }}
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# - name: Store Bundle Stats
# if: github.event.pull_request.base.ref == 'next'
# uses: actions/github-script@v6
# env:
# GITHUB_TOKEN: ${{ secrets.GIST_TOKEN }}
# with:
# script: |
# const gistId = '${{ secrets.BUNDLE_STATS_GIST_ID }}';
# async function getGistContent() {
# const { data } = await github.rest.gists.get({
# gist_id: gistId,
# headers: {
# authorization: `token ${process.env.GITHUB_TOKEN}`
# }
# });
# return JSON.parse(data.files['bundle-stats.json'].content || '{}');
# }
# async function updateGistContent(content) {
# await github.rest.gists.update({
# gist_id: gistId,
# headers: {
# authorization: `token ${process.env.GITHUB_TOKEN}`
# },
# files: {
# 'bundle-stats.json': {
# content: JSON.stringify(content, null, 2)
# }
# }
# });
# }
# const stats = require('/tmp/bundle-stats.json');
# const content = await getGistContent();
# content['${{ github.event.pull_request.base.ref }}'] = stats;
# await updateGistContent(content);
# - name: Update PR Description
# uses: actions/github-script@v6
# with:
# script: |
# const { data: pr } = await github.rest.pulls.get({
# owner: context.repo.owner,
# repo: context.repo.repo,
# pull_number: context.issue.number
# });
# let body = pr.body || '';
# const statsMarker = '### Bundle Size';
# const comparison = '${{ steps.compare.outputs.stats }}';
# if (body.includes(statsMarker)) {
# body = body.replace(
# new RegExp(`${statsMarker}[^\n]*\n[^\n]*`),
# `${statsMarker}\n${comparison}`
# );
# } else {
# body += `\n\n${statsMarker}\n${comparison}`;
# }
# await github.rest.pulls.update({
# owner: context.repo.owner,
# repo: context.repo.repo,
# pull_number: context.issue.number,
# body
# });
# dedupe-check:
# runs-on: ubuntu-latest
# if: github.event.pull_request.head.ref == 'next'

View file

@ -30,18 +30,18 @@ jobs:
- name: Write Release Info
run: |
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
run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
env:
CONFIG_JSON_SOURCE: BUNDLED
LOCAL_CONFIG_FILE: config.mcraft-only.json
- run: pnpm build-storybook
- name: Copy playground files
run: |
mkdir -p .vercel/output/static/playground
pnpm build-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
uses: mathiasvr/command-output@v2.0.0
with:

View file

@ -1,4 +1,4 @@
name: Vercel PR Deploy (Preview)
name: Vercel Deploy Preview
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
@ -52,19 +52,6 @@ jobs:
with:
node-version: 22
cache: "pnpm"
- name: Update deployAlwaysUpdate packages
run: |
if [ -f package.json ]; then
PACKAGES=$(node -e "const pkg = require('./package.json'); if (pkg.deployAlwaysUpdate) console.log(pkg.deployAlwaysUpdate.join(' '))")
if [ ! -z "$PACKAGES" ]; then
echo "Updating packages: $PACKAGES"
pnpm up -L $PACKAGES
else
echo "No deployAlwaysUpdate packages found in package.json"
fi
else
echo "package.json not found"
fi
- name: Install Global Dependencies
run: pnpm add -g vercel
- name: Pull Vercel Environment Information
@ -72,13 +59,11 @@ jobs:
- name: Write Release Info
run: |
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
run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
env:
CONFIG_JSON_SOURCE: BUNDLED
LOCAL_CONFIG_FILE: config.mcraft-only.json
- run: pnpm build-storybook
- name: Copy playground files
run: |
mkdir -p .vercel/output/static/playground
@ -92,6 +77,8 @@ jobs:
run: |
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
- name: Download Generated Sounds map
run: node scripts/downloadSoundsMap.mjs
- name: Deploy Project Artifacts to Vercel
uses: mathiasvr/command-output@v2.0.0
with:

View file

@ -29,18 +29,22 @@ jobs:
run: pnpx zardoy-release empty --skip-github --output-file assets/release.json
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Download Generated Sounds map
run: node scripts/downloadSoundsMap.mjs
- run: vercel build --token=${{ secrets.VERCEL_TOKEN }} --prod
env:
CONFIG_JSON_SOURCE: BUNDLED
LOCAL_CONFIG_FILE: config.mcraft-only.json
- run: pnpm build-storybook
- name: Copy playground files
run: |
mkdir -p .vercel/output/static/playground
pnpm build-playground
cp -r renderer/dist/* .vercel/output/static/playground/
- 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
- run: cp vercel.json .vercel/output/static/vercel.json
- uses: peaceiris/actions-gh-pages@v3
@ -49,39 +53,6 @@ jobs:
publish_dir: .vercel/output/static
force_orphan: true
# Create CNAME file for custom domain
- name: Create CNAME file
run: echo "github.mcraft.fun" > .vercel/output/static/CNAME
- name: Deploy to mwc-mcraft-pages repository
uses: peaceiris/actions-gh-pages@v3
with:
personal_token: ${{ secrets.MCW_MCRAFT_PAGE_DEPLOY_TOKEN }}
external_repository: ${{ github.repository_owner }}/mwc-mcraft-pages
publish_dir: .vercel/output/static
publish_branch: main
destination_dir: docs
force_orphan: true
- name: Change index.html title
run: |
# change <title>Minecraft Web Client</title> to <title>Minecraft Web Client — Free Online Browser Version</title>
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
run: pnpm build-single-file && mv dist/single/index.html minecraft.html
- name: Build self-host version
@ -99,7 +70,7 @@ jobs:
zip -r ../self-host.zip .
- 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:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# has possible output: tag

3
.gitignore vendored
View file

@ -10,7 +10,7 @@ localSettings.mjs
dist*
.DS_Store
.idea/
/world
world
data*.json
out
*.iml
@ -19,6 +19,5 @@ generated
storybook-static
server-jar
config.local.json
logs/
src/react/npmReactComponents.ts

View file

@ -9,10 +9,8 @@ After forking the repository, run the following commands to get started:
4. Let us know if you are working on something and be sure to open a PR if you got any changes. Happy coding!
*(1): If you are getting `Cannot find matching keyid` update corepack to the latest version with `npm i -g corepack`.
*(2): If still something doesn't work ensure you have the right nodejs version with `node -v` (tested on 22.x)
<!-- *(3): For GitHub codespaces (cloud ide): Run `pnpm i @rsbuild/core@1.2.4 @rsbuild/plugin-node-polyfill@1.3.0 @rsbuild/plugin-react@1.1.0 @rsbuild/plugin-typed-css-modules@1.0.2` command to avoid crashes because of limited ram -->
*(3): For GitHub codespaces (cloud ide): Run `pnpm i @rsbuild/core@1.2.4 @rsbuild/plugin-node-polyfill@1.3.0 @rsbuild/plugin-react@1.1.0 @rsbuild/plugin-typed-css-modules@1.0.2` command to avoid crashes because of limited ram
## Project Structure
@ -177,13 +175,8 @@ New React components, improve UI (including mobile support).
## Updating Dependencies
1. Use `pnpm update-git-deps` to check and update git dependencies (like mineflayer fork, prismarine packages etc). The script will:
- Show which git dependencies have updates available
- Ask if you want to update them
- Skip dependencies listed in `pnpm.updateConfig.ignoreDependencies`
1. Ensure mineflayer fork is up to date with the latest version of mineflayer original repo
2. Update PrismarineJS dependencies to the latest version: `minecraft-data` (be sure to replace the version twice in the package.json), `mineflayer`, `minecraft-protocol`, `prismarine-block`, `prismarine-chunk`, `prismarine-item`, ...
3. If `minecraft-protocol` patch fails, do this:
1. Remove the patch from `patchedDependencies` in `package.json`
2. Run `pnpm patch minecraft-protocol`, open patch directory

View file

@ -4,8 +4,8 @@ FROM node:18-alpine AS build
RUN apk add git
WORKDIR /app
COPY . /app
# install pnpm with corepack
RUN corepack enable
# install pnpm
RUN npm i -g pnpm@9.0.4
# Build arguments
ARG DOWNLOAD_SOUNDS=false
ARG DISABLE_SERVICE_WORKER=false
@ -35,7 +35,7 @@ WORKDIR /app
COPY --from=build /app/dist /app/dist
COPY server.js /app/server.js
# Install express
RUN npm i -g pnpm@10.8.0
RUN npm i -g pnpm@9.0.4
RUN npm init -yp
RUN pnpm i express github:zardoy/prismarinejs-net-browserify compression cors
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.
> For Turkey/Russia use [ru.mcraft.fun](https://ru.mcraft.fun/) (since Cloudflare is blocked)
Don't confuse with [Eaglercraft](https://git.eaglercraft.rip/eaglercraft/eaglercraft-1.8) which is a REAL vanilla Minecraft Java edition port to the web (but with its own limitations). Eaglercraft is a fully playable solution, but this project is more in position of a "technical demo" to show how it's possible to build games for web at scale entirely with the JS ecosystem. Have fun!
Don't confuse with [Eaglercraft](https://git.eaglercraft.rip/eaglercraft/eaglercraft-1.8) which is a REAL vanilla Minecraft Java edition port to the web (but with its own limitations). Eaglercraft is a fully playable solution, meanwhile this project is aimed for *device-compatiiblity* and better performance so it feels portable, flexible and lightweight. It's also a very strong example on how to build true HTML games for the web at scale entirely with the JS ecosystem. Have fun!
For building the project yourself / contributing, see [Development, Debugging & Contributing](#development-debugging--contributing). For reference at what and how web technologies / frameworks are used, see [TECH.md](./TECH.md) (also for comparison with Eaglercraft).
> **Note**: You can deploy it on your own server in less than a minute using a one-liner script from [Minecraft Everywhere repo](https://github.com/zardoy/minecraft-everywhere)
For building the project yourself / contributing, see [Development, Debugging & Contributing](#development-debugging--contributing). For reference at what and how web technologies / frameworks are used, see [TECH.md](./TECH.md).
### Big Features
- 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!
- 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)
@ -29,39 +24,24 @@ For building the project yourself / contributing, see [Development, Debugging &
- Custom protocol channel extensions (eg for custom block models in the world)
- Play with friends over internet! (P2P is powered by Peer.js discovery servers)
- ~~Google Drive support for reading / saving worlds back to the cloud~~
- Support for custom rendering 3D engines. Modular architecture.
- even even more!
All components that are in [Storybook](https://minimap.mcraft.fun/storybook/) are published as npm module and can be used in other projects: [`minecraft-react`](https://npmjs.com/minecraft-react)
All components that are in [Storybook](https://mcraft.fun/storybook) are published as npm module and can be used in other projects: [`minecraft-react`](https://npmjs.com/minecraft-react)
### Recommended Settings
- Controls -> **Touch Controls Type** -> **Joystick**
- 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)
- Interface -> **Chat Select** -> **On** - To select chat messages (UPD: already enabled by default)
### Browser Notes
This project is tested with BrowserStack. Special thanks to [BrowserStack](https://www.browserstack.com/) for providing testing infrastructure!
Howerver, it's known that these browsers have issues:
These browsers have issues with capturing pointer:
**Opera Mini**: Disable *mouse gestures* in browsre settings to avoid opening new tab on right click hold
**Vivaldi**: Disable Controls -> *Raw Input* in game settings if experiencing issues
### Versions Support
Server versions 1.8 - 1.21.5 are supported.
First class versions (most of the features are tested on these versions):
- 1.19.4
- 1.21.4
Versions below 1.13 are not tested currently and may not work correctly.
### World Loading
Zip files and folders are supported. Just drag and drop them into the browser window. You can open folders in readonly and read-write mode. New chunks may be generated incorrectly for now.
@ -78,8 +58,6 @@ There is a builtin proxy, but you can also host your one! Just clone the repo, r
[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?name=minecraft-web-client&type=git&repository=zardoy%2Fminecraft-web-client&branch=next&builder=dockerfile&env%5B%5D=&ports=8080%3Bhttp%3B%2F)
> **Note**: If you want to make **your own** Minecraft server accessible to web clients (without our proxies), you can use [mwc-proxy](https://github.com/zardoy/mwc-proxy) - a lightweight JS WebSocket proxy that runs on the same server as your Minecraft server, allowing players to connect directly via `wss://play.example.com`. `?client_mcraft` is added to the URL, so the proxy will know that it's this client.
Proxy servers are used to connect to Minecraft servers which use TCP protocol. When you connect connect to a server with a proxy, websocket connection is created between you (browser client) and the proxy server located in Europe, then the proxy connects to the Minecraft server and sends the data to the client (you) without any packet deserialization to avoid any additional delays. That said all the Minecraft protocol packets are processed by the client, right in your browser.
```mermaid
@ -127,12 +105,12 @@ There is world renderer playground ([link](https://mcon.vercel.app/playground/))
However, there are many things that can be done in online production version (like debugging actual source code). Also you can access some global variables in the console and there are a few useful examples:
- If you type `debugToggle`, press enter in console - It will enables all debug messages! Warning: this will start all packets spam.
- `localStorage.debug = '*'` - Enables all debug messages! Warning: this will start all packets spam.
Instead I recommend setting `options.debugLogNotFrequentPackets`. Also you can use `debugTopPackets` (with JSON.stringify) to see what packets were received/sent by name
- `bot` - Mineflayer bot instance. See Mineflayer documentation for more.
- `world` - Three.js world instance, basically does all the rendering (part of renderer backend).
- `world.sectionObjects` - Object with all active chunk sections (geometries) in the world. Each chunk section is a Three.js mesh or group.
- `viewer` - Three.js viewer instance, basically does all the rendering.
- `viewer.world.sectionObjects` - Object with all active chunk sections (geometries) in the world. Each chunk section is a Three.js mesh or group.
- `debugSceneChunks` - The same as above, but relative to current bot position (e.g. 0,0 is the current chunk).
- `debugChangedOptions` - See what options are changed. Don't change options here.
- `localServer`/`server` - Only for singleplayer mode/host. Flying Squid server instance, see it's documentation for more.
@ -141,7 +119,7 @@ Instead I recommend setting `options.debugLogNotFrequentPackets`. Also you can u
- `nbt.simplify(someNbt)` - Simplifies nbt data, so it's easier to read.
The most useful thing in devtools is the watch expression. You can add any expression there and it will be re-evaluated in real time. For example, you can add `world.getCameraPosition()` to see the camera position and so on.
The most useful thing in devtools is the watch expression. You can add any expression there and it will be re-evaluated in real time. For example, you can add `viewer.camera.position` to see the camera position and so on.
<img src="./docs-assets/watch-expr.png" alt="Watch expression" width="480"/>
@ -178,7 +156,6 @@ Server specific:
- `?lockConnect=true` - Only works then `ip` parameter is set. Disables cancel/save buttons and all inputs in the connect screen already set as parameters. Useful for integrates iframes.
- `?autoConnect=true` - Only works then `ip` and `version` parameters are set and `allowAutoConnect` is `true` in config.json! Directly connects to the specified server. Useful for integrates iframes.
- `?serversList=<list_or_url>` - `<list_or_url>` can be a list of servers in the format `ip:version,ip` or a url to a json file with the same format (array) or a txt file with line-delimited list of server IPs.
- `?addPing=<ping>` - Add a latency to both sides of the connection. Useful for testing ping issues. For example `?addPing=100` will add 200ms to your ping.
Single player specific:
@ -235,4 +212,3 @@ Only during development:
- [https://github.com/ClassiCube/ClassiCube](ClassiCube - Better C# Rewrite) [DEMO](https://www.classicube.net/server/play/?warned=true)
- [https://m.eaglercraft.com/](EaglerCraft) - Eaglercraft runnable on mobile (real Minecraft in the browser)
- [js-minecraft](https://github.com/LabyStudio/js-minecraft) - An insanely well done clone from the graphical side that inspired many features here

13
TECH.md
View file

@ -10,27 +10,26 @@ This client generally has better performance but some features reproduction migh
| 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) |
| 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 (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 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... |
| 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 |
| 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 |
| 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) |
| Video Recording | ❌ | ✅ | Doesn't feel needed |
| Metaverse Features | ✅(50%) | ❌ | We have videos / images support inside world, but not iframes (custom protocol channel) |
| 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 | ❌ | ✅ | Don't feel needed |
| Metaverse Features | ❌(roadmap) | ❌ | Iframes, video streams inside of game world (custom protocol channel) |
| Sounds | ✅ | ✅ | |
| 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 |
| Graphics | | | |
| 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 |
| 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 |
| 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,36 +3,25 @@
"defaultHost": "<from-proxy>",
"defaultProxy": "https://proxy.mcraft.fun",
"mapsProvider": "https://maps.mcraft.fun/",
"skinTexturesProxy": "",
"peerJsServer": "",
"peerJsServerFallback": "https://p2p.mcraft.fun",
"promoteServers": [
{
"ip": "wss://mcraft.ryzyn.xyz",
"version": "1.19.4"
},
{
"ip": "wss://play.mcraft.fun"
},
{
"ip": "wss://play.webmc.fun",
"name": "WebMC"
},
{
"ip": "wss://ws.fuchsmc.net"
},
{
"ip": "wss://play2.mcraft.fun"
},
{
"ip": "wss://play-creative.mcraft.fun",
"description": "Might be available soon, stay tuned!"
},
{
"ip": "kaboom.pw",
"version": "1.20.3",
"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": [
[
{
@ -42,39 +31,5 @@
"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,7 +1,5 @@
import { defineConfig } from 'cypress'
const isPerformanceTest = process.env.PERFORMANCE_TEST === 'true'
export default defineConfig({
video: false,
chromeWebSecurity: false,
@ -34,7 +32,7 @@ export default defineConfig({
return require('./cypress/plugins/index.js')(on, config)
},
baseUrl: 'http://localhost:8080',
specPattern: !isPerformanceTest ? 'cypress/e2e/smoke.spec.ts' : 'cypress/e2e/rendering_performance.spec.ts',
specPattern: 'cypress/e2e/**/*.spec.ts',
excludeSpecPattern: ['**/__snapshots__/*', '**/__image_snapshots__/*'],
},
})

View file

@ -1,32 +0,0 @@
/// <reference types="cypress" />
import { BenchmarkAdapterInfo, getAllInfoLines } from '../../src/benchmarkAdapter'
import { cleanVisit } from './shared'
it('Benchmark rendering performance', () => {
cleanVisit('/?openBenchmark=true&renderDistance=5')
// wait for render end event
return cy.document().then({ timeout: 180_000 }, doc => {
return new Cypress.Promise(resolve => {
cy.log('Waiting for world to load')
doc.addEventListener('cypress-world-ready', resolve)
}).then(() => {
cy.log('World loaded')
})
}).then(() => {
cy.window().then(win => {
const adapter = win.benchmarkAdapter as BenchmarkAdapterInfo
const messages = getAllInfoLines(adapter)
// wait for 10 seconds
cy.wait(10_000)
const messages2 = getAllInfoLines(adapter, true)
for (const message of messages) {
cy.log(message)
}
for (const message of messages2) {
cy.log(message)
}
cy.writeFile('benchmark.txt', [...messages, ...messages2].join('\n'))
})
})
})

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,14 +7,12 @@
"dev-proxy": "node server.js",
"start": "run-p dev-proxy dev-rsbuild watch-mesher",
"start2": "run-p dev-rsbuild watch-mesher",
"start-metrics": "ENABLE_METRICS=true rsbuild dev",
"build": "pnpm build-other-workers && rsbuild build",
"build-analyze": "BUNDLE_ANALYZE=true rsbuild build && pnpm build-other-workers",
"build-single-file": "SINGLE_FILE_BUILD=true rsbuild build",
"prepare-project": "tsx scripts/genShims.ts && tsx scripts/makeOptimizedMcData.mjs && tsx scripts/genLargeDataAliases.ts",
"check-build": "pnpm prepare-project && tsc && pnpm build",
"test:cypress": "cypress run",
"test:benchmark": "PERFORMANCE_TEST=true cypress run",
"test:cypress:open": "cypress open",
"test-unit": "vitest",
"test:e2e": "start-test http-get://localhost:8080 test:cypress",
@ -32,9 +30,7 @@
"run-playground": "run-p watch-mesher watch-other-workers watch-playground",
"run-all": "run-p start run-playground",
"build-playground": "rsbuild build --config renderer/rsbuild.config.ts",
"watch-playground": "rsbuild dev --config renderer/rsbuild.config.ts",
"update-git-deps": "tsx scripts/updateGitDeps.ts",
"request-data": "tsx scripts/requestData.ts"
"watch-playground": "rsbuild dev --config renderer/rsbuild.config.ts"
},
"keywords": [
"prismarine",
@ -54,9 +50,8 @@
"dependencies": {
"@dimaka/interface": "0.0.3-alpha.0",
"@floating-ui/react": "^0.26.1",
"@monaco-editor/react": "^4.7.0",
"@nxg-org/mineflayer-auto-jump": "^0.7.18",
"@nxg-org/mineflayer-tracker": "1.3.0",
"@nxg-org/mineflayer-auto-jump": "^0.7.12",
"@nxg-org/mineflayer-tracker": "1.2.1",
"@react-oauth/google": "^0.12.1",
"@stylistic/eslint-plugin": "^2.6.1",
"@types/gapi": "^0.0.47",
@ -80,19 +75,18 @@
"esbuild-plugin-polyfill-node": "^0.3.0",
"express": "^4.18.2",
"filesize": "^10.0.12",
"flying-squid": "npm:@zardoy/flying-squid@^0.0.104",
"framer-motion": "^12.9.2",
"flying-squid": "npm:@zardoy/flying-squid@^0.0.51",
"fs-extra": "^11.1.1",
"google-drive-browserfs": "github:zardoy/browserfs#google-drive",
"jszip": "^3.10.1",
"lodash-es": "^4.17.21",
"mcraft-fun-mineflayer": "^0.1.23",
"minecraft-data": "3.98.0",
"minecraft-data": "3.83.1",
"minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master",
"mineflayer-item-map-downloader": "github:zardoy/mineflayer-item-map-downloader",
"mojangson": "^2.0.4",
"net-browserify": "github:zardoy/prismarinejs-net-browserify",
"node-gzip": "^1.1.2",
"mcraft-fun-mineflayer": "^0.1.10",
"peerjs": "^1.5.0",
"pixelarticons": "^1.8.1",
"pretty-bytes": "^6.1.1",
@ -106,6 +100,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-select": "^5.8.0",
"react-transition-group": "^4.4.5",
"react-zoom-pan-pinch": "3.4.4",
"remark": "^15.0.1",
"sanitize-filename": "^1.6.3",
@ -123,11 +118,11 @@
"workbox-build": "^7.0.0"
},
"devDependencies": {
"@rsbuild/core": "1.3.5",
"@rsbuild/plugin-node-polyfill": "1.3.0",
"@rsbuild/plugin-react": "1.2.0",
"@rsbuild/plugin-type-check": "1.2.1",
"@rsbuild/plugin-typed-css-modules": "1.0.2",
"@rsbuild/core": "^1.0.1-beta.9",
"@rsbuild/plugin-node-polyfill": "^1.0.3",
"@rsbuild/plugin-react": "^1.0.1-beta.9",
"@rsbuild/plugin-type-check": "^1.0.1-beta.9",
"@rsbuild/plugin-typed-css-modules": "^1.0.1",
"@storybook/addon-essentials": "^7.4.6",
"@storybook/addon-links": "^7.4.6",
"@storybook/blocks": "^7.4.6",
@ -135,6 +130,7 @@
"@storybook/react-vite": "^7.4.6",
"@types/diff-match-patch": "^1.0.36",
"@types/lodash-es": "^4.17.9",
"@types/react-transition-group": "^4.4.7",
"@types/stats.js": "^0.17.1",
"@types/three": "0.154.0",
"@types/ua-parser-js": "^0.7.39",
@ -144,7 +140,7 @@
"browserify-zlib": "^0.2.0",
"buffer": "^6.0.3",
"constants-browserify": "^1.0.0",
"contro-max": "^0.1.9",
"contro-max": "^0.1.8",
"crypto-browserify": "^3.12.0",
"cypress-esbuild-preprocessor": "^1.0.2",
"eslint": "^8.50.0",
@ -154,16 +150,17 @@
"http-browserify": "^1.7.0",
"http-server": "^14.1.1",
"https-browserify": "^1.0.0",
"mc-assets": "^0.2.62",
"mc-assets": "^0.2.42",
"mineflayer-mouse": "^0.0.9",
"minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next",
"mineflayer": "github:zardoy/mineflayer#gen-the-master",
"mineflayer-mouse": "^0.1.21",
"mineflayer": "github:zardoy/mineflayer",
"mineflayer-pathfinder": "^2.4.4",
"npm-run-all": "^4.1.5",
"os-browserify": "^0.3.0",
"path-browserify": "^1.0.1",
"path-exists-cli": "^2.0.0",
"process": "github:PrismarineJS/node-process",
"renderer": "link:renderer",
"process": "github:PrismarineJS/node-process",
"rimraf": "^5.0.1",
"storybook": "^7.4.6",
"stream-browserify": "^3.0.0",
@ -197,15 +194,14 @@
},
"pnpm": {
"overrides": {
"mineflayer": "github:zardoy/mineflayer#gen-the-master",
"@nxg-org/mineflayer-physics-util": "1.8.10",
"buffer": "^6.0.3",
"vec3": "0.1.10",
"@nxg-org/mineflayer-physics-util": "1.5.8",
"three": "0.154.0",
"diamond-square": "github:zardoy/diamond-square",
"prismarine-block": "github:zardoy/prismarine-block#next-era",
"prismarine-world": "github:zardoy/prismarine-world#next-era",
"minecraft-data": "3.98.0",
"minecraft-data": "3.83.1",
"prismarine-provider-anvil": "github:zardoy/prismarine-provider-anvil#everything",
"prismarine-physics": "github:zardoy/prismarine-physics",
"minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master",
@ -214,29 +210,14 @@
"prismarine-item": "latest"
},
"updateConfig": {
"ignoreDependencies": [
"browserfs",
"google-drive-browserfs"
]
"ignoreDependencies": []
},
"patchedDependencies": {
"three@0.154.0": "patches/three@0.154.0.patch",
"pixelarticons@1.8.1": "patches/pixelarticons@1.8.1.patch",
"mineflayer-item-map-downloader@1.2.0": "patches/mineflayer-item-map-downloader@1.2.0.patch",
"minecraft-protocol": "patches/minecraft-protocol.patch"
},
"ignoredBuiltDependencies": [
"canvas",
"core-js",
"gl"
],
"onlyBuiltDependencies": [
"sharp",
"cypress",
"esbuild",
"fsevents"
],
"ignorePatchFailures": false,
"allowUnusedPatches": false
"minecraft-protocol@1.54.0": "patches/minecraft-protocol@1.54.0.patch"
}
},
"packageManager": "pnpm@10.8.0+sha512.0e82714d1b5b43c74610193cb20734897c1d00de89d0e18420aebc5977fa13d780a9cb05734624e81ebd81cc876cd464794850641c48b9544326b5622ca29971"
"packageManager": "pnpm@9.0.4"
}

View file

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

View file

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

View file

@ -0,0 +1,16 @@
diff --git a/examples/jsm/webxr/VRButton.js b/examples/jsm/webxr/VRButton.js
index 6856a21b17aa45d7922bbf776fd2d7e63c7a9b4e..0925b706f7629bd52f0bb5af469536af8f5fce2c 100644
--- a/examples/jsm/webxr/VRButton.js
+++ b/examples/jsm/webxr/VRButton.js
@@ -62,7 +62,10 @@ class VRButton {
// ('local' is always available for immersive sessions and doesn't need to
// be requested separately.)
- const sessionInit = { optionalFeatures: [ 'local-floor', 'bounded-floor', 'hand-tracking', 'layers' ] };
+ const sessionInit = {
+ optionalFeatures: ['local-floor', 'bounded-floor', 'layers'],
+ domOverlay: { root: document.body },
+ };
navigator.xr.requestSession( 'immersive-vr', sessionInit ).then( onSessionStarted );
} else {

11203
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -22,7 +22,7 @@ const buildOptions = {
},
platform: 'browser',
entryPoints: [path.join(__dirname, './viewer/lib/mesher/mesher.ts')],
minify: !watch,
minify: true,
logLevel: 'info',
drop: !watch ? [
'debugger'
@ -35,15 +35,11 @@ const buildOptions = {
define: {
'process.env.BROWSER': '"true"',
},
loader: {
'.png': 'dataurl',
'.obj': 'text'
},
plugins: [
...mesherSharedPlugins,
{
name: 'external-json',
setup(build) {
setup (build) {
build.onResolve({ filter: /\.json$/ }, args => {
const fileName = args.path.split('/').pop().replace('.json', '')
if (args.resolveDir.includes('minecraft-data')) {

View file

@ -11,17 +11,11 @@
html, body {
height: 100%;
touch-action: none;
margin: 0;
padding: 0;
}
* {
user-select: none;
-webkit-user-select: none;
}
canvas {
height: 100%;
width: 100%;

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,4 +1,3 @@
//@ts-nocheck
import { Vec3 } from 'vec3'
import * as THREE from 'three'
import '../../src/getCollisionShapes'

View file

@ -1,12 +1,11 @@
if (!new URL(location.href).searchParams.get('playground')) location.href = '/?playground=true'
// import { BasePlaygroundScene } from './baseScene'
// import { playgroundGlobalUiState } from './playgroundUi'
// import * as scenes from './scenes'
import { BasePlaygroundScene } from './baseScene'
import { playgroundGlobalUiState } from './playgroundUi'
import * as scenes from './scenes'
// const qsScene = new URLSearchParams(window.location.search).get('scene')
// const Scene: typeof BasePlaygroundScene = qsScene ? scenes[qsScene] : scenes.main
// playgroundGlobalUiState.scenes = ['main', 'railsCobweb', 'floorRandom', 'lightingStarfield', 'transparencyIssue', 'entities', 'frequentUpdates', 'slabsOptimization', 'allEntities']
// playgroundGlobalUiState.selected = qsScene ?? 'main'
const qsScene = new URLSearchParams(window.location.search).get('scene')
const Scene: typeof BasePlaygroundScene = qsScene ? scenes[qsScene] : scenes.main
playgroundGlobalUiState.scenes = ['main', 'railsCobweb', 'floorRandom', 'lightingStarfield', 'transparencyIssue', 'entities', 'frequentUpdates', 'slabsOptimization', 'allEntities']
playgroundGlobalUiState.selected = qsScene ?? 'main'
// const scene = new Scene()
// globalThis.scene = scene
const scene = new Scene()
globalThis.scene = scene

View file

@ -1,6 +1,5 @@
import { BasePlaygroundScene } from '../baseScene'
import { EntityDebugFlags, EntityMesh, rendererSpecialHandled } from '../../viewer/three/entity/EntityMesh'
import { displayEntitiesDebugList } from '../allEntitiesDebug'
import { EntityDebugFlags, EntityMesh, rendererSpecialHandled } from '../../viewer/lib/entity/EntityMesh'
export default class AllEntities extends BasePlaygroundScene {
continuousRender = false
@ -8,6 +7,159 @@ export default class AllEntities extends BasePlaygroundScene {
async 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

@ -1,8 +1,7 @@
//@ts-nocheck
import * as THREE from 'three'
import { Vec3 } from 'vec3'
import { BasePlaygroundScene } from '../baseScene'
import { WorldRendererThree } from '../../viewer/three/worldrendererThree'
import { WorldRendererThree } from '../../viewer/lib/worldrendererThree'
export default class extends BasePlaygroundScene {
continuousRender = true

View file

@ -1,4 +1,3 @@
//@ts-nocheck
import { Vec3 } from 'vec3'
import { BasePlaygroundScene } from '../baseScene'

View file

@ -1,8 +1,7 @@
//@ts-nocheck
import * as THREE from 'three'
import { Vec3 } from 'vec3'
import { BasePlaygroundScene } from '../baseScene'
import { WorldRendererThree } from '../../viewer/three/worldrendererThree'
import { WorldRendererThree } from '../../viewer/lib/worldrendererThree'
export default class extends BasePlaygroundScene {
continuousRender = true

View file

@ -1,11 +1,10 @@
//@ts-nocheck
// eslint-disable-next-line import/no-named-as-default
import GUI, { Controller } from 'lil-gui'
import * as THREE from 'three'
import JSZip from 'jszip'
import { BasePlaygroundScene } from '../baseScene'
import { TWEEN_DURATION } from '../../viewer/three/entities'
import { EntityMesh } from '../../viewer/three/entity/EntityMesh'
import { TWEEN_DURATION } from '../../viewer/lib/entities'
import { EntityMesh } from '../../viewer/lib/entity/EntityMesh'
class MainScene extends BasePlaygroundScene {
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
@ -174,6 +173,7 @@ class MainScene extends BasePlaygroundScene {
canvas.height = size
renderer.setSize(size, size)
//@ts-expect-error
viewer.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 10)
viewer.scene.background = null

View file

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

View file

@ -2,7 +2,6 @@ import { defineConfig, ModifyRspackConfigUtils } from '@rsbuild/core';
import { pluginNodePolyfill } from '@rsbuild/plugin-node-polyfill';
import { pluginReact } from '@rsbuild/plugin-react';
import path from 'path'
import fs from 'fs'
export const appAndRendererSharedConfig = () => defineConfig({
dev: {
@ -61,24 +60,18 @@ export const appAndRendererSharedConfig = () => defineConfig({
],
tools: {
rspack (config, helpers) {
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8'))
const hasFileProtocol = Object.values(packageJson.pnpm.overrides).some((dep) => (dep as string).startsWith('file:'))
if (hasFileProtocol) {
// enable node_modules watching
config.watchOptions.ignored = /\.git/
}
rspackViewerConfig(config, helpers)
}
},
})
export const rspackViewerConfig = (config, { appendPlugins, addRules, rspack }: ModifyRspackConfigUtils) => {
appendPlugins(new rspack.NormalModuleReplacementPlugin(/data|prismarine-physics/, (resource) => {
appendPlugins(new rspack.NormalModuleReplacementPlugin(/data/, (resource) => {
let absolute: string
const request = resource.request.replaceAll('\\', '/')
absolute = path.join(resource.context, request).replaceAll('\\', '/')
if (request.includes('minecraft-data/data/pc/1.') || request.includes('prismarine-physics')) {
console.log('Error: incompatible resource', request, 'from', resource.contextInfo.issuer)
if (request.includes('minecraft-data/data/pc/1.')) {
console.log('Error: incompatible resource', request, resource.contextInfo.issuer)
process.exit(1)
// throw new Error(`${resource.request} was requested by ${resource.contextInfo.issuer}`)
}
@ -108,10 +101,6 @@ export const rspackViewerConfig = (config, { appendPlugins, addRules, rspack }:
{
test: /\.txt$/,
type: 'asset/source',
},
{
test: /\.log$/,
type: 'asset/source',
}
])
config.ignoreWarnings = [

View file

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

6
renderer/viewer/index.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
Viewer: require('./lib/viewer').Viewer,
WorldDataEmitter: require('./lib/worldDataEmitter').WorldDataEmitter,
Entity: require('./lib/entity/EntityMesh'),
getBufferFromStream: require('./lib/simpleUtils').getBufferFromStream
}

View file

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

View file

@ -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

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

View file

@ -0,0 +1,103 @@
import { PlayerAnimation } from 'skinview3d'
export class WalkingGeneralSwing extends PlayerAnimation {
switchAnimationCallback
isRunning = false
isMoving = true
_startArmSwing
swingArm() {
this._startArmSwing = this.progress
}
animate(player) {
// Multiply by animation's natural speed
let t
const updateT = () => {
if (!this.isMoving) {
t = 0
return
}
if (this.isRunning) {
t = this.progress * 10 + Math.PI * 0.5
} else {
t = this.progress * 8
}
}
updateT()
let reset = false
if ((this.isRunning ? Math.cos(t) : Math.sin(t)) < 0.01) {
if (this.switchAnimationCallback) {
reset = true
this.progress = 0
updateT()
}
}
if (this.isRunning) {
// Leg swing with larger amplitude
player.skin.leftLeg.rotation.x = Math.cos(t + Math.PI) * 1.3
player.skin.rightLeg.rotation.x = Math.cos(t) * 1.3
} else {
// Leg swing
player.skin.leftLeg.rotation.x = Math.sin(t) * 0.5
player.skin.rightLeg.rotation.x = Math.sin(t + Math.PI) * 0.5
}
if (this._startArmSwing) {
const tHand = (this.progress - this._startArmSwing) * 18 + Math.PI * 0.5
player.skin.rightArm.rotation.x = Math.cos(tHand) * 1.5
const basicArmRotationZ = Math.PI * 0.1
player.skin.rightArm.rotation.z = Math.cos(t + Math.PI) * 0.3 - basicArmRotationZ
if (tHand > Math.PI + Math.PI * 0.5) {
this._startArmSwing = null
player.skin.rightArm.rotation.z = 0
}
}
if (this.isRunning) {
player.skin.leftArm.rotation.x = Math.cos(t) * 1.5
if (!this._startArmSwing) {
player.skin.rightArm.rotation.x = Math.cos(t + Math.PI) * 1.5
}
const basicArmRotationZ = Math.PI * 0.1
player.skin.leftArm.rotation.z = Math.cos(t) * 0.1 + basicArmRotationZ
if (!this._startArmSwing) {
player.skin.rightArm.rotation.z = Math.cos(t + Math.PI) * 0.1 - basicArmRotationZ
}
} else {
// Arm swing
player.skin.leftArm.rotation.x = Math.sin(t + Math.PI) * 0.5
if (!this._startArmSwing) {
player.skin.rightArm.rotation.x = Math.sin(t) * 0.5
}
const basicArmRotationZ = Math.PI * 0.02
player.skin.leftArm.rotation.z = Math.cos(t) * 0.03 + basicArmRotationZ
if (!this._startArmSwing) {
player.skin.rightArm.rotation.z = Math.cos(t + Math.PI) * 0.03 - basicArmRotationZ
}
}
if (this.isRunning) {
player.rotation.z = Math.cos(t + Math.PI) * 0.01
}
if (this.isRunning) {
const basicCapeRotationX = Math.PI * 0.3
player.cape.rotation.x = Math.sin(t * 2) * 0.1 + basicCapeRotationX
} else {
// Always add an angle for cape around the x axis
const basicCapeRotationX = Math.PI * 0.06
player.cape.rotation.x = Math.sin(t / 1.5) * 0.06 + basicCapeRotationX
}
if (reset) {
this.switchAnimationCallback()
this.switchAnimationCallback = null
}
}
}

View file

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

View file

@ -2,22 +2,27 @@
import { ItemRenderer, Identifier, ItemStack, NbtString, Structure, StructureRenderer, ItemRendererResources, BlockDefinition, BlockModel, TextureAtlas, Resources, ItemModel } from 'deepslate'
import { mat4, vec3 } from 'gl-matrix'
import { AssetsParser } from 'mc-assets/dist/assetsParser'
import { getLoadedImage, versionToNumber } from 'mc-assets/dist/utils'
import { getLoadedImage } from 'mc-assets/dist/utils'
import { BlockModel as BlockModelMcAssets, AtlasParser } from 'mc-assets'
import { getLoadedBlockstatesStore, getLoadedModelsStore } from 'mc-assets/dist/stores'
import { makeTextureAtlas } from 'mc-assets/dist/atlasCreator'
import { proxy, ref } from 'valtio'
import { getItemDefinition } from 'mc-assets/dist/itemDefinitions'
import { versionToNumber } from '../prepare/utils'
export const activeGuiAtlas = proxy({
atlas: null as null | { json, image },
})
export const getNonFullBlocksModels = () => {
let version = appViewer.resourcesManager.currentResources!.version ?? 'latest'
let version = viewer.world.texturesVersion ?? 'latest'
if (versionToNumber(version) < versionToNumber('1.13')) version = '1.13'
const itemsDefinitions = appViewer.resourcesManager.itemsDefinitionsStore.data.latest
const itemsDefinitions = viewer.world.itemsDefinitionsStore.data.latest
const blockModelsResolved = {} as Record<string, any>
const itemsModelsResolved = {} as Record<string, any>
const fullBlocksWithNonStandardDisplay = [] as string[]
const handledItemsWithDefinitions = new Set()
const assetsParser = new AssetsParser(version, getLoadedBlockstatesStore(appViewer.resourcesManager.currentResources!.blockstatesModels), getLoadedModelsStore(appViewer.resourcesManager.currentResources!.blockstatesModels))
const assetsParser = new AssetsParser(version, getLoadedBlockstatesStore(viewer.world.blockstatesModels), getLoadedModelsStore(viewer.world.blockstatesModels))
const standardGuiDisplay = {
'rotation': [
@ -43,15 +48,13 @@ export const getNonFullBlocksModels = () => {
if (!model?.elements?.length) return
const isFullBlock = model.elements.length === 1 && arrEqual(model.elements[0].from, [0, 0, 0]) && arrEqual(model.elements[0].to, [16, 16, 16])
if (isFullBlock) return
const hasBetterPrerender = assetsParser.blockModelsStore.data.latest[`item/${name}`]?.textures?.['layer0']?.startsWith('invsprite_')
if (hasBetterPrerender) return
model['display'] ??= {}
model['display']['gui'] ??= standardGuiDisplay
blockModelsResolved[name] = model
}
for (const [name, definition] of Object.entries(itemsDefinitions)) {
const item = getItemDefinition(appViewer.resourcesManager.itemsDefinitionsStore, {
const item = getItemDefinition(viewer.world.itemsDefinitionsStore, {
version,
name,
properties: {
@ -64,6 +67,7 @@ export const getNonFullBlocksModels = () => {
handledItemsWithDefinitions.add(name)
}
if (resolvedModel?.elements) {
let hasStandardDisplay = true
if (resolvedModel['display']?.gui) {
hasStandardDisplay =
@ -93,7 +97,7 @@ export const getNonFullBlocksModels = () => {
}
}
for (const [name, blockstate] of Object.entries(appViewer.resourcesManager.currentResources!.blockstatesModels.blockstates.latest)) {
for (const [name, blockstate] of Object.entries(viewer.world.blockstatesModels.blockstates.latest)) {
if (handledItemsWithDefinitions.has(name)) {
continue
}
@ -116,19 +120,18 @@ export const getNonFullBlocksModels = () => {
const RENDER_SIZE = 64
const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isItems = false) => {
const { currentResources } = appViewer.resourcesManager
const imgBitmap = isItems ? currentResources!.itemsAtlasImage : currentResources!.blocksAtlasImage
const img = await getLoadedImage(isItems ? viewer.world.itemsAtlasParser!.latestImage : viewer.world.blocksAtlasParser!.latestImage)
const canvasTemp = document.createElement('canvas')
canvasTemp.width = imgBitmap.width
canvasTemp.height = imgBitmap.height
canvasTemp.width = img.width
canvasTemp.height = img.height
canvasTemp.style.imageRendering = 'pixelated'
const ctx = canvasTemp.getContext('2d')!
ctx.imageSmoothingEnabled = false
ctx.drawImage(imgBitmap, 0, 0)
ctx.drawImage(img, 0, 0)
const atlasParser = isItems ? appViewer.resourcesManager.itemsAtlasParser : appViewer.resourcesManager.blocksAtlasParser
const atlasParser = isItems ? viewer.world.itemsAtlasParser! : viewer.world.blocksAtlasParser!
const textureAtlas = new TextureAtlas(
ctx.getImageData(0, 0, imgBitmap.width, imgBitmap.height),
ctx.getImageData(0, 0, img.width, img.height),
Object.fromEntries(Object.entries(atlasParser.atlas.latest.textures).map(([key, value]) => {
return [key, [
value.u,
@ -142,7 +145,6 @@ const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isIt
const PREVIEW_ID = Identifier.parse('preview:preview')
const PREVIEW_DEFINITION = new BlockDefinition({ '': { model: PREVIEW_ID.toString() } }, undefined)
let textureWasRequested = false
let modelData: any
let currentModelName: string | undefined
const resources: ItemRendererResources = {
@ -153,7 +155,6 @@ const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isIt
return null
},
getTextureUV (texture) {
textureWasRequested = true
return textureAtlas.getTextureUV(texture.toString().replace('minecraft:', '').replace('block/', '').replace('item/', '').replace('blocks/', '').replace('items/', '') as any)
},
getTextureAtlas () {
@ -202,7 +203,6 @@ const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isIt
const renderer = new ItemRenderer(gl, item, resources, { display_context: 'gui' })
const missingTextures = new Set()
for (const [modelName, model] of Object.entries(models)) {
textureWasRequested = false
if (includeOnly.length && !includeOnly.includes(modelName)) continue
const patchMissingTextures = () => {
@ -224,7 +224,6 @@ const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isIt
if (!modelData) continue
renderer.setItem(item, { display_context: 'gui' })
renderer.drawItem()
if (!textureWasRequested) continue
const url = canvas.toDataURL()
// eslint-disable-next-line no-await-in-loop
const img = await getLoadedImage(url)
@ -238,9 +237,6 @@ const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isIt
return images
}
/**
* @mainThread
*/
const generateAtlas = async (images: Record<string, HTMLImageElement>) => {
const atlas = makeTextureAtlas({
input: Object.keys(images),
@ -258,9 +254,9 @@ const generateAtlas = async (images: Record<string, HTMLImageElement>) => {
// a.download = 'blocks_atlas.png'
// a.click()
appViewer.resourcesManager.currentResources!.guiAtlas = {
activeGuiAtlas.atlas = {
json: atlas.json,
image: await createImageBitmap(atlas.canvas),
image: ref(await getLoadedImage(atlas.canvas.toDataURL())),
}
return atlas
@ -277,6 +273,5 @@ export const generateGuiAtlas = async () => {
const itemImages = await generateItemsGui(itemsModelsResolved, true)
console.timeEnd('generate items gui atlas')
await generateAtlas({ ...blockImages, ...itemImages })
appViewer.resourcesManager.currentResources!.guiAtlasVersion++
// await generateAtlas(blockImages)
}

View file

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

View file

@ -1,17 +1,15 @@
import * as THREE from 'three'
import * as tweenJs from '@tweenjs/tween.js'
import PrismarineItem from 'prismarine-item'
import worldBlockProvider, { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider'
import worldBlockProvider from 'mc-assets/dist/worldBlockProvider'
import { BlockModel } from 'mc-assets'
import { getThreeBlockModelGroup, renderBlockThree, setBlockPosition } from '../lib/mesher/standaloneRenderer'
import { MovementState, PlayerStateRenderer } from '../lib/basePlayerState'
import { DebugGui } from '../lib/DebugGui'
import { SmoothSwitcher } from '../lib/smoothSwitcher'
import { watchProperty } from '../lib/utils/proxy'
import { WorldRendererConfig } from '../lib/worldrendererCommon'
import { getThreeBlockModelGroup, renderBlockThree, setBlockPosition } from './mesher/standaloneRenderer'
import { getMyHand } from './hand'
import { WorldRendererThree } from './worldrendererThree'
import { IPlayerState, MovementState } from './basePlayerState'
import { DebugGui } from './DebugGui'
import { SmoothSwitcher } from './smoothSwitcher'
import { watchProperty } from './utils/proxy'
import { disposeObject } from './threeJsUtils'
import { WorldRendererConfig } from './worldrendererCommon'
export type HandItemBlock = {
name?
@ -116,61 +114,41 @@ export default class HoldingBlock {
offHandModeLegacy = false
swingAnimator: HandSwingAnimator | undefined
config: WorldRendererConfig
constructor (public worldRenderer: WorldRendererThree, public offHand = false) {
constructor (public playerState: IPlayerState, public config: WorldRendererConfig, public offHand = false) {
this.initCameraGroup()
this.worldRenderer.onReactivePlayerStateUpdated('heldItemMain', () => {
if (!this.offHand) {
this.updateItem()
}
}, false)
this.worldRenderer.onReactivePlayerStateUpdated('heldItemOff', () => {
if (this.offHand) {
this.updateItem()
}
}, false)
this.config = worldRenderer.displayOptions.inWorldRenderingConfig
this.playerState.events.on('heldItemChanged', (_, isOffHand) => {
if (this.offHand !== isOffHand) return
this.updateItem()
})
this.offHandDisplay = this.offHand
// this.offHandDisplay = true
if (!this.offHand) {
// load default hand
void getMyHand().then((hand) => {
this.playerHand = hand
// trigger update
this.updateItem()
}).then(() => {
// now watch over the player skin
watchProperty(
async () => {
return getMyHand(this.worldRenderer.playerStateReactive.playerSkin, this.worldRenderer.playerStateReactive.onlineMode ? this.worldRenderer.playerStateReactive.username : undefined)
},
this.worldRenderer.playerStateReactive,
'playerSkin',
(newHand) => {
if (newHand) {
this.playerHand = newHand
// trigger update
this.updateItem()
}
},
(oldHand) => {
disposeObject(oldHand!, true)
}
)
})
// watch over my hand
watchProperty(
async () => {
return getMyHand(this.playerState.reactive.playerSkin, this.playerState.onlineMode ? this.playerState.username : undefined)
},
this.playerState.reactive,
'playerSkin',
(newHand) => {
this.playerHand = newHand
},
(oldHand) => {
disposeObject(oldHand, true)
}
)
}
}
updateItem () {
if (!this.ready) return
const item = this.offHand ? this.worldRenderer.playerStateReactive.heldItemOff : this.worldRenderer.playerStateReactive.heldItemMain
if (!this.ready || !this.playerState.getHeldItem) return
const item = this.playerState.getHeldItem(this.offHand)
if (item) {
void this.setNewItem(item)
} else if (this.offHand) {
void this.setNewItem()
} else {
} else if (!this.offHand) {
void this.setNewItem({
type: 'hand',
})
@ -302,7 +280,6 @@ export default class HoldingBlock {
}
isDifferentItem (block: HandItemBlock | undefined) {
const Item = PrismarineItem(this.worldRenderer.version)
if (!this.lastHeldItem) {
return true
}
@ -310,7 +287,7 @@ export default class HoldingBlock {
return true
}
// 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
}
@ -350,14 +327,14 @@ export default class HoldingBlock {
let blockInner: THREE.Object3D | undefined
if (handItem.type === 'item' || handItem.type === 'block') {
const result = this.worldRenderer.entities.getItemMesh({
const result = viewer.entities.getItemMesh({
...handItem.fullItem,
itemId: handItem.id,
}, {
'minecraft:display_context': 'firstperson',
'minecraft:use_duration': this.worldRenderer.playerStateReactive.itemUsageTicks,
'minecraft:using_item': !!this.worldRenderer.playerStateReactive.itemUsageTicks,
}, false, this.lastItemModelName)
'minecraft:use_duration': this.playerState.getItemUsageTicks?.(),
'minecraft:using_item': !!this.playerState.getItemUsageTicks?.(),
}, this.lastItemModelName)
if (result) {
const { mesh: itemMesh, isBlock, modelName } = result
if (isBlock) {
@ -473,7 +450,7 @@ export default class HoldingBlock {
this.swingAnimator = new HandSwingAnimator(this.holdingBlockInnerGroup)
this.swingAnimator.type = result.type
if (this.config.viewBobbing) {
this.idleAnimator = new HandIdleAnimator(this.holdingBlockInnerGroup, this.worldRenderer.playerStateReactive)
this.idleAnimator = new HandIdleAnimator(this.holdingBlockInnerGroup, this.playerState)
}
}
@ -554,7 +531,7 @@ class HandIdleAnimator {
private readonly debugGui: DebugGui
constructor (public handMesh: THREE.Object3D, public playerState: PlayerStateRenderer) {
constructor (public handMesh: THREE.Object3D, public playerState: IPlayerState) {
this.handMesh = handMesh
this.globalTime = 0
this.currentState = 'NOT_MOVING'
@ -708,7 +685,7 @@ class HandIdleAnimator {
// Check for state changes from player state
if (this.playerState) {
const newState = this.playerState.movementState
const newState = this.playerState.getMovementState()
if (newState !== this.targetState) {
this.setState(newState)
}
@ -924,10 +901,11 @@ class HandSwingAnimator {
}
}
export const getBlockMeshFromModel = (material: THREE.Material, model: BlockModel, name: string, blockProvider: WorldBlockProvider) => {
export const getBlockMeshFromModel = (material: THREE.Material, model: BlockModel, name: string) => {
const blockProvider = worldBlockProvider(viewer.world.blockstatesModels, viewer.world.blocksAtlasParser!.atlas, 'latest')
const worldRenderModel = blockProvider.transformModel(model, {
name,
properties: {}
}) as any
})
return getThreeBlockModelGroup(material, [[worldRenderModel]], undefined, 'plains', loadedData)
}

View file

@ -2,7 +2,6 @@ import { Vec3 } from 'vec3'
import { World } from './world'
import { getSectionGeometry, setBlockStatesData as setMesherData } from './models'
import { BlockStateModelInfo } from './shared'
import { INVISIBLE_BLOCKS } from './worldConstants'
globalThis.structuredClone ??= (value) => JSON.parse(JSON.stringify(value))
@ -54,7 +53,7 @@ function setSectionDirty (pos, value = true) {
const key = sectionKey(x, y, z)
if (!value) {
dirtySections.delete(key)
postMessage({ type: 'sectionFinished', key, workerIndex })
postMessage({ type: 'sectionFinished', key })
return
}
@ -62,7 +61,7 @@ function setSectionDirty (pos, value = true) {
if (chunk?.getSection(pos)) {
dirtySections.set(key, (dirtySections.get(key) || 0) + 1)
} else {
postMessage({ type: 'sectionFinished', key, workerIndex })
postMessage({ type: 'sectionFinished', key })
}
}
@ -77,7 +76,6 @@ const handleMessage = data => {
if (data.type === 'mcData') {
globalVar.mcData = data.mcData
globalVar.loadedData = data.mcData
}
if (data.config) {
@ -123,13 +121,11 @@ const handleMessage = data => {
}
case 'blockUpdate': {
const loc = new Vec3(data.pos.x, data.pos.y, data.pos.z).floored()
if (data.stateId !== undefined && data.stateId !== null) {
world?.setBlockStateId(loc, data.stateId)
}
world.setBlockStateId(loc, data.stateId)
const chunkKey = `${Math.floor(loc.x / 16) * 16},${Math.floor(loc.z / 16) * 16}`
if (data.customBlockModels) {
world?.customBlockModels.set(chunkKey, data.customBlockModels)
world.customBlockModels.set(chunkKey, data.customBlockModels)
}
break
}
@ -139,40 +135,8 @@ const handleMessage = data => {
dirtySections = new Map()
// todo also remove cached
globalVar.mcData = null
globalVar.loadedData = null
allDataReady = false
break
}
case 'getCustomBlockModel': {
const pos = new Vec3(data.pos.x, data.pos.y, data.pos.z)
const chunkKey = `${Math.floor(pos.x / 16) * 16},${Math.floor(pos.z / 16) * 16}`
const customBlockModel = world.customBlockModels.get(chunkKey)?.[`${pos.x},${pos.y},${pos.z}`]
global.postMessage({ type: 'customBlockModel', chunkKey, customBlockModel })
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

View file

@ -2,7 +2,7 @@ import { Vec3 } from 'vec3'
import worldBlockProvider, { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider'
import legacyJson from '../../../../src/preflatMap.json'
import { BlockType } from '../../../playground/shared'
import { World, BlockModelPartsResolved, WorldBlock as Block, WorldBlock } from './world'
import { World, BlockModelPartsResolved, WorldBlock as Block } from './world'
import { BlockElement, buildRotationMatrix, elemFaces, matmul3, matmulmat3, vecadd3, vecsub3 } from './modelsGeometryCommon'
import { INVISIBLE_BLOCKS } from './worldConstants'
import { MesherGeometryOutput, HighestBlockInfo } from './shared'
@ -103,8 +103,7 @@ function tintToGl (tint) {
return [r / 255, g / 255, b / 255]
}
function getLiquidRenderHeight (world: World, block: WorldBlock | null, type: number, pos: Vec3, isWater: boolean, isRealWater: boolean) {
if ((isWater && !isRealWater) || (block && isBlockWaterlogged(block))) return 8 / 9
function getLiquidRenderHeight (world, block, type, pos) {
if (!block || block.type !== type) return 1 / 9
if (block.metadata === 0) { // source block
const blockAbove = world.getBlock(pos.offset(0, 1, 0))
@ -125,19 +124,12 @@ const isCube = (block: Block) => {
}))
}
const getVec = (v: Vec3, dir: Vec3) => {
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) {
function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, type: number, biome: string, water: boolean, attr: Record<string, any>) {
const heights: number[] = []
for (let z = -1; z <= 1; z++) {
for (let x = -1; x <= 1; x++) {
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))
}
}
const cornerHeights = [
@ -149,14 +141,15 @@ function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, typ
// eslint-disable-next-line guard-for-in
for (const face in elemFaces) {
const { dir, corners, mask1, mask2 } = elemFaces[face]
const { dir, corners } = elemFaces[face]
const isUp = dir[1] === 1
const neighborPos = cursor.offset(...dir as [number, number, number])
const neighbor = world.getBlock(neighborPos)
if (!neighbor) continue
if (neighbor.type === type || (water && (neighbor.name === 'water' || isBlockWaterlogged(neighbor)))) continue
if (isCube(neighbor) && !isUp) continue
if (neighbor.type === type) continue
const isGlass = neighbor.name.includes('glass')
if ((isCube(neighbor) && !isUp) || neighbor.material === 'plant' || neighbor.getProperties().waterlogged) continue
let tint = [1, 1, 1]
if (water) {
@ -187,44 +180,16 @@ function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, typ
const { su } = 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) {
const height = cornerHeights[pos[2] * 2 + pos[0]]
const OFFSET = 0.0001
attr.t_positions!.push(
(pos[0] ? 1 - OFFSET : OFFSET) + (cursor.x & 15) - 8,
(pos[1] ? height - OFFSET : OFFSET) + (cursor.y & 15) - 8,
(pos[2] ? 1 - OFFSET : OFFSET) + (cursor.z & 15) - 8
attr.t_positions.push(
(pos[0] ? 0.999 : 0.001) + (cursor.x & 15) - 8,
(pos[1] ? height - 0.001 : 0.001) + (cursor.y & 15) - 8,
(pos[2] ? 0.999 : 0.001) + (cursor.z & 15) - 8
)
attr.t_normals!.push(...dir)
attr.t_uvs!.push(pos[3] * su + u, pos[4] * sv * (pos[1] ? 1 : height) + v)
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)
attr.t_normals.push(...dir)
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])
}
}
}
@ -288,7 +253,7 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO:
if (!neighbor.transparent && (isCube(neighbor) || identicalCull(element, neighbor, new Vec3(...dir)))) continue
} else {
needSectionRecomputeOnChange = true
// continue
continue
}
}
@ -336,7 +301,7 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO:
let localShift = null as any
if (element.rotation && !needTiles) {
// Rescale support for block model rotations
// todo do we support rescale?
localMatrix = buildRotationMatrix(
element.rotation.axis,
element.rotation.angle
@ -349,37 +314,6 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO:
element.rotation.origin
)
)
// Apply rescale if specified
if (element.rotation.rescale) {
const FIT_TO_BLOCK_SCALE_MULTIPLIER = 2 - Math.sqrt(2)
const angleRad = element.rotation.angle * Math.PI / 180
const scale = Math.abs(Math.sin(angleRad)) * FIT_TO_BLOCK_SCALE_MULTIPLIER
// Get axis vector components (1 for the rotation axis, 0 for others)
const axisX = element.rotation.axis === 'x' ? 1 : 0
const axisY = element.rotation.axis === 'y' ? 1 : 0
const axisZ = element.rotation.axis === 'z' ? 1 : 0
// Create scale matrix: scale = (1 - axisComponent) * scaleFactor + 1
const scaleMatrix = [
[(1 - axisX) * scale + 1, 0, 0],
[0, (1 - axisY) * scale + 1, 0],
[0, 0, (1 - axisZ) * scale + 1]
]
// Apply scaling to the transformation matrix
localMatrix = matmulmat3(localMatrix, scaleMatrix)
// Recalculate shift with the new matrix
localShift = vecsub3(
element.rotation.origin,
matmul3(
localMatrix,
element.rotation.origin
)
)
}
}
const aos: number[] = []
@ -489,19 +423,13 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO:
if (!needTiles) {
if (doAO && aos[0] + aos[3] >= aos[1] + aos[2]) {
attr.indices[attr.indicesCount++] = ndx
attr.indices[attr.indicesCount++] = 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
attr.indices.push(
ndx, ndx + 3, ndx + 2, ndx, ndx + 1, ndx + 3
)
} else {
attr.indices[attr.indicesCount++] = ndx
attr.indices[attr.indicesCount++] = ndx + 1
attr.indices[attr.indicesCount++] = ndx + 2
attr.indices[attr.indicesCount++] = ndx + 2
attr.indices[attr.indicesCount++] = ndx + 1
attr.indices[attr.indicesCount++] = ndx + 3
attr.indices.push(
ndx, ndx + 1, ndx + 2, ndx + 2, ndx + 1, ndx + 3
)
}
}
}
@ -519,7 +447,7 @@ const isBlockWaterlogged = (block: Block) => {
}
let unknownBlockModel: BlockModelPartsResolved
export function getSectionGeometry (sx: number, sy: number, sz: number, world: World) {
export function getSectionGeometry (sx, sy, sz, world: World) {
let delayedRender = [] as Array<() => void>
const attr: MesherGeometryOutput = {
@ -535,13 +463,12 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W
t_colors: [],
t_uvs: [],
indices: [],
indicesCount: 0, // Track current index position
using32Array: true,
tiles: {},
// todo this can be removed here
heads: {},
signs: {},
// isFull: true,
highestBlocks: new Map<string, HighestBlockInfo>([]),
hadErrors: false,
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.x = sx; cursor.x < sx + 16; cursor.x++) {
let block = world.getBlock(cursor, blockProvider, attr)!
if (!INVISIBLE_BLOCKS.has(block.name)) {
const highest = attr.highestBlocks.get(`${cursor.x},${cursor.z}`)
if (!highest || highest.y < cursor.y) {
attr.highestBlocks.set(`${cursor.x},${cursor.z}`, { y: cursor.y, stateId: block.stateId, biomeId: block.biome.id })
}
}
if (INVISIBLE_BLOCKS.has(block.name)) continue
if ((block.name.includes('_sign') || block.name === 'sign') && !world.config.disableSignsMapsSupport) {
const key = `${cursor.x},${cursor.y},${cursor.z}`
@ -606,11 +539,11 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W
const pos = cursor.clone()
// eslint-disable-next-line @typescript-eslint/no-loop-func
delayedRender.push(() => {
renderLiquid(world, pos, blockProvider.getTextureInfo('water_still'), block.type, biome, true, attr, !isWaterlogged)
renderLiquid(world, pos, blockProvider.getTextureInfo('water_still'), block.type, biome, true, attr)
})
attr.blocksCount++
} else if (block.name === 'lava') {
renderLiquid(world, cursor, blockProvider.getTextureInfo('lava_still'), block.type, biome, false, attr, false)
renderLiquid(world, cursor, blockProvider.getTextureInfo('lava_still'), block.type, biome, false, attr)
attr.blocksCount++
}
if (block.name !== 'water' && block.name !== 'lava' && !INVISIBLE_BLOCKS.has(block.name)) {
@ -672,19 +605,12 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W
let ndx = attr.positions.length / 3
for (let i = 0; i < attr.t_positions!.length / 12; i++) {
attr.indices[attr.indicesCount++] = ndx
attr.indices[attr.indicesCount++] = ndx + 1
attr.indices[attr.indicesCount++] = ndx + 2
attr.indices[attr.indicesCount++] = ndx + 2
attr.indices[attr.indicesCount++] = 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
attr.indices.push(
ndx, ndx + 1, ndx + 2, ndx + 2, ndx + 1, ndx + 3,
// eslint-disable-next-line @stylistic/function-call-argument-newline
// back face
ndx, ndx + 2, ndx + 1, ndx + 2, ndx + 3, ndx + 1
)
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.colors = new Float32Array(attr.colors) 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) {
delete attr.positions
@ -719,21 +639,6 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W
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') => {
blockProvider = worldBlockProvider(blockstatesModels, blocksAtlas, version)
globalThis.blockProvider = blockProvider

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