Compare commits

...
Sign in to create a new pull request.

412 commits

Author SHA1 Message Date
Vitaly Turovsky
253e094c74 add zardoy/mwc-proxy repo ref 2025-10-11 02:25:14 +03:00
Vitaly Turovsky
fef94f03fb feat: add support for alt+arrows navigation to navigate between commands only 2025-10-11 02:25:06 +03:00
Vitaly Turovsky
e9f91f8ecd feat: enable music by default, add slider for controlling its volume 2025-10-11 02:24:51 +03:00
Colbster937
634df8d03d
Add WebMC & WS changes (#431)
Co-authored-by: Colbster937 <96893162+colbychittenden@users.noreply.github.com>
2025-10-11 01:52:06 +03:00
Vitaly Turovsky
a88c8b5470 possible fix for rare edgecase where skins from server were not applied. Cause: renderer due to rare circumnstances could be loaded AFTER gameLoaded which is fired only when starting rendering 3d world. classic no existing data handling issue
why not mineflayerBotCreated? because getThreeJsRendererMethods not available at that time so would make things only much complex
2025-09-30 09:38:37 +03:00
Vitaly Turovsky
f51254d97a fix: dont stop local replay server with keep alive connection error 2025-09-30 07:20:30 +03:00
Vitaly Turovsky
05cd560d6b add shadow and directional light for player in inventory (model viewer) 2025-09-29 02:01:04 +03:00
Vitaly Turovsky
b239636356 feat: add debugServerPacketNames and debugClientPacketNames for quick access of names with intellisense of packets for current protocol. Should be used with window.inspectPacket in console 2025-09-28 22:04:17 +03:00
Vitaly Turovsky
4f421ae45f respect loadPlayerSkins option for inventory skin 2025-09-28 21:59:00 +03:00
Vitaly
3b94889bed
feat: make arrows colorful and metadata (#430)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-09-20 02:57:59 +03:00
Vitaly
636a7fdb54
feat: improve fog a little (#427) 2025-09-19 05:42:22 +03:00
Vitaly Turovsky
c930365e32 fix sometimes inventory player should not be rendered 2025-09-18 07:49:44 +03:00
Vitaly Turovsky
852dd737ae fix: fix some UI like error screen was not visible fully (buttons were clipped behind the screen) on larger scale on large screens 2025-09-11 22:24:04 +03:00
Vitaly Turovsky
06dc3cb033 feat: Add saveLoginPassword option to control password saving behavior in browser for offline auth on servers 2025-09-08 05:38:16 +03:00
Vitaly Turovsky
c4097975bf add a way to disable sky box for old behavior (not tested) 2025-09-08 05:29:34 +03:00
Vitaly Turovsky
1525fac2a1 fix: some visual camera world view issues (visible lines between blocks) 2025-09-08 05:22:24 +03:00
Vitaly Turovsky
f24cb49a87 up lockfile 2025-09-08 04:55:43 +03:00
Vitaly Turovsky
0b1183f541 up minecraft-data 2025-09-08 04:36:09 +03:00
Vitaly Turovsky
739a6fad24 fix lockfile 2025-09-08 04:34:17 +03:00
Vitaly Turovsky
7f7a14ac65 feat: Add overlay model viewer. Already integrated into inventory to display player! 2025-09-08 04:19:38 +03:00
Vitaly
265d02d18d up protocol for 1.21.8 2025-09-07 18:23:13 +00:00
Vitaly
b2e36840b9
feat: brand new default skybox with fog, better daycycle and colors (#425)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-05 05:02:54 +03:00
Vitaly
7043bf49f3
fix: adding support for newer skin profile data structure in player heads (#424) 2025-09-04 21:55:34 +03:00
Vitaly
528d8f516b
Update worldrendererThree.ts 2025-09-04 21:55:02 +03:00
Kesuaheli
70534d8b5a
fix: adding support for newer skin profile data structure in player heads 2025-09-04 12:51:56 +02:00
Vitaly Turovsky
9d54c70fb7 use node 22 2025-09-02 19:05:18 +03:00
Vitaly Turovsky
7e3ba8bece up integrated server for the latest fixes and better stability 2025-09-02 19:02:30 +03:00
Vitaly
513201be87 up browserify 2025-09-01 08:56:08 +03:00
Vitaly Turovsky
cb82188272 add addPing query param for testing 2025-08-31 19:31:26 +03:00
Vitaly Turovsky
d0d5234ba4 fix: stop right click emulation once window got opened eg chest 2025-08-31 18:31:49 +03:00
Vitaly Turovsky
e81d608554 fix cname 2025-08-27 19:52:09 +03:00
Vitaly Turovsky
1f240d8c20 up mouse allowing to disable positive break block 2025-08-27 19:50:53 +03:00
Vitaly Turovsky
2a1746eb7a [skip ci] fix repository name 2025-08-27 13:33:39 +03:00
Vitaly Turovsky
9718610131 ci: add deployment step for mcw-mcraft-page repository in GitHub Actions 2025-08-27 12:08:20 +03:00
Vitaly Turovsky
8f62fbd4da fix: window title sometimes was not showing up on old versions 2025-08-26 13:50:36 +03:00
Vitaly Turovsky
bc2972fe99 fix registering custom channels too late (a few ms diff) 2025-08-24 19:53:44 +03:00
Vitaly
a12c61bc6c
add simple monaco (#418) 2025-08-21 13:21:02 +03:00
Vitaly Turovsky
6e0d54ea17 up mc-protocol patch 2025-08-20 20:45:02 +03:00
Vitaly Turovsky
72e9e656cc new! helpful errors on custom channels payloads! 2025-08-20 20:42:40 +03:00
Vitaly Turovsky
4a5f2e799c disable experimentalClientSelfReload by default until it's reworked with more fine-tuned checks against server connection 2025-08-20 20:02:57 +03:00
Vitaly
a8fa3d47d1 up protocol & mineflayer for 1.21.6 2025-08-19 12:49:33 +03:00
Vitaly Turovsky
9a84a7acfb do less annoying logging 2025-08-18 11:37:20 +03:00
Vitaly Turovsky
d6eb1601e9 disable remote sounds by default 2025-08-16 09:16:29 +03:00
Vitaly Turovsky
e1293b6cb3 - Introduced a patchAssets script to apply custom textures to the blocks and items atlases.
- Enhanced the ThreeJsSound class to support sound playback timeout and volume adjustments.
- Added a custom sound system to handle named sound effects with metadata.
2025-08-16 09:15:37 +03:00
Vitaly Turovsky
cc4f705aea feat: new experimental chunk loading logic by forcing into spiral queue
feat: add highly experimental logic to try to self-restore any issues with chunks loading by automating f3+a action. write debug info into chat for now. can be disabled
feat: rework chunks debug screen showing actually useful information now
2025-08-15 07:46:52 +03:00
Vitaly Turovsky
54c114a702 feat(big): items are now rendered in 3d not in 2d and it makes insanely huge difference on the game visuals 2025-08-15 05:26:11 +03:00
Vitaly Turovsky
65575e2665 rm faulty mineflayer ver, fix kickin in singleplayer 2025-08-15 01:33:37 +03:00
Vitaly
1ddaa79162
rm not tested pathfinder (#415) 2025-08-15 01:12:24 +03:00
Vitaly Turovsky
e2b141cca0 fix a few packet specific errors 2025-08-14 04:51:37 +03:00
Vitaly
15e3325971 add param for testing for immediate reconnect after kick or error (warning: will cause infinite reload loop) 2025-08-14 01:25:24 +03:00
Vitaly Turovsky
60fc5ef315 feat: add skybox renderer: test it by dragging an image window into window, fix waypoint block pos 2025-08-13 19:19:46 +03:00
Vitaly
8827aab981 dont add test waypoints on dev 2025-08-12 06:27:42 +03:00
Vitaly
0a474e6780 feat: add custom experimental waypints impl 2025-08-12 06:27:06 +03:00
Vitaly Turovsky
cdd8c31a0e fix: fix player colored username rendering, fix sometimes skin was overriden 2025-08-11 21:21:44 +03:00
Vitaly Turovsky
e7c358d3fc feat: add minecraft-web-client:block-interactions-customization 2025-08-11 03:12:05 +03:00
Vitaly Turovsky
fb395041b9 fix: fix on 1.18.2 many blocks like mushrom blocks, fence gates, deepslate, basalt, copper stuff like ore, infested stone, cakes and tinted glass was resulting in instant breaking on the client
dev: add debugTestPing
2025-08-11 01:39:08 +03:00
Vitaly Turovsky
353ba2ecb3 fix: some blocks textures were not update in hotbar after texturepack change 2025-08-08 21:52:55 +03:00
Vitaly Turovsky
53cbff7699 dont conflict fire with chat 2025-08-08 18:37:10 +03:00
Vitaly Turovsky
caf4695637 feat: silly player on fire renderer effect 2025-08-08 18:33:20 +03:00
Vitaly
167b49da08
fix: fix cannot write after stream was destroyed message (#413) 2025-08-08 02:07:52 +03:00
Vitaly Turovsky
d7bd26b6b5 up protocol patch 2025-08-06 01:47:09 +03:00
Vitaly Turovsky
d41527edc8 manually fix lockfile because of silly pnpm dep resolution 2025-08-03 03:33:37 +03:00
Vitaly Turovsky
24ab260e8e fix: up protocol to support 1.21.5 2025-08-03 03:20:38 +03:00
Vitaly
c4b284b9b7 fix: fix supported versions display in server menu 2025-08-02 21:34:33 +03:00
Kesu
67855ae25a
fix: fix some window titles (#401) 2025-07-27 15:24:26 +02:00
Vitaly Turovsky
b9c8ade9bf fix: fix chat was crashing sometimes 2025-07-20 10:06:57 +03:00
Max Lee
4d7e3df859
feat: Item projectiles support (#395) 2025-07-18 14:18:05 +03:00
Vitaly Turovsky
a498778703 always wait for config load so autoConnect works on remote config 2025-07-18 09:56:50 +03:00
Vitaly Turovsky
b6d4728c44 display disconnect always last 2025-07-18 09:44:50 +03:00
Vitaly Turovsky
0dca8bbbe5 fix(important): F3 actions didn't work on mobile at all like chunks reload 2025-07-18 08:32:32 +03:00
Vitaly Turovsky
de9bfba3a8 allow auto connect on mcraft for last integrations 2025-07-18 08:02:13 +03:00
Vitaly Turovsky
45408476a5 fix(appStorage): Fix that settings were not possible to save on vercel domains, use robust self-checking mechanism to ensure user data never lost when cookies storage enabled! 2025-07-18 07:53:47 +03:00
Vitaly Turovsky
c360115f60 fix: fix rare ios safari bug where hotbar would not be visible due to unclear fixed&bottom:0 css using 2025-07-18 07:46:25 +03:00
Max Lee
a8635e9e2f
fix: Effects and Game Indicators overlay toggles didn't work (#397) 2025-07-18 05:55:29 +03:00
Vitaly
5bd33a546a
More build configs & optimise reconnect and immediate game enter (#398)
feat(custom-builds): Add a way to bundle only specific minecraft version data, this does not affect assets though
env:
MIN_MC_VERSION
MAX_MC_VERSION
new SKIP_MC_DATA_RECIPES - if recipes are not used in game
fix: refactor QS params handling to ensure panorama & main menu never loaded when immedieate game enter action is expected (eg ?autoConnect=1)
2025-07-18 04:39:05 +03:00
Vitaly Turovsky
e9c7840dae feat(mobile): fix annoying issues with box and foods usage on screen hold 2025-07-16 16:18:15 +03:00
Vitaly
52c0c75ccf docs: update readme 2025-07-16 12:09:34 +03:00
Vitaly Turovsky
b2f2d85e4f feat(setting): add a way to specify default perspective view 2025-07-14 00:18:42 +03:00
Vitaly Turovsky
7a83a2a657 fix(important): fix all known issues wiht panorama crashing whole game in single file build (minecraft.html) 2025-07-14 00:13:51 +03:00
Vitaly Turovsky
64da602294 add creative server 2025-07-12 05:38:24 +03:00
Max Lee
a09cd7d3ed
fix: Nametag & sign text fixes (#391) 2025-07-11 17:56:37 +03:00
Max Lee
39aca1735e
fix: custom item model data on 1.21.4+ (#392) 2025-07-11 17:13:24 +03:00
Vitaly Turovsky
e9320c68d2 fix metrics port 2025-07-09 17:13:36 +03:00
Vitaly Turovsky
95cc0e6c74 reduce ram usage by 15% 2025-07-09 16:33:50 +03:00
Vitaly
826b24d9e2
Metrics server (#390) 2025-07-09 16:10:50 +03:00
Vitaly Turovsky
16609aa010 fix falsey settings apply 2025-07-08 16:35:05 +03:00
Vitaly Turovsky
09b0e2e493 add a way to disable some parts of bars ui via config and select them via devtool elem select 2025-07-08 15:42:06 +03:00
Vitaly Turovsky
c844b99cf2 fix(regression): fix chat completions were not visible on pc 2025-07-08 15:15:54 +03:00
Vitaly Turovsky
089f2224e2 fix(mobile): drop stack on hotbar hold
feat(config): add powerful way to disable some actions in the client entirely (eg opening inventory or dropping items)
2025-07-08 15:12:23 +03:00
Vitaly Turovsky
2f93c08b1e fix lint 2025-07-04 18:05:26 +03:00
Vitaly Turovsky
fa56d479b1 feat: add report bug button 2025-07-04 18:04:24 +03:00
Max Lee
f489c5f477
fix: skin from textures property would not show (#385) 2025-07-04 17:33:36 +03:00
Vitaly Turovsky
45bc76d825 hotfix(chat): fix all annoying issues on mobile. fix chat was not visible on mobile at all! 2025-07-04 17:29:55 +03:00
Vitaly Turovsky
01567ea589 add "mcraft.fun/debug-inputs.html" 2025-07-04 01:44:26 +03:00
Vitaly Turovsky
e8b0a34c0b fix: fix chat visual text alignment issues on chat opening 2025-07-03 19:52:49 +03:00
Vitaly Turovsky
5cfd301d10 fix: fix controls debug was not visible 2025-07-03 19:35:19 +03:00
Vitaly Turovsky
cdd23bc6a6 fix(critical): fix support for all versions above 1.20.2 in mineflayer dependency 2025-07-03 19:29:27 +03:00
Max Lee
4277c3a262
feat: Support nameTagVisibility option of teams (#373) 2025-07-03 18:15:24 +03:00
Vitaly
9f3d3f93fb
docs: update README to clarify BrowserStack testing (#384) 2025-07-03 18:01:38 +03:00
Vitaly Turovsky
7162d2f549 fix music crash 2025-07-02 19:22:47 +03:00
Vitaly Turovsky
fcf987efe4 always display reconnect button on mcraft.fun 2025-07-02 19:21:18 +03:00
Vitaly Turovsky
d112b01177 fix gameLoaded 2025-07-02 18:55:33 +03:00
Vitaly Turovsky
043e28ed97 add more visible integration trigger 2025-07-02 18:16:44 +03:00
Vitaly Turovsky
08fbc67c31 add update git deps script, fix inventory crashes 2025-07-02 17:22:44 +03:00
Vitaly Turovsky
3bf34a8781 add browserstack partner 2025-07-02 16:55:32 +03:00
Vitaly Turovsky
3cc862b05d up mineflayer 2025-07-02 16:53:30 +03:00
Vitaly Turovsky
c913d63c46 fix(regression): fix ios 16 world rendering support! 2025-07-02 16:25:36 +03:00
Vitaly Turovsky
3320f65b9c now app crash should be fixed 2025-07-02 06:24:02 +03:00
Vitaly Turovsky
ed7c33ff9f up mouse 2025-07-02 06:22:17 +03:00
Vitaly Turovsky
9086435aee rm building storybook 2025-07-02 06:21:15 +03:00
Vitaly Turovsky
8a50412395 fix app crash 2025-07-02 06:19:31 +03:00
Vitaly Turovsky
71257bdf13 add fuchsmc.net server partner back! 2025-07-02 06:16:06 +03:00
Vitaly Turovsky
7aea07f83a up patch again 2025-07-02 06:13:40 +03:00
Vitaly Turovsky
3bcf0f533a up protocol patch 2025-07-02 06:06:21 +03:00
Max Lee
b1298cbe1f
feat: Config option to proxy skin textures (#382) 2025-07-02 05:50:15 +03:00
Vitaly Turovsky
661892af7c up mineflayer 2025-07-02 05:47:49 +03:00
Vitaly Turovsky
c55827db96 up mc-data 2025-07-02 05:46:53 +03:00
Vitaly Turovsky
a2711dbe6c up mc-assets 2025-07-02 05:45:18 +03:00
Vitaly Turovsky
f79e54f11d limit columns in player tab 2025-07-02 05:45:14 +03:00
Vitaly Turovsky
6f5239e1d8 fix: fix inventory crash on picking item with gui-generated texture, fix shulker box 2025-07-01 02:29:37 +03:00
Vitaly Turovsky
13e145cc3a docs: add one liner script! 2025-07-01 00:05:43 +03:00
Vitaly Turovsky
d4ff7de64e stop building storybook... 2025-06-30 19:53:09 +03:00
Vitaly Turovsky
1310109c01 fix: username was not saved after properly after resolving the storage conflict 2025-06-30 19:48:43 +03:00
Vitaly Turovsky
dc2c5a2d88 fix: make it run on ios 15! 2025-06-30 19:11:59 +03:00
Vitaly
31b91e5a33
add logging to sw unregistration on error 2025-06-30 02:06:45 +03:00
Vitaly
f2a11d0a73
Steingify if needed 2025-06-29 20:44:58 +03:00
Vitaly Turovsky
6eae7136ec fix storage conflict modal 2025-06-29 15:34:25 +03:00
Vitaly Turovsky
34eecc166f feat: rework singleplayer generators types. now any generator can be used internally. add a few 2025-06-29 02:38:19 +03:00
Vitaly Turovsky
fec887c28d deeply stringify gui items to avoid futher modifications 2025-06-29 00:56:37 +03:00
Vitaly Turovsky
369166e094 fix tsc, up readme 2025-06-28 00:45:54 +03:00
Vitaly Turovsky
e161426caf always dipslay close buttons from settings 2025-06-27 22:11:49 +03:00
Vitaly Turovsky
0e4435ef91 feat: add support for /ping command, fix chat fading! 2025-06-27 22:06:10 +03:00
Vitaly Turovsky
3336680a0e fix z index of modal 2025-06-27 18:08:33 +03:00
Vitaly Turovsky
83d783226f fix migration marking 2025-06-27 18:08:03 +03:00
Vitaly Turovsky
af5a0b2835 fix: fix camera desync updates in 3rd view and starfield 2025-06-27 16:50:44 +03:00
Vitaly Turovsky
eedd9f1b8f feat: Now settings and servers list synced via top-domain cookies! Eg different subdomains like s.mcraft.fun and mcraft.fun will now share the same settings! Can be disabled.
feat: Now its possible to import data!
2025-06-27 16:28:15 +03:00
Vitaly Turovsky
0b1bc76327 fix ws 2025-06-26 06:22:38 +03:00
Vitaly Turovsky
b839bb8b9b rm readme patching 2025-06-26 06:01:13 +03:00
Vitaly Turovsky
3a7f267b5b dont ignore patch failures 2025-06-26 04:34:08 +03:00
Vitaly Turovsky
2055579b72 feat: finally add block RESCALE support! Cobwebs and ascending rails are now rendered correctly 2025-06-25 15:22:07 +03:00
Vitaly Turovsky
1148378ce6 fix vr again 2025-06-25 15:11:18 +03:00
Vitaly
383e6c4d80 fix lint 2025-06-24 10:03:23 +00:00
Vitaly Turovsky
e9e144621f improve auth features in edge cases 2025-06-24 02:13:06 +03:00
Vitaly
332bd4e0f3
Display auth button 2025-06-22 15:18:51 +03:00
Vitaly Turovsky
32b19ab7af fix: fix elytra skin 2025-06-22 01:20:34 +03:00
Vitaly Turovsky
5221104980 feat: F5: 3rd person view camera! 2025-06-22 01:14:15 +03:00
Vitaly Turovsky
7c8ccba2c1 add testIosCrash for debugging these scenarios 2025-06-21 03:34:04 +03:00
Vitaly Turovsky
fdeb78d96b add /ping and /connect GET endpoints for server info/ping data 2025-06-20 13:56:25 +03:00
Max Lee
5269ad21b5
feat: Add spectator mode entity spectating (#369) 2025-06-18 17:07:00 +03:00
Vitaly Turovsky
f126f56844 fix: fix visual gaps between blocks of water! 2025-06-18 16:57:03 +03:00
Vitaly Turovsky
1b20845ed5 fix initial component mount sometimes displays not found modal
the reason why it happens is known
2025-06-18 08:37:22 +03:00
Vitaly
f3ff4bef03
big renderer codebase cleanup: clean player state (#371) 2025-06-18 08:19:04 +03:00
Vitaly Turovsky
679c3775f7 feat: convert formatted text color to display p3 display so its more vibrant on macbooks with xdr display and other p3 monitors 2025-06-15 16:52:24 +03:00
Vitaly Turovsky
8c71f70db2 fix: fix shifting didn't work on some servers after check 2025-06-13 15:12:58 +03:00
Vitaly Turovsky
9f3079b5f5 feat: rework effects display with new UI! fix a few related bugs 2025-06-13 14:14:08 +03:00
Vitaly Turovsky
794cafb1f6 feat: add useful entities debug entry to F3 2025-06-13 13:22:15 +03:00
Vitaly Turovsky
a3dcfed4d0 feat: add time and battery status that is displayed in fullscreen by default 2025-06-13 13:11:06 +03:00
Vitaly Turovsky
b69813435c up test 2025-06-13 08:06:09 +03:00
Vitaly Turovsky
1e513f87dd feat: add End portal & gateway rendering 2025-06-13 05:23:58 +03:00
Vitaly Turovsky
243db1dc45 use range generation instead 2025-06-13 04:56:40 +03:00
Vitaly Turovsky
ac7d28760f feat: Implement always up-to-date recommended servers display! Fix other annoying issues in servers list 2025-06-13 04:51:48 +03:00
Vitaly Turovsky
cfce898918 make random username configurable 2025-06-11 03:19:14 +03:00
Vitaly
14effc7400
Should fix lint 2025-06-10 07:26:50 +03:00
Vitaly Turovsky
a562316cba rm dead servers 2025-06-10 06:33:18 +03:00
Vitaly Turovsky
6a583d2a36 feat: Custom chat ping functionality!!! To ping on any server type @ and on other web client such ping will be highlighted. Can be disabled.
todo: enable a way to display ping-only messages
todo: reply (public/private) command
todo: fix copmletion offset
2025-06-10 06:33:01 +03:00
Vitaly Turovsky
5575933559 change title for mcraft.fun version 2025-06-10 05:26:32 +03:00
Vitaly Turovsky
e982bf1493 fix(important): make chat word breaking match Minecraftt behavior 2025-06-10 05:24:28 +03:00
Vitaly Turovsky
1c93fd7f60 fix wording 2025-06-10 05:22:52 +03:00
Vitaly
a2e9404a70
feat: Simple but effective renderer perf debug features (#347) 2025-06-05 20:22:58 +03:00
Vitaly Turovsky
38a1d83cf2 fix(regression): B on gamepad was opening pause menu instead of start (default actions map conflict). Now start if clicked again closes all modals 2025-06-05 04:07:22 +03:00
Vitaly Turovsky
314ddf7215 mobile: add back select item button, drop stack on hold action (add command!) 2025-06-05 03:37:17 +03:00
Vitaly Turovsky
829e588ac1 fix F3 handling when other keys are pressed (restore), adjust mic button 2025-06-05 03:32:09 +03:00
Max Lee
8b2276a7ae
Add server-side logging and timeout option (#366) 2025-06-04 17:07:00 +03:00
Maksim Grigorev
7635375471
Resolve issue with non-functional F3 key (#365) 2025-06-03 13:14:34 +03:00
Maksim Grigorev
087e167826
feat: configurable mobile top buttons! (#361) 2025-05-30 16:43:06 +03:00
Vitaly Turovsky
c500d08ed7 hotfix: restore hand 2025-05-27 11:30:21 +03:00
Vitaly Turovsky
50907138f7 fix edge case infinite loop in mesher 2025-05-26 01:09:43 +03:00
Vitaly Turovsky
99d05fc94b improve stability of minimap
(though full refactor is still needed)
2025-05-25 16:16:14 +03:00
Vitaly Turovsky
0c68e63ba6 fix: restore VR support. Fix rotation / position camera bugs 2025-05-25 12:55:27 +03:00
Vitaly Turovsky
04a85e9bd1 minimap: don't do more 20 updates per seconds 2025-05-24 18:26:47 +03:00
Vitaly
3cd778538c
feat: Sync armor rotation for players (#363) 2025-05-22 14:50:58 +03:00
Vitaly Turovsky
9726257577 fix: do not display capture lock message when possilbe (avoid flickering - do strategy switch)
feat: make tab (players list) keybindign configurable and add a way to assign to a gamepad button
2025-05-22 14:46:44 +03:00
Max Lee
5a663aac2f
fix: item textures in inventory break after loading resourcepack (#362) 2025-05-21 18:53:20 +03:00
Vitaly
7cea1b8755
fix tsc 2025-05-21 18:45:54 +03:00
Vitaly Turovsky
5ea2ab9c1a fix: update tab header/footer in real time, use player display name in tab 2025-05-21 13:55:52 +03:00
Vitaly Turovsky
b36d08528f fix: fix hanging forever server ping and weboscket connections 2025-05-21 06:36:39 +03:00
Vitaly Turovsky
a4e70768dd fix test 2025-05-21 05:53:59 +03:00
Vitaly Turovsky
ecb53fab88 up tracker 2025-05-21 05:46:36 +03:00
Vitaly Turovsky
b2ef71fc19 up mc data, physics, autojump 2025-05-21 05:37:33 +03:00
Vitaly
f4196d6aba
feat(config): Add support for remote dynamic splash text (#358) 2025-05-20 19:50:26 +03:00
Vitaly
4f78534ca4
Update config.json 2025-05-20 19:49:36 +03:00
Maksim Grigorev
7799ccc370
Update src/react/MainMenu.tsx
Co-authored-by: Vitaly <vital2580@icloud.com>
2025-05-20 19:08:34 +03:00
Maxim Grigorev
5efe3508df fix: optimized splash text loading 2025-05-20 22:03:31 +07:00
Vitaly
4d70128ac6
Update config.json 2025-05-20 13:53:26 +03:00
Maxim Grigorev
b4df2e1837 feat: improved splash text loading for better UI 2025-05-20 16:56:38 +07:00
Vitaly Turovsky
83366ec5fa improve Click to capture mouse by not displaying it in less cases 2025-05-20 02:02:43 +03:00
Maxim Grigorev
b2a1bd10e4 fix: improoved the code 2025-05-19 19:26:12 +07:00
Maksim Grigorev
90c283c5ee
Update src/utils/splashText.ts
Co-authored-by: Vitaly <vital2580@icloud.com>
2025-05-19 15:17:36 +03:00
Vitaly Turovsky
67dbd56f14 dont display new hint when gamepad is used 2025-05-19 03:07:01 +03:00
Vitaly Turovsky
517f5d3501 feat(ui): display persistent capture mouse indicator instead of notification 2025-05-19 03:02:23 +03:00
Vitaly Turovsky
7f6fc00f02 fix: fix lever interaction shape 2025-05-19 02:21:23 +03:00
Vitaly Turovsky
f5835f54fa fix: was not possible to open blast furnace GUI 2025-05-19 02:05:14 +03:00
Vitaly Turovsky
970ed614ae fix crash on error message 2025-05-19 02:04:53 +03:00
Vitaly Turovsky
080d75f939 less annoying false block swap animations 2025-05-19 02:01:11 +03:00
Vitaly Turovsky
67d365b9c3 fix: fix crafting on 1.16.5 and below 2025-05-19 01:57:48 +03:00
Vitaly Turovsky
e2a0df748e rorce button height 2025-05-19 00:32:44 +03:00
Vitaly Turovsky
2dc811b2a1 adjust settings visually 2025-05-19 00:23:20 +03:00
Vitaly Turovsky
a5d16a75ef fix(regression): hotbar on mobile was broken 2025-05-19 00:16:18 +03:00
gguio
785ab490f2
fix: restore minimal and full map (#348)
Co-authored-by: gguio <nikvish150@gmail.com>
2025-05-18 09:58:52 +03:00
Vitaly Turovsky
a9b94ec897 fix: fix sneaking didnt work with water 2025-05-17 06:58:33 +03:00
Vitaly
42f973e057
Update config.json 2025-05-17 05:33:31 +03:00
Maksim Grigorev
ff29fc1fc5
feat: Credits modal (#354) 2025-05-16 19:50:38 +03:00
Maxim Grigorev
051cc5b35c fix: optimized splashText working process 2025-05-16 22:47:59 +07:00
Maxim Grigorev
f921275c87 feat: improoved code safety 2025-05-16 20:44:32 +07:00
Maxim Grigorev
2b0f178fe0 feat: improoved the code 2025-05-16 20:35:04 +07:00
Maxim Grigorev
6302a3815f feat: Add remote splash text loading via URLs 2025-05-16 20:19:23 +07:00
M G
0a61bbba75
Fixed button shown even when cursor is not over the entity (e.g., a video) (#356)
fix: button shown even when cursor is not over the entity (e.g., a video)
2025-05-14 18:15:21 +03:00
Vitaly Turovsky
7ed3413b28 fix save is undefined in rare cases 2025-05-14 03:12:55 +03:00
Vitaly Turovsky
75adc29bf0 up mc assets 2025-05-14 03:07:02 +03:00
Vitaly Turovsky
489c16793b fix original name logging on error 2025-05-14 01:24:53 +03:00
Vitaly Turovsky
48cdd9484f annoying contextmenu on windows on right click sometimes was appearing 2025-05-12 18:56:20 +03:00
M G
e2400ee667
Entity Interaction Button works now (#352) 2025-05-12 18:15:45 +03:00
M G
a58ff0776e
server name field "flex-start" now (#351) 2025-05-12 18:09:10 +03:00
Vitaly Turovsky
674b6ab00d feat: add chat scroll debug and use alternative ways of forcing scroll in DOM! 2025-05-08 21:23:04 +03:00
Vitaly Turovsky
25f2fdef4e fix: dont display hand & cursor block outline in spectator. 2025-05-08 19:56:13 +03:00
Vitaly Turovsky
f76c7fb782 fix: water now supports lighting when its enabled 2025-05-08 19:51:42 +03:00
Vitaly Turovsky
aa817139b7 fix: fix auto jump on mobile! 2025-05-08 04:11:17 +03:00
Vitaly Turovsky
58799d973c fix transfer on 1.20 and above (should have added patch a long time ago) 2025-05-08 00:07:33 +03:00
M G
b3392bea6b
feat: Entity interaction button for mobile (#346) 2025-05-07 16:31:36 +03:00
Vitaly
bf3381c803
Do not automatically enable renderer debug in dev 2025-05-07 15:03:48 +03:00
M G
bb9bb48efd
feat: Combine IP and port input fields (#345) 2025-05-06 16:39:42 +03:00
Vitaly Turovsky
28022a2054 feat: rework server data saving and auto login by using more aggressive strategy 2025-05-06 16:36:31 +03:00
Vitaly
1845530e22
Make link clickable in release body 2025-05-04 14:21:05 +03:00
Vitaly Turovsky
b01cfe475d change color and fix rsbuild hoisting var bug 2025-05-04 11:35:43 +03:00
Vitaly Turovsky
22483d7a76 regression: some UIs like settings became not scrollable 2025-05-04 11:24:15 +03:00
Vitaly Turovsky
e250061757 smooth appear panorama on load textures 2025-05-03 23:11:15 +03:00
Vitaly Turovsky
1fd9a29192 up mouse & chunk 2025-05-03 23:01:47 +03:00
Vitaly Turovsky
29c6a3d739 up browserify and mineflayer to fix a few bugs 2025-05-03 12:50:55 +03:00
Vitaly Turovsky
cd7c053a3c fix(critical-regression): player animation was glitching just a lot 2025-05-03 12:50:55 +03:00
Vitaly Turovsky
5bfb9bebd7 f3 hint 2025-05-03 12:50:55 +03:00
M G
951790dad6
feat: Debug Response Time Indicator (#342) 2025-05-03 11:00:31 +03:00
M G
c5f72f2fb3
fix: corrected scroll of main component at client mods; feat: improoved ux (#341) 2025-05-02 19:10:43 +03:00
Vitaly Turovsky
f12de4ea23 fix releasing alias 2025-05-01 15:23:58 +03:00
Vitaly Turovsky
813c952420 fix: fix lava rendering 2025-05-01 12:53:44 +03:00
Vitaly Turovsky
ec142c0ce4 fix heads & server lighting 2025-04-30 18:33:30 +03:00
Vitaly Turovsky
378b668d46 fix: sometimes walking animation was stuck 2025-04-30 18:29:39 +03:00
M G
0d9cb0625e
chore: migrated from react-transition-group to framer-motion (#339)
Co-authored-by: nikandrovaelena93@gmail.com <ashcat2507@gmail.com>
2025-04-30 14:26:59 +03:00
Vitaly Turovsky
221f99ffdf fix: fix players list disappear on dimension switch 2025-04-30 12:17:47 +03:00
Vitaly Turovsky
4f1cb85301 fix: fix p2p where peerjs server works 2025-04-30 08:11:38 +03:00
Vitaly Turovsky
5bf66b8e50 ci: add fixed short link for released version eg v90.mcraft.fun 2025-04-29 04:22:59 +03:00
Vitaly Turovsky
5caca68e8e disable pr update desc 2025-04-28 07:09:54 +03:00
Vitaly Turovsky
95163fb288 update page description and tech 2025-04-27 11:19:29 +03:00
Vitaly Turovsky
0f2e4f1329 add allEntitiesDebug 2025-04-26 10:50:29 +03:00
Vitaly Turovsky
aa0024faa2 experimental mods actions 2025-04-26 10:39:12 +03:00
Vitaly Turovsky
1599917134 feat: falling block & summoned tnt entities rendering support 2025-04-26 10:38:59 +03:00
Vitaly Turovsky
e706f7d086 fix ocelot rendering 2025-04-26 10:38:32 +03:00
Vitaly Turovsky
e20fb8be53 add ?debugEntities for list of supported entities 2025-04-26 10:38:19 +03:00
Vitaly Turovsky
cd2ff62d6d fix: remove skeleton helper which was causing them flying for ALL bedrock entities
fixes #270
2025-04-26 09:51:43 +03:00
Vitaly Turovsky
fa36ed2678 fix tsc 2025-04-26 06:19:05 +03:00
Vitaly Turovsky
305f4d8a31 fix surrogate pair check in js 2025-04-26 05:51:00 +03:00
Vitaly Turovsky
86ef4f268e fix sound id mapping for versions before 1.19.3 2025-04-26 05:32:17 +03:00
Vitaly Turovsky
0c7900a655 fix skins loading on real prod 2025-04-26 05:19:06 +03:00
Vitaly Turovsky
8c37db4051 fix(critical-regression): chunks never loaded when dimension was changed but position chunk was the same (eg spawn 0,0) 2025-04-26 05:14:33 +03:00
Vitaly Turovsky
4ded3b5d2b feat: implement safe features for chat: limit length, avoid sending formatting symbol to avoid kicks (with very nice ux!) 2025-04-26 05:01:20 +03:00
Vitaly Turovsky
db1b72a582 fix: fix bow usage visually 2025-04-25 09:27:41 +03:00
Vitaly Turovsky
89fc31a2c2 feat: holding item/block display for players! 2025-04-25 09:05:49 +03:00
Vitaly Turovsky
01b6d87331 feat: player crouching and better hit animation 2025-04-25 08:29:18 +03:00
Vitaly Turovsky
a654396238 try to not to use unreliable bot.player 2025-04-25 07:40:31 +03:00
Vitaly Turovsky
948a52a2a5 feat: restore skins display with new API (thanks to Nodecraft!). Unfortunately capes are not supported from API anymore. Restore skin display from server properties. 2025-04-25 07:26:23 +03:00
Vitaly Turovsky
d0ac00843d up packets list 2025-04-25 04:45:59 +03:00
Vitaly Turovsky
4ca9a801a8 fix sound.js caching, increase priority of scripts (make difference only in very rare cases) 2025-04-25 04:04:59 +03:00
Vitaly Turovsky
510d163067 allow client to be loaded from other domains 2025-04-24 23:19:12 +03:00
Vitaly Turovsky
97533cfddb cache sounds, report downloading assets 2025-04-24 21:37:49 +03:00
Vitaly Turovsky
b30e7fc152 dont display no sound id mapping in default sp 2025-04-24 05:36:35 +03:00
Vitaly Turovsky
d7fdf18416 feat: jokes over. use a reliable source (thanks to ViaVersion!) for sound id mappings to avoid screamers on mobs in latest versions 2025-04-24 03:40:40 +03:00
Vitaly
28faa9417a
feat: Client side js mods. Modding! (#255) 2025-04-23 09:17:33 +03:00
Vitaly Turovsky
109daa2783 fix imports 2025-04-23 05:59:49 +03:00
Vitaly Turovsky
14ad1c5934 sync fork: add a bunch of not needed side core features like translation
AND fix critical performance regression (& ram)
2025-04-23 05:55:59 +03:00
Vitaly Turovsky
585b19d8dc up mcraft-fun-mineflayer support 2025-04-22 19:19:59 +03:00
Vitaly Turovsky
71f63a3be0 sync fork: add loading timer for debug, better connecting messages, better recording panel 2025-04-22 19:01:04 +03:00
Vitaly Turovsky
2b881ea5ba fix: disable physics for viewer 2025-04-21 16:06:01 +03:00
Vitaly Turovsky
a0bfa275af lets be safer and use 32array 2025-04-19 00:43:31 +03:00
Vitaly Turovsky
193c748feb fix: add chunks a little faster on low tier devices: use indicies 2025-04-19 00:43:20 +03:00
Vitaly Turovsky
c3112794c0 try to fix scroll chat bug 2025-04-19 00:07:41 +03:00
Vitaly Turovsky
dbfd2b23f6 up physics 2025-04-18 23:43:26 +03:00
Vitaly Turovsky
9646fbbc0f fix(regression): hotbar switch on mobile was broken 2025-04-18 23:42:51 +03:00
Vitaly Turovsky
a7c35df959 fix: fix movement sound 2025-04-18 17:46:10 +03:00
Vitaly Turovsky
529b465d32 sync for changes between forks
fix: sometimes autlogin save prompt is not displayed
fix: add connect server only ui
add some other components for future usage
fix: make some fields custommization in main menu from config.json
fix: adjust logic on player disconnect in some rare cases
2025-04-17 20:51:06 +03:00
Vitaly Turovsky
1582e16d3b fix critical regression that led to not loading chunks twice that was a valid behavior before 2025-04-17 19:58:30 +03:00
Vitaly Turovsky
e8b1f190a7 add more debug into to f3+h 2025-04-17 19:58:03 +03:00
Vitaly Turovsky
73ccb48d02 feat: Add F3+H chunks debug screen! not really useful for now since chunks not visible bug was not fixed yet 2025-04-16 18:24:19 +03:00
Vitaly Turovsky
143d4a3bb3 fix: fix double chests. fix inventory crashing when it doesnt know the texture to render 2025-04-16 14:34:51 +03:00
Vitaly Turovsky
f5ed17d2fb fix(critical-regression): FIX broken inventory! There was a huge regression with a month-old inventory update which was breaking it in some ways 2025-04-16 03:23:41 +03:00
Vitaly Turovsky
6a8de1fdfb fix: sometimes auto login suggestion was not visible due to overlapping notification issue 2025-04-16 03:22:25 +03:00
Vitaly Turovsky
a541e82e04 fix: add freezeSettings param 2025-04-15 02:29:09 +03:00
Vitaly Turovsky
c5e8fcb90c feat: add controls debug interface! Debug happening actions in your game & keyboard buttons!!!! 2025-04-14 17:21:22 +03:00
Vitaly Turovsky
7a53d4de63 fix(feedback-controls): prevent default action for side mouse buttons to avoid page leave annoying modal on accidental clicks 2025-04-14 17:06:13 +03:00
Vitaly Turovsky
83502eba60 fix(important): Formatted text display: fix reading extra when its text which might happen in kick messages or server info 2025-04-12 03:10:29 +03:00
Vitaly Turovsky
70557a6282 fix dockerfile 2025-04-11 21:44:40 +03:00
Vitaly Turovsky
7e5a12934c fix: fix looking camera desync in local saves 2025-04-11 21:44:08 +03:00
Vitaly Turovsky
4b85b16b73 feat(experimental-part1): rework chunk loading strategy by forcing spiral order loading into renderer processor and ignoring server order 2025-04-11 21:42:55 +03:00
Vitaly Turovsky
27df313f26 microoptimisation on big number of chunks load 2025-04-11 18:08:21 +03:00
Vitaly Turovsky
77449c5c12 forgot to fix what codereabbit told to fix 2025-04-10 19:33:39 +03:00
Vitaly Turovsky
deb8ec6c0f build sharp to avoid crashes 2025-04-10 19:25:43 +03:00
Vitaly Turovsky
024da5bf6d fix: now media dont receive global lighting by default 2025-04-10 18:54:02 +03:00
Vitaly
c755f085d9
fix: update rsbuild and pnpm to latest version to resolve long-standi… (#328) 2025-04-10 18:53:33 +03:00
Vitaly Turovsky
3c6ee2dbb3 up lockfile 2025-04-10 03:15:01 +03:00
Vitaly Turovsky
bf790861d9 fix: finally fix plants rendering in underwater and near water! 2025-04-10 01:20:29 +03:00
Vitaly
a977d09031
Up physics to fix jump 2025-04-09 16:53:42 +03:00
Vitaly
1fbbf36859
feat: NEW PHYSICS ENGINE - GRIM PASSES >1.19.4 (#327) 2025-04-09 05:51:34 +03:00
Vitaly Turovsky
31d5089e9c should fix rare case when some tiles are not rendered 2025-04-09 05:50:39 +03:00
Vitaly Turovsky
7824cf64a2 fix lint 2025-04-09 05:46:40 +03:00
Vitaly Turovsky
f4bd38fa5c use more reliable bot entity instead of player.entity 2025-04-09 05:30:51 +03:00
Vitaly Turovsky
5adbce39e0 pick changes from webgpu:
fix cursor lines
fix background color change
2025-04-09 05:07:41 +03:00
Vitaly Turovsky
0b72ea61c7 fix: add time to debug overlay 2025-04-09 04:34:58 +03:00
Vitaly Turovsky
33a6f4d088 fix: a little better perf when media is not in use 2025-04-08 17:23:32 +03:00
Vitaly Turovsky
758405da03 [skip ci] add doc 2025-04-07 19:16:16 +03:00
Vitaly Turovsky
4fc8011413 feat(customChannelsPreview): add sections moving animations 2025-04-07 19:00:21 +03:00
Vitaly Turovsky
d347957f64 Update pnpm install command in benchmark workflow to use --no-frozen-lockfile 2025-04-07 17:07:32 +03:00
Vitaly Turovsky
0a85de180e should fix modal not found error on server connect UI 2025-04-07 17:00:39 +03:00
Vitaly Turovsky
cc264e895f Move Cypress from optionalDependencies to dependencies in benchmark workflow 2025-04-07 17:00:16 +03:00
Vitaly Turovsky
4dce591f8b fix if for benchmark 2025-04-07 16:45:48 +03:00
Vitaly Turovsky
b35b88236d use whitelist to active touch cancel hack to avoid issues with side integrations 2025-04-07 16:20:06 +03:00
Vitaly Turovsky
f79472a1da add client info, fix small width data display 2025-04-07 16:09:01 +03:00
Vitaly Turovsky
d1a646ed54 never wait for load waitForChunksRender 2025-04-07 14:21:25 +03:00
Vitaly Turovsky
914dcb6110 add more fixtures support 2025-04-07 14:16:44 +03:00
Vitaly Turovsky
23bab8dbd5 basically fix all the debug panes, record that info 2025-04-07 13:55:18 +03:00
Vitaly Turovsky
881d105c57 always render debug 2025-04-07 13:42:13 +03:00
Vitaly Turovsky
3109be2d8a universal load backend + fallback 2025-04-07 12:49:33 +03:00
Vitaly Turovsky
568ea3d18b adjust default render distance to match testing on ci 2025-04-07 12:49:24 +03:00
Vitaly Turovsky
27e51b65df refactor: rename benchmarkName to fixture and update related logic in benchmark files 2025-04-07 02:26:11 +03:00
Vitaly
0aa4d11bdd
feat: Performance benchmark!! (#153) 2025-04-07 02:21:37 +03:00
Vitaly Turovsky
ce5ef7c7cb remove not neede functionality 2025-04-06 11:09:17 +03:00
Vitaly Turovsky
9b71ae1a24 feat: rework mobile button control sizes & joystick. Make size of every button and joystick configurable via configurator UI from settings! 2025-04-06 00:23:17 +03:00
Vitaly
908fa64f2f
pick most of changes from webgpu for better stability (#322) 2025-04-06 00:22:27 +03:00
Vitaly Turovsky
c025a1c75a fix a lot annoying sentry errors 2025-04-05 18:55:59 +03:00
Vitaly Turovsky
04c37c1eef fix: fix durability is nan 2025-04-05 18:37:21 +03:00
Vitaly Turovsky
dbfadde044 fix(renderer): rendering of ALL blocks of north side was not correct. Fix texture flipping 2025-04-05 11:15:39 +03:00
Vitaly Turovsky
d78a8b1220 add mesher logging functionality for advanced debugging on other client end 2025-03-31 22:55:17 +03:00
Vitaly Turovsky
70fbe1b0e2 dont clip edition 2025-03-31 22:06:30 +03:00
Vitaly Turovsky
394a12b147 fix(important): fix physics crash in powder snow and pink petals 2025-03-31 14:08:56 +03:00
Vitaly Turovsky
f895304380 add debug method and fix f3 custom block name display 2025-03-31 13:32:40 +03:00
Vitaly Turovsky
4f45cd072a fix(perf): dont load gui textures on panorama start in singlefile build
fix: update textures in inventory & hotbar after textures load, including jei
fix: one row of jei was out of the screen
2025-03-31 13:16:57 +03:00
Vitaly Turovsky
5af290ac4e also remove media on world remove, and stop 2025-03-31 11:49:05 +03:00
Vitaly Turovsky
c5c9fd9bcd fix: fix name display on new versions in edge cases 2025-03-31 11:43:21 +03:00
Vitaly Turovsky
1b9b6c954c fix: remove media and custom blocks on world switch, minor fixes 2025-03-31 11:43:07 +03:00
Vitaly Turovsky
3c2ed440b6 up proxy software to avoid new crashes 2025-03-31 08:22:29 +03:00
Vitaly Turovsky
9c6bc49921 fix msg for proxy 2025-03-30 18:01:21 +03:00
Vitaly Turovsky
1a87951bc8 up proxy 2025-03-30 16:09:50 +03:00
Vitaly Turovsky
73e65c6656 try different intersect raycast media approach 2025-03-30 16:09:45 +03:00
Vitaly Turovsky
c324ce29ab fix: always limit error texture dimension to avoid crashes on high width/height 2025-03-30 14:03:50 +03:00
Vitaly Turovsky
b666f6e3c3 ci: disable functionality that should work but doesnt work because github doesnt make any sense 2025-03-29 11:27:10 +03:00
Vitaly Turovsky
115022a21b ci: use github_token var 2025-03-29 11:23:48 +03:00
Vitaly Turovsky
18ee1dc532 ci: fix syntax 2025-03-29 11:15:54 +03:00
Vitaly Turovsky
291ead079a use token: workaround 2025-03-29 11:13:33 +03:00
Vitaly Turovsky
983b8a184b refactor: remove bundle-stats workflow and integrate its functionality directly into CI 2025-03-29 11:01:35 +03:00
Vitaly Turovsky
66fa59a87a ci: add size tracking for minecraft.html and adjust build steps 2025-03-29 10:53:50 +03:00
Vitaly
47864f0023
ci: report size change + always check single file build (#320)
* init

* should be final

* mbmb

* Update .github/workflows/bundle-stats.yml

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-03-29 10:41:25 +03:00
Vitaly Turovsky
6f15fcc726 fix: display proxy disconnect message to the client when proxy server is being shutdown 2025-03-29 09:57:57 +03:00
Vitaly Turovsky
af9da93978 enabling parsing kick messages 2025-03-29 09:19:54 +03:00
Vitaly Turovsky
08bb0b6777 fix: fix almost all known inventory update bugs 2025-03-29 09:17:48 +03:00
Vitaly Turovsky
187e9fa6b4 important: fix visual update issues in inventory after server packet 2025-03-29 09:16:36 +03:00
Vitaly Turovsky
4fd290c636 fix(regression): fix rendering almost all items in the game, like shield, banners, beds, filled maps, ... 2025-03-29 06:44:48 +03:00
Vitaly Turovsky
e2f28e4975 fix: fix hotbar & inv texture updates on resources change
fix: delay autologin /login sending
2025-03-29 05:34:32 +03:00
Vitaly Turovsky
c3b4eb953f feat: add much better video support 2025-03-29 04:48:39 +03:00
Vitaly Turovsky
850ae6c2da fix: put just connected server to start of list 2025-03-29 02:01:05 +03:00
Vitaly Turovsky
66b9f58c6f fix error not displayed when crash during world display happens, dont crash world display on entity error 2025-03-29 01:53:54 +03:00
Vitaly Turovsky
b58950bec2 update list of servers 2025-03-28 12:46:05 +03:00
Max Lee
47be0ac865
feat: Item custom model data support (#318)
* feat: Item custom model data support

* rename prop, jsdoc for clarity

* explicit resource manager because it can be run in different threads, up mc-assets

* fix tsc

---------

Co-authored-by: Vitaly Turovsky <vital2580@icloud.com>
2025-03-26 08:22:38 +03:00
Vitaly Turovsky
cd9b796f16 feat: now save actually changed options instead of all options in new localstorage key changedSettings for clarity 2025-03-26 07:12:53 +03:00
Vitaly Turovsky
b32bab8211 fix inscreen button 2025-03-26 06:55:09 +03:00
Vitaly Turovsky
52755fc18f feat(renderer): add toggle for batch chunks display in options GUI 2025-03-26 06:47:21 +03:00
Vitaly Turovsky
f8800d5a31 ci: add step to update deployAlwaysUpdate packages in preview workflow 2025-03-26 06:46:51 +03:00
Vitaly Turovsky
797459b0fc fix: pass custom brand for ws:// servers 2025-03-26 05:08:10 +03:00
Vitaly Turovsky
f8ef748e58 fix: disable chunk batch display by default because of incorrect order and its too slow 2025-03-26 05:01:17 +03:00
Vitaly Turovsky
f161fd31d4 feat(renderer): dont display underground sections. Display chunks only when they are fully loaded 2025-03-26 04:40:11 +03:00
Vitaly Turovsky
3690cb22aa fix classic panorama regression 2025-03-26 04:39:33 +03:00
Vitaly Turovsky
33debc1475 feat(experimental): make loading chunks as smooth as possible by delaying work to get more rendered fps at that time. It helps to eliminate massive fps drops on big chunks of data, however might introduce new possible race conditions.
Also disabled full maps completely because it was hard to optimize atm.
Now chunks load ~30-50% slower but smoother. Note that using waitChunksToLoad settings remains unchanged - time is the same
2025-03-26 03:48:22 +03:00
Vitaly Turovsky
46787309e2 fix: fix camera shake effect! rewrite impl. Fix offhand holding block now can become empty 2025-03-25 22:58:24 +03:00
Vitaly Turovsky
1015556834 use graphics backend id 2025-03-25 21:33:12 +03:00
Vitaly Turovsky
db1c8a1e1a fix: allow custom media to be transparent 2025-03-25 21:25:14 +03:00
Vitaly Turovsky
761c92e27c fix: always set sign text even in rich formatted mode so you dont lose your text if nbt edit command doesnt work (which is the case for the latest versions) 2025-03-25 21:09:48 +03:00
Vitaly Turovsky
118377cbc3 ci: fix relative import path 2025-03-25 04:15:05 +03:00
Vitaly Turovsky
8786448d07 fix tsc 2025-03-25 04:07:34 +03:00
Vitaly Turovsky
2d288153e3 move three.js related files to its own renderer dir 2025-03-25 04:01:43 +03:00
Vitaly Turovsky
a53a6e5f03 fix chests regression 2025-03-25 03:55:06 +03:00
Vitaly Turovsky
237aeec6ac feat: add preventBackgroundTimeoutKick which is disabled by default but can be enabled from advanced settings. Allows to avoid kicking you out of the server when tab is not focused for long time by playing 1hz sound at very low volume to keep tab active 2025-03-25 03:46:08 +03:00
Vitaly
0f3145bb8e
feat: app <-> renderer REWORK. Add new layers for stability (#315) 2025-03-25 02:08:32 +03:00
Vitaly Turovsky
df10bc6f1b feat: add client tps info to f3 2025-03-25 00:50:28 +03:00
Vitaly Turovsky
89a8584060 fix test 2025-03-25 00:48:15 +03:00
Vitaly Turovsky
b6842508ae fix(blockPlacing): fix packets order on latest version. Fix placing end crystals. Fix using hoe and axe. Fix offhand placing. Validate sending placing packets so its 90% accurate
fixes #316
2025-03-24 23:58:16 +03:00
Vitaly
563f5fa007
feat: Add videos/images from source with protocol extension (#301) 2025-03-24 20:17:54 +03:00
Vitaly Turovsky
4a4823fd6a highlight main actions 2025-03-21 04:18:24 +03:00
Vitaly Turovsky
f87e7850ec fix: fix false hurt_animation packet handlings 2025-03-20 22:32:37 +03:00
Vitaly Turovsky
e1758a84d0 fix bossbar crash when server tries to upate/remove non existent one 2025-03-20 05:14:23 +03:00
Vitaly Turovsky
fdd770eeb9 fix: per component error boundary was not working properly crashing whole HUD gui when only component was going out of order 2025-03-20 05:07:55 +03:00
Vitaly Turovsky
abe75c7b8d 10x inventory performance improvements in freq updated inventories eg roulettes 2025-03-20 04:53:44 +03:00
Vitaly Turovsky
6b1a82a6b3 fix possible crash on non existing server data update 2025-03-20 04:37:42 +03:00
Vitaly Turovsky
b0eb73cd76 proper functionality for packets recording at any time: only via console for now 2025-03-20 04:33:26 +03:00
Vitaly Turovsky
8714fd484b fix: fix resource pack = never 2025-03-20 04:23:12 +03:00
Vitaly Turovsky
ba0287f278 fix: preserve list of initial servers when adding server, add always to top
feat: focus server ip input on adding or editing
2025-03-20 04:19:11 +03:00
Vitaly Turovsky
2277020de7 fix swing animations and improve replay server functions 2025-03-18 21:46:44 +03:00
Vitaly Turovsky
c1012a77d0 should also export servers list when requested, fix versions slider 2025-03-17 20:14:39 +03:00
Vitaly Turovsky
1b96577402 update group name 2025-03-17 20:09:57 +03:00
Vitaly Turovsky
5bb09a88bc fix: dont confuse with incorrect version display, allow to use config values as params in real time 2025-03-17 20:08:32 +03:00
Vitaly
36bf18b02f
feat: refactor all app storage managment (#310) 2025-03-17 16:05:04 +03:00
Vitaly Turovsky
da35cfb8a2 up mouse 2025-03-15 16:46:51 +03:00
Vitaly Turovsky
3e056946ec add world download button 2025-03-15 02:20:47 +03:00
Vitaly Turovsky
72028d925d feat: revamp right click experience by reworking block placing prediction and extending activatble items list 2025-03-14 23:25:13 +03:00
Vitaly
897c991a0e
fix: respect main menu links display from config (#308) 2025-03-14 19:53:59 +03:00
Vitaly Turovsky
baa6158872 fix: support custom names search & display in jei 2025-03-14 19:32:02 +03:00
Vitaly Turovsky
a67b9d7aa2 active back all vanilla mechanics like hotbar wheel when replay window is minimized 2025-03-14 19:11:14 +03:00
345 changed files with 25975 additions and 11851 deletions

View file

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

View file

@ -23,6 +23,7 @@
// ],
"@stylistic/arrow-spacing": "error",
"@stylistic/block-spacing": "error",
"@typescript-eslint/no-this-alias": "off",
"@stylistic/brace-style": [
"error",
"1tbs",
@ -102,6 +103,7 @@
// "@stylistic/multiline-ternary": "error", // not needed
// "@stylistic/newline-per-chained-call": "error", // not sure if needed
"@stylistic/new-parens": "error",
"@typescript-eslint/class-literal-property-style": "off",
"@stylistic/no-confusing-arrow": "error",
"@stylistic/wrap-iife": "error",
"@stylistic/space-before-blocks": "error",

59
.github/workflows/benchmark.yml vendored Normal file
View file

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

View file

@ -23,6 +23,8 @@ jobs:
- name: Build single-file version - minecraft.html
run: pnpm build-single-file && mv dist/single/index.html minecraft.html
env:
LOCAL_CONFIG_FILE: config.mcraft-only.json
- name: Upload artifact
uses: actions/upload-artifact@v4

View file

@ -23,6 +23,8 @@ jobs:
- name: Build project
run: pnpm build
env:
LOCAL_CONFIG_FILE: config.mcraft-only.json
- name: Bundle server.js
run: |

View file

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

View file

@ -30,18 +30,18 @@ jobs:
- name: Write Release Info
run: |
echo "{\"latestTag\": \"$(git rev-parse --short $GITHUB_SHA)\", \"isCommit\": true}" > assets/release.json
- name: Download Generated Sounds map
run: node scripts/downloadSoundsMap.mjs
- name: Build Project Artifacts
run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
env:
CONFIG_JSON_SOURCE: BUNDLED
- run: pnpm build-storybook
LOCAL_CONFIG_FILE: config.mcraft-only.json
- name: Copy playground files
run: |
mkdir -p .vercel/output/static/playground
pnpm build-playground
cp -r renderer/dist/* .vercel/output/static/playground/
- name: Download Generated Sounds map
run: node scripts/downloadSoundsMap.mjs
- name: Deploy Project Artifacts to Vercel
uses: mathiasvr/command-output@v2.0.0
with:

View file

@ -1,4 +1,4 @@
name: Vercel Deploy Preview
name: Vercel PR Deploy (Preview)
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
@ -52,6 +52,19 @@ jobs:
with:
node-version: 22
cache: "pnpm"
- name: Update deployAlwaysUpdate packages
run: |
if [ -f package.json ]; then
PACKAGES=$(node -e "const pkg = require('./package.json'); if (pkg.deployAlwaysUpdate) console.log(pkg.deployAlwaysUpdate.join(' '))")
if [ ! -z "$PACKAGES" ]; then
echo "Updating packages: $PACKAGES"
pnpm up -L $PACKAGES
else
echo "No deployAlwaysUpdate packages found in package.json"
fi
else
echo "package.json not found"
fi
- name: Install Global Dependencies
run: pnpm add -g vercel
- name: Pull Vercel Environment Information
@ -59,11 +72,13 @@ jobs:
- name: Write Release Info
run: |
echo "{\"latestTag\": \"$(git rev-parse --short ${{ github.event.pull_request.head.sha }})\", \"isCommit\": true}" > assets/release.json
- name: Download Generated Sounds map
run: node scripts/downloadSoundsMap.mjs
- name: Build Project Artifacts
run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
env:
CONFIG_JSON_SOURCE: BUNDLED
- run: pnpm build-storybook
LOCAL_CONFIG_FILE: config.mcraft-only.json
- name: Copy playground files
run: |
mkdir -p .vercel/output/static/playground
@ -77,8 +92,6 @@ jobs:
run: |
mkdir -p .vercel/output/static/commit
echo "<meta http-equiv='refresh' content='0;url=https://github.com/${{ github.repository }}/pull/${{ github.event.issue.number || github.event.pull_request.number }}/commits/${{ github.event.pull_request.head.sha }}'>" > .vercel/output/static/commit/index.html
- name: Download Generated Sounds map
run: node scripts/downloadSoundsMap.mjs
- name: Deploy Project Artifacts to Vercel
uses: mathiasvr/command-output@v2.0.0
with:

View file

@ -29,22 +29,18 @@ jobs:
run: pnpx zardoy-release empty --skip-github --output-file assets/release.json
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Download Generated Sounds map
run: node scripts/downloadSoundsMap.mjs
- run: vercel build --token=${{ secrets.VERCEL_TOKEN }} --prod
env:
CONFIG_JSON_SOURCE: BUNDLED
- run: pnpm build-storybook
LOCAL_CONFIG_FILE: config.mcraft-only.json
- name: Copy playground files
run: |
mkdir -p .vercel/output/static/playground
pnpm build-playground
cp -r renderer/dist/* .vercel/output/static/playground/
- name: Download Generated Sounds map
run: node scripts/downloadSoundsMap.mjs
- name: Deploy Project to Vercel
uses: mathiasvr/command-output@v2.0.0
with:
run: vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }} --prod
id: deploy
# publish to github
- run: cp vercel.json .vercel/output/static/vercel.json
- uses: peaceiris/actions-gh-pages@v3
@ -53,6 +49,39 @@ jobs:
publish_dir: .vercel/output/static
force_orphan: true
# Create CNAME file for custom domain
- name: Create CNAME file
run: echo "github.mcraft.fun" > .vercel/output/static/CNAME
- name: Deploy to mwc-mcraft-pages repository
uses: peaceiris/actions-gh-pages@v3
with:
personal_token: ${{ secrets.MCW_MCRAFT_PAGE_DEPLOY_TOKEN }}
external_repository: ${{ github.repository_owner }}/mwc-mcraft-pages
publish_dir: .vercel/output/static
publish_branch: main
destination_dir: docs
force_orphan: true
- name: Change index.html title
run: |
# change <title>Minecraft Web Client</title> to <title>Minecraft Web Client — Free Online Browser Version</title>
sed -i 's/<title>Minecraft Web Client<\/title>/<title>Minecraft Web Client — Free Online Browser Version<\/title>/' .vercel/output/static/index.html
- name: Deploy Project to Vercel
uses: mathiasvr/command-output@v2.0.0
with:
run: vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }} --prod
id: deploy
- name: Get releasing alias
run: node scripts/githubActions.mjs getReleasingAlias
id: alias
- name: Set deployment alias
run: |
for alias in $(echo ${{ steps.alias.outputs.alias }} | tr "," "\n"); do
vercel alias set ${{ steps.deploy.outputs.stdout }} $alias --token=${{ secrets.VERCEL_TOKEN }} --scope=zaro
done
- name: Build single-file version - minecraft.html
run: pnpm build-single-file && mv dist/single/index.html minecraft.html
- name: Build self-host version
@ -70,7 +99,7 @@ jobs:
zip -r ../self-host.zip .
- run: |
pnpx zardoy-release node --footer "This release URL: ${{ steps.deploy.outputs.stdout }}"
pnpx zardoy-release node --footer "This release URL: https://$(echo ${{ steps.alias.outputs.alias }} | cut -d',' -f1) (Vercel URL: ${{ steps.deploy.outputs.stdout }})"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# has possible output: tag

3
.gitignore vendored
View file

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

View file

@ -9,8 +9,10 @@ After forking the repository, run the following commands to get started:
4. Let us know if you are working on something and be sure to open a PR if you got any changes. Happy coding!
*(1): If you are getting `Cannot find matching keyid` update corepack to the latest version with `npm i -g corepack`.
*(2): If still something doesn't work ensure you have the right nodejs version with `node -v` (tested on 22.x)
*(3): For GitHub codespaces (cloud ide): Run `pnpm i @rsbuild/core@1.2.4 @rsbuild/plugin-node-polyfill@1.3.0 @rsbuild/plugin-react@1.1.0 @rsbuild/plugin-typed-css-modules@1.0.2` command to avoid crashes because of limited ram
<!-- *(3): For GitHub codespaces (cloud ide): Run `pnpm i @rsbuild/core@1.2.4 @rsbuild/plugin-node-polyfill@1.3.0 @rsbuild/plugin-react@1.1.0 @rsbuild/plugin-typed-css-modules@1.0.2` command to avoid crashes because of limited ram -->
## Project Structure
@ -175,8 +177,13 @@ New React components, improve UI (including mobile support).
## Updating Dependencies
1. Ensure mineflayer fork is up to date with the latest version of mineflayer original repo
1. Use `pnpm update-git-deps` to check and update git dependencies (like mineflayer fork, prismarine packages etc). The script will:
- Show which git dependencies have updates available
- Ask if you want to update them
- Skip dependencies listed in `pnpm.updateConfig.ignoreDependencies`
2. Update PrismarineJS dependencies to the latest version: `minecraft-data` (be sure to replace the version twice in the package.json), `mineflayer`, `minecraft-protocol`, `prismarine-block`, `prismarine-chunk`, `prismarine-item`, ...
3. If `minecraft-protocol` patch fails, do this:
1. Remove the patch from `patchedDependencies` in `package.json`
2. Run `pnpm patch minecraft-protocol`, open patch directory

View file

@ -4,8 +4,8 @@ FROM node:18-alpine AS build
RUN apk add git
WORKDIR /app
COPY . /app
# install pnpm
RUN npm i -g pnpm@9.0.4
# install pnpm with corepack
RUN corepack enable
# Build arguments
ARG DOWNLOAD_SOUNDS=false
ARG DISABLE_SERVICE_WORKER=false
@ -35,7 +35,7 @@ WORKDIR /app
COPY --from=build /app/dist /app/dist
COPY server.js /app/server.js
# Install express
RUN npm i -g pnpm@9.0.4
RUN npm i -g pnpm@10.8.0
RUN npm init -yp
RUN pnpm i express github:zardoy/prismarinejs-net-browserify compression cors
EXPOSE 8080

View file

@ -6,12 +6,17 @@ Minecraft **clone** rewritten in TypeScript using the best modern web technologi
You can try this out at [mcraft.fun](https://mcraft.fun/), [pcm.gg](https://pcm.gg) (short link), [mcon.vercel.app](https://mcon.vercel.app/) or the GitHub pages deploy. Every commit from the default (`develop`) branch is deployed to [s.mcraft.fun](https://s.mcraft.fun/) and [s.pcm.gg](https://s.pcm.gg/) - so it's usually newer, but might be less stable.
Don't confuse with [Eaglercraft](https://git.eaglercraft.rip/eaglercraft/eaglercraft-1.8) which is a REAL vanilla Minecraft Java edition port to the web (but with its own limitations). Eaglercraft is a fully playable solution, but this project is more in position of a "technical demo" to show how it's possible to build games for web at scale entirely with the JS ecosystem. Have fun!
> For Turkey/Russia use [ru.mcraft.fun](https://ru.mcraft.fun/) (since Cloudflare is blocked)
For building the project yourself / contributing, see [Development, Debugging & Contributing](#development-debugging--contributing). For reference at what and how web technologies / frameworks are used, see [TECH.md](./TECH.md).
Don't confuse with [Eaglercraft](https://git.eaglercraft.rip/eaglercraft/eaglercraft-1.8) which is a REAL vanilla Minecraft Java edition port to the web (but with its own limitations). Eaglercraft is a fully playable solution, meanwhile this project is aimed for *device-compatiiblity* and better performance so it feels portable, flexible and lightweight. It's also a very strong example on how to build true HTML games for the web at scale entirely with the JS ecosystem. Have fun!
For building the project yourself / contributing, see [Development, Debugging & Contributing](#development-debugging--contributing). For reference at what and how web technologies / frameworks are used, see [TECH.md](./TECH.md) (also for comparison with Eaglercraft).
> **Note**: You can deploy it on your own server in less than a minute using a one-liner script from [Minecraft Everywhere repo](https://github.com/zardoy/minecraft-everywhere)
### Big Features
- Official Mineflayer [plugin integration](https://github.com/zardoy/mcraft-fun-mineflayer-plugin)! View / Control your bot remotely.
- Open any zip world file or even folder in read-write mode!
- Connect to Java servers running in both offline (cracked) and online mode* (it's possible because of proxy servers, see below)
- Integrated JS server clone capable of opening Java world saves in any way (folders, zip, web chunks streaming, etc)
@ -24,24 +29,39 @@ For building the project yourself / contributing, see [Development, Debugging &
- Custom protocol channel extensions (eg for custom block models in the world)
- Play with friends over internet! (P2P is powered by Peer.js discovery servers)
- ~~Google Drive support for reading / saving worlds back to the cloud~~
- Support for custom rendering 3D engines. Modular architecture.
- even even more!
All components that are in [Storybook](https://mcraft.fun/storybook) are published as npm module and can be used in other projects: [`minecraft-react`](https://npmjs.com/minecraft-react)
All components that are in [Storybook](https://minimap.mcraft.fun/storybook/) are published as npm module and can be used in other projects: [`minecraft-react`](https://npmjs.com/minecraft-react)
### Recommended Settings
- Controls -> **Touch Controls Type** -> **Joystick**
- Controls -> **Auto Full Screen** -> **On** - To avoid ctrl+w issue
- Interface -> **Enable Minimap** -> **Always** - To enable useful minimap (why not?)
- Controls -> **Raw Input** -> **On** - This will make the controls more precise (UPD: already enabled by default)
- Interface -> **Chat Select** -> **On** - To select chat messages (UPD: already enabled by default)
### Browser Notes
These browsers have issues with capturing pointer:
This project is tested with BrowserStack. Special thanks to [BrowserStack](https://www.browserstack.com/) for providing testing infrastructure!
Howerver, it's known that these browsers have issues:
**Opera Mini**: Disable *mouse gestures* in browsre settings to avoid opening new tab on right click hold
**Vivaldi**: Disable Controls -> *Raw Input* in game settings if experiencing issues
### Versions Support
Server versions 1.8 - 1.21.5 are supported.
First class versions (most of the features are tested on these versions):
- 1.19.4
- 1.21.4
Versions below 1.13 are not tested currently and may not work correctly.
### World Loading
Zip files and folders are supported. Just drag and drop them into the browser window. You can open folders in readonly and read-write mode. New chunks may be generated incorrectly for now.
@ -58,6 +78,8 @@ There is a builtin proxy, but you can also host your one! Just clone the repo, r
[![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.
Proxy servers are used to connect to Minecraft servers which use TCP protocol. When you connect connect to a server with a proxy, websocket connection is created between you (browser client) and the proxy server located in Europe, then the proxy connects to the Minecraft server and sends the data to the client (you) without any packet deserialization to avoid any additional delays. That said all the Minecraft protocol packets are processed by the client, right in your browser.
```mermaid
@ -105,12 +127,12 @@ There is world renderer playground ([link](https://mcon.vercel.app/playground/))
However, there are many things that can be done in online production version (like debugging actual source code). Also you can access some global variables in the console and there are a few useful examples:
- `localStorage.debug = '*'` - Enables all debug messages! Warning: this will start all packets spam.
- If you type `debugToggle`, press enter in console - It will enables all debug messages! Warning: this will start all packets spam.
Instead I recommend setting `options.debugLogNotFrequentPackets`. Also you can use `debugTopPackets` (with JSON.stringify) to see what packets were received/sent by name
- `bot` - Mineflayer bot instance. See Mineflayer documentation for more.
- `viewer` - Three.js viewer instance, basically does all the rendering.
- `viewer.world.sectionObjects` - Object with all active chunk sections (geometries) in the world. Each chunk section is a Three.js mesh or group.
- `world` - Three.js world instance, basically does all the rendering (part of renderer backend).
- `world.sectionObjects` - Object with all active chunk sections (geometries) in the world. Each chunk section is a Three.js mesh or group.
- `debugSceneChunks` - The same as above, but relative to current bot position (e.g. 0,0 is the current chunk).
- `debugChangedOptions` - See what options are changed. Don't change options here.
- `localServer`/`server` - Only for singleplayer mode/host. Flying Squid server instance, see it's documentation for more.
@ -119,7 +141,7 @@ Instead I recommend setting `options.debugLogNotFrequentPackets`. Also you can u
- `nbt.simplify(someNbt)` - Simplifies nbt data, so it's easier to read.
The most useful thing in devtools is the watch expression. You can add any expression there and it will be re-evaluated in real time. For example, you can add `viewer.camera.position` to see the camera position and so on.
The most useful thing in devtools is the watch expression. You can add any expression there and it will be re-evaluated in real time. For example, you can add `world.getCameraPosition()` to see the camera position and so on.
<img src="./docs-assets/watch-expr.png" alt="Watch expression" width="480"/>
@ -156,6 +178,7 @@ Server specific:
- `?lockConnect=true` - Only works then `ip` parameter is set. Disables cancel/save buttons and all inputs in the connect screen already set as parameters. Useful for integrates iframes.
- `?autoConnect=true` - Only works then `ip` and `version` parameters are set and `allowAutoConnect` is `true` in config.json! Directly connects to the specified server. Useful for integrates iframes.
- `?serversList=<list_or_url>` - `<list_or_url>` can be a list of servers in the format `ip:version,ip` or a url to a json file with the same format (array) or a txt file with line-delimited list of server IPs.
- `?addPing=<ping>` - Add a latency to both sides of the connection. Useful for testing ping issues. For example `?addPing=100` will add 200ms to your ping.
Single player specific:
@ -212,3 +235,4 @@ Only during development:
- [https://github.com/ClassiCube/ClassiCube](ClassiCube - Better C# Rewrite) [DEMO](https://www.classicube.net/server/play/?warned=true)
- [https://m.eaglercraft.com/](EaglerCraft) - Eaglercraft runnable on mobile (real Minecraft in the browser)
- [js-minecraft](https://github.com/LabyStudio/js-minecraft) - An insanely well done clone from the graphical side that inspired many features here

13
TECH.md
View file

@ -10,26 +10,27 @@ This client generally has better performance but some features reproduction migh
| Gamepad Support | ✅ | ❌ | |
| A11Y | ✅ | ❌ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page (but maybe it's not needed) |
| Game Features | | | |
| Servers Support (quality) | ❌ | ✅ | Eaglercraft is vanilla Minecraft, while this project tries to emulate original game behavior at protocol level (Mineflayer is used) |
| Servers Support (quality) | ❌(+) | ✅ | Eaglercraft is vanilla Minecraft, while this project tries to emulate original game behavior at protocol level (Mineflayer is used) |
| Servers Support (any version, ip) | ✅ | ❌ | We support almost all Minecraft versions, only important if you connect to a server where you need new content like blocks or if you play with friends. And you can connect to almost any server using proxy servers! |
| Servers Support (online mode) | ✅ | ❌ | Join to online servers like Hypixel using your Microsoft account without additional proxies |
| Singleplayer Survival Features | ❌ | ✅ | Just like Eaglercraft this project can generate and save worlds, but generator is simple and only a few survival features are supported (look here for [supported features list](https://github.com/zardoy/space-squid)) |
| Singleplayer Maps | ✅ | ✅ | We support any version, but adventure maps won't work, but simple parkour and build maps might be interesting to explore... |
| Singleplayer Maps World Streaming | ✅ | ❌ | Thanks to Browserfs, saves can be loaded to local singleplayer server using multiple ways: from local folder, server directory (not zip), dropbox or other cloud *backend* etc... |
| P2P Multiplayer | ✅ | ✅ | A way to connect to other browser running the project. But it's almost useless here since many survival features are not implemented. Maybe only to build / explore maps together... |
| Voice Chat | ❌ | ✅ | Eaglercraft has custom WebRTC voice chat implementation, though it could also be easily implemented there |
| Voice Chat | ❌(+) | ✅ | Eaglercraft has custom WebRTC voice chat implementation, though it could also be easily implemented there |
| Online Servers | ✅ | ❌ | We have custom implementation (including integration on proxy side) for joining to servers |
| Plugin Features | ✅ | ❌ | We have Mineflayer plugins support, like Auto Jump & Auto Parkour was added here that way |
| Direct Connection | ✅ | ✅ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page |
| Moding | ❌(roadmap, client-side) | ❌ | This project will support mods for singleplayer. In theory its possible to implement support for modded servers on protocol level (including all needed mods) |
| Video Recording | ❌ | ✅ | Don't feel needed |
| Metaverse Features | ❌(roadmap) | ❌ | Iframes, video streams inside of game world (custom protocol channel) |
| Moding | ✅(own js mods) | ❌ | This project will support mods for singleplayer. In theory its possible to implement support for modded servers on protocol level (including all needed mods) |
| Video Recording | ❌ | ✅ | Doesn't feel needed |
| Metaverse Features | ✅(50%) | ❌ | We have videos / images support inside world, but not iframes (custom protocol channel) |
| Sounds | ✅ | ✅ | |
| Resource Packs | ✅(+extras) | ✅ | This project has very limited support for them (only textures images are loadable for now) |
| Assets Compressing & Splitting | ✅ | ❌ | We have advanced Minecraft data processing and good code chunk splitting so the web app will open much faster and use less memory |
| Graphics | | | |
| Fancy Graphics | ❌ | ✅ | While Eaglercraft has top-level shaders we don't even support lighting |
| Fast & Efficient Graphics | ❌(+) | ❌ | Feels like no one needs to have 64 rendering distance work smoothly |
| VR | ✅ | ❌ | Feels like not needed feature. UI is missing in this project since DOM can't be rendered in VR so Eaglercraft could be better in that aspect |
| VR | ✅(-) | ❌ | Feels like not needed feature. UI is missing in this project since DOM can't be rendered in VR so Eaglercraft could be better in that aspect |
| AR | ❌ | ❌ | Would be the most useless feature |
| Minimap & Waypoints | ✅(-) | ❌ | We have buggy minimap, which can be enabled in settings and full map is opened by pressing `M` key |

39
assets/config.html Normal file
View file

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

View file

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

237
assets/debug-inputs.html Normal file
View file

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

View file

@ -3,25 +3,36 @@
"defaultHost": "<from-proxy>",
"defaultProxy": "https://proxy.mcraft.fun",
"mapsProvider": "https://maps.mcraft.fun/",
"skinTexturesProxy": "",
"peerJsServer": "",
"peerJsServerFallback": "https://p2p.mcraft.fun",
"promoteServers": [
{
"ip": "wss://mcraft.ryzyn.xyz",
"version": "1.19.4"
},
{
"ip": "wss://play.mcraft.fun"
},
{
"ip": "wss://play.webmc.fun",
"name": "WebMC"
},
{
"ip": "wss://ws.fuchsmc.net"
},
{
"ip": "wss://play2.mcraft.fun"
},
{
"ip": "wss://play-creative.mcraft.fun",
"description": "Might be available soon, stay tuned!"
},
{
"ip": "kaboom.pw",
"version": "1.20.3",
"description": "Very nice a polite server. Must try for everyone!"
}
],
"rightSideText": "A Minecraft client clone in the browser!",
"splashText": "The sunset is coming!",
"splashTextFallback": "Welcome!",
"pauseLinks": [
[
{
@ -31,5 +42,39 @@
"type": "discord"
}
]
],
"defaultUsername": "mcrafter{0-9999}",
"mobileButtons": [
{
"action": "general.drop",
"actionHold": "general.dropStack",
"label": "Q"
},
{
"action": "general.selectItem",
"actionHold": "",
"label": "S"
},
{
"action": "general.debugOverlay",
"actionHold": "general.debugOverlayHelpMenu",
"label": "F3"
},
{
"action": "general.playersList",
"actionHold": "",
"icon": "pixelarticons:users",
"label": "TAB"
},
{
"action": "general.chat",
"actionHold": "",
"label": ""
},
{
"action": "ui.pauseMenu",
"actionHold": "",
"label": ""
}
]
}

5
config.mcraft-only.json Normal file
View file

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

View file

@ -1,5 +1,7 @@
import { defineConfig } from 'cypress'
const isPerformanceTest = process.env.PERFORMANCE_TEST === 'true'
export default defineConfig({
video: false,
chromeWebSecurity: false,
@ -32,7 +34,7 @@ export default defineConfig({
return require('./cypress/plugins/index.js')(on, config)
},
baseUrl: 'http://localhost:8080',
specPattern: 'cypress/e2e/**/*.spec.ts',
specPattern: !isPerformanceTest ? 'cypress/e2e/smoke.spec.ts' : 'cypress/e2e/rendering_performance.spec.ts',
excludeSpecPattern: ['**/__snapshots__/*', '**/__image_snapshots__/*'],
},
})

View file

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

View file

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

108
experiments/three-item.ts Normal file
View file

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

View file

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

View file

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

View file

@ -1,101 +1,60 @@
import * as THREE from 'three'
import * as tweenJs from '@tweenjs/tween.js'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import * as THREE from 'three';
import Jimp from 'jimp';
// Create scene, camera and renderer
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
camera.position.set(0, 0, 5)
const renderer = new THREE.WebGLRenderer()
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)
const controls = new OrbitControls(camera, renderer.domElement)
// Position camera
camera.position.z = 5
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 })
const cube = new THREE.Mesh(geometry, material)
cube.position.set(0.5, 0.5, 0.5);
const group = new THREE.Group()
group.add(cube)
group.position.set(-0.5, -0.5, -0.5);
const outerGroup = new THREE.Group()
outerGroup.add(group)
outerGroup.scale.set(0.2, 0.2, 0.2)
outerGroup.position.set(1, 1, 0)
scene.add(outerGroup)
// Create a canvas with some content
const canvas = document.createElement('canvas')
canvas.width = 256
canvas.height = 256
const ctx = canvas.getContext('2d')
// const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({ color: 0x00_00_ff, transparent: true, opacity: 0.5 }))
// mesh.position.set(0.5, 1, 0.5)
// const group = new THREE.Group()
// group.add(mesh)
// group.position.set(-0.5, -1, -0.5)
// const outerGroup = new THREE.Group()
// outerGroup.add(group)
// // outerGroup.position.set(this.camera.position.x, this.camera.position.y, this.camera.position.z)
// scene.add(outerGroup)
scene.background = new THREE.Color(0x444444)
new tweenJs.Tween(group.rotation).to({ z: THREE.MathUtils.degToRad(90) }, 1000).yoyo(true).repeat(Infinity).start()
// Draw something on the canvas
ctx.fillStyle = '#444444'
// ctx.fillRect(0, 0, 256, 256)
ctx.fillStyle = 'red'
ctx.font = '48px Arial'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText('Hello!', 128, 128)
const tweenGroup = new tweenJs.Group()
function animate () {
tweenGroup.update()
requestAnimationFrame(animate)
// cube.rotation.x += 0.01
// cube.rotation.y += 0.01
renderer.render(scene, camera)
// Create bitmap and texture
async function createTexturedBox() {
const canvas2 = new OffscreenCanvas(256, 256)
const ctx2 = canvas2.getContext('2d')!
ctx2.drawImage(canvas, 0, 0)
const texture = new THREE.Texture(canvas2)
texture.magFilter = THREE.NearestFilter
texture.minFilter = THREE.NearestFilter
texture.needsUpdate = true
texture.flipY = false
// Create box with texture
const geometry = new THREE.BoxGeometry(2, 2, 2)
const material = new THREE.MeshBasicMaterial({
map: texture,
side: THREE.DoubleSide,
premultipliedAlpha: false,
})
const cube = new THREE.Mesh(geometry, material)
scene.add(cube)
}
// Create the textured box
createTexturedBox()
// Animation loop
function animate() {
requestAnimationFrame(animate)
renderer.render(scene, camera)
}
animate()
// let animation
window.animate = () => {
// new Tween.Tween(group.position).to({ y: group.position.y - 1}, 1000 * 0.35/2).yoyo(true).repeat(1).start()
new tweenJs.Tween(group.rotation, tweenGroup).to({ z: THREE.MathUtils.degToRad(90) }, 1000 * 0.35 / 2).yoyo(true).repeat(Infinity).start().onRepeat(() => {
console.log('done')
})
}
window.stop = () => {
tweenGroup.removeAll()
}
function createGeometryFromImage() {
return new Promise<THREE.ShapeGeometry>((resolve, reject) => {
const img = new Image();
img.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAABEElEQVQ4jWNkIAPw2Zv9J0cfXPOSvx/+L/n74T+HqsJ/JlI1T9u3i6H91B7ybdY+vgZuO1majV+fppFmPnuz/+ihy2dv9t/49Wm8mlECkV1FHh5FfPZm/1XXTGX4cechA4eKPMNVq1CGH7cfMBJ0rlxX+X8OVYX/xq9P/5frKifoZ0Z0AwS8HRkYGBgYvt+8xyDXUUbQZgwJPnuz/+wq8gw/7zxk+PXsFUFno0h6mon+l5fgZFhwnYmBTUqMgYGBgaAhLMiaHQyFGOZvf8Lw49FXRgYGhv8MDAwwg/7jMoQFFury/C8Y5m9/wnADohnZVryJhoWBARJ9Cw69gtmMAgiFAcuvZ68Yfj17hU8NXgAATdKfkzbQhBEAAAAASUVORK5CYII='
console.log('img.complete', img.complete)
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const context = canvas.getContext('2d');
context.drawImage(img, 0, 0, img.width, img.height);
const imgData = context.getImageData(0, 0, img.width, img.height);
const shape = new THREE.Shape();
for (let y = 0; y < img.height; y++) {
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const alpha = imgData.data[index + 3];
if (alpha !== 0) {
shape.lineTo(x, y);
}
}
}
const geometry = new THREE.ShapeGeometry(shape);
resolve(geometry);
};
img.onerror = reject;
});
}
// Usage:
const shapeGeomtry = createGeometryFromImage().then(geometry => {
const material = new THREE.MeshBasicMaterial({ color: 0xffffff });
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
})

View file

@ -27,6 +27,7 @@
<div style="font-size: var(--font-size);color: rgb(176, 176, 176);margin-top: 3px;text-align: center" class="subtitle">A true Minecraft client in your browser!</div>
<!-- small text pre -->
<div style="font-size: calc(var(--font-size) * 0.6);color: rgb(150, 150, 150);margin-top: 3px;text-align: center;white-space: pre-line;" class="advanced-info"></div>
<div style="font-size: calc(var(--font-size) * 0.6);color: rgb(255, 100, 100);margin-top: 10px;text-align: center;display: none;" class="ios-warning">Only iOS 15+ is supported due to performance optimizations</div>
</div>
</div>
`
@ -36,6 +37,13 @@
if (!window.pageLoaded) {
document.documentElement.appendChild(loadingDivElem)
}
// iOS version detection
const getIOSVersion = () => {
const match = navigator.userAgent.match(/OS (\d+)_(\d+)_?(\d+)?/);
return match ? parseInt(match[1], 10) : null;
}
// load error handling
const onError = (errorOrMessage, log = false) => {
let message = errorOrMessage instanceof Error ? (errorOrMessage.stack ?? errorOrMessage.message) : errorOrMessage
@ -46,12 +54,23 @@
const [errorMessage, ...errorStack] = message.split('\n')
document.querySelector('.initial-loader').querySelector('.subtitle').textContent = errorMessage
document.querySelector('.initial-loader').querySelector('.advanced-info').textContent = errorStack.join('\n')
// Show iOS warning if applicable
const iosVersion = getIOSVersion();
if (iosVersion !== null && iosVersion < 15) {
document.querySelector('.initial-loader').querySelector('.ios-warning').style.display = 'block';
}
if (window.navigator.maxTouchPoints > 1) window.location.hash = '#dev' // show eruda
// unregister all sw
if (window.navigator.serviceWorker) {
if (window.navigator.serviceWorker && document.querySelector('.initial-loader').style.opacity !== 0) {
console.log('got worker')
window.navigator.serviceWorker.getRegistrations().then(registrations => {
registrations.forEach(registration => {
registration.unregister()
console.log('got registration')
registration.unregister().then(() => {
console.log('worker unregistered')
})
})
})
}
@ -130,7 +149,7 @@
</script> -->
<title>Minecraft Web Client</title>
<!-- <link rel="canonical" href="https://mcraft.fun"> -->
<meta name="description" content="Minecraft web client running in your browser">
<meta name="description" content="Minecraft Java Edition Client in Browser — Full Multiplayer Support, Server Connect, Offline Play — Join real Minecraft servers">
<meta name="keywords" content="Play, Minecraft, Online, Web, Java, Server, Single player, Javascript, PrismarineJS, Voxel, WebGL, Three.js">
<meta name="date" content="2024-07-11" scheme="YYYY-MM-DD">
<meta name="language" content="English">

View file

@ -7,12 +7,14 @@
"dev-proxy": "node server.js",
"start": "run-p dev-proxy dev-rsbuild watch-mesher",
"start2": "run-p dev-rsbuild watch-mesher",
"start-metrics": "ENABLE_METRICS=true rsbuild dev",
"build": "pnpm build-other-workers && rsbuild build",
"build-analyze": "BUNDLE_ANALYZE=true rsbuild build && pnpm build-other-workers",
"build-single-file": "SINGLE_FILE_BUILD=true rsbuild build",
"prepare-project": "tsx scripts/genShims.ts && tsx scripts/makeOptimizedMcData.mjs && tsx scripts/genLargeDataAliases.ts",
"check-build": "pnpm prepare-project && tsc && pnpm build",
"test:cypress": "cypress run",
"test:benchmark": "PERFORMANCE_TEST=true cypress run",
"test:cypress:open": "cypress open",
"test-unit": "vitest",
"test:e2e": "start-test http-get://localhost:8080 test:cypress",
@ -30,7 +32,9 @@
"run-playground": "run-p watch-mesher watch-other-workers watch-playground",
"run-all": "run-p start run-playground",
"build-playground": "rsbuild build --config renderer/rsbuild.config.ts",
"watch-playground": "rsbuild dev --config renderer/rsbuild.config.ts"
"watch-playground": "rsbuild dev --config renderer/rsbuild.config.ts",
"update-git-deps": "tsx scripts/updateGitDeps.ts",
"request-data": "tsx scripts/requestData.ts"
},
"keywords": [
"prismarine",
@ -50,8 +54,9 @@
"dependencies": {
"@dimaka/interface": "0.0.3-alpha.0",
"@floating-ui/react": "^0.26.1",
"@nxg-org/mineflayer-auto-jump": "^0.7.12",
"@nxg-org/mineflayer-tracker": "1.2.1",
"@monaco-editor/react": "^4.7.0",
"@nxg-org/mineflayer-auto-jump": "^0.7.18",
"@nxg-org/mineflayer-tracker": "1.3.0",
"@react-oauth/google": "^0.12.1",
"@stylistic/eslint-plugin": "^2.6.1",
"@types/gapi": "^0.0.47",
@ -75,18 +80,19 @@
"esbuild-plugin-polyfill-node": "^0.3.0",
"express": "^4.18.2",
"filesize": "^10.0.12",
"flying-squid": "npm:@zardoy/flying-squid@^0.0.51",
"flying-squid": "npm:@zardoy/flying-squid@^0.0.104",
"framer-motion": "^12.9.2",
"fs-extra": "^11.1.1",
"google-drive-browserfs": "github:zardoy/browserfs#google-drive",
"jszip": "^3.10.1",
"lodash-es": "^4.17.21",
"minecraft-data": "3.83.1",
"mcraft-fun-mineflayer": "^0.1.23",
"minecraft-data": "3.98.0",
"minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master",
"mineflayer-item-map-downloader": "github:zardoy/mineflayer-item-map-downloader",
"mojangson": "^2.0.4",
"net-browserify": "github:zardoy/prismarinejs-net-browserify",
"node-gzip": "^1.1.2",
"mcraft-fun-mineflayer": "^0.1.10",
"peerjs": "^1.5.0",
"pixelarticons": "^1.8.1",
"pretty-bytes": "^6.1.1",
@ -100,7 +106,6 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-select": "^5.8.0",
"react-transition-group": "^4.4.5",
"react-zoom-pan-pinch": "3.4.4",
"remark": "^15.0.1",
"sanitize-filename": "^1.6.3",
@ -118,11 +123,11 @@
"workbox-build": "^7.0.0"
},
"devDependencies": {
"@rsbuild/core": "^1.0.1-beta.9",
"@rsbuild/plugin-node-polyfill": "^1.0.3",
"@rsbuild/plugin-react": "^1.0.1-beta.9",
"@rsbuild/plugin-type-check": "^1.0.1-beta.9",
"@rsbuild/plugin-typed-css-modules": "^1.0.1",
"@rsbuild/core": "1.3.5",
"@rsbuild/plugin-node-polyfill": "1.3.0",
"@rsbuild/plugin-react": "1.2.0",
"@rsbuild/plugin-type-check": "1.2.1",
"@rsbuild/plugin-typed-css-modules": "1.0.2",
"@storybook/addon-essentials": "^7.4.6",
"@storybook/addon-links": "^7.4.6",
"@storybook/blocks": "^7.4.6",
@ -130,7 +135,6 @@
"@storybook/react-vite": "^7.4.6",
"@types/diff-match-patch": "^1.0.36",
"@types/lodash-es": "^4.17.9",
"@types/react-transition-group": "^4.4.7",
"@types/stats.js": "^0.17.1",
"@types/three": "0.154.0",
"@types/ua-parser-js": "^0.7.39",
@ -140,7 +144,7 @@
"browserify-zlib": "^0.2.0",
"buffer": "^6.0.3",
"constants-browserify": "^1.0.0",
"contro-max": "^0.1.8",
"contro-max": "^0.1.9",
"crypto-browserify": "^3.12.0",
"cypress-esbuild-preprocessor": "^1.0.2",
"eslint": "^8.50.0",
@ -150,17 +154,16 @@
"http-browserify": "^1.7.0",
"http-server": "^14.1.1",
"https-browserify": "^1.0.0",
"mc-assets": "^0.2.42",
"mineflayer-mouse": "^0.0.9",
"mc-assets": "^0.2.62",
"minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next",
"mineflayer": "github:zardoy/mineflayer",
"mineflayer-pathfinder": "^2.4.4",
"mineflayer": "github:zardoy/mineflayer#gen-the-master",
"mineflayer-mouse": "^0.1.21",
"npm-run-all": "^4.1.5",
"os-browserify": "^0.3.0",
"path-browserify": "^1.0.1",
"path-exists-cli": "^2.0.0",
"renderer": "link:renderer",
"process": "github:PrismarineJS/node-process",
"renderer": "link:renderer",
"rimraf": "^5.0.1",
"storybook": "^7.4.6",
"stream-browserify": "^3.0.0",
@ -194,14 +197,15 @@
},
"pnpm": {
"overrides": {
"mineflayer": "github:zardoy/mineflayer#gen-the-master",
"@nxg-org/mineflayer-physics-util": "1.8.10",
"buffer": "^6.0.3",
"vec3": "0.1.10",
"@nxg-org/mineflayer-physics-util": "1.5.8",
"three": "0.154.0",
"diamond-square": "github:zardoy/diamond-square",
"prismarine-block": "github:zardoy/prismarine-block#next-era",
"prismarine-world": "github:zardoy/prismarine-world#next-era",
"minecraft-data": "3.83.1",
"minecraft-data": "3.98.0",
"prismarine-provider-anvil": "github:zardoy/prismarine-provider-anvil#everything",
"prismarine-physics": "github:zardoy/prismarine-physics",
"minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master",
@ -210,14 +214,29 @@
"prismarine-item": "latest"
},
"updateConfig": {
"ignoreDependencies": []
"ignoreDependencies": [
"browserfs",
"google-drive-browserfs"
]
},
"patchedDependencies": {
"three@0.154.0": "patches/three@0.154.0.patch",
"pixelarticons@1.8.1": "patches/pixelarticons@1.8.1.patch",
"mineflayer-item-map-downloader@1.2.0": "patches/mineflayer-item-map-downloader@1.2.0.patch",
"minecraft-protocol@1.54.0": "patches/minecraft-protocol@1.54.0.patch"
}
"minecraft-protocol": "patches/minecraft-protocol.patch"
},
"ignoredBuiltDependencies": [
"canvas",
"core-js",
"gl"
],
"onlyBuiltDependencies": [
"sharp",
"cypress",
"esbuild",
"fsevents"
],
"ignorePatchFailures": false,
"allowUnusedPatches": false
},
"packageManager": "pnpm@9.0.4"
"packageManager": "pnpm@10.8.0+sha512.0e82714d1b5b43c74610193cb20734897c1d00de89d0e18420aebc5977fa13d780a9cb05734624e81ebd81cc876cd464794850641c48b9544326b5622ca29971"
}

View file

@ -1,26 +1,26 @@
diff --git a/src/client/chat.js b/src/client/chat.js
index f14269bea055d4329cd729271e7406ec4b344de7..00f5482eb6e3c911381ca9a728b1b4aae0d1d337 100644
index 0021870994fc59a82f0ac8aba0a65a8be43ef2f4..a53fceb843105ea2a1d88722b3fc7c3b43cb102a 100644
--- a/src/client/chat.js
+++ b/src/client/chat.js
@@ -111,7 +111,7 @@ module.exports = function (client, options) {
for (const player of packet.data) {
if (!player.chatSession) continue
client._players[player.UUID] = {
- publicKey: crypto.createPublicKey({ key: player.chatSession.publicKey.keyBytes, format: 'der', type: 'spki' }),
+ // publicKey: crypto.createPublicKey({ key: player.chatSession.publicKey.keyBytes, format: 'der', type: 'spki' }),
publicKeyDER: player.chatSession.publicKey.keyBytes,
sessionUuid: player.chatSession.uuid
}
@@ -127,7 +127,7 @@ module.exports = function (client, options) {
for (const player of packet.data) {
if (player.crypto) {
client._players[player.UUID] = {
- publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }),
+ // publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }),
publicKeyDER: player.crypto.publicKey,
signature: player.crypto.signature,
displayName: player.displayName || player.name
@@ -198,7 +198,7 @@ module.exports = function (client, options) {
@@ -116,7 +116,7 @@ module.exports = function (client, options) {
for (const player of packet.data) {
if (player.chatSession) {
client._players[player.uuid] = {
- publicKey: crypto.createPublicKey({ key: player.chatSession.publicKey.keyBytes, format: 'der', type: 'spki' }),
+ // publicKey: crypto.createPublicKey({ key: player.chatSession.publicKey.keyBytes, format: 'der', type: 'spki' }),
publicKeyDER: player.chatSession.publicKey.keyBytes,
sessionUuid: player.chatSession.uuid
}
@@ -126,7 +126,7 @@ module.exports = function (client, options) {
if (player.crypto) {
client._players[player.uuid] = {
- publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }),
+ // publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }),
publicKeyDER: player.crypto.publicKey,
signature: player.crypto.signature,
displayName: player.displayName || player.name
@@ -196,7 +196,7 @@ module.exports = function (client, options) {
if (mcData.supportFeature('useChatSessions')) {
const tsDelta = BigInt(Date.now()) - packet.timestamp
const expired = !packet.timestamp || tsDelta > messageExpireTime || tsDelta < 0
@ -28,8 +28,8 @@ index f14269bea055d4329cd729271e7406ec4b344de7..00f5482eb6e3c911381ca9a728b1b4aa
+ const verified = false && !packet.unsignedChatContent && updateAndValidateSession(packet.senderUuid, packet.plainMessage, packet.signature, packet.index, packet.previousMessages, packet.salt, packet.timestamp) && !expired
if (verified) client._signatureCache.push(packet.signature)
client.emit('playerChat', {
plainMessage: packet.plainMessage,
@@ -363,7 +363,7 @@ module.exports = function (client, options) {
globalIndex: packet.globalIndex,
@@ -362,7 +362,7 @@ module.exports = function (client, options) {
}
}
@ -38,16 +38,16 @@ index f14269bea055d4329cd729271e7406ec4b344de7..00f5482eb6e3c911381ca9a728b1b4aa
options.timestamp = options.timestamp || BigInt(Date.now())
options.salt = options.salt || 1n
@@ -405,7 +405,7 @@ module.exports = function (client, options) {
@@ -407,7 +407,7 @@ module.exports = function (client, options) {
message,
timestamp: options.timestamp,
salt: options.salt,
- signature: (client.profileKeys && client._session) ? client.signMessage(message, options.timestamp, options.salt, undefined, acknowledgements) : undefined,
+ signature: (client.profileKeys && client._session) ? await client.signMessage(message, options.timestamp, options.salt, undefined, acknowledgements) : undefined,
offset: client._lastSeenMessages.pending,
checksum: computeChatChecksum(client._lastSeenMessages), // 1.21.5+
acknowledged
})
@@ -419,7 +419,7 @@ module.exports = function (client, options) {
@@ -422,7 +422,7 @@ module.exports = function (client, options) {
message,
timestamp: options.timestamp,
salt: options.salt,
@ -57,7 +57,7 @@ index f14269bea055d4329cd729271e7406ec4b344de7..00f5482eb6e3c911381ca9a728b1b4aa
previousMessages: client._lastSeenMessages.map((e) => ({
messageSender: e.sender,
diff --git a/src/client/encrypt.js b/src/client/encrypt.js
index b9d21bab9faccd5dbf1975fc423fc55c73e906c5..99ffd76527b410e3a393181beb260108f4c63536 100644
index 63cc2bd9615100bd2fd63dfe14c094aa6b8cd1c9..36df57d1196af9761d920fa285ac48f85410eaef 100644
--- a/src/client/encrypt.js
+++ b/src/client/encrypt.js
@@ -25,7 +25,11 @@ module.exports = function (client, options) {
@ -73,28 +73,24 @@ index b9d21bab9faccd5dbf1975fc423fc55c73e906c5..99ffd76527b410e3a393181beb260108
}
function onJoinServerResponse (err) {
diff --git a/src/client/pluginChannels.js b/src/client/pluginChannels.js
index 671eb452f31e6b5fcd57d715f1009d010160c65f..7f69f511c8fb97d431ec5125c851b49be8e2ab76 100644
--- a/src/client/pluginChannels.js
+++ b/src/client/pluginChannels.js
@@ -57,7 +57,7 @@ module.exports = function (client, options) {
try {
packet.data = proto.parsePacketBuffer(channel, packet.data).data
} catch (error) {
- client.emit('error', error)
+ client.emit('error', error, { customPayload: packet })
return
}
}
diff --git a/src/client.js b/src/client.js
index 74749698f8cee05b5dc749c271544f78d06645b0..e77e0a3f41c1ee780c3abbd54b0801d248c2a07c 100644
index e369e77d055ba919e8f9da7b8e8b5dc879c74cf4..54bb9e6644388e9b6bd42b3012951875989cdf0c 100644
--- a/src/client.js
+++ b/src/client.js
@@ -89,10 +89,12 @@ class Client extends EventEmitter {
parsed.metadata.name = parsed.data.name
parsed.data = parsed.data.params
parsed.metadata.state = state
- debug('read packet ' + state + '.' + parsed.metadata.name)
- if (debug.enabled) {
- const s = JSON.stringify(parsed.data, null, 2)
- debug(s && s.length > 10000 ? parsed.data : s)
+ if (!globalThis.excludeCommunicationDebugEvents?.includes(parsed.metadata.name)) {
+ debug('read packet ' + state + '.' + parsed.metadata.name)
+ if (debug.enabled) {
+ const s = JSON.stringify(parsed.data, null, 2)
+ debug(s && s.length > 10000 ? parsed.data : s)
+ }
}
if (this._hasBundlePacket && parsed.metadata.name === 'bundle_delimiter') {
if (this._mcBundle.length) { // End bundle
@@ -110,7 +112,13 @@ class Client extends EventEmitter {
@@ -111,7 +111,13 @@ class Client extends EventEmitter {
this._hasBundlePacket = false
}
} else {
@ -109,7 +105,7 @@ index 74749698f8cee05b5dc749c271544f78d06645b0..e77e0a3f41c1ee780c3abbd54b0801d2
}
})
}
@@ -168,7 +176,10 @@ class Client extends EventEmitter {
@@ -169,7 +175,10 @@ class Client extends EventEmitter {
}
const onFatalError = (err) => {
@ -121,25 +117,21 @@ index 74749698f8cee05b5dc749c271544f78d06645b0..e77e0a3f41c1ee780c3abbd54b0801d2
endSocket()
}
@@ -197,6 +208,8 @@ class Client extends EventEmitter {
@@ -198,6 +207,10 @@ class Client extends EventEmitter {
serializer -> framer -> socket -> splitter -> deserializer */
if (this.serializer) {
this.serializer.end()
+ this.socket?.end()
+ this.socket?.emit('end')
+ setTimeout(() => {
+ this.socket?.end()
+ this.socket?.emit('end')
+ }, 2000) // allow the serializer to finish writing
} else {
if (this.socket) this.socket.end()
}
@@ -238,8 +251,11 @@ class Client extends EventEmitter {
write (name, params) {
if (!this.serializer.writable) { return }
- debug('writing packet ' + this.state + '.' + name)
- debug(params)
+ if (!globalThis.excludeCommunicationDebugEvents?.includes(name)) {
+ debug(`[${this.state}] from ${this.isServer ? 'server' : 'client'}: ` + name)
+ debug(params)
+ }
@@ -243,6 +256,7 @@ class Client extends EventEmitter {
debug('writing packet ' + this.state + '.' + name)
debug(params)
}
+ this.emit('writePacket', name, params)
this.serializer.write({ name, params })
}

View file

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

View file

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

11249
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -22,7 +22,7 @@ const buildOptions = {
},
platform: 'browser',
entryPoints: [path.join(__dirname, './viewer/lib/mesher/mesher.ts')],
minify: true,
minify: !watch,
logLevel: 'info',
drop: !watch ? [
'debugger'
@ -35,11 +35,15 @@ const buildOptions = {
define: {
'process.env.BROWSER': '"true"',
},
loader: {
'.png': 'dataurl',
'.obj': 'text'
},
plugins: [
...mesherSharedPlugins,
{
name: 'external-json',
setup (build) {
setup(build) {
build.onResolve({ filter: /\.json$/ }, args => {
const fileName = args.path.split('/').pop().replace('.json', '')
if (args.resolveDir.includes('minecraft-data')) {

View file

@ -11,11 +11,17 @@
html, body {
height: 100%;
touch-action: none;
margin: 0;
padding: 0;
}
* {
user-select: none;
-webkit-user-select: none;
}
canvas {
height: 100%;
width: 100%;

View file

@ -0,0 +1,170 @@
import { EntityMesh, rendererSpecialHandled, EntityDebugFlags } from '../viewer/three/entity/EntityMesh'
export const displayEntitiesDebugList = (version: string) => {
// Create results container
const container = document.createElement('div')
container.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-height: 90vh;
overflow-y: auto;
background: rgba(0,0,0,0.8);
color: white;
padding: 20px;
border-radius: 8px;
font-family: monospace;
min-width: 400px;
z-index: 1000;
`
document.body.appendChild(container)
// Add title
const title = document.createElement('h2')
title.textContent = 'Minecraft Entity Support'
title.style.cssText = 'margin-top: 0; text-align: center;'
container.appendChild(title)
// Test entities
const results: Array<{
entity: string;
supported: boolean;
type?: 'obj' | 'bedrock' | 'special';
mappedFrom?: string;
textureMap?: boolean;
errors?: string[];
}> = []
const { mcData } = window
const entityNames = Object.keys(mcData.entitiesArray.reduce((acc, entity) => {
acc[entity.name] = true
return acc
}, {}))
// Add loading indicator
const loading = document.createElement('div')
loading.textContent = 'Testing entities...'
loading.style.textAlign = 'center'
container.appendChild(loading)
for (const entity of entityNames) {
const debugFlags: EntityDebugFlags = {}
if (rendererSpecialHandled.includes(entity)) {
results.push({
entity,
supported: true,
type: 'special',
})
continue
}
try {
const { mesh: entityMesh } = new EntityMesh(version, entity, undefined, {}, debugFlags)
// find the most distant pos child
window.objects ??= {}
window.objects[entity] = entityMesh
results.push({
entity,
supported: !!debugFlags.type || rendererSpecialHandled.includes(entity),
type: debugFlags.type,
mappedFrom: debugFlags.tempMap,
textureMap: debugFlags.textureMap,
errors: debugFlags.errors
})
} catch (e) {
console.error(e)
results.push({
entity,
supported: false,
mappedFrom: debugFlags.tempMap
})
}
}
// Remove loading indicator
loading.remove()
const createSection = (title: string, items: any[], filter: (item: any) => boolean) => {
const section = document.createElement('div')
section.style.marginBottom = '20px'
const sectionTitle = document.createElement('h3')
sectionTitle.textContent = title
sectionTitle.style.textAlign = 'center'
section.appendChild(sectionTitle)
const list = document.createElement('ul')
list.style.cssText = 'padding-left: 20px; list-style-type: none; margin: 0;'
const filteredItems = items.filter(filter)
for (const item of filteredItems) {
const listItem = document.createElement('li')
listItem.style.cssText = 'line-height: 1.4; margin: 8px 0;'
const entityName = document.createElement('strong')
entityName.style.cssText = 'user-select: text;-webkit-user-select: text;'
entityName.textContent = item.entity
listItem.appendChild(entityName)
let text = ''
if (item.mappedFrom) {
text += ` -> ${item.mappedFrom}`
}
if (item.type) {
text += ` - ${item.type}`
}
if (item.textureMap) {
text += ' ⚠️'
}
if (item.errors) {
text += ' ❌'
}
listItem.appendChild(document.createTextNode(text))
list.appendChild(listItem)
}
section.appendChild(list)
return { section, count: filteredItems.length }
}
// Sort results - bedrock first
results.sort((a, b) => {
if (a.type === 'bedrock' && b.type !== 'bedrock') return -1
if (a.type !== 'bedrock' && b.type === 'bedrock') return 1
return a.entity.localeCompare(b.entity)
})
// Add sections
const sections = [
{
title: '❌ Unsupported Entities',
filter: (r: any) => !r.supported && !r.mappedFrom
},
{
title: '⚠️ Partially Supported Entities',
filter: (r: any) => r.mappedFrom
},
{
title: '✅ Supported Entities',
filter: (r: any) => r.supported && !r.mappedFrom
}
]
for (const { title, filter } of sections) {
const { section, count } = createSection(title, results, filter)
if (count > 0) {
container.appendChild(section)
}
}
// log object with errors per entity
const errors = results.filter(r => r.errors).map(r => ({
entity: r.entity,
errors: r.errors
}))
console.log(errors)
}

View file

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

View file

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

View file

@ -1,5 +1,6 @@
import { BasePlaygroundScene } from '../baseScene'
import { EntityDebugFlags, EntityMesh, rendererSpecialHandled } from '../../viewer/lib/entity/EntityMesh'
import { EntityDebugFlags, EntityMesh, rendererSpecialHandled } from '../../viewer/three/entity/EntityMesh'
import { displayEntitiesDebugList } from '../allEntitiesDebug'
export default class AllEntities extends BasePlaygroundScene {
continuousRender = false
@ -7,159 +8,6 @@ export default class AllEntities extends BasePlaygroundScene {
async initData () {
await super.initData()
// Create results container
const container = document.createElement('div')
container.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-height: 90vh;
overflow-y: auto;
background: rgba(0,0,0,0.8);
color: white;
padding: 20px;
border-radius: 8px;
font-family: monospace;
min-width: 400px;
`
document.body.appendChild(container)
// Add title
const title = document.createElement('h2')
title.textContent = 'Minecraft Entity Support'
title.style.cssText = 'margin-top: 0; text-align: center;'
container.appendChild(title)
// Test entities
const results: Array<{
entity: string;
supported: boolean;
type?: 'obj' | 'bedrock';
mappedFrom?: string;
textureMap?: boolean;
errors?: string[];
}> = []
const { mcData } = window
const entityNames = Object.keys(mcData.entitiesArray.reduce((acc, entity) => {
acc[entity.name] = true
return acc
}, {}))
// Add loading indicator
const loading = document.createElement('div')
loading.textContent = 'Testing entities...'
loading.style.textAlign = 'center'
container.appendChild(loading)
for (const entity of entityNames) {
const debugFlags: EntityDebugFlags = {}
try {
// eslint-disable-next-line no-new
new EntityMesh(this.version, entity, viewer.world, {}, debugFlags)
results.push({
entity,
supported: !!debugFlags.type || rendererSpecialHandled.includes(entity),
type: debugFlags.type,
mappedFrom: debugFlags.tempMap,
textureMap: debugFlags.textureMap,
errors: debugFlags.errors
})
} catch (e) {
console.error(e)
results.push({
entity,
supported: false,
mappedFrom: debugFlags.tempMap
})
}
}
// Remove loading indicator
loading.remove()
const createSection = (title: string, items: any[], filter: (item: any) => boolean) => {
const section = document.createElement('div')
section.style.marginBottom = '20px'
const sectionTitle = document.createElement('h3')
sectionTitle.textContent = title
sectionTitle.style.textAlign = 'center'
section.appendChild(sectionTitle)
const list = document.createElement('ul')
list.style.cssText = 'padding-left: 20px; list-style-type: none; margin: 0;'
const filteredItems = items.filter(filter)
for (const item of filteredItems) {
const listItem = document.createElement('li')
listItem.style.cssText = 'line-height: 1.4; margin: 8px 0;'
const entityName = document.createElement('strong')
entityName.style.cssText = 'user-select: text;-webkit-user-select: text;'
entityName.textContent = item.entity
listItem.appendChild(entityName)
let text = ''
if (item.mappedFrom) {
text += ` -> ${item.mappedFrom}`
}
if (item.type) {
text += ` - ${item.type}`
}
if (item.textureMap) {
text += ' ⚠️'
}
if (item.errors) {
text += ' ❌'
}
listItem.appendChild(document.createTextNode(text))
list.appendChild(listItem)
}
section.appendChild(list)
return { section, count: filteredItems.length }
}
// Sort results - bedrock first
results.sort((a, b) => {
if (a.type === 'bedrock' && b.type !== 'bedrock') return -1
if (a.type !== 'bedrock' && b.type === 'bedrock') return 1
return a.entity.localeCompare(b.entity)
})
// Add sections
const sections = [
{
title: '❌ Unsupported Entities',
filter: (r: any) => !r.supported && !r.mappedFrom
},
{
title: '⚠️ Partially Supported Entities',
filter: (r: any) => r.mappedFrom
},
{
title: '✅ Supported Entities',
filter: (r: any) => r.supported && !r.mappedFrom
}
]
for (const { title, filter } of sections) {
const { section, count } = createSection(title, results, filter)
if (count > 0) {
container.appendChild(section)
}
}
// log object with errors per entity
const errors = results.filter(r => r.errors).map(r => ({
entity: r.entity,
errors: r.errors
}))
console.log(errors)
displayEntitiesDebugList(this.version)
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -65,7 +65,7 @@ function getAllMethods (obj) {
return [...methods] as string[]
}
export const delayedIterator = async <T> (arr: T[], delay: number, exec: (item: T, index: number) => void, chunkSize = 1) => {
export const delayedIterator = async <T> (arr: T[], delay: number, exec: (item: T, index: number) => Promise<void>, chunkSize = 1) => {
// if delay is 0 then don't use setTimeout
for (let i = 0; i < arr.length; i += chunkSize) {
if (delay) {
@ -74,6 +74,6 @@ export const delayedIterator = async <T> (arr: T[], delay: number, exec: (item:
setTimeout(resolve, delay)
})
}
exec(arr[i], i)
await exec(arr[i], i)
}
}

View file

@ -2,6 +2,7 @@ import { defineConfig, ModifyRspackConfigUtils } from '@rsbuild/core';
import { pluginNodePolyfill } from '@rsbuild/plugin-node-polyfill';
import { pluginReact } from '@rsbuild/plugin-react';
import path from 'path'
import fs from 'fs'
export const appAndRendererSharedConfig = () => defineConfig({
dev: {
@ -60,18 +61,24 @@ export const appAndRendererSharedConfig = () => defineConfig({
],
tools: {
rspack (config, helpers) {
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8'))
const hasFileProtocol = Object.values(packageJson.pnpm.overrides).some((dep) => (dep as string).startsWith('file:'))
if (hasFileProtocol) {
// enable node_modules watching
config.watchOptions.ignored = /\.git/
}
rspackViewerConfig(config, helpers)
}
},
})
export const rspackViewerConfig = (config, { appendPlugins, addRules, rspack }: ModifyRspackConfigUtils) => {
appendPlugins(new rspack.NormalModuleReplacementPlugin(/data/, (resource) => {
appendPlugins(new rspack.NormalModuleReplacementPlugin(/data|prismarine-physics/, (resource) => {
let absolute: string
const request = resource.request.replaceAll('\\', '/')
absolute = path.join(resource.context, request).replaceAll('\\', '/')
if (request.includes('minecraft-data/data/pc/1.')) {
console.log('Error: incompatible resource', request, resource.contextInfo.issuer)
if (request.includes('minecraft-data/data/pc/1.') || request.includes('prismarine-physics')) {
console.log('Error: incompatible resource', request, 'from', resource.contextInfo.issuer)
process.exit(1)
// throw new Error(`${resource.request} was requested by ${resource.contextInfo.issuer}`)
}
@ -101,6 +108,10 @@ export const rspackViewerConfig = (config, { appendPlugins, addRules, rspack }:
{
test: /\.txt$/,
type: 'asset/source',
},
{
test: /\.log$/,
type: 'asset/source',
}
])
config.ignoreWarnings = [

View file

@ -0,0 +1,27 @@
import { proxy } from 'valtio'
import { NonReactiveState, RendererReactiveState } from '../../src/appViewer'
export const getDefaultRendererState = (): {
reactive: RendererReactiveState
nonReactive: NonReactiveState
} => {
return {
reactive: proxy({
world: {
chunksLoaded: new Set(),
heightmaps: new Map(),
allChunksLoaded: true,
mesherWork: false,
intersectMedia: null
},
renderer: '',
preventEscapeMenu: false
}),
nonReactive: {
world: {
chunksLoaded: new Set(),
chunksTotalNumber: 0,
}
}
}
}

View file

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

View file

@ -1,88 +1,87 @@
import { EventEmitter } from 'events'
import { Vec3 } from 'vec3'
import TypedEmitter from 'typed-emitter'
import { ItemSelector } from 'mc-assets/dist/itemDefinitions'
import { GameMode, Team } from 'mineflayer'
import { proxy } from 'valtio'
import { HandItemBlock } from './holdingBlock'
import type { HandItemBlock } from '../three/holdingBlock'
export type MovementState = 'NOT_MOVING' | 'WALKING' | 'SPRINTING' | 'SNEAKING'
export type ItemSpecificContextProperties = Partial<Pick<ItemSelector['properties'], 'minecraft:using_item' | 'minecraft:use_duration' | 'minecraft:use_cycle' | 'minecraft:display_context'>>
export type CameraPerspective = 'first_person' | 'third_person_back' | 'third_person_front'
export type BlockShape = { position: any; width: any; height: any; depth: any; }
export type BlocksShapes = BlockShape[]
export type PlayerStateEvents = {
heldItemChanged: (item: HandItemBlock | undefined, isLeftHand: boolean) => void
}
// edit src/mineflayer/playerState.ts for implementation of player state from mineflayer
export const getInitialPlayerState = () => proxy({
playerSkin: undefined as string | undefined,
inWater: false,
waterBreathing: false,
backgroundColor: [0, 0, 0] as [number, number, number],
ambientLight: 0,
directionalLight: 0,
eyeHeight: 0,
gameMode: undefined as GameMode | undefined,
lookingAtBlock: undefined as {
x: number
y: number
z: number
face?: number
shapes: BlocksShapes
} | undefined,
diggingBlock: undefined as {
x: number
y: number
z: number
stage: number
face?: number
mergedShape: BlockShape | undefined
} | undefined,
movementState: 'NOT_MOVING' as MovementState,
onGround: true,
sneaking: false,
flying: false,
sprinting: false,
itemUsageTicks: 0,
username: '',
onlineMode: false,
lightingDisabled: false,
shouldHideHand: false,
heldItemMain: undefined as HandItemBlock | undefined,
heldItemOff: undefined as HandItemBlock | undefined,
perspective: 'first_person' as CameraPerspective,
onFire: false,
export interface IPlayerState {
getEyeHeight(): number
getMovementState(): MovementState
getVelocity(): Vec3
isOnGround(): boolean
isSneaking(): boolean
isFlying(): boolean
isSprinting (): boolean
getItemUsageTicks?(): number
// isUsingItem?(): boolean
getHeldItem?(isLeftHand: boolean): HandItemBlock | undefined
username?: string
onlineMode?: boolean
cameraSpectatingEntity: undefined as number | undefined,
events: TypedEmitter<PlayerStateEvents>
team: undefined as Team | undefined,
})
reactive: {
playerSkin: string | undefined
}
}
export class BasePlayerState implements IPlayerState {
reactive = proxy({
playerSkin: undefined
})
protected movementState: MovementState = 'NOT_MOVING'
protected velocity = new Vec3(0, 0, 0)
protected onGround = true
protected sneaking = false
protected flying = false
protected sprinting = false
readonly events = new EventEmitter() as TypedEmitter<PlayerStateEvents>
getEyeHeight (): number {
return 1.62
}
getMovementState (): MovementState {
return this.movementState
}
getVelocity (): Vec3 {
return this.velocity
}
isOnGround (): boolean {
return this.onGround
}
isSneaking (): boolean {
return this.sneaking
}
isFlying (): boolean {
return this.flying
}
isSprinting (): boolean {
return this.sprinting
}
// For testing purposes
setState (state: Partial<{
movementState: MovementState
velocity: Vec3
onGround: boolean
sneaking: boolean
flying: boolean
sprinting: boolean
}>) {
Object.assign(this, state)
export const getPlayerStateUtils = (reactive: PlayerStateReactive) => ({
isSpectator () {
return reactive.gameMode === 'spectator'
},
isSpectatingEntity () {
return reactive.cameraSpectatingEntity !== undefined && reactive.gameMode === 'spectator'
},
isThirdPerson () {
if ((this as PlayerStateUtils).isSpectatingEntity()) return false
return reactive.perspective === 'third_person_back' || reactive.perspective === 'third_person_front'
}
})
export const getInitialPlayerStateRenderer = () => ({
reactive: getInitialPlayerState()
})
export type PlayerStateReactive = ReturnType<typeof getInitialPlayerState>
export type PlayerStateUtils = ReturnType<typeof getPlayerStateUtils>
export type PlayerStateRenderer = PlayerStateReactive
export const getItemSelector = (playerState: PlayerStateRenderer, specificProperties: ItemSpecificContextProperties, item?: import('prismarine-item').Item) => {
return {
...specificProperties,
'minecraft:date': new Date(),
// "minecraft:context_dimension": bot.entityp,
// 'minecraft:time': bot.time.timeOfDay / 24_000,
}
}

View file

@ -0,0 +1,55 @@
import { PlayerObject, PlayerAnimation } from 'skinview3d'
import * as THREE from 'three'
import { WalkingGeneralSwing } from '../three/entity/animations'
import { loadSkinImage, stevePngUrl } from './utils/skins'
export type PlayerObjectType = PlayerObject & {
animation?: PlayerAnimation
realPlayerUuid: string
realUsername: string
}
export function createPlayerObject (options: {
username?: string
uuid?: string
scale?: number
}): {
playerObject: PlayerObjectType
wrapper: THREE.Group
} {
const wrapper = new THREE.Group()
const playerObject = new PlayerObject() as PlayerObjectType
playerObject.realPlayerUuid = options.uuid ?? ''
playerObject.realUsername = options.username ?? ''
playerObject.position.set(0, 16, 0)
// fix issues with starfield
playerObject.traverse((obj) => {
if (obj instanceof THREE.Mesh && obj.material instanceof THREE.MeshStandardMaterial) {
obj.material.transparent = true
}
})
wrapper.add(playerObject as any)
const scale = options.scale ?? (1 / 16)
wrapper.scale.set(scale, scale, scale)
wrapper.rotation.set(0, Math.PI, 0)
// Set up animation
playerObject.animation = new WalkingGeneralSwing()
;(playerObject.animation as WalkingGeneralSwing).isMoving = false
playerObject.animation.update(playerObject, 0)
return { playerObject, wrapper }
}
export const applySkinToPlayerObject = async (playerObject: PlayerObjectType, skinUrl: string) => {
return loadSkinImage(skinUrl || stevePngUrl).then(({ canvas }) => {
const skinTexture = new THREE.CanvasTexture(canvas)
skinTexture.magFilter = THREE.NearestFilter
skinTexture.minFilter = THREE.NearestFilter
skinTexture.needsUpdate = true
playerObject.skin.map = skinTexture as any
}).catch(console.error)
}

View file

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

View file

@ -2,27 +2,22 @@
import { ItemRenderer, Identifier, ItemStack, NbtString, Structure, StructureRenderer, ItemRendererResources, BlockDefinition, BlockModel, TextureAtlas, Resources, ItemModel } from 'deepslate'
import { mat4, vec3 } from 'gl-matrix'
import { AssetsParser } from 'mc-assets/dist/assetsParser'
import { getLoadedImage } from 'mc-assets/dist/utils'
import { getLoadedImage, versionToNumber } from 'mc-assets/dist/utils'
import { BlockModel as BlockModelMcAssets, AtlasParser } from 'mc-assets'
import { getLoadedBlockstatesStore, getLoadedModelsStore } from 'mc-assets/dist/stores'
import { makeTextureAtlas } from 'mc-assets/dist/atlasCreator'
import { proxy, ref } from 'valtio'
import { getItemDefinition } from 'mc-assets/dist/itemDefinitions'
import { versionToNumber } from '../prepare/utils'
export const activeGuiAtlas = proxy({
atlas: null as null | { json, image },
})
export const getNonFullBlocksModels = () => {
let version = viewer.world.texturesVersion ?? 'latest'
let version = appViewer.resourcesManager.currentResources!.version ?? 'latest'
if (versionToNumber(version) < versionToNumber('1.13')) version = '1.13'
const itemsDefinitions = viewer.world.itemsDefinitionsStore.data.latest
const itemsDefinitions = appViewer.resourcesManager.itemsDefinitionsStore.data.latest
const blockModelsResolved = {} as Record<string, any>
const itemsModelsResolved = {} as Record<string, any>
const fullBlocksWithNonStandardDisplay = [] as string[]
const handledItemsWithDefinitions = new Set()
const assetsParser = new AssetsParser(version, getLoadedBlockstatesStore(viewer.world.blockstatesModels), getLoadedModelsStore(viewer.world.blockstatesModels))
const assetsParser = new AssetsParser(version, getLoadedBlockstatesStore(appViewer.resourcesManager.currentResources!.blockstatesModels), getLoadedModelsStore(appViewer.resourcesManager.currentResources!.blockstatesModels))
const standardGuiDisplay = {
'rotation': [
@ -48,13 +43,15 @@ export const getNonFullBlocksModels = () => {
if (!model?.elements?.length) return
const isFullBlock = model.elements.length === 1 && arrEqual(model.elements[0].from, [0, 0, 0]) && arrEqual(model.elements[0].to, [16, 16, 16])
if (isFullBlock) return
const hasBetterPrerender = assetsParser.blockModelsStore.data.latest[`item/${name}`]?.textures?.['layer0']?.startsWith('invsprite_')
if (hasBetterPrerender) return
model['display'] ??= {}
model['display']['gui'] ??= standardGuiDisplay
blockModelsResolved[name] = model
}
for (const [name, definition] of Object.entries(itemsDefinitions)) {
const item = getItemDefinition(viewer.world.itemsDefinitionsStore, {
const item = getItemDefinition(appViewer.resourcesManager.itemsDefinitionsStore, {
version,
name,
properties: {
@ -67,7 +64,6 @@ export const getNonFullBlocksModels = () => {
handledItemsWithDefinitions.add(name)
}
if (resolvedModel?.elements) {
let hasStandardDisplay = true
if (resolvedModel['display']?.gui) {
hasStandardDisplay =
@ -97,7 +93,7 @@ export const getNonFullBlocksModels = () => {
}
}
for (const [name, blockstate] of Object.entries(viewer.world.blockstatesModels.blockstates.latest)) {
for (const [name, blockstate] of Object.entries(appViewer.resourcesManager.currentResources!.blockstatesModels.blockstates.latest)) {
if (handledItemsWithDefinitions.has(name)) {
continue
}
@ -120,18 +116,19 @@ export const getNonFullBlocksModels = () => {
const RENDER_SIZE = 64
const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isItems = false) => {
const img = await getLoadedImage(isItems ? viewer.world.itemsAtlasParser!.latestImage : viewer.world.blocksAtlasParser!.latestImage)
const { currentResources } = appViewer.resourcesManager
const imgBitmap = isItems ? currentResources!.itemsAtlasImage : currentResources!.blocksAtlasImage
const canvasTemp = document.createElement('canvas')
canvasTemp.width = img.width
canvasTemp.height = img.height
canvasTemp.width = imgBitmap.width
canvasTemp.height = imgBitmap.height
canvasTemp.style.imageRendering = 'pixelated'
const ctx = canvasTemp.getContext('2d')!
ctx.imageSmoothingEnabled = false
ctx.drawImage(img, 0, 0)
ctx.drawImage(imgBitmap, 0, 0)
const atlasParser = isItems ? viewer.world.itemsAtlasParser! : viewer.world.blocksAtlasParser!
const atlasParser = isItems ? appViewer.resourcesManager.itemsAtlasParser : appViewer.resourcesManager.blocksAtlasParser
const textureAtlas = new TextureAtlas(
ctx.getImageData(0, 0, img.width, img.height),
ctx.getImageData(0, 0, imgBitmap.width, imgBitmap.height),
Object.fromEntries(Object.entries(atlasParser.atlas.latest.textures).map(([key, value]) => {
return [key, [
value.u,
@ -145,6 +142,7 @@ const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isIt
const PREVIEW_ID = Identifier.parse('preview:preview')
const PREVIEW_DEFINITION = new BlockDefinition({ '': { model: PREVIEW_ID.toString() } }, undefined)
let textureWasRequested = false
let modelData: any
let currentModelName: string | undefined
const resources: ItemRendererResources = {
@ -155,6 +153,7 @@ const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isIt
return null
},
getTextureUV (texture) {
textureWasRequested = true
return textureAtlas.getTextureUV(texture.toString().replace('minecraft:', '').replace('block/', '').replace('item/', '').replace('blocks/', '').replace('items/', '') as any)
},
getTextureAtlas () {
@ -203,6 +202,7 @@ const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isIt
const renderer = new ItemRenderer(gl, item, resources, { display_context: 'gui' })
const missingTextures = new Set()
for (const [modelName, model] of Object.entries(models)) {
textureWasRequested = false
if (includeOnly.length && !includeOnly.includes(modelName)) continue
const patchMissingTextures = () => {
@ -224,6 +224,7 @@ const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isIt
if (!modelData) continue
renderer.setItem(item, { display_context: 'gui' })
renderer.drawItem()
if (!textureWasRequested) continue
const url = canvas.toDataURL()
// eslint-disable-next-line no-await-in-loop
const img = await getLoadedImage(url)
@ -237,6 +238,9 @@ const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isIt
return images
}
/**
* @mainThread
*/
const generateAtlas = async (images: Record<string, HTMLImageElement>) => {
const atlas = makeTextureAtlas({
input: Object.keys(images),
@ -254,9 +258,9 @@ const generateAtlas = async (images: Record<string, HTMLImageElement>) => {
// a.download = 'blocks_atlas.png'
// a.click()
activeGuiAtlas.atlas = {
appViewer.resourcesManager.currentResources!.guiAtlas = {
json: atlas.json,
image: ref(await getLoadedImage(atlas.canvas.toDataURL())),
image: await createImageBitmap(atlas.canvas),
}
return atlas
@ -273,5 +277,6 @@ export const generateGuiAtlas = async () => {
const itemImages = await generateItemsGui(itemsModelsResolved, true)
console.timeEnd('generate items gui atlas')
await generateAtlas({ ...blockImages, ...itemImages })
appViewer.resourcesManager.currentResources!.guiAtlasVersion++
// await generateAtlas(blockImages)
}

View file

@ -2,6 +2,7 @@ import { Vec3 } from 'vec3'
import { World } from './world'
import { getSectionGeometry, setBlockStatesData as setMesherData } from './models'
import { BlockStateModelInfo } from './shared'
import { INVISIBLE_BLOCKS } from './worldConstants'
globalThis.structuredClone ??= (value) => JSON.parse(JSON.stringify(value))
@ -53,7 +54,7 @@ function setSectionDirty (pos, value = true) {
const key = sectionKey(x, y, z)
if (!value) {
dirtySections.delete(key)
postMessage({ type: 'sectionFinished', key })
postMessage({ type: 'sectionFinished', key, workerIndex })
return
}
@ -61,7 +62,7 @@ function setSectionDirty (pos, value = true) {
if (chunk?.getSection(pos)) {
dirtySections.set(key, (dirtySections.get(key) || 0) + 1)
} else {
postMessage({ type: 'sectionFinished', key })
postMessage({ type: 'sectionFinished', key, workerIndex })
}
}
@ -76,6 +77,7 @@ const handleMessage = data => {
if (data.type === 'mcData') {
globalVar.mcData = data.mcData
globalVar.loadedData = data.mcData
}
if (data.config) {
@ -121,11 +123,13 @@ const handleMessage = data => {
}
case 'blockUpdate': {
const loc = new Vec3(data.pos.x, data.pos.y, data.pos.z).floored()
world.setBlockStateId(loc, data.stateId)
if (data.stateId !== undefined && data.stateId !== null) {
world?.setBlockStateId(loc, data.stateId)
}
const chunkKey = `${Math.floor(loc.x / 16) * 16},${Math.floor(loc.z / 16) * 16}`
if (data.customBlockModels) {
world.customBlockModels.set(chunkKey, data.customBlockModels)
world?.customBlockModels.set(chunkKey, data.customBlockModels)
}
break
}
@ -135,8 +139,40 @@ const handleMessage = data => {
dirtySections = new Map()
// todo also remove cached
globalVar.mcData = null
globalVar.loadedData = null
allDataReady = false
break
}
case 'getCustomBlockModel': {
const pos = new Vec3(data.pos.x, data.pos.y, data.pos.z)
const chunkKey = `${Math.floor(pos.x / 16) * 16},${Math.floor(pos.z / 16) * 16}`
const customBlockModel = world.customBlockModels.get(chunkKey)?.[`${pos.x},${pos.y},${pos.z}`]
global.postMessage({ type: 'customBlockModel', chunkKey, customBlockModel })
break
}
case 'getHeightmap': {
const heightmap = new Uint8Array(256)
const blockPos = new Vec3(0, 0, 0)
for (let z = 0; z < 16; z++) {
for (let x = 0; x < 16; x++) {
const blockX = x + data.x
const blockZ = z + data.z
blockPos.x = blockX
blockPos.z = blockZ
blockPos.y = world.config.worldMaxY
let block = world.getBlock(blockPos)
while (block && INVISIBLE_BLOCKS.has(block.name) && blockPos.y > world.config.worldMinY) {
blockPos.y -= 1
block = world.getBlock(blockPos)
}
const index = z * 16 + x
heightmap[index] = block ? blockPos.y : 0
}
}
postMessage({ type: 'heightmap', key: `${Math.floor(data.x / 16)},${Math.floor(data.z / 16)}`, heightmap })
break
}
// No default

View file

@ -2,7 +2,7 @@ import { Vec3 } from 'vec3'
import worldBlockProvider, { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider'
import legacyJson from '../../../../src/preflatMap.json'
import { BlockType } from '../../../playground/shared'
import { World, BlockModelPartsResolved, WorldBlock as Block } from './world'
import { World, BlockModelPartsResolved, WorldBlock as Block, WorldBlock } from './world'
import { BlockElement, buildRotationMatrix, elemFaces, matmul3, matmulmat3, vecadd3, vecsub3 } from './modelsGeometryCommon'
import { INVISIBLE_BLOCKS } from './worldConstants'
import { MesherGeometryOutput, HighestBlockInfo } from './shared'
@ -103,7 +103,8 @@ function tintToGl (tint) {
return [r / 255, g / 255, b / 255]
}
function getLiquidRenderHeight (world, block, type, pos) {
function getLiquidRenderHeight (world: World, block: WorldBlock | null, type: number, pos: Vec3, isWater: boolean, isRealWater: boolean) {
if ((isWater && !isRealWater) || (block && isBlockWaterlogged(block))) return 8 / 9
if (!block || block.type !== type) return 1 / 9
if (block.metadata === 0) { // source block
const blockAbove = world.getBlock(pos.offset(0, 1, 0))
@ -124,12 +125,19 @@ const isCube = (block: Block) => {
}))
}
function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, type: number, biome: string, water: boolean, attr: Record<string, any>) {
const getVec = (v: Vec3, dir: Vec3) => {
for (const coord of ['x', 'y', 'z']) {
if (Math.abs(dir[coord]) > 0) v[coord] = 0
}
return v.plus(dir)
}
function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, type: number, biome: string, water: boolean, attr: MesherGeometryOutput, isRealWater: boolean) {
const heights: number[] = []
for (let z = -1; z <= 1; z++) {
for (let x = -1; x <= 1; x++) {
const pos = cursor.offset(x, 0, z)
heights.push(getLiquidRenderHeight(world, world.getBlock(pos), type, pos))
heights.push(getLiquidRenderHeight(world, world.getBlock(pos), type, pos, water, isRealWater))
}
}
const cornerHeights = [
@ -141,15 +149,14 @@ function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, typ
// eslint-disable-next-line guard-for-in
for (const face in elemFaces) {
const { dir, corners } = elemFaces[face]
const { dir, corners, mask1, mask2 } = elemFaces[face]
const isUp = dir[1] === 1
const neighborPos = cursor.offset(...dir as [number, number, number])
const neighbor = world.getBlock(neighborPos)
if (!neighbor) continue
if (neighbor.type === type) continue
const isGlass = neighbor.name.includes('glass')
if ((isCube(neighbor) && !isUp) || neighbor.material === 'plant' || neighbor.getProperties().waterlogged) continue
if (neighbor.type === type || (water && (neighbor.name === 'water' || isBlockWaterlogged(neighbor)))) continue
if (isCube(neighbor) && !isUp) continue
let tint = [1, 1, 1]
if (water) {
@ -180,16 +187,44 @@ function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, typ
const { su } = texture
const { sv } = texture
// Get base light value for the face
const baseLight = world.getLight(neighborPos, undefined, undefined, water ? 'water' : 'lava') / 15
for (const pos of corners) {
const height = cornerHeights[pos[2] * 2 + pos[0]]
attr.t_positions.push(
(pos[0] ? 0.999 : 0.001) + (cursor.x & 15) - 8,
(pos[1] ? height - 0.001 : 0.001) + (cursor.y & 15) - 8,
(pos[2] ? 0.999 : 0.001) + (cursor.z & 15) - 8
const OFFSET = 0.0001
attr.t_positions!.push(
(pos[0] ? 1 - OFFSET : OFFSET) + (cursor.x & 15) - 8,
(pos[1] ? height - OFFSET : OFFSET) + (cursor.y & 15) - 8,
(pos[2] ? 1 - OFFSET : OFFSET) + (cursor.z & 15) - 8
)
attr.t_normals.push(...dir)
attr.t_uvs.push(pos[3] * su + u, pos[4] * sv * (pos[1] ? 1 : height) + v)
attr.t_colors.push(tint[0], tint[1], tint[2])
attr.t_normals!.push(...dir)
attr.t_uvs!.push(pos[3] * su + u, pos[4] * sv * (pos[1] ? 1 : height) + v)
let cornerLightResult = baseLight
if (world.config.smoothLighting) {
const dx = pos[0] * 2 - 1
const dy = pos[1] * 2 - 1
const dz = pos[2] * 2 - 1
const cornerDir: [number, number, number] = [dx, dy, dz]
const side1Dir: [number, number, number] = [dx * mask1[0], dy * mask1[1], dz * mask1[2]]
const side2Dir: [number, number, number] = [dx * mask2[0], dy * mask2[1], dz * mask2[2]]
const dirVec = new Vec3(...dir as [number, number, number])
const side1LightDir = getVec(new Vec3(...side1Dir), dirVec)
const side1Light = world.getLight(cursor.plus(side1LightDir)) / 15
const side2DirLight = getVec(new Vec3(...side2Dir), dirVec)
const side2Light = world.getLight(cursor.plus(side2DirLight)) / 15
const cornerLightDir = getVec(new Vec3(...cornerDir), dirVec)
const cornerLight = world.getLight(cursor.plus(cornerLightDir)) / 15
// interpolate
const lights = [side1Light, side2Light, cornerLight, baseLight]
cornerLightResult = lights.reduce((acc, cur) => acc + cur, 0) / lights.length
}
// Apply light value to tint
attr.t_colors!.push(tint[0] * cornerLightResult, tint[1] * cornerLightResult, tint[2] * cornerLightResult)
}
}
}
@ -253,7 +288,7 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO:
if (!neighbor.transparent && (isCube(neighbor) || identicalCull(element, neighbor, new Vec3(...dir)))) continue
} else {
needSectionRecomputeOnChange = true
continue
// continue
}
}
@ -301,7 +336,7 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO:
let localShift = null as any
if (element.rotation && !needTiles) {
// todo do we support rescale?
// Rescale support for block model rotations
localMatrix = buildRotationMatrix(
element.rotation.axis,
element.rotation.angle
@ -314,6 +349,37 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO:
element.rotation.origin
)
)
// Apply rescale if specified
if (element.rotation.rescale) {
const FIT_TO_BLOCK_SCALE_MULTIPLIER = 2 - Math.sqrt(2)
const angleRad = element.rotation.angle * Math.PI / 180
const scale = Math.abs(Math.sin(angleRad)) * FIT_TO_BLOCK_SCALE_MULTIPLIER
// Get axis vector components (1 for the rotation axis, 0 for others)
const axisX = element.rotation.axis === 'x' ? 1 : 0
const axisY = element.rotation.axis === 'y' ? 1 : 0
const axisZ = element.rotation.axis === 'z' ? 1 : 0
// Create scale matrix: scale = (1 - axisComponent) * scaleFactor + 1
const scaleMatrix = [
[(1 - axisX) * scale + 1, 0, 0],
[0, (1 - axisY) * scale + 1, 0],
[0, 0, (1 - axisZ) * scale + 1]
]
// Apply scaling to the transformation matrix
localMatrix = matmulmat3(localMatrix, scaleMatrix)
// Recalculate shift with the new matrix
localShift = vecsub3(
element.rotation.origin,
matmul3(
localMatrix,
element.rotation.origin
)
)
}
}
const aos: number[] = []
@ -423,13 +489,19 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO:
if (!needTiles) {
if (doAO && aos[0] + aos[3] >= aos[1] + aos[2]) {
attr.indices.push(
ndx, ndx + 3, ndx + 2, ndx, ndx + 1, ndx + 3
)
attr.indices[attr.indicesCount++] = ndx
attr.indices[attr.indicesCount++] = ndx + 3
attr.indices[attr.indicesCount++] = ndx + 2
attr.indices[attr.indicesCount++] = ndx
attr.indices[attr.indicesCount++] = ndx + 1
attr.indices[attr.indicesCount++] = ndx + 3
} else {
attr.indices.push(
ndx, ndx + 1, ndx + 2, ndx + 2, ndx + 1, ndx + 3
)
attr.indices[attr.indicesCount++] = ndx
attr.indices[attr.indicesCount++] = ndx + 1
attr.indices[attr.indicesCount++] = ndx + 2
attr.indices[attr.indicesCount++] = ndx + 2
attr.indices[attr.indicesCount++] = ndx + 1
attr.indices[attr.indicesCount++] = ndx + 3
}
}
}
@ -447,7 +519,7 @@ const isBlockWaterlogged = (block: Block) => {
}
let unknownBlockModel: BlockModelPartsResolved
export function getSectionGeometry (sx, sy, sz, world: World) {
export function getSectionGeometry (sx: number, sy: number, sz: number, world: World) {
let delayedRender = [] as Array<() => void>
const attr: MesherGeometryOutput = {
@ -463,12 +535,13 @@ export function getSectionGeometry (sx, sy, sz, world: World) {
t_colors: [],
t_uvs: [],
indices: [],
indicesCount: 0, // Track current index position
using32Array: true,
tiles: {},
// todo this can be removed here
heads: {},
signs: {},
// isFull: true,
highestBlocks: new Map<string, HighestBlockInfo>([]),
hadErrors: false,
blocksCount: 0
}
@ -478,12 +551,6 @@ export function getSectionGeometry (sx, sy, sz, world: World) {
for (cursor.z = sz; cursor.z < sz + 16; cursor.z++) {
for (cursor.x = sx; cursor.x < sx + 16; cursor.x++) {
let block = world.getBlock(cursor, blockProvider, attr)!
if (!INVISIBLE_BLOCKS.has(block.name)) {
const highest = attr.highestBlocks.get(`${cursor.x},${cursor.z}`)
if (!highest || highest.y < cursor.y) {
attr.highestBlocks.set(`${cursor.x},${cursor.z}`, { y: cursor.y, stateId: block.stateId, biomeId: block.biome.id })
}
}
if (INVISIBLE_BLOCKS.has(block.name)) continue
if ((block.name.includes('_sign') || block.name === 'sign') && !world.config.disableSignsMapsSupport) {
const key = `${cursor.x},${cursor.y},${cursor.z}`
@ -539,11 +606,11 @@ export function getSectionGeometry (sx, sy, sz, world: World) {
const pos = cursor.clone()
// eslint-disable-next-line @typescript-eslint/no-loop-func
delayedRender.push(() => {
renderLiquid(world, pos, blockProvider.getTextureInfo('water_still'), block.type, biome, true, attr)
renderLiquid(world, pos, blockProvider.getTextureInfo('water_still'), block.type, biome, true, attr, !isWaterlogged)
})
attr.blocksCount++
} else if (block.name === 'lava') {
renderLiquid(world, cursor, blockProvider.getTextureInfo('lava_still'), block.type, biome, false, attr)
renderLiquid(world, cursor, blockProvider.getTextureInfo('lava_still'), block.type, biome, false, attr, false)
attr.blocksCount++
}
if (block.name !== 'water' && block.name !== 'lava' && !INVISIBLE_BLOCKS.has(block.name)) {
@ -605,12 +672,19 @@ export function getSectionGeometry (sx, sy, sz, world: World) {
let ndx = attr.positions.length / 3
for (let i = 0; i < attr.t_positions!.length / 12; i++) {
attr.indices.push(
ndx, ndx + 1, ndx + 2, ndx + 2, ndx + 1, ndx + 3,
// eslint-disable-next-line @stylistic/function-call-argument-newline
// back face
ndx, ndx + 2, ndx + 1, ndx + 2, ndx + 3, ndx + 1
)
attr.indices[attr.indicesCount++] = ndx
attr.indices[attr.indicesCount++] = ndx + 1
attr.indices[attr.indicesCount++] = ndx + 2
attr.indices[attr.indicesCount++] = ndx + 2
attr.indices[attr.indicesCount++] = ndx + 1
attr.indices[attr.indicesCount++] = ndx + 3
// back face
attr.indices[attr.indicesCount++] = ndx
attr.indices[attr.indicesCount++] = ndx + 2
attr.indices[attr.indicesCount++] = ndx + 1
attr.indices[attr.indicesCount++] = ndx + 2
attr.indices[attr.indicesCount++] = ndx + 3
attr.indices[attr.indicesCount++] = ndx + 1
ndx += 4
}
@ -628,6 +702,12 @@ export function getSectionGeometry (sx, sy, sz, world: World) {
attr.normals = new Float32Array(attr.normals) as any
attr.colors = new Float32Array(attr.colors) as any
attr.uvs = new Float32Array(attr.uvs) as any
attr.using32Array = arrayNeedsUint32(attr.indices)
if (attr.using32Array) {
attr.indices = new Uint32Array(attr.indices)
} else {
attr.indices = new Uint16Array(attr.indices)
}
if (needTiles) {
delete attr.positions
@ -639,6 +719,21 @@ export function getSectionGeometry (sx, sy, sz, world: World) {
return attr
}
// copied from three.js
function arrayNeedsUint32 (array) {
// assumes larger values usually on last
for (let i = array.length - 1; i >= 0; -- i) {
if (array[i] >= 65_535) return true // account for PRIMITIVE_RESTART_FIXED_INDEX, #24565
}
return false
}
export const setBlockStatesData = (blockstatesModels, blocksAtlas: any, _needTiles = false, useUnknownBlockModel = true, version = 'latest') => {
blockProvider = worldBlockProvider(blockstatesModels, blocksAtlas, version)
globalThis.blockProvider = blockProvider

View file

@ -122,10 +122,10 @@ export const elemFaces = {
mask1: [1, 0, 1],
mask2: [0, 1, 1],
corners: [
[1, 0, 0, 0, 1],
[0, 0, 0, 1, 1],
[1, 1, 0, 0, 0],
[0, 1, 0, 1, 0]
[1, 0, 0, 1, 1],
[0, 0, 0, 0, 1],
[1, 1, 0, 1, 0],
[0, 1, 0, 0, 0]
]
},
south: {

View file

@ -1,12 +1,15 @@
import { BlockType } from '../../../playground/shared'
// only here for easier testing
export const defaultMesherConfig = {
version: '',
worldMaxY: 256,
worldMinY: 0,
enableLighting: true,
skyLight: 15,
smoothLighting: true,
outputFormat: 'threeJs' as 'threeJs' | 'webgpu',
textureSize: 1024, // for testing
// textureSize: 1024, // for testing
debugModelVariant: undefined as undefined | number[],
clipWorldBelowY: undefined as undefined | number,
disableSignsMapsSupport: false
@ -32,17 +35,27 @@ export type MesherGeometryOutput = {
t_colors?: number[],
t_uvs?: number[],
indices: number[],
indices: Uint32Array | Uint16Array | number[],
indicesCount: number,
using32Array: boolean,
tiles: Record<string, BlockType>,
heads: Record<string, any>,
signs: Record<string, any>,
// isFull: boolean
highestBlocks: Map<string, HighestBlockInfo>
hadErrors: boolean
blocksCount: number
customBlockModels?: CustomBlockModels
}
export interface MesherMainEvents {
geometry: { type: 'geometry'; key: string; geometry: MesherGeometryOutput; workerIndex: number };
sectionFinished: { type: 'sectionFinished'; key: string; workerIndex: number; processTime?: number };
blockStateModelInfo: { type: 'blockStateModelInfo'; info: Record<string, BlockStateModelInfo> };
heightmap: { type: 'heightmap'; key: string; heightmap: Uint8Array };
}
export type MesherMainEvent = MesherMainEvents[keyof MesherMainEvents]
export type HighestBlockInfo = { y: number, stateId: number | undefined, biomeId: number | undefined }
export type BlockStateModelInfo = {

View file

@ -49,8 +49,6 @@ test('Known blocks are not rendered', () => {
// TODO resolve creaking_heart issue (1.21.3)
expect(missingBlocks).toMatchInlineSnapshot(`
{
"end_gateway": true,
"end_portal": true,
"structure_void": true,
}
`)

View file

@ -0,0 +1,131 @@
/* eslint-disable no-await-in-loop */
import { Vec3 } from 'vec3'
// import log from '../../../../../Downloads/mesher (2).log'
import { WorldRendererCommon } from './worldrendererCommon'
const log = ''
export class MesherLogReader {
chunksToReceive: Array<{
x: number
z: number
chunkLength: number
}> = []
messagesQueue: Array<{
fromWorker: boolean
workerIndex: number
message: any
}> = []
sectionFinishedToReceive = null as {
messagesLeft: string[]
resolve: () => void
} | null
replayStarted = false
constructor (private readonly worldRenderer: WorldRendererCommon) {
this.parseMesherLog()
}
chunkReceived (x: number, z: number, chunkLength: number) {
// remove existing chunks with same x and z
const existingChunkIndex = this.chunksToReceive.findIndex(chunk => chunk.x === x && chunk.z === z)
if (existingChunkIndex === -1) {
// console.error('Chunk not found', x, z)
} else {
// warn if chunkLength is different
if (this.chunksToReceive[existingChunkIndex].chunkLength !== chunkLength) {
// console.warn('Chunk length mismatch', x, z, this.chunksToReceive[existingChunkIndex].chunkLength, chunkLength)
}
// remove chunk
this.chunksToReceive = this.chunksToReceive.filter((chunk, index) => chunk.x !== x || chunk.z !== z)
}
this.maybeStartReplay()
}
async maybeStartReplay () {
if (this.chunksToReceive.length !== 0 || this.replayStarted) return
const lines = log.split('\n')
console.log('starting replay')
this.replayStarted = true
const waitForWorkersMessages = async () => {
if (!this.sectionFinishedToReceive) return
await new Promise<void>(resolve => {
this.sectionFinishedToReceive!.resolve = resolve
})
}
for (const line of lines) {
if (line.includes('dispatchMessages dirty')) {
await waitForWorkersMessages()
this.worldRenderer.stopMesherMessagesProcessing = true
const message = JSON.parse(line.slice(line.indexOf('{'), line.lastIndexOf('}') + 1))
if (!message.value) continue
const index = line.split(' ')[1]
const type = line.split(' ')[3]
// console.log('sending message', message.x, message.y, message.z)
this.worldRenderer.forceCallFromMesherReplayer = true
this.worldRenderer.setSectionDirty(new Vec3(message.x, message.y, message.z), message.value)
this.worldRenderer.forceCallFromMesherReplayer = false
}
if (line.includes('-> blockUpdate')) {
await waitForWorkersMessages()
this.worldRenderer.stopMesherMessagesProcessing = true
const message = JSON.parse(line.slice(line.indexOf('{'), line.lastIndexOf('}') + 1))
this.worldRenderer.forceCallFromMesherReplayer = true
this.worldRenderer.setBlockStateIdInner(new Vec3(message.pos.x, message.pos.y, message.pos.z), message.stateId)
this.worldRenderer.forceCallFromMesherReplayer = false
}
if (line.includes(' sectionFinished ')) {
if (!this.sectionFinishedToReceive) {
console.log('starting worker message processing validating')
this.worldRenderer.stopMesherMessagesProcessing = false
this.sectionFinishedToReceive = {
messagesLeft: [],
resolve: () => {
this.sectionFinishedToReceive = null
}
}
}
const parts = line.split(' ')
const coordsPart = parts.find(part => part.split(',').length === 3)
if (!coordsPart) throw new Error(`no coords part found ${line}`)
const [x, y, z] = coordsPart.split(',').map(Number)
this.sectionFinishedToReceive.messagesLeft.push(`${x},${y},${z}`)
}
}
}
workerMessageReceived (type: string, message: any) {
if (type === 'sectionFinished') {
const { key } = message
if (!this.sectionFinishedToReceive) {
console.warn(`received sectionFinished message but no sectionFinishedToReceive ${key}`)
return
}
const idx = this.sectionFinishedToReceive.messagesLeft.indexOf(key)
if (idx === -1) {
console.warn(`received sectionFinished message for non-outstanding section ${key}`)
return
}
this.sectionFinishedToReceive.messagesLeft.splice(idx, 1)
if (this.sectionFinishedToReceive.messagesLeft.length === 0) {
this.sectionFinishedToReceive.resolve()
}
}
}
parseMesherLog () {
const lines = log.split('\n')
for (const line of lines) {
if (line.startsWith('-> chunk')) {
const chunk = JSON.parse(line.slice('-> chunk'.length))
this.chunksToReceive.push(chunk)
continue
}
}
}
}

View file

@ -1,11 +0,0 @@
import { fromFormattedString } from '@xmcl/text-component'
export const formattedStringToSimpleString = (str) => {
const result = fromFormattedString(str)
str = result.text
// todo recursive
for (const extra of result.extra) {
str += extra.text
}
return str
}

View file

@ -1,18 +0,0 @@
import * as THREE from 'three'
export const disposeObject = (obj: THREE.Object3D, cleanTextures = false) => {
// not cleaning texture there as it might be used by other objects, but would be good to also do that
if (obj instanceof THREE.Mesh) {
obj.geometry?.dispose?.()
obj.material?.dispose?.()
}
if (obj.children) {
// eslint-disable-next-line unicorn/no-array-for-each
obj.children.forEach(child => disposeObject(child, cleanTextures))
}
if (cleanTextures) {
if (obj instanceof THREE.Mesh) {
obj.material?.map?.dispose?.()
}
}
}

View file

@ -7,7 +7,7 @@ let lastY = 40
export const addNewStat = (id: string, width = 80, x = rightOffset, y = lastY) => {
const pane = document.createElement('div')
pane.style.position = 'fixed'
pane.style.top = `${y}px`
pane.style.top = `${y ?? lastY}px`
pane.style.right = `${x}px`
// gray bg
pane.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'
@ -19,7 +19,7 @@ export const addNewStat = (id: string, width = 80, x = rightOffset, y = lastY) =
pane.style.pointerEvents = 'none'
document.body.appendChild(pane)
stats[id] = pane
if (y === 0) { // otherwise it's a custom position
if (y === undefined && x === rightOffset) { // otherwise it's a custom position
// rightOffset += width
lastY += 20
}
@ -35,11 +35,75 @@ export const addNewStat = (id: string, width = 80, x = rightOffset, y = lastY) =
}
}
export const addNewStat2 = (id: string, { top, bottom, right, left, displayOnlyWhenWider }: { top?: number, bottom?: number, right?: number, left?: number, displayOnlyWhenWider?: number }) => {
if (top === undefined && bottom === undefined) top = 0
const pane = document.createElement('div')
pane.style.position = 'fixed'
if (top !== undefined) {
pane.style.top = `${top}px`
}
if (bottom !== undefined) {
pane.style.bottom = `${bottom}px`
}
if (left !== undefined) {
pane.style.left = `${left}px`
}
if (right !== undefined) {
pane.style.right = `${right}px`
}
// gray bg
pane.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'
pane.style.color = 'white'
pane.style.padding = '2px'
pane.style.fontFamily = 'monospace'
pane.style.fontSize = '12px'
pane.style.zIndex = '10000'
pane.style.pointerEvents = 'none'
document.body.appendChild(pane)
stats[id] = pane
const resizeCheck = () => {
if (!displayOnlyWhenWider) return
pane.style.display = window.innerWidth > displayOnlyWhenWider ? 'block' : 'none'
}
window.addEventListener('resize', resizeCheck)
resizeCheck()
return {
updateText (text: string) {
pane.innerText = text
},
setVisibility (visible: boolean) {
pane.style.display = visible ? 'block' : 'none'
}
}
}
export const updateStatText = (id, text) => {
if (!stats[id]) return
stats[id].innerText = text
}
export const updatePanesVisibility = (visible: boolean) => {
// eslint-disable-next-line guard-for-in
for (const id in stats) {
stats[id].style.display = visible ? 'block' : 'none'
}
}
export const removeAllStats = () => {
// eslint-disable-next-line guard-for-in
for (const id in stats) {
removeStat(id)
}
}
export const removeStat = (id) => {
if (!stats[id]) return
stats[id].remove()
delete stats[id]
}
if (typeof customEvents !== 'undefined') {
customEvents.on('gameLoaded', () => {
const chunksLoaded = addNewStat('chunks-loaded', 80, 0, 0)

View file

@ -1,28 +1,4 @@
import * as THREE from 'three'
let textureCache: Record<string, THREE.Texture> = {}
let imagesPromises: Record<string, Promise<THREE.Texture>> = {}
export async function loadTexture (texture: string, cb: (texture: THREE.Texture) => void, onLoad?: () => void): Promise<void> {
const cached = textureCache[texture]
if (!cached) {
const { promise, resolve } = Promise.withResolvers<THREE.Texture>()
textureCache[texture] = new THREE.TextureLoader().load(texture, resolve)
imagesPromises[texture] = promise
}
cb(textureCache[texture])
void imagesPromises[texture].then(() => {
onLoad?.()
})
}
export const clearTextureCache = () => {
textureCache = {}
imagesPromises = {}
}
export const loadScript = async function (scriptSrc: string): Promise<HTMLScriptElement> {
export const loadScript = async function (scriptSrc: string, highPriority = true): Promise<HTMLScriptElement> {
const existingScript = document.querySelector<HTMLScriptElement>(`script[src="${scriptSrc}"]`)
if (existingScript) {
return existingScript
@ -31,6 +7,10 @@ export const loadScript = async function (scriptSrc: string): Promise<HTMLScript
return new Promise((resolve, reject) => {
const scriptElement = document.createElement('script')
scriptElement.src = scriptSrc
if (highPriority) {
scriptElement.fetchPriority = 'high'
}
scriptElement.async = true
scriptElement.addEventListener('load', () => {
@ -45,3 +25,33 @@ export const loadScript = async function (scriptSrc: string): Promise<HTMLScript
document.head.appendChild(scriptElement)
})
}
const detectFullOffscreenCanvasSupport = () => {
if (typeof OffscreenCanvas === 'undefined') return false
try {
const canvas = new OffscreenCanvas(1, 1)
// Try to get a WebGL context - this will fail on iOS where only 2D is supported (iOS 16)
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl')
return gl !== null
} catch (e) {
return false
}
}
const hasFullOffscreenCanvasSupport = detectFullOffscreenCanvasSupport()
export const createCanvas = (width: number, height: number): OffscreenCanvas => {
if (hasFullOffscreenCanvasSupport) {
return new OffscreenCanvas(width, height)
}
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
return canvas as unknown as OffscreenCanvas // todo-low
}
export async function loadImageFromUrl (imageUrl: string): Promise<ImageBitmap> {
const response = await fetch(imageUrl)
const blob = await response.blob()
return createImageBitmap(blob)
}

View file

@ -1,27 +1,59 @@
import { loadSkinToCanvas } from 'skinview-utils'
import * as THREE from 'three'
import stevePng from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png'
import { createCanvas, loadImageFromUrl } from '../utils'
// eslint-disable-next-line unicorn/prefer-export-from
export const stevePngUrl = stevePng
export const steveTexture = new THREE.TextureLoader().loadAsync(stevePng)
export { default as stevePngUrl } from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png'
export async function loadImageFromUrl (imageUrl: string): Promise<HTMLImageElement> {
const img = new Image()
img.src = imageUrl
await new Promise<void>(resolve => {
img.onload = () => resolve()
})
return img
const config = {
apiEnabled: true,
}
export function getLookupUrl (username: string, type: 'skin' | 'cape'): string {
return `https://mulv.tycrek.dev/api/lookup?username=${username}&type=${type}`
export const setSkinsConfig = (newConfig: Partial<typeof config>) => {
Object.assign(config, newConfig)
}
export async function loadSkinImage (skinUrl: string): Promise<{ canvas: HTMLCanvasElement, image: HTMLImageElement }> {
export async function loadSkinFromUsername (username: string, type: 'skin' | 'cape'): Promise<string | undefined> {
if (!config.apiEnabled) return
if (type === 'cape') return
const url = `https://playerdb.co/api/player/minecraft/${username}`
const response = await fetch(url)
if (!response.ok) return
const data: {
data: {
player: {
skin_texture: string
}
}
} = await response.json()
return data.data.player.skin_texture
}
export const parseSkinTexturesValue = (value: string) => {
const decodedData: {
textures: {
SKIN: {
url: string
}
}
} = JSON.parse(Buffer.from(value, 'base64').toString())
return decodedData.textures?.SKIN?.url
}
export async function loadSkinImage (skinUrl: string): Promise<{ canvas: OffscreenCanvas, image: ImageBitmap }> {
if (!skinUrl.startsWith('data:')) {
skinUrl = await fetchAndConvertBase64Skin(skinUrl.replace('http://', 'https://'))
}
const image = await loadImageFromUrl(skinUrl)
const skinCanvas = document.createElement('canvas')
const skinCanvas = createCanvas(64, 64)
loadSkinToCanvas(skinCanvas, image)
return { canvas: skinCanvas, image }
}
const fetchAndConvertBase64Skin = async (skinUrl: string) => {
const response = await fetch(skinUrl, { })
const arrayBuffer = await response.arrayBuffer()
const base64 = Buffer.from(arrayBuffer).toString('base64')
return `data:image/png;base64,${base64}`
}

View file

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

View file

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

View file

@ -1,8 +1,20 @@
export function createWorkerProxy<T extends Record<string, (...args: any[]) => void>> (handlers: T): { __workerProxy: T } {
addEventListener('message', (event) => {
const { type, args } = event.data
import { proxy, getVersion, subscribe } from 'valtio'
export function createWorkerProxy<T extends Record<string, (...args: any[]) => void | Promise<any>>> (handlers: T, channel?: MessagePort): { __workerProxy: T } {
const target = channel ?? globalThis
target.addEventListener('message', (event: any) => {
const { type, args, msgId } = event.data
if (handlers[type]) {
handlers[type](...args)
const result = handlers[type](...args)
if (result instanceof Promise) {
void result.then((result) => {
target.postMessage({
type: 'result',
msgId,
args: [result]
})
})
}
}
})
return null as any
@ -19,9 +31,10 @@ export function createWorkerProxy<T extends Record<string, (...args: any[]) => v
* const workerChannel = useWorkerProxy<typeof importedTypeWorkerProxy>(worker)
* ```
*/
export const useWorkerProxy = <T extends { __workerProxy: Record<string, (...args: any[]) => void> }> (worker: Worker, autoTransfer = true): T['__workerProxy'] & {
export const useWorkerProxy = <T extends { __workerProxy: Record<string, (...args: any[]) => void> }> (worker: Worker | MessagePort, autoTransfer = true): T['__workerProxy'] & {
transfer: (...args: Transferable[]) => T['__workerProxy']
} => {
let messageId = 0
// in main thread
return new Proxy({} as any, {
get (target, prop) {
@ -40,11 +53,30 @@ export const useWorkerProxy = <T extends { __workerProxy: Record<string, (...arg
}
}
return (...args: any[]) => {
const transfer = autoTransfer ? args.filter(arg => arg instanceof ArrayBuffer || arg instanceof MessagePort || arg instanceof ImageBitmap || arg instanceof OffscreenCanvas) : []
const msgId = messageId++
const transfer = autoTransfer ? args.filter(arg => {
return arg instanceof ArrayBuffer || arg instanceof MessagePort
|| (typeof ImageBitmap !== 'undefined' && arg instanceof ImageBitmap)
|| (typeof OffscreenCanvas !== 'undefined' && arg instanceof OffscreenCanvas)
|| (typeof ImageData !== 'undefined' && arg instanceof ImageData)
}) : []
worker.postMessage({
type: prop,
msgId,
args,
}, transfer)
return {
// eslint-disable-next-line unicorn/no-thenable
then (onfulfilled: (value: any) => void) {
const handler = ({ data }: MessageEvent): void => {
if (data.type === 'result' && data.msgId === msgId) {
onfulfilled(data.args[0])
worker.removeEventListener('message', handler as EventListener)
}
}
worker.addEventListener('message', handler as EventListener)
}
}
}
}
})

View file

@ -5,52 +5,85 @@ import { EventEmitter } from 'events'
import { generateSpiralMatrix, ViewRect } from 'flying-squid/dist/utils'
import { Vec3 } from 'vec3'
import { BotEvents } from 'mineflayer'
import { getItemFromBlock } from '../../../src/chatUtils'
import { proxy } from 'valtio'
import TypedEmitter from 'typed-emitter'
import { Biome } from 'minecraft-data'
import { delayedIterator } from '../../playground/shared'
import { playerState } from '../../../src/mineflayer/playerState'
import { chunkPos } from './simpleUtils'
export type ChunkPosKey = string
type ChunkPos = { x: number, z: number }
export type ChunkPosKey = string // like '16,16'
type ChunkPos = { x: number, z: number } // like { x: 16, z: 16 }
/**
* Usually connects to mineflayer bot and emits world data (chunks, entities)
* It's up to the consumer to serialize the data if needed
*/
export class WorldDataEmitter extends EventEmitter {
private loadedChunks: Record<ChunkPosKey, boolean>
private readonly lastPos: Vec3
export type WorldDataEmitterEvents = {
chunkPosUpdate: (data: { pos: Vec3 }) => void
blockUpdate: (data: { pos: Vec3, stateId: number }) => void
entity: (data: any) => void
entityMoved: (data: any) => void
playerEntity: (data: any) => void
time: (data: number) => void
renderDistance: (viewDistance: number) => void
blockEntities: (data: Record<string, any> | { blockEntities: Record<string, any> }) => void
markAsLoaded: (data: { x: number, z: number }) => void
unloadChunk: (data: { x: number, z: number }) => void
loadChunk: (data: { x: number, z: number, chunk: string, blockEntities: any, worldConfig: any, isLightUpdate: boolean }) => void
updateLight: (data: { pos: Vec3 }) => void
onWorldSwitch: () => void
end: () => void
biomeUpdate: (data: { biome: Biome }) => void
biomeReset: () => void
}
export class WorldDataEmitterWorker extends (EventEmitter as new () => TypedEmitter<WorldDataEmitterEvents>) {
static readonly restorerName = 'WorldDataEmitterWorker'
}
export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<WorldDataEmitterEvents>) {
spiralNumber = 0
gotPanicLastTime = false
panicChunksReload = () => {}
loadedChunks: Record<ChunkPosKey, boolean>
private inLoading = false
private chunkReceiveTimes: number[] = []
private lastChunkReceiveTime = 0
public lastChunkReceiveTimeAvg = 0
private panicTimeout?: NodeJS.Timeout
readonly lastPos: Vec3
private eventListeners: Record<string, any> = {}
private readonly emitter: WorldDataEmitter
keepChunksDistance = 0
debugChunksInfo: Record<ChunkPosKey, {
loads: Array<{
dataLength: number
reason: string
time: number
}>
// blockUpdates: number
}> = {}
waitingSpiralChunksLoad = {} as Record<ChunkPosKey, (value: boolean) => void>
addWaitTime = 1
isPlayground = false
/* config */ keepChunksDistance = 0
/* config */ isPlayground = false
/* config */ allowPositionUpdate = true
constructor (public world: typeof __type_bot['world'], public viewDistance: number, position: Vec3 = new Vec3(0, 0, 0)) {
// eslint-disable-next-line constructor-super
super()
this.loadedChunks = {}
this.lastPos = new Vec3(0, 0, 0).update(position)
// todo
this.emitter = this
this.emitter.on('mouseClick', async (click) => {
const ori = new Vec3(click.origin.x, click.origin.y, click.origin.z)
const dir = new Vec3(click.direction.x, click.direction.y, click.direction.z)
const block = this.world.raycast(ori, dir, 256)
if (!block) return
this.emit('blockClicked', block, block.face, click.button)
})
}
setBlockStateId (position: Vec3, stateId: number) {
const val = this.world.setBlockStateId(position, stateId) as Promise<void> | void
if (val) throw new Error('setBlockStateId returned promise (not supported)')
const chunkX = Math.floor(position.x / 16)
const chunkZ = Math.floor(position.z / 16)
if (!this.loadedChunks[`${chunkX},${chunkZ}`]) {
void this.loadChunk({ x: chunkX, z: chunkZ })
return
}
// const chunkX = Math.floor(position.x / 16)
// const chunkZ = Math.floor(position.z / 16)
// if (!this.loadedChunks[`${chunkX},${chunkZ}`] && !this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`]) {
// void this.loadChunk({ x: chunkX, z: chunkZ })
// return
// }
this.emit('blockUpdate', { pos: position, stateId })
}
@ -61,12 +94,28 @@ export class WorldDataEmitter extends EventEmitter {
}
listenToBot (bot: typeof __type_bot) {
const emitEntity = (e) => {
if (!e || e === bot.entity) return
this.emitter.emit('entity', {
const entitiesObjectData = new Map<string, number>()
bot._client.prependListener('spawn_entity', (data) => {
if (data.objectData && data.entityId !== undefined) {
entitiesObjectData.set(data.entityId, data.objectData)
}
})
const emitEntity = (e, name = 'entity') => {
if (!e) return
if (e === bot.entity) {
if (name === 'entity') {
this.emitter.emit('playerEntity', e)
}
return
}
if (!e.name) return // mineflayer received update for not spawned entity
e.objectData = entitiesObjectData.get(e.id)
this.emitter.emit(name as any, {
...e,
pos: e.position,
username: e.username,
team: bot.teamMap[e.username] || bot.teamMap[e.uuid],
// set debugTree (obj) {
// e.debugTree = obj
// }
@ -85,14 +134,29 @@ export class WorldDataEmitter extends EventEmitter {
entityUpdate (e: any) {
emitEntity(e)
},
entityMoved (e: any) {
entityEquip (e: any) {
emitEntity(e)
},
entityMoved (e: any) {
emitEntity(e, 'entityMoved')
},
entityGone: (e: any) => {
this.emitter.emit('entity', { id: e.id, delete: true })
},
chunkColumnLoad: (pos: Vec3) => {
void this.loadChunk(pos)
const now = performance.now()
if (this.lastChunkReceiveTime) {
this.chunkReceiveTimes.push(now - this.lastChunkReceiveTime)
}
this.lastChunkReceiveTime = now
if (this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`]) {
this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`](true)
delete this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`]
} else if (this.loadedChunks[`${pos.x},${pos.z}`]) {
void this.loadChunk(pos, false, 'Received another chunkColumnLoad event while already loaded')
}
this.chunkProgress()
},
chunkColumnUnload: (pos: Vec3) => {
this.unloadChunk(pos)
@ -103,41 +167,55 @@ export class WorldDataEmitter extends EventEmitter {
},
time: () => {
this.emitter.emit('time', bot.time.timeOfDay)
}
},
end: () => {
this.emitter.emit('end')
},
// when dimension might change
login: () => {
void this.updatePosition(bot.entity.position, true)
this.emitter.emit('playerEntity', bot.entity)
},
respawn: () => {
void this.updatePosition(bot.entity.position, true)
this.emitter.emit('playerEntity', bot.entity)
this.emitter.emit('onWorldSwitch')
},
} satisfies Partial<BotEvents>
bot._client.on('update_light', ({ chunkX, chunkZ }) => {
const chunkPos = new Vec3(chunkX * 16, 0, chunkZ * 16)
void this.loadChunk(chunkPos, true)
if (!this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`] && this.loadedChunks[`${chunkX},${chunkZ}`]) {
void this.loadChunk(chunkPos, true, 'update_light')
}
})
this.emitter.on('listening', () => {
this.emitter.emit('blockEntities', new Proxy({}, {
get (_target, posKey, receiver) {
if (typeof posKey !== 'string') return
const [x, y, z] = posKey.split(',').map(Number)
return bot.world.getBlock(new Vec3(x, y, z))?.entity
},
}))
this.emitter.emit('renderDistance', this.viewDistance)
this.emitter.emit('time', bot.time.timeOfDay)
})
// node.js stream data event pattern
if (this.emitter.listenerCount('blockEntities')) {
this.emitter.emit('listening')
}
for (const [evt, listener] of Object.entries(this.eventListeners)) {
bot.on(evt as any, listener)
}
for (const id in bot.entities) {
const e = bot.entities[id]
emitEntity(e)
try {
emitEntity(e)
} catch (err) {
// reportError?.(err)
console.error('error processing entity', err)
}
}
}
emitterGotConnected () {
this.emitter.emit('blockEntities', new Proxy({}, {
get (_target, posKey, receiver) {
if (typeof posKey !== 'string') return
const [x, y, z] = posKey.split(',').map(Number)
return bot.world.getBlock(new Vec3(x, y, z))?.entity
},
}))
}
removeListenersFromBot (bot: import('mineflayer').Bot) {
for (const [evt, listener] of Object.entries(this.eventListeners)) {
bot.removeListener(evt as any, listener)
@ -147,36 +225,95 @@ export class WorldDataEmitter extends EventEmitter {
async init (pos: Vec3) {
this.updateViewDistance(this.viewDistance)
this.emitter.emit('chunkPosUpdate', { pos })
if (bot?.time?.timeOfDay) {
this.emitter.emit('time', bot.time.timeOfDay)
}
if (bot?.entity) {
this.emitter.emit('playerEntity', bot.entity)
}
this.emitterGotConnected()
const [botX, botZ] = chunkPos(pos)
const positions = generateSpiralMatrix(this.viewDistance).map(([x, z]) => new Vec3((botX + x) * 16, 0, (botZ + z) * 16))
this.lastPos.update(pos)
await this._loadChunks(positions)
await this._loadChunks(positions, pos)
}
async _loadChunks (positions: Vec3[], sliceSize = 5) {
const promises = [] as Array<Promise<void>>
await delayedIterator(positions, this.addWaitTime, (pos) => {
promises.push(this.loadChunk(pos))
chunkProgress () {
if (this.panicTimeout) clearTimeout(this.panicTimeout)
if (this.chunkReceiveTimes.length >= 5) {
const avgReceiveTime = this.chunkReceiveTimes.reduce((a, b) => a + b, 0) / this.chunkReceiveTimes.length
this.lastChunkReceiveTimeAvg = avgReceiveTime
const timeoutDelay = avgReceiveTime * 2 + 1000 // 2x average + 1 second
// Clear any existing timeout
if (this.panicTimeout) clearTimeout(this.panicTimeout)
// Set new timeout for panic reload
this.panicTimeout = setTimeout(() => {
if (!this.gotPanicLastTime && this.inLoading) {
console.warn('Chunk loading seems stuck, triggering panic reload')
this.gotPanicLastTime = true
this.panicChunksReload()
}
}, timeoutDelay)
}
}
async _loadChunks (positions: Vec3[], centerPos: Vec3) {
this.spiralNumber++
const { spiralNumber } = this
// stop loading previous chunks
for (const pos of Object.keys(this.waitingSpiralChunksLoad)) {
this.waitingSpiralChunksLoad[pos](false)
delete this.waitingSpiralChunksLoad[pos]
}
let continueLoading = true
this.inLoading = true
await delayedIterator(positions, this.addWaitTime, async (pos) => {
if (!continueLoading || this.loadedChunks[`${pos.x},${pos.z}`]) return
// Wait for chunk to be available from server
if (!this.world.getColumnAt(pos)) {
continueLoading = await new Promise<boolean>(resolve => {
this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`] = resolve
})
}
if (!continueLoading) return
await this.loadChunk(pos, undefined, `spiral ${spiralNumber} from ${centerPos.x},${centerPos.z}`)
this.chunkProgress()
})
await Promise.all(promises)
if (this.panicTimeout) clearTimeout(this.panicTimeout)
this.inLoading = false
this.gotPanicLastTime = false
this.chunkReceiveTimes = []
this.lastChunkReceiveTime = 0
}
readdDebug () {
const clonedLoadedChunks = { ...this.loadedChunks }
this.unloadAllChunks()
console.time('readdDebug')
for (const loadedChunk in clonedLoadedChunks) {
const [x, z] = loadedChunk.split(',').map(Number)
void this.loadChunk(new Vec3(x, 0, z))
}
const interval = setInterval(() => {
if (appViewer.rendererState.world.allChunksLoaded) {
clearInterval(interval)
console.timeEnd('readdDebug')
}
}, 100)
}
// debugGotChunkLatency = [] as number[]
// lastTime = 0
async loadChunk (pos: ChunkPos, isLightUpdate = false) {
async loadChunk (pos: ChunkPos, isLightUpdate = false, reason = 'spiral') {
const [botX, botZ] = chunkPos(this.lastPos)
const dx = Math.abs(botX - Math.floor(pos.x / 16))
const dz = Math.abs(botZ - Math.floor(pos.z / 16))
if (dx <= this.viewDistance && dz <= this.viewDistance) {
@ -196,6 +333,15 @@ export class WorldDataEmitter extends EventEmitter {
//@ts-expect-error
this.emitter.emit('loadChunk', { x: pos.x, z: pos.z, chunk, blockEntities: column.blockEntities, worldConfig, isLightUpdate })
this.loadedChunks[`${pos.x},${pos.z}`] = true
this.debugChunksInfo[`${pos.x},${pos.z}`] ??= {
loads: []
}
this.debugChunksInfo[`${pos.x},${pos.z}`].loads.push({
dataLength: chunk.length,
reason,
time: Date.now(),
})
} else if (this.isPlayground) { // don't allow in real worlds pre-flag chunks as loaded to avoid race condition when the chunk might still be loading. In playground it's assumed we always pre-load all chunks first
this.emitter.emit('markAsLoaded', { x: pos.x, z: pos.z })
}
@ -214,13 +360,46 @@ export class WorldDataEmitter extends EventEmitter {
unloadChunk (pos: ChunkPos) {
this.emitter.emit('unloadChunk', { x: pos.x, z: pos.z })
delete this.loadedChunks[`${pos.x},${pos.z}`]
delete this.debugChunksInfo[`${pos.x},${pos.z}`]
}
lastBiomeId: number | null = null
udpateBiome (pos: Vec3) {
try {
const biomeId = this.world.getBiome(pos)
if (biomeId !== this.lastBiomeId) {
this.lastBiomeId = biomeId
const biomeData = loadedData.biomes[biomeId]
if (biomeData) {
this.emitter.emit('biomeUpdate', {
biome: biomeData
})
} else {
// unknown biome
this.emitter.emit('biomeReset')
}
}
} catch (e) {
console.error('error updating biome', e)
}
}
lastPosCheck: Vec3 | null = null
async updatePosition (pos: Vec3, force = false) {
if (!this.allowPositionUpdate) return
const posFloored = pos.floored()
if (!force && this.lastPosCheck && this.lastPosCheck.equals(posFloored)) return
this.lastPosCheck = posFloored
this.udpateBiome(pos)
const [lastX, lastZ] = chunkPos(this.lastPos)
const [botX, botZ] = chunkPos(pos)
if (lastX !== botX || lastZ !== botZ || force) {
this.emitter.emit('chunkPosUpdate', { pos })
// unload chunks that are no longer in view
const newViewToUnload = new ViewRect(botX, botZ, this.viewDistance + this.keepChunksDistance)
const chunksToUnload: Vec3[] = []
for (const coords of Object.keys(this.loadedChunks)) {
@ -232,17 +411,18 @@ export class WorldDataEmitter extends EventEmitter {
chunksToUnload.push(p)
}
}
console.log('unloading', chunksToUnload.length, 'total now', Object.keys(this.loadedChunks).length)
for (const p of chunksToUnload) {
this.unloadChunk(p)
}
// load new chunks
const positions = generateSpiralMatrix(this.viewDistance).map(([x, z]) => {
const pos = new Vec3((botX + x) * 16, 0, (botZ + z) * 16)
if (!this.loadedChunks[`${pos.x},${pos.z}`]) return pos
return undefined!
}).filter(a => !!a)
this.lastPos.update(pos)
void this._loadChunks(positions)
void this._loadChunks(positions, pos)
} else {
this.emitter.emit('chunkPosUpdate', { pos }) // todo-low
this.lastPos.update(pos)

File diff suppressed because it is too large Load diff

View file

@ -1,568 +0,0 @@
import * as THREE from 'three'
import { Vec3 } from 'vec3'
import nbt from 'prismarine-nbt'
import PrismarineChatLoader from 'prismarine-chat'
import * as tweenJs from '@tweenjs/tween.js'
import { BloomPass, RenderPass, UnrealBloomPass, EffectComposer, WaterPass, GlitchPass, LineSegmentsGeometry, Wireframe, LineMaterial } from 'three-stdlib'
import worldBlockProvider from 'mc-assets/dist/worldBlockProvider'
import { renderSign } from '../sign-renderer'
import { chunkPos, sectionPos } from './simpleUtils'
import { WorldRendererCommon, WorldRendererConfig } from './worldrendererCommon'
import { disposeObject } from './threeJsUtils'
import HoldingBlock, { HandItemBlock } from './holdingBlock'
import { addNewStat } from './ui/newStats'
import { MesherGeometryOutput } from './mesher/shared'
import { IPlayerState } from './basePlayerState'
import { getMesh } from './entity/EntityMesh'
import { armorModel } from './entity/armorModels'
export class WorldRendererThree extends WorldRendererCommon {
interactionLines: null | { blockPos; mesh } = null
outputFormat = 'threeJs' as const
blockEntities = {}
sectionObjects: Record<string, THREE.Object3D> = {}
chunkTextures = new Map<string, { [pos: string]: THREE.Texture }>()
signsCache = new Map<string, any>()
starField: StarField
cameraSectionPos: Vec3 = new Vec3(0, 0, 0)
holdingBlock: HoldingBlock
holdingBlockLeft: HoldingBlock
rendererDevice = '...'
get tilesRendered () {
return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj as any).tilesCount, 0)
}
get blocksRendered () {
return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj as any).blocksCount, 0)
}
constructor (public scene: THREE.Scene, public renderer: THREE.WebGLRenderer, public config: WorldRendererConfig, public playerState: IPlayerState) {
super(config)
this.rendererDevice = `${WorldRendererThree.getRendererInfo(this.renderer)} powered by three.js r${THREE.REVISION}`
this.starField = new StarField(scene)
this.holdingBlock = new HoldingBlock(playerState, this.config)
this.holdingBlockLeft = new HoldingBlock(playerState, this.config, true)
this.renderUpdateEmitter.on('itemsTextureDownloaded', () => {
this.holdingBlock.ready = true
this.holdingBlock.updateItem()
this.holdingBlockLeft.ready = true
this.holdingBlockLeft.updateItem()
})
this.addDebugOverlay()
}
changeHandSwingingState (isAnimationPlaying: boolean, isLeft = false) {
const holdingBlock = isLeft ? this.holdingBlockLeft : this.holdingBlock
if (isAnimationPlaying) {
holdingBlock.startSwing()
} else {
holdingBlock.stopSwing()
}
}
changeBackgroundColor (color: [number, number, number]): void {
this.scene.background = new THREE.Color(color[0], color[1], color[2])
}
timeUpdated (newTime: number): void {
const nightTime = 13_500
const morningStart = 23_000
const displayStars = newTime > nightTime && newTime < morningStart
if (displayStars) {
this.starField.addToScene()
} else {
this.starField.remove()
}
}
debugOverlayAdded = false
addDebugOverlay () {
if (this.debugOverlayAdded) return
this.debugOverlayAdded = true
const pane = addNewStat('debug-overlay')
setInterval(() => {
pane.setVisibility(this.displayStats)
if (this.displayStats) {
pane.updateText(`C: ${this.renderer.info.render.calls} TR: ${this.renderer.info.render.triangles} TE: ${this.renderer.info.memory.textures} F: ${this.tilesRendered} B: ${this.blocksRendered}`)
}
}, 100)
}
/**
* Optionally update data that are depedendent on the viewer position
*/
updatePosDataChunk (key: string) {
const [x, y, z] = key.split(',').map(x => Math.floor(+x / 16))
// sum of distances: x + y + z
const chunkDistance = Math.abs(x - this.cameraSectionPos.x) + Math.abs(y - this.cameraSectionPos.y) + Math.abs(z - this.cameraSectionPos.z)
const section = this.sectionObjects[key].children.find(child => child.name === 'mesh')!
section.renderOrder = 500 - chunkDistance
}
updateViewerPosition (pos: Vec3): void {
this.viewerPosition = pos
const cameraPos = this.camera.position.toArray().map(x => Math.floor(x / 16)) as [number, number, number]
this.cameraSectionPos = new Vec3(...cameraPos)
// eslint-disable-next-line guard-for-in
for (const key in this.sectionObjects) {
const value = this.sectionObjects[key]
if (!value) continue
this.updatePosDataChunk(key)
}
}
// debugRecomputedDeletedObjects = 0
handleWorkerMessage (data: { geometry: MesherGeometryOutput, key, type }): void {
if (data.type !== 'geometry') return
let object: THREE.Object3D = this.sectionObjects[data.key]
if (object) {
this.scene.remove(object)
disposeObject(object)
delete this.sectionObjects[data.key]
}
const chunkCoords = data.key.split(',')
if (!this.loadedChunks[chunkCoords[0] + ',' + chunkCoords[2]] || !data.geometry.positions.length || !this.active) return
// if (object) {
// this.debugRecomputedDeletedObjects++
// }
// if (!this.initialChunksLoad && this.enableChunksLoadDelay) {
// const newPromise = new Promise(resolve => {
// if (this.droppedFpsPercentage > 0.5) {
// setTimeout(resolve, 1000 / 50 * this.droppedFpsPercentage)
// } else {
// setTimeout(resolve)
// }
// })
// this.promisesQueue.push(newPromise)
// for (const promise of this.promisesQueue) {
// await promise
// }
// }
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.BufferAttribute(data.geometry.positions, 3))
geometry.setAttribute('normal', new THREE.BufferAttribute(data.geometry.normals, 3))
geometry.setAttribute('color', new THREE.BufferAttribute(data.geometry.colors, 3))
geometry.setAttribute('uv', new THREE.BufferAttribute(data.geometry.uvs, 2))
geometry.setIndex(data.geometry.indices)
const mesh = new THREE.Mesh(geometry, this.material)
mesh.position.set(data.geometry.sx, data.geometry.sy, data.geometry.sz)
mesh.name = 'mesh'
object = new THREE.Group()
object.add(mesh)
// mesh with static dimensions: 16x16x16
const staticChunkMesh = new THREE.Mesh(new THREE.BoxGeometry(16, 16, 16), new THREE.MeshBasicMaterial({ color: 0x00_00_00, transparent: true, opacity: 0 }))
staticChunkMesh.position.set(data.geometry.sx, data.geometry.sy, data.geometry.sz)
const boxHelper = new THREE.BoxHelper(staticChunkMesh, 0xff_ff_00)
boxHelper.name = 'helper'
object.add(boxHelper)
object.name = 'chunk';
(object as any).tilesCount = data.geometry.positions.length / 3 / 4;
(object as any).blocksCount = data.geometry.blocksCount
if (!this.config.showChunkBorders) {
boxHelper.visible = false
}
// should not compute it once
if (Object.keys(data.geometry.signs).length) {
for (const [posKey, { isWall, isHanging, rotation }] of Object.entries(data.geometry.signs)) {
const signBlockEntity = this.blockEntities[posKey]
if (!signBlockEntity) continue
const [x, y, z] = posKey.split(',')
const sign = this.renderSign(new Vec3(+x, +y, +z), rotation, isWall, isHanging, nbt.simplify(signBlockEntity))
if (!sign) continue
object.add(sign)
}
}
if (Object.keys(data.geometry.heads).length) {
for (const [posKey, { isWall, rotation }] of Object.entries(data.geometry.heads)) {
const headBlockEntity = this.blockEntities[posKey]
if (!headBlockEntity) continue
const [x, y, z] = posKey.split(',')
const head = this.renderHead(new Vec3(+x, +y, +z), rotation, isWall, nbt.simplify(headBlockEntity))
if (!head) continue
object.add(head)
}
}
this.sectionObjects[data.key] = object
this.updatePosDataChunk(data.key)
object.matrixAutoUpdate = false
mesh.onAfterRender = (renderer, scene, camera, geometry, material, group) => {
// mesh.matrixAutoUpdate = false
}
this.scene.add(object)
}
getSignTexture (position: Vec3, blockEntity, backSide = false) {
const chunk = chunkPos(position)
let textures = this.chunkTextures.get(`${chunk[0]},${chunk[1]}`)
if (!textures) {
textures = {}
this.chunkTextures.set(`${chunk[0]},${chunk[1]}`, textures)
}
const texturekey = `${position.x},${position.y},${position.z}`
// todo investigate bug and remove this so don't need to clean in section dirty
if (textures[texturekey]) return textures[texturekey]
const PrismarineChat = PrismarineChatLoader(this.version!)
const canvas = renderSign(blockEntity, PrismarineChat)
if (!canvas) return
const tex = new THREE.Texture(canvas)
tex.magFilter = THREE.NearestFilter
tex.minFilter = THREE.NearestFilter
tex.needsUpdate = true
textures[texturekey] = tex
return tex
}
updateCamera (pos: Vec3 | null, yaw: number, pitch: number): void {
if (this.freeFlyMode) {
pos = this.freeFlyState.position
pitch = this.freeFlyState.pitch
yaw = this.freeFlyState.yaw
}
if (pos) {
new tweenJs.Tween(this.camera.position).to({ x: pos.x, y: pos.y, z: pos.z }, 50).start()
this.freeFlyState.position = pos
}
this.camera.rotation.set(pitch, yaw, this.cameraRoll, 'ZYX')
}
render () {
tweenJs.update()
// eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
const cam = this.camera instanceof THREE.Group ? this.camera.children.find(child => child instanceof THREE.PerspectiveCamera) as THREE.PerspectiveCamera : this.camera
this.renderer.render(this.scene, cam)
if (this.config.showHand && !this.freeFlyMode) {
this.holdingBlock.render(this.camera, this.renderer, viewer.ambientLight, viewer.directionalLight)
this.holdingBlockLeft.render(this.camera, this.renderer, viewer.ambientLight, viewer.directionalLight)
}
}
renderHead (position: Vec3, rotation: number, isWall: boolean, blockEntity) {
const textures = blockEntity.SkullOwner?.Properties?.textures[0]
if (!textures) return
try {
const textureData = JSON.parse(Buffer.from(textures.Value, 'base64').toString())
const skinUrl = textureData.textures?.SKIN?.url
const mesh = getMesh(this, skinUrl, armorModel.head)
const group = new THREE.Group()
if (isWall) {
mesh.position.set(0, 0.3125, 0.3125)
}
// move head model down as armor have a different offset than blocks
mesh.position.y -= 23 / 16
group.add(mesh)
group.position.set(position.x + 0.5, position.y + 0.045, position.z + 0.5)
group.rotation.set(
0,
-THREE.MathUtils.degToRad(rotation * (isWall ? 90 : 45 / 2)),
0
)
group.scale.set(0.8, 0.8, 0.8)
return group
} catch (err) {
console.error('Error decoding player texture:', err)
}
}
renderSign (position: Vec3, rotation: number, isWall: boolean, isHanging: boolean, blockEntity) {
const tex = this.getSignTexture(position, blockEntity)
if (!tex) return
// todo implement
// const key = JSON.stringify({ position, rotation, isWall })
// if (this.signsCache.has(key)) {
// console.log('cached', key)
// } else {
// this.signsCache.set(key, tex)
// }
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ map: tex, transparent: true }))
mesh.renderOrder = 999
const lineHeight = 7 / 16
const scaleFactor = isHanging ? 1.3 : 1
mesh.scale.set(1 * scaleFactor, lineHeight * scaleFactor, 1 * scaleFactor)
const thickness = (isHanging ? 2 : 1.5) / 16
const wallSpacing = 0.25 / 16
if (isWall && !isHanging) {
mesh.position.set(0, 0, -0.5 + thickness + wallSpacing + 0.0001)
} else {
mesh.position.set(0, 0, thickness / 2 + 0.0001)
}
const group = new THREE.Group()
group.rotation.set(
0,
-THREE.MathUtils.degToRad(rotation * (isWall ? 90 : 45 / 2)),
0
)
group.add(mesh)
const height = (isHanging ? 10 : 8) / 16
const heightOffset = (isHanging ? 0 : isWall ? 4.333 : 9.333) / 16
const textPosition = height / 2 + heightOffset
group.position.set(position.x + 0.5, position.y + textPosition, position.z + 0.5)
return group
}
updateLight (chunkX: number, chunkZ: number) {
// set all sections in the chunk dirty
for (let y = this.worldConfig.minY; y < this.worldConfig.worldHeight; y += 16) {
this.setSectionDirty(new Vec3(chunkX, y, chunkZ))
}
}
async doHmr () {
const oldSections = { ...this.sectionObjects }
this.sectionObjects = {} // skip clearing
worldView!.unloadAllChunks()
void this.setVersion(this.version, this.texturesVersion)
this.sectionObjects = oldSections
// this.rerenderAllChunks()
// supply new data
await worldView!.updatePosition(bot.entity.position, true)
}
rerenderAllChunks () { // todo not clear what to do with loading chunks
for (const key of Object.keys(this.sectionObjects)) {
const [x, y, z] = key.split(',').map(Number)
this.setSectionDirty(new Vec3(x, y, z))
}
}
updateShowChunksBorder (value: boolean) {
this.config.showChunkBorders = value
for (const object of Object.values(this.sectionObjects)) {
for (const child of object.children) {
if (child.name === 'helper') {
child.visible = value
}
}
}
}
resetWorld () {
super.resetWorld()
for (const mesh of Object.values(this.sectionObjects)) {
this.scene.remove(mesh)
}
}
getLoadedChunksRelative (pos: Vec3, includeY = false) {
const [currentX, currentY, currentZ] = sectionPos(pos)
return Object.fromEntries(Object.entries(this.sectionObjects).map(([key, o]) => {
const [xRaw, yRaw, zRaw] = key.split(',').map(Number)
const [x, y, z] = sectionPos({ x: xRaw, y: yRaw, z: zRaw })
const setKey = includeY ? `${x - currentX},${y - currentY},${z - currentZ}` : `${x - currentX},${z - currentZ}`
return [setKey, o]
}))
}
cleanChunkTextures (x, z) {
const textures = this.chunkTextures.get(`${Math.floor(x / 16)},${Math.floor(z / 16)}`) ?? {}
for (const key of Object.keys(textures)) {
textures[key].dispose()
delete textures[key]
}
}
readdChunks () {
for (const key of Object.keys(this.sectionObjects)) {
this.scene.remove(this.sectionObjects[key])
}
setTimeout(() => {
for (const key of Object.keys(this.sectionObjects)) {
this.scene.add(this.sectionObjects[key])
}
}, 500)
}
disableUpdates (children = this.scene.children) {
for (const child of children) {
child.matrixWorldNeedsUpdate = false
this.disableUpdates(child.children ?? [])
}
}
removeColumn (x, z) {
super.removeColumn(x, z)
this.cleanChunkTextures(x, z)
for (let y = this.worldConfig.minY; y < this.worldConfig.worldHeight; y += 16) {
this.setSectionDirty(new Vec3(x, y, z), false)
const key = `${x},${y},${z}`
const mesh = this.sectionObjects[key]
if (mesh) {
this.scene.remove(mesh)
disposeObject(mesh)
}
delete this.sectionObjects[key]
}
}
setSectionDirty (...args: Parameters<WorldRendererCommon['setSectionDirty']>) {
const [pos] = args
this.cleanChunkTextures(pos.x, pos.z) // todo don't do this!
super.setSectionDirty(...args)
}
setHighlightCursorBlock (blockPos: typeof this.cursorBlock, shapePositions?: Array<{ position: any; width: any; height: any; depth: any; }>): void {
this.cursorBlock = blockPos
if (blockPos && this.interactionLines && blockPos.equals(this.interactionLines.blockPos)) {
return
}
if (this.interactionLines !== null) {
this.scene.remove(this.interactionLines.mesh)
this.interactionLines = null
}
if (blockPos === null) {
return
}
const group = new THREE.Group()
for (const { position, width, height, depth } of shapePositions ?? []) {
const scale = [1.0001 * width, 1.0001 * height, 1.0001 * depth] as const
const geometry = new THREE.BoxGeometry(...scale)
const lines = new LineSegmentsGeometry().fromEdgesGeometry(new THREE.EdgesGeometry(geometry))
const wireframe = new Wireframe(lines, this.threejsCursorLineMaterial)
const pos = blockPos.plus(position)
wireframe.position.set(pos.x, pos.y, pos.z)
wireframe.computeLineDistances()
group.add(wireframe)
}
this.scene.add(group)
this.interactionLines = { blockPos, mesh: group }
}
static getRendererInfo (renderer: THREE.WebGLRenderer) {
try {
const gl = renderer.getContext()
return `${gl.getParameter(gl.getExtension('WEBGL_debug_renderer_info')!.UNMASKED_RENDERER_WEBGL)}`
} catch (err) {
console.warn('Failed to get renderer info', err)
}
}
}
class StarField {
points?: THREE.Points
private _enabled = true
get enabled () {
return this._enabled
}
set enabled (value) {
this._enabled = value
if (this.points) {
this.points.visible = value
}
}
constructor (private readonly scene: THREE.Scene) {
}
addToScene () {
if (this.points || !this.enabled) return
const radius = 80
const depth = 50
const count = 7000
const factor = 7
const saturation = 10
const speed = 0.2
const geometry = new THREE.BufferGeometry()
const genStar = r => new THREE.Vector3().setFromSpherical(new THREE.Spherical(r, Math.acos(1 - Math.random() * 2), Math.random() * 2 * Math.PI))
const positions = [] as number[]
const colors = [] as number[]
const sizes = Array.from({ length: count }, () => (0.5 + 0.5 * Math.random()) * factor)
const color = new THREE.Color()
let r = radius + depth
const increment = depth / count
for (let i = 0; i < count; i++) {
r -= increment * Math.random()
positions.push(...genStar(r).toArray())
color.setHSL(i / count, saturation, 0.9)
colors.push(color.r, color.g, color.b)
}
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3))
geometry.setAttribute('size', new THREE.Float32BufferAttribute(sizes, 1))
// Create a material
const material = new StarfieldMaterial()
material.blending = THREE.AdditiveBlending
material.depthTest = false
material.transparent = true
// Create points and add them to the scene
this.points = new THREE.Points(geometry, material)
this.scene.add(this.points)
const clock = new THREE.Clock()
this.points.onBeforeRender = (renderer, scene, camera) => {
this.points?.position.copy?.(camera.position)
material.uniforms.time.value = clock.getElapsedTime() * speed
}
this.points.renderOrder = -1
}
remove () {
if (this.points) {
this.points.geometry.dispose();
(this.points.material as THREE.Material).dispose()
this.scene.remove(this.points)
this.points = undefined
}
}
}
const version = parseInt(THREE.REVISION.replaceAll(/\D+/g, ''), 10)
class StarfieldMaterial extends THREE.ShaderMaterial {
constructor () {
super({
uniforms: { time: { value: 0 }, fade: { value: 1 } },
vertexShader: /* glsl */ `
uniform float time;
attribute float size;
varying vec3 vColor;
attribute vec3 color;
void main() {
vColor = color;
vec4 mvPosition = modelViewMatrix * vec4(position, 0.5);
gl_PointSize = 0.7 * size * (30.0 / -mvPosition.z) * (3.0 + sin(time + 100.0));
gl_Position = projectionMatrix * mvPosition;
}`,
fragmentShader: /* glsl */ `
uniform sampler2D pointTexture;
uniform float fade;
varying vec3 vColor;
void main() {
float opacity = 1.0;
gl_FragColor = vec4(vColor, 1.0);
#include <tonemapping_fragment>
#include <${version >= 154 ? 'colorspace_fragment' : 'encodings_fragment'}>
}`,
})
}
}

View file

@ -1,5 +1,5 @@
import { fromFormattedString, render, RenderNode, TextComponent } from '@xmcl/text-component'
import type { ChatMessage } from 'prismarine-chat'
import { createCanvas } from '../lib/utils'
type SignBlockEntity = {
Color?: string
@ -32,29 +32,40 @@ const parseSafe = (text: string, task: string) => {
}
}
export const renderSign = (blockEntity: SignBlockEntity, PrismarineChat: typeof ChatMessage, ctxHook = (ctx) => { }) => {
const LEGACY_COLORS = {
black: '#000000',
dark_blue: '#0000AA',
dark_green: '#00AA00',
dark_aqua: '#00AAAA',
dark_red: '#AA0000',
dark_purple: '#AA00AA',
gold: '#FFAA00',
gray: '#AAAAAA',
dark_gray: '#555555',
blue: '#5555FF',
green: '#55FF55',
aqua: '#55FFFF',
red: '#FF5555',
light_purple: '#FF55FF',
yellow: '#FFFF55',
white: '#FFFFFF',
}
export const renderSign = (
blockEntity: SignBlockEntity,
isHanging: boolean,
PrismarineChat: typeof ChatMessage,
ctxHook = (ctx) => { },
canvasCreator = (width, height): OffscreenCanvas => { return createCanvas(width, height) }
) => {
// todo don't use texture rendering, investigate the font rendering when possible
// or increase factor when needed
const factor = 40
const fontSize = 1.6 * factor
const signboardY = [16, 9]
const heightOffset = signboardY[0] - signboardY[1]
const heightScalar = heightOffset / 16
let canvas: HTMLCanvasElement | undefined
let _ctx: CanvasRenderingContext2D | null = null
const getCtx = () => {
if (_ctx) return _ctx
canvas = document.createElement('canvas')
canvas.width = 16 * factor
canvas.height = heightOffset * factor
_ctx = canvas.getContext('2d')!
_ctx.imageSmoothingEnabled = false
ctxHook(_ctx)
return _ctx
}
// todo the text should be clipped based on it's render width (needs investigate)
const texts = 'front_text' in blockEntity ? /* > 1.20 */ blockEntity.front_text.messages : [
blockEntity.Text1,
@ -62,78 +73,144 @@ export const renderSign = (blockEntity: SignBlockEntity, PrismarineChat: typeof
blockEntity.Text3,
blockEntity.Text4
]
if (!texts.some((text) => text !== 'null')) {
return undefined
}
const canvas = canvasCreator(16 * factor, heightOffset * factor)
const _ctx = canvas.getContext('2d')!
ctxHook(_ctx)
const defaultColor = ('front_text' in blockEntity ? blockEntity.front_text.color : blockEntity.Color) || 'black'
for (const [lineNum, text] of texts.slice(0, 4).entries()) {
// todo: in pre flatenning it seems the format was not json
if (text === 'null') continue
const parsed = text?.startsWith('{') || text?.startsWith('"') ? parseSafe(text ?? '""', 'sign text') : text
if (!parsed || (typeof parsed !== 'object' && typeof parsed !== 'string')) continue
// todo fix type
const message = typeof parsed === 'string' ? fromFormattedString(parsed) : new PrismarineChat(parsed) as never
const patchExtra = ({ extra }: TextComponent) => {
if (!extra) return
for (const child of extra) {
if (child.color) {
child.color = child.color === 'dark_green' ? child.color.toUpperCase() : child.color.toLowerCase()
}
patchExtra(child)
}
}
patchExtra(message)
const rendered = render(message)
const toRenderCanvas: Array<{
fontStyle: string
fillStyle: string
underlineStyle: boolean
strikeStyle: boolean
text: string
}> = []
let plainText = ''
// todo the text should be clipped based on it's render width (needs investigate)
const MAX_LENGTH = 50 // avoid abusing the signboard
const renderText = (node: RenderNode) => {
const { component } = node
let { text } = component
if (plainText.length + text.length > MAX_LENGTH) {
text = text.slice(0, MAX_LENGTH - plainText.length)
if (!text) return false
}
plainText += text
toRenderCanvas.push({
fontStyle: `${component.bold ? 'bold' : ''} ${component.italic ? 'italic' : ''}`,
fillStyle: node.style['color'] || defaultColor,
underlineStyle: component.underlined ?? false,
strikeStyle: component.strikethrough ?? false,
text
})
for (const child of node.children) {
const stop = renderText(child) === false
if (stop) return false
}
}
renderText(rendered)
// skip rendering empty lines (and possible signs)
if (!plainText.trim()) continue
const ctx = getCtx()
const fontSize = 1.6 * factor
ctx.font = `${fontSize}px mojangles`
const textWidth = ctx.measureText(plainText).width
let renderedWidth = 0
for (const { fillStyle, fontStyle, strikeStyle, text, underlineStyle } of toRenderCanvas) {
// todo strikeStyle, underlineStyle
ctx.fillStyle = fillStyle
ctx.font = `${fontStyle} ${fontSize}px mojangles`
ctx.fillText(text, (canvas!.width - textWidth) / 2 + renderedWidth, fontSize * (lineNum + 1))
renderedWidth += ctx.measureText(text).width // todo isn't the font is monospace?
}
renderComponent(text, PrismarineChat, canvas, fontSize, defaultColor, fontSize * (lineNum + 1) + (isHanging ? 0 : -8))
}
// ctx.fillStyle = 'red'
// ctx.fillRect(0, 0, canvas.width, canvas.height)
return canvas
}
export const renderComponent = (
text: JsonEncodedType | string | undefined,
PrismarineChat: typeof ChatMessage,
canvas: OffscreenCanvas,
fontSize: number,
defaultColor: string,
offset = 0
) => {
// todo: in pre flatenning it seems the format was not json
const parsed = typeof text === 'string' && (text?.startsWith('{') || text?.startsWith('"')) ? parseSafe(text ?? '""', 'sign text') : text
if (!parsed || (typeof parsed !== 'object' && typeof parsed !== 'string')) return
// todo fix type
const ctx = canvas.getContext('2d')!
if (!ctx) throw new Error('Could not get 2d context')
ctx.imageSmoothingEnabled = false
ctx.font = `${fontSize}px mojangles`
type Formatting = {
color: string | undefined
underlined: boolean | undefined
strikethrough: boolean | undefined
bold: boolean | undefined
italic: boolean | undefined
}
type Message = ChatMessage & Formatting & { text: string }
const message = new PrismarineChat(parsed) as Message
const toRenderCanvas: Array<{
fontStyle: string
fillStyle: string
underlineStyle: boolean
strikeStyle: boolean
offset: number
text: string
}> = []
let visibleFormatting = false
let plainText = ''
let textOffset = offset
const textWidths: number[] = []
const renderText = (component: Message, parentFormatting?: Formatting | undefined) => {
const { text } = component
const formatting = {
color: component.color ?? parentFormatting?.color,
underlined: component.underlined ?? parentFormatting?.underlined,
strikethrough: component.strikethrough ?? parentFormatting?.strikethrough,
bold: component.bold ?? parentFormatting?.bold,
italic: component.italic ?? parentFormatting?.italic
}
visibleFormatting = visibleFormatting || formatting.underlined || formatting.strikethrough || false
if (text?.includes('\n')) {
for (const line of text.split('\n')) {
addTextPart(line, formatting)
textOffset += fontSize
plainText = ''
}
} else if (text) {
addTextPart(text, formatting)
}
if (component.extra) {
for (const child of component.extra) {
renderText(child as Message, formatting)
}
}
}
const addTextPart = (text: string, formatting: Formatting) => {
plainText += text
textWidths[textOffset] = ctx.measureText(plainText).width
let color = formatting.color ?? defaultColor
if (!color.startsWith('#')) {
color = LEGACY_COLORS[color.toLowerCase()] || color
}
toRenderCanvas.push({
fontStyle: `${formatting.bold ? 'bold' : ''} ${formatting.italic ? 'italic' : ''}`,
fillStyle: color,
underlineStyle: formatting.underlined ?? false,
strikeStyle: formatting.strikethrough ?? false,
offset: textOffset,
text
})
}
renderText(message)
// skip rendering empty lines
if (!visibleFormatting && !message.toString().trim()) return
let renderedWidth = 0
let previousOffsetY = 0
for (const { fillStyle, fontStyle, underlineStyle, strikeStyle, offset: offsetY, text } of toRenderCanvas) {
if (previousOffsetY !== offsetY) {
renderedWidth = 0
}
previousOffsetY = offsetY
ctx.fillStyle = fillStyle
ctx.textRendering = 'optimizeLegibility'
ctx.font = `${fontStyle} ${fontSize}px mojangles`
const textWidth = textWidths[offsetY] ?? ctx.measureText(text).width
const offsetX = (canvas.width - textWidth) / 2 + renderedWidth
ctx.fillText(text, offsetX, offsetY)
if (strikeStyle) {
ctx.lineWidth = fontSize / 8
ctx.strokeStyle = fillStyle
ctx.beginPath()
ctx.moveTo(offsetX, offsetY - ctx.lineWidth * 2.5)
ctx.lineTo(offsetX + ctx.measureText(text).width, offsetY - ctx.lineWidth * 2.5)
ctx.stroke()
}
if (underlineStyle) {
ctx.lineWidth = fontSize / 8
ctx.strokeStyle = fillStyle
ctx.beginPath()
ctx.moveTo(offsetX, offsetY + ctx.lineWidth)
ctx.lineTo(offsetX + ctx.measureText(text).width, offsetY + ctx.lineWidth)
ctx.stroke()
}
renderedWidth += ctx.measureText(text).width
}
}

View file

@ -21,9 +21,14 @@ const blockEntity = {
await document.fonts.load('1em mojangles')
const canvas = renderSign(blockEntity, PrismarineChat, (ctx) => {
const canvas = renderSign(blockEntity, false, PrismarineChat, (ctx) => {
ctx.drawImage(img, 0, 0, ctx.canvas.width, ctx.canvas.height)
})
}, (width, height) => {
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
return canvas as unknown as OffscreenCanvas
}) as unknown as HTMLCanvasElement
if (canvas) {
canvas.style.imageRendering = 'pixelated'

View file

@ -22,7 +22,7 @@ global.document = {
const render = (entity) => {
ctxTexts = []
renderSign(entity, PrismarineChat)
renderSign(entity, true, PrismarineChat)
return ctxTexts.map(({ text, y }) => [y / 64, text])
}
@ -37,10 +37,6 @@ test('sign renderer', () => {
} as any
expect(render(blockEntity)).toMatchInlineSnapshot(`
[
[
1,
"",
],
[
1,
"Minecraft ",

View file

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

View file

@ -0,0 +1,120 @@
import * as THREE from 'three'
import { WorldRendererThree } from './worldrendererThree'
export class CameraShake {
private rollAngle = 0
private get damageRollAmount () { return 5 }
private get damageAnimDuration () { return 200 }
private rollAnimation?: { startTime: number, startRoll: number, targetRoll: number, duration: number, returnToZero?: boolean }
private basePitch = 0
private baseYaw = 0
constructor (public worldRenderer: WorldRendererThree, public onRenderCallbacks: Array<() => void>) {
onRenderCallbacks.push(() => {
this.update()
})
}
setBaseRotation (pitch: number, yaw: number) {
this.basePitch = pitch
this.baseYaw = yaw
this.update()
}
getBaseRotation () {
return { pitch: this.basePitch, yaw: this.baseYaw }
}
shakeFromDamage (yaw?: number) {
// Add roll animation
const startRoll = this.rollAngle
const targetRoll = startRoll + (yaw ?? (Math.random() < 0.5 ? -1 : 1)) * this.damageRollAmount
this.rollAnimation = {
startTime: performance.now(),
startRoll,
targetRoll,
duration: this.damageAnimDuration / 2
}
}
update () {
if (this.worldRenderer.playerStateUtils.isSpectatingEntity()) {
// Remove any shaking when spectating
this.rollAngle = 0
this.rollAnimation = undefined
}
// Update roll animation
if (this.rollAnimation) {
const now = performance.now()
const elapsed = now - this.rollAnimation.startTime
const progress = Math.min(elapsed / this.rollAnimation.duration, 1)
if (this.rollAnimation.returnToZero) {
// Ease back to zero
this.rollAngle = this.rollAnimation.startRoll * (1 - this.easeInOut(progress))
if (progress === 1) {
this.rollAnimation = undefined
}
} else {
// Initial roll
this.rollAngle = this.rollAnimation.startRoll + (this.rollAnimation.targetRoll - this.rollAnimation.startRoll) * this.easeOut(progress)
if (progress === 1) {
// Start return to zero animation
this.rollAnimation = {
startTime: now,
startRoll: this.rollAngle,
targetRoll: 0,
duration: this.damageAnimDuration / 2,
returnToZero: true
}
}
}
}
const camera = this.worldRenderer.cameraObject
if (this.worldRenderer.cameraGroupVr) {
// For VR camera, only apply yaw rotation
const yawQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), this.baseYaw)
camera.setRotationFromQuaternion(yawQuat)
} else {
// For regular camera, apply all rotations
// Add tiny offsets to prevent z-fighting at ideal angles (90, 180, 270 degrees)
const pitchOffset = this.addAntiZfightingOffset(this.basePitch)
const yawOffset = this.addAntiZfightingOffset(this.baseYaw)
const pitchQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), pitchOffset)
const yawQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), yawOffset)
const rollQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), THREE.MathUtils.degToRad(this.rollAngle))
// Combine rotations in the correct order: pitch -> yaw -> roll
const finalQuat = yawQuat.multiply(pitchQuat).multiply(rollQuat)
camera.setRotationFromQuaternion(finalQuat)
}
}
private easeOut (t: number): number {
return 1 - (1 - t) * (1 - t)
}
private easeInOut (t: number): number {
return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2
}
private addAntiZfightingOffset (angle: number): number {
const offset = 0.001 // Very small offset in radians (about 0.057 degrees)
// Check if the angle is close to ideal angles (0, π/2, π, 3π/2)
const normalizedAngle = ((angle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)
const tolerance = 0.01 // Tolerance for considering an angle "ideal"
if (Math.abs(normalizedAngle) < tolerance ||
Math.abs(normalizedAngle - Math.PI / 2) < tolerance ||
Math.abs(normalizedAngle - Math.PI) < tolerance ||
Math.abs(normalizedAngle - 3 * Math.PI / 2) < tolerance) {
return angle + offset
}
return angle
}
}

View file

@ -0,0 +1,328 @@
import * as THREE from 'three'
import Stats from 'stats.js'
import StatsGl from 'stats-gl'
import * as tween from '@tweenjs/tween.js'
import { GraphicsBackendConfig, GraphicsInitOptions } from '../../../src/appViewer'
import { WorldRendererConfig } from '../lib/worldrendererCommon'
export class DocumentRenderer {
canvas: HTMLCanvasElement | OffscreenCanvas
readonly renderer: THREE.WebGLRenderer
private animationFrameId?: number
private timeoutId?: number
private lastRenderTime = 0
private previousCanvasWidth = 0
private previousCanvasHeight = 0
private currentWidth = 0
private currentHeight = 0
private renderedFps = 0
private fpsInterval: any
private readonly stats: TopRightStats | undefined
private paused = false
disconnected = false
preRender = () => { }
render = (sizeChanged: boolean) => { }
postRender = () => { }
sizeChanged = () => { }
droppedFpsPercentage: number
config: GraphicsBackendConfig
onRender = [] as Array<(sizeChanged: boolean) => void>
inWorldRenderingConfig: WorldRendererConfig | undefined
constructor (initOptions: GraphicsInitOptions, public externalCanvas?: OffscreenCanvas) {
this.config = initOptions.config
// Handle canvas creation/transfer based on context
if (externalCanvas) {
this.canvas = externalCanvas
} else {
this.addToPage()
}
try {
this.renderer = new THREE.WebGLRenderer({
canvas: this.canvas,
preserveDrawingBuffer: true,
logarithmicDepthBuffer: true,
powerPreference: this.config.powerPreference
})
} catch (err) {
initOptions.callbacks.displayCriticalError(new Error(`Failed to create WebGL context, not possible to render (restart browser): ${err.message}`))
throw err
}
this.renderer.outputColorSpace = THREE.LinearSRGBColorSpace
if (!externalCanvas) {
this.updatePixelRatio()
}
this.sizeUpdated()
// Initialize previous dimensions
this.previousCanvasWidth = this.canvas.width
this.previousCanvasHeight = this.canvas.height
const supportsWebGL2 = 'WebGL2RenderingContext' in window
// Only initialize stats and DOM-related features in main thread
if (!externalCanvas && supportsWebGL2) {
this.stats = new TopRightStats(this.canvas as HTMLCanvasElement, this.config.statsVisible)
this.setupFpsTracking()
}
this.startRenderLoop()
}
updatePixelRatio () {
let pixelRatio = window.devicePixelRatio || 1 // todo this value is too high on ios, need to check, probably we should use avg, also need to make it configurable
if (!this.renderer.capabilities.isWebGL2) {
pixelRatio = 1 // webgl1 has issues with high pixel ratio (sometimes screen is clipped)
}
this.renderer.setPixelRatio(pixelRatio)
}
sizeUpdated () {
this.renderer.setSize(this.currentWidth, this.currentHeight, false)
}
private addToPage () {
this.canvas = addCanvasToPage()
this.updateCanvasSize()
}
updateSizeExternal (newWidth: number, newHeight: number, pixelRatio: number) {
this.currentWidth = newWidth
this.currentHeight = newHeight
this.renderer.setPixelRatio(pixelRatio)
this.sizeUpdated()
}
private updateCanvasSize () {
if (!this.externalCanvas) {
const innnerWidth = window.innerWidth
const innnerHeight = window.innerHeight
if (this.currentWidth !== innnerWidth) {
this.currentWidth = innnerWidth
}
if (this.currentHeight !== innnerHeight) {
this.currentHeight = innnerHeight
}
}
}
private setupFpsTracking () {
let max = 0
this.fpsInterval = setInterval(() => {
if (max > 0) {
this.droppedFpsPercentage = this.renderedFps / max
}
max = Math.max(this.renderedFps, max)
this.renderedFps = 0
}, 1000)
}
private startRenderLoop () {
const animate = () => {
if (this.disconnected) return
if (this.config.timeoutRendering) {
this.timeoutId = setTimeout(animate, this.config.fpsLimit ? 1000 / this.config.fpsLimit : 0) as unknown as number
} else {
this.animationFrameId = requestAnimationFrame(animate)
}
if (this.paused || (this.renderer.xr.isPresenting && !this.inWorldRenderingConfig?.vrPageGameRendering)) return
// Handle FPS limiting
if (this.config.fpsLimit) {
const now = performance.now()
const elapsed = now - this.lastRenderTime
const fpsInterval = 1000 / this.config.fpsLimit
if (elapsed < fpsInterval) {
return
}
this.lastRenderTime = now - (elapsed % fpsInterval)
}
let sizeChanged = false
this.updateCanvasSize()
if (this.previousCanvasWidth !== this.currentWidth || this.previousCanvasHeight !== this.currentHeight) {
this.previousCanvasWidth = this.currentWidth
this.previousCanvasHeight = this.currentHeight
this.sizeUpdated()
sizeChanged = true
}
this.frameRender(sizeChanged)
// Update stats visibility each frame (main thread only)
if (this.config.statsVisible !== undefined) {
this.stats?.setVisibility(this.config.statsVisible)
}
}
animate()
}
frameRender (sizeChanged: boolean) {
this.preRender()
this.stats?.markStart()
tween.update()
if (!globalThis.freezeRender) {
this.render(sizeChanged)
}
for (const fn of this.onRender) {
fn(sizeChanged)
}
this.renderedFps++
this.stats?.markEnd()
this.postRender()
}
setPaused (paused: boolean) {
this.paused = paused
}
dispose () {
this.disconnected = true
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId)
}
if (this.timeoutId) {
clearTimeout(this.timeoutId)
}
if (this.canvas instanceof HTMLCanvasElement) {
this.canvas.remove()
}
clearInterval(this.fpsInterval)
this.stats?.dispose()
this.renderer.dispose()
}
}
class TopRightStats {
private readonly stats: Stats
private readonly stats2: Stats
private readonly statsGl: StatsGl
private total = 0
private readonly denseMode: boolean
constructor (private readonly canvas: HTMLCanvasElement, initialStatsVisible = 0) {
this.stats = new Stats()
this.stats2 = new Stats()
this.statsGl = new StatsGl({ minimal: true })
this.stats2.showPanel(2)
this.denseMode = process.env.NODE_ENV === 'production' || window.innerHeight < 500
this.initStats()
this.setVisibility(initialStatsVisible)
}
private addStat (dom: HTMLElement, size = 80) {
dom.style.position = 'absolute'
if (this.denseMode) dom.style.height = '12px'
dom.style.overflow = 'hidden'
dom.style.left = ''
dom.style.top = '0'
dom.style.right = `${this.total}px`
dom.style.width = '80px'
dom.style.zIndex = '1'
dom.style.opacity = '0.8'
document.body.appendChild(dom)
this.total += size
}
private initStats () {
const hasRamPanel = this.stats2.dom.children.length === 3
this.addStat(this.stats.dom)
if (process.env.NODE_ENV === 'development' && document.exitPointerLock) {
this.stats.dom.style.top = ''
this.stats.dom.style.bottom = '0'
}
if (hasRamPanel) {
this.addStat(this.stats2.dom)
}
this.statsGl.init(this.canvas)
this.statsGl.container.style.display = 'flex'
this.statsGl.container.style.justifyContent = 'flex-end'
let i = 0
for (const _child of this.statsGl.container.children) {
const child = _child as HTMLElement
if (i++ === 0) {
child.style.display = 'none'
}
child.style.position = ''
}
}
setVisibility (level: number) {
const visible = level > 0
if (visible) {
this.stats.dom.style.display = 'block'
this.stats2.dom.style.display = level >= 2 ? 'block' : 'none'
this.statsGl.container.style.display = level >= 2 ? 'block' : 'none'
} else {
this.stats.dom.style.display = 'none'
this.stats2.dom.style.display = 'none'
this.statsGl.container.style.display = 'none'
}
}
markStart () {
this.stats.begin()
this.stats2.begin()
this.statsGl.begin()
}
markEnd () {
this.stats.end()
this.stats2.end()
this.statsGl.end()
}
dispose () {
this.stats.dom.remove()
this.stats2.dom.remove()
this.statsGl.container.remove()
}
}
const addCanvasToPage = () => {
const canvas = document.createElement('canvas')
canvas.id = 'viewer-canvas'
document.body.appendChild(canvas)
return canvas
}
export const addCanvasForWorker = () => {
const canvas = addCanvasToPage()
const transferred = canvas.transferControlToOffscreen()
let removed = false
let onSizeChanged = (w, h) => { }
let oldSize = { width: 0, height: 0 }
const checkSize = () => {
if (removed) return
if (oldSize.width !== window.innerWidth || oldSize.height !== window.innerHeight) {
onSizeChanged(window.innerWidth, window.innerHeight)
oldSize = { width: window.innerWidth, height: window.innerHeight }
}
requestAnimationFrame(checkSize)
}
requestAnimationFrame(checkSize)
return {
canvas: transferred,
destroy () {
removed = true
canvas.remove()
},
onSizeChanged (cb: (width: number, height: number) => void) {
onSizeChanged = cb
},
get size () {
return { width: window.innerWidth, height: window.innerHeight }
}
}
}

View file

@ -2,11 +2,12 @@ import * as THREE from 'three'
import { OBJLoader } from 'three-stdlib'
import huskPng from 'mc-assets/dist/other-textures/latest/entity/zombie/husk.png'
import { Vec3 } from 'vec3'
import ocelotPng from '../../../../node_modules/mc-assets/dist/other-textures/latest/entity/cat/ocelot.png'
import arrowTexture from '../../../../node_modules/mc-assets/dist/other-textures/1.21.2/entity/projectiles/arrow.png'
import spectralArrowTexture from '../../../../node_modules/mc-assets/dist/other-textures/1.21.2/entity/projectiles/spectral_arrow.png'
import tippedArrowTexture from '../../../../node_modules/mc-assets/dist/other-textures/1.21.2/entity/projectiles/tipped_arrow.png'
import { WorldRendererCommon } from '../worldrendererCommon'
import { loadTexture } from '../utils'
import { loadTexture } from '../threeJsUtils'
import { WorldRendererThree } from '../worldrendererThree'
import entities from './entities.json'
import { externalModels } from './objModels'
import externalTexturesJson from './externalTextures.json'
@ -223,7 +224,7 @@ function addCube (
}
export function getMesh (
worldRenderer: WorldRendererCommon | undefined,
worldRenderer: WorldRendererThree | undefined,
texture: string,
jsonModel: JsonModel,
overrides: EntityOverrides = {},
@ -237,10 +238,11 @@ export function getMesh (
if (useBlockTexture) {
if (!worldRenderer) throw new Error('worldRenderer is required for block textures')
const blockName = texture.slice(6)
const textureInfo = worldRenderer.blocksAtlasParser!.getTextureInfo(blockName)
const textureInfo = worldRenderer.resourcesManager.currentResources.blocksAtlasJson.textures[blockName]
if (textureInfo) {
textureWidth = blocksTexture!.image.width
textureHeight = blocksTexture!.image.height
textureWidth = blocksTexture?.image.width ?? textureWidth
textureHeight = blocksTexture?.image.height ?? textureHeight
// todo support su/sv
textureOffset = [textureInfo.u, textureInfo.v]
} else {
console.error(`Unknown block ${blockName}`)
@ -437,7 +439,7 @@ export class EntityMesh {
constructor (
version: string,
type: string,
worldRenderer?: WorldRendererCommon,
worldRenderer?: WorldRendererThree,
overrides: EntityOverrides = {},
debugFlags: EntityDebugFlags = {}
) {
@ -456,7 +458,7 @@ export class EntityMesh {
'skeleton_horse': `textures/${version}/entity/horse/horse_skeleton.png`,
'donkey': `textures/${version}/entity/horse/donkey.png`,
'mule': `textures/${version}/entity/horse/mule.png`,
'ocelot': `textures/${version}/entity/cat/ocelot.png`,
'ocelot': ocelotPng,
'arrow': arrowTexture,
'spectral_arrow': spectralArrowTexture,
'tipped_arrow': tippedArrowTexture
@ -527,12 +529,6 @@ export class EntityMesh {
debugFlags)
mesh.name = `geometry_${name}`
this.mesh.add(mesh)
const skeletonHelper = new THREE.SkeletonHelper(mesh)
//@ts-expect-error
skeletonHelper.material.linewidth = 2
skeletonHelper.visible = false
this.mesh.add(skeletonHelper)
}
debugFlags.type = 'bedrock'
}
@ -551,3 +547,4 @@ export class EntityMesh {
}
}
}
globalThis.EntityMesh = EntityMesh

View file

@ -0,0 +1,171 @@
//@ts-check
import { PlayerAnimation } from 'skinview3d'
export class WalkingGeneralSwing extends PlayerAnimation {
switchAnimationCallback
isRunning = false
isMoving = true
isCrouched = false
_startArmSwing
swingArm() {
this._startArmSwing = this.progress
}
animate(player) {
// Multiply by animation's natural speed
let t = 0
const updateT = () => {
if (!this.isMoving) {
t = 0
return
}
if (this.isRunning) {
t = this.progress * 10 + Math.PI * 0.5
} else {
t = this.progress * 8
}
}
updateT()
let reset = false
croughAnimation(player, this.isCrouched)
if ((this.isRunning ? Math.cos(t) : Math.sin(t)) < 0.01) {
if (this.switchAnimationCallback) {
reset = true
this.progress = 0
updateT()
}
}
if (this.isRunning) {
// Leg swing with larger amplitude
player.skin.leftLeg.rotation.x = Math.cos(t + Math.PI) * 1.3
player.skin.rightLeg.rotation.x = Math.cos(t) * 1.3
} else {
// Leg swing
player.skin.leftLeg.rotation.x = Math.sin(t) * 0.5
player.skin.rightLeg.rotation.x = Math.sin(t + Math.PI) * 0.5
}
if (this._startArmSwing) {
const tHand = (this.progress - this._startArmSwing) * 18 + Math.PI * 0.5
// player.skin.rightArm.rotation.x = Math.cos(tHand) * 1.5
// const basicArmRotationZ = Math.PI * 0.1
// player.skin.rightArm.rotation.z = Math.cos(t + Math.PI) * 0.3 - basicArmRotationZ
HitAnimation.animate((this.progress - this._startArmSwing), player, this.isMoving)
if (tHand > Math.PI + Math.PI) {
this._startArmSwing = null
player.skin.rightArm.rotation.z = 0
}
}
if (this.isRunning) {
player.skin.leftArm.rotation.x = Math.cos(t) * 1.5
if (!this._startArmSwing) {
player.skin.rightArm.rotation.x = Math.cos(t + Math.PI) * 1.5
}
const basicArmRotationZ = Math.PI * 0.1
player.skin.leftArm.rotation.z = Math.cos(t) * 0.1 + basicArmRotationZ
if (!this._startArmSwing) {
player.skin.rightArm.rotation.z = Math.cos(t + Math.PI) * 0.1 - basicArmRotationZ
}
} else {
// Arm swing
player.skin.leftArm.rotation.x = Math.sin(t + Math.PI) * 0.5
if (!this._startArmSwing) {
player.skin.rightArm.rotation.x = Math.sin(t) * 0.5
}
const basicArmRotationZ = Math.PI * 0.02
player.skin.leftArm.rotation.z = Math.cos(t) * 0.03 + basicArmRotationZ
if (!this._startArmSwing) {
player.skin.rightArm.rotation.z = Math.cos(t + Math.PI) * 0.03 - basicArmRotationZ
}
}
if (this.isRunning) {
player.rotation.z = Math.cos(t + Math.PI) * 0.01
}
if (this.isRunning) {
const basicCapeRotationX = Math.PI * 0.3
player.cape.rotation.x = Math.sin(t * 2) * 0.1 + basicCapeRotationX
} else {
// Always add an angle for cape around the x axis
const basicCapeRotationX = Math.PI * 0.06
player.cape.rotation.x = Math.sin(t / 1.5) * 0.06 + basicCapeRotationX
}
if (reset) {
this.switchAnimationCallback()
this.switchAnimationCallback = null
}
}
}
const HitAnimation = {
animate(progress, player, isMovingOrRunning) {
const t = progress * 18
player.skin.rightArm.rotation.x = -0.453_786_055_2 * 2 + 2 * Math.sin(t + Math.PI) * 0.3
if (!isMovingOrRunning) {
const basicArmRotationZ = 0.01 * Math.PI + 0.06
player.skin.rightArm.rotation.z = -Math.cos(t) * 0.403 + basicArmRotationZ
player.skin.body.rotation.y = -Math.cos(t) * 0.06
player.skin.leftArm.rotation.x = Math.sin(t + Math.PI) * 0.077
player.skin.leftArm.rotation.z = -Math.cos(t) * 0.015 + 0.13 - 0.05
player.skin.leftArm.position.z = Math.cos(t) * 0.3
player.skin.leftArm.position.x = 5 - Math.cos(t) * 0.05
}
},
}
const croughAnimation = (player, isCrouched) => {
const erp = 0
// let pr = this.progress * 8;
let pr = isCrouched ? 1 : 0
const showProgress = false
if (showProgress) {
pr = Math.floor(pr)
}
player.skin.body.rotation.x = 0.453_786_055_2 * Math.abs(Math.sin((pr * Math.PI) / 2))
player.skin.body.position.z =
1.325_618_1 * Math.abs(Math.sin((pr * Math.PI) / 2)) - 3.450_031_037_7 * Math.abs(Math.sin((pr * Math.PI) / 2))
player.skin.body.position.y = -6 - 2.103_677_462 * Math.abs(Math.sin((pr * Math.PI) / 2))
player.cape.position.y = 8 - 1.851_236_166_577_372 * Math.abs(Math.sin((pr * Math.PI) / 2))
player.cape.rotation.x = (10.8 * Math.PI) / 180 + 0.294_220_265_771 * Math.abs(Math.sin((pr * Math.PI) / 2))
player.cape.position.z =
-2 + 3.786_619_432 * Math.abs(Math.sin((pr * Math.PI) / 2)) - 3.450_031_037_7 * Math.abs(Math.sin((pr * Math.PI) / 2))
player.elytra.position.x = player.cape.position.x
player.elytra.position.y = player.cape.position.y
player.elytra.position.z = player.cape.position.z
player.elytra.rotation.x = player.cape.rotation.x - (10.8 * Math.PI) / 180
// const pr1 = this.progress / this.speed;
const pr1 = 1
if (Math.abs(Math.sin((pr * Math.PI) / 2)) === 1) {
player.elytra.leftWing.rotation.z =
0.261_799_44 + 0.458_200_6 * Math.abs(Math.sin((Math.min(pr1 - erp, 1) * Math.PI) / 2))
player.elytra.updateRightWing()
} else if (isCrouched !== undefined) {
player.elytra.leftWing.rotation.z =
0.72 - 0.458_200_6 * Math.abs(Math.sin((Math.min(pr1 - erp, 1) * Math.PI) / 2))
player.elytra.updateRightWing()
}
player.skin.head.position.y = -3.618_325_234_674 * Math.abs(Math.sin((pr * Math.PI) / 2))
player.skin.leftArm.position.z =
3.618_325_234_674 * Math.abs(Math.sin((pr * Math.PI) / 2)) - 3.450_031_037_7 * Math.abs(Math.sin((pr * Math.PI) / 2))
player.skin.rightArm.position.z = player.skin.leftArm.position.z
player.skin.leftArm.rotation.x = 0.410_367_746_202 * Math.abs(Math.sin((pr * Math.PI) / 2))
player.skin.rightArm.rotation.x = player.skin.leftArm.rotation.x
player.skin.leftArm.rotation.z = 0.1
player.skin.rightArm.rotation.z = -player.skin.leftArm.rotation.z
player.skin.leftArm.position.y = -2 - 2.539_433_18 * Math.abs(Math.sin((pr * Math.PI) / 2))
player.skin.rightArm.position.y = player.skin.leftArm.position.y
player.skin.rightLeg.position.z = -3.450_031_037_7 * Math.abs(Math.sin((pr * Math.PI) / 2))
player.skin.leftLeg.position.z = player.skin.rightLeg.position.z
}

View file

@ -14,6 +14,7 @@ import { default as netheriteLayer1 } from 'mc-assets/dist/other-textures/latest
import { default as netheriteLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/netherite_layer_2.png'
import { default as turtleLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/turtle_layer_1.png'
export { default as elytraTexture } from 'mc-assets/dist/other-textures/latest/entity/elytra.png'
export { default as armorModel } from './armorModels.json'
export const armorTextures = {

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