diff --git a/.eslintrc.json b/.eslintrc.json index 8b2225b5..3552f6a7 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -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", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92b7e7f3..d624be53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 6408c86a..4b755f7b 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -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 diff --git a/.gitignore b/.gitignore index f2a0006e..bd774315 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ localSettings.mjs dist* .DS_Store .idea/ -world +/world data*.json out *.iml diff --git a/README.MD b/README.MD index 90e8f35f..67e46333 100644 --- a/README.MD +++ b/README.MD @@ -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) diff --git a/config.json b/config.json index 532ed9c7..2ede8070 100644 --- a/config.json +++ b/config.json @@ -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", diff --git a/package.json b/package.json index cd8c25ff..ed9cbec1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9093dfc0..ba4d494f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/renderer/playground/baseScene.ts b/renderer/playground/baseScene.ts index 02a6432f..b9e7791d 100644 --- a/renderer/playground/baseScene.ts +++ b/renderer/playground/baseScene.ts @@ -1,3 +1,4 @@ +//@ts-nocheck import { Vec3 } from 'vec3' import * as THREE from 'three' import '../../src/getCollisionShapes' diff --git a/renderer/playground/playground.ts b/renderer/playground/playground.ts index a233bca2..de201d8f 100644 --- a/renderer/playground/playground.ts +++ b/renderer/playground/playground.ts @@ -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 diff --git a/renderer/playground/scenes/allEntities.ts b/renderer/playground/scenes/allEntities.ts index 7fa0b6eb..68714cf8 100644 --- a/renderer/playground/scenes/allEntities.ts +++ b/renderer/playground/scenes/allEntities.ts @@ -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 diff --git a/renderer/playground/scenes/entities.ts b/renderer/playground/scenes/entities.ts index 11b1591a..5b5d0582 100644 --- a/renderer/playground/scenes/entities.ts +++ b/renderer/playground/scenes/entities.ts @@ -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 diff --git a/renderer/playground/scenes/frequentUpdates.ts b/renderer/playground/scenes/frequentUpdates.ts index bc401255..caaf7207 100644 --- a/renderer/playground/scenes/frequentUpdates.ts +++ b/renderer/playground/scenes/frequentUpdates.ts @@ -1,3 +1,4 @@ +//@ts-nocheck import { Vec3 } from 'vec3' import { BasePlaygroundScene } from '../baseScene' diff --git a/renderer/playground/scenes/lightingStarfield.ts b/renderer/playground/scenes/lightingStarfield.ts index 4b259b89..eec0a7d3 100644 --- a/renderer/playground/scenes/lightingStarfield.ts +++ b/renderer/playground/scenes/lightingStarfield.ts @@ -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 diff --git a/renderer/playground/scenes/main.ts b/renderer/playground/scenes/main.ts index 73a36762..64925f61 100644 --- a/renderer/playground/scenes/main.ts +++ b/renderer/playground/scenes/main.ts @@ -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 diff --git a/renderer/viewer/baseGraphicsBackend.ts b/renderer/viewer/baseGraphicsBackend.ts new file mode 100644 index 00000000..79607695 --- /dev/null +++ b/renderer/viewer/baseGraphicsBackend.ts @@ -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 + } +} diff --git a/renderer/viewer/prepare/utils.ts b/renderer/viewer/common/utils.ts similarity index 100% rename from renderer/viewer/prepare/utils.ts rename to renderer/viewer/common/utils.ts diff --git a/renderer/viewer/index.js b/renderer/viewer/index.js deleted file mode 100644 index 3e263db9..00000000 --- a/renderer/viewer/index.js +++ /dev/null @@ -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 -} diff --git a/renderer/viewer/lib/basePlayerState.ts b/renderer/viewer/lib/basePlayerState.ts index 1bbb690f..3384f0b4 100644 --- a/renderer/viewer/lib/basePlayerState.ts +++ b/renderer/viewer/lib/basePlayerState.ts @@ -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> @@ -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 diff --git a/renderer/viewer/lib/guiRenderer.ts b/renderer/viewer/lib/guiRenderer.ts index d2987ce6..bdfd805c 100644 --- a/renderer/viewer/lib/guiRenderer.ts +++ b/renderer/viewer/lib/guiRenderer.ts @@ -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 const itemsModelsResolved = {} as Record 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, 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, 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, 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, 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, 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, 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) diff --git a/renderer/viewer/lib/mesher/mesher.ts b/renderer/viewer/lib/mesher/mesher.ts index 9afcfeb3..42432a6d 100644 --- a/renderer/viewer/lib/mesher/mesher.ts +++ b/renderer/viewer/lib/mesher/mesher.ts @@ -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) { diff --git a/renderer/viewer/lib/mesher/models.ts b/renderer/viewer/lib/mesher/models.ts index dd2952fb..677886f9 100644 --- a/renderer/viewer/lib/mesher/models.ts +++ b/renderer/viewer/lib/mesher/models.ts @@ -468,7 +468,7 @@ export function getSectionGeometry (sx, sy, sz, world: World) { heads: {}, signs: {}, // isFull: true, - highestBlocks: new Map([]), + 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 diff --git a/renderer/viewer/lib/mesher/shared.ts b/renderer/viewer/lib/mesher/shared.ts index 92ea2c82..0e36c73b 100644 --- a/renderer/viewer/lib/mesher/shared.ts +++ b/renderer/viewer/lib/mesher/shared.ts @@ -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, signs: Record, // isFull: boolean - highestBlocks: Map + highestBlocks: Record hadErrors: boolean blocksCount: number customBlockModels?: CustomBlockModels diff --git a/renderer/viewer/lib/mesher/test/tests.test.ts b/renderer/viewer/lib/mesher/test/tests.test.ts index 9ebd6604..7959f573 100644 --- a/renderer/viewer/lib/mesher/test/tests.test.ts +++ b/renderer/viewer/lib/mesher/test/tests.test.ts @@ -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, diff --git a/renderer/viewer/lib/ui/newStats.ts b/renderer/viewer/lib/ui/newStats.ts index d18ad16c..6c84ef69 100644 --- a/renderer/viewer/lib/ui/newStats.ts +++ b/renderer/viewer/lib/ui/newStats.ts @@ -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) diff --git a/renderer/viewer/lib/viewer.ts b/renderer/viewer/lib/viewer.ts deleted file mode 100644 index 941f2182..00000000 --- a/renderer/viewer/lib/viewer.ts +++ /dev/null @@ -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 { - 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(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) - } - 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() - } -} diff --git a/renderer/viewer/lib/viewerWrapper.ts b/renderer/viewer/lib/viewerWrapper.ts deleted file mode 100644 index 6b2fb562..00000000 --- a/renderer/viewer/lib/viewerWrapper.ts +++ /dev/null @@ -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() - } -} diff --git a/renderer/viewer/lib/worldDataEmitter.ts b/renderer/viewer/lib/worldDataEmitter.ts index 7c2be715..b4014239 100644 --- a/renderer/viewer/lib/worldDataEmitter.ts +++ b/renderer/viewer/lib/worldDataEmitter.ts @@ -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 | { blockEntities: Record }) => 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) { private loadedChunks: Record private readonly lastPos: Vec3 private eventListeners: Record = {} @@ -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[] diff --git a/renderer/viewer/lib/worldrendererCommon.ts b/renderer/viewer/lib/worldrendererCommon.ts index 6ccbdca5..f48f7956 100644 --- a/renderer/viewer/lib/worldrendererCommon.ts +++ b/renderer/viewer/lib/worldrendererCommon.ts @@ -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 -} - export abstract class WorldRendererCommon { - // 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 // data is added for these chunks and they might be still processing @@ -85,60 +76,38 @@ export abstract class WorldRendererCommon 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() - blockstatesModels: any - customBlockStates: Record | undefined - customModels: Record | undefined - itemsAtlasParser: AtlasParser | undefined - blocksAtlasParser: AtlasParser | undefined + highestBlocksByChunks = {} as Record + highestBlocksBySections = {} as Record + 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 lastAddChunk = null as null | { @@ -150,14 +119,6 @@ export abstract class WorldRendererCommon 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 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 } 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 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 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 } 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 - 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 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 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 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(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) + } + 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 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 y: pos.y, z: pos.z, value, - config: this.mesherConfig, + config: this.getMesherConfig(), }) this.dispatchMessages() } @@ -679,8 +806,24 @@ export abstract class WorldRendererCommon } 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') + } } diff --git a/renderer/viewer/three/appShared.ts b/renderer/viewer/three/appShared.ts new file mode 100644 index 00000000..9afbe563 --- /dev/null +++ b/renderer/viewer/three/appShared.ts @@ -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, specificProps: ItemSpecificContextProperties, resourcesManager: ResourcesManager): { + u: number + v: number + su: number + sv: number + renderInfo?: ReturnType + 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' + } + } +} diff --git a/renderer/viewer/three/cameraShake.ts b/renderer/viewer/three/cameraShake.ts new file mode 100644 index 00000000..f6a61e2e --- /dev/null +++ b/renderer/viewer/three/cameraShake.ts @@ -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 + } +} diff --git a/renderer/viewer/three/documentRenderer.ts b/renderer/viewer/three/documentRenderer.ts new file mode 100644 index 00000000..dbea1205 --- /dev/null +++ b/renderer/viewer/three/documentRenderer.ts @@ -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() + } +} diff --git a/renderer/viewer/lib/entities.ts b/renderer/viewer/three/entities.ts similarity index 88% rename from renderer/viewer/lib/entities.ts rename to renderer/viewer/three/entities.ts index c6be8004..35872deb 100644 --- a/renderer/viewer/lib/entities.ts +++ b/renderer/viewer/three/entities.ts @@ -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 - 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 itemFrameMaps = {} as Record>> - getItemUv: undefined | ((item: Record, 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 { const byName: Record = {} @@ -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() + 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 + 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 (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) } diff --git a/renderer/viewer/lib/entity/EntityMesh.ts b/renderer/viewer/three/entity/EntityMesh.ts similarity index 97% rename from renderer/viewer/lib/entity/EntityMesh.ts rename to renderer/viewer/three/entity/EntityMesh.ts index 74023794..af6cc576 100644 --- a/renderer/viewer/lib/entity/EntityMesh.ts +++ b/renderer/viewer/three/entity/EntityMesh.ts @@ -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 = {} ) { diff --git a/renderer/viewer/lib/entity/animations.js b/renderer/viewer/three/entity/animations.js similarity index 100% rename from renderer/viewer/lib/entity/animations.js rename to renderer/viewer/three/entity/animations.js diff --git a/renderer/viewer/lib/entity/armorModels.json b/renderer/viewer/three/entity/armorModels.json similarity index 100% rename from renderer/viewer/lib/entity/armorModels.json rename to renderer/viewer/three/entity/armorModels.json diff --git a/renderer/viewer/lib/entity/armorModels.ts b/renderer/viewer/three/entity/armorModels.ts similarity index 100% rename from renderer/viewer/lib/entity/armorModels.ts rename to renderer/viewer/three/entity/armorModels.ts diff --git a/renderer/viewer/lib/entity/entities.json b/renderer/viewer/three/entity/entities.json similarity index 100% rename from renderer/viewer/lib/entity/entities.json rename to renderer/viewer/three/entity/entities.json diff --git a/renderer/viewer/lib/entity/exportedModels.js b/renderer/viewer/three/entity/exportedModels.js similarity index 100% rename from renderer/viewer/lib/entity/exportedModels.js rename to renderer/viewer/three/entity/exportedModels.js diff --git a/renderer/viewer/lib/entity/externalTextures.json b/renderer/viewer/three/entity/externalTextures.json similarity index 100% rename from renderer/viewer/lib/entity/externalTextures.json rename to renderer/viewer/three/entity/externalTextures.json diff --git a/renderer/viewer/lib/entity/models/allay.obj b/renderer/viewer/three/entity/models/allay.obj similarity index 100% rename from renderer/viewer/lib/entity/models/allay.obj rename to renderer/viewer/three/entity/models/allay.obj diff --git a/renderer/viewer/lib/entity/models/arrow.obj b/renderer/viewer/three/entity/models/arrow.obj similarity index 100% rename from renderer/viewer/lib/entity/models/arrow.obj rename to renderer/viewer/three/entity/models/arrow.obj diff --git a/renderer/viewer/lib/entity/models/axolotl.obj b/renderer/viewer/three/entity/models/axolotl.obj similarity index 100% rename from renderer/viewer/lib/entity/models/axolotl.obj rename to renderer/viewer/three/entity/models/axolotl.obj diff --git a/renderer/viewer/lib/entity/models/blaze.obj b/renderer/viewer/three/entity/models/blaze.obj similarity index 100% rename from renderer/viewer/lib/entity/models/blaze.obj rename to renderer/viewer/three/entity/models/blaze.obj diff --git a/renderer/viewer/lib/entity/models/boat.obj b/renderer/viewer/three/entity/models/boat.obj similarity index 100% rename from renderer/viewer/lib/entity/models/boat.obj rename to renderer/viewer/three/entity/models/boat.obj diff --git a/renderer/viewer/lib/entity/models/camel.obj b/renderer/viewer/three/entity/models/camel.obj similarity index 100% rename from renderer/viewer/lib/entity/models/camel.obj rename to renderer/viewer/three/entity/models/camel.obj diff --git a/renderer/viewer/lib/entity/models/cat.obj b/renderer/viewer/three/entity/models/cat.obj similarity index 100% rename from renderer/viewer/lib/entity/models/cat.obj rename to renderer/viewer/three/entity/models/cat.obj diff --git a/renderer/viewer/lib/entity/models/chicken.obj b/renderer/viewer/three/entity/models/chicken.obj similarity index 100% rename from renderer/viewer/lib/entity/models/chicken.obj rename to renderer/viewer/three/entity/models/chicken.obj diff --git a/renderer/viewer/lib/entity/models/cod.obj b/renderer/viewer/three/entity/models/cod.obj similarity index 100% rename from renderer/viewer/lib/entity/models/cod.obj rename to renderer/viewer/three/entity/models/cod.obj diff --git a/renderer/viewer/lib/entity/models/creeper.obj b/renderer/viewer/three/entity/models/creeper.obj similarity index 100% rename from renderer/viewer/lib/entity/models/creeper.obj rename to renderer/viewer/three/entity/models/creeper.obj diff --git a/renderer/viewer/lib/entity/models/dolphin.obj b/renderer/viewer/three/entity/models/dolphin.obj similarity index 100% rename from renderer/viewer/lib/entity/models/dolphin.obj rename to renderer/viewer/three/entity/models/dolphin.obj diff --git a/renderer/viewer/lib/entity/models/ender_dragon.obj b/renderer/viewer/three/entity/models/ender_dragon.obj similarity index 100% rename from renderer/viewer/lib/entity/models/ender_dragon.obj rename to renderer/viewer/three/entity/models/ender_dragon.obj diff --git a/renderer/viewer/lib/entity/models/enderman.obj b/renderer/viewer/three/entity/models/enderman.obj similarity index 100% rename from renderer/viewer/lib/entity/models/enderman.obj rename to renderer/viewer/three/entity/models/enderman.obj diff --git a/renderer/viewer/lib/entity/models/endermite.obj b/renderer/viewer/three/entity/models/endermite.obj similarity index 100% rename from renderer/viewer/lib/entity/models/endermite.obj rename to renderer/viewer/three/entity/models/endermite.obj diff --git a/renderer/viewer/lib/entity/models/fox.obj b/renderer/viewer/three/entity/models/fox.obj similarity index 100% rename from renderer/viewer/lib/entity/models/fox.obj rename to renderer/viewer/three/entity/models/fox.obj diff --git a/renderer/viewer/lib/entity/models/frog.obj b/renderer/viewer/three/entity/models/frog.obj similarity index 100% rename from renderer/viewer/lib/entity/models/frog.obj rename to renderer/viewer/three/entity/models/frog.obj diff --git a/renderer/viewer/lib/entity/models/ghast.obj b/renderer/viewer/three/entity/models/ghast.obj similarity index 100% rename from renderer/viewer/lib/entity/models/ghast.obj rename to renderer/viewer/three/entity/models/ghast.obj diff --git a/renderer/viewer/lib/entity/models/goat.obj b/renderer/viewer/three/entity/models/goat.obj similarity index 100% rename from renderer/viewer/lib/entity/models/goat.obj rename to renderer/viewer/three/entity/models/goat.obj diff --git a/renderer/viewer/lib/entity/models/guardian.obj b/renderer/viewer/three/entity/models/guardian.obj similarity index 100% rename from renderer/viewer/lib/entity/models/guardian.obj rename to renderer/viewer/three/entity/models/guardian.obj diff --git a/renderer/viewer/lib/entity/models/horse.obj b/renderer/viewer/three/entity/models/horse.obj similarity index 100% rename from renderer/viewer/lib/entity/models/horse.obj rename to renderer/viewer/three/entity/models/horse.obj diff --git a/renderer/viewer/lib/entity/models/llama.obj b/renderer/viewer/three/entity/models/llama.obj similarity index 100% rename from renderer/viewer/lib/entity/models/llama.obj rename to renderer/viewer/three/entity/models/llama.obj diff --git a/renderer/viewer/lib/entity/models/minecart.obj b/renderer/viewer/three/entity/models/minecart.obj similarity index 100% rename from renderer/viewer/lib/entity/models/minecart.obj rename to renderer/viewer/three/entity/models/minecart.obj diff --git a/renderer/viewer/lib/entity/models/parrot.obj b/renderer/viewer/three/entity/models/parrot.obj similarity index 100% rename from renderer/viewer/lib/entity/models/parrot.obj rename to renderer/viewer/three/entity/models/parrot.obj diff --git a/renderer/viewer/lib/entity/models/piglin.obj b/renderer/viewer/three/entity/models/piglin.obj similarity index 100% rename from renderer/viewer/lib/entity/models/piglin.obj rename to renderer/viewer/three/entity/models/piglin.obj diff --git a/renderer/viewer/lib/entity/models/pillager.obj b/renderer/viewer/three/entity/models/pillager.obj similarity index 100% rename from renderer/viewer/lib/entity/models/pillager.obj rename to renderer/viewer/three/entity/models/pillager.obj diff --git a/renderer/viewer/lib/entity/models/rabbit.obj b/renderer/viewer/three/entity/models/rabbit.obj similarity index 100% rename from renderer/viewer/lib/entity/models/rabbit.obj rename to renderer/viewer/three/entity/models/rabbit.obj diff --git a/renderer/viewer/lib/entity/models/sheep.obj b/renderer/viewer/three/entity/models/sheep.obj similarity index 100% rename from renderer/viewer/lib/entity/models/sheep.obj rename to renderer/viewer/three/entity/models/sheep.obj diff --git a/renderer/viewer/lib/entity/models/shulker.obj b/renderer/viewer/three/entity/models/shulker.obj similarity index 100% rename from renderer/viewer/lib/entity/models/shulker.obj rename to renderer/viewer/three/entity/models/shulker.obj diff --git a/renderer/viewer/lib/entity/models/sniffer.obj b/renderer/viewer/three/entity/models/sniffer.obj similarity index 100% rename from renderer/viewer/lib/entity/models/sniffer.obj rename to renderer/viewer/three/entity/models/sniffer.obj diff --git a/renderer/viewer/lib/entity/models/spider.obj b/renderer/viewer/three/entity/models/spider.obj similarity index 100% rename from renderer/viewer/lib/entity/models/spider.obj rename to renderer/viewer/three/entity/models/spider.obj diff --git a/renderer/viewer/lib/entity/models/tadpole.obj b/renderer/viewer/three/entity/models/tadpole.obj similarity index 100% rename from renderer/viewer/lib/entity/models/tadpole.obj rename to renderer/viewer/three/entity/models/tadpole.obj diff --git a/renderer/viewer/lib/entity/models/turtle.obj b/renderer/viewer/three/entity/models/turtle.obj similarity index 100% rename from renderer/viewer/lib/entity/models/turtle.obj rename to renderer/viewer/three/entity/models/turtle.obj diff --git a/renderer/viewer/lib/entity/models/vex.obj b/renderer/viewer/three/entity/models/vex.obj similarity index 100% rename from renderer/viewer/lib/entity/models/vex.obj rename to renderer/viewer/three/entity/models/vex.obj diff --git a/renderer/viewer/lib/entity/models/villager.obj b/renderer/viewer/three/entity/models/villager.obj similarity index 100% rename from renderer/viewer/lib/entity/models/villager.obj rename to renderer/viewer/three/entity/models/villager.obj diff --git a/renderer/viewer/lib/entity/models/warden.obj b/renderer/viewer/three/entity/models/warden.obj similarity index 100% rename from renderer/viewer/lib/entity/models/warden.obj rename to renderer/viewer/three/entity/models/warden.obj diff --git a/renderer/viewer/lib/entity/models/witch.obj b/renderer/viewer/three/entity/models/witch.obj similarity index 100% rename from renderer/viewer/lib/entity/models/witch.obj rename to renderer/viewer/three/entity/models/witch.obj diff --git a/renderer/viewer/lib/entity/models/wolf.obj b/renderer/viewer/three/entity/models/wolf.obj similarity index 100% rename from renderer/viewer/lib/entity/models/wolf.obj rename to renderer/viewer/three/entity/models/wolf.obj diff --git a/renderer/viewer/lib/entity/models/zombie_villager.obj b/renderer/viewer/three/entity/models/zombie_villager.obj similarity index 100% rename from renderer/viewer/lib/entity/models/zombie_villager.obj rename to renderer/viewer/three/entity/models/zombie_villager.obj diff --git a/renderer/viewer/lib/entity/objModels.js b/renderer/viewer/three/entity/objModels.js similarity index 100% rename from renderer/viewer/lib/entity/objModels.js rename to renderer/viewer/three/entity/objModels.js diff --git a/renderer/viewer/three/graphicsBackend.ts b/renderer/viewer/three/graphicsBackend.ts new file mode 100644 index 00000000..a3a4c156 --- /dev/null +++ b/renderer/viewer/three/graphicsBackend.ts @@ -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 + +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 => { + 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 diff --git a/renderer/viewer/lib/holdingBlock.ts b/renderer/viewer/three/holdingBlock.ts similarity index 96% rename from renderer/viewer/lib/holdingBlock.ts rename to renderer/viewer/three/holdingBlock.ts index 084953fd..9ba76224 100644 --- a/renderer/viewer/lib/holdingBlock.ts +++ b/renderer/viewer/three/holdingBlock.ts @@ -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: {} diff --git a/renderer/viewer/three/panorama.ts b/renderer/viewer/three/panorama.ts new file mode 100644 index 00000000..8f79ff54 --- /dev/null +++ b/renderer/viewer/three/panorama.ts @@ -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 | 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 +// } +// } diff --git a/renderer/viewer/lib/primitives.js b/renderer/viewer/three/primitives.js similarity index 100% rename from renderer/viewer/lib/primitives.js rename to renderer/viewer/three/primitives.js diff --git a/renderer/viewer/three/threeJsMedia.ts b/renderer/viewer/three/threeJsMedia.ts new file mode 100644 index 00000000..e9ddcd9f --- /dev/null +++ b/renderer/viewer/three/threeJsMedia.ts @@ -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 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 + } +} diff --git a/renderer/viewer/three/threeJsMethods.ts b/renderer/viewer/three/threeJsMethods.ts new file mode 100644 index 00000000..629909c9 --- /dev/null +++ b/renderer/viewer/three/threeJsMethods.ts @@ -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 +} diff --git a/renderer/viewer/three/threeJsSound.ts b/renderer/viewer/three/threeJsSound.ts new file mode 100644 index 00000000..627cabf8 --- /dev/null +++ b/renderer/viewer/three/threeJsSound.ts @@ -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() + 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') + } +} diff --git a/renderer/viewer/lib/threeJsUtils.ts b/renderer/viewer/three/threeJsUtils.ts similarity index 100% rename from renderer/viewer/lib/threeJsUtils.ts rename to renderer/viewer/three/threeJsUtils.ts diff --git a/renderer/viewer/three/world/cursorBlock.ts b/renderer/viewer/three/world/cursorBlock.ts new file mode 100644 index 00000000..ab0c4854 --- /dev/null +++ b/renderer/viewer/three/world/cursorBlock.ts @@ -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() + } +} diff --git a/src/vr.ts b/renderer/viewer/three/world/vr.ts similarity index 85% rename from src/vr.ts rename to renderer/viewer/three/world/vr.ts index dd97560e..925ba0bb 100644 --- a/src/vr.ts +++ b/renderer/viewer/three/world/vr.ts @@ -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 = [ diff --git a/renderer/viewer/lib/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts similarity index 65% rename from renderer/viewer/lib/worldrendererThree.ts rename to renderer/viewer/three/worldrendererThree.ts index 697717c9..31f68b10 100644 --- a/renderer/viewer/lib/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -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 = {} chunkTextures = new Map() signsCache = new Map() @@ -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 = (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 { + 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, 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 { diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 1701fc5c..36ccb9b0 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -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') diff --git a/src/appViewer.ts b/src/appViewer.ts new file mode 100644 index 00000000..f4032712 --- /dev/null +++ b/src/appViewer.ts @@ -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 + startWorld: (options: DisplayWorldOptions) => void + disconnect: () => void + setRendering: (rendering: boolean) => void + getDebugOverlay?: () => Record + updateCamera: (pos: Vec3 | null, yaw: number, pitch: number) => void + setRoll?: (roll: number) => void + soundSystem: SoundSystem | undefined + + backendMethods: Record | 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 + 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() + 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() diff --git a/src/botUtils.ts b/src/botUtils.ts index ac2deb02..10609322 100644 --- a/src/botUtils.ts +++ b/src/botUtils.ts @@ -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 { diff --git a/src/cameraRotationControls.ts b/src/cameraRotationControls.ts index 0c222dc6..8b21e53d 100644 --- a/src/cameraRotationControls.ts +++ b/src/cameraRotationControls.ts @@ -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' }) } diff --git a/src/chatUtils.ts b/src/chatUtils.ts index 27ff2bf9..5143c10f 100644 --- a/src/chatUtils.ts +++ b/src/chatUtils.ts @@ -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 & { text: string diff --git a/src/connect.ts b/src/connect.ts index 278c2925..5afdb0f6 100644 --- a/src/connect.ts +++ b/src/connect.ts @@ -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 () => { diff --git a/src/controls.ts b/src/controls.ts index 98d32062..aa78b103 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -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 diff --git a/src/core/progressReporter.ts b/src/core/progressReporter.ts index 6ef6044f..f4e4e701 100644 --- a/src/core/progressReporter.ts +++ b/src/core/progressReporter.ts @@ -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) { + } + }) +} diff --git a/src/core/timers.ts b/src/core/timers.ts new file mode 100644 index 00000000..fd9c72f5 --- /dev/null +++ b/src/core/timers.ts @@ -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 () => {} + } +} diff --git a/src/customChannels.ts b/src/customChannels.ts index ff0f8a32..2a26cc5a 100644 --- a/src/customChannels.ts +++ b/src/customChannels.ts @@ -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('.') +} diff --git a/src/dayCycle.ts b/src/dayCycle.ts index f092c465..50e63a21 100644 --- a/src/dayCycle.ts +++ b/src/dayCycle.ts @@ -4,7 +4,6 @@ import { updateBackground } from './water' export default () => { const timeUpdated = () => { - assertDefined(viewer) // 0 morning const dayTotal = 24_000 const evening = 11_500 @@ -37,8 +36,8 @@ export default () => { const colorInt = Math.max(int, 0.1) updateBackground({ r: dayColor.r * colorInt, g: dayColor.g * colorInt, b: dayColor.b * colorInt }) if (!options.newVersionsLighting && bot.supportFeature('blockStateId')) { - viewer.ambientLight.intensity = Math.max(int, 0.25) - viewer.directionalLight.intensity = Math.min(int, 0.5) + appViewer.playerState.reactive.ambientLight = Math.max(int, 0.25) + appViewer.playerState.reactive.directionalLight = Math.min(int, 0.5) } } diff --git a/src/devReload.ts b/src/devReload.ts index 5be5f2f6..e778d8d4 100644 --- a/src/devReload.ts +++ b/src/devReload.ts @@ -1,5 +1,4 @@ import { isMobile } from 'renderer/viewer/lib/simpleUtils' -import { WorldRendererThree } from 'renderer/viewer/lib/worldrendererThree' if (process.env.NODE_ENV === 'development') { // mobile devtools diff --git a/src/devtools.ts b/src/devtools.ts index daaf1a35..5b15711f 100644 --- a/src/devtools.ts +++ b/src/devtools.ts @@ -1,7 +1,7 @@ // global variables useful for debugging import fs from 'fs' -import { WorldRendererThree } from 'renderer/viewer/lib/worldrendererThree' +import { WorldRendererThree } from 'renderer/viewer/three/worldrendererThree' import { enable, disable, enabled } from 'debug' import { Vec3 } from 'vec3' @@ -21,7 +21,7 @@ window.inspectPlayer = () => require('fs').promises.readFile('/world/playerdata/ Object.defineProperty(window, 'debugSceneChunks', { get () { - return (viewer.world as WorldRendererThree).getLoadedChunksRelative?.(bot.entity.position, true) + return (window.world as WorldRendererThree)?.getLoadedChunksRelative?.(bot.entity.position, true) }, }) @@ -149,7 +149,7 @@ Object.defineProperty(window, 'debugToggle', { }) customEvents.on('gameLoaded', () => { - window.holdingBlock = (viewer.world as WorldRendererThree).holdingBlock + window.holdingBlock = (window.world as WorldRendererThree).holdingBlock }) window.clearStorage = (...keysToKeep: string[]) => { @@ -161,3 +161,50 @@ window.clearStorage = (...keysToKeep: string[]) => { } return `Cleared ${localStorage.length - keysToKeep.length} items from localStorage. Kept: ${keysToKeep.join(', ')}` } + + +// PERF DEBUG + +// for advanced debugging, use with watch expression + +window.statsPerSecAvg = {} +let currentStatsPerSec = {} as Record +const waitingStatsPerSec = {} +window.markStart = (label) => { + waitingStatsPerSec[label] ??= [] + waitingStatsPerSec[label][0] = performance.now() +} +window.markEnd = (label) => { + if (!waitingStatsPerSec[label]?.[0]) return + currentStatsPerSec[label] ??= [] + currentStatsPerSec[label].push(performance.now() - waitingStatsPerSec[label][0]) + delete waitingStatsPerSec[label] +} +const updateStatsPerSecAvg = () => { + window.statsPerSecAvg = Object.fromEntries(Object.entries(currentStatsPerSec).map(([key, value]) => { + return [key, { + avg: value.reduce((a, b) => a + b, 0) / value.length, + count: value.length + }] + })) + currentStatsPerSec = {} +} + + +window.statsPerSec = {} +let statsPerSecCurrent = {} +let lastReset = performance.now() +window.addStatPerSec = (name) => { + statsPerSecCurrent[name] ??= 0 + statsPerSecCurrent[name]++ +} +window.statsPerSecCurrent = statsPerSecCurrent +setInterval(() => { + window.statsPerSec = { duration: Math.floor(performance.now() - lastReset), ...statsPerSecCurrent, } + statsPerSecCurrent = {} + window.statsPerSecCurrent = statsPerSecCurrent + updateStatsPerSecAvg() + lastReset = performance.now() +}, 1000) + +// --- diff --git a/src/entities.ts b/src/entities.ts index 774ce32e..9ba31f2e 100644 --- a/src/entities.ts +++ b/src/entities.ts @@ -1,8 +1,9 @@ import { Entity } from 'prismarine-entity' -import { versionToNumber } from 'renderer/viewer/prepare/utils' +import { versionToNumber } from 'renderer/viewer/common/utils' import tracker from '@nxg-org/mineflayer-tracker' import { loader as autoJumpPlugin } from '@nxg-org/mineflayer-auto-jump' import { subscribeKey } from 'valtio/utils' +import { getThreeJsRendererMethods } from 'renderer/viewer/three/threeJsMethods' import { options, watchValue } from './optionsStorage' import { miscUiState } from './globalState' @@ -40,23 +41,13 @@ customEvents.on('gameLoaded', () => { bot.loadPlugin(autoJumpPlugin) updateAutoJump() - // todo cleanup (move to viewer, also shouldnt be used at all) const playerPerAnimation = {} as Record const entityData = (e: Entity) => { if (!e.username) return window.debugEntityMetadata ??= {} window.debugEntityMetadata[e.username] = e - // todo entity spawn timing issue, check perf - const playerObject = viewer.entities.entities[e.id]?.playerObject - if (playerObject) { - // todo throttle! + if (e.type === 'player') { bot.tracker.trackEntity(e) - playerObject.backEquipment = e.equipment.some((item) => item?.name === 'elytra') ? 'elytra' : 'cape' - if (playerObject.cape.map === null) { - playerObject.cape.visible = false - } - // todo (easy, important) elytra flying animation - // todo cleanup states } } @@ -76,57 +67,34 @@ customEvents.on('gameLoaded', () => { const isSprinting = Math.abs(speed.x) > SPRINTING_SPEED || Math.abs(speed.z) > SPRINTING_SPEED const newAnimation = isWalking ? (isSprinting ? 'running' : 'walking') : 'idle' if (newAnimation !== playerPerAnimation[id]) { - viewer.entities.playAnimation(e.id, newAnimation) + getThreeJsRendererMethods()?.playEntityAnimation(e.id, newAnimation) playerPerAnimation[id] = newAnimation } } }) bot.on('entitySwingArm', (e) => { - if (viewer.entities.entities[e.id]?.playerObject) { - viewer.entities.playAnimation(e.id, 'oneSwing') - } + getThreeJsRendererMethods()?.playEntityAnimation(e.id, 'oneSwing') }) bot._client.on('damage_event', (data) => { const { entityId, sourceTypeId: damage } = data - if (viewer.entities.entities[entityId]) { - viewer.entities.handleDamageEvent(entityId, damage) - } + getThreeJsRendererMethods()?.damageEntity(entityId, damage) }) bot._client.on('entity_status', (data) => { if (versionToNumber(bot.version) >= versionToNumber('1.19.4')) return const { entityId, entityStatus } = data - if (entityStatus === 2 && viewer.entities.entities[entityId]) { - viewer.entities.handleDamageEvent(entityId, entityStatus) + if (entityStatus === 2) { + getThreeJsRendererMethods()?.damageEntity(entityId, entityStatus) } }) - const loadedSkinEntityIds = new Set() - - const playerRenderSkin = (e: Entity) => { - const mesh = viewer.entities.entities[e.id] - if (!mesh) return - if (!mesh.playerObject || !options.loadPlayerSkins) return - const MAX_DISTANCE_SKIN_LOAD = 128 - const distance = e.position.distanceTo(bot.entity.position) - if (distance < MAX_DISTANCE_SKIN_LOAD && distance < (bot.settings.viewDistance as number) * 16) { - if (viewer.entities.entities[e.id]) { - if (loadedSkinEntityIds.has(e.id)) return - loadedSkinEntityIds.add(e.id) - viewer.entities.updatePlayerSkin(e.id, e.username, e.uuid, true, true) - } - } - } - viewer.entities.addListener('remove', (e) => { - loadedSkinEntityIds.delete(e.id) - playerPerAnimation[e.id] = '' - bot.tracker.stopTrackingEntity(e, true) + bot.on('entityGone', (entity) => { + bot.tracker.stopTrackingEntity(entity, true) }) bot.on('entityMoved', (e) => { - playerRenderSkin(e) entityData(e) }) bot._client.on('entity_velocity', (packet) => { @@ -135,11 +103,6 @@ customEvents.on('gameLoaded', () => { entityData(e) }) - viewer.entities.addListener('add', (e) => { - if (!viewer.entities.entities[e.id]) throw new Error('mesh still not loaded') - playerRenderSkin(e) - }) - for (const entity of Object.values(bot.entities)) { if (entity !== bot.entity) { entityData(entity) @@ -150,10 +113,6 @@ customEvents.on('gameLoaded', () => { bot.on('entityUpdate', entityData) bot.on('entityEquip', entityData) - watchValue(options, o => { - viewer.entities.setDebugMode(o.showChunkBorders ? 'basic' : 'none') - }) - // Texture override from packet properties bot._client.on('player_info', (packet) => { for (const playerEntry of packet.data) { @@ -177,7 +136,7 @@ customEvents.on('gameLoaded', () => { } } // even if not found, still record to cache - viewer.entities.updatePlayerSkin(entityId, playerEntry.player?.name, playerEntry.uuid, skinUrl, capeUrl) + getThreeJsRendererMethods()?.updatePlayerSkin(entityId, playerEntry.player?.name, playerEntry.uuid, skinUrl, capeUrl) } catch (err) { console.error('Error decoding player texture:', err) } diff --git a/src/globals.d.ts b/src/globals.d.ts index 1dfb0255..2a854f16 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -11,7 +11,7 @@ declare const bot: Omit & { } } declare const __type_bot: typeof bot -declare const viewer: import('renderer/viewer/lib/viewer').Viewer +declare const appViewer: import('./appViewer').AppViewer declare const worldView: import('renderer/viewer/lib/worldDataEmitter').WorldDataEmitter | undefined declare const addStatPerSec: (name: string) => void declare const localServer: import('flying-squid/dist/index').FullServer & { options } | undefined diff --git a/src/index.ts b/src/index.ts index 45593447..988cf68e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import './testCrasher' import './globals' import './devtools' import './entities' -import './customChannels' +import customChannels from './customChannels' import './globalDomListeners' import './mineflayer/maps' import './mineflayer/cameraShake' @@ -13,18 +13,17 @@ import './shims/patchShims' import './mineflayer/java-tester/index' import './external' import './appConfig' +import './mineflayer/timers' import { getServerInfo } from './mineflayer/mc-protocol' -import { onGameLoad, renderSlot } from './inventoryWindows' -import { GeneralInputItem, RenderItem } from './mineflayer/items' +import { onGameLoad } from './inventoryWindows' import initCollisionShapes from './getCollisionInteractionShapes' import protocolMicrosoftAuth from 'minecraft-protocol/src/client/microsoftAuth' import microsoftAuthflow from './microsoftAuthflow' import { Duplex } from 'stream' import './scaleInterface' -import { initWithRenderer } from './topRightStats' -import { options, watchValue } from './optionsStorage' +import { options } from './optionsStorage' import './reactUi' import { lockUrl, onBotCreate } from './controls' import './dragndrop' @@ -33,18 +32,13 @@ import { watchOptionsAfterViewerInit, watchOptionsAfterWorldViewInit } from './w import downloadAndOpenFile from './downloadAndOpenFile' import fs from 'fs' -import net from 'net' +import net, { Socket } from 'net' import mineflayer from 'mineflayer' -import { WorldDataEmitter, Viewer } from 'renderer/viewer' -import pathfinder from 'mineflayer-pathfinder' -import * as THREE from 'three' -import MinecraftData from 'minecraft-data' import debug from 'debug' import { defaultsDeep } from 'lodash-es' import initializePacketsReplay from './packetsReplay/packetsReplayLegacy' -import { initVR } from './vr' import { activeModalStack, activeModalStacks, @@ -60,11 +54,6 @@ import { parseServerAddress } from './parseServerAddress' import { setLoadingScreenStatus } from './appStatus' import { isCypress } from './standaloneUtils' -import { - removePanorama -} from './panorama' -import { getItemDefinition } from 'mc-assets/dist/itemDefinitions' - import { startLocalServer, unsupportedLocalServerFeatures } from './createLocalServer' import defaultServerOptions from './defaultLocalServerOptions' import dayCycle from './dayCycle' @@ -79,42 +68,37 @@ import { fsState } from './loadSave' import { watchFov } from './rendererUtils' import { loadInMemorySave } from './react/SingleplayerProvider' -import { ua } from './react/utils' import { possiblyHandleStateVariable } from './googledrive' import flyingSquidEvents from './flyingSquidEvents' -import { hideNotification, notificationProxy, showNotification } from './react/NotificationProvider' +import { showNotification } from './react/NotificationProvider' import { saveToBrowserMemory } from './react/PauseScreen' -import { ViewerWrapper } from 'renderer/viewer/lib/viewerWrapper' import './devReload' import './water' import { ConnectOptions, loadMinecraftData, getVersionAutoSelect, downloadOtherGameData, downloadAllMinecraftData } from './connect' import { ref, subscribe } from 'valtio' import { signInMessageState } from './react/SignInMessageProvider' import { updateAuthenticatedAccountData, updateLoadedServerData, updateServerConnectionHistory } from './react/serversStorage' -import { versionToNumber } from 'renderer/viewer/prepare/utils' import packetsPatcher from './mineflayer/plugins/packetsPatcher' import { mainMenuState } from './react/MainMenuRenderApp' -import { ItemsRenderer } from 'mc-assets/dist/itemsRenderer' import './mobileShim' import { parseFormattedMessagePacket } from './botUtils' import { getViewerVersionData, getWsProtocolStream, handleCustomChannel } from './viewerConnector' import { getWebsocketStream } from './mineflayer/websocket-core' import { appQueryParams, appQueryParamsArray } from './appParams' -import { playerState, PlayerStateManager } from './mineflayer/playerState' +import { playerState } from './mineflayer/playerState' import { states } from 'minecraft-protocol' import { initMotionTracking } from './react/uiMotion' import { UserError } from './mineflayer/userError' import ping from './mineflayer/plugins/ping' import mouse from './mineflayer/plugins/mouse' -import { LocalServer } from './customServer' import { startLocalReplayServer } from './packetsReplay/replayPackets' import { localRelayServerPlugin } from './mineflayer/plugins/packetsRecording' -import { createFullScreenProgressReporter } from './core/progressReporter' -import { getItemModelName } from './resourcesManager' -import { importLargeData } from '../generated/large-data-aliases' +import { createConsoleLogProgressReporter, createFullScreenProgressReporter, ProgressReporter } from './core/progressReporter' +import { appViewer } from './appViewer' +import createGraphicsBackend from 'renderer/viewer/three/graphicsBackend' +import { subscribeKey } from 'valtio/utils' window.debug = debug -window.THREE = THREE window.beforeRenderFrame = [] // ACTUAL CODE @@ -127,106 +111,32 @@ initCollisionShapes() initializePacketsReplay() packetsPatcher() onAppLoad() - -// Create three.js context, add to page -let renderer: THREE.WebGLRenderer -try { - renderer = new THREE.WebGLRenderer({ - powerPreference: options.gpuPreference, - preserveDrawingBuffer: true, - logarithmicDepthBuffer: true, - }) -} catch (err) { - console.error(err) - throw new Error(`Failed to create WebGL context, not possible to render (restart browser): ${err.message}`) -} - -// renderer.localClippingEnabled = true -initWithRenderer(renderer.domElement) -const renderWrapper = new ViewerWrapper(renderer.domElement, renderer) -renderWrapper.addToPage() -watchValue(options, (o) => { - renderWrapper.renderInterval = o.frameLimit ? 1000 / o.frameLimit : 0 - renderWrapper.renderIntervalUnfocused = o.backgroundRendering === '5fps' ? 1000 / 5 : o.backgroundRendering === '20fps' ? 1000 / 20 : undefined -}) - -const isFirefox = ua.getBrowser().name === 'Firefox' -if (isFirefox) { - // set custom property - document.body.style.setProperty('--thin-if-firefox', 'thin') -} - -const isIphone = ua.getDevice().model === 'iPhone' // todo ipad? - -if (isIphone) { - document.documentElement.style.setProperty('--hud-bottom-max', '21px') // env-safe-aria-inset-bottom -} +customChannels() if (appQueryParams.testCrashApp === '2') throw new Error('test') -// Create viewer -const viewer: import('renderer/viewer/lib/viewer').Viewer = new Viewer(renderer, undefined, playerState) -window.viewer = viewer -Object.defineProperty(window, 'world', { - get () { - return viewer.world - }, -}) -// todo unify -viewer.entities.getItemUv = (item, specificProps) => { - 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) - - const renderInfo = renderSlot({ - modelName: model, - }, false, true) - - if (!renderInfo) throw new Error(`Failed to get render info for item ${name}`) - - const textureThree = renderInfo.texture === 'blocks' ? viewer.world.material.map! : viewer.entities.itemsTexture! - const img = textureThree.image - - if (renderInfo.blockData) { - return { - resolvedModel: renderInfo.blockData.resolvedModel, - modelName: renderInfo.modelName! - } +const loadBackend = () => { + appViewer.loadBackend(createGraphicsBackend) +} +window.loadBackend = loadBackend +if (process.env.SINGLE_FILE_BUILD_MODE) { + const unsub = subscribeKey(miscUiState, 'fsReady', () => { + if (miscUiState.fsReady) { + // don't do it earlier to load fs and display menu faster + loadBackend() + unsub() } - 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, - texture: textureThree, - 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 / viewer.world.material.map!.image.width, - sv: 16 / viewer.world.material.map!.image.width, - texture: viewer.world.material.map! - } - } + }) +} else { + loadBackend() } -viewer.entities.entitiesOptions = { - fontFamily: 'mojangles' +const animLoop = () => { + for (const fn of beforeRenderFrame) fn() + requestAnimationFrame(animLoop) } +requestAnimationFrame(animLoop) + watchOptionsAfterViewerInit() function hideCurrentScreens () { @@ -252,30 +162,26 @@ function listenGlobalEvents () { }) } -let listeners = [] as Array<{ target, event, callback }> -let cleanupFunctions = [] as Array<() => void> -// only for dom listeners (no removeAllListeners) -// todo refactor them out of connect fn instead -const registerListener: import('./utilsTs').RegisterListener = (target, event, callback) => { - target.addEventListener(event, callback) - listeners.push({ target, event, callback }) -} -const removeAllListeners = () => { - for (const { target, event, callback } of listeners) { - target.removeEventListener(event, callback) - } - for (const cleanupFunction of cleanupFunctions) { - cleanupFunction() - } - cleanupFunctions = [] - listeners = [] -} - export async function connect (connectOptions: ConnectOptions) { if (miscUiState.gameLoaded) return + + if (sessionStorage.delayLoadUntilFocus) { + await new Promise(resolve => { + if (document.hasFocus()) { + resolve(undefined) + } else { + window.addEventListener('focus', resolve) + } + }) + } + if (sessionStorage.delayLoadUntilClick) { + await new Promise(resolve => { + window.addEventListener('click', resolve) + }) + } + miscUiState.hasErrors = false lastConnectOptions.value = connectOptions - removePanorama() const { singleplayer } = connectOptions const p2pMultiplayer = !!connectOptions.peerId @@ -318,12 +224,11 @@ export async function connect (connectOptions: ConnectOptions) { const destroyAll = () => { if (ended) return ended = true - viewer.resetAll() progress.end() + // dont reset viewer so we can still do debugging localServer = window.localServer = window.server = undefined gameAdditionalState.viewerConnection = false - renderWrapper.postRender = () => { } if (bot) { bot.end() // ensure mineflayer plugins receive this event for cleanup @@ -337,7 +242,6 @@ export async function connect (connectOptions: ConnectOptions) { } resetStateAfterDisconnect() cleanFs() - removeAllListeners() } const cleanFs = () => { if (singleplayer && !fsState.inMemorySave) { @@ -396,6 +300,7 @@ export async function connect (connectOptions: ConnectOptions) { let updateDataAfterJoin = () => { } let localServer let localReplaySession: ReturnType | undefined + let lastKnownKickReason = undefined as string | undefined try { const serverOptions = defaultsDeep({}, connectOptions.serverOverrides ?? {}, options.localServerOptions, defaultServerOptions) Object.assign(serverOptions, connectOptions.serverOverridesFlat ?? {}) @@ -411,13 +316,20 @@ export async function connect (connectOptions: ConnectOptions) { const downloadMcData = async (version: string) => { if (dataDownloaded) return dataDownloaded = true + appViewer.resourcesManager.currentConfig = { version, texturesVersion: options.useVersionsTextures || undefined } + + await progress.executeWithMessage( + 'Loading minecraft data', + async () => { + await appViewer.resourcesManager.loadSourceData(version) + } + ) await progress.executeWithMessage( 'Applying user-installed resource pack', async () => { - await loadMinecraftData(version) try { - await resourcepackReload(version) + await resourcepackReload(true) } catch (err) { console.error(err) const doContinue = confirm('Failed to apply texture pack. See errors in the console. Continue?') @@ -429,11 +341,9 @@ export async function connect (connectOptions: ConnectOptions) { ) await progress.executeWithMessage( - 'Loading minecraft models', + 'Preparing textures', async () => { - viewer.world.blockstatesModels = await importLargeData('blockStatesModels') - void viewer.setVersion(version, options.useVersionsTextures === 'latest' ? version : options.useVersionsTextures) - miscUiState.loadedDataVersion = version + await appViewer.resourcesManager.updateAssetsData({}) } ) } @@ -555,9 +465,11 @@ export async function connect (connectOptions: ConnectOptions) { await downloadMcData(finalVersion) } + const brand = clientDataStream ? 'minecraft-web-client' : undefined bot = mineflayer.createBot({ host: server.host, port: server.port ? +server.port : undefined, + brand, version: finalVersion || false, ...clientDataStream ? { stream: clientDataStream as any, @@ -655,6 +567,16 @@ export async function connect (connectOptions: ConnectOptions) { bot._client.emit('connect') } else { const setupConnectHandlers = () => { + Socket.prototype['handleStringMessage'] = function (message: string) { + if (message.startsWith('proxy-message') || message.startsWith('proxy-command:')) { // for future + return false + } + if (message.startsWith('proxy-shutdown:')) { + lastKnownKickReason = message.slice('proxy-shutdown:'.length) + return false + } + return true + } bot._client.socket.on('connect', () => { console.log('Proxy WebSocket connection established') //@ts-expect-error @@ -717,8 +639,6 @@ export async function connect (connectOptions: ConnectOptions) { destroyAll() }) - // bot.emit('kicked', '{"translate":"disconnect.genericReason","with":["Internal Exception: io.netty.handler.codec.EncoderException: com.viaversion.viaversion.exception.InformativeException: Please report this on the Via support Discord or open an issue on the relevant GitHub repository\\nPacket Type: SYSTEM_CHAT, Index: 1, Type: TagType, Data: [], Packet ID: 103, Source 0: com.viaversion.viabackwards.protocol.v1_20_3to1_20_2.Protocol1_20_3To1_20_2$$Lambda/0x00007f9930f63080"]}', false) - const packetBeforePlay = (_, __, ___, fullBuffer) => { lastPacket = fullBuffer.toString() } @@ -734,9 +654,9 @@ export async function connect (connectOptions: ConnectOptions) { if (ended) return console.log('disconnected for', endReason) if (endReason === 'socketClosed') { - endReason = 'Connection with server lost' + endReason = lastKnownKickReason ?? 'Connection with server lost' } - setLoadingScreenStatus(`You have been disconnected from the server. End reason: ${endReason}`, true) + setLoadingScreenStatus(`You have been disconnected from the server. End reason:\n${endReason}`, true) appStatusState.showReconnect = true onPossibleErrorDisconnect() destroyAll() @@ -749,20 +669,31 @@ export async function connect (connectOptions: ConnectOptions) { setLoadingScreenStatus('Loading world') }) - const start = Date.now() + const loadStart = Date.now() let worldWasReady = false - void viewer.world.renderUpdateEmitter.on('update', () => { - // todo might not emit as servers simply don't send chunk if it's empty - if (!viewer.world.allChunksFinished || worldWasReady) return - worldWasReady = true - console.log('All chunks done and ready! Time from renderer open to ready', (Date.now() - start) / 1000, 's') - viewer.render() // ensure the last state is rendered + const waitForChunksToLoad = async (progress?: ProgressReporter) => { + await new Promise(resolve => { + const unsub = subscribe(appViewer.rendererState, () => { + if (worldWasReady) return + if (appViewer.rendererState.world.allChunksLoaded) { + worldWasReady = true + resolve() + unsub() + } else { + const perc = Math.round(appViewer.rendererState.world.chunksLoaded.length / appViewer.rendererState.world.chunksTotalNumber * 100) + progress?.reportProgress('chunks', perc / 100) + } + }) + }) + } + + void waitForChunksToLoad().then(() => { + console.log('All chunks done and ready! Time from renderer connect to ready', (Date.now() - loadStart) / 1000, 's') document.dispatchEvent(new Event('cypress-world-ready')) }) const spawnEarlier = !singleplayer && !p2pMultiplayer - // don't use spawn event, player can be dead - bot.once(spawnEarlier ? 'forcedMove' : 'health', async () => { + const displayWorld = async () => { if (resourcePackState.isServerInstalling) { await new Promise(resolve => { subscribe(resourcePackState, () => { @@ -772,113 +703,98 @@ export async function connect (connectOptions: ConnectOptions) { }) }) } + console.log('try to focus window') window.focus?.() errorAbortController.abort() - - if (p2pConnectTimeout) clearTimeout(p2pConnectTimeout) - playerState.onlineMode = !!connectOptions.authenticatedAccount - - setLoadingScreenStatus('Placing blocks (starting viewer)') - if (!connectOptions.worldStateFileContents || connectOptions.worldStateFileContents.length < 3 * 1024 * 1024) { - localStorage.lastConnectOptions = JSON.stringify(connectOptions) - if (process.env.NODE_ENV === 'development' && !localStorage.lockUrl && !Object.keys(window.debugQueryParams).length) { - lockUrl() - } - } else { - localStorage.removeItem('lastConnectOptions') - } - connectOptions.onSuccessfulPlay?.() - updateDataAfterJoin() - if (connectOptions.autoLoginPassword) { - bot.chat(`/login ${connectOptions.autoLoginPassword}`) - } - - console.log('bot spawned - starting viewer') - - const center = bot.entity.position - - const worldView = window.worldView = new WorldDataEmitter(bot.world, renderDistance, center) - watchOptionsAfterWorldViewInit() - - void initVR() - initMotionTracking() - - renderWrapper.postRender = () => { - viewer.setFirstPersonCamera(null, bot.entity.yaw, bot.entity.pitch) - } - - // Link WorldDataEmitter and Viewer - viewer.connect(worldView) - worldView.listenToBot(bot) - void worldView.init(bot.entity.position) - - dayCycle() - - // Bot position callback - function botPosition () { - viewer.world.lastCamUpdate = Date.now() - // this might cause lag, but not sure - viewer.setFirstPersonCamera(bot.entity.position, bot.entity.yaw, bot.entity.pitch) - void worldView.updatePosition(bot.entity.position) - } - bot.on('move', botPosition) - botPosition() - - setLoadingScreenStatus('Setting callbacks') - - onGameLoad(() => {}) - if (appStatusState.isError) return - const waitForChunks = async () => { - if (appQueryParams.sp === '1') return //todo - const waitForChunks = options.waitForChunksRender === 'sp-only' ? !!singleplayer : options.waitForChunksRender - if (viewer.world.allChunksFinished || !waitForChunks) { - return + try { + if (p2pConnectTimeout) clearTimeout(p2pConnectTimeout) + playerState.onlineMode = !!connectOptions.authenticatedAccount + + progress.setMessage('Placing blocks (starting viewer)') + if (!connectOptions.worldStateFileContents || connectOptions.worldStateFileContents.length < 3 * 1024 * 1024) { + localStorage.lastConnectOptions = JSON.stringify(connectOptions) + if (process.env.NODE_ENV === 'development' && !localStorage.lockUrl && !Object.keys(window.debugQueryParams).length) { + lockUrl() + } + } else { + localStorage.removeItem('lastConnectOptions') + } + connectOptions.onSuccessfulPlay?.() + updateDataAfterJoin() + if (connectOptions.autoLoginPassword) { + setTimeout(() => { + bot.chat(`/login ${connectOptions.autoLoginPassword}`) + }, 500) } - await progress.executeWithMessage( - 'Loading chunks', - 'chunks', - async () => { - await new Promise(resolve => { - let wasFinished = false - void viewer.world.renderUpdateEmitter.on('update', () => { - if (wasFinished) return - if (viewer.world.allChunksFinished) { - wasFinished = true - resolve() - } else { - const perc = Math.round(Object.keys(viewer.world.finishedChunks).length / viewer.world.chunksLength * 100) - progress.reportProgress('chunks', perc / 100) - } - }) + + console.log('bot spawned - starting viewer') + appViewer.startWorld(bot.world, renderDistance) + appViewer.worldView!.listenToBot(bot) + + initMotionTracking() + dayCycle() + + // Bot position callback + const botPosition = () => { + appViewer.lastCamUpdate = Date.now() + // this might cause lag, but not sure + appViewer.backend?.updateCamera(bot.entity.position, bot.entity.yaw, bot.entity.pitch) + void appViewer.worldView?.updatePosition(bot.entity.position) + } + bot.on('move', botPosition) + botPosition() + + progress.setMessage('Setting callbacks') + + onGameLoad() + + if (appStatusState.isError) return + + const waitForChunks = async () => { + if (appQueryParams.sp === '1') return //todo + const waitForChunks = options.waitForChunksRender === 'sp-only' ? !!singleplayer : options.waitForChunksRender + if (!appViewer.backend || appViewer.rendererState.world.allChunksLoaded || !waitForChunks) { + return + } + + await progress.executeWithMessage( + 'Loading chunks', + 'chunks', + async () => { + await waitForChunksToLoad(progress) + } + ) + } + + await waitForChunks() + + setTimeout(() => { + if (appQueryParams.suggest_save) { + showNotification('Suggestion', 'Save the world to keep your progress!', false, undefined, async () => { + const savePath = await saveToBrowserMemory() + if (!savePath) return + const saveName = savePath.split('/').pop() + bot.end() + // todo hot reload + location.search = `loadSave=${saveName}` }) } - ) + }, 600) + + miscUiState.gameLoaded = true + miscUiState.loadedServerIndex = connectOptions.serverIndex ?? '' + customEvents.emit('gameLoaded') + progress.end() + setLoadingScreenStatus(undefined) + } catch (err) { + handleError(err) } - - await waitForChunks() - - setTimeout(() => { - if (appQueryParams.suggest_save) { - showNotification('Suggestion', 'Save the world to keep your progress!', false, undefined, async () => { - const savePath = await saveToBrowserMemory() - if (!savePath) return - const saveName = savePath.split('/').pop() - bot.end() - // todo hot reload - location.search = `loadSave=${saveName}` - }) - } - }, 600) - - miscUiState.gameLoaded = true - miscUiState.loadedServerIndex = connectOptions.serverIndex ?? '' - customEvents.emit('gameLoaded') - progress.end() - setLoadingScreenStatus(undefined) - }) + } + // don't use spawn event, player can be dead + bot.once(spawnEarlier ? 'forcedMove' : 'health', displayWorld) if (singleplayer && connectOptions.serverOverrides.worldFolder) { fsState.saveLoaded = true diff --git a/src/inventoryWindows.ts b/src/inventoryWindows.ts index 303e91be..05e371ef 100644 --- a/src/inventoryWindows.ts +++ b/src/inventoryWindows.ts @@ -6,7 +6,7 @@ import { RecipeItem } from 'minecraft-data' import { flat, fromFormattedString } from '@xmcl/text-component' import { splitEvery, equals } from 'rambda' import PItem, { Item } from 'prismarine-item' -import { versionToNumber } from 'renderer/viewer/prepare/utils' +import { versionToNumber } from 'renderer/viewer/common/utils' import { getRenamedData } from 'flying-squid/dist/blockRenames' import PrismarineChatLoader from 'prismarine-chat' import { BlockModel } from 'mc-assets' @@ -20,8 +20,7 @@ import { displayClientChat } from './botUtils' import { currentScaling } from './scaleInterface' import { getItemDescription } from './itemsDescriptions' import { MessageFormatPart } from './chatUtils' -import { GeneralInputItem, getItemMetadata, getItemNameRaw, RenderItem } from './mineflayer/items' -import { getItemModelName } from './resourcesManager' +import { GeneralInputItem, getItemMetadata, getItemModelName, getItemNameRaw, RenderItem } from './mineflayer/items' const loadedImagesCache = new Map() const cleanLoadedImagesCache = () => { @@ -29,35 +28,18 @@ const cleanLoadedImagesCache = () => { } let lastWindow: ReturnType +let lastWindowType: string | null | undefined // null is inventory /** bot version */ let version: string let PrismarineItem: typeof Item -export const allImagesLoadedState = proxy({ - value: false -}) - export const jeiCustomCategories = proxy({ value: [] as Array<{ id: string, categoryTitle: string, items: any[] }> }) -export const onGameLoad = (onLoad) => { - allImagesLoadedState.value = false +export const onGameLoad = () => { version = bot.version - const checkIfLoaded = () => { - if (!viewer.world.itemsAtlasParser) return - if (!allImagesLoadedState.value) { - onLoad?.() - } - allImagesLoadedState.value = false - setTimeout(() => { - allImagesLoadedState.value = true - }, 0) - } - viewer.world.renderUpdateEmitter.on('textureDownloaded', checkIfLoaded) - checkIfLoaded() - PrismarineItem = PItem(version) const mapWindowType = (type: string, inventoryStart: number) => { @@ -134,14 +116,23 @@ export const onGameLoad = (onLoad) => { if (!lastWindow) return upJei(q) }) + + if (!appViewer.resourcesManager['_inventoryChangeTracked']) { + appViewer.resourcesManager['_inventoryChangeTracked'] = true + const upWindowItems = () => { + if (!lastWindow) return + upWindowItemsLocal() + } + appViewer.resourcesManager.on('assetsInventoryReady', () => upWindowItems()) + appViewer.resourcesManager.on('assetsTexturesUpdated', () => upWindowItems()) + } } const getImageSrc = (path): string | HTMLImageElement => { - assertDefined(viewer) switch (path) { case 'gui/container/inventory': return appReplacableResources.latest_gui_container_inventory.content - case 'blocks': return viewer.world.blocksAtlasParser!.latestImage - case 'items': return viewer.world.itemsAtlasParser!.latestImage + case 'blocks': return appViewer.resourcesManager.currentResources!.blocksAtlasParser.latestImage + case 'items': return appViewer.resourcesManager.currentResources!.itemsAtlasParser.latestImage case 'gui/container/dispenser': return appReplacableResources.latest_gui_container_dispenser.content case 'gui/container/furnace': return appReplacableResources.latest_gui_container_furnace.content case 'gui/container/crafting_table': return appReplacableResources.latest_gui_container_crafting_table.content @@ -209,7 +200,7 @@ export const renderSlot = (model: ResolvedItemModelRender, debugIsQuickbar = fal if (!fullBlockModelSupport) { const atlas = activeGuiAtlas.atlas?.json // todo atlas holds all rendered blocks, not all possibly rendered item/block models, need to request this on demand instead (this is how vanilla works) - const item = atlas?.textures[itemModelName.replace('minecraft:', '').replace('block/', '').replace('blocks/', '').replace('item/', '').replace('items/', '').replace('_inventory', '').replace('_bottom', '')] + const item = atlas?.textures[itemModelName.replace('minecraft:', '').replace('block/', '').replace('blocks/', '').replace('item/', '').replace('items/', '').replace('_inventory', '').replace('_bottom', '').replace('_height2', '').replace('_stable', '').replace('_unstable', '')] if (item) { const x = item.u * atlas.width const y = item.v * atlas.height @@ -223,13 +214,13 @@ export const renderSlot = (model: ResolvedItemModelRender, debugIsQuickbar = fal } try { - assertDefined(viewer.world.itemsRenderer) + assertDefined(appViewer.resourcesManager.currentResources?.itemsRenderer) itemTexture = - viewer.world.itemsRenderer.getItemTexture(itemModelName, {}, false, fullBlockModelSupport) - ?? viewer.world.itemsRenderer.getItemTexture('item/missing_texture')! + appViewer.resourcesManager.currentResources.itemsRenderer.getItemTexture(itemModelName, {}, false, fullBlockModelSupport) + ?? appViewer.resourcesManager.currentResources.itemsRenderer.getItemTexture('item/missing_texture')! } catch (err) { inGameError(`Failed to render item ${itemModelName} (original: ${originalItemName}) on ${bot.version} (resourcepack: ${options.enabledResourcepack}): ${err.stack}`) - itemTexture = viewer.world.itemsRenderer!.getItemTexture('block/errored')! + itemTexture = appViewer.resourcesManager.currentResources!.itemsRenderer.getItemTexture('block/errored')! } @@ -251,7 +242,7 @@ export const renderSlot = (model: ResolvedItemModelRender, debugIsQuickbar = fal } const getItemName = (slot: Item | RenderItem | null) => { - const parsed = getItemNameRaw(slot) + const parsed = getItemNameRaw(slot, appViewer.resourcesManager) if (!parsed) return // todo display full text renderer from sign renderer const text = flat(parsed as MessageFormatPart).map(x => x.text) @@ -261,7 +252,7 @@ const getItemName = (slot: Item | RenderItem | null) => { let lastMappedSots = [] as any[] const itemToVisualKey = (slot: RenderItem | Item | null) => { if (!slot) return null - return slot.name + (slot['metadata'] ?? '-') + (slot.nbt ? JSON.stringify(slot.nbt) : '') + (slot['components'] ? JSON.stringify(slot['components']) : '') + return slot.name + slot['count'] + (slot['metadata'] ?? '-') + (slot.nbt ? JSON.stringify(slot.nbt) : '') + (slot['components'] ? JSON.stringify(slot['components']) : '') } const mapSlots = (slots: Array, isJei = false) => { const newSlots = slots.map((slot, i) => { @@ -278,7 +269,7 @@ const mapSlots = (slots: Array, isJei = false) => { try { if (slot.durabilityUsed && slot.maxDurability) slot.durabilityUsed = Math.min(slot.durabilityUsed, slot.maxDurability) const debugIsQuickbar = !isJei && i === bot.inventory.hotbarStart + bot.quickBarSlot - const modelName = getItemModelName(slot, { 'minecraft:display_context': 'gui', }) + const modelName = getItemModelName(slot, { 'minecraft:display_context': 'gui', }, appViewer.resourcesManager) const slotCustomProps = renderSlot({ modelName }, debugIsQuickbar) const itemCustomName = getItemName(slot) Object.assign(slot, { ...slotCustomProps, displayName: itemCustomName ?? slot.displayName }) @@ -385,6 +376,15 @@ export const openItemsCanvas = (type, _bot = bot as typeof bot | null) => { return inv } +const upWindowItemsLocal = () => { + if (!lastWindow && bot.currentWindow) { + // edge case: might happen due to high ping, inventory should be closed soon! + // openWindow(implementedContainersGuiMap[bot.currentWindow.type]) + return + } + void Promise.resolve().then(() => upInventoryItems(lastWindowType === null)) +} + let skipClosePacketSending = false const openWindow = (type: string | undefined) => { // if (activeModalStack.some(x => x.reactType?.includes?.('player_win:'))) { @@ -397,6 +397,7 @@ const openWindow = (type: string | undefined) => { return } } + lastWindowType = type ?? null showModal({ reactType: `player_win:${type}`, }) @@ -405,6 +406,7 @@ const openWindow = (type: string | undefined) => { if (type !== undefined && bot.currentWindow && !skipClosePacketSending) bot.currentWindow['close']() lastWindow.destroy() lastWindow = null as any + lastWindowType = null window.lastWindow = lastWindow miscUiState.displaySearchInput = false destroyFn() @@ -442,15 +444,8 @@ const openWindow = (type: string | undefined) => { } lastWindow = inv - const upWindowItems = () => { - if (!lastWindow && bot.currentWindow) { - // edge case: might happen due to high ping, inventory should be closed soon! - // openWindow(implementedContainersGuiMap[bot.currentWindow.type]) - return - } - void Promise.resolve().then(() => upInventoryItems(type === undefined)) - } - upWindowItems() + + upWindowItemsLocal() lastWindow.pwindow.touch = miscUiState.currentTouch ?? false const oldOnInventoryEvent = lastWindow.pwindow.onInventoryEvent.bind(lastWindow.pwindow) @@ -512,14 +507,14 @@ const openWindow = (type: string | undefined) => { if (type === undefined) { // player inventory - bot.inventory.on('updateSlot', upWindowItems) + bot.inventory.on('updateSlot', upWindowItemsLocal) destroyFn = () => { - bot.inventory.off('updateSlot', upWindowItems) + bot.inventory.off('updateSlot', upWindowItemsLocal) } } else { //@ts-expect-error bot.currentWindow.on('updateSlot', () => { - upWindowItems() + upWindowItemsLocal() }) } } diff --git a/src/loadSave.ts b/src/loadSave.ts index 861cc212..4746a95c 100644 --- a/src/loadSave.ts +++ b/src/loadSave.ts @@ -3,7 +3,7 @@ import path from 'path' import * as nbt from 'prismarine-nbt' import { proxy } from 'valtio' import { gzip } from 'node-gzip' -import { versionToNumber } from 'renderer/viewer/prepare/utils' +import { versionToNumber } from 'renderer/viewer/common/utils' import { options } from './optionsStorage' import { nameToMcOfflineUUID, disconnect } from './flyingSquidUtils' import { existsViaStats, forceCachedDataPaths, forceRedirectPaths, mkdirRecursive } from './browserfs' diff --git a/src/markdownToFormattedText.ts b/src/markdownToFormattedText.ts index 0f722bd1..5fff20f8 100644 --- a/src/markdownToFormattedText.ts +++ b/src/markdownToFormattedText.ts @@ -2,7 +2,7 @@ import { remark } from 'remark' export default (markdown: string) => { const arr = markdown.split('\n\n') - const lines = ['', '', '', ''] + const lines = ['', '', '', ''] as any[] for (const [i, ast] of arr.map(md => remark().parse(md)).entries()) { lines[i] = transformToMinecraftJSON(ast as Element) } diff --git a/src/mineflayer/cameraShake.ts b/src/mineflayer/cameraShake.ts index b2c7f273..9e271da5 100644 --- a/src/mineflayer/cameraShake.ts +++ b/src/mineflayer/cameraShake.ts @@ -1,92 +1,8 @@ -import * as THREE from 'three' - -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 } - - constructor () { - this.rollAngle = 0 - } - - 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 - } - } - } - } - - // Apply roll in camera's local space to maintain consistent left/right roll - const { camera } = viewer - const rollQuat = new THREE.Quaternion() - rollQuat.setFromAxisAngle(new THREE.Vector3(0, 0, 1), THREE.MathUtils.degToRad(this.rollAngle)) - - // Get camera's current rotation - const camQuat = new THREE.Quaternion() - camera.getWorldQuaternion(camQuat) - - // Apply roll after camera rotation - const finalQuat = camQuat.multiply(rollQuat) - 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 - } -} - -let cameraShake: CameraShake +import { getThreeJsRendererMethods } from 'renderer/viewer/three/threeJsMethods' customEvents.on('mineflayerBotCreated', () => { - if (!cameraShake) { - cameraShake = new CameraShake() - beforeRenderFrame.push(() => { - cameraShake.update() - }) - } - customEvents.on('hurtAnimation', (yaw) => { - cameraShake.shakeFromDamage(yaw) + getThreeJsRendererMethods()?.shakeFromDamage() }) bot._client.on('hurt_animation', ({ entityId, yaw }) => { diff --git a/src/mineflayer/items.ts b/src/mineflayer/items.ts index 3c2134f1..45638cd4 100644 --- a/src/mineflayer/items.ts +++ b/src/mineflayer/items.ts @@ -1,7 +1,11 @@ import mojangson from 'mojangson' import nbt from 'prismarine-nbt' import { fromFormattedString } from '@xmcl/text-component' +import { ItemSpecificContextProperties } from 'renderer/viewer/lib/basePlayerState' +import { getItemDefinition } from 'mc-assets/dist/itemDefinitions' import { MessageFormatPart } from '../chatUtils' +import { ResourcesManager } from '../resourcesManager' +import { playerState } from './playerState' type RenderSlotComponent = { type: string, @@ -24,13 +28,21 @@ export type GeneralInputItem = Pick { +export const getItemMetadata = (item: GeneralInputItem, resourcesManager: ResourcesManager) => { let customText = undefined as string | any | undefined let customModel = undefined as string | undefined + + let itemId = item.name + if (!itemId.includes(':')) { + itemId = `minecraft:${itemId}` + } + const customModelDataDefinitions = resourcesManager.currentResources?.customItemModelNames[itemId] + if (item.components) { const componentMap = new Map() for (const component of item.components) { @@ -45,6 +57,15 @@ export const getItemMetadata = (item: GeneralInputItem) => { if (customModelComponent) { customModel = customModelComponent.data } + if (customModelDataDefinitions) { + const customModelDataComponent: any = componentMap.get('custom_model_data') + if (customModelDataComponent?.data && typeof customModelDataComponent.data === 'number') { + const customModelData = customModelDataComponent.data + if (customModelDataDefinitions[customModelData]) { + customModel = customModelDataDefinitions[customModelData] + } + } + } const loreComponent = componentMap.get('lore') if (loreComponent) { customText ??= item.displayName ?? item.name @@ -58,6 +79,9 @@ export const getItemMetadata = (item: GeneralInputItem) => { if (customName) { customText = customName } + if (customModelDataDefinitions && itemNbt.CustomModelData && customModelDataDefinitions[itemNbt.CustomModelData]) { + customModel = customModelDataDefinitions[itemNbt.CustomModelData] + } } return { @@ -67,8 +91,8 @@ export const getItemMetadata = (item: GeneralInputItem) => { } -export const getItemNameRaw = (item: Pick | null) => { - const { customText } = getItemMetadata(item as any) +export const getItemNameRaw = (item: Pick | null, resourcesManager: ResourcesManager) => { + const { customText } = getItemMetadata(item as any, resourcesManager) if (!customText) return try { if (typeof customText === 'object') { @@ -86,3 +110,22 @@ export const getItemNameRaw = (item: Pick } } } + +export const getItemModelName = (item: GeneralInputItem, specificProps: ItemSpecificContextProperties, resourcesManager: ResourcesManager) => { + let itemModelName = item.name + const { customModel } = getItemMetadata(item, resourcesManager) + if (customModel) { + itemModelName = customModel + } + + const itemSelector = playerState.getItemSelector({ + ...specificProps + }) + const modelFromDef = getItemDefinition(appViewer.resourcesManager.itemsDefinitionsStore, { + name: itemModelName, + version: appViewer.resourcesManager.currentResources!.version, + properties: itemSelector + })?.model + const model = (modelFromDef === 'minecraft:special' ? undefined : modelFromDef) ?? itemModelName + return model +} diff --git a/src/mineflayer/maps.ts b/src/mineflayer/maps.ts index c5d4f716..5e968205 100644 --- a/src/mineflayer/maps.ts +++ b/src/mineflayer/maps.ts @@ -1,5 +1,6 @@ import { mapDownloader } from 'mineflayer-item-map-downloader' import { setImageConverter } from 'mineflayer-item-map-downloader/lib/util' +import { getThreeJsRendererMethods } from 'renderer/viewer/three/threeJsMethods' setImageConverter((buf: Uint8Array) => { const canvas = document.createElement('canvas') @@ -17,7 +18,7 @@ customEvents.on('mineflayerBotCreated', () => { bot.on('login', () => { bot.loadPlugin(mapDownloader) bot.mapDownloader.on('new_map', ({ png, id }) => { - viewer.entities.updateMap(id, png) + getThreeJsRendererMethods()?.updateMap(id, png) }) }) }) diff --git a/src/mineflayer/playerState.ts b/src/mineflayer/playerState.ts index 260f8e53..964e3d1b 100644 --- a/src/mineflayer/playerState.ts +++ b/src/mineflayer/playerState.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'events' import { Vec3 } from 'vec3' -import { IPlayerState, ItemSpecificContextProperties, MovementState, PlayerStateEvents } from 'renderer/viewer/lib/basePlayerState' -import { HandItemBlock } from 'renderer/viewer/lib/holdingBlock' +import { BasePlayerState, IPlayerState, ItemSpecificContextProperties, MovementState, PlayerStateEvents } from 'renderer/viewer/lib/basePlayerState' +import { HandItemBlock } from 'renderer/viewer/three/holdingBlock' import TypedEmitter from 'typed-emitter' import { ItemSelector } from 'mc-assets/dist/itemDefinitions' import { proxy } from 'valtio' @@ -29,9 +29,7 @@ export class PlayerStateManager implements IPlayerState { return bot.player?.username ?? '' } - reactive = proxy({ - playerSkin: undefined as string | undefined, - }) + reactive: IPlayerState['reactive'] = new BasePlayerState().reactive static getInstance (): PlayerStateManager { if (!this.instance) { @@ -40,7 +38,7 @@ export class PlayerStateManager implements IPlayerState { return this.instance } - private constructor () { + constructor () { this.updateState = this.updateState.bind(this) customEvents.on('mineflayerBotCreated', () => { this.ready = false @@ -70,6 +68,11 @@ export class PlayerStateManager implements IPlayerState { // Initial held items setup this.updateHeldItem(false) this.updateHeldItem(true) + + bot.on('game', () => { + this.reactive.gameMode = bot.game.gameMode + }) + this.reactive.gameMode = bot.game?.gameMode } // #region Movement and Physics State @@ -133,6 +136,10 @@ export class PlayerStateManager implements IPlayerState { isSprinting (): boolean { return gameAdditionalState.isSprinting } + + getPosition (): Vec3 { + return bot.player?.entity.position ?? new Vec3(0, 0, 0) + } // #endregion // #region Held Item State diff --git a/src/mineflayer/plugins/mouse.ts b/src/mineflayer/plugins/mouse.ts index e5b5e283..219a8be8 100644 --- a/src/mineflayer/plugins/mouse.ts +++ b/src/mineflayer/plugins/mouse.ts @@ -1,141 +1,30 @@ import { createMouse } from 'mineflayer-mouse' -import * as THREE from 'three' import { Bot } from 'mineflayer' import { Block } from 'prismarine-block' -import { Vec3 } from 'vec3' -import { LineMaterial } from 'three-stdlib' -import { subscribeKey } from 'valtio/utils' -import { disposeObject } from 'renderer/viewer/lib/threeJsUtils' +import { getThreeJsRendererMethods } from 'renderer/viewer/three/threeJsMethods' import { isGameActive, showModal } from '../../globalState' -// wouldn't better to create atlas instead? -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' -import { options } from '../../optionsStorage' import { isCypress } from '../../standaloneUtils' import { playerState } from '../playerState' +import { sendVideoInteraction, videoCursorInteraction } from '../../customChannels' -function createDisplayManager (bot: Bot, scene: THREE.Scene, renderer: THREE.WebGLRenderer) { - // State - const state = { - blockBreakMesh: null as THREE.Mesh | null, - breakTextures: [] as THREE.Texture[], - } - - // 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 - state.breakTextures.push(texture) - } - - const breakMaterial = new THREE.MeshBasicMaterial({ - transparent: true, - blending: THREE.MultiplyBlending, - alphaTest: 0.5, - }) - state.blockBreakMesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), breakMaterial) - state.blockBreakMesh.visible = false - state.blockBreakMesh.renderOrder = 999 - state.blockBreakMesh.name = 'blockBreakMesh' - scene.add(state.blockBreakMesh) - - // Update functions - function updateLineMaterial () { - const inCreative = bot.game.gameMode === 'creative' - const pixelRatio = viewer.renderer.getPixelRatio() - - viewer.world.threejsCursorLineMaterial = new LineMaterial({ - color: (() => { - switch (options.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, - }) - } - - function updateDisplay () { - if (viewer.world.threejsCursorLineMaterial) { - const { renderer } = viewer - viewer.world.threejsCursorLineMaterial.resolution.set(renderer.domElement.width, renderer.domElement.height) - viewer.world.threejsCursorLineMaterial.dashOffset = performance.now() / 750 - } - } - beforeRenderFrame.push(updateDisplay) - - // Update cursor line material on game mode change - bot.on('game', updateLineMaterial) - // Update material when highlight color setting changes - subscribeKey(options, 'highlightBlockColor', updateLineMaterial) - - function updateBreakAnimation (block: Block | undefined, stage: number | null) { - hideBreakAnimation() - if (!state.blockBreakMesh) return // todo - if (stage === null || !block) return - - const mergedShape = bot.mouse.getMergedCursorShape(block) - if (!mergedShape) return - const { position, width, height, depth } = bot.mouse.getDataFromShape(mergedShape) - state.blockBreakMesh.scale.set(width * 1.001, height * 1.001, depth * 1.001) - position.add(block.position) - state.blockBreakMesh.position.set(position.x, position.y, position.z) - state.blockBreakMesh.visible = true - - //@ts-expect-error - state.blockBreakMesh.material.map = state.breakTextures[stage] ?? state.breakTextures.at(-1) - //@ts-expect-error - state.blockBreakMesh.material.needsUpdate = true - } - - function hideBreakAnimation () { - if (state.blockBreakMesh) { - state.blockBreakMesh.visible = false - } - } - - function updateCursorBlock (data?: { block: Block }) { +function cursorBlockDisplay (bot: Bot) { + const updateCursorBlock = (data?: { block: Block }) => { if (!data?.block) { - viewer.world.setHighlightCursorBlock(null) + getThreeJsRendererMethods()?.setHighlightCursorBlock(null) return } const { block } = data - viewer.world.setHighlightCursorBlock(block.position, bot.mouse.getBlockCursorShapes(block).map(shape => { + getThreeJsRendererMethods()?.setHighlightCursorBlock(block.position, bot.mouse.getBlockCursorShapes(block).map(shape => { return bot.mouse.getDataFromShape(shape) })) } bot.on('highlightCursorBlock', updateCursorBlock) - bot.on('blockBreakProgressStage', updateBreakAnimation) - - bot.on('end', () => { - disposeObject(state.blockBreakMesh!, true) - scene.remove(state.blockBreakMesh!) - viewer.world.setHighlightCursorBlock(null) + bot.on('blockBreakProgressStage', (block, stage) => { + getThreeJsRendererMethods()?.updateBreakAnimation(block, stage) }) } @@ -143,7 +32,7 @@ export default (bot: Bot) => { bot.loadPlugin(createMouse({})) domListeners(bot) - createDisplayManager(bot, viewer.scene, viewer.renderer) + cursorBlockDisplay(bot) otherListeners() } @@ -158,11 +47,11 @@ const otherListeners = () => { }) bot.on('botArmSwingStart', (hand) => { - viewer.world.changeHandSwingingState(true, hand === 'left') + getThreeJsRendererMethods()?.changeHandSwingingState(true, hand === 'left') }) bot.on('botArmSwingEnd', (hand) => { - viewer.world.changeHandSwingingState(false, hand === 'left') + getThreeJsRendererMethods()?.changeHandSwingingState(false, hand === 'left') }) bot.on('startUsingItem', (item, slot, isOffhand, duration) => { @@ -176,16 +65,25 @@ const otherListeners = () => { } const domListeners = (bot: Bot) => { + const abortController = new AbortController() document.addEventListener('mousedown', (e) => { if (e.isTrusted && !document.pointerLockElement && !isCypress()) return if (!isGameActive(true)) return + getThreeJsRendererMethods()?.onPageInteraction() + + const videoInteraction = videoCursorInteraction() + if (videoInteraction) { + sendVideoInteraction(videoInteraction.id, videoInteraction.x, videoInteraction.y, e.button === 0) + return + } + if (e.button === 0) { bot.leftClickStart() } else if (e.button === 2) { bot.rightClickStart() } - }) + }, { signal: abortController.signal }) document.addEventListener('mouseup', (e) => { if (e.button === 0) { @@ -193,7 +91,7 @@ const domListeners = (bot: Bot) => { } else if (e.button === 2) { bot.rightClickEnd() } - }) + }, { signal: abortController.signal }) bot.mouse.beforeUpdateChecks = () => { if (!document.hasFocus()) { @@ -201,4 +99,8 @@ const domListeners = (bot: Bot) => { bot.mouse.buttons.fill(false) } } + + bot.on('end', () => { + abortController.abort() + }) } diff --git a/src/mineflayer/plugins/ping.ts b/src/mineflayer/plugins/ping.ts index 47af8986..9753e4ed 100644 --- a/src/mineflayer/plugins/ping.ts +++ b/src/mineflayer/plugins/ping.ts @@ -1,4 +1,4 @@ -import { versionToNumber } from 'renderer/viewer/prepare/utils' +import { versionToNumber } from 'renderer/viewer/common/utils' export default () => { let i = 0 diff --git a/src/mineflayer/timers.ts b/src/mineflayer/timers.ts new file mode 100644 index 00000000..698b624e --- /dev/null +++ b/src/mineflayer/timers.ts @@ -0,0 +1,9 @@ +import { preventThrottlingWithSound } from '../core/timers' +import { options } from '../optionsStorage' + +customEvents.on('mineflayerBotCreated', () => { + if (options.preventBackgroundTimeoutKick) { + const unsub = preventThrottlingWithSound() + bot.on('end', unsub) + } +}) diff --git a/src/optimizeJson.ts b/src/optimizeJson.ts index 8c23dac0..a7fe7d4e 100644 --- a/src/optimizeJson.ts +++ b/src/optimizeJson.ts @@ -1,4 +1,4 @@ -import { versionToNumber } from 'renderer/viewer/prepare/utils' +import { versionToNumber } from 'renderer/viewer/common/utils' type IdMap = Record diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index ac0503ca..a247350b 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -11,7 +11,7 @@ import Slider from './react/Slider' import { getScreenRefreshRate } from './utils' import { setLoadingScreenStatus } from './appStatus' import { openFilePicker, resetLocalStorage } from './browserfs' -import { completeResourcepackPackInstall, getResourcePackNames, resourcePackState, uninstallResourcePack } from './resourcePack' +import { completeResourcepackPackInstall, getResourcePackNames, resourcepackReload, resourcePackState, uninstallResourcePack } from './resourcePack' import { downloadPacketsReplay, packetsRecordingState } from './packetsReplay/packetsReplayLegacy' import { showInputsModal, showOptionsModal } from './react/SelectOption' import supportedVersions from './supportedVersions.mjs' @@ -106,6 +106,18 @@ export const guiOptionsScheme: { ], }, }, + { + custom () { + const { _renderByChunks } = useSnapshot(options).rendererOptions.three + return - {!lockConnect && backAction &&