Compare commits

..

12 commits

Author SHA1 Message Date
Vitaly
da6ce8cd57
Merge branch 'next' into holding-block-hand 2024-09-01 03:22:29 +03:00
Vitaly Turovsky
362c0106bc use block properties 2024-09-01 03:17:17 +03:00
Vitaly Turovsky
45fb8fa592 workaround for recent regreession with mc data change 2024-09-01 03:08:17 +03:00
Vitaly Turovsky
0d224f50fd demo item and wip holding item display! 2024-09-01 03:08:00 +03:00
Vitaly Turovsky
183c1edfa3 fix: when left click was pressed down the swing arm packet sending was not limited 2024-09-01 02:37:22 +03:00
Vitaly Turovsky
4e3325d2c9 Merge remote-tracking branch 'origin/next' into holding-block-hand 2024-09-01 02:25:12 +03:00
Vitaly Turovsky
33ab8745b3 add debug stats 2024-09-01 02:23:33 +03:00
Vitaly Turovsky
d4cd8c37de finish integration! +setting 2024-09-01 02:13:16 +03:00
Vitaly Turovsky
fd6b2e9a08 everything works for blocks! 2024-09-01 00:51:50 +03:00
Vitaly Turovsky
e2e20d5075 it just works for now 2024-08-31 23:07:01 +03:00
Vitaly Turovsky
8132dea5bb display is almost done 2024-08-31 18:02:10 +03:00
Vitaly Turovsky
b6349093fa wip 2024-08-27 03:34:49 +03:00
496 changed files with 38800 additions and 56578 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

@ -3,7 +3,4 @@ rsbuild.config.ts
*.module.css.d.ts
*.generated.ts
generated
dist
public
**/*/rsbuildSharedConfig.ts
src/mcDataTypes.ts

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",
@ -199,8 +197,7 @@
"no-async-promise-executor": "off",
"no-bitwise": "off",
"unicorn/filename-case": "off",
"max-depth": "off",
"unicorn/no-typeof-undefined": "off"
"max-depth": "off"
},
"overrides": [
{

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

@ -1,33 +0,0 @@
name: build-single-file
on:
workflow_dispatch:
jobs:
build-and-bundle:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Checkout repository
uses: actions/checkout@master
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install
- 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
with:
name: minecraft.html
path: minecraft.html

View file

@ -1,45 +0,0 @@
name: Make Self Host Zip
on:
workflow_dispatch:
jobs:
build-and-bundle:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Checkout repository
uses: actions/checkout@master
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install
- name: Build project
run: pnpm build
env:
LOCAL_CONFIG_FILE: config.mcraft-only.json
- name: Bundle server.js
run: |
pnpm esbuild server.js --bundle --platform=node --outfile=bundled-server.js --define:process.env.NODE_ENV="'production'"
- name: Create distribution package
run: |
mkdir -p package
cp -r dist package/
cp bundled-server.js package/server.js
cd package
zip -r ../self-host.zip .
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: self-host
path: self-host.zip

View file

@ -13,165 +13,30 @@ jobs:
with:
java-version: 17
java-package: jre
- name: Install pnpm
run: npm i -g pnpm@9.0.4
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 18
# cache: "pnpm"
- 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: pnpm tsx scripts/buildNpmReact.ts
- run: nohup pnpm prod-start &
- run: nohup pnpm test-mc-server &
- uses: cypress-io/github-action@v5
with:
install: false
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v3
if: failure()
with:
name: cypress-images
path: cypress/screenshots/
# - run: node scripts/outdatedGitPackages.mjs
# 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'
# steps:
# - name: Checkout repository
# uses: actions/checkout@v2
# - name: Install pnpm
# run: npm install -g pnpm@9.0.4
# - name: Run pnpm dedupe
# run: pnpm dedupe
# - name: Check for changes
# run: |
# if ! git diff --exit-code --quiet pnpm-lock.yaml; then
# echo "pnpm dedupe introduced changes:"
# git diff --color=always pnpm-lock.yaml
# exit 1
# else
# echo "No changes detected after pnpm dedupe in pnpm-lock.yaml"
# fi
path: cypress/integration/__image_snapshots__/
- run: node scripts/outdatedGitPackages.mjs
if: github.ref == 'refs/heads/next'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,29 +0,0 @@
name: Fix Lint Command
on:
issue_comment:
types: [created]
jobs:
deploy:
runs-on: ubuntu-latest
if: >-
github.event.issue.pull_request != '' &&
(
contains(github.event.comment.body, '/fix')
)
permissions:
pull-requests: write
steps:
- uses: actions/checkout@v2
with:
ref: refs/pull/${{ github.event.issue.number }}/head
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
uses: pnpm/action-setup@v4
- run: pnpm install
- run: pnpm lint --fix
- name: Push Changes
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,28 +0,0 @@
name: Update Base Branch Command
on:
issue_comment:
types: [created]
jobs:
deploy:
runs-on: ubuntu-latest
if: >-
github.event.issue.pull_request != '' &&
(
contains(github.event.comment.body, '/update')
)
permissions:
pull-requests: write
contents: write
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0 # Fetch all history so we can merge branches
ref: refs/pull/${{ github.event.issue.number }}/head
- name: Fetch All Branches
run: git fetch --all
# - name: Checkout PR
# run: git checkout ${{ github.event.issue.pull_request.head.ref }}
- name: Merge From Next
run: git merge origin/next --strategy-option=theirs
- name: Push Changes
run: git push

View file

@ -16,76 +16,25 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install Global Dependencies
run: pnpm add -g vercel
- name: Install Dependencies
run: pnpm install
run: npm install --global vercel pnpm@9.0.4
- name: Pull Vercel Environment Information
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
- 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/
run: pnpm build-playground && cp prismarine-viewer/public/index.html .vercel/output/static/playground.html && cp prismarine-viewer/public/playground.js .vercel/output/static/playground.js
- name: Download Generated Sounds map
run: node scripts/downloadSoundsMap.mjs
- name: Deploy Project Artifacts to Vercel
uses: mathiasvr/command-output@v2.0.0
with:
run: vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }}
id: deploy
- name: Start servers for testing
run: |
nohup pnpm prod-start &
nohup pnpm test-mc-server &
- name: Run Cypress smoke tests
uses: cypress-io/github-action@v5
with:
install: false
spec: cypress/e2e/smoke.spec.ts
- uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress-smoke-test-screenshots
path: cypress/screenshots/
- name: Set deployment aliases
run: |
for alias in $(echo ${{ secrets.TEST_PREVIEW_DOMAIN }} | tr "," "\n"); do
vercel alias set ${{ steps.deploy.outputs.stdout }} $alias --token=${{ secrets.VERCEL_TOKEN }} --scope=zaro
done
- name: Create Release Pull Request
uses: actions/github-script@v6
with:
script: |
const { data: pulls } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
head: `${context.repo.owner}:next`,
base: 'release',
state: 'open'
});
if (pulls.length === 0) {
await github.rest.pulls.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: 'Release',
head: 'next',
base: 'release',
body: 'PR was created automatically by the release workflow, hope you release it as soon as possible!',
});
}
- name: Set deployment alias
run: vercel alias set ${{ steps.deploy.outputs.stdout }} ${{ secrets.TEST_PREVIEW_DOMAIN }} --token=${{ secrets.VERCEL_TOKEN }} --scope=zaro
# - uses: mshick/add-pr-comment@v2
# with:
# message: |
# Deployed to Vercel Preview: ${{ steps.deploy.outputs.stdout }}

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 }}
@ -6,109 +6,57 @@ env:
on:
issue_comment:
types: [created]
pull_request_target:
jobs:
deploy:
runs-on: ubuntu-latest
# todo skip already created deploys on that commit
if: >-
github.event.issue.pull_request != '' &&
(
(
github.event_name == 'issue_comment' &&
contains(github.event.comment.body, '/deploy') &&
github.event.issue.pull_request != null
) ||
(
github.event_name == 'pull_request_target' &&
contains(fromJson(vars.AUTO_DEPLOY_PRS), github.event.pull_request.number)
)
contains(github.event.comment.body, '/deploy')
)
permissions:
pull-requests: write
steps:
- name: Checkout Base To Temp
- name: Checkout
uses: actions/checkout@v2
with:
path: temp-base-repo
- name: Get deployment alias
run: node temp-base-repo/scripts/githubActions.mjs getAlias
id: alias
env:
ALIASES: ${{ env.ALIASES }}
PULL_URL: ${{ github.event.issue.pull_request.url || github.event.pull_request.url }}
- name: Checkout PR (comment)
uses: actions/checkout@v2
if: github.event_name == 'issue_comment'
with:
ref: refs/pull/${{ github.event.issue.number }}/head
- name: Checkout PR (pull_request)
uses: actions/checkout@v2
if: github.event_name == 'pull_request_target'
with:
ref: refs/pull/${{ github.event.pull_request.number }}/head
- name: Install pnpm
uses: pnpm/action-setup@v4
- run: npm i -g pnpm@9.0.4
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 18
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
run: npm install --global vercel
- name: Pull Vercel Environment Information
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
- 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
pnpm build-playground
cp -r renderer/dist/* .vercel/output/static/playground/
- name: Write pr redirect index.html
run: |
mkdir -p .vercel/output/static/pr
echo "<meta http-equiv='refresh' content='0;url=https://github.com/${{ github.repository }}/pull/${{ github.event.issue.number || github.event.pull_request.number }}'>" > .vercel/output/static/pr/index.html
- name: Write commit redirect index.html
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
run: pnpm build-playground && cp prismarine-viewer/public/index.html .vercel/output/static/playground.html && cp prismarine-viewer/public/playground.js .vercel/output/static/playground.js
- name: Download Generated Sounds map
run: node scripts/downloadSoundsMap.mjs
- name: Deploy Project Artifacts to Vercel
uses: mathiasvr/command-output@v2.0.0
with:
run: vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }}
id: deploy
- uses: mshick/add-pr-comment@v2
# if: github.event_name == 'issue_comment'
with:
allow-repeats: true
message: |
Deployed to Vercel Preview: ${{ steps.deploy.outputs.stdout }}
[Playground](${{ steps.deploy.outputs.stdout }}/playground/)
[Playground](${{ steps.deploy.outputs.stdout }}/playground.html)
[Storybook](${{ steps.deploy.outputs.stdout }}/storybook/)
# - run: git checkout next scripts/githubActions.mjs
- name: Get deployment alias
run: node scripts/githubActions.mjs getAlias
id: alias
env:
ALIASES: ${{ env.ALIASES }}
PULL_URL: ${{ github.event.issue.pull_request.url }}
- name: Set deployment alias
if: ${{ steps.alias.outputs.alias != '' && steps.alias.outputs.alias != 'mcraft.fun' && steps.alias.outputs.alias != 's.mcraft.fun' }}
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
run: vercel alias set ${{ steps.deploy.outputs.stdout }} ${{ steps.alias.outputs.alias }} --token=${{ secrets.VERCEL_TOKEN }} --scope=zaro

50
.github/workflows/publish.yml vendored Normal file
View file

@ -0,0 +1,50 @@
name: Release
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
MAIN_MENU_LINKS: ${{ vars.MAIN_MENU_LINKS }}
on:
push:
branches: [release]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Checkout repository
uses: actions/checkout@master
- name: Install pnpm
run: npm i -g vercel pnpm@9.0.4
# - run: pnpm install
# - run: pnpm build
- run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
- run: node scripts/replaceFavicon.mjs ${{ secrets.FAVICON_MAIN }}
# will install + build to .vercel/output/static
- run: vercel build --token=${{ secrets.VERCEL_TOKEN }} --prod
- run: pnpm build-storybook
- name: Copy playground files
run: pnpm build-playground && cp prismarine-viewer/public/index.html .vercel/output/static/playground.html && cp prismarine-viewer/public/playground.js .vercel/output/static/playground.js
- 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
- run: |
pnpx zardoy-release node --footer "This release URL: ${{ steps.deploy.outputs.stdout }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# has possible output: tag
id: release
# has output
- run: cp vercel.json .vercel/output/static/vercel.json
- uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: .vercel/output/static
force_orphan: true
- run: pnpm tsx scripts/buildNpmReact.ts ${{ steps.release.outputs.tag }}
if: steps.release.outputs.tag
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

View file

@ -1,116 +0,0 @@
name: Release
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
MAIN_MENU_LINKS: ${{ vars.MAIN_MENU_LINKS }}
on:
push:
branches: [release]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Checkout repository
uses: actions/checkout@master
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install Global Dependencies
run: pnpm add -g vercel
# - run: pnpm install
# - run: pnpm build
- run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
- run: node scripts/replaceFavicon.mjs ${{ secrets.FAVICON_MAIN }}
# will install + build to .vercel/output/static
- name: Get Release Info
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
- name: Copy playground files
run: |
mkdir -p .vercel/output/static/playground
pnpm build-playground
cp -r renderer/dist/* .vercel/output/static/playground/
# publish to github
- run: cp vercel.json .vercel/output/static/vercel.json
- uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: .vercel/output/static
force_orphan: true
# Create CNAME file for custom domain
- name: Create CNAME file
run: echo "github.mcraft.fun" > .vercel/output/static/CNAME
- name: Deploy to mwc-mcraft-pages repository
uses: peaceiris/actions-gh-pages@v3
with:
personal_token: ${{ secrets.MCW_MCRAFT_PAGE_DEPLOY_TOKEN }}
external_repository: ${{ github.repository_owner }}/mwc-mcraft-pages
publish_dir: .vercel/output/static
publish_branch: main
destination_dir: docs
force_orphan: true
- name: Change index.html title
run: |
# change <title>Minecraft Web Client</title> to <title>Minecraft Web Client — Free Online Browser Version</title>
sed -i 's/<title>Minecraft Web Client<\/title>/<title>Minecraft Web Client — Free Online Browser Version<\/title>/' .vercel/output/static/index.html
- name: Deploy Project to Vercel
uses: mathiasvr/command-output@v2.0.0
with:
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
run: pnpm build
- name: Bundle server.js
run: |
pnpm esbuild server.js --bundle --platform=node --outfile=bundled-server.js --define:process.env.NODE_ENV="'production'"
- name: Create zip package
run: |
mkdir -p package
cp -r dist package/
cp bundled-server.js package/server.js
cd package
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 }})"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# has possible output: tag
id: release
# has output
- name: Set publishing config
run: pnpm config set '//registry.npmjs.org/:_authToken' "${NODE_AUTH_TOKEN}"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# - run: pnpm tsx scripts/buildNpmReact.ts ${{ steps.release.outputs.tag }}
# if: steps.release.outputs.tag
# env:
# NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

4
.gitignore vendored
View file

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

View file

@ -2,18 +2,12 @@
After forking the repository, run the following commands to get started:
0. Ensure you have [Node.js](https://nodejs.org) installed. Enable corepack with `corepack enable` *(1).
0. Ensure you have [Node.js](https://nodejs.org) and `pnpm` installed. To install pnpm run `npm i -g pnpm@9.0.4`.
1. Install dependencies: `pnpm i`
2. Start the project in development mode: `pnpm start` or build the project for production: `pnpm build`
2. Start the project in development mode: `pnpm start`
3. Read the [Tasks Categories](#tasks-categories) and [Workflow](#workflow) sections below
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 -->
## Project Structure
There are 3 main parts of the project:
@ -33,11 +27,11 @@ Paths:
- `src` - main app source code
- `src/react` - React components - almost all UI is in this folder. Almost every component has its base (reused in app and storybook) and `Provider` - which is a component that provides context to its children. Consider looking at DeathScreen component to see how it's used.
### Renderer: Playground & Mesher (`renderer`)
### Renderer: Playground & Mesher (`prismarine-viewer`)
- Playground Scripts:
- Start: `pnpm run-playground` (playground, mesher + server) or `pnpm watch-playground`
- Build: `pnpm build-playground` or `node renderer/esbuild.mjs`
- Build: `pnpm build-playground` or `node prismarine-viewer/esbuild.mjs`
- Mesher Scripts:
- Start: `pnpm watch-mesher`
@ -45,10 +39,10 @@ Paths:
Paths:
- `renderer` - Improved and refactored version of <https://github.com/PrismarineJS/prismarine-viewer>. Here is everything related to rendering the game world itself (no ui at all). Two most important parts here are:
- `renderer/viewer/lib/worldrenderer.ts` - adding new objects to three.js happens here (sections)
- `renderer/viewer/lib/models.ts` - preparing data for rendering (blocks) - happens in worker: out file - `worker.js`, building - `renderer/buildWorker.mjs`
- `renderer/playground/playground.ts` - Playground (source of <mcraft.fun/playground.html>) Use this for testing any rendering changes. You can also modify the playground code.
- `prismarine-viewer` - Improved and refactored version of <https://github.com/prismarineJS/prismarine-viewer>. Here is everything related to rendering the game world itself (no ui at all). Two most important parts here are:
- `prismarine-viewer/viewer/lib/worldrenderer.ts` - adding new objects to three.js happens here (sections)
- `prismarine-viewer/viewer/lib/models.ts` - preparing data for rendering (blocks) - happens in worker: out file - `worker.js`, building - `prismarine-viewer/buildWorker.mjs`
- `prismarine-viewer/examples/playground.ts` - Playground (source of <mcraft.fun/playground.html>) Use this for testing any rendering changes. You can also modify the playground code.
### Storybook (`.storybook`)
@ -76,7 +70,7 @@ Cypress tests are located in `cypress` folder. To run them, run `pnpm test-mc-se
## Unit Tests
There are not many unit tests for now (which we are trying to improve).
Location of unit tests: `**/*.test.ts` files in `src` folder and `renderer` folder.
Location of unit tests: `**/*.test.ts` files in `src` folder and `prismarine-viewer` folder.
Start them with `pnpm test-unit`.
## Making protocol-related changes
@ -175,21 +169,6 @@ New React components, improve UI (including mobile support).
3. Develop, try to fix and test. Finally we should find a way to fix it. It's ideal to have an automatic test but it's not necessary for now
3. Repeat step 1 to make sure the task is done and the problem is fixed (or the feature is implemented)
## 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`
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
3. Apply the patch manually in this directory: `patch -p1 < minecraft-protocol@<version>.patch`
4. Run the suggested command from `pnpm patch ...` (previous step) to update the patch
### Would be useful to have
- cleanup folder & modules structure, cleanup playground code

View file

@ -4,28 +4,20 @@ FROM node:18-alpine AS build
RUN apk add git
WORKDIR /app
COPY . /app
# install pnpm with corepack
RUN corepack enable
# Build arguments
ARG DOWNLOAD_SOUNDS=false
ARG DISABLE_SERVICE_WORKER=false
ARG CONFIG_JSON_SOURCE=REMOTE
# install pnpm
RUN npm i -g pnpm@9.0.4
# TODO need flat --no-root-optional
RUN node ./scripts/dockerPrepare.mjs
RUN pnpm i
# Download sounds if flag is enabled
RUN if [ "$DOWNLOAD_SOUNDS" = "true" ] ; then node scripts/downloadSoundsMap.mjs ; fi
# TODO for development
# EXPOSE 9090
# VOLUME /app/src
# VOLUME /app/renderer
# VOLUME /app/prismarine-viewer
# ENTRYPOINT ["pnpm", "run", "run-all"]
# only for prod
RUN DISABLE_SERVICE_WORKER=$DISABLE_SERVICE_WORKER \
CONFIG_JSON_SOURCE=$CONFIG_JSON_SOURCE \
pnpm run build
RUN pnpm run build
# ---- Run Stage ----
FROM node:18-alpine
@ -35,9 +27,8 @@ 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
VOLUME /app/public
ENTRYPOINT ["node", "server.js", "--prod"]

View file

@ -2,66 +2,34 @@
![banner](./docs-assets/banner.jpg)
Minecraft **clone** rewritten in TypeScript using the best modern web technologies. Minecraft vanilla-compatible client and integrated server packaged into a single web app.
A true Minecraft client running in your browser! A port of the original game to the web, written in JavaScript using the best modern web technologies.
You can try this out at [mcraft.fun](https://mcraft.fun/), [pcm.gg](https://pcm.gg) (short link), [mcon.vercel.app](https://mcon.vercel.app/) or the GitHub pages deploy. Every commit from the default (`develop`) branch is deployed to [s.mcraft.fun](https://s.mcraft.fun/) and [s.pcm.gg](https://s.pcm.gg/) - so it's usually newer, but might be less stable.
You can try this out at [mcraft.fun](https://mcraft.fun/), [pcm.gg](https://pcm.gg) (short link) [mcon.vercel.app](https://mcon.vercel.app/) or the GitHub pages deploy. Every commit from the `develop` (default) branch is deployed to [s.mcraft.fun](https://s.mcraft.fun/) - 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, 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). If you encounter any bugs or usability issues, please report them!
### 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)
- Singleplayer mode with simple world generations!
- Google Drive support for reading / saving worlds
- Works offline
- First-class touch (mobile) & controller support
- First-class keybindings configuration
- Advanced Resource pack support: Custom GUI, all textures. Server resource packs are supported with proper CORS configuration.
- Builtin JEI with recipes & descriptions for almost every item (JEI is creative inventory replacement)
- 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.
- First-class touch (mobile) & controller support
- FULL Resource pack support: Custom GUI, all textures & custom models! Server resource packs are also supported.
- Builtin JEI with recipes & guides for every item (also replaces creative inventory)
- 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:
**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.
@ -70,15 +38,11 @@ Whatever offline mode you used (zip, folder, just single player), you can always
![docs-assets/singleplayer-future-city-1-10-2.jpg](./docs-assets/singleplayer-future-city-1-10-2.jpg)
### Servers & Proxy
### Servers
You can play almost on any Java server, vanilla servers are fully supported.
See the [Mineflayer](https://github.com/PrismarineJS/mineflayer) repo for the list of supported versions (should support majority of versions).
There is a builtin proxy, but you can also host your one! Just clone the repo, run `pnpm i` (following CONTRIBUTING.MD) and run `pnpm prod-start`, then you can specify `http://localhost:8080` in the proxy field. Or you can deploy it to the cloud service:
[![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.
There is a builtin proxy, but you can also host your one! Just clone the repo, run `pnpm i` (following CONTRIBUTING.MD) and run `pnpm prod-start`, then you can specify `http://localhost:8080` in the proxy field.
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.
@ -123,25 +87,24 @@ To open the console, press `F12`, or if you are on mobile, you can type `#dev` i
It should be easy to build/start the project locally. See [CONTRIBUTING.MD](./CONTRIBUTING.md) for more info. Also you can look at Dockerfile for reference.
There is world renderer playground ([link](https://mcon.vercel.app/playground/)).
There is world renderer playground ([link](https://mcon.vercel.app/playground.html)).
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.
- `localServer.overworld.storageProvider.regions` - See ALL LOADED region files with all raw data.
- `localServer.levelData.LevelName = 'test'; localServer.writeLevelDat()` - Change name of the world
- `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"/>
@ -162,12 +125,6 @@ Press `Y` to set query parameters to url of your current game state.
There are some parameters you can set in the url to archive some specific behaviors:
General:
- **`?setting=<setting_name>:<setting_value>`** - Set and lock the setting on load. You can set multiple settings by separating them with `&` e.g. `?setting=autoParkour:true&setting=renderDistance:4`
- `?modal=<modal>` - Open specific modal on page load eg `keybindings`. Very useful on UI changes testing during dev. For path use `,` as separator. To get currently opened modal type this in the console: `activeModalStack.at(-1).reactType`
- `?replayFileUrl=<url>` - Load and start a packet replay session from a URL with a integrated server. For debugging / previewing recorded sessions. The file must be CORS enabled.
Server specific:
- `?ip=<server_address>` - Display connect screen to the server on load with predefined server ip. `:<port>` is optional and can be added to the ip.
@ -176,17 +133,14 @@ Server specific:
- `?proxy=<proxy_address>` - Set the proxy server address to use for the server
- `?username=<username>` - Set the username for the server
- `?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.
- `?reconnect=true` - Reconnect to the server on page reloads. Available in **dev mode only** and very useful on server testing.
Single player specific:
- `?loadSave=<save_name>` - Load the save on load with the specified folder name (not title)
- `?singleplayer=1` or `?sp=1` - Create empty world on load. Nothing will be saved
- `?singleplayer=1` - Create empty world on load. Nothing will be saved
- `?version=<version>` - Set the version for the singleplayer world (when used with `?singleplayer=1`)
- `?noSave=true` - Disable auto save on unload / disconnect / export whenever a world is loaded. Only manual save with `/save` command will work.
- `?serverSetting=<key>:<value>` - Set local server [options](https://github.com/zardoy/space-squid/tree/everything/src/modules.ts#L51). For example `?serverSetting=noInitialChunksSend:true` will disable initial chunks loading on the loading screen.
- `?map=<map_url>` - Load the map from ZIP. You can use any url, but it must be **CORS enabled**.
- `?mapDir=<index_file_url>` - Load the map from a file descriptor. It's recommended and the fastest way to load world but requires additional setup. The file must be in the following format:
@ -211,12 +165,12 @@ In this case you must use `?mapDirBaseUrl` to specify the base URL to fetch the
- `?mapDirBaseUrl` - See above.
Only during development:
- `?reconnect=true` - Reconnect to the server on page reloads. Very useful on server testing.
<!-- - `?mapDirGuess=<base_url>` - Load the map from the provided URL and paths will be guessed with a few additional fetch requests. -->
General:
- `?setting=<setting_name>:<setting_value>` - Set and lock the setting on load. You can set multiple settings by separating them with `&` e.g. `?setting=autoParkour:true&setting=renderDistance:4`
### Notable Things that Power this Project
- [Mineflayer](https://github.com/PrismarineJS/mineflayer) - Handles all client-side communications with the server (including the builtin one) - forked
@ -234,5 +188,3 @@ Only during development:
### Alternatives
- [https://github.com/ClassiCube/ClassiCube](ClassiCube - Better C# Rewrite) [DEMO](https://www.classicube.net/server/play/?warned=true)
- [https://m.eaglercraft.com/](EaglerCraft) - Eaglercraft runnable on mobile (real Minecraft in the browser)
- [js-minecraft](https://github.com/LabyStudio/js-minecraft) - An insanely well done clone from the graphical side that inspired many features here

View file

@ -6,7 +6,7 @@ Minecraft UI components for React extracted from [mcraft.fun](https://mcraft.fun
pnpm i minecraft-react
```
![demo](./docs-assets/npm-banner.jpeg)
![demo](https://github-production-user-asset-6210df.s3.amazonaws.com/46503702/346295584-80f3ed4a-cab6-45d2-8896-5e20233cc9b1.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAVCODYLSA53PQK4ZA%2F20240706%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20240706T195400Z&X-Amz-Expires=300&X-Amz-Signature=5b063823a57057c4042c15edd1db3edd107e00940fd0e66a2ba1df4e564a2809&X-Amz-SignedHeaders=host&actor_id=46503702&key_id=0&repo_id=432411890)
## Usage

58
TECH.md
View file

@ -1,58 +0,0 @@
### Eaglercraft Comparison
This project uses proxies so you can connect to almost any vanilla server. Though proxies have some limitations such as increased latency and servers will complain about using VPN (though we have a workaround for that, but ping will be much higher).
This client generally has better performance but some features reproduction might be inaccurate eg its less stable and more buggy in some cases.
| Feature | This project | Eaglercraft | Description |
| --------------------------------- | ----------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| General | | | |
| Mobile Support (touch) | ✅(+) | ✅ | |
| 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 (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 |
| 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) |
| 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 |
| 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 |
Features available to only this project:
- CSS & JS Customization
- JS Real Time Debugging & Console Scripting (eg Devtools)
### Tech Stack
Bundler: Rsbuild!
UI: powered by React and css modules. Storybook helps with UI development.
### Rare WEB Features
There are a number of web features that are not commonly used but you might be interested in them if you decide to build your own game in the web.
TODO
| API | Usage & Description |
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `Crypto` API | Used to make chat features work when joining online servers with authentication. |
| `requestPointerLock({ unadjustedMovement: true })` API | Required for games. Disables system mouse acceleration (important for Mac users). Aka mouse raw input |
| `navigator.keyboard.lock()` | (only in Chromium browsers) When entering fullscreen it allows to use any key combination like ctrl+w in the game |
| `navigator.keyboard.getLayoutMap()` | (only in Chromium browsers) To display the right keyboard symbol for the key keybinding on different keyboard layouts (e.g. QWERTY vs AZERTY) |

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

@ -1,6 +1,6 @@
{
"name": "Minecraft Web Client",
"short_name": "Minecraft Web Client",
"name": "Prismarine Web Client",
"short_name": "Prismarine Web Client",
"scope": "./",
"start_url": "./",
"icons": [

View file

@ -1,4 +0,0 @@
<!-- just redirect to /playground/ -->
<script>
window.location.href = `/playground/${window.location.search}`
</script>

View file

@ -1,80 +1,23 @@
{
"version": 1,
"defaultHost": "<from-proxy>",
"defaultProxy": "https://proxy.mcraft.fun",
"defaultProxy": "proxy.mcraft.fun",
"mapsProvider": "https://maps.mcraft.fun/",
"skinTexturesProxy": "",
"peerJsServer": "",
"peerJsServerFallback": "https://p2p.mcraft.fun",
"promoteServers": [
{
"ip": "wss://play.mcraft.fun"
},
{
"ip": "wss://play.webmc.fun",
"name": "WebMC"
},
{
"ip": "wss://ws.fuchsmc.net"
},
{
"ip": "wss://play2.mcraft.fun"
},
{
"ip": "wss://play-creative.mcraft.fun",
"description": "Might be available soon, stay tuned!"
},
{
"ip": "kaboom.pw",
"version": "1.20.3",
"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": [
[
{
"type": "github"
},
{
"type": "discord"
}
]
],
"defaultUsername": "mcrafter{0-9999}",
"mobileButtons": [
{
"action": "general.drop",
"actionHold": "general.dropStack",
"label": "Q"
"version": "1.18.2",
"description": "Chaos and destruction server. Free for everyone."
},
{
"action": "general.selectItem",
"actionHold": "",
"label": "S"
"ip": "go.mineberry.org",
"version": "1.18.2",
"description": "One of the best servers here. Join now!"
},
{
"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": ""
"ip": "sus.shhnowisnottheti.me",
"version": "1.18.2",
"description": "Creative, your own 'boxes' (islands)"
}
]
}

View file

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

View file

@ -1,11 +1,8 @@
import { defineConfig } from 'cypress'
const isPerformanceTest = process.env.PERFORMANCE_TEST === 'true'
export default defineConfig({
video: false,
chromeWebSecurity: false,
screenshotOnRunFailure: true, // Enable screenshots on test failures
e2e: {
// We've imported your old cypress plugins here.
// You may want to clean this up later by importing these.
@ -34,7 +31,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

@ -38,18 +38,18 @@ it('Loads & renders singleplayer', () => {
testWorldLoad()
})
it.skip('Joins to local flying-squid server', () => {
it('Joins to local flying-squid server', () => {
visit('/?ip=localhost&version=1.16.1')
window.localStorage.version = ''
// todo replace with data-test
// cy.get('[data-test-id="servers-screen-button"]').click()
// cy.get('[data-test-id="server-ip"]').clear().focus().type('localhost')
// cy.get('[data-test-id="version"]').clear().focus().type('1.16.1') // todo needs to fix autoversion
cy.get('[data-test-id="connect-qs"]').click() // todo! cypress sometimes doesn't click
cy.get('[data-test-id="connect-qs"]').click()
testWorldLoad()
})
it.skip('Joins to local latest Java vanilla server', () => {
it('Joins to local latest Java vanilla server', () => {
const version = supportedVersions.at(-1)!
cy.task('startServer', [version, 25_590]).then(() => {
visit('/?ip=localhost:25590&username=bot')

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,6 +1,6 @@
//@ts-check
import mcServer from 'flying-squid'
import defaultOptions from 'flying-squid/config/default-settings.json' with { type: 'json' }
import defaultOptions from 'flying-squid/config/default-settings.json' assert { type: 'json' }
/** @type {Options} */
const serverOptions = {

View file

@ -32,8 +32,8 @@
❌ world_border_warning_reach
❌ simulation_distance
❌ chunk_biomes
❌ damage_event
❌ hurt_animation
✅ damage_event
✅ spawn_entity
✅ spawn_entity_experience_orb
✅ named_entity_spawn

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

View file

@ -1 +0,0 @@
<script src="state.ts" type="module"></script>

View file

@ -1,37 +0,0 @@
import { SmoothSwitcher } from '../renderer/viewer/lib/smoothSwitcher'
const div = document.createElement('div')
div.style.width = '100px'
div.style.height = '100px'
div.style.backgroundColor = 'red'
document.body.appendChild(div)
const pos = {x: 0, y: 0}
const positionSwitcher = new SmoothSwitcher(() => pos, (key, value) => {
pos[key] = value
})
globalThis.positionSwitcher = positionSwitcher
document.body.addEventListener('keydown', e => {
if (e.code === 'ArrowLeft' || e.code === 'ArrowRight') {
const to = {
x: e.code === 'ArrowLeft' ? -100 : 100
}
console.log(pos, to)
positionSwitcher.transitionTo(to, e.code === 'ArrowLeft' ? 'Left' : 'Right', () => {
console.log('Switched to ', e.code === 'ArrowLeft' ? 'Left' : 'Right')
})
}
if (e.code === 'Space') {
pos.x = 200
}
})
const render = () => {
positionSwitcher.update()
div.style.transform = `translate(${pos.x}px, ${pos.y}px)`
requestAnimationFrame(render)
}
render()

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

@ -1,18 +1,8 @@
<!DOCTYPE html>
<html>
<head>
<meta name="darkreader-lock">
<script>
window.startLoad = Date.now()
// g663 fix: forbid change of string prototype
Object.defineProperty(String.prototype, 'format', {
writable: false,
configurable: false
});
Object.defineProperty(String.prototype, 'replaceAll', {
writable: false,
configurable: false
});
</script>
<!-- // #region initial loader -->
<script async>
@ -25,9 +15,6 @@
<div>
<div style="font-size: calc(var(--font-size) * 1.8);color: lightgray;" class="title">Loading...</div>
<div style="font-size: var(--font-size);color: rgb(176, 176, 176);margin-top: 3px;text-align: center" class="subtitle">A true Minecraft client in your browser!</div>
<!-- small text pre -->
<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,48 +24,17 @@
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
if (log) console.log(message)
if (typeof message !== 'string') message = String(message)
const onError = (message) => {
console.log(message)
if (document.querySelector('.initial-loader') && document.querySelector('.initial-loader').querySelector('.title').textContent !== 'Error') {
document.querySelector('.initial-loader').querySelector('.title').textContent = 'Error'
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';
}
document.querySelector('.initial-loader').querySelector('.subtitle').textContent = message
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')
window.navigator.serviceWorker.getRegistrations().then(registrations => {
registrations.forEach(registration => {
console.log('got registration')
registration.unregister().then(() => {
console.log('worker unregistered')
})
})
})
}
window.lastError = errorOrMessage instanceof Error ? errorOrMessage : new Error(errorOrMessage)
}
}
window.addEventListener('unhandledrejection', (e) => onError(e.reason, true))
window.addEventListener('error', (e) => onError(e.error ?? e.message))
window.addEventListener('unhandledrejection', (e) => onError(e.reason))
window.addEventListener('error', (e) => onError(e.message))
}
insertLoadingDiv()
document.addEventListener('DOMContentLoaded', () => {
@ -95,25 +51,6 @@
import('https://cdn.skypack.dev/eruda').then(({ default: eruda }) => {
eruda.init()
})
Promise.all([import('https://cdn.skypack.dev/stacktrace-gps'), import('https://cdn.skypack.dev/error-stack-parser')]).then(async ([{ default: StackTraceGPS }, { default: ErrorStackParser }]) => {
if (!window.lastError) return
let stackFrames = [];
if (window.lastError instanceof Error) {
stackFrames = ErrorStackParser.parse(window.lastError);
}
console.log('stackFrames', stackFrames)
const gps = new StackTraceGPS()
const mappedFrames = await Promise.all(
stackFrames.map(frame => gps.pinpoint(frame))
);
console.log('mappedFrames', mappedFrames)
const stackTrace = mappedFrames
.map(frame => `at ${frame.functionName} (${frame.fileName}:${frame.lineNumber}:${frame.columnNumber})`)
.join('\n');
console.log('stackTrace', stackTrace)
})
}
}
checkLoadEruda()
@ -147,17 +84,21 @@
window.loadedPlugins[pluginName] = await import(script)
}
</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">
<title>Prismarine Web Client</title>
<link rel="favicon" href="favicon.png">
<link rel="icon" type="image/png" href="favicon.png" />
<link rel="canonical" href="https://mcraft.fun">
<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">
<meta name="theme-color" content="#349474">
<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover'>
<meta property="og:title" content="Minecraft Web Client" />
<meta property="og:title" content="Prismarine Web Client" />
<meta property="og:type" content="website" />
<meta property="og:image" content="favicon.png" />
<meta name="format-detection" content="telephone=no">
<link rel="manifest" href="manifest.json" crossorigin="use-credentials">
</head>
<body>
<div id="react-root"></div>

View file

@ -5,45 +5,34 @@
"scripts": {
"dev-rsbuild": "rsbuild dev",
"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",
"start": "run-p dev-rsbuild dev-proxy watch-mesher",
"start-watch-script": "nodemon -w rsbuild.config.ts --watch",
"build": "rsbuild build",
"build-analyze": "BUNDLE_ANALYZE=true rsbuild build",
"check-build": "tsx scripts/genShims.ts && 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",
"prod-start": "node server.js --prod",
"test-mc-server": "tsx cypress/minecraft-server.mjs",
"lint": "eslint \"{src,cypress,renderer}/**/*.{ts,js,jsx,tsx}\"",
"lint-fix": "pnpm lint --fix",
"lint": "eslint \"{src,cypress,prismarine-viewer}/**/*.{ts,js,jsx,tsx}\"",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build && node scripts/build.js moveStorybookFiles",
"start-experiments": "vite --config experiments/vite.config.ts --host",
"watch-other-workers": "echo NOT IMPLEMENTED",
"build-other-workers": "echo NOT IMPLEMENTED",
"build-mesher": "node renderer/buildMesherWorker.mjs",
"build-mesher": "node prismarine-viewer/buildMesherWorker.mjs",
"watch-mesher": "pnpm build-mesher -w",
"run-playground": "run-p watch-mesher watch-other-workers watch-playground",
"run-playground": "run-p watch-mesher watch-other-workers playground-server 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"
"playground-server": "live-server --port=9090 prismarine-viewer/public",
"build-playground": "node prismarine-viewer/esbuild.mjs",
"watch-playground": "node prismarine-viewer/esbuild.mjs -w"
},
"keywords": [
"prismarine",
"web",
"client"
],
"release": {
"attachReleaseFiles": "{self-host.zip,minecraft.html}"
},
"publish": {
"preset": {
"publishOnlyIfChanged": true,
@ -54,9 +43,9 @@
"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",
"@mui/base": "5.0.0-beta.40",
"@nxg-org/mineflayer-auto-jump": "^0.7.7",
"@nxg-org/mineflayer-tracker": "^1.2.1",
"@react-oauth/google": "^0.12.1",
"@stylistic/eslint-plugin": "^2.6.1",
"@types/gapi": "^0.0.47",
@ -73,21 +62,18 @@
"compression": "^1.7.4",
"cors": "^2.8.5",
"debug": "^4.3.4",
"deepslate": "^0.23.5",
"diff-match-patch": "^1.0.5",
"eruda": "^3.0.1",
"esbuild": "^0.19.3",
"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.36",
"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.65.0",
"minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master",
"mineflayer-item-map-downloader": "github:zardoy/mineflayer-item-map-downloader",
"mojangson": "^2.0.4",
@ -106,7 +92,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-select": "^5.8.0",
"react-zoom-pan-pinch": "3.4.4",
"react-transition-group": "^4.4.5",
"remark": "^15.0.1",
"sanitize-filename": "^1.6.3",
"skinview3d": "^3.0.1",
@ -123,11 +109,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 +121,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 +131,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 +141,16 @@
"http-browserify": "^1.7.0",
"http-server": "^14.1.1",
"https-browserify": "^1.0.0",
"mc-assets": "^0.2.62",
"mc-assets": "^0.2.12",
"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",
"prismarine-viewer": "link:prismarine-viewer",
"process": "github:PrismarineJS/node-process",
"renderer": "link:renderer",
"rimraf": "^5.0.1",
"storybook": "^7.4.6",
"stream-browserify": "^3.0.0",
@ -176,67 +163,31 @@
"optionalDependencies": {
"cypress": "^10.11.0",
"cypress-plugin-snapshots": "^1.4.4",
"sharp": "^0.33.5",
"systeminformation": "^5.21.22"
},
"browserslist": {
"production": [
"iOS >= 14",
"Android >= 13",
"Chrome >= 103",
"not dead",
"not ie <= 11",
"not op_mini all",
"> 0.5%"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"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.65.0",
"prismarine-provider-anvil": "github:zardoy/prismarine-provider-anvil#everything",
"prismarine-physics": "github:zardoy/prismarine-physics",
"minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master",
"react": "^18.2.0",
"prismarine-chunk": "github:zardoy/prismarine-chunk#master",
"prismarine-item": "latest"
"prismarine-chunk": "github:zardoy/prismarine-chunk"
},
"updateConfig": {
"ignoreDependencies": [
"browserfs",
"google-drive-browserfs"
]
"ignoreDependencies": []
},
"patchedDependencies": {
"minecraft-protocol@1.47.0": "patches/minecraft-protocol@1.47.0.patch",
"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
"mineflayer-item-map-downloader@1.2.0": "patches/mineflayer-item-map-downloader@1.2.0.patch"
}
},
"packageManager": "pnpm@10.8.0+sha512.0e82714d1b5b43c74610193cb20734897c1d00de89d0e18420aebc5977fa13d780a9cb05734624e81ebd81cc876cd464794850641c48b9544326b5622ca29971"
"packageManager": "pnpm@9.0.4"
}

View file

@ -1,138 +0,0 @@
diff --git a/src/client/chat.js b/src/client/chat.js
index 0021870994fc59a82f0ac8aba0a65a8be43ef2f4..a53fceb843105ea2a1d88722b3fc7c3b43cb102a 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) {
if (mcData.supportFeature('useChatSessions')) {
const tsDelta = BigInt(Date.now()) - packet.timestamp
const expired = !packet.timestamp || tsDelta > messageExpireTime || tsDelta < 0
- const verified = !packet.unsignedChatContent && updateAndValidateSession(packet.senderUuid, packet.plainMessage, packet.signature, packet.index, packet.previousMessages, packet.salt, packet.timestamp) && !expired
+ const verified = false && !packet.unsignedChatContent && updateAndValidateSession(packet.senderUuid, packet.plainMessage, packet.signature, packet.index, packet.previousMessages, packet.salt, packet.timestamp) && !expired
if (verified) client._signatureCache.push(packet.signature)
client.emit('playerChat', {
globalIndex: packet.globalIndex,
@@ -362,7 +362,7 @@ module.exports = function (client, options) {
}
}
- client._signedChat = (message, options = {}) => {
+ client._signedChat = async (message, options = {}) => {
options.timestamp = options.timestamp || BigInt(Date.now())
options.salt = options.salt || 1n
@@ -407,7 +407,7 @@ module.exports = function (client, options) {
message,
timestamp: options.timestamp,
salt: options.salt,
- signature: (client.profileKeys && client._session) ? client.signMessage(message, options.timestamp, options.salt, undefined, acknowledgements) : undefined,
+ signature: (client.profileKeys && client._session) ? await client.signMessage(message, options.timestamp, options.salt, undefined, acknowledgements) : undefined,
offset: client._lastSeenMessages.pending,
checksum: computeChatChecksum(client._lastSeenMessages), // 1.21.5+
acknowledged
@@ -422,7 +422,7 @@ module.exports = function (client, options) {
message,
timestamp: options.timestamp,
salt: options.salt,
- signature: client.profileKeys ? client.signMessage(message, options.timestamp, options.salt, options.preview) : Buffer.alloc(0),
+ signature: client.profileKeys ? await client.signMessage(message, options.timestamp, options.salt, options.preview) : Buffer.alloc(0),
signedPreview: options.didPreview,
previousMessages: client._lastSeenMessages.map((e) => ({
messageSender: e.sender,
diff --git a/src/client/encrypt.js b/src/client/encrypt.js
index 63cc2bd9615100bd2fd63dfe14c094aa6b8cd1c9..36df57d1196af9761d920fa285ac48f85410eaef 100644
--- a/src/client/encrypt.js
+++ b/src/client/encrypt.js
@@ -25,7 +25,11 @@ module.exports = function (client, options) {
if (packet.serverId !== '-') {
debug('This server appears to be an online server and you are providing no password, the authentication will probably fail')
}
- sendEncryptionKeyResponse()
+ client.end('This server appears to be an online server and you are providing no authentication. Try authenticating first.')
+ // sendEncryptionKeyResponse()
+ // client.once('set_compression', () => {
+ // clearTimeout(loginTimeout)
+ // })
}
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
--- a/src/client.js
+++ b/src/client.js
@@ -111,7 +111,13 @@ class Client extends EventEmitter {
this._hasBundlePacket = false
}
} else {
- emitPacket(parsed)
+ try {
+ emitPacket(parsed)
+ } catch (err) {
+ console.log('Client incorrectly handled packet ' + parsed.metadata.name)
+ console.error(err)
+ // todo investigate why it doesn't close the stream even if unhandled there
+ }
}
})
}
@@ -169,7 +175,10 @@ class Client extends EventEmitter {
}
const onFatalError = (err) => {
- this.emit('error', err)
+ // todo find out what is trying to write after client disconnect
+ if(err.code !== 'ECONNABORTED') {
+ this.emit('error', err)
+ }
endSocket()
}
@@ -198,6 +207,10 @@ 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
} else {
if (this.socket) this.socket.end()
}
@@ -243,6 +256,7 @@ class Client extends EventEmitter {
debug('writing packet ' + this.state + '.' + name)
debug(params)
}
+ this.emit('writePacket', name, params)
this.serializer.write({ name, params })
}

View file

@ -0,0 +1,188 @@
diff --git a/src/client/autoVersion.js b/src/client/autoVersion.js
index c437ecf3a0e4ab5758a48538c714b7e9651bb5da..d9c9895ae8614550aa09ad60a396ac32ffdf1287 100644
--- a/src/client/autoVersion.js
+++ b/src/client/autoVersion.js
@@ -9,7 +9,7 @@ module.exports = function (client, options) {
client.wait_connect = true // don't let src/client/setProtocol proceed on socket 'connect' until 'connect_allowed'
debug('pinging', options.host)
// TODO: use 0xfe ping instead for better compatibility/performance? https://github.com/deathcap/node-minecraft-ping
- ping(options, function (err, response) {
+ ping(options, async function (err, response) {
if (err) { return client.emit('error', err) }
debug('ping response', response)
// TODO: could also use ping pre-connect to save description, type, max players, etc.
@@ -40,6 +40,7 @@ module.exports = function (client, options) {
// Reinitialize client object with new version TODO: move out of its constructor?
client.version = minecraftVersion
+ await options.versionSelectedHook?.(client)
client.state = states.HANDSHAKING
// Let other plugins such as Forge/FML (modinfo) respond to the ping response
diff --git a/src/client/encrypt.js b/src/client/encrypt.js
index b9d21bab9faccd5dbf1975fc423fc55c73e906c5..99ffd76527b410e3a393181beb260108f4c63536 100644
--- a/src/client/encrypt.js
+++ b/src/client/encrypt.js
@@ -25,7 +25,11 @@ module.exports = function (client, options) {
if (packet.serverId !== '-') {
debug('This server appears to be an online server and you are providing no password, the authentication will probably fail')
}
- sendEncryptionKeyResponse()
+ client.end('This server appears to be an online server and you are providing no authentication. Try authenticating first.')
+ // sendEncryptionKeyResponse()
+ // client.once('set_compression', () => {
+ // clearTimeout(loginTimeout)
+ // })
}
function onJoinServerResponse (err) {
diff --git a/src/client.js b/src/client.js
index c89375e32babbf3559655b1e95f6441b9a30796f..f24cd5dc8fa9a0a4000b184fb3c79590a3ad8b8a 100644
--- a/src/client.js
+++ b/src/client.js
@@ -88,10 +88,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
@@ -109,7 +111,13 @@ class Client extends EventEmitter {
this._hasBundlePacket = false
}
} else {
- emitPacket(parsed)
+ try {
+ emitPacket(parsed)
+ } catch (err) {
+ console.log('Client incorrectly handled packet ' + parsed.metadata.name)
+ console.error(err)
+ // todo investigate why it doesn't close the stream even if unhandled there
+ }
}
})
}
@@ -166,7 +174,10 @@ class Client extends EventEmitter {
}
const onFatalError = (err) => {
- this.emit('error', err)
+ // todo find out what is trying to write after client disconnect
+ if(err.code !== 'ECONNABORTED') {
+ this.emit('error', err)
+ }
endSocket()
}
@@ -195,6 +206,8 @@ class Client extends EventEmitter {
serializer -> framer -> socket -> splitter -> deserializer */
if (this.serializer) {
this.serializer.end()
+ this.socket?.end()
+ this.socket?.emit('end')
} else {
if (this.socket) this.socket.end()
}
@@ -236,8 +249,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 })
}
diff --git a/src/index.d.ts b/src/index.d.ts
index 0a5821c32d735e11205a280aa5a503c13533dc14..94a49f661d922478b940d853169b6087e6ec3df5 100644
--- a/src/index.d.ts
+++ b/src/index.d.ts
@@ -121,6 +121,7 @@ declare module 'minecraft-protocol' {
sessionServer?: string
keepAlive?: boolean
closeTimeout?: number
+ closeTimeout?: number
noPongTimeout?: number
checkTimeoutInterval?: number
version?: string
@@ -141,6 +142,8 @@ declare module 'minecraft-protocol' {
disableChatSigning?: boolean
/** Pass custom client implementation if needed. */
Client?: Client
+ /** Can be used to prepare mc data on autoVersion (client.version has selected version) */
+ versionSelectedHook?: (client: Client) => Promise<void> | void
}
export class Server extends EventEmitter {
diff --git a/src/client/chat.js b/src/client/chat.js
index 5cad9954db13d7121ed0a03792c2304156cdf436..ffd7c7d6299ef54854e0923f8d5296bf2a58956b 100644
--- a/src/client/chat.js
+++ b/src/client/chat.js
@@ -111,7 +111,7 @@ module.exports = function (client, options) {
for (const player of packet.data) {
if (!player.chatSession) continue
client._players[player.UUID] = {
- publicKey: crypto.createPublicKey({ key: player.chatSession.publicKey.keyBytes, format: 'der', type: 'spki' }),
+ // publicKey: crypto.createPublicKey({ key: player.chatSession.publicKey.keyBytes, format: 'der', type: 'spki' }),
publicKeyDER: player.chatSession.publicKey.keyBytes,
sessionUuid: player.chatSession.uuid
}
@@ -127,7 +127,7 @@ module.exports = function (client, options) {
for (const player of packet.data) {
if (player.crypto) {
client._players[player.UUID] = {
- publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }),
+ // publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }),
publicKeyDER: player.crypto.publicKey,
signature: player.crypto.signature,
displayName: player.displayName || player.name
@@ -198,7 +198,7 @@ module.exports = function (client, options) {
if (mcData.supportFeature('useChatSessions')) {
const tsDelta = BigInt(Date.now()) - packet.timestamp
const expired = !packet.timestamp || tsDelta > messageExpireTime || tsDelta < 0
- const verified = !packet.unsignedChatContent && updateAndValidateSession(packet.senderUuid, packet.plainMessage, packet.signature, packet.index, packet.previousMessages, packet.salt, packet.timestamp) && !expired
+ const verified = false && !packet.unsignedChatContent && updateAndValidateSession(packet.senderUuid, packet.plainMessage, packet.signature, packet.index, packet.previousMessages, packet.salt, packet.timestamp) && !expired
if (verified) client._signatureCache.push(packet.signature)
client.emit('playerChat', {
plainMessage: packet.plainMessage,
@@ -363,7 +363,7 @@ module.exports = function (client, options) {
}
}
- client._signedChat = (message, options = {}) => {
+ client._signedChat = async (message, options = {}) => {
options.timestamp = options.timestamp || BigInt(Date.now())
options.salt = options.salt || 1n
@@ -404,7 +404,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,
acknowledged
})
@@ -418,7 +418,7 @@ module.exports = function (client, options) {
message,
timestamp: options.timestamp,
salt: options.salt,
- signature: client.profileKeys ? client.signMessage(message, options.timestamp, options.salt, options.preview) : Buffer.alloc(0),
+ signature: client.profileKeys ? await client.signMessage(message, options.timestamp, options.salt, options.preview) : Buffer.alloc(0),
signedPreview: options.didPreview,
previousMessages: client._lastSeenMessages.map((e) => ({
messageSender: e.sender,

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 {

13481
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
packages:
- "."
- "renderer"
- "renderer/viewer/sign-renderer/"
- "prismarine-viewer"
- "prismarine-viewer/viewer/sign-renderer/"

View file

@ -1,5 +0,0 @@
# Prismarine Viewer
Renamed to `renderer`.
For more info see [CONTRIBUTING.md](../CONTRIBUTING.md).

View file

@ -22,28 +22,23 @@ const buildOptions = {
},
platform: 'browser',
entryPoints: [path.join(__dirname, './viewer/lib/mesher/mesher.ts')],
minify: !watch,
minify: true,
logLevel: 'info',
drop: !watch ? [
'debugger'
] : [],
sourcemap: 'linked',
target: watch ? undefined : ['ios14'],
write: false,
metafile: true,
outdir: path.join(__dirname, './dist'),
outdir: path.join(__dirname, './public'),
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')) {
@ -113,9 +108,9 @@ const buildOptions = {
})
build.onEnd(({ metafile, outputFiles }) => {
if (!metafile) return
fs.mkdirSync(path.join(__dirname, './dist'), { recursive: true })
fs.writeFileSync(path.join(__dirname, './dist/metafile.json'), JSON.stringify(metafile))
for (const outDir of ['../dist/', './dist/']) {
fs.mkdirSync(path.join(__dirname, './public'), { recursive: true })
fs.writeFileSync(path.join(__dirname, './public/metafile.json'), JSON.stringify(metafile))
for (const outDir of ['../dist/', './public/']) {
for (const outputFile of outputFiles) {
if (outDir === '../dist/' && outputFile.path.endsWith('.map')) {
// skip writing & browser loading sourcemap there, worker debugging should be done in playground

View file

@ -0,0 +1,96 @@
//@ts-check
import * as fs from 'fs'
import fsExtra from 'fs-extra'
import * as esbuild from 'esbuild'
import { polyfillNode } from 'esbuild-plugin-polyfill-node'
import path, { dirname, join } from 'path'
import { fileURLToPath } from 'url'
import childProcess from 'child_process'
import supportedVersions from '../src/supportedVersions.mjs'
const dev = process.argv.includes('-w')
const __dirname = path.dirname(fileURLToPath(new URL(import.meta.url)))
const mcDataPath = join(__dirname, '../generated/minecraft-data-optimized.json')
if (!fs.existsSync(mcDataPath)) {
childProcess.execSync('tsx ../scripts/makeOptimizedMcData.mjs', { stdio: 'inherit', cwd: __dirname })
}
fs.copyFileSync(join(__dirname, 'playground.html'), join(__dirname, 'public/index.html'))
/** @type {import('esbuild').BuildOptions} */
const buildOptions = {
bundle: true,
entryPoints: [join(__dirname, './examples/playground.ts')],
// target: ['es2020'],
// logLevel: 'debug',
logLevel: 'info',
platform: 'browser',
sourcemap: dev ? 'inline' : false,
minify: !dev,
outfile: join(__dirname, 'public/playground.js'),
mainFields: [
'browser', 'module', 'main'
],
keepNames: true,
banner: {
js: `globalThis.global = globalThis;globalThis.includedVersions = ${JSON.stringify(supportedVersions)};`,
},
alias: {
events: 'events',
buffer: 'buffer',
'fs': 'browserfs/dist/shims/fs.js',
http: 'http-browserify',
stream: 'stream-browserify',
net: 'net-browserify',
// 'mc-assets': '/Users/vitaly/Documents/mc-assets',
},
inject: [],
metafile: true,
loader: {
'.png': 'dataurl',
'.obj': 'text',
},
plugins: [
{
name: 'minecraft-data',
setup(build) {
build.onLoad({
filter: /minecraft-data[\/\\]data.js$/,
}, () => {
const defaultVersionsObj = {}
return {
contents: fs.readFileSync(join(__dirname, '../src/shims/minecraftData.ts'), 'utf8'),
loader: 'ts',
resolveDir: join(__dirname, '../src/shims'),
}
})
build.onEnd((e) => {
if (e.errors.length) return
fs.writeFileSync(join(__dirname, './public/metafile.json'), JSON.stringify(e.metafile), 'utf8')
})
}
},
polyfillNode({
polyfills: {
fs: false,
crypto: false,
events: false,
http: false,
stream: false,
buffer: false,
perf_hooks: false,
net: false,
},
})
],
}
if (dev) {
(await esbuild.context(buildOptions)).watch()
} else {
await esbuild.build(buildOptions)
}
// await ctx.rebuild()

View file

@ -0,0 +1 @@
export { default as rotation } from './rotation'

View file

@ -0,0 +1,9 @@
import { Vec3 } from 'vec3'
import { ExampleSetupFunction } from './type'
const setup: ExampleSetupFunction = (world, mcData, mesherConfig, setupParam) => {
mesherConfig.debugModelVariant = [3]
void world.setBlockStateId(new Vec3(0, 0, 0), mcData.blocksByName.sand.defaultState!)
}
export default setup

View file

@ -0,0 +1,6 @@
import { CustomWorld } from 'flying-squid/dist/lib/modules/world'
import { IndexedData } from 'minecraft-data'
import { MesherConfig } from '../../viewer/lib/mesher/shared'
type SetupParams = {}
export type ExampleSetupFunction = (world: CustomWorld, mcData: IndexedData, mesherConfig: MesherConfig, setupParam: SetupParams) => void

View file

@ -0,0 +1,498 @@
import _ from 'lodash'
import { Vec3 } from 'vec3'
import BlockLoader from 'prismarine-block'
import ChunkLoader from 'prismarine-chunk'
import WorldLoader from 'prismarine-world'
import * as THREE from 'three'
import { GUI } from 'lil-gui'
import JSZip from 'jszip'
import blockstatesModels from 'mc-assets/dist/blockStatesModels.json'
//@ts-expect-error
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import { IndexedData } from 'minecraft-data'
import { loadScript } from '../viewer/lib/utils'
import { TWEEN_DURATION } from '../viewer/lib/entities'
import { EntityMesh } from '../viewer/lib/entity/EntityMesh'
import { WorldDataEmitter, Viewer } from '../viewer'
import '../../src/getCollisionShapes'
import { toMajorVersion } from '../../src/utils'
window.THREE = THREE
const gui = new GUI()
// initial values
const params = {
skipQs: '',
version: globalThis.includedVersions.sort((a, b) => {
const s = (x) => {
const parts = x.split('.')
return +parts[0] + (+parts[1])
}
return s(a) - s(b)
}).at(-1),
block: '',
metadata: 0,
supportBlock: false,
entity: '',
removeEntity () {
this.entity = ''
},
entityRotate: false,
camera: '',
playSound () { },
blockIsomorphicRenderBundle () { },
modelVariant: 0
}
const qs = new URLSearchParams(window.location.search)
for (const [key, value] of qs.entries()) {
const parsed = /^-?\d+$/.test(value) ? Number(value) : value === 'true' ? true : value === 'false' ? false : value
params[key] = parsed
}
const setQs = () => {
const newQs = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (!value || typeof value === 'function' || params.skipQs.includes(key)) continue
newQs.set(key, value)
}
window.history.replaceState({}, '', `${window.location.pathname}?${newQs.toString()}`)
}
let ignoreResize = false
async function main () {
let continuousRender = false
const { version } = params
await window._LOAD_MC_DATA()
// temporary solution until web worker is here, cache data for faster reloads
// const globalMcData = window['mcData']
// if (!globalMcData['version']) {
// const major = toMajorVersion(version)
// const sessionKey = `mcData-${major}`
// if (sessionStorage[sessionKey]) {
// Object.assign(globalMcData, JSON.parse(sessionStorage[sessionKey]))
// } else {
// if (sessionStorage.length > 1) sessionStorage.clear()
// try {
// sessionStorage[sessionKey] = JSON.stringify(Object.fromEntries(Object.entries(globalMcData).filter(([ver]) => ver.startsWith(major))))
// } catch { }
// }
// }
const mcData: IndexedData = require('minecraft-data')(version)
window['loadedData'] = mcData
gui.add(params, 'version', globalThis.includedVersions)
gui.add(params, 'block', mcData.blocksArray.map(b => b.name).sort((a, b) => a.localeCompare(b)))
const metadataGui = gui.add(params, 'metadata')
gui.add(params, 'modelVariant')
gui.add(params, 'supportBlock')
gui.add(params, 'entity', mcData.entitiesArray.map(b => b.name).sort((a, b) => a.localeCompare(b))).listen()
gui.add(params, 'removeEntity')
gui.add(params, 'entityRotate')
gui.add(params, 'skipQs')
gui.add(params, 'playSound')
gui.add(params, 'blockIsomorphicRenderBundle')
gui.open(false)
let metadataFolder = gui.addFolder('metadata')
// let entityRotationFolder = gui.addFolder('entity metadata')
const Chunk = ChunkLoader(version)
const Block = BlockLoader(version)
// const data = await fetch('smallhouse1.schem').then(r => r.arrayBuffer())
// const schem = await Schematic.read(Buffer.from(data), version)
const viewDistance = 0
const targetPos = new Vec3(2, 90, 2)
const World = WorldLoader(version)
// const diamondSquare = require('diamond-square')({ version, seed: Math.floor(Math.random() * Math.pow(2, 31)) })
//@ts-expect-error
const chunk1 = new Chunk()
//@ts-expect-error
const chunk2 = new Chunk()
chunk1.setBlockStateId(targetPos, 34)
chunk2.setBlockStateId(targetPos.offset(1, 0, 0), 34)
//@ts-expect-error
const world = new World((chunkX, chunkZ) => {
// if (chunkX === 0 && chunkZ === 0) return chunk1
// if (chunkX === 1 && chunkZ === 0) return chunk2
//@ts-expect-error
const chunk = new Chunk()
return chunk
})
// await schem.paste(world, new Vec3(0, 60, 0))
const worldView = new WorldDataEmitter(world, viewDistance, targetPos)
// Create three.js context, add to page
const renderer = new THREE.WebGLRenderer({ alpha: true, ...localStorage['renderer'] })
renderer.setPixelRatio(window.devicePixelRatio || 1)
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)
// Create viewer
const viewer = new Viewer(renderer, { numWorkers: 1, showChunkBorders: false, })
viewer.world.blockstatesModels = blockstatesModels
viewer.entities.setDebugMode('basic')
viewer.setVersion(version)
viewer.entities.onSkinUpdate = () => {
viewer.render()
}
viewer.world.mesherConfig.enableLighting = false
viewer.listen(worldView)
// Load chunks
await worldView.init(targetPos)
window['worldView'] = worldView
window['viewer'] = viewer
params.blockIsomorphicRenderBundle = () => {
const canvas = renderer.domElement
const onlyCurrent = !confirm('Ok - render all blocks, Cancel - render only current one')
const sizeRaw = prompt('Size', '512')
if (!sizeRaw) return
const size = parseInt(sizeRaw, 10)
// const size = 512
ignoreResize = true
canvas.width = size
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
const rad = THREE.MathUtils.degToRad(-120)
viewer.directionalLight.position.set(
Math.cos(rad),
Math.sin(rad),
0.2
).normalize()
viewer.directionalLight.intensity = 1
const cameraPos = targetPos.offset(2, 2, 2)
const pitch = THREE.MathUtils.degToRad(-30)
const yaw = THREE.MathUtils.degToRad(45)
viewer.camera.rotation.set(pitch, yaw, 0, 'ZYX')
// viewer.camera.lookAt(center.x + 0.5, center.y + 0.5, center.z + 0.5)
viewer.camera.position.set(cameraPos.x + 1, cameraPos.y + 0.5, cameraPos.z + 1)
const allBlocks = mcData.blocksArray.map(b => b.name)
// const allBlocks = ['stone', 'warped_slab']
let blockCount = 1
let blockName = allBlocks[0]
const updateBlock = () => {
// viewer.setBlockStateId(targetPos, mcData.blocksByName[blockName].minStateId)
params.block = blockName
// todo cleanup (introduce getDefaultState)
onUpdate.block()
applyChanges(false, true)
}
void viewer.waitForChunksToRender().then(async () => {
// wait for next macro task
await new Promise(resolve => {
setTimeout(resolve, 0)
})
if (onlyCurrent) {
viewer.render()
onWorldUpdate()
} else {
// will be called on every render update
viewer.world.renderUpdateEmitter.addListener('update', onWorldUpdate)
updateBlock()
}
})
const zip = new JSZip()
zip.file('description.txt', 'Generated with prismarine-viewer')
const end = async () => {
// download zip file
const a = document.createElement('a')
const blob = await zip.generateAsync({ type: 'blob' })
const dataUrlZip = URL.createObjectURL(blob)
a.href = dataUrlZip
a.download = 'blocks_render.zip'
a.click()
URL.revokeObjectURL(dataUrlZip)
console.log('end')
viewer.world.renderUpdateEmitter.removeListener('update', onWorldUpdate)
}
async function onWorldUpdate () {
// await new Promise(resolve => {
// setTimeout(resolve, 50)
// })
const dataUrl = canvas.toDataURL('image/png')
zip.file(`${blockName}.png`, dataUrl.split(',')[1], { base64: true })
if (onlyCurrent) {
end()
} else {
nextBlock()
}
}
const nextBlock = async () => {
blockName = allBlocks[blockCount++]
console.log(allBlocks.length, '/', blockCount, blockName)
if (blockCount % 5 === 0) {
await new Promise(resolve => {
setTimeout(resolve, 100)
})
}
if (blockName) {
updateBlock()
} else {
end()
}
}
}
const controls = new OrbitControls(viewer.camera, renderer.domElement)
controls.target.set(targetPos.x + 0.5, targetPos.y + 0.5, targetPos.z + 0.5)
const cameraPos = targetPos.offset(2, 2, 2)
const pitch = THREE.MathUtils.degToRad(-45)
const yaw = THREE.MathUtils.degToRad(45)
viewer.camera.rotation.set(pitch, yaw, 0, 'ZYX')
viewer.camera.lookAt(targetPos.x + 0.5, targetPos.y + 0.5, targetPos.z + 0.5)
viewer.camera.position.set(cameraPos.x + 0.5, cameraPos.y + 0.5, cameraPos.z + 0.5)
controls.update()
let blockProps = {}
const entityOverrides = {}
const getBlock = () => {
return mcData.blocksByName[params.block || 'air']
}
const entityUpdateShared = () => {
viewer.entities.clear()
if (!params.entity) return
worldView.emit('entity', {
id: 'id', name: params.entity, pos: targetPos.offset(0.5, 1, 0.5), width: 1, height: 1, username: localStorage.testUsername, yaw: Math.PI, pitch: 0
})
const enableSkeletonDebug = (obj) => {
const { children, isSkeletonHelper } = obj
if (!Array.isArray(children)) return
if (isSkeletonHelper) {
obj.visible = true
return
}
for (const child of children) {
if (typeof child === 'object') enableSkeletonDebug(child)
}
}
enableSkeletonDebug(viewer.entities.entities['id'])
setTimeout(() => {
viewer.render()
}, TWEEN_DURATION)
}
const onUpdate = {
version (initialUpdate) {
// if (initialUpdate) return
// viewer.world.texturesVersion = params.version
// viewer.world.updateTexturesData()
// todo warning
},
block () {
blockProps = {}
metadataFolder.destroy()
const block = mcData.blocksByName[params.block]
if (!block) return
console.log('block', block.name)
const props = new Block(block.id, 0, 0).getProperties()
//@ts-expect-error
const { states } = mcData.blocksByStateId[getBlock()?.minStateId] ?? {}
metadataFolder = gui.addFolder('metadata')
if (states) {
for (const state of states) {
let defaultValue: string | number | boolean
if (state.values) { // int, enum
defaultValue = state.values[0]
} else {
switch (state.type) {
case 'bool':
defaultValue = false
break
case 'int':
defaultValue = 0
break
case 'direction':
defaultValue = 'north'
break
default:
continue
}
}
blockProps[state.name] = defaultValue
if (state.values) {
metadataFolder.add(blockProps, state.name, state.values)
} else {
metadataFolder.add(blockProps, state.name)
}
}
} else {
for (const [name, value] of Object.entries(props)) {
blockProps[name] = value
metadataFolder.add(blockProps, name)
}
}
console.log('props', blockProps)
metadataFolder.open()
},
entity () {
continuousRender = params.entity === 'player'
entityUpdateShared()
if (!params.entity) return
if (params.entity === 'player') {
viewer.entities.updatePlayerSkin('id', viewer.entities.entities.id.username, true, true)
viewer.entities.playAnimation('id', 'running')
}
// let prev = false
// setInterval(() => {
// viewer.entities.playAnimation('id', prev ? 'running' : 'idle')
// prev = !prev
// }, 1000)
EntityMesh.getStaticData(params.entity)
// entityRotationFolder.destroy()
// entityRotationFolder = gui.addFolder('entity metadata')
// entityRotationFolder.add(params, 'entityRotate')
// entityRotationFolder.open()
},
supportBlock () {
viewer.setBlockStateId(targetPos.offset(0, -1, 0), params.supportBlock ? 1 : 0)
},
modelVariant () {
viewer.world.mesherConfig.debugModelVariant = params.modelVariant === 0 ? undefined : [params.modelVariant]
}
}
const applyChanges = (metadataUpdate = false, skipQs = false) => {
const blockId = getBlock()?.id
let block: BlockLoader.Block
if (metadataUpdate) {
block = new Block(blockId, 0, params.metadata)
Object.assign(blockProps, block.getProperties())
for (const _child of metadataFolder.children) {
const child = _child as import('lil-gui').Controller
child.updateDisplay()
}
} else {
try {
block = Block.fromProperties(blockId ?? -1, blockProps, 0)
} catch (err) {
console.error(err)
block = Block.fromStateId(0, 0)
}
}
//@ts-expect-error
viewer.setBlockStateId(targetPos, block.stateId)
console.log('up stateId', block.stateId)
params.metadata = block.metadata
metadataGui.updateDisplay()
if (!skipQs) {
setQs()
}
}
gui.onChange(({ property, object }) => {
if (object === params) {
if (property === 'camera') return
onUpdate[property]?.()
applyChanges(property === 'metadata')
} else {
applyChanges()
}
})
void viewer.waitForChunksToRender().then(async () => {
// TODO!
await new Promise(resolve => {
setTimeout(resolve, 50)
})
for (const update of Object.values(onUpdate)) {
update(true)
}
applyChanges()
gui.openAnimated()
})
const animate = () => {
// if (controls) controls.update()
// worldView.updatePosition(controls.target)
viewer.render()
// window.requestAnimationFrame(animate)
}
viewer.world.renderUpdateEmitter.addListener('update', () => {
animate()
})
animate()
// #region camera rotation param
if (params.camera) {
const [x, y] = params.camera.split(',')
viewer.camera.rotation.set(parseFloat(x), parseFloat(y), 0, 'ZYX')
controls.update()
console.log(viewer.camera.rotation.x, parseFloat(x))
}
const throttledCamQsUpdate = _.throttle(() => {
const { camera } = viewer
// params.camera = `${camera.rotation.x.toFixed(2)},${camera.rotation.y.toFixed(2)}`
setQs()
}, 200)
controls.addEventListener('change', () => {
throttledCamQsUpdate()
animate()
})
// #endregion
const continuousUpdate = () => {
if (continuousRender) {
animate()
}
requestAnimationFrame(continuousUpdate)
}
continuousUpdate()
window.onresize = () => {
if (ignoreResize) return
// const vec3 = new THREE.Vector3()
// vec3.set(-1, -1, -1).unproject(viewer.camera)
// console.log(vec3)
// box.position.set(vec3.x, vec3.y, vec3.z-1)
const { camera } = viewer
viewer.camera.aspect = window.innerWidth / window.innerHeight
viewer.camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
animate()
}
window.dispatchEvent(new Event('resize'))
params.playSound = () => {
viewer.playSound(targetPos, 'button_click.mp3')
}
addEventListener('keydown', (e) => {
if (e.code === 'KeyE') {
params.playSound()
}
}, { capture: true })
}
main()

View file

@ -1,5 +1,5 @@
{
"name": "renderer",
"name": "prismarine-viewer",
"version": "1.25.0",
"description": "Web based viewer",
"main": "index.js",
@ -26,7 +26,7 @@
"prismarine-block": "^1.7.3",
"prismarine-chunk": "^1.22.0",
"prismarine-schematic": "^1.2.0",
"renderer": "link:./",
"prismarine-viewer": "link:./",
"process": "^0.11.10",
"socket.io": "^4.0.0",
"socket.io-client": "^4.0.0",

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%;
@ -34,18 +28,9 @@
font-family: mojangles;
src: url(../../../assets/mojangles.ttf);
}
* {
user-select: none;
}
</style>
<script>
if (window.location.pathname.endsWith('playground')) {
// add trailing slash
window.location.href = `${window.location.origin}${window.location.pathname}/${window.location.search}`
}
</script>
</head>
<body>
<div id="root"></div>
<script type="text/javascript" src="playground.js"></script>
</body>
</html>

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

@ -0,0 +1,499 @@
//@ts-check
import EventEmitter from 'events'
import nbt from 'prismarine-nbt'
import * as TWEEN from '@tweenjs/tween.js'
import * as THREE from 'three'
import { PlayerObject, PlayerAnimation } from 'skinview3d'
import { loadSkinToCanvas, loadEarsToCanvasFromSkin, inferModelType, loadCapeToCanvas, loadImage } from 'skinview-utils'
// todo replace with url
import stevePng from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png'
import { NameTagObject } from 'skinview3d/libs/nametag'
import { flat, fromFormattedString } from '@xmcl/text-component'
import mojangson from 'mojangson'
import * as Entity from './entity/EntityMesh'
import { WalkingGeneralSwing } from './entity/animations'
import externalTexturesJson from './entity/externalTextures.json'
import { disposeObject } from './threeJsUtils'
export const TWEEN_DURATION = 120
/**
* @param {string} username
*/
function getUsernameTexture(username, { fontFamily = 'sans-serif' }) {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get 2d context')
const fontSize = 50
const padding = 5
ctx.font = `${fontSize}px ${fontFamily}`
const textWidth = ctx.measureText(username).width + padding * 2
canvas.width = textWidth
canvas.height = fontSize + padding * 2
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.font = `${fontSize}px ${fontFamily}`
ctx.fillStyle = 'white'
ctx.fillText(username, padding, fontSize)
return canvas
}
const addNametag = (entity, options, mesh) => {
if (entity.username !== undefined) {
if (mesh.children.some(c => c.name === 'nametag')) return // todo update
const canvas = getUsernameTexture(entity.username, options)
const tex = new THREE.Texture(canvas)
tex.needsUpdate = true
const spriteMat = new THREE.SpriteMaterial({ map: tex })
const sprite = new THREE.Sprite(spriteMat)
sprite.renderOrder = 1000
sprite.scale.set(canvas.width * 0.005, canvas.height * 0.005, 1)
sprite.position.y += entity.height + 0.6
sprite.name = 'nametag'
mesh.add(sprite)
}
}
// todo cleanup
const nametags = {}
function getEntityMesh(entity, scene, options, overrides) {
if (entity.name) {
try {
// https://github.com/PrismarineJS/prismarine-viewer/pull/410
const entityName = entity.name.toLowerCase()
const e = new Entity.EntityMesh('1.16.4', entityName, scene, overrides)
if (e.mesh) {
addNametag(entity, options, e.mesh)
return e.mesh
}
} catch (err) {
reportError?.(err)
}
}
const geometry = new THREE.BoxGeometry(entity.width, entity.height, entity.width)
geometry.translate(0, entity.height / 2, 0)
const material = new THREE.MeshBasicMaterial({ color: 0xff_00_ff })
const cube = new THREE.Mesh(geometry, material)
const nametagCount = (nametags[entity.name] = (nametags[entity.name] || 0) + 1)
if (nametagCount < 6) {
addNametag({
username: entity.name,
height: entity.height,
}, options, cube)
}
return cube
}
export class Entities extends EventEmitter {
constructor(scene) {
super()
/** @type {THREE.Scene} */
this.scene = scene
this.entities = {}
this.entitiesOptions = {}
this.debugMode = 'none'
this.onSkinUpdate = () => { }
this.clock = new THREE.Clock()
this.rendering = true
/** @type {THREE.Texture | null} */
this.itemsTexture = null
this.getItemUv = undefined
}
clear() {
for (const mesh of Object.values(this.entities)) {
this.scene.remove(mesh)
disposeObject(mesh)
}
this.entities = {}
}
setDebugMode(mode, /** @type {THREE.Object3D?} */entity = null) {
this.debugMode = mode
for (const mesh of entity ? [entity] : Object.values(this.entities)) {
const boxHelper = mesh.children.find(c => c.name === 'debug')
boxHelper.visible = false
if (this.debugMode === 'basic') {
boxHelper.visible = true
}
// todo advanced
}
}
setRendering(rendering, /** @type {THREE.Object3D?} */entity = null) {
this.rendering = rendering
for (const ent of entity ? [entity] : Object.values(this.entities)) {
if (rendering) {
if (!this.scene.children.includes(ent)) this.scene.add(ent)
} else {
this.scene.remove(ent)
}
}
}
render() {
const dt = this.clock.getDelta()
for (const entityId of Object.keys(this.entities)) {
const playerObject = this.getPlayerObject(entityId)
if (playerObject?.animation) {
playerObject.animation.update(playerObject, dt)
}
}
}
getPlayerObject(entityId) {
/** @type {(PlayerObject & { animation?: PlayerAnimation }) | undefined} */
const playerObject = this.entities[entityId]?.playerObject
return playerObject
}
// fixme workaround
defaultSteveTexture
// true means use default skin url
updatePlayerSkin(entityId, username, /** @type {string | true} */skinUrl, /** @type {string | true | undefined} */capeUrl = undefined) {
let playerObject = this.getPlayerObject(entityId)
if (!playerObject) return
// const username = this.entities[entityId].username
// or https://mulv.vercel.app/
if (skinUrl === true) {
skinUrl = `https://mulv.tycrek.dev/api/lookup?username=${username}&type=skin`
if (!username) return
}
loadImage(skinUrl).then(image => {
playerObject = this.getPlayerObject(entityId)
if (!playerObject) return
/** @type {THREE.CanvasTexture} */
let skinTexture
if (skinUrl === stevePng && this.defaultSteveTexture) {
skinTexture = this.defaultSteveTexture
} else {
const skinCanvas = document.createElement('canvas')
loadSkinToCanvas(skinCanvas, image)
skinTexture = new THREE.CanvasTexture(skinCanvas)
if (skinUrl === stevePng) {
this.defaultSteveTexture = skinTexture
}
}
skinTexture.magFilter = THREE.NearestFilter
skinTexture.minFilter = THREE.NearestFilter
skinTexture.needsUpdate = true
//@ts-expect-error
playerObject.skin.map = skinTexture
playerObject.skin.modelType = inferModelType(skinTexture.image)
const earsCanvas = document.createElement('canvas')
loadEarsToCanvasFromSkin(earsCanvas, image)
if (isCanvasBlank(earsCanvas)) {
playerObject.ears.map = null
playerObject.ears.visible = false
} else {
const earsTexture = new THREE.CanvasTexture(earsCanvas)
earsTexture.magFilter = THREE.NearestFilter
earsTexture.minFilter = THREE.NearestFilter
earsTexture.needsUpdate = true
//@ts-expect-error
playerObject.ears.map = earsTexture
playerObject.ears.visible = true
}
this.onSkinUpdate?.()
if (capeUrl) {
if (capeUrl === true) capeUrl = `https://mulv.tycrek.dev/api/lookup?username=${username}&type=cape`
loadImage(capeUrl).then(capeImage => {
playerObject = this.getPlayerObject(entityId)
if (!playerObject) return
const capeCanvas = document.createElement('canvas')
loadCapeToCanvas(capeCanvas, capeImage)
const capeTexture = new THREE.CanvasTexture(capeCanvas)
capeTexture.magFilter = THREE.NearestFilter
capeTexture.minFilter = THREE.NearestFilter
capeTexture.needsUpdate = true
//@ts-expect-error
playerObject.cape.map = capeTexture
playerObject.cape.visible = true
//@ts-expect-error
playerObject.elytra.map = capeTexture
this.onSkinUpdate?.()
if (!playerObject.backEquipment) {
playerObject.backEquipment = 'cape'
}
}, () => { })
}
}, () => { })
playerObject.cape.visible = false
if (!capeUrl) {
playerObject.backEquipment = null
playerObject.elytra.map = null
if (playerObject.cape.map) {
playerObject.cape.map.dispose()
}
playerObject.cape.map = null
}
function isCanvasBlank(canvas) {
return !canvas.getContext('2d')
.getImageData(0, 0, canvas.width, canvas.height).data
.some(channel => channel !== 0)
}
}
playAnimation(entityPlayerId, /** @type {'walking' | 'running' | 'oneSwing' | 'idle'} */animation) {
const playerObject = this.getPlayerObject(entityPlayerId)
if (!playerObject) return
if (animation === 'oneSwing') {
if (!(playerObject.animation instanceof WalkingGeneralSwing)) throw new Error('Expected WalkingGeneralSwing')
playerObject.animation.swingArm()
return
}
if (playerObject.animation instanceof WalkingGeneralSwing) {
playerObject.animation.switchAnimationCallback = () => {
if (!(playerObject.animation instanceof WalkingGeneralSwing)) throw new Error('Expected WalkingGeneralSwing')
playerObject.animation.isMoving = animation !== 'idle'
playerObject.animation.isRunning = animation === 'running'
}
}
}
parseEntityLabel(jsonLike) {
if (!jsonLike) return
try {
const parsed = typeof jsonLike === 'string' ? mojangson.simplify(mojangson.parse(jsonLike)) : nbt.simplify(jsonLike)
const text = flat(parsed).map(x => x.text)
return text.join('')
} catch (err) {
return jsonLike
}
}
getItemMesh(item) {
const textureUv = this.getItemUv?.(item.itemId ?? item.blockId)
if (textureUv) {
// todo use geometry buffer uv instead!
const { u, v, size, su, sv, texture } = textureUv
const itemsTexture = texture.clone()
itemsTexture.flipY = true
itemsTexture.offset.set(u, 1 - v - (sv ?? size))
itemsTexture.repeat.set(su ?? size, sv ?? size)
itemsTexture.needsUpdate = true
itemsTexture.magFilter = THREE.NearestFilter
itemsTexture.minFilter = THREE.NearestFilter
const itemsTextureFlipped = itemsTexture.clone()
itemsTextureFlipped.repeat.x *= -1
itemsTextureFlipped.needsUpdate = true
itemsTextureFlipped.offset.set(u + (su ?? size), 1 - v - (sv ?? size))
const material = new THREE.MeshStandardMaterial({
map: itemsTexture,
transparent: true,
alphaTest: 0.1,
})
const materialFlipped = new THREE.MeshStandardMaterial({
map: itemsTextureFlipped,
transparent: true,
alphaTest: 0.1,
})
const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 0), [
// top left and right bottom are black box materials others are transparent
new THREE.MeshBasicMaterial({ color: 0x00_00_00 }), new THREE.MeshBasicMaterial({ color: 0x00_00_00 }),
new THREE.MeshBasicMaterial({ color: 0x00_00_00 }), new THREE.MeshBasicMaterial({ color: 0x00_00_00 }),
material, materialFlipped,
])
return {
mesh,
itemsTexture,
itemsTextureFlipped,
}
}
}
update(/** @type {import('prismarine-entity').Entity & {delete?, pos}} */entity, overrides) {
let isPlayerModel = entity.name === 'player'
if (entity.name === 'zombie' || entity.name === 'zombie_villager' || entity.name === 'husk') {
isPlayerModel = true
overrides.texture = `textures/1.16.4/entity/${entity.name === 'zombie_villager' ? 'zombie_villager/zombie_villager.png' : `zombie/${entity.name}.png`}`
}
if (!this.entities[entity.id] && !entity.delete) {
const group = new THREE.Group()
let mesh
if (entity.name === 'item') {
/** @type {any} */
//@ts-expect-error
const item = entity.metadata?.find(m => typeof m === 'object' && m?.itemCount)
if (item) {
const object = this.getItemMesh(item)
if (object) {
object.scale.set(0.5, 0.5, 0.5)
object.position.set(0, 0.2, 0)
// set faces
// mesh.position.set(targetPos.x + 0.5 + 2, targetPos.y + 0.5, targetPos.z + 0.5)
// viewer.scene.add(mesh)
const clock = new THREE.Clock()
object.onBeforeRender = () => {
const delta = clock.getDelta()
object.rotation.y += delta
}
//@ts-expect-error
group.additionalCleanup = () => {
// important: avoid texture memory leak and gpu slowdown
object.itemsTexture.dispose()
object.itemsTextureFlipped.dispose()
}
}
}
} else if (isPlayerModel) {
// CREATE NEW PLAYER ENTITY
const wrapper = new THREE.Group()
/** @type {PlayerObject & { animation?: PlayerAnimation }} */
const playerObject = new PlayerObject()
playerObject.position.set(0, 16, 0)
//@ts-expect-error
wrapper.add(playerObject)
const scale = 1 / 16
wrapper.scale.set(scale, scale, scale)
if (entity.username) {
// todo proper colors
const nameTag = new NameTagObject(fromFormattedString(entity.username).text, {
font: `48px ${this.entitiesOptions.fontFamily}`,
})
nameTag.position.y = playerObject.position.y + playerObject.scale.y * 16 + 3
nameTag.renderOrder = 1000
//@ts-expect-error
wrapper.add(nameTag)
}
//@ts-expect-error
group.playerObject = playerObject
wrapper.rotation.set(0, Math.PI, 0)
mesh = wrapper
playerObject.animation = new WalkingGeneralSwing()
//@ts-expect-error
playerObject.animation.isMoving = false
} else {
mesh = getEntityMesh(entity, this.scene, this.entitiesOptions, overrides)
}
if (!mesh) return
mesh.name = 'mesh'
// set initial position so there are no weird jumps update after
group.position.set(entity.pos.x, entity.pos.y, entity.pos.z)
// todo use width and height instead
const boxHelper = new THREE.BoxHelper(
mesh,
entity.type === 'hostile' ? 0xff_00_00 :
entity.type === 'mob' ? 0x00_ff_00 :
entity.type === 'player' ? 0x00_00_ff :
0xff_a5_00,
)
boxHelper.name = 'debug'
group.add(mesh)
group.add(boxHelper)
boxHelper.visible = false
this.scene.add(group)
this.entities[entity.id] = group
this.emit('add', entity)
if (isPlayerModel) {
this.updatePlayerSkin(entity.id, '', overrides?.texture || stevePng)
}
this.setDebugMode(this.debugMode, group)
this.setRendering(this.rendering, group)
}
//@ts-expect-error
// set visibility
const isInvisible = entity.metadata?.[0] & 0x20
for (const child of this.entities[entity.id]?.children.find(c => c.name === 'mesh')?.children ?? []) {
if (child.name !== 'nametag') {
child.visible = !isInvisible
}
}
// ---
// not player
const displayText = entity.metadata?.[3] && this.parseEntityLabel(entity.metadata[2])
if (entity.name !== 'player' && displayText) {
addNametag({ ...entity, username: displayText }, this.entitiesOptions, this.entities[entity.id].children.find(c => c.name === 'mesh'))
}
// todo handle map, map_chunks events
// if (entity.name === 'item_frame' || entity.name === 'glow_item_frame') {
// const example = {
// "present": true,
// "itemId": 847,
// "itemCount": 1,
// "nbtData": {
// "type": "compound",
// "name": "",
// "value": {
// "map": {
// "type": "int",
// "value": 2146483444
// },
// "interactiveboard": {
// "type": "byte",
// "value": 1
// }
// }
// }
// }
// const item = entity.metadata?.[8]
// if (item.nbtData) {
// const nbt = nbt.simplify(item.nbtData)
// }
// }
// this can be undefined in case where packet entity_destroy was sent twice (so it was already deleted)
const e = this.entities[entity.id]
if (entity.username) {
e.username = entity.username
}
if (e?.playerObject && overrides?.rotation?.head) {
/** @type {PlayerObject} */
// eslint-disable-next-line prefer-destructuring
const playerObject = e.playerObject
const headRotationDiff = overrides.rotation.head.y ? overrides.rotation.head.y - entity.yaw : 0
playerObject.skin.head.rotation.y = -headRotationDiff
playerObject.skin.head.rotation.x = overrides.rotation.head.x ? - overrides.rotation.head.x : 0
}
if (entity.delete && e) {
if (e.additionalCleanup) e.additionalCleanup()
this.emit('remove', entity)
this.scene.remove(e)
disposeObject(e)
// todo dispose textures as well ?
delete this.entities[entity.id]
}
if (entity.pos) {
new TWEEN.Tween(e.position).to({ x: entity.pos.x, y: entity.pos.y, z: entity.pos.z }, TWEEN_DURATION).start()
}
if (entity.yaw) {
const da = (entity.yaw - e.rotation.y) % (Math.PI * 2)
const dy = 2 * da % (Math.PI * 2) - da
new TWEEN.Tween(e.rotation).to({ y: e.rotation.y + dy }, TWEEN_DURATION).start()
}
}
}

View file

@ -0,0 +1,381 @@
//@ts-check
import * as THREE from 'three'
import { OBJLoader } from 'three-stdlib'
import entities from './entities.json'
import { externalModels } from './objModels'
import externalTexturesJson from './externalTextures.json'
// import { loadTexture } from globalThis.isElectron ? '../utils.electron.js' : '../utils';
const { loadTexture } = globalThis.isElectron ? require('../utils.electron.js') : require('../utils')
const elemFaces = {
up: {
dir: [0, 1, 0],
u0: [0, 0, 1],
v0: [0, 0, 0],
u1: [1, 0, 1],
v1: [0, 0, 1],
corners: [
[0, 1, 1, 0, 0],
[1, 1, 1, 1, 0],
[0, 1, 0, 0, 1],
[1, 1, 0, 1, 1]
]
},
down: {
dir: [0, -1, 0],
u0: [1, 0, 1],
v0: [0, 0, 0],
u1: [2, 0, 1],
v1: [0, 0, 1],
corners: [
[1, 0, 1, 0, 0],
[0, 0, 1, 1, 0],
[1, 0, 0, 0, 1],
[0, 0, 0, 1, 1]
]
},
east: {
dir: [1, 0, 0],
u0: [0, 0, 0],
v0: [0, 0, 1],
u1: [0, 0, 1],
v1: [0, 1, 1],
corners: [
[1, 1, 1, 0, 0],
[1, 0, 1, 0, 1],
[1, 1, 0, 1, 0],
[1, 0, 0, 1, 1]
]
},
west: {
dir: [-1, 0, 0],
u0: [1, 0, 1],
v0: [0, 0, 1],
u1: [1, 0, 2],
v1: [0, 1, 1],
corners: [
[0, 1, 0, 0, 0],
[0, 0, 0, 0, 1],
[0, 1, 1, 1, 0],
[0, 0, 1, 1, 1]
]
},
north: {
dir: [0, 0, -1],
u0: [0, 0, 1],
v0: [0, 0, 1],
u1: [1, 0, 1],
v1: [0, 1, 1],
corners: [
[1, 0, 0, 0, 1],
[0, 0, 0, 1, 1],
[1, 1, 0, 0, 0],
[0, 1, 0, 1, 0]
]
},
south: {
dir: [0, 0, 1],
u0: [1, 0, 2],
v0: [0, 0, 1],
u1: [2, 0, 2],
v1: [0, 1, 1],
corners: [
[0, 0, 1, 0, 1],
[1, 0, 1, 1, 1],
[0, 1, 1, 0, 0],
[1, 1, 1, 1, 0]
]
}
}
function dot(a, b) {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
function addCube(attr, boneId, bone, cube, texWidth = 64, texHeight = 64) {
const cubeRotation = new THREE.Euler(0, 0, 0)
if (cube.rotation) {
cubeRotation.x = -cube.rotation[0] * Math.PI / 180
cubeRotation.y = -cube.rotation[1] * Math.PI / 180
cubeRotation.z = -cube.rotation[2] * Math.PI / 180
}
for (const { dir, corners, u0, v0, u1, v1 } of Object.values(elemFaces)) {
const ndx = Math.floor(attr.positions.length / 3)
for (const pos of corners) {
const u = (cube.uv[0] + dot(pos[3] ? u1 : u0, cube.size)) / texWidth
const v = (cube.uv[1] + dot(pos[4] ? v1 : v0, cube.size)) / texHeight
const inflate = cube.inflate ?? 0
let vecPos = new THREE.Vector3(
cube.origin[0] + pos[0] * cube.size[0] + (pos[0] ? inflate : -inflate),
cube.origin[1] + pos[1] * cube.size[1] + (pos[1] ? inflate : -inflate),
cube.origin[2] + pos[2] * cube.size[2] + (pos[2] ? inflate : -inflate)
)
vecPos = vecPos.applyEuler(cubeRotation)
vecPos = vecPos.sub(bone.position)
vecPos = vecPos.applyEuler(bone.rotation)
vecPos = vecPos.add(bone.position)
attr.positions.push(vecPos.x, vecPos.y, vecPos.z)
attr.normals.push(...dir)
attr.uvs.push(u, v)
attr.skinIndices.push(boneId, 0, 0, 0)
attr.skinWeights.push(1, 0, 0, 0)
}
attr.indices.push(ndx, ndx + 1, ndx + 2, ndx + 2, ndx + 1, ndx + 3)
}
}
function getMesh(texture, jsonModel, overrides = {}) {
const bones = {}
const geoData = {
positions: [],
normals: [],
uvs: [],
indices: [],
skinIndices: [],
skinWeights: []
}
let i = 0
for (const jsonBone of jsonModel.bones) {
const bone = new THREE.Bone()
if (jsonBone.pivot) {
bone.position.x = jsonBone.pivot[0]
bone.position.y = jsonBone.pivot[1]
bone.position.z = jsonBone.pivot[2]
}
if (jsonBone.bind_pose_rotation) {
bone.rotation.x = -jsonBone.bind_pose_rotation[0] * Math.PI / 180
bone.rotation.y = -jsonBone.bind_pose_rotation[1] * Math.PI / 180
bone.rotation.z = -jsonBone.bind_pose_rotation[2] * Math.PI / 180
} else if (jsonBone.rotation) {
bone.rotation.x = -jsonBone.rotation[0] * Math.PI / 180
bone.rotation.y = -jsonBone.rotation[1] * Math.PI / 180
bone.rotation.z = -jsonBone.rotation[2] * Math.PI / 180
}
if (overrides.rotation?.[jsonBone.name]) {
bone.rotation.x -= (overrides.rotation[jsonBone.name].x ?? 0) * Math.PI / 180
bone.rotation.y -= (overrides.rotation[jsonBone.name].y ?? 0) * Math.PI / 180
bone.rotation.z -= (overrides.rotation[jsonBone.name].z ?? 0) * Math.PI / 180
}
bone.name = `bone_${jsonBone.name}`
bones[jsonBone.name] = bone
if (jsonBone.cubes) {
for (const cube of jsonBone.cubes) {
addCube(geoData, i, bone, cube, jsonModel.texturewidth, jsonModel.textureheight)
}
}
i++
}
const rootBones = []
for (const jsonBone of jsonModel.bones) {
if (jsonBone.parent && bones[jsonBone.parent]) { bones[jsonBone.parent].add(bones[jsonBone.name]) } else {
rootBones.push(bones[jsonBone.name])
}
}
const skeleton = new THREE.Skeleton(Object.values(bones))
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(geoData.positions, 3))
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(geoData.normals, 3))
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(geoData.uvs, 2))
geometry.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(geoData.skinIndices, 4))
geometry.setAttribute('skinWeight', new THREE.Float32BufferAttribute(geoData.skinWeights, 4))
geometry.setIndex(geoData.indices)
const material = new THREE.MeshLambertMaterial({ transparent: true, alphaTest: 0.1 })
const mesh = new THREE.SkinnedMesh(geometry, material)
mesh.add(...rootBones)
mesh.bind(skeleton)
mesh.scale.set(1 / 16, 1 / 16, 1 / 16)
loadTexture(texture, texture => {
if (material.map) {
// texture is already loaded
return
}
texture.magFilter = THREE.NearestFilter
texture.minFilter = THREE.NearestFilter
texture.flipY = false
texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.RepeatWrapping
material.map = texture
})
return mesh
}
export const knownNotHandled = [
'area_effect_cloud', 'block_display',
'chest_boat', 'end_crystal',
'falling_block', 'furnace_minecart',
'giant', 'glow_item_frame',
'glow_squid', 'illusioner',
'interaction', 'item',
'item_display', 'item_frame',
'lightning_bolt', 'marker',
'painting', 'spawner_minecart',
'spectral_arrow', 'text_display',
'tnt', 'trader_llama', 'zombie_horse'
]
export const temporaryMap = {
'furnace_minecart': 'minecart',
'spawner_minecart': 'minecart',
'chest_minecart': 'minecart',
'hopper_minecart': 'minecart',
'command_block_minecart': 'minecart',
'tnt_minecart': 'minecart',
'glow_squid': 'squid',
'trader_llama': 'llama',
'chest_boat': 'boat',
'spectral_arrow': 'arrow',
'husk': 'zombie',
'zombie_horse': 'horse',
'donkey': 'horse',
'skeleton_horse': 'horse',
'mule': 'horse',
'ocelot': 'cat',
// 'falling_block': 'block',
// 'lightning_bolt': 'lightning',
}
const getEntity = (name) => {
return entities[name]
}
// const externalModelsTextures = {
// allay: 'allay/allay',
// axolotl: 'axolotl/axolotl_blue',
// blaze: 'blaze',
// camel: 'camel/camel',
// cat: 'cat/black',
// chicken: 'chicken',
// cod: 'fish/cod',
// creeper: 'creeper/creeper',
// dolphin: 'dolphin',
// ender_dragon: 'enderdragon/dragon',
// enderman: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAAABGdBTUEAALGPC/xhBQAAAY5JREFUaN7lWNESgzAI8yv8/z/tXjZPHSShYitb73rXedo1AQJ0WchY17WhudQZ7TS18Qb5AXtY/yUBO8tXIaCRqRNwXlcgwDJgmAALfBUP8AjYEdHnAZUIAGdvPy+CnobJIVw9DVIPEABawuEyyvYx1sMIMP8fAbUO7ukBImZmCCEP2AhglnRip8vio7MIxYEsaVkdeYNjYfbN/BBA1twP9AxpB0qlMwj48gBP5Ji1rXc8nfBImk6A5+KqShNwdTwgKy0xYRzdS4yoY651W8EDRwGVJEDVITGtjiEAaEBq3o4SwGqRVAKsdVYIsAzDCACV6VwCFMBCpqLvgudzQ6CnjL5afmeX4pdE0LIQuYCBzZbQfT4rC6COUQGn9B3MQ28pSIxDSDdNrKdQSZJ7lDurMeZm6iEjKVENh8cQgBowBFK5gEHhsO3xFA/oKXp6vg8RoHaD2QRkiaDnAYcZAcB+E6GTRVAhQCVJyVImKOUiBLW3KL4jzU2POHp64RIQ/ADO6D6Ry1gl9tlN1Xm+AK8s2jHadDijAAAAAElFTkSuQmCC',
// endermite: 'endermite',
// fox: 'fox/fox',
// frog: 'frog/cold_frog',
// ghast: 'ghast/ghast',
// goat: 'goat/goat',
// guardian: 'guardian',
// horse: 'horse/horse_brown',
// llama: 'llama/creamy',
// minecart: 'minecart',
// parrot: 'parrot/parrot_grey',
// piglin: 'piglin/piglin',
// pillager: 'illager/pillager',
// rabbit: 'rabbit/brown',
// sheep: 'sheep/sheep',
// shulker: 'shulker/shulker',
// sniffer: 'sniffer/sniffer',
// spider: 'spider/spider',
// tadpole: 'tadpole/tadpole',
// turtle: 'turtle/big_sea_turtle',
// vex: 'illager/vex',
// villager: 'villager/villager',
// warden: 'warden/warden',
// witch: 'witch',
// wolf: 'wolf/wolf',
// zombie_villager: 'zombie_villager/zombie_villager'
// }
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
export class EntityMesh {
constructor(version, type, scene, /** @type {{textures?, rotation?: Record<string, {x,y,z}>}} */overrides = {}) {
const originalType = type
const mappedValue = temporaryMap[type]
if (mappedValue) type = mappedValue
if (externalModels[type]) {
const objLoader = new OBJLoader()
let texturePath = externalTexturesJson[type]
if (originalType === 'zombie_horse') {
texturePath = `textures/${version}/entity/horse/horse_zombie.png`
}
if (originalType === 'skeleton_horse') {
texturePath = `textures/${version}/entity/horse/horse_skeleton.png`
}
if (originalType === 'donkey') {
texturePath = `textures/${version}/entity/horse/donkey.png`
}
if (originalType === 'mule') {
texturePath = `textures/${version}/entity/horse/mule.png`
}
if (originalType === 'ocelot') {
texturePath = `textures/${version}/entity/cat/ocelot.png`
}
if (!texturePath) throw new Error(`No texture for ${type}`)
const texture = new THREE.TextureLoader().load(texturePath)
texture.minFilter = THREE.NearestFilter
texture.magFilter = THREE.NearestFilter
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
alphaTest: 0.1
})
const obj = objLoader.parse(externalModels[type])
if (type === 'boat') obj.position.y = -1 // todo, should not be hardcoded
obj.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material = material
// todo
if (child.name === 'Head layer') child.visible = false
if (child.name === 'Head' && overrides.rotation?.head) { // todo
child.rotation.x -= (overrides.rotation.head.x ?? 0) * Math.PI / 180
child.rotation.y -= (overrides.rotation.head.y ?? 0) * Math.PI / 180
child.rotation.z -= (overrides.rotation.head.z ?? 0) * Math.PI / 180
}
}
})
this.mesh = obj
return
}
const e = getEntity(type)
if (!e) {
if (knownNotHandled.includes(type)) return
throw new Error(`Unknown entity ${type}`)
}
this.mesh = new THREE.Object3D()
for (const [name, jsonModel] of Object.entries(e.geometry)) {
const texture = overrides.textures?.[name] ?? e.textures[name]
if (!texture) continue
// console.log(JSON.stringify(jsonModel, null, 2))
const mesh = getMesh(texture + '.png', jsonModel, overrides)
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)
}
}
static getStaticData(name) {
name = temporaryMap[name] || name
if (externalModels[name]) {
return {
boneNames: [] // todo
}
}
const e = getEntity(name)
if (!e) throw new Error(`Unknown entity ${name}`)
return {
boneNames: Object.values(e.geometry).flatMap(x => x.name)
}
}
}

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

File diff suppressed because it is too large Load diff

View file

@ -22,8 +22,7 @@ export { default as parrot } from './models/parrot.obj'
export { default as piglin } from './models/piglin.obj'
export { default as pillager } from './models/pillager.obj'
export { default as rabbit } from './models/rabbit.obj'
export { default as sheep } from './models/sheep.obj'
export { default as arrow } from './models/arrow.obj'
// export { default as sheep } from './models/sheep.obj'
export { default as shulker } from './models/shulker.obj'
export { default as sniffer } from './models/sniffer.obj'
export { default as spider } from './models/spider.obj'
@ -35,4 +34,5 @@ export { default as warden } from './models/warden.obj'
export { default as witch } from './models/witch.obj'
export { default as wolf } from './models/wolf.obj'
export { default as zombie_villager } from './models/zombie_villager.obj'
export { default as zombie } from './models/zombie.obj'
export { default as boat } from './models/boat.obj'

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