mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
427
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6dbc3b39f | ||
|
|
dbc66a5a41 | ||
|
|
d14b100a97 | ||
|
|
9ed21bf436 | ||
|
|
ab2006a230 | ||
|
|
b61ef8f9be | ||
|
|
bd4236f3dd | ||
|
|
8ec23a2606 | ||
|
|
1b6a55e47d | ||
|
|
c143be7fd0 | ||
|
|
da8ae88eee | ||
|
|
3a419877b0 | ||
|
|
5fefa6ce42 | ||
|
|
6e4cb6210e | ||
|
|
985ec67b83 | ||
|
|
ca05c89c6e | ||
|
|
18c74b438c | ||
|
|
ff7cfe0f10 | ||
|
|
250c148e78 | ||
|
|
40fcc91eff | ||
|
|
8b3d4a069c | ||
|
|
a839062259 | ||
|
|
6194463454 | ||
|
|
a5f574b77e | ||
|
|
c4573fb561 | ||
|
|
57fc1570b2 | ||
|
|
628e230217 | ||
|
|
6926c76492 | ||
|
|
d2c96af142 | ||
|
|
d33f84ef73 | ||
|
|
ad63f2e960 | ||
|
|
7d0ae56589 | ||
|
|
e3de4a4604 | ||
|
|
9ae86086dc | ||
|
|
fbfec6435f | ||
|
|
e49de342ed | ||
|
|
7a3578b7d6 | ||
|
|
604ec7e2e4 | ||
|
|
21ff80eb8c | ||
|
|
f3213aa158 | ||
|
|
71f3c23dd9 | ||
|
|
574d54e6e0 | ||
|
|
c74490a003 | ||
|
|
d6cc631735 | ||
|
|
0d18ff7ca7 | ||
|
|
b7fcd446b6 | ||
|
|
f23db61ecf | ||
|
|
b470bec351 | ||
|
|
53e1191480 | ||
|
|
f28722fb15 | ||
|
|
a61c7faa23 | ||
|
|
c0bf5cf954 | ||
|
|
fb1f421117 | ||
|
|
0c5d229ad0 | ||
|
|
327a6cafc7 | ||
|
|
6a32ee68d4 | ||
|
|
0e73edceff | ||
|
|
f7bd3e381b | ||
|
|
ea0905e8a2 | ||
|
|
b149a0ec3f | ||
|
|
81b1be055c | ||
|
|
ebf76f4c03 | ||
|
|
fa093994ae | ||
|
|
273c456adc | ||
|
|
d30be3d994 | ||
|
|
c1e90297e2 | ||
|
|
9dfdff9d62 | ||
|
|
e56acaa7a9 | ||
|
|
65425da01d | ||
|
|
6b88291009 | ||
|
|
e29a71e5e0 | ||
|
|
80eafe84e9 | ||
|
|
6017830623 | ||
|
|
fb34815ce1 | ||
|
|
f49e3a2d24 | ||
|
|
cb0865e242 | ||
|
|
a27322f250 | ||
|
|
ed3243ca62 | ||
|
|
f59bb8b8d5 | ||
|
|
00705d8431 | ||
|
|
0564ab0bcd | ||
|
|
9409d2c19b | ||
|
|
eb1fdf5fb4 | ||
|
|
2ee008f8fa | ||
|
|
8a0303ed62 | ||
|
|
b15e46c3af | ||
|
|
e988b9b833 | ||
|
|
504cb4f74c | ||
|
|
659bfad86c | ||
|
|
76408b734e | ||
|
|
4e3e038c80 | ||
|
|
b0918f3be1 | ||
|
|
08491bcda3 | ||
|
|
b165506d6e | ||
|
|
09e95ae5a1 | ||
|
|
5a0e650e63 | ||
|
|
3b586ff903 | ||
|
|
6acf7fb6ab | ||
|
|
afb3ae79a2 | ||
|
|
4a675206b1 | ||
|
|
486df91b2d | ||
|
|
2c80009c70 | ||
|
|
41f8cfa3bc | ||
|
|
1ef5b82b7d | ||
|
|
5d19fcaa06 | ||
|
|
489b5a4ffa | ||
|
|
342cb6c500 | ||
|
|
eed98be9cf | ||
|
|
859236bd9d | ||
|
|
07519df062 | ||
|
|
b1eae8a5b8 | ||
|
|
cc65eeec82 | ||
|
|
792ac58f46 | ||
|
|
578f077347 | ||
|
|
b6ec56ac9b | ||
|
|
e334dd3d55 | ||
|
|
8948bf90c4 | ||
|
|
753a99b933 | ||
|
|
dc1e16f472 | ||
|
|
f2f58aca94 | ||
|
|
f9e3244281 | ||
|
|
c36e81916b | ||
|
|
87f377b29c | ||
|
|
6e86e07781 | ||
|
|
4bb7f96f81 | ||
|
|
e820e9e1af | ||
|
|
89f426c48f | ||
|
|
961af30997 | ||
|
|
c10261f008 | ||
|
|
a96dccf691 | ||
|
|
6d1f6965d2 | ||
|
|
f66c257ae5 | ||
|
|
9f1a880951 | ||
|
|
7a7bfdde79 | ||
|
|
947d65e64d | ||
|
|
fe253fe534 | ||
|
|
96b6ed4916 | ||
|
|
6240124557 | ||
|
|
e55b5b7c00 | ||
|
|
b0a5334cb5 | ||
|
|
a93c1507f9 | ||
|
|
a26297058a | ||
|
|
149864c3b4 | ||
|
|
7c27b85fbd | ||
|
|
42afd721e7 | ||
|
|
4d3f3b6391 | ||
|
|
75d70a1a74 | ||
|
|
856f0c2e40 | ||
|
|
75c009e3fc | ||
|
|
213f1bbb2e | ||
|
|
e30166f23b | ||
|
|
ba09b086a6 | ||
|
|
f07d66c7cf | ||
|
|
6d735c26d3 | ||
|
|
3dc14b6eda | ||
|
|
f500be1b5e | ||
|
|
3e3ab7a413 | ||
|
|
0253d9bc47 | ||
|
|
fab71df185 | ||
|
|
8698b4ad0c | ||
|
|
76638aaf80 | ||
|
|
eaa9f3c203 | ||
|
|
395c256d11 | ||
|
|
f31c18aa6c | ||
|
|
a2a91f98f9 | ||
|
|
75983739ec | ||
|
|
a4fa1a770d | ||
|
|
8a7831e58b | ||
|
|
3612f51812 | ||
|
|
d6e5fb5331 | ||
|
|
3b3590ea03 | ||
|
|
c41a053c15 | ||
|
|
0d1736da8f | ||
|
|
977ef7181f | ||
|
|
75b7812f68 | ||
|
|
e0344f1f79 | ||
|
|
6774b2b3e7 | ||
|
|
466b1de4c4 | ||
|
|
282ecf16a9 | ||
|
|
7072a40701 | ||
|
|
2a6347dc55 | ||
|
|
f1cfb3069f | ||
|
|
de840da0e5 | ||
|
|
8800d1878a | ||
|
|
1776d7ca5c | ||
|
|
7b56b59e7c | ||
|
|
7d76923a04 | ||
|
|
59281c5c73 | ||
|
|
303d497a67 | ||
|
|
7334bebc1b | ||
|
|
d604409be2 | ||
|
|
2716ad41be | ||
|
|
791eaca45f | ||
|
|
cb1c9e6935 | ||
|
|
0849283a9d | ||
|
|
40eb3e202a | ||
|
|
be34f4cd5f | ||
|
|
bffb68b0c5 | ||
|
|
65d7c4b027 | ||
|
|
7becd7fdca | ||
|
|
4ad05aa94a | ||
|
|
8c611e677b | ||
|
|
c6980fb2b7 | ||
|
|
3f1a7b28af | ||
|
|
6c6c08f4d9 | ||
|
|
7d06aca17a | ||
|
|
0e607404d1 | ||
|
|
66070b918e | ||
|
|
6b2ec5f830 | ||
|
|
d064dba945 | ||
|
|
72f88befb8 | ||
|
|
891e1c1283 | ||
|
|
d83b3e18bc | ||
|
|
37c36b548a | ||
|
|
6e8a96a734 | ||
|
|
508cb2df9f | ||
|
|
4c3abd86de | ||
|
|
a5f5d8df34 | ||
|
|
adcfe810f2 | ||
|
|
3802a97dc9 | ||
|
|
90b5e162ae | ||
|
|
b61b25f80f | ||
|
|
ff91907057 | ||
|
|
d742d725f4 | ||
|
|
fe128948ba | ||
|
|
33c2512cbd | ||
|
|
2e35028930 | ||
|
|
d0b7c20371 | ||
|
|
7c1dd49810 | ||
|
|
f239955997 | ||
|
|
4c65ed2f5c | ||
|
|
afcb4062cb | ||
|
|
352645b417 | ||
|
|
aee0b8b589 | ||
|
|
7f995af677 | ||
|
|
61e044e67c | ||
|
|
18b63d7acb | ||
|
|
e683cdf322 | ||
|
|
e5bfce3b37 | ||
|
|
0a1d3a7534 | ||
|
|
66235cc981 | ||
|
|
f80d00802e | ||
|
|
397d529db7 | ||
|
|
eb0a6f74e6 | ||
|
|
07e46daa59 | ||
|
|
f1ff81e874 | ||
|
|
1b23a14ee7 | ||
|
|
16d49fe660 | ||
|
|
fccac1e744 | ||
|
|
42714deca6 | ||
|
|
1a4a2ec120 | ||
|
|
ac43cdea70 | ||
|
|
6d81b98f18 | ||
|
|
9223b401e7 | ||
|
|
1dffadeea3 | ||
|
|
29c5e65d17 | ||
|
|
83657f3bbf | ||
|
|
094d5c8e16 | ||
|
|
98639d1471 | ||
|
|
0c6816811c | ||
|
|
1be8a4caea | ||
|
|
4ebba25280 | ||
|
|
7fca3c2f97 | ||
|
|
db758a622f | ||
|
|
2ad81884f9 | ||
|
|
84bdd1d0b0 | ||
|
|
493e5f8d3a | ||
|
|
ccb827282b | ||
|
|
56aa5f9549 | ||
|
|
d271176f23 | ||
|
|
d65282fb0f | ||
|
|
6a698c40f0 | ||
|
|
71292d2ed7 | ||
|
|
46f4237b67 | ||
|
|
643f331f8c | ||
|
|
186ab70305 | ||
|
|
cdfcc3d4d4 | ||
|
|
7a1638a190 | ||
|
|
ab8b379097 | ||
|
|
a3b4ed57b2 | ||
|
|
4e7c25bb55 | ||
|
|
ba9b876e3e | ||
|
|
e1052e1bd3 | ||
|
|
afb638b81c | ||
|
|
f1369b4efe | ||
|
|
d49b0eb98b | ||
|
|
68bc9f82b6 | ||
|
|
717acb07af | ||
|
|
e423e570c9 | ||
|
|
06ec15f3a0 | ||
|
|
2a9e0ab28d | ||
|
|
87aa88de54 | ||
|
|
7efdbdb39a | ||
|
|
535f2d2a04 | ||
|
|
7eb7791240 | ||
|
|
ca90a20992 | ||
|
|
923368f4df | ||
|
|
25bdaf5321 | ||
|
|
8a04e18b91 | ||
|
|
5d54f28c42 | ||
|
|
818d1515c6 | ||
|
|
e61e6345bc | ||
|
|
02039fed5b | ||
|
|
976956fc3e | ||
|
|
dfae12cfe2 | ||
|
|
8a8348c93f | ||
|
|
0a3965a7b1 | ||
|
|
704931ed14 | ||
|
|
a713dbd46d | ||
|
|
1868fc39ae | ||
|
|
50f2894ca8 | ||
|
|
4b44399242 | ||
|
|
43e6bfcf98 | ||
|
|
f688726b34 | ||
|
|
826cf8ffe6 | ||
|
|
e2dd6b67df | ||
|
|
ebd25abd0e | ||
|
|
9624e99801 | ||
|
|
6a5c749f7c | ||
|
|
c33acfdd05 | ||
|
|
7c83fbb6df | ||
|
|
c26ced533e | ||
|
|
e0d29dc233 | ||
|
|
6eb34eb868 | ||
|
|
b21423ae01 | ||
|
|
a6e9374f88 | ||
|
|
50aee22996 | ||
|
|
01133fac82 | ||
|
|
4507ded0e4 | ||
|
|
46304cc1a7 | ||
|
|
377d81a329 | ||
|
|
905acfe7cd | ||
|
|
c18f3dbfde | ||
|
|
9c843819b7 | ||
|
|
c466c5ddad | ||
|
|
d5bd35025e | ||
|
|
2cd8b88a5b | ||
|
|
0b581f9ffe | ||
|
|
f67b2b3204 | ||
|
|
d139f9bee6 | ||
|
|
03d93d79b2 | ||
|
|
1b14e0d171 | ||
|
|
f8e4e31b12 | ||
|
|
67e07f3faa | ||
|
|
2bbbc70620 | ||
|
|
9c134f54ac | ||
|
|
a00dc868ec | ||
|
|
08306d1586 | ||
|
|
3b70dc9c16 | ||
|
|
13c14563c4 | ||
|
|
99b058442c | ||
|
|
8cce2e2f8a | ||
|
|
582856aac2 | ||
|
|
0a95a78e03 | ||
|
|
5205b909f7 | ||
|
|
5ec40c8004 | ||
|
|
82bba24f43 | ||
|
|
7b0316e6a4 | ||
|
|
a29e10ef9b | ||
|
|
92050712fa | ||
|
|
122e3bf287 | ||
|
|
65031a7739 | ||
|
|
82218db860 | ||
|
|
040ac1bec4 | ||
|
|
59f018eabe | ||
|
|
969bcec6c9 | ||
|
|
ca0637da80 | ||
|
|
47459b0b19 | ||
|
|
9b16d37e80 | ||
|
|
03be14bb59 | ||
|
|
45e5b64520 | ||
|
|
d65be4183f | ||
|
|
e307f03684 | ||
|
|
26a91e3a08 | ||
|
|
8c81cc63f7 | ||
|
|
221545e92b | ||
|
|
88b27070bd | ||
|
|
499f8bddfc | ||
|
|
5904922865 | ||
|
|
49e706e8be | ||
|
|
5e63b0512d | ||
|
|
d66466db81 | ||
|
|
6b55abd66a | ||
|
|
22b2711cf9 | ||
|
|
6ea7167cbb | ||
|
|
51de8afe95 | ||
|
|
3e04df41c8 | ||
|
|
7371b2cccd | ||
|
|
077c4c9214 | ||
|
|
8f081ae460 | ||
|
|
14461c1d96 | ||
|
|
ea424ebd52 | ||
|
|
fc9822b241 | ||
|
|
4b1e298a44 | ||
|
|
a4a101ca9c | ||
|
|
c6ed3112e7 | ||
|
|
f96101cd47 | ||
|
|
fa4ea80d32 | ||
|
|
0bbe50c6e8 | ||
|
|
c924894624 | ||
|
|
287d7a7eb5 | ||
|
|
c86996c234 | ||
|
|
d7b5cd8c85 | ||
|
|
4daa91cf52 | ||
|
|
5595db9077 | ||
|
|
fe70ff90b4 | ||
|
|
4a442f7250 | ||
|
|
f47f6ee472 | ||
|
|
85c6103d45 | ||
|
|
60611ed5a3 | ||
|
|
4fa5966338 | ||
|
|
eb79cfdb01 | ||
|
|
717f54bf28 | ||
|
|
ad76f911bd | ||
|
|
5ed8181adc | ||
|
|
5617d702cc | ||
|
|
52d8ed6721 | ||
|
|
de358cd721 | ||
|
|
9acc68f09f | ||
|
|
a8c21c27df | ||
|
|
0f3a92ac2b | ||
|
|
a79077b30e | ||
|
|
ac811d4708 | ||
|
|
5858cb113e | ||
|
|
fe6ae937ef | ||
|
|
c344a26e80 | ||
|
|
6a58fc20da |
No files matched your search
@@ -3,3 +3,4 @@
|
||||
docs/launcher-commands.md text eol=lf
|
||||
docs/horizon-ctl-commands.md text eol=lf
|
||||
website/src/tokens.css text eol=lf
|
||||
docs/api/*-reference.md text eol=lf
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec python3 tools/check_public_tree.py --history
|
||||
exec python3 tools/check_public_tree.py --pre-push
|
||||
@@ -430,7 +430,7 @@ jobs:
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_float_log_parity.py
|
||||
portable-mod:
|
||||
jfr-analysis:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: [self-hosted, linux, horizon-game]
|
||||
steps:
|
||||
@@ -442,101 +442,7 @@ jobs:
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
- name: Compile original mod and test metrics
|
||||
run: python tools/build_mod.py
|
||||
- name: Verify streaming JFR analysis against the JDK exporter
|
||||
run: python tools/test_jfr.py
|
||||
- name: Verify ZGC pause and allocation event interpretation
|
||||
run: python tools/test_jfr.py --gc zgc
|
||||
- name: Build pinned portable packs
|
||||
run: python tools/build_pack.py
|
||||
- name: Measure isolated worker pacing on this platform
|
||||
run: python -c "from tools.build_mod import benchmark_workers; benchmark_workers()"
|
||||
- name: Measure isolated debounce queue polling
|
||||
run: python -c "from tools.build_mod import benchmark_debounce; benchmark_debounce()"
|
||||
- name: Measure wakeable idle queue latency
|
||||
run: python -c "from tools.build_mod import benchmark_idle; benchmark_idle()"
|
||||
- name: Measure object allocations with compact headers off and on
|
||||
run: python -c "from tools.build_mod import benchmark_headers; benchmark_headers()"
|
||||
- name: Test pack integrity and comparisons
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: Start recommended OpenGL macOS clients and verify framebuffer readback
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
python tools/test_client_graphics.py --native-display
|
||||
cp dist/client-graphics-smoke.json dist/client-graphics-macos-title.json
|
||||
python tools/test_client_graphics.py --native-display --world
|
||||
cp dist/client-graphics-smoke.json dist/client-graphics-macos-world.json
|
||||
- name: Compile the private dispatch fixture
|
||||
run: python -c "from tools.build_mod import build_dispatch_fixture; build_dispatch_fixture()"
|
||||
- name: Compile the private integrated-server graphics fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('server-graphics', 'dev.horizonlab.metrics.ServerGraphicsIntegration', fixture_dependencies={})"
|
||||
- name: Compile the private tick-manager state fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('tick-state', 'dev.horizonlab.metrics.TickStateIntegration')"
|
||||
- name: Compile the private terrain tick membership fixture
|
||||
run: python -c "from tools.build_mod import build_tick_dedup_fixture; build_tick_dedup_fixture()"
|
||||
- name: Compile the private culling whitelist fixture
|
||||
run: python -c "from tools.build_mod import build_culling_empty_fixture; build_culling_empty_fixture()"
|
||||
- name: Compile the private culling ray geometry fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('culling-rays', 'dev.horizonlab.dispatch.CullingRayIntegration', ['entityculling'], ['CullingRayAccess'])"
|
||||
- name: Compile the private terrain position lookup fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('terrain-position', 'dev.horizonlab.dispatch.PositionIntegration')"
|
||||
- name: Compile the private terrain quad reuse fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('quad-reuse', 'dev.horizonlab.dispatch.QuadReuseIntegration')"
|
||||
- name: Compile the private object-size agent and terrain quad fixture
|
||||
run: python -c "from tools.test_quad_sizes import build_agent; from tools.build_mod import build_integration_fixture; build_agent(); build_integration_fixture('quad-size', 'dev.horizonlab.dispatch.QuadSizeIntegration')"
|
||||
- name: Compile the private terrain read shutdown fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('terrain-read', 'dev.horizonlab.dispatch.TerrainReadIntegration')"
|
||||
- name: Install headless graphics libraries for applied-hook checks
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb libgl1 libegl1 libvulkan1 mesa-vulkan-drivers libasound2t64 libxkbcommon0
|
||||
- name: Verify integrated-server graphics report against the actual device
|
||||
run: python tools/test_server_graphics.py ${{ runner.os == 'macOS' && '--native-display' || '' }}
|
||||
- name: Exercise the applied dispatcher hooks and upstream rejection path
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_dispatch.py
|
||||
- name: Verify applied tick membership transitions and allocation
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_tick_dedup.py
|
||||
- name: Verify live culling whitelist registration and empty iteration
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_culling_empty.py
|
||||
- name: Compare applied culling ray geometry and provider queries
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_culling_rays.py
|
||||
- name: Verify actual full-data position lookup equivalence
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_terrain_position.py
|
||||
- name: Verify actual terrain quad geometry and allocation
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_quad_reuse.py
|
||||
- name: Measure actual builder and quad shallow sizes in both header modes
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_quad_sizes.py
|
||||
- name: Verify interrupted DH reads and preserved shared futures
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_terrain_read.py
|
||||
- name: Upload original mod and pack manifests only
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: horizon-linux
|
||||
path: |
|
||||
dist/*.mrpack
|
||||
dist/horizon-*.jar
|
||||
dist/worker-pacing-benchmark.txt
|
||||
dist/debounce-queue-benchmark.txt
|
||||
dist/idle-queue-benchmark.txt
|
||||
dist/object-header-benchmark.txt
|
||||
dist/dispatch-integration.json
|
||||
dist/tick-dedup-integration.json
|
||||
dist/culling-empty-integration.json
|
||||
dist/terrain-read-integration.json
|
||||
dist/culling-rays-integration.json
|
||||
dist/terrain-position-integration.json
|
||||
dist/quad-reuse-integration.json
|
||||
dist/quad-size-integration.json
|
||||
dist/server-graphics-smoke.json
|
||||
dist/client-graphics-smoke.json
|
||||
dist/client-graphics-macos-title.json
|
||||
dist/client-graphics-macos-world.json
|
||||
retention-days: 3
|
||||
@@ -0,0 +1,34 @@
|
||||
name: mod-registry
|
||||
# The submission service is its own workspace (crates/horizon-mod-registry), so the checks job doesn't build
|
||||
# it: this one does, when it or horizon-mods (the checks it shares with the launcher and the game) changes.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'crates/horizon-mods/**'
|
||||
- 'crates/horizon-mod-registry/**'
|
||||
- '.github/workflows/mod-registry.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'crates/horizon-mods/**'
|
||||
- 'crates/horizon-mod-registry/**'
|
||||
- '.github/workflows/mod-registry.yml'
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: crates/horizon-mod-registry
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: crates/horizon-mod-registry
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- run: cargo +stable fmt --check
|
||||
- run: cargo +stable clippy --all-targets --locked -- -D warnings
|
||||
- run: cargo +stable test --locked
|
||||
@@ -1,8 +1,8 @@
|
||||
name: release
|
||||
# Automatic game releases (docs/release-flow.md). After every green `checks` run on main, every 10 minutes (so a
|
||||
# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides:
|
||||
# a release when pull requests merged since the last one changed what players run and the newest merged 5 minutes
|
||||
# ago (the oldest 20 minutes ago, if merges never pause), or at once for a pull request labelled release-now.
|
||||
# a release when 5 pull requests merged since the last one changed what players run and the newest merged 5 minutes
|
||||
# ago, or when the oldest has waited 6 hours, or at once for a pull request labelled release-now.
|
||||
# RELEASES_PAUSED=1 (repository variable) stops them; so does an open issue labelled release-blocker, forced runs too.
|
||||
# Builds Linux (an older Ubuntu, so the binary runs on any recent distribution and on NixOS through nix-ld) and
|
||||
# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub), then
|
||||
@@ -367,10 +367,15 @@ jobs:
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
# The release and the latest at once: there is one channel (docs/release-flow.md).
|
||||
# The release and the latest at once: there is one channel (docs/release-flow.md). Each archive gets a
|
||||
# label (file#label), which GitHub's page shows instead of the file name, so nobody takes the game's
|
||||
# files for the download (#1647). Launchers, servers and install.sh match the name, which stays.
|
||||
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
|
||||
--notes-file notes/body.md --latest dist/horizon-linux-x86_64.tar.gz \
|
||||
dist/horizon-windows-x64.zip dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz \
|
||||
--notes-file notes/body.md --latest \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs: Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Game files the launcher installs: Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Game files the launcher installs: macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
- name: Dry run, the release that would be published
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
|
||||
@@ -12,17 +12,20 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
|
||||
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`. Never write to
|
||||
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
|
||||
- **Mark what an agent posts.** A PR body starts with
|
||||
`> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.` (issues: "Filed by", reviews:
|
||||
"Review by"); every comment starts with `🤖 Agent:`. Never write as Ludovic.
|
||||
- **Attribution.** Commits end with `Co-Authored-By: Claude <model> <[email protected]>`; PR bodies end
|
||||
with `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
|
||||
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
|
||||
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
|
||||
contributor's agent names its own person and tool (issues: "Filed by", reviews: "Review by"). Every
|
||||
comment starts with `🤖 Agent:`. Never write as Ludovic or as anyone else.
|
||||
- **Attribution.** Commits end with a `Co-Authored-By:` line for the agent that wrote them, and PR bodies
|
||||
with that agent's own "Generated with" line. Ludovic's agents use
|
||||
`Co-Authored-By: Claude <model> <[email protected]>` and
|
||||
`🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
|
||||
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
|
||||
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
|
||||
they would change the vanilla look.
|
||||
- Small PRs (< ~500 lines). Labels: `feature` (players get something new), `skip-release` (they see no
|
||||
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Every merge to main ships
|
||||
([docs/release-flow.md](docs/release-flow.md)).
|
||||
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Merges ship in batches of 5, or after
|
||||
6 hours ([docs/release-flow.md](docs/release-flow.md)).
|
||||
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
|
||||
|
||||
## Crates
|
||||
@@ -40,12 +43,12 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
|
||||
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-shaderpack` | OptiFine/Iris packs: preprocessor, GLSL → WGSL ([docs/shader-packs.md](docs/shader-packs.md)) |
|
||||
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
|
||||
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `voxel-kernels`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, occupancy kernels, repo tasks |
|
||||
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, repo tasks |
|
||||
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
|
||||
|
||||
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
|
||||
@@ -123,7 +126,6 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; L: `keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `osk.rs`, `clipboard.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `lang.rs`; crate `horizon-assets`; G: `packs.rs` |
|
||||
| Shader packs | L: `shader_pack.rs` (Horizon WGSL packs), `java_pack.rs` + crate `horizon-shaderpack` (Iris/OptiFine), `gfx/pack_stages.rs`, `gfx/java_stages.rs`; G: `shader_packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
|
||||
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
|
||||
@@ -148,19 +150,19 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
|
||||
## The two single-player paths
|
||||
|
||||
1. **In-frame (default since #1428):** the window's `Game` ticks the world itself, one level that follows
|
||||
the player (`fn enter_dimension`). This is what Stable players run and what harnesses test.
|
||||
2. **Server thread (`--server-thread` or `HORIZON_SERVER_THREAD=1`):** `game/integrated.rs` runs a second
|
||||
`Game` (`serving`) on a "Server thread" with one level per dimension; the window joins it as a client
|
||||
through memory (`remote`), exactly as a LAN guest or a dedicated server's player does. Not at parity yet:
|
||||
open `[server-thread]` issues list the gaps.
|
||||
1. **Server thread (default since #1514):** `game/integrated.rs` runs a second `Game` (`serving`) on a
|
||||
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
|
||||
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
|
||||
`[server-thread]` issues list the gaps left.
|
||||
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`):** the window's `Game` ticks
|
||||
the world itself, one level that follows the player (`fn enter_dimension`). Most harnesses test this path.
|
||||
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
|
||||
when you touch player-facing gameplay:
|
||||
|
||||
```sh
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # in-frame
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --server-thread # server thread
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --in-frame # in-frame
|
||||
python3 tools/lan_e2e.py --check <name> # host in-frame, guest is a server client
|
||||
python3 tools/lan_e2e.py --server-thread --check <name> # host on its server thread (portals, levels, distance, lava need it)
|
||||
python3 tools/parity.py dedicated_server server_entities server_player_damage
|
||||
|
||||
@@ -39,6 +39,17 @@ probe against Horizon, and compares their outputs. There are about 290 of them.
|
||||
|
||||
Never commit game files, decompiled source or prepared resources (`.local/`): `tools/check_public_tree.py` rejects
|
||||
them (`git config core.hooksPath .githooks` runs it before every commit).
|
||||
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
|
||||
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
|
||||
|
||||
## Only original code
|
||||
|
||||
Horizon's code is written from Minecraft 26.3 itself and from public documentation, never from another mod, loader
|
||||
or renderer. Don't read, copy, translate or paraphrase the source of Iris, OptiFine, Sodium or any other project,
|
||||
and don't send code an AI tool wrote from it. Their user documentation and file formats are fine to read, and their
|
||||
packs and mods are fine to run and observe. Shader packs were removed on 24 October 2026 (#1561) because their code
|
||||
had been written from another project's source; they come back only as a clean-room rewrite from a written
|
||||
description of the pack format.
|
||||
|
||||
## Before asking for review
|
||||
|
||||
|
||||
Generated
+65
-387
@@ -155,7 +155,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -178,15 +178,6 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
|
||||
|
||||
[[package]]
|
||||
name = "ascii-canvas"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891"
|
||||
dependencies = [
|
||||
"term",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ash"
|
||||
version = "0.38.0+1.3.281"
|
||||
@@ -214,30 +205,15 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
|
||||
dependencies = [
|
||||
"bit-vec 0.9.1",
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
@@ -283,16 +259,6 @@ dependencies = [
|
||||
"objc2 0.6.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -462,6 +428,8 @@ version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"termcolor",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
@@ -484,15 +452,6 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coords-hud"
|
||||
version = "0.1.0"
|
||||
@@ -797,41 +756,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
@@ -849,29 +773,6 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
|
||||
dependencies = [
|
||||
"derive_more-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more-impl"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn 2.0.119",
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -958,15 +859,6 @@ version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||
|
||||
[[package]]
|
||||
name = "ena"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.42"
|
||||
@@ -1230,58 +1122,6 @@ version = "0.30.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
|
||||
|
||||
[[package]]
|
||||
name = "glsl-lang"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "983a18d0c2ecad18ac9ff3c94b069d6bd03b47405a200836d9d8d63def8e34de"
|
||||
dependencies = [
|
||||
"glsl-lang-lexer",
|
||||
"glsl-lang-types",
|
||||
"lalrpop",
|
||||
"lalrpop-util",
|
||||
"lang-util",
|
||||
"once_cell",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glsl-lang-lexer"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abdbc0bdfbc81d557f4865e695457eb23d68031bfb061fda3444f56511b26eee"
|
||||
dependencies = [
|
||||
"glsl-lang-pp",
|
||||
"glsl-lang-types",
|
||||
"lalrpop-util",
|
||||
"lang-util",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glsl-lang-pp"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88677876fb27f116015d0ecebda6d77bdf65f3a6fa3f5dfeed10b8c8f04cb2c5"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"derive_more",
|
||||
"lang-util",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glsl-lang-types"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd8b1532b424d40df634cf2dc2be56d59f994b9ac07fa7c1e3183fd33db9cb07"
|
||||
dependencies = [
|
||||
"lang-util",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpu-allocator"
|
||||
version = "0.28.0"
|
||||
@@ -1428,7 +1268,7 @@ dependencies = [
|
||||
"horizon-mods",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
"horizon-shaderpack",
|
||||
"horizon-shaders",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
@@ -1441,9 +1281,9 @@ dependencies = [
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"symphonia",
|
||||
"toml",
|
||||
"ureq",
|
||||
"wgpu",
|
||||
"winit",
|
||||
@@ -1478,7 +1318,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"socket2",
|
||||
"socket2 0.5.10",
|
||||
"spake2",
|
||||
]
|
||||
|
||||
@@ -1494,8 +1334,10 @@ name = "horizon-mods"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
"wasmtime",
|
||||
"wit-component 0.239.0",
|
||||
@@ -1541,6 +1383,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"horizon-core",
|
||||
"horizon-world",
|
||||
"ipconfig",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
@@ -1595,12 +1438,11 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-shaderpack"
|
||||
name = "horizon-shaders"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"glsl-lang",
|
||||
"horizon-core",
|
||||
"naga",
|
||||
"pp-rs",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -1755,12 +1597,6 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -1833,6 +1669,19 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipconfig"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
|
||||
dependencies = [
|
||||
"socket2 0.6.5",
|
||||
"widestring",
|
||||
"windows-registry",
|
||||
"windows-result 0.4.1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
@@ -1943,73 +1792,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keccak"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
|
||||
dependencies = [
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lalrpop"
|
||||
version = "0.22.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501"
|
||||
dependencies = [
|
||||
"ascii-canvas",
|
||||
"bit-set 0.8.0",
|
||||
"ena",
|
||||
"itertools",
|
||||
"lalrpop-util",
|
||||
"petgraph 0.7.1",
|
||||
"pico-args",
|
||||
"regex",
|
||||
"regex-syntax",
|
||||
"sha3",
|
||||
"string_cache",
|
||||
"term",
|
||||
"unicode-xid",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lalrpop-util"
|
||||
version = "0.22.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lang-util"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1715a1e56998f119cd6d49ac8a69802dcbe4fca96d8291243d6dae87abeb913"
|
||||
dependencies = [
|
||||
"derive_more",
|
||||
"lalrpop-util",
|
||||
"lang-util-derive",
|
||||
"line-span",
|
||||
"smol_str 0.3.6",
|
||||
"text-size",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lang-util-derive"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f2acc66aedf0e445534e0a6b3f0f6ee20ef6ebb9647dbe10735e52ea8d6a096"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.1"
|
||||
@@ -2066,12 +1848,6 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "line-span"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29fc123b2f6600099ca18248f69e3ee02b09c4188c0d98e9a690d90dec3e408a"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
@@ -2150,6 +1926,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2183,7 +1965,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set 0.10.0",
|
||||
"bit-set",
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
@@ -2245,12 +2027,6 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
@@ -2750,16 +2526,6 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.8.3"
|
||||
@@ -2771,31 +2537,6 @@ dependencies = [
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pico-args"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
@@ -2923,12 +2664,6 @@ dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "presser"
|
||||
version = "0.3.1"
|
||||
@@ -3022,15 +2757,6 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
@@ -3369,16 +3095,6 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha3"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -3407,12 +3123,6 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -3462,16 +3172,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smol_str"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523"
|
||||
dependencies = [
|
||||
"borsh",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.10"
|
||||
@@ -3482,6 +3182,16 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spake2"
|
||||
version = "0.4.0"
|
||||
@@ -3521,37 +3231,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared",
|
||||
"precomputed-hash",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache_codegen"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -3668,15 +3347,6 @@ version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||
|
||||
[[package]]
|
||||
name = "term"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
@@ -3686,12 +3356,6 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "text-size"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233"
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -3852,7 +3516,7 @@ checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"nom",
|
||||
"petgraph 0.8.3",
|
||||
"petgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3968,10 +3632,6 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "voxel-kernels"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@@ -4552,6 +4212,7 @@ dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"js-sys",
|
||||
"log",
|
||||
"naga",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"profiling",
|
||||
@@ -4573,8 +4234,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set 0.10.0",
|
||||
"bit-vec 0.9.1",
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bitflags 2.13.2",
|
||||
"bytemuck",
|
||||
"cfg_aliases",
|
||||
@@ -4624,7 +4285,7 @@ dependencies = [
|
||||
"android_system_properties",
|
||||
"arrayvec",
|
||||
"ash",
|
||||
"bit-set 0.10.0",
|
||||
"bit-set",
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
@@ -4688,6 +4349,12 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "widestring"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -4820,6 +4487,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
@@ -5122,7 +4800,7 @@ dependencies = [
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit",
|
||||
"smol_str 0.2.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
"unicode-segmentation",
|
||||
"wasm-bindgen",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-shaderpack", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -45,7 +45,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
|
||||
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs, shader packs and data packs:** resource packs work, Java shader packs run their full-screen effects (experimental), and data packs load functions, predicates and item modifiers.
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining offline-mode servers. Horizon cannot sign in to a Microsoft account yet, so it cannot join servers that check accounts. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
@@ -57,7 +57,7 @@ Screenshots, captured in the Rust client itself with vanilla assets and no mods
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/survival.webp" alt="Survival at dusk: a zombie walks up, with the hotbar, health, hunger and experience"></td>
|
||||
<td width="50%"><img src="docs/media/settings.webp" alt="Vanilla's Video Settings screen with Horizon's own buttons at the top: Horizon Graphics, Advanced Graphics and Shader Packs"></td>
|
||||
<td width="50%"><img src="docs/media/settings.webp" alt="Vanilla's Video Settings screen with Horizon's own buttons at the top: Horizon Graphics and Advanced Graphics"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/village.webp" alt="A plains village among savanna hills, with farms, villagers and an iron golem"></td>
|
||||
|
||||
@@ -1,35 +1,5 @@
|
||||
# Third-party notices
|
||||
|
||||
## CullLeaves
|
||||
|
||||
The optional leaf and mangrove-root face-culling behavior is adapted from
|
||||
[CullLeaves](https://github.com/TeamMidnightDust/CullLeaves) by MidnightDust.
|
||||
Its MIT license is reproduced below.
|
||||
|
||||
```text
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 MidnightDust
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
## wgpu-hal
|
||||
|
||||
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
|
||||
|
||||
@@ -131,8 +131,8 @@ average) and reached the ThinkPad over the tailnet.
|
||||
It ran with Paper's defaults except `connection-throttle: -1` in `bukkit.yml`. Every bot connects from one
|
||||
address, and the default throttle (4 s) refused all but the first. Paperclip patched the pinned server jar
|
||||
from its cache. Same Java and heap.
|
||||
- Fabric + Lithium: the pinned server jar on Fabric loader 0.19.5 (`packs/fabric.lock.json`) with Lithium
|
||||
`mc26.3-0.26.2-fabric` (`packs/mods.lock.json`, checked against its SHA-512). Lithium needs no Fabric API.
|
||||
- Fabric + Lithium: the pinned server jar on Fabric loader 0.19.5 (`tools/benchmark-deps/fabric.lock.json`) with Lithium
|
||||
`mc26.3-0.26.2-fabric` (`tools/benchmark-deps/mods.lock.json`, checked against its SHA-512). Lithium needs no Fabric API.
|
||||
Same Java and heap.
|
||||
|
||||
The Paper and Fabric jars are dev-only downloads into `.local/server-bench/jars` and are never committed or
|
||||
|
||||
@@ -4,10 +4,10 @@ version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.86"
|
||||
description = "cargo horizon-mod new/build/run: make Horizon mods in Rust (docs/mods.md)"
|
||||
description = "cargo horizon-mod new/build/run/check: make Horizon mods in Rust (docs/mods.md)"
|
||||
|
||||
[dependencies]
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack"] }
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack", "runtime", "index"] }
|
||||
serde_json = "1.0.151"
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
//! `cargo horizon-mod check <file>`: what the registry checks of a submission,
|
||||
//! run anywhere: its kind's own checks (docs/api/mod-index.md), its signature
|
||||
//! when given a key, and for a mod a trial run in the sandbox: it must start,
|
||||
//! survive ticks, frames, a chat line and each of its commands, and import
|
||||
//! no more than its capabilities.
|
||||
use horizon_mods::{
|
||||
Clock, Mods, Output, Package, Player, Snapshot, Status,
|
||||
index::{self, Facts, Kind},
|
||||
manifest::component_imports,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
/// How long the trial run plays: two seconds of game.
|
||||
const TICKS: u64 = 40;
|
||||
const FRAMES_PER_TICK: u64 = 3;
|
||||
|
||||
pub struct Report {
|
||||
pub kind: Kind,
|
||||
pub facts: Facts,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
pub signed: bool,
|
||||
/// A mod's component imports.
|
||||
pub imports: Vec<String>,
|
||||
/// A mod's commands, each run once.
|
||||
pub commands: Vec<String>,
|
||||
/// What the mod showed during the trial run.
|
||||
pub output: Vec<String>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
pub fn json(&self) -> Value {
|
||||
let requires: Vec<Value> = self
|
||||
.facts
|
||||
.requires
|
||||
.iter()
|
||||
.map(|r| json!({"id": r.id, "version": r.version, "sha256": r.sha256}))
|
||||
.collect();
|
||||
json!({
|
||||
"kind": self.kind.name(),
|
||||
"id": self.facts.id,
|
||||
"version": self.facts.version,
|
||||
"api": self.facts.api,
|
||||
"side": self.facts.side,
|
||||
"capabilities": self.facts.capabilities,
|
||||
"game": self.facts.game,
|
||||
"requires": requires,
|
||||
"sha256": self.sha256,
|
||||
"size": self.size,
|
||||
"signed": self.signed,
|
||||
"imports": self.imports,
|
||||
"commands": self.commands,
|
||||
"output": self.output,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn text(&self) -> String {
|
||||
let f = &self.facts;
|
||||
let mut out = format!(
|
||||
"{} {} ({}",
|
||||
f.id.as_deref().unwrap_or("resource pack"),
|
||||
f.version.as_deref().unwrap_or(""),
|
||||
self.kind.name()
|
||||
);
|
||||
if let Some(side) = f.side {
|
||||
let side = serde_json::to_value(side).unwrap_or_default();
|
||||
out += &format!(", API {}, {}", f.api, side.as_str().unwrap_or("?"));
|
||||
} else if !f.api.is_empty() {
|
||||
out += &format!(", format {}", f.api);
|
||||
}
|
||||
if let Some(game) = &f.game {
|
||||
out += &format!(", for {game}");
|
||||
}
|
||||
let signed = if self.signed {
|
||||
"signature verified"
|
||||
} else {
|
||||
"unsigned"
|
||||
};
|
||||
out += &format!(
|
||||
")\n {} bytes, SHA-256 {}, {signed}\n",
|
||||
self.size, self.sha256
|
||||
);
|
||||
for c in &f.capabilities {
|
||||
out += &format!(" may {}\n", c.describe());
|
||||
}
|
||||
for r in &f.requires {
|
||||
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
|
||||
}
|
||||
if self.kind == Kind::Mod {
|
||||
let commands = if self.commands.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(", /{}", self.commands.join(", /"))
|
||||
};
|
||||
out += &format!(
|
||||
" trial run: started, {TICKS} ticks, {} frames, a chat line{commands}: ok\n",
|
||||
TICKS * FRAMES_PER_TICK
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind a file's extension says.
|
||||
pub fn kind_of(path: &Path) -> Option<Kind> {
|
||||
match path.extension()?.to_str()? {
|
||||
"hmod" => Some(Kind::Mod),
|
||||
"hpack" => Some(Kind::Modpack),
|
||||
"zip" => Some(Kind::ResourcePack),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks the file at `path` as `kind`; `key` and `signature` (the
|
||||
/// `.minisig` text) check its signature.
|
||||
pub fn check(
|
||||
path: &Path,
|
||||
kind: Kind,
|
||||
key: Option<&str>,
|
||||
signature: Option<&str>,
|
||||
) -> Result<Report, String> {
|
||||
let size = std::fs::metadata(path)
|
||||
.map_err(|e| format!("{}: {e}", path.display()))?
|
||||
.len();
|
||||
if size > index::MAX_FILE {
|
||||
return Err(format!("over {} MiB", index::MAX_FILE >> 20));
|
||||
}
|
||||
index::open(&kind)?;
|
||||
let facts = index::inspect(&kind, path)?;
|
||||
let signed = match (key, signature) {
|
||||
(Some(key), Some(sig)) => {
|
||||
index::verify_signature(key, sig, path)?;
|
||||
true
|
||||
}
|
||||
(None, None) => false,
|
||||
_ => return Err("--key and --signature go together".into()),
|
||||
};
|
||||
let mut report = Report {
|
||||
kind,
|
||||
facts,
|
||||
sha256: index::sha256_file(path)?,
|
||||
size,
|
||||
signed,
|
||||
imports: Vec::new(),
|
||||
commands: Vec::new(),
|
||||
output: Vec::new(),
|
||||
};
|
||||
if report.kind == Kind::Mod {
|
||||
trial_run(path, &mut report)?;
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Runs a mod in the sandbox, as the game does, from a scratch copy so its
|
||||
/// storage lands in a folder removed afterwards.
|
||||
fn trial_run(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
let package = Package::read(path)?;
|
||||
let wasm = package.wasm.as_deref().ok_or("the .hmod has no mod.wasm")?;
|
||||
report.imports = component_imports(wasm)?;
|
||||
package
|
||||
.manifest
|
||||
.check_imports(report.imports.iter().map(String::as_str))?;
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-check-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let copy = dir.join("mod.hmod");
|
||||
let result = std::fs::copy(path, ©)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|_| play(©, report));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
result
|
||||
}
|
||||
|
||||
fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
let mut mods = Mods::load(&[path.to_path_buf()], |_| true, |_| false);
|
||||
let failed = |mods: &Mods| match &mods.entries()[0].status {
|
||||
Status::Failed(reason) => Some(reason.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(reason) = failed(&mods) {
|
||||
return Err(format!("it didn't start: {reason}"));
|
||||
}
|
||||
report.commands = mods.commands().map(str::to_owned).collect();
|
||||
for t in 0..TICKS {
|
||||
mods.set_snapshot(Snapshot {
|
||||
player: Some(Player {
|
||||
x: 0.5 + t as f64 * 0.1,
|
||||
y: 64.0,
|
||||
z: 0.5,
|
||||
yaw: 90.0,
|
||||
pitch: 0.0,
|
||||
health: 20.0,
|
||||
food: 20,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
}),
|
||||
clock: Some(Clock {
|
||||
game_time: t,
|
||||
day_time: 1000 + t,
|
||||
}),
|
||||
});
|
||||
mods.tick(t);
|
||||
for _ in 0..FRAMES_PER_TICK {
|
||||
mods.frame(1.0 / 60.0);
|
||||
}
|
||||
}
|
||||
mods.chat("<Steve> hello");
|
||||
for name in report.commands.clone() {
|
||||
mods.command(&name);
|
||||
}
|
||||
for out in mods.drain() {
|
||||
report.output.push(match out {
|
||||
Output::Chat { text, .. } => format!("chat: {text}"),
|
||||
Output::Feedback { text, error } => {
|
||||
format!("{}: {text}", if error { "error" } else { "feedback" })
|
||||
}
|
||||
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
|
||||
});
|
||||
}
|
||||
match failed(&mods) {
|
||||
Some(reason) => Err(format!("it stopped: {reason}")),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture(name: &str) -> std::path::PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../horizon-mods/tests/index")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_each_kind_its_own_way() {
|
||||
let pack = fixture("cozy-1.0.0.hpack");
|
||||
let report = check(&pack, kind_of(&pack).unwrap(), None, None).unwrap();
|
||||
assert_eq!(report.facts.requires[0].id, "coords_hud");
|
||||
assert!(
|
||||
report
|
||||
.text()
|
||||
.contains("cozy 1.0.0 (modpack, format 1, for 26.3)")
|
||||
);
|
||||
// The fixture mod's code is a stand-in: its manifest reads, its run fails.
|
||||
let hmod = fixture("coords_hud-0.1.0.hmod");
|
||||
let key = std::fs::read_to_string(fixture("test.pub")).unwrap();
|
||||
let sig = std::fs::read_to_string(fixture("coords_hud-0.1.0.hmod.minisig")).unwrap();
|
||||
let key = key.lines().last().unwrap();
|
||||
let e = check(&hmod, Kind::Mod, Some(key), Some(&sig))
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(e.contains("didn't start"), "{e}");
|
||||
let e = check(
|
||||
&hmod,
|
||||
Kind::Mod,
|
||||
Some(key),
|
||||
Some("untrusted comment: x\nRUQ=\n"),
|
||||
)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(e.contains("signature"), "{e}");
|
||||
let e = check(&pack, Kind::Shader, None, None).err().unwrap();
|
||||
assert!(e.contains("shader packs open"), "{e}");
|
||||
assert!(check(&pack, Kind::Mod, None, None).is_err());
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,16 @@
|
||||
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`.
|
||||
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
|
||||
//! the mod; run `build` again and the game reloads it.
|
||||
//! - `check <file> [--json]`: what the registry checks of a submission
|
||||
//! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod.
|
||||
//! - `registry build|review|check|list`: the registry repository's CI.
|
||||
use horizon_mods::{
|
||||
Manifest, Package,
|
||||
manifest::{MANIFEST, component_imports, componentize},
|
||||
};
|
||||
mod check;
|
||||
mod registry;
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, ExitCode, Stdio},
|
||||
@@ -28,7 +34,9 @@ fn main() -> ExitCode {
|
||||
println!("Built {}", p.display());
|
||||
}),
|
||||
Some("run") => run(&args[1..]),
|
||||
_ => Err("usage: cargo horizon-mod new <path> | build [--debug] | run [--game <horizon-client>] [-- <game args>]".into()),
|
||||
Some("check") => check(&args[1..]),
|
||||
Some("registry") => registry::main(&args[1..]),
|
||||
_ => Err("usage: cargo horizon-mod new <path> | build [--debug] | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -150,6 +158,37 @@ fn build(release: bool) -> Result<PathBuf, String> {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// `check <file>`: the registry's checks of a submission, printed; fails
|
||||
/// when one does.
|
||||
fn check(args: &[String]) -> Result<(), String> {
|
||||
let file = args
|
||||
.first()
|
||||
.filter(|a| !a.starts_with("--"))
|
||||
.ok_or("cargo horizon-mod check <file>")?;
|
||||
let path = Path::new(file);
|
||||
let kind = match flag(args, "--kind") {
|
||||
Some(k) => horizon_mods::index::Kind::from(k.to_string()),
|
||||
None => check::kind_of(path)
|
||||
.ok_or("pass --kind mod|modpack|resourcepack: the extension doesn't say")?,
|
||||
};
|
||||
let signature = flag(args, "--signature")
|
||||
.map(|p| std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}")))
|
||||
.transpose()?;
|
||||
let report = check::check(path, kind, flag(args, "--key"), signature.as_deref());
|
||||
if args.iter().any(|a| a == "--json") {
|
||||
let json = match &report {
|
||||
Ok(r) => serde_json::json!({"ok": true, "report": r.json()}),
|
||||
Err(e) => serde_json::json!({"ok": false, "error": e}),
|
||||
};
|
||||
println!("{json}");
|
||||
}
|
||||
let report = report?;
|
||||
if !args.iter().any(|a| a == "--json") {
|
||||
print!("{}", report.text());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let path = build(true)?;
|
||||
let game = flag(args, "--game")
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! `cargo horizon-mod registry build|review|check|list`: the registry repository's CI
|
||||
//! (`ludovic111/horizon-mods`, docs/api/mod-index.md). Network-free: the
|
||||
//! workflow downloads the files and publishes what this writes.
|
||||
use horizon_mods::index::{
|
||||
FILE_HOST,
|
||||
registry::{self, Sources},
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
/// Where the registry's site is published (its icons).
|
||||
const SITE: &str = "https://ludovic111.github.io/horizon-mods/";
|
||||
|
||||
const USAGE: &str = "usage: cargo horizon-mod registry build <checkout> --files <dir> --out <index.json> [--release <url>] [--site <url>] [--generated <time>]
|
||||
cargo horizon-mod registry review <base checkout> <head checkout> --author <login> [--json]
|
||||
cargo horizon-mod registry check <base checkout> <head checkout> --files <dir>
|
||||
cargo horizon-mod registry list <checkout>";
|
||||
|
||||
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
|
||||
super::flag(args, name)
|
||||
}
|
||||
|
||||
fn positional(args: &[String]) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let mut skip = false;
|
||||
for a in args {
|
||||
if skip {
|
||||
skip = false;
|
||||
} else if a == "--json" {
|
||||
} else if a.starts_with("--") {
|
||||
skip = true;
|
||||
} else {
|
||||
out.push(a.as_str());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn lines(errors: Vec<String>) -> String {
|
||||
format!("{} problem(s):\n {}", errors.len(), errors.join("\n "))
|
||||
}
|
||||
|
||||
pub fn main(args: &[String]) -> Result<(), String> {
|
||||
let rest = args.get(1..).unwrap_or_default();
|
||||
let pos = positional(rest);
|
||||
match args.first().map(String::as_str) {
|
||||
Some("build") => {
|
||||
let [checkout] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let (files, out) = (flag(rest, "--files"), flag(rest, "--out"));
|
||||
let (Some(files), Some(out)) = (files, out) else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let generated = flag(rest, "--generated")
|
||||
.map(str::to_owned)
|
||||
.or_else(|| std::env::var("REGISTRY_GENERATED").ok())
|
||||
.unwrap_or_default();
|
||||
let index = registry::build(&Sources {
|
||||
registry: Path::new(checkout),
|
||||
files: Path::new(files),
|
||||
release: flag(rest, "--release").unwrap_or(FILE_HOST),
|
||||
site: flag(rest, "--site").unwrap_or(SITE),
|
||||
generated,
|
||||
})
|
||||
.map_err(lines)?;
|
||||
let text = serde_json::to_string_pretty(&index).map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, text + "\n").map_err(|e| format!("{out}: {e}"))?;
|
||||
println!(
|
||||
"Wrote {out}: {} project(s), {} taken down",
|
||||
index.projects.len(),
|
||||
index.revoked.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Some("review") => {
|
||||
let [base, head] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let author = flag(rest, "--author").ok_or(USAGE)?;
|
||||
let (base, _) = registry::load(Path::new(base)).map_err(lines)?;
|
||||
let (head, _) = registry::load(Path::new(head)).map_err(lines)?;
|
||||
let review = registry::review(&base, &head, author);
|
||||
if rest.iter().any(|a| a == "--json") {
|
||||
let json = serde_json::json!({"errors": review.errors, "notes": review.notes});
|
||||
println!("{json}");
|
||||
} else {
|
||||
for n in &review.notes {
|
||||
println!("note: {n}");
|
||||
}
|
||||
for e in &review.errors {
|
||||
println!("error: {e}");
|
||||
}
|
||||
}
|
||||
if review.errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"{} change(s) the registry refuses",
|
||||
review.errors.len()
|
||||
))
|
||||
}
|
||||
}
|
||||
// `check` (and its trial run) on each file `head` lists and `base`
|
||||
// doesn't: a pull request's new files.
|
||||
Some("check") => {
|
||||
let [base, head] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let files = Path::new(flag(rest, "--files").ok_or(USAGE)?);
|
||||
let old: Vec<String> = match registry::load(Path::new(base)) {
|
||||
Ok((entries, _)) => entries
|
||||
.iter()
|
||||
.flat_map(|e| e.versions.iter().map(|v| v.sha256.clone()))
|
||||
.collect(),
|
||||
// The first pull request has no entries to compare with.
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
let (entries, _) = registry::load(Path::new(head)).map_err(lines)?;
|
||||
let mut failed = 0;
|
||||
for e in &entries {
|
||||
for v in e.versions.iter().filter(|v| !old.contains(&v.sha256)) {
|
||||
println!("{} {} ({}):", e.id, v.version, v.file);
|
||||
let report = crate::check::check(
|
||||
&files.join(&v.sha256),
|
||||
e.kind.clone(),
|
||||
e.key.as_deref(),
|
||||
v.signature.as_deref(),
|
||||
);
|
||||
match report {
|
||||
Ok(r) => print!("{}", r.text()),
|
||||
Err(err) => {
|
||||
failed += 1;
|
||||
println!(" error: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if failed == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{failed} file(s) failed their check"))
|
||||
}
|
||||
}
|
||||
// The files CI must download for `build`: `<sha256> <id> <version> <file>`.
|
||||
Some("list") => {
|
||||
let [checkout] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let (entries, _) = registry::load(Path::new(checkout)).map_err(lines)?;
|
||||
for e in &entries {
|
||||
for v in &e.versions {
|
||||
println!("{} {} {} {}", v.sha256, e.id, v.version, v.file);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(USAGE.into()),
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,15 @@ use crate::json::{Result, error};
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Choice {
|
||||
Single(Variant),
|
||||
enum Choice<T> {
|
||||
Single(T),
|
||||
Weighted {
|
||||
variants: Vec<Variant>,
|
||||
variants: Vec<T>,
|
||||
ends: Vec<i32>,
|
||||
total: i32,
|
||||
},
|
||||
}
|
||||
impl Choice {
|
||||
impl Choice<Variant> {
|
||||
fn new(value: &VariantSet) -> Result<Self> {
|
||||
match value {
|
||||
VariantSet::Single(value) => Ok(Self::Single(value.clone())),
|
||||
@@ -41,7 +41,23 @@ impl Choice {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn select(&self, random: &mut impl RandomSource) -> &Variant {
|
||||
}
|
||||
impl<T> Choice<T> {
|
||||
fn map<U>(&self, f: &mut impl FnMut(&T) -> U) -> Choice<U> {
|
||||
match self {
|
||||
Self::Single(value) => Choice::Single(f(value)),
|
||||
Self::Weighted {
|
||||
variants,
|
||||
ends,
|
||||
total,
|
||||
} => Choice::Weighted {
|
||||
variants: variants.iter().map(&mut *f).collect(),
|
||||
ends: ends.clone(),
|
||||
total: *total,
|
||||
},
|
||||
}
|
||||
}
|
||||
fn select(&self, random: &mut impl RandomSource) -> &T {
|
||||
match self {
|
||||
Self::Single(value) => value,
|
||||
Self::Weighted {
|
||||
@@ -60,10 +76,11 @@ impl Choice {
|
||||
}
|
||||
/// Compile once per definition and share across its bound block states. Prefix
|
||||
/// sums avoid rebuilding weight totals or scanning all entries during selection.
|
||||
/// `map` swaps each variant for what it stands for (its baked model).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CompiledChoices {
|
||||
variants: Vec<Choice>,
|
||||
multipart: Vec<Choice>,
|
||||
pub struct CompiledChoices<T = Variant> {
|
||||
variants: Vec<Choice<T>>,
|
||||
multipart: Vec<Choice<T>>,
|
||||
}
|
||||
impl CompiledChoices {
|
||||
pub fn new(definition: &BlockStateDefinition) -> Result<Self> {
|
||||
@@ -82,10 +99,19 @@ impl CompiledChoices {
|
||||
.collect::<Result<_>>()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
impl<T> CompiledChoices<T> {
|
||||
/// The same choices, with `f` of each variant in its place.
|
||||
pub fn map<U>(&self, mut f: impl FnMut(&T) -> U) -> CompiledChoices<U> {
|
||||
CompiledChoices {
|
||||
variants: self.variants.iter().map(|c| c.map(&mut f)).collect(),
|
||||
multipart: self.multipart.iter().map(|c| c.map(&mut f)).collect(),
|
||||
}
|
||||
}
|
||||
/// The variant `particleMaterial` comes from: the first entry of a weighted
|
||||
/// list (`WeightedVariants`) and, for a multipart root, of its first
|
||||
/// selector whether or not that selector applies (`MultiPartModel`).
|
||||
pub fn particle_variant(&self, selected: &SelectedModels) -> Option<&Variant> {
|
||||
pub fn particle_variant(&self, selected: &SelectedModels) -> Option<&T> {
|
||||
let choice = match selected {
|
||||
SelectedModels::Variant(index) => self.variants.get(*index)?,
|
||||
SelectedModels::Multipart(_) => self.multipart.first()?,
|
||||
@@ -95,14 +121,23 @@ impl CompiledChoices {
|
||||
Choice::Weighted { variants, .. } => variants.first()?,
|
||||
})
|
||||
}
|
||||
/// Append selected variants without allocating intermediate part lists. A
|
||||
/// multipart root consumes nextLong even for an empty selected subset, then
|
||||
/// resets the same random source to that seed before each selected part.
|
||||
/// Append selected variants without allocating intermediate part lists.
|
||||
pub fn collect<'a>(
|
||||
&'a self,
|
||||
selected: &SelectedModels,
|
||||
random: &mut impl RandomSource,
|
||||
output: &mut Vec<&'a Variant>,
|
||||
output: &mut Vec<&'a T>,
|
||||
) -> Result<()> {
|
||||
self.for_each(selected, random, |v| output.push(v))
|
||||
}
|
||||
/// Each selected variant, in order. A multipart root consumes nextLong
|
||||
/// even for an empty selected subset, then resets the same random source
|
||||
/// to that seed before each selected part.
|
||||
pub fn for_each<'a>(
|
||||
&'a self,
|
||||
selected: &SelectedModels,
|
||||
random: &mut impl RandomSource,
|
||||
mut f: impl FnMut(&'a T),
|
||||
) -> Result<()> {
|
||||
match selected {
|
||||
SelectedModels::Variant(index) => {
|
||||
@@ -110,7 +145,7 @@ impl CompiledChoices {
|
||||
.variants
|
||||
.get(*index)
|
||||
.ok_or_else(|| error("invalid variant selection"))?;
|
||||
output.push(choice.select(random));
|
||||
f(choice.select(random));
|
||||
}
|
||||
SelectedModels::Multipart(indices) => {
|
||||
if indices.iter().any(|index| *index >= self.multipart.len()) {
|
||||
@@ -119,7 +154,7 @@ impl CompiledChoices {
|
||||
let seed = random.next_long();
|
||||
for index in indices {
|
||||
random.set_seed(seed);
|
||||
output.push(self.multipart[*index].select(random));
|
||||
f(self.multipart[*index].select(random));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
|
||||
//! come from the program that rendered (the game knows its adapter, the launcher its window).
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
use horizon_telemetry::system::command_output as output;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -173,12 +172,6 @@ pub fn cpu_seconds(pid: u32) -> Option<f64> {
|
||||
Some(ticks / 100.0)
|
||||
}
|
||||
|
||||
fn output(program: &str, args: &[&str]) -> Option<String> {
|
||||
let out = Command::new(program).args(args).output().ok()?;
|
||||
let text = String::from_utf8(out.stdout).ok()?.trim().to_owned();
|
||||
(out.status.success() && !text.is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn os_version() -> Option<String> {
|
||||
output("sw_vers", &["-productVersion"])
|
||||
|
||||
@@ -245,13 +245,19 @@ pub struct Report {
|
||||
impl Report {
|
||||
/// An empty report for this machine, created now.
|
||||
pub fn new(kind: Kind, app: App) -> Report {
|
||||
Self::with_hardware(kind, app, Hardware::detect())
|
||||
}
|
||||
|
||||
/// Starts a report using supplied hardware, without running system probes.
|
||||
/// Interactive apps can collect the remaining machine details after their first frame.
|
||||
pub fn with_hardware(kind: Kind, app: App, hardware: Hardware) -> Report {
|
||||
Report {
|
||||
format: FORMAT.to_owned(),
|
||||
version: VERSION,
|
||||
kind,
|
||||
created: utc_now(),
|
||||
app,
|
||||
hardware: Hardware::detect(),
|
||||
hardware,
|
||||
conditions: Conditions::default(),
|
||||
metrics: BTreeMap::new(),
|
||||
frames: Vec::new(),
|
||||
|
||||
@@ -173,6 +173,11 @@ impl Random {
|
||||
}
|
||||
}
|
||||
|
||||
/// One bot thread that panics poisons the locks it held; the others and the report go on.
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn raw(id: i32, write: impl FnOnce(&mut Writer)) -> Vec<u8> {
|
||||
let mut w = Writer::new();
|
||||
w.varint(id);
|
||||
@@ -329,7 +334,7 @@ fn read(
|
||||
) {
|
||||
use ids::play::clientbound as cb;
|
||||
use ids::play::serverbound as sb;
|
||||
let send = |body: Vec<u8>| writer.lock().unwrap().send(&body);
|
||||
let send = |body: Vec<u8>| lock(&writer).send(&body);
|
||||
let mut seen = HashSet::new();
|
||||
let mut batch = BatchSize::new();
|
||||
let mut teleports = 0u64;
|
||||
@@ -338,7 +343,7 @@ fn read(
|
||||
Ok(packet) => packet,
|
||||
Err(e) => {
|
||||
if bot.online.swap(false, Ordering::SeqCst) {
|
||||
*bot.error.lock().unwrap() = Some(e.to_string());
|
||||
*lock(&bot.error) = Some(e.to_string());
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -356,7 +361,7 @@ fn read(
|
||||
cb::LEVEL_CHUNK_WITH_LIGHT => {
|
||||
bot.chunks.fetch_add(1, Ordering::Relaxed);
|
||||
if let (Ok(x), Ok(z)) = (r.i32(), r.i32()) {
|
||||
if !bot.held.lock().unwrap().insert((x, z)) {
|
||||
if !lock(&bot.held).insert((x, z)) {
|
||||
bot.resent.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
if seen.insert((x, z)) {
|
||||
@@ -368,7 +373,7 @@ fn read(
|
||||
cb::FORGET_LEVEL_CHUNK => {
|
||||
if let Ok(play::Clientbound::ForgetLevelChunk(p)) =
|
||||
play::Clientbound::decode(id, &body)
|
||||
&& bot.held.lock().unwrap().remove(&(p.pos[0], p.pos[1]))
|
||||
&& lock(&bot.held).remove(&(p.pos[0], p.pos[1]))
|
||||
{
|
||||
bot.forgotten.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
@@ -389,7 +394,7 @@ fn read(
|
||||
}
|
||||
cb::PLAYER_POSITION => match play::Clientbound::decode(id, &body) {
|
||||
Ok(play::Clientbound::PlayerPosition(p)) => {
|
||||
let mut at = bot.teleport.lock().unwrap();
|
||||
let mut at = lock(&bot.teleport);
|
||||
let previous = at.unwrap_or([0.0; 3]);
|
||||
// Relative flags: X 1, Y 2, Z 4.
|
||||
let pos: [f64; 3] = std::array::from_fn(|i| {
|
||||
@@ -420,7 +425,7 @@ fn read(
|
||||
},
|
||||
cb::DISCONNECT | cb::START_CONFIGURATION => {
|
||||
bot.online.store(false, Ordering::SeqCst);
|
||||
*bot.error.lock().unwrap() = Some(if id == cb::DISCONNECT {
|
||||
*lock(&bot.error) = Some(if id == cb::DISCONNECT {
|
||||
format!(
|
||||
"disconnected: {}",
|
||||
String::from_utf8_lossy(&body)
|
||||
@@ -455,7 +460,7 @@ fn run_bot(
|
||||
let connection = match join(&o, &name) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
*bot.error.lock().unwrap() = Some(e);
|
||||
*lock(&bot.error) = Some(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -469,12 +474,12 @@ fn run_bot(
|
||||
use ids::play::serverbound as sb;
|
||||
// Wait for the spawn position.
|
||||
let spawn = loop {
|
||||
if let Some(at) = *bot.teleport.lock().unwrap() {
|
||||
if let Some(at) = *lock(&bot.teleport) {
|
||||
break at;
|
||||
}
|
||||
if !bot.online.load(Ordering::SeqCst) || asked.elapsed() > Duration::from_secs(60) {
|
||||
if bot.error.lock().unwrap().is_none() {
|
||||
*bot.error.lock().unwrap() = Some("no spawn position within 60 s".into());
|
||||
if lock(&bot.error).is_none() {
|
||||
*lock(&bot.error) = Some("no spawn position within 60 s".into());
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -482,7 +487,7 @@ fn run_bot(
|
||||
};
|
||||
bot.joined_after_ms
|
||||
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
|
||||
let send = |body: Vec<u8>| writer.lock().unwrap().send(&body).is_ok();
|
||||
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
|
||||
send(raw(sb::PLAYER_LOADED, |_| {}));
|
||||
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
|
||||
let mut pos = spawn;
|
||||
@@ -491,17 +496,15 @@ fn run_bot(
|
||||
let mut target = pos;
|
||||
let mut next = Instant::now();
|
||||
loop {
|
||||
if until.lock().unwrap().is_some_and(|u| Instant::now() >= u)
|
||||
|| !bot.online.load(Ordering::SeqCst)
|
||||
{
|
||||
if lock(&until).is_some_and(|u| Instant::now() >= u) || !bot.online.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
// A teleport from the server wins over where the bot thought it was.
|
||||
if let Some(at) = bot.teleport.lock().unwrap().take() {
|
||||
if let Some(at) = lock(&bot.teleport).take() {
|
||||
pos = at;
|
||||
}
|
||||
let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
|
||||
if !bot.held.lock().unwrap().contains(&here) {
|
||||
if !lock(&bot.held).contains(&here) {
|
||||
bot.waited.fetch_add(1, Ordering::Relaxed);
|
||||
} else if pos[1] < cruise {
|
||||
pos[1] = (pos[1] + 0.5).min(cruise);
|
||||
@@ -558,7 +561,7 @@ fn run_bot(
|
||||
}
|
||||
// Offline first, so the reader doesn't count the closed connection as a failure.
|
||||
bot.online.store(false, Ordering::SeqCst);
|
||||
writer.lock().unwrap().shutdown();
|
||||
lock(&writer).shutdown();
|
||||
}
|
||||
|
||||
fn behaviour_of(mode: &str, index: usize) -> Behaviour {
|
||||
@@ -612,13 +615,10 @@ fn main() {
|
||||
let total = |f: fn(&Bot) -> &AtomicU64| -> u64 {
|
||||
bots.iter().map(|b| f(b).load(Ordering::Relaxed)).sum()
|
||||
};
|
||||
let failed = bots
|
||||
.iter()
|
||||
.filter(|b| b.error.lock().unwrap().is_some())
|
||||
.count();
|
||||
let failed = bots.iter().filter(|b| lock(&b.error).is_some()).count();
|
||||
if first.is_none() && online > 0 {
|
||||
first = Some(Instant::now());
|
||||
*until.lock().unwrap() = Some(Instant::now() + Duration::from_secs(o.seconds));
|
||||
*lock(&until) = Some(Instant::now() + Duration::from_secs(o.seconds));
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
@@ -636,10 +636,10 @@ fn main() {
|
||||
})
|
||||
);
|
||||
previous_chunks = chunks;
|
||||
let over = until.lock().unwrap().is_some_and(|u| Instant::now() >= u);
|
||||
let over = lock(&until).is_some_and(|u| Instant::now() >= u);
|
||||
let all_failed = failed == o.bots;
|
||||
if over || all_failed || (first.is_none() && start.elapsed() > Duration::from_secs(120)) {
|
||||
*until.lock().unwrap() = Some(Instant::now());
|
||||
*lock(&until) = Some(Instant::now());
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -669,7 +669,7 @@ fn main() {
|
||||
"forgotten": b.forgotten.load(Ordering::Relaxed),
|
||||
"pulled_back": b.pulled_back.load(Ordering::Relaxed),
|
||||
"waited_ticks": b.waited.load(Ordering::Relaxed),
|
||||
"error": *b.error.lock().unwrap(),
|
||||
"error": *lock(&b.error),
|
||||
})).collect::<Vec<_>>(),
|
||||
});
|
||||
if o.report.as_deref() == Some("-") {
|
||||
|
||||
@@ -16,26 +16,26 @@ horizon-world = { path = "../horizon-world" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
# server.properties, eula.txt and the user lists, shared with horizon-server.
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
# Shader packs: reading them and turning their GLSL into naga modules.
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
horizon-friends = { path = "../horizon-friends" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
# Their runtime is the `mods` feature.
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl"] }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
|
||||
winit = "0.30.13"
|
||||
pollster = "0.4"
|
||||
bytemuck = { version = "1.24", features = ["derive"] }
|
||||
glam = "0.30"
|
||||
png = "0.18"
|
||||
sha2 = "0.10.9"
|
||||
# Server resource packs are checked and cached by their SHA-1, as vanilla does.
|
||||
sha1 = "0.10"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
# Shader packs: pack.toml, and WGSL checked before it reaches wgpu.
|
||||
toml = "1.1"
|
||||
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
|
||||
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
|
||||
# Java (OptiFine/Iris) shader packs, translated to WGSL.
|
||||
horizon-shaderpack = { path = "../horizon-shaderpack" }
|
||||
# Sound (ALSA on Linux); the `audio` feature.
|
||||
cpal = { version = "0.16", optional = true }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
|
||||
@@ -85,5 +85,14 @@ mods = ["horizon-mods/runtime"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
|
||||
# Its own name, not `probe`: Cargo copies every crate's examples into one target/<profile>/examples.
|
||||
[[example]]
|
||||
name = "client_probe"
|
||||
path = "examples/probe/main.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"horizon.options.controller": "Controller",
|
||||
"horizon.options.vibration": "Vibration",
|
||||
"horizon.options.adaptive_triggers": "Adaptive Triggers",
|
||||
"horizon.options.lightbar": "Lightbar",
|
||||
"horizon.options.look_sensitivity": "Look Sensitivity",
|
||||
"horizon.options.invert_x": "Invert Look X",
|
||||
"horizon.options.invert_y": "Invert Look Y",
|
||||
"horizon.options.sneak_button": "Sneak Button",
|
||||
"horizon.options.move_deadzone": "Move Deadzone",
|
||||
"horizon.options.look_deadzone": "Look Deadzone",
|
||||
"horizon.options.button_glyphs": "Button Glyphs",
|
||||
"horizon.options.menu_navigation": "Menu Navigation",
|
||||
"horizon.options.on_screen_keyboard": "On-Screen Keyboard"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"horizon.options.controller": "Геймпад",
|
||||
"horizon.options.vibration": "Вибрация",
|
||||
"horizon.options.adaptive_triggers": "Адаптивные триггеры",
|
||||
"horizon.options.lightbar": "Подсветка",
|
||||
"horizon.options.look_sensitivity": "Чувствительность обзора",
|
||||
"horizon.options.invert_x": "Инверсия обзора по X",
|
||||
"horizon.options.invert_y": "Инверсия обзора по Y",
|
||||
"horizon.options.sneak_button": "Кнопка приседания",
|
||||
"horizon.options.move_deadzone": "Мёртвая зона движения",
|
||||
"horizon.options.look_deadzone": "Мёртвая зона обзора",
|
||||
"horizon.options.button_glyphs": "Символы кнопок",
|
||||
"horizon.options.menu_navigation": "Навигация по меню",
|
||||
"horizon.options.on_screen_keyboard": "Экранная клавиатура"
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! What a column costs to load, on real terrain: generates and lights the
|
||||
//! columns around a point with the vanilla generator, as a world being explored
|
||||
//! does, then meshes every section of the inner columns on this one thread, so a
|
||||
//! change to generation or meshing shows as time per column and per section.
|
||||
//!
|
||||
//! cargo run --release -p horizon-client --example chunk_load_bench -- \
|
||||
//! .local/horizon-client/26.3 [--seed 1234] [--center CX CZ] [--radius 8]
|
||||
//! [--threads N] [--rounds 3]
|
||||
//!
|
||||
//! The default centre is the village of `tools/perf_bench.py`. The generation
|
||||
//! figure is wall time with N workers (the game's own pool); the mesh figure is
|
||||
//! this thread's CPU time in `Mesher::mesh`, the best of `--rounds`, so other
|
||||
//! work on the machine changes it less. The checksum of the meshes shows that an
|
||||
//! A/B pair of builds made the same ones.
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
chunk::HEIGHT,
|
||||
light::LightTables,
|
||||
mesher::{MeshTables, Mesher, Region, borrow},
|
||||
models,
|
||||
spans::thread_cpu,
|
||||
streaming::{Streaming, WorkerContext},
|
||||
vanillagen::VanillaTerrain,
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::biome::{BiomeZoom, obfuscate_seed};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
type Error = Box<dyn std::error::Error>;
|
||||
|
||||
fn flag<T: std::str::FromStr>(args: &[String], name: &str, default: T) -> T {
|
||||
args.iter()
|
||||
.position(|a| a == name)
|
||||
.and_then(|i| args.get(i + 1)?.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Error> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let root = PathBuf::from(args.get(1).ok_or("usage: chunk_load_bench <resources>")?);
|
||||
let seed: i64 = flag(&args, "--seed", 1234);
|
||||
let radius: i32 = flag(&args, "--radius", 8);
|
||||
let rounds: usize = flag(&args, "--rounds", 3);
|
||||
let threads: usize = flag(
|
||||
&args,
|
||||
"--threads",
|
||||
thread::available_parallelism().map_or(2, |n| n.get()),
|
||||
);
|
||||
let center = args
|
||||
.iter()
|
||||
.position(|a| a == "--center")
|
||||
.map(|i| -> Result<(i32, i32), Error> {
|
||||
Ok((
|
||||
args.get(i + 1).ok_or("--center CX CZ")?.parse()?,
|
||||
args.get(i + 2).ok_or("--center CX CZ")?.parse()?,
|
||||
))
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or((-60, 10));
|
||||
|
||||
let assets = Arc::new(Assets::load(&root)?);
|
||||
let render = Arc::new(assets.reload(&root)?);
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let visibility = models::load_visibility(&root, assets.registry)?;
|
||||
let biomes = Arc::new(Biomes::load(&root)?);
|
||||
let context = Arc::new(WorkerContext {
|
||||
tables: Arc::new(MeshTables::new(&assets, &render, &facts, visibility)?),
|
||||
light: Arc::new(LightTables::load(&root, assets.registry, &facts)?),
|
||||
biomes: biomes.clone(),
|
||||
assets: assets.clone(),
|
||||
render,
|
||||
});
|
||||
let terrain = VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed)?;
|
||||
|
||||
// Generation and light, on the game's worker pool. Meshing is left out
|
||||
// here and measured on its own below.
|
||||
let mut streaming = Streaming::new(Arc::new(terrain), context.clone(), threads);
|
||||
streaming.mesh = false;
|
||||
streaming.center = center;
|
||||
streaming.radius = radius;
|
||||
let mut world = World::new(BlockStateId(0), 0);
|
||||
let started = Instant::now();
|
||||
let mut quiet = 0;
|
||||
while quiet < 100 {
|
||||
let before = world.chunks.len();
|
||||
let _ = streaming.update(&mut world);
|
||||
thread::sleep(Duration::from_millis(2));
|
||||
quiet = if streaming.pending() == 0 && before == world.chunks.len() {
|
||||
quiet + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
}
|
||||
let generated = started.elapsed() - Duration::from_millis(2 * 100);
|
||||
let columns = world.chunks.len();
|
||||
println!(
|
||||
"generated and lit {columns} columns in {:.0} ms with {threads} workers ({:.2} ms per column)",
|
||||
generated.as_secs_f64() * 1e3,
|
||||
generated.as_secs_f64() * 1e3 / columns.max(1) as f64
|
||||
);
|
||||
|
||||
// Meshing every section of the columns whose neighbours are all loaded.
|
||||
let inner = radius - 1;
|
||||
let regions: Vec<(Region, i32)> = (center.0 - inner..=center.0 + inner)
|
||||
.flat_map(|x| (center.1 - inner..=center.1 + inner).map(move |z| (x, z)))
|
||||
.filter_map(|(x, z)| {
|
||||
let region: Region = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| {
|
||||
world
|
||||
.chunks
|
||||
.get(&(x + dx as i32 - 1, z + dz as i32 - 1))
|
||||
.cloned()
|
||||
})
|
||||
});
|
||||
region
|
||||
.iter()
|
||||
.flatten()
|
||||
.all(Option::is_some)
|
||||
.then_some(region)
|
||||
})
|
||||
.flat_map(|region| (0..HEIGHT / 16).map(move |sy| (region.clone(), sy)))
|
||||
.collect();
|
||||
let mut best = Duration::MAX;
|
||||
let (mut vertices, mut checksum) = (0, 0);
|
||||
for _ in 0..rounds.max(1) {
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
zoom: BiomeZoom::new(obfuscate_seed(seed)),
|
||||
blend_radius: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
biomes: &context.biomes,
|
||||
visibility: context.tables.visibility.clone(),
|
||||
};
|
||||
vertices = 0;
|
||||
let mut hash = DefaultHasher::new();
|
||||
let mut spent = Duration::ZERO;
|
||||
for (region, sy) in ®ions {
|
||||
let started = thread_cpu().ok_or("no thread CPU clock here")?;
|
||||
let mesh = mesher.mesh(&borrow(region), *sy);
|
||||
spent += thread_cpu().ok_or("no thread CPU clock here")? - started;
|
||||
vertices += mesh.solid.len() + mesh.translucent.len();
|
||||
bytemuck::cast_slice::<_, u8>(&mesh.solid).hash(&mut hash);
|
||||
bytemuck::cast_slice::<_, u8>(&mesh.translucent).hash(&mut hash);
|
||||
}
|
||||
checksum = hash.finish();
|
||||
best = best.min(spent);
|
||||
}
|
||||
println!(
|
||||
"meshed {} sections ({vertices} vertices, checksum {checksum:016x}) in {:.1} ms of CPU ({:.1} us per section)",
|
||||
regions.len(),
|
||||
best.as_secs_f64() * 1e3,
|
||||
best.as_secs_f64() * 1e6 / regions.len().max(1) as f64
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -41,7 +41,7 @@ pub fn main(names: &[&str], run: fn(&str) -> Option<ExitCode>) -> ExitCode {
|
||||
let name = std::env::args().nth(1).unwrap_or_default();
|
||||
run(&name).unwrap_or_else(|| {
|
||||
eprintln!(
|
||||
"usage: probe <name> [arguments...]\nprobes: {}",
|
||||
"usage: client_probe <name> [arguments...]\nprobes: {}",
|
||||
names.join(" ")
|
||||
);
|
||||
ExitCode::from(2)
|
||||
|
||||
@@ -85,6 +85,7 @@ mod sprint_probe;
|
||||
mod subtitle_fixture;
|
||||
mod tab_list_fixture;
|
||||
mod team_rules_fixture;
|
||||
mod text_display_fixture;
|
||||
mod toggle_keys_probe;
|
||||
mod tooltip_display_fixture;
|
||||
mod trading_fixture;
|
||||
@@ -170,6 +171,7 @@ probes! {
|
||||
team_rules_fixture,
|
||||
toggle_keys_probe,
|
||||
tooltip_display_fixture,
|
||||
text_display_fixture,
|
||||
trading_fixture,
|
||||
translucency_fixture,
|
||||
weather_fixture,
|
||||
|
||||
@@ -293,6 +293,47 @@ fn spawn_seed(index: usize) -> i64 {
|
||||
1000 + 7919 * index as i64
|
||||
}
|
||||
|
||||
/// `Villager.spawnGolemIfNeeded` for villager `a`, the game's way: the golem
|
||||
/// draws from the level's random and joins the scenario's mobs next tick,
|
||||
/// seeded as the oracle seeds what a tick spawned.
|
||||
fn summon_golem(
|
||||
mobs: &mut [Mob],
|
||||
a: usize,
|
||||
needed: usize,
|
||||
w: &MobWorld,
|
||||
types: &MobTypes,
|
||||
born: &mut Vec<Mob>,
|
||||
level_random: &mut LegacyRandom,
|
||||
) {
|
||||
use horizon_client::mobs::{villager, villager_brain};
|
||||
let Some(near) = villager::golem_summoners(mobs, a, w.game_time, needed) else {
|
||||
return;
|
||||
};
|
||||
let kind = Arc::new(
|
||||
types
|
||||
.types
|
||||
.get("minecraft:iron_golem")
|
||||
.expect("iron golem")
|
||||
.clone(),
|
||||
);
|
||||
let index = mobs.len() + born.len();
|
||||
let p = mobs[a].position;
|
||||
let start = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
|
||||
let golem = villager::summon_golem(w, start, level_random, |random, at, yaw| {
|
||||
let mut m = Mob::new(100_000 + index as u32, kind.clone(), at, yaw);
|
||||
villager::finalize_summoned_golem(&mut m, random);
|
||||
m
|
||||
});
|
||||
let Some(mut golem) = golem else {
|
||||
return;
|
||||
};
|
||||
golem.random = LegacyRandom::new(spawn_seed(index));
|
||||
born.push(golem);
|
||||
for j in near {
|
||||
villager_brain::golem_detected(&mut mobs[j], w.game_time);
|
||||
}
|
||||
}
|
||||
|
||||
/// Villager events that act at once in vanilla (`TradeWithVillager` and
|
||||
/// `VillagerMakeLove` write the other villager directly).
|
||||
fn villager_events(
|
||||
@@ -304,6 +345,7 @@ fn villager_events(
|
||||
items: &Items,
|
||||
born: &mut Vec<Mob>,
|
||||
day_time: i64,
|
||||
level_random: &mut LegacyRandom,
|
||||
) {
|
||||
use horizon_client::mobs::villager;
|
||||
let index = |mobs: &[Mob], id: u32| {
|
||||
@@ -314,6 +356,7 @@ fn villager_events(
|
||||
while i < events.len() {
|
||||
match events[i] {
|
||||
MobEvent::VillagerMeet { mob, other } => {
|
||||
events.remove(i);
|
||||
if let (Some(a), Some(b)) = (index(mobs, mob), index(mobs, other)) {
|
||||
let meeting = villager::meet(mobs, a, b, w.game_time, |item| {
|
||||
items.get(item).map_or(64, |i| i.max_stack)
|
||||
@@ -322,8 +365,16 @@ fn villager_events(
|
||||
meeting.thrown.is_empty(),
|
||||
"villagers toss items: not modelled here"
|
||||
);
|
||||
if meeting.gossiped {
|
||||
summon_golem(mobs, a, 5, w, types, born, level_random);
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::GolemCheck { mob, needed } => {
|
||||
events.remove(i);
|
||||
if let Some(a) = index(mobs, mob) {
|
||||
summon_golem(mobs, a, needed, w, types, born, level_random);
|
||||
}
|
||||
}
|
||||
MobEvent::VillagerBirth {
|
||||
parent,
|
||||
@@ -1167,6 +1218,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
&items,
|
||||
&mut born,
|
||||
s.time,
|
||||
&mut scratch,
|
||||
);
|
||||
horizon_client::mobs::warden::hear_mob_events(&mut mobs, &events, from, &w);
|
||||
// Melee hits on other mobs land during the attacker's tick
|
||||
@@ -1323,6 +1375,31 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
world.set(at[0], at[1], at[2], registry.resolve(&st)?)?;
|
||||
}
|
||||
}
|
||||
// Doors villagers opened or closed (DoorBlock.setOpen), both halves.
|
||||
let doors: Vec<([i32; 3], bool)> = events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
MobEvent::SetDoorOpen { pos, open } => Some((*pos, *open)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for (at, open) in doors {
|
||||
let Some(half) = registry
|
||||
.state(world.block(at[0], at[1], at[2]))
|
||||
.and_then(|s| s.properties.get("half").cloned())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let other = if half == "lower" { 1 } else { -1 };
|
||||
for y in [at[1], at[1] + other] {
|
||||
if let Some(mut st) = registry.state(world.block(at[0], y, at[2])).cloned()
|
||||
&& st.properties.contains_key("open")
|
||||
{
|
||||
st.properties.insert("open".to_owned(), open.to_string());
|
||||
world.set(at[0], y, at[2], registry.resolve(&st)?)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
mobs.append(&mut born);
|
||||
mobs.append(&mut added);
|
||||
for e in &heart_events {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! usage: pack_screen_fixture RESOURCES CASES OUTPUT
|
||||
use horizon_client::{
|
||||
hud::HudAtlas,
|
||||
hud::{HudAtlas, PACK_ICON_SIZE},
|
||||
lang,
|
||||
pack_screen::{Compat, PackInfo, PackScreen, Source},
|
||||
packs::{self, Position},
|
||||
@@ -13,7 +13,7 @@ use horizon_client::{
|
||||
text::Text,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{fmt::Write as _, fs, path::PathBuf};
|
||||
use std::{fmt::Write as _, fs, path::PathBuf, sync::Arc};
|
||||
|
||||
/// Styled runs: [text, rgb or null, bold, italic, underlined, strikethrough, obfuscated].
|
||||
pub fn runs(t: &Text) -> Value {
|
||||
@@ -105,7 +105,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
Some("unknown") => Compat::Unknown,
|
||||
_ => Compat::Compatible,
|
||||
},
|
||||
icon: p["icon"].as_bool().unwrap_or(false),
|
||||
icon: p["icon"].as_bool().unwrap_or(false).then(|| {
|
||||
Arc::from(vec![0; (PACK_ICON_SIZE * PACK_ICON_SIZE * 4) as usize])
|
||||
}),
|
||||
required: p["required"].as_bool().unwrap_or(false),
|
||||
position: if p["position"] == "top" {
|
||||
Position::Top
|
||||
|
||||
@@ -83,7 +83,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let mut extra = String::new();
|
||||
match kind {
|
||||
"give" => count = book.add_recipes(&index, &list()).0,
|
||||
"take" => count = book.remove_recipes(&index, &list()),
|
||||
"take" => count = book.remove_recipes(&index, &list()).len(),
|
||||
"seen" => book.remove_highlight(op["id"].as_str().unwrap_or_default()),
|
||||
"set" => {
|
||||
let i = match op["type"].as_str().unwrap_or_default() {
|
||||
|
||||
@@ -136,7 +136,6 @@ fn scene(
|
||||
blend_radius,
|
||||
smooth_lighting,
|
||||
cutout_leaves,
|
||||
cull_leaves: false,
|
||||
};
|
||||
let section_y = int(&row["section_y"])?;
|
||||
let started = Instant::now();
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
use horizon_client::text_display::TextDisplayData;
|
||||
use horizon_protocol::{ids, packets::play::Clientbound};
|
||||
use std::{error::Error, io::Write};
|
||||
|
||||
pub fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let mut out = std::fs::File::create(&args[2])?;
|
||||
for line in std::fs::read_to_string(&args[1])?.lines() {
|
||||
let input: serde_json::Value = serde_json::from_str(line)?;
|
||||
let hex = input["wire"].as_str().unwrap();
|
||||
let bytes: Vec<u8> = (0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let Clientbound::SetEntityData(packet) =
|
||||
Clientbound::decode(ids::play::clientbound::SET_ENTITY_DATA, &bytes)?
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let mut display = TextDisplayData::default();
|
||||
display.apply(&packet.values);
|
||||
if let Some(angles) = input["angles"].as_array() {
|
||||
let a: Vec<_> = angles.iter().map(|v| v.as_f64().unwrap() as f32).collect();
|
||||
writeln!(
|
||||
out,
|
||||
"{}",
|
||||
serde_json::to_string(&display.matrix([a[0], a[1]], [a[2], a[3]]).to_cols_array())?
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
let mut state = serde_json::to_value(&display)?;
|
||||
state["text"] = display.text.plain().into();
|
||||
state["alignment"] = format!("{:?}", display.align()).to_uppercase().into();
|
||||
writeln!(out, "{}", state)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -161,6 +161,9 @@ pub struct Audio {
|
||||
/// How many times each positional sound played (the control socket's `status`),
|
||||
/// with or without an audio device.
|
||||
pub heard: std::collections::BTreeMap<String, u32>,
|
||||
/// How many times each selection was stopped ("record *", "* minecraft:…", `*` for
|
||||
/// any), with or without an audio device, as `heard`.
|
||||
pub stopped: std::collections::BTreeMap<String, u32>,
|
||||
}
|
||||
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
use symphonia::core::{
|
||||
@@ -595,6 +598,7 @@ impl Audio {
|
||||
source_override: None,
|
||||
capture: None,
|
||||
heard: Default::default(),
|
||||
stopped: Default::default(),
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
@@ -825,6 +829,14 @@ impl Audio {
|
||||
/// and/or a source.
|
||||
pub fn stop_sounds(&mut self, event: Option<&str>, source: Option<usize>) {
|
||||
let event = event.map(crate::sound_events::normalize);
|
||||
let selection = format!(
|
||||
"{} {}",
|
||||
source
|
||||
.and_then(|s| crate::settings::SOUND_SOURCES.get(s))
|
||||
.map_or("*", |s| s),
|
||||
event.as_deref().unwrap_or("*")
|
||||
);
|
||||
*self.stopped.entry(selection).or_default() += 1;
|
||||
self.engine
|
||||
.stop_matching(event.as_deref(), source, &mut self.backend);
|
||||
}
|
||||
|
||||
@@ -147,7 +147,6 @@ const PAGES: Ty = Ty::Enum(&[
|
||||
"graphics-advanced",
|
||||
"extras",
|
||||
"horizon",
|
||||
"shader-packs",
|
||||
"agent",
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
//! `BossHealthOverlay` and `LerpingBossEvent`: the boss bars a client is
|
||||
//! showing, in the order their add packets arrived.
|
||||
use crate::{
|
||||
hud_overlays::BossBar,
|
||||
scoreboard::{comp_text, tag_to_json},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_protocol::packets::play::{BossAction, BossEvent};
|
||||
|
||||
/// `LerpingBossEvent.LERP_MILLISECONDS`.
|
||||
const LERP_MS: u128 = 100;
|
||||
|
||||
/// `Util.getMillis`.
|
||||
pub fn now_millis() -> u128 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// One `LerpingBossEvent`.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Event {
|
||||
id: u128,
|
||||
name: Text,
|
||||
/// The progress the lerp started from (`progress`).
|
||||
progress: f32,
|
||||
/// `targetPercent`.
|
||||
target: f32,
|
||||
/// `setTime`.
|
||||
set_at: u128,
|
||||
color: u8,
|
||||
overlay: u8,
|
||||
darken: bool,
|
||||
music: bool,
|
||||
fog: bool,
|
||||
}
|
||||
impl Event {
|
||||
/// `LerpingBossEvent.getProgress`.
|
||||
fn displayed(&self, now: u128) -> f32 {
|
||||
let t = ((now.saturating_sub(self.set_at)) as f32 / LERP_MS as f32).clamp(0.0, 1.0);
|
||||
self.progress + (self.target - self.progress) * t
|
||||
}
|
||||
/// `LerpingBossEvent.setProgress`: the bar starts the next lerp from
|
||||
/// whatever it is showing now.
|
||||
fn set_progress(&mut self, progress: f32, now: u128) {
|
||||
self.progress = self.displayed(now);
|
||||
self.target = progress;
|
||||
self.set_at = now;
|
||||
}
|
||||
}
|
||||
|
||||
/// `BossHealthOverlay`: the events, plus `GameRenderer`'s world darkening.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct BossOverlay {
|
||||
events: Vec<Event>,
|
||||
/// `bossOverlayWorldDarkening` and the previous tick's value.
|
||||
darken: f32,
|
||||
darken_o: f32,
|
||||
}
|
||||
impl BossOverlay {
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
/// `ClientboundBossEventPacket.dispatch` into `BossHealthOverlay`'s
|
||||
/// handler. An id already shown keeps its place when added again.
|
||||
pub fn apply(&mut self, packet: &BossEvent, now: u128) {
|
||||
// An ordinal past the enum shows as its last value.
|
||||
let color = |c: i32| c.clamp(0, 6) as u8;
|
||||
let overlay = |o: i32| o.clamp(0, 4) as u8;
|
||||
let id = packet.id;
|
||||
if let BossAction::Add {
|
||||
name,
|
||||
progress,
|
||||
color: c,
|
||||
overlay: o,
|
||||
darken,
|
||||
music,
|
||||
fog,
|
||||
} = &packet.action
|
||||
{
|
||||
let event = Event {
|
||||
id,
|
||||
name: comp_text(&tag_to_json(name)),
|
||||
progress: *progress,
|
||||
target: *progress,
|
||||
set_at: now,
|
||||
color: color(*c),
|
||||
overlay: overlay(*o),
|
||||
darken: *darken,
|
||||
music: *music,
|
||||
fog: *fog,
|
||||
};
|
||||
match self.events.iter_mut().find(|e| e.id == id) {
|
||||
Some(slot) => *slot = event,
|
||||
None => self.events.push(event),
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let BossAction::Remove = packet.action {
|
||||
self.events.retain(|e| e.id != id);
|
||||
return;
|
||||
}
|
||||
let Some(e) = self.events.iter_mut().find(|e| e.id == id) else {
|
||||
return;
|
||||
};
|
||||
match &packet.action {
|
||||
BossAction::UpdateProgress { progress } => e.set_progress(*progress, now),
|
||||
BossAction::UpdateName { name } => e.name = comp_text(&tag_to_json(name)),
|
||||
BossAction::UpdateStyle {
|
||||
color: c,
|
||||
overlay: o,
|
||||
} => {
|
||||
e.color = color(*c);
|
||||
e.overlay = overlay(*o);
|
||||
}
|
||||
BossAction::UpdateProperties { darken, music, fog } => {
|
||||
(e.darken, e.music, e.fog) = (*darken, *music, *fog);
|
||||
}
|
||||
BossAction::Add { .. } | BossAction::Remove => {}
|
||||
}
|
||||
}
|
||||
/// `GameRenderer.tick` while the level runs.
|
||||
pub fn tick_darken(&mut self, running: bool) {
|
||||
if !running {
|
||||
return;
|
||||
}
|
||||
self.darken_o = self.darken;
|
||||
if self.should_darken() {
|
||||
self.darken = (self.darken + 0.05).min(1.0);
|
||||
} else if self.darken > 0.0 {
|
||||
self.darken = (self.darken - 0.0125).max(0.0);
|
||||
}
|
||||
}
|
||||
/// `GameRenderer.bossOverlayWorldDarkening`.
|
||||
pub fn darken_amount(&self, partial: f32) -> f32 {
|
||||
let t = partial.clamp(0.0, 1.0);
|
||||
self.darken_o + (self.darken - self.darken_o) * t
|
||||
}
|
||||
pub fn should_darken(&self) -> bool {
|
||||
self.events.iter().any(|e| e.darken)
|
||||
}
|
||||
pub fn should_play_music(&self) -> bool {
|
||||
self.events.iter().any(|e| e.music)
|
||||
}
|
||||
pub fn should_create_world_fog(&self) -> bool {
|
||||
self.events.iter().any(|e| e.fog)
|
||||
}
|
||||
/// The bars `extractRenderState` walks, with the lerped progress.
|
||||
pub fn bars(&self, now: u128) -> Vec<BossBar> {
|
||||
self.events
|
||||
.iter()
|
||||
.map(|e| BossBar {
|
||||
name: e.name.clone(),
|
||||
progress: e.displayed(now),
|
||||
color: e.color as usize,
|
||||
overlay: e.overlay as usize,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
fn add(id: u128, name: &str, progress: f32, darken: bool, fog: bool) -> BossEvent {
|
||||
BossEvent {
|
||||
id,
|
||||
action: BossAction::Add {
|
||||
name: Tag::String(name.into()),
|
||||
progress,
|
||||
color: 5,
|
||||
overlay: 0,
|
||||
darken,
|
||||
music: false,
|
||||
fog,
|
||||
},
|
||||
}
|
||||
}
|
||||
fn progress(id: u128, progress: f32) -> BossEvent {
|
||||
BossEvent {
|
||||
id,
|
||||
action: BossAction::UpdateProgress { progress },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_lerps_over_a_tenth_of_a_second() {
|
||||
let mut overlay = BossOverlay::default();
|
||||
overlay.apply(&add(1, "W", 0.0, true, false), 0);
|
||||
overlay.apply(&progress(1, 1.0), 0);
|
||||
assert!((overlay.bars(50)[0].progress - 0.5).abs() < 1.0e-5);
|
||||
assert!((overlay.bars(100)[0].progress - 1.0).abs() < 1.0e-5);
|
||||
// A new target starts from the value on screen.
|
||||
overlay.apply(&progress(1, 0.0), 50);
|
||||
assert!((overlay.bars(150)[0].progress - 0.0).abs() < 1.0e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn darken_ramps_and_fades() {
|
||||
let mut overlay = BossOverlay::default();
|
||||
overlay.apply(&add(1, "W", 1.0, true, false), 0);
|
||||
overlay.tick_darken(true);
|
||||
assert!((overlay.darken_amount(1.0) - 0.05).abs() < 1.0e-5);
|
||||
overlay.apply(
|
||||
&BossEvent {
|
||||
id: 1,
|
||||
action: BossAction::Remove,
|
||||
},
|
||||
0,
|
||||
);
|
||||
overlay.tick_darken(true);
|
||||
assert!((overlay.darken - 0.0375).abs() < 1.0e-5);
|
||||
assert!(!overlay.should_play_music());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bars_keep_arrival_order() {
|
||||
let mut overlay = BossOverlay::default();
|
||||
overlay.apply(&add(2, "b", 1.0, false, true), 0);
|
||||
overlay.apply(&add(1, "a", 1.0, false, false), 0);
|
||||
overlay.apply(&add(2, "c", 0.5, false, true), 0);
|
||||
// An update for a bar this client never got is ignored.
|
||||
overlay.apply(&progress(9, 0.5), 0);
|
||||
let names: Vec<_> = overlay
|
||||
.bars(0)
|
||||
.into_iter()
|
||||
.map(|b| b.name.plain())
|
||||
.collect();
|
||||
assert_eq!(names, ["c", "a"]);
|
||||
assert!(overlay.should_create_world_fog());
|
||||
overlay.apply(
|
||||
&BossEvent {
|
||||
id: 2,
|
||||
action: BossAction::Remove,
|
||||
},
|
||||
0,
|
||||
);
|
||||
assert!(!overlay.should_create_world_fog());
|
||||
}
|
||||
}
|
||||
@@ -14,40 +14,64 @@ pub const STYLES: [&str; 5] = [
|
||||
"notched_12",
|
||||
"notched_20",
|
||||
];
|
||||
/// `BossBarColor.getFormatting`.
|
||||
/// `BossBarColor.getFormatting`. Purple is dark blue in 26.3.
|
||||
pub fn color_rgb(color: &str) -> u32 {
|
||||
let name = match color {
|
||||
"pink" => "red",
|
||||
"red" => "dark_red",
|
||||
"purple" => "dark_purple",
|
||||
"purple" => "dark_blue",
|
||||
other => other,
|
||||
};
|
||||
text::named_color(name).unwrap_or(text::WHITE)
|
||||
}
|
||||
|
||||
/// A version-4 shaped id from a resource id. Saved boss events do not keep
|
||||
/// their UUID (`CustomBossEvents` mints a new one on load).
|
||||
pub fn stable_uuid(id: &str) -> u128 {
|
||||
let mut h = 0xcbf29ce484222325u64;
|
||||
for b in id.as_bytes() {
|
||||
h ^= u64::from(*b);
|
||||
h = h.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
let most = (h as i64) & -61441 | 16384;
|
||||
let least = (h.rotate_left(17) as i64) & 4_611_686_018_427_387_903 | i64::MIN;
|
||||
((most as u64 as u128) << 64) | (least as u64 as u128)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BossBar {
|
||||
pub id: String,
|
||||
/// The `ServerBossEvent` UUID. Not saved.
|
||||
pub uuid: u128,
|
||||
pub name: Comp,
|
||||
pub color: String,
|
||||
pub style: String,
|
||||
pub value: i32,
|
||||
pub max: i32,
|
||||
/// `ServerBossEvent.visible`. A new bar is visible; nobody sees it until
|
||||
/// a player is added.
|
||||
pub visible: bool,
|
||||
/// Whether the (single) local player is in the bar's player set.
|
||||
pub player: bool,
|
||||
pub darken: bool,
|
||||
pub music: bool,
|
||||
pub fog: bool,
|
||||
/// `CustomBossEvent.players`, the UUIDs told about the bar.
|
||||
pub players: Vec<[i32; 4]>,
|
||||
}
|
||||
impl BossBar {
|
||||
pub fn new(id: &str, name: Comp) -> Self {
|
||||
Self {
|
||||
id: id.to_owned(),
|
||||
uuid: stable_uuid(id),
|
||||
name,
|
||||
color: "white".into(),
|
||||
style: "progress".into(),
|
||||
value: 0,
|
||||
max: 100,
|
||||
visible: true,
|
||||
player: false,
|
||||
darken: false,
|
||||
music: false,
|
||||
fog: false,
|
||||
players: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// `CustomBossEvent.getDisplayName`.
|
||||
@@ -91,31 +115,34 @@ impl BossBars {
|
||||
pub fn ids(&self) -> Vec<String> {
|
||||
self.bars.iter().map(|b| b.id.clone()).collect()
|
||||
}
|
||||
/// The saved compound; the player's UUID stands for "the local player".
|
||||
pub fn pack(&self, player: Option<[i32; 4]>) -> Tag {
|
||||
/// The saved compound (`CustomBossEvent.Packed`).
|
||||
pub fn pack(&self) -> Tag {
|
||||
let mut c = Compound::new();
|
||||
for b in &self.bars {
|
||||
let mut e = vec![
|
||||
("Name", json_to_tag(&b.name)),
|
||||
("Visible", Tag::Byte(i8::from(b.visible))),
|
||||
("Value", Tag::Int(b.value)),
|
||||
("Max", Tag::Int(b.max)),
|
||||
("Color", Tag::String(b.color.as_str().into())),
|
||||
("Overlay", Tag::String(b.style.as_str().into())),
|
||||
("DarkenScreen", Tag::Byte(0)),
|
||||
("PlayBossMusic", Tag::Byte(0)),
|
||||
("CreateWorldFog", Tag::Byte(0)),
|
||||
];
|
||||
let players = match (b.player, player) {
|
||||
(true, Some(u)) => vec![Tag::IntArray(u.to_vec())],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
e.push(("Players", Tag::List(players)));
|
||||
c.insert(b.id.as_str().into(), Tag::compound(e));
|
||||
let players = b
|
||||
.players
|
||||
.iter()
|
||||
.map(|u| Tag::IntArray(u.to_vec()))
|
||||
.collect();
|
||||
c.insert(
|
||||
b.id.as_str().into(),
|
||||
Tag::compound([
|
||||
("Name", json_to_tag(&b.name)),
|
||||
("Visible", Tag::Byte(i8::from(b.visible))),
|
||||
("Value", Tag::Int(b.value)),
|
||||
("Max", Tag::Int(b.max)),
|
||||
("Color", Tag::String(b.color.as_str().into())),
|
||||
("Overlay", Tag::String(b.style.as_str().into())),
|
||||
("DarkenScreen", Tag::Byte(i8::from(b.darken))),
|
||||
("PlayBossMusic", Tag::Byte(i8::from(b.music))),
|
||||
("CreateWorldFog", Tag::Byte(i8::from(b.fog))),
|
||||
("Players", Tag::List(players)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
Tag::Compound(c)
|
||||
}
|
||||
pub fn unpack(data: &Tag, player: Option<[i32; 4]>) -> Self {
|
||||
pub fn unpack(data: &Tag) -> Self {
|
||||
let mut bars = Vec::new();
|
||||
if let Tag::Compound(c) = data {
|
||||
for (id, e) in c {
|
||||
@@ -130,21 +157,30 @@ impl BossBars {
|
||||
Some(Tag::Byte(v)) => i32::from(*v),
|
||||
_ => d,
|
||||
};
|
||||
let in_players = match (get("Players"), player) {
|
||||
(Some(Tag::List(l)), Some(u)) => l
|
||||
let players = match get("Players") {
|
||||
Some(Tag::List(l)) => l
|
||||
.iter()
|
||||
.any(|t| matches!(t, Tag::IntArray(a) if a.as_slice() == u)),
|
||||
_ => false,
|
||||
.filter_map(|t| match t {
|
||||
Tag::IntArray(a) if a.len() == 4 => Some([a[0], a[1], a[2], a[3]]),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let id = id.to_string_lossy();
|
||||
bars.push(BossBar {
|
||||
id: id.to_string_lossy(),
|
||||
uuid: stable_uuid(&id),
|
||||
id,
|
||||
name: get("Name").map_or_else(|| literal(""), tag_to_json),
|
||||
color: text("Color", "white"),
|
||||
style: text("Overlay", "progress"),
|
||||
value: num("Value", 0),
|
||||
max: num("Max", 100),
|
||||
visible: num("Visible", 0) != 0,
|
||||
player: in_players,
|
||||
darken: num("DarkenScreen", 0) != 0,
|
||||
music: num("PlayBossMusic", 0) != 0,
|
||||
fog: num("CreateWorldFog", 0) != 0,
|
||||
players,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -167,17 +203,15 @@ mod tests {
|
||||
bar.value = 25;
|
||||
bar.max = 50;
|
||||
bar.visible = true;
|
||||
bar.player = true;
|
||||
let uuid = Some([1, 2, 3, 4]);
|
||||
let back = BossBars::unpack(&b.pack(uuid), uuid);
|
||||
bar.darken = true;
|
||||
bar.players = vec![[1, 2, 3, 4]];
|
||||
let back = BossBars::unpack(&b.pack());
|
||||
assert_eq!(back, b);
|
||||
// Another player's UUID does not include the local player.
|
||||
assert!(!BossBars::unpack(&b.pack(uuid), Some([9, 9, 9, 9])).bars[0].player);
|
||||
let bar = b.get("test:x").unwrap();
|
||||
assert_eq!(bar.progress(), 0.5);
|
||||
assert_eq!(bar.display_name().plain(), "chat.square_brackets");
|
||||
assert_eq!(color_rgb("pink"), text::RED);
|
||||
assert_eq!(color_rgb("purple"), 0xAA00AA);
|
||||
assert_eq!(color_rgb("purple"), text::named_color("dark_blue").unwrap());
|
||||
b.remove("test:x");
|
||||
assert!(b.bars.is_empty());
|
||||
}
|
||||
|
||||
@@ -133,6 +133,30 @@ impl<W: EntityMovementWorld<Error = io::Error>> EntityMovementWorld for EntityCo
|
||||
border.collision_shape().map(Some).map_err(io::Error::other)
|
||||
}
|
||||
}
|
||||
impl EntityCollisions<'_, SceneCollisions<'_, '_>> {
|
||||
/// `SceneCollisions::resolve` that also stops at the entity boxes.
|
||||
pub fn resolve(
|
||||
&mut self,
|
||||
bounds: Aabb,
|
||||
movement: Vec3,
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
) -> Result<Vec3> {
|
||||
let context = self.blocks.context(bounds);
|
||||
Ok(resolve_world_movement(
|
||||
self,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context,
|
||||
bounds,
|
||||
movement,
|
||||
max_step,
|
||||
on_ground,
|
||||
candidate_budget: 1_000_000,
|
||||
},
|
||||
)?)
|
||||
}
|
||||
}
|
||||
impl SceneCollisions<'_, '_> {
|
||||
/// Position contexts permit geometric movement probes without inventing a
|
||||
/// player, inventory, effects or fluid-standing behavior; a lava walker
|
||||
|
||||
@@ -55,7 +55,7 @@ impl CommandStorage {
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str().into(), v.clone()))
|
||||
.collect();
|
||||
let _ = save::write_dat(
|
||||
if let Err(e) = save::write_dat(
|
||||
&file(root, namespace),
|
||||
Tag::compound([
|
||||
(
|
||||
@@ -64,7 +64,9 @@ impl CommandStorage {
|
||||
),
|
||||
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
|
||||
]),
|
||||
);
|
||||
) {
|
||||
log::warn!("command storage {namespace} was not written: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `get`: an empty compound when unset.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//! `ConfirmScreen`: a title, a message wrapped to `width - 50` (15 lines at
|
||||
//! most) and two 150-wide buttons 4 apart and 16 below it, the whole column
|
||||
//! centred; Escape answers no.
|
||||
use crate::{
|
||||
font::Font,
|
||||
hud::{HudAtlas, HudBatch},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
|
||||
pub struct Confirm {
|
||||
/// Each line and where it is drawn.
|
||||
pub lines: Vec<(Text, f32, f32)>,
|
||||
/// The yes and no buttons' corners.
|
||||
pub buttons: [(f32, f32); 2],
|
||||
}
|
||||
impl Confirm {
|
||||
pub fn new(font: &Font, (w, h): (f32, f32), title: &Text, message: &Text) -> Self {
|
||||
let message: Vec<Text> = font
|
||||
.split_lines(message, w - 50.0)
|
||||
.into_iter()
|
||||
.map(|(line, _)| line)
|
||||
.take(15)
|
||||
.collect();
|
||||
let tw = font.width_text(title);
|
||||
let mw = message
|
||||
.iter()
|
||||
.map(|l| font.width_text(l))
|
||||
.fold(0.0, f32::max);
|
||||
let mh = 9.0 * message.len() as f32;
|
||||
let col = tw.max(mw).max(304.0);
|
||||
let total = 9.0 + 8.0 + mh + 8.0 + 36.0;
|
||||
let (lx, ly) = (((w - col) / 2.0).trunc(), ((h - total) / 2.0).trunc());
|
||||
let cell = |cw: f32| lx + ((col - cw) / 2.0).trunc();
|
||||
let mid = cell(mw) + (mw / 2.0).trunc();
|
||||
let mut lines = vec![(title.clone(), cell(tw), ly)];
|
||||
for (i, line) in message.into_iter().enumerate() {
|
||||
let x = mid - (font.width_text(&line) / 2.0).trunc();
|
||||
lines.push((line, x, ly + 17.0 + 9.0 * i as f32));
|
||||
}
|
||||
let (bx, by) = (cell(304.0), ly + 9.0 + 8.0 + mh + 8.0 + 16.0);
|
||||
Self {
|
||||
lines,
|
||||
buttons: [(bx, by), (bx + 154.0, by)],
|
||||
}
|
||||
}
|
||||
/// The answer a click at `cursor` gives: true for yes, false for no.
|
||||
pub fn answer_at(&self, cursor: (f32, f32)) -> Option<bool> {
|
||||
let [yes, no] = self.buttons;
|
||||
let on = |(x, y): (f32, f32)| ui::hovered(cursor, x, y, 150.0, 20.0);
|
||||
if on(yes) {
|
||||
Some(true)
|
||||
} else if on(no) {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// The lines and the buttons (yes first), over whatever is behind.
|
||||
pub fn draw(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
labels: [&Text; 2],
|
||||
) {
|
||||
for (line, x, y) in &self.lines {
|
||||
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
|
||||
}
|
||||
for ((bx, by), label) in self.buttons.into_iter().zip(labels) {
|
||||
let hover = ui::hovered(cursor, bx, by, 150.0, 20.0);
|
||||
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, true, hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -382,7 +382,7 @@ impl Providers {
|
||||
);
|
||||
Self { ints, floats }
|
||||
}
|
||||
fn eval(&self, v: &Value, fast: bool, table: &HashMap<String, Value>, depth: u32) -> f64 {
|
||||
fn eval(v: &Value, fast: bool, table: &HashMap<String, Value>, depth: u32) -> f64 {
|
||||
if depth > 16 {
|
||||
return 0.0;
|
||||
}
|
||||
@@ -390,10 +390,10 @@ impl Providers {
|
||||
Value::Number(n) => n.as_f64().unwrap_or(0.0),
|
||||
Value::String(name) => table
|
||||
.get(name)
|
||||
.map_or(0.0, |v| self.eval(v, fast, table, depth + 1)),
|
||||
.map_or(0.0, |v| Self::eval(v, fast, table, depth + 1)),
|
||||
Value::Object(o) => {
|
||||
let f =
|
||||
|k: &str| self.eval(o.get(k).unwrap_or(&Value::Null), fast, table, depth + 1);
|
||||
|k: &str| Self::eval(o.get(k).unwrap_or(&Value::Null), fast, table, depth + 1);
|
||||
match o.get("type").and_then(Value::as_str).unwrap_or("") {
|
||||
"minecraft:div" => {
|
||||
let r = f("right");
|
||||
@@ -425,14 +425,14 @@ impl Providers {
|
||||
}
|
||||
/// A plain `context_int_provider` value or reference (no context conditions).
|
||||
pub fn int_value(&self, v: &Value) -> i32 {
|
||||
self.eval(v, false, &self.ints, 0) as i32
|
||||
Self::eval(v, false, &self.ints, 0) as i32
|
||||
}
|
||||
/// A plain `context_float_provider` value or reference; `default` when absent.
|
||||
pub fn float_value(&self, v: &Value, default: f32) -> f32 {
|
||||
if v.is_null() {
|
||||
default
|
||||
} else {
|
||||
self.eval(v, false, &self.floats, 0) as f32
|
||||
Self::eval(v, false, &self.floats, 0) as f32
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
@@ -446,14 +446,14 @@ impl Providers {
|
||||
items
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.fuel.as_ref())
|
||||
.map_or(0, |(time, _)| self.eval(time, fast, &self.ints, 0) as i32)
|
||||
.map_or(0, |(time, _)| Self::eval(time, fast, &self.ints, 0) as i32)
|
||||
}
|
||||
pub fn speed(&self, items: &Items, stack: &ItemStack, fast: bool) -> f32 {
|
||||
items
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.fuel.as_ref())
|
||||
.map_or(1.0, |(_, speed)| {
|
||||
self.eval(speed, fast, &self.floats, 0) as f32
|
||||
Self::eval(speed, fast, &self.floats, 0) as f32
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,6 +425,29 @@ fn split_id(id: &str) -> (String, String) {
|
||||
None => ("minecraft".to_owned(), id.to_owned()),
|
||||
}
|
||||
}
|
||||
/// The slot under the cursor, for the search tab's keys.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Hover {
|
||||
Nothing,
|
||||
/// A display slot (`isCreativeSlot`), showing an item or past the end.
|
||||
Display {
|
||||
filled: bool,
|
||||
},
|
||||
/// Any other slot: the hotbar row, or the inventory tab's slots.
|
||||
Slot,
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen.keyPressed` on the search tab: whether a
|
||||
/// key is a hotbar swap on the hovered slot instead of text for the search
|
||||
/// box. `numeric` is `getNumericKeyValue().isPresent()` (the digit and keypad
|
||||
/// keys); `hotbar_key` is a `keyHotbarSlots` key. Over an empty display slot
|
||||
/// the key types (`doQuickSwap` is false), and `checkHotbarKeyPressed` needs
|
||||
/// a hovered slot and an empty cursor.
|
||||
pub fn search_key_swaps(numeric: bool, hotbar_key: bool, hover: Hover, carrying: bool) -> bool {
|
||||
let quick_swap = hover != Hover::Display { filled: false };
|
||||
quick_swap && numeric && hotbar_key && !carrying && hover != Hover::Nothing
|
||||
}
|
||||
|
||||
/// What a click on an item tab's display slot does besides changing the
|
||||
/// carried stack.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
@@ -529,6 +552,22 @@ mod tests {
|
||||
(carried, effect)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_tab_number_keys_swap_over_a_slot_before_typing() {
|
||||
let filled = Hover::Display { filled: true };
|
||||
let empty = Hover::Display { filled: false };
|
||||
// Over an item, the hotbar row or another slot: the hotbar swap.
|
||||
assert!(search_key_swaps(true, true, filled, false));
|
||||
assert!(search_key_swaps(true, true, Hover::Slot, false));
|
||||
// Over an empty display slot, nothing or with a carried stack: text.
|
||||
assert!(!search_key_swaps(true, true, empty, false));
|
||||
assert!(!search_key_swaps(true, true, Hover::Nothing, false));
|
||||
assert!(!search_key_swaps(true, true, filled, true));
|
||||
// Letters always type; so do the digits no hotbar key is bound to.
|
||||
assert!(!search_key_swaps(false, false, filled, false));
|
||||
assert!(!search_key_swaps(true, false, filled, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_slot_clicks_follow_slot_clicked() {
|
||||
use crate::menu::Input::*;
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
//! Discord Rich Presence: "Playing Horizon" on the player's Discord profile, with what they are
|
||||
//! doing (docs/native-client.md). A thread of its own talks to the Discord app on this computer
|
||||
//! through its local IPC socket (Discord's RPC docs: `discord-ipc-N`, frames of opcode u32 LE +
|
||||
//! length u32 LE + JSON). The game only calls [`set`] each frame: a change wakes the thread,
|
||||
//! which connects when Discord runs, retries every 15 s when it doesn't, keeps under Discord's
|
||||
//! update limit and clears the activity on quit. Nothing is sent with the setting off,
|
||||
//! or with `HORIZON_NO_DISCORD=1`.
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Condvar, Mutex, OnceLock};
|
||||
use std::thread::Thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::command::args::GameMode;
|
||||
|
||||
/// The "Horizon" application on Discord's developer portal (public: it names the game). Its
|
||||
/// Rich Presence assets: `horizon`, the logo.
|
||||
pub const DISCORD_APP_ID: &str = "1563857609738690560";
|
||||
/// Whether the application has the game-mode images (`survival`, `creative`, `adventure`,
|
||||
/// `spectator`) beside the logo; it doesn't yet.
|
||||
const MODE_IMAGES: bool = false;
|
||||
/// Points the game at a test's fake Discord (a folder holding `discord-ipc-0`).
|
||||
const IPC_DIR_ENV: &str = "HORIZON_DISCORD_IPC_DIR";
|
||||
|
||||
/// Discord drops activity updates beyond 5 in 20 seconds.
|
||||
const BURST: usize = 5;
|
||||
const WINDOW: Duration = Duration::from_secs(20);
|
||||
/// Between two tries to reach Discord while it isn't running.
|
||||
const RETRY: Duration = Duration::from_secs(15);
|
||||
/// The activity is sent again this often, so a Discord restarted meanwhile gets it back.
|
||||
const REFRESH: Duration = Duration::from_secs(60);
|
||||
|
||||
const HANDSHAKE: u32 = 0;
|
||||
const FRAME: u32 = 1;
|
||||
const CLOSE: u32 = 2;
|
||||
const PING: u32 = 3;
|
||||
const PONG: u32 = 4;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Dimension {
|
||||
Overworld,
|
||||
Nether,
|
||||
End,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Play {
|
||||
Singleplayer,
|
||||
/// A server or someone else's LAN world. Its address and name are never shown.
|
||||
Multiplayer,
|
||||
/// This world is open to LAN or friends; `players` counts the host.
|
||||
Hosting {
|
||||
players: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
/// The player turned Rich Presence off: the activity is cleared.
|
||||
Off,
|
||||
Menus,
|
||||
World {
|
||||
play: Play,
|
||||
dimension: Dimension,
|
||||
mode: GameMode,
|
||||
},
|
||||
}
|
||||
|
||||
/// What the game wants shown, and since when it has been in a world.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct Wanted {
|
||||
status: Status,
|
||||
/// Unix seconds: when the player entered the world (dimension changes keep it).
|
||||
since: Option<u64>,
|
||||
}
|
||||
impl Wanted {
|
||||
/// A new status at `now`; false if it is the one already wanted.
|
||||
fn update(&mut self, status: Status, now: u64) -> bool {
|
||||
if self.status == status {
|
||||
return false;
|
||||
}
|
||||
let in_world = |s: Status| matches!(s, Status::World { .. });
|
||||
if !in_world(status) {
|
||||
self.since = None;
|
||||
} else if !in_world(self.status) {
|
||||
self.since = Some(now);
|
||||
}
|
||||
self.status = status;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
wanted: Wanted,
|
||||
stop: bool,
|
||||
stopped: bool,
|
||||
}
|
||||
static SHARED: Mutex<Shared> = Mutex::new(Shared {
|
||||
wanted: Wanted {
|
||||
status: Status::Menus,
|
||||
since: None,
|
||||
},
|
||||
stop: false,
|
||||
stopped: false,
|
||||
});
|
||||
static STOPPED: Condvar = Condvar::new();
|
||||
static THREAD: OnceLock<Thread> = OnceLock::new();
|
||||
|
||||
/// Starts the presence thread, once: when the game starts, never for a dedicated server.
|
||||
/// `automated` runs (offscreen, benchmarks) only talk to a test's fake Discord.
|
||||
pub fn start(enabled: bool, automated: bool) {
|
||||
let fake_discord = std::env::var_os(IPC_DIR_ENV).is_some();
|
||||
if THREAD.get().is_some()
|
||||
|| std::env::var_os("HORIZON_NO_DISCORD").is_some_and(|v| v != "0")
|
||||
|| (automated && !fake_discord)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !enabled {
|
||||
lock().wanted.status = Status::Off;
|
||||
}
|
||||
match std::thread::Builder::new()
|
||||
.name("Discord presence".into())
|
||||
.spawn(run)
|
||||
{
|
||||
Ok(handle) => {
|
||||
let _ = THREAD.set(handle.thread().clone());
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: no Discord presence: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the player does now; only a change wakes the thread. Free without [`start`].
|
||||
pub fn set(status: Status) {
|
||||
let Some(thread) = THREAD.get() else {
|
||||
return;
|
||||
};
|
||||
if lock().wanted.update(status, unix_now()) {
|
||||
thread.unpark();
|
||||
}
|
||||
}
|
||||
|
||||
/// On quit: clears the activity, waiting at most a moment for Discord.
|
||||
pub fn stop() {
|
||||
let Some(thread) = THREAD.get() else {
|
||||
return;
|
||||
};
|
||||
let mut shared = lock();
|
||||
shared.stop = true;
|
||||
thread.unpark();
|
||||
let _ = STOPPED.wait_timeout_while(shared, Duration::from_millis(300), |s| !s.stopped);
|
||||
}
|
||||
|
||||
fn lock() -> std::sync::MutexGuard<'static, Shared> {
|
||||
SHARED.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs())
|
||||
}
|
||||
|
||||
fn run() {
|
||||
let mut client: Option<Client> = None;
|
||||
let mut sent: Option<(Wanted, Instant)> = None;
|
||||
let mut last_try: Option<Instant> = None;
|
||||
let mut limit = RateLimit::default();
|
||||
loop {
|
||||
let (wanted, stop) = {
|
||||
let s = lock();
|
||||
(s.wanted, s.stop)
|
||||
};
|
||||
if stop || wanted.status == Status::Off {
|
||||
if let Some(mut c) = client.take() {
|
||||
let _ = c.set_activity(None);
|
||||
c.close();
|
||||
}
|
||||
sent = None;
|
||||
if stop {
|
||||
lock().stopped = true;
|
||||
STOPPED.notify_all();
|
||||
return;
|
||||
}
|
||||
std::thread::park();
|
||||
continue;
|
||||
}
|
||||
let now = Instant::now();
|
||||
if client.is_none() {
|
||||
if let Some(wait) = last_try.and_then(|t| RETRY.checked_sub(now - t)) {
|
||||
std::thread::park_timeout(wait);
|
||||
continue;
|
||||
}
|
||||
last_try = Some(now);
|
||||
client = Client::connect();
|
||||
if client.is_none() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let due = sent.is_none_or(|(w, at)| w != wanted || now - at >= REFRESH);
|
||||
if !due {
|
||||
let (_, at) = sent.expect("sent");
|
||||
std::thread::park_timeout(REFRESH - (now - at));
|
||||
continue;
|
||||
}
|
||||
let wait = limit.wait(now);
|
||||
if !wait.is_zero() {
|
||||
std::thread::park_timeout(wait);
|
||||
continue;
|
||||
}
|
||||
limit.record(now);
|
||||
let c = client.as_mut().expect("connected");
|
||||
match c.set_activity(activity(wanted.status, wanted.since)) {
|
||||
Ok(()) => sent = Some((wanted, now)),
|
||||
Err(e) => {
|
||||
log::info!("Horizon: Discord presence disconnected ({e})");
|
||||
client = None;
|
||||
sent = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The activity Discord shows for `status`; `None` clears it.
|
||||
fn activity(status: Status, since: Option<u64>) -> Option<Value> {
|
||||
let mut assets = json!({"large_image": "horizon", "large_text": "Horizon"});
|
||||
let (details, state, party) = match status {
|
||||
Status::Off => return None,
|
||||
Status::Menus => ("In the menus", None, None),
|
||||
Status::World {
|
||||
play,
|
||||
dimension,
|
||||
mode,
|
||||
} => {
|
||||
if MODE_IMAGES {
|
||||
let (key, text) = match mode {
|
||||
GameMode::Survival => ("survival", "Survival"),
|
||||
GameMode::Creative => ("creative", "Creative"),
|
||||
GameMode::Adventure => ("adventure", "Adventure"),
|
||||
GameMode::Spectator => ("spectator", "Spectator"),
|
||||
};
|
||||
assets["small_image"] = key.into();
|
||||
assets["small_text"] = text.into();
|
||||
}
|
||||
let place = match dimension {
|
||||
Dimension::Overworld => "In the Overworld",
|
||||
Dimension::Nether => "In the Nether",
|
||||
Dimension::End => "In the End",
|
||||
};
|
||||
let (details, party) = match play {
|
||||
Play::Singleplayer => ("Singleplayer", None),
|
||||
Play::Multiplayer => ("Multiplayer", None),
|
||||
Play::Hosting { players } => ("Hosting a world", Some(players)),
|
||||
};
|
||||
(details, Some(place), party)
|
||||
}
|
||||
};
|
||||
let mut a = json!({"details": details, "assets": assets});
|
||||
if let Some(state) = state {
|
||||
a["state"] = state.into();
|
||||
}
|
||||
if let Some(start) = since {
|
||||
a["timestamps"] = json!({"start": start});
|
||||
}
|
||||
if let Some(players) = party {
|
||||
let max = crate::lan::MAX_PLAYERS;
|
||||
a["party"] = json!({
|
||||
"id": format!("horizon-{}", std::process::id()),
|
||||
"size": [players.clamp(1, max), max],
|
||||
});
|
||||
}
|
||||
Some(a)
|
||||
}
|
||||
|
||||
/// At most [`BURST`] updates in any [`WINDOW`].
|
||||
#[derive(Default)]
|
||||
struct RateLimit {
|
||||
sent: VecDeque<Instant>,
|
||||
}
|
||||
impl RateLimit {
|
||||
/// How long until the next update may go.
|
||||
fn wait(&self, now: Instant) -> Duration {
|
||||
if self.sent.len() < BURST {
|
||||
return Duration::ZERO;
|
||||
}
|
||||
(self.sent[0] + WINDOW).saturating_duration_since(now)
|
||||
}
|
||||
fn record(&mut self, now: Instant) {
|
||||
if self.sent.len() == BURST {
|
||||
self.sent.pop_front();
|
||||
}
|
||||
self.sent.push_back(now);
|
||||
}
|
||||
}
|
||||
|
||||
fn encode(op: u32, payload: &Value) -> Vec<u8> {
|
||||
let body = payload.to_string();
|
||||
let mut frame = Vec::with_capacity(8 + body.len());
|
||||
frame.extend_from_slice(&op.to_le_bytes());
|
||||
frame.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
frame.extend_from_slice(body.as_bytes());
|
||||
frame
|
||||
}
|
||||
|
||||
fn read_frame(r: &mut impl Read) -> io::Result<(u32, Value)> {
|
||||
let mut header = [0; 8];
|
||||
r.read_exact(&mut header)?;
|
||||
let op = u32::from_le_bytes(header[..4].try_into().expect("4 bytes"));
|
||||
let len = u32::from_le_bytes(header[4..].try_into().expect("4 bytes")) as usize;
|
||||
if len > 1 << 20 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"frame too large",
|
||||
));
|
||||
}
|
||||
let mut body = vec![0; len];
|
||||
r.read_exact(&mut body)?;
|
||||
Ok((op, serde_json::from_slice(&body)?))
|
||||
}
|
||||
|
||||
trait Pipe: Read + Write + Send {}
|
||||
impl<T: Read + Write + Send> Pipe for T {}
|
||||
|
||||
struct Client {
|
||||
pipe: Box<dyn Pipe>,
|
||||
nonce: u64,
|
||||
}
|
||||
impl Client {
|
||||
/// The first Discord socket that answers the handshake with READY.
|
||||
fn connect() -> Option<Self> {
|
||||
sockets().into_iter().find_map(|path| {
|
||||
let mut client = Client {
|
||||
pipe: open(&path).ok()?,
|
||||
nonce: 0,
|
||||
};
|
||||
match client.handshake() {
|
||||
Ok(()) => {
|
||||
log::info!("Horizon: connected to Discord for Rich Presence");
|
||||
Some(client)
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("Horizon: Discord at {} refused: {e}", path.display());
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
fn handshake(&mut self) -> io::Result<()> {
|
||||
let hello = json!({"v": 1, "client_id": DISCORD_APP_ID});
|
||||
self.pipe.write_all(&encode(HANDSHAKE, &hello))?;
|
||||
self.reply(|frame| frame["evt"] == "READY")
|
||||
}
|
||||
fn set_activity(&mut self, activity: Option<Value>) -> io::Result<()> {
|
||||
self.nonce += 1;
|
||||
let nonce = self.nonce.to_string();
|
||||
let command = json!({
|
||||
"cmd": "SET_ACTIVITY",
|
||||
"args": {"pid": std::process::id(), "activity": activity},
|
||||
"nonce": nonce,
|
||||
});
|
||||
self.pipe.write_all(&encode(FRAME, &command))?;
|
||||
self.reply(|frame| frame["nonce"] == nonce.as_str())
|
||||
}
|
||||
/// Reads until the frame `ours` picks, answering pings; an ERROR is logged, not fatal.
|
||||
fn reply(&mut self, ours: impl Fn(&Value) -> bool) -> io::Result<()> {
|
||||
loop {
|
||||
match read_frame(&mut self.pipe)? {
|
||||
(FRAME, frame) if ours(&frame) => {
|
||||
if frame["evt"] == "ERROR" {
|
||||
log::info!("Horizon: Discord presence: {}", frame["data"]["message"]);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
(PING, frame) => self.pipe.write_all(&encode(PONG, &frame))?,
|
||||
(CLOSE, frame) => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::ConnectionAborted,
|
||||
frame["message"].as_str().unwrap_or("closed").to_owned(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn close(mut self) {
|
||||
let _ = self.pipe.write_all(&encode(CLOSE, &json!({})));
|
||||
}
|
||||
}
|
||||
|
||||
/// Where Discord listens: `discord-ipc-0..9` in the temporary folders (and Flatpak's and
|
||||
/// Snap's subfolders) on Linux and macOS, named pipes on Windows.
|
||||
fn sockets() -> Vec<PathBuf> {
|
||||
let names = (0..10).map(|n| format!("discord-ipc-{n}"));
|
||||
if let Some(dir) = std::env::var_os(IPC_DIR_ENV) {
|
||||
return names.map(|n| PathBuf::from(&dir).join(n)).collect();
|
||||
}
|
||||
if cfg!(windows) {
|
||||
return names.map(|n| PathBuf::from(r"\\?\pipe\").join(n)).collect();
|
||||
}
|
||||
let mut dirs: Vec<PathBuf> = ["XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP"]
|
||||
.into_iter()
|
||||
.filter_map(std::env::var_os)
|
||||
.map(PathBuf::from)
|
||||
.collect();
|
||||
dirs.push("/tmp".into());
|
||||
dirs.dedup();
|
||||
let mut paths = Vec::new();
|
||||
for dir in dirs {
|
||||
for sub in ["", "app/com.discordapp.Discord", "snap.discord"] {
|
||||
paths.extend(names.clone().map(|n| dir.join(sub).join(n)));
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn open(path: &std::path::Path) -> io::Result<Box<dyn Pipe>> {
|
||||
let stream = std::os::unix::net::UnixStream::connect(path)?;
|
||||
// A Discord that stops answering costs this thread a few seconds, never the game.
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn open(path: &std::path::Path) -> io::Result<Box<dyn Pipe>> {
|
||||
let pipe = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(path)?;
|
||||
Ok(Box::new(pipe))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn world(play: Play, dimension: Dimension) -> Status {
|
||||
Status::World {
|
||||
play,
|
||||
dimension,
|
||||
mode: GameMode::Survival,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_round_trip() {
|
||||
let frame = encode(FRAME, &json!({"cmd": "SET_ACTIVITY"}));
|
||||
assert_eq!(&frame[..4], &[1, 0, 0, 0]);
|
||||
assert_eq!(frame[4..8], (frame.len() as u32 - 8).to_le_bytes());
|
||||
let (op, value) = read_frame(&mut frame.as_slice()).unwrap();
|
||||
assert_eq!((op, value), (FRAME, json!({"cmd": "SET_ACTIVITY"})));
|
||||
assert!(read_frame(&mut &frame[..6]).is_err());
|
||||
let huge = [1, 0, 0, 0, 0, 0, 0, 2];
|
||||
assert!(read_frame(&mut huge.as_slice()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activities_say_what_the_player_does_and_nothing_private() {
|
||||
assert_eq!(activity(Status::Off, None), None);
|
||||
let menus = activity(Status::Menus, None).unwrap();
|
||||
assert_eq!(menus["details"], "In the menus");
|
||||
assert!(menus.get("state").is_none() && menus.get("timestamps").is_none());
|
||||
assert_eq!(menus["assets"]["large_image"], "horizon");
|
||||
|
||||
let single = activity(world(Play::Singleplayer, Dimension::Nether), Some(1000)).unwrap();
|
||||
assert_eq!(single["details"], "Singleplayer");
|
||||
assert_eq!(single["state"], "In the Nether");
|
||||
assert_eq!(single["timestamps"]["start"], 1000);
|
||||
assert!(single.get("party").is_none());
|
||||
assert!(single["assets"].get("small_image").is_none());
|
||||
|
||||
let multi = activity(world(Play::Multiplayer, Dimension::End), Some(5)).unwrap();
|
||||
assert_eq!(multi["details"], "Multiplayer");
|
||||
assert_eq!(multi["state"], "In the End");
|
||||
|
||||
let host = world(Play::Hosting { players: 3 }, Dimension::Overworld);
|
||||
let host = activity(host, Some(5)).unwrap();
|
||||
assert_eq!(host["details"], "Hosting a world");
|
||||
assert_eq!(host["state"], "In the Overworld");
|
||||
assert_eq!(host["party"]["size"], json!([3, 8]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_counts_from_entering_the_world() {
|
||||
let mut w = Wanted {
|
||||
status: Status::Menus,
|
||||
since: None,
|
||||
};
|
||||
assert!(!w.update(Status::Menus, 10));
|
||||
assert!(w.update(world(Play::Singleplayer, Dimension::Overworld), 20));
|
||||
assert_eq!(w.since, Some(20));
|
||||
// Another dimension, opening to LAN: the same visit.
|
||||
assert!(w.update(world(Play::Singleplayer, Dimension::Nether), 30));
|
||||
assert!(w.update(world(Play::Hosting { players: 1 }, Dimension::Nether), 40));
|
||||
assert_eq!(w.since, Some(20));
|
||||
assert!(w.update(Status::Menus, 50));
|
||||
assert_eq!(w.since, None);
|
||||
assert!(w.update(world(Play::Multiplayer, Dimension::Overworld), 60));
|
||||
assert_eq!(w.since, Some(60));
|
||||
assert!(w.update(Status::Off, 70));
|
||||
assert_eq!(w.since, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_most_five_updates_in_twenty_seconds() {
|
||||
let t = Instant::now();
|
||||
let mut limit = RateLimit::default();
|
||||
for i in 0..5 {
|
||||
let now = t + Duration::from_secs(i);
|
||||
assert_eq!(limit.wait(now), Duration::ZERO);
|
||||
limit.record(now);
|
||||
}
|
||||
assert_eq!(
|
||||
limit.wait(t + Duration::from_secs(5)),
|
||||
Duration::from_secs(15)
|
||||
);
|
||||
assert_eq!(limit.wait(t + WINDOW), Duration::ZERO);
|
||||
limit.record(t + WINDOW);
|
||||
assert_eq!(limit.wait(t + WINDOW), Duration::from_secs(1));
|
||||
}
|
||||
|
||||
/// A fake Discord: answers the handshake with READY, each command with its nonce, after a ping.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn talks_to_discord_over_its_socket() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-discord-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("discord-ipc-0");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let listener = std::os::unix::net::UnixListener::bind(&path).unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut s, _) = listener.accept().unwrap();
|
||||
let (op, hello) = read_frame(&mut s).unwrap();
|
||||
assert_eq!((op, &hello["v"]), (HANDSHAKE, &json!(1)));
|
||||
let ready = json!({"cmd": "DISPATCH", "evt": "READY", "data": {"v": 1}});
|
||||
s.write_all(&encode(FRAME, &ready)).unwrap();
|
||||
let mut got = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let (op, cmd) = read_frame(&mut s).unwrap();
|
||||
assert_eq!(op, FRAME);
|
||||
s.write_all(&encode(PING, &json!({"n": 1}))).unwrap();
|
||||
assert_eq!(read_frame(&mut s).unwrap(), (PONG, json!({"n": 1})));
|
||||
let reply = json!({"cmd": "SET_ACTIVITY", "nonce": cmd["nonce"], "data": null});
|
||||
s.write_all(&encode(FRAME, &reply)).unwrap();
|
||||
got.push(cmd);
|
||||
}
|
||||
got
|
||||
});
|
||||
let mut client = Client {
|
||||
pipe: open(&path).unwrap(),
|
||||
nonce: 0,
|
||||
};
|
||||
client.handshake().unwrap();
|
||||
let shown = activity(world(Play::Singleplayer, Dimension::End), Some(7));
|
||||
client.set_activity(shown.clone()).unwrap();
|
||||
client.set_activity(None).unwrap();
|
||||
let got = server.join().unwrap();
|
||||
assert_eq!(got[0]["cmd"], "SET_ACTIVITY");
|
||||
assert_eq!(got[0]["args"]["pid"], std::process::id());
|
||||
assert_eq!(got[0]["args"]["activity"], shown.unwrap());
|
||||
assert_eq!(got[1]["args"]["activity"], Value::Null);
|
||||
assert_ne!(got[0]["nonce"], got[1]["nonce"]);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -1155,6 +1155,72 @@ pub fn arrow_from_nbt(
|
||||
Some((p, tag.get("inBlockState").cloned()))
|
||||
}
|
||||
|
||||
/// `LargeFireball` and `SmallFireball`: `AbstractHurtingProjectile`'s
|
||||
/// `acceleration_power`, `Projectile`'s owner and flags, `ExplosionPower`.
|
||||
pub fn fireball_to_nbt(
|
||||
p: &Projectile,
|
||||
uuid: [i32; 4],
|
||||
owner: Option<[i32; 4]>,
|
||||
raw: Option<&Tag>,
|
||||
) -> Option<Tag> {
|
||||
let power = match p.kind {
|
||||
ProjectileKind::LargeFireball { power } => Some(power),
|
||||
ProjectileKind::SmallFireball => None,
|
||||
_ => return None,
|
||||
};
|
||||
let base = Base {
|
||||
id: p.kind.id(),
|
||||
uuid,
|
||||
position: p.position,
|
||||
motion: p.velocity,
|
||||
rotation: [p.yaw, p.pitch],
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
put(
|
||||
&mut tag,
|
||||
"acceleration_power",
|
||||
Tag::Double(p.acceleration.to_bits()),
|
||||
);
|
||||
// Projectile: LeftOwner only when set; HasBeenShot once it has ticked
|
||||
// (a fireball saved before its first tick keeps what it was loaded with).
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"LeftOwner".into());
|
||||
}
|
||||
if p.left_owner {
|
||||
put(&mut tag, "LeftOwner", Tag::Byte(1));
|
||||
}
|
||||
if p.age > 0 || tag.get("HasBeenShot").is_none() {
|
||||
put(&mut tag, "HasBeenShot", Tag::Byte(i8::from(p.age > 0)));
|
||||
}
|
||||
if let Some(power) = power {
|
||||
put(&mut tag, "ExplosionPower", Tag::Byte(power as i8));
|
||||
}
|
||||
if let Some(owner) = owner {
|
||||
put(&mut tag, "Owner", Tag::IntArray(owner.to_vec()));
|
||||
}
|
||||
Some(tag)
|
||||
}
|
||||
|
||||
pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
|
||||
let kind = match kind(tag)?.as_str() {
|
||||
"minecraft:fireball" => ProjectileKind::LargeFireball {
|
||||
power: number(tag.get("ExplosionPower")).unwrap_or(1) as i32,
|
||||
},
|
||||
"minecraft:small_fireball" => ProjectileKind::SmallFireball,
|
||||
_ => return None,
|
||||
};
|
||||
let owner = uuid_from_tag(tag.get("Owner"));
|
||||
let from_player = player.is_some() && owner == player;
|
||||
let mut p = Projectile::new(kind, position(tag)?, from_player.then_some(Who::Player));
|
||||
p.velocity = motion(tag);
|
||||
[p.yaw, p.pitch] = rotation(tag);
|
||||
p.left_owner = flag(tag, "LeftOwner");
|
||||
p.acceleration = float(tag.get("acceleration_power")).unwrap_or(0.1);
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// `FallingBlockEntity`: BlockState, Time, DropItem, HurtEntities.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn falling_block_to_nbt(
|
||||
@@ -2139,3 +2205,41 @@ mod sheep_nbt_tests {
|
||||
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fireball_nbt_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn a_fireball_round_trips_its_flight() {
|
||||
let raw = Tag::compound([
|
||||
("id", Tag::String("minecraft:fireball".into())),
|
||||
("Pos", doubles(&[1.0, 70.0, -2.5])),
|
||||
("Motion", doubles(&[0.0, -0.5, 1.0])),
|
||||
("ExplosionPower", Tag::Byte(4)),
|
||||
("acceleration_power", Tag::Double(0.0f64.to_bits())),
|
||||
]);
|
||||
let p = fireball_from_nbt(&raw, None).unwrap();
|
||||
assert_eq!(p.kind, ProjectileKind::LargeFireball { power: 4 });
|
||||
assert_eq!((p.velocity.z, p.acceleration), (1.0, 0.0));
|
||||
let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, None).unwrap();
|
||||
assert_eq!(number(back.get("ExplosionPower")), Some(4));
|
||||
assert_eq!(float(back.get("acceleration_power")), Some(0.0));
|
||||
// Projectile.addAdditionalSaveData: no LeftOwner unless set, HasBeenShot always.
|
||||
assert!(back.get("LeftOwner").is_none());
|
||||
assert_eq!(number(back.get("HasBeenShot")), Some(0));
|
||||
assert_eq!(
|
||||
read_doubles::<3>(back.get("Motion")),
|
||||
Some([0.0, -0.5, 1.0])
|
||||
);
|
||||
// A default fireball keeps vanilla's power and acceleration.
|
||||
let plain = Tag::compound([
|
||||
("id", Tag::String("minecraft:fireball".into())),
|
||||
("Pos", doubles(&[0.0, 0.0, 0.0])),
|
||||
]);
|
||||
let p = fireball_from_nbt(&plain, None).unwrap();
|
||||
assert_eq!(
|
||||
(p.kind, p.acceleration),
|
||||
(ProjectileKind::LargeFireball { power: 1 }, 0.1)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -198,6 +198,8 @@ pub struct FogInputs<'a> {
|
||||
pub blindness: Option<(i32, bool)>,
|
||||
/// The darkness effect's blend factor, while the effect is on.
|
||||
pub darkness: Option<f32>,
|
||||
/// `GameRenderer.bossOverlayWorldDarkening`: a wither's bar dims the fog.
|
||||
pub darken_world: f32,
|
||||
/// `BossHealthOverlay.shouldCreateWorldFog`: a boss bar with
|
||||
/// `createWorldFog` is shown (the Ender Dragon's).
|
||||
pub boss_world_fog: bool,
|
||||
@@ -263,6 +265,13 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
let brightness = (1.0 - darkness) * (1.0 - darkness);
|
||||
color = color.map(|c| c * brightness);
|
||||
}
|
||||
// FogRenderer.computeFogColor: a boss bar's world darkening.
|
||||
if input.darken_world > 0.0 {
|
||||
let t = input.darken_world;
|
||||
color[0] += (color[0] * 0.7 - color[0]) * t;
|
||||
color[1] += (color[1] * 0.6 - color[1]) * t;
|
||||
color[2] += (color[2] * 0.6 - color[2]) * t;
|
||||
}
|
||||
let brighten = if eye_in_water { water_vision } else { 0.0 };
|
||||
if color.iter().all(|&c| c != 0.0) {
|
||||
let scale = 1.0 / color[0].max(color[1]).max(color[2]);
|
||||
@@ -394,6 +403,7 @@ mod tests {
|
||||
skybox: true,
|
||||
blindness: None,
|
||||
darkness: None,
|
||||
darken_world: 0.0,
|
||||
boss_world_fog: false,
|
||||
}
|
||||
}
|
||||
@@ -422,6 +432,25 @@ mod tests {
|
||||
assert_eq!(water.environmental_start, -8.0);
|
||||
}
|
||||
#[test]
|
||||
fn boss_darkening_dims_fog_color() {
|
||||
// FogRenderer.computeFogColor: lerp toward 70/60/60% of each channel.
|
||||
let sky = Sky {
|
||||
fog_color: [1.0, 1.0, 1.0],
|
||||
sky_color: [1.0, 1.0, 1.0],
|
||||
..Sky::default()
|
||||
};
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let mut input = inputs(&sky);
|
||||
input.skybox = false;
|
||||
input.darken_world = 1.0;
|
||||
let fog = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert!((fog.color[0] - 0.7).abs() < 1.0e-5);
|
||||
assert!((fog.color[1] - 0.6).abs() < 1.0e-5);
|
||||
assert!((fog.color[2] - 0.6).abs() < 1.0e-5);
|
||||
}
|
||||
#[test]
|
||||
fn void_darkness_starts_32_above_the_floor() {
|
||||
let sky = Sky {
|
||||
fog_end: 1024.0,
|
||||
|
||||
@@ -102,7 +102,8 @@ enum Provider {
|
||||
/// One font (`FontSet`): its providers and the glyphs resolved so far.
|
||||
struct FontSet {
|
||||
providers: Vec<Provider>,
|
||||
glyphs: HashMap<char, Glyph>,
|
||||
/// Every char drawn looks up here, so the hasher is Fx, not SipHash.
|
||||
glyphs: crate::fast_hash::FxHashMap<char, Glyph>,
|
||||
/// `glyphsByWidth` for obfuscated text (built on first use).
|
||||
by_width: Option<HashMap<u32, Vec<char>>>,
|
||||
}
|
||||
@@ -647,7 +648,7 @@ impl Font {
|
||||
.collect();
|
||||
FontSet {
|
||||
providers,
|
||||
glyphs: HashMap::new(),
|
||||
glyphs: Default::default(),
|
||||
by_width: None,
|
||||
}
|
||||
}
|
||||
|
||||
+318
-215
File diff suppressed because it is too large.
Load diff
@@ -881,30 +881,40 @@ pub(super) fn adv_name(a: &adv::Advancement) -> Text {
|
||||
let Some(d) = &a.display else {
|
||||
return Text::literal(a.id.clone());
|
||||
};
|
||||
let color = match d.frame {
|
||||
Frame::Challenge => text::DARK_PURPLE,
|
||||
_ => text::GREEN,
|
||||
};
|
||||
// Advancement.decorateName: the title in the frame's colour (styles
|
||||
// merged into it), a new line and the description.
|
||||
let coloured = text_of(&d.title).with_color(color);
|
||||
let mut component = coloured.component();
|
||||
if let Some(o) = component.as_object_mut() {
|
||||
o.insert(
|
||||
"extra".into(),
|
||||
serde_json::json!(["\n", text_of(&d.description).component()]),
|
||||
);
|
||||
}
|
||||
let tooltip = coloured
|
||||
.append("\n")
|
||||
.append(text_of(&d.description))
|
||||
.with_component(component);
|
||||
let color = frame_color(d.frame);
|
||||
let title = text_of(&d.title).with_style(&Style {
|
||||
hover: Some(Box::new(tooltip)),
|
||||
hover: Some(Box::new(decorate_name(d))),
|
||||
..Style::default()
|
||||
});
|
||||
Text::translate("chat.square_brackets", &[title]).with_color(color)
|
||||
}
|
||||
fn frame_color(frame: Frame) -> u32 {
|
||||
match frame {
|
||||
Frame::Challenge => text::DARK_PURPLE,
|
||||
_ => text::GREEN,
|
||||
}
|
||||
}
|
||||
/// `Advancement.decorateName`: a copy of the title with the frame's colour
|
||||
/// under its own style (mergeStyles), then a new line and the description
|
||||
/// after the title's own siblings, so both inherit the title's root style.
|
||||
/// Built on the title's JSON (a list as its first element with the rest as
|
||||
/// siblings): a parsed Text moves a styled root's style down a level once it
|
||||
/// has siblings.
|
||||
fn decorate_name(d: &adv::Display) -> Text {
|
||||
let mut component = match horizon_client::scoreboard::canonical_component(&d.title) {
|
||||
Value::String(s) => serde_json::json!({ "text": s }),
|
||||
c => c,
|
||||
};
|
||||
if let Some(o) = component.as_object_mut() {
|
||||
let frame = Text::empty().with_color(frame_color(d.frame)).component();
|
||||
o.entry("color").or_insert_with(|| frame["color"].clone());
|
||||
let extra = o.entry("extra").or_insert_with(|| serde_json::json!([]));
|
||||
if let Some(extra) = extra.as_array_mut() {
|
||||
extra.extend(["\n".into(), d.description.clone()]);
|
||||
}
|
||||
}
|
||||
text_of(&component).with_component(component)
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `AdvancementCommands.Action.GRANT` on one advancement (or one of
|
||||
/// its criteria): false when nothing changed.
|
||||
@@ -1051,17 +1061,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A text component from advancement JSON (`translate` or `text`).
|
||||
/// An advancement's title or description: a whole component (ComponentSerialization.CODEC),
|
||||
/// with its siblings and styles, as a plugin's `{"text":"","extra":[...]}` needs.
|
||||
pub(super) fn text_of(v: &Value) -> Text {
|
||||
if let Some(key) = v["translate"].as_str() {
|
||||
Text::translate(key, &[])
|
||||
} else if let Some(s) = v["text"].as_str() {
|
||||
Text::literal(s)
|
||||
} else if let Some(s) = v.as_str() {
|
||||
Text::literal(s)
|
||||
} else {
|
||||
Text::empty()
|
||||
}
|
||||
horizon_client::scoreboard::comp_text(v)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1095,6 +1098,105 @@ mod tests {
|
||||
(seen, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn titles_keep_their_siblings_and_styles() {
|
||||
let title = text_of(&serde_json::json!({
|
||||
"text": "",
|
||||
"extra": [{"text": "Quest ", "color": "gold"}, "Board"],
|
||||
}));
|
||||
assert_eq!(title.plain(), "Quest Board");
|
||||
assert_eq!(title.spans[0].style.color, Some(text::GOLD));
|
||||
assert_eq!(title.spans[1].style.color, None);
|
||||
let vanilla = text_of(&serde_json::json!({"translate": "advancements.story.root.title"}));
|
||||
assert_eq!(
|
||||
vanilla,
|
||||
Text::translate("advancements.story.root.title", &[])
|
||||
);
|
||||
assert_eq!(
|
||||
vanilla.translation_key(),
|
||||
Some("advancements.story.root.title")
|
||||
);
|
||||
}
|
||||
|
||||
fn announced(title: Value, description: Value) -> adv::Advancement {
|
||||
adv::Advancement {
|
||||
id: "quests:board".into(),
|
||||
parent: None,
|
||||
display: Some(adv::Display {
|
||||
icon: "minecraft:book".into(),
|
||||
title,
|
||||
description,
|
||||
frame: Frame::Task,
|
||||
background: None,
|
||||
show_toast: true,
|
||||
announce: true,
|
||||
hidden: false,
|
||||
}),
|
||||
criteria: Vec::new(),
|
||||
requirements: Vec::new(),
|
||||
rewards: adv::Rewards::default(),
|
||||
}
|
||||
}
|
||||
/// The chat hover: the JSON a client receives, and the spans drawn here.
|
||||
fn hover(a: &adv::Advancement) -> (Value, Text) {
|
||||
let local = decorate_name(a.display.as_ref().unwrap());
|
||||
let sent = adv_name(a).component()["with"][0]["hover_event"]["value"].clone();
|
||||
(sent, local)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_hover_keeps_the_titles_siblings_before_the_description() {
|
||||
let a = announced(
|
||||
serde_json::json!({"text": "", "extra": [{"text": "Quest ", "color": "gold"}, "Board"]}),
|
||||
serde_json::json!("Open the board"),
|
||||
);
|
||||
let (sent, local) = hover(&a);
|
||||
let sent = horizon_client::scoreboard::comp_text(&sent);
|
||||
assert_eq!(sent.plain(), "Quest Board\nOpen the board");
|
||||
let colors = |t: &Text| t.spans.iter().map(|s| s.style.color).collect::<Vec<_>>();
|
||||
let (gold, green) = (Some(text::GOLD), Some(text::GREEN));
|
||||
assert_eq!(colors(&sent), [gold, green, green, green]);
|
||||
// The spans drawn here come from the same JSON a client receives.
|
||||
assert_eq!(colors(&local), colors(&sent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_hover_description_takes_a_styled_titles_own_style() {
|
||||
// mergeStyles keeps the title's gold; the description inherits it.
|
||||
let object = announced(
|
||||
serde_json::json!({"text": "Quest", "color": "gold", "bold": true, "extra": [" Board"]}),
|
||||
serde_json::json!("Open the board"),
|
||||
);
|
||||
let list = announced(
|
||||
serde_json::json!([{"text": "Quest", "color": "gold", "bold": true}, " Board"]),
|
||||
serde_json::json!("Open the board"),
|
||||
);
|
||||
for a in [object, list] {
|
||||
let (sent, local) = hover(&a);
|
||||
let sent = horizon_client::scoreboard::comp_text(&sent);
|
||||
assert_eq!(sent.plain(), "Quest Board\nOpen the board");
|
||||
let last = &sent.spans.last().unwrap().style;
|
||||
assert_eq!((last.color, last.bold), (Some(text::GOLD), Some(true)));
|
||||
assert_eq!(local.spans.last().unwrap().style, *last);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_hover_of_a_vanilla_advancement() {
|
||||
let a = announced(
|
||||
serde_json::json!({"translate": "advancements.story.root.title"}),
|
||||
serde_json::json!({"translate": "advancements.story.root.description"}),
|
||||
);
|
||||
assert_eq!(
|
||||
hover(&a).0,
|
||||
serde_json::json!({
|
||||
"translate": "advancements.story.root.title",
|
||||
"color": "green",
|
||||
"extra": ["\n", {"translate": "advancements.story.root.description"}],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toasts_slide_in_squared_and_out_after_five_seconds() {
|
||||
let mut toast = recipes(1);
|
||||
|
||||
@@ -9,6 +9,10 @@ use horizon_client::{
|
||||
block_entity_anim::Ticker,
|
||||
chunk::{MIN_Y, SECTIONS},
|
||||
items::ItemStack,
|
||||
mobs::{
|
||||
Species,
|
||||
render::{PROFESSIONS, VILLAGER_TYPES},
|
||||
},
|
||||
widgets,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
@@ -335,6 +339,12 @@ impl Game<'_> {
|
||||
Some(Ticker::Shulker { .. }) => v["lid"] = json!(self.block_anim(pos, 1.0).shulker.0),
|
||||
_ => {}
|
||||
}
|
||||
// The lines of a sign, as this client has them.
|
||||
if let Some(horizon_client::containers::BlockEntity::Sign(sign)) =
|
||||
self.world.block_entities.get(&(pos[0], pos[1], pos[2]))
|
||||
{
|
||||
v["sign"] = json!({ "front": sign.front.lines, "back": sign.back.lines });
|
||||
}
|
||||
v
|
||||
}
|
||||
pub(super) fn stack_value(&self, s: &ItemStack) -> Value {
|
||||
@@ -457,10 +467,13 @@ impl Game<'_> {
|
||||
"velocity": [p.velocity.x, p.velocity.y, p.velocity.z],
|
||||
"on_ground": p.ground.on_ground,
|
||||
"flying": p.flying,
|
||||
"fall_flying": p.fall_flying,
|
||||
"sneaking": self.keys.contains(&KeyCode::ShiftLeft),
|
||||
"sprinting": self.keys.contains(&KeyCode::ControlLeft),
|
||||
"health": p.health.health(),
|
||||
"absorption": p.health.absorption(),
|
||||
// The hurt animation's ticks left (the camera's hurt tilt).
|
||||
"hurt_time": p.health.hurt_animation_time,
|
||||
"air": p.air,
|
||||
"food": p.health.food.food,
|
||||
"saturation": p.health.food.saturation,
|
||||
@@ -585,10 +598,20 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
for m in self.mobs.iter().filter(|m| m.alive()) {
|
||||
let mut v = json!({ "id": m.id, "kind": "mob", "health": m.health, "baby": m.is_baby(), "hurt_time": m.hurt_time });
|
||||
// VillagerData as this game knows it: on a client, what the
|
||||
// server's synched data says.
|
||||
if matches!(m.species(), Species::Villager | Species::ZombieVillager) {
|
||||
v["villager"] = json!({
|
||||
"type": VILLAGER_TYPES[m.extra.variant as usize % VILLAGER_TYPES.len()],
|
||||
"profession": PROFESSIONS[m.extra.variant2 as usize % PROFESSIONS.len()],
|
||||
"level": m.extra.variant3,
|
||||
});
|
||||
}
|
||||
push(
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
m.kind.id.as_ref(),
|
||||
json!({ "id": m.id, "kind": "mob", "health": m.health, "baby": m.is_baby(), "hurt_time": m.hurt_time }),
|
||||
v,
|
||||
);
|
||||
}
|
||||
for item in &self.dropped {
|
||||
|
||||
@@ -59,6 +59,30 @@ impl Game<'_> {
|
||||
}
|
||||
/// Block commands; None when `words[0]` is not one of them.
|
||||
pub(super) fn block_command(&mut self, words: &[&str]) -> Result<Option<Value>> {
|
||||
let ours = matches!(
|
||||
words.first(),
|
||||
Some(&("setblock" | "fill" | "getblock" | "container"))
|
||||
);
|
||||
// Single player on its server thread: the world is the server's, so
|
||||
// the command runs there, in the owner's level, and the client hears
|
||||
// of it like any change.
|
||||
if ours && let Some(server) = &self.integrated {
|
||||
let owned: Vec<String> = words.iter().map(|w| (*w).to_owned()).collect();
|
||||
return server
|
||||
.call(move |g| {
|
||||
let words: Vec<&str> = owned.iter().map(String::as_str).collect();
|
||||
g.in_owner_level(|g| g.block_command(&words).map_err(|e| e.to_string()))
|
||||
})
|
||||
.ok_or("the integrated server stopped")?
|
||||
.map_err(Into::into);
|
||||
}
|
||||
// Another server's world changes only through its own commands, which
|
||||
// it checks as the player's; `getblock` and `container` read what this
|
||||
// client has of it.
|
||||
if self.remote.is_some() && matches!(words.first(), Some(&("setblock" | "fill"))) {
|
||||
self.send_chat(&format!("/{}", words.join(" ")));
|
||||
return Ok(Some(json!({ "ok": true, "sent": true })));
|
||||
}
|
||||
let int = |i: usize| -> Result<i32> {
|
||||
Ok(words
|
||||
.get(i)
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Boats and the entities around them: what a boat runs into
|
||||
//! (`AbstractBoat.canVehicleCollide`), the pushes between a boat and what
|
||||
//! touches it (`AbstractBoat.push`, `LivingEntity.pushEntities`), the mobs
|
||||
//! it picks up (the end of `AbstractBoat.tick`), where they sit
|
||||
//! (`positionRider`, `clampRotation`) and how they get out.
|
||||
//! Saving them under the boat's `Passengers` lives in `persist.rs`.
|
||||
use super::{Game, boats::Boat, lan_mobs::guest_bounds};
|
||||
use horizon_client::{
|
||||
camera::wrap_degrees,
|
||||
mobs::{Mob, Species, Who, drops::is_animal, equine, riders},
|
||||
scoreboard::pushable_by,
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
|
||||
/// `Entity.boardingCooldown` once a mob leaves its boat.
|
||||
const BOARDING_COOLDOWN: i32 = 60;
|
||||
/// `Player.DEFAULT_VEHICLE_ATTACHMENT`: how far below the seat a player's
|
||||
/// feet are.
|
||||
const PLAYER_RIDING_OFFSET: f64 = 0.6;
|
||||
|
||||
/// `Entity.isVehicle`: a player or a mob rides it.
|
||||
fn carries(m: &Mob) -> bool {
|
||||
m.rider.is_some() || m.extra.rider.is_some()
|
||||
}
|
||||
/// `isPushable`, as `pushableBy` and `canVehicleCollide` ask it:
|
||||
/// `Mob::pushable` plus the shulkers it leaves out (they take no push, but
|
||||
/// count), less a ridden horse (`AbstractHorse.isPushable`).
|
||||
fn pushable(m: &Mob) -> bool {
|
||||
!m.removed
|
||||
&& (m.pushable() || m.species() == Species::Shulker && m.alive())
|
||||
&& !(equine::is_equine(m.species()) && carries(m))
|
||||
}
|
||||
/// Whether `Entity.push` moves the mob: it is pushable and carries no one.
|
||||
fn moved_by_push(m: &Mob) -> bool {
|
||||
!m.removed && m.pushable() && !carries(m)
|
||||
}
|
||||
/// `Entity.push(Entity)`: the push `other` gets from `this` (which takes the
|
||||
/// opposite one), or None when they stand on nearly the same spot.
|
||||
pub(super) fn push_impulse(this: Vec3, other: Vec3) -> Option<Vec3> {
|
||||
let (mut xa, mut za) = (other.x - this.x, other.z - this.z);
|
||||
let mut dd = xa.abs().max(za.abs());
|
||||
if dd < f64::from(0.01_f32) {
|
||||
return None;
|
||||
}
|
||||
dd = dd.sqrt();
|
||||
xa /= dd;
|
||||
za /= dd;
|
||||
let pow = (1.0 / dd).min(1.0);
|
||||
let k = f64::from(0.05_f32);
|
||||
Some(Vec3::new(xa * pow * k, 0.0, za * pow * k))
|
||||
}
|
||||
/// `AbstractBoat.push`: another boat is pushed while it is lower than this
|
||||
/// boat's top, anything else while its feet are no higher than the boat's.
|
||||
pub(super) fn boat_pushes(boat: Aabb, other: Aabb, other_is_boat: bool) -> bool {
|
||||
if other_is_boat {
|
||||
other.min_position().y < boat.max_position().y
|
||||
} else {
|
||||
other.min_position().y <= boat.min_position().y
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The players in boat `id`: the window's player (or the server player
|
||||
/// acting as it) and the server's other players.
|
||||
pub(super) fn boat_players(&self, id: u32) -> usize {
|
||||
let guests = self.lan.as_ref().map_or(0, |h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| Some(g.conn) != self.lan_acting && g.vehicle == Some(id))
|
||||
.count()
|
||||
});
|
||||
usize::from(self.riding == Some(id)) + guests
|
||||
}
|
||||
/// The mobs in boat `id`, in their seat order after the players
|
||||
/// (`Entity.addPassenger` puts a player first).
|
||||
pub(super) fn boat_mobs(&self, id: u32) -> Vec<usize> {
|
||||
(0..self.mobs.len())
|
||||
.filter(|&j| {
|
||||
let m = &self.mobs[j];
|
||||
m.extra.mount == Some(id) && !m.removed && m.alive()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `getPassengers().size()` of boat `id`.
|
||||
pub(super) fn boat_passengers(&self, id: u32) -> usize {
|
||||
self.boat_players(id) + self.boat_mobs(id).len()
|
||||
}
|
||||
/// `AbstractBoat.canAddPassenger`: a seat is free and the boat is not
|
||||
/// under water.
|
||||
pub(super) fn boat_has_room(&self, i: usize) -> bool {
|
||||
let b = &self.boats[i];
|
||||
self.boat_passengers(b.id) < b.max_passengers() && !b.under_water()
|
||||
}
|
||||
/// Where the player in boat `i` sits: in the middle alone, in front of a
|
||||
/// mob.
|
||||
pub(super) fn player_boat_seat(&self, i: usize) -> Vec3 {
|
||||
let b = &self.boats[i];
|
||||
let count = self.boat_players(b.id).max(1) + self.boat_mobs(b.id).len();
|
||||
b.seat(0, count, false, PLAYER_RIDING_OFFSET, false)
|
||||
}
|
||||
/// `getRootVehicle` of mob `j`: the mob or boat at the bottom of what it
|
||||
/// rides, None when it rides nothing.
|
||||
fn mob_root_vehicle(&self, mut j: usize) -> Option<u32> {
|
||||
let mut root = None;
|
||||
for _ in 0..8 {
|
||||
let Some(v) = self.mobs[j].extra.mount else {
|
||||
break;
|
||||
};
|
||||
root = Some(v);
|
||||
match self.mobs.iter().position(|m| m.id == v) {
|
||||
Some(k) => j = k,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
root
|
||||
}
|
||||
/// The players outside boat `id` that push and block it (living, not
|
||||
/// spectators), with the window player's team.
|
||||
fn players_outside(&self, id: u32) -> Vec<(Aabb, bool)> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(p) = self.player.as_ref()
|
||||
&& self.riding != Some(id)
|
||||
&& !self.spectator()
|
||||
&& !p.health.dead_or_dying()
|
||||
{
|
||||
out.push((p.bounds(), true));
|
||||
}
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
for g in host.hub.guests.iter().filter(|g| {
|
||||
Some(g.conn) != self.lan_acting
|
||||
&& host.hub.here(g)
|
||||
&& g.loaded
|
||||
&& !g.dead
|
||||
&& !g.spectator()
|
||||
&& g.vehicle != Some(id)
|
||||
}) {
|
||||
out.push((guest_bounds(g), false));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `getEntityCollisions` of boat `id` within `area`, with
|
||||
/// `AbstractBoat.canVehicleCollide`: the other boats, minecarts,
|
||||
/// shulkers and pushable mobs and players, but not its own passengers.
|
||||
pub(super) fn boat_obstacles(&self, id: u32, area: Aabb) -> Vec<Aabb> {
|
||||
let mut out: Vec<Aabb> = (0..self.mobs.len())
|
||||
.filter(|&j| {
|
||||
let m = &self.mobs[j];
|
||||
pushable(m)
|
||||
&& m.bounds().intersects(area)
|
||||
&& (m.extra.mount.is_none() || self.mob_root_vehicle(j) != Some(id))
|
||||
})
|
||||
.map(|j| self.mobs[j].bounds())
|
||||
.collect();
|
||||
out.extend(
|
||||
self.boats
|
||||
.iter()
|
||||
.filter(|b| b.id != id)
|
||||
.map(Boat::bounds)
|
||||
.chain(self.carts.iter().map(|c| c.bounds()))
|
||||
.chain(self.players_outside(id).into_iter().map(|(bb, _)| bb))
|
||||
.filter(|bb| bb.intersects(area)),
|
||||
);
|
||||
out
|
||||
}
|
||||
/// What boat `b` may run into this tick.
|
||||
pub(super) fn boat_move_obstacles(&self, b: &Boat) -> Vec<Aabb> {
|
||||
let reach = b.velocity.length() + 1.0;
|
||||
self.boat_obstacles(b.id, b.bounds().inflate(Vec3::new(reach, reach, reach)))
|
||||
}
|
||||
/// `noEntityCollision(boat, box)` for a new boat (`BoatItem.use`).
|
||||
pub(super) fn boat_entity_collision(&self, bb: Aabb) -> bool {
|
||||
// EntityGetter.getEntityCollisions: the box grown by 1e-7.
|
||||
let area = bb.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
|
||||
!self.boat_obstacles(u32::MAX, area).is_empty()
|
||||
}
|
||||
|
||||
/// The end of `AbstractBoat.tick` on the server for boat `i`: each
|
||||
/// entity it touches gets in (a mob, while no player steers) or is
|
||||
/// pushed apart from it, and `LivingEntity.pushEntities` of each mob and
|
||||
/// player touching it pushes the two apart once more. Only the window's
|
||||
/// own player is moved here: a server's players are pushed by their
|
||||
/// clients, which tick the boats near them (`remote_pushes`).
|
||||
pub(super) fn boat_contacts(&mut self, i: usize) {
|
||||
let (id, bb, max) = {
|
||||
let b = &self.boats[i];
|
||||
(b.id, b.bounds(), b.max_passengers())
|
||||
};
|
||||
let area = bb.inflate(Vec3::new(
|
||||
f64::from(0.2_f32),
|
||||
f64::from(-0.01_f32),
|
||||
f64::from(0.2_f32),
|
||||
));
|
||||
let mut count = self.boat_passengers(id);
|
||||
// getControllingPassenger is a player: one rides it (players sit first).
|
||||
let pick_up = self.boat_players(id) == 0;
|
||||
// canAddPassenger, which startRiding asks after the checks below.
|
||||
let room = !self.boats[i].under_water();
|
||||
// (mob, touched by the boat's sweep, its own pushEntities reaches the boat)
|
||||
let teams = self.mob_teams();
|
||||
let mobs: Vec<(usize, bool, bool)> = (0..self.mobs.len())
|
||||
.filter_map(|j| {
|
||||
let m = &self.mobs[j];
|
||||
if !pushable(m) || m.extra.mount.is_some() && self.mob_root_vehicle(j) == Some(id) {
|
||||
return None;
|
||||
}
|
||||
let mb = m.bounds();
|
||||
let team = teams.of(Who::Mob(m.id));
|
||||
let touched = mb.intersects(area) && pushable_by(None, team, true);
|
||||
// Bats push nothing (Bat.pushEntities).
|
||||
let pushes = m.species() != Species::Bat
|
||||
&& mb.intersects(bb)
|
||||
&& pushable_by(team, None, true);
|
||||
(touched || pushes).then_some((j, touched, pushes))
|
||||
})
|
||||
.collect();
|
||||
let player_team = teams.of(Who::Player);
|
||||
let players: Vec<(Aabb, bool, bool, bool)> = self
|
||||
.players_outside(id)
|
||||
.into_iter()
|
||||
.map(|(pb, own)| {
|
||||
let team = if own { player_team } else { None };
|
||||
(
|
||||
pb,
|
||||
own,
|
||||
pb.intersects(area) && pushable_by(None, team, true),
|
||||
pb.intersects(bb) && pushable_by(team, None, true),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
drop(teams);
|
||||
for (j, touched, pushes) in mobs {
|
||||
if touched {
|
||||
let m = &self.mobs[j];
|
||||
let boards = pick_up
|
||||
&& count < max
|
||||
&& m.extra.mount.is_none()
|
||||
&& m.width() < super::boats::WIDTH as f32
|
||||
&& !self
|
||||
.res
|
||||
.mob_data()
|
||||
.entity_has("minecraft:cannot_be_pushed_onto_boats", &m.kind.id);
|
||||
if boards {
|
||||
// startRiding fails quietly (no push) while the mob's
|
||||
// boarding cooldown runs (Entity.canRide) or the boat
|
||||
// is under water.
|
||||
if room && m.extra.boarding_cooldown <= 0 {
|
||||
let m = &mut self.mobs[j];
|
||||
m.extra.mount = Some(id);
|
||||
m.extra.passenger = true;
|
||||
m.ai.nav.stop();
|
||||
let mob = m.id;
|
||||
count += 1;
|
||||
// Mob.startRiding drops its lead.
|
||||
self.break_leash(mob, true, false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
self.boat_push_mob(i, j, count);
|
||||
}
|
||||
if pushes {
|
||||
self.boat_push_mob(i, j, count);
|
||||
}
|
||||
}
|
||||
for (pb, own, touched, pushes) in players {
|
||||
let Some(v) = push_impulse(self.boats[i].position, pb.center())
|
||||
.filter(|_| boat_pushes(bb, pb, false))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for _ in 0..usize::from(touched) + usize::from(pushes) {
|
||||
if count == 0 {
|
||||
let b = &mut self.boats[i];
|
||||
b.velocity = b.velocity.subtract(v);
|
||||
}
|
||||
if own && let Some(p) = self.player.as_mut() {
|
||||
p.velocity = p.velocity.add_vector(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
for k in 0..self.boats.len() {
|
||||
let ob = self.boats[k].bounds();
|
||||
if k == i || !ob.intersects(area) || !boat_pushes(bb, ob, true) {
|
||||
continue;
|
||||
}
|
||||
let Some(v) = push_impulse(self.boats[i].position, self.boats[k].position) else {
|
||||
continue;
|
||||
};
|
||||
if count == 0 {
|
||||
self.boats[i].velocity = self.boats[i].velocity.subtract(v);
|
||||
}
|
||||
if self.boat_passengers(self.boats[k].id) == 0 {
|
||||
self.boats[k].velocity = self.boats[k].velocity.add_vector(v);
|
||||
}
|
||||
}
|
||||
let guest_carts = self.guest_vehicles();
|
||||
for c in 0..self.carts.len() {
|
||||
let cb = self.carts[c].bounds();
|
||||
if !cb.intersects(area) || !boat_pushes(bb, cb, false) {
|
||||
continue;
|
||||
}
|
||||
let Some(v) = push_impulse(self.boats[i].position, self.carts[c].position) else {
|
||||
continue;
|
||||
};
|
||||
if count == 0 {
|
||||
self.boats[i].velocity = self.boats[i].velocity.subtract(v);
|
||||
}
|
||||
let cid = self.carts[c].id;
|
||||
let ridden = self.riding == Some(cid)
|
||||
|| guest_carts.contains(&cid)
|
||||
|| self.mobs.iter().any(|m| m.extra.mount == Some(cid));
|
||||
if !ridden {
|
||||
self.carts[c].velocity = self.carts[c].velocity.add_vector(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AbstractBoat.push(mob)` from boat `i`, which has `count` passengers.
|
||||
fn boat_push_mob(&mut self, i: usize, j: usize, count: usize) {
|
||||
let (b, m) = (&self.boats[i], &self.mobs[j]);
|
||||
if !boat_pushes(b.bounds(), m.bounds(), false) {
|
||||
return;
|
||||
}
|
||||
let Some(v) = push_impulse(b.position, m.position) else {
|
||||
return;
|
||||
};
|
||||
if count == 0 {
|
||||
let b = &mut self.boats[i];
|
||||
b.velocity = b.velocity.subtract(v);
|
||||
}
|
||||
let m = &mut self.mobs[j];
|
||||
if moved_by_push(m) {
|
||||
m.velocity = m.velocity.add_vector(v);
|
||||
m.needs_sync = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// `positionRider` and `clampRotation` for the mobs in boats: each sits
|
||||
/// in its seat facing the way the boat does, an animal in a full boat
|
||||
/// sideways, its head kept within its reach of its body.
|
||||
pub(super) fn seat_boat_mobs(&mut self) {
|
||||
for i in 0..self.boats.len() {
|
||||
let id = self.boats[i].id;
|
||||
let mobs = self.boat_mobs(id);
|
||||
if mobs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let players = self.boat_players(id);
|
||||
let count = players + mobs.len();
|
||||
let b = &self.boats[i];
|
||||
let full = count == b.max_passengers();
|
||||
let delta = b.delta_rotation;
|
||||
let seats: Vec<_> = mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(n, &j)| {
|
||||
let m = &self.mobs[j];
|
||||
let animal = is_animal(m.species());
|
||||
// Another player, on a server's client.
|
||||
let offset = if &*m.kind.id == "minecraft:player" {
|
||||
PLAYER_RIDING_OFFSET
|
||||
} else {
|
||||
riders::vehicle_attachment(m)
|
||||
};
|
||||
let index = players + n;
|
||||
// calculatePassengerBodyYRot.
|
||||
let turn = match (animal && full, m.id.is_multiple_of(2)) {
|
||||
(false, _) => 0.0,
|
||||
(true, true) => 90.0,
|
||||
(true, false) => 270.0,
|
||||
};
|
||||
let turns = !self
|
||||
.res
|
||||
.mob_data()
|
||||
.entity_has("minecraft:can_turn_in_boats", &m.kind.id);
|
||||
(
|
||||
j,
|
||||
b.seat(index, count, animal, offset, false),
|
||||
b.seat(index, count, animal, offset, true),
|
||||
turns.then_some((b.yaw + turn, b.previous_yaw + turn)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for (j, at, previous, facing) in seats {
|
||||
let m = &mut self.mobs[j];
|
||||
m.position = at;
|
||||
m.previous = previous;
|
||||
m.velocity = Vec3::ZERO;
|
||||
m.fall_distance = 0.0;
|
||||
m.on_ground = false;
|
||||
m.extra.passenger = true;
|
||||
m.ai.nav.stop();
|
||||
let Some((body, previous_body)) = facing else {
|
||||
continue;
|
||||
};
|
||||
m.head_yaw += delta;
|
||||
m.body_yaw = body;
|
||||
m.previous_body_yaw = previous_body;
|
||||
m.yaw = body;
|
||||
m.previous_yaw = previous_body;
|
||||
// Mob.clampHeadRotationToBody.
|
||||
let limit = m.kind.max_head_y_rot;
|
||||
let d = wrap_degrees(body - m.head_yaw);
|
||||
m.head_yaw += d - d.clamp(-limit, limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ejectPassengers` for the mobs in boat `i`: each gets out where
|
||||
/// `getDismountLocationForPassenger` puts it (beside the boat when it
|
||||
/// broke: `LivingEntity.dismountVehicle`) and can't board for a while.
|
||||
pub(super) fn eject_boat_mobs(&mut self, i: usize, broken: bool) {
|
||||
let b = &self.boats[i];
|
||||
let (id, y) = (b.id, b.position.y);
|
||||
for j in self.boat_mobs(id) {
|
||||
let m = &self.mobs[j];
|
||||
let at = if broken {
|
||||
Vec3::new(m.position.x, m.position.y.max(y), m.position.z)
|
||||
} else {
|
||||
self.boat_dismount_target(i, f64::from(m.width()), m.yaw)
|
||||
};
|
||||
let m = &mut self.mobs[j];
|
||||
m.extra.mount = None;
|
||||
m.extra.passenger = false;
|
||||
m.extra.boarding_cooldown = BOARDING_COOLDOWN;
|
||||
m.position = at;
|
||||
m.previous = at;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn push_is_capped_and_scaled() {
|
||||
// One block apart on x: the full 0.05 along x.
|
||||
let v = push_impulse(Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0)).unwrap();
|
||||
assert!((v.x - f64::from(0.05_f32)).abs() < 1e-12 && v.z == 0.0);
|
||||
// Almost on the same spot: no push.
|
||||
assert!(push_impulse(Vec3::ZERO, Vec3::new(0.005, 0.0, 0.0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boats_push_by_height() {
|
||||
let boat = Aabb::new(Vec3::new(0.0, 64.0, 0.0), Vec3::new(1.0, 64.5625, 1.0));
|
||||
let at = |y: f64| Aabb::new(Vec3::new(0.0, y, 0.0), Vec3::new(1.0, y + 1.0, 1.0));
|
||||
assert!(boat_pushes(boat, at(64.0), false));
|
||||
assert!(!boat_pushes(boat, at(64.1), false));
|
||||
assert!(boat_pushes(boat, at(64.5), true));
|
||||
assert!(!boat_pushes(boat, at(64.5625), true));
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,10 @@
|
||||
use super::{Game, Keys, entity_culling::at, view_vector};
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
collisions::SceneCollisions,
|
||||
collisions::{EntityCollisions, SceneCollisions},
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::{Part, anim::Poses},
|
||||
mobs::{Part, anim::Poses, riders},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
@@ -54,10 +54,8 @@ pub(super) struct Boat {
|
||||
}
|
||||
/// `ChestBoat.CONTAINER_SIZE`.
|
||||
pub(super) const CHEST_SLOTS: usize = 27;
|
||||
const WIDTH: f64 = 1.375;
|
||||
pub(super) const WIDTH: f64 = 1.375;
|
||||
const HEIGHT: f64 = 0.5625;
|
||||
/// Player.DEFAULT_VEHICLE_ATTACHMENT minus the boat's passenger attachment.
|
||||
pub(super) const SEAT: f64 = 0.1875 - 0.6;
|
||||
impl Boat {
|
||||
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
|
||||
Self {
|
||||
@@ -98,6 +96,54 @@ impl Boat {
|
||||
Vec3::new(p.x + w, p.y + HEIGHT, p.z + w),
|
||||
)
|
||||
}
|
||||
/// `isUnderWater`; it stands in for `canAddPassenger`'s
|
||||
/// `isEyeInFluid(WATER)` too.
|
||||
pub fn under_water(&self) -> bool {
|
||||
matches!(self.status, Status::UnderWater | Status::UnderFlowingWater)
|
||||
}
|
||||
/// `getMaxPassengers`: two, one beside a chest.
|
||||
pub fn max_passengers(&self) -> usize {
|
||||
if self.chest { 1 } else { 2 }
|
||||
}
|
||||
/// `rideHeight`: a third of a boat's height, 8/9 of a raft's.
|
||||
fn ride_height(&self) -> f64 {
|
||||
let height = HEIGHT as f32;
|
||||
f64::from(if &*self.wood == "bamboo" {
|
||||
height * 0.888_888_9
|
||||
} else {
|
||||
height / 3.0
|
||||
})
|
||||
}
|
||||
/// Where the passenger at `index` of `count` stands, at the boat's current
|
||||
/// (or, with `previous`, last) place: `getPassengerAttachmentPoint`
|
||||
/// turned with the boat, less the passenger's `rider_offset`.
|
||||
pub fn seat(
|
||||
&self,
|
||||
index: usize,
|
||||
count: usize,
|
||||
animal: bool,
|
||||
rider_offset: f64,
|
||||
previous: bool,
|
||||
) -> Vec3 {
|
||||
let mut z = if self.chest { 0.15f32 } else { 0.0 };
|
||||
if count > 1 {
|
||||
z = if index == 0 { 0.2 } else { -0.6 };
|
||||
if animal {
|
||||
z += 0.2;
|
||||
}
|
||||
}
|
||||
let (at, yaw) = if previous {
|
||||
(self.previous, self.previous_yaw)
|
||||
} else {
|
||||
(self.position, self.yaw)
|
||||
};
|
||||
riders::seat(
|
||||
[0.0, self.ride_height(), f64::from(z)],
|
||||
rider_offset,
|
||||
at,
|
||||
yaw,
|
||||
)
|
||||
}
|
||||
/// `getRowingTime`.
|
||||
pub fn rowing_time(&self, side: usize, a: f32) -> f32 {
|
||||
if self.paddling[side] {
|
||||
@@ -183,13 +229,18 @@ impl BoatModels {
|
||||
}
|
||||
impl Game<'_> {
|
||||
fn fluid_height(&self, x: i32, y: i32, z: i32) -> Option<(f32, bool)> {
|
||||
self.fluid_height_of(x, y, z, Some(Fluid::Water))
|
||||
}
|
||||
/// `FluidState.getHeight` and whether it is a source, for `fluid` or,
|
||||
/// with `None`, any fluid (`ClipContext.Fluid.ANY`).
|
||||
fn fluid_height_of(&self, x: i32, y: i32, z: i32, fluid: Option<Fluid>) -> Option<(f32, bool)> {
|
||||
let f = self.res.facts().get(self.world.block(x, y, z));
|
||||
if f.fluid != Fluid::Water {
|
||||
if f.fluid == Fluid::None || fluid.is_some_and(|want| f.fluid != want) {
|
||||
return None;
|
||||
}
|
||||
let above = self.res.facts().get(self.world.block(x, y + 1, z));
|
||||
// FluidState.getHeight: 1 under the same fluid, else amount / 9.
|
||||
let height = if above.fluid == Fluid::Water {
|
||||
let height = if above.fluid == f.fluid {
|
||||
1.0
|
||||
} else {
|
||||
f32::from(f.fluid_level) / 9.0
|
||||
@@ -315,6 +366,7 @@ impl Game<'_> {
|
||||
let mut vspeed = -0.04;
|
||||
let mut buoyancy = 0.0;
|
||||
let inv_friction: f32;
|
||||
let obstacles = self.boat_move_obstacles(&b);
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
@@ -396,6 +448,11 @@ impl Game<'_> {
|
||||
b.paddling = [right && !left || up, left && !right || up];
|
||||
}
|
||||
// Entity.move: collisions zero the blocked components.
|
||||
let mut scene = EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: &obstacles,
|
||||
border: None,
|
||||
};
|
||||
if let Ok(moved) = scene.resolve(b.bounds(), b.velocity, 0.0, b.on_ground) {
|
||||
b.position = Vec3::new(
|
||||
b.position.x + moved.x,
|
||||
@@ -461,6 +518,72 @@ impl Game<'_> {
|
||||
let b = self.boats[i].clone();
|
||||
self.boats[i] = self.tick_boat(b, false);
|
||||
}
|
||||
// AbstractBoat.tick's applyEffectsFromBlocks, on the server for every
|
||||
// boat, the client-driven ones too.
|
||||
let mut i = 0;
|
||||
while i < self.boats.len() {
|
||||
let p = self.boats[i].position;
|
||||
let skip = Some(self.boats[i].id) == self.riding
|
||||
|| !ticking(p.x.floor() as i32, p.z.floor() as i32);
|
||||
if skip || !self.boat_lava_tick(i) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
// The rest of AbstractBoat.tick on the server, for every boat: the
|
||||
// passengers of one under water too long get out, and it picks up or
|
||||
// pushes what it touches.
|
||||
for i in 0..self.boats.len() {
|
||||
let p = self.boats[i].position;
|
||||
if !ticking(p.x.floor() as i32, p.z.floor() as i32) {
|
||||
continue;
|
||||
}
|
||||
if self.boats[i].out_of_control >= 60.0 {
|
||||
self.eject_boat_mobs(i, false);
|
||||
}
|
||||
self.boat_contacts(i);
|
||||
}
|
||||
}
|
||||
/// `LavaFluid.entityInside` (`lavaHurt`) from the two
|
||||
/// `applyEffectsFromBlocks` calls in `AbstractBoat.tick`: twice 40
|
||||
/// damage, so lava breaks a boat on its first tick inside, and the fire
|
||||
/// `lavaIgnite` lights never gets to burn it. True when the boat broke.
|
||||
fn boat_lava_tick(&mut self, i: usize) -> bool {
|
||||
if !self.in_lava(self.boats[i].bounds()) {
|
||||
return false;
|
||||
}
|
||||
for _ in 0..2 {
|
||||
let p = self.boats[i].position;
|
||||
let broke = self.hurt_boat(i, 4.0, false);
|
||||
self.lava_hurt_sound([p.x, p.y, p.z]);
|
||||
if broke {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `Entity.lavaHurt`'s sound once the hurt went through.
|
||||
pub(super) fn lava_hurt_sound(&mut self, at: [f64; 3]) {
|
||||
let pitch = 2.0 + self.world.random.next_float() * 0.4;
|
||||
self.audio
|
||||
.play("minecraft:entity.generic.burn", Some(at), 0.4, pitch);
|
||||
}
|
||||
/// Whether a vehicle's box, deflated by 1e-5, reaches into lava below its
|
||||
/// surface (`Entity.collidedWithFluid`).
|
||||
pub(super) fn in_lava(&self, bb: Aabb) -> bool {
|
||||
let (lo, hi) = (bb.min_position(), bb.max_position());
|
||||
let e = 1.0e-5;
|
||||
for x in (lo.x + e).floor() as i32..=(hi.x - e).floor() as i32 {
|
||||
for y in (lo.y + e).floor() as i32..=(hi.y - e).floor() as i32 {
|
||||
for z in (lo.z + e).floor() as i32..=(hi.z - e).floor() as i32 {
|
||||
if let Some((height, _)) = self.fluid_height_of(x, y, z, Some(Fluid::Lava))
|
||||
&& lo.y + e < f64::from(y) + f64::from(height)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
/// The player's tick while riding: steer, tick the boat, sit in it.
|
||||
pub(super) fn tick_riding(&mut self, keys: Keys) -> horizon_client::Result<()> {
|
||||
@@ -482,22 +605,35 @@ impl Game<'_> {
|
||||
self.yaw += b.delta_rotation;
|
||||
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
self.yaw += delta.clamp(-105.0, 105.0) - delta;
|
||||
self.boats[i] = b;
|
||||
let seat = self.player_boat_seat(i);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
p.position = Vec3::new(b.position.x, b.position.y + SEAT, b.position.z);
|
||||
p.position = seat;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
self.boats[i] = b;
|
||||
if self.boat_lava_tick(i) {
|
||||
self.riding = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `getDismountLocationForPassenger`: in front of the player if there is
|
||||
/// floor there, else on top of the boat.
|
||||
pub(super) fn dismount(&mut self, i: usize) {
|
||||
self.riding = None;
|
||||
let target = self.boat_dismount_target(i, 0.6, self.yaw);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = target;
|
||||
p.velocity = Vec3::ZERO;
|
||||
}
|
||||
}
|
||||
/// `getDismountLocationForPassenger` for a passenger `width` wide
|
||||
/// facing `yaw`.
|
||||
pub(super) fn boat_dismount_target(&self, i: usize, width: f64, yaw: f32) -> Vec3 {
|
||||
let b = &self.boats[i];
|
||||
let r = self.yaw.to_radians();
|
||||
let distance = (WIDTH * std::f64::consts::SQRT_2 + 0.6 + 1.0e-5) / 2.0;
|
||||
let r = yaw.to_radians();
|
||||
let distance = (WIDTH * std::f64::consts::SQRT_2 + width + 1.0e-5) / 2.0;
|
||||
let (s, c) = (-(r.sin()) as f64, r.cos() as f64);
|
||||
let m = s.abs().max(c.abs());
|
||||
let (x, z) = (
|
||||
@@ -508,17 +644,13 @@ impl Game<'_> {
|
||||
let (bx, by, bz) = (x.floor() as i32, top.floor() as i32, z.floor() as i32);
|
||||
let below = self.res.facts().get(self.world.block(bx, by - 1, bz));
|
||||
let here = self.res.facts().get(self.world.block(bx, by, bz));
|
||||
let target = if below.fluid != Fluid::Water
|
||||
if below.fluid != Fluid::Water
|
||||
&& !here.full_collision
|
||||
&& (below.full_collision || below.legacy_solid)
|
||||
{
|
||||
Vec3::new(x, f64::from(by), z)
|
||||
} else {
|
||||
Vec3::new(b.position.x, top, b.position.z)
|
||||
};
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = target;
|
||||
p.velocity = Vec3::ZERO;
|
||||
}
|
||||
}
|
||||
/// `BoatItem.use`: aim at a block or the water surface.
|
||||
@@ -534,7 +666,8 @@ impl Game<'_> {
|
||||
};
|
||||
let dir = view_vector(self.yaw, self.pitch);
|
||||
let reach = self.reach();
|
||||
// ClipContext.Fluid.ANY: the first water surface or block along the ray.
|
||||
// ClipContext.Fluid.ANY: the first fluid surface (water or lava) or
|
||||
// block along the ray.
|
||||
let block = self.pick().map(|h| {
|
||||
(
|
||||
h.point,
|
||||
@@ -544,7 +677,7 @@ impl Game<'_> {
|
||||
.sqrt(),
|
||||
)
|
||||
});
|
||||
let mut water = None;
|
||||
let mut fluid = None;
|
||||
let mut t = 0.0;
|
||||
while t < reach {
|
||||
let p: [f64; 3] = std::array::from_fn(|a| eye[a] + dir[a] * t);
|
||||
@@ -553,15 +686,15 @@ impl Game<'_> {
|
||||
p[1].floor() as i32,
|
||||
p[2].floor() as i32,
|
||||
);
|
||||
if let Some((h, _)) = self.fluid_height(x, y, z)
|
||||
if let Some((h, _)) = self.fluid_height_of(x, y, z, None)
|
||||
&& p[1] <= f64::from(y) + f64::from(h)
|
||||
{
|
||||
water = Some(([p[0], f64::from(y) + f64::from(h), p[2]], t));
|
||||
fluid = Some(([p[0], f64::from(y) + f64::from(h), p[2]], t));
|
||||
break;
|
||||
}
|
||||
t += 0.05;
|
||||
}
|
||||
let hit = match (block, water) {
|
||||
let hit = match (block, fluid) {
|
||||
(Some(b), Some(w)) => {
|
||||
if w.1 < b.1 {
|
||||
w.0
|
||||
@@ -594,7 +727,7 @@ impl Game<'_> {
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
if !scene.unobstructed(inner).unwrap_or(false) {
|
||||
if !scene.unobstructed(inner).unwrap_or(false) || self.boat_entity_collision(bb) {
|
||||
return false;
|
||||
}
|
||||
self.next_entity += 1;
|
||||
@@ -649,7 +782,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
// ChestBoat.interact: sneaking (or a full seat) opens the chest.
|
||||
if self.boats[i].chest && (sneaking || self.riding.is_some()) {
|
||||
if self.boats[i].chest && (sneaking || self.riding.is_some() || !self.boat_has_room(i)) {
|
||||
let b = &self.boats[i];
|
||||
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, b.id as i32);
|
||||
self.world.block_entities.insert(
|
||||
@@ -666,6 +799,7 @@ impl Game<'_> {
|
||||
|| self.boats[i].out_of_control >= 60.0
|
||||
|| self.riding.is_some()
|
||||
|| self.ridden_by_guest(self.boats[i].id)
|
||||
|| !self.boat_has_room(i)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -682,15 +816,24 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
self.vehicle_attack_sounds();
|
||||
self.hurt_boat(i, damage, self.creative);
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// `VehicleEntity.hurtServer`: wobble, add the damage and break past 40
|
||||
/// (`creative`: a creative player's hit removes it without drops).
|
||||
/// True when the boat was removed.
|
||||
pub(super) fn hurt_boat(&mut self, i: usize, damage: f32, creative: bool) -> bool {
|
||||
let b = &mut self.boats[i];
|
||||
b.hurt_dir = -b.hurt_dir;
|
||||
b.hurt_time = 10;
|
||||
b.damage += damage * 10.0;
|
||||
let destroyed = self.creative || b.damage > 40.0;
|
||||
let destroyed = creative || b.damage > 40.0;
|
||||
if destroyed {
|
||||
self.eject_boat_mobs(i, true);
|
||||
let b = self.boats.remove(i);
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
if !self.creative && self.ui.rules.bool("entity_drops") {
|
||||
if !creative && self.ui.rules.bool("entity_drops") {
|
||||
// ChestBoat.destroy: chestVehicleDestroyed spills the contents.
|
||||
for stack in b.items.iter().flatten() {
|
||||
self.spawn_item(stack.clone(), at, super::dispenser::new_item_motion(), 10);
|
||||
@@ -709,8 +852,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
self.swing_arm();
|
||||
true
|
||||
destroyed
|
||||
}
|
||||
/// AbstractBoatRenderer.
|
||||
pub(super) fn boat_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
//! pixels, 1024 characters, 100 pages), signing it into a written book,
|
||||
//! reading written books, and lecterns (placing a book, turning its pages
|
||||
//! with a redstone pulse, the comparator output and taking the book).
|
||||
use super::Game;
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
containers::{BlockEntity, Lectern},
|
||||
first_person::Hand,
|
||||
hud::{HudBatch, rgba},
|
||||
item_data::WrittenBook,
|
||||
items::ItemStack,
|
||||
@@ -14,6 +15,7 @@ use horizon_client::{
|
||||
ui,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
|
||||
use horizon_world::level::flags as level_flags;
|
||||
use std::time::Instant;
|
||||
use winit::{
|
||||
@@ -48,9 +50,32 @@ impl Game<'_> {
|
||||
fn book_lines(&self, page: &str) -> Vec<Text> {
|
||||
self.gui.atlas.font.split(&Text::literal(page), 114.0)
|
||||
}
|
||||
/// `WritableBookItem.use` / `WrittenBookItem.use`.
|
||||
/// `WritableBookItem.use` / `WrittenBookItem.use` (`Player.openItemGui`).
|
||||
/// A server's player writes in its own client's editor
|
||||
/// (`LocalPlayer.openItemGui`), and reads on its own client once told to
|
||||
/// (`ServerPlayer.openItemGui`'s ClientboundOpenBookPacket).
|
||||
pub(super) fn use_book(&mut self, stack: &ItemStack) -> bool {
|
||||
let screen = match &*stack.item {
|
||||
let written = match &*stack.item {
|
||||
"minecraft:writable_book" => false,
|
||||
"minecraft:written_book" => true,
|
||||
_ => return false,
|
||||
};
|
||||
if self.lan_acting.is_some() {
|
||||
if written {
|
||||
let hand = i32::from(self.hand == Hand::Off);
|
||||
self.guest_packets
|
||||
.push(C::OpenBook(play::OpenBook { hand }));
|
||||
}
|
||||
} else if let Some(screen) = self.book_screen(stack) {
|
||||
self.open_book(screen);
|
||||
}
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// The screen a book in hand opens: `BookEditScreen` for a book and
|
||||
/// quill, `BookViewScreen` for a written book.
|
||||
fn book_screen(&self, stack: &ItemStack) -> Option<BookScreen> {
|
||||
Some(match &*stack.item {
|
||||
"minecraft:writable_book" => {
|
||||
let mut pages = stack
|
||||
.data()
|
||||
@@ -77,12 +102,31 @@ impl Game<'_> {
|
||||
page: 0,
|
||||
lectern: None,
|
||||
},
|
||||
_ => return false,
|
||||
};
|
||||
self.open_book(screen);
|
||||
self.swing_arm();
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// `MultiPlayerGameMode.useItem`'s own use of a book and quill on a
|
||||
/// server's client: its editor opens here at once.
|
||||
pub(super) fn remote_use_book(&mut self, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:writable_book" {
|
||||
return false;
|
||||
}
|
||||
if let Some(screen) = self.book_screen(stack) {
|
||||
self.open_book(screen);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `ClientPacketListener.handleOpenBook`: the written book in that hand.
|
||||
pub(super) fn remote_open_book(&mut self, hand: i32) {
|
||||
let hand = if hand == 1 { Hand::Off } else { Hand::Main };
|
||||
let screen = self.with_hand(hand, |g| {
|
||||
let stack = g.held().filter(|s| &*s.item == "minecraft:written_book")?;
|
||||
g.book_screen(&stack.clone())
|
||||
});
|
||||
if let Some(screen) = screen {
|
||||
self.open_book(screen);
|
||||
}
|
||||
}
|
||||
fn open_book(&mut self, screen: BookScreen) {
|
||||
self.book = Some(screen);
|
||||
self.keys.clear();
|
||||
@@ -104,7 +148,9 @@ impl Game<'_> {
|
||||
self.book = None;
|
||||
self.capture(true);
|
||||
}
|
||||
/// `BookEditScreen.saveChanges`: trailing empty pages dropped.
|
||||
/// `BookEditScreen.saveChanges` and `BookSignScreen`'s Sign and Close:
|
||||
/// trailing empty pages dropped, the book in hand updated here and, on a
|
||||
/// server's client, sent (ServerboundEditBookPacket).
|
||||
fn save_book(&mut self, sign: bool) {
|
||||
let Some(BookScreen::Edit {
|
||||
slot, pages, title, ..
|
||||
@@ -116,29 +162,59 @@ impl Game<'_> {
|
||||
while pages.len() > 1 && pages.last().is_some_and(String::is_empty) {
|
||||
pages.pop();
|
||||
}
|
||||
if let Some(stack) = self.stack_in_mut(slot).as_mut()
|
||||
&& &*stack.item == "minecraft:writable_book"
|
||||
{
|
||||
if sign {
|
||||
let mut written = ItemStack::new("minecraft:written_book", 1);
|
||||
written.data_mut().written = Some(WrittenBook {
|
||||
title: title.trim().to_owned(),
|
||||
author: super::chat::PLAYER_NAME.to_owned(),
|
||||
generation: 0,
|
||||
pages,
|
||||
});
|
||||
*stack = written;
|
||||
} else {
|
||||
stack.data_mut().writable_pages = if pages.iter().all(String::is_empty) {
|
||||
Vec::new()
|
||||
} else {
|
||||
pages
|
||||
};
|
||||
stack.tidy();
|
||||
}
|
||||
let title = sign.then(|| title.trim().to_owned());
|
||||
if self.remote.is_some() {
|
||||
self.send(S::EditBook(play::EditBook {
|
||||
slot: slot as i32,
|
||||
pages: pages.clone(),
|
||||
title: title.clone(),
|
||||
}));
|
||||
}
|
||||
self.write_book(slot, pages, title);
|
||||
self.capture(true);
|
||||
}
|
||||
/// The book and quill at `slot` takes the pages, or with a title becomes
|
||||
/// the written book its writer signed.
|
||||
fn write_book(&mut self, slot: usize, pages: Vec<String>, title: Option<String>) {
|
||||
let author = self
|
||||
.lan_acting_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| super::chat::PLAYER_NAME.to_owned());
|
||||
let Some(stack) = self
|
||||
.stack_in_mut(slot)
|
||||
.as_mut()
|
||||
.filter(|s| &*s.item == "minecraft:writable_book")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Some(title) = title {
|
||||
let mut written = ItemStack::new("minecraft:written_book", 1);
|
||||
written.data_mut().written = Some(WrittenBook {
|
||||
title,
|
||||
author,
|
||||
generation: 0,
|
||||
pages,
|
||||
});
|
||||
*stack = written;
|
||||
} else {
|
||||
stack.data_mut().writable_pages = if pages.iter().all(String::is_empty) {
|
||||
Vec::new()
|
||||
} else {
|
||||
pages
|
||||
};
|
||||
stack.tidy();
|
||||
}
|
||||
}
|
||||
/// `ServerGamePacketListenerImpl.handleEditBook`: a guest's book in a
|
||||
/// hotbar slot or its off hand (`updateBookContents`, `signBook`).
|
||||
pub(super) fn lan_edit_book(&mut self, host: &mut LanHost, conn: u32, p: play::EditBook) {
|
||||
if !((0..9).contains(&p.slot) || p.slot == super::shield::OFFHAND_SLOT as i32) {
|
||||
return;
|
||||
}
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.write_book(p.slot as usize, p.pages, p.title)
|
||||
});
|
||||
}
|
||||
pub(super) fn book_key(&mut self, event: &KeyEvent) {
|
||||
if event.state != ElementState::Pressed {
|
||||
return;
|
||||
@@ -519,7 +595,26 @@ impl Game<'_> {
|
||||
Some(key) => self.lectern_button(key, 3),
|
||||
None => return Err("not a lectern".into()),
|
||||
},
|
||||
Some("done") => self.close_book(),
|
||||
// The book and quill's buttons: Sign, then Sign and Close; Done
|
||||
// (which saves), then Cancel.
|
||||
Some("sign") => match self.book.as_mut() {
|
||||
Some(BookScreen::Edit {
|
||||
signing: true,
|
||||
title,
|
||||
..
|
||||
}) if !title.trim().is_empty() => self.save_book(true),
|
||||
Some(BookScreen::Edit { signing, .. }) => *signing = true,
|
||||
_ => return Err("not a book and quill".into()),
|
||||
},
|
||||
Some("done") => match self.book.as_mut() {
|
||||
Some(BookScreen::Edit { signing: true, .. }) => {
|
||||
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
|
||||
*signing = false;
|
||||
}
|
||||
}
|
||||
Some(BookScreen::Edit { .. }) => self.save_book(false),
|
||||
_ => self.close_book(),
|
||||
},
|
||||
Some(other) => return Err(format!("unknown book button {other}").into()),
|
||||
}
|
||||
Ok(match &self.book {
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
//! `/bossbar` (BossBarCommands) and drawing the custom boss bars.
|
||||
use super::{
|
||||
Game,
|
||||
commands::{R, err},
|
||||
Dim, Game,
|
||||
commands::{R, Target, err},
|
||||
};
|
||||
use horizon_client::{
|
||||
boss_overlay,
|
||||
bossbar::{self, BossBars},
|
||||
command::{Arg, Context},
|
||||
hud_overlays, save,
|
||||
scoreboard::{self as sb, Comp, comp_text},
|
||||
mobs::Species,
|
||||
save,
|
||||
scoreboard::{self as sb, Comp, json_to_tag},
|
||||
scoreboard_cmd::format_list,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_protocol::packets::play::{self, BossAction, Clientbound as C};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct BossState {
|
||||
pub bars: BossBars,
|
||||
loaded: bool,
|
||||
/// What each viewer was last sent, so a change becomes one packet.
|
||||
sent: HashMap<u128, HashMap<u128, Offer>>,
|
||||
}
|
||||
|
||||
fn num(n: impl std::fmt::Display) -> Text {
|
||||
@@ -36,7 +43,7 @@ impl Game<'_> {
|
||||
if let Some(root) = save::read_dat(&self.bossbar_path())
|
||||
&& let Some(data) = root.get("data")
|
||||
{
|
||||
self.bosses.bars = BossBars::unpack(data, self.level.player_uuid);
|
||||
self.bosses.bars = BossBars::unpack(data);
|
||||
}
|
||||
self.ui.registries.bossbars = self.bosses.bars.ids();
|
||||
}
|
||||
@@ -47,13 +54,15 @@ impl Game<'_> {
|
||||
if self.bosses.bars.bars.is_empty() && !self.bossbar_path().exists() {
|
||||
return;
|
||||
}
|
||||
let _ = save::write_dat(
|
||||
if let Err(e) = save::write_dat(
|
||||
&self.bossbar_path(),
|
||||
Tag::compound([
|
||||
("data", self.bosses.bars.pack(self.level.player_uuid)),
|
||||
("data", self.bosses.bars.pack()),
|
||||
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
|
||||
]),
|
||||
);
|
||||
) {
|
||||
log::warn!("boss bars were not written: {e}");
|
||||
}
|
||||
}
|
||||
pub(in crate::game) fn bossbar_exists(&self, id: &str) -> bool {
|
||||
self.bosses.bars.get(id).is_some()
|
||||
@@ -68,6 +77,7 @@ impl Game<'_> {
|
||||
b.value = b.value.min(b.max);
|
||||
}
|
||||
}
|
||||
self.publish_boss_bars();
|
||||
}
|
||||
fn bar_id(ctx: &Context) -> String {
|
||||
match ctx.get("id") {
|
||||
@@ -86,6 +96,7 @@ impl Game<'_> {
|
||||
self.load_bossbars();
|
||||
let result = self.bossbar_command(id, ctx);
|
||||
self.ui.registries.bossbars = self.bosses.bars.ids();
|
||||
self.publish_boss_bars();
|
||||
result
|
||||
}
|
||||
fn bossbar_command(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
@@ -97,6 +108,10 @@ impl Game<'_> {
|
||||
}
|
||||
let name = self.component_json(ctx, "name")?;
|
||||
self.bosses.bars.add(&bar, name);
|
||||
if let Some(b) = self.bosses.bars.get_mut(&bar) {
|
||||
// CustomBossEvents.create: Mth.createInsecureUUID(level.random).
|
||||
b.uuid = insecure_uuid(&mut self.world.random);
|
||||
}
|
||||
let shown = self
|
||||
.bosses
|
||||
.bars
|
||||
@@ -173,19 +188,20 @@ impl Game<'_> {
|
||||
i32::from(b.visible)
|
||||
}
|
||||
_ => {
|
||||
if b.player {
|
||||
let name = self.target_name(super::commands::Target::Player);
|
||||
self.feedback(Text::translate(
|
||||
"commands.bossbar.get.players.some",
|
||||
&[s, num(1), format_list(vec![name])],
|
||||
));
|
||||
} else {
|
||||
let names = self.bossbar_player_names(&b.players);
|
||||
let n = names.len();
|
||||
if n == 0 {
|
||||
self.feedback(Text::translate(
|
||||
"commands.bossbar.get.players.none",
|
||||
&[s],
|
||||
));
|
||||
} else {
|
||||
self.feedback(Text::translate(
|
||||
"commands.bossbar.get.players.some",
|
||||
&[s, num(n), format_list(names)],
|
||||
));
|
||||
}
|
||||
i32::from(b.player)
|
||||
n as i32
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -295,31 +311,46 @@ impl Game<'_> {
|
||||
Ok(0)
|
||||
}
|
||||
_ => {
|
||||
// players [<targets>]
|
||||
let include = if ctx.get("targets").is_some() {
|
||||
!self.entities(ctx, "targets", true)?.is_empty()
|
||||
// players [<targets>]: replace the set. No argument clears it.
|
||||
let targets = if ctx.get("targets").is_some() {
|
||||
self.entities(ctx, "targets", true)?
|
||||
} else {
|
||||
false
|
||||
Vec::new()
|
||||
};
|
||||
let mut uuids = Vec::new();
|
||||
let mut names = Vec::new();
|
||||
for t in &targets {
|
||||
let Some(uuid) = self.player_target_uuid(*t) else {
|
||||
continue;
|
||||
};
|
||||
if !uuids.contains(&uuid) {
|
||||
uuids.push(uuid);
|
||||
names.push(self.target_name(*t));
|
||||
}
|
||||
}
|
||||
let b = self.bosses.bars.get_mut(bar).expect("checked");
|
||||
if b.player == include {
|
||||
let mut had = b.players.clone();
|
||||
had.sort();
|
||||
let mut next = uuids.clone();
|
||||
next.sort();
|
||||
if had == next {
|
||||
return Err(err("commands.bossbar.set.players.unchanged", &[]));
|
||||
}
|
||||
b.player = include;
|
||||
b.players = uuids;
|
||||
let n = b.players.len();
|
||||
let s = shown(self);
|
||||
if include {
|
||||
let name = self.target_name(super::commands::Target::Player);
|
||||
self.feedback(Text::translate(
|
||||
"commands.bossbar.set.players.success.some",
|
||||
&[s, num(1), format_list(vec![name])],
|
||||
));
|
||||
} else {
|
||||
if n == 0 {
|
||||
self.feedback(Text::translate(
|
||||
"commands.bossbar.set.players.success.none",
|
||||
&[s],
|
||||
));
|
||||
} else {
|
||||
self.feedback(Text::translate(
|
||||
"commands.bossbar.set.players.success.some",
|
||||
&[s, num(targets.len()), format_list(names)],
|
||||
));
|
||||
}
|
||||
Ok(i32::from(include))
|
||||
Ok(n as i32)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,29 +364,316 @@ impl Game<'_> {
|
||||
Ok(self.resolve_component(value))
|
||||
}
|
||||
|
||||
// ---- drawing ------------------------------------------------------------------
|
||||
|
||||
/// The visible custom bars (`/bossbar`), after the built-in ones.
|
||||
pub(super) fn custom_bossbars(&self, bars: &mut Vec<hud_overlays::BossBar>) {
|
||||
for b in self
|
||||
.bosses
|
||||
.bars
|
||||
.bars
|
||||
.iter()
|
||||
.filter(|b| b.visible && b.player)
|
||||
{
|
||||
bars.push(hud_overlays::BossBar {
|
||||
name: comp_text(&b.name),
|
||||
progress: b.progress(),
|
||||
color: bossbar::COLORS
|
||||
/// Names of the online players in a boss bar's set, in join order.
|
||||
fn bossbar_player_names(&self, players: &[[i32; 4]]) -> Vec<Text> {
|
||||
self.known_boss_players()
|
||||
.into_iter()
|
||||
.filter(|(uuid, _)| players.contains(uuid))
|
||||
.map(|(_, name)| name)
|
||||
.collect()
|
||||
}
|
||||
/// The players who can be told about a bar: the one in place, then guests.
|
||||
fn known_boss_players(&self) -> Vec<([i32; 4], Text)> {
|
||||
let mut out = Vec::new();
|
||||
if self.player.is_some() {
|
||||
out.push((self.acting_uuid(), self.target_name(Target::Player)));
|
||||
}
|
||||
if let Some(host) = &self.lan {
|
||||
for g in host.hub.guests.iter().filter(|g| g.placed) {
|
||||
let ints = horizon_client::lan::playerdata::uuid_ints(g.uuid);
|
||||
if out.iter().any(|(uuid, _)| *uuid == ints) {
|
||||
continue;
|
||||
}
|
||||
out.push((ints, Text::literal(g.name.clone())));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
fn player_target_uuid(&self, target: Target) -> Option<[i32; 4]> {
|
||||
match target {
|
||||
Target::Player => Some(self.acting_uuid()),
|
||||
Target::Guest(conn) => self.lan.as_ref().and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.position(|c| *c == b.color)
|
||||
.unwrap_or(0),
|
||||
overlay: bossbar::STYLES
|
||||
.iter()
|
||||
.position(|s| *s == b.style)
|
||||
.unwrap_or(0),
|
||||
});
|
||||
.find(|g| g.conn == conn)
|
||||
.map(|g| horizon_client::lan::playerdata::uuid_ints(g.uuid))
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ServerBossEvent delivery ------------------------------------------------
|
||||
|
||||
/// Tell every viewer the boss events they should be seeing. The window
|
||||
/// that simulates the world applies them to its own overlay; everyone
|
||||
/// else gets `ClientboundBossEventPacket`s.
|
||||
pub(super) fn publish_boss_bars(&mut self) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
self.load_bossbars();
|
||||
let now = boss_overlay::now_millis();
|
||||
let viewers = self.boss_viewers();
|
||||
let live: HashSet<u128> = viewers.iter().map(|v| v.uuid).collect();
|
||||
self.bosses.sent.retain(|id, _| live.contains(id));
|
||||
let mut packets = Vec::new();
|
||||
for viewer in &viewers {
|
||||
let desired = self.boss_events_for(viewer);
|
||||
for event in self.diff_boss_events(viewer, desired) {
|
||||
if viewer.local {
|
||||
self.boss_overlay.apply(&event, now);
|
||||
} else if let Some(conn) = viewer.conn {
|
||||
packets.push((conn, C::BossEvent(event)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if packets.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return;
|
||||
};
|
||||
for (conn, packet) in &packets {
|
||||
host.hub.send_to(*conn, packet);
|
||||
}
|
||||
}
|
||||
fn boss_viewers(&self) -> Vec<Viewer> {
|
||||
let mut out = Vec::new();
|
||||
// While `as_guest` has swapped a guest in, the host's own position
|
||||
// is not in place. Their overlay updates once the tick puts it back.
|
||||
let local =
|
||||
self.serving.is_none() && self.options.dedicated.is_none() && self.lan_acting.is_none();
|
||||
if local && let Some(p) = &self.player {
|
||||
let ints = self.level.player_uuid.unwrap_or_else(|| self.acting_uuid());
|
||||
out.push(Viewer {
|
||||
uuid: horizon_client::lan::playerdata::ints_uuid(ints),
|
||||
ints,
|
||||
at: [p.position.x, p.position.y, p.position.z],
|
||||
alive: !p.health.dead_or_dying(),
|
||||
here: true,
|
||||
local: true,
|
||||
conn: None,
|
||||
view_chunks: self.settings.render_distance,
|
||||
});
|
||||
}
|
||||
if let Some(host) = &self.lan {
|
||||
for g in host.hub.guests.iter().filter(|g| g.placed) {
|
||||
if out.iter().any(|v| v.uuid == g.uuid) {
|
||||
continue;
|
||||
}
|
||||
out.push(Viewer {
|
||||
uuid: g.uuid,
|
||||
ints: horizon_client::lan::playerdata::uuid_ints(g.uuid),
|
||||
at: g.position,
|
||||
alive: !g.dead,
|
||||
here: g.dimension == self.dim.id(),
|
||||
local: false,
|
||||
conn: Some(g.conn),
|
||||
view_chunks: g.view_distance,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Custom bars (`CustomBossEvents.onPlayerConnect` sends them as a player
|
||||
/// joins), then the dragon, withers and the raid.
|
||||
fn boss_events_for(&self, viewer: &Viewer) -> Vec<Offer> {
|
||||
let mut out = Vec::new();
|
||||
for b in &self.bosses.bars.bars {
|
||||
if !b.visible || !b.players.contains(&viewer.ints) {
|
||||
continue;
|
||||
}
|
||||
out.push(Offer {
|
||||
id: b.uuid,
|
||||
name: json_to_tag(&b.name),
|
||||
progress: b.progress(),
|
||||
color: color_index(&b.color),
|
||||
overlay: style_index(&b.style),
|
||||
darken: b.darken,
|
||||
music: b.music,
|
||||
fog: b.fog,
|
||||
scope: Scope::Global,
|
||||
});
|
||||
}
|
||||
if viewer.here
|
||||
&& let Some(progress) = self.dragon_boss_progress(viewer.at, viewer.alive)
|
||||
{
|
||||
out.push(Offer {
|
||||
id: bossbar::stable_uuid("minecraft:ender_dragon"),
|
||||
name: translate_tag("entity.minecraft.ender_dragon"),
|
||||
progress,
|
||||
color: 0,
|
||||
overlay: 0,
|
||||
darken: false,
|
||||
music: true,
|
||||
fog: true,
|
||||
scope: Scope::Level(self.dim),
|
||||
});
|
||||
}
|
||||
if viewer.here {
|
||||
let range = f64::from(10.min(viewer.view_chunks.max(0)) * 16);
|
||||
for m in self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.species() == Species::Wither && m.alive() && !m.removed)
|
||||
{
|
||||
let (dx, dz) = (m.position.x - viewer.at[0], m.position.z - viewer.at[2]);
|
||||
if dx * dx + dz * dz > range * range {
|
||||
continue;
|
||||
}
|
||||
let progress = if m.extra.counter > 0 {
|
||||
1.0 - m.extra.counter as f32 / horizon_client::mobs::wither::INVULNERABLE as f32
|
||||
} else {
|
||||
(m.health / m.attrs.max_health).clamp(0.0, 1.0)
|
||||
};
|
||||
let name = if let Some(text) = &m.custom_name_text {
|
||||
json_to_tag(&text.component())
|
||||
} else if let Some(name) = &m.custom_name {
|
||||
Tag::String(name.as_str().into())
|
||||
} else {
|
||||
translate_tag("entity.minecraft.wither")
|
||||
};
|
||||
out.push(Offer {
|
||||
id: super::lan::entity_uuid(m.id),
|
||||
name,
|
||||
progress,
|
||||
color: 5,
|
||||
overlay: 0,
|
||||
darken: true,
|
||||
music: false,
|
||||
fog: false,
|
||||
scope: Scope::Level(self.dim),
|
||||
});
|
||||
}
|
||||
}
|
||||
if viewer.here
|
||||
&& let Some((id, name, progress)) = self.raid_boss_at(viewer.at, viewer.alive)
|
||||
{
|
||||
out.push(Offer {
|
||||
id: bossbar::stable_uuid(&format!("raid:{id}")),
|
||||
name,
|
||||
progress,
|
||||
color: 2,
|
||||
overlay: 2,
|
||||
darken: false,
|
||||
music: false,
|
||||
fog: false,
|
||||
scope: Scope::Level(self.dim),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The packets that take `viewer` from what it was last sent to `desired`
|
||||
/// (`ServerBossEvent`'s broadcasts).
|
||||
fn diff_boss_events(&mut self, viewer: &Viewer, desired: Vec<Offer>) -> Vec<play::BossEvent> {
|
||||
let full = viewer.local;
|
||||
let dim = self.dim;
|
||||
let sent = self.bosses.sent.entry(viewer.uuid).or_default();
|
||||
let mut out = Vec::new();
|
||||
let event = |id, action| play::BossEvent { id, action };
|
||||
sent.retain(|id, prev| {
|
||||
let owned = full || prev.scope == Scope::Global || prev.scope == Scope::Level(dim);
|
||||
let keep = !owned || desired.iter().any(|e| e.id == *id);
|
||||
if !keep {
|
||||
out.push(event(*id, BossAction::Remove));
|
||||
}
|
||||
keep
|
||||
});
|
||||
for offer in desired {
|
||||
let Some(prev) = sent.get(&offer.id) else {
|
||||
out.push(event(
|
||||
offer.id,
|
||||
BossAction::Add {
|
||||
name: offer.name.clone(),
|
||||
progress: offer.progress,
|
||||
color: offer.color,
|
||||
overlay: offer.overlay,
|
||||
darken: offer.darken,
|
||||
music: offer.music,
|
||||
fog: offer.fog,
|
||||
},
|
||||
));
|
||||
sent.insert(offer.id, offer);
|
||||
continue;
|
||||
};
|
||||
let id = offer.id;
|
||||
if prev.progress != offer.progress {
|
||||
let progress = offer.progress;
|
||||
out.push(event(id, BossAction::UpdateProgress { progress }));
|
||||
}
|
||||
if prev.name != offer.name {
|
||||
let name = offer.name.clone();
|
||||
out.push(event(id, BossAction::UpdateName { name }));
|
||||
}
|
||||
if (prev.color, prev.overlay) != (offer.color, offer.overlay) {
|
||||
let (color, overlay) = (offer.color, offer.overlay);
|
||||
out.push(event(id, BossAction::UpdateStyle { color, overlay }));
|
||||
}
|
||||
if (prev.darken, prev.music, prev.fog) != (offer.darken, offer.music, offer.fog) {
|
||||
let (darken, music, fog) = (offer.darken, offer.music, offer.fog);
|
||||
out.push(event(
|
||||
id,
|
||||
BossAction::UpdateProperties { darken, music, fog },
|
||||
));
|
||||
}
|
||||
sent.insert(id, offer);
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `ClientPacketListener.handleBossUpdate`.
|
||||
pub(super) fn apply_boss_event(&mut self, packet: &play::BossEvent) {
|
||||
self.boss_overlay.apply(packet, boss_overlay::now_millis());
|
||||
}
|
||||
}
|
||||
|
||||
/// `Mth.createInsecureUUID`.
|
||||
fn insecure_uuid(random: &mut impl RandomSource) -> u128 {
|
||||
let most = random.next_long() & -61441 | 16384;
|
||||
let least = random.next_long() & 4_611_686_018_427_387_903 | i64::MIN;
|
||||
((most as u64 as u128) << 64) | (least as u64 as u128)
|
||||
}
|
||||
fn translate_tag(key: &str) -> Tag {
|
||||
Tag::compound([("translate", Tag::String(key.into()))])
|
||||
}
|
||||
fn color_index(color: &str) -> i32 {
|
||||
bossbar::COLORS
|
||||
.iter()
|
||||
.position(|c| *c == color)
|
||||
.unwrap_or(6) as i32
|
||||
}
|
||||
fn style_index(style: &str) -> i32 {
|
||||
bossbar::STYLES
|
||||
.iter()
|
||||
.position(|s| *s == style)
|
||||
.unwrap_or(0) as i32
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Scope {
|
||||
Global,
|
||||
Level(Dim),
|
||||
}
|
||||
struct Viewer {
|
||||
uuid: u128,
|
||||
ints: [i32; 4],
|
||||
at: [f64; 3],
|
||||
alive: bool,
|
||||
here: bool,
|
||||
local: bool,
|
||||
conn: Option<u32>,
|
||||
view_chunks: i32,
|
||||
}
|
||||
/// A boss event as one viewer should see it, and as it was last sent.
|
||||
struct Offer {
|
||||
id: u128,
|
||||
name: Tag,
|
||||
progress: f32,
|
||||
/// `BossBarColor` and `BossBarOverlay` ordinals.
|
||||
color: i32,
|
||||
overlay: i32,
|
||||
darken: bool,
|
||||
music: bool,
|
||||
fog: bool,
|
||||
scope: Scope,
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Chat, the command registries and the per-world state behind commands
|
||||
//! (game rules, the world clock, difficulty), bound to the game.
|
||||
use super::{Dim, Game, Screen};
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
chat::{Chat, ChatAction, ChatScreen, Commands, SYSTEM_INDICATOR},
|
||||
command::Registries,
|
||||
@@ -570,8 +570,14 @@ impl Game<'_> {
|
||||
/// ChatScreen.handleChatInput: history, then a command or a chat line.
|
||||
pub(super) fn handle_chat_input(&mut self, message: &str) {
|
||||
self.ui.chat.add_recent(message);
|
||||
// On a server (single player's own included) the server runs it.
|
||||
// On a server (single player's own included) the server runs it,
|
||||
// unless a client mod owns the command.
|
||||
if self.remote.is_some() {
|
||||
if let Some(command) = message.strip_prefix('/')
|
||||
&& self.mod_command(command)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.send_chat(message);
|
||||
return;
|
||||
}
|
||||
@@ -695,10 +701,7 @@ impl Game<'_> {
|
||||
/// Advance the overworld clock (ServerClockManager.tick) when
|
||||
/// `advance_time` allows it.
|
||||
pub(super) fn advance_clock(&mut self) {
|
||||
if !self.ui.rules.bool("advance_time")
|
||||
|| self.ui.clock.paused
|
||||
|| self.dim == Dim::Nether && false
|
||||
{
|
||||
if !self.ui.rules.bool("advance_time") || self.ui.clock.paused {
|
||||
return;
|
||||
}
|
||||
let c = &mut self.ui.clock;
|
||||
@@ -819,8 +822,12 @@ impl Game<'_> {
|
||||
("data", data),
|
||||
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
|
||||
]);
|
||||
let _ = horizon_client::save::write_dat(&path, root);
|
||||
let _ = write_difficulty(&self.world_root, self.ui.difficulty);
|
||||
if let Err(e) = horizon_client::save::write_dat(&path, root) {
|
||||
log::warn!("the world clocks were not written: {e}");
|
||||
}
|
||||
if let Err(e) = write_difficulty(&self.world_root, self.ui.difficulty) {
|
||||
log::warn!("the difficulty was not written: {e}");
|
||||
}
|
||||
}
|
||||
/// Death message in chat (CombatTracker.getDeathMessage, as far as the
|
||||
/// cause is known to the client).
|
||||
|
||||
@@ -42,7 +42,8 @@ pub(super) enum Target {
|
||||
Mob(u32),
|
||||
Item(u32),
|
||||
Orb(usize),
|
||||
Arrow(usize),
|
||||
/// By index in `creatures.projectiles`.
|
||||
Projectile(usize),
|
||||
Stand(u32),
|
||||
Frame(u32),
|
||||
Painting(u32),
|
||||
@@ -327,7 +328,7 @@ impl Game<'_> {
|
||||
.orbs
|
||||
.get(i)
|
||||
.map(|o| [o.position.x, o.position.y, o.position.z]),
|
||||
Target::Arrow(i) => self
|
||||
Target::Projectile(i) => self
|
||||
.creatures
|
||||
.projectiles
|
||||
.get(i)
|
||||
@@ -382,7 +383,11 @@ impl Game<'_> {
|
||||
}),
|
||||
Target::Item(_) => "minecraft:item".into(),
|
||||
Target::Orb(_) => "minecraft:experience_orb".into(),
|
||||
Target::Arrow(_) => "minecraft:arrow".into(),
|
||||
Target::Projectile(i) => self
|
||||
.creatures
|
||||
.projectiles
|
||||
.get(i)
|
||||
.map_or_else(String::new, |p| p.kind.id().into()),
|
||||
Target::Stand(_) => "minecraft:armor_stand".into(),
|
||||
Target::Frame(id) => {
|
||||
if self.item_frames.iter().any(|f| f.id == id && f.glow) {
|
||||
@@ -503,6 +508,7 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
if s.kind != 's' {
|
||||
use horizon_client::projectiles::ProjectileKind as K;
|
||||
candidates.extend(self.player_candidates(s));
|
||||
candidates.extend(
|
||||
self.mobs
|
||||
@@ -517,8 +523,12 @@ impl Game<'_> {
|
||||
.projectiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| p.arrow.is_some())
|
||||
.map(|(i, _)| Target::Arrow(i)),
|
||||
// The kinds /data can write (entity_nbt).
|
||||
.filter(|(_, p)| {
|
||||
p.arrow.is_some()
|
||||
|| matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
|
||||
})
|
||||
.map(|(i, _)| Target::Projectile(i)),
|
||||
);
|
||||
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
|
||||
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
|
||||
@@ -1082,6 +1092,8 @@ impl Game<'_> {
|
||||
self.for_player(t, |g| g.teleport_to(Target::Player, pos, rotation));
|
||||
}
|
||||
Target::Player => {
|
||||
// ServerPlayer.teleport: removeVehicle before placing the player.
|
||||
self.stop_riding();
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = v;
|
||||
p.velocity = Vec3::new(0.0, 0.0, 0.0);
|
||||
@@ -1098,9 +1110,15 @@ impl Game<'_> {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.position = v;
|
||||
m.previous = v;
|
||||
if let Some([yaw, _]) = rotation {
|
||||
// Entity.teleportSetPosition: setYRot, setYHeadRot, setXRot
|
||||
// (clamped to -90..90), then setOldRot.
|
||||
if let Some([yaw, pitch]) = rotation {
|
||||
m.yaw = yaw;
|
||||
m.body_yaw = yaw;
|
||||
m.head_yaw = yaw;
|
||||
m.head_pitch = pitch.clamp(-90.0, 90.0);
|
||||
m.previous_yaw = m.yaw;
|
||||
m.previous_head_pitch = m.head_pitch;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1116,7 +1134,7 @@ impl Game<'_> {
|
||||
o.previous = v;
|
||||
}
|
||||
}
|
||||
Target::Arrow(i) => {
|
||||
Target::Projectile(i) => {
|
||||
if let Some(a) = self.creatures.projectiles.get_mut(i) {
|
||||
a.position = v;
|
||||
a.previous = v;
|
||||
@@ -1540,14 +1558,10 @@ impl Game<'_> {
|
||||
self.orbs[i].age = u32::MAX / 2;
|
||||
}
|
||||
}
|
||||
Target::Arrow(i) => {
|
||||
if let Some(a) = self
|
||||
.creatures
|
||||
.projectiles
|
||||
.get_mut(i)
|
||||
.and_then(|p| p.arrow.as_mut())
|
||||
{
|
||||
a.removed = true;
|
||||
// cmd_kill goes from the highest index down.
|
||||
Target::Projectile(i) => {
|
||||
if i < self.creatures.projectiles.len() {
|
||||
self.creatures.projectiles.remove(i);
|
||||
}
|
||||
}
|
||||
Target::Stand(id) => self.armor_stands.retain(|a| a.id != id),
|
||||
@@ -1569,7 +1583,7 @@ impl Game<'_> {
|
||||
let mut ordered = targets.clone();
|
||||
ordered.sort_by_key(|t| {
|
||||
std::cmp::Reverse(match t {
|
||||
Target::Orb(i) | Target::Arrow(i) => *i,
|
||||
Target::Orb(i) | Target::Projectile(i) => *i,
|
||||
_ => 0,
|
||||
})
|
||||
});
|
||||
@@ -1577,9 +1591,6 @@ impl Game<'_> {
|
||||
self.kill_target(t);
|
||||
}
|
||||
self.orbs.retain(|o| o.age < u32::MAX / 2);
|
||||
self.creatures
|
||||
.projectiles
|
||||
.retain(|p| !p.arrow.as_ref().is_some_and(|a| a.removed));
|
||||
let message = if targets.len() == 1 {
|
||||
Text::translate("commands.kill.success.single", &[names[0].clone()])
|
||||
} else {
|
||||
@@ -1783,8 +1794,11 @@ impl Game<'_> {
|
||||
self.level_set(pos, state)
|
||||
.map_err(|e| CommandError::new(Text::literal(e.to_string())))?;
|
||||
}
|
||||
// LevelChunk.setBlockState creates it when the block, not the state, is still
|
||||
// there after onPlace: a powered hopper turns itself `enabled=false`.
|
||||
let landed = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if old != new
|
||||
&& self.world.block(pos[0], pos[1], pos[2]) == state
|
||||
&& self.res.facts().get(landed).name == new
|
||||
&& let Some(entity) = horizon_client::containers::BlockEntity::for_block(&new)
|
||||
{
|
||||
self.world.block_entities.insert(key, entity);
|
||||
|
||||
@@ -65,9 +65,10 @@ impl Game<'_> {
|
||||
tag
|
||||
}
|
||||
Target::Mob(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
let m = self.mobs.iter().find(|m| m.id == id)?;
|
||||
entity_nbt::mob_to_nbt(m, uuid, self.persist.raw.get(&id))
|
||||
// `saveWithoutId` writes the entity as saved, riders under
|
||||
// `Passengers`: `/data get` reads the same tree persistence does (#1519).
|
||||
let i = self.mobs.iter().position(|m| m.id == id)?;
|
||||
self.mob_tree_nbt(i)
|
||||
}
|
||||
Target::Item(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
@@ -113,7 +114,7 @@ impl Game<'_> {
|
||||
self.other_to_nbt(&o, uuid)
|
||||
}
|
||||
Target::Orb(i) => entity_nbt::orb_to_nbt(self.orbs.get(i)?, random_uuid()),
|
||||
Target::Arrow(i) => {
|
||||
Target::Projectile(i) => {
|
||||
let p = self.creatures.projectiles.get(i)?;
|
||||
let owner = if p.owner == Some(horizon_client::mobs::Who::Player) {
|
||||
player_uuid
|
||||
@@ -125,7 +126,8 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.and_then(|a| a.last_state)
|
||||
.map(|s| self.state_to_nbt(s));
|
||||
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)?
|
||||
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)
|
||||
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))?
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -59,7 +59,15 @@ impl Game<'_> {
|
||||
self.recipe_unlocked(&unlocked);
|
||||
count as i32
|
||||
} else {
|
||||
self.level.recipe_book.remove_recipes(index, &recipes) as i32
|
||||
let removed = self.level.recipe_book.remove_recipes(index, &recipes);
|
||||
let count = removed.len() as i32;
|
||||
// A network player's client drops them from its book.
|
||||
if self.lan_acting.is_some() && !removed.is_empty() {
|
||||
self.guest_packets.push(C::RecipeBookRemove(
|
||||
horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed },
|
||||
));
|
||||
}
|
||||
count
|
||||
};
|
||||
tracker.track(&h, n);
|
||||
}
|
||||
@@ -345,7 +353,26 @@ impl Game<'_> {
|
||||
Some(Arg::Resource(s)) => Some(s.clone()),
|
||||
_ => None,
|
||||
};
|
||||
self.audio.stop_sounds(sound.as_deref(), source);
|
||||
// StopSoundCommand: each target's connection gets the packet; the
|
||||
// window's own player stops its sounds.
|
||||
let packet = C::StopSound(play::StopSound {
|
||||
source: source.map(|s| s as i32),
|
||||
sound: sound.clone(),
|
||||
});
|
||||
for &t in &targets {
|
||||
match t {
|
||||
Target::Player if self.lan_acting.is_some() => {
|
||||
self.guest_packets.push(packet.clone());
|
||||
}
|
||||
Target::Player => self.audio.stop_sounds(sound.as_deref(), source),
|
||||
Target::Guest(conn) => {
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let key = match (source, &sound) {
|
||||
(Some(_), Some(_)) => "commands.stopsound.success.source.sound",
|
||||
(Some(_), None) => "commands.stopsound.success.source.any",
|
||||
|
||||
@@ -128,7 +128,7 @@ impl Game<'_> {
|
||||
if self.state_in_tag(avoid, ground) {
|
||||
return false;
|
||||
}
|
||||
let entities = self.happy_ghast_player_collisions();
|
||||
let entities = self.player_entity_collisions();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -226,12 +226,29 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(Some(json!({ "ok": true, "cull": r.section_cull() })))
|
||||
}
|
||||
"section-sides" => {
|
||||
let r = self.renderer.as_mut().ok_or("no renderer")?;
|
||||
match words.get(1).copied() {
|
||||
None => {}
|
||||
Some("on") => r.set_section_sides(true),
|
||||
Some("off") => r.set_section_sides(false),
|
||||
Some(other) => return Err(format!("unknown section sides mode {other}").into()),
|
||||
}
|
||||
Ok(Some(json!({ "ok": true, "sides": r.section_sides() })))
|
||||
}
|
||||
"sculk" => Ok(Some(self.sculk_command(&words)?)),
|
||||
"status" => Ok(Some(self.control_status())),
|
||||
"pad" => Ok(Some(self.pad_command(&words)?)),
|
||||
"key" => {
|
||||
let key = key_code(arg(1)?).ok_or_else(|| format!("unknown key {}", words[1]))?;
|
||||
self.require_world()?;
|
||||
// The sign editor takes raw presses (Enter, arrows, Backspace, Escape).
|
||||
if self.sign_edit.is_some() {
|
||||
if arg(2)? != "up" {
|
||||
self.sign_input(Some(key), None);
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
// A held edit modifier (`key super down`) and A/C/V/X: the
|
||||
// open book's Select All, Copy, Paste or Cut.
|
||||
if self.book.is_some() && arg(2)? != "up" {
|
||||
@@ -496,7 +513,10 @@ impl Game<'_> {
|
||||
if self.pending_screenshot.is_some() {
|
||||
return Err("a screenshot is already pending".into());
|
||||
}
|
||||
renderer.screenshot = Some(arg(1)?.into());
|
||||
arg(1)?;
|
||||
// The rest of the line: a path may hold spaces.
|
||||
let path = line.trim_start().strip_prefix("screenshot").unwrap_or("");
|
||||
renderer.screenshot = Some(path.trim().into());
|
||||
self.pending_screenshot = Some(reply.clone());
|
||||
Ok(None)
|
||||
}
|
||||
@@ -524,6 +544,10 @@ impl Game<'_> {
|
||||
self.book_type(text);
|
||||
return ok();
|
||||
}
|
||||
if self.sign_edit.is_some() {
|
||||
self.sign_input(None, Some(text));
|
||||
return ok();
|
||||
}
|
||||
match self.screen {
|
||||
Screen::Chat => self.chat_type(text),
|
||||
Screen::CommandBlock => self.command_block_type(text),
|
||||
@@ -549,7 +573,7 @@ impl Game<'_> {
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
"type needs the chat screen (key t tap), the language search, the creative search, the anvil or World Options".into(),
|
||||
"type needs the chat screen (key t tap), a sign or book, the language search, the creative search, the anvil or World Options".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -578,7 +602,6 @@ impl Game<'_> {
|
||||
"credits" => Page::Credits,
|
||||
"horizon" => Page::Horizon,
|
||||
"agent" => Page::Agent,
|
||||
"shader-packs" => Page::ShaderPacks,
|
||||
other => return Err(format!("unknown options page {other}").into()),
|
||||
};
|
||||
if self.screen == Screen::Playing && self.title.is_none() {
|
||||
@@ -971,12 +994,14 @@ impl Game<'_> {
|
||||
status["destroying"] = json!(destroying);
|
||||
status["physical_keys"] = json!(physical_keys);
|
||||
status["heard"] = json!(self.audio.heard);
|
||||
status["stopped_sounds"] = json!(self.audio.stopped);
|
||||
// Boats, minecarts, armor stands, item frames and paintings.
|
||||
status["objects"] = json!(self.objects_status());
|
||||
status["lightning"] = json!({"bolts": self.bolts.len(), "struck": self.lightning_seen[0],
|
||||
"flashes": self.lightning_seen[1], "flash": self.flash});
|
||||
// Primed TNT, falling blocks, projectiles, tridents and end crystals.
|
||||
status["projectiles"] = json!(self.projectiles_status());
|
||||
status["maps"] = json!(self.maps_status());
|
||||
// The particles made so far, by type.
|
||||
status["particles"] = json!(self.particles.made());
|
||||
// The world border this side knows of.
|
||||
|
||||
@@ -47,6 +47,8 @@ pub(super) struct Creatures {
|
||||
pub pearl_cooldown: u32,
|
||||
/// The wind charge's use cooldown (ticks).
|
||||
pub wind_cooldown: u32,
|
||||
/// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs).
|
||||
pub wind_bursts: Vec<super::wind::Burst>,
|
||||
/// Ticks left of the elder guardian's apparition (MobAppearanceParticle).
|
||||
pub elder_apparition: i32,
|
||||
/// The wandering trader spawner's clock.
|
||||
@@ -976,7 +978,9 @@ impl Game<'_> {
|
||||
m.ai.target = Some(target);
|
||||
}
|
||||
}
|
||||
MobEvent::Projectile(p) => {
|
||||
MobEvent::Projectile(mut p) => {
|
||||
self.next_entity += 1;
|
||||
p.id = self.next_entity;
|
||||
let (owner, at) = (p.owner, [p.position.x, p.position.y, p.position.z]);
|
||||
self.creatures.projectiles.push(p);
|
||||
self.projectile_event("minecraft:projectile_shoot", owner, at);
|
||||
@@ -1005,6 +1009,11 @@ impl Game<'_> {
|
||||
MobEvent::SetBlock { pos, state } => {
|
||||
self.world.set(pos[0], pos[1], pos[2], state)?
|
||||
}
|
||||
MobEvent::SetDoorOpen { pos, open } => {
|
||||
let pos = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| b.set_door_open(level, ticks, pos, open));
|
||||
self.process_level_events()?;
|
||||
}
|
||||
MobEvent::SetBlockName { pos, name, effect } => {
|
||||
if effect {
|
||||
let old = self.world.block(pos[0], pos[1], pos[2]);
|
||||
@@ -1489,12 +1498,16 @@ impl Game<'_> {
|
||||
o.hurt
|
||||
}
|
||||
{
|
||||
let (xd, zd) = (from[0] - player.position.x, from[2] - player.position.z);
|
||||
self.knock_back_player(f64::from(0.4_f32), xd, zd);
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& let Some(r) = p.hurt_log.last_mut()
|
||||
{
|
||||
r.knockback = Some([p.velocity.x, p.velocity.y, p.velocity.z]);
|
||||
// LivingEntity.hurtServer's dealDefaultKnockback: not inside the
|
||||
// hurt cooldown (a stronger hit there only adds its difference).
|
||||
if o.knockback {
|
||||
let (xd, zd) = (from[0] - player.position.x, from[2] - player.position.z);
|
||||
self.knock_back_player(f64::from(0.4_f32), xd, zd);
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& let Some(r) = p.hurt_log.last_mut()
|
||||
{
|
||||
r.knockback = Some([p.velocity.x, p.velocity.y, p.velocity.z]);
|
||||
}
|
||||
}
|
||||
self.hurt_armor(amount);
|
||||
self.thorns(from);
|
||||
@@ -1778,7 +1791,7 @@ impl Game<'_> {
|
||||
_ => self.projectile_hit(&p, hit)?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
self.flush_wind_bursts()
|
||||
}
|
||||
/// `projectile_hit` for a projectile the guest `conn` shot, run as them;
|
||||
/// as the host's when they have left.
|
||||
|
||||
@@ -38,7 +38,7 @@ impl DebugHost for Host<'_, '_> {
|
||||
// the menu as it always has (key_pressed).
|
||||
let g = &mut *self.game;
|
||||
if without_menu && g.screen == Screen::Playing {
|
||||
g.pause_menu = !(g.has_singleplayer_server() && !g.is_published());
|
||||
g.pause_menu = !g.has_singleplayer_server() || g.is_published();
|
||||
g.screen = Screen::Paused;
|
||||
g.capture(false);
|
||||
g.mining = None;
|
||||
@@ -82,8 +82,10 @@ impl Game<'_> {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Screens with a text box take every key press first
|
||||
// (EditBox.keyPressed); releases still reach KeyboardHandler.
|
||||
// Vanilla types into a focused EditBox through charTyped, which no
|
||||
// debug key can swallow. Here the character comes with the key press,
|
||||
// so a screen with a focused text box takes every press first;
|
||||
// releases still reach KeyboardHandler.
|
||||
let typing = matches!(
|
||||
self.screen,
|
||||
Screen::Chat | Screen::Options | Screen::WorldOptions | Screen::CommandBlock
|
||||
@@ -91,7 +93,11 @@ impl Game<'_> {
|
||||
|| self.book.is_some()
|
||||
|| (self.screen == Screen::Creative && self.creative_screen.tab().search)
|
||||
|| self.recipe_search_focused()
|
||||
|| self.station_typing();
|
||||
|| self.station_typing()
|
||||
|| self
|
||||
.title
|
||||
.as_mut()
|
||||
.is_some_and(|t| t.focused_field().is_some());
|
||||
// Container screens use Escape, the inventory key and the hotbar and
|
||||
// drop keys first; any other screen closes on Escape.
|
||||
let screen_key = match self.screen {
|
||||
@@ -179,7 +185,9 @@ impl Game<'_> {
|
||||
// Minecraft.emergencySaveAndCrash: the world is saved, then the
|
||||
// game stops; main writes the crash report.
|
||||
let crash = horizon_client::crash::DebugCrash::new();
|
||||
let _ = self.save_game();
|
||||
if let Err(e) = self.save_game() {
|
||||
log::error!("the emergency save before the crash failed: {e}");
|
||||
}
|
||||
return Err(crash.into());
|
||||
}
|
||||
}
|
||||
@@ -593,7 +601,6 @@ impl Game<'_> {
|
||||
DebugAction::ReloadResourcePacks => {
|
||||
self.debug_translated("debug.reload_resourcepacks.message");
|
||||
self.reload_packs();
|
||||
self.load_shader_pack();
|
||||
true
|
||||
}
|
||||
DebugAction::CopyLocation if player => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! its simulation and the play-state hub that LAN hosting uses, configured
|
||||
//! from server.properties, with the operator, whitelist and ban lists and a
|
||||
//! console on standard input.
|
||||
use super::{Game, Options, lan::LanSettings};
|
||||
use super::{Game, Options, lan::LanSettings, world_loading::WorldRequest};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
lan::{LoginGate, NetConfig, NetLog, OnlineMode, hub::HubConfig},
|
||||
@@ -218,21 +218,22 @@ impl Lists {
|
||||
use_whitelist: false,
|
||||
enforce_whitelist: false,
|
||||
};
|
||||
// DedicatedPlayerList's constructor order and its LOGGER.warn lines.
|
||||
let warn = |what: &str, r: std::result::Result<(), String>| {
|
||||
if let Err(e) = r {
|
||||
log("WARN", &format!("Failed to load {what}: {e}"));
|
||||
log("WARN", &format!("Failed to {what}: {e}"));
|
||||
}
|
||||
};
|
||||
// DedicatedPlayerList's constructor order.
|
||||
warn("user banlist", l.bans.load());
|
||||
let _ = l.bans.save();
|
||||
warn("ip banlist", l.ip_bans.load());
|
||||
let _ = l.ip_bans.save();
|
||||
warn("operators list", l.ops.load());
|
||||
warn("white-list", l.white.load());
|
||||
let _ = l.ops.save();
|
||||
let saved = |r: std::io::Result<()>| r.map_err(|e| e.to_string());
|
||||
warn("load user banlist", l.bans.load());
|
||||
warn("save user banlist", saved(l.bans.save()));
|
||||
warn("load ip banlist", l.ip_bans.load());
|
||||
warn("save ip banlist", saved(l.ip_bans.save()));
|
||||
warn("load operators list", l.ops.load());
|
||||
warn("load white-list", l.white.load());
|
||||
warn("save operators list", saved(l.ops.save()));
|
||||
if !l.white.file.exists() {
|
||||
let _ = l.white.save();
|
||||
warn("save white-list", saved(l.white.save()));
|
||||
}
|
||||
l
|
||||
}
|
||||
@@ -546,13 +547,13 @@ impl Game<'_> {
|
||||
let lists = Arc::new(Mutex::new(lists));
|
||||
log("INFO", &format!("Preparing level \"{level_name}\""));
|
||||
let folder = world_dir.to_string_lossy().into_owned();
|
||||
self.open_world(
|
||||
self.load_world(WorldRequest::new(
|
||||
&folder,
|
||||
Some(&level_name),
|
||||
Some(props.seed),
|
||||
game_mode == 1,
|
||||
None,
|
||||
)?;
|
||||
)?)?;
|
||||
if new_world {
|
||||
self.level.hardcore = props.hardcore;
|
||||
}
|
||||
@@ -608,6 +609,7 @@ impl Game<'_> {
|
||||
lang: Arc::new(horizon_client::net::load_language(&self.options.resources)),
|
||||
log_ips: props.log_ips,
|
||||
}),
|
||||
resource_pack: props.resource_pack.clone(),
|
||||
};
|
||||
let config = HubConfig {
|
||||
default_game_mode: game_mode,
|
||||
|
||||
@@ -12,7 +12,6 @@ use horizon_client::{
|
||||
hud_layout, hud_overlays,
|
||||
mesher::Vertex,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
@@ -2157,23 +2156,12 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `BossHealthOverlay`: the dragon's pink bar, then the withers', the
|
||||
/// raids' and the custom bars, in that order.
|
||||
/// `BossHealthOverlay.extractRenderState`: whatever `publish_boss_bars`
|
||||
/// (or a server's packets) put on this client's overlay.
|
||||
pub(super) fn draw_boss_bars(&self, batch: &mut HudBatch) {
|
||||
let mut bars = Vec::new();
|
||||
if let Some(d) = self.dragon.as_ref().filter(|_| self.dragon_bar_shown()) {
|
||||
bars.push(hud_overlays::BossBar {
|
||||
name: Text::literal(
|
||||
self.translate("entity.minecraft.ender_dragon", "Ender Dragon"),
|
||||
),
|
||||
progress: (d.health / MAX_HEALTH).clamp(0.0, 1.0),
|
||||
color: 0,
|
||||
overlay: 0,
|
||||
});
|
||||
}
|
||||
self.wither_bars(&mut bars);
|
||||
self.raid_bars(&mut bars);
|
||||
self.custom_bossbars(&mut bars);
|
||||
let bars = self
|
||||
.boss_overlay
|
||||
.bars(horizon_client::boss_overlay::now_millis());
|
||||
let draws = hud_overlays::boss_overlay(
|
||||
batch.width as i32,
|
||||
batch.height as i32,
|
||||
@@ -2182,14 +2170,26 @@ impl Game<'_> {
|
||||
);
|
||||
hud_layout::paint(batch, &self.gui.atlas, &draws);
|
||||
}
|
||||
/// The dragon fight's boss bar shows (`EnderDragonFight.dragonEvent`,
|
||||
/// with `setCreateWorldFog`).
|
||||
pub(super) fn dragon_bar_shown(&self) -> bool {
|
||||
self.dim == Dim::End && self.dragon.is_some()
|
||||
/// The dragon fight's bar for a player at `at`
|
||||
/// (`EnderDragonFight.dragonEvent`, visible while the fight is not won
|
||||
/// and the player is alive within 192 blocks of (0, 128, 0)).
|
||||
pub(super) fn dragon_boss_progress(&self, at: [f64; 3], alive: bool) -> Option<f32> {
|
||||
if self.dim != Dim::End || !alive || !self.fight.loaded || self.fight.killed {
|
||||
return None;
|
||||
}
|
||||
let near = at[0].powi(2) + (at[1] - 128.0).powi(2) + at[2].powi(2) <= 192.0 * 192.0;
|
||||
if !near {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
self.dragon
|
||||
.as_ref()
|
||||
.map_or(1.0, |d| (d.health / MAX_HEALTH).clamp(0.0, 1.0)),
|
||||
)
|
||||
}
|
||||
/// `music.dragon` while the boss bar plays music.
|
||||
/// `BossHealthOverlay.shouldPlayMusic`.
|
||||
pub(super) fn boss_music(&self) -> bool {
|
||||
self.dim == Dim::End && self.dragon.is_some() || self.wither_near()
|
||||
self.boss_overlay.should_play_music()
|
||||
}
|
||||
}
|
||||
pub(super) struct DragonFireball {
|
||||
|
||||
@@ -121,11 +121,13 @@ impl Game<'_> {
|
||||
let bonus = if knockback_attack { 0.5 } else { 0.0 };
|
||||
(enchanted / 2.0 + bonus, knockback_attack)
|
||||
}
|
||||
/// `causeExtraKnockback`'s direction, `(sin yRot, -cos yRot)`: the target
|
||||
/// flies where the attacker looks, not away from it.
|
||||
/// `causeExtraKnockback`'s direction, `(sin yRot, -cos yRot)` from Mth's
|
||||
/// sine table: the target flies where the attacker looks, not away from
|
||||
/// it (straight along an axis when the attacker faces one).
|
||||
pub(super) fn extra_knockback_direction(&self) -> (f64, f64) {
|
||||
let yaw = self.yaw.to_radians();
|
||||
(f64::from(yaw.sin()), -f64::from(yaw.cos()))
|
||||
use horizon_core::geometry::trig;
|
||||
let yaw = f64::from(self.yaw * trig::DEG_TO_RAD);
|
||||
(f64::from(trig::sin(yaw)), -f64::from(trig::cos(yaw)))
|
||||
}
|
||||
/// The attacker's side of `causeExtraKnockback` as the attacking client
|
||||
/// sees it: the server's player stops sprinting, and its flag reaches the
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//! A Game's execution role is fixed at construction. Connecting or disconnecting
|
||||
//! changes `remote`, not this role. A client may simulate a world in-frame.
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum ExecutionRole {
|
||||
Client,
|
||||
IntegratedServer,
|
||||
DedicatedServer,
|
||||
}
|
||||
|
||||
impl ExecutionRole {
|
||||
/// Only a client owns presentation work (audio, loading UI, chunk meshes).
|
||||
pub fn is_client(self) -> bool {
|
||||
self == Self::Client
|
||||
}
|
||||
|
||||
/// The optional in-frame simulation remains a client execution mode.
|
||||
pub fn starts_integrated_server(self, server_thread: bool) -> bool {
|
||||
self.is_client() && server_thread
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ExecutionRole::*;
|
||||
|
||||
#[test]
|
||||
fn world_loading_keeps_in_frame_clients_and_server_roles_separate() {
|
||||
for (role, client, threaded) in [
|
||||
(Client, true, true),
|
||||
(IntegratedServer, false, false),
|
||||
(DedicatedServer, false, false),
|
||||
] {
|
||||
assert_eq!(role.is_client(), client);
|
||||
assert_eq!(role.starts_integrated_server(true), threaded);
|
||||
assert!(!role.starts_integrated_server(false));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,12 +69,18 @@ impl Game<'_> {
|
||||
let lo = Vec3::new(f.pos[0] - half, f.pos[1], f.pos[2] - half);
|
||||
let hi = Vec3::new(f.pos[0] + half, f.pos[1] + 0.8, f.pos[2] + half);
|
||||
let b = horizon_core::geometry::Aabb::new(lo, hi);
|
||||
if let Some(p) = self.player_view()
|
||||
&& p.alive
|
||||
&& p.bounds.intersects(b)
|
||||
{
|
||||
self.mob_hits_player_kind(6.0, f.pos, "minecraft:indirect_magic");
|
||||
}
|
||||
// Every player in the box, the server's players too.
|
||||
let pos = f.pos;
|
||||
let bite = move |g: &mut Self| {
|
||||
if let Some(p) = g.player_view()
|
||||
&& p.alive
|
||||
&& p.bounds.intersects(b)
|
||||
{
|
||||
g.mob_hits_player_kind(6.0, pos, "minecraft:indirect_magic");
|
||||
}
|
||||
};
|
||||
bite(self);
|
||||
self.each_guest_hit(bite);
|
||||
for m in &mut self.mobs {
|
||||
let ally = matches!(
|
||||
m.species(),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! bobbing, lure and bite timers, open water, hooking mobs, retrieving the
|
||||
//! `gameplay/fishing` loot) and `FishingHookRenderer` (bobber and line).
|
||||
use super::{Game, view_vector};
|
||||
use horizon_client::lan::{entity::States, hub::Hub};
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
collisions::SceneCollisions,
|
||||
@@ -15,7 +16,10 @@ use horizon_core::{
|
||||
geometry::{Aabb, Vec3, trig},
|
||||
random::RandomSource,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use horizon_protocol::entity_data::{Value, serializer};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use super::lan_actions::GuestPlay;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum State {
|
||||
@@ -24,6 +28,8 @@ enum State {
|
||||
Hooked(u32),
|
||||
}
|
||||
pub(super) struct Hook {
|
||||
/// Its entity id, which a server's players track it under.
|
||||
id: u32,
|
||||
position: Vec3,
|
||||
previous: Vec3,
|
||||
velocity: Vec3,
|
||||
@@ -114,7 +120,9 @@ impl Game<'_> {
|
||||
);
|
||||
// A spawned hook's start position is its feet: centre the box on the eye.
|
||||
let start = Vec3::new(start.x, start.y - SIZE / 2.0, start.z);
|
||||
self.next_entity += 1;
|
||||
self.hook = Some(Hook {
|
||||
id: self.next_entity,
|
||||
position: start,
|
||||
previous: start,
|
||||
velocity: v,
|
||||
@@ -562,12 +570,17 @@ impl Game<'_> {
|
||||
lerp(h.previous.z, h.position.z),
|
||||
))
|
||||
}
|
||||
/// FishingHookRenderer: the bobber, a camera-facing half-block quad
|
||||
/// centred on the hook.
|
||||
/// FishingHookRenderer: the bobbers (this player's and the others' a
|
||||
/// server tracks), camera-facing half-block quads centred on the hooks.
|
||||
pub(super) fn fishing_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
let Some(p) = self.hook_position(alpha) else {
|
||||
let hooks: Vec<glam::DVec3> = self
|
||||
.hook_position(alpha)
|
||||
.into_iter()
|
||||
.chain(self.remote_hook_ends(alpha).into_iter().map(|(h, _)| h))
|
||||
.collect();
|
||||
if hooks.is_empty() {
|
||||
return;
|
||||
};
|
||||
}
|
||||
let Some(r) = self
|
||||
.gui
|
||||
.atlas
|
||||
@@ -577,56 +590,57 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let light = self
|
||||
.world
|
||||
.light(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
let forward = glam::DVec3::from_array(view_vector(self.yaw, self.pitch));
|
||||
let right = forward.cross(glam::DVec3::Y).normalize_or_zero();
|
||||
let up = right.cross(forward).normalize_or_zero();
|
||||
let s = 0.25;
|
||||
let corners = [
|
||||
p - right * s - up * s,
|
||||
p + right * s - up * s,
|
||||
p + right * s + up * s,
|
||||
p - right * s + up * s,
|
||||
];
|
||||
let uv = [[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]];
|
||||
for (v, uv) in corners.iter().zip(uv) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: (*v - camera).as_vec3().to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: packed,
|
||||
});
|
||||
for p in hooks {
|
||||
let light =
|
||||
self.world
|
||||
.light(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
let corners = [
|
||||
p - right * s - up * s,
|
||||
p + right * s - up * s,
|
||||
p + right * s + up * s,
|
||||
p - right * s + up * s,
|
||||
];
|
||||
for (v, uv) in corners.iter().zip(uv) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: (*v - camera).as_vec3().to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: packed,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
/// FishingHookRenderer: the line, 16 black `lines` segments sagging from
|
||||
/// a quarter block above the hook to the rod in the player's hand,
|
||||
/// FishingHookRenderer: each line, 16 black `lines` segments sagging
|
||||
/// from a quarter block above the hook to the rod in its owner's hand,
|
||||
/// `width` pixels wide (`Window.getAppropriateLineWidth`).
|
||||
pub(super) fn fishing_line(&self, alpha: f64, size: [u32; 2]) -> Vec<Line> {
|
||||
let (Some(hook), Some(hand)) = (
|
||||
self.hook_position(alpha),
|
||||
self.rod_hand_position(alpha, size),
|
||||
) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let offset = (hand - (hook + glam::DVec3::new(0.0, 0.25, 0.0))).as_vec3();
|
||||
let point = |a: f32| {
|
||||
let y = offset.y * (a * a + a) * 0.5 + 0.25;
|
||||
hook + glam::Vec3::new(offset.x * a, y, offset.z * a).as_dvec3()
|
||||
};
|
||||
let own = self
|
||||
.hook_position(alpha)
|
||||
.zip(self.rod_hand_position(alpha, size));
|
||||
let width = horizon_client::gfx::outline_width(size[0]);
|
||||
(0..16)
|
||||
.map(|i| Line {
|
||||
let mut lines = Vec::new();
|
||||
for (hook, hand) in own.into_iter().chain(self.remote_hook_ends(alpha)) {
|
||||
let offset = (hand - (hook + glam::DVec3::new(0.0, 0.25, 0.0))).as_vec3();
|
||||
let point = |a: f32| {
|
||||
let y = offset.y * (a * a + a) * 0.5 + 0.25;
|
||||
hook + glam::Vec3::new(offset.x * a, y, offset.z * a).as_dvec3()
|
||||
};
|
||||
lines.extend((0..16).map(|i| Line {
|
||||
start: point(i as f32 / 16.0).to_array(),
|
||||
end: point((i + 1) as f32 / 16.0).to_array(),
|
||||
color: 0xFF00_0000,
|
||||
width,
|
||||
})
|
||||
.collect()
|
||||
}));
|
||||
}
|
||||
lines
|
||||
}
|
||||
/// `FishingHookRenderer.getPlayerHandPos`: on the near plane in first
|
||||
/// person, beside the body otherwise.
|
||||
@@ -694,6 +708,20 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// The hooks drawn here, with their owners' ids (0 for this player's own).
|
||||
pub(super) fn hooks_status(&self) -> Vec<serde_json::Value> {
|
||||
let at = |p: Vec3| [p.x, p.y, p.z];
|
||||
let own = self.hook.iter().map(|h| (h.id, 0, h.position));
|
||||
let others = self
|
||||
.remote_hooks
|
||||
.iter()
|
||||
.map(|h| (h.id, h.owner, h.position));
|
||||
own.chain(others)
|
||||
.map(|(id, owner, p)| {
|
||||
serde_json::json!({"id": id, "kind": "minecraft:fishing_bobber", "owner": owner, "position": at(p)})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The hook for `horizon-ctl status`.
|
||||
pub(super) fn hook_status(&self) -> Option<serde_json::Value> {
|
||||
let h = self.hook.as_ref()?;
|
||||
@@ -706,3 +734,175 @@ impl Game<'_> {
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// A hook a server tracks to this client (`FishingHook.recreateFromPacket`)
|
||||
/// that another player cast: drawn where the server puts it, its line
|
||||
/// running to that player's hand.
|
||||
pub(super) struct RemoteHook {
|
||||
id: u32,
|
||||
owner: u32,
|
||||
position: Vec3,
|
||||
previous: Vec3,
|
||||
}
|
||||
|
||||
impl Hook {
|
||||
/// This client's own hook as its server tracks it: drawn, never ticked here.
|
||||
fn remote(id: u32, at: Vec3) -> Self {
|
||||
Self {
|
||||
id,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
state: State::Flying,
|
||||
life: 0,
|
||||
on_ground: false,
|
||||
nibble: 0,
|
||||
time_until_lured: 0,
|
||||
time_until_hooked: 0,
|
||||
fish_angle: 0.0,
|
||||
out_of_water: 0,
|
||||
open_water: true,
|
||||
luck: 0,
|
||||
lure: 0,
|
||||
}
|
||||
}
|
||||
/// `FishingHook`'s entity for the players tracking it: the owner's id in
|
||||
/// its add packet, DATA_HOOKED_ENTITY (the hooked id plus one) and
|
||||
/// DATA_BITING when they differ from their defaults.
|
||||
fn track(&self, owner: i32, out: &mut States) {
|
||||
let p = self.position;
|
||||
let e = out.add(
|
||||
self.id as i32,
|
||||
super::lan::entity_uuid(self.id),
|
||||
"minecraft:fishing_bobber",
|
||||
);
|
||||
e.position = [p.x, p.y, p.z];
|
||||
e.velocity = [self.velocity.x, self.velocity.y, self.velocity.z];
|
||||
e.on_ground = self.on_ground;
|
||||
e.data = owner;
|
||||
if let State::Hooked(id) = self.state {
|
||||
e.meta.push((8, serializer::INT, Value::Int(id as i32 + 1)));
|
||||
}
|
||||
if self.nibble > 0 {
|
||||
e.meta.push((9, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The hooks guests track: the host's and each guest's in the level.
|
||||
pub(super) fn lan_hook_states(
|
||||
&self,
|
||||
host_entity: Option<i32>,
|
||||
play: &HashMap<u32, GuestPlay>,
|
||||
hub: &Hub,
|
||||
out: &mut States,
|
||||
) {
|
||||
if let (Some(h), Some(owner)) = (&self.hook, host_entity) {
|
||||
h.track(owner, out);
|
||||
}
|
||||
for g in hub.guests.iter().filter(|g| hub.here(g)) {
|
||||
if let Some(h) = play.get(&g.conn).and_then(|p| p.hook.as_ref()) {
|
||||
h.track(g.entity_id, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `handleAddEntity` for a fishing bobber: this player's own becomes its
|
||||
/// hook (`Player.fishing`), another player's is drawn on its line to them.
|
||||
pub(super) fn spawn_remote_hook(&mut self, id: u32, at: Vec3, owner: i32) {
|
||||
let own = self.remote.as_ref().map(|r| r.entity_id);
|
||||
if own == Some(owner) {
|
||||
self.hook = Some(Hook::remote(id, at));
|
||||
} else {
|
||||
self.remote_hooks.push(RemoteHook {
|
||||
id,
|
||||
owner: owner as u32,
|
||||
position: at,
|
||||
previous: at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// One tick of interpolation for the hooks a server tracks.
|
||||
pub(super) fn step_remote_hooks(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
|
||||
if self.remote.is_none() {
|
||||
return;
|
||||
}
|
||||
if let Some(h) = self.hook.as_mut() {
|
||||
h.previous = h.position;
|
||||
h.position = step(h.id, h.position);
|
||||
}
|
||||
for h in &mut self.remote_hooks {
|
||||
h.previous = h.position;
|
||||
h.position = step(h.id, h.position);
|
||||
}
|
||||
}
|
||||
|
||||
/// `FishingHook.onSyncedDataUpdated`: this player's hook is biting
|
||||
/// (DATA_BITING), which `status` shows as a nibble.
|
||||
pub(super) fn remote_hook_data(&mut self, id: u32, values: &[(u8, i32, Value)]) {
|
||||
let Some(h) = self
|
||||
.hook
|
||||
.as_mut()
|
||||
.filter(|h| h.id == id && self.remote.is_some())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for (index, _, value) in values {
|
||||
if let (9, Value::Bool(biting)) = (index, value) {
|
||||
h.nibble = i32::from(*biting);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn remove_remote_hook(&mut self, id: u32) {
|
||||
if self.remote.is_some() && self.hook.as_ref().is_some_and(|h| h.id == id) {
|
||||
self.hook = None;
|
||||
}
|
||||
self.remote_hooks.retain(|h| h.id != id);
|
||||
}
|
||||
|
||||
pub(super) fn clear_remote_hooks(&mut self) {
|
||||
if self.remote.is_some() {
|
||||
self.hook = None;
|
||||
}
|
||||
self.remote_hooks.clear();
|
||||
}
|
||||
|
||||
/// Other players' hooks: the bobber and its line to the owner's hand
|
||||
/// (`FishingHookRenderer.getPlayerHandPos` in third person), as
|
||||
/// (hook, hand) this frame.
|
||||
fn remote_hook_ends(&self, alpha: f64) -> Vec<(glam::DVec3, glam::DVec3)> {
|
||||
let lerp = |a: Vec3, b: Vec3| {
|
||||
glam::DVec3::new(
|
||||
a.x + (b.x - a.x) * alpha,
|
||||
a.y + (b.y - a.y) * alpha,
|
||||
a.z + (b.z - a.z) * alpha,
|
||||
)
|
||||
};
|
||||
self.remote_hooks
|
||||
.iter()
|
||||
.filter_map(|h| {
|
||||
let owner = self.mobs.iter().find(|m| m.id == h.owner)?;
|
||||
let feet = lerp(owner.previous, owner.position);
|
||||
let body = horizon_client::camera::lerp(
|
||||
alpha as f32,
|
||||
owner.previous_body_yaw,
|
||||
owner.body_yaw,
|
||||
);
|
||||
let angle = f64::from(body * (std::f64::consts::PI / 180.0) as f32);
|
||||
let (sin, cos) = (f64::from(trig::sin(angle)), f64::from(trig::cos(angle)));
|
||||
// The right arm; a crouching owner's eyes are lower.
|
||||
let eye = owner.eye_y() - owner.position.y;
|
||||
let hand = feet
|
||||
+ glam::DVec3::new(
|
||||
-cos * 0.35 - sin * 0.8,
|
||||
eye - 0.45,
|
||||
-sin * 0.35 + cos * 0.8,
|
||||
);
|
||||
Some((lerp(h.previous, h.position), hand))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
/// The fluid at the camera, as `Camera.getFluidInCamera` reports it.
|
||||
fn camera_fluid(&self, camera: [f64; 3]) -> CameraFluid {
|
||||
pub(super) fn camera_fluid(&self, camera: [f64; 3]) -> CameraFluid {
|
||||
match fog_type(&self.world, self.res.facts(), camera, self.forward()) {
|
||||
FogType::Water => CameraFluid::Water,
|
||||
FogType::Lava => CameraFluid::Lava,
|
||||
|
||||
@@ -200,7 +200,7 @@ impl Game<'_> {
|
||||
}
|
||||
Action::Copy => self.friends_copy(),
|
||||
Action::Stop => self.friends_stop(),
|
||||
// The system browser, as the Shader Packs page opens its folder.
|
||||
// The system browser, as the folder buttons open their folders.
|
||||
Action::Help => {
|
||||
if let Some(url) = &view.help {
|
||||
super::packs::open_folder(std::path::Path::new(url));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! box each, and Done sending the changed ones (ServerboundSetGameRulePacket).
|
||||
//! Leaving with changes asks first (ConfirmScreen).
|
||||
use horizon_client::{
|
||||
confirm_screen::Confirm,
|
||||
font::Font,
|
||||
gamerules::{self, GameRules, RULES, Rule, Value},
|
||||
hud::{HudAtlas, HudBatch},
|
||||
@@ -66,9 +67,6 @@ fn parse(r: &Rule, s: &str) -> Option<i32> {
|
||||
.flatten()
|
||||
.filter(|v| (r.min..=r.max).contains(v))
|
||||
}
|
||||
fn widest(font: &Font, lines: &[Text]) -> f32 {
|
||||
lines.iter().map(|l| font.width_text(l)).fold(0.0, f32::max)
|
||||
}
|
||||
fn lines(font: &Font, t: &Text, width: f32) -> Vec<Text> {
|
||||
font.split_lines(t, width)
|
||||
.into_iter()
|
||||
@@ -95,38 +93,11 @@ fn yellow() -> u32 {
|
||||
named_color("yellow").unwrap_or(0xFFFF55)
|
||||
}
|
||||
|
||||
/// ConfirmScreen: the title, the message (at most `width - 50` wide), and
|
||||
/// Yes / No 16 below, 4 apart, all centred.
|
||||
struct Confirm {
|
||||
lines: Vec<(Text, f32, f32)>,
|
||||
buttons: [(f32, f32); 2],
|
||||
}
|
||||
impl Confirm {
|
||||
fn new(font: &Font, (w, h): (f32, f32)) -> Self {
|
||||
let title = Text::translate("editGamerule.inGame.discardChanges.title", &[]);
|
||||
let message = Text::translate("editGamerule.inGame.discardChanges.message", &[]);
|
||||
let message: Vec<Text> = lines(font, &message, w - 50.0)
|
||||
.into_iter()
|
||||
.take(15)
|
||||
.collect();
|
||||
let (tw, mw) = (font.width_text(&title), widest(font, &message));
|
||||
let mh = 9.0 * message.len() as f32;
|
||||
let col = tw.max(mw).max(304.0);
|
||||
let total = 9.0 + 8.0 + mh + 8.0 + 36.0;
|
||||
let (lx, ly) = (((w - col) / 2.0).trunc(), ((h - total) / 2.0).trunc());
|
||||
let cell = |cw: f32| lx + ((col - cw) / 2.0).trunc();
|
||||
let mid = cell(mw) + (mw / 2.0).trunc();
|
||||
let mut out = vec![(title, cell(tw), ly)];
|
||||
for (i, line) in message.into_iter().enumerate() {
|
||||
let x = mid - (font.width_text(&line) / 2.0).trunc();
|
||||
out.push((line, x, ly + 17.0 + 9.0 * i as f32));
|
||||
}
|
||||
let (bx, by) = (cell(304.0), ly + 9.0 + 8.0 + mh + 8.0 + 16.0);
|
||||
Self {
|
||||
lines: out,
|
||||
buttons: [(bx, by), (bx + 154.0, by)],
|
||||
}
|
||||
}
|
||||
/// ConfirmScreen "Game Rule Changes".
|
||||
fn confirm(font: &Font, size: (f32, f32)) -> Confirm {
|
||||
let title = Text::translate("editGamerule.inGame.discardChanges.title", &[]);
|
||||
let message = Text::translate("editGamerule.inGame.discardChanges.message", &[]);
|
||||
Confirm::new(font, size, &title, &message)
|
||||
}
|
||||
|
||||
impl RulesScreen {
|
||||
@@ -301,13 +272,10 @@ impl RulesScreen {
|
||||
let (w, h) = size;
|
||||
let on = |x: f32, y: f32, bw: f32, bh: f32| ui::hovered(cursor, x, y, bw, bh);
|
||||
if self.confirm {
|
||||
let c = Confirm::new(&atlas.font, size);
|
||||
let [(yx, yy), (nx, ny)] = c.buttons;
|
||||
if on(yx, yy, 150.0, 20.0) {
|
||||
return Some(Exit::Discard);
|
||||
}
|
||||
if on(nx, ny, 150.0, 20.0) {
|
||||
self.confirm = false;
|
||||
match confirm(&atlas.font, size).answer_at(cursor) {
|
||||
Some(true) => return Some(Exit::Discard),
|
||||
Some(false) => self.confirm = false,
|
||||
None => {}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
@@ -367,7 +335,7 @@ impl RulesScreen {
|
||||
let (w, h) = size;
|
||||
let on = |(x, y): (f32, f32)| ui::hovered(cursor, x, y, 150.0, 20.0);
|
||||
if self.confirm {
|
||||
return Confirm::new(&atlas.font, size).buttons.into_iter().any(on);
|
||||
return confirm(&atlas.font, size).answer_at(cursor).is_some();
|
||||
}
|
||||
let [done, cancel] = footer(w, h);
|
||||
if (on(done) && !self.invalid()) || on(cancel) {
|
||||
@@ -384,15 +352,11 @@ impl RulesScreen {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
if self.confirm {
|
||||
let c = Confirm::new(&atlas.font, (w, h));
|
||||
for (line, x, y) in &c.lines {
|
||||
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
|
||||
}
|
||||
for ((bx, by), key) in c.buttons.into_iter().zip(["gui.yes", "gui.no"]) {
|
||||
let hover = ui::hovered(cursor, bx, by, 150.0, 20.0);
|
||||
let label = Text::translate(key, &[]);
|
||||
ui::button(batch, atlas, bx, by, 150.0, 20.0, &label, true, hover);
|
||||
}
|
||||
let labels = [
|
||||
Text::translate("gui.yes", &[]),
|
||||
Text::translate("gui.no", &[]),
|
||||
];
|
||||
confirm(&atlas.font, (w, h)).draw(batch, atlas, cursor, [&labels[0], &labels[1]]);
|
||||
return;
|
||||
}
|
||||
let rows = self.rows();
|
||||
|
||||
@@ -10,9 +10,10 @@ use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
impl Game<'_> {
|
||||
/// Client `EntityGetter.getEntityCollisions`: only solid happy ghasts,
|
||||
/// excluding the player's own vehicle.
|
||||
pub(super) fn happy_ghast_player_collisions(&self) -> Vec<Aabb> {
|
||||
/// Client `EntityGetter.getEntityCollisions` for the player: the
|
||||
/// entities that `canBeCollidedWith` it, solid happy ghasts and boats
|
||||
/// (`AbstractBoat.canBeCollidedWith`), except the player's own vehicle.
|
||||
pub(super) fn player_entity_collisions(&self) -> Vec<Aabb> {
|
||||
let Some(p) = &self.player else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -24,6 +25,12 @@ impl Game<'_> {
|
||||
&& happy_ghast::collides_with_player(m, p.position.y)
|
||||
})
|
||||
.map(|m| m.bounds())
|
||||
.chain(
|
||||
self.boats
|
||||
.iter()
|
||||
.filter(|b| Some(b.id) != self.riding)
|
||||
.map(super::boats::Boat::bounds),
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
/// `HappyGhast.mobInteract`. Returns whether the click was used.
|
||||
|
||||
@@ -6,15 +6,15 @@
|
||||
//! the world's options) with [`Integrated::call`], as vanilla's client calls
|
||||
//! into its `IntegratedServer`.
|
||||
//!
|
||||
//! Opt-in again (`--server-thread` or `HORIZON_SERVER_THREAD=1`) until a
|
||||
//! server's players reach parity with the in-frame world: on the server
|
||||
//! thread, signs open no editor, books don't open, a totem doesn't save its
|
||||
//! holder and death keeps the inventory (#1380, #1366, #1292, #1284 and the
|
||||
//! other `[server-thread]` issues). LAN guests and dedicated servers use the
|
||||
//! same code either way.
|
||||
//! On by default. `--in-frame` (or `--no-server-thread`, or
|
||||
//! `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead, the
|
||||
//! escape hatch. LAN guests and dedicated servers use the same code either
|
||||
//! way.
|
||||
use super::{
|
||||
Game, Options, PackResources, Resources, UiResources,
|
||||
execution_role::ExecutionRole,
|
||||
lan::{LanSettings, Owner},
|
||||
world_loading::WorldRequest,
|
||||
};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
@@ -28,7 +28,6 @@ use horizon_protocol::connection::Pipe;
|
||||
use horizon_protocol::{crypto::offline_uuid, text::translatable};
|
||||
use std::{
|
||||
net::TcpListener,
|
||||
path::Path,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, AtomicI32, AtomicU16, AtomicU32, Ordering},
|
||||
@@ -172,11 +171,6 @@ struct Launch {
|
||||
player: String,
|
||||
}
|
||||
|
||||
/// Whether this world opens on a server thread: asked for.
|
||||
pub(super) fn wanted(options: &Options, _world: &Path) -> bool {
|
||||
options.server_thread && options.dedicated.is_none()
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Minecraft.hasSingleplayerServer`: a world of this window's own, run
|
||||
/// in the frame or on its server thread.
|
||||
@@ -217,9 +211,10 @@ impl Game<'_> {
|
||||
};
|
||||
let mut options = self.options.clone();
|
||||
// A server without a console: no local player, no window, no control socket.
|
||||
// Compatibility for gameplay checks outside startup/world loading.
|
||||
options.dedicated = Some(Vec::new());
|
||||
options.server_thread = false;
|
||||
options.integrated = true;
|
||||
options.execution_role = ExecutionRole::IntegratedServer;
|
||||
options.control = None;
|
||||
options.benchmark = None;
|
||||
options.frames = None;
|
||||
@@ -289,13 +284,13 @@ impl Game<'_> {
|
||||
tasks: Receiver<Task>,
|
||||
ready: &Sender<Ready>,
|
||||
) -> Result<()> {
|
||||
self.open_world(
|
||||
self.load_world(WorldRequest::new(
|
||||
&launch.folder,
|
||||
launch.name.as_deref(),
|
||||
launch.seed,
|
||||
launch.creative,
|
||||
launch.create.as_ref(),
|
||||
)?;
|
||||
)?)?;
|
||||
let saved = Box::new(self.level.clone());
|
||||
// Math.max(2, options.renderDistance): past vanilla's 32 when the
|
||||
// window's render distance is (Horizon's options go to 64).
|
||||
@@ -498,6 +493,22 @@ impl Game<'_> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Run `f` with the owner's level in place (the server holds one per
|
||||
/// dimension), then put back the level that was.
|
||||
pub(super) fn in_owner_level<T>(&mut self, f: impl FnOnce(&mut Self) -> T) -> T {
|
||||
let owner = self.serving.as_ref().map(|s| s.owner);
|
||||
let dim = self.lan.as_ref().and_then(|h| {
|
||||
let g = h.hub.guests.iter().find(|g| Some(g.uuid) == owner)?;
|
||||
Some(super::Dim::from_id(&g.dimension))
|
||||
});
|
||||
let back = dim.and_then(|d| self.enter_command_level(d));
|
||||
let out = f(self);
|
||||
if let Some(back) = back {
|
||||
self.enter_command_level(back);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The window's requests, between two ticks.
|
||||
fn run_tasks(&mut self) {
|
||||
let tasks: Vec<Task> = match &self.serving {
|
||||
|
||||
@@ -411,7 +411,12 @@ impl Game<'_> {
|
||||
"game_mode": g.game_mode,
|
||||
// ItemCooldowns: the ticks the shield an axe disabled stays down.
|
||||
"shield_cooldown": h.play.get(&g.conn).map_or(0, |p| p.shield_cooldown),
|
||||
// The item the server has this guest using: its slot and ticks.
|
||||
"using": h.play.get(&g.conn).and_then(|p| p.using())
|
||||
.map(|(slot, ticks)| json!({ "slot": slot, "ticks": ticks })),
|
||||
"sneaking": g.sneaking,
|
||||
// The server's Elytra glide (shared flag 7).
|
||||
"fall_flying": g.fall_flying,
|
||||
"sleeping": g.bed,
|
||||
"health": g.health,
|
||||
// The server's body: its air supply and golden hearts.
|
||||
@@ -829,8 +834,11 @@ impl Game<'_> {
|
||||
self.lan_object_states(host_entity, hub, out);
|
||||
}
|
||||
|
||||
/// Run the hub for this tick and apply what the guests did.
|
||||
pub(super) fn tick_lan(&mut self) {
|
||||
/// Run the hub for this tick and apply what the guests did. While the
|
||||
/// single player game is `paused` only the connections run
|
||||
/// (`IntegratedServer.tickPaused`): `ServerGamePacketListenerImpl.tick`
|
||||
/// skips `tickPlayer`, so no player's air, hunger or damage moves on.
|
||||
pub(super) fn tick_lan(&mut self, paused: bool) {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
@@ -870,6 +878,7 @@ impl Game<'_> {
|
||||
.collect();
|
||||
self.lan_riding(host_entity, &guests, &mut host.hub.riding);
|
||||
self.lan_entities(host_entity, &host.mob_players, &mut host.hub, &mut states);
|
||||
self.lan_hook_states(host_entity, &host.play, &host.hub, &mut states);
|
||||
} else {
|
||||
states.clear();
|
||||
}
|
||||
@@ -994,7 +1003,9 @@ impl Game<'_> {
|
||||
host.states = states;
|
||||
}
|
||||
self.lan_flush_effects(&mut host);
|
||||
self.tick_guests(&mut host);
|
||||
if !paused {
|
||||
self.tick_guests(&mut host);
|
||||
}
|
||||
self.lan_living_flags(&mut host);
|
||||
self.tick_server_travel(&mut host);
|
||||
self.lan_swings(&mut host);
|
||||
@@ -1006,6 +1017,8 @@ impl Game<'_> {
|
||||
drop(holding);
|
||||
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
|
||||
self.lan = Some(host);
|
||||
// A mace's wind burst from a guest's attack.
|
||||
let _ = self.flush_wind_bursts();
|
||||
}
|
||||
|
||||
fn lan_guest_joined(
|
||||
@@ -1061,6 +1074,7 @@ impl Game<'_> {
|
||||
}
|
||||
// A dedicated server has no host player.
|
||||
self.lan_entities(None, &host.mob_players, &mut host.hub, &mut theirs);
|
||||
self.lan_hook_states(None, &host.play, &host.hub, &mut theirs);
|
||||
theirs.as_slice()
|
||||
} else {
|
||||
entities
|
||||
|
||||
@@ -30,6 +30,8 @@ use winit::keyboard::KeyCode;
|
||||
/// Per-guest state of the gameplay code that lives outside `Guest`.
|
||||
#[derive(Default)]
|
||||
pub(super) struct GuestPlay {
|
||||
/// The sign this guest may write on (`SignBlockEntity.playerWhoMayEdit`).
|
||||
pub(super) sign_editing: Option<[i32; 3]>,
|
||||
mining: Option<Mining>,
|
||||
destroy_delay: u32,
|
||||
using: Option<(usize, u32)>,
|
||||
@@ -80,6 +82,8 @@ pub(super) struct GuestPlay {
|
||||
/// (`LivingEntity.collectEquipmentChanges`).
|
||||
modifiers: Vec<(String, String)>,
|
||||
equipped: Option<Vec<Option<ItemStack>>>,
|
||||
/// The guest's cast fishing hook (`Player.fishing`).
|
||||
pub(super) hook: Option<super::fishing::Hook>,
|
||||
}
|
||||
|
||||
/// A guest's command waiting for the hub (`LanHost::commands`).
|
||||
@@ -259,6 +263,7 @@ impl Game<'_> {
|
||||
motion.pitch = g.pitch;
|
||||
motion.sprinting = g.sprinting;
|
||||
motion.may_fly = g.creative() || g.game_mode == SPECTATOR;
|
||||
motion.flying = g.flying;
|
||||
motion.invulnerable = motion.may_fly;
|
||||
let _ = motion.set_health(g.health, false);
|
||||
motion.health.food.food = g.food;
|
||||
@@ -342,6 +347,7 @@ impl Game<'_> {
|
||||
std::mem::swap(&mut self.adv.unlocked, &mut g.recipes_unlocked);
|
||||
std::mem::swap(&mut self.level.recipe_book, &mut g.recipe_book);
|
||||
std::mem::swap(&mut self.stats, &mut play.stats);
|
||||
std::mem::swap(&mut self.hook, &mut play.hook);
|
||||
let adv_loaded = std::mem::replace(&mut self.adv.loaded, true);
|
||||
let last_inventory = std::mem::replace(&mut self.adv.last_inventory, play.last_inventory);
|
||||
let death_cause = std::mem::replace(&mut self.ui.death_cause, play.death_cause.take());
|
||||
@@ -513,7 +519,8 @@ impl Game<'_> {
|
||||
std::mem::swap(&mut self.mount, &mut play.mount);
|
||||
self.container = container;
|
||||
self.screen = screen;
|
||||
self.sign_edit = host_sign;
|
||||
// A sign the guest's placement opened (SignBlock.openTextEdit).
|
||||
let guest_sign = std::mem::replace(&mut self.sign_edit, host_sign);
|
||||
// A lectern the guest's use opened (LecternBlock.openScreen).
|
||||
let guest_lectern = match std::mem::replace(&mut self.book, host_book) {
|
||||
Some(super::books::BookScreen::View {
|
||||
@@ -548,6 +555,7 @@ impl Game<'_> {
|
||||
std::mem::swap(&mut self.adv.unlocked, &mut g.recipes_unlocked);
|
||||
std::mem::swap(&mut self.level.recipe_book, &mut g.recipe_book);
|
||||
std::mem::swap(&mut self.stats, &mut play.stats);
|
||||
std::mem::swap(&mut self.hook, &mut play.hook);
|
||||
self.adv.loaded = adv_loaded;
|
||||
play.last_inventory = std::mem::replace(&mut self.adv.last_inventory, last_inventory);
|
||||
let stat_events = std::mem::take(&mut play.stats.events);
|
||||
@@ -587,6 +595,11 @@ impl Game<'_> {
|
||||
// A teleport (commands, a pearl) moves the guest; a pearl's is
|
||||
// sent even to where they stand (ServerPlayer.teleport).
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
// A hit with extra knockback stopped the guest's sprint
|
||||
// (Player.causeExtraKnockback's setSprinting(false)).
|
||||
if !m.sprinting {
|
||||
g.sprinting = false;
|
||||
}
|
||||
m.sprinting = false;
|
||||
let teleported = std::mem::take(&mut m.teleported);
|
||||
let moved: f64 = (0..3).map(|k| (at[k] - g.position[k]).powi(2)).sum();
|
||||
@@ -652,6 +665,9 @@ impl Game<'_> {
|
||||
if let Some(station) = opened_station {
|
||||
self.lan_station_opened(host, conn, station);
|
||||
}
|
||||
if let Some(edit) = guest_sign {
|
||||
self.lan_open_sign_editor(host, conn, edit);
|
||||
}
|
||||
if let Some(key) = guest_lectern {
|
||||
self.lan_open_lectern(host, conn, key);
|
||||
}
|
||||
@@ -865,14 +881,20 @@ impl Game<'_> {
|
||||
self.as_guest(host, conn, |g| g.drop_held(a.action == 4));
|
||||
}
|
||||
}
|
||||
// RELEASE_USE_ITEM: ServerPlayer.releaseUsingItem, with the
|
||||
// hand of the item in use acting.
|
||||
6 => {
|
||||
self.as_guest(host, conn, |g| {
|
||||
if let Some((slot, ticks)) = g.using.take()
|
||||
&& g.stack_in(slot)
|
||||
.is_some_and(|s| &*s.item == "minecraft:bow")
|
||||
{
|
||||
g.release_bow(ticks);
|
||||
}
|
||||
let Some(hand) = g.used_hand() else {
|
||||
return;
|
||||
};
|
||||
g.with_hand(hand, |g| {
|
||||
if let Some((slot, ticks)) = g.using.take()
|
||||
&& let Some(stack) = g.stack_in(slot).cloned()
|
||||
{
|
||||
g.release_used_item(&stack, ticks, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
// SWAP_ITEM_WITH_OFFHAND: the hands swap and the item in use stops
|
||||
@@ -1029,30 +1051,27 @@ impl Game<'_> {
|
||||
g.open_ridden_mount_menu();
|
||||
});
|
||||
}
|
||||
S::PlayerCommand(p) if p.action == 6 => self.lan_start_gliding(host, conn),
|
||||
S::ContainerButtonClick(p) => {
|
||||
self.lan_station_button(host, conn, p.container, p.button);
|
||||
}
|
||||
S::RenameItem(p) => self.lan_station_rename(host, conn, &p.name),
|
||||
S::SignUpdate(p) => self.lan_sign_update(host, conn, p),
|
||||
S::EditBook(p) => self.lan_edit_book(host, conn, p),
|
||||
S::ContainerSlotStateChanged(p) => {
|
||||
self.lan_crafter_slot_state(host, conn, p.container, p.slot, p.enabled);
|
||||
}
|
||||
S::SetBeacon(p) => self.lan_station_beacon(host, conn, p.primary, p.secondary),
|
||||
S::SelectTrade(p) => self.lan_select_trade(host, conn, p.item),
|
||||
S::ContainerClose(p) if self.lan_merchant_closed(host, conn, Some(p.container)) => {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if let Some(c) = g.carried.take() {
|
||||
self.drop_guest_items(host, gi, vec![c]);
|
||||
}
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
S::ContainerClose(p)
|
||||
if self.lan_crafting_closed(host, conn, Some(p.container))
|
||||
|| self.lan_station_closed(host, conn, Some(p.container))
|
||||
|| self.lan_mount_closed(host, conn, Some(p.container)) =>
|
||||
{
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if let Some(c) = g.carried.take() {
|
||||
self.drop_guest_items(host, gi, vec![c]);
|
||||
}
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
S::ContainerClick(p) => {
|
||||
let rules = ItemRules(self.res.items());
|
||||
@@ -1068,10 +1087,7 @@ impl Game<'_> {
|
||||
self.guest_container_closed(host, conn);
|
||||
}
|
||||
host.hub.container_closed(conn, p.container);
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if let Some(c) = g.carried.take() {
|
||||
self.drop_guest_items(host, gi, vec![c]);
|
||||
}
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
S::ChatCommand(c) => self.guest_chat_command(host, conn, &c.command),
|
||||
S::ChatCommandSigned(p) => self.guest_signed_command(host, conn, p),
|
||||
@@ -1218,6 +1234,17 @@ impl Game<'_> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `AbstractContainerMenu.removed`: what a closing player held on the
|
||||
/// cursor goes back into their inventory (`placeItemBackInInventory`,
|
||||
/// dropped only if it doesn't fit), and their client's cursor empties
|
||||
/// with the inventory it is sent.
|
||||
fn guest_carried_back(&mut self, host: &mut LanHost, gi: usize) {
|
||||
if let Some(c) = host.hub.guests[gi].carried.take() {
|
||||
self.lan_place_back(host, gi, vec![c]);
|
||||
host.hub.send_inventory(gi);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn drop_guest_items(&mut self, host: &LanHost, gi: usize, stacks: Vec<ItemStack>) {
|
||||
let g = &host.hub.guests[gi];
|
||||
let eye = [
|
||||
@@ -1250,10 +1277,24 @@ impl Game<'_> {
|
||||
id: i32,
|
||||
location: Option<[f64; 3]>,
|
||||
) -> Option<([f64; 3], [f64; 3])> {
|
||||
let eye = {
|
||||
let g = &host.hub.guests[gi];
|
||||
[g.position[0], g.position[1] + g.eye_height(), g.position[2]]
|
||||
};
|
||||
let object = || {
|
||||
let (lo, hi, origin) = self.object_target(id)?;
|
||||
let middle = [0, 1, 2].map(|k| (lo[k] + hi[k]) / 2.0);
|
||||
let Some(l) = location else {
|
||||
// A boat's mobs sit over its middle: aim low at its near
|
||||
// side, so the ray meets the boat before them (the server
|
||||
// attacks the entity it is sent).
|
||||
let carries = u32::try_from(id).is_ok_and(|v| {
|
||||
self.boats.iter().any(|b| b.id == v) && !self.boat_mobs(v).is_empty()
|
||||
});
|
||||
if carries {
|
||||
let near = |k: usize| eye[k].clamp(lo[k] + 0.01, hi[k] - 0.01);
|
||||
return Some([near(0), lo[1] + 0.05, near(2)]);
|
||||
}
|
||||
return Some(middle);
|
||||
};
|
||||
let point = [0, 1, 2].map(|k| origin[k] + l[k]);
|
||||
@@ -1284,7 +1325,6 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
let g = &mut host.hub.guests[gi];
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
let d = [centre[0] - eye[0], centre[1] - eye[1], centre[2] - eye[2]];
|
||||
let horizontal = (d[0] * d[0] + d[2] * d[2]).sqrt();
|
||||
g.yaw = (-d[0].atan2(d[2])).to_degrees() as f32;
|
||||
@@ -1300,8 +1340,11 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
if let Some(victim) = Self::pvp_victim(host, conn, id) {
|
||||
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike()) {
|
||||
self.pvp_land(host, victim, &strike);
|
||||
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike())
|
||||
&& self.pvp_land(host, victim, &strike)
|
||||
&& strike.stops_sprint()
|
||||
{
|
||||
self.as_guest(host, conn, |g| g.stop_sprint_after_extra_knockback());
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1683,6 +1726,17 @@ impl Game<'_> {
|
||||
g.use_delay -= 1;
|
||||
}
|
||||
});
|
||||
// ServerPlayerGameMode.handleBlockBreakAction: a creative
|
||||
// player's START breaks the block once (destroyAndAck) and
|
||||
// leaves nothing being destroyed; its client sends no STOP.
|
||||
// Kept, the dig broke whatever was put there next, every
|
||||
// destroy delay, for as long as the player broke nothing else.
|
||||
if digging.is_some()
|
||||
&& host.hub.guests[gi].creative()
|
||||
&& let Some(play) = host.play.get_mut(&conn)
|
||||
{
|
||||
play.digging = None;
|
||||
}
|
||||
}
|
||||
// Player.tick: a different main-hand item resets the attack
|
||||
// strength, and detectEquipmentUpdates gives the guest its modifiers.
|
||||
@@ -1704,16 +1758,23 @@ impl Game<'_> {
|
||||
// PlayerAdvancements.flushDirty.
|
||||
let sleeping = host.hub.guests[gi].sleeping;
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.tick_fishing();
|
||||
g.tick_maps();
|
||||
g.count_tick_stats(sleeping);
|
||||
g.tick_advancements();
|
||||
g.update_player_attributes();
|
||||
});
|
||||
self.lan_map_updates(host, conn);
|
||||
host.hub.flush_progress(conn);
|
||||
self.guest_dig_feedback(host, conn);
|
||||
self.guest_pickups(host, gi);
|
||||
self.guest_orbs(host, gi);
|
||||
if host.hub.guests[gi].health <= 0.0 && !host.hub.guests[gi].dead {
|
||||
self.guest_forget_digging(host, conn);
|
||||
// FishingHook.shouldStopFishing: its owner is dead.
|
||||
if let Some(play) = host.play.get_mut(&conn) {
|
||||
play.hook = None;
|
||||
}
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if g.health <= 0.0 && !g.dead {
|
||||
@@ -1727,6 +1788,9 @@ impl Game<'_> {
|
||||
}
|
||||
.death_reward(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
|
||||
let (name, uuid) = (g.name.clone(), g.uuid);
|
||||
// ServerPlayer.die: dropAllDeathLoot unless a spectator, from the eyes less 0.3.
|
||||
let drops = (g.game_mode != SPECTATOR)
|
||||
.then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]);
|
||||
let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take());
|
||||
let message = match cause {
|
||||
// ServerPlayer.die: the death message, to everyone unless
|
||||
@@ -1760,6 +1824,9 @@ impl Game<'_> {
|
||||
);
|
||||
// LivingEntity.die: the death animation, the dying player included.
|
||||
host.hub.entity_event(id, 3, at);
|
||||
if let Some(eyes) = drops {
|
||||
self.as_guest(host, conn, |game| game.drop_inventory(eyes));
|
||||
}
|
||||
self.spawn_orbs(at, reward);
|
||||
}
|
||||
}
|
||||
@@ -1846,6 +1913,13 @@ impl Game<'_> {
|
||||
/// sounds are heard at the guest.
|
||||
fn guest_server_tick(&mut self, host: &mut LanHost, conn: u32) {
|
||||
self.guest_vehicle_tick(host, conn);
|
||||
// The last move's fall stands in for the server's delta movement.
|
||||
let fell = host
|
||||
.hub
|
||||
.guest_mut(conn)
|
||||
.and_then(|g| g.moves.last().map(|m| m.0[1]))
|
||||
.unwrap_or(0.0);
|
||||
self.guest_glide_tick(host, conn, fell);
|
||||
let Some(g) = host.hub.guest_mut(conn) else {
|
||||
return;
|
||||
};
|
||||
@@ -1865,11 +1939,11 @@ impl Game<'_> {
|
||||
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
|
||||
let at = g.position;
|
||||
let sneaking = g.sneaking;
|
||||
let sounds = self.as_guest(host, conn, |game| {
|
||||
let events = self.as_guest(host, conn, |game| {
|
||||
let (rules, motion) = (game.world.health_rules, game.motion);
|
||||
let health = &motion.health;
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
return Vec::new();
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
let mut w = MotionWorld {
|
||||
entities: &[],
|
||||
@@ -1945,22 +2019,122 @@ impl Game<'_> {
|
||||
game.border_damage();
|
||||
game.tick_mob_effects();
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
return Vec::new();
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
let _ = player.peaceful_regeneration(rules);
|
||||
let _ = player.food_tick(health, rules);
|
||||
events.append(&mut player.events);
|
||||
events
|
||||
.into_iter()
|
||||
.filter_map(|e| match e {
|
||||
MotionEvent::HurtSound(sound) => Some(sound),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
let _ = MotionWorld {
|
||||
entities: &[],
|
||||
player,
|
||||
scene: &mut game.world,
|
||||
assets: &game.pack.assets,
|
||||
collision_binding: game.collision,
|
||||
resources: game.motion,
|
||||
}
|
||||
.server_travel();
|
||||
// A hurt since the last tick marked the velocity (Entity.markHurt):
|
||||
// ServerEntity.sendChanges sends it as it is after this travel.
|
||||
let marked = std::mem::take(&mut player.health.sync_velocity);
|
||||
(events, marked.then_some(player.velocity))
|
||||
});
|
||||
for sound in sounds.into_iter().flatten() {
|
||||
self.player_hurt_sound(sound, at);
|
||||
let Some((events, marked)) = events else {
|
||||
return;
|
||||
};
|
||||
for event in events {
|
||||
match event {
|
||||
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at),
|
||||
MotionEvent::Fall(event) => self.landing_block_updates(&event),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let (Some(v), Some(g)) = (marked, host.hub.guest(conn)) {
|
||||
g.send(&C::SetEntityMotion(play::SetEntityMotion {
|
||||
id: g.entity_id,
|
||||
velocity: [v.x, v.y, v.z],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// ServerGamePacketListenerImpl.handlePlayerCommand START_FALL_FLYING:
|
||||
/// `Player.tryToStartFallFlying`, or else `stopFallFlying` (a second
|
||||
/// start while gliding ends the glide, as in vanilla).
|
||||
fn lan_start_gliding(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let Some(g) = host.hub.guest_mut(conn) else {
|
||||
return;
|
||||
};
|
||||
let start = !g.fall_flying && self.guest_can_glide(host, conn) == Some(true);
|
||||
let in_liquid = self
|
||||
.as_guest(host, conn, |game| {
|
||||
game.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.fluids.was_touching_water || p.is_in_lava())
|
||||
})
|
||||
.unwrap_or(true);
|
||||
if let Some(g) = host.hub.guest_mut(conn) {
|
||||
g.fall_flying = start && !in_liquid;
|
||||
}
|
||||
}
|
||||
|
||||
/// `Player.canGlide`: not flying, airborne, not riding, no levitation, and
|
||||
/// a glider that its next point of wear won't break.
|
||||
fn guest_can_glide(&mut self, host: &mut LanHost, conn: u32) -> Option<bool> {
|
||||
let g = host.hub.guest_mut(conn)?;
|
||||
if g.flying || g.on_ground || g.vehicle.is_some() {
|
||||
return Some(false);
|
||||
}
|
||||
self.as_guest(host, conn, |game| {
|
||||
game.glider_worn() && game.potions.effects.get("levitation").is_none()
|
||||
})
|
||||
}
|
||||
|
||||
/// `LivingEntity.updateFallFlying` on the server, once a tick for a
|
||||
/// gliding guest: the glide ends once `canGlide` fails; otherwise every
|
||||
/// 20th tick wears the glider by one, and every 10th is an
|
||||
/// `elytra_glide` game event. Entity.checkFallDistanceAccumulation runs
|
||||
/// first: a glide that isn't diving keeps the fall distance at 1.
|
||||
fn guest_glide_tick(&mut self, host: &mut LanHost, conn: u32, fell: f64) {
|
||||
let Some(g) = host.hub.guest_mut(conn) else {
|
||||
return;
|
||||
};
|
||||
if !g.fall_flying {
|
||||
if let Some(m) = g.motion.as_mut() {
|
||||
m.fall_fly_ticks = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let creative = g.creative();
|
||||
if let Some(m) = g.motion.as_mut()
|
||||
&& fell > -0.5
|
||||
&& m.fall_distance > 1.0
|
||||
{
|
||||
m.fall_distance = 1.0;
|
||||
}
|
||||
if self.guest_can_glide(host, conn) != Some(true) {
|
||||
if let Some(g) = host.hub.guest_mut(conn) {
|
||||
g.fall_flying = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.as_guest(host, conn, |game| {
|
||||
let Some(p) = game.player.as_mut() else {
|
||||
return;
|
||||
};
|
||||
p.fall_fly_ticks += 1;
|
||||
let next = p.fall_fly_ticks + 1;
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
if next % 10 != 0 {
|
||||
return;
|
||||
}
|
||||
if (next / 10) % 2 == 0
|
||||
&& !creative
|
||||
&& let Some(elytra) = game.armor[1].as_mut()
|
||||
{
|
||||
elytra.damage += 1;
|
||||
}
|
||||
let ctx = game.player_context(None);
|
||||
game.post_game_event("minecraft:elytra_glide", at, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
/// `Player.isInWall`: a suffocating block in the 0.48-wide box at the
|
||||
@@ -2275,6 +2449,32 @@ impl Game<'_> {
|
||||
.map_or(&[][..], |log| log.get(logged..).unwrap_or(&[]))
|
||||
.to_vec();
|
||||
let facts = self.res.facts();
|
||||
// BlockItem.place: `playSound(player, ...)`, for the others only. The
|
||||
// placer's client plays it as its block appears.
|
||||
let placed: Vec<([f64; 3], String)> = changed
|
||||
.iter()
|
||||
.map(|&(x, y, z)| {
|
||||
let at = [f64::from(x) + 0.5, f64::from(y) + 0.5, f64::from(z) + 0.5];
|
||||
let name = &facts.get(self.world.block(x, y, z)).sounds.place;
|
||||
(at, name.trim_start_matches("minecraft:").to_owned())
|
||||
})
|
||||
.collect();
|
||||
if let Some(capture) = self.audio.capture.as_mut() {
|
||||
let mut i = heard.min(capture.len());
|
||||
while i < capture.len() {
|
||||
let (name, at, ..) = &capture[i];
|
||||
let place = placed
|
||||
.iter()
|
||||
.any(|(p, n)| p == at && name.trim_start_matches("minecraft:") == n.as_str());
|
||||
if place {
|
||||
let (name, at, volume, pitch) = capture.remove(i);
|
||||
let source = horizon_client::settings::sound_source(&name) as i32;
|
||||
host.hub.sound(&name, at, volume, pitch, source, Some(conn));
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed.is_empty()
|
||||
|| !changed.iter().all(|&(x, y, z)| {
|
||||
super::remote::hand_operated(&facts.get(self.world.block(x, y, z)).name)
|
||||
|
||||
@@ -321,20 +321,7 @@ impl Game<'_> {
|
||||
}
|
||||
for p in self.creatures.projectiles.iter().filter(|p| p.id != 0) {
|
||||
use horizon_client::projectiles::ProjectileKind as K;
|
||||
let kind = match &p.kind {
|
||||
K::Arrow { .. } => "minecraft:arrow",
|
||||
K::Snowball => "minecraft:snowball",
|
||||
K::Egg => "minecraft:egg",
|
||||
K::EnderPearl => "minecraft:ender_pearl",
|
||||
K::SmallFireball => "minecraft:small_fireball",
|
||||
K::LargeFireball { .. } => "minecraft:fireball",
|
||||
K::LlamaSpit => "minecraft:llama_spit",
|
||||
K::WindCharge => "minecraft:wind_charge",
|
||||
K::BreezeWindCharge => "minecraft:breeze_wind_charge",
|
||||
K::ShulkerBullet { .. } => "minecraft:shulker_bullet",
|
||||
K::WitherSkull { .. } => "minecraft:wither_skull",
|
||||
K::Potion { .. } => "minecraft:splash_potion",
|
||||
};
|
||||
let kind = p.kind.id();
|
||||
let mut e = state(p.id, kind, at(p.position), p.yaw, false);
|
||||
e.pitch = p.pitch;
|
||||
e.velocity = at(p.velocity);
|
||||
|
||||
@@ -78,6 +78,8 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
|
||||
target: Who::Player,
|
||||
..
|
||||
}
|
||||
| MobEvent::EffectPlayer { .. }
|
||||
| MobEvent::AxolotlSupportPlayer
|
||||
)
|
||||
}
|
||||
|
||||
@@ -115,6 +117,25 @@ impl Game<'_> {
|
||||
views
|
||||
}
|
||||
|
||||
/// Runs `f` as each guest in play here, for what strikes every player in
|
||||
/// an area (`Level.getEntities`), not only the window's own.
|
||||
pub(super) fn each_guest_hit(&mut self, mut f: impl FnMut(&mut Self)) {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
let conns: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in conns {
|
||||
self.guest_hit_by(&mut host, conn, &mut f);
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
|
||||
/// Mob events aimed at a guest, run as that guest (`guest_hit_by`).
|
||||
pub(super) fn lan_guest_mob_events(&mut self, events: Vec<(u32, MobEvent)>) {
|
||||
if events.is_empty() {
|
||||
@@ -192,7 +213,9 @@ impl Game<'_> {
|
||||
|
||||
impl Game<'_> {
|
||||
/// Hurt a guest: `f` runs as them, and the knockback it gave reaches
|
||||
/// their client (`hurtMarked`: ClientboundSetEntityMotionPacket).
|
||||
/// their client. A hurt marks the velocity (Entity.markHurt), which goes
|
||||
/// out once the guest's server copy has travelled (`guest_server_tick`);
|
||||
/// a push with no hurt goes out at once.
|
||||
pub(super) fn guest_hit_by<R>(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
@@ -202,16 +225,47 @@ impl Game<'_> {
|
||||
let (result, pushed) = self.as_guest(host, conn, |g| {
|
||||
let before = g.player.as_ref().map(|p| p.velocity);
|
||||
let result = f(g);
|
||||
let after = g.player.as_ref().map(|p| p.velocity);
|
||||
(result, after.filter(|v| Some(*v) != before))
|
||||
let pushed = g
|
||||
.player
|
||||
.as_ref()
|
||||
.filter(|p| !p.health.sync_velocity && Some(p.velocity) != before)
|
||||
.map(|p| p.velocity);
|
||||
(result, pushed)
|
||||
})?;
|
||||
if let (Some(v), Some(g)) = (pushed, host.hub.guest(conn)) {
|
||||
self.send_guest_motion(host, conn, pushed);
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// A player's melee hit on a guest (Player.causeExtraKnockback on a
|
||||
/// ServerPlayer): a hurt that marked the velocity sends it at once, and
|
||||
/// the server's copy goes back to the velocity it had before the hit.
|
||||
pub(super) fn guest_struck_by<R>(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
f: impl FnOnce(&mut Self) -> R,
|
||||
) -> Option<R> {
|
||||
let (result, sent) = self.as_guest(host, conn, |g| {
|
||||
let before = g.player.as_ref().map(|p| p.velocity);
|
||||
let result = f(g);
|
||||
let sent = g.player.as_mut().zip(before).and_then(|(p, before)| {
|
||||
std::mem::take(&mut p.health.sync_velocity)
|
||||
.then(|| std::mem::replace(&mut p.velocity, before))
|
||||
});
|
||||
(result, sent)
|
||||
})?;
|
||||
self.send_guest_motion(host, conn, sent);
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// ClientboundSetEntityMotionPacket to a guest about themselves.
|
||||
pub(super) fn send_guest_motion(&self, host: &LanHost, conn: u32, velocity: Option<Vec3>) {
|
||||
if let (Some(v), Some(g)) = (velocity, host.hub.guest(conn)) {
|
||||
g.send(&C::SetEntityMotion(play::SetEntityMotion {
|
||||
id: g.entity_id,
|
||||
velocity: [v.x, v.y, v.z],
|
||||
}));
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! `/particle` uses) and `ClientboundExplodePacket` (`ServerLevel.explode`).
|
||||
use super::Game;
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use horizon_protocol::{
|
||||
entity_data::{Particle, ParticleData},
|
||||
packets::play::{self, Clientbound as C},
|
||||
@@ -154,7 +154,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.handleExplosion`: the blast's particles (and its
|
||||
/// sound, when the packet carries it).
|
||||
/// sound, when the packet carries it), then the player's push.
|
||||
pub(super) fn remote_explode(&mut self, p: &play::Explode) {
|
||||
if p.play_sound {
|
||||
let pitch = (1.0
|
||||
@@ -168,37 +168,88 @@ impl Game<'_> {
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
let (world, facts) = (&self.world, self.res.facts());
|
||||
self.particles
|
||||
.explosion(p.center, p.radius, p.block_count.max(0) as usize, &|b| {
|
||||
facts.is_air(world.block(b[0], b[1], b[2]))
|
||||
});
|
||||
if let Some(options) = self.received_particle(&p.particle, p.center) {
|
||||
self.particles.add(&options, p.center, [1.0, 0.0, 0.0]);
|
||||
}
|
||||
// A wind charge's burst throws none.
|
||||
if !p.block_particles.is_empty() {
|
||||
let (world, facts) = (&self.world, self.res.facts());
|
||||
self.particles.explosion_blocks(
|
||||
p.center,
|
||||
p.radius,
|
||||
p.block_count.max(0) as usize,
|
||||
&|b| facts.is_air(world.block(b[0], b[1], b[2])),
|
||||
);
|
||||
}
|
||||
// Entity.pushFromExplosion.
|
||||
if let (Some(push), Some(player)) = (p.knockback, self.player.as_mut()) {
|
||||
player.velocity = Vec3::new(
|
||||
player.velocity.x + push[0],
|
||||
player.velocity.y + push[1],
|
||||
player.velocity.z + push[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerLevel.explode`'s packet to each player within 64 blocks. The
|
||||
/// sound and the push reach them on their own (the level's sounds, the
|
||||
/// players' motion), so the packet carries neither.
|
||||
/// motion a hurt player is sent), so the packet carries neither.
|
||||
pub(super) fn lan_explode(&self, center: [f64; 3], radius: f32, block_count: usize) {
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let particle = |name: &str| {
|
||||
Some(Particle {
|
||||
id: host.hub.registry_id("minecraft:particle_type", name)?,
|
||||
data: None,
|
||||
})
|
||||
};
|
||||
let emitter = if radius < 2.0 {
|
||||
"minecraft:explosion"
|
||||
} else {
|
||||
"minecraft:explosion_emitter"
|
||||
};
|
||||
let (Some(at_center), Some(poof), Some(smoke)) = (
|
||||
particle(emitter),
|
||||
particle("minecraft:poof"),
|
||||
particle("minecraft:smoke"),
|
||||
) else {
|
||||
return;
|
||||
let sound = "minecraft:entity.generic.explode";
|
||||
if let Some(packet) =
|
||||
self.explosion_packet(center, radius, block_count, emitter, sound, true)
|
||||
{
|
||||
self.send_explosion(packet, &[]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A wind charge's burst (`AbstractWindCharge.explode`): gust emitters,
|
||||
/// no block particles, and for each player it pushed the push
|
||||
/// (`ServerExplosion.hitPlayers`), which only that client applies, as
|
||||
/// nothing hurt them.
|
||||
pub(super) fn lan_wind_burst(
|
||||
&self,
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
sound: &str,
|
||||
block_count: usize,
|
||||
pushed: &[(u32, [f64; 3])],
|
||||
) {
|
||||
let emitter = if radius < 2.0 {
|
||||
"minecraft:gust_emitter_small"
|
||||
} else {
|
||||
"minecraft:gust_emitter_large"
|
||||
};
|
||||
if let Some(packet) =
|
||||
self.explosion_packet(center, radius, block_count, emitter, sound, false)
|
||||
{
|
||||
self.send_explosion(packet, pushed);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClientboundExplodePacket` without a push, or None while not serving.
|
||||
/// The sound is the level's, which the players hear already, so it isn't
|
||||
/// played from the packet.
|
||||
fn explosion_packet(
|
||||
&self,
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
block_count: usize,
|
||||
emitter: &str,
|
||||
sound: &str,
|
||||
block_particles: bool,
|
||||
) -> Option<play::Explode> {
|
||||
let hub = &self.lan.as_ref()?.hub;
|
||||
let particle = |name: &str| {
|
||||
Some(Particle {
|
||||
id: hub.registry_id("minecraft:particle_type", name)?,
|
||||
data: None,
|
||||
})
|
||||
};
|
||||
// Level.DEFAULT_EXPLOSION_BLOCK_PARTICLES.
|
||||
let info = |particle, scaling| play::ExplosionParticle {
|
||||
@@ -207,22 +258,49 @@ impl Game<'_> {
|
||||
speed: 1.0,
|
||||
weight: 1,
|
||||
};
|
||||
let packet = C::Explode(play::Explode {
|
||||
let block_particles = if block_particles {
|
||||
vec![
|
||||
info(particle("minecraft:poof")?, 0.5),
|
||||
info(particle("minecraft:smoke")?, 1.0),
|
||||
]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Some(play::Explode {
|
||||
center,
|
||||
radius,
|
||||
block_count: block_count as i32,
|
||||
knockback: None,
|
||||
particle: at_center,
|
||||
sound: match host
|
||||
.hub
|
||||
.registry_id("minecraft:sound_event", "minecraft:entity.generic.explode")
|
||||
{
|
||||
particle: particle(emitter)?,
|
||||
sound: match hub.registry_id("minecraft:sound_event", sound) {
|
||||
Some(id) => (id, None),
|
||||
None => (-1, Some(("minecraft:entity.generic.explode".into(), None))),
|
||||
None => (-1, Some((sound.into(), None))),
|
||||
},
|
||||
block_particles: vec![info(poof, 0.5), info(smoke, 1.0)],
|
||||
block_particles,
|
||||
play_sound: false,
|
||||
});
|
||||
host.hub.broadcast_near(center, 64.0, &packet, None);
|
||||
})
|
||||
}
|
||||
|
||||
/// `packet` to each player within 64 blocks of its centre, with the push
|
||||
/// `pushed` holds for them.
|
||||
fn send_explosion(&self, mut packet: play::Explode, pushed: &[(u32, [f64; 3])]) {
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let center = packet.center;
|
||||
let plain = C::Explode(packet.clone()).encode();
|
||||
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
|
||||
let d: f64 = (0..3).map(|k| (g.position[k] - center[k]).powi(2)).sum();
|
||||
if d >= 4096.0 {
|
||||
continue;
|
||||
}
|
||||
match pushed.iter().find(|(conn, _)| *conn == g.conn) {
|
||||
Some((_, push)) => {
|
||||
packet.knockback = Some(*push);
|
||||
g.send(&C::Explode(packet.clone()));
|
||||
}
|
||||
None => g.sender.send(plain.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,14 @@ pub(super) struct Strike {
|
||||
team: Option<horizon_client::scoreboard::Team>,
|
||||
}
|
||||
|
||||
impl Strike {
|
||||
/// Whether it has extra knockback, which stops the attacker's sprint
|
||||
/// once it lands (Player.causeExtraKnockback).
|
||||
pub(super) fn stops_sprint(&self) -> bool {
|
||||
self.knockback.0 > 0.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The guest on the host's crosshair and how far, within reach.
|
||||
pub(super) fn guest_hit(&self) -> Option<(u32, f64)> {
|
||||
@@ -74,7 +82,7 @@ impl Game<'_> {
|
||||
};
|
||||
if let Some(strike) = self.pvp_strike()
|
||||
&& self.pvp_land(&mut host, Victim::Guest(conn), &strike)
|
||||
&& strike.knockback.0 > 0.0
|
||||
&& strike.stops_sprint()
|
||||
{
|
||||
self.stop_sprint_after_extra_knockback();
|
||||
}
|
||||
@@ -240,7 +248,7 @@ impl Game<'_> {
|
||||
&& harm
|
||||
&& match victim {
|
||||
Victim::Host => land(self),
|
||||
Victim::Guest(conn) => self.guest_hit_by(host, conn, land).unwrap_or(false),
|
||||
Victim::Guest(conn) => self.guest_struck_by(host, conn, land).unwrap_or(false),
|
||||
};
|
||||
let sound = match (hurt, s.crit, s.scale > 0.9) {
|
||||
(false, ..) => "minecraft:entity.player.attack.nodamage",
|
||||
|
||||
@@ -588,17 +588,7 @@ impl Game<'_> {
|
||||
let [x, y, z] = gs.pos;
|
||||
let name = &self.res.facts().get(self.world.block(x, y, z)).name;
|
||||
let block = StationKind::from_block(name) == Some(gs.kind);
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
// The block interaction range (4.5, creative 5) and the buffer.
|
||||
let reach = if g.creative() { 5.0 } else { 4.5 } + 4.0;
|
||||
let near = (0..3)
|
||||
.map(|k| {
|
||||
let lo = f64::from(gs.pos[k]);
|
||||
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
|
||||
})
|
||||
.sum::<f64>()
|
||||
< reach * reach;
|
||||
if !(block && near) {
|
||||
if !(block && within_reach(g, gs.pos)) {
|
||||
let window = gs.window;
|
||||
self.lan_station_closed(host, conn, Some(window));
|
||||
host.hub.send_to(
|
||||
@@ -670,3 +660,18 @@ pub(super) fn station_inputs(gs: &GuestStation) -> Vec<ItemStack> {
|
||||
.map(|(_, _, inputs)| inputs.filter_map(|s| gs.menu.slot(s).cloned()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `Player.isWithinBlockInteractionRange(pos, 4)`: the guest's eyes within
|
||||
/// its block interaction range (4.5, creative 5) and a 4-block buffer of the
|
||||
/// block's box.
|
||||
pub(super) fn within_reach(g: &horizon_client::lan::guest::Guest, pos: [i32; 3]) -> bool {
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
let reach = if g.creative() { 5.0 } else { 4.5 } + 4.0;
|
||||
(0..3)
|
||||
.map(|k| {
|
||||
let lo = f64::from(pos[k]);
|
||||
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
|
||||
})
|
||||
.sum::<f64>()
|
||||
< reach * reach
|
||||
}
|
||||
@@ -48,10 +48,9 @@ impl Game<'_> {
|
||||
if let Some(id) = state.mob
|
||||
&& state.session.villager
|
||||
{
|
||||
// startTrading with the guest's reputation (their effects are
|
||||
// not modelled on the host: no Hero of the Village).
|
||||
// startTrading with the guest's reputation. The session already
|
||||
// captured their Hero effect while they acted as the player.
|
||||
state.session.reputation = self.villager_reputation_of(id, uuid);
|
||||
state.session.hero = None;
|
||||
state.session.update_special_prices();
|
||||
}
|
||||
let title = self.merchant_display_name(&state);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! here with the rider's input (`ServerPlayer.getLastClientMoveIntent`).
|
||||
//! Sneaking gets out (`Player.rideTick`: `wantsToStopRiding`), and the client
|
||||
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
|
||||
use super::{Game, Keys, boats::SEAT, lan::LanHost};
|
||||
use super::{Game, Keys, lan::LanHost, minecarts::SEAT};
|
||||
use horizon_client::collisions::SceneCollisions;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_protocol::{
|
||||
@@ -120,8 +120,8 @@ impl Game<'_> {
|
||||
game.dismount(i);
|
||||
return None;
|
||||
}
|
||||
let p = game.boats[i].position;
|
||||
return Some([p.x, p.y + SEAT, p.z]);
|
||||
let p = game.player_boat_seat(i);
|
||||
return Some([p.x, p.y, p.z]);
|
||||
}
|
||||
// The minecart moves with the rider's input, and lets it out when
|
||||
// it sneaks or the cart breaks. An animal takes the rider's input
|
||||
@@ -161,19 +161,19 @@ impl Game<'_> {
|
||||
let at = self
|
||||
.boats
|
||||
.iter()
|
||||
.find(|b| b.id == vehicle)
|
||||
.map(|b| b.position)
|
||||
.position(|b| b.id == vehicle)
|
||||
.map(|i| (self.boats[i].position, self.player_boat_seat(i)))
|
||||
.or_else(|| {
|
||||
self.carts
|
||||
.iter()
|
||||
.find(|c| c.id == vehicle)
|
||||
.map(|c| c.position)
|
||||
.map(|c| (c.position, c.position.add_vector(Vec3::new(0.0, SEAT, 0.0))))
|
||||
});
|
||||
if let Some(p) = at {
|
||||
if let Some((p, seat)) = at {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.vehicle_good = Some([p.x, p.y, p.z]);
|
||||
g.previous_position = g.position;
|
||||
g.position = [p.x, p.y + SEAT, p.z];
|
||||
g.position = [seat.x, seat.y, seat.z];
|
||||
g.on_ground = false;
|
||||
return;
|
||||
}
|
||||
@@ -205,7 +205,9 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let [x, y, z] = p.position;
|
||||
if !(x.is_finite() && y.is_finite() && z.is_finite())
|
||||
if !x.is_finite()
|
||||
|| !y.is_finite()
|
||||
|| !z.is_finite()
|
||||
|| !p.yaw.is_finite()
|
||||
|| !p.pitch.is_finite()
|
||||
{
|
||||
@@ -287,10 +289,11 @@ impl Game<'_> {
|
||||
b.previous_yaw = b.yaw;
|
||||
b.yaw = p.yaw;
|
||||
b.on_ground = p.on_ground;
|
||||
let seat = self.player_boat_seat(i);
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.vehicle_good = Some([target.x, target.y, target.z]);
|
||||
g.previous_position = g.position;
|
||||
g.position = [target.x, target.y + SEAT, target.z];
|
||||
g.position = [seat.x, seat.y, seat.z];
|
||||
}
|
||||
|
||||
/// `handlePaddleBoat`: the paddles of the boat the player drives.
|
||||
|
||||
@@ -221,7 +221,7 @@ impl Game<'_> {
|
||||
/// holds it (`self.lan` is taken during the network tick).
|
||||
pub(super) fn enter_level(&mut self, host: Option<&mut LanHost>, target: Dim) -> Result<()> {
|
||||
// The in-frame game keeps its one level (`enter_dimension`).
|
||||
if self.options.dedicated.is_none() {
|
||||
if self.options.execution_role.is_client() {
|
||||
return Ok(());
|
||||
}
|
||||
if target != self.dim {
|
||||
@@ -261,7 +261,7 @@ impl Game<'_> {
|
||||
/// nobody is in it; returns the level to put back afterwards. Only a
|
||||
/// server holds other levels.
|
||||
pub(super) fn enter_command_level(&mut self, dim: Dim) -> Option<Dim> {
|
||||
if self.options.dedicated.is_none() || dim == self.dim {
|
||||
if self.options.execution_role.is_client() || dim == self.dim {
|
||||
return None;
|
||||
}
|
||||
let back = self.dim;
|
||||
@@ -296,7 +296,7 @@ impl Game<'_> {
|
||||
self.update_dedicated_streaming();
|
||||
self.update_world();
|
||||
result = if paused {
|
||||
self.tick_lan();
|
||||
self.tick_lan(true);
|
||||
Ok(())
|
||||
} else if k == 0 {
|
||||
self.tick()
|
||||
|
||||
@@ -280,11 +280,12 @@ impl Game<'_> {
|
||||
}
|
||||
/// `thunderHit` for entities within 3 blocks (up to 9 above).
|
||||
fn thunder_hit(&mut self, b: &mut Bolt) {
|
||||
let inside = |p: [f64; 3]| {
|
||||
(p[0] - b.at[0]).abs() <= 3.0
|
||||
&& (p[2] - b.at[2]).abs() <= 3.0
|
||||
&& p[1] >= b.at[1] - 3.0
|
||||
&& p[1] <= b.at[1] + 9.0
|
||||
let at = b.at;
|
||||
let inside = move |p: [f64; 3]| {
|
||||
(p[0] - at[0]).abs() <= 3.0
|
||||
&& (p[2] - at[2]).abs() <= 3.0
|
||||
&& p[1] >= at[1] - 3.0
|
||||
&& p[1] <= at[1] + 9.0
|
||||
};
|
||||
let damage = Damage {
|
||||
kind: DamageKind::Generic,
|
||||
@@ -349,19 +350,24 @@ impl Game<'_> {
|
||||
if !events.is_empty() {
|
||||
let _ = self.handle_mob_events(events);
|
||||
}
|
||||
if let Some(p) = self.player.as_ref().map(|p| p.position)
|
||||
&& inside([p.x, p.y, p.z])
|
||||
{
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.fire_ticks += 1;
|
||||
if p.fire_ticks == 0
|
||||
&& let Err(error) = p.ignite_for_ticks(160)
|
||||
{
|
||||
self.error = Some(error);
|
||||
// Every player in the box, the server's players too.
|
||||
let strike = move |g: &mut Self| {
|
||||
if let Some(p) = g.player.as_ref().map(|p| p.position)
|
||||
&& inside([p.x, p.y, p.z])
|
||||
{
|
||||
if let Some(p) = g.player.as_mut() {
|
||||
p.fire_ticks += 1;
|
||||
if p.fire_ticks == 0
|
||||
&& let Err(error) = p.ignite_for_ticks(160)
|
||||
{
|
||||
g.error = Some(error);
|
||||
}
|
||||
}
|
||||
g.hurt_player_by("minecraft:lightning_bolt", 5.0);
|
||||
}
|
||||
self.hurt_player_by("minecraft:lightning_bolt", 5.0);
|
||||
}
|
||||
};
|
||||
strike(self);
|
||||
self.each_guest_hit(strike);
|
||||
// Dropped items burn up (ItemEntity health 5), save fire-resistant ones.
|
||||
self.dropped.retain(|e| {
|
||||
!inside([e.position.x, e.position.y, e.position.z])
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! shading, the dithered edge), the player marker, `data/minecraft/maps/<id>.dat` and
|
||||
//! `maps/last_id.dat`, the 128x128 texture in an atlas slot, the two-handed
|
||||
//! first-person view and maps in item frames.
|
||||
use super::Game;
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
blocks::{Fluid, MAP_COLORS},
|
||||
hud::{MAP_SLOTS, Rect},
|
||||
@@ -14,13 +14,48 @@ use horizon_client::{
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A decoration: sprite, map position (-128..127, half pixels) and rotation (0..15).
|
||||
/// A decoration: sprite, map position (-128..127, half pixels) and rotation
|
||||
/// (0..15), and the player it marks (an item's own decorations mark none).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct Decoration {
|
||||
pub sprite: &'static str,
|
||||
pub x: i8,
|
||||
pub y: i8,
|
||||
pub rot: u8,
|
||||
pub holder: Option<u32>,
|
||||
}
|
||||
/// The host among a map's holders (the guests are their connections).
|
||||
const HOST: u32 = u32::MAX;
|
||||
/// A player carrying a map, or seeing it framed
|
||||
/// (`MapItemSavedData.HoldingPlayer`): the column sweep's step, and what
|
||||
/// changed since the last `ClientboundMapItemDataPacket` it was sent.
|
||||
struct Holder {
|
||||
who: u32,
|
||||
/// The last tick it carried the map, and saw it framed.
|
||||
carried: i64,
|
||||
framed: i64,
|
||||
step: i32,
|
||||
/// The changed pixels' box (min x, min y, max x, max y).
|
||||
dirty: Option<[i32; 4]>,
|
||||
decorations: bool,
|
||||
tick: u32,
|
||||
}
|
||||
impl Holder {
|
||||
fn new(who: u32) -> Self {
|
||||
Self {
|
||||
who,
|
||||
carried: 0,
|
||||
framed: 0,
|
||||
step: 0,
|
||||
dirty: Some([0, 0, 127, 127]),
|
||||
decorations: true,
|
||||
tick: 0,
|
||||
}
|
||||
}
|
||||
/// Still carrying the map, or seeing it framed (frames tick every 10).
|
||||
fn holds(&self, now: i64) -> bool {
|
||||
self.carried + 1 >= now || self.framed + 10 >= now
|
||||
}
|
||||
}
|
||||
pub(super) struct MapData {
|
||||
pub center: [i32; 2],
|
||||
@@ -31,8 +66,7 @@ pub(super) struct MapData {
|
||||
pub unlimited: bool,
|
||||
pub colors: Vec<u8>,
|
||||
pub decorations: Vec<Decoration>,
|
||||
/// HoldingPlayer.step for the column sweep.
|
||||
step: i32,
|
||||
holders: Vec<Holder>,
|
||||
/// Changed since the last save / texture upload.
|
||||
dirty: bool,
|
||||
texture_dirty: bool,
|
||||
@@ -51,7 +85,7 @@ impl MapData {
|
||||
unlimited: false,
|
||||
colors: vec![0; 128 * 128],
|
||||
decorations: Vec::new(),
|
||||
step: 0,
|
||||
holders: Vec::new(),
|
||||
dirty: true,
|
||||
texture_dirty: true,
|
||||
}
|
||||
@@ -64,8 +98,128 @@ impl MapData {
|
||||
self.colors[i] = color;
|
||||
self.dirty = true;
|
||||
self.texture_dirty = true;
|
||||
// setColorsDirty: every holder's next packet carries it.
|
||||
for h in &mut self.holders {
|
||||
h.dirty = Some(match h.dirty {
|
||||
Some([x0, y0, x1, y1]) => [x0.min(x), y0.min(y), x1.max(x), y1.max(y)],
|
||||
None => [x, y, x, y],
|
||||
});
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `tickCarriedBy`'s holder for `who`, added when new.
|
||||
fn holder(&mut self, who: u32) -> &mut Holder {
|
||||
let i = match self.holders.iter().position(|h| h.who == who) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
self.holders.push(Holder::new(who));
|
||||
self.holders.len() - 1
|
||||
}
|
||||
};
|
||||
&mut self.holders[i]
|
||||
}
|
||||
/// The holders that no longer carry the map nor see it framed go, and
|
||||
/// their markers with them (`tickCarriedBy`'s removeDecoration).
|
||||
fn drop_holders(&mut self, now: i64) {
|
||||
self.holders.retain(|h| h.holds(now));
|
||||
let holders = &self.holders;
|
||||
let before = self.decorations.len();
|
||||
self.decorations
|
||||
.retain(|d| d.holder.is_none_or(|w| holders.iter().any(|h| h.who == w)));
|
||||
if self.decorations.len() != before {
|
||||
self.decorations_changed();
|
||||
}
|
||||
}
|
||||
/// setDecorationsDirty.
|
||||
fn decorations_changed(&mut self) {
|
||||
for h in &mut self.holders {
|
||||
h.decorations = true;
|
||||
}
|
||||
}
|
||||
/// `addDecoration` / `removeDecoration` for `holder`'s marker: in place,
|
||||
/// so that the order (the drawing order) stays.
|
||||
fn set_marker(&mut self, holder: u32, marker: Option<Decoration>) {
|
||||
let i = self
|
||||
.decorations
|
||||
.iter()
|
||||
.position(|d| d.holder == Some(holder));
|
||||
let changed = match (i, marker) {
|
||||
(Some(i), Some(d)) if self.decorations[i] == d => false,
|
||||
(Some(i), Some(d)) => {
|
||||
self.decorations[i] = d;
|
||||
true
|
||||
}
|
||||
(Some(i), None) => {
|
||||
self.decorations.remove(i);
|
||||
true
|
||||
}
|
||||
(None, Some(d)) => {
|
||||
self.decorations.push(d);
|
||||
true
|
||||
}
|
||||
(None, None) => false,
|
||||
};
|
||||
if changed {
|
||||
self.decorations_changed();
|
||||
}
|
||||
}
|
||||
/// `HoldingPlayer.nextUpdatePacket` for `who`: the changed pixels, and
|
||||
/// the decorations (at most every 5th call while they changed).
|
||||
fn update_packet(
|
||||
&mut self,
|
||||
id: i32,
|
||||
who: u32,
|
||||
kind: impl Fn(&str) -> Option<i32>,
|
||||
) -> Option<horizon_protocol::packets::play::MapItemData> {
|
||||
use horizon_protocol::packets::play::{MapDecoration, MapItemData, MapPatch};
|
||||
let h = self.holders.iter_mut().find(|h| h.who == who)?;
|
||||
let patch = h.dirty.take().map(|[x0, y0, x1, y1]| {
|
||||
let (width, height) = (x1 + 1 - x0, y1 + 1 - y0);
|
||||
let mut colors = Vec::with_capacity((width * height) as usize);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
colors.push(self.colors[(x0 + x + (y0 + y) * 128) as usize]);
|
||||
}
|
||||
}
|
||||
MapPatch {
|
||||
start_x: x0,
|
||||
start_y: y0,
|
||||
width,
|
||||
height,
|
||||
colors,
|
||||
}
|
||||
});
|
||||
let decorations = if h.decorations && {
|
||||
let due = h.tick % 5 == 0;
|
||||
h.tick += 1;
|
||||
due
|
||||
} {
|
||||
h.decorations = false;
|
||||
Some(
|
||||
self.decorations
|
||||
.iter()
|
||||
.filter_map(|d| {
|
||||
Some(MapDecoration {
|
||||
kind: kind(decoration_type(d.sprite)?)?,
|
||||
x: d.x,
|
||||
y: d.y,
|
||||
rot: d.rot as i8,
|
||||
name: None,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(patch.is_some() || decorations.is_some()).then_some(MapItemData {
|
||||
map_id: id,
|
||||
scale: self.scale as i8,
|
||||
locked: self.locked,
|
||||
decorations,
|
||||
patch,
|
||||
})
|
||||
}
|
||||
fn to_nbt(&self) -> Tag {
|
||||
let flag = |b: bool| Tag::Byte(i8::from(b));
|
||||
Tag::compound([
|
||||
@@ -112,7 +266,7 @@ impl MapData {
|
||||
unlimited: int("unlimitedTracking").unwrap_or(0) != 0,
|
||||
colors,
|
||||
decorations: Vec::new(),
|
||||
step: 0,
|
||||
holders: Vec::new(),
|
||||
dirty: false,
|
||||
texture_dirty: true,
|
||||
})
|
||||
@@ -143,36 +297,48 @@ fn packed_rgba(packed: u8) -> [u8; 4] {
|
||||
pub(super) fn map_id(stack: &ItemStack) -> Option<i32> {
|
||||
stack.data().and_then(|d| d.map_id)
|
||||
}
|
||||
/// `MapDecorationTypes`: the sprite of a decoration type that a
|
||||
/// `minecraft:map_decorations` entry can name.
|
||||
/// `MapDecorationTypes` Horizon draws: (type, sprite).
|
||||
const DECORATION_TYPES: [(&str, &str); 24] = [
|
||||
("player", "map/decorations/player"),
|
||||
("frame", "map/decorations/frame"),
|
||||
("red_marker", "map/decorations/red_marker"),
|
||||
("blue_marker", "map/decorations/blue_marker"),
|
||||
("target_x", "map/decorations/target_x"),
|
||||
("target_point", "map/decorations/target_point"),
|
||||
("player_off_map", "map/decorations/player_off_map"),
|
||||
("player_off_limits", "map/decorations/player_off_limits"),
|
||||
("mansion", "map/decorations/woodland_mansion"),
|
||||
("monument", "map/decorations/ocean_monument"),
|
||||
("red_x", "map/decorations/red_x"),
|
||||
("village_desert", "map/decorations/desert_village"),
|
||||
("village_plains", "map/decorations/plains_village"),
|
||||
("village_savanna", "map/decorations/savanna_village"),
|
||||
("village_snowy", "map/decorations/snowy_village"),
|
||||
("village_taiga", "map/decorations/taiga_village"),
|
||||
("jungle_temple", "map/decorations/jungle_temple"),
|
||||
("swamp_hut", "map/decorations/swamp_hut"),
|
||||
("trial_chambers", "map/decorations/trial_chambers"),
|
||||
("abandoned_camp", "map/decorations/abandoned_camp"),
|
||||
("ancient_city", "map/decorations/ancient_city"),
|
||||
("desert_pyramid", "map/decorations/desert_pyramid"),
|
||||
("mineshaft", "map/decorations/mineshaft"),
|
||||
("ocean_ruin_warm", "map/decorations/warm_ocean_ruins"),
|
||||
];
|
||||
/// The sprite of a decoration type that a `minecraft:map_decorations`
|
||||
/// entry or a server's packet names.
|
||||
fn decoration_sprite(id: &str) -> Option<&'static str> {
|
||||
Some(match id.strip_prefix("minecraft:").unwrap_or(id) {
|
||||
"player" => "map/decorations/player",
|
||||
"frame" => "map/decorations/frame",
|
||||
"red_marker" => "map/decorations/red_marker",
|
||||
"blue_marker" => "map/decorations/blue_marker",
|
||||
"target_x" => "map/decorations/target_x",
|
||||
"target_point" => "map/decorations/target_point",
|
||||
"player_off_map" => "map/decorations/player_off_map",
|
||||
"player_off_limits" => "map/decorations/player_off_limits",
|
||||
"mansion" => "map/decorations/woodland_mansion",
|
||||
"monument" => "map/decorations/ocean_monument",
|
||||
"red_x" => "map/decorations/red_x",
|
||||
"village_desert" => "map/decorations/desert_village",
|
||||
"village_plains" => "map/decorations/plains_village",
|
||||
"village_savanna" => "map/decorations/savanna_village",
|
||||
"village_snowy" => "map/decorations/snowy_village",
|
||||
"village_taiga" => "map/decorations/taiga_village",
|
||||
"jungle_temple" => "map/decorations/jungle_temple",
|
||||
"swamp_hut" => "map/decorations/swamp_hut",
|
||||
"trial_chambers" => "map/decorations/trial_chambers",
|
||||
"abandoned_camp" => "map/decorations/abandoned_camp",
|
||||
"ancient_city" => "map/decorations/ancient_city",
|
||||
"desert_pyramid" => "map/decorations/desert_pyramid",
|
||||
"mineshaft" => "map/decorations/mineshaft",
|
||||
"ocean_ruin_warm" => "map/decorations/warm_ocean_ruins",
|
||||
_ => return None,
|
||||
})
|
||||
let name = id.strip_prefix("minecraft:").unwrap_or(id);
|
||||
DECORATION_TYPES
|
||||
.iter()
|
||||
.find(|(n, _)| *n == name)
|
||||
.map(|(_, sprite)| *sprite)
|
||||
}
|
||||
/// The type a decoration's sprite stands for, as its registry names it.
|
||||
fn decoration_type(sprite: &str) -> Option<&'static str> {
|
||||
DECORATION_TYPES
|
||||
.iter()
|
||||
.find(|(_, s)| *s == sprite)
|
||||
.map(|(name, _)| *name)
|
||||
}
|
||||
/// `minecraft:map_decorations` of a map item: (sprite, x, z, rotation), in
|
||||
/// the component's order.
|
||||
@@ -275,12 +441,29 @@ impl Game<'_> {
|
||||
}
|
||||
self.maps.data.get_mut(&id)
|
||||
}
|
||||
/// `ClientboundMapItemDataPacket.handle`: the server's colours for a map
|
||||
/// this client does not own (lobby map art, a framed map on a wall).
|
||||
/// `ClientboundMapItemDataPacket.handle`: the server's colours and
|
||||
/// decorations for a map (one carried or framed on a server).
|
||||
pub(super) fn remote_map_item_data(
|
||||
&mut self,
|
||||
p: &horizon_protocol::packets::play::MapItemData,
|
||||
) {
|
||||
let decorations = p.decorations.as_ref().map(|list| {
|
||||
let types = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| r.builtin.get("minecraft:map_decoration_type"));
|
||||
list.iter()
|
||||
.filter_map(|d| {
|
||||
Some(Decoration {
|
||||
sprite: decoration_sprite(types?.name(d.kind)?)?,
|
||||
x: d.x,
|
||||
y: d.y,
|
||||
rot: (d.rot & 15) as u8,
|
||||
holder: None,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let dimension = self.dimension_id();
|
||||
let map = self
|
||||
.maps
|
||||
@@ -288,6 +471,9 @@ impl Game<'_> {
|
||||
.entry(p.map_id)
|
||||
.or_insert_with(|| MapData::fresh(0.0, 0.0, p.scale.max(0) as u8, dimension));
|
||||
map.locked = p.locked;
|
||||
if let Some(decorations) = decorations {
|
||||
map.decorations = decorations;
|
||||
}
|
||||
let Some(patch) = &p.patch else {
|
||||
map.dirty = false;
|
||||
return;
|
||||
@@ -566,10 +752,14 @@ impl Game<'_> {
|
||||
.collect();
|
||||
let (yaw, dim) = (self.yaw, self.dimension_id());
|
||||
let (nether, game_time) = (self.dim == super::Dim::Nether, self.world.game_time);
|
||||
// The guest whose turn it is, else the host.
|
||||
let me = self.lan_acting.unwrap_or(HOST);
|
||||
for (id, targets) in carried {
|
||||
let Some(m) = self.map(id) else { continue };
|
||||
m.holder(me).carried = game_time;
|
||||
m.drop_holders(game_time);
|
||||
// tickCarriedBy: the player marker while tracking in this dimension.
|
||||
m.decorations.clear();
|
||||
let mut marker = None;
|
||||
if m.tracking && m.dimension == dim {
|
||||
let s = f64::from(1 << m.scale);
|
||||
let dx = ((p.x - f64::from(m.center[0])) / s) as f32;
|
||||
@@ -596,18 +786,19 @@ impl Game<'_> {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(sprite) = sprite {
|
||||
m.decorations.push(Decoration {
|
||||
sprite,
|
||||
x: clamp(dx),
|
||||
y: clamp(dz),
|
||||
rot,
|
||||
});
|
||||
}
|
||||
marker = sprite.map(|sprite| Decoration {
|
||||
sprite,
|
||||
x: clamp(dx),
|
||||
y: clamp(dz),
|
||||
rot,
|
||||
holder: Some(me),
|
||||
});
|
||||
}
|
||||
m.set_marker(me, marker);
|
||||
// The item's MAP_DECORATIONS (an explorer map's target), added
|
||||
// after the players as addDecoration would.
|
||||
let s = (1 << m.scale) as f32;
|
||||
let mut items: Vec<Decoration> = Vec::new();
|
||||
for (sprite, x, z, rotation) in targets {
|
||||
let dx = (x - f64::from(m.center[0])) as f32 / s;
|
||||
let dz = (z - f64::from(m.center[1])) as f32 / s;
|
||||
@@ -620,16 +811,95 @@ impl Game<'_> {
|
||||
x: clamp_map_coordinate(dx),
|
||||
y: clamp_map_coordinate(dz),
|
||||
rot: decoration_rotation(nether, game_time, f64::from(rotation)),
|
||||
holder: None,
|
||||
};
|
||||
if !m.decorations.contains(&d) {
|
||||
m.decorations.push(d);
|
||||
if !items.contains(&d) {
|
||||
items.push(d);
|
||||
}
|
||||
}
|
||||
if !m
|
||||
.decorations
|
||||
.iter()
|
||||
.filter(|d| d.holder.is_none())
|
||||
.eq(items.iter())
|
||||
{
|
||||
m.decorations.retain(|d| d.holder.is_some());
|
||||
m.decorations.extend(items);
|
||||
m.decorations_changed();
|
||||
}
|
||||
}
|
||||
for id in held {
|
||||
self.update_map(id);
|
||||
}
|
||||
}
|
||||
/// The maps the player carries, for `status`: how many of their pixels
|
||||
/// are drawn, and their decorations.
|
||||
pub(super) fn maps_status(&self) -> Vec<serde_json::Value> {
|
||||
self.inventory
|
||||
.iter()
|
||||
.chain(std::iter::once(&self.offhand))
|
||||
.flatten()
|
||||
.filter_map(map_id)
|
||||
.filter_map(|id| {
|
||||
let m = self.maps.data.get(&id)?;
|
||||
Some(serde_json::json!({
|
||||
"id": id,
|
||||
"pixels": m.colors.iter().filter(|&&c| c != 0).count(),
|
||||
"decorations": m.decorations.iter().map(|d| d.sprite).collect::<Vec<_>>(),
|
||||
}))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `ServerPlayer.synchronizeSpecialItemUpdates` for each map the guest
|
||||
/// carries, and every 10 ticks (`ServerEntity.sendChanges`) for each map
|
||||
/// framed in the level: a `ClientboundMapItemDataPacket` when something
|
||||
/// changed since the guest's last one.
|
||||
pub(super) fn lan_map_updates(&mut self, host: &LanHost, conn: u32) {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return;
|
||||
};
|
||||
let mut ids: Vec<i32> = g
|
||||
.inventory
|
||||
.iter()
|
||||
.chain(std::iter::once(&g.offhand))
|
||||
.flatten()
|
||||
.filter_map(map_id)
|
||||
.collect();
|
||||
let now = self.world.game_time;
|
||||
if now % 10 == 0 && host.hub.here(g) {
|
||||
let framed: Vec<i32> = self
|
||||
.item_frames
|
||||
.iter()
|
||||
.filter_map(|f| f.item.as_ref().and_then(map_id))
|
||||
.collect();
|
||||
for id in framed {
|
||||
if let Some(m) = self.map(id) {
|
||||
m.holder(conn).framed = now;
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
let kind = |name: &str| {
|
||||
host.hub.registry_id(
|
||||
"minecraft:map_decoration_type",
|
||||
&format!("minecraft:{name}"),
|
||||
)
|
||||
};
|
||||
for id in ids {
|
||||
if let Some(p) = self
|
||||
.maps
|
||||
.data
|
||||
.get_mut(&id)
|
||||
.and_then(|m| m.update_packet(id, conn, kind))
|
||||
{
|
||||
g.send(&horizon_protocol::packets::play::Clientbound::MapItemData(
|
||||
p,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `MapItem.update` for the player holding map `id`.
|
||||
fn update_map(&mut self, id: i32) {
|
||||
let dim = self.dimension_id();
|
||||
@@ -651,8 +921,9 @@ impl Game<'_> {
|
||||
if nether {
|
||||
radius /= 2;
|
||||
}
|
||||
m.step += 1;
|
||||
let step = m.step;
|
||||
let h = m.holder(self.lan_acting.unwrap_or(HOST));
|
||||
h.step += 1;
|
||||
let step = h.step;
|
||||
let mut changes: Vec<(i32, i32, u8)> = Vec::new();
|
||||
let mut consecutive = false;
|
||||
for img_x in px - radius + 1..px + radius {
|
||||
|
||||
@@ -95,7 +95,7 @@ fn display_offset(kind: CartKind) -> f64 {
|
||||
if kind == CartKind::Hopper { 1.0 } else { 6.0 }
|
||||
}
|
||||
/// The minecart's passenger attachment minus Player.DEFAULT_VEHICLE_ATTACHMENT.
|
||||
const SEAT: f64 = 0.1875 - 0.6;
|
||||
pub(super) const SEAT: f64 = 0.1875 - 0.6;
|
||||
/// The placeholder container key under which an open cart's items live.
|
||||
pub(super) const CART_KEY_Y: i32 = -100_000;
|
||||
|
||||
@@ -686,40 +686,53 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AbstractMinecart.push(player)` when the player walks into a cart.
|
||||
/// `AbstractMinecart.push` from each player walking into a cart
|
||||
/// (`LivingEntity.pushEntities`): the window's player and every server
|
||||
/// player (single player's own on its server thread, LAN guests). A
|
||||
/// player in a vehicle pushes nothing here. The quarter push the player
|
||||
/// gets back only moves the server's copy of it (`Entity.push` marks
|
||||
/// `needsSync`, which `ServerEntity` sends to the other players only),
|
||||
/// so no player feels it.
|
||||
fn push_carts(&mut self) {
|
||||
let player = self.player.as_ref().map(|p| p.position);
|
||||
let riding = self.riding;
|
||||
for i in 0..self.carts.len() {
|
||||
if Some(self.carts[i].id) == riding {
|
||||
continue;
|
||||
if self.carts.is_empty() {
|
||||
return;
|
||||
}
|
||||
// (feet, box)
|
||||
let mut pushers: Vec<(Vec3, Aabb)> = Vec::new();
|
||||
if self.riding.is_none()
|
||||
&& let Some(p) = self.player.as_ref()
|
||||
{
|
||||
pushers.push((p.position, p.bounds()));
|
||||
}
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
for g in host.hub.guests.iter().filter(|g| {
|
||||
host.hub.here(g) && g.loaded && !g.dead && !g.spectator() && g.vehicle.is_none()
|
||||
}) {
|
||||
let [x, y, z] = g.position;
|
||||
let height = if g.sneaking { 1.5 } else { 1.8 };
|
||||
let bb = Aabb::new(
|
||||
Vec3::new(x - 0.3, y, z - 0.3),
|
||||
Vec3::new(x + 0.3, y + height, z + 0.3),
|
||||
);
|
||||
pushers.push((Vec3::new(x, y, z), bb));
|
||||
}
|
||||
if let Some(pp) = player {
|
||||
let bb = self.carts[i].bounds();
|
||||
let (lo, hi) = (bb.min_position(), bb.max_position());
|
||||
let touching = pp.x + 0.3 > lo.x
|
||||
&& pp.x - 0.3 < hi.x
|
||||
&& pp.z + 0.3 > lo.z
|
||||
&& pp.z - 0.3 < hi.z
|
||||
&& pp.y + 1.8 > lo.y
|
||||
&& pp.y < hi.y;
|
||||
if touching && riding.is_none() {
|
||||
let c = &mut self.carts[i];
|
||||
let (mut xa, mut za) = (pp.x - c.position.x, pp.z - c.position.z);
|
||||
let dd = xa * xa + za * za;
|
||||
if dd >= 1.0e-4 {
|
||||
let d = dd.sqrt();
|
||||
let pow = (1.0 / d).min(1.0);
|
||||
xa = xa / d * pow * f64::from(0.1f32) * 0.5;
|
||||
za = za / d * pow * f64::from(0.1f32) * 0.5;
|
||||
c.velocity.x -= xa;
|
||||
c.velocity.z -= za;
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.velocity.x += xa / 4.0;
|
||||
p.velocity.z += za / 4.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (feet, bb) in pushers {
|
||||
for c in &mut self.carts {
|
||||
if !bb.intersects(c.bounds()) {
|
||||
continue;
|
||||
}
|
||||
let (mut xa, mut za) = (feet.x - c.position.x, feet.z - c.position.z);
|
||||
let dd = xa * xa + za * za;
|
||||
if dd < f64::from(1.0e-4f32) {
|
||||
continue;
|
||||
}
|
||||
let d = dd.sqrt();
|
||||
let pow = (1.0 / d).min(1.0);
|
||||
xa = xa / d * pow * f64::from(0.1f32) * 0.5;
|
||||
za = za / d * pow * f64::from(0.1f32) * 0.5;
|
||||
c.velocity.x -= xa;
|
||||
c.velocity.z -= za;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -797,7 +810,7 @@ impl Game<'_> {
|
||||
let ridden = self.guest_vehicles();
|
||||
// By id: an exploding cart can destroy or prime others mid-loop.
|
||||
let ids: Vec<u32> = self.carts.iter().map(|c| c.id).collect();
|
||||
for id in ids {
|
||||
for &id in &ids {
|
||||
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
|
||||
continue;
|
||||
};
|
||||
@@ -823,6 +836,33 @@ impl Game<'_> {
|
||||
self.carts.remove(i);
|
||||
}
|
||||
}
|
||||
// On the server for every cart, the ridden ones too.
|
||||
for id in ids {
|
||||
if let Some(i) = self.carts.iter().position(|c| c.id == id) {
|
||||
let p = self.carts[i].position;
|
||||
if ticking(p.x.floor() as i32, p.z.floor() as i32) {
|
||||
self.cart_lava_tick(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `lavaHurt` twice a tick in lava: from `LavaFluid.entityInside` in the
|
||||
/// behaviour's `applyEffectsFromBlocks`, then from `AbstractMinecart.tick`'s
|
||||
/// own `isInLava`. The second breaks the cart, or primes a TNT minecart
|
||||
/// (`MinecartTNT.destroy` for a fire source). A cart has no `baseTick`,
|
||||
/// so the fire `lavaIgnite` lights never burns it.
|
||||
fn cart_lava_tick(&mut self, id: u32) {
|
||||
for _ in 0..2 {
|
||||
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
|
||||
return;
|
||||
};
|
||||
if !self.in_lava(self.carts[i].bounds()) {
|
||||
return;
|
||||
}
|
||||
let p = self.carts[i].position;
|
||||
self.hurt_cart(i, 4.0, false, true);
|
||||
self.lava_hurt_sound([p.x, p.y, p.z]);
|
||||
}
|
||||
}
|
||||
/// `ClientPacketListener.postAddEntitySoundInstance` for the carts that
|
||||
/// joined the level since the last tick, and `LocalPlayer.startRiding`'s
|
||||
|
||||
@@ -237,15 +237,7 @@ impl Game<'_> {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::OpenShaderPackFolder => {
|
||||
let dir = horizon_client::shader_pack::folder();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
|
||||
Event::ReloadShaderPack => {
|
||||
self.load_shader_pack();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -294,14 +286,6 @@ impl Game<'_> {
|
||||
font.reset_cache();
|
||||
self.ui.chat.rescale(&self.gui.atlas);
|
||||
}
|
||||
// A new renderer (resource reload, first window) takes the loaded pack.
|
||||
let stale = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.shader_pack_id() != self.shader_packs.id());
|
||||
if stale && let Err(e) = self.apply_shader_pack() {
|
||||
log::warn!("Horizon: shader pack: {e}");
|
||||
}
|
||||
}
|
||||
/// `Window.updateFullscreenState`: a fullscreen change made by the window
|
||||
/// system is adopted by the option (`Minecraft.fullscreenStateChanged`).
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::Game;
|
||||
use horizon_client::{
|
||||
hud::HudAtlas,
|
||||
lang,
|
||||
pack_screen::{Compat, PackInfo, Source as PackSource},
|
||||
pack_screen::{self, Compat, PackInfo, Source as PackSource},
|
||||
packs::{self, Pack, Position},
|
||||
text::{Style, Text},
|
||||
};
|
||||
@@ -34,10 +34,16 @@ pub fn open_folder(dir: &Path) {
|
||||
log::warn!("Horizon: could not open {}: {e}", dir.display());
|
||||
}
|
||||
}
|
||||
/// The resource root to load from, given the vanilla root and the selection.
|
||||
pub fn startup_root(vanilla: &Path, selected: &[String]) -> std::io::Result<PathBuf> {
|
||||
/// The resource root to load from, given the vanilla root, the selection and
|
||||
/// a server's packs, which go on top (`Pack.Position.TOP`, fixed).
|
||||
pub fn startup_root(
|
||||
vanilla: &Path,
|
||||
selected: &[String],
|
||||
server: &[Pack],
|
||||
) -> std::io::Result<PathBuf> {
|
||||
let all = packs::discover(vanilla, &packs_dir());
|
||||
let enabled = packs::enabled(&all, selected);
|
||||
let mut enabled = packs::enabled(&all, selected);
|
||||
enabled.extend_from_slice(server);
|
||||
packs::resource_root(vanilla, &enabled, &cache_dir())
|
||||
}
|
||||
fn info(p: &Pack) -> PackInfo {
|
||||
@@ -53,14 +59,15 @@ fn info(p: &Pack) -> PackInfo {
|
||||
PackSource::Default
|
||||
},
|
||||
compat: p.compatibility,
|
||||
icon: p.icon,
|
||||
icon: p.icon_png().as_deref().and_then(pack_screen::load_icon),
|
||||
required: false,
|
||||
position: Position::Top,
|
||||
fixed: false,
|
||||
}
|
||||
}
|
||||
/// The vanilla pack: required, at the bottom (`BuiltInPackSource`).
|
||||
fn vanilla_info() -> PackInfo {
|
||||
/// The vanilla pack: required, at the bottom (`BuiltInPackSource`), with the
|
||||
/// client JAR's `pack.png` that the prepared resources hold.
|
||||
fn vanilla_info(vanilla: &Path) -> PackInfo {
|
||||
PackInfo {
|
||||
id: "vanilla".into(),
|
||||
title: Text::translate("resourcePack.vanilla.name", &[]),
|
||||
@@ -68,7 +75,10 @@ fn vanilla_info() -> PackInfo {
|
||||
description_style: Style::default(),
|
||||
source: PackSource::BuiltIn,
|
||||
compat: Compat::Compatible,
|
||||
icon: true,
|
||||
icon: std::fs::read(vanilla.join("pack.png"))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(pack_screen::load_icon),
|
||||
required: true,
|
||||
position: Position::Bottom,
|
||||
fixed: false,
|
||||
@@ -80,7 +90,7 @@ pub(super) fn pack_infos(vanilla: &Path) -> Vec<PackInfo> {
|
||||
.iter()
|
||||
.map(info)
|
||||
.collect();
|
||||
infos.push(vanilla_info());
|
||||
infos.push(vanilla_info(vanilla));
|
||||
infos
|
||||
}
|
||||
impl Game<'_> {
|
||||
@@ -113,14 +123,7 @@ impl Game<'_> {
|
||||
self.pending_pack_reload = true;
|
||||
return;
|
||||
}
|
||||
let vanilla = self.vanilla_root();
|
||||
if self.ui.vanilla_root.as_os_str().is_empty() {
|
||||
self.ui.vanilla_root = vanilla.clone();
|
||||
}
|
||||
let result = startup_root(&vanilla, &self.settings.resource_packs)
|
||||
.map_err(Into::into)
|
||||
.and_then(|root| self.reload_resources(&root));
|
||||
if let Err(e) = result {
|
||||
if let Err(e) = self.reload_selected_packs() {
|
||||
log::warn!("Horizon: resource reload failed: {e}");
|
||||
if self.screen != super::Screen::Options {
|
||||
self.open_options_at(
|
||||
@@ -131,6 +134,16 @@ impl Game<'_> {
|
||||
self.ui.options.resource_error = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
/// Reload from the selected packs and the server's.
|
||||
pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> {
|
||||
let vanilla = self.vanilla_root();
|
||||
if self.ui.vanilla_root.as_os_str().is_empty() {
|
||||
self.ui.vanilla_root = vanilla.clone();
|
||||
}
|
||||
let server = &self.server_packs.active;
|
||||
let root = startup_root(&vanilla, &self.settings.resource_packs, server)?;
|
||||
self.reload_resources(&root)
|
||||
}
|
||||
fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> {
|
||||
let render = std::sync::Arc::new(self.pack.assets.reload(root)?);
|
||||
let mut atlas = HudAtlas::build_over(root, &self.vanilla_root())?;
|
||||
|
||||
@@ -38,9 +38,16 @@ impl Game<'_> {
|
||||
m.extra.rider = Some(rider);
|
||||
}
|
||||
}
|
||||
/// Riders sit on their vehicles (`Entity.positionRider`), and let go when
|
||||
/// the vehicle dies or is gone.
|
||||
/// Riders sit on their vehicles (`Entity.positionRider`), mobs or boats,
|
||||
/// and let go when the vehicle dies or is gone.
|
||||
pub(super) fn sync_riders(&mut self) {
|
||||
// Entity.baseTick.
|
||||
for m in &mut self.mobs {
|
||||
if m.extra.boarding_cooldown > 0 {
|
||||
m.extra.boarding_cooldown -= 1;
|
||||
}
|
||||
}
|
||||
let boats: std::collections::HashSet<u32> = self.boats.iter().map(|b| b.id).collect();
|
||||
let index: HashMap<u32, usize> = self
|
||||
.mobs
|
||||
.iter()
|
||||
@@ -54,9 +61,10 @@ impl Game<'_> {
|
||||
};
|
||||
let valid = self.mobs[i].alive()
|
||||
&& !self.mobs[i].removed
|
||||
&& index
|
||||
.get(&v)
|
||||
.is_some_and(|&j| self.mobs[j].alive() && !self.mobs[j].removed);
|
||||
&& (boats.contains(&v)
|
||||
|| index
|
||||
.get(&v)
|
||||
.is_some_and(|&j| self.mobs[j].alive() && !self.mobs[j].removed));
|
||||
if valid {
|
||||
riding.insert(v, self.mobs[i].id);
|
||||
} else {
|
||||
@@ -68,6 +76,19 @@ impl Game<'_> {
|
||||
for m in &mut self.mobs {
|
||||
m.extra.rider = riding.get(&m.id).copied();
|
||||
}
|
||||
self.position_riders();
|
||||
}
|
||||
/// `Entity.positionRider` for every mob passenger: in its boat, then on
|
||||
/// its mob. A server's client places them itself too, as the server sends
|
||||
/// no passenger's position.
|
||||
pub(super) fn position_riders(&mut self) {
|
||||
self.seat_boat_mobs();
|
||||
let index: HashMap<u32, usize> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, m)| (m.id, i))
|
||||
.collect();
|
||||
// Riders of riders settle over a few passes.
|
||||
for _ in 0..3 {
|
||||
for i in 0..self.mobs.len() {
|
||||
|
||||
@@ -156,6 +156,8 @@ impl Game<'_> {
|
||||
kind.as_str(),
|
||||
"minecraft:experience_orb"
|
||||
| "minecraft:arrow"
|
||||
| "minecraft:fireball"
|
||||
| "minecraft:small_fireball"
|
||||
| "minecraft:falling_block"
|
||||
| "minecraft:end_crystal"
|
||||
| "minecraft:ender_dragon"
|
||||
@@ -195,6 +197,11 @@ impl Game<'_> {
|
||||
self.creatures.projectiles.push(p);
|
||||
}
|
||||
}
|
||||
"minecraft:fireball" | "minecraft:small_fireball" => {
|
||||
if let Some(p) = entity_nbt::fireball_from_nbt(&tag, player_uuid) {
|
||||
self.creatures.projectiles.push(p);
|
||||
}
|
||||
}
|
||||
"minecraft:falling_block" => {
|
||||
if let Some((state, position, velocity, age, fall_distance)) =
|
||||
entity_nbt::falling_block_from_nbt(&tag)
|
||||
@@ -243,6 +250,8 @@ impl Game<'_> {
|
||||
.is_some_and(|(wood, chest)| self.boat_models.has(wood, chest)) =>
|
||||
{
|
||||
if let Some((position, velocity, yaw)) = entity_nbt::boat_from_nbt(&tag) {
|
||||
let mut tag = tag;
|
||||
let riders = self.take_mob_passengers(&mut tag);
|
||||
self.next_entity += 1;
|
||||
let id = self.next_entity;
|
||||
let (wood, chest) =
|
||||
@@ -263,6 +272,9 @@ impl Game<'_> {
|
||||
b.velocity = velocity;
|
||||
self.remember(id, uuid, tag);
|
||||
self.boats.push(b);
|
||||
for rider in riders {
|
||||
self.load_mob(rider, Some(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
k if super::minecarts::CartKind::from_id(k).is_some() => {
|
||||
@@ -341,25 +353,10 @@ impl Game<'_> {
|
||||
// Structure entities placed with finalizeEntities.
|
||||
let mut tag = tag;
|
||||
let mut finalize = false;
|
||||
let mut riders = Vec::new();
|
||||
let mut unknown = Vec::new();
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
finalize = c.remove(&"horizon:finalize".into()).is_some();
|
||||
if let Some(Tag::List(list)) = c.remove(&"Passengers".into()) {
|
||||
for p in list {
|
||||
let known =
|
||||
entity_nbt::kind(&p).is_some_and(|k| self.res.mob_types().contains_key(&k));
|
||||
if known {
|
||||
riders.push(p);
|
||||
} else {
|
||||
unknown.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !unknown.is_empty() {
|
||||
c.insert("Passengers".into(), Tag::List(unknown));
|
||||
}
|
||||
}
|
||||
let riders = self.take_mob_passengers(&mut tag);
|
||||
let mut m = entity_nbt::mob_from_nbt(&tag, self.res.mob_types(), self.next_entity)?;
|
||||
m.extra.mount = mount;
|
||||
m.extra.passenger = mount.is_some();
|
||||
@@ -401,6 +398,49 @@ impl Game<'_> {
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
/// The mobs under an entity's `Passengers`, taken out of it; the
|
||||
/// passengers Horizon doesn't simulate stay.
|
||||
fn take_mob_passengers(&self, tag: &mut Tag) -> Vec<Tag> {
|
||||
let mut riders = Vec::new();
|
||||
if let Tag::Compound(c) = tag
|
||||
&& let Some(Tag::List(list)) = c.remove(&"Passengers".into())
|
||||
{
|
||||
let mut unknown = Vec::new();
|
||||
for p in list {
|
||||
let known =
|
||||
entity_nbt::kind(&p).is_some_and(|k| self.res.mob_types().contains_key(&k));
|
||||
if known {
|
||||
riders.push(p);
|
||||
} else {
|
||||
unknown.push(p);
|
||||
}
|
||||
}
|
||||
if !unknown.is_empty() {
|
||||
c.insert("Passengers".into(), Tag::List(unknown));
|
||||
}
|
||||
}
|
||||
riders
|
||||
}
|
||||
/// The mobs riding entity `id` under `tag`'s `Passengers`, after the
|
||||
/// passengers it kept from loading.
|
||||
fn put_mob_passengers(&mut self, tag: &mut Tag, id: u32) {
|
||||
let riders: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&j| self.mobs[j].extra.mount == Some(id) && self.mobs[j].alive())
|
||||
.collect();
|
||||
let riders: Vec<Tag> = riders.into_iter().map(|j| self.mob_tree_nbt(j)).collect();
|
||||
if let Tag::Compound(c) = tag {
|
||||
let mut list = match c.get(&"Passengers".into()) {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
list.extend(riders);
|
||||
if list.is_empty() {
|
||||
c.remove(&"Passengers".into());
|
||||
} else {
|
||||
c.insert("Passengers".into(), Tag::List(list));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// An entity of a type Horizon does not simulate, kept as its compound.
|
||||
fn load_other(&mut self, tag: Tag, kind: &str) -> Option<u32> {
|
||||
let position = entity_nbt::position(&tag)?;
|
||||
@@ -476,22 +516,7 @@ impl Game<'_> {
|
||||
let uuid = self.uuid_for(id);
|
||||
let mut tag = entity_nbt::mob_to_nbt(&self.mobs[i], uuid, self.persist.raw.get(&id));
|
||||
self.write_leash(&mut tag, i);
|
||||
let riders: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&j| self.mobs[j].extra.mount == Some(id) && self.mobs[j].alive())
|
||||
.collect();
|
||||
let riders: Vec<Tag> = riders.into_iter().map(|j| self.mob_tree_nbt(j)).collect();
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
let mut list = match c.get(&"Passengers".into()) {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
list.extend(riders);
|
||||
if list.is_empty() {
|
||||
c.remove(&"Passengers".into());
|
||||
} else {
|
||||
c.insert("Passengers".into(), Tag::List(list));
|
||||
}
|
||||
}
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
tag
|
||||
}
|
||||
/// Forget an entity that left the world (its UUID and loaded compound).
|
||||
@@ -569,10 +594,15 @@ impl Game<'_> {
|
||||
let mut out: HashMap<(i32, i32), Vec<Tag>> =
|
||||
keys.iter().map(|k| (*k, Vec::new())).collect();
|
||||
let player_uuid = self.level.player_uuid;
|
||||
// A rider is saved inside its root vehicle, in the root's chunk.
|
||||
// A rider is saved inside its root vehicle, in the root's chunk; a
|
||||
// boat's passengers inside the boat.
|
||||
let mobs: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&i| {
|
||||
self.mob_root(i) == i
|
||||
&& self.mobs[i]
|
||||
.extra
|
||||
.mount
|
||||
.is_none_or(|v| self.boats.iter().all(|b| b.id != v))
|
||||
&& keys.contains(&chunk_of(self.mobs[i].position))
|
||||
&& self.mobs[i].alive()
|
||||
})
|
||||
@@ -619,13 +649,15 @@ impl Game<'_> {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (uuid, raw) = anon(&self.persist, "minecraft:arrow", p.position);
|
||||
let (uuid, raw) = anon(&self.persist, p.kind.id(), p.position);
|
||||
let in_block = p
|
||||
.arrow
|
||||
.as_ref()
|
||||
.and_then(|a| a.last_state)
|
||||
.map(|s| self.state_to_nbt(s));
|
||||
if let Some(tag) = entity_nbt::arrow_to_nbt(p, uuid, owner, in_block, raw.as_ref()) {
|
||||
let tag = entity_nbt::arrow_to_nbt(p, uuid, owner, in_block, raw.as_ref())
|
||||
.or_else(|| entity_nbt::fireball_to_nbt(p, uuid, owner, raw.as_ref()));
|
||||
if let Some(tag) = tag {
|
||||
out.entry(chunk_of(p.position)).or_default().push(tag);
|
||||
}
|
||||
}
|
||||
@@ -650,25 +682,29 @@ impl Game<'_> {
|
||||
.push(with_raw(tag, raw.as_ref()));
|
||||
}
|
||||
}
|
||||
for b in &self.boats {
|
||||
if keys.contains(&chunk_of(b.position)) {
|
||||
let uuid = self
|
||||
.persist
|
||||
.uuids
|
||||
.get(&b.id)
|
||||
.copied()
|
||||
.unwrap_or_else(random_uuid);
|
||||
let tag = entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
uuid,
|
||||
self.persist.raw.get(&b.id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
out.entry(chunk_of(b.position)).or_default().push(tag);
|
||||
}
|
||||
let boats: Vec<usize> = (0..self.boats.len())
|
||||
.filter(|&i| keys.contains(&chunk_of(self.boats[i].position)))
|
||||
.collect();
|
||||
for i in boats {
|
||||
let b = &self.boats[i];
|
||||
let (id, at) = (b.id, b.position);
|
||||
let uuid = self
|
||||
.persist
|
||||
.uuids
|
||||
.get(&id)
|
||||
.copied()
|
||||
.unwrap_or_else(random_uuid);
|
||||
let mut tag = entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
out.entry(chunk_of(at)).or_default().push(tag);
|
||||
}
|
||||
if let Some(d) = &self.dragon
|
||||
&& keys.contains(&chunk_of(d.position))
|
||||
@@ -833,9 +869,16 @@ impl Game<'_> {
|
||||
}
|
||||
keep
|
||||
});
|
||||
// Riders leave with their root vehicle.
|
||||
// Riders leave with their root vehicle, a boat's with the boat.
|
||||
let leaving: HashSet<u32> = (0..self.mobs.len())
|
||||
.filter(|&i| gone(self.mobs[self.mob_root(i)].position))
|
||||
.filter(|&i| {
|
||||
let r = self.mob_root(i);
|
||||
match self.mobs[r].extra.mount {
|
||||
Some(v) if removed.contains(&v) => true,
|
||||
Some(v) if self.boats.iter().any(|b| b.id == v) => false,
|
||||
_ => gone(self.mobs[r].position),
|
||||
}
|
||||
})
|
||||
.map(|i| self.mobs[i].id)
|
||||
.collect();
|
||||
self.mobs.retain(|m| {
|
||||
|
||||
@@ -6,7 +6,6 @@ use super::{Dim, Game};
|
||||
use horizon_client::mobs::villager as mobs_villager;
|
||||
use horizon_client::{
|
||||
effects::{EffectInstance, OmenTick},
|
||||
hud_overlays,
|
||||
mobs::{
|
||||
Mob, Species, raider,
|
||||
spawn_rules::{self, SpawnLevel},
|
||||
@@ -15,9 +14,9 @@ use horizon_client::{
|
||||
},
|
||||
patrol::{GameplayAttributes, Member, PatrolLevel, PatrolSpawner},
|
||||
raid::{self, Action, BarName, RaidLevel, RaidPlayer, RaiderInfo, RaiderType, Raids, Spawn},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// Patrol and raid state owned by the game.
|
||||
@@ -1063,37 +1062,18 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The raid boss bars: red and notched in tens, with the raid's title.
|
||||
pub(super) fn raid_bars(&self, bars: &mut Vec<hud_overlays::BossBar>) {
|
||||
for raid in self.raiding.raids.map.values() {
|
||||
if !raid.bar.visible || !raid.bar.player || raid.is_stopped() {
|
||||
continue;
|
||||
}
|
||||
let title = self.translate("event.minecraft.raid", "Raid");
|
||||
let name = match raid.bar.name {
|
||||
BarName::Raid => title,
|
||||
BarName::Remaining(n) => format!(
|
||||
"{title} - {}",
|
||||
self.translate(
|
||||
"event.minecraft.raid.raiders_remaining",
|
||||
"Raiders Remaining: %s"
|
||||
)
|
||||
.replace("%s", &n.to_string())
|
||||
),
|
||||
BarName::Victory => {
|
||||
self.translate("event.minecraft.raid.victory.full", "Raid - Victory")
|
||||
}
|
||||
BarName::Defeat => {
|
||||
self.translate("event.minecraft.raid.defeat.full", "Raid - Defeat")
|
||||
}
|
||||
};
|
||||
bars.push(hud_overlays::BossBar {
|
||||
name: Text::literal(name),
|
||||
progress: raid.bar.progress,
|
||||
color: 2,
|
||||
overlay: 2,
|
||||
});
|
||||
/// The raid bar a living player at `at` is in (`Raid.getPlayers`): red,
|
||||
/// notched in tens. The name is the translate component the client draws.
|
||||
pub(super) fn raid_boss_at(&self, at: [f64; 3], alive: bool) -> Option<(i32, Tag, f32)> {
|
||||
if !alive || self.dim != Dim::Overworld {
|
||||
return None;
|
||||
}
|
||||
let block = block_of(Vec3::new(at[0], at[1], at[2]));
|
||||
let (id, raid) = self.raiding.raids.nearby(block)?;
|
||||
if !raid.bar.visible || raid.is_stopped() {
|
||||
return None;
|
||||
}
|
||||
Some((id, raid_name_tag(raid.bar.name), raid.bar.progress))
|
||||
}
|
||||
/// A firework a villager launches to celebrate (`CelebrateVillagersSurvivedRaid`).
|
||||
pub(super) fn villager_firework(&mut self, at: [f64; 3], color: usize, flight: u8) {
|
||||
@@ -1239,6 +1219,36 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Raid.raidEvent`'s name: the raid title, the remaining-raiders line, or
|
||||
/// the victory and defeat lines.
|
||||
fn raid_name_tag(name: BarName) -> Tag {
|
||||
let key = |k: &str| Tag::compound([("translate", Tag::String(k.into()))]);
|
||||
match name {
|
||||
BarName::Raid => key("event.minecraft.raid"),
|
||||
BarName::Victory => key("event.minecraft.raid.victory.full"),
|
||||
BarName::Defeat => key("event.minecraft.raid.defeat.full"),
|
||||
BarName::Remaining(n) => Tag::compound([
|
||||
("translate", Tag::String("event.minecraft.raid".into())),
|
||||
(
|
||||
"extra",
|
||||
Tag::List(vec![
|
||||
Tag::String(" - ".into()),
|
||||
Tag::compound([
|
||||
(
|
||||
"translate",
|
||||
Tag::String("event.minecraft.raid.raiders_remaining".into()),
|
||||
),
|
||||
(
|
||||
"with",
|
||||
Tag::List(vec![Tag::String(n.to_string().as_str().into())]),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -140,9 +140,13 @@ pub(super) struct Remote {
|
||||
stepped: HashMap<i32, SteppedInterpolation>,
|
||||
/// `LivingEntity.lerpHeadTo`: the head yaw and the ticks left to reach it.
|
||||
head_lerps: HashMap<i32, (f32, u32)>,
|
||||
/// Item entities waiting for their item data.
|
||||
pending_items: HashMap<i32, [f64; 3]>,
|
||||
/// Item entities waiting for their item data: where they are and their
|
||||
/// motion.
|
||||
pending_items: HashMap<i32, ([f64; 3], Vec3)>,
|
||||
health: (f32, i32, f32),
|
||||
/// `LocalPlayer.flashOnSetHealth`: set by the first health the server
|
||||
/// sends; from then on a drop starts the hurt animation.
|
||||
flash_on_set_health: bool,
|
||||
game_mode: i32,
|
||||
pub chat: Vec<(String, Instant)>,
|
||||
/// Lines received since joining, for the control socket's events.
|
||||
@@ -305,6 +309,16 @@ impl Game<'_> {
|
||||
// A signed-in account always plays under its own name.
|
||||
let auth = horizon_client::auth::current();
|
||||
let name = auth.as_ref().map_or(name, |a| a.name.as_str());
|
||||
let user = auth.as_ref().map_or_else(
|
||||
|| {
|
||||
(
|
||||
name.to_owned(),
|
||||
horizon_protocol::crypto::offline_uuid(name),
|
||||
)
|
||||
},
|
||||
|a| (a.name.clone(), a.uuid),
|
||||
);
|
||||
self.server_packs_connect(local.is_none().then_some(address), user);
|
||||
let session = match local {
|
||||
Some(pipe) => Session::connect_local(pipe, name, view, lang.clone()),
|
||||
None => {
|
||||
@@ -369,6 +383,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.apply_settings();
|
||||
self.clear_waypoints();
|
||||
self.boss_overlay.reset();
|
||||
self.remote = Some(Remote {
|
||||
session,
|
||||
brand: "unknown".into(),
|
||||
@@ -401,6 +416,7 @@ impl Game<'_> {
|
||||
head_lerps: HashMap::new(),
|
||||
pending_items: HashMap::new(),
|
||||
health: (20.0, 20, 5.0),
|
||||
flash_on_set_health: false,
|
||||
game_mode: 0,
|
||||
chat: Vec::new(),
|
||||
chat_received: 0,
|
||||
@@ -450,9 +466,11 @@ impl Game<'_> {
|
||||
self.extras_left();
|
||||
}
|
||||
self.clear_waypoints();
|
||||
self.boss_overlay.reset();
|
||||
if let Some(mut r) = self.remote.take() {
|
||||
r.session.disconnect();
|
||||
}
|
||||
self.server_packs_disconnect();
|
||||
// Single player's server thread saves the world before the title shows.
|
||||
let single_player = match self.integrated.take() {
|
||||
Some(server) => {
|
||||
@@ -544,7 +562,7 @@ impl Game<'_> {
|
||||
/// (`LivingEntity.forceAddEffect`): the effect replaces any of its kind.
|
||||
/// Other entities' effects are not shown.
|
||||
fn remote_effect(&mut self, p: &play::UpdateMobEffect) {
|
||||
if !self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
if self.remote.as_ref().is_none_or(|r| r.entity_id != p.id) {
|
||||
return;
|
||||
}
|
||||
let Some(id) = self.remote_effect_id(p.effect) else {
|
||||
@@ -596,6 +614,21 @@ impl Game<'_> {
|
||||
continue;
|
||||
};
|
||||
let value = match c {
|
||||
// Read past, not decoded: the item keeps its prototype's value.
|
||||
Component::Raw(_) => continue,
|
||||
// Trim materials and patterns are synced registries, as enchantments are.
|
||||
Component::Trim { material, pattern } => {
|
||||
let (Some(material), Some(pattern)) = (
|
||||
r.registries.name("minecraft:trim_material", *material),
|
||||
r.registries.name("minecraft:trim_pattern", *pattern),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
Tag::compound([
|
||||
("material", Tag::String(material.into())),
|
||||
("pattern", Tag::String(pattern.into())),
|
||||
])
|
||||
}
|
||||
Component::Int(v) if *kind == component::RARITY => {
|
||||
let rarity = ["common", "uncommon", "rare", "epic"];
|
||||
Tag::String(rarity.get(*v as usize).copied().unwrap_or("common").into())
|
||||
@@ -641,6 +674,22 @@ impl Game<'_> {
|
||||
("mob_factor", Tag::Float(v[5].to_bits())),
|
||||
]),
|
||||
Component::Profile(p) => profile_tag(p),
|
||||
// ItemStackTemplate.CODEC: id, count and components.
|
||||
Component::Stacks(stacks) => Tag::List(
|
||||
stacks
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let mut entries = vec![
|
||||
("id", Tag::String(self.item_name(t.item).as_ref().into())),
|
||||
("count", Tag::Int(t.count)),
|
||||
];
|
||||
if let Some(c) = self.protocol_components(t) {
|
||||
entries.push(("components", c));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
Component::CustomModelData {
|
||||
floats,
|
||||
flags,
|
||||
@@ -706,6 +755,9 @@ impl Game<'_> {
|
||||
self.ticks,
|
||||
self.screen == Screen::Chat,
|
||||
);
|
||||
if self.mods.running() {
|
||||
self.mods_chat(&text);
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.chat.push((text, Instant::now()));
|
||||
r.chat_received += 1;
|
||||
@@ -802,7 +854,11 @@ impl Game<'_> {
|
||||
r.joined = true;
|
||||
r.profile = uuid;
|
||||
r.profile_name = name.clone();
|
||||
r.status = format!("Joined as {name}; loading terrain...");
|
||||
// LevelLoadingScreen's line: no player name.
|
||||
r.status = horizon_client::lang::get_or(
|
||||
"multiplayer.downloadingTerrain",
|
||||
"Loading terrain...",
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::Brand(brand) => {
|
||||
@@ -811,6 +867,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Event::Reconfigured(registries) => self.set_registries(registries),
|
||||
Event::PackPush(p) => self.handle_pack_push(p),
|
||||
Event::PackPop(id) => self.handle_pack_pop(id),
|
||||
Event::DecodeError(e) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.decode_errors += 1;
|
||||
@@ -1353,7 +1411,7 @@ impl Game<'_> {
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
if kind == "minecraft:item" {
|
||||
r.pending_items.insert(id, position);
|
||||
r.pending_items.insert(id, (position, Vec3::ZERO));
|
||||
}
|
||||
r.entities.insert(
|
||||
id,
|
||||
@@ -1432,6 +1490,23 @@ impl Game<'_> {
|
||||
m.on_ground = on_ground;
|
||||
return;
|
||||
}
|
||||
if let Some(p) = r.pending_items.get_mut(&id) {
|
||||
p.0 = position;
|
||||
}
|
||||
// ItemEntity and FallingBlockEntity keep Entity's NO_OP
|
||||
// InterpolationHandler: moveOrInterpolateTo puts them there at
|
||||
// once, and their own tick moves them on between the server's
|
||||
// updates (`tick_remote_entities`).
|
||||
let at = Vec3::new(position[0], position[1], position[2]);
|
||||
if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
|
||||
d.position = at;
|
||||
d.on_ground = on_ground;
|
||||
return;
|
||||
}
|
||||
if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
|
||||
f.position = at;
|
||||
return;
|
||||
}
|
||||
// AbstractInterpolationHandler.interpolateTo: without a rotation,
|
||||
// the one it was heading for.
|
||||
let rotation = rotation.or_else(|| r.lerps.get(&id).and_then(|l| l.rotation));
|
||||
@@ -1443,11 +1518,9 @@ impl Game<'_> {
|
||||
steps: 3,
|
||||
},
|
||||
);
|
||||
if let Some(p) = r.pending_items.get_mut(&id) {
|
||||
*p = position;
|
||||
}
|
||||
}
|
||||
self.turn_remote_object(id, rotation.map(|(yaw, _)| yaw), on_ground);
|
||||
self.move_remote_text_display(id, position, rotation);
|
||||
self.turn_remote_projectile(id, rotation);
|
||||
}
|
||||
fn remove_entity(&mut self, id: i32) {
|
||||
@@ -1553,6 +1626,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Find a game slot in the menu's complete, contiguous slot mapping.
|
||||
fn menu_slot_index(menu: &str, internal: usize) -> Option<usize> {
|
||||
(0..)
|
||||
.map_while(|slot| Self::menu_slot(menu, slot))
|
||||
.position(|slot| slot == internal)
|
||||
}
|
||||
/// The workstation screen of a menu type a server opens.
|
||||
pub(super) fn remote_station_kind(menu: &str) -> Option<super::stations::Kind> {
|
||||
use super::stations::Kind;
|
||||
@@ -1757,6 +1836,9 @@ impl Game<'_> {
|
||||
self.audio.play(&name, Some(at), p.volume, p.pitch);
|
||||
}
|
||||
}
|
||||
C::StopSound(p) => self
|
||||
.audio
|
||||
.stop_sounds(p.sound.as_deref(), p.source.map(|s| s as usize)),
|
||||
C::AddEntity(p) => {
|
||||
let kind = self
|
||||
.remote
|
||||
@@ -1778,6 +1860,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.spawn_entity(p.id, &kind, p.position, rotation);
|
||||
// Entity.recreateFromPacket: setDeltaMovement(packet.getMovement()).
|
||||
self.remote_motion(p.id, p.velocity);
|
||||
if !self.spawn_remote_object(p.id, &kind, p.position, rotation[0], p.data) {
|
||||
self.spawn_remote_projectile(
|
||||
p.id,
|
||||
@@ -1788,6 +1872,7 @@ impl Game<'_> {
|
||||
p.data,
|
||||
);
|
||||
}
|
||||
self.move_remote_text_display(p.id, p.position, Some((rotation[0], rotation[1])));
|
||||
}
|
||||
C::RemoveEntities(p) => {
|
||||
for id in p.ids {
|
||||
@@ -1974,6 +2059,15 @@ impl Game<'_> {
|
||||
}
|
||||
self.sleeping = *bed;
|
||||
}
|
||||
// Entity.DATA_SHARED_FLAGS_ID's sprinting bit: the
|
||||
// server stops the sprint after a hit's extra
|
||||
// knockback (the flag only, as assignValues sets
|
||||
// it: the speed modifier and wasSprinting stay).
|
||||
(0, Value::Byte(flags)) => {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.sprinting = flags & 0x08 != 0;
|
||||
}
|
||||
}
|
||||
// Entity.DATA_AIR_SUPPLY_ID and
|
||||
// Player.DATA_PLAYER_ABSORPTION_ID: the bubbles
|
||||
// and golden hearts the server's player has.
|
||||
@@ -2087,17 +2181,29 @@ impl Game<'_> {
|
||||
pl.velocity = Vec3::new(p.velocity[0], p.velocity[1], p.velocity[2]);
|
||||
}
|
||||
}
|
||||
// The same packet for another entity: Entity.lerpMotion.
|
||||
C::SetEntityMotion(p) => self.remote_motion(p.id, p.velocity),
|
||||
// ClientPacketListener.handleDamageEvent: LivingEntity.handleDamageEvent
|
||||
// jolts the walk animation and restarts the hurt flash. Its hurt
|
||||
// sound comes as a sound from Horizon's host, which plays it itself.
|
||||
// This player's own hurt tilt and flash come from the server too
|
||||
// (sent to the player itself): its own moves never hurt it.
|
||||
C::DamageEvent(p) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.health.animate_hurt();
|
||||
}
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
m.limb_speed = 1.5;
|
||||
m.hurt_time = 10;
|
||||
}
|
||||
}
|
||||
C::HurtAnimation(p) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.health.animate_hurt();
|
||||
}
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
m.hurt_time = 10;
|
||||
}
|
||||
}
|
||||
@@ -2163,9 +2269,14 @@ impl Game<'_> {
|
||||
self.remote_world_options_difficulty(self.ui.difficulty, p.locked);
|
||||
}
|
||||
C::GameRuleValues(p) => self.remote_world_options_rules(p.entries),
|
||||
// ClientPacketListener.handleSetHealth: LocalPlayer.hurtTo.
|
||||
C::SetHealth(p) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
let flash = self.remote.as_mut().is_some_and(|r| {
|
||||
r.health = (p.health, p.food, p.saturation);
|
||||
std::mem::replace(&mut r.flash_on_set_health, true)
|
||||
});
|
||||
if flash && let Some(pl) = self.player.as_mut() {
|
||||
pl.health.flash_health_change(p.health);
|
||||
}
|
||||
self.apply_remote_health();
|
||||
if p.health <= 0.0 && self.screen != Screen::Dead && self.player.is_some() {
|
||||
@@ -2557,6 +2668,8 @@ impl Game<'_> {
|
||||
self.drop_remote_chunks_out_of_range();
|
||||
}
|
||||
C::BlockEntityData(p) => self.remote_block_entity_data(p.pos, p.kind, &p.tag),
|
||||
C::OpenSignEditor(p) => self.remote_open_sign_editor(p),
|
||||
C::OpenBook(p) => self.remote_open_book(p.hand),
|
||||
// handleSetSpawn: ClientLevel.setRespawnData, which spawn compasses point at.
|
||||
C::SetDefaultSpawnPosition(p) => {
|
||||
let [x, y, z] = p.pos.pos;
|
||||
@@ -2613,6 +2726,7 @@ impl Game<'_> {
|
||||
}
|
||||
// ClientPacketListener.setActionBarText, setTitleText,
|
||||
// setSubtitleText, setTitlesAnimation and handleTitlesClear.
|
||||
C::BossEvent(p) => self.apply_boss_event(&p),
|
||||
C::SetActionBarText(p) => self.titles.set_overlay(remote_text(&p.text), false),
|
||||
C::SetTitleText(p) => self.titles.set_title(remote_text(&p.text)),
|
||||
C::SetSubtitleText(p) => self.titles.set_subtitle(remote_text(&p.text)),
|
||||
@@ -2893,7 +3007,9 @@ impl Game<'_> {
|
||||
item.stack = stack;
|
||||
return;
|
||||
}
|
||||
let Some(p) = position else { return };
|
||||
let Some((p, velocity)) = position else {
|
||||
return;
|
||||
};
|
||||
let at = Vec3::new(p[0], p[1], p[2]);
|
||||
let bob_offset = self.world.random.next_float() * std::f32::consts::PI * 2.0;
|
||||
self.dropped.push(ItemEntity {
|
||||
@@ -2904,7 +3020,7 @@ impl Game<'_> {
|
||||
stack,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
velocity,
|
||||
on_ground: false,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
@@ -2912,6 +3028,23 @@ impl Game<'_> {
|
||||
ticks: 0,
|
||||
});
|
||||
}
|
||||
/// `Entity.lerpMotion` (and `recreateFromPacket`'s motion) for the
|
||||
/// entities whose own tick moves them on a client: dropped items and
|
||||
/// falling blocks.
|
||||
fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) {
|
||||
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
|
||||
if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
|
||||
d.velocity = v;
|
||||
} else if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
|
||||
f.velocity = v;
|
||||
} else if let Some(p) = self
|
||||
.remote
|
||||
.as_mut()
|
||||
.and_then(|r| r.pending_items.get_mut(&id))
|
||||
{
|
||||
p.1 = v;
|
||||
}
|
||||
}
|
||||
fn apply_remote_health(&mut self) {
|
||||
let Some((health, food, saturation)) = self.remote.as_ref().map(|r| r.health) else {
|
||||
return;
|
||||
@@ -2967,6 +3100,8 @@ impl Game<'_> {
|
||||
}
|
||||
None => {
|
||||
let mut player = PlayerMotion::new(self.motion, position)?;
|
||||
// Player.tick derives noPhysics from the current game mode.
|
||||
player.spectator = self.spectator();
|
||||
player.velocity = delta;
|
||||
self.player = Some(player);
|
||||
self.pad.player_replaced();
|
||||
@@ -3001,6 +3136,8 @@ impl Game<'_> {
|
||||
/// One client tick while connected (Minecraft.tick with a ClientLevel).
|
||||
pub(super) fn tick_remote(&mut self) -> Result<()> {
|
||||
self.tick_remote_level()?;
|
||||
// Client mods tick on any server, single player's own included.
|
||||
self.tick_mods();
|
||||
// Minecraft.tick sends this even when the player cannot tick yet,
|
||||
// including while waiting for chunks. Keep it outside early returns.
|
||||
if self.remote.as_ref().is_some_and(|r| r.joined) {
|
||||
@@ -3026,6 +3163,7 @@ impl Game<'_> {
|
||||
// Minecraft.tick: TickRateManager.tick; ClientLevel.tickTime advances
|
||||
// the time only while the level runs normally.
|
||||
let running = self.clock.world_runs();
|
||||
self.boss_overlay.tick_darken(running);
|
||||
if running {
|
||||
self.world.game_time += 1;
|
||||
self.remote_clock_tick();
|
||||
@@ -3054,7 +3192,7 @@ impl Game<'_> {
|
||||
self.sleep_timer = super::tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
|
||||
// Gui.tick: the titles, the action bar and the held item's name.
|
||||
self.tick_hud();
|
||||
let ghast_collisions = self.happy_ghast_player_collisions();
|
||||
let entity_collisions = self.player_entity_collisions();
|
||||
let pushed = self.remote_pushes();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(());
|
||||
@@ -3109,6 +3247,9 @@ impl Game<'_> {
|
||||
| u8::from(keys.sprint) << 6;
|
||||
// A passenger moves with its vehicle (Entity.rideTick), not by itself.
|
||||
let riding = self.screen != Screen::Dead && self.remote_ride(keys);
|
||||
// The server sends no passenger's position: they sit where their
|
||||
// vehicles are now.
|
||||
self.position_riders();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -3121,14 +3262,11 @@ impl Game<'_> {
|
||||
[player.position.x, player.position.y, player.position.z],
|
||||
);
|
||||
let was_flying = player.flying;
|
||||
player.yaw = self.yaw;
|
||||
// As the window's own tick has them: the helmet's Respiration
|
||||
// (decreaseAirSupply) and the dimension's FAST_LAVA, the Nether's
|
||||
// stronger lava current.
|
||||
player.respiration = self.armor[0]
|
||||
.as_ref()
|
||||
.map_or(0, |s| s.enchantment_level("minecraft:respiration") as i32);
|
||||
player.fluids.fast_lava = self.dim == Dim::Nether;
|
||||
self.sync_player_body();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let was_gliding = player.fall_flying;
|
||||
let dead = self.screen == Screen::Dead;
|
||||
// LivingEntity.isImmobile: a sleeper lies still in the bed.
|
||||
if let Some(head) = self.sleeping {
|
||||
@@ -3139,9 +3277,10 @@ impl Game<'_> {
|
||||
);
|
||||
player.velocity = Vec3::ZERO;
|
||||
} else if !dead && !riding {
|
||||
player.client_side = true;
|
||||
player.velocity = player.velocity.add_vector(pushed);
|
||||
MotionWorld {
|
||||
entities: &ghast_collisions,
|
||||
entities: &entity_collisions,
|
||||
player,
|
||||
scene: &mut self.world,
|
||||
assets: &self.pack.assets,
|
||||
@@ -3153,6 +3292,9 @@ impl Game<'_> {
|
||||
// LocalPlayer.onUpdateAbilities: flight turned on or off (a double
|
||||
// jump, a spectator, landing) is sent to the server.
|
||||
let flying = player.flying;
|
||||
// LocalPlayer.aiStep: a glide this client started is sent to the
|
||||
// server (ServerboundPlayerCommandPacket START_FALL_FLYING).
|
||||
let started_gliding = player.fall_flying && !was_gliding;
|
||||
let events = std::mem::take(&mut player.events);
|
||||
self.previous_eye = self.eye;
|
||||
self.eye += (player.dimensions.eye_height - self.eye) * 0.5;
|
||||
@@ -3179,7 +3321,7 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// The server owns health and hunger: undo local food/fall changes.
|
||||
// The server owns health and hunger.
|
||||
self.apply_remote_health();
|
||||
self.tick_avatar(before, false);
|
||||
// The player's own model (third person, the inventory's preview).
|
||||
@@ -3192,6 +3334,14 @@ impl Game<'_> {
|
||||
flags: if flying { 2 } else { 0 },
|
||||
}));
|
||||
}
|
||||
if started_gliding {
|
||||
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
|
||||
self.send(S::PlayerCommand(play::PlayerCommand {
|
||||
id,
|
||||
action: 6,
|
||||
data: 0,
|
||||
}));
|
||||
}
|
||||
self.remote_send_state(input);
|
||||
Ok(())
|
||||
}
|
||||
@@ -3211,9 +3361,10 @@ impl Game<'_> {
|
||||
player.flying_speed = a.flying_speed;
|
||||
}
|
||||
/// The local player's share of every touching entity's
|
||||
/// `LivingEntity.pushEntities`: on a client `EntitySelector.pushableBy`
|
||||
/// lets entities push only the local player, so the server never sends
|
||||
/// these pushes. `doPush(player)` is `player.push(entity)`.
|
||||
/// `LivingEntity.pushEntities`, and of the boats' pushes: on a client
|
||||
/// `EntitySelector.pushableBy` lets entities push only the local player,
|
||||
/// so the server never sends these pushes. `doPush(player)` is
|
||||
/// `player.push(entity)`.
|
||||
fn remote_pushes(&self) -> Vec3 {
|
||||
let (Some(p), Some(r)) = (self.player.as_ref(), self.remote.as_ref()) else {
|
||||
return Vec3::ZERO;
|
||||
@@ -3272,11 +3423,54 @@ impl Game<'_> {
|
||||
za *= f64::from(0.05_f32);
|
||||
push = Vec3::new(push.x - xa, push.y, push.z - za);
|
||||
}
|
||||
// AbstractBoat.push(player) from the boat's own tick (its box grown
|
||||
// by 0.2 sideways) and from the player's pushEntities (its box).
|
||||
let player_team = teams.of(Who::Player);
|
||||
let area = Vec3::new(f64::from(0.2_f32), f64::from(-0.01_f32), f64::from(0.2_f32));
|
||||
for b in self.boats.iter().filter(|b| self.riding != Some(b.id)) {
|
||||
let bb = b.bounds();
|
||||
let times = usize::from(
|
||||
bounds.intersects(bb.inflate(area))
|
||||
&& horizon_client::scoreboard::pushable_by(None, player_team, true),
|
||||
) + usize::from(
|
||||
bounds.intersects(bb)
|
||||
&& horizon_client::scoreboard::pushable_by(player_team, None, true),
|
||||
);
|
||||
if times > 0
|
||||
&& super::boat_passengers::boat_pushes(bb, bounds, false)
|
||||
&& let Some(v) = super::boat_passengers::push_impulse(b.position, p.position)
|
||||
{
|
||||
push = push.add_vector(v.scale(times as f64));
|
||||
}
|
||||
}
|
||||
push
|
||||
}
|
||||
/// ClientLevel.tickEntities; a frozen level only ticks the players
|
||||
/// (`TickRateManager.isEntityFrozen`).
|
||||
fn tick_remote_entities(&mut self, running: bool) {
|
||||
if running {
|
||||
self.tick_text_displays();
|
||||
}
|
||||
// EntityFluidInteraction.update for the entities this window only displays: the
|
||||
// server never sends the fluid contact, and the renderers read it (cod, salmon and
|
||||
// tropical fish lie on their side without `in_water`, #1591).
|
||||
// A frozen level ticks only the players, as below.
|
||||
let fluids: Vec<_> = {
|
||||
let w = self.mob_world_base(&[]);
|
||||
let players = self.remote.as_ref().map(|r| &r.level_players);
|
||||
self.mobs
|
||||
.iter()
|
||||
.map(|m| {
|
||||
(running || players.is_some_and(|p| p.contains_key(&(m.id as i32))))
|
||||
.then(|| w.fluids(m.bounds(), m.eye_y()))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
for (m, fluids) in self.mobs.iter_mut().zip(fluids) {
|
||||
if let Some(fluids) = fluids {
|
||||
m.fluids = fluids;
|
||||
}
|
||||
}
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -3379,20 +3573,12 @@ impl Game<'_> {
|
||||
l.steps -= 1;
|
||||
}
|
||||
}
|
||||
// ItemEntity.tick runs on a client too: gravity, collisions and
|
||||
// friction move the item between the server's updates (every 20
|
||||
// ticks), and only the server removes it.
|
||||
let (world, facts, collision) = (&self.world, self.res.facts(), self.collision);
|
||||
for d in self.dropped.iter_mut().filter(|_| running) {
|
||||
d.previous = d.position;
|
||||
d.age += 1;
|
||||
if let Some(l) = lerps.get_mut(&(d.id as i32))
|
||||
&& l.steps > 0
|
||||
{
|
||||
let n = f64::from(l.steps);
|
||||
d.position = Vec3::new(
|
||||
d.position.x + (l.target[0] - d.position.x) / n,
|
||||
d.position.y + (l.target[1] - d.position.y) / n,
|
||||
d.position.z + (l.target[2] - d.position.z) / n,
|
||||
);
|
||||
l.steps -= 1;
|
||||
}
|
||||
d.tick(world, facts, collision, false);
|
||||
}
|
||||
head_lerps.retain(|_, (_, steps)| *steps > 0);
|
||||
for (item, ticks, at, eye, pitch, yaw) in using {
|
||||
@@ -3741,7 +3927,7 @@ impl Game<'_> {
|
||||
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
// A boat, minecart, item frame, armor stand or painting nearer than
|
||||
// any mob: the server uses it (AbstractBoat.interact boards it).
|
||||
if let Some(hit) = self.remote_object_hit()
|
||||
if let Some(hit) = self.remote_object_hit(false)
|
||||
&& self.mob_hit().is_none_or(|(_, d)| hit.distance < d)
|
||||
&& self.before_block(hit.distance)
|
||||
{
|
||||
@@ -3827,11 +4013,17 @@ impl Game<'_> {
|
||||
self.predict_item_on_block(stack, &hit);
|
||||
}
|
||||
if held_block {
|
||||
if reacts
|
||||
|| held
|
||||
.as_ref()
|
||||
.is_some_and(|stack| self.remote_can_place(&hit, stack))
|
||||
{
|
||||
let placed = if reacts {
|
||||
None
|
||||
} else {
|
||||
held.as_ref()
|
||||
.and_then(|stack| self.remote_can_place(&hit, stack))
|
||||
};
|
||||
if let Some(placed) = &placed {
|
||||
self.predict_place(placed, seq);
|
||||
self.with_hand(hand, Game::consume_held);
|
||||
}
|
||||
if reacts || placed.is_some() {
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
}
|
||||
// BlockItem.place returns FAIL when placement is rejected:
|
||||
@@ -3891,18 +4083,7 @@ impl Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
for &(p, state) in changes {
|
||||
let pos = [p.x, p.y, p.z];
|
||||
let before = self.world.block(p.x, p.y, p.z);
|
||||
// BlockStatePredictionHandler.retainKnownServerState.
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.predictions
|
||||
.entry(pos)
|
||||
.and_modify(|e| e.0 = seq)
|
||||
.or_insert((seq, before.0 as i32));
|
||||
}
|
||||
self.set_block(pos, state.0 as i32);
|
||||
}
|
||||
self.predict_blocks(changes, seq);
|
||||
// `level.playSound(player, ...)`: the clicker hears it from its own use; the
|
||||
// server sends it to the others only.
|
||||
for (pos, sound, volume, pitch) in sounds {
|
||||
@@ -3962,12 +4143,45 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Run BlockItem.place on a disposable level before predicting its swing.
|
||||
/// The real level supplies collision shapes and building-blocking entities;
|
||||
/// block writes, random draws, scheduled ticks and events stay in the preview.
|
||||
fn remote_can_place(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
|
||||
/// MultiPlayerGameMode.useItemOn inside startPrediction, for a block item:
|
||||
/// the blocks `BlockItem.place` set at `pos` are placed on this client's
|
||||
/// level at once, so the next click of a fast bridge aims at them rather
|
||||
/// than past them (the server would refuse it). Its place sound is the
|
||||
/// placer's own (`playSound(player, ...)`: the server sends it to the
|
||||
/// others only).
|
||||
fn predict_place(&mut self, (pos, changes): &DryPlace, seq: i32) {
|
||||
let ticks = self.ticks;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
// set_block plays the place sound of the block at `placing`.
|
||||
r.placing = Some(([pos.x, pos.y, pos.z], ticks));
|
||||
}
|
||||
self.predict_blocks(changes, seq);
|
||||
}
|
||||
/// The blocks a use changed, set on this client's level and kept as
|
||||
/// predicted under the use's sequence until the server acknowledges it
|
||||
/// (`BlockStatePredictionHandler.retainKnownServerState`): a use the
|
||||
/// server refused goes back.
|
||||
fn predict_blocks(&mut self, changes: &DryChanges, seq: i32) {
|
||||
for &(p, state) in changes {
|
||||
let pos = [p.x, p.y, p.z];
|
||||
let before = self.world.block(p.x, p.y, p.z);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.predictions
|
||||
.entry(pos)
|
||||
.and_modify(|e| e.0 = seq)
|
||||
.or_insert((seq, before.0 as i32));
|
||||
}
|
||||
self.set_block(pos, state.0 as i32);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run BlockItem.place on a disposable level: where it placed and the
|
||||
/// blocks it set. The real level supplies collision shapes and
|
||||
/// building-blocking entities; random draws, scheduled ticks and events
|
||||
/// stay in the preview.
|
||||
fn remote_can_place(&mut self, hit: &super::Hit, stack: &ItemStack) -> Option<DryPlace> {
|
||||
if !self.may_build() {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
let ctx = horizon_world::level::PlaceContext {
|
||||
clicked_pos: horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
|
||||
@@ -3982,14 +4196,16 @@ impl Game<'_> {
|
||||
};
|
||||
self.with_level(|b, level, _| {
|
||||
let mut level = DryLevel::new(level);
|
||||
matches!(
|
||||
b.place_block(
|
||||
&mut level,
|
||||
&mut horizon_world::level::Ticks::default(),
|
||||
&ctx
|
||||
),
|
||||
horizon_world::level::PlaceOutcome::Placed { .. }
|
||||
)
|
||||
match b.place_block(
|
||||
&mut level,
|
||||
&mut horizon_world::level::Ticks::default(),
|
||||
&ctx,
|
||||
) {
|
||||
horizon_world::level::PlaceOutcome::Placed { pos, .. } => {
|
||||
Some((pos, level.changed.into_iter().collect()))
|
||||
}
|
||||
horizon_world::level::PlaceOutcome::Failed => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4010,21 +4226,60 @@ impl Game<'_> {
|
||||
return false;
|
||||
};
|
||||
// MultiPlayerGameMode.useItem runs the use here too: food starts
|
||||
// being eaten (Consumable.startConsuming).
|
||||
// being eaten (Consumable.startConsuming), a book and quill opens.
|
||||
self.with_hand(hand, |g| g.start_eating(&info));
|
||||
// BowItem.use: startUsingItem here too (with an arrow, or in creative), so that letting
|
||||
// go sends RELEASE_USE_ITEM (Minecraft.handleKeybinds) and the server shoots.
|
||||
if &*held.item == "minecraft:bow" {
|
||||
self.with_hand(hand, |g| {
|
||||
if g.using.is_none() && (g.creative || g.projectile_slot("minecraft:bow").is_some())
|
||||
{
|
||||
g.using = Some((g.held_slot(), 0));
|
||||
}
|
||||
});
|
||||
if self.with_hand(hand, |g| g.remote_use_book(&held)) {
|
||||
return true;
|
||||
}
|
||||
self.with_hand(hand, |g| g.remote_start_held_use(&held));
|
||||
item_has_use(&info)
|
||||
}
|
||||
|
||||
/// `Item.use` runs on the client too (MultiPlayerGameMode.useItem): an
|
||||
/// item held in use until the button lets go starts being used here, so
|
||||
/// that letting go sends RELEASE_USE_ITEM (Minecraft.handleKeybinds) and
|
||||
/// the server ends the use: a bow or a trident is let go, a crossbow's
|
||||
/// charge ends, a shield is lowered, a spyglass stops scoping. Their
|
||||
/// sounds, loaded arrows and thrown tridents come from the server.
|
||||
fn remote_start_held_use(&mut self, held: &ItemStack) {
|
||||
if self.using.is_some() {
|
||||
return;
|
||||
}
|
||||
let start = match &*held.item {
|
||||
// BowItem.use: with an arrow, or in creative.
|
||||
"minecraft:bow" => self.creative || self.projectile_slot("minecraft:bow").is_some(),
|
||||
// CrossbowItem.use: charging when not charged and with ammunition;
|
||||
// a charged one shoots on the server.
|
||||
"minecraft:crossbow" => {
|
||||
held.data().is_none_or(|d| d.charged.is_empty())
|
||||
&& (self.creative || self.projectile_slot("minecraft:crossbow").is_some())
|
||||
}
|
||||
// TridentItem.use: not about to break, Riptide only in water or rain.
|
||||
"minecraft:trident" => {
|
||||
self.use_trident(held);
|
||||
return;
|
||||
}
|
||||
// SpyglassItem.use (its sound is the server's).
|
||||
"minecraft:spyglass" => true,
|
||||
// ShieldItem.use: anything that blocks attacks, off cooldown.
|
||||
_ => {
|
||||
self.raise_shield(self.held_slot());
|
||||
return;
|
||||
}
|
||||
};
|
||||
if start {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
}
|
||||
}
|
||||
/// Whether `stack` stays in use until the button lets go (bows,
|
||||
/// crossbows, tridents, spyglasses, shields): its use only counts ticks here.
|
||||
fn held_until_released(&self, stack: &ItemStack) -> bool {
|
||||
matches!(
|
||||
&*stack.item,
|
||||
"minecraft:bow" | "minecraft:crossbow" | "minecraft:trident" | "minecraft:spyglass"
|
||||
) || self.blocks_attacks(stack).is_some()
|
||||
}
|
||||
|
||||
/// The rest of Minecraft.startUseItem once the main hand's target passed:
|
||||
/// the main hand's item, then the off hand's turn (its block, its item).
|
||||
/// The server does each packet's part with that packet's hand only.
|
||||
@@ -4043,7 +4298,8 @@ impl Game<'_> {
|
||||
}
|
||||
self.remote_use_item(Hand::Off);
|
||||
}
|
||||
/// `LivingEntity.updatingUsingItem` on a server client: a drawn bow, and a
|
||||
/// `LivingEntity.updatingUsingItem` on a server client: an item held in
|
||||
/// use (`held_until_released`), and a
|
||||
/// consumable's eating animation and its crumbs. The server eats it and
|
||||
/// sends the stack, the hunger and the sounds.
|
||||
fn remote_tick_using(&mut self) {
|
||||
@@ -4051,10 +4307,13 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let stack = self.stack_in(slot).cloned();
|
||||
// A drawn bow: held until the button lets go (or another slot is picked).
|
||||
if stack.as_ref().is_some_and(|s| &*s.item == "minecraft:bow") {
|
||||
self.using = (slot == self.selected || slot == super::shield::OFFHAND_SLOT)
|
||||
.then_some((slot, ticks + 1));
|
||||
// Another hotbar slot picked: LivingEntity.updatingUsingItem sees a
|
||||
// different stack in the hand. The off hand keeps its item.
|
||||
let in_hand = slot == self.selected || slot == super::shield::OFFHAND_SLOT;
|
||||
// A drawn bow, a charging crossbow, a raised shield...: held until
|
||||
// the button lets go.
|
||||
if stack.as_ref().is_some_and(|s| self.held_until_released(s)) {
|
||||
self.using = in_hand.then_some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
let consumable = stack
|
||||
@@ -4068,7 +4327,7 @@ impl Game<'_> {
|
||||
// Button release belongs to the next handleKeybinds phase, which
|
||||
// sends RELEASE_USE_ITEM. Do not silently clear a use begun by a
|
||||
// quick press/release between ticks.
|
||||
if slot != self.selected {
|
||||
if !in_hand {
|
||||
self.using = None;
|
||||
return;
|
||||
}
|
||||
@@ -4177,12 +4436,12 @@ impl Game<'_> {
|
||||
id: self.creatures.projectiles[i].id as i32,
|
||||
}));
|
||||
self.reset_attack_strength();
|
||||
} else if let Some(hit) = self.remote_object_hit()
|
||||
} else if let Some(hit) = self.remote_object_hit(true)
|
||||
&& self.mob_hit().is_none_or(|(_, d)| hit.distance < d)
|
||||
&& self.before_block(hit.distance)
|
||||
{
|
||||
// VehicleEntity.hurtServer, ItemFrame and ArmorStand hurtServer,
|
||||
// a painting broken: the server decides.
|
||||
// a painting broken or an EndCrystal hurtServer: the server decides.
|
||||
self.send(S::Attack(play::Attack { id: hit.id }));
|
||||
self.reset_attack_strength();
|
||||
} else if let Some((i, distance)) = self.mob_hit()
|
||||
@@ -4242,8 +4501,7 @@ impl Game<'_> {
|
||||
/// Keys with a multiplayer meaning; returns true when consumed.
|
||||
pub(super) fn remote_key(&mut self, key: KeyCode) -> Result<bool> {
|
||||
if self.screen != Screen::Playing {
|
||||
if key == KeyCode::KeyE && self.screen == Screen::Inventory
|
||||
|| key == KeyCode::Escape && self.screen == Screen::Inventory
|
||||
if (key == KeyCode::KeyE || key == KeyCode::Escape) && self.screen == Screen::Inventory
|
||||
{
|
||||
self.remote_close_container();
|
||||
self.screen = Screen::Playing;
|
||||
@@ -4390,12 +4648,10 @@ impl Game<'_> {
|
||||
for &(slot, button, input) in ops {
|
||||
let slot = match slot {
|
||||
None => -999,
|
||||
Some(internal) => {
|
||||
match (0..64).find(|&s| Self::menu_slot(&menu, s) == Some(internal)) {
|
||||
Some(s) => s as i16,
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
Some(internal) => match Self::menu_slot_index(&menu, internal) {
|
||||
Some(s) => s as i16,
|
||||
None => return false,
|
||||
},
|
||||
};
|
||||
clicks.push((slot, button, input));
|
||||
}
|
||||
@@ -4464,7 +4720,7 @@ impl Game<'_> {
|
||||
/// such slot.
|
||||
pub(super) fn remote_slot_click(&mut self, internal: usize, button: i8, input: i32) -> bool {
|
||||
let (container, menu) = self.remote_menu();
|
||||
let slot = (0..64).find(|&s| Self::menu_slot(&menu, s) == Some(internal));
|
||||
let slot = Self::menu_slot_index(&menu, internal);
|
||||
let Some(slot) = slot else { return false };
|
||||
let carried = self.to_network(&self.carried).map(|s| Hashed::of(&s));
|
||||
let state = self.remote.as_ref().map_or(0, |r| r.state_id);
|
||||
@@ -4899,6 +5155,8 @@ impl Game<'_> {
|
||||
.collect::<Vec<_>>(),
|
||||
// The player's own model exists (third person and the inventory draw it).
|
||||
"player_model": self.player_model.is_some(),
|
||||
"text_display_vertices": self.text_display_vertices,
|
||||
"text_displays": self.text_displays.len(),
|
||||
"health": r.health.0,
|
||||
"food": r.health.1,
|
||||
"decode_errors": r.decode_errors,
|
||||
@@ -4998,11 +5256,12 @@ type DrySound = (
|
||||
f32,
|
||||
f32,
|
||||
);
|
||||
/// The blocks a use on the dry level set.
|
||||
type DryChanges = Vec<(horizon_core::position::BlockPos, BlockStateId)>;
|
||||
/// What a use on the dry level changed, and the sounds it played.
|
||||
type DryUse = (
|
||||
Vec<(horizon_core::position::BlockPos, BlockStateId)>,
|
||||
Vec<DrySound>,
|
||||
);
|
||||
type DryUse = (DryChanges, Vec<DrySound>);
|
||||
/// Where `BlockItem.place` on the dry level placed, and the blocks it set.
|
||||
type DryPlace = (horizon_core::position::BlockPos, DryChanges);
|
||||
|
||||
/// The blocks a guest's hand flips that its use predicts (and hears at once): a wooden or
|
||||
/// copper door, trapdoor or fence gate, a lever or a button. An iron door or trapdoor has
|
||||
@@ -5323,4 +5582,28 @@ mod tests {
|
||||
}
|
||||
assert_eq!(Game::menu_slot("inventory", 46), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_clicks_find_slots_past_the_old_large_chest_cutoff() {
|
||||
for (menu, internal, index) in [
|
||||
("minecraft:generic_9x6", 18, 63),
|
||||
("minecraft:generic_9x6", 19, 64),
|
||||
("minecraft:generic_9x6", 0, 81),
|
||||
("minecraft:generic_9x6", 8, 89),
|
||||
("minecraft:generic_9x6", 353, 53),
|
||||
("minecraft:generic_9x5", 8, 80),
|
||||
("minecraft:generic_9x4", 8, 71),
|
||||
("minecraft:generic_9x3", 8, 62),
|
||||
("inventory", 504, 45),
|
||||
] {
|
||||
assert_eq!(
|
||||
Game::menu_slot_index(menu, internal),
|
||||
Some(index),
|
||||
"{menu}, game slot {internal}"
|
||||
);
|
||||
}
|
||||
assert_eq!(Game::menu_slot_index("minecraft:generic_9x6", 354), None);
|
||||
assert_eq!(Game::menu_slot_index("minecraft:generic_9x6", 504), None);
|
||||
assert_eq!(Game::menu_slot_index("unknown", 0), None);
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,15 @@
|
||||
//! `onSyncedDataUpdated`): what a client draws from it rather than from its
|
||||
//! own simulation, which a client of a server doesn't run.
|
||||
use super::Game;
|
||||
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
|
||||
use horizon_client::mobs::{Mob, Species, tame};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
use horizon_protocol::registry::Builtin;
|
||||
|
||||
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
|
||||
/// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses).
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
|
||||
/// `builtin` names the ids of built-in registries.
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
|
||||
let species = m.species();
|
||||
let baby = super::lan::baby_index(&m.kind.id);
|
||||
for (index, _, value) in values {
|
||||
@@ -42,16 +45,45 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
|
||||
(Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => {
|
||||
m.extra.size = (*size).clamp(1, 127);
|
||||
}
|
||||
// Villager.DATA_VILLAGER_DATA and ZombieVillager's (after
|
||||
// Zombie's three): what VillagerProfessionLayer draws.
|
||||
(Species::Villager, 19, Value::Ids(data))
|
||||
| (Species::ZombieVillager, 20, Value::Ids(data)) => villager_data(m, data, builtin),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// VillagerData as the network sends it: the type and the profession by
|
||||
/// their built-in registry ids, then the level.
|
||||
fn villager_data(m: &mut Mob, data: &[i32], builtin: &Builtin) {
|
||||
let &[kind, profession, level] = data else {
|
||||
return;
|
||||
};
|
||||
let index = |registry: &str, id: i32, names: &[&str]| {
|
||||
let name = builtin
|
||||
.get(registry)?
|
||||
.name(id)?
|
||||
.strip_prefix("minecraft:")?;
|
||||
names.iter().position(|n| *n == name).map(|i| i as u8)
|
||||
};
|
||||
if let Some(t) = index("minecraft:villager_type", kind, &VILLAGER_TYPES) {
|
||||
m.extra.variant = t;
|
||||
}
|
||||
if let Some(p) = index("minecraft:villager_profession", profession, &PROFESSIONS) {
|
||||
m.extra.variant2 = p;
|
||||
}
|
||||
m.extra.variant3 = level.clamp(0, i32::from(u8::MAX)) as u8;
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// SetEntityData for a mob of the server's.
|
||||
pub(super) fn remote_mob_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
|
||||
let Some(r) = &self.remote else {
|
||||
return;
|
||||
};
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
|
||||
apply(m, values);
|
||||
apply(m, values, &r.builtin);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,11 +104,23 @@ mod tests {
|
||||
|
||||
/// A mob of the prepared resources' type `kind`, as a client spawns it
|
||||
/// (None without the resources).
|
||||
fn mob(kind: &str) -> Option<Mob> {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
fn root() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
let types = horizon_client::mobs::MobTypes::load(&root).ok()?;
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
}
|
||||
|
||||
/// The prepared resources' built-in registries (empty without them).
|
||||
fn builtin() -> Builtin {
|
||||
Builtin::load(&root().join("registries.json")).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
|
||||
super::apply(m, values, &builtin());
|
||||
}
|
||||
|
||||
fn mob(kind: &str) -> Option<Mob> {
|
||||
let types = horizon_client::mobs::MobTypes::load(&root()).ok()?;
|
||||
let t = std::sync::Arc::new(types.types.get(kind)?.clone());
|
||||
let mut m = Mob::new(1, t, horizon_core::geometry::Vec3::ZERO, 0.0);
|
||||
client_defaults(&mut m);
|
||||
@@ -133,4 +177,45 @@ mod tests {
|
||||
apply(&mut m, &[(0, s::BYTE, Value::Byte(0))]);
|
||||
assert_eq!(m.fire_ticks, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_villager_takes_its_type_profession_and_level() {
|
||||
let Some(mut m) = mob("minecraft:villager") else {
|
||||
return;
|
||||
};
|
||||
let registries = builtin();
|
||||
let id = |registry: &str, name: &str| registries.get(registry)?.id(name);
|
||||
let (Some(desert), Some(farmer), Some(librarian)) = (
|
||||
id("minecraft:villager_type", "minecraft:desert"),
|
||||
id("minecraft:villager_profession", "minecraft:farmer"),
|
||||
id("minecraft:villager_profession", "minecraft:librarian"),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let name = |m: &Mob| {
|
||||
(
|
||||
VILLAGER_TYPES[m.extra.variant as usize],
|
||||
PROFESSIONS[m.extra.variant2 as usize],
|
||||
m.extra.variant3,
|
||||
)
|
||||
};
|
||||
apply(
|
||||
&mut m,
|
||||
&[(19, s::VILLAGER_DATA, Value::Ids(vec![desert, farmer, 2]))],
|
||||
);
|
||||
assert_eq!(name(&m), ("desert", "farmer", 2));
|
||||
// A job taken later: only the profession and the level change.
|
||||
apply(
|
||||
&mut m,
|
||||
&[(19, s::VILLAGER_DATA, Value::Ids(vec![desert, librarian, 5]))],
|
||||
);
|
||||
assert_eq!(name(&m), ("desert", "librarian", 5));
|
||||
// A zombie villager's data comes after Zombie's three entries.
|
||||
let mut z = mob("minecraft:zombie_villager").unwrap();
|
||||
apply(
|
||||
&mut z,
|
||||
&[(20, s::VILLAGER_DATA, Value::Ids(vec![desert, farmer, 3]))],
|
||||
);
|
||||
assert_eq!(name(&z), ("desert", "farmer", 3));
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,12 @@ impl Game<'_> {
|
||||
let at = Vec3::new(position[0], position[1], position[2]);
|
||||
let block = position.map(|v| v.floor() as i32);
|
||||
self.remove_remote_object(id);
|
||||
if let Some(cart) = super::minecarts::CartKind::from_id(kind) {
|
||||
if kind == "minecraft:text_display" {
|
||||
self.text_displays.insert(
|
||||
id as i32,
|
||||
super::text_displays::RemoteTextDisplay::new(position, yaw),
|
||||
);
|
||||
} else if let Some(cart) = super::minecarts::CartKind::from_id(kind) {
|
||||
let mut c = super::minecarts::Minecart::new(id, cart, at);
|
||||
c.yaw = yaw;
|
||||
c.previous_yaw = yaw;
|
||||
@@ -93,6 +98,10 @@ impl Game<'_> {
|
||||
|
||||
/// `handleSetEntityData` for an object: what changes how it looks.
|
||||
pub(super) fn remote_object_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
|
||||
if let Some(display) = self.text_displays.get_mut(&id) {
|
||||
display.metadata(values);
|
||||
return;
|
||||
}
|
||||
let id = id as u32;
|
||||
for (index, _, value) in values {
|
||||
if self.item_frames.iter().any(|f| f.id == id) {
|
||||
@@ -273,6 +282,7 @@ impl Game<'_> {
|
||||
|
||||
/// Every object a server sent (a new connection, level or dimension).
|
||||
pub(super) fn clear_remote_objects(&mut self) {
|
||||
self.text_displays.clear();
|
||||
self.carts.clear();
|
||||
self.boats.clear();
|
||||
self.armor_stands.clear();
|
||||
@@ -281,6 +291,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
pub(super) fn remove_remote_object(&mut self, id: u32) {
|
||||
self.text_displays.remove(&(id as i32));
|
||||
self.carts.retain(|c| c.id != id);
|
||||
self.boats.retain(|b| b.id != id);
|
||||
self.armor_stands.retain(|a| a.id != id);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! end crystals on a server's client (`ClientPacketListener.handleAddEntity`
|
||||
//! and `handleSetEntityData`): the server simulates them, the client draws
|
||||
//! them where it says and interpolates their moves.
|
||||
use super::{FallingBlock, Game};
|
||||
use super::{FallStep, FallingBlock, Game};
|
||||
use horizon_client::projectiles::{Projectile, ProjectileKind};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
@@ -24,6 +24,14 @@ fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
|
||||
"minecraft:llama_spit" => ProjectileKind::LlamaSpit,
|
||||
"minecraft:wind_charge" => ProjectileKind::WindCharge,
|
||||
"minecraft:breeze_wind_charge" => ProjectileKind::BreezeWindCharge,
|
||||
// Homing state stays on the server; the client interpolates its positions.
|
||||
"minecraft:shulker_bullet" => ProjectileKind::ShulkerBullet {
|
||||
target: horizon_client::mobs::Who::Player,
|
||||
dir: None,
|
||||
steps: 0,
|
||||
delta: [0.0; 3],
|
||||
seed: 0,
|
||||
},
|
||||
"minecraft:wither_skull" => ProjectileKind::WitherSkull { dangerous: false },
|
||||
_ => return None,
|
||||
})
|
||||
@@ -88,6 +96,8 @@ impl Game<'_> {
|
||||
self.end
|
||||
.add_remote_eye(id, position, [velocity.x, velocity.y, velocity.z])
|
||||
}
|
||||
// FishingHook.recreateFromPacket: the owner's id is the data.
|
||||
"minecraft:fishing_bobber" => self.spawn_remote_hook(id, at, data),
|
||||
"minecraft:end_crystal" => self.crystals.push(super::dragon::Crystal {
|
||||
id,
|
||||
position: at,
|
||||
@@ -114,6 +124,7 @@ impl Game<'_> {
|
||||
/// `handleSetEntityData` for these types: what changes how they look.
|
||||
pub(super) fn remote_projectile_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
|
||||
let id = id as u32;
|
||||
self.remote_hook_data(id, values);
|
||||
if self.potions.has_thrown(id) {
|
||||
// ThrowableItemProjectile.DATA_ITEM_STACK: the potion it is.
|
||||
for (index, _, value) in values {
|
||||
@@ -182,11 +193,17 @@ impl Game<'_> {
|
||||
t.previous = t.position;
|
||||
t.position = step(t.id, t.position);
|
||||
}
|
||||
for f in &mut self.falling {
|
||||
f.previous = f.position;
|
||||
f.age += 1;
|
||||
f.position = step(f.id, f.position);
|
||||
// FallingBlockEntity.tick runs on a client too: the block falls by
|
||||
// itself between the server's updates (every 20 ticks), then waits
|
||||
// where it lands for the server to place it and remove it.
|
||||
let mut falling = std::mem::take(&mut self.falling);
|
||||
for f in &mut falling {
|
||||
if let Ok(FallStep::Landed(_)) = self.fall_step(f, false) {
|
||||
// Block.updateEntityMovementAfterFallOn, then the air drag.
|
||||
f.velocity = Vec3::new(f.velocity.x * 0.98, 0.0, f.velocity.z * 0.98);
|
||||
}
|
||||
}
|
||||
self.falling = falling;
|
||||
for p in &mut self.creatures.projectiles {
|
||||
p.previous = p.position;
|
||||
p.age += 1;
|
||||
@@ -205,6 +222,7 @@ impl Game<'_> {
|
||||
c.position = step(c.id, c.position);
|
||||
}
|
||||
self.potions.step_remote_thrown(&mut step);
|
||||
self.step_remote_hooks(&mut step);
|
||||
self.end.step_remote_eyes(|id, at| {
|
||||
let at = step(id, Vec3::new(at[0], at[1], at[2]));
|
||||
[at.x, at.y, at.z]
|
||||
@@ -220,6 +238,7 @@ impl Game<'_> {
|
||||
self.crystals.retain(|c| c.id != id);
|
||||
self.potions.remove_remote_thrown(id);
|
||||
self.end.remove_remote_eye(id);
|
||||
self.remove_remote_hook(id);
|
||||
}
|
||||
|
||||
/// Every one a server sent (a new connection, level or dimension).
|
||||
@@ -232,6 +251,7 @@ impl Game<'_> {
|
||||
self.crystals.clear();
|
||||
self.potions.clear_thrown();
|
||||
self.end.clear_remote_eyes();
|
||||
self.clear_remote_hooks();
|
||||
}
|
||||
|
||||
/// Their kind and place, for `status`.
|
||||
@@ -260,6 +280,7 @@ impl Game<'_> {
|
||||
for (id, p) in self.end.eye_positions() {
|
||||
out.push(json!({"id": id, "kind": "minecraft:eye_of_ender", "position": p}));
|
||||
}
|
||||
out.extend(self.hooks_status());
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@
|
||||
//! server (`LocalPlayer.tick`: `ServerboundMoveVehiclePacket`,
|
||||
//! `AbstractBoat.tick`: `ServerboundPaddleBoatPacket`). A minecart moves on
|
||||
//! the server with the rider's input, and the rider sits in it.
|
||||
use super::{Game, Keys, view_vector};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use super::{Game, Keys, ray_box, view_vector};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
|
||||
/// An object under the crosshair: its entity id, how far along the view it
|
||||
@@ -24,8 +24,8 @@ pub(super) struct ObjectHit {
|
||||
|
||||
impl Game<'_> {
|
||||
/// The nearest boat, minecart, item frame, armor stand or painting the
|
||||
/// view hits within reach.
|
||||
pub(super) fn remote_object_hit(&self) -> Option<ObjectHit> {
|
||||
/// view hits within reach; also End crystals when attacking.
|
||||
pub(super) fn remote_object_hit(&self, attacking: bool) -> Option<ObjectHit> {
|
||||
let eye = self.eye_position()?;
|
||||
let dir = view_vector(self.yaw, self.pitch);
|
||||
let at = |d: f64| {
|
||||
@@ -64,6 +64,22 @@ impl Game<'_> {
|
||||
let p = self.carts[i].position;
|
||||
hits.push((self.carts[i].id as i32, d, [p.x, p.y, p.z]));
|
||||
}
|
||||
if attacking {
|
||||
// EndCrystal.isPickable: its 2 x 2 box, with the server's entity
|
||||
// id, not special_targets' local projectile target id.
|
||||
// Entity.interact passes on a crystal, so right clicks use the item.
|
||||
let reach = if self.creative { 5.0 } else { 3.0 };
|
||||
for c in self.crystals.iter().filter(|c| c.id != 0) {
|
||||
let p = c.position;
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(p.x - 1.0, p.y, p.z - 1.0),
|
||||
Vec3::new(p.x + 1.0, p.y + 2.0, p.z + 1.0),
|
||||
);
|
||||
if let Some(d) = ray_box(eye, dir, reach, bounds) {
|
||||
hits.push((c.id as i32, d, [p.x, p.y, p.z]));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (id, distance, origin) = hits.into_iter().min_by(|a, b| a.1.total_cmp(&b.1))?;
|
||||
let point = at(distance);
|
||||
Some(ObjectHit {
|
||||
@@ -88,8 +104,10 @@ impl Game<'_> {
|
||||
for m in &mut self.mobs {
|
||||
if m.extra.mount == Some(vehicle_id) && !passengers.contains(&(m.id as i32)) {
|
||||
m.extra.mount = None;
|
||||
m.extra.passenger = false;
|
||||
} else if passengers.contains(&(m.id as i32)) {
|
||||
m.extra.mount = Some(vehicle_id);
|
||||
m.extra.passenger = true;
|
||||
}
|
||||
}
|
||||
if passengers.contains(&me) {
|
||||
@@ -129,17 +147,20 @@ impl Game<'_> {
|
||||
self.yaw += b.delta_rotation;
|
||||
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
self.yaw += delta.clamp(-105.0, 105.0) - delta;
|
||||
let seat = b.position;
|
||||
self.boats[i] = b;
|
||||
Some(seat)
|
||||
// In front of a mob that shares the boat.
|
||||
Some(self.player_boat_seat(i))
|
||||
} else {
|
||||
// A minecart: the server moves it, the rider sits in it.
|
||||
self.carts.iter().find(|c| c.id == id).map(|c| c.position)
|
||||
self.carts.iter().find(|c| c.id == id).map(|c| {
|
||||
c.position
|
||||
.add_vector(Vec3::new(0.0, super::minecarts::SEAT, 0.0))
|
||||
})
|
||||
};
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
if let Some(v) = seat {
|
||||
p.position = Vec3::new(v.x, v.y + super::boats::SEAT, v.z);
|
||||
p.position = v;
|
||||
}
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
|
||||
@@ -13,6 +13,7 @@ pub const HELP: &[&str] = &[
|
||||
"signedit X Y Z [front|back] open the sign editor for the sign at X Y Z",
|
||||
"section-draws [flat|indirect] how terrain sections are drawn (A/B checks); replies the mode",
|
||||
"section-cull [on|off] occlusion culling of solid sections (A/B checks); replies the mode",
|
||||
"section-sides [on|off] leave out solid quads facing away from the eye (A/B checks); replies the mode",
|
||||
];
|
||||
|
||||
impl Game<'_> {
|
||||
|
||||
@@ -100,49 +100,13 @@ impl Game<'_> {
|
||||
skybox: self.dim == Dim::Overworld,
|
||||
blindness: None,
|
||||
darkness: self.darkness_fog(),
|
||||
boss_world_fog: self.dragon_bar_shown(),
|
||||
darken_world: self
|
||||
.boss_overlay
|
||||
.darken_amount(self.clock.fraction() as f32),
|
||||
boss_world_fog: self.boss_overlay.should_create_world_fog(),
|
||||
},
|
||||
)
|
||||
}
|
||||
/// What a shader pack's `Frame` uniform reads of the game this frame.
|
||||
pub(super) fn shader_inputs(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
partial_tick: f32,
|
||||
) -> horizon_client::gfx::ShaderInputs {
|
||||
let (rain, thunder) = if self.dim == Dim::Overworld {
|
||||
self.world.weather.levels(self.clock.fraction() as f32)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
};
|
||||
let at = [camera.x, camera.y, camera.z];
|
||||
let day = self.world.day_time;
|
||||
let has_sky_light = self.dim == Dim::Overworld;
|
||||
// CommonUniforms.getEyeBrightness: the camera entity's eye block.
|
||||
let eye = self.eye_position().unwrap_or(at).map(|v| v.floor() as i32);
|
||||
let light = self.world.light(eye[0], eye[1], eye[2]);
|
||||
let eye_light = [light & 15, if has_sky_light { light >> 4 } else { 0 }];
|
||||
horizon_client::gfx::ShaderInputs {
|
||||
rain,
|
||||
thunder,
|
||||
submerged: match fog_type(&self.world, self.res.facts(), at, self.forward()) {
|
||||
FogType::Atmospheric => 0,
|
||||
FogType::Water => 1,
|
||||
FogType::Lava => 2,
|
||||
FogType::PowderSnow => 3,
|
||||
},
|
||||
dimension: match self.dim {
|
||||
Dim::Overworld => 0,
|
||||
Dim::Nether => -1,
|
||||
Dim::End => 1,
|
||||
},
|
||||
has_sky_light,
|
||||
moon_phase: day.div_euclid(24_000).rem_euclid(8) as u8,
|
||||
day_fraction: (day.rem_euclid(24_000) as f32 + partial_tick) / 24_000.0,
|
||||
partial_tick,
|
||||
eye_light,
|
||||
}
|
||||
}
|
||||
/// `SkyRenderer.shouldRenderDarkDisc`: eye below the horizon (y 63) and not under water.
|
||||
pub(super) fn dark_disc(&self, camera: glam::DVec3) -> bool {
|
||||
camera.y < 63.0
|
||||
|
||||
@@ -180,7 +180,7 @@ impl Game<'_> {
|
||||
| Target::Cart(id)
|
||||
| Target::Other(id) => self.persist.raw.get(&id),
|
||||
Target::Player => self.level.player_extra.as_ref(),
|
||||
Target::Guest(_) | Target::Orb(_) | Target::Arrow(_) => None,
|
||||
Target::Guest(_) | Target::Orb(_) | Target::Projectile(_) => None,
|
||||
};
|
||||
match raw.and_then(|r| r.get("Tags")) {
|
||||
Some(Tag::List(l)) => l
|
||||
|
||||
@@ -1054,6 +1054,7 @@ impl Game<'_> {
|
||||
let r = &mut self.world.random;
|
||||
let bonus = r.triangle(0.0, 0.11485000000000001);
|
||||
mob.attrs.follow_range += mob.kind.follow_range * bonus;
|
||||
mob.update_max_visited_nodes();
|
||||
let _left_handed = r.next_float() < 0.05;
|
||||
if !self.warden_fits(&mob) {
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
//! A server's resource packs bound to the game (`ClientCommonPacketListenerImpl.
|
||||
//! handleResourcePackPush` and `handleResourcePackPop`, its `PackConfirmScreen`,
|
||||
//! and `DownloadedPackSource` as `Minecraft` drives it): the prompt, the
|
||||
//! server list's setting, downloads into `downloads/`, and a resource reload
|
||||
//! with the server's packs on top of the player's own.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
confirm_screen::Confirm,
|
||||
hud::{HudAtlas, HudBatch},
|
||||
java_text, multiplayer, packs,
|
||||
scoreboard::{comp_text, tag_to_json},
|
||||
server_packs::{Prompt, ServerPacks, action, download, parse_sha1, valid_url},
|
||||
text::{Text, named_color},
|
||||
toasts::SystemToast,
|
||||
ui,
|
||||
};
|
||||
use horizon_core::jmath;
|
||||
use horizon_protocol::{packets::configuration::ResourcePackPush, servers};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::mpsc::{Receiver, TryRecvError},
|
||||
};
|
||||
|
||||
/// `ClientCommonPacketListenerImpl.PackConfirmScreen`: the packs it asks
|
||||
/// about, whether the last one is required, and the server's message.
|
||||
pub(super) struct PackPrompt {
|
||||
requests: Vec<(u128, String, String)>,
|
||||
required: bool,
|
||||
prompt: Option<Text>,
|
||||
}
|
||||
impl PackPrompt {
|
||||
fn texts(&self) -> (Text, Text, [Text; 2]) {
|
||||
let t = |key: &str| Text::translate(key, &[]);
|
||||
let (title, line2) = if self.required {
|
||||
let yellow = named_color("yellow").unwrap_or(0xFFFF55);
|
||||
(
|
||||
t("multiplayer.requiredTexturePrompt.line1"),
|
||||
t("multiplayer.requiredTexturePrompt.line2")
|
||||
.with_color(yellow)
|
||||
.bold(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
t("multiplayer.texturePrompt.line1"),
|
||||
t("multiplayer.texturePrompt.line2"),
|
||||
)
|
||||
};
|
||||
// preparePackPrompt.
|
||||
let message = match &self.prompt {
|
||||
Some(prompt) => Text::translate(
|
||||
"multiplayer.texturePrompt.serverPrompt",
|
||||
&[line2, prompt.clone()],
|
||||
),
|
||||
None => line2,
|
||||
};
|
||||
let buttons = if self.required {
|
||||
[t("gui.proceed"), t("menu.disconnect")]
|
||||
} else {
|
||||
[t("gui.yes"), t("gui.no")]
|
||||
};
|
||||
(title, message, buttons)
|
||||
}
|
||||
fn layout(&self, atlas: &HudAtlas, size: (f32, f32)) -> Confirm {
|
||||
let (title, message, _) = self.texts();
|
||||
Confirm::new(&atlas.font, size, &title, &message)
|
||||
}
|
||||
}
|
||||
|
||||
/// The game's `DownloadedPackSource`.
|
||||
#[derive(Default)]
|
||||
pub(super) struct ServerPackState {
|
||||
packs: ServerPacks,
|
||||
/// `downloads/` in the game folder, opened at the first download.
|
||||
queue: Option<download::Queue>,
|
||||
pub(super) prompt: Option<PackPrompt>,
|
||||
/// The server list entry the setting is saved to (none: a direct join).
|
||||
address: Option<String>,
|
||||
/// The account the downloads are made for (the download headers).
|
||||
user: (String, u128),
|
||||
/// The packs loaded on top of the player's own, bottom first.
|
||||
pub(super) active: Vec<packs::Pack>,
|
||||
/// The download batches' notifiers, oldest first.
|
||||
notifiers: Vec<PackNotifier>,
|
||||
}
|
||||
/// `DownloadedPackSource.createDownloadNotifier`: one batch's progress in its
|
||||
/// own `SystemToast`.
|
||||
struct PackNotifier {
|
||||
progress: Receiver<download::Progress>,
|
||||
total: usize,
|
||||
/// `count`: the requests started.
|
||||
count: usize,
|
||||
/// `failCount`.
|
||||
failed: usize,
|
||||
/// `totalBytes`: the Content-Length of the request in flight.
|
||||
size: Option<u64>,
|
||||
/// The bytes it has written so far: none before the first response.
|
||||
bytes: Option<u64>,
|
||||
/// The last request has finished.
|
||||
done: bool,
|
||||
toast: Option<SystemToast>,
|
||||
}
|
||||
impl PackNotifier {
|
||||
fn new(progress: Receiver<download::Progress>, total: usize) -> Self {
|
||||
Self {
|
||||
progress,
|
||||
total,
|
||||
count: 0,
|
||||
failed: 0,
|
||||
size: None,
|
||||
bytes: None,
|
||||
done: false,
|
||||
toast: None,
|
||||
}
|
||||
}
|
||||
/// The batch's events since the last call, with the toast updated once
|
||||
/// for all of them. False once the batch has ended and its toast is gone.
|
||||
fn poll(&mut self) -> bool {
|
||||
let (mut changed, mut hide) = (false, false);
|
||||
let ended = loop {
|
||||
let event = match self.progress.try_recv() {
|
||||
Ok(event) => event,
|
||||
Err(e) => break e == TryRecvError::Disconnected,
|
||||
};
|
||||
changed |= match event {
|
||||
download::Progress::RequestStart => {
|
||||
self.count += 1;
|
||||
true
|
||||
}
|
||||
download::Progress::DownloadStart(size) => {
|
||||
self.size = size;
|
||||
self.bytes = Some(0);
|
||||
true
|
||||
}
|
||||
download::Progress::Bytes(bytes) => {
|
||||
self.bytes = Some(bytes);
|
||||
true
|
||||
}
|
||||
download::Progress::RequestFinished(ok) => {
|
||||
self.failed += usize::from(!ok);
|
||||
self.done = self.count == self.total;
|
||||
// Every pack came: forceHide; else the failure title.
|
||||
hide |= self.done && self.failed == 0;
|
||||
self.done && self.failed > 0
|
||||
}
|
||||
};
|
||||
};
|
||||
if changed {
|
||||
let (title, message) = self.text();
|
||||
let (title, message) = (title.plain(), message.map(|m| m.plain()));
|
||||
// SystemToast.addOrUpdate.
|
||||
match &mut self.toast {
|
||||
Some(toast) => toast.reset(title, message),
|
||||
None => self.toast = Some(SystemToast::new(title, message)),
|
||||
}
|
||||
}
|
||||
if hide && let Some(toast) = &mut self.toast {
|
||||
toast.force_hide();
|
||||
}
|
||||
!ended || self.toast.is_some()
|
||||
}
|
||||
/// The toast's title and message.
|
||||
fn text(&self) -> (Text, Option<Text>) {
|
||||
if self.done && self.failed > 0 {
|
||||
let failed = Text::tr("download.pack.failed", &[&self.failed, &self.total]);
|
||||
return (failed, None);
|
||||
}
|
||||
let title = Text::tr("download.pack.title", &[&self.count, &self.total]);
|
||||
let message = self.bytes.map(|bytes| match self.size {
|
||||
Some(size) if size > 0 => {
|
||||
Text::tr("download.pack.progress.percent", &[&(bytes * 100 / size)])
|
||||
}
|
||||
_ => Text::tr("download.pack.progress.bytes", &[&human_readable(bytes)]),
|
||||
});
|
||||
(title, message)
|
||||
}
|
||||
}
|
||||
/// `com.mojang.realmsclient.Unit.humanReadable`, for
|
||||
/// `download.pack.progress.bytes`.
|
||||
fn human_readable(bytes: u64) -> String {
|
||||
if bytes < 1024 {
|
||||
return format!("{bytes} B");
|
||||
}
|
||||
let exp = (jmath::log(bytes as f64) / jmath::log(1024.0)) as i32;
|
||||
let prefix = &"KMGTPE"[exp as usize - 1..exp as usize];
|
||||
let value = bytes as f64 / 1024f64.powi(exp);
|
||||
format!("{} {prefix}B", java_text::format_fixed(value, 1))
|
||||
}
|
||||
|
||||
fn saves() -> PathBuf {
|
||||
std::env::current_dir().unwrap_or_default().join("saves")
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ConnectScreen`: the server's setting decides what its packs do
|
||||
/// (`configureForServerControl`); single player's own server takes them
|
||||
/// all, quietly (`configureForLocalWorld`).
|
||||
pub(super) fn server_packs_connect(&mut self, address: Option<&str>, user: (String, u128)) {
|
||||
let state = &mut self.server_packs;
|
||||
state.user = user;
|
||||
state.prompt = None;
|
||||
state.notifiers.clear();
|
||||
let Some(address) = address else {
|
||||
state.address = None;
|
||||
state.packs.local_world();
|
||||
return;
|
||||
};
|
||||
let file = multiplayer::servers_file(&saves());
|
||||
let entry = servers::load(&file).into_iter().find(|s| s.ip == address);
|
||||
let setting = entry.as_ref().and_then(|e| e.accept_textures);
|
||||
state.address = entry.map(|e| e.ip);
|
||||
state.packs.connect(Prompt::for_server(setting));
|
||||
}
|
||||
/// `Minecraft.clearDownloadedResourcePacks`: every server pack goes at once.
|
||||
pub(super) fn server_packs_disconnect(&mut self) {
|
||||
self.server_packs.prompt = None;
|
||||
self.server_packs.notifiers.clear();
|
||||
self.server_packs.packs.disconnect();
|
||||
self.tick_server_packs();
|
||||
}
|
||||
|
||||
/// `handleResourcePackPush`.
|
||||
pub(super) fn handle_pack_push(&mut self, p: ResourcePackPush) {
|
||||
if !valid_url(&p.url) {
|
||||
self.send_pack_status(p.id, action::INVALID_URL);
|
||||
return;
|
||||
}
|
||||
let setting = self.server_setting();
|
||||
let prompt = Prompt::for_server(setting);
|
||||
if prompt != Prompt::Pending && (!p.required || prompt != Prompt::Declined) {
|
||||
self.server_packs
|
||||
.packs
|
||||
.push(p.id, &p.url, parse_sha1(&p.hash));
|
||||
return;
|
||||
}
|
||||
// addOrUpdatePackPrompt: one prompt for every pack pushed before it is answered.
|
||||
let text = p.prompt.as_ref().map(|t| comp_text(&tag_to_json(t)));
|
||||
let state = &mut self.server_packs;
|
||||
let open = state.prompt.get_or_insert(PackPrompt {
|
||||
requests: Vec::new(),
|
||||
required: p.required,
|
||||
prompt: None,
|
||||
});
|
||||
open.requests.push((p.id, p.url, p.hash));
|
||||
open.required = p.required;
|
||||
open.prompt = text;
|
||||
self.capture(false);
|
||||
}
|
||||
/// `handleResourcePackPop`: one pack, or all of them.
|
||||
pub(super) fn handle_pack_pop(&mut self, id: Option<u128>) {
|
||||
match id {
|
||||
Some(id) => self.server_packs.packs.pop(id),
|
||||
None => self.server_packs.packs.pop_all(),
|
||||
}
|
||||
}
|
||||
/// The joined server's entry in the server list: its `acceptTextures`.
|
||||
fn server_setting(&self) -> Option<bool> {
|
||||
let address = self.server_packs.address.as_deref()?;
|
||||
servers::load(&multiplayer::servers_file(&saves()))
|
||||
.into_iter()
|
||||
.find(|s| s.ip == address)?
|
||||
.accept_textures
|
||||
}
|
||||
fn send_pack_status(&self, id: u128, status: i32) {
|
||||
if let Some(r) = &self.remote {
|
||||
r.session.send_pack_status(id, status);
|
||||
}
|
||||
}
|
||||
|
||||
/// The prompt's answer (`PackConfirmScreen`'s callback): the server's
|
||||
/// setting follows it, declining a required pack disconnects, and every
|
||||
/// pack it asked about is pushed.
|
||||
pub(super) fn answer_pack_prompt(&mut self, yes: bool) -> Result<()> {
|
||||
let Some(prompt) = self.server_packs.prompt.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
if self.player.is_some() && self.screen == super::Screen::Playing {
|
||||
self.capture(true);
|
||||
}
|
||||
let setting = if yes {
|
||||
self.server_packs.packs.allow();
|
||||
Some(true)
|
||||
} else {
|
||||
self.server_packs.packs.reject();
|
||||
if prompt.required {
|
||||
let reason = Text::translate("multiplayer.requiredTexturePrompt.disconnect", &[]);
|
||||
return self.leave_server(Some(reason.plain()));
|
||||
}
|
||||
Some(false)
|
||||
};
|
||||
for (id, url, hash) in prompt.requests {
|
||||
self.server_packs.packs.push(id, &url, parse_sha1(&hash));
|
||||
}
|
||||
// ServerList.saveSingleServer.
|
||||
if let Some(address) = &self.server_packs.address {
|
||||
let file = multiplayer::servers_file(&saves());
|
||||
let mut list = servers::load(&file);
|
||||
for entry in list.iter_mut().filter(|s| &s.ip == address) {
|
||||
entry.accept_textures = setting;
|
||||
}
|
||||
if let Err(e) = servers::save(&file, &list) {
|
||||
log::warn!("Horizon: could not save {}: {e}", file.display());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs what the packs need (`createUpdateScheduler`): the downloads
|
||||
/// that finished, the ones to start, a resource reload when the active
|
||||
/// packs change, and the statuses for the server.
|
||||
pub(super) fn tick_server_packs(&mut self) {
|
||||
self.server_packs.notifiers.retain_mut(PackNotifier::poll);
|
||||
// A reload waits for the world's resources (`reload_packs`).
|
||||
if self.rest_loader.is_some() || self.world_loader.is_some() {
|
||||
return;
|
||||
}
|
||||
let finished = self
|
||||
.server_packs
|
||||
.queue
|
||||
.as_ref()
|
||||
.map(download::Queue::finished)
|
||||
.unwrap_or_default();
|
||||
for batch in finished {
|
||||
self.server_packs.packs.downloaded(batch);
|
||||
}
|
||||
while self.server_packs.packs.wants_tick() {
|
||||
let tick = self.server_packs.packs.tick();
|
||||
if !tick.download.is_empty() {
|
||||
self.start_pack_downloads(tick.download);
|
||||
}
|
||||
if let Some(mut load) = tick.reload {
|
||||
while let Some(recovery) = {
|
||||
let ok = self.reload_server_packs(&load);
|
||||
self.server_packs.packs.reloaded(ok)
|
||||
} {
|
||||
load = recovery;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, status) in self.server_packs.packs.take_replies() {
|
||||
self.send_pack_status(id, status);
|
||||
}
|
||||
}
|
||||
fn start_pack_downloads(&mut self, requests: Vec<horizon_client::server_packs::Request>) {
|
||||
let state = &mut self.server_packs;
|
||||
if state.queue.is_none() {
|
||||
let dir = std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join("downloads");
|
||||
match download::Queue::open(dir) {
|
||||
Ok(queue) => state.queue = Some(queue),
|
||||
Err(e) => {
|
||||
log::error!("Horizon: failed to open the download queue: {e}");
|
||||
// Every request failed, as the notifier would hear it.
|
||||
let (progress, events) = std::sync::mpsc::channel();
|
||||
for _ in &requests {
|
||||
let _ = progress.send(download::Progress::RequestStart);
|
||||
let _ = progress.send(download::Progress::RequestFinished(false));
|
||||
}
|
||||
state
|
||||
.notifiers
|
||||
.push(PackNotifier::new(events, requests.len()));
|
||||
let failed = requests.iter().map(|r| r.id).collect();
|
||||
state.packs.downloaded(horizon_client::server_packs::Batch {
|
||||
downloaded: Vec::new(),
|
||||
failed,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let headers = download::headers(&state.user.0, state.user.1);
|
||||
let (progress, events) = std::sync::mpsc::channel();
|
||||
state
|
||||
.notifiers
|
||||
.push(PackNotifier::new(events, requests.len()));
|
||||
if let Some(queue) = &state.queue {
|
||||
queue.download(requests, headers, progress);
|
||||
}
|
||||
}
|
||||
/// `DownloadedPackSource.startReload`: the packs on top of the player's
|
||||
/// own, then `Minecraft.reloadResourcePacks`. False when a pack has no
|
||||
/// readable `pack.mcmeta` or the reload failed.
|
||||
fn reload_server_packs(&mut self, load: &[(u128, PathBuf)]) -> bool {
|
||||
let packs: Option<Vec<_>> = load
|
||||
.iter()
|
||||
.map(|(id, path)| packs::server_pack(*id, path))
|
||||
.collect();
|
||||
let Some(packs) = packs else {
|
||||
log::warn!("Horizon: invalid pack metadata in a server pack, ignoring all");
|
||||
return false;
|
||||
};
|
||||
self.server_packs.active = packs;
|
||||
match self.reload_selected_packs() {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: server resource pack reload failed: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn draw_pack_prompt(&self, batch: &mut HudBatch) {
|
||||
let Some(prompt) = &self.server_packs.prompt else {
|
||||
return;
|
||||
};
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
if self.player.is_none() {
|
||||
// In place of the connecting screen, on its background.
|
||||
batch.fill(
|
||||
atlas,
|
||||
0.0,
|
||||
0.0,
|
||||
w,
|
||||
h,
|
||||
horizon_client::hud::rgba(20, 20, 20, 255),
|
||||
);
|
||||
}
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.player.is_some());
|
||||
let (_, _, [yes, no]) = prompt.texts();
|
||||
prompt
|
||||
.layout(atlas, (w, h))
|
||||
.draw(batch, atlas, self.cursor, [&yes, &no]);
|
||||
}
|
||||
/// The download notifiers' toasts, each in the next 32-pixel slot
|
||||
/// below an advancement or recipe toast (`ToastManager`).
|
||||
pub(super) fn draw_pack_toasts(&mut self, batch: &mut HudBatch) {
|
||||
let multiplier = f64::from(self.settings.notification_display_time);
|
||||
let mut slot = usize::from(!self.adv.toasts.is_empty());
|
||||
for notifier in &mut self.server_packs.notifiers {
|
||||
let Some(toast) = &mut notifier.toast else {
|
||||
continue;
|
||||
};
|
||||
let (sound, finished) = toast.update(multiplier);
|
||||
if let Some(show) = sound {
|
||||
let event = if show { "ui.toast.in" } else { "ui.toast.out" };
|
||||
self.audio
|
||||
.play(&format!("minecraft:{event}"), None, 1.0, 1.0);
|
||||
}
|
||||
if finished {
|
||||
notifier.toast = None;
|
||||
continue;
|
||||
}
|
||||
toast.draw(&self.gui.atlas, batch, 32.0 * slot as f32);
|
||||
slot += 1;
|
||||
}
|
||||
}
|
||||
/// A left click on the prompt: its answer when it hit a button.
|
||||
pub(super) fn pack_prompt_click(&mut self) -> Result<()> {
|
||||
let Some(prompt) = &self.server_packs.prompt else {
|
||||
return Ok(());
|
||||
};
|
||||
let answer = prompt
|
||||
.layout(&self.gui.atlas, self.gui_size())
|
||||
.answer_at(self.cursor);
|
||||
match answer {
|
||||
Some(yes) => {
|
||||
self.audio.play_button_click();
|
||||
self.answer_pack_prompt(yes)
|
||||
}
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use download::Progress::*;
|
||||
|
||||
fn notifier(total: usize, events: &[download::Progress]) -> PackNotifier {
|
||||
let (send, receive) = std::sync::mpsc::channel();
|
||||
for event in events {
|
||||
send.send(*event).unwrap();
|
||||
}
|
||||
let mut notifier = PackNotifier::new(receive, total);
|
||||
assert!(notifier.poll());
|
||||
notifier
|
||||
}
|
||||
fn texts(n: &PackNotifier) -> (serde_json::Value, Option<serde_json::Value>) {
|
||||
let (title, message) = n.text();
|
||||
(title.component(), message.map(|m| m.component()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_toast_counts_requests_and_shows_progress() {
|
||||
let n = notifier(2, &[RequestStart]);
|
||||
let (title, message) = texts(&n);
|
||||
assert_eq!(title["translate"], "download.pack.title");
|
||||
assert_eq!(title["with"], serde_json::json!([1, 2]));
|
||||
assert_eq!(message, None);
|
||||
let n = notifier(2, &[RequestStart, DownloadStart(Some(400)), Bytes(100)]);
|
||||
assert_eq!(
|
||||
texts(&n).1.unwrap(),
|
||||
serde_json::json!({"translate": "download.pack.progress.percent", "with": [25]})
|
||||
);
|
||||
let n = notifier(1, &[RequestStart, DownloadStart(None), Bytes(1280)]);
|
||||
assert_eq!(
|
||||
texts(&n).1.unwrap()["with"],
|
||||
serde_json::json!([{"text": "1.3 KB"}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_pack_turns_the_toast_into_the_failure_count() {
|
||||
let events = [
|
||||
RequestStart,
|
||||
RequestFinished(false),
|
||||
RequestStart,
|
||||
DownloadStart(Some(3)),
|
||||
Bytes(3),
|
||||
];
|
||||
let (send, receive) = std::sync::mpsc::channel();
|
||||
for event in events {
|
||||
send.send(event).unwrap();
|
||||
}
|
||||
let mut n = PackNotifier::new(receive, 2);
|
||||
assert!(n.poll());
|
||||
// Not the last request yet: still downloading.
|
||||
assert_eq!(texts(&n).0["translate"], "download.pack.title");
|
||||
send.send(RequestFinished(true)).unwrap();
|
||||
drop(send);
|
||||
assert!(n.poll(), "the failure toast stays until it slides out");
|
||||
let (title, message) = texts(&n);
|
||||
assert_eq!(
|
||||
title,
|
||||
serde_json::json!({"translate": "download.pack.failed", "with": [1, 2]})
|
||||
);
|
||||
assert_eq!(message, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_batch_without_a_toast_is_dropped() {
|
||||
let (send, receive) = std::sync::mpsc::channel::<download::Progress>();
|
||||
drop(send);
|
||||
assert!(!PackNotifier::new(receive, 1).poll());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizes_read_as_realms_unit_human_readable() {
|
||||
// com.mojang.realmsclient.Unit.humanReadable, run on the 26.3 jar.
|
||||
for (bytes, text) in [
|
||||
(0, "0 B"),
|
||||
(1023, "1023 B"),
|
||||
(1024, "1.0 KB"),
|
||||
(1280, "1.3 KB"),
|
||||
(1535, "1.5 KB"),
|
||||
(10_000, "9.8 KB"),
|
||||
(1_048_575, "1024.0 KB"),
|
||||
(1_048_576, "1.0 MB"),
|
||||
(52_428_800, "50.0 MB"),
|
||||
(262_144_000, "250.0 MB"),
|
||||
(1_073_741_824, "1.0 GB"),
|
||||
] {
|
||||
assert_eq!(human_readable(bytes), text, "{bytes}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,128 +1,84 @@
|
||||
//! The selected Horizon shader pack (`shaderPack` in `horizon-graphics.txt`,
|
||||
//! docs/shader-packs.md): loaded from `shaderpacks/`, given to the renderer,
|
||||
//! and loaded again when the selection changes, on F3 + T, or when its files
|
||||
//! change (checked twice a second). A pack that fails leaves the last good
|
||||
//! one (or the vanilla picture) on and says why in chat.
|
||||
use super::Game;
|
||||
use horizon_client::shader_pack::{self, ShaderPack, Stamp};
|
||||
use horizon_client::text::Text;
|
||||
use horizon_shaderpack::properties::SkyDirectives;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
//! Shader packs, for development until the Shader Packs page exists: `HORIZON_SHADER_PACK=<name>`
|
||||
//! runs `shaderpacks/<name>` (a folder or a zip beside `options.txt`) over the world. The
|
||||
//! programs of the current dimension's folder are loaded when the dimension changes (spec 1.2).
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ShaderPacks {
|
||||
/// The pack the renderer runs.
|
||||
pack: Option<ShaderPack>,
|
||||
/// The selection and its files at the last load, success or not.
|
||||
selected: String,
|
||||
stamp: Stamp,
|
||||
checked: Option<Instant>,
|
||||
}
|
||||
use horizon_client::camera::CameraFluid;
|
||||
use horizon_client::gfx::pack_uniforms::PackInputs;
|
||||
use horizon_shaders::{Macros, Pack, Properties, dimension_folder, load_post};
|
||||
|
||||
impl ShaderPacks {
|
||||
/// The loaded pack's folder name.
|
||||
pub(super) fn id(&self) -> Option<&str> {
|
||||
self.pack.as_ref().map(|p| p.id.as_str())
|
||||
}
|
||||
/// What a loaded Java pack changes in the vanilla sky of `dimension`
|
||||
/// (0 overworld, -1 the Nether, 1 the End): its `sunPathRotation`, and
|
||||
/// whether it keeps the clouds, sun and moon (Iris's sky mixins).
|
||||
pub(super) fn java_sky(&self, dimension: i8) -> Option<(f32, SkyDirectives)> {
|
||||
let java = self.pack.as_ref()?.java.as_ref()?;
|
||||
let folder = &java.folders[java.folder_for(dimension)?];
|
||||
Some((folder.directives.sun_path_rotation, java.sky))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn dir() -> PathBuf {
|
||||
shader_pack::folder()
|
||||
}
|
||||
use super::{Dim, Game};
|
||||
|
||||
impl Game<'_> {
|
||||
/// Load the selected pack and give it to the renderer.
|
||||
pub(super) fn load_shader_pack(&mut self) {
|
||||
let id = self.settings.horizon.shader_pack.clone();
|
||||
let dir = dir();
|
||||
let state = &mut self.shader_packs;
|
||||
state.selected = id.clone();
|
||||
state.checked = Some(Instant::now());
|
||||
let pack = if id.is_empty() {
|
||||
state.stamp = Stamp::default();
|
||||
None
|
||||
} else {
|
||||
state.stamp = Stamp::of(&dir.join(&id));
|
||||
// Java packs are told which GPU they run on.
|
||||
let (vendor, renderer) = self.renderer.as_ref().map_or(("Unknown", ""), |r| {
|
||||
let name = r.info.name.as_str();
|
||||
(
|
||||
horizon_client::java_pack::vendor_name(r.info.vendor, name),
|
||||
name,
|
||||
)
|
||||
});
|
||||
let values = shader_pack::load_values(&dir, &id);
|
||||
match ShaderPack::load_any(&dir.join(&id), &values, vendor, renderer) {
|
||||
Ok(pack) => Some(pack),
|
||||
Err(e) => return self.shader_pack_error(&id, &e),
|
||||
}
|
||||
/// Loads the pack for the current dimension when it changed, and returns this frame's
|
||||
/// inputs to the pack's uniforms.
|
||||
pub(super) fn shader_pack_inputs(&mut self, camera: glam::DVec3, partial: f32) -> PackInputs {
|
||||
let Some(name) = std::env::var_os("HORIZON_SHADER_PACK") else {
|
||||
return PackInputs::default();
|
||||
};
|
||||
if let Some(r) = self.renderer.as_mut()
|
||||
&& let Err(e) = r.set_shader_pack(pack.as_ref())
|
||||
let dimension = match self.dim {
|
||||
Dim::Overworld => 0,
|
||||
Dim::Nether => -1,
|
||||
Dim::End => 1,
|
||||
};
|
||||
if let Some(renderer) = &mut self.renderer
|
||||
&& renderer.shader_pack_dimension() != Some(dimension)
|
||||
{
|
||||
return self.shader_pack_error(&id, &e.to_string());
|
||||
}
|
||||
self.shader_packs.pack = pack;
|
||||
self.ui.options.shader_pack_error = None;
|
||||
}
|
||||
/// Give the loaded pack to the renderer (also after it is rebuilt).
|
||||
pub(super) fn apply_shader_pack(&mut self) -> horizon_client::Result<()> {
|
||||
match self.renderer.as_mut() {
|
||||
Some(r) => r.set_shader_pack(self.shader_packs.pack.as_ref()),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
/// Every frame: a new selection or edited pack files load it again.
|
||||
pub(super) fn poll_shader_pack(&mut self) {
|
||||
let state = &mut self.shader_packs;
|
||||
if self.settings.horizon.shader_pack != state.selected {
|
||||
return self.load_shader_pack();
|
||||
}
|
||||
if state.selected.is_empty()
|
||||
|| state
|
||||
.checked
|
||||
.is_some_and(|t| t.elapsed() < Duration::from_millis(500))
|
||||
{
|
||||
return;
|
||||
}
|
||||
state.checked = Some(Instant::now());
|
||||
if Stamp::of(&dir().join(&state.selected)) != state.stamp {
|
||||
self.load_shader_pack();
|
||||
}
|
||||
}
|
||||
/// A file dropped on the window: a shader pack while the Shader Packs
|
||||
/// page shows (`OptionsUi::drop_shader_pack`).
|
||||
pub(super) fn drop_shader_pack(&mut self, path: &std::path::Path) {
|
||||
match self.ui.options.drop_shader_pack(&mut self.settings, path) {
|
||||
Some(Ok(())) => {
|
||||
self.settings.save(&Self::options_path());
|
||||
self.load_shader_pack();
|
||||
let name = name.to_string_lossy();
|
||||
let started = std::time::Instant::now();
|
||||
let path = std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join("shaderpacks")
|
||||
.join(&*name);
|
||||
match Pack::open(&path) {
|
||||
Ok(pack) => {
|
||||
let macros = Macros::standard();
|
||||
let text = pack.read_text("shaders.properties").unwrap_or_default();
|
||||
let properties = Properties::parse(&text, ¯os);
|
||||
for note in &properties.notes {
|
||||
log::warn!(
|
||||
"shader pack {name}: shaders.properties:{}: {}",
|
||||
note.line,
|
||||
note.message
|
||||
);
|
||||
}
|
||||
let folder = dimension_folder(&pack, dimension);
|
||||
let post = load_post(&pack, &folder, &properties, ¯os, None);
|
||||
if let Some((programs, notes)) = renderer.set_shader_pack(dimension, Some(post))
|
||||
{
|
||||
for note in notes {
|
||||
log::warn!("shader pack {name}: {note}");
|
||||
}
|
||||
log::info!(
|
||||
"shader pack {name}: folder `{folder}`, {} programs ({}) in {} ms",
|
||||
programs.len(),
|
||||
programs.join(" "),
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("shader pack {name}: {e}");
|
||||
renderer.set_shader_pack(dimension, None);
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
log::warn!("Horizon: shader pack {}: {e}", path.display());
|
||||
self.system_message(Text::literal(format!("Shader pack: {e}")));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
fn shader_pack_error(&mut self, id: &str, error: &str) {
|
||||
log::warn!("Horizon: shader pack {id}: {error}");
|
||||
// The Shader Packs page says why on the pack's row: the first line,
|
||||
// since translator errors go on with a drawn source excerpt.
|
||||
let first = error.lines().map(str::trim).find(|l| !l.is_empty());
|
||||
self.ui.options.shader_pack_error =
|
||||
Some((id.to_owned(), first.unwrap_or(error).to_owned()));
|
||||
self.system_message(Text::literal(format!("Shader pack {id}: {error}")));
|
||||
let lerp = |(old, new): (f32, f32)| old + (new - old) * partial;
|
||||
let at = camera.floor().as_ivec3();
|
||||
let light = self.world.light(at.x, at.y, at.z);
|
||||
PackInputs {
|
||||
day_time: self.world.day_time,
|
||||
rain: lerp(self.world.weather.rain_level),
|
||||
thunder: lerp(self.world.weather.thunder_level),
|
||||
eye_in: match self.camera_fluid(camera.to_array()) {
|
||||
CameraFluid::None => 0,
|
||||
CameraFluid::Water => 1,
|
||||
CameraFluid::Lava => 2,
|
||||
CameraFluid::PowderSnow => 3,
|
||||
},
|
||||
blindness: f32::from(u8::from(self.potions.effects.get("blindness").is_some())),
|
||||
eye_light: [i32::from(light & 15), i32::from(light >> 4)],
|
||||
hide_gui: self.ui.hide_gui,
|
||||
brightness: self.settings.gamma,
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 799 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user