Compare commits

..

126 commits

Author SHA1 Message Date
Vitaly Turovsky
7be450d6d2 a bit bigger oversize 2024-08-02 05:04:45 +03:00
Vitaly Turovsky
d46a475e24 effective updates & fov 2024-08-02 04:55:26 +03:00
Vitaly Turovsky
eeca0e6360 Merge branch 'webgpu-true' into webgpu-tree-fresh 2024-08-02 03:49:21 +03:00
Vitaly Turovsky
c01f8185f6 fix make it work 2024-08-02 03:48:07 +03:00
Илья Белов
aba18dc896 Small fixes for mobile platform 2024-08-02 03:44:13 +03:00
Илья Белов
b34a637952 typo fix 2024-08-02 03:19:28 +03:00
Илья Белов
c23075b33e Working frustrum culling
Co-authored-by: Vitaly <vital2580@icloud.com>
2024-08-02 03:05:21 +03:00
Vitaly Turovsky
7d3c046eeb Merge remote-tracking branch 'origin/next' into webgpu-tree-fresh 2024-08-02 02:19:56 +03:00
Vitaly Turovsky
2dfd17939f Merge remote-tracking branch 'origin/next' into webgpu-true-fresh 2024-08-02 02:15:42 +03:00
Илья Белов
e62b4d8cda Passtrough of cubes 2024-07-31 04:06:33 +03:00
Илья Белов
de8e3dea69 Working compute shader 2024-07-31 02:43:40 +03:00
Vitaly Turovsky
8e7d7b9cb6 wip compute pipeline: indirect draw 2024-07-19 01:40:09 +03:00
Vitaly Turovsky
16d4f5db19 fix build
Co-authored-by: Ilya <sa2urami@users.noreply.github.com>
2024-07-19 01:08:36 +03:00
Vitaly Turovsky
c9b1a9e8c3 refactor webgpu renderer
Co-authored-by: Ilya <sa2urami@users.noreply.github.com>
2024-07-10 23:53:08 +03:00
Vitaly Turovsky
92afdf54fb Merge remote-tracking branch 'origin/next' into webgpu-true 2024-07-10 20:26:04 +03:00
Vitaly Turovsky
2ea730e9d1 huge work by ilya. again. 2024-07-10 02:29:26 +03:00
Vitaly Turovsky
ae4618b1d8 fix basic world opening, wip export tiles
Co-authored-by: Ilya <sa2urami@users.noreply.github.com>
2024-06-25 02:42:34 +03:00
Vitaly Turovsky
5c2be2e147 add textures, colors
Co-authored-by: Ilya <sa2urami@users.noreply.github.com>
2024-05-25 04:22:24 +03:00
Vitaly Turovsky
408df667bb Merge remote-tracking branch 'origin/webgpu-2' into webgpu-true 2024-05-25 03:41:57 +03:00
Vitaly Turovsky
02a84b83ab add textures 2024-05-25 03:41:00 +03:00
Vitaly Turovsky
1c05804398 fix build 2024-05-25 03:40:31 +03:00
Vitaly Turovsky
6d375d83e9 Refactor shader code for cube rendering 2024-05-25 03:17:57 +03:00
Vitaly Turovsky
6f0e238409 reduce size
Co-authored-by: Ilya <sa2urami@users.noreply.github.com>
2024-05-25 03:15:54 +03:00
Vitaly Turovsky
560d5fb5e3 up cube
Co-authored-by: Ilya <sa2urami@users.noreply.github.com>
2024-05-25 02:02:50 +03:00
Vitaly Turovsky
bbe2a6bef1 blocks adding 2024-05-25 01:32:19 +03:00
Vitaly Turovsky
208b026530 instancing 2024-05-25 00:50:53 +03:00
Vitaly Turovsky
c1880b137d Merge branch 'next' into pr/sa2urami/120
Co-authored-by: Ilya <sa2urami@users.noreply.github.com>
2024-05-25 00:05:47 +03:00
Vitaly Turovsky
c33ab7ad6c fix crashes 2024-05-22 05:11:06 +03:00
Vitaly Turovsky
fb6b965f98 use new insane workers api, cleanup webgpu constants 2024-05-22 05:06:46 +03:00
Vitaly Turovsky
c8310945aa initial instance shading
Co-authored-by: Ilya <sa2urami@users.noreply.github.com>
2024-05-22 02:19:31 +03:00
Vitaly Turovsky
4abdead924 working cube! 2024-05-22 00:14:46 +03:00
Vitaly Turovsky
130daac35e triangle 2024-05-21 23:14:03 +03:00
Vitaly Turovsky
137666c65c FIX waitForChunksToRender ! fix a lot of worker issues! implement allChunksLoaded method correctly!! 2024-04-14 18:43:19 +03:00
Vitaly Turovsky
3c4996f1ef impl removal, fix cam 2024-04-14 18:37:12 +03:00
Vitaly Turovsky
6c5fdbdc5e another fixup for cam pos 2024-04-12 19:38:12 +03:00
Vitaly Turovsky
4e1257cb7c fix cam fixtup 2024-04-12 19:33:03 +03:00
Vitaly Turovsky
e9ca5cf3d2 fix playground and slow cam move in worker 2024-04-12 19:28:36 +03:00
Vitaly Turovsky
662c823c23 [pick] fix alias set 2024-04-11 20:19:32 +03:00
Vitaly Turovsky
a12ab1cd16 clean fix for image.width is undefined! 2024-04-11 20:15:28 +03:00
Vitaly Turovsky
830424eec2 [to pick] customize gamemode from menu! 2024-04-11 19:57:55 +03:00
Vitaly Turovsky
4f9efb4c01 [wip] fixture export / loading! 2024-04-11 02:56:41 +03:00
Vitaly Turovsky
a3c414e09e commit stats 2024-04-11 00:55:25 +03:00
Vitaly Turovsky
a313c5ffc1 new stats! 2024-04-11 00:52:50 +03:00
Vitaly Turovsky
bb8a8413ae implement buffer extend 2024-04-10 22:13:44 +03:00
Vitaly Turovsky
890afee8e7 [pick] creative gamemode for ?singleplayer=1 2024-04-10 21:38:10 +03:00
Илья Белов
40cda86f01 Merge branch 'webgl' of https://github.com/sa2urami/prismarine-web-client into webgl 2024-04-10 04:27:19 +03:00
Илья Белов
0cebd53e3d Triangle Strip 2024-04-10 04:27:09 +03:00
Vitaly Turovsky
324f07014b fix playground 2024-04-10 04:21:17 +03:00
Vitaly Turovsky
33d563cf94 fix action 2024-04-10 04:19:22 +03:00
Vitaly Turovsky
50efd76595 workaround and potential fix 2024-04-10 04:18:52 +03:00
Vitaly Turovsky
a5f0af8e6b [deploy] fix singleplayer 2024-04-10 02:33:36 +03:00
Илья Белов
f970397886 Merge branch 'webgl' of https://github.com/sa2urami/prismarine-web-client into webgl 2024-04-10 01:48:58 +03:00
Илья Белов
ff5ca18a22 KAVOfasdfasdf 2024-04-10 01:48:14 +03:00
Vitaly Turovsky
e3205ed9c6 prev commit fixes, fix bg, add reset 2024-04-10 01:42:32 +03:00
Илья Белов
2bc843959e Merge branch 'webgl' of https://github.com/sa2urami/prismarine-web-client into webgl 2024-04-10 00:58:55 +03:00
Илья Белов
ed74485b01 Quad instanced rendreing 2024-04-10 00:58:46 +03:00
Vitaly Turovsky
469ef4982e [pick] allow to set 0 render distance for debug 2024-04-09 22:41:28 +03:00
Vitaly Turovsky
de227f08e4 Merge remote-tracking branch 'origin/next' into webgl 2024-04-09 16:47:35 +03:00
Vitaly Turovsky
0edf51aef3 better workarounds! 2024-04-09 16:43:34 +03:00
Vitaly Turovsky
28d55f9cfc reload on worker change 2024-04-09 16:33:06 +03:00
Vitaly Turovsky
c5c8f27e18 allow to override 2024-04-09 16:29:44 +03:00
Vitaly Turovsky
484844b35d add playground scripts! 2024-04-09 14:41:21 +03:00
Vitaly
5476b391ff Merge branch 'next' into webgl 2024-04-09 03:52:08 +03:00
Vitaly
dc9f15e903 naive transparent 2024-04-09 03:51:11 +03:00
Vitaly Turovsky
029a7175ab fix page loadeding indicator showing sometimes 2024-04-07 04:01:04 +03:00
gguio
48d3bbeef5 feat: effects + system indicators in hud! (#95)
Co-authored-by: gguio <nikvish150@gmail.com>
2024-04-07 04:01:04 +03:00
Vitaly
889987d908 three shake three.js (#94) 2024-04-07 04:00:49 +03:00
Vitaly
c4d65ee9fd fix warps display for greenfield & click action 2024-04-07 04:00:49 +03:00
Vitaly Turovsky
db722dba34 fix vercel preview deploy 2024-04-07 04:00:49 +03:00
Илья Белов
9f89af613f Add biome color support to fragment shader 2024-04-07 02:37:21 +03:00
Vitaly Turovsky
31b419c6ee add tint 2024-04-07 02:03:46 +03:00
Vitaly Turovsky
7777307c4f fix critical performance scene render 2024-04-07 01:48:43 +03:00
Илья Белов
183f8e461e Fix texture buffer data type and usage in webglRendererWorker.ts 2024-04-07 01:34:21 +03:00
Vitaly Turovsky
17cd4bff77 fix critical texture index bug 2024-04-07 01:13:03 +03:00
Vitaly Turovsky
9fe0e340f8 minor render tweaks 2024-04-06 23:55:45 +03:00
Vitaly Turovsky
4c58f4538e copy webgl data! 2024-04-06 19:57:06 +03:00
Vitaly Turovsky
12dfa9f9a8 implement fully working animation in playground 2024-04-06 19:48:39 +03:00
Vitaly Turovsky
8b6e1d210b make worldrenderer extendable and customizable with your own render implementations! 2024-04-06 19:00:13 +03:00
Vitaly Turovsky
97a9e92119 impl basic debug animation 2024-04-06 02:55:44 +03:00
Vitaly Turovsky
16fdd37776 better render debug 2024-04-06 02:45:40 +03:00
Vitaly Turovsky
349edd17ed undo l 2024-04-05 23:35:34 +03:00
Vitaly Turovsky
984a2081a7 almost working texture switch in playground 2024-04-05 23:35:17 +03:00
Vitaly Turovsky
d0501d331d atlas now with animated blocks! 2024-04-05 23:16:21 +03:00
Vitaly Turovsky
5951e14b68 fix stone display 2024-04-05 22:57:42 +03:00
Vitaly Turovsky
86f1b5e569 add animation, impl texture indexes, fix perf 2024-04-04 13:46:13 +03:00
Илья Белов
a75aeb0c92 Addon to last commit 2024-04-04 01:05:52 +03:00
Илья Белов
e9ae58612c Super Performant Multi Textures 2024-04-04 01:05:35 +03:00
Илья Белов
61a8a97dbb Multi textures 2024-04-04 01:02:42 +03:00
Vitaly Turovsky
11c04fb3db implement removal, small impr 2024-04-04 00:14:31 +03:00
Vitaly Turovsky
7b61ff68e8 fix worker build on win 2024-04-03 22:46:10 +03:00
Vitaly Turovsky
dc625b58d7 implement correct cubes update via subdata (faster new chunks add) 2024-03-31 23:33:51 +03:00
Vitaly Turovsky
6275d787a2 compute index in worker: less lags 2024-03-31 22:50:57 +03:00
Vitaly Turovsky
5b78000697 a bit better performance on blocks update - new struct 2024-03-29 19:45:08 +03:00
Vitaly Turovsky
61d0ff46b3 up playground impl 2024-03-29 14:31:19 +03:00
Vitaly Turovsky
f168bdbfa4 up tasks 2024-03-29 14:26:03 +03:00
Vitaly Turovsky
a7313b3a70 insane ilya work by working hard (harder than usual) 2024-03-28 03:44:00 +03:00
Vitaly Turovsky
506706e88b fixed camera, coords, cam, textures, controls 2024-03-28 03:43:42 +03:00
Vitaly Turovsky
7d5f1c504f worker part 2 2024-03-27 11:16:07 +03:00
Vitaly Turovsky
a19c9ad784 fix cam 2024-03-25 14:37:37 +03:00
Vitaly Turovsky
f3295593bf build worker correctly 2024-03-25 13:00:24 +03:00
Vitaly Turovsky
d0a6941031 build workers 2024-03-25 12:40:39 +03:00
Vitaly Turovsky
00b9683e78 fix worker output! 2024-03-25 12:35:33 +03:00
Vitaly Turovsky
ce194599d7 up world 2024-03-25 12:27:03 +03:00
Vitaly Turovsky
a3290063e8 restore singleplayer 2024-03-25 12:26:58 +03:00
Vitaly Turovsky
51ba0eaec5 put renderer into worker! viewerWrapper & perf cypress test 2024-03-25 07:28:03 +03:00
Vitaly Turovsky
b35bc0882f Merge remote-tracking branch 'origin/next' into webgl 2024-03-25 03:46:05 +03:00
Vitaly Turovsky
4133eca3a7 dynamic entrypoint 2024-03-24 10:44:00 +03:00
Vitaly Turovsky
c8e6475954 fix textures 2024-03-24 09:38:46 +03:00
Vitaly Turovsky
9e26f70011 add touch 2024-03-24 09:29:11 +03:00
Vitaly Turovsky
c6c4ca841b almost ok for the app 2024-03-22 04:49:33 +03:00
Vitaly Turovsky
b403ed4606 fix build 2024-03-22 03:49:23 +03:00
Vitaly Turovsky
a682d4b3da Instanced mashing with indexed texturing 2024-03-22 02:33:20 +03:00
Vitaly Turovsky
70c9686f89 insane instace shading 2024-03-22 01:31:22 +03:00
Илья Белов
ad5a906a95 fix things 2024-03-22 00:31:24 +03:00
Илья Белов
a1db77a21f Merge remote-tracking branch 'upstream/next' into webgl 2024-03-22 00:02:17 +03:00
Илья Белов
027f5c6917 up package json 2024-03-22 00:02:11 +03:00
Vitaly
2ee0ef3483 pus render 2024-03-21 23:42:33 +03:00
Vitaly Turovsky
73a5e9d8ba up render 2024-03-21 22:50:44 +03:00
Vitaly Turovsky
024891c6d5 add 2024-03-21 15:47:27 +03:00
Vitaly Turovsky
dee63df232 Merge branch 'next' into webgl 2024-03-21 14:17:00 +03:00
Vitaly Turovsky
253fb8a2b5 move code around, +export 2024-03-21 14:16:18 +03:00
Vitaly Turovsky
6e84db0a7a make cubes
undo viewer changes before merge
2024-03-21 02:56:32 +03:00
Vitaly Turovsky
b28f69c126 nice work by ilya 2024-03-18 21:42:42 +03:00
Илья Белов
06b2f3bfa8 degrees_to_radians 2024-03-06 03:26:03 +03:00
Илья Белов
1175d3fae9 Working texture bindings 2024-03-06 02:36:52 +03:00
Илья Белов
163290d841 Stash
Co-authored-by: Vitaly <vital2580@icloud.com>
2024-03-01 03:03:44 +03:00
675 changed files with 50685 additions and 59774 deletions

View file

@ -1,18 +0,0 @@
---
description: Restricts usage of the global Mineflayer `bot` variable to only src/ files; prohibits usage in renderer/. Specifies correct usage of player state and appViewer globals.
globs: src/**/*.ts,renderer/**/*.ts
alwaysApply: false
---
Ask AI
- The global variable `bot` refers to the Mineflayer bot instance.
- You may use `bot` directly in any file under the `src/` directory (e.g., `src/mineflayer/playerState.ts`).
- Do **not** use `bot` directly in any file under the `renderer/` directory or its subfolders (e.g., `renderer/viewer/three/worldrendererThree.ts`).
- In renderer code, all bot/player state and events must be accessed via explicit interfaces, state managers, or passed-in objects, never by referencing `bot` directly.
- In renderer code (such as in `WorldRendererThree`), use the `playerState` property (e.g., `worldRenderer.playerState.gameMode`) to access player state. The implementation for `playerState` lives in `src/mineflayer/playerState.ts`.
- In `src/` code, you may use the global variable `appViewer` from `src/appViewer.ts` directly. Do **not** import `appViewer` or use `window.appViewer`; use the global `appViewer` variable as-is.
- Some other global variables that can be used without window prefixes are listed in src/globals.d.ts
Rationale: This ensures a clean separation between the Mineflayer logic (server-side/game logic) and the renderer (client-side/view logic), making the renderer portable and testable, and maintains proper usage of global state.
For more general project contributing guides see CONTRIBUTING.md on like how to setup the project. Use pnpm tsc if needed to validate result with typechecking the whole project.

View file

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

View file

@ -1,76 +1,33 @@
{
"extends": [
"zardoy",
"plugin:@stylistic/disable-legacy"
],
"extends": "zardoy",
"ignorePatterns": [
"!*.js"
],
"plugins": [
"@stylistic"
"!*.js",
"prismarine-viewer/"
],
"rules": {
// style
"@stylistic/space-infix-ops": "error",
"@stylistic/no-multi-spaces": "error",
"@stylistic/no-trailing-spaces": "error",
"@stylistic/space-before-function-paren": "error",
"@stylistic/array-bracket-spacing": "error",
// would be great to have but breaks TS code like (url?) => ...
// "@stylistic/arrow-parens": [
// "error",
// "as-needed"
// ],
"@stylistic/arrow-spacing": "error",
"@stylistic/block-spacing": "error",
"@typescript-eslint/no-this-alias": "off",
"@stylistic/brace-style": [
"error",
"1tbs",
{
"allowSingleLine": true
}
],
// too annoying to be forced to multi-line, probably should be enforced to never
// "@stylistic/comma-dangle": [
// "error",
// "always-multiline"
// ],
"@stylistic/computed-property-spacing": "error",
"@stylistic/dot-location": [
"error",
"property"
],
"@stylistic/eol-last": "error",
"@stylistic/function-call-spacing": "error",
"@stylistic/function-paren-newline": [
"error",
"consistent"
],
"@stylistic/generator-star-spacing": "error",
"@stylistic/implicit-arrow-linebreak": "error",
"@stylistic/indent-binary-ops": [
"error",
2
],
"@stylistic/function-call-argument-newline": [
"error",
"consistent"
],
"@stylistic/space-in-parens": [
"space-infix-ops": "error",
"no-multi-spaces": "error",
"no-trailing-spaces": "error",
"space-before-function-paren": "error",
"space-in-parens": [
"error",
"never"
],
"@stylistic/object-curly-spacing": [
"object-curly-spacing": [
"error",
"always"
],
"@stylistic/comma-spacing": "error",
"@stylistic/semi": [
"comma-spacing": "error",
"semi": [
"error",
"never"
],
"@stylistic/indent": [
"comma-dangle": [
"error",
// todo maybe "always-multiline"?
"only-multiline"
],
"indent": [
"error",
2,
{
@ -80,72 +37,13 @@
]
}
],
"@stylistic/quotes": [
"quotes": [
"error",
"single",
{
"allowTemplateLiterals": true
}
],
"@stylistic/key-spacing": "error",
"@stylistic/keyword-spacing": "error",
// "@stylistic/line-comment-position": "error", // not needed
// "@stylistic/lines-around-comment": "error", // also not sure if needed
// "@stylistic/max-len": "error", // also not sure if needed
// "@stylistic/linebreak-style": "error", // let git decide
"@stylistic/max-statements-per-line": [
"error",
{
"max": 5
}
],
// "@stylistic/member-delimiter-style": "error",
// "@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",
"@stylistic/type-generic-spacing": "error",
"@stylistic/template-tag-spacing": "error",
"@stylistic/template-curly-spacing": "error",
"@stylistic/type-annotation-spacing": "error",
"@stylistic/jsx-child-element-spacing": "error",
// buggy
// "@stylistic/jsx-closing-bracket-location": "error",
// "@stylistic/jsx-closing-tag-location": "error",
"@stylistic/jsx-curly-brace-presence": "error",
"@stylistic/jsx-curly-newline": "error",
"@stylistic/jsx-curly-spacing": "error",
"@stylistic/jsx-equals-spacing": "error",
"@stylistic/jsx-first-prop-new-line": "error",
"@stylistic/jsx-function-call-newline": "error",
"@stylistic/jsx-max-props-per-line": [
"error",
{
"maximum": 7
}
],
"@stylistic/jsx-pascal-case": "error",
"@stylistic/jsx-props-no-multi-spaces": "error",
"@stylistic/jsx-self-closing-comp": "error",
// "@stylistic/jsx-sort-props": [
// "error",
// {
// "callbacksLast": false,
// "shorthandFirst": true,
// "shorthandLast": false,
// "multiline": "ignore",
// "ignoreCase": true,
// "noSortAlphabetically": true,
// "reservedFirst": [
// "key",
// "className"
// ],
// "locale": "auto"
// }
// ],
// perf
"import/no-deprecated": "off",
// ---
@ -155,7 +53,6 @@
// intentional: improve readability in some cases
"no-else-return": "off",
"@typescript-eslint/padding-line-between-statements": "off",
"@typescript-eslint/no-dynamic-delete": "off",
"arrow-body-style": "off",
"unicorn/prefer-ternary": "off",
"unicorn/switch-case-braces": "off",
@ -192,15 +89,13 @@
"@typescript-eslint/no-confusing-void-expression": "off",
"unicorn/no-empty-file": "off",
"unicorn/prefer-event-target": "off",
"@typescript-eslint/member-ordering": "off",
// needs to be fixed actually
"complexity": "off",
"@typescript-eslint/no-floating-promises": "warn",
"no-async-promise-executor": "off",
"no-bitwise": "off",
"unicorn/filename-case": "off",
"max-depth": "off",
"unicorn/no-typeof-undefined": "off"
"max-depth": "off"
},
"overrides": [
{
@ -208,7 +103,7 @@
"*.js"
],
"rules": {
"@stylistic/space-before-function-paren": [
"space-before-function-paren": [
"error",
{
"anonymous": "always",

View file

@ -1,59 +0,0 @@
name: Benchmark
on:
issue_comment:
types: [created]
push:
branches:
- perf-test
jobs:
deploy:
runs-on: ubuntu-latest
if: >-
(github.event_name == 'push' && github.ref == 'refs/heads/perf-test') ||
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request != '' &&
(startsWith(github.event.comment.body, '/benchmark'))
)
permissions:
pull-requests: write
steps:
- run: lscpu
- name: Checkout
uses: actions/checkout@v2
- name: Setup pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- name: Move Cypress to dependencies
run: |
jq '.dependencies.cypress = .optionalDependencies.cypress | del(.optionalDependencies.cypress)' package.json > package.json.tmp
mv package.json.tmp package.json
- run: pnpm install --no-frozen-lockfile
- run: pnpm build
- run: nohup pnpm prod-start &
- run: pnpm test:benchmark
id: benchmark
continue-on-error: true
# read benchmark results from stdout
- run: |
if [ -f benchmark.txt ]; then
# Format the benchmark results for GitHub comment
BENCHMARK_RESULT=$(cat benchmark.txt | sed 's/^/- /')
echo "BENCHMARK_RESULT<<EOF" >> $GITHUB_ENV
echo "$BENCHMARK_RESULT" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
else
echo "BENCHMARK_RESULT=Benchmark failed to run or produce results" >> $GITHUB_ENV
fi
- uses: mshick/add-pr-comment@v2
with:
allow-repeats: true
message: |
Benchmark result: ${{ env.BENCHMARK_RESULT }}

View file

@ -1,33 +0,0 @@
name: build-single-file
on:
workflow_dispatch:
jobs:
build-and-bundle:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Checkout repository
uses: actions/checkout@master
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install
- name: Build single-file version - minecraft.html
run: pnpm build-single-file && mv dist/single/index.html minecraft.html
env:
LOCAL_CONFIG_FILE: config.mcraft-only.json
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: minecraft.html
path: minecraft.html

View file

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

View file

@ -13,165 +13,28 @@ jobs:
with:
java-version: 17
java-package: jre
- name: Install pnpm
run: npm i -g pnpm@9.0.4
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 18
# cache: "pnpm"
- name: Install pnpm
uses: pnpm/action-setup@v4
- run: pnpm install
- run: pnpm build-single-file
- name: Store minecraft.html size
run: |
SIZE_BYTES=$(du -s dist/single/minecraft.html 2>/dev/null | cut -f1)
echo "SIZE_BYTES=$SIZE_BYTES" >> $GITHUB_ENV
- run: pnpm check-build
- name: Create zip package for size comparison
run: |
mkdir -p package
cp -r dist package/
cd package
zip -r ../self-host.zip .
- run: pnpm build-playground
# - run: pnpm build-storybook
- run: pnpm test-unit
- run: pnpm lint
- name: Parse Bundle Stats
run: |
GZIP_BYTES=$(du -s self-host.zip 2>/dev/null | cut -f1)
SIZE=$(echo "scale=2; $SIZE_BYTES/1024/1024" | bc)
GZIP_SIZE=$(echo "scale=2; $GZIP_BYTES/1024/1024" | bc)
echo "{\"total\": ${SIZE}, \"gzipped\": ${GZIP_SIZE}}" > /tmp/bundle-stats.json
# - name: Compare Bundle Stats
# id: compare
# uses: actions/github-script@v6
# env:
# GITHUB_TOKEN: ${{ secrets.GIST_TOKEN }}
# with:
# script: |
# const gistId = '${{ secrets.BUNDLE_STATS_GIST_ID }}';
# async function getGistContent() {
# const { data } = await github.rest.gists.get({
# gist_id: gistId,
# headers: {
# authorization: `token ${process.env.GITHUB_TOKEN}`
# }
# });
# return JSON.parse(data.files['bundle-stats.json'].content || '{}');
# }
# const content = await getGistContent();
# const baseStats = content['${{ github.event.pull_request.base.ref }}'];
# const newStats = require('/tmp/bundle-stats.json');
# const comparison = `minecraft.html (normal build gzip)\n${baseStats.total}MB (${baseStats.gzipped}MB compressed) -> ${newStats.total}MB (${newStats.gzipped}MB compressed)`;
# core.setOutput('stats', comparison);
# - run: pnpm tsx scripts/buildNpmReact.ts
- run: pnpm tsx scripts/buildNpmReact.ts
- run: nohup pnpm prod-start &
- run: nohup pnpm test-mc-server &
- uses: cypress-io/github-action@v5
with:
install: false
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v3
if: failure()
with:
name: cypress-images
path: cypress/screenshots/
# - run: node scripts/outdatedGitPackages.mjs
# if: ${{ github.event.pull_request.base.ref == 'release' }}
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# - name: Store Bundle Stats
# if: github.event.pull_request.base.ref == 'next'
# uses: actions/github-script@v6
# env:
# GITHUB_TOKEN: ${{ secrets.GIST_TOKEN }}
# with:
# script: |
# const gistId = '${{ secrets.BUNDLE_STATS_GIST_ID }}';
# async function getGistContent() {
# const { data } = await github.rest.gists.get({
# gist_id: gistId,
# headers: {
# authorization: `token ${process.env.GITHUB_TOKEN}`
# }
# });
# return JSON.parse(data.files['bundle-stats.json'].content || '{}');
# }
# async function updateGistContent(content) {
# await github.rest.gists.update({
# gist_id: gistId,
# headers: {
# authorization: `token ${process.env.GITHUB_TOKEN}`
# },
# files: {
# 'bundle-stats.json': {
# content: JSON.stringify(content, null, 2)
# }
# }
# });
# }
# const stats = require('/tmp/bundle-stats.json');
# const content = await getGistContent();
# content['${{ github.event.pull_request.base.ref }}'] = stats;
# await updateGistContent(content);
# - name: Update PR Description
# uses: actions/github-script@v6
# with:
# script: |
# const { data: pr } = await github.rest.pulls.get({
# owner: context.repo.owner,
# repo: context.repo.repo,
# pull_number: context.issue.number
# });
# let body = pr.body || '';
# const statsMarker = '### Bundle Size';
# const comparison = '${{ steps.compare.outputs.stats }}';
# if (body.includes(statsMarker)) {
# body = body.replace(
# new RegExp(`${statsMarker}[^\n]*\n[^\n]*`),
# `${statsMarker}\n${comparison}`
# );
# } else {
# body += `\n\n${statsMarker}\n${comparison}`;
# }
# await github.rest.pulls.update({
# owner: context.repo.owner,
# repo: context.repo.repo,
# pull_number: context.issue.number,
# body
# });
# dedupe-check:
# runs-on: ubuntu-latest
# if: github.event.pull_request.head.ref == 'next'
# steps:
# - name: Checkout repository
# uses: actions/checkout@v2
# - name: Install pnpm
# run: npm install -g pnpm@9.0.4
# - name: Run pnpm dedupe
# run: pnpm dedupe
# - name: Check for changes
# run: |
# if ! git diff --exit-code --quiet pnpm-lock.yaml; then
# echo "pnpm dedupe introduced changes:"
# git diff --color=always pnpm-lock.yaml
# exit 1
# else
# echo "No changes detected after pnpm dedupe in pnpm-lock.yaml"
# fi
path: cypress/integration/__image_snapshots__/
- run: node scripts/outdatedGitPackages.mjs
if: github.ref == 'refs/heads/next'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
name: Vercel PR Deploy (Preview)
name: Vercel Deploy Preview
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
@ -6,109 +6,57 @@ env:
on:
issue_comment:
types: [created]
pull_request_target:
jobs:
deploy:
runs-on: ubuntu-latest
# todo skip already created deploys on that commit
if: >-
github.event.issue.pull_request != '' &&
(
(
github.event_name == 'issue_comment' &&
contains(github.event.comment.body, '/deploy') &&
github.event.issue.pull_request != null
) ||
(
github.event_name == 'pull_request_target' &&
contains(fromJson(vars.AUTO_DEPLOY_PRS), github.event.pull_request.number)
)
contains(github.event.comment.body, '/deploy')
)
permissions:
pull-requests: write
steps:
- name: Checkout Base To Temp
- name: Checkout
uses: actions/checkout@v2
with:
path: temp-base-repo
- name: Get deployment alias
run: node temp-base-repo/scripts/githubActions.mjs getAlias
id: alias
env:
ALIASES: ${{ env.ALIASES }}
PULL_URL: ${{ github.event.issue.pull_request.url || github.event.pull_request.url }}
- name: Checkout PR (comment)
uses: actions/checkout@v2
if: github.event_name == 'issue_comment'
with:
ref: refs/pull/${{ github.event.issue.number }}/head
- name: Checkout PR (pull_request)
uses: actions/checkout@v2
if: github.event_name == 'pull_request_target'
with:
ref: refs/pull/${{ github.event.pull_request.number }}/head
- name: Install pnpm
uses: pnpm/action-setup@v4
- run: npm i -g pnpm@9.0.4
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 18
cache: "pnpm"
- name: Update deployAlwaysUpdate packages
run: |
if [ -f package.json ]; then
PACKAGES=$(node -e "const pkg = require('./package.json'); if (pkg.deployAlwaysUpdate) console.log(pkg.deployAlwaysUpdate.join(' '))")
if [ ! -z "$PACKAGES" ]; then
echo "Updating packages: $PACKAGES"
pnpm up -L $PACKAGES
else
echo "No deployAlwaysUpdate packages found in package.json"
fi
else
echo "package.json not found"
fi
- name: Install Global Dependencies
run: pnpm add -g vercel
run: npm install --global vercel
- name: Pull Vercel Environment Information
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
- name: Write Release Info
run: |
echo "{\"latestTag\": \"$(git rev-parse --short ${{ github.event.pull_request.head.sha }})\", \"isCommit\": true}" > assets/release.json
- name: Download Generated Sounds map
run: node scripts/downloadSoundsMap.mjs
- name: Build Project Artifacts
run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
env:
CONFIG_JSON_SOURCE: BUNDLED
LOCAL_CONFIG_FILE: config.mcraft-only.json
- run: pnpm build-storybook
- name: Copy playground files
run: |
mkdir -p .vercel/output/static/playground
pnpm build-playground
cp -r renderer/dist/* .vercel/output/static/playground/
- name: Write pr redirect index.html
run: |
mkdir -p .vercel/output/static/pr
echo "<meta http-equiv='refresh' content='0;url=https://github.com/${{ github.repository }}/pull/${{ github.event.issue.number || github.event.pull_request.number }}'>" > .vercel/output/static/pr/index.html
- name: Write commit redirect index.html
run: |
mkdir -p .vercel/output/static/commit
echo "<meta http-equiv='refresh' content='0;url=https://github.com/${{ github.repository }}/pull/${{ github.event.issue.number || github.event.pull_request.number }}/commits/${{ github.event.pull_request.head.sha }}'>" > .vercel/output/static/commit/index.html
run: node prismarine-viewer/esbuild.mjs && cp prismarine-viewer/public/index.html .vercel/output/static/playground.html && cp prismarine-viewer/public/playground.js .vercel/output/static/playground.js
- name: Download Generated Sounds map
run: node scripts/downloadSoundsMap.mjs
- name: Deploy Project Artifacts to Vercel
uses: mathiasvr/command-output@v2.0.0
with:
run: vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }}
id: deploy
- uses: mshick/add-pr-comment@v2
# if: github.event_name == 'issue_comment'
with:
allow-repeats: true
message: |
Deployed to Vercel Preview: ${{ steps.deploy.outputs.stdout }}
[Playground](${{ steps.deploy.outputs.stdout }}/playground/)
[Playground](${{ steps.deploy.outputs.stdout }}/playground.html)
[Storybook](${{ steps.deploy.outputs.stdout }}/storybook/)
# - run: git checkout next scripts/githubActions.mjs
- name: Get deployment alias
run: node scripts/githubActions.mjs getAlias
id: alias
env:
ALIASES: ${{ env.ALIASES }}
PULL_URL: ${{ github.event.issue.pull_request.url }}
- name: Set deployment alias
if: ${{ steps.alias.outputs.alias != '' && steps.alias.outputs.alias != 'mcraft.fun' && steps.alias.outputs.alias != 's.mcraft.fun' }}
run: |
for alias in $(echo ${{ steps.alias.outputs.alias }} | tr "," "\n"); do
vercel alias set ${{ steps.deploy.outputs.stdout }} $alias --token=${{ secrets.VERCEL_TOKEN }} --scope=zaro
done
run: vercel alias set ${{ steps.deploy.outputs.stdout }} ${{ steps.alias.outputs.alias }} --token=${{ secrets.VERCEL_TOKEN }} --scope=zaro

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

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

View file

@ -1,116 +0,0 @@
name: Release
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
MAIN_MENU_LINKS: ${{ vars.MAIN_MENU_LINKS }}
on:
push:
branches: [release]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Checkout repository
uses: actions/checkout@master
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install Global Dependencies
run: pnpm add -g vercel
# - run: pnpm install
# - run: pnpm build
- run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
- run: node scripts/replaceFavicon.mjs ${{ secrets.FAVICON_MAIN }}
# will install + build to .vercel/output/static
- name: Get Release Info
run: pnpx zardoy-release empty --skip-github --output-file assets/release.json
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Download Generated Sounds map
run: node scripts/downloadSoundsMap.mjs
- run: vercel build --token=${{ secrets.VERCEL_TOKEN }} --prod
env:
CONFIG_JSON_SOURCE: BUNDLED
LOCAL_CONFIG_FILE: config.mcraft-only.json
- name: Copy playground files
run: |
mkdir -p .vercel/output/static/playground
pnpm build-playground
cp -r renderer/dist/* .vercel/output/static/playground/
# publish to github
- run: cp vercel.json .vercel/output/static/vercel.json
- uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: .vercel/output/static
force_orphan: true
# Create CNAME file for custom domain
- name: Create CNAME file
run: echo "github.mcraft.fun" > .vercel/output/static/CNAME
- name: Deploy to mwc-mcraft-pages repository
uses: peaceiris/actions-gh-pages@v3
with:
personal_token: ${{ secrets.MCW_MCRAFT_PAGE_DEPLOY_TOKEN }}
external_repository: ${{ github.repository_owner }}/mwc-mcraft-pages
publish_dir: .vercel/output/static
publish_branch: main
destination_dir: docs
force_orphan: true
- name: Change index.html title
run: |
# change <title>Minecraft Web Client</title> to <title>Minecraft Web Client — Free Online Browser Version</title>
sed -i 's/<title>Minecraft Web Client<\/title>/<title>Minecraft Web Client — Free Online Browser Version<\/title>/' .vercel/output/static/index.html
- name: Deploy Project to Vercel
uses: mathiasvr/command-output@v2.0.0
with:
run: vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }} --prod
id: deploy
- name: Get releasing alias
run: node scripts/githubActions.mjs getReleasingAlias
id: alias
- name: Set deployment alias
run: |
for alias in $(echo ${{ steps.alias.outputs.alias }} | tr "," "\n"); do
vercel alias set ${{ steps.deploy.outputs.stdout }} $alias --token=${{ secrets.VERCEL_TOKEN }} --scope=zaro
done
- name: Build single-file version - minecraft.html
run: pnpm build-single-file && mv dist/single/index.html minecraft.html
- name: Build self-host version
run: pnpm build
- name: Bundle server.js
run: |
pnpm esbuild server.js --bundle --platform=node --outfile=bundled-server.js --define:process.env.NODE_ENV="'production'"
- name: Create zip package
run: |
mkdir -p package
cp -r dist package/
cp bundled-server.js package/server.js
cd package
zip -r ../self-host.zip .
- run: |
pnpx zardoy-release node --footer "This release URL: https://$(echo ${{ steps.alias.outputs.alias }} | cut -d',' -f1) (Vercel URL: ${{ steps.deploy.outputs.stdout }})"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# has possible output: tag
id: release
# has output
- name: Set publishing config
run: pnpm config set '//registry.npmjs.org/:_authToken' "${NODE_AUTH_TOKEN}"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# - run: pnpm tsx scripts/buildNpmReact.ts ${{ steps.release.outputs.tag }}
# if: steps.release.outputs.tag
# env:
# NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

4
.gitignore vendored
View file

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

1
.vscode/launch.json vendored
View file

@ -1,5 +1,6 @@
{
"configurations": [
// UPDATED: all configs below are misconfigured and will crash vscode, open dist/index.html and use live preview debug instead
// recommended as much faster
{
// to launch "C:\Program Files\Google\Chrome Beta\Application\chrome.exe" --remote-debugging-port=9222

14
.vscode/tasks.json vendored
View file

@ -25,10 +25,20 @@
},
},
{
"label": "viewer server+esbuild",
"label": "webgl-worker",
"type": "shell",
"command": "node buildWorkers.mjs -w",
"problemMatcher": "$esbuild-watch",
"presentation": {
"reveal": "silent"
},
},
{
"label": "viewer server+esbuild+workers",
"dependsOn": [
"viewer-server",
"viewer-esbuild"
"viewer-esbuild",
"webgl-worker"
],
"dependsOrder": "parallel",
}

View file

@ -2,83 +2,25 @@
After forking the repository, run the following commands to get started:
0. Ensure you have [Node.js](https://nodejs.org) installed. Enable corepack with `corepack enable` *(1).
0. Ensure you have [Node.js](https://nodejs.org) and `pnpm` installed. To install pnpm run `npm i -g pnpm@9.0.4`.
1. Install dependencies: `pnpm i`
2. Start the project in development mode: `pnpm start` or build the project for production: `pnpm build`
3. Read the [Tasks Categories](#tasks-categories) and [Workflow](#workflow) sections below
4. Let us know if you are working on something and be sure to open a PR if you got any changes. Happy coding!
*(1): If you are getting `Cannot find matching keyid` update corepack to the latest version with `npm i -g corepack`.
*(2): If still something doesn't work ensure you have the right nodejs version with `node -v` (tested on 22.x)
<!-- *(3): For GitHub codespaces (cloud ide): Run `pnpm i @rsbuild/core@1.2.4 @rsbuild/plugin-node-polyfill@1.3.0 @rsbuild/plugin-react@1.1.0 @rsbuild/plugin-typed-css-modules@1.0.2` command to avoid crashes because of limited ram -->
2. Start the project in development mode: `pnpm start`
## Project Structure
There are 3 main parts of the project:
### Core (`src`)
This is the main app source code which reuses all the other parts of the project.
> The first version used Webpack, then was migrated to Esbuild and now is using Rsbuild!
- Scripts:
- Start: `pnpm start`, `pnpm dev-rsbuild` (if you don't need proxy server also running)
- Build: `pnpm build` (note that `build` script builds only the core app, not the whole project!)
Paths:
- `src` - main app source code
- `src/react` - React components - almost all UI is in this folder. Almost every component has its base (reused in app and storybook) and `Provider` - which is a component that provides context to its children. Consider looking at DeathScreen component to see how it's used.
- `src/menus` - Old Lit Element GUI. In the process of migration to React.
### Renderer: Playground & Mesher (`renderer`)
- Playground Scripts:
- Start: `pnpm run-playground` (playground, mesher + server) or `pnpm watch-playground`
- Build: `pnpm build-playground` or `node renderer/esbuild.mjs`
- Mesher Scripts:
- Start: `pnpm watch-mesher`
- Build: `pnpm build-mesher`
Paths:
- `renderer` - Improved and refactored version of <https://github.com/PrismarineJS/prismarine-viewer>. Here is everything related to rendering the game world itself (no ui at all). Two most important parts here are:
- `renderer/viewer/lib/worldrenderer.ts` - adding new objects to three.js happens here (sections)
- `renderer/viewer/lib/models.ts` - preparing data for rendering (blocks) - happens in worker: out file - `worker.js`, building - `renderer/buildWorker.mjs`
- `renderer/playground/playground.ts` - Playground (source of <mcraft.fun/playground.html>) Use this for testing any rendering changes. You can also modify the playground code.
### Storybook (`.storybook`)
Storybook is a tool for easier developing and testing React components.
Path of all Storybook stories is `src/react/**/*.stories.tsx`.
- Scripts:
- Start: `pnpm storybook`
- Build: `pnpm build-storybook`
## Core-related
- `prismarine-viewer` - Improved version of <https://github.com/prismarineJS/prismarine-viewer>. Here is everything related to rendering the game world itself (no ui at all). Two most important parts here are:
- `prismarine-viewer/viewer/lib/worldrenderer.ts` - adding new objects to three.js happens here (sections)
- `prismarine-viewer/viewer/lib/models.ts` - preparing data for rendering (blocks) - happens in worker: out file - `worker.js`, building - `prismarine-viewer/buildWorker.mjs`
- `prismarine-viewer/examples/playground.ts` - Playground (source of <mcraft.fun/playground.html>) Use this for testing render changes. You can also modify playground code.
How different modules are used:
- `mineflayer` - provider `bot` variable and as mineflayer states it is a wrapper for the `node-minecraft-protocol` module and is used to connect and interact with real Java Minecraft servers. However not all events & properties are exposed and sometimes you have to use `bot._client.on('packet_name', data => ...)` to handle packets that are not handled via mineflayer API. Also you can use almost any mineflayer plugin.
## Running Main App + Playground
To start the main web app and playground, run `pnpm run-all`. Note is doesn't start storybook and tests.
## Cypress Tests (E2E)
Cypress tests are located in `cypress` folder. To run them, run `pnpm test-mc-server` and then `pnpm test:cypress` when the `pnpm prod-start` is running (or change the port to 3000 to test with the dev server). Usually you don't need to run these until you get issues on the CI.
## Unit Tests
There are not many unit tests for now (which we are trying to improve).
Location of unit tests: `**/*.test.ts` files in `src` folder and `renderer` folder.
Start them with `pnpm test-unit`.
## Making protocol-related changes
You can get a description of packets for the latest protocol version from <https://wiki.vg/Protocol> and for previous protocol versions from <https://wiki.vg/Protocol_version_numbers> (look for *Page* links that have *Protocol* in URL).
@ -97,99 +39,6 @@ Also there are [src/generatedClientPackets.ts](src/generatedClientPackets.ts) an
- Use `start-prod` script to start the project in production mode after running the `build` script to build the project.
- If CI is failing on the next branch for some reason, feel free to use the latest commit for release branch. We will update the base branch asap. Please, always make sure to allow maintainers do changes when opening PRs.
## Tasks Categories
(most important for now are on top).
## 1. Client-side Logic (most important right now)
Everything related to the client side packets. Investigate issues when something goes wrong with some server. It's much easier to work on these types of tasks when you have experience in Java with Minecraft, a deep understanding of the original client, and know how to debug it (which is not hard actually). Right now the client is easily detectable by anti-cheat plugins, and the main goal is to fix it (mostly because of wrong physics implementation).
Priority tasks:
- Rewrite or fix the physics logic (Botcraft or Grim can be used as a reference as well)
- Implement basic minecart / boat / horse riding
- Fix auto jump module (false triggers, performance issues)
- Investigate connection issues to some servers
- Setup a platform for automatic cron testing against the latest version of the anti-cheat plugins
- ...
Goals:
- Make more servers playable. Right now on hypixel-like servers (servers with minigames), only tnt run (and probably ) is fully playable.
Notes:
- You can see the incoming/outgoing packets in the console (F12 in Chrome) by enabling `options.debugLogNotFrequentPackets = true`. However, if you need a FULL log of all packets, you can start recording the packets by going into `Settings` > `Advanced` > `Enable Packets Replay` and then you can download the file and use it to replay the packets.
- You can use mcraft-e2e studio to send the same packets over and over again (which is useful for testing) or use the packets replayer (which is useful for debugging).
## 2. Three.js Renderer
Example tasks:
- Improve / fix entity rendering
- Better update entities on specific packets
- Investigate performance issues under different conditions (instructions provided)
- Work on the playground code
Goals:
- Fix a lot of entity rendering issues (including position updates)
- Implement switching camera mode (first person, third person, etc)
- Animated blocks
- Armor rendering
- ...
Note:
- It's useful to know how to use helpers & additional cameras (e.g. setScissor)
## 3. Server-side Logic
Flying squid fork (space-squid).
Example tasks:
- Add missing commands (e.g. /scoreboard)
- Basic physics (player fall damage, falling blocks & entities)
- Basic entities AI (spawning, attacking)
- Pvp
- Emit more packets on some specific events (e.g. when a player uses an item)
- Make more maps playable (e.g. fix when something is not implemented in both server and client and blocking map interaction)
- ...
Long Term Goals:
- Make most adventure maps playable
- Make a way to complete the game from the scratch (crafting, different dimensions, terrain generation, etc)
- Make bedwars playable!
Most of the tasks are straightforward to implement, just be sure to use a debugger ;). If you feel you are stuck, ask for help on Discord. Absolutely any tests / refactor suggestions are welcome!
## 4. Frontend
New React components, improve UI (including mobile support).
## Workflow
1. Locate the problem on the public test server & make an easily reproducible environment (you can also use local packets replay server or your custom server setup). Dm me for details on public test server / replay server
2. Debug the code, find an issue in the code, isolate the problem
3. Develop, try to fix and test. Finally we should find a way to fix it. It's ideal to have an automatic test but it's not necessary for now
3. Repeat step 1 to make sure the task is done and the problem is fixed (or the feature is implemented)
## Updating Dependencies
1. Use `pnpm update-git-deps` to check and update git dependencies (like mineflayer fork, prismarine packages etc). The script will:
- Show which git dependencies have updates available
- Ask if you want to update them
- Skip dependencies listed in `pnpm.updateConfig.ignoreDependencies`
2. Update PrismarineJS dependencies to the latest version: `minecraft-data` (be sure to replace the version twice in the package.json), `mineflayer`, `minecraft-protocol`, `prismarine-block`, `prismarine-chunk`, `prismarine-item`, ...
3. If `minecraft-protocol` patch fails, do this:
1. Remove the patch from `patchedDependencies` in `package.json`
2. Run `pnpm patch minecraft-protocol`, open patch directory
3. Apply the patch manually in this directory: `patch -p1 < minecraft-protocol@<version>.patch`
4. Run the suggested command from `pnpm patch ...` (previous step) to update the patch
### Would be useful to have
- cleanup folder & modules structure, cleanup playground code

View file

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

119
README.MD
View file

@ -2,65 +2,32 @@
![banner](./docs-assets/banner.jpg)
Minecraft **clone** rewritten in TypeScript using the best modern web technologies. Minecraft vanilla-compatible client and integrated server packaged into a single web app.
A true Minecraft client running in your browser! A port of the original game to the web, written in JavaScript using modern web technologies.
You can try this out at [mcraft.fun](https://mcraft.fun/), [pcm.gg](https://pcm.gg) (short link), [mcon.vercel.app](https://mcon.vercel.app/) or the GitHub pages deploy. Every commit from the default (`develop`) branch is deployed to [s.mcraft.fun](https://s.mcraft.fun/) and [s.pcm.gg](https://s.pcm.gg/) - so it's usually newer, but might be less stable.
If you encounter any bugs or usability issues, please report them! For development, see [development](#development--debugging).
> For Turkey/Russia use [ru.mcraft.fun](https://ru.mcraft.fun/) (since Cloudflare is blocked)
Don't confuse with [Eaglercraft](https://git.eaglercraft.rip/eaglercraft/eaglercraft-1.8) which is a REAL vanilla Minecraft Java edition port to the web (but with its own limitations). Eaglercraft is a fully playable solution, meanwhile this project is aimed for *device-compatiiblity* and better performance so it feels portable, flexible and lightweight. It's also a very strong example on how to build true HTML games for the web at scale entirely with the JS ecosystem. Have fun!
For building the project yourself / contributing, see [Development, Debugging & Contributing](#development-debugging--contributing). For reference at what and how web technologies / frameworks are used, see [TECH.md](./TECH.md) (also for comparison with Eaglercraft).
> **Note**: You can deploy it on your own server in less than a minute using a one-liner script from [Minecraft Everywhere repo](https://github.com/zardoy/minecraft-everywhere)
You can try this out at [mcraft.fun](https://mcraft.fun/), [pcm.gg](https://pcm.gg) (short link) [mcon.vercel.app](https://mcon.vercel.app/) or the GitHub pages deploy. Every commit from the `develop` (default) branch is deployed to [s.mcraft.fun](https://s.mcraft.fun/) - so it's usually newer, but might be less stable.
### Big Features
- Official Mineflayer [plugin integration](https://github.com/zardoy/mcraft-fun-mineflayer-plugin)! View / Control your bot remotely.
- Open any zip world file or even folder in read-write mode!
- Connect to Java servers running in both offline (cracked) and online mode* (it's possible because of proxy servers, see below)
- Integrated JS server clone capable of opening Java world saves in any way (folders, zip, web chunks streaming, etc)
- Singleplayer mode with simple world generations!
- Works offline
- First-class touch (mobile) & controller support
- First-class keybindings configuration
- Advanced Resource pack support: Custom GUI, all textures. Server resource packs are supported with proper CORS configuration.
- Builtin JEI with recipes & descriptions for almost every item (JEI is creative inventory replacement)
- Custom protocol channel extensions (eg for custom block models in the world)
- Play with friends over internet! (P2P is powered by Peer.js discovery servers)
- ~~Google Drive support for reading / saving worlds back to the cloud~~
- Support for custom rendering 3D engines. Modular architecture.
- First-class touch (mobile) & controller support
- FULL Resource pack support: Custom GUI, all textures & custom models! Server resource packs are also supported.
- Builtin JEI with recipes & guides for every item (also replaces creative inventory)
- even even more!
All components that are in [Storybook](https://minimap.mcraft.fun/storybook/) are published as npm module and can be used in other projects: [`minecraft-react`](https://npmjs.com/minecraft-react)
All components that are in [Storybook](https://mcraft.fun/storybook) are published as npm module and can be used in other projects: [`minecraft-react`](https://npmjs.com/minecraft-react)
### Recommended Settings
- Controls -> **Raw Input** -> **On** - This will make the controls more precise
- Controls -> **Touch Controls Type** -> **Joystick**
- Controls -> **Auto Full Screen** -> **On** - To avoid ctrl+w issue
- Interface -> **Enable Minimap** -> **Always** - To enable useful minimap (why not?)
- Controls -> **Raw Input** -> **On** - This will make the controls more precise (UPD: already enabled by default)
- Interface -> **Chat Select** -> **On** - To select chat messages (UPD: already enabled by default)
### Browser Notes
This project is tested with BrowserStack. Special thanks to [BrowserStack](https://www.browserstack.com/) for providing testing infrastructure!
Howerver, it's known that these browsers have issues:
**Opera Mini**: Disable *mouse gestures* in browsre settings to avoid opening new tab on right click hold
**Vivaldi**: Disable Controls -> *Raw Input* in game settings if experiencing issues
### Versions Support
Server versions 1.8 - 1.21.5 are supported.
First class versions (most of the features are tested on these versions):
- 1.19.4
- 1.21.4
Versions below 1.13 are not tested currently and may not work correctly.
- Interface -> **Chat Select** -> **On** - To select chat messages
### World Loading
@ -70,15 +37,11 @@ Whatever offline mode you used (zip, folder, just single player), you can always
![docs-assets/singleplayer-future-city-1-10-2.jpg](./docs-assets/singleplayer-future-city-1-10-2.jpg)
### Servers & Proxy
### Servers
You can play almost on any Java server, vanilla servers are fully supported.
See the [Mineflayer](https://github.com/PrismarineJS/mineflayer) repo for the list of supported versions (should support majority of versions).
There is a builtin proxy, but you can also host your one! Just clone the repo, run `pnpm i` (following CONTRIBUTING.MD) and run `pnpm prod-start`, then you can specify `http://localhost:8080` in the proxy field. Or you can deploy it to the cloud service:
[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?name=minecraft-web-client&type=git&repository=zardoy%2Fminecraft-web-client&branch=next&builder=dockerfile&env%5B%5D=&ports=8080%3Bhttp%3B%2F)
> **Note**: If you want to make **your own** Minecraft server accessible to web clients (without our proxies), you can use [mwc-proxy](https://github.com/zardoy/mwc-proxy) - a lightweight JS WebSocket proxy that runs on the same server as your Minecraft server, allowing players to connect directly via `wss://play.example.com`. `?client_mcraft` is added to the URL, so the proxy will know that it's this client.
There is a builtin proxy, but you can also host your one! Just clone the repo, run `pnpm i` (following CONTRIBUTING.MD) and run `pnpm prod-start`, then you can specify `http://localhost:8080` in the proxy field.
Proxy servers are used to connect to Minecraft servers which use TCP protocol. When you connect connect to a server with a proxy, websocket connection is created between you (browser client) and the proxy server located in Europe, then the proxy connects to the Minecraft server and sends the data to the client (you) without any packet deserialization to avoid any additional delays. That said all the Minecraft protocol packets are processed by the client, right in your browser.
@ -119,29 +82,28 @@ There are many many settings, that are not exposed in the UI yet. You can find o
To open the console, press `F12`, or if you are on mobile, you can type `#dev` in the URL (browser address bar), it wont't reload the page, but you will see a button to open the console. This way you can change advanced settings and see all errors or warnings. Also this way you can access global variables (described below).
### Development, Debugging & Contributing
### Development & Debugging
It should be easy to build/start the project locally. See [CONTRIBUTING.MD](./CONTRIBUTING.md) for more info. Also you can look at Dockerfile for reference.
There is world renderer playground ([link](https://mcon.vercel.app/playground/)).
There is world renderer playground ([link](https://mcon.vercel.app/playground.html)).
However, there are many things that can be done in online production version (like debugging actual source code). Also you can access some global variables in the console and there are a few useful examples:
However, there are many things that can be done in online version. You can access some global variables in the console and useful examples:
- If you type `debugToggle`, press enter in console - It will enables all debug messages! Warning: this will start all packets spam.
- `localStorage.debug = '*'` - Enables all debug messages! Warning: this will start all packets spam.
Instead I recommend setting `options.debugLogNotFrequentPackets`. Also you can use `debugTopPackets` (with JSON.stringify) to see what packets were received/sent by name
- `bot` - Mineflayer bot instance. See Mineflayer documentation for more.
- `world` - Three.js world instance, basically does all the rendering (part of renderer backend).
- `world.sectionObjects` - Object with all active chunk sections (geometries) in the world. Each chunk section is a Three.js mesh or group.
- `viewer` - Three.js viewer instance, basically does all the rendering.
- `viewer.world.sectionObjects` - Object with all active chunk sections (geometries) in the world. Each chunk section is a Three.js mesh or group.
- `debugSceneChunks` - The same as above, but relative to current bot position (e.g. 0,0 is the current chunk).
- `debugChangedOptions` - See what options are changed. Don't change options here.
- `localServer`/`server` - Only for singleplayer mode/host. Flying Squid server instance, see it's documentation for more.
- `localServer.overworld.storageProvider.regions` - See ALL LOADED region files with all raw data.
- `localServer.levelData.LevelName = 'test'; localServer.writeLevelDat()` - Change name of the world
- `nbt.simplify(someNbt)` - Simplifies nbt data, so it's easier to read.
The most useful thing in devtools is the watch expression. You can add any expression there and it will be re-evaluated in real time. For example, you can add `world.getCameraPosition()` to see the camera position and so on.
The most useful thing in devtools is the watch expression. You can add any expression there and it will be re-evaluated in real time. For example, you can add `viewer.camera.position` to see the camera position and so on.
<img src="./docs-assets/watch-expr.png" alt="Watch expression" width="480"/>
@ -162,12 +124,6 @@ Press `Y` to set query parameters to url of your current game state.
There are some parameters you can set in the url to archive some specific behaviors:
General:
- **`?setting=<setting_name>:<setting_value>`** - Set and lock the setting on load. You can set multiple settings by separating them with `&` e.g. `?setting=autoParkour:true&setting=renderDistance:4`
- `?modal=<modal>` - Open specific modal on page load eg `keybindings`. Very useful on UI changes testing during dev. For path use `,` as separator. To get currently opened modal type this in the console: `activeModalStack.at(-1).reactType`
- `?replayFileUrl=<url>` - Load and start a packet replay session from a URL with a integrated server. For debugging / previewing recorded sessions. The file must be CORS enabled.
Server specific:
- `?ip=<server_address>` - Display connect screen to the server on load with predefined server ip. `:<port>` is optional and can be added to the ip.
@ -176,46 +132,19 @@ Server specific:
- `?proxy=<proxy_address>` - Set the proxy server address to use for the server
- `?username=<username>` - Set the username for the server
- `?lockConnect=true` - Only works then `ip` parameter is set. Disables cancel/save buttons and all inputs in the connect screen already set as parameters. Useful for integrates iframes.
- `?autoConnect=true` - Only works then `ip` and `version` parameters are set and `allowAutoConnect` is `true` in config.json! Directly connects to the specified server. Useful for integrates iframes.
- `?serversList=<list_or_url>` - `<list_or_url>` can be a list of servers in the format `ip:version,ip` or a url to a json file with the same format (array) or a txt file with line-delimited list of server IPs.
- `?addPing=<ping>` - Add a latency to both sides of the connection. Useful for testing ping issues. For example `?addPing=100` will add 200ms to your ping.
- `?reconnect=true` - Reconnect to the server on page reloads. Available in **dev mode only** and very useful on server testing.
Single player specific:
- `?loadSave=<save_name>` - Load the save on load with the specified folder name (not title)
- `?singleplayer=1` or `?sp=1` - Create empty world on load. Nothing will be saved
- `?singleplayer=1` - Create empty world on load. Nothing will be saved
- `?version=<version>` - Set the version for the singleplayer world (when used with `?singleplayer=1`)
- `?noSave=true` - Disable auto save on unload / disconnect / export whenever a world is loaded. Only manual save with `/save` command will work.
- `?serverSetting=<key>:<value>` - Set local server [options](https://github.com/zardoy/space-squid/tree/everything/src/modules.ts#L51). For example `?serverSetting=noInitialChunksSend:true` will disable initial chunks loading on the loading screen.
- `?map=<map_url>` - Load the map from ZIP. You can use any url, but it must be **CORS enabled**.
- `?mapDir=<index_file_url>` - Load the map from a file descriptor. It's recommended and the fastest way to load world but requires additional setup. The file must be in the following format:
- `?map=<map_url>` - Load the map from ZIP. You can use any url, but it must be CORS enabled.
```json
{
"baseUrl": "<url>",
"index": {
"level.dat": null,
"region": {
"r.-1.-1.mca": null,
"r.-1.0.mca": null,
"r.0.-1.mca": null,
"r.0.0.mca": null,
}
}
}
```
General:
Note that `mapDir` also accepts base64 encoded JSON like so:
`?mapDir=data:application/json;base64,...` where `...` is the base64 encoded JSON of the index file.
In this case you must use `?mapDirBaseUrl` to specify the base URL to fetch the files from index.
- `?mapDirBaseUrl` - See above.
Only during development:
- `?reconnect=true` - Reconnect to the server on page reloads. Very useful on server testing.
<!-- - `?mapDirGuess=<base_url>` - Load the map from the provided URL and paths will be guessed with a few additional fetch requests. -->
- `?setting=<setting_name>:<setting_value>` - Set and lock the setting on load. You can set multiple settings by separating them with `&` e.g. `?setting=autoParkour:true&setting=renderDistance:4`
### Notable Things that Power this Project
@ -234,5 +163,3 @@ Only during development:
### Alternatives
- [https://github.com/ClassiCube/ClassiCube](ClassiCube - Better C# Rewrite) [DEMO](https://www.classicube.net/server/play/?warned=true)
- [https://m.eaglercraft.com/](EaglerCraft) - Eaglercraft runnable on mobile (real Minecraft in the browser)
- [js-minecraft](https://github.com/LabyStudio/js-minecraft) - An insanely well done clone from the graphical side that inspired many features here

View file

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

58
TECH.md
View file

@ -1,58 +0,0 @@
### Eaglercraft Comparison
This project uses proxies so you can connect to almost any vanilla server. Though proxies have some limitations such as increased latency and servers will complain about using VPN (though we have a workaround for that, but ping will be much higher).
This client generally has better performance but some features reproduction might be inaccurate eg its less stable and more buggy in some cases.
| Feature | This project | Eaglercraft | Description |
| --------------------------------- | ----------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| General | | | |
| Mobile Support (touch) | ✅(+) | ✅ | |
| Gamepad Support | ✅ | ❌ | |
| A11Y | ✅ | ❌ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page (but maybe it's not needed) |
| Game Features | | | |
| Servers Support (quality) | ❌(+) | ✅ | Eaglercraft is vanilla Minecraft, while this project tries to emulate original game behavior at protocol level (Mineflayer is used) |
| Servers Support (any version, ip) | ✅ | ❌ | We support almost all Minecraft versions, only important if you connect to a server where you need new content like blocks or if you play with friends. And you can connect to almost any server using proxy servers! |
| Servers Support (online mode) | ✅ | ❌ | Join to online servers like Hypixel using your Microsoft account without additional proxies |
| Singleplayer Survival Features | ❌ | ✅ | Just like Eaglercraft this project can generate and save worlds, but generator is simple and only a few survival features are supported (look here for [supported features list](https://github.com/zardoy/space-squid)) |
| Singleplayer Maps | ✅ | ✅ | We support any version, but adventure maps won't work, but simple parkour and build maps might be interesting to explore... |
| Singleplayer Maps World Streaming | ✅ | ❌ | Thanks to Browserfs, saves can be loaded to local singleplayer server using multiple ways: from local folder, server directory (not zip), dropbox or other cloud *backend* etc... |
| P2P Multiplayer | ✅ | ✅ | A way to connect to other browser running the project. But it's almost useless here since many survival features are not implemented. Maybe only to build / explore maps together... |
| Voice Chat | ❌(+) | ✅ | Eaglercraft has custom WebRTC voice chat implementation, though it could also be easily implemented there |
| Online Servers | ✅ | ❌ | We have custom implementation (including integration on proxy side) for joining to servers |
| Plugin Features | ✅ | ❌ | We have Mineflayer plugins support, like Auto Jump & Auto Parkour was added here that way |
| Direct Connection | ✅ | ✅ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page |
| Moding | ✅(own js mods) | ❌ | This project will support mods for singleplayer. In theory its possible to implement support for modded servers on protocol level (including all needed mods) |
| Video Recording | ❌ | ✅ | Doesn't feel needed |
| Metaverse Features | ✅(50%) | ❌ | We have videos / images support inside world, but not iframes (custom protocol channel) |
| Sounds | ✅ | ✅ | |
| Resource Packs | ✅(+extras) | ✅ | This project has very limited support for them (only textures images are loadable for now) |
| Assets Compressing & Splitting | ✅ | ❌ | We have advanced Minecraft data processing and good code chunk splitting so the web app will open much faster and use less memory |
| Graphics | | | |
| Fancy Graphics | ❌ | ✅ | While Eaglercraft has top-level shaders we don't even support lighting |
| Fast & Efficient Graphics | ❌(+) | ❌ | Feels like no one needs to have 64 rendering distance work smoothly |
| VR | ✅(-) | ❌ | Feels like not needed feature. UI is missing in this project since DOM can't be rendered in VR so Eaglercraft could be better in that aspect |
| AR | ❌ | ❌ | Would be the most useless feature |
| Minimap & Waypoints | ✅(-) | ❌ | We have buggy minimap, which can be enabled in settings and full map is opened by pressing `M` key |
Features available to only this project:
- CSS & JS Customization
- JS Real Time Debugging & Console Scripting (eg Devtools)
### Tech Stack
Bundler: Rsbuild!
UI: powered by React and css modules. Storybook helps with UI development.
### Rare WEB Features
There are a number of web features that are not commonly used but you might be interested in them if you decide to build your own game in the web.
TODO
| API | Usage & Description |
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `Crypto` API | Used to make chat features work when joining online servers with authentication. |
| `requestPointerLock({ unadjustedMovement: true })` API | Required for games. Disables system mouse acceleration (important for Mac users). Aka mouse raw input |
| `navigator.keyboard.lock()` | (only in Chromium browsers) When entering fullscreen it allows to use any key combination like ctrl+w in the game |
| `navigator.keyboard.getLayoutMap()` | (only in Chromium browsers) To display the right keyboard symbol for the key keybinding on different keyboard layouts (e.g. QWERTY vs AZERTY) |

View file

@ -1,39 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Configure client</title>
<script>
function removeSettings() {
if (confirm('Are you sure you want to RESET ALL SETTINGS?')) {
localStorage.setItem('options', '{}');
location.reload();
}
}
function removeAllData() {
localStorage.removeItem('serversList')
localStorage.removeItem('serversHistory')
localStorage.removeItem('authenticatedAccounts')
localStorage.removeItem('modsAutoUpdateLastCheck')
localStorage.removeItem('firstModsPageVisit')
localStorage.removeItem('proxiesData')
localStorage.removeItem('keybindings')
localStorage.removeItem('username')
localStorage.removeItem('customCommands')
localStorage.removeItem('options')
}
</script>
</head>
<body>
<div style="display: flex;gap: 10px;">
<button onclick="removeSettings()">Reset all settings</button>
<button onclick="removeAllData()">Remove all user data (but not mods or worlds)</button>
<!-- <button>Remove all user data (worlds, resourcepacks)</button> -->
<!-- <button>Remove all mods</button> -->
<!-- <button>Remove all mod repositories</button> -->
</div>
<input />
</body>
</html>

View file

@ -1,2 +0,0 @@
here you can place custom textures for bundled files (blocks/items) e.g. blocks/stone.png
get file names from here (blocks/items) https://zardoy.github.io/mc-assets/

View file

@ -1,237 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Input Debugger</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
background: #f0f0f0;
}
.key-container {
display: grid;
grid-template-columns: repeat(3, 60px);
gap: 5px;
margin: 20px 0;
}
.key {
width: 60px;
height: 60px;
border: 2px solid #333;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
background: white;
position: relative;
user-select: none;
}
.key.pressed {
background: #90EE90;
}
.key .duration {
position: absolute;
bottom: 2px;
font-size: 10px;
}
.key .count {
position: absolute;
top: 2px;
right: 2px;
font-size: 10px;
}
.controls {
margin: 20px 0;
padding: 10px;
background: white;
border-radius: 5px;
}
.wasd-container {
position: relative;
width: 190px;
height: 130px;
}
#KeyW {
position: absolute;
left: 65px;
top: 0;
}
#KeyA {
position: absolute;
left: 0;
top: 65px;
}
#KeyS {
position: absolute;
left: 65px;
top: 65px;
}
#KeyD {
position: absolute;
left: 130px;
top: 65px;
}
.space-container {
margin-top: 20px;
}
#Space {
width: 190px;
}
</style>
</head>
<body>
<div class="controls">
<label>
<input type="checkbox" id="repeatMode"> Use keydown repeat mode (auto key-up after 150ms of no repeat)
</label>
</div>
<div class="wasd-container">
<div id="KeyW" class="key" data-code="KeyW">W</div>
<div id="KeyA" class="key" data-code="KeyA">A</div>
<div id="KeyS" class="key" data-code="KeyS">S</div>
<div id="KeyD" class="key" data-code="KeyD">D</div>
</div>
<div class="key-container">
<div id="ControlLeft" class="key" data-code="ControlLeft">Ctrl</div>
</div>
<div class="space-container">
<div id="Space" class="key" data-code="Space">Space</div>
</div>
<script>
const keys = {};
const keyStats = {};
const pressStartTimes = {};
const keyTimeouts = {};
function initKeyStats(code) {
if (!keyStats[code]) {
keyStats[code] = {
pressCount: 0,
duration: 0,
startTime: 0
};
}
}
function updateKeyVisuals(code) {
const element = document.getElementById(code);
if (!element) return;
const stats = keyStats[code];
if (keys[code]) {
element.classList.add('pressed');
const currentDuration = ((Date.now() - stats.startTime) / 1000).toFixed(1);
element.innerHTML = `${element.getAttribute('data-code').replace('Key', '').replace('Left', '')}<span class="duration">${currentDuration}s</span><span class="count">${stats.pressCount}</span>`;
} else {
element.classList.remove('pressed');
element.innerHTML = `${element.getAttribute('data-code').replace('Key', '').replace('Left', '')}<span class="count">${stats.pressCount}</span>`;
}
}
function releaseKey(code) {
keys[code] = false;
if (pressStartTimes[code]) {
keyStats[code].duration += (Date.now() - pressStartTimes[code]) / 1000;
delete pressStartTimes[code];
}
updateKeyVisuals(code);
}
function handleKeyDown(event) {
const code = event.code;
const isRepeatMode = document.getElementById('repeatMode').checked;
initKeyStats(code);
// Clear any existing timeout for this key
if (keyTimeouts[code]) {
clearTimeout(keyTimeouts[code]);
delete keyTimeouts[code];
}
if (isRepeatMode) {
// In repeat mode, always handle the keydown
if (!keys[code] || event.repeat) {
keys[code] = true;
if (!event.repeat) {
// Only increment count on initial press, not repeats
keyStats[code].pressCount++;
keyStats[code].startTime = Date.now();
pressStartTimes[code] = Date.now();
}
}
// Set timeout to release key if no repeat events come
keyTimeouts[code] = setTimeout(() => {
releaseKey(code);
}, 150);
} else {
// In normal mode, only handle keydown if key is not already pressed
if (!keys[code]) {
keys[code] = true;
keyStats[code].pressCount++;
keyStats[code].startTime = Date.now();
pressStartTimes[code] = Date.now();
}
}
updateKeyVisuals(code);
event.preventDefault();
}
function handleKeyUp(event) {
const code = event.code;
const isRepeatMode = document.getElementById('repeatMode').checked;
if (!isRepeatMode) {
releaseKey(code);
}
event.preventDefault();
}
// Initialize all monitored keys
const monitoredKeys = ['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ControlLeft', 'Space'];
monitoredKeys.forEach(code => {
initKeyStats(code);
const element = document.getElementById(code);
if (element) {
element.innerHTML = `${element.getAttribute('data-code').replace('Key', '').replace('Left', '')}<span class="count">0</span>`;
}
});
// Start visual updates
setInterval(() => {
monitoredKeys.forEach(code => {
if (keys[code]) {
updateKeyVisuals(code);
}
});
}, 100);
// Event listeners
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('keyup', handleKeyUp);
// Handle mode changes
document.getElementById('repeatMode').addEventListener('change', () => {
// Release all keys when switching modes
monitoredKeys.forEach(code => {
if (keys[code]) {
releaseKey(code);
}
if (keyTimeouts[code]) {
clearTimeout(keyTimeouts[code]);
delete keyTimeouts[code];
}
});
});
</script>
</body>
</html>

View file

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

View file

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

59
buildWorkers.mjs Normal file
View file

@ -0,0 +1,59 @@
// main worker file intended for computing world geometry is built using prismarine-viewer/buildWorker.mjs
import { build, context } from 'esbuild'
import fs from 'fs'
import path from 'path'
const watch = process.argv.includes('-w')
const result = await (watch ? context : build)({
bundle: true,
platform: 'browser',
entryPoints: ['prismarine-viewer/examples/webgpuRendererWorker.ts'],
outdir: 'prismarine-viewer/public/',
sourcemap: watch ? 'inline' : 'external',
minify: !watch,
treeShaking: true,
logLevel: 'info',
alias: {
'three': './node_modules/three/src/Three.js',
events: 'events', // make explicit
buffer: 'buffer',
'fs': 'browserfs/dist/shims/fs.js',
http: 'http-browserify',
perf_hooks: './src/perf_hooks_replacement.js',
crypto: './src/crypto.js',
stream: 'stream-browserify',
net: 'net-browserify',
assert: 'assert',
dns: './src/dns.js'
},
plugins: [
{
name: 'writeOutput',
setup (build) {
build.onEnd(({ outputFiles }) => {
for (const file of outputFiles) {
for (const dir of ['prismarine-viewer/public', 'dist']) {
const baseName = path.basename(file.path)
fs.writeFileSync(path.join(dir, baseName), file.contents)
}
}
})
}
}
],
loader: {
'.vert': 'text',
'.frag': 'text',
'.wgsl': 'text',
},
mainFields: [
'browser', 'module', 'main'
],
keepNames: true,
write: false,
})
if (watch) {
await result.watch()
}

View file

@ -1,80 +1,23 @@
{
"version": 1,
"defaultHost": "<from-proxy>",
"defaultProxy": "https://proxy.mcraft.fun",
"defaultProxy": "proxy.mcraft.fun",
"mapsProvider": "https://maps.mcraft.fun/",
"skinTexturesProxy": "",
"peerJsServer": "",
"peerJsServerFallback": "https://p2p.mcraft.fun",
"promoteServers": [
{
"ip": "wss://play.mcraft.fun"
},
{
"ip": "wss://play.webmc.fun",
"name": "WebMC"
},
{
"ip": "wss://ws.fuchsmc.net"
},
{
"ip": "wss://play2.mcraft.fun"
},
{
"ip": "wss://play-creative.mcraft.fun",
"description": "Might be available soon, stay tuned!"
},
{
"ip": "kaboom.pw",
"version": "1.20.3",
"description": "Very nice a polite server. Must try for everyone!"
}
],
"rightSideText": "A Minecraft client clone in the browser!",
"splashText": "The sunset is coming!",
"splashTextFallback": "Welcome!",
"pauseLinks": [
[
{
"type": "github"
},
{
"type": "discord"
}
]
],
"defaultUsername": "mcrafter{0-9999}",
"mobileButtons": [
{
"action": "general.drop",
"actionHold": "general.dropStack",
"label": "Q"
"version": "1.18.2",
"description": "Chaos and destruction server. Free for everyone."
},
{
"action": "general.selectItem",
"actionHold": "",
"label": "S"
"ip": "go.mineberry.org",
"version": "1.18.2",
"description": "One of the best servers here. Join now!"
},
{
"action": "general.debugOverlay",
"actionHold": "general.debugOverlayHelpMenu",
"label": "F3"
},
{
"action": "general.playersList",
"actionHold": "",
"icon": "pixelarticons:users",
"label": "TAB"
},
{
"action": "general.chat",
"actionHold": "",
"label": ""
},
{
"action": "ui.pauseMenu",
"actionHold": "",
"label": ""
"ip": "sus.shhnowisnottheti.me",
"version": "1.18.2",
"description": "Creative, your own 'boxes' (islands)"
}
]
}

View file

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

View file

@ -1,11 +1,8 @@
import { defineConfig } from 'cypress'
const isPerformanceTest = process.env.PERFORMANCE_TEST === 'true'
export default defineConfig({
video: false,
chromeWebSecurity: false,
screenshotOnRunFailure: true, // Enable screenshots on test failures
e2e: {
// We've imported your old cypress plugins here.
// You may want to clean this up later by importing these.
@ -34,7 +31,7 @@ export default defineConfig({
return require('./cypress/plugins/index.js')(on, config)
},
baseUrl: 'http://localhost:8080',
specPattern: !isPerformanceTest ? 'cypress/e2e/smoke.spec.ts' : 'cypress/e2e/rendering_performance.spec.ts',
specPattern: 'cypress/e2e/**/*.spec.ts',
excludeSpecPattern: ['**/__snapshots__/*', '**/__image_snapshots__/*'],
},
})

View file

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

View file

@ -0,0 +1,25 @@
import { cleanVisit, setOptions } from './shared'
it('Loads & renders singleplayer', () => {
cleanVisit('/?singleplayer=1')
setOptions({
renderDistance: 2
})
// wait for .initial-loader to disappear
cy.get('.initial-loader', { timeout: 20_000 }).should('not.exist')
cy.window()
.its('performance')
.invoke('mark', 'worldLoad')
cy.document().then({ timeout: 20_000 }, doc => {
return new Cypress.Promise(resolve => {
doc.addEventListener('cypress-world-ready', resolve)
})
}).then(() => {
const duration = cy.window()
.its('performance')
.invoke('measure', 'modalOpen')
.its('duration')
cy.log('Duration', duration)
})
})

View file

@ -1,32 +0,0 @@
/// <reference types="cypress" />
import { BenchmarkAdapterInfo, getAllInfoLines } from '../../src/benchmarkAdapter'
import { cleanVisit } from './shared'
it('Benchmark rendering performance', () => {
cleanVisit('/?openBenchmark=true&renderDistance=5')
// wait for render end event
return cy.document().then({ timeout: 180_000 }, doc => {
return new Cypress.Promise(resolve => {
cy.log('Waiting for world to load')
doc.addEventListener('cypress-world-ready', resolve)
}).then(() => {
cy.log('World loaded')
})
}).then(() => {
cy.window().then(win => {
const adapter = win.benchmarkAdapter as BenchmarkAdapterInfo
const messages = getAllInfoLines(adapter)
// wait for 10 seconds
cy.wait(10_000)
const messages2 = getAllInfoLines(adapter, true)
for (const message of messages) {
cy.log(message)
}
for (const message of messages2) {
cy.log(message)
}
cy.writeFile('benchmark.txt', [...messages, ...messages2].join('\n'))
})
})
})

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

View file

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

View file

@ -1,26 +0,0 @@
// Include the pako library
import pako from 'pako';
import compressedJsRaw from './compressed.js?raw'
function decompressFromBase64(input) {
// Decode the Base64 string
const binaryString = atob(input);
const len = binaryString.length;
const bytes = new Uint8Array(len);
// Convert the binary string to a byte array
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
// Decompress the byte array
const decompressedData = pako.inflate(bytes, { to: 'string' });
return decompressedData;
}
// Use the function
console.time('decompress');
const decompressedData = decompressFromBase64(compressedJsRaw);
console.timeEnd('decompress')
console.log(decompressedData)

View file

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

View file

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

View file

@ -1,13 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Minecraft Item Viewer</title>
<style>
body { margin: 0; overflow: hidden; }
canvas { display: block; }
</style>
</head>
<body>
<script type="module" src="./three-item.ts"></script>
</body>
</html>

View file

@ -1,108 +0,0 @@
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import itemsAtlas from 'mc-assets/dist/itemsAtlasLegacy.png'
import { createItemMeshFromCanvas, createItemMesh } from '../renderer/viewer/three/itemMesh'
// Create scene, camera and renderer
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)
// Setup camera and controls
camera.position.set(0, 0, 3)
const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
// Background and lights
scene.background = new THREE.Color(0x333333)
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7)
scene.add(ambientLight)
// Animation loop
function animate () {
requestAnimationFrame(animate)
controls.update()
renderer.render(scene, camera)
}
async function setupItemMesh () {
try {
const loader = new THREE.TextureLoader()
const atlasTexture = await loader.loadAsync(itemsAtlas)
// Pixel-art configuration
atlasTexture.magFilter = THREE.NearestFilter
atlasTexture.minFilter = THREE.NearestFilter
atlasTexture.generateMipmaps = false
atlasTexture.wrapS = atlasTexture.wrapT = THREE.ClampToEdgeWrapping
// Extract the tile at x=2, y=0 (16x16)
const tileSize = 16
const tileX = 2
const tileY = 0
const canvas = document.createElement('canvas')
canvas.width = tileSize
canvas.height = tileSize
const ctx = canvas.getContext('2d')!
ctx.imageSmoothingEnabled = false
ctx.drawImage(
atlasTexture.image,
tileX * tileSize,
tileY * tileSize,
tileSize,
tileSize,
0,
0,
tileSize,
tileSize
)
// Test both approaches - working manual extraction:
const meshOld = createItemMeshFromCanvas(canvas, { depth: 0.1 })
meshOld.position.x = -1
meshOld.rotation.x = -Math.PI / 12
meshOld.rotation.y = Math.PI / 12
scene.add(meshOld)
// And new unified function:
const atlasWidth = atlasTexture.image.width
const atlasHeight = atlasTexture.image.height
const u = (tileX * tileSize) / atlasWidth
const v = (tileY * tileSize) / atlasHeight
const sizeX = tileSize / atlasWidth
const sizeY = tileSize / atlasHeight
console.log('Debug texture coords:', {u, v, sizeX, sizeY, atlasWidth, atlasHeight})
const resultNew = createItemMesh(atlasTexture, {
u, v, sizeX, sizeY
}, {
faceCamera: false,
use3D: true,
depth: 0.1
})
resultNew.mesh.position.x = 1
resultNew.mesh.rotation.x = -Math.PI / 12
resultNew.mesh.rotation.y = Math.PI / 12
scene.add(resultNew.mesh)
animate()
} catch (err) {
console.error('Failed to create item mesh:', err)
}
}
// Handle window resize
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
})
// Start
setupItemMesh()

View file

@ -1,5 +0,0 @@
<script type="module" src="three-labels.ts"></script>
<style>
body { margin: 0; }
canvas { display: block; }
</style>

View file

@ -1,67 +0,0 @@
import * as THREE from 'three'
import { FirstPersonControls } from 'three/addons/controls/FirstPersonControls.js'
import { createWaypointSprite, WAYPOINT_CONFIG } from '../renderer/viewer/three/waypointSprite'
// Create scene, camera and renderer
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)
// Add FirstPersonControls
const controls = new FirstPersonControls(camera, renderer.domElement)
controls.lookSpeed = 0.1
controls.movementSpeed = 10
controls.lookVertical = true
controls.constrainVertical = true
controls.verticalMin = 0.1
controls.verticalMax = Math.PI - 0.1
// Position camera
camera.position.y = 1.6 // Typical eye height
camera.lookAt(0, 1.6, -1)
// Create a helper grid and axes
const grid = new THREE.GridHelper(20, 20)
scene.add(grid)
const axes = new THREE.AxesHelper(5)
scene.add(axes)
// Create waypoint sprite via utility
const waypoint = createWaypointSprite({
position: new THREE.Vector3(0, 0, -5),
color: 0xff0000,
label: 'Target',
})
scene.add(waypoint.group)
// Use built-in offscreen arrow from utils
waypoint.enableOffscreenArrow(true)
waypoint.setArrowParent(scene)
// Animation loop
function animate() {
requestAnimationFrame(animate)
const delta = Math.min(clock.getDelta(), 0.1)
controls.update(delta)
// Unified camera update (size, distance text, arrow, visibility)
const sizeVec = renderer.getSize(new THREE.Vector2())
waypoint.updateForCamera(camera.position, camera, sizeVec.width, sizeVec.height)
renderer.render(scene, camera)
}
// Handle window resize
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
})
// Add clock for controls
const clock = new THREE.Clock()
animate()

View file

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

View file

@ -1,60 +0,0 @@
import * as THREE from 'three'
// 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()
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)
// Position camera
camera.position.z = 5
// Create a canvas with some content
const canvas = document.createElement('canvas')
canvas.width = 256
canvas.height = 256
const ctx = canvas.getContext('2d')
scene.background = new THREE.Color(0x444444)
// 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)
// 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()

View file

@ -1,18 +1,8 @@
<!DOCTYPE html>
<html>
<head>
<meta name="darkreader-lock">
<script>
window.startLoad = Date.now()
// g663 fix: forbid change of string prototype
Object.defineProperty(String.prototype, 'format', {
writable: false,
configurable: false
});
Object.defineProperty(String.prototype, 'replaceAll', {
writable: false,
configurable: false
});
</script>
<!-- // #region initial loader -->
<script async>
@ -25,9 +15,6 @@
<div>
<div style="font-size: calc(var(--font-size) * 1.8);color: lightgray;" class="title">Loading...</div>
<div style="font-size: var(--font-size);color: rgb(176, 176, 176);margin-top: 3px;text-align: center" class="subtitle">A true Minecraft client in your browser!</div>
<!-- small text pre -->
<div style="font-size: calc(var(--font-size) * 0.6);color: rgb(150, 150, 150);margin-top: 3px;text-align: center;white-space: pre-line;" class="advanced-info"></div>
<div style="font-size: calc(var(--font-size) * 0.6);color: rgb(255, 100, 100);margin-top: 10px;text-align: center;display: none;" class="ios-warning">Only iOS 15+ is supported due to performance optimizations</div>
</div>
</div>
`
@ -37,55 +24,24 @@
if (!window.pageLoaded) {
document.documentElement.appendChild(loadingDivElem)
}
// iOS version detection
const getIOSVersion = () => {
const match = navigator.userAgent.match(/OS (\d+)_(\d+)_?(\d+)?/);
return match ? parseInt(match[1], 10) : null;
}
// load error handling
const onError = (errorOrMessage, log = false) => {
let message = errorOrMessage instanceof Error ? (errorOrMessage.stack ?? errorOrMessage.message) : errorOrMessage
if (log) console.log(message)
if (typeof message !== 'string') message = String(message)
const onError = (message) => {
console.log(message)
if (document.querySelector('.initial-loader') && document.querySelector('.initial-loader').querySelector('.title').textContent !== 'Error') {
document.querySelector('.initial-loader').querySelector('.title').textContent = 'Error'
const [errorMessage, ...errorStack] = message.split('\n')
document.querySelector('.initial-loader').querySelector('.subtitle').textContent = errorMessage
document.querySelector('.initial-loader').querySelector('.advanced-info').textContent = errorStack.join('\n')
// Show iOS warning if applicable
const iosVersion = getIOSVersion();
if (iosVersion !== null && iosVersion < 15) {
document.querySelector('.initial-loader').querySelector('.ios-warning').style.display = 'block';
}
document.querySelector('.initial-loader').querySelector('.subtitle').textContent = message
if (window.navigator.maxTouchPoints > 1) window.location.hash = '#dev' // show eruda
// unregister all sw
if (window.navigator.serviceWorker && document.querySelector('.initial-loader').style.opacity !== 0) {
console.log('got worker')
window.navigator.serviceWorker.getRegistrations().then(registrations => {
registrations.forEach(registration => {
console.log('got registration')
registration.unregister().then(() => {
console.log('worker unregistered')
})
})
})
}
window.lastError = errorOrMessage instanceof Error ? errorOrMessage : new Error(errorOrMessage)
}
}
window.addEventListener('unhandledrejection', (e) => onError(e.reason, true))
window.addEventListener('error', (e) => onError(e.error ?? e.message))
window.addEventListener('unhandledrejection', (e) => onError(e.reason))
window.addEventListener('error', (e) => onError(e.message))
}
insertLoadingDiv()
document.addEventListener('DOMContentLoaded', () => {
// move loading div to the end of body
const loadingDivElem = document.querySelector('.initial-loader');
const newContainer = document.body; // replace with your new container
if (loadingDivElem) newContainer.appendChild(loadingDivElem);
newContainer.appendChild(loadingDivElem);
})
</script>
<script type="module" async>
@ -95,25 +51,6 @@
import('https://cdn.skypack.dev/eruda').then(({ default: eruda }) => {
eruda.init()
})
Promise.all([import('https://cdn.skypack.dev/stacktrace-gps'), import('https://cdn.skypack.dev/error-stack-parser')]).then(async ([{ default: StackTraceGPS }, { default: ErrorStackParser }]) => {
if (!window.lastError) return
let stackFrames = [];
if (window.lastError instanceof Error) {
stackFrames = ErrorStackParser.parse(window.lastError);
}
console.log('stackFrames', stackFrames)
const gps = new StackTraceGPS()
const mappedFrames = await Promise.all(
stackFrames.map(frame => gps.pinpoint(frame))
);
console.log('mappedFrames', mappedFrames)
const stackTrace = mappedFrames
.map(frame => `at ${frame.functionName} (${frame.fileName}:${frame.lineNumber}:${frame.columnNumber})`)
.join('\n');
console.log('stackTrace', stackTrace)
})
}
}
checkLoadEruda()
@ -147,17 +84,21 @@
window.loadedPlugins[pluginName] = await import(script)
}
</script> -->
<title>Minecraft Web Client</title>
<!-- <link rel="canonical" href="https://mcraft.fun"> -->
<meta name="description" content="Minecraft Java Edition Client in Browser — Full Multiplayer Support, Server Connect, Offline Play — Join real Minecraft servers">
<title>Prismarine Web Client</title>
<link rel="favicon" href="favicon.png">
<link rel="icon" type="image/png" href="favicon.png" />
<link rel="canonical" href="https://mcraft.fun">
<meta name="description" content="Minecraft web client running in your browser">
<meta name="keywords" content="Play, Minecraft, Online, Web, Java, Server, Single player, Javascript, PrismarineJS, Voxel, WebGL, Three.js">
<meta name="date" content="2024-07-11" scheme="YYYY-MM-DD">
<meta name="language" content="English">
<meta name="theme-color" content="#349474">
<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover'>
<meta property="og:title" content="Minecraft Web Client" />
<meta property="og:title" content="Prismarine Web Client" />
<meta property="og:type" content="website" />
<meta property="og:image" content="favicon.png" />
<meta name="format-detection" content="telephone=no">
<link rel="manifest" href="manifest.json" crossorigin="use-credentials">
</head>
<body>
<div id="react-root"></div>

View file

@ -5,45 +5,35 @@
"scripts": {
"dev-rsbuild": "rsbuild dev",
"dev-proxy": "node server.js",
"start": "run-p dev-proxy dev-rsbuild watch-mesher",
"start2": "run-p dev-rsbuild watch-mesher",
"start-metrics": "ENABLE_METRICS=true rsbuild dev",
"build": "pnpm build-other-workers && rsbuild build",
"build-analyze": "BUNDLE_ANALYZE=true rsbuild build && pnpm build-other-workers",
"build-single-file": "SINGLE_FILE_BUILD=true rsbuild build",
"prepare-project": "tsx scripts/genShims.ts && tsx scripts/makeOptimizedMcData.mjs && tsx scripts/genLargeDataAliases.ts",
"check-build": "pnpm prepare-project && tsc && pnpm build",
"start": "run-p dev-rsbuild dev-proxy",
"build": "node buildWorkers.mjs && rsbuild build",
"build-analyze": "BUNDLE_ANALYZE=true rsbuild build && node buildWorkers.mjs",
"check-build": "tsx scripts/genShims.ts && tsc && pnpm build",
"test:cypress": "cypress run",
"test:benchmark": "PERFORMANCE_TEST=true cypress run",
"test:cypress:open": "cypress open",
"test:cypress:perf": "cypress run --spec cypress/e2e/perf.spec.ts --browser edge",
"test-unit": "vitest",
"test:e2e": "start-test http-get://localhost:8080 test:cypress",
"prod-start": "node server.js --prod",
"postinstall": "tsx scripts/optimizeBlockCollisions.ts && pnpm build-mesher",
"test-mc-server": "tsx cypress/minecraft-server.mjs",
"lint": "eslint \"{src,cypress,renderer}/**/*.{ts,js,jsx,tsx}\"",
"lint-fix": "pnpm lint --fix",
"lint": "eslint \"{src,cypress}/**/*.{ts,js,jsx,tsx}\"",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build && node scripts/build.js moveStorybookFiles",
"start-experiments": "vite --config experiments/vite.config.ts --host",
"watch-other-workers": "echo NOT IMPLEMENTED",
"build-other-workers": "echo NOT IMPLEMENTED",
"build-mesher": "node renderer/buildMesherWorker.mjs",
"watch-other-workers": "node buildWorkers.mjs -w",
"build-mesher": "node prismarine-viewer/buildMesherWorker.mjs",
"watch-mesher": "pnpm build-mesher -w",
"run-playground": "run-p watch-mesher watch-other-workers watch-playground",
"run-playground": "run-p watch-mesher watch-other-workers playground-server watch-playground",
"run-all": "run-p start run-playground",
"build-playground": "rsbuild build --config renderer/rsbuild.config.ts",
"watch-playground": "rsbuild dev --config renderer/rsbuild.config.ts",
"update-git-deps": "tsx scripts/updateGitDeps.ts",
"request-data": "tsx scripts/requestData.ts"
"playground-server": "live-server --port=9090 prismarine-viewer/public",
"start-watch-script": "nodemon -w rsbuild.config.ts --watch",
"watch-playground": "node prismarine-viewer/esbuild.mjs -w"
},
"keywords": [
"prismarine",
"web",
"client"
],
"release": {
"attachReleaseFiles": "{self-host.zip,minecraft.html}"
},
"publish": {
"preset": {
"publishOnlyIfChanged": true,
@ -54,15 +44,15 @@
"dependencies": {
"@dimaka/interface": "0.0.3-alpha.0",
"@floating-ui/react": "^0.26.1",
"@monaco-editor/react": "^4.7.0",
"@nxg-org/mineflayer-auto-jump": "^0.7.18",
"@nxg-org/mineflayer-tracker": "1.3.0",
"@mui/base": "5.0.0-beta.40",
"@nxg-org/mineflayer-auto-jump": "^0.7.7",
"@nxg-org/mineflayer-tracker": "^1.2.1",
"@react-oauth/google": "^0.12.1",
"@stylistic/eslint-plugin": "^2.6.1",
"@types/gapi": "^0.0.47",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/wicg-file-system-access": "^2023.10.2",
"@webgpu/types": "^0.1.44",
"@xmcl/text-component": "^2.1.3",
"@zardoy/react-util": "^0.2.4",
"@zardoy/utils": "^0.0.11",
@ -73,21 +63,19 @@
"compression": "^1.7.4",
"cors": "^2.8.5",
"debug": "^4.3.4",
"deepslate": "^0.23.5",
"diff-match-patch": "^1.0.5",
"eruda": "^3.0.1",
"esbuild": "^0.19.3",
"esbuild-plugin-polyfill-node": "^0.3.0",
"express": "^4.18.2",
"filesize": "^10.0.12",
"flying-squid": "npm:@zardoy/flying-squid@^0.0.104",
"framer-motion": "^12.9.2",
"flying-squid": "npm:@zardoy/flying-squid@^0.0.34",
"fs-extra": "^11.1.1",
"google-drive-browserfs": "github:zardoy/browserfs#google-drive",
"jszip": "^3.10.1",
"lodash-es": "^4.17.21",
"mcraft-fun-mineflayer": "^0.1.23",
"minecraft-data": "3.98.0",
"math.gl": "^4.0.0",
"minecraft-data": "3.65.0",
"minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master",
"mineflayer-item-map-downloader": "github:zardoy/mineflayer-item-map-downloader",
"mojangson": "^2.0.4",
@ -105,8 +93,7 @@
"qrcode.react": "^3.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-select": "^5.8.0",
"react-zoom-pan-pinch": "3.4.4",
"react-transition-group": "^4.4.5",
"remark": "^15.0.1",
"sanitize-filename": "^1.6.3",
"skinview3d": "^3.0.1",
@ -115,6 +102,7 @@
"stats.js": "^0.17.0",
"tabbable": "^6.2.0",
"title-case": "3.x",
"twgl.js": "^5.5.4",
"ua-parser-js": "^1.0.37",
"use-typed-event-listener": "^4.0.2",
"valtio": "^1.11.1",
@ -123,11 +111,11 @@
"workbox-build": "^7.0.0"
},
"devDependencies": {
"@rsbuild/core": "1.3.5",
"@rsbuild/plugin-node-polyfill": "1.3.0",
"@rsbuild/plugin-react": "1.2.0",
"@rsbuild/plugin-type-check": "1.2.1",
"@rsbuild/plugin-typed-css-modules": "1.0.2",
"@rsbuild/core": "1.0.1-beta.4",
"@rsbuild/plugin-node-polyfill": "^1.0.3",
"@rsbuild/plugin-type-check": "1.0.1-beta.4",
"@rsbuild/plugin-typed-css-modules": "^1.0.1",
"@rsbuild/plugin-react": "^1.0.1-beta.4",
"@storybook/addon-essentials": "^7.4.6",
"@storybook/addon-links": "^7.4.6",
"@storybook/blocks": "^7.4.6",
@ -135,6 +123,7 @@
"@storybook/react-vite": "^7.4.6",
"@types/diff-match-patch": "^1.0.36",
"@types/lodash-es": "^4.17.9",
"@types/react-transition-group": "^4.4.7",
"@types/stats.js": "^0.17.1",
"@types/three": "0.154.0",
"@types/ua-parser-js": "^0.7.39",
@ -144,7 +133,7 @@
"browserify-zlib": "^0.2.0",
"buffer": "^6.0.3",
"constants-browserify": "^1.0.0",
"contro-max": "^0.1.9",
"contro-max": "^0.1.8",
"crypto-browserify": "^3.12.0",
"cypress-esbuild-preprocessor": "^1.0.2",
"eslint": "^8.50.0",
@ -154,16 +143,16 @@
"http-browserify": "^1.7.0",
"http-server": "^14.1.1",
"https-browserify": "^1.0.0",
"mc-assets": "^0.2.62",
"mc-assets": "^0.2.5",
"minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next",
"mineflayer": "github:zardoy/mineflayer#gen-the-master",
"mineflayer-mouse": "^0.1.21",
"mineflayer": "github:zardoy/mineflayer",
"mineflayer-pathfinder": "^2.4.4",
"npm-run-all": "^4.1.5",
"os-browserify": "^0.3.0",
"path-browserify": "^1.0.1",
"path-exists-cli": "^2.0.0",
"prismarine-viewer": "link:prismarine-viewer",
"process": "github:PrismarineJS/node-process",
"renderer": "link:renderer",
"rimraf": "^5.0.1",
"storybook": "^7.4.6",
"stream-browserify": "^3.0.0",
@ -176,67 +165,31 @@
"optionalDependencies": {
"cypress": "^10.11.0",
"cypress-plugin-snapshots": "^1.4.4",
"sharp": "^0.33.5",
"systeminformation": "^5.21.22"
},
"browserslist": {
"production": [
"iOS >= 14",
"Android >= 13",
"Chrome >= 103",
"not dead",
"not ie <= 11",
"not op_mini all",
"> 0.5%"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"pnpm": {
"overrides": {
"mineflayer": "github:zardoy/mineflayer#gen-the-master",
"@nxg-org/mineflayer-physics-util": "1.8.10",
"buffer": "^6.0.3",
"vec3": "0.1.10",
"@nxg-org/mineflayer-physics-util": "1.5.8",
"three": "0.154.0",
"diamond-square": "github:zardoy/diamond-square",
"prismarine-block": "github:zardoy/prismarine-block#next-era",
"prismarine-world": "github:zardoy/prismarine-world#next-era",
"minecraft-data": "3.98.0",
"minecraft-data": "3.65.0",
"prismarine-provider-anvil": "github:zardoy/prismarine-provider-anvil#everything",
"prismarine-physics": "github:zardoy/prismarine-physics",
"minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master",
"react": "^18.2.0",
"prismarine-chunk": "github:zardoy/prismarine-chunk#master",
"prismarine-item": "latest"
"prismarine-chunk": "github:zardoy/prismarine-chunk"
},
"updateConfig": {
"ignoreDependencies": [
"browserfs",
"google-drive-browserfs"
]
"ignoreDependencies": []
},
"patchedDependencies": {
"minecraft-protocol@1.47.0": "patches/minecraft-protocol@1.47.0.patch",
"three@0.154.0": "patches/three@0.154.0.patch",
"pixelarticons@1.8.1": "patches/pixelarticons@1.8.1.patch",
"mineflayer-item-map-downloader@1.2.0": "patches/mineflayer-item-map-downloader@1.2.0.patch",
"minecraft-protocol": "patches/minecraft-protocol.patch"
},
"ignoredBuiltDependencies": [
"canvas",
"core-js",
"gl"
],
"onlyBuiltDependencies": [
"sharp",
"cypress",
"esbuild",
"fsevents"
],
"ignorePatchFailures": false,
"allowUnusedPatches": false
"mineflayer-item-map-downloader@1.2.0": "patches/mineflayer-item-map-downloader@1.2.0.patch"
}
},
"packageManager": "pnpm@10.8.0+sha512.0e82714d1b5b43c74610193cb20734897c1d00de89d0e18420aebc5977fa13d780a9cb05734624e81ebd81cc876cd464794850641c48b9544326b5622ca29971"
"packageManager": "pnpm@9.0.4"
}

View file

@ -1,138 +0,0 @@
diff --git a/src/client/chat.js b/src/client/chat.js
index 0021870994fc59a82f0ac8aba0a65a8be43ef2f4..a53fceb843105ea2a1d88722b3fc7c3b43cb102a 100644
--- a/src/client/chat.js
+++ b/src/client/chat.js
@@ -116,7 +116,7 @@ module.exports = function (client, options) {
for (const player of packet.data) {
if (player.chatSession) {
client._players[player.uuid] = {
- publicKey: crypto.createPublicKey({ key: player.chatSession.publicKey.keyBytes, format: 'der', type: 'spki' }),
+ // publicKey: crypto.createPublicKey({ key: player.chatSession.publicKey.keyBytes, format: 'der', type: 'spki' }),
publicKeyDER: player.chatSession.publicKey.keyBytes,
sessionUuid: player.chatSession.uuid
}
@@ -126,7 +126,7 @@ module.exports = function (client, options) {
if (player.crypto) {
client._players[player.uuid] = {
- publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }),
+ // publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }),
publicKeyDER: player.crypto.publicKey,
signature: player.crypto.signature,
displayName: player.displayName || player.name
@@ -196,7 +196,7 @@ module.exports = function (client, options) {
if (mcData.supportFeature('useChatSessions')) {
const tsDelta = BigInt(Date.now()) - packet.timestamp
const expired = !packet.timestamp || tsDelta > messageExpireTime || tsDelta < 0
- const verified = !packet.unsignedChatContent && updateAndValidateSession(packet.senderUuid, packet.plainMessage, packet.signature, packet.index, packet.previousMessages, packet.salt, packet.timestamp) && !expired
+ const verified = false && !packet.unsignedChatContent && updateAndValidateSession(packet.senderUuid, packet.plainMessage, packet.signature, packet.index, packet.previousMessages, packet.salt, packet.timestamp) && !expired
if (verified) client._signatureCache.push(packet.signature)
client.emit('playerChat', {
globalIndex: packet.globalIndex,
@@ -362,7 +362,7 @@ module.exports = function (client, options) {
}
}
- client._signedChat = (message, options = {}) => {
+ client._signedChat = async (message, options = {}) => {
options.timestamp = options.timestamp || BigInt(Date.now())
options.salt = options.salt || 1n
@@ -407,7 +407,7 @@ module.exports = function (client, options) {
message,
timestamp: options.timestamp,
salt: options.salt,
- signature: (client.profileKeys && client._session) ? client.signMessage(message, options.timestamp, options.salt, undefined, acknowledgements) : undefined,
+ signature: (client.profileKeys && client._session) ? await client.signMessage(message, options.timestamp, options.salt, undefined, acknowledgements) : undefined,
offset: client._lastSeenMessages.pending,
checksum: computeChatChecksum(client._lastSeenMessages), // 1.21.5+
acknowledged
@@ -422,7 +422,7 @@ module.exports = function (client, options) {
message,
timestamp: options.timestamp,
salt: options.salt,
- signature: client.profileKeys ? client.signMessage(message, options.timestamp, options.salt, options.preview) : Buffer.alloc(0),
+ signature: client.profileKeys ? await client.signMessage(message, options.timestamp, options.salt, options.preview) : Buffer.alloc(0),
signedPreview: options.didPreview,
previousMessages: client._lastSeenMessages.map((e) => ({
messageSender: e.sender,
diff --git a/src/client/encrypt.js b/src/client/encrypt.js
index 63cc2bd9615100bd2fd63dfe14c094aa6b8cd1c9..36df57d1196af9761d920fa285ac48f85410eaef 100644
--- a/src/client/encrypt.js
+++ b/src/client/encrypt.js
@@ -25,7 +25,11 @@ module.exports = function (client, options) {
if (packet.serverId !== '-') {
debug('This server appears to be an online server and you are providing no password, the authentication will probably fail')
}
- sendEncryptionKeyResponse()
+ client.end('This server appears to be an online server and you are providing no authentication. Try authenticating first.')
+ // sendEncryptionKeyResponse()
+ // client.once('set_compression', () => {
+ // clearTimeout(loginTimeout)
+ // })
}
function onJoinServerResponse (err) {
diff --git a/src/client/pluginChannels.js b/src/client/pluginChannels.js
index 671eb452f31e6b5fcd57d715f1009d010160c65f..7f69f511c8fb97d431ec5125c851b49be8e2ab76 100644
--- a/src/client/pluginChannels.js
+++ b/src/client/pluginChannels.js
@@ -57,7 +57,7 @@ module.exports = function (client, options) {
try {
packet.data = proto.parsePacketBuffer(channel, packet.data).data
} catch (error) {
- client.emit('error', error)
+ client.emit('error', error, { customPayload: packet })
return
}
}
diff --git a/src/client.js b/src/client.js
index e369e77d055ba919e8f9da7b8e8b5dc879c74cf4..54bb9e6644388e9b6bd42b3012951875989cdf0c 100644
--- a/src/client.js
+++ b/src/client.js
@@ -111,7 +111,13 @@ class Client extends EventEmitter {
this._hasBundlePacket = false
}
} else {
- emitPacket(parsed)
+ try {
+ emitPacket(parsed)
+ } catch (err) {
+ console.log('Client incorrectly handled packet ' + parsed.metadata.name)
+ console.error(err)
+ // todo investigate why it doesn't close the stream even if unhandled there
+ }
}
})
}
@@ -169,7 +175,10 @@ class Client extends EventEmitter {
}
const onFatalError = (err) => {
- this.emit('error', err)
+ // todo find out what is trying to write after client disconnect
+ if(err.code !== 'ECONNABORTED') {
+ this.emit('error', err)
+ }
endSocket()
}
@@ -198,6 +207,10 @@ class Client extends EventEmitter {
serializer -> framer -> socket -> splitter -> deserializer */
if (this.serializer) {
this.serializer.end()
+ setTimeout(() => {
+ this.socket?.end()
+ this.socket?.emit('end')
+ }, 2000) // allow the serializer to finish writing
} else {
if (this.socket) this.socket.end()
}
@@ -243,6 +256,7 @@ class Client extends EventEmitter {
debug('writing packet ' + this.state + '.' + name)
debug(params)
}
+ this.emit('writePacket', name, params)
this.serializer.write({ name, params })
}

View file

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

View file

@ -1,5 +1,5 @@
diff --git a/fonts/pixelart-icons-font.css b/fonts/pixelart-icons-font.css
index 3b2ebe839370d96bf93ef5ca94a827f07e49378d..4f8d76be2ca6e4ddc43c68d0a6f0f69979165ab4 100644
index 3b2ebe839370d96bf93ef5ca94a827f07e49378d..103ab4d6b9f3b5c9f41d1407e3cbf4ac392fbf41 100644
--- a/fonts/pixelart-icons-font.css
+++ b/fonts/pixelart-icons-font.css
@@ -1,16 +1,13 @@
@ -10,11 +10,10 @@ index 3b2ebe839370d96bf93ef5ca94a827f07e49378d..4f8d76be2ca6e4ddc43c68d0a6f0f699
+ src:
url("pixelart-icons-font.woff2?t=1711815892278") format("woff2"),
url("pixelart-icons-font.woff?t=1711815892278") format("woff"),
- url('pixelart-icons-font.ttf?t=1711815892278') format('truetype'), /* chrome, firefox, opera, Safari, Android, iOS 4.2+*/
url('pixelart-icons-font.ttf?t=1711815892278') format('truetype'), /* chrome, firefox, opera, Safari, Android, iOS 4.2+*/
- url('pixelart-icons-font.svg?t=1711815892278#pixelart-icons-font') format('svg'); /* iOS 4.1- */
+ url('pixelart-icons-font.ttf?t=1711815892278') format('truetype'); /* chrome, firefox, opera, Safari, Android, iOS 4.2+*/
}
[class^="pixelart-icons-font-"], [class*=" pixelart-icons-font-"] {
font-family: 'pixelart-icons-font' !important;
- font-size:24px;

View file

@ -0,0 +1,16 @@
diff --git a/examples/jsm/webxr/VRButton.js b/examples/jsm/webxr/VRButton.js
index 6856a21b17aa45d7922bbf776fd2d7e63c7a9b4e..0925b706f7629bd52f0bb5af469536af8f5fce2c 100644
--- a/examples/jsm/webxr/VRButton.js
+++ b/examples/jsm/webxr/VRButton.js
@@ -62,7 +62,10 @@ class VRButton {
// ('local' is always available for immersive sessions and doesn't need to
// be requested separately.)
- const sessionInit = { optionalFeatures: [ 'local-floor', 'bounded-floor', 'hand-tracking', 'layers' ] };
+ const sessionInit = {
+ optionalFeatures: ['local-floor', 'bounded-floor', 'layers'],
+ domOverlay: { root: document.body },
+ };
navigator.xr.requestSession( 'immersive-vr', sessionInit ).then( onSessionStarted );
} else {

13569
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -0,0 +1,98 @@
//@ts-check
import * as fs from 'fs'
import fsExtra from 'fs-extra'
import * as esbuild from 'esbuild'
import { polyfillNode } from 'esbuild-plugin-polyfill-node'
import path, { dirname, join } from 'path'
import { fileURLToPath } from 'url'
const dev = process.argv.includes('-w')
const __dirname = path.dirname(fileURLToPath(new URL(import.meta.url)))
const mcDataPath = join(__dirname, '../dist/mc-data')
if (!fs.existsSync(mcDataPath)) {
// shouldn't it be in the viewer instead?
await import('../scripts/prepareData.mjs')
}
fs.copyFileSync(join(__dirname, 'playground.html'), join(__dirname, 'public/index.html'))
fsExtra.copySync(mcDataPath, join(__dirname, 'public/mc-data'))
const availableVersions = fs.readdirSync(mcDataPath).map(ver => ver.replace('.js', ''))
/** @type {import('esbuild').BuildOptions} */
const buildOptions = {
bundle: true,
entryPoints: [join(__dirname, './examples/playground.ts')],
// target: ['es2020'],
// logLevel: 'debug',
logLevel: 'info',
platform: 'browser',
sourcemap: dev ? 'inline' : false,
minify: !dev,
outfile: join(__dirname, 'public/playground.js'),
mainFields: [
'browser', 'module', 'main'
],
keepNames: true,
banner: {
js: `globalThis.global = globalThis;globalThis.includedVersions = ${JSON.stringify(availableVersions)};`,
},
alias: {
events: 'events',
buffer: 'buffer',
'fs': 'browserfs/dist/shims/fs.js',
http: 'http-browserify',
stream: 'stream-browserify',
net: 'net-browserify',
'stats.js': 'stats.js/src/Stats.js',
},
inject: [],
metafile: true,
loader: {
'.png': 'dataurl',
'.vert': 'text',
'.frag': 'text',
'.obj': 'text',
},
plugins: [
{
name: 'minecraft-data',
setup (build) {
build.onLoad({
filter: /minecraft-data[\/\\]data.js$/,
}, () => {
const defaultVersionsObj = {}
return {
contents: `window.mcData ??= ${JSON.stringify(defaultVersionsObj)};module.exports = { pc: window.mcData }`,
loader: 'js',
}
})
build.onEnd((e) => {
if (e.errors.length) return
// fs.writeFileSync(join(__dirname, 'dist/metafile.json'), JSON.stringify(e.metafile), 'utf8')
})
}
},
polyfillNode({
polyfills: {
fs: false,
crypto: false,
events: false,
http: false,
stream: false,
buffer: false,
perf_hooks: false,
net: false,
},
})
],
}
if (dev) {
(await esbuild.context(buildOptions)).watch()
} else {
await esbuild.build(buildOptions)
}
// await ctx.rebuild()

View file

@ -0,0 +1,45 @@
struct Cube {
position: vec3f,
textureIndex: f32,
colorBlend: vec3f,
//tt: f32
}
struct Uniforms {
ViewProjectionMatrix: mat4x4<f32>,
}
struct IndirectDrawParams {
vertexCount: u32,
instanceCount: atomic<u32>,
firstVertex: u32,
firstInstance: u32,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var<storage, read> cubes: array<Cube>;
@group(0) @binding(2) var<storage, read_write> visibleCubes: array<Cube>; // Changed to @binding(4)
@group(0) @binding(3) var<storage, read_write> drawParams: IndirectDrawParams;
@compute @workgroup_size(256)
fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
let index = global_id.x;
if (index >= arrayLength(&cubes)) {
return;
}
let cube = cubes[index];
// Transform cube position to clip space
let clipPos = uniforms.ViewProjectionMatrix * (vec4<f32>(cube.position, 1.0) + vec4<f32>(0.5, 0.0, 0.5, 0.0));
let clipDepth = clipPos.z / clipPos.w; // Obtain depth in clip space
let clipX = clipPos.x / clipPos.w;
let clipY = clipPos.y / clipPos.w;
// Check if cube is within the view frustum z-range (depth within near and far planes)
let Oversize = 1.2;
if (clipDepth >= 0.0 && clipDepth <= 1.0 && clipX > -1.0 * Oversize && clipX < 1.0 * Oversize && clipY > -1.0 * Oversize && clipY < 1.0 * Oversize) { //Small Oversize because binding size
let visibleIndex = atomicAdd(&drawParams.instanceCount, 1);
visibleCubes[visibleIndex] = cube;
}
}

View file

@ -0,0 +1,14 @@
@group(0) @binding(1) var mySampler: sampler;
@group(0) @binding(2) var myTexture: texture_2d<f32>;
@fragment
fn main(
@location(0) fragUV: vec2f,
@location(1) @interpolate(flat) TextureIndex: f32,
@location(2) @interpolate(flat) ColorBlend: vec3f
) -> @location(0) vec4f {
let textureSize: vec2<f32> = vec2<f32>(textureDimensions(myTexture));
let tileSize: vec2<f32> = vec2<f32>(16.0f,16.0f);
let tilesPerTexture: vec2<f32> = vec2<f32>(textureSize)/tileSize;
return textureSample(myTexture, mySampler, fragUV/tilesPerTexture + vec2f(trunc(TextureIndex%tilesPerTexture.y),trunc(TextureIndex/tilesPerTexture.x) )/tilesPerTexture) * vec4f(ColorBlend,1.0);
}

View file

@ -0,0 +1,36 @@
struct Uniforms {
ViewProjectionMatrix: mat4x4<f32>,
}
struct Cube {
position: vec3f,
textureIndex: f32,
colorBlend: vec3f,
//tt: f32
}
struct VertexOutput {
@builtin(position) Position: vec4f,
@location(0) fragUV: vec2f,
@location(1) @interpolate(flat) TextureIndex: f32,
@location(2) @interpolate(flat) ColorBlend: vec3f
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(3) var<storage, read> visibleCubes: array<Cube>;
@vertex
fn main(
@builtin(instance_index) instanceIndex: u32,
@location(0) position: vec4<f32>,
@location(1) uv: vec2<f32>
) -> VertexOutput {
let cube = visibleCubes[instanceIndex];
//cube.position.x = instance_index * 2;
var output: VertexOutput;
output.Position = uniforms.ViewProjectionMatrix * (position + vec4<f32>(cube.position, 0.0) + vec4<f32>(0.5, 0.0, 0.5, 0.0));
output.fragUV = uv;
output.TextureIndex = cube.textureIndex;
output.ColorBlend = cube.colorBlend;
return output;
}

View file

@ -0,0 +1,95 @@
export const cubeVertexSize = 4 * 5 // Byte size of one cube vertex.
export const cubePositionOffset = 0
//export const cubeColorOffset = 4 * 3 // Byte offset of cube vertex color attribute.
export const cubeUVOffset = 4 * 3
export const cubeVertexCount = 36
//@ts-format-ignore-region
export const cubeVertexArray = new Float32Array([
-0.5, -0.5, -0.5, 0.0, 0.0, // Bottom-let
0.5, -0.5, -0.5, 1.0, 0.0, // bottom-right
0.5, 0.5, -0.5, 1.0, 1.0, // top-right
0.5, 0.5, -0.5, 1.0, 1.0, // top-right
-0.5, 0.5, -0.5, 0.0, 1.0, // top-let
-0.5, -0.5, -0.5, 0.0, 0.0, // bottom-let
// ront ace
-0.5, -0.5, 0.5, 0.0, 0.0, // bottom-let
0.5, 0.5, 0.5, 1.0, 1.0, // top-right
0.5, -0.5, 0.5, 1.0, 0.0, // bottom-right
0.5, 0.5, 0.5, 1.0, 1.0, // top-right
-0.5, -0.5, 0.5, 0.0, 0.0, // bottom-let
-0.5, 0.5, 0.5, 0.0, 1.0, // top-let
// Let ace
-0.5, 0.5, 0.5, 1.0, 0.0, // top-right
-0.5, -0.5, -0.5, 0.0, 1.0, // bottom-let
-0.5, 0.5, -0.5, 1.0, 1.0, // top-let
-0.5, -0.5, -0.5, 0.0, 1.0, // bottom-let
-0.5, 0.5, 0.5, 1.0, 0.0, // top-right
-0.5, -0.5, 0.5, 0.0, 0.0, // bottom-right
// Right ace
0.5, 0.5, 0.5, 1.0, 0.0, // top-let
0.5, 0.5, -0.5, 1.0, 1.0, // top-right
0.5, -0.5, -0.5, 0.0, 1.0, // bottom-right
0.5, -0.5, -0.5, 0.0, 1.0, // bottom-right
0.5, -0.5, 0.5, 0.0, 0.0, // bottom-let
0.5, 0.5, 0.5, 1.0, 0.0, // top-let
// Bottom ace
-0.5, -0.5, -0.5, 0.0, 1.0, // top-right
0.5, -0.5, 0.5, 1.0, 0.0, // bottom-let
0.5, -0.5, -0.5, 1.0, 1.0, // top-let
0.5, -0.5, 0.5, 1.0, 0.0, // bottom-let
-0.5, -0.5, -0.5, 0.0, 1.0, // top-right
-0.5, -0.5, 0.5, 0.0, 0.0, // bottom-right
// Top ace
-0.5, 0.5, -0.5, 0.0, 1.0, // top-let
0.5, 0.5, -0.5, 1.0, 1.0, // top-right
0.5, 0.5, 0.5, 1.0, 0.0, // bottom-right
0.5, 0.5, 0.5, 1.0, 0.0, // bottom-right
-0.5, 0.5, 0.5, 0.0, 0.0, // bottom-let
-0.5, 0.5, -0.5, 0.0, 1.0// top-let˚
]);
export const cubeVertexArraySub = new Float32Array([
-0.5, -0.5, -0.5, 0.0, 0.0, // Bottom-let
0.5, -0.5, -0.5, 1.0, 0.0, // bottom-right
0.5, 0.5, -0.5, 1.0, 1.0, // top-right
0.5, 0.5, -0.5, 1.0, 1.0, // top-right
-0.5, 0.5, -0.5, 0.0, 1.0, // top-let
-0.5, -0.5, -0.5, 0.0, 0.0, // bottom-let
// ront ace
-0.5, -0.5, 0.5, 0.0, 0.0, // bottom-let
0.5, 0.5, 0.5, 1.0, 1.0, // top-right
0.5, -0.5, 0.5, 1.0, 0.0, // bottom-right
0.5, 0.5, 0.5, 1.0, 1.0, // top-right
-0.5, -0.5, 0.5, 0.0, 0.0, // bottom-let
-0.5, 0.5, 0.5, 0.0, 1.0, // top-let
// Let ace
-0.5, 0.5, 0.5, 1.0, 0.0, // top-right
-0.5, -0.5, -0.5, 0.0, 1.0, // bottom-let
-0.5, 0.5, -0.5, 1.0, 1.0, // top-let
-0.5, -0.5, -0.5, 0.0, 1.0, // bottom-let
-0.5, 0.5, 0.5, 1.0, 0.0, // top-right
-0.5, -0.5, 0.5, 0.0, 0.0, // bottom-right
// Right ace
0.5, 0.5, 0.5, 1.0, 0.0, // top-let
0.5, 0.5, -0.5, 1.0, 1.0, // top-right
0.5, -0.5, -0.5, 0.0, 1.0, // bottom-right
0.5, -0.5, -0.5, 0.0, 1.0, // bottom-right
0.5, -0.5, 0.5, 0.0, 0.0, // bottom-let
0.5, 0.5, 0.5, 1.0, 0.0, // top-let
// Bottom ace
-0.5, -0.5, -0.5, 0.0, 1.0, // top-right
0.5, -0.5, 0.5, 1.0, 0.0, // bottom-let
0.5, -0.5, -0.5, 1.0, 1.0, // top-let
0.5, -0.5, 0.5, 1.0, 0.0, // bottom-let
-0.5, -0.5, -0.5, 0.0, 1.0, // top-right
-0.5, -0.5, 0.5, 0.0, 0.0, // bottom-right
// Top ace
-0.5, 0.5, -0.5, 0.0, 1.0, // top-let
0.5, 0.5, -0.5, 1.0, 1.0, // top-right
0.5, 0.5, 0.5, 1.0, 0.0, // bottom-right
0.5, 0.5, 0.5, 1.0, 0.0, // bottom-right
-0.5, 0.5, 0.5, 0.0, 0.0, // bottom-let
-0.5, 0.5, -0.5, 0.0, 1.0, 5.0// top-let˚
]);

View file

@ -0,0 +1,69 @@
export type AnimationControlSwitches = {
tick: number
interpolationTick: number // next one
}
type Data = {
interpolate: boolean;
frametime: number;
frames: ({
index: number;
time: number;
} | number)[] | undefined;
};
export class TextureAnimation {
data: Data;
frameImages: number;
frameDelta: number;
frameTime: number;
framesToSwitch: number;
frameIndex: number;
constructor(public animationControl: AnimationControlSwitches, data: Data, public framesImages: number) {
this.data = {
interpolate: false,
frametime: 1,
...data
};
this.frameImages = 1;
this.frameDelta = 0;
this.frameTime = this.data.frametime * 50;
this.frameIndex = 0;
this.framesToSwitch = this.frameImages;
if (this.data.frames) {
this.framesToSwitch = this.data.frames.length;
}
}
step (deltaMs: number) {
this.frameDelta += deltaMs;
if (this.frameDelta > this.frameTime) {
this.frameDelta -= this.frameTime;
this.frameDelta %= this.frameTime;
this.frameIndex++;
this.frameIndex %= this.framesToSwitch;
const frames = this.data.frames.map(frame => typeof frame === 'number' ? { index: frame, time: this.data.frametime } : frame);
if (frames) {
let frame = frames[this.frameIndex]
let nextFrame = frames[(this.frameIndex + 1) % this.framesToSwitch];
this.animationControl.tick = frame.index;
this.animationControl.interpolationTick = nextFrame.index;
this.frameTime = frame.time * 50;
} else {
this.animationControl.tick = this.frameIndex;
this.animationControl.interpolationTick = (this.frameIndex + 1) % this.framesToSwitch;
}
}
if (this.data.interpolate) {
this.animationControl.interpolationTick = this.frameDelta / this.frameTime;
}
}
}

View file

@ -0,0 +1,63 @@
import React, { useEffect } from 'react'
import { LeftTouchArea, RightTouchArea, useInterfaceState } from '@dimaka/interface'
import { css } from '@emotion/css'
import { Viewer } from '../viewer/lib/viewer'
import { renderToDom } from '@zardoy/react-util'
import { Vec3 } from 'vec3'
import * as THREE from 'three'
declare const viewer: Viewer
const Controls = () => {
// todo setting
const usingTouch = navigator.maxTouchPoints > 0
useEffect(() => {
let vec3 = new Vec3(0, 0, 0)
setInterval(() => {
viewer.camera.position.add(new THREE.Vector3(vec3.x, vec3.y, vec3.z))
}, 1000 / 30)
useInterfaceState.setState({
isFlying: false,
uiCustomization: {
touchButtonSize: 40,
},
updateCoord ([coord, state]) {
vec3 = new Vec3(0, 0, 0)
vec3[coord] = state
}
})
}, [])
if (!usingTouch) return null
return (
<div
style={{ zIndex: 8 }}
className={css`
position: fixed;
inset: 0;
height: 100%;
display: flex;
width: 100%;
justify-content: space-between;
align-items: flex-end;
pointer-events: none;
touch-action: none;
& > div {
pointer-events: auto;
}
`}
>
<LeftTouchArea />
<div />
<RightTouchArea />
</div>
)
}
export const renderPlayground = () => {
renderToDom(<Controls />, {
// selector: 'body',
})
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,35 @@
let rightOffset = 0
const stats = {}
export const addNewStat = (id: string, width = 80, x = rightOffset, y = 0) => {
const pane = document.createElement('div')
pane.id = 'fps-counter'
pane.style.position = 'fixed'
pane.style.top = `${y}px`
pane.style.right = `${x}px`
// gray bg
pane.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
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
if (y === 0) { // otherwise it's a custom position
rightOffset += width
}
return {
updateText: (text: string) => {
pane.innerText = text
}
}
}
export const updateStatText = (id, text) => {
if (!stats[id]) return
stats[id].innerText = text
}

View file

@ -0,0 +1,555 @@
import _ from 'lodash'
import { WorldDataEmitter, Viewer } from '../viewer'
import { Vec3 } from 'vec3'
import BlockLoader from 'prismarine-block'
import ChunkLoader from 'prismarine-chunk'
import WorldLoader from 'prismarine-world'
import * as THREE from 'three'
import { GUI } from 'lil-gui'
import { loadScript } from '../viewer/lib/utils'
import JSZip from 'jszip'
import { TWEEN_DURATION } from '../viewer/lib/entities'
import { EntityMesh } from '../viewer/lib/entity/EntityMesh'
import blockstatesModels from 'mc-assets/dist/blockStatesModels.json'
// import * as Mathgl from 'math.gl'
import { initWebgpuRenderer, loadFixtureSides, setAnimationTick, webgpuChannel } from './webgpuRendererMain'
import { renderToDom } from '@zardoy/react-util'
globalThis.THREE = THREE
//@ts-ignore
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import { toMajorVersion } from '../../src/utils'
import { renderPlayground } from './TouchControls2'
import { WorldRendererWebgpu } from '../viewer/lib/worldrendererWebgpu'
import { TextureAnimation } from './TextureAnimation'
import { BlockType } from './shared'
import { addNewStat } from './newStats'
const gui = new GUI()
const { updateText: updateTextEvent } = addNewStat('events', 90, 0, 40)
// initial values
const params = {
skipQs: '',
version: globalThis.includedVersions.sort((a, b) => {
const s = (x) => {
const parts = x.split('.')
return +parts[0] + (+parts[1])
}
return s(a) - s(b)
}).at(-1),
block: '',
metadata: 0,
supportBlock: false,
entity: '',
removeEntity () {
this.entity = ''
},
entityRotate: false,
camera: '',
playSound () { },
blockIsomorphicRenderBundle () { },
modelVariant: 0,
animationTick: 0
}
const qs = new URLSearchParams(window.location.search)
qs.forEach((value, key) => {
const parsed = value.match(/^-?\d+$/) ? parseInt(value) : value === 'true' ? true : value === 'false' ? false : value
params[key] = parsed
})
const setQs = () => {
const newQs = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (!value || typeof value === 'function' || params.skipQs.includes(key)) continue
//@ts-ignore
newQs.set(key, value)
}
window.history.replaceState({}, '', `${window.location.pathname}?${newQs}`)
}
let ignoreResize = false
const enableControls = new URLSearchParams(window.location.search).get('controls') === 'true'
async function main () {
let continuousRender = false
// const { version } = params
let fixtureUrl = qs.get('fixture')
let fixture: undefined | Record<string, any>
if (fixtureUrl) {
console.log('Loading fixture')
fixture = await fetch(fixtureUrl).then(r => r.json())
console.log('Loaded fixture')
}
const version = fixture?.version ?? '1.20.2'
// temporary solution until web worker is here, cache data for faster reloads
const globalMcData = window['mcData']
if (!globalMcData['version']) {
const major = toMajorVersion(version)
const sessionKey = `mcData-${major}`
if (sessionStorage[sessionKey]) {
Object.assign(globalMcData, JSON.parse(sessionStorage[sessionKey]))
} else {
if (sessionStorage.length > 1) sessionStorage.clear()
await loadScript(`./mc-data/${major}.js`)
try {
sessionStorage[sessionKey] = JSON.stringify(Object.fromEntries(Object.entries(globalMcData).filter(([ver]) => ver.startsWith(major))))
} catch { }
}
}
const mcData = require('minecraft-data')(version)
window['loadedData'] = mcData
gui.add(params, 'version', globalThis.includedVersions)
gui.add(params, 'block', mcData.blocksArray.map(b => b.name).sort((a, b) => a.localeCompare(b)))
const metadataGui = gui.add(params, 'metadata')
gui.add(params, 'modelVariant')
gui.add(params, 'supportBlock')
gui.add(params, 'entity', mcData.entitiesArray.map(b => b.name).sort((a, b) => a.localeCompare(b))).listen()
gui.add(params, 'removeEntity')
gui.add(params, 'entityRotate')
gui.add(params, 'skipQs')
gui.add(params, 'playSound')
gui.add(params, 'blockIsomorphicRenderBundle')
const animationController = gui.add(params, 'animationTick', -1, 20, 1).listen()
gui.open(false)
let metadataFolder = gui.addFolder('metadata')
// let entityRotationFolder = gui.addFolder('entity metadata')
const Chunk = ChunkLoader(version)
const Block = BlockLoader(version)
// const data = await fetch('smallhouse1.schem').then(r => r.arrayBuffer())
// const schem = await Schematic.read(Buffer.from(data), version)
const viewDistance = 0
const targetPos = new Vec3(2, 90, 2)
const World = WorldLoader(version)
// const diamondSquare = require('diamond-square')({ version, seed: Math.floor(Math.random() * Math.pow(2, 31)) })
//@ts-ignore
const chunk1 = new Chunk()
//@ts-ignore
const chunk2 = new Chunk()
chunk1.setBlockStateId(targetPos, 34)
chunk2.setBlockStateId(targetPos.offset(1, 0, 0), 34)
//@ts-ignore
const world = new World((chunkX, chunkZ) => {
// if (chunkX === 0 && chunkZ === 0) return chunk1
// if (chunkX === 1 && chunkZ === 0) return chunk2
//@ts-ignore
const chunk = new Chunk()
return chunk
})
let stopUpdate = false
// let stopUpdate = true
// await schem.paste(world, new Vec3(0, 60, 0))
const worldView = new WorldDataEmitter(world, viewDistance, targetPos)
const nullRenderer = new THREE.WebGLRenderer({ antialias: true })
const viewer = new Viewer(nullRenderer, { numWorkers: 1, showChunkBorders: false })
viewer.world.blockstatesModels = blockstatesModels
viewer.entities.setDebugMode('basic')
viewer.world.stopBlockUpdate = stopUpdate
viewer.setVersion(version)
globalThis.viewer = viewer
await initWebgpuRenderer(() => { }, !enableControls && !fixture, true)
const simpleControls = () => {
let pressedKeys = new Set<string>()
const loop = () => {
// Create a vector that points in the direction the camera is looking
let direction = new THREE.Vector3(0, 0, 0)
if (pressedKeys.has('KeyW')) {
direction.z = -0.5
}
if (pressedKeys.has('KeyS')) {
direction.z += 0.5
}
if (pressedKeys.has('KeyA')) {
direction.x -= 0.5
}
if (pressedKeys.has('KeyD')) {
direction.x += 0.5
}
if (pressedKeys.has('ShiftLeft')) {
viewer.camera.position.y -= 0.5
}
if (pressedKeys.has('Space')) {
viewer.camera.position.y += 0.5
}
direction.applyQuaternion(viewer.camera.quaternion)
direction.y = 0
if (pressedKeys.has('ShiftLeft')) {
direction.y *= 2
direction.x *= 2
direction.z *= 2
}
// Add the vector to the camera's position to move the camera
viewer.camera.position.add(direction)
}
setInterval(loop, 1000 / 30)
const keys = (e) => {
const code = e.code
const pressed = e.type === 'keydown'
if (pressed) {
pressedKeys.add(code)
} else {
pressedKeys.delete(code)
}
}
window.addEventListener('keydown', keys)
window.addEventListener('keyup', keys)
window.addEventListener('blur', (e) => {
for (const key of pressedKeys) {
keys(new KeyboardEvent('keyup', { code: key }))
}
})
// mouse
const mouse = { x: 0, y: 0 }
let mouseMoveCounter = 0
const mouseMove = (e: PointerEvent) => {
if ((e.target as HTMLElement).closest('.lil-gui')) return
if (e.buttons === 1 || e.pointerType === 'touch') {
mouseMoveCounter++
viewer.camera.rotation.x -= e.movementY / 100
//viewer.camera.
viewer.camera.rotation.y -= e.movementX / 100
if (viewer.camera.rotation.x < -Math.PI / 2) viewer.camera.rotation.x = -Math.PI / 2
if (viewer.camera.rotation.x > Math.PI / 2) viewer.camera.rotation.x = Math.PI / 2
// yaw += e.movementY / 20;
// pitch += e.movementX / 20;
}
if (e.buttons === 2) {
viewer.camera.position.set(0, 0, 0)
}
}
setInterval(() => {
updateTextEvent(`Mouse Events: ${mouseMoveCounter}`)
mouseMoveCounter = 0
}, 1000)
window.addEventListener('pointermove', mouseMove)
}
viewer.camera.position.set(0, 0, 8)
simpleControls()
renderPlayground()
const writeToIndexedDb = async (name, data) => {
const db = await window.indexedDB.open(name, 1)
db.onupgradeneeded = (e) => {
const db = (e.target as any).result
db.createObjectStore(name)
}
db.onsuccess = (e) => {
const db = (e.target as any).result
const tx = db.transaction(name, 'readwrite')
const store = tx.objectStore(name)
store.add(data, name)
}
}
if (fixture) {
loadFixtureSides(fixture.sides)
const pos = fixture.camera[0]
viewer.camera.position.set(pos[0], pos[1], pos[2])
}
let blocks: Record<string, BlockType> = {}
let i = 0
console.log('generating random data')
webgpuChannel.generateRandom(490_000)
return
// Create viewer
viewer.listen(worldView)
// Load chunks
await worldView.init(targetPos)
window['worldView'] = worldView
window['viewer'] = viewer
//@ts-ignore
// const controls = new OrbitControls(viewer.camera, nullRenderer.domElement)
// controls.target.set(targetPos.x + 0.5, targetPos.y + 0.5, targetPos.z + 0.5)
const cameraPos = targetPos.offset(2, 2, 2)
const pitch = THREE.MathUtils.degToRad(-45)
const yaw = THREE.MathUtils.degToRad(45)
viewer.camera.rotation.set(pitch, yaw, 0, 'ZYX')
// viewer.camera.lookAt(targetPos.x + 0.5, targetPos.y + 0.5, targetPos.z + 0.5)
viewer.camera.position.set(cameraPos.x, cameraPos.y, cameraPos.z)
// controls.update()
let blockProps = {}
let entityOverrides = {}
const getBlock = () => {
return mcData.blocksByName[params.block || 'air']
}
const entityUpdateShared = () => {
viewer.entities.clear()
if (!params.entity) return
worldView.emit('entity', {
id: 'id', name: params.entity, pos: targetPos.offset(0.5, 1, 0.5), width: 1, height: 1, username: localStorage.testUsername, yaw: Math.PI, pitch: 0
})
const enableSkeletonDebug = (obj) => {
const { children, isSkeletonHelper } = obj
if (!Array.isArray(children)) return
if (isSkeletonHelper) {
obj.visible = true
return
}
for (const child of children) {
if (typeof child === 'object') enableSkeletonDebug(child)
}
}
enableSkeletonDebug(viewer.entities.entities['id'])
setTimeout(() => {
viewer.render()
}, TWEEN_DURATION)
}
params.block ||= 'stone'
let textureAnimation: TextureAnimation | undefined
const onUpdate = {
version (initialUpdate) {
if (initialUpdate) return
// viewer.world.texturesVersion = params.version
// viewer.world.updateTexturesData()
// todo warning
},
block () {
blockProps = {}
metadataFolder.destroy()
const block = mcData.blocksByName[params.block]
if (!block) return
console.log('block', block.name)
const props = new Block(block.id, 0, 0).getProperties()
//@ts-ignore
const { states } = mcData.blocksByStateId[getBlock()?.minStateId] ?? {}
metadataFolder = gui.addFolder('metadata')
if (states) {
for (const state of states) {
let defaultValue: string | number | boolean
if (state.values) { // int, enum
defaultValue = state.values[0]
} else {
switch (state.type) {
case 'bool':
defaultValue = false
break
case 'int':
defaultValue = 0
break
case 'direction':
defaultValue = 'north'
break
default:
continue
}
}
blockProps[state.name] = defaultValue
if (state.values) {
metadataFolder.add(blockProps, state.name, state.values)
} else {
metadataFolder.add(blockProps, state.name)
}
}
} else {
for (const [name, value] of Object.entries(props)) {
blockProps[name] = value
metadataFolder.add(blockProps, name)
}
}
console.log('props', blockProps)
metadataFolder.open()
},
entity () {
continuousRender = params.entity === 'player'
entityUpdateShared()
if (!params.entity) return
if (params.entity === 'player') {
viewer.entities.updatePlayerSkin('id', viewer.entities.entities.id.username, true, true)
viewer.entities.playAnimation('id', 'running')
}
// let prev = false
// setInterval(() => {
// viewer.entities.playAnimation('id', prev ? 'running' : 'idle')
// prev = !prev
// }, 1000)
EntityMesh.getStaticData(params.entity)
// entityRotationFolder.destroy()
// entityRotationFolder = gui.addFolder('entity metadata')
// entityRotationFolder.add(params, 'entityRotate')
// entityRotationFolder.open()
},
supportBlock () {
viewer.setBlockStateId(targetPos.offset(0, -1, 0), params.supportBlock ? 1 : 0)
},
modelVariant () {
viewer.world.mesherConfig.debugModelVariant = params.modelVariant === 0 ? undefined : [params.modelVariant]
},
animationTick () {
// TODO
const webgl = (viewer.world as WorldRendererWebgpu).playgroundGetWebglData() as unknown as { animation: any }
if (!webgl?.animation) {
setAnimationTick(0)
return
}
if (params.animationTick === -1) {
textureAnimation = new TextureAnimation(new Proxy({} as any, {
set (t, p, v) {
if (p === 'tick') {
setAnimationTick(v)
}
return true
}
}), webgl.animation, webgl.animation.framesCount)
} else {
setAnimationTick(params.animationTick)
textureAnimation = undefined
}
}
}
const applyChanges = (metadataUpdate = false, skipQs = false) => {
const blockId = getBlock()?.id
let block: BlockLoader.Block
if (metadataUpdate) {
block = new Block(blockId, 0, params.metadata)
Object.assign(blockProps, block.getProperties())
for (const _child of metadataFolder.children) {
const child = _child as import('lil-gui').Controller
child.updateDisplay()
}
} else {
try {
//@ts-ignore
block = Block.fromProperties(blockId ?? -1, blockProps, 0)
} catch (err) {
console.error(err)
block = Block.fromStateId(0, 0)
}
}
//@ts-ignore
viewer.setBlockStateId(targetPos, block.stateId)
console.log('up stateId', block.stateId)
params.metadata = block.metadata
metadataGui.updateDisplay()
if (!skipQs) {
setQs()
}
}
gui.onChange(({ property, object }) => {
if (object === params) {
if (property === 'camera') return
onUpdate[property]?.()
if (property !== 'animationTick') {
applyChanges(property === 'metadata')
}
} else {
applyChanges()
}
})
viewer.waitForChunksToRender().then(async () => {
// TODO!
await new Promise(resolve => {
setTimeout(resolve, 50)
})
for (const update of Object.values(onUpdate)) {
update(true)
}
applyChanges()
// gui.openAnimated()
})
const animate = () => { }
const animate2 = () => {
// if (controls) controls.update()
// worldView.updatePosition(controls.target)
viewer.render()
window.requestAnimationFrame(animate2)
}
viewer.world.renderUpdateEmitter.addListener('update', () => {
// const frames = viewer.world.hasWithFrames ? viewer.world.hasWithFrames - 1 : 0;
const webgl = (viewer.world as WorldRendererWebgpu).playgroundGetWebglData()
// if (webgl?.animation) {
// params.animationTick = -1
// animationController.show()
// animationController.max(webgl.animation.framesCount)
// } else {
// animationController.hide()
// }
onUpdate.animationTick()
})
animate2()
// #region camera rotation param
if (params.camera) {
const [x, y] = params.camera.split(',')
viewer.camera.rotation.set(parseFloat(x), parseFloat(y), 0, 'ZYX')
// controls.update()
console.log(viewer.camera.rotation.x, parseFloat(x))
}
const throttledCamQsUpdate = _.throttle(() => {
const { camera } = viewer
// params.camera = `${camera.rotation.x.toFixed(2)},${camera.rotation.y.toFixed(2)}`
setQs()
}, 200)
// controls.addEventListener('change', () => {
// throttledCamQsUpdate()
// animate()
// })
// #endregion
let time = performance.now()
const continuousUpdate = () => {
textureAnimation?.step(performance.now() - time)
time = performance.now()
requestAnimationFrame(continuousUpdate)
}
continuousUpdate()
window.onresize = () => {
if (ignoreResize) return
// const vec3 = new THREE.Vector3()
// vec3.set(-1, -1, -1).unproject(viewer.camera)
// console.log(vec3)
// box.position.set(vec3.x, vec3.y, vec3.z-1)
const { camera } = viewer
viewer.camera.aspect = window.innerWidth / window.innerHeight
viewer.camera.updateProjectionMatrix()
nullRenderer.setSize(window.innerWidth, window.innerHeight)
animate()
}
window.dispatchEvent(new Event('resize'))
params.playSound = () => {
viewer.playSound(targetPos, 'button_click.mp3')
}
addEventListener('keydown', (e) => {
if (e.code === 'KeyE') {
params.playSound()
}
}, { capture: true })
}
main()

View file

@ -0,0 +1,11 @@
export type BlockFaceType = {
side: number
textureIndex: number
textureName?: string
tint?: [number, number, number]
isTransparent?: boolean
}
export type BlockType = {
faces: BlockFaceType[]
}

View file

@ -0,0 +1,511 @@
import * as THREE from 'three';
import { BlockFaceType } from './shared';
import * as tweenJs from '@tweenjs/tween.js';
import { cubePositionOffset, cubeUVOffset, cubeVertexArray, cubeVertexCount, cubeVertexSize } from './CubeDef';
import VertShader from './Cube.vert.wgsl';
import FragShader from './Cube.frag.wgsl';
import ComputeShader from './Cube.comp.wgsl';
import { updateSize, allSides } from './webgpuRendererWorker';
export class WebgpuRenderer {
rendering = true
NUMBER_OF_CUBES = 490_000;
renderedFrames = 0
localStorage: any = {}
ready = false;
device: GPUDevice;
renderPassDescriptor: GPURenderPassDescriptor;
uniformBindGroup: GPUBindGroup;
UniformBuffer: GPUBuffer;
ViewUniformBuffer: GPUBuffer;
ProjectionUniformBuffer: GPUBuffer;
ctx: GPUCanvasContext;
verticesBuffer: GPUBuffer;
InstancedModelBuffer: GPUBuffer;
pipeline: GPURenderPipeline;
InstancedTextureIndexBuffer: GPUBuffer;
InstancedColorBuffer: GPUBuffer;
notRenderedAdditions = 0;
// Add these properties to the WebgpuRenderer class
computePipeline: GPUComputePipeline;
indirectDrawBuffer: GPUBuffer;
cubesBuffer: GPUBuffer;
visibleCubesBuffer: GPUBuffer;
computeBindGroup: GPUBindGroup;
computeBindGroupLayout: GPUBindGroupLayout;
indirectDrawParams: Uint32Array;
maxBufferSize: number
constructor(public canvas: HTMLCanvasElement, public imageBlob: ImageBitmapSource, public isPlayground: boolean, public camera: THREE.PerspectiveCamera) {
this.init();
}
async init () {
const { canvas, imageBlob, isPlayground, localStorage } = this;
updateSize(canvas.width, canvas.height);
const textureBitmap = await createImageBitmap(imageBlob);
const textureWidth = textureBitmap.width;
const textureHeight = textureBitmap.height;
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) throw new Error('WebGPU not supported');
this.device = await adapter.requestDevice();
const { device } = this;
this.maxBufferSize = device.limits.maxStorageBufferBindingSize;
this.renderedFrames = device.limits.maxComputeWorkgroupSizeX;
console.log('max buffer size', this.maxBufferSize / 1024 / 1024, 'MB')
const ctx = this.ctx = canvas.getContext('webgpu')!;
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
ctx.configure({
device,
format: presentationFormat,
alphaMode: 'premultiplied',
});
const verticesBuffer = device.createBuffer({
size: cubeVertexArray.byteLength,
usage: GPUBufferUsage.VERTEX,
mappedAtCreation: true,
});
this.verticesBuffer = verticesBuffer;
new Float32Array(verticesBuffer.getMappedRange()).set(cubeVertexArray);
verticesBuffer.unmap();
const pipeline = device.createRenderPipeline({
label: 'mainPipeline',
layout: 'auto',
vertex: {
module: device.createShaderModule({
code: localStorage.vertShader || VertShader,
}),
buffers: [
{
arrayStride: cubeVertexSize,
attributes: [
{
shaderLocation: 0,
offset: cubePositionOffset,
format: 'float32x3',
},
{
shaderLocation: 1,
offset: cubeUVOffset,
format: 'float32x2',
},
],
},
// {
// arrayStride: 3 * 4,
// attributes: [
// {
// shaderLocation: 2,
// offset: 0,
// format: 'float32x3',
// },
// ],
// stepMode: 'instance',
// },
// {
// arrayStride: 1 * 4,
// attributes: [
// {
// shaderLocation: 3,
// offset: 0,
// format: 'float32',
// },
// ],
// stepMode: 'instance',
// },
// {
// arrayStride: 3 * 4,
// attributes: [
// {
// shaderLocation: 4,
// offset: 0,
// format: 'float32x3',
// },
// ],
// stepMode: 'instance',
// },
],
},
fragment: {
module: device.createShaderModule({
code: localStorage.fragShader || FragShader,
}),
targets: [
{
format: presentationFormat,
blend: {
color: {
srcFactor: 'src-alpha',
dstFactor: 'one-minus-src-alpha',
operation: 'add',
},
alpha: {
srcFactor: 'src-alpha',
dstFactor: 'one-minus-src-alpha',
operation: 'add',
},
},
},
],
},
primitive: {
topology: 'triangle-list',
cullMode: 'front',
},
depthStencil: {
depthWriteEnabled: true,
depthCompare: 'less',
format: 'depth24plus',
},
});
this.pipeline = pipeline;
const depthTexture = device.createTexture({
size: [canvas.width, canvas.height],
format: 'depth24plus',
usage: GPUTextureUsage.RENDER_ATTACHMENT,
});
const uniformBufferSize = 4 * (4 * 4); // 4x4 matrix
this.UniformBuffer = device.createBuffer({
size: uniformBufferSize,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
// Fetch the image and upload it into a GPUTexture.
let cubeTexture: GPUTexture;
{
cubeTexture = device.createTexture({
size: [textureBitmap.width, textureBitmap.height, 1],
format: 'rgb10a2unorm',
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
});
device.queue.copyExternalImageToTexture(
{ source: textureBitmap },
{ texture: cubeTexture },
[textureBitmap.width, textureBitmap.height]
);
}
const sampler = device.createSampler({
magFilter: 'nearest',
minFilter: 'nearest',
});
this.renderPassDescriptor = {
label: 'MainRenderPassDescriptor',
colorAttachments: [
{
view: undefined as any, // Assigned later
clearValue: [0.6784313725490196, 0.8470588235294118, 0.9019607843137255, 1],
loadOp: 'clear',
storeOp: 'store',
},
],
depthStencilAttachment: {
view: depthTexture.createView(),
depthClearValue: 1,
depthLoadOp: 'clear',
depthStoreOp: 'store',
},
};
// Create compute pipeline
const computeShaderModule = device.createShaderModule({
code: localStorage.computeShader || ComputeShader,
label: 'Culled Instance',
});
const computeBindGroupLayout = device.createBindGroupLayout({
label: 'computeBindGroupLayout',
entries: [
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
{ binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
{ binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
{ binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
],
});
const computePipelineLayout = device.createPipelineLayout({
label: 'computePipelineLayout',
bindGroupLayouts: [computeBindGroupLayout],
})
this.computePipeline = device.createComputePipeline({
label: 'Culled Instance',
layout: computePipelineLayout,
// layout: 'auto',
compute: {
module: computeShaderModule,
entryPoint: 'main',
},
});
// Create buffers for compute shader and indirect drawing
this.cubesBuffer = device.createBuffer({
label: 'cubesBuffer',
size: this.NUMBER_OF_CUBES * 32, // 8 floats per cube - minimum buffer size
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
this.visibleCubesBuffer = device.createBuffer({
label: 'visibleCubesBuffer',
size: this.NUMBER_OF_CUBES * 32,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX,
});
this.indirectDrawBuffer = device.createBuffer({
label: 'indirectDrawBuffer',
size: 16, // 4 uint32 values
usage: GPUBufferUsage.INDIRECT | GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
// Initialize indirect draw parameters
const indirectDrawParams = new Uint32Array([cubeVertexCount, 0, 0, 0]);
device.queue.writeBuffer(this.indirectDrawBuffer, 0, indirectDrawParams);
// const vertexBindGroupLayout = device.createBindGroupLayout({
// label: 'vertexBindGroupLayout',
// entries: [
// { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: 'uniform' } },
// { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } } // Read-only storage
// ]
// });
// Create bind group for render pass
this.uniformBindGroup = device.createBindGroup({
label: 'uniformBindGroups',
//layout: vertexBindGroupLayout,
layout: pipeline.getBindGroupLayout(0),
entries: [
{
binding: 0,
resource: {
buffer: this.UniformBuffer,
},
},
{
binding: 1,
resource: sampler,
},
{
binding: 2,
resource: cubeTexture.createView(),
},
{
binding: 3,
resource: {
buffer: this.visibleCubesBuffer
}
}
],
});
// // Create bind group for compute shader
// this.computeBindGroupLayout = device.createBindGroupLayout({
// label: 'computeBindGroupLayout',
// entries: [
// {
// binding: 0,
// visibility: GPUShaderStage.COMPUTE,
// buffer: {
// type: 'uniform',
// },
// },
// {
// binding: 1,
// visibility: GPUShaderStage.COMPUTE,
// buffer: {
// type: 'storage',
// },
// },
// {
// binding: 2,
// visibility: GPUShaderStage.COMPUTE,
// buffer: {
// type: '',
// },
// },
// {
// binding: 3,
// visibility: GPUShaderStage.COMPUTE,
// buffer: {
// type: 'storage',
// },
// },
// ],
// });
this.computeBindGroup = device.createBindGroup({
//layout: this.computeBindGroupLayout,
layout: this.computePipeline.getBindGroupLayout(0),
label: 'computeBindGroup',
entries: [
{
binding: 0,
resource: { buffer: this.UniformBuffer },
},
{
binding: 1,
resource: { buffer: this.cubesBuffer },
},
{
binding: 2,
resource: { buffer: this.visibleCubesBuffer },
},
{
binding: 3,
resource: { buffer: this.indirectDrawBuffer },
},
],
})
this.indirectDrawParams = new Uint32Array([cubeVertexCount, 0, 0, 0]);
// always last!
this.rendering = false;
console.log('init finish')
this.updateSides()
this.loop();
this.ready = true;
return canvas;
}
removeOne () { }
realNumberOfCubes = 0;
updateSides (startOffset = 0) {
console.time('updateSides')
this.rendering = true;
const positions = [] as number[];
let textureIndexes = [] as number[];
let colors = [] as number[];
const blocksPerFace = {} as Record<string, BlockFaceType>;
for (const side of allSides.slice(startOffset)) {
if (!side) continue;
const [x, y, z] = side.slice(0, 3);
const key = `${x},${y},${z}`;
if (blocksPerFace[key]) continue;
blocksPerFace[key] = side[3];
}
for (const key in blocksPerFace) {
const side = key.split(',').map(Number);
positions.push(...[side[0], side[1], side[2]]);
const face = blocksPerFace[key];
textureIndexes.push(face.textureIndex);
colors.push(1, 1, 1);
}
const NUMBER_OF_CUBES_NEEDED = Math.ceil(positions.length / 3);
this.realNumberOfCubes = NUMBER_OF_CUBES_NEEDED;
if (NUMBER_OF_CUBES_NEEDED > this.NUMBER_OF_CUBES) {
console.warn('extending number of cubes', NUMBER_OF_CUBES_NEEDED, this.NUMBER_OF_CUBES)
this.NUMBER_OF_CUBES = NUMBER_OF_CUBES_NEEDED + 5000;
}
const BYTES_PER_ELEMENT = 8;
const cubeData = new Float32Array(this.NUMBER_OF_CUBES * BYTES_PER_ELEMENT);
for (let i = 0; i < this.NUMBER_OF_CUBES; i++) {
const offset = i * BYTES_PER_ELEMENT;
cubeData[offset] = positions[i * 3];
cubeData[offset + 1] = positions[i * 3 + 1];
cubeData[offset + 2] = positions[i * 3 + 2];
cubeData[offset + 3] = textureIndexes[i];
cubeData[offset + 4] = colors[i * 3];
cubeData[offset + 5] = colors[i * 3 + 1];
cubeData[offset + 6] = colors[i * 3 + 2];
//cubeData[offset + 7] = 0.5; // Sphere radius
}
console.time('writeCubes buffer')
this.device.queue.writeBuffer(this.cubesBuffer, 0, cubeData);
console.timeEnd('writeCubes buffer')
// Reset indirect draw parameters
// this.indirectDrawParams = new Uint32Array([cubeVertexCount, 0, 0, 0]);
//this.device.queue.writeBuffer(this.indirectDrawBuffer, 0, this.indirectDrawParams);
this.notRenderedAdditions++;
console.timeEnd('updateSides')
}
lastCall = performance.now();
logged = false;
loop () {
if (!this.rendering) {
requestAnimationFrame(() => this.loop());
return;
}
const { device, UniformBuffer: uniformBuffer, renderPassDescriptor, uniformBindGroup, pipeline, ctx, verticesBuffer } = this;
const now = Date.now();
tweenJs.update();
const ViewProjectionMat4 = new THREE.Matrix4();
this.camera.updateMatrix();
const projectionMatrix = this.camera.projectionMatrix;
ViewProjectionMat4.multiplyMatrices(projectionMatrix, this.camera.matrix.invert());
const ViewProjection = new Float32Array(ViewProjectionMat4.elements);
device.queue.writeBuffer(
uniformBuffer,
0,
ViewProjection
);
// const EmptyVisibleCubes = new Float32Array([36, 0, 0, 0]) ;
device.queue.writeBuffer(
this.indirectDrawBuffer, 0, this.indirectDrawParams);
renderPassDescriptor.colorAttachments[0].view = ctx
.getCurrentTexture()
.createView();
let commandEncoder = device.createCommandEncoder();
// Compute pass for occlusion culling
commandEncoder.label = "Main Comand Encoder"
const computePass = commandEncoder.beginComputePass();
computePass.label = "ComputePass"
computePass.setPipeline(this.computePipeline);
//computePass.setBindGroup(0, this.uniformBindGroup);
computePass.setBindGroup(0, this.computeBindGroup);
computePass.dispatchWorkgroups(Math.ceil(this.NUMBER_OF_CUBES / 256));
computePass.end();
device.queue.submit([commandEncoder.finish()]);
commandEncoder = device.createCommandEncoder();
//device.queue.submit([commandEncoder.finish()]);
// Render pass
//console.log(this.indirectDrawBuffer.getMappedRange());
const renderPass = commandEncoder.beginRenderPass(this.renderPassDescriptor);
renderPass.label = "RenderPass"
renderPass.setPipeline(pipeline);
renderPass.setBindGroup(0, this.uniformBindGroup);
renderPass.setVertexBuffer(0, verticesBuffer);
// Use indirect drawing
renderPass.drawIndirect(this.indirectDrawBuffer, 0);
renderPass.end();
device.queue.submit([commandEncoder.finish()]);
this.renderedFrames++;
requestAnimationFrame(() => this.loop());
this.notRenderedAdditions = 0;
}
}

View file

@ -0,0 +1,204 @@
import { generateSpiralMatrix } from 'flying-squid/dist/utils'
import { Viewer } from '../viewer/lib/viewer'
import { addNewStat } from './newStats'
import type { workerProxyType } from './webgpuRendererWorker'
import { useWorkerProxy } from './workerProxy'
import { MesherGeometryOutput } from '../viewer/lib/mesher/shared'
import { pickObj } from '@zardoy/utils'
let worker: Worker
export let webgpuChannel: typeof workerProxyType['__workerProxy'] = new Proxy({}, {
get: () => () => { }
}) as any // placeholder to avoid crashes
declare const viewer: Viewer
let allReceived = false
declare const customEvents
declare const bot
if (typeof customEvents !== 'undefined') {
customEvents.on('gameLoaded', () => {
const chunksExpected = generateSpiralMatrix(globalThis.options.renderDistance)
let received = 0
bot.on('chunkColumnLoad', (data) => {
received++
if (received === chunksExpected.length) {
allReceived = true
// addBlocksSection('all', viewer.world.newChunks)
}
})
})
}
let isWaitingToUpload = false
globalThis.tiles = {}
export const addBlocksSection = (key, data: MesherGeometryOutput) => {
if (globalThis.tiles[key]) return
globalThis.tiles[key] = data.tiles
webgpuChannel.addBlocksSection(data.tiles, key, false)
if (playground && !isWaitingToUpload) {
isWaitingToUpload = true
// viewer.waitForChunksToRender().then(() => {
// isWaitingToUpload = false
// sendWorkerMessage({
// type: 'addBlocksSectionDone'
// })
// })
}
}
export const loadFixtureSides = (json) => {
webgpuChannel.loadFixture(json)
}
export const sendCameraToWorker = () => {
const cameraVectors = ['rotation', 'position'].reduce((acc, key) => {
acc[key] = ['x', 'y', 'z'].reduce((acc2, key2) => {
acc2[key2] = viewer.camera[key][key2]
return acc2
}, {})
return acc
}, {}) as any
webgpuChannel.camera({
...cameraVectors,
fov: viewer.camera.fov
})
}
export const removeBlocksSection = (key) => {
webgpuChannel.removeBlocksSection(key)
}
let playground = false
export const initWebgpuRenderer = async (postRender = () => { }, playgroundModeInWorker = false, actuallyPlayground = false) => {
playground = actuallyPlayground
await new Promise(resolve => {
// console.log('viewer.world.material.map!.image', viewer.world.material.map!.image)
// viewer.world.material.map!.image.onload = () => {
// console.log(this.material.map!.image)
// resolve()
// }
viewer.world.renderUpdateEmitter.once('textureDownloaded', resolve)
})
const image = viewer.world.material.map!.image
const imageBlob = await fetch(image.src).then((res) => res.blob())
const canvas = document.createElement('canvas')
canvas.width = window.innerWidth * window.devicePixelRatio
canvas.height = window.innerHeight * window.devicePixelRatio
document.body.appendChild(canvas)
canvas.id = 'viewer-canvas'
console.log('starting offscreen')
const offscreen = canvas.transferControlToOffscreen()
// replacable by initWebglRenderer
worker = new Worker('./webgpuRendererWorker.js')
addFpsCounters()
webgpuChannel = useWorkerProxy<typeof workerProxyType>(worker, true)
webgpuChannel.canvas(offscreen, imageBlob, playgroundModeInWorker, pickObj(localStorage, 'vertShader', 'fragShader', 'computeShader'))
let oldWidth = window.innerWidth
let oldHeight = window.innerHeight
let oldCamera = {
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 }
}
let focused = true
window.addEventListener('focus', () => {
focused = true
webgpuChannel.startRender()
})
window.addEventListener('blur', () => {
focused = false
webgpuChannel.stopRender()
})
const mainLoop = () => {
requestAnimationFrame(mainLoop)
//@ts-ignore
if (!focused || window.stopRender) return
if (oldWidth !== window.innerWidth || oldHeight !== window.innerHeight) {
oldWidth = window.innerWidth
oldHeight = window.innerHeight
webgpuChannel.resize(window.innerWidth * window.devicePixelRatio, window.innerHeight * window.devicePixelRatio)
}
postRender()
// TODO! do it in viewer to avoid possible delays
if (actuallyPlayground && ['rotation', 'position'].some((key) => oldCamera[key] !== viewer.camera[key])) {
// TODO fix
for (const [key, val] of Object.entries(oldCamera)) {
for (const key2 of Object.keys(val)) {
oldCamera[key][key2] = viewer.camera[key][key2]
}
}
sendCameraToWorker()
}
}
requestAnimationFrame(mainLoop)
}
export const setAnimationTick = (tick: number, frames?: number) => {
webgpuChannel.animationTick(tick, frames)
}
export const exportLoadedTiles = () => {
webgpuChannel.exportData()
const controller = new AbortController()
worker.addEventListener('message', async (e) => {
const receivedData = e.data.data
console.log('received fixture')
// await new Promise(resolve => {
// setTimeout(resolve, 0)
// })
try {
const a = document.createElement('a')
type Vec3 = [number, number, number]
type PlayTimeline = [pos: Vec3, rot: Vec3, time: number]
const vec3ToArr = (vec3: { x, y, z }) => [vec3.x, vec3.y, vec3.z] as Vec3
// const dataObj = {
// ...receivedData,
// version: viewer.version,
// camera: [vec3ToArr(viewer.camera.position), vec3ToArr(viewer.camera.rotation)],
// playTimeline: [] as PlayTimeline[]
// }
// split into two chunks
const objectURL = URL.createObjectURL(new Blob([receivedData.sides.buffer], { type: 'application/octet-stream' }))
a.href = objectURL
a.download = 'dumped-chunks-tiles.bin'
a.click()
URL.revokeObjectURL(objectURL)
} finally {
controller.abort()
}
}, { signal: controller.signal })
}
const addFpsCounters = () => {
const { updateText } = addNewStat('fps')
let prevTimeout
worker.addEventListener('message', (e) => {
if (e.data.type === 'fps') {
updateText(`FPS: ${e.data.fps}`)
if (prevTimeout) clearTimeout(prevTimeout)
prevTimeout = setTimeout(() => {
updateText('<hanging>')
}, 1002)
}
})
const { updateText: updateText2 } = addNewStat('fps-main', 90, 0, 20)
let updates = 0
const mainLoop = () => {
requestAnimationFrame(mainLoop)
updates++
}
mainLoop()
setInterval(() => {
updateText2(`Main Loop: ${updates}`)
updates = 0
}, 1000)
}

View file

@ -0,0 +1,241 @@
/// <reference types="@webgpu/types" />
import * as THREE from 'three'
import { BlockFaceType, BlockType } from './shared'
import * as tweenJs from '@tweenjs/tween.js'
//@ts-ignore
//@ts-ignore
import { createWorkerProxy } from './workerProxy'
import { WebgpuRenderer } from './webgpuRenderer'
export let allSides = [] as ([number, number, number, BlockFaceType] | undefined)[]
globalThis.allSides = allSides
let allSidesAdded = 0
let needsSidesUpdate = false
let chunksArrIndexes = {}
let freeArrayIndexes = [] as [number, number][]
let sidePositions
let lastNotUpdatedIndex
let lastNotUpdatedArrSize
let animationTick = 0
const camera = new THREE.PerspectiveCamera(75, 1 / 1, 0.1, 10_000)
globalThis.camera = camera
let webgpuRenderer: WebgpuRenderer | undefined
setInterval(() => {
if (!webgpuRenderer) return
// console.log('FPS:', renderedFrames)
postMessage({ type: 'fps', fps: webgpuRenderer.renderedFrames })
webgpuRenderer.renderedFrames = 0
}, 1000)
export const updateSize = (width, height) => {
camera.aspect = width / height
camera.updateProjectionMatrix()
}
let fullReset
const updateCubesWhenAvailable = (pos) => {
if (webgpuRenderer?.ready) {
webgpuRenderer.updateSides(pos)
} else {
setTimeout(updateCubesWhenAvailable, 100)
}
}
let started = false
let newWidth: number | undefined
let newHeight: number | undefined
let autoTickUpdate = undefined as number | undefined
export const workerProxyType = createWorkerProxy({
canvas (canvas, imageBlob, isPlayground, localStorage) {
started = true
webgpuRenderer = new WebgpuRenderer(canvas, imageBlob, isPlayground, camera)
webgpuRenderer.localStorage
globalThis.webgpuRenderer = webgpuRenderer
},
startRender () {
if (!webgpuRenderer) return
webgpuRenderer.rendering = true
},
stopRender () {
if (!webgpuRenderer) return
webgpuRenderer.rendering = false
},
resize (newWidth, newHeight) {
newWidth = newWidth
newHeight = newHeight
updateSize(newWidth, newHeight)
},
generateRandom (count: number) {
const square = Math.sqrt(count)
if (square % 1 !== 0) throw new Error('square must be a whole number')
const blocks = {}
const getFace = (face: number) => {
return {
side: face,
textureIndex: Math.floor(Math.random() * 512)
}
}
for (let x = 0; x < square; x++) {
for (let z = 0; z < square; z++) {
blocks[`${x},${0},${z}`] = {
faces: [
getFace(0),
getFace(1),
getFace(2),
getFace(3),
getFace(4),
getFace(5)
],
}
}
}
console.log('data ready')
this.addBlocksSection(blocks, `0,0,0`)
},
addBlocksSection (tiles: Record<string, BlockType>, key: string, update = true) {
const currentLength = allSides.length
// in: object - name, out: [x, y, z, name]
const newData = Object.entries(tiles).flatMap(([key, value]) => {
const [x, y, z] = key.split(',').map(Number)
const block = value as BlockType
return block.faces.map((side) => {
return [x, y, z, side] as [number, number, number, BlockFaceType]
})
})
// find freeIndexes if possible
const freeArea = freeArrayIndexes.find(([startIndex, endIndex]) => endIndex - startIndex >= newData.length)
if (freeArea) {
const [startIndex, endIndex] = freeArea
allSides.splice(startIndex, newData.length, ...newData)
lastNotUpdatedIndex ??= startIndex
const freeAreaIndex = freeArrayIndexes.indexOf(freeArea)
freeArrayIndexes[freeAreaIndex] = [startIndex + newData.length, endIndex]
if (freeArrayIndexes[freeAreaIndex][0] >= freeArrayIndexes[freeAreaIndex][1]) {
freeArrayIndexes.splice(freeAreaIndex, 1)
// todo merge
}
lastNotUpdatedArrSize = newData.length
console.log('using free area', freeArea)
}
chunksArrIndexes[key] = [currentLength, currentLength + newData.length]
let i = 0;
while (i < newData.length) {
allSides.splice(currentLength + i, 0, ...newData.slice(i, i + 1024));
i += 1024;
}
lastNotUpdatedIndex ??= currentLength
// if (webglRendererWorker && webglRendererWorker.notRenderedAdditions < 5) {
if (update) {
updateCubesWhenAvailable(currentLength)
}
},
addBlocksSectionDone () {
updateCubesWhenAvailable(lastNotUpdatedIndex)
lastNotUpdatedIndex = undefined
lastNotUpdatedArrSize = undefined
},
removeBlocksSection (key) {
return
// fill data with 0
const [startIndex, endIndex] = chunksArrIndexes[key]
for (let i = startIndex; i < endIndex; i++) {
allSides[i] = undefined
}
lastNotUpdatedArrSize = endIndex - startIndex
updateCubesWhenAvailable(startIndex)
// freeArrayIndexes.push([startIndex, endIndex])
// // merge freeArrayIndexes TODO
// if (freeArrayIndexes.at(-1)[0] === freeArrayIndexes.at(-2)?.[1]) {
// const [startIndex, endIndex] = freeArrayIndexes.pop()!
// const [startIndex2, endIndex2] = freeArrayIndexes.pop()!
// freeArrayIndexes.push([startIndex2, endIndex])
// }
},
camera (newCam: { rotation: { x: number, y: number, z: number }, position: { x: number, y: number, z: number }, fov: number }) {
// if (webgpuRenderer?.isPlayground) {
// camera.rotation.order = 'ZYX'
// new tweenJs.Tween(camera.rotation).to({ x: newCam.rotation.x, y: newCam.rotation.y, z: newCam.rotation.z }, 50).start()
// } else {
camera.rotation.set(newCam.rotation.x, newCam.rotation.y, newCam.rotation.z, 'ZYX')
// }
if (newCam.position.x === 0 && newCam.position.y === 0 && newCam.position.z === 0) {
// initial camera position
camera.position.set(newCam.position.x, newCam.position.y, newCam.position.z)
} else {
new tweenJs.Tween(camera.position).to({ x: newCam.position.x, y: newCam.position.y, z: newCam.position.z }, 50).start()
}
if (newCam.fov !== camera.fov) {
camera.fov = newCam.fov
camera.updateProjectionMatrix()
}
},
animationTick (frames, tick) {
if (frames <= 0) {
autoTickUpdate = undefined
animationTick = 0
return
}
if (tick === -1) {
autoTickUpdate = frames
} else {
autoTickUpdate = undefined
animationTick = tick % 20 // todo update automatically in worker
}
},
fullReset () {
fullReset()
},
exportData () {
const exported = exportData()
postMessage({ type: 'exportData', data: exported }, undefined as any, [exported.sides.buffer])
},
loadFixture (json) {
// allSides = json.map(([x, y, z, face, textureIndex]) => {
// return [x, y, z, { face, textureIndex }] as [number, number, number, BlockFaceType]
// })
const dataSize = json.length / 5
for (let i = 0; i < json.length; i += 5) {
allSides.push([json[i], json[i + 1], json[i + 2], { side: json[i + 3], textureIndex: json[i + 4] }])
}
updateCubesWhenAvailable(0)
},
})
globalThis.testDuplicates = () => {
const duplicates = allSides.filter((value, index, self) => self.indexOf(value) !== index)
console.log('duplicates', duplicates)
}
const exportData = () => {
// Calculate the total length of the final array
const totalLength = allSides.length * 5
// Create a new Int16Array with the total length
const flatData = new Int16Array(totalLength)
// Fill the flatData array
for (let i = 0; i < allSides.length; i++) {
const sideData = allSides[i]
if (!sideData) continue
const [x, y, z, side] = sideData
flatData.set([x, y, z, side.side, side.textureIndex], i * 5)
}
return { sides: flatData }
}
setInterval(() => {
if (autoTickUpdate) {
animationTick = (animationTick + 1) % autoTickUpdate
}
}, 1000 / 20)

View file

@ -0,0 +1,58 @@
export function createWorkerProxy<T extends Record<string, (...args: any[]) => void>> (handlers: T): { __workerProxy: T } {
addEventListener('message', (event) => {
const { type, args } = event.data
if (handlers[type]) {
handlers[type](...args)
}
})
return null as any
}
/**
* in main thread
* ```ts
* // either:
* import type { importedTypeWorkerProxy } from './worker'
* // or:
* type importedTypeWorkerProxy = import('./worker').importedTypeWorkerProxy
*
* const workerChannel = useWorkerProxy<typeof importedTypeWorkerProxy>(worker)
* ```
*/
export const useWorkerProxy = <T extends { __workerProxy: Record<string, (...args: any[]) => void> }> (worker: Worker, autoTransfer = true): T['__workerProxy'] & {
transfer: (...args: Transferable[]) => T['__workerProxy']
} => {
// in main thread
return new Proxy({} as any, {
get: (target, prop) => {
if (prop === 'transfer') return (...transferable: Transferable[]) => {
return new Proxy({}, {
get: (target, prop) => {
return (...args: any[]) => {
worker.postMessage({
type: prop,
args,
}, transferable)
}
}
})
}
return (...args: any[]) => {
const transfer = autoTransfer ? args.filter(arg => arg instanceof ArrayBuffer || arg instanceof MessagePort || arg instanceof ImageBitmap || arg instanceof OffscreenCanvas || arg instanceof ImageData) : []
worker.postMessage({
type: prop,
args,
}, transfer)
}
}
})
}
// const workerProxy = createWorkerProxy({
// startRender (canvas: HTMLCanvasElement) {
// },
// })
// const worker = useWorkerProxy(null, workerProxy)
// worker.

9
prismarine-viewer/globals.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
type StringKeys<T extends object> = Extract<keyof T, string>
interface ObjectConstructor {
keys<T extends object> (obj: T): Array<StringKeys<T>>
entries<T extends object> (obj: T): Array<[StringKeys<T>, T[keyof T]]>
// todo review https://stackoverflow.com/questions/57390305/trying-to-get-fromentries-type-right
fromEntries<T extends Array<[string, any]>> (obj: T): Record<T[number][0], T[number][1]>
assign<T extends Record<string, any>, K extends Record<string, any>> (target: T, source: K): asserts target is T & K
}

View file

@ -0,0 +1,6 @@
module.exports = {
mineflayer: require('./lib/mineflayer'),
standalone: require('./lib/standalone'),
headless: require('./lib/headless'),
viewer: require('./viewer'),
}

View file

@ -0,0 +1,12 @@
const path = require('path')
const compression = require('compression')
const express = require('express')
function setupRoutes (app, prefix = '') {
app.use(compression())
app.use(prefix + '/', express.static(path.join(__dirname, '../public')))
}
module.exports = {
setupRoutes
}

View file

@ -0,0 +1,135 @@
/* global THREE */
function safeRequire (path) {
try {
return require(path)
} catch (e) {
return {}
}
}
const { spawn } = require('child_process')
const net = require('net')
global.THREE = require('three')
global.Worker = require('worker_threads').Worker
const { createCanvas } = safeRequire('node-canvas-webgl/lib')
const { WorldDataEmitter, Viewer, getBufferFromStream } = require('../viewer')
module.exports = (bot, { viewDistance = 6, output = 'output.mp4', frames = -1, width = 512, height = 512, logFFMPEG = false, jpegOptions }) => {
const canvas = createCanvas(width, height)
const renderer = new THREE.WebGLRenderer({ canvas })
const viewer = new Viewer(renderer)
viewer.setVersion(bot.version)
viewer.setFirstPersonCamera(bot.entity.position, bot.entity.yaw, bot.entity.pitch)
// Load world
const worldView = new WorldDataEmitter(bot.world, viewDistance, bot.entity.position)
viewer.listen(worldView)
worldView.init(bot.entity.position)
function botPosition () {
viewer.setFirstPersonCamera(bot.entity.position, bot.entity.yaw, bot.entity.pitch)
worldView.updatePosition(bot.entity.position)
}
// Render loop streaming
const rtmpOutput = output.startsWith('rtmp://')
const ffmpegOutput = output.endsWith('mp4')
let client = null
if (rtmpOutput) {
const fps = 20
const gop = fps * 2
const gopMin = fps
const probesize = '42M'
const cbr = '1000k'
const threads = 4
const args = `-y -r ${fps} -probesize ${probesize} -i pipe:0 -f flv -ac 2 -ar 44100 -vcodec libx264 -g ${gop} -keyint_min ${gopMin} -b:v ${cbr} -minrate ${cbr} -maxrate ${cbr} -pix_fmt yuv420p -s 1280x720 -preset ultrafast -tune film -threads ${threads} -strict normal -bufsize ${cbr} ${output}`.split(' ')
client = spawn('ffmpeg', args)
if (logFFMPEG) {
client.stdout.on('data', (data) => {
console.log(`stdout: ${data}`)
})
client.stderr.on('data', (data) => {
console.error(`stderr: ${data}`)
})
}
update()
} else if (ffmpegOutput) {
client = spawn('ffmpeg', ['-y', '-i', 'pipe:0', output])
if (logFFMPEG) {
client.stdout.on('data', (data) => {
console.log(`stdout: ${data}`)
})
client.stderr.on('data', (data) => {
console.error(`stderr: ${data}`)
})
}
update()
} else {
const [host, port] = output.split(':')
client = new net.Socket()
client.connect(parseInt(port, 10), host, () => {
update()
})
}
// Force end of stream
bot.on('end', () => { frames = 0 })
let idx = 0
function update () {
viewer.update()
renderer.render(viewer.scene, viewer.camera)
const imageStream = canvas.createJPEGStream({
bufsize: 4096,
quality: 1,
progressive: false,
...jpegOptions
})
if (rtmpOutput || ffmpegOutput) {
imageStream.on('data', (chunk) => {
if (client.stdin.writable) {
client.stdin.write(chunk)
} else {
console.log('Error: ffmpeg stdin closed!')
}
})
imageStream.on('end', () => {
idx++
if (idx < frames || frames < 0) {
setTimeout(update, 16)
} else {
console.log('done streaming')
client.stdin.end()
}
})
imageStream.on('error', () => { })
} else {
getBufferFromStream(imageStream).then((buffer) => {
const sizebuff = new Uint8Array(4)
const view = new DataView(sizebuff.buffer, 0)
view.setUint32(0, buffer.length, true)
client.write(sizebuff)
client.write(buffer)
idx++
if (idx < frames || frames < 0) {
setTimeout(update, 16)
} else {
client.end()
}
}).catch(() => {})
}
}
// Register events
bot.on('move', botPosition)
worldView.listenToBot(bot)
return client
}

View file

@ -0,0 +1,71 @@
/* global THREE */
global.THREE = require('three')
const TWEEN = require('@tweenjs/tween.js')
require('three/examples/js/controls/OrbitControls')
const { Viewer, Entity } = require('../viewer')
const io = require('socket.io-client')
const socket = io()
let firstPositionUpdate = true
const renderer = new THREE.WebGLRenderer()
renderer.setPixelRatio(window.devicePixelRatio || 1)
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)
const viewer = new Viewer(renderer)
let controls = new THREE.OrbitControls(viewer.camera, renderer.domElement)
function animate () {
window.requestAnimationFrame(animate)
if (controls) controls.update()
viewer.update()
renderer.render(viewer.scene, viewer.camera)
}
animate()
window.addEventListener('resize', () => {
viewer.camera.aspect = window.innerWidth / window.innerHeight
viewer.camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
})
socket.on('version', (version) => {
viewer.setVersion(version)
firstPositionUpdate = true
viewer.listen(socket)
let botMesh
socket.on('position', ({ pos, addMesh, yaw, pitch }) => {
if (yaw !== undefined && pitch !== undefined) {
if (controls) {
controls.dispose()
controls = null
}
viewer.setFirstPersonCamera(pos, yaw, pitch)
return
}
if (pos.y > 0 && firstPositionUpdate) {
controls.target.set(pos.x, pos.y, pos.z)
viewer.camera.position.set(pos.x, pos.y + 20, pos.z + 20)
controls.update()
firstPositionUpdate = false
}
if (addMesh) {
if (!botMesh) {
botMesh = new Entity('1.16.4', 'player', viewer.scene).mesh
viewer.scene.add(botMesh)
}
new TWEEN.Tween(botMesh.position).to({ x: pos.x, y: pos.y, z: pos.z }, 50).start()
const da = (yaw - botMesh.rotation.y) % (Math.PI * 2)
const dy = 2 * da % (Math.PI * 2) - da
new TWEEN.Tween(botMesh.rotation).to({ y: botMesh.rotation.y + dy }, 50).start()
}
})
})

View file

@ -0,0 +1,91 @@
const EventEmitter = require('events')
const { WorldDataEmitter } = require('../viewer')
module.exports = (bot, { viewDistance = 6, firstPerson = false, port = 3000, prefix = '' }) => {
const express = require('express')
const app = express()
const http = require('http').createServer(app)
const io = require('socket.io')(http, { path: prefix + '/socket.io' })
const { setupRoutes } = require('./common')
setupRoutes(app, prefix)
const sockets = []
const primitives = {}
bot.viewer = new EventEmitter()
bot.viewer.erase = (id) => {
delete primitives[id]
for (const socket of sockets) {
socket.emit('primitive', { id })
}
}
bot.viewer.drawBoxGrid = (id, start, end, color = 'aqua') => {
primitives[id] = { type: 'boxgrid', id, start, end, color }
for (const socket of sockets) {
socket.emit('primitive', primitives[id])
}
}
bot.viewer.drawLine = (id, points, color = 0xff0000) => {
primitives[id] = { type: 'line', id, points, color }
for (const socket of sockets) {
socket.emit('primitive', primitives[id])
}
}
bot.viewer.drawPoints = (id, points, color = 0xff0000, size = 5) => {
primitives[id] = { type: 'points', id, points, color, size }
for (const socket of sockets) {
socket.emit('primitive', primitives[id])
}
}
io.on('connection', (socket) => {
socket.emit('version', bot.version)
sockets.push(socket)
const worldView = new WorldDataEmitter(bot.world, viewDistance, bot.entity.position, socket)
worldView.init(bot.entity.position)
worldView.on('blockClicked', (block, face, button) => {
bot.viewer.emit('blockClicked', block, face, button)
})
for (const id in primitives) {
socket.emit('primitive', primitives[id])
}
function botPosition () {
const packet = { pos: bot.entity.position, yaw: bot.entity.yaw, addMesh: true }
if (firstPerson) {
packet.pitch = bot.entity.pitch
}
socket.emit('position', packet)
worldView.updatePosition(bot.entity.position)
}
bot.on('move', botPosition)
worldView.listenToBot(bot)
socket.on('disconnect', () => {
bot.removeListener('move', botPosition)
worldView.removeListenersFromBot(bot)
sockets.splice(sockets.indexOf(socket), 1)
})
})
http.listen(port, () => {
console.log(`Prismarine viewer web server running on *:${port}`)
})
bot.viewer.close = () => {
http.close()
for (const socket of sockets) {
socket.disconnect()
}
}
}

View file

@ -0,0 +1,52 @@
const { Vec3 } = require('vec3')
module.exports = ({ version, world, center = new Vec3(0, 0, 0), viewDistance = 4, port = 3000, prefix = '' }) => {
const express = require('express')
const app = express()
const http = require('http').createServer(app)
const io = require('socket.io')(http)
const { setupRoutes } = require('./common')
setupRoutes(app, prefix)
const sockets = []
const viewer = { world }
async function sendChunks (sockets) {
const cx = Math.floor(center.x / 16)
const cz = Math.floor(center.z / 16)
for (let x = cx - viewDistance; x <= cx + viewDistance; x++) {
for (let z = cz - viewDistance; z <= cz + viewDistance; z++) {
const chunk = (await viewer.world.getColumn(x, z)).toJson()
for (const socket of sockets) {
socket.emit('loadChunk', { x: x * 16, z: z * 16, chunk })
}
}
}
}
viewer.update = () => {
sendChunks(sockets)
}
io.on('connection', (socket) => {
socket.emit('version', version)
sockets.push(socket)
sendChunks([socket])
socket.emit('position', { pos: center, addMesh: false })
socket.on('disconnect', () => {
sockets.splice(sockets.indexOf(socket), 1)
})
})
http.listen(port, () => {
console.log(`Prismarine viewer web server running on *:${port}`)
})
return viewer
}

View file

@ -1,5 +1,5 @@
{
"name": "renderer",
"name": "prismarine-viewer",
"version": "1.25.0",
"description": "Web based viewer",
"main": "index.js",
@ -16,6 +16,7 @@
},
"dependencies": {
"@tweenjs/tween.js": "^20.0.3",
"live-server": "^1.2.2",
"assert": "^2.0.0",
"buffer": "^6.0.3",
"filesize": "^10.0.12",
@ -26,7 +27,7 @@
"prismarine-block": "^1.7.3",
"prismarine-chunk": "^1.22.0",
"prismarine-schematic": "^1.2.0",
"renderer": "link:./",
"prismarine-viewer": "link:./",
"process": "^0.11.10",
"socket.io": "^4.0.0",
"socket.io-client": "^4.0.0",
@ -38,8 +39,5 @@
"optionalDependencies": {
"canvas": "^2.11.2",
"node-canvas-webgl": "^0.3.0"
},
"devDependencies": {
"live-server": "^1.2.2"
}
}

View file

@ -1,27 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<title>Renderer Playground</title>
<title>Prismarine Viewer Playground</title>
<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover'>
<style type="text/css">
html {
overflow: hidden;
background: black;
user-select: none;
touch-action: none;
}
html, body {
height: 100%;
touch-action: none;
margin: 0;
padding: 0;
}
* {
user-select: none;
-webkit-user-select: none;
}
canvas {
height: 100%;
width: 100%;
@ -34,18 +30,10 @@
font-family: mojangles;
src: url(../../../assets/mojangles.ttf);
}
* {
user-select: none;
}
</style>
<script>
if (window.location.pathname.endsWith('playground')) {
// add trailing slash
window.location.href = `${window.location.origin}${window.location.pathname}/${window.location.search}`
}
</script>
</head>
<body>
<div id="root"></div>
<script type="text/javascript" src="playground.js"></script>
</body>
</html>

View file

@ -0,0 +1,3 @@
export const sharedPlaygroundMainOptions = {
alias: {}
}

View file

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

View file

@ -1,6 +1,6 @@
export function buildCleanupDecorator (cleanupMethod: string) {
return function () {
return function (_target: { snapshotInitialValues }, propertyKey: string) {
return function (_target: {snapshotInitialValues}, propertyKey: string) {
const target = _target as any
// Store the initial value of the property
if (!target._snapshotMethodPatched) {
@ -19,8 +19,7 @@ export function buildCleanupDecorator (cleanupMethod: string) {
for (const key of target._toCleanup) {
this[key] = this._initialValues[key]
}
// eslint-disable-next-line prefer-rest-params
Reflect.apply(originalMethod, this, arguments)
originalMethod.apply(this, arguments)
}
}
target._cleanupPatched = true

923
prismarine-viewer/viewer/lib/controls.js vendored Normal file
View file

@ -0,0 +1,923 @@
/* eslint-disable */
// Similar to THREE MapControls with more Minecraft-like
// controls.
// Defaults:
// Shift = Move Down, Space = Move Up
// W/Z - north, S - south, A/Q - west, D - east
const STATE = {
NONE: -1,
ROTATE: 0,
DOLLY: 1,
PAN: 2,
TOUCH_ROTATE: 3,
TOUCH_PAN: 4,
TOUCH_DOLLY_PAN: 5,
TOUCH_DOLLY_ROTATE: 6
}
class MapControls {
constructor(camera, domElement) {
this.enabled = true
this.object = camera
this.element = domElement
// Mouse buttons
this.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN }
// Touch fingers
this.touches = { ONE: THREE.TOUCH.ROTATE, TWO: THREE.TOUCH.DOLLY_PAN }
this.controlMap = {
MOVE_FORWARD: ['KeyW', 'KeyZ'],
MOVE_BACKWARD: 'KeyS',
MOVE_LEFT: ['KeyA', 'KeyQ'],
MOVE_RIGHT: 'KeyD',
MOVE_DOWN: 'ShiftLeft',
MOVE_UP: 'Space'
}
this.target = new THREE.Vector3()
// How far you can dolly in and out ( PerspectiveCamera only )
this.minDistance = 0
this.maxDistance = Infinity
// How far you can zoom in and out ( OrthographicCamera only )
this.minZoom = 0
this.maxZoom = Infinity
// How far you can orbit vertically, upper and lower limits.
// Range is 0 to Math.PI radians.
this.minPolarAngle = 0 // radians
this.maxPolarAngle = Math.PI // radians
// How far you can orbit horizontally, upper and lower limits.
// If set, the interval [ min, max ] must be a sub-interval of [ - 2 PI, 2 PI ], with ( max - min < 2 PI )
this.minAzimuthAngle = -Infinity // radians
this.maxAzimuthAngle = Infinity // radians
// Set to true to enable damping (inertia)
// If damping is enabled, you must call controls.update() in your animation loop
this.enableDamping = false
this.dampingFactor = 0.01
// This option actually enables dollying in and out; left as "zoom" for backwards compatibility.
// Set to false to disable zooming
this.enableZoom = true
this.enableTouchZoom = true
this.zoomSpeed = 1.0
// Set to false to disable rotating
this.enableRotate = true
this.enableTouchRotate = true
this.rotateSpeed = 1.0
// Set to false to disable panning
this.enablePan = true
this.enableTouchPan = true
this.panSpeed = 1.0
this.screenSpacePanning = false // if false, pan orthogonal to world-space direction camera.up
this.keyPanDistance = 32 // how far to pan
this.keyPanSpeed = 10 // pixels moved per arrow key push
this.verticalTranslationSpeed = 0.5 // how much Y increments moving up/down
this.keyDowns = []
// State-related stuff
this.changeEvent = { type: 'change' }
this.startEvent = { type: 'start' }
this.endEvent = { type: 'end' }
this.state = STATE.NONE
this.EPS = 0.000001
this.spherical = new THREE.Spherical()
this.sphericalDelta = new THREE.Spherical()
this.scale = 1
this.panOffset = new THREE.Vector3()
this.zoomChanged = false
this.rotateStart = new THREE.Vector2()
this.rotateEnd = new THREE.Vector2()
this.rotateDelta = new THREE.Vector2()
this.panStart = new THREE.Vector2()
this.panEnd = new THREE.Vector2()
this.panDelta = new THREE.Vector2()
this.dollyStart = new THREE.Vector2()
this.dollyEnd = new THREE.Vector2()
this.dollyDelta = new THREE.Vector2()
// for reset
this.target0 = this.target.clone()
this.position0 = this.object.position.clone()
this.zoom0 = this.object.zoom
this.ticks = 0
// register event handlers
this.onPointerMove = this.onPointerMove.bind(this)
this.onPointerUp = this.onPointerUp.bind(this)
this.onPointerDown = this.onPointerDown.bind(this)
this.onMouseWheel = this.onMouseWheel.bind(this)
this.onTouchStart = this.onTouchStart.bind(this)
this.onTouchEnd = this.onTouchEnd.bind(this)
this.onTouchMove = this.onTouchMove.bind(this)
this.onContextMenu = this.onContextMenu.bind(this)
this.onKeyDown = this.onKeyDown.bind(this)
this.onKeyUp = this.onKeyUp.bind(this)
this.registerHandlers()
}
//#region Public Methods
setRotationOrigin(position) {
this.target = position.clone()
}
unsetRotationOrigin() {
this.target = new THREE.Vector3()
}
getPolarAngle() {
return this.spherical.phi
}
getAzimuthalAngle() {
return this.spherical.theta
}
saveState() {
this.target0.copy(this.target)
this.position0.copy(this.object.position)
this.zoom0 = this.object.zoom
}
reset() {
this.target.copy(this.target0)
this.object.position.copy(this.position0)
this.object.zoom = this.zoom0
this.object.updateProjectionMatrix()
this.dispatchEvent(this.changeEvent)
this.update(true)
this.state = STATE.NONE
}
// this method is exposed, but perhaps it would be better if we can make it private...
update(force) {
// tick controls if called from render loop
if (!force) {
this.tickControls()
}
var offset = new THREE.Vector3()
// so camera.up is the orbit axis
var quat = new THREE.Quaternion().setFromUnitVectors(this.object.up, new THREE.Vector3(0, 1, 0))
var quatInverse = quat.clone().invert()
var lastPosition = new THREE.Vector3()
var lastQuaternion = new THREE.Quaternion()
var twoPI = 2 * Math.PI
var position = this.object.position
offset.copy(position).sub(this.target)
// rotate offset to "y-axis-is-up" space
offset.applyQuaternion(quat)
// angle from z-axis around y-axis
this.spherical.setFromVector3(offset)
if (this.autoRotate && this.state === STATE.NONE) {
this.rotateLeft(this.getAutoRotationAngle())
}
if (this.enableDamping) {
this.spherical.theta += this.sphericalDelta.theta * this.dampingFactor
this.spherical.phi += this.sphericalDelta.phi * this.dampingFactor
} else {
this.spherical.theta += this.sphericalDelta.theta
this.spherical.phi += this.sphericalDelta.phi
}
// restrict theta to be between desired limits
var min = this.minAzimuthAngle
var max = this.maxAzimuthAngle
if (isFinite(min) && isFinite(max)) {
if (min < - Math.PI) min += twoPI; else if (min > Math.PI) min -= twoPI
if (max < - Math.PI) max += twoPI; else if (max > Math.PI) max -= twoPI
if (min < max) {
this.spherical.theta = Math.max(min, Math.min(max, this.spherical.theta))
} else {
this.spherical.theta = (this.spherical.theta > (min + max) / 2) ?
Math.max(min, this.spherical.theta) :
Math.min(max, this.spherical.theta)
}
}
// restrict phi to be between desired limits
this.spherical.phi = Math.max(this.minPolarAngle, Math.min(this.maxPolarAngle, this.spherical.phi))
this.spherical.makeSafe()
this.spherical.radius *= this.scale
// restrict radius to be between desired limits
this.spherical.radius = Math.max(this.minDistance, Math.min(this.maxDistance, this.spherical.radius))
// move target to panned location
if (this.enableDamping === true) {
this.target.addScaledVector(this.panOffset, this.dampingFactor)
} else {
this.target.add(this.panOffset)
}
offset.setFromSpherical(this.spherical)
// rotate offset back to "camera-up-vector-is-up" space
offset.applyQuaternion(quatInverse)
position.copy(this.target).add(offset)
this.object.lookAt(this.target)
if (this.enableDamping === true) {
this.sphericalDelta.theta *= (1 - this.dampingFactor)
this.sphericalDelta.phi *= (1 - this.dampingFactor)
this.panOffset.multiplyScalar(1 - this.dampingFactor)
} else {
this.sphericalDelta.set(0, 0, 0)
this.panOffset.set(0, 0, 0)
}
this.scale = 1
// update condition is:
// min(camera displacement, camera rotation in radians)^2 > EPS
// using small-angle approximation cos(x/2) = 1 - x^2 / 8
if (this.zoomChanged ||
lastPosition.distanceToSquared(this.object.position) > this.EPS ||
8 * (1 - lastQuaternion.dot(this.object.quaternion)) > this.EPS) {
this.dispatchEvent(this.changeEvent)
lastPosition.copy(this.object.position)
lastQuaternion.copy(this.object.quaternion)
this.zoomChanged = false
return true
}
return false
}
//#endregion
//#region Orbit Controls
getAutoRotationAngle() {
return 2 * Math.PI / 60 / 60 * this.autoRotateSpeed
}
getZoomScale() {
return Math.pow(0.95, this.zoomSpeed)
}
rotateLeft(angle) {
this.sphericalDelta.theta -= angle
}
rotateUp(angle) {
this.sphericalDelta.phi -= angle
}
panLeft(distance, objectMatrix) {
let v = new THREE.Vector3()
v.setFromMatrixColumn(objectMatrix, 0) // get X column of objectMatrix
v.multiplyScalar(- distance)
this.panOffset.add(v)
}
panUp(distance, objectMatrix) {
let v = new THREE.Vector3()
if (this.screenSpacePanning === true) {
v.setFromMatrixColumn(objectMatrix, 1)
} else {
v.setFromMatrixColumn(objectMatrix, 0)
v.crossVectors(this.object.up, v)
}
v.multiplyScalar(distance)
this.panOffset.add(v)
}
// Patch - translate Y
translateY(delta) {
this.panOffset.y += delta
}
// deltaX and deltaY are in pixels; right and down are positive
pan(deltaX, deltaY, distance) {
let offset = new THREE.Vector3()
if (this.object.isPerspectiveCamera) {
// perspective
var position = this.object.position
offset.copy(position).sub(this.target)
var targetDistance = offset.length()
// half of the fov is center to top of screen
targetDistance *= Math.tan((this.object.fov / 2) * Math.PI / 180.0)
targetDistance = distance || targetDistance
// we use only clientHeight here so aspect ratio does not distort speed
this.panLeft(2 * deltaX * targetDistance / this.element.clientHeight, this.object.matrix)
this.panUp(2 * deltaY * targetDistance / this.element.clientHeight, this.object.matrix)
} else if (this.object.isOrthographicCamera) {
// orthographic
this.panLeft(deltaX * (this.object.right - this.object.left) / this.object.zoom / this.element.clientWidth, this.object.matrix)
this.panUp(deltaY * (this.object.top - this.object.bottom) / this.object.zoom / this.element.clientHeight, this.object.matrix)
} else {
// camera neither orthographic nor perspective
console.warn('WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.')
this.enablePan = false
}
}
dollyOut(dollyScale) {
if (this.object.isPerspectiveCamera) {
this.scale /= dollyScale
} else if (this.object.isOrthographicCamera) {
this.object.zoom = Math.max(this.minZoom, Math.min(this.maxZoom, this.object.zoom * dollyScale))
this.object.updateProjectionMatrix()
this.zoomChanged = true
} else {
console.warn('WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.')
this.enableZoom = false
}
}
dollyIn(dollyScale) {
if (this.object.isPerspectiveCamera) {
this.scale *= dollyScale
} else if (this.object.isOrthographicCamera) {
this.object.zoom = Math.max(this.minZoom, Math.min(this.maxZoom, this.object.zoom / dollyScale))
this.object.updateProjectionMatrix()
this.zoomChanged = true
} else {
console.warn('WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.')
this.enableZoom = false
}
}
//#endregion
//#region Event Callbacks - update the object state
handleMouseDownRotate(event) {
this.rotateStart.set(event.clientX, event.clientY)
}
handleMouseDownDolly(event) {
this.dollyStart.set(event.clientX, event.clientY)
}
handleMouseDownPan(event) {
this.panStart.set(event.clientX, event.clientY)
}
handleMouseMoveRotate(event) {
this.rotateEnd.set(event.clientX, event.clientY)
this.rotateDelta.subVectors(this.rotateEnd, this.rotateStart).multiplyScalar(this.rotateSpeed)
this.rotateLeft(2 * Math.PI * this.rotateDelta.x / this.element.clientHeight) // yes, height
this.rotateUp(2 * Math.PI * this.rotateDelta.y / this.element.clientHeight)
this.rotateStart.copy(this.rotateEnd)
this.update(true)
}
handleMouseMoveDolly(event) {
this.dollyEnd.set(event.clientX, event.clientY)
this.dollyDelta.subVectors(this.dollyEnd, this.dollyStart)
if (this.dollyDelta.y > 0) {
this.dollyOut(this.getZoomScale())
} else if (this.dollyDelta.y < 0) {
this.dollyIn(this.getZoomScale())
}
this.dollyStart.copy(this.dollyEnd)
this.update(true)
}
handleMouseMovePan(event) {
this.panEnd.set(event.clientX, event.clientY)
this.panDelta.subVectors(this.panEnd, this.panStart).multiplyScalar(this.panSpeed)
this.pan(this.panDelta.x, this.panDelta.y)
this.panStart.copy(this.panEnd)
this.update(true)
}
handleMouseUp(/*event*/) {
// no-op
}
handleMouseWheel(event) {
if (event.deltaY < 0) {
this.dollyIn(this.getZoomScale())
} else if (event.deltaY > 0) {
this.dollyOut(this.getZoomScale())
}
this.update(true)
}
//#endregion
//#region Mouse/Keyboard handlers
// Called when the cursor location has moved
onPointerMove(event) {
if (!this.enabled || (this.state == STATE.NONE)) return
switch (event.pointerType) {
case 'mouse':
case 'pen':
this.onMouseMove(event)
break
// TODO touch
}
}
// Called when the cursor is no longer behind held
onPointerUp(event) {
if (!this.enabled) return
switch (event.pointerType) {
case 'mouse':
case 'pen':
this.onMouseUp(event)
break
// TODO touch
}
}
// On left click or tap
onPointerDown(event) {
if (!this.enabled) return
switch (event.pointerType) {
case 'mouse':
case 'pen':
this.onMouseDown(event)
break
// TODO touch
}
}
onMouseDown(event) {
// Prevent the browser from scrolling.
event.preventDefault()
// Manually set the focus since calling preventDefault above
// prevents the browser from setting it automatically.
this.element.focus ? this.element.focus() : window.focus()
var mouseAction
switch (event.button) {
case 0:
mouseAction = this.mouseButtons.LEFT
break
case 1:
mouseAction = this.mouseButtons.MIDDLE
break
case 2:
mouseAction = this.mouseButtons.RIGHT
break
default:
mouseAction = - 1
}
switch (mouseAction) {
case THREE.MOUSE.DOLLY:
if (this.enableZoom === false) return
this.handleMouseDownDolly(event)
this.state = STATE.DOLLY
break
case THREE.MOUSE.ROTATE:
if (event.ctrlKey || event.metaKey || event.shiftKey) {
if (this.enablePan === false) return
this.handleMouseDownPan(event)
this.state = STATE.PAN
} else {
if (this.enableRotate === false) return
this.handleMouseDownRotate(event)
this.state = STATE.ROTATE
}
break
case THREE.MOUSE.PAN:
if (event.ctrlKey || event.metaKey || event.shiftKey) {
if (this.enableRotate === false) return
this.handleMouseDownRotate(event)
this.state = STATE.ROTATE
} else {
if (this.enablePan === false) return
this.handleMouseDownPan(event)
this.state = STATE.PAN
}
break
default:
this.state = STATE.NONE
}
}
onMouseMove(event) {
if (this.enabled === false) return
event.preventDefault()
switch (this.state) {
case STATE.ROTATE:
if (this.enableRotate === false) return
this.handleMouseMoveRotate(event)
break
case STATE.DOLLY:
if (this.enableZoom === false) return
this.handleMouseMoveDolly(event)
break
case STATE.PAN:
if (this.enablePan === false) return
this.handleMouseMovePan(event)
break
}
}
onMouseUp(event) {
this.state = STATE.NONE
}
onMouseWheel(event) {
if (this.enabled === false || this.enableZoom === false || (this.state !== STATE.NONE && this.state !== STATE.ROTATE)) return
event.preventDefault()
event.stopPropagation()
this.dispatchEvent(this.startEvent)
this.handleMouseWheel(event)
this.dispatchEvent(this.endEvent)
}
//#endregion
//#region Touch handlers
handleTouchStartRotate(event) {
if (event.touches.length == 1) {
this.rotateStart.set(event.touches[0].pageX, event.touches[0].pageY)
} else {
var x = 0.5 * (event.touches[0].pageX + event.touches[1].pageX)
var y = 0.5 * (event.touches[0].pageY + event.touches[1].pageY)
this.rotateStart.set(x, y)
}
}
handleTouchStartPan(event) {
if (event.touches.length == 1) {
this.panStart.set(event.touches[0].pageX, event.touches[0].pageY)
} else {
var x = 0.5 * (event.touches[0].pageX + event.touches[1].pageX)
var y = 0.5 * (event.touches[0].pageY + event.touches[1].pageY)
this.panStart.set(x, y)
}
}
handleTouchStartDolly(event) {
var dx = event.touches[0].pageX - event.touches[1].pageX
var dy = event.touches[0].pageY - event.touches[1].pageY
var distance = Math.sqrt(dx * dx + dy * dy)
this.dollyStart.set(0, distance)
}
handleTouchStartDollyPan(event) {
if (this.enableTouchZoom) this.handleTouchStartDolly(event)
if (this.enableTouchPan) this.handleTouchStartPan(event)
}
handleTouchStartDollyRotate(event) {
if (this.enableTouchZoom) this.handleTouchStartDolly(event)
if (this.enableTouchRotate) this.handleTouchStartRotate(event)
}
handleTouchMoveRotate(event) {
if (event.touches.length == 1) {
this.rotateEnd.set(event.touches[0].pageX, event.touches[0].pageY)
} else {
var x = 0.5 * (event.touches[0].pageX + event.touches[1].pageX)
var y = 0.5 * (event.touches[0].pageY + event.touches[1].pageY)
this.rotateEnd.set(x, y)
}
this.rotateDelta.subVectors(this.rotateEnd, this.rotateStart).multiplyScalar(this.rotateSpeed)
this.rotateLeft(2 * Math.PI * this.rotateDelta.x / this.element.clientHeight) // yes, height
this.rotateUp(2 * Math.PI * this.rotateDelta.y / this.element.clientHeight)
this.rotateStart.copy(this.rotateEnd)
}
handleTouchMovePan(event) {
if (event.touches.length == 1) {
this.panEnd.set(event.touches[0].pageX, event.touches[0].pageY)
} else {
var x = 0.5 * (event.touches[0].pageX + event.touches[1].pageX)
var y = 0.5 * (event.touches[0].pageY + event.touches[1].pageY)
this.panEnd.set(x, y)
}
this.panDelta.subVectors(this.panEnd, this.panStart).multiplyScalar(this.panSpeed)
this.pan(this.panDelta.x, this.panDelta.y)
this.panStart.copy(this.panEnd)
}
handleTouchMoveDolly(event) {
var dx = event.touches[0].pageX - event.touches[1].pageX
var dy = event.touches[0].pageY - event.touches[1].pageY
var distance = Math.sqrt(dx * dx + dy * dy)
this.dollyEnd.set(0, distance)
this.dollyDelta.set(0, Math.pow(this.dollyEnd.y / this.dollyStart.y, this.zoomSpeed))
this.dollyOut(this.dollyDelta.y)
this.dollyStart.copy(this.dollyEnd)
}
handleTouchMoveDollyPan(event) {
if (this.enableTouchZoom) this.handleTouchMoveDolly(event)
if (this.enableTouchPan) this.handleTouchMovePan(event)
}
handleTouchMoveDollyRotate(event) {
if (this.enableTouchZoom) this.handleTouchMoveDolly(event)
if (this.enableTouchRotate) this.handleTouchMoveRotate(event)
}
handleTouchEnd( /*event*/) {
// no-op
}
//#endregion
tickControls() {
const control = this.controlMap
for (var keyCode of this.keyDowns) {
if (control.MOVE_FORWARD.includes(keyCode)) {
this.pan(0, this.keyPanSpeed, this.keyPanDistance)
} else if (control.MOVE_BACKWARD.includes(keyCode)) {
this.pan(0, -this.keyPanSpeed, this.keyPanDistance)
} else if (control.MOVE_LEFT.includes(keyCode)) {
this.pan(this.keyPanSpeed, 0, this.keyPanDistance)
} else if (control.MOVE_RIGHT.includes(keyCode)) {
this.pan(-this.keyPanSpeed, 0, this.keyPanDistance)
} else if (control.MOVE_UP.includes(keyCode)) {
this.translateY(+this.verticalTranslationSpeed)
} else if (control.MOVE_DOWN.includes(keyCode)) {
this.translateY(-this.verticalTranslationSpeed)
}
}
}
onKeyDown(e) {
if (!this.enabled) return
if (e.code && !this.keyDowns.includes(e.code)) {
this.keyDowns.push(e.code)
// console.debug('[control] Key down: ', this.keyDowns)
}
}
onKeyUp(event) {
// console.log('[control] Key up: ', event.code, this.keyDowns)
this.keyDowns = this.keyDowns.filter(code => code != event.code)
}
onTouchStart(event) {
if (this.enabled === false) return
event.preventDefault() // prevent scrolling
switch (event.touches.length) {
case 1:
switch (this.touches.ONE) {
case THREE.TOUCH.ROTATE:
if (this.enableTouchRotate === false) return
this.handleTouchStartRotate(event)
this.state = STATE.TOUCH_ROTATE
break
case THREE.TOUCH.PAN:
if (this.enableTouchPan === false) return
this.handleTouchStartPan(event)
this.state = STATE.TOUCH_PAN
break
default:
this.state = STATE.NONE
}
break
case 2:
switch (this.touches.TWO) {
case THREE.TOUCH.DOLLY_PAN:
if (this.enableTouchZoom === false && this.enableTouchPan === false) return
this.handleTouchStartDollyPan(event)
this.state = STATE.TOUCH_DOLLY_PAN
break
case THREE.TOUCH.DOLLY_ROTATE:
if (this.enableTouchZoom === false && this.enableTouchRotate === false) return
this.handleTouchStartDollyRotate(event)
this.state = STATE.TOUCH_DOLLY_ROTATE
break
default:
this.state = STATE.NONE
}
break
default:
this.state = STATE.NONE
}
if (this.state !== STATE.NONE) {
this.dispatchEvent(this.startEvent)
}
}
onTouchMove(event) {
if (this.enabled === false) return
event.preventDefault() // prevent scrolling
event.stopPropagation()
switch (this.state) {
case STATE.TOUCH_ROTATE:
if (this.enableTouchRotate === false) return
this.handleTouchMoveRotate(event)
this.update()
break
case STATE.TOUCH_PAN:
if (this.enableTouchPan === false) return
this.handleTouchMovePan(event)
this.update()
break
case STATE.TOUCH_DOLLY_PAN:
if (this.enableTouchZoom === false && this.enableTouchPan === false) return
this.handleTouchMoveDollyPan(event)
this.update()
break
case STATE.TOUCH_DOLLY_ROTATE:
if (this.enableTouchZoom === false && this.enableTouchRotate === false) return
this.handleTouchMoveDollyRotate(event)
this.update()
break
default:
this.state = STATE.NONE
}
}
onTouchEnd(event) {
if (this.enabled === false) return
this.handleTouchEnd(event)
this.dispatchEvent(this.endEvent)
this.state = STATE.NONE
}
onContextMenu(event) {
// Disable context menu
if (this.enabled) event.preventDefault()
}
registerHandlers() {
this.element.addEventListener('pointermove', this.onPointerMove, false, {passive: true})
this.element.addEventListener('pointerup', this.onPointerUp, false, {passive: true})
this.element.addEventListener('pointerdown', this.onPointerDown, false, {passive: true})
this.element.addEventListener('wheel', this.onMouseWheel, true, {passive: true})
this.element.addEventListener('touchstart', this.onTouchStart, false, {passive: true})
this.element.addEventListener('touchend', this.onTouchEnd, false, {passive: true})
this.element.addEventListener('touchmove', this.onTouchMove, false, {passive: true})
this.element.ownerDocument.addEventListener('contextmenu', this.onContextMenu, false, {passive: true})
this.element.ownerDocument.addEventListener('keydown', this.onKeyDown, false, {passive: true})
this.element.ownerDocument.addEventListener('keyup', this.onKeyUp, false, {passive: true})
console.log('[controls] registered handlers', this.element)
}
unregisterHandlers() {
this.element.removeEventListener('pointermove', this.onPointerMove, false, {passive: true})
this.element.removeEventListener('pointerup', this.onPointerUp, false, {passive: true})
this.element.removeEventListener('pointerdown', this.onPointerDown, false, {passive: true})
this.element.removeEventListener('wheel', this.onMouseWheel, true, {passive: true})
this.element.removeEventListener('touchstart', this.onTouchStart, false, {passive: true})
this.element.removeEventListener('touchend', this.onTouchEnd, false, {passive: true})
this.element.removeEventListener('touchmove', this.onTouchMove, false, {passive: true})
this.element.ownerDocument.removeEventListener('contextmenu', this.onContextMenu, false, {passive: true})
this.element.ownerDocument.removeEventListener('keydown', this.onKeyDown, false, {passive: true})
this.element.ownerDocument.removeEventListener('keyup', this.onKeyUp, false, {passive: true})
console.log('[controls] unregistered handlers', this.element)
}
dispatchEvent() {
// no-op
}
}
module.exports = { MapControls }

View file

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

View file

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

View file

@ -0,0 +1,103 @@
import { PlayerAnimation } from 'skinview3d'
export class WalkingGeneralSwing extends PlayerAnimation {
switchAnimationCallback
isRunning = false
isMoving = true
_startArmSwing
swingArm () {
this._startArmSwing = this.progress
}
animate (player) {
// Multiply by animation's natural speed
let t
const updateT = () => {
if (!this.isMoving) {
t = 0
return
}
if (this.isRunning) {
t = this.progress * 10 + Math.PI * 0.5
} else {
t = this.progress * 8
}
}
updateT()
let reset = false
if ((this.isRunning ? Math.cos(t) : Math.sin(t)) < 0.01) {
if (this.switchAnimationCallback) {
reset = true
this.progress = 0
updateT()
}
}
if (this.isRunning) {
// Leg swing with larger amplitude
player.skin.leftLeg.rotation.x = Math.cos(t + Math.PI) * 1.3
player.skin.rightLeg.rotation.x = Math.cos(t) * 1.3
} else {
// Leg swing
player.skin.leftLeg.rotation.x = Math.sin(t) * 0.5
player.skin.rightLeg.rotation.x = Math.sin(t + Math.PI) * 0.5
}
if (this._startArmSwing) {
let tHand = (this.progress - this._startArmSwing) * 18 + Math.PI * 0.5
player.skin.rightArm.rotation.x = Math.cos(tHand) * 1.5
const basicArmRotationZ = Math.PI * 0.1
player.skin.rightArm.rotation.z = Math.cos(t + Math.PI) * 0.3 - basicArmRotationZ
if (tHand > Math.PI + Math.PI * 0.5) {
this._startArmSwing = null
player.skin.rightArm.rotation.z = 0
}
}
if (this.isRunning) {
player.skin.leftArm.rotation.x = Math.cos(t) * 1.5
if (!this._startArmSwing) {
player.skin.rightArm.rotation.x = Math.cos(t + Math.PI) * 1.5
}
const basicArmRotationZ = Math.PI * 0.1
player.skin.leftArm.rotation.z = Math.cos(t) * 0.1 + basicArmRotationZ
if (!this._startArmSwing) {
player.skin.rightArm.rotation.z = Math.cos(t + Math.PI) * 0.1 - basicArmRotationZ
}
} else {
// Arm swing
player.skin.leftArm.rotation.x = Math.sin(t + Math.PI) * 0.5
if (!this._startArmSwing) {
player.skin.rightArm.rotation.x = Math.sin(t) * 0.5
}
const basicArmRotationZ = Math.PI * 0.02
player.skin.leftArm.rotation.z = Math.cos(t) * 0.03 + basicArmRotationZ
if (!this._startArmSwing) {
player.skin.rightArm.rotation.z = Math.cos(t + Math.PI) * 0.03 - basicArmRotationZ
}
}
if (this.isRunning) {
player.rotation.z = Math.cos(t + Math.PI) * 0.01
}
if (this.isRunning) {
const basicCapeRotationX = Math.PI * 0.3
player.cape.rotation.x = Math.sin(t * 2) * 0.1 + basicCapeRotationX
} else {
// Always add an angle for cape around the x axis
const basicCapeRotationX = Math.PI * 0.06
player.cape.rotation.x = Math.sin(t / 1.5) * 0.06 + basicCapeRotationX
}
if (reset) {
this.switchAnimationCallback()
this.switchAnimationCallback = null
}
}
}

File diff suppressed because it is too large Load diff

View file

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

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