This commit is contained in:
Vitaly 2025-03-29 11:41:54 +03:00 committed by GitHub
commit a3cb14ee1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
148 changed files with 4484 additions and 2244 deletions

View file

@ -102,6 +102,7 @@
// "@stylistic/multiline-ternary": "error", // not needed
// "@stylistic/newline-per-chained-call": "error", // not sure if needed
"@stylistic/new-parens": "error",
"@typescript-eslint/class-literal-property-style": "off",
"@stylistic/no-confusing-arrow": "error",
"@stylistic/wrap-iife": "error",
"@stylistic/space-before-blocks": "error",

View file

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

View file

@ -52,6 +52,19 @@ jobs:
with:
node-version: 22
cache: "pnpm"
- name: Update deployAlwaysUpdate packages
run: |
if [ -f package.json ]; then
PACKAGES=$(node -e "const pkg = require('./package.json'); if (pkg.deployAlwaysUpdate) console.log(pkg.deployAlwaysUpdate.join(' '))")
if [ ! -z "$PACKAGES" ]; then
echo "Updating packages: $PACKAGES"
pnpm up -L $PACKAGES
else
echo "No deployAlwaysUpdate packages found in package.json"
fi
else
echo "package.json not found"
fi
- name: Install Global Dependencies
run: pnpm add -g vercel
- name: Pull Vercel Environment Information

2
.gitignore vendored
View file

@ -10,7 +10,7 @@ localSettings.mjs
dist*
.DS_Store
.idea/
world
/world
data*.json
out
*.iml

View file

@ -24,6 +24,7 @@ For building the project yourself / contributing, see [Development, Debugging &
- Custom protocol channel extensions (eg for custom block models in the world)
- Play with friends over internet! (P2P is powered by Peer.js discovery servers)
- ~~Google Drive support for reading / saving worlds back to the cloud~~
- Support for custom rendering 3D engines. Modular architecture.
- even even more!
All components that are in [Storybook](https://mcraft.fun/storybook) are published as npm module and can be used in other projects: [`minecraft-react`](https://npmjs.com/minecraft-react)

View file

@ -6,16 +6,19 @@
"peerJsServer": "",
"peerJsServerFallback": "https://p2p.mcraft.fun",
"promoteServers": [
{
"ip": "wss://mcraft.ryzyn.xyz",
"version": "1.19.4"
},
{
"ip": "wss://play.mcraft.fun"
},
{
"ip": "wss://ws.fuchsmc.net"
},
{
"ip": "wss://play2.mcraft.fun"
},
{
"ip": "wss://mcraft.ryzyn.xyz",
"version": "1.19.4"
},
{
"ip": "kaboom.pw",
"version": "1.20.3",

View file

@ -150,8 +150,8 @@
"http-browserify": "^1.7.0",
"http-server": "^14.1.1",
"https-browserify": "^1.0.0",
"mc-assets": "^0.2.42",
"mineflayer-mouse": "^0.1.2",
"mc-assets": "^0.2.48",
"mineflayer-mouse": "^0.1.7",
"minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next",
"mineflayer": "github:zardoy/mineflayer",
"mineflayer-pathfinder": "^2.4.4",

87
pnpm-lock.yaml generated
View file

@ -151,7 +151,7 @@ importers:
version: 2.0.4
net-browserify:
specifier: github:zardoy/prismarinejs-net-browserify
version: https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/624cc67c16f5e8b23b772e7eaabae16ba84b8590
version: https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/29cb893a3dd40c663b1b9b34fbecdbf967b089d4
node-gzip:
specifier: ^1.1.2
version: 1.1.2
@ -353,17 +353,17 @@ importers:
specifier: ^1.0.0
version: 1.0.0
mc-assets:
specifier: ^0.2.42
version: 0.2.42
specifier: ^0.2.48
version: 0.2.48
minecraft-inventory-gui:
specifier: github:zardoy/minecraft-inventory-gui#next
version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/75e940a4cd50d89e0ba03db3733d5d704917a3c8(@types/react@18.2.20)(react@18.2.0)
version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/69dee6aa15f7c8a6df2a2ab01972e6a7b2f9ee30(@types/react@18.2.20)(react@18.2.0)
mineflayer:
specifier: github:zardoy/mineflayer
version: https://codeload.github.com/zardoy/mineflayer/tar.gz/06e3050ddf4d9aa655fea6e2bed182937a81705d(encoding@0.1.13)
mineflayer-mouse:
specifier: ^0.1.2
version: 0.1.2(@types/debug@4.1.12)(@types/node@22.8.1)(terser@5.31.3)(tsx@4.7.0)(yaml@2.4.1)
specifier: ^0.1.7
version: 0.1.7(@types/debug@4.1.12)(@types/node@22.8.1)(terser@5.31.3)(tsx@4.7.0)(yaml@2.4.1)
mineflayer-pathfinder:
specifier: ^2.4.4
version: 2.4.4
@ -5497,6 +5497,10 @@ packages:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
from@0.1.7:
resolution: {integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==}
@ -6690,8 +6694,8 @@ packages:
maxrects-packer@2.7.3:
resolution: {integrity: sha512-bG6qXujJ1QgttZVIH4WDanhoJtvbud/xP/XPyf6A69C9RdA61BM4TomFALCq2nrTa+tARRIBB4LuIFsnUQU2wA==}
mc-assets@0.2.42:
resolution: {integrity: sha512-j2D1RNYtB5Z9gFu9MVjyDBbiALI0mWZ3xW/A3PPefVAHm3HJ2T1vH+1XBov1spBGPl7u+Zo7mRXza3X0egbeOg==}
mc-assets@0.2.48:
resolution: {integrity: sha512-ixFBAkdWuluBZ3RhWXvD+KyLX5jKAK8ksXJamAuJxc7nXHP6xK5rEAR3qQ7JVYh27USl3mE+GWTFy/aF0CGRYg==}
engines: {node: '>=18.0.0'}
mcraft-fun-mineflayer@0.1.14:
@ -6861,10 +6865,18 @@ packages:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-db@1.54.0:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
mime-types@3.0.1:
resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==}
engines: {node: '>= 0.6'}
mime@1.6.0:
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
engines: {node: '>=4'}
@ -6900,8 +6912,8 @@ packages:
minecraft-folder-path@1.2.0:
resolution: {integrity: sha512-qaUSbKWoOsH9brn0JQuBhxNAzTDMwrOXorwuRxdJKKKDYvZhtml+6GVCUrY5HRiEsieBEjCUnhVpDuQiKsiFaw==}
minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/75e940a4cd50d89e0ba03db3733d5d704917a3c8:
resolution: {tarball: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/75e940a4cd50d89e0ba03db3733d5d704917a3c8}
minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/69dee6aa15f7c8a6df2a2ab01972e6a7b2f9ee30:
resolution: {tarball: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/69dee6aa15f7c8a6df2a2ab01972e6a7b2f9ee30}
version: 1.0.1
minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/3bd4dc1b2002cd7badfa5b9cf8dda35cd6cc9ac1:
@ -6925,8 +6937,8 @@ packages:
resolution: {tarball: https://codeload.github.com/zardoy/mineflayer-item-map-downloader/tar.gz/a8d210ecdcf78dd082fa149a96e1612cc9747824}
version: 1.2.0
mineflayer-mouse@0.1.2:
resolution: {integrity: sha512-QPGEXkF9PurZEpRq0xakKE8SV6sMY/6kCM9cdMeFbtq95IpYeh8ZJdD/twX2A3g3s8MooxlGovfxbpeHdWcOEQ==}
mineflayer-mouse@0.1.7:
resolution: {integrity: sha512-sUX77P8Z94N6N/KEvplZdXLwtuCHLx3lmU4Y1FB8189+jFCl3jH/+bdzwtksObmrXWW+evJ5nAPpzmOOxqcfag==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
mineflayer-pathfinder@2.4.4:
@ -7095,8 +7107,8 @@ packages:
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
net-browserify@https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/624cc67c16f5e8b23b772e7eaabae16ba84b8590:
resolution: {tarball: https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/624cc67c16f5e8b23b772e7eaabae16ba84b8590}
net-browserify@https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/29cb893a3dd40c663b1b9b34fbecdbf967b089d4:
resolution: {tarball: https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/29cb893a3dd40c663b1b9b34fbecdbf967b089d4}
version: 0.2.4
nice-try@1.0.5:
@ -7239,8 +7251,8 @@ packages:
resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
engines: {node: '>= 0.4'}
object.entries@1.1.8:
resolution: {integrity: sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==}
object.entries@1.1.9:
resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==}
engines: {node: '>= 0.4'}
object.fromentries@2.0.8:
@ -7657,6 +7669,9 @@ packages:
prismarine-nbt@2.5.0:
resolution: {integrity: sha512-F0/8UAa9SDDnAGrBYqZc4nG8h2zj5cE2eAJU5xlDR/IsQQ3moVxkOjE3h3nMv6SbvZrvAcgX7waA/nd9LLHYdA==}
prismarine-nbt@2.7.0:
resolution: {integrity: sha512-Du9OLQAcCj3y29YtewOJbbV4ARaSUEJiTguw0PPQbPBy83f+eCyDRkyBpnXTi/KPyEpgYCzsjGzElevLpFoYGQ==}
prismarine-physics@https://codeload.github.com/zardoy/prismarine-physics/tar.gz/353e25b800149393f40539ec381218be44cbb03b:
resolution: {tarball: https://codeload.github.com/zardoy/prismarine-physics/tar.gz/353e25b800149393f40539ec381218be44cbb03b}
version: 1.9.0
@ -8366,8 +8381,8 @@ packages:
resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==}
engines: {node: '>= 0.8.0'}
send@1.1.0:
resolution: {integrity: sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==}
send@1.2.0:
resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==}
engines: {node: '>= 18'}
sentence-case@3.0.4:
@ -15724,7 +15739,7 @@ snapshots:
estraverse: 5.3.0
jsx-ast-utils: 3.3.5
minimatch: 3.1.2
object.entries: 1.1.8
object.entries: 1.1.9
object.fromentries: 2.0.8
object.hasown: 1.1.4
object.values: 1.2.1
@ -16234,6 +16249,8 @@ snapshots:
fresh@0.5.2: {}
fresh@2.0.0: {}
from@0.1.7: {}
fs-constants@1.0.0: {}
@ -17384,7 +17401,7 @@ snapshots:
object-assign: 4.1.1
opn: 6.0.0
proxy-middleware: 0.15.0
send: 1.1.0
send: 1.2.0
serve-index: 1.9.1
transitivePeerDependencies:
- supports-color
@ -17588,7 +17605,7 @@ snapshots:
maxrects-packer@2.7.3: {}
mc-assets@0.2.42:
mc-assets@0.2.48:
dependencies:
maxrects-packer: 2.7.3
zod: 3.24.1
@ -17873,10 +17890,16 @@ snapshots:
mime-db@1.52.0: {}
mime-db@1.54.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
mime-types@3.0.1:
dependencies:
mime-db: 1.54.0
mime@1.6.0: {}
mime@2.6.0: {}
@ -17899,7 +17922,7 @@ snapshots:
minecraft-folder-path@1.2.0: {}
minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/75e940a4cd50d89e0ba03db3733d5d704917a3c8(@types/react@18.2.20)(react@18.2.0):
minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/69dee6aa15f7c8a6df2a2ab01972e6a7b2f9ee30(@types/react@18.2.20)(react@18.2.0):
dependencies:
valtio: 1.11.2(@types/react@18.2.20)(react@18.2.0)
transitivePeerDependencies:
@ -17989,7 +18012,7 @@ snapshots:
- encoding
- supports-color
mineflayer-mouse@0.1.2(@types/debug@4.1.12)(@types/node@22.8.1)(terser@5.31.3)(tsx@4.7.0)(yaml@2.4.1):
mineflayer-mouse@0.1.7(@types/debug@4.1.12)(@types/node@22.8.1)(terser@5.31.3)(tsx@4.7.0)(yaml@2.4.1):
dependencies:
change-case: 5.4.4
debug: 4.4.0(supports-color@8.1.1)
@ -18245,7 +18268,7 @@ snapshots:
neo-async@2.6.2: {}
net-browserify@https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/624cc67c16f5e8b23b772e7eaabae16ba84b8590:
net-browserify@https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/29cb893a3dd40c663b1b9b34fbecdbf967b089d4:
dependencies:
body-parser: 1.20.2
express: 4.18.2
@ -18435,9 +18458,10 @@ snapshots:
has-symbols: 1.1.0
object-keys: 1.1.1
object.entries@1.1.8:
object.entries@1.1.9:
dependencies:
call-bind: 1.0.8
call-bound: 1.0.4
define-properties: 1.2.1
es-object-atoms: 1.1.1
@ -18882,13 +18906,17 @@ snapshots:
prismarine-item@1.16.0:
dependencies:
prismarine-nbt: 2.5.0
prismarine-nbt: 2.7.0
prismarine-registry: 1.11.0
prismarine-nbt@2.5.0:
dependencies:
protodef: 1.18.0
prismarine-nbt@2.7.0:
dependencies:
protodef: 1.18.0
prismarine-physics@https://codeload.github.com/zardoy/prismarine-physics/tar.gz/353e25b800149393f40539ec381218be44cbb03b:
dependencies:
minecraft-data: 3.83.1
@ -19793,16 +19821,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
send@1.1.0:
send@1.2.0:
dependencies:
debug: 4.4.0(supports-color@8.1.1)
destroy: 1.2.0
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
fresh: 0.5.2
fresh: 2.0.0
http-errors: 2.0.0
mime-types: 2.1.35
mime-types: 3.0.1
ms: 2.1.3
on-finished: 2.4.1
range-parser: 1.2.1

View file

@ -1,3 +1,4 @@
//@ts-nocheck
import { Vec3 } from 'vec3'
import * as THREE from 'three'
import '../../src/getCollisionShapes'

View file

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

View file

@ -1,5 +1,6 @@
//@ts-nocheck
import { BasePlaygroundScene } from '../baseScene'
import { EntityDebugFlags, EntityMesh, rendererSpecialHandled } from '../../viewer/lib/entity/EntityMesh'
import { EntityDebugFlags, EntityMesh, rendererSpecialHandled } from '../../viewer/three/entity/EntityMesh'
export default class AllEntities extends BasePlaygroundScene {
continuousRender = false

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,15 @@
import { RendererReactiveState } from '../../src/appViewer'
export const getDefaultRendererState = (): RendererReactiveState => {
return {
world: {
chunksLoaded: [],
chunksTotalNumber: 0,
allChunksLoaded: true,
mesherWork: false,
intersectMedia: null
},
renderer: '',
preventEscapeMenu: false
}
}

View file

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

View file

@ -3,7 +3,8 @@ import { Vec3 } from 'vec3'
import TypedEmitter from 'typed-emitter'
import { ItemSelector } from 'mc-assets/dist/itemDefinitions'
import { proxy } from 'valtio'
import { HandItemBlock } from './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'>>
@ -22,6 +23,7 @@ export interface IPlayerState {
isFlying(): boolean
isSprinting (): boolean
getItemUsageTicks?(): number
getPosition(): Vec3
// isUsingItem?(): boolean
getHeldItem?(isLeftHand: boolean): HandItemBlock | undefined
username?: string
@ -31,12 +33,21 @@ export interface IPlayerState {
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
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)
@ -74,6 +85,10 @@ export class BasePlayerState implements IPlayerState {
return this.sprinting
}
getPosition (): Vec3 {
return new Vec3(0, 0, 0)
}
// For testing purposes
setState (state: Partial<{
movementState: MovementState

View file

@ -2,27 +2,26 @@
import { ItemRenderer, Identifier, ItemStack, NbtString, Structure, StructureRenderer, ItemRendererResources, BlockDefinition, BlockModel, TextureAtlas, Resources, ItemModel } from 'deepslate'
import { mat4, vec3 } from 'gl-matrix'
import { AssetsParser } from 'mc-assets/dist/assetsParser'
import { getLoadedImage } from 'mc-assets/dist/utils'
import { getLoadedImage, versionToNumber } from 'mc-assets/dist/utils'
import { BlockModel as BlockModelMcAssets, AtlasParser } from 'mc-assets'
import { getLoadedBlockstatesStore, getLoadedModelsStore } from 'mc-assets/dist/stores'
import { makeTextureAtlas } from 'mc-assets/dist/atlasCreator'
import { proxy, ref } from 'valtio'
import { getItemDefinition } from 'mc-assets/dist/itemDefinitions'
import { versionToNumber } from '../prepare/utils'
export const activeGuiAtlas = proxy({
atlas: null as null | { json, image },
})
export const getNonFullBlocksModels = () => {
let version = viewer.world.texturesVersion ?? 'latest'
let version = appViewer.resourcesManager.currentResources!.version ?? 'latest'
if (versionToNumber(version) < versionToNumber('1.13')) version = '1.13'
const itemsDefinitions = viewer.world.itemsDefinitionsStore.data.latest
const itemsDefinitions = appViewer.resourcesManager.itemsDefinitionsStore.data.latest
const blockModelsResolved = {} as Record<string, any>
const itemsModelsResolved = {} as Record<string, any>
const fullBlocksWithNonStandardDisplay = [] as string[]
const handledItemsWithDefinitions = new Set()
const assetsParser = new AssetsParser(version, getLoadedBlockstatesStore(viewer.world.blockstatesModels), getLoadedModelsStore(viewer.world.blockstatesModels))
const assetsParser = new AssetsParser(version, getLoadedBlockstatesStore(appViewer.resourcesManager.currentResources!.blockstatesModels), getLoadedModelsStore(appViewer.resourcesManager.currentResources!.blockstatesModels))
const standardGuiDisplay = {
'rotation': [
@ -48,13 +47,15 @@ export const getNonFullBlocksModels = () => {
if (!model?.elements?.length) return
const isFullBlock = model.elements.length === 1 && arrEqual(model.elements[0].from, [0, 0, 0]) && arrEqual(model.elements[0].to, [16, 16, 16])
if (isFullBlock) return
const hasBetterPrerender = assetsParser.blockModelsStore.data.latest[`item/${name}`]?.textures?.['layer0']?.startsWith('invsprite_')
if (hasBetterPrerender) return
model['display'] ??= {}
model['display']['gui'] ??= standardGuiDisplay
blockModelsResolved[name] = model
}
for (const [name, definition] of Object.entries(itemsDefinitions)) {
const item = getItemDefinition(viewer.world.itemsDefinitionsStore, {
const item = getItemDefinition(appViewer.resourcesManager.itemsDefinitionsStore, {
version,
name,
properties: {
@ -67,7 +68,6 @@ export const getNonFullBlocksModels = () => {
handledItemsWithDefinitions.add(name)
}
if (resolvedModel?.elements) {
let hasStandardDisplay = true
if (resolvedModel['display']?.gui) {
hasStandardDisplay =
@ -97,7 +97,7 @@ export const getNonFullBlocksModels = () => {
}
}
for (const [name, blockstate] of Object.entries(viewer.world.blockstatesModels.blockstates.latest)) {
for (const [name, blockstate] of Object.entries(appViewer.resourcesManager.currentResources!.blockstatesModels.blockstates.latest)) {
if (handledItemsWithDefinitions.has(name)) {
continue
}
@ -120,7 +120,8 @@ export const getNonFullBlocksModels = () => {
const RENDER_SIZE = 64
const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isItems = false) => {
const img = await getLoadedImage(isItems ? viewer.world.itemsAtlasParser!.latestImage : viewer.world.blocksAtlasParser!.latestImage)
const { currentResources } = appViewer.resourcesManager
const img = await getLoadedImage(isItems ? currentResources!.itemsAtlasParser.latestImage : currentResources!.blocksAtlasParser.latestImage)
const canvasTemp = document.createElement('canvas')
canvasTemp.width = img.width
canvasTemp.height = img.height
@ -129,7 +130,7 @@ const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isIt
ctx.imageSmoothingEnabled = false
ctx.drawImage(img, 0, 0)
const atlasParser = isItems ? viewer.world.itemsAtlasParser! : viewer.world.blocksAtlasParser!
const atlasParser = isItems ? currentResources!.itemsAtlasParser : currentResources!.blocksAtlasParser
const textureAtlas = new TextureAtlas(
ctx.getImageData(0, 0, img.width, img.height),
Object.fromEntries(Object.entries(atlasParser.atlas.latest.textures).map(([key, value]) => {
@ -145,6 +146,7 @@ const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isIt
const PREVIEW_ID = Identifier.parse('preview:preview')
const PREVIEW_DEFINITION = new BlockDefinition({ '': { model: PREVIEW_ID.toString() } }, undefined)
let textureWasRequested = false
let modelData: any
let currentModelName: string | undefined
const resources: ItemRendererResources = {
@ -155,6 +157,7 @@ const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isIt
return null
},
getTextureUV (texture) {
textureWasRequested = true
return textureAtlas.getTextureUV(texture.toString().replace('minecraft:', '').replace('block/', '').replace('item/', '').replace('blocks/', '').replace('items/', '') as any)
},
getTextureAtlas () {
@ -203,6 +206,7 @@ const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isIt
const renderer = new ItemRenderer(gl, item, resources, { display_context: 'gui' })
const missingTextures = new Set()
for (const [modelName, model] of Object.entries(models)) {
textureWasRequested = false
if (includeOnly.length && !includeOnly.includes(modelName)) continue
const patchMissingTextures = () => {
@ -224,6 +228,7 @@ const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isIt
if (!modelData) continue
renderer.setItem(item, { display_context: 'gui' })
renderer.drawItem()
if (!textureWasRequested) continue
const url = canvas.toDataURL()
// eslint-disable-next-line no-await-in-loop
const img = await getLoadedImage(url)

View file

@ -121,7 +121,9 @@ const handleMessage = data => {
}
case 'blockUpdate': {
const loc = new Vec3(data.pos.x, data.pos.y, data.pos.z).floored()
world.setBlockStateId(loc, data.stateId)
if (data.stateId !== undefined && data.stateId !== null) {
world.setBlockStateId(loc, data.stateId)
}
const chunkKey = `${Math.floor(loc.x / 16) * 16},${Math.floor(loc.z / 16) * 16}`
if (data.customBlockModels) {

View file

@ -468,7 +468,7 @@ export function getSectionGeometry (sx, sy, sz, world: World) {
heads: {},
signs: {},
// isFull: true,
highestBlocks: new Map<string, HighestBlockInfo>([]),
highestBlocks: {},
hadErrors: false,
blocksCount: 0
}
@ -479,9 +479,9 @@ export function getSectionGeometry (sx, sy, sz, world: World) {
for (cursor.x = sx; cursor.x < sx + 16; cursor.x++) {
let block = world.getBlock(cursor, blockProvider, attr)!
if (!INVISIBLE_BLOCKS.has(block.name)) {
const highest = attr.highestBlocks.get(`${cursor.x},${cursor.z}`)
const highest = attr.highestBlocks[`${cursor.x},${cursor.z}`]
if (!highest || highest.y < cursor.y) {
attr.highestBlocks.set(`${cursor.x},${cursor.z}`, { y: cursor.y, stateId: block.stateId, biomeId: block.biome.id })
attr.highestBlocks[`${cursor.x},${cursor.z}`] = { y: cursor.y, stateId: block.stateId, biomeId: block.biome.id }
}
}
if (INVISIBLE_BLOCKS.has(block.name)) continue

View file

@ -1,5 +1,6 @@
import { BlockType } from '../../../playground/shared'
// only here for easier testing
export const defaultMesherConfig = {
version: '',
enableLighting: true,
@ -37,7 +38,7 @@ export type MesherGeometryOutput = {
heads: Record<string, any>,
signs: Record<string, any>,
// isFull: boolean
highestBlocks: Map<string, HighestBlockInfo>
highestBlocks: Record<string, HighestBlockInfo>
hadErrors: boolean
blocksCount: number
customBlockModels?: CustomBlockModels

View file

@ -49,6 +49,7 @@ 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,

View file

@ -40,6 +40,19 @@ export const updateStatText = (id, text) => {
stats[id].innerText = text
}
export const removeAllStats = () => {
// eslint-disable-next-line guard-for-in
for (const id in stats) {
removeStat(id)
}
}
export const removeStat = (id) => {
if (!stats[id]) return
stats[id].remove()
delete stats[id]
}
if (typeof customEvents !== 'undefined') {
customEvents.on('gameLoaded', () => {
const chunksLoaded = addNewStat('chunks-loaded', 80, 0, 0)

View file

@ -1,325 +0,0 @@
import EventEmitter from 'events'
import * as THREE from 'three'
import { Vec3 } from 'vec3'
import { generateSpiralMatrix } from 'flying-squid/dist/utils'
import worldBlockProvider from 'mc-assets/dist/worldBlockProvider'
import { Entities } from './entities'
import { Primitives } from './primitives'
import { WorldRendererThree } from './worldrendererThree'
import { WorldRendererCommon, WorldRendererConfig, defaultWorldRendererConfig } from './worldrendererCommon'
import { getThreeBlockModelGroup, renderBlockThree, setBlockPosition } from './mesher/standaloneRenderer'
import { addNewStat } from './ui/newStats'
import { getMyHand } from './hand'
import { IPlayerState, BasePlayerState } from './basePlayerState'
import { CameraBobbing } from './cameraBobbing'
export class Viewer {
scene: THREE.Scene
ambientLight: THREE.AmbientLight
directionalLight: THREE.DirectionalLight
world: WorldRendererCommon
entities: Entities
// primitives: Primitives
domElement: HTMLCanvasElement
playerHeight = 1.62
threeJsWorld: WorldRendererThree
cameraObjectOverride?: THREE.Object3D // for xr
audioListener: THREE.AudioListener
renderingUntilNoUpdates = false
processEntityOverrides = (e, overrides) => overrides
private readonly cameraBobbing: CameraBobbing
get camera () {
return this.world.camera
}
set camera (camera) {
this.world.camera = camera
}
constructor (public renderer: THREE.WebGLRenderer, worldConfig = defaultWorldRendererConfig, public playerState: IPlayerState = new BasePlayerState()) {
// https://discourse.threejs.org/t/updates-to-color-management-in-three-js-r152/50791
THREE.ColorManagement.enabled = false
renderer.outputColorSpace = THREE.LinearSRGBColorSpace
this.scene = new THREE.Scene()
this.scene.matrixAutoUpdate = false // for perf
this.threeJsWorld = new WorldRendererThree(this.scene, this.renderer, worldConfig, this.playerState)
this.setWorld()
this.resetScene()
this.entities = new Entities(this)
// this.primitives = new Primitives(this.scene, this.camera)
this.cameraBobbing = new CameraBobbing()
this.domElement = renderer.domElement
}
setWorld () {
this.world = this.threeJsWorld
}
resetScene () {
this.scene.background = new THREE.Color('lightblue')
if (this.ambientLight) this.scene.remove(this.ambientLight)
this.ambientLight = new THREE.AmbientLight(0xcc_cc_cc)
this.scene.add(this.ambientLight)
if (this.directionalLight) this.scene.remove(this.directionalLight)
this.directionalLight = new THREE.DirectionalLight(0xff_ff_ff, 0.5)
this.directionalLight.position.set(1, 1, 0.5).normalize()
this.directionalLight.castShadow = true
this.scene.add(this.directionalLight)
const size = this.renderer.getSize(new THREE.Vector2())
this.camera = new THREE.PerspectiveCamera(75, size.x / size.y, 0.1, 1000)
}
resetAll () {
this.resetScene()
this.world.resetWorld()
this.entities.clear()
// this.primitives.clear()
}
setVersion (userVersion: string, texturesVersion = userVersion): void | Promise<void> {
console.log('[viewer] Using version:', userVersion, 'textures:', texturesVersion)
this.entities.clear()
// this.primitives.clear()
return this.world.setVersion(userVersion, texturesVersion)
}
addColumn (x, z, chunk, isLightUpdate = false) {
this.world.addColumn(x, z, chunk, isLightUpdate)
}
removeColumn (x: string, z: string) {
this.world.removeColumn(x, z)
}
setBlockStateId (pos: Vec3, stateId: number) {
const set = async () => {
const sectionX = Math.floor(pos.x / 16) * 16
const sectionZ = Math.floor(pos.z / 16) * 16
if (this.world.queuedChunks.has(`${sectionX},${sectionZ}`)) {
await new Promise<void>(resolve => {
this.world.queuedFunctions.push(() => {
resolve()
})
})
}
if (!this.world.loadedChunks[`${sectionX},${sectionZ}`]) {
// console.debug('[should be unreachable] setBlockStateId called for unloaded chunk', pos)
}
this.world.setBlockStateId(pos, stateId)
}
void set()
}
async demoModel () {
//@ts-expect-error
const pos = cursorBlockRel(0, 1, 0).position
const mesh = await getMyHand()
// mesh.rotation.y = THREE.MathUtils.degToRad(90)
setBlockPosition(mesh, pos)
const helper = new THREE.BoxHelper(mesh, 0xff_ff_00)
mesh.add(helper)
this.scene.add(mesh)
}
demoItem () {
//@ts-expect-error
const pos = cursorBlockRel(0, 1, 0).position
const { mesh } = this.entities.getItemMesh({
itemId: 541,
}, {})!
mesh.position.set(pos.x + 0.5, pos.y + 0.5, pos.z + 0.5)
// mesh.scale.set(0.5, 0.5, 0.5)
const helper = new THREE.BoxHelper(mesh, 0xff_ff_00)
mesh.add(helper)
this.scene.add(mesh)
}
updateEntity (e) {
this.entities.update(e, this.processEntityOverrides(e, {
rotation: {
head: {
x: e.headPitch ?? e.pitch,
y: e.headYaw,
z: 0
}
}
}))
}
setFirstPersonCamera (pos: Vec3 | null, yaw: number, pitch: number) {
const cam = this.cameraObjectOverride || this.camera
const yOffset = this.playerState.getEyeHeight()
// if (this.playerState.isSneaking()) yOffset -= 0.3
this.world.camera = cam as THREE.PerspectiveCamera
this.world.updateCamera(pos?.offset(0, yOffset, 0) ?? null, yaw, pitch)
// // Update camera bobbing based on movement state
// const velocity = this.playerState.getVelocity()
// const movementState = this.playerState.getMovementState()
// const isMoving = movementState === 'SPRINTING' || movementState === 'WALKING'
// const speed = Math.hypot(velocity.x, velocity.z)
// // Update bobbing state
// this.cameraBobbing.updateWalkDistance(speed)
// this.cameraBobbing.updateBobAmount(isMoving)
// // Get bobbing offsets
// const bobbing = isMoving ? this.cameraBobbing.getBobbing() : { position: { x: 0, y: 0 }, rotation: { x: 0, z: 0 } }
// // Apply camera position with bobbing
// const finalPos = pos ? pos.offset(bobbing.position.x, yOffset + bobbing.position.y, 0) : null
// this.world.updateCamera(finalPos, yaw + bobbing.rotation.x, pitch)
// // Apply roll rotation separately since updateCamera doesn't handle it
// this.camera.rotation.z = bobbing.rotation.z
}
playSound (position: Vec3, path: string, volume = 1, pitch = 1) {
if (!this.audioListener) {
this.audioListener = new THREE.AudioListener()
this.camera.add(this.audioListener)
}
const sound = new THREE.PositionalAudio(this.audioListener)
const audioLoader = new THREE.AudioLoader()
const start = Date.now()
void audioLoader.loadAsync(path).then((buffer) => {
if (Date.now() - start > 500) return
// play
sound.setBuffer(buffer)
sound.setRefDistance(20)
sound.setVolume(volume)
sound.setPlaybackRate(pitch) // set the pitch
this.scene.add(sound)
// set sound position
sound.position.set(position.x, position.y, position.z)
sound.onEnded = () => {
this.scene.remove(sound)
sound.disconnect()
audioLoader.manager.itemEnd(path)
}
sound.play()
})
}
addChunksBatchWaitTime = 200
connect (worldEmitter: EventEmitter) {
worldEmitter.on('entity', (e) => {
this.updateEntity(e)
})
worldEmitter.on('primitive', (p) => {
// this.updatePrimitive(p)
})
let currentLoadChunkBatch = null as {
timeout
data
} | null
worldEmitter.on('loadChunk', ({ x, z, chunk, worldConfig, isLightUpdate }) => {
this.world.worldConfig = worldConfig
this.world.queuedChunks.add(`${x},${z}`)
const args = [x, z, chunk, isLightUpdate]
if (!currentLoadChunkBatch) {
// add a setting to use debounce instead
currentLoadChunkBatch = {
data: [],
timeout: setTimeout(() => {
for (const args of currentLoadChunkBatch!.data) {
this.world.queuedChunks.delete(`${args[0]},${args[1]}`)
this.addColumn(...args as Parameters<typeof this.addColumn>)
}
for (const fn of this.world.queuedFunctions) {
fn()
}
this.world.queuedFunctions = []
currentLoadChunkBatch = null
}, this.addChunksBatchWaitTime)
}
}
currentLoadChunkBatch.data.push(args)
})
// todo remove and use other architecture instead so data flow is clear
worldEmitter.on('blockEntities', (blockEntities) => {
if (this.world instanceof WorldRendererThree) (this.world).blockEntities = blockEntities
})
worldEmitter.on('unloadChunk', ({ x, z }) => {
this.removeColumn(x, z)
})
worldEmitter.on('blockUpdate', ({ pos, stateId }) => {
this.setBlockStateId(new Vec3(pos.x, pos.y, pos.z), stateId)
})
worldEmitter.on('chunkPosUpdate', ({ pos }) => {
this.world.updateViewerPosition(pos)
})
worldEmitter.on('renderDistance', (d) => {
this.world.viewDistance = d
this.world.chunksLength = d === 0 ? 1 : generateSpiralMatrix(d).length
})
worldEmitter.on('renderDistance', (d) => {
this.world.viewDistance = d
this.world.chunksLength = d === 0 ? 1 : generateSpiralMatrix(d).length
this.world.allChunksFinished = Object.keys(this.world.finishedChunks).length === this.world.chunksLength
})
worldEmitter.on('markAsLoaded', ({ x, z }) => {
this.world.markAsLoaded(x, z)
})
worldEmitter.on('updateLight', ({ pos }) => {
if (this.world instanceof WorldRendererThree) (this.world).updateLight(pos.x, pos.z)
})
worldEmitter.on('time', (timeOfDay) => {
this.world.timeUpdated?.(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.world.mesherConfig.skyLight === skyLight) return
this.world.mesherConfig.skyLight = skyLight
if (this.world instanceof WorldRendererThree) {
(this.world).rerenderAllChunks?.()
}
})
worldEmitter.emit('listening')
}
render () {
if (this.world instanceof WorldRendererThree) {
(this.world).render()
this.entities.render()
}
}
async waitForChunksToRender () {
await this.world.waitForChunksToRender()
}
}

View file

@ -1,122 +0,0 @@
import * as THREE from 'three'
import { statsEnd, statsStart } from '../../../src/topRightStats'
import { activeModalStack } from '../../../src/globalState'
// wrapper for now
export class ViewerWrapper {
previousWindowWidth: number
previousWindowHeight: number
globalObject = globalThis as any
stopRenderOnBlur = false
addedToPage = false
renderInterval = 0
renderIntervalUnfocused: number | undefined
fpsInterval
constructor (public canvas: HTMLCanvasElement, public renderer?: THREE.WebGLRenderer) {
if (this.renderer) this.globalObject.renderer = this.renderer
}
addToPage (startRendering = true) {
if (this.addedToPage) throw new Error('Already added to page')
let pixelRatio = window.devicePixelRatio || 1 // todo this value is too high on ios, need to check, probably we should use avg, also need to make it configurable
if (this.renderer) {
if (!this.renderer.capabilities.isWebGL2) pixelRatio = 1 // webgl1 has issues with high pixel ratio (sometimes screen is clipped)
this.renderer.setPixelRatio(pixelRatio)
this.renderer.setSize(window.innerWidth, window.innerHeight)
} else {
this.canvas.width = window.innerWidth * pixelRatio
this.canvas.height = window.innerHeight * pixelRatio
}
this.previousWindowWidth = window.innerWidth
this.previousWindowHeight = window.innerHeight
this.canvas.id = 'viewer-canvas'
document.body.appendChild(this.canvas)
this.addedToPage = true
let max = 0
this.fpsInterval = setInterval(() => {
if (max > 0) {
viewer.world.droppedFpsPercentage = this.renderedFps / max
}
max = Math.max(this.renderedFps, max)
this.renderedFps = 0
}, 1000)
if (startRendering) {
this.globalObject.requestAnimationFrame(this.render.bind(this))
}
if (typeof window !== 'undefined') {
this.trackWindowFocus()
}
}
windowFocused = true
trackWindowFocus () {
window.addEventListener('focus', () => {
this.windowFocused = true
})
window.addEventListener('blur', () => {
this.windowFocused = false
})
}
dispose () {
if (!this.addedToPage) throw new Error('Not added to page')
this.canvas.remove()
this.renderer?.dispose()
// this.addedToPage = false
clearInterval(this.fpsInterval)
}
renderedFps = 0
lastTime = performance.now()
delta = 0
preRender = () => { }
postRender = () => { }
render (time: DOMHighResTimeStamp) {
if (this.globalObject.stopLoop) return
this.globalObject.requestAnimationFrame(this.render.bind(this))
if (activeModalStack.some(m => m.reactType === 'app-status')) return
if (!viewer || this.globalObject.stopRender || this.renderer?.xr.isPresenting || (this.stopRenderOnBlur && !this.windowFocused)) return
const renderInterval = (this.windowFocused ? this.renderInterval : this.renderIntervalUnfocused) ?? this.renderInterval
if (renderInterval) {
this.delta += time - this.lastTime
this.lastTime = time
if (this.delta > renderInterval) {
this.delta %= renderInterval
// continue rendering
} else {
return
}
}
for (const fn of beforeRenderFrame) fn()
this.preRender()
statsStart()
// ios bug: viewport dimensions are updated after the resize event
if (this.previousWindowWidth !== window.innerWidth || this.previousWindowHeight !== window.innerHeight) {
this.resizeHandler()
this.previousWindowWidth = window.innerWidth
this.previousWindowHeight = window.innerHeight
}
viewer.render()
this.renderedFps++
statsEnd()
this.postRender()
}
resizeHandler () {
const width = window.innerWidth
const height = window.innerHeight
viewer.camera.aspect = width / height
viewer.camera.updateProjectionMatrix()
if (this.renderer) {
this.renderer.setSize(width, height)
}
viewer.world.handleResize()
}
}

View file

@ -5,6 +5,8 @@ import { EventEmitter } from 'events'
import { generateSpiralMatrix, ViewRect } from 'flying-squid/dist/utils'
import { Vec3 } from 'vec3'
import { BotEvents } from 'mineflayer'
import { proxy } from 'valtio'
import TypedEmitter from 'typed-emitter'
import { getItemFromBlock } from '../../../src/chatUtils'
import { delayedIterator } from '../../playground/shared'
import { playerState } from '../../../src/mineflayer/playerState'
@ -13,11 +15,26 @@ import { chunkPos } from './simpleUtils'
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
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: any, blockEntities: any, worldConfig: any, isLightUpdate: boolean }) => void
updateLight: (data: { pos: Vec3 }) => void
}
/**
* 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 {
export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<WorldDataEmitterEvents>) {
private loadedChunks: Record<ChunkPosKey, boolean>
private readonly lastPos: Vec3
private eventListeners: Record<string, any> = {}
@ -26,20 +43,18 @@ export class WorldDataEmitter extends EventEmitter {
addWaitTime = 1
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
super()
this.loadedChunks = {}
this.lastPos = new Vec3(0, 0, 0).update(position)
// todo
this.emitter = this
this.emitter.on('mouseClick', async (click) => {
const ori = new Vec3(click.origin.x, click.origin.y, click.origin.z)
const dir = new Vec3(click.direction.x, click.direction.y, click.direction.z)
const block = this.world.raycast(ori, dir, 256)
if (!block) return
this.emit('blockClicked', block, block.face, click.button)
})
}
setBlockStateId (position: Vec3, stateId: number) {
@ -61,9 +76,10 @@ export class WorldDataEmitter extends EventEmitter {
}
listenToBot (bot: typeof __type_bot) {
const emitEntity = (e) => {
const emitEntity = (e, name = 'entity') => {
if (!e || e === bot.entity) return
this.emitter.emit('entity', {
if (!e.name) return // mineflayer received update for not spawned entity
this.emitter.emit(name as any, {
...e,
pos: e.position,
username: e.username,
@ -86,7 +102,7 @@ export class WorldDataEmitter extends EventEmitter {
emitEntity(e)
},
entityMoved (e: any) {
emitEntity(e)
emitEntity(e, 'entityMoved')
},
entityGone: (e: any) => {
this.emitter.emit('entity', { id: e.id, delete: true })
@ -134,7 +150,12 @@ export class WorldDataEmitter extends EventEmitter {
for (const id in bot.entities) {
const e = bot.entities[id]
emitEntity(e)
try {
emitEntity(e)
} catch (err) {
// reportError?.(err)
console.error('error processing entity', err)
}
}
}
@ -166,10 +187,17 @@ export class WorldDataEmitter extends EventEmitter {
readdDebug () {
const clonedLoadedChunks = { ...this.loadedChunks }
this.unloadAllChunks()
console.time('readdDebug')
for (const loadedChunk in clonedLoadedChunks) {
const [x, z] = loadedChunk.split(',').map(Number)
void this.loadChunk(new Vec3(x, 0, z))
}
const interval = setInterval(() => {
if (appViewer.rendererState.world.allChunksLoaded) {
clearInterval(interval)
console.timeEnd('readdDebug')
}
}, 100)
}
// debugGotChunkLatency = [] as number[]

View file

@ -3,28 +3,21 @@ import { EventEmitter } from 'events'
import { Vec3 } from 'vec3'
import * as THREE from 'three'
import mcDataRaw from 'minecraft-data/data.js' // note: using alias
import blocksAtlases from 'mc-assets/dist/blocksAtlases.json'
import blocksAtlasLatest from 'mc-assets/dist/blocksAtlasLatest.png'
import blocksAtlasLegacy from 'mc-assets/dist/blocksAtlasLegacy.png'
import itemsAtlases from 'mc-assets/dist/itemsAtlases.json'
import itemsAtlasLatest from 'mc-assets/dist/itemsAtlasLatest.png'
import itemsAtlasLegacy from 'mc-assets/dist/itemsAtlasLegacy.png'
import { AtlasParser, getLoadedItemDefinitionsStore } from 'mc-assets'
import TypedEmitter from 'typed-emitter'
import { LineMaterial } from 'three-stdlib'
import christmasPack from 'mc-assets/dist/textureReplacements/christmas'
import { ItemsRenderer } from 'mc-assets/dist/itemsRenderer'
import itemDefinitionsJson from 'mc-assets/dist/itemDefinitions.json'
import worldBlockProvider, { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider'
import { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider'
import { generateSpiralMatrix } from 'flying-squid/dist/utils'
import { dynamicMcDataFiles } from '../../buildMesherConfig.mjs'
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 { defaultMesherConfig, HighestBlockInfo, MesherGeometryOutput, CustomBlockModels, BlockStateModelInfo } from './mesher/shared'
import { HighestBlockInfo, MesherGeometryOutput, CustomBlockModels, BlockStateModelInfo, getBlockAssetsCacheKey, MesherConfig } from './mesher/shared'
import { chunkPos } from './simpleUtils'
import { HandItemBlock } from './holdingBlock'
import { updateStatText } from './ui/newStats'
import { WorldRendererThree } from './worldrendererThree'
import { generateGuiAtlas } from './guiRenderer'
import { removeStat, updateStatText } from './ui/newStats'
import { WorldDataEmitter } from './worldDataEmitter'
import { IPlayerState } from './basePlayerState'
function mod (x, n) {
return ((x % n) + n) % n
@ -34,38 +27,36 @@ export const worldCleanup = buildCleanupDecorator('resetWorld')
export const defaultWorldRendererConfig = {
showChunkBorders: false,
numWorkers: 4,
mesherWorkers: 4,
isPlayground: false,
renderEars: true,
// game renderer setting actually
showHand: false,
viewBobbing: false
viewBobbing: false,
extraBlockRenderers: true,
clipWorldBelowY: undefined as number | undefined,
smoothLighting: true,
enableLighting: true,
starfield: true,
addChunksBatchWaitTime: 200,
vrSupport: true,
renderEntities: true,
fov: 75,
fetchPlayerSkins: true,
highlightBlockColor: 'blue',
foreground: true,
_experimentalSmoothChunkLoading: true,
_renderByChunks: false
}
export type WorldRendererConfig = typeof defaultWorldRendererConfig
type CustomTexturesData = {
tileSize: number | undefined
textures: Record<string, HTMLImageElement>
}
export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any> {
// todo
@worldCleanup()
threejsCursorLineMaterial: LineMaterial
@worldCleanup()
cursorBlock = null as Vec3 | null
displayStats = true
@worldCleanup()
worldConfig = { minY: 0, worldHeight: 256 }
// todo need to cleanup
material = new THREE.MeshLambertMaterial({ vertexColors: true, transparent: true, alphaTest: 0.1 })
cameraRoll = 0
worldSizeParams = { minY: 0, worldHeight: 256 }
@worldCleanup()
active = false
version = undefined as string | undefined
// #region CHUNK & SECTIONS TRACKING
@worldCleanup()
loadedChunks = {} as Record<string, boolean> // data is added for these chunks and they might be still processing
@ -85,60 +76,38 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
queuedFunctions = [] as Array<() => void>
// #endregion
@worldCleanup()
renderUpdateEmitter = new EventEmitter() as unknown as TypedEmitter<{
dirty (pos: Vec3, value: boolean): void
update (/* pos: Vec3, value: boolean */): void
textureDownloaded (): void
itemsTextureDownloaded (): void
chunkFinished (key: string): void
}>
customTexturesDataUrl = undefined as string | undefined
@worldCleanup()
currentTextureImage = undefined as any
workers: any[] = []
@worldCleanup()
viewerPosition?: Vec3
lastCamUpdate = 0
droppedFpsPercentage = 0
@worldCleanup()
initialChunkLoadWasStartedIn: number | undefined
@worldCleanup()
initialChunksLoad = true
enableChunksLoadDelay = false
texturesVersion?: string
viewDistance = -1
chunksLength = 0
@worldCleanup()
allChunksFinished = false
messageQueue: any[] = []
isProcessingQueue = false
ONMESSAGE_TIME_LIMIT = 30 // ms
handleResize = () => { }
mesherConfig = defaultMesherConfig
camera: THREE.PerspectiveCamera
highestBlocks = new Map<string, HighestBlockInfo>()
blockstatesModels: any
customBlockStates: Record<string, any> | undefined
customModels: Record<string, any> | undefined
itemsAtlasParser: AtlasParser | undefined
blocksAtlasParser: AtlasParser | undefined
highestBlocksByChunks = {} as Record<string, { [chunkKey: string]: HighestBlockInfo }>
highestBlocksBySections = {} as Record<string, { [sectionKey: string]: HighestBlockInfo }>
blockEntities = {}
sourceData = {
blocksAtlases,
itemsAtlases,
itemDefinitionsJson
}
customTextures: {
items?: CustomTexturesData
blocks?: CustomTexturesData
armor?: CustomTexturesData
} = {}
itemsDefinitionsStore = getLoadedItemDefinitionsStore(this.sourceData.itemDefinitionsJson)
workersProcessAverageTime = 0
workersProcessAverageTimeCount = 0
maxWorkersProcessTime = 0
geometryReceiveCount = {}
allLoadedIn: undefined | number
rendererDevice = '...'
edgeChunks = {} as Record<string, boolean>
lastAddChunk = null as null | {
@ -150,14 +119,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
lastChunkDistance = 0
debugStopGeometryUpdate = false
@worldCleanup()
freeFlyMode = false
@worldCleanup()
freeFlyState = {
yaw: 0,
pitch: 0,
position: new Vec3(0, 0, 0)
}
@worldCleanup()
itemsRenderer: ItemsRenderer | undefined
@ -168,22 +129,79 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
abstract outputFormat: 'threeJs' | 'webgpu'
worldBlockProvider: WorldBlockProvider
soundSystem: SoundSystem | undefined
abstract changeBackgroundColor (color: [number, number, number]): void
constructor (public config: WorldRendererConfig) {
worldRendererConfig: WorldRendererConfig
playerState: IPlayerState
reactiveState: RendererReactiveState
abortController = new AbortController()
lastRendered = 0
renderingActive = true
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.playerState = displayOptions.playerState
this.reactiveState = displayOptions.rendererState
this.renderUpdateEmitter.on('update', () => {
const loadedChunks = Object.keys(this.finishedChunks).length
updateStatText('loaded-chunks', `${loadedChunks}/${this.chunksLength} chunks (${this.lastChunkDistance}/${this.viewDistance})`)
})
this.connect(this.displayOptions.worldView)
}
init () {
if (this.active) throw new Error('WorldRendererCommon is already initialized')
void this.setVersion(this.version).then(() => {
this.resourcesManager.on('assetsTexturesUpdated', () => {
if (!this.active) return
void this.updateAssetsData()
})
if (this.resourcesManager.currentResources) {
void this.updateAssetsData()
}
})
}
snapshotInitialValues () { }
initWorkers (numWorkers = this.config.numWorkers) {
wasChunkSentToWorker (chunkKey: string) {
return this.loadedChunks[chunkKey]
}
async getHighestBlocks (chunkKey: string) {
return this.highestBlocksByChunks[chunkKey]
}
updateCustomBlock (chunkKey: string, blockPos: string, model: string) {
this.protocolCustomBlocks.set(chunkKey, {
...this.protocolCustomBlocks.get(chunkKey),
[blockPos]: model
})
if (this.wasChunkSentToWorker(chunkKey)) {
const [x, y, z] = blockPos.split(',').map(Number)
this.setBlockStateId(new Vec3(x, y, z), undefined)
}
}
async getBlockInfo (blockPos: { x: number, y: number, z: number }, stateId: number) {
const chunkKey = `${Math.floor(blockPos.x / 16) * 16},${Math.floor(blockPos.z / 16) * 16}`
const customBlockName = this.protocolCustomBlocks.get(chunkKey)?.[`${blockPos.x},${blockPos.y},${blockPos.z}`]
const cacheKey = getBlockAssetsCacheKey(stateId, customBlockName)
const modelInfo = this.blockStateModelInfo.get(cacheKey)
return {
customBlockName,
modelInfo
}
}
initWorkers (numWorkers = this.worldRendererConfig.mesherWorkers) {
// init workers
for (let i = 0; i < numWorkers + 1; i++) {
// Node environment needs an absolute path, but browser needs the url of the file
@ -199,87 +217,140 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
} else {
worker = new Worker(src)
}
const handleMessage = (data) => {
if (!this.active) return
if (data.type !== 'geometry' || !this.debugStopGeometryUpdate) {
this.handleWorkerMessage(data)
}
if (data.type === 'geometry') {
this.geometryReceiveCount[data.workerIndex] ??= 0
this.geometryReceiveCount[data.workerIndex]++
const geometry = data.geometry as MesherGeometryOutput
for (const [key, highest] of geometry.highestBlocks.entries()) {
const currHighest = this.highestBlocks.get(key)
if (!currHighest || currHighest.y < highest.y) {
this.highestBlocks.set(key, highest)
}
}
// for (const key in geometry.highestBlocks) {
// const highest = geometry.highestBlocks[key]
// if (!this.highestBlocks[key] || this.highestBlocks[key].y < highest.y) {
// this.highestBlocks[key] = highest
// }
// }
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
if (!this.sectionsWaiting.get(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) {
this.sectionsWaiting.delete(data.key)
this.finishedSections[data.key] = true
}
const chunkCoords = data.key.split(',').map(Number)
if (this.loadedChunks[`${chunkCoords[0]},${chunkCoords[2]}`]) { // ensure chunk data was added, not a neighbor chunk update
const loadingKeys = [...this.sectionsWaiting.keys()]
if (!loadingKeys.some(key => {
const [x, y, z] = key.split(',').map(Number)
return x === chunkCoords[0] && z === chunkCoords[2]
})) {
this.finishedChunks[`${chunkCoords[0]},${chunkCoords[2]}`] = true
this.renderUpdateEmitter.emit(`chunkFinished`, `${chunkCoords[0] / 16},${chunkCoords[2] / 16}`)
}
}
this.checkAllFinished()
this.renderUpdateEmitter.emit('update')
if (data.processTime) {
this.workersProcessAverageTimeCount++
this.workersProcessAverageTime = ((this.workersProcessAverageTime * (this.workersProcessAverageTimeCount - 1)) + data.processTime) / this.workersProcessAverageTimeCount
this.maxWorkersProcessTime = Math.max(this.maxWorkersProcessTime, data.processTime)
}
}
if (data.type === 'blockStateModelInfo') {
for (const [cacheKey, info] of Object.entries(data.info)) {
this.blockStateModelInfo.set(cacheKey, info)
}
}
}
worker.onmessage = ({ data }) => {
if (Array.isArray(data)) {
// eslint-disable-next-line unicorn/no-array-for-each
data.forEach(handleMessage)
return
this.messageQueue.push(...data)
} else {
this.messageQueue.push(data)
}
handleMessage(data)
void this.processMessageQueue()
}
if (worker.on) worker.on('message', (data) => { worker.onmessage({ data }) })
this.workers.push(worker)
}
}
checkAllFinished () {
if (this.sectionsWaiting.size === 0) {
const allFinished = Object.keys(this.finishedChunks).length === this.chunksLength
if (allFinished) {
this.allChunksLoaded?.()
this.allChunksFinished = true
this.allLoadedIn ??= Date.now() - this.initialChunkLoadWasStartedIn!
async processMessageQueue () {
if (this.isProcessingQueue || this.messageQueue.length === 0) return
if (this.lastRendered && performance.now() - this.lastRendered > 30 && this.worldRendererConfig._experimentalSmoothChunkLoading && this.renderingActive) {
await new Promise(resolve => {
requestAnimationFrame(resolve)
})
}
this.isProcessingQueue = true
const startTime = performance.now()
let processedCount = 0
while (this.messageQueue.length > 0) {
const data = this.messageQueue.shift()!
this.handleMessage(data)
processedCount++
// Check if we've exceeded the time limit
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()
})
return
}
break
}
}
this.isProcessingQueue = false
}
handleMessage (data) {
if (!this.active) return
if (data.type !== 'geometry' || !this.debugStopGeometryUpdate) {
this.handleWorkerMessage(data)
}
if (data.type === 'geometry') {
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
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) {
this.sectionsWaiting.delete(data.key)
this.finishedSections[data.key] = true
}
const chunkCoords = data.key.split(',').map(Number)
const chunkKey = `${chunkCoords[0]},${chunkCoords[2]}`
if (this.loadedChunks[chunkKey]) { // ensure chunk data was added, not a neighbor chunk update
let loaded = true
for (let y = this.worldMinYRender; y < this.worldSizeParams.worldHeight; y += 16) {
if (!this.finishedSections[`${chunkCoords[0]},${y},${chunkCoords[2]}`]) {
loaded = false
break
}
}
if (loaded) {
// CHUNK FINISHED
this.finishedChunks[chunkKey] = true
this.renderUpdateEmitter.emit(`chunkFinished`, `${chunkCoords[0]},${chunkCoords[2]}`)
this.checkAllFinished()
// merge highest blocks by sections into highest blocks by chunks
// for (let y = this.worldMinYRender; y < this.worldSizeParams.worldHeight; y += 16) {
// const sectionKey = `${chunkCoords[0]},${y},${chunkCoords[2]}`
// for (let x = 0; x < 16; x++) {
// for (let z = 0; z < 16; z++) {
// const posInsideKey = `${chunkCoords[0] + x},${chunkCoords[2] + z}`
// let block = null as HighestBlockInfo | null
// const highestBlock = this.highestBlocksBySections[sectionKey]?.[posInsideKey]
// if (!highestBlock) continue
// if (!block || highestBlock.y > block.y) {
// block = highestBlock
// }
// if (block) {
// this.highestBlocksByChunks[chunkKey] ??= {}
// this.highestBlocksByChunks[chunkKey][posInsideKey] = block
// }
// }
// }
// delete this.highestBlocksBySections[sectionKey]
// }
}
}
this.renderUpdateEmitter.emit('update')
if (data.processTime) {
this.workersProcessAverageTimeCount++
this.workersProcessAverageTime = ((this.workersProcessAverageTime * (this.workersProcessAverageTimeCount - 1)) + data.processTime) / this.workersProcessAverageTimeCount
this.maxWorkersProcessTime = Math.max(this.maxWorkersProcessTime, data.processTime)
}
}
if (data.type === 'blockStateModelInfo') {
for (const [cacheKey, info] of Object.entries(data.info)) {
this.blockStateModelInfo.set(cacheKey, info)
}
}
}
checkAllFinished () {
if (this.sectionsWaiting.size === 0) {
this.reactiveState.world.mesherWork = false
}
// todo check exact surrounding chunks
const allFinished = Object.keys(this.finishedChunks).length >= this.chunksLength
if (allFinished) {
this.allChunksLoaded?.()
this.allChunksFinished = true
this.allLoadedIn ??= Date.now() - this.initialChunkLoadWasStartedIn!
}
this.updateChunksStats()
}
changeHandSwingingState (isAnimationPlaying: boolean, isLeftHand: boolean): void { }
@ -328,20 +399,15 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
worker.terminate()
}
this.workers = []
this.currentTextureImage = undefined
this.blocksAtlasParser = undefined
this.itemsAtlasParser = undefined
}
// new game load happens here
async setVersion (version, texturesVersion = version) {
if (!this.blockstatesModels) throw new Error('Blockstates models is not loaded yet')
async setVersion (version: string) {
this.version = version
this.texturesVersion = texturesVersion
this.resetWorld()
// for workers in single file build
if (document.readyState === 'loading') {
if (document?.readyState === 'loading') {
await new Promise(resolve => {
document.addEventListener('DOMContentLoaded', resolve)
})
@ -349,11 +415,26 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
this.initWorkers()
this.active = true
this.mesherConfig.outputFormat = this.outputFormat
this.mesherConfig.version = this.version!
await this.resourcesManager.loadMcData(version)
this.sendMesherMcData()
await this.updateAssetsData()
if (!this.resourcesManager.currentResources) {
await this.resourcesManager.updateAssetsData({ })
}
}
getMesherConfig (): MesherConfig {
return {
version: this.version,
enableLighting: this.worldRendererConfig.enableLighting,
skyLight: 15,
smoothLighting: this.worldRendererConfig.smoothLighting,
outputFormat: this.outputFormat,
textureSize: this.resourcesManager.currentResources!.blocksAtlasParser.atlas.latest.width,
debugModelVariant: undefined,
clipWorldBelowY: this.worldRendererConfig.clipWorldBelowY,
disableSignsMapsSupport: !this.worldRendererConfig.extraBlockRenderers
}
}
sendMesherMcData () {
@ -366,111 +447,41 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
}
for (const worker of this.workers) {
worker.postMessage({ type: 'mcData', mcData, config: this.mesherConfig })
worker.postMessage({ type: 'mcData', mcData, config: this.getMesherConfig() })
}
}
async generateGuiTextures () {
await generateGuiAtlas()
}
async updateAssetsData (resourcePackUpdate = false, prioritizeBlockTextures?: string[]) {
const blocksAssetsParser = new AtlasParser(this.sourceData.blocksAtlases, blocksAtlasLatest, blocksAtlasLegacy)
const itemsAssetsParser = new AtlasParser(this.sourceData.itemsAtlases, itemsAtlasLatest, itemsAtlasLegacy)
const blockTexturesChanges = {} as Record<string, string>
const date = new Date()
if ((date.getMonth() === 11 && date.getDate() >= 24) || (date.getMonth() === 0 && date.getDate() <= 6)) {
Object.assign(blockTexturesChanges, christmasPack)
}
const customBlockTextures = Object.keys(this.customTextures.blocks?.textures ?? {})
const customItemTextures = Object.keys(this.customTextures.items?.textures ?? {})
console.time('createBlocksAtlas')
const { atlas: blocksAtlas, canvas: blocksCanvas } = await blocksAssetsParser.makeNewAtlas(this.texturesVersion ?? this.version ?? 'latest', (textureName) => {
const texture = this.customTextures?.blocks?.textures[textureName]
return blockTexturesChanges[textureName] ?? texture
}, /* this.customTextures?.blocks?.tileSize */undefined, prioritizeBlockTextures, customBlockTextures)
console.timeEnd('createBlocksAtlas')
console.time('createItemsAtlas')
const { atlas: itemsAtlas, canvas: itemsCanvas } = await itemsAssetsParser.makeNewAtlas(this.texturesVersion ?? this.version ?? 'latest', (textureName) => {
const texture = this.customTextures?.items?.textures[textureName]
if (!texture) return
return texture
}, this.customTextures?.items?.tileSize, undefined, customItemTextures)
console.timeEnd('createItemsAtlas')
this.blocksAtlasParser = new AtlasParser({ latest: blocksAtlas }, blocksCanvas.toDataURL())
this.itemsAtlasParser = new AtlasParser({ latest: itemsAtlas }, itemsCanvas.toDataURL())
this.itemsRenderer = new ItemsRenderer(this.version!, this.blockstatesModels, this.itemsAtlasParser, this.blocksAtlasParser)
this.worldBlockProvider = worldBlockProvider(this.blockstatesModels, this.blocksAtlasParser.atlas, 'latest')
const texture = await new THREE.TextureLoader().loadAsync(this.blocksAtlasParser.latestImage)
texture.magFilter = THREE.NearestFilter
texture.minFilter = THREE.NearestFilter
texture.flipY = false
this.material.map = texture
this.currentTextureImage = this.material.map.image
this.mesherConfig.textureSize = this.material.map.image.width
async updateAssetsData () {
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()) {
const { blockstatesModels } = this
if (this.customBlockStates) {
// TODO! remove from other versions as well
blockstatesModels.blockstates.latest = {
...blockstatesModels.blockstates.latest,
...this.customBlockStates
}
}
if (this.customModels) {
blockstatesModels.models.latest = {
...blockstatesModels.models.latest,
...this.customModels
}
}
const { blockstatesModels } = resources
worker.postMessage({
type: 'mesherData',
workerIndex: i,
blocksAtlas: {
latest: blocksAtlas
latest: resources.blocksAtlasParser.atlas.latest
},
blockstatesModels,
config: this.mesherConfig,
config: this.getMesherConfig(),
})
}
if (!this.itemsAtlasParser) return
const itemsTexture = await new THREE.TextureLoader().loadAsync(this.itemsAtlasParser.latestImage)
itemsTexture.magFilter = THREE.NearestFilter
itemsTexture.minFilter = THREE.NearestFilter
itemsTexture.flipY = false
viewer.entities.itemsTexture = itemsTexture
if (!this.itemsAtlasParser) return
this.renderUpdateEmitter.emit('textureDownloaded')
console.time('generateGuiTextures')
await this.generateGuiTextures()
console.timeEnd('generateGuiTextures')
if (!this.itemsAtlasParser) return
this.renderUpdateEmitter.emit('itemsTextureDownloaded')
console.log('textures loaded')
}
async downloadDebugAtlas (isItems = false) {
const atlasParser = (isItems ? this.itemsAtlasParser : this.blocksAtlasParser)!
const dataUrl = await atlasParser.createDebugImage(true)
const a = document.createElement('a')
a.href = dataUrl
a.download = `atlas-debug-${isItems ? 'items' : 'blocks'}.png`
a.click()
}
get worldMinYRender () {
return Math.floor(Math.max(this.worldConfig.minY, this.mesherConfig.clipWorldBelowY ?? -Infinity) / 16) * 16
return Math.floor(Math.max(this.worldSizeParams.minY, this.worldRendererConfig.clipWorldBelowY ?? -Infinity) / 16) * 16
}
updateChunksStatsText () {
updateChunksStats () {
const loadedChunks = Object.keys(this.finishedChunks)
this.displayOptions.nonReactiveState.world.chunksLoaded = loadedChunks
this.displayOptions.nonReactiveState.world.chunksTotalNumber = this.chunksLength
this.reactiveState.world.allChunksLoaded = this.allChunksFinished
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)`)
}
@ -480,7 +491,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
this.initialChunksLoad = false
this.initialChunkLoadWasStartedIn ??= Date.now()
this.loadedChunks[`${x},${z}`] = true
this.updateChunksStatsText()
this.updateChunksStats()
const chunkKey = `${x},${z}`
const customBlockModels = this.protocolCustomBlocks.get(chunkKey)
@ -494,10 +505,10 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
customBlockModels: customBlockModels || undefined
})
}
for (let y = this.worldMinYRender; y < this.worldConfig.worldHeight; y += 16) {
for (let y = this.worldMinYRender; y < this.worldSizeParams.worldHeight; y += 16) {
const loc = new Vec3(x, y, z)
this.setSectionDirty(loc)
if (this.neighborChunkUpdates && (!isLightUpdate || this.mesherConfig.smoothLighting)) {
if (this.neighborChunkUpdates && (!isLightUpdate || this.worldRendererConfig.smoothLighting)) {
this.setSectionDirty(loc.offset(-16, 0, 0))
this.setSectionDirty(loc.offset(16, 0, 0))
this.setSectionDirty(loc.offset(0, 0, -16))
@ -523,24 +534,140 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
this.allLoadedIn = undefined
this.initialChunkLoadWasStartedIn = undefined
}
for (let y = this.worldConfig.minY; y < this.worldConfig.worldHeight; y += 16) {
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}`]
}
delete this.highestBlocksByChunks[`${x},${z}`]
// remove from highestBlocks
const startX = Math.floor(x / 16) * 16
const startZ = Math.floor(z / 16) * 16
const endX = Math.ceil((x + 1) / 16) * 16
const endZ = Math.ceil((z + 1) / 16) * 16
for (let x = startX; x < endX; x += 16) {
for (let z = startZ; z < endZ; z += 16) {
delete this.highestBlocks[`${x},${z}`]
}
}
this.updateChunksStats()
}
setBlockStateId (pos: Vec3, stateId: number) {
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
if (this.queuedChunks.has(`${sectionX},${sectionZ}`)) {
await new Promise<void>(resolve => {
this.queuedFunctions.push(() => {
resolve()
})
})
}
if (!this.loadedChunks[`${sectionX},${sectionZ}`]) {
// console.debug('[should be unreachable] setBlockStateId called for unloaded chunk', pos)
}
this.setBlockStateIdInner(pos, stateId)
}
void set()
}
updateEntity (e: any, isUpdate = false) { }
lightUpdate (chunkX: number, chunkZ: number) { }
connect (worldView: WorldDataEmitter) {
const worldEmitter = worldView
worldEmitter.on('entity', (e) => {
this.updateEntity(e, false)
})
worldEmitter.on('entityMoved', (e) => {
this.updateEntity(e, true)
})
let currentLoadChunkBatch = null as {
timeout
data
} | null
worldEmitter.on('loadChunk', ({ x, z, chunk, worldConfig, isLightUpdate }) => {
this.worldSizeParams = worldConfig
this.queuedChunks.add(`${x},${z}`)
const args = [x, z, chunk, isLightUpdate]
if (!currentLoadChunkBatch) {
// add a setting to use debounce instead
currentLoadChunkBatch = {
data: [],
timeout: setTimeout(() => {
for (const args of currentLoadChunkBatch!.data) {
this.queuedChunks.delete(`${args[0]},${args[1]}`)
this.addColumn(...args as Parameters<typeof this.addColumn>)
}
for (const fn of this.queuedFunctions) {
fn()
}
this.queuedFunctions = []
currentLoadChunkBatch = null
}, this.worldRendererConfig.addChunksBatchWaitTime)
}
}
currentLoadChunkBatch.data.push(args)
})
// todo remove and use other architecture instead so data flow is clear
worldEmitter.on('blockEntities', (blockEntities) => {
this.blockEntities = blockEntities
})
worldEmitter.on('unloadChunk', ({ x, z }) => {
this.removeColumn(x, z)
})
worldEmitter.on('blockUpdate', ({ pos, stateId }) => {
this.setBlockStateId(new Vec3(pos.x, pos.y, pos.z), stateId)
})
worldEmitter.on('chunkPosUpdate', ({ pos }) => {
this.updateViewerPosition(pos)
})
worldEmitter.on('renderDistance', (d) => {
this.viewDistance = d
this.chunksLength = d === 0 ? 1 : generateSpiralMatrix(d).length
})
worldEmitter.on('renderDistance', (d) => {
this.viewDistance = d
this.chunksLength = d === 0 ? 1 : generateSpiralMatrix(d).length
this.allChunksFinished = Object.keys(this.finishedChunks).length === this.chunksLength
})
worldEmitter.on('markAsLoaded', ({ x, z }) => {
this.markAsLoaded(x, z)
})
worldEmitter.on('updateLight', ({ pos }) => {
this.lightUpdate(pos.x, pos.z)
})
worldEmitter.on('time', (timeOfDay) => {
this.timeUpdated?.(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
// if (this instanceof WorldRendererThree) {
// (this).rerenderAllChunks?.()
// }
})
worldEmitter.emit('listening')
}
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}`
@ -604,7 +731,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
setSectionDirty (pos: Vec3, value = true, useChangeWorker = false) { // value false is used for unloading chunks
if (this.viewDistance === -1) throw new Error('viewDistance not set')
this.allChunksFinished = false
this.reactiveState.world.mesherWork = true
const distance = this.getDistance(pos)
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}`
@ -623,7 +750,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
y: pos.y,
z: pos.z,
value,
config: this.mesherConfig,
config: this.getMesherConfig(),
})
this.dispatchMessages()
}
@ -679,8 +806,24 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
}
destroy () {
console.warn('world destroy is not implemented')
}
// Stop all workers
for (const worker of this.workers) {
worker.terminate()
}
this.workers = []
abstract setHighlightCursorBlock (block: typeof this.cursorBlock, shapePositions?: Array<{ position; width; height; depth }>): void
// Stop and destroy sound system
if (this.soundSystem) {
this.soundSystem.destroy()
this.soundSystem = undefined
}
this.active = false
this.renderUpdateEmitter.removeAllListeners()
this.displayOptions.worldView.removeAllListeners() // todo
this.abortController.abort()
removeStat('chunks-loaded')
removeStat('chunks-read')
}
}

View file

@ -0,0 +1,70 @@
import { BlockModel } from 'mc-assets/dist/types'
import { ItemSpecificContextProperties } from 'renderer/viewer/lib/basePlayerState'
import { renderSlot } from '../../../src/inventoryWindows'
import { GeneralInputItem, getItemModelName } from '../../../src/mineflayer/items'
import { ResourcesManager } from '../../../src/resourcesManager'
export const getItemUv = (item: Record<string, any>, specificProps: ItemSpecificContextProperties, resourcesManager: ResourcesManager): {
u: number
v: number
su: number
sv: number
renderInfo?: ReturnType<typeof renderSlot>
texture: HTMLImageElement
modelName: string
} | {
resolvedModel: BlockModel
modelName: string
} => {
const resources = resourcesManager.currentResources
if (!resources) throw new Error('Resources not loaded')
const idOrName = item.itemId ?? item.blockId ?? item.name
try {
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)
const renderInfo = renderSlot({
modelName: model,
}, false, true)
if (!renderInfo) throw new Error(`Failed to get render info for item ${name}`)
const img = renderInfo.texture === 'blocks' ? resources.blocksAtlasImage : resources.itemsAtlasImage
if (renderInfo.blockData) {
return {
resolvedModel: renderInfo.blockData.resolvedModel,
modelName: renderInfo.modelName!
}
}
if (renderInfo.slice) {
// Get slice coordinates from either block or item texture
const [x, y, w, h] = renderInfo.slice
const [u, v, su, sv] = [x / img.width, y / img.height, (w / img.width), (h / img.height)]
return {
u, v, su, sv,
renderInfo,
texture: img,
modelName: renderInfo.modelName!
}
}
throw new Error(`Invalid render info for item ${name}`)
} catch (err) {
reportError?.(err)
// Return default UV coordinates for missing texture
return {
u: 0,
v: 0,
su: 16 / resources.blocksAtlasImage.width,
sv: 16 / resources.blocksAtlasImage.width,
texture: resources.blocksAtlasImage,
modelName: 'missing'
}
}
}

View file

@ -0,0 +1,82 @@
import * as THREE from 'three'
export class CameraShake {
private rollAngle = 0
private get damageRollAmount () { return 5 }
private get damageAnimDuration () { return 200 }
private rollAnimation?: { startTime: number, startRoll: number, targetRoll: number, duration: number, returnToZero?: boolean }
private basePitch = 0
private baseYaw = 0
constructor (public camera: THREE.Camera, public onRenderCallbacks: Array<() => void>) {
onRenderCallbacks.push(() => {
this.update()
})
}
setBaseRotation (pitch: number, yaw: number) {
this.basePitch = pitch
this.baseYaw = yaw
this.update()
}
shakeFromDamage (yaw?: number) {
// Add roll animation
const startRoll = this.rollAngle
const targetRoll = startRoll + (yaw ?? (Math.random() < 0.5 ? -1 : 1)) * this.damageRollAmount
this.rollAnimation = {
startTime: performance.now(),
startRoll,
targetRoll,
duration: this.damageAnimDuration / 2
}
}
update () {
// Update roll animation
if (this.rollAnimation) {
const now = performance.now()
const elapsed = now - this.rollAnimation.startTime
const progress = Math.min(elapsed / this.rollAnimation.duration, 1)
if (this.rollAnimation.returnToZero) {
// Ease back to zero
this.rollAngle = this.rollAnimation.startRoll * (1 - this.easeInOut(progress))
if (progress === 1) {
this.rollAnimation = undefined
}
} else {
// Initial roll
this.rollAngle = this.rollAnimation.startRoll + (this.rollAnimation.targetRoll - this.rollAnimation.startRoll) * this.easeOut(progress)
if (progress === 1) {
// Start return to zero animation
this.rollAnimation = {
startTime: now,
startRoll: this.rollAngle,
targetRoll: 0,
duration: this.damageAnimDuration / 2,
returnToZero: true
}
}
}
}
// 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))
// 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 {
return 1 - (1 - t) * (1 - t)
}
private easeInOut (t: number): number {
return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2
}
}

View file

@ -0,0 +1,240 @@
import * as THREE from 'three'
import Stats from 'stats.js'
import StatsGl from 'stats-gl'
import * as tween from '@tweenjs/tween.js'
import { GraphicsBackendConfig, GraphicsInitOptions } from '../../../src/appViewer'
export class DocumentRenderer {
readonly canvas = document.createElement('canvas')
readonly renderer: THREE.WebGLRenderer
private animationFrameId?: number
private lastRenderTime = 0
private previousWindowWidth = window.innerWidth
private previousWindowHeight = window.innerHeight
private renderedFps = 0
private fpsInterval: any
private readonly stats: TopRightStats
private paused = false
disconnected = false
preRender = () => { }
render = (sizeChanged: boolean) => { }
postRender = () => { }
sizeChanged = () => { }
droppedFpsPercentage: number
config: GraphicsBackendConfig
onRender = [] as Array<(sizeChanged: boolean) => void>
constructor (initOptions: GraphicsInitOptions) {
this.config = initOptions.config
try {
this.renderer = new THREE.WebGLRenderer({
canvas: this.canvas,
preserveDrawingBuffer: true,
logarithmicDepthBuffer: true,
powerPreference: this.config.powerPreference
})
} catch (err) {
initOptions.displayCriticalError(new Error(`Failed to create WebGL context, not possible to render (restart browser): ${err.message}`))
throw err
}
this.renderer.outputColorSpace = THREE.LinearSRGBColorSpace
this.updatePixelRatio()
this.updateSize()
this.addToPage()
this.stats = new TopRightStats(this.canvas, this.config.statsVisible)
this.setupFpsTracking()
this.startRenderLoop()
}
updatePixelRatio () {
let pixelRatio = window.devicePixelRatio || 1 // todo this value is too high on ios, need to check, probably we should use avg, also need to make it configurable
if (!this.renderer.capabilities.isWebGL2) {
pixelRatio = 1 // webgl1 has issues with high pixel ratio (sometimes screen is clipped)
}
this.renderer.setPixelRatio(pixelRatio)
}
updateSize () {
this.renderer.setSize(window.innerWidth, window.innerHeight)
}
private addToPage () {
this.canvas.id = 'viewer-canvas'
this.canvas.style.width = '100%'
this.canvas.style.height = '100%'
document.body.appendChild(this.canvas)
}
private setupFpsTracking () {
let max = 0
this.fpsInterval = setInterval(() => {
if (max > 0) {
this.droppedFpsPercentage = this.renderedFps / max
}
max = Math.max(this.renderedFps, max)
this.renderedFps = 0
}, 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.paused) return
// Handle FPS limiting
if (this.config.fpsLimit) {
const now = performance.now()
const elapsed = now - this.lastRenderTime
const fpsInterval = 1000 / this.config.fpsLimit
if (elapsed < fpsInterval) {
return
}
this.lastRenderTime = now - (elapsed % fpsInterval)
}
let sizeChanged = false
if (this.previousWindowWidth !== window.innerWidth || this.previousWindowHeight !== window.innerHeight) {
this.previousWindowWidth = window.innerWidth
this.previousWindowHeight = window.innerHeight
this.updateSize()
sizeChanged = true
}
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
if (this.config.statsVisible !== undefined) {
this.stats.setVisibility(this.config.statsVisible)
}
}
animate()
}
setPaused (paused: boolean) {
this.paused = paused
}
dispose () {
this.disconnected = true
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId)
}
this.canvas.remove()
this.renderer.dispose()
clearInterval(this.fpsInterval)
this.stats.dispose()
}
}
class TopRightStats {
private readonly stats: Stats
private readonly stats2: Stats
private readonly statsGl: StatsGl
private total = 0
private readonly denseMode: boolean
constructor (private readonly canvas: HTMLCanvasElement, initialStatsVisible = 0) {
this.stats = new Stats()
this.stats2 = new Stats()
this.statsGl = new StatsGl({ minimal: true })
this.stats2.showPanel(2)
this.denseMode = process.env.NODE_ENV === 'production' || window.innerHeight < 500
this.initStats()
this.setVisibility(initialStatsVisible)
}
private addStat (dom: HTMLElement, size = 80) {
dom.style.position = 'absolute'
if (this.denseMode) dom.style.height = '12px'
dom.style.overflow = 'hidden'
dom.style.left = ''
dom.style.top = '0'
dom.style.right = `${this.total}px`
dom.style.width = '80px'
dom.style.zIndex = '1'
dom.style.opacity = '0.8'
document.body.appendChild(dom)
this.total += size
}
private initStats () {
const hasRamPanel = this.stats2.dom.children.length === 3
this.addStat(this.stats.dom)
if (hasRamPanel) {
this.addStat(this.stats2.dom)
}
this.statsGl.init(this.canvas)
this.statsGl.container.style.display = 'flex'
this.statsGl.container.style.justifyContent = 'flex-end'
let i = 0
for (const _child of this.statsGl.container.children) {
const child = _child as HTMLElement
if (i++ === 0) {
child.style.display = 'none'
}
child.style.position = ''
}
}
setVisibility (level: number) {
const visible = level > 0
if (visible) {
this.stats.dom.style.display = 'block'
this.stats2.dom.style.display = level >= 2 ? 'block' : 'none'
this.statsGl.container.style.display = level >= 2 ? 'block' : 'none'
} else {
this.stats.dom.style.display = 'none'
this.stats2.dom.style.display = 'none'
this.statsGl.container.style.display = 'none'
}
}
markStart () {
this.stats.begin()
this.stats2.begin()
this.statsGl.begin()
}
markEnd () {
this.stats.end()
this.stats2.end()
this.statsGl.end()
}
dispose () {
this.stats.dom.remove()
this.stats2.dom.remove()
this.statsGl.container.remove()
}
}

View file

@ -14,17 +14,19 @@ import mojangson from 'mojangson'
import { snakeCase } from 'change-case'
import { Item } from 'prismarine-item'
import { BlockModel } from 'mc-assets'
import { isEntityAttackable } from 'mineflayer-mouse/dist/attackableEntity'
import { Vec3 } from 'vec3'
import { EntityMetadataVersions } from '../../../src/mcDataTypes'
import { ItemSpecificContextProperties } from '../lib/basePlayerState'
import { loadSkinImage, getLookupUrl, stevePngUrl, steveTexture } from '../lib/utils/skins'
import { loadTexture } from '../lib/utils'
import { getBlockMeshFromModel } from './holdingBlock'
import * as Entity from './entity/EntityMesh'
import { getMesh } from './entity/EntityMesh'
import { WalkingGeneralSwing } from './entity/animations'
import { disposeObject } from './threeJsUtils'
import { armorModel, armorTextures } from './entity/armorModels'
import { Viewer } from './viewer'
import { getBlockMeshFromModel } from './holdingBlock'
import { ItemSpecificContextProperties } from './basePlayerState'
import { loadSkinImage, getLookupUrl, stevePngUrl, steveTexture } from './utils/skins'
import { loadTexture } from './utils'
import { WorldRendererThree } from './worldrendererThree'
export const TWEEN_DURATION = 120
@ -167,7 +169,7 @@ const nametags = {}
const isFirstUpperCase = (str) => str.charAt(0) === str.charAt(0).toUpperCase()
function getEntityMesh (entity, world, options, overrides) {
function getEntityMesh (entity: import('prismarine-entity').Entity & { delete?: any; pos: any; name: any }, world: WorldRendererThree | undefined, options: { fontFamily: string }, overrides) {
if (entity.name) {
try {
// https://github.com/PrismarineJS/prismarine-viewer/pull/410
@ -183,6 +185,7 @@ function getEntityMesh (entity, world, options, overrides) {
}
}
if (!isEntityAttackable(loadedData, entity)) return
const geometry = new THREE.BoxGeometry(entity.width, entity.height, entity.width)
geometry.translate(0, entity.height / 2, 0)
const material = new THREE.MeshBasicMaterial({ color: 0xff_00_ff })
@ -206,30 +209,17 @@ export type SceneEntity = THREE.Object3D & {
additionalCleanup?: () => void
}
export class Entities extends EventEmitter {
export class Entities {
entities = {} as Record<string, SceneEntity>
entitiesOptions: {
fontFamily?: string
} = {}
entitiesOptions = {
fontFamily: 'mojangles'
}
debugMode: string
onSkinUpdate: () => void
clock = new THREE.Clock()
rendering = true
itemsTexture: THREE.Texture | null = null
currentlyRendering = true
cachedMapsImages = {} as Record<number, string>
itemFrameMaps = {} as Record<number, Array<THREE.Mesh<THREE.PlaneGeometry, THREE.MeshLambertMaterial>>>
getItemUv: undefined | ((item: Record<string, any>, specificProps: ItemSpecificContextProperties) => {
texture: THREE.Texture;
u: number;
v: number;
su?: number;
sv?: number;
size?: number;
modelName?: string;
} | {
resolvedModel: BlockModel
modelName: string
} | undefined)
get entitiesByName (): Record<string, SceneEntity[]> {
const byName: Record<string, SceneEntity[]> = {}
@ -245,16 +235,14 @@ export class Entities extends EventEmitter {
return Object.values(this.entities).filter(entity => entity.visible).length
}
constructor (public viewer: Viewer) {
super()
this.entitiesOptions = {}
constructor (public worldRenderer: WorldRendererThree) {
this.debugMode = 'none'
this.onSkinUpdate = () => { }
}
clear () {
for (const mesh of Object.values(this.entities)) {
this.viewer.scene.remove(mesh)
this.worldRenderer.scene.remove(mesh)
disposeObject(mesh)
}
this.entities = {}
@ -273,19 +261,24 @@ export class Entities extends EventEmitter {
}
setRendering (rendering: boolean, entity: THREE.Object3D | null = null) {
this.rendering = rendering
this.currentlyRendering = rendering
for (const ent of entity ? [entity] : Object.values(this.entities)) {
if (rendering) {
if (!this.viewer.scene.children.includes(ent)) this.viewer.scene.add(ent)
if (!this.worldRenderer.scene.children.includes(ent)) this.worldRenderer.scene.add(ent)
} else {
this.viewer.scene.remove(ent)
this.worldRenderer.scene.remove(ent)
}
}
}
render () {
const renderEntitiesConfig = this.worldRenderer.worldRendererConfig.renderEntities
if (renderEntitiesConfig !== this.currentlyRendering) {
this.setRendering(renderEntitiesConfig)
}
const dt = this.clock.getDelta()
const botPos = this.viewer.world.viewerPosition
const botPos = this.worldRenderer.viewerPosition
const VISIBLE_DISTANCE = 8 * 8
for (const entityId of Object.keys(this.entities)) {
@ -310,7 +303,7 @@ export class Entities extends EventEmitter {
const chunkKey = `${chunkX},${chunkZ}`
// Entity is visible if within 16 blocks OR in a finished chunk
entity.visible = !!(distanceSquared < VISIBLE_DISTANCE || this.viewer.world.finishedChunks[chunkKey])
entity.visible = !!(distanceSquared < VISIBLE_DISTANCE || this.worldRenderer.finishedChunks[chunkKey])
}
}
}
@ -349,7 +342,7 @@ export class Entities extends EventEmitter {
}
if (typeof skinUrl !== 'string') throw new Error('Invalid skin url')
const renderEars = this.viewer.world.config.renderEars || username === 'deadmau5'
const renderEars = this.worldRenderer.worldRendererConfig.renderEars || username === 'deadmau5'
void this.loadAndApplySkin(entityId, skinUrl, renderEars).then(() => {
if (capeUrl) {
if (capeUrl === true && username) {
@ -500,11 +493,12 @@ export class Entities extends EventEmitter {
}
getItemMesh (item, specificProps: ItemSpecificContextProperties, previousModel?: string) {
const textureUv = this.getItemUv?.(item, specificProps)
if (!item.nbt && item.nbtData) item.nbt = item.nbtData
const textureUv = this.worldRenderer.getItemRenderData(item, specificProps)
if (previousModel && previousModel === textureUv?.modelName) return undefined
if (textureUv && 'resolvedModel' in textureUv) {
const mesh = getBlockMeshFromModel(this.viewer.world.material, textureUv.resolvedModel, textureUv.modelName)
const mesh = getBlockMeshFromModel(this.worldRenderer.material, textureUv.resolvedModel, textureUv.modelName, this.worldRenderer.resourcesManager.currentResources!.worldBlockProvider)
let SCALE = 1
if (specificProps['minecraft:display_context'] === 'ground') {
SCALE = 0.5
@ -525,9 +519,11 @@ export class Entities extends EventEmitter {
// TODO: Render proper model (especially for blocks) instead of flat texture
if (textureUv) {
const textureThree = textureUv.renderInfo?.texture === 'blocks' ? this.worldRenderer.material.map! : this.worldRenderer.itemsTexture
// todo use geometry buffer uv instead!
const { u, v, size, su, sv, texture } = textureUv
const itemsTexture = texture.clone()
const { u, v, su, sv } = textureUv
const size = undefined
const itemsTexture = textureThree.clone()
itemsTexture.flipY = true
const sizeY = (sv ?? size)!
const sizeX = (su ?? size)!
@ -584,6 +580,8 @@ export class Entities extends EventEmitter {
}
update (entity: import('prismarine-entity').Entity & { delete?; pos, name }, overrides) {
const justAdded = !this.entities[entity.id]
const isPlayerModel = entity.name === 'player'
if (entity.name === 'zombie_villager' || entity.name === 'husk') {
overrides.texture = `textures/1.16.4/entity/${entity.name === 'zombie_villager' ? 'zombie_villager/zombie_villager.png' : `zombie/${entity.name}.png`}`
@ -601,8 +599,8 @@ export class Entities extends EventEmitter {
e.traverse(c => {
if (c['additionalCleanup']) c['additionalCleanup']()
})
this.emit('remove', entity)
this.viewer.scene.remove(e)
this.onRemoveEntity(entity)
this.worldRenderer.scene.remove(e)
disposeObject(e)
// todo dispose textures as well ?
delete this.entities[entity.id]
@ -675,7 +673,7 @@ export class Entities extends EventEmitter {
//@ts-expect-error
playerObject.animation.isMoving = false
} else {
mesh = getEntityMesh(entity, this.viewer.world, this.entitiesOptions, overrides)
mesh = getEntityMesh(entity, this.worldRenderer, this.entitiesOptions, overrides)
}
if (!mesh) return
mesh.name = 'mesh'
@ -694,20 +692,20 @@ export class Entities extends EventEmitter {
group.add(mesh)
group.add(boxHelper)
boxHelper.visible = false
this.viewer.scene.add(group)
this.worldRenderer.scene.add(group)
e = group
e.name = 'entity'
e['realName'] = entity.name
this.entities[entity.id] = e
this.emit('add', entity)
this.onAddEntity(entity)
if (isPlayerModel) {
this.updatePlayerSkin(entity.id, entity.username, entity.uuid, overrides?.texture || stevePngUrl)
}
this.setDebugMode(this.debugMode, group)
this.setRendering(this.rendering, group)
this.setRendering(this.currentlyRendering, group)
} else {
mesh = e.children.find(c => c.name === 'mesh')
}
@ -716,10 +714,10 @@ export class Entities extends EventEmitter {
if (entity.equipment) {
this.addItemModel(e, 'left', entity.equipment[0])
this.addItemModel(e, 'right', entity.equipment[1])
addArmorModel(e, 'feet', entity.equipment[2])
addArmorModel(e, 'legs', entity.equipment[3], 2)
addArmorModel(e, 'chest', entity.equipment[4])
addArmorModel(e, 'head', entity.equipment[5])
addArmorModel(this.worldRenderer, e, 'feet', entity.equipment[2])
addArmorModel(this.worldRenderer, e, 'legs', entity.equipment[3], 2)
addArmorModel(this.worldRenderer, e, 'chest', entity.equipment[4])
addArmorModel(this.worldRenderer, e, 'head', entity.equipment[5])
}
const meta = getGeneralEntitiesMetadata(entity)
@ -884,6 +882,30 @@ export class Entities extends EventEmitter {
e.username = entity.username
}
if (entity.type === 'player' && entity.equipment && e.playerObject) {
const { playerObject } = e
playerObject.backEquipment = entity.equipment.some((item) => item?.name === 'elytra') ? 'elytra' : 'cape'
if (playerObject.cape.map === null) {
playerObject.cape.visible = false
}
}
this.updateEntityPosition(entity, justAdded, overrides)
}
updateEntityPosition (entity: import('prismarine-entity').Entity, justAdded: boolean, overrides: { rotation?: { head?: { y: number, x: number } } }) {
const e = this.entities[entity.id]
if (!e) return
const ANIMATION_DURATION = justAdded ? 0 : TWEEN_DURATION
if (entity.position) {
new TWEEN.Tween(e.position).to({ x: entity.position.x, y: entity.position.y, z: entity.position.z }, ANIMATION_DURATION).start()
}
if (entity.yaw) {
const da = (entity.yaw - e.rotation.y) % (Math.PI * 2)
const dy = 2 * da % (Math.PI * 2) - da
new TWEEN.Tween(e.rotation).to({ y: e.rotation.y + dy }, ANIMATION_DURATION).start()
}
if (e?.playerObject && overrides?.rotation?.head) {
const playerObject = e.playerObject as PlayerObjectType
const headRotationDiff = overrides.rotation.head.y ? overrides.rotation.head.y - entity.yaw : 0
@ -891,16 +913,34 @@ export class Entities extends EventEmitter {
playerObject.skin.head.rotation.x = overrides.rotation.head.x ? - overrides.rotation.head.x : 0
}
if (entity.pos) {
new TWEEN.Tween(e.position).to({ x: entity.pos.x, y: entity.pos.y, z: entity.pos.z }, TWEEN_DURATION).start()
}
if (entity.yaw) {
const da = (entity.yaw - e.rotation.y) % (Math.PI * 2)
const dy = 2 * da % (Math.PI * 2) - da
new TWEEN.Tween(e.rotation).to({ y: e.rotation.y + dy }, TWEEN_DURATION).start()
this.maybeRenderPlayerSkin(entity)
}
onAddEntity (entity: import('prismarine-entity').Entity) {
}
loadedSkinEntityIds = new Set<number>()
maybeRenderPlayerSkin (entity: import('prismarine-entity').Entity) {
const mesh = this.entities[entity.id]
if (!mesh) return
if (!mesh.playerObject || !this.worldRenderer.worldRendererConfig.fetchPlayerSkins) return
const MAX_DISTANCE_SKIN_LOAD = 128
const cameraPos = this.worldRenderer.camera.position
const distance = entity.position.distanceTo(new Vec3(cameraPos.x, cameraPos.y, cameraPos.z))
if (distance < MAX_DISTANCE_SKIN_LOAD && distance < (this.worldRenderer.viewDistance * 16)) {
if (this.entities[entity.id]) {
if (this.loadedSkinEntityIds.has(entity.id)) return
this.loadedSkinEntityIds.add(entity.id)
this.updatePlayerSkin(entity.id, entity.username, entity.uuid, true, true)
}
}
}
playerPerAnimation = {} as Record<number, string>
onRemoveEntity (entity: import('prismarine-entity').Entity) {
this.loadedSkinEntityIds.delete(entity.id)
}
updateMap (mapNumber: string | number, data: string) {
this.cachedMapsImages[mapNumber] = data
let itemFrameMeshes = this.itemFrameMaps[mapNumber]
@ -983,7 +1023,7 @@ export class Entities extends EventEmitter {
const itemObject = this.getItemMesh(item, {
'minecraft:display_context': 'thirdperson',
})
if (itemObject) {
if (itemObject?.mesh) {
entityMesh.traverse(c => {
if (c.name.toLowerCase() === parentName) {
const group = new THREE.Object3D()
@ -1043,7 +1083,7 @@ function getSpecificEntityMetadata<T extends keyof EntityMetadataVersions> (name
return getGeneralEntitiesMetadata(entity) as any
}
function addArmorModel (entityMesh: THREE.Object3D, slotType: string, item: Item, layer = 1, overlay = false) {
function addArmorModel (worldRenderer: WorldRendererThree, entityMesh: THREE.Object3D, slotType: string, item: Item, layer = 1, overlay = false) {
if (!item) {
removeArmorModel(entityMesh, slotType)
return
@ -1077,7 +1117,7 @@ function addArmorModel (entityMesh: THREE.Object3D, slotType: string, item: Item
if (!texturePath) {
// TODO: Support mirroring on certain parts of the model
const armorTextureName = `${armorMaterial}_layer_${layer}${overlay ? '_overlay' : ''}`
texturePath = viewer.world.customTextures.armor?.textures[armorTextureName]?.src ?? armorTextures[armorTextureName]
texturePath = worldRenderer.resourcesManager.currentResources!.customTextures.armor?.textures[armorTextureName]?.src ?? armorTextures[armorTextureName]
}
if (!texturePath || !armorModel[slotType]) {
removeArmorModel(entityMesh, slotType)
@ -1098,7 +1138,7 @@ function addArmorModel (entityMesh: THREE.Object3D, slotType: string, item: Item
material.map = texture
})
} else {
mesh = getMesh(viewer.world, texturePath, armorModel[slotType])
mesh = getMesh(worldRenderer, texturePath, armorModel[slotType])
mesh.name = meshName
material = mesh.material
if (!isPlayerHead) {
@ -1115,7 +1155,7 @@ function addArmorModel (entityMesh: THREE.Object3D, slotType: string, item: Item
} else {
material.color.setHex(0xB5_6D_51) // default brown color
}
addArmorModel(entityMesh, slotType, item, layer, true)
addArmorModel(worldRenderer, entityMesh, slotType, item, layer, true)
} else {
material.color.setHex(0xFF_FF_FF)
}

View file

@ -5,8 +5,8 @@ import { Vec3 } from 'vec3'
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 { WorldRendererCommon } from '../worldrendererCommon'
import { loadTexture } from '../utils'
import { loadTexture } from '../../lib/utils'
import { WorldRendererThree } from '../worldrendererThree'
import entities from './entities.json'
import { externalModels } from './objModels'
import externalTexturesJson from './externalTextures.json'
@ -223,7 +223,7 @@ function addCube (
}
export function getMesh (
worldRenderer: WorldRendererCommon | undefined,
worldRenderer: WorldRendererThree | undefined,
texture: string,
jsonModel: JsonModel,
overrides: EntityOverrides = {},
@ -237,10 +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.blocksAtlasParser!.getTextureInfo(blockName)
const textureInfo = worldRenderer.resourcesManager.currentResources!.blocksAtlasParser.getTextureInfo(blockName)
if (textureInfo) {
textureWidth = blocksTexture!.image.width
textureHeight = blocksTexture!.image.height
textureWidth = blocksTexture?.image.width ?? textureWidth
textureHeight = blocksTexture?.image.height ?? textureHeight
textureOffset = [textureInfo.u, textureInfo.v]
} else {
console.error(`Unknown block ${blockName}`)
@ -437,7 +437,7 @@ export class EntityMesh {
constructor (
version: string,
type: string,
worldRenderer?: WorldRendererCommon,
worldRenderer?: WorldRendererThree,
overrides: EntityOverrides = {},
debugFlags: EntityDebugFlags = {}
) {

View file

@ -0,0 +1,119 @@
import * as THREE from 'three'
import { Vec3 } from 'vec3'
import { proxy } from 'valtio'
import { GraphicsBackendLoader, GraphicsBackend, GraphicsInitOptions, DisplayWorldOptions, RendererReactiveState } from '../../../src/appViewer'
import { ProgressReporter } from '../../../src/core/progressReporter'
import { WorldRendererThree } from './worldrendererThree'
import { DocumentRenderer } from './documentRenderer'
import { PanoramaRenderer } from './panorama'
// https://discourse.threejs.org/t/updates-to-color-management-in-three-js-r152/50791
THREE.ColorManagement.enabled = false
window.THREE = THREE
const getBackendMethods = (worldRenderer: WorldRendererThree) => {
return {
updateMap: worldRenderer.entities.updateMap.bind(worldRenderer.entities),
updateCustomBlock: worldRenderer.updateCustomBlock.bind(worldRenderer),
getBlockInfo: worldRenderer.getBlockInfo.bind(worldRenderer),
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),
rerenderAllChunks: worldRenderer.rerenderAllChunks.bind(worldRenderer),
addMedia: worldRenderer.media.addMedia.bind(worldRenderer.media),
destroyMedia: worldRenderer.media.destroyMedia.bind(worldRenderer.media),
setVideoPlaying: worldRenderer.media.setVideoPlaying.bind(worldRenderer.media),
setVideoSeeking: worldRenderer.media.setVideoSeeking.bind(worldRenderer.media),
setVideoVolume: worldRenderer.media.setVideoVolume.bind(worldRenderer.media),
setVideoSpeed: worldRenderer.media.setVideoSpeed.bind(worldRenderer.media),
shakeFromDamage: worldRenderer.cameraShake.shakeFromDamage.bind(worldRenderer.cameraShake),
onPageInteraction: worldRenderer.media.onPageInteraction.bind(worldRenderer.media),
}
}
export type ThreeJsBackendMethods = ReturnType<typeof getBackendMethods>
const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitOptions) => {
// Private state
const documentRenderer = new DocumentRenderer(initOptions)
globalThis.renderer = documentRenderer.renderer
let panoramaRenderer: PanoramaRenderer | null = null
let worldRenderer: WorldRendererThree | null = null
const startPanorama = () => {
if (worldRenderer) return
if (!panoramaRenderer) {
panoramaRenderer = new PanoramaRenderer(documentRenderer, initOptions, !!process.env.SINGLE_FILE_BUILD_MODE)
void panoramaRenderer.start()
window.panoramaRenderer = panoramaRenderer
}
}
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)
documentRenderer.render = (sizeChanged: boolean) => {
worldRenderer?.render(sizeChanged)
}
window.world = worldRenderer
}
const disconnect = () => {
if (panoramaRenderer) {
panoramaRenderer.dispose()
panoramaRenderer = null
}
if (documentRenderer) {
documentRenderer.dispose()
}
if (worldRenderer) {
worldRenderer.destroy()
worldRenderer = null
}
}
// Public interface
const backend: GraphicsBackend = {
id: 'threejs',
displayName: `three.js ${THREE.REVISION}`,
startPanorama,
startWorld,
disconnect,
setRendering (rendering) {
documentRenderer.setPaused(!rendering)
if (worldRenderer) worldRenderer.renderingActive = rendering
},
getDebugOverlay: () => ({
}),
updateCamera (pos: Vec3 | null, yaw: number, pitch: number) {
worldRenderer?.setFirstPersonCamera(pos, yaw, pitch)
},
get soundSystem () {
return worldRenderer?.soundSystem
},
get backendMethods () {
if (!worldRenderer) return undefined
return getBackendMethods(worldRenderer)
}
}
return backend
}
export default createGraphicsBackend

View file

@ -1,15 +1,16 @@
import * as THREE from 'three'
import * as tweenJs from '@tweenjs/tween.js'
import worldBlockProvider from 'mc-assets/dist/worldBlockProvider'
import worldBlockProvider, { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider'
import { BlockModel } from 'mc-assets'
import { getThreeBlockModelGroup, renderBlockThree, setBlockPosition } from './mesher/standaloneRenderer'
import { getMyHand } from './hand'
import { IPlayerState, MovementState } from './basePlayerState'
import { DebugGui } from './DebugGui'
import { SmoothSwitcher } from './smoothSwitcher'
import { watchProperty } from './utils/proxy'
import { getThreeBlockModelGroup, renderBlockThree, setBlockPosition } from '../lib/mesher/standaloneRenderer'
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 { WorldRendererThree } from './worldrendererThree'
import { disposeObject } from './threeJsUtils'
import { WorldRendererConfig } from './worldrendererCommon'
export type HandItemBlock = {
name?
@ -114,14 +115,17 @@ export default class HoldingBlock {
offHandModeLegacy = false
swingAnimator: HandSwingAnimator | undefined
playerState: IPlayerState
config: WorldRendererConfig
constructor (public playerState: IPlayerState, public config: WorldRendererConfig, public offHand = false) {
constructor (public worldRenderer: WorldRendererThree, public offHand = false) {
this.initCameraGroup()
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
@ -148,7 +152,9 @@ export default class HoldingBlock {
const item = this.playerState.getHeldItem(this.offHand)
if (item) {
void this.setNewItem(item)
} else if (!this.offHand) {
} else if (this.offHand) {
void this.setNewItem()
} else {
void this.setNewItem({
type: 'hand',
})
@ -327,7 +333,7 @@ export default class HoldingBlock {
let blockInner: THREE.Object3D | undefined
if (handItem.type === 'item' || handItem.type === 'block') {
const result = viewer.entities.getItemMesh({
const result = this.worldRenderer.entities.getItemMesh({
...handItem.fullItem,
itemId: handItem.id,
}, {
@ -901,8 +907,7 @@ class HandSwingAnimator {
}
}
export const getBlockMeshFromModel = (material: THREE.Material, model: BlockModel, name: string) => {
const blockProvider = worldBlockProvider(viewer.world.blockstatesModels, viewer.world.blocksAtlasParser!.atlas, 'latest')
export const getBlockMeshFromModel = (material: THREE.Material, model: BlockModel, name: string, blockProvider: WorldBlockProvider) => {
const worldRenderModel = blockProvider.transformModel(model, {
name,
properties: {}

View file

@ -0,0 +1,267 @@
import { join } from 'path'
import * as THREE from 'three'
import { getSyncWorld } from 'renderer/playground/shared'
import { Vec3 } from 'vec3'
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 { WorldRendererThree } from './worldrendererThree'
import { EntityMesh } from './entity/EntityMesh'
import { DocumentRenderer } from './documentRenderer'
const panoramaFiles = [
'panorama_3.png', // right (+x)
'panorama_1.png', // left (-x)
'panorama_4.png', // top (+y)
'panorama_5.png', // bottom (-y)
'panorama_0.png', // front (+z)
'panorama_2.png', // back (-z)
]
export class PanoramaRenderer {
private readonly camera: THREE.PerspectiveCamera
private scene: THREE.Scene
private readonly ambientLight: THREE.AmbientLight
private readonly directionalLight: THREE.DirectionalLight
private panoramaGroup: THREE.Object3D | null = null
private time = 0
private readonly abortController = new AbortController()
private worldRenderer: WorldRendererCommon | WorldRendererThree | undefined
public WorldRendererClass = WorldRendererThree
constructor (private readonly documentRenderer: DocumentRenderer, private readonly options: GraphicsInitOptions, private readonly doWorldBlocksPanorama = false) {
this.scene = new THREE.Scene()
this.scene.background = new THREE.Color(this.options.config.sceneBackground)
// Add ambient light
this.ambientLight = new THREE.AmbientLight(0xcc_cc_cc)
this.scene.add(this.ambientLight)
// Add directional light
this.directionalLight = new THREE.DirectionalLight(0xff_ff_ff, 0.5)
this.directionalLight.position.set(1, 1, 0.5).normalize()
this.directionalLight.castShadow = true
this.scene.add(this.directionalLight)
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)
}
async start () {
if (this.doWorldBlocksPanorama) {
await this.worldBlocksPanorama()
} else {
this.addClassicPanorama()
}
this.documentRenderer.render = (sizeChanged = false) => {
if (sizeChanged) {
this.camera.aspect = window.innerWidth / window.innerHeight
this.camera.updateProjectionMatrix()
}
this.documentRenderer.renderer.render(this.scene, this.camera)
}
}
addClassicPanorama () {
const panorGeo = new THREE.BoxGeometry(1000, 1000, 1000)
const loader = new THREE.TextureLoader()
const panorMaterials = [] as THREE.MeshBasicMaterial[]
for (const file of panoramaFiles) {
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
)
texture.wrapS = THREE.ClampToEdgeWrapping // Changed from RepeatWrapping
texture.wrapT = THREE.ClampToEdgeWrapping // Changed from RepeatWrapping
texture.minFilter = THREE.LinearFilter
texture.magFilter = THREE.LinearFilter
panorMaterials.push(new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
}))
}
const panoramaBox = new THREE.Mesh(panorGeo, panorMaterials)
panoramaBox.onBeforeRender = () => {
this.time += 0.01
panoramaBox.rotation.y = Math.PI + this.time * 0.01
panoramaBox.rotation.z = Math.sin(-this.time * 0.001) * 0.001
}
const group = new THREE.Object3D()
group.add(panoramaBox)
// Add squids
for (let i = 0; i < 20; i++) {
const m = new EntityMesh('1.16.4', 'squid').mesh
m.position.set(Math.random() * 30 - 15, Math.random() * 20 - 10, Math.random() * 10 - 17)
m.rotation.set(0, Math.PI + Math.random(), -Math.PI / 4, 'ZYX')
const v = Math.random() * 0.01
m.children[0].onBeforeRender = () => {
m.rotation.y += v
m.rotation.z = Math.cos(panoramaBox.rotation.y * 3) * Math.PI / 4 - Math.PI / 2
}
group.add(m)
}
this.scene.add(group)
this.panoramaGroup = group
}
async worldBlocksPanorama () {
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)
const PrismarineBlock = require('prismarine-block')
const Block = PrismarineBlock(version)
const fullBlocks = loadedData.blocksArray.filter(block => {
// if (block.name.includes('leaves')) return false
if (/* !block.name.includes('wool') && */!block.name.includes('stained_glass')/* && !block.name.includes('terracotta') */) return false
const b = Block.fromStateId(block.defaultState, 0)
if (b.shapes?.length !== 1) return false
const shape = b.shapes[0]
return shape[0] === 0 && shape[1] === 0 && shape[2] === 0 && shape[3] === 1 && shape[4] === 1 && shape[5] === 1
})
const Z = -15
const sizeX = 100
const sizeY = 100
for (let x = -sizeX; x < sizeX; x++) {
for (let y = -sizeY; y < sizeY; y++) {
const block = fullBlocks[Math.floor(Math.random() * fullBlocks.length)]
world.setBlockStateId(new Vec3(x, y, Z), block.defaultState)
}
}
this.camera.updateProjectionMatrix()
this.camera.position.set(0.5, sizeY / 2 + 0.5, 0.5)
this.camera.rotation.set(0, 0, 0)
const initPos = new Vec3(...this.camera.position.toArray())
const worldView = new WorldDataEmitter(world, 2, initPos)
// worldView.addWaitTime = 0
if (this.abortController.signal.aborted) return
this.worldRenderer = new this.WorldRendererClass(
this.documentRenderer.renderer,
this.options,
{
version,
worldView,
inWorldRenderingConfig: defaultWorldRendererConfig,
playerState: new BasePlayerState(),
rendererState: getDefaultRendererState(),
nonReactiveState: getDefaultRendererState()
}
)
if (this.worldRenderer instanceof WorldRendererThree) {
this.scene = this.worldRenderer.scene
}
void worldView.init(initPos)
await this.worldRenderer.waitForChunksToRender()
if (this.abortController.signal.aborted) return
// add small camera rotation to side on mouse move depending on absolute position of the cursor
const { camera } = this
const initX = camera.position.x
const initY = camera.position.y
let prevTwin: tweenJs.Tween<THREE.Vector3> | undefined
document.body.addEventListener('pointermove', (e) => {
if (e.pointerType !== 'mouse') return
const pos = new THREE.Vector2(e.clientX, e.clientY)
const SCALE = 0.2
/* -0.5 - 0.5 */
const xRel = pos.x / window.innerWidth - 0.5
const yRel = -(pos.y / window.innerHeight - 0.5)
prevTwin?.stop()
const to = {
x: initX + (xRel * SCALE),
y: initY + (yRel * SCALE)
}
prevTwin = new tweenJs.Tween(camera.position).to(to, 0) // todo use the number depending on diff // todo use the number depending on diff
// prevTwin.easing(tweenJs.Easing.Exponential.InOut)
prevTwin.start()
camera.updateProjectionMatrix()
}, {
signal: this.abortController.signal
})
console.timeEnd('load panorama scene')
}
dispose () {
this.scene.clear()
this.worldRenderer?.destroy()
this.abortController.abort()
}
}
// export class ClassicPanoramaRenderer {
// panoramaGroup: THREE.Object3D
// constructor (private readonly backgroundFiles: string[], onRender: Array<(sizeChanged: boolean) => void>, addSquids = true) {
// const panorGeo = new THREE.BoxGeometry(1000, 1000, 1000)
// const loader = new THREE.TextureLoader()
// const panorMaterials = [] as THREE.MeshBasicMaterial[]
// for (const file of this.backgroundFiles) {
// const texture = loader.load(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
// )
// texture.wrapS = THREE.ClampToEdgeWrapping // Changed from RepeatWrapping
// texture.wrapT = THREE.ClampToEdgeWrapping // Changed from RepeatWrapping
// texture.minFilter = THREE.LinearFilter
// texture.magFilter = THREE.LinearFilter
// panorMaterials.push(new THREE.MeshBasicMaterial({
// map: texture,
// transparent: true,
// side: THREE.DoubleSide,
// depthWrite: false,
// }))
// }
// const panoramaBox = new THREE.Mesh(panorGeo, panorMaterials)
// panoramaBox.onBeforeRender = () => {
// }
// const group = new THREE.Object3D()
// group.add(panoramaBox)
// if (addSquids) {
// // Add squids
// for (let i = 0; i < 20; i++) {
// const m = new EntityMesh('1.16.4', 'squid').mesh
// m.position.set(Math.random() * 30 - 15, Math.random() * 20 - 10, Math.random() * 10 - 17)
// m.rotation.set(0, Math.PI + Math.random(), -Math.PI / 4, 'ZYX')
// const v = Math.random() * 0.01
// onRender.push(() => {
// m.rotation.y += v
// m.rotation.z = Math.cos(panoramaBox.rotation.y * 3) * Math.PI / 4 - Math.PI / 2
// })
// group.add(m)
// }
// }
// this.panoramaGroup = group
// }
// }

View file

@ -0,0 +1,550 @@
import * as THREE from 'three'
import { sendVideoPlay, sendVideoStop } from '../../../src/customChannels'
import { WorldRendererThree } from './worldrendererThree'
import { ThreeJsSound } from './threeJsSound'
interface MediaProperties {
position: { x: number, y: number, z: number }
size: { width: number, height: number }
src: string
rotation?: 0 | 1 | 2 | 3 // 0-3 for 0°, 90°, 180°, 270°
doubleSide?: boolean
background?: number // Hexadecimal color (e.g., 0x000000 for black)
opacity?: number // 0-1 value for transparency
uvMapping?: { startU: number, endU: number, startV: number, endV: number }
allowOrigins?: string[] | boolean
loop?: boolean
volume?: number
autoPlay?: boolean
}
export class ThreeJsMedia {
customMedia = new Map<string, {
mesh: THREE.Object3D
props: MediaProperties
video: HTMLVideoElement | undefined
texture: THREE.Texture
updateUVMapping: (config: { startU: number, endU: number, startV: number, endV: number }) => void
positionalAudio?: THREE.PositionalAudio
hadAutoPlayError?: boolean
}>()
constructor (private readonly worldRenderer: WorldRendererThree) {
}
private createErrorTexture (width: number, height: number, background = 0x00_00_00, error = 'Failed to load'): THREE.CanvasTexture {
const canvas = document.createElement('canvas')
// 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)
// Clear with transparent background
ctx.clearRect(0, 0, canvas.width, canvas.height)
// Add background color
ctx.fillStyle = `rgba(${background >> 16 & 255}, ${background >> 8 & 255}, ${background & 255}, 0.5)`
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Add red text
ctx.fillStyle = '#ff0000'
ctx.font = 'bold 10px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(error, canvas.width / 2, canvas.height / 2, canvas.width)
const texture = new THREE.CanvasTexture(canvas)
texture.minFilter = THREE.LinearFilter
texture.magFilter = THREE.LinearFilter
return texture
}
private createBackgroundTexture (width: number, height: number, color = 0x00_00_00, opacity = 1): THREE.CanvasTexture {
const canvas = document.createElement('canvas')
canvas.width = 1
canvas.height = 1
const ctx = canvas.getContext('2d')
if (!ctx) return new THREE.CanvasTexture(canvas)
// Convert hex color to rgba
const r = (color >> 16) & 255
const g = (color >> 8) & 255
const b = color & 255
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${opacity})`
ctx.fillRect(0, 0, 1, 1)
const texture = new THREE.CanvasTexture(canvas)
texture.minFilter = THREE.NearestFilter
texture.magFilter = THREE.NearestFilter
return texture
}
validateOrigin (src: string, allowOrigins: string[] | boolean) {
if (allowOrigins === true) return true
if (allowOrigins === false) return false
const url = new URL(src)
return allowOrigins.some(origin => url.origin.endsWith(origin))
}
onPageInteraction () {
for (const [id, videoData] of this.customMedia.entries()) {
if (videoData.hadAutoPlayError) {
videoData.hadAutoPlayError = false
void videoData.video?.play()
.catch(err => {
if (err.name === 'AbortError') return
console.error('Failed to play video:', err)
videoData.hadAutoPlayError = true
return true
})
.then((fromCatch) => {
if (fromCatch) return
if (videoData.positionalAudio) {
// workaround: audio has to be recreated
this.addMedia(id, videoData.props)
}
})
}
}
}
addMedia (id: string, props: MediaProperties) {
const originalProps = structuredClone(props)
this.destroyMedia(id)
const { scene } = this.worldRenderer
const originSecurityError = props.allowOrigins !== undefined && !this.validateOrigin(props.src, props.allowOrigins)
if (originSecurityError) {
console.warn('Remote resource blocked due to security policy', props.src, 'allowed origins:', props.allowOrigins, 'you can control it with `remoteContentNotSameOrigin` option')
props.src = ''
}
const isImage = props.src.endsWith('.png') || props.src.endsWith('.jpg') || props.src.endsWith('.jpeg')
let video: HTMLVideoElement | undefined
let positionalAudio: THREE.PositionalAudio | undefined
if (!isImage) {
video = document.createElement('video')
video.src = props.src.endsWith('.gif') ? props.src.replace('.gif', '.mp4') : props.src
video.loop = props.loop ?? true
video.volume = props.volume ?? 1
video.playsInline = true
video.crossOrigin = 'anonymous'
// Create positional audio
const soundSystem = this.worldRenderer.soundSystem as ThreeJsSound
soundSystem.initAudioListener()
if (!soundSystem.audioListener) throw new Error('Audio listener not initialized')
positionalAudio = new THREE.PositionalAudio(soundSystem.audioListener)
positionalAudio.setRefDistance(6)
positionalAudio.setVolume(props.volume ?? 1)
scene.add(positionalAudio)
positionalAudio.position.set(props.position.x, props.position.y, props.position.z)
// Connect video to positional audio
positionalAudio.setMediaElementSource(video)
positionalAudio.connect()
video.addEventListener('pause', () => {
positionalAudio?.pause()
sendVideoStop(id, 'paused', video!.currentTime)
})
video.addEventListener('play', () => {
positionalAudio?.play()
sendVideoPlay(id)
})
video.addEventListener('seeked', () => {
if (positionalAudio && video) {
positionalAudio.offset = video.currentTime
}
})
video.addEventListener('stalled', () => {
sendVideoStop(id, 'stalled', video!.currentTime)
})
video.addEventListener('waiting', () => {
sendVideoStop(id, 'waiting', video!.currentTime)
})
video.addEventListener('error', ({ error }) => {
sendVideoStop(id, `error: ${error}`, video!.currentTime)
})
video.addEventListener('ended', () => {
sendVideoStop(id, 'ended', video!.currentTime)
})
}
// Create background texture first
const backgroundTexture = this.createBackgroundTexture(
props.size.width,
props.size.height,
props.background,
// props.opacity ?? 1
)
const handleError = (text?: string) => {
const errorTexture = this.createErrorTexture(props.size.width, props.size.height, props.background, text)
material.map = errorTexture
material.needsUpdate = true
}
// Create a plane geometry with configurable UV mapping
const geometry = new THREE.PlaneGeometry(1, 1)
// Create material with initial properties using background texture
const material = new THREE.MeshLambertMaterial({
map: backgroundTexture,
transparent: true,
side: props.doubleSide ? THREE.DoubleSide : THREE.FrontSide,
alphaTest: 0.1
})
const texture = video
? new THREE.VideoTexture(video)
: new THREE.TextureLoader().load(props.src, () => {
if (this.customMedia.get(id)?.texture === texture) {
material.map = texture
material.needsUpdate = true
}
}, undefined, () => handleError()) // todo cache
texture.minFilter = THREE.NearestFilter
texture.magFilter = THREE.NearestFilter
// texture.format = THREE.RGBAFormat
// texture.colorSpace = THREE.SRGBColorSpace
texture.generateMipmaps = false
// Create inner mesh for offsets
const mesh = new THREE.Mesh(geometry, material)
const { mesh: panel } = this.positionMeshExact(mesh, THREE.MathUtils.degToRad((props.rotation ?? 0) * 90), props.position, props.size.width, props.size.height)
scene.add(panel)
if (video) {
// Start playing the video
video.play().catch(err => {
if (err.name === 'AbortError') return
console.error('Failed to play video:', err)
videoData.hadAutoPlayError = true
handleError(err.name === 'NotAllowedError' ? 'Waiting for user interaction' : 'Failed to auto play')
})
// Update texture in animation loop
mesh.onBeforeRender = () => {
if (video.readyState === video.HAVE_ENOUGH_DATA && (!video.paused || !videoData?.hadAutoPlayError)) {
if (material.map !== texture) {
material.map = texture
material.needsUpdate = true
}
texture.needsUpdate = true
// Sync audio position with video position
if (positionalAudio) {
positionalAudio.position.copy(panel.position)
positionalAudio.rotation.copy(panel.rotation)
}
}
}
}
// UV mapping configuration
const updateUVMapping = (config: { startU: number, endU: number, startV: number, endV: number }) => {
const uvs = geometry.attributes.uv.array as Float32Array
uvs[0] = config.startU
uvs[1] = config.startV
uvs[2] = config.endU
uvs[3] = config.startV
uvs[4] = config.endU
uvs[5] = config.endV
uvs[6] = config.startU
uvs[7] = config.endV
geometry.attributes.uv.needsUpdate = true
}
// Apply initial UV mapping if provided
if (props.uvMapping) {
updateUVMapping(props.uvMapping)
}
const videoData = {
mesh: panel,
video,
texture,
updateUVMapping,
positionalAudio,
props: originalProps,
hadAutoPlayError: false
}
// Store video data
this.customMedia.set(id, videoData)
return id
}
setVideoPlaying (id: string, playing: boolean) {
const videoData = this.customMedia.get(id)
if (videoData?.video) {
if (playing) {
videoData.video.play().catch(console.error)
} else {
videoData.video.pause()
}
}
}
setVideoSeeking (id: string, seconds: number) {
const videoData = this.customMedia.get(id)
if (videoData?.video) {
videoData.video.currentTime = seconds
}
}
setVideoVolume (id: string, volume: number) {
const videoData = this.customMedia.get(id)
if (videoData?.video) {
videoData.video.volume = volume
}
}
setVideoSpeed (id: string, speed: number) {
const videoData = this.customMedia.get(id)
if (videoData?.video) {
videoData.video.playbackRate = speed
}
}
destroyMedia (id: string) {
const { scene } = this.worldRenderer
const mediaData = this.customMedia.get(id)
if (mediaData) {
if (mediaData.video) {
mediaData.video.pause()
mediaData.video.src = ''
mediaData.video.remove()
}
if (mediaData.positionalAudio) {
// mediaData.positionalAudio.stop()
// mediaData.positionalAudio.disconnect()
scene.remove(mediaData.positionalAudio)
}
scene.remove(mediaData.mesh)
mediaData.texture.dispose()
// Get the inner mesh from the group
const mesh = mediaData.mesh.children[0] as THREE.Mesh
if (mesh) {
mesh.geometry.dispose()
if (mesh.material instanceof THREE.Material) {
mesh.material.dispose()
}
}
this.customMedia.delete(id)
}
}
/**
* Positions a mesh exactly at startPosition and extends it along the rotation direction
* with the specified width and height
*
* @param mesh The mesh to position
* @param rotation Rotation in radians (applied to Y axis)
* @param startPosition The exact starting position (corner) of the mesh
* @param width Width of the mesh
* @param height Height of the mesh
* @param depth Depth of the mesh (default: 1)
* @returns The positioned mesh for chaining
*/
positionMeshExact (
mesh: THREE.Mesh,
rotation: number,
startPosition: { x: number, y: number, z: number },
width: number,
height: number,
depth = 1
) {
// avoid z-fighting with the ground plane
if (rotation === 0) {
startPosition.z += 0.001
}
if (rotation === Math.PI / 2) {
startPosition.x -= 0.001
}
if (rotation === Math.PI) {
startPosition.z -= 0.001
}
if (rotation === 3 * Math.PI / 2) {
startPosition.x += 0.001
}
// rotation normalize coordinates
if (rotation === 0) {
startPosition.z += 1
}
if (rotation === Math.PI) {
startPosition.x += 1
}
if (rotation === 3 * Math.PI / 2) {
startPosition.z += 1
startPosition.x += 1
}
// First, clean up any previous transformations
mesh.matrix.identity()
mesh.position.set(0, 0, 0)
mesh.rotation.set(0, 0, 0)
mesh.scale.set(1, 1, 1)
// By default, PlaneGeometry creates a plane in the XY plane (facing +Z)
// We need to set up the proper orientation for our use case
// Rotate the plane to face the correct direction based on the rotation parameter
mesh.rotateY(rotation)
if (rotation === Math.PI / 2 || rotation === 3 * Math.PI / 2) {
mesh.rotateZ(-Math.PI)
mesh.rotateX(-Math.PI)
}
// Scale it to the desired size
mesh.scale.set(width, height, depth)
// For a PlaneGeometry, if we want the corner at the origin, we need to offset
// by half the dimensions after scaling
mesh.geometry.translate(0.5, 0.5, 0)
mesh.geometry.attributes.position.needsUpdate = true
// Now place the mesh at the start position
mesh.position.set(startPosition.x, startPosition.y, startPosition.z)
// Create a group to hold our mesh and markers
const debugGroup = new THREE.Group()
debugGroup.add(mesh)
// Add a marker at the starting position (should be exactly at pos)
const startMarker = new THREE.Mesh(
new THREE.BoxGeometry(0.1, 0.1, 0.1),
new THREE.MeshBasicMaterial({ color: 0xff_00_00 })
)
startMarker.position.copy(new THREE.Vector3(startPosition.x, startPosition.y, startPosition.z))
debugGroup.add(startMarker)
// Add a marker at the end position (width units away in the rotated direction)
const endX = startPosition.x + Math.cos(rotation) * width
const endZ = startPosition.z + Math.sin(rotation) * width
const endYMarker = new THREE.Mesh(
new THREE.BoxGeometry(0.1, 0.1, 0.1),
new THREE.MeshBasicMaterial({ color: 0x00_00_ff })
)
endYMarker.position.set(startPosition.x, startPosition.y + height, startPosition.z)
debugGroup.add(endYMarker)
// Add a marker at the width endpoint
const endWidthMarker = new THREE.Mesh(
new THREE.BoxGeometry(0.1, 0.1, 0.1),
new THREE.MeshBasicMaterial({ color: 0xff_ff_00 })
)
endWidthMarker.position.set(endX, startPosition.y, endZ)
debugGroup.add(endWidthMarker)
// Add a marker at the corner diagonal endpoint (both width and height)
const endCornerMarker = new THREE.Mesh(
new THREE.BoxGeometry(0.1, 0.1, 0.1),
new THREE.MeshBasicMaterial({ color: 0xff_00_ff })
)
endCornerMarker.position.set(endX, startPosition.y + height, endZ)
debugGroup.add(endCornerMarker)
// Also add a visual helper to show the rotation direction
const directionHelper = new THREE.ArrowHelper(
new THREE.Vector3(Math.cos(rotation), 0, Math.sin(rotation)),
new THREE.Vector3(startPosition.x, startPosition.y, startPosition.z),
1,
0xff_00_00
)
debugGroup.add(directionHelper)
return {
mesh,
debugGroup
}
}
createTestCanvasTexture () {
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')
if (!ctx) return null
ctx.font = '10px Arial'
ctx.fillStyle = 'red'
ctx.fillText('Hello World', 0, 10) // at
return new THREE.CanvasTexture(canvas)
}
/**
* Creates a test mesh that demonstrates the exact positioning
*/
addTestMeshExact (rotationNum: number) {
const pos = window.cursorBlockRel().position
console.log('Creating exact positioned test mesh at:', pos)
// Create a plane mesh with a wireframe to visualize boundaries
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(1, 1),
new THREE.MeshBasicMaterial({
// side: THREE.DoubleSide,
map: this.createTestCanvasTexture()
})
)
const width = 2
const height = 1
const rotation = THREE.MathUtils.degToRad(rotationNum * 90) // 90 degrees in radians
// Position the mesh exactly where we want it
const { debugGroup } = this.positionMeshExact(plane, rotation, pos, width, height)
this.worldRenderer.scene.add(debugGroup)
console.log('Exact test mesh added with dimensions:', width, height, 'and rotation:', rotation)
}
tryIntersectMedia () {
const { camera } = this.worldRenderer
const raycaster = new THREE.Raycaster()
// Get mouse position at center of screen
const mouse = new THREE.Vector2(0, 0)
// Update the raycaster
raycaster.setFromCamera(mouse, camera)
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
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
}
}
}
this.worldRenderer.reactiveState.world.intersectMedia = result
this.worldRenderer['debugVideo'] = result ? this.customMedia.get(result.id) : null
this.worldRenderer.cursorBlock.cursorLinesHidden = !!result
}
}

View file

@ -0,0 +1,15 @@
import type { GraphicsBackend } from '../../../src/appViewer'
import type { ThreeJsBackendMethods } from './graphicsBackend'
export function getThreeJsRendererMethods (): ThreeJsBackendMethods | undefined {
const renderer = appViewer.backend
if (renderer?.id !== 'threejs' || !renderer.backendMethods) return
return new Proxy(renderer.backendMethods, {
get (target, prop) {
return async (...args) => {
const result = await (target[prop as any] as any)(...args)
return result
}
}
}) as ThreeJsBackendMethods
}

View file

@ -0,0 +1,67 @@
import * as THREE from 'three'
import { WorldRendererThree } from './worldrendererThree'
export interface SoundSystem {
playSound: (position: { x: number, y: number, z: number }, path: string, volume?: number, pitch?: number) => void
destroy: () => void
}
export class ThreeJsSound implements SoundSystem {
audioListener: THREE.AudioListener | undefined
private readonly activeSounds = new Set<THREE.PositionalAudio>()
private readonly audioContext: AudioContext | undefined
constructor (public worldRenderer: WorldRendererThree) {
}
initAudioListener () {
if (this.audioListener) return
this.audioListener = new THREE.AudioListener()
this.worldRenderer.camera.add(this.audioListener)
}
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)
const audioLoader = new THREE.AudioLoader()
const start = Date.now()
void audioLoader.loadAsync(path).then((buffer) => {
if (Date.now() - start > 500) return
// play
sound.setBuffer(buffer)
sound.setRefDistance(20)
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)
sound.disconnect()
this.activeSounds.delete(sound)
audioLoader.manager.itemEnd(path)
}
sound.play()
})
}
destroy () {
// 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()
this.audioListener = undefined
}
}
playTestSound () {
this.playSound(this.worldRenderer.camera.position, '/sound.mp3')
}
}

View file

@ -0,0 +1,156 @@
import * as THREE from 'three'
import { LineMaterial, LineSegmentsGeometry, Wireframe } from 'three-stdlib'
import { Vec3 } from 'vec3'
import { subscribeKey } from 'valtio/utils'
import { Block } from 'prismarine-block'
import { WorldRendererThree } from '../worldrendererThree'
import destroyStage0 from '../../../../assets/destroy_stage_0.png'
import destroyStage1 from '../../../../assets/destroy_stage_1.png'
import destroyStage2 from '../../../../assets/destroy_stage_2.png'
import destroyStage3 from '../../../../assets/destroy_stage_3.png'
import destroyStage4 from '../../../../assets/destroy_stage_4.png'
import destroyStage5 from '../../../../assets/destroy_stage_5.png'
import destroyStage6 from '../../../../assets/destroy_stage_6.png'
import destroyStage7 from '../../../../assets/destroy_stage_7.png'
import destroyStage8 from '../../../../assets/destroy_stage_8.png'
import destroyStage9 from '../../../../assets/destroy_stage_9.png'
export class CursorBlock {
_cursorLinesHidden = false
get cursorLinesHidden () {
return this._cursorLinesHidden
}
set cursorLinesHidden (value: boolean) {
if (this.interactionLines) {
this.interactionLines.mesh.visible = !value
}
this._cursorLinesHidden = value
}
cursorLineMaterial: LineMaterial
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++) {
const texture = loader.load(destroyStagesImages[i])
texture.magFilter = THREE.NearestFilter
texture.minFilter = THREE.NearestFilter
this.breakTextures.push(texture)
}
const breakMaterial = new THREE.MeshBasicMaterial({
transparent: true,
blending: THREE.MultiplyBlending,
alphaTest: 0.5,
})
this.blockBreakMesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), breakMaterial)
this.blockBreakMesh.visible = false
this.blockBreakMesh.renderOrder = 999
this.blockBreakMesh.name = 'blockBreakMesh'
this.worldRenderer.scene.add(this.blockBreakMesh)
subscribeKey(this.worldRenderer.playerState.reactive, 'gameMode', () => {
this.updateLineMaterial()
})
this.updateLineMaterial()
}
// Update functions
updateLineMaterial () {
const inCreative = this.worldRenderer.displayOptions.playerState.reactive.gameMode === 'creative'
const pixelRatio = this.worldRenderer.renderer.getPixelRatio()
this.cursorLineMaterial = new LineMaterial({
color: (() => {
switch (this.worldRenderer.worldRendererConfig.highlightBlockColor) {
case 'blue':
return 0x40_80_ff
case 'classic':
return 0x00_00_00
default:
return inCreative ? 0x40_80_ff : 0x00_00_00
}
})(),
linewidth: Math.max(pixelRatio * 0.7, 1) * 2,
// dashed: true,
// dashSize: 5,
})
this.prevColor = this.worldRenderer.worldRendererConfig.highlightBlockColor
}
updateBreakAnimation (block: Block | undefined, stage: number | null) {
this.hideBreakAnimation()
if (stage === null || !block) return
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(block.position)
this.blockBreakMesh.position.set(position.x, position.y, position.z)
this.blockBreakMesh.visible = true;
(this.blockBreakMesh.material as THREE.MeshBasicMaterial).map = this.breakTextures[stage] ?? this.breakTextures.at(-1);
(this.blockBreakMesh.material as THREE.MeshBasicMaterial).needsUpdate = true
}
hideBreakAnimation () {
if (this.blockBreakMesh) {
this.blockBreakMesh.visible = false
}
}
updateDisplay () {
if (this.cursorLineMaterial) {
const { renderer } = this.worldRenderer
this.cursorLineMaterial.resolution.set(renderer.domElement.width, renderer.domElement.height)
this.cursorLineMaterial.dashOffset = performance.now() / 750
}
}
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) {
this.worldRenderer.scene.remove(this.interactionLines.mesh)
this.interactionLines = null
}
if (blockPos === null) {
return
}
const group = new THREE.Group()
for (const { position, width, height, depth } of shapePositions ?? []) {
const scale = [1.0001 * width, 1.0001 * height, 1.0001 * depth] as const
const geometry = new THREE.BoxGeometry(...scale)
const lines = new LineSegmentsGeometry().fromEdgesGeometry(new THREE.EdgesGeometry(geometry))
const wireframe = new Wireframe(lines, this.cursorLineMaterial)
const pos = blockPos.plus(position)
wireframe.position.set(pos.x, pos.y, pos.z)
wireframe.computeLineDistances()
group.add(wireframe)
}
this.worldRenderer.scene.add(group)
group.visible = this.cursorLinesHidden
this.interactionLines = { blockPos, mesh: group }
}
render () {
if (this.prevColor !== this.worldRenderer.worldRendererConfig.highlightBlockColor) {
this.updateLineMaterial()
}
this.updateDisplay()
}
}

View file

@ -3,41 +3,38 @@ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { XRControllerModelFactory } from 'three/examples/jsm/webxr/XRControllerModelFactory.js'
import { buttonMap as standardButtonsMap } from 'contro-max/build/gamepad'
import * as THREE from 'three'
import { subscribeKey } from 'valtio/utils'
import { subscribe } from 'valtio'
import { activeModalStack, hideModal } from './globalState'
import { watchUnloadForCleanup } from './gameUnload'
import { options } from './optionsStorage'
import { WorldRendererThree } from '../worldrendererThree'
export async function initVR () {
options.vrSupport = true
const { renderer } = viewer
if (!('xr' in navigator)) return
export async function initVR (worldRenderer: WorldRendererThree) {
if (!('xr' in navigator) || !worldRenderer.worldRendererConfig.vrSupport) return
const { renderer } = worldRenderer
const isSupported = await checkVRSupport()
if (!isSupported) return
enableVr(renderer)
enableVr()
const vrButtonContainer = createVrButtonContainer(renderer)
const updateVrButtons = () => {
vrButtonContainer.hidden = !options.vrSupport || activeModalStack.length !== 0
const newHidden = !worldRenderer.worldRendererConfig.vrSupport || !worldRenderer.worldRendererConfig.foreground
if (vrButtonContainer.hidden !== newHidden) {
vrButtonContainer.hidden = newHidden
}
}
const unsubWatchSetting = subscribeKey(options, 'vrSupport', updateVrButtons)
const unsubWatchModals = subscribe(activeModalStack, updateVrButtons)
worldRenderer.onRender.push(updateVrButtons)
function enableVr (renderer) {
function enableVr () {
renderer.xr.enabled = true
worldRenderer.reactiveState.preventEscapeMenu = true
}
function disableVr () {
renderer.xr.enabled = false
viewer.cameraObjectOverride = undefined
viewer.scene.remove(user)
worldRenderer.cameraObjectOverride = undefined
worldRenderer.reactiveState.preventEscapeMenu = false
worldRenderer.scene.remove(user)
vrButtonContainer.hidden = true
unsubWatchSetting()
unsubWatchModals()
}
function createVrButtonContainer (renderer) {
@ -84,7 +81,7 @@ export async function initVR () {
closeButton.addEventListener('click', () => {
container.hidden = true
options.vrSupport = false
worldRenderer.worldRendererConfig.vrSupport = false
})
return closeButton
@ -103,8 +100,8 @@ export async function initVR () {
// hack for vr camera
const user = new THREE.Group()
user.add(viewer.camera)
viewer.scene.add(user)
user.add(worldRenderer.camera)
worldRenderer.scene.add(user)
const controllerModelFactory = new XRControllerModelFactory(new GLTFLoader())
const controller1 = renderer.xr.getControllerGrip(0)
const controller2 = renderer.xr.getControllerGrip(1)
@ -191,8 +188,8 @@ export async function initVR () {
rotSnapReset = true
}
// viewer.setFirstPersonCamera(null, yawOffset, 0)
viewer.setFirstPersonCamera(null, bot.entity.yaw, bot.entity.pitch)
// appViewer.backend?.updateCamera(null, yawOffset, 0)
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()
@ -203,20 +200,16 @@ export async function initVR () {
// todo ?
// bot.physics.stepHeight = 1
viewer.render()
worldRenderer.render()
})
renderer.xr.addEventListener('sessionstart', () => {
viewer.cameraObjectOverride = user
// close all modals to be in game
for (const _modal of activeModalStack) {
hideModal(undefined, {}, { force: true })
}
worldRenderer.cameraObjectOverride = user
})
renderer.xr.addEventListener('sessionend', () => {
viewer.cameraObjectOverride = undefined
worldRenderer.cameraObjectOverride = undefined
})
watchUnloadForCleanup(disableVr)
worldRenderer.abortController.signal.addEventListener('abort', disableVr)
}
const xrStandardRightButtonsMap = [

View file

@ -3,23 +3,33 @@ import { Vec3 } from 'vec3'
import nbt from 'prismarine-nbt'
import PrismarineChatLoader from 'prismarine-chat'
import * as tweenJs from '@tweenjs/tween.js'
import { BloomPass, RenderPass, UnrealBloomPass, EffectComposer, WaterPass, GlitchPass, LineSegmentsGeometry, Wireframe, LineMaterial } from 'three-stdlib'
import worldBlockProvider from 'mc-assets/dist/worldBlockProvider'
import { subscribeKey } from 'valtio/utils'
import { renderSign } from '../sign-renderer'
import { chunkPos, sectionPos } from './simpleUtils'
import { WorldRendererCommon, WorldRendererConfig } from './worldrendererCommon'
import { disposeObject } from './threeJsUtils'
import HoldingBlock, { HandItemBlock } from './holdingBlock'
import { addNewStat } from './ui/newStats'
import { MesherGeometryOutput } from './mesher/shared'
import { IPlayerState } from './basePlayerState'
import { DisplayWorldOptions, GraphicsInitOptions, RendererReactiveState } from '../../../src/appViewer'
import { chunkPos, sectionPos } from '../lib/simpleUtils'
import { WorldRendererCommon } from '../lib/worldrendererCommon'
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 { sendVideoPlay, sendVideoStop } from '../../../src/customChannels'
import HoldingBlock from './holdingBlock'
import { getMesh } from './entity/EntityMesh'
import { armorModel } from './entity/armorModels'
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'
type SectionKey = string
export class WorldRendererThree extends WorldRendererCommon {
interactionLines: null | { blockPos; mesh } = null
outputFormat = 'threeJs' as const
blockEntities = {}
sectionObjects: Record<string, THREE.Object3D> = {}
chunkTextures = new Map<string, { [pos: string]: THREE.Texture }>()
signsCache = new Map<string, any>()
@ -27,7 +37,18 @@ export class WorldRendererThree extends WorldRendererCommon {
cameraSectionPos: Vec3 = new Vec3(0, 0, 0)
holdingBlock: HoldingBlock
holdingBlockLeft: HoldingBlock
rendererDevice = '...'
scene = new THREE.Scene()
ambientLight = new THREE.AmbientLight(0xcc_cc_cc)
directionalLight = new THREE.DirectionalLight(0xff_ff_ff, 0.5)
entities = new Entities(this)
cameraObjectOverride?: THREE.Object3D // for xr
material = new THREE.MeshLambertMaterial({ vertexColors: true, transparent: true, alphaTest: 0.1 })
itemsTexture: THREE.Texture
cursorBlock = new CursorBlock(this)
onRender: Array<() => void> = []
cameraShake: CameraShake
media: ThreeJsMedia
waitingChunksToDisplay = {} as { [chunkKey: string]: SectionKey[] }
get tilesRendered () {
return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj as any).tilesCount, 0)
@ -37,21 +58,78 @@ export class WorldRendererThree extends WorldRendererCommon {
return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj as any).blocksCount, 0)
}
constructor (public scene: THREE.Scene, public renderer: THREE.WebGLRenderer, public config: WorldRendererConfig, public playerState: IPlayerState) {
super(config)
this.rendererDevice = `${WorldRendererThree.getRendererInfo(this.renderer)} powered by three.js r${THREE.REVISION}`
this.starField = new StarField(scene)
this.holdingBlock = new HoldingBlock(playerState, this.config)
this.holdingBlockLeft = new HoldingBlock(playerState, this.config, true)
constructor (public renderer: THREE.WebGLRenderer, public initOptions: GraphicsInitOptions, public displayOptions: DisplayWorldOptions) {
if (!initOptions.resourcesManager) throw new Error('resourcesManager is required')
super(initOptions.resourcesManager, displayOptions, displayOptions.version)
this.renderUpdateEmitter.on('itemsTextureDownloaded', () => {
this.holdingBlock.ready = true
this.holdingBlock.updateItem()
this.holdingBlockLeft.ready = true
this.holdingBlockLeft.updateItem()
})
displayOptions.rendererState.renderer = WorldRendererThree.getRendererInfo(renderer) ?? '...'
this.starField = new StarField(this.scene)
this.holdingBlock = new HoldingBlock(this)
this.holdingBlockLeft = new HoldingBlock(this, true)
this.addDebugOverlay()
this.resetScene()
this.watchReactivePlayerState()
this.init()
void initVR(this)
this.soundSystem = new ThreeJsSound(this)
this.cameraShake = new CameraShake(this.camera, this.onRender)
this.media = new ThreeJsMedia(this)
this.renderUpdateEmitter.on('chunkFinished', (chunkKey: string) => {
this.finishChunk(chunkKey)
})
}
updateEntity (e, isPosUpdate = false) {
const overrides = {
rotation: {
head: {
x: e.headPitch ?? e.pitch,
y: e.headYaw,
z: 0
}
}
}
if (isPosUpdate) {
this.entities.updateEntityPosition(e, false, overrides)
} else {
this.entities.update(e, overrides)
}
}
resetScene () {
this.scene.matrixAutoUpdate = false // for perf
this.scene.background = new THREE.Color(this.initOptions.config.sceneBackground)
this.scene.add(this.ambientLight)
this.directionalLight.position.set(1, 1, 0.5).normalize()
this.directionalLight.castShadow = true
this.scene.add(this.directionalLight)
const size = this.renderer.getSize(new THREE.Vector2())
this.camera = new THREE.PerspectiveCamera(75, size.x / size.y, 0.1, 1000)
}
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)
})
updateValue('inWater', (value) => {
this.scene.fog = value ? new THREE.Fog(0x00_00_ff, 0.1, 100) : null
})
updateValue('ambientLight', (value) => {
if (!value) return
this.ambientLight.intensity = value
})
updateValue('directionalLight', (value) => {
if (!value) return
this.directionalLight.intensity = value
})
}
changeHandSwingingState (isAnimationPlaying: boolean, isLeft = false) {
@ -63,6 +141,46 @@ export class WorldRendererThree extends WorldRendererCommon {
}
}
async updateAssetsData (): Promise<void> {
const resources = this.resourcesManager.currentResources!
const oldTexture = this.material.map
const oldItemsTexture = this.itemsTexture
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 = await new THREE.TextureLoader().loadAsync(resources.itemsAtlasParser.latestImage)
itemsTexture.magFilter = THREE.NearestFilter
itemsTexture.minFilter = THREE.NearestFilter
itemsTexture.flipY = false
this.itemsTexture = itemsTexture
if (oldTexture) {
oldTexture.dispose()
}
if (oldItemsTexture) {
oldItemsTexture.dispose()
}
await super.updateAssetsData()
this.onAllTexturesLoaded()
if (Object.keys(this.loadedChunks).length > 0) {
console.log('rerendering chunks because of texture update')
this.rerenderAllChunks()
}
}
onAllTexturesLoaded () {
this.holdingBlock.ready = true
this.holdingBlock.updateItem()
this.holdingBlockLeft.ready = true
this.holdingBlockLeft.updateItem()
}
changeBackgroundColor (color: [number, number, number]): void {
this.scene.background = new THREE.Color(color[0], color[1], color[2])
}
@ -78,6 +196,35 @@ export class WorldRendererThree extends WorldRendererCommon {
}
}
getItemRenderData (item: Record<string, any>, specificProps: ItemSpecificContextProperties) {
return getItemUv(item, specificProps, this.resourcesManager)
}
async demoModel () {
//@ts-expect-error
const pos = cursorBlockRel(0, 1, 0).position
const mesh = await getMyHand()
// mesh.rotation.y = THREE.MathUtils.degToRad(90)
setBlockPosition(mesh, pos)
const helper = new THREE.BoxHelper(mesh, 0xff_ff_00)
mesh.add(helper)
this.scene.add(mesh)
}
demoItem () {
//@ts-expect-error
const pos = cursorBlockRel(0, 1, 0).position
const { mesh } = this.entities.getItemMesh({
itemId: 541,
}, {})!
mesh.position.set(pos.x + 0.5, pos.y + 0.5, pos.z + 0.5)
// mesh.scale.set(0.5, 0.5, 0.5)
const helper = new THREE.BoxHelper(mesh, 0xff_ff_00)
mesh.add(helper)
this.scene.add(mesh)
}
debugOverlayAdded = false
addDebugOverlay () {
if (this.debugOverlayAdded) return
@ -88,7 +235,7 @@ export class WorldRendererThree extends WorldRendererCommon {
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}`)
}
}, 100)
}, 200)
}
/**
@ -114,6 +261,18 @@ export class WorldRendererThree extends WorldRendererCommon {
}
}
getDir (current: number, origin: number) {
if (current === origin) return 0
return current < origin ? 1 : -1
}
finishChunk (chunkKey: string) {
for (const sectionKey of this.waitingChunksToDisplay[chunkKey] ?? []) {
this.sectionObjects[sectionKey].visible = true
}
delete this.waitingChunksToDisplay[chunkKey]
}
// debugRecomputedDeletedObjects = 0
handleWorkerMessage (data: { geometry: MesherGeometryOutput, key, type }): void {
if (data.type !== 'geometry') return
@ -166,7 +325,7 @@ export class WorldRendererThree extends WorldRendererCommon {
object.name = 'chunk';
(object as any).tilesCount = data.geometry.positions.length / 3 / 4;
(object as any).blocksCount = data.geometry.blocksCount
if (!this.config.showChunkBorders) {
if (!this.displayOptions.inWorldRenderingConfig.showChunkBorders) {
boxHelper.visible = false
}
// should not compute it once
@ -191,6 +350,17 @@ export class WorldRendererThree extends WorldRendererCommon {
}
}
this.sectionObjects[data.key] = object
if (this.displayOptions.inWorldRenderingConfig._renderByChunks) {
object.visible = false
const chunkKey = `${chunkCoords[0]},${chunkCoords[2]}`
this.waitingChunksToDisplay[chunkKey] ??= []
this.waitingChunksToDisplay[chunkKey].push(data.key)
if (this.finishedChunks[chunkKey]) {
// todo it might happen even when it was not an update
this.finishChunk(chunkKey)
}
}
this.updatePosDataChunk(data.key)
object.matrixAutoUpdate = false
mesh.onAfterRender = (renderer, scene, camera, geometry, material, group) => {
@ -211,7 +381,7 @@ export class WorldRendererThree extends WorldRendererCommon {
// todo investigate bug and remove this so don't need to clean in section dirty
if (textures[texturekey]) return textures[texturekey]
const PrismarineChat = PrismarineChatLoader(this.version!)
const PrismarineChat = PrismarineChatLoader(this.version)
const canvas = renderSign(blockEntity, PrismarineChat)
if (!canvas) return
const tex = new THREE.Texture(canvas)
@ -222,28 +392,53 @@ export class WorldRendererThree extends WorldRendererCommon {
return tex
}
setFirstPersonCamera (pos: Vec3 | null, yaw: number, pitch: number) {
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()
}
updateCamera (pos: Vec3 | null, yaw: number, pitch: number): void {
if (this.freeFlyMode) {
pos = this.freeFlyState.position
pitch = this.freeFlyState.pitch
yaw = this.freeFlyState.yaw
}
// if (this.freeFlyMode) {
// pos = this.freeFlyState.position
// pitch = this.freeFlyState.pitch
// yaw = this.freeFlyState.yaw
// }
if (pos) {
new tweenJs.Tween(this.camera.position).to({ x: pos.x, y: pos.y, z: pos.z }, 50).start()
this.freeFlyState.position = pos
// this.freeFlyState.position = pos
}
this.camera.rotation.set(pitch, yaw, this.cameraRoll, 'ZYX')
this.cameraShake.setBaseRotation(pitch, yaw)
}
render () {
tweenJs.update()
render (sizeChanged = false) {
this.lastRendered = performance.now()
this.cursorBlock.render()
const sizeOrFovChanged = sizeChanged || this.displayOptions.inWorldRenderingConfig.fov !== this.camera.fov
if (sizeOrFovChanged) {
this.camera.aspect = window.innerWidth / window.innerHeight
this.camera.fov = this.displayOptions.inWorldRenderingConfig.fov
this.camera.updateProjectionMatrix()
}
this.entities.render()
// eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
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.config.showHand && !this.freeFlyMode) {
this.holdingBlock.render(this.camera, this.renderer, viewer.ambientLight, viewer.directionalLight)
this.holdingBlockLeft.render(this.camera, this.renderer, viewer.ambientLight, viewer.directionalLight)
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 onRender of this.onRender) {
onRender()
}
}
@ -318,25 +513,13 @@ export class WorldRendererThree extends WorldRendererCommon {
return group
}
updateLight (chunkX: number, chunkZ: number) {
lightUpdate (chunkX: number, chunkZ: number) {
// set all sections in the chunk dirty
for (let y = this.worldConfig.minY; y < this.worldConfig.worldHeight; y += 16) {
for (let y = this.worldSizeParams.minY; y < this.worldSizeParams.worldHeight; y += 16) {
this.setSectionDirty(new Vec3(chunkX, y, chunkZ))
}
}
async doHmr () {
const oldSections = { ...this.sectionObjects }
this.sectionObjects = {} // skip clearing
worldView!.unloadAllChunks()
void this.setVersion(this.version, this.texturesVersion)
this.sectionObjects = oldSections
// this.rerenderAllChunks()
// supply new data
await worldView!.updatePosition(bot.entity.position, true)
}
rerenderAllChunks () { // todo not clear what to do with loading chunks
for (const key of Object.keys(this.sectionObjects)) {
const [x, y, z] = key.split(',').map(Number)
@ -345,7 +528,7 @@ export class WorldRendererThree extends WorldRendererCommon {
}
updateShowChunksBorder (value: boolean) {
this.config.showChunkBorders = value
this.displayOptions.inWorldRenderingConfig.showChunkBorders = value
for (const object of Object.values(this.sectionObjects)) {
for (const child of object.children) {
if (child.name === 'helper') {
@ -403,7 +586,7 @@ export class WorldRendererThree extends WorldRendererCommon {
super.removeColumn(x, z)
this.cleanChunkTextures(x, z)
for (let y = this.worldConfig.minY; y < this.worldConfig.worldHeight; y += 16) {
for (let y = this.worldSizeParams.minY; y < this.worldSizeParams.worldHeight; y += 16) {
this.setSectionDirty(new Vec3(x, y, z), false)
const key = `${x},${y},${z}`
const mesh = this.sectionObjects[key]
@ -421,34 +604,6 @@ export class WorldRendererThree extends WorldRendererCommon {
super.setSectionDirty(...args)
}
setHighlightCursorBlock (blockPos: typeof this.cursorBlock, shapePositions?: Array<{ position: any; width: any; height: any; depth: any; }>): void {
this.cursorBlock = blockPos
if (blockPos && this.interactionLines && blockPos.equals(this.interactionLines.blockPos)) {
return
}
if (this.interactionLines !== null) {
this.scene.remove(this.interactionLines.mesh)
this.interactionLines = null
}
if (blockPos === null) {
return
}
const group = new THREE.Group()
for (const { position, width, height, depth } of shapePositions ?? []) {
const scale = [1.0001 * width, 1.0001 * height, 1.0001 * depth] as const
const geometry = new THREE.BoxGeometry(...scale)
const lines = new LineSegmentsGeometry().fromEdgesGeometry(new THREE.EdgesGeometry(geometry))
const wireframe = new Wireframe(lines, this.threejsCursorLineMaterial)
const pos = blockPos.plus(position)
wireframe.position.set(pos.x, pos.y, pos.z)
wireframe.computeLineDistances()
group.add(wireframe)
}
this.scene.add(group)
this.interactionLines = { blockPos, mesh: group }
}
static getRendererInfo (renderer: THREE.WebGLRenderer) {
try {
const gl = renderer.getContext()
@ -457,6 +612,11 @@ export class WorldRendererThree extends WorldRendererCommon {
console.warn('Failed to get renderer info', err)
}
}
destroy (): void {
removeAllStats()
super.destroy()
}
}
class StarField {

View file

@ -203,6 +203,12 @@ const appConfig = defineConfig({
})
build.onAfterBuild(async () => {
if (SINGLE_FILE_BUILD) {
// check that only index.html is in the dist/single folder
const singleBuildFiles = fs.readdirSync('./dist/single')
if (singleBuildFiles.length !== 1 || singleBuildFiles[0] !== 'index.html') {
throw new Error('Single file build must only have index.html in the dist/single folder. Ensure workers are imported & built correctly.')
}
// process index.html
const singleBuildHtml = './dist/single/index.html'
let html = fs.readFileSync(singleBuildHtml, 'utf8')

266
src/appViewer.ts Normal file
View file

@ -0,0 +1,266 @@
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 } from 'valtio'
import { getDefaultRendererState } from 'renderer/viewer/baseGraphicsBackend'
import { getSyncWorld } from 'renderer/playground/shared'
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 } from './resourcesManager'
import { watchOptionsAfterWorldViewInit } from './watchOptions'
export interface RendererReactiveState {
world: {
chunksLoaded: string[]
chunksTotalNumber: number
allChunksLoaded: boolean
mesherWork: boolean
intersectMedia: { id: string, x: number, y: number } | null
}
renderer: string
preventEscapeMenu: boolean
}
export interface NonReactiveState {
world: {
chunksLoaded: string[]
chunksTotalNumber: number
allChunksLoaded: boolean
mesherWork: boolean
intersectMedia: { id: string, x: number, y: number } | null
}
}
export interface GraphicsBackendConfig {
fpsLimit?: number
powerPreference?: 'high-performance' | 'low-power'
statsVisible?: number
sceneBackground: string
}
const defaultGraphicsBackendConfig: GraphicsBackendConfig = {
fpsLimit: undefined,
powerPreference: undefined,
sceneBackground: 'lightblue'
}
export interface GraphicsInitOptions {
resourcesManager: ResourcesManager
config: GraphicsBackendConfig
displayCriticalError: (error: Error) => void
}
export interface DisplayWorldOptions {
version: string
worldView: WorldDataEmitter
inWorldRenderingConfig: WorldRendererConfig
playerState: IPlayerState
rendererState: RendererReactiveState
nonReactiveState: NonReactiveState
}
export type GraphicsBackendLoader = (options: GraphicsInitOptions) => GraphicsBackend
// no sync methods
export interface GraphicsBackend {
id: string
displayName?: string
startPanorama: () => void
// prepareResources: (version: string, progressReporter: ProgressReporter) => Promise<void>
startWorld: (options: DisplayWorldOptions) => void
disconnect: () => void
setRendering: (rendering: boolean) => void
getDebugOverlay?: () => Record<string, any>
updateCamera: (pos: Vec3 | null, yaw: number, pitch: number) => void
setRoll?: (roll: number) => void
soundSystem: SoundSystem | undefined
backendMethods: Record<string, unknown> | undefined
}
export class AppViewer {
resourcesManager = new ResourcesManager()
worldView: WorldDataEmitter | undefined
readonly config: GraphicsBackendConfig = {
...defaultGraphicsBackendConfig,
powerPreference: options.gpuPreference === 'default' ? undefined : options.gpuPreference
}
backend?: GraphicsBackend
backendLoader?: GraphicsBackendLoader
private currentState?: {
method: string
args: any[]
}
currentDisplay = null as 'menu' | 'world' | null
inWorldRenderingConfig: WorldRendererConfig = proxy(defaultWorldRendererConfig)
lastCamUpdate = 0
playerState = playerState
rendererState = proxy(getDefaultRendererState())
nonReactiveState: NonReactiveState = getDefaultRendererState()
worldReady: Promise<void>
private resolveWorldReady: () => void
constructor () {
this.disconnectBackend()
}
loadBackend (loader: GraphicsBackendLoader) {
if (this.backend) {
this.disconnectBackend()
}
this.backendLoader = loader
const loaderOptions: GraphicsInitOptions = {
resourcesManager: this.resourcesManager,
config: this.config,
displayCriticalError (error) {
console.error(error)
setLoadingScreenStatus(error.message, true)
},
}
this.backend = loader(loaderOptions)
// if (this.resourcesManager.currentResources) {
// void this.prepareResources(this.resourcesManager.currentResources.version, createNotificationProgressReporter())
// }
// Execute queued action if exists
if (this.currentState) {
const { method, args } = this.currentState
this.backend[method](...args)
if (method === 'startWorld') {
void this.worldView!.init(args[0].playerState.getPosition())
}
}
}
startWorld (world, renderDistance: number, playerStateSend: IPlayerState = this.playerState) {
if (this.currentDisplay === 'world') throw new Error('World already started')
this.currentDisplay = 'world'
const startPosition = playerStateSend.getPosition()
this.worldView = new WorldDataEmitter(world, renderDistance, startPosition)
window.worldView = this.worldView
watchOptionsAfterWorldViewInit(this.worldView)
const displayWorldOptions: DisplayWorldOptions = {
version: this.resourcesManager.currentConfig!.version,
worldView: this.worldView,
inWorldRenderingConfig: this.inWorldRenderingConfig,
playerState: playerStateSend,
rendererState: this.rendererState,
nonReactiveState: this.nonReactiveState
}
if (this.backend) {
this.backend.startWorld(displayWorldOptions)
void this.worldView.init(startPosition)
}
this.currentState = { method: 'startWorld', args: [displayWorldOptions] }
// Resolve the promise after world is started
this.resolveWorldReady()
}
resetBackend (cleanState = false) {
if (cleanState) {
this.currentState = undefined
this.currentDisplay = null
this.worldView = undefined
}
if (this.backendLoader) {
this.loadBackend(this.backendLoader)
}
}
startPanorama () {
if (this.currentDisplay === 'menu') return
this.currentDisplay = 'menu'
if (options.disableAssets) return
if (this.backend) {
this.backend.startPanorama()
}
this.currentState = { method: 'startPanorama', args: [] }
}
// async prepareResources (version: string, progressReporter: ProgressReporter) {
// if (this.backend) {
// await this.backend.prepareResources(version, progressReporter)
// }
// }
destroyAll () {
this.disconnectBackend()
this.resourcesManager.destroy()
}
disconnectBackend () {
if (this.backend) {
this.backend.disconnect()
this.backend = undefined
}
this.currentDisplay = null
const { promise, resolve } = Promise.withResolvers<void>()
this.worldReady = promise
this.resolveWorldReady = resolve
Object.assign(this.rendererState, getDefaultRendererState())
// this.queuedDisplay = undefined
}
get utils () {
return {
async waitingForChunks () {
if (this.backend?.worldState.allChunksLoaded) return
return new Promise((resolve) => {
const interval = setInterval(() => {
if (this.backend?.worldState.allChunksLoaded) {
clearInterval(interval)
resolve(true)
}
}, 100)
})
}
}
}
}
export const appViewer = new AppViewer()
window.appViewer = appViewer
const initialMenuStart = async () => {
if (appViewer.currentDisplay === 'world') {
appViewer.resetBackend(true)
}
appViewer.startPanorama()
// 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 modalStackUpdateChecks = () => {
// maybe start panorama
if (activeModalStack.length === 0 && !miscUiState.gameLoaded) {
void initialMenuStart()
}
if (appViewer.backend) {
const hasAppStatus = activeModalStack.some(m => m.reactType === 'app-status')
appViewer.backend.setRendering(!hasAppStatus)
}
appViewer.inWorldRenderingConfig.foreground = activeModalStack.length === 0
}
subscribeKey(activeModalStack, 'length', modalStackUpdateChecks)
modalStackUpdateChecks()

View file

@ -1,4 +1,4 @@
import { versionToNumber } from 'renderer/viewer/prepare/utils'
import { versionToNumber } from 'renderer/viewer/common/utils'
import * as nbt from 'prismarine-nbt'
export const displayClientChat = (text: string) => {
@ -21,15 +21,15 @@ export const displayClientChat = (text: string) => {
}
export const parseFormattedMessagePacket = (arg) => {
// if (typeof arg === 'string') {
// try {
// arg = JSON.parse(arg)
// return {
// formatted: arg,
// plain: ''
// }
// } catch {}
// }
if (typeof arg === 'string') {
try {
arg = JSON.parse(arg)
return {
formatted: arg,
plain: ''
}
} catch {}
}
if (typeof arg === 'object') {
try {
return {

View file

@ -37,18 +37,19 @@ export const moveCameraRawHandler = ({ x, y }: { x: number; y: number }) => {
const maxPitch = 0.5 * Math.PI
const minPitch = -0.5 * Math.PI
viewer.world.lastCamUpdate = Date.now()
appViewer.lastCamUpdate = Date.now()
if (viewer.world.freeFlyMode) {
// Update freeFlyState directly
viewer.world.freeFlyState.yaw = (viewer.world.freeFlyState.yaw - x) % (2 * Math.PI)
viewer.world.freeFlyState.pitch = Math.max(minPitch, Math.min(maxPitch, viewer.world.freeFlyState.pitch - y))
return
}
// if (viewer.world.freeFlyMode) {
// // Update freeFlyState directly
// viewer.world.freeFlyState.yaw = (viewer.world.freeFlyState.yaw - x) % (2 * Math.PI)
// viewer.world.freeFlyState.pitch = Math.max(minPitch, Math.min(maxPitch, viewer.world.freeFlyState.pitch - y))
// return
// }
if (!bot?.entity) return
const pitch = bot.entity.pitch - y
void bot.look(bot.entity.yaw - x, Math.max(minPitch, Math.min(maxPitch, pitch)), true)
appViewer.backend?.updateCamera(null, bot.entity.yaw, pitch)
}
window.addEventListener('mousemove', (e: MouseEvent) => {
@ -76,7 +77,7 @@ function pointerLockChangeCallback () {
if (notificationProxy.id === 'pointerlockchange') {
hideNotification()
}
if (viewer.renderer.xr.isPresenting) return // todo
if (appViewer.rendererState.preventEscapeMenu) return
if (!pointerLock.hasPointerLock && activeModalStack.length === 0 && miscUiState.gameLoaded) {
showModal({ reactType: 'pause-screen' })
}

View file

@ -2,7 +2,7 @@
import { fromFormattedString, TextComponent } from '@xmcl/text-component'
import type { IndexedData } from 'minecraft-data'
import { versionToNumber } from 'renderer/viewer/prepare/utils'
import { versionToNumber } from 'renderer/viewer/common/utils'
export type MessageFormatPart = Pick<TextComponent, 'hoverEvent' | 'clickEvent'> & {
text: string

View file

@ -4,7 +4,6 @@ import MinecraftData from 'minecraft-data'
import PrismarineBlock from 'prismarine-block'
import PrismarineItem from 'prismarine-item'
import pathfinder from 'mineflayer-pathfinder'
import { importLargeData } from '../generated/large-data-aliases'
import { miscUiState } from './globalState'
import supportedVersions from './supportedVersions.mjs'
import { options } from './optionsStorage'
@ -44,7 +43,7 @@ export const getVersionAutoSelect = (autoVersionSelect = options.serversAutoVers
return autoVersionSelect
}
export const loadMinecraftData = async (version: string, importBlockstatesModels = false) => {
export const loadMinecraftData = async (version: string) => {
await window._LOAD_MC_DATA()
// setLoadingScreenStatus(`Loading data for ${version}`)
// // todo expose cache
@ -58,12 +57,9 @@ export const loadMinecraftData = async (version: string, importBlockstatesModels
window.PrismarineBlock = PrismarineBlock(mcData.version.minecraftVersion!)
window.PrismarineItem = PrismarineItem(mcData.version.minecraftVersion!)
window.loadedData = mcData
window.mcData = mcData
window.pathfinder = pathfinder
miscUiState.loadedDataVersion = version
if (importBlockstatesModels) {
viewer.world.blockstatesModels = await importLargeData('blockStatesModels')
}
}
export const downloadAllMinecraftData = async () => {

View file

@ -2,13 +2,12 @@
import { Vec3 } from 'vec3'
import { proxy, subscribe } from 'valtio'
import * as THREE from 'three'
import { ControMax } from 'contro-max/build/controMax'
import { CommandEventArgument, SchemaCommandInput } from 'contro-max/build/types'
import { stringStartsWith } from 'contro-max/build/stringUtils'
import { UserOverrideCommand, UserOverridesConfig } from 'contro-max/build/types/store'
import { GameMode } from 'mineflayer'
import { getThreeJsRendererMethods } from 'renderer/viewer/three/threeJsMethods'
import { isGameActive, showModal, gameAdditionalState, activeModalStack, hideCurrentModal, miscUiState, hideModal, hideAllModals } from './globalState'
import { goFullscreen, isInRealGameSession, pointerLock, reloadChunks } from './utils'
import { options } from './optionsStorage'
@ -133,21 +132,21 @@ contro.on('movementUpdate', ({ vector, soleVector, gamepadIndex }) => {
miscUiState.usingGamepadInput = gamepadIndex !== undefined
if (!bot || !isGameActive(false)) return
if (viewer.world.freeFlyMode) {
// Create movement vector from input
const direction = new THREE.Vector3(0, 0, 0)
if (vector.z !== undefined) direction.z = vector.z
if (vector.x !== undefined) direction.x = vector.x
// if (viewer.world.freeFlyMode) {
// // Create movement vector from input
// const direction = new THREE.Vector3(0, 0, 0)
// if (vector.z !== undefined) direction.z = vector.z
// if (vector.x !== undefined) direction.x = vector.x
// Apply camera rotation to movement direction
direction.applyQuaternion(viewer.camera.quaternion)
// // Apply camera rotation to movement direction
// direction.applyQuaternion(viewer.camera.quaternion)
// Update freeFlyState position with normalized direction
const moveSpeed = 1
direction.multiplyScalar(moveSpeed)
viewer.world.freeFlyState.position.add(new Vec3(direction.x, direction.y, direction.z))
return
}
// // Update freeFlyState position with normalized direction
// const moveSpeed = 1
// direction.multiplyScalar(moveSpeed)
// viewer.world.freeFlyState.position.add(new Vec3(direction.x, direction.y, direction.z))
// return
// }
// gamepadIndex will be used for splitscreen in future
const coordToAction = [
@ -355,20 +354,20 @@ const onTriggerOrReleased = (command: Command, pressed: boolean) => {
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
switch (command) {
case 'general.jump':
if (viewer.world.freeFlyMode) {
const moveSpeed = 0.5
viewer.world.freeFlyState.position.add(new Vec3(0, pressed ? moveSpeed : 0, 0))
} else {
bot.setControlState('jump', pressed)
}
// if (viewer.world.freeFlyMode) {
// const moveSpeed = 0.5
// viewer.world.freeFlyState.position.add(new Vec3(0, pressed ? moveSpeed : 0, 0))
// } else {
bot.setControlState('jump', pressed)
// }
break
case 'general.sneak':
if (viewer.world.freeFlyMode) {
const moveSpeed = 0.5
viewer.world.freeFlyState.position.add(new Vec3(0, pressed ? -moveSpeed : 0, 0))
} else {
setSneaking(pressed)
}
// if (viewer.world.freeFlyMode) {
// const moveSpeed = 0.5
// viewer.world.freeFlyState.position.add(new Vec3(0, pressed ? -moveSpeed : 0, 0))
// } else {
setSneaking(pressed)
// }
break
case 'general.sprint':
// todo add setting to change behavior
@ -592,12 +591,12 @@ export const f3Keybinds: Array<{
for (const [x, z] of loadedChunks) {
worldView!.unloadChunk({ x, z })
}
for (const child of viewer.scene.children) {
if (child.name === 'chunk') { // should not happen
viewer.scene.remove(child)
console.warn('forcefully removed chunk from scene')
}
}
// for (const child of viewer.scene.children) {
// if (child.name === 'chunk') { // should not happen
// viewer.scene.remove(child)
// console.warn('forcefully removed chunk from scene')
// }
// }
if (localServer) {
//@ts-expect-error not sure why it is private... maybe revisit api?
localServer.players[0].world.columns = {}
@ -610,7 +609,6 @@ export const f3Keybinds: Array<{
key: 'KeyG',
action () {
options.showChunkBorders = !options.showChunkBorders
viewer.world.updateShowChunksBorder(options.showChunkBorders)
},
mobileTitle: 'Toggle chunk borders',
},
@ -877,6 +875,7 @@ addEventListener('mousedown', async (e) => {
if (!isInRealGameSession() && !(e.target as HTMLElement).id.includes('ui-root')) return
void pointerLock.requestPointerLock()
if (!bot) return
getThreeJsRendererMethods()?.onPageInteraction()
// wheel click
// todo support ctrl+wheel (+nbt)
if (e.button === 1) {
@ -886,6 +885,10 @@ addEventListener('mousedown', async (e) => {
window.addEventListener('keydown', (e) => {
if (e.code !== 'Escape') return
if (!activeModalStack.length) {
getThreeJsRendererMethods()?.onPageInteraction()
}
if (activeModalStack.length) {
const hideAll = e.ctrlKey || e.metaKey
if (hideAll) {
@ -894,6 +897,7 @@ window.addEventListener('keydown', (e) => {
hideCurrentModal()
}
if (activeModalStack.length === 0) {
getThreeJsRendererMethods()?.onPageInteraction()
pointerLock.justHitEscape = true
}
} else if (pointerLock.hasPointerLock) {
@ -926,9 +930,16 @@ window.addEventListener('keydown', (e) => {
// #region experimental debug things
window.addEventListener('keydown', (e) => {
if (e.code === 'KeyL' && e.altKey) {
if (e.code === 'KeyL' && e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
console.clear()
}
if (e.code === 'KeyK' && e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
if (sessionStorage.delayLoadUntilFocus) {
sessionStorage.removeItem('delayLoadUntilFocus')
} else {
sessionStorage.setItem('delayLoadUntilFocus', 'true')
}
}
})
// #endregion

View file

@ -181,13 +181,13 @@ export const createNotificationProgressReporter = (endMessage?: string): Progres
})
}
export const createConsoleLogProgressReporter = (): ProgressReporter => {
export const createConsoleLogProgressReporter = (group?: string): ProgressReporter => {
return createProgressReporter({
setMessage (message: string) {
console.log(message)
console.log(group ? `[${group}] ${message}` : message)
},
end () {
console.log('done')
console.log(group ? `[${group}] done` : 'done')
},
error (message: string): void {
@ -217,3 +217,14 @@ export const createWrappedProgressReporter = (reporter: ProgressReporter, messag
}
})
}
export const createNullProgressReporter = (): ProgressReporter => {
return createProgressReporter({
setMessage (message: string) {
},
end () {
},
error (message: string) {
}
})
}

139
src/core/timers.ts Normal file
View file

@ -0,0 +1,139 @@
import { options } from '../optionsStorage'
interface Timer {
id: number
callback: () => void
targetTime: number
isInterval: boolean
interval?: number
cleanup?: () => void
}
let nextTimerId = 1
const timers: Timer[] = []
// TODO implementation breaks tps (something is wrong with intervals)
const fixBrowserTimers = () => {
const originalSetTimeout = window.setTimeout
//@ts-expect-error
window.setTimeout = (callback: () => void, delay: number) => {
if (!delay) {
return originalSetTimeout(callback)
}
const id = nextTimerId++
const targetTime = performance.now() + delay
timers.push({ id, callback, targetTime, isInterval: false })
originalSetTimeout(() => {
checkTimers()
}, delay)
return id
}
const originalSetInterval = window.setInterval
//@ts-expect-error
window.setInterval = (callback: () => void, interval: number) => {
if (!interval) {
return originalSetInterval(callback, interval)
}
const id = nextTimerId++
const targetTime = performance.now() + interval
const originalInterval = originalSetInterval(() => {
checkTimers()
}, interval)
timers.push({
id,
callback,
targetTime,
isInterval: true,
interval,
cleanup () {
originalClearInterval(originalInterval)
},
})
return id
}
const originalClearTimeout = window.clearTimeout
//@ts-expect-error
window.clearTimeout = (id: number) => {
const index = timers.findIndex(t => t.id === id)
if (index !== -1) {
timers.splice(index, 1)
}
return originalClearTimeout(id)
}
const originalClearInterval = window.clearInterval
//@ts-expect-error
window.clearInterval = (id: number) => {
const index = timers.findIndex(t => t.id === id)
if (index !== -1) {
const timer = timers[index]
if (timer.cleanup) {
timer.cleanup()
}
timers.splice(index, 1)
}
return originalClearInterval(id)
}
}
export const checkTimers = () => {
const now = performance.now()
let triggered = false
for (let i = timers.length - 1; i >= 0; i--) {
const timer = timers[i]
if (now >= timer.targetTime) {
triggered = true
timer.callback()
if (timer.isInterval && timer.interval) {
// Reschedule interval
timer.targetTime = now + timer.interval
} else {
// Remove one-time timer
timers.splice(i, 1)
}
}
}
if (!triggered) {
console.log('No timers triggered!')
}
}
// workaround for browser timers throttling after 5 minutes of tab inactivity
export const preventThrottlingWithSound = () => {
try {
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
const oscillator = audioContext.createOscillator()
const gainNode = audioContext.createGain()
// Unfortunatelly cant use 0
gainNode.gain.value = 0.001
// Connect nodes
oscillator.connect(gainNode)
gainNode.connect(audioContext.destination)
// Use a very low frequency
oscillator.frequency.value = 1
// Start playing
oscillator.start()
return () => {
try {
oscillator.stop()
audioContext.close()
} catch (err) {
console.error('Error stopping silent audio:', err)
}
}
} catch (err) {
console.error('Error creating silent audio:', err)
return () => {}
}
}

View file

@ -1,15 +1,35 @@
import { Vec3 } from 'vec3'
import PItem from 'prismarine-item'
import { getThreeJsRendererMethods } from 'renderer/viewer/three/threeJsMethods'
import { options } from './optionsStorage'
import { jeiCustomCategories } from './inventoryWindows'
customEvents.on('mineflayerBotCreated', async () => {
if (!options.customChannels) return
await new Promise(resolve => {
bot.once('login', () => {
resolve(true)
export default () => {
customEvents.on('mineflayerBotCreated', async () => {
if (!options.customChannels) return
await new Promise(resolve => {
bot.once('login', () => {
resolve(true)
})
})
registerBlockModelsChannel()
registerMediaChannels()
registeredJeiChannel()
})
registerBlockModelsChannel()
})
}
const registerChannel = (channelName: string, packetStructure: any[], handler: (data: any) => void, waitForWorld = true) => {
bot._client.registerChannel(channelName, packetStructure, true)
bot._client.on(channelName as any, async (data) => {
if (waitForWorld) {
await appViewer.worldReady
handler(data)
} else {
handler(data)
}
})
console.debug(`registered custom channel ${channelName} channel`)
}
const registerBlockModelsChannel = () => {
const CHANNEL_NAME = 'minecraft-web-client:blockmodels'
@ -40,9 +60,7 @@ const registerBlockModelsChannel = () => {
]
]
bot._client.registerChannel(CHANNEL_NAME, packetStructure, true)
bot._client.on(CHANNEL_NAME as any, (data) => {
registerChannel(CHANNEL_NAME, packetStructure, (data) => {
const { worldName, x, y, z, model } = data
const chunkX = Math.floor(x / 16) * 16
@ -50,31 +68,8 @@ const registerBlockModelsChannel = () => {
const chunkKey = `${chunkX},${chunkZ}`
const blockPosKey = `${x},${y},${z}`
const chunkModels = viewer.world.protocolCustomBlocks.get(chunkKey) || {}
if (model) {
chunkModels[blockPosKey] = model
} else {
delete chunkModels[blockPosKey]
}
if (Object.keys(chunkModels).length > 0) {
viewer.world.protocolCustomBlocks.set(chunkKey, chunkModels)
} else {
viewer.world.protocolCustomBlocks.delete(chunkKey)
}
// Trigger update
if (worldView) {
const block = worldView.world.getBlock(new Vec3(x, y, z))
if (block) {
worldView.world.setBlockStateId(new Vec3(x, y, z), block.stateId)
}
}
})
console.debug(`registered custom channel ${CHANNEL_NAME} channel`)
getThreeJsRendererMethods()?.updateCustomBlock(chunkKey, blockPosKey, model)
}, true)
}
const registeredJeiChannel = () => {
@ -88,7 +83,7 @@ const registeredJeiChannel = () => {
type: ['pstring', { countType: 'i16' }]
},
{
name: 'categoryTitle',
name: '_categoryTitle',
type: ['pstring', { countType: 'i16' }]
},
{
@ -102,8 +97,260 @@ const registeredJeiChannel = () => {
bot._client.on(CHANNEL_NAME as any, (data) => {
const { id, categoryTitle, items } = data
// ...
if (items === '') {
// remove category
jeiCustomCategories.value = jeiCustomCategories.value.filter(x => x.id !== id)
return
}
const PrismarineItem = PItem(bot.version)
jeiCustomCategories.value.push({
id,
categoryTitle,
items: JSON.parse(items).map(x => {
const itemString = x.itemName || x.item_name || x.item || x.itemId
const itemId = loadedData.itemsByName[itemString.replace('minecraft:', '')]
if (!itemId) {
console.warn(`Could not add item ${itemString} to JEI category ${categoryTitle} because it was not found`)
return null
}
// const item = new PrismarineItem(itemId.id, x.itemCount || x.item_count || x.count || 1, x.itemDamage || x.item_damage || x.damage || 0, x.itemNbt || x.item_nbt || x.nbt || null)
return PrismarineItem.fromNotch({
...x,
itemId: itemId.id,
})
})
})
})
console.debug(`registered custom channel ${CHANNEL_NAME} channel`)
}
const registerMediaChannels = () => {
// Media Add Channel
const ADD_CHANNEL = 'minecraft-web-client:media-add'
const addPacketStructure = [
'container',
[
{ name: 'id', type: ['pstring', { countType: 'i16' }] },
{ name: 'x', type: 'f32' },
{ name: 'y', type: 'f32' },
{ name: 'z', type: 'f32' },
{ name: 'width', type: 'f32' },
{ name: 'height', type: 'f32' },
// N, 0
// W, 3
// S, 2
// E, 1
{ name: 'rotation', type: 'i16' }, // 0: 0° - towards positive z, 1: 90° - positive x, 2: 180° - negative z, 3: 270° - negative x (3-6 is same but double side)
{ name: 'source', type: ['pstring', { countType: 'i16' }] },
{ name: 'loop', type: 'bool' },
{ name: 'volume', type: 'f32' }, // 0
{ name: '_aspectRatioMode', type: 'i16' }, // 0
{ name: '_background', type: 'i16' }, // 0
{ name: '_opacity', type: 'i16' }, // 1
{ name: '_cropXStart', type: 'f32' }, // 0
{ name: '_cropYStart', type: 'f32' }, // 0
{ name: '_cropXEnd', type: 'f32' }, // 0
{ name: '_cropYEnd', type: 'f32' }, // 0
]
]
// Media Control Channels
const PLAY_CHANNEL = 'minecraft-web-client:media-play'
const PAUSE_CHANNEL = 'minecraft-web-client:media-pause'
const SEEK_CHANNEL = 'minecraft-web-client:media-seek'
const VOLUME_CHANNEL = 'minecraft-web-client:media-volume'
const SPEED_CHANNEL = 'minecraft-web-client:media-speed'
const DESTROY_CHANNEL = 'minecraft-web-client:media-destroy'
const noDataPacketStructure = [
'container',
[
{ name: 'id', type: ['pstring', { countType: 'i16' }] }
]
]
const setNumberPacketStructure = [
'container',
[
{ name: 'id', type: ['pstring', { countType: 'i16' }] },
{ name: 'seconds', type: 'f32' }
]
]
// Register channels
bot._client.registerChannel(ADD_CHANNEL, addPacketStructure, true)
bot._client.registerChannel(PLAY_CHANNEL, noDataPacketStructure, true)
bot._client.registerChannel(PAUSE_CHANNEL, noDataPacketStructure, true)
bot._client.registerChannel(SEEK_CHANNEL, setNumberPacketStructure, true)
bot._client.registerChannel(VOLUME_CHANNEL, setNumberPacketStructure, true)
bot._client.registerChannel(SPEED_CHANNEL, setNumberPacketStructure, true)
bot._client.registerChannel(DESTROY_CHANNEL, noDataPacketStructure, true)
// Handle media add
registerChannel(ADD_CHANNEL, addPacketStructure, (data) => {
const { id, x, y, z, width, height, rotation, source, loop, volume, background, opacity } = data
// Add new video
getThreeJsRendererMethods()?.addMedia(id, {
position: { x, y, z },
size: { width, height },
// side: 'towards',
src: source,
rotation: rotation as 0 | 1 | 2 | 3,
doubleSide: false,
background,
opacity: opacity / 100,
allowOrigins: options.remoteContentNotSameOrigin === false ? [getCurrentTopDomain()] : options.remoteContentNotSameOrigin,
loop,
volume
})
})
// Handle media play
registerChannel(PLAY_CHANNEL, noDataPacketStructure, (data) => {
const { id } = data
getThreeJsRendererMethods()?.setVideoPlaying(id, true)
}, true)
// Handle media pause
registerChannel(PAUSE_CHANNEL, noDataPacketStructure, (data) => {
const { id } = data
getThreeJsRendererMethods()?.setVideoPlaying(id, false)
}, true)
// Handle media seek
registerChannel(SEEK_CHANNEL, setNumberPacketStructure, (data) => {
const { id, seconds } = data
getThreeJsRendererMethods()?.setVideoSeeking(id, seconds)
}, true)
// Handle media destroy
registerChannel(DESTROY_CHANNEL, noDataPacketStructure, (data) => {
const { id } = data
getThreeJsRendererMethods()?.destroyMedia(id)
}, true)
// Handle media volume
registerChannel(VOLUME_CHANNEL, setNumberPacketStructure, (data) => {
const { id, volume } = data
getThreeJsRendererMethods()?.setVideoVolume(id, volume)
}, true)
// Handle media speed
registerChannel(SPEED_CHANNEL, setNumberPacketStructure, (data) => {
const { id, speed } = data
getThreeJsRendererMethods()?.setVideoSpeed(id, speed)
}, true)
// ---
// Video interaction channel
const interactionPacketStructure = [
'container',
[
{ name: 'id', type: ['pstring', { countType: 'i16' }] },
{ name: 'x', type: 'f32' },
{ name: 'y', type: 'f32' },
{ name: 'isRightClick', type: 'bool' }
]
]
bot._client.registerChannel(MEDIA_INTERACTION_CHANNEL, interactionPacketStructure, true)
// Media play channel
bot._client.registerChannel(MEDIA_PLAY_CHANNEL_CLIENTBOUND, noDataPacketStructure, true)
const mediaStopPacketStructure = [
'container',
[
{ name: 'id', type: ['pstring', { countType: 'i16' }] },
// ended - emitted even when loop is true (will continue playing)
// error: ...
// stalled - connection drops, server stops sending data
// waiting - connection is slow, server is sending data, but not fast enough (buffering)
// control
{ name: 'reason', type: ['pstring', { countType: 'i16' }] },
{ name: 'time', type: 'f32' }
]
]
bot._client.registerChannel(MEDIA_STOP_CHANNEL_CLIENTBOUND, mediaStopPacketStructure, true)
console.debug('Registered media channels')
}
const MEDIA_INTERACTION_CHANNEL = 'minecraft-web-client:media-interaction'
const MEDIA_PLAY_CHANNEL_CLIENTBOUND = 'minecraft-web-client:media-play'
const MEDIA_STOP_CHANNEL_CLIENTBOUND = 'minecraft-web-client:media-stop'
export const sendVideoInteraction = (id: string, x: number, y: number, isRightClick: boolean) => {
bot._client.writeChannel(MEDIA_INTERACTION_CHANNEL, { id, x, y, isRightClick })
}
export const sendVideoPlay = (id: string) => {
bot._client.writeChannel(MEDIA_PLAY_CHANNEL_CLIENTBOUND, { id })
}
export const sendVideoStop = (id: string, reason: string, time: number) => {
bot._client.writeChannel(MEDIA_STOP_CHANNEL_CLIENTBOUND, { id, reason, time })
}
export const videoCursorInteraction = () => {
const { intersectMedia } = appViewer.rendererState.world
if (!intersectMedia) return null
return intersectMedia
}
window.videoCursorInteraction = videoCursorInteraction
const addTestVideo = (rotation = 0 as 0 | 1 | 2 | 3, scale = 1, isImage = false) => {
const block = window.cursorBlockRel()
if (!block) return
const { position: startPosition } = block
// Add video with proper positioning
getThreeJsRendererMethods()?.addMedia('test-video', {
position: {
x: startPosition.x,
y: startPosition.y + 1,
z: startPosition.z
},
size: {
width: scale,
height: scale
},
src: isImage ? 'https://bucket.mcraft.fun/test_image.png' : 'https://bucket.mcraft.fun/test_video.mp4',
rotation,
// doubleSide: true,
background: 0x00_00_00, // Black color
// TODO broken
// uvMapping: {
// startU: 0,
// endU: 1,
// startV: 0,
// endV: 1
// },
opacity: 1,
allowOrigins: true,
})
}
window.addTestVideo = addTestVideo
function getCurrentTopDomain (): string {
const { hostname } = location
// Split hostname into parts
const parts = hostname.split('.')
// Handle special cases like co.uk, com.br, etc.
if (parts.length > 2) {
// Check for common country codes with additional segments
if (parts.at(-2) === 'co' ||
parts.at(-2) === 'com' ||
parts.at(-2) === 'org' ||
parts.at(-2) === 'gov') {
// Return last 3 parts (e.g., example.co.uk)
return parts.slice(-3).join('.')
}
}
// Return last 2 parts (e.g., example.com)
return parts.slice(-2).join('.')
}

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