Compare commits
5 commits
next
...
ci-report-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa793e3459 |
||
|
|
258bda6506 | ||
|
|
1b98c925bf | ||
|
|
97a1ac909d | ||
|
|
0e9fddff8b |
270 changed files with 9637 additions and 21107 deletions
|
|
@ -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.
|
||||
|
|
@ -23,7 +23,6 @@
|
|||
// ],
|
||||
"@stylistic/arrow-spacing": "error",
|
||||
"@stylistic/block-spacing": "error",
|
||||
"@typescript-eslint/no-this-alias": "off",
|
||||
"@stylistic/brace-style": [
|
||||
"error",
|
||||
"1tbs",
|
||||
|
|
|
|||
59
.github/workflows/benchmark.yml
vendored
59
.github/workflows/benchmark.yml
vendored
|
|
@ -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 }}
|
||||
2
.github/workflows/build-single-file.yml
vendored
2
.github/workflows/build-single-file.yml
vendored
|
|
@ -23,8 +23,6 @@ jobs:
|
|||
|
||||
- name: Build single-file version - minecraft.html
|
||||
run: pnpm build-single-file && mv dist/single/index.html minecraft.html
|
||||
env:
|
||||
LOCAL_CONFIG_FILE: config.mcraft-only.json
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
|
|
|||
2
.github/workflows/build-zip.yml
vendored
2
.github/workflows/build-zip.yml
vendored
|
|
@ -23,8 +23,6 @@ jobs:
|
|||
|
||||
- name: Build project
|
||||
run: pnpm build
|
||||
env:
|
||||
LOCAL_CONFIG_FILE: config.mcraft-only.json
|
||||
|
||||
- name: Bundle server.js
|
||||
run: |
|
||||
|
|
|
|||
57
.github/workflows/bundle-stats.yml
vendored
Normal file
57
.github/workflows/bundle-stats.yml
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
name: Bundle Stats
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
mode:
|
||||
required: true
|
||||
type: string
|
||||
description: "'store' or 'compare'"
|
||||
branch:
|
||||
required: true
|
||||
type: string
|
||||
outputs:
|
||||
stats:
|
||||
description: "Bundle stats comparison"
|
||||
value: ${{ jobs.bundle-stats.outputs.stats }}
|
||||
|
||||
jobs:
|
||||
bundle-stats:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
stats: ${{ steps.gist-ops.outputs.stats }}
|
||||
steps:
|
||||
- uses: actions/github-script@v6
|
||||
id: gist-ops
|
||||
with:
|
||||
script: |
|
||||
const gistId = '${{ secrets.BUNDLE_STATS_GIST_ID }}';
|
||||
|
||||
async function getGistContent() {
|
||||
const { data } = await github.rest.gists.get({ gist_id: gistId });
|
||||
return JSON.parse(data.files['bundle-stats.json'].content || '{}');
|
||||
}
|
||||
|
||||
async function updateGistContent(content) {
|
||||
await github.rest.gists.update({
|
||||
gist_id: gistId,
|
||||
files: {
|
||||
'bundle-stats.json': {
|
||||
content: JSON.stringify(content, null, 2)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ('${{ inputs.mode }}' === 'store') {
|
||||
const stats = require('/tmp/bundle-stats.json');
|
||||
const content = await getGistContent();
|
||||
content['${{ inputs.branch }}'] = stats;
|
||||
await updateGistContent(content);
|
||||
} else {
|
||||
const content = await getGistContent();
|
||||
const baseStats = content['${{ inputs.branch }}'];
|
||||
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);
|
||||
}
|
||||
148
.github/workflows/ci.yml
vendored
148
.github/workflows/ci.yml
vendored
|
|
@ -20,11 +20,6 @@ jobs:
|
|||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- run: pnpm install
|
||||
- run: pnpm build-single-file
|
||||
- name: Store minecraft.html size
|
||||
run: |
|
||||
SIZE_BYTES=$(du -s dist/single/minecraft.html 2>/dev/null | cut -f1)
|
||||
echo "SIZE_BYTES=$SIZE_BYTES" >> $GITHUB_ENV
|
||||
- run: pnpm check-build
|
||||
- name: Create zip package for size comparison
|
||||
run: |
|
||||
|
|
@ -32,44 +27,11 @@ jobs:
|
|||
cp -r dist package/
|
||||
cd package
|
||||
zip -r ../self-host.zip .
|
||||
- run: pnpm build-single-file
|
||||
- run: pnpm build-playground
|
||||
# - run: pnpm build-storybook
|
||||
- run: pnpm build-storybook
|
||||
- run: pnpm test-unit
|
||||
- run: pnpm lint
|
||||
|
||||
- name: Parse Bundle Stats
|
||||
run: |
|
||||
GZIP_BYTES=$(du -s self-host.zip 2>/dev/null | cut -f1)
|
||||
SIZE=$(echo "scale=2; $SIZE_BYTES/1024/1024" | bc)
|
||||
GZIP_SIZE=$(echo "scale=2; $GZIP_BYTES/1024/1024" | bc)
|
||||
echo "{\"total\": ${SIZE}, \"gzipped\": ${GZIP_SIZE}}" > /tmp/bundle-stats.json
|
||||
|
||||
# - name: Compare Bundle Stats
|
||||
# id: compare
|
||||
# uses: actions/github-script@v6
|
||||
# env:
|
||||
# GITHUB_TOKEN: ${{ secrets.GIST_TOKEN }}
|
||||
# with:
|
||||
# script: |
|
||||
# const gistId = '${{ secrets.BUNDLE_STATS_GIST_ID }}';
|
||||
|
||||
# async function getGistContent() {
|
||||
# const { data } = await github.rest.gists.get({
|
||||
# gist_id: gistId,
|
||||
# headers: {
|
||||
# authorization: `token ${process.env.GITHUB_TOKEN}`
|
||||
# }
|
||||
# });
|
||||
# return JSON.parse(data.files['bundle-stats.json'].content || '{}');
|
||||
# }
|
||||
|
||||
# const content = await getGistContent();
|
||||
# const baseStats = content['${{ github.event.pull_request.base.ref }}'];
|
||||
# const newStats = require('/tmp/bundle-stats.json');
|
||||
|
||||
# const comparison = `minecraft.html (normal build gzip)\n${baseStats.total}MB (${baseStats.gzipped}MB compressed) -> ${newStats.total}MB (${newStats.gzipped}MB compressed)`;
|
||||
# core.setOutput('stats', comparison);
|
||||
|
||||
# - run: pnpm tsx scripts/buildNpmReact.ts
|
||||
- run: nohup pnpm prod-start &
|
||||
- run: nohup pnpm test-mc-server &
|
||||
|
|
@ -86,73 +48,57 @@ jobs:
|
|||
# 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 }}';
|
||||
- name: Parse Bundle Stats
|
||||
run: |
|
||||
SIZE_BYTES=$(du -s dist/single/minecraft.html 2>/dev/null | cut -f1)
|
||||
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
|
||||
|
||||
# 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 || '{}');
|
||||
# }
|
||||
- name: Compare Bundle Stats
|
||||
id: compare
|
||||
uses: ./.github/workflows/bundle-stats
|
||||
with:
|
||||
mode: compare
|
||||
branch: ${{ github.event.pull_request.base.ref }}
|
||||
|
||||
# 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)
|
||||
# }
|
||||
# }
|
||||
# });
|
||||
# }
|
||||
- name: Store Bundle Stats
|
||||
if: github.event.pull_request.base.ref == 'next'
|
||||
uses: ./.github/workflows/bundle-stats
|
||||
with:
|
||||
mode: store
|
||||
branch: ${{ github.event.pull_request.base.ref }}
|
||||
|
||||
# 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
|
||||
});
|
||||
|
||||
# - 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 }}';
|
||||
|
||||
# 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}`;
|
||||
}
|
||||
|
||||
# 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
|
||||
# });
|
||||
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'
|
||||
|
|
|
|||
6
.github/workflows/next-deploy.yml
vendored
6
.github/workflows/next-deploy.yml
vendored
|
|
@ -30,18 +30,18 @@ jobs:
|
|||
- name: Write Release Info
|
||||
run: |
|
||||
echo "{\"latestTag\": \"$(git rev-parse --short $GITHUB_SHA)\", \"isCommit\": true}" > assets/release.json
|
||||
- name: Download Generated Sounds map
|
||||
run: node scripts/downloadSoundsMap.mjs
|
||||
- name: Build Project Artifacts
|
||||
run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
|
||||
env:
|
||||
CONFIG_JSON_SOURCE: BUNDLED
|
||||
LOCAL_CONFIG_FILE: config.mcraft-only.json
|
||||
- run: pnpm build-storybook
|
||||
- name: Copy playground files
|
||||
run: |
|
||||
mkdir -p .vercel/output/static/playground
|
||||
pnpm build-playground
|
||||
cp -r renderer/dist/* .vercel/output/static/playground/
|
||||
- name: Download Generated Sounds map
|
||||
run: node scripts/downloadSoundsMap.mjs
|
||||
- name: Deploy Project Artifacts to Vercel
|
||||
uses: mathiasvr/command-output@v2.0.0
|
||||
with:
|
||||
|
|
|
|||
8
.github/workflows/preview.yml
vendored
8
.github/workflows/preview.yml
vendored
|
|
@ -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 }}
|
||||
|
|
@ -72,13 +72,11 @@ jobs:
|
|||
- name: Write Release Info
|
||||
run: |
|
||||
echo "{\"latestTag\": \"$(git rev-parse --short ${{ github.event.pull_request.head.sha }})\", \"isCommit\": true}" > assets/release.json
|
||||
- name: Download Generated Sounds map
|
||||
run: node scripts/downloadSoundsMap.mjs
|
||||
- name: Build Project Artifacts
|
||||
run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
|
||||
env:
|
||||
CONFIG_JSON_SOURCE: BUNDLED
|
||||
LOCAL_CONFIG_FILE: config.mcraft-only.json
|
||||
- run: pnpm build-storybook
|
||||
- name: Copy playground files
|
||||
run: |
|
||||
mkdir -p .vercel/output/static/playground
|
||||
|
|
@ -92,6 +90,8 @@ jobs:
|
|||
run: |
|
||||
mkdir -p .vercel/output/static/commit
|
||||
echo "<meta http-equiv='refresh' content='0;url=https://github.com/${{ github.repository }}/pull/${{ github.event.issue.number || github.event.pull_request.number }}/commits/${{ github.event.pull_request.head.sha }}'>" > .vercel/output/static/commit/index.html
|
||||
- name: Download Generated Sounds map
|
||||
run: node scripts/downloadSoundsMap.mjs
|
||||
- name: Deploy Project Artifacts to Vercel
|
||||
uses: mathiasvr/command-output@v2.0.0
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -29,18 +29,22 @@ jobs:
|
|||
run: pnpx zardoy-release empty --skip-github --output-file assets/release.json
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Download Generated Sounds map
|
||||
run: node scripts/downloadSoundsMap.mjs
|
||||
- run: vercel build --token=${{ secrets.VERCEL_TOKEN }} --prod
|
||||
env:
|
||||
CONFIG_JSON_SOURCE: BUNDLED
|
||||
LOCAL_CONFIG_FILE: config.mcraft-only.json
|
||||
- run: pnpm build-storybook
|
||||
- name: Copy playground files
|
||||
run: |
|
||||
mkdir -p .vercel/output/static/playground
|
||||
pnpm build-playground
|
||||
cp -r renderer/dist/* .vercel/output/static/playground/
|
||||
|
||||
- name: Download Generated Sounds map
|
||||
run: node scripts/downloadSoundsMap.mjs
|
||||
- name: Deploy Project to Vercel
|
||||
uses: mathiasvr/command-output@v2.0.0
|
||||
with:
|
||||
run: vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }} --prod
|
||||
id: deploy
|
||||
# publish to github
|
||||
- run: cp vercel.json .vercel/output/static/vercel.json
|
||||
- uses: peaceiris/actions-gh-pages@v3
|
||||
|
|
@ -49,39 +53,6 @@ jobs:
|
|||
publish_dir: .vercel/output/static
|
||||
force_orphan: true
|
||||
|
||||
# Create CNAME file for custom domain
|
||||
- name: Create CNAME file
|
||||
run: echo "github.mcraft.fun" > .vercel/output/static/CNAME
|
||||
|
||||
- name: Deploy to mwc-mcraft-pages repository
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
personal_token: ${{ secrets.MCW_MCRAFT_PAGE_DEPLOY_TOKEN }}
|
||||
external_repository: ${{ github.repository_owner }}/mwc-mcraft-pages
|
||||
publish_dir: .vercel/output/static
|
||||
publish_branch: main
|
||||
destination_dir: docs
|
||||
force_orphan: true
|
||||
|
||||
- name: Change index.html title
|
||||
run: |
|
||||
# change <title>Minecraft Web Client</title> to <title>Minecraft Web Client — Free Online Browser Version</title>
|
||||
sed -i 's/<title>Minecraft Web Client<\/title>/<title>Minecraft Web Client — Free Online Browser Version<\/title>/' .vercel/output/static/index.html
|
||||
|
||||
- name: Deploy Project to Vercel
|
||||
uses: mathiasvr/command-output@v2.0.0
|
||||
with:
|
||||
run: vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }} --prod
|
||||
id: deploy
|
||||
- name: Get releasing alias
|
||||
run: node scripts/githubActions.mjs getReleasingAlias
|
||||
id: alias
|
||||
- name: Set deployment alias
|
||||
run: |
|
||||
for alias in $(echo ${{ steps.alias.outputs.alias }} | tr "," "\n"); do
|
||||
vercel alias set ${{ steps.deploy.outputs.stdout }} $alias --token=${{ secrets.VERCEL_TOKEN }} --scope=zaro
|
||||
done
|
||||
|
||||
- name: Build single-file version - minecraft.html
|
||||
run: pnpm build-single-file && mv dist/single/index.html minecraft.html
|
||||
- name: Build self-host version
|
||||
|
|
@ -99,7 +70,7 @@ jobs:
|
|||
zip -r ../self-host.zip .
|
||||
|
||||
- run: |
|
||||
pnpx zardoy-release node --footer "This release URL: https://$(echo ${{ steps.alias.outputs.alias }} | cut -d',' -f1) (Vercel URL: ${{ steps.deploy.outputs.stdout }})"
|
||||
pnpx zardoy-release node --footer "This release URL: ${{ steps.deploy.outputs.stdout }}"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# has possible output: tag
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -19,6 +19,5 @@ generated
|
|||
storybook-static
|
||||
server-jar
|
||||
config.local.json
|
||||
logs/
|
||||
|
||||
src/react/npmReactComponents.ts
|
||||
|
|
|
|||
|
|
@ -9,10 +9,8 @@ After forking the repository, run the following commands to get started:
|
|||
4. Let us know if you are working on something and be sure to open a PR if you got any changes. Happy coding!
|
||||
|
||||
*(1): If you are getting `Cannot find matching keyid` update corepack to the latest version with `npm i -g corepack`.
|
||||
|
||||
*(2): If still something doesn't work ensure you have the right nodejs version with `node -v` (tested on 22.x)
|
||||
|
||||
<!-- *(3): For GitHub codespaces (cloud ide): Run `pnpm i @rsbuild/core@1.2.4 @rsbuild/plugin-node-polyfill@1.3.0 @rsbuild/plugin-react@1.1.0 @rsbuild/plugin-typed-css-modules@1.0.2` command to avoid crashes because of limited ram -->
|
||||
*(3): For GitHub codespaces (cloud ide): Run `pnpm i @rsbuild/core@1.2.4 @rsbuild/plugin-node-polyfill@1.3.0 @rsbuild/plugin-react@1.1.0 @rsbuild/plugin-typed-css-modules@1.0.2` command to avoid crashes because of limited ram
|
||||
|
||||
## Project Structure
|
||||
|
||||
|
|
@ -177,13 +175,8 @@ New React components, improve UI (including mobile support).
|
|||
|
||||
## Updating Dependencies
|
||||
|
||||
1. Use `pnpm update-git-deps` to check and update git dependencies (like mineflayer fork, prismarine packages etc). The script will:
|
||||
- Show which git dependencies have updates available
|
||||
- Ask if you want to update them
|
||||
- Skip dependencies listed in `pnpm.updateConfig.ignoreDependencies`
|
||||
|
||||
1. Ensure mineflayer fork is up to date with the latest version of mineflayer original repo
|
||||
2. Update PrismarineJS dependencies to the latest version: `minecraft-data` (be sure to replace the version twice in the package.json), `mineflayer`, `minecraft-protocol`, `prismarine-block`, `prismarine-chunk`, `prismarine-item`, ...
|
||||
|
||||
3. If `minecraft-protocol` patch fails, do this:
|
||||
1. Remove the patch from `patchedDependencies` in `package.json`
|
||||
2. Run `pnpm patch minecraft-protocol`, open patch directory
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ FROM node:18-alpine AS build
|
|||
RUN apk add git
|
||||
WORKDIR /app
|
||||
COPY . /app
|
||||
# install pnpm with corepack
|
||||
RUN corepack enable
|
||||
# install pnpm
|
||||
RUN npm i -g pnpm@9.0.4
|
||||
# Build arguments
|
||||
ARG DOWNLOAD_SOUNDS=false
|
||||
ARG DISABLE_SERVICE_WORKER=false
|
||||
|
|
@ -35,7 +35,7 @@ WORKDIR /app
|
|||
COPY --from=build /app/dist /app/dist
|
||||
COPY server.js /app/server.js
|
||||
# Install express
|
||||
RUN npm i -g pnpm@10.8.0
|
||||
RUN npm i -g pnpm@9.0.4
|
||||
RUN npm init -yp
|
||||
RUN pnpm i express github:zardoy/prismarinejs-net-browserify compression cors
|
||||
EXPOSE 8080
|
||||
|
|
|
|||
39
README.MD
39
README.MD
|
|
@ -6,17 +6,12 @@ Minecraft **clone** rewritten in TypeScript using the best modern web technologi
|
|||
|
||||
You can try this out at [mcraft.fun](https://mcraft.fun/), [pcm.gg](https://pcm.gg) (short link), [mcon.vercel.app](https://mcon.vercel.app/) or the GitHub pages deploy. Every commit from the default (`develop`) branch is deployed to [s.mcraft.fun](https://s.mcraft.fun/) and [s.pcm.gg](https://s.pcm.gg/) - so it's usually newer, but might be less stable.
|
||||
|
||||
> For Turkey/Russia use [ru.mcraft.fun](https://ru.mcraft.fun/) (since Cloudflare is blocked)
|
||||
Don't confuse with [Eaglercraft](https://git.eaglercraft.rip/eaglercraft/eaglercraft-1.8) which is a REAL vanilla Minecraft Java edition port to the web (but with its own limitations). Eaglercraft is a fully playable solution, but this project is more in position of a "technical demo" to show how it's possible to build games for web at scale entirely with the JS ecosystem. Have fun!
|
||||
|
||||
Don't confuse with [Eaglercraft](https://git.eaglercraft.rip/eaglercraft/eaglercraft-1.8) which is a REAL vanilla Minecraft Java edition port to the web (but with its own limitations). Eaglercraft is a fully playable solution, meanwhile this project is aimed for *device-compatiiblity* and better performance so it feels portable, flexible and lightweight. It's also a very strong example on how to build true HTML games for the web at scale entirely with the JS ecosystem. Have fun!
|
||||
|
||||
For building the project yourself / contributing, see [Development, Debugging & Contributing](#development-debugging--contributing). For reference at what and how web technologies / frameworks are used, see [TECH.md](./TECH.md) (also for comparison with Eaglercraft).
|
||||
|
||||
> **Note**: You can deploy it on your own server in less than a minute using a one-liner script from [Minecraft Everywhere repo](https://github.com/zardoy/minecraft-everywhere)
|
||||
For building the project yourself / contributing, see [Development, Debugging & Contributing](#development-debugging--contributing). For reference at what and how web technologies / frameworks are used, see [TECH.md](./TECH.md).
|
||||
|
||||
### Big Features
|
||||
|
||||
- Official Mineflayer [plugin integration](https://github.com/zardoy/mcraft-fun-mineflayer-plugin)! View / Control your bot remotely.
|
||||
- Open any zip world file or even folder in read-write mode!
|
||||
- Connect to Java servers running in both offline (cracked) and online mode* (it's possible because of proxy servers, see below)
|
||||
- Integrated JS server clone capable of opening Java world saves in any way (folders, zip, web chunks streaming, etc)
|
||||
|
|
@ -32,36 +27,22 @@ For building the project yourself / contributing, see [Development, Debugging &
|
|||
- Support for custom rendering 3D engines. Modular architecture.
|
||||
- even even more!
|
||||
|
||||
All components that are in [Storybook](https://minimap.mcraft.fun/storybook/) are published as npm module and can be used in other projects: [`minecraft-react`](https://npmjs.com/minecraft-react)
|
||||
All components that are in [Storybook](https://mcraft.fun/storybook) are published as npm module and can be used in other projects: [`minecraft-react`](https://npmjs.com/minecraft-react)
|
||||
|
||||
### Recommended Settings
|
||||
|
||||
- Controls -> **Touch Controls Type** -> **Joystick**
|
||||
- Controls -> **Auto Full Screen** -> **On** - To avoid ctrl+w issue
|
||||
- Interface -> **Enable Minimap** -> **Always** - To enable useful minimap (why not?)
|
||||
- Controls -> **Raw Input** -> **On** - This will make the controls more precise (UPD: already enabled by default)
|
||||
- Interface -> **Chat Select** -> **On** - To select chat messages (UPD: already enabled by default)
|
||||
|
||||
### Browser Notes
|
||||
|
||||
This project is tested with BrowserStack. Special thanks to [BrowserStack](https://www.browserstack.com/) for providing testing infrastructure!
|
||||
|
||||
Howerver, it's known that these browsers have issues:
|
||||
These browsers have issues with capturing pointer:
|
||||
|
||||
**Opera Mini**: Disable *mouse gestures* in browsre settings to avoid opening new tab on right click hold
|
||||
|
||||
**Vivaldi**: Disable Controls -> *Raw Input* in game settings if experiencing issues
|
||||
|
||||
### Versions Support
|
||||
|
||||
Server versions 1.8 - 1.21.5 are supported.
|
||||
First class versions (most of the features are tested on these versions):
|
||||
|
||||
- 1.19.4
|
||||
- 1.21.4
|
||||
|
||||
Versions below 1.13 are not tested currently and may not work correctly.
|
||||
|
||||
### World Loading
|
||||
|
||||
Zip files and folders are supported. Just drag and drop them into the browser window. You can open folders in readonly and read-write mode. New chunks may be generated incorrectly for now.
|
||||
|
|
@ -78,8 +59,6 @@ There is a builtin proxy, but you can also host your one! Just clone the repo, r
|
|||
|
||||
[](https://app.koyeb.com/deploy?name=minecraft-web-client&type=git&repository=zardoy%2Fminecraft-web-client&branch=next&builder=dockerfile&env%5B%5D=&ports=8080%3Bhttp%3B%2F)
|
||||
|
||||
> **Note**: If you want to make **your own** Minecraft server accessible to web clients (without our proxies), you can use [mwc-proxy](https://github.com/zardoy/mwc-proxy) - a lightweight JS WebSocket proxy that runs on the same server as your Minecraft server, allowing players to connect directly via `wss://play.example.com`. `?client_mcraft` is added to the URL, so the proxy will know that it's this client.
|
||||
|
||||
Proxy servers are used to connect to Minecraft servers which use TCP protocol. When you connect connect to a server with a proxy, websocket connection is created between you (browser client) and the proxy server located in Europe, then the proxy connects to the Minecraft server and sends the data to the client (you) without any packet deserialization to avoid any additional delays. That said all the Minecraft protocol packets are processed by the client, right in your browser.
|
||||
|
||||
```mermaid
|
||||
|
|
@ -127,12 +106,12 @@ There is world renderer playground ([link](https://mcon.vercel.app/playground/))
|
|||
|
||||
However, there are many things that can be done in online production version (like debugging actual source code). Also you can access some global variables in the console and there are a few useful examples:
|
||||
|
||||
- If you type `debugToggle`, press enter in console - It will enables all debug messages! Warning: this will start all packets spam.
|
||||
- `localStorage.debug = '*'` - Enables all debug messages! Warning: this will start all packets spam.
|
||||
Instead I recommend setting `options.debugLogNotFrequentPackets`. Also you can use `debugTopPackets` (with JSON.stringify) to see what packets were received/sent by name
|
||||
|
||||
- `bot` - Mineflayer bot instance. See Mineflayer documentation for more.
|
||||
- `world` - Three.js world instance, basically does all the rendering (part of renderer backend).
|
||||
- `world.sectionObjects` - Object with all active chunk sections (geometries) in the world. Each chunk section is a Three.js mesh or group.
|
||||
- `viewer` - Three.js viewer instance, basically does all the rendering.
|
||||
- `viewer.world.sectionObjects` - Object with all active chunk sections (geometries) in the world. Each chunk section is a Three.js mesh or group.
|
||||
- `debugSceneChunks` - The same as above, but relative to current bot position (e.g. 0,0 is the current chunk).
|
||||
- `debugChangedOptions` - See what options are changed. Don't change options here.
|
||||
- `localServer`/`server` - Only for singleplayer mode/host. Flying Squid server instance, see it's documentation for more.
|
||||
|
|
@ -141,7 +120,7 @@ Instead I recommend setting `options.debugLogNotFrequentPackets`. Also you can u
|
|||
|
||||
- `nbt.simplify(someNbt)` - Simplifies nbt data, so it's easier to read.
|
||||
|
||||
The most useful thing in devtools is the watch expression. You can add any expression there and it will be re-evaluated in real time. For example, you can add `world.getCameraPosition()` to see the camera position and so on.
|
||||
The most useful thing in devtools is the watch expression. You can add any expression there and it will be re-evaluated in real time. For example, you can add `viewer.camera.position` to see the camera position and so on.
|
||||
|
||||
<img src="./docs-assets/watch-expr.png" alt="Watch expression" width="480"/>
|
||||
|
||||
|
|
@ -178,7 +157,6 @@ Server specific:
|
|||
- `?lockConnect=true` - Only works then `ip` parameter is set. Disables cancel/save buttons and all inputs in the connect screen already set as parameters. Useful for integrates iframes.
|
||||
- `?autoConnect=true` - Only works then `ip` and `version` parameters are set and `allowAutoConnect` is `true` in config.json! Directly connects to the specified server. Useful for integrates iframes.
|
||||
- `?serversList=<list_or_url>` - `<list_or_url>` can be a list of servers in the format `ip:version,ip` or a url to a json file with the same format (array) or a txt file with line-delimited list of server IPs.
|
||||
- `?addPing=<ping>` - Add a latency to both sides of the connection. Useful for testing ping issues. For example `?addPing=100` will add 200ms to your ping.
|
||||
|
||||
Single player specific:
|
||||
|
||||
|
|
@ -235,4 +213,3 @@ Only during development:
|
|||
|
||||
- [https://github.com/ClassiCube/ClassiCube](ClassiCube - Better C# Rewrite) [DEMO](https://www.classicube.net/server/play/?warned=true)
|
||||
- [https://m.eaglercraft.com/](EaglerCraft) - Eaglercraft runnable on mobile (real Minecraft in the browser)
|
||||
- [js-minecraft](https://github.com/LabyStudio/js-minecraft) - An insanely well done clone from the graphical side that inspired many features here
|
||||
|
|
|
|||
13
TECH.md
13
TECH.md
|
|
@ -10,27 +10,26 @@ This client generally has better performance but some features reproduction migh
|
|||
| Gamepad Support | ✅ | ❌ | |
|
||||
| A11Y | ✅ | ❌ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page (but maybe it's not needed) |
|
||||
| Game Features | | | |
|
||||
| Servers Support (quality) | ❌(+) | ✅ | Eaglercraft is vanilla Minecraft, while this project tries to emulate original game behavior at protocol level (Mineflayer is used) |
|
||||
| Servers Support (quality) | ❌ | ✅ | Eaglercraft is vanilla Minecraft, while this project tries to emulate original game behavior at protocol level (Mineflayer is used) |
|
||||
| Servers Support (any version, ip) | ✅ | ❌ | We support almost all Minecraft versions, only important if you connect to a server where you need new content like blocks or if you play with friends. And you can connect to almost any server using proxy servers! |
|
||||
| Servers Support (online mode) | ✅ | ❌ | Join to online servers like Hypixel using your Microsoft account without additional proxies |
|
||||
| Singleplayer Survival Features | ❌ | ✅ | Just like Eaglercraft this project can generate and save worlds, but generator is simple and only a few survival features are supported (look here for [supported features list](https://github.com/zardoy/space-squid)) |
|
||||
| Singleplayer Maps | ✅ | ✅ | We support any version, but adventure maps won't work, but simple parkour and build maps might be interesting to explore... |
|
||||
| Singleplayer Maps World Streaming | ✅ | ❌ | Thanks to Browserfs, saves can be loaded to local singleplayer server using multiple ways: from local folder, server directory (not zip), dropbox or other cloud *backend* etc... |
|
||||
| P2P Multiplayer | ✅ | ✅ | A way to connect to other browser running the project. But it's almost useless here since many survival features are not implemented. Maybe only to build / explore maps together... |
|
||||
| Voice Chat | ❌(+) | ✅ | Eaglercraft has custom WebRTC voice chat implementation, though it could also be easily implemented there |
|
||||
| Voice Chat | ❌ | ✅ | Eaglercraft has custom WebRTC voice chat implementation, though it could also be easily implemented there |
|
||||
| Online Servers | ✅ | ❌ | We have custom implementation (including integration on proxy side) for joining to servers |
|
||||
| Plugin Features | ✅ | ❌ | We have Mineflayer plugins support, like Auto Jump & Auto Parkour was added here that way |
|
||||
| Direct Connection | ✅ | ✅ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page |
|
||||
| Moding | ✅(own js mods) | ❌ | This project will support mods for singleplayer. In theory its possible to implement support for modded servers on protocol level (including all needed mods) |
|
||||
| Video Recording | ❌ | ✅ | Doesn't feel needed |
|
||||
| Metaverse Features | ✅(50%) | ❌ | We have videos / images support inside world, but not iframes (custom protocol channel) |
|
||||
| Moding | ❌(roadmap, client-side) | ❌ | This project will support mods for singleplayer. In theory its possible to implement support for modded servers on protocol level (including all needed mods) |
|
||||
| Video Recording | ❌ | ✅ | Don't feel needed |
|
||||
| Metaverse Features | ❌(roadmap) | ❌ | Iframes, video streams inside of game world (custom protocol channel) |
|
||||
| Sounds | ✅ | ✅ | |
|
||||
| Resource Packs | ✅(+extras) | ✅ | This project has very limited support for them (only textures images are loadable for now) |
|
||||
| Assets Compressing & Splitting | ✅ | ❌ | We have advanced Minecraft data processing and good code chunk splitting so the web app will open much faster and use less memory |
|
||||
| Graphics | | | |
|
||||
| Fancy Graphics | ❌ | ✅ | While Eaglercraft has top-level shaders we don't even support lighting |
|
||||
| Fast & Efficient Graphics | ❌(+) | ❌ | Feels like no one needs to have 64 rendering distance work smoothly |
|
||||
| VR | ✅(-) | ❌ | Feels like not needed feature. UI is missing in this project since DOM can't be rendered in VR so Eaglercraft could be better in that aspect |
|
||||
| VR | ✅ | ❌ | Feels like not needed feature. UI is missing in this project since DOM can't be rendered in VR so Eaglercraft could be better in that aspect |
|
||||
| AR | ❌ | ❌ | Would be the most useless feature |
|
||||
| Minimap & Waypoints | ✅(-) | ❌ | We have buggy minimap, which can be enabled in settings and full map is opened by pressing `M` key |
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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/
|
||||
|
|
@ -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>
|
||||
46
config.json
46
config.json
|
|
@ -3,17 +3,12 @@
|
|||
"defaultHost": "<from-proxy>",
|
||||
"defaultProxy": "https://proxy.mcraft.fun",
|
||||
"mapsProvider": "https://maps.mcraft.fun/",
|
||||
"skinTexturesProxy": "",
|
||||
"peerJsServer": "",
|
||||
"peerJsServerFallback": "https://p2p.mcraft.fun",
|
||||
"promoteServers": [
|
||||
{
|
||||
"ip": "wss://play.mcraft.fun"
|
||||
},
|
||||
{
|
||||
"ip": "wss://play.webmc.fun",
|
||||
"name": "WebMC"
|
||||
},
|
||||
{
|
||||
"ip": "wss://ws.fuchsmc.net"
|
||||
},
|
||||
|
|
@ -21,8 +16,8 @@
|
|||
"ip": "wss://play2.mcraft.fun"
|
||||
},
|
||||
{
|
||||
"ip": "wss://play-creative.mcraft.fun",
|
||||
"description": "Might be available soon, stay tuned!"
|
||||
"ip": "wss://mcraft.ryzyn.xyz",
|
||||
"version": "1.19.4"
|
||||
},
|
||||
{
|
||||
"ip": "kaboom.pw",
|
||||
|
|
@ -30,9 +25,6 @@
|
|||
"description": "Very nice a polite server. Must try for everyone!"
|
||||
}
|
||||
],
|
||||
"rightSideText": "A Minecraft client clone in the browser!",
|
||||
"splashText": "The sunset is coming!",
|
||||
"splashTextFallback": "Welcome!",
|
||||
"pauseLinks": [
|
||||
[
|
||||
{
|
||||
|
|
@ -42,39 +34,5 @@
|
|||
"type": "discord"
|
||||
}
|
||||
]
|
||||
],
|
||||
"defaultUsername": "mcrafter{0-9999}",
|
||||
"mobileButtons": [
|
||||
{
|
||||
"action": "general.drop",
|
||||
"actionHold": "general.dropStack",
|
||||
"label": "Q"
|
||||
},
|
||||
{
|
||||
"action": "general.selectItem",
|
||||
"actionHold": "",
|
||||
"label": "S"
|
||||
},
|
||||
{
|
||||
"action": "general.debugOverlay",
|
||||
"actionHold": "general.debugOverlayHelpMenu",
|
||||
"label": "F3"
|
||||
},
|
||||
{
|
||||
"action": "general.playersList",
|
||||
"actionHold": "",
|
||||
"icon": "pixelarticons:users",
|
||||
"label": "TAB"
|
||||
},
|
||||
{
|
||||
"action": "general.chat",
|
||||
"actionHold": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"action": "ui.pauseMenu",
|
||||
"actionHold": "",
|
||||
"label": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"alwaysReconnectButton": true,
|
||||
"reportBugButtonWithReconnect": true,
|
||||
"allowAutoConnect": true
|
||||
}
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
import { defineConfig } from 'cypress'
|
||||
|
||||
const isPerformanceTest = process.env.PERFORMANCE_TEST === 'true'
|
||||
|
||||
export default defineConfig({
|
||||
video: false,
|
||||
chromeWebSecurity: false,
|
||||
|
|
@ -34,7 +32,7 @@ export default defineConfig({
|
|||
return require('./cypress/plugins/index.js')(on, config)
|
||||
},
|
||||
baseUrl: 'http://localhost:8080',
|
||||
specPattern: !isPerformanceTest ? 'cypress/e2e/smoke.spec.ts' : 'cypress/e2e/rendering_performance.spec.ts',
|
||||
specPattern: 'cypress/e2e/**/*.spec.ts',
|
||||
excludeSpecPattern: ['**/__snapshots__/*', '**/__image_snapshots__/*'],
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
@ -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()
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<script type="module" src="three-labels.ts"></script>
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
canvas { display: block; }
|
||||
</style>
|
||||
|
|
@ -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()
|
||||
|
|
@ -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);
|
||||
})
|
||||
|
|
|
|||
25
index.html
25
index.html
|
|
@ -27,7 +27,6 @@
|
|||
<div style="font-size: var(--font-size);color: rgb(176, 176, 176);margin-top: 3px;text-align: center" class="subtitle">A true Minecraft client in your browser!</div>
|
||||
<!-- small text pre -->
|
||||
<div style="font-size: calc(var(--font-size) * 0.6);color: rgb(150, 150, 150);margin-top: 3px;text-align: center;white-space: pre-line;" class="advanced-info"></div>
|
||||
<div style="font-size: calc(var(--font-size) * 0.6);color: rgb(255, 100, 100);margin-top: 10px;text-align: center;display: none;" class="ios-warning">Only iOS 15+ is supported due to performance optimizations</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
|
@ -37,13 +36,6 @@
|
|||
if (!window.pageLoaded) {
|
||||
document.documentElement.appendChild(loadingDivElem)
|
||||
}
|
||||
|
||||
// iOS version detection
|
||||
const getIOSVersion = () => {
|
||||
const match = navigator.userAgent.match(/OS (\d+)_(\d+)_?(\d+)?/);
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
}
|
||||
|
||||
// load error handling
|
||||
const onError = (errorOrMessage, log = false) => {
|
||||
let message = errorOrMessage instanceof Error ? (errorOrMessage.stack ?? errorOrMessage.message) : errorOrMessage
|
||||
|
|
@ -54,23 +46,12 @@
|
|||
const [errorMessage, ...errorStack] = message.split('\n')
|
||||
document.querySelector('.initial-loader').querySelector('.subtitle').textContent = errorMessage
|
||||
document.querySelector('.initial-loader').querySelector('.advanced-info').textContent = errorStack.join('\n')
|
||||
|
||||
// Show iOS warning if applicable
|
||||
const iosVersion = getIOSVersion();
|
||||
if (iosVersion !== null && iosVersion < 15) {
|
||||
document.querySelector('.initial-loader').querySelector('.ios-warning').style.display = 'block';
|
||||
}
|
||||
|
||||
if (window.navigator.maxTouchPoints > 1) window.location.hash = '#dev' // show eruda
|
||||
// unregister all sw
|
||||
if (window.navigator.serviceWorker && document.querySelector('.initial-loader').style.opacity !== 0) {
|
||||
console.log('got worker')
|
||||
if (window.navigator.serviceWorker) {
|
||||
window.navigator.serviceWorker.getRegistrations().then(registrations => {
|
||||
registrations.forEach(registration => {
|
||||
console.log('got registration')
|
||||
registration.unregister().then(() => {
|
||||
console.log('worker unregistered')
|
||||
})
|
||||
registration.unregister()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -149,7 +130,7 @@
|
|||
</script> -->
|
||||
<title>Minecraft Web Client</title>
|
||||
<!-- <link rel="canonical" href="https://mcraft.fun"> -->
|
||||
<meta name="description" content="Minecraft Java Edition Client in Browser — Full Multiplayer Support, Server Connect, Offline Play — Join real Minecraft servers">
|
||||
<meta name="description" content="Minecraft web client running in your browser">
|
||||
<meta name="keywords" content="Play, Minecraft, Online, Web, Java, Server, Single player, Javascript, PrismarineJS, Voxel, WebGL, Three.js">
|
||||
<meta name="date" content="2024-07-11" scheme="YYYY-MM-DD">
|
||||
<meta name="language" content="English">
|
||||
|
|
|
|||
71
package.json
71
package.json
|
|
@ -7,14 +7,12 @@
|
|||
"dev-proxy": "node server.js",
|
||||
"start": "run-p dev-proxy dev-rsbuild watch-mesher",
|
||||
"start2": "run-p dev-rsbuild watch-mesher",
|
||||
"start-metrics": "ENABLE_METRICS=true rsbuild dev",
|
||||
"build": "pnpm build-other-workers && rsbuild build",
|
||||
"build-analyze": "BUNDLE_ANALYZE=true rsbuild build && pnpm build-other-workers",
|
||||
"build-single-file": "SINGLE_FILE_BUILD=true rsbuild build",
|
||||
"prepare-project": "tsx scripts/genShims.ts && tsx scripts/makeOptimizedMcData.mjs && tsx scripts/genLargeDataAliases.ts",
|
||||
"check-build": "pnpm prepare-project && tsc && pnpm build",
|
||||
"test:cypress": "cypress run",
|
||||
"test:benchmark": "PERFORMANCE_TEST=true cypress run",
|
||||
"test:cypress:open": "cypress open",
|
||||
"test-unit": "vitest",
|
||||
"test:e2e": "start-test http-get://localhost:8080 test:cypress",
|
||||
|
|
@ -32,9 +30,7 @@
|
|||
"run-playground": "run-p watch-mesher watch-other-workers watch-playground",
|
||||
"run-all": "run-p start run-playground",
|
||||
"build-playground": "rsbuild build --config renderer/rsbuild.config.ts",
|
||||
"watch-playground": "rsbuild dev --config renderer/rsbuild.config.ts",
|
||||
"update-git-deps": "tsx scripts/updateGitDeps.ts",
|
||||
"request-data": "tsx scripts/requestData.ts"
|
||||
"watch-playground": "rsbuild dev --config renderer/rsbuild.config.ts"
|
||||
},
|
||||
"keywords": [
|
||||
"prismarine",
|
||||
|
|
@ -54,9 +50,8 @@
|
|||
"dependencies": {
|
||||
"@dimaka/interface": "0.0.3-alpha.0",
|
||||
"@floating-ui/react": "^0.26.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@nxg-org/mineflayer-auto-jump": "^0.7.18",
|
||||
"@nxg-org/mineflayer-tracker": "1.3.0",
|
||||
"@nxg-org/mineflayer-auto-jump": "^0.7.12",
|
||||
"@nxg-org/mineflayer-tracker": "1.2.1",
|
||||
"@react-oauth/google": "^0.12.1",
|
||||
"@stylistic/eslint-plugin": "^2.6.1",
|
||||
"@types/gapi": "^0.0.47",
|
||||
|
|
@ -80,19 +75,18 @@
|
|||
"esbuild-plugin-polyfill-node": "^0.3.0",
|
||||
"express": "^4.18.2",
|
||||
"filesize": "^10.0.12",
|
||||
"flying-squid": "npm:@zardoy/flying-squid@^0.0.104",
|
||||
"framer-motion": "^12.9.2",
|
||||
"flying-squid": "npm:@zardoy/flying-squid@^0.0.51",
|
||||
"fs-extra": "^11.1.1",
|
||||
"google-drive-browserfs": "github:zardoy/browserfs#google-drive",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mcraft-fun-mineflayer": "^0.1.23",
|
||||
"minecraft-data": "3.98.0",
|
||||
"minecraft-data": "3.83.1",
|
||||
"minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master",
|
||||
"mineflayer-item-map-downloader": "github:zardoy/mineflayer-item-map-downloader",
|
||||
"mojangson": "^2.0.4",
|
||||
"net-browserify": "github:zardoy/prismarinejs-net-browserify",
|
||||
"node-gzip": "^1.1.2",
|
||||
"mcraft-fun-mineflayer": "^0.1.14",
|
||||
"peerjs": "^1.5.0",
|
||||
"pixelarticons": "^1.8.1",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
|
|
@ -106,6 +100,7 @@
|
|||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-select": "^5.8.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-zoom-pan-pinch": "3.4.4",
|
||||
"remark": "^15.0.1",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
|
|
@ -123,11 +118,11 @@
|
|||
"workbox-build": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rsbuild/core": "1.3.5",
|
||||
"@rsbuild/plugin-node-polyfill": "1.3.0",
|
||||
"@rsbuild/plugin-react": "1.2.0",
|
||||
"@rsbuild/plugin-type-check": "1.2.1",
|
||||
"@rsbuild/plugin-typed-css-modules": "1.0.2",
|
||||
"@rsbuild/core": "^1.0.1-beta.9",
|
||||
"@rsbuild/plugin-node-polyfill": "^1.0.3",
|
||||
"@rsbuild/plugin-react": "^1.0.1-beta.9",
|
||||
"@rsbuild/plugin-type-check": "^1.0.1-beta.9",
|
||||
"@rsbuild/plugin-typed-css-modules": "^1.0.1",
|
||||
"@storybook/addon-essentials": "^7.4.6",
|
||||
"@storybook/addon-links": "^7.4.6",
|
||||
"@storybook/blocks": "^7.4.6",
|
||||
|
|
@ -135,6 +130,7 @@
|
|||
"@storybook/react-vite": "^7.4.6",
|
||||
"@types/diff-match-patch": "^1.0.36",
|
||||
"@types/lodash-es": "^4.17.9",
|
||||
"@types/react-transition-group": "^4.4.7",
|
||||
"@types/stats.js": "^0.17.1",
|
||||
"@types/three": "0.154.0",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
|
|
@ -144,7 +140,7 @@
|
|||
"browserify-zlib": "^0.2.0",
|
||||
"buffer": "^6.0.3",
|
||||
"constants-browserify": "^1.0.0",
|
||||
"contro-max": "^0.1.9",
|
||||
"contro-max": "^0.1.8",
|
||||
"crypto-browserify": "^3.12.0",
|
||||
"cypress-esbuild-preprocessor": "^1.0.2",
|
||||
"eslint": "^8.50.0",
|
||||
|
|
@ -154,16 +150,17 @@
|
|||
"http-browserify": "^1.7.0",
|
||||
"http-server": "^14.1.1",
|
||||
"https-browserify": "^1.0.0",
|
||||
"mc-assets": "^0.2.62",
|
||||
"mc-assets": "^0.2.48",
|
||||
"mineflayer-mouse": "^0.1.7",
|
||||
"minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next",
|
||||
"mineflayer": "github:zardoy/mineflayer#gen-the-master",
|
||||
"mineflayer-mouse": "^0.1.21",
|
||||
"mineflayer": "github:zardoy/mineflayer",
|
||||
"mineflayer-pathfinder": "^2.4.4",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"os-browserify": "^0.3.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"path-exists-cli": "^2.0.0",
|
||||
"process": "github:PrismarineJS/node-process",
|
||||
"renderer": "link:renderer",
|
||||
"process": "github:PrismarineJS/node-process",
|
||||
"rimraf": "^5.0.1",
|
||||
"storybook": "^7.4.6",
|
||||
"stream-browserify": "^3.0.0",
|
||||
|
|
@ -197,15 +194,14 @@
|
|||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"mineflayer": "github:zardoy/mineflayer#gen-the-master",
|
||||
"@nxg-org/mineflayer-physics-util": "1.8.10",
|
||||
"buffer": "^6.0.3",
|
||||
"vec3": "0.1.10",
|
||||
"@nxg-org/mineflayer-physics-util": "1.5.8",
|
||||
"three": "0.154.0",
|
||||
"diamond-square": "github:zardoy/diamond-square",
|
||||
"prismarine-block": "github:zardoy/prismarine-block#next-era",
|
||||
"prismarine-world": "github:zardoy/prismarine-world#next-era",
|
||||
"minecraft-data": "3.98.0",
|
||||
"minecraft-data": "3.83.1",
|
||||
"prismarine-provider-anvil": "github:zardoy/prismarine-provider-anvil#everything",
|
||||
"prismarine-physics": "github:zardoy/prismarine-physics",
|
||||
"minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master",
|
||||
|
|
@ -214,29 +210,14 @@
|
|||
"prismarine-item": "latest"
|
||||
},
|
||||
"updateConfig": {
|
||||
"ignoreDependencies": [
|
||||
"browserfs",
|
||||
"google-drive-browserfs"
|
||||
]
|
||||
"ignoreDependencies": []
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"three@0.154.0": "patches/three@0.154.0.patch",
|
||||
"pixelarticons@1.8.1": "patches/pixelarticons@1.8.1.patch",
|
||||
"mineflayer-item-map-downloader@1.2.0": "patches/mineflayer-item-map-downloader@1.2.0.patch",
|
||||
"minecraft-protocol": "patches/minecraft-protocol.patch"
|
||||
},
|
||||
"ignoredBuiltDependencies": [
|
||||
"canvas",
|
||||
"core-js",
|
||||
"gl"
|
||||
],
|
||||
"onlyBuiltDependencies": [
|
||||
"sharp",
|
||||
"cypress",
|
||||
"esbuild",
|
||||
"fsevents"
|
||||
],
|
||||
"ignorePatchFailures": false,
|
||||
"allowUnusedPatches": false
|
||||
"minecraft-protocol@1.54.0": "patches/minecraft-protocol@1.54.0.patch"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.8.0+sha512.0e82714d1b5b43c74610193cb20734897c1d00de89d0e18420aebc5977fa13d780a9cb05734624e81ebd81cc876cd464794850641c48b9544326b5622ca29971"
|
||||
"packageManager": "pnpm@9.0.4"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
diff --git a/src/client/chat.js b/src/client/chat.js
|
||||
index 0021870994fc59a82f0ac8aba0a65a8be43ef2f4..a53fceb843105ea2a1d88722b3fc7c3b43cb102a 100644
|
||||
index f14269bea055d4329cd729271e7406ec4b344de7..00f5482eb6e3c911381ca9a728b1b4aae0d1d337 100644
|
||||
--- a/src/client/chat.js
|
||||
+++ b/src/client/chat.js
|
||||
@@ -116,7 +116,7 @@ module.exports = function (client, options) {
|
||||
for (const player of packet.data) {
|
||||
if (player.chatSession) {
|
||||
client._players[player.uuid] = {
|
||||
- publicKey: crypto.createPublicKey({ key: player.chatSession.publicKey.keyBytes, format: 'der', type: 'spki' }),
|
||||
+ // publicKey: crypto.createPublicKey({ key: player.chatSession.publicKey.keyBytes, format: 'der', type: 'spki' }),
|
||||
publicKeyDER: player.chatSession.publicKey.keyBytes,
|
||||
sessionUuid: player.chatSession.uuid
|
||||
}
|
||||
@@ -126,7 +126,7 @@ module.exports = function (client, options) {
|
||||
|
||||
if (player.crypto) {
|
||||
client._players[player.uuid] = {
|
||||
- publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }),
|
||||
+ // publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }),
|
||||
publicKeyDER: player.crypto.publicKey,
|
||||
signature: player.crypto.signature,
|
||||
displayName: player.displayName || player.name
|
||||
@@ -196,7 +196,7 @@ module.exports = function (client, options) {
|
||||
@@ -111,7 +111,7 @@ module.exports = function (client, options) {
|
||||
for (const player of packet.data) {
|
||||
if (!player.chatSession) continue
|
||||
client._players[player.UUID] = {
|
||||
- publicKey: crypto.createPublicKey({ key: player.chatSession.publicKey.keyBytes, format: 'der', type: 'spki' }),
|
||||
+ // publicKey: crypto.createPublicKey({ key: player.chatSession.publicKey.keyBytes, format: 'der', type: 'spki' }),
|
||||
publicKeyDER: player.chatSession.publicKey.keyBytes,
|
||||
sessionUuid: player.chatSession.uuid
|
||||
}
|
||||
@@ -127,7 +127,7 @@ module.exports = function (client, options) {
|
||||
for (const player of packet.data) {
|
||||
if (player.crypto) {
|
||||
client._players[player.UUID] = {
|
||||
- publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }),
|
||||
+ // publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }),
|
||||
publicKeyDER: player.crypto.publicKey,
|
||||
signature: player.crypto.signature,
|
||||
displayName: player.displayName || player.name
|
||||
@@ -198,7 +198,7 @@ module.exports = function (client, options) {
|
||||
if (mcData.supportFeature('useChatSessions')) {
|
||||
const tsDelta = BigInt(Date.now()) - packet.timestamp
|
||||
const expired = !packet.timestamp || tsDelta > messageExpireTime || tsDelta < 0
|
||||
|
|
@ -28,8 +28,8 @@ index 0021870994fc59a82f0ac8aba0a65a8be43ef2f4..a53fceb843105ea2a1d88722b3fc7c3b
|
|||
+ const verified = false && !packet.unsignedChatContent && updateAndValidateSession(packet.senderUuid, packet.plainMessage, packet.signature, packet.index, packet.previousMessages, packet.salt, packet.timestamp) && !expired
|
||||
if (verified) client._signatureCache.push(packet.signature)
|
||||
client.emit('playerChat', {
|
||||
globalIndex: packet.globalIndex,
|
||||
@@ -362,7 +362,7 @@ module.exports = function (client, options) {
|
||||
plainMessage: packet.plainMessage,
|
||||
@@ -363,7 +363,7 @@ module.exports = function (client, options) {
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -38,16 +38,16 @@ index 0021870994fc59a82f0ac8aba0a65a8be43ef2f4..a53fceb843105ea2a1d88722b3fc7c3b
|
|||
options.timestamp = options.timestamp || BigInt(Date.now())
|
||||
options.salt = options.salt || 1n
|
||||
|
||||
@@ -407,7 +407,7 @@ module.exports = function (client, options) {
|
||||
@@ -405,7 +405,7 @@ module.exports = function (client, options) {
|
||||
message,
|
||||
timestamp: options.timestamp,
|
||||
salt: options.salt,
|
||||
- signature: (client.profileKeys && client._session) ? client.signMessage(message, options.timestamp, options.salt, undefined, acknowledgements) : undefined,
|
||||
+ signature: (client.profileKeys && client._session) ? await client.signMessage(message, options.timestamp, options.salt, undefined, acknowledgements) : undefined,
|
||||
offset: client._lastSeenMessages.pending,
|
||||
checksum: computeChatChecksum(client._lastSeenMessages), // 1.21.5+
|
||||
acknowledged
|
||||
@@ -422,7 +422,7 @@ module.exports = function (client, options) {
|
||||
})
|
||||
@@ -419,7 +419,7 @@ module.exports = function (client, options) {
|
||||
message,
|
||||
timestamp: options.timestamp,
|
||||
salt: options.salt,
|
||||
|
|
@ -57,7 +57,7 @@ index 0021870994fc59a82f0ac8aba0a65a8be43ef2f4..a53fceb843105ea2a1d88722b3fc7c3b
|
|||
previousMessages: client._lastSeenMessages.map((e) => ({
|
||||
messageSender: e.sender,
|
||||
diff --git a/src/client/encrypt.js b/src/client/encrypt.js
|
||||
index 63cc2bd9615100bd2fd63dfe14c094aa6b8cd1c9..36df57d1196af9761d920fa285ac48f85410eaef 100644
|
||||
index b9d21bab9faccd5dbf1975fc423fc55c73e906c5..99ffd76527b410e3a393181beb260108f4c63536 100644
|
||||
--- a/src/client/encrypt.js
|
||||
+++ b/src/client/encrypt.js
|
||||
@@ -25,7 +25,11 @@ module.exports = function (client, options) {
|
||||
|
|
@ -73,24 +73,28 @@ index 63cc2bd9615100bd2fd63dfe14c094aa6b8cd1c9..36df57d1196af9761d920fa285ac48f8
|
|||
}
|
||||
|
||||
function onJoinServerResponse (err) {
|
||||
diff --git a/src/client/pluginChannels.js b/src/client/pluginChannels.js
|
||||
index 671eb452f31e6b5fcd57d715f1009d010160c65f..7f69f511c8fb97d431ec5125c851b49be8e2ab76 100644
|
||||
--- a/src/client/pluginChannels.js
|
||||
+++ b/src/client/pluginChannels.js
|
||||
@@ -57,7 +57,7 @@ module.exports = function (client, options) {
|
||||
try {
|
||||
packet.data = proto.parsePacketBuffer(channel, packet.data).data
|
||||
} catch (error) {
|
||||
- client.emit('error', error)
|
||||
+ client.emit('error', error, { customPayload: packet })
|
||||
return
|
||||
}
|
||||
}
|
||||
diff --git a/src/client.js b/src/client.js
|
||||
index e369e77d055ba919e8f9da7b8e8b5dc879c74cf4..54bb9e6644388e9b6bd42b3012951875989cdf0c 100644
|
||||
index 74749698f8cee05b5dc749c271544f78d06645b0..e77e0a3f41c1ee780c3abbd54b0801d248c2a07c 100644
|
||||
--- a/src/client.js
|
||||
+++ b/src/client.js
|
||||
@@ -111,7 +111,13 @@ class Client extends EventEmitter {
|
||||
@@ -89,10 +89,12 @@ class Client extends EventEmitter {
|
||||
parsed.metadata.name = parsed.data.name
|
||||
parsed.data = parsed.data.params
|
||||
parsed.metadata.state = state
|
||||
- debug('read packet ' + state + '.' + parsed.metadata.name)
|
||||
- if (debug.enabled) {
|
||||
- const s = JSON.stringify(parsed.data, null, 2)
|
||||
- debug(s && s.length > 10000 ? parsed.data : s)
|
||||
+ if (!globalThis.excludeCommunicationDebugEvents?.includes(parsed.metadata.name)) {
|
||||
+ debug('read packet ' + state + '.' + parsed.metadata.name)
|
||||
+ if (debug.enabled) {
|
||||
+ const s = JSON.stringify(parsed.data, null, 2)
|
||||
+ debug(s && s.length > 10000 ? parsed.data : s)
|
||||
+ }
|
||||
}
|
||||
if (this._hasBundlePacket && parsed.metadata.name === 'bundle_delimiter') {
|
||||
if (this._mcBundle.length) { // End bundle
|
||||
@@ -110,7 +112,13 @@ class Client extends EventEmitter {
|
||||
this._hasBundlePacket = false
|
||||
}
|
||||
} else {
|
||||
|
|
@ -105,7 +109,7 @@ index e369e77d055ba919e8f9da7b8e8b5dc879c74cf4..54bb9e6644388e9b6bd42b3012951875
|
|||
}
|
||||
})
|
||||
}
|
||||
@@ -169,7 +175,10 @@ class Client extends EventEmitter {
|
||||
@@ -168,7 +176,10 @@ class Client extends EventEmitter {
|
||||
}
|
||||
|
||||
const onFatalError = (err) => {
|
||||
|
|
@ -117,21 +121,25 @@ index e369e77d055ba919e8f9da7b8e8b5dc879c74cf4..54bb9e6644388e9b6bd42b3012951875
|
|||
endSocket()
|
||||
}
|
||||
|
||||
@@ -198,6 +207,10 @@ class Client extends EventEmitter {
|
||||
@@ -197,6 +208,8 @@ class Client extends EventEmitter {
|
||||
serializer -> framer -> socket -> splitter -> deserializer */
|
||||
if (this.serializer) {
|
||||
this.serializer.end()
|
||||
+ setTimeout(() => {
|
||||
+ this.socket?.end()
|
||||
+ this.socket?.emit('end')
|
||||
+ }, 2000) // allow the serializer to finish writing
|
||||
+ this.socket?.end()
|
||||
+ this.socket?.emit('end')
|
||||
} else {
|
||||
if (this.socket) this.socket.end()
|
||||
}
|
||||
@@ -243,6 +256,7 @@ class Client extends EventEmitter {
|
||||
debug('writing packet ' + this.state + '.' + name)
|
||||
debug(params)
|
||||
}
|
||||
@@ -238,8 +251,11 @@ class Client extends EventEmitter {
|
||||
|
||||
write (name, params) {
|
||||
if (!this.serializer.writable) { return }
|
||||
- debug('writing packet ' + this.state + '.' + name)
|
||||
- debug(params)
|
||||
+ if (!globalThis.excludeCommunicationDebugEvents?.includes(name)) {
|
||||
+ debug(`[${this.state}] from ${this.isServer ? 'server' : 'client'}: ` + name)
|
||||
+ debug(params)
|
||||
+ }
|
||||
+ this.emit('writePacket', name, params)
|
||||
this.serializer.write({ name, params })
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
16
patches/three@0.154.0.patch
Normal file
16
patches/three@0.154.0.patch
Normal 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 {
|
||||
11192
pnpm-lock.yaml
generated
11192
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -22,7 +22,7 @@ const buildOptions = {
|
|||
},
|
||||
platform: 'browser',
|
||||
entryPoints: [path.join(__dirname, './viewer/lib/mesher/mesher.ts')],
|
||||
minify: !watch,
|
||||
minify: true,
|
||||
logLevel: 'info',
|
||||
drop: !watch ? [
|
||||
'debugger'
|
||||
|
|
@ -35,15 +35,11 @@ const buildOptions = {
|
|||
define: {
|
||||
'process.env.BROWSER': '"true"',
|
||||
},
|
||||
loader: {
|
||||
'.png': 'dataurl',
|
||||
'.obj': 'text'
|
||||
},
|
||||
plugins: [
|
||||
...mesherSharedPlugins,
|
||||
{
|
||||
name: 'external-json',
|
||||
setup(build) {
|
||||
setup (build) {
|
||||
build.onResolve({ filter: /\.json$/ }, args => {
|
||||
const fileName = args.path.split('/').pop().replace('.json', '')
|
||||
if (args.resolveDir.includes('minecraft-data')) {
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -1,170 +0,0 @@
|
|||
import { EntityMesh, rendererSpecialHandled, EntityDebugFlags } from '../viewer/three/entity/EntityMesh'
|
||||
|
||||
export const displayEntitiesDebugList = (version: string) => {
|
||||
// Create results container
|
||||
const container = document.createElement('div')
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
background: rgba(0,0,0,0.8);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
font-family: monospace;
|
||||
min-width: 400px;
|
||||
z-index: 1000;
|
||||
`
|
||||
document.body.appendChild(container)
|
||||
|
||||
// Add title
|
||||
const title = document.createElement('h2')
|
||||
title.textContent = 'Minecraft Entity Support'
|
||||
title.style.cssText = 'margin-top: 0; text-align: center;'
|
||||
container.appendChild(title)
|
||||
|
||||
// Test entities
|
||||
const results: Array<{
|
||||
entity: string;
|
||||
supported: boolean;
|
||||
type?: 'obj' | 'bedrock' | 'special';
|
||||
mappedFrom?: string;
|
||||
textureMap?: boolean;
|
||||
errors?: string[];
|
||||
}> = []
|
||||
const { mcData } = window
|
||||
const entityNames = Object.keys(mcData.entitiesArray.reduce((acc, entity) => {
|
||||
acc[entity.name] = true
|
||||
return acc
|
||||
}, {}))
|
||||
|
||||
// Add loading indicator
|
||||
const loading = document.createElement('div')
|
||||
loading.textContent = 'Testing entities...'
|
||||
loading.style.textAlign = 'center'
|
||||
container.appendChild(loading)
|
||||
|
||||
for (const entity of entityNames) {
|
||||
const debugFlags: EntityDebugFlags = {}
|
||||
|
||||
if (rendererSpecialHandled.includes(entity)) {
|
||||
results.push({
|
||||
entity,
|
||||
supported: true,
|
||||
type: 'special',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
const { mesh: entityMesh } = new EntityMesh(version, entity, undefined, {}, debugFlags)
|
||||
// find the most distant pos child
|
||||
window.objects ??= {}
|
||||
window.objects[entity] = entityMesh
|
||||
|
||||
results.push({
|
||||
entity,
|
||||
supported: !!debugFlags.type || rendererSpecialHandled.includes(entity),
|
||||
type: debugFlags.type,
|
||||
mappedFrom: debugFlags.tempMap,
|
||||
textureMap: debugFlags.textureMap,
|
||||
errors: debugFlags.errors
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
results.push({
|
||||
entity,
|
||||
supported: false,
|
||||
mappedFrom: debugFlags.tempMap
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Remove loading indicator
|
||||
loading.remove()
|
||||
|
||||
const createSection = (title: string, items: any[], filter: (item: any) => boolean) => {
|
||||
const section = document.createElement('div')
|
||||
section.style.marginBottom = '20px'
|
||||
|
||||
const sectionTitle = document.createElement('h3')
|
||||
sectionTitle.textContent = title
|
||||
sectionTitle.style.textAlign = 'center'
|
||||
section.appendChild(sectionTitle)
|
||||
|
||||
const list = document.createElement('ul')
|
||||
list.style.cssText = 'padding-left: 20px; list-style-type: none; margin: 0;'
|
||||
|
||||
const filteredItems = items.filter(filter)
|
||||
for (const item of filteredItems) {
|
||||
const listItem = document.createElement('li')
|
||||
listItem.style.cssText = 'line-height: 1.4; margin: 8px 0;'
|
||||
|
||||
const entityName = document.createElement('strong')
|
||||
entityName.style.cssText = 'user-select: text;-webkit-user-select: text;'
|
||||
entityName.textContent = item.entity
|
||||
listItem.appendChild(entityName)
|
||||
|
||||
let text = ''
|
||||
if (item.mappedFrom) {
|
||||
text += ` -> ${item.mappedFrom}`
|
||||
}
|
||||
if (item.type) {
|
||||
text += ` - ${item.type}`
|
||||
}
|
||||
if (item.textureMap) {
|
||||
text += ' ⚠️'
|
||||
}
|
||||
if (item.errors) {
|
||||
text += ' ❌'
|
||||
}
|
||||
|
||||
listItem.appendChild(document.createTextNode(text))
|
||||
list.appendChild(listItem)
|
||||
}
|
||||
|
||||
section.appendChild(list)
|
||||
return { section, count: filteredItems.length }
|
||||
}
|
||||
|
||||
// Sort results - bedrock first
|
||||
results.sort((a, b) => {
|
||||
if (a.type === 'bedrock' && b.type !== 'bedrock') return -1
|
||||
if (a.type !== 'bedrock' && b.type === 'bedrock') return 1
|
||||
return a.entity.localeCompare(b.entity)
|
||||
})
|
||||
|
||||
// Add sections
|
||||
const sections = [
|
||||
{
|
||||
title: '❌ Unsupported Entities',
|
||||
filter: (r: any) => !r.supported && !r.mappedFrom
|
||||
},
|
||||
{
|
||||
title: '⚠️ Partially Supported Entities',
|
||||
filter: (r: any) => r.mappedFrom
|
||||
},
|
||||
{
|
||||
title: '✅ Supported Entities',
|
||||
filter: (r: any) => r.supported && !r.mappedFrom
|
||||
}
|
||||
]
|
||||
|
||||
for (const { title, filter } of sections) {
|
||||
const { section, count } = createSection(title, results, filter)
|
||||
if (count > 0) {
|
||||
container.appendChild(section)
|
||||
}
|
||||
}
|
||||
|
||||
// log object with errors per entity
|
||||
const errors = results.filter(r => r.errors).map(r => ({
|
||||
entity: r.entity,
|
||||
errors: r.errors
|
||||
}))
|
||||
console.log(errors)
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
//@ts-nocheck
|
||||
import { BasePlaygroundScene } from '../baseScene'
|
||||
import { EntityDebugFlags, EntityMesh, rendererSpecialHandled } from '../../viewer/three/entity/EntityMesh'
|
||||
import { displayEntitiesDebugList } from '../allEntitiesDebug'
|
||||
|
||||
export default class AllEntities extends BasePlaygroundScene {
|
||||
continuousRender = false
|
||||
|
|
@ -8,6 +8,159 @@ export default class AllEntities extends BasePlaygroundScene {
|
|||
|
||||
async initData () {
|
||||
await super.initData()
|
||||
displayEntitiesDebugList(this.version)
|
||||
|
||||
// Create results container
|
||||
const container = document.createElement('div')
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
background: rgba(0,0,0,0.8);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
font-family: monospace;
|
||||
min-width: 400px;
|
||||
`
|
||||
document.body.appendChild(container)
|
||||
|
||||
// Add title
|
||||
const title = document.createElement('h2')
|
||||
title.textContent = 'Minecraft Entity Support'
|
||||
title.style.cssText = 'margin-top: 0; text-align: center;'
|
||||
container.appendChild(title)
|
||||
|
||||
// Test entities
|
||||
const results: Array<{
|
||||
entity: string;
|
||||
supported: boolean;
|
||||
type?: 'obj' | 'bedrock';
|
||||
mappedFrom?: string;
|
||||
textureMap?: boolean;
|
||||
errors?: string[];
|
||||
}> = []
|
||||
const { mcData } = window
|
||||
const entityNames = Object.keys(mcData.entitiesArray.reduce((acc, entity) => {
|
||||
acc[entity.name] = true
|
||||
return acc
|
||||
}, {}))
|
||||
|
||||
// Add loading indicator
|
||||
const loading = document.createElement('div')
|
||||
loading.textContent = 'Testing entities...'
|
||||
loading.style.textAlign = 'center'
|
||||
container.appendChild(loading)
|
||||
|
||||
for (const entity of entityNames) {
|
||||
const debugFlags: EntityDebugFlags = {}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new EntityMesh(this.version, entity, viewer.world, {}, debugFlags)
|
||||
|
||||
results.push({
|
||||
entity,
|
||||
supported: !!debugFlags.type || rendererSpecialHandled.includes(entity),
|
||||
type: debugFlags.type,
|
||||
mappedFrom: debugFlags.tempMap,
|
||||
textureMap: debugFlags.textureMap,
|
||||
errors: debugFlags.errors
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
results.push({
|
||||
entity,
|
||||
supported: false,
|
||||
mappedFrom: debugFlags.tempMap
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Remove loading indicator
|
||||
loading.remove()
|
||||
|
||||
const createSection = (title: string, items: any[], filter: (item: any) => boolean) => {
|
||||
const section = document.createElement('div')
|
||||
section.style.marginBottom = '20px'
|
||||
|
||||
const sectionTitle = document.createElement('h3')
|
||||
sectionTitle.textContent = title
|
||||
sectionTitle.style.textAlign = 'center'
|
||||
section.appendChild(sectionTitle)
|
||||
|
||||
const list = document.createElement('ul')
|
||||
list.style.cssText = 'padding-left: 20px; list-style-type: none; margin: 0;'
|
||||
|
||||
const filteredItems = items.filter(filter)
|
||||
for (const item of filteredItems) {
|
||||
const listItem = document.createElement('li')
|
||||
listItem.style.cssText = 'line-height: 1.4; margin: 8px 0;'
|
||||
|
||||
const entityName = document.createElement('strong')
|
||||
entityName.style.cssText = 'user-select: text;-webkit-user-select: text;'
|
||||
entityName.textContent = item.entity
|
||||
listItem.appendChild(entityName)
|
||||
|
||||
let text = ''
|
||||
if (item.mappedFrom) {
|
||||
text += ` -> ${item.mappedFrom}`
|
||||
}
|
||||
if (item.type) {
|
||||
text += ` - ${item.type}`
|
||||
}
|
||||
if (item.textureMap) {
|
||||
text += ' ⚠️'
|
||||
}
|
||||
if (item.errors) {
|
||||
text += ' ❌'
|
||||
}
|
||||
|
||||
listItem.appendChild(document.createTextNode(text))
|
||||
list.appendChild(listItem)
|
||||
}
|
||||
|
||||
section.appendChild(list)
|
||||
return { section, count: filteredItems.length }
|
||||
}
|
||||
|
||||
// Sort results - bedrock first
|
||||
results.sort((a, b) => {
|
||||
if (a.type === 'bedrock' && b.type !== 'bedrock') return -1
|
||||
if (a.type !== 'bedrock' && b.type === 'bedrock') return 1
|
||||
return a.entity.localeCompare(b.entity)
|
||||
})
|
||||
|
||||
// Add sections
|
||||
const sections = [
|
||||
{
|
||||
title: '❌ Unsupported Entities',
|
||||
filter: (r: any) => !r.supported && !r.mappedFrom
|
||||
},
|
||||
{
|
||||
title: '⚠️ Partially Supported Entities',
|
||||
filter: (r: any) => r.mappedFrom
|
||||
},
|
||||
{
|
||||
title: '✅ Supported Entities',
|
||||
filter: (r: any) => r.supported && !r.mappedFrom
|
||||
}
|
||||
]
|
||||
|
||||
for (const { title, filter } of sections) {
|
||||
const { section, count } = createSection(title, results, filter)
|
||||
if (count > 0) {
|
||||
container.appendChild(section)
|
||||
}
|
||||
}
|
||||
|
||||
// log object with errors per entity
|
||||
const errors = results.filter(r => r.errors).map(r => ({
|
||||
entity: r.entity,
|
||||
errors: r.errors
|
||||
}))
|
||||
console.log(errors)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ function getAllMethods (obj) {
|
|||
return [...methods] as string[]
|
||||
}
|
||||
|
||||
export const delayedIterator = async <T> (arr: T[], delay: number, exec: (item: T, index: number) => Promise<void>, chunkSize = 1) => {
|
||||
export const delayedIterator = async <T> (arr: T[], delay: number, exec: (item: T, index: number) => void, chunkSize = 1) => {
|
||||
// if delay is 0 then don't use setTimeout
|
||||
for (let i = 0; i < arr.length; i += chunkSize) {
|
||||
if (delay) {
|
||||
|
|
@ -74,6 +74,6 @@ export const delayedIterator = async <T> (arr: T[], delay: number, exec: (item:
|
|||
setTimeout(resolve, delay)
|
||||
})
|
||||
}
|
||||
await exec(arr[i], i)
|
||||
exec(arr[i], i)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { defineConfig, ModifyRspackConfigUtils } from '@rsbuild/core';
|
|||
import { pluginNodePolyfill } from '@rsbuild/plugin-node-polyfill';
|
||||
import { pluginReact } from '@rsbuild/plugin-react';
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
export const appAndRendererSharedConfig = () => defineConfig({
|
||||
dev: {
|
||||
|
|
@ -61,24 +60,18 @@ export const appAndRendererSharedConfig = () => defineConfig({
|
|||
],
|
||||
tools: {
|
||||
rspack (config, helpers) {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8'))
|
||||
const hasFileProtocol = Object.values(packageJson.pnpm.overrides).some((dep) => (dep as string).startsWith('file:'))
|
||||
if (hasFileProtocol) {
|
||||
// enable node_modules watching
|
||||
config.watchOptions.ignored = /\.git/
|
||||
}
|
||||
rspackViewerConfig(config, helpers)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const rspackViewerConfig = (config, { appendPlugins, addRules, rspack }: ModifyRspackConfigUtils) => {
|
||||
appendPlugins(new rspack.NormalModuleReplacementPlugin(/data|prismarine-physics/, (resource) => {
|
||||
appendPlugins(new rspack.NormalModuleReplacementPlugin(/data/, (resource) => {
|
||||
let absolute: string
|
||||
const request = resource.request.replaceAll('\\', '/')
|
||||
absolute = path.join(resource.context, request).replaceAll('\\', '/')
|
||||
if (request.includes('minecraft-data/data/pc/1.') || request.includes('prismarine-physics')) {
|
||||
console.log('Error: incompatible resource', request, 'from', resource.contextInfo.issuer)
|
||||
if (request.includes('minecraft-data/data/pc/1.')) {
|
||||
console.log('Error: incompatible resource', request, resource.contextInfo.issuer)
|
||||
process.exit(1)
|
||||
// throw new Error(`${resource.request} was requested by ${resource.contextInfo.issuer}`)
|
||||
}
|
||||
|
|
@ -108,10 +101,6 @@ export const rspackViewerConfig = (config, { appendPlugins, addRules, rspack }:
|
|||
{
|
||||
test: /\.txt$/,
|
||||
type: 'asset/source',
|
||||
},
|
||||
{
|
||||
test: /\.log$/,
|
||||
type: 'asset/source',
|
||||
}
|
||||
])
|
||||
config.ignoreWarnings = [
|
||||
|
|
|
|||
|
|
@ -1,27 +1,15 @@
|
|||
import { proxy } from 'valtio'
|
||||
import { NonReactiveState, RendererReactiveState } from '../../src/appViewer'
|
||||
import { RendererReactiveState } from '../../src/appViewer'
|
||||
|
||||
export const getDefaultRendererState = (): {
|
||||
reactive: RendererReactiveState
|
||||
nonReactive: NonReactiveState
|
||||
} => {
|
||||
export const getDefaultRendererState = (): RendererReactiveState => {
|
||||
return {
|
||||
reactive: proxy({
|
||||
world: {
|
||||
chunksLoaded: new Set(),
|
||||
heightmaps: new Map(),
|
||||
allChunksLoaded: true,
|
||||
mesherWork: false,
|
||||
intersectMedia: null
|
||||
},
|
||||
renderer: '',
|
||||
preventEscapeMenu: false
|
||||
}),
|
||||
nonReactive: {
|
||||
world: {
|
||||
chunksLoaded: new Set(),
|
||||
chunksTotalNumber: 0,
|
||||
}
|
||||
}
|
||||
world: {
|
||||
chunksLoaded: [],
|
||||
chunksTotalNumber: 0,
|
||||
allChunksLoaded: true,
|
||||
mesherWork: false,
|
||||
intersectMedia: null
|
||||
},
|
||||
renderer: '',
|
||||
preventEscapeMenu: false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,87 +1,103 @@
|
|||
import { EventEmitter } from 'events'
|
||||
import { Vec3 } from 'vec3'
|
||||
import TypedEmitter from 'typed-emitter'
|
||||
import { ItemSelector } from 'mc-assets/dist/itemDefinitions'
|
||||
import { GameMode, Team } from 'mineflayer'
|
||||
import { proxy } from 'valtio'
|
||||
import type { HandItemBlock } from '../three/holdingBlock'
|
||||
import { GameMode } from 'mineflayer'
|
||||
import { HandItemBlock } from '../three/holdingBlock'
|
||||
|
||||
export type MovementState = 'NOT_MOVING' | 'WALKING' | 'SPRINTING' | 'SNEAKING'
|
||||
export type ItemSpecificContextProperties = Partial<Pick<ItemSelector['properties'], 'minecraft:using_item' | 'minecraft:use_duration' | 'minecraft:use_cycle' | 'minecraft:display_context'>>
|
||||
export type CameraPerspective = 'first_person' | 'third_person_back' | 'third_person_front'
|
||||
|
||||
export type BlockShape = { position: any; width: any; height: any; depth: any; }
|
||||
export type BlocksShapes = BlockShape[]
|
||||
|
||||
// edit src/mineflayer/playerState.ts for implementation of player state from mineflayer
|
||||
export const getInitialPlayerState = () => proxy({
|
||||
playerSkin: undefined as string | undefined,
|
||||
inWater: false,
|
||||
waterBreathing: false,
|
||||
backgroundColor: [0, 0, 0] as [number, number, number],
|
||||
ambientLight: 0,
|
||||
directionalLight: 0,
|
||||
eyeHeight: 0,
|
||||
gameMode: undefined as GameMode | undefined,
|
||||
lookingAtBlock: undefined as {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
face?: number
|
||||
shapes: BlocksShapes
|
||||
} | undefined,
|
||||
diggingBlock: undefined as {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
stage: number
|
||||
face?: number
|
||||
mergedShape: BlockShape | undefined
|
||||
} | undefined,
|
||||
movementState: 'NOT_MOVING' as MovementState,
|
||||
onGround: true,
|
||||
sneaking: false,
|
||||
flying: false,
|
||||
sprinting: false,
|
||||
itemUsageTicks: 0,
|
||||
username: '',
|
||||
onlineMode: false,
|
||||
lightingDisabled: false,
|
||||
shouldHideHand: false,
|
||||
heldItemMain: undefined as HandItemBlock | undefined,
|
||||
heldItemOff: undefined as HandItemBlock | undefined,
|
||||
perspective: 'first_person' as CameraPerspective,
|
||||
onFire: false,
|
||||
export type PlayerStateEvents = {
|
||||
heldItemChanged: (item: HandItemBlock | undefined, isLeftHand: boolean) => void
|
||||
}
|
||||
|
||||
cameraSpectatingEntity: undefined as number | undefined,
|
||||
export interface IPlayerState {
|
||||
getEyeHeight(): number
|
||||
getMovementState(): MovementState
|
||||
getVelocity(): Vec3
|
||||
isOnGround(): boolean
|
||||
isSneaking(): boolean
|
||||
isFlying(): boolean
|
||||
isSprinting (): boolean
|
||||
getItemUsageTicks?(): number
|
||||
getPosition(): Vec3
|
||||
// isUsingItem?(): boolean
|
||||
getHeldItem?(isLeftHand: boolean): HandItemBlock | undefined
|
||||
username?: string
|
||||
onlineMode?: boolean
|
||||
|
||||
team: undefined as Team | undefined,
|
||||
})
|
||||
events: TypedEmitter<PlayerStateEvents>
|
||||
|
||||
export const getPlayerStateUtils = (reactive: PlayerStateReactive) => ({
|
||||
isSpectator () {
|
||||
return reactive.gameMode === 'spectator'
|
||||
},
|
||||
isSpectatingEntity () {
|
||||
return reactive.cameraSpectatingEntity !== undefined && reactive.gameMode === 'spectator'
|
||||
},
|
||||
isThirdPerson () {
|
||||
if ((this as PlayerStateUtils).isSpectatingEntity()) return false
|
||||
return reactive.perspective === 'third_person_back' || reactive.perspective === 'third_person_front'
|
||||
}
|
||||
})
|
||||
|
||||
export const getInitialPlayerStateRenderer = () => ({
|
||||
reactive: getInitialPlayerState()
|
||||
})
|
||||
|
||||
export type PlayerStateReactive = ReturnType<typeof getInitialPlayerState>
|
||||
export type PlayerStateUtils = ReturnType<typeof getPlayerStateUtils>
|
||||
|
||||
export type PlayerStateRenderer = PlayerStateReactive
|
||||
|
||||
export const getItemSelector = (playerState: PlayerStateRenderer, specificProperties: ItemSpecificContextProperties, item?: import('prismarine-item').Item) => {
|
||||
return {
|
||||
...specificProperties,
|
||||
'minecraft:date': new Date(),
|
||||
// "minecraft:context_dimension": bot.entityp,
|
||||
// 'minecraft:time': bot.time.timeOfDay / 24_000,
|
||||
reactive: {
|
||||
playerSkin: string | undefined
|
||||
inWater: boolean
|
||||
backgroundColor: [number, number, number]
|
||||
ambientLight: number
|
||||
directionalLight: number
|
||||
gameMode?: GameMode
|
||||
}
|
||||
}
|
||||
|
||||
export class BasePlayerState implements IPlayerState {
|
||||
reactive = proxy({
|
||||
playerSkin: undefined as string | undefined,
|
||||
inWater: false,
|
||||
backgroundColor: [0, 0, 0] as [number, number, number],
|
||||
ambientLight: 0,
|
||||
directionalLight: 0,
|
||||
})
|
||||
protected movementState: MovementState = 'NOT_MOVING'
|
||||
protected velocity = new Vec3(0, 0, 0)
|
||||
protected onGround = true
|
||||
protected sneaking = false
|
||||
protected flying = false
|
||||
protected sprinting = false
|
||||
readonly events = new EventEmitter() as TypedEmitter<PlayerStateEvents>
|
||||
|
||||
getEyeHeight (): number {
|
||||
return 1.62
|
||||
}
|
||||
|
||||
getMovementState (): MovementState {
|
||||
return this.movementState
|
||||
}
|
||||
|
||||
getVelocity (): Vec3 {
|
||||
return this.velocity
|
||||
}
|
||||
|
||||
isOnGround (): boolean {
|
||||
return this.onGround
|
||||
}
|
||||
|
||||
isSneaking (): boolean {
|
||||
return this.sneaking
|
||||
}
|
||||
|
||||
isFlying (): boolean {
|
||||
return this.flying
|
||||
}
|
||||
|
||||
isSprinting (): boolean {
|
||||
return this.sprinting
|
||||
}
|
||||
|
||||
getPosition (): Vec3 {
|
||||
return new Vec3(0, 0, 0)
|
||||
}
|
||||
|
||||
// For testing purposes
|
||||
setState (state: Partial<{
|
||||
movementState: MovementState
|
||||
velocity: Vec3
|
||||
onGround: boolean
|
||||
sneaking: boolean
|
||||
flying: boolean
|
||||
sprinting: boolean
|
||||
}>) {
|
||||
Object.assign(this, state)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
import { PlayerObject, PlayerAnimation } from 'skinview3d'
|
||||
import * as THREE from 'three'
|
||||
import { WalkingGeneralSwing } from '../three/entity/animations'
|
||||
import { loadSkinImage, stevePngUrl } from './utils/skins'
|
||||
|
||||
export type PlayerObjectType = PlayerObject & {
|
||||
animation?: PlayerAnimation
|
||||
realPlayerUuid: string
|
||||
realUsername: string
|
||||
}
|
||||
|
||||
export function createPlayerObject (options: {
|
||||
username?: string
|
||||
uuid?: string
|
||||
scale?: number
|
||||
}): {
|
||||
playerObject: PlayerObjectType
|
||||
wrapper: THREE.Group
|
||||
} {
|
||||
const wrapper = new THREE.Group()
|
||||
const playerObject = new PlayerObject() as PlayerObjectType
|
||||
|
||||
playerObject.realPlayerUuid = options.uuid ?? ''
|
||||
playerObject.realUsername = options.username ?? ''
|
||||
playerObject.position.set(0, 16, 0)
|
||||
|
||||
// fix issues with starfield
|
||||
playerObject.traverse((obj) => {
|
||||
if (obj instanceof THREE.Mesh && obj.material instanceof THREE.MeshStandardMaterial) {
|
||||
obj.material.transparent = true
|
||||
}
|
||||
})
|
||||
|
||||
wrapper.add(playerObject as any)
|
||||
const scale = options.scale ?? (1 / 16)
|
||||
wrapper.scale.set(scale, scale, scale)
|
||||
wrapper.rotation.set(0, Math.PI, 0)
|
||||
|
||||
// Set up animation
|
||||
playerObject.animation = new WalkingGeneralSwing()
|
||||
;(playerObject.animation as WalkingGeneralSwing).isMoving = false
|
||||
playerObject.animation.update(playerObject, 0)
|
||||
|
||||
return { playerObject, wrapper }
|
||||
}
|
||||
|
||||
export const applySkinToPlayerObject = async (playerObject: PlayerObjectType, skinUrl: string) => {
|
||||
return loadSkinImage(skinUrl || stevePngUrl).then(({ canvas }) => {
|
||||
const skinTexture = new THREE.CanvasTexture(canvas)
|
||||
skinTexture.magFilter = THREE.NearestFilter
|
||||
skinTexture.minFilter = THREE.NearestFilter
|
||||
skinTexture.needsUpdate = true
|
||||
playerObject.skin.map = skinTexture as any
|
||||
}).catch(console.error)
|
||||
}
|
||||
|
|
@ -9,6 +9,10 @@ import { makeTextureAtlas } from 'mc-assets/dist/atlasCreator'
|
|||
import { proxy, ref } from 'valtio'
|
||||
import { getItemDefinition } from 'mc-assets/dist/itemDefinitions'
|
||||
|
||||
export const activeGuiAtlas = proxy({
|
||||
atlas: null as null | { json, image },
|
||||
})
|
||||
|
||||
export const getNonFullBlocksModels = () => {
|
||||
let version = appViewer.resourcesManager.currentResources!.version ?? 'latest'
|
||||
if (versionToNumber(version) < versionToNumber('1.13')) version = '1.13'
|
||||
|
|
@ -117,18 +121,18 @@ const RENDER_SIZE = 64
|
|||
|
||||
const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isItems = false) => {
|
||||
const { currentResources } = appViewer.resourcesManager
|
||||
const imgBitmap = isItems ? currentResources!.itemsAtlasImage : currentResources!.blocksAtlasImage
|
||||
const img = await getLoadedImage(isItems ? currentResources!.itemsAtlasParser.latestImage : currentResources!.blocksAtlasParser.latestImage)
|
||||
const canvasTemp = document.createElement('canvas')
|
||||
canvasTemp.width = imgBitmap.width
|
||||
canvasTemp.height = imgBitmap.height
|
||||
canvasTemp.width = img.width
|
||||
canvasTemp.height = img.height
|
||||
canvasTemp.style.imageRendering = 'pixelated'
|
||||
const ctx = canvasTemp.getContext('2d')!
|
||||
ctx.imageSmoothingEnabled = false
|
||||
ctx.drawImage(imgBitmap, 0, 0)
|
||||
ctx.drawImage(img, 0, 0)
|
||||
|
||||
const atlasParser = isItems ? appViewer.resourcesManager.itemsAtlasParser : appViewer.resourcesManager.blocksAtlasParser
|
||||
const atlasParser = isItems ? currentResources!.itemsAtlasParser : currentResources!.blocksAtlasParser
|
||||
const textureAtlas = new TextureAtlas(
|
||||
ctx.getImageData(0, 0, imgBitmap.width, imgBitmap.height),
|
||||
ctx.getImageData(0, 0, img.width, img.height),
|
||||
Object.fromEntries(Object.entries(atlasParser.atlas.latest.textures).map(([key, value]) => {
|
||||
return [key, [
|
||||
value.u,
|
||||
|
|
@ -238,9 +242,6 @@ const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isIt
|
|||
return images
|
||||
}
|
||||
|
||||
/**
|
||||
* @mainThread
|
||||
*/
|
||||
const generateAtlas = async (images: Record<string, HTMLImageElement>) => {
|
||||
const atlas = makeTextureAtlas({
|
||||
input: Object.keys(images),
|
||||
|
|
@ -258,9 +259,9 @@ const generateAtlas = async (images: Record<string, HTMLImageElement>) => {
|
|||
// a.download = 'blocks_atlas.png'
|
||||
// a.click()
|
||||
|
||||
appViewer.resourcesManager.currentResources!.guiAtlas = {
|
||||
activeGuiAtlas.atlas = {
|
||||
json: atlas.json,
|
||||
image: await createImageBitmap(atlas.canvas),
|
||||
image: ref(await getLoadedImage(atlas.canvas.toDataURL())),
|
||||
}
|
||||
|
||||
return atlas
|
||||
|
|
@ -277,6 +278,5 @@ export const generateGuiAtlas = async () => {
|
|||
const itemImages = await generateItemsGui(itemsModelsResolved, true)
|
||||
console.timeEnd('generate items gui atlas')
|
||||
await generateAtlas({ ...blockImages, ...itemImages })
|
||||
appViewer.resourcesManager.currentResources!.guiAtlasVersion++
|
||||
// await generateAtlas(blockImages)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import * as THREE from 'three'
|
||||
import { loadSkinFromUsername, loadSkinImage } from '../lib/utils/skins'
|
||||
import { steveTexture } from './entities'
|
||||
|
||||
import { loadSkinToCanvas } from 'skinview-utils'
|
||||
import { getLookupUrl, loadSkinImage, steveTexture } from './utils/skins'
|
||||
|
||||
export const getMyHand = async (image?: string, userName?: string) => {
|
||||
let newMap: THREE.Texture
|
||||
|
|
@ -9,10 +8,7 @@ export const getMyHand = async (image?: string, userName?: string) => {
|
|||
newMap = await steveTexture
|
||||
} else {
|
||||
if (!image) {
|
||||
image = await loadSkinFromUsername(userName!, 'skin')
|
||||
}
|
||||
if (!image) {
|
||||
return
|
||||
image = getLookupUrl(userName!, 'skin')
|
||||
}
|
||||
const { canvas } = await loadSkinImage(image)
|
||||
newMap = new THREE.CanvasTexture(canvas)
|
||||
|
|
@ -2,7 +2,6 @@ import { Vec3 } from 'vec3'
|
|||
import { World } from './world'
|
||||
import { getSectionGeometry, setBlockStatesData as setMesherData } from './models'
|
||||
import { BlockStateModelInfo } from './shared'
|
||||
import { INVISIBLE_BLOCKS } from './worldConstants'
|
||||
|
||||
globalThis.structuredClone ??= (value) => JSON.parse(JSON.stringify(value))
|
||||
|
||||
|
|
@ -54,7 +53,7 @@ function setSectionDirty (pos, value = true) {
|
|||
const key = sectionKey(x, y, z)
|
||||
if (!value) {
|
||||
dirtySections.delete(key)
|
||||
postMessage({ type: 'sectionFinished', key, workerIndex })
|
||||
postMessage({ type: 'sectionFinished', key })
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -62,7 +61,7 @@ function setSectionDirty (pos, value = true) {
|
|||
if (chunk?.getSection(pos)) {
|
||||
dirtySections.set(key, (dirtySections.get(key) || 0) + 1)
|
||||
} else {
|
||||
postMessage({ type: 'sectionFinished', key, workerIndex })
|
||||
postMessage({ type: 'sectionFinished', key })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -77,7 +76,6 @@ const handleMessage = data => {
|
|||
|
||||
if (data.type === 'mcData') {
|
||||
globalVar.mcData = data.mcData
|
||||
globalVar.loadedData = data.mcData
|
||||
}
|
||||
|
||||
if (data.config) {
|
||||
|
|
@ -124,12 +122,12 @@ const handleMessage = data => {
|
|||
case 'blockUpdate': {
|
||||
const loc = new Vec3(data.pos.x, data.pos.y, data.pos.z).floored()
|
||||
if (data.stateId !== undefined && data.stateId !== null) {
|
||||
world?.setBlockStateId(loc, data.stateId)
|
||||
world.setBlockStateId(loc, data.stateId)
|
||||
}
|
||||
|
||||
const chunkKey = `${Math.floor(loc.x / 16) * 16},${Math.floor(loc.z / 16) * 16}`
|
||||
if (data.customBlockModels) {
|
||||
world?.customBlockModels.set(chunkKey, data.customBlockModels)
|
||||
world.customBlockModels.set(chunkKey, data.customBlockModels)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
|
@ -139,40 +137,8 @@ const handleMessage = data => {
|
|||
dirtySections = new Map()
|
||||
// todo also remove cached
|
||||
globalVar.mcData = null
|
||||
globalVar.loadedData = null
|
||||
allDataReady = false
|
||||
|
||||
break
|
||||
}
|
||||
case 'getCustomBlockModel': {
|
||||
const pos = new Vec3(data.pos.x, data.pos.y, data.pos.z)
|
||||
const chunkKey = `${Math.floor(pos.x / 16) * 16},${Math.floor(pos.z / 16) * 16}`
|
||||
const customBlockModel = world.customBlockModels.get(chunkKey)?.[`${pos.x},${pos.y},${pos.z}`]
|
||||
global.postMessage({ type: 'customBlockModel', chunkKey, customBlockModel })
|
||||
break
|
||||
}
|
||||
case 'getHeightmap': {
|
||||
const heightmap = new Uint8Array(256)
|
||||
|
||||
const blockPos = new Vec3(0, 0, 0)
|
||||
for (let z = 0; z < 16; z++) {
|
||||
for (let x = 0; x < 16; x++) {
|
||||
const blockX = x + data.x
|
||||
const blockZ = z + data.z
|
||||
blockPos.x = blockX
|
||||
blockPos.z = blockZ
|
||||
blockPos.y = world.config.worldMaxY
|
||||
let block = world.getBlock(blockPos)
|
||||
while (block && INVISIBLE_BLOCKS.has(block.name) && blockPos.y > world.config.worldMinY) {
|
||||
blockPos.y -= 1
|
||||
block = world.getBlock(blockPos)
|
||||
}
|
||||
const index = z * 16 + x
|
||||
heightmap[index] = block ? blockPos.y : 0
|
||||
}
|
||||
}
|
||||
postMessage({ type: 'heightmap', key: `${Math.floor(data.x / 16)},${Math.floor(data.z / 16)}`, heightmap })
|
||||
|
||||
break
|
||||
}
|
||||
// No default
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Vec3 } from 'vec3'
|
|||
import worldBlockProvider, { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider'
|
||||
import legacyJson from '../../../../src/preflatMap.json'
|
||||
import { BlockType } from '../../../playground/shared'
|
||||
import { World, BlockModelPartsResolved, WorldBlock as Block, WorldBlock } from './world'
|
||||
import { World, BlockModelPartsResolved, WorldBlock as Block } from './world'
|
||||
import { BlockElement, buildRotationMatrix, elemFaces, matmul3, matmulmat3, vecadd3, vecsub3 } from './modelsGeometryCommon'
|
||||
import { INVISIBLE_BLOCKS } from './worldConstants'
|
||||
import { MesherGeometryOutput, HighestBlockInfo } from './shared'
|
||||
|
|
@ -103,8 +103,7 @@ function tintToGl (tint) {
|
|||
return [r / 255, g / 255, b / 255]
|
||||
}
|
||||
|
||||
function getLiquidRenderHeight (world: World, block: WorldBlock | null, type: number, pos: Vec3, isWater: boolean, isRealWater: boolean) {
|
||||
if ((isWater && !isRealWater) || (block && isBlockWaterlogged(block))) return 8 / 9
|
||||
function getLiquidRenderHeight (world, block, type, pos) {
|
||||
if (!block || block.type !== type) return 1 / 9
|
||||
if (block.metadata === 0) { // source block
|
||||
const blockAbove = world.getBlock(pos.offset(0, 1, 0))
|
||||
|
|
@ -125,19 +124,12 @@ const isCube = (block: Block) => {
|
|||
}))
|
||||
}
|
||||
|
||||
const getVec = (v: Vec3, dir: Vec3) => {
|
||||
for (const coord of ['x', 'y', 'z']) {
|
||||
if (Math.abs(dir[coord]) > 0) v[coord] = 0
|
||||
}
|
||||
return v.plus(dir)
|
||||
}
|
||||
|
||||
function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, type: number, biome: string, water: boolean, attr: MesherGeometryOutput, isRealWater: boolean) {
|
||||
function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, type: number, biome: string, water: boolean, attr: Record<string, any>) {
|
||||
const heights: number[] = []
|
||||
for (let z = -1; z <= 1; z++) {
|
||||
for (let x = -1; x <= 1; x++) {
|
||||
const pos = cursor.offset(x, 0, z)
|
||||
heights.push(getLiquidRenderHeight(world, world.getBlock(pos), type, pos, water, isRealWater))
|
||||
heights.push(getLiquidRenderHeight(world, world.getBlock(pos), type, pos))
|
||||
}
|
||||
}
|
||||
const cornerHeights = [
|
||||
|
|
@ -149,14 +141,15 @@ function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, typ
|
|||
|
||||
// eslint-disable-next-line guard-for-in
|
||||
for (const face in elemFaces) {
|
||||
const { dir, corners, mask1, mask2 } = elemFaces[face]
|
||||
const { dir, corners } = elemFaces[face]
|
||||
const isUp = dir[1] === 1
|
||||
|
||||
const neighborPos = cursor.offset(...dir as [number, number, number])
|
||||
const neighbor = world.getBlock(neighborPos)
|
||||
if (!neighbor) continue
|
||||
if (neighbor.type === type || (water && (neighbor.name === 'water' || isBlockWaterlogged(neighbor)))) continue
|
||||
if (isCube(neighbor) && !isUp) continue
|
||||
if (neighbor.type === type) continue
|
||||
const isGlass = neighbor.name.includes('glass')
|
||||
if ((isCube(neighbor) && !isUp) || neighbor.material === 'plant' || neighbor.getProperties().waterlogged) continue
|
||||
|
||||
let tint = [1, 1, 1]
|
||||
if (water) {
|
||||
|
|
@ -187,44 +180,16 @@ function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, typ
|
|||
const { su } = texture
|
||||
const { sv } = texture
|
||||
|
||||
// Get base light value for the face
|
||||
const baseLight = world.getLight(neighborPos, undefined, undefined, water ? 'water' : 'lava') / 15
|
||||
|
||||
for (const pos of corners) {
|
||||
const height = cornerHeights[pos[2] * 2 + pos[0]]
|
||||
const OFFSET = 0.0001
|
||||
attr.t_positions!.push(
|
||||
(pos[0] ? 1 - OFFSET : OFFSET) + (cursor.x & 15) - 8,
|
||||
(pos[1] ? height - OFFSET : OFFSET) + (cursor.y & 15) - 8,
|
||||
(pos[2] ? 1 - OFFSET : OFFSET) + (cursor.z & 15) - 8
|
||||
attr.t_positions.push(
|
||||
(pos[0] ? 0.999 : 0.001) + (cursor.x & 15) - 8,
|
||||
(pos[1] ? height - 0.001 : 0.001) + (cursor.y & 15) - 8,
|
||||
(pos[2] ? 0.999 : 0.001) + (cursor.z & 15) - 8
|
||||
)
|
||||
attr.t_normals!.push(...dir)
|
||||
attr.t_uvs!.push(pos[3] * su + u, pos[4] * sv * (pos[1] ? 1 : height) + v)
|
||||
|
||||
let cornerLightResult = baseLight
|
||||
if (world.config.smoothLighting) {
|
||||
const dx = pos[0] * 2 - 1
|
||||
const dy = pos[1] * 2 - 1
|
||||
const dz = pos[2] * 2 - 1
|
||||
const cornerDir: [number, number, number] = [dx, dy, dz]
|
||||
const side1Dir: [number, number, number] = [dx * mask1[0], dy * mask1[1], dz * mask1[2]]
|
||||
const side2Dir: [number, number, number] = [dx * mask2[0], dy * mask2[1], dz * mask2[2]]
|
||||
|
||||
const dirVec = new Vec3(...dir as [number, number, number])
|
||||
|
||||
const side1LightDir = getVec(new Vec3(...side1Dir), dirVec)
|
||||
const side1Light = world.getLight(cursor.plus(side1LightDir)) / 15
|
||||
const side2DirLight = getVec(new Vec3(...side2Dir), dirVec)
|
||||
const side2Light = world.getLight(cursor.plus(side2DirLight)) / 15
|
||||
const cornerLightDir = getVec(new Vec3(...cornerDir), dirVec)
|
||||
const cornerLight = world.getLight(cursor.plus(cornerLightDir)) / 15
|
||||
// interpolate
|
||||
const lights = [side1Light, side2Light, cornerLight, baseLight]
|
||||
cornerLightResult = lights.reduce((acc, cur) => acc + cur, 0) / lights.length
|
||||
}
|
||||
|
||||
// Apply light value to tint
|
||||
attr.t_colors!.push(tint[0] * cornerLightResult, tint[1] * cornerLightResult, tint[2] * cornerLightResult)
|
||||
attr.t_normals.push(...dir)
|
||||
attr.t_uvs.push(pos[3] * su + u, pos[4] * sv * (pos[1] ? 1 : height) + v)
|
||||
attr.t_colors.push(tint[0], tint[1], tint[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -288,7 +253,7 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO:
|
|||
if (!neighbor.transparent && (isCube(neighbor) || identicalCull(element, neighbor, new Vec3(...dir)))) continue
|
||||
} else {
|
||||
needSectionRecomputeOnChange = true
|
||||
// continue
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -336,7 +301,7 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO:
|
|||
let localShift = null as any
|
||||
|
||||
if (element.rotation && !needTiles) {
|
||||
// Rescale support for block model rotations
|
||||
// todo do we support rescale?
|
||||
localMatrix = buildRotationMatrix(
|
||||
element.rotation.axis,
|
||||
element.rotation.angle
|
||||
|
|
@ -349,37 +314,6 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO:
|
|||
element.rotation.origin
|
||||
)
|
||||
)
|
||||
|
||||
// Apply rescale if specified
|
||||
if (element.rotation.rescale) {
|
||||
const FIT_TO_BLOCK_SCALE_MULTIPLIER = 2 - Math.sqrt(2)
|
||||
const angleRad = element.rotation.angle * Math.PI / 180
|
||||
const scale = Math.abs(Math.sin(angleRad)) * FIT_TO_BLOCK_SCALE_MULTIPLIER
|
||||
|
||||
// Get axis vector components (1 for the rotation axis, 0 for others)
|
||||
const axisX = element.rotation.axis === 'x' ? 1 : 0
|
||||
const axisY = element.rotation.axis === 'y' ? 1 : 0
|
||||
const axisZ = element.rotation.axis === 'z' ? 1 : 0
|
||||
|
||||
// Create scale matrix: scale = (1 - axisComponent) * scaleFactor + 1
|
||||
const scaleMatrix = [
|
||||
[(1 - axisX) * scale + 1, 0, 0],
|
||||
[0, (1 - axisY) * scale + 1, 0],
|
||||
[0, 0, (1 - axisZ) * scale + 1]
|
||||
]
|
||||
|
||||
// Apply scaling to the transformation matrix
|
||||
localMatrix = matmulmat3(localMatrix, scaleMatrix)
|
||||
|
||||
// Recalculate shift with the new matrix
|
||||
localShift = vecsub3(
|
||||
element.rotation.origin,
|
||||
matmul3(
|
||||
localMatrix,
|
||||
element.rotation.origin
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const aos: number[] = []
|
||||
|
|
@ -489,19 +423,13 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO:
|
|||
|
||||
if (!needTiles) {
|
||||
if (doAO && aos[0] + aos[3] >= aos[1] + aos[2]) {
|
||||
attr.indices[attr.indicesCount++] = ndx
|
||||
attr.indices[attr.indicesCount++] = ndx + 3
|
||||
attr.indices[attr.indicesCount++] = ndx + 2
|
||||
attr.indices[attr.indicesCount++] = ndx
|
||||
attr.indices[attr.indicesCount++] = ndx + 1
|
||||
attr.indices[attr.indicesCount++] = ndx + 3
|
||||
attr.indices.push(
|
||||
ndx, ndx + 3, ndx + 2, ndx, ndx + 1, ndx + 3
|
||||
)
|
||||
} else {
|
||||
attr.indices[attr.indicesCount++] = ndx
|
||||
attr.indices[attr.indicesCount++] = ndx + 1
|
||||
attr.indices[attr.indicesCount++] = ndx + 2
|
||||
attr.indices[attr.indicesCount++] = ndx + 2
|
||||
attr.indices[attr.indicesCount++] = ndx + 1
|
||||
attr.indices[attr.indicesCount++] = ndx + 3
|
||||
attr.indices.push(
|
||||
ndx, ndx + 1, ndx + 2, ndx + 2, ndx + 1, ndx + 3
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -519,7 +447,7 @@ const isBlockWaterlogged = (block: Block) => {
|
|||
}
|
||||
|
||||
let unknownBlockModel: BlockModelPartsResolved
|
||||
export function getSectionGeometry (sx: number, sy: number, sz: number, world: World) {
|
||||
export function getSectionGeometry (sx, sy, sz, world: World) {
|
||||
let delayedRender = [] as Array<() => void>
|
||||
|
||||
const attr: MesherGeometryOutput = {
|
||||
|
|
@ -535,13 +463,12 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W
|
|||
t_colors: [],
|
||||
t_uvs: [],
|
||||
indices: [],
|
||||
indicesCount: 0, // Track current index position
|
||||
using32Array: true,
|
||||
tiles: {},
|
||||
// todo this can be removed here
|
||||
heads: {},
|
||||
signs: {},
|
||||
// isFull: true,
|
||||
highestBlocks: {},
|
||||
hadErrors: false,
|
||||
blocksCount: 0
|
||||
}
|
||||
|
|
@ -551,6 +478,12 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W
|
|||
for (cursor.z = sz; cursor.z < sz + 16; cursor.z++) {
|
||||
for (cursor.x = sx; cursor.x < sx + 16; cursor.x++) {
|
||||
let block = world.getBlock(cursor, blockProvider, attr)!
|
||||
if (!INVISIBLE_BLOCKS.has(block.name)) {
|
||||
const highest = attr.highestBlocks[`${cursor.x},${cursor.z}`]
|
||||
if (!highest || highest.y < cursor.y) {
|
||||
attr.highestBlocks[`${cursor.x},${cursor.z}`] = { y: cursor.y, stateId: block.stateId, biomeId: block.biome.id }
|
||||
}
|
||||
}
|
||||
if (INVISIBLE_BLOCKS.has(block.name)) continue
|
||||
if ((block.name.includes('_sign') || block.name === 'sign') && !world.config.disableSignsMapsSupport) {
|
||||
const key = `${cursor.x},${cursor.y},${cursor.z}`
|
||||
|
|
@ -606,11 +539,11 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W
|
|||
const pos = cursor.clone()
|
||||
// eslint-disable-next-line @typescript-eslint/no-loop-func
|
||||
delayedRender.push(() => {
|
||||
renderLiquid(world, pos, blockProvider.getTextureInfo('water_still'), block.type, biome, true, attr, !isWaterlogged)
|
||||
renderLiquid(world, pos, blockProvider.getTextureInfo('water_still'), block.type, biome, true, attr)
|
||||
})
|
||||
attr.blocksCount++
|
||||
} else if (block.name === 'lava') {
|
||||
renderLiquid(world, cursor, blockProvider.getTextureInfo('lava_still'), block.type, biome, false, attr, false)
|
||||
renderLiquid(world, cursor, blockProvider.getTextureInfo('lava_still'), block.type, biome, false, attr)
|
||||
attr.blocksCount++
|
||||
}
|
||||
if (block.name !== 'water' && block.name !== 'lava' && !INVISIBLE_BLOCKS.has(block.name)) {
|
||||
|
|
@ -672,19 +605,12 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W
|
|||
|
||||
let ndx = attr.positions.length / 3
|
||||
for (let i = 0; i < attr.t_positions!.length / 12; i++) {
|
||||
attr.indices[attr.indicesCount++] = ndx
|
||||
attr.indices[attr.indicesCount++] = ndx + 1
|
||||
attr.indices[attr.indicesCount++] = ndx + 2
|
||||
attr.indices[attr.indicesCount++] = ndx + 2
|
||||
attr.indices[attr.indicesCount++] = ndx + 1
|
||||
attr.indices[attr.indicesCount++] = ndx + 3
|
||||
// back face
|
||||
attr.indices[attr.indicesCount++] = ndx
|
||||
attr.indices[attr.indicesCount++] = ndx + 2
|
||||
attr.indices[attr.indicesCount++] = ndx + 1
|
||||
attr.indices[attr.indicesCount++] = ndx + 2
|
||||
attr.indices[attr.indicesCount++] = ndx + 3
|
||||
attr.indices[attr.indicesCount++] = ndx + 1
|
||||
attr.indices.push(
|
||||
ndx, ndx + 1, ndx + 2, ndx + 2, ndx + 1, ndx + 3,
|
||||
// eslint-disable-next-line @stylistic/function-call-argument-newline
|
||||
// back face
|
||||
ndx, ndx + 2, ndx + 1, ndx + 2, ndx + 3, ndx + 1
|
||||
)
|
||||
ndx += 4
|
||||
}
|
||||
|
||||
|
|
@ -702,12 +628,6 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W
|
|||
attr.normals = new Float32Array(attr.normals) as any
|
||||
attr.colors = new Float32Array(attr.colors) as any
|
||||
attr.uvs = new Float32Array(attr.uvs) as any
|
||||
attr.using32Array = arrayNeedsUint32(attr.indices)
|
||||
if (attr.using32Array) {
|
||||
attr.indices = new Uint32Array(attr.indices)
|
||||
} else {
|
||||
attr.indices = new Uint16Array(attr.indices)
|
||||
}
|
||||
|
||||
if (needTiles) {
|
||||
delete attr.positions
|
||||
|
|
@ -719,21 +639,6 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W
|
|||
return attr
|
||||
}
|
||||
|
||||
// copied from three.js
|
||||
function arrayNeedsUint32 (array) {
|
||||
|
||||
// assumes larger values usually on last
|
||||
|
||||
for (let i = array.length - 1; i >= 0; -- i) {
|
||||
|
||||
if (array[i] >= 65_535) return true // account for PRIMITIVE_RESTART_FIXED_INDEX, #24565
|
||||
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
}
|
||||
|
||||
export const setBlockStatesData = (blockstatesModels, blocksAtlas: any, _needTiles = false, useUnknownBlockModel = true, version = 'latest') => {
|
||||
blockProvider = worldBlockProvider(blockstatesModels, blocksAtlas, version)
|
||||
globalThis.blockProvider = blockProvider
|
||||
|
|
|
|||
|
|
@ -122,10 +122,10 @@ export const elemFaces = {
|
|||
mask1: [1, 0, 1],
|
||||
mask2: [0, 1, 1],
|
||||
corners: [
|
||||
[1, 0, 0, 1, 1],
|
||||
[0, 0, 0, 0, 1],
|
||||
[1, 1, 0, 1, 0],
|
||||
[0, 1, 0, 0, 0]
|
||||
[1, 0, 0, 0, 1],
|
||||
[0, 0, 0, 1, 1],
|
||||
[1, 1, 0, 0, 0],
|
||||
[0, 1, 0, 1, 0]
|
||||
]
|
||||
},
|
||||
south: {
|
||||
|
|
|
|||
|
|
@ -3,13 +3,11 @@ import { BlockType } from '../../../playground/shared'
|
|||
// only here for easier testing
|
||||
export const defaultMesherConfig = {
|
||||
version: '',
|
||||
worldMaxY: 256,
|
||||
worldMinY: 0,
|
||||
enableLighting: true,
|
||||
skyLight: 15,
|
||||
smoothLighting: true,
|
||||
outputFormat: 'threeJs' as 'threeJs' | 'webgpu',
|
||||
// textureSize: 1024, // for testing
|
||||
textureSize: 1024, // for testing
|
||||
debugModelVariant: undefined as undefined | number[],
|
||||
clipWorldBelowY: undefined as undefined | number,
|
||||
disableSignsMapsSupport: false
|
||||
|
|
@ -35,27 +33,17 @@ export type MesherGeometryOutput = {
|
|||
t_colors?: number[],
|
||||
t_uvs?: number[],
|
||||
|
||||
indices: Uint32Array | Uint16Array | number[],
|
||||
indicesCount: number,
|
||||
using32Array: boolean,
|
||||
indices: number[],
|
||||
tiles: Record<string, BlockType>,
|
||||
heads: Record<string, any>,
|
||||
signs: Record<string, any>,
|
||||
// isFull: boolean
|
||||
highestBlocks: Record<string, HighestBlockInfo>
|
||||
hadErrors: boolean
|
||||
blocksCount: number
|
||||
customBlockModels?: CustomBlockModels
|
||||
}
|
||||
|
||||
export interface MesherMainEvents {
|
||||
geometry: { type: 'geometry'; key: string; geometry: MesherGeometryOutput; workerIndex: number };
|
||||
sectionFinished: { type: 'sectionFinished'; key: string; workerIndex: number; processTime?: number };
|
||||
blockStateModelInfo: { type: 'blockStateModelInfo'; info: Record<string, BlockStateModelInfo> };
|
||||
heightmap: { type: 'heightmap'; key: string; heightmap: Uint8Array };
|
||||
}
|
||||
|
||||
export type MesherMainEvent = MesherMainEvents[keyof MesherMainEvents]
|
||||
|
||||
export type HighestBlockInfo = { y: number, stateId: number | undefined, biomeId: number | undefined }
|
||||
|
||||
export type BlockStateModelInfo = {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ test('Known blocks are not rendered', () => {
|
|||
// TODO resolve creaking_heart issue (1.21.3)
|
||||
expect(missingBlocks).toMatchInlineSnapshot(`
|
||||
{
|
||||
"creaking_heart": true,
|
||||
"end_gateway": true,
|
||||
"end_portal": true,
|
||||
"structure_void": true,
|
||||
}
|
||||
`)
|
||||
|
|
|
|||
|
|
@ -1,131 +0,0 @@
|
|||
/* eslint-disable no-await-in-loop */
|
||||
import { Vec3 } from 'vec3'
|
||||
|
||||
// import log from '../../../../../Downloads/mesher (2).log'
|
||||
import { WorldRendererCommon } from './worldrendererCommon'
|
||||
const log = ''
|
||||
|
||||
|
||||
export class MesherLogReader {
|
||||
chunksToReceive: Array<{
|
||||
x: number
|
||||
z: number
|
||||
chunkLength: number
|
||||
}> = []
|
||||
messagesQueue: Array<{
|
||||
fromWorker: boolean
|
||||
workerIndex: number
|
||||
message: any
|
||||
}> = []
|
||||
|
||||
sectionFinishedToReceive = null as {
|
||||
messagesLeft: string[]
|
||||
resolve: () => void
|
||||
} | null
|
||||
replayStarted = false
|
||||
|
||||
constructor (private readonly worldRenderer: WorldRendererCommon) {
|
||||
this.parseMesherLog()
|
||||
}
|
||||
|
||||
chunkReceived (x: number, z: number, chunkLength: number) {
|
||||
// remove existing chunks with same x and z
|
||||
const existingChunkIndex = this.chunksToReceive.findIndex(chunk => chunk.x === x && chunk.z === z)
|
||||
if (existingChunkIndex === -1) {
|
||||
// console.error('Chunk not found', x, z)
|
||||
} else {
|
||||
// warn if chunkLength is different
|
||||
if (this.chunksToReceive[existingChunkIndex].chunkLength !== chunkLength) {
|
||||
// console.warn('Chunk length mismatch', x, z, this.chunksToReceive[existingChunkIndex].chunkLength, chunkLength)
|
||||
}
|
||||
// remove chunk
|
||||
this.chunksToReceive = this.chunksToReceive.filter((chunk, index) => chunk.x !== x || chunk.z !== z)
|
||||
}
|
||||
this.maybeStartReplay()
|
||||
}
|
||||
|
||||
async maybeStartReplay () {
|
||||
if (this.chunksToReceive.length !== 0 || this.replayStarted) return
|
||||
const lines = log.split('\n')
|
||||
console.log('starting replay')
|
||||
this.replayStarted = true
|
||||
const waitForWorkersMessages = async () => {
|
||||
if (!this.sectionFinishedToReceive) return
|
||||
await new Promise<void>(resolve => {
|
||||
this.sectionFinishedToReceive!.resolve = resolve
|
||||
})
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes('dispatchMessages dirty')) {
|
||||
await waitForWorkersMessages()
|
||||
this.worldRenderer.stopMesherMessagesProcessing = true
|
||||
const message = JSON.parse(line.slice(line.indexOf('{'), line.lastIndexOf('}') + 1))
|
||||
if (!message.value) continue
|
||||
const index = line.split(' ')[1]
|
||||
const type = line.split(' ')[3]
|
||||
// console.log('sending message', message.x, message.y, message.z)
|
||||
this.worldRenderer.forceCallFromMesherReplayer = true
|
||||
this.worldRenderer.setSectionDirty(new Vec3(message.x, message.y, message.z), message.value)
|
||||
this.worldRenderer.forceCallFromMesherReplayer = false
|
||||
}
|
||||
if (line.includes('-> blockUpdate')) {
|
||||
await waitForWorkersMessages()
|
||||
this.worldRenderer.stopMesherMessagesProcessing = true
|
||||
const message = JSON.parse(line.slice(line.indexOf('{'), line.lastIndexOf('}') + 1))
|
||||
this.worldRenderer.forceCallFromMesherReplayer = true
|
||||
this.worldRenderer.setBlockStateIdInner(new Vec3(message.pos.x, message.pos.y, message.pos.z), message.stateId)
|
||||
this.worldRenderer.forceCallFromMesherReplayer = false
|
||||
}
|
||||
|
||||
if (line.includes(' sectionFinished ')) {
|
||||
if (!this.sectionFinishedToReceive) {
|
||||
console.log('starting worker message processing validating')
|
||||
this.worldRenderer.stopMesherMessagesProcessing = false
|
||||
this.sectionFinishedToReceive = {
|
||||
messagesLeft: [],
|
||||
resolve: () => {
|
||||
this.sectionFinishedToReceive = null
|
||||
}
|
||||
}
|
||||
}
|
||||
const parts = line.split(' ')
|
||||
const coordsPart = parts.find(part => part.split(',').length === 3)
|
||||
if (!coordsPart) throw new Error(`no coords part found ${line}`)
|
||||
const [x, y, z] = coordsPart.split(',').map(Number)
|
||||
this.sectionFinishedToReceive.messagesLeft.push(`${x},${y},${z}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
workerMessageReceived (type: string, message: any) {
|
||||
if (type === 'sectionFinished') {
|
||||
const { key } = message
|
||||
if (!this.sectionFinishedToReceive) {
|
||||
console.warn(`received sectionFinished message but no sectionFinishedToReceive ${key}`)
|
||||
return
|
||||
}
|
||||
|
||||
const idx = this.sectionFinishedToReceive.messagesLeft.indexOf(key)
|
||||
if (idx === -1) {
|
||||
console.warn(`received sectionFinished message for non-outstanding section ${key}`)
|
||||
return
|
||||
}
|
||||
this.sectionFinishedToReceive.messagesLeft.splice(idx, 1)
|
||||
if (this.sectionFinishedToReceive.messagesLeft.length === 0) {
|
||||
this.sectionFinishedToReceive.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parseMesherLog () {
|
||||
const lines = log.split('\n')
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('-> chunk')) {
|
||||
const chunk = JSON.parse(line.slice('-> chunk'.length))
|
||||
this.chunksToReceive.push(chunk)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
renderer/viewer/lib/renderUtils.js
Normal file
11
renderer/viewer/lib/renderUtils.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { fromFormattedString } from '@xmcl/text-component'
|
||||
|
||||
export const formattedStringToSimpleString = (str) => {
|
||||
const result = fromFormattedString(str)
|
||||
str = result.text
|
||||
// todo recursive
|
||||
for (const extra of result.extra) {
|
||||
str += extra.text
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ let lastY = 40
|
|||
export const addNewStat = (id: string, width = 80, x = rightOffset, y = lastY) => {
|
||||
const pane = document.createElement('div')
|
||||
pane.style.position = 'fixed'
|
||||
pane.style.top = `${y ?? lastY}px`
|
||||
pane.style.top = `${y}px`
|
||||
pane.style.right = `${x}px`
|
||||
// gray bg
|
||||
pane.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'
|
||||
|
|
@ -19,7 +19,7 @@ export const addNewStat = (id: string, width = 80, x = rightOffset, y = lastY) =
|
|||
pane.style.pointerEvents = 'none'
|
||||
document.body.appendChild(pane)
|
||||
stats[id] = pane
|
||||
if (y === undefined && x === rightOffset) { // otherwise it's a custom position
|
||||
if (y === 0) { // otherwise it's a custom position
|
||||
// rightOffset += width
|
||||
lastY += 20
|
||||
}
|
||||
|
|
@ -35,62 +35,11 @@ export const addNewStat = (id: string, width = 80, x = rightOffset, y = lastY) =
|
|||
}
|
||||
}
|
||||
|
||||
export const addNewStat2 = (id: string, { top, bottom, right, left, displayOnlyWhenWider }: { top?: number, bottom?: number, right?: number, left?: number, displayOnlyWhenWider?: number }) => {
|
||||
if (top === undefined && bottom === undefined) top = 0
|
||||
const pane = document.createElement('div')
|
||||
pane.style.position = 'fixed'
|
||||
if (top !== undefined) {
|
||||
pane.style.top = `${top}px`
|
||||
}
|
||||
if (bottom !== undefined) {
|
||||
pane.style.bottom = `${bottom}px`
|
||||
}
|
||||
if (left !== undefined) {
|
||||
pane.style.left = `${left}px`
|
||||
}
|
||||
if (right !== undefined) {
|
||||
pane.style.right = `${right}px`
|
||||
}
|
||||
// gray bg
|
||||
pane.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'
|
||||
pane.style.color = 'white'
|
||||
pane.style.padding = '2px'
|
||||
pane.style.fontFamily = 'monospace'
|
||||
pane.style.fontSize = '12px'
|
||||
pane.style.zIndex = '10000'
|
||||
pane.style.pointerEvents = 'none'
|
||||
document.body.appendChild(pane)
|
||||
stats[id] = pane
|
||||
|
||||
const resizeCheck = () => {
|
||||
if (!displayOnlyWhenWider) return
|
||||
pane.style.display = window.innerWidth > displayOnlyWhenWider ? 'block' : 'none'
|
||||
}
|
||||
window.addEventListener('resize', resizeCheck)
|
||||
resizeCheck()
|
||||
|
||||
return {
|
||||
updateText (text: string) {
|
||||
pane.innerText = text
|
||||
},
|
||||
setVisibility (visible: boolean) {
|
||||
pane.style.display = visible ? 'block' : 'none'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const updateStatText = (id, text) => {
|
||||
if (!stats[id]) return
|
||||
stats[id].innerText = text
|
||||
}
|
||||
|
||||
export const updatePanesVisibility = (visible: boolean) => {
|
||||
// eslint-disable-next-line guard-for-in
|
||||
for (const id in stats) {
|
||||
stats[id].style.display = visible ? 'block' : 'none'
|
||||
}
|
||||
}
|
||||
|
||||
export const removeAllStats = () => {
|
||||
// eslint-disable-next-line guard-for-in
|
||||
for (const id in stats) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,28 @@
|
|||
export const loadScript = async function (scriptSrc: string, highPriority = true): Promise<HTMLScriptElement> {
|
||||
import * as THREE from 'three'
|
||||
|
||||
let textureCache: Record<string, THREE.Texture> = {}
|
||||
let imagesPromises: Record<string, Promise<THREE.Texture>> = {}
|
||||
|
||||
export async function loadTexture (texture: string, cb: (texture: THREE.Texture) => void, onLoad?: () => void): Promise<void> {
|
||||
const cached = textureCache[texture]
|
||||
if (!cached) {
|
||||
const { promise, resolve } = Promise.withResolvers<THREE.Texture>()
|
||||
textureCache[texture] = new THREE.TextureLoader().load(texture, resolve)
|
||||
imagesPromises[texture] = promise
|
||||
}
|
||||
|
||||
cb(textureCache[texture])
|
||||
void imagesPromises[texture].then(() => {
|
||||
onLoad?.()
|
||||
})
|
||||
}
|
||||
|
||||
export const clearTextureCache = () => {
|
||||
textureCache = {}
|
||||
imagesPromises = {}
|
||||
}
|
||||
|
||||
export const loadScript = async function (scriptSrc: string): Promise<HTMLScriptElement> {
|
||||
const existingScript = document.querySelector<HTMLScriptElement>(`script[src="${scriptSrc}"]`)
|
||||
if (existingScript) {
|
||||
return existingScript
|
||||
|
|
@ -7,10 +31,6 @@ export const loadScript = async function (scriptSrc: string, highPriority = true
|
|||
return new Promise((resolve, reject) => {
|
||||
const scriptElement = document.createElement('script')
|
||||
scriptElement.src = scriptSrc
|
||||
|
||||
if (highPriority) {
|
||||
scriptElement.fetchPriority = 'high'
|
||||
}
|
||||
scriptElement.async = true
|
||||
|
||||
scriptElement.addEventListener('load', () => {
|
||||
|
|
@ -25,33 +45,3 @@ export const loadScript = async function (scriptSrc: string, highPriority = true
|
|||
document.head.appendChild(scriptElement)
|
||||
})
|
||||
}
|
||||
|
||||
const detectFullOffscreenCanvasSupport = () => {
|
||||
if (typeof OffscreenCanvas === 'undefined') return false
|
||||
try {
|
||||
const canvas = new OffscreenCanvas(1, 1)
|
||||
// Try to get a WebGL context - this will fail on iOS where only 2D is supported (iOS 16)
|
||||
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl')
|
||||
return gl !== null
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const hasFullOffscreenCanvasSupport = detectFullOffscreenCanvasSupport()
|
||||
|
||||
export const createCanvas = (width: number, height: number): OffscreenCanvas => {
|
||||
if (hasFullOffscreenCanvasSupport) {
|
||||
return new OffscreenCanvas(width, height)
|
||||
}
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
return canvas as unknown as OffscreenCanvas // todo-low
|
||||
}
|
||||
|
||||
export async function loadImageFromUrl (imageUrl: string): Promise<ImageBitmap> {
|
||||
const response = await fetch(imageUrl)
|
||||
const blob = await response.blob()
|
||||
return createImageBitmap(blob)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +1,27 @@
|
|||
import { loadSkinToCanvas } from 'skinview-utils'
|
||||
import { createCanvas, loadImageFromUrl } from '../utils'
|
||||
import * as THREE from 'three'
|
||||
import stevePng from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png'
|
||||
|
||||
export { default as stevePngUrl } from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png'
|
||||
// eslint-disable-next-line unicorn/prefer-export-from
|
||||
export const stevePngUrl = stevePng
|
||||
export const steveTexture = new THREE.TextureLoader().loadAsync(stevePng)
|
||||
|
||||
const config = {
|
||||
apiEnabled: true,
|
||||
export async function loadImageFromUrl (imageUrl: string): Promise<HTMLImageElement> {
|
||||
const img = new Image()
|
||||
img.src = imageUrl
|
||||
await new Promise<void>(resolve => {
|
||||
img.onload = () => resolve()
|
||||
})
|
||||
return img
|
||||
}
|
||||
|
||||
export const setSkinsConfig = (newConfig: Partial<typeof config>) => {
|
||||
Object.assign(config, newConfig)
|
||||
export function getLookupUrl (username: string, type: 'skin' | 'cape'): string {
|
||||
return `https://mulv.tycrek.dev/api/lookup?username=${username}&type=${type}`
|
||||
}
|
||||
|
||||
export async function loadSkinFromUsername (username: string, type: 'skin' | 'cape'): Promise<string | undefined> {
|
||||
if (!config.apiEnabled) return
|
||||
|
||||
if (type === 'cape') return
|
||||
const url = `https://playerdb.co/api/player/minecraft/${username}`
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) return
|
||||
|
||||
const data: {
|
||||
data: {
|
||||
player: {
|
||||
skin_texture: string
|
||||
}
|
||||
}
|
||||
} = await response.json()
|
||||
return data.data.player.skin_texture
|
||||
}
|
||||
|
||||
export const parseSkinTexturesValue = (value: string) => {
|
||||
const decodedData: {
|
||||
textures: {
|
||||
SKIN: {
|
||||
url: string
|
||||
}
|
||||
}
|
||||
} = JSON.parse(Buffer.from(value, 'base64').toString())
|
||||
return decodedData.textures?.SKIN?.url
|
||||
}
|
||||
|
||||
export async function loadSkinImage (skinUrl: string): Promise<{ canvas: OffscreenCanvas, image: ImageBitmap }> {
|
||||
if (!skinUrl.startsWith('data:')) {
|
||||
skinUrl = await fetchAndConvertBase64Skin(skinUrl.replace('http://', 'https://'))
|
||||
}
|
||||
|
||||
export async function loadSkinImage (skinUrl: string): Promise<{ canvas: HTMLCanvasElement, image: HTMLImageElement }> {
|
||||
const image = await loadImageFromUrl(skinUrl)
|
||||
const skinCanvas = createCanvas(64, 64)
|
||||
const skinCanvas = document.createElement('canvas')
|
||||
loadSkinToCanvas(skinCanvas, image)
|
||||
return { canvas: skinCanvas, image }
|
||||
}
|
||||
|
||||
const fetchAndConvertBase64Skin = async (skinUrl: string) => {
|
||||
const response = await fetch(skinUrl, { })
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const base64 = Buffer.from(arrayBuffer).toString('base64')
|
||||
return `data:image/png;base64,${base64}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,8 @@
|
|||
import { proxy, getVersion, subscribe } from 'valtio'
|
||||
|
||||
export function createWorkerProxy<T extends Record<string, (...args: any[]) => void | Promise<any>>> (handlers: T, channel?: MessagePort): { __workerProxy: T } {
|
||||
const target = channel ?? globalThis
|
||||
target.addEventListener('message', (event: any) => {
|
||||
const { type, args, msgId } = event.data
|
||||
export function createWorkerProxy<T extends Record<string, (...args: any[]) => void>> (handlers: T): { __workerProxy: T } {
|
||||
addEventListener('message', (event) => {
|
||||
const { type, args } = event.data
|
||||
if (handlers[type]) {
|
||||
const result = handlers[type](...args)
|
||||
if (result instanceof Promise) {
|
||||
void result.then((result) => {
|
||||
target.postMessage({
|
||||
type: 'result',
|
||||
msgId,
|
||||
args: [result]
|
||||
})
|
||||
})
|
||||
}
|
||||
handlers[type](...args)
|
||||
}
|
||||
})
|
||||
return null as any
|
||||
|
|
@ -31,10 +19,9 @@ export function createWorkerProxy<T extends Record<string, (...args: any[]) => v
|
|||
* const workerChannel = useWorkerProxy<typeof importedTypeWorkerProxy>(worker)
|
||||
* ```
|
||||
*/
|
||||
export const useWorkerProxy = <T extends { __workerProxy: Record<string, (...args: any[]) => void> }> (worker: Worker | MessagePort, autoTransfer = true): T['__workerProxy'] & {
|
||||
export const useWorkerProxy = <T extends { __workerProxy: Record<string, (...args: any[]) => void> }> (worker: Worker, autoTransfer = true): T['__workerProxy'] & {
|
||||
transfer: (...args: Transferable[]) => T['__workerProxy']
|
||||
} => {
|
||||
let messageId = 0
|
||||
// in main thread
|
||||
return new Proxy({} as any, {
|
||||
get (target, prop) {
|
||||
|
|
@ -53,30 +40,11 @@ export const useWorkerProxy = <T extends { __workerProxy: Record<string, (...arg
|
|||
}
|
||||
}
|
||||
return (...args: any[]) => {
|
||||
const msgId = messageId++
|
||||
const transfer = autoTransfer ? args.filter(arg => {
|
||||
return arg instanceof ArrayBuffer || arg instanceof MessagePort
|
||||
|| (typeof ImageBitmap !== 'undefined' && arg instanceof ImageBitmap)
|
||||
|| (typeof OffscreenCanvas !== 'undefined' && arg instanceof OffscreenCanvas)
|
||||
|| (typeof ImageData !== 'undefined' && arg instanceof ImageData)
|
||||
}) : []
|
||||
const transfer = autoTransfer ? args.filter(arg => arg instanceof ArrayBuffer || arg instanceof MessagePort || arg instanceof ImageBitmap || arg instanceof OffscreenCanvas) : []
|
||||
worker.postMessage({
|
||||
type: prop,
|
||||
msgId,
|
||||
args,
|
||||
}, transfer)
|
||||
return {
|
||||
// eslint-disable-next-line unicorn/no-thenable
|
||||
then (onfulfilled: (value: any) => void) {
|
||||
const handler = ({ data }: MessageEvent): void => {
|
||||
if (data.type === 'result' && data.msgId === msgId) {
|
||||
onfulfilled(data.args[0])
|
||||
worker.removeEventListener('message', handler as EventListener)
|
||||
}
|
||||
}
|
||||
worker.addEventListener('message', handler as EventListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,64 +7,46 @@ import { Vec3 } from 'vec3'
|
|||
import { BotEvents } from 'mineflayer'
|
||||
import { proxy } from 'valtio'
|
||||
import TypedEmitter from 'typed-emitter'
|
||||
import { Biome } from 'minecraft-data'
|
||||
import { getItemFromBlock } from '../../../src/chatUtils'
|
||||
import { delayedIterator } from '../../playground/shared'
|
||||
import { playerState } from '../../../src/mineflayer/playerState'
|
||||
import { chunkPos } from './simpleUtils'
|
||||
|
||||
export type ChunkPosKey = string // like '16,16'
|
||||
type ChunkPos = { x: number, z: number } // like { x: 16, z: 16 }
|
||||
export type ChunkPosKey = string
|
||||
type ChunkPos = { x: number, z: number }
|
||||
|
||||
export type WorldDataEmitterEvents = {
|
||||
chunkPosUpdate: (data: { pos: Vec3 }) => void
|
||||
blockUpdate: (data: { pos: Vec3, stateId: number }) => void
|
||||
entity: (data: any) => void
|
||||
entityMoved: (data: any) => void
|
||||
playerEntity: (data: any) => void
|
||||
time: (data: number) => void
|
||||
renderDistance: (viewDistance: number) => void
|
||||
blockEntities: (data: Record<string, any> | { blockEntities: Record<string, any> }) => void
|
||||
listening: () => void
|
||||
markAsLoaded: (data: { x: number, z: number }) => void
|
||||
unloadChunk: (data: { x: number, z: number }) => void
|
||||
loadChunk: (data: { x: number, z: number, chunk: string, blockEntities: any, worldConfig: any, isLightUpdate: boolean }) => void
|
||||
loadChunk: (data: { x: number, z: number, chunk: any, blockEntities: any, worldConfig: any, isLightUpdate: boolean }) => void
|
||||
updateLight: (data: { pos: Vec3 }) => void
|
||||
onWorldSwitch: () => void
|
||||
end: () => void
|
||||
biomeUpdate: (data: { biome: Biome }) => void
|
||||
biomeReset: () => void
|
||||
}
|
||||
|
||||
export class WorldDataEmitterWorker extends (EventEmitter as new () => TypedEmitter<WorldDataEmitterEvents>) {
|
||||
static readonly restorerName = 'WorldDataEmitterWorker'
|
||||
}
|
||||
|
||||
/**
|
||||
* Usually connects to mineflayer bot and emits world data (chunks, entities)
|
||||
* It's up to the consumer to serialize the data if needed
|
||||
*/
|
||||
export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<WorldDataEmitterEvents>) {
|
||||
spiralNumber = 0
|
||||
gotPanicLastTime = false
|
||||
panicChunksReload = () => {}
|
||||
loadedChunks: Record<ChunkPosKey, boolean>
|
||||
private inLoading = false
|
||||
private chunkReceiveTimes: number[] = []
|
||||
private lastChunkReceiveTime = 0
|
||||
public lastChunkReceiveTimeAvg = 0
|
||||
private panicTimeout?: NodeJS.Timeout
|
||||
readonly lastPos: Vec3
|
||||
private loadedChunks: Record<ChunkPosKey, boolean>
|
||||
private readonly lastPos: Vec3
|
||||
private eventListeners: Record<string, any> = {}
|
||||
private readonly emitter: WorldDataEmitter
|
||||
debugChunksInfo: Record<ChunkPosKey, {
|
||||
loads: Array<{
|
||||
dataLength: number
|
||||
reason: string
|
||||
time: number
|
||||
}>
|
||||
// blockUpdates: number
|
||||
}> = {}
|
||||
|
||||
waitingSpiralChunksLoad = {} as Record<ChunkPosKey, (value: boolean) => void>
|
||||
|
||||
keepChunksDistance = 0
|
||||
addWaitTime = 1
|
||||
/* config */ keepChunksDistance = 0
|
||||
/* config */ isPlayground = false
|
||||
/* config */ allowPositionUpdate = true
|
||||
isPlayground = false
|
||||
|
||||
public reactive = proxy({
|
||||
cursorBlock: null as Vec3 | null,
|
||||
cursorBlockBreakingStage: null as number | null,
|
||||
})
|
||||
|
||||
constructor (public world: typeof __type_bot['world'], public viewDistance: number, position: Vec3 = new Vec3(0, 0, 0)) {
|
||||
// eslint-disable-next-line constructor-super
|
||||
|
|
@ -78,12 +60,12 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
|
|||
setBlockStateId (position: Vec3, stateId: number) {
|
||||
const val = this.world.setBlockStateId(position, stateId) as Promise<void> | void
|
||||
if (val) throw new Error('setBlockStateId returned promise (not supported)')
|
||||
// const chunkX = Math.floor(position.x / 16)
|
||||
// const chunkZ = Math.floor(position.z / 16)
|
||||
// if (!this.loadedChunks[`${chunkX},${chunkZ}`] && !this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`]) {
|
||||
// void this.loadChunk({ x: chunkX, z: chunkZ })
|
||||
// return
|
||||
// }
|
||||
const chunkX = Math.floor(position.x / 16)
|
||||
const chunkZ = Math.floor(position.z / 16)
|
||||
if (!this.loadedChunks[`${chunkX},${chunkZ}`]) {
|
||||
void this.loadChunk({ x: chunkX, z: chunkZ })
|
||||
return
|
||||
}
|
||||
|
||||
this.emit('blockUpdate', { pos: position, stateId })
|
||||
}
|
||||
|
|
@ -94,28 +76,13 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
|
|||
}
|
||||
|
||||
listenToBot (bot: typeof __type_bot) {
|
||||
const entitiesObjectData = new Map<string, number>()
|
||||
bot._client.prependListener('spawn_entity', (data) => {
|
||||
if (data.objectData && data.entityId !== undefined) {
|
||||
entitiesObjectData.set(data.entityId, data.objectData)
|
||||
}
|
||||
})
|
||||
|
||||
const emitEntity = (e, name = 'entity') => {
|
||||
if (!e) return
|
||||
if (e === bot.entity) {
|
||||
if (name === 'entity') {
|
||||
this.emitter.emit('playerEntity', e)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!e || e === bot.entity) return
|
||||
if (!e.name) return // mineflayer received update for not spawned entity
|
||||
e.objectData = entitiesObjectData.get(e.id)
|
||||
this.emitter.emit(name as any, {
|
||||
...e,
|
||||
pos: e.position,
|
||||
username: e.username,
|
||||
team: bot.teamMap[e.username] || bot.teamMap[e.uuid],
|
||||
// set debugTree (obj) {
|
||||
// e.debugTree = obj
|
||||
// }
|
||||
|
|
@ -134,9 +101,6 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
|
|||
entityUpdate (e: any) {
|
||||
emitEntity(e)
|
||||
},
|
||||
entityEquip (e: any) {
|
||||
emitEntity(e)
|
||||
},
|
||||
entityMoved (e: any) {
|
||||
emitEntity(e, 'entityMoved')
|
||||
},
|
||||
|
|
@ -144,19 +108,7 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
|
|||
this.emitter.emit('entity', { id: e.id, delete: true })
|
||||
},
|
||||
chunkColumnLoad: (pos: Vec3) => {
|
||||
const now = performance.now()
|
||||
if (this.lastChunkReceiveTime) {
|
||||
this.chunkReceiveTimes.push(now - this.lastChunkReceiveTime)
|
||||
}
|
||||
this.lastChunkReceiveTime = now
|
||||
|
||||
if (this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`]) {
|
||||
this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`](true)
|
||||
delete this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`]
|
||||
} else if (this.loadedChunks[`${pos.x},${pos.z}`]) {
|
||||
void this.loadChunk(pos, false, 'Received another chunkColumnLoad event while already loaded')
|
||||
}
|
||||
this.chunkProgress()
|
||||
void this.loadChunk(pos)
|
||||
},
|
||||
chunkColumnUnload: (pos: Vec3) => {
|
||||
this.unloadChunk(pos)
|
||||
|
|
@ -167,30 +119,31 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
|
|||
},
|
||||
time: () => {
|
||||
this.emitter.emit('time', bot.time.timeOfDay)
|
||||
},
|
||||
end: () => {
|
||||
this.emitter.emit('end')
|
||||
},
|
||||
// when dimension might change
|
||||
login: () => {
|
||||
void this.updatePosition(bot.entity.position, true)
|
||||
this.emitter.emit('playerEntity', bot.entity)
|
||||
},
|
||||
respawn: () => {
|
||||
void this.updatePosition(bot.entity.position, true)
|
||||
this.emitter.emit('playerEntity', bot.entity)
|
||||
this.emitter.emit('onWorldSwitch')
|
||||
},
|
||||
}
|
||||
} satisfies Partial<BotEvents>
|
||||
|
||||
|
||||
bot._client.on('update_light', ({ chunkX, chunkZ }) => {
|
||||
const chunkPos = new Vec3(chunkX * 16, 0, chunkZ * 16)
|
||||
if (!this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`] && this.loadedChunks[`${chunkX},${chunkZ}`]) {
|
||||
void this.loadChunk(chunkPos, true, 'update_light')
|
||||
}
|
||||
void this.loadChunk(chunkPos, true)
|
||||
})
|
||||
|
||||
this.emitter.on('listening', () => {
|
||||
this.emitter.emit('blockEntities', new Proxy({}, {
|
||||
get (_target, posKey, receiver) {
|
||||
if (typeof posKey !== 'string') return
|
||||
const [x, y, z] = posKey.split(',').map(Number)
|
||||
return bot.world.getBlock(new Vec3(x, y, z))?.entity
|
||||
},
|
||||
}))
|
||||
this.emitter.emit('renderDistance', this.viewDistance)
|
||||
this.emitter.emit('time', bot.time.timeOfDay)
|
||||
})
|
||||
// node.js stream data event pattern
|
||||
if (this.emitter.listenerCount('blockEntities')) {
|
||||
this.emitter.emit('listening')
|
||||
}
|
||||
|
||||
for (const [evt, listener] of Object.entries(this.eventListeners)) {
|
||||
bot.on(evt as any, listener)
|
||||
}
|
||||
|
|
@ -206,16 +159,6 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
|
|||
}
|
||||
}
|
||||
|
||||
emitterGotConnected () {
|
||||
this.emitter.emit('blockEntities', new Proxy({}, {
|
||||
get (_target, posKey, receiver) {
|
||||
if (typeof posKey !== 'string') return
|
||||
const [x, y, z] = posKey.split(',').map(Number)
|
||||
return bot.world.getBlock(new Vec3(x, y, z))?.entity
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
removeListenersFromBot (bot: import('mineflayer').Bot) {
|
||||
for (const [evt, listener] of Object.entries(this.eventListeners)) {
|
||||
bot.removeListener(evt as any, listener)
|
||||
|
|
@ -225,71 +168,20 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
|
|||
async init (pos: Vec3) {
|
||||
this.updateViewDistance(this.viewDistance)
|
||||
this.emitter.emit('chunkPosUpdate', { pos })
|
||||
if (bot?.time?.timeOfDay) {
|
||||
this.emitter.emit('time', bot.time.timeOfDay)
|
||||
}
|
||||
if (bot?.entity) {
|
||||
this.emitter.emit('playerEntity', bot.entity)
|
||||
}
|
||||
this.emitterGotConnected()
|
||||
const [botX, botZ] = chunkPos(pos)
|
||||
|
||||
const positions = generateSpiralMatrix(this.viewDistance).map(([x, z]) => new Vec3((botX + x) * 16, 0, (botZ + z) * 16))
|
||||
|
||||
this.lastPos.update(pos)
|
||||
await this._loadChunks(positions, pos)
|
||||
await this._loadChunks(positions)
|
||||
}
|
||||
|
||||
chunkProgress () {
|
||||
if (this.panicTimeout) clearTimeout(this.panicTimeout)
|
||||
if (this.chunkReceiveTimes.length >= 5) {
|
||||
const avgReceiveTime = this.chunkReceiveTimes.reduce((a, b) => a + b, 0) / this.chunkReceiveTimes.length
|
||||
this.lastChunkReceiveTimeAvg = avgReceiveTime
|
||||
const timeoutDelay = avgReceiveTime * 2 + 1000 // 2x average + 1 second
|
||||
|
||||
// Clear any existing timeout
|
||||
if (this.panicTimeout) clearTimeout(this.panicTimeout)
|
||||
|
||||
// Set new timeout for panic reload
|
||||
this.panicTimeout = setTimeout(() => {
|
||||
if (!this.gotPanicLastTime && this.inLoading) {
|
||||
console.warn('Chunk loading seems stuck, triggering panic reload')
|
||||
this.gotPanicLastTime = true
|
||||
this.panicChunksReload()
|
||||
}
|
||||
}, timeoutDelay)
|
||||
}
|
||||
}
|
||||
|
||||
async _loadChunks (positions: Vec3[], centerPos: Vec3) {
|
||||
this.spiralNumber++
|
||||
const { spiralNumber } = this
|
||||
// stop loading previous chunks
|
||||
for (const pos of Object.keys(this.waitingSpiralChunksLoad)) {
|
||||
this.waitingSpiralChunksLoad[pos](false)
|
||||
delete this.waitingSpiralChunksLoad[pos]
|
||||
}
|
||||
|
||||
let continueLoading = true
|
||||
this.inLoading = true
|
||||
await delayedIterator(positions, this.addWaitTime, async (pos) => {
|
||||
if (!continueLoading || this.loadedChunks[`${pos.x},${pos.z}`]) return
|
||||
|
||||
// Wait for chunk to be available from server
|
||||
if (!this.world.getColumnAt(pos)) {
|
||||
continueLoading = await new Promise<boolean>(resolve => {
|
||||
this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`] = resolve
|
||||
})
|
||||
}
|
||||
if (!continueLoading) return
|
||||
await this.loadChunk(pos, undefined, `spiral ${spiralNumber} from ${centerPos.x},${centerPos.z}`)
|
||||
this.chunkProgress()
|
||||
async _loadChunks (positions: Vec3[], sliceSize = 5) {
|
||||
const promises = [] as Array<Promise<void>>
|
||||
await delayedIterator(positions, this.addWaitTime, (pos) => {
|
||||
promises.push(this.loadChunk(pos))
|
||||
})
|
||||
if (this.panicTimeout) clearTimeout(this.panicTimeout)
|
||||
this.inLoading = false
|
||||
this.gotPanicLastTime = false
|
||||
this.chunkReceiveTimes = []
|
||||
this.lastChunkReceiveTime = 0
|
||||
await Promise.all(promises)
|
||||
}
|
||||
|
||||
readdDebug () {
|
||||
|
|
@ -311,9 +203,8 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
|
|||
// debugGotChunkLatency = [] as number[]
|
||||
// lastTime = 0
|
||||
|
||||
async loadChunk (pos: ChunkPos, isLightUpdate = false, reason = 'spiral') {
|
||||
async loadChunk (pos: ChunkPos, isLightUpdate = false) {
|
||||
const [botX, botZ] = chunkPos(this.lastPos)
|
||||
|
||||
const dx = Math.abs(botX - Math.floor(pos.x / 16))
|
||||
const dz = Math.abs(botZ - Math.floor(pos.z / 16))
|
||||
if (dx <= this.viewDistance && dz <= this.viewDistance) {
|
||||
|
|
@ -333,15 +224,6 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
|
|||
//@ts-expect-error
|
||||
this.emitter.emit('loadChunk', { x: pos.x, z: pos.z, chunk, blockEntities: column.blockEntities, worldConfig, isLightUpdate })
|
||||
this.loadedChunks[`${pos.x},${pos.z}`] = true
|
||||
|
||||
this.debugChunksInfo[`${pos.x},${pos.z}`] ??= {
|
||||
loads: []
|
||||
}
|
||||
this.debugChunksInfo[`${pos.x},${pos.z}`].loads.push({
|
||||
dataLength: chunk.length,
|
||||
reason,
|
||||
time: Date.now(),
|
||||
})
|
||||
} else if (this.isPlayground) { // don't allow in real worlds pre-flag chunks as loaded to avoid race condition when the chunk might still be loading. In playground it's assumed we always pre-load all chunks first
|
||||
this.emitter.emit('markAsLoaded', { x: pos.x, z: pos.z })
|
||||
}
|
||||
|
|
@ -360,46 +242,13 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
|
|||
unloadChunk (pos: ChunkPos) {
|
||||
this.emitter.emit('unloadChunk', { x: pos.x, z: pos.z })
|
||||
delete this.loadedChunks[`${pos.x},${pos.z}`]
|
||||
delete this.debugChunksInfo[`${pos.x},${pos.z}`]
|
||||
}
|
||||
|
||||
lastBiomeId: number | null = null
|
||||
|
||||
udpateBiome (pos: Vec3) {
|
||||
try {
|
||||
const biomeId = this.world.getBiome(pos)
|
||||
if (biomeId !== this.lastBiomeId) {
|
||||
this.lastBiomeId = biomeId
|
||||
const biomeData = loadedData.biomes[biomeId]
|
||||
if (biomeData) {
|
||||
this.emitter.emit('biomeUpdate', {
|
||||
biome: biomeData
|
||||
})
|
||||
} else {
|
||||
// unknown biome
|
||||
this.emitter.emit('biomeReset')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error updating biome', e)
|
||||
}
|
||||
}
|
||||
|
||||
lastPosCheck: Vec3 | null = null
|
||||
async updatePosition (pos: Vec3, force = false) {
|
||||
if (!this.allowPositionUpdate) return
|
||||
const posFloored = pos.floored()
|
||||
if (!force && this.lastPosCheck && this.lastPosCheck.equals(posFloored)) return
|
||||
this.lastPosCheck = posFloored
|
||||
|
||||
this.udpateBiome(pos)
|
||||
|
||||
const [lastX, lastZ] = chunkPos(this.lastPos)
|
||||
const [botX, botZ] = chunkPos(pos)
|
||||
if (lastX !== botX || lastZ !== botZ || force) {
|
||||
this.emitter.emit('chunkPosUpdate', { pos })
|
||||
|
||||
// unload chunks that are no longer in view
|
||||
const newViewToUnload = new ViewRect(botX, botZ, this.viewDistance + this.keepChunksDistance)
|
||||
const chunksToUnload: Vec3[] = []
|
||||
for (const coords of Object.keys(this.loadedChunks)) {
|
||||
|
|
@ -411,18 +260,17 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
|
|||
chunksToUnload.push(p)
|
||||
}
|
||||
}
|
||||
console.log('unloading', chunksToUnload.length, 'total now', Object.keys(this.loadedChunks).length)
|
||||
for (const p of chunksToUnload) {
|
||||
this.unloadChunk(p)
|
||||
}
|
||||
|
||||
// load new chunks
|
||||
const positions = generateSpiralMatrix(this.viewDistance).map(([x, z]) => {
|
||||
const pos = new Vec3((botX + x) * 16, 0, (botZ + z) * 16)
|
||||
if (!this.loadedChunks[`${pos.x},${pos.z}`]) return pos
|
||||
return undefined!
|
||||
}).filter(a => !!a)
|
||||
this.lastPos.update(pos)
|
||||
void this._loadChunks(positions, pos)
|
||||
void this._loadChunks(positions)
|
||||
} else {
|
||||
this.emitter.emit('chunkPosUpdate', { pos }) // todo-low
|
||||
this.lastPos.update(pos)
|
||||
|
|
|
|||
|
|
@ -1,96 +1,59 @@
|
|||
/* eslint-disable guard-for-in */
|
||||
import { EventEmitter } from 'events'
|
||||
import { Vec3 } from 'vec3'
|
||||
import * as THREE from 'three'
|
||||
import mcDataRaw from 'minecraft-data/data.js' // note: using alias
|
||||
import TypedEmitter from 'typed-emitter'
|
||||
import { ItemsRenderer } from 'mc-assets/dist/itemsRenderer'
|
||||
import { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider'
|
||||
import { generateSpiralMatrix } from 'flying-squid/dist/utils'
|
||||
import { subscribeKey } from 'valtio/utils'
|
||||
import { proxy } from 'valtio'
|
||||
import { dynamicMcDataFiles } from '../../buildMesherConfig.mjs'
|
||||
import type { ResourcesManagerTransferred } from '../../../src/resourcesManager'
|
||||
import { DisplayWorldOptions, GraphicsInitOptions, RendererReactiveState } from '../../../src/appViewer'
|
||||
import { toMajorVersion } from '../../../src/utils'
|
||||
import { ResourcesManager } from '../../../src/resourcesManager'
|
||||
import { DisplayWorldOptions, RendererReactiveState } from '../../../src/appViewer'
|
||||
import { SoundSystem } from '../three/threeJsSound'
|
||||
import { buildCleanupDecorator } from './cleanupDecorator'
|
||||
import { HighestBlockInfo, CustomBlockModels, BlockStateModelInfo, getBlockAssetsCacheKey, MesherConfig, MesherMainEvent } from './mesher/shared'
|
||||
import { HighestBlockInfo, MesherGeometryOutput, CustomBlockModels, BlockStateModelInfo, getBlockAssetsCacheKey, MesherConfig } from './mesher/shared'
|
||||
import { chunkPos } from './simpleUtils'
|
||||
import { addNewStat, removeAllStats, updatePanesVisibility, updateStatText } from './ui/newStats'
|
||||
import { WorldDataEmitterWorker } from './worldDataEmitter'
|
||||
import { getPlayerStateUtils, PlayerStateReactive, PlayerStateRenderer, PlayerStateUtils } from './basePlayerState'
|
||||
import { MesherLogReader } from './mesherlogReader'
|
||||
import { setSkinsConfig } from './utils/skins'
|
||||
import { removeStat, updateStatText } from './ui/newStats'
|
||||
import { WorldDataEmitter } from './worldDataEmitter'
|
||||
import { IPlayerState } from './basePlayerState'
|
||||
|
||||
function mod (x, n) {
|
||||
return ((x % n) + n) % n
|
||||
}
|
||||
|
||||
const toMajorVersion = version => {
|
||||
const [a, b] = (String(version)).split('.')
|
||||
return `${a}.${b}`
|
||||
}
|
||||
|
||||
export const worldCleanup = buildCleanupDecorator('resetWorld')
|
||||
|
||||
export const defaultWorldRendererConfig = {
|
||||
// Debug settings
|
||||
showChunkBorders: false,
|
||||
enableDebugOverlay: false,
|
||||
|
||||
// Performance settings
|
||||
mesherWorkers: 4,
|
||||
addChunksBatchWaitTime: 200,
|
||||
_experimentalSmoothChunkLoading: true,
|
||||
_renderByChunks: false,
|
||||
|
||||
// Rendering engine settings
|
||||
dayCycle: true,
|
||||
isPlayground: false,
|
||||
renderEars: true,
|
||||
// game renderer setting actually
|
||||
showHand: false,
|
||||
viewBobbing: false,
|
||||
extraBlockRenderers: true,
|
||||
clipWorldBelowY: undefined as number | undefined,
|
||||
smoothLighting: true,
|
||||
enableLighting: true,
|
||||
starfield: true,
|
||||
defaultSkybox: true,
|
||||
renderEntities: true,
|
||||
extraBlockRenderers: true,
|
||||
foreground: true,
|
||||
fov: 75,
|
||||
volume: 1,
|
||||
|
||||
// Camera visual related settings
|
||||
showHand: false,
|
||||
viewBobbing: false,
|
||||
renderEars: true,
|
||||
highlightBlockColor: 'blue',
|
||||
|
||||
// Player models
|
||||
fetchPlayerSkins: true,
|
||||
skinTexturesProxy: undefined as string | undefined,
|
||||
|
||||
// VR settings
|
||||
addChunksBatchWaitTime: 200,
|
||||
vrSupport: true,
|
||||
vrPageGameRendering: true,
|
||||
|
||||
// World settings
|
||||
clipWorldBelowY: undefined as number | undefined,
|
||||
isPlayground: false
|
||||
renderEntities: true,
|
||||
fov: 75,
|
||||
fetchPlayerSkins: true,
|
||||
highlightBlockColor: 'blue',
|
||||
foreground: true,
|
||||
_experimentalSmoothChunkLoading: true,
|
||||
_renderByChunks: false
|
||||
}
|
||||
|
||||
export type WorldRendererConfig = typeof defaultWorldRendererConfig
|
||||
|
||||
export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any> {
|
||||
worldReadyResolvers = Promise.withResolvers<void>()
|
||||
worldReadyPromise = this.worldReadyResolvers.promise
|
||||
timeOfTheDay = 0
|
||||
displayStats = true
|
||||
worldSizeParams = { minY: 0, worldHeight: 256 }
|
||||
reactiveDebugParams = proxy({
|
||||
stopRendering: false,
|
||||
chunksRenderAboveOverride: undefined as number | undefined,
|
||||
chunksRenderAboveEnabled: false,
|
||||
chunksRenderBelowOverride: undefined as number | undefined,
|
||||
chunksRenderBelowEnabled: false,
|
||||
chunksRenderDistanceOverride: undefined as number | undefined,
|
||||
chunksRenderDistanceEnabled: false,
|
||||
disableEntities: false,
|
||||
// disableParticles: false
|
||||
})
|
||||
|
||||
active = false
|
||||
|
||||
|
|
@ -117,11 +80,10 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
dirty (pos: Vec3, value: boolean): void
|
||||
update (/* pos: Vec3, value: boolean */): void
|
||||
chunkFinished (key: string): void
|
||||
heightmap (key: string, heightmap: Uint8Array): void
|
||||
}>
|
||||
customTexturesDataUrl = undefined as string | undefined
|
||||
workers: any[] = []
|
||||
viewerChunkPosition?: Vec3
|
||||
viewerPosition?: Vec3
|
||||
lastCamUpdate = 0
|
||||
droppedFpsPercentage = 0
|
||||
initialChunkLoadWasStartedIn: number | undefined
|
||||
|
|
@ -136,18 +98,17 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
ONMESSAGE_TIME_LIMIT = 30 // ms
|
||||
|
||||
handleResize = () => { }
|
||||
highestBlocksByChunks = new Map<string, { [chunkKey: string]: HighestBlockInfo }>()
|
||||
camera: THREE.PerspectiveCamera
|
||||
highestBlocksByChunks = {} as Record<string, { [chunkKey: string]: HighestBlockInfo }>
|
||||
highestBlocksBySections = {} as Record<string, { [sectionKey: string]: HighestBlockInfo }>
|
||||
blockEntities = {}
|
||||
|
||||
workersProcessAverageTime = 0
|
||||
workersProcessAverageTimeCount = 0
|
||||
maxWorkersProcessTime = 0
|
||||
geometryReceiveCount = {} as Record<number, number>
|
||||
geometryReceiveCount = {}
|
||||
allLoadedIn: undefined | number
|
||||
onWorldSwitched = [] as Array<() => void>
|
||||
renderTimeMax = 0
|
||||
renderTimeAvg = 0
|
||||
renderTimeAvgCount = 0
|
||||
|
||||
edgeChunks = {} as Record<string, boolean>
|
||||
lastAddChunk = null as null | {
|
||||
timeout: any
|
||||
|
|
@ -158,6 +119,9 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
lastChunkDistance = 0
|
||||
debugStopGeometryUpdate = false
|
||||
|
||||
@worldCleanup()
|
||||
itemsRenderer: ItemsRenderer | undefined
|
||||
|
||||
protocolCustomBlocks = new Map<string, CustomBlockModels>()
|
||||
|
||||
@worldCleanup()
|
||||
|
|
@ -170,106 +134,39 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
abstract changeBackgroundColor (color: [number, number, number]): void
|
||||
|
||||
worldRendererConfig: WorldRendererConfig
|
||||
playerStateReactive: PlayerStateReactive
|
||||
playerStateUtils: PlayerStateUtils
|
||||
playerState: IPlayerState
|
||||
reactiveState: RendererReactiveState
|
||||
mesherLogReader: MesherLogReader | undefined
|
||||
forceCallFromMesherReplayer = false
|
||||
stopMesherMessagesProcessing = false
|
||||
|
||||
abortController = new AbortController()
|
||||
lastRendered = 0
|
||||
renderingActive = true
|
||||
geometryReceiveCountPerSec = 0
|
||||
mesherLogger = {
|
||||
contents: [] as string[],
|
||||
active: new URL(location.href).searchParams.get('mesherlog') === 'true'
|
||||
}
|
||||
currentRenderedFrames = 0
|
||||
fpsAverage = 0
|
||||
lastFps = 0
|
||||
fpsWorst = undefined as number | undefined
|
||||
fpsSamples = 0
|
||||
mainThreadRendering = true
|
||||
backendInfoReport = '-'
|
||||
chunksFullInfo = '-'
|
||||
workerCustomHandleTime = 0
|
||||
|
||||
get version () {
|
||||
return this.displayOptions.version
|
||||
}
|
||||
|
||||
get displayAdvancedStats () {
|
||||
return (this.initOptions.config.statsVisible ?? 0) > 1
|
||||
}
|
||||
|
||||
constructor (public readonly resourcesManager: ResourcesManagerTransferred, public displayOptions: DisplayWorldOptions, public initOptions: GraphicsInitOptions) {
|
||||
constructor (public readonly resourcesManager: ResourcesManager, public displayOptions: DisplayWorldOptions, public version: string) {
|
||||
// this.initWorkers(1) // preload script on page load
|
||||
this.snapshotInitialValues()
|
||||
this.worldRendererConfig = displayOptions.inWorldRenderingConfig
|
||||
this.playerStateReactive = displayOptions.playerStateReactive
|
||||
this.playerStateUtils = getPlayerStateUtils(this.playerStateReactive)
|
||||
this.playerState = displayOptions.playerState
|
||||
this.reactiveState = displayOptions.rendererState
|
||||
// this.mesherLogReader = new MesherLogReader(this)
|
||||
|
||||
this.renderUpdateEmitter.on('update', () => {
|
||||
const loadedChunks = Object.keys(this.finishedChunks).length
|
||||
updateStatText('loaded-chunks', `${loadedChunks}/${this.chunksLength} chunks (${this.lastChunkDistance}/${this.viewDistance})`)
|
||||
})
|
||||
|
||||
addNewStat('downloaded-chunks', 100, 140, 20)
|
||||
|
||||
this.connect(this.displayOptions.worldView)
|
||||
|
||||
const interval = setInterval(() => {
|
||||
this.geometryReceiveCountPerSec = Object.values(this.geometryReceiveCount).reduce((acc, curr) => acc + curr, 0)
|
||||
this.geometryReceiveCount = {}
|
||||
updatePanesVisibility(this.displayAdvancedStats)
|
||||
this.updateChunksStats()
|
||||
if (this.mainThreadRendering) {
|
||||
this.fpsUpdate()
|
||||
}
|
||||
}, 500)
|
||||
this.abortController.signal.addEventListener('abort', () => {
|
||||
clearInterval(interval)
|
||||
})
|
||||
}
|
||||
|
||||
fpsUpdate () {
|
||||
this.fpsSamples++
|
||||
this.fpsAverage = (this.fpsAverage * (this.fpsSamples - 1) + this.currentRenderedFrames) / this.fpsSamples
|
||||
if (this.fpsWorst === undefined) {
|
||||
this.fpsWorst = this.currentRenderedFrames
|
||||
} else {
|
||||
this.fpsWorst = Math.min(this.fpsWorst, this.currentRenderedFrames)
|
||||
}
|
||||
this.lastFps = this.currentRenderedFrames
|
||||
this.currentRenderedFrames = 0
|
||||
}
|
||||
|
||||
logWorkerWork (message: string | (() => string)) {
|
||||
if (!this.mesherLogger.active) return
|
||||
this.mesherLogger.contents.push(typeof message === 'function' ? message() : message)
|
||||
}
|
||||
|
||||
async init () {
|
||||
init () {
|
||||
if (this.active) throw new Error('WorldRendererCommon is already initialized')
|
||||
|
||||
await Promise.all([
|
||||
this.resetWorkers(),
|
||||
(async () => {
|
||||
if (this.resourcesManager.currentResources?.allReady) {
|
||||
await this.updateAssetsData()
|
||||
}
|
||||
})()
|
||||
])
|
||||
|
||||
this.resourcesManager.on('assetsTexturesUpdated', async () => {
|
||||
if (!this.active) return
|
||||
await this.updateAssetsData()
|
||||
void this.setVersion(this.version).then(() => {
|
||||
this.resourcesManager.on('assetsTexturesUpdated', () => {
|
||||
if (!this.active) return
|
||||
void this.updateAssetsData()
|
||||
})
|
||||
if (this.resourcesManager.currentResources) {
|
||||
void this.updateAssetsData()
|
||||
}
|
||||
})
|
||||
|
||||
this.watchReactivePlayerState()
|
||||
this.watchReactiveConfig()
|
||||
this.worldReadyResolvers.resolve()
|
||||
}
|
||||
|
||||
snapshotInitialValues () { }
|
||||
|
|
@ -279,7 +176,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
}
|
||||
|
||||
async getHighestBlocks (chunkKey: string) {
|
||||
return this.highestBlocksByChunks.get(chunkKey)
|
||||
return this.highestBlocksByChunks[chunkKey]
|
||||
}
|
||||
|
||||
updateCustomBlock (chunkKey: string, blockPos: string, model: string) {
|
||||
|
|
@ -287,7 +184,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
...this.protocolCustomBlocks.get(chunkKey),
|
||||
[blockPos]: model
|
||||
})
|
||||
this.logWorkerWork(() => `-> updateCustomBlock ${chunkKey} ${blockPos} ${model} ${this.wasChunkSentToWorker(chunkKey)}`)
|
||||
if (this.wasChunkSentToWorker(chunkKey)) {
|
||||
const [x, y, z] = blockPos.split(',').map(Number)
|
||||
this.setBlockStateId(new Vec3(x, y, z), undefined)
|
||||
|
|
@ -308,56 +204,39 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
initWorkers (numWorkers = this.worldRendererConfig.mesherWorkers) {
|
||||
// init workers
|
||||
for (let i = 0; i < numWorkers + 1; i++) {
|
||||
const worker = initMesherWorker((data) => {
|
||||
// Node environment needs an absolute path, but browser needs the url of the file
|
||||
const workerName = 'mesher.js'
|
||||
// eslint-disable-next-line node/no-path-concat
|
||||
const src = typeof window === 'undefined' ? `${__dirname}/${workerName}` : workerName
|
||||
|
||||
let worker: any
|
||||
if (process.env.SINGLE_FILE_BUILD) {
|
||||
const workerCode = document.getElementById('mesher-worker-code')!.textContent!
|
||||
const blob = new Blob([workerCode], { type: 'text/javascript' })
|
||||
worker = new Worker(window.URL.createObjectURL(blob))
|
||||
} else {
|
||||
worker = new Worker(src)
|
||||
}
|
||||
|
||||
worker.onmessage = ({ data }) => {
|
||||
if (Array.isArray(data)) {
|
||||
this.messageQueue.push(...data)
|
||||
} else {
|
||||
this.messageQueue.push(data)
|
||||
}
|
||||
void this.processMessageQueue('worker')
|
||||
})
|
||||
void this.processMessageQueue()
|
||||
}
|
||||
if (worker.on) worker.on('message', (data) => { worker.onmessage({ data }) })
|
||||
this.workers.push(worker)
|
||||
}
|
||||
}
|
||||
|
||||
onReactivePlayerStateUpdated<T extends keyof PlayerStateReactive>(key: T, callback: (value: PlayerStateReactive[T]) => void, initial = true) {
|
||||
if (initial) {
|
||||
callback(this.playerStateReactive[key])
|
||||
}
|
||||
subscribeKey(this.playerStateReactive, key, callback)
|
||||
}
|
||||
|
||||
onReactiveConfigUpdated<T extends keyof typeof this.worldRendererConfig>(key: T, callback: (value: typeof this.worldRendererConfig[T]) => void) {
|
||||
callback(this.worldRendererConfig[key])
|
||||
subscribeKey(this.worldRendererConfig, key, callback)
|
||||
}
|
||||
|
||||
onReactiveDebugUpdated<T extends keyof typeof this.reactiveDebugParams>(key: T, callback: (value: typeof this.reactiveDebugParams[T]) => void) {
|
||||
callback(this.reactiveDebugParams[key])
|
||||
subscribeKey(this.reactiveDebugParams, key, callback)
|
||||
}
|
||||
|
||||
watchReactivePlayerState () {
|
||||
this.onReactivePlayerStateUpdated('backgroundColor', (value) => {
|
||||
this.changeBackgroundColor(value)
|
||||
})
|
||||
}
|
||||
|
||||
watchReactiveConfig () {
|
||||
this.onReactiveConfigUpdated('fetchPlayerSkins', (value) => {
|
||||
setSkinsConfig({ apiEnabled: value })
|
||||
})
|
||||
}
|
||||
|
||||
async processMessageQueue (source: string) {
|
||||
async processMessageQueue () {
|
||||
if (this.isProcessingQueue || this.messageQueue.length === 0) return
|
||||
this.logWorkerWork(`# ${source} processing queue`)
|
||||
if (this.lastRendered && performance.now() - this.lastRendered > this.ONMESSAGE_TIME_LIMIT && this.worldRendererConfig._experimentalSmoothChunkLoading && this.renderingActive) {
|
||||
const start = performance.now()
|
||||
if (this.lastRendered && performance.now() - this.lastRendered > 30 && this.worldRendererConfig._experimentalSmoothChunkLoading && this.renderingActive) {
|
||||
await new Promise(resolve => {
|
||||
requestAnimationFrame(resolve)
|
||||
})
|
||||
this.logWorkerWork(`# processing got delayed by ${performance.now() - start}ms`)
|
||||
}
|
||||
this.isProcessingQueue = true
|
||||
|
||||
|
|
@ -365,20 +244,17 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
let processedCount = 0
|
||||
|
||||
while (this.messageQueue.length > 0) {
|
||||
const processingStopped = this.stopMesherMessagesProcessing
|
||||
if (!processingStopped) {
|
||||
const data = this.messageQueue.shift()!
|
||||
this.handleMessage(data)
|
||||
processedCount++
|
||||
}
|
||||
const data = this.messageQueue.shift()!
|
||||
this.handleMessage(data)
|
||||
processedCount++
|
||||
|
||||
// Check if we've exceeded the time limit
|
||||
if (processingStopped || (performance.now() - startTime > this.ONMESSAGE_TIME_LIMIT && this.renderingActive && this.worldRendererConfig._experimentalSmoothChunkLoading)) {
|
||||
if (performance.now() - startTime > this.ONMESSAGE_TIME_LIMIT && this.renderingActive && this.worldRendererConfig._experimentalSmoothChunkLoading) {
|
||||
// If we have more messages and exceeded time limit, schedule next batch
|
||||
if (this.messageQueue.length > 0) {
|
||||
requestAnimationFrame(async () => {
|
||||
this.isProcessingQueue = false
|
||||
void this.processMessageQueue('queue-delay')
|
||||
void this.processMessageQueue()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
|
@ -389,24 +265,20 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
this.isProcessingQueue = false
|
||||
}
|
||||
|
||||
handleMessage (rawData: any) {
|
||||
const data = rawData as MesherMainEvent
|
||||
handleMessage (data) {
|
||||
if (!this.active) return
|
||||
this.mesherLogReader?.workerMessageReceived(data.type, data)
|
||||
if (data.type !== 'geometry' || !this.debugStopGeometryUpdate) {
|
||||
const start = performance.now()
|
||||
this.handleWorkerMessage(data as WorkerReceive)
|
||||
this.workerCustomHandleTime += performance.now() - start
|
||||
this.handleWorkerMessage(data)
|
||||
}
|
||||
if (data.type === 'geometry') {
|
||||
this.logWorkerWork(() => `-> ${data.workerIndex} geometry ${data.key} ${JSON.stringify({ dataSize: JSON.stringify(data).length })}`)
|
||||
this.geometryReceiveCount[data.workerIndex] ??= 0
|
||||
this.geometryReceiveCount[data.workerIndex]++
|
||||
const geometry = data.geometry as MesherGeometryOutput
|
||||
this.highestBlocksBySections[data.key] = geometry.highestBlocks
|
||||
const chunkCoords = data.key.split(',').map(Number)
|
||||
this.lastChunkDistance = Math.max(...this.getDistance(new Vec3(chunkCoords[0], 0, chunkCoords[2])))
|
||||
}
|
||||
if (data.type === 'sectionFinished') { // on after load & unload section
|
||||
this.logWorkerWork(`<- ${data.workerIndex} sectionFinished ${data.key} ${JSON.stringify({ processTime: data.processTime })}`)
|
||||
if (!this.sectionsWaiting.has(data.key)) throw new Error(`sectionFinished event for non-outstanding section ${data.key}`)
|
||||
this.sectionsWaiting.set(data.key, this.sectionsWaiting.get(data.key)! - 1)
|
||||
if (this.sectionsWaiting.get(data.key) === 0) {
|
||||
|
|
@ -427,7 +299,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
if (loaded) {
|
||||
// CHUNK FINISHED
|
||||
this.finishedChunks[chunkKey] = true
|
||||
this.reactiveState.world.chunksLoaded.add(`${Math.floor(chunkCoords[0] / 16)},${Math.floor(chunkCoords[2] / 16)}`)
|
||||
this.renderUpdateEmitter.emit(`chunkFinished`, `${chunkCoords[0]},${chunkCoords[2]}`)
|
||||
this.checkAllFinished()
|
||||
// merge highest blocks by sections into highest blocks by chunks
|
||||
|
|
@ -466,17 +337,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
this.blockStateModelInfo.set(cacheKey, info)
|
||||
}
|
||||
}
|
||||
|
||||
if (data.type === 'heightmap') {
|
||||
this.reactiveState.world.heightmaps.set(data.key, new Uint8Array(data.heightmap))
|
||||
}
|
||||
}
|
||||
|
||||
downloadMesherLog () {
|
||||
const a = document.createElement('a')
|
||||
a.href = 'data:text/plain;charset=utf-8,' + encodeURIComponent(this.mesherLogger.contents.join('\n'))
|
||||
a.download = 'mesher.log'
|
||||
a.click()
|
||||
}
|
||||
|
||||
checkAllFinished () {
|
||||
|
|
@ -510,12 +370,8 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
|
||||
timeUpdated? (newTime: number): void
|
||||
|
||||
biomeUpdated? (biome: any): void
|
||||
|
||||
biomeReset? (): void
|
||||
|
||||
updateViewerPosition (pos: Vec3) {
|
||||
this.viewerChunkPosition = pos
|
||||
this.viewerPosition = pos
|
||||
for (const [key, value] of Object.entries(this.loadedChunks)) {
|
||||
if (!value) continue
|
||||
this.updatePosDataChunk?.(key)
|
||||
|
|
@ -529,7 +385,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
}
|
||||
|
||||
getDistance (posAbsolute: Vec3) {
|
||||
const [botX, botZ] = chunkPos(this.viewerChunkPosition!)
|
||||
const [botX, botZ] = chunkPos(this.viewerPosition!)
|
||||
const dx = Math.abs(botX - Math.floor(posAbsolute.x / 16))
|
||||
const dz = Math.abs(botZ - Math.floor(posAbsolute.z / 16))
|
||||
return [dx, dz] as [number, number]
|
||||
|
|
@ -545,11 +401,13 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
this.workers = []
|
||||
}
|
||||
|
||||
async resetWorkers () {
|
||||
// new game load happens here
|
||||
async setVersion (version: string) {
|
||||
this.version = version
|
||||
this.resetWorld()
|
||||
|
||||
// for workers in single file build
|
||||
if (typeof document !== 'undefined' && document?.readyState === 'loading') {
|
||||
if (document?.readyState === 'loading') {
|
||||
await new Promise(resolve => {
|
||||
document.addEventListener('DOMContentLoaded', resolve)
|
||||
})
|
||||
|
|
@ -558,35 +416,24 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
this.initWorkers()
|
||||
this.active = true
|
||||
|
||||
await this.resourcesManager.loadMcData(version)
|
||||
this.sendMesherMcData()
|
||||
if (!this.resourcesManager.currentResources) {
|
||||
await this.resourcesManager.updateAssetsData({ })
|
||||
}
|
||||
}
|
||||
|
||||
getMesherConfig (): MesherConfig {
|
||||
let skyLight = 15
|
||||
const timeOfDay = this.timeOfTheDay
|
||||
if (timeOfDay < 0 || timeOfDay > 24_000) {
|
||||
//
|
||||
} else if (timeOfDay <= 6000 || timeOfDay >= 18_000) {
|
||||
skyLight = 15
|
||||
} else if (timeOfDay > 6000 && timeOfDay < 12_000) {
|
||||
skyLight = 15 - ((timeOfDay - 6000) / 6000) * 15
|
||||
} else if (timeOfDay >= 12_000 && timeOfDay < 18_000) {
|
||||
skyLight = ((timeOfDay - 12_000) / 6000) * 15
|
||||
}
|
||||
|
||||
skyLight = Math.floor(skyLight)
|
||||
return {
|
||||
version: this.version,
|
||||
enableLighting: this.worldRendererConfig.enableLighting,
|
||||
skyLight,
|
||||
skyLight: 15,
|
||||
smoothLighting: this.worldRendererConfig.smoothLighting,
|
||||
outputFormat: this.outputFormat,
|
||||
// textureSize: this.resourcesManager.currentResources!.blocksAtlasParser.atlas.latest.width,
|
||||
textureSize: this.resourcesManager.currentResources!.blocksAtlasParser.atlas.latest.width,
|
||||
debugModelVariant: undefined,
|
||||
clipWorldBelowY: this.worldRendererConfig.clipWorldBelowY,
|
||||
disableSignsMapsSupport: !this.worldRendererConfig.extraBlockRenderers,
|
||||
worldMinY: this.worldMinYRender,
|
||||
worldMaxY: this.worldMinYRender + this.worldSizeParams.worldHeight,
|
||||
disableSignsMapsSupport: !this.worldRendererConfig.extraBlockRenderers
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -602,11 +449,10 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
for (const worker of this.workers) {
|
||||
worker.postMessage({ type: 'mcData', mcData, config: this.getMesherConfig() })
|
||||
}
|
||||
this.logWorkerWork('# mcData sent')
|
||||
}
|
||||
|
||||
async updateAssetsData () {
|
||||
const resources = this.resourcesManager.currentResources
|
||||
const resources = this.resourcesManager.currentResources!
|
||||
|
||||
if (this.workers.length === 0) throw new Error('workers not initialized yet')
|
||||
for (const [i, worker] of this.workers.entries()) {
|
||||
|
|
@ -616,14 +462,13 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
type: 'mesherData',
|
||||
workerIndex: i,
|
||||
blocksAtlas: {
|
||||
latest: resources.blocksAtlasJson
|
||||
latest: resources.blocksAtlasParser.atlas.latest
|
||||
},
|
||||
blockstatesModels,
|
||||
config: this.getMesherConfig(),
|
||||
})
|
||||
}
|
||||
|
||||
this.logWorkerWork('# mesherData sent')
|
||||
console.log('textures loaded')
|
||||
}
|
||||
|
||||
|
|
@ -633,13 +478,11 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
|
||||
updateChunksStats () {
|
||||
const loadedChunks = Object.keys(this.finishedChunks)
|
||||
this.displayOptions.nonReactiveState.world.chunksLoaded = new Set(loadedChunks)
|
||||
this.displayOptions.nonReactiveState.world.chunksLoaded = loadedChunks
|
||||
this.displayOptions.nonReactiveState.world.chunksTotalNumber = this.chunksLength
|
||||
this.reactiveState.world.allChunksLoaded = this.allChunksFinished
|
||||
|
||||
const text = `Q: ${this.messageQueue.length} ${Object.keys(this.loadedChunks).length}/${Object.keys(this.finishedChunks).length}/${this.chunksLength} chunks (${this.workers.length}:${this.workersProcessAverageTime.toFixed(0)}ms/${this.geometryReceiveCountPerSec}ss/${this.allLoadedIn?.toFixed(1) ?? '-'}s)`
|
||||
this.chunksFullInfo = text
|
||||
updateStatText('downloaded-chunks', text)
|
||||
updateStatText('downloaded-chunks', `${Object.keys(this.loadedChunks).length}/${this.chunksLength} chunks D (${this.workers.length}:${this.workersProcessAverageTime.toFixed(0)}ms/${this.allLoadedIn?.toFixed(1) ?? '-'}s)`)
|
||||
}
|
||||
|
||||
addColumn (x: number, z: number, chunk: any, isLightUpdate: boolean) {
|
||||
|
|
@ -662,13 +505,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
customBlockModels: customBlockModels || undefined
|
||||
})
|
||||
}
|
||||
this.workers[0].postMessage({
|
||||
type: 'getHeightmap',
|
||||
x,
|
||||
z,
|
||||
})
|
||||
this.logWorkerWork(() => `-> chunk ${JSON.stringify({ x, z, chunkLength: chunk.length, customBlockModelsLength: customBlockModels ? Object.keys(customBlockModels).length : 0 })}`)
|
||||
this.mesherLogReader?.chunkReceived(x, z, chunk.length)
|
||||
for (let y = this.worldMinYRender; y < this.worldSizeParams.worldHeight; y += 16) {
|
||||
const loc = new Vec3(x, y, z)
|
||||
this.setSectionDirty(loc)
|
||||
|
|
@ -684,7 +520,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
markAsLoaded (x, z) {
|
||||
this.loadedChunks[`${x},${z}`] = true
|
||||
this.finishedChunks[`${x},${z}`] = true
|
||||
this.logWorkerWork(`-> markAsLoaded ${JSON.stringify({ x, z })}`)
|
||||
this.checkAllFinished()
|
||||
}
|
||||
|
||||
|
|
@ -693,29 +528,23 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
for (const worker of this.workers) {
|
||||
worker.postMessage({ type: 'unloadChunk', x, z })
|
||||
}
|
||||
this.logWorkerWork(`-> unloadChunk ${JSON.stringify({ x, z })}`)
|
||||
delete this.finishedChunks[`${x},${z}`]
|
||||
this.allChunksFinished = Object.keys(this.finishedChunks).length === this.chunksLength
|
||||
if (Object.keys(this.finishedChunks).length === 0) {
|
||||
if (!this.allChunksFinished) {
|
||||
this.allLoadedIn = undefined
|
||||
this.initialChunkLoadWasStartedIn = undefined
|
||||
}
|
||||
for (let y = this.worldSizeParams.minY; y < this.worldSizeParams.worldHeight; y += 16) {
|
||||
this.setSectionDirty(new Vec3(x, y, z), false)
|
||||
delete this.finishedSections[`${x},${y},${z}`]
|
||||
delete this.highestBlocksBySections[`${x},${y},${z}`]
|
||||
}
|
||||
this.highestBlocksByChunks.delete(`${x},${z}`)
|
||||
delete this.highestBlocksByChunks[`${x},${z}`]
|
||||
|
||||
this.updateChunksStats()
|
||||
|
||||
if (Object.keys(this.loadedChunks).length === 0) {
|
||||
this.mesherLogger.contents = []
|
||||
this.logWorkerWork('# all chunks unloaded. New log started')
|
||||
void this.mesherLogReader?.maybeStartReplay()
|
||||
}
|
||||
}
|
||||
|
||||
setBlockStateId (pos: Vec3, stateId: number | undefined, needAoRecalculation = true) {
|
||||
setBlockStateId (pos: Vec3, stateId: number | undefined) {
|
||||
const set = async () => {
|
||||
const sectionX = Math.floor(pos.x / 16) * 16
|
||||
const sectionZ = Math.floor(pos.z / 16) * 16
|
||||
|
|
@ -729,18 +558,16 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
if (!this.loadedChunks[`${sectionX},${sectionZ}`]) {
|
||||
// console.debug('[should be unreachable] setBlockStateId called for unloaded chunk', pos)
|
||||
}
|
||||
this.setBlockStateIdInner(pos, stateId, needAoRecalculation)
|
||||
this.setBlockStateIdInner(pos, stateId)
|
||||
}
|
||||
void set()
|
||||
}
|
||||
|
||||
updateEntity (e: any, isUpdate = false) { }
|
||||
|
||||
abstract updatePlayerEntity? (e: any): void
|
||||
|
||||
lightUpdate (chunkX: number, chunkZ: number) { }
|
||||
|
||||
connect (worldView: WorldDataEmitterWorker) {
|
||||
connect (worldView: WorldDataEmitter) {
|
||||
const worldEmitter = worldView
|
||||
|
||||
worldEmitter.on('entity', (e) => {
|
||||
|
|
@ -749,9 +576,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
worldEmitter.on('entityMoved', (e) => {
|
||||
this.updateEntity(e, true)
|
||||
})
|
||||
worldEmitter.on('playerEntity', (e) => {
|
||||
this.updatePlayerEntity?.(e)
|
||||
})
|
||||
|
||||
let currentLoadChunkBatch = null as {
|
||||
timeout
|
||||
|
|
@ -797,10 +621,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
this.updateViewerPosition(pos)
|
||||
})
|
||||
|
||||
worldEmitter.on('end', () => {
|
||||
this.worldStop?.()
|
||||
})
|
||||
|
||||
|
||||
worldEmitter.on('renderDistance', (d) => {
|
||||
this.viewDistance = d
|
||||
|
|
@ -821,24 +641,21 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
this.lightUpdate(pos.x, pos.z)
|
||||
})
|
||||
|
||||
worldEmitter.on('onWorldSwitch', () => {
|
||||
for (const fn of this.onWorldSwitched) {
|
||||
try {
|
||||
fn()
|
||||
} catch (e) {
|
||||
setTimeout(() => {
|
||||
console.log('[Renderer Backend] Error in onWorldSwitched:')
|
||||
throw e
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
worldEmitter.on('time', (timeOfDay) => {
|
||||
if (!this.worldRendererConfig.dayCycle) return
|
||||
this.timeUpdated?.(timeOfDay)
|
||||
|
||||
this.timeOfTheDay = timeOfDay
|
||||
let skyLight = 15
|
||||
if (timeOfDay < 0 || timeOfDay > 24_000) {
|
||||
throw new Error('Invalid time of day. It should be between 0 and 24000.')
|
||||
} else if (timeOfDay <= 6000 || timeOfDay >= 18_000) {
|
||||
skyLight = 15
|
||||
} else if (timeOfDay > 6000 && timeOfDay < 12_000) {
|
||||
skyLight = 15 - ((timeOfDay - 6000) / 6000) * 15
|
||||
} else if (timeOfDay >= 12_000 && timeOfDay < 18_000) {
|
||||
skyLight = ((timeOfDay - 12_000) / 6000) * 15
|
||||
}
|
||||
|
||||
skyLight = Math.floor(skyLight) // todo: remove this after optimization
|
||||
|
||||
// if (this.worldRendererConfig.skyLight === skyLight) return
|
||||
// this.worldRendererConfig.skyLight = skyLight
|
||||
|
|
@ -847,16 +664,11 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
// }
|
||||
})
|
||||
|
||||
worldEmitter.on('biomeUpdate', ({ biome }) => {
|
||||
this.biomeUpdated?.(biome)
|
||||
})
|
||||
|
||||
worldEmitter.on('biomeReset', () => {
|
||||
this.biomeReset?.()
|
||||
})
|
||||
worldEmitter.emit('listening')
|
||||
}
|
||||
|
||||
setBlockStateIdInner (pos: Vec3, stateId: number | undefined, needAoRecalculation = true) {
|
||||
setBlockStateIdInner (pos: Vec3, stateId: number | undefined) {
|
||||
const needAoRecalculation = true
|
||||
const chunkKey = `${Math.floor(pos.x / 16) * 16},${Math.floor(pos.z / 16) * 16}`
|
||||
const blockPosKey = `${pos.x},${pos.y},${pos.z}`
|
||||
const customBlockModels = this.protocolCustomBlocks.get(chunkKey) || {}
|
||||
|
|
@ -869,7 +681,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
customBlockModels
|
||||
})
|
||||
}
|
||||
this.logWorkerWork(`-> blockUpdate ${JSON.stringify({ pos, stateId, customBlockModels })}`)
|
||||
this.setSectionDirty(pos, true, true)
|
||||
if (this.neighborChunkUpdates) {
|
||||
if ((pos.x & 15) === 0) this.setSectionDirty(pos.offset(-16, 0, 0), true, true)
|
||||
|
|
@ -904,10 +715,8 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
}
|
||||
}
|
||||
|
||||
abstract worldStop? ()
|
||||
|
||||
queueAwaited = false
|
||||
toWorkerMessagesQueue = {} as { [workerIndex: string]: any[] }
|
||||
messagesQueue = {} as { [workerIndex: string]: any[] }
|
||||
|
||||
getWorkerNumber (pos: Vec3, updateAction = false) {
|
||||
if (updateAction) {
|
||||
|
|
@ -920,31 +729,10 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
return hash + 1
|
||||
}
|
||||
|
||||
async debugGetWorkerCustomBlockModel (pos: Vec3) {
|
||||
const data = [] as Array<Promise<string>>
|
||||
for (const worker of this.workers) {
|
||||
data.push(new Promise((resolve) => {
|
||||
worker.addEventListener('message', (e) => {
|
||||
if (e.data.type === 'customBlockModel') {
|
||||
resolve(e.data.customBlockModel)
|
||||
}
|
||||
})
|
||||
}))
|
||||
worker.postMessage({
|
||||
type: 'getCustomBlockModel',
|
||||
pos
|
||||
})
|
||||
}
|
||||
return Promise.all(data)
|
||||
}
|
||||
|
||||
setSectionDirty (pos: Vec3, value = true, useChangeWorker = false) { // value false is used for unloading chunks
|
||||
if (!this.forceCallFromMesherReplayer && this.mesherLogReader) return
|
||||
|
||||
if (this.viewDistance === -1) throw new Error('viewDistance not set')
|
||||
this.reactiveState.world.mesherWork = true
|
||||
const distance = this.getDistance(pos)
|
||||
// todo shouldnt we check loadedChunks instead?
|
||||
if (!this.workers.length || distance[0] > this.viewDistance || distance[1] > this.viewDistance) return
|
||||
const key = `${Math.floor(pos.x / 16) * 16},${Math.floor(pos.y / 16) * 16},${Math.floor(pos.z / 16) * 16}`
|
||||
// if (this.sectionsOutstanding.has(key)) return
|
||||
|
|
@ -952,30 +740,19 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
// Dispatch sections to workers based on position
|
||||
// This guarantees uniformity accross workers and that a given section
|
||||
// is always dispatched to the same worker
|
||||
const hash = this.getWorkerNumber(pos, useChangeWorker && this.mesherLogger.active)
|
||||
const hash = this.getWorkerNumber(pos, useChangeWorker)
|
||||
this.sectionsWaiting.set(key, (this.sectionsWaiting.get(key) ?? 0) + 1)
|
||||
if (this.forceCallFromMesherReplayer) {
|
||||
this.workers[hash].postMessage({
|
||||
type: 'dirty',
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
z: pos.z,
|
||||
value,
|
||||
config: this.getMesherConfig(),
|
||||
})
|
||||
} else {
|
||||
this.toWorkerMessagesQueue[hash] ??= []
|
||||
this.toWorkerMessagesQueue[hash].push({
|
||||
// this.workers[hash].postMessage({
|
||||
type: 'dirty',
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
z: pos.z,
|
||||
value,
|
||||
config: this.getMesherConfig(),
|
||||
})
|
||||
this.dispatchMessages()
|
||||
}
|
||||
this.messagesQueue[hash] ??= []
|
||||
this.messagesQueue[hash].push({
|
||||
// this.workers[hash].postMessage({
|
||||
type: 'dirty',
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
z: pos.z,
|
||||
value,
|
||||
config: this.getMesherConfig(),
|
||||
})
|
||||
this.dispatchMessages()
|
||||
}
|
||||
|
||||
dispatchMessages () {
|
||||
|
|
@ -983,14 +760,11 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
this.queueAwaited = true
|
||||
setTimeout(() => {
|
||||
// group messages and send as one
|
||||
for (const workerIndex in this.toWorkerMessagesQueue) {
|
||||
for (const workerIndex in this.messagesQueue) {
|
||||
const worker = this.workers[Number(workerIndex)]
|
||||
worker.postMessage(this.toWorkerMessagesQueue[workerIndex])
|
||||
for (const message of this.toWorkerMessagesQueue[workerIndex]) {
|
||||
this.logWorkerWork(`-> ${workerIndex} dispatchMessages ${message.type} ${JSON.stringify({ x: message.x, y: message.y, z: message.z, value: message.value })}`)
|
||||
}
|
||||
worker.postMessage(this.messagesQueue[workerIndex])
|
||||
}
|
||||
this.toWorkerMessagesQueue = {}
|
||||
this.messagesQueue = {}
|
||||
this.queueAwaited = false
|
||||
})
|
||||
}
|
||||
|
|
@ -1047,41 +821,9 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
this.active = false
|
||||
|
||||
this.renderUpdateEmitter.removeAllListeners()
|
||||
this.displayOptions.worldView.removeAllListeners() // todo
|
||||
this.abortController.abort()
|
||||
removeAllStats()
|
||||
}
|
||||
}
|
||||
|
||||
export const initMesherWorker = (onGotMessage: (data: any) => void) => {
|
||||
// Node environment needs an absolute path, but browser needs the url of the file
|
||||
const workerName = 'mesher.js'
|
||||
|
||||
let worker: any
|
||||
if (process.env.SINGLE_FILE_BUILD) {
|
||||
const workerCode = document.getElementById('mesher-worker-code')!.textContent!
|
||||
const blob = new Blob([workerCode], { type: 'text/javascript' })
|
||||
worker = new Worker(window.URL.createObjectURL(blob))
|
||||
} else {
|
||||
worker = new Worker(workerName)
|
||||
}
|
||||
|
||||
worker.onmessage = ({ data }) => {
|
||||
onGotMessage(data)
|
||||
}
|
||||
if (worker.on) worker.on('message', (data) => { worker.onmessage({ data }) })
|
||||
return worker
|
||||
}
|
||||
|
||||
export const meshersSendMcData = (workers: Worker[], version: string, addData = {} as Record<string, any>) => {
|
||||
const allMcData = mcDataRaw.pc[version] ?? mcDataRaw.pc[toMajorVersion(version)]
|
||||
const mcData = {
|
||||
version: JSON.parse(JSON.stringify(allMcData.version))
|
||||
}
|
||||
for (const key of dynamicMcDataFiles) {
|
||||
mcData[key] = allMcData[key]
|
||||
}
|
||||
|
||||
for (const worker of workers) {
|
||||
worker.postMessage({ type: 'mcData', mcData, ...addData })
|
||||
removeStat('chunks-loaded')
|
||||
removeStat('chunks-read')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { fromFormattedString, render, RenderNode, TextComponent } from '@xmcl/text-component'
|
||||
import type { ChatMessage } from 'prismarine-chat'
|
||||
import { createCanvas } from '../lib/utils'
|
||||
|
||||
type SignBlockEntity = {
|
||||
Color?: string
|
||||
|
|
@ -32,40 +32,29 @@ const parseSafe = (text: string, task: string) => {
|
|||
}
|
||||
}
|
||||
|
||||
const LEGACY_COLORS = {
|
||||
black: '#000000',
|
||||
dark_blue: '#0000AA',
|
||||
dark_green: '#00AA00',
|
||||
dark_aqua: '#00AAAA',
|
||||
dark_red: '#AA0000',
|
||||
dark_purple: '#AA00AA',
|
||||
gold: '#FFAA00',
|
||||
gray: '#AAAAAA',
|
||||
dark_gray: '#555555',
|
||||
blue: '#5555FF',
|
||||
green: '#55FF55',
|
||||
aqua: '#55FFFF',
|
||||
red: '#FF5555',
|
||||
light_purple: '#FF55FF',
|
||||
yellow: '#FFFF55',
|
||||
white: '#FFFFFF',
|
||||
}
|
||||
|
||||
export const renderSign = (
|
||||
blockEntity: SignBlockEntity,
|
||||
isHanging: boolean,
|
||||
PrismarineChat: typeof ChatMessage,
|
||||
ctxHook = (ctx) => { },
|
||||
canvasCreator = (width, height): OffscreenCanvas => { return createCanvas(width, height) }
|
||||
) => {
|
||||
export const renderSign = (blockEntity: SignBlockEntity, PrismarineChat: typeof ChatMessage, ctxHook = (ctx) => { }) => {
|
||||
// todo don't use texture rendering, investigate the font rendering when possible
|
||||
// or increase factor when needed
|
||||
const factor = 40
|
||||
const fontSize = 1.6 * factor
|
||||
const signboardY = [16, 9]
|
||||
const heightOffset = signboardY[0] - signboardY[1]
|
||||
const heightScalar = heightOffset / 16
|
||||
// todo the text should be clipped based on it's render width (needs investigate)
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined
|
||||
let _ctx: CanvasRenderingContext2D | null = null
|
||||
const getCtx = () => {
|
||||
if (_ctx) return _ctx
|
||||
canvas = document.createElement('canvas')
|
||||
|
||||
canvas.width = 16 * factor
|
||||
canvas.height = heightOffset * factor
|
||||
|
||||
_ctx = canvas.getContext('2d')!
|
||||
_ctx.imageSmoothingEnabled = false
|
||||
|
||||
ctxHook(_ctx)
|
||||
return _ctx
|
||||
}
|
||||
|
||||
const texts = 'front_text' in blockEntity ? /* > 1.20 */ blockEntity.front_text.messages : [
|
||||
blockEntity.Text1,
|
||||
|
|
@ -73,144 +62,78 @@ export const renderSign = (
|
|||
blockEntity.Text3,
|
||||
blockEntity.Text4
|
||||
]
|
||||
|
||||
if (!texts.some((text) => text !== 'null')) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const canvas = canvasCreator(16 * factor, heightOffset * factor)
|
||||
|
||||
const _ctx = canvas.getContext('2d')!
|
||||
|
||||
ctxHook(_ctx)
|
||||
const defaultColor = ('front_text' in blockEntity ? blockEntity.front_text.color : blockEntity.Color) || 'black'
|
||||
for (const [lineNum, text] of texts.slice(0, 4).entries()) {
|
||||
// todo: in pre flatenning it seems the format was not json
|
||||
if (text === 'null') continue
|
||||
renderComponent(text, PrismarineChat, canvas, fontSize, defaultColor, fontSize * (lineNum + 1) + (isHanging ? 0 : -8))
|
||||
const parsed = text?.startsWith('{') || text?.startsWith('"') ? parseSafe(text ?? '""', 'sign text') : text
|
||||
if (!parsed || (typeof parsed !== 'object' && typeof parsed !== 'string')) continue
|
||||
// todo fix type
|
||||
const message = typeof parsed === 'string' ? fromFormattedString(parsed) : new PrismarineChat(parsed) as never
|
||||
const patchExtra = ({ extra }: TextComponent) => {
|
||||
if (!extra) return
|
||||
for (const child of extra) {
|
||||
if (child.color) {
|
||||
child.color = child.color === 'dark_green' ? child.color.toUpperCase() : child.color.toLowerCase()
|
||||
}
|
||||
patchExtra(child)
|
||||
}
|
||||
}
|
||||
patchExtra(message)
|
||||
const rendered = render(message)
|
||||
|
||||
const toRenderCanvas: Array<{
|
||||
fontStyle: string
|
||||
fillStyle: string
|
||||
underlineStyle: boolean
|
||||
strikeStyle: boolean
|
||||
text: string
|
||||
}> = []
|
||||
let plainText = ''
|
||||
// todo the text should be clipped based on it's render width (needs investigate)
|
||||
const MAX_LENGTH = 50 // avoid abusing the signboard
|
||||
const renderText = (node: RenderNode) => {
|
||||
const { component } = node
|
||||
let { text } = component
|
||||
if (plainText.length + text.length > MAX_LENGTH) {
|
||||
text = text.slice(0, MAX_LENGTH - plainText.length)
|
||||
if (!text) return false
|
||||
}
|
||||
plainText += text
|
||||
toRenderCanvas.push({
|
||||
fontStyle: `${component.bold ? 'bold' : ''} ${component.italic ? 'italic' : ''}`,
|
||||
fillStyle: node.style['color'] || defaultColor,
|
||||
underlineStyle: component.underlined ?? false,
|
||||
strikeStyle: component.strikethrough ?? false,
|
||||
text
|
||||
})
|
||||
for (const child of node.children) {
|
||||
const stop = renderText(child) === false
|
||||
if (stop) return false
|
||||
}
|
||||
}
|
||||
|
||||
renderText(rendered)
|
||||
|
||||
// skip rendering empty lines (and possible signs)
|
||||
if (!plainText.trim()) continue
|
||||
|
||||
const ctx = getCtx()
|
||||
const fontSize = 1.6 * factor
|
||||
ctx.font = `${fontSize}px mojangles`
|
||||
const textWidth = ctx.measureText(plainText).width
|
||||
|
||||
let renderedWidth = 0
|
||||
for (const { fillStyle, fontStyle, strikeStyle, text, underlineStyle } of toRenderCanvas) {
|
||||
// todo strikeStyle, underlineStyle
|
||||
ctx.fillStyle = fillStyle
|
||||
ctx.font = `${fontStyle} ${fontSize}px mojangles`
|
||||
ctx.fillText(text, (canvas!.width - textWidth) / 2 + renderedWidth, fontSize * (lineNum + 1))
|
||||
renderedWidth += ctx.measureText(text).width // todo isn't the font is monospace?
|
||||
}
|
||||
}
|
||||
// ctx.fillStyle = 'red'
|
||||
// ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
return canvas
|
||||
}
|
||||
|
||||
export const renderComponent = (
|
||||
text: JsonEncodedType | string | undefined,
|
||||
PrismarineChat: typeof ChatMessage,
|
||||
canvas: OffscreenCanvas,
|
||||
fontSize: number,
|
||||
defaultColor: string,
|
||||
offset = 0
|
||||
) => {
|
||||
// todo: in pre flatenning it seems the format was not json
|
||||
const parsed = typeof text === 'string' && (text?.startsWith('{') || text?.startsWith('"')) ? parseSafe(text ?? '""', 'sign text') : text
|
||||
if (!parsed || (typeof parsed !== 'object' && typeof parsed !== 'string')) return
|
||||
// todo fix type
|
||||
|
||||
const ctx = canvas.getContext('2d')!
|
||||
if (!ctx) throw new Error('Could not get 2d context')
|
||||
ctx.imageSmoothingEnabled = false
|
||||
ctx.font = `${fontSize}px mojangles`
|
||||
|
||||
type Formatting = {
|
||||
color: string | undefined
|
||||
underlined: boolean | undefined
|
||||
strikethrough: boolean | undefined
|
||||
bold: boolean | undefined
|
||||
italic: boolean | undefined
|
||||
}
|
||||
|
||||
type Message = ChatMessage & Formatting & { text: string }
|
||||
|
||||
const message = new PrismarineChat(parsed) as Message
|
||||
|
||||
const toRenderCanvas: Array<{
|
||||
fontStyle: string
|
||||
fillStyle: string
|
||||
underlineStyle: boolean
|
||||
strikeStyle: boolean
|
||||
offset: number
|
||||
text: string
|
||||
}> = []
|
||||
let visibleFormatting = false
|
||||
let plainText = ''
|
||||
let textOffset = offset
|
||||
const textWidths: number[] = []
|
||||
|
||||
const renderText = (component: Message, parentFormatting?: Formatting | undefined) => {
|
||||
const { text } = component
|
||||
const formatting = {
|
||||
color: component.color ?? parentFormatting?.color,
|
||||
underlined: component.underlined ?? parentFormatting?.underlined,
|
||||
strikethrough: component.strikethrough ?? parentFormatting?.strikethrough,
|
||||
bold: component.bold ?? parentFormatting?.bold,
|
||||
italic: component.italic ?? parentFormatting?.italic
|
||||
}
|
||||
visibleFormatting = visibleFormatting || formatting.underlined || formatting.strikethrough || false
|
||||
if (text?.includes('\n')) {
|
||||
for (const line of text.split('\n')) {
|
||||
addTextPart(line, formatting)
|
||||
textOffset += fontSize
|
||||
plainText = ''
|
||||
}
|
||||
} else if (text) {
|
||||
addTextPart(text, formatting)
|
||||
}
|
||||
if (component.extra) {
|
||||
for (const child of component.extra) {
|
||||
renderText(child as Message, formatting)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const addTextPart = (text: string, formatting: Formatting) => {
|
||||
plainText += text
|
||||
textWidths[textOffset] = ctx.measureText(plainText).width
|
||||
let color = formatting.color ?? defaultColor
|
||||
if (!color.startsWith('#')) {
|
||||
color = LEGACY_COLORS[color.toLowerCase()] || color
|
||||
}
|
||||
toRenderCanvas.push({
|
||||
fontStyle: `${formatting.bold ? 'bold' : ''} ${formatting.italic ? 'italic' : ''}`,
|
||||
fillStyle: color,
|
||||
underlineStyle: formatting.underlined ?? false,
|
||||
strikeStyle: formatting.strikethrough ?? false,
|
||||
offset: textOffset,
|
||||
text
|
||||
})
|
||||
}
|
||||
|
||||
renderText(message)
|
||||
|
||||
// skip rendering empty lines
|
||||
if (!visibleFormatting && !message.toString().trim()) return
|
||||
|
||||
let renderedWidth = 0
|
||||
let previousOffsetY = 0
|
||||
for (const { fillStyle, fontStyle, underlineStyle, strikeStyle, offset: offsetY, text } of toRenderCanvas) {
|
||||
if (previousOffsetY !== offsetY) {
|
||||
renderedWidth = 0
|
||||
}
|
||||
previousOffsetY = offsetY
|
||||
ctx.fillStyle = fillStyle
|
||||
ctx.textRendering = 'optimizeLegibility'
|
||||
ctx.font = `${fontStyle} ${fontSize}px mojangles`
|
||||
const textWidth = textWidths[offsetY] ?? ctx.measureText(text).width
|
||||
const offsetX = (canvas.width - textWidth) / 2 + renderedWidth
|
||||
ctx.fillText(text, offsetX, offsetY)
|
||||
if (strikeStyle) {
|
||||
ctx.lineWidth = fontSize / 8
|
||||
ctx.strokeStyle = fillStyle
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(offsetX, offsetY - ctx.lineWidth * 2.5)
|
||||
ctx.lineTo(offsetX + ctx.measureText(text).width, offsetY - ctx.lineWidth * 2.5)
|
||||
ctx.stroke()
|
||||
}
|
||||
if (underlineStyle) {
|
||||
ctx.lineWidth = fontSize / 8
|
||||
ctx.strokeStyle = fillStyle
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(offsetX, offsetY + ctx.lineWidth)
|
||||
ctx.lineTo(offsetX + ctx.measureText(text).width, offsetY + ctx.lineWidth)
|
||||
ctx.stroke()
|
||||
}
|
||||
renderedWidth += ctx.measureText(text).width
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,14 +21,9 @@ const blockEntity = {
|
|||
|
||||
await document.fonts.load('1em mojangles')
|
||||
|
||||
const canvas = renderSign(blockEntity, false, PrismarineChat, (ctx) => {
|
||||
const canvas = renderSign(blockEntity, PrismarineChat, (ctx) => {
|
||||
ctx.drawImage(img, 0, 0, ctx.canvas.width, ctx.canvas.height)
|
||||
}, (width, height) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
return canvas as unknown as OffscreenCanvas
|
||||
}) as unknown as HTMLCanvasElement
|
||||
})
|
||||
|
||||
if (canvas) {
|
||||
canvas.style.imageRendering = 'pixelated'
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ global.document = {
|
|||
|
||||
const render = (entity) => {
|
||||
ctxTexts = []
|
||||
renderSign(entity, true, PrismarineChat)
|
||||
renderSign(entity, PrismarineChat)
|
||||
return ctxTexts.map(({ text, y }) => [y / 64, text])
|
||||
}
|
||||
|
||||
|
|
@ -37,6 +37,10 @@ test('sign renderer', () => {
|
|||
} as any
|
||||
expect(render(blockEntity)).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
1,
|
||||
"",
|
||||
],
|
||||
[
|
||||
1,
|
||||
"Minecraft ",
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import { BlockModel } from 'mc-assets/dist/types'
|
||||
import { ItemSpecificContextProperties, PlayerStateRenderer } from 'renderer/viewer/lib/basePlayerState'
|
||||
import { ItemSpecificContextProperties } from 'renderer/viewer/lib/basePlayerState'
|
||||
import { renderSlot } from '../../../src/inventoryWindows'
|
||||
import { GeneralInputItem, getItemModelName } from '../../../src/mineflayer/items'
|
||||
import { ResourcesManager, ResourcesManagerTransferred } from '../../../src/resourcesManager'
|
||||
import { renderSlot } from './renderSlot'
|
||||
import { ResourcesManager } from '../../../src/resourcesManager'
|
||||
|
||||
export const getItemUv = (item: Record<string, any>, specificProps: ItemSpecificContextProperties, resourcesManager: ResourcesManagerTransferred, playerState: PlayerStateRenderer): {
|
||||
export const getItemUv = (item: Record<string, any>, specificProps: ItemSpecificContextProperties, resourcesManager: ResourcesManager): {
|
||||
u: number
|
||||
v: number
|
||||
su: number
|
||||
sv: number
|
||||
renderInfo?: ReturnType<typeof renderSlot>
|
||||
// texture: ImageBitmap
|
||||
texture: HTMLImageElement
|
||||
modelName: string
|
||||
} | {
|
||||
resolvedModel: BlockModel
|
||||
|
|
@ -19,22 +19,18 @@ export const getItemUv = (item: Record<string, any>, specificProps: ItemSpecific
|
|||
const resources = resourcesManager.currentResources
|
||||
if (!resources) throw new Error('Resources not loaded')
|
||||
const idOrName = item.itemId ?? item.blockId ?? item.name
|
||||
const { blockState } = item
|
||||
try {
|
||||
const name =
|
||||
blockState
|
||||
? loadedData.blocksByStateId[blockState]?.name
|
||||
: typeof idOrName === 'number' ? loadedData.items[idOrName]?.name : idOrName
|
||||
const name = typeof idOrName === 'number' ? loadedData.items[idOrName]?.name : idOrName
|
||||
if (!name) throw new Error(`Item not found: ${idOrName}`)
|
||||
|
||||
const model = getItemModelName({
|
||||
...item,
|
||||
name,
|
||||
} as GeneralInputItem, specificProps, resourcesManager, playerState)
|
||||
} as GeneralInputItem, specificProps, resourcesManager)
|
||||
|
||||
const renderInfo = renderSlot({
|
||||
modelName: model,
|
||||
}, resourcesManager, false, true)
|
||||
}, false, true)
|
||||
|
||||
if (!renderInfo) throw new Error(`Failed to get render info for item ${name}`)
|
||||
|
||||
|
|
@ -53,7 +49,7 @@ export const getItemUv = (item: Record<string, any>, specificProps: ItemSpecific
|
|||
return {
|
||||
u, v, su, sv,
|
||||
renderInfo,
|
||||
// texture: img,
|
||||
texture: img,
|
||||
modelName: renderInfo.modelName!
|
||||
}
|
||||
}
|
||||
|
|
@ -67,7 +63,7 @@ export const getItemUv = (item: Record<string, any>, specificProps: ItemSpecific
|
|||
v: 0,
|
||||
su: 16 / resources.blocksAtlasImage.width,
|
||||
sv: 16 / resources.blocksAtlasImage.width,
|
||||
// texture: resources.blocksAtlasImage,
|
||||
texture: resources.blocksAtlasImage,
|
||||
modelName: 'missing'
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import * as THREE from 'three'
|
||||
import { WorldRendererThree } from './worldrendererThree'
|
||||
|
||||
export class CameraShake {
|
||||
private rollAngle = 0
|
||||
|
|
@ -9,7 +8,7 @@ export class CameraShake {
|
|||
private basePitch = 0
|
||||
private baseYaw = 0
|
||||
|
||||
constructor (public worldRenderer: WorldRendererThree, public onRenderCallbacks: Array<() => void>) {
|
||||
constructor (public camera: THREE.Camera, public onRenderCallbacks: Array<() => void>) {
|
||||
onRenderCallbacks.push(() => {
|
||||
this.update()
|
||||
})
|
||||
|
|
@ -21,10 +20,6 @@ export class CameraShake {
|
|||
this.update()
|
||||
}
|
||||
|
||||
getBaseRotation () {
|
||||
return { pitch: this.basePitch, yaw: this.baseYaw }
|
||||
}
|
||||
|
||||
shakeFromDamage (yaw?: number) {
|
||||
// Add roll animation
|
||||
const startRoll = this.rollAngle
|
||||
|
|
@ -39,11 +34,6 @@ export class CameraShake {
|
|||
}
|
||||
|
||||
update () {
|
||||
if (this.worldRenderer.playerStateUtils.isSpectatingEntity()) {
|
||||
// Remove any shaking when spectating
|
||||
this.rollAngle = 0
|
||||
this.rollAnimation = undefined
|
||||
}
|
||||
// Update roll animation
|
||||
if (this.rollAnimation) {
|
||||
const now = performance.now()
|
||||
|
|
@ -72,25 +62,14 @@ export class CameraShake {
|
|||
}
|
||||
}
|
||||
|
||||
const camera = this.worldRenderer.cameraObject
|
||||
// Create rotation quaternions
|
||||
const pitchQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), this.basePitch)
|
||||
const yawQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), this.baseYaw)
|
||||
const rollQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), THREE.MathUtils.degToRad(this.rollAngle))
|
||||
|
||||
if (this.worldRenderer.cameraGroupVr) {
|
||||
// For VR camera, only apply yaw rotation
|
||||
const yawQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), this.baseYaw)
|
||||
camera.setRotationFromQuaternion(yawQuat)
|
||||
} else {
|
||||
// For regular camera, apply all rotations
|
||||
// Add tiny offsets to prevent z-fighting at ideal angles (90, 180, 270 degrees)
|
||||
const pitchOffset = this.addAntiZfightingOffset(this.basePitch)
|
||||
const yawOffset = this.addAntiZfightingOffset(this.baseYaw)
|
||||
|
||||
const pitchQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), pitchOffset)
|
||||
const yawQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), yawOffset)
|
||||
const rollQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), THREE.MathUtils.degToRad(this.rollAngle))
|
||||
// Combine rotations in the correct order: pitch -> yaw -> roll
|
||||
const finalQuat = yawQuat.multiply(pitchQuat).multiply(rollQuat)
|
||||
camera.setRotationFromQuaternion(finalQuat)
|
||||
}
|
||||
// Combine rotations in the correct order: pitch -> yaw -> roll
|
||||
const finalQuat = yawQuat.multiply(pitchQuat).multiply(rollQuat)
|
||||
this.camera.setRotationFromQuaternion(finalQuat)
|
||||
}
|
||||
|
||||
private easeOut (t: number): number {
|
||||
|
|
@ -100,21 +79,4 @@ export class CameraShake {
|
|||
private easeInOut (t: number): number {
|
||||
return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2
|
||||
}
|
||||
|
||||
private addAntiZfightingOffset (angle: number): number {
|
||||
const offset = 0.001 // Very small offset in radians (about 0.057 degrees)
|
||||
|
||||
// Check if the angle is close to ideal angles (0, π/2, π, 3π/2)
|
||||
const normalizedAngle = ((angle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)
|
||||
const tolerance = 0.01 // Tolerance for considering an angle "ideal"
|
||||
|
||||
if (Math.abs(normalizedAngle) < tolerance ||
|
||||
Math.abs(normalizedAngle - Math.PI / 2) < tolerance ||
|
||||
Math.abs(normalizedAngle - Math.PI) < tolerance ||
|
||||
Math.abs(normalizedAngle - 3 * Math.PI / 2) < tolerance) {
|
||||
return angle + offset
|
||||
}
|
||||
|
||||
return angle
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,23 +3,17 @@ import Stats from 'stats.js'
|
|||
import StatsGl from 'stats-gl'
|
||||
import * as tween from '@tweenjs/tween.js'
|
||||
import { GraphicsBackendConfig, GraphicsInitOptions } from '../../../src/appViewer'
|
||||
import { WorldRendererConfig } from '../lib/worldrendererCommon'
|
||||
|
||||
export class DocumentRenderer {
|
||||
canvas: HTMLCanvasElement | OffscreenCanvas
|
||||
readonly canvas = document.createElement('canvas')
|
||||
readonly renderer: THREE.WebGLRenderer
|
||||
private animationFrameId?: number
|
||||
private timeoutId?: number
|
||||
private lastRenderTime = 0
|
||||
|
||||
private previousCanvasWidth = 0
|
||||
private previousCanvasHeight = 0
|
||||
private currentWidth = 0
|
||||
private currentHeight = 0
|
||||
|
||||
private previousWindowWidth = window.innerWidth
|
||||
private previousWindowHeight = window.innerHeight
|
||||
private renderedFps = 0
|
||||
private fpsInterval: any
|
||||
private readonly stats: TopRightStats | undefined
|
||||
private readonly stats: TopRightStats
|
||||
private paused = false
|
||||
disconnected = false
|
||||
preRender = () => { }
|
||||
|
|
@ -29,18 +23,10 @@ export class DocumentRenderer {
|
|||
droppedFpsPercentage: number
|
||||
config: GraphicsBackendConfig
|
||||
onRender = [] as Array<(sizeChanged: boolean) => void>
|
||||
inWorldRenderingConfig: WorldRendererConfig | undefined
|
||||
|
||||
constructor (initOptions: GraphicsInitOptions, public externalCanvas?: OffscreenCanvas) {
|
||||
constructor (initOptions: GraphicsInitOptions) {
|
||||
this.config = initOptions.config
|
||||
|
||||
// Handle canvas creation/transfer based on context
|
||||
if (externalCanvas) {
|
||||
this.canvas = externalCanvas
|
||||
} else {
|
||||
this.addToPage()
|
||||
}
|
||||
|
||||
try {
|
||||
this.renderer = new THREE.WebGLRenderer({
|
||||
canvas: this.canvas,
|
||||
|
|
@ -49,25 +35,17 @@ export class DocumentRenderer {
|
|||
powerPreference: this.config.powerPreference
|
||||
})
|
||||
} catch (err) {
|
||||
initOptions.callbacks.displayCriticalError(new Error(`Failed to create WebGL context, not possible to render (restart browser): ${err.message}`))
|
||||
initOptions.displayCriticalError(new Error(`Failed to create WebGL context, not possible to render (restart browser): ${err.message}`))
|
||||
throw err
|
||||
}
|
||||
this.renderer.outputColorSpace = THREE.LinearSRGBColorSpace
|
||||
if (!externalCanvas) {
|
||||
this.updatePixelRatio()
|
||||
}
|
||||
this.sizeUpdated()
|
||||
// Initialize previous dimensions
|
||||
this.previousCanvasWidth = this.canvas.width
|
||||
this.previousCanvasHeight = this.canvas.height
|
||||
this.updatePixelRatio()
|
||||
this.updateSize()
|
||||
this.addToPage()
|
||||
|
||||
const supportsWebGL2 = 'WebGL2RenderingContext' in window
|
||||
// Only initialize stats and DOM-related features in main thread
|
||||
if (!externalCanvas && supportsWebGL2) {
|
||||
this.stats = new TopRightStats(this.canvas as HTMLCanvasElement, this.config.statsVisible)
|
||||
this.setupFpsTracking()
|
||||
}
|
||||
this.stats = new TopRightStats(this.canvas, this.config.statsVisible)
|
||||
|
||||
this.setupFpsTracking()
|
||||
this.startRenderLoop()
|
||||
}
|
||||
|
||||
|
|
@ -79,33 +57,15 @@ export class DocumentRenderer {
|
|||
this.renderer.setPixelRatio(pixelRatio)
|
||||
}
|
||||
|
||||
sizeUpdated () {
|
||||
this.renderer.setSize(this.currentWidth, this.currentHeight, false)
|
||||
updateSize () {
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight)
|
||||
}
|
||||
|
||||
private addToPage () {
|
||||
this.canvas = addCanvasToPage()
|
||||
this.updateCanvasSize()
|
||||
}
|
||||
|
||||
updateSizeExternal (newWidth: number, newHeight: number, pixelRatio: number) {
|
||||
this.currentWidth = newWidth
|
||||
this.currentHeight = newHeight
|
||||
this.renderer.setPixelRatio(pixelRatio)
|
||||
this.sizeUpdated()
|
||||
}
|
||||
|
||||
private updateCanvasSize () {
|
||||
if (!this.externalCanvas) {
|
||||
const innnerWidth = window.innerWidth
|
||||
const innnerHeight = window.innerHeight
|
||||
if (this.currentWidth !== innnerWidth) {
|
||||
this.currentWidth = innnerWidth
|
||||
}
|
||||
if (this.currentHeight !== innnerHeight) {
|
||||
this.currentHeight = innnerHeight
|
||||
}
|
||||
}
|
||||
this.canvas.id = 'viewer-canvas'
|
||||
this.canvas.style.width = '100%'
|
||||
this.canvas.style.height = '100%'
|
||||
document.body.appendChild(this.canvas)
|
||||
}
|
||||
|
||||
private setupFpsTracking () {
|
||||
|
|
@ -119,17 +79,22 @@ export class DocumentRenderer {
|
|||
}, 1000)
|
||||
}
|
||||
|
||||
// private handleResize () {
|
||||
// const width = window.innerWidth
|
||||
// const height = window.innerHeight
|
||||
|
||||
// viewer.camera.aspect = width / height
|
||||
// viewer.camera.updateProjectionMatrix()
|
||||
// this.renderer.setSize(width, height)
|
||||
// viewer.world.handleResize()
|
||||
// }
|
||||
|
||||
private startRenderLoop () {
|
||||
const animate = () => {
|
||||
if (this.disconnected) return
|
||||
this.animationFrameId = requestAnimationFrame(animate)
|
||||
|
||||
if (this.config.timeoutRendering) {
|
||||
this.timeoutId = setTimeout(animate, this.config.fpsLimit ? 1000 / this.config.fpsLimit : 0) as unknown as number
|
||||
} else {
|
||||
this.animationFrameId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
if (this.paused || (this.renderer.xr.isPresenting && !this.inWorldRenderingConfig?.vrPageGameRendering)) return
|
||||
if (this.paused) return
|
||||
|
||||
// Handle FPS limiting
|
||||
if (this.config.fpsLimit) {
|
||||
|
|
@ -145,40 +110,33 @@ export class DocumentRenderer {
|
|||
}
|
||||
|
||||
let sizeChanged = false
|
||||
this.updateCanvasSize()
|
||||
if (this.previousCanvasWidth !== this.currentWidth || this.previousCanvasHeight !== this.currentHeight) {
|
||||
this.previousCanvasWidth = this.currentWidth
|
||||
this.previousCanvasHeight = this.currentHeight
|
||||
this.sizeUpdated()
|
||||
if (this.previousWindowWidth !== window.innerWidth || this.previousWindowHeight !== window.innerHeight) {
|
||||
this.previousWindowWidth = window.innerWidth
|
||||
this.previousWindowHeight = window.innerHeight
|
||||
this.updateSize()
|
||||
sizeChanged = true
|
||||
}
|
||||
|
||||
this.frameRender(sizeChanged)
|
||||
this.preRender()
|
||||
this.stats.markStart()
|
||||
tween.update()
|
||||
this.render(sizeChanged)
|
||||
for (const fn of this.onRender) {
|
||||
fn(sizeChanged)
|
||||
}
|
||||
this.renderedFps++
|
||||
this.stats.markEnd()
|
||||
this.postRender()
|
||||
|
||||
// Update stats visibility each frame (main thread only)
|
||||
// Update stats visibility each frame
|
||||
if (this.config.statsVisible !== undefined) {
|
||||
this.stats?.setVisibility(this.config.statsVisible)
|
||||
this.stats.setVisibility(this.config.statsVisible)
|
||||
}
|
||||
}
|
||||
|
||||
animate()
|
||||
}
|
||||
|
||||
frameRender (sizeChanged: boolean) {
|
||||
this.preRender()
|
||||
this.stats?.markStart()
|
||||
tween.update()
|
||||
if (!globalThis.freezeRender) {
|
||||
this.render(sizeChanged)
|
||||
}
|
||||
for (const fn of this.onRender) {
|
||||
fn(sizeChanged)
|
||||
}
|
||||
this.renderedFps++
|
||||
this.stats?.markEnd()
|
||||
this.postRender()
|
||||
}
|
||||
|
||||
setPaused (paused: boolean) {
|
||||
this.paused = paused
|
||||
}
|
||||
|
|
@ -188,15 +146,10 @@ export class DocumentRenderer {
|
|||
if (this.animationFrameId) {
|
||||
cancelAnimationFrame(this.animationFrameId)
|
||||
}
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId)
|
||||
}
|
||||
if (this.canvas instanceof HTMLCanvasElement) {
|
||||
this.canvas.remove()
|
||||
}
|
||||
clearInterval(this.fpsInterval)
|
||||
this.stats?.dispose()
|
||||
this.canvas.remove()
|
||||
this.renderer.dispose()
|
||||
clearInterval(this.fpsInterval)
|
||||
this.stats.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -236,10 +189,6 @@ class TopRightStats {
|
|||
const hasRamPanel = this.stats2.dom.children.length === 3
|
||||
|
||||
this.addStat(this.stats.dom)
|
||||
if (process.env.NODE_ENV === 'development' && document.exitPointerLock) {
|
||||
this.stats.dom.style.top = ''
|
||||
this.stats.dom.style.bottom = '0'
|
||||
}
|
||||
if (hasRamPanel) {
|
||||
this.addStat(this.stats2.dom)
|
||||
}
|
||||
|
|
@ -289,40 +238,3 @@ class TopRightStats {
|
|||
this.statsGl.container.remove()
|
||||
}
|
||||
}
|
||||
|
||||
const addCanvasToPage = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.id = 'viewer-canvas'
|
||||
document.body.appendChild(canvas)
|
||||
return canvas
|
||||
}
|
||||
|
||||
export const addCanvasForWorker = () => {
|
||||
const canvas = addCanvasToPage()
|
||||
const transferred = canvas.transferControlToOffscreen()
|
||||
let removed = false
|
||||
let onSizeChanged = (w, h) => { }
|
||||
let oldSize = { width: 0, height: 0 }
|
||||
const checkSize = () => {
|
||||
if (removed) return
|
||||
if (oldSize.width !== window.innerWidth || oldSize.height !== window.innerHeight) {
|
||||
onSizeChanged(window.innerWidth, window.innerHeight)
|
||||
oldSize = { width: window.innerWidth, height: window.innerHeight }
|
||||
}
|
||||
requestAnimationFrame(checkSize)
|
||||
}
|
||||
requestAnimationFrame(checkSize)
|
||||
return {
|
||||
canvas: transferred,
|
||||
destroy () {
|
||||
removed = true
|
||||
canvas.remove()
|
||||
},
|
||||
onSizeChanged (cb: (width: number, height: number) => void) {
|
||||
onSizeChanged = cb
|
||||
},
|
||||
get size () {
|
||||
return { width: window.innerWidth, height: window.innerHeight }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2,11 +2,10 @@ import * as THREE from 'three'
|
|||
import { OBJLoader } from 'three-stdlib'
|
||||
import huskPng from 'mc-assets/dist/other-textures/latest/entity/zombie/husk.png'
|
||||
import { Vec3 } from 'vec3'
|
||||
import ocelotPng from '../../../../node_modules/mc-assets/dist/other-textures/latest/entity/cat/ocelot.png'
|
||||
import arrowTexture from '../../../../node_modules/mc-assets/dist/other-textures/1.21.2/entity/projectiles/arrow.png'
|
||||
import spectralArrowTexture from '../../../../node_modules/mc-assets/dist/other-textures/1.21.2/entity/projectiles/spectral_arrow.png'
|
||||
import tippedArrowTexture from '../../../../node_modules/mc-assets/dist/other-textures/1.21.2/entity/projectiles/tipped_arrow.png'
|
||||
import { loadTexture } from '../threeJsUtils'
|
||||
import { loadTexture } from '../../lib/utils'
|
||||
import { WorldRendererThree } from '../worldrendererThree'
|
||||
import entities from './entities.json'
|
||||
import { externalModels } from './objModels'
|
||||
|
|
@ -238,11 +237,10 @@ export function getMesh (
|
|||
if (useBlockTexture) {
|
||||
if (!worldRenderer) throw new Error('worldRenderer is required for block textures')
|
||||
const blockName = texture.slice(6)
|
||||
const textureInfo = worldRenderer.resourcesManager.currentResources.blocksAtlasJson.textures[blockName]
|
||||
const textureInfo = worldRenderer.resourcesManager.currentResources!.blocksAtlasParser.getTextureInfo(blockName)
|
||||
if (textureInfo) {
|
||||
textureWidth = blocksTexture?.image.width ?? textureWidth
|
||||
textureHeight = blocksTexture?.image.height ?? textureHeight
|
||||
// todo support su/sv
|
||||
textureOffset = [textureInfo.u, textureInfo.v]
|
||||
} else {
|
||||
console.error(`Unknown block ${blockName}`)
|
||||
|
|
@ -458,7 +456,7 @@ export class EntityMesh {
|
|||
'skeleton_horse': `textures/${version}/entity/horse/horse_skeleton.png`,
|
||||
'donkey': `textures/${version}/entity/horse/donkey.png`,
|
||||
'mule': `textures/${version}/entity/horse/mule.png`,
|
||||
'ocelot': ocelotPng,
|
||||
'ocelot': `textures/${version}/entity/cat/ocelot.png`,
|
||||
'arrow': arrowTexture,
|
||||
'spectral_arrow': spectralArrowTexture,
|
||||
'tipped_arrow': tippedArrowTexture
|
||||
|
|
@ -529,6 +527,12 @@ export class EntityMesh {
|
|||
debugFlags)
|
||||
mesh.name = `geometry_${name}`
|
||||
this.mesh.add(mesh)
|
||||
|
||||
const skeletonHelper = new THREE.SkeletonHelper(mesh)
|
||||
//@ts-expect-error
|
||||
skeletonHelper.material.linewidth = 2
|
||||
skeletonHelper.visible = false
|
||||
this.mesh.add(skeletonHelper)
|
||||
}
|
||||
debugFlags.type = 'bedrock'
|
||||
}
|
||||
|
|
@ -547,4 +551,3 @@ export class EntityMesh {
|
|||
}
|
||||
}
|
||||
}
|
||||
globalThis.EntityMesh = EntityMesh
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
//@ts-check
|
||||
import { PlayerAnimation } from 'skinview3d'
|
||||
|
||||
export class WalkingGeneralSwing extends PlayerAnimation {
|
||||
|
|
@ -7,7 +6,6 @@ export class WalkingGeneralSwing extends PlayerAnimation {
|
|||
|
||||
isRunning = false
|
||||
isMoving = true
|
||||
isCrouched = false
|
||||
|
||||
_startArmSwing
|
||||
|
||||
|
|
@ -17,7 +15,7 @@ export class WalkingGeneralSwing extends PlayerAnimation {
|
|||
|
||||
animate(player) {
|
||||
// Multiply by animation's natural speed
|
||||
let t = 0
|
||||
let t
|
||||
const updateT = () => {
|
||||
if (!this.isMoving) {
|
||||
t = 0
|
||||
|
|
@ -32,8 +30,6 @@ export class WalkingGeneralSwing extends PlayerAnimation {
|
|||
updateT()
|
||||
let reset = false
|
||||
|
||||
croughAnimation(player, this.isCrouched)
|
||||
|
||||
if ((this.isRunning ? Math.cos(t) : Math.sin(t)) < 0.01) {
|
||||
if (this.switchAnimationCallback) {
|
||||
reset = true
|
||||
|
|
@ -54,12 +50,11 @@ export class WalkingGeneralSwing extends PlayerAnimation {
|
|||
|
||||
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
|
||||
HitAnimation.animate((this.progress - this._startArmSwing), player, this.isMoving)
|
||||
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) {
|
||||
if (tHand > Math.PI + Math.PI * 0.5) {
|
||||
this._startArmSwing = null
|
||||
player.skin.rightArm.rotation.z = 0
|
||||
}
|
||||
|
|
@ -106,66 +101,3 @@ export class WalkingGeneralSwing extends PlayerAnimation {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
const HitAnimation = {
|
||||
animate(progress, player, isMovingOrRunning) {
|
||||
const t = progress * 18
|
||||
player.skin.rightArm.rotation.x = -0.453_786_055_2 * 2 + 2 * Math.sin(t + Math.PI) * 0.3
|
||||
|
||||
if (!isMovingOrRunning) {
|
||||
const basicArmRotationZ = 0.01 * Math.PI + 0.06
|
||||
player.skin.rightArm.rotation.z = -Math.cos(t) * 0.403 + basicArmRotationZ
|
||||
player.skin.body.rotation.y = -Math.cos(t) * 0.06
|
||||
player.skin.leftArm.rotation.x = Math.sin(t + Math.PI) * 0.077
|
||||
player.skin.leftArm.rotation.z = -Math.cos(t) * 0.015 + 0.13 - 0.05
|
||||
player.skin.leftArm.position.z = Math.cos(t) * 0.3
|
||||
player.skin.leftArm.position.x = 5 - Math.cos(t) * 0.05
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const croughAnimation = (player, isCrouched) => {
|
||||
const erp = 0
|
||||
|
||||
// let pr = this.progress * 8;
|
||||
let pr = isCrouched ? 1 : 0
|
||||
const showProgress = false
|
||||
if (showProgress) {
|
||||
pr = Math.floor(pr)
|
||||
}
|
||||
player.skin.body.rotation.x = 0.453_786_055_2 * Math.abs(Math.sin((pr * Math.PI) / 2))
|
||||
player.skin.body.position.z =
|
||||
1.325_618_1 * Math.abs(Math.sin((pr * Math.PI) / 2)) - 3.450_031_037_7 * Math.abs(Math.sin((pr * Math.PI) / 2))
|
||||
player.skin.body.position.y = -6 - 2.103_677_462 * Math.abs(Math.sin((pr * Math.PI) / 2))
|
||||
player.cape.position.y = 8 - 1.851_236_166_577_372 * Math.abs(Math.sin((pr * Math.PI) / 2))
|
||||
player.cape.rotation.x = (10.8 * Math.PI) / 180 + 0.294_220_265_771 * Math.abs(Math.sin((pr * Math.PI) / 2))
|
||||
player.cape.position.z =
|
||||
-2 + 3.786_619_432 * Math.abs(Math.sin((pr * Math.PI) / 2)) - 3.450_031_037_7 * Math.abs(Math.sin((pr * Math.PI) / 2))
|
||||
player.elytra.position.x = player.cape.position.x
|
||||
player.elytra.position.y = player.cape.position.y
|
||||
player.elytra.position.z = player.cape.position.z
|
||||
player.elytra.rotation.x = player.cape.rotation.x - (10.8 * Math.PI) / 180
|
||||
// const pr1 = this.progress / this.speed;
|
||||
const pr1 = 1
|
||||
if (Math.abs(Math.sin((pr * Math.PI) / 2)) === 1) {
|
||||
player.elytra.leftWing.rotation.z =
|
||||
0.261_799_44 + 0.458_200_6 * Math.abs(Math.sin((Math.min(pr1 - erp, 1) * Math.PI) / 2))
|
||||
player.elytra.updateRightWing()
|
||||
} else if (isCrouched !== undefined) {
|
||||
player.elytra.leftWing.rotation.z =
|
||||
0.72 - 0.458_200_6 * Math.abs(Math.sin((Math.min(pr1 - erp, 1) * Math.PI) / 2))
|
||||
player.elytra.updateRightWing()
|
||||
}
|
||||
player.skin.head.position.y = -3.618_325_234_674 * Math.abs(Math.sin((pr * Math.PI) / 2))
|
||||
player.skin.leftArm.position.z =
|
||||
3.618_325_234_674 * Math.abs(Math.sin((pr * Math.PI) / 2)) - 3.450_031_037_7 * Math.abs(Math.sin((pr * Math.PI) / 2))
|
||||
player.skin.rightArm.position.z = player.skin.leftArm.position.z
|
||||
player.skin.leftArm.rotation.x = 0.410_367_746_202 * Math.abs(Math.sin((pr * Math.PI) / 2))
|
||||
player.skin.rightArm.rotation.x = player.skin.leftArm.rotation.x
|
||||
player.skin.leftArm.rotation.z = 0.1
|
||||
player.skin.rightArm.rotation.z = -player.skin.leftArm.rotation.z
|
||||
player.skin.leftArm.position.y = -2 - 2.539_433_18 * Math.abs(Math.sin((pr * Math.PI) / 2))
|
||||
player.skin.rightArm.position.y = player.skin.leftArm.position.y
|
||||
player.skin.rightLeg.position.z = -3.450_031_037_7 * Math.abs(Math.sin((pr * Math.PI) / 2))
|
||||
player.skin.leftLeg.position.z = player.skin.rightLeg.position.z
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import { default as netheriteLayer1 } from 'mc-assets/dist/other-textures/latest
|
|||
import { default as netheriteLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/netherite_layer_2.png'
|
||||
import { default as turtleLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/turtle_layer_1.png'
|
||||
|
||||
export { default as elytraTexture } from 'mc-assets/dist/other-textures/latest/entity/elytra.png'
|
||||
export { default as armorModel } from './armorModels.json'
|
||||
|
||||
export const armorTextures = {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,15 @@
|
|||
import * as THREE from 'three'
|
||||
import { Vec3 } from 'vec3'
|
||||
import { GraphicsBackendLoader, GraphicsBackend, GraphicsInitOptions, DisplayWorldOptions } from '../../../src/appViewer'
|
||||
import { proxy } from 'valtio'
|
||||
import { GraphicsBackendLoader, GraphicsBackend, GraphicsInitOptions, DisplayWorldOptions, RendererReactiveState } from '../../../src/appViewer'
|
||||
import { ProgressReporter } from '../../../src/core/progressReporter'
|
||||
import { showNotification } from '../../../src/react/NotificationProvider'
|
||||
import { displayEntitiesDebugList } from '../../playground/allEntitiesDebug'
|
||||
import supportedVersions from '../../../src/supportedVersions.mjs'
|
||||
import { ResourcesManager } from '../../../src/resourcesManager'
|
||||
import { WorldRendererThree } from './worldrendererThree'
|
||||
import { DocumentRenderer } from './documentRenderer'
|
||||
import { PanoramaRenderer } from './panorama'
|
||||
import { initVR } from './world/vr'
|
||||
|
||||
// https://discourse.threejs.org/t/updates-to-color-management-in-three-js-r152/50791
|
||||
THREE.ColorManagement.enabled = false
|
||||
globalThis.THREE = THREE
|
||||
window.THREE = THREE
|
||||
|
||||
const getBackendMethods = (worldRenderer: WorldRendererThree) => {
|
||||
return {
|
||||
|
|
@ -23,9 +19,11 @@ const getBackendMethods = (worldRenderer: WorldRendererThree) => {
|
|||
playEntityAnimation: worldRenderer.entities.playAnimation.bind(worldRenderer.entities),
|
||||
damageEntity: worldRenderer.entities.handleDamageEvent.bind(worldRenderer.entities),
|
||||
updatePlayerSkin: worldRenderer.entities.updatePlayerSkin.bind(worldRenderer.entities),
|
||||
setHighlightCursorBlock: worldRenderer.cursorBlock.setHighlightCursorBlock.bind(worldRenderer.cursorBlock),
|
||||
updateBreakAnimation: worldRenderer.cursorBlock.updateBreakAnimation.bind(worldRenderer.cursorBlock),
|
||||
changeHandSwingingState: worldRenderer.changeHandSwingingState.bind(worldRenderer),
|
||||
getHighestBlocks: worldRenderer.getHighestBlocks.bind(worldRenderer),
|
||||
reloadWorld: worldRenderer.reloadWorld.bind(worldRenderer),
|
||||
rerenderAllChunks: worldRenderer.rerenderAllChunks.bind(worldRenderer),
|
||||
|
||||
addMedia: worldRenderer.media.addMedia.bind(worldRenderer.media),
|
||||
destroyMedia: worldRenderer.media.destroyMedia.bind(worldRenderer.media),
|
||||
|
|
@ -34,22 +32,8 @@ const getBackendMethods = (worldRenderer: WorldRendererThree) => {
|
|||
setVideoVolume: worldRenderer.media.setVideoVolume.bind(worldRenderer.media),
|
||||
setVideoSpeed: worldRenderer.media.setVideoSpeed.bind(worldRenderer.media),
|
||||
|
||||
addSectionAnimation (id: string, animation: typeof worldRenderer.sectionsOffsetsAnimations[string]) {
|
||||
worldRenderer.sectionsOffsetsAnimations[id] = animation
|
||||
},
|
||||
removeSectionAnimation (id: string) {
|
||||
delete worldRenderer.sectionsOffsetsAnimations[id]
|
||||
},
|
||||
|
||||
shakeFromDamage: worldRenderer.cameraShake.shakeFromDamage.bind(worldRenderer.cameraShake),
|
||||
onPageInteraction: worldRenderer.media.onPageInteraction.bind(worldRenderer.media),
|
||||
downloadMesherLog: worldRenderer.downloadMesherLog.bind(worldRenderer),
|
||||
|
||||
addWaypoint: worldRenderer.waypoints.addWaypoint.bind(worldRenderer.waypoints),
|
||||
removeWaypoint: worldRenderer.waypoints.removeWaypoint.bind(worldRenderer.waypoints),
|
||||
|
||||
// New method for updating skybox
|
||||
setSkyboxImage: worldRenderer.skyboxRenderer.setSkyboxImage.bind(worldRenderer.skyboxRenderer)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -63,42 +47,31 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO
|
|||
let panoramaRenderer: PanoramaRenderer | null = null
|
||||
let worldRenderer: WorldRendererThree | null = null
|
||||
|
||||
const startPanorama = async () => {
|
||||
if (!documentRenderer) throw new Error('Document renderer not initialized')
|
||||
const startPanorama = () => {
|
||||
if (worldRenderer) return
|
||||
const qs = new URLSearchParams(location.search)
|
||||
if (qs.get('debugEntities')) {
|
||||
const fullResourceManager = initOptions.resourcesManager as ResourcesManager
|
||||
fullResourceManager.currentConfig = { version: qs.get('version') || supportedVersions.at(-1)!, noInventoryGui: true }
|
||||
await fullResourceManager.updateAssetsData({ })
|
||||
|
||||
displayEntitiesDebugList(fullResourceManager.currentConfig.version)
|
||||
return
|
||||
}
|
||||
|
||||
if (!panoramaRenderer) {
|
||||
panoramaRenderer = new PanoramaRenderer(documentRenderer, initOptions, !!process.env.SINGLE_FILE_BUILD_MODE)
|
||||
globalThis.panoramaRenderer = panoramaRenderer
|
||||
callModsMethod('panoramaCreated', panoramaRenderer)
|
||||
await panoramaRenderer.start()
|
||||
callModsMethod('panoramaReady', panoramaRenderer)
|
||||
void panoramaRenderer.start()
|
||||
window.panoramaRenderer = panoramaRenderer
|
||||
}
|
||||
}
|
||||
|
||||
const startWorld = async (displayOptions: DisplayWorldOptions) => {
|
||||
let version = ''
|
||||
const prepareResources = async (ver: string, progressReporter: ProgressReporter): Promise<void> => {
|
||||
version = ver
|
||||
await initOptions.resourcesManager.updateAssetsData({ })
|
||||
}
|
||||
|
||||
const startWorld = (displayOptions: DisplayWorldOptions) => {
|
||||
if (panoramaRenderer) {
|
||||
panoramaRenderer.dispose()
|
||||
panoramaRenderer = null
|
||||
}
|
||||
worldRenderer = new WorldRendererThree(documentRenderer.renderer, initOptions, displayOptions)
|
||||
void initVR(worldRenderer, documentRenderer)
|
||||
await worldRenderer.worldReadyPromise
|
||||
documentRenderer.render = (sizeChanged: boolean) => {
|
||||
worldRenderer?.render(sizeChanged)
|
||||
}
|
||||
documentRenderer.inWorldRenderingConfig = displayOptions.inWorldRenderingConfig
|
||||
window.world = worldRenderer
|
||||
callModsMethod('worldReady', worldRenderer)
|
||||
}
|
||||
|
||||
const disconnect = () => {
|
||||
|
|
@ -127,9 +100,6 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO
|
|||
if (worldRenderer) worldRenderer.renderingActive = rendering
|
||||
},
|
||||
getDebugOverlay: () => ({
|
||||
get entitiesString () {
|
||||
return worldRenderer?.entities.getDebugString()
|
||||
},
|
||||
}),
|
||||
updateCamera (pos: Vec3 | null, yaw: number, pitch: number) {
|
||||
worldRenderer?.setFirstPersonCamera(pos, yaw, pitch)
|
||||
|
|
@ -143,24 +113,7 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO
|
|||
}
|
||||
}
|
||||
|
||||
globalThis.threeJsBackend = backend
|
||||
globalThis.resourcesManager = initOptions.resourcesManager
|
||||
callModsMethod('default', backend)
|
||||
|
||||
return backend
|
||||
}
|
||||
|
||||
const callModsMethod = (method: string, ...args: any[]) => {
|
||||
for (const mod of Object.values((window.loadedMods ?? {}) as Record<string, any>)) {
|
||||
try {
|
||||
mod.threeJsBackendModule?.[method]?.(...args)
|
||||
} catch (err) {
|
||||
const errorMessage = `[mod three.js] Error calling ${method} on ${mod.name}: ${err}`
|
||||
showNotification(errorMessage, 'error')
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createGraphicsBackend.id = 'threejs'
|
||||
export default createGraphicsBackend
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
import * as THREE from 'three'
|
||||
import * as tweenJs from '@tweenjs/tween.js'
|
||||
import PrismarineItem from 'prismarine-item'
|
||||
import worldBlockProvider, { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider'
|
||||
import { BlockModel } from 'mc-assets'
|
||||
import { getThreeBlockModelGroup, renderBlockThree, setBlockPosition } from '../lib/mesher/standaloneRenderer'
|
||||
import { MovementState, PlayerStateRenderer } from '../lib/basePlayerState'
|
||||
import { getMyHand } from '../lib/hand'
|
||||
import { IPlayerState, MovementState } from '../lib/basePlayerState'
|
||||
import { DebugGui } from '../lib/DebugGui'
|
||||
import { SmoothSwitcher } from '../lib/smoothSwitcher'
|
||||
import { watchProperty } from '../lib/utils/proxy'
|
||||
import { WorldRendererConfig } from '../lib/worldrendererCommon'
|
||||
import { getMyHand } from './hand'
|
||||
import { WorldRendererThree } from './worldrendererThree'
|
||||
import { disposeObject } from './threeJsUtils'
|
||||
|
||||
|
|
@ -116,56 +115,41 @@ export default class HoldingBlock {
|
|||
offHandModeLegacy = false
|
||||
|
||||
swingAnimator: HandSwingAnimator | undefined
|
||||
playerState: IPlayerState
|
||||
config: WorldRendererConfig
|
||||
|
||||
constructor (public worldRenderer: WorldRendererThree, public offHand = false) {
|
||||
this.initCameraGroup()
|
||||
this.worldRenderer.onReactivePlayerStateUpdated('heldItemMain', () => {
|
||||
if (!this.offHand) {
|
||||
this.updateItem()
|
||||
}
|
||||
}, false)
|
||||
this.worldRenderer.onReactivePlayerStateUpdated('heldItemOff', () => {
|
||||
if (this.offHand) {
|
||||
this.updateItem()
|
||||
}
|
||||
}, false)
|
||||
this.playerState = worldRenderer.displayOptions.playerState
|
||||
this.playerState.events.on('heldItemChanged', (_, isOffHand) => {
|
||||
if (this.offHand !== isOffHand) return
|
||||
this.updateItem()
|
||||
})
|
||||
this.config = worldRenderer.displayOptions.inWorldRenderingConfig
|
||||
|
||||
this.offHandDisplay = this.offHand
|
||||
// this.offHandDisplay = true
|
||||
if (!this.offHand) {
|
||||
// load default hand
|
||||
void getMyHand().then((hand) => {
|
||||
this.playerHand = hand
|
||||
// trigger update
|
||||
this.updateItem()
|
||||
}).then(() => {
|
||||
// now watch over the player skin
|
||||
watchProperty(
|
||||
async () => {
|
||||
return getMyHand(this.worldRenderer.playerStateReactive.playerSkin, this.worldRenderer.playerStateReactive.onlineMode ? this.worldRenderer.playerStateReactive.username : undefined)
|
||||
},
|
||||
this.worldRenderer.playerStateReactive,
|
||||
'playerSkin',
|
||||
(newHand) => {
|
||||
if (newHand) {
|
||||
this.playerHand = newHand
|
||||
// trigger update
|
||||
this.updateItem()
|
||||
}
|
||||
},
|
||||
(oldHand) => {
|
||||
disposeObject(oldHand!, true)
|
||||
}
|
||||
)
|
||||
})
|
||||
// watch over my hand
|
||||
watchProperty(
|
||||
async () => {
|
||||
return getMyHand(this.playerState.reactive.playerSkin, this.playerState.onlineMode ? this.playerState.username : undefined)
|
||||
},
|
||||
this.playerState.reactive,
|
||||
'playerSkin',
|
||||
(newHand) => {
|
||||
this.playerHand = newHand
|
||||
},
|
||||
(oldHand) => {
|
||||
disposeObject(oldHand, true)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
updateItem () {
|
||||
if (!this.ready) return
|
||||
const item = this.offHand ? this.worldRenderer.playerStateReactive.heldItemOff : this.worldRenderer.playerStateReactive.heldItemMain
|
||||
if (!this.ready || !this.playerState.getHeldItem) return
|
||||
const item = this.playerState.getHeldItem(this.offHand)
|
||||
if (item) {
|
||||
void this.setNewItem(item)
|
||||
} else if (this.offHand) {
|
||||
|
|
@ -302,7 +286,6 @@ export default class HoldingBlock {
|
|||
}
|
||||
|
||||
isDifferentItem (block: HandItemBlock | undefined) {
|
||||
const Item = PrismarineItem(this.worldRenderer.version)
|
||||
if (!this.lastHeldItem) {
|
||||
return true
|
||||
}
|
||||
|
|
@ -310,7 +293,7 @@ export default class HoldingBlock {
|
|||
return true
|
||||
}
|
||||
// eslint-disable-next-line sonarjs/prefer-single-boolean-return
|
||||
if (!Item.equal(this.lastHeldItem.fullItem, block?.fullItem ?? {}) || JSON.stringify(this.lastHeldItem.fullItem.components) !== JSON.stringify(block?.fullItem?.components)) {
|
||||
if (JSON.stringify(this.lastHeldItem.fullItem) !== JSON.stringify(block?.fullItem ?? '{}')) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -355,9 +338,9 @@ export default class HoldingBlock {
|
|||
itemId: handItem.id,
|
||||
}, {
|
||||
'minecraft:display_context': 'firstperson',
|
||||
'minecraft:use_duration': this.worldRenderer.playerStateReactive.itemUsageTicks,
|
||||
'minecraft:using_item': !!this.worldRenderer.playerStateReactive.itemUsageTicks,
|
||||
}, false, this.lastItemModelName)
|
||||
'minecraft:use_duration': this.playerState.getItemUsageTicks?.(),
|
||||
'minecraft:using_item': !!this.playerState.getItemUsageTicks?.(),
|
||||
}, this.lastItemModelName)
|
||||
if (result) {
|
||||
const { mesh: itemMesh, isBlock, modelName } = result
|
||||
if (isBlock) {
|
||||
|
|
@ -473,7 +456,7 @@ export default class HoldingBlock {
|
|||
this.swingAnimator = new HandSwingAnimator(this.holdingBlockInnerGroup)
|
||||
this.swingAnimator.type = result.type
|
||||
if (this.config.viewBobbing) {
|
||||
this.idleAnimator = new HandIdleAnimator(this.holdingBlockInnerGroup, this.worldRenderer.playerStateReactive)
|
||||
this.idleAnimator = new HandIdleAnimator(this.holdingBlockInnerGroup, this.playerState)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -554,7 +537,7 @@ class HandIdleAnimator {
|
|||
|
||||
private readonly debugGui: DebugGui
|
||||
|
||||
constructor (public handMesh: THREE.Object3D, public playerState: PlayerStateRenderer) {
|
||||
constructor (public handMesh: THREE.Object3D, public playerState: IPlayerState) {
|
||||
this.handMesh = handMesh
|
||||
this.globalTime = 0
|
||||
this.currentState = 'NOT_MOVING'
|
||||
|
|
@ -708,7 +691,7 @@ class HandIdleAnimator {
|
|||
|
||||
// Check for state changes from player state
|
||||
if (this.playerState) {
|
||||
const newState = this.playerState.movementState
|
||||
const newState = this.playerState.getMovementState()
|
||||
if (newState !== this.targetState) {
|
||||
this.setState(newState)
|
||||
}
|
||||
|
|
@ -928,6 +911,6 @@ export const getBlockMeshFromModel = (material: THREE.Material, model: BlockMode
|
|||
const worldRenderModel = blockProvider.transformModel(model, {
|
||||
name,
|
||||
properties: {}
|
||||
}) as any
|
||||
})
|
||||
return getThreeBlockModelGroup(material, [[worldRenderModel]], undefined, 'plains', loadedData)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,427 +0,0 @@
|
|||
import * as THREE from 'three'
|
||||
|
||||
export interface Create3DItemMeshOptions {
|
||||
depth: number
|
||||
pixelSize?: number
|
||||
}
|
||||
|
||||
export interface Create3DItemMeshResult {
|
||||
geometry: THREE.BufferGeometry
|
||||
totalVertices: number
|
||||
totalTriangles: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a 3D item geometry with front/back faces and connecting edges
|
||||
* from a canvas containing the item texture
|
||||
*/
|
||||
export function create3DItemMesh (
|
||||
canvas: HTMLCanvasElement,
|
||||
options: Create3DItemMeshOptions
|
||||
): Create3DItemMeshResult {
|
||||
const { depth, pixelSize } = options
|
||||
|
||||
// Validate canvas dimensions
|
||||
if (canvas.width <= 0 || canvas.height <= 0) {
|
||||
throw new Error(`Invalid canvas dimensions: ${canvas.width}x${canvas.height}`)
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d')!
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
const { data } = imageData
|
||||
|
||||
const w = canvas.width
|
||||
const h = canvas.height
|
||||
const halfDepth = depth / 2
|
||||
const actualPixelSize = pixelSize ?? (1 / Math.max(w, h))
|
||||
|
||||
// Find opaque pixels
|
||||
const isOpaque = (x: number, y: number) => {
|
||||
if (x < 0 || y < 0 || x >= w || y >= h) return false
|
||||
const i = (y * w + x) * 4
|
||||
return data[i + 3] > 128 // alpha > 128
|
||||
}
|
||||
|
||||
const vertices: number[] = []
|
||||
const indices: number[] = []
|
||||
const uvs: number[] = []
|
||||
const normals: number[] = []
|
||||
|
||||
let vertexIndex = 0
|
||||
|
||||
// Helper to add a vertex
|
||||
const addVertex = (x: number, y: number, z: number, u: number, v: number, nx: number, ny: number, nz: number) => {
|
||||
vertices.push(x, y, z)
|
||||
uvs.push(u, v)
|
||||
normals.push(nx, ny, nz)
|
||||
return vertexIndex++
|
||||
}
|
||||
|
||||
// Helper to add a quad (two triangles)
|
||||
const addQuad = (v0: number, v1: number, v2: number, v3: number) => {
|
||||
indices.push(v0, v1, v2, v0, v2, v3)
|
||||
}
|
||||
|
||||
// Convert pixel coordinates to world coordinates
|
||||
const pixelToWorld = (px: number, py: number) => {
|
||||
const x = (px / w - 0.5) * actualPixelSize * w
|
||||
const y = -(py / h - 0.5) * actualPixelSize * h
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
// Create a grid of vertices for front and back faces
|
||||
const frontVertices: Array<Array<number | null>> = Array.from({ length: h + 1 }, () => Array.from({ length: w + 1 }, () => null))
|
||||
const backVertices: Array<Array<number | null>> = Array.from({ length: h + 1 }, () => Array.from({ length: w + 1 }, () => null))
|
||||
|
||||
// Create vertices at pixel corners
|
||||
for (let py = 0; py <= h; py++) {
|
||||
for (let px = 0; px <= w; px++) {
|
||||
const { x, y } = pixelToWorld(px - 0.5, py - 0.5)
|
||||
|
||||
// UV coordinates should map to the texture space of the extracted tile
|
||||
const u = px / w
|
||||
const v = py / h
|
||||
|
||||
// Check if this vertex is needed for any face or edge
|
||||
let needVertex = false
|
||||
|
||||
// Check all 4 adjacent pixels to see if any are opaque
|
||||
const adjacentPixels = [
|
||||
[px - 1, py - 1], // top-left pixel
|
||||
[px, py - 1], // top-right pixel
|
||||
[px - 1, py], // bottom-left pixel
|
||||
[px, py] // bottom-right pixel
|
||||
]
|
||||
|
||||
for (const [adjX, adjY] of adjacentPixels) {
|
||||
if (isOpaque(adjX, adjY)) {
|
||||
needVertex = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (needVertex) {
|
||||
frontVertices[py][px] = addVertex(x, y, halfDepth, u, v, 0, 0, 1)
|
||||
backVertices[py][px] = addVertex(x, y, -halfDepth, u, v, 0, 0, -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create front and back faces
|
||||
for (let py = 0; py < h; py++) {
|
||||
for (let px = 0; px < w; px++) {
|
||||
if (!isOpaque(px, py)) continue
|
||||
|
||||
const v00 = frontVertices[py][px]
|
||||
const v10 = frontVertices[py][px + 1]
|
||||
const v11 = frontVertices[py + 1][px + 1]
|
||||
const v01 = frontVertices[py + 1][px]
|
||||
|
||||
const b00 = backVertices[py][px]
|
||||
const b10 = backVertices[py][px + 1]
|
||||
const b11 = backVertices[py + 1][px + 1]
|
||||
const b01 = backVertices[py + 1][px]
|
||||
|
||||
if (v00 !== null && v10 !== null && v11 !== null && v01 !== null) {
|
||||
// Front face
|
||||
addQuad(v00, v10, v11, v01)
|
||||
}
|
||||
|
||||
if (b00 !== null && b10 !== null && b11 !== null && b01 !== null) {
|
||||
// Back face (reversed winding)
|
||||
addQuad(b10, b00, b01, b11)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create edge faces for each side of the pixel with proper UVs
|
||||
for (let py = 0; py < h; py++) {
|
||||
for (let px = 0; px < w; px++) {
|
||||
if (!isOpaque(px, py)) continue
|
||||
|
||||
const pixelU = (px + 0.5) / w // Center of current pixel
|
||||
const pixelV = (py + 0.5) / h
|
||||
|
||||
// Left edge (x = px)
|
||||
if (!isOpaque(px - 1, py)) {
|
||||
const f0 = frontVertices[py][px]
|
||||
const f1 = frontVertices[py + 1][px]
|
||||
const b0 = backVertices[py][px]
|
||||
const b1 = backVertices[py + 1][px]
|
||||
|
||||
if (f0 !== null && f1 !== null && b0 !== null && b1 !== null) {
|
||||
// Create new vertices for edge with current pixel's UV
|
||||
const ef0 = addVertex(vertices[f0 * 3], vertices[f0 * 3 + 1], vertices[f0 * 3 + 2], pixelU, pixelV, -1, 0, 0)
|
||||
const ef1 = addVertex(vertices[f1 * 3], vertices[f1 * 3 + 1], vertices[f1 * 3 + 2], pixelU, pixelV, -1, 0, 0)
|
||||
const eb1 = addVertex(vertices[b1 * 3], vertices[b1 * 3 + 1], vertices[b1 * 3 + 2], pixelU, pixelV, -1, 0, 0)
|
||||
const eb0 = addVertex(vertices[b0 * 3], vertices[b0 * 3 + 1], vertices[b0 * 3 + 2], pixelU, pixelV, -1, 0, 0)
|
||||
addQuad(ef0, ef1, eb1, eb0)
|
||||
}
|
||||
}
|
||||
|
||||
// Right edge (x = px + 1)
|
||||
if (!isOpaque(px + 1, py)) {
|
||||
const f0 = frontVertices[py + 1][px + 1]
|
||||
const f1 = frontVertices[py][px + 1]
|
||||
const b0 = backVertices[py + 1][px + 1]
|
||||
const b1 = backVertices[py][px + 1]
|
||||
|
||||
if (f0 !== null && f1 !== null && b0 !== null && b1 !== null) {
|
||||
const ef0 = addVertex(vertices[f0 * 3], vertices[f0 * 3 + 1], vertices[f0 * 3 + 2], pixelU, pixelV, 1, 0, 0)
|
||||
const ef1 = addVertex(vertices[f1 * 3], vertices[f1 * 3 + 1], vertices[f1 * 3 + 2], pixelU, pixelV, 1, 0, 0)
|
||||
const eb1 = addVertex(vertices[b1 * 3], vertices[b1 * 3 + 1], vertices[b1 * 3 + 2], pixelU, pixelV, 1, 0, 0)
|
||||
const eb0 = addVertex(vertices[b0 * 3], vertices[b0 * 3 + 1], vertices[b0 * 3 + 2], pixelU, pixelV, 1, 0, 0)
|
||||
addQuad(ef0, ef1, eb1, eb0)
|
||||
}
|
||||
}
|
||||
|
||||
// Top edge (y = py)
|
||||
if (!isOpaque(px, py - 1)) {
|
||||
const f0 = frontVertices[py][px]
|
||||
const f1 = frontVertices[py][px + 1]
|
||||
const b0 = backVertices[py][px]
|
||||
const b1 = backVertices[py][px + 1]
|
||||
|
||||
if (f0 !== null && f1 !== null && b0 !== null && b1 !== null) {
|
||||
const ef0 = addVertex(vertices[f0 * 3], vertices[f0 * 3 + 1], vertices[f0 * 3 + 2], pixelU, pixelV, 0, -1, 0)
|
||||
const ef1 = addVertex(vertices[f1 * 3], vertices[f1 * 3 + 1], vertices[f1 * 3 + 2], pixelU, pixelV, 0, -1, 0)
|
||||
const eb1 = addVertex(vertices[b1 * 3], vertices[b1 * 3 + 1], vertices[b1 * 3 + 2], pixelU, pixelV, 0, -1, 0)
|
||||
const eb0 = addVertex(vertices[b0 * 3], vertices[b0 * 3 + 1], vertices[b0 * 3 + 2], pixelU, pixelV, 0, -1, 0)
|
||||
addQuad(ef0, ef1, eb1, eb0)
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom edge (y = py + 1)
|
||||
if (!isOpaque(px, py + 1)) {
|
||||
const f0 = frontVertices[py + 1][px + 1]
|
||||
const f1 = frontVertices[py + 1][px]
|
||||
const b0 = backVertices[py + 1][px + 1]
|
||||
const b1 = backVertices[py + 1][px]
|
||||
|
||||
if (f0 !== null && f1 !== null && b0 !== null && b1 !== null) {
|
||||
const ef0 = addVertex(vertices[f0 * 3], vertices[f0 * 3 + 1], vertices[f0 * 3 + 2], pixelU, pixelV, 0, 1, 0)
|
||||
const ef1 = addVertex(vertices[f1 * 3], vertices[f1 * 3 + 1], vertices[f1 * 3 + 2], pixelU, pixelV, 0, 1, 0)
|
||||
const eb1 = addVertex(vertices[b1 * 3], vertices[b1 * 3 + 1], vertices[b1 * 3 + 2], pixelU, pixelV, 0, 1, 0)
|
||||
const eb0 = addVertex(vertices[b0 * 3], vertices[b0 * 3 + 1], vertices[b0 * 3 + 2], pixelU, pixelV, 0, 1, 0)
|
||||
addQuad(ef0, ef1, eb1, eb0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3))
|
||||
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
|
||||
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
|
||||
geometry.setIndex(indices)
|
||||
|
||||
// Compute normals properly
|
||||
geometry.computeVertexNormals()
|
||||
|
||||
return {
|
||||
geometry,
|
||||
totalVertices: vertexIndex,
|
||||
totalTriangles: indices.length / 3
|
||||
}
|
||||
}
|
||||
|
||||
export interface ItemTextureInfo {
|
||||
u: number
|
||||
v: number
|
||||
sizeX: number
|
||||
sizeY: number
|
||||
}
|
||||
|
||||
export interface ItemMeshResult {
|
||||
mesh: THREE.Object3D
|
||||
itemsTexture?: THREE.Texture
|
||||
itemsTextureFlipped?: THREE.Texture
|
||||
cleanup?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts item texture region to a canvas
|
||||
*/
|
||||
export function extractItemTextureToCanvas (
|
||||
sourceTexture: THREE.Texture,
|
||||
textureInfo: ItemTextureInfo
|
||||
): HTMLCanvasElement {
|
||||
const { u, v, sizeX, sizeY } = textureInfo
|
||||
|
||||
// Calculate canvas size - fix the calculation
|
||||
const canvasWidth = Math.max(1, Math.floor(sizeX * sourceTexture.image.width))
|
||||
const canvasHeight = Math.max(1, Math.floor(sizeY * sourceTexture.image.height))
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = canvasWidth
|
||||
canvas.height = canvasHeight
|
||||
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.imageSmoothingEnabled = false
|
||||
|
||||
// Draw the item texture region to canvas
|
||||
ctx.drawImage(
|
||||
sourceTexture.image,
|
||||
u * sourceTexture.image.width,
|
||||
v * sourceTexture.image.height,
|
||||
sizeX * sourceTexture.image.width,
|
||||
sizeY * sourceTexture.image.height,
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height
|
||||
)
|
||||
|
||||
return canvas
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates either a 2D or 3D item mesh based on parameters
|
||||
*/
|
||||
export function createItemMesh (
|
||||
sourceTexture: THREE.Texture,
|
||||
textureInfo: ItemTextureInfo,
|
||||
options: {
|
||||
faceCamera?: boolean
|
||||
use3D?: boolean
|
||||
depth?: number
|
||||
} = {}
|
||||
): ItemMeshResult {
|
||||
const { faceCamera = false, use3D = true, depth = 0.04 } = options
|
||||
const { u, v, sizeX, sizeY } = textureInfo
|
||||
|
||||
if (faceCamera) {
|
||||
// Create sprite for camera-facing items
|
||||
const itemsTexture = sourceTexture.clone()
|
||||
itemsTexture.flipY = true
|
||||
itemsTexture.offset.set(u, 1 - v - sizeY)
|
||||
itemsTexture.repeat.set(sizeX, sizeY)
|
||||
itemsTexture.needsUpdate = true
|
||||
itemsTexture.magFilter = THREE.NearestFilter
|
||||
itemsTexture.minFilter = THREE.NearestFilter
|
||||
|
||||
const spriteMat = new THREE.SpriteMaterial({
|
||||
map: itemsTexture,
|
||||
transparent: true,
|
||||
alphaTest: 0.1,
|
||||
})
|
||||
const mesh = new THREE.Sprite(spriteMat)
|
||||
|
||||
return {
|
||||
mesh,
|
||||
itemsTexture,
|
||||
cleanup () {
|
||||
itemsTexture.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (use3D) {
|
||||
// Try to create 3D mesh
|
||||
try {
|
||||
const canvas = extractItemTextureToCanvas(sourceTexture, textureInfo)
|
||||
const { geometry } = create3DItemMesh(canvas, { depth })
|
||||
|
||||
// Create texture from canvas for the 3D mesh
|
||||
const itemsTexture = new THREE.CanvasTexture(canvas)
|
||||
itemsTexture.magFilter = THREE.NearestFilter
|
||||
itemsTexture.minFilter = THREE.NearestFilter
|
||||
itemsTexture.wrapS = itemsTexture.wrapT = THREE.ClampToEdgeWrapping
|
||||
itemsTexture.flipY = false
|
||||
itemsTexture.needsUpdate = true
|
||||
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
map: itemsTexture,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
alphaTest: 0.1,
|
||||
})
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material)
|
||||
|
||||
return {
|
||||
mesh,
|
||||
itemsTexture,
|
||||
cleanup () {
|
||||
itemsTexture.dispose()
|
||||
geometry.dispose()
|
||||
if (material.map) material.map.dispose()
|
||||
material.dispose()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to create 3D item mesh, falling back to 2D:', error)
|
||||
// Fall through to 2D rendering
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to 2D flat rendering
|
||||
const itemsTexture = sourceTexture.clone()
|
||||
itemsTexture.flipY = true
|
||||
itemsTexture.offset.set(u, 1 - v - sizeY)
|
||||
itemsTexture.repeat.set(sizeX, sizeY)
|
||||
itemsTexture.needsUpdate = true
|
||||
itemsTexture.magFilter = THREE.NearestFilter
|
||||
itemsTexture.minFilter = THREE.NearestFilter
|
||||
|
||||
const itemsTextureFlipped = itemsTexture.clone()
|
||||
itemsTextureFlipped.repeat.x *= -1
|
||||
itemsTextureFlipped.needsUpdate = true
|
||||
itemsTextureFlipped.offset.set(u + sizeX, 1 - v - sizeY)
|
||||
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
map: itemsTexture,
|
||||
transparent: true,
|
||||
alphaTest: 0.1,
|
||||
})
|
||||
const materialFlipped = new THREE.MeshStandardMaterial({
|
||||
map: itemsTextureFlipped,
|
||||
transparent: true,
|
||||
alphaTest: 0.1,
|
||||
})
|
||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 0), [
|
||||
new THREE.MeshBasicMaterial({ color: 0x00_00_00 }), new THREE.MeshBasicMaterial({ color: 0x00_00_00 }),
|
||||
new THREE.MeshBasicMaterial({ color: 0x00_00_00 }), new THREE.MeshBasicMaterial({ color: 0x00_00_00 }),
|
||||
material, materialFlipped,
|
||||
])
|
||||
|
||||
return {
|
||||
mesh,
|
||||
itemsTexture,
|
||||
itemsTextureFlipped,
|
||||
cleanup () {
|
||||
itemsTexture.dispose()
|
||||
itemsTextureFlipped.dispose()
|
||||
material.dispose()
|
||||
materialFlipped.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a complete 3D item mesh from a canvas texture
|
||||
*/
|
||||
export function createItemMeshFromCanvas (
|
||||
canvas: HTMLCanvasElement,
|
||||
options: Create3DItemMeshOptions
|
||||
): THREE.Mesh {
|
||||
const { geometry } = create3DItemMesh(canvas, options)
|
||||
|
||||
// Base color texture for the item
|
||||
const colorTexture = new THREE.CanvasTexture(canvas)
|
||||
colorTexture.magFilter = THREE.NearestFilter
|
||||
colorTexture.minFilter = THREE.NearestFilter
|
||||
colorTexture.wrapS = colorTexture.wrapT = THREE.ClampToEdgeWrapping
|
||||
colorTexture.flipY = false // Important for canvas textures
|
||||
colorTexture.needsUpdate = true
|
||||
|
||||
// Material - no transparency, no alpha test needed for edges
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: colorTexture,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
alphaTest: 0.1
|
||||
})
|
||||
|
||||
return new THREE.Mesh(geometry, material)
|
||||
}
|
||||
|
|
@ -6,14 +6,11 @@ import * as tweenJs from '@tweenjs/tween.js'
|
|||
import type { GraphicsInitOptions } from '../../../src/appViewer'
|
||||
import { WorldDataEmitter } from '../lib/worldDataEmitter'
|
||||
import { defaultWorldRendererConfig, WorldRendererCommon } from '../lib/worldrendererCommon'
|
||||
import { BasePlayerState } from '../lib/basePlayerState'
|
||||
import { getDefaultRendererState } from '../baseGraphicsBackend'
|
||||
import { ResourcesManager } from '../../../src/resourcesManager'
|
||||
import { getInitialPlayerStateRenderer } from '../lib/basePlayerState'
|
||||
import { loadThreeJsTextureFromUrl, loadThreeJsTextureFromUrlSync } from './threeJsUtils'
|
||||
import { WorldRendererThree } from './worldrendererThree'
|
||||
import { EntityMesh } from './entity/EntityMesh'
|
||||
import { DocumentRenderer } from './documentRenderer'
|
||||
import { PANORAMA_VERSION } from './panoramaShared'
|
||||
|
||||
const panoramaFiles = [
|
||||
'panorama_3.png', // right (+x)
|
||||
|
|
@ -34,12 +31,10 @@ export class PanoramaRenderer {
|
|||
private readonly abortController = new AbortController()
|
||||
private worldRenderer: WorldRendererCommon | WorldRendererThree | undefined
|
||||
public WorldRendererClass = WorldRendererThree
|
||||
public startTimes = new Map<THREE.MeshBasicMaterial, number>()
|
||||
|
||||
constructor (private readonly documentRenderer: DocumentRenderer, private readonly options: GraphicsInitOptions, private readonly doWorldBlocksPanorama = false) {
|
||||
this.scene = new THREE.Scene()
|
||||
// #324568
|
||||
this.scene.background = new THREE.Color(0x32_45_68)
|
||||
this.scene.background = new THREE.Color(this.options.config.sceneBackground)
|
||||
|
||||
// Add ambient light
|
||||
this.ambientLight = new THREE.AmbientLight(0xcc_cc_cc)
|
||||
|
|
@ -51,7 +46,7 @@ export class PanoramaRenderer {
|
|||
this.directionalLight.castShadow = true
|
||||
this.scene.add(this.directionalLight)
|
||||
|
||||
this.camera = new THREE.PerspectiveCamera(85, this.documentRenderer.canvas.width / this.documentRenderer.canvas.height, 0.05, 1000)
|
||||
this.camera = new THREE.PerspectiveCamera(85, window.innerWidth / window.innerHeight, 0.05, 1000)
|
||||
this.camera.position.set(0, 0, 0)
|
||||
this.camera.rotation.set(0, 0, 0)
|
||||
}
|
||||
|
|
@ -66,57 +61,38 @@ export class PanoramaRenderer {
|
|||
|
||||
this.documentRenderer.render = (sizeChanged = false) => {
|
||||
if (sizeChanged) {
|
||||
this.camera.aspect = this.documentRenderer.canvas.width / this.documentRenderer.canvas.height
|
||||
this.camera.aspect = window.innerWidth / window.innerHeight
|
||||
this.camera.updateProjectionMatrix()
|
||||
}
|
||||
this.documentRenderer.renderer.render(this.scene, this.camera)
|
||||
}
|
||||
}
|
||||
|
||||
async debugImageInFrontOfCamera () {
|
||||
const image = await loadThreeJsTextureFromUrl(join('background', 'panorama_0.png'))
|
||||
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1000, 1000), new THREE.MeshBasicMaterial({ map: image }))
|
||||
mesh.position.set(0, 0, -500)
|
||||
mesh.rotation.set(0, 0, 0)
|
||||
this.scene.add(mesh)
|
||||
}
|
||||
|
||||
addClassicPanorama () {
|
||||
const panorGeo = new THREE.BoxGeometry(1000, 1000, 1000)
|
||||
const loader = new THREE.TextureLoader()
|
||||
const panorMaterials = [] as THREE.MeshBasicMaterial[]
|
||||
const fadeInDuration = 200
|
||||
|
||||
// void this.debugImageInFrontOfCamera()
|
||||
|
||||
for (const file of panoramaFiles) {
|
||||
const load = async () => {
|
||||
const { texture } = loadThreeJsTextureFromUrlSync(join('background', file))
|
||||
const texture = loader.load(join('background', file))
|
||||
|
||||
// Instead of using repeat/offset to flip, we'll use the texture matrix
|
||||
texture.matrixAutoUpdate = false
|
||||
texture.matrix.set(
|
||||
-1, 0, 1, 0, 1, 0, 0, 0, 1
|
||||
)
|
||||
// Instead of using repeat/offset to flip, we'll use the texture matrix
|
||||
texture.matrixAutoUpdate = false
|
||||
texture.matrix.set(
|
||||
-1, 0, 1, 0, 1, 0, 0, 0, 1
|
||||
)
|
||||
|
||||
texture.wrapS = THREE.ClampToEdgeWrapping
|
||||
texture.wrapT = THREE.ClampToEdgeWrapping
|
||||
texture.minFilter = THREE.LinearFilter
|
||||
texture.magFilter = THREE.LinearFilter
|
||||
texture.wrapS = THREE.ClampToEdgeWrapping // Changed from RepeatWrapping
|
||||
texture.wrapT = THREE.ClampToEdgeWrapping // Changed from RepeatWrapping
|
||||
texture.minFilter = THREE.LinearFilter
|
||||
texture.magFilter = THREE.LinearFilter
|
||||
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
opacity: 0 // Start with 0 opacity
|
||||
})
|
||||
|
||||
// Start fade-in when texture is loaded
|
||||
this.startTimes.set(material, Date.now())
|
||||
panorMaterials.push(material)
|
||||
}
|
||||
|
||||
void load()
|
||||
panorMaterials.push(new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
}))
|
||||
}
|
||||
|
||||
const panoramaBox = new THREE.Mesh(panorGeo, panorMaterials)
|
||||
|
|
@ -124,16 +100,6 @@ export class PanoramaRenderer {
|
|||
this.time += 0.01
|
||||
panoramaBox.rotation.y = Math.PI + this.time * 0.01
|
||||
panoramaBox.rotation.z = Math.sin(-this.time * 0.001) * 0.001
|
||||
|
||||
// Time-based fade in animation for each material
|
||||
for (const material of panorMaterials) {
|
||||
const startTime = this.startTimes.get(material)
|
||||
if (startTime) {
|
||||
const elapsed = Date.now() - startTime
|
||||
const progress = Math.min(1, elapsed / fadeInDuration)
|
||||
material.opacity = progress
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const group = new THREE.Object3D()
|
||||
|
|
@ -157,10 +123,9 @@ export class PanoramaRenderer {
|
|||
}
|
||||
|
||||
async worldBlocksPanorama () {
|
||||
const version = PANORAMA_VERSION
|
||||
const fullResourceManager = this.options.resourcesManager as ResourcesManager
|
||||
fullResourceManager.currentConfig = { version, noInventoryGui: true, }
|
||||
await fullResourceManager.updateAssetsData({ })
|
||||
const version = '1.21.4'
|
||||
this.options.resourcesManager.currentConfig = { version }
|
||||
await this.options.resourcesManager.updateAssetsData({ })
|
||||
if (this.abortController.signal.aborted) return
|
||||
console.time('load panorama scene')
|
||||
const world = getSyncWorld(version)
|
||||
|
|
@ -198,9 +163,9 @@ export class PanoramaRenderer {
|
|||
version,
|
||||
worldView,
|
||||
inWorldRenderingConfig: defaultWorldRendererConfig,
|
||||
playerStateReactive: getInitialPlayerStateRenderer().reactive,
|
||||
rendererState: getDefaultRendererState().reactive,
|
||||
nonReactiveState: getDefaultRendererState().nonReactive
|
||||
playerState: new BasePlayerState(),
|
||||
rendererState: getDefaultRendererState(),
|
||||
nonReactiveState: getDefaultRendererState()
|
||||
}
|
||||
)
|
||||
if (this.worldRenderer instanceof WorldRendererThree) {
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export const PANORAMA_VERSION = '1.21.4'
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
import { getRenamedData } from 'flying-squid/dist/blockRenames'
|
||||
import { BlockModel } from 'mc-assets'
|
||||
import { versionToNumber } from 'mc-assets/dist/utils'
|
||||
import type { ResourcesManagerCommon } from '../../../src/resourcesManager'
|
||||
|
||||
export type ResolvedItemModelRender = {
|
||||
modelName: string,
|
||||
originalItemName?: string
|
||||
}
|
||||
|
||||
export const renderSlot = (model: ResolvedItemModelRender, resourcesManager: ResourcesManagerCommon, debugIsQuickbar = false, fullBlockModelSupport = false): {
|
||||
texture: string,
|
||||
blockData: Record<string, { slice, path }> & { resolvedModel: BlockModel } | null,
|
||||
scale: number | null,
|
||||
slice: number[] | null,
|
||||
modelName: string | null,
|
||||
} => {
|
||||
let itemModelName = model.modelName
|
||||
const isItem = loadedData.itemsByName[itemModelName]
|
||||
|
||||
// #region normalize item name
|
||||
if (versionToNumber(bot.version) < versionToNumber('1.13')) itemModelName = getRenamedData(isItem ? 'items' : 'blocks', itemModelName, bot.version, '1.13.1') as string
|
||||
// #endregion
|
||||
|
||||
|
||||
let itemTexture
|
||||
|
||||
if (!fullBlockModelSupport) {
|
||||
const atlas = resourcesManager.currentResources?.guiAtlas?.json
|
||||
// todo atlas holds all rendered blocks, not all possibly rendered item/block models, need to request this on demand instead (this is how vanilla works)
|
||||
const tryGetAtlasTexture = (name?: string) => name && atlas?.textures[name.replace('minecraft:', '').replace('block/', '').replace('blocks/', '').replace('item/', '').replace('items/', '').replace('_inventory', '')]
|
||||
const item = tryGetAtlasTexture(itemModelName) ?? tryGetAtlasTexture(model.originalItemName)
|
||||
if (item) {
|
||||
const x = item.u * atlas.width
|
||||
const y = item.v * atlas.height
|
||||
return {
|
||||
texture: 'gui',
|
||||
slice: [x, y, atlas.tileSize, atlas.tileSize],
|
||||
scale: 0.25,
|
||||
blockData: null,
|
||||
modelName: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const blockToTopTexture = (r) => r.top ?? r
|
||||
|
||||
try {
|
||||
if (!appViewer.resourcesManager.currentResources?.itemsRenderer) throw new Error('Items renderer is not available')
|
||||
itemTexture =
|
||||
appViewer.resourcesManager.currentResources.itemsRenderer.getItemTexture(itemModelName, {}, false, fullBlockModelSupport)
|
||||
?? (model.originalItemName ? appViewer.resourcesManager.currentResources.itemsRenderer.getItemTexture(model.originalItemName, {}, false, fullBlockModelSupport) : undefined)
|
||||
?? appViewer.resourcesManager.currentResources.itemsRenderer.getItemTexture('item/missing_texture')!
|
||||
} catch (err) {
|
||||
// get resourcepack from resource manager
|
||||
reportError?.(`Failed to render item ${itemModelName} (original: ${model.originalItemName}) on ${bot.version} (resourcepack: TODO!): ${err.stack}`)
|
||||
itemTexture = blockToTopTexture(appViewer.resourcesManager.currentResources!.itemsRenderer.getItemTexture('errored')!)
|
||||
}
|
||||
|
||||
itemTexture ??= blockToTopTexture(appViewer.resourcesManager.currentResources!.itemsRenderer.getItemTexture('unknown')!)
|
||||
|
||||
|
||||
if ('type' in itemTexture) {
|
||||
// is item
|
||||
return {
|
||||
texture: itemTexture.type,
|
||||
slice: itemTexture.slice,
|
||||
modelName: itemModelName,
|
||||
blockData: null,
|
||||
scale: null
|
||||
}
|
||||
} else {
|
||||
// is block
|
||||
return {
|
||||
texture: 'blocks',
|
||||
blockData: itemTexture,
|
||||
modelName: itemModelName,
|
||||
slice: null,
|
||||
scale: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,406 +0,0 @@
|
|||
import * as THREE from 'three'
|
||||
import { DebugGui } from '../lib/DebugGui'
|
||||
|
||||
export const DEFAULT_TEMPERATURE = 0.75
|
||||
|
||||
export class SkyboxRenderer {
|
||||
private texture: THREE.Texture | null = null
|
||||
private mesh: THREE.Mesh<THREE.SphereGeometry, THREE.MeshBasicMaterial> | null = null
|
||||
private skyMesh: THREE.Mesh | null = null
|
||||
private voidMesh: THREE.Mesh | null = null
|
||||
|
||||
// World state
|
||||
private worldTime = 0
|
||||
private partialTicks = 0
|
||||
private viewDistance = 4
|
||||
private temperature = DEFAULT_TEMPERATURE
|
||||
private inWater = false
|
||||
private waterBreathing = false
|
||||
private fogBrightness = 0
|
||||
private prevFogBrightness = 0
|
||||
private readonly fogOrangeness = 0 // Debug property to control sky color orangeness
|
||||
private readonly distanceFactor = 2.7
|
||||
|
||||
private readonly brightnessAtPosition = 1
|
||||
debugGui: DebugGui
|
||||
|
||||
constructor (private readonly scene: THREE.Scene, public defaultSkybox: boolean, public initialImage: string | null) {
|
||||
this.debugGui = new DebugGui('skybox_renderer', this, [
|
||||
'temperature',
|
||||
'worldTime',
|
||||
'inWater',
|
||||
'waterBreathing',
|
||||
'fogOrangeness',
|
||||
'brightnessAtPosition',
|
||||
'distanceFactor'
|
||||
], {
|
||||
brightnessAtPosition: { min: 0, max: 1, step: 0.01 },
|
||||
temperature: { min: 0, max: 1, step: 0.01 },
|
||||
worldTime: { min: 0, max: 24_000, step: 1 },
|
||||
fogOrangeness: { min: -1, max: 1, step: 0.01 },
|
||||
distanceFactor: { min: 0, max: 5, step: 0.01 },
|
||||
})
|
||||
|
||||
if (!initialImage) {
|
||||
this.createGradientSky()
|
||||
}
|
||||
// this.debugGui.activate()
|
||||
}
|
||||
|
||||
async init () {
|
||||
if (this.initialImage) {
|
||||
await this.setSkyboxImage(this.initialImage)
|
||||
}
|
||||
}
|
||||
|
||||
async setSkyboxImage (imageUrl: string) {
|
||||
// Dispose old textures if they exist
|
||||
if (this.texture) {
|
||||
this.texture.dispose()
|
||||
}
|
||||
|
||||
// Load the equirectangular texture
|
||||
const textureLoader = new THREE.TextureLoader()
|
||||
this.texture = await new Promise((resolve) => {
|
||||
textureLoader.load(
|
||||
imageUrl,
|
||||
(texture) => {
|
||||
texture.mapping = THREE.EquirectangularReflectionMapping
|
||||
texture.encoding = THREE.sRGBEncoding
|
||||
// Keep pixelated look
|
||||
texture.minFilter = THREE.NearestFilter
|
||||
texture.magFilter = THREE.NearestFilter
|
||||
texture.needsUpdate = true
|
||||
resolve(texture)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// Create or update the skybox
|
||||
if (this.mesh) {
|
||||
// Just update the texture on the existing material
|
||||
this.mesh.material.map = this.texture
|
||||
this.mesh.material.needsUpdate = true
|
||||
} else {
|
||||
// Create a large sphere geometry for the skybox
|
||||
const geometry = new THREE.SphereGeometry(500, 60, 40)
|
||||
// Flip the geometry inside out
|
||||
geometry.scale(-1, 1, 1)
|
||||
|
||||
// Create material using the loaded texture
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: this.texture,
|
||||
side: THREE.FrontSide // Changed to FrontSide since we're flipping the geometry
|
||||
})
|
||||
|
||||
// Create and add the skybox mesh
|
||||
this.mesh = new THREE.Mesh(geometry, material)
|
||||
this.scene.add(this.mesh)
|
||||
}
|
||||
}
|
||||
|
||||
update (cameraPosition: THREE.Vector3, newViewDistance: number) {
|
||||
if (newViewDistance !== this.viewDistance) {
|
||||
this.viewDistance = newViewDistance
|
||||
this.updateSkyColors()
|
||||
}
|
||||
|
||||
if (this.mesh) {
|
||||
// Update skybox position
|
||||
this.mesh.position.copy(cameraPosition)
|
||||
} else if (this.skyMesh) {
|
||||
// Update gradient sky position
|
||||
this.skyMesh.position.copy(cameraPosition)
|
||||
this.voidMesh?.position.copy(cameraPosition)
|
||||
this.updateSkyColors() // Update colors based on time of day
|
||||
}
|
||||
}
|
||||
|
||||
// Update world time
|
||||
updateTime (timeOfDay: number, partialTicks = 0) {
|
||||
if (this.debugGui.visible) return
|
||||
this.worldTime = timeOfDay
|
||||
this.partialTicks = partialTicks
|
||||
this.updateSkyColors()
|
||||
}
|
||||
|
||||
// Update view distance
|
||||
updateViewDistance (viewDistance: number) {
|
||||
this.viewDistance = viewDistance
|
||||
this.updateSkyColors()
|
||||
}
|
||||
|
||||
// Update temperature (for biome support)
|
||||
updateTemperature (temperature: number) {
|
||||
if (this.debugGui.visible) return
|
||||
this.temperature = temperature
|
||||
this.updateSkyColors()
|
||||
}
|
||||
|
||||
// Update water state
|
||||
updateWaterState (inWater: boolean, waterBreathing: boolean) {
|
||||
if (this.debugGui.visible) return
|
||||
this.inWater = inWater
|
||||
this.waterBreathing = waterBreathing
|
||||
this.updateSkyColors()
|
||||
}
|
||||
|
||||
// Update default skybox setting
|
||||
updateDefaultSkybox (defaultSkybox: boolean) {
|
||||
if (this.debugGui.visible) return
|
||||
this.defaultSkybox = defaultSkybox
|
||||
this.updateSkyColors()
|
||||
}
|
||||
|
||||
private createGradientSky () {
|
||||
const size = 64
|
||||
const scale = 256 / size + 2
|
||||
|
||||
{
|
||||
const geometry = new THREE.PlaneGeometry(size * scale * 2, size * scale * 2)
|
||||
geometry.rotateX(-Math.PI / 2)
|
||||
geometry.translate(0, 16, 0)
|
||||
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color: 0xff_ff_ff,
|
||||
side: THREE.DoubleSide,
|
||||
depthTest: false
|
||||
})
|
||||
|
||||
this.skyMesh = new THREE.Mesh(geometry, material)
|
||||
this.scene.add(this.skyMesh)
|
||||
}
|
||||
|
||||
{
|
||||
const geometry = new THREE.PlaneGeometry(size * scale * 2, size * scale * 2)
|
||||
geometry.rotateX(-Math.PI / 2)
|
||||
geometry.translate(0, -16, 0)
|
||||
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color: 0xff_ff_ff,
|
||||
side: THREE.DoubleSide,
|
||||
depthTest: false
|
||||
})
|
||||
|
||||
this.voidMesh = new THREE.Mesh(geometry, material)
|
||||
this.scene.add(this.voidMesh)
|
||||
}
|
||||
|
||||
this.updateSkyColors()
|
||||
}
|
||||
|
||||
private getFogColor (partialTicks = 0): THREE.Vector3 {
|
||||
const angle = this.getCelestialAngle(partialTicks)
|
||||
let rotation = Math.cos(angle * Math.PI * 2) * 2 + 0.5
|
||||
rotation = Math.max(0, Math.min(1, rotation))
|
||||
|
||||
let x = 0.752_941_2
|
||||
let y = 0.847_058_83
|
||||
let z = 1
|
||||
|
||||
x *= (rotation * 0.94 + 0.06)
|
||||
y *= (rotation * 0.94 + 0.06)
|
||||
z *= (rotation * 0.91 + 0.09)
|
||||
|
||||
return new THREE.Vector3(x, y, z)
|
||||
}
|
||||
|
||||
private getSkyColor (x = 0, z = 0, partialTicks = 0): THREE.Vector3 {
|
||||
const angle = this.getCelestialAngle(partialTicks)
|
||||
let brightness = Math.cos(angle * 3.141_593 * 2) * 2 + 0.5
|
||||
|
||||
if (brightness < 0) brightness = 0
|
||||
if (brightness > 1) brightness = 1
|
||||
|
||||
const temperature = this.getTemperature(x, z)
|
||||
const rgb = this.getSkyColorByTemp(temperature)
|
||||
|
||||
const red = ((rgb >> 16) & 0xff) / 255
|
||||
const green = ((rgb >> 8) & 0xff) / 255
|
||||
const blue = (rgb & 0xff) / 255
|
||||
|
||||
return new THREE.Vector3(
|
||||
red * brightness,
|
||||
green * brightness,
|
||||
blue * brightness
|
||||
)
|
||||
}
|
||||
|
||||
private calculateCelestialAngle (time: number, partialTicks: number): number {
|
||||
const modTime = (time % 24_000)
|
||||
let angle = (modTime + partialTicks) / 24_000 - 0.25
|
||||
|
||||
if (angle < 0) {
|
||||
angle++
|
||||
}
|
||||
if (angle > 1) {
|
||||
angle--
|
||||
}
|
||||
|
||||
angle = 1 - ((Math.cos(angle * Math.PI) + 1) / 2)
|
||||
angle += (angle - angle) / 3
|
||||
|
||||
return angle
|
||||
}
|
||||
|
||||
private getCelestialAngle (partialTicks: number): number {
|
||||
return this.calculateCelestialAngle(this.worldTime, partialTicks)
|
||||
}
|
||||
|
||||
private getTemperature (x: number, z: number): number {
|
||||
return this.temperature
|
||||
}
|
||||
|
||||
private getSkyColorByTemp (temperature: number): number {
|
||||
temperature /= 3
|
||||
if (temperature < -1) temperature = -1
|
||||
if (temperature > 1) temperature = 1
|
||||
|
||||
// Apply debug fog orangeness to hue - positive values make it more orange, negative make it less orange
|
||||
const baseHue = 0.622_222_2 - temperature * 0.05
|
||||
// Orange is around hue 0.08-0.15, so we need to shift from blue-purple (0.62) toward orange
|
||||
// Use a more dramatic shift and also increase saturation for more noticeable effect
|
||||
const orangeHue = 0.12 // Orange hue value
|
||||
const hue = this.fogOrangeness > 0
|
||||
? baseHue + (orangeHue - baseHue) * this.fogOrangeness * 0.8 // Blend toward orange
|
||||
: baseHue + this.fogOrangeness * 0.1 // Subtle shift for negative values
|
||||
const saturation = 0.5 + temperature * 0.1 + Math.abs(this.fogOrangeness) * 0.3 // Increase saturation with orangeness
|
||||
const brightness = 1
|
||||
|
||||
return this.hsbToRgb(hue, saturation, brightness)
|
||||
}
|
||||
|
||||
private hsbToRgb (hue: number, saturation: number, brightness: number): number {
|
||||
let r = 0; let g = 0; let b = 0
|
||||
if (saturation === 0) {
|
||||
r = g = b = Math.floor(brightness * 255 + 0.5)
|
||||
} else {
|
||||
const h = (hue - Math.floor(hue)) * 6
|
||||
const f = h - Math.floor(h)
|
||||
const p = brightness * (1 - saturation)
|
||||
const q = brightness * (1 - saturation * f)
|
||||
const t = brightness * (1 - (saturation * (1 - f)))
|
||||
switch (Math.floor(h)) {
|
||||
case 0:
|
||||
r = Math.floor(brightness * 255 + 0.5)
|
||||
g = Math.floor(t * 255 + 0.5)
|
||||
b = Math.floor(p * 255 + 0.5)
|
||||
break
|
||||
case 1:
|
||||
r = Math.floor(q * 255 + 0.5)
|
||||
g = Math.floor(brightness * 255 + 0.5)
|
||||
b = Math.floor(p * 255 + 0.5)
|
||||
break
|
||||
case 2:
|
||||
r = Math.floor(p * 255 + 0.5)
|
||||
g = Math.floor(brightness * 255 + 0.5)
|
||||
b = Math.floor(t * 255 + 0.5)
|
||||
break
|
||||
case 3:
|
||||
r = Math.floor(p * 255 + 0.5)
|
||||
g = Math.floor(q * 255 + 0.5)
|
||||
b = Math.floor(brightness * 255 + 0.5)
|
||||
break
|
||||
case 4:
|
||||
r = Math.floor(t * 255 + 0.5)
|
||||
g = Math.floor(p * 255 + 0.5)
|
||||
b = Math.floor(brightness * 255 + 0.5)
|
||||
break
|
||||
case 5:
|
||||
r = Math.floor(brightness * 255 + 0.5)
|
||||
g = Math.floor(p * 255 + 0.5)
|
||||
b = Math.floor(q * 255 + 0.5)
|
||||
break
|
||||
}
|
||||
}
|
||||
return 0xff_00_00_00 | (r << 16) | (g << 8) | (Math.trunc(b))
|
||||
}
|
||||
|
||||
private updateSkyColors () {
|
||||
if (!this.skyMesh || !this.voidMesh) return
|
||||
|
||||
// If default skybox is disabled, hide the skybox meshes
|
||||
if (!this.defaultSkybox) {
|
||||
this.skyMesh.visible = false
|
||||
this.voidMesh.visible = false
|
||||
if (this.mesh) {
|
||||
this.mesh.visible = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Show skybox meshes when default skybox is enabled
|
||||
this.skyMesh.visible = true
|
||||
this.voidMesh.visible = true
|
||||
if (this.mesh) {
|
||||
this.mesh.visible = true
|
||||
}
|
||||
|
||||
// Update fog brightness with smooth transition
|
||||
this.prevFogBrightness = this.fogBrightness
|
||||
const renderDistance = this.viewDistance / 32
|
||||
const targetBrightness = this.brightnessAtPosition * (1 - renderDistance) + renderDistance
|
||||
this.fogBrightness += (targetBrightness - this.fogBrightness) * 0.1
|
||||
|
||||
// Handle water fog
|
||||
if (this.inWater) {
|
||||
const waterViewDistance = this.waterBreathing ? 100 : 5
|
||||
this.scene.fog = new THREE.Fog(new THREE.Color(0, 0, 1), 0.0025, waterViewDistance)
|
||||
this.scene.background = new THREE.Color(0, 0, 1)
|
||||
|
||||
// Update sky and void colors for underwater effect
|
||||
;(this.skyMesh.material as THREE.MeshBasicMaterial).color.set(new THREE.Color(0, 0, 1))
|
||||
;(this.voidMesh.material as THREE.MeshBasicMaterial).color.set(new THREE.Color(0, 0, 0.6))
|
||||
return
|
||||
}
|
||||
|
||||
// Normal sky colors
|
||||
const viewDistance = this.viewDistance * 16
|
||||
const viewFactor = 1 - (0.25 + 0.75 * this.viewDistance / 32) ** 0.25
|
||||
|
||||
const angle = this.getCelestialAngle(this.partialTicks)
|
||||
const skyColor = this.getSkyColor(0, 0, this.partialTicks)
|
||||
const fogColor = this.getFogColor(this.partialTicks)
|
||||
|
||||
const brightness = Math.cos(angle * Math.PI * 2) * 2 + 0.5
|
||||
const clampedBrightness = Math.max(0, Math.min(1, brightness))
|
||||
|
||||
// Interpolate fog brightness
|
||||
const interpolatedBrightness = this.prevFogBrightness + (this.fogBrightness - this.prevFogBrightness) * this.partialTicks
|
||||
|
||||
const red = (fogColor.x + (skyColor.x - fogColor.x) * viewFactor) * clampedBrightness * interpolatedBrightness
|
||||
const green = (fogColor.y + (skyColor.y - fogColor.y) * viewFactor) * clampedBrightness * interpolatedBrightness
|
||||
const blue = (fogColor.z + (skyColor.z - fogColor.z) * viewFactor) * clampedBrightness * interpolatedBrightness
|
||||
|
||||
this.scene.background = new THREE.Color(red, green, blue)
|
||||
this.scene.fog = new THREE.Fog(new THREE.Color(red, green, blue), 0.0025, viewDistance * this.distanceFactor)
|
||||
|
||||
;(this.skyMesh.material as THREE.MeshBasicMaterial).color.set(new THREE.Color(skyColor.x, skyColor.y, skyColor.z))
|
||||
;(this.voidMesh.material as THREE.MeshBasicMaterial).color.set(new THREE.Color(
|
||||
skyColor.x * 0.2 + 0.04,
|
||||
skyColor.y * 0.2 + 0.04,
|
||||
skyColor.z * 0.6 + 0.1
|
||||
))
|
||||
}
|
||||
|
||||
dispose () {
|
||||
if (this.texture) {
|
||||
this.texture.dispose()
|
||||
}
|
||||
if (this.mesh) {
|
||||
this.mesh.geometry.dispose()
|
||||
;(this.mesh.material as THREE.Material).dispose()
|
||||
this.scene.remove(this.mesh)
|
||||
}
|
||||
if (this.skyMesh) {
|
||||
this.skyMesh.geometry.dispose()
|
||||
;(this.skyMesh.material as THREE.Material).dispose()
|
||||
this.scene.remove(this.skyMesh)
|
||||
}
|
||||
if (this.voidMesh) {
|
||||
this.voidMesh.geometry.dispose()
|
||||
;(this.voidMesh.material as THREE.Material).dispose()
|
||||
this.scene.remove(this.voidMesh)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,8 +16,6 @@ interface MediaProperties {
|
|||
loop?: boolean
|
||||
volume?: number
|
||||
autoPlay?: boolean
|
||||
|
||||
allowLighting?: boolean
|
||||
}
|
||||
|
||||
export class ThreeJsMedia {
|
||||
|
|
@ -32,33 +30,13 @@ export class ThreeJsMedia {
|
|||
}>()
|
||||
|
||||
constructor (private readonly worldRenderer: WorldRendererThree) {
|
||||
this.worldRenderer.onWorldSwitched.push(() => {
|
||||
this.onWorldGone()
|
||||
})
|
||||
|
||||
this.worldRenderer.onRender.push(() => {
|
||||
this.render()
|
||||
})
|
||||
}
|
||||
|
||||
onWorldGone () {
|
||||
for (const [id, videoData] of this.customMedia.entries()) {
|
||||
this.destroyMedia(id)
|
||||
}
|
||||
}
|
||||
|
||||
onWorldStop () {
|
||||
for (const [id, videoData] of this.customMedia.entries()) {
|
||||
this.setVideoPlaying(id, false)
|
||||
}
|
||||
}
|
||||
|
||||
private createErrorTexture (width: number, height: number, background = 0x00_00_00, error = 'Failed to load'): THREE.CanvasTexture {
|
||||
const canvas = document.createElement('canvas')
|
||||
const MAX_DIMENSION = 100
|
||||
|
||||
canvas.width = MAX_DIMENSION
|
||||
canvas.height = MAX_DIMENSION
|
||||
// Scale up the canvas size for better text quality
|
||||
canvas.width = width * 100
|
||||
canvas.height = height * 100
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return new THREE.CanvasTexture(canvas)
|
||||
|
|
@ -70,7 +48,7 @@ export class ThreeJsMedia {
|
|||
ctx.fillStyle = `rgba(${background >> 16 & 255}, ${background >> 8 & 255}, ${background & 255}, 0.5)`
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Add red text with size relative to canvas dimensions
|
||||
// Add red text
|
||||
ctx.fillStyle = '#ff0000'
|
||||
ctx.font = 'bold 10px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
|
|
@ -218,8 +196,7 @@ export class ThreeJsMedia {
|
|||
const geometry = new THREE.PlaneGeometry(1, 1)
|
||||
|
||||
// Create material with initial properties using background texture
|
||||
const MaterialClass = props.allowLighting ? THREE.MeshLambertMaterial : THREE.MeshBasicMaterial
|
||||
const material = new MaterialClass({
|
||||
const material = new THREE.MeshLambertMaterial({
|
||||
map: backgroundTexture,
|
||||
transparent: true,
|
||||
side: props.doubleSide ? THREE.DoubleSide : THREE.FrontSide,
|
||||
|
|
@ -308,18 +285,6 @@ export class ThreeJsMedia {
|
|||
return id
|
||||
}
|
||||
|
||||
render () {
|
||||
for (const [id, videoData] of this.customMedia.entries()) {
|
||||
const chunkX = Math.floor(videoData.props.position.x / 16) * 16
|
||||
const chunkZ = Math.floor(videoData.props.position.z / 16) * 16
|
||||
const sectionY = Math.floor(videoData.props.position.y / 16) * 16
|
||||
|
||||
const chunkKey = `${chunkX},${chunkZ}`
|
||||
const sectionKey = `${chunkX},${sectionY},${chunkZ}`
|
||||
videoData.mesh.visible = !!this.worldRenderer.sectionObjects[sectionKey] || !!this.worldRenderer.finishedChunks[chunkKey]
|
||||
}
|
||||
}
|
||||
|
||||
setVideoPlaying (id: string, playing: boolean) {
|
||||
const videoData = this.customMedia.get(id)
|
||||
if (videoData?.video) {
|
||||
|
|
@ -547,15 +512,8 @@ export class ThreeJsMedia {
|
|||
console.log('Exact test mesh added with dimensions:', width, height, 'and rotation:', rotation)
|
||||
}
|
||||
|
||||
lastCheck = 0
|
||||
THROTTLE_TIME = 100
|
||||
tryIntersectMedia () {
|
||||
// hack: need to optimize this by pulling only in distance of interaction instead and throttle
|
||||
if (this.customMedia.size === 0) return
|
||||
if (Date.now() - this.lastCheck < this.THROTTLE_TIME) return
|
||||
this.lastCheck = Date.now()
|
||||
|
||||
const { camera, scene } = this.worldRenderer
|
||||
const { camera } = this.worldRenderer
|
||||
const raycaster = new THREE.Raycaster()
|
||||
|
||||
// Get mouse position at center of screen
|
||||
|
|
@ -564,36 +522,29 @@ export class ThreeJsMedia {
|
|||
// Update the raycaster
|
||||
raycaster.setFromCamera(mouse, camera)
|
||||
|
||||
// Check intersection with all objects in scene
|
||||
const intersects = raycaster.intersectObjects(scene.children, true)
|
||||
if (intersects.length > 0) {
|
||||
const intersection = intersects[0]
|
||||
const intersectedObject = intersection.object
|
||||
let result = null as { id: string, x: number, y: number } | null
|
||||
// Check intersection with all video meshes
|
||||
for (const [id, videoData] of this.customMedia.entries()) {
|
||||
// Get the actual mesh (first child of the group)
|
||||
const { mesh } = videoData
|
||||
if (!mesh) continue
|
||||
|
||||
// Find if this object belongs to any media
|
||||
for (const [id, videoData] of this.customMedia.entries()) {
|
||||
// Check if the intersected object is part of our media mesh
|
||||
if (intersectedObject === videoData.mesh ||
|
||||
videoData.mesh.children.includes(intersectedObject)) {
|
||||
const { uv } = intersection
|
||||
if (uv) {
|
||||
const result = {
|
||||
id,
|
||||
x: uv.x,
|
||||
y: uv.y
|
||||
}
|
||||
this.worldRenderer.reactiveState.world.intersectMedia = result
|
||||
this.worldRenderer['debugVideo'] = videoData
|
||||
this.worldRenderer.cursorBlock.cursorLinesHidden = true
|
||||
return
|
||||
const intersects = raycaster.intersectObject(mesh, false)
|
||||
if (intersects.length > 0) {
|
||||
const intersection = intersects[0]
|
||||
const { uv } = intersection
|
||||
if (uv) {
|
||||
result = {
|
||||
id,
|
||||
x: uv.x,
|
||||
y: uv.y
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No media intersection found
|
||||
this.worldRenderer.reactiveState.world.intersectMedia = null
|
||||
this.worldRenderer['debugVideo'] = null
|
||||
this.worldRenderer.cursorBlock.cursorLinesHidden = false
|
||||
this.worldRenderer.reactiveState.world.intersectMedia = result
|
||||
this.worldRenderer['debugVideo'] = result ? this.customMedia.get(result.id) : null
|
||||
this.worldRenderer.cursorBlock.cursorLinesHidden = !!result
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,160 +0,0 @@
|
|||
import * as THREE from 'three'
|
||||
|
||||
interface ParticleMesh extends THREE.Mesh {
|
||||
velocity: THREE.Vector3;
|
||||
}
|
||||
|
||||
interface ParticleConfig {
|
||||
fountainHeight: number;
|
||||
resetHeight: number;
|
||||
xVelocityRange: number;
|
||||
zVelocityRange: number;
|
||||
particleCount: number;
|
||||
particleRadiusRange: { min: number; max: number };
|
||||
yVelocityRange: { min: number; max: number };
|
||||
}
|
||||
|
||||
export interface FountainOptions {
|
||||
position?: { x: number, y: number, z: number }
|
||||
particleConfig?: Partial<ParticleConfig>;
|
||||
}
|
||||
|
||||
export class Fountain {
|
||||
private readonly particles: ParticleMesh[] = []
|
||||
private readonly config: { particleConfig: ParticleConfig }
|
||||
private readonly position: THREE.Vector3
|
||||
container: THREE.Object3D | undefined
|
||||
|
||||
constructor (public sectionId: string, options: FountainOptions = {}) {
|
||||
this.position = options.position ? new THREE.Vector3(options.position.x, options.position.y, options.position.z) : new THREE.Vector3(0, 0, 0)
|
||||
this.config = this.createConfig(options.particleConfig)
|
||||
}
|
||||
|
||||
private createConfig (
|
||||
particleConfigOverride?: Partial<ParticleConfig>
|
||||
): { particleConfig: ParticleConfig } {
|
||||
const particleConfig: ParticleConfig = {
|
||||
fountainHeight: 10,
|
||||
resetHeight: 0,
|
||||
xVelocityRange: 0.4,
|
||||
zVelocityRange: 0.4,
|
||||
particleCount: 400,
|
||||
particleRadiusRange: { min: 0.1, max: 0.6 },
|
||||
yVelocityRange: { min: 0.1, max: 2 },
|
||||
...particleConfigOverride
|
||||
}
|
||||
|
||||
return { particleConfig }
|
||||
}
|
||||
|
||||
|
||||
createParticles (container: THREE.Object3D): void {
|
||||
this.container = container
|
||||
const colorStart = new THREE.Color(0xff_ff_00)
|
||||
const colorEnd = new THREE.Color(0xff_a5_00)
|
||||
|
||||
for (let i = 0; i < this.config.particleConfig.particleCount; i++) {
|
||||
const radius = Math.random() *
|
||||
(this.config.particleConfig.particleRadiusRange.max - this.config.particleConfig.particleRadiusRange.min) +
|
||||
this.config.particleConfig.particleRadiusRange.min
|
||||
const geometry = new THREE.SphereGeometry(radius)
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color: colorStart.clone().lerp(colorEnd, Math.random())
|
||||
})
|
||||
const mesh = new THREE.Mesh(geometry, material)
|
||||
const particle = mesh as unknown as ParticleMesh
|
||||
|
||||
particle.position.set(
|
||||
this.position.x + (Math.random() - 0.5) * this.config.particleConfig.xVelocityRange * 2,
|
||||
this.position.y + this.config.particleConfig.fountainHeight,
|
||||
this.position.z + (Math.random() - 0.5) * this.config.particleConfig.zVelocityRange * 2
|
||||
)
|
||||
|
||||
particle.velocity = new THREE.Vector3(
|
||||
(Math.random() - 0.5) * this.config.particleConfig.xVelocityRange,
|
||||
-Math.random() * this.config.particleConfig.yVelocityRange.max,
|
||||
(Math.random() - 0.5) * this.config.particleConfig.zVelocityRange
|
||||
)
|
||||
|
||||
this.particles.push(particle)
|
||||
this.container.add(particle)
|
||||
|
||||
// this.container.onBeforeRender = () => {
|
||||
// this.render()
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
render (): void {
|
||||
for (const particle of this.particles) {
|
||||
particle.velocity.y -= 0.01 + Math.random() * 0.1
|
||||
particle.position.add(particle.velocity)
|
||||
|
||||
if (particle.position.y < this.position.y + this.config.particleConfig.resetHeight) {
|
||||
particle.position.set(
|
||||
this.position.x + (Math.random() - 0.5) * this.config.particleConfig.xVelocityRange * 2,
|
||||
this.position.y + this.config.particleConfig.fountainHeight,
|
||||
this.position.z + (Math.random() - 0.5) * this.config.particleConfig.zVelocityRange * 2
|
||||
)
|
||||
particle.velocity.set(
|
||||
(Math.random() - 0.5) * this.config.particleConfig.xVelocityRange,
|
||||
-Math.random() * this.config.particleConfig.yVelocityRange.max,
|
||||
(Math.random() - 0.5) * this.config.particleConfig.zVelocityRange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateParticleCount (newCount: number): void {
|
||||
if (newCount !== this.config.particleConfig.particleCount) {
|
||||
this.config.particleConfig.particleCount = newCount
|
||||
const currentCount = this.particles.length
|
||||
|
||||
if (newCount > currentCount) {
|
||||
this.addParticles(newCount - currentCount)
|
||||
} else if (newCount < currentCount) {
|
||||
this.removeParticles(currentCount - newCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private addParticles (count: number): void {
|
||||
const geometry = new THREE.SphereGeometry(0.1)
|
||||
const material = new THREE.MeshBasicMaterial({ color: 0x00_ff_00 })
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const mesh = new THREE.Mesh(geometry, material)
|
||||
const particle = mesh as unknown as ParticleMesh
|
||||
particle.position.copy(this.position)
|
||||
particle.velocity = new THREE.Vector3(
|
||||
Math.random() * this.config.particleConfig.xVelocityRange -
|
||||
this.config.particleConfig.xVelocityRange / 2,
|
||||
Math.random() * 2,
|
||||
Math.random() * this.config.particleConfig.zVelocityRange -
|
||||
this.config.particleConfig.zVelocityRange / 2
|
||||
)
|
||||
this.particles.push(particle)
|
||||
this.container!.add(particle)
|
||||
}
|
||||
}
|
||||
|
||||
private removeParticles (count: number): void {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const particle = this.particles.pop()
|
||||
if (particle) {
|
||||
this.container!.remove(particle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public dispose (): void {
|
||||
for (const particle of this.particles) {
|
||||
particle.geometry.dispose()
|
||||
if (Array.isArray(particle.material)) {
|
||||
for (const material of particle.material) material.dispose()
|
||||
} else {
|
||||
particle.material.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import * as THREE from 'three'
|
|||
import { WorldRendererThree } from './worldrendererThree'
|
||||
|
||||
export interface SoundSystem {
|
||||
playSound: (position: { x: number, y: number, z: number }, path: string, volume?: number, pitch?: number, timeout?: number) => void
|
||||
playSound: (position: { x: number, y: number, z: number }, path: string, volume?: number, pitch?: number) => void
|
||||
destroy: () => void
|
||||
}
|
||||
|
||||
|
|
@ -10,17 +10,7 @@ export class ThreeJsSound implements SoundSystem {
|
|||
audioListener: THREE.AudioListener | undefined
|
||||
private readonly activeSounds = new Set<THREE.PositionalAudio>()
|
||||
private readonly audioContext: AudioContext | undefined
|
||||
private readonly soundVolumes = new Map<THREE.PositionalAudio, number>()
|
||||
baseVolume = 1
|
||||
|
||||
constructor (public worldRenderer: WorldRendererThree) {
|
||||
worldRenderer.onWorldSwitched.push(() => {
|
||||
this.stopAll()
|
||||
})
|
||||
|
||||
worldRenderer.onReactiveConfigUpdated('volume', (volume) => {
|
||||
this.changeVolume(volume)
|
||||
})
|
||||
}
|
||||
|
||||
initAudioListener () {
|
||||
|
|
@ -29,63 +19,41 @@ export class ThreeJsSound implements SoundSystem {
|
|||
this.worldRenderer.camera.add(this.audioListener)
|
||||
}
|
||||
|
||||
playSound (position: { x: number, y: number, z: number }, path: string, volume = 1, pitch = 1, timeout = 500) {
|
||||
playSound (position: { x: number, y: number, z: number }, path: string, volume = 1, pitch = 1) {
|
||||
this.initAudioListener()
|
||||
|
||||
const sound = new THREE.PositionalAudio(this.audioListener!)
|
||||
this.activeSounds.add(sound)
|
||||
this.soundVolumes.set(sound, volume)
|
||||
|
||||
const audioLoader = new THREE.AudioLoader()
|
||||
const start = Date.now()
|
||||
void audioLoader.loadAsync(path).then((buffer) => {
|
||||
if (Date.now() - start > timeout) {
|
||||
console.warn('Ignored playing sound', path, 'due to timeout:', timeout, 'ms <', Date.now() - start, 'ms')
|
||||
return
|
||||
}
|
||||
if (Date.now() - start > 500) return
|
||||
// play
|
||||
sound.setBuffer(buffer)
|
||||
sound.setRefDistance(20)
|
||||
sound.setVolume(volume * this.baseVolume)
|
||||
sound.setVolume(volume)
|
||||
sound.setPlaybackRate(pitch) // set the pitch
|
||||
this.worldRenderer.scene.add(sound)
|
||||
// set sound position
|
||||
sound.position.set(position.x, position.y, position.z)
|
||||
sound.onEnded = () => {
|
||||
this.worldRenderer.scene.remove(sound)
|
||||
if (sound.source) {
|
||||
sound.disconnect()
|
||||
}
|
||||
sound.disconnect()
|
||||
this.activeSounds.delete(sound)
|
||||
this.soundVolumes.delete(sound)
|
||||
audioLoader.manager.itemEnd(path)
|
||||
}
|
||||
sound.play()
|
||||
})
|
||||
}
|
||||
|
||||
stopAll () {
|
||||
for (const sound of this.activeSounds) {
|
||||
if (!sound) continue
|
||||
sound.stop()
|
||||
if (sound.source) {
|
||||
sound.disconnect()
|
||||
}
|
||||
this.worldRenderer.scene.remove(sound)
|
||||
}
|
||||
this.activeSounds.clear()
|
||||
this.soundVolumes.clear()
|
||||
}
|
||||
|
||||
changeVolume (volume: number) {
|
||||
this.baseVolume = volume
|
||||
for (const [sound, individualVolume] of this.soundVolumes) {
|
||||
sound.setVolume(individualVolume * this.baseVolume)
|
||||
}
|
||||
}
|
||||
|
||||
destroy () {
|
||||
this.stopAll()
|
||||
// Stop and clean up all active sounds
|
||||
for (const sound of this.activeSounds) {
|
||||
sound.stop()
|
||||
sound.disconnect()
|
||||
}
|
||||
|
||||
// Remove and cleanup audio listener
|
||||
if (this.audioListener) {
|
||||
this.audioListener.removeFromParent()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import * as THREE from 'three'
|
||||
import { getLoadedImage } from 'mc-assets/dist/utils'
|
||||
import { createCanvas } from '../lib/utils'
|
||||
|
||||
export const disposeObject = (obj: THREE.Object3D, cleanTextures = false) => {
|
||||
// not cleaning texture there as it might be used by other objects, but would be good to also do that
|
||||
|
|
@ -18,56 +16,3 @@ export const disposeObject = (obj: THREE.Object3D, cleanTextures = false) => {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
let textureCache: Record<string, THREE.Texture> = {}
|
||||
let imagesPromises: Record<string, Promise<THREE.Texture>> = {}
|
||||
|
||||
export const loadThreeJsTextureFromUrlSync = (imageUrl: string) => {
|
||||
const texture = new THREE.Texture()
|
||||
const promise = getLoadedImage(imageUrl).then(image => {
|
||||
texture.image = image
|
||||
texture.needsUpdate = true
|
||||
return texture
|
||||
})
|
||||
return {
|
||||
texture,
|
||||
promise
|
||||
}
|
||||
}
|
||||
|
||||
export const loadThreeJsTextureFromUrl = async (imageUrl: string) => {
|
||||
const loaded = new THREE.TextureLoader().loadAsync(imageUrl)
|
||||
return loaded
|
||||
}
|
||||
|
||||
export const loadThreeJsTextureFromBitmap = (image: ImageBitmap) => {
|
||||
const canvas = createCanvas(image.width, image.height)
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.drawImage(image, 0, 0)
|
||||
const texture = new THREE.Texture(canvas)
|
||||
texture.magFilter = THREE.NearestFilter
|
||||
texture.minFilter = THREE.NearestFilter
|
||||
return texture
|
||||
}
|
||||
|
||||
export async function loadTexture (texture: string, cb: (texture: THREE.Texture) => void, onLoad?: () => void): Promise<void> {
|
||||
const cached = textureCache[texture]
|
||||
if (!cached) {
|
||||
const { promise, resolve } = Promise.withResolvers<THREE.Texture>()
|
||||
const t = loadThreeJsTextureFromUrlSync(texture)
|
||||
textureCache[texture] = t.texture
|
||||
void t.promise.then(resolve)
|
||||
imagesPromises[texture] = promise
|
||||
}
|
||||
|
||||
cb(textureCache[texture])
|
||||
void imagesPromises[texture].then(() => {
|
||||
onLoad?.()
|
||||
})
|
||||
}
|
||||
|
||||
export const clearTextureCache = () => {
|
||||
textureCache = {}
|
||||
imagesPromises = {}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,418 +0,0 @@
|
|||
import * as THREE from 'three'
|
||||
|
||||
// Centralized visual configuration (in screen pixels)
|
||||
export const WAYPOINT_CONFIG = {
|
||||
// Target size in screen pixels (this controls the final sprite size)
|
||||
TARGET_SCREEN_PX: 150,
|
||||
// Canvas size for internal rendering (keep power of 2 for textures)
|
||||
CANVAS_SIZE: 256,
|
||||
// Relative positions in canvas (0-1)
|
||||
LAYOUT: {
|
||||
DOT_Y: 0.3,
|
||||
NAME_Y: 0.45,
|
||||
DISTANCE_Y: 0.55,
|
||||
},
|
||||
// Multiplier for canvas internal resolution to keep text crisp
|
||||
CANVAS_SCALE: 2,
|
||||
ARROW: {
|
||||
enabledDefault: false,
|
||||
pixelSize: 50,
|
||||
paddingPx: 50,
|
||||
},
|
||||
}
|
||||
|
||||
export type WaypointSprite = {
|
||||
group: THREE.Group
|
||||
sprite: THREE.Sprite
|
||||
// Offscreen arrow controls
|
||||
enableOffscreenArrow: (enabled: boolean) => void
|
||||
setArrowParent: (parent: THREE.Object3D | null) => void
|
||||
// Convenience combined updater
|
||||
updateForCamera: (
|
||||
cameraPosition: THREE.Vector3,
|
||||
camera: THREE.PerspectiveCamera,
|
||||
viewportWidthPx: number,
|
||||
viewportHeightPx: number
|
||||
) => boolean
|
||||
// Utilities
|
||||
setColor: (color: number) => void
|
||||
setLabel: (label?: string) => void
|
||||
updateDistanceText: (label: string, distanceText: string) => void
|
||||
setVisible: (visible: boolean) => void
|
||||
setPosition: (x: number, y: number, z: number) => void
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
export function createWaypointSprite (options: {
|
||||
position: THREE.Vector3 | { x: number, y: number, z: number },
|
||||
color?: number,
|
||||
label?: string,
|
||||
depthTest?: boolean,
|
||||
// Y offset in world units used by updateScaleWorld only (screen-pixel API ignores this)
|
||||
labelYOffset?: number,
|
||||
metadata?: any,
|
||||
}): WaypointSprite {
|
||||
const color = options.color ?? 0xFF_00_00
|
||||
const depthTest = options.depthTest ?? false
|
||||
const labelYOffset = options.labelYOffset ?? 1.5
|
||||
|
||||
// Build combined sprite
|
||||
const sprite = createCombinedSprite(color, options.label ?? '', '0m', depthTest)
|
||||
sprite.renderOrder = 10
|
||||
let currentLabel = options.label ?? ''
|
||||
|
||||
// Offscreen arrow (detached by default)
|
||||
let arrowSprite: THREE.Sprite | undefined
|
||||
let arrowParent: THREE.Object3D | null = null
|
||||
let arrowEnabled = WAYPOINT_CONFIG.ARROW.enabledDefault
|
||||
|
||||
// Group for easy add/remove
|
||||
const group = new THREE.Group()
|
||||
group.add(sprite)
|
||||
|
||||
// Initial position
|
||||
const { x, y, z } = options.position
|
||||
group.position.set(x, y, z)
|
||||
|
||||
function setColor (newColor: number) {
|
||||
const canvas = drawCombinedCanvas(newColor, currentLabel, '0m')
|
||||
const texture = new THREE.CanvasTexture(canvas)
|
||||
const mat = sprite.material
|
||||
mat.map?.dispose()
|
||||
mat.map = texture
|
||||
mat.needsUpdate = true
|
||||
}
|
||||
|
||||
function setLabel (newLabel?: string) {
|
||||
currentLabel = newLabel ?? ''
|
||||
const canvas = drawCombinedCanvas(color, currentLabel, '0m')
|
||||
const texture = new THREE.CanvasTexture(canvas)
|
||||
const mat = sprite.material
|
||||
mat.map?.dispose()
|
||||
mat.map = texture
|
||||
mat.needsUpdate = true
|
||||
}
|
||||
|
||||
function updateDistanceText (label: string, distanceText: string) {
|
||||
const canvas = drawCombinedCanvas(color, label, distanceText)
|
||||
const texture = new THREE.CanvasTexture(canvas)
|
||||
const mat = sprite.material
|
||||
mat.map?.dispose()
|
||||
mat.map = texture
|
||||
mat.needsUpdate = true
|
||||
}
|
||||
|
||||
function setVisible (visible: boolean) {
|
||||
sprite.visible = visible
|
||||
}
|
||||
|
||||
function setPosition (nx: number, ny: number, nz: number) {
|
||||
group.position.set(nx, ny, nz)
|
||||
}
|
||||
|
||||
// Keep constant pixel size on screen using global config
|
||||
function updateScaleScreenPixels (
|
||||
cameraPosition: THREE.Vector3,
|
||||
cameraFov: number,
|
||||
distance: number,
|
||||
viewportHeightPx: number
|
||||
) {
|
||||
const vFovRad = cameraFov * Math.PI / 180
|
||||
const worldUnitsPerScreenHeightAtDist = Math.tan(vFovRad / 2) * 2 * distance
|
||||
// Use configured target screen size
|
||||
const scale = worldUnitsPerScreenHeightAtDist * (WAYPOINT_CONFIG.TARGET_SCREEN_PX / viewportHeightPx)
|
||||
sprite.scale.set(scale, scale, 1)
|
||||
}
|
||||
|
||||
function ensureArrow () {
|
||||
if (arrowSprite) return
|
||||
const size = 128
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.clearRect(0, 0, size, size)
|
||||
|
||||
// Draw arrow shape
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(size * 0.15, size * 0.5)
|
||||
ctx.lineTo(size * 0.85, size * 0.5)
|
||||
ctx.lineTo(size * 0.5, size * 0.15)
|
||||
ctx.closePath()
|
||||
|
||||
// Use waypoint color for arrow
|
||||
const colorHex = `#${color.toString(16).padStart(6, '0')}`
|
||||
ctx.lineWidth = 6
|
||||
ctx.strokeStyle = 'black'
|
||||
ctx.stroke()
|
||||
ctx.fillStyle = colorHex
|
||||
ctx.fill()
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas)
|
||||
const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false, depthWrite: false })
|
||||
arrowSprite = new THREE.Sprite(material)
|
||||
arrowSprite.renderOrder = 12
|
||||
arrowSprite.visible = false
|
||||
if (arrowParent) arrowParent.add(arrowSprite)
|
||||
}
|
||||
|
||||
function enableOffscreenArrow (enabled: boolean) {
|
||||
arrowEnabled = enabled
|
||||
if (!enabled && arrowSprite) arrowSprite.visible = false
|
||||
}
|
||||
|
||||
function setArrowParent (parent: THREE.Object3D | null) {
|
||||
if (arrowSprite?.parent) arrowSprite.parent.remove(arrowSprite)
|
||||
arrowParent = parent
|
||||
if (arrowSprite && parent) parent.add(arrowSprite)
|
||||
}
|
||||
|
||||
function updateOffscreenArrow (
|
||||
camera: THREE.PerspectiveCamera,
|
||||
viewportWidthPx: number,
|
||||
viewportHeightPx: number
|
||||
): boolean {
|
||||
if (!arrowEnabled) return true
|
||||
ensureArrow()
|
||||
if (!arrowSprite) return true
|
||||
|
||||
// Check if onlyLeftRight is enabled in metadata
|
||||
const onlyLeftRight = options.metadata?.onlyLeftRight === true
|
||||
|
||||
// Build camera basis using camera.up to respect custom orientations
|
||||
const forward = new THREE.Vector3()
|
||||
camera.getWorldDirection(forward) // camera look direction
|
||||
const upWorld = camera.up.clone().normalize()
|
||||
const right = new THREE.Vector3().copy(forward).cross(upWorld).normalize()
|
||||
const upCam = new THREE.Vector3().copy(right).cross(forward).normalize()
|
||||
|
||||
// Vector from camera to waypoint
|
||||
const camPos = new THREE.Vector3().setFromMatrixPosition(camera.matrixWorld)
|
||||
const toWp = new THREE.Vector3(group.position.x, group.position.y, group.position.z).sub(camPos)
|
||||
|
||||
// Components in camera basis
|
||||
const z = toWp.dot(forward)
|
||||
const x = toWp.dot(right)
|
||||
const y = toWp.dot(upCam)
|
||||
|
||||
const aspect = viewportWidthPx / viewportHeightPx
|
||||
const vFovRad = camera.fov * Math.PI / 180
|
||||
const hFovRad = 2 * Math.atan(Math.tan(vFovRad / 2) * aspect)
|
||||
|
||||
// Determine if waypoint is inside view frustum using angular checks
|
||||
const thetaX = Math.atan2(x, z)
|
||||
const thetaY = Math.atan2(y, z)
|
||||
const visible = z > 0 && Math.abs(thetaX) <= hFovRad / 2 && Math.abs(thetaY) <= vFovRad / 2
|
||||
if (visible) {
|
||||
arrowSprite.visible = false
|
||||
return true
|
||||
}
|
||||
|
||||
// Direction on screen in normalized frustum units
|
||||
let rx = thetaX / (hFovRad / 2)
|
||||
let ry = thetaY / (vFovRad / 2)
|
||||
|
||||
// If behind the camera, snap to dominant axis to avoid confusing directions
|
||||
if (z <= 0) {
|
||||
if (Math.abs(rx) > Math.abs(ry)) {
|
||||
rx = Math.sign(rx)
|
||||
ry = 0
|
||||
} else {
|
||||
rx = 0
|
||||
ry = Math.sign(ry)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply onlyLeftRight logic - restrict arrows to left/right edges only
|
||||
if (onlyLeftRight) {
|
||||
// Force the arrow to appear only on left or right edges
|
||||
if (Math.abs(rx) > Math.abs(ry)) {
|
||||
// Horizontal direction is dominant, keep it
|
||||
ry = 0
|
||||
} else {
|
||||
// Vertical direction is dominant, but we want only left/right
|
||||
// So choose left or right based on the sign of rx
|
||||
rx = rx >= 0 ? 1 : -1
|
||||
ry = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Place on the rectangle border [-1,1]x[-1,1]
|
||||
const s = Math.max(Math.abs(rx), Math.abs(ry)) || 1
|
||||
let ndcX = rx / s
|
||||
let ndcY = ry / s
|
||||
|
||||
// Apply padding in pixel space by clamping
|
||||
const padding = WAYPOINT_CONFIG.ARROW.paddingPx
|
||||
const pxX = ((ndcX + 1) * 0.5) * viewportWidthPx
|
||||
const pxY = ((1 - ndcY) * 0.5) * viewportHeightPx
|
||||
const clampedPxX = Math.min(Math.max(pxX, padding), viewportWidthPx - padding)
|
||||
const clampedPxY = Math.min(Math.max(pxY, padding), viewportHeightPx - padding)
|
||||
ndcX = (clampedPxX / viewportWidthPx) * 2 - 1
|
||||
ndcY = -(clampedPxY / viewportHeightPx) * 2 + 1
|
||||
|
||||
// Compute world position at a fixed distance in front of the camera using camera basis
|
||||
const placeDist = Math.max(2, camera.near * 4)
|
||||
const halfPlaneHeight = Math.tan(vFovRad / 2) * placeDist
|
||||
const halfPlaneWidth = halfPlaneHeight * aspect
|
||||
const pos = camPos.clone()
|
||||
.add(forward.clone().multiplyScalar(placeDist))
|
||||
.add(right.clone().multiplyScalar(ndcX * halfPlaneWidth))
|
||||
.add(upCam.clone().multiplyScalar(ndcY * halfPlaneHeight))
|
||||
|
||||
// Update arrow sprite
|
||||
arrowSprite.visible = true
|
||||
arrowSprite.position.copy(pos)
|
||||
|
||||
// Angle for rotation relative to screen right/up (derived from camera up vector)
|
||||
const angle = Math.atan2(ry, rx)
|
||||
arrowSprite.material.rotation = angle - Math.PI / 2
|
||||
|
||||
// Constant pixel size for arrow (use fixed placement distance)
|
||||
const worldUnitsPerScreenHeightAtDist = Math.tan(vFovRad / 2) * 2 * placeDist
|
||||
const sPx = worldUnitsPerScreenHeightAtDist * (WAYPOINT_CONFIG.ARROW.pixelSize / viewportHeightPx)
|
||||
arrowSprite.scale.set(sPx, sPx, 1)
|
||||
return false
|
||||
}
|
||||
|
||||
function computeDistance (cameraPosition: THREE.Vector3): number {
|
||||
return cameraPosition.distanceTo(group.position)
|
||||
}
|
||||
|
||||
function updateForCamera (
|
||||
cameraPosition: THREE.Vector3,
|
||||
camera: THREE.PerspectiveCamera,
|
||||
viewportWidthPx: number,
|
||||
viewportHeightPx: number
|
||||
): boolean {
|
||||
const distance = computeDistance(cameraPosition)
|
||||
// Keep constant pixel size
|
||||
updateScaleScreenPixels(cameraPosition, camera.fov, distance, viewportHeightPx)
|
||||
// Update text
|
||||
updateDistanceText(currentLabel, `${Math.round(distance)}m`)
|
||||
// Update arrow and visibility
|
||||
const onScreen = updateOffscreenArrow(camera, viewportWidthPx, viewportHeightPx)
|
||||
setVisible(onScreen)
|
||||
return onScreen
|
||||
}
|
||||
|
||||
function dispose () {
|
||||
const mat = sprite.material
|
||||
mat.map?.dispose()
|
||||
mat.dispose()
|
||||
if (arrowSprite) {
|
||||
const am = arrowSprite.material
|
||||
am.map?.dispose()
|
||||
am.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
group,
|
||||
sprite,
|
||||
enableOffscreenArrow,
|
||||
setArrowParent,
|
||||
updateForCamera,
|
||||
setColor,
|
||||
setLabel,
|
||||
updateDistanceText,
|
||||
setVisible,
|
||||
setPosition,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
// Internal helpers
|
||||
function drawCombinedCanvas (color: number, id: string, distance: string): HTMLCanvasElement {
|
||||
const scale = WAYPOINT_CONFIG.CANVAS_SCALE * (globalThis.devicePixelRatio || 1)
|
||||
const size = WAYPOINT_CONFIG.CANVAS_SIZE * scale
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, size, size)
|
||||
|
||||
// Draw dot
|
||||
const centerX = size / 2
|
||||
const dotY = Math.round(size * WAYPOINT_CONFIG.LAYOUT.DOT_Y)
|
||||
const radius = Math.round(size * 0.05) // Dot takes up ~12% of canvas height
|
||||
const borderWidth = Math.max(2, Math.round(4 * scale))
|
||||
|
||||
// Outer border (black)
|
||||
ctx.beginPath()
|
||||
ctx.arc(centerX, dotY, radius + borderWidth, 0, Math.PI * 2)
|
||||
ctx.fillStyle = 'black'
|
||||
ctx.fill()
|
||||
|
||||
// Inner circle (colored)
|
||||
ctx.beginPath()
|
||||
ctx.arc(centerX, dotY, radius, 0, Math.PI * 2)
|
||||
ctx.fillStyle = `#${color.toString(16).padStart(6, '0')}`
|
||||
ctx.fill()
|
||||
|
||||
// Text properties
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
// Title
|
||||
const nameFontPx = Math.round(size * 0.08) // ~8% of canvas height
|
||||
const distanceFontPx = Math.round(size * 0.06) // ~6% of canvas height
|
||||
ctx.font = `bold ${nameFontPx}px mojangles`
|
||||
ctx.lineWidth = Math.max(2, Math.round(3 * scale))
|
||||
const nameY = Math.round(size * WAYPOINT_CONFIG.LAYOUT.NAME_Y)
|
||||
|
||||
ctx.strokeStyle = 'black'
|
||||
ctx.strokeText(id, centerX, nameY)
|
||||
ctx.fillStyle = 'white'
|
||||
ctx.fillText(id, centerX, nameY)
|
||||
|
||||
// Distance
|
||||
ctx.font = `bold ${distanceFontPx}px mojangles`
|
||||
ctx.lineWidth = Math.max(2, Math.round(2 * scale))
|
||||
const distanceY = Math.round(size * WAYPOINT_CONFIG.LAYOUT.DISTANCE_Y)
|
||||
|
||||
ctx.strokeStyle = 'black'
|
||||
ctx.strokeText(distance, centerX, distanceY)
|
||||
ctx.fillStyle = '#CCCCCC'
|
||||
ctx.fillText(distance, centerX, distanceY)
|
||||
|
||||
return canvas
|
||||
}
|
||||
|
||||
function createCombinedSprite (color: number, id: string, distance: string, depthTest: boolean): THREE.Sprite {
|
||||
const canvas = drawCombinedCanvas(color, id, distance)
|
||||
const texture = new THREE.CanvasTexture(canvas)
|
||||
texture.anisotropy = 1
|
||||
texture.magFilter = THREE.LinearFilter
|
||||
texture.minFilter = THREE.LinearFilter
|
||||
const material = new THREE.SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
depthTest,
|
||||
depthWrite: false,
|
||||
})
|
||||
const sprite = new THREE.Sprite(material)
|
||||
sprite.position.set(0, 0, 0)
|
||||
return sprite
|
||||
}
|
||||
|
||||
export const WaypointHelpers = {
|
||||
// World-scale constant size helper
|
||||
computeWorldScale (distance: number, fixedReference = 10) {
|
||||
return Math.max(0.0001, distance / fixedReference)
|
||||
},
|
||||
// Screen-pixel constant size helper
|
||||
computeScreenPixelScale (
|
||||
camera: THREE.PerspectiveCamera,
|
||||
distance: number,
|
||||
pixelSize: number,
|
||||
viewportHeightPx: number
|
||||
) {
|
||||
const vFovRad = camera.fov * Math.PI / 180
|
||||
const worldUnitsPerScreenHeightAtDist = Math.tan(vFovRad / 2) * 2 * distance
|
||||
return worldUnitsPerScreenHeightAtDist * (pixelSize / viewportHeightPx)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
import * as THREE from 'three'
|
||||
import { WorldRendererThree } from './worldrendererThree'
|
||||
import { createWaypointSprite, type WaypointSprite } from './waypointSprite'
|
||||
|
||||
interface Waypoint {
|
||||
id: string
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
minDistance: number
|
||||
color: number
|
||||
label?: string
|
||||
sprite: WaypointSprite
|
||||
}
|
||||
|
||||
interface WaypointOptions {
|
||||
color?: number
|
||||
label?: string
|
||||
minDistance?: number
|
||||
metadata?: any
|
||||
}
|
||||
|
||||
export class WaypointsRenderer {
|
||||
private readonly waypoints = new Map<string, Waypoint>()
|
||||
private readonly waypointScene = new THREE.Scene()
|
||||
|
||||
constructor (
|
||||
private readonly worldRenderer: WorldRendererThree
|
||||
) {
|
||||
}
|
||||
|
||||
private updateWaypoints () {
|
||||
const playerPos = this.worldRenderer.cameraObject.position
|
||||
const sizeVec = this.worldRenderer.renderer.getSize(new THREE.Vector2())
|
||||
|
||||
for (const waypoint of this.waypoints.values()) {
|
||||
const waypointPos = new THREE.Vector3(waypoint.x, waypoint.y, waypoint.z)
|
||||
const distance = playerPos.distanceTo(waypointPos)
|
||||
const visible = !waypoint.minDistance || distance >= waypoint.minDistance
|
||||
|
||||
waypoint.sprite.setVisible(visible)
|
||||
|
||||
if (visible) {
|
||||
// Update position
|
||||
waypoint.sprite.setPosition(waypoint.x, waypoint.y, waypoint.z)
|
||||
// Ensure camera-based update each frame
|
||||
waypoint.sprite.updateForCamera(this.worldRenderer.getCameraPosition(), this.worldRenderer.camera, sizeVec.width, sizeVec.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
if (this.waypoints.size === 0) return
|
||||
|
||||
// Update waypoint scaling
|
||||
this.updateWaypoints()
|
||||
|
||||
// Render waypoints scene with the world camera
|
||||
this.worldRenderer.renderer.render(this.waypointScene, this.worldRenderer.camera)
|
||||
}
|
||||
|
||||
// Removed sprite/label texture creation. Use utils/waypointSprite.ts
|
||||
|
||||
addWaypoint (
|
||||
id: string,
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
options: WaypointOptions = {}
|
||||
) {
|
||||
// Remove existing waypoint if it exists
|
||||
this.removeWaypoint(id)
|
||||
|
||||
const color = options.color ?? 0xFF_00_00
|
||||
const { label, metadata } = options
|
||||
const minDistance = options.minDistance ?? 0
|
||||
|
||||
const sprite = createWaypointSprite({
|
||||
position: new THREE.Vector3(x, y, z),
|
||||
color,
|
||||
label: (label || id),
|
||||
metadata,
|
||||
})
|
||||
sprite.enableOffscreenArrow(true)
|
||||
sprite.setArrowParent(this.waypointScene)
|
||||
|
||||
this.waypointScene.add(sprite.group)
|
||||
|
||||
this.waypoints.set(id, {
|
||||
id, x: x + 0.5, y: y + 0.5, z: z + 0.5, minDistance,
|
||||
color, label,
|
||||
sprite,
|
||||
})
|
||||
}
|
||||
|
||||
removeWaypoint (id: string) {
|
||||
const waypoint = this.waypoints.get(id)
|
||||
if (waypoint) {
|
||||
this.waypointScene.remove(waypoint.sprite.group)
|
||||
waypoint.sprite.dispose()
|
||||
this.waypoints.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
clear () {
|
||||
for (const id of this.waypoints.keys()) {
|
||||
this.removeWaypoint(id)
|
||||
}
|
||||
}
|
||||
|
||||
testWaypoint () {
|
||||
this.addWaypoint('Test Point', 0, 70, 0, { color: 0x00_FF_00, label: 'Test Point' })
|
||||
this.addWaypoint('Spawn', 0, 64, 0, { color: 0xFF_FF_00, label: 'Spawn' })
|
||||
this.addWaypoint('Far Point', 100, 70, 100, { color: 0x00_00_FF, label: 'Far Point' })
|
||||
}
|
||||
|
||||
getWaypoint (id: string): Waypoint | undefined {
|
||||
return this.waypoints.get(id)
|
||||
}
|
||||
|
||||
getAllWaypoints (): Waypoint[] {
|
||||
return [...this.waypoints.values()]
|
||||
}
|
||||
|
||||
setWaypointColor (id: string, color: number) {
|
||||
const waypoint = this.waypoints.get(id)
|
||||
if (waypoint) {
|
||||
waypoint.sprite.setColor(color)
|
||||
waypoint.color = color
|
||||
}
|
||||
}
|
||||
|
||||
setWaypointLabel (id: string, label?: string) {
|
||||
const waypoint = this.waypoints.get(id)
|
||||
if (waypoint) {
|
||||
waypoint.label = label
|
||||
waypoint.sprite.setLabel(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import * as THREE from 'three'
|
||||
import { LineMaterial, LineSegmentsGeometry, Wireframe } from 'three-stdlib'
|
||||
import { Vec3 } from 'vec3'
|
||||
import { BlockShape, BlocksShapes } from 'renderer/viewer/lib/basePlayerState'
|
||||
import { subscribeKey } from 'valtio/utils'
|
||||
import { Block } from 'prismarine-block'
|
||||
import { WorldRendererThree } from '../worldrendererThree'
|
||||
import { loadThreeJsTextureFromUrl } from '../threeJsUtils'
|
||||
import destroyStage0 from '../../../../assets/destroy_stage_0.png'
|
||||
import destroyStage1 from '../../../../assets/destroy_stage_1.png'
|
||||
import destroyStage2 from '../../../../assets/destroy_stage_2.png'
|
||||
|
|
@ -28,24 +28,24 @@ export class CursorBlock {
|
|||
}
|
||||
|
||||
cursorLineMaterial: LineMaterial
|
||||
interactionLines: null | { blockPos: Vec3, mesh: THREE.Group, shapePositions: BlocksShapes | undefined } = null
|
||||
interactionLines: null | { blockPos: Vec3, mesh: THREE.Group } = null
|
||||
prevColor: string | undefined
|
||||
blockBreakMesh: THREE.Mesh
|
||||
breakTextures: THREE.Texture[] = []
|
||||
|
||||
constructor (public readonly worldRenderer: WorldRendererThree) {
|
||||
// Initialize break mesh and textures
|
||||
const loader = new THREE.TextureLoader()
|
||||
const destroyStagesImages = [
|
||||
destroyStage0, destroyStage1, destroyStage2, destroyStage3, destroyStage4,
|
||||
destroyStage5, destroyStage6, destroyStage7, destroyStage8, destroyStage9
|
||||
]
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
void loadThreeJsTextureFromUrl(destroyStagesImages[i]).then((texture) => {
|
||||
texture.magFilter = THREE.NearestFilter
|
||||
texture.minFilter = THREE.NearestFilter
|
||||
this.breakTextures.push(texture)
|
||||
})
|
||||
const texture = loader.load(destroyStagesImages[i])
|
||||
texture.magFilter = THREE.NearestFilter
|
||||
texture.minFilter = THREE.NearestFilter
|
||||
this.breakTextures.push(texture)
|
||||
}
|
||||
|
||||
const breakMaterial = new THREE.MeshBasicMaterial({
|
||||
|
|
@ -59,26 +59,18 @@ export class CursorBlock {
|
|||
this.blockBreakMesh.name = 'blockBreakMesh'
|
||||
this.worldRenderer.scene.add(this.blockBreakMesh)
|
||||
|
||||
this.worldRenderer.onReactivePlayerStateUpdated('gameMode', () => {
|
||||
subscribeKey(this.worldRenderer.playerState.reactive, 'gameMode', () => {
|
||||
this.updateLineMaterial()
|
||||
})
|
||||
// todo figure out why otherwise fog from skybox breaks it
|
||||
setTimeout(() => {
|
||||
this.updateLineMaterial()
|
||||
if (this.interactionLines) {
|
||||
this.setHighlightCursorBlock(this.interactionLines.blockPos, this.interactionLines.shapePositions, true)
|
||||
}
|
||||
})
|
||||
|
||||
this.updateLineMaterial()
|
||||
}
|
||||
|
||||
// Update functions
|
||||
updateLineMaterial () {
|
||||
const inCreative = this.worldRenderer.playerStateReactive.gameMode === 'creative'
|
||||
const inCreative = this.worldRenderer.displayOptions.playerState.reactive.gameMode === 'creative'
|
||||
const pixelRatio = this.worldRenderer.renderer.getPixelRatio()
|
||||
|
||||
if (this.cursorLineMaterial) {
|
||||
this.cursorLineMaterial.dispose()
|
||||
}
|
||||
this.cursorLineMaterial = new LineMaterial({
|
||||
color: (() => {
|
||||
switch (this.worldRenderer.worldRendererConfig.highlightBlockColor) {
|
||||
|
|
@ -97,13 +89,15 @@ export class CursorBlock {
|
|||
this.prevColor = this.worldRenderer.worldRendererConfig.highlightBlockColor
|
||||
}
|
||||
|
||||
updateBreakAnimation (blockPosition: { x: number, y: number, z: number } | undefined, stage: number | null, mergedShape?: BlockShape) {
|
||||
updateBreakAnimation (block: Block | undefined, stage: number | null) {
|
||||
this.hideBreakAnimation()
|
||||
if (stage === null || !blockPosition || !mergedShape) return
|
||||
if (stage === null || !block) return
|
||||
|
||||
const { position, width, height, depth } = mergedShape
|
||||
const mergedShape = bot.mouse.getMergedCursorShape(block)
|
||||
if (!mergedShape) return
|
||||
const { position, width, height, depth } = bot.mouse.getDataFromShape(mergedShape)
|
||||
this.blockBreakMesh.scale.set(width * 1.001, height * 1.001, depth * 1.001)
|
||||
position.add(blockPosition)
|
||||
position.add(block.position)
|
||||
this.blockBreakMesh.position.set(position.x, position.y, position.z)
|
||||
this.blockBreakMesh.visible = true;
|
||||
|
||||
|
|
@ -125,8 +119,8 @@ export class CursorBlock {
|
|||
}
|
||||
}
|
||||
|
||||
setHighlightCursorBlock (blockPos: Vec3 | null, shapePositions?: BlocksShapes, force = false): void {
|
||||
if (blockPos && this.interactionLines && blockPos.equals(this.interactionLines.blockPos) && !force) {
|
||||
setHighlightCursorBlock (blockPos: Vec3 | null, shapePositions?: Array<{ position: any; width: any; height: any; depth: any; }>): void {
|
||||
if (blockPos && this.interactionLines && blockPos.equals(this.interactionLines.blockPos)) {
|
||||
return
|
||||
}
|
||||
if (this.interactionLines !== null) {
|
||||
|
|
@ -149,8 +143,8 @@ export class CursorBlock {
|
|||
group.add(wireframe)
|
||||
}
|
||||
this.worldRenderer.scene.add(group)
|
||||
group.visible = !this.cursorLinesHidden
|
||||
this.interactionLines = { blockPos, mesh: group, shapePositions }
|
||||
group.visible = this.cursorLinesHidden
|
||||
this.interactionLines = { blockPos, mesh: group }
|
||||
}
|
||||
|
||||
render () {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@ import { XRControllerModelFactory } from 'three/examples/jsm/webxr/XRControllerM
|
|||
import { buttonMap as standardButtonsMap } from 'contro-max/build/gamepad'
|
||||
import * as THREE from 'three'
|
||||
import { WorldRendererThree } from '../worldrendererThree'
|
||||
import { DocumentRenderer } from '../documentRenderer'
|
||||
|
||||
export async function initVR (worldRenderer: WorldRendererThree, documentRenderer: DocumentRenderer) {
|
||||
export async function initVR (worldRenderer: WorldRendererThree) {
|
||||
if (!('xr' in navigator) || !worldRenderer.worldRendererConfig.vrSupport) return
|
||||
const { renderer } = worldRenderer
|
||||
|
||||
|
|
@ -27,13 +26,12 @@ export async function initVR (worldRenderer: WorldRendererThree, documentRendere
|
|||
|
||||
function enableVr () {
|
||||
renderer.xr.enabled = true
|
||||
// renderer.xr.setReferenceSpaceType('local-floor')
|
||||
worldRenderer.reactiveState.preventEscapeMenu = true
|
||||
}
|
||||
|
||||
function disableVr () {
|
||||
renderer.xr.enabled = false
|
||||
worldRenderer.cameraGroupVr = undefined
|
||||
worldRenderer.cameraObjectOverride = undefined
|
||||
worldRenderer.reactiveState.preventEscapeMenu = false
|
||||
worldRenderer.scene.remove(user)
|
||||
vrButtonContainer.hidden = true
|
||||
|
|
@ -102,7 +100,7 @@ export async function initVR (worldRenderer: WorldRendererThree, documentRendere
|
|||
|
||||
// hack for vr camera
|
||||
const user = new THREE.Group()
|
||||
user.name = 'vr-camera-container'
|
||||
user.add(worldRenderer.camera)
|
||||
worldRenderer.scene.add(user)
|
||||
const controllerModelFactory = new XRControllerModelFactory(new GLTFLoader())
|
||||
const controller1 = renderer.xr.getControllerGrip(0)
|
||||
|
|
@ -191,7 +189,7 @@ export async function initVR (worldRenderer: WorldRendererThree, documentRendere
|
|||
}
|
||||
|
||||
// appViewer.backend?.updateCamera(null, yawOffset, 0)
|
||||
// worldRenderer.updateCamera(null, bot.entity.yaw, bot.entity.pitch)
|
||||
worldRenderer.updateCamera(null, bot.entity.yaw, bot.entity.pitch)
|
||||
|
||||
// todo restore this logic (need to preserve ability to move camera)
|
||||
// const xrCamera = renderer.xr.getCamera()
|
||||
|
|
@ -199,15 +197,16 @@ export async function initVR (worldRenderer: WorldRendererThree, documentRendere
|
|||
// bot.entity.yaw = Math.atan2(-d.x, -d.z)
|
||||
// bot.entity.pitch = Math.asin(d.y)
|
||||
|
||||
documentRenderer.frameRender(false)
|
||||
// todo ?
|
||||
// bot.physics.stepHeight = 1
|
||||
|
||||
worldRenderer.render()
|
||||
})
|
||||
renderer.xr.addEventListener('sessionstart', () => {
|
||||
user.add(worldRenderer.camera)
|
||||
worldRenderer.cameraGroupVr = user
|
||||
worldRenderer.cameraObjectOverride = user
|
||||
})
|
||||
renderer.xr.addEventListener('sessionend', () => {
|
||||
worldRenderer.cameraGroupVr = undefined
|
||||
user.remove(worldRenderer.camera)
|
||||
worldRenderer.cameraObjectOverride = undefined
|
||||
})
|
||||
|
||||
worldRenderer.abortController.signal.addEventListener('abort', disableVr)
|
||||
|
|
|
|||
|
|
@ -3,35 +3,34 @@ import { Vec3 } from 'vec3'
|
|||
import nbt from 'prismarine-nbt'
|
||||
import PrismarineChatLoader from 'prismarine-chat'
|
||||
import * as tweenJs from '@tweenjs/tween.js'
|
||||
import { Biome } from 'minecraft-data'
|
||||
import { subscribeKey } from 'valtio/utils'
|
||||
import { renderSign } from '../sign-renderer'
|
||||
import { DisplayWorldOptions, GraphicsInitOptions } from '../../../src/appViewer'
|
||||
import { DisplayWorldOptions, GraphicsInitOptions, RendererReactiveState } from '../../../src/appViewer'
|
||||
import { chunkPos, sectionPos } from '../lib/simpleUtils'
|
||||
import { WorldRendererCommon } from '../lib/worldrendererCommon'
|
||||
import { addNewStat } from '../lib/ui/newStats'
|
||||
import { addNewStat, removeAllStats } from '../lib/ui/newStats'
|
||||
import { MesherGeometryOutput } from '../lib/mesher/shared'
|
||||
import { ItemSpecificContextProperties } from '../lib/basePlayerState'
|
||||
import { getMyHand } from '../lib/hand'
|
||||
import { setBlockPosition } from '../lib/mesher/standaloneRenderer'
|
||||
import { getMyHand } from './hand'
|
||||
import { sendVideoPlay, sendVideoStop } from '../../../src/customChannels'
|
||||
import HoldingBlock from './holdingBlock'
|
||||
import { getMesh } from './entity/EntityMesh'
|
||||
import { armorModel } from './entity/armorModels'
|
||||
import { disposeObject, loadThreeJsTextureFromBitmap } from './threeJsUtils'
|
||||
import { disposeObject } from './threeJsUtils'
|
||||
import { CursorBlock } from './world/cursorBlock'
|
||||
import { getItemUv } from './appShared'
|
||||
import { initVR } from './world/vr'
|
||||
import { Entities } from './entities'
|
||||
import { ThreeJsSound } from './threeJsSound'
|
||||
import { CameraShake } from './cameraShake'
|
||||
import { ThreeJsMedia } from './threeJsMedia'
|
||||
import { Fountain } from './threeJsParticles'
|
||||
import { WaypointsRenderer } from './waypoints'
|
||||
import { DEFAULT_TEMPERATURE, SkyboxRenderer } from './skyboxRenderer'
|
||||
|
||||
type SectionKey = string
|
||||
|
||||
export class WorldRendererThree extends WorldRendererCommon {
|
||||
outputFormat = 'threeJs' as const
|
||||
sectionObjects: Record<string, THREE.Object3D & { foutain?: boolean }> = {}
|
||||
sectionObjects: Record<string, THREE.Object3D> = {}
|
||||
chunkTextures = new Map<string, { [pos: string]: THREE.Texture }>()
|
||||
signsCache = new Map<string, any>()
|
||||
starField: StarField
|
||||
|
|
@ -42,41 +41,14 @@ export class WorldRendererThree extends WorldRendererCommon {
|
|||
ambientLight = new THREE.AmbientLight(0xcc_cc_cc)
|
||||
directionalLight = new THREE.DirectionalLight(0xff_ff_ff, 0.5)
|
||||
entities = new Entities(this)
|
||||
cameraGroupVr?: THREE.Object3D
|
||||
cameraObjectOverride?: THREE.Object3D // for xr
|
||||
material = new THREE.MeshLambertMaterial({ vertexColors: true, transparent: true, alphaTest: 0.1 })
|
||||
itemsTexture: THREE.Texture
|
||||
cursorBlock: CursorBlock
|
||||
cursorBlock = new CursorBlock(this)
|
||||
onRender: Array<() => void> = []
|
||||
cameraShake: CameraShake
|
||||
cameraContainer: THREE.Object3D
|
||||
media: ThreeJsMedia
|
||||
waitingChunksToDisplay = {} as { [chunkKey: string]: SectionKey[] }
|
||||
waypoints: WaypointsRenderer
|
||||
camera: THREE.PerspectiveCamera
|
||||
renderTimeAvg = 0
|
||||
sectionsOffsetsAnimations = {} as {
|
||||
[chunkKey: string]: {
|
||||
time: number,
|
||||
// also specifies direction
|
||||
speedX: number,
|
||||
speedY: number,
|
||||
speedZ: number,
|
||||
|
||||
currentOffsetX: number,
|
||||
currentOffsetY: number,
|
||||
currentOffsetZ: number,
|
||||
|
||||
limitX?: number,
|
||||
limitY?: number,
|
||||
limitZ?: number,
|
||||
}
|
||||
}
|
||||
fountains: Fountain[] = []
|
||||
DEBUG_RAYCAST = false
|
||||
skyboxRenderer: SkyboxRenderer
|
||||
|
||||
private currentPosTween?: tweenJs.Tween<THREE.Vector3>
|
||||
private currentRotTween?: tweenJs.Tween<{ pitch: number, yaw: number }>
|
||||
|
||||
get tilesRendered () {
|
||||
return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj as any).tilesCount, 0)
|
||||
|
|
@ -88,51 +60,26 @@ export class WorldRendererThree extends WorldRendererCommon {
|
|||
|
||||
constructor (public renderer: THREE.WebGLRenderer, public initOptions: GraphicsInitOptions, public displayOptions: DisplayWorldOptions) {
|
||||
if (!initOptions.resourcesManager) throw new Error('resourcesManager is required')
|
||||
super(initOptions.resourcesManager, displayOptions, initOptions)
|
||||
super(initOptions.resourcesManager, displayOptions, displayOptions.version)
|
||||
|
||||
this.renderer = renderer
|
||||
displayOptions.rendererState.renderer = WorldRendererThree.getRendererInfo(renderer) ?? '...'
|
||||
this.starField = new StarField(this)
|
||||
this.cursorBlock = new CursorBlock(this)
|
||||
this.starField = new StarField(this.scene)
|
||||
this.holdingBlock = new HoldingBlock(this)
|
||||
this.holdingBlockLeft = new HoldingBlock(this, true)
|
||||
|
||||
// Initialize skybox renderer
|
||||
this.skyboxRenderer = new SkyboxRenderer(this.scene, this.worldRendererConfig.defaultSkybox, null)
|
||||
void this.skyboxRenderer.init()
|
||||
|
||||
this.addDebugOverlay()
|
||||
this.resetScene()
|
||||
void this.init()
|
||||
this.watchReactivePlayerState()
|
||||
this.init()
|
||||
void initVR(this)
|
||||
|
||||
this.soundSystem = new ThreeJsSound(this)
|
||||
this.cameraShake = new CameraShake(this, this.onRender)
|
||||
this.cameraShake = new CameraShake(this.camera, this.onRender)
|
||||
this.media = new ThreeJsMedia(this)
|
||||
this.waypoints = new WaypointsRenderer(this)
|
||||
|
||||
// this.fountain = new Fountain(this.scene, this.scene, {
|
||||
// position: new THREE.Vector3(0, 10, 0),
|
||||
// })
|
||||
|
||||
this.renderUpdateEmitter.on('chunkFinished', (chunkKey: string) => {
|
||||
this.finishChunk(chunkKey)
|
||||
})
|
||||
this.worldSwitchActions()
|
||||
}
|
||||
|
||||
get cameraObject () {
|
||||
return this.cameraGroupVr ?? this.cameraContainer
|
||||
}
|
||||
|
||||
worldSwitchActions () {
|
||||
this.onWorldSwitched.push(() => {
|
||||
// clear custom blocks
|
||||
this.protocolCustomBlocks.clear()
|
||||
// Reset section animations
|
||||
this.sectionsOffsetsAnimations = {}
|
||||
// Clear waypoints
|
||||
this.waypoints.clear()
|
||||
})
|
||||
}
|
||||
|
||||
updateEntity (e, isPosUpdate = false) {
|
||||
|
|
@ -152,10 +99,6 @@ export class WorldRendererThree extends WorldRendererCommon {
|
|||
}
|
||||
}
|
||||
|
||||
updatePlayerEntity (e: any) {
|
||||
this.entities.handlePlayerEntity(e)
|
||||
}
|
||||
|
||||
resetScene () {
|
||||
this.scene.matrixAutoUpdate = false // for perf
|
||||
this.scene.background = new THREE.Color(this.initOptions.config.sceneBackground)
|
||||
|
|
@ -166,49 +109,27 @@ export class WorldRendererThree extends WorldRendererCommon {
|
|||
|
||||
const size = this.renderer.getSize(new THREE.Vector2())
|
||||
this.camera = new THREE.PerspectiveCamera(75, size.x / size.y, 0.1, 1000)
|
||||
this.cameraContainer = new THREE.Object3D()
|
||||
this.cameraContainer.add(this.camera)
|
||||
this.scene.add(this.cameraContainer)
|
||||
}
|
||||
|
||||
override watchReactivePlayerState () {
|
||||
super.watchReactivePlayerState()
|
||||
this.onReactivePlayerStateUpdated('inWater', (value) => {
|
||||
this.skyboxRenderer.updateWaterState(value, this.playerStateReactive.waterBreathing)
|
||||
watchReactivePlayerState () {
|
||||
const updateValue = <T extends keyof typeof this.displayOptions.playerState.reactive>(key: T, callback: (value: typeof this.displayOptions.playerState.reactive[T]) => void) => {
|
||||
callback(this.displayOptions.playerState.reactive[key])
|
||||
subscribeKey(this.displayOptions.playerState.reactive, key, callback)
|
||||
}
|
||||
updateValue('backgroundColor', (value) => {
|
||||
this.changeBackgroundColor(value)
|
||||
})
|
||||
this.onReactivePlayerStateUpdated('waterBreathing', (value) => {
|
||||
this.skyboxRenderer.updateWaterState(this.playerStateReactive.inWater, value)
|
||||
updateValue('inWater', (value) => {
|
||||
this.scene.fog = value ? new THREE.Fog(0x00_00_ff, 0.1, 100) : null
|
||||
})
|
||||
this.onReactivePlayerStateUpdated('ambientLight', (value) => {
|
||||
updateValue('ambientLight', (value) => {
|
||||
if (!value) return
|
||||
this.ambientLight.intensity = value
|
||||
})
|
||||
this.onReactivePlayerStateUpdated('directionalLight', (value) => {
|
||||
updateValue('directionalLight', (value) => {
|
||||
if (!value) return
|
||||
this.directionalLight.intensity = value
|
||||
})
|
||||
this.onReactivePlayerStateUpdated('lookingAtBlock', (value) => {
|
||||
this.cursorBlock.setHighlightCursorBlock(value ? new Vec3(value.x, value.y, value.z) : null, value?.shapes)
|
||||
})
|
||||
this.onReactivePlayerStateUpdated('diggingBlock', (value) => {
|
||||
this.cursorBlock.updateBreakAnimation(value ? { x: value.x, y: value.y, z: value.z } : undefined, value?.stage ?? null, value?.mergedShape)
|
||||
})
|
||||
this.onReactivePlayerStateUpdated('perspective', (value) => {
|
||||
// Update camera perspective when it changes
|
||||
const vecPos = new Vec3(this.cameraObject.position.x, this.cameraObject.position.y, this.cameraObject.position.z)
|
||||
this.updateCamera(vecPos, this.cameraShake.getBaseRotation().yaw, this.cameraShake.getBaseRotation().pitch)
|
||||
// todo also update camera when block within camera was changed
|
||||
})
|
||||
}
|
||||
|
||||
override watchReactiveConfig () {
|
||||
super.watchReactiveConfig()
|
||||
this.onReactiveConfigUpdated('showChunkBorders', (value) => {
|
||||
this.updateShowChunksBorder(value)
|
||||
})
|
||||
this.onReactiveConfigUpdated('defaultSkybox', (value) => {
|
||||
this.skyboxRenderer.updateDefaultSkybox(value)
|
||||
})
|
||||
}
|
||||
|
||||
changeHandSwingingState (isAnimationPlaying: boolean, isLeft = false) {
|
||||
|
|
@ -221,18 +142,20 @@ export class WorldRendererThree extends WorldRendererCommon {
|
|||
}
|
||||
|
||||
async updateAssetsData (): Promise<void> {
|
||||
const resources = this.resourcesManager.currentResources
|
||||
const resources = this.resourcesManager.currentResources!
|
||||
|
||||
const oldTexture = this.material.map
|
||||
const oldItemsTexture = this.itemsTexture
|
||||
|
||||
const texture = loadThreeJsTextureFromBitmap(resources.blocksAtlasImage)
|
||||
texture.needsUpdate = true
|
||||
const texture = await new THREE.TextureLoader().loadAsync(resources.blocksAtlasParser.latestImage)
|
||||
texture.magFilter = THREE.NearestFilter
|
||||
texture.minFilter = THREE.NearestFilter
|
||||
texture.flipY = false
|
||||
this.material.map = texture
|
||||
|
||||
const itemsTexture = loadThreeJsTextureFromBitmap(resources.itemsAtlasImage)
|
||||
itemsTexture.needsUpdate = true
|
||||
const itemsTexture = await new THREE.TextureLoader().loadAsync(resources.itemsAtlasParser.latestImage)
|
||||
itemsTexture.magFilter = THREE.NearestFilter
|
||||
itemsTexture.minFilter = THREE.NearestFilter
|
||||
itemsTexture.flipY = false
|
||||
this.itemsTexture = itemsTexture
|
||||
|
||||
|
|
@ -271,30 +194,17 @@ export class WorldRendererThree extends WorldRendererCommon {
|
|||
} else {
|
||||
this.starField.remove()
|
||||
}
|
||||
|
||||
this.skyboxRenderer.updateTime(newTime)
|
||||
}
|
||||
|
||||
biomeUpdated (biome: Biome): void {
|
||||
if (biome?.temperature !== undefined) {
|
||||
this.skyboxRenderer.updateTemperature(biome.temperature)
|
||||
}
|
||||
}
|
||||
|
||||
biomeReset (): void {
|
||||
// Reset to default temperature when biome is unknown
|
||||
this.skyboxRenderer.updateTemperature(DEFAULT_TEMPERATURE)
|
||||
}
|
||||
|
||||
getItemRenderData (item: Record<string, any>, specificProps: ItemSpecificContextProperties) {
|
||||
return getItemUv(item, specificProps, this.resourcesManager, this.playerStateReactive)
|
||||
return getItemUv(item, specificProps, this.resourcesManager)
|
||||
}
|
||||
|
||||
async demoModel () {
|
||||
//@ts-expect-error
|
||||
const pos = cursorBlockRel(0, 1, 0).position
|
||||
|
||||
const mesh = (await getMyHand())!
|
||||
const mesh = await getMyHand()
|
||||
// mesh.rotation.y = THREE.MathUtils.degToRad(90)
|
||||
setBlockPosition(mesh, pos)
|
||||
const helper = new THREE.BoxHelper(mesh, 0xff_ff_00)
|
||||
|
|
@ -321,19 +231,9 @@ export class WorldRendererThree extends WorldRendererCommon {
|
|||
this.debugOverlayAdded = true
|
||||
const pane = addNewStat('debug-overlay')
|
||||
setInterval(() => {
|
||||
pane.setVisibility(this.displayAdvancedStats)
|
||||
if (this.displayAdvancedStats) {
|
||||
const formatBigNumber = (num: number) => {
|
||||
return new Intl.NumberFormat('en-US', {}).format(num)
|
||||
}
|
||||
let text = ''
|
||||
text += `C: ${formatBigNumber(this.renderer.info.render.calls)} `
|
||||
text += `TR: ${formatBigNumber(this.renderer.info.render.triangles)} `
|
||||
text += `TE: ${formatBigNumber(this.renderer.info.memory.textures)} `
|
||||
text += `F: ${formatBigNumber(this.tilesRendered)} `
|
||||
text += `B: ${formatBigNumber(this.blocksRendered)}`
|
||||
pane.updateText(text)
|
||||
this.backendInfoReport = text
|
||||
pane.setVisibility(this.displayStats)
|
||||
if (this.displayStats) {
|
||||
pane.updateText(`C: ${this.renderer.info.render.calls} TR: ${this.renderer.info.render.triangles} TE: ${this.renderer.info.memory.textures} F: ${this.tilesRendered} B: ${this.blocksRendered}`)
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
|
|
@ -349,11 +249,10 @@ export class WorldRendererThree extends WorldRendererCommon {
|
|||
section.renderOrder = 500 - chunkDistance
|
||||
}
|
||||
|
||||
override updateViewerPosition (pos: Vec3): void {
|
||||
this.viewerChunkPosition = pos
|
||||
}
|
||||
|
||||
cameraSectionPositionUpdate () {
|
||||
updateViewerPosition (pos: Vec3): void {
|
||||
this.viewerPosition = pos
|
||||
const cameraPos = this.camera.position.toArray().map(x => Math.floor(x / 16)) as [number, number, number]
|
||||
this.cameraSectionPos = new Vec3(...cameraPos)
|
||||
// eslint-disable-next-line guard-for-in
|
||||
for (const key in this.sectionObjects) {
|
||||
const value = this.sectionObjects[key]
|
||||
|
|
@ -391,12 +290,26 @@ export class WorldRendererThree extends WorldRendererCommon {
|
|||
// this.debugRecomputedDeletedObjects++
|
||||
// }
|
||||
|
||||
// if (!this.initialChunksLoad && this.enableChunksLoadDelay) {
|
||||
// const newPromise = new Promise(resolve => {
|
||||
// if (this.droppedFpsPercentage > 0.5) {
|
||||
// setTimeout(resolve, 1000 / 50 * this.droppedFpsPercentage)
|
||||
// } else {
|
||||
// setTimeout(resolve)
|
||||
// }
|
||||
// })
|
||||
// this.promisesQueue.push(newPromise)
|
||||
// for (const promise of this.promisesQueue) {
|
||||
// await promise
|
||||
// }
|
||||
// }
|
||||
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(data.geometry.positions, 3))
|
||||
geometry.setAttribute('normal', new THREE.BufferAttribute(data.geometry.normals, 3))
|
||||
geometry.setAttribute('color', new THREE.BufferAttribute(data.geometry.colors, 3))
|
||||
geometry.setAttribute('uv', new THREE.BufferAttribute(data.geometry.uvs, 2))
|
||||
geometry.index = new THREE.BufferAttribute(data.geometry.indices as Uint32Array | Uint16Array, 1)
|
||||
geometry.setIndex(data.geometry.indices)
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, this.material)
|
||||
mesh.position.set(data.geometry.sx, data.geometry.sy, data.geometry.sz)
|
||||
|
|
@ -457,7 +370,7 @@ export class WorldRendererThree extends WorldRendererCommon {
|
|||
this.scene.add(object)
|
||||
}
|
||||
|
||||
getSignTexture (position: Vec3, blockEntity, isHanging, backSide = false) {
|
||||
getSignTexture (position: Vec3, blockEntity, backSide = false) {
|
||||
const chunk = chunkPos(position)
|
||||
let textures = this.chunkTextures.get(`${chunk[0]},${chunk[1]}`)
|
||||
if (!textures) {
|
||||
|
|
@ -469,7 +382,7 @@ export class WorldRendererThree extends WorldRendererCommon {
|
|||
if (textures[texturekey]) return textures[texturekey]
|
||||
|
||||
const PrismarineChat = PrismarineChatLoader(this.version)
|
||||
const canvas = renderSign(blockEntity, isHanging, PrismarineChat)
|
||||
const canvas = renderSign(blockEntity, PrismarineChat)
|
||||
if (!canvas) return
|
||||
const tex = new THREE.Texture(canvas)
|
||||
tex.magFilter = THREE.NearestFilter
|
||||
|
|
@ -479,149 +392,15 @@ export class WorldRendererThree extends WorldRendererCommon {
|
|||
return tex
|
||||
}
|
||||
|
||||
getCameraPosition () {
|
||||
const worldPos = new THREE.Vector3()
|
||||
this.camera.getWorldPosition(worldPos)
|
||||
return worldPos
|
||||
}
|
||||
|
||||
getSectionCameraPosition () {
|
||||
const pos = this.getCameraPosition()
|
||||
return new Vec3(
|
||||
Math.floor(pos.x / 16),
|
||||
Math.floor(pos.y / 16),
|
||||
Math.floor(pos.z / 16)
|
||||
)
|
||||
}
|
||||
|
||||
updateCameraSectionPos () {
|
||||
const newSectionPos = this.getSectionCameraPosition()
|
||||
if (!this.cameraSectionPos.equals(newSectionPos)) {
|
||||
this.cameraSectionPos = newSectionPos
|
||||
this.cameraSectionPositionUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
setFirstPersonCamera (pos: Vec3 | null, yaw: number, pitch: number) {
|
||||
const yOffset = this.playerStateReactive.eyeHeight
|
||||
const cam = this.cameraObjectOverride || this.camera
|
||||
const yOffset = this.displayOptions.playerState.getEyeHeight()
|
||||
|
||||
this.camera = cam as THREE.PerspectiveCamera
|
||||
this.updateCamera(pos?.offset(0, yOffset, 0) ?? null, yaw, pitch)
|
||||
this.media.tryIntersectMedia()
|
||||
this.updateCameraSectionPos()
|
||||
}
|
||||
|
||||
getThirdPersonCamera (pos: THREE.Vector3 | null, yaw: number, pitch: number) {
|
||||
pos ??= this.cameraObject.position
|
||||
|
||||
// Calculate camera offset based on perspective
|
||||
const isBack = this.playerStateReactive.perspective === 'third_person_back'
|
||||
const distance = 4 // Default third person distance
|
||||
|
||||
// Calculate direction vector using proper world orientation
|
||||
// We need to get the camera's current look direction and use that for positioning
|
||||
|
||||
// Create a direction vector that represents where the camera is looking
|
||||
// This matches the Three.js camera coordinate system
|
||||
const direction = new THREE.Vector3(0, 0, -1) // Forward direction in camera space
|
||||
|
||||
// Apply the same rotation that's applied to the camera container
|
||||
const pitchQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), pitch)
|
||||
const yawQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), yaw)
|
||||
const finalQuat = new THREE.Quaternion().multiplyQuaternions(yawQuat, pitchQuat)
|
||||
|
||||
// Transform the direction vector by the camera's rotation
|
||||
direction.applyQuaternion(finalQuat)
|
||||
|
||||
// For back view, we want the camera behind the player (opposite to view direction)
|
||||
// For front view, we want the camera in front of the player (same as view direction)
|
||||
if (isBack) {
|
||||
direction.multiplyScalar(-1)
|
||||
}
|
||||
|
||||
// Create debug visualization if advanced stats are enabled
|
||||
if (this.DEBUG_RAYCAST) {
|
||||
this.debugRaycast(pos, direction, distance)
|
||||
}
|
||||
|
||||
// Perform raycast to avoid camera going through blocks
|
||||
const raycaster = new THREE.Raycaster()
|
||||
raycaster.set(pos, direction)
|
||||
raycaster.far = distance // Limit raycast distance
|
||||
|
||||
// Filter to only nearby chunks for performance
|
||||
const nearbyChunks = Object.values(this.sectionObjects)
|
||||
.filter(obj => obj.name === 'chunk' && obj.visible)
|
||||
.filter(obj => {
|
||||
// Get the mesh child which has the actual geometry
|
||||
const mesh = obj.children.find(child => child.name === 'mesh')
|
||||
if (!mesh) return false
|
||||
|
||||
// Check distance from player position to chunk
|
||||
const chunkWorldPos = new THREE.Vector3()
|
||||
mesh.getWorldPosition(chunkWorldPos)
|
||||
const distance = pos.distanceTo(chunkWorldPos)
|
||||
return distance < 80 // Only check chunks within 80 blocks
|
||||
})
|
||||
|
||||
// Get all mesh children for raycasting
|
||||
const meshes: THREE.Object3D[] = []
|
||||
for (const chunk of nearbyChunks) {
|
||||
const mesh = chunk.children.find(child => child.name === 'mesh')
|
||||
if (mesh) meshes.push(mesh)
|
||||
}
|
||||
|
||||
const intersects = raycaster.intersectObjects(meshes, false)
|
||||
|
||||
let finalDistance = distance
|
||||
if (intersects.length > 0) {
|
||||
// Use intersection distance minus a small offset to prevent clipping
|
||||
finalDistance = Math.max(0.5, intersects[0].distance - 0.2)
|
||||
}
|
||||
|
||||
const finalPos = new Vec3(
|
||||
pos.x + direction.x * finalDistance,
|
||||
pos.y + direction.y * finalDistance,
|
||||
pos.z + direction.z * finalDistance
|
||||
)
|
||||
|
||||
return finalPos
|
||||
}
|
||||
|
||||
private debugRaycastHelper?: THREE.ArrowHelper
|
||||
private debugHitPoint?: THREE.Mesh
|
||||
|
||||
private debugRaycast (pos: THREE.Vector3, direction: THREE.Vector3, distance: number) {
|
||||
// Remove existing debug objects
|
||||
if (this.debugRaycastHelper) {
|
||||
this.scene.remove(this.debugRaycastHelper)
|
||||
this.debugRaycastHelper = undefined
|
||||
}
|
||||
if (this.debugHitPoint) {
|
||||
this.scene.remove(this.debugHitPoint)
|
||||
this.debugHitPoint = undefined
|
||||
}
|
||||
|
||||
// Create raycast arrow
|
||||
this.debugRaycastHelper = new THREE.ArrowHelper(
|
||||
direction.clone().normalize(),
|
||||
pos,
|
||||
distance,
|
||||
0xff_00_00, // Red color
|
||||
distance * 0.1,
|
||||
distance * 0.05
|
||||
)
|
||||
this.scene.add(this.debugRaycastHelper)
|
||||
|
||||
// Create hit point indicator
|
||||
const hitGeometry = new THREE.SphereGeometry(0.2, 8, 8)
|
||||
const hitMaterial = new THREE.MeshBasicMaterial({ color: 0x00_ff_00 })
|
||||
this.debugHitPoint = new THREE.Mesh(hitGeometry, hitMaterial)
|
||||
this.debugHitPoint.position.copy(pos).add(direction.clone().multiplyScalar(distance))
|
||||
this.scene.add(this.debugHitPoint)
|
||||
}
|
||||
|
||||
prevFramePerspective = null as string | null
|
||||
|
||||
updateCamera (pos: Vec3 | null, yaw: number, pitch: number): void {
|
||||
// if (this.freeFlyMode) {
|
||||
// pos = this.freeFlyState.position
|
||||
|
|
@ -630,179 +409,46 @@ export class WorldRendererThree extends WorldRendererCommon {
|
|||
// }
|
||||
|
||||
if (pos) {
|
||||
if (this.renderer.xr.isPresenting) {
|
||||
pos.y -= this.camera.position.y // Fix Y position of camera in world
|
||||
}
|
||||
|
||||
this.currentPosTween?.stop()
|
||||
this.currentPosTween = new tweenJs.Tween(this.cameraObject.position).to({ x: pos.x, y: pos.y, z: pos.z }, this.playerStateUtils.isSpectatingEntity() ? 150 : 50).start()
|
||||
new tweenJs.Tween(this.camera.position).to({ x: pos.x, y: pos.y, z: pos.z }, 50).start()
|
||||
// this.freeFlyState.position = pos
|
||||
}
|
||||
|
||||
if (this.playerStateUtils.isSpectatingEntity()) {
|
||||
const rotation = this.cameraShake.getBaseRotation()
|
||||
// wrap in the correct direction
|
||||
let yawOffset = 0
|
||||
const halfPi = Math.PI / 2
|
||||
if (rotation.yaw < halfPi && yaw > Math.PI + halfPi) {
|
||||
yawOffset = -Math.PI * 2
|
||||
} else if (yaw < halfPi && rotation.yaw > Math.PI + halfPi) {
|
||||
yawOffset = Math.PI * 2
|
||||
}
|
||||
this.currentRotTween?.stop()
|
||||
this.currentRotTween = new tweenJs.Tween(rotation).to({ pitch, yaw: yaw + yawOffset }, 100)
|
||||
.onUpdate(params => this.cameraShake.setBaseRotation(params.pitch, params.yaw - yawOffset)).start()
|
||||
} else {
|
||||
this.currentRotTween?.stop()
|
||||
this.cameraShake.setBaseRotation(pitch, yaw)
|
||||
|
||||
const { perspective } = this.playerStateReactive
|
||||
if (perspective === 'third_person_back' || perspective === 'third_person_front') {
|
||||
// Use getThirdPersonCamera for proper raycasting with max distance of 4
|
||||
const currentCameraPos = this.cameraObject.position
|
||||
const thirdPersonPos = this.getThirdPersonCamera(
|
||||
new THREE.Vector3(currentCameraPos.x, currentCameraPos.y, currentCameraPos.z),
|
||||
yaw,
|
||||
pitch
|
||||
)
|
||||
|
||||
const distance = currentCameraPos.distanceTo(new THREE.Vector3(thirdPersonPos.x, thirdPersonPos.y, thirdPersonPos.z))
|
||||
// Apply Z offset based on perspective and calculated distance
|
||||
const zOffset = perspective === 'third_person_back' ? distance : -distance
|
||||
this.camera.position.set(0, 0, zOffset)
|
||||
|
||||
if (perspective === 'third_person_front') {
|
||||
// Flip camera view 180 degrees around Y axis for front view
|
||||
this.camera.rotation.set(0, Math.PI, 0)
|
||||
} else {
|
||||
this.camera.rotation.set(0, 0, 0)
|
||||
}
|
||||
} else {
|
||||
this.camera.position.set(0, 0, 0)
|
||||
this.camera.rotation.set(0, 0, 0)
|
||||
|
||||
// remove any debug raycasting
|
||||
if (this.debugRaycastHelper) {
|
||||
this.scene.remove(this.debugRaycastHelper)
|
||||
this.debugRaycastHelper = undefined
|
||||
}
|
||||
if (this.debugHitPoint) {
|
||||
this.scene.remove(this.debugHitPoint)
|
||||
this.debugHitPoint = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.updateCameraSectionPos()
|
||||
}
|
||||
|
||||
debugChunksVisibilityOverride () {
|
||||
const { chunksRenderAboveOverride, chunksRenderBelowOverride, chunksRenderDistanceOverride, chunksRenderAboveEnabled, chunksRenderBelowEnabled, chunksRenderDistanceEnabled } = this.reactiveDebugParams
|
||||
|
||||
const baseY = this.cameraSectionPos.y * 16
|
||||
|
||||
if (
|
||||
this.displayOptions.inWorldRenderingConfig.enableDebugOverlay &&
|
||||
chunksRenderAboveOverride !== undefined ||
|
||||
chunksRenderBelowOverride !== undefined ||
|
||||
chunksRenderDistanceOverride !== undefined
|
||||
) {
|
||||
for (const [key, object] of Object.entries(this.sectionObjects)) {
|
||||
const [x, y, z] = key.split(',').map(Number)
|
||||
const isVisible =
|
||||
// eslint-disable-next-line no-constant-binary-expression, sonarjs/no-redundant-boolean
|
||||
(chunksRenderAboveEnabled && chunksRenderAboveOverride !== undefined) ? y <= (baseY + chunksRenderAboveOverride) : true &&
|
||||
// eslint-disable-next-line @stylistic/indent-binary-ops, no-constant-binary-expression, sonarjs/no-redundant-boolean
|
||||
(chunksRenderBelowEnabled && chunksRenderBelowOverride !== undefined) ? y >= (baseY - chunksRenderBelowOverride) : true &&
|
||||
// eslint-disable-next-line @stylistic/indent-binary-ops
|
||||
(chunksRenderDistanceEnabled && chunksRenderDistanceOverride !== undefined) ? Math.abs(y - baseY) <= chunksRenderDistanceOverride : true
|
||||
|
||||
object.visible = isVisible
|
||||
}
|
||||
} else {
|
||||
for (const object of Object.values(this.sectionObjects)) {
|
||||
object.visible = true
|
||||
}
|
||||
}
|
||||
this.cameraShake.setBaseRotation(pitch, yaw)
|
||||
}
|
||||
|
||||
render (sizeChanged = false) {
|
||||
if (this.reactiveDebugParams.stopRendering) return
|
||||
this.debugChunksVisibilityOverride()
|
||||
const start = performance.now()
|
||||
this.lastRendered = performance.now()
|
||||
this.cursorBlock.render()
|
||||
this.updateSectionOffsets()
|
||||
|
||||
// Update skybox position to follow camera
|
||||
const cameraPos = this.getCameraPosition()
|
||||
this.skyboxRenderer.update(cameraPos, this.viewDistance)
|
||||
|
||||
const sizeOrFovChanged = sizeChanged || this.displayOptions.inWorldRenderingConfig.fov !== this.camera.fov
|
||||
if (sizeOrFovChanged) {
|
||||
const size = this.renderer.getSize(new THREE.Vector2())
|
||||
this.camera.aspect = size.width / size.height
|
||||
this.camera.aspect = window.innerWidth / window.innerHeight
|
||||
this.camera.fov = this.displayOptions.inWorldRenderingConfig.fov
|
||||
this.camera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
if (!this.reactiveDebugParams.disableEntities) {
|
||||
this.entities.render()
|
||||
}
|
||||
this.entities.render()
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
|
||||
const cam = this.cameraGroupVr instanceof THREE.Group ? this.cameraGroupVr.children.find(child => child instanceof THREE.PerspectiveCamera) as THREE.PerspectiveCamera : this.camera
|
||||
const cam = this.camera instanceof THREE.Group ? this.camera.children.find(child => child instanceof THREE.PerspectiveCamera) as THREE.PerspectiveCamera : this.camera
|
||||
this.renderer.render(this.scene, cam)
|
||||
|
||||
if (
|
||||
this.displayOptions.inWorldRenderingConfig.showHand &&
|
||||
this.playerStateReactive.gameMode !== 'spectator' &&
|
||||
this.playerStateReactive.perspective === 'first_person' &&
|
||||
// !this.freeFlyMode &&
|
||||
!this.renderer.xr.isPresenting
|
||||
) {
|
||||
if (this.displayOptions.inWorldRenderingConfig.showHand/* && !this.freeFlyMode */) {
|
||||
this.holdingBlock.render(this.camera, this.renderer, this.ambientLight, this.directionalLight)
|
||||
this.holdingBlockLeft.render(this.camera, this.renderer, this.ambientLight, this.directionalLight)
|
||||
}
|
||||
|
||||
for (const fountain of this.fountains) {
|
||||
if (this.sectionObjects[fountain.sectionId] && !this.sectionObjects[fountain.sectionId].foutain) {
|
||||
fountain.createParticles(this.sectionObjects[fountain.sectionId])
|
||||
this.sectionObjects[fountain.sectionId].foutain = true
|
||||
}
|
||||
fountain.render()
|
||||
}
|
||||
|
||||
this.waypoints.render()
|
||||
|
||||
for (const onRender of this.onRender) {
|
||||
onRender()
|
||||
}
|
||||
const end = performance.now()
|
||||
const totalTime = end - start
|
||||
this.renderTimeAvgCount++
|
||||
this.renderTimeAvg = ((this.renderTimeAvg * (this.renderTimeAvgCount - 1)) + totalTime) / this.renderTimeAvgCount
|
||||
this.renderTimeMax = Math.max(this.renderTimeMax, totalTime)
|
||||
this.currentRenderedFrames++
|
||||
}
|
||||
|
||||
renderHead (position: Vec3, rotation: number, isWall: boolean, blockEntity) {
|
||||
let textureData: string
|
||||
if (blockEntity.SkullOwner) {
|
||||
textureData = blockEntity.SkullOwner.Properties?.textures?.[0]?.Value
|
||||
} else {
|
||||
textureData = blockEntity.profile?.properties?.find(p => p.name === 'textures')?.value
|
||||
}
|
||||
if (!textureData) return
|
||||
const textures = blockEntity.SkullOwner?.Properties?.textures[0]
|
||||
if (!textures) return
|
||||
|
||||
try {
|
||||
const decodedData = JSON.parse(Buffer.from(textureData, 'base64').toString())
|
||||
let skinUrl = decodedData.textures?.SKIN?.url
|
||||
const { skinTexturesProxy } = this.worldRendererConfig
|
||||
if (skinTexturesProxy) {
|
||||
skinUrl = skinUrl?.replace('http://textures.minecraft.net/', skinTexturesProxy)
|
||||
.replace('https://textures.minecraft.net/', skinTexturesProxy)
|
||||
}
|
||||
const textureData = JSON.parse(Buffer.from(textures.Value, 'base64').toString())
|
||||
const skinUrl = textureData.textures?.SKIN?.url
|
||||
|
||||
const mesh = getMesh(this, skinUrl, armorModel.head)
|
||||
const group = new THREE.Group()
|
||||
|
|
@ -826,7 +472,7 @@ export class WorldRendererThree extends WorldRendererCommon {
|
|||
}
|
||||
|
||||
renderSign (position: Vec3, rotation: number, isWall: boolean, isHanging: boolean, blockEntity) {
|
||||
const tex = this.getSignTexture(position, blockEntity, isHanging)
|
||||
const tex = this.getSignTexture(position, blockEntity)
|
||||
|
||||
if (!tex) return
|
||||
|
||||
|
|
@ -882,6 +528,7 @@ export class WorldRendererThree extends WorldRendererCommon {
|
|||
}
|
||||
|
||||
updateShowChunksBorder (value: boolean) {
|
||||
this.displayOptions.inWorldRenderingConfig.showChunkBorders = value
|
||||
for (const object of Object.values(this.sectionObjects)) {
|
||||
for (const child of object.children) {
|
||||
if (child.name === 'helper') {
|
||||
|
|
@ -897,16 +544,6 @@ export class WorldRendererThree extends WorldRendererCommon {
|
|||
for (const mesh of Object.values(this.sectionObjects)) {
|
||||
this.scene.remove(mesh)
|
||||
}
|
||||
|
||||
// Clean up debug objects
|
||||
if (this.debugRaycastHelper) {
|
||||
this.scene.remove(this.debugRaycastHelper)
|
||||
this.debugRaycastHelper = undefined
|
||||
}
|
||||
if (this.debugHitPoint) {
|
||||
this.scene.remove(this.debugHitPoint)
|
||||
this.debugHitPoint = undefined
|
||||
}
|
||||
}
|
||||
|
||||
getLoadedChunksRelative (pos: Vec3, includeY = false) {
|
||||
|
|
@ -976,76 +613,9 @@ export class WorldRendererThree extends WorldRendererCommon {
|
|||
}
|
||||
}
|
||||
|
||||
worldStop () {
|
||||
this.media.onWorldStop()
|
||||
}
|
||||
|
||||
destroy (): void {
|
||||
removeAllStats()
|
||||
super.destroy()
|
||||
this.skyboxRenderer.dispose()
|
||||
}
|
||||
|
||||
shouldObjectVisible (object: THREE.Object3D) {
|
||||
// Get chunk coordinates
|
||||
const chunkX = Math.floor(object.position.x / 16) * 16
|
||||
const chunkZ = Math.floor(object.position.z / 16) * 16
|
||||
const sectionY = Math.floor(object.position.y / 16) * 16
|
||||
|
||||
const chunkKey = `${chunkX},${chunkZ}`
|
||||
const sectionKey = `${chunkX},${sectionY},${chunkZ}`
|
||||
|
||||
return !!this.finishedChunks[chunkKey] || !!this.sectionObjects[sectionKey]
|
||||
}
|
||||
|
||||
updateSectionOffsets () {
|
||||
const currentTime = performance.now()
|
||||
for (const [key, anim] of Object.entries(this.sectionsOffsetsAnimations)) {
|
||||
const timeDelta = (currentTime - anim.time) / 1000 // Convert to seconds
|
||||
anim.time = currentTime
|
||||
|
||||
// Update offsets based on speed and time delta
|
||||
anim.currentOffsetX += anim.speedX * timeDelta
|
||||
anim.currentOffsetY += anim.speedY * timeDelta
|
||||
anim.currentOffsetZ += anim.speedZ * timeDelta
|
||||
|
||||
// Apply limits if they exist
|
||||
if (anim.limitX !== undefined) {
|
||||
if (anim.speedX > 0) {
|
||||
anim.currentOffsetX = Math.min(anim.currentOffsetX, anim.limitX)
|
||||
} else {
|
||||
anim.currentOffsetX = Math.max(anim.currentOffsetX, anim.limitX)
|
||||
}
|
||||
}
|
||||
if (anim.limitY !== undefined) {
|
||||
if (anim.speedY > 0) {
|
||||
anim.currentOffsetY = Math.min(anim.currentOffsetY, anim.limitY)
|
||||
} else {
|
||||
anim.currentOffsetY = Math.max(anim.currentOffsetY, anim.limitY)
|
||||
}
|
||||
}
|
||||
if (anim.limitZ !== undefined) {
|
||||
if (anim.speedZ > 0) {
|
||||
anim.currentOffsetZ = Math.min(anim.currentOffsetZ, anim.limitZ)
|
||||
} else {
|
||||
anim.currentOffsetZ = Math.max(anim.currentOffsetZ, anim.limitZ)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the offset to the section object
|
||||
const section = this.sectionObjects[key]
|
||||
if (section) {
|
||||
section.position.set(
|
||||
anim.currentOffsetX,
|
||||
anim.currentOffsetY,
|
||||
anim.currentOffsetZ
|
||||
)
|
||||
section.updateMatrix()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reloadWorld () {
|
||||
this.entities.reloadEntities()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1063,16 +633,7 @@ class StarField {
|
|||
}
|
||||
}
|
||||
|
||||
constructor (
|
||||
private readonly worldRenderer: WorldRendererThree
|
||||
) {
|
||||
const clock = new THREE.Clock()
|
||||
const speed = 0.2
|
||||
this.worldRenderer.onRender.push(() => {
|
||||
if (!this.points) return
|
||||
this.points.position.copy(this.worldRenderer.getCameraPosition());
|
||||
(this.points.material as StarfieldMaterial).uniforms.time.value = clock.getElapsedTime() * speed
|
||||
})
|
||||
constructor (private readonly scene: THREE.Scene) {
|
||||
}
|
||||
|
||||
addToScene () {
|
||||
|
|
@ -1083,6 +644,7 @@ class StarField {
|
|||
const count = 7000
|
||||
const factor = 7
|
||||
const saturation = 10
|
||||
const speed = 0.2
|
||||
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
|
||||
|
|
@ -1113,8 +675,13 @@ class StarField {
|
|||
|
||||
// Create points and add them to the scene
|
||||
this.points = new THREE.Points(geometry, material)
|
||||
this.worldRenderer.scene.add(this.points)
|
||||
this.scene.add(this.points)
|
||||
|
||||
const clock = new THREE.Clock()
|
||||
this.points.onBeforeRender = (renderer, scene, camera) => {
|
||||
this.points?.position.copy?.(camera.position)
|
||||
material.uniforms.time.value = clock.getElapsedTime() * speed
|
||||
}
|
||||
this.points.renderOrder = -1
|
||||
}
|
||||
|
||||
|
|
@ -1122,7 +689,7 @@ class StarField {
|
|||
if (this.points) {
|
||||
this.points.geometry.dispose();
|
||||
(this.points.material as THREE.Material).dispose()
|
||||
this.worldRenderer.scene.remove(this.points)
|
||||
this.scene.remove(this.points)
|
||||
|
||||
this.points = undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/// <reference types="./src/env" />
|
||||
import { defineConfig, mergeRsbuildConfig, RsbuildPluginAPI } from '@rsbuild/core'
|
||||
import { pluginReact } from '@rsbuild/plugin-react'
|
||||
import { pluginTypedCSSModules } from '@rsbuild/plugin-typed-css-modules'
|
||||
|
|
@ -15,7 +14,6 @@ import { appAndRendererSharedConfig } from './renderer/rsbuildSharedConfig'
|
|||
import { genLargeDataAliases } from './scripts/genLargeDataAliases'
|
||||
import sharp from 'sharp'
|
||||
import supportedVersions from './src/supportedVersions.mjs'
|
||||
import { startWsServer } from './scripts/wsServer'
|
||||
|
||||
const SINGLE_FILE_BUILD = process.env.SINGLE_FILE_BUILD === 'true'
|
||||
|
||||
|
|
@ -50,7 +48,7 @@ if (fs.existsSync('./assets/release.json')) {
|
|||
|
||||
const configJson = JSON.parse(fs.readFileSync('./config.json', 'utf8'))
|
||||
try {
|
||||
Object.assign(configJson, JSON.parse(fs.readFileSync(process.env.LOCAL_CONFIG_FILE || './config.local.json', 'utf8')))
|
||||
Object.assign(configJson, JSON.parse(fs.readFileSync('./config.local.json', 'utf8')))
|
||||
} catch (err) {}
|
||||
if (dev) {
|
||||
configJson.defaultProxy = ':8080'
|
||||
|
|
@ -60,8 +58,6 @@ const configSource = (SINGLE_FILE_BUILD ? 'BUNDLED' : (process.env.CONFIG_JSON_S
|
|||
|
||||
const faviconPath = 'favicon.png'
|
||||
|
||||
const enableMetrics = process.env.ENABLE_METRICS === 'true'
|
||||
|
||||
// base options are in ./renderer/rsbuildSharedConfig.ts
|
||||
const appConfig = defineConfig({
|
||||
html: {
|
||||
|
|
@ -73,7 +69,7 @@ const appConfig = defineConfig({
|
|||
tag: 'link',
|
||||
attrs: {
|
||||
rel: 'manifest',
|
||||
crossorigin: 'anonymous',
|
||||
crossorigin: 'use-credentials',
|
||||
href: 'manifest.json'
|
||||
},
|
||||
}
|
||||
|
|
@ -115,22 +111,6 @@ const appConfig = defineConfig({
|
|||
js: 'source-map',
|
||||
css: true,
|
||||
},
|
||||
minify: {
|
||||
// js: false,
|
||||
jsOptions: {
|
||||
minimizerOptions: {
|
||||
mangle: {
|
||||
safari10: true,
|
||||
keep_classnames: true,
|
||||
keep_fnames: true,
|
||||
keep_private_props: true,
|
||||
},
|
||||
compress: {
|
||||
unused: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
distPath: SINGLE_FILE_BUILD ? {
|
||||
html: './single',
|
||||
} : undefined,
|
||||
|
|
@ -139,13 +119,6 @@ const appConfig = defineConfig({
|
|||
// 50kb limit for data uri
|
||||
dataUriLimit: SINGLE_FILE_BUILD ? 1 * 1024 * 1024 * 1024 : 50 * 1024
|
||||
},
|
||||
performance: {
|
||||
// prefetch: {
|
||||
// include(filename) {
|
||||
// return filename.includes('mc-data') || filename.includes('mc-assets')
|
||||
// },
|
||||
// },
|
||||
},
|
||||
source: {
|
||||
entry: {
|
||||
index: './src/index.ts',
|
||||
|
|
@ -161,15 +134,12 @@ const appConfig = defineConfig({
|
|||
'process.platform': '"browser"',
|
||||
'process.env.GITHUB_URL':
|
||||
JSON.stringify(`https://github.com/${process.env.GITHUB_REPOSITORY || `${process.env.VERCEL_GIT_REPO_OWNER}/${process.env.VERCEL_GIT_REPO_SLUG}` || githubRepositoryFallback}`),
|
||||
'process.env.ALWAYS_MINIMAL_SERVER_UI': JSON.stringify(process.env.ALWAYS_MINIMAL_SERVER_UI),
|
||||
'process.env.DEPS_VERSIONS': JSON.stringify({}),
|
||||
'process.env.RELEASE_TAG': JSON.stringify(releaseTag),
|
||||
'process.env.RELEASE_LINK': JSON.stringify(releaseLink),
|
||||
'process.env.RELEASE_CHANGELOG': JSON.stringify(releaseChangelog),
|
||||
'process.env.DISABLE_SERVICE_WORKER': JSON.stringify(disableServiceWorker),
|
||||
'process.env.INLINED_APP_CONFIG': JSON.stringify(configSource === 'BUNDLED' ? configJson : null),
|
||||
'process.env.ENABLE_COOKIE_STORAGE': JSON.stringify(process.env.ENABLE_COOKIE_STORAGE || true),
|
||||
'process.env.COOKIE_STORAGE_PREFIX': JSON.stringify(process.env.COOKIE_STORAGE_PREFIX || ''),
|
||||
'process.env.WS_PORT': JSON.stringify(enableMetrics ? 8081 : false),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
|
|
@ -197,21 +167,19 @@ const appConfig = defineConfig({
|
|||
childProcess.execSync('tsx ./scripts/optimizeBlockCollisions.ts', { stdio: 'inherit' })
|
||||
}
|
||||
// childProcess.execSync(['tsx', './scripts/genLargeDataAliases.ts', ...(SINGLE_FILE_BUILD ? ['--compressed'] : [])].join(' '), { stdio: 'inherit' })
|
||||
genLargeDataAliases(SINGLE_FILE_BUILD || process.env.ALWAYS_COMPRESS_LARGE_DATA === 'true')
|
||||
genLargeDataAliases(SINGLE_FILE_BUILD)
|
||||
fsExtra.copySync('./node_modules/mc-assets/dist/other-textures/latest/entity', './dist/textures/entity')
|
||||
fsExtra.copySync('./assets/background', './dist/background')
|
||||
fs.copyFileSync('./assets/favicon.png', './dist/favicon.png')
|
||||
fs.copyFileSync('./assets/playground.html', './dist/playground.html')
|
||||
fs.copyFileSync('./assets/manifest.json', './dist/manifest.json')
|
||||
fs.copyFileSync('./assets/config.html', './dist/config.html')
|
||||
fs.copyFileSync('./assets/debug-inputs.html', './dist/debug-inputs.html')
|
||||
fs.copyFileSync('./assets/loading-bg.jpg', './dist/loading-bg.jpg')
|
||||
if (fs.existsSync('./assets/release.json')) {
|
||||
fs.copyFileSync('./assets/release.json', './dist/release.json')
|
||||
}
|
||||
|
||||
if (configSource === 'REMOTE') {
|
||||
fs.writeFileSync('./dist/config.json', JSON.stringify(configJson, undefined, 2), 'utf8')
|
||||
fs.writeFileSync('./dist/config.json', JSON.stringify(configJson), 'utf8')
|
||||
}
|
||||
if (fs.existsSync('./generated/sounds.js')) {
|
||||
fs.copyFileSync('./generated/sounds.js', './dist/sounds.js')
|
||||
|
|
@ -227,12 +195,6 @@ const appConfig = defineConfig({
|
|||
await execAsync('pnpm run build-mesher')
|
||||
}
|
||||
fs.writeFileSync('./dist/version.txt', buildingVersion, 'utf-8')
|
||||
|
||||
// Start WebSocket server in development
|
||||
if (dev && enableMetrics) {
|
||||
await startWsServer(8081, false)
|
||||
}
|
||||
|
||||
console.timeEnd('total-prep')
|
||||
}
|
||||
if (!dev) {
|
||||
|
|
@ -240,10 +202,6 @@ const appConfig = defineConfig({
|
|||
prep()
|
||||
})
|
||||
build.onAfterBuild(async () => {
|
||||
if (fs.readdirSync('./assets/customTextures').length > 0) {
|
||||
childProcess.execSync('tsx ./scripts/patchAssets.ts', { stdio: 'inherit' })
|
||||
}
|
||||
|
||||
if (SINGLE_FILE_BUILD) {
|
||||
// check that only index.html is in the dist/single folder
|
||||
const singleBuildFiles = fs.readdirSync('./dist/single')
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ exports.getSwAdditionalEntries = () => {
|
|||
'manifest.json',
|
||||
'worldSaveWorker.js',
|
||||
`textures/entity/squid/squid.png`,
|
||||
'sounds.js',
|
||||
// everything but not .map
|
||||
'static/**/!(*.map)',
|
||||
]
|
||||
|
|
|
|||
|
|
@ -16,8 +16,7 @@ export const genLargeDataAliases = async (isCompressed: boolean) => {
|
|||
|
||||
let str = `${decoderCode}\nexport const importLargeData = async (mod: ${Object.keys(modules).map(x => `'${x}'`).join(' | ')}) => {\n`
|
||||
for (const [module, { compressed, raw }] of Object.entries(modules)) {
|
||||
const chunkName = module === 'mcData' ? 'mc-data' : 'mc-assets';
|
||||
let importCode = `(await import(/* webpackChunkName: "${chunkName}" */ '${isCompressed ? compressed : raw}')).default`;
|
||||
let importCode = `(await import('${isCompressed ? compressed : raw}')).default`;
|
||||
if (isCompressed) {
|
||||
importCode = `JSON.parse(decompressFromBase64(${importCode}))`
|
||||
}
|
||||
|
|
@ -31,8 +30,6 @@ export const genLargeDataAliases = async (isCompressed: boolean) => {
|
|||
const decoderCode = /* ts */ `
|
||||
import pako from 'pako';
|
||||
|
||||
globalThis.pako = { inflate: pako.inflate.bind(pako) }
|
||||
|
||||
function decompressFromBase64(input) {
|
||||
console.time('decompressFromBase64')
|
||||
// Decode the Base64 string
|
||||
|
|
|
|||
|
|
@ -15,17 +15,6 @@ const fns = {
|
|||
// set github output
|
||||
setOutput('alias', alias[1])
|
||||
}
|
||||
},
|
||||
getReleasingAlias() {
|
||||
const final = (ver) => `${ver}.mcraft.fun,${ver}.pcm.gg`
|
||||
const releaseJson = JSON.parse(fs.readFileSync('./assets/release.json', 'utf8'))
|
||||
const tag = releaseJson.latestTag
|
||||
const [major, minor, patch] = tag.replace('v', '').split('.')
|
||||
if (major === '0' && minor === '1') {
|
||||
setOutput('alias', final(`v${patch}`))
|
||||
} else {
|
||||
setOutput('alias', final(tag))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import { dirname } from 'node:path'
|
|||
import supportedVersions from '../src/supportedVersions.mjs'
|
||||
import { gzipSizeFromFileSync } from 'gzip-size'
|
||||
import fs from 'fs'
|
||||
import { default as _JsonOptimizer } from '../src/optimizeJson'
|
||||
import { gzipSync } from 'zlib'
|
||||
import {default as _JsonOptimizer} from '../src/optimizeJson'
|
||||
import { gzipSync } from 'zlib';
|
||||
import MinecraftData from 'minecraft-data'
|
||||
import MCProtocol from 'minecraft-protocol'
|
||||
|
||||
|
|
@ -21,12 +21,12 @@ const require = Module.createRequire(import.meta.url)
|
|||
|
||||
const dataPaths = require('minecraft-data/minecraft-data/data/dataPaths.json')
|
||||
|
||||
function toMajor(version) {
|
||||
function toMajor (version) {
|
||||
const [a, b] = (version + '').split('.')
|
||||
return `${a}.${b}`
|
||||
}
|
||||
|
||||
let versions = {}
|
||||
const versions = {}
|
||||
const dataTypes = new Set()
|
||||
|
||||
for (const [version, dataSet] of Object.entries(dataPaths.pc)) {
|
||||
|
|
@ -42,31 +42,6 @@ const versionToNumber = (ver) => {
|
|||
return +`${x.padStart(2, '0')}${y.padStart(2, '0')}${z.padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Version clipping support
|
||||
const minVersion = process.env.MIN_MC_VERSION
|
||||
const maxVersion = process.env.MAX_MC_VERSION
|
||||
|
||||
// Filter versions based on MIN_VERSION and MAX_VERSION if provided
|
||||
if (minVersion || maxVersion) {
|
||||
const filteredVersions = {}
|
||||
const minVersionNum = minVersion ? versionToNumber(minVersion) : 0
|
||||
const maxVersionNum = maxVersion ? versionToNumber(maxVersion) : Infinity
|
||||
|
||||
for (const [version, dataSet] of Object.entries(versions)) {
|
||||
const versionNum = versionToNumber(version)
|
||||
if (versionNum >= minVersionNum && versionNum <= maxVersionNum) {
|
||||
filteredVersions[version] = dataSet
|
||||
}
|
||||
}
|
||||
|
||||
versions = filteredVersions
|
||||
|
||||
console.log(`Version clipping applied: ${minVersion || 'none'} to ${maxVersion || 'none'}`)
|
||||
console.log(`Processing ${Object.keys(versions).length} versions:`, Object.keys(versions).sort((a, b) => versionToNumber(a) - versionToNumber(b)))
|
||||
}
|
||||
|
||||
console.log('Bundling version range:', Object.keys(versions)[0], 'to', Object.keys(versions).at(-1))
|
||||
|
||||
// if not included here (even as {}) will not be bundled & accessible!
|
||||
// const compressedOutput = !!process.env.SINGLE_FILE_BUILD
|
||||
const compressedOutput = true
|
||||
|
|
@ -82,27 +57,22 @@ const dataTypeBundling2 = {
|
|||
}
|
||||
}
|
||||
const dataTypeBundling = {
|
||||
language: process.env.SKIP_MC_DATA_LANGUAGE === 'true' ? {
|
||||
raw: {}
|
||||
} : {
|
||||
language: {
|
||||
ignoreRemoved: true,
|
||||
ignoreChanges: true
|
||||
},
|
||||
blocks: {
|
||||
arrKey: 'name',
|
||||
processData(current, prev, _, version) {
|
||||
processData (current, prev) {
|
||||
for (const block of current) {
|
||||
const prevBlock = prev?.find(x => x.name === block.name)
|
||||
if (block.transparent) {
|
||||
const forceOpaque = block.name.includes('shulker_box') || block.name.match(/^double_.+_slab\d?$/) || ['melon_block', 'lit_pumpkin', 'lit_redstone_ore', 'lit_furnace'].includes(block.name)
|
||||
|
||||
const prevBlock = prev?.find(x => x.name === block.name);
|
||||
if (forceOpaque || (prevBlock && !prevBlock.transparent)) {
|
||||
block.transparent = false
|
||||
}
|
||||
}
|
||||
if (block.hardness === 0 && prevBlock && prevBlock.hardness > 0) {
|
||||
block.hardness = prevBlock.hardness
|
||||
}
|
||||
}
|
||||
}
|
||||
// ignoreRemoved: true,
|
||||
|
|
@ -166,9 +136,7 @@ const dataTypeBundling = {
|
|||
blockLoot: {
|
||||
arrKey: 'block'
|
||||
},
|
||||
recipes: process.env.SKIP_MC_DATA_RECIPES === 'true' ? {
|
||||
raw: {}
|
||||
} : {
|
||||
recipes: {
|
||||
raw: true
|
||||
// processData: processRecipes
|
||||
},
|
||||
|
|
@ -182,7 +150,7 @@ const dataTypeBundling = {
|
|||
// }
|
||||
}
|
||||
|
||||
function processRecipes(current, prev, getData, version) {
|
||||
function processRecipes (current, prev, getData, version) {
|
||||
// can require the same multiple times per different versions
|
||||
if (current._proccessed) return
|
||||
const items = getData('items')
|
||||
|
|
@ -274,39 +242,30 @@ for (const [i, [version, dataSet]] of versionsArr.reverse().entries()) {
|
|||
for (const [dataType, dataPath] of Object.entries(dataSet)) {
|
||||
const config = dataTypeBundling[dataType]
|
||||
if (!config) continue
|
||||
const ignoreCollisionShapes = dataType === 'blockCollisionShapes' && versionToNumber(version) >= versionToNumber('1.13')
|
||||
|
||||
if (dataType === 'blockCollisionShapes' && versionToNumber(version) >= versionToNumber('1.13')) {
|
||||
// contents += ` get ${dataType} () { return window.globalGetCollisionShapes?.("${version}") },\n`
|
||||
continue
|
||||
}
|
||||
let injectCode = ''
|
||||
const getRealData = (type) => {
|
||||
const getData = (type) => {
|
||||
const loc = `minecraft-data/data/${dataSet[type]}/`
|
||||
const dataPathAbsolute = require.resolve(`minecraft-data/${loc}${type}`)
|
||||
// const data = fs.readFileSync(dataPathAbsolute, 'utf8')
|
||||
const dataRaw = require(dataPathAbsolute)
|
||||
return dataRaw
|
||||
}
|
||||
const dataRaw = getRealData(dataType)
|
||||
const dataRaw = getData(dataType)
|
||||
let rawData = dataRaw
|
||||
if (config.raw) {
|
||||
rawDataVersions[dataType] ??= {}
|
||||
rawDataVersions[dataType][version] = rawData
|
||||
if (config.raw === true) {
|
||||
rawData = dataRaw
|
||||
} else {
|
||||
rawData = config.raw
|
||||
}
|
||||
|
||||
if (ignoreCollisionShapes && dataType === 'blockCollisionShapes') {
|
||||
rawData = {
|
||||
blocks: {},
|
||||
shapes: {}
|
||||
}
|
||||
}
|
||||
rawData = dataRaw
|
||||
} else {
|
||||
if (!diffSources[dataType]) {
|
||||
diffSources[dataType] = new JsonOptimizer(config.arrKey, config.ignoreChanges, config.ignoreRemoved)
|
||||
}
|
||||
try {
|
||||
config.processData?.(dataRaw, previousData[dataType], getRealData, version)
|
||||
config.processData?.(dataRaw, previousData[dataType], getData, version)
|
||||
diffSources[dataType].recordDiff(version, dataRaw)
|
||||
injectCode = `restoreDiff(sources, ${JSON.stringify(dataType)}, ${JSON.stringify(version)})`
|
||||
} catch (err) {
|
||||
|
|
@ -338,16 +297,16 @@ console.log('total size (mb)', totalSize / 1024 / 1024)
|
|||
console.log(
|
||||
'size per data type (mb, %)',
|
||||
Object.fromEntries(Object.entries(sizePerDataType).map(([dataType, size]) => {
|
||||
return [dataType, [size / 1024 / 1024, Math.round(size / totalSize * 100)]]
|
||||
return [dataType, [size / 1024 / 1024, Math.round(size / totalSize * 100)]];
|
||||
}).sort((a, b) => {
|
||||
//@ts-ignore
|
||||
return b[1][1] - a[1][1]
|
||||
return b[1][1] - a[1][1];
|
||||
}))
|
||||
)
|
||||
|
||||
function compressToBase64(input) {
|
||||
const buffer = gzipSync(input)
|
||||
return buffer.toString('base64')
|
||||
const buffer = gzipSync(input);
|
||||
return buffer.toString('base64');
|
||||
}
|
||||
|
||||
const filePath = './generated/minecraft-data-optimized.json'
|
||||
|
|
@ -371,7 +330,6 @@ console.log('size', fs.lstatSync(filePath).size / 1000 / 1000, gzipSizeFromFileS
|
|||
|
||||
const { defaultVersion } = MCProtocol
|
||||
const data = MinecraftData(defaultVersion)
|
||||
console.log('defaultVersion', defaultVersion, !!data)
|
||||
const initialMcData = {
|
||||
[defaultVersion]: {
|
||||
version: data.version,
|
||||
|
|
|
|||
|
|
@ -1,137 +0,0 @@
|
|||
import blocksAtlas from 'mc-assets/dist/blocksAtlases.json'
|
||||
import itemsAtlas from 'mc-assets/dist/itemsAtlases.json'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import sharp from 'sharp'
|
||||
|
||||
interface AtlasFile {
|
||||
latest: {
|
||||
suSv: number
|
||||
tileSize: number
|
||||
width: number
|
||||
height: number
|
||||
textures: {
|
||||
[key: string]: {
|
||||
u: number
|
||||
v: number
|
||||
su: number
|
||||
sv: number
|
||||
tileIndex: number
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function patchTextureAtlas(
|
||||
atlasType: 'blocks' | 'items',
|
||||
atlasData: AtlasFile,
|
||||
customTexturesDir: string,
|
||||
distDir: string
|
||||
) {
|
||||
// Check if custom textures directory exists and has files
|
||||
if (!fs.existsSync(customTexturesDir) || fs.readdirSync(customTexturesDir).length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Find the latest atlas file
|
||||
const atlasFiles = fs.readdirSync(distDir)
|
||||
.filter(file => file.startsWith(`${atlasType}AtlasLatest`) && file.endsWith('.png'))
|
||||
.sort()
|
||||
|
||||
if (atlasFiles.length === 0) {
|
||||
console.log(`No ${atlasType}AtlasLatest.png found in ${distDir}`)
|
||||
return
|
||||
}
|
||||
|
||||
const latestAtlasFile = atlasFiles[atlasFiles.length - 1]
|
||||
const atlasPath = path.join(distDir, latestAtlasFile)
|
||||
console.log(`Patching ${atlasPath}`)
|
||||
|
||||
// Get atlas dimensions
|
||||
const atlasMetadata = await sharp(atlasPath).metadata()
|
||||
if (!atlasMetadata.width || !atlasMetadata.height) {
|
||||
throw new Error(`Failed to get atlas dimensions for ${atlasPath}`)
|
||||
}
|
||||
|
||||
// Process each custom texture
|
||||
const customTextureFiles = fs.readdirSync(customTexturesDir)
|
||||
.filter(file => file.endsWith('.png'))
|
||||
|
||||
if (customTextureFiles.length === 0) return
|
||||
|
||||
// Prepare composite operations
|
||||
const composites: sharp.OverlayOptions[] = []
|
||||
|
||||
for (const textureFile of customTextureFiles) {
|
||||
const textureName = path.basename(textureFile, '.png')
|
||||
|
||||
if (atlasData.latest.textures[textureName]) {
|
||||
const textureData = atlasData.latest.textures[textureName]
|
||||
const customTexturePath = path.join(customTexturesDir, textureFile)
|
||||
|
||||
try {
|
||||
// Convert UV coordinates to pixel coordinates
|
||||
const x = Math.round(textureData.u * atlasMetadata.width)
|
||||
const y = Math.round(textureData.v * atlasMetadata.height)
|
||||
const width = Math.round((textureData.su ?? atlasData.latest.suSv) * atlasMetadata.width)
|
||||
const height = Math.round((textureData.sv ?? atlasData.latest.suSv) * atlasMetadata.height)
|
||||
|
||||
// Resize custom texture to match atlas dimensions and add to composite operations
|
||||
const resizedTextureBuffer = await sharp(customTexturePath)
|
||||
.resize(width, height, {
|
||||
fit: 'fill',
|
||||
kernel: 'nearest' // Preserve pixel art quality
|
||||
})
|
||||
.png()
|
||||
.toBuffer()
|
||||
|
||||
composites.push({
|
||||
input: resizedTextureBuffer,
|
||||
left: x,
|
||||
top: y,
|
||||
blend: 'over'
|
||||
})
|
||||
|
||||
console.log(`Prepared ${textureName} at (${x}, ${y}) with size (${width}, ${height})`)
|
||||
} catch (error) {
|
||||
console.error(`Failed to prepare ${textureName}:`, error)
|
||||
}
|
||||
} else {
|
||||
console.warn(`Texture ${textureName} not found in ${atlasType} atlas`)
|
||||
}
|
||||
}
|
||||
|
||||
if (composites.length > 0) {
|
||||
// Apply all patches at once using Sharp's composite
|
||||
await sharp(atlasPath)
|
||||
.composite(composites)
|
||||
.png()
|
||||
.toFile(atlasPath + '.tmp')
|
||||
|
||||
// Replace original with patched version
|
||||
fs.renameSync(atlasPath + '.tmp', atlasPath)
|
||||
console.log(`Saved patched ${atlasType} atlas to ${atlasPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const customBlocksDir = './assets/customTextures/blocks'
|
||||
const customItemsDir = './assets/customTextures/items'
|
||||
const distDir = './dist/static/image'
|
||||
|
||||
try {
|
||||
// Patch blocks atlas
|
||||
await patchTextureAtlas('blocks', blocksAtlas as unknown as AtlasFile, customBlocksDir, distDir)
|
||||
|
||||
// Patch items atlas
|
||||
await patchTextureAtlas('items', itemsAtlas as unknown as AtlasFile, customItemsDir, distDir)
|
||||
|
||||
console.log('Texture atlas patching completed!')
|
||||
} catch (error) {
|
||||
console.error('Failed to patch texture atlases:', error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script
|
||||
main()
|
||||
|
|
@ -11,12 +11,7 @@ import supportedVersions from '../src/supportedVersions.mjs'
|
|||
|
||||
const __dirname = path.dirname(fileURLToPath(new URL(import.meta.url)))
|
||||
|
||||
export const versionToNumber = (ver) => {
|
||||
const [x, y = '0', z = '0'] = ver.split('.')
|
||||
return +`${x.padStart(2, '0')}${y.padStart(2, '0')}${z.padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const targetedVersions = [...supportedVersions].sort((a, b) => versionToNumber(b) - versionToNumber(a))
|
||||
const targetedVersions = supportedVersions.reverse()
|
||||
|
||||
/** @type {{name, size, hash}[]} */
|
||||
let prevSounds = null
|
||||
|
|
@ -178,36 +173,13 @@ const writeSoundsMap = async () => {
|
|||
|
||||
// todo REMAP ONLY IDS. Do diffs, as mostly only ids are changed between versions
|
||||
// const localTargetedVersions = targetedVersions.slice(0, 2)
|
||||
let lastMappingsJson
|
||||
const localTargetedVersions = targetedVersions
|
||||
for (const targetedVersion of [...localTargetedVersions].reverse()) {
|
||||
console.log('Processing version', targetedVersion)
|
||||
|
||||
for (const targetedVersion of localTargetedVersions) {
|
||||
const burgerData = await fetch(burgerDataUrl(targetedVersion)).then((r) => r.json()).catch((err) => {
|
||||
// console.error('error fetching burger data', targetedVersion, err)
|
||||
console.error('error fetching burger data', targetedVersion, err)
|
||||
return null
|
||||
})
|
||||
/** @type {{sounds: string[]}} */
|
||||
const mappingJson = await fetch(`https://raw.githubusercontent.com/ViaVersion/Mappings/7a45c1f9dbc1f1fdadacfecdb205ba84e55766fc/mappings/mapping-${targetedVersion}.json`).then(async (r) => {
|
||||
return r.json()
|
||||
// lastMappingsJson = r.status === 404 ? lastMappingsJson : (await r.json())
|
||||
// if (r.status === 404) {
|
||||
// console.warn('using prev mappings json for ' + targetedVersion)
|
||||
// }
|
||||
// return lastMappingsJson
|
||||
}).catch((err) => {
|
||||
// console.error('error fetching mapping json', targetedVersion, err)
|
||||
return null
|
||||
})
|
||||
// if (!mappingJson) throw new Error('no initial mapping json for ' + targetedVersion)
|
||||
if (burgerData && !mappingJson) {
|
||||
console.warn('has burger but no mapping json for ' + targetedVersion)
|
||||
continue
|
||||
}
|
||||
if (!mappingJson || !burgerData) {
|
||||
console.warn('no mapping json or burger data for ' + targetedVersion)
|
||||
continue
|
||||
}
|
||||
if (!burgerData) continue
|
||||
const allSoundsMap = getSoundsMap(burgerData)
|
||||
// console.log(Object.keys(sounds).length, 'ids')
|
||||
const outputIdMap = {}
|
||||
|
|
@ -218,7 +190,7 @@ const writeSoundsMap = async () => {
|
|||
new: 0,
|
||||
same: 0
|
||||
}
|
||||
for (const { _id, subtitle, sounds, name } of Object.values(allSoundsMap)) {
|
||||
for (const { id, subtitle, sounds, name } of Object.values(allSoundsMap)) {
|
||||
if (!sounds?.length /* && !subtitle */) continue
|
||||
const firstName = sounds[0].name
|
||||
// const includeSound = isSoundWhitelisted(firstName)
|
||||
|
|
@ -238,11 +210,6 @@ const writeSoundsMap = async () => {
|
|||
if (sound.weight && isNaN(sound.weight)) debugger
|
||||
outputUseSoundLine.push(`${sound.volume ?? 1};${sound.name};${sound.weight ?? minWeight}`)
|
||||
}
|
||||
const id = mappingJson.sounds.findIndex(x => x === name)
|
||||
if (id === -1) {
|
||||
console.warn('no id for sound', name, targetedVersion)
|
||||
continue
|
||||
}
|
||||
const key = `${id};${name}`
|
||||
outputIdMap[key] = outputUseSoundLine.join(',')
|
||||
if (prevMap && prevMap[key]) {
|
||||
|
|
@ -316,6 +283,6 @@ if (action) {
|
|||
} else {
|
||||
// downloadAllSoundsAndCreateMap()
|
||||
// convertSounds()
|
||||
writeSoundsMap()
|
||||
// makeSoundsBundle()
|
||||
// writeSoundsMap()
|
||||
makeSoundsBundle()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
import WebSocket from 'ws'
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
return `${(bytes).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
function formatTime(ms: number) {
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
const ws = new WebSocket('ws://localhost:8081')
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log('Connected to metrics server, waiting for metrics...')
|
||||
})
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const metrics = JSON.parse(data.toString())
|
||||
console.log('\nPerformance Metrics:')
|
||||
console.log('------------------')
|
||||
console.log(`Load Time: ${formatTime(metrics.loadTime)}`)
|
||||
console.log(`Memory Usage: ${formatBytes(metrics.memoryUsage)}`)
|
||||
console.log(`Timestamp: ${new Date(metrics.timestamp).toLocaleString()}`)
|
||||
if (!process.argv.includes('-f')) { // follow mode
|
||||
process.exit(0)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing metrics:', error)
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
// Exit if no metrics received after 5 seconds
|
||||
setTimeout(() => {
|
||||
console.error('Timeout waiting for metrics')
|
||||
process.exit(1)
|
||||
}, 5000)
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import yaml from 'yaml'
|
||||
import { execSync } from 'child_process'
|
||||
import { createInterface } from 'readline'
|
||||
|
||||
interface LockfilePackage {
|
||||
specifier: string
|
||||
version: string
|
||||
}
|
||||
|
||||
interface Lockfile {
|
||||
importers: {
|
||||
'.': {
|
||||
dependencies?: Record<string, LockfilePackage>
|
||||
devDependencies?: Record<string, LockfilePackage>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface PackageJson {
|
||||
pnpm?: {
|
||||
updateConfig?: {
|
||||
ignoreDependencies?: string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function prompt(question: string): Promise<string> {
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
})
|
||||
|
||||
return new Promise(resolve => {
|
||||
rl.question(question, answer => {
|
||||
rl.close()
|
||||
resolve(answer.toLowerCase().trim())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function getLatestCommit(owner: string, repo: string): Promise<string> {
|
||||
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/commits/HEAD`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch latest commit: ${response.statusText}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
return data.sha
|
||||
}
|
||||
|
||||
function extractGitInfo(specifier: string): { owner: string; repo: string; branch: string } | null {
|
||||
const match = specifier.match(/github:([^/]+)\/([^#]+)(?:#(.+))?/)
|
||||
if (!match) return null
|
||||
return {
|
||||
owner: match[1],
|
||||
repo: match[2],
|
||||
branch: match[3] || 'master'
|
||||
}
|
||||
}
|
||||
|
||||
function extractCommitHash(version: string): string | null {
|
||||
const match = version.match(/https:\/\/codeload\.github\.com\/[^/]+\/[^/]+\/tar\.gz\/([a-f0-9]+)/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
function getIgnoredDependencies(): string[] {
|
||||
try {
|
||||
const packageJsonPath = path.join(process.cwd(), 'package.json')
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as PackageJson
|
||||
return packageJson.pnpm?.updateConfig?.ignoreDependencies || []
|
||||
} catch (error) {
|
||||
console.warn('Failed to read package.json for ignored dependencies:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const lockfilePath = path.join(process.cwd(), 'pnpm-lock.yaml')
|
||||
const lockfileContent = fs.readFileSync(lockfilePath, 'utf8')
|
||||
const lockfile = yaml.parse(lockfileContent) as Lockfile
|
||||
|
||||
const ignoredDependencies = new Set(getIgnoredDependencies())
|
||||
console.log('Ignoring dependencies:', Array.from(ignoredDependencies).join(', ') || 'none')
|
||||
|
||||
const dependencies = {
|
||||
...lockfile.importers['.'].dependencies,
|
||||
...lockfile.importers['.'].devDependencies
|
||||
}
|
||||
|
||||
const updates: Array<{
|
||||
name: string
|
||||
currentHash: string
|
||||
latestHash: string
|
||||
gitInfo: ReturnType<typeof extractGitInfo>
|
||||
}> = []
|
||||
|
||||
console.log('\nChecking git dependencies...')
|
||||
for (const [name, pkg] of Object.entries(dependencies)) {
|
||||
if (ignoredDependencies.has(name)) {
|
||||
console.log(`Skipping ignored dependency: ${name}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!pkg.specifier.startsWith('github:')) continue
|
||||
|
||||
const gitInfo = extractGitInfo(pkg.specifier)
|
||||
if (!gitInfo) continue
|
||||
|
||||
const currentHash = extractCommitHash(pkg.version)
|
||||
if (!currentHash) continue
|
||||
|
||||
try {
|
||||
process.stdout.write(`Checking ${name}... `)
|
||||
const latestHash = await getLatestCommit(gitInfo.owner, gitInfo.repo)
|
||||
if (currentHash !== latestHash) {
|
||||
console.log('update available')
|
||||
updates.push({ name, currentHash, latestHash, gitInfo })
|
||||
} else {
|
||||
console.log('up to date')
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('failed')
|
||||
console.error(`Error checking ${name}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
console.log('\nAll git dependencies are up to date!')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('\nThe following git dependencies can be updated:')
|
||||
for (const update of updates) {
|
||||
console.log(`\n${update.name}:`)
|
||||
console.log(` Current: ${update.currentHash}`)
|
||||
console.log(` Latest: ${update.latestHash}`)
|
||||
console.log(` Repo: ${update.gitInfo!.owner}/${update.gitInfo!.repo}`)
|
||||
}
|
||||
|
||||
const answer = await prompt('\nWould you like to update these dependencies? (y/N): ')
|
||||
if (answer === 'y' || answer === 'yes') {
|
||||
let newLockfileContent = lockfileContent
|
||||
for (const update of updates) {
|
||||
newLockfileContent = newLockfileContent.replace(
|
||||
new RegExp(update.currentHash, 'g'),
|
||||
update.latestHash
|
||||
)
|
||||
}
|
||||
fs.writeFileSync(lockfilePath, newLockfileContent)
|
||||
console.log('\nUpdated pnpm-lock.yaml with new commit hashes')
|
||||
// console.log('Running pnpm install to apply changes...')
|
||||
// execSync('pnpm install', { stdio: 'inherit' })
|
||||
console.log('Done!')
|
||||
} else {
|
||||
console.log('\nNo changes were made.')
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import {WebSocketServer} from 'ws'
|
||||
|
||||
export function startWsServer(port: number = 8081, tryOtherPort: boolean = true): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tryPort = (currentPort: number) => {
|
||||
const wss = new WebSocketServer({ port: currentPort })
|
||||
.on('listening', () => {
|
||||
console.log(`WebSocket server started on port ${currentPort}`)
|
||||
resolve(currentPort)
|
||||
})
|
||||
.on('error', (err: any) => {
|
||||
if (err.code === 'EADDRINUSE' && tryOtherPort) {
|
||||
console.log(`Port ${currentPort} in use, trying ${currentPort + 1}`)
|
||||
wss.close()
|
||||
tryPort(currentPort + 1)
|
||||
} else {
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
console.log('Client connected')
|
||||
|
||||
ws.on('message', (message) => {
|
||||
try {
|
||||
// Simply relay the message to all connected clients except sender
|
||||
wss.clients.forEach(client => {
|
||||
if (client !== ws && client.readyState === WebSocket.OPEN) {
|
||||
client.send(message.toString())
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error processing message:', error)
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('Client disconnected')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
tryPort(port)
|
||||
})
|
||||
}
|
||||
16
server.js
16
server.js
|
|
@ -16,23 +16,9 @@ try {
|
|||
const app = express()
|
||||
|
||||
const isProd = process.argv.includes('--prod') || process.env.NODE_ENV === 'production'
|
||||
const timeoutIndex = process.argv.indexOf('--timeout')
|
||||
let timeout = timeoutIndex > -1 && timeoutIndex + 1 < process.argv.length
|
||||
? parseInt(process.argv[timeoutIndex + 1])
|
||||
: process.env.TIMEOUT
|
||||
? parseInt(process.env.TIMEOUT)
|
||||
: 10000
|
||||
if (isNaN(timeout) || timeout < 0) {
|
||||
console.warn('Invalid timeout value provided, using default of 10000ms')
|
||||
timeout = 10000
|
||||
}
|
||||
app.use(compression())
|
||||
app.use(cors())
|
||||
app.use(netApi({
|
||||
allowOrigin: '*',
|
||||
log: process.argv.includes('--log') || process.env.LOG === 'true',
|
||||
timeout
|
||||
}))
|
||||
app.use(netApi({ allowOrigin: '*' }))
|
||||
if (!isProd) {
|
||||
app.use('/sounds', express.static(path.join(__dirname, './generated/sounds/')))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,7 @@
|
|||
import { defaultsDeep } from 'lodash'
|
||||
import { disabledSettings, options, qsOptions } from './optionsStorage'
|
||||
import { miscUiState } from './globalState'
|
||||
import { setLoadingScreenStatus } from './appStatus'
|
||||
import { setStorageDataOnAppConfigLoad } from './react/appStorageProvider'
|
||||
import { customKeymaps, updateBinds } from './controls'
|
||||
|
||||
export type CustomAction = {
|
||||
readonly type: string
|
||||
readonly input: readonly any[]
|
||||
}
|
||||
|
||||
export type ActionType = string | CustomAction
|
||||
|
||||
export type ActionHoldConfig = {
|
||||
readonly command: ActionType
|
||||
readonly longPressAction?: ActionType
|
||||
readonly duration?: number
|
||||
readonly threshold?: number
|
||||
}
|
||||
|
||||
export type MobileButtonConfig = {
|
||||
readonly label?: string
|
||||
readonly icon?: string
|
||||
readonly action?: ActionType
|
||||
readonly actionHold?: ActionType | ActionHoldConfig
|
||||
readonly iconStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
export type AppConfig = {
|
||||
// defaultHost?: string
|
||||
|
|
@ -35,34 +11,19 @@ export type AppConfig = {
|
|||
// defaultVersion?: string
|
||||
peerJsServer?: string
|
||||
peerJsServerFallback?: string
|
||||
promoteServers?: Array<{ ip, description, name?, version?, }>
|
||||
promoteServers?: Array<{ ip, description, version? }>
|
||||
mapsProvider?: string
|
||||
|
||||
appParams?: Record<string, any> // query string params
|
||||
rightSideText?: string
|
||||
|
||||
defaultSettings?: Record<string, any>
|
||||
forceSettings?: Record<string, boolean>
|
||||
// hideSettings?: Record<string, boolean>
|
||||
allowAutoConnect?: boolean
|
||||
splashText?: string
|
||||
splashTextFallback?: string
|
||||
pauseLinks?: Array<Array<Record<string, any>>>
|
||||
mobileButtons?: MobileButtonConfig[]
|
||||
keybindings?: Record<string, any>
|
||||
defaultLanguage?: string
|
||||
displayLanguageSelector?: boolean
|
||||
supportedLanguages?: string[]
|
||||
showModsButton?: boolean
|
||||
defaultUsername?: string
|
||||
skinTexturesProxy?: string
|
||||
alwaysReconnectButton?: boolean
|
||||
reportBugButtonWithReconnect?: boolean
|
||||
disabledCommands?: string[] // Array of command IDs to disable (e.g. ['general.jump', 'general.chat'])
|
||||
}
|
||||
|
||||
export const loadAppConfig = (appConfig: AppConfig) => {
|
||||
|
||||
if (miscUiState.appConfig) {
|
||||
Object.assign(miscUiState.appConfig, appConfig)
|
||||
} else {
|
||||
|
|
@ -74,7 +35,7 @@ export const loadAppConfig = (appConfig: AppConfig) => {
|
|||
if (value) {
|
||||
disabledSettings.value.add(key)
|
||||
// since the setting is forced, we need to set it to that value
|
||||
if (appConfig.defaultSettings && key in appConfig.defaultSettings && !qsOptions[key]) {
|
||||
if (appConfig.defaultSettings?.[key] && !qsOptions[key]) {
|
||||
options[key] = appConfig.defaultSettings[key]
|
||||
}
|
||||
} else {
|
||||
|
|
@ -82,16 +43,8 @@ export const loadAppConfig = (appConfig: AppConfig) => {
|
|||
}
|
||||
}
|
||||
}
|
||||
// todo apply defaultSettings to defaults even if not forced in case of remote config
|
||||
|
||||
if (appConfig.keybindings) {
|
||||
Object.assign(customKeymaps, defaultsDeep(appConfig.keybindings, customKeymaps))
|
||||
updateBinds(customKeymaps)
|
||||
}
|
||||
|
||||
appViewer?.appConfigUdpate()
|
||||
|
||||
setStorageDataOnAppConfigLoad(appConfig)
|
||||
setStorageDataOnAppConfigLoad()
|
||||
}
|
||||
|
||||
export const isBundledConfigUsed = !!process.env.INLINED_APP_CONFIG
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ export type AppQsParams = {
|
|||
username?: string
|
||||
lockConnect?: string
|
||||
autoConnect?: string
|
||||
alwaysReconnect?: string
|
||||
// googledrive.ts params
|
||||
state?: string
|
||||
// ServersListProvider.tsx params
|
||||
|
|
@ -43,11 +42,6 @@ export type AppQsParams = {
|
|||
suggest_save?: string
|
||||
noPacketsValidation?: string
|
||||
testCrashApp?: string
|
||||
onlyConnect?: string
|
||||
connectText?: string
|
||||
freezeSettings?: string
|
||||
testIosCrash?: string
|
||||
addPing?: string
|
||||
|
||||
// Replay params
|
||||
replayFilter?: string
|
||||
|
|
@ -57,13 +51,6 @@ export type AppQsParams = {
|
|||
replayStopOnError?: string
|
||||
replaySkipMissingOnTimeout?: string
|
||||
replayPacketsSenderDelay?: string
|
||||
|
||||
// Benchmark params
|
||||
openBenchmark?: string
|
||||
renderDistance?: string
|
||||
downloadBenchmark?: string
|
||||
benchmarkMapZipUrl?: string
|
||||
benchmarkPosition?: string
|
||||
}
|
||||
|
||||
export type AppQsParamsArray = {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { resetStateAfterDisconnect } from './browserfs'
|
||||
import { hideModal, activeModalStack, showModal, miscUiState } from './globalState'
|
||||
import { appStatusState, resetAppStatusState } from './react/AppStatusProvider'
|
||||
|
||||
let ourLastStatus: string | undefined = ''
|
||||
export const setLoadingScreenStatus = function (status: string | undefined | null, isError = false, hideDots = false, fromFlyingSquid = false, minecraftJsonMessage?: Record<string, any>) {
|
||||
if (typeof status === 'string') status = window.translateText?.(status) ?? status
|
||||
// null can come from flying squid, should restore our last status
|
||||
if (status === null) {
|
||||
status = ourLastStatus
|
||||
|
|
@ -26,6 +24,7 @@ export const setLoadingScreenStatus = function (status: string | undefined | nul
|
|||
}
|
||||
showModal({ reactType: 'app-status' })
|
||||
if (appStatusState.isError) {
|
||||
miscUiState.gameLoaded = false
|
||||
return
|
||||
}
|
||||
appStatusState.hideDots = hideDots
|
||||
|
|
@ -33,9 +32,5 @@ export const setLoadingScreenStatus = function (status: string | undefined | nul
|
|||
appStatusState.lastStatus = isError ? appStatusState.status : ''
|
||||
appStatusState.status = status
|
||||
appStatusState.minecraftJsonMessage = minecraftJsonMessage ?? null
|
||||
|
||||
if (isError && miscUiState.gameLoaded) {
|
||||
resetStateAfterDisconnect()
|
||||
}
|
||||
}
|
||||
globalThis.setLoadingScreenStatus = setLoadingScreenStatus
|
||||
|
|
|
|||
212
src/appViewer.ts
212
src/appViewer.ts
|
|
@ -1,30 +1,24 @@
|
|||
import { WorldDataEmitter, WorldDataEmitterWorker } from 'renderer/viewer/lib/worldDataEmitter'
|
||||
import { getInitialPlayerState, PlayerStateRenderer, PlayerStateReactive } from 'renderer/viewer/lib/basePlayerState'
|
||||
import { WorldDataEmitter } from 'renderer/viewer/lib/worldDataEmitter'
|
||||
import { BasePlayerState, IPlayerState } from 'renderer/viewer/lib/basePlayerState'
|
||||
import { subscribeKey } from 'valtio/utils'
|
||||
import { defaultWorldRendererConfig, WorldRendererConfig } from 'renderer/viewer/lib/worldrendererCommon'
|
||||
import { Vec3 } from 'vec3'
|
||||
import { SoundSystem } from 'renderer/viewer/three/threeJsSound'
|
||||
import { proxy, subscribe } from 'valtio'
|
||||
import { proxy } from 'valtio'
|
||||
import { getDefaultRendererState } from 'renderer/viewer/baseGraphicsBackend'
|
||||
import { getSyncWorld } from 'renderer/playground/shared'
|
||||
import { MaybePromise } from 'contro-max/build/types/store'
|
||||
import { PANORAMA_VERSION } from 'renderer/viewer/three/panoramaShared'
|
||||
import { playerState } from './mineflayer/playerState'
|
||||
import { createNotificationProgressReporter, ProgressReporter } from './core/progressReporter'
|
||||
import { setLoadingScreenStatus } from './appStatus'
|
||||
import { activeModalStack, miscUiState } from './globalState'
|
||||
import { options } from './optionsStorage'
|
||||
import { ResourcesManager, ResourcesManagerTransferred } from './resourcesManager'
|
||||
import { ResourcesManager } from './resourcesManager'
|
||||
import { watchOptionsAfterWorldViewInit } from './watchOptions'
|
||||
import { loadMinecraftData } from './connect'
|
||||
import { reloadChunks } from './utils'
|
||||
import { displayClientChat } from './botUtils'
|
||||
|
||||
export interface RendererReactiveState {
|
||||
world: {
|
||||
chunksLoaded: Set<string>
|
||||
// chunksTotalNumber: number
|
||||
heightmaps: Map<string, Uint8Array>
|
||||
chunksLoaded: string[]
|
||||
chunksTotalNumber: number
|
||||
allChunksLoaded: boolean
|
||||
mesherWork: boolean
|
||||
intersectMedia: { id: string, x: number, y: number } | null
|
||||
|
|
@ -34,8 +28,11 @@ export interface RendererReactiveState {
|
|||
}
|
||||
export interface NonReactiveState {
|
||||
world: {
|
||||
chunksLoaded: Set<string>
|
||||
chunksLoaded: string[]
|
||||
chunksTotalNumber: number
|
||||
allChunksLoaded: boolean
|
||||
mesherWork: boolean
|
||||
intersectMedia: { id: string, x: number, y: number } | null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -44,41 +41,31 @@ export interface GraphicsBackendConfig {
|
|||
powerPreference?: 'high-performance' | 'low-power'
|
||||
statsVisible?: number
|
||||
sceneBackground: string
|
||||
timeoutRendering?: boolean
|
||||
}
|
||||
|
||||
const defaultGraphicsBackendConfig: GraphicsBackendConfig = {
|
||||
fpsLimit: undefined,
|
||||
powerPreference: undefined,
|
||||
sceneBackground: 'lightblue',
|
||||
timeoutRendering: false
|
||||
sceneBackground: 'lightblue'
|
||||
}
|
||||
|
||||
export interface GraphicsInitOptions<S = any> {
|
||||
resourcesManager: ResourcesManagerTransferred
|
||||
export interface GraphicsInitOptions {
|
||||
resourcesManager: ResourcesManager
|
||||
config: GraphicsBackendConfig
|
||||
rendererSpecificSettings: S
|
||||
|
||||
callbacks: {
|
||||
displayCriticalError: (error: Error) => void
|
||||
setRendererSpecificSettings: (key: string, value: any) => void
|
||||
|
||||
fireCustomEvent: (eventName: string, ...args: any[]) => void
|
||||
}
|
||||
displayCriticalError: (error: Error) => void
|
||||
}
|
||||
|
||||
export interface DisplayWorldOptions {
|
||||
version: string
|
||||
worldView: WorldDataEmitterWorker
|
||||
worldView: WorldDataEmitter
|
||||
inWorldRenderingConfig: WorldRendererConfig
|
||||
playerStateReactive: PlayerStateReactive
|
||||
playerState: IPlayerState
|
||||
rendererState: RendererReactiveState
|
||||
nonReactiveState: NonReactiveState
|
||||
}
|
||||
|
||||
export type GraphicsBackendLoader = ((options: GraphicsInitOptions) => MaybePromise<GraphicsBackend>) & {
|
||||
id: string
|
||||
}
|
||||
export type GraphicsBackendLoader = (options: GraphicsInitOptions) => GraphicsBackend
|
||||
|
||||
// no sync methods
|
||||
export interface GraphicsBackend {
|
||||
|
|
@ -86,7 +73,7 @@ export interface GraphicsBackend {
|
|||
displayName?: string
|
||||
startPanorama: () => void
|
||||
// prepareResources: (version: string, progressReporter: ProgressReporter) => Promise<void>
|
||||
startWorld: (options: DisplayWorldOptions) => Promise<void> | void
|
||||
startWorld: (options: DisplayWorldOptions) => void
|
||||
disconnect: () => void
|
||||
setRendering: (rendering: boolean) => void
|
||||
getDebugOverlay?: () => Record<string, any>
|
||||
|
|
@ -98,8 +85,6 @@ export interface GraphicsBackend {
|
|||
}
|
||||
|
||||
export class AppViewer {
|
||||
waitBackendLoadPromises = [] as Array<Promise<void>>
|
||||
|
||||
resourcesManager = new ResourcesManager()
|
||||
worldView: WorldDataEmitter | undefined
|
||||
readonly config: GraphicsBackendConfig = {
|
||||
|
|
@ -116,8 +101,8 @@ export class AppViewer {
|
|||
inWorldRenderingConfig: WorldRendererConfig = proxy(defaultWorldRendererConfig)
|
||||
lastCamUpdate = 0
|
||||
playerState = playerState
|
||||
rendererState = getDefaultRendererState().reactive
|
||||
nonReactiveState: NonReactiveState = getDefaultRendererState().nonReactive
|
||||
rendererState = proxy(getDefaultRendererState())
|
||||
nonReactiveState: NonReactiveState = getDefaultRendererState()
|
||||
worldReady: Promise<void>
|
||||
private resolveWorldReady: () => void
|
||||
|
||||
|
|
@ -125,40 +110,21 @@ export class AppViewer {
|
|||
this.disconnectBackend()
|
||||
}
|
||||
|
||||
async loadBackend (loader: GraphicsBackendLoader) {
|
||||
loadBackend (loader: GraphicsBackendLoader) {
|
||||
if (this.backend) {
|
||||
this.disconnectBackend()
|
||||
}
|
||||
|
||||
await Promise.all(this.waitBackendLoadPromises)
|
||||
this.waitBackendLoadPromises = []
|
||||
|
||||
this.backendLoader = loader
|
||||
const rendererSpecificSettings = {} as Record<string, any>
|
||||
const rendererSettingsKey = `renderer.${this.backendLoader?.id}`
|
||||
for (const key in options) {
|
||||
if (key.startsWith(rendererSettingsKey)) {
|
||||
rendererSpecificSettings[key.slice(rendererSettingsKey.length + 1)] = options[key]
|
||||
}
|
||||
}
|
||||
const loaderOptions: GraphicsInitOptions = { // todo!
|
||||
resourcesManager: this.resourcesManager as ResourcesManagerTransferred,
|
||||
const loaderOptions: GraphicsInitOptions = {
|
||||
resourcesManager: this.resourcesManager,
|
||||
config: this.config,
|
||||
callbacks: {
|
||||
displayCriticalError (error) {
|
||||
console.error(error)
|
||||
setLoadingScreenStatus(error.message, true)
|
||||
},
|
||||
setRendererSpecificSettings (key: string, value: any) {
|
||||
options[`${rendererSettingsKey}.${key}`] = value
|
||||
},
|
||||
fireCustomEvent (eventName, ...args) {
|
||||
// this.callbacks.fireCustomEvent(eventName, ...args)
|
||||
}
|
||||
displayCriticalError (error) {
|
||||
console.error(error)
|
||||
setLoadingScreenStatus(error.message, true)
|
||||
},
|
||||
rendererSpecificSettings,
|
||||
}
|
||||
this.backend = await loader(loaderOptions)
|
||||
this.backend = loader(loaderOptions)
|
||||
|
||||
// if (this.resourcesManager.currentResources) {
|
||||
// void this.prepareResources(this.resourcesManager.currentResources.version, createNotificationProgressReporter())
|
||||
|
|
@ -166,90 +132,57 @@ export class AppViewer {
|
|||
|
||||
// Execute queued action if exists
|
||||
if (this.currentState) {
|
||||
if (this.currentState.method === 'startPanorama') {
|
||||
this.startPanorama()
|
||||
} else {
|
||||
const { method, args } = this.currentState
|
||||
this.backend[method](...args)
|
||||
if (method === 'startWorld') {
|
||||
void this.worldView!.init(bot.entity.position)
|
||||
// void this.worldView!.init(args[0].playerState.getPosition())
|
||||
}
|
||||
const { method, args } = this.currentState
|
||||
this.backend[method](...args)
|
||||
if (method === 'startWorld') {
|
||||
void this.worldView!.init(args[0].playerState.getPosition())
|
||||
}
|
||||
}
|
||||
|
||||
// todo
|
||||
modalStackUpdateChecks()
|
||||
}
|
||||
|
||||
async startWithBot () {
|
||||
const renderDistance = miscUiState.singleplayer ? options.renderDistance : options.multiplayerRenderDistance
|
||||
await this.startWorld(bot.world, renderDistance)
|
||||
this.worldView!.listenToBot(bot)
|
||||
}
|
||||
|
||||
appConfigUdpate () {
|
||||
if (miscUiState.appConfig) {
|
||||
this.inWorldRenderingConfig.skinTexturesProxy = miscUiState.appConfig.skinTexturesProxy
|
||||
}
|
||||
}
|
||||
|
||||
async startWorld (world, renderDistance: number, playerStateSend: PlayerStateRenderer = this.playerState.reactive) {
|
||||
startWorld (world, renderDistance: number, playerStateSend: IPlayerState = this.playerState) {
|
||||
if (this.currentDisplay === 'world') throw new Error('World already started')
|
||||
this.currentDisplay = 'world'
|
||||
const startPosition = bot.entity?.position ?? new Vec3(0, 64, 0)
|
||||
const startPosition = playerStateSend.getPosition()
|
||||
this.worldView = new WorldDataEmitter(world, renderDistance, startPosition)
|
||||
this.worldView.panicChunksReload = () => {
|
||||
if (!options.experimentalClientSelfReload) return
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
displayClientChat(`[client] client panicked due to too long loading time. Soft reloading chunks...`)
|
||||
}
|
||||
void reloadChunks()
|
||||
}
|
||||
window.worldView = this.worldView
|
||||
watchOptionsAfterWorldViewInit(this.worldView)
|
||||
this.appConfigUdpate()
|
||||
|
||||
const displayWorldOptions: DisplayWorldOptions = {
|
||||
version: this.resourcesManager.currentConfig!.version,
|
||||
worldView: this.worldView,
|
||||
inWorldRenderingConfig: this.inWorldRenderingConfig,
|
||||
playerStateReactive: playerStateSend,
|
||||
playerState: playerStateSend,
|
||||
rendererState: this.rendererState,
|
||||
nonReactiveState: this.nonReactiveState
|
||||
}
|
||||
let promise: undefined | Promise<void>
|
||||
if (this.backend) {
|
||||
promise = this.backend.startWorld(displayWorldOptions) ?? undefined
|
||||
// void this.worldView.init(startPosition)
|
||||
this.backend.startWorld(displayWorldOptions)
|
||||
void this.worldView.init(startPosition)
|
||||
}
|
||||
this.currentState = { method: 'startWorld', args: [displayWorldOptions] }
|
||||
|
||||
await promise
|
||||
// Resolve the promise after world is started
|
||||
this.resolveWorldReady()
|
||||
return !!promise
|
||||
}
|
||||
|
||||
resetBackend (cleanState = false) {
|
||||
this.disconnectBackend(cleanState)
|
||||
if (cleanState) {
|
||||
this.currentState = undefined
|
||||
this.currentDisplay = null
|
||||
this.worldView = undefined
|
||||
}
|
||||
if (this.backendLoader) {
|
||||
void this.loadBackend(this.backendLoader)
|
||||
this.loadBackend(this.backendLoader)
|
||||
}
|
||||
}
|
||||
|
||||
startPanorama () {
|
||||
if (this.currentDisplay === 'menu') return
|
||||
this.currentDisplay = 'menu'
|
||||
if (options.disableAssets) return
|
||||
if (this.backend && !hasAppStatus()) {
|
||||
this.currentDisplay = 'menu'
|
||||
if (process.env.SINGLE_FILE_BUILD_MODE) {
|
||||
void loadMinecraftData(PANORAMA_VERSION).then(() => {
|
||||
this.backend?.startPanorama()
|
||||
})
|
||||
} else {
|
||||
this.backend.startPanorama()
|
||||
}
|
||||
if (this.backend) {
|
||||
this.backend.startPanorama()
|
||||
}
|
||||
this.currentState = { method: 'startPanorama', args: [] }
|
||||
}
|
||||
|
|
@ -265,12 +198,7 @@ export class AppViewer {
|
|||
this.resourcesManager.destroy()
|
||||
}
|
||||
|
||||
disconnectBackend (cleanState = false) {
|
||||
if (cleanState) {
|
||||
this.currentState = undefined
|
||||
this.currentDisplay = null
|
||||
this.worldView = undefined
|
||||
}
|
||||
disconnectBackend () {
|
||||
if (this.backend) {
|
||||
this.backend.disconnect()
|
||||
this.backend = undefined
|
||||
|
|
@ -279,8 +207,7 @@ export class AppViewer {
|
|||
const { promise, resolve } = Promise.withResolvers<void>()
|
||||
this.worldReady = promise
|
||||
this.resolveWorldReady = resolve
|
||||
this.rendererState = proxy(getDefaultRendererState().reactive)
|
||||
this.nonReactiveState = getDefaultRendererState().nonReactive
|
||||
Object.assign(this.rendererState, getDefaultRendererState())
|
||||
// this.queuedDisplay = undefined
|
||||
}
|
||||
|
||||
|
|
@ -301,7 +228,6 @@ export class AppViewer {
|
|||
}
|
||||
}
|
||||
|
||||
// do not import this. Use global appViewer instead (without window prefix).
|
||||
export const appViewer = new AppViewer()
|
||||
window.appViewer = appViewer
|
||||
|
||||
|
|
@ -309,46 +235,32 @@ const initialMenuStart = async () => {
|
|||
if (appViewer.currentDisplay === 'world') {
|
||||
appViewer.resetBackend(true)
|
||||
}
|
||||
const demo = new URLSearchParams(window.location.search).get('demo')
|
||||
if (!demo) {
|
||||
appViewer.startPanorama()
|
||||
return
|
||||
}
|
||||
appViewer.startPanorama()
|
||||
|
||||
// const version = '1.18.2'
|
||||
const version = '1.21.4'
|
||||
const { loadMinecraftData } = await import('./connect')
|
||||
const { getSyncWorld } = await import('../renderer/playground/shared')
|
||||
await loadMinecraftData(version)
|
||||
const world = getSyncWorld(version)
|
||||
world.setBlockStateId(new Vec3(0, 64, 0), loadedData.blocksByName.water.defaultState)
|
||||
world.setBlockStateId(new Vec3(1, 64, 0), loadedData.blocksByName.water.defaultState)
|
||||
world.setBlockStateId(new Vec3(1, 64, 1), loadedData.blocksByName.water.defaultState)
|
||||
world.setBlockStateId(new Vec3(0, 64, 1), loadedData.blocksByName.water.defaultState)
|
||||
world.setBlockStateId(new Vec3(-1, 64, -1), loadedData.blocksByName.water.defaultState)
|
||||
world.setBlockStateId(new Vec3(-1, 64, 0), loadedData.blocksByName.water.defaultState)
|
||||
world.setBlockStateId(new Vec3(0, 64, -1), loadedData.blocksByName.water.defaultState)
|
||||
appViewer.resourcesManager.currentConfig = { version }
|
||||
appViewer.playerState.reactive = getInitialPlayerState()
|
||||
await appViewer.resourcesManager.updateAssetsData({})
|
||||
await appViewer.startWorld(world, 3)
|
||||
appViewer.backend!.updateCamera(new Vec3(0, 65.7, 0), 0, -Math.PI / 2) // Y+1 and pitch = PI/2 to look down
|
||||
void appViewer.worldView!.init(new Vec3(0, 64, 0))
|
||||
// await appViewer.resourcesManager.loadMcData('1.21.4')
|
||||
// const world = getSyncWorld('1.21.4')
|
||||
// world.setBlockStateId(new Vec3(0, 64, 0), 1)
|
||||
// appViewer.resourcesManager.currentConfig = { version: '1.21.4' }
|
||||
// await appViewer.resourcesManager.updateAssetsData({})
|
||||
// appViewer.playerState = new BasePlayerState() as any
|
||||
// appViewer.startWorld(world, 3)
|
||||
// appViewer.backend?.updateCamera(new Vec3(0, 64, 2), 0, 0)
|
||||
// void appViewer.worldView!.init(new Vec3(0, 64, 0))
|
||||
}
|
||||
window.initialMenuStart = initialMenuStart
|
||||
|
||||
const hasAppStatus = () => activeModalStack.some(m => m.reactType === 'app-status')
|
||||
|
||||
const modalStackUpdateChecks = () => {
|
||||
// maybe start panorama
|
||||
if (!miscUiState.gameLoaded && !hasAppStatus()) {
|
||||
if (activeModalStack.length === 0 && !miscUiState.gameLoaded) {
|
||||
void initialMenuStart()
|
||||
}
|
||||
|
||||
if (appViewer.backend) {
|
||||
appViewer.backend.setRendering(!hasAppStatus())
|
||||
const hasAppStatus = activeModalStack.some(m => m.reactType === 'app-status')
|
||||
appViewer.backend.setRendering(!hasAppStatus)
|
||||
}
|
||||
|
||||
appViewer.inWorldRenderingConfig.foreground = activeModalStack.length === 0
|
||||
}
|
||||
subscribe(activeModalStack, modalStackUpdateChecks)
|
||||
subscribeKey(activeModalStack, 'length', modalStackUpdateChecks)
|
||||
modalStackUpdateChecks()
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
import { subscribeKey } from 'valtio/utils'
|
||||
import createGraphicsBackend from 'renderer/viewer/three/graphicsBackend'
|
||||
import { options } from './optionsStorage'
|
||||
import { appViewer } from './appViewer'
|
||||
import { miscUiState } from './globalState'
|
||||
import { watchOptionsAfterViewerInit } from './watchOptions'
|
||||
import { showNotification } from './react/NotificationProvider'
|
||||
|
||||
const backends = [
|
||||
createGraphicsBackend,
|
||||
]
|
||||
const loadBackend = async () => {
|
||||
let backend = backends.find(backend => backend.id === options.activeRenderer)
|
||||
if (!backend) {
|
||||
showNotification(`No backend found for renderer ${options.activeRenderer}`, `Falling back to ${backends[0].id}`, true)
|
||||
backend = backends[0]
|
||||
}
|
||||
await appViewer.loadBackend(backend)
|
||||
}
|
||||
window.loadBackend = loadBackend
|
||||
if (process.env.SINGLE_FILE_BUILD_MODE) {
|
||||
const unsub = subscribeKey(miscUiState, 'fsReady', () => {
|
||||
if (miscUiState.fsReady) {
|
||||
// don't do it earlier to load fs and display menu faster
|
||||
void loadBackend()
|
||||
unsub()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
void loadBackend()
|
||||
})
|
||||
}
|
||||
|
||||
const animLoop = () => {
|
||||
for (const fn of beforeRenderFrame) fn()
|
||||
requestAnimationFrame(animLoop)
|
||||
}
|
||||
requestAnimationFrame(animLoop)
|
||||
|
||||
watchOptionsAfterViewerInit()
|
||||
|
||||
// reset backend when renderer changes
|
||||
|
||||
subscribeKey(options, 'activeRenderer', async () => {
|
||||
if (appViewer.currentDisplay === 'world' && bot) {
|
||||
appViewer.resetBackend(true)
|
||||
await loadBackend()
|
||||
void appViewer.startWithBot()
|
||||
}
|
||||
})
|
||||
|
|
@ -7,12 +7,7 @@ let audioContext: AudioContext
|
|||
const sounds: Record<string, any> = {}
|
||||
|
||||
// Track currently playing sounds and their gain nodes
|
||||
const activeSounds: Array<{
|
||||
source: AudioBufferSourceNode;
|
||||
gainNode: GainNode;
|
||||
volumeMultiplier: number;
|
||||
isMusic: boolean;
|
||||
}> = []
|
||||
const activeSounds: Array<{ source: AudioBufferSourceNode; gainNode: GainNode; volumeMultiplier: number }> = []
|
||||
window.activeSounds = activeSounds
|
||||
|
||||
// load as many resources on page load as possible instead on demand as user can disable internet connection after he thinks the page is loaded
|
||||
|
|
@ -48,7 +43,7 @@ export async function loadSound (path: string, contents = path) {
|
|||
}
|
||||
}
|
||||
|
||||
export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = options.remoteSoundsLoadTimeout, loop = false, isMusic = false) => {
|
||||
export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = 500) => {
|
||||
const soundBuffer = sounds[url]
|
||||
if (!soundBuffer) {
|
||||
const start = Date.now()
|
||||
|
|
@ -56,11 +51,11 @@ export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = option
|
|||
if (cancelled || Date.now() - start > loadTimeout) return
|
||||
}
|
||||
|
||||
return playSound(url, soundVolume, loop, isMusic)
|
||||
return playSound(url, soundVolume)
|
||||
}
|
||||
|
||||
export async function playSound (url, soundVolume = 1, loop = false, isMusic = false) {
|
||||
const volume = soundVolume * (options.volume / 100) * (isMusic ? options.musicVolume / 100 : 1)
|
||||
export async function playSound (url, soundVolume = 1) {
|
||||
const volume = soundVolume * (options.volume / 100)
|
||||
|
||||
if (!volume) return
|
||||
|
||||
|
|
@ -80,14 +75,13 @@ export async function playSound (url, soundVolume = 1, loop = false, isMusic = f
|
|||
const gainNode = audioContext.createGain()
|
||||
const source = audioContext.createBufferSource()
|
||||
source.buffer = soundBuffer
|
||||
source.loop = loop
|
||||
source.connect(gainNode)
|
||||
gainNode.connect(audioContext.destination)
|
||||
gainNode.gain.value = volume
|
||||
source.start(0)
|
||||
|
||||
// Add to active sounds
|
||||
activeSounds.push({ source, gainNode, volumeMultiplier: soundVolume, isMusic })
|
||||
activeSounds.push({ source, gainNode, volumeMultiplier: soundVolume })
|
||||
|
||||
const callbacks = [] as Array<() => void>
|
||||
source.onended = () => {
|
||||
|
|
@ -105,17 +99,6 @@ export async function playSound (url, soundVolume = 1, loop = false, isMusic = f
|
|||
onEnded (callback: () => void) {
|
||||
callbacks.push(callback)
|
||||
},
|
||||
stop () {
|
||||
try {
|
||||
source.stop()
|
||||
// Remove from active sounds
|
||||
const index = activeSounds.findIndex(s => s.source === source)
|
||||
if (index !== -1) activeSounds.splice(index, 1)
|
||||
} catch (err) {
|
||||
console.warn('Failed to stop sound:', err)
|
||||
}
|
||||
},
|
||||
gainNode,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -130,24 +113,11 @@ export function stopAllSounds () {
|
|||
activeSounds.length = 0
|
||||
}
|
||||
|
||||
export function stopSound (url: string) {
|
||||
const soundIndex = activeSounds.findIndex(s => s.source.buffer === sounds[url])
|
||||
if (soundIndex !== -1) {
|
||||
const { source } = activeSounds[soundIndex]
|
||||
try {
|
||||
source.stop()
|
||||
} catch (err) {
|
||||
console.warn('Failed to stop sound:', err)
|
||||
}
|
||||
activeSounds.splice(soundIndex, 1)
|
||||
}
|
||||
}
|
||||
|
||||
export function changeVolumeOfCurrentlyPlayingSounds (newVolume: number, newMusicVolume: number) {
|
||||
export function changeVolumeOfCurrentlyPlayingSounds (newVolume: number) {
|
||||
const normalizedVolume = newVolume / 100
|
||||
for (const { gainNode, volumeMultiplier, isMusic } of activeSounds) {
|
||||
for (const { gainNode, volumeMultiplier } of activeSounds) {
|
||||
try {
|
||||
gainNode.gain.value = normalizedVolume * volumeMultiplier * (isMusic ? newMusicVolume / 100 : 1)
|
||||
gainNode.gain.value = normalizedVolume * volumeMultiplier
|
||||
} catch (err) {
|
||||
console.warn('Failed to change sound volume:', err)
|
||||
}
|
||||
|
|
@ -155,9 +125,5 @@ export function changeVolumeOfCurrentlyPlayingSounds (newVolume: number, newMusi
|
|||
}
|
||||
|
||||
subscribeKey(options, 'volume', () => {
|
||||
changeVolumeOfCurrentlyPlayingSounds(options.volume, options.musicVolume)
|
||||
})
|
||||
|
||||
subscribeKey(options, 'musicVolume', () => {
|
||||
changeVolumeOfCurrentlyPlayingSounds(options.volume, options.musicVolume)
|
||||
changeVolumeOfCurrentlyPlayingSounds(options.volume)
|
||||
})
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue