Compare commits
412 commits
any-server
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
253e094c74 | ||
|
|
fef94f03fb | ||
|
|
e9f91f8ecd | ||
|
|
634df8d03d |
||
|
|
a88c8b5470 | ||
|
|
f51254d97a | ||
|
|
05cd560d6b | ||
|
|
b239636356 | ||
|
|
4f421ae45f | ||
|
|
3b94889bed |
||
|
|
636a7fdb54 |
||
|
|
c930365e32 | ||
|
|
852dd737ae | ||
|
|
06dc3cb033 | ||
|
|
c4097975bf | ||
|
|
1525fac2a1 | ||
|
|
f24cb49a87 | ||
|
|
0b1183f541 | ||
|
|
739a6fad24 | ||
|
|
7f7a14ac65 | ||
|
|
265d02d18d | ||
|
|
b2e36840b9 |
||
|
|
7043bf49f3 |
||
|
|
528d8f516b |
||
|
|
70534d8b5a |
||
|
|
9d54c70fb7 | ||
|
|
7e3ba8bece | ||
|
|
513201be87 | ||
|
|
cb82188272 | ||
|
|
d0d5234ba4 | ||
|
|
e81d608554 | ||
|
|
1f240d8c20 | ||
|
|
2a1746eb7a | ||
|
|
9718610131 | ||
|
|
8f62fbd4da | ||
|
|
bc2972fe99 | ||
|
|
a12c61bc6c |
||
|
|
6e0d54ea17 | ||
|
|
72e9e656cc | ||
|
|
4a5f2e799c | ||
|
|
a8fa3d47d1 | ||
|
|
9a84a7acfb | ||
|
|
d6eb1601e9 | ||
|
|
e1293b6cb3 | ||
|
|
cc4f705aea | ||
|
|
54c114a702 | ||
|
|
65575e2665 | ||
|
|
1ddaa79162 |
||
|
|
e2b141cca0 | ||
|
|
15e3325971 | ||
|
|
60fc5ef315 | ||
|
|
8827aab981 | ||
|
|
0a474e6780 | ||
|
|
cdd8c31a0e | ||
|
|
e7c358d3fc | ||
|
|
fb395041b9 | ||
|
|
353ba2ecb3 | ||
|
|
53cbff7699 | ||
|
|
caf4695637 | ||
|
|
167b49da08 |
||
|
|
d7bd26b6b5 | ||
|
|
d41527edc8 | ||
|
|
24ab260e8e | ||
|
|
c4b284b9b7 | ||
|
|
67855ae25a |
||
|
|
b9c8ade9bf | ||
|
|
4d7e3df859 |
||
|
|
a498778703 | ||
|
|
b6d4728c44 | ||
|
|
0dca8bbbe5 | ||
|
|
de9bfba3a8 | ||
|
|
45408476a5 | ||
|
|
c360115f60 | ||
|
|
a8635e9e2f |
||
|
|
5bd33a546a |
||
|
|
e9c7840dae | ||
|
|
52c0c75ccf | ||
|
|
b2f2d85e4f | ||
|
|
7a83a2a657 | ||
|
|
64da602294 | ||
|
|
a09cd7d3ed |
||
|
|
39aca1735e |
||
|
|
e9320c68d2 | ||
|
|
95cc0e6c74 | ||
|
|
826b24d9e2 |
||
|
|
16609aa010 | ||
|
|
09b0e2e493 | ||
|
|
c844b99cf2 | ||
|
|
089f2224e2 | ||
|
|
2f93c08b1e | ||
|
|
fa56d479b1 | ||
|
|
f489c5f477 |
||
|
|
45bc76d825 | ||
|
|
01567ea589 | ||
|
|
e8b0a34c0b | ||
|
|
5cfd301d10 | ||
|
|
cdd23bc6a6 | ||
|
|
4277c3a262 |
||
|
|
9f3d3f93fb |
||
|
|
7162d2f549 | ||
|
|
fcf987efe4 | ||
|
|
d112b01177 | ||
|
|
043e28ed97 | ||
|
|
08fbc67c31 | ||
|
|
3bf34a8781 | ||
|
|
3cc862b05d | ||
|
|
c913d63c46 | ||
|
|
3320f65b9c | ||
|
|
ed7c33ff9f | ||
|
|
9086435aee | ||
|
|
8a50412395 | ||
|
|
71257bdf13 | ||
|
|
7aea07f83a | ||
|
|
3bcf0f533a | ||
|
|
b1298cbe1f |
||
|
|
661892af7c | ||
|
|
c55827db96 | ||
|
|
a2711dbe6c | ||
|
|
f79e54f11d | ||
|
|
6f5239e1d8 | ||
|
|
13e145cc3a | ||
|
|
d4ff7de64e | ||
|
|
1310109c01 | ||
|
|
dc2c5a2d88 | ||
|
|
31b91e5a33 |
||
|
|
f2a11d0a73 |
||
|
|
6eae7136ec | ||
|
|
34eecc166f | ||
|
|
fec887c28d | ||
|
|
369166e094 | ||
|
|
e161426caf | ||
|
|
0e4435ef91 | ||
|
|
3336680a0e | ||
|
|
83d783226f | ||
|
|
af5a0b2835 | ||
|
|
eedd9f1b8f | ||
|
|
0b1bc76327 | ||
|
|
b839bb8b9b | ||
|
|
3a7f267b5b | ||
|
|
2055579b72 | ||
|
|
1148378ce6 | ||
|
|
383e6c4d80 | ||
|
|
e9e144621f | ||
|
|
332bd4e0f3 |
||
|
|
32b19ab7af | ||
|
|
5221104980 | ||
|
|
7c8ccba2c1 | ||
|
|
fdeb78d96b | ||
|
|
5269ad21b5 |
||
|
|
f126f56844 | ||
|
|
1b20845ed5 | ||
|
|
f3ff4bef03 |
||
|
|
679c3775f7 | ||
|
|
8c71f70db2 | ||
|
|
9f3079b5f5 | ||
|
|
794cafb1f6 | ||
|
|
a3dcfed4d0 | ||
|
|
b69813435c | ||
|
|
1e513f87dd | ||
|
|
243db1dc45 | ||
|
|
ac7d28760f | ||
|
|
cfce898918 | ||
|
|
14effc7400 |
||
|
|
a562316cba | ||
|
|
6a583d2a36 | ||
|
|
5575933559 | ||
|
|
e982bf1493 | ||
|
|
1c93fd7f60 | ||
|
|
a2e9404a70 |
||
|
|
38a1d83cf2 | ||
|
|
314ddf7215 | ||
|
|
829e588ac1 | ||
|
|
8b2276a7ae |
||
|
|
7635375471 |
||
|
|
087e167826 |
||
|
|
c500d08ed7 | ||
|
|
50907138f7 | ||
|
|
99d05fc94b | ||
|
|
0c68e63ba6 | ||
|
|
04a85e9bd1 | ||
|
|
3cd778538c |
||
|
|
9726257577 | ||
|
|
5a663aac2f |
||
|
|
7cea1b8755 |
||
|
|
5ea2ab9c1a | ||
|
|
b36d08528f | ||
|
|
a4e70768dd | ||
|
|
ecb53fab88 | ||
|
|
b2ef71fc19 | ||
|
|
f4196d6aba |
||
|
|
4f78534ca4 |
||
|
|
7799ccc370 |
||
|
|
5efe3508df | ||
|
|
4d70128ac6 |
||
|
|
b4df2e1837 | ||
|
|
83366ec5fa | ||
|
|
b2a1bd10e4 | ||
|
|
90c283c5ee |
||
|
|
67dbd56f14 | ||
|
|
517f5d3501 | ||
|
|
7f6fc00f02 | ||
|
|
f5835f54fa | ||
|
|
970ed614ae | ||
|
|
080d75f939 | ||
|
|
67d365b9c3 | ||
|
|
e2a0df748e | ||
|
|
2dc811b2a1 | ||
|
|
a5d16a75ef | ||
|
|
785ab490f2 |
||
|
|
a9b94ec897 | ||
|
|
42f973e057 |
||
|
|
ff29fc1fc5 |
||
|
|
051cc5b35c | ||
|
|
f921275c87 | ||
|
|
2b0f178fe0 | ||
|
|
6302a3815f | ||
|
|
0a61bbba75 |
||
|
|
7ed3413b28 | ||
|
|
75adc29bf0 | ||
|
|
489c16793b | ||
|
|
48cdd9484f | ||
|
|
e2400ee667 |
||
|
|
a58ff0776e |
||
|
|
674b6ab00d | ||
|
|
25f2fdef4e | ||
|
|
f76c7fb782 | ||
|
|
aa817139b7 | ||
|
|
58799d973c | ||
|
|
b3392bea6b |
||
|
|
bf3381c803 |
||
|
|
bb9bb48efd |
||
|
|
28022a2054 | ||
|
|
1845530e22 |
||
|
|
b01cfe475d | ||
|
|
22483d7a76 | ||
|
|
e250061757 | ||
|
|
1fd9a29192 | ||
|
|
29c6a3d739 | ||
|
|
cd7c053a3c | ||
|
|
5bfb9bebd7 | ||
|
|
951790dad6 |
||
|
|
c5f72f2fb3 |
||
|
|
f12de4ea23 | ||
|
|
813c952420 | ||
|
|
ec142c0ce4 | ||
|
|
378b668d46 | ||
|
|
0d9cb0625e |
||
|
|
221f99ffdf | ||
|
|
4f1cb85301 | ||
|
|
5bf66b8e50 | ||
|
|
5caca68e8e | ||
|
|
95163fb288 | ||
|
|
0f2e4f1329 | ||
|
|
aa0024faa2 | ||
|
|
1599917134 | ||
|
|
e706f7d086 | ||
|
|
e20fb8be53 | ||
|
|
cd2ff62d6d | ||
|
|
fa36ed2678 | ||
|
|
305f4d8a31 | ||
|
|
86ef4f268e | ||
|
|
0c7900a655 | ||
|
|
8c37db4051 | ||
|
|
4ded3b5d2b | ||
|
|
db1b72a582 | ||
|
|
89fc31a2c2 | ||
|
|
01b6d87331 | ||
|
|
a654396238 | ||
|
|
948a52a2a5 | ||
|
|
d0ac00843d | ||
|
|
4ca9a801a8 | ||
|
|
510d163067 | ||
|
|
97533cfddb | ||
|
|
b30e7fc152 | ||
|
|
d7fdf18416 | ||
|
|
28faa9417a |
||
|
|
109daa2783 | ||
|
|
14ad1c5934 | ||
|
|
585b19d8dc | ||
|
|
71f63a3be0 | ||
|
|
2b881ea5ba | ||
|
|
a0bfa275af | ||
|
|
193c748feb | ||
|
|
c3112794c0 | ||
|
|
dbfd2b23f6 | ||
|
|
9646fbbc0f | ||
|
|
a7c35df959 | ||
|
|
529b465d32 | ||
|
|
1582e16d3b | ||
|
|
e8b1f190a7 | ||
|
|
73ccb48d02 | ||
|
|
143d4a3bb3 | ||
|
|
f5ed17d2fb | ||
|
|
6a8de1fdfb | ||
|
|
a541e82e04 | ||
|
|
c5e8fcb90c | ||
|
|
7a53d4de63 | ||
|
|
83502eba60 | ||
|
|
70557a6282 | ||
|
|
7e5a12934c | ||
|
|
4b85b16b73 | ||
|
|
27df313f26 | ||
|
|
77449c5c12 | ||
|
|
deb8ec6c0f | ||
|
|
024da5bf6d | ||
|
|
c755f085d9 |
||
|
|
3c6ee2dbb3 | ||
|
|
bf790861d9 | ||
|
|
a977d09031 |
||
|
|
1fbbf36859 |
||
|
|
31d5089e9c | ||
|
|
7824cf64a2 | ||
|
|
f4bd38fa5c | ||
|
|
5adbce39e0 | ||
|
|
0b72ea61c7 | ||
|
|
33a6f4d088 | ||
|
|
758405da03 | ||
|
|
4fc8011413 | ||
|
|
d347957f64 | ||
|
|
0a85de180e | ||
|
|
cc264e895f | ||
|
|
4dce591f8b | ||
|
|
b35b88236d | ||
|
|
f79472a1da | ||
|
|
d1a646ed54 | ||
|
|
914dcb6110 | ||
|
|
23bab8dbd5 | ||
|
|
881d105c57 | ||
|
|
3109be2d8a | ||
|
|
568ea3d18b | ||
|
|
27e51b65df | ||
|
|
0aa4d11bdd |
||
|
|
ce5ef7c7cb | ||
|
|
9b71ae1a24 | ||
|
|
908fa64f2f |
||
|
|
c025a1c75a | ||
|
|
04c37c1eef | ||
|
|
dbfadde044 | ||
|
|
d78a8b1220 | ||
|
|
70fbe1b0e2 | ||
|
|
394a12b147 | ||
|
|
f895304380 | ||
|
|
4f45cd072a | ||
|
|
5af290ac4e | ||
|
|
c5c9fd9bcd | ||
|
|
1b9b6c954c | ||
|
|
3c2ed440b6 | ||
|
|
9c6bc49921 | ||
|
|
1a87951bc8 | ||
|
|
73e65c6656 | ||
|
|
c324ce29ab | ||
|
|
b666f6e3c3 | ||
|
|
115022a21b | ||
|
|
18ee1dc532 | ||
|
|
291ead079a | ||
|
|
983b8a184b | ||
|
|
66fa59a87a | ||
|
|
47864f0023 |
||
|
|
6f15fcc726 | ||
|
|
af9da93978 | ||
|
|
08bb0b6777 | ||
|
|
187e9fa6b4 | ||
|
|
4fd290c636 | ||
|
|
e2f28e4975 | ||
|
|
c3b4eb953f | ||
|
|
850ae6c2da | ||
|
|
66b9f58c6f | ||
|
|
b58950bec2 | ||
|
|
47be0ac865 |
||
|
|
cd9b796f16 | ||
|
|
b32bab8211 | ||
|
|
52755fc18f | ||
|
|
f8800d5a31 | ||
|
|
797459b0fc | ||
|
|
f8ef748e58 | ||
|
|
f161fd31d4 | ||
|
|
3690cb22aa | ||
|
|
33debc1475 | ||
|
|
46787309e2 | ||
|
|
1015556834 | ||
|
|
db1c8a1e1a | ||
|
|
761c92e27c | ||
|
|
118377cbc3 | ||
|
|
8786448d07 | ||
|
|
2d288153e3 | ||
|
|
a53a6e5f03 | ||
|
|
237aeec6ac | ||
|
|
0f3145bb8e |
||
|
|
df10bc6f1b | ||
|
|
89a8584060 | ||
|
|
b6842508ae | ||
|
|
563f5fa007 |
||
|
|
4a4823fd6a | ||
|
|
f87e7850ec | ||
|
|
e1758a84d0 | ||
|
|
fdd770eeb9 | ||
|
|
abe75c7b8d | ||
|
|
6b1a82a6b3 | ||
|
|
b0eb73cd76 | ||
|
|
8714fd484b | ||
|
|
ba0287f278 | ||
|
|
2277020de7 | ||
|
|
c1012a77d0 | ||
|
|
1b96577402 | ||
|
|
5bb09a88bc | ||
|
|
36bf18b02f |
||
|
|
da35cfb8a2 | ||
|
|
3e056946ec | ||
|
|
72028d925d | ||
|
|
897c991a0e |
||
|
|
baa6158872 | ||
|
|
a67b9d7aa2 |
345 changed files with 25975 additions and 11851 deletions
18
.cursor/rules/vars-usage.mdc
Normal file
18
.cursor/rules/vars-usage.mdc
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
description: Restricts usage of the global Mineflayer `bot` variable to only src/ files; prohibits usage in renderer/. Specifies correct usage of player state and appViewer globals.
|
||||
globs: src/**/*.ts,renderer/**/*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
Ask AI
|
||||
|
||||
- The global variable `bot` refers to the Mineflayer bot instance.
|
||||
- You may use `bot` directly in any file under the `src/` directory (e.g., `src/mineflayer/playerState.ts`).
|
||||
- Do **not** use `bot` directly in any file under the `renderer/` directory or its subfolders (e.g., `renderer/viewer/three/worldrendererThree.ts`).
|
||||
- In renderer code, all bot/player state and events must be accessed via explicit interfaces, state managers, or passed-in objects, never by referencing `bot` directly.
|
||||
- In renderer code (such as in `WorldRendererThree`), use the `playerState` property (e.g., `worldRenderer.playerState.gameMode`) to access player state. The implementation for `playerState` lives in `src/mineflayer/playerState.ts`.
|
||||
- In `src/` code, you may use the global variable `appViewer` from `src/appViewer.ts` directly. Do **not** import `appViewer` or use `window.appViewer`; use the global `appViewer` variable as-is.
|
||||
- Some other global variables that can be used without window prefixes are listed in src/globals.d.ts
|
||||
|
||||
Rationale: This ensures a clean separation between the Mineflayer logic (server-side/game logic) and the renderer (client-side/view logic), making the renderer portable and testable, and maintains proper usage of global state.
|
||||
|
||||
For more general project contributing guides see CONTRIBUTING.md on like how to setup the project. Use pnpm tsc if needed to validate result with typechecking the whole project.
|
||||
|
|
@ -23,6 +23,7 @@
|
|||
// ],
|
||||
"@stylistic/arrow-spacing": "error",
|
||||
"@stylistic/block-spacing": "error",
|
||||
"@typescript-eslint/no-this-alias": "off",
|
||||
"@stylistic/brace-style": [
|
||||
"error",
|
||||
"1tbs",
|
||||
|
|
@ -102,6 +103,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",
|
||||
|
|
|
|||
59
.github/workflows/benchmark.yml
vendored
Normal file
59
.github/workflows/benchmark.yml
vendored
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
name: Benchmark
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
push:
|
||||
branches:
|
||||
- perf-test
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/perf-test') ||
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request != '' &&
|
||||
(startsWith(github.event.comment.body, '/benchmark'))
|
||||
)
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- run: lscpu
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
- name: Move Cypress to dependencies
|
||||
run: |
|
||||
jq '.dependencies.cypress = .optionalDependencies.cypress | del(.optionalDependencies.cypress)' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
- run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- run: pnpm build
|
||||
- run: nohup pnpm prod-start &
|
||||
|
||||
- run: pnpm test:benchmark
|
||||
id: benchmark
|
||||
continue-on-error: true
|
||||
# read benchmark results from stdout
|
||||
- run: |
|
||||
if [ -f benchmark.txt ]; then
|
||||
# Format the benchmark results for GitHub comment
|
||||
BENCHMARK_RESULT=$(cat benchmark.txt | sed 's/^/- /')
|
||||
echo "BENCHMARK_RESULT<<EOF" >> $GITHUB_ENV
|
||||
echo "$BENCHMARK_RESULT" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
else
|
||||
echo "BENCHMARK_RESULT=Benchmark failed to run or produce results" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- uses: mshick/add-pr-comment@v2
|
||||
with:
|
||||
allow-repeats: true
|
||||
message: |
|
||||
Benchmark result: ${{ env.BENCHMARK_RESULT }}
|
||||
2
.github/workflows/build-single-file.yml
vendored
2
.github/workflows/build-single-file.yml
vendored
|
|
@ -23,6 +23,8 @@ jobs:
|
|||
|
||||
- name: Build single-file version - minecraft.html
|
||||
run: pnpm build-single-file && mv dist/single/index.html minecraft.html
|
||||
env:
|
||||
LOCAL_CONFIG_FILE: config.mcraft-only.json
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
|
|
|||
2
.github/workflows/build-zip.yml
vendored
2
.github/workflows/build-zip.yml
vendored
|
|
@ -23,6 +23,8 @@ jobs:
|
|||
|
||||
- name: Build project
|
||||
run: pnpm build
|
||||
env:
|
||||
LOCAL_CONFIG_FILE: config.mcraft-only.json
|
||||
|
||||
- name: Bundle server.js
|
||||
run: |
|
||||
|
|
|
|||
115
.github/workflows/ci.yml
vendored
115
.github/workflows/ci.yml
vendored
|
|
@ -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 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'
|
||||
|
|
|
|||
6
.github/workflows/next-deploy.yml
vendored
6
.github/workflows/next-deploy.yml
vendored
|
|
@ -30,18 +30,18 @@ jobs:
|
|||
- name: Write Release Info
|
||||
run: |
|
||||
echo "{\"latestTag\": \"$(git rev-parse --short $GITHUB_SHA)\", \"isCommit\": true}" > assets/release.json
|
||||
- name: Download Generated Sounds map
|
||||
run: node scripts/downloadSoundsMap.mjs
|
||||
- name: Build Project Artifacts
|
||||
run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
|
||||
env:
|
||||
CONFIG_JSON_SOURCE: BUNDLED
|
||||
- run: pnpm build-storybook
|
||||
LOCAL_CONFIG_FILE: config.mcraft-only.json
|
||||
- name: Copy playground files
|
||||
run: |
|
||||
mkdir -p .vercel/output/static/playground
|
||||
pnpm build-playground
|
||||
cp -r renderer/dist/* .vercel/output/static/playground/
|
||||
- name: Download Generated Sounds map
|
||||
run: node scripts/downloadSoundsMap.mjs
|
||||
- name: Deploy Project Artifacts to Vercel
|
||||
uses: mathiasvr/command-output@v2.0.0
|
||||
with:
|
||||
|
|
|
|||
21
.github/workflows/preview.yml
vendored
21
.github/workflows/preview.yml
vendored
|
|
@ -1,4 +1,4 @@
|
|||
name: Vercel Deploy Preview
|
||||
name: Vercel PR Deploy (Preview)
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
|
|
@ -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
|
||||
|
|
@ -59,11 +72,13 @@ jobs:
|
|||
- name: Write Release Info
|
||||
run: |
|
||||
echo "{\"latestTag\": \"$(git rev-parse --short ${{ github.event.pull_request.head.sha }})\", \"isCommit\": true}" > assets/release.json
|
||||
- name: Download Generated Sounds map
|
||||
run: node scripts/downloadSoundsMap.mjs
|
||||
- name: Build Project Artifacts
|
||||
run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
|
||||
env:
|
||||
CONFIG_JSON_SOURCE: BUNDLED
|
||||
- run: pnpm build-storybook
|
||||
LOCAL_CONFIG_FILE: config.mcraft-only.json
|
||||
- name: Copy playground files
|
||||
run: |
|
||||
mkdir -p .vercel/output/static/playground
|
||||
|
|
@ -77,8 +92,6 @@ jobs:
|
|||
run: |
|
||||
mkdir -p .vercel/output/static/commit
|
||||
echo "<meta http-equiv='refresh' content='0;url=https://github.com/${{ github.repository }}/pull/${{ github.event.issue.number || github.event.pull_request.number }}/commits/${{ github.event.pull_request.head.sha }}'>" > .vercel/output/static/commit/index.html
|
||||
- name: Download Generated Sounds map
|
||||
run: node scripts/downloadSoundsMap.mjs
|
||||
- name: Deploy Project Artifacts to Vercel
|
||||
uses: mathiasvr/command-output@v2.0.0
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -29,22 +29,18 @@ jobs:
|
|||
run: pnpx zardoy-release empty --skip-github --output-file assets/release.json
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Download Generated Sounds map
|
||||
run: node scripts/downloadSoundsMap.mjs
|
||||
- run: vercel build --token=${{ secrets.VERCEL_TOKEN }} --prod
|
||||
env:
|
||||
CONFIG_JSON_SOURCE: BUNDLED
|
||||
- run: pnpm build-storybook
|
||||
LOCAL_CONFIG_FILE: config.mcraft-only.json
|
||||
- name: Copy playground files
|
||||
run: |
|
||||
mkdir -p .vercel/output/static/playground
|
||||
pnpm build-playground
|
||||
cp -r renderer/dist/* .vercel/output/static/playground/
|
||||
- name: Download Generated Sounds map
|
||||
run: node scripts/downloadSoundsMap.mjs
|
||||
- name: Deploy Project to Vercel
|
||||
uses: mathiasvr/command-output@v2.0.0
|
||||
with:
|
||||
run: vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }} --prod
|
||||
id: deploy
|
||||
|
||||
# publish to github
|
||||
- run: cp vercel.json .vercel/output/static/vercel.json
|
||||
- uses: peaceiris/actions-gh-pages@v3
|
||||
|
|
@ -53,6 +49,39 @@ jobs:
|
|||
publish_dir: .vercel/output/static
|
||||
force_orphan: true
|
||||
|
||||
# Create CNAME file for custom domain
|
||||
- name: Create CNAME file
|
||||
run: echo "github.mcraft.fun" > .vercel/output/static/CNAME
|
||||
|
||||
- name: Deploy to mwc-mcraft-pages repository
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
personal_token: ${{ secrets.MCW_MCRAFT_PAGE_DEPLOY_TOKEN }}
|
||||
external_repository: ${{ github.repository_owner }}/mwc-mcraft-pages
|
||||
publish_dir: .vercel/output/static
|
||||
publish_branch: main
|
||||
destination_dir: docs
|
||||
force_orphan: true
|
||||
|
||||
- name: Change index.html title
|
||||
run: |
|
||||
# change <title>Minecraft Web Client</title> to <title>Minecraft Web Client — Free Online Browser Version</title>
|
||||
sed -i 's/<title>Minecraft Web Client<\/title>/<title>Minecraft Web Client — Free Online Browser Version<\/title>/' .vercel/output/static/index.html
|
||||
|
||||
- name: Deploy Project to Vercel
|
||||
uses: mathiasvr/command-output@v2.0.0
|
||||
with:
|
||||
run: vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }} --prod
|
||||
id: deploy
|
||||
- name: Get releasing alias
|
||||
run: node scripts/githubActions.mjs getReleasingAlias
|
||||
id: alias
|
||||
- name: Set deployment alias
|
||||
run: |
|
||||
for alias in $(echo ${{ steps.alias.outputs.alias }} | tr "," "\n"); do
|
||||
vercel alias set ${{ steps.deploy.outputs.stdout }} $alias --token=${{ secrets.VERCEL_TOKEN }} --scope=zaro
|
||||
done
|
||||
|
||||
- name: Build single-file version - minecraft.html
|
||||
run: pnpm build-single-file && mv dist/single/index.html minecraft.html
|
||||
- name: Build self-host version
|
||||
|
|
@ -70,7 +99,7 @@ jobs:
|
|||
zip -r ../self-host.zip .
|
||||
|
||||
- run: |
|
||||
pnpx zardoy-release node --footer "This release URL: ${{ steps.deploy.outputs.stdout }}"
|
||||
pnpx zardoy-release node --footer "This release URL: https://$(echo ${{ steps.alias.outputs.alias }} | cut -d',' -f1) (Vercel URL: ${{ steps.deploy.outputs.stdout }})"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# has possible output: tag
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -10,7 +10,7 @@ localSettings.mjs
|
|||
dist*
|
||||
.DS_Store
|
||||
.idea/
|
||||
world
|
||||
/world
|
||||
data*.json
|
||||
out
|
||||
*.iml
|
||||
|
|
@ -19,5 +19,6 @@ generated
|
|||
storybook-static
|
||||
server-jar
|
||||
config.local.json
|
||||
logs/
|
||||
|
||||
src/react/npmReactComponents.ts
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@ After forking the repository, run the following commands to get started:
|
|||
4. Let us know if you are working on something and be sure to open a PR if you got any changes. Happy coding!
|
||||
|
||||
*(1): If you are getting `Cannot find matching keyid` update corepack to the latest version with `npm i -g corepack`.
|
||||
|
||||
*(2): If still something doesn't work ensure you have the right nodejs version with `node -v` (tested on 22.x)
|
||||
*(3): For GitHub codespaces (cloud ide): Run `pnpm i @rsbuild/core@1.2.4 @rsbuild/plugin-node-polyfill@1.3.0 @rsbuild/plugin-react@1.1.0 @rsbuild/plugin-typed-css-modules@1.0.2` command to avoid crashes because of limited ram
|
||||
|
||||
<!-- *(3): For GitHub codespaces (cloud ide): Run `pnpm i @rsbuild/core@1.2.4 @rsbuild/plugin-node-polyfill@1.3.0 @rsbuild/plugin-react@1.1.0 @rsbuild/plugin-typed-css-modules@1.0.2` command to avoid crashes because of limited ram -->
|
||||
|
||||
## Project Structure
|
||||
|
||||
|
|
@ -175,8 +177,13 @@ New React components, improve UI (including mobile support).
|
|||
|
||||
## Updating Dependencies
|
||||
|
||||
1. Ensure mineflayer fork is up to date with the latest version of mineflayer original repo
|
||||
1. Use `pnpm update-git-deps` to check and update git dependencies (like mineflayer fork, prismarine packages etc). The script will:
|
||||
- Show which git dependencies have updates available
|
||||
- Ask if you want to update them
|
||||
- Skip dependencies listed in `pnpm.updateConfig.ignoreDependencies`
|
||||
|
||||
2. Update PrismarineJS dependencies to the latest version: `minecraft-data` (be sure to replace the version twice in the package.json), `mineflayer`, `minecraft-protocol`, `prismarine-block`, `prismarine-chunk`, `prismarine-item`, ...
|
||||
|
||||
3. If `minecraft-protocol` patch fails, do this:
|
||||
1. Remove the patch from `patchedDependencies` in `package.json`
|
||||
2. Run `pnpm patch minecraft-protocol`, open patch directory
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ FROM node:18-alpine AS build
|
|||
RUN apk add git
|
||||
WORKDIR /app
|
||||
COPY . /app
|
||||
# install pnpm
|
||||
RUN npm i -g pnpm@9.0.4
|
||||
# install pnpm with corepack
|
||||
RUN corepack enable
|
||||
# Build arguments
|
||||
ARG DOWNLOAD_SOUNDS=false
|
||||
ARG DISABLE_SERVICE_WORKER=false
|
||||
|
|
@ -35,7 +35,7 @@ WORKDIR /app
|
|||
COPY --from=build /app/dist /app/dist
|
||||
COPY server.js /app/server.js
|
||||
# Install express
|
||||
RUN npm i -g pnpm@9.0.4
|
||||
RUN npm i -g pnpm@10.8.0
|
||||
RUN npm init -yp
|
||||
RUN pnpm i express github:zardoy/prismarinejs-net-browserify compression cors
|
||||
EXPOSE 8080
|
||||
|
|
|
|||
40
README.MD
40
README.MD
|
|
@ -6,12 +6,17 @@ Minecraft **clone** rewritten in TypeScript using the best modern web technologi
|
|||
|
||||
You can try this out at [mcraft.fun](https://mcraft.fun/), [pcm.gg](https://pcm.gg) (short link), [mcon.vercel.app](https://mcon.vercel.app/) or the GitHub pages deploy. Every commit from the default (`develop`) branch is deployed to [s.mcraft.fun](https://s.mcraft.fun/) and [s.pcm.gg](https://s.pcm.gg/) - so it's usually newer, but might be less stable.
|
||||
|
||||
Don't confuse with [Eaglercraft](https://git.eaglercraft.rip/eaglercraft/eaglercraft-1.8) which is a REAL vanilla Minecraft Java edition port to the web (but with its own limitations). Eaglercraft is a fully playable solution, but this project is more in position of a "technical demo" to show how it's possible to build games for web at scale entirely with the JS ecosystem. Have fun!
|
||||
> For Turkey/Russia use [ru.mcraft.fun](https://ru.mcraft.fun/) (since Cloudflare is blocked)
|
||||
|
||||
For building the project yourself / contributing, see [Development, Debugging & Contributing](#development-debugging--contributing). For reference at what and how web technologies / frameworks are used, see [TECH.md](./TECH.md).
|
||||
Don't confuse with [Eaglercraft](https://git.eaglercraft.rip/eaglercraft/eaglercraft-1.8) which is a REAL vanilla Minecraft Java edition port to the web (but with its own limitations). Eaglercraft is a fully playable solution, meanwhile this project is aimed for *device-compatiiblity* and better performance so it feels portable, flexible and lightweight. It's also a very strong example on how to build true HTML games for the web at scale entirely with the JS ecosystem. Have fun!
|
||||
|
||||
For building the project yourself / contributing, see [Development, Debugging & Contributing](#development-debugging--contributing). For reference at what and how web technologies / frameworks are used, see [TECH.md](./TECH.md) (also for comparison with Eaglercraft).
|
||||
|
||||
> **Note**: You can deploy it on your own server in less than a minute using a one-liner script from [Minecraft Everywhere repo](https://github.com/zardoy/minecraft-everywhere)
|
||||
|
||||
### Big Features
|
||||
|
||||
- Official Mineflayer [plugin integration](https://github.com/zardoy/mcraft-fun-mineflayer-plugin)! View / Control your bot remotely.
|
||||
- Open any zip world file or even folder in read-write mode!
|
||||
- Connect to Java servers running in both offline (cracked) and online mode* (it's possible because of proxy servers, see below)
|
||||
- Integrated JS server clone capable of opening Java world saves in any way (folders, zip, web chunks streaming, etc)
|
||||
|
|
@ -24,24 +29,39 @@ 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)
|
||||
All components that are in [Storybook](https://minimap.mcraft.fun/storybook/) are published as npm module and can be used in other projects: [`minecraft-react`](https://npmjs.com/minecraft-react)
|
||||
|
||||
### Recommended Settings
|
||||
|
||||
- Controls -> **Touch Controls Type** -> **Joystick**
|
||||
- Controls -> **Auto Full Screen** -> **On** - To avoid ctrl+w issue
|
||||
- Interface -> **Enable Minimap** -> **Always** - To enable useful minimap (why not?)
|
||||
- Controls -> **Raw Input** -> **On** - This will make the controls more precise (UPD: already enabled by default)
|
||||
- Interface -> **Chat Select** -> **On** - To select chat messages (UPD: already enabled by default)
|
||||
|
||||
### Browser Notes
|
||||
|
||||
These browsers have issues with capturing pointer:
|
||||
This project is tested with BrowserStack. Special thanks to [BrowserStack](https://www.browserstack.com/) for providing testing infrastructure!
|
||||
|
||||
Howerver, it's known that these browsers have issues:
|
||||
|
||||
**Opera Mini**: Disable *mouse gestures* in browsre settings to avoid opening new tab on right click hold
|
||||
|
||||
**Vivaldi**: Disable Controls -> *Raw Input* in game settings if experiencing issues
|
||||
|
||||
### Versions Support
|
||||
|
||||
Server versions 1.8 - 1.21.5 are supported.
|
||||
First class versions (most of the features are tested on these versions):
|
||||
|
||||
- 1.19.4
|
||||
- 1.21.4
|
||||
|
||||
Versions below 1.13 are not tested currently and may not work correctly.
|
||||
|
||||
### World Loading
|
||||
|
||||
Zip files and folders are supported. Just drag and drop them into the browser window. You can open folders in readonly and read-write mode. New chunks may be generated incorrectly for now.
|
||||
|
|
@ -58,6 +78,8 @@ There is a builtin proxy, but you can also host your one! Just clone the repo, r
|
|||
|
||||
[](https://app.koyeb.com/deploy?name=minecraft-web-client&type=git&repository=zardoy%2Fminecraft-web-client&branch=next&builder=dockerfile&env%5B%5D=&ports=8080%3Bhttp%3B%2F)
|
||||
|
||||
> **Note**: If you want to make **your own** Minecraft server accessible to web clients (without our proxies), you can use [mwc-proxy](https://github.com/zardoy/mwc-proxy) - a lightweight JS WebSocket proxy that runs on the same server as your Minecraft server, allowing players to connect directly via `wss://play.example.com`. `?client_mcraft` is added to the URL, so the proxy will know that it's this client.
|
||||
|
||||
Proxy servers are used to connect to Minecraft servers which use TCP protocol. When you connect connect to a server with a proxy, websocket connection is created between you (browser client) and the proxy server located in Europe, then the proxy connects to the Minecraft server and sends the data to the client (you) without any packet deserialization to avoid any additional delays. That said all the Minecraft protocol packets are processed by the client, right in your browser.
|
||||
|
||||
```mermaid
|
||||
|
|
@ -105,12 +127,12 @@ There is world renderer playground ([link](https://mcon.vercel.app/playground/))
|
|||
|
||||
However, there are many things that can be done in online production version (like debugging actual source code). Also you can access some global variables in the console and there are a few useful examples:
|
||||
|
||||
- `localStorage.debug = '*'` - Enables all debug messages! Warning: this will start all packets spam.
|
||||
- If you type `debugToggle`, press enter in console - It will enables all debug messages! Warning: this will start all packets spam.
|
||||
Instead I recommend setting `options.debugLogNotFrequentPackets`. Also you can use `debugTopPackets` (with JSON.stringify) to see what packets were received/sent by name
|
||||
|
||||
- `bot` - Mineflayer bot instance. See Mineflayer documentation for more.
|
||||
- `viewer` - Three.js viewer instance, basically does all the rendering.
|
||||
- `viewer.world.sectionObjects` - Object with all active chunk sections (geometries) in the world. Each chunk section is a Three.js mesh or group.
|
||||
- `world` - Three.js world instance, basically does all the rendering (part of renderer backend).
|
||||
- `world.sectionObjects` - Object with all active chunk sections (geometries) in the world. Each chunk section is a Three.js mesh or group.
|
||||
- `debugSceneChunks` - The same as above, but relative to current bot position (e.g. 0,0 is the current chunk).
|
||||
- `debugChangedOptions` - See what options are changed. Don't change options here.
|
||||
- `localServer`/`server` - Only for singleplayer mode/host. Flying Squid server instance, see it's documentation for more.
|
||||
|
|
@ -119,7 +141,7 @@ Instead I recommend setting `options.debugLogNotFrequentPackets`. Also you can u
|
|||
|
||||
- `nbt.simplify(someNbt)` - Simplifies nbt data, so it's easier to read.
|
||||
|
||||
The most useful thing in devtools is the watch expression. You can add any expression there and it will be re-evaluated in real time. For example, you can add `viewer.camera.position` to see the camera position and so on.
|
||||
The most useful thing in devtools is the watch expression. You can add any expression there and it will be re-evaluated in real time. For example, you can add `world.getCameraPosition()` to see the camera position and so on.
|
||||
|
||||
<img src="./docs-assets/watch-expr.png" alt="Watch expression" width="480"/>
|
||||
|
||||
|
|
@ -156,6 +178,7 @@ Server specific:
|
|||
- `?lockConnect=true` - Only works then `ip` parameter is set. Disables cancel/save buttons and all inputs in the connect screen already set as parameters. Useful for integrates iframes.
|
||||
- `?autoConnect=true` - Only works then `ip` and `version` parameters are set and `allowAutoConnect` is `true` in config.json! Directly connects to the specified server. Useful for integrates iframes.
|
||||
- `?serversList=<list_or_url>` - `<list_or_url>` can be a list of servers in the format `ip:version,ip` or a url to a json file with the same format (array) or a txt file with line-delimited list of server IPs.
|
||||
- `?addPing=<ping>` - Add a latency to both sides of the connection. Useful for testing ping issues. For example `?addPing=100` will add 200ms to your ping.
|
||||
|
||||
Single player specific:
|
||||
|
||||
|
|
@ -212,3 +235,4 @@ Only during development:
|
|||
|
||||
- [https://github.com/ClassiCube/ClassiCube](ClassiCube - Better C# Rewrite) [DEMO](https://www.classicube.net/server/play/?warned=true)
|
||||
- [https://m.eaglercraft.com/](EaglerCraft) - Eaglercraft runnable on mobile (real Minecraft in the browser)
|
||||
- [js-minecraft](https://github.com/LabyStudio/js-minecraft) - An insanely well done clone from the graphical side that inspired many features here
|
||||
|
|
|
|||
13
TECH.md
13
TECH.md
|
|
@ -10,26 +10,27 @@ This client generally has better performance but some features reproduction migh
|
|||
| Gamepad Support | ✅ | ❌ | |
|
||||
| A11Y | ✅ | ❌ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page (but maybe it's not needed) |
|
||||
| Game Features | | | |
|
||||
| Servers Support (quality) | ❌ | ✅ | Eaglercraft is vanilla Minecraft, while this project tries to emulate original game behavior at protocol level (Mineflayer is used) |
|
||||
| Servers Support (quality) | ❌(+) | ✅ | Eaglercraft is vanilla Minecraft, while this project tries to emulate original game behavior at protocol level (Mineflayer is used) |
|
||||
| Servers Support (any version, ip) | ✅ | ❌ | We support almost all Minecraft versions, only important if you connect to a server where you need new content like blocks or if you play with friends. And you can connect to almost any server using proxy servers! |
|
||||
| Servers Support (online mode) | ✅ | ❌ | Join to online servers like Hypixel using your Microsoft account without additional proxies |
|
||||
| Singleplayer Survival Features | ❌ | ✅ | Just like Eaglercraft this project can generate and save worlds, but generator is simple and only a few survival features are supported (look here for [supported features list](https://github.com/zardoy/space-squid)) |
|
||||
| Singleplayer Maps | ✅ | ✅ | We support any version, but adventure maps won't work, but simple parkour and build maps might be interesting to explore... |
|
||||
| Singleplayer Maps World Streaming | ✅ | ❌ | Thanks to Browserfs, saves can be loaded to local singleplayer server using multiple ways: from local folder, server directory (not zip), dropbox or other cloud *backend* etc... |
|
||||
| P2P Multiplayer | ✅ | ✅ | A way to connect to other browser running the project. But it's almost useless here since many survival features are not implemented. Maybe only to build / explore maps together... |
|
||||
| Voice Chat | ❌ | ✅ | Eaglercraft has custom WebRTC voice chat implementation, though it could also be easily implemented there |
|
||||
| Voice Chat | ❌(+) | ✅ | Eaglercraft has custom WebRTC voice chat implementation, though it could also be easily implemented there |
|
||||
| Online Servers | ✅ | ❌ | We have custom implementation (including integration on proxy side) for joining to servers |
|
||||
| Plugin Features | ✅ | ❌ | We have Mineflayer plugins support, like Auto Jump & Auto Parkour was added here that way |
|
||||
| Direct Connection | ✅ | ✅ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page |
|
||||
| Moding | ❌(roadmap, client-side) | ❌ | This project will support mods for singleplayer. In theory its possible to implement support for modded servers on protocol level (including all needed mods) |
|
||||
| Video Recording | ❌ | ✅ | Don't feel needed |
|
||||
| Metaverse Features | ❌(roadmap) | ❌ | Iframes, video streams inside of game world (custom protocol channel) |
|
||||
| Moding | ✅(own js mods) | ❌ | This project will support mods for singleplayer. In theory its possible to implement support for modded servers on protocol level (including all needed mods) |
|
||||
| Video Recording | ❌ | ✅ | Doesn't feel needed |
|
||||
| Metaverse Features | ✅(50%) | ❌ | We have videos / images support inside world, but not iframes (custom protocol channel) |
|
||||
| Sounds | ✅ | ✅ | |
|
||||
| Resource Packs | ✅(+extras) | ✅ | This project has very limited support for them (only textures images are loadable for now) |
|
||||
| Assets Compressing & Splitting | ✅ | ❌ | We have advanced Minecraft data processing and good code chunk splitting so the web app will open much faster and use less memory |
|
||||
| Graphics | | | |
|
||||
| Fancy Graphics | ❌ | ✅ | While Eaglercraft has top-level shaders we don't even support lighting |
|
||||
| Fast & Efficient Graphics | ❌(+) | ❌ | Feels like no one needs to have 64 rendering distance work smoothly |
|
||||
| VR | ✅ | ❌ | Feels like not needed feature. UI is missing in this project since DOM can't be rendered in VR so Eaglercraft could be better in that aspect |
|
||||
| VR | ✅(-) | ❌ | Feels like not needed feature. UI is missing in this project since DOM can't be rendered in VR so Eaglercraft could be better in that aspect |
|
||||
| AR | ❌ | ❌ | Would be the most useless feature |
|
||||
| Minimap & Waypoints | ✅(-) | ❌ | We have buggy minimap, which can be enabled in settings and full map is opened by pressing `M` key |
|
||||
|
||||
|
|
|
|||
39
assets/config.html
Normal file
39
assets/config.html
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Configure client</title>
|
||||
<script>
|
||||
function removeSettings() {
|
||||
if (confirm('Are you sure you want to RESET ALL SETTINGS?')) {
|
||||
localStorage.setItem('options', '{}');
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
function removeAllData() {
|
||||
localStorage.removeItem('serversList')
|
||||
localStorage.removeItem('serversHistory')
|
||||
localStorage.removeItem('authenticatedAccounts')
|
||||
localStorage.removeItem('modsAutoUpdateLastCheck')
|
||||
localStorage.removeItem('firstModsPageVisit')
|
||||
localStorage.removeItem('proxiesData')
|
||||
localStorage.removeItem('keybindings')
|
||||
localStorage.removeItem('username')
|
||||
localStorage.removeItem('customCommands')
|
||||
localStorage.removeItem('options')
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div style="display: flex;gap: 10px;">
|
||||
<button onclick="removeSettings()">Reset all settings</button>
|
||||
<button onclick="removeAllData()">Remove all user data (but not mods or worlds)</button>
|
||||
<!-- <button>Remove all user data (worlds, resourcepacks)</button> -->
|
||||
<!-- <button>Remove all mods</button> -->
|
||||
<!-- <button>Remove all mod repositories</button> -->
|
||||
</div>
|
||||
<input />
|
||||
</body>
|
||||
</html>
|
||||
2
assets/customTextures/readme.md
Normal file
2
assets/customTextures/readme.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
here you can place custom textures for bundled files (blocks/items) e.g. blocks/stone.png
|
||||
get file names from here (blocks/items) https://zardoy.github.io/mc-assets/
|
||||
237
assets/debug-inputs.html
Normal file
237
assets/debug-inputs.html
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Web Input Debugger</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 20px;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
.key-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 60px);
|
||||
gap: 5px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.key {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 2px solid #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
background: white;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
.key.pressed {
|
||||
background: #90EE90;
|
||||
}
|
||||
.key .duration {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.key .count {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.controls {
|
||||
margin: 20px 0;
|
||||
padding: 10px;
|
||||
background: white;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.wasd-container {
|
||||
position: relative;
|
||||
width: 190px;
|
||||
height: 130px;
|
||||
}
|
||||
#KeyW {
|
||||
position: absolute;
|
||||
left: 65px;
|
||||
top: 0;
|
||||
}
|
||||
#KeyA {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 65px;
|
||||
}
|
||||
#KeyS {
|
||||
position: absolute;
|
||||
left: 65px;
|
||||
top: 65px;
|
||||
}
|
||||
#KeyD {
|
||||
position: absolute;
|
||||
left: 130px;
|
||||
top: 65px;
|
||||
}
|
||||
.space-container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
#Space {
|
||||
width: 190px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="controls">
|
||||
<label>
|
||||
<input type="checkbox" id="repeatMode"> Use keydown repeat mode (auto key-up after 150ms of no repeat)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="wasd-container">
|
||||
<div id="KeyW" class="key" data-code="KeyW">W</div>
|
||||
<div id="KeyA" class="key" data-code="KeyA">A</div>
|
||||
<div id="KeyS" class="key" data-code="KeyS">S</div>
|
||||
<div id="KeyD" class="key" data-code="KeyD">D</div>
|
||||
</div>
|
||||
|
||||
<div class="key-container">
|
||||
<div id="ControlLeft" class="key" data-code="ControlLeft">Ctrl</div>
|
||||
</div>
|
||||
|
||||
<div class="space-container">
|
||||
<div id="Space" class="key" data-code="Space">Space</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const keys = {};
|
||||
const keyStats = {};
|
||||
const pressStartTimes = {};
|
||||
const keyTimeouts = {};
|
||||
|
||||
function initKeyStats(code) {
|
||||
if (!keyStats[code]) {
|
||||
keyStats[code] = {
|
||||
pressCount: 0,
|
||||
duration: 0,
|
||||
startTime: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function updateKeyVisuals(code) {
|
||||
const element = document.getElementById(code);
|
||||
if (!element) return;
|
||||
|
||||
const stats = keyStats[code];
|
||||
if (keys[code]) {
|
||||
element.classList.add('pressed');
|
||||
const currentDuration = ((Date.now() - stats.startTime) / 1000).toFixed(1);
|
||||
element.innerHTML = `${element.getAttribute('data-code').replace('Key', '').replace('Left', '')}<span class="duration">${currentDuration}s</span><span class="count">${stats.pressCount}</span>`;
|
||||
} else {
|
||||
element.classList.remove('pressed');
|
||||
element.innerHTML = `${element.getAttribute('data-code').replace('Key', '').replace('Left', '')}<span class="count">${stats.pressCount}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function releaseKey(code) {
|
||||
keys[code] = false;
|
||||
if (pressStartTimes[code]) {
|
||||
keyStats[code].duration += (Date.now() - pressStartTimes[code]) / 1000;
|
||||
delete pressStartTimes[code];
|
||||
}
|
||||
updateKeyVisuals(code);
|
||||
}
|
||||
|
||||
function handleKeyDown(event) {
|
||||
const code = event.code;
|
||||
const isRepeatMode = document.getElementById('repeatMode').checked;
|
||||
|
||||
initKeyStats(code);
|
||||
|
||||
// Clear any existing timeout for this key
|
||||
if (keyTimeouts[code]) {
|
||||
clearTimeout(keyTimeouts[code]);
|
||||
delete keyTimeouts[code];
|
||||
}
|
||||
|
||||
if (isRepeatMode) {
|
||||
// In repeat mode, always handle the keydown
|
||||
if (!keys[code] || event.repeat) {
|
||||
keys[code] = true;
|
||||
if (!event.repeat) {
|
||||
// Only increment count on initial press, not repeats
|
||||
keyStats[code].pressCount++;
|
||||
keyStats[code].startTime = Date.now();
|
||||
pressStartTimes[code] = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
// Set timeout to release key if no repeat events come
|
||||
keyTimeouts[code] = setTimeout(() => {
|
||||
releaseKey(code);
|
||||
}, 150);
|
||||
} else {
|
||||
// In normal mode, only handle keydown if key is not already pressed
|
||||
if (!keys[code]) {
|
||||
keys[code] = true;
|
||||
keyStats[code].pressCount++;
|
||||
keyStats[code].startTime = Date.now();
|
||||
pressStartTimes[code] = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
updateKeyVisuals(code);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleKeyUp(event) {
|
||||
const code = event.code;
|
||||
const isRepeatMode = document.getElementById('repeatMode').checked;
|
||||
|
||||
if (!isRepeatMode) {
|
||||
releaseKey(code);
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Initialize all monitored keys
|
||||
const monitoredKeys = ['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ControlLeft', 'Space'];
|
||||
monitoredKeys.forEach(code => {
|
||||
initKeyStats(code);
|
||||
const element = document.getElementById(code);
|
||||
if (element) {
|
||||
element.innerHTML = `${element.getAttribute('data-code').replace('Key', '').replace('Left', '')}<span class="count">0</span>`;
|
||||
}
|
||||
});
|
||||
|
||||
// Start visual updates
|
||||
setInterval(() => {
|
||||
monitoredKeys.forEach(code => {
|
||||
if (keys[code]) {
|
||||
updateKeyVisuals(code);
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
|
||||
// Event listeners
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener('keyup', handleKeyUp);
|
||||
|
||||
// Handle mode changes
|
||||
document.getElementById('repeatMode').addEventListener('change', () => {
|
||||
// Release all keys when switching modes
|
||||
monitoredKeys.forEach(code => {
|
||||
if (keys[code]) {
|
||||
releaseKey(code);
|
||||
}
|
||||
if (keyTimeouts[code]) {
|
||||
clearTimeout(keyTimeouts[code]);
|
||||
delete keyTimeouts[code];
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
53
config.json
53
config.json
|
|
@ -3,25 +3,36 @@
|
|||
"defaultHost": "<from-proxy>",
|
||||
"defaultProxy": "https://proxy.mcraft.fun",
|
||||
"mapsProvider": "https://maps.mcraft.fun/",
|
||||
"skinTexturesProxy": "",
|
||||
"peerJsServer": "",
|
||||
"peerJsServerFallback": "https://p2p.mcraft.fun",
|
||||
"promoteServers": [
|
||||
{
|
||||
"ip": "wss://mcraft.ryzyn.xyz",
|
||||
"version": "1.19.4"
|
||||
},
|
||||
{
|
||||
"ip": "wss://play.mcraft.fun"
|
||||
},
|
||||
{
|
||||
"ip": "wss://play.webmc.fun",
|
||||
"name": "WebMC"
|
||||
},
|
||||
{
|
||||
"ip": "wss://ws.fuchsmc.net"
|
||||
},
|
||||
{
|
||||
"ip": "wss://play2.mcraft.fun"
|
||||
},
|
||||
{
|
||||
"ip": "wss://play-creative.mcraft.fun",
|
||||
"description": "Might be available soon, stay tuned!"
|
||||
},
|
||||
{
|
||||
"ip": "kaboom.pw",
|
||||
"version": "1.20.3",
|
||||
"description": "Very nice a polite server. Must try for everyone!"
|
||||
}
|
||||
],
|
||||
"rightSideText": "A Minecraft client clone in the browser!",
|
||||
"splashText": "The sunset is coming!",
|
||||
"splashTextFallback": "Welcome!",
|
||||
"pauseLinks": [
|
||||
[
|
||||
{
|
||||
|
|
@ -31,5 +42,39 @@
|
|||
"type": "discord"
|
||||
}
|
||||
]
|
||||
],
|
||||
"defaultUsername": "mcrafter{0-9999}",
|
||||
"mobileButtons": [
|
||||
{
|
||||
"action": "general.drop",
|
||||
"actionHold": "general.dropStack",
|
||||
"label": "Q"
|
||||
},
|
||||
{
|
||||
"action": "general.selectItem",
|
||||
"actionHold": "",
|
||||
"label": "S"
|
||||
},
|
||||
{
|
||||
"action": "general.debugOverlay",
|
||||
"actionHold": "general.debugOverlayHelpMenu",
|
||||
"label": "F3"
|
||||
},
|
||||
{
|
||||
"action": "general.playersList",
|
||||
"actionHold": "",
|
||||
"icon": "pixelarticons:users",
|
||||
"label": "TAB"
|
||||
},
|
||||
{
|
||||
"action": "general.chat",
|
||||
"actionHold": "",
|
||||
"label": ""
|
||||
},
|
||||
{
|
||||
"action": "ui.pauseMenu",
|
||||
"actionHold": "",
|
||||
"label": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
5
config.mcraft-only.json
Normal file
5
config.mcraft-only.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"alwaysReconnectButton": true,
|
||||
"reportBugButtonWithReconnect": true,
|
||||
"allowAutoConnect": true
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { defineConfig } from 'cypress'
|
||||
|
||||
const isPerformanceTest = process.env.PERFORMANCE_TEST === 'true'
|
||||
|
||||
export default defineConfig({
|
||||
video: false,
|
||||
chromeWebSecurity: false,
|
||||
|
|
@ -32,7 +34,7 @@ export default defineConfig({
|
|||
return require('./cypress/plugins/index.js')(on, config)
|
||||
},
|
||||
baseUrl: 'http://localhost:8080',
|
||||
specPattern: 'cypress/e2e/**/*.spec.ts',
|
||||
specPattern: !isPerformanceTest ? 'cypress/e2e/smoke.spec.ts' : 'cypress/e2e/rendering_performance.spec.ts',
|
||||
excludeSpecPattern: ['**/__snapshots__/*', '**/__image_snapshots__/*'],
|
||||
},
|
||||
})
|
||||
|
|
|
|||
32
cypress/e2e/rendering_performance.spec.ts
Normal file
32
cypress/e2e/rendering_performance.spec.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/// <reference types="cypress" />
|
||||
import { BenchmarkAdapterInfo, getAllInfoLines } from '../../src/benchmarkAdapter'
|
||||
import { cleanVisit } from './shared'
|
||||
|
||||
it('Benchmark rendering performance', () => {
|
||||
cleanVisit('/?openBenchmark=true&renderDistance=5')
|
||||
// wait for render end event
|
||||
return cy.document().then({ timeout: 180_000 }, doc => {
|
||||
return new Cypress.Promise(resolve => {
|
||||
cy.log('Waiting for world to load')
|
||||
doc.addEventListener('cypress-world-ready', resolve)
|
||||
}).then(() => {
|
||||
cy.log('World loaded')
|
||||
})
|
||||
}).then(() => {
|
||||
cy.window().then(win => {
|
||||
const adapter = win.benchmarkAdapter as BenchmarkAdapterInfo
|
||||
|
||||
const messages = getAllInfoLines(adapter)
|
||||
// wait for 10 seconds
|
||||
cy.wait(10_000)
|
||||
const messages2 = getAllInfoLines(adapter, true)
|
||||
for (const message of messages) {
|
||||
cy.log(message)
|
||||
}
|
||||
for (const message of messages2) {
|
||||
cy.log(message)
|
||||
}
|
||||
cy.writeFile('benchmark.txt', [...messages, ...messages2].join('\n'))
|
||||
})
|
||||
})
|
||||
})
|
||||
13
experiments/three-item.html
Normal file
13
experiments/three-item.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Minecraft Item Viewer</title>
|
||||
<style>
|
||||
body { margin: 0; overflow: hidden; }
|
||||
canvas { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="./three-item.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
108
experiments/three-item.ts
Normal file
108
experiments/three-item.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import * as THREE from 'three'
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
import itemsAtlas from 'mc-assets/dist/itemsAtlasLegacy.png'
|
||||
import { createItemMeshFromCanvas, createItemMesh } from '../renderer/viewer/three/itemMesh'
|
||||
|
||||
// Create scene, camera and renderer
|
||||
const scene = new THREE.Scene()
|
||||
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true })
|
||||
renderer.setSize(window.innerWidth, window.innerHeight)
|
||||
document.body.appendChild(renderer.domElement)
|
||||
|
||||
// Setup camera and controls
|
||||
camera.position.set(0, 0, 3)
|
||||
const controls = new OrbitControls(camera, renderer.domElement)
|
||||
controls.enableDamping = true
|
||||
|
||||
// Background and lights
|
||||
scene.background = new THREE.Color(0x333333)
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7)
|
||||
scene.add(ambientLight)
|
||||
|
||||
// Animation loop
|
||||
function animate () {
|
||||
requestAnimationFrame(animate)
|
||||
controls.update()
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
|
||||
async function setupItemMesh () {
|
||||
try {
|
||||
const loader = new THREE.TextureLoader()
|
||||
const atlasTexture = await loader.loadAsync(itemsAtlas)
|
||||
|
||||
// Pixel-art configuration
|
||||
atlasTexture.magFilter = THREE.NearestFilter
|
||||
atlasTexture.minFilter = THREE.NearestFilter
|
||||
atlasTexture.generateMipmaps = false
|
||||
atlasTexture.wrapS = atlasTexture.wrapT = THREE.ClampToEdgeWrapping
|
||||
|
||||
// Extract the tile at x=2, y=0 (16x16)
|
||||
const tileSize = 16
|
||||
const tileX = 2
|
||||
const tileY = 0
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = tileSize
|
||||
canvas.height = tileSize
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
ctx.imageSmoothingEnabled = false
|
||||
ctx.drawImage(
|
||||
atlasTexture.image,
|
||||
tileX * tileSize,
|
||||
tileY * tileSize,
|
||||
tileSize,
|
||||
tileSize,
|
||||
0,
|
||||
0,
|
||||
tileSize,
|
||||
tileSize
|
||||
)
|
||||
|
||||
// Test both approaches - working manual extraction:
|
||||
const meshOld = createItemMeshFromCanvas(canvas, { depth: 0.1 })
|
||||
meshOld.position.x = -1
|
||||
meshOld.rotation.x = -Math.PI / 12
|
||||
meshOld.rotation.y = Math.PI / 12
|
||||
scene.add(meshOld)
|
||||
|
||||
// And new unified function:
|
||||
const atlasWidth = atlasTexture.image.width
|
||||
const atlasHeight = atlasTexture.image.height
|
||||
const u = (tileX * tileSize) / atlasWidth
|
||||
const v = (tileY * tileSize) / atlasHeight
|
||||
const sizeX = tileSize / atlasWidth
|
||||
const sizeY = tileSize / atlasHeight
|
||||
|
||||
console.log('Debug texture coords:', {u, v, sizeX, sizeY, atlasWidth, atlasHeight})
|
||||
|
||||
const resultNew = createItemMesh(atlasTexture, {
|
||||
u, v, sizeX, sizeY
|
||||
}, {
|
||||
faceCamera: false,
|
||||
use3D: true,
|
||||
depth: 0.1
|
||||
})
|
||||
|
||||
resultNew.mesh.position.x = 1
|
||||
resultNew.mesh.rotation.x = -Math.PI / 12
|
||||
resultNew.mesh.rotation.y = Math.PI / 12
|
||||
scene.add(resultNew.mesh)
|
||||
|
||||
animate()
|
||||
} catch (err) {
|
||||
console.error('Failed to create item mesh:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener('resize', () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight
|
||||
camera.updateProjectionMatrix()
|
||||
renderer.setSize(window.innerWidth, window.innerHeight)
|
||||
})
|
||||
|
||||
// Start
|
||||
setupItemMesh()
|
||||
5
experiments/three-labels.html
Normal file
5
experiments/three-labels.html
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<script type="module" src="three-labels.ts"></script>
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
canvas { display: block; }
|
||||
</style>
|
||||
67
experiments/three-labels.ts
Normal file
67
experiments/three-labels.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import * as THREE from 'three'
|
||||
import { FirstPersonControls } from 'three/addons/controls/FirstPersonControls.js'
|
||||
import { createWaypointSprite, WAYPOINT_CONFIG } from '../renderer/viewer/three/waypointSprite'
|
||||
|
||||
// Create scene, camera and renderer
|
||||
const scene = new THREE.Scene()
|
||||
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true })
|
||||
renderer.setSize(window.innerWidth, window.innerHeight)
|
||||
document.body.appendChild(renderer.domElement)
|
||||
|
||||
// Add FirstPersonControls
|
||||
const controls = new FirstPersonControls(camera, renderer.domElement)
|
||||
controls.lookSpeed = 0.1
|
||||
controls.movementSpeed = 10
|
||||
controls.lookVertical = true
|
||||
controls.constrainVertical = true
|
||||
controls.verticalMin = 0.1
|
||||
controls.verticalMax = Math.PI - 0.1
|
||||
|
||||
// Position camera
|
||||
camera.position.y = 1.6 // Typical eye height
|
||||
camera.lookAt(0, 1.6, -1)
|
||||
|
||||
// Create a helper grid and axes
|
||||
const grid = new THREE.GridHelper(20, 20)
|
||||
scene.add(grid)
|
||||
const axes = new THREE.AxesHelper(5)
|
||||
scene.add(axes)
|
||||
|
||||
// Create waypoint sprite via utility
|
||||
const waypoint = createWaypointSprite({
|
||||
position: new THREE.Vector3(0, 0, -5),
|
||||
color: 0xff0000,
|
||||
label: 'Target',
|
||||
})
|
||||
scene.add(waypoint.group)
|
||||
|
||||
// Use built-in offscreen arrow from utils
|
||||
waypoint.enableOffscreenArrow(true)
|
||||
waypoint.setArrowParent(scene)
|
||||
|
||||
// Animation loop
|
||||
function animate() {
|
||||
requestAnimationFrame(animate)
|
||||
|
||||
const delta = Math.min(clock.getDelta(), 0.1)
|
||||
controls.update(delta)
|
||||
|
||||
// Unified camera update (size, distance text, arrow, visibility)
|
||||
const sizeVec = renderer.getSize(new THREE.Vector2())
|
||||
waypoint.updateForCamera(camera.position, camera, sizeVec.width, sizeVec.height)
|
||||
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener('resize', () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight
|
||||
camera.updateProjectionMatrix()
|
||||
renderer.setSize(window.innerWidth, window.innerHeight)
|
||||
})
|
||||
|
||||
// Add clock for controls
|
||||
const clock = new THREE.Clock()
|
||||
|
||||
animate()
|
||||
|
|
@ -1,101 +1,60 @@
|
|||
import * as THREE from 'three'
|
||||
import * as tweenJs from '@tweenjs/tween.js'
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
|
||||
import * as THREE from 'three';
|
||||
import Jimp from 'jimp';
|
||||
|
||||
// Create scene, camera and renderer
|
||||
const scene = new THREE.Scene()
|
||||
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
|
||||
camera.position.set(0, 0, 5)
|
||||
const renderer = new THREE.WebGLRenderer()
|
||||
renderer.setSize(window.innerWidth, window.innerHeight)
|
||||
document.body.appendChild(renderer.domElement)
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement)
|
||||
// Position camera
|
||||
camera.position.z = 5
|
||||
|
||||
const geometry = new THREE.BoxGeometry(1, 1, 1)
|
||||
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 })
|
||||
const cube = new THREE.Mesh(geometry, material)
|
||||
cube.position.set(0.5, 0.5, 0.5);
|
||||
const group = new THREE.Group()
|
||||
group.add(cube)
|
||||
group.position.set(-0.5, -0.5, -0.5);
|
||||
const outerGroup = new THREE.Group()
|
||||
outerGroup.add(group)
|
||||
outerGroup.scale.set(0.2, 0.2, 0.2)
|
||||
outerGroup.position.set(1, 1, 0)
|
||||
scene.add(outerGroup)
|
||||
// Create a canvas with some content
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 256
|
||||
canvas.height = 256
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
// const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({ color: 0x00_00_ff, transparent: true, opacity: 0.5 }))
|
||||
// mesh.position.set(0.5, 1, 0.5)
|
||||
// const group = new THREE.Group()
|
||||
// group.add(mesh)
|
||||
// group.position.set(-0.5, -1, -0.5)
|
||||
// const outerGroup = new THREE.Group()
|
||||
// outerGroup.add(group)
|
||||
// // outerGroup.position.set(this.camera.position.x, this.camera.position.y, this.camera.position.z)
|
||||
// scene.add(outerGroup)
|
||||
scene.background = new THREE.Color(0x444444)
|
||||
|
||||
new tweenJs.Tween(group.rotation).to({ z: THREE.MathUtils.degToRad(90) }, 1000).yoyo(true).repeat(Infinity).start()
|
||||
// Draw something on the canvas
|
||||
ctx.fillStyle = '#444444'
|
||||
// ctx.fillRect(0, 0, 256, 256)
|
||||
ctx.fillStyle = 'red'
|
||||
ctx.font = '48px Arial'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText('Hello!', 128, 128)
|
||||
|
||||
const tweenGroup = new tweenJs.Group()
|
||||
function animate () {
|
||||
tweenGroup.update()
|
||||
requestAnimationFrame(animate)
|
||||
// cube.rotation.x += 0.01
|
||||
// cube.rotation.y += 0.01
|
||||
renderer.render(scene, camera)
|
||||
// Create bitmap and texture
|
||||
async function createTexturedBox() {
|
||||
const canvas2 = new OffscreenCanvas(256, 256)
|
||||
const ctx2 = canvas2.getContext('2d')!
|
||||
ctx2.drawImage(canvas, 0, 0)
|
||||
const texture = new THREE.Texture(canvas2)
|
||||
texture.magFilter = THREE.NearestFilter
|
||||
texture.minFilter = THREE.NearestFilter
|
||||
texture.needsUpdate = true
|
||||
texture.flipY = false
|
||||
|
||||
// Create box with texture
|
||||
const geometry = new THREE.BoxGeometry(2, 2, 2)
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
side: THREE.DoubleSide,
|
||||
premultipliedAlpha: false,
|
||||
})
|
||||
const cube = new THREE.Mesh(geometry, material)
|
||||
scene.add(cube)
|
||||
}
|
||||
|
||||
// Create the textured box
|
||||
createTexturedBox()
|
||||
|
||||
// Animation loop
|
||||
function animate() {
|
||||
requestAnimationFrame(animate)
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
animate()
|
||||
|
||||
// let animation
|
||||
|
||||
window.animate = () => {
|
||||
// new Tween.Tween(group.position).to({ y: group.position.y - 1}, 1000 * 0.35/2).yoyo(true).repeat(1).start()
|
||||
new tweenJs.Tween(group.rotation, tweenGroup).to({ z: THREE.MathUtils.degToRad(90) }, 1000 * 0.35 / 2).yoyo(true).repeat(Infinity).start().onRepeat(() => {
|
||||
console.log('done')
|
||||
})
|
||||
}
|
||||
|
||||
window.stop = () => {
|
||||
tweenGroup.removeAll()
|
||||
}
|
||||
|
||||
|
||||
function createGeometryFromImage() {
|
||||
return new Promise<THREE.ShapeGeometry>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAABEElEQVQ4jWNkIAPw2Zv9J0cfXPOSvx/+L/n74T+HqsJ/JlI1T9u3i6H91B7ybdY+vgZuO1majV+fppFmPnuz/+ihy2dv9t/49Wm8mlECkV1FHh5FfPZm/1XXTGX4cechA4eKPMNVq1CGH7cfMBJ0rlxX+X8OVYX/xq9P/5frKifoZ0Z0AwS8HRkYGBgYvt+8xyDXUUbQZgwJPnuz/+wq8gw/7zxk+PXsFUFno0h6mon+l5fgZFhwnYmBTUqMgYGBgaAhLMiaHQyFGOZvf8Lw49FXRgYGhv8MDAwwg/7jMoQFFury/C8Y5m9/wnADohnZVryJhoWBARJ9Cw69gtmMAgiFAcuvZ68Yfj17hU8NXgAATdKfkzbQhBEAAAAASUVORK5CYII='
|
||||
console.log('img.complete', img.complete)
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const context = canvas.getContext('2d');
|
||||
context.drawImage(img, 0, 0, img.width, img.height);
|
||||
const imgData = context.getImageData(0, 0, img.width, img.height);
|
||||
|
||||
const shape = new THREE.Shape();
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
const index = (y * img.width + x) * 4;
|
||||
const alpha = imgData.data[index + 3];
|
||||
if (alpha !== 0) {
|
||||
shape.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const geometry = new THREE.ShapeGeometry(shape);
|
||||
resolve(geometry);
|
||||
};
|
||||
img.onerror = reject;
|
||||
});
|
||||
}
|
||||
|
||||
// Usage:
|
||||
const shapeGeomtry = createGeometryFromImage().then(geometry => {
|
||||
const material = new THREE.MeshBasicMaterial({ color: 0xffffff });
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
scene.add(mesh);
|
||||
})
|
||||
|
|
|
|||
25
index.html
25
index.html
|
|
@ -27,6 +27,7 @@
|
|||
<div style="font-size: var(--font-size);color: rgb(176, 176, 176);margin-top: 3px;text-align: center" class="subtitle">A true Minecraft client in your browser!</div>
|
||||
<!-- small text pre -->
|
||||
<div style="font-size: calc(var(--font-size) * 0.6);color: rgb(150, 150, 150);margin-top: 3px;text-align: center;white-space: pre-line;" class="advanced-info"></div>
|
||||
<div style="font-size: calc(var(--font-size) * 0.6);color: rgb(255, 100, 100);margin-top: 10px;text-align: center;display: none;" class="ios-warning">Only iOS 15+ is supported due to performance optimizations</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
|
@ -36,6 +37,13 @@
|
|||
if (!window.pageLoaded) {
|
||||
document.documentElement.appendChild(loadingDivElem)
|
||||
}
|
||||
|
||||
// iOS version detection
|
||||
const getIOSVersion = () => {
|
||||
const match = navigator.userAgent.match(/OS (\d+)_(\d+)_?(\d+)?/);
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
}
|
||||
|
||||
// load error handling
|
||||
const onError = (errorOrMessage, log = false) => {
|
||||
let message = errorOrMessage instanceof Error ? (errorOrMessage.stack ?? errorOrMessage.message) : errorOrMessage
|
||||
|
|
@ -46,12 +54,23 @@
|
|||
const [errorMessage, ...errorStack] = message.split('\n')
|
||||
document.querySelector('.initial-loader').querySelector('.subtitle').textContent = errorMessage
|
||||
document.querySelector('.initial-loader').querySelector('.advanced-info').textContent = errorStack.join('\n')
|
||||
|
||||
// Show iOS warning if applicable
|
||||
const iosVersion = getIOSVersion();
|
||||
if (iosVersion !== null && iosVersion < 15) {
|
||||
document.querySelector('.initial-loader').querySelector('.ios-warning').style.display = 'block';
|
||||
}
|
||||
|
||||
if (window.navigator.maxTouchPoints > 1) window.location.hash = '#dev' // show eruda
|
||||
// unregister all sw
|
||||
if (window.navigator.serviceWorker) {
|
||||
if (window.navigator.serviceWorker && document.querySelector('.initial-loader').style.opacity !== 0) {
|
||||
console.log('got worker')
|
||||
window.navigator.serviceWorker.getRegistrations().then(registrations => {
|
||||
registrations.forEach(registration => {
|
||||
registration.unregister()
|
||||
console.log('got registration')
|
||||
registration.unregister().then(() => {
|
||||
console.log('worker unregistered')
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -130,7 +149,7 @@
|
|||
</script> -->
|
||||
<title>Minecraft Web Client</title>
|
||||
<!-- <link rel="canonical" href="https://mcraft.fun"> -->
|
||||
<meta name="description" content="Minecraft web client running in your browser">
|
||||
<meta name="description" content="Minecraft Java Edition Client in Browser — Full Multiplayer Support, Server Connect, Offline Play — Join real Minecraft servers">
|
||||
<meta name="keywords" content="Play, Minecraft, Online, Web, Java, Server, Single player, Javascript, PrismarineJS, Voxel, WebGL, Three.js">
|
||||
<meta name="date" content="2024-07-11" scheme="YYYY-MM-DD">
|
||||
<meta name="language" content="English">
|
||||
|
|
|
|||
71
package.json
71
package.json
|
|
@ -7,12 +7,14 @@
|
|||
"dev-proxy": "node server.js",
|
||||
"start": "run-p dev-proxy dev-rsbuild watch-mesher",
|
||||
"start2": "run-p dev-rsbuild watch-mesher",
|
||||
"start-metrics": "ENABLE_METRICS=true rsbuild dev",
|
||||
"build": "pnpm build-other-workers && rsbuild build",
|
||||
"build-analyze": "BUNDLE_ANALYZE=true rsbuild build && pnpm build-other-workers",
|
||||
"build-single-file": "SINGLE_FILE_BUILD=true rsbuild build",
|
||||
"prepare-project": "tsx scripts/genShims.ts && tsx scripts/makeOptimizedMcData.mjs && tsx scripts/genLargeDataAliases.ts",
|
||||
"check-build": "pnpm prepare-project && tsc && pnpm build",
|
||||
"test:cypress": "cypress run",
|
||||
"test:benchmark": "PERFORMANCE_TEST=true cypress run",
|
||||
"test:cypress:open": "cypress open",
|
||||
"test-unit": "vitest",
|
||||
"test:e2e": "start-test http-get://localhost:8080 test:cypress",
|
||||
|
|
@ -30,7 +32,9 @@
|
|||
"run-playground": "run-p watch-mesher watch-other-workers watch-playground",
|
||||
"run-all": "run-p start run-playground",
|
||||
"build-playground": "rsbuild build --config renderer/rsbuild.config.ts",
|
||||
"watch-playground": "rsbuild dev --config renderer/rsbuild.config.ts"
|
||||
"watch-playground": "rsbuild dev --config renderer/rsbuild.config.ts",
|
||||
"update-git-deps": "tsx scripts/updateGitDeps.ts",
|
||||
"request-data": "tsx scripts/requestData.ts"
|
||||
},
|
||||
"keywords": [
|
||||
"prismarine",
|
||||
|
|
@ -50,8 +54,9 @@
|
|||
"dependencies": {
|
||||
"@dimaka/interface": "0.0.3-alpha.0",
|
||||
"@floating-ui/react": "^0.26.1",
|
||||
"@nxg-org/mineflayer-auto-jump": "^0.7.12",
|
||||
"@nxg-org/mineflayer-tracker": "1.2.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@nxg-org/mineflayer-auto-jump": "^0.7.18",
|
||||
"@nxg-org/mineflayer-tracker": "1.3.0",
|
||||
"@react-oauth/google": "^0.12.1",
|
||||
"@stylistic/eslint-plugin": "^2.6.1",
|
||||
"@types/gapi": "^0.0.47",
|
||||
|
|
@ -75,18 +80,19 @@
|
|||
"esbuild-plugin-polyfill-node": "^0.3.0",
|
||||
"express": "^4.18.2",
|
||||
"filesize": "^10.0.12",
|
||||
"flying-squid": "npm:@zardoy/flying-squid@^0.0.51",
|
||||
"flying-squid": "npm:@zardoy/flying-squid@^0.0.104",
|
||||
"framer-motion": "^12.9.2",
|
||||
"fs-extra": "^11.1.1",
|
||||
"google-drive-browserfs": "github:zardoy/browserfs#google-drive",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"minecraft-data": "3.83.1",
|
||||
"mcraft-fun-mineflayer": "^0.1.23",
|
||||
"minecraft-data": "3.98.0",
|
||||
"minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master",
|
||||
"mineflayer-item-map-downloader": "github:zardoy/mineflayer-item-map-downloader",
|
||||
"mojangson": "^2.0.4",
|
||||
"net-browserify": "github:zardoy/prismarinejs-net-browserify",
|
||||
"node-gzip": "^1.1.2",
|
||||
"mcraft-fun-mineflayer": "^0.1.10",
|
||||
"peerjs": "^1.5.0",
|
||||
"pixelarticons": "^1.8.1",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
|
|
@ -100,7 +106,6 @@
|
|||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-select": "^5.8.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-zoom-pan-pinch": "3.4.4",
|
||||
"remark": "^15.0.1",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
|
|
@ -118,11 +123,11 @@
|
|||
"workbox-build": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rsbuild/core": "^1.0.1-beta.9",
|
||||
"@rsbuild/plugin-node-polyfill": "^1.0.3",
|
||||
"@rsbuild/plugin-react": "^1.0.1-beta.9",
|
||||
"@rsbuild/plugin-type-check": "^1.0.1-beta.9",
|
||||
"@rsbuild/plugin-typed-css-modules": "^1.0.1",
|
||||
"@rsbuild/core": "1.3.5",
|
||||
"@rsbuild/plugin-node-polyfill": "1.3.0",
|
||||
"@rsbuild/plugin-react": "1.2.0",
|
||||
"@rsbuild/plugin-type-check": "1.2.1",
|
||||
"@rsbuild/plugin-typed-css-modules": "1.0.2",
|
||||
"@storybook/addon-essentials": "^7.4.6",
|
||||
"@storybook/addon-links": "^7.4.6",
|
||||
"@storybook/blocks": "^7.4.6",
|
||||
|
|
@ -130,7 +135,6 @@
|
|||
"@storybook/react-vite": "^7.4.6",
|
||||
"@types/diff-match-patch": "^1.0.36",
|
||||
"@types/lodash-es": "^4.17.9",
|
||||
"@types/react-transition-group": "^4.4.7",
|
||||
"@types/stats.js": "^0.17.1",
|
||||
"@types/three": "0.154.0",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
|
|
@ -140,7 +144,7 @@
|
|||
"browserify-zlib": "^0.2.0",
|
||||
"buffer": "^6.0.3",
|
||||
"constants-browserify": "^1.0.0",
|
||||
"contro-max": "^0.1.8",
|
||||
"contro-max": "^0.1.9",
|
||||
"crypto-browserify": "^3.12.0",
|
||||
"cypress-esbuild-preprocessor": "^1.0.2",
|
||||
"eslint": "^8.50.0",
|
||||
|
|
@ -150,17 +154,16 @@
|
|||
"http-browserify": "^1.7.0",
|
||||
"http-server": "^14.1.1",
|
||||
"https-browserify": "^1.0.0",
|
||||
"mc-assets": "^0.2.42",
|
||||
"mineflayer-mouse": "^0.0.9",
|
||||
"mc-assets": "^0.2.62",
|
||||
"minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next",
|
||||
"mineflayer": "github:zardoy/mineflayer",
|
||||
"mineflayer-pathfinder": "^2.4.4",
|
||||
"mineflayer": "github:zardoy/mineflayer#gen-the-master",
|
||||
"mineflayer-mouse": "^0.1.21",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"os-browserify": "^0.3.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"path-exists-cli": "^2.0.0",
|
||||
"renderer": "link:renderer",
|
||||
"process": "github:PrismarineJS/node-process",
|
||||
"renderer": "link:renderer",
|
||||
"rimraf": "^5.0.1",
|
||||
"storybook": "^7.4.6",
|
||||
"stream-browserify": "^3.0.0",
|
||||
|
|
@ -194,14 +197,15 @@
|
|||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"mineflayer": "github:zardoy/mineflayer#gen-the-master",
|
||||
"@nxg-org/mineflayer-physics-util": "1.8.10",
|
||||
"buffer": "^6.0.3",
|
||||
"vec3": "0.1.10",
|
||||
"@nxg-org/mineflayer-physics-util": "1.5.8",
|
||||
"three": "0.154.0",
|
||||
"diamond-square": "github:zardoy/diamond-square",
|
||||
"prismarine-block": "github:zardoy/prismarine-block#next-era",
|
||||
"prismarine-world": "github:zardoy/prismarine-world#next-era",
|
||||
"minecraft-data": "3.83.1",
|
||||
"minecraft-data": "3.98.0",
|
||||
"prismarine-provider-anvil": "github:zardoy/prismarine-provider-anvil#everything",
|
||||
"prismarine-physics": "github:zardoy/prismarine-physics",
|
||||
"minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master",
|
||||
|
|
@ -210,14 +214,29 @@
|
|||
"prismarine-item": "latest"
|
||||
},
|
||||
"updateConfig": {
|
||||
"ignoreDependencies": []
|
||||
"ignoreDependencies": [
|
||||
"browserfs",
|
||||
"google-drive-browserfs"
|
||||
]
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"three@0.154.0": "patches/three@0.154.0.patch",
|
||||
"pixelarticons@1.8.1": "patches/pixelarticons@1.8.1.patch",
|
||||
"mineflayer-item-map-downloader@1.2.0": "patches/mineflayer-item-map-downloader@1.2.0.patch",
|
||||
"minecraft-protocol@1.54.0": "patches/minecraft-protocol@1.54.0.patch"
|
||||
}
|
||||
"minecraft-protocol": "patches/minecraft-protocol.patch"
|
||||
},
|
||||
"ignoredBuiltDependencies": [
|
||||
"canvas",
|
||||
"core-js",
|
||||
"gl"
|
||||
],
|
||||
"onlyBuiltDependencies": [
|
||||
"sharp",
|
||||
"cypress",
|
||||
"esbuild",
|
||||
"fsevents"
|
||||
],
|
||||
"ignorePatchFailures": false,
|
||||
"allowUnusedPatches": false
|
||||
},
|
||||
"packageManager": "pnpm@9.0.4"
|
||||
"packageManager": "pnpm@10.8.0+sha512.0e82714d1b5b43c74610193cb20734897c1d00de89d0e18420aebc5977fa13d780a9cb05734624e81ebd81cc876cd464794850641c48b9544326b5622ca29971"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
diff --git a/src/client/chat.js b/src/client/chat.js
|
||||
index f14269bea055d4329cd729271e7406ec4b344de7..00f5482eb6e3c911381ca9a728b1b4aae0d1d337 100644
|
||||
index 0021870994fc59a82f0ac8aba0a65a8be43ef2f4..a53fceb843105ea2a1d88722b3fc7c3b43cb102a 100644
|
||||
--- a/src/client/chat.js
|
||||
+++ b/src/client/chat.js
|
||||
@@ -111,7 +111,7 @@ module.exports = function (client, options) {
|
||||
for (const player of packet.data) {
|
||||
if (!player.chatSession) continue
|
||||
client._players[player.UUID] = {
|
||||
- publicKey: crypto.createPublicKey({ key: player.chatSession.publicKey.keyBytes, format: 'der', type: 'spki' }),
|
||||
+ // publicKey: crypto.createPublicKey({ key: player.chatSession.publicKey.keyBytes, format: 'der', type: 'spki' }),
|
||||
publicKeyDER: player.chatSession.publicKey.keyBytes,
|
||||
sessionUuid: player.chatSession.uuid
|
||||
}
|
||||
@@ -127,7 +127,7 @@ module.exports = function (client, options) {
|
||||
for (const player of packet.data) {
|
||||
if (player.crypto) {
|
||||
client._players[player.UUID] = {
|
||||
- publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }),
|
||||
+ // publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }),
|
||||
publicKeyDER: player.crypto.publicKey,
|
||||
signature: player.crypto.signature,
|
||||
displayName: player.displayName || player.name
|
||||
@@ -198,7 +198,7 @@ module.exports = function (client, options) {
|
||||
@@ -116,7 +116,7 @@ module.exports = function (client, options) {
|
||||
for (const player of packet.data) {
|
||||
if (player.chatSession) {
|
||||
client._players[player.uuid] = {
|
||||
- publicKey: crypto.createPublicKey({ key: player.chatSession.publicKey.keyBytes, format: 'der', type: 'spki' }),
|
||||
+ // publicKey: crypto.createPublicKey({ key: player.chatSession.publicKey.keyBytes, format: 'der', type: 'spki' }),
|
||||
publicKeyDER: player.chatSession.publicKey.keyBytes,
|
||||
sessionUuid: player.chatSession.uuid
|
||||
}
|
||||
@@ -126,7 +126,7 @@ module.exports = function (client, options) {
|
||||
|
||||
if (player.crypto) {
|
||||
client._players[player.uuid] = {
|
||||
- publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }),
|
||||
+ // publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }),
|
||||
publicKeyDER: player.crypto.publicKey,
|
||||
signature: player.crypto.signature,
|
||||
displayName: player.displayName || player.name
|
||||
@@ -196,7 +196,7 @@ module.exports = function (client, options) {
|
||||
if (mcData.supportFeature('useChatSessions')) {
|
||||
const tsDelta = BigInt(Date.now()) - packet.timestamp
|
||||
const expired = !packet.timestamp || tsDelta > messageExpireTime || tsDelta < 0
|
||||
|
|
@ -28,8 +28,8 @@ index f14269bea055d4329cd729271e7406ec4b344de7..00f5482eb6e3c911381ca9a728b1b4aa
|
|||
+ const verified = false && !packet.unsignedChatContent && updateAndValidateSession(packet.senderUuid, packet.plainMessage, packet.signature, packet.index, packet.previousMessages, packet.salt, packet.timestamp) && !expired
|
||||
if (verified) client._signatureCache.push(packet.signature)
|
||||
client.emit('playerChat', {
|
||||
plainMessage: packet.plainMessage,
|
||||
@@ -363,7 +363,7 @@ module.exports = function (client, options) {
|
||||
globalIndex: packet.globalIndex,
|
||||
@@ -362,7 +362,7 @@ module.exports = function (client, options) {
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -38,16 +38,16 @@ index f14269bea055d4329cd729271e7406ec4b344de7..00f5482eb6e3c911381ca9a728b1b4aa
|
|||
options.timestamp = options.timestamp || BigInt(Date.now())
|
||||
options.salt = options.salt || 1n
|
||||
|
||||
@@ -405,7 +405,7 @@ module.exports = function (client, options) {
|
||||
@@ -407,7 +407,7 @@ module.exports = function (client, options) {
|
||||
message,
|
||||
timestamp: options.timestamp,
|
||||
salt: options.salt,
|
||||
- signature: (client.profileKeys && client._session) ? client.signMessage(message, options.timestamp, options.salt, undefined, acknowledgements) : undefined,
|
||||
+ signature: (client.profileKeys && client._session) ? await client.signMessage(message, options.timestamp, options.salt, undefined, acknowledgements) : undefined,
|
||||
offset: client._lastSeenMessages.pending,
|
||||
checksum: computeChatChecksum(client._lastSeenMessages), // 1.21.5+
|
||||
acknowledged
|
||||
})
|
||||
@@ -419,7 +419,7 @@ module.exports = function (client, options) {
|
||||
@@ -422,7 +422,7 @@ module.exports = function (client, options) {
|
||||
message,
|
||||
timestamp: options.timestamp,
|
||||
salt: options.salt,
|
||||
|
|
@ -57,7 +57,7 @@ index f14269bea055d4329cd729271e7406ec4b344de7..00f5482eb6e3c911381ca9a728b1b4aa
|
|||
previousMessages: client._lastSeenMessages.map((e) => ({
|
||||
messageSender: e.sender,
|
||||
diff --git a/src/client/encrypt.js b/src/client/encrypt.js
|
||||
index b9d21bab9faccd5dbf1975fc423fc55c73e906c5..99ffd76527b410e3a393181beb260108f4c63536 100644
|
||||
index 63cc2bd9615100bd2fd63dfe14c094aa6b8cd1c9..36df57d1196af9761d920fa285ac48f85410eaef 100644
|
||||
--- a/src/client/encrypt.js
|
||||
+++ b/src/client/encrypt.js
|
||||
@@ -25,7 +25,11 @@ module.exports = function (client, options) {
|
||||
|
|
@ -73,28 +73,24 @@ index b9d21bab9faccd5dbf1975fc423fc55c73e906c5..99ffd76527b410e3a393181beb260108
|
|||
}
|
||||
|
||||
function onJoinServerResponse (err) {
|
||||
diff --git a/src/client/pluginChannels.js b/src/client/pluginChannels.js
|
||||
index 671eb452f31e6b5fcd57d715f1009d010160c65f..7f69f511c8fb97d431ec5125c851b49be8e2ab76 100644
|
||||
--- a/src/client/pluginChannels.js
|
||||
+++ b/src/client/pluginChannels.js
|
||||
@@ -57,7 +57,7 @@ module.exports = function (client, options) {
|
||||
try {
|
||||
packet.data = proto.parsePacketBuffer(channel, packet.data).data
|
||||
} catch (error) {
|
||||
- client.emit('error', error)
|
||||
+ client.emit('error', error, { customPayload: packet })
|
||||
return
|
||||
}
|
||||
}
|
||||
diff --git a/src/client.js b/src/client.js
|
||||
index 74749698f8cee05b5dc749c271544f78d06645b0..e77e0a3f41c1ee780c3abbd54b0801d248c2a07c 100644
|
||||
index e369e77d055ba919e8f9da7b8e8b5dc879c74cf4..54bb9e6644388e9b6bd42b3012951875989cdf0c 100644
|
||||
--- a/src/client.js
|
||||
+++ b/src/client.js
|
||||
@@ -89,10 +89,12 @@ class Client extends EventEmitter {
|
||||
parsed.metadata.name = parsed.data.name
|
||||
parsed.data = parsed.data.params
|
||||
parsed.metadata.state = state
|
||||
- debug('read packet ' + state + '.' + parsed.metadata.name)
|
||||
- if (debug.enabled) {
|
||||
- const s = JSON.stringify(parsed.data, null, 2)
|
||||
- debug(s && s.length > 10000 ? parsed.data : s)
|
||||
+ if (!globalThis.excludeCommunicationDebugEvents?.includes(parsed.metadata.name)) {
|
||||
+ debug('read packet ' + state + '.' + parsed.metadata.name)
|
||||
+ if (debug.enabled) {
|
||||
+ const s = JSON.stringify(parsed.data, null, 2)
|
||||
+ debug(s && s.length > 10000 ? parsed.data : s)
|
||||
+ }
|
||||
}
|
||||
if (this._hasBundlePacket && parsed.metadata.name === 'bundle_delimiter') {
|
||||
if (this._mcBundle.length) { // End bundle
|
||||
@@ -110,7 +112,13 @@ class Client extends EventEmitter {
|
||||
@@ -111,7 +111,13 @@ class Client extends EventEmitter {
|
||||
this._hasBundlePacket = false
|
||||
}
|
||||
} else {
|
||||
|
|
@ -109,7 +105,7 @@ index 74749698f8cee05b5dc749c271544f78d06645b0..e77e0a3f41c1ee780c3abbd54b0801d2
|
|||
}
|
||||
})
|
||||
}
|
||||
@@ -168,7 +176,10 @@ class Client extends EventEmitter {
|
||||
@@ -169,7 +175,10 @@ class Client extends EventEmitter {
|
||||
}
|
||||
|
||||
const onFatalError = (err) => {
|
||||
|
|
@ -121,25 +117,21 @@ index 74749698f8cee05b5dc749c271544f78d06645b0..e77e0a3f41c1ee780c3abbd54b0801d2
|
|||
endSocket()
|
||||
}
|
||||
|
||||
@@ -197,6 +208,8 @@ class Client extends EventEmitter {
|
||||
@@ -198,6 +207,10 @@ class Client extends EventEmitter {
|
||||
serializer -> framer -> socket -> splitter -> deserializer */
|
||||
if (this.serializer) {
|
||||
this.serializer.end()
|
||||
+ this.socket?.end()
|
||||
+ this.socket?.emit('end')
|
||||
+ setTimeout(() => {
|
||||
+ this.socket?.end()
|
||||
+ this.socket?.emit('end')
|
||||
+ }, 2000) // allow the serializer to finish writing
|
||||
} else {
|
||||
if (this.socket) this.socket.end()
|
||||
}
|
||||
@@ -238,8 +251,11 @@ class Client extends EventEmitter {
|
||||
|
||||
write (name, params) {
|
||||
if (!this.serializer.writable) { return }
|
||||
- debug('writing packet ' + this.state + '.' + name)
|
||||
- debug(params)
|
||||
+ if (!globalThis.excludeCommunicationDebugEvents?.includes(name)) {
|
||||
+ debug(`[${this.state}] from ${this.isServer ? 'server' : 'client'}: ` + name)
|
||||
+ debug(params)
|
||||
+ }
|
||||
@@ -243,6 +256,7 @@ class Client extends EventEmitter {
|
||||
debug('writing packet ' + this.state + '.' + name)
|
||||
debug(params)
|
||||
}
|
||||
+ this.emit('writePacket', name, params)
|
||||
this.serializer.write({ name, params })
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
diff --git a/fonts/pixelart-icons-font.css b/fonts/pixelart-icons-font.css
|
||||
index 3b2ebe839370d96bf93ef5ca94a827f07e49378d..103ab4d6b9f3b5c9f41d1407e3cbf4ac392fbf41 100644
|
||||
index 3b2ebe839370d96bf93ef5ca94a827f07e49378d..4f8d76be2ca6e4ddc43c68d0a6f0f69979165ab4 100644
|
||||
--- a/fonts/pixelart-icons-font.css
|
||||
+++ b/fonts/pixelart-icons-font.css
|
||||
@@ -1,16 +1,13 @@
|
||||
|
|
@ -10,10 +10,11 @@ index 3b2ebe839370d96bf93ef5ca94a827f07e49378d..103ab4d6b9f3b5c9f41d1407e3cbf4ac
|
|||
+ src:
|
||||
url("pixelart-icons-font.woff2?t=1711815892278") format("woff2"),
|
||||
url("pixelart-icons-font.woff?t=1711815892278") format("woff"),
|
||||
url('pixelart-icons-font.ttf?t=1711815892278') format('truetype'), /* chrome, firefox, opera, Safari, Android, iOS 4.2+*/
|
||||
- url('pixelart-icons-font.ttf?t=1711815892278') format('truetype'), /* chrome, firefox, opera, Safari, Android, iOS 4.2+*/
|
||||
- url('pixelart-icons-font.svg?t=1711815892278#pixelart-icons-font') format('svg'); /* iOS 4.1- */
|
||||
+ url('pixelart-icons-font.ttf?t=1711815892278') format('truetype'); /* chrome, firefox, opera, Safari, Android, iOS 4.2+*/
|
||||
}
|
||||
|
||||
|
||||
[class^="pixelart-icons-font-"], [class*=" pixelart-icons-font-"] {
|
||||
font-family: 'pixelart-icons-font' !important;
|
||||
- font-size:24px;
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
diff --git a/examples/jsm/webxr/VRButton.js b/examples/jsm/webxr/VRButton.js
|
||||
index 6856a21b17aa45d7922bbf776fd2d7e63c7a9b4e..0925b706f7629bd52f0bb5af469536af8f5fce2c 100644
|
||||
--- a/examples/jsm/webxr/VRButton.js
|
||||
+++ b/examples/jsm/webxr/VRButton.js
|
||||
@@ -62,7 +62,10 @@ class VRButton {
|
||||
// ('local' is always available for immersive sessions and doesn't need to
|
||||
// be requested separately.)
|
||||
|
||||
- const sessionInit = { optionalFeatures: [ 'local-floor', 'bounded-floor', 'hand-tracking', 'layers' ] };
|
||||
+ const sessionInit = {
|
||||
+ optionalFeatures: ['local-floor', 'bounded-floor', 'layers'],
|
||||
+ domOverlay: { root: document.body },
|
||||
+ };
|
||||
navigator.xr.requestSession( 'immersive-vr', sessionInit ).then( onSessionStarted );
|
||||
|
||||
} else {
|
||||
11249
pnpm-lock.yaml
generated
11249
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -22,7 +22,7 @@ const buildOptions = {
|
|||
},
|
||||
platform: 'browser',
|
||||
entryPoints: [path.join(__dirname, './viewer/lib/mesher/mesher.ts')],
|
||||
minify: true,
|
||||
minify: !watch,
|
||||
logLevel: 'info',
|
||||
drop: !watch ? [
|
||||
'debugger'
|
||||
|
|
@ -35,11 +35,15 @@ const buildOptions = {
|
|||
define: {
|
||||
'process.env.BROWSER': '"true"',
|
||||
},
|
||||
loader: {
|
||||
'.png': 'dataurl',
|
||||
'.obj': 'text'
|
||||
},
|
||||
plugins: [
|
||||
...mesherSharedPlugins,
|
||||
{
|
||||
name: 'external-json',
|
||||
setup (build) {
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /\.json$/ }, args => {
|
||||
const fileName = args.path.split('/').pop().replace('.json', '')
|
||||
if (args.resolveDir.includes('minecraft-data')) {
|
||||
|
|
|
|||
|
|
@ -11,11 +11,17 @@
|
|||
|
||||
html, body {
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
canvas {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
|
|
|||
170
renderer/playground/allEntitiesDebug.ts
Normal file
170
renderer/playground/allEntitiesDebug.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { EntityMesh, rendererSpecialHandled, EntityDebugFlags } from '../viewer/three/entity/EntityMesh'
|
||||
|
||||
export const displayEntitiesDebugList = (version: string) => {
|
||||
// Create results container
|
||||
const container = document.createElement('div')
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
background: rgba(0,0,0,0.8);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
font-family: monospace;
|
||||
min-width: 400px;
|
||||
z-index: 1000;
|
||||
`
|
||||
document.body.appendChild(container)
|
||||
|
||||
// Add title
|
||||
const title = document.createElement('h2')
|
||||
title.textContent = 'Minecraft Entity Support'
|
||||
title.style.cssText = 'margin-top: 0; text-align: center;'
|
||||
container.appendChild(title)
|
||||
|
||||
// Test entities
|
||||
const results: Array<{
|
||||
entity: string;
|
||||
supported: boolean;
|
||||
type?: 'obj' | 'bedrock' | 'special';
|
||||
mappedFrom?: string;
|
||||
textureMap?: boolean;
|
||||
errors?: string[];
|
||||
}> = []
|
||||
const { mcData } = window
|
||||
const entityNames = Object.keys(mcData.entitiesArray.reduce((acc, entity) => {
|
||||
acc[entity.name] = true
|
||||
return acc
|
||||
}, {}))
|
||||
|
||||
// Add loading indicator
|
||||
const loading = document.createElement('div')
|
||||
loading.textContent = 'Testing entities...'
|
||||
loading.style.textAlign = 'center'
|
||||
container.appendChild(loading)
|
||||
|
||||
for (const entity of entityNames) {
|
||||
const debugFlags: EntityDebugFlags = {}
|
||||
|
||||
if (rendererSpecialHandled.includes(entity)) {
|
||||
results.push({
|
||||
entity,
|
||||
supported: true,
|
||||
type: 'special',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
const { mesh: entityMesh } = new EntityMesh(version, entity, undefined, {}, debugFlags)
|
||||
// find the most distant pos child
|
||||
window.objects ??= {}
|
||||
window.objects[entity] = entityMesh
|
||||
|
||||
results.push({
|
||||
entity,
|
||||
supported: !!debugFlags.type || rendererSpecialHandled.includes(entity),
|
||||
type: debugFlags.type,
|
||||
mappedFrom: debugFlags.tempMap,
|
||||
textureMap: debugFlags.textureMap,
|
||||
errors: debugFlags.errors
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
results.push({
|
||||
entity,
|
||||
supported: false,
|
||||
mappedFrom: debugFlags.tempMap
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Remove loading indicator
|
||||
loading.remove()
|
||||
|
||||
const createSection = (title: string, items: any[], filter: (item: any) => boolean) => {
|
||||
const section = document.createElement('div')
|
||||
section.style.marginBottom = '20px'
|
||||
|
||||
const sectionTitle = document.createElement('h3')
|
||||
sectionTitle.textContent = title
|
||||
sectionTitle.style.textAlign = 'center'
|
||||
section.appendChild(sectionTitle)
|
||||
|
||||
const list = document.createElement('ul')
|
||||
list.style.cssText = 'padding-left: 20px; list-style-type: none; margin: 0;'
|
||||
|
||||
const filteredItems = items.filter(filter)
|
||||
for (const item of filteredItems) {
|
||||
const listItem = document.createElement('li')
|
||||
listItem.style.cssText = 'line-height: 1.4; margin: 8px 0;'
|
||||
|
||||
const entityName = document.createElement('strong')
|
||||
entityName.style.cssText = 'user-select: text;-webkit-user-select: text;'
|
||||
entityName.textContent = item.entity
|
||||
listItem.appendChild(entityName)
|
||||
|
||||
let text = ''
|
||||
if (item.mappedFrom) {
|
||||
text += ` -> ${item.mappedFrom}`
|
||||
}
|
||||
if (item.type) {
|
||||
text += ` - ${item.type}`
|
||||
}
|
||||
if (item.textureMap) {
|
||||
text += ' ⚠️'
|
||||
}
|
||||
if (item.errors) {
|
||||
text += ' ❌'
|
||||
}
|
||||
|
||||
listItem.appendChild(document.createTextNode(text))
|
||||
list.appendChild(listItem)
|
||||
}
|
||||
|
||||
section.appendChild(list)
|
||||
return { section, count: filteredItems.length }
|
||||
}
|
||||
|
||||
// Sort results - bedrock first
|
||||
results.sort((a, b) => {
|
||||
if (a.type === 'bedrock' && b.type !== 'bedrock') return -1
|
||||
if (a.type !== 'bedrock' && b.type === 'bedrock') return 1
|
||||
return a.entity.localeCompare(b.entity)
|
||||
})
|
||||
|
||||
// Add sections
|
||||
const sections = [
|
||||
{
|
||||
title: '❌ Unsupported Entities',
|
||||
filter: (r: any) => !r.supported && !r.mappedFrom
|
||||
},
|
||||
{
|
||||
title: '⚠️ Partially Supported Entities',
|
||||
filter: (r: any) => r.mappedFrom
|
||||
},
|
||||
{
|
||||
title: '✅ Supported Entities',
|
||||
filter: (r: any) => r.supported && !r.mappedFrom
|
||||
}
|
||||
]
|
||||
|
||||
for (const { title, filter } of sections) {
|
||||
const { section, count } = createSection(title, results, filter)
|
||||
if (count > 0) {
|
||||
container.appendChild(section)
|
||||
}
|
||||
}
|
||||
|
||||
// log object with errors per entity
|
||||
const errors = results.filter(r => r.errors).map(r => ({
|
||||
entity: r.entity,
|
||||
errors: r.errors
|
||||
}))
|
||||
console.log(errors)
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
//@ts-nocheck
|
||||
import { Vec3 } from 'vec3'
|
||||
import * as THREE from 'three'
|
||||
import '../../src/getCollisionShapes'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { BasePlaygroundScene } from '../baseScene'
|
||||
import { EntityDebugFlags, EntityMesh, rendererSpecialHandled } from '../../viewer/lib/entity/EntityMesh'
|
||||
import { EntityDebugFlags, EntityMesh, rendererSpecialHandled } from '../../viewer/three/entity/EntityMesh'
|
||||
import { displayEntitiesDebugList } from '../allEntitiesDebug'
|
||||
|
||||
export default class AllEntities extends BasePlaygroundScene {
|
||||
continuousRender = false
|
||||
|
|
@ -7,159 +8,6 @@ export default class AllEntities extends BasePlaygroundScene {
|
|||
|
||||
async initData () {
|
||||
await super.initData()
|
||||
|
||||
// Create results container
|
||||
const container = document.createElement('div')
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
background: rgba(0,0,0,0.8);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
font-family: monospace;
|
||||
min-width: 400px;
|
||||
`
|
||||
document.body.appendChild(container)
|
||||
|
||||
// Add title
|
||||
const title = document.createElement('h2')
|
||||
title.textContent = 'Minecraft Entity Support'
|
||||
title.style.cssText = 'margin-top: 0; text-align: center;'
|
||||
container.appendChild(title)
|
||||
|
||||
// Test entities
|
||||
const results: Array<{
|
||||
entity: string;
|
||||
supported: boolean;
|
||||
type?: 'obj' | 'bedrock';
|
||||
mappedFrom?: string;
|
||||
textureMap?: boolean;
|
||||
errors?: string[];
|
||||
}> = []
|
||||
const { mcData } = window
|
||||
const entityNames = Object.keys(mcData.entitiesArray.reduce((acc, entity) => {
|
||||
acc[entity.name] = true
|
||||
return acc
|
||||
}, {}))
|
||||
|
||||
// Add loading indicator
|
||||
const loading = document.createElement('div')
|
||||
loading.textContent = 'Testing entities...'
|
||||
loading.style.textAlign = 'center'
|
||||
container.appendChild(loading)
|
||||
|
||||
for (const entity of entityNames) {
|
||||
const debugFlags: EntityDebugFlags = {}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new EntityMesh(this.version, entity, viewer.world, {}, debugFlags)
|
||||
|
||||
results.push({
|
||||
entity,
|
||||
supported: !!debugFlags.type || rendererSpecialHandled.includes(entity),
|
||||
type: debugFlags.type,
|
||||
mappedFrom: debugFlags.tempMap,
|
||||
textureMap: debugFlags.textureMap,
|
||||
errors: debugFlags.errors
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
results.push({
|
||||
entity,
|
||||
supported: false,
|
||||
mappedFrom: debugFlags.tempMap
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Remove loading indicator
|
||||
loading.remove()
|
||||
|
||||
const createSection = (title: string, items: any[], filter: (item: any) => boolean) => {
|
||||
const section = document.createElement('div')
|
||||
section.style.marginBottom = '20px'
|
||||
|
||||
const sectionTitle = document.createElement('h3')
|
||||
sectionTitle.textContent = title
|
||||
sectionTitle.style.textAlign = 'center'
|
||||
section.appendChild(sectionTitle)
|
||||
|
||||
const list = document.createElement('ul')
|
||||
list.style.cssText = 'padding-left: 20px; list-style-type: none; margin: 0;'
|
||||
|
||||
const filteredItems = items.filter(filter)
|
||||
for (const item of filteredItems) {
|
||||
const listItem = document.createElement('li')
|
||||
listItem.style.cssText = 'line-height: 1.4; margin: 8px 0;'
|
||||
|
||||
const entityName = document.createElement('strong')
|
||||
entityName.style.cssText = 'user-select: text;-webkit-user-select: text;'
|
||||
entityName.textContent = item.entity
|
||||
listItem.appendChild(entityName)
|
||||
|
||||
let text = ''
|
||||
if (item.mappedFrom) {
|
||||
text += ` -> ${item.mappedFrom}`
|
||||
}
|
||||
if (item.type) {
|
||||
text += ` - ${item.type}`
|
||||
}
|
||||
if (item.textureMap) {
|
||||
text += ' ⚠️'
|
||||
}
|
||||
if (item.errors) {
|
||||
text += ' ❌'
|
||||
}
|
||||
|
||||
listItem.appendChild(document.createTextNode(text))
|
||||
list.appendChild(listItem)
|
||||
}
|
||||
|
||||
section.appendChild(list)
|
||||
return { section, count: filteredItems.length }
|
||||
}
|
||||
|
||||
// Sort results - bedrock first
|
||||
results.sort((a, b) => {
|
||||
if (a.type === 'bedrock' && b.type !== 'bedrock') return -1
|
||||
if (a.type !== 'bedrock' && b.type === 'bedrock') return 1
|
||||
return a.entity.localeCompare(b.entity)
|
||||
})
|
||||
|
||||
// Add sections
|
||||
const sections = [
|
||||
{
|
||||
title: '❌ Unsupported Entities',
|
||||
filter: (r: any) => !r.supported && !r.mappedFrom
|
||||
},
|
||||
{
|
||||
title: '⚠️ Partially Supported Entities',
|
||||
filter: (r: any) => r.mappedFrom
|
||||
},
|
||||
{
|
||||
title: '✅ Supported Entities',
|
||||
filter: (r: any) => r.supported && !r.mappedFrom
|
||||
}
|
||||
]
|
||||
|
||||
for (const { title, filter } of sections) {
|
||||
const { section, count } = createSection(title, results, filter)
|
||||
if (count > 0) {
|
||||
container.appendChild(section)
|
||||
}
|
||||
}
|
||||
|
||||
// log object with errors per entity
|
||||
const errors = results.filter(r => r.errors).map(r => ({
|
||||
entity: r.entity,
|
||||
errors: r.errors
|
||||
}))
|
||||
console.log(errors)
|
||||
displayEntitiesDebugList(this.version)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
//@ts-nocheck
|
||||
import { Vec3 } from 'vec3'
|
||||
import { BasePlaygroundScene } from '../baseScene'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ function getAllMethods (obj) {
|
|||
return [...methods] as string[]
|
||||
}
|
||||
|
||||
export const delayedIterator = async <T> (arr: T[], delay: number, exec: (item: T, index: number) => void, chunkSize = 1) => {
|
||||
export const delayedIterator = async <T> (arr: T[], delay: number, exec: (item: T, index: number) => Promise<void>, chunkSize = 1) => {
|
||||
// if delay is 0 then don't use setTimeout
|
||||
for (let i = 0; i < arr.length; i += chunkSize) {
|
||||
if (delay) {
|
||||
|
|
@ -74,6 +74,6 @@ export const delayedIterator = async <T> (arr: T[], delay: number, exec: (item:
|
|||
setTimeout(resolve, delay)
|
||||
})
|
||||
}
|
||||
exec(arr[i], i)
|
||||
await exec(arr[i], i)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { defineConfig, ModifyRspackConfigUtils } from '@rsbuild/core';
|
|||
import { pluginNodePolyfill } from '@rsbuild/plugin-node-polyfill';
|
||||
import { pluginReact } from '@rsbuild/plugin-react';
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
export const appAndRendererSharedConfig = () => defineConfig({
|
||||
dev: {
|
||||
|
|
@ -60,18 +61,24 @@ export const appAndRendererSharedConfig = () => defineConfig({
|
|||
],
|
||||
tools: {
|
||||
rspack (config, helpers) {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8'))
|
||||
const hasFileProtocol = Object.values(packageJson.pnpm.overrides).some((dep) => (dep as string).startsWith('file:'))
|
||||
if (hasFileProtocol) {
|
||||
// enable node_modules watching
|
||||
config.watchOptions.ignored = /\.git/
|
||||
}
|
||||
rspackViewerConfig(config, helpers)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const rspackViewerConfig = (config, { appendPlugins, addRules, rspack }: ModifyRspackConfigUtils) => {
|
||||
appendPlugins(new rspack.NormalModuleReplacementPlugin(/data/, (resource) => {
|
||||
appendPlugins(new rspack.NormalModuleReplacementPlugin(/data|prismarine-physics/, (resource) => {
|
||||
let absolute: string
|
||||
const request = resource.request.replaceAll('\\', '/')
|
||||
absolute = path.join(resource.context, request).replaceAll('\\', '/')
|
||||
if (request.includes('minecraft-data/data/pc/1.')) {
|
||||
console.log('Error: incompatible resource', request, resource.contextInfo.issuer)
|
||||
if (request.includes('minecraft-data/data/pc/1.') || request.includes('prismarine-physics')) {
|
||||
console.log('Error: incompatible resource', request, 'from', resource.contextInfo.issuer)
|
||||
process.exit(1)
|
||||
// throw new Error(`${resource.request} was requested by ${resource.contextInfo.issuer}`)
|
||||
}
|
||||
|
|
@ -101,6 +108,10 @@ export const rspackViewerConfig = (config, { appendPlugins, addRules, rspack }:
|
|||
{
|
||||
test: /\.txt$/,
|
||||
type: 'asset/source',
|
||||
},
|
||||
{
|
||||
test: /\.log$/,
|
||||
type: 'asset/source',
|
||||
}
|
||||
])
|
||||
config.ignoreWarnings = [
|
||||
|
|
|
|||
27
renderer/viewer/baseGraphicsBackend.ts
Normal file
27
renderer/viewer/baseGraphicsBackend.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { proxy } from 'valtio'
|
||||
import { NonReactiveState, RendererReactiveState } from '../../src/appViewer'
|
||||
|
||||
export const getDefaultRendererState = (): {
|
||||
reactive: RendererReactiveState
|
||||
nonReactive: NonReactiveState
|
||||
} => {
|
||||
return {
|
||||
reactive: proxy({
|
||||
world: {
|
||||
chunksLoaded: new Set(),
|
||||
heightmaps: new Map(),
|
||||
allChunksLoaded: true,
|
||||
mesherWork: false,
|
||||
intersectMedia: null
|
||||
},
|
||||
renderer: '',
|
||||
preventEscapeMenu: false
|
||||
}),
|
||||
nonReactive: {
|
||||
world: {
|
||||
chunksLoaded: new Set(),
|
||||
chunksTotalNumber: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,88 +1,87 @@
|
|||
import { EventEmitter } from 'events'
|
||||
import { Vec3 } from 'vec3'
|
||||
import TypedEmitter from 'typed-emitter'
|
||||
import { ItemSelector } from 'mc-assets/dist/itemDefinitions'
|
||||
import { GameMode, Team } from 'mineflayer'
|
||||
import { proxy } from 'valtio'
|
||||
import { HandItemBlock } from './holdingBlock'
|
||||
import type { HandItemBlock } from '../three/holdingBlock'
|
||||
|
||||
export type MovementState = 'NOT_MOVING' | 'WALKING' | 'SPRINTING' | 'SNEAKING'
|
||||
export type ItemSpecificContextProperties = Partial<Pick<ItemSelector['properties'], 'minecraft:using_item' | 'minecraft:use_duration' | 'minecraft:use_cycle' | 'minecraft:display_context'>>
|
||||
export type CameraPerspective = 'first_person' | 'third_person_back' | 'third_person_front'
|
||||
|
||||
export type BlockShape = { position: any; width: any; height: any; depth: any; }
|
||||
export type BlocksShapes = BlockShape[]
|
||||
|
||||
export type PlayerStateEvents = {
|
||||
heldItemChanged: (item: HandItemBlock | undefined, isLeftHand: boolean) => void
|
||||
}
|
||||
// edit src/mineflayer/playerState.ts for implementation of player state from mineflayer
|
||||
export const getInitialPlayerState = () => proxy({
|
||||
playerSkin: undefined as string | undefined,
|
||||
inWater: false,
|
||||
waterBreathing: false,
|
||||
backgroundColor: [0, 0, 0] as [number, number, number],
|
||||
ambientLight: 0,
|
||||
directionalLight: 0,
|
||||
eyeHeight: 0,
|
||||
gameMode: undefined as GameMode | undefined,
|
||||
lookingAtBlock: undefined as {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
face?: number
|
||||
shapes: BlocksShapes
|
||||
} | undefined,
|
||||
diggingBlock: undefined as {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
stage: number
|
||||
face?: number
|
||||
mergedShape: BlockShape | undefined
|
||||
} | undefined,
|
||||
movementState: 'NOT_MOVING' as MovementState,
|
||||
onGround: true,
|
||||
sneaking: false,
|
||||
flying: false,
|
||||
sprinting: false,
|
||||
itemUsageTicks: 0,
|
||||
username: '',
|
||||
onlineMode: false,
|
||||
lightingDisabled: false,
|
||||
shouldHideHand: false,
|
||||
heldItemMain: undefined as HandItemBlock | undefined,
|
||||
heldItemOff: undefined as HandItemBlock | undefined,
|
||||
perspective: 'first_person' as CameraPerspective,
|
||||
onFire: false,
|
||||
|
||||
export interface IPlayerState {
|
||||
getEyeHeight(): number
|
||||
getMovementState(): MovementState
|
||||
getVelocity(): Vec3
|
||||
isOnGround(): boolean
|
||||
isSneaking(): boolean
|
||||
isFlying(): boolean
|
||||
isSprinting (): boolean
|
||||
getItemUsageTicks?(): number
|
||||
// isUsingItem?(): boolean
|
||||
getHeldItem?(isLeftHand: boolean): HandItemBlock | undefined
|
||||
username?: string
|
||||
onlineMode?: boolean
|
||||
cameraSpectatingEntity: undefined as number | undefined,
|
||||
|
||||
events: TypedEmitter<PlayerStateEvents>
|
||||
team: undefined as Team | undefined,
|
||||
})
|
||||
|
||||
reactive: {
|
||||
playerSkin: string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
export class BasePlayerState implements IPlayerState {
|
||||
reactive = proxy({
|
||||
playerSkin: undefined
|
||||
})
|
||||
protected movementState: MovementState = 'NOT_MOVING'
|
||||
protected velocity = new Vec3(0, 0, 0)
|
||||
protected onGround = true
|
||||
protected sneaking = false
|
||||
protected flying = false
|
||||
protected sprinting = false
|
||||
readonly events = new EventEmitter() as TypedEmitter<PlayerStateEvents>
|
||||
|
||||
getEyeHeight (): number {
|
||||
return 1.62
|
||||
}
|
||||
|
||||
getMovementState (): MovementState {
|
||||
return this.movementState
|
||||
}
|
||||
|
||||
getVelocity (): Vec3 {
|
||||
return this.velocity
|
||||
}
|
||||
|
||||
isOnGround (): boolean {
|
||||
return this.onGround
|
||||
}
|
||||
|
||||
isSneaking (): boolean {
|
||||
return this.sneaking
|
||||
}
|
||||
|
||||
isFlying (): boolean {
|
||||
return this.flying
|
||||
}
|
||||
|
||||
isSprinting (): boolean {
|
||||
return this.sprinting
|
||||
}
|
||||
|
||||
// For testing purposes
|
||||
setState (state: Partial<{
|
||||
movementState: MovementState
|
||||
velocity: Vec3
|
||||
onGround: boolean
|
||||
sneaking: boolean
|
||||
flying: boolean
|
||||
sprinting: boolean
|
||||
}>) {
|
||||
Object.assign(this, state)
|
||||
export const getPlayerStateUtils = (reactive: PlayerStateReactive) => ({
|
||||
isSpectator () {
|
||||
return reactive.gameMode === 'spectator'
|
||||
},
|
||||
isSpectatingEntity () {
|
||||
return reactive.cameraSpectatingEntity !== undefined && reactive.gameMode === 'spectator'
|
||||
},
|
||||
isThirdPerson () {
|
||||
if ((this as PlayerStateUtils).isSpectatingEntity()) return false
|
||||
return reactive.perspective === 'third_person_back' || reactive.perspective === 'third_person_front'
|
||||
}
|
||||
})
|
||||
|
||||
export const getInitialPlayerStateRenderer = () => ({
|
||||
reactive: getInitialPlayerState()
|
||||
})
|
||||
|
||||
export type PlayerStateReactive = ReturnType<typeof getInitialPlayerState>
|
||||
export type PlayerStateUtils = ReturnType<typeof getPlayerStateUtils>
|
||||
|
||||
export type PlayerStateRenderer = PlayerStateReactive
|
||||
|
||||
export const getItemSelector = (playerState: PlayerStateRenderer, specificProperties: ItemSpecificContextProperties, item?: import('prismarine-item').Item) => {
|
||||
return {
|
||||
...specificProperties,
|
||||
'minecraft:date': new Date(),
|
||||
// "minecraft:context_dimension": bot.entityp,
|
||||
// 'minecraft:time': bot.time.timeOfDay / 24_000,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
55
renderer/viewer/lib/createPlayerObject.ts
Normal file
55
renderer/viewer/lib/createPlayerObject.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { PlayerObject, PlayerAnimation } from 'skinview3d'
|
||||
import * as THREE from 'three'
|
||||
import { WalkingGeneralSwing } from '../three/entity/animations'
|
||||
import { loadSkinImage, stevePngUrl } from './utils/skins'
|
||||
|
||||
export type PlayerObjectType = PlayerObject & {
|
||||
animation?: PlayerAnimation
|
||||
realPlayerUuid: string
|
||||
realUsername: string
|
||||
}
|
||||
|
||||
export function createPlayerObject (options: {
|
||||
username?: string
|
||||
uuid?: string
|
||||
scale?: number
|
||||
}): {
|
||||
playerObject: PlayerObjectType
|
||||
wrapper: THREE.Group
|
||||
} {
|
||||
const wrapper = new THREE.Group()
|
||||
const playerObject = new PlayerObject() as PlayerObjectType
|
||||
|
||||
playerObject.realPlayerUuid = options.uuid ?? ''
|
||||
playerObject.realUsername = options.username ?? ''
|
||||
playerObject.position.set(0, 16, 0)
|
||||
|
||||
// fix issues with starfield
|
||||
playerObject.traverse((obj) => {
|
||||
if (obj instanceof THREE.Mesh && obj.material instanceof THREE.MeshStandardMaterial) {
|
||||
obj.material.transparent = true
|
||||
}
|
||||
})
|
||||
|
||||
wrapper.add(playerObject as any)
|
||||
const scale = options.scale ?? (1 / 16)
|
||||
wrapper.scale.set(scale, scale, scale)
|
||||
wrapper.rotation.set(0, Math.PI, 0)
|
||||
|
||||
// Set up animation
|
||||
playerObject.animation = new WalkingGeneralSwing()
|
||||
;(playerObject.animation as WalkingGeneralSwing).isMoving = false
|
||||
playerObject.animation.update(playerObject, 0)
|
||||
|
||||
return { playerObject, wrapper }
|
||||
}
|
||||
|
||||
export const applySkinToPlayerObject = async (playerObject: PlayerObjectType, skinUrl: string) => {
|
||||
return loadSkinImage(skinUrl || stevePngUrl).then(({ canvas }) => {
|
||||
const skinTexture = new THREE.CanvasTexture(canvas)
|
||||
skinTexture.magFilter = THREE.NearestFilter
|
||||
skinTexture.minFilter = THREE.NearestFilter
|
||||
skinTexture.needsUpdate = true
|
||||
playerObject.skin.map = skinTexture as any
|
||||
}).catch(console.error)
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
import { PlayerAnimation } from 'skinview3d'
|
||||
|
||||
export class WalkingGeneralSwing extends PlayerAnimation {
|
||||
|
||||
switchAnimationCallback
|
||||
|
||||
isRunning = false
|
||||
isMoving = true
|
||||
|
||||
_startArmSwing
|
||||
|
||||
swingArm() {
|
||||
this._startArmSwing = this.progress
|
||||
}
|
||||
|
||||
animate(player) {
|
||||
// Multiply by animation's natural speed
|
||||
let t
|
||||
const updateT = () => {
|
||||
if (!this.isMoving) {
|
||||
t = 0
|
||||
return
|
||||
}
|
||||
if (this.isRunning) {
|
||||
t = this.progress * 10 + Math.PI * 0.5
|
||||
} else {
|
||||
t = this.progress * 8
|
||||
}
|
||||
}
|
||||
updateT()
|
||||
let reset = false
|
||||
|
||||
if ((this.isRunning ? Math.cos(t) : Math.sin(t)) < 0.01) {
|
||||
if (this.switchAnimationCallback) {
|
||||
reset = true
|
||||
this.progress = 0
|
||||
updateT()
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isRunning) {
|
||||
// Leg swing with larger amplitude
|
||||
player.skin.leftLeg.rotation.x = Math.cos(t + Math.PI) * 1.3
|
||||
player.skin.rightLeg.rotation.x = Math.cos(t) * 1.3
|
||||
} else {
|
||||
// Leg swing
|
||||
player.skin.leftLeg.rotation.x = Math.sin(t) * 0.5
|
||||
player.skin.rightLeg.rotation.x = Math.sin(t + Math.PI) * 0.5
|
||||
}
|
||||
|
||||
if (this._startArmSwing) {
|
||||
const tHand = (this.progress - this._startArmSwing) * 18 + Math.PI * 0.5
|
||||
player.skin.rightArm.rotation.x = Math.cos(tHand) * 1.5
|
||||
const basicArmRotationZ = Math.PI * 0.1
|
||||
player.skin.rightArm.rotation.z = Math.cos(t + Math.PI) * 0.3 - basicArmRotationZ
|
||||
|
||||
if (tHand > Math.PI + Math.PI * 0.5) {
|
||||
this._startArmSwing = null
|
||||
player.skin.rightArm.rotation.z = 0
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isRunning) {
|
||||
player.skin.leftArm.rotation.x = Math.cos(t) * 1.5
|
||||
if (!this._startArmSwing) {
|
||||
player.skin.rightArm.rotation.x = Math.cos(t + Math.PI) * 1.5
|
||||
}
|
||||
const basicArmRotationZ = Math.PI * 0.1
|
||||
player.skin.leftArm.rotation.z = Math.cos(t) * 0.1 + basicArmRotationZ
|
||||
if (!this._startArmSwing) {
|
||||
player.skin.rightArm.rotation.z = Math.cos(t + Math.PI) * 0.1 - basicArmRotationZ
|
||||
}
|
||||
} else {
|
||||
// Arm swing
|
||||
player.skin.leftArm.rotation.x = Math.sin(t + Math.PI) * 0.5
|
||||
if (!this._startArmSwing) {
|
||||
player.skin.rightArm.rotation.x = Math.sin(t) * 0.5
|
||||
}
|
||||
const basicArmRotationZ = Math.PI * 0.02
|
||||
player.skin.leftArm.rotation.z = Math.cos(t) * 0.03 + basicArmRotationZ
|
||||
if (!this._startArmSwing) {
|
||||
player.skin.rightArm.rotation.z = Math.cos(t + Math.PI) * 0.03 - basicArmRotationZ
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isRunning) {
|
||||
player.rotation.z = Math.cos(t + Math.PI) * 0.01
|
||||
}
|
||||
if (this.isRunning) {
|
||||
const basicCapeRotationX = Math.PI * 0.3
|
||||
player.cape.rotation.x = Math.sin(t * 2) * 0.1 + basicCapeRotationX
|
||||
} else {
|
||||
// Always add an angle for cape around the x axis
|
||||
const basicCapeRotationX = Math.PI * 0.06
|
||||
player.cape.rotation.x = Math.sin(t / 1.5) * 0.06 + basicCapeRotationX
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
this.switchAnimationCallback()
|
||||
this.switchAnimationCallback = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,27 +2,22 @@
|
|||
import { ItemRenderer, Identifier, ItemStack, NbtString, Structure, StructureRenderer, ItemRendererResources, BlockDefinition, BlockModel, TextureAtlas, Resources, ItemModel } from 'deepslate'
|
||||
import { mat4, vec3 } from 'gl-matrix'
|
||||
import { AssetsParser } from 'mc-assets/dist/assetsParser'
|
||||
import { getLoadedImage } from 'mc-assets/dist/utils'
|
||||
import { getLoadedImage, versionToNumber } from 'mc-assets/dist/utils'
|
||||
import { BlockModel as BlockModelMcAssets, AtlasParser } from 'mc-assets'
|
||||
import { getLoadedBlockstatesStore, getLoadedModelsStore } from 'mc-assets/dist/stores'
|
||||
import { makeTextureAtlas } from 'mc-assets/dist/atlasCreator'
|
||||
import { proxy, ref } from 'valtio'
|
||||
import { getItemDefinition } from 'mc-assets/dist/itemDefinitions'
|
||||
import { versionToNumber } from '../prepare/utils'
|
||||
|
||||
export const activeGuiAtlas = proxy({
|
||||
atlas: null as null | { json, image },
|
||||
})
|
||||
|
||||
export const getNonFullBlocksModels = () => {
|
||||
let version = viewer.world.texturesVersion ?? 'latest'
|
||||
let version = appViewer.resourcesManager.currentResources!.version ?? 'latest'
|
||||
if (versionToNumber(version) < versionToNumber('1.13')) version = '1.13'
|
||||
const itemsDefinitions = viewer.world.itemsDefinitionsStore.data.latest
|
||||
const itemsDefinitions = appViewer.resourcesManager.itemsDefinitionsStore.data.latest
|
||||
const blockModelsResolved = {} as Record<string, any>
|
||||
const itemsModelsResolved = {} as Record<string, any>
|
||||
const fullBlocksWithNonStandardDisplay = [] as string[]
|
||||
const handledItemsWithDefinitions = new Set()
|
||||
const assetsParser = new AssetsParser(version, getLoadedBlockstatesStore(viewer.world.blockstatesModels), getLoadedModelsStore(viewer.world.blockstatesModels))
|
||||
const assetsParser = new AssetsParser(version, getLoadedBlockstatesStore(appViewer.resourcesManager.currentResources!.blockstatesModels), getLoadedModelsStore(appViewer.resourcesManager.currentResources!.blockstatesModels))
|
||||
|
||||
const standardGuiDisplay = {
|
||||
'rotation': [
|
||||
|
|
@ -48,13 +43,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 +64,6 @@ export const getNonFullBlocksModels = () => {
|
|||
handledItemsWithDefinitions.add(name)
|
||||
}
|
||||
if (resolvedModel?.elements) {
|
||||
|
||||
let hasStandardDisplay = true
|
||||
if (resolvedModel['display']?.gui) {
|
||||
hasStandardDisplay =
|
||||
|
|
@ -97,7 +93,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,18 +116,19 @@ export const getNonFullBlocksModels = () => {
|
|||
const RENDER_SIZE = 64
|
||||
|
||||
const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isItems = false) => {
|
||||
const img = await getLoadedImage(isItems ? viewer.world.itemsAtlasParser!.latestImage : viewer.world.blocksAtlasParser!.latestImage)
|
||||
const { currentResources } = appViewer.resourcesManager
|
||||
const imgBitmap = isItems ? currentResources!.itemsAtlasImage : currentResources!.blocksAtlasImage
|
||||
const canvasTemp = document.createElement('canvas')
|
||||
canvasTemp.width = img.width
|
||||
canvasTemp.height = img.height
|
||||
canvasTemp.width = imgBitmap.width
|
||||
canvasTemp.height = imgBitmap.height
|
||||
canvasTemp.style.imageRendering = 'pixelated'
|
||||
const ctx = canvasTemp.getContext('2d')!
|
||||
ctx.imageSmoothingEnabled = false
|
||||
ctx.drawImage(img, 0, 0)
|
||||
ctx.drawImage(imgBitmap, 0, 0)
|
||||
|
||||
const atlasParser = isItems ? viewer.world.itemsAtlasParser! : viewer.world.blocksAtlasParser!
|
||||
const atlasParser = isItems ? appViewer.resourcesManager.itemsAtlasParser : appViewer.resourcesManager.blocksAtlasParser
|
||||
const textureAtlas = new TextureAtlas(
|
||||
ctx.getImageData(0, 0, img.width, img.height),
|
||||
ctx.getImageData(0, 0, imgBitmap.width, imgBitmap.height),
|
||||
Object.fromEntries(Object.entries(atlasParser.atlas.latest.textures).map(([key, value]) => {
|
||||
return [key, [
|
||||
value.u,
|
||||
|
|
@ -145,6 +142,7 @@ const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isIt
|
|||
const PREVIEW_ID = Identifier.parse('preview:preview')
|
||||
const PREVIEW_DEFINITION = new BlockDefinition({ '': { model: PREVIEW_ID.toString() } }, undefined)
|
||||
|
||||
let textureWasRequested = false
|
||||
let modelData: any
|
||||
let currentModelName: string | undefined
|
||||
const resources: ItemRendererResources = {
|
||||
|
|
@ -155,6 +153,7 @@ const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isIt
|
|||
return null
|
||||
},
|
||||
getTextureUV (texture) {
|
||||
textureWasRequested = true
|
||||
return textureAtlas.getTextureUV(texture.toString().replace('minecraft:', '').replace('block/', '').replace('item/', '').replace('blocks/', '').replace('items/', '') as any)
|
||||
},
|
||||
getTextureAtlas () {
|
||||
|
|
@ -203,6 +202,7 @@ const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isIt
|
|||
const renderer = new ItemRenderer(gl, item, resources, { display_context: 'gui' })
|
||||
const missingTextures = new Set()
|
||||
for (const [modelName, model] of Object.entries(models)) {
|
||||
textureWasRequested = false
|
||||
if (includeOnly.length && !includeOnly.includes(modelName)) continue
|
||||
|
||||
const patchMissingTextures = () => {
|
||||
|
|
@ -224,6 +224,7 @@ const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isIt
|
|||
if (!modelData) continue
|
||||
renderer.setItem(item, { display_context: 'gui' })
|
||||
renderer.drawItem()
|
||||
if (!textureWasRequested) continue
|
||||
const url = canvas.toDataURL()
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const img = await getLoadedImage(url)
|
||||
|
|
@ -237,6 +238,9 @@ const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isIt
|
|||
return images
|
||||
}
|
||||
|
||||
/**
|
||||
* @mainThread
|
||||
*/
|
||||
const generateAtlas = async (images: Record<string, HTMLImageElement>) => {
|
||||
const atlas = makeTextureAtlas({
|
||||
input: Object.keys(images),
|
||||
|
|
@ -254,9 +258,9 @@ const generateAtlas = async (images: Record<string, HTMLImageElement>) => {
|
|||
// a.download = 'blocks_atlas.png'
|
||||
// a.click()
|
||||
|
||||
activeGuiAtlas.atlas = {
|
||||
appViewer.resourcesManager.currentResources!.guiAtlas = {
|
||||
json: atlas.json,
|
||||
image: ref(await getLoadedImage(atlas.canvas.toDataURL())),
|
||||
image: await createImageBitmap(atlas.canvas),
|
||||
}
|
||||
|
||||
return atlas
|
||||
|
|
@ -273,5 +277,6 @@ export const generateGuiAtlas = async () => {
|
|||
const itemImages = await generateItemsGui(itemsModelsResolved, true)
|
||||
console.timeEnd('generate items gui atlas')
|
||||
await generateAtlas({ ...blockImages, ...itemImages })
|
||||
appViewer.resourcesManager.currentResources!.guiAtlasVersion++
|
||||
// await generateAtlas(blockImages)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Vec3 } from 'vec3'
|
|||
import { World } from './world'
|
||||
import { getSectionGeometry, setBlockStatesData as setMesherData } from './models'
|
||||
import { BlockStateModelInfo } from './shared'
|
||||
import { INVISIBLE_BLOCKS } from './worldConstants'
|
||||
|
||||
globalThis.structuredClone ??= (value) => JSON.parse(JSON.stringify(value))
|
||||
|
||||
|
|
@ -53,7 +54,7 @@ function setSectionDirty (pos, value = true) {
|
|||
const key = sectionKey(x, y, z)
|
||||
if (!value) {
|
||||
dirtySections.delete(key)
|
||||
postMessage({ type: 'sectionFinished', key })
|
||||
postMessage({ type: 'sectionFinished', key, workerIndex })
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -61,7 +62,7 @@ function setSectionDirty (pos, value = true) {
|
|||
if (chunk?.getSection(pos)) {
|
||||
dirtySections.set(key, (dirtySections.get(key) || 0) + 1)
|
||||
} else {
|
||||
postMessage({ type: 'sectionFinished', key })
|
||||
postMessage({ type: 'sectionFinished', key, workerIndex })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -76,6 +77,7 @@ const handleMessage = data => {
|
|||
|
||||
if (data.type === 'mcData') {
|
||||
globalVar.mcData = data.mcData
|
||||
globalVar.loadedData = data.mcData
|
||||
}
|
||||
|
||||
if (data.config) {
|
||||
|
|
@ -121,11 +123,13 @@ 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) {
|
||||
world.customBlockModels.set(chunkKey, data.customBlockModels)
|
||||
world?.customBlockModels.set(chunkKey, data.customBlockModels)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
|
@ -135,8 +139,40 @@ const handleMessage = data => {
|
|||
dirtySections = new Map()
|
||||
// todo also remove cached
|
||||
globalVar.mcData = null
|
||||
globalVar.loadedData = null
|
||||
allDataReady = false
|
||||
|
||||
break
|
||||
}
|
||||
case 'getCustomBlockModel': {
|
||||
const pos = new Vec3(data.pos.x, data.pos.y, data.pos.z)
|
||||
const chunkKey = `${Math.floor(pos.x / 16) * 16},${Math.floor(pos.z / 16) * 16}`
|
||||
const customBlockModel = world.customBlockModels.get(chunkKey)?.[`${pos.x},${pos.y},${pos.z}`]
|
||||
global.postMessage({ type: 'customBlockModel', chunkKey, customBlockModel })
|
||||
break
|
||||
}
|
||||
case 'getHeightmap': {
|
||||
const heightmap = new Uint8Array(256)
|
||||
|
||||
const blockPos = new Vec3(0, 0, 0)
|
||||
for (let z = 0; z < 16; z++) {
|
||||
for (let x = 0; x < 16; x++) {
|
||||
const blockX = x + data.x
|
||||
const blockZ = z + data.z
|
||||
blockPos.x = blockX
|
||||
blockPos.z = blockZ
|
||||
blockPos.y = world.config.worldMaxY
|
||||
let block = world.getBlock(blockPos)
|
||||
while (block && INVISIBLE_BLOCKS.has(block.name) && blockPos.y > world.config.worldMinY) {
|
||||
blockPos.y -= 1
|
||||
block = world.getBlock(blockPos)
|
||||
}
|
||||
const index = z * 16 + x
|
||||
heightmap[index] = block ? blockPos.y : 0
|
||||
}
|
||||
}
|
||||
postMessage({ type: 'heightmap', key: `${Math.floor(data.x / 16)},${Math.floor(data.z / 16)}`, heightmap })
|
||||
|
||||
break
|
||||
}
|
||||
// No default
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Vec3 } from 'vec3'
|
|||
import worldBlockProvider, { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider'
|
||||
import legacyJson from '../../../../src/preflatMap.json'
|
||||
import { BlockType } from '../../../playground/shared'
|
||||
import { World, BlockModelPartsResolved, WorldBlock as Block } from './world'
|
||||
import { World, BlockModelPartsResolved, WorldBlock as Block, WorldBlock } from './world'
|
||||
import { BlockElement, buildRotationMatrix, elemFaces, matmul3, matmulmat3, vecadd3, vecsub3 } from './modelsGeometryCommon'
|
||||
import { INVISIBLE_BLOCKS } from './worldConstants'
|
||||
import { MesherGeometryOutput, HighestBlockInfo } from './shared'
|
||||
|
|
@ -103,7 +103,8 @@ function tintToGl (tint) {
|
|||
return [r / 255, g / 255, b / 255]
|
||||
}
|
||||
|
||||
function getLiquidRenderHeight (world, block, type, pos) {
|
||||
function getLiquidRenderHeight (world: World, block: WorldBlock | null, type: number, pos: Vec3, isWater: boolean, isRealWater: boolean) {
|
||||
if ((isWater && !isRealWater) || (block && isBlockWaterlogged(block))) return 8 / 9
|
||||
if (!block || block.type !== type) return 1 / 9
|
||||
if (block.metadata === 0) { // source block
|
||||
const blockAbove = world.getBlock(pos.offset(0, 1, 0))
|
||||
|
|
@ -124,12 +125,19 @@ const isCube = (block: Block) => {
|
|||
}))
|
||||
}
|
||||
|
||||
function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, type: number, biome: string, water: boolean, attr: Record<string, any>) {
|
||||
const getVec = (v: Vec3, dir: Vec3) => {
|
||||
for (const coord of ['x', 'y', 'z']) {
|
||||
if (Math.abs(dir[coord]) > 0) v[coord] = 0
|
||||
}
|
||||
return v.plus(dir)
|
||||
}
|
||||
|
||||
function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, type: number, biome: string, water: boolean, attr: MesherGeometryOutput, isRealWater: boolean) {
|
||||
const heights: number[] = []
|
||||
for (let z = -1; z <= 1; z++) {
|
||||
for (let x = -1; x <= 1; x++) {
|
||||
const pos = cursor.offset(x, 0, z)
|
||||
heights.push(getLiquidRenderHeight(world, world.getBlock(pos), type, pos))
|
||||
heights.push(getLiquidRenderHeight(world, world.getBlock(pos), type, pos, water, isRealWater))
|
||||
}
|
||||
}
|
||||
const cornerHeights = [
|
||||
|
|
@ -141,15 +149,14 @@ function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, typ
|
|||
|
||||
// eslint-disable-next-line guard-for-in
|
||||
for (const face in elemFaces) {
|
||||
const { dir, corners } = elemFaces[face]
|
||||
const { dir, corners, mask1, mask2 } = elemFaces[face]
|
||||
const isUp = dir[1] === 1
|
||||
|
||||
const neighborPos = cursor.offset(...dir as [number, number, number])
|
||||
const neighbor = world.getBlock(neighborPos)
|
||||
if (!neighbor) continue
|
||||
if (neighbor.type === type) continue
|
||||
const isGlass = neighbor.name.includes('glass')
|
||||
if ((isCube(neighbor) && !isUp) || neighbor.material === 'plant' || neighbor.getProperties().waterlogged) continue
|
||||
if (neighbor.type === type || (water && (neighbor.name === 'water' || isBlockWaterlogged(neighbor)))) continue
|
||||
if (isCube(neighbor) && !isUp) continue
|
||||
|
||||
let tint = [1, 1, 1]
|
||||
if (water) {
|
||||
|
|
@ -180,16 +187,44 @@ function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, typ
|
|||
const { su } = texture
|
||||
const { sv } = texture
|
||||
|
||||
// Get base light value for the face
|
||||
const baseLight = world.getLight(neighborPos, undefined, undefined, water ? 'water' : 'lava') / 15
|
||||
|
||||
for (const pos of corners) {
|
||||
const height = cornerHeights[pos[2] * 2 + pos[0]]
|
||||
attr.t_positions.push(
|
||||
(pos[0] ? 0.999 : 0.001) + (cursor.x & 15) - 8,
|
||||
(pos[1] ? height - 0.001 : 0.001) + (cursor.y & 15) - 8,
|
||||
(pos[2] ? 0.999 : 0.001) + (cursor.z & 15) - 8
|
||||
const OFFSET = 0.0001
|
||||
attr.t_positions!.push(
|
||||
(pos[0] ? 1 - OFFSET : OFFSET) + (cursor.x & 15) - 8,
|
||||
(pos[1] ? height - OFFSET : OFFSET) + (cursor.y & 15) - 8,
|
||||
(pos[2] ? 1 - OFFSET : OFFSET) + (cursor.z & 15) - 8
|
||||
)
|
||||
attr.t_normals.push(...dir)
|
||||
attr.t_uvs.push(pos[3] * su + u, pos[4] * sv * (pos[1] ? 1 : height) + v)
|
||||
attr.t_colors.push(tint[0], tint[1], tint[2])
|
||||
attr.t_normals!.push(...dir)
|
||||
attr.t_uvs!.push(pos[3] * su + u, pos[4] * sv * (pos[1] ? 1 : height) + v)
|
||||
|
||||
let cornerLightResult = baseLight
|
||||
if (world.config.smoothLighting) {
|
||||
const dx = pos[0] * 2 - 1
|
||||
const dy = pos[1] * 2 - 1
|
||||
const dz = pos[2] * 2 - 1
|
||||
const cornerDir: [number, number, number] = [dx, dy, dz]
|
||||
const side1Dir: [number, number, number] = [dx * mask1[0], dy * mask1[1], dz * mask1[2]]
|
||||
const side2Dir: [number, number, number] = [dx * mask2[0], dy * mask2[1], dz * mask2[2]]
|
||||
|
||||
const dirVec = new Vec3(...dir as [number, number, number])
|
||||
|
||||
const side1LightDir = getVec(new Vec3(...side1Dir), dirVec)
|
||||
const side1Light = world.getLight(cursor.plus(side1LightDir)) / 15
|
||||
const side2DirLight = getVec(new Vec3(...side2Dir), dirVec)
|
||||
const side2Light = world.getLight(cursor.plus(side2DirLight)) / 15
|
||||
const cornerLightDir = getVec(new Vec3(...cornerDir), dirVec)
|
||||
const cornerLight = world.getLight(cursor.plus(cornerLightDir)) / 15
|
||||
// interpolate
|
||||
const lights = [side1Light, side2Light, cornerLight, baseLight]
|
||||
cornerLightResult = lights.reduce((acc, cur) => acc + cur, 0) / lights.length
|
||||
}
|
||||
|
||||
// Apply light value to tint
|
||||
attr.t_colors!.push(tint[0] * cornerLightResult, tint[1] * cornerLightResult, tint[2] * cornerLightResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -253,7 +288,7 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO:
|
|||
if (!neighbor.transparent && (isCube(neighbor) || identicalCull(element, neighbor, new Vec3(...dir)))) continue
|
||||
} else {
|
||||
needSectionRecomputeOnChange = true
|
||||
continue
|
||||
// continue
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -301,7 +336,7 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO:
|
|||
let localShift = null as any
|
||||
|
||||
if (element.rotation && !needTiles) {
|
||||
// todo do we support rescale?
|
||||
// Rescale support for block model rotations
|
||||
localMatrix = buildRotationMatrix(
|
||||
element.rotation.axis,
|
||||
element.rotation.angle
|
||||
|
|
@ -314,6 +349,37 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO:
|
|||
element.rotation.origin
|
||||
)
|
||||
)
|
||||
|
||||
// Apply rescale if specified
|
||||
if (element.rotation.rescale) {
|
||||
const FIT_TO_BLOCK_SCALE_MULTIPLIER = 2 - Math.sqrt(2)
|
||||
const angleRad = element.rotation.angle * Math.PI / 180
|
||||
const scale = Math.abs(Math.sin(angleRad)) * FIT_TO_BLOCK_SCALE_MULTIPLIER
|
||||
|
||||
// Get axis vector components (1 for the rotation axis, 0 for others)
|
||||
const axisX = element.rotation.axis === 'x' ? 1 : 0
|
||||
const axisY = element.rotation.axis === 'y' ? 1 : 0
|
||||
const axisZ = element.rotation.axis === 'z' ? 1 : 0
|
||||
|
||||
// Create scale matrix: scale = (1 - axisComponent) * scaleFactor + 1
|
||||
const scaleMatrix = [
|
||||
[(1 - axisX) * scale + 1, 0, 0],
|
||||
[0, (1 - axisY) * scale + 1, 0],
|
||||
[0, 0, (1 - axisZ) * scale + 1]
|
||||
]
|
||||
|
||||
// Apply scaling to the transformation matrix
|
||||
localMatrix = matmulmat3(localMatrix, scaleMatrix)
|
||||
|
||||
// Recalculate shift with the new matrix
|
||||
localShift = vecsub3(
|
||||
element.rotation.origin,
|
||||
matmul3(
|
||||
localMatrix,
|
||||
element.rotation.origin
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const aos: number[] = []
|
||||
|
|
@ -423,13 +489,19 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO:
|
|||
|
||||
if (!needTiles) {
|
||||
if (doAO && aos[0] + aos[3] >= aos[1] + aos[2]) {
|
||||
attr.indices.push(
|
||||
ndx, ndx + 3, ndx + 2, ndx, ndx + 1, ndx + 3
|
||||
)
|
||||
attr.indices[attr.indicesCount++] = ndx
|
||||
attr.indices[attr.indicesCount++] = ndx + 3
|
||||
attr.indices[attr.indicesCount++] = ndx + 2
|
||||
attr.indices[attr.indicesCount++] = ndx
|
||||
attr.indices[attr.indicesCount++] = ndx + 1
|
||||
attr.indices[attr.indicesCount++] = ndx + 3
|
||||
} else {
|
||||
attr.indices.push(
|
||||
ndx, ndx + 1, ndx + 2, ndx + 2, ndx + 1, ndx + 3
|
||||
)
|
||||
attr.indices[attr.indicesCount++] = ndx
|
||||
attr.indices[attr.indicesCount++] = ndx + 1
|
||||
attr.indices[attr.indicesCount++] = ndx + 2
|
||||
attr.indices[attr.indicesCount++] = ndx + 2
|
||||
attr.indices[attr.indicesCount++] = ndx + 1
|
||||
attr.indices[attr.indicesCount++] = ndx + 3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -447,7 +519,7 @@ const isBlockWaterlogged = (block: Block) => {
|
|||
}
|
||||
|
||||
let unknownBlockModel: BlockModelPartsResolved
|
||||
export function getSectionGeometry (sx, sy, sz, world: World) {
|
||||
export function getSectionGeometry (sx: number, sy: number, sz: number, world: World) {
|
||||
let delayedRender = [] as Array<() => void>
|
||||
|
||||
const attr: MesherGeometryOutput = {
|
||||
|
|
@ -463,12 +535,13 @@ export function getSectionGeometry (sx, sy, sz, world: World) {
|
|||
t_colors: [],
|
||||
t_uvs: [],
|
||||
indices: [],
|
||||
indicesCount: 0, // Track current index position
|
||||
using32Array: true,
|
||||
tiles: {},
|
||||
// todo this can be removed here
|
||||
heads: {},
|
||||
signs: {},
|
||||
// isFull: true,
|
||||
highestBlocks: new Map<string, HighestBlockInfo>([]),
|
||||
hadErrors: false,
|
||||
blocksCount: 0
|
||||
}
|
||||
|
|
@ -478,12 +551,6 @@ export function getSectionGeometry (sx, sy, sz, world: World) {
|
|||
for (cursor.z = sz; cursor.z < sz + 16; cursor.z++) {
|
||||
for (cursor.x = sx; cursor.x < sx + 16; cursor.x++) {
|
||||
let block = world.getBlock(cursor, blockProvider, attr)!
|
||||
if (!INVISIBLE_BLOCKS.has(block.name)) {
|
||||
const highest = attr.highestBlocks.get(`${cursor.x},${cursor.z}`)
|
||||
if (!highest || highest.y < cursor.y) {
|
||||
attr.highestBlocks.set(`${cursor.x},${cursor.z}`, { y: cursor.y, stateId: block.stateId, biomeId: block.biome.id })
|
||||
}
|
||||
}
|
||||
if (INVISIBLE_BLOCKS.has(block.name)) continue
|
||||
if ((block.name.includes('_sign') || block.name === 'sign') && !world.config.disableSignsMapsSupport) {
|
||||
const key = `${cursor.x},${cursor.y},${cursor.z}`
|
||||
|
|
@ -539,11 +606,11 @@ export function getSectionGeometry (sx, sy, sz, world: World) {
|
|||
const pos = cursor.clone()
|
||||
// eslint-disable-next-line @typescript-eslint/no-loop-func
|
||||
delayedRender.push(() => {
|
||||
renderLiquid(world, pos, blockProvider.getTextureInfo('water_still'), block.type, biome, true, attr)
|
||||
renderLiquid(world, pos, blockProvider.getTextureInfo('water_still'), block.type, biome, true, attr, !isWaterlogged)
|
||||
})
|
||||
attr.blocksCount++
|
||||
} else if (block.name === 'lava') {
|
||||
renderLiquid(world, cursor, blockProvider.getTextureInfo('lava_still'), block.type, biome, false, attr)
|
||||
renderLiquid(world, cursor, blockProvider.getTextureInfo('lava_still'), block.type, biome, false, attr, false)
|
||||
attr.blocksCount++
|
||||
}
|
||||
if (block.name !== 'water' && block.name !== 'lava' && !INVISIBLE_BLOCKS.has(block.name)) {
|
||||
|
|
@ -605,12 +672,19 @@ export function getSectionGeometry (sx, sy, sz, world: World) {
|
|||
|
||||
let ndx = attr.positions.length / 3
|
||||
for (let i = 0; i < attr.t_positions!.length / 12; i++) {
|
||||
attr.indices.push(
|
||||
ndx, ndx + 1, ndx + 2, ndx + 2, ndx + 1, ndx + 3,
|
||||
// eslint-disable-next-line @stylistic/function-call-argument-newline
|
||||
// back face
|
||||
ndx, ndx + 2, ndx + 1, ndx + 2, ndx + 3, ndx + 1
|
||||
)
|
||||
attr.indices[attr.indicesCount++] = ndx
|
||||
attr.indices[attr.indicesCount++] = ndx + 1
|
||||
attr.indices[attr.indicesCount++] = ndx + 2
|
||||
attr.indices[attr.indicesCount++] = ndx + 2
|
||||
attr.indices[attr.indicesCount++] = ndx + 1
|
||||
attr.indices[attr.indicesCount++] = ndx + 3
|
||||
// back face
|
||||
attr.indices[attr.indicesCount++] = ndx
|
||||
attr.indices[attr.indicesCount++] = ndx + 2
|
||||
attr.indices[attr.indicesCount++] = ndx + 1
|
||||
attr.indices[attr.indicesCount++] = ndx + 2
|
||||
attr.indices[attr.indicesCount++] = ndx + 3
|
||||
attr.indices[attr.indicesCount++] = ndx + 1
|
||||
ndx += 4
|
||||
}
|
||||
|
||||
|
|
@ -628,6 +702,12 @@ export function getSectionGeometry (sx, sy, sz, world: World) {
|
|||
attr.normals = new Float32Array(attr.normals) as any
|
||||
attr.colors = new Float32Array(attr.colors) as any
|
||||
attr.uvs = new Float32Array(attr.uvs) as any
|
||||
attr.using32Array = arrayNeedsUint32(attr.indices)
|
||||
if (attr.using32Array) {
|
||||
attr.indices = new Uint32Array(attr.indices)
|
||||
} else {
|
||||
attr.indices = new Uint16Array(attr.indices)
|
||||
}
|
||||
|
||||
if (needTiles) {
|
||||
delete attr.positions
|
||||
|
|
@ -639,6 +719,21 @@ export function getSectionGeometry (sx, sy, sz, world: World) {
|
|||
return attr
|
||||
}
|
||||
|
||||
// copied from three.js
|
||||
function arrayNeedsUint32 (array) {
|
||||
|
||||
// assumes larger values usually on last
|
||||
|
||||
for (let i = array.length - 1; i >= 0; -- i) {
|
||||
|
||||
if (array[i] >= 65_535) return true // account for PRIMITIVE_RESTART_FIXED_INDEX, #24565
|
||||
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
}
|
||||
|
||||
export const setBlockStatesData = (blockstatesModels, blocksAtlas: any, _needTiles = false, useUnknownBlockModel = true, version = 'latest') => {
|
||||
blockProvider = worldBlockProvider(blockstatesModels, blocksAtlas, version)
|
||||
globalThis.blockProvider = blockProvider
|
||||
|
|
|
|||
|
|
@ -122,10 +122,10 @@ export const elemFaces = {
|
|||
mask1: [1, 0, 1],
|
||||
mask2: [0, 1, 1],
|
||||
corners: [
|
||||
[1, 0, 0, 0, 1],
|
||||
[0, 0, 0, 1, 1],
|
||||
[1, 1, 0, 0, 0],
|
||||
[0, 1, 0, 1, 0]
|
||||
[1, 0, 0, 1, 1],
|
||||
[0, 0, 0, 0, 1],
|
||||
[1, 1, 0, 1, 0],
|
||||
[0, 1, 0, 0, 0]
|
||||
]
|
||||
},
|
||||
south: {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import { BlockType } from '../../../playground/shared'
|
||||
|
||||
// only here for easier testing
|
||||
export const defaultMesherConfig = {
|
||||
version: '',
|
||||
worldMaxY: 256,
|
||||
worldMinY: 0,
|
||||
enableLighting: true,
|
||||
skyLight: 15,
|
||||
smoothLighting: true,
|
||||
outputFormat: 'threeJs' as 'threeJs' | 'webgpu',
|
||||
textureSize: 1024, // for testing
|
||||
// textureSize: 1024, // for testing
|
||||
debugModelVariant: undefined as undefined | number[],
|
||||
clipWorldBelowY: undefined as undefined | number,
|
||||
disableSignsMapsSupport: false
|
||||
|
|
@ -32,17 +35,27 @@ export type MesherGeometryOutput = {
|
|||
t_colors?: number[],
|
||||
t_uvs?: number[],
|
||||
|
||||
indices: number[],
|
||||
indices: Uint32Array | Uint16Array | number[],
|
||||
indicesCount: number,
|
||||
using32Array: boolean,
|
||||
tiles: Record<string, BlockType>,
|
||||
heads: Record<string, any>,
|
||||
signs: Record<string, any>,
|
||||
// isFull: boolean
|
||||
highestBlocks: Map<string, HighestBlockInfo>
|
||||
hadErrors: boolean
|
||||
blocksCount: number
|
||||
customBlockModels?: CustomBlockModels
|
||||
}
|
||||
|
||||
export interface MesherMainEvents {
|
||||
geometry: { type: 'geometry'; key: string; geometry: MesherGeometryOutput; workerIndex: number };
|
||||
sectionFinished: { type: 'sectionFinished'; key: string; workerIndex: number; processTime?: number };
|
||||
blockStateModelInfo: { type: 'blockStateModelInfo'; info: Record<string, BlockStateModelInfo> };
|
||||
heightmap: { type: 'heightmap'; key: string; heightmap: Uint8Array };
|
||||
}
|
||||
|
||||
export type MesherMainEvent = MesherMainEvents[keyof MesherMainEvents]
|
||||
|
||||
export type HighestBlockInfo = { y: number, stateId: number | undefined, biomeId: number | undefined }
|
||||
|
||||
export type BlockStateModelInfo = {
|
||||
|
|
|
|||
|
|
@ -49,8 +49,6 @@ test('Known blocks are not rendered', () => {
|
|||
// TODO resolve creaking_heart issue (1.21.3)
|
||||
expect(missingBlocks).toMatchInlineSnapshot(`
|
||||
{
|
||||
"end_gateway": true,
|
||||
"end_portal": true,
|
||||
"structure_void": true,
|
||||
}
|
||||
`)
|
||||
|
|
|
|||
131
renderer/viewer/lib/mesherlogReader.ts
Normal file
131
renderer/viewer/lib/mesherlogReader.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
/* eslint-disable no-await-in-loop */
|
||||
import { Vec3 } from 'vec3'
|
||||
|
||||
// import log from '../../../../../Downloads/mesher (2).log'
|
||||
import { WorldRendererCommon } from './worldrendererCommon'
|
||||
const log = ''
|
||||
|
||||
|
||||
export class MesherLogReader {
|
||||
chunksToReceive: Array<{
|
||||
x: number
|
||||
z: number
|
||||
chunkLength: number
|
||||
}> = []
|
||||
messagesQueue: Array<{
|
||||
fromWorker: boolean
|
||||
workerIndex: number
|
||||
message: any
|
||||
}> = []
|
||||
|
||||
sectionFinishedToReceive = null as {
|
||||
messagesLeft: string[]
|
||||
resolve: () => void
|
||||
} | null
|
||||
replayStarted = false
|
||||
|
||||
constructor (private readonly worldRenderer: WorldRendererCommon) {
|
||||
this.parseMesherLog()
|
||||
}
|
||||
|
||||
chunkReceived (x: number, z: number, chunkLength: number) {
|
||||
// remove existing chunks with same x and z
|
||||
const existingChunkIndex = this.chunksToReceive.findIndex(chunk => chunk.x === x && chunk.z === z)
|
||||
if (existingChunkIndex === -1) {
|
||||
// console.error('Chunk not found', x, z)
|
||||
} else {
|
||||
// warn if chunkLength is different
|
||||
if (this.chunksToReceive[existingChunkIndex].chunkLength !== chunkLength) {
|
||||
// console.warn('Chunk length mismatch', x, z, this.chunksToReceive[existingChunkIndex].chunkLength, chunkLength)
|
||||
}
|
||||
// remove chunk
|
||||
this.chunksToReceive = this.chunksToReceive.filter((chunk, index) => chunk.x !== x || chunk.z !== z)
|
||||
}
|
||||
this.maybeStartReplay()
|
||||
}
|
||||
|
||||
async maybeStartReplay () {
|
||||
if (this.chunksToReceive.length !== 0 || this.replayStarted) return
|
||||
const lines = log.split('\n')
|
||||
console.log('starting replay')
|
||||
this.replayStarted = true
|
||||
const waitForWorkersMessages = async () => {
|
||||
if (!this.sectionFinishedToReceive) return
|
||||
await new Promise<void>(resolve => {
|
||||
this.sectionFinishedToReceive!.resolve = resolve
|
||||
})
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes('dispatchMessages dirty')) {
|
||||
await waitForWorkersMessages()
|
||||
this.worldRenderer.stopMesherMessagesProcessing = true
|
||||
const message = JSON.parse(line.slice(line.indexOf('{'), line.lastIndexOf('}') + 1))
|
||||
if (!message.value) continue
|
||||
const index = line.split(' ')[1]
|
||||
const type = line.split(' ')[3]
|
||||
// console.log('sending message', message.x, message.y, message.z)
|
||||
this.worldRenderer.forceCallFromMesherReplayer = true
|
||||
this.worldRenderer.setSectionDirty(new Vec3(message.x, message.y, message.z), message.value)
|
||||
this.worldRenderer.forceCallFromMesherReplayer = false
|
||||
}
|
||||
if (line.includes('-> blockUpdate')) {
|
||||
await waitForWorkersMessages()
|
||||
this.worldRenderer.stopMesherMessagesProcessing = true
|
||||
const message = JSON.parse(line.slice(line.indexOf('{'), line.lastIndexOf('}') + 1))
|
||||
this.worldRenderer.forceCallFromMesherReplayer = true
|
||||
this.worldRenderer.setBlockStateIdInner(new Vec3(message.pos.x, message.pos.y, message.pos.z), message.stateId)
|
||||
this.worldRenderer.forceCallFromMesherReplayer = false
|
||||
}
|
||||
|
||||
if (line.includes(' sectionFinished ')) {
|
||||
if (!this.sectionFinishedToReceive) {
|
||||
console.log('starting worker message processing validating')
|
||||
this.worldRenderer.stopMesherMessagesProcessing = false
|
||||
this.sectionFinishedToReceive = {
|
||||
messagesLeft: [],
|
||||
resolve: () => {
|
||||
this.sectionFinishedToReceive = null
|
||||
}
|
||||
}
|
||||
}
|
||||
const parts = line.split(' ')
|
||||
const coordsPart = parts.find(part => part.split(',').length === 3)
|
||||
if (!coordsPart) throw new Error(`no coords part found ${line}`)
|
||||
const [x, y, z] = coordsPart.split(',').map(Number)
|
||||
this.sectionFinishedToReceive.messagesLeft.push(`${x},${y},${z}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
workerMessageReceived (type: string, message: any) {
|
||||
if (type === 'sectionFinished') {
|
||||
const { key } = message
|
||||
if (!this.sectionFinishedToReceive) {
|
||||
console.warn(`received sectionFinished message but no sectionFinishedToReceive ${key}`)
|
||||
return
|
||||
}
|
||||
|
||||
const idx = this.sectionFinishedToReceive.messagesLeft.indexOf(key)
|
||||
if (idx === -1) {
|
||||
console.warn(`received sectionFinished message for non-outstanding section ${key}`)
|
||||
return
|
||||
}
|
||||
this.sectionFinishedToReceive.messagesLeft.splice(idx, 1)
|
||||
if (this.sectionFinishedToReceive.messagesLeft.length === 0) {
|
||||
this.sectionFinishedToReceive.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parseMesherLog () {
|
||||
const lines = log.split('\n')
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('-> chunk')) {
|
||||
const chunk = JSON.parse(line.slice('-> chunk'.length))
|
||||
this.chunksToReceive.push(chunk)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { fromFormattedString } from '@xmcl/text-component'
|
||||
|
||||
export const formattedStringToSimpleString = (str) => {
|
||||
const result = fromFormattedString(str)
|
||||
str = result.text
|
||||
// todo recursive
|
||||
for (const extra of result.extra) {
|
||||
str += extra.text
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import * as THREE from 'three'
|
||||
|
||||
export const disposeObject = (obj: THREE.Object3D, cleanTextures = false) => {
|
||||
// not cleaning texture there as it might be used by other objects, but would be good to also do that
|
||||
if (obj instanceof THREE.Mesh) {
|
||||
obj.geometry?.dispose?.()
|
||||
obj.material?.dispose?.()
|
||||
}
|
||||
if (obj.children) {
|
||||
// eslint-disable-next-line unicorn/no-array-for-each
|
||||
obj.children.forEach(child => disposeObject(child, cleanTextures))
|
||||
}
|
||||
if (cleanTextures) {
|
||||
if (obj instanceof THREE.Mesh) {
|
||||
obj.material?.map?.dispose?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ let lastY = 40
|
|||
export const addNewStat = (id: string, width = 80, x = rightOffset, y = lastY) => {
|
||||
const pane = document.createElement('div')
|
||||
pane.style.position = 'fixed'
|
||||
pane.style.top = `${y}px`
|
||||
pane.style.top = `${y ?? lastY}px`
|
||||
pane.style.right = `${x}px`
|
||||
// gray bg
|
||||
pane.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'
|
||||
|
|
@ -19,7 +19,7 @@ export const addNewStat = (id: string, width = 80, x = rightOffset, y = lastY) =
|
|||
pane.style.pointerEvents = 'none'
|
||||
document.body.appendChild(pane)
|
||||
stats[id] = pane
|
||||
if (y === 0) { // otherwise it's a custom position
|
||||
if (y === undefined && x === rightOffset) { // otherwise it's a custom position
|
||||
// rightOffset += width
|
||||
lastY += 20
|
||||
}
|
||||
|
|
@ -35,11 +35,75 @@ export const addNewStat = (id: string, width = 80, x = rightOffset, y = lastY) =
|
|||
}
|
||||
}
|
||||
|
||||
export const addNewStat2 = (id: string, { top, bottom, right, left, displayOnlyWhenWider }: { top?: number, bottom?: number, right?: number, left?: number, displayOnlyWhenWider?: number }) => {
|
||||
if (top === undefined && bottom === undefined) top = 0
|
||||
const pane = document.createElement('div')
|
||||
pane.style.position = 'fixed'
|
||||
if (top !== undefined) {
|
||||
pane.style.top = `${top}px`
|
||||
}
|
||||
if (bottom !== undefined) {
|
||||
pane.style.bottom = `${bottom}px`
|
||||
}
|
||||
if (left !== undefined) {
|
||||
pane.style.left = `${left}px`
|
||||
}
|
||||
if (right !== undefined) {
|
||||
pane.style.right = `${right}px`
|
||||
}
|
||||
// gray bg
|
||||
pane.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'
|
||||
pane.style.color = 'white'
|
||||
pane.style.padding = '2px'
|
||||
pane.style.fontFamily = 'monospace'
|
||||
pane.style.fontSize = '12px'
|
||||
pane.style.zIndex = '10000'
|
||||
pane.style.pointerEvents = 'none'
|
||||
document.body.appendChild(pane)
|
||||
stats[id] = pane
|
||||
|
||||
const resizeCheck = () => {
|
||||
if (!displayOnlyWhenWider) return
|
||||
pane.style.display = window.innerWidth > displayOnlyWhenWider ? 'block' : 'none'
|
||||
}
|
||||
window.addEventListener('resize', resizeCheck)
|
||||
resizeCheck()
|
||||
|
||||
return {
|
||||
updateText (text: string) {
|
||||
pane.innerText = text
|
||||
},
|
||||
setVisibility (visible: boolean) {
|
||||
pane.style.display = visible ? 'block' : 'none'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const updateStatText = (id, text) => {
|
||||
if (!stats[id]) return
|
||||
stats[id].innerText = text
|
||||
}
|
||||
|
||||
export const updatePanesVisibility = (visible: boolean) => {
|
||||
// eslint-disable-next-line guard-for-in
|
||||
for (const id in stats) {
|
||||
stats[id].style.display = visible ? 'block' : 'none'
|
||||
}
|
||||
}
|
||||
|
||||
export const removeAllStats = () => {
|
||||
// eslint-disable-next-line guard-for-in
|
||||
for (const id in stats) {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,28 +1,4 @@
|
|||
import * as THREE from 'three'
|
||||
|
||||
let textureCache: Record<string, THREE.Texture> = {}
|
||||
let imagesPromises: Record<string, Promise<THREE.Texture>> = {}
|
||||
|
||||
export async function loadTexture (texture: string, cb: (texture: THREE.Texture) => void, onLoad?: () => void): Promise<void> {
|
||||
const cached = textureCache[texture]
|
||||
if (!cached) {
|
||||
const { promise, resolve } = Promise.withResolvers<THREE.Texture>()
|
||||
textureCache[texture] = new THREE.TextureLoader().load(texture, resolve)
|
||||
imagesPromises[texture] = promise
|
||||
}
|
||||
|
||||
cb(textureCache[texture])
|
||||
void imagesPromises[texture].then(() => {
|
||||
onLoad?.()
|
||||
})
|
||||
}
|
||||
|
||||
export const clearTextureCache = () => {
|
||||
textureCache = {}
|
||||
imagesPromises = {}
|
||||
}
|
||||
|
||||
export const loadScript = async function (scriptSrc: string): Promise<HTMLScriptElement> {
|
||||
export const loadScript = async function (scriptSrc: string, highPriority = true): Promise<HTMLScriptElement> {
|
||||
const existingScript = document.querySelector<HTMLScriptElement>(`script[src="${scriptSrc}"]`)
|
||||
if (existingScript) {
|
||||
return existingScript
|
||||
|
|
@ -31,6 +7,10 @@ export const loadScript = async function (scriptSrc: string): Promise<HTMLScript
|
|||
return new Promise((resolve, reject) => {
|
||||
const scriptElement = document.createElement('script')
|
||||
scriptElement.src = scriptSrc
|
||||
|
||||
if (highPriority) {
|
||||
scriptElement.fetchPriority = 'high'
|
||||
}
|
||||
scriptElement.async = true
|
||||
|
||||
scriptElement.addEventListener('load', () => {
|
||||
|
|
@ -45,3 +25,33 @@ export const loadScript = async function (scriptSrc: string): Promise<HTMLScript
|
|||
document.head.appendChild(scriptElement)
|
||||
})
|
||||
}
|
||||
|
||||
const detectFullOffscreenCanvasSupport = () => {
|
||||
if (typeof OffscreenCanvas === 'undefined') return false
|
||||
try {
|
||||
const canvas = new OffscreenCanvas(1, 1)
|
||||
// Try to get a WebGL context - this will fail on iOS where only 2D is supported (iOS 16)
|
||||
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl')
|
||||
return gl !== null
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const hasFullOffscreenCanvasSupport = detectFullOffscreenCanvasSupport()
|
||||
|
||||
export const createCanvas = (width: number, height: number): OffscreenCanvas => {
|
||||
if (hasFullOffscreenCanvasSupport) {
|
||||
return new OffscreenCanvas(width, height)
|
||||
}
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
return canvas as unknown as OffscreenCanvas // todo-low
|
||||
}
|
||||
|
||||
export async function loadImageFromUrl (imageUrl: string): Promise<ImageBitmap> {
|
||||
const response = await fetch(imageUrl)
|
||||
const blob = await response.blob()
|
||||
return createImageBitmap(blob)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,59 @@
|
|||
import { loadSkinToCanvas } from 'skinview-utils'
|
||||
import * as THREE from 'three'
|
||||
import stevePng from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png'
|
||||
import { createCanvas, loadImageFromUrl } from '../utils'
|
||||
|
||||
// eslint-disable-next-line unicorn/prefer-export-from
|
||||
export const stevePngUrl = stevePng
|
||||
export const steveTexture = new THREE.TextureLoader().loadAsync(stevePng)
|
||||
export { default as stevePngUrl } from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png'
|
||||
|
||||
export async function loadImageFromUrl (imageUrl: string): Promise<HTMLImageElement> {
|
||||
const img = new Image()
|
||||
img.src = imageUrl
|
||||
await new Promise<void>(resolve => {
|
||||
img.onload = () => resolve()
|
||||
})
|
||||
return img
|
||||
const config = {
|
||||
apiEnabled: true,
|
||||
}
|
||||
|
||||
export function getLookupUrl (username: string, type: 'skin' | 'cape'): string {
|
||||
return `https://mulv.tycrek.dev/api/lookup?username=${username}&type=${type}`
|
||||
export const setSkinsConfig = (newConfig: Partial<typeof config>) => {
|
||||
Object.assign(config, newConfig)
|
||||
}
|
||||
|
||||
export async function loadSkinImage (skinUrl: string): Promise<{ canvas: HTMLCanvasElement, image: HTMLImageElement }> {
|
||||
export async function loadSkinFromUsername (username: string, type: 'skin' | 'cape'): Promise<string | undefined> {
|
||||
if (!config.apiEnabled) return
|
||||
|
||||
if (type === 'cape') return
|
||||
const url = `https://playerdb.co/api/player/minecraft/${username}`
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) return
|
||||
|
||||
const data: {
|
||||
data: {
|
||||
player: {
|
||||
skin_texture: string
|
||||
}
|
||||
}
|
||||
} = await response.json()
|
||||
return data.data.player.skin_texture
|
||||
}
|
||||
|
||||
export const parseSkinTexturesValue = (value: string) => {
|
||||
const decodedData: {
|
||||
textures: {
|
||||
SKIN: {
|
||||
url: string
|
||||
}
|
||||
}
|
||||
} = JSON.parse(Buffer.from(value, 'base64').toString())
|
||||
return decodedData.textures?.SKIN?.url
|
||||
}
|
||||
|
||||
export async function loadSkinImage (skinUrl: string): Promise<{ canvas: OffscreenCanvas, image: ImageBitmap }> {
|
||||
if (!skinUrl.startsWith('data:')) {
|
||||
skinUrl = await fetchAndConvertBase64Skin(skinUrl.replace('http://', 'https://'))
|
||||
}
|
||||
|
||||
const image = await loadImageFromUrl(skinUrl)
|
||||
const skinCanvas = document.createElement('canvas')
|
||||
const skinCanvas = createCanvas(64, 64)
|
||||
loadSkinToCanvas(skinCanvas, image)
|
||||
return { canvas: skinCanvas, image }
|
||||
}
|
||||
|
||||
const fetchAndConvertBase64Skin = async (skinUrl: string) => {
|
||||
const response = await fetch(skinUrl, { })
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const base64 = Buffer.from(arrayBuffer).toString('base64')
|
||||
return `data:image/png;base64,${base64}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,325 +0,0 @@
|
|||
import EventEmitter from 'events'
|
||||
import * as THREE from 'three'
|
||||
import { Vec3 } from 'vec3'
|
||||
import { generateSpiralMatrix } from 'flying-squid/dist/utils'
|
||||
import worldBlockProvider from 'mc-assets/dist/worldBlockProvider'
|
||||
import { Entities } from './entities'
|
||||
import { Primitives } from './primitives'
|
||||
import { WorldRendererThree } from './worldrendererThree'
|
||||
import { WorldRendererCommon, WorldRendererConfig, defaultWorldRendererConfig } from './worldrendererCommon'
|
||||
import { getThreeBlockModelGroup, renderBlockThree, setBlockPosition } from './mesher/standaloneRenderer'
|
||||
import { addNewStat } from './ui/newStats'
|
||||
import { getMyHand } from './hand'
|
||||
import { IPlayerState, BasePlayerState } from './basePlayerState'
|
||||
import { CameraBobbing } from './cameraBobbing'
|
||||
|
||||
export class Viewer {
|
||||
scene: THREE.Scene
|
||||
ambientLight: THREE.AmbientLight
|
||||
directionalLight: THREE.DirectionalLight
|
||||
world: WorldRendererCommon
|
||||
entities: Entities
|
||||
// primitives: Primitives
|
||||
domElement: HTMLCanvasElement
|
||||
playerHeight = 1.62
|
||||
threeJsWorld: WorldRendererThree
|
||||
cameraObjectOverride?: THREE.Object3D // for xr
|
||||
audioListener: THREE.AudioListener
|
||||
renderingUntilNoUpdates = false
|
||||
processEntityOverrides = (e, overrides) => overrides
|
||||
private readonly cameraBobbing: CameraBobbing
|
||||
|
||||
get camera () {
|
||||
return this.world.camera
|
||||
}
|
||||
|
||||
set camera (camera) {
|
||||
this.world.camera = camera
|
||||
}
|
||||
|
||||
constructor (public renderer: THREE.WebGLRenderer, worldConfig = defaultWorldRendererConfig, public playerState: IPlayerState = new BasePlayerState()) {
|
||||
// https://discourse.threejs.org/t/updates-to-color-management-in-three-js-r152/50791
|
||||
THREE.ColorManagement.enabled = false
|
||||
renderer.outputColorSpace = THREE.LinearSRGBColorSpace
|
||||
|
||||
this.scene = new THREE.Scene()
|
||||
this.scene.matrixAutoUpdate = false // for perf
|
||||
this.threeJsWorld = new WorldRendererThree(this.scene, this.renderer, worldConfig, this.playerState)
|
||||
this.setWorld()
|
||||
this.resetScene()
|
||||
this.entities = new Entities(this)
|
||||
// this.primitives = new Primitives(this.scene, this.camera)
|
||||
this.cameraBobbing = new CameraBobbing()
|
||||
|
||||
this.domElement = renderer.domElement
|
||||
}
|
||||
|
||||
setWorld () {
|
||||
this.world = this.threeJsWorld
|
||||
}
|
||||
|
||||
resetScene () {
|
||||
this.scene.background = new THREE.Color('lightblue')
|
||||
|
||||
if (this.ambientLight) this.scene.remove(this.ambientLight)
|
||||
this.ambientLight = new THREE.AmbientLight(0xcc_cc_cc)
|
||||
this.scene.add(this.ambientLight)
|
||||
|
||||
if (this.directionalLight) this.scene.remove(this.directionalLight)
|
||||
this.directionalLight = new THREE.DirectionalLight(0xff_ff_ff, 0.5)
|
||||
this.directionalLight.position.set(1, 1, 0.5).normalize()
|
||||
this.directionalLight.castShadow = true
|
||||
this.scene.add(this.directionalLight)
|
||||
|
||||
const size = this.renderer.getSize(new THREE.Vector2())
|
||||
this.camera = new THREE.PerspectiveCamera(75, size.x / size.y, 0.1, 1000)
|
||||
}
|
||||
|
||||
resetAll () {
|
||||
this.resetScene()
|
||||
this.world.resetWorld()
|
||||
this.entities.clear()
|
||||
// this.primitives.clear()
|
||||
}
|
||||
|
||||
setVersion (userVersion: string, texturesVersion = userVersion): void | Promise<void> {
|
||||
console.log('[viewer] Using version:', userVersion, 'textures:', texturesVersion)
|
||||
this.entities.clear()
|
||||
// this.primitives.clear()
|
||||
return this.world.setVersion(userVersion, texturesVersion)
|
||||
}
|
||||
|
||||
addColumn (x, z, chunk, isLightUpdate = false) {
|
||||
this.world.addColumn(x, z, chunk, isLightUpdate)
|
||||
}
|
||||
|
||||
removeColumn (x: string, z: string) {
|
||||
this.world.removeColumn(x, z)
|
||||
}
|
||||
|
||||
setBlockStateId (pos: Vec3, stateId: number) {
|
||||
const set = async () => {
|
||||
const sectionX = Math.floor(pos.x / 16) * 16
|
||||
const sectionZ = Math.floor(pos.z / 16) * 16
|
||||
if (this.world.queuedChunks.has(`${sectionX},${sectionZ}`)) {
|
||||
await new Promise<void>(resolve => {
|
||||
this.world.queuedFunctions.push(() => {
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
if (!this.world.loadedChunks[`${sectionX},${sectionZ}`]) {
|
||||
// console.debug('[should be unreachable] setBlockStateId called for unloaded chunk', pos)
|
||||
}
|
||||
this.world.setBlockStateId(pos, stateId)
|
||||
}
|
||||
void set()
|
||||
}
|
||||
|
||||
async demoModel () {
|
||||
//@ts-expect-error
|
||||
const pos = cursorBlockRel(0, 1, 0).position
|
||||
|
||||
const mesh = await getMyHand()
|
||||
// mesh.rotation.y = THREE.MathUtils.degToRad(90)
|
||||
setBlockPosition(mesh, pos)
|
||||
const helper = new THREE.BoxHelper(mesh, 0xff_ff_00)
|
||||
mesh.add(helper)
|
||||
this.scene.add(mesh)
|
||||
}
|
||||
|
||||
demoItem () {
|
||||
//@ts-expect-error
|
||||
const pos = cursorBlockRel(0, 1, 0).position
|
||||
const { mesh } = this.entities.getItemMesh({
|
||||
itemId: 541,
|
||||
}, {})!
|
||||
mesh.position.set(pos.x + 0.5, pos.y + 0.5, pos.z + 0.5)
|
||||
// mesh.scale.set(0.5, 0.5, 0.5)
|
||||
const helper = new THREE.BoxHelper(mesh, 0xff_ff_00)
|
||||
mesh.add(helper)
|
||||
this.scene.add(mesh)
|
||||
}
|
||||
|
||||
updateEntity (e) {
|
||||
this.entities.update(e, this.processEntityOverrides(e, {
|
||||
rotation: {
|
||||
head: {
|
||||
x: e.headPitch ?? e.pitch,
|
||||
y: e.headYaw,
|
||||
z: 0
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
setFirstPersonCamera (pos: Vec3 | null, yaw: number, pitch: number) {
|
||||
const cam = this.cameraObjectOverride || this.camera
|
||||
const yOffset = this.playerState.getEyeHeight()
|
||||
// if (this.playerState.isSneaking()) yOffset -= 0.3
|
||||
|
||||
this.world.camera = cam as THREE.PerspectiveCamera
|
||||
this.world.updateCamera(pos?.offset(0, yOffset, 0) ?? null, yaw, pitch)
|
||||
|
||||
// // Update camera bobbing based on movement state
|
||||
// const velocity = this.playerState.getVelocity()
|
||||
// const movementState = this.playerState.getMovementState()
|
||||
// const isMoving = movementState === 'SPRINTING' || movementState === 'WALKING'
|
||||
// const speed = Math.hypot(velocity.x, velocity.z)
|
||||
|
||||
// // Update bobbing state
|
||||
// this.cameraBobbing.updateWalkDistance(speed)
|
||||
// this.cameraBobbing.updateBobAmount(isMoving)
|
||||
|
||||
// // Get bobbing offsets
|
||||
// const bobbing = isMoving ? this.cameraBobbing.getBobbing() : { position: { x: 0, y: 0 }, rotation: { x: 0, z: 0 } }
|
||||
|
||||
// // Apply camera position with bobbing
|
||||
// const finalPos = pos ? pos.offset(bobbing.position.x, yOffset + bobbing.position.y, 0) : null
|
||||
// this.world.updateCamera(finalPos, yaw + bobbing.rotation.x, pitch)
|
||||
|
||||
// // Apply roll rotation separately since updateCamera doesn't handle it
|
||||
// this.camera.rotation.z = bobbing.rotation.z
|
||||
}
|
||||
|
||||
playSound (position: Vec3, path: string, volume = 1, pitch = 1) {
|
||||
if (!this.audioListener) {
|
||||
this.audioListener = new THREE.AudioListener()
|
||||
this.camera.add(this.audioListener)
|
||||
}
|
||||
|
||||
const sound = new THREE.PositionalAudio(this.audioListener)
|
||||
|
||||
const audioLoader = new THREE.AudioLoader()
|
||||
const start = Date.now()
|
||||
void audioLoader.loadAsync(path).then((buffer) => {
|
||||
if (Date.now() - start > 500) return
|
||||
// play
|
||||
sound.setBuffer(buffer)
|
||||
sound.setRefDistance(20)
|
||||
sound.setVolume(volume)
|
||||
sound.setPlaybackRate(pitch) // set the pitch
|
||||
this.scene.add(sound)
|
||||
// set sound position
|
||||
sound.position.set(position.x, position.y, position.z)
|
||||
sound.onEnded = () => {
|
||||
this.scene.remove(sound)
|
||||
sound.disconnect()
|
||||
audioLoader.manager.itemEnd(path)
|
||||
}
|
||||
sound.play()
|
||||
})
|
||||
}
|
||||
|
||||
addChunksBatchWaitTime = 200
|
||||
|
||||
connect (worldEmitter: EventEmitter) {
|
||||
worldEmitter.on('entity', (e) => {
|
||||
this.updateEntity(e)
|
||||
})
|
||||
|
||||
worldEmitter.on('primitive', (p) => {
|
||||
// this.updatePrimitive(p)
|
||||
})
|
||||
|
||||
let currentLoadChunkBatch = null as {
|
||||
timeout
|
||||
data
|
||||
} | null
|
||||
worldEmitter.on('loadChunk', ({ x, z, chunk, worldConfig, isLightUpdate }) => {
|
||||
this.world.worldConfig = worldConfig
|
||||
this.world.queuedChunks.add(`${x},${z}`)
|
||||
const args = [x, z, chunk, isLightUpdate]
|
||||
if (!currentLoadChunkBatch) {
|
||||
// add a setting to use debounce instead
|
||||
currentLoadChunkBatch = {
|
||||
data: [],
|
||||
timeout: setTimeout(() => {
|
||||
for (const args of currentLoadChunkBatch!.data) {
|
||||
this.world.queuedChunks.delete(`${args[0]},${args[1]}`)
|
||||
this.addColumn(...args as Parameters<typeof this.addColumn>)
|
||||
}
|
||||
for (const fn of this.world.queuedFunctions) {
|
||||
fn()
|
||||
}
|
||||
this.world.queuedFunctions = []
|
||||
currentLoadChunkBatch = null
|
||||
}, this.addChunksBatchWaitTime)
|
||||
}
|
||||
}
|
||||
currentLoadChunkBatch.data.push(args)
|
||||
})
|
||||
// todo remove and use other architecture instead so data flow is clear
|
||||
worldEmitter.on('blockEntities', (blockEntities) => {
|
||||
if (this.world instanceof WorldRendererThree) (this.world).blockEntities = blockEntities
|
||||
})
|
||||
|
||||
worldEmitter.on('unloadChunk', ({ x, z }) => {
|
||||
this.removeColumn(x, z)
|
||||
})
|
||||
|
||||
worldEmitter.on('blockUpdate', ({ pos, stateId }) => {
|
||||
this.setBlockStateId(new Vec3(pos.x, pos.y, pos.z), stateId)
|
||||
})
|
||||
|
||||
worldEmitter.on('chunkPosUpdate', ({ pos }) => {
|
||||
this.world.updateViewerPosition(pos)
|
||||
})
|
||||
|
||||
|
||||
worldEmitter.on('renderDistance', (d) => {
|
||||
this.world.viewDistance = d
|
||||
this.world.chunksLength = d === 0 ? 1 : generateSpiralMatrix(d).length
|
||||
})
|
||||
|
||||
worldEmitter.on('renderDistance', (d) => {
|
||||
this.world.viewDistance = d
|
||||
this.world.chunksLength = d === 0 ? 1 : generateSpiralMatrix(d).length
|
||||
this.world.allChunksFinished = Object.keys(this.world.finishedChunks).length === this.world.chunksLength
|
||||
})
|
||||
|
||||
worldEmitter.on('markAsLoaded', ({ x, z }) => {
|
||||
this.world.markAsLoaded(x, z)
|
||||
})
|
||||
|
||||
worldEmitter.on('updateLight', ({ pos }) => {
|
||||
if (this.world instanceof WorldRendererThree) (this.world).updateLight(pos.x, pos.z)
|
||||
})
|
||||
|
||||
worldEmitter.on('time', (timeOfDay) => {
|
||||
this.world.timeUpdated?.(timeOfDay)
|
||||
|
||||
let skyLight = 15
|
||||
if (timeOfDay < 0 || timeOfDay > 24_000) {
|
||||
throw new Error('Invalid time of day. It should be between 0 and 24000.')
|
||||
} else if (timeOfDay <= 6000 || timeOfDay >= 18_000) {
|
||||
skyLight = 15
|
||||
} else if (timeOfDay > 6000 && timeOfDay < 12_000) {
|
||||
skyLight = 15 - ((timeOfDay - 6000) / 6000) * 15
|
||||
} else if (timeOfDay >= 12_000 && timeOfDay < 18_000) {
|
||||
skyLight = ((timeOfDay - 12_000) / 6000) * 15
|
||||
}
|
||||
|
||||
skyLight = Math.floor(skyLight) // todo: remove this after optimization
|
||||
|
||||
if (this.world.mesherConfig.skyLight === skyLight) return
|
||||
this.world.mesherConfig.skyLight = skyLight
|
||||
if (this.world instanceof WorldRendererThree) {
|
||||
(this.world).rerenderAllChunks?.()
|
||||
}
|
||||
})
|
||||
|
||||
worldEmitter.emit('listening')
|
||||
}
|
||||
|
||||
render () {
|
||||
if (this.world instanceof WorldRendererThree) {
|
||||
(this.world).render()
|
||||
this.entities.render()
|
||||
}
|
||||
}
|
||||
|
||||
async waitForChunksToRender () {
|
||||
await this.world.waitForChunksToRender()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,20 @@
|
|||
export function createWorkerProxy<T extends Record<string, (...args: any[]) => void>> (handlers: T): { __workerProxy: T } {
|
||||
addEventListener('message', (event) => {
|
||||
const { type, args } = event.data
|
||||
import { proxy, getVersion, subscribe } from 'valtio'
|
||||
|
||||
export function createWorkerProxy<T extends Record<string, (...args: any[]) => void | Promise<any>>> (handlers: T, channel?: MessagePort): { __workerProxy: T } {
|
||||
const target = channel ?? globalThis
|
||||
target.addEventListener('message', (event: any) => {
|
||||
const { type, args, msgId } = event.data
|
||||
if (handlers[type]) {
|
||||
handlers[type](...args)
|
||||
const result = handlers[type](...args)
|
||||
if (result instanceof Promise) {
|
||||
void result.then((result) => {
|
||||
target.postMessage({
|
||||
type: 'result',
|
||||
msgId,
|
||||
args: [result]
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
return null as any
|
||||
|
|
@ -19,9 +31,10 @@ export function createWorkerProxy<T extends Record<string, (...args: any[]) => v
|
|||
* const workerChannel = useWorkerProxy<typeof importedTypeWorkerProxy>(worker)
|
||||
* ```
|
||||
*/
|
||||
export const useWorkerProxy = <T extends { __workerProxy: Record<string, (...args: any[]) => void> }> (worker: Worker, autoTransfer = true): T['__workerProxy'] & {
|
||||
export const useWorkerProxy = <T extends { __workerProxy: Record<string, (...args: any[]) => void> }> (worker: Worker | MessagePort, autoTransfer = true): T['__workerProxy'] & {
|
||||
transfer: (...args: Transferable[]) => T['__workerProxy']
|
||||
} => {
|
||||
let messageId = 0
|
||||
// in main thread
|
||||
return new Proxy({} as any, {
|
||||
get (target, prop) {
|
||||
|
|
@ -40,11 +53,30 @@ export const useWorkerProxy = <T extends { __workerProxy: Record<string, (...arg
|
|||
}
|
||||
}
|
||||
return (...args: any[]) => {
|
||||
const transfer = autoTransfer ? args.filter(arg => arg instanceof ArrayBuffer || arg instanceof MessagePort || arg instanceof ImageBitmap || arg instanceof OffscreenCanvas) : []
|
||||
const msgId = messageId++
|
||||
const transfer = autoTransfer ? args.filter(arg => {
|
||||
return arg instanceof ArrayBuffer || arg instanceof MessagePort
|
||||
|| (typeof ImageBitmap !== 'undefined' && arg instanceof ImageBitmap)
|
||||
|| (typeof OffscreenCanvas !== 'undefined' && arg instanceof OffscreenCanvas)
|
||||
|| (typeof ImageData !== 'undefined' && arg instanceof ImageData)
|
||||
}) : []
|
||||
worker.postMessage({
|
||||
type: prop,
|
||||
msgId,
|
||||
args,
|
||||
}, transfer)
|
||||
return {
|
||||
// eslint-disable-next-line unicorn/no-thenable
|
||||
then (onfulfilled: (value: any) => void) {
|
||||
const handler = ({ data }: MessageEvent): void => {
|
||||
if (data.type === 'result' && data.msgId === msgId) {
|
||||
onfulfilled(data.args[0])
|
||||
worker.removeEventListener('message', handler as EventListener)
|
||||
}
|
||||
}
|
||||
worker.addEventListener('message', handler as EventListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,52 +5,85 @@ import { EventEmitter } from 'events'
|
|||
import { generateSpiralMatrix, ViewRect } from 'flying-squid/dist/utils'
|
||||
import { Vec3 } from 'vec3'
|
||||
import { BotEvents } from 'mineflayer'
|
||||
import { getItemFromBlock } from '../../../src/chatUtils'
|
||||
import { proxy } from 'valtio'
|
||||
import TypedEmitter from 'typed-emitter'
|
||||
import { Biome } from 'minecraft-data'
|
||||
import { delayedIterator } from '../../playground/shared'
|
||||
import { playerState } from '../../../src/mineflayer/playerState'
|
||||
import { chunkPos } from './simpleUtils'
|
||||
|
||||
export type ChunkPosKey = string
|
||||
type ChunkPos = { x: number, z: number }
|
||||
export type ChunkPosKey = string // like '16,16'
|
||||
type ChunkPos = { x: number, z: number } // like { x: 16, z: 16 }
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
private loadedChunks: Record<ChunkPosKey, boolean>
|
||||
private readonly lastPos: Vec3
|
||||
export type WorldDataEmitterEvents = {
|
||||
chunkPosUpdate: (data: { pos: Vec3 }) => void
|
||||
blockUpdate: (data: { pos: Vec3, stateId: number }) => void
|
||||
entity: (data: any) => void
|
||||
entityMoved: (data: any) => void
|
||||
playerEntity: (data: any) => void
|
||||
time: (data: number) => void
|
||||
renderDistance: (viewDistance: number) => void
|
||||
blockEntities: (data: Record<string, any> | { blockEntities: Record<string, any> }) => void
|
||||
markAsLoaded: (data: { x: number, z: number }) => void
|
||||
unloadChunk: (data: { x: number, z: number }) => void
|
||||
loadChunk: (data: { x: number, z: number, chunk: string, blockEntities: any, worldConfig: any, isLightUpdate: boolean }) => void
|
||||
updateLight: (data: { pos: Vec3 }) => void
|
||||
onWorldSwitch: () => void
|
||||
end: () => void
|
||||
biomeUpdate: (data: { biome: Biome }) => void
|
||||
biomeReset: () => void
|
||||
}
|
||||
|
||||
export class WorldDataEmitterWorker extends (EventEmitter as new () => TypedEmitter<WorldDataEmitterEvents>) {
|
||||
static readonly restorerName = 'WorldDataEmitterWorker'
|
||||
}
|
||||
|
||||
export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<WorldDataEmitterEvents>) {
|
||||
spiralNumber = 0
|
||||
gotPanicLastTime = false
|
||||
panicChunksReload = () => {}
|
||||
loadedChunks: Record<ChunkPosKey, boolean>
|
||||
private inLoading = false
|
||||
private chunkReceiveTimes: number[] = []
|
||||
private lastChunkReceiveTime = 0
|
||||
public lastChunkReceiveTimeAvg = 0
|
||||
private panicTimeout?: NodeJS.Timeout
|
||||
readonly lastPos: Vec3
|
||||
private eventListeners: Record<string, any> = {}
|
||||
private readonly emitter: WorldDataEmitter
|
||||
keepChunksDistance = 0
|
||||
debugChunksInfo: Record<ChunkPosKey, {
|
||||
loads: Array<{
|
||||
dataLength: number
|
||||
reason: string
|
||||
time: number
|
||||
}>
|
||||
// blockUpdates: number
|
||||
}> = {}
|
||||
|
||||
waitingSpiralChunksLoad = {} as Record<ChunkPosKey, (value: boolean) => void>
|
||||
|
||||
addWaitTime = 1
|
||||
isPlayground = false
|
||||
/* config */ keepChunksDistance = 0
|
||||
/* config */ isPlayground = false
|
||||
/* config */ allowPositionUpdate = true
|
||||
|
||||
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) {
|
||||
const val = this.world.setBlockStateId(position, stateId) as Promise<void> | void
|
||||
if (val) throw new Error('setBlockStateId returned promise (not supported)')
|
||||
const chunkX = Math.floor(position.x / 16)
|
||||
const chunkZ = Math.floor(position.z / 16)
|
||||
if (!this.loadedChunks[`${chunkX},${chunkZ}`]) {
|
||||
void this.loadChunk({ x: chunkX, z: chunkZ })
|
||||
return
|
||||
}
|
||||
// const chunkX = Math.floor(position.x / 16)
|
||||
// const chunkZ = Math.floor(position.z / 16)
|
||||
// if (!this.loadedChunks[`${chunkX},${chunkZ}`] && !this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`]) {
|
||||
// void this.loadChunk({ x: chunkX, z: chunkZ })
|
||||
// return
|
||||
// }
|
||||
|
||||
this.emit('blockUpdate', { pos: position, stateId })
|
||||
}
|
||||
|
|
@ -61,12 +94,28 @@ export class WorldDataEmitter extends EventEmitter {
|
|||
}
|
||||
|
||||
listenToBot (bot: typeof __type_bot) {
|
||||
const emitEntity = (e) => {
|
||||
if (!e || e === bot.entity) return
|
||||
this.emitter.emit('entity', {
|
||||
const entitiesObjectData = new Map<string, number>()
|
||||
bot._client.prependListener('spawn_entity', (data) => {
|
||||
if (data.objectData && data.entityId !== undefined) {
|
||||
entitiesObjectData.set(data.entityId, data.objectData)
|
||||
}
|
||||
})
|
||||
|
||||
const emitEntity = (e, name = 'entity') => {
|
||||
if (!e) return
|
||||
if (e === bot.entity) {
|
||||
if (name === 'entity') {
|
||||
this.emitter.emit('playerEntity', e)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!e.name) return // mineflayer received update for not spawned entity
|
||||
e.objectData = entitiesObjectData.get(e.id)
|
||||
this.emitter.emit(name as any, {
|
||||
...e,
|
||||
pos: e.position,
|
||||
username: e.username,
|
||||
team: bot.teamMap[e.username] || bot.teamMap[e.uuid],
|
||||
// set debugTree (obj) {
|
||||
// e.debugTree = obj
|
||||
// }
|
||||
|
|
@ -85,14 +134,29 @@ export class WorldDataEmitter extends EventEmitter {
|
|||
entityUpdate (e: any) {
|
||||
emitEntity(e)
|
||||
},
|
||||
entityMoved (e: any) {
|
||||
entityEquip (e: any) {
|
||||
emitEntity(e)
|
||||
},
|
||||
entityMoved (e: any) {
|
||||
emitEntity(e, 'entityMoved')
|
||||
},
|
||||
entityGone: (e: any) => {
|
||||
this.emitter.emit('entity', { id: e.id, delete: true })
|
||||
},
|
||||
chunkColumnLoad: (pos: Vec3) => {
|
||||
void this.loadChunk(pos)
|
||||
const now = performance.now()
|
||||
if (this.lastChunkReceiveTime) {
|
||||
this.chunkReceiveTimes.push(now - this.lastChunkReceiveTime)
|
||||
}
|
||||
this.lastChunkReceiveTime = now
|
||||
|
||||
if (this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`]) {
|
||||
this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`](true)
|
||||
delete this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`]
|
||||
} else if (this.loadedChunks[`${pos.x},${pos.z}`]) {
|
||||
void this.loadChunk(pos, false, 'Received another chunkColumnLoad event while already loaded')
|
||||
}
|
||||
this.chunkProgress()
|
||||
},
|
||||
chunkColumnUnload: (pos: Vec3) => {
|
||||
this.unloadChunk(pos)
|
||||
|
|
@ -103,41 +167,55 @@ export class WorldDataEmitter extends EventEmitter {
|
|||
},
|
||||
time: () => {
|
||||
this.emitter.emit('time', bot.time.timeOfDay)
|
||||
}
|
||||
},
|
||||
end: () => {
|
||||
this.emitter.emit('end')
|
||||
},
|
||||
// when dimension might change
|
||||
login: () => {
|
||||
void this.updatePosition(bot.entity.position, true)
|
||||
this.emitter.emit('playerEntity', bot.entity)
|
||||
},
|
||||
respawn: () => {
|
||||
void this.updatePosition(bot.entity.position, true)
|
||||
this.emitter.emit('playerEntity', bot.entity)
|
||||
this.emitter.emit('onWorldSwitch')
|
||||
},
|
||||
} satisfies Partial<BotEvents>
|
||||
|
||||
|
||||
bot._client.on('update_light', ({ chunkX, chunkZ }) => {
|
||||
const chunkPos = new Vec3(chunkX * 16, 0, chunkZ * 16)
|
||||
void this.loadChunk(chunkPos, true)
|
||||
if (!this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`] && this.loadedChunks[`${chunkX},${chunkZ}`]) {
|
||||
void this.loadChunk(chunkPos, true, 'update_light')
|
||||
}
|
||||
})
|
||||
|
||||
this.emitter.on('listening', () => {
|
||||
this.emitter.emit('blockEntities', new Proxy({}, {
|
||||
get (_target, posKey, receiver) {
|
||||
if (typeof posKey !== 'string') return
|
||||
const [x, y, z] = posKey.split(',').map(Number)
|
||||
return bot.world.getBlock(new Vec3(x, y, z))?.entity
|
||||
},
|
||||
}))
|
||||
this.emitter.emit('renderDistance', this.viewDistance)
|
||||
this.emitter.emit('time', bot.time.timeOfDay)
|
||||
})
|
||||
// node.js stream data event pattern
|
||||
if (this.emitter.listenerCount('blockEntities')) {
|
||||
this.emitter.emit('listening')
|
||||
}
|
||||
|
||||
for (const [evt, listener] of Object.entries(this.eventListeners)) {
|
||||
bot.on(evt as any, listener)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emitterGotConnected () {
|
||||
this.emitter.emit('blockEntities', new Proxy({}, {
|
||||
get (_target, posKey, receiver) {
|
||||
if (typeof posKey !== 'string') return
|
||||
const [x, y, z] = posKey.split(',').map(Number)
|
||||
return bot.world.getBlock(new Vec3(x, y, z))?.entity
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
removeListenersFromBot (bot: import('mineflayer').Bot) {
|
||||
for (const [evt, listener] of Object.entries(this.eventListeners)) {
|
||||
bot.removeListener(evt as any, listener)
|
||||
|
|
@ -147,36 +225,95 @@ export class WorldDataEmitter extends EventEmitter {
|
|||
async init (pos: Vec3) {
|
||||
this.updateViewDistance(this.viewDistance)
|
||||
this.emitter.emit('chunkPosUpdate', { pos })
|
||||
if (bot?.time?.timeOfDay) {
|
||||
this.emitter.emit('time', bot.time.timeOfDay)
|
||||
}
|
||||
if (bot?.entity) {
|
||||
this.emitter.emit('playerEntity', bot.entity)
|
||||
}
|
||||
this.emitterGotConnected()
|
||||
const [botX, botZ] = chunkPos(pos)
|
||||
|
||||
const positions = generateSpiralMatrix(this.viewDistance).map(([x, z]) => new Vec3((botX + x) * 16, 0, (botZ + z) * 16))
|
||||
|
||||
this.lastPos.update(pos)
|
||||
await this._loadChunks(positions)
|
||||
await this._loadChunks(positions, pos)
|
||||
}
|
||||
|
||||
async _loadChunks (positions: Vec3[], sliceSize = 5) {
|
||||
const promises = [] as Array<Promise<void>>
|
||||
await delayedIterator(positions, this.addWaitTime, (pos) => {
|
||||
promises.push(this.loadChunk(pos))
|
||||
chunkProgress () {
|
||||
if (this.panicTimeout) clearTimeout(this.panicTimeout)
|
||||
if (this.chunkReceiveTimes.length >= 5) {
|
||||
const avgReceiveTime = this.chunkReceiveTimes.reduce((a, b) => a + b, 0) / this.chunkReceiveTimes.length
|
||||
this.lastChunkReceiveTimeAvg = avgReceiveTime
|
||||
const timeoutDelay = avgReceiveTime * 2 + 1000 // 2x average + 1 second
|
||||
|
||||
// Clear any existing timeout
|
||||
if (this.panicTimeout) clearTimeout(this.panicTimeout)
|
||||
|
||||
// Set new timeout for panic reload
|
||||
this.panicTimeout = setTimeout(() => {
|
||||
if (!this.gotPanicLastTime && this.inLoading) {
|
||||
console.warn('Chunk loading seems stuck, triggering panic reload')
|
||||
this.gotPanicLastTime = true
|
||||
this.panicChunksReload()
|
||||
}
|
||||
}, timeoutDelay)
|
||||
}
|
||||
}
|
||||
|
||||
async _loadChunks (positions: Vec3[], centerPos: Vec3) {
|
||||
this.spiralNumber++
|
||||
const { spiralNumber } = this
|
||||
// stop loading previous chunks
|
||||
for (const pos of Object.keys(this.waitingSpiralChunksLoad)) {
|
||||
this.waitingSpiralChunksLoad[pos](false)
|
||||
delete this.waitingSpiralChunksLoad[pos]
|
||||
}
|
||||
|
||||
let continueLoading = true
|
||||
this.inLoading = true
|
||||
await delayedIterator(positions, this.addWaitTime, async (pos) => {
|
||||
if (!continueLoading || this.loadedChunks[`${pos.x},${pos.z}`]) return
|
||||
|
||||
// Wait for chunk to be available from server
|
||||
if (!this.world.getColumnAt(pos)) {
|
||||
continueLoading = await new Promise<boolean>(resolve => {
|
||||
this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`] = resolve
|
||||
})
|
||||
}
|
||||
if (!continueLoading) return
|
||||
await this.loadChunk(pos, undefined, `spiral ${spiralNumber} from ${centerPos.x},${centerPos.z}`)
|
||||
this.chunkProgress()
|
||||
})
|
||||
await Promise.all(promises)
|
||||
if (this.panicTimeout) clearTimeout(this.panicTimeout)
|
||||
this.inLoading = false
|
||||
this.gotPanicLastTime = false
|
||||
this.chunkReceiveTimes = []
|
||||
this.lastChunkReceiveTime = 0
|
||||
}
|
||||
|
||||
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[]
|
||||
// lastTime = 0
|
||||
|
||||
async loadChunk (pos: ChunkPos, isLightUpdate = false) {
|
||||
async loadChunk (pos: ChunkPos, isLightUpdate = false, reason = 'spiral') {
|
||||
const [botX, botZ] = chunkPos(this.lastPos)
|
||||
|
||||
const dx = Math.abs(botX - Math.floor(pos.x / 16))
|
||||
const dz = Math.abs(botZ - Math.floor(pos.z / 16))
|
||||
if (dx <= this.viewDistance && dz <= this.viewDistance) {
|
||||
|
|
@ -196,6 +333,15 @@ export class WorldDataEmitter extends EventEmitter {
|
|||
//@ts-expect-error
|
||||
this.emitter.emit('loadChunk', { x: pos.x, z: pos.z, chunk, blockEntities: column.blockEntities, worldConfig, isLightUpdate })
|
||||
this.loadedChunks[`${pos.x},${pos.z}`] = true
|
||||
|
||||
this.debugChunksInfo[`${pos.x},${pos.z}`] ??= {
|
||||
loads: []
|
||||
}
|
||||
this.debugChunksInfo[`${pos.x},${pos.z}`].loads.push({
|
||||
dataLength: chunk.length,
|
||||
reason,
|
||||
time: Date.now(),
|
||||
})
|
||||
} else if (this.isPlayground) { // don't allow in real worlds pre-flag chunks as loaded to avoid race condition when the chunk might still be loading. In playground it's assumed we always pre-load all chunks first
|
||||
this.emitter.emit('markAsLoaded', { x: pos.x, z: pos.z })
|
||||
}
|
||||
|
|
@ -214,13 +360,46 @@ export class WorldDataEmitter extends EventEmitter {
|
|||
unloadChunk (pos: ChunkPos) {
|
||||
this.emitter.emit('unloadChunk', { x: pos.x, z: pos.z })
|
||||
delete this.loadedChunks[`${pos.x},${pos.z}`]
|
||||
delete this.debugChunksInfo[`${pos.x},${pos.z}`]
|
||||
}
|
||||
|
||||
lastBiomeId: number | null = null
|
||||
|
||||
udpateBiome (pos: Vec3) {
|
||||
try {
|
||||
const biomeId = this.world.getBiome(pos)
|
||||
if (biomeId !== this.lastBiomeId) {
|
||||
this.lastBiomeId = biomeId
|
||||
const biomeData = loadedData.biomes[biomeId]
|
||||
if (biomeData) {
|
||||
this.emitter.emit('biomeUpdate', {
|
||||
biome: biomeData
|
||||
})
|
||||
} else {
|
||||
// unknown biome
|
||||
this.emitter.emit('biomeReset')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error updating biome', e)
|
||||
}
|
||||
}
|
||||
|
||||
lastPosCheck: Vec3 | null = null
|
||||
async updatePosition (pos: Vec3, force = false) {
|
||||
if (!this.allowPositionUpdate) return
|
||||
const posFloored = pos.floored()
|
||||
if (!force && this.lastPosCheck && this.lastPosCheck.equals(posFloored)) return
|
||||
this.lastPosCheck = posFloored
|
||||
|
||||
this.udpateBiome(pos)
|
||||
|
||||
const [lastX, lastZ] = chunkPos(this.lastPos)
|
||||
const [botX, botZ] = chunkPos(pos)
|
||||
if (lastX !== botX || lastZ !== botZ || force) {
|
||||
this.emitter.emit('chunkPosUpdate', { pos })
|
||||
|
||||
// unload chunks that are no longer in view
|
||||
const newViewToUnload = new ViewRect(botX, botZ, this.viewDistance + this.keepChunksDistance)
|
||||
const chunksToUnload: Vec3[] = []
|
||||
for (const coords of Object.keys(this.loadedChunks)) {
|
||||
|
|
@ -232,17 +411,18 @@ export class WorldDataEmitter extends EventEmitter {
|
|||
chunksToUnload.push(p)
|
||||
}
|
||||
}
|
||||
console.log('unloading', chunksToUnload.length, 'total now', Object.keys(this.loadedChunks).length)
|
||||
for (const p of chunksToUnload) {
|
||||
this.unloadChunk(p)
|
||||
}
|
||||
|
||||
// load new chunks
|
||||
const positions = generateSpiralMatrix(this.viewDistance).map(([x, z]) => {
|
||||
const pos = new Vec3((botX + x) * 16, 0, (botZ + z) * 16)
|
||||
if (!this.loadedChunks[`${pos.x},${pos.z}`]) return pos
|
||||
return undefined!
|
||||
}).filter(a => !!a)
|
||||
this.lastPos.update(pos)
|
||||
void this._loadChunks(positions)
|
||||
void this._loadChunks(positions, pos)
|
||||
} else {
|
||||
this.emitter.emit('chunkPosUpdate', { pos }) // todo-low
|
||||
this.lastPos.update(pos)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,568 +0,0 @@
|
|||
import * as THREE from 'three'
|
||||
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 { 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 { getMesh } from './entity/EntityMesh'
|
||||
import { armorModel } from './entity/armorModels'
|
||||
|
||||
export class WorldRendererThree extends WorldRendererCommon {
|
||||
interactionLines: null | { blockPos; mesh } = null
|
||||
outputFormat = 'threeJs' as const
|
||||
blockEntities = {}
|
||||
sectionObjects: Record<string, THREE.Object3D> = {}
|
||||
chunkTextures = new Map<string, { [pos: string]: THREE.Texture }>()
|
||||
signsCache = new Map<string, any>()
|
||||
starField: StarField
|
||||
cameraSectionPos: Vec3 = new Vec3(0, 0, 0)
|
||||
holdingBlock: HoldingBlock
|
||||
holdingBlockLeft: HoldingBlock
|
||||
rendererDevice = '...'
|
||||
|
||||
get tilesRendered () {
|
||||
return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj as any).tilesCount, 0)
|
||||
}
|
||||
|
||||
get blocksRendered () {
|
||||
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)
|
||||
|
||||
this.renderUpdateEmitter.on('itemsTextureDownloaded', () => {
|
||||
this.holdingBlock.ready = true
|
||||
this.holdingBlock.updateItem()
|
||||
this.holdingBlockLeft.ready = true
|
||||
this.holdingBlockLeft.updateItem()
|
||||
})
|
||||
|
||||
this.addDebugOverlay()
|
||||
}
|
||||
|
||||
changeHandSwingingState (isAnimationPlaying: boolean, isLeft = false) {
|
||||
const holdingBlock = isLeft ? this.holdingBlockLeft : this.holdingBlock
|
||||
if (isAnimationPlaying) {
|
||||
holdingBlock.startSwing()
|
||||
} else {
|
||||
holdingBlock.stopSwing()
|
||||
}
|
||||
}
|
||||
|
||||
changeBackgroundColor (color: [number, number, number]): void {
|
||||
this.scene.background = new THREE.Color(color[0], color[1], color[2])
|
||||
}
|
||||
|
||||
timeUpdated (newTime: number): void {
|
||||
const nightTime = 13_500
|
||||
const morningStart = 23_000
|
||||
const displayStars = newTime > nightTime && newTime < morningStart
|
||||
if (displayStars) {
|
||||
this.starField.addToScene()
|
||||
} else {
|
||||
this.starField.remove()
|
||||
}
|
||||
}
|
||||
|
||||
debugOverlayAdded = false
|
||||
addDebugOverlay () {
|
||||
if (this.debugOverlayAdded) return
|
||||
this.debugOverlayAdded = true
|
||||
const pane = addNewStat('debug-overlay')
|
||||
setInterval(() => {
|
||||
pane.setVisibility(this.displayStats)
|
||||
if (this.displayStats) {
|
||||
pane.updateText(`C: ${this.renderer.info.render.calls} TR: ${this.renderer.info.render.triangles} TE: ${this.renderer.info.memory.textures} F: ${this.tilesRendered} B: ${this.blocksRendered}`)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionally update data that are depedendent on the viewer position
|
||||
*/
|
||||
updatePosDataChunk (key: string) {
|
||||
const [x, y, z] = key.split(',').map(x => Math.floor(+x / 16))
|
||||
// sum of distances: x + y + z
|
||||
const chunkDistance = Math.abs(x - this.cameraSectionPos.x) + Math.abs(y - this.cameraSectionPos.y) + Math.abs(z - this.cameraSectionPos.z)
|
||||
const section = this.sectionObjects[key].children.find(child => child.name === 'mesh')!
|
||||
section.renderOrder = 500 - chunkDistance
|
||||
}
|
||||
|
||||
updateViewerPosition (pos: Vec3): void {
|
||||
this.viewerPosition = pos
|
||||
const cameraPos = this.camera.position.toArray().map(x => Math.floor(x / 16)) as [number, number, number]
|
||||
this.cameraSectionPos = new Vec3(...cameraPos)
|
||||
// eslint-disable-next-line guard-for-in
|
||||
for (const key in this.sectionObjects) {
|
||||
const value = this.sectionObjects[key]
|
||||
if (!value) continue
|
||||
this.updatePosDataChunk(key)
|
||||
}
|
||||
}
|
||||
|
||||
// debugRecomputedDeletedObjects = 0
|
||||
handleWorkerMessage (data: { geometry: MesherGeometryOutput, key, type }): void {
|
||||
if (data.type !== 'geometry') return
|
||||
let object: THREE.Object3D = this.sectionObjects[data.key]
|
||||
if (object) {
|
||||
this.scene.remove(object)
|
||||
disposeObject(object)
|
||||
delete this.sectionObjects[data.key]
|
||||
}
|
||||
|
||||
const chunkCoords = data.key.split(',')
|
||||
if (!this.loadedChunks[chunkCoords[0] + ',' + chunkCoords[2]] || !data.geometry.positions.length || !this.active) return
|
||||
|
||||
// if (object) {
|
||||
// this.debugRecomputedDeletedObjects++
|
||||
// }
|
||||
|
||||
// if (!this.initialChunksLoad && this.enableChunksLoadDelay) {
|
||||
// const newPromise = new Promise(resolve => {
|
||||
// if (this.droppedFpsPercentage > 0.5) {
|
||||
// setTimeout(resolve, 1000 / 50 * this.droppedFpsPercentage)
|
||||
// } else {
|
||||
// setTimeout(resolve)
|
||||
// }
|
||||
// })
|
||||
// this.promisesQueue.push(newPromise)
|
||||
// for (const promise of this.promisesQueue) {
|
||||
// await promise
|
||||
// }
|
||||
// }
|
||||
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(data.geometry.positions, 3))
|
||||
geometry.setAttribute('normal', new THREE.BufferAttribute(data.geometry.normals, 3))
|
||||
geometry.setAttribute('color', new THREE.BufferAttribute(data.geometry.colors, 3))
|
||||
geometry.setAttribute('uv', new THREE.BufferAttribute(data.geometry.uvs, 2))
|
||||
geometry.setIndex(data.geometry.indices)
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, this.material)
|
||||
mesh.position.set(data.geometry.sx, data.geometry.sy, data.geometry.sz)
|
||||
mesh.name = 'mesh'
|
||||
object = new THREE.Group()
|
||||
object.add(mesh)
|
||||
// mesh with static dimensions: 16x16x16
|
||||
const staticChunkMesh = new THREE.Mesh(new THREE.BoxGeometry(16, 16, 16), new THREE.MeshBasicMaterial({ color: 0x00_00_00, transparent: true, opacity: 0 }))
|
||||
staticChunkMesh.position.set(data.geometry.sx, data.geometry.sy, data.geometry.sz)
|
||||
const boxHelper = new THREE.BoxHelper(staticChunkMesh, 0xff_ff_00)
|
||||
boxHelper.name = 'helper'
|
||||
object.add(boxHelper)
|
||||
object.name = 'chunk';
|
||||
(object as any).tilesCount = data.geometry.positions.length / 3 / 4;
|
||||
(object as any).blocksCount = data.geometry.blocksCount
|
||||
if (!this.config.showChunkBorders) {
|
||||
boxHelper.visible = false
|
||||
}
|
||||
// should not compute it once
|
||||
if (Object.keys(data.geometry.signs).length) {
|
||||
for (const [posKey, { isWall, isHanging, rotation }] of Object.entries(data.geometry.signs)) {
|
||||
const signBlockEntity = this.blockEntities[posKey]
|
||||
if (!signBlockEntity) continue
|
||||
const [x, y, z] = posKey.split(',')
|
||||
const sign = this.renderSign(new Vec3(+x, +y, +z), rotation, isWall, isHanging, nbt.simplify(signBlockEntity))
|
||||
if (!sign) continue
|
||||
object.add(sign)
|
||||
}
|
||||
}
|
||||
if (Object.keys(data.geometry.heads).length) {
|
||||
for (const [posKey, { isWall, rotation }] of Object.entries(data.geometry.heads)) {
|
||||
const headBlockEntity = this.blockEntities[posKey]
|
||||
if (!headBlockEntity) continue
|
||||
const [x, y, z] = posKey.split(',')
|
||||
const head = this.renderHead(new Vec3(+x, +y, +z), rotation, isWall, nbt.simplify(headBlockEntity))
|
||||
if (!head) continue
|
||||
object.add(head)
|
||||
}
|
||||
}
|
||||
this.sectionObjects[data.key] = object
|
||||
this.updatePosDataChunk(data.key)
|
||||
object.matrixAutoUpdate = false
|
||||
mesh.onAfterRender = (renderer, scene, camera, geometry, material, group) => {
|
||||
// mesh.matrixAutoUpdate = false
|
||||
}
|
||||
|
||||
this.scene.add(object)
|
||||
}
|
||||
|
||||
getSignTexture (position: Vec3, blockEntity, backSide = false) {
|
||||
const chunk = chunkPos(position)
|
||||
let textures = this.chunkTextures.get(`${chunk[0]},${chunk[1]}`)
|
||||
if (!textures) {
|
||||
textures = {}
|
||||
this.chunkTextures.set(`${chunk[0]},${chunk[1]}`, textures)
|
||||
}
|
||||
const texturekey = `${position.x},${position.y},${position.z}`
|
||||
// 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 canvas = renderSign(blockEntity, PrismarineChat)
|
||||
if (!canvas) return
|
||||
const tex = new THREE.Texture(canvas)
|
||||
tex.magFilter = THREE.NearestFilter
|
||||
tex.minFilter = THREE.NearestFilter
|
||||
tex.needsUpdate = true
|
||||
textures[texturekey] = tex
|
||||
return tex
|
||||
}
|
||||
|
||||
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 (pos) {
|
||||
new tweenJs.Tween(this.camera.position).to({ x: pos.x, y: pos.y, z: pos.z }, 50).start()
|
||||
this.freeFlyState.position = pos
|
||||
}
|
||||
this.camera.rotation.set(pitch, yaw, this.cameraRoll, 'ZYX')
|
||||
}
|
||||
|
||||
render () {
|
||||
tweenJs.update()
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
renderHead (position: Vec3, rotation: number, isWall: boolean, blockEntity) {
|
||||
const textures = blockEntity.SkullOwner?.Properties?.textures[0]
|
||||
if (!textures) return
|
||||
|
||||
try {
|
||||
const textureData = JSON.parse(Buffer.from(textures.Value, 'base64').toString())
|
||||
const skinUrl = textureData.textures?.SKIN?.url
|
||||
|
||||
const mesh = getMesh(this, skinUrl, armorModel.head)
|
||||
const group = new THREE.Group()
|
||||
if (isWall) {
|
||||
mesh.position.set(0, 0.3125, 0.3125)
|
||||
}
|
||||
// move head model down as armor have a different offset than blocks
|
||||
mesh.position.y -= 23 / 16
|
||||
group.add(mesh)
|
||||
group.position.set(position.x + 0.5, position.y + 0.045, position.z + 0.5)
|
||||
group.rotation.set(
|
||||
0,
|
||||
-THREE.MathUtils.degToRad(rotation * (isWall ? 90 : 45 / 2)),
|
||||
0
|
||||
)
|
||||
group.scale.set(0.8, 0.8, 0.8)
|
||||
return group
|
||||
} catch (err) {
|
||||
console.error('Error decoding player texture:', err)
|
||||
}
|
||||
}
|
||||
|
||||
renderSign (position: Vec3, rotation: number, isWall: boolean, isHanging: boolean, blockEntity) {
|
||||
const tex = this.getSignTexture(position, blockEntity)
|
||||
|
||||
if (!tex) return
|
||||
|
||||
// todo implement
|
||||
// const key = JSON.stringify({ position, rotation, isWall })
|
||||
// if (this.signsCache.has(key)) {
|
||||
// console.log('cached', key)
|
||||
// } else {
|
||||
// this.signsCache.set(key, tex)
|
||||
// }
|
||||
|
||||
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ map: tex, transparent: true }))
|
||||
mesh.renderOrder = 999
|
||||
|
||||
const lineHeight = 7 / 16
|
||||
const scaleFactor = isHanging ? 1.3 : 1
|
||||
mesh.scale.set(1 * scaleFactor, lineHeight * scaleFactor, 1 * scaleFactor)
|
||||
|
||||
const thickness = (isHanging ? 2 : 1.5) / 16
|
||||
const wallSpacing = 0.25 / 16
|
||||
if (isWall && !isHanging) {
|
||||
mesh.position.set(0, 0, -0.5 + thickness + wallSpacing + 0.0001)
|
||||
} else {
|
||||
mesh.position.set(0, 0, thickness / 2 + 0.0001)
|
||||
}
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.rotation.set(
|
||||
0,
|
||||
-THREE.MathUtils.degToRad(rotation * (isWall ? 90 : 45 / 2)),
|
||||
0
|
||||
)
|
||||
group.add(mesh)
|
||||
const height = (isHanging ? 10 : 8) / 16
|
||||
const heightOffset = (isHanging ? 0 : isWall ? 4.333 : 9.333) / 16
|
||||
const textPosition = height / 2 + heightOffset
|
||||
group.position.set(position.x + 0.5, position.y + textPosition, position.z + 0.5)
|
||||
return group
|
||||
}
|
||||
|
||||
updateLight (chunkX: number, chunkZ: number) {
|
||||
// set all sections in the chunk dirty
|
||||
for (let y = this.worldConfig.minY; y < this.worldConfig.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)
|
||||
this.setSectionDirty(new Vec3(x, y, z))
|
||||
}
|
||||
}
|
||||
|
||||
updateShowChunksBorder (value: boolean) {
|
||||
this.config.showChunkBorders = value
|
||||
for (const object of Object.values(this.sectionObjects)) {
|
||||
for (const child of object.children) {
|
||||
if (child.name === 'helper') {
|
||||
child.visible = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetWorld () {
|
||||
super.resetWorld()
|
||||
|
||||
for (const mesh of Object.values(this.sectionObjects)) {
|
||||
this.scene.remove(mesh)
|
||||
}
|
||||
}
|
||||
|
||||
getLoadedChunksRelative (pos: Vec3, includeY = false) {
|
||||
const [currentX, currentY, currentZ] = sectionPos(pos)
|
||||
return Object.fromEntries(Object.entries(this.sectionObjects).map(([key, o]) => {
|
||||
const [xRaw, yRaw, zRaw] = key.split(',').map(Number)
|
||||
const [x, y, z] = sectionPos({ x: xRaw, y: yRaw, z: zRaw })
|
||||
const setKey = includeY ? `${x - currentX},${y - currentY},${z - currentZ}` : `${x - currentX},${z - currentZ}`
|
||||
return [setKey, o]
|
||||
}))
|
||||
}
|
||||
|
||||
cleanChunkTextures (x, z) {
|
||||
const textures = this.chunkTextures.get(`${Math.floor(x / 16)},${Math.floor(z / 16)}`) ?? {}
|
||||
for (const key of Object.keys(textures)) {
|
||||
textures[key].dispose()
|
||||
delete textures[key]
|
||||
}
|
||||
}
|
||||
|
||||
readdChunks () {
|
||||
for (const key of Object.keys(this.sectionObjects)) {
|
||||
this.scene.remove(this.sectionObjects[key])
|
||||
}
|
||||
setTimeout(() => {
|
||||
for (const key of Object.keys(this.sectionObjects)) {
|
||||
this.scene.add(this.sectionObjects[key])
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
disableUpdates (children = this.scene.children) {
|
||||
for (const child of children) {
|
||||
child.matrixWorldNeedsUpdate = false
|
||||
this.disableUpdates(child.children ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
removeColumn (x, z) {
|
||||
super.removeColumn(x, z)
|
||||
|
||||
this.cleanChunkTextures(x, z)
|
||||
for (let y = this.worldConfig.minY; y < this.worldConfig.worldHeight; y += 16) {
|
||||
this.setSectionDirty(new Vec3(x, y, z), false)
|
||||
const key = `${x},${y},${z}`
|
||||
const mesh = this.sectionObjects[key]
|
||||
if (mesh) {
|
||||
this.scene.remove(mesh)
|
||||
disposeObject(mesh)
|
||||
}
|
||||
delete this.sectionObjects[key]
|
||||
}
|
||||
}
|
||||
|
||||
setSectionDirty (...args: Parameters<WorldRendererCommon['setSectionDirty']>) {
|
||||
const [pos] = args
|
||||
this.cleanChunkTextures(pos.x, pos.z) // todo don't do this!
|
||||
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()
|
||||
return `${gl.getParameter(gl.getExtension('WEBGL_debug_renderer_info')!.UNMASKED_RENDERER_WEBGL)}`
|
||||
} catch (err) {
|
||||
console.warn('Failed to get renderer info', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class StarField {
|
||||
points?: THREE.Points
|
||||
private _enabled = true
|
||||
get enabled () {
|
||||
return this._enabled
|
||||
}
|
||||
|
||||
set enabled (value) {
|
||||
this._enabled = value
|
||||
if (this.points) {
|
||||
this.points.visible = value
|
||||
}
|
||||
}
|
||||
|
||||
constructor (private readonly scene: THREE.Scene) {
|
||||
}
|
||||
|
||||
addToScene () {
|
||||
if (this.points || !this.enabled) return
|
||||
|
||||
const radius = 80
|
||||
const depth = 50
|
||||
const count = 7000
|
||||
const factor = 7
|
||||
const saturation = 10
|
||||
const speed = 0.2
|
||||
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
|
||||
const genStar = r => new THREE.Vector3().setFromSpherical(new THREE.Spherical(r, Math.acos(1 - Math.random() * 2), Math.random() * 2 * Math.PI))
|
||||
|
||||
const positions = [] as number[]
|
||||
const colors = [] as number[]
|
||||
const sizes = Array.from({ length: count }, () => (0.5 + 0.5 * Math.random()) * factor)
|
||||
const color = new THREE.Color()
|
||||
let r = radius + depth
|
||||
const increment = depth / count
|
||||
for (let i = 0; i < count; i++) {
|
||||
r -= increment * Math.random()
|
||||
positions.push(...genStar(r).toArray())
|
||||
color.setHSL(i / count, saturation, 0.9)
|
||||
colors.push(color.r, color.g, color.b)
|
||||
}
|
||||
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
||||
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3))
|
||||
geometry.setAttribute('size', new THREE.Float32BufferAttribute(sizes, 1))
|
||||
|
||||
// Create a material
|
||||
const material = new StarfieldMaterial()
|
||||
material.blending = THREE.AdditiveBlending
|
||||
material.depthTest = false
|
||||
material.transparent = true
|
||||
|
||||
// Create points and add them to the scene
|
||||
this.points = new THREE.Points(geometry, material)
|
||||
this.scene.add(this.points)
|
||||
|
||||
const clock = new THREE.Clock()
|
||||
this.points.onBeforeRender = (renderer, scene, camera) => {
|
||||
this.points?.position.copy?.(camera.position)
|
||||
material.uniforms.time.value = clock.getElapsedTime() * speed
|
||||
}
|
||||
this.points.renderOrder = -1
|
||||
}
|
||||
|
||||
remove () {
|
||||
if (this.points) {
|
||||
this.points.geometry.dispose();
|
||||
(this.points.material as THREE.Material).dispose()
|
||||
this.scene.remove(this.points)
|
||||
|
||||
this.points = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const version = parseInt(THREE.REVISION.replaceAll(/\D+/g, ''), 10)
|
||||
class StarfieldMaterial extends THREE.ShaderMaterial {
|
||||
constructor () {
|
||||
super({
|
||||
uniforms: { time: { value: 0 }, fade: { value: 1 } },
|
||||
vertexShader: /* glsl */ `
|
||||
uniform float time;
|
||||
attribute float size;
|
||||
varying vec3 vColor;
|
||||
attribute vec3 color;
|
||||
void main() {
|
||||
vColor = color;
|
||||
vec4 mvPosition = modelViewMatrix * vec4(position, 0.5);
|
||||
gl_PointSize = 0.7 * size * (30.0 / -mvPosition.z) * (3.0 + sin(time + 100.0));
|
||||
gl_Position = projectionMatrix * mvPosition;
|
||||
}`,
|
||||
fragmentShader: /* glsl */ `
|
||||
uniform sampler2D pointTexture;
|
||||
uniform float fade;
|
||||
varying vec3 vColor;
|
||||
void main() {
|
||||
float opacity = 1.0;
|
||||
gl_FragColor = vec4(vColor, 1.0);
|
||||
|
||||
#include <tonemapping_fragment>
|
||||
#include <${version >= 154 ? 'colorspace_fragment' : 'encodings_fragment'}>
|
||||
}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { fromFormattedString, render, RenderNode, TextComponent } from '@xmcl/text-component'
|
||||
import type { ChatMessage } from 'prismarine-chat'
|
||||
import { createCanvas } from '../lib/utils'
|
||||
|
||||
type SignBlockEntity = {
|
||||
Color?: string
|
||||
|
|
@ -32,29 +32,40 @@ const parseSafe = (text: string, task: string) => {
|
|||
}
|
||||
}
|
||||
|
||||
export const renderSign = (blockEntity: SignBlockEntity, PrismarineChat: typeof ChatMessage, ctxHook = (ctx) => { }) => {
|
||||
const LEGACY_COLORS = {
|
||||
black: '#000000',
|
||||
dark_blue: '#0000AA',
|
||||
dark_green: '#00AA00',
|
||||
dark_aqua: '#00AAAA',
|
||||
dark_red: '#AA0000',
|
||||
dark_purple: '#AA00AA',
|
||||
gold: '#FFAA00',
|
||||
gray: '#AAAAAA',
|
||||
dark_gray: '#555555',
|
||||
blue: '#5555FF',
|
||||
green: '#55FF55',
|
||||
aqua: '#55FFFF',
|
||||
red: '#FF5555',
|
||||
light_purple: '#FF55FF',
|
||||
yellow: '#FFFF55',
|
||||
white: '#FFFFFF',
|
||||
}
|
||||
|
||||
export const renderSign = (
|
||||
blockEntity: SignBlockEntity,
|
||||
isHanging: boolean,
|
||||
PrismarineChat: typeof ChatMessage,
|
||||
ctxHook = (ctx) => { },
|
||||
canvasCreator = (width, height): OffscreenCanvas => { return createCanvas(width, height) }
|
||||
) => {
|
||||
// todo don't use texture rendering, investigate the font rendering when possible
|
||||
// or increase factor when needed
|
||||
const factor = 40
|
||||
const fontSize = 1.6 * factor
|
||||
const signboardY = [16, 9]
|
||||
const heightOffset = signboardY[0] - signboardY[1]
|
||||
const heightScalar = heightOffset / 16
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined
|
||||
let _ctx: CanvasRenderingContext2D | null = null
|
||||
const getCtx = () => {
|
||||
if (_ctx) return _ctx
|
||||
canvas = document.createElement('canvas')
|
||||
|
||||
canvas.width = 16 * factor
|
||||
canvas.height = heightOffset * factor
|
||||
|
||||
_ctx = canvas.getContext('2d')!
|
||||
_ctx.imageSmoothingEnabled = false
|
||||
|
||||
ctxHook(_ctx)
|
||||
return _ctx
|
||||
}
|
||||
// todo the text should be clipped based on it's render width (needs investigate)
|
||||
|
||||
const texts = 'front_text' in blockEntity ? /* > 1.20 */ blockEntity.front_text.messages : [
|
||||
blockEntity.Text1,
|
||||
|
|
@ -62,78 +73,144 @@ export const renderSign = (blockEntity: SignBlockEntity, PrismarineChat: typeof
|
|||
blockEntity.Text3,
|
||||
blockEntity.Text4
|
||||
]
|
||||
|
||||
if (!texts.some((text) => text !== 'null')) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const canvas = canvasCreator(16 * factor, heightOffset * factor)
|
||||
|
||||
const _ctx = canvas.getContext('2d')!
|
||||
|
||||
ctxHook(_ctx)
|
||||
const defaultColor = ('front_text' in blockEntity ? blockEntity.front_text.color : blockEntity.Color) || 'black'
|
||||
for (const [lineNum, text] of texts.slice(0, 4).entries()) {
|
||||
// todo: in pre flatenning it seems the format was not json
|
||||
if (text === 'null') continue
|
||||
const parsed = text?.startsWith('{') || text?.startsWith('"') ? parseSafe(text ?? '""', 'sign text') : text
|
||||
if (!parsed || (typeof parsed !== 'object' && typeof parsed !== 'string')) continue
|
||||
// todo fix type
|
||||
const message = typeof parsed === 'string' ? fromFormattedString(parsed) : new PrismarineChat(parsed) as never
|
||||
const patchExtra = ({ extra }: TextComponent) => {
|
||||
if (!extra) return
|
||||
for (const child of extra) {
|
||||
if (child.color) {
|
||||
child.color = child.color === 'dark_green' ? child.color.toUpperCase() : child.color.toLowerCase()
|
||||
}
|
||||
patchExtra(child)
|
||||
}
|
||||
}
|
||||
patchExtra(message)
|
||||
const rendered = render(message)
|
||||
|
||||
const toRenderCanvas: Array<{
|
||||
fontStyle: string
|
||||
fillStyle: string
|
||||
underlineStyle: boolean
|
||||
strikeStyle: boolean
|
||||
text: string
|
||||
}> = []
|
||||
let plainText = ''
|
||||
// todo the text should be clipped based on it's render width (needs investigate)
|
||||
const MAX_LENGTH = 50 // avoid abusing the signboard
|
||||
const renderText = (node: RenderNode) => {
|
||||
const { component } = node
|
||||
let { text } = component
|
||||
if (plainText.length + text.length > MAX_LENGTH) {
|
||||
text = text.slice(0, MAX_LENGTH - plainText.length)
|
||||
if (!text) return false
|
||||
}
|
||||
plainText += text
|
||||
toRenderCanvas.push({
|
||||
fontStyle: `${component.bold ? 'bold' : ''} ${component.italic ? 'italic' : ''}`,
|
||||
fillStyle: node.style['color'] || defaultColor,
|
||||
underlineStyle: component.underlined ?? false,
|
||||
strikeStyle: component.strikethrough ?? false,
|
||||
text
|
||||
})
|
||||
for (const child of node.children) {
|
||||
const stop = renderText(child) === false
|
||||
if (stop) return false
|
||||
}
|
||||
}
|
||||
|
||||
renderText(rendered)
|
||||
|
||||
// skip rendering empty lines (and possible signs)
|
||||
if (!plainText.trim()) continue
|
||||
|
||||
const ctx = getCtx()
|
||||
const fontSize = 1.6 * factor
|
||||
ctx.font = `${fontSize}px mojangles`
|
||||
const textWidth = ctx.measureText(plainText).width
|
||||
|
||||
let renderedWidth = 0
|
||||
for (const { fillStyle, fontStyle, strikeStyle, text, underlineStyle } of toRenderCanvas) {
|
||||
// todo strikeStyle, underlineStyle
|
||||
ctx.fillStyle = fillStyle
|
||||
ctx.font = `${fontStyle} ${fontSize}px mojangles`
|
||||
ctx.fillText(text, (canvas!.width - textWidth) / 2 + renderedWidth, fontSize * (lineNum + 1))
|
||||
renderedWidth += ctx.measureText(text).width // todo isn't the font is monospace?
|
||||
}
|
||||
renderComponent(text, PrismarineChat, canvas, fontSize, defaultColor, fontSize * (lineNum + 1) + (isHanging ? 0 : -8))
|
||||
}
|
||||
// ctx.fillStyle = 'red'
|
||||
// ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
return canvas
|
||||
}
|
||||
|
||||
export const renderComponent = (
|
||||
text: JsonEncodedType | string | undefined,
|
||||
PrismarineChat: typeof ChatMessage,
|
||||
canvas: OffscreenCanvas,
|
||||
fontSize: number,
|
||||
defaultColor: string,
|
||||
offset = 0
|
||||
) => {
|
||||
// todo: in pre flatenning it seems the format was not json
|
||||
const parsed = typeof text === 'string' && (text?.startsWith('{') || text?.startsWith('"')) ? parseSafe(text ?? '""', 'sign text') : text
|
||||
if (!parsed || (typeof parsed !== 'object' && typeof parsed !== 'string')) return
|
||||
// todo fix type
|
||||
|
||||
const ctx = canvas.getContext('2d')!
|
||||
if (!ctx) throw new Error('Could not get 2d context')
|
||||
ctx.imageSmoothingEnabled = false
|
||||
ctx.font = `${fontSize}px mojangles`
|
||||
|
||||
type Formatting = {
|
||||
color: string | undefined
|
||||
underlined: boolean | undefined
|
||||
strikethrough: boolean | undefined
|
||||
bold: boolean | undefined
|
||||
italic: boolean | undefined
|
||||
}
|
||||
|
||||
type Message = ChatMessage & Formatting & { text: string }
|
||||
|
||||
const message = new PrismarineChat(parsed) as Message
|
||||
|
||||
const toRenderCanvas: Array<{
|
||||
fontStyle: string
|
||||
fillStyle: string
|
||||
underlineStyle: boolean
|
||||
strikeStyle: boolean
|
||||
offset: number
|
||||
text: string
|
||||
}> = []
|
||||
let visibleFormatting = false
|
||||
let plainText = ''
|
||||
let textOffset = offset
|
||||
const textWidths: number[] = []
|
||||
|
||||
const renderText = (component: Message, parentFormatting?: Formatting | undefined) => {
|
||||
const { text } = component
|
||||
const formatting = {
|
||||
color: component.color ?? parentFormatting?.color,
|
||||
underlined: component.underlined ?? parentFormatting?.underlined,
|
||||
strikethrough: component.strikethrough ?? parentFormatting?.strikethrough,
|
||||
bold: component.bold ?? parentFormatting?.bold,
|
||||
italic: component.italic ?? parentFormatting?.italic
|
||||
}
|
||||
visibleFormatting = visibleFormatting || formatting.underlined || formatting.strikethrough || false
|
||||
if (text?.includes('\n')) {
|
||||
for (const line of text.split('\n')) {
|
||||
addTextPart(line, formatting)
|
||||
textOffset += fontSize
|
||||
plainText = ''
|
||||
}
|
||||
} else if (text) {
|
||||
addTextPart(text, formatting)
|
||||
}
|
||||
if (component.extra) {
|
||||
for (const child of component.extra) {
|
||||
renderText(child as Message, formatting)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const addTextPart = (text: string, formatting: Formatting) => {
|
||||
plainText += text
|
||||
textWidths[textOffset] = ctx.measureText(plainText).width
|
||||
let color = formatting.color ?? defaultColor
|
||||
if (!color.startsWith('#')) {
|
||||
color = LEGACY_COLORS[color.toLowerCase()] || color
|
||||
}
|
||||
toRenderCanvas.push({
|
||||
fontStyle: `${formatting.bold ? 'bold' : ''} ${formatting.italic ? 'italic' : ''}`,
|
||||
fillStyle: color,
|
||||
underlineStyle: formatting.underlined ?? false,
|
||||
strikeStyle: formatting.strikethrough ?? false,
|
||||
offset: textOffset,
|
||||
text
|
||||
})
|
||||
}
|
||||
|
||||
renderText(message)
|
||||
|
||||
// skip rendering empty lines
|
||||
if (!visibleFormatting && !message.toString().trim()) return
|
||||
|
||||
let renderedWidth = 0
|
||||
let previousOffsetY = 0
|
||||
for (const { fillStyle, fontStyle, underlineStyle, strikeStyle, offset: offsetY, text } of toRenderCanvas) {
|
||||
if (previousOffsetY !== offsetY) {
|
||||
renderedWidth = 0
|
||||
}
|
||||
previousOffsetY = offsetY
|
||||
ctx.fillStyle = fillStyle
|
||||
ctx.textRendering = 'optimizeLegibility'
|
||||
ctx.font = `${fontStyle} ${fontSize}px mojangles`
|
||||
const textWidth = textWidths[offsetY] ?? ctx.measureText(text).width
|
||||
const offsetX = (canvas.width - textWidth) / 2 + renderedWidth
|
||||
ctx.fillText(text, offsetX, offsetY)
|
||||
if (strikeStyle) {
|
||||
ctx.lineWidth = fontSize / 8
|
||||
ctx.strokeStyle = fillStyle
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(offsetX, offsetY - ctx.lineWidth * 2.5)
|
||||
ctx.lineTo(offsetX + ctx.measureText(text).width, offsetY - ctx.lineWidth * 2.5)
|
||||
ctx.stroke()
|
||||
}
|
||||
if (underlineStyle) {
|
||||
ctx.lineWidth = fontSize / 8
|
||||
ctx.strokeStyle = fillStyle
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(offsetX, offsetY + ctx.lineWidth)
|
||||
ctx.lineTo(offsetX + ctx.measureText(text).width, offsetY + ctx.lineWidth)
|
||||
ctx.stroke()
|
||||
}
|
||||
renderedWidth += ctx.measureText(text).width
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,9 +21,14 @@ const blockEntity = {
|
|||
|
||||
await document.fonts.load('1em mojangles')
|
||||
|
||||
const canvas = renderSign(blockEntity, PrismarineChat, (ctx) => {
|
||||
const canvas = renderSign(blockEntity, false, PrismarineChat, (ctx) => {
|
||||
ctx.drawImage(img, 0, 0, ctx.canvas.width, ctx.canvas.height)
|
||||
})
|
||||
}, (width, height) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
return canvas as unknown as OffscreenCanvas
|
||||
}) as unknown as HTMLCanvasElement
|
||||
|
||||
if (canvas) {
|
||||
canvas.style.imageRendering = 'pixelated'
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ global.document = {
|
|||
|
||||
const render = (entity) => {
|
||||
ctxTexts = []
|
||||
renderSign(entity, PrismarineChat)
|
||||
renderSign(entity, true, PrismarineChat)
|
||||
return ctxTexts.map(({ text, y }) => [y / 64, text])
|
||||
}
|
||||
|
||||
|
|
@ -37,10 +37,6 @@ test('sign renderer', () => {
|
|||
} as any
|
||||
expect(render(blockEntity)).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
1,
|
||||
"",
|
||||
],
|
||||
[
|
||||
1,
|
||||
"Minecraft ",
|
||||
|
|
|
|||
74
renderer/viewer/three/appShared.ts
Normal file
74
renderer/viewer/three/appShared.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { BlockModel } from 'mc-assets/dist/types'
|
||||
import { ItemSpecificContextProperties, PlayerStateRenderer } from 'renderer/viewer/lib/basePlayerState'
|
||||
import { GeneralInputItem, getItemModelName } from '../../../src/mineflayer/items'
|
||||
import { ResourcesManager, ResourcesManagerTransferred } from '../../../src/resourcesManager'
|
||||
import { renderSlot } from './renderSlot'
|
||||
|
||||
export const getItemUv = (item: Record<string, any>, specificProps: ItemSpecificContextProperties, resourcesManager: ResourcesManagerTransferred, playerState: PlayerStateRenderer): {
|
||||
u: number
|
||||
v: number
|
||||
su: number
|
||||
sv: number
|
||||
renderInfo?: ReturnType<typeof renderSlot>
|
||||
// texture: ImageBitmap
|
||||
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
|
||||
const { blockState } = item
|
||||
try {
|
||||
const name =
|
||||
blockState
|
||||
? loadedData.blocksByStateId[blockState]?.name
|
||||
: typeof idOrName === 'number' ? loadedData.items[idOrName]?.name : idOrName
|
||||
if (!name) throw new Error(`Item not found: ${idOrName}`)
|
||||
|
||||
const model = getItemModelName({
|
||||
...item,
|
||||
name,
|
||||
} as GeneralInputItem, specificProps, resourcesManager, playerState)
|
||||
|
||||
const renderInfo = renderSlot({
|
||||
modelName: model,
|
||||
}, resourcesManager, 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
120
renderer/viewer/three/cameraShake.ts
Normal file
120
renderer/viewer/three/cameraShake.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import * as THREE from 'three'
|
||||
import { WorldRendererThree } from './worldrendererThree'
|
||||
|
||||
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 worldRenderer: WorldRendererThree, public onRenderCallbacks: Array<() => void>) {
|
||||
onRenderCallbacks.push(() => {
|
||||
this.update()
|
||||
})
|
||||
}
|
||||
|
||||
setBaseRotation (pitch: number, yaw: number) {
|
||||
this.basePitch = pitch
|
||||
this.baseYaw = yaw
|
||||
this.update()
|
||||
}
|
||||
|
||||
getBaseRotation () {
|
||||
return { pitch: this.basePitch, yaw: this.baseYaw }
|
||||
}
|
||||
|
||||
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 () {
|
||||
if (this.worldRenderer.playerStateUtils.isSpectatingEntity()) {
|
||||
// Remove any shaking when spectating
|
||||
this.rollAngle = 0
|
||||
this.rollAnimation = undefined
|
||||
}
|
||||
// Update roll animation
|
||||
if (this.rollAnimation) {
|
||||
const now = performance.now()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const camera = this.worldRenderer.cameraObject
|
||||
|
||||
if (this.worldRenderer.cameraGroupVr) {
|
||||
// For VR camera, only apply yaw rotation
|
||||
const yawQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), this.baseYaw)
|
||||
camera.setRotationFromQuaternion(yawQuat)
|
||||
} else {
|
||||
// For regular camera, apply all rotations
|
||||
// Add tiny offsets to prevent z-fighting at ideal angles (90, 180, 270 degrees)
|
||||
const pitchOffset = this.addAntiZfightingOffset(this.basePitch)
|
||||
const yawOffset = this.addAntiZfightingOffset(this.baseYaw)
|
||||
|
||||
const pitchQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), pitchOffset)
|
||||
const yawQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), yawOffset)
|
||||
const rollQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), THREE.MathUtils.degToRad(this.rollAngle))
|
||||
// Combine rotations in the correct order: pitch -> yaw -> roll
|
||||
const finalQuat = yawQuat.multiply(pitchQuat).multiply(rollQuat)
|
||||
camera.setRotationFromQuaternion(finalQuat)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private addAntiZfightingOffset (angle: number): number {
|
||||
const offset = 0.001 // Very small offset in radians (about 0.057 degrees)
|
||||
|
||||
// Check if the angle is close to ideal angles (0, π/2, π, 3π/2)
|
||||
const normalizedAngle = ((angle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)
|
||||
const tolerance = 0.01 // Tolerance for considering an angle "ideal"
|
||||
|
||||
if (Math.abs(normalizedAngle) < tolerance ||
|
||||
Math.abs(normalizedAngle - Math.PI / 2) < tolerance ||
|
||||
Math.abs(normalizedAngle - Math.PI) < tolerance ||
|
||||
Math.abs(normalizedAngle - 3 * Math.PI / 2) < tolerance) {
|
||||
return angle + offset
|
||||
}
|
||||
|
||||
return angle
|
||||
}
|
||||
}
|
||||
328
renderer/viewer/three/documentRenderer.ts
Normal file
328
renderer/viewer/three/documentRenderer.ts
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
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'
|
||||
import { WorldRendererConfig } from '../lib/worldrendererCommon'
|
||||
|
||||
export class DocumentRenderer {
|
||||
canvas: HTMLCanvasElement | OffscreenCanvas
|
||||
readonly renderer: THREE.WebGLRenderer
|
||||
private animationFrameId?: number
|
||||
private timeoutId?: number
|
||||
private lastRenderTime = 0
|
||||
|
||||
private previousCanvasWidth = 0
|
||||
private previousCanvasHeight = 0
|
||||
private currentWidth = 0
|
||||
private currentHeight = 0
|
||||
|
||||
private renderedFps = 0
|
||||
private fpsInterval: any
|
||||
private readonly stats: TopRightStats | undefined
|
||||
private paused = false
|
||||
disconnected = false
|
||||
preRender = () => { }
|
||||
render = (sizeChanged: boolean) => { }
|
||||
postRender = () => { }
|
||||
sizeChanged = () => { }
|
||||
droppedFpsPercentage: number
|
||||
config: GraphicsBackendConfig
|
||||
onRender = [] as Array<(sizeChanged: boolean) => void>
|
||||
inWorldRenderingConfig: WorldRendererConfig | undefined
|
||||
|
||||
constructor (initOptions: GraphicsInitOptions, public externalCanvas?: OffscreenCanvas) {
|
||||
this.config = initOptions.config
|
||||
|
||||
// Handle canvas creation/transfer based on context
|
||||
if (externalCanvas) {
|
||||
this.canvas = externalCanvas
|
||||
} else {
|
||||
this.addToPage()
|
||||
}
|
||||
|
||||
try {
|
||||
this.renderer = new THREE.WebGLRenderer({
|
||||
canvas: this.canvas,
|
||||
preserveDrawingBuffer: true,
|
||||
logarithmicDepthBuffer: true,
|
||||
powerPreference: this.config.powerPreference
|
||||
})
|
||||
} catch (err) {
|
||||
initOptions.callbacks.displayCriticalError(new Error(`Failed to create WebGL context, not possible to render (restart browser): ${err.message}`))
|
||||
throw err
|
||||
}
|
||||
this.renderer.outputColorSpace = THREE.LinearSRGBColorSpace
|
||||
if (!externalCanvas) {
|
||||
this.updatePixelRatio()
|
||||
}
|
||||
this.sizeUpdated()
|
||||
// Initialize previous dimensions
|
||||
this.previousCanvasWidth = this.canvas.width
|
||||
this.previousCanvasHeight = this.canvas.height
|
||||
|
||||
const supportsWebGL2 = 'WebGL2RenderingContext' in window
|
||||
// Only initialize stats and DOM-related features in main thread
|
||||
if (!externalCanvas && supportsWebGL2) {
|
||||
this.stats = new TopRightStats(this.canvas as HTMLCanvasElement, this.config.statsVisible)
|
||||
this.setupFpsTracking()
|
||||
}
|
||||
|
||||
this.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)
|
||||
}
|
||||
|
||||
sizeUpdated () {
|
||||
this.renderer.setSize(this.currentWidth, this.currentHeight, false)
|
||||
}
|
||||
|
||||
private addToPage () {
|
||||
this.canvas = addCanvasToPage()
|
||||
this.updateCanvasSize()
|
||||
}
|
||||
|
||||
updateSizeExternal (newWidth: number, newHeight: number, pixelRatio: number) {
|
||||
this.currentWidth = newWidth
|
||||
this.currentHeight = newHeight
|
||||
this.renderer.setPixelRatio(pixelRatio)
|
||||
this.sizeUpdated()
|
||||
}
|
||||
|
||||
private updateCanvasSize () {
|
||||
if (!this.externalCanvas) {
|
||||
const innnerWidth = window.innerWidth
|
||||
const innnerHeight = window.innerHeight
|
||||
if (this.currentWidth !== innnerWidth) {
|
||||
this.currentWidth = innnerWidth
|
||||
}
|
||||
if (this.currentHeight !== innnerHeight) {
|
||||
this.currentHeight = innnerHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 startRenderLoop () {
|
||||
const animate = () => {
|
||||
if (this.disconnected) return
|
||||
|
||||
if (this.config.timeoutRendering) {
|
||||
this.timeoutId = setTimeout(animate, this.config.fpsLimit ? 1000 / this.config.fpsLimit : 0) as unknown as number
|
||||
} else {
|
||||
this.animationFrameId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
if (this.paused || (this.renderer.xr.isPresenting && !this.inWorldRenderingConfig?.vrPageGameRendering)) return
|
||||
|
||||
// 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
|
||||
this.updateCanvasSize()
|
||||
if (this.previousCanvasWidth !== this.currentWidth || this.previousCanvasHeight !== this.currentHeight) {
|
||||
this.previousCanvasWidth = this.currentWidth
|
||||
this.previousCanvasHeight = this.currentHeight
|
||||
this.sizeUpdated()
|
||||
sizeChanged = true
|
||||
}
|
||||
|
||||
this.frameRender(sizeChanged)
|
||||
|
||||
// Update stats visibility each frame (main thread only)
|
||||
if (this.config.statsVisible !== undefined) {
|
||||
this.stats?.setVisibility(this.config.statsVisible)
|
||||
}
|
||||
}
|
||||
|
||||
animate()
|
||||
}
|
||||
|
||||
frameRender (sizeChanged: boolean) {
|
||||
this.preRender()
|
||||
this.stats?.markStart()
|
||||
tween.update()
|
||||
if (!globalThis.freezeRender) {
|
||||
this.render(sizeChanged)
|
||||
}
|
||||
for (const fn of this.onRender) {
|
||||
fn(sizeChanged)
|
||||
}
|
||||
this.renderedFps++
|
||||
this.stats?.markEnd()
|
||||
this.postRender()
|
||||
}
|
||||
|
||||
setPaused (paused: boolean) {
|
||||
this.paused = paused
|
||||
}
|
||||
|
||||
dispose () {
|
||||
this.disconnected = true
|
||||
if (this.animationFrameId) {
|
||||
cancelAnimationFrame(this.animationFrameId)
|
||||
}
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId)
|
||||
}
|
||||
if (this.canvas instanceof HTMLCanvasElement) {
|
||||
this.canvas.remove()
|
||||
}
|
||||
clearInterval(this.fpsInterval)
|
||||
this.stats?.dispose()
|
||||
this.renderer.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 (process.env.NODE_ENV === 'development' && document.exitPointerLock) {
|
||||
this.stats.dom.style.top = ''
|
||||
this.stats.dom.style.bottom = '0'
|
||||
}
|
||||
if (hasRamPanel) {
|
||||
this.addStat(this.stats2.dom)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
const addCanvasToPage = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.id = 'viewer-canvas'
|
||||
document.body.appendChild(canvas)
|
||||
return canvas
|
||||
}
|
||||
|
||||
export const addCanvasForWorker = () => {
|
||||
const canvas = addCanvasToPage()
|
||||
const transferred = canvas.transferControlToOffscreen()
|
||||
let removed = false
|
||||
let onSizeChanged = (w, h) => { }
|
||||
let oldSize = { width: 0, height: 0 }
|
||||
const checkSize = () => {
|
||||
if (removed) return
|
||||
if (oldSize.width !== window.innerWidth || oldSize.height !== window.innerHeight) {
|
||||
onSizeChanged(window.innerWidth, window.innerHeight)
|
||||
oldSize = { width: window.innerWidth, height: window.innerHeight }
|
||||
}
|
||||
requestAnimationFrame(checkSize)
|
||||
}
|
||||
requestAnimationFrame(checkSize)
|
||||
return {
|
||||
canvas: transferred,
|
||||
destroy () {
|
||||
removed = true
|
||||
canvas.remove()
|
||||
},
|
||||
onSizeChanged (cb: (width: number, height: number) => void) {
|
||||
onSizeChanged = cb
|
||||
},
|
||||
get size () {
|
||||
return { width: window.innerWidth, height: window.innerHeight }
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -2,11 +2,12 @@ import * as THREE from 'three'
|
|||
import { OBJLoader } from 'three-stdlib'
|
||||
import huskPng from 'mc-assets/dist/other-textures/latest/entity/zombie/husk.png'
|
||||
import { Vec3 } from 'vec3'
|
||||
import ocelotPng from '../../../../node_modules/mc-assets/dist/other-textures/latest/entity/cat/ocelot.png'
|
||||
import arrowTexture from '../../../../node_modules/mc-assets/dist/other-textures/1.21.2/entity/projectiles/arrow.png'
|
||||
import spectralArrowTexture from '../../../../node_modules/mc-assets/dist/other-textures/1.21.2/entity/projectiles/spectral_arrow.png'
|
||||
import tippedArrowTexture from '../../../../node_modules/mc-assets/dist/other-textures/1.21.2/entity/projectiles/tipped_arrow.png'
|
||||
import { WorldRendererCommon } from '../worldrendererCommon'
|
||||
import { loadTexture } from '../utils'
|
||||
import { loadTexture } from '../threeJsUtils'
|
||||
import { WorldRendererThree } from '../worldrendererThree'
|
||||
import entities from './entities.json'
|
||||
import { externalModels } from './objModels'
|
||||
import externalTexturesJson from './externalTextures.json'
|
||||
|
|
@ -223,7 +224,7 @@ function addCube (
|
|||
}
|
||||
|
||||
export function getMesh (
|
||||
worldRenderer: WorldRendererCommon | undefined,
|
||||
worldRenderer: WorldRendererThree | undefined,
|
||||
texture: string,
|
||||
jsonModel: JsonModel,
|
||||
overrides: EntityOverrides = {},
|
||||
|
|
@ -237,10 +238,11 @@ 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.blocksAtlasJson.textures[blockName]
|
||||
if (textureInfo) {
|
||||
textureWidth = blocksTexture!.image.width
|
||||
textureHeight = blocksTexture!.image.height
|
||||
textureWidth = blocksTexture?.image.width ?? textureWidth
|
||||
textureHeight = blocksTexture?.image.height ?? textureHeight
|
||||
// todo support su/sv
|
||||
textureOffset = [textureInfo.u, textureInfo.v]
|
||||
} else {
|
||||
console.error(`Unknown block ${blockName}`)
|
||||
|
|
@ -437,7 +439,7 @@ export class EntityMesh {
|
|||
constructor (
|
||||
version: string,
|
||||
type: string,
|
||||
worldRenderer?: WorldRendererCommon,
|
||||
worldRenderer?: WorldRendererThree,
|
||||
overrides: EntityOverrides = {},
|
||||
debugFlags: EntityDebugFlags = {}
|
||||
) {
|
||||
|
|
@ -456,7 +458,7 @@ export class EntityMesh {
|
|||
'skeleton_horse': `textures/${version}/entity/horse/horse_skeleton.png`,
|
||||
'donkey': `textures/${version}/entity/horse/donkey.png`,
|
||||
'mule': `textures/${version}/entity/horse/mule.png`,
|
||||
'ocelot': `textures/${version}/entity/cat/ocelot.png`,
|
||||
'ocelot': ocelotPng,
|
||||
'arrow': arrowTexture,
|
||||
'spectral_arrow': spectralArrowTexture,
|
||||
'tipped_arrow': tippedArrowTexture
|
||||
|
|
@ -527,12 +529,6 @@ export class EntityMesh {
|
|||
debugFlags)
|
||||
mesh.name = `geometry_${name}`
|
||||
this.mesh.add(mesh)
|
||||
|
||||
const skeletonHelper = new THREE.SkeletonHelper(mesh)
|
||||
//@ts-expect-error
|
||||
skeletonHelper.material.linewidth = 2
|
||||
skeletonHelper.visible = false
|
||||
this.mesh.add(skeletonHelper)
|
||||
}
|
||||
debugFlags.type = 'bedrock'
|
||||
}
|
||||
|
|
@ -551,3 +547,4 @@ export class EntityMesh {
|
|||
}
|
||||
}
|
||||
}
|
||||
globalThis.EntityMesh = EntityMesh
|
||||
171
renderer/viewer/three/entity/animations.js
Normal file
171
renderer/viewer/three/entity/animations.js
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
//@ts-check
|
||||
import { PlayerAnimation } from 'skinview3d'
|
||||
|
||||
export class WalkingGeneralSwing extends PlayerAnimation {
|
||||
|
||||
switchAnimationCallback
|
||||
|
||||
isRunning = false
|
||||
isMoving = true
|
||||
isCrouched = false
|
||||
|
||||
_startArmSwing
|
||||
|
||||
swingArm() {
|
||||
this._startArmSwing = this.progress
|
||||
}
|
||||
|
||||
animate(player) {
|
||||
// Multiply by animation's natural speed
|
||||
let t = 0
|
||||
const updateT = () => {
|
||||
if (!this.isMoving) {
|
||||
t = 0
|
||||
return
|
||||
}
|
||||
if (this.isRunning) {
|
||||
t = this.progress * 10 + Math.PI * 0.5
|
||||
} else {
|
||||
t = this.progress * 8
|
||||
}
|
||||
}
|
||||
updateT()
|
||||
let reset = false
|
||||
|
||||
croughAnimation(player, this.isCrouched)
|
||||
|
||||
if ((this.isRunning ? Math.cos(t) : Math.sin(t)) < 0.01) {
|
||||
if (this.switchAnimationCallback) {
|
||||
reset = true
|
||||
this.progress = 0
|
||||
updateT()
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isRunning) {
|
||||
// Leg swing with larger amplitude
|
||||
player.skin.leftLeg.rotation.x = Math.cos(t + Math.PI) * 1.3
|
||||
player.skin.rightLeg.rotation.x = Math.cos(t) * 1.3
|
||||
} else {
|
||||
// Leg swing
|
||||
player.skin.leftLeg.rotation.x = Math.sin(t) * 0.5
|
||||
player.skin.rightLeg.rotation.x = Math.sin(t + Math.PI) * 0.5
|
||||
}
|
||||
|
||||
if (this._startArmSwing) {
|
||||
const tHand = (this.progress - this._startArmSwing) * 18 + Math.PI * 0.5
|
||||
// player.skin.rightArm.rotation.x = Math.cos(tHand) * 1.5
|
||||
// const basicArmRotationZ = Math.PI * 0.1
|
||||
// player.skin.rightArm.rotation.z = Math.cos(t + Math.PI) * 0.3 - basicArmRotationZ
|
||||
HitAnimation.animate((this.progress - this._startArmSwing), player, this.isMoving)
|
||||
|
||||
if (tHand > Math.PI + Math.PI) {
|
||||
this._startArmSwing = null
|
||||
player.skin.rightArm.rotation.z = 0
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isRunning) {
|
||||
player.skin.leftArm.rotation.x = Math.cos(t) * 1.5
|
||||
if (!this._startArmSwing) {
|
||||
player.skin.rightArm.rotation.x = Math.cos(t + Math.PI) * 1.5
|
||||
}
|
||||
const basicArmRotationZ = Math.PI * 0.1
|
||||
player.skin.leftArm.rotation.z = Math.cos(t) * 0.1 + basicArmRotationZ
|
||||
if (!this._startArmSwing) {
|
||||
player.skin.rightArm.rotation.z = Math.cos(t + Math.PI) * 0.1 - basicArmRotationZ
|
||||
}
|
||||
} else {
|
||||
// Arm swing
|
||||
player.skin.leftArm.rotation.x = Math.sin(t + Math.PI) * 0.5
|
||||
if (!this._startArmSwing) {
|
||||
player.skin.rightArm.rotation.x = Math.sin(t) * 0.5
|
||||
}
|
||||
const basicArmRotationZ = Math.PI * 0.02
|
||||
player.skin.leftArm.rotation.z = Math.cos(t) * 0.03 + basicArmRotationZ
|
||||
if (!this._startArmSwing) {
|
||||
player.skin.rightArm.rotation.z = Math.cos(t + Math.PI) * 0.03 - basicArmRotationZ
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isRunning) {
|
||||
player.rotation.z = Math.cos(t + Math.PI) * 0.01
|
||||
}
|
||||
if (this.isRunning) {
|
||||
const basicCapeRotationX = Math.PI * 0.3
|
||||
player.cape.rotation.x = Math.sin(t * 2) * 0.1 + basicCapeRotationX
|
||||
} else {
|
||||
// Always add an angle for cape around the x axis
|
||||
const basicCapeRotationX = Math.PI * 0.06
|
||||
player.cape.rotation.x = Math.sin(t / 1.5) * 0.06 + basicCapeRotationX
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
this.switchAnimationCallback()
|
||||
this.switchAnimationCallback = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const HitAnimation = {
|
||||
animate(progress, player, isMovingOrRunning) {
|
||||
const t = progress * 18
|
||||
player.skin.rightArm.rotation.x = -0.453_786_055_2 * 2 + 2 * Math.sin(t + Math.PI) * 0.3
|
||||
|
||||
if (!isMovingOrRunning) {
|
||||
const basicArmRotationZ = 0.01 * Math.PI + 0.06
|
||||
player.skin.rightArm.rotation.z = -Math.cos(t) * 0.403 + basicArmRotationZ
|
||||
player.skin.body.rotation.y = -Math.cos(t) * 0.06
|
||||
player.skin.leftArm.rotation.x = Math.sin(t + Math.PI) * 0.077
|
||||
player.skin.leftArm.rotation.z = -Math.cos(t) * 0.015 + 0.13 - 0.05
|
||||
player.skin.leftArm.position.z = Math.cos(t) * 0.3
|
||||
player.skin.leftArm.position.x = 5 - Math.cos(t) * 0.05
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const croughAnimation = (player, isCrouched) => {
|
||||
const erp = 0
|
||||
|
||||
// let pr = this.progress * 8;
|
||||
let pr = isCrouched ? 1 : 0
|
||||
const showProgress = false
|
||||
if (showProgress) {
|
||||
pr = Math.floor(pr)
|
||||
}
|
||||
player.skin.body.rotation.x = 0.453_786_055_2 * Math.abs(Math.sin((pr * Math.PI) / 2))
|
||||
player.skin.body.position.z =
|
||||
1.325_618_1 * Math.abs(Math.sin((pr * Math.PI) / 2)) - 3.450_031_037_7 * Math.abs(Math.sin((pr * Math.PI) / 2))
|
||||
player.skin.body.position.y = -6 - 2.103_677_462 * Math.abs(Math.sin((pr * Math.PI) / 2))
|
||||
player.cape.position.y = 8 - 1.851_236_166_577_372 * Math.abs(Math.sin((pr * Math.PI) / 2))
|
||||
player.cape.rotation.x = (10.8 * Math.PI) / 180 + 0.294_220_265_771 * Math.abs(Math.sin((pr * Math.PI) / 2))
|
||||
player.cape.position.z =
|
||||
-2 + 3.786_619_432 * Math.abs(Math.sin((pr * Math.PI) / 2)) - 3.450_031_037_7 * Math.abs(Math.sin((pr * Math.PI) / 2))
|
||||
player.elytra.position.x = player.cape.position.x
|
||||
player.elytra.position.y = player.cape.position.y
|
||||
player.elytra.position.z = player.cape.position.z
|
||||
player.elytra.rotation.x = player.cape.rotation.x - (10.8 * Math.PI) / 180
|
||||
// const pr1 = this.progress / this.speed;
|
||||
const pr1 = 1
|
||||
if (Math.abs(Math.sin((pr * Math.PI) / 2)) === 1) {
|
||||
player.elytra.leftWing.rotation.z =
|
||||
0.261_799_44 + 0.458_200_6 * Math.abs(Math.sin((Math.min(pr1 - erp, 1) * Math.PI) / 2))
|
||||
player.elytra.updateRightWing()
|
||||
} else if (isCrouched !== undefined) {
|
||||
player.elytra.leftWing.rotation.z =
|
||||
0.72 - 0.458_200_6 * Math.abs(Math.sin((Math.min(pr1 - erp, 1) * Math.PI) / 2))
|
||||
player.elytra.updateRightWing()
|
||||
}
|
||||
player.skin.head.position.y = -3.618_325_234_674 * Math.abs(Math.sin((pr * Math.PI) / 2))
|
||||
player.skin.leftArm.position.z =
|
||||
3.618_325_234_674 * Math.abs(Math.sin((pr * Math.PI) / 2)) - 3.450_031_037_7 * Math.abs(Math.sin((pr * Math.PI) / 2))
|
||||
player.skin.rightArm.position.z = player.skin.leftArm.position.z
|
||||
player.skin.leftArm.rotation.x = 0.410_367_746_202 * Math.abs(Math.sin((pr * Math.PI) / 2))
|
||||
player.skin.rightArm.rotation.x = player.skin.leftArm.rotation.x
|
||||
player.skin.leftArm.rotation.z = 0.1
|
||||
player.skin.rightArm.rotation.z = -player.skin.leftArm.rotation.z
|
||||
player.skin.leftArm.position.y = -2 - 2.539_433_18 * Math.abs(Math.sin((pr * Math.PI) / 2))
|
||||
player.skin.rightArm.position.y = player.skin.leftArm.position.y
|
||||
player.skin.rightLeg.position.z = -3.450_031_037_7 * Math.abs(Math.sin((pr * Math.PI) / 2))
|
||||
player.skin.leftLeg.position.z = player.skin.rightLeg.position.z
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import { default as netheriteLayer1 } from 'mc-assets/dist/other-textures/latest
|
|||
import { default as netheriteLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/netherite_layer_2.png'
|
||||
import { default as turtleLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/turtle_layer_1.png'
|
||||
|
||||
export { default as elytraTexture } from 'mc-assets/dist/other-textures/latest/entity/elytra.png'
|
||||
export { default as armorModel } from './armorModels.json'
|
||||
|
||||
export const armorTextures = {
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue