mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 10:31:29 -10:00
Compare commits
435
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3be9ca142a | ||
|
|
e84a47c069 | ||
|
|
ba6344a59c | ||
|
|
483d832a31 | ||
|
|
45982faa5c | ||
|
|
5d80bf5b89 | ||
|
|
bb64067197 | ||
|
|
cfab69e790 | ||
|
|
2c526aa767 | ||
|
|
d129fb7cfe | ||
|
|
92e956be80 | ||
|
|
b1dfde280b | ||
|
|
1bd934d3fb | ||
|
|
03498ad1c7 | ||
|
|
fbd7288a1d | ||
|
|
6966a69d36 | ||
|
|
e4fb44e067 | ||
|
|
0c441c9009 | ||
|
|
c48d6773c5 | ||
|
|
2160cd016a | ||
|
|
a99ef86dc6 | ||
|
|
c3601b74f3 | ||
|
|
68f036059e | ||
|
|
99998b9f3c | ||
|
|
c2db12a1a0 | ||
|
|
0c149c7d1c | ||
|
|
5fb90b440c | ||
|
|
bd9b325872 | ||
|
|
b51637616b | ||
|
|
5a017c9006 | ||
|
|
c18e893143 | ||
|
|
b9f8ea2f27 | ||
|
|
536c3b90fd | ||
|
|
4abef77bba | ||
|
|
bc3383d1bf | ||
|
|
b76ae35f5b | ||
|
|
c6bf75b3b1 | ||
|
|
a6aa6a071a | ||
|
|
c9a87963ef | ||
|
|
6edf1ff467 | ||
|
|
33f417fb32 | ||
|
|
ee883db4b9 | ||
|
|
f11bbea3b1 | ||
|
|
bb0bf9e1c8 | ||
|
|
a6a734da85 | ||
|
|
3b82d4770f | ||
|
|
fa50f136fe | ||
|
|
eb3f03d7f3 | ||
|
|
061751a023 | ||
|
|
5851a80ce0 | ||
|
|
bf6c0b8a24 | ||
|
|
05f5c2197b | ||
|
|
3ffa45a5cf | ||
|
|
05114a3673 | ||
|
|
d322e068e2 | ||
|
|
df583d34af | ||
|
|
737657443d | ||
|
|
101d3b0bec | ||
|
|
0359138d94 | ||
|
|
b5ec91342e | ||
|
|
6883b74274 | ||
|
|
d9e5234fa3 | ||
|
|
9aeb985305 | ||
|
|
0ec337f17e | ||
|
|
a93ffc2f36 | ||
|
|
6e0605ad33 | ||
|
|
3bd99f79ae | ||
|
|
655b331b1e | ||
|
|
a46ce6898b | ||
|
|
8a7cd0deaf | ||
|
|
6269b7a21e | ||
|
|
c5410785c0 | ||
|
|
ada669266f | ||
|
|
b9c54ecd50 | ||
|
|
24bea8540d | ||
|
|
f03c1771af | ||
|
|
25f9cd3a99 | ||
|
|
f257391453 | ||
|
|
e497995b44 | ||
|
|
2c91574bbb | ||
|
|
f0ac52bf7f | ||
|
|
1f3ccb0fc3 | ||
|
|
fcbb0d627a | ||
|
|
30666b33b6 | ||
|
|
ada67586d0 | ||
|
|
6ca9531468 | ||
|
|
972719f088 | ||
|
|
b548c70594 | ||
|
|
4fef8a8682 | ||
|
|
a80678b890 | ||
|
|
be8a82a40b | ||
|
|
e54e8003e5 | ||
|
|
bc31cef0a7 | ||
|
|
2b2a8bedd4 | ||
|
|
6db70a1ae4 | ||
|
|
0d5cd6a591 | ||
|
|
6d67bf43bb | ||
|
|
6d59158973 | ||
|
|
647050d7aa | ||
|
|
1c333aa336 | ||
|
|
41d772380a | ||
|
|
df00ca8693 | ||
|
|
589164c071 | ||
|
|
ed196ffd66 | ||
|
|
00480831ce | ||
|
|
2fa595676e | ||
|
|
d3338640bc | ||
|
|
ea10a17842 | ||
|
|
fcb14be442 | ||
|
|
f1c0ab982a | ||
|
|
173d00dadb | ||
|
|
ea7e58787d | ||
|
|
7ce7388582 | ||
|
|
6fea98f668 | ||
|
|
3ee203767a | ||
|
|
a013e649c9 | ||
|
|
b78e407c12 | ||
|
|
9858aec68b | ||
|
|
7852c700f1 | ||
|
|
14fad0786e | ||
|
|
0dfa513424 | ||
|
|
51c10dd31d | ||
|
|
0d9399ead9 | ||
|
|
923f425f19 | ||
|
|
2be6cda974 | ||
|
|
9ad9d087ef | ||
|
|
79593d80b6 | ||
|
|
58853fdf50 | ||
|
|
834819ddf4 | ||
|
|
7835010339 | ||
|
|
590542c5ee | ||
|
|
4cb754474d | ||
|
|
0855cc910f | ||
|
|
793337b0ed | ||
|
|
a603c95eba | ||
|
|
564b8a4b9e | ||
|
|
a16097dd96 | ||
|
|
4c802c1342 | ||
|
|
304a3ca76e | ||
|
|
d163f38b6a | ||
|
|
e2fa940581 | ||
|
|
a32ab2c5e4 | ||
|
|
03b464ec0e | ||
|
|
186095dd37 | ||
|
|
a4f0482bd9 | ||
|
|
2ba6caa289 | ||
|
|
5f5d0434cb | ||
|
|
11159a1ed5 | ||
|
|
bf8d58facd | ||
|
|
a8e3cebc5f | ||
|
|
30fcf2c30c | ||
|
|
9d72c1ddcd | ||
|
|
62caef810a | ||
|
|
1c16f98098 | ||
|
|
69d2086e7d | ||
|
|
0829657bcc | ||
|
|
88e79c1119 | ||
|
|
1dde830baf | ||
|
|
588ca0cd22 | ||
|
|
4488cda84f | ||
|
|
2fd01bd8bd | ||
|
|
ba5585da84 | ||
|
|
d409f691dc | ||
|
|
cc683c75f0 | ||
|
|
57e49b068e | ||
|
|
89f40134b2 | ||
|
|
490a33aea9 | ||
|
|
7cce0bf15b | ||
|
|
a536b00c30 | ||
|
|
4289f22f00 | ||
|
|
e4d8570fbc | ||
|
|
4e3a161ac6 | ||
|
|
6cd8cc4987 | ||
|
|
30da32b83c | ||
|
|
173e278206 | ||
|
|
31ad980443 | ||
|
|
084394a849 | ||
|
|
e8d5059b6a | ||
|
|
23c5335910 | ||
|
|
ae0512e299 | ||
|
|
8c3d85eb58 | ||
|
|
4592a7e799 | ||
|
|
5dc59b0f6a | ||
|
|
e93427276f | ||
|
|
70e83d9f65 | ||
|
|
8a773bca20 | ||
|
|
0e4f8e2799 | ||
|
|
4464c5d64a | ||
|
|
b0c2f375bb | ||
|
|
c41e95bf53 | ||
|
|
3c8a73f86f | ||
|
|
f58660a875 | ||
|
|
c744e51084 | ||
|
|
7f1edb9263 | ||
|
|
396ba2e5e7 | ||
|
|
10e715be15 | ||
|
|
5b3b108515 | ||
|
|
a72876f0c0 | ||
|
|
39314175e9 | ||
|
|
88b9342ffc | ||
|
|
21a033cea0 | ||
|
|
a18c9206d9 | ||
|
|
ad9f9a66f6 | ||
|
|
ba59ac2ccf | ||
|
|
7ca3671df3 | ||
|
|
0ce65d34c6 | ||
|
|
379ec36d52 | ||
|
|
7f70737138 | ||
|
|
b80c1a9eef | ||
|
|
682f8a20fa | ||
|
|
c1de70edd4 | ||
|
|
149c4b87f8 | ||
|
|
a6b3066f85 | ||
|
|
08918a8ab0 | ||
|
|
fe08d02d5d | ||
|
|
359e83b338 | ||
|
|
8b17c0383e | ||
|
|
407dd201f8 | ||
|
|
889931c0b9 | ||
|
|
509536179d | ||
|
|
a9d2ae39d9 | ||
|
|
9d44aa4ba0 | ||
|
|
0c34e3f36c | ||
|
|
4049220715 | ||
|
|
1139057d3e | ||
|
|
9f3cba308c | ||
|
|
80b68bae0c | ||
|
|
48efd53e00 | ||
|
|
c7440b78d5 | ||
|
|
0703ae6bd4 | ||
|
|
ec6ea46499 | ||
|
|
62c4d91aa5 | ||
|
|
d5d42c8a5f | ||
|
|
765ed5d0c2 | ||
|
|
a63b69c300 | ||
|
|
126f997703 | ||
|
|
6befa8622d | ||
|
|
4fe17c268c | ||
|
|
1474b118b4 | ||
|
|
4fcaff9935 | ||
|
|
0554db3906 | ||
|
|
e94f1917a7 | ||
|
|
713b2774aa | ||
|
|
882e3a3005 | ||
|
|
d684cafca1 | ||
|
|
20ad245e02 | ||
|
|
6840b57201 | ||
|
|
f31e30a081 | ||
|
|
913ad15462 | ||
|
|
1976c27870 | ||
|
|
7af0a21f2d | ||
|
|
7a2121d84f | ||
|
|
3d5407ab11 | ||
|
|
ef890e667f | ||
|
|
b5bcf031e5 | ||
|
|
1e3f05fe8c | ||
|
|
88dc2c5f8d | ||
|
|
8880e0d373 | ||
|
|
b9ddeb1820 | ||
|
|
50121630ce | ||
|
|
edd7a9cb61 | ||
|
|
5270924ba9 | ||
|
|
97eda2b7f5 | ||
|
|
86c1795ec6 | ||
|
|
b469038d45 | ||
|
|
d17c1bc0e9 | ||
|
|
ec77633cfe | ||
|
|
5909dc68b6 | ||
|
|
bb7adf1207 | ||
|
|
97294e3838 | ||
|
|
01cf25687f | ||
|
|
2151ef21f2 | ||
|
|
d686f4b9d7 | ||
|
|
2b90acbc0d | ||
|
|
2e49c68051 | ||
|
|
aa30c8e9d2 | ||
|
|
4dcb669759 | ||
|
|
ada08437e5 | ||
|
|
997bbef785 | ||
|
|
753660c0b4 | ||
|
|
eba7cb0409 | ||
|
|
2d477df4fd | ||
|
|
71092caa14 | ||
|
|
5968cd0f09 | ||
|
|
8ce62d3aaa | ||
|
|
2153681d47 | ||
|
|
349127673c | ||
|
|
32e16761bd | ||
|
|
57a2512025 | ||
|
|
25d15d2a0a | ||
|
|
a1a45472c8 | ||
|
|
37f9481bb7 | ||
|
|
04fb868e99 | ||
|
|
7ed6012c79 | ||
|
|
a8be1d9ee0 | ||
|
|
b6f3d91b09 | ||
|
|
26f46141c7 | ||
|
|
4f5a70ee53 | ||
|
|
d0761d3c50 | ||
|
|
caca2ef2d4 | ||
|
|
8874140947 | ||
|
|
a369faeb32 | ||
|
|
6dc2f77e99 | ||
|
|
bb4da395a3 | ||
|
|
2555b46e7c | ||
|
|
65a23ca98e | ||
|
|
24101477da | ||
|
|
e04428b60c | ||
|
|
79ed5ba529 | ||
|
|
a569559196 | ||
|
|
ac74644dc9 | ||
|
|
c419845102 | ||
|
|
a6e91967a1 | ||
|
|
af8751173f | ||
|
|
af0bc10c34 | ||
|
|
7fd82c846b | ||
|
|
f27a098c2d | ||
|
|
08e206c459 | ||
|
|
5bc9606d99 | ||
|
|
c4f0c49c0e | ||
|
|
ff70b164cc | ||
|
|
66c20c246d | ||
|
|
32f2156b13 | ||
|
|
810a0152d3 | ||
|
|
c124b94c8b | ||
|
|
845343e019 | ||
|
|
89ecd6cd9d | ||
|
|
e314b5c6e1 | ||
|
|
be2963b1d6 | ||
|
|
d68d59f6b6 | ||
|
|
7a98019450 | ||
|
|
c40d0112a6 | ||
|
|
89cbb09961 | ||
|
|
d43b957afc | ||
|
|
93dd53e1c6 | ||
|
|
1a371c3e20 | ||
|
|
f1a0963f2a | ||
|
|
2947c2d71f | ||
|
|
952fc5a1f5 | ||
|
|
ba7f106d53 | ||
|
|
9191728d12 | ||
|
|
bd89ae3df7 | ||
|
|
5ea674c6e6 | ||
|
|
d8ac144995 | ||
|
|
a7f03f52fd | ||
|
|
fabb6d78ec | ||
|
|
3082b36894 | ||
|
|
c9053670cd | ||
|
|
4269899778 | ||
|
|
6eb26eb046 | ||
|
|
d1129a9fce | ||
|
|
0cfb0400c8 | ||
|
|
230d89f7ec | ||
|
|
be48b7a4a9 | ||
|
|
ef3163c244 | ||
|
|
96877dcf48 | ||
|
|
3931a46585 | ||
|
|
bcfeeeeb2a | ||
|
|
e28ed6d877 | ||
|
|
34c77f9eac | ||
|
|
20ffb882bd | ||
|
|
38a315331d | ||
|
|
e09cbf2b9e | ||
|
|
1c4b029b95 | ||
|
|
31c0c3dfe4 | ||
|
|
fea840e0d4 | ||
|
|
582331c3ea | ||
|
|
eed02f0cda | ||
|
|
dacde01da2 | ||
|
|
26c139f901 | ||
|
|
d4f466623d | ||
|
|
c9999e3336 | ||
|
|
3a37c32d8d | ||
|
|
91591e4dcf | ||
|
|
6180a78195 | ||
|
|
731896f222 | ||
|
|
7703ebcf95 | ||
|
|
8ca96ad967 | ||
|
|
c11d57d1f8 | ||
|
|
0b6c48be77 | ||
|
|
815beda680 | ||
|
|
8e0939f3e8 | ||
|
|
df96b6787d | ||
|
|
0c79bae87d | ||
|
|
5071edb248 | ||
|
|
94eff29428 | ||
|
|
136349ae95 | ||
|
|
5cdf9a9151 | ||
|
|
fa77b35140 | ||
|
|
845dbae91b | ||
|
|
a167a89215 | ||
|
|
953a5a84ee | ||
|
|
37a2cc0057 | ||
|
|
029870bb90 | ||
|
|
1afbdca0f0 | ||
|
|
5601fea75e | ||
|
|
158075a8e8 | ||
|
|
e8c1a797c8 | ||
|
|
9fa7dddbe4 | ||
|
|
3c9b2599d8 | ||
|
|
ce2b51090f | ||
|
|
30d33e077f | ||
|
|
35345be102 | ||
|
|
6e2ac2522d | ||
|
|
e72a8c5c6e | ||
|
|
c74d170fbc | ||
|
|
21773ca7f5 | ||
|
|
ce45e065c3 | ||
|
|
861be0b075 | ||
|
|
c075240fa3 | ||
|
|
7d43179850 | ||
|
|
64ff13890a | ||
|
|
708f0b48bf | ||
|
|
9e91278794 | ||
|
|
fdb4e917c0 | ||
|
|
c2b55479f1 | ||
|
|
a6d8409466 | ||
|
|
46c62bdf2a | ||
|
|
0c639904f1 | ||
|
|
9c18b4e524 | ||
|
|
c7b02f8568 | ||
|
|
8401cde519 | ||
|
|
9bb3fc077e | ||
|
|
2aa9056259 | ||
|
|
8117e2767f | ||
|
|
bc5ec6fb46 | ||
|
|
e5ebeddb90 | ||
|
|
d0b42fb88e | ||
|
|
bca77d1ac4 | ||
|
|
815c09b1f8 | ||
|
|
0104eeaf33 | ||
|
|
729a224eef | ||
|
|
22054eee90 | ||
|
|
52f0e38d41 | ||
|
|
1a80673d50 |
No files matched your search
@@ -22,7 +22,9 @@ jobs:
|
||||
if: >-
|
||||
github.event_name == 'issue_comment' && github.event.comment.user.type != 'Bot' && (
|
||||
startsWith(github.event.comment.body, '/claim') ||
|
||||
startsWith(github.event.comment.body, '/unclaim'))
|
||||
startsWith(github.event.comment.body, '/unclaim') ||
|
||||
startsWith(github.event.comment.body, '🤖 Agent: /claim') ||
|
||||
startsWith(github.event.comment.body, '🤖 Agent: /unclaim'))
|
||||
# One command per issue at a time, so two `/claim`s can't both find it free. On the job, not the workflow:
|
||||
# the skipped runs of ordinary comments must not cancel a waiting one.
|
||||
concurrency:
|
||||
|
||||
@@ -53,7 +53,7 @@ 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-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
|
||||
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
@@ -73,7 +73,7 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
|
||||
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
|
||||
python3 tools/pr_ready.py --quick # same without harnesses
|
||||
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client --features horizon-client/mods --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo test -p horizon-client --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
@@ -93,9 +93,6 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
|
||||
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
|
||||
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
|
||||
- **macOS:** `cargo build/test --workspace` may fail to link the two wasm guest cdylibs (undefined
|
||||
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude predator-missile --exclude horizon-mods-test-guest --exclude horizon-mods-test-ui-guest`.
|
||||
Their real check is `cargo test -p horizon-mods` (builds them for `wasm32-unknown-unknown`).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
|
||||
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
|
||||
`target/fast/horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --offscreen --wait --resources $PWD/.local/horizon-client/26.3`,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<img src="https://img.shields.io/badge/Minecraft-Java%2026.3-5D9C3F" alt="Minecraft Java 26.3">
|
||||
<img src="https://img.shields.io/badge/Rust-stable-B7410E?logo=rust&logoColor=white" alt="Rust">
|
||||
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
|
||||
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-833%20open-F0795A" alt="Player-visible gaps open"></a>
|
||||
<img src="https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-2A2470" alt="Linux, macOS and Windows">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3B2F7E" alt="MIT license"></a>
|
||||
</p>
|
||||
@@ -48,7 +49,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
- **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 and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation (vanilla's heights and biomes for now).
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** Horizon has its own mods, installed from the launcher. The official ones (Zoom, FPS Counter, Keystrokes and Minimap) are native Rust that the launcher builds into your game, with no Rust to install; older WebAssembly mods still run while they move. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
- **Mods:** content mods add blocks, items and data, and native mods change the game's own Rust code, built by the launcher on your copy, with no Rust to install. Horizon's own (Zoom, FPS Counter, Keystrokes, Minimap, Recipes and Coordinates HUD) are native mods. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
|
||||
|
||||
@@ -127,6 +128,8 @@ It runs rustfmt, clippy with warnings denied, the tests of the crates you touche
|
||||
|
||||
## Project status
|
||||
|
||||
**Player-visible gaps: 833 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Screens, HUD and client packets [197](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [129](https://github.com/horizon-rust/horizon/issues/3519); Animals [110](https://github.com/horizon-rust/horizon/issues/3512); Server and players on the server [106](https://github.com/horizon-rust/horizon/issues/3520); Blocks, redstone and fluids [86](https://github.com/horizon-rust/horizon/issues/3516); Entities, projectiles, vehicles and decorations [64](https://github.com/horizon-rust/horizon/issues/3514); Monsters and bosses [53](https://github.com/horizon-rust/horizon/issues/3513); Mob AI, villagers and raids [35](https://github.com/horizon-rust/horizon/issues/3521); World, world generation and saves [32](https://github.com/horizon-rust/horizon/issues/3517); Items, enchantments and inventories [21](https://github.com/horizon-rust/horizon/issues/3515). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
|
||||
|
||||
The table below is the project's own audit of each subsystem. Every subsystem now matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not proof that nothing else differs. The remaining known differences are listed below.
|
||||
|
||||
### Java parity: 100%
|
||||
|
||||
+9
-9
@@ -76,7 +76,7 @@ Owner: [levzzz5154](https://github.com/levzzz5154) · Tracking: [#3911](https://
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. The WebAssembly runtime is being removed ([#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353)). [docs/native-mods.md](docs/native-mods.md).
|
||||
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. The WebAssembly runtime is removed ([#3350](https://github.com/horizon-rust/horizon/issues/3350), [#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353)). [docs/native-mods.md](docs/native-mods.md).
|
||||
|
||||
More: [#3360](https://github.com/horizon-rust/horizon/issues/3360) · [#1596](https://github.com/horizon-rust/horizon/issues/1596) · [#1635](https://github.com/horizon-rust/horizon/issues/1635)
|
||||
|
||||
@@ -350,7 +350,7 @@ Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a c
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Earlier research ([#527](https://github.com/horizon-rust/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.
|
||||
Earlier research ([#527](https://github.com/horizon-rust/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Option to look at again: a bridge for simple Fabric mods.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -370,7 +370,7 @@ When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods where the API allows it (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
|
||||
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods as native mods (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -380,7 +380,7 @@ Making mods that add machines, magic and mobs work in Horizon, in steps, startin
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
|
||||
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path. Code mods are native mods now ([docs/native-mods.md](docs/native-mods.md)): each port is a horizon-mixins bundle. The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -490,7 +490,7 @@ Small mods written as scripts, in the style of KubeJS, with no compiler.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is being removed. A script layer over the native hook points is to be designed.
|
||||
Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is removed. A script layer over the native hook points is to be designed.
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
@@ -498,11 +498,11 @@ More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
#### Orbital Laser on the normal game
|
||||
|
||||
The community mod Orbital Laser, made for a fork of Horizon, running on the normal game and listed in the registry as the showcase of the mod API.
|
||||
The community mod Orbital Laser, made for a fork of Horizon, running on the normal game as a native mod and listed in the registry.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A pull request on [https://github.com/Stink-O/orbital-laser](https://github.com/Stink-O/orbital-laser) (MIT) once the input, ui, render and world capabilities exist (see 'Screens, input and drawing for mods'): today the mod needs them through its author's fork. Tested in the game, and listed in the registry.
|
||||
A native port (a horizon-mixins bundle, docs/native-mods.md) of [https://github.com/Stink-O/orbital-laser](https://github.com/Stink-O/orbital-laser) (MIT), which today runs through its author's fork. Tested in the game, and listed in the registry.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -524,7 +524,7 @@ Contraptions and physics like the Create and Aeronautics mods, as a mod for Hori
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#805](https://github.com/horizon-rust/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.
|
||||
Issue [#805](https://github.com/horizon-rust/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -534,7 +534,7 @@ Hear nearby players talk, as with the voice chat mods of other Minecraft version
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Needs server mods, mod networking and an audio interface in the mod API before anything can be built ([docs/api/mods.md](docs/api/mods.md)).
|
||||
A native mod ([docs/native-mods.md](docs/native-mods.md)). It needs a way to carry voice between players and an audio path in the game before anything can be built.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -21,18 +21,19 @@ use horizon_protocol::{
|
||||
connection::{ConnWriter, Connection},
|
||||
crypto::offline_uuid,
|
||||
ids,
|
||||
item::Stack,
|
||||
packets::{
|
||||
Empty, configuration,
|
||||
handshake::{self, Intention},
|
||||
login::{self, ServerHello},
|
||||
play,
|
||||
play, recipes,
|
||||
},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
Arc, LazyLock, Mutex,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
thread,
|
||||
@@ -44,7 +45,22 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
|
||||
--server HOST:PORT default 127.0.0.1:25565
|
||||
--bots N default 10
|
||||
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed
|
||||
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed.
|
||||
One abusive client, to test a server's limits (run it beside a normal crowd):
|
||||
flood-move (--flood moves a tick, each 64 to 256 blocks away, ignoring chunks),
|
||||
flood-acks (--flood chunk batch acks asking for 64 chunks a tick, while exploring),
|
||||
flood-packets (--flood rotations a tick),
|
||||
flood-chunks (one move a tick, 9.9 blocks straight ahead: as fast as the
|
||||
moved-too-quickly check allows, never waiting for chunks),
|
||||
and --flood a tick of packets that cost the server work:
|
||||
flood-use (place and break stone around itself), flood-click (inventory
|
||||
clicks with a stale state), flood-suggest (command suggestions, every
|
||||
other one 30 000 characters long), flood-book (100 pages of 1024 characters
|
||||
into a writable book), flood-sign (four 384-character lines on the block
|
||||
below), flood-recipe (recipe book placements)
|
||||
--flood N packets a tick for the flood behaviours; default 100
|
||||
--no-read stop reading from the server once spawned (a client that lets the
|
||||
server's replies pile up)
|
||||
--join-every SECONDS default 1
|
||||
--seconds N how long to run after the first bot joins; default 60
|
||||
--view-distance N what the bots ask for; default 10
|
||||
@@ -59,6 +75,16 @@ enum Behaviour {
|
||||
Idle,
|
||||
Walk,
|
||||
Explore,
|
||||
FloodMove,
|
||||
FloodAcks,
|
||||
FloodPackets,
|
||||
FloodChunks,
|
||||
FloodUse,
|
||||
FloodClick,
|
||||
FloodSuggest,
|
||||
FloodBook,
|
||||
FloodSign,
|
||||
FloodRecipe,
|
||||
}
|
||||
|
||||
impl Behaviour {
|
||||
@@ -67,6 +93,16 @@ impl Behaviour {
|
||||
Self::Idle => "idle",
|
||||
Self::Walk => "walk",
|
||||
Self::Explore => "explore",
|
||||
Self::FloodMove => "flood-move",
|
||||
Self::FloodAcks => "flood-acks",
|
||||
Self::FloodPackets => "flood-packets",
|
||||
Self::FloodChunks => "flood-chunks",
|
||||
Self::FloodUse => "flood-use",
|
||||
Self::FloodClick => "flood-click",
|
||||
Self::FloodSuggest => "flood-suggest",
|
||||
Self::FloodBook => "flood-book",
|
||||
Self::FloodSign => "flood-sign",
|
||||
Self::FloodRecipe => "flood-recipe",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +117,8 @@ struct Options {
|
||||
view_distance: i8,
|
||||
seed: u64,
|
||||
explore_speed: f64,
|
||||
flood: usize,
|
||||
no_read: bool,
|
||||
name: String,
|
||||
report: Option<String>,
|
||||
}
|
||||
@@ -96,6 +134,8 @@ fn options() -> Result<Options, String> {
|
||||
view_distance: 10,
|
||||
seed: 12345,
|
||||
explore_speed: 22.0,
|
||||
flood: 100,
|
||||
no_read: false,
|
||||
name: "Bot".into(),
|
||||
report: None,
|
||||
};
|
||||
@@ -105,6 +145,10 @@ fn options() -> Result<Options, String> {
|
||||
print!("{HELP}");
|
||||
std::process::exit(0);
|
||||
}
|
||||
if arg == "--no-read" {
|
||||
o.no_read = true;
|
||||
continue;
|
||||
}
|
||||
let value = args.next().ok_or(format!("{arg} needs a value"))?;
|
||||
let number = |what: &str| format!("{arg} needs {what}, not {value:?}");
|
||||
match arg.as_str() {
|
||||
@@ -122,14 +166,31 @@ fn options() -> Result<Options, String> {
|
||||
"--explore-speed" => {
|
||||
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
|
||||
}
|
||||
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
|
||||
"--name" => o.name = value,
|
||||
"--report" => o.report = Some(value),
|
||||
_ => return Err(format!("unknown option {arg}; --help lists them")),
|
||||
}
|
||||
}
|
||||
if !["idle", "walk", "explore", "mixed"].contains(&o.behaviour.as_str()) {
|
||||
const BEHAVIOURS: [&str; 14] = [
|
||||
"idle",
|
||||
"walk",
|
||||
"explore",
|
||||
"mixed",
|
||||
"flood-move",
|
||||
"flood-acks",
|
||||
"flood-packets",
|
||||
"flood-chunks",
|
||||
"flood-use",
|
||||
"flood-click",
|
||||
"flood-suggest",
|
||||
"flood-book",
|
||||
"flood-sign",
|
||||
"flood-recipe",
|
||||
];
|
||||
if !BEHAVIOURS.contains(&o.behaviour.as_str()) {
|
||||
return Err(format!(
|
||||
"--behaviour {}: use idle, walk, explore or mixed",
|
||||
"--behaviour {}: use idle, walk, explore, mixed or a flood behaviour (--help lists them)",
|
||||
o.behaviour
|
||||
));
|
||||
}
|
||||
@@ -156,6 +217,12 @@ struct Bot {
|
||||
teleport: Mutex<Option<[f64; 3]>>,
|
||||
error: Mutex<Option<String>>,
|
||||
joined_after_ms: AtomicU64,
|
||||
/// When the bot asked to join, and how long after that it got the last
|
||||
/// chunk new to it: for a bot that stands still, its first full view.
|
||||
asked: Mutex<Option<Instant>>,
|
||||
view_after_ms: AtomicU64,
|
||||
/// The bot stopped reading (--no-read).
|
||||
deaf: AtomicBool,
|
||||
}
|
||||
|
||||
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
|
||||
@@ -185,6 +252,89 @@ fn raw(id: i32, write: impl FnOnce(&mut Writer)) -> Vec<u8> {
|
||||
w.data
|
||||
}
|
||||
|
||||
/// Item ids of the pinned version (26.3's item registry).
|
||||
const STONE: i32 = 1;
|
||||
const WRITABLE_BOOK: i32 = 1371;
|
||||
|
||||
/// The `i`th packet of a costly flood this tick, the bot at `pos`; none for other behaviours.
|
||||
fn costly(
|
||||
behaviour: Behaviour,
|
||||
i: usize,
|
||||
sequence: i32,
|
||||
pos: [f64; 3],
|
||||
random: &mut Random,
|
||||
) -> Option<Vec<u8>> {
|
||||
use ids::play::serverbound as sb;
|
||||
static LONG_COMMAND: LazyLock<String> =
|
||||
LazyLock::new(|| format!("/msg {}", "x".repeat(30_000)));
|
||||
static BOOK: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
||||
play::Serverbound::EditBook(play::EditBook {
|
||||
slot: 0,
|
||||
pages: vec!["a".repeat(1024); 100],
|
||||
title: None,
|
||||
})
|
||||
.encode()
|
||||
});
|
||||
let block = pos.map(|v| v.floor() as i32);
|
||||
let packet = match behaviour {
|
||||
Behaviour::FloodUse => {
|
||||
// A block within reach: place stone there, or break what is there.
|
||||
let at = [0, 1, 2].map(|k| block[k] + (random.unit() * 7.0) as i32 - 3);
|
||||
if i % 2 == 0 {
|
||||
play::Serverbound::UseItemOn(play::UseItemOn {
|
||||
hand: 0,
|
||||
pos: at,
|
||||
face: 1,
|
||||
cursor: [0.5, 1.0, 0.5],
|
||||
inside: false,
|
||||
world_border: false,
|
||||
sequence,
|
||||
})
|
||||
} else {
|
||||
play::Serverbound::PlayerAction(play::PlayerAction {
|
||||
action: 0,
|
||||
pos: at,
|
||||
direction: 1,
|
||||
sequence,
|
||||
})
|
||||
}
|
||||
}
|
||||
Behaviour::FloodClick => play::Serverbound::ContainerClick(play::ContainerClick {
|
||||
container: 0,
|
||||
state: 0,
|
||||
slot: 36 + (i % 9) as i16,
|
||||
button: 0,
|
||||
input: 0,
|
||||
changed: Vec::new(),
|
||||
carried: None,
|
||||
}),
|
||||
Behaviour::FloodSuggest => {
|
||||
let text = if i % 2 == 0 {
|
||||
"/"
|
||||
} else {
|
||||
LONG_COMMAND.as_str()
|
||||
};
|
||||
return Some(raw(sb::COMMAND_SUGGESTION, |w| {
|
||||
w.varint(sequence);
|
||||
w.string(text);
|
||||
}));
|
||||
}
|
||||
Behaviour::FloodBook => return Some(BOOK.clone()),
|
||||
Behaviour::FloodSign => play::Serverbound::SignUpdate(play::SignUpdate {
|
||||
pos: [block[0], block[1] - 1, block[2]],
|
||||
lines: std::array::from_fn(|_| "a".repeat(384)),
|
||||
front: true,
|
||||
}),
|
||||
Behaviour::FloodRecipe => play::Serverbound::PlaceRecipe(recipes::PlaceRecipe {
|
||||
container: 0,
|
||||
recipe: (i % 2042) as i32,
|
||||
use_max: true,
|
||||
}),
|
||||
_ => return None,
|
||||
};
|
||||
Some(packet.encode())
|
||||
}
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> String {
|
||||
e.to_string()
|
||||
}
|
||||
@@ -339,6 +489,12 @@ fn read(
|
||||
let mut batch = BatchSize::new();
|
||||
let mut teleports = 0u64;
|
||||
loop {
|
||||
if bot.deaf.load(Ordering::Relaxed) {
|
||||
while bot.online.load(Ordering::SeqCst) {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (id, body) = match reader.recv() {
|
||||
Ok(packet) => packet,
|
||||
Err(e) => {
|
||||
@@ -363,6 +519,9 @@ fn read(
|
||||
if let (Ok(x), Ok(z)) = (r.i32(), r.i32()) {
|
||||
if !lock(&bot.held).insert((x, z)) {
|
||||
bot.resent.fetch_add(1, Ordering::Relaxed);
|
||||
} else if let Some(asked) = *lock(&bot.asked) {
|
||||
bot.view_after_ms
|
||||
.store(asked.elapsed().as_millis() as u64, Ordering::Relaxed);
|
||||
}
|
||||
if seen.insert((x, z)) {
|
||||
bot.unique.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -457,6 +616,7 @@ fn run_bot(
|
||||
) {
|
||||
let name = format!("{}{index}", o.name);
|
||||
let asked = Instant::now();
|
||||
*lock(&bot.asked) = Some(asked);
|
||||
let connection = match join(&o, &name) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
@@ -489,6 +649,24 @@ fn run_bot(
|
||||
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
|
||||
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
|
||||
send(raw(sb::PLAYER_LOADED, |_| {}));
|
||||
bot.deaf.store(o.no_read, Ordering::Relaxed);
|
||||
// What the costly floods need in hand (creative: the server takes the bot's word for it).
|
||||
let tool = match behaviour {
|
||||
Behaviour::FloodUse => Some(Stack::new(STONE, 64)),
|
||||
Behaviour::FloodBook => Some(Stack::new(WRITABLE_BOOK, 1)),
|
||||
_ => None,
|
||||
};
|
||||
if tool.is_some() {
|
||||
send(
|
||||
play::Serverbound::SetCreativeModeSlot(play::SetCreativeModeSlot {
|
||||
slot: 36,
|
||||
item: tool,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
send(play::Serverbound::SetCarriedItem(play::SetCarriedItem { slot: 0 }).encode());
|
||||
}
|
||||
let mut sequence = 0;
|
||||
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
|
||||
let mut pos = spawn;
|
||||
let cruise = spawn[1] + 24.0;
|
||||
@@ -504,7 +682,38 @@ fn run_bot(
|
||||
pos = at;
|
||||
}
|
||||
let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
|
||||
if !lock(&bot.held).contains(&here) {
|
||||
// The abusive client doesn't wait for its chunk, as a real client must.
|
||||
if behaviour == Behaviour::FloodMove {
|
||||
// The last of these moves goes out below, with the tick's end.
|
||||
pos[1] = cruise;
|
||||
for _ in 1..o.flood.max(1) {
|
||||
let angle = random.unit() * std::f64::consts::TAU;
|
||||
let reach = 64.0 + random.unit() * 192.0;
|
||||
pos[0] += angle.cos() * reach;
|
||||
pos[2] += angle.sin() * reach;
|
||||
send(
|
||||
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
|
||||
x: pos[0],
|
||||
y: pos[1],
|
||||
z: pos[2],
|
||||
flags: 0,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
}
|
||||
let angle = random.unit() * std::f64::consts::TAU;
|
||||
pos[0] += angle.cos() * 128.0;
|
||||
pos[2] += angle.sin() * 128.0;
|
||||
} else if behaviour == Behaviour::FloodChunks {
|
||||
// Up to cruising height first, then just under the server's 10 blocks a tick:
|
||||
// every tick asks for new chunks.
|
||||
if pos[1] < cruise {
|
||||
pos[1] = (pos[1] + 0.5).min(cruise);
|
||||
} else {
|
||||
pos[0] += heading.cos() * 9.9;
|
||||
pos[2] += heading.sin() * 9.9;
|
||||
}
|
||||
} else 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);
|
||||
@@ -529,7 +738,37 @@ fn run_bot(
|
||||
pos[2] += dz / distance * step;
|
||||
}
|
||||
}
|
||||
Behaviour::Explore => {
|
||||
Behaviour::FloodMove
|
||||
| Behaviour::FloodChunks
|
||||
| Behaviour::FloodUse
|
||||
| Behaviour::FloodClick
|
||||
| Behaviour::FloodSuggest
|
||||
| Behaviour::FloodBook
|
||||
| Behaviour::FloodSign
|
||||
| Behaviour::FloodRecipe => {}
|
||||
Behaviour::FloodPackets => {
|
||||
for i in 0..o.flood {
|
||||
send(
|
||||
play::Serverbound::MovePlayerRot(play::MovePlayerRot {
|
||||
yaw: (i % 360) as f32,
|
||||
pitch: 0.0,
|
||||
flags: 0,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Behaviour::FloodAcks | Behaviour::Explore => {
|
||||
if behaviour == Behaviour::FloodAcks {
|
||||
for _ in 0..o.flood {
|
||||
send(
|
||||
play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived {
|
||||
chunks_per_tick: 64.0,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Flying in creative, by default about 22 blocks a second, which keeps new
|
||||
// chunks coming as fast as the server sends them.
|
||||
let step = o.explore_speed / 20.0;
|
||||
@@ -538,6 +777,13 @@ fn run_bot(
|
||||
}
|
||||
}
|
||||
}
|
||||
// The costly floods go out wherever the bot is, chunks or not (--no-read gets none).
|
||||
for i in 0..o.flood {
|
||||
sequence += 1;
|
||||
if let Some(body) = costly(behaviour, i, sequence, pos, &mut random) {
|
||||
send(body);
|
||||
}
|
||||
}
|
||||
let ok = send(
|
||||
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
|
||||
x: pos[0],
|
||||
@@ -569,6 +815,16 @@ fn behaviour_of(mode: &str, index: usize) -> Behaviour {
|
||||
"idle" => Behaviour::Idle,
|
||||
"walk" => Behaviour::Walk,
|
||||
"explore" => Behaviour::Explore,
|
||||
"flood-move" => Behaviour::FloodMove,
|
||||
"flood-acks" => Behaviour::FloodAcks,
|
||||
"flood-packets" => Behaviour::FloodPackets,
|
||||
"flood-chunks" => Behaviour::FloodChunks,
|
||||
"flood-use" => Behaviour::FloodUse,
|
||||
"flood-click" => Behaviour::FloodClick,
|
||||
"flood-suggest" => Behaviour::FloodSuggest,
|
||||
"flood-book" => Behaviour::FloodBook,
|
||||
"flood-sign" => Behaviour::FloodSign,
|
||||
"flood-recipe" => Behaviour::FloodRecipe,
|
||||
// mixed: of every five bots, three walk, one explores, one stands.
|
||||
_ => match index % 5 {
|
||||
0 | 2 | 4 => Behaviour::Walk,
|
||||
@@ -663,6 +919,9 @@ fn main() {
|
||||
"name": format!("{}{i}", o.name),
|
||||
"behaviour": behaviour_of(&o.behaviour, i).name(),
|
||||
"joined_after_ms": b.joined_after_ms.load(Ordering::SeqCst),
|
||||
// Only an idle bot's last new chunk ends its first view.
|
||||
"view_after_ms": (behaviour_of(&o.behaviour, i) == Behaviour::Idle)
|
||||
.then(|| b.view_after_ms.load(Ordering::Relaxed)),
|
||||
"chunks": b.chunks.load(Ordering::Relaxed),
|
||||
"unique_chunks": b.unique.load(Ordering::Relaxed),
|
||||
"resent": b.resent.load(Ordering::Relaxed),
|
||||
|
||||
@@ -55,6 +55,12 @@ particle_types! {
|
||||
Rain = "rain",
|
||||
Bubble = "bubble",
|
||||
SulfurBubbles = "sulfur_bubbles",
|
||||
NoxiousGas = "noxious_gas",
|
||||
NoxiousGasCloud = "noxious_gas_cloud",
|
||||
Geyser = "geyser",
|
||||
GeyserBase = "geyser_base",
|
||||
GeyserPoof = "geyser_poof",
|
||||
GeyserPlume = "geyser_plume",
|
||||
BubblePop = "bubble_pop",
|
||||
BubbleColumnUp = "bubble_column_up",
|
||||
CurrentDown = "current_down",
|
||||
@@ -149,6 +155,10 @@ impl ParticleType {
|
||||
matches!(
|
||||
self,
|
||||
Self::BlockMarker
|
||||
| Self::Geyser
|
||||
| Self::GeyserBase
|
||||
| Self::GeyserPoof
|
||||
| Self::GeyserPlume
|
||||
| Self::DamageIndicator
|
||||
| Self::ExplosionEmitter
|
||||
| Self::Explosion
|
||||
@@ -191,6 +201,8 @@ impl ParticleType {
|
||||
// SpellParticle.
|
||||
| Self::Effect | Self::InstantEffect | Self::EntityEffect
|
||||
| Self::Witch | Self::Infested | Self::TrialOmen | Self::RaidOmen
|
||||
// NoxiousGasParticle.
|
||||
| Self::NoxiousGas
|
||||
// EmissiveRisingParticle.
|
||||
| Self::Soul | Self::SculkSoul
|
||||
// SculkChargeParticle, SculkChargePopParticle, ShriekParticle,
|
||||
|
||||
@@ -13,6 +13,12 @@ pub enum Button {
|
||||
}
|
||||
|
||||
pub trait InteractionWorld {
|
||||
/// Minecraft.pick before handleKeybinds: the tick's `hitResult`, which
|
||||
/// every click of the tick reads, even after one of them changed the
|
||||
/// block it names (a creative break, then a use that replaces it).
|
||||
fn pick_hit_result(&mut self);
|
||||
/// The tick's clicks are done: later picks follow the crosshair again.
|
||||
fn release_hit_result(&mut self);
|
||||
fn using_item(&self) -> bool;
|
||||
fn release_using(&mut self);
|
||||
fn start_attack(&mut self) -> bool;
|
||||
@@ -48,6 +54,7 @@ impl InteractionInput {
|
||||
|
||||
pub fn tick(&mut self, world: &mut impl InteractionWorld, attack_down: bool, use_down: bool) {
|
||||
let [attack, use_item, pick] = std::mem::take(&mut self.clicks);
|
||||
world.pick_hit_result();
|
||||
let mut instant = false;
|
||||
if world.using_item() {
|
||||
if !use_down {
|
||||
@@ -70,6 +77,7 @@ impl InteractionInput {
|
||||
world.start_use();
|
||||
}
|
||||
world.continue_attack(!instant && attack_down);
|
||||
world.release_hit_result();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +94,8 @@ mod tests {
|
||||
events: Vec<&'static str>,
|
||||
}
|
||||
impl InteractionWorld for World {
|
||||
fn pick_hit_result(&mut self) {}
|
||||
fn release_hit_result(&mut self) {}
|
||||
fn using_item(&self) -> bool {
|
||||
self.using
|
||||
}
|
||||
@@ -183,4 +193,67 @@ mod tests {
|
||||
input.tick(&mut world, false, false);
|
||||
assert_eq!(world.events, ["release", "continue"]);
|
||||
}
|
||||
|
||||
/// A floating block in front of the crosshair, nothing behind it, and a
|
||||
/// block item in the hand of a creative player.
|
||||
#[derive(Default)]
|
||||
struct Column {
|
||||
block: bool,
|
||||
hit: Option<Option<()>>,
|
||||
placed: bool,
|
||||
}
|
||||
impl Column {
|
||||
/// Minecraft.hitResult: the tick's pick, or a fresh one outside it.
|
||||
fn target(&self) -> Option<()> {
|
||||
self.hit.unwrap_or(self.block.then_some(()))
|
||||
}
|
||||
}
|
||||
impl InteractionWorld for Column {
|
||||
fn pick_hit_result(&mut self) {
|
||||
self.hit = Some(self.target());
|
||||
}
|
||||
fn release_hit_result(&mut self) {
|
||||
self.hit = None;
|
||||
}
|
||||
fn using_item(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn release_using(&mut self) {}
|
||||
fn start_attack(&mut self) -> bool {
|
||||
// MultiPlayerGameMode.startDestroyBlock in creative: the
|
||||
// predicted break leaves air.
|
||||
if self.target().is_some() {
|
||||
self.block = false;
|
||||
}
|
||||
!self.block
|
||||
}
|
||||
fn start_use(&mut self) {
|
||||
// BlockItem.place: the clicked block, now air, can be replaced.
|
||||
if self.target().is_some() && !self.block {
|
||||
self.block = true;
|
||||
self.placed = true;
|
||||
}
|
||||
}
|
||||
fn pick(&mut self) {}
|
||||
fn use_delay(&self) -> u32 {
|
||||
0
|
||||
}
|
||||
fn continue_attack(&mut self, _: bool) {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attack_and_use_in_one_tick_share_the_hit_result() {
|
||||
// #3006: both buttons at once replace the block the crosshair is on.
|
||||
let mut input = InteractionInput::default();
|
||||
let mut world = Column {
|
||||
block: true,
|
||||
..Default::default()
|
||||
};
|
||||
input.press(Button::Attack);
|
||||
input.press(Button::Use);
|
||||
input.tick(&mut world, false, false);
|
||||
assert!(world.placed, "the use acts on the block the attack broke");
|
||||
assert!(world.block);
|
||||
assert!(world.hit.is_none(), "the next pick follows the crosshair");
|
||||
}
|
||||
}
|
||||
@@ -192,9 +192,17 @@ impl Assets {
|
||||
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
|
||||
RenderAssets::load(root, self)
|
||||
}
|
||||
/// [`Assets::reload`] for a server, which draws nothing: the models and
|
||||
/// the sprites' layers, without the textures' pixels.
|
||||
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
|
||||
RenderAssets::load_with(root, self, false)
|
||||
}
|
||||
}
|
||||
impl RenderAssets {
|
||||
pub fn load(root: &Path, assets: &Assets) -> Result<Self> {
|
||||
Self::load_with(root, assets, true)
|
||||
}
|
||||
fn load_with(root: &Path, assets: &Assets, pixels: bool) -> Result<Self> {
|
||||
let registry = assets.registry;
|
||||
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
|
||||
for name in MATERIALS {
|
||||
@@ -218,7 +226,11 @@ impl RenderAssets {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
sprites.finish();
|
||||
if pixels {
|
||||
sprites.finish();
|
||||
} else {
|
||||
sprites.finish_without_pixels();
|
||||
}
|
||||
// `finish` grows the buffer by doubling: keep only the bytes in use.
|
||||
sprites.pixels.shrink_to_fit();
|
||||
Ok(Self {
|
||||
@@ -241,6 +253,7 @@ impl RenderAssets {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::items::ConsumeEffect;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
|
||||
#[test]
|
||||
fn always_edible_and_consume_effects_loaded() {
|
||||
@@ -251,7 +264,8 @@ mod tests {
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let items = crate::items::Items::load(&root, assets.registry, &facts).unwrap();
|
||||
let items =
|
||||
crate::items::Items::load(&root, assets.registry, &facts, &PackOverlay::NONE).unwrap();
|
||||
|
||||
let apple = items.get("minecraft:golden_apple").expect("golden apple");
|
||||
assert!(apple.can_always_eat);
|
||||
|
||||
@@ -865,13 +865,23 @@ const VIEW_DISTANCE: f64 = 64.0;
|
||||
|
||||
impl BlockEntityRenderer {
|
||||
pub fn load(root: &Path, registry: &BlockRegistry) -> crate::Result<Self> {
|
||||
let visibility = crate::models::load_visibility(root, registry)?;
|
||||
Self::load_with_visibility(root, registry, visibility)
|
||||
}
|
||||
/// [`BlockEntityRenderer::load`] over the face visibility the meshes use
|
||||
/// (`MeshTables::visibility`): a clone shares its shapes and their faces.
|
||||
pub fn load_with_visibility(
|
||||
root: &Path,
|
||||
registry: &BlockRegistry,
|
||||
visibility: FaceVisibility,
|
||||
) -> crate::Result<Self> {
|
||||
let all: Value = serde_json::from_slice(&std::fs::read(root.join("entity-models.json"))?)?;
|
||||
let models = MODELS
|
||||
.iter()
|
||||
.filter_map(|(key, layer)| all.get(*layer).map(|v| (*key, ModelPart::parse(v))))
|
||||
.collect();
|
||||
let mut renderer = Self::new(models, registry);
|
||||
renderer.visibility = Some(Mutex::new(crate::models::load_visibility(root, registry)?));
|
||||
renderer.visibility = Some(Mutex::new(visibility));
|
||||
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
|
||||
if let Ok(bytes) = std::fs::read(root.join("items.json")) {
|
||||
let items: Value = serde_json::from_slice(&bytes)?;
|
||||
@@ -2363,6 +2373,7 @@ mod tests {
|
||||
lines: [line.into(), "two".into(), String::new(), "four".into()],
|
||||
color: 3,
|
||||
glowing,
|
||||
..Default::default()
|
||||
};
|
||||
let first_text = crate::signs::SignData {
|
||||
front: text("one", false),
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::{
|
||||
items::{ItemStack, Items},
|
||||
providers::Providers,
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashSet, fs, path::Path};
|
||||
|
||||
@@ -142,20 +143,41 @@ pub struct Brewing {
|
||||
pub inputs: HashSet<String>,
|
||||
}
|
||||
impl Brewing {
|
||||
pub fn load(root: &Path, items: &Items) -> Self {
|
||||
let mut files: Vec<_> = fs::read_dir(root.join("data/minecraft/recipe/brewing"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
files.sort();
|
||||
let mut recipes = Vec::new();
|
||||
for p in files {
|
||||
let Ok(bytes) = fs::read(&p) else { continue };
|
||||
let Ok(v) = serde_json::from_slice::<Value>(&bytes) else {
|
||||
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self {
|
||||
let mut sources: Vec<(String, Value)> =
|
||||
fs::read_dir(root.join("data/minecraft/recipe/brewing"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let bytes = fs::read(e.path()).ok()?;
|
||||
let v = serde_json::from_slice::<Value>(&bytes).ok()?;
|
||||
Some((
|
||||
format!(
|
||||
"minecraft:brewing/{}",
|
||||
e.file_name().to_string_lossy().strip_suffix(".json")?
|
||||
),
|
||||
v,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
// The world's packs' brewing recipes over the game's own: the top
|
||||
// pack's file for each id replaces it (`SimpleJsonResourceReloadListener`).
|
||||
for (id, files) in overlay.kind("recipe") {
|
||||
if !id.starts_with("minecraft:brewing/") {
|
||||
continue;
|
||||
}
|
||||
let Ok(v) = serde_json::from_slice::<Value>(PackOverlay::top(files)) else {
|
||||
continue;
|
||||
};
|
||||
match sources.iter_mut().find(|(n, _)| n == id) {
|
||||
Some(slot) => slot.1 = v,
|
||||
None => sources.push((id.to_owned(), v)),
|
||||
}
|
||||
}
|
||||
sources.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let mut recipes = Vec::new();
|
||||
for (id, v) in sources {
|
||||
if v["type"] != "minecraft:brewing" {
|
||||
continue;
|
||||
}
|
||||
@@ -169,10 +191,7 @@ impl Brewing {
|
||||
output = output.with_potion(potion);
|
||||
}
|
||||
recipes.push(BrewingRecipe {
|
||||
id: format!(
|
||||
"minecraft:brewing/{}",
|
||||
p.file_stem().and_then(|s| s.to_str()).unwrap_or("")
|
||||
),
|
||||
id,
|
||||
input: PotionIngredient::parse(&v["input"], items),
|
||||
reagent: PotionIngredient::parse(&v["reagent"], items),
|
||||
output,
|
||||
|
||||
@@ -8,10 +8,159 @@ use std::sync::{Arc, OnceLock};
|
||||
|
||||
pub mod block_entity;
|
||||
|
||||
/// The vanilla Overworld's column, which every dimension's chunks hold for
|
||||
/// now (`LevelShape::column`).
|
||||
pub const MIN_Y: i32 = -64;
|
||||
pub const HEIGHT: i32 = 384;
|
||||
pub const SECTIONS: usize = (HEIGHT / 16) as usize;
|
||||
|
||||
/// A level's build range and skylight, from its dimension type (`min_y`,
|
||||
/// `height`, `logical_height`, `has_skylight`), as `LevelHeightAccessor` and
|
||||
/// `SerializableChunkData.write` read them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct LevelShape {
|
||||
pub sky_light: bool,
|
||||
pub min_y: i32,
|
||||
pub height: i32,
|
||||
/// `DimensionType.logicalHeight`: how high portals and chorus fruit reach.
|
||||
pub logical_height: i32,
|
||||
}
|
||||
impl LevelShape {
|
||||
pub const OVERWORLD: Self = Self {
|
||||
sky_light: true,
|
||||
min_y: MIN_Y,
|
||||
height: HEIGHT,
|
||||
logical_height: HEIGHT,
|
||||
};
|
||||
pub const NETHER: Self = Self {
|
||||
sky_light: false,
|
||||
min_y: 0,
|
||||
height: 256,
|
||||
logical_height: 128,
|
||||
};
|
||||
pub const END: Self = Self {
|
||||
sky_light: true,
|
||||
min_y: 0,
|
||||
height: 256,
|
||||
logical_height: 256,
|
||||
};
|
||||
/// A dimension type's shape, checked as its codec checks it.
|
||||
pub fn new(
|
||||
min_y: i32,
|
||||
height: i32,
|
||||
logical_height: i32,
|
||||
sky_light: bool,
|
||||
) -> Result<Self, String> {
|
||||
ColumnLayout::new(min_y, height)?;
|
||||
if !(0..=height).contains(&logical_height) {
|
||||
return Err(format!(
|
||||
"logical_height {logical_height} is outside 0..={height}"
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
sky_light,
|
||||
min_y,
|
||||
height,
|
||||
logical_height,
|
||||
})
|
||||
}
|
||||
/// One above the highest block (`getMaxY() + 1`).
|
||||
pub fn max_y(self) -> i32 {
|
||||
self.min_y + self.height
|
||||
}
|
||||
pub fn min_section(self) -> i32 {
|
||||
self.min_y >> 4
|
||||
}
|
||||
pub fn section_count(self) -> usize {
|
||||
(self.height >> 4) as usize
|
||||
}
|
||||
/// Whether section `y` (a section coordinate) is inside the level.
|
||||
pub fn has_section(self, y: i32) -> bool {
|
||||
(self.min_section()..self.min_section() + self.section_count() as i32).contains(&y)
|
||||
}
|
||||
/// The sections a chunk of this level holds: the level's, widened to the
|
||||
/// vanilla Overworld's `-64..320` so that vanilla's Nether and End keep
|
||||
/// the 24 sections they have always had.
|
||||
pub fn column(self) -> ColumnLayout {
|
||||
let low = self.min_y.min(MIN_Y);
|
||||
let high = self.max_y().max(MIN_Y + HEIGHT);
|
||||
ColumnLayout {
|
||||
min_y: low,
|
||||
sections: ((high - low) / 16) as usize,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The span of block Y a chunk holds: from `min_y`, `sections` sections up
|
||||
/// (`LevelShape::column`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ColumnLayout {
|
||||
min_y: i32,
|
||||
sections: usize,
|
||||
}
|
||||
impl ColumnLayout {
|
||||
pub const VANILLA: Self = Self {
|
||||
min_y: MIN_Y,
|
||||
sections: SECTIONS,
|
||||
};
|
||||
/// `DimensionType.MIN_Y`, `MAX_Y`: 12 bits of `BlockPos` Y, less 32.
|
||||
pub const LOWEST_Y: i32 = -2032;
|
||||
pub const HIGHEST_Y: i32 = 2031;
|
||||
/// A dimension type's `min_y` and `height`, as its codec accepts them:
|
||||
/// multiples of 16, at least 16 high, within `LOWEST_Y..=HIGHEST_Y`.
|
||||
pub fn new(min_y: i32, height: i32) -> Result<Self, String> {
|
||||
if min_y % 16 != 0 || height % 16 != 0 {
|
||||
return Err(format!(
|
||||
"min_y {min_y} and height {height} must be multiples of 16"
|
||||
));
|
||||
}
|
||||
if height < 16 {
|
||||
return Err(format!("height {height} is below 16"));
|
||||
}
|
||||
if min_y < Self::LOWEST_Y
|
||||
|| i64::from(min_y) + i64::from(height) > i64::from(Self::HIGHEST_Y) + 1
|
||||
{
|
||||
return Err(format!(
|
||||
"min_y {min_y} with height {height} leaves {}..={}",
|
||||
Self::LOWEST_Y,
|
||||
Self::HIGHEST_Y
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
min_y,
|
||||
sections: (height / 16) as usize,
|
||||
})
|
||||
}
|
||||
pub fn min_y(self) -> i32 {
|
||||
self.min_y
|
||||
}
|
||||
/// One above the highest block.
|
||||
pub fn max_y(self) -> i32 {
|
||||
self.min_y + self.height()
|
||||
}
|
||||
pub fn height(self) -> i32 {
|
||||
self.sections as i32 * 16
|
||||
}
|
||||
pub fn sections(self) -> usize {
|
||||
self.sections
|
||||
}
|
||||
/// The index of the section holding block `y`, if the column holds it.
|
||||
pub fn section_index(self, y: i32) -> Option<usize> {
|
||||
usize::try_from((y - self.min_y) >> 4)
|
||||
.ok()
|
||||
.filter(|&s| s < self.sections)
|
||||
}
|
||||
/// The minimum block Y of section `index`.
|
||||
pub fn section_y(self, index: usize) -> i32 {
|
||||
self.min_y + index as i32 * 16
|
||||
}
|
||||
}
|
||||
impl Default for ColumnLayout {
|
||||
fn default() -> Self {
|
||||
Self::VANILLA
|
||||
}
|
||||
}
|
||||
|
||||
/// Biomes are 4x4x4 containers over the biome table (67 entries: a 7-bit
|
||||
/// global palette).
|
||||
pub const BIOME_STRATEGY: Strategy = Strategy::biomes(7);
|
||||
@@ -138,9 +287,11 @@ impl Section {
|
||||
pub struct Chunk {
|
||||
pub x: i32,
|
||||
pub z: i32,
|
||||
/// The minimum block Y of `sections[0]` (the layout's when it was made).
|
||||
min_y: i32,
|
||||
pub sections: Vec<Arc<Section>>,
|
||||
/// `Heightmap.Types.WORLD_SURFACE` per column (`z << 4 | x`): one above
|
||||
/// the highest non-air block, or `MIN_Y`. Built on the first read; a
|
||||
/// the highest non-air block, or the column's minimum Y. Built on the first read; a
|
||||
/// `set` that can move a column's surface drops it.
|
||||
surface: OnceLock<Box<[i16; 256]>>,
|
||||
/// `Heightmap.Types.MOTION_BLOCKING` per column, kept like `surface`.
|
||||
@@ -169,11 +320,16 @@ pub struct Chunk {
|
||||
pub structure_starts: Vec<Arc<horizon_world::worldgen::structure::start::StructureStart>>,
|
||||
}
|
||||
impl Chunk {
|
||||
/// An all-air chunk in the current `layout`.
|
||||
pub fn empty(x: i32, z: i32) -> Self {
|
||||
Self::with_layout(x, z, ColumnLayout::VANILLA)
|
||||
}
|
||||
pub fn with_layout(x: i32, z: i32, layout: ColumnLayout) -> Self {
|
||||
Self {
|
||||
x,
|
||||
z,
|
||||
sections: (0..SECTIONS)
|
||||
min_y: layout.min_y(),
|
||||
sections: (0..layout.sections())
|
||||
.map(|_| Arc::new(Section::default()))
|
||||
.collect(),
|
||||
surface: OnceLock::new(),
|
||||
@@ -190,13 +346,36 @@ impl Chunk {
|
||||
entities: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The minimum block Y of the column.
|
||||
#[inline]
|
||||
pub fn min_y(&self) -> i32 {
|
||||
self.min_y
|
||||
}
|
||||
/// The column the chunk holds.
|
||||
pub fn layout(&self) -> ColumnLayout {
|
||||
ColumnLayout {
|
||||
min_y: self.min_y,
|
||||
sections: self.sections.len(),
|
||||
}
|
||||
}
|
||||
/// One above the column's highest block.
|
||||
#[inline]
|
||||
pub fn max_y(&self) -> i32 {
|
||||
self.min_y + self.sections.len() as i32 * 16
|
||||
}
|
||||
/// The index of the section holding block `y`, if the column holds it.
|
||||
#[inline]
|
||||
pub fn section_index(&self, y: i32) -> Option<usize> {
|
||||
usize::try_from((y - self.min_y) >> 4)
|
||||
.ok()
|
||||
.filter(|&s| s < self.sections.len())
|
||||
}
|
||||
#[inline]
|
||||
pub fn get(&self, x: i32, y: i32, z: i32) -> u32 {
|
||||
let s = (y - MIN_Y) >> 4;
|
||||
if !(0..SECTIONS as i32).contains(&s) {
|
||||
return 0;
|
||||
match self.section_index(y) {
|
||||
Some(s) => self.sections[s].get(index(x, y, z)),
|
||||
None => 0,
|
||||
}
|
||||
self.sections[s as usize].get(index(x, y, z))
|
||||
}
|
||||
#[inline]
|
||||
pub fn block(&self, x: i32, y: i32, z: i32) -> BlockStateId {
|
||||
@@ -211,16 +390,15 @@ impl Chunk {
|
||||
.rev()
|
||||
.filter(|(_, section)| !section.is_uniform(skip))
|
||||
.find_map(|(s, _)| {
|
||||
let base = MIN_Y + s as i32 * 16;
|
||||
let base = self.min_y + s as i32 * 16;
|
||||
(base..base + 16).rev().find(|&y| matches(y))
|
||||
})
|
||||
}
|
||||
pub fn set(&mut self, x: i32, y: i32, z: i32, v: u32) -> u32 {
|
||||
let s = (y - MIN_Y) >> 4;
|
||||
if !(0..SECTIONS as i32).contains(&s) {
|
||||
let Some(s) = self.section_index(y) else {
|
||||
return v;
|
||||
}
|
||||
let previous = Arc::make_mut(&mut self.sections[s as usize]).set(index(x, y, z), v);
|
||||
};
|
||||
let previous = Arc::make_mut(&mut self.sections[s]).set(index(x, y, z), v);
|
||||
// `Heightmap.update`: a write two or more below a map's height can't
|
||||
// move it. Any other change rebuilds that map on the next read.
|
||||
if previous != v {
|
||||
@@ -236,7 +414,8 @@ impl Chunk {
|
||||
previous
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)` (`x`, `z` within the
|
||||
/// chunk): one above the highest block that isn't `air`, or `MIN_Y`.
|
||||
/// chunk): one above the highest block that isn't `air`, or the column's
|
||||
/// minimum Y.
|
||||
/// Sections must change only through `set` once this has been read.
|
||||
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(u32) -> bool) -> i32 {
|
||||
let surface = self.surface.get_or_init(|| self.prime(|v| !air(v)));
|
||||
@@ -244,7 +423,8 @@ impl Chunk {
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)` (`x`, `z` within
|
||||
/// the chunk): one above the highest state where `blocks` (a motion
|
||||
/// blocking block or a fluid), or `MIN_Y`. Kept as `world_surface` is.
|
||||
/// blocking block or a fluid), or the column's minimum Y. Kept as
|
||||
/// `world_surface` is.
|
||||
pub fn motion_blocking(&self, x: i32, z: i32, blocks: impl Fn(u32) -> bool) -> i32 {
|
||||
let heights = self.motion_blocking.get_or_init(|| self.prime(blocks));
|
||||
i32::from(heights[column(x, z)])
|
||||
@@ -252,7 +432,7 @@ impl Chunk {
|
||||
/// `Heightmap.primeHeightmaps`: each section from the top, skipping
|
||||
/// those of one state that doesn't count, until every column is found.
|
||||
fn prime(&self, counts: impl Fn(u32) -> bool) -> Box<[i16; 256]> {
|
||||
let mut surface = Box::new([MIN_Y as i16; 256]);
|
||||
let mut surface = Box::new([self.min_y as i16; 256]);
|
||||
let mut found = [false; 256];
|
||||
let mut left = 256;
|
||||
let mut values = [0u32; 4096];
|
||||
@@ -264,7 +444,7 @@ impl Chunk {
|
||||
continue;
|
||||
}
|
||||
section.unpack(&mut values);
|
||||
let base = MIN_Y + s as i32 * 16;
|
||||
let base = self.min_y + s as i32 * 16;
|
||||
for i in 0..256 {
|
||||
if found[i] {
|
||||
continue;
|
||||
@@ -280,11 +460,11 @@ impl Chunk {
|
||||
}
|
||||
#[inline]
|
||||
pub fn light(&self, x: i32, y: i32, z: i32) -> u8 {
|
||||
let s = (y - MIN_Y) >> 4;
|
||||
let s = (y - self.min_y) >> 4;
|
||||
if s < 0 {
|
||||
return 0;
|
||||
}
|
||||
if s >= SECTIONS as i32 {
|
||||
if s >= self.sections.len() as i32 {
|
||||
return 0xF0;
|
||||
}
|
||||
self.sections[s as usize].light(index(x, y, z))
|
||||
@@ -297,7 +477,7 @@ impl Chunk {
|
||||
/// the quart y clamped to the column).
|
||||
#[inline]
|
||||
pub fn biome(&self, x: i32, y: i32, z: i32) -> u16 {
|
||||
let qy = ((y - MIN_Y) >> 2).clamp(0, HEIGHT / 4 - 1);
|
||||
let qy = ((y - self.min_y) >> 2).clamp(0, self.sections.len() as i32 * 4 - 1);
|
||||
self.sections[(qy >> 2) as usize]
|
||||
.biomes
|
||||
.get(((qy & 3) << 4 | ((z & 15) >> 2) << 2 | (x & 15) >> 2) as usize) as u16
|
||||
@@ -504,4 +684,69 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_follow_the_dimension_type_codec() {
|
||||
assert_eq!(ColumnLayout::new(-64, 384), Ok(ColumnLayout::VANILLA));
|
||||
assert_eq!(ColumnLayout::default(), ColumnLayout::VANILLA);
|
||||
// To The Max's Overworld: the highest top vanilla allows.
|
||||
let tall = ColumnLayout::new(-64, 2096).unwrap();
|
||||
assert_eq!((tall.sections(), tall.max_y()), (131, 2032));
|
||||
assert!(ColumnLayout::new(-2032, 4064).is_ok());
|
||||
for (min_y, height) in [(-64, 2112), (-2048, 64), (-60, 384), (0, 250), (0, 0)] {
|
||||
assert!(
|
||||
ColumnLayout::new(min_y, height).is_err(),
|
||||
"{min_y} {height}"
|
||||
);
|
||||
}
|
||||
assert_eq!(tall.section_index(-65), None);
|
||||
assert_eq!(tall.section_index(-64), Some(0));
|
||||
assert_eq!(tall.section_index(2031), Some(130));
|
||||
assert_eq!(tall.section_index(2032), None);
|
||||
assert_eq!(tall.section_y(130), 2016);
|
||||
assert_eq!(
|
||||
LevelShape::new(-64, 384, 384, true),
|
||||
Ok(LevelShape::OVERWORLD)
|
||||
);
|
||||
assert_eq!(LevelShape::new(0, 256, 128, false), Ok(LevelShape::NETHER));
|
||||
assert!(LevelShape::new(0, 256, 257, true).is_err());
|
||||
assert!(LevelShape::new(-60, 384, 384, true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_levels_keep_the_overworld_column() {
|
||||
for shape in [LevelShape::OVERWORLD, LevelShape::NETHER, LevelShape::END] {
|
||||
assert_eq!(shape.column(), ColumnLayout::VANILLA, "{shape:?}");
|
||||
}
|
||||
let tall = LevelShape::new(-64, 2096, 2096, true).unwrap();
|
||||
assert_eq!(tall.column(), ColumnLayout::new(-64, 2096).unwrap());
|
||||
let deep = LevelShape::new(-512, 256, 256, false).unwrap();
|
||||
assert_eq!(deep.column(), ColumnLayout::new(-512, 832).unwrap());
|
||||
assert!(LevelShape::NETHER.has_section(15) && !LevelShape::NETHER.has_section(16));
|
||||
assert!(!LevelShape::NETHER.has_section(-1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tall_column_stores_its_whole_height() {
|
||||
let layout = ColumnLayout::new(-64, 2096).unwrap();
|
||||
let mut chunk = Chunk::with_layout(0, 0, layout);
|
||||
assert_eq!(
|
||||
(chunk.min_y(), chunk.max_y(), chunk.sections.len()),
|
||||
(-64, 2032, 131)
|
||||
);
|
||||
let stone = 1;
|
||||
assert_eq!(chunk.set(5, 2031, 6, stone), 0);
|
||||
assert_eq!(chunk.set(5, 2032, 6, stone), stone, "above the column");
|
||||
assert_eq!(chunk.get(5, 2031, 6), stone);
|
||||
assert_eq!(chunk.get(5, 2032, 6), 0);
|
||||
assert_eq!(chunk.world_surface(5, 6, |v| v == 0), 2032);
|
||||
assert_eq!(chunk.world_surface(0, 0, |v| v == 0), -64);
|
||||
assert_eq!(chunk.highest(0, |y| y < 1000), None);
|
||||
chunk.set(5, 999, 6, stone);
|
||||
assert_eq!(chunk.highest(0, |y| y < 1000), Some(999));
|
||||
assert_eq!(chunk.light(0, 2100, 0), 0xF0);
|
||||
assert_eq!(chunk.light(0, -65, 0), 0);
|
||||
chunk.set_biome_cell(0, 130 * 4 + 3, 0, 9);
|
||||
assert_eq!(chunk.biome(0, 5000, 0), 9);
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,24 @@ pub enum BlockEntity {
|
||||
Shelf(Box<Shelf>),
|
||||
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
|
||||
ChiseledBookshelf(Box<ChiseledBookshelf>),
|
||||
/// `PotentSulfurBlockEntity` (`game/potent_sulfur.rs`).
|
||||
PotentSulfur(PotentSulfur),
|
||||
}
|
||||
/// `PotentSulfurBlockEntity`: the seconds left to the geyser's next change of
|
||||
/// state (`waitingCountdown`, saved as `countdown`) and the game time of its
|
||||
/// last eruption block event, which the client's plume counts from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PotentSulfur {
|
||||
pub waiting_countdown: i32,
|
||||
pub eruption_tick: i64,
|
||||
}
|
||||
impl Default for PotentSulfur {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
waiting_countdown: -1,
|
||||
eruption_tick: -1,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `BrushableBlockEntity`: the loot table is resolved on the first brush.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -228,6 +246,8 @@ pub struct SignText {
|
||||
/// `DyeColor` id.
|
||||
pub color: u8,
|
||||
pub glowing: bool,
|
||||
/// The `click_event` in each line's component style, as saved.
|
||||
pub click: [Option<Tag>; 4],
|
||||
}
|
||||
/// `SignBlockEntity` / `HangingSignBlockEntity` data.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
@@ -236,6 +256,8 @@ pub struct SignData {
|
||||
pub front: SignText,
|
||||
pub back: SignText,
|
||||
pub waxed: bool,
|
||||
/// `allow_op_features`: the sign's click events may run commands.
|
||||
pub allow_op_features: bool,
|
||||
}
|
||||
/// `VibrationInfo`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
|
||||
@@ -82,6 +82,9 @@ impl crate::light::LightWorld for ChunkMap {
|
||||
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: crate::chunk::Light) {
|
||||
self.columns.set_section_light(cx, s, cz, light);
|
||||
}
|
||||
fn layout(&self) -> crate::chunk::ColumnLayout {
|
||||
self.columns.layout()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! from the data-driven `context_int_provider`/`context_float_provider` files.
|
||||
pub use crate::chunk::block_entity::{
|
||||
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
|
||||
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, Shelf, Skull, SkullSkin,
|
||||
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, PotentSulfur, Shelf, Skull, SkullSkin,
|
||||
};
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
@@ -306,6 +306,9 @@ impl BlockEntity {
|
||||
if name == "minecraft:chiseled_bookshelf" {
|
||||
return Some(Self::ChiseledBookshelf(Box::default()));
|
||||
}
|
||||
if name == "minecraft:potent_sulfur" {
|
||||
return Some(Self::PotentSulfur(PotentSulfur::default()));
|
||||
}
|
||||
let size = match name {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
|
||||
n if n.ends_with("copper_chest") => 27,
|
||||
@@ -349,6 +352,7 @@ impl BlockEntity {
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
|
||||
Self::Shelf(_) => "minecraft:shelf",
|
||||
Self::PotentSulfur(_) => "minecraft:potent_sulfur",
|
||||
}
|
||||
}
|
||||
pub fn contents(&self) -> Vec<ItemStack> {
|
||||
@@ -367,6 +371,7 @@ impl BlockEntity {
|
||||
| Self::CommandBlock(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::PotentSulfur(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => Vec::new(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
@@ -391,6 +396,7 @@ impl BlockEntity {
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::PotentSulfur(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => &[],
|
||||
Self::Crafter(c) => &c.items,
|
||||
@@ -419,6 +425,7 @@ impl BlockEntity {
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::PotentSulfur(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => &mut [],
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
|
||||
@@ -423,9 +423,31 @@ pub struct DataStore {
|
||||
pub predicates: BTreeMap<String, serde_json::Value>,
|
||||
/// `item_modifier` files, for `/item modify`.
|
||||
pub item_modifiers: BTreeMap<String, serde_json::Value>,
|
||||
/// The content tables' raw files from the packs after the built-in one:
|
||||
/// `recipe/`, `loot_table/`, `advancement/` and `tags/…/` files, by
|
||||
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
|
||||
/// merge them over what they read from the resource root.
|
||||
pub files: BTreeMap<String, Vec<Vec<u8>>>,
|
||||
/// Functions that failed to parse (id, message), for logging.
|
||||
pub errors: Vec<(String, String)>,
|
||||
}
|
||||
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
|
||||
/// `recipe/`, `loot_table/`, `advancement/` or `tags/<sub>/`, where the id
|
||||
/// keeps the sub path so a nested tag kind stays addressable.
|
||||
fn content_id(file: &str) -> Option<String> {
|
||||
let rest = file.strip_prefix("data/")?;
|
||||
let (ns, rest) = rest.split_once('/')?;
|
||||
let (kind, rest) = rest.split_once('/')?;
|
||||
let kind = match kind {
|
||||
"recipe" | "loot_table" | "advancement" => kind,
|
||||
"tags" => {
|
||||
let (sub, name) = rest.rsplit_once('/')?;
|
||||
return Some(format!("tags/{sub}/{ns}:{}", name.strip_suffix(".json")?));
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!("{kind}/{ns}:{}", rest.strip_suffix(".json")?))
|
||||
}
|
||||
/// `ns:path` for a file `data/<ns>/<kind>/<path>.<ext>`.
|
||||
fn resource_id(file: &str, kind: &str, ext: &str) -> Option<String> {
|
||||
let rest = file.strip_prefix("data/")?;
|
||||
@@ -440,7 +462,22 @@ impl DataStore {
|
||||
pub fn load(packs: &[&DataPack]) -> Self {
|
||||
let mut s = Self::default();
|
||||
for pack in packs {
|
||||
for f in pack.list_kinds(&["function", "item_modifier", "predicate", "tags/function"]) {
|
||||
// `tags` walks the whole tags tree, so a `tags/function` file is
|
||||
// listed by both kinds: handle every file once.
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let builtin = pack.id == "vanilla";
|
||||
for f in pack.list_kinds(&[
|
||||
"advancement",
|
||||
"function",
|
||||
"item_modifier",
|
||||
"loot_table",
|
||||
"predicate",
|
||||
"recipe",
|
||||
"tags",
|
||||
]) {
|
||||
if !seen.insert(f.clone()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(id) = resource_id(&f, "function", ".mcfunction")
|
||||
&& let Some(bytes) = pack.read(&f)
|
||||
{
|
||||
@@ -470,6 +507,14 @@ impl DataStore {
|
||||
&& let Ok(v) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
s.item_modifiers.insert(id, v);
|
||||
} else if !builtin
|
||||
&& let Some(id) = content_id(&f)
|
||||
&& let Some(bytes) = pack.read(&f)
|
||||
{
|
||||
// The built-in pack's content files are the resource
|
||||
// root's own, which the tables read from disk; only the
|
||||
// world's packs come through here.
|
||||
s.files.entry(id).or_default().push(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ struct Out {
|
||||
@location(9) light_color: vec4<f32>,
|
||||
@location(10) @interpolate(flat) weather_rect: vec4<f32>,
|
||||
// The `GLINT_TEXTURING` sample for overlay 7 (item foil), or the model UV
|
||||
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 13 (armour).
|
||||
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 36 (armour).
|
||||
@location(11) glint_uv: vec2<f32>,
|
||||
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
|
||||
@location(12) @interpolate(flat) cull: u32,
|
||||
@@ -254,8 +254,8 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
// Enchantment glint: item.fsh's GLINT path, sampled from
|
||||
// enchanted_glint_item at the `GLINT_TEXTURING` UV.
|
||||
color = glint_over(color, textureSample(glint_tex, glint_sampler, in.glint_uv));
|
||||
} else if in.overlay == 13u {
|
||||
// Enchanted armour: enchanted_glint_armor at the scrolling model UV.
|
||||
} else if in.overlay == 36u {
|
||||
// Enchanted armour (mobs::render::ARMOR_GLINT): enchanted_glint_armor at the scrolling model UV.
|
||||
color = glint_over(
|
||||
color,
|
||||
textureSample(glint_armor_tex, glint_sampler, armor_glint_uv(in.glint_uv)),
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::{
|
||||
graphics::GraphicsApi,
|
||||
hud::{HudBatch, HudRenderer},
|
||||
mesher::{SectionMesh, Sides, TerrainVertex, Vertex},
|
||||
section_mask::SectionMask,
|
||||
sprites::SpriteAnimation,
|
||||
};
|
||||
use glam::{DVec3, Mat4, Vec3, Vec4};
|
||||
@@ -105,8 +106,6 @@ mod view;
|
||||
mod wgsl_prefix;
|
||||
mod world_post;
|
||||
|
||||
/// A column whose every section has its mesh (`Renderer::lod_detail_columns`).
|
||||
const COMPLETE_COLUMN: u32 = (1 << (crate::chunk::HEIGHT / 16)) - 1;
|
||||
/// A section's instance: its camera-relative origin and, in `w`, the step of
|
||||
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
|
||||
/// The world pass again after occlusion culling's test: it loads both
|
||||
@@ -517,7 +516,7 @@ pub struct Renderer {
|
||||
lod_retiring: crate::fast_hash::FxHashSet<SectionKey>,
|
||||
/// Finished tile meshes `upload_lods` has yet to upload.
|
||||
lod_pending: Option<lod::Pending>,
|
||||
detail_columns: crate::fast_hash::FxHashMap<(i32, i32), u32>,
|
||||
detail_columns: crate::fast_hash::FxHashMap<(i32, i32), SectionMask>,
|
||||
/// Bumped whenever a column becomes or stops being complete.
|
||||
detail_revision: u64,
|
||||
arena: arena::Arena,
|
||||
@@ -556,6 +555,9 @@ pub struct Renderer {
|
||||
block_entity_revision: u64,
|
||||
/// VisGraph face connectivity per meshed section (cave culling).
|
||||
occlusion: occlusion::Occlusion,
|
||||
/// The column `occlusion`, `sections` and `detail_columns` count in: the
|
||||
/// level's (`clear_sections`).
|
||||
layout: crate::chunk::ColumnLayout,
|
||||
/// Resolution scale, FXAA and TAA (Horizon settings).
|
||||
world_post: world_post::Post,
|
||||
/// HDR output (Horizon setting): `Some` while the swapchain is HDR.
|
||||
@@ -2744,6 +2746,7 @@ impl Renderer {
|
||||
block_entities: Default::default(),
|
||||
block_entity_revision: next_block_entity_revision(),
|
||||
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
|
||||
layout: crate::chunk::ColumnLayout::VANILLA,
|
||||
world_post,
|
||||
hdr: None,
|
||||
hdr_output,
|
||||
@@ -3180,7 +3183,8 @@ impl Renderer {
|
||||
pub fn block_entity_revision(&self) -> u64 {
|
||||
self.block_entity_revision
|
||||
}
|
||||
pub fn clear_sections(&mut self) {
|
||||
/// Drops every section, for a level whose chunks hold `layout`.
|
||||
pub fn clear_sections(&mut self, layout: crate::chunk::ColumnLayout) {
|
||||
self.block_entities.clear();
|
||||
self.block_entity_revision = next_block_entity_revision();
|
||||
self.sections.clear();
|
||||
@@ -3196,12 +3200,23 @@ impl Renderer {
|
||||
}
|
||||
self.sorted_indices.clear();
|
||||
self.translucency_cursor = 0;
|
||||
self.occlusion.clear();
|
||||
if layout == self.layout {
|
||||
self.occlusion.clear();
|
||||
} else {
|
||||
self.layout = layout;
|
||||
self.occlusion = occlusion::Occlusion::new(layout.min_y(), layout.sections() as i32);
|
||||
self.sections.set_min_y(layout.min_y());
|
||||
}
|
||||
}
|
||||
/// A column whose every section has its mesh (`lod_detail_columns`).
|
||||
fn complete_column(&self) -> SectionMask {
|
||||
SectionMask::below(self.layout.sections())
|
||||
}
|
||||
pub fn lod_detail_columns(&self) -> impl Iterator<Item = (i32, i32)> + '_ {
|
||||
let complete = self.complete_column();
|
||||
self.detail_columns
|
||||
.iter()
|
||||
.filter_map(move |(&column, &mask)| (mask == COMPLETE_COLUMN).then_some(column))
|
||||
.filter_map(move |(&column, &mask)| (mask == complete).then_some(column))
|
||||
}
|
||||
/// Changes whenever `lod_detail_columns` does.
|
||||
pub fn lod_detail_revision(&self) -> u64 {
|
||||
@@ -3210,16 +3225,22 @@ impl Renderer {
|
||||
/// Whether the section at `key` has its mesh, for `lod_detail_columns`.
|
||||
fn set_detail(&mut self, key: SectionKey, meshed: bool) {
|
||||
let column = (key.0, key.2);
|
||||
let mask = self.detail_columns.get(&column).copied().unwrap_or(0);
|
||||
let next = if meshed {
|
||||
mask | 1 << key.1
|
||||
let mask = self
|
||||
.detail_columns
|
||||
.get(&column)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
let mut next = mask;
|
||||
if meshed {
|
||||
next.insert(key.1 as usize);
|
||||
} else {
|
||||
mask & !(1 << key.1)
|
||||
};
|
||||
if (mask == COMPLETE_COLUMN) != (next == COMPLETE_COLUMN) {
|
||||
next.remove(key.1 as usize);
|
||||
}
|
||||
let complete = self.complete_column();
|
||||
if (mask == complete) != (next == complete) {
|
||||
self.detail_revision += 1;
|
||||
}
|
||||
if next == 0 {
|
||||
if next.is_empty() {
|
||||
self.detail_columns.remove(&column);
|
||||
} else {
|
||||
self.detail_columns.insert(column, next);
|
||||
@@ -3232,12 +3253,12 @@ impl Renderer {
|
||||
let mut released = Vec::new();
|
||||
let mut block_entities = false;
|
||||
for (x, z) in columns {
|
||||
if self.detail_columns.remove(&(x, z)) == Some(COMPLETE_COLUMN) {
|
||||
if self.detail_columns.remove(&(x, z)) == Some(self.complete_column()) {
|
||||
self.detail_revision += 1;
|
||||
}
|
||||
self.occlusion.remove_column(x, z);
|
||||
let retiring = !self.lod_cutout.is_empty() && self.lod_cutout.contains(&(x, z));
|
||||
for sy in 0..crate::chunk::SECTIONS as i32 {
|
||||
for sy in 0..self.layout.sections() as i32 {
|
||||
let key = (x, sy, z);
|
||||
block_entities |= self.block_entities.remove(&key).is_some();
|
||||
if retiring {
|
||||
@@ -3927,7 +3948,7 @@ impl Renderer {
|
||||
planes: frustum(cull_proj),
|
||||
};
|
||||
let camera = frame.camera;
|
||||
let camera_section = camera_section(camera);
|
||||
let camera_section = camera_section(camera, self.layout.min_y());
|
||||
// Solid quads facing away from the eye are left out: the bob moves
|
||||
// the eye off `camera`, so it is found from the projection.
|
||||
let eye = projection_center(cull_proj).filter(|_| self.sides_on);
|
||||
@@ -4141,7 +4162,7 @@ impl Renderer {
|
||||
let scheduled = (i + count - self.translucency_cursor) % count < budget;
|
||||
let origin = [
|
||||
v.key.0 * 16,
|
||||
crate::chunk::MIN_Y + v.key.1 * 16,
|
||||
self.layout.min_y() + v.key.1 * 16,
|
||||
v.key.2 * 16,
|
||||
];
|
||||
if (!s.sort.is_sorted() || nearby || scheduled)
|
||||
@@ -4244,7 +4265,7 @@ impl Renderer {
|
||||
{
|
||||
let origin = [
|
||||
v.key.0 * 16,
|
||||
crate::chunk::MIN_Y + v.key.1 * 16,
|
||||
self.layout.min_y() + v.key.1 * 16,
|
||||
v.key.2 * 16,
|
||||
];
|
||||
if let Some(indices) =
|
||||
@@ -5420,6 +5441,16 @@ impl Renderer {
|
||||
if let (Some(hdr), Some(f)) = (&self.hdr, &surface_frame) {
|
||||
hdr.present(&self.queue, &mut encoder, &f.texture, !frame.title);
|
||||
}
|
||||
// With HDR on, a full-frame screenshot gets an HDR10 copy beside it.
|
||||
let hdr_readback = readback
|
||||
.as_ref()
|
||||
.filter(|(request, _)| request.size.is_none())
|
||||
.zip(self.hdr.as_ref())
|
||||
.and_then(|(_, hdr)| {
|
||||
let texture = hdr.capture(&self.device, &self.queue, &mut encoder, !frame.title)?;
|
||||
let size = [self.config.width, self.config.height];
|
||||
Some(copy_frame(&self.device, &mut encoder, &texture, size))
|
||||
});
|
||||
let commands = crate::spans::time("encode", || encoder.finish());
|
||||
let first_frame = self.first_frame == FirstFrame::Submit;
|
||||
if first_frame {
|
||||
@@ -5438,6 +5469,16 @@ impl Renderer {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if let Some((request, buffer)) = readback {
|
||||
if let Some(hdr_buffer) = hdr_readback {
|
||||
let hdr_path = hdr_screenshot_path(&request.path);
|
||||
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
|
||||
Ok(()) => log::info!("Saved HDR screenshot to {}", hdr_path.display()),
|
||||
Err(e) => log::warn!(
|
||||
"Horizon renderer: HDR screenshot {}: {e}",
|
||||
hdr_path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&request, &buffer)
|
||||
.map_err(|e| e.to_string()),
|
||||
@@ -5546,11 +5587,8 @@ impl Renderer {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn write_screenshot(
|
||||
&self,
|
||||
request: &ScreenshotRequest,
|
||||
buffer: &wgpu::Buffer,
|
||||
) -> Result<[u32; 2]> {
|
||||
/// The pixels `copy_frame` read back into `buffer`, 4 bytes each, row after row.
|
||||
fn read_frame(&self, buffer: &wgpu::Buffer) -> Result<Vec<[u8; 4]>> {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let _ = sender.send(r);
|
||||
@@ -5563,28 +5601,34 @@ impl Renderer {
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (width, height, row) = (
|
||||
self.config.width,
|
||||
self.config.height,
|
||||
self.config.width as usize,
|
||||
self.config.height as usize,
|
||||
padded_row(self.config.width) as usize,
|
||||
);
|
||||
let mut pixels = Vec::with_capacity(width * height);
|
||||
{
|
||||
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
|
||||
for y in 0..height {
|
||||
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
|
||||
}
|
||||
}
|
||||
buffer.unmap();
|
||||
Ok(pixels)
|
||||
}
|
||||
fn write_screenshot(
|
||||
&self,
|
||||
request: &ScreenshotRequest,
|
||||
buffer: &wgpu::Buffer,
|
||||
) -> Result<[u32; 2]> {
|
||||
let bgra = matches!(
|
||||
self.config.format.remove_srgb_suffix(),
|
||||
wgpu::TextureFormat::Bgra8Unorm
|
||||
);
|
||||
let mut pixels = Vec::with_capacity((width * height * 4) as usize);
|
||||
{
|
||||
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
|
||||
for y in 0..height as usize {
|
||||
for px in data[y * row..y * row + width as usize * 4]
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
{
|
||||
let (r, b) = if bgra { (px[2], px[0]) } else { (px[0], px[2]) };
|
||||
pixels.extend_from_slice(&[r, px[1], b, 255]);
|
||||
}
|
||||
}
|
||||
let mut pixels = Vec::with_capacity((self.config.width * self.config.height * 4) as usize);
|
||||
for px in self.read_frame(buffer)? {
|
||||
let (r, b) = if bgra { (px[2], px[0]) } else { (px[0], px[2]) };
|
||||
pixels.extend_from_slice(&[r, px[1], b, 255]);
|
||||
}
|
||||
buffer.unmap();
|
||||
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
|
||||
// is the frame's centre square averaged down to its size.
|
||||
let (width, height, pixels) = match request.size {
|
||||
@@ -5605,6 +5649,50 @@ impl Renderer {
|
||||
encoder.write_header()?.write_image_data(&pixels)?;
|
||||
Ok([width, height])
|
||||
}
|
||||
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
|
||||
/// third edition), with the peak and paper white as its content light levels (cLLI).
|
||||
fn write_hdr_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<()> {
|
||||
let Some(hdr) = &self.hdr else {
|
||||
return Ok(());
|
||||
};
|
||||
let pixels = hdr10_to_rgb16(&self.read_frame(buffer)?);
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::io::BufWriter::new(std::fs::File::create(path)?),
|
||||
self.config.width,
|
||||
self.config.height,
|
||||
);
|
||||
encoder.set_color(png::ColorType::Rgb);
|
||||
encoder.set_depth(png::BitDepth::Sixteen);
|
||||
let mut writer = encoder.write_header()?;
|
||||
// BT.2020 primaries, PQ, RGB, full range.
|
||||
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
|
||||
let nits = |n: f32| ((n * 10_000.0) as u32).to_be_bytes();
|
||||
let peak = hdr.peak.max(hdr.paper_white);
|
||||
writer.write_chunk(
|
||||
png::chunk::cLLI,
|
||||
&[nits(peak), nits(hdr.paper_white.min(peak))].concat(),
|
||||
)?;
|
||||
writer.write_image_data(&pixels)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
/// `screenshots/2026-10-28_12.00.00.png` -> `screenshots/2026-10-28_12.00.00-hdr.png`.
|
||||
fn hdr_screenshot_path(path: &std::path::Path) -> PathBuf {
|
||||
let stem = path.file_stem().unwrap_or_default().to_string_lossy();
|
||||
path.with_file_name(format!("{stem}-hdr.png"))
|
||||
}
|
||||
/// `Rgb10a2Unorm` pixels as 16-bit RGB, big-endian as PNG stores it: each 10-bit value
|
||||
/// with its top bits repeated below, so 0 stays 0 and 1023 becomes 65535.
|
||||
fn hdr10_to_rgb16(pixels: &[[u8; 4]]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(pixels.len() * 6);
|
||||
for &px in pixels {
|
||||
let v = u32::from_le_bytes(px);
|
||||
for shift in [0, 10, 20] {
|
||||
let c = (v >> shift & 0x3ff) as u16;
|
||||
out.extend_from_slice(&(c << 6 | c >> 4).to_be_bytes());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Centre-crop the frame to a square and average it down to `size`.
|
||||
pub(crate) fn square_downscale(pixels: &[u8], width: u32, height: u32, size: [u32; 2]) -> Vec<u8> {
|
||||
@@ -5678,10 +5766,10 @@ impl Frustum {
|
||||
/// are GREATER_THAN_OR_EQUAL), which keeps far faces a hair apart, like
|
||||
/// fire_side's 0.01 pixel in front of its block, from z-fighting.
|
||||
/// The section the camera is in.
|
||||
fn camera_section(camera: DVec3) -> SectionKey {
|
||||
fn camera_section(camera: DVec3, min_y: i32) -> SectionKey {
|
||||
(
|
||||
(camera.x.floor() as i32) >> 4,
|
||||
(camera.y.floor() as i32 - crate::chunk::MIN_Y) >> 4,
|
||||
(camera.y.floor() as i32 - min_y) >> 4,
|
||||
(camera.z.floor() as i32) >> 4,
|
||||
)
|
||||
}
|
||||
@@ -5958,6 +6046,17 @@ mod tests {
|
||||
assert_eq!(blend.alpha.dst_factor, wgpu::BlendFactor::Zero);
|
||||
}
|
||||
#[test]
|
||||
fn hdr_screenshots_keep_every_10_bit_step() {
|
||||
// Rgb10a2Unorm: red in the low 10 bits, then green, then blue.
|
||||
let px = |r: u32, g: u32, b: u32| (r | g << 10 | b << 20 | 3 << 30).to_le_bytes();
|
||||
let out = super::hdr10_to_rgb16(&[px(0, 1023, 512)]);
|
||||
assert_eq!(out, [0, 0, 0xff, 0xff, 0x80, 0x20]);
|
||||
assert_eq!(
|
||||
super::hdr_screenshot_path(std::path::Path::new("screenshots/2026-10-28_12.00.00.png")),
|
||||
std::path::Path::new("screenshots/2026-10-28_12.00.00-hdr.png")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn shader_globals_match_the_rust_layout() {
|
||||
// terrain.wgsl and entity.wgsl bind the same buffer: same fields, same order.
|
||||
let fields = |source: &str| -> Vec<String> {
|
||||
|
||||
@@ -102,6 +102,10 @@ pub struct Hdr {
|
||||
/// output keeps the hand at the world's brightness.
|
||||
hand_mark: wgpu::RenderPipeline,
|
||||
params: wgpu::Buffer,
|
||||
/// The same pass drawing HDR10 for a screenshot (`capture`), with its own parameters,
|
||||
/// so it can differ from the swapchain's in one frame.
|
||||
capture_pipeline: wgpu::RenderPipeline,
|
||||
capture_params: wgpu::Buffer,
|
||||
pub output: Output,
|
||||
/// The frame's format: what every other pipeline draws in.
|
||||
format: wgpu::TextureFormat,
|
||||
@@ -156,31 +160,33 @@ impl Hdr {
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: output.format(),
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let pipeline_for = |format| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let alpha_pass = |label, blend| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
@@ -223,18 +229,22 @@ impl Hdr {
|
||||
alpha: scale_alpha,
|
||||
}),
|
||||
);
|
||||
let params = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 80,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let params = || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 80,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
Self {
|
||||
layout,
|
||||
pipeline,
|
||||
pipeline: pipeline_for(output.format()),
|
||||
clear_alpha,
|
||||
hand_mark,
|
||||
params,
|
||||
params: params(),
|
||||
capture_pipeline: pipeline_for(Output::Pq.format()),
|
||||
capture_params: params(),
|
||||
output,
|
||||
format,
|
||||
targets: None,
|
||||
@@ -375,6 +385,64 @@ impl Hdr {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
};
|
||||
queue.write_buffer(
|
||||
&self.params,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, self.output == Output::Pq)),
|
||||
);
|
||||
Self::draw(encoder, &self.pipeline, &t.group, target);
|
||||
}
|
||||
|
||||
/// The frame `present` writes, as HDR10 (BT.2020, PQ, 10 bits a channel) whatever
|
||||
/// the swapchain is, in a texture of its own for a screenshot (`Rgb10a2Unorm`).
|
||||
pub fn capture(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
world: bool,
|
||||
) -> Option<wgpu::Texture> {
|
||||
let t = self.targets.as_ref()?;
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon HDR screenshot"),
|
||||
size: t.frame.size(),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: Output::Pq.format(),
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon HDR screenshot bindings"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&view(&t.frame)),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&view(&t.world)),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.capture_params.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
queue.write_buffer(
|
||||
&self.capture_params,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, true)),
|
||||
);
|
||||
Self::draw(encoder, &self.capture_pipeline, &group, &texture);
|
||||
Some(texture)
|
||||
}
|
||||
|
||||
/// hdr.wgsl's `Params`.
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 20] {
|
||||
let paper = self.paper_white.max(1.0);
|
||||
let mut params = [0.0; 20];
|
||||
params[..5].copy_from_slice(&[
|
||||
@@ -382,7 +450,7 @@ impl Hdr {
|
||||
(self.peak / paper).max(1.0),
|
||||
f32::from(u8::from(world)),
|
||||
f32::from(u8::from(self.debug)),
|
||||
f32::from(u8::from(self.output == Output::Pq)),
|
||||
f32::from(u8::from(pq)),
|
||||
]);
|
||||
params[5] = self.black.clamp(0.0, paper);
|
||||
params[6] = self.ui.clamp(0.25, 1.0);
|
||||
@@ -391,7 +459,15 @@ impl Hdr {
|
||||
params[12 + i] = t.start;
|
||||
params[16 + i] = t.full;
|
||||
}
|
||||
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(¶ms));
|
||||
params
|
||||
}
|
||||
|
||||
fn draw(
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
group: &wgpu::BindGroup,
|
||||
target: &wgpu::Texture,
|
||||
) {
|
||||
let view = target.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
@@ -407,8 +483,8 @@ impl Hdr {
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &t.group, &[]);
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ impl Column {
|
||||
/// Values of type `T` per section key, in a dense list with stable keys and
|
||||
/// slots that move only when a section is removed.
|
||||
pub struct Resident<T> {
|
||||
/// The minimum Y of section index 0 (the level's column).
|
||||
min_y: i32,
|
||||
items: Vec<(SectionKey, T)>,
|
||||
slots: FxHashMap<SectionKey, u32>,
|
||||
columns: Vec<Column>,
|
||||
@@ -36,6 +38,7 @@ pub struct Resident<T> {
|
||||
impl<T> Default for Resident<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_y: crate::chunk::MIN_Y,
|
||||
items: Vec::new(),
|
||||
slots: FxHashMap::default(),
|
||||
columns: Vec::new(),
|
||||
@@ -61,6 +64,10 @@ impl<T> Resident<T> {
|
||||
pub fn get_mut(&mut self, slot: u32) -> &mut T {
|
||||
&mut self.items[slot as usize].1
|
||||
}
|
||||
/// Sections from now on count from `min_y` (a level of another column).
|
||||
pub fn set_min_y(&mut self, min_y: i32) {
|
||||
self.min_y = min_y;
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.items.clear();
|
||||
self.slots.clear();
|
||||
@@ -144,10 +151,10 @@ pub struct Visible {
|
||||
}
|
||||
|
||||
/// The camera-relative origin of the section at `key`.
|
||||
fn origin(key: SectionKey, camera: DVec3) -> DVec3 {
|
||||
fn origin(key: SectionKey, min_y: i32, camera: DVec3) -> DVec3 {
|
||||
DVec3::new(
|
||||
f64::from(key.0 * 16),
|
||||
f64::from(crate::chunk::MIN_Y + key.1 * 16),
|
||||
f64::from(min_y + key.1 * 16),
|
||||
f64::from(key.2 * 16),
|
||||
) - camera
|
||||
}
|
||||
@@ -169,18 +176,19 @@ fn in_view(key: SectionKey, camera: DVec3, radius: i32, frustum: &Frustum) -> Op
|
||||
if !in_view_distance(camera_column(camera), radius, key.0, key.2) {
|
||||
return None;
|
||||
}
|
||||
in_frustum(key, camera, frustum, false)
|
||||
in_frustum(key, crate::chunk::MIN_Y, camera, frustum, false)
|
||||
}
|
||||
|
||||
/// `in_view` without the distance test; `inside`: the section's column is
|
||||
/// entirely inside the frustum, so the section is too.
|
||||
fn in_frustum(
|
||||
key: SectionKey,
|
||||
min_y: i32,
|
||||
camera: DVec3,
|
||||
frustum: &Frustum,
|
||||
inside: bool,
|
||||
) -> Option<(f64, Vec3)> {
|
||||
let origin = origin(key, camera);
|
||||
let origin = origin(key, min_y, camera);
|
||||
let min = origin.as_vec3();
|
||||
if !inside && !frustum.intersects(min, min + Vec3::splat(16.0)) {
|
||||
return None;
|
||||
@@ -216,8 +224,8 @@ impl<T> Resident<T> {
|
||||
if !near && !beyond(column.x, column.z) {
|
||||
continue;
|
||||
}
|
||||
let min = origin((column.x, column.low, column.z), camera).as_vec3();
|
||||
let top = origin((column.x, column.high, column.z), camera).as_vec3();
|
||||
let min = origin((column.x, column.low, column.z), self.min_y, camera).as_vec3();
|
||||
let top = origin((column.x, column.high, column.z), self.min_y, camera).as_vec3();
|
||||
let max = top + Vec3::splat(16.0);
|
||||
let inside = frustum.contains(min, max);
|
||||
if !inside && !frustum.intersects(min, max) {
|
||||
@@ -228,7 +236,7 @@ impl<T> Resident<T> {
|
||||
if near && !reachable(key) {
|
||||
continue;
|
||||
}
|
||||
if let Some((d, o)) = in_frustum(key, camera, frustum, inside) {
|
||||
if let Some((d, o)) = in_frustum(key, self.min_y, camera, frustum, inside) {
|
||||
out.push(Visible {
|
||||
distance: d.to_bits(),
|
||||
key,
|
||||
@@ -286,7 +294,7 @@ mod tests {
|
||||
if near {
|
||||
in_view(k, camera, radius, &frustum).filter(|_| reachable(k))
|
||||
} else if beyond(k.0, k.2) {
|
||||
in_frustum(k, camera, &frustum, false)
|
||||
in_frustum(k, crate::chunk::MIN_Y, camera, &frustum, false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -329,8 +337,8 @@ mod tests {
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
let min = origin((c.x, c.low, c.z), camera).as_vec3();
|
||||
let top = origin((c.x, c.high, c.z), camera).as_vec3();
|
||||
let min = origin((c.x, c.low, c.z), crate::chunk::MIN_Y, camera).as_vec3();
|
||||
let top = origin((c.x, c.high, c.z), crate::chunk::MIN_Y, camera).as_vec3();
|
||||
frustum.contains(min, top + Vec3::splat(16.0))
|
||||
})
|
||||
.count();
|
||||
|
||||
@@ -59,10 +59,12 @@ pub mod packs;
|
||||
pub mod par;
|
||||
pub mod particles;
|
||||
pub mod poi_sections;
|
||||
pub mod potent_sulfur;
|
||||
pub mod providers;
|
||||
pub mod screen_draw;
|
||||
pub mod screen_effects;
|
||||
pub mod sculk;
|
||||
pub mod section_mask;
|
||||
pub mod settings;
|
||||
pub mod shader_pack_list;
|
||||
pub mod signs;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use crate::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, HEIGHT, Light, MIN_Y, SECTIONS, Section, index},
|
||||
chunk::{Chunk, ColumnLayout, Light, Section, index},
|
||||
};
|
||||
use horizon_core::geometry::{Direction, ShapeOp, VoxelShape};
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
@@ -273,7 +273,7 @@ pub fn light_chunk(
|
||||
tables: &LightTables,
|
||||
sky_light: bool,
|
||||
) -> Vec<Light> {
|
||||
region[1][1].expect("centre chunk");
|
||||
let sections = region[1][1].expect("centre chunk").sections.len();
|
||||
// Highest occupied section in the region bounds the work.
|
||||
let mut top_section = 0;
|
||||
for chunk in region.iter().flatten().flatten() {
|
||||
@@ -286,7 +286,7 @@ pub fn light_chunk(
|
||||
}
|
||||
// One extra section above the highest occupied one: block light rises
|
||||
// up to 14 blocks into it (sky light there is always 15).
|
||||
let h = ((top_section + 1) * 16).min(HEIGHT as usize);
|
||||
let h = ((top_section + 1) * 16).min(sections * 16);
|
||||
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
|
||||
// Packed cells (`type << 4 | dampening`), not state ids: the flood fill
|
||||
// then never touches the large `LightTables::cells` table.
|
||||
@@ -422,14 +422,21 @@ pub fn light_chunk(
|
||||
};
|
||||
spread(&mut sky, &mut sky_queue);
|
||||
spread(&mut block, &mut queue);
|
||||
pack_lighting(h, &sky, &block, sky_light)
|
||||
pack_lighting(sections, h, &sky, &block, sky_light)
|
||||
}
|
||||
|
||||
/// Extract just the centre column's light; never copy worker snapshot sections.
|
||||
fn pack_lighting(h: usize, sky: &[u8], block: &[u8], sky_light: bool) -> Vec<Light> {
|
||||
/// Extract just the centre column's light (`sections` of it); never copy
|
||||
/// worker snapshot sections.
|
||||
fn pack_lighting(
|
||||
sections: usize,
|
||||
h: usize,
|
||||
sky: &[u8],
|
||||
block: &[u8],
|
||||
sky_light: bool,
|
||||
) -> Vec<Light> {
|
||||
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
|
||||
let open = if sky_light { 0xF0 } else { 0x00 };
|
||||
(0..SECTIONS)
|
||||
(0..sections)
|
||||
.map(|si| {
|
||||
if si * 16 >= h {
|
||||
return Light::Uniform(open);
|
||||
@@ -467,6 +474,7 @@ pub fn apply_lighting(chunk: &mut Chunk, lights: Vec<Light>) {
|
||||
/// `apply_lighting` for a chunk lit before: writes only the sections whose
|
||||
/// light changed and adds the cells that changed to `changed`.
|
||||
pub fn apply_relighting(chunk: &mut Chunk, lights: Vec<Light>, changed: &mut LightChanges) {
|
||||
changed.keyed(chunk.layout());
|
||||
for (sy, (section, light)) in chunk.sections.iter_mut().zip(lights).enumerate() {
|
||||
let old = §ion.light;
|
||||
if let (Light::Uniform(a), Light::Uniform(b)) = (old, &light)
|
||||
@@ -506,7 +514,8 @@ pub fn light_from_layers<L: AsRef<[u8]>>(
|
||||
mut above: [u8; 256],
|
||||
sky_light: bool,
|
||||
) {
|
||||
for (si, (sky, block)) in layers.iter().enumerate().take(SECTIONS).rev() {
|
||||
let sections = chunk.sections.len();
|
||||
for (si, (sky, block)) in layers.iter().enumerate().take(sections).rev() {
|
||||
let sky = sky.as_ref().filter(|_| sky_light);
|
||||
let section = Arc::make_mut(&mut chunk.sections[si]);
|
||||
if sky.is_none() && block.is_none() && above.iter().all(|&v| v == above[0]) {
|
||||
@@ -539,7 +548,8 @@ pub fn update_light_layers<L: AsRef<[u8]>>(
|
||||
sky_light: bool,
|
||||
) -> Vec<usize> {
|
||||
let mut changed = Vec::new();
|
||||
for (si, (sky, block)) in layers.iter().enumerate().take(SECTIONS) {
|
||||
let sections = chunk.sections.len();
|
||||
for (si, (sky, block)) in layers.iter().enumerate().take(sections) {
|
||||
let sky = sky.as_ref().filter(|_| sky_light);
|
||||
if sky.is_none() && block.is_none() {
|
||||
continue;
|
||||
@@ -569,8 +579,15 @@ pub trait LightWorld {
|
||||
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<Section>>;
|
||||
/// Replace the light of a section `section` returned.
|
||||
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: Light);
|
||||
/// The column the world's chunks hold (its level's).
|
||||
fn layout(&self) -> ColumnLayout;
|
||||
}
|
||||
impl<S: std::hash::BuildHasher> LightWorld for HashMap<(i32, i32), Arc<Chunk>, S> {
|
||||
fn layout(&self) -> ColumnLayout {
|
||||
self.values()
|
||||
.next()
|
||||
.map_or(ColumnLayout::VANILLA, |c| c.layout())
|
||||
}
|
||||
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<Section>> {
|
||||
self.get(&(cx, cz))
|
||||
.filter(|c| c.lit)
|
||||
@@ -587,6 +604,7 @@ impl<S: std::hash::BuildHasher> LightWorld for HashMap<(i32, i32), Arc<Chunk>, S
|
||||
/// gets written is copied out on the first write and stored back by `finish`.
|
||||
struct Region<'w> {
|
||||
world: &'w mut dyn LightWorld,
|
||||
layout: ColumnLayout,
|
||||
slots: Vec<Slot>,
|
||||
/// Section key (cx, s, cz) to slot, `NONE` where the chunk isn't lit.
|
||||
index: FxHashMap<Pos, u32>,
|
||||
@@ -602,6 +620,7 @@ const NONE: u32 = u32::MAX;
|
||||
impl<'w> Region<'w> {
|
||||
fn new(world: &'w mut dyn LightWorld) -> Self {
|
||||
Self {
|
||||
layout: world.layout(),
|
||||
world,
|
||||
slots: Vec::new(),
|
||||
index: FxHashMap::default(),
|
||||
@@ -610,10 +629,8 @@ impl<'w> Region<'w> {
|
||||
}
|
||||
#[inline]
|
||||
fn slot(&mut self, x: i32, y: i32, z: i32) -> Option<usize> {
|
||||
if !(MIN_Y..MIN_Y + HEIGHT).contains(&y) {
|
||||
return None;
|
||||
}
|
||||
let key = (x >> 4, (y - MIN_Y) >> 4, z >> 4);
|
||||
let s = self.layout.section_index(y)?;
|
||||
let key = (x >> 4, s as i32, z >> 4);
|
||||
if self.last.0 != key {
|
||||
self.last = (key, self.load(key));
|
||||
}
|
||||
@@ -702,12 +719,20 @@ type Pos = (i32, i32, i32);
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct LightChanges {
|
||||
sections: FxHashMap<Pos, u32>,
|
||||
/// The column the section indexes count in.
|
||||
layout: ColumnLayout,
|
||||
}
|
||||
impl LightChanges {
|
||||
/// Changes in chunks of `layout`: what the relighter or a light job adds
|
||||
/// after this (they all come from one level).
|
||||
fn keyed(&mut self, layout: ColumnLayout) {
|
||||
debug_assert!(self.sections.is_empty() || self.layout == layout);
|
||||
self.layout = layout;
|
||||
}
|
||||
/// A cell whose stored light changed.
|
||||
#[inline]
|
||||
pub fn push(&mut self, (x, y, z): Pos) {
|
||||
let key = (x >> 4, (y - MIN_Y) >> 4, z >> 4);
|
||||
let key = (x >> 4, (y - self.layout.min_y()) >> 4, z >> 4);
|
||||
*self.sections.entry(key).or_default() |= 1 << Self::class(x, y, z);
|
||||
}
|
||||
/// Cells of section `key` (x, section index, z) in the given classes.
|
||||
@@ -732,7 +757,7 @@ impl LightChanges {
|
||||
self.sections.keys().copied()
|
||||
}
|
||||
/// Every section (x, section index, z) whose cells lie within one block
|
||||
/// of a changed cell, section indexes outside `0..SECTIONS` left out (a
|
||||
/// of a changed cell, section indexes outside the column left out (a
|
||||
/// section next to several changed ones comes once for each).
|
||||
pub fn dirty_sections(&self, mut out: impl FnMut(Pos)) {
|
||||
// Per class, the bit set of neighbour offsets (dx, dy, dz in -1..=1,
|
||||
@@ -759,6 +784,7 @@ impl LightChanges {
|
||||
}
|
||||
reach
|
||||
};
|
||||
let sections = self.layout.sections() as i32;
|
||||
for (&(x, sy, z), &classes) in &self.sections {
|
||||
let mut offsets = 0;
|
||||
let mut rest = classes;
|
||||
@@ -770,7 +796,7 @@ impl LightChanges {
|
||||
let o = offsets.trailing_zeros() as i32;
|
||||
offsets &= offsets - 1;
|
||||
let s = sy + o / 3 % 3 - 1;
|
||||
if (0..SECTIONS as i32).contains(&s) {
|
||||
if (0..sections).contains(&s) {
|
||||
out((x + o % 3 - 1, s, z + o / 9 - 1));
|
||||
}
|
||||
}
|
||||
@@ -794,11 +820,12 @@ fn set_level(world: &mut Region, sky: bool, p: Pos, value: u8) {
|
||||
/// `ChunkSkyLightSources`: the Y of the upper block of the highest occluded
|
||||
/// edge in the column (every block at or above it is a sky source).
|
||||
fn lowest_sky_source(world: &mut Region, tables: &LightTables, x: i32, z: i32) -> i32 {
|
||||
let mut y = MIN_Y + HEIGHT - 1;
|
||||
let (min_y, max_y) = (world.layout.min_y(), world.layout.max_y());
|
||||
let mut y = max_y - 1;
|
||||
let Some(mut top) = world.state(x, y, z) else {
|
||||
return MIN_Y;
|
||||
return min_y;
|
||||
};
|
||||
while y > MIN_Y {
|
||||
while y > min_y {
|
||||
let Some(bottom) = world.state(x, y - 1, z) else {
|
||||
break;
|
||||
};
|
||||
@@ -808,7 +835,7 @@ fn lowest_sky_source(world: &mut Region, tables: &LightTables, x: i32, z: i32) -
|
||||
top = bottom;
|
||||
y -= 1;
|
||||
}
|
||||
MIN_Y
|
||||
min_y
|
||||
}
|
||||
/// Vanilla's `checkBlock` + `runLightUpdates` for changed positions on
|
||||
/// resident, lit chunks: clear the light that may have come through or from
|
||||
@@ -825,6 +852,7 @@ pub fn relight(
|
||||
changed: &mut LightChanges,
|
||||
) {
|
||||
let mut region = Region::new(world);
|
||||
changed.keyed(region.layout);
|
||||
relight_region(&mut region, tables, sky_light, positions, force, changed);
|
||||
region.finish();
|
||||
}
|
||||
@@ -840,6 +868,7 @@ fn relight_region(
|
||||
relight_layer(world, tables, false, positions.to_vec(), force, changed)
|
||||
});
|
||||
if sky_light {
|
||||
let (min_y, max_y) = (world.layout.min_y(), world.layout.max_y());
|
||||
let mut seeds = positions.to_vec();
|
||||
let mut columns: FxHashMap<(i32, i32), i32> = FxHashMap::default();
|
||||
crate::spans::time("lt_columns", || {
|
||||
@@ -851,13 +880,13 @@ fn relight_region(
|
||||
columns.insert((x, z), source);
|
||||
// Cells whose source status changed: non-15 at or above the new
|
||||
// lowest source, and old 15s (sources) below it.
|
||||
for y in source..MIN_Y + HEIGHT {
|
||||
for y in source..max_y {
|
||||
if level(world, true, (x, y, z)).is_some_and(|l| l != 15) {
|
||||
seeds.push((x, y, z));
|
||||
}
|
||||
}
|
||||
let mut y = source - 1;
|
||||
while y >= MIN_Y && level(world, true, (x, y, z)) == Some(15) {
|
||||
while y >= min_y && level(world, true, (x, y, z)) == Some(15) {
|
||||
seeds.push((x, y, z));
|
||||
y -= 1;
|
||||
}
|
||||
@@ -977,6 +1006,7 @@ fn relight_layer(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::chunk::{HEIGHT, MIN_Y, SECTIONS};
|
||||
|
||||
const AIR: u32 = 0;
|
||||
const STONE: u32 = 1;
|
||||
@@ -1004,11 +1034,18 @@ mod tests {
|
||||
)
|
||||
}
|
||||
fn world(edit: impl Fn(&mut Chunk)) -> HashMap<(i32, i32), Arc<Chunk>> {
|
||||
world_of(crate::chunk::ColumnLayout::VANILLA, edit)
|
||||
}
|
||||
/// Lit chunks around (0, 0) of `layout`, stone up to y 0.
|
||||
fn world_of(
|
||||
layout: crate::chunk::ColumnLayout,
|
||||
edit: impl Fn(&mut Chunk),
|
||||
) -> HashMap<(i32, i32), Arc<Chunk>> {
|
||||
let mut chunks = HashMap::new();
|
||||
for z in -1..=1 {
|
||||
for x in -1..=1 {
|
||||
let mut c = Chunk::empty(x, z);
|
||||
for y in MIN_Y..0 {
|
||||
let mut c = Chunk::with_layout(x, z, layout);
|
||||
for y in layout.min_y()..0 {
|
||||
for i in 0..256 {
|
||||
c.set(i & 15, y, i >> 4, STONE);
|
||||
}
|
||||
@@ -1218,6 +1255,66 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn a_tall_column_is_lit_to_its_top() {
|
||||
let layout = crate::chunk::ColumnLayout::new(-64, 2096).unwrap();
|
||||
let mut chunk = Chunk::with_layout(0, 0, layout);
|
||||
for i in 0..256 {
|
||||
chunk.set(i & 15, 1600, i >> 4, STONE);
|
||||
}
|
||||
chunk.set(8, 1500, 8, TORCH);
|
||||
let region = [[None; 3], [None, Some(&chunk), None], [None; 3]];
|
||||
let lights = light_chunk(®ion, &tables(), true);
|
||||
assert_eq!(lights.len(), 131);
|
||||
apply_lighting(&mut chunk, lights);
|
||||
assert_eq!(chunk.light(8, 1500, 8) & 15, 14);
|
||||
assert_eq!(chunk.light(8, 1501, 8) & 15, 13);
|
||||
assert_eq!(chunk.light(8, 1601, 8) >> 4, 15);
|
||||
assert_eq!(chunk.light(8, 1599, 8) >> 4, 0, "under the roof");
|
||||
assert_eq!(chunk.light(8, 2031, 8), 0xF0);
|
||||
}
|
||||
#[test]
|
||||
fn a_tall_column_relights_as_its_full_light_and_keys_its_sections() {
|
||||
let layout = crate::chunk::ColumnLayout::new(-64, 2096).unwrap();
|
||||
let mut w = world_of(layout, |c| {
|
||||
for i in 0..256 {
|
||||
c.set(i & 15, 1900, i >> 4, STONE);
|
||||
}
|
||||
});
|
||||
let edits = [
|
||||
((8, 1800, 8), TORCH),
|
||||
((8, 1900, 8), AIR),
|
||||
((3, 2020, 3), TORCH),
|
||||
];
|
||||
let mut changed = LightChanges::default();
|
||||
for ((x, y, z), state) in edits {
|
||||
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(x, y, z, state);
|
||||
relight(&mut w, &tables(), true, &[(x, y, z)], false, &mut changed);
|
||||
}
|
||||
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| w.get(&(dx as i32 - 1, dz as i32 - 1)).map(|c| &**c))
|
||||
});
|
||||
let full = light_chunk(®ion, &tables(), true);
|
||||
assert_eq!(full.len(), 131);
|
||||
for (si, s) in full.iter().enumerate() {
|
||||
for i in 0..4096 {
|
||||
assert_eq!(
|
||||
s.get(i),
|
||||
w[&(0, 0)].sections[si].light(i),
|
||||
"section {si} index {i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// y 2020 is section 130 of the tall column, the top one.
|
||||
assert!(
|
||||
changed
|
||||
.sections()
|
||||
.any(|(x, sy, z)| (x, sy, z) == (0, 130, 0))
|
||||
);
|
||||
let mut dirty = Vec::new();
|
||||
changed.dirty_sections(|s| dirty.push(s.1));
|
||||
assert!(dirty.iter().all(|&sy| (0..131).contains(&sy)), "{dirty:?}");
|
||||
}
|
||||
#[test]
|
||||
fn incremental_relight_matches_full_relight() {
|
||||
let mut w = world(|c| {
|
||||
c.set(8, 0, 8, TORCH);
|
||||
@@ -1521,7 +1618,7 @@ mod tests {
|
||||
let mut old = fixture.clone();
|
||||
let mut new = fixture.clone();
|
||||
baseline_apply(&mut old, baseline_pack(&fixture, h, &sky, &block));
|
||||
apply_lighting(&mut new, pack_lighting(h, &sky, &block, true));
|
||||
apply_lighting(&mut new, pack_lighting(SECTIONS, h, &sky, &block, true));
|
||||
for (a, b) in old.sections.iter().zip(&new.sections) {
|
||||
for i in 0..4096 {
|
||||
assert_eq!(a.light(i), b.light(i));
|
||||
@@ -1538,14 +1635,20 @@ mod tests {
|
||||
let mut current = fixture.clone();
|
||||
let held = shared.then(|| current.clone());
|
||||
// Warm up and detach for the uniquely-owned scenario.
|
||||
apply_lighting(&mut current, pack_lighting(h, &sky, &block, true));
|
||||
apply_lighting(&mut current, pack_lighting(SECTIONS, h, &sky, &block, true));
|
||||
let started = Instant::now();
|
||||
for _ in 0..500 {
|
||||
let retained = shared.then(|| current.clone());
|
||||
if optimized {
|
||||
apply_lighting(
|
||||
black_box(&mut current),
|
||||
pack_lighting(h, black_box(&sky), black_box(&block), true),
|
||||
pack_lighting(
|
||||
SECTIONS,
|
||||
h,
|
||||
black_box(&sky),
|
||||
black_box(&block),
|
||||
true,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
baseline_apply(
|
||||
|
||||
@@ -161,6 +161,31 @@ impl Column {
|
||||
self.spans.push(span);
|
||||
}
|
||||
}
|
||||
/// Open water and its floor lit as open sky, as the seed's far columns
|
||||
/// are: a chunk's own light fades a level per block of water, so the
|
||||
/// chunks a player passed drew a darker band through the seed's sea.
|
||||
/// The water is one span again.
|
||||
fn light_open_water(&mut self, kinds: &[u8]) {
|
||||
// Water; lava glows.
|
||||
let water = |span: &Span| {
|
||||
let kind = kinds.get(span.state.0 as usize).copied().unwrap_or(0);
|
||||
kind & FLUID != 0 && kind >> 4 == 0
|
||||
};
|
||||
if !self.spans.last().is_some_and(water) {
|
||||
return;
|
||||
}
|
||||
let floor = self
|
||||
.spans
|
||||
.iter()
|
||||
.rposition(|span| !water(span))
|
||||
.unwrap_or(0);
|
||||
for (i, mut span) in std::mem::take(&mut self.spans).into_iter().enumerate() {
|
||||
if i >= floor {
|
||||
span.light |= 0xf0;
|
||||
}
|
||||
self.push(span);
|
||||
}
|
||||
}
|
||||
/// `kinds`: `state_kinds`, per block state.
|
||||
fn from_chunk(chunk: &Chunk, kinds: &[u8], x: i32, z: i32) -> Self {
|
||||
let kind = |state: BlockStateId| kinds.get(state.0 as usize).copied().unwrap_or(0);
|
||||
@@ -212,6 +237,7 @@ impl Column {
|
||||
});
|
||||
}
|
||||
}
|
||||
column.light_open_water(kinds);
|
||||
let mut water = None;
|
||||
for span in column.spans.iter().rev() {
|
||||
if kind(span.state) & FLUID != 0 {
|
||||
@@ -1772,6 +1798,48 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn open_water_and_its_floor_are_lit_as_the_seed_lights_them() {
|
||||
let span = |min, max, state, light| Span {
|
||||
min,
|
||||
max,
|
||||
state: BlockStateId(state),
|
||||
biome: 0,
|
||||
light,
|
||||
};
|
||||
// 0 air, 1 stone, 2 water, 3 lava.
|
||||
let kinds = [AIR, KEPT, KEPT | FLUID, KEPT | FLUID | 15 << 4];
|
||||
// A chunk's own light: a cave, the floor, water fading a level a block.
|
||||
let mut sea = Column {
|
||||
surface: None,
|
||||
spans: vec![
|
||||
span(30, 40, 1, 0),
|
||||
span(40, 48, 1, 0x10 | 2 << 12),
|
||||
span(48, 61, 2, 0x20),
|
||||
span(61, 62, 2, 0xe0),
|
||||
span(62, 63, 2, 0xf0),
|
||||
],
|
||||
};
|
||||
sea.light_open_water(&kinds);
|
||||
assert_eq!(
|
||||
sea.spans,
|
||||
[
|
||||
span(30, 40, 1, 0),
|
||||
span(40, 48, 1, 0xf0 | 2 << 12),
|
||||
span(48, 63, 2, 0xf0)
|
||||
]
|
||||
);
|
||||
// Land, and lava, keep their light.
|
||||
for top in [1, 3] {
|
||||
let mut column = Column {
|
||||
surface: None,
|
||||
spans: vec![span(40, 50, 2, 0x20), span(50, 63, top, 0x80)],
|
||||
};
|
||||
let before = column.spans.clone();
|
||||
column.light_open_water(&kinds);
|
||||
assert_eq!(column.spans, before);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn coarse_voxels_reduce_the_area_instead_of_inflating_one_leaf() {
|
||||
let span = |min, max, state| Span {
|
||||
min,
|
||||
|
||||
@@ -639,7 +639,7 @@ impl Padded {
|
||||
fn new(region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> Self {
|
||||
let mut blocks = vec![0u32; P * P * P];
|
||||
let mut light = vec![0u8; P * P * P];
|
||||
let base_y = MIN_Y + sy * 16;
|
||||
let base_y = region[1][1].map_or(MIN_Y, Chunk::min_y) + sy * 16;
|
||||
// The section itself is unpacked in one pass; the border cells of the
|
||||
// neighbouring sections are read one by one.
|
||||
let mut own = Box::new([0u32; 4096]);
|
||||
@@ -848,7 +848,7 @@ impl Mesher<'_> {
|
||||
}
|
||||
let pad = Padded::new(region, sy);
|
||||
out.visibility = visibility(&pad, &self.tables.info);
|
||||
let base_y = MIN_Y + sy * 16;
|
||||
let base_y = center.min_y() + sy * 16;
|
||||
let mut tints = Tints::new(
|
||||
region,
|
||||
self.biomes,
|
||||
|
||||
@@ -2797,26 +2797,7 @@ impl OptionsUi {
|
||||
}
|
||||
Act::Done => events.extend(self.back(s)),
|
||||
Act::Cycle(opt) => {
|
||||
opt.cycle(s, right, framebuffer);
|
||||
if opt == Opt::Preset {
|
||||
apply_preset(s);
|
||||
}
|
||||
if opt == Opt::HighContrast {
|
||||
// Options.highContrast: the built-in pack follows the option.
|
||||
let on = s.resource_packs.iter().any(|id| id == "high_contrast");
|
||||
if on != s.high_contrast {
|
||||
s.resource_packs.retain(|id| id != "high_contrast");
|
||||
if s.high_contrast {
|
||||
s.resource_packs.push("high_contrast".into());
|
||||
}
|
||||
// PackSelectionScreen reads the repository each time it
|
||||
// opens: the next one starts from this selection.
|
||||
self.pack_selection = s.resource_packs.clone();
|
||||
self.pack_screen = None;
|
||||
events.push(Event::Packs(s.resource_packs.clone()));
|
||||
}
|
||||
}
|
||||
events.push(Event::Changed);
|
||||
events.extend(self.cycle_option(s, opt, right, framebuffer));
|
||||
}
|
||||
Act::Extra(item) => {
|
||||
if let Some(name) = item.key_name() {
|
||||
@@ -2924,6 +2905,77 @@ impl OptionsUi {
|
||||
self.scroll -= amount * 18.0;
|
||||
self.clamp_scroll(top, bottom, content);
|
||||
}
|
||||
/// `CycleButton.cycleValue` on an option, then what the option changes:
|
||||
/// the preset's values, High Contrast's built-in pack.
|
||||
fn cycle_option(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
opt: Opt,
|
||||
back: bool,
|
||||
framebuffer: [i32; 2],
|
||||
) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
opt.cycle(s, back, framebuffer);
|
||||
if opt == Opt::Preset {
|
||||
apply_preset(s);
|
||||
}
|
||||
if opt == Opt::HighContrast {
|
||||
// Options.highContrast: the built-in pack follows the option.
|
||||
let on = s.resource_packs.iter().any(|id| id == "high_contrast");
|
||||
if on != s.high_contrast {
|
||||
s.resource_packs.retain(|id| id != "high_contrast");
|
||||
if s.high_contrast {
|
||||
s.resource_packs.push("high_contrast".into());
|
||||
}
|
||||
// PackSelectionScreen reads the repository each time it
|
||||
// opens: the next one starts from this selection.
|
||||
self.pack_selection = s.resource_packs.clone();
|
||||
self.pack_screen = None;
|
||||
events.push(Event::Packs(s.resource_packs.clone()));
|
||||
}
|
||||
}
|
||||
events.push(Event::Changed);
|
||||
events
|
||||
}
|
||||
/// The mouse wheel at the cursor. Over a cycle button it takes the step
|
||||
/// (`CycleButton.mouseScrolled`: up goes back) and the list stays put.
|
||||
pub fn wheel(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
size: (f32, f32),
|
||||
cursor: (f32, f32),
|
||||
framebuffer: [i32; 2],
|
||||
amount: f32,
|
||||
) -> Vec<Event> {
|
||||
let plain = self.resource_error.is_none()
|
||||
&& Self::graphics_level(self.page()).is_none()
|
||||
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
|
||||
if !plain {
|
||||
self.scroll_at(s, size, cursor, amount);
|
||||
return Vec::new();
|
||||
}
|
||||
let (widgets, top, bottom, _) = self.layout(s, size.0, size.1);
|
||||
let hovered = widgets.iter().find_map(|wd| match &wd.kind {
|
||||
WKind::Button {
|
||||
active: true,
|
||||
act: Act::Cycle(opt),
|
||||
..
|
||||
} if ui::hovered(cursor, wd.x, wd.y, wd.w, wd.h)
|
||||
&& !(wd.list && (cursor.1 < top || cursor.1 >= bottom)) =>
|
||||
{
|
||||
Some(*opt)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
match hovered {
|
||||
Some(opt) if amount != 0.0 => self.cycle_option(s, opt, amount > 0.0, framebuffer),
|
||||
Some(_) => Vec::new(),
|
||||
None => {
|
||||
self.scroll_at(s, size, cursor, amount);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The mouse wheel at the cursor.
|
||||
pub fn scroll_at(&mut self, s: &Settings, size: (f32, f32), cursor: (f32, f32), amount: f32) {
|
||||
if self.resource_error.is_some() {
|
||||
@@ -2940,6 +2992,33 @@ impl OptionsUi {
|
||||
_ => self.scroll(s, size, amount),
|
||||
}
|
||||
}
|
||||
/// `VideoSettingsScreen.mouseScrolled`: with Ctrl held the wheel steps the GUI scale
|
||||
/// (auto counts as one past the maximum) and the list goes back to the top.
|
||||
pub fn video_wheel_gui_scale(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
framebuffer: [i32; 2],
|
||||
amount: f32,
|
||||
) -> Vec<Event> {
|
||||
let max = i64::from(crate::window::calculate_scale(
|
||||
framebuffer,
|
||||
0,
|
||||
s.force_unicode,
|
||||
));
|
||||
let old = if s.gui_scale == 0 {
|
||||
max + 1
|
||||
} else {
|
||||
i64::from(s.gui_scale)
|
||||
};
|
||||
let step = i64::from(amount > 0.0) - i64::from(amount < 0.0);
|
||||
let new = old + step;
|
||||
if new == 0 || new > max {
|
||||
return Vec::new();
|
||||
}
|
||||
s.gui_scale = new as u32;
|
||||
self.scroll = 0.0;
|
||||
vec![Event::Changed]
|
||||
}
|
||||
/// The pack screen at this GUI size, made on first use.
|
||||
fn packs(&mut self, atlas: &HudAtlas, size: (f32, f32)) -> &mut crate::pack_screen::PackScreen {
|
||||
let m = crate::screen_draw::Measure(&atlas.font);
|
||||
|
||||
@@ -44,6 +44,9 @@ pub struct ParticleOptions {
|
||||
/// `ItemParticleOption`: the particle sprite of each layer of the item's
|
||||
/// model as resolved for the ground.
|
||||
pub item: Option<Vec<String>>,
|
||||
/// `GeyserParticleOptions.waterBlocks` and `GeyserBaseParticleOptions`'
|
||||
/// `waterBlocks` and `burstImpulseBase` (0 where the type has none).
|
||||
pub geyser: Option<(i32, f32)>,
|
||||
}
|
||||
impl ParticleOptions {
|
||||
pub fn simple(ty: ParticleType) -> Self {
|
||||
@@ -55,6 +58,21 @@ impl ParticleOptions {
|
||||
to_color: [1.0, 0.0, 0.0],
|
||||
trail: None,
|
||||
item: None,
|
||||
geyser: None,
|
||||
}
|
||||
}
|
||||
/// `GeyserParticleOptions(type, waterBlocks)`.
|
||||
pub fn geyser(ty: ParticleType, water_blocks: i32) -> Self {
|
||||
Self {
|
||||
geyser: Some((water_blocks, 0.0)),
|
||||
..Self::simple(ty)
|
||||
}
|
||||
}
|
||||
/// `GeyserBaseParticleOptions(type, waterBlocks, burstImpulseBase)`.
|
||||
pub fn geyser_base(ty: ParticleType, water_blocks: i32, burst_impulse_base: f32) -> Self {
|
||||
Self {
|
||||
geyser: Some((water_blocks, burst_impulse_base)),
|
||||
..Self::simple(ty)
|
||||
}
|
||||
}
|
||||
/// `ItemParticleOption`: the stack's layer particle sprites (`BreakingItemParticle`).
|
||||
@@ -189,6 +207,29 @@ enum Behaviour {
|
||||
size_start: f32,
|
||||
y_prev: f64,
|
||||
},
|
||||
/// `NoxiousGasParticle`: fades out from `fade_start` ticks of age.
|
||||
NoxiousGas {
|
||||
fade_start: f32,
|
||||
},
|
||||
/// `NoxiousGasCloudParticle`: every second tick, a gas particle where the
|
||||
/// gas reaches.
|
||||
NoxiousGasCloud,
|
||||
/// `GeyserEruptionParticle`: the base puffs, the plume and the poofs of
|
||||
/// one eruption; `aux` is the speed its provider was given.
|
||||
GeyserEruption {
|
||||
water_blocks: i32,
|
||||
aux: [f64; 3],
|
||||
},
|
||||
/// `GeyserPlumeParticle`: shoots up `plumeHeight` blocks, then falls back.
|
||||
GeyserPlume {
|
||||
start_y: f64,
|
||||
max_y: f64,
|
||||
propulsion: f32,
|
||||
spray: [f32; 2],
|
||||
min_size: f32,
|
||||
max_size: f32,
|
||||
done: bool,
|
||||
},
|
||||
BubblePop,
|
||||
BubbleColumnUp,
|
||||
CurrentDown {
|
||||
@@ -869,6 +910,26 @@ impl Particle {
|
||||
(age / self.lifetime as f32).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
/// `NoxiousGasCloudParticle.pickRandomParticleSpawnPoint`: a point up to three
|
||||
/// blocks from the centre of `center_block` in a random horizontal direction,
|
||||
/// a quarter block lower, from the level's random.
|
||||
fn pick_random_particle_spawn_point(random: &mut LegacyRandom, center_block: [i32; 3]) -> [f64; 3] {
|
||||
let dx = f64::from(random.next_float() - 0.5);
|
||||
let dz = f64::from(random.next_float() - 0.5);
|
||||
// Vec3.normalize.
|
||||
let length = (dx * dx + dz * dz).sqrt();
|
||||
let (dx, dz) = if length < f64::from(1.0e-5f32) {
|
||||
(0.0, 0.0)
|
||||
} else {
|
||||
(dx / length, dz / length)
|
||||
};
|
||||
let distance = f64::from(random.next_float() * 3.0);
|
||||
[
|
||||
f64::from(center_block[0]) + 0.5 + dx * distance,
|
||||
f64::from(center_block[1]) + 0.5 - 0.25,
|
||||
f64::from(center_block[2]) + 0.5 + dz * distance,
|
||||
]
|
||||
}
|
||||
/// `FireflyParticle.getFadeAmount`.
|
||||
fn fade_amount(progress: f32, fade_in: f32, fade_out: f32) -> f32 {
|
||||
if progress >= 1.0 - fade_in {
|
||||
@@ -928,7 +989,7 @@ impl ParticleLevel<'_> {
|
||||
Err(_) => d,
|
||||
}
|
||||
}
|
||||
fn block_at(p: [f64; 3]) -> [i32; 3] {
|
||||
pub(crate) fn block_at(p: [f64; 3]) -> [i32; 3] {
|
||||
[
|
||||
p[0].floor() as i32,
|
||||
p[1].floor() as i32,
|
||||
@@ -950,7 +1011,7 @@ impl ParticleLevel<'_> {
|
||||
(s.fluid, height)
|
||||
}
|
||||
/// The fluid at a block and whether it is a source (`FluidState.isSource`).
|
||||
fn fluid_source(&self, b: [i32; 3]) -> (Fluid, bool) {
|
||||
pub(crate) fn fluid_source(&self, b: [i32; 3]) -> (Fluid, bool) {
|
||||
let s = self.facts.get(self.world.block(b[0], b[1], b[2]));
|
||||
(s.fluid, s.fluid_level == 8 && !s.fluid_falling)
|
||||
}
|
||||
@@ -1165,6 +1226,17 @@ impl ParticleEngine {
|
||||
self.create(options, p, v);
|
||||
}
|
||||
}
|
||||
/// `NoxiousGasCloudParticle.spawnNoxiousGasParticle`: the gas, as an
|
||||
/// always visible particle (`ClientLevel.addAlwaysVisibleParticle`).
|
||||
fn spawn_noxious_gas_particle(&mut self, pos: [f64; 3]) {
|
||||
self.add_with(
|
||||
&ParticleOptions::simple(ParticleType::NoxiousGas),
|
||||
pos,
|
||||
[0.0; 3],
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
/// `ClientLevel.calculateParticleLevel`: minimal still shows one in ten
|
||||
/// always-visible particles as decreased, and decreased drops a third.
|
||||
fn calculate_particle_level(&mut self, always_show: bool) -> u8 {
|
||||
@@ -1562,7 +1634,15 @@ impl ParticleEngine {
|
||||
q.set = set;
|
||||
q
|
||||
}
|
||||
T::Smoke | T::LargeSmoke | T::WhiteSmoke | T::Ash | T::WhiteAsh | T::DustPlume => {
|
||||
T::Smoke
|
||||
| T::LargeSmoke
|
||||
| T::WhiteSmoke
|
||||
| T::Ash
|
||||
| T::WhiteAsh
|
||||
| T::DustPlume
|
||||
| T::NoxiousGas
|
||||
| T::GeyserBase
|
||||
| T::GeyserPoof => {
|
||||
// BaseAshSmokeParticle(dir, xa, scale, colorRandom, maxLifetime, gravity, hasPhysics).
|
||||
let (dir, scale, color_random, max_lifetime, gravity, physics, vel) = match ty {
|
||||
T::Smoke | T::WhiteSmoke => ([0.1, 0.1, 0.1], 1.0, 0.3, 8, -0.1, true, v),
|
||||
@@ -1578,6 +1658,22 @@ impl ParticleEngine {
|
||||
false,
|
||||
[v[0], v[1] + f64::from(0.15f32), v[2]],
|
||||
),
|
||||
// NoxiousGasParticle.Provider: scale 3.
|
||||
T::NoxiousGas => ([0.1, 0.1, 0.1], 3.0, 0.3, 5, -0.02, true, v),
|
||||
// GeyserBaseParticle: the burst impulse scales every axis.
|
||||
T::GeyserBase | T::GeyserPoof => {
|
||||
let (water_blocks, burst_impulse_base) = o.geyser?;
|
||||
let burst_impulse = burst_impulse_base + 0.25 * water_blocks as f32;
|
||||
(
|
||||
[f64::from(burst_impulse); 3],
|
||||
3.0 + 0.125 * water_blocks as f32,
|
||||
0.0,
|
||||
0,
|
||||
0.0,
|
||||
true,
|
||||
v,
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
// WhiteAshParticle.Provider draws the motion.
|
||||
let pr = &mut self.random;
|
||||
@@ -1593,6 +1689,18 @@ impl ParticleEngine {
|
||||
([0.1, -0.1, 0.1], 1.0, 0.0, 20, 0.0125, false, [xa, ya, za])
|
||||
}
|
||||
};
|
||||
// GeyserBaseParticle.Provider: a random spot half a block
|
||||
// around the point, a fifth of a block up (the provider's
|
||||
// random, as float arithmetic added to the double position).
|
||||
let p = if matches!(ty, T::GeyserBase | T::GeyserPoof) {
|
||||
let pr = &mut self.random;
|
||||
let x = p[0] + f64::from((pr.next_float() - 0.5) * 0.5);
|
||||
let y = p[1] + f64::from((pr.next_float() - 0.5) * 0.5) + f64::from(0.2f32);
|
||||
let z = p[2] + f64::from((pr.next_float() - 0.5) * 0.5);
|
||||
[x, y, z]
|
||||
} else {
|
||||
p
|
||||
};
|
||||
let mut q = Particle::moving(r, ty, p, [0.0; 3]);
|
||||
q.friction = 0.96;
|
||||
q.gravity = gravity;
|
||||
@@ -1621,6 +1729,87 @@ impl ParticleEngine {
|
||||
);
|
||||
q.behaviour = Behaviour::DustPlume;
|
||||
}
|
||||
match ty {
|
||||
// NoxiousGasParticle: white, living `6 / (0.5 to 1) * 3`
|
||||
// ticks, fading out over its second half.
|
||||
T::NoxiousGas => {
|
||||
q.set_color(1.0, 1.0, 1.0);
|
||||
q.lifetime = (6.0 / (f64::from(r.next_float()) * 0.5 + 0.5)
|
||||
* f64::from(3.0f32)) as i32;
|
||||
q.behaviour = Behaviour::NoxiousGas {
|
||||
fade_start: q.lifetime as f32 / 2.0,
|
||||
};
|
||||
}
|
||||
// GeyserBaseParticle: white, only ever rising, 20 to 25 ticks.
|
||||
T::GeyserBase | T::GeyserPoof => {
|
||||
q.friction = 0.725;
|
||||
q.set_color(1.0, 1.0, 1.0);
|
||||
q.velocity[1] = q.velocity[1].abs();
|
||||
let lifetime_factor = 0.8 + 0.2 * self.level_random.next_float();
|
||||
q.lifetime = (25.0 * lifetime_factor) as i32;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
q.set = set;
|
||||
q
|
||||
}
|
||||
// NoxiousGasCloudParticle: lives 20 ticks, draws nothing itself.
|
||||
T::NoxiousGasCloud => {
|
||||
let mut q = Particle::base_at(r, ty, p);
|
||||
q.lifetime = 20;
|
||||
q.no_render = true;
|
||||
q.behaviour = Behaviour::NoxiousGasCloud;
|
||||
q
|
||||
}
|
||||
// GeyserEruptionParticle: lives 20 ticks, draws nothing itself.
|
||||
T::Geyser => {
|
||||
let (water_blocks, _) = o.geyser?;
|
||||
let mut q = Particle::base_at(r, ty, p);
|
||||
q.lifetime = 20;
|
||||
q.no_render = true;
|
||||
q.behaviour = Behaviour::GeyserEruption {
|
||||
water_blocks,
|
||||
aux: v,
|
||||
};
|
||||
q
|
||||
}
|
||||
// GeyserPlumeParticle, after its provider's random placement.
|
||||
T::GeyserPlume => {
|
||||
let (water_blocks, _) = o.geyser?;
|
||||
let pr = &mut self.random;
|
||||
let at = [
|
||||
p[0] + f64::from((pr.next_float() - 0.5) * 0.2),
|
||||
p[1] + f64::from(pr.next_float()),
|
||||
p[2] + f64::from((pr.next_float() - 0.5) * 0.2),
|
||||
];
|
||||
let plume_height = 5 * water_blocks.max(1);
|
||||
let mut q = Particle::moving(r, ty, at, v);
|
||||
q.has_physics = true;
|
||||
q.speed_up_when_y_blocked = true;
|
||||
q.lifetime = plume_height * 5;
|
||||
q.velocity[1] = 0.0;
|
||||
let level = &mut self.level_random;
|
||||
let spray = [
|
||||
(level.next_float() - 0.5) * 0.2,
|
||||
(level.next_float() - 0.5) * 0.2,
|
||||
];
|
||||
q.friction = 1.0;
|
||||
let propulsion =
|
||||
(if water_blocks == 1 { 1.5 } else { 1.0 }) * plume_height as f32 * 1.45;
|
||||
q.gravity = -propulsion;
|
||||
let initial_size = q.size * 0.75;
|
||||
let min_size = initial_size * (2.0 + plume_height as f32 / 8.0);
|
||||
let max_size = initial_size * (3.0 + plume_height as f32 / 8.0);
|
||||
q.size = min_size;
|
||||
q.behaviour = Behaviour::GeyserPlume {
|
||||
start_y: at[1],
|
||||
max_y: at[1] + f64::from(plume_height) - 1.0,
|
||||
propulsion,
|
||||
spray,
|
||||
min_size,
|
||||
max_size,
|
||||
done: false,
|
||||
};
|
||||
q.set = set;
|
||||
q
|
||||
}
|
||||
@@ -3198,6 +3387,98 @@ impl ParticleEngine {
|
||||
y_prev: p.position[1],
|
||||
};
|
||||
}
|
||||
Behaviour::NoxiousGas { fade_start } => {
|
||||
p.base_tick(level);
|
||||
self.sprite_from_age(p);
|
||||
if p.age as f32 > fade_start {
|
||||
let frames_since_fade_out_start = p.age as f32 - fade_start;
|
||||
p.rgba[3] =
|
||||
(p.lifetime as f32 - frames_since_fade_out_start) / p.lifetime as f32;
|
||||
}
|
||||
}
|
||||
Behaviour::NoxiousGasCloud => {
|
||||
p.base_tick(level);
|
||||
if p.age % 2 == 0 {
|
||||
let source = ParticleLevel::block_at(p.position);
|
||||
let spawn = pick_random_particle_spawn_point(&mut self.level_random, source);
|
||||
if crate::potent_sulfur::can_be_reached_by_noxious_gas(level, source, spawn) {
|
||||
self.spawn_noxious_gas_particle(spawn);
|
||||
}
|
||||
}
|
||||
}
|
||||
Behaviour::GeyserEruption { water_blocks, aux } => {
|
||||
p.base_tick(level);
|
||||
if p.age % 2 == 0 {
|
||||
for _ in 0..2 {
|
||||
self.add(
|
||||
&ParticleOptions::geyser_base(
|
||||
ParticleType::GeyserBase,
|
||||
water_blocks,
|
||||
1.5,
|
||||
),
|
||||
p.position,
|
||||
aux,
|
||||
);
|
||||
}
|
||||
}
|
||||
for _ in 0..water_blocks + 2 {
|
||||
self.add(
|
||||
&ParticleOptions::geyser(ParticleType::GeyserPlume, water_blocks),
|
||||
p.position,
|
||||
aux,
|
||||
);
|
||||
}
|
||||
if p.age % 10 == 0 {
|
||||
for _ in 0..20 {
|
||||
self.add(
|
||||
&ParticleOptions::geyser_base(
|
||||
ParticleType::GeyserPoof,
|
||||
water_blocks,
|
||||
2.0,
|
||||
),
|
||||
p.position,
|
||||
aux,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Behaviour::GeyserPlume {
|
||||
start_y,
|
||||
max_y,
|
||||
propulsion,
|
||||
spray,
|
||||
min_size,
|
||||
max_size,
|
||||
mut done,
|
||||
} => {
|
||||
p.base_tick(level);
|
||||
if !done
|
||||
&& (p.velocity[1] < 0.0
|
||||
|| p.position[1] > max_y
|
||||
|| p.position[1] == p.previous[1])
|
||||
{
|
||||
p.lifetime = p.lifetime.min(p.age + 5);
|
||||
p.friction = 0.0;
|
||||
done = true;
|
||||
}
|
||||
let y_progress_linear =
|
||||
((p.position[1] - start_y) / (max_y - start_y)).clamp(0.0, 1.0);
|
||||
let y_progress_exponential = horizon_core::jmath::pow(y_progress_linear, 3.0);
|
||||
p.gravity = propulsion * y_progress_exponential as f32 * 0.12;
|
||||
p.velocity[0] = y_progress_linear * f64::from(spray[0]);
|
||||
p.velocity[2] = y_progress_linear * f64::from(spray[1]);
|
||||
self.sprite_from_age(p);
|
||||
p.size = min_size + (y_progress_linear * f64::from(max_size - min_size)) as f32;
|
||||
p.behaviour = Behaviour::GeyserPlume {
|
||||
start_y,
|
||||
max_y,
|
||||
propulsion,
|
||||
spray,
|
||||
min_size,
|
||||
max_size,
|
||||
done,
|
||||
};
|
||||
}
|
||||
Behaviour::BubbleColumnUp => {
|
||||
p.base_tick(level);
|
||||
if !p.removed && level.fluid(ParticleLevel::block_at(p.position)).0 != Fluid::Water
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//! `PotentSulfurBlockEntity`'s reads of the level: where the noxious gas of a
|
||||
//! potent sulfur block comes out, how far a geyser throws, and whether the gas
|
||||
//! reaches a point. The client's particles and the server's nausea and
|
||||
//! launches read the level the same way, through a [`ParticleLevel`].
|
||||
use crate::{blocks::Fluid, collisions::SceneCollisions, particles::ParticleLevel};
|
||||
use horizon_core::{
|
||||
geometry::{Vec3, traverse_blocks},
|
||||
position::BlockPos,
|
||||
};
|
||||
|
||||
/// `PotentSulfurBlock.ALLOWED_WATER_BLOCKS_ABOVE`.
|
||||
const ALLOWED_WATER_BLOCKS_ABOVE: i32 = 4;
|
||||
|
||||
/// `PotentSulfurBlockEntity.isGeyserPassableBlock`: air, water, or a block
|
||||
/// with no collision shape.
|
||||
pub fn is_geyser_passable_block(level: &ParticleLevel, pos: [i32; 3]) -> bool {
|
||||
let state = level.world.block(pos[0], pos[1], pos[2]);
|
||||
if level.facts.is_air(state) || &*level.facts.get(state).name == "minecraft:water" {
|
||||
return true;
|
||||
}
|
||||
let mut scene = SceneCollisions {
|
||||
world: level.world,
|
||||
binding: level.collision,
|
||||
subject: None,
|
||||
};
|
||||
match level.collision.collision_shape(&mut scene, state, pos) {
|
||||
Ok(shape) => shape.is_empty(),
|
||||
Err(_) => !level.facts.get(state).full_collision,
|
||||
}
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.isWater`: the block at `pos` holds a water source
|
||||
/// (`FluidState.isSourceOfType`).
|
||||
fn is_water_source(level: &ParticleLevel, pos: [i32; 3]) -> bool {
|
||||
let (fluid, source) = level.fluid_source(pos);
|
||||
fluid == Fluid::Water && source
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.findNoxiousGasSourceBlock`: the first block above
|
||||
/// the sulfur, within the water column's reach, that is not water-filled and
|
||||
/// lets gas through; None when something solid or a deeper column stops it.
|
||||
pub fn find_noxious_gas_source_block(level: &ParticleLevel, origin: [i32; 3]) -> Option<[i32; 3]> {
|
||||
let max_y = origin[1] + ALLOWED_WATER_BLOCKS_ABOVE + 1;
|
||||
let mut pos = [origin[0], origin[1] + 1, origin[2]];
|
||||
while pos[1] <= max_y {
|
||||
let state = level.world.block(pos[0], pos[1], pos[2]);
|
||||
let is_water_block = &*level.facts.get(state).name == "minecraft:water";
|
||||
if !is_water_source(level, pos)
|
||||
|| (!is_water_block && !is_geyser_passable_block(level, pos))
|
||||
{
|
||||
if level.facts.is_air(state) || is_geyser_passable_block(level, pos) {
|
||||
return Some(pos);
|
||||
}
|
||||
break;
|
||||
}
|
||||
pos[1] += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.getUnobstructedBlockCount`: how many blocks from
|
||||
/// `pos` up, at most six per water block, a geyser's push goes through.
|
||||
pub fn unobstructed_block_count(level: &ParticleLevel, pos: [i32; 3], water_blocks: i32) -> i32 {
|
||||
let geyser_force_height = 6 * water_blocks;
|
||||
for i in 0..geyser_force_height {
|
||||
if !is_geyser_passable_block(level, [pos[0], pos[1] + i, pos[2]]) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
geyser_force_height
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.canBeReachedByNoxiousGas`: `pos` is in a block gas
|
||||
/// can enter, within three blocks of the source block's centre, and above
|
||||
/// water the source's water can be seen from.
|
||||
pub fn can_be_reached_by_noxious_gas(
|
||||
level: &ParticleLevel,
|
||||
source_block: [i32; 3],
|
||||
pos: [f64; 3],
|
||||
) -> bool {
|
||||
let block = ParticleLevel::block_at(pos);
|
||||
if !is_geyser_passable_block(level, block) {
|
||||
return false;
|
||||
}
|
||||
let center = source_block.map(|c| f64::from(c) + 0.5);
|
||||
let distance_sqr =
|
||||
(center[0] - pos[0]).powi(2) + (center[1] - pos[1]).powi(2) + (center[2] - pos[2]).powi(2);
|
||||
if distance_sqr > 9.0 {
|
||||
return false;
|
||||
}
|
||||
let below_source = [center[0], f64::from(source_block[1] - 1) + 0.5, center[2]];
|
||||
let below_pos = [pos[0], pos[1] - 1.0, pos[2]];
|
||||
is_water_source(level, ParticleLevel::block_at(below_pos))
|
||||
&& have_line_of_sight(level, below_source, below_pos)
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.haveLineOfSight`: a clip over collider shapes and
|
||||
/// no fluids (as the mobs' line of sight does) finds no block between the
|
||||
/// points.
|
||||
fn have_line_of_sight(level: &ParticleLevel, a: [f64; 3], b: [f64; 3]) -> bool {
|
||||
let (from, to) = (Vec3::new(a[0], a[1], a[2]), Vec3::new(b[0], b[1], b[2]));
|
||||
let mut scene = SceneCollisions {
|
||||
world: level.world,
|
||||
binding: level.collision,
|
||||
subject: None,
|
||||
};
|
||||
let hit = traverse_blocks(from, to, 512, |p: BlockPos| {
|
||||
let state = level.world.block(p.x, p.y, p.z);
|
||||
if level.facts.is_air(state) {
|
||||
return None;
|
||||
}
|
||||
let shape = level
|
||||
.collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.ok()?;
|
||||
shape.clip(from, to, p).map(|_| ())
|
||||
});
|
||||
!matches!(hit, Ok(Some(())))
|
||||
}
|
||||
@@ -121,6 +121,10 @@ pub fn warden_can_listen(event: &str) -> bool {
|
||||
|| event == SHRIEK
|
||||
|| shrieker_can_listen(event)
|
||||
}
|
||||
/// `GameEventTags.ALLAY_CAN_LISTEN`: note block notes.
|
||||
pub fn allay_can_listen(event: &str) -> bool {
|
||||
event == "minecraft:note_block_play"
|
||||
}
|
||||
/// `GameEventTags.IGNORE_VIBRATIONS_SNEAKING`.
|
||||
pub fn ignored_when_sneaking(event: &str) -> bool {
|
||||
matches!(
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Sets of section indexes in a chunk column, a bit per section.
|
||||
//!
|
||||
//! Vanilla's tallest level has 254 sections (`DimensionType.Y_SIZE / 16`), and
|
||||
//! light data adds a section below and above it: 256 bits hold every layout a
|
||||
//! dimension type allows.
|
||||
|
||||
/// Section indexes of one column, 0 being the lowest.
|
||||
#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
pub struct SectionMask([u64; 4]);
|
||||
|
||||
impl SectionMask {
|
||||
pub const CAPACITY: usize = 256;
|
||||
pub const EMPTY: Self = Self([0; 4]);
|
||||
pub const ALL: Self = Self([u64::MAX; 4]);
|
||||
|
||||
/// The indexes `0..n`.
|
||||
pub const fn below(n: usize) -> Self {
|
||||
let mut words = [0; 4];
|
||||
let mut w = 0;
|
||||
while w < 4 {
|
||||
let bits = n.saturating_sub(w * 64);
|
||||
words[w] = if bits >= 64 {
|
||||
u64::MAX
|
||||
} else {
|
||||
(1 << bits) - 1
|
||||
};
|
||||
w += 1;
|
||||
}
|
||||
Self(words)
|
||||
}
|
||||
pub fn single(i: usize) -> Self {
|
||||
let mut mask = Self::EMPTY;
|
||||
mask.insert(i);
|
||||
mask
|
||||
}
|
||||
#[inline]
|
||||
pub fn contains(&self, i: usize) -> bool {
|
||||
i < Self::CAPACITY && self.0[i >> 6] >> (i & 63) & 1 != 0
|
||||
}
|
||||
#[inline]
|
||||
pub fn insert(&mut self, i: usize) {
|
||||
self.0[i >> 6] |= 1 << (i & 63);
|
||||
}
|
||||
#[inline]
|
||||
pub fn remove(&mut self, i: usize) {
|
||||
self.0[i >> 6] &= !(1 << (i & 63));
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0 == [0; 4]
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.iter().map(|w| w.count_ones() as usize).sum()
|
||||
}
|
||||
/// The indexes from `i` up.
|
||||
pub fn from(self, i: usize) -> Self {
|
||||
self & !Self::below(i)
|
||||
}
|
||||
/// The indexes, lowest first.
|
||||
pub fn iter(self) -> Indexes {
|
||||
Indexes(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::BitOr for SectionMask {
|
||||
type Output = Self;
|
||||
fn bitor(self, other: Self) -> Self {
|
||||
Self(std::array::from_fn(|w| self.0[w] | other.0[w]))
|
||||
}
|
||||
}
|
||||
impl std::ops::BitOrAssign for SectionMask {
|
||||
fn bitor_assign(&mut self, other: Self) {
|
||||
*self = *self | other;
|
||||
}
|
||||
}
|
||||
impl std::ops::BitAnd for SectionMask {
|
||||
type Output = Self;
|
||||
fn bitand(self, other: Self) -> Self {
|
||||
Self(std::array::from_fn(|w| self.0[w] & other.0[w]))
|
||||
}
|
||||
}
|
||||
impl std::ops::Not for SectionMask {
|
||||
type Output = Self;
|
||||
fn not(self) -> Self {
|
||||
Self(self.0.map(|w| !w))
|
||||
}
|
||||
}
|
||||
impl std::fmt::Debug for SectionMask {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_set().entries(self.iter()).finish()
|
||||
}
|
||||
}
|
||||
impl FromIterator<usize> for SectionMask {
|
||||
fn from_iter<I: IntoIterator<Item = usize>>(indexes: I) -> Self {
|
||||
let mut mask = Self::EMPTY;
|
||||
for i in indexes {
|
||||
mask.insert(i);
|
||||
}
|
||||
mask
|
||||
}
|
||||
}
|
||||
|
||||
/// `SectionMask::iter`: the set's indexes in ascending order.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Indexes(SectionMask);
|
||||
impl Iterator for Indexes {
|
||||
type Item = usize;
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<usize> {
|
||||
let words = &mut (self.0).0;
|
||||
let w = words.iter().position(|&w| w != 0)?;
|
||||
let bit = words[w].trailing_zeros() as usize;
|
||||
words[w] &= words[w] - 1;
|
||||
Some(w * 64 + bit)
|
||||
}
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
let len = self.0.len();
|
||||
(len, Some(len))
|
||||
}
|
||||
}
|
||||
impl ExactSizeIterator for Indexes {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sets_across_words() {
|
||||
let mut mask = SectionMask::EMPTY;
|
||||
for i in [0, 23, 63, 64, 130, 255] {
|
||||
mask.insert(i);
|
||||
}
|
||||
assert_eq!(mask.iter().collect::<Vec<_>>(), [0, 23, 63, 64, 130, 255]);
|
||||
assert_eq!(mask.len(), 6);
|
||||
assert!(mask.contains(130) && !mask.contains(131) && !mask.contains(300));
|
||||
mask.remove(64);
|
||||
assert_eq!(mask.from(24).iter().collect::<Vec<_>>(), [63, 130, 255]);
|
||||
assert_eq!(mask.from(256), SectionMask::EMPTY);
|
||||
assert_eq!(mask.from(0), mask);
|
||||
assert!(SectionMask::EMPTY.is_empty() && !mask.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn below_counts_whole_and_partial_words() {
|
||||
for n in [0, 1, 24, 63, 64, 65, 131, 254, 256] {
|
||||
let mask = SectionMask::below(n);
|
||||
assert_eq!(mask.len(), n);
|
||||
assert_eq!(mask.iter().collect::<Vec<_>>(), (0..n).collect::<Vec<_>>());
|
||||
}
|
||||
assert_eq!(SectionMask::below(1000), SectionMask::ALL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operators() {
|
||||
let a: SectionMask = [1, 70, 200].into_iter().collect();
|
||||
let b: SectionMask = [70, 3].into_iter().collect();
|
||||
assert_eq!((a | b).iter().collect::<Vec<_>>(), [1, 3, 70, 200]);
|
||||
assert_eq!((a & b).iter().collect::<Vec<_>>(), [70]);
|
||||
assert_eq!(
|
||||
(!a & SectionMask::below(4)).iter().collect::<Vec<_>>(),
|
||||
[0, 2, 3]
|
||||
);
|
||||
assert_eq!(format!("{a:?}"), "{1, 70, 200}");
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,8 @@ pub struct Settings {
|
||||
pub chat_links: bool,
|
||||
pub chat_links_prompt: bool,
|
||||
pub chat_opacity: f32,
|
||||
/// Chat Delay in seconds (`chatDelay`): player chat shows this far apart.
|
||||
pub chat_delay: f64,
|
||||
pub text_background_opacity: f32,
|
||||
pub chat_scale: f32,
|
||||
pub chat_width: f32,
|
||||
@@ -246,6 +248,7 @@ impl Default for Settings {
|
||||
chat_links: true,
|
||||
chat_links_prompt: true,
|
||||
chat_opacity: 1.0,
|
||||
chat_delay: 0.0,
|
||||
text_background_opacity: 0.5,
|
||||
chat_scale: 1.0,
|
||||
chat_width: 1.0,
|
||||
@@ -448,6 +451,7 @@ impl Settings {
|
||||
chat_links: f.bool("chatLinks"),
|
||||
chat_links_prompt: f.bool("chatLinksPrompt"),
|
||||
chat_opacity: f.double("chatOpacity") as f32,
|
||||
chat_delay: f.double("chatDelay"),
|
||||
text_background_opacity: f.double("textBackgroundOpacity") as f32,
|
||||
chat_scale: f.double("chatScale") as f32,
|
||||
chat_width: f.double("chatWidth") as f32,
|
||||
@@ -551,6 +555,9 @@ impl Settings {
|
||||
put_b(&mut f, "chatLinks", self.chat_links);
|
||||
put_b(&mut f, "chatLinksPrompt", self.chat_links_prompt);
|
||||
put_f(&mut f, "chatOpacity", self.chat_opacity);
|
||||
if self.chat_delay != f.double("chatDelay") {
|
||||
f.set("chatDelay", V::Double(self.chat_delay));
|
||||
}
|
||||
put_f(
|
||||
&mut f,
|
||||
"textBackgroundOpacity",
|
||||
|
||||
@@ -31,9 +31,45 @@ impl Default for SignText {
|
||||
lines: Default::default(),
|
||||
color: BLACK,
|
||||
glowing: false,
|
||||
click: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The click events `SignBlockEntity.executeClickCommandsIfPresent` acts on.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum ClickAction<'a> {
|
||||
/// `ClickEvent.RunCommand`.
|
||||
RunCommand(String),
|
||||
/// `ClickEvent.ShowDialog`: a registry id or the dialog itself.
|
||||
ShowDialog(&'a Tag),
|
||||
/// `ClickEvent.Custom`.
|
||||
Custom {
|
||||
id: String,
|
||||
payload: Option<&'a Tag>,
|
||||
},
|
||||
}
|
||||
/// A saved `click_event` as its action: the three a sign acts on, read the
|
||||
/// way `ClickEvent.CODEC` does; any other action or a malformed one is None.
|
||||
pub fn click_action(event: &Tag) -> Option<ClickAction<'_>> {
|
||||
let Some(Tag::String(action)) = event.get("action") else {
|
||||
return None;
|
||||
};
|
||||
match action.to_string_lossy().as_str() {
|
||||
"run_command" => match event.get("command") {
|
||||
Some(Tag::String(command)) => Some(ClickAction::RunCommand(command.to_string_lossy())),
|
||||
_ => None,
|
||||
},
|
||||
"show_dialog" => event.get("dialog").map(ClickAction::ShowDialog),
|
||||
"custom" => match event.get("id") {
|
||||
Some(Tag::String(id)) => Some(ClickAction::Custom {
|
||||
id: id.to_string_lossy(),
|
||||
payload: event.get("payload"),
|
||||
}),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
impl SignText {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.lines.iter().all(String::is_empty)
|
||||
@@ -42,6 +78,14 @@ impl SignText {
|
||||
pub fn text_color(&self) -> u32 {
|
||||
DYE_TEXT[usize::from(self.color.min(15))].1
|
||||
}
|
||||
/// `SignText.hasAnyClickCommands`: a line whose style has a `run_command`
|
||||
/// click event.
|
||||
pub fn has_any_click_commands(&self) -> bool {
|
||||
self.click
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|event| matches!(click_action(event), Some(ClickAction::RunCommand(_))))
|
||||
}
|
||||
/// `AbstractSignRenderer.getDarkColor`.
|
||||
pub fn dark_color(&self) -> u32 {
|
||||
let c = self.text_color();
|
||||
@@ -70,6 +114,13 @@ impl SignData {
|
||||
pub fn line_height(&self) -> f32 {
|
||||
if self.hanging { 9.0 } else { 10.0 }
|
||||
}
|
||||
/// `SignBlockEntity.canExecuteClickCommands`: a waxed sign whose side
|
||||
/// has a `run_command` click event. (Horizon keeps no filtered text, so
|
||||
/// `Player.isTextFilteringEnabled` never selects it.)
|
||||
pub fn can_execute_click_commands(&self, front: bool) -> bool {
|
||||
let side = if front { &self.front } else { &self.back };
|
||||
self.waxed && side.has_any_click_commands()
|
||||
}
|
||||
/// NBT entries after the common block-entity header.
|
||||
pub fn encode(&self) -> Vec<(&'static str, Tag)> {
|
||||
let side = |t: &SignText| {
|
||||
@@ -79,7 +130,18 @@ impl SignData {
|
||||
Tag::List(
|
||||
t.lines
|
||||
.iter()
|
||||
.map(|l| Tag::String(l.as_str().into()))
|
||||
.zip(&t.click)
|
||||
.map(|(line, click)| {
|
||||
let text = Tag::String(line.as_str().into());
|
||||
match click {
|
||||
// The line's style keeps its click event.
|
||||
Some(event) => Tag::compound([
|
||||
("text", text),
|
||||
("click_event", event.clone()),
|
||||
]),
|
||||
None => text,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
),
|
||||
@@ -90,19 +152,25 @@ impl SignData {
|
||||
("has_glowing_text", Tag::Byte(i8::from(t.glowing))),
|
||||
])
|
||||
};
|
||||
vec![
|
||||
let mut out = Vec::new();
|
||||
if self.allow_op_features {
|
||||
out.push(("allow_op_features", Tag::Byte(1)));
|
||||
}
|
||||
out.extend([
|
||||
("front_text", side(&self.front)),
|
||||
("back_text", side(&self.back)),
|
||||
("is_waxed", Tag::Byte(i8::from(self.waxed))),
|
||||
]
|
||||
]);
|
||||
out
|
||||
}
|
||||
pub fn decode(&mut self, tag: &Tag) {
|
||||
let side = |t: Option<&Tag>| -> SignText {
|
||||
let mut out = SignText::default();
|
||||
let Some(t) = t else { return out };
|
||||
if let Some(Tag::List(messages)) = t.get("messages") {
|
||||
for (line, m) in out.lines.iter_mut().zip(messages) {
|
||||
for ((line, click), m) in out.lines.iter_mut().zip(&mut out.click).zip(messages) {
|
||||
*line = component_text(m);
|
||||
*click = click_event(m);
|
||||
}
|
||||
}
|
||||
if let Some(Tag::String(c)) = t.get("color") {
|
||||
@@ -117,6 +185,21 @@ impl SignData {
|
||||
self.front = side(tag.get("front_text"));
|
||||
self.back = side(tag.get("back_text"));
|
||||
self.waxed = matches!(tag.get("is_waxed"), Some(Tag::Byte(b)) if *b != 0);
|
||||
self.allow_op_features =
|
||||
matches!(tag.get("allow_op_features"), Some(Tag::Byte(b)) if *b != 0);
|
||||
}
|
||||
}
|
||||
/// The `click_event` in the style of a stored line: the one of a component
|
||||
/// object, or of the first component of a list (`createFromList` copies it
|
||||
/// and appends the rest as siblings).
|
||||
fn click_event(tag: &Tag) -> Option<Tag> {
|
||||
let root = match tag {
|
||||
Tag::List(list) => list.first()?,
|
||||
other => other,
|
||||
};
|
||||
match root.get("click_event") {
|
||||
Some(event @ Tag::Compound(_)) => Some(event.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Plain text of a stored text component (string, `{text: ...}` or JSON string).
|
||||
|
||||
@@ -159,6 +159,14 @@ impl Sprites {
|
||||
/// up to a power of two and limited to [`MAX_ARRAY_BYTES`]. Smaller
|
||||
/// textures are scaled up by whole pixels, so UVs stay per layer.
|
||||
pub fn finish(&mut self) {
|
||||
self.lay_out(true);
|
||||
}
|
||||
/// [`Sprites::finish`] for a server, which draws nothing: the layers'
|
||||
/// size, but neither their pixels nor the animations' frames.
|
||||
pub fn finish_without_pixels(&mut self) {
|
||||
self.lay_out(false);
|
||||
}
|
||||
fn lay_out(&mut self, pixels: bool) {
|
||||
let layers = self.native.len().max(1) as u64;
|
||||
let largest = self.native.iter().map(|n| n.0).max().unwrap_or(SPRITE_SIZE);
|
||||
self.minimum_size = self.native.iter().map(|n| n.0).min().unwrap_or(SPRITE_SIZE);
|
||||
@@ -166,6 +174,12 @@ impl Sprites {
|
||||
while size > SPRITE_SIZE && layers * u64::from(size * size) * 4 > MAX_ARRAY_BYTES {
|
||||
size /= 2;
|
||||
}
|
||||
if !pixels {
|
||||
self.pixels = Vec::new();
|
||||
self.animations = Vec::new();
|
||||
self.size = size;
|
||||
return;
|
||||
}
|
||||
let mut resized = Vec::with_capacity(self.native.len());
|
||||
self.pixels.clear();
|
||||
for (from, frames, initial) in &self.native {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! so stale work is dropped.
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use crate::{
|
||||
chunk::{Chunk, SECTIONS},
|
||||
chunk::Chunk,
|
||||
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
|
||||
mesher::{MeshTables, Mesher, Region, SectionMesh, borrow},
|
||||
world::World,
|
||||
@@ -16,11 +16,15 @@ use std::{
|
||||
collections::{BinaryHeap, HashSet, VecDeque},
|
||||
sync::{
|
||||
Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
},
|
||||
thread,
|
||||
};
|
||||
|
||||
/// How long a worker waits with nothing to do before it gives back its
|
||||
/// generator's scratch buffers ([`ChunkGenerator::idle`]).
|
||||
pub const IDLE_RELEASE: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// A chunk under generation, opaque to the streamer.
|
||||
pub type Proto = Box<dyn std::any::Any + Send>;
|
||||
/// Chunk generation in vanilla's stages: a neighbour-independent proto chunk
|
||||
@@ -57,6 +61,9 @@ pub trait ChunkGenerator: Send + Sync + 'static {
|
||||
/// What a loaded chunk needs from the generator before the game uses it,
|
||||
/// on the worker that loaded it.
|
||||
fn prepare_loaded(&self, _worker: &mut Self::Worker, _chunk: &Chunk) {}
|
||||
/// The worker has had nothing to do for [`IDLE_RELEASE`]: give back what
|
||||
/// it keeps between jobs only to go faster.
|
||||
fn idle(&self, _worker: &mut Self::Worker) {}
|
||||
fn proto(&self, worker: &mut Self::Worker, x: i32, z: i32) -> Proto;
|
||||
fn decorates(&self) -> bool {
|
||||
false
|
||||
@@ -220,6 +227,9 @@ struct Shared {
|
||||
/// packs reload; each mesh job takes the current one.
|
||||
context: RwLock<Arc<WorkerContext>>,
|
||||
done: Mailbox,
|
||||
/// Workers that have freed their buffers ([`IDLE_RELEASE`]) and taken no
|
||||
/// job since: the last of them gives the freed memory back to the OS.
|
||||
released: AtomicUsize,
|
||||
}
|
||||
/// Finished jobs, from the workers to the game thread, in the order they
|
||||
/// finished. Not an `mpsc` channel: its receiver waits for a sender it finds
|
||||
@@ -310,6 +320,8 @@ pub struct Streaming {
|
||||
deferred: Vec<Done>,
|
||||
sequence: u64,
|
||||
pub center: (i32, i32),
|
||||
/// The view distance around `center`; below 0, no area there (a dedicated
|
||||
/// server nobody is on).
|
||||
pub radius: i32,
|
||||
/// `/forceload`ed chunks: kept loaded whatever the distance.
|
||||
pub forced: HashSet<(i32, i32)>,
|
||||
@@ -513,7 +525,9 @@ impl Streaming {
|
||||
stop: AtomicBool::new(false),
|
||||
context: RwLock::new(context.clone()),
|
||||
done: Mailbox::default(),
|
||||
released: AtomicUsize::new(0),
|
||||
});
|
||||
let workers = threads.max(1);
|
||||
let light_tables = context.light.clone();
|
||||
let saves: Arc<dyn Saves> = generator.clone();
|
||||
let threads = (0..threads.max(1))
|
||||
@@ -534,14 +548,42 @@ impl Streaming {
|
||||
loop {
|
||||
let job = {
|
||||
let mut queue = shared.queue.lock().unwrap();
|
||||
let mut released = false;
|
||||
loop {
|
||||
if shared.stop.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if let Some(job) = queue.pop() {
|
||||
if released {
|
||||
shared.released.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
break job.job;
|
||||
}
|
||||
queue = shared.ready.wait(queue).unwrap();
|
||||
if released {
|
||||
queue = shared.ready.wait(queue).unwrap();
|
||||
continue;
|
||||
}
|
||||
let (woken, wait) =
|
||||
shared.ready.wait_timeout(queue, IDLE_RELEASE).unwrap();
|
||||
queue = woken;
|
||||
if wait.timed_out() && queue.is_empty() {
|
||||
// A server nobody is on, a client standing still:
|
||||
// free the generator's buffers (and the mesher's
|
||||
// face cache) until the next job needs them.
|
||||
drop(queue);
|
||||
generator.idle(&mut worker);
|
||||
faces = None;
|
||||
released = true;
|
||||
// The allocator keeps freed pages until memory
|
||||
// runs short: once every worker has freed its own,
|
||||
// hand them back.
|
||||
if shared.released.fetch_add(1, Ordering::Relaxed) + 1
|
||||
== workers
|
||||
{
|
||||
horizon_threads::release_free_memory();
|
||||
}
|
||||
queue = shared.queue.lock().unwrap();
|
||||
}
|
||||
}
|
||||
};
|
||||
let done = match job {
|
||||
@@ -677,7 +719,7 @@ impl Streaming {
|
||||
self.meshing.clear();
|
||||
for &(x, z) in world.chunks.keys() {
|
||||
if self.lit.contains(&(x, z)) {
|
||||
for sy in 0..SECTIONS as i32 {
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
world.dirty_sections.insert((x, sy, z));
|
||||
}
|
||||
}
|
||||
@@ -695,7 +737,7 @@ impl Streaming {
|
||||
self.generated_total += 1;
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
for sy in 0..SECTIONS as i32 {
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
world.dirty_sections.insert((key.0 + dx, sy, key.1 + dz));
|
||||
}
|
||||
}
|
||||
@@ -710,7 +752,7 @@ impl Streaming {
|
||||
for dy in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
let s = sy as i32 + dy;
|
||||
if (0..SECTIONS as i32).contains(&s) {
|
||||
if (0..world.layout().sections() as i32).contains(&s) {
|
||||
world.dirty_sections.insert((x + dx, s, z + dz));
|
||||
}
|
||||
}
|
||||
@@ -736,7 +778,7 @@ impl Streaming {
|
||||
self.lighting.remove(&(x, z));
|
||||
// By key, not by a pass over every pending section: a moved or
|
||||
// shrunk storage forgets thousands of columns in one frame.
|
||||
for sy in 0..SECTIONS as i32 {
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
self.meshing.remove(&(x, sy, z));
|
||||
self.waiting.remove(&(x, sy, z));
|
||||
}
|
||||
@@ -854,7 +896,7 @@ impl Streaming {
|
||||
world.chunks.insert(key, Arc::new(chunk));
|
||||
if loaded {
|
||||
self.lit.insert(key);
|
||||
for sy in 0..SECTIONS as i32 {
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
world.dirty_sections.insert((key.0, sy, key.1));
|
||||
}
|
||||
}
|
||||
@@ -1037,7 +1079,7 @@ impl Streaming {
|
||||
if first {
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
for sy in 0..SECTIONS as i32 {
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
world.dirty_sections.insert((x + dx, sy, z + dz));
|
||||
}
|
||||
}
|
||||
@@ -1105,7 +1147,9 @@ impl Streaming {
|
||||
.iter()
|
||||
.filter(|&(&(x, z), p)| {
|
||||
p.is_some()
|
||||
&& i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2) > r4 * r4
|
||||
&& (self.radius < 0
|
||||
|| i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2)
|
||||
> r4 * r4)
|
||||
&& !ranges.in_extra(x, z, 4)
|
||||
&& !ranges.in_band(x, z, 4)
|
||||
&& !ranges.forced.iter().any(|f| {
|
||||
@@ -1331,7 +1375,7 @@ impl Streaming {
|
||||
}
|
||||
if !self.ranges().in_view(x, z) {
|
||||
world.dirty_sections.take_column(x, z);
|
||||
for sy in section_indexes(sections) {
|
||||
for sy in sections.iter().map(|sy| sy as i32) {
|
||||
self.waiting.insert((x, sy, z));
|
||||
}
|
||||
continue;
|
||||
@@ -1355,7 +1399,7 @@ impl Streaming {
|
||||
continue;
|
||||
};
|
||||
world.dirty_sections.take_column(x, z);
|
||||
for sy in section_indexes(sections) {
|
||||
for sy in sections.iter().map(|sy| sy as i32) {
|
||||
let first = self.meshed.insert((x, z));
|
||||
self.sequence += 1;
|
||||
self.meshing.insert((x, sy, z), self.sequence);
|
||||
@@ -1472,7 +1516,7 @@ impl Streaming {
|
||||
fn mark_light_changes(world: &mut World, changed: &LightChanges) {
|
||||
if let Some(log) = &mut world.light_log {
|
||||
for (x, sy, z) in changed.sections() {
|
||||
*log.entry((x, z)).or_default() |= 1 << sy;
|
||||
log.entry((x, z)).or_default().insert(sy as usize);
|
||||
}
|
||||
}
|
||||
changed.dirty_sections(|section| {
|
||||
@@ -1556,6 +1600,16 @@ impl Streaming {
|
||||
fn in_range(&self, x: i32, z: i32, margin: i32) -> bool {
|
||||
self.ranges().in_range(x, z, margin)
|
||||
}
|
||||
/// Rank the 3x3 a chunk loaded to FULL decorates, in one generation
|
||||
/// task's order (`ChunkGenerationTask`: x-outer, z-inner), after every
|
||||
/// chunk ranked before.
|
||||
pub fn rank_full_task(&mut self, x: i32, z: i32) {
|
||||
for dx in -1..=1 {
|
||||
for dz in -1..=1 {
|
||||
self.decoration_order.rank_or_append(x + dx, z + dz);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn band(&self) -> Option<Band> {
|
||||
self.band
|
||||
}
|
||||
@@ -1861,7 +1915,7 @@ impl Ranges<'_> {
|
||||
let (dx, dz) = (gap(x - self.center.0), gap(z - self.center.1));
|
||||
let r = i64::from(self.radius + 1);
|
||||
// The extra areas are squares: their finished square grown by `reach`.
|
||||
dx * dx + dz * dz <= r * r
|
||||
(self.radius >= 0 && dx * dx + dz * dz <= r * r)
|
||||
|| self.in_extra(x, z, 1 + reach)
|
||||
|| self.in_band(x, z, 1 + reach)
|
||||
}
|
||||
@@ -1894,7 +1948,7 @@ impl Ranges<'_> {
|
||||
fn in_main_range(&self, x: i32, z: i32, margin: i32) -> bool {
|
||||
let r = i64::from(self.radius + margin);
|
||||
let (dx, dz) = (i64::from(x - self.center.0), i64::from(z - self.center.1));
|
||||
if dx * dx + dz * dz <= r * r {
|
||||
if self.radius >= 0 && dx * dx + dz * dz <= r * r {
|
||||
return true;
|
||||
}
|
||||
// A forced chunk holds a FORCED ticket at level 31
|
||||
@@ -1931,16 +1985,6 @@ impl Drop for Streaming {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The section indexes set in a column's bits, lowest first.
|
||||
fn section_indexes(mut bits: u32) -> impl Iterator<Item = i32> {
|
||||
std::iter::from_fn(move || {
|
||||
(bits != 0).then(|| {
|
||||
let sy = bits.trailing_zeros() as i32;
|
||||
bits &= bits - 1;
|
||||
sy
|
||||
})
|
||||
})
|
||||
}
|
||||
/// The queued jobs' priorities computed again from each one's column
|
||||
/// `distance` (`Streaming::submit`).
|
||||
fn rank_again(queue: &mut BinaryHeap<Queued>, distance: impl Fn(i32, i32) -> i64) {
|
||||
@@ -2529,6 +2573,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// An idle dedicated server keeps no area around its centre: only a spawn
|
||||
/// search's chunk (FULL alone: its 3x3 decorated, 5x5 generated) and the
|
||||
/// forced chunks.
|
||||
#[test]
|
||||
fn no_area_around_an_idle_servers_centre() {
|
||||
let forced = HashSet::from([(30, 30)]);
|
||||
let extra = [((5, -3), -1)];
|
||||
let r = Ranges {
|
||||
center: (5, -3),
|
||||
radius: -1,
|
||||
extra: &extra,
|
||||
forced: &forced,
|
||||
cover: None,
|
||||
band: None,
|
||||
};
|
||||
for x in -5i32..=15 {
|
||||
for z in -13i32..=7 {
|
||||
let d = (x - 5).abs().max((z + 3).abs());
|
||||
assert_eq!(r.in_range(x, z, 1), d == 0, "({x}, {z})");
|
||||
assert_eq!(r.chunk_kept(x, z, true), d <= 2, "({x}, {z})");
|
||||
assert_eq!(r.requested(x, z, true), d <= 2, "({x}, {z})");
|
||||
}
|
||||
}
|
||||
assert!(r.in_range(30, 30, 1));
|
||||
let idle = Ranges { extra: &[], ..r };
|
||||
assert!(!idle.requested(5, -3, true) && idle.unloaded(5, -3, true));
|
||||
}
|
||||
|
||||
/// A server player's area (`lan_update_streaming`: the server view
|
||||
/// distance + 1) finishes vanilla's FULL square: PLAYER_LOADING tickets at
|
||||
/// level 31 out to the view distance, FULL (33) two chunks past it. Every
|
||||
|
||||
@@ -1078,6 +1078,21 @@ impl EditBox {
|
||||
self.set_cursor(pos, shift);
|
||||
}
|
||||
}
|
||||
/// `MultilineTextField.selectWordAtCursor` on a book page: the word before
|
||||
/// the cursor is selected, its end found past non-whitespace only
|
||||
/// (`getWordEndPosition`).
|
||||
pub fn select_word_at_cursor(&mut self) {
|
||||
let begin = multiline_word_position(&self.value, -1, self.cursor);
|
||||
let end = begin
|
||||
+ self
|
||||
.value
|
||||
.chars()
|
||||
.skip(begin)
|
||||
.take_while(|c| !java_whitespace(*c))
|
||||
.count();
|
||||
self.set_cursor(begin, false);
|
||||
self.set_cursor(end, true);
|
||||
}
|
||||
/// EditBox.onDrag: the selection follows the pointer.
|
||||
pub fn drag_to(&mut self, atlas: &HudAtlas, x: f32) {
|
||||
let pos = self.position_at(atlas, x);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! Resident client world: streamed chunk columns plus the bookkeeping the game
|
||||
//! loop needs to relight and remesh after edits.
|
||||
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use crate::{
|
||||
chunk::{Chunk, ColumnLayout, LevelShape},
|
||||
fast_hash::{FxHashMap, FxHashSet},
|
||||
section_mask::SectionMask,
|
||||
};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::Arc;
|
||||
@@ -11,28 +14,30 @@ use std::sync::Arc;
|
||||
/// rather than at every section of it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DirtySections {
|
||||
columns: crate::fast_hash::FxHashMap<(i32, i32), u32>,
|
||||
columns: crate::fast_hash::FxHashMap<(i32, i32), SectionMask>,
|
||||
}
|
||||
const _: () = assert!(SECTIONS <= 32);
|
||||
impl DirtySections {
|
||||
pub fn insert(&mut self, (x, sy, z): (i32, i32, i32)) {
|
||||
debug_assert!((0..SECTIONS as i32).contains(&sy));
|
||||
*self.columns.entry((x, z)).or_default() |= 1 << sy;
|
||||
debug_assert!((0..SectionMask::CAPACITY as i32).contains(&sy));
|
||||
self.columns.entry((x, z)).or_default().insert(sy as usize);
|
||||
}
|
||||
pub fn contains(&self, &(x, sy, z): &(i32, i32, i32)) -> bool {
|
||||
(0..SECTIONS as i32).contains(&sy)
|
||||
&& self.columns.get(&(x, z)).is_some_and(|m| m & 1 << sy != 0)
|
||||
(0..SectionMask::CAPACITY as i32).contains(&sy)
|
||||
&& self
|
||||
.columns
|
||||
.get(&(x, z))
|
||||
.is_some_and(|m| m.contains(sy as usize))
|
||||
}
|
||||
/// The dirty section indexes of column (x, z), as bits, removed.
|
||||
pub fn take_column(&mut self, x: i32, z: i32) -> u32 {
|
||||
self.columns.remove(&(x, z)).unwrap_or(0)
|
||||
/// The dirty section indexes of column (x, z), removed.
|
||||
pub fn take_column(&mut self, x: i32, z: i32) -> SectionMask {
|
||||
self.columns.remove(&(x, z)).unwrap_or_default()
|
||||
}
|
||||
/// Keep the columns `keep` accepts.
|
||||
pub fn retain_columns(&mut self, mut keep: impl FnMut(i32, i32) -> bool) {
|
||||
self.columns.retain(|&(x, z), _| keep(x, z));
|
||||
}
|
||||
/// The columns holding dirty sections, with their section bits.
|
||||
pub fn columns(&self) -> impl Iterator<Item = ((i32, i32), u32)> + '_ {
|
||||
/// The columns holding dirty sections, with their section indexes.
|
||||
pub fn columns(&self) -> impl Iterator<Item = ((i32, i32), SectionMask)> + '_ {
|
||||
self.columns.iter().map(|(&c, &m)| (c, m))
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
@@ -43,10 +48,13 @@ impl DirtySections {
|
||||
}
|
||||
/// How many sections are dirty.
|
||||
pub fn len(&self) -> usize {
|
||||
self.columns.values().map(|m| m.count_ones() as usize).sum()
|
||||
self.columns.values().map(SectionMask::len).sum()
|
||||
}
|
||||
}
|
||||
pub struct World {
|
||||
/// The level's build range and skylight (its dimension type); its
|
||||
/// chunks hold `shape.column()`.
|
||||
pub shape: LevelShape,
|
||||
pub fast_lava: bool,
|
||||
pub chunks: crate::chunk_map::ChunkMap,
|
||||
pub random: LegacyRandom,
|
||||
@@ -88,7 +96,7 @@ pub struct World {
|
||||
/// The sections whose light changed since the log was last drained, as
|
||||
/// a bit per section of each column, kept while `block_log` is
|
||||
/// (`ChunkHolder.sectionLightChanged`).
|
||||
pub light_log: Option<crate::fast_hash::FxHashMap<(i32, i32), u32>>,
|
||||
pub light_log: Option<crate::fast_hash::FxHashMap<(i32, i32), SectionMask>>,
|
||||
/// The villager POIs of each section (`PoiManager`'s sections).
|
||||
pub pois: crate::poi_sections::PoiSections<crate::poi_sections::Poi>,
|
||||
/// Moving-piston states removed during the last tick; retained until the
|
||||
@@ -165,6 +173,7 @@ impl Weather {
|
||||
impl World {
|
||||
pub fn new(air: BlockStateId, seed: i64) -> Self {
|
||||
Self {
|
||||
shape: LevelShape::OVERWORLD,
|
||||
chunks: Default::default(),
|
||||
random: LegacyRandom::new(seed),
|
||||
revision: 0,
|
||||
@@ -249,6 +258,10 @@ impl World {
|
||||
world.revision = 0;
|
||||
Ok(world)
|
||||
}
|
||||
/// The column the level's chunks hold.
|
||||
pub fn layout(&self) -> ColumnLayout {
|
||||
self.shape.column()
|
||||
}
|
||||
/// Resident storage write; marks the touched sections (and neighbours on
|
||||
/// section borders) for remeshing and the surrounding chunks for relighting.
|
||||
/// Neighbour updates, drops and block entities belong to the gameplay layer.
|
||||
@@ -256,7 +269,8 @@ impl World {
|
||||
let Some(chunk) = self.chunks.get_mut(&(x >> 4, z >> 4)) else {
|
||||
return Err("write outside resident chunks".into());
|
||||
};
|
||||
if y < MIN_Y || y >= MIN_Y + (SECTIONS as i32) * 16 {
|
||||
let (min_y, sections) = (chunk.min_y(), chunk.sections.len() as i32);
|
||||
if chunk.section_index(y).is_none() {
|
||||
return Err("write outside world height".into());
|
||||
}
|
||||
let previous = Arc::make_mut(chunk).set(x & 15, y, z & 15, state.0);
|
||||
@@ -276,14 +290,14 @@ impl World {
|
||||
// the sections it can reach: this section, the one above/below
|
||||
// only on a section face, and a neighbour column only on a chunk
|
||||
// border (the diagonal only where both borders meet).
|
||||
let sy = (y - MIN_Y) >> 4;
|
||||
let sy = (y - min_y) >> 4;
|
||||
let local_y = y & 15;
|
||||
let mark = |dirty: &mut DirtySections, bx: i32, bz: i32| {
|
||||
dirty.insert((bx >> 4, sy, bz >> 4));
|
||||
if local_y == 0 && sy > 0 {
|
||||
dirty.insert((bx >> 4, sy - 1, bz >> 4));
|
||||
}
|
||||
if local_y == 15 && sy + 1 < SECTIONS as i32 {
|
||||
if local_y == 15 && sy + 1 < sections {
|
||||
dirty.insert((bx >> 4, sy + 1, bz >> 4));
|
||||
}
|
||||
};
|
||||
@@ -319,17 +333,20 @@ impl World {
|
||||
Ok(())
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)`: one above the highest
|
||||
/// block that isn't `air`, or `MIN_Y` (also where the chunk isn't loaded).
|
||||
/// block that isn't `air`, or the column's minimum Y (also where the chunk
|
||||
/// isn't loaded).
|
||||
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(BlockStateId) -> bool) -> i32 {
|
||||
self.chunks.get(&(x >> 4, z >> 4)).map_or(MIN_Y, |chunk| {
|
||||
let bottom = self.layout().min_y();
|
||||
self.chunks.get(&(x >> 4, z >> 4)).map_or(bottom, |chunk| {
|
||||
chunk.world_surface(x, z, |v| air(BlockStateId(v)))
|
||||
})
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)`: one above the
|
||||
/// highest block where `blocks` (motion blocking or holding a fluid),
|
||||
/// or `MIN_Y` (also where the chunk isn't loaded).
|
||||
/// or the column's minimum Y (also where the chunk isn't loaded).
|
||||
pub fn motion_blocking(&self, x: i32, z: i32, blocks: impl Fn(BlockStateId) -> bool) -> i32 {
|
||||
self.chunks.get(&(x >> 4, z >> 4)).map_or(MIN_Y, |chunk| {
|
||||
let bottom = self.layout().min_y();
|
||||
self.chunks.get(&(x >> 4, z >> 4)).map_or(bottom, |chunk| {
|
||||
chunk.motion_blocking(x & 15, z & 15, |v| blocks(BlockStateId(v)))
|
||||
})
|
||||
}
|
||||
@@ -348,12 +365,13 @@ impl World {
|
||||
hi: [i32; 3],
|
||||
mut matches: impl FnMut(BlockStateId) -> bool,
|
||||
) -> bool {
|
||||
let top = MIN_Y + SECTIONS as i32 * 16 - 1;
|
||||
let layout = self.layout();
|
||||
let (bottom, top) = (layout.min_y(), layout.max_y() - 1);
|
||||
// Heights outside the sections read as state 0.
|
||||
if (lo[1] < MIN_Y || hi[1] > top) && matches(BlockStateId(0)) {
|
||||
if (lo[1] < bottom || hi[1] > top) && matches(BlockStateId(0)) {
|
||||
return true;
|
||||
}
|
||||
let sections = (lo[1].max(MIN_Y) - MIN_Y) >> 4..=(hi[1].min(top) - MIN_Y) >> 4;
|
||||
let sections = (lo[1].max(bottom) - bottom) >> 4..=(hi[1].min(top) - bottom) >> 4;
|
||||
for cx in lo[0] >> 4..=hi[0] >> 4 {
|
||||
for cz in lo[2] >> 4..=hi[2] >> 4 {
|
||||
let Some(chunk) = self.chunks.get(&(cx, cz)) else {
|
||||
@@ -465,8 +483,8 @@ mod tests {
|
||||
}
|
||||
let mut got: Vec<_> = world.dirty_sections.columns().collect();
|
||||
let mut want: Vec<_> = expected.columns().collect();
|
||||
got.sort_unstable();
|
||||
want.sort_unstable();
|
||||
got.sort_unstable_by_key(|&(column, _)| column);
|
||||
want.sort_unstable_by_key(|&(column, _)| column);
|
||||
assert_eq!(got, want, "write at {x} {y} {z}");
|
||||
if (x & 15, z & 15) == (3, 3) {
|
||||
assert_eq!(world.dirty_sections.len(), 1);
|
||||
|
||||
@@ -18,6 +18,7 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::{geometry::Axis, random::LegacyRandom};
|
||||
use horizon_world::chunk::BlockState;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::{collections::BTreeMap, fs, path::Path};
|
||||
|
||||
@@ -77,9 +78,9 @@ fn main() -> Result<()> {
|
||||
let assets = Assets::load(&root).map_err(step("assets"))?;
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(&root, registry).map_err(step("block facts"))?;
|
||||
let items = Items::load(&root, registry, &facts).map_err(step("items"))?;
|
||||
let recipes = Recipes::load(&root, &items).map_err(step("recipes"))?;
|
||||
let stations = StationRecipes::load(&root, &items);
|
||||
let items = Items::load(&root, registry, &facts, &PackOverlay::NONE).map_err(step("items"))?;
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).map_err(step("recipes"))?;
|
||||
let stations = StationRecipes::load(&root, &items, &PackOverlay::NONE);
|
||||
let collisions =
|
||||
horizon_client::collisions::load(&root, registry).map_err(step("collisions"))?;
|
||||
let loaded = began.elapsed().as_secs_f64() - built;
|
||||
|
||||
@@ -7,6 +7,7 @@ use horizon_client::{
|
||||
chunk::{Chunk, MIN_Y, SECTIONS, Section},
|
||||
fast_hash::FxHashMap,
|
||||
portal_sections::PortalSections,
|
||||
section_mask::SectionMask,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
@@ -83,17 +84,14 @@ fn may_hold_portal_cached(
|
||||
if count > 64 {
|
||||
return true;
|
||||
}
|
||||
let mut y_mask = 0u32;
|
||||
for sy in y0..=y1 {
|
||||
y_mask |= 1 << sy;
|
||||
}
|
||||
let y_mask = SectionMask::below(y1 as usize + 1).from(y0 as usize);
|
||||
(x0..=x1).any(|cx| {
|
||||
(z0..=z1).any(|cz| {
|
||||
world.get(&(cx, cz)).is_some_and(|chunk| {
|
||||
cache.get((cx, cz), chunk, |v| {
|
||||
let portals = cache.get((cx, cz), chunk, |v| {
|
||||
behaviours.portal_axis(BlockStateId(v)).is_some()
|
||||
}) & y_mask
|
||||
!= 0
|
||||
});
|
||||
!(portals & y_mask).is_empty()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ use horizon_client::{
|
||||
items::Items,
|
||||
};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
@@ -127,7 +128,7 @@ pub fn main() -> Result<()> {
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let renderer = BlockEntityRenderer::load(root, registry)?;
|
||||
let tags = Items::tags_only(root)?;
|
||||
let tags = Items::tags_only(root, &PackOverlay::NONE)?;
|
||||
let frames = tags
|
||||
.block_tags
|
||||
.get("minecraft:conduit_effect_block")
|
||||
|
||||
@@ -9,6 +9,7 @@ use horizon_client::{
|
||||
items::Items,
|
||||
};
|
||||
use horizon_world::chunk::BlockState;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
fn parse(text: &str) -> BlockState {
|
||||
@@ -58,7 +59,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.ok_or("usage: block_transform_fixture RESOURCES INPUT OUTPUT")?,
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let assets = Assets::load(root)?;
|
||||
let transformers = Transformers::load(root, assets.registry)?;
|
||||
let mut out = String::new();
|
||||
|
||||
@@ -10,6 +10,7 @@ use horizon_client::{
|
||||
menu::{Env, Kind, Menu},
|
||||
};
|
||||
use horizon_world::chunk::BlockState;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::Value;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
@@ -77,9 +78,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.ok_or("usage: cauldron_fixture RESOURCES INPUT OUTPUT")?,
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items, &PackOverlay::NONE);
|
||||
// The inventory menu never reads banner patterns.
|
||||
let banners = horizon_client::banner::Banners::default();
|
||||
let station_recipes = horizon_client::stations::StationRecipes::default();
|
||||
|
||||
@@ -3,6 +3,7 @@ use horizon_client::{
|
||||
item_data::StackData,
|
||||
items::{ItemStack, Items},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
@@ -14,7 +15,7 @@ use std::{
|
||||
pub fn main() -> Result<()> {
|
||||
let args: Vec<_> = crate::args().collect();
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
|
||||
let items = Items::catalog(&root)?;
|
||||
let items = Items::catalog(&root, &PackOverlay::NONE)?;
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for line in BufReader::new(File::open(&args[1])?).lines() {
|
||||
let row: Value = serde_json::from_str(&line?)?;
|
||||
|
||||
@@ -8,6 +8,7 @@ use horizon_client::{
|
||||
items::{ItemStack, Items},
|
||||
stations,
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::{fmt::Write as _, fs, path::Path};
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
@@ -17,7 +18,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.ok_or("usage: enchanting_fixture RESOURCES INPUT OUTPUT")?,
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let items = Items::tags_only(root)?;
|
||||
let items = Items::tags_only(root, &PackOverlay::NONE)?;
|
||||
let e = Enchantments::load(root);
|
||||
let mut out = String::new();
|
||||
for line in input.lines() {
|
||||
|
||||
@@ -593,6 +593,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
num("maxCrossbowChageDuration"),
|
||||
num("ticksUsingItem"),
|
||||
false,
|
||||
flag("isRiding"),
|
||||
);
|
||||
}
|
||||
Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator => {
|
||||
|
||||
@@ -8,7 +8,7 @@ use horizon_client::{
|
||||
random_sequences::RandomSequences,
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::LegacyRandom};
|
||||
use horizon_world::loot::{LootDamage, LootData, LootEntity, LootStack};
|
||||
use horizon_world::loot::{LootDamage, LootData, LootEntity, LootStack, PackOverlay};
|
||||
use std::{collections::BTreeMap, fmt::Write as _, fs, path::Path, sync::Arc};
|
||||
|
||||
/// `id[;enchantment:level...]`.
|
||||
@@ -64,7 +64,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let types = MobTypes::load(root)?;
|
||||
let loot = LootData::load(root)?;
|
||||
let loot = LootData::load(root, &PackOverlay::NONE)?;
|
||||
let mut out = String::new();
|
||||
for raw in input.lines() {
|
||||
if raw.trim().is_empty() || raw.starts_with('#') {
|
||||
|
||||
@@ -23,6 +23,7 @@ use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::{collections::HashMap, fmt::Write as _, fs, path::Path, sync::Arc};
|
||||
|
||||
struct Level<'a> {
|
||||
@@ -142,8 +143,8 @@ const GENES: [&str; 7] = [
|
||||
"aggressive",
|
||||
];
|
||||
const MEMORIES: [&str; 7] = [
|
||||
"long_jump_cooldown",
|
||||
"ram_cooldown",
|
||||
"long_jump_cooling_down",
|
||||
"ram_cooldown_ticks",
|
||||
"attack_target_cooldown",
|
||||
"hunted_recently",
|
||||
"dig_cooldown",
|
||||
@@ -399,7 +400,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let assets = Assets::load(root)?;
|
||||
let facts = BlockFacts::load(root, assets.registry)?;
|
||||
let mob_data = MobData::load(root, &facts);
|
||||
let items = Items::tags_only(root)?;
|
||||
let items = Items::tags_only(root, &PackOverlay::NONE)?;
|
||||
let enchantments = Enchantments::load(root);
|
||||
let types = MobTypes::load(root)?;
|
||||
let registries = SpawnRegistries::load(root);
|
||||
@@ -506,6 +507,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
difficulty,
|
||||
data: &data,
|
||||
created: Vec::new(),
|
||||
jockey_rider: None,
|
||||
};
|
||||
group = finalize::finalize(&mut cx, &mut mob, reason, group);
|
||||
let created = std::mem::take(&mut cx.created);
|
||||
|
||||
@@ -10,6 +10,7 @@ use horizon_client::{
|
||||
items::Items,
|
||||
recipe_book::{Book, BookType, GhostTarget, RecipeIndex},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::Value;
|
||||
use std::{fmt::Write as _, fs, path::Path};
|
||||
|
||||
@@ -18,9 +19,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let usage = "usage: ghost_recipe_fixture RESOURCES CASES OUTPUT";
|
||||
let root = Path::new(args.get(1).ok_or(usage)?);
|
||||
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let index = RecipeIndex::load(root);
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
|
||||
let index = RecipeIndex::load(root, &PackOverlay::NONE);
|
||||
let mut book = Book::build(&recipes, &items);
|
||||
book.set_display_ids(&index);
|
||||
let mut out = String::new();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//!
|
||||
//! usage: hotbar_fixture RESOURCES CASES OUTPUT
|
||||
use horizon_client::{hotbar, item_data::canon_stack, items::Items};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::nbt::{Tag, parse_snbt};
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
@@ -13,7 +14,10 @@ use std::{fmt::Write as _, fs};
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let usage = "usage: hotbar_fixture RESOURCES CASES OUTPUT";
|
||||
let items = Items::catalog(Path::new(args.first().and(args.get(1)).ok_or(usage)?))?;
|
||||
let items = Items::catalog(
|
||||
Path::new(args.first().and(args.get(1)).ok_or(usage)?),
|
||||
&PackOverlay::NONE,
|
||||
)?;
|
||||
let known = |id: &str| items.get(id).is_some();
|
||||
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let mut out = String::new();
|
||||
|
||||
@@ -15,6 +15,9 @@ struct World {
|
||||
passing: bool,
|
||||
}
|
||||
impl InteractionWorld for World {
|
||||
// The fixture's targets don't change within a tick.
|
||||
fn pick_hit_result(&mut self) {}
|
||||
fn release_hit_result(&mut self) {}
|
||||
fn using_item(&self) -> bool {
|
||||
self.using
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use horizon_client::{
|
||||
menu::{Env, Input, Kind, Menu},
|
||||
};
|
||||
use horizon_protocol::registry::Ids;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
@@ -83,11 +84,12 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.ok_or("usage: menu_fixture RESOURCES INPUT OUTPUT")?,
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items, &PackOverlay::NONE);
|
||||
let banners = horizon_client::banner::Banners::load(root);
|
||||
let station_recipes = horizon_client::stations::StationRecipes::load(root, &items);
|
||||
let station_recipes =
|
||||
horizon_client::stations::StationRecipes::load(root, &items, &PackOverlay::NONE);
|
||||
let enchantments = horizon_client::enchant::Enchantments::load(root);
|
||||
let names = horizon_client::creative::Names::load(root);
|
||||
let case_maps = CaseMaps::default();
|
||||
|
||||
@@ -27,6 +27,7 @@ use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::LegacyRandom,
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::{chunk::BlockState, registry::BlockRegistry, registry::BlockStateId};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
@@ -196,7 +197,11 @@ fn who(w: Option<Who>) -> String {
|
||||
fn goal_name(m: &Mob, name: &str) -> String {
|
||||
let vanilla = match name {
|
||||
"Float" => "FloatGoal",
|
||||
"RandomFloatAround" => "RandomFloatAroundGoal",
|
||||
"GhastLook" => "GhastLookGoal",
|
||||
"GhastShoot" => "GhastShootFireballGoal",
|
||||
"Stroll" => "WaterAvoidingRandomStrollGoal",
|
||||
"ParrotWander" => "ParrotWanderGoal",
|
||||
"RandomStroll" => "RandomStrollGoal",
|
||||
// AbstractFish's private subclass of RandomSwimmingGoal.
|
||||
"RandomSwimming"
|
||||
@@ -223,6 +228,8 @@ fn goal_name(m: &Mob, name: &str) -> String {
|
||||
"MoveThroughVillage" => "MoveThroughVillageGoal",
|
||||
"RestrictSun" => "RestrictSunGoal",
|
||||
"FleeSun" => "FleeSunGoal",
|
||||
// `Cat.CatAvoidEntityGoal`, `Fox.FoxAvoidEntityGoal`... name their subclass.
|
||||
"AvoidEntity" if m.species() == Species::Cat => "CatAvoidEntityGoal",
|
||||
"AvoidEntity" => "AvoidEntityGoal",
|
||||
"BowAttack" => "RangedBowAttackGoal",
|
||||
"Swell" => "SwellGoal",
|
||||
@@ -873,7 +880,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let biomes = Biomes::load(root)?;
|
||||
let items = Items::load(root, registry, &facts)?;
|
||||
let items = Items::load(root, registry, &facts, &PackOverlay::NONE)?;
|
||||
let tables = LightTables::load(root, registry, &facts)?;
|
||||
let collision = collisions::load(root, registry)?;
|
||||
let air = registry.default_state("minecraft:air").ok_or("no air")?;
|
||||
|
||||
@@ -53,7 +53,8 @@ use horizon_core::{
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::{
|
||||
block_collision::BlockCollisionRegistry, chunk::BlockState, nbt::Tag, registry::BlockStateId,
|
||||
block_collision::BlockCollisionRegistry, chunk::BlockState, loot::PackOverlay, nbt::Tag,
|
||||
registry::BlockStateId,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
@@ -522,11 +523,11 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let data = MobData::load(root, &facts);
|
||||
let items = Items::tags_only(root)?;
|
||||
let items = Items::tags_only(root, &PackOverlay::NONE)?;
|
||||
// Trial spawner rewards: the loot tables and the configs.
|
||||
let loot = if input.contains("mode=trial") || input.contains("mode=vault") {
|
||||
horizon_client::mobs::trial_spawner::load_registry(root);
|
||||
horizon_client::structure_blocks::loot_data(root)
|
||||
horizon_client::structure_blocks::loot_data(root, &PackOverlay::NONE)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -795,7 +796,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
chunk,
|
||||
&mut gen_random,
|
||||
&mut rules,
|
||||
&mut spawn,
|
||||
&mut |l, r, _| spawn(l, r).is_some(),
|
||||
&mut |e| match e {
|
||||
GenEvent::Column(x, z) => log.push(format!("col {x} {z}")),
|
||||
GenEvent::Spawn(t, p, yaw) => spawns.push(format!(
|
||||
|
||||
@@ -76,6 +76,14 @@ fn options(case: &Value) -> Option<ParticleOptions> {
|
||||
..ParticleOptions::simple(ty)
|
||||
},
|
||||
ParticleType::SculkCharge => ParticleOptions::sculk_charge(scale),
|
||||
ParticleType::Geyser | ParticleType::GeyserPlume => {
|
||||
ParticleOptions::geyser(ty, o["water_blocks"].as_i64()? as i32)
|
||||
}
|
||||
ParticleType::GeyserBase | ParticleType::GeyserPoof => ParticleOptions::geyser_base(
|
||||
ty,
|
||||
o["water_blocks"].as_i64()? as i32,
|
||||
o["burst_impulse_base"].as_f64()? as f32,
|
||||
),
|
||||
ParticleType::Shriek => ParticleOptions::shriek(scale as i32),
|
||||
ParticleType::Trail => {
|
||||
let t = &o["target"];
|
||||
|
||||
@@ -10,6 +10,7 @@ use horizon_client::{
|
||||
items::Items,
|
||||
menu::{Env, Kind, Menu, place::PostPlace},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::Value;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
@@ -34,9 +35,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let root = Path::new(args.get(1).ok_or(usage)?);
|
||||
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let orders = fs::read_to_string(args.get(3).ok_or(usage)?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items, &PackOverlay::NONE);
|
||||
let banners = horizon_client::banner::Banners::default();
|
||||
let station_recipes = horizon_client::stations::StationRecipes::default();
|
||||
let enchantments = horizon_client::enchant::Enchantments::default();
|
||||
|
||||
@@ -19,6 +19,7 @@ use horizon_client::{
|
||||
raid::{self, Action, BarName, RaidLevel, RaidPlayer, RaiderInfo, Raids},
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::{chunk::BlockState, nbt::Tag, registry::BlockStateId};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
@@ -470,7 +471,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let data = MobData::load(root, &facts);
|
||||
let items = Items::tags_only(root)?;
|
||||
let items = Items::tags_only(root, &PackOverlay::NONE)?;
|
||||
let attributes = GameplayAttributes::load(root);
|
||||
let shared = Shared {
|
||||
facts: &facts,
|
||||
|
||||
@@ -9,6 +9,7 @@ use horizon_protocol::{
|
||||
buf::{Codec, Reader, Writer},
|
||||
packets::recipes::RecipeBookChangeSettings,
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{collections::BTreeSet, fs, path::Path};
|
||||
@@ -66,7 +67,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let usage = "usage: recipe_book_fixture RESOURCES CASES OUTPUT";
|
||||
let root = Path::new(args.get(1).ok_or(usage)?);
|
||||
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let index = RecipeIndex::load(root);
|
||||
let index = RecipeIndex::load(root, &PackOverlay::NONE);
|
||||
let mut out = String::new();
|
||||
for line in cases.lines() {
|
||||
let case: Value = serde_json::from_str(line)?;
|
||||
|
||||
@@ -16,6 +16,7 @@ use horizon_client::{
|
||||
TabView, draw, overlay_cells, overlay_origin,
|
||||
},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::{Value, json};
|
||||
use std::{fmt::Write as _, fs, path::Path};
|
||||
|
||||
@@ -35,8 +36,8 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let usage = "usage: recipe_book_render_fixture RESOURCES CASES OUTPUT";
|
||||
let root = Path::new(args.get(1).ok_or(usage)?);
|
||||
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
|
||||
let book = Book::build(&recipes, &items);
|
||||
let atlas = HudAtlas::build(root)?;
|
||||
let root_buf = root.to_path_buf();
|
||||
|
||||
@@ -12,6 +12,7 @@ use horizon_client::{
|
||||
menu::place::Stacked,
|
||||
recipe_book::{Book, BookType, ClientBook, Display, RecipeIndex, Tab},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::Value;
|
||||
use std::{fmt::Write as _, fs, path::Path};
|
||||
|
||||
@@ -20,9 +21,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let usage = "usage: recipe_book_view_fixture RESOURCES CASES OUTPUT";
|
||||
let root = Path::new(args.get(1).ok_or(usage)?);
|
||||
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let index = RecipeIndex::load(root);
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
|
||||
let index = RecipeIndex::load(root, &PackOverlay::NONE);
|
||||
let mut book = Book::build(&recipes, &items);
|
||||
book.set_display_ids(&index);
|
||||
let max = |s: &horizon_client::items::ItemStack| items.get(&s.item).map_or(64, |i| i.max_stack);
|
||||
|
||||
@@ -10,6 +10,7 @@ use horizon_client::{
|
||||
items::Items,
|
||||
stations::StationRecipes,
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
@@ -19,9 +20,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.ok_or("usage: recipe_fixture RESOURCES INPUT OUTPUT")?,
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let stations = StationRecipes::load(root, &items);
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let recipes = Recipes::load(root, &items, &PackOverlay::NONE)?;
|
||||
let stations = StationRecipes::load(root, &items, &PackOverlay::NONE);
|
||||
let enchantments = Enchantments::load(root);
|
||||
let mut out = String::new();
|
||||
for line in input.lines() {
|
||||
|
||||
@@ -14,6 +14,7 @@ use horizon_client::{
|
||||
},
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::{chunk::BlockState, registry::BlockStateId};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
@@ -147,7 +148,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let data = MobData::load(root, &facts);
|
||||
let items = Items::tags_only(root)?;
|
||||
let items = Items::tags_only(root, &PackOverlay::NONE)?;
|
||||
let types = MobTypes::load(root)?;
|
||||
let mut out = String::new();
|
||||
for raw in input.lines() {
|
||||
|
||||
@@ -36,7 +36,7 @@ pub fn main() -> Result<()> {
|
||||
aquatic::squid_cycle_event(&mut mob);
|
||||
}
|
||||
let velocity = mob.velocity;
|
||||
aquatic::squid_client_step(&mut mob);
|
||||
aquatic::squid_client_step(&mut mob, &mut Vec::new());
|
||||
assert_eq!(
|
||||
velocity, mob.velocity,
|
||||
"a remote animation must preserve packet velocity"
|
||||
|
||||
@@ -13,6 +13,7 @@ use horizon_client::{
|
||||
random_sequences::RandomSequences,
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::{collections::HashMap, fmt::Write as _, fs, path::Path};
|
||||
|
||||
@@ -237,12 +238,12 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let root = Path::new(args.get(1).ok_or(usage)?);
|
||||
let input = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let vanilla = fs::read_to_string(args.get(3).ok_or(usage)?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let enchantments = Enchantments::load(root);
|
||||
let names = horizon_client::creative::Names::default();
|
||||
let trades = Trades::load(root);
|
||||
let recipes = horizon_client::crafting::Recipes::load(root, &items)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
let recipes = horizon_client::crafting::Recipes::load(root, &items, &PackOverlay::NONE)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items, &PackOverlay::NONE);
|
||||
let banners = horizon_client::banner::Banners::load(root);
|
||||
let station_recipes = horizon_client::stations::StationRecipes::default();
|
||||
let types: HashMap<_, _> = horizon_client::mobs::MobTypes::load(root)?
|
||||
|
||||
@@ -14,6 +14,7 @@ use horizon_client::{
|
||||
light::LightTables, mesher::MeshTables, models, motion::MotionResources,
|
||||
vanillagen::VanillaTerrain,
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::{
|
||||
alloc::{GlobalAlloc, Layout, System},
|
||||
sync::atomic::{AtomicUsize, Ordering::Relaxed},
|
||||
@@ -105,9 +106,9 @@ fn main() -> Result<()> {
|
||||
// The game decodes gameplay facts once for both tables (BlockFacts::load_pair).
|
||||
let (facts, behaviours) = BlockFacts::load_pair(&root, assets.registry)?;
|
||||
step("block facts+behaviours");
|
||||
let items = Items::load(&root, assets.registry, &facts)?;
|
||||
let items = Items::load(&root, assets.registry, &facts, &PackOverlay::NONE)?;
|
||||
step("items");
|
||||
let recipes = Recipes::load(&root, &items)?;
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE)?;
|
||||
step("recipes");
|
||||
let biomes = Biomes::load(&root)?;
|
||||
step("biomes");
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, HashMap},
|
||||
path::Path,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex, Weak},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -66,8 +67,36 @@ fn frame(v: &Value) -> Frame {
|
||||
}
|
||||
}
|
||||
impl Tree {
|
||||
/// [`Tree::load`], shared while anyone holds it: a server's advancement
|
||||
/// packets (`ServerAdvancements`) and its game's criteria read the same
|
||||
/// files, and each kept its own copy (7 MB). A world whose data packs bring
|
||||
/// advancements gets its own tree ([`Tree::load_with`]).
|
||||
pub fn shared(data: &Path, overlay: &horizon_world::loot::PackOverlay) -> Arc<Tree> {
|
||||
if overlay.kind("advancement").next().is_some() {
|
||||
return Arc::new(Tree::load_with(data, overlay));
|
||||
}
|
||||
static TREES: Mutex<Vec<(PathBuf, Weak<Tree>)>> = Mutex::new(Vec::new());
|
||||
// Held while loading: a second reader waits for the first one's tree.
|
||||
let mut trees = TREES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
trees.retain(|(_, tree)| tree.strong_count() > 0);
|
||||
if let Some(tree) = trees
|
||||
.iter()
|
||||
.find(|(path, _)| path == data)
|
||||
.and_then(|(_, tree)| tree.upgrade())
|
||||
{
|
||||
return tree;
|
||||
}
|
||||
let tree = Arc::new(Tree::load(data));
|
||||
trees.push((data.to_path_buf(), Arc::downgrade(&tree)));
|
||||
tree
|
||||
}
|
||||
/// Loads `<data>/<namespace>/advancement/**/*.json`.
|
||||
pub fn load(data: &Path) -> Tree {
|
||||
Self::load_with(data, &horizon_world::loot::PackOverlay::NONE)
|
||||
}
|
||||
/// The tree with a world's data packs' advancement files over the game's
|
||||
/// own: the top pack's file for each id replaces it.
|
||||
pub fn load_with(data: &Path, overlay: &horizon_world::loot::PackOverlay) -> Tree {
|
||||
let mut tree = Tree::default();
|
||||
let Ok(namespaces) = std::fs::read_dir(data) else {
|
||||
return tree;
|
||||
@@ -77,6 +106,8 @@ impl Tree {
|
||||
let root = ns.path().join("advancement");
|
||||
collect(&root, &root, &ns.file_name().to_string_lossy(), &mut files);
|
||||
}
|
||||
// One JSON per id, the packs' over the game's own.
|
||||
let mut json: BTreeMap<String, Value> = BTreeMap::new();
|
||||
files.sort();
|
||||
for (id, path) in files {
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
@@ -85,6 +116,17 @@ impl Tree {
|
||||
let Ok(v) = serde_json::from_str::<Value>(&text) else {
|
||||
continue;
|
||||
};
|
||||
json.insert(id, v);
|
||||
}
|
||||
for (id, files) in overlay.kind("advancement") {
|
||||
match serde_json::from_slice::<Value>(horizon_world::loot::PackOverlay::top(files)) {
|
||||
Ok(v) => {
|
||||
json.insert(id.to_owned(), v);
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: pack advancement {id} not loaded: {e}"),
|
||||
}
|
||||
}
|
||||
for (id, v) in json {
|
||||
if let Some(a) = parse(&id, &v) {
|
||||
let index = tree.list.len();
|
||||
for (ci, c) in a.criteria.iter().enumerate() {
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::{
|
||||
items::{ItemStack, Items},
|
||||
particles::{DYE_FIREWORK_COLORS, FireworkExplosion, FireworkShape},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
@@ -1180,7 +1181,7 @@ fn matches_all(ingredients: &[Ingredient], stacks: &[&ItemStack]) -> bool {
|
||||
})
|
||||
}
|
||||
impl Recipes {
|
||||
pub fn load(root: &Path, items: &Items) -> Result<Self> {
|
||||
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Result<Self> {
|
||||
let mut crafting = Vec::new();
|
||||
let mut cooking = Vec::new();
|
||||
let mut meta = HashMap::new();
|
||||
@@ -1193,27 +1194,47 @@ impl Recipes {
|
||||
files.sort();
|
||||
Ok(files)
|
||||
};
|
||||
let stem = |p: &Path| {
|
||||
p.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_owned()
|
||||
};
|
||||
// Vanilla's recipes by bare name, then content mods' as `<ns>:<name>`.
|
||||
let mut files: Vec<(String, PathBuf)> = json_files(&root.join("data/minecraft/recipe"))?
|
||||
.into_iter()
|
||||
.map(|p| (String::new(), p))
|
||||
.collect();
|
||||
let mut sources: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
for p in json_files(&root.join("data/minecraft/recipe"))? {
|
||||
sources.push((stem(&p), fs::read(&p)?));
|
||||
}
|
||||
for namespace in crate::mod_content::namespaces(root) {
|
||||
let dir = root.join(format!("data/{namespace}/recipe"));
|
||||
if dir.is_dir() {
|
||||
files.extend(
|
||||
json_files(&dir)?
|
||||
.into_iter()
|
||||
.map(|p| (format!("{namespace}:"), p)),
|
||||
);
|
||||
for p in json_files(&dir)? {
|
||||
sources.push((format!("{namespace}:{}", stem(&p)), fs::read(&p)?));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (prefix, path) in files {
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||
let name = format!("{prefix}{stem}");
|
||||
// The world's packs' recipes over the game's own: the top pack's
|
||||
// file for each id replaces it (`SimpleJsonResourceReloadListener`
|
||||
// sees one resource per id, the resource stack's top).
|
||||
for (id, files) in overlay.kind("recipe") {
|
||||
let Some((ns, path)) = id.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let name = if ns == "minecraft" {
|
||||
path.to_owned()
|
||||
} else {
|
||||
id.to_owned()
|
||||
};
|
||||
let bytes = PackOverlay::top(files).to_vec();
|
||||
match sources.iter_mut().find(|(n, _)| *n == name) {
|
||||
Some(slot) => slot.1 = bytes,
|
||||
None => sources.push((name, bytes)),
|
||||
}
|
||||
}
|
||||
for (name, bytes) in sources {
|
||||
// Vanilla's RecipeManager logs a file that does not parse and
|
||||
// goes on ("Couldn't parse data file").
|
||||
let v: Value = match serde_json::from_slice(&fs::read(&path)?) {
|
||||
let v: Value = match serde_json::from_slice(&bytes) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: recipe {name} not loaded: {e}");
|
||||
@@ -1420,7 +1441,7 @@ impl Recipes {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let curses = crate::items::load_tags(root, "enchantment")?
|
||||
let curses = crate::items::load_tags(root, "enchantment", overlay)?
|
||||
.remove("minecraft:curse")
|
||||
.unwrap_or_default();
|
||||
Ok(Self {
|
||||
|
||||
@@ -20,6 +20,9 @@ pub struct Tab {
|
||||
pub column: u32,
|
||||
pub search: bool,
|
||||
pub inventory: bool,
|
||||
/// `CreativeModeTab.Type.HOTBAR`: the saved toolbars, filled when the
|
||||
/// tab is selected (`set_saved_hotbars`).
|
||||
pub hotbar: bool,
|
||||
}
|
||||
/// What the client's creative search trees (SessionSearchTrees) index for one
|
||||
/// stack of the search tab: its creative tooltip lines and its item tags.
|
||||
@@ -229,7 +232,7 @@ impl Creative {
|
||||
}
|
||||
search_items = items.clone();
|
||||
}
|
||||
if kind == "HOTBAR" || t["visible"] == false {
|
||||
if t["visible"] == false {
|
||||
continue;
|
||||
}
|
||||
let icon = t["icon"]
|
||||
@@ -244,6 +247,7 @@ impl Creative {
|
||||
column: t["column"].as_u64().unwrap_or(0) as u32,
|
||||
search: kind == "SEARCH",
|
||||
inventory: kind == "INVENTORY",
|
||||
hotbar: kind == "HOTBAR",
|
||||
});
|
||||
}
|
||||
Ok(Self::new(tabs, search_items, search_index))
|
||||
@@ -277,6 +281,7 @@ impl Creative {
|
||||
column: t["column"].as_u64().unwrap_or(0) as u32,
|
||||
search: kind == "SEARCH",
|
||||
inventory: kind == "INVENTORY",
|
||||
hotbar: false,
|
||||
});
|
||||
}
|
||||
let search_index = search_items
|
||||
@@ -333,6 +338,7 @@ impl Creative {
|
||||
column: t.column,
|
||||
search: t.search,
|
||||
inventory: t.inventory,
|
||||
hotbar: t.hotbar,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -464,6 +470,23 @@ impl Creative {
|
||||
});
|
||||
items
|
||||
}
|
||||
/// The stack in grid cell `index` (row-major from the tab's top), None
|
||||
/// for an empty cell: a saved toolbar's empty slot, or past the end.
|
||||
pub fn shown(&self, names: &Names, index: usize) -> Option<ItemStack> {
|
||||
self.items(names)
|
||||
.get(index)
|
||||
.filter(|s| s.count > 0)
|
||||
.cloned()
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.selectTab` for the HOTBAR tab: the nine
|
||||
/// saved rows of `HotbarManager` as the tab's items, an empty stack (count
|
||||
/// 0) for an empty slot.
|
||||
pub fn set_saved_hotbars(&mut self, items: Vec<ItemStack>) {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|t| t.hotbar) {
|
||||
tab.items = items;
|
||||
}
|
||||
self.forget_items();
|
||||
}
|
||||
/// Drop the shown items, so the next `items` works them out again: a
|
||||
/// reload can rename items without changing the Search label.
|
||||
pub fn forget_items(&self) {
|
||||
@@ -664,6 +687,13 @@ pub enum DisplayClick {
|
||||
Drop(ItemStack),
|
||||
}
|
||||
|
||||
/// Whether a stack has `minecraft:creative_slot_lock`.
|
||||
pub fn slot_locked(stack: &ItemStack) -> bool {
|
||||
stack
|
||||
.data()
|
||||
.is_some_and(|d| d.other("minecraft:creative_slot_lock").is_some())
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen.slotClicked` on a display slot
|
||||
/// (`slot.container == CONTAINER`): `shown` is the slot's item (None past
|
||||
/// the tab's end) and `max` gives an item's max stack size. The tab's items
|
||||
@@ -676,6 +706,11 @@ pub fn click_display(
|
||||
input: crate::menu::Input,
|
||||
) -> DisplayClick {
|
||||
use crate::menu::Input;
|
||||
// CustomCreativeSlot.mayPickup: a `creative_slot_lock` stack (the saved
|
||||
// toolbars' hint) can't be clicked at all.
|
||||
if shown.is_some_and(slot_locked) {
|
||||
return DisplayClick::Nothing;
|
||||
}
|
||||
let copy = |s: &ItemStack, count: u32| ItemStack { count, ..s.clone() };
|
||||
let quick_key = input == Input::QuickMove;
|
||||
match input {
|
||||
@@ -738,6 +773,7 @@ pub fn click_display(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
fn stack(item: &str, count: u32) -> ItemStack {
|
||||
ItemStack {
|
||||
@@ -1047,6 +1083,53 @@ mod tests {
|
||||
assert_eq!(c.search.value, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_hotbars_tab_shows_its_rows_and_locks_the_hint() {
|
||||
let mut c = Creative::from_json(&serde_json::json!({"schema": 2, "tabs": [
|
||||
{"id": "minecraft:hotbar", "type": "HOTBAR", "row": "TOP", "column": 5, "title": "Saved Hotbars",
|
||||
"icon": "{count:1,id:\"minecraft:bookshelf\"}", "visible": true, "items": []},
|
||||
{"id": "minecraft:inventory", "type": "INVENTORY", "row": "BOTTOM", "column": 6, "title": "Survival Inventory",
|
||||
"icon": "{count:1,id:\"minecraft:chest\"}", "visible": true, "items": []}
|
||||
]}))
|
||||
.unwrap();
|
||||
assert!(c.tab().hotbar);
|
||||
assert!(!c.tab_headers()[1].hotbar);
|
||||
let mut hint = stack("paper", 1);
|
||||
hint.data_mut().set_other(
|
||||
"minecraft:creative_slot_lock",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
// A saved sword in slot 0, an empty slot, then the hint.
|
||||
c.set_saved_hotbars(vec![stack("diamond_sword", 1), stack("air", 0), hint]);
|
||||
let names = Names {
|
||||
map: HashMap::new(),
|
||||
};
|
||||
assert_eq!(c.shown(&names, 0), Some(stack("diamond_sword", 1)));
|
||||
assert_eq!(c.shown(&names, 1), None);
|
||||
assert_eq!(c.shown(&names, 3), None);
|
||||
let hint = c.shown(&names, 2).unwrap();
|
||||
assert!(slot_locked(&hint));
|
||||
assert!(!slot_locked(&stack("diamond_sword", 1)));
|
||||
// CustomCreativeSlot.mayPickup: the hint is never taken, and a carried
|
||||
// stack stays as it is.
|
||||
use crate::menu::Input::*;
|
||||
for (carried, button, input) in [
|
||||
(None, 0, Pickup),
|
||||
(None, 0, QuickMove),
|
||||
(None, 2, Clone),
|
||||
(None, 3, Swap),
|
||||
(None, 0, Throw),
|
||||
(Some(stack("dirt", 2)), 0, Pickup),
|
||||
(Some(stack("dirt", 2)), 1, Pickup),
|
||||
] {
|
||||
let mut carried = carried;
|
||||
let before = carried.clone();
|
||||
let effect = click_display(&mut carried, Some(&hint), |_| 64, button, input);
|
||||
assert_eq!(effect, DisplayClick::Nothing);
|
||||
assert_eq!(carried, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_matches_vanilla_trees() {
|
||||
let c = creative(0);
|
||||
@@ -1191,6 +1274,7 @@ mod tests {
|
||||
column,
|
||||
search: false,
|
||||
inventory: false,
|
||||
hotbar: false,
|
||||
};
|
||||
// Top row, second column: the button spans x 27..53, y -28..4.
|
||||
let t = tab(true, 1);
|
||||
|
||||
@@ -179,12 +179,17 @@ fn input(font: &Font, i: usize, control: &Control) -> Element {
|
||||
};
|
||||
}
|
||||
// CommonLayouts.labeledElement: the label, 4 below it the box,
|
||||
// both on the left.
|
||||
// both on the left. The label is a plain StringWidget: it has no
|
||||
// click handler, so a click event in it does nothing.
|
||||
let mut shown = label.clone();
|
||||
for span in &mut shown.spans {
|
||||
span.style.click = None;
|
||||
}
|
||||
Element {
|
||||
w: w.max(font.width_text(label)),
|
||||
h: 9.0 + 4.0 + 20.0,
|
||||
parts: vec![
|
||||
Part::Line(label.clone(), 0.0, 0.0),
|
||||
Part::Line(shown, 0.0, 0.0),
|
||||
Part::Field {
|
||||
input: i,
|
||||
rect: [0.0, 13.0, w, 20.0],
|
||||
|
||||
@@ -980,6 +980,7 @@ mod experience_tests {
|
||||
#[cfg(test)]
|
||||
mod fire_environment_tests {
|
||||
use super::*;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
#[test]
|
||||
fn ordinary_items_burn_but_native_netherite_resists_lava() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -991,7 +992,8 @@ mod fire_environment_tests {
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let items = crate::items::Items::load(&root, assets.registry, &facts).unwrap();
|
||||
let items =
|
||||
crate::items::Items::load(&root, assets.registry, &facts, &PackOverlay::NONE).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
world
|
||||
|
||||
@@ -87,6 +87,11 @@ impl Base<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The mobs that save `Age` and `AgeLocked`.
|
||||
fn saves_age(s: crate::mobs::Species) -> bool {
|
||||
use crate::mobs::{Species, drops::can_be_baby, finalize::ageable};
|
||||
s == Species::Tadpole || (ageable(s) && can_be_baby(s))
|
||||
}
|
||||
/// `LivingEntity` + `Mob` (+ `AgeableMob`, creeper and skeleton specifics).
|
||||
/// The mobs whose class extends vanilla's `Animal` (and so save `InLove`):
|
||||
/// not allays, villagers, golems, dolphins or fish.
|
||||
@@ -150,6 +155,123 @@ fn player_uuid() -> [i32; 4] {
|
||||
u as u32 as i32,
|
||||
]
|
||||
}
|
||||
/// `ResolvableProfile.CODEC`: the name, id and properties of a profile, then the
|
||||
/// skin's texture ids and model (`PlayerSkin.Patch`: "texture", "cape", "elytra", "model").
|
||||
fn profile_nbt(p: &horizon_protocol::entity_data::Profile) -> Tag {
|
||||
let mut entries: Vec<(&'static str, Tag)> = Vec::new();
|
||||
if let Some(name) = &p.name {
|
||||
entries.push(("name", string(name)));
|
||||
}
|
||||
if let Some(id) = p.uuid {
|
||||
entries.push((
|
||||
"id",
|
||||
Tag::IntArray(vec![
|
||||
(id >> 96) as i32,
|
||||
(id >> 64) as i32,
|
||||
(id >> 32) as i32,
|
||||
id as i32,
|
||||
]),
|
||||
));
|
||||
}
|
||||
if !p.properties.is_empty() {
|
||||
let properties = p
|
||||
.properties
|
||||
.iter()
|
||||
.map(|property| {
|
||||
let mut entries: Vec<(&'static str, Tag)> = vec![
|
||||
("name", string(&property.name)),
|
||||
("value", string(&property.value)),
|
||||
];
|
||||
if let Some(signature) = &property.signature {
|
||||
entries.push(("signature", string(signature)));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
})
|
||||
.collect();
|
||||
entries.push(("properties", Tag::List(properties)));
|
||||
}
|
||||
for (key, texture) in [
|
||||
("texture", &p.body),
|
||||
("cape", &p.cape),
|
||||
("elytra", &p.elytra),
|
||||
] {
|
||||
if let Some(texture) = texture {
|
||||
entries.push((key, string(texture)));
|
||||
}
|
||||
}
|
||||
if let Some(slim) = p.slim {
|
||||
entries.push(("model", string(if slim { "slim" } else { "wide" })));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
}
|
||||
/// `ResolvableProfile.CODEC` read back: the compound `profile_nbt` writes, or a
|
||||
/// bare player name (`Codec.withAlternative(FULL_CODEC, PLAYER_NAME, ...)`).
|
||||
fn profile_from_nbt(tag: &Tag) -> Option<horizon_protocol::entity_data::Profile> {
|
||||
if let Tag::String(name) = tag {
|
||||
return Some(horizon_protocol::entity_data::Profile {
|
||||
name: Some(name.to_string_lossy()),
|
||||
uuid: None,
|
||||
properties: Vec::new(),
|
||||
body: None,
|
||||
cape: None,
|
||||
elytra: None,
|
||||
slim: None,
|
||||
});
|
||||
}
|
||||
let property = |name: String, value: &Tag, signature: Option<String>| match value {
|
||||
Tag::String(v) => Some(horizon_protocol::packets::login::Property {
|
||||
name,
|
||||
value: v.to_string_lossy(),
|
||||
signature,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let properties: Vec<horizon_protocol::packets::login::Property> = match tag.get("properties") {
|
||||
// `ExtraCodecs.PROPERTY_MAP`: a list of properties ...
|
||||
Some(Tag::List(list)) => list
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
let name = match p.get("name")? {
|
||||
Tag::String(s) => s.to_string_lossy(),
|
||||
_ => return None,
|
||||
};
|
||||
let signature = match p.get("signature") {
|
||||
Some(Tag::String(s)) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
};
|
||||
property(name, p.get("value")?, signature)
|
||||
})
|
||||
.collect(),
|
||||
// ... or a map from a name to its values.
|
||||
Some(Tag::Compound(map)) => map
|
||||
.iter()
|
||||
.flat_map(|(name, values)| match values {
|
||||
Tag::List(values) => values
|
||||
.iter()
|
||||
.filter_map(|v| property(name.to_string_lossy(), v, None))
|
||||
.collect::<Vec<_>>(),
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let text = |key: &str| match tag.get(key) {
|
||||
Some(Tag::String(s)) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
};
|
||||
Some(horizon_protocol::entity_data::Profile {
|
||||
name: text("name"),
|
||||
uuid: uuid_from_tag(tag.get("id")).map(|a| {
|
||||
a.iter()
|
||||
.fold(0u128, |u, &x| (u << 32) | u128::from(x as u32))
|
||||
}),
|
||||
properties,
|
||||
body: text("texture"),
|
||||
cape: text("cape"),
|
||||
elytra: text("elytra"),
|
||||
slim: text("model").map(|model| model == "slim"),
|
||||
})
|
||||
}
|
||||
pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let base = Base {
|
||||
id: &m.kind.id,
|
||||
@@ -196,6 +318,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
put(&mut tag, "angry_at", Tag::IntArray(player_uuid().to_vec()));
|
||||
}
|
||||
}
|
||||
// `Mannequin.addAdditionalSaveData`: the profile whose skin it wears.
|
||||
if m.species() == crate::mobs::Species::Mannequin
|
||||
&& let Some(profile) = &m.profile
|
||||
{
|
||||
put(&mut tag, "profile", profile_nbt(profile));
|
||||
}
|
||||
// `Entity.saveWithoutId` writes the flag only when set.
|
||||
if m.name_visible {
|
||||
put(&mut tag, "CustomNameVisible", Tag::Byte(1));
|
||||
@@ -286,19 +414,14 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
let id = m.kind.id.as_str();
|
||||
// Only breedable animals have an age; a loaded creature that was saved without
|
||||
// one (a frog) stays without.
|
||||
let aged = raw.is_none_or(|r| r.get("Age").is_some());
|
||||
if (m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin) && aged {
|
||||
// AgeableMob.addAdditionalSaveData, only if canBeABaby (not a frog or a
|
||||
// parrot); a tadpole saves its own age.
|
||||
let species = m.species();
|
||||
if saves_age(species) {
|
||||
map.insert("Age".into(), Tag::Int(m.extra.age));
|
||||
map.insert("AgeLocked".into(), Tag::Byte(i8::from(m.extra.age_locked)));
|
||||
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
|
||||
for (key, value) in [
|
||||
("Age", Tag::Int(0)),
|
||||
("ForcedAge", Tag::Int(0)),
|
||||
("AgeLocked", Tag::Byte(0)),
|
||||
] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
if species != crate::mobs::Species::Tadpole {
|
||||
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
|
||||
}
|
||||
}
|
||||
// Animal.addAdditionalSaveData: the love ticks left, whatever the save had.
|
||||
@@ -324,19 +447,29 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
);
|
||||
}
|
||||
match id {
|
||||
// Fox.addAdditionalSaveData; Horizon models only the type.
|
||||
// Fox.addAdditionalSaveData; Horizon models only the type, the
|
||||
// flags and no trusted players yet.
|
||||
"minecraft:fox" => {
|
||||
let kind = if m.extra.variant == 1 { "snow" } else { "red" };
|
||||
map.insert("Type".into(), string(kind));
|
||||
for (key, value) in [
|
||||
("Trusted", Tag::List(Vec::new())),
|
||||
("Sleeping", Tag::Byte(0)),
|
||||
("Sitting", Tag::Byte(0)),
|
||||
("Crouching", Tag::Byte(0)),
|
||||
map.entry("Trusted".into()).or_insert(Tag::List(Vec::new()));
|
||||
for (key, bit) in [
|
||||
("Sleeping", crate::mobs::fox::SLEEPING),
|
||||
("Sitting", crate::mobs::fox::SITTING),
|
||||
("Crouching", crate::mobs::fox::CROUCHING),
|
||||
] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
map.insert(
|
||||
key.into(),
|
||||
Tag::Byte(i8::from(crate::mobs::fox::flag(m, bit))),
|
||||
);
|
||||
}
|
||||
}
|
||||
// MushroomCow.addAdditionalSaveData: the Variant codec's name (stew
|
||||
// effects are not modelled).
|
||||
"minecraft:mooshroom" => {
|
||||
let kind = if m.extra.variant == 1 { "brown" } else { "red" };
|
||||
map.insert("Type".into(), string(kind));
|
||||
}
|
||||
// Rabbit.addAdditionalSaveData (the legacy id; 99 is the killer bunny).
|
||||
"minecraft:rabbit" => {
|
||||
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
|
||||
@@ -531,6 +664,8 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
"minecraft:villager" => {
|
||||
map.insert("VillagerData".into(), villager_data(m));
|
||||
// A loaded villager with VillagerData is finalized.
|
||||
map.insert("VillagerDataFinalized".into(), Tag::Byte(1));
|
||||
let t = m.extra.trade.as_deref().cloned().unwrap_or_default();
|
||||
map.insert("Xp".into(), Tag::Int(t.xp));
|
||||
map.insert("LastRestock".into(), Tag::Long(t.last_restock));
|
||||
@@ -584,32 +719,56 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
]));
|
||||
}
|
||||
map.insert("Inventory".into(), Tag::List(inv));
|
||||
if let Some(st) = &m.extra.allay {
|
||||
map.insert("listener".into(), st.vibration.to_nbt());
|
||||
}
|
||||
map.insert(
|
||||
"DuplicationCooldown".into(),
|
||||
Tag::Long(i64::from(m.extra.egg_time)),
|
||||
);
|
||||
let mut memories: Vec<(&str, Tag)> = Vec::new();
|
||||
if m.extra.tame {
|
||||
map.insert(
|
||||
"Brain".into(),
|
||||
memories.push((
|
||||
"minecraft:liked_player",
|
||||
Tag::compound([(
|
||||
"memories",
|
||||
Tag::compound([(
|
||||
"minecraft:liked_player",
|
||||
Tag::compound([(
|
||||
"value",
|
||||
Tag::IntArray(m.extra.owner.unwrap_or([0, 0, 0, 1]).to_vec()),
|
||||
)]),
|
||||
)]),
|
||||
"value",
|
||||
Tag::IntArray(m.extra.owner.unwrap_or([0, 0, 0, 1]).to_vec()),
|
||||
)]),
|
||||
);
|
||||
));
|
||||
}
|
||||
// GlobalPos.CODEC, and the cooldown while it runs.
|
||||
if let Some(liked) = m.extra.allay.as_ref().and_then(|st| st.noteblock) {
|
||||
memories.push((
|
||||
"minecraft:liked_noteblock",
|
||||
Tag::compound([(
|
||||
"value",
|
||||
Tag::compound([
|
||||
("dimension", string(liked.dimension)),
|
||||
("pos", Tag::IntArray(liked.pos.to_vec())),
|
||||
]),
|
||||
)]),
|
||||
));
|
||||
}
|
||||
if m.extra.ints[2] > 0 {
|
||||
memories.push((
|
||||
"minecraft:liked_noteblock_cooldown_ticks",
|
||||
Tag::compound([("value", Tag::Int(m.extra.ints[2]))]),
|
||||
));
|
||||
}
|
||||
// Always written, so a forgotten note block does not come back
|
||||
// from the compound the allay was loaded with.
|
||||
map.insert(
|
||||
"Brain".into(),
|
||||
Tag::compound([("memories", Tag::compound(memories))]),
|
||||
);
|
||||
}
|
||||
"minecraft:camel" | "minecraft:camel_husk" => {
|
||||
// LastPoseTick: negative while sitting.
|
||||
let sitting = crate::mobs::camel::sitting(m);
|
||||
// LastPoseTick: the pose change time, negative while sitting. The
|
||||
// game time is not known here; the world's save overwrites it
|
||||
// with the real one (`camel::synced_pose_tick`).
|
||||
map.insert(
|
||||
"LastPoseTick".into(),
|
||||
Tag::Long(if sitting { -1 } else { 0 }),
|
||||
Tag::Long(crate::mobs::camel::synced_pose_tick(m, 0)),
|
||||
);
|
||||
if let Some(saddle) = m.saddle_item() {
|
||||
map.insert(
|
||||
@@ -667,6 +826,13 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.insert("Peek".into(), Tag::Byte(m.extra.counter as i8));
|
||||
map.insert("Color".into(), Tag::Byte(m.extra.color as i8));
|
||||
}
|
||||
// GlowSquid.addAdditionalSaveData.
|
||||
"minecraft:glow_squid" => {
|
||||
map.insert(
|
||||
"DarkTicksRemaining".into(),
|
||||
Tag::Int(crate::mobs::aquatic::dark_ticks(m)),
|
||||
);
|
||||
}
|
||||
"minecraft:slime" | "minecraft:magma_cube" => {
|
||||
map.insert("Size".into(), Tag::Int(m.extra.size.max(1) - 1));
|
||||
map.insert("wasOnGround".into(), Tag::Byte(i8::from(m.extra.flag2)));
|
||||
@@ -728,9 +894,64 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
) {
|
||||
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
|
||||
}
|
||||
// NeutralMob.addPersistentAngerSaveData; the bee saves its own.
|
||||
if matches!(
|
||||
id,
|
||||
"minecraft:iron_golem"
|
||||
| "minecraft:wolf"
|
||||
| "minecraft:polar_bear"
|
||||
| "minecraft:zombified_piglin"
|
||||
| "minecraft:enderman"
|
||||
) {
|
||||
map.entry("anger_end_time".into()).or_insert(Tag::Long(-1));
|
||||
}
|
||||
if let Some(a) = m.extra.attributes.as_deref() {
|
||||
put_attributes(map, m, a);
|
||||
}
|
||||
// The attribute their load makes, which AttributeMap.pack then saves:
|
||||
// Zombie and Piglin.setBaby (even for an adult) the speed, Rabbit.setVariant
|
||||
// the attack damage.
|
||||
use crate::mobs::finalize::Attr;
|
||||
let made = if crate::mobs::doors::is_zombie(species) || species == crate::mobs::Species::Piglin
|
||||
{
|
||||
Some(Attr::MovementSpeed)
|
||||
} else if species == crate::mobs::Species::Rabbit {
|
||||
Some(Attr::AttackDamage)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(attr) = made {
|
||||
let base = crate::mobs::finalize::base_value(m, attr);
|
||||
let mut list = match map.get(&"attributes".into()) {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let attr_id = format!("minecraft:{}", attr.name());
|
||||
if !list
|
||||
.iter()
|
||||
.any(|t| text(t.get("id")).as_deref() == Some(attr_id.as_str()))
|
||||
{
|
||||
list.push(Tag::compound([
|
||||
("id", string(&attr_id)),
|
||||
("base", Tag::Double(base.to_bits())),
|
||||
]));
|
||||
map.insert("attributes".into(), Tag::List(list));
|
||||
}
|
||||
}
|
||||
// Mob.addAdditionalSaveData: the chances that are not the default.
|
||||
if let Some(chances) = &m.extra.drop_chances {
|
||||
let set: Vec<(&str, Tag)> = crate::mobs::drops::SLOTS
|
||||
.iter()
|
||||
.zip(chances.iter())
|
||||
.filter(|(_, c)| **c != crate::mobs::drops::DEFAULT_DROP_CHANCE)
|
||||
.map(|(slot, c)| (*slot, Tag::Float(c.to_bits())))
|
||||
.collect();
|
||||
if set.is_empty() {
|
||||
map.remove(&"drop_chances".into());
|
||||
} else {
|
||||
map.insert("drop_chances".into(), Tag::compound(set));
|
||||
}
|
||||
}
|
||||
crate::mobs::raider::write_nbt(m, map);
|
||||
crate::mobs::riders::write_nbt(m, map);
|
||||
// LivingEntity equipment: what the mob holds now.
|
||||
@@ -1046,11 +1267,24 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.angry_at = Some(crate::mobs::Who::Player);
|
||||
}
|
||||
}
|
||||
// `Mannequin.readAdditionalSaveData`: the profile comes back with the skin.
|
||||
if m.species() == crate::mobs::Species::Mannequin {
|
||||
m.profile = tag.get("profile").and_then(profile_from_nbt);
|
||||
}
|
||||
m.extra.pick_up_loot = flag(tag, "CanPickUpLoot");
|
||||
m.custom_name = custom_name(tag);
|
||||
m.custom_name_text = tag.get("CustomName").map(name_text);
|
||||
m.extra.left_handed = flag(tag, "LeftHanded");
|
||||
if m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin {
|
||||
if let Some(Tag::Compound(c)) = tag.get("drop_chances") {
|
||||
let mut chances = [crate::mobs::drops::DEFAULT_DROP_CHANCE; 8];
|
||||
for (chance, slot) in chances.iter_mut().zip(crate::mobs::drops::SLOTS) {
|
||||
if let Some(Tag::Float(v)) = c.get(&slot.into()) {
|
||||
*chance = f32::from_bits(*v);
|
||||
}
|
||||
}
|
||||
m.extra.drop_chances = Some(Box::new(chances));
|
||||
}
|
||||
if saves_age(m.species()) {
|
||||
m.extra.age = number(tag.get("Age")).unwrap_or(0) as i32;
|
||||
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
|
||||
m.extra.age_locked = flag(tag, "AgeLocked");
|
||||
@@ -1181,6 +1415,21 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
match m.species() {
|
||||
crate::mobs::Species::Fox => {
|
||||
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("snow"));
|
||||
// Fox.readAdditionalSaveData: the sleeping, sitting and crouching flags.
|
||||
for (key, bit) in [
|
||||
("Sleeping", crate::mobs::fox::SLEEPING),
|
||||
("Sitting", crate::mobs::fox::SITTING),
|
||||
("Crouching", crate::mobs::fox::CROUCHING),
|
||||
] {
|
||||
if flag(tag, key) {
|
||||
m.extra.ints[0] |= bit;
|
||||
}
|
||||
}
|
||||
}
|
||||
// MushroomCow.readAdditionalSaveData: a missing or unknown name is
|
||||
// Variant.DEFAULT, red.
|
||||
crate::mobs::Species::Mooshroom => {
|
||||
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("brown"));
|
||||
}
|
||||
crate::mobs::Species::Rabbit => {
|
||||
// Rabbit.Variant.byId: an unknown id is brown.
|
||||
@@ -1256,6 +1505,9 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.ints[1] = number(first.get("count")).unwrap_or(1) as i32;
|
||||
}
|
||||
m.extra.egg_time = number(tag.get("DuplicationCooldown")).unwrap_or(0) as i32;
|
||||
if let (Some(st), Some(listener)) = (m.extra.allay.as_mut(), tag.get("listener")) {
|
||||
st.vibration = crate::sculk::Data::from_nbt(listener);
|
||||
}
|
||||
let liked = tag
|
||||
.get("Brain")
|
||||
.and_then(|b| b.get("memories"))
|
||||
@@ -1264,13 +1516,37 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.owner = liked
|
||||
.and_then(|l| l.get("value"))
|
||||
.and_then(|v| crate::save::uuid_from_tag(Some(v)));
|
||||
let memory = |key: &str| {
|
||||
tag.get("Brain")
|
||||
.and_then(|b| b.get("memories"))
|
||||
.and_then(|b| b.get(key))
|
||||
.and_then(|l| l.get("value"))
|
||||
};
|
||||
m.extra.ints[2] =
|
||||
number(memory("minecraft:liked_noteblock_cooldown_ticks")).unwrap_or(0) as i32;
|
||||
if let Some(st) = m.extra.allay.as_mut()
|
||||
&& let Some(noteblock) = memory("minecraft:liked_noteblock")
|
||||
&& let Some(Tag::IntArray(p)) = noteblock.get("pos")
|
||||
&& p.len() == 3
|
||||
{
|
||||
st.noteblock = Some(crate::mobs::allay::GlobalPos {
|
||||
dimension: crate::mobs::allay::dimension_id(
|
||||
&text(noteblock.get("dimension")).unwrap_or_default(),
|
||||
),
|
||||
pos: [p[0], p[1], p[2]],
|
||||
});
|
||||
}
|
||||
}
|
||||
if crate::mobs::camel::is_camel(m.species()) {
|
||||
let sitting = matches!(tag.get("LastPoseTick"), Some(Tag::Long(v)) if *v < 0);
|
||||
// The tick is a game time: `camel::finish_load` puts it on the mob's
|
||||
// clock once the world's time is known.
|
||||
let tick = match tag.get("LastPoseTick") {
|
||||
Some(Tag::Long(v)) => *v,
|
||||
_ => 0,
|
||||
};
|
||||
crate::mobs::camel::stand_up_instantly(&mut m);
|
||||
if sitting {
|
||||
m.extra.egg_time = -1 - 400;
|
||||
}
|
||||
m.extra.pose_change_tick = tick;
|
||||
m.extra.camel_sitting = tick < 0;
|
||||
// Camels are always tame (`Camel.register` in Horizon).
|
||||
m.extra.tame = true;
|
||||
}
|
||||
@@ -1357,6 +1633,13 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.color = number(tag.get("Color")).map_or(0, |c| (c as u8) & 15);
|
||||
m.extra.sheared = flag(tag, "Sheared");
|
||||
}
|
||||
// GlowSquid.readAdditionalSaveData: not dimmed when absent.
|
||||
if m.species() == crate::mobs::Species::GlowSquid {
|
||||
crate::mobs::aquatic::set_dark_ticks(
|
||||
&mut m,
|
||||
number(tag.get("DarkTicksRemaining")).unwrap_or(0) as i32,
|
||||
);
|
||||
}
|
||||
// IronGolem.readAdditionalSaveData: not player created when absent.
|
||||
if m.species() == crate::mobs::Species::IronGolem {
|
||||
m.extra.player_created = flag(tag, "PlayerCreated");
|
||||
@@ -3014,7 +3297,34 @@ mod sheep_nbt_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foxes_rabbits_and_goats_keep_their_kind() {
|
||||
fn a_baby_villager_keeps_its_age() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let saved = Tag::compound([
|
||||
("id", string("minecraft:villager")),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
("Age", Tag::Int(-21359)),
|
||||
]);
|
||||
let m = mob_from_nbt(&saved, &types, 1).unwrap();
|
||||
assert_eq!(m.extra.age, -21359);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
assert_eq!(number(tag.get("Age")), Some(-21359));
|
||||
assert_eq!(number(tag.get("AgeLocked")), Some(0));
|
||||
assert_eq!(number(tag.get("VillagerDataFinalized")), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foxes_mooshrooms_rabbits_and_goats_keep_their_kind() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
@@ -3038,6 +3348,17 @@ mod sheep_nbt_tests {
|
||||
assert_eq!(text(tag.get("Type")).as_deref(), Some("snow"));
|
||||
assert_eq!(number(tag.get("Sleeping")), Some(0));
|
||||
assert_eq!(back.extra.variant, 1);
|
||||
// MushroomCow: a brown mooshroom stays brown, and a save without (or
|
||||
// with an unknown) Type reads as red.
|
||||
let mut mooshroom = new("minecraft:mooshroom");
|
||||
mooshroom.extra.variant = 1;
|
||||
let (back, tag) = round_trip(&mooshroom);
|
||||
assert_eq!(text(tag.get("Type")).as_deref(), Some("brown"));
|
||||
assert_eq!(back.extra.variant, 1);
|
||||
mooshroom.extra.variant = 0;
|
||||
let (back, tag) = round_trip(&mooshroom);
|
||||
assert_eq!(text(tag.get("Type")).as_deref(), Some("red"));
|
||||
assert_eq!(back.extra.variant, 0);
|
||||
let mut rabbit = new("minecraft:rabbit");
|
||||
rabbit.extra.variant = 4;
|
||||
let (back, tag) = round_trip(&rabbit);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! FallingBlockEntity's impact damage, anvil wear, saved impact settings and
|
||||
//! the cobwebs that hold it back.
|
||||
//! the blocks it moves through (cobwebs, bubble columns).
|
||||
use crate::motion::BubbleColumn;
|
||||
use crate::{
|
||||
mobs::{Damage as MobDamage, DamageKind, Mob},
|
||||
save::{float, number, put},
|
||||
@@ -118,30 +119,76 @@ pub fn bounds(position: Vec3) -> Aabb {
|
||||
pub fn web_multiplier() -> Vec3 {
|
||||
Vec3::new(0.25, f64::from(0.05_f32), 0.25)
|
||||
}
|
||||
/// What a block a falling block moves through does to it in `entityInside`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Inside {
|
||||
/// `WebBlock`: `Entity.makeStuckInBlock`.
|
||||
Cobweb,
|
||||
/// `BubbleColumnBlock`: lifts or drags it.
|
||||
BubbleColumn(BubbleColumn),
|
||||
Nothing,
|
||||
}
|
||||
/// `Entity.applyEffectsFromBlocks` after a falling block's move this tick
|
||||
/// (`from` to `to`; `original` the move it asked for, when the move was
|
||||
/// recorded): whether `checkInsideBlocks` reaches a cobweb (`is_web`), whose
|
||||
/// `entityInside` makes it stuck. Each movement sweeps its axes in
|
||||
/// `Direction.axisStepOrder` within 16 iterations, then the end alone.
|
||||
pub fn passes_cobweb(
|
||||
/// recorded; `velocity` already zeroed on the axes `Entity.move` collided on):
|
||||
/// the blocks it went through (`block` says what each is) push it and reset
|
||||
/// its fall. Returns whether a cobweb made it stuck.
|
||||
pub fn apply_effects_from_blocks(
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
original: Option<Vec3>,
|
||||
mut is_web: impl FnMut(BlockPos) -> bool,
|
||||
velocity: &mut Vec3,
|
||||
fall_distance: &mut f64,
|
||||
mut block: impl FnMut(BlockPos) -> Inside,
|
||||
) -> bool {
|
||||
let mut stuck = false;
|
||||
inside_blocks(from, to, original, |p, intersects| match block(p) {
|
||||
Inside::Cobweb => {
|
||||
// makeStuckInBlock forgets the fall.
|
||||
*fall_distance = 0.0;
|
||||
stuck = true;
|
||||
}
|
||||
// BubbleColumnBlock.entityInside acts only when intersecting.
|
||||
Inside::BubbleColumn(column) if intersects => {
|
||||
if column.push(velocity) {
|
||||
*fall_distance = 0.0;
|
||||
}
|
||||
}
|
||||
Inside::BubbleColumn(_) | Inside::Nothing => {}
|
||||
});
|
||||
stuck
|
||||
}
|
||||
/// Each block `checkInsideBlocks` reaches, once, in order, with
|
||||
/// whether its `entityInside` counts the block as intersected (every block of a
|
||||
/// movement a block or longer, else only those the box ends in). Each movement
|
||||
/// sweeps its axes in `Direction.axisStepOrder` within 16 iterations, then the
|
||||
/// end alone. The blocks the caller acts on (cobwebs, bubble columns) have a
|
||||
/// whole-block `getEntityInsideCollisionShape`, so every one visited counts.
|
||||
fn inside_blocks(
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
original: Option<Vec3>,
|
||||
mut visit: impl FnMut(BlockPos, bool),
|
||||
) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
// checkInsideBlocks(from, to, ..): the iterations used plus one.
|
||||
let mut check = |from: Vec3, to: Vec3, max: i32| {
|
||||
let d = f64::from(1.0e-5_f32);
|
||||
let target = bounds(to).deflate(Vec3::new(d, d, d));
|
||||
// The move spans a block or more: Mth.square(1.0 - 1.0E-5F).
|
||||
let long = from.distance_squared(to) > (1.0 - d) * (1.0 - d);
|
||||
let mut iterations = 0;
|
||||
let _ = sweep_intersected_blocks(from, to, target, 1_000_000, |p, iteration| {
|
||||
if iteration >= max {
|
||||
return false;
|
||||
}
|
||||
iterations = iteration;
|
||||
// getEntityInsideCollisionShape is the whole block for cobwebs.
|
||||
stuck |= is_web(p);
|
||||
if seen.insert(p) {
|
||||
let corner = Vec3::new(f64::from(p.x), f64::from(p.y), f64::from(p.z));
|
||||
let inside =
|
||||
target.intersects_corners(corner, corner.add_vector(Vec3::new(1.0, 1.0, 1.0)));
|
||||
visit(p, long || inside);
|
||||
}
|
||||
true
|
||||
});
|
||||
iterations + 1
|
||||
@@ -170,7 +217,6 @@ pub fn passes_cobweb(
|
||||
if budget <= 0 {
|
||||
check(to, to, 1);
|
||||
}
|
||||
stuck
|
||||
}
|
||||
pub fn hurt_mobs(mobs: &mut [Mob], position: Vec3, amount: f32) {
|
||||
hurt_mobs_with_kind(mobs, position, amount, DamageKind::FallingBlock);
|
||||
@@ -249,6 +295,23 @@ mod tests {
|
||||
assert!(!wear_anvil(&mut state));
|
||||
}
|
||||
}
|
||||
fn passes_cobweb(
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
original: Option<Vec3>,
|
||||
is_web: impl Fn(BlockPos) -> bool,
|
||||
) -> bool {
|
||||
let (mut velocity, mut fall) = (Vec3::ZERO, 3.0);
|
||||
let stuck = apply_effects_from_blocks(from, to, original, &mut velocity, &mut fall, |p| {
|
||||
if is_web(p) {
|
||||
Inside::Cobweb
|
||||
} else {
|
||||
Inside::Nothing
|
||||
}
|
||||
});
|
||||
assert_eq!(fall == 0.0, stuck);
|
||||
stuck
|
||||
}
|
||||
#[test]
|
||||
fn a_cobweb_on_the_path_or_at_the_end_holds_a_falling_block() {
|
||||
let web = BlockPos::new(0, 5, 0);
|
||||
@@ -285,6 +348,50 @@ mod tests {
|
||||
assert!(!passes_cobweb(beside, beside, None, is_web));
|
||||
}
|
||||
#[test]
|
||||
fn bubble_columns_lift_or_drag_a_falling_block() {
|
||||
// Soul sand under x 0 lifts, magma under x 3 drags, both up to y 9.
|
||||
let block = |p: BlockPos| match (p.x, p.y, p.z) {
|
||||
(0 | 3, 0..=9, 0) => Inside::BubbleColumn(BubbleColumn {
|
||||
drag: p.x == 3,
|
||||
surface: p.y == 9,
|
||||
}),
|
||||
_ => Inside::Nothing,
|
||||
};
|
||||
let step = |x: f64, from: f64, to: f64, vy: f64| {
|
||||
let (from, to) = (Vec3::new(x, from, 0.5), Vec3::new(x, to, 0.5));
|
||||
let mut velocity = Vec3::new(0.0, vy, 0.0);
|
||||
let mut fall = 2.0;
|
||||
let original = Some(to.subtract(from));
|
||||
let stuck =
|
||||
apply_effects_from_blocks(from, to, original, &mut velocity, &mut fall, block);
|
||||
assert!(!stuck);
|
||||
(velocity.y, fall)
|
||||
};
|
||||
let near = |a: f64, b: f64| (a - b).abs() < 1.0e-9;
|
||||
// Released in the column: its box ends in blocks 5 and 6, each lifts
|
||||
// it by 0.06 (handleOnInsideBubbleColumn), and its fall is forgotten.
|
||||
let (vy, fall) = step(0.5, 5.5, 5.46, -0.04);
|
||||
assert!(near(vy, 0.08), "{vy}");
|
||||
assert_eq!(fall, 0.0);
|
||||
// The lift is capped at 0.7.
|
||||
assert!(near(step(0.5, 5.0, 5.3, 0.68).0, 0.7));
|
||||
// Falling a block or more into the surface block: every column
|
||||
// block on the way counts, the surface throws it up by 0.1
|
||||
// (handleOnAboveBubbleColumn), which keeps its fall.
|
||||
let (vy, fall) = step(0.5, 11.0, 9.5, -1.5);
|
||||
assert!(near(vy, -1.5 + 0.1), "{vy}");
|
||||
assert_eq!(fall, 2.0);
|
||||
// Magma drags down by 0.03, at most to -0.3 inside and -0.9 at the surface.
|
||||
assert!(near(step(3.5, 5.5, 5.4, -0.1).0, -0.16));
|
||||
assert!(near(step(3.5, 5.5, 5.4, -0.29).0, -0.3));
|
||||
assert!(near(step(3.5, 9.8, 9.6, -0.89).0, -0.9));
|
||||
// A short move that leaves the column behind: the block it went
|
||||
// through no longer intersects its box, so nothing pushes it.
|
||||
assert_eq!(step(0.5, 9.95, 10.5, 0.55), (0.55, 2.0));
|
||||
// Beside the column.
|
||||
assert_eq!(step(1.5, 5.5, 5.46, -0.04), (-0.04, 2.0));
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires prepared entity models/data in HORIZON_TEST_RESOURCES"]
|
||||
fn impact_hurts_only_living_intersecting_noninvulnerable_mobs() {
|
||||
use crate::mobs::{Mob, MobTypes};
|
||||
|
||||
+476
-170
File diff suppressed because it is too large.
Load diff
@@ -115,10 +115,18 @@ pub(super) struct AdvState {
|
||||
pub(super) announced: Vec<Text>,
|
||||
/// The tree being read on another thread since the world opened
|
||||
/// (`prefetch_advancement_tree`), taken by the first `advancement_tree`.
|
||||
pub(super) pending_tree: Option<std::thread::JoinHandle<Tree>>,
|
||||
pub(super) pending_tree: Option<std::thread::JoinHandle<Arc<Tree>>>,
|
||||
/// `inventory_changed`'s criteria by item, built for `tree`.
|
||||
inventory_index: Option<(Arc<Tree>, Arc<adv::InventoryIndex>)>,
|
||||
}
|
||||
impl AdvState {
|
||||
/// Drop the prefetched tree and the index built for the old one (a
|
||||
/// `/reload` rebuilt the tree).
|
||||
pub(super) fn reset_tree(&mut self) {
|
||||
self.pending_tree = None;
|
||||
self.inventory_index = None;
|
||||
}
|
||||
}
|
||||
/// The game's world for predicates.
|
||||
struct View<'a, 'g>(&'a Game<'g>);
|
||||
impl adv::World for View<'_, '_> {
|
||||
@@ -261,9 +269,12 @@ impl Game<'_> {
|
||||
if self.adv.tree.list.is_empty() {
|
||||
let tree = match self.adv.pending_tree.take().map(|h| h.join()) {
|
||||
Some(Ok(tree)) => tree,
|
||||
_ => Tree::load(&self.options.resources.join("data")),
|
||||
_ => {
|
||||
let overlay = self.pack_files();
|
||||
Tree::shared(&self.options.resources.join("data"), &overlay)
|
||||
}
|
||||
};
|
||||
self.adv.tree = Arc::new(tree);
|
||||
self.adv.tree = tree;
|
||||
// With the tree, while the world loads: built on the first
|
||||
// inventory change it cost that tick ~10 ms.
|
||||
self.inventory_index();
|
||||
@@ -278,7 +289,7 @@ impl Game<'_> {
|
||||
{
|
||||
return index.clone();
|
||||
}
|
||||
let items = self.res.items();
|
||||
let items = self.items();
|
||||
let index = Arc::new(adv::InventoryIndex::build(&tree, |tag| {
|
||||
items
|
||||
.item_tags
|
||||
@@ -297,9 +308,15 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let data = self.options.resources.join("data");
|
||||
// The world's packs' advancements go over the game's own.
|
||||
let files = super::functions::content_overlay(
|
||||
&self.world_root,
|
||||
&self.options.resources,
|
||||
self.cmds.vanilla_disabled,
|
||||
);
|
||||
self.adv.pending_tree = std::thread::Builder::new()
|
||||
.name("horizon-advancements".into())
|
||||
.spawn(move || Tree::load(&data))
|
||||
.spawn(move || Tree::shared(&data, &horizon_world::loot::PackOverlay { files: &files }))
|
||||
.ok();
|
||||
}
|
||||
pub(super) fn load_advancements(&mut self) {
|
||||
@@ -537,13 +554,13 @@ impl Game<'_> {
|
||||
.rewards
|
||||
.recipes
|
||||
.iter()
|
||||
.filter(|r| self.res.stations().recipe_index.contains(r))
|
||||
.filter(|r| world_stations!(self).recipe_index.contains(r))
|
||||
.cloned()
|
||||
.collect();
|
||||
let (_, unlocked) = self
|
||||
.level
|
||||
.recipe_book
|
||||
.add_recipes(&self.res.stations().recipe_index, &rewards);
|
||||
.add_recipes(&world_stations!(self).recipe_index, &rewards);
|
||||
for table in &a.rewards.loot {
|
||||
let items = self
|
||||
.res
|
||||
@@ -1089,6 +1106,31 @@ impl Game<'_> {
|
||||
let it = item_view(stack);
|
||||
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
|
||||
}
|
||||
/// `ServerPlayer.onItemPickup`: the player in place (the host, or the
|
||||
/// guest `as_guest` runs) picked up an item entity whose owner
|
||||
/// (`ItemEntity.getOwner`, the thrower) is `thrower`: the whole stack, or
|
||||
/// what is left after a partial take. An owner that is that player also
|
||||
/// fires `LivingEntity.onItemPickup` (the by-entity trigger); one that is
|
||||
/// gone, or another player, fires nothing here.
|
||||
pub(super) fn trigger_thrown_picked_up_by_player(
|
||||
&mut self,
|
||||
stack: &ItemStack,
|
||||
thrower: [i32; 4],
|
||||
) {
|
||||
let target = if thrower == self.acting_uuid() {
|
||||
let me = self.player_entity();
|
||||
self.trigger_item_entity("minecraft:thrown_item_picked_up_by_entity", stack, &me);
|
||||
me
|
||||
} else if let Some(id) = self.mob_by_uuid(thrower) {
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
self.mob_entity(m)
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
self.trigger_item_entity("minecraft:thrown_item_picked_up_by_player", stack, &target);
|
||||
}
|
||||
/// `ConstructBeaconTrigger`: the pyramid levels of the new beacon.
|
||||
pub(super) fn trigger_construct_beacon(&mut self, levels: i32) {
|
||||
let me = self.player_entity();
|
||||
|
||||
@@ -486,6 +486,8 @@ impl Game<'_> {
|
||||
// LivingEntity.deathTime while dead (the camera's death roll).
|
||||
"death_time": self.death_time(),
|
||||
"air": p.air,
|
||||
// LocalPlayer.portalEffectIntensity: the Nether portal overlay.
|
||||
"portal_effect": self.screen_fx.portal,
|
||||
"food": p.health.food.food,
|
||||
"saturation": p.health.food.saturation,
|
||||
"shield_cooldown": self.shield_cooldown,
|
||||
@@ -598,7 +600,7 @@ impl Game<'_> {
|
||||
Mode::Delete { world } => {
|
||||
json!({ "mode": "delete-world", "folder": world.folder, "name": world.name })
|
||||
}
|
||||
Mode::BackupBeforeUpgrade { world } => {
|
||||
Mode::BackupBeforeUpgrade { world, .. } => {
|
||||
json!({ "mode": "backup-before-upgrade", "folder": world.folder, "name": world.name })
|
||||
}
|
||||
Mode::Error { message, .. } => {
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
//! throwing their load to the player or note block, dancing to jukeboxes,
|
||||
//! and the hand-over interaction (`Allay.mobInteract`).
|
||||
use super::Game;
|
||||
use horizon_client::{containers::BlockEntity, items::ItemStack, mobs::Species};
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
items::ItemStack,
|
||||
mobs::{Species, allay},
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -131,14 +135,20 @@ impl Game<'_> {
|
||||
if m.extra.ints[1] <= 0 {
|
||||
return;
|
||||
}
|
||||
// AllayAi.getItemDepositPosition: the liked note block, else the player.
|
||||
let dimension = self.dim.id();
|
||||
let noteblock = allay::noteblock_deposit(&mut self.mobs[i], dimension, |p| {
|
||||
&*self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(p[0], p[1], p[2]))
|
||||
.name
|
||||
== "minecraft:note_block"
|
||||
});
|
||||
let player = self.allay_liked_player(i);
|
||||
let m = &self.mobs[i];
|
||||
let target = match m.extra.home.filter(|_| m.extra.ints[2] > 0) {
|
||||
Some(h) => horizon_core::geometry::Vec3::new(
|
||||
f64::from(h[0]) + 0.5,
|
||||
f64::from(h[1]) + 1.0,
|
||||
f64::from(h[2]) + 0.5,
|
||||
),
|
||||
let target = match noteblock {
|
||||
Some(p) => horizon_core::geometry::Vec3::new(p[0], p[1], p[2]),
|
||||
None => match player.filter(|_| m.extra.tame) {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
@@ -181,6 +191,11 @@ impl Game<'_> {
|
||||
m.extra.ints[3] = 60;
|
||||
// ItemEntity.setDefaultPickUpDelay.
|
||||
self.spawn_item(ItemStack::new(&item, 1), from, v, 10);
|
||||
// BehaviorUtils.throwItem: ItemEntity.setThrower(the allay).
|
||||
let allay = self.mob_uuid(self.mobs[i].id);
|
||||
if let Some(thrown) = self.dropped.last_mut() {
|
||||
thrown.thrower = Some(allay);
|
||||
}
|
||||
if self.world.game_time % 7 == 0 && self.world.random.next_double() < 0.9 {
|
||||
const PITCHES: [f32; 16] = [
|
||||
0.5625, 0.625, 0.75, 0.9375, 1.0, 1.0, 1.125, 1.25, 1.5, 1.875, 2.0, 2.25, 2.5,
|
||||
|
||||
@@ -137,16 +137,16 @@ impl ArmorStandAssets {
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `HumanoidArmorLayer` for the player: (model, texture rect, colour) of
|
||||
/// each worn piece, head to feet.
|
||||
pub(super) fn player_armor_layers(&self) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
/// `HumanoidArmorLayer` for the player: (model, texture rect, colour,
|
||||
/// overlay) of each worn piece, head to feet.
|
||||
pub(super) fn player_armor_layers(&self) -> Vec<(&Part, [f32; 4], u32, u32)> {
|
||||
self.stack_armor_layers(&self.armor)
|
||||
}
|
||||
/// `HumanoidArmorLayer` for worn stacks (head to feet), trims included.
|
||||
pub(super) fn stack_armor_layers(
|
||||
&self,
|
||||
armor: &[Option<ItemStack>; 4],
|
||||
) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
) -> Vec<(&Part, [f32; 4], u32, u32)> {
|
||||
self.armor_layers(
|
||||
armor
|
||||
.each_ref()
|
||||
@@ -155,7 +155,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `HumanoidArmorLayer` for another player's model: the armour it wears
|
||||
/// (`extra.head` .. `extra.feet`).
|
||||
pub(super) fn worn_armor_layers(&self, m: &Mob) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
pub(super) fn worn_armor_layers(&self, m: &Mob) -> Vec<(&Part, [f32; 4], u32, u32)> {
|
||||
let e = &m.extra;
|
||||
self.armor_layers(
|
||||
[
|
||||
@@ -172,7 +172,7 @@ impl Game<'_> {
|
||||
fn armor_layers(
|
||||
&self,
|
||||
worn: [Option<(&str, Option<&ItemStack>)>; 4],
|
||||
) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
) -> Vec<(&Part, [f32; 4], u32, u32)> {
|
||||
let assets = &self.armor_stand_assets;
|
||||
let mut out = Vec::new();
|
||||
for (item, layer) in worn
|
||||
@@ -206,13 +206,27 @@ impl Game<'_> {
|
||||
continue;
|
||||
};
|
||||
let color = layer_color(*undyed, stack);
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], color));
|
||||
// EquipmentLayerRenderer.renderLayers: NO_OVERLAY, the armour
|
||||
// glint on the layer and its trim when `stack.hasFoil()`.
|
||||
let item_glint = self
|
||||
.res
|
||||
.items()
|
||||
.enchantments
|
||||
.props
|
||||
.get(item)
|
||||
.is_some_and(|p| p.glint);
|
||||
let overlay = if stack.is_some_and(|s| s.has_foil(item_glint)) {
|
||||
horizon_client::mobs::render::ARMOR_GLINT
|
||||
} else {
|
||||
0
|
||||
};
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], color, overlay));
|
||||
// EquipmentLayerRenderer: the trim layer over the armour.
|
||||
if let Some(r) = stack
|
||||
.and_then(|s| assets.trim_texture(&asset, kind, s))
|
||||
.and_then(|t| self.gui.atlas.sprites.get(&t.sprite()))
|
||||
{
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], 0xFFFF_FFFF));
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], 0xFFFF_FFFF, overlay));
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -224,7 +238,7 @@ impl Game<'_> {
|
||||
pub(super) fn prepare_trims(&mut self) {
|
||||
use horizon_client::hud::TRIM_SLOTS;
|
||||
let assets = &self.armor_stand_assets;
|
||||
let items = self.res.items();
|
||||
let items = self.items();
|
||||
let mut wanted: Vec<TrimTexture> = Vec::new();
|
||||
let guests = self.lan.iter().flat_map(|host| host.hub.guests.iter());
|
||||
let stacks = self
|
||||
@@ -820,6 +834,7 @@ impl Game<'_> {
|
||||
horizon_client::mobs::ElytraAnimation::RESTING,
|
||||
false,
|
||||
packed,
|
||||
self.wings_overlay(a.equipment[4].as_ref()),
|
||||
out,
|
||||
);
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
@@ -866,7 +881,7 @@ impl Game<'_> {
|
||||
/// `Equippable.swapWithEquipmentSlot`: right-clicking armor swaps it into
|
||||
/// its slot (the worn piece comes back to the hand).
|
||||
pub(super) fn equip_from_hand(&mut self, stack: &ItemStack) -> bool {
|
||||
let Some(info) = self.res.items().get(&stack.item) else {
|
||||
let Some(info) = self.items().get(&stack.item) else {
|
||||
return false;
|
||||
};
|
||||
let (index, slot) = match info.equip_slot.as_deref() {
|
||||
|
||||
@@ -14,7 +14,7 @@ impl Game<'_> {
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
layer_textures(&self.res.stations().banners, &b.layers)
|
||||
layer_textures(&self.stations().banners, &b.layers)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,16 @@ impl Game<'_> {
|
||||
for head in left {
|
||||
self.player_beds.retain(|b| *b != (dim, head));
|
||||
self.set_bed_occupied(head, false);
|
||||
// AbstractBedBlock.onStopSleeping: the Overworld's straw_bed_rule
|
||||
// has destroy_on_leave, so a straw bed breaks when its sleeper gets up.
|
||||
let bed = self.world.block(head[0], head[1], head[2]);
|
||||
let straw = &*self.res.facts().get(bed).name == "minecraft:straw_bed";
|
||||
if dim == Dim::Overworld
|
||||
&& straw
|
||||
&& let Err(e) = self.destroy_straw_bed(head)
|
||||
{
|
||||
log::warn!("could not break the straw bed at {head:?}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ impl Game<'_> {
|
||||
pub(super) fn block_entity_event(&mut self, pos: [i32; 3], a: i32, b: i32) {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]).0;
|
||||
let time = self.world.game_time;
|
||||
// PotentSulfurBlock.triggerEvent.
|
||||
if self.potent_sulfur_eruption_event((pos[0], pos[1], pos[2])) {
|
||||
return;
|
||||
}
|
||||
match self.be_anims.ticker(state) {
|
||||
Some(Ticker::Lid) => {
|
||||
self.be_anims
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! `Level.tickBlockEntities` for the block entities ticked together here
|
||||
//! (hoppers, crafters, jukeboxes, beacons, creaking hearts, spawners, trial
|
||||
//! spawners, vaults, beehives and furnaces): only those whose state has a
|
||||
//! spawners, vaults, beehives, furnaces and potent sulfur): only those whose state has a
|
||||
//! ticker, in vanilla's list order (`horizon_client::block_entities`), in
|
||||
//! chunks that tick blocks. Sculk, brewing stands and pistons tick in their
|
||||
//! own passes.
|
||||
@@ -64,6 +64,14 @@ impl Game<'_> {
|
||||
Some(BlockEntity::Brewing(_)) => self.tick_brewing_stand(pos)?,
|
||||
Some(BlockEntity::DaylightDetector) => self.tick_daylight_detector(pos),
|
||||
Some(BlockEntity::Campfire(_)) => self.tick_campfire(pos)?,
|
||||
Some(BlockEntity::PotentSulfur(_)) => {
|
||||
self.tick_potent_sulfur(pos)?;
|
||||
// A window that simulates the world itself is its own
|
||||
// client; a server's clients tick it on their own.
|
||||
if self.options.execution_role.is_client() {
|
||||
self.client_tick_potent_sulfur(pos);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +113,14 @@ impl Game<'_> {
|
||||
// Lit, cookTick; unlit, cooldownTick.
|
||||
| "minecraft:campfire"
|
||||
| "minecraft:soul_campfire" => Ticking::Yes,
|
||||
// Every state but dry has a ticker.
|
||||
"minecraft:potent_sulfur" => {
|
||||
let dry = registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("potent_sulfur_state"))
|
||||
.is_some_and(|v| v == "dry");
|
||||
if dry { Ticking::Idle } else { Ticking::Yes }
|
||||
}
|
||||
"minecraft:jukebox" => {
|
||||
let record = registry
|
||||
.state(state)
|
||||
@@ -141,7 +157,11 @@ impl Game<'_> {
|
||||
let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&pos) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(lit) = f.tick(self.res.recipes(), self.res.items(), self.res.providers()) else {
|
||||
let Some(lit) = f.tick(
|
||||
world_recipes!(self),
|
||||
world_items!(self),
|
||||
self.res.providers(),
|
||||
) else {
|
||||
return Ok(());
|
||||
};
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
|
||||
@@ -601,9 +601,10 @@ impl Game<'_> {
|
||||
self.dismount(i);
|
||||
return Ok(());
|
||||
}
|
||||
let mut b = self.boats[i].clone();
|
||||
// The boat steers with the input `LocalPlayer.rideTick` latched on the
|
||||
// previous tick; this tick's keys are latched after it ticked.
|
||||
let mut b = self.tick_boat(self.boats[i].clone(), true);
|
||||
b.input = [keys.left, keys.right, keys.forward, keys.backward];
|
||||
let b = self.tick_boat(b, true);
|
||||
// positionRider: the seat, turned with the boat; clampRotation to 105 degrees.
|
||||
self.yaw += b.delta_rotation;
|
||||
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
|
||||
@@ -622,8 +622,15 @@ impl Game<'_> {
|
||||
let pos = self.gui.atlas.font.fit(title, rel);
|
||||
title_edit.set_cursor(pos, shift);
|
||||
}
|
||||
/// A click on the open page.
|
||||
fn book_page_click(&mut self, origin_x: f32, origin_y: f32, mx: f32, my: f32) {
|
||||
/// A click on the open page (`MultiLineEditBox.onClick`): a double click
|
||||
/// selects the word before the cursor and leaves the cursor where it is.
|
||||
fn book_page_click(&mut self, origin_x: f32, origin_y: f32, mx: f32, my: f32, double: bool) {
|
||||
if double {
|
||||
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
|
||||
page_edit.select_word_at_cursor();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let shift = self.has_shift_down();
|
||||
let text = match self.book.as_ref() {
|
||||
Some(BookScreen::Edit { pages, page, .. }) => pages[*page].clone(),
|
||||
@@ -989,6 +996,12 @@ impl Game<'_> {
|
||||
let b = HudBatch::new(size);
|
||||
let (w, left) = (b.width, ((b.width - 192.0) / 2.0).floor());
|
||||
let (mx, my) = self.cursor;
|
||||
// MouseHandler: a click within 250 ms of the last one on this screen is a double click.
|
||||
let now = std::time::Instant::now();
|
||||
let double = self
|
||||
.book_last_click
|
||||
.is_some_and(|at| now.duration_since(at).as_millis() < 250);
|
||||
self.book_last_click = Some(now);
|
||||
let inside =
|
||||
|x: f32, y: f32, bw: f32, bh: f32| mx >= x && my >= y && mx < x + bw && my < y + bh;
|
||||
let click = |game: &mut Self| {
|
||||
@@ -1029,7 +1042,7 @@ impl Game<'_> {
|
||||
click(self);
|
||||
self.save_book(false);
|
||||
} else if inside(left + 36.0, 32.0, 114.0, 14.0 * 9.0) {
|
||||
self.book_page_click(left + 36.0, 32.0, mx, my);
|
||||
self.book_page_click(left + 36.0, 32.0, mx, my, double);
|
||||
}
|
||||
}
|
||||
Some(BookScreen::View {
|
||||
|
||||
@@ -170,7 +170,10 @@ impl Game<'_> {
|
||||
"minecraft:brushable_block",
|
||||
);
|
||||
self.trigger_loot_table(&table);
|
||||
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
|
||||
let loot = horizon_client::structure_blocks::loot_data(
|
||||
&self.options.resources,
|
||||
&self.pack_files(),
|
||||
);
|
||||
decorated_pot::unpack_loot(
|
||||
&mut tag,
|
||||
loot.as_deref(),
|
||||
|
||||
@@ -44,7 +44,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
let stack = self.slot_mut(slot).clone();
|
||||
let items = self.res.items();
|
||||
let items = self.items();
|
||||
let Some(bundle) = stack.filter(|s| items.get(&s.item).is_some_and(|i| i.bundle)) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ impl Game<'_> {
|
||||
let Some(held) = self.held().cloned() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(time) = self.res.recipes().cook(KIND, &held).map(|r| r.time as i32) else {
|
||||
let Some(time) = self.recipes().cook(KIND, &held).map(|r| r.time as i32) else {
|
||||
return Ok(None);
|
||||
};
|
||||
// A client of a server predicts the placing; the server decides.
|
||||
|
||||
@@ -77,11 +77,20 @@ impl TimeMarker {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ChatComponent.Draft`: the text the chat box kept, and whether it was a command.
|
||||
pub struct ChatDraft {
|
||||
pub text: String,
|
||||
pub command: bool,
|
||||
}
|
||||
/// UI and command state that lives beside the game.
|
||||
#[derive(Default)]
|
||||
pub struct UiState {
|
||||
pub chat: Chat,
|
||||
pub chat_screen: Option<ChatScreen>,
|
||||
/// `ChatComponent.latestDraft`.
|
||||
pub chat_draft: Option<ChatDraft>,
|
||||
/// `ChatScreen.isDraft`: the box opened with the draft and is unchanged since.
|
||||
pub chat_draft_shown: bool,
|
||||
/// When the chat screen opened (its input took the focus: the cursor blink).
|
||||
pub chat_opened: Option<std::time::Instant>,
|
||||
/// The chat screen is `InBedChatScreen`: the sleeper's, with a Leave Bed button.
|
||||
@@ -466,10 +475,25 @@ impl Game<'_> {
|
||||
self.ui.registries.players = names;
|
||||
}
|
||||
}
|
||||
/// `ChatComponent.createScreen`: T or / opens the chat with the draft kept
|
||||
/// last time, when this method can restore it (a command draft only for
|
||||
/// /), else with its prefix.
|
||||
pub(super) fn open_chat_method(&mut self, prefix: &str) {
|
||||
let command = prefix == "/";
|
||||
let draft = self
|
||||
.ui
|
||||
.chat_draft
|
||||
.as_ref()
|
||||
.filter(|d| !command || d.command)
|
||||
.map(|d| d.text.clone());
|
||||
self.open_chat(draft.as_deref().unwrap_or(prefix));
|
||||
self.ui.chat_draft_shown = draft.is_some() && self.ui.chat_screen.is_some();
|
||||
}
|
||||
pub(super) fn open_chat(&mut self, initial: &str) {
|
||||
if self.player.is_none() {
|
||||
return;
|
||||
}
|
||||
self.ui.chat_draft_shown = false;
|
||||
self.refresh_dynamic_registries();
|
||||
let size = self
|
||||
.renderer
|
||||
@@ -506,6 +530,13 @@ impl Game<'_> {
|
||||
self.wake_up();
|
||||
return;
|
||||
}
|
||||
let text = self
|
||||
.ui
|
||||
.chat_screen
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |s| s.value().to_owned());
|
||||
self.keep_chat_draft(&text);
|
||||
self.ui.chat_draft_shown = false;
|
||||
self.ui.chat_screen = None;
|
||||
self.ui.chat.reset_scroll();
|
||||
if self.screen == Screen::Chat {
|
||||
@@ -513,6 +544,21 @@ impl Game<'_> {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
/// `ChatScreen.removed`: the text left in the box becomes the draft when
|
||||
/// Save Chat Drafts is on, else (and when blank) the draft is discarded. A
|
||||
/// draft still shown unchanged stays as it is.
|
||||
fn keep_chat_draft(&mut self, text: &str) {
|
||||
let unchanged =
|
||||
self.ui.chat_draft_shown && self.ui.chat_draft.as_ref().is_some_and(|d| d.text == text);
|
||||
if !self.settings.file.bool("saveChatDrafts") || text.trim().is_empty() {
|
||||
self.ui.chat_draft = None;
|
||||
} else if !unchanged {
|
||||
self.ui.chat_draft = Some(ChatDraft {
|
||||
text: text.to_owned(),
|
||||
command: text.starts_with('/'),
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `Gui.tick`: a sleeper gets `InBedChatScreen`; when the player has
|
||||
/// woken (`onPlayerWokeUp`) it closes, or turns into the plain chat
|
||||
/// screen if something was typed.
|
||||
@@ -648,7 +694,11 @@ impl Game<'_> {
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
match screen.key(&m, &c, &self.ui.chat.recent, key, shift, ctrl) {
|
||||
ChatAction::Close => self.close_chat(),
|
||||
ChatAction::Close => {
|
||||
// Escape: the box still holds its text, which close_chat keeps as the draft.
|
||||
self.ui.chat_screen = Some(screen);
|
||||
self.close_chat();
|
||||
}
|
||||
ChatAction::Submit(message) => {
|
||||
// InBedChatScreen sends without closing (`closeOnSubmit` is
|
||||
// false): the box empties and the screen stays.
|
||||
@@ -657,6 +707,8 @@ impl Game<'_> {
|
||||
self.ui.chat.reset_scroll();
|
||||
self.ui.chat_screen = Some(screen);
|
||||
} else {
|
||||
// The screen is taken here, so close_chat sees no text: a sent
|
||||
// message discards the draft (DONE).
|
||||
self.close_chat();
|
||||
}
|
||||
if !message.is_empty() {
|
||||
@@ -983,7 +1035,7 @@ impl Game<'_> {
|
||||
.hover_item
|
||||
.as_deref()
|
||||
.and_then(horizon_client::item_data::stack_from_hover)
|
||||
.filter(|s| self.res.items().get(&s.item).is_some())
|
||||
.filter(|s| self.items().get(&s.item).is_some())
|
||||
{
|
||||
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ type Pos = (i32, i32, i32);
|
||||
|
||||
/// The open `CommandBlockEditScreen`.
|
||||
pub(super) struct CommandBlockScreen {
|
||||
pos: Pos,
|
||||
pub(super) pos: Pos,
|
||||
/// The command box with its `CommandSuggestions`.
|
||||
command: ChatScreen,
|
||||
/// When the command box was focused, for its cursor's blink.
|
||||
@@ -112,7 +112,6 @@ impl CommandBlockScreen {
|
||||
self.previous.set_value(&shown);
|
||||
}
|
||||
}
|
||||
|
||||
fn state_of(registry: &BlockRegistry, state: BlockStateId) -> Option<&BlockState> {
|
||||
registry.state(state)
|
||||
}
|
||||
@@ -241,6 +240,40 @@ impl Game<'_> {
|
||||
let at = BlockPos::new(pos.0, pos.1, pos.2);
|
||||
self.with_level(|b, level, ticks| b.update_neighbour_for_output_signal(level, ticks, at));
|
||||
}
|
||||
/// `CommandBlockEditScreen.updateGui`: the server's block-entity data
|
||||
/// arrived while the screen is open; refresh the fields from the entity.
|
||||
/// The mode and conditional live in the block state here (vanilla's
|
||||
/// `CommandBlockEntity.Mode`).
|
||||
pub(super) fn update_command_block_screen(&mut self) {
|
||||
let Some(screen_pos) = self.command_block_screen.as_ref().map(|s| s.pos) else {
|
||||
return;
|
||||
};
|
||||
let state = self.block_state_at(screen_pos).map(|s| {
|
||||
(
|
||||
Mode::of_block(&s.name).unwrap_or(Mode::Redstone),
|
||||
conditional(s),
|
||||
)
|
||||
});
|
||||
let block = self.command_block(screen_pos).cloned();
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: super::chat::chat_tree(&self.remote),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
let Some(screen) = self.command_block_screen.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if let Some((mode, conditional)) = state {
|
||||
screen.mode = mode;
|
||||
screen.conditional = conditional;
|
||||
}
|
||||
if let Some(block) = block {
|
||||
screen.command.suggest_command(&m, &c, &block.command);
|
||||
screen.track_output = block.track_output;
|
||||
screen.auto = block.auto;
|
||||
screen.show_previous(&block);
|
||||
}
|
||||
}
|
||||
|
||||
/// `CommandBlock.setPlacedBy`: unless the item carried block entity data
|
||||
/// (`defaults` false), output tracking follows `send_command_feedback` and
|
||||
|
||||
@@ -1053,6 +1053,11 @@ impl Game<'_> {
|
||||
&horizon_client::brewing::potion_name_key(item, s.potion()),
|
||||
&[],
|
||||
)
|
||||
} else if item == "minecraft:shield"
|
||||
&& let Some(color) = s.data().and_then(|d| d.base_color.as_deref())
|
||||
{
|
||||
// `ShieldItem.getName`: the banner's base colour names the shield.
|
||||
Text::translate(&format!("item.minecraft.shield.{color}"), &[])
|
||||
} else {
|
||||
// Item.getDescriptionId: a block item's is the block's.
|
||||
let (namespace, path) = item.split_once(':').unwrap_or(("minecraft", item));
|
||||
@@ -1132,7 +1137,7 @@ impl Game<'_> {
|
||||
};
|
||||
let count = Self::int(ctx, "count").unwrap_or(1);
|
||||
let targets = self.entities(ctx, "targets", true)?;
|
||||
let max_stack = self.res.items().get(&id).map_or(64, |i| i.max_stack) as i32;
|
||||
let max_stack = self.items().get(&id).map_or(64, |i| i.max_stack) as i32;
|
||||
if count > max_stack * 100 {
|
||||
return Err(CommandError::new(Text::translate(
|
||||
"commands.give.failed.toomanyitems",
|
||||
@@ -1941,7 +1946,7 @@ impl Game<'_> {
|
||||
.items()
|
||||
.block_tags
|
||||
.get(tag)
|
||||
.or_else(|| self.res.items().block_tags.get(&b.id))
|
||||
.or_else(|| self.items().block_tags.get(&b.id))
|
||||
.is_some_and(|set| {
|
||||
set.contains(&s.name) || set.contains(s.name.trim_start_matches("minecraft:"))
|
||||
})
|
||||
@@ -2132,7 +2137,7 @@ impl Game<'_> {
|
||||
let table = save::text(tag.get("LootTable"));
|
||||
let rolled = match (table, entity) {
|
||||
(Some(table), BlockEntity::Chest(slots)) if slots.iter().all(Option::is_none) => {
|
||||
let loot = structure_blocks::loot_data(&self.options.resources);
|
||||
let loot = structure_blocks::loot_data(&self.options.resources, &self.pack_files());
|
||||
let at = Vec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
|
||||
let biome = self.biome_name_at(at).unwrap_or_default();
|
||||
Some(structure_blocks::roll_container(
|
||||
@@ -2730,7 +2735,7 @@ impl Game<'_> {
|
||||
.items()
|
||||
.item_tags
|
||||
.get(tag)
|
||||
.or_else(|| self.res.items().item_tags.get(&predicate.id))
|
||||
.or_else(|| self.items().item_tags.get(&predicate.id))
|
||||
.is_some_and(|s| {
|
||||
s.contains(item) || s.contains(item.trim_start_matches("minecraft:"))
|
||||
});
|
||||
|
||||
@@ -182,7 +182,7 @@ impl Game<'_> {
|
||||
return Ok(0);
|
||||
};
|
||||
let level = Self::int(ctx, "level").unwrap_or(1);
|
||||
let enchantments = &self.res.stations().enchantments;
|
||||
let enchantments = &self.stations().enchantments;
|
||||
let Some(def) = enchantments.get(&id) else {
|
||||
return Err(err(
|
||||
"argument.resource.not_found",
|
||||
@@ -208,8 +208,8 @@ impl Game<'_> {
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let e = &self.res.stations().enchantments;
|
||||
let compatible = e.can_enchant(self.res.items(), &id, &stack.item)
|
||||
let e = &self.stations().enchantments;
|
||||
let compatible = e.can_enchant(self.items(), &id, &stack.item)
|
||||
&& stack
|
||||
.crafting_enchantments()
|
||||
.iter()
|
||||
|
||||
@@ -1048,6 +1048,23 @@ impl Game<'_> {
|
||||
let Some(holder) = self.dialog_holder(dialog) else {
|
||||
return;
|
||||
};
|
||||
self.send_dialog(targets, &holder);
|
||||
}
|
||||
/// `ServerPlayer.openDialog` for the dialog of a `show_dialog` click
|
||||
/// event (a sign's): a registry id or the dialog itself, opened for the
|
||||
/// player the game acts for.
|
||||
pub(in crate::game) fn show_click_dialog(&mut self, dialog: &horizon_world::nbt::Tag) {
|
||||
let holder = match dialog {
|
||||
horizon_world::nbt::Tag::String(id) => self.dialog_holder(&id.to_string_lossy()),
|
||||
direct => Some(DialogHolder::Direct(direct.clone())),
|
||||
};
|
||||
if let Some(holder) = holder {
|
||||
self.send_dialog(&[Target::Player], &holder);
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.openDialog` for each target: the packet to a guest, the
|
||||
/// screen to the window's own player.
|
||||
fn send_dialog(&mut self, targets: &[Target], holder: &DialogHolder) {
|
||||
let packet = C::ShowDialog(play::ShowDialog {
|
||||
dialog: holder.clone(),
|
||||
});
|
||||
@@ -1056,7 +1073,7 @@ impl Game<'_> {
|
||||
Target::Player if self.lan_acting.is_some() => {
|
||||
self.guest_packets.push(packet.clone());
|
||||
}
|
||||
Target::Player => self.show_play_dialog(&holder),
|
||||
Target::Player => self.show_play_dialog(holder),
|
||||
Target::Guest(conn) => {
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
host.hub.send_to(conn, &packet);
|
||||
|
||||
@@ -600,7 +600,7 @@ impl Game<'_> {
|
||||
if let Some(Tag::Int(n)) = s.data().and_then(|d| d.other("minecraft:max_stack_size")) {
|
||||
return (*n).clamp(1, 99) as u32;
|
||||
}
|
||||
self.res.items().get(&s.item).map_or(64, |i| i.max_stack)
|
||||
self.items().get(&s.item).map_or(64, |i| i.max_stack)
|
||||
}
|
||||
/// `ResourceOrIdArgument.getItemModifier`: an inline modifier (SNBT) or
|
||||
/// a registered one.
|
||||
|
||||
@@ -491,7 +491,7 @@ impl Game<'_> {
|
||||
.inline_loot_table(&json, &mut self.world.random);
|
||||
(drops, None)
|
||||
} else {
|
||||
if !self.res.items().has_loot_table(&table) {
|
||||
if !self.items().has_loot_table(&table) {
|
||||
return Err(err("argument.resource_or_id.failed_to_parse", &[&table]));
|
||||
}
|
||||
let held = if id == "loot.fish" {
|
||||
@@ -499,14 +499,14 @@ impl Game<'_> {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let drops = match self.res.items().loot_sequence(&table) {
|
||||
let drops = match world_items!(self).loot_sequence(&table) {
|
||||
Some(seq) => {
|
||||
let random = self.rand_sequences.get(&seq, self.level.seed);
|
||||
self.res
|
||||
.items()
|
||||
.loot_table(&table, false, held.as_ref(), random)
|
||||
}
|
||||
None => self.res.items().loot_table(
|
||||
None => world_items!(self).loot_table(
|
||||
&table,
|
||||
false,
|
||||
held.as_ref(),
|
||||
@@ -523,7 +523,7 @@ impl Game<'_> {
|
||||
};
|
||||
let ty = self.target_type(t);
|
||||
let table = format!("minecraft:entities/{}", ty.trim_start_matches("minecraft:"));
|
||||
if t == Target::Player || !self.res.items().has_loot_table(&table) {
|
||||
if t == Target::Player || !world_items!(self).has_loot_table(&table) {
|
||||
return Err(err(
|
||||
"commands.drop.no_loot_table.entity",
|
||||
&[&self.target_name(t).plain()],
|
||||
@@ -534,14 +534,14 @@ impl Game<'_> {
|
||||
None | Some(Some(Target::Player))
|
||||
);
|
||||
let held = self.slot_get(&super::items::Slot::Inv(self.selected));
|
||||
let drops = match self.res.items().loot_sequence(&table) {
|
||||
let drops = match world_items!(self).loot_sequence(&table) {
|
||||
Some(seq) => {
|
||||
let random = self.rand_sequences.get(&seq, self.level.seed);
|
||||
self.res
|
||||
.items()
|
||||
.entity_drops(&ty, by_player, held.as_ref(), random)
|
||||
}
|
||||
None => self.res.items().entity_drops(
|
||||
None => world_items!(self).entity_drops(
|
||||
&ty,
|
||||
by_player,
|
||||
held.as_ref(),
|
||||
@@ -556,21 +556,21 @@ impl Game<'_> {
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
let name = self.data_block_name(p);
|
||||
let table = format!("minecraft:blocks/{}", name.trim_start_matches("minecraft:"));
|
||||
if !self.res.items().has_loot_table(&table) {
|
||||
if !world_items!(self).has_loot_table(&table) {
|
||||
return Err(err("commands.drop.no_loot_table.block", &[&name]));
|
||||
}
|
||||
let tool = self.tool_stack(ctx)?;
|
||||
let drops = match self.res.items().loot_sequence(&table) {
|
||||
let drops = match world_items!(self).loot_sequence(&table) {
|
||||
Some(seq) => {
|
||||
let random = self.rand_sequences.get(&seq, self.level.seed);
|
||||
self.res.items().block_drops(
|
||||
world_items!(self).block_drops(
|
||||
self.pack.assets.registry,
|
||||
state,
|
||||
tool.as_ref(),
|
||||
random,
|
||||
)
|
||||
}
|
||||
None => self.res.items().block_drops(
|
||||
None => world_items!(self).block_drops(
|
||||
self.pack.assets.registry,
|
||||
state,
|
||||
tool.as_ref(),
|
||||
@@ -637,7 +637,7 @@ impl Game<'_> {
|
||||
&& cur.damage == left.damage
|
||||
&& cur.extra == left.extra =>
|
||||
{
|
||||
let max = self.res.items().get(&cur.item).map_or(64, |i| i.max_stack);
|
||||
let max = self.items().get(&cur.item).map_or(64, |i| i.max_stack);
|
||||
let take = left.count.min(max.saturating_sub(cur.count));
|
||||
if take > 0 {
|
||||
cur.count += take;
|
||||
|
||||
@@ -32,7 +32,7 @@ impl Game<'_> {
|
||||
|
||||
/// Every recipe id of the recipe manager (what `/recipe` resolves and suggests).
|
||||
pub(super) fn learnable_recipes(&self) -> Vec<String> {
|
||||
self.res.stations().recipe_index.ids().cloned().collect()
|
||||
self.stations().recipe_index.ids().cloned().collect()
|
||||
}
|
||||
pub(super) fn cmd_recipe(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
let targets = self.entities(ctx, "targets", true)?;
|
||||
@@ -82,7 +82,7 @@ impl Game<'_> {
|
||||
/// many recipe book displays changed (a transmute recipe has one per
|
||||
/// material count).
|
||||
fn change_recipes(&mut self, give: bool, recipes: &[String]) -> i32 {
|
||||
let index = &self.res.stations().recipe_index;
|
||||
let index = &world_stations!(self).recipe_index;
|
||||
if give {
|
||||
let (count, unlocked) = self.level.recipe_book.add_recipes(index, recipes);
|
||||
self.recipe_unlocked(&unlocked);
|
||||
@@ -113,7 +113,8 @@ impl Game<'_> {
|
||||
"explosion" | "player_explosion" | "bad_respawn_point" => DamageKind::Explosion,
|
||||
"fall" | "ender_pearl" | "fly_into_wall" => DamageKind::Fall,
|
||||
"on_fire" => DamageKind::OnFire,
|
||||
"in_fire" | "campfire" | "hot_floor" => DamageKind::InFire,
|
||||
"in_fire" | "hot_floor" => DamageKind::InFire,
|
||||
"campfire" => DamageKind::Campfire,
|
||||
"lava" => DamageKind::Lava,
|
||||
"drown" => DamageKind::Drown,
|
||||
"dry_out" => DamageKind::DryOut,
|
||||
@@ -170,23 +171,25 @@ impl Game<'_> {
|
||||
let hurt = match target {
|
||||
// The causing mob is the one the hit is credited to and thorns
|
||||
// answer; the source position gives the knockback its direction.
|
||||
Target::Player => {
|
||||
Target::Player | Target::Guest(_) => {
|
||||
// `DamageSource(type, cause, by)`: the death message names the
|
||||
// causing entity, else the direct one. A mob is noted by
|
||||
// `hurt_player_from`, a player here.
|
||||
if let Some(t @ (Target::Player | Target::Guest(_))) = cause.or(by) {
|
||||
let name = self.target_name(t);
|
||||
self.note_direct_entity(&ty, name);
|
||||
}
|
||||
if self.spectator() {
|
||||
false
|
||||
} else {
|
||||
let attacker = match cause.or(by) {
|
||||
Some(Target::Mob(i)) => Some(Some(i)),
|
||||
_ => Some(None),
|
||||
};
|
||||
self.hurt_player_from(amount, from, &ty, attacker)
|
||||
}
|
||||
let named = match cause.or(by) {
|
||||
Some(t @ (Target::Player | Target::Guest(_))) => Some(self.target_name(t)),
|
||||
_ => None,
|
||||
};
|
||||
let attacker = match cause.or(by) {
|
||||
Some(Target::Mob(i)) => Some(Some(i)),
|
||||
_ => Some(None),
|
||||
};
|
||||
self.for_player(target, |g| {
|
||||
if let Some(name) = named {
|
||||
g.note_direct_entity(&ty, name);
|
||||
}
|
||||
!g.spectator() && g.hurt_player_from(amount, from, &ty, attacker)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
Target::Mob(id) => {
|
||||
let attacker = cause.or(by).and_then(|t| match t {
|
||||
|
||||
@@ -22,7 +22,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// The item's `compostable` layers provider.
|
||||
fn compostable(&self, stack: &ItemStack) -> Option<serde_json::Value> {
|
||||
self.res.items().get(&stack.item)?.compostable.clone()
|
||||
self.items().get(&stack.item)?.compostable.clone()
|
||||
}
|
||||
/// `ComposterBlock.addLayer` and level event 1500: the provider is
|
||||
/// evaluated in the composter's block-interaction loot context (its
|
||||
@@ -66,6 +66,17 @@ impl Game<'_> {
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, stack);
|
||||
true
|
||||
}
|
||||
/// `ComposterBlock.useItemOn` on a client's level: whether `held` is
|
||||
/// compostable and the composter at `pos` below level 8 (SUCCESS).
|
||||
pub(super) fn composter_takes(&self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
|
||||
self.composter_level(pos).is_some_and(|level| level < 8)
|
||||
&& held.is_some_and(|stack| {
|
||||
self.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.is_some_and(|i| i.compostable.is_some())
|
||||
})
|
||||
}
|
||||
/// `HopperBlockEntity.addItem` into the composter's `InputContainer`
|
||||
/// (levels 0 to 6; one item, compostable, from above): the remainder.
|
||||
pub(super) fn composter_container_insert(
|
||||
|
||||
@@ -227,6 +227,8 @@ impl Game<'_> {
|
||||
self.dismount(i);
|
||||
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
|
||||
self.dismount_cart(i);
|
||||
} else if let Some(c) = self.cushions.iter().find(|c| c.id == id).cloned() {
|
||||
self.get_up_from_cushion(&c);
|
||||
} else {
|
||||
self.dismount_mount();
|
||||
}
|
||||
@@ -255,10 +257,19 @@ impl EmissionWorld for Emitter<'_, '_> {
|
||||
fn consume_sound(&mut self, _: ItemStack) -> Result<String, Infallible> {
|
||||
unreachable!("players play the consumable's own sound")
|
||||
}
|
||||
/// `Entity.playSound`: at the player's position.
|
||||
/// `Player.playSound`: at the player's position, for everyone but the
|
||||
/// player, whose own client plays it (`level.playSound(player, ...)`):
|
||||
/// a guest's server turn leaves the guest out.
|
||||
fn play_sound(&mut self, sound: String, volume: f32, pitch: f32) -> Result<(), Infallible> {
|
||||
let at = self.0.feet();
|
||||
self.0.audio.play(&sound, at, volume, pitch);
|
||||
if self.0.lan_acting.is_some() {
|
||||
self.0.audio.play_unshared(&sound, at, volume, pitch);
|
||||
if let Some(at) = at {
|
||||
self.0.guest_player_sounds.push((sound, at, volume, pitch));
|
||||
}
|
||||
} else {
|
||||
self.0.audio.play(&sound, at, volume, pitch);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,8 +448,10 @@ impl Game<'_> {
|
||||
ok()
|
||||
}
|
||||
"turn" => {
|
||||
self.yaw += arg(1)?.parse::<f32>()?;
|
||||
self.pitch = (self.pitch + arg(2)?.parse::<f32>()?).clamp(-90.0, 90.0);
|
||||
let yaw = arg(1)?.parse::<f32>()?;
|
||||
let pitch = arg(2)?.parse::<f32>()?;
|
||||
// Degrees, as `Entity.turn` degrees.
|
||||
self.turn_player(f64::from(yaw / 0.15), f64::from(pitch / 0.15));
|
||||
ok()
|
||||
}
|
||||
"tp" => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! The creative toolbars, `hotbar.nbt` beside `options.txt`: hold the save or
|
||||
//! load key and press a hotbar key (`Minecraft.handleKeybinds`).
|
||||
use super::Game;
|
||||
use horizon_client::{hotbar, keybinds, text::Text};
|
||||
use horizon_client::{hotbar, items::ItemStack, keybinds, text::Text};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether the key bound to `mapping` is held.
|
||||
@@ -10,13 +11,65 @@ impl Game<'_> {
|
||||
.is_some_and(|k| self.physical_keys.contains(&k))
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen.selectTab` on the HOTBAR tab: each saved
|
||||
/// row's nine stacks, or for an empty row a locked paper in that row's own
|
||||
/// slot, named after the keys that save it (`inventory.hotbarInfo`).
|
||||
pub(super) fn refresh_saved_hotbars(&mut self) {
|
||||
if !self.creative_screen.tab().hotbar {
|
||||
return;
|
||||
}
|
||||
let path = Self::options_path().with_file_name("hotbar.nbt");
|
||||
let known = |id: &str| self.res.items().get(id).is_some();
|
||||
let rows = hotbar::load(&path, &known);
|
||||
let key = |mapping: &str| keybinds::display_name(self.settings.keys.get(mapping)).plain();
|
||||
let empty = || ItemStack::new("minecraft:air", 0);
|
||||
let mut items = Vec::with_capacity(hotbar::GROUPS * hotbar::SIZE);
|
||||
for (i, row) in rows.iter().enumerate().take(hotbar::GROUPS) {
|
||||
if row.iter().any(Option::is_some) {
|
||||
items.extend(row.iter().map(|s| s.clone().unwrap_or_else(empty)));
|
||||
continue;
|
||||
}
|
||||
for j in 0..hotbar::SIZE {
|
||||
if j != i {
|
||||
items.push(empty());
|
||||
continue;
|
||||
}
|
||||
let with = [
|
||||
key("key.saveToolbarActivator"),
|
||||
key(&format!("key.hotbar.{}", i + 1)),
|
||||
];
|
||||
let mut paper = ItemStack::new("minecraft:paper", 1);
|
||||
let data = paper.data_mut();
|
||||
data.set_other(
|
||||
"minecraft:creative_slot_lock",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
data.set_other(
|
||||
"minecraft:item_name",
|
||||
Some(Tag::compound([
|
||||
("translate", Tag::String("inventory.hotbarInfo".into())),
|
||||
(
|
||||
"with",
|
||||
Tag::List(
|
||||
with.iter()
|
||||
.map(|s| Tag::String(s.as_str().into()))
|
||||
.collect(),
|
||||
),
|
||||
),
|
||||
])),
|
||||
);
|
||||
items.push(paper);
|
||||
}
|
||||
}
|
||||
self.creative_screen.set_saved_hotbars(items);
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.handleHotbarLoadOrSave` for the hotbar key
|
||||
/// `index`: load puts the saved row in the hotbar (the creative slot
|
||||
/// packets follow from the inventory change), save stores the hotbar and
|
||||
/// says so on the action bar.
|
||||
pub(super) fn hotbar_load_or_save(&mut self, index: usize, load: bool) {
|
||||
let path = Self::options_path().with_file_name("hotbar.nbt");
|
||||
let known = |id: &str| self.res.items().get(id).is_some();
|
||||
let known = |id: &str| self.items().get(id).is_some();
|
||||
let mut rows = hotbar::load(&path, &known);
|
||||
if load {
|
||||
for (slot, stack) in rows[index].iter().enumerate() {
|
||||
|
||||
@@ -127,6 +127,27 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
|
||||
.full_collision
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractWindCharge` is pickable, so a thrown pearl's
|
||||
/// `ProjectileUtil.getEntityHitResult` can strike it (`ThrownEnderpearl.onHit`).
|
||||
const WIND_CHARGE_HIT: u32 = super::dragon::SPECIAL - 1;
|
||||
|
||||
fn wind_charge_hitboxes(projectiles: &[Projectile], skip: usize) -> Vec<(Who, Aabb)> {
|
||||
projectiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(j, q)| {
|
||||
*j != skip
|
||||
&& !q.stuck
|
||||
&& matches!(
|
||||
q.kind,
|
||||
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge
|
||||
)
|
||||
})
|
||||
.map(|(_, q)| (Who::Mob(WIND_CHARGE_HIT), q.aabb()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `FlameFeatureRenderer`: fire quads stacked up a burning entity's box,
|
||||
/// turned to the camera around Y, at full block light. `at` is the entity's
|
||||
/// feet relative to the camera.
|
||||
@@ -257,6 +278,7 @@ impl Game<'_> {
|
||||
snowy: has("minecraft:spawns_snow_foxes")
|
||||
|| b.name.contains("snow")
|
||||
|| b.name.contains("frozen"),
|
||||
snow_fox: has("minecraft:spawns_snow_foxes"),
|
||||
swamp: b.name.contains("swamp"),
|
||||
ocean: has("minecraft:is_ocean"),
|
||||
mushroom: b.name == "minecraft:mushroom_fields",
|
||||
@@ -468,7 +490,7 @@ impl Game<'_> {
|
||||
raining: weather && rain > 0.2,
|
||||
thundering: weather && thunder > 0.9,
|
||||
nether: self.dim == Dim::Nether,
|
||||
block_tags: &self.res.items().block_tags,
|
||||
block_tags: &self.items().block_tags,
|
||||
biomes: self.res.biomes(),
|
||||
mob_griefing: self.ui.rules.bool("mob_griefing"),
|
||||
mob_drops: self.ui.rules.bool("mob_drops"),
|
||||
@@ -477,7 +499,7 @@ impl Game<'_> {
|
||||
world_seed: self.level.seed,
|
||||
sea_level: if self.dim == Dim::Nether { 32 } else { 63 },
|
||||
teams: std::sync::Arc::new(self.mob_teams()),
|
||||
items: Some(self.res.items()),
|
||||
items: Some(self.items()),
|
||||
lid_openers: &self.lid_openers,
|
||||
// The first player in the level: a server has no player of its own.
|
||||
difficulty: self.local_difficulty(
|
||||
@@ -514,9 +536,9 @@ impl Game<'_> {
|
||||
};
|
||||
spawning::LocalDifficulty::new(base, self.world.day_time, inhabited, moon)
|
||||
}
|
||||
/// Advance every mob one tick, then apply their events.
|
||||
pub(super) fn tick_creatures(&mut self) -> Result<()> {
|
||||
self.attack_ticks = self.attack_ticks.saturating_add(1);
|
||||
/// Generation's animals for the chunks generated since, once lit: chunk
|
||||
/// generation places them whether the level ticks or not.
|
||||
pub(super) fn spawn_fresh_chunks(&mut self) {
|
||||
let fresh: Vec<(i32, i32)> = self
|
||||
.world
|
||||
.chunks
|
||||
@@ -530,6 +552,10 @@ impl Game<'_> {
|
||||
}
|
||||
self.spawn_generation_creatures(key.0, key.1);
|
||||
}
|
||||
}
|
||||
/// Advance every mob one tick, then apply their events.
|
||||
pub(super) fn tick_creatures(&mut self) -> Result<()> {
|
||||
self.attack_ticks = self.attack_ticks.saturating_add(1);
|
||||
self.tick_leads();
|
||||
self.spawn_natural();
|
||||
// ServerChunkCache.tickChunks: the custom spawners tick only with spawn_mobs.
|
||||
@@ -940,8 +966,10 @@ impl Game<'_> {
|
||||
fn drop_death_loot(&mut self, m: &mut Mob, by_player: bool, consumed: bool, pos: [f64; 3]) {
|
||||
use horizon_client::mobs::{DamageKind, Who, drops};
|
||||
use horizon_world::loot::{LootDamage, LootEntity};
|
||||
let Some(loot) = horizon_client::structure_blocks::loot_data(&self.options.resources)
|
||||
else {
|
||||
let Some(loot) = horizon_client::structure_blocks::loot_data(
|
||||
&self.options.resources,
|
||||
&self.pack_files(),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let player = || {
|
||||
@@ -1101,7 +1129,10 @@ impl Game<'_> {
|
||||
fn drop_gift_loot(&mut self, mob: u32, table: &'static str, at: [f64; 3]) {
|
||||
use horizon_client::mobs::drops;
|
||||
use horizon_world::loot::EntityLootParams;
|
||||
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
|
||||
let loot = horizon_client::structure_blocks::loot_data(
|
||||
&self.options.resources,
|
||||
&self.pack_files(),
|
||||
);
|
||||
let this = self
|
||||
.mobs
|
||||
.iter()
|
||||
@@ -1382,7 +1413,9 @@ impl Game<'_> {
|
||||
.play(&format!("minecraft:{sound}"), Some(at), 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
MobEvent::Tamed { .. } | MobEvent::Buck { .. } => self.mount_event(&e),
|
||||
MobEvent::Tamed { .. } | MobEvent::Buck { .. } | MobEvent::StabDismount { .. } => {
|
||||
self.mount_event(&e)
|
||||
}
|
||||
MobEvent::Launch { target, up } => match target {
|
||||
Who::Player => {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
@@ -1608,7 +1641,7 @@ impl Game<'_> {
|
||||
self.destroy_particles(pos, old);
|
||||
}
|
||||
if drop {
|
||||
let drops = self.res.items().block_drops_in(
|
||||
let drops = world_items!(self).block_drops_in(
|
||||
self.pack.assets.registry,
|
||||
old,
|
||||
None,
|
||||
@@ -1759,6 +1792,16 @@ impl Game<'_> {
|
||||
horizon_client::mobs::animal_brain::set_breed_partner(m, partner);
|
||||
}
|
||||
}
|
||||
MobEvent::Retreat { mob, from, at } => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
|
||||
horizon_client::mobs::piglin::receive_retreat(m, from, at);
|
||||
}
|
||||
}
|
||||
MobEvent::StartRiding { rider, vehicle } => {
|
||||
horizon_client::mobs::riders::start_riding(&mut self.mobs, rider, vehicle);
|
||||
}
|
||||
// Applied after the fish's tick (`apply_brain_events`).
|
||||
MobEvent::StartFollowing { .. } | MobEvent::SchoolSize { .. } => {}
|
||||
MobEvent::LeashBreak { mob, drop, sound } => self.break_leash(mob, drop, sound),
|
||||
MobEvent::Vibration { from, to, ticks } => {
|
||||
self.mob_vibration_particle(from, to, ticks)
|
||||
@@ -1912,6 +1955,64 @@ impl Game<'_> {
|
||||
self.spawn_orbs(at, amount);
|
||||
}
|
||||
}
|
||||
/// `DyeColor.getMixedColor`: the colour of the crafting recipe that mixes
|
||||
/// the two dyes, else one of the two picked at random.
|
||||
fn dye_mix(&mut self, a: u8, b: u8) -> u8 {
|
||||
if let Some(mixed) = self.dye_color_mix_in_recipes(a, b) {
|
||||
return mixed;
|
||||
}
|
||||
if self.world.random.next_boolean() {
|
||||
a
|
||||
} else {
|
||||
b
|
||||
}
|
||||
}
|
||||
/// `DyeColor.findColorMixInRecipes`: the dye colour a two-dye crafting
|
||||
/// recipe makes, when its result is a dye.
|
||||
fn dye_color_mix_in_recipes(&self, a: u8, b: u8) -> Option<u8> {
|
||||
let dye = |c: u8| {
|
||||
let name = horizon_client::mobs::render::DYE_NAMES[usize::from(c) & 15];
|
||||
Some(ItemStack::new(&format!("minecraft:{name}_dye"), 1))
|
||||
};
|
||||
let grid = [dye(a), dye(b), None, None];
|
||||
let (_, result) = self.res.recipes().find(&grid, 2)?;
|
||||
let name = result
|
||||
.item
|
||||
.strip_prefix("minecraft:")?
|
||||
.strip_suffix("_dye")?;
|
||||
let index = horizon_client::mobs::render::DYE_NAMES
|
||||
.iter()
|
||||
.position(|n| *n == name)?;
|
||||
u8::try_from(index).ok()
|
||||
}
|
||||
/// `ServerPlayer.tellNeutralMobsThatIDied`: the mobs within 32 blocks
|
||||
/// across and 10 up and down get `NeutralMob.playerDied`, when the
|
||||
/// `forgive_dead_players` rule is on. A mob's `Who::Player` is the guest
|
||||
/// it is bound to in `bindings` (game/lan_mobs.rs), else the host's
|
||||
/// player, so only the mobs whose binding is `conn` (`None` for the
|
||||
/// host's player) hear it.
|
||||
pub(super) fn tell_neutral_mobs_player_died(
|
||||
&mut self,
|
||||
feet: [f64; 3],
|
||||
conn: Option<u32>,
|
||||
bindings: &std::collections::HashMap<u32, u32>,
|
||||
) {
|
||||
// A client of someone else's world has the server's mobs only to show.
|
||||
if self.remote.is_some() || !self.ui.rules.bool("forgive_dead_players") {
|
||||
return;
|
||||
}
|
||||
let block = feet.map(f64::floor);
|
||||
let area = Aabb::new(
|
||||
Vec3::new(block[0], block[1], block[2]),
|
||||
Vec3::new(block[0] + 1.0, block[1] + 1.0, block[2] + 1.0),
|
||||
)
|
||||
.inflate(Vec3::new(32.0, 10.0, 32.0));
|
||||
for m in self.mobs.iter_mut() {
|
||||
if bindings.get(&m.id).copied() == conn && m.bounds().intersects(area) {
|
||||
horizon_client::mobs::ai::forgive_dead_player(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Animal.spawnChildFromBreeding
|
||||
fn breed(
|
||||
&mut self,
|
||||
@@ -1983,7 +2084,24 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let coin = self.world.random.next_int_bound(2).unwrap_or(0) == 0;
|
||||
let coin2 = self.world.random.next_int_bound(2).unwrap_or(0) == 0;
|
||||
// Axolotl.getBreedOffspring: useRareVariant (one in 1200) makes it blue,
|
||||
// else either parent's variant. The draws come from the mother's random.
|
||||
let axolotl = if p.species() == Species::Axolotl {
|
||||
let other = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant);
|
||||
self.mobs.iter_mut().find(|m| m.id == parent).map(|m| {
|
||||
if m.random.next_int_bound(1200).unwrap_or(1) == 0 {
|
||||
// Axolotl.Variant.getRareSpawnVariant: the one blue, still a draw.
|
||||
let _ = m.random.next_int_bound(1);
|
||||
4
|
||||
} else if m.random.next_boolean() {
|
||||
p.extra.variant
|
||||
} else {
|
||||
other
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// A horse and a donkey have a mule.
|
||||
let kind = match (p.species(), q.as_ref().map(|q| q.species())) {
|
||||
(Species::Horse, Some(Species::Donkey)) | (Species::Donkey, Some(Species::Horse)) => {
|
||||
@@ -1998,6 +2116,24 @@ impl Game<'_> {
|
||||
// getBreedOffspring creates the baby without finalizeSpawn: it keeps
|
||||
// the default (`classic`) sound variant.
|
||||
child.extra.sound_variant = None;
|
||||
// Fox.getBreedOffspring runs no finalizeSpawn: nothing in the
|
||||
// baby fox's mouth.
|
||||
if p.species() == Species::Fox {
|
||||
child.extra.main_hand = None;
|
||||
}
|
||||
// Goat.getBreedOffspring: GoatAi.initMemories, then the baby
|
||||
// screams if the parent a coin toss picks does, or on a 2% roll.
|
||||
if p.species() == Species::Goat {
|
||||
child.extra.lost_horns = [false; 2];
|
||||
horizon_client::mobs::finalize::goat_init_memories(child, &mut self.world.random);
|
||||
let pick_parent = self.world.random.next_boolean();
|
||||
let parent_screams = if pick_parent {
|
||||
p.extra.screaming
|
||||
} else {
|
||||
q.as_ref().unwrap_or(&p).extra.screaming
|
||||
};
|
||||
child.extra.screaming = parent_screams || self.world.random.next_double() < 0.02;
|
||||
}
|
||||
// A llama's baby was drawn in the parent's tick, with the
|
||||
// experience.
|
||||
if let Some(o) = llama {
|
||||
@@ -2046,11 +2182,12 @@ impl Game<'_> {
|
||||
if p.extra.tame {
|
||||
child.extra.tame = true;
|
||||
child.extra.owner = p.extra.owner;
|
||||
child.extra.color = if coin2 {
|
||||
p.extra.color
|
||||
} else {
|
||||
other.extra.color
|
||||
};
|
||||
// The mix needs all of `self`, so it waits until `child`'s
|
||||
// borrow of the mob list is over.
|
||||
let mixed = self.dye_mix(p.extra.color, other.extra.color);
|
||||
if let Some(c) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
c.extra.color = mixed;
|
||||
}
|
||||
}
|
||||
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
|
||||
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
|
||||
@@ -2058,7 +2195,7 @@ impl Game<'_> {
|
||||
}
|
||||
if p.species() == Species::Wolf {
|
||||
// Wolf.getBreedOffspring: a parent's variant; tame parents
|
||||
// give a tame baby with one of their collar colours.
|
||||
// give a tame baby with their collar colours mixed.
|
||||
let other = q.as_ref().unwrap_or(&p);
|
||||
child.extra.variant = if coin {
|
||||
p.extra.variant
|
||||
@@ -2068,11 +2205,12 @@ impl Game<'_> {
|
||||
if p.extra.tame {
|
||||
horizon_client::mobs::tame::set_tame(child, true);
|
||||
child.extra.owner = p.extra.owner;
|
||||
child.extra.color = if coin2 {
|
||||
p.extra.color
|
||||
} else {
|
||||
other.extra.color
|
||||
};
|
||||
// The mix needs all of `self`, so it waits until `child`'s
|
||||
// borrow of the mob list is over.
|
||||
let mixed = self.dye_mix(p.extra.color, other.extra.color);
|
||||
if let Some(c) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
c.extra.color = mixed;
|
||||
}
|
||||
}
|
||||
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
|
||||
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
|
||||
@@ -2085,6 +2223,14 @@ impl Game<'_> {
|
||||
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
|
||||
return;
|
||||
}
|
||||
// Axolotl.getBreedOffspring: the drawn variant, and the baby is persistent.
|
||||
if let Some(variant) = axolotl {
|
||||
child.extra.variant = variant;
|
||||
child.persistent = true;
|
||||
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
|
||||
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
|
||||
return;
|
||||
}
|
||||
// Nautilus.getBreedOffspring: a tame parent's baby is tame too.
|
||||
if p.species() == Species::Nautilus && p.extra.tame {
|
||||
child.extra.tame = true;
|
||||
@@ -2101,8 +2247,13 @@ impl Game<'_> {
|
||||
{
|
||||
child.extra.variant = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant);
|
||||
}
|
||||
// Sheep.getBreedOffspring: the lamb's wool mixes both parents' dyes.
|
||||
if p.species() == Species::Sheep {
|
||||
child.extra.color = p.extra.color;
|
||||
let other = q.as_ref().unwrap_or(&p);
|
||||
let mixed = self.dye_mix(p.extra.color, other.extra.color);
|
||||
if let Some(c) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
c.extra.color = mixed;
|
||||
}
|
||||
}
|
||||
}
|
||||
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
|
||||
@@ -2491,13 +2642,21 @@ impl Game<'_> {
|
||||
.map(|g| g.conn)
|
||||
.collect()
|
||||
});
|
||||
projectiles.retain_mut(|p| {
|
||||
let mut keep_flags = vec![true; projectiles.len()];
|
||||
for i in 0..projectiles.len() {
|
||||
let winds = if matches!(projectiles[i].kind, ProjectileKind::EnderPearl) {
|
||||
wind_charge_hitboxes(&projectiles, i)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let p = &mut projectiles[i];
|
||||
if vanish
|
||||
&& matches!(p.kind, ProjectileKind::EnderPearl)
|
||||
&& p.owner == Some(Who::Player)
|
||||
&& p.guest.map_or(host_dead, |c| dead_guests.contains(&c))
|
||||
{
|
||||
return false;
|
||||
keep_flags[i] = false;
|
||||
continue;
|
||||
}
|
||||
let mob_shot = !matches!(p.owner, Some(Who::Player));
|
||||
// A player's arrows, snowballs and eggs meet every player; a
|
||||
@@ -2512,11 +2671,25 @@ impl Game<'_> {
|
||||
if shooter.is_some() {
|
||||
p.owner = shooter;
|
||||
}
|
||||
let (keep, hit) = match (p.arrow.is_some(), mob_shot || pvp) {
|
||||
(true, false) => horizon_client::arrows::tick(p, &scene, &arrow_targets),
|
||||
(true, true) => horizon_client::arrows::tick(p, &scene, &arrow_targets_guests),
|
||||
(false, false) => p.tick(&pw),
|
||||
(false, true) => p.tick(&pw_guests),
|
||||
let (keep, hit) = match (p.arrow.is_some(), mob_shot || pvp, winds.is_empty()) {
|
||||
(true, false, _) => horizon_client::arrows::tick(p, &scene, &arrow_targets),
|
||||
(true, true, _) => {
|
||||
horizon_client::arrows::tick(p, &scene, &arrow_targets_guests)
|
||||
}
|
||||
(false, false, true) => p.tick(&pw),
|
||||
(false, true, true) => p.tick(&pw_guests),
|
||||
(false, pvp_shot, false) => {
|
||||
let mut targets = if pvp_shot {
|
||||
pw_guests.targets.clone()
|
||||
} else {
|
||||
pw.targets.clone()
|
||||
};
|
||||
targets.extend(winds);
|
||||
p.tick(&ProjWorld {
|
||||
w: if pvp_shot { pw_guests.w } else { pw.w },
|
||||
targets,
|
||||
})
|
||||
}
|
||||
};
|
||||
if shooter.is_some() {
|
||||
p.owner = Some(Who::Player);
|
||||
@@ -2562,7 +2735,7 @@ impl Game<'_> {
|
||||
arrow_events.extend(a.events.drain(..).map(|e| (p.position, e)));
|
||||
}
|
||||
deflections.push([at.x, at.y, at.z]);
|
||||
return true;
|
||||
continue;
|
||||
}
|
||||
// AbstractWindCharge.tick: out of the world's top it bursts.
|
||||
if wind && keep && p.position.y > 350.0 {
|
||||
@@ -2574,12 +2747,19 @@ impl Game<'_> {
|
||||
face: [0; 3],
|
||||
},
|
||||
));
|
||||
return false;
|
||||
keep_flags[i] = false;
|
||||
continue;
|
||||
}
|
||||
if hit != Hit::None {
|
||||
hits.push((p.clone(), hit));
|
||||
}
|
||||
keep
|
||||
keep_flags[i] = keep;
|
||||
}
|
||||
let mut idx = 0;
|
||||
projectiles.retain(|_| {
|
||||
let k = keep_flags[idx];
|
||||
idx += 1;
|
||||
k
|
||||
});
|
||||
}
|
||||
self.smoke_trails(trails);
|
||||
@@ -2656,10 +2836,10 @@ impl Game<'_> {
|
||||
attacker: (shot.owner == Some(Who::Player)).then_some("minecraft:player"),
|
||||
direct_attacker: Some("minecraft:trident"),
|
||||
damage_type: Some("minecraft:trident"),
|
||||
items: Some(self.res.items()),
|
||||
items: Some(world_items!(self)),
|
||||
..Default::default()
|
||||
};
|
||||
self.res.stations().enchantments.modify_value(
|
||||
world_stations!(self).enchantments.modify_value(
|
||||
Some(&trident),
|
||||
"minecraft:damage",
|
||||
&ctx,
|
||||
@@ -3543,6 +3723,69 @@ impl Game<'_> {
|
||||
self.particles.add(&options, at, v);
|
||||
}
|
||||
}
|
||||
/// `AgeableMob.mobInteract`: whether a click with `item` on `m` gets to
|
||||
/// the golden dandelion check (`canUseGoldenDandelion`, which a tadpole
|
||||
/// calls with `isBaby` true). A panda that is scared or on its back and an
|
||||
/// armadillo in its shell answer in their own `mobInteract` first.
|
||||
pub(super) fn age_lock_takes(&self, m: &Mob, item: &str) -> bool {
|
||||
let species = m.species();
|
||||
let baby = if species == Species::Tadpole {
|
||||
true
|
||||
} else if horizon_client::mobs::finalize::ageable(species) {
|
||||
m.is_baby()
|
||||
&& !(species == Species::Panda
|
||||
&& (horizon_client::mobs::panda::scared(m)
|
||||
|| horizon_client::mobs::panda::is_on_back(m)))
|
||||
&& !(species == Species::Armadillo && horizon_client::mobs::armadillo::scared(m))
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let cannot = self
|
||||
.res
|
||||
.mob_data()
|
||||
.entity_has("minecraft:cannot_be_age_locked", &m.kind.id);
|
||||
horizon_client::mobs::ai::can_use_golden_dandelion(
|
||||
item,
|
||||
baby,
|
||||
m.extra.age_lock_particle_timer,
|
||||
cannot,
|
||||
)
|
||||
}
|
||||
/// `AgeableMob.setAgeLocked`: a golden dandelion on mob `index` flips its
|
||||
/// age lock (a tadpole's or an ageable mob's own `setAgeLockedData`) and is
|
||||
/// used up; a locked mob stays (`setPersistenceRequired`), and the lock's
|
||||
/// use or unuse sound plays at its block for everyone near.
|
||||
pub(super) fn set_age_locked(&mut self, index: usize) {
|
||||
let m = &mut self.mobs[index];
|
||||
if m.species() == Species::Tadpole {
|
||||
horizon_client::mobs::aquatic::set_age_locked_data(m);
|
||||
} else {
|
||||
horizon_client::mobs::ai::set_age_locked_data(m);
|
||||
}
|
||||
self.consume_held();
|
||||
let m = &mut self.mobs[index];
|
||||
let locked = m.extra.age_locked;
|
||||
if locked {
|
||||
m.persistent = true;
|
||||
}
|
||||
// Level.playSound(null, blockPosition, ..): the middle of the block.
|
||||
let at = [
|
||||
m.position.x.floor() + 0.5,
|
||||
m.position.y.floor() + 0.5,
|
||||
m.position.z.floor() + 0.5,
|
||||
];
|
||||
let chunk = (
|
||||
m.position.x.floor() as i32 >> 4,
|
||||
m.position.z.floor() as i32 >> 4,
|
||||
);
|
||||
self.world.modified.insert(chunk);
|
||||
let sound = if locked {
|
||||
"minecraft:item.golden_dandelion.use"
|
||||
} else {
|
||||
"minecraft:item.golden_dandelion.unuse"
|
||||
};
|
||||
self.audio.play(sound, Some(at), 1.0, 1.0);
|
||||
}
|
||||
/// Right click on a mob (Mob.interact / mobInteract). Returns true when consumed.
|
||||
pub(super) fn interact_mob(&mut self) -> Result<bool> {
|
||||
let Some((index, distance)) = self.mob_hit() else {
|
||||
@@ -3614,6 +3857,17 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
// AgeableMob.mobInteract: a golden dandelion on a baby freezes its
|
||||
// growth, or lets it grow again.
|
||||
if self.mobs[index].alive() && self.age_lock_takes(&self.mobs[index], &item) {
|
||||
self.set_age_locked(index);
|
||||
if let Some(stack) = &held {
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target);
|
||||
}
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
let creative = self.creative;
|
||||
// The hand's slot (Player.interactOn passes the hand).
|
||||
let selected = self.held_slot();
|
||||
@@ -3772,7 +4026,7 @@ impl Game<'_> {
|
||||
Species::Sheep if item == "minecraft:shears" && !m.extra.sheared && !m.is_baby() => {
|
||||
m.extra.sheared = true;
|
||||
let color = horizon_client::mobs::render::DYE_NAMES[m.extra.color as usize & 15];
|
||||
let stacks = self.res.items().loot_table(
|
||||
let stacks = world_items!(self).loot_table(
|
||||
&format!("minecraft:shearing/sheep/{color}"),
|
||||
false,
|
||||
None,
|
||||
@@ -4088,7 +4342,7 @@ impl Game<'_> {
|
||||
// adult remember it (`gotFish`), to swim to a treasure.
|
||||
Species::Dolphin if fish => {
|
||||
eat = Some(("entity.dolphin.eat", 1.0, 1.0));
|
||||
if m.extra.age < 0 {
|
||||
if horizon_client::mobs::ai::can_age_up(m) {
|
||||
let secs = ((-m.extra.age / 20) as f32 * 0.1) as i32;
|
||||
horizon_client::mobs::ai::age_up(m, secs, true);
|
||||
} else {
|
||||
@@ -4137,6 +4391,12 @@ impl Game<'_> {
|
||||
Some(sound) => {
|
||||
consume = true;
|
||||
eat = sound;
|
||||
// Axolotl.usePlayerItem: a tropical fish bucket is
|
||||
// emptied into a water bucket in its place.
|
||||
if species == Species::Axolotl && &*item == "minecraft:tropical_fish_bucket"
|
||||
{
|
||||
give = Some("minecraft:water_bucket");
|
||||
}
|
||||
// Animal.setInLove: entity event 18 (a baby that
|
||||
// only grew is not in love).
|
||||
if self.mobs[index].extra.in_love > 0 {
|
||||
@@ -4579,6 +4839,13 @@ impl Game<'_> {
|
||||
if mob.fire_ticks > 0 || matches!(mob.species(), Species::Blaze | Species::MagmaCube) {
|
||||
packed = (15 * 16) | ((15 * 16) << 8);
|
||||
}
|
||||
// GlowSquidRenderer.getBlockLightLevel: the body brightens as the
|
||||
// dark ticks run out (the block light is the second byte).
|
||||
if mob.species() == Species::GlowSquid {
|
||||
let lit = (packed >> 12) & 15;
|
||||
let block = horizon_client::mobs::aquatic::glow_squid_block_light(mob, lit);
|
||||
packed = (packed & 0xFF) | ((block * 16) << 8);
|
||||
}
|
||||
if mob.species() == Species::SulfurCube
|
||||
&& !horizon_client::mobs::trader::is_invisible(mob)
|
||||
{
|
||||
@@ -4930,10 +5197,12 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
|
||||
K::FallingStalactite => "minecraft:falling_stalactite",
|
||||
K::OnFire => "minecraft:on_fire",
|
||||
K::InFire => "minecraft:in_fire",
|
||||
K::Campfire => "minecraft:campfire",
|
||||
K::Lava => "minecraft:lava",
|
||||
K::Drown => "minecraft:drown",
|
||||
K::DryOut => "minecraft:dry_out",
|
||||
K::Magic => "minecraft:magic",
|
||||
K::IndirectMagic => "minecraft:indirect_magic",
|
||||
K::Sting => "minecraft:sting",
|
||||
K::Cactus => "minecraft:cactus",
|
||||
K::SweetBerryBush => "minecraft:sweet_berry_bush",
|
||||
|
||||
@@ -130,27 +130,15 @@ impl Game<'_> {
|
||||
let bounds = aabb(c.bounds()).next_deflated();
|
||||
blocks_in(bounds).any(|p| {
|
||||
let facts = self.res.facts().get(self.world.block(p[0], p[1], p[2]));
|
||||
self.res.items().block_in_tag("minecraft:fire", &facts.name)
|
||||
self.items().block_in_tag("minecraft:fire", &facts.name)
|
||||
})
|
||||
}
|
||||
|
||||
/// `CushionItem.useOn`: on the top face only, spawning at the click's
|
||||
/// height above the clicked block (or the replaceable block itself).
|
||||
pub(super) fn place_cushion(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
|
||||
let Some(info) = self.res.items().get(&stack.item).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let Some(default_color) = info.cushion_color.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
let color_override = stack
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:cushion/color"))
|
||||
.and_then(|tag| match tag {
|
||||
horizon_world::nbt::Tag::String(s) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
});
|
||||
let color = dye_index(color_override.as_deref().unwrap_or(default_color));
|
||||
/// `CushionItem.useOn` up to `Cushion.canBePlacedAt`, which the client
|
||||
/// checks too: on the top face only, at the click's height above the
|
||||
/// clicked block (or the replaceable block itself). The position and
|
||||
/// spawn box of a cushion that may go there.
|
||||
pub(super) fn cushion_spawn(&self, hit: &Hit) -> Option<(Vec3, Aabb)> {
|
||||
// CushionItem.recalculateContextForSpecialCollisionShapes: open containers
|
||||
// (cauldrons, hoppers and composters) use the collision ray's actual height.
|
||||
let mut adjusted = Hit {
|
||||
@@ -159,7 +147,7 @@ impl Game<'_> {
|
||||
point: hit.point,
|
||||
};
|
||||
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
if self.res.items().block_in_tag(
|
||||
if self.items().block_in_tag(
|
||||
"minecraft:cushion_uses_collision_shape",
|
||||
&self.res.facts().get(state).name,
|
||||
) && let Some(eye) = self.eye_position()
|
||||
@@ -186,7 +174,7 @@ impl Game<'_> {
|
||||
}
|
||||
let hit = &adjusted;
|
||||
if hit.face != [0, 1, 0] {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
// BlockPlaceContext: a replaceable clicked block is replaced.
|
||||
let clicked = self
|
||||
@@ -205,9 +193,30 @@ impl Game<'_> {
|
||||
[position.x - 0.5, position.y, position.z - 0.5],
|
||||
[position.x + 0.5, position.y + 0.25, position.z + 0.5],
|
||||
));
|
||||
if !horizon_client::cushion::can_place(&PlacementWorld(self), bounds) {
|
||||
horizon_client::cushion::can_place(&PlacementWorld(self), bounds)
|
||||
.then_some((position, bounds))
|
||||
}
|
||||
|
||||
/// `CushionItem.useOn`: spawn the cushion where `cushion_spawn` allows,
|
||||
/// unless another cushion is in the way.
|
||||
pub(super) fn place_cushion(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
|
||||
let Some(info) = self.items().get(&stack.item).cloned() else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let Some(default_color) = info.cushion_color.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
let color_override = stack
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:cushion/color"))
|
||||
.and_then(|tag| match tag {
|
||||
horizon_world::nbt::Tag::String(s) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
});
|
||||
let color = dye_index(color_override.as_deref().unwrap_or(default_color));
|
||||
let Some((position, bounds)) = self.cushion_spawn(hit) else {
|
||||
return false;
|
||||
};
|
||||
// `getEntitiesOfClass(Cushion.class, spawnAABB).isEmpty()`.
|
||||
if self
|
||||
.cushions
|
||||
@@ -255,6 +264,8 @@ impl Game<'_> {
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
// `CushionItem.useOn` returns SUCCESS: the hand swings.
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -280,11 +291,9 @@ impl Game<'_> {
|
||||
if occupied {
|
||||
return false;
|
||||
}
|
||||
// `Player.startRiding` gives up the vehicle it already has; the
|
||||
// client never sends that click, so pass instead.
|
||||
if self.riding.is_some() {
|
||||
return false;
|
||||
}
|
||||
// `Entity.startRiding`: a passenger first gets off what it rides
|
||||
// (`stopRiding`), so a seated player moves to this cushion.
|
||||
self.stop_riding();
|
||||
self.riding = Some(id);
|
||||
let p = self.cushions[i].position;
|
||||
self.audio.play(
|
||||
@@ -394,6 +403,26 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.stopRiding` off a cushion: `dismountVehicle` beside it,
|
||||
/// and `Cushion.removePassenger`'s get-up sound for a rider that left
|
||||
/// rather than one whose cushion was killed.
|
||||
pub(super) fn get_up_from_cushion(&mut self, c: &Cushion) {
|
||||
let at = c.dismount();
|
||||
self.riding = None;
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
p.position = at;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
self.audio.play(
|
||||
"minecraft:entity.cushion.get_up",
|
||||
Some([c.position.x, c.position.y, c.position.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
|
||||
/// `Player.rideTick` on a cushion: hold the seat, and get up on sneak.
|
||||
pub(super) fn tick_cushion_riding(&mut self, keys: super::Keys) -> bool {
|
||||
let Some(c) = self
|
||||
@@ -405,22 +434,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
};
|
||||
if keys.shift {
|
||||
let at = c.dismount();
|
||||
self.riding = None;
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
p.position = at;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
// `Cushion.removePassenger`: the get-up sound for a rider that
|
||||
// left rather than one whose cushion was killed.
|
||||
self.audio.play(
|
||||
"minecraft:entity.cushion.get_up",
|
||||
Some([c.position.x, c.position.y, c.position.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
self.get_up_from_cushion(&c);
|
||||
return true;
|
||||
}
|
||||
let seat = c.seat();
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
//! `DebugStickItem`: selecting a block-state property with the left button and
|
||||
//! cycling its value with the right button, as vanilla does.
|
||||
//!
|
||||
//! The selected property is the `minecraft:debug_stick_state` component, a
|
||||
//! compound of block id to property name. Horizon keeps unmodelled components
|
||||
//! in `StackData::other`, so the component travels through NBT unchanged.
|
||||
use super::Game;
|
||||
use horizon_client::{Result, items::ItemStack, text::Text};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::level::flags;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
const DEBUG_STICK: &str = "minecraft:debug_stick";
|
||||
|
||||
fn is_debug_stick(stack: &ItemStack) -> bool {
|
||||
&*stack.item == DEBUG_STICK
|
||||
}
|
||||
|
||||
/// `Util.findNextInIterable` / `findPreviousInIterable` on an index.
|
||||
///
|
||||
/// A missing current value starts at the first value forward and the last
|
||||
/// value backward; both wrap around.
|
||||
fn relative_index(len: usize, current: Option<usize>, backward: bool) -> usize {
|
||||
match (current, backward) {
|
||||
(Some(0), true) => len - 1,
|
||||
(Some(i), true) => i - 1,
|
||||
(Some(i), false) => (i + 1) % len,
|
||||
(None, true) => len - 1,
|
||||
(None, false) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn text(key: &str, args: &[&str]) -> Text {
|
||||
let args: Vec<Text> = args.iter().map(|arg| Text::literal(*arg)).collect();
|
||||
Text::translate(key, &args)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `DebugStickItem.useOn`: a right click cycles the value of the property
|
||||
/// in `debug_stick_state` (the first property when none is selected yet).
|
||||
/// `Some` is the vanilla result (`SUCCESS`/`FAIL`), `None` another item.
|
||||
pub(super) fn debug_stick_use_on(&mut self, pos: [i32; 3]) -> Result<Option<bool>> {
|
||||
if !self.held().is_some_and(is_debug_stick) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(self.debug_stick_interact(pos, true)?))
|
||||
}
|
||||
|
||||
/// `DebugStickItem.canDestroyBlock`: a left click selects the next
|
||||
/// property and never destroys the block.
|
||||
pub(super) fn debug_stick_can_destroy(&mut self, pos: [i32; 3]) -> Result<bool> {
|
||||
if !self.held().is_some_and(is_debug_stick) {
|
||||
return Ok(true);
|
||||
}
|
||||
let _ = self.debug_stick_interact(pos, false)?;
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// `DebugStickItem.handleInteraction`: whether the interaction succeeded.
|
||||
fn debug_stick_interact(&mut self, pos: [i32; 3], cycle: bool) -> Result<bool> {
|
||||
// `Player.canUseGameMasterBlocks`: creative with game-master commands.
|
||||
if !self.can_use_game_master_blocks() {
|
||||
return Ok(false);
|
||||
}
|
||||
let state_id = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let Some(state) = self.pack.assets.registry.state(state_id).cloned() else {
|
||||
return Ok(false);
|
||||
};
|
||||
if state.properties.is_empty() {
|
||||
self.debug_stick_message(text(
|
||||
"item.minecraft.debug_stick.empty",
|
||||
&[state.name.as_str()],
|
||||
));
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let selected = self
|
||||
.held()
|
||||
.and_then(ItemStack::data)
|
||||
.and_then(|data| data.debug_stick_property(&state.name));
|
||||
// `Player.isSecondaryUseActive` / `isShiftKeyDown`: the acting
|
||||
// player's sneak input. `keys` holds the host's, toggled sneak
|
||||
// included; `enter_guest` fills it from the guest's sneak state.
|
||||
let backward = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
|
||||
if cycle {
|
||||
// `cycleState`: a property selected on another block falls back to
|
||||
// the first one. The property list is sorted by name, as vanilla's
|
||||
// `ImmutableSortedMap`.
|
||||
let (property, value) = {
|
||||
let registry = &self.pack.assets.registry;
|
||||
let property = selected
|
||||
.as_deref()
|
||||
.filter(|property| state.properties.contains_key(*property))
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| state.properties.keys().next().unwrap().clone());
|
||||
let values = registry.property_values(&state.name, &property);
|
||||
if values.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let current = state.properties.get(&property).map(String::as_str);
|
||||
let current = current.and_then(|value| values.iter().position(|v| *v == value));
|
||||
let value = values[relative_index(values.len(), current, backward)].to_owned();
|
||||
(property, value)
|
||||
};
|
||||
let mut next = state;
|
||||
next.properties.insert(property.clone(), value.clone());
|
||||
let Ok(next_id) = self.pack.assets.registry.resolve(&next) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
// `Level.setBlock(pos, newState, 18)`: clients and the known shape,
|
||||
// without neighbour updates.
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.set_block(
|
||||
level,
|
||||
ticks,
|
||||
at,
|
||||
next_id,
|
||||
flags::UPDATE_CLIENTS | flags::UPDATE_KNOWN_SHAPE,
|
||||
);
|
||||
});
|
||||
self.process_level_events()?;
|
||||
self.debug_stick_message(text(
|
||||
"item.minecraft.debug_stick.update",
|
||||
&[property.as_str(), value.as_str()],
|
||||
));
|
||||
} else {
|
||||
// `getRelative(properties, property, backward)`; the property list
|
||||
// is sorted, so its key order is vanilla's.
|
||||
let keys: Vec<&String> = state.properties.keys().collect();
|
||||
let current = selected
|
||||
.as_deref()
|
||||
.and_then(|selected| keys.iter().position(|key| key.as_str() == selected));
|
||||
let property = keys[relative_index(keys.len(), current, backward)].clone();
|
||||
let value = state.properties.get(&property).cloned().unwrap_or_default();
|
||||
self.set_debug_stick_property(&state.name, &property);
|
||||
self.debug_stick_message(text(
|
||||
"item.minecraft.debug_stick.select",
|
||||
&[property.as_str(), value.as_str()],
|
||||
));
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// `DebugStickState.withProperty(block, property)` on the acting hand.
|
||||
fn set_debug_stick_property(&mut self, block: &str, property: &str) {
|
||||
let Some(stack) = self.held_mut().as_mut() else {
|
||||
return;
|
||||
};
|
||||
stack.data_mut().set_debug_stick_property(block, property);
|
||||
stack.tidy();
|
||||
}
|
||||
|
||||
/// `ServerPlayer.sendOverlayMessage`: the steady player sees it on this
|
||||
/// game's HUD; a server client receives it as a packet.
|
||||
fn debug_stick_message(&mut self, message: Text) {
|
||||
if self.lan_acting.is_some() {
|
||||
self.guest_packets
|
||||
.push(horizon_protocol::packets::play::Clientbound::SystemChat(
|
||||
horizon_protocol::packets::play::SystemChat {
|
||||
content: horizon_client::lan::data::component_nbt(&message.component()),
|
||||
overlay: true,
|
||||
},
|
||||
));
|
||||
} else if self.options.dedicated.is_none() {
|
||||
self.titles.set_overlay(message, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::relative_index;
|
||||
|
||||
#[test]
|
||||
fn relative_index_wraps_like_util() {
|
||||
assert_eq!(relative_index(4, None, false), 0);
|
||||
assert_eq!(relative_index(4, None, true), 3);
|
||||
assert_eq!(relative_index(4, Some(0), false), 1);
|
||||
assert_eq!(relative_index(4, Some(0), true), 3);
|
||||
assert_eq!(relative_index(4, Some(2), false), 3);
|
||||
assert_eq!(relative_index(4, Some(2), true), 1);
|
||||
assert_eq!(relative_index(4, Some(3), false), 0);
|
||||
assert_eq!(relative_index(4, Some(3), true), 2);
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,10 @@ impl Game<'_> {
|
||||
("z", Tag::Int(pos[2])),
|
||||
])
|
||||
});
|
||||
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
|
||||
let loot = horizon_client::structure_blocks::loot_data(
|
||||
&self.options.resources,
|
||||
&self.pack_files(),
|
||||
);
|
||||
unpack_loot(
|
||||
&mut tag,
|
||||
loot.as_deref(),
|
||||
|
||||
@@ -531,7 +531,17 @@ const BROADCAST: &[&str] = &[
|
||||
|
||||
impl Game<'_> {
|
||||
/// DedicatedServer.initServer and MinecraftServer.runServer.
|
||||
// The epoch starts at world open; failures before that send no lifecycle event.
|
||||
pub(super) fn run_dedicated(&mut self) -> Result<()> {
|
||||
let result = self.run_dedicated_inner();
|
||||
if let Err(error) = &result {
|
||||
self.native_lifecycle_world_failed(&error.to_string());
|
||||
self.native_lifecycle_close_failed(&error.to_string());
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn run_dedicated_inner(&mut self) -> Result<()> {
|
||||
// This thread ticks the server (MinecraftServer.spin's priority).
|
||||
horizon_threads::favour_current();
|
||||
self.join_world_rest()?;
|
||||
@@ -609,13 +619,9 @@ impl Game<'_> {
|
||||
// MinecraftServer.initializeKeyPair (only online mode uses it).
|
||||
log("INFO", "Generating keypair");
|
||||
let online = if props.online_mode {
|
||||
let key = horizon_protocol::rsa::RsaPrivateKey::generate(1024)
|
||||
.map_err(|e| format!("Failed to generate key pair: {e}"))?;
|
||||
Some(Arc::new(OnlineMode {
|
||||
public_der: key.public_der(),
|
||||
key,
|
||||
prevent_proxy: props.prevent_proxy_connections,
|
||||
}))
|
||||
Some(Arc::new(OnlineMode::generate(
|
||||
props.prevent_proxy_connections,
|
||||
)?))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -651,6 +657,7 @@ 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.native_lifecycle_open_started();
|
||||
self.load_world(WorldRequest::new(
|
||||
&folder,
|
||||
Some(&level_name),
|
||||
@@ -676,7 +683,8 @@ impl Game<'_> {
|
||||
}
|
||||
self.settings.render_distance = props.view_distance.clamp(2, 32);
|
||||
self.settings.simulation_distance = props.simulation_distance.clamp(2, 32);
|
||||
self.streaming.radius = self.settings.render_distance;
|
||||
// Nothing is kept around the spawn while nobody is on.
|
||||
self.streaming.radius = -1;
|
||||
self.settle_spawn()?;
|
||||
let gate_lists = lists.clone();
|
||||
let gate: LoginGate = Arc::new(move |address, uuid, name| {
|
||||
@@ -767,6 +775,8 @@ impl Game<'_> {
|
||||
self.sync_ops(&lists);
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
host.hub.spawn_protection = props.spawn_protection;
|
||||
host.hub.chat_spam_threshold = props.chat_spam_threshold_seconds.wrapping_mul(20);
|
||||
host.hub.command_spam_threshold = props.command_spam_threshold_seconds.wrapping_mul(20);
|
||||
}
|
||||
log(
|
||||
"INFO",
|
||||
@@ -781,6 +791,8 @@ impl Game<'_> {
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
host.hub.entity_range_percent = props.entity_broadcast_range_percentage;
|
||||
}
|
||||
// The dedicated server's open is complete before its first tick.
|
||||
self.native_lifecycle_world_ready();
|
||||
let mut state = ServerState {
|
||||
lists,
|
||||
console,
|
||||
@@ -848,10 +860,39 @@ impl Game<'_> {
|
||||
self.stop_dedicated()
|
||||
}
|
||||
|
||||
/// A dedicated server of its own, not single player's server thread
|
||||
/// (which runs with `options.dedicated` too).
|
||||
pub(super) fn standalone_server(&self) -> bool {
|
||||
self.options.dedicated.is_some() && self.serving.is_none()
|
||||
}
|
||||
/// Load the spawn area and find the world spawn as single player does,
|
||||
/// without keeping a player there.
|
||||
/// without keeping a player there. A dedicated server only finds the world
|
||||
/// spawn (MinecraftServer.setInitialSpawn): a player's spawn is found as
|
||||
/// one joins.
|
||||
pub(super) fn settle_spawn(&mut self) -> Result<()> {
|
||||
let deadline = Instant::now() + Duration::from_secs(600);
|
||||
if self.standalone_server() {
|
||||
if self.level.spawn.is_none() {
|
||||
let [x, y, z] = loop {
|
||||
self.update_world();
|
||||
if let Some(pos) = self.find_world_spawn()? {
|
||||
break pos;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
return Err("the spawn area did not load".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
};
|
||||
self.level.spawn = Some([f64::from(x), f64::from(y), f64::from(z)]);
|
||||
self.level.world_spawn.get_or_insert([x, y, z]);
|
||||
self.streaming.extra.clear();
|
||||
}
|
||||
self.spawn = self
|
||||
.level
|
||||
.spawn
|
||||
.map(|s| horizon_core::geometry::Vec3::new(s[0] + 0.5, s[1], s[2] + 0.5));
|
||||
return Ok(());
|
||||
}
|
||||
while self.player.is_none() {
|
||||
self.update_world();
|
||||
self.spawn_player()?;
|
||||
@@ -864,24 +905,28 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The chunks to keep loaded: the players' (lan_update_streaming), or
|
||||
/// the spawn chunk while nobody is online.
|
||||
/// The chunks to keep loaded: the players' (lan_update_streaming). While
|
||||
/// nobody is online a dedicated server keeps none (vanilla has no spawn
|
||||
/// ticket), an integrated one the spawn chunk's.
|
||||
pub(super) fn update_dedicated_streaming(&mut self) {
|
||||
let center = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| h.hub.here(g))
|
||||
.map(|g| g.chunk())
|
||||
})
|
||||
.or_else(|| {
|
||||
self.spawn
|
||||
.map(|s| ((s.x.floor() as i32) >> 4, (s.z.floor() as i32) >> 4))
|
||||
});
|
||||
if let Some(c) = center {
|
||||
let guest = self.lan.as_ref().and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| h.hub.here(g))
|
||||
.map(|g| g.chunk())
|
||||
});
|
||||
if self.standalone_server() {
|
||||
self.streaming.radius = if guest.is_some() {
|
||||
self.settings.render_distance
|
||||
} else {
|
||||
-1
|
||||
};
|
||||
}
|
||||
let spawn = self
|
||||
.spawn
|
||||
.map(|s| ((s.x.floor() as i32) >> 4, (s.z.floor() as i32) >> 4));
|
||||
if let Some(c) = guest.or(spawn) {
|
||||
self.streaming.center = c;
|
||||
}
|
||||
}
|
||||
@@ -1452,6 +1497,8 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
fn stop_dedicated(&mut self) -> Result<()> {
|
||||
// The close request starts before player and world saves.
|
||||
self.native_lifecycle.begin_close();
|
||||
log("INFO", "Stopping server");
|
||||
log("INFO", "Saving players");
|
||||
if let Some(h) = self.lan.as_mut() {
|
||||
@@ -1479,6 +1526,8 @@ impl Game<'_> {
|
||||
self.free_player_beds()?;
|
||||
self.save_game()?;
|
||||
self.log_saved_levels();
|
||||
// Only a successful final save closes the epoch; storage is still held.
|
||||
self.native_lifecycle_world_closed();
|
||||
self.lan_close(false);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use horizon_client::{
|
||||
dialog::{AfterAction, Body, Click, Control, Dialog, InputValue, Kind},
|
||||
dialog_screen::{BOX, Layout, Part, WARNING_SPRITES, waiting_button},
|
||||
hud::{HudBatch, rgba},
|
||||
text::Text,
|
||||
text::{ClickEvent, Text},
|
||||
ui,
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
@@ -544,6 +544,7 @@ impl Game<'_> {
|
||||
};
|
||||
let [_, ay, _, ah] = layout.area;
|
||||
let mut action = None;
|
||||
let mut text_click: Option<ClickEvent> = None;
|
||||
open.focus = None;
|
||||
for (part, clipped) in layout.placed(open.scroll.amount) {
|
||||
if clipped && !(cursor.1 >= ay && cursor.1 < ay + ah) {
|
||||
@@ -592,11 +593,39 @@ impl Game<'_> {
|
||||
} if ui::hovered(cursor, x, y, w, h) => {
|
||||
action = Some(Some(index));
|
||||
}
|
||||
// AbstractStringWidget.setComponentClickHandler: the click event
|
||||
// of the styled text under a centred message line.
|
||||
Part::Line(line, x, y)
|
||||
if text_click.is_none()
|
||||
&& cursor.0 >= x
|
||||
&& (0.0..9.0).contains(&(cursor.1 - y)) =>
|
||||
{
|
||||
let content = line.resolve_legacy();
|
||||
let mut left = cursor.0 - x;
|
||||
for (c, style) in content.chars() {
|
||||
left -= self.gui.atlas.font.advance(c, style);
|
||||
if left < 0.0 {
|
||||
text_click = style.click.clone();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let Some(action) = action else {
|
||||
return Ok(());
|
||||
// A text click runs its event (DialogScreen.runAction) with no button sound.
|
||||
let click = match text_click {
|
||||
Some(ClickEvent::OpenUrl(url)) => Click::OpenUrl(url),
|
||||
Some(ClickEvent::ChangePage(page)) => Click::ChangePage(page),
|
||||
Some(ClickEvent::SuggestCommand(command)) => Click::SuggestCommand(command),
|
||||
Some(ClickEvent::RunCommand(command)) => Click::RunCommand(command),
|
||||
Some(ClickEvent::CopyToClipboard(value)) => Click::CopyToClipboard(value),
|
||||
Some(ClickEvent::ShowDialog(dialog)) => Click::ShowDialog(dialog),
|
||||
Some(ClickEvent::OpenFile(_)) | None => return Ok(()),
|
||||
};
|
||||
let after = open.dialog.after_action;
|
||||
return self.run_dialog_action(Some(click), after);
|
||||
};
|
||||
self.audio.play_button_click();
|
||||
let Some(index) = action else {
|
||||
|
||||
Loaded 100 of 324 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user