mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 22:43:47 -10:00
Compare commits
443
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c39b4ed4b8 | ||
|
|
9510efd2fe | ||
|
|
747ccd0b5b | ||
|
|
396ff1e5f9 | ||
|
|
cb93abfe66 | ||
|
|
70ede6a0ef | ||
|
|
7a6dd1ffc9 | ||
|
|
656b7c7c9b | ||
|
|
e0cee924f0 | ||
|
|
061a7dda03 | ||
|
|
baae358894 | ||
|
|
83b29e43ff | ||
|
|
d35bf3ba33 | ||
|
|
6667abcb04 | ||
|
|
9b5e0026f3 | ||
|
|
12eb38e131 | ||
|
|
3c1d4b39c6 | ||
|
|
c5abd34fb1 | ||
|
|
434577c913 | ||
|
|
a55abdfe89 | ||
|
|
5a70e0ba0d | ||
|
|
56dcdc8464 | ||
|
|
2a009ad16f | ||
|
|
37b1f605d5 | ||
|
|
a6cf9d0370 | ||
|
|
68e9da706c | ||
|
|
f2f768bb36 | ||
|
|
dd4f61225b | ||
|
|
1b99614b7b | ||
|
|
2789409634 | ||
|
|
d9cbb0f4e6 | ||
|
|
3bfec3857d | ||
|
|
37aa729526 | ||
|
|
1c6992a28c | ||
|
|
7e902a6886 | ||
|
|
101280753b | ||
|
|
d5c4248f62 | ||
|
|
0580d262da | ||
|
|
d3a5cf3925 | ||
|
|
4bbd8f3e8e | ||
|
|
a71733bfe7 | ||
|
|
79c76034a0 | ||
|
|
1af0acfc37 | ||
|
|
e8897035e4 | ||
|
|
a26ebcb37e | ||
|
|
8fc8f02820 | ||
|
|
d3fd453cfa | ||
|
|
be529ea12d | ||
|
|
3275d82094 | ||
|
|
2dcafe3ee3 | ||
|
|
a87db94c53 | ||
|
|
33d8b6ebeb | ||
|
|
917e58b2da | ||
|
|
c830a170ad | ||
|
|
5eb665b1c3 | ||
|
|
672b204c0a | ||
|
|
a79436fb97 | ||
|
|
3da2b20cc8 | ||
|
|
4b461a9aae | ||
|
|
9990eabc7f | ||
|
|
d9e7cae928 | ||
|
|
dc29fd238b | ||
|
|
393a461720 | ||
|
|
973eeadcfd | ||
|
|
f82149a3dd | ||
|
|
0dc793fdb5 | ||
|
|
9054950e06 | ||
|
|
84ebab8a78 | ||
|
|
b307359409 | ||
|
|
424dc918e7 | ||
|
|
ce12bd5b6d | ||
|
|
0d04976fa1 | ||
|
|
303e418b08 | ||
|
|
403df829f3 | ||
|
|
8e40d11b27 | ||
|
|
e921ddb7c5 | ||
|
|
a4fec395ee | ||
|
|
d8d6dd15aa | ||
|
|
e825d36af8 | ||
|
|
ae19796505 | ||
|
|
ec156a856f | ||
|
|
d49bcab591 | ||
|
|
5f5acece3f | ||
|
|
51119592e2 | ||
|
|
f4a81d27ee | ||
|
|
1f4dba50e3 | ||
|
|
7a09947e46 | ||
|
|
6034bab012 | ||
|
|
1b06b53500 | ||
|
|
6907fe0a79 | ||
|
|
a12b5151e1 | ||
|
|
65b312814b | ||
|
|
771394b76d | ||
|
|
5f06dbbc16 | ||
|
|
ef7ef9959c | ||
|
|
a1f56c2f2e | ||
|
|
c3c2ffc43e | ||
|
|
0ec594d9d3 | ||
|
|
98b6a442cf | ||
|
|
96caf0bf88 | ||
|
|
31192110c6 | ||
|
|
00b1e2c12a | ||
|
|
b0cd391031 | ||
|
|
22c09057fc | ||
|
|
7366ff29b5 | ||
|
|
fd92adb803 | ||
|
|
b914de7e69 | ||
|
|
c5d4217dbb | ||
|
|
3c5dd6b03b | ||
|
|
c4c4b63aba | ||
|
|
56f9a17682 | ||
|
|
35212f09a5 | ||
|
|
e017850f93 | ||
|
|
0d1dccd5ff | ||
|
|
6307b54dea | ||
|
|
51629f9085 | ||
|
|
b74a78c970 | ||
|
|
e694791bb5 | ||
|
|
65030881fc | ||
|
|
ceb5791a69 | ||
|
|
4fa552591a | ||
|
|
699afa8007 | ||
|
|
dc35e78173 | ||
|
|
49db9b276b | ||
|
|
0b95a7a921 | ||
|
|
524071f9cf | ||
|
|
5d3a45f09f | ||
|
|
ab92024350 | ||
|
|
b91f0330f4 | ||
|
|
dc6e548538 | ||
|
|
95f097fd06 | ||
|
|
c6dd5e2205 | ||
|
|
1604f8abee | ||
|
|
f9d0a88d27 | ||
|
|
c739b7af73 | ||
|
|
177aff1240 | ||
|
|
062d66e495 | ||
|
|
aa44f715e6 | ||
|
|
ffe68c936f | ||
|
|
d2fe5c0768 | ||
|
|
f788711fe1 | ||
|
|
c6132e2b9b | ||
|
|
e1a530a71e | ||
|
|
b5b451de7c | ||
|
|
1498477aec | ||
|
|
c527d60b75 | ||
|
|
0a825b42a2 | ||
|
|
75867c69b9 | ||
|
|
475d096fe2 | ||
|
|
c0f09fed88 | ||
|
|
04c7cfc1af | ||
|
|
70436b6f7d | ||
|
|
ecd85c497f | ||
|
|
8bad582185 | ||
|
|
963b7d8bd9 | ||
|
|
ee7d443c8f | ||
|
|
3554efb04f | ||
|
|
94f48bbf6a | ||
|
|
ada4e6567d | ||
|
|
fb9db82d1b | ||
|
|
8922550fdf | ||
|
|
1085b42fc5 | ||
|
|
2e321e385a | ||
|
|
9c471a85ce | ||
|
|
0bb993afd7 | ||
|
|
6f26c1f355 | ||
|
|
2052e55b0b | ||
|
|
8975278e5e | ||
|
|
c4c7df589a | ||
|
|
f387c1d78f | ||
|
|
571e0e5341 | ||
|
|
68b90d860b | ||
|
|
53ba582419 | ||
|
|
c995c9b76e | ||
|
|
ab2ab8ef61 | ||
|
|
9e267fbf9e | ||
|
|
618469513b | ||
|
|
e387764263 | ||
|
|
0e6e9ab098 | ||
|
|
6f3757c8e4 | ||
|
|
49878e9aee | ||
|
|
dd63a5cc5f | ||
|
|
8e83c3eb23 | ||
|
|
48f539e7d3 | ||
|
|
6db9492f5f | ||
|
|
3ad2bda85f | ||
|
|
2817362973 | ||
|
|
4acfc70975 | ||
|
|
d214aa61d7 | ||
|
|
797d6512ea | ||
|
|
3c66c314b9 | ||
|
|
337b0e67fb | ||
|
|
12a7bf8b29 | ||
|
|
3a906e8647 | ||
|
|
d2b451af13 | ||
|
|
5560aadd24 | ||
|
|
795c8e96a0 | ||
|
|
f257d62aa3 | ||
|
|
4bd0bd7dc1 | ||
|
|
b0d000b4b4 | ||
|
|
860a3ec2b2 | ||
|
|
0c65b58a65 | ||
|
|
5ab1a8f543 | ||
|
|
0e282c022c | ||
|
|
dcddbbe841 | ||
|
|
03f7a69993 | ||
|
|
2d4e6ba0be | ||
|
|
cab2754fe1 | ||
|
|
dfb9e41079 | ||
|
|
1374959d6f | ||
|
|
0f1a100391 | ||
|
|
ac6f38c3d6 | ||
|
|
5d47a8557c | ||
|
|
48960bffd6 | ||
|
|
5370ae92cf | ||
|
|
77e8224441 | ||
|
|
c003ada028 | ||
|
|
4daf4be5c0 | ||
|
|
b3f160b674 | ||
|
|
13451e7207 | ||
|
|
00c456d313 | ||
|
|
6c8e7e3e59 | ||
|
|
a71dfda331 | ||
|
|
0d1681d649 | ||
|
|
318ed97be4 | ||
|
|
fde63c0597 | ||
|
|
1803841469 | ||
|
|
660babec5b | ||
|
|
3613a6eb0d | ||
|
|
49544daa24 | ||
|
|
44a07ac4bf | ||
|
|
1beefecb54 | ||
|
|
a2fa7b322d | ||
|
|
e654389d2a | ||
|
|
0ce6c9fe94 | ||
|
|
d57bcbca83 | ||
|
|
521a01282b | ||
|
|
aa1a9b9379 | ||
|
|
176cfef84f | ||
|
|
9d59efaf3f | ||
|
|
d9cbe12a4f | ||
|
|
2b2851a0dc | ||
|
|
1adb5cf92d | ||
|
|
4742c9b3f5 | ||
|
|
1f82ef2116 | ||
|
|
b9e348c2c0 | ||
|
|
f4da935e0f | ||
|
|
066eb31ad5 | ||
|
|
073afd484e | ||
|
|
2bc03baa6b | ||
|
|
3a5ac7ffed | ||
|
|
0a7ad01ed6 | ||
|
|
be0445848d | ||
|
|
f5c79065e9 | ||
|
|
a14cac7f7c | ||
|
|
08918a8a7f | ||
|
|
34118b285f | ||
|
|
b680d71bdc | ||
|
|
1eedd39c76 | ||
|
|
74351c8057 | ||
|
|
8486a32926 | ||
|
|
31300e6846 | ||
|
|
cf6e5c2474 | ||
|
|
deec9041ac | ||
|
|
c822f6eb82 | ||
|
|
e1314bbcc9 | ||
|
|
8d82e986f9 | ||
|
|
d51a2eb268 | ||
|
|
b9f756ebad | ||
|
|
cf38f00b54 | ||
|
|
76ab943b40 | ||
|
|
6f846cf6a5 | ||
|
|
d0fbd3f638 | ||
|
|
a14c9886ce | ||
|
|
07641bb0b2 | ||
|
|
bf29bd9166 | ||
|
|
820913dc4c | ||
|
|
e021395210 | ||
|
|
e311967b29 | ||
|
|
5edf728ee3 | ||
|
|
6e8e088c25 | ||
|
|
c27092a567 | ||
|
|
17b1f8fd19 | ||
|
|
6b3aacf619 | ||
|
|
f2d9c04794 | ||
|
|
19ca1c8b33 | ||
|
|
b781de899d | ||
|
|
af8f2fb7c2 | ||
|
|
04687c99c6 | ||
|
|
b90b7d8aac | ||
|
|
03331b3d52 | ||
|
|
bf4e738618 | ||
|
|
1de9f9fc69 | ||
|
|
2b85acfeaf | ||
|
|
e74907f551 | ||
|
|
85e7517b76 | ||
|
|
faf7d52f20 | ||
|
|
cb2a7fb1b6 | ||
|
|
7857967863 | ||
|
|
4fabf2025c | ||
|
|
3c580814c8 | ||
|
|
876d0003d5 | ||
|
|
72f66bb71c | ||
|
|
f969e62a56 | ||
|
|
aced527885 | ||
|
|
648531c6da | ||
|
|
c08c051a92 | ||
|
|
3b284f173f | ||
|
|
18a900a360 | ||
|
|
a502eccc92 | ||
|
|
8dff50318a | ||
|
|
c8c02381b5 | ||
|
|
c1a5052c43 | ||
|
|
fd855f8815 | ||
|
|
c1db3bad48 | ||
|
|
4cbfa78bee | ||
|
|
a9b932e4c2 | ||
|
|
0c9ac0cfae | ||
|
|
e51783d5bd | ||
|
|
9b47769757 | ||
|
|
b6e7d3e7c0 | ||
|
|
6350e5219d | ||
|
|
97f2c57e5d | ||
|
|
4b32fb8cf7 | ||
|
|
5d7c622895 | ||
|
|
fd41f0e36f | ||
|
|
9845e2cd9b | ||
|
|
b01025353e | ||
|
|
907b6e1dcc | ||
|
|
8fcfb3b8f4 | ||
|
|
2e97b2f5f9 | ||
|
|
8373160c35 | ||
|
|
ddbaa5a64c | ||
|
|
01e9ac5702 | ||
|
|
f429f93420 | ||
|
|
c96733f3c6 | ||
|
|
5459f6e232 | ||
|
|
a02d0c4a1a | ||
|
|
46dfd693f5 | ||
|
|
5a7cfb443f | ||
|
|
b947048ad6 | ||
|
|
511b974107 | ||
|
|
fbac0ae2d9 | ||
|
|
548fca6308 | ||
|
|
60203586ca | ||
|
|
17aab6b601 | ||
|
|
8aff0594b5 | ||
|
|
aa284acb23 | ||
|
|
1167274823 | ||
|
|
7ba8d7ee65 | ||
|
|
6a066d9076 | ||
|
|
6b5428915b | ||
|
|
59a1f51d34 | ||
|
|
6a03966205 | ||
|
|
d3d714e9eb | ||
|
|
b3f866c5a6 | ||
|
|
61066655fe | ||
|
|
3df5f5d5c4 | ||
|
|
c0f16e65e2 | ||
|
|
57eac62cd2 | ||
|
|
18f79e7683 | ||
|
|
0c9468753d | ||
|
|
0887fd0c69 | ||
|
|
ca04a6de62 | ||
|
|
dab1861c21 | ||
|
|
c7cfd91f14 | ||
|
|
8e10fc9924 | ||
|
|
bf103fc383 | ||
|
|
f69bf3deb5 | ||
|
|
393d90d870 | ||
|
|
7255ecd851 | ||
|
|
a1e033003a | ||
|
|
a3aa60d7bc | ||
|
|
0e573b8e25 | ||
|
|
b4181cf43a | ||
|
|
14d7f036c3 | ||
|
|
c89bf6241d | ||
|
|
0271c62825 | ||
|
|
0bebf89220 | ||
|
|
3c2a49c4ef | ||
|
|
1eeda33ae5 | ||
|
|
aba4ee3d56 | ||
|
|
803bff885b | ||
|
|
51b39ee118 | ||
|
|
4459fe3989 | ||
|
|
027aef4b3b | ||
|
|
2de673c4f8 | ||
|
|
912a9febc7 | ||
|
|
558a9a13e9 | ||
|
|
6f42cbb13f | ||
|
|
6ffe36494e | ||
|
|
c1c7cdb3c1 | ||
|
|
8880df58c1 | ||
|
|
6b0d88bf88 | ||
|
|
f83be65d79 | ||
|
|
effec4887d | ||
|
|
73632dae7e | ||
|
|
8ec6e3d9f4 | ||
|
|
a97afdc93f | ||
|
|
557c4c5cf9 | ||
|
|
29363ee3e9 | ||
|
|
7cb03beeed | ||
|
|
7aaa68865f | ||
|
|
723a2a44cb | ||
|
|
d2e26c32d4 | ||
|
|
7df9ed0545 | ||
|
|
2c803ae875 | ||
|
|
baa05a30ec | ||
|
|
94e167b796 | ||
|
|
dc925bf620 | ||
|
|
b916a49fbf | ||
|
|
99791ea225 | ||
|
|
807901034b | ||
|
|
3cb4177026 | ||
|
|
e4b5b0f07f | ||
|
|
df19d46a5a | ||
|
|
d9d0bd3289 | ||
|
|
def1dbef33 | ||
|
|
f57fe836c4 | ||
|
|
0239b3c97f | ||
|
|
27c3c0be31 | ||
|
|
52399eb28a | ||
|
|
f3a3c1291b | ||
|
|
da98be3276 | ||
|
|
967ec25579 | ||
|
|
880eb7350f | ||
|
|
15a22366e3 | ||
|
|
a554d6e7c4 | ||
|
|
e716347329 | ||
|
|
6774e47aae | ||
|
|
e855ded821 | ||
|
|
4264d34f1c | ||
|
|
f4bdc66781 | ||
|
|
e4c1af1fff | ||
|
|
0503ee0cbf | ||
|
|
a91763379f | ||
|
|
a9d14449e8 | ||
|
|
d39715cca0 | ||
|
|
f904f44047 | ||
|
|
26fa54369c | ||
|
|
88e7afbedf | ||
|
|
0e4c964d0a | ||
|
|
2228a7456e |
No files matched your search
@@ -52,6 +52,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-prepare` | Builds `.local/horizon-client/26.3` from the player's own Minecraft install (runs the Java exporters) |
|
||||
| `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-anticheat` | The dedicated server's anticheat, off by default: `[anticheat]` settings, violation levels, the checks' rules; the hub runs them (`lan/anticheat.rs`) ([docs/anticheat.md](docs/anticheat.md)) |
|
||||
| `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`, `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)) |
|
||||
@@ -135,7 +136,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
|
||||
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
|
||||
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
|
||||
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
|
||||
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` |
|
||||
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server`, `horizon-anticheat` (L: `lan/anticheat.rs`) |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
|
||||
Generated
+12
@@ -1074,6 +1074,14 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-anticheat"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-assets"
|
||||
version = "0.1.0"
|
||||
@@ -1111,6 +1119,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"gilrs",
|
||||
"glam",
|
||||
"horizon-anticheat",
|
||||
"horizon-assets",
|
||||
"horizon-bench",
|
||||
"horizon-client-core",
|
||||
@@ -1175,6 +1184,7 @@ name = "horizon-client-render"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"base64",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"cpal",
|
||||
@@ -1203,6 +1213,7 @@ dependencies = [
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"symphonia",
|
||||
"unicode-bidi",
|
||||
@@ -1350,6 +1361,7 @@ name = "horizon-server"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"horizon-anticheat",
|
||||
"horizon-bench",
|
||||
"horizon-release-sign",
|
||||
"horizon-server-files",
|
||||
|
||||
@@ -13,6 +13,7 @@ members = [
|
||||
"crates/horizon-command",
|
||||
"crates/horizon-items",
|
||||
"crates/horizon-server-files",
|
||||
"crates/horizon-anticheat",
|
||||
"crates/horizon-server",
|
||||
"crates/horizon-bots",
|
||||
"crates/horizon-assets",
|
||||
|
||||
@@ -19,3 +19,42 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1
|
||||
[Pixelarticons](https://github.com/halfmage/pixelarticons) by Gerrit Halfmann, under the MIT
|
||||
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on
|
||||
the same grid.
|
||||
|
||||
## AMD FidelityFX SDK (FSR 3.1 frame generation)
|
||||
|
||||
`crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl` is a port to WGSL of the
|
||||
optical flow shaders of the [AMD FidelityFX SDK](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK)
|
||||
1.1.4 (`sdk/include/FidelityFX/gpu/opticalflow`, at commit
|
||||
[c6efa6bf7f20](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK/tree/c6efa6bf7f2027b3ec94f28578bb5965eabb9e55)),
|
||||
and `optical_flow.rs` follows the SDK's `sdk/src/components/opticalflow/ffx_opticalflow.cpp`
|
||||
(its passes, their order and their dispatch sizes). Copyright (C) 2024 Advanced Micro Devices,
|
||||
Inc., under the MIT license, whose text heads both files. AMD and FidelityFX are AMD's
|
||||
trademarks; Horizon is not affiliated with AMD.
|
||||
|
||||
## Mod loader logos
|
||||
|
||||
The launcher shows the logo of the mod loader an instance plays with.
|
||||
|
||||
- `launcher/assets/logos/quilt.png` is the Quilt mini icon (`brand/128png/quilt_mini_icon_transparent.png`)
|
||||
from [QuiltMC/art](https://github.com/QuiltMC/art), by the Quilt Project, dedicated to the public
|
||||
domain under [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/).
|
||||
- `launcher/assets/logos/neoforge.png` is NeoForged's icon (`art/neoforged_icon_16x16.png`) from
|
||||
[neoforged/.github](https://github.com/neoforged/.github/tree/main/art), © the NeoForged team,
|
||||
created by Ridanisaurus, under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). It is
|
||||
unmodified (the launcher scales it up by whole pixels).
|
||||
- Fabric's and Forge's icons are not shipped: neither publishes a license for its logo. The launcher
|
||||
fetches them from their own sites when an instance needs them
|
||||
([fabric-loader's icon](https://github.com/FabricMC/fabric-loader/blob/master/src/main/resources/assets/fabricloader/icon.png),
|
||||
[files.minecraftforge.net's](https://files.minecraftforge.net/)).
|
||||
|
||||
Each logo belongs to its project. Showing it says which loader an instance runs; it does not mean
|
||||
the project endorses Horizon.
|
||||
|
||||
## Sign in with Microsoft
|
||||
|
||||
The launcher's sign-in buttons follow Microsoft's published branding guidelines for apps that sign in
|
||||
with Microsoft accounts
|
||||
([Sign in with Microsoft branding](https://learn.microsoft.com/en-us/entra/identity-platform/howto-add-branding-in-apps)).
|
||||
The four-square Microsoft symbol in `launcher/src/microsoft.rs` reproduces the squares, positions and
|
||||
colours of Microsoft's official `ms-symbollockup_mssymbol_19.svg` from that page, unmodified. Microsoft
|
||||
and its logo are trademarks of the Microsoft group of companies; Horizon is not endorsed by Microsoft.
|
||||
@@ -13,7 +13,9 @@ const SITE: &str = "https://horizon-rust.github.io/horizon-mods/";
|
||||
const USAGE: &str = "usage: cargo horizon-mod registry build <checkout> --files <dir> --out <index.json> [--release <url>] [--site <url>] [--generated <time>]
|
||||
cargo horizon-mod registry review <base checkout> <head checkout> --author <login> [--json]
|
||||
cargo horizon-mod registry check <base checkout> <head checkout> --files <dir>
|
||||
cargo horizon-mod registry list <checkout>";
|
||||
cargo horizon-mod registry list <checkout>
|
||||
cargo horizon-mod registry release <checkout> <id> <file> --tag <tag> --published <YYYY-MM-DD> [--notes <file>] [--signature <file>] [--game <version>]
|
||||
cargo horizon-mod registry page <checkout> <id> <horizon-registry.toml>";
|
||||
|
||||
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
|
||||
super::flag(args, name)
|
||||
@@ -154,6 +156,74 @@ pub fn main(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
// A release of a project's repository, downloaded: the checks of a submission, then the
|
||||
// version as the index lists it (JSON on stdout), for the registry's release sync.
|
||||
Some("release") => {
|
||||
let [checkout, id, file] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let (tag, published) = (
|
||||
flag(rest, "--tag").ok_or(USAGE)?,
|
||||
flag(rest, "--published").ok_or(USAGE)?,
|
||||
);
|
||||
let read = |name: &str| {
|
||||
flag(rest, name)
|
||||
.map(|p| std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}")))
|
||||
.transpose()
|
||||
};
|
||||
let (notes, signature) = (read("--notes")?, read("--signature")?);
|
||||
let entry = entry_of(checkout, id)?;
|
||||
let path = Path::new(file);
|
||||
let cache = registry::Cache::read(Path::new(checkout), id)?;
|
||||
let entry = entry.resolved(&cache.page);
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
crate::check::check(
|
||||
path,
|
||||
entry.kind.clone(),
|
||||
entry.key.as_deref(),
|
||||
signature.as_deref(),
|
||||
)?;
|
||||
let version = registry::release_version(
|
||||
&entry,
|
||||
®istry::Release {
|
||||
tag,
|
||||
published,
|
||||
notes: notes.as_deref().unwrap_or(""),
|
||||
asset: flag(rest, "--name").unwrap_or(name),
|
||||
game: flag(rest, "--game").unwrap_or("26.3"),
|
||||
signature: signature.as_deref(),
|
||||
},
|
||||
path,
|
||||
)?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&version).map_err(|e| e.to_string())?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
// The page file of a project's repository, checked against its entry: the page as JSON.
|
||||
Some("page") => {
|
||||
let [checkout, id, file] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let entry = entry_of(checkout, id)?;
|
||||
let text = std::fs::read_to_string(file).map_err(|e| format!("{file}: {e}"))?;
|
||||
let page = registry::page(&entry, &text)?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&page).map_err(|e| e.to_string())?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(USAGE.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `mods/<id>.toml` of the checkout, which must name a repository.
|
||||
fn entry_of(checkout: &str, id: &str) -> Result<registry::Entry, String> {
|
||||
let (entries, _) = registry::load(Path::new(checkout)).map_err(lines)?;
|
||||
entries
|
||||
.into_iter()
|
||||
.find(|e| e.id == id && e.repo.is_some())
|
||||
.ok_or_else(|| format!("{id}: no entry with a repo"))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "horizon-anticheat"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.85"
|
||||
description = "The dedicated server's anticheat: its [anticheat] settings, violation levels and the checks' rules, with no game state (docs/anticheat.md)"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "1.1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,303 @@
|
||||
//! The dedicated server's anticheat (docs/anticheat.md), without the game: the `[anticheat]` section of
|
||||
//! `server.toml`, each player's violation levels, and the rules of the checks. The server's hub
|
||||
//! (`horizon_client::lan`) feeds it what players send and carries out what it decides.
|
||||
//!
|
||||
//! It is off unless the owner turns it on. A check that fails *flags* the player: its violation level
|
||||
//! (VL) rises by the flag's weight and falls back by `decay` a second. From `setback` on, the move or
|
||||
//! action is cancelled (the player is sent back where the server last had them); from `alert` on,
|
||||
//! online operators and the console are told; from `kick` on, the player is disconnected. Nothing bans.
|
||||
//! Any threshold at 0 turns that action off.
|
||||
//!
|
||||
//! The checks are written from vanilla 26.3's own rules and from what cheats are publicly described to
|
||||
//! do, never from another anticheat's code.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod timer;
|
||||
|
||||
/// What one check's violation level leads to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Actions {
|
||||
pub setback: f32,
|
||||
pub alert: f32,
|
||||
pub kick: f32,
|
||||
/// Violation level lost each second.
|
||||
pub decay: f32,
|
||||
}
|
||||
|
||||
macro_rules! checks {
|
||||
($(
|
||||
$(#[$doc:meta])*
|
||||
$field:ident: $Check:ident => $Config:ident [$setback:expr, $alert:expr, $kick:expr, $decay:expr] {
|
||||
$( $(#[$xdoc:meta])* $extra:ident: $ty:ty = $default:expr, )*
|
||||
}
|
||||
)*) => {
|
||||
/// Every check, in the order of the players' violation levels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Check { $($Check,)* }
|
||||
|
||||
impl Check {
|
||||
pub const ALL: &[Check] = &[$(Check::$Check,)*];
|
||||
/// Its name in `server.toml` and in alerts.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self { $(Check::$Check => stringify!($field),)* }
|
||||
}
|
||||
}
|
||||
|
||||
$(
|
||||
$(#[$doc])*
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct $Config {
|
||||
pub enabled: bool,
|
||||
pub setback: f32,
|
||||
pub alert: f32,
|
||||
pub kick: f32,
|
||||
pub decay: f32,
|
||||
$( $(#[$xdoc])* pub $extra: $ty, )*
|
||||
}
|
||||
|
||||
impl Default for $Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
setback: $setback,
|
||||
alert: $alert,
|
||||
kick: $kick,
|
||||
decay: $decay,
|
||||
$($extra: $default,)*
|
||||
}
|
||||
}
|
||||
}
|
||||
)*
|
||||
|
||||
/// `server.toml`'s `[anticheat]`: off by default.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
pub enabled: bool,
|
||||
/// Tell online operators of an alert in their chat (the console and the log always are).
|
||||
pub alerts: bool,
|
||||
/// The permission level a player needs to see alerts.
|
||||
pub alert_level: u8,
|
||||
/// Every flag, one line each, relative to the server's folder; empty: no file.
|
||||
pub log: String,
|
||||
$(pub $field: $Config,)*
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
alerts: true,
|
||||
alert_level: 2,
|
||||
log: "logs/anticheat.log".into(),
|
||||
$($field: $Config::default(),)*
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Whether `check` runs: the anticheat and the check both on.
|
||||
pub fn runs(&self, check: Check) -> bool {
|
||||
self.enabled && match check { $(Check::$Check => self.$field.enabled,)* }
|
||||
}
|
||||
|
||||
pub fn actions(&self, check: Check) -> Actions {
|
||||
match check {
|
||||
$(Check::$Check => {
|
||||
let c = &self.$field;
|
||||
Actions { setback: c.setback, alert: c.alert, kick: c.kick, decay: c.decay }
|
||||
})*
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
checks! {
|
||||
/// Timer: more client ticks than the server's clock allows (a sped-up game). Vanilla's client ends
|
||||
/// every tick with ServerboundClientTickEndPacket, at the server's tick rate; each one spends a tick's
|
||||
/// time from a balance that wall-clock time fills, capped so a pause can't be saved up for a burst.
|
||||
timer: Timer => TimerConfig [2.0, 5.0, 20.0, 0.02] {
|
||||
/// How far ahead of the clock, in milliseconds, a client may get before it is flagged.
|
||||
tolerance_ms: f64 = 300.0,
|
||||
/// The most time, in milliseconds, a quiet connection banks for the ticks that follow (a lag
|
||||
/// spike's backlog arrives at once).
|
||||
max_lag_ms: f64 = 1000.0,
|
||||
}
|
||||
|
||||
/// Ground: a move that says it ends on the ground with nothing there to stand on: no-fall (claiming
|
||||
/// the ground to skip fall damage) and walking on water. The claim is always dropped, so vanilla's
|
||||
/// fall damage follows; from `setback` on the player is also sent back.
|
||||
ground: Ground => GroundConfig [5.0, 15.0, 60.0, 2.0] {}
|
||||
}
|
||||
|
||||
/// How many checks there are.
|
||||
pub const CHECKS: usize = Check::ALL.len();
|
||||
|
||||
impl Config {
|
||||
/// The `[anticheat]` section of a whole `server.toml`; the default (off) when it has none.
|
||||
pub fn from_server_toml(text: &str) -> Result<Self, String> {
|
||||
#[derive(Deserialize)]
|
||||
struct File {
|
||||
#[serde(default)]
|
||||
anticheat: Config,
|
||||
}
|
||||
// Other sections are the supervisor's to check: only this one is read here.
|
||||
let mut table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
|
||||
let mut only = toml::Table::new();
|
||||
if let Some(section) = table.remove("anticheat") {
|
||||
only.insert("anticheat".into(), section);
|
||||
}
|
||||
let file: File = only
|
||||
.try_into()
|
||||
.map_err(|e: toml::de::Error| format!("[anticheat]: {e}"))?;
|
||||
file.anticheat.check()?;
|
||||
Ok(file.anticheat)
|
||||
}
|
||||
|
||||
/// Thresholds can't be negative.
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
for &c in Check::ALL {
|
||||
let a = self.actions(c);
|
||||
if [a.setback, a.alert, a.kick, a.decay]
|
||||
.iter()
|
||||
.any(|v| !v.is_finite() || *v < 0.0)
|
||||
{
|
||||
return Err(format!(
|
||||
"anticheat.{}: setback, alert, kick and decay are numbers from 0 up",
|
||||
c.name()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// What a flag leads to.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Verdict {
|
||||
/// The check's violation level after the flag.
|
||||
pub vl: f32,
|
||||
/// Cancel the move or action.
|
||||
pub setback: bool,
|
||||
/// Tell operators and the console (at most once a second per check).
|
||||
pub alert: bool,
|
||||
pub kick: bool,
|
||||
}
|
||||
|
||||
/// One player's anticheat state. Clean players cost nothing: levels only change when a check flags.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Player {
|
||||
vl: [f32; CHECKS],
|
||||
/// The server tick of each check's last flag (its decay runs from there).
|
||||
flagged: [u64; CHECKS],
|
||||
/// The server tick of each check's last alert.
|
||||
alerted: [Option<u64>; CHECKS],
|
||||
pub timer: timer::Balance,
|
||||
/// The server tick and height where the ground last held the player.
|
||||
pub ground: Option<(u64, f64)>,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
/// The check's violation level at `tick`, with its decay since the last flag.
|
||||
pub fn vl(&self, config: &Config, check: Check, tick: u64) -> f32 {
|
||||
let i = check as usize;
|
||||
let seconds = tick.saturating_sub(self.flagged[i]) as f32 / 20.0;
|
||||
(self.vl[i] - config.actions(check).decay * seconds).max(0.0)
|
||||
}
|
||||
|
||||
/// `check` failed with this weight at server tick `tick`.
|
||||
pub fn flag(&mut self, config: &Config, check: Check, weight: f32, tick: u64) -> Verdict {
|
||||
let i = check as usize;
|
||||
let a = config.actions(check);
|
||||
let vl = self.vl(config, check, tick) + weight;
|
||||
self.vl[i] = vl;
|
||||
self.flagged[i] = tick;
|
||||
let reached = |threshold: f32| threshold > 0.0 && vl >= threshold;
|
||||
let alert = reached(a.alert) && self.alerted[i].is_none_or(|t| tick >= t + 20);
|
||||
if alert {
|
||||
self.alerted[i] = Some(tick);
|
||||
}
|
||||
Verdict {
|
||||
vl,
|
||||
setback: reached(a.setback),
|
||||
alert,
|
||||
kick: reached(a.kick),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn on() -> Config {
|
||||
Config {
|
||||
enabled: true,
|
||||
..Config::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_by_default_and_without_a_section() {
|
||||
assert!(!Config::default().enabled);
|
||||
let c = Config::from_server_toml("[server]\nport = 25565\n").unwrap();
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(!c.runs(Check::Timer));
|
||||
assert!(!c.runs(Check::Ground));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_its_section() {
|
||||
let text = "[server]\nport = 1\n[anticheat]\nenabled = true\n[anticheat.timer]\nkick = 0\ntolerance_ms = 500\n";
|
||||
let c = Config::from_server_toml(text).unwrap();
|
||||
assert!(c.runs(Check::Timer));
|
||||
assert_eq!(c.timer.kick, 0.0);
|
||||
assert_eq!(c.timer.tolerance_ms, 500.0);
|
||||
assert_eq!(c.timer.alert, TimerConfig::default().alert);
|
||||
let off = "[anticheat]\nenabled = true\n[anticheat.timer]\nenabled = false\n";
|
||||
assert!(!Config::from_server_toml(off).unwrap().runs(Check::Timer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_unknown_keys_and_negative_levels() {
|
||||
assert!(Config::from_server_toml("[anticheat]\nenable = true\n").is_err());
|
||||
assert!(Config::from_server_toml("[anticheat.timer]\nkick = -1\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_rise_decay_and_act_in_order() {
|
||||
let mut c = on();
|
||||
c.timer = TimerConfig {
|
||||
setback: 2.0,
|
||||
alert: 3.0,
|
||||
kick: 5.0,
|
||||
decay: 1.0,
|
||||
..TimerConfig::default()
|
||||
};
|
||||
let mut p = Player::default();
|
||||
let v = p.flag(&c, Check::Timer, 1.0, 100);
|
||||
assert_eq!((v.setback, v.alert, v.kick), (false, false, false));
|
||||
let v = p.flag(&c, Check::Timer, 1.0, 100);
|
||||
assert_eq!((v.vl, v.setback, v.alert), (2.0, true, false));
|
||||
let v = p.flag(&c, Check::Timer, 1.0, 100);
|
||||
assert!(v.alert && !v.kick);
|
||||
// At most one alert a second.
|
||||
assert!(!p.flag(&c, Check::Timer, 0.5, 110).alert);
|
||||
// 3 at tick 110 (half a level gone, half added); two seconds later two more are gone.
|
||||
assert_eq!(p.vl(&c, Check::Timer, 150), 1.0);
|
||||
assert_eq!(p.vl(&c, Check::Timer, 10_000), 0.0);
|
||||
let v = p.flag(&c, Check::Timer, 5.0, 10_000);
|
||||
assert!(v.kick && v.alert);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_turns_an_action_off() {
|
||||
let mut c = on();
|
||||
c.timer.kick = 0.0;
|
||||
let mut p = Player::default();
|
||||
assert!(!p.flag(&c, Check::Timer, 1000.0, 1).kick);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! The timer check's balance: how far a client's ticks are behind (positive) or ahead (negative) of the
|
||||
//! server's clock, in milliseconds.
|
||||
//!
|
||||
//! The server adds the wall-clock time of each of its ticks, and every ServerboundClientTickEndPacket
|
||||
//! takes away the length of one tick at the server's tick rate (vanilla's client ticks at that rate,
|
||||
//! `TickRateManager.millisecondsPerTick`). The balance is capped at `max_lag_ms` *after* the tick's
|
||||
//! packets are counted: a slow server tick that handles a second of packets at once also added that
|
||||
//! second, and a quiet connection banks at most `max_lag_ms` for the burst that ends a lag spike.
|
||||
|
||||
use crate::TimerConfig;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Balance {
|
||||
pub ms: f64,
|
||||
}
|
||||
|
||||
impl Balance {
|
||||
/// The start of a server tick that came `elapsed_ms` after the previous one.
|
||||
pub fn elapse(&mut self, elapsed_ms: f64, config: &TimerConfig) {
|
||||
self.ms = self.ms.min(config.max_lag_ms) + elapsed_ms;
|
||||
}
|
||||
|
||||
/// One client tick ended, at a tick rate of `tick_rate` a second. Some(how far ahead, in ms) when the
|
||||
/// client is further ahead than the tolerance; the balance starts again from zero then, so a
|
||||
/// sustained speed-up is flagged again each time it gets that far ahead.
|
||||
pub fn tick_end(&mut self, tick_rate: f32, config: &TimerConfig) -> Option<f64> {
|
||||
self.ms -= 1000.0 / f64::from(tick_rate.max(1.0));
|
||||
if self.ms < -config.tolerance_ms {
|
||||
let ahead = -self.ms;
|
||||
self.ms = 0.0;
|
||||
return Some(ahead);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The tick rate changed or the player arrived: start again.
|
||||
pub fn reset(&mut self) {
|
||||
self.ms = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `seconds` of server ticks of `server_ms` each, with `per_tick` client ticks handled in each.
|
||||
fn run(b: &mut Balance, server_ms: f64, per_tick: f64, seconds: f64) -> usize {
|
||||
let c = TimerConfig::default();
|
||||
let mut owed = 0.0;
|
||||
let mut flags = 0;
|
||||
for _ in 0..(seconds * 1000.0 / server_ms) as usize {
|
||||
b.elapse(server_ms, &c);
|
||||
owed += per_tick;
|
||||
while owed >= 1.0 {
|
||||
owed -= 1.0;
|
||||
flags += usize::from(b.tick_end(20.0, &c).is_some());
|
||||
}
|
||||
}
|
||||
flags
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vanilla_client_is_never_flagged() {
|
||||
let mut b = Balance::default();
|
||||
assert_eq!(run(&mut b, 50.0, 1.0, 600.0), 0);
|
||||
// A server that runs at 10 ticks a second handles two client ticks in each.
|
||||
assert_eq!(run(&mut b, 100.0, 2.0, 600.0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slow_server_tick_with_its_backlog_is_not_flagged() {
|
||||
let (mut b, c) = (Balance::default(), TimerConfig::default());
|
||||
b.elapse(3000.0, &c);
|
||||
for _ in 0..60 {
|
||||
assert_eq!(b.tick_end(20.0, &c), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_network_stall_is_not_flagged() {
|
||||
let (mut b, c) = (Balance::default(), TimerConfig::default());
|
||||
// 800 ms with nothing arriving, then the 16 ticks sent meanwhile.
|
||||
for _ in 0..16 {
|
||||
b.elapse(50.0, &c);
|
||||
}
|
||||
b.elapse(50.0, &c);
|
||||
for _ in 0..17 {
|
||||
assert_eq!(b.tick_end(20.0, &c), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sped_up_client_is_flagged_again_and_again() {
|
||||
let mut b = Balance::default();
|
||||
// 10 % faster: 5 ms ahead each tick, 300 ms after 3 s, so close to 20 flags a minute.
|
||||
let flags = run(&mut b, 50.0, 1.1, 60.0);
|
||||
assert!((15..=20).contains(&flags), "{flags}");
|
||||
// Twice as fast: a flag every 7 ticks.
|
||||
assert!(run(&mut Balance::default(), 50.0, 2.0, 10.0) >= 25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pause_cant_be_saved_for_a_burst() {
|
||||
let (mut b, c) = (Balance::default(), TimerConfig::default());
|
||||
for _ in 0..200 {
|
||||
b.elapse(50.0, &c);
|
||||
}
|
||||
let flagged = (0..60).any(|_| b.tick_end(20.0, &c).is_some());
|
||||
assert!(flagged);
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,10 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
--explore-speed B blocks a second an explore bot flies; default 22 (as fast as chunks come)
|
||||
--name PREFIX bot names: PREFIX0, PREFIX1...; default Bot
|
||||
--brand B the client brand sent in configuration (minecraft:brand); default none
|
||||
--height H blocks above spawn the bots fly at (walk, explore, idle); default 24
|
||||
--ground claim the ground in every move (with --height 0 on a flat world: walking
|
||||
on it; higher, or over water: a no-fall or jesus cheat, to test an anticheat)
|
||||
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
|
||||
--report FILE a JSON summary at the end (- prints it after the per-second lines)
|
||||
";
|
||||
|
||||
@@ -129,10 +133,14 @@ struct Options {
|
||||
attack_after: f64,
|
||||
flood: usize,
|
||||
no_read: bool,
|
||||
height: f64,
|
||||
ground: bool,
|
||||
name: String,
|
||||
/// The `minecraft:brand` to send, if any.
|
||||
brand: Option<String>,
|
||||
report: Option<String>,
|
||||
/// How many times as fast as vanilla's 20 Hz the bots tick (a timer cheat above 1).
|
||||
timer: f64,
|
||||
}
|
||||
|
||||
fn options() -> Result<Options, String> {
|
||||
@@ -150,9 +158,12 @@ fn options() -> Result<Options, String> {
|
||||
attack_after: 5.0,
|
||||
flood: 100,
|
||||
no_read: false,
|
||||
height: 24.0,
|
||||
ground: false,
|
||||
name: "Bot".into(),
|
||||
report: None,
|
||||
brand: None,
|
||||
timer: 1.0,
|
||||
};
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
@@ -164,6 +175,10 @@ fn options() -> Result<Options, String> {
|
||||
o.no_read = true;
|
||||
continue;
|
||||
}
|
||||
if arg == "--ground" {
|
||||
o.ground = 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() {
|
||||
@@ -187,6 +202,13 @@ fn options() -> Result<Options, String> {
|
||||
"--name" => o.name = value,
|
||||
"--report" => o.report = Some(value),
|
||||
"--brand" => o.brand = Some(value),
|
||||
"--height" => o.height = value.parse().map_err(|_| number("blocks"))?,
|
||||
"--timer" => {
|
||||
o.timer = value.parse().map_err(|_| number("a speed, like 1.5"))?;
|
||||
if !(o.timer >= 0.1 && o.timer <= 20.0) {
|
||||
return Err(format!("--timer {value}: use 0.1 to 20"));
|
||||
}
|
||||
}
|
||||
_ => return Err(format!("unknown option {arg}; --help lists them")),
|
||||
}
|
||||
}
|
||||
@@ -715,7 +737,7 @@ fn run_bot(
|
||||
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;
|
||||
let cruise = spawn[1] + o.height;
|
||||
let heading = random.unit() * std::f64::consts::TAU;
|
||||
let mut target = pos;
|
||||
let mut next = Instant::now();
|
||||
@@ -847,7 +869,7 @@ fn run_bot(
|
||||
x: pos[0],
|
||||
y: pos[1],
|
||||
z: pos[2],
|
||||
flags: u8::from(behaviour == Behaviour::Stand),
|
||||
flags: u8::from(behaviour == Behaviour::Stand || o.ground),
|
||||
})
|
||||
.encode(),
|
||||
) && send(raw(sb::CLIENT_TICK_END, |_| {}));
|
||||
@@ -855,7 +877,7 @@ fn run_bot(
|
||||
bot.online.store(false, Ordering::SeqCst);
|
||||
break;
|
||||
}
|
||||
next += Duration::from_millis(50);
|
||||
next += Duration::from_secs_f64(0.05 / o.timer);
|
||||
let now = Instant::now();
|
||||
if next > now {
|
||||
thread::sleep(next - now);
|
||||
|
||||
@@ -1,32 +1,68 @@
|
||||
//! Key display names (`InputConstants.Key.getDisplayName`), shared by key
|
||||
//! mappings and `keybind` text components.
|
||||
use crate::text::Text;
|
||||
use std::{collections::BTreeMap, sync::RwLock};
|
||||
|
||||
/// InputConstants.Key.getDisplayName: one-character keys as the upper-case
|
||||
/// character, others by translation.
|
||||
/// The keys that write a symbol, with what they write on a US layout.
|
||||
const SYMBOLS: [(&str, &str); 11] = [
|
||||
("key.keyboard.minus", "-"),
|
||||
("key.keyboard.equal", "="),
|
||||
("key.keyboard.left.bracket", "["),
|
||||
("key.keyboard.right.bracket", "]"),
|
||||
("key.keyboard.backslash", "\\"),
|
||||
("key.keyboard.semicolon", ";"),
|
||||
("key.keyboard.apostrophe", "'"),
|
||||
("key.keyboard.grave.accent", "`"),
|
||||
("key.keyboard.comma", ","),
|
||||
("key.keyboard.period", "."),
|
||||
("key.keyboard.slash", "/"),
|
||||
];
|
||||
|
||||
/// What the player's keyboard layout writes on each key that writes
|
||||
/// something, by key name, as the window reported it (`learn_layout`).
|
||||
static LAYOUT: RwLock<BTreeMap<String, String>> = RwLock::new(BTreeMap::new());
|
||||
|
||||
/// The keyboard layout writes `text` on the key `key` (`key.keyboard.q`):
|
||||
/// its name from now on, as `SDL_GetKeyFromScancode` names it with SDL's
|
||||
/// default keycode options. Only letter and symbol keys take it; a letter
|
||||
/// key keeps its Latin letter on a non-Latin layout (`latin_letters`), and
|
||||
/// the number row keeps its digits (`french_numbers`).
|
||||
pub fn learn_layout(key: &str, text: &str) {
|
||||
let Some(rest) = key.strip_prefix("key.keyboard.") else {
|
||||
return;
|
||||
};
|
||||
let mut chars = text.chars();
|
||||
let (Some(c), None) = (chars.next(), chars.next()) else {
|
||||
return;
|
||||
};
|
||||
let letter_key = rest.len() == 1 && rest.as_bytes()[0].is_ascii_lowercase();
|
||||
let latin = c.is_ascii() || ('\u{C0}'..='\u{24F}').contains(&c);
|
||||
if c.is_control()
|
||||
|| c.is_whitespace()
|
||||
|| !(letter_key || SYMBOLS.iter().any(|(k, _)| *k == key))
|
||||
|| (letter_key && c.is_alphabetic() && !latin)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Ok(mut layout) = LAYOUT.write() {
|
||||
layout.insert(key.to_owned(), c.to_uppercase().collect());
|
||||
}
|
||||
}
|
||||
|
||||
/// InputConstants.Key.getDisplayName: what the layout writes on the key,
|
||||
/// upper-case, else the US layout's character, else by translation.
|
||||
pub fn display_name(key: &str) -> Text {
|
||||
if let Some(label) = LAYOUT.read().ok().and_then(|l| l.get(key).cloned()) {
|
||||
return Text::literal(label);
|
||||
}
|
||||
if let Some(rest) = key.strip_prefix("key.keyboard.")
|
||||
&& rest.chars().count() == 1
|
||||
{
|
||||
let c = rest.to_uppercase();
|
||||
return Text::literal(c);
|
||||
}
|
||||
let symbol = match key {
|
||||
"key.keyboard.minus" => Some("-"),
|
||||
"key.keyboard.equal" => Some("="),
|
||||
"key.keyboard.left.bracket" => Some("["),
|
||||
"key.keyboard.right.bracket" => Some("]"),
|
||||
"key.keyboard.backslash" => Some("\\"),
|
||||
"key.keyboard.semicolon" => Some(";"),
|
||||
"key.keyboard.apostrophe" => Some("'"),
|
||||
"key.keyboard.grave.accent" => Some("`"),
|
||||
"key.keyboard.comma" => Some(","),
|
||||
"key.keyboard.period" => Some("."),
|
||||
"key.keyboard.slash" => Some("/"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(s) = symbol {
|
||||
return Text::literal(s);
|
||||
if let Some((_, s)) = SYMBOLS.iter().find(|(k, _)| *k == key) {
|
||||
return Text::literal(*s);
|
||||
}
|
||||
if let Some(n) = key.strip_prefix("key.mouse.")
|
||||
&& n.parse::<u32>().is_ok()
|
||||
@@ -36,3 +72,36 @@ pub fn display_name(key: &str) -> Text {
|
||||
}
|
||||
Text::translate(key, &[])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keys_show_what_the_layout_writes() {
|
||||
// The layout table is shared: these keys are this test's alone.
|
||||
assert_eq!(display_name("key.keyboard.q").plain(), "Q");
|
||||
// AZERTY: A on the Q key, M on the semicolon key, a dead ^ on the
|
||||
// left bracket, a comma on the M key.
|
||||
learn_layout("key.keyboard.q", "a");
|
||||
learn_layout("key.keyboard.semicolon", "m");
|
||||
learn_layout("key.keyboard.left.bracket", "^");
|
||||
learn_layout("key.keyboard.m", ",");
|
||||
assert_eq!(display_name("key.keyboard.q").plain(), "A");
|
||||
assert_eq!(display_name("key.keyboard.semicolon").plain(), "M");
|
||||
assert_eq!(display_name("key.keyboard.left.bracket").plain(), "^");
|
||||
assert_eq!(display_name("key.keyboard.m").plain(), ",");
|
||||
// The number row keeps its digits; a Cyrillic letter, nothing
|
||||
// printable and keys that write nothing are left alone.
|
||||
learn_layout("key.keyboard.1", "&");
|
||||
learn_layout("key.keyboard.f", "а");
|
||||
learn_layout("key.keyboard.g", "\u{8}");
|
||||
learn_layout("key.keyboard.tab", "\t");
|
||||
learn_layout("key.keyboard.h", "ab");
|
||||
assert_eq!(display_name("key.keyboard.1").plain(), "1");
|
||||
assert_eq!(display_name("key.keyboard.f").plain(), "F");
|
||||
assert_eq!(display_name("key.keyboard.g").plain(), "G");
|
||||
assert_eq!(display_name("key.keyboard.h").plain(), "H");
|
||||
assert_eq!(display_name("key.keyboard.slash").plain(), "/");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
//! Item-use and sneak multipliers come from the player's current state; this
|
||||
//! module does not implement sprint eligibility, poses or the input lifecycle.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use winit::keyboard::{KeyCode, ModifiersState};
|
||||
|
||||
fn length(v: [f32; 2]) -> f32 {
|
||||
f64::from(v[0] * v[0] + v[1] * v[1]).sqrt() as f32
|
||||
}
|
||||
@@ -65,3 +68,45 @@ fn square_movement(v: [f32; 2]) -> [f32; 2] {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// `InputQuirks.isQuitShortcutDown`: checks whether the platform's window-closing
|
||||
/// keyboard shortcut is held.
|
||||
/// - On macOS: Command (`super_key()`) + `Q`.
|
||||
/// - On Windows / Linux / other: Alt (`alt_key()`) + `F4`.
|
||||
pub fn is_quit_shortcut_down(modifiers: ModifiersState, keys: &HashSet<KeyCode>) -> bool {
|
||||
if cfg!(target_os = "macos") {
|
||||
modifiers.super_key() && keys.contains(&KeyCode::KeyQ)
|
||||
} else {
|
||||
modifiers.alt_key() && keys.contains(&KeyCode::F4)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quit_shortcut_follows_platform_modifiers_and_keys() {
|
||||
let mut keys = HashSet::new();
|
||||
let alt = ModifiersState::ALT;
|
||||
let super_key = ModifiersState::SUPER;
|
||||
|
||||
assert!(!is_quit_shortcut_down(ModifiersState::empty(), &keys));
|
||||
|
||||
keys.insert(KeyCode::F4);
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(!is_quit_shortcut_down(alt, &keys));
|
||||
keys.remove(&KeyCode::F4);
|
||||
keys.insert(KeyCode::KeyQ);
|
||||
assert!(is_quit_shortcut_down(super_key, &keys));
|
||||
assert!(!is_quit_shortcut_down(alt, &keys));
|
||||
} else {
|
||||
assert!(is_quit_shortcut_down(alt, &keys));
|
||||
assert!(!is_quit_shortcut_down(super_key, &keys));
|
||||
keys.remove(&KeyCode::F4);
|
||||
keys.insert(KeyCode::KeyQ);
|
||||
assert!(!is_quit_shortcut_down(alt, &keys));
|
||||
assert!(!is_quit_shortcut_down(super_key, &keys));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -284,6 +284,26 @@ pub fn mouse_button(name: &str) -> Option<MouseButton> {
|
||||
}
|
||||
pub use horizon_client_core::key_display::display_name;
|
||||
|
||||
/// A key event: what the layout writes on its key, without modifiers, names
|
||||
/// that key from now on (`key_display::learn_layout`), so an AZERTY player's
|
||||
/// Q key shows A.
|
||||
pub fn learn_layout(event: &winit::event::KeyEvent) {
|
||||
use winit::keyboard::{Key, PhysicalKey};
|
||||
use winit::platform::modifier_supplement::KeyEventExtModifierSupplement;
|
||||
let PhysicalKey::Code(code) = event.physical_key else {
|
||||
return;
|
||||
};
|
||||
let Some(name) = key_name(code) else {
|
||||
return;
|
||||
};
|
||||
let learn = horizon_client_core::key_display::learn_layout;
|
||||
match event.key_without_modifiers() {
|
||||
Key::Character(text) => learn(name, &text),
|
||||
Key::Dead(Some(c)) => learn(name, c.encode_utf8(&mut [0; 4])),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Current bindings by mapping name.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct KeyBinds {
|
||||
|
||||
@@ -342,6 +342,28 @@ pub fn mouse_position_after_grab_change(
|
||||
}
|
||||
}
|
||||
|
||||
/// `MacosUtil.setCtrlClickEmulatesRightClick`: with the option on (and only on
|
||||
/// macOS), a left click made with Control held is a right click, and so is
|
||||
/// its release, whatever Control does in between.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct RightClickEmulation {
|
||||
emulating: bool,
|
||||
}
|
||||
impl RightClickEmulation {
|
||||
/// Whether the left button's `pressed` or released event is a right click.
|
||||
/// `enabled` is the option on macOS.
|
||||
pub fn is_right_click(&mut self, pressed: bool, control: bool, enabled: bool) -> bool {
|
||||
if pressed {
|
||||
self.emulating = enabled && control;
|
||||
}
|
||||
let right = self.emulating;
|
||||
if !pressed {
|
||||
self.emulating = false;
|
||||
}
|
||||
right
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -526,4 +548,21 @@ mod tests {
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_click_is_a_right_click_until_released() {
|
||||
let mut e = RightClickEmulation::default();
|
||||
// The option off, or no Control: a left click stays one.
|
||||
assert!(!e.is_right_click(true, true, false));
|
||||
assert!(!e.is_right_click(false, true, false));
|
||||
assert!(!e.is_right_click(true, false, true));
|
||||
assert!(!e.is_right_click(false, true, true));
|
||||
// Control held at the press: the press and the release are right clicks,
|
||||
// even when Control was let go before the release.
|
||||
assert!(e.is_right_click(true, true, true));
|
||||
assert!(e.is_right_click(false, false, true));
|
||||
// The next left click is its own.
|
||||
assert!(!e.is_right_click(true, false, true));
|
||||
assert!(!e.is_right_click(false, false, true));
|
||||
}
|
||||
}
|
||||
@@ -284,12 +284,18 @@ impl FramerateLimiter {
|
||||
/// (`Window.updateFullscreenState` -> `Minecraft.fullscreenStateChanged`), and
|
||||
/// the window is only asked to change when the option itself changes
|
||||
/// (`Window.updateFullscreenIfChanged`), never re-forced to the option.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FullscreenState {
|
||||
/// The state the window system last reported (`Window.fullscreen`).
|
||||
actual: bool,
|
||||
/// The state last asked of the window system, or adopted from it.
|
||||
requested: bool,
|
||||
/// `Window.exclusiveFullscreen`.
|
||||
exclusive: bool,
|
||||
/// `Window.preferredFullscreenVideoMode`.
|
||||
mode: Option<VideoMode>,
|
||||
/// `Window.dirty`: the preferred mode changed and the screen has not been left yet.
|
||||
mode_dirty: bool,
|
||||
}
|
||||
|
||||
impl FullscreenState {
|
||||
@@ -298,9 +304,70 @@ impl FullscreenState {
|
||||
Self {
|
||||
actual: false,
|
||||
requested,
|
||||
exclusive: false,
|
||||
mode: None,
|
||||
mode_dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `Window.updateFullscreenIfChanged` with the exclusive option and preferred mode: asks the
|
||||
/// window to enter, leave or change its fullscreen only when one of them changed.
|
||||
/// `in_video_screen` holds a changed mode back until the Video Settings screen closes
|
||||
/// (`changeFullscreenVideoMode`).
|
||||
pub fn apply(
|
||||
&mut self,
|
||||
window: &winit::window::Window,
|
||||
wanted: bool,
|
||||
exclusive: bool,
|
||||
preferred: Option<VideoMode>,
|
||||
in_video_screen: bool,
|
||||
) {
|
||||
let request = self.request(wanted);
|
||||
let reapply = self.set_exclusive(exclusive);
|
||||
self.set_preferred_mode(preferred);
|
||||
let mode_change = !in_video_screen && self.take_mode_change();
|
||||
match request {
|
||||
Some(false) => window.set_fullscreen(None),
|
||||
Some(true) => window.set_fullscreen(Some(fullscreen_request(
|
||||
window,
|
||||
exclusive,
|
||||
preferred.as_ref(),
|
||||
))),
|
||||
None if reapply || mode_change => window.set_fullscreen(Some(fullscreen_request(
|
||||
window,
|
||||
exclusive,
|
||||
preferred.as_ref(),
|
||||
))),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Window.setExclusiveFullscreen`: true when the option changed while fullscreen is
|
||||
/// requested, so the window has to enter the other kind of fullscreen at once.
|
||||
pub fn set_exclusive(&mut self, exclusive: bool) -> bool {
|
||||
let changed = self.exclusive != exclusive;
|
||||
self.exclusive = exclusive;
|
||||
changed && self.requested
|
||||
}
|
||||
|
||||
/// `Window.setPreferredFullscreenVideoMode`: a changed mode waits for `take_mode_change`.
|
||||
pub fn set_preferred_mode(&mut self, mode: Option<VideoMode>) {
|
||||
if self.mode != mode {
|
||||
self.mode = mode;
|
||||
self.mode_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// `Window.changeFullscreenVideoMode` (the Video Settings screen closing): true once when
|
||||
/// a changed mode has to be applied to a fullscreen window.
|
||||
pub fn take_mode_change(&mut self) -> bool {
|
||||
let apply = self.requested && self.mode_dirty;
|
||||
if apply {
|
||||
self.mode_dirty = false;
|
||||
}
|
||||
apply
|
||||
}
|
||||
|
||||
/// `Window.updateFullscreenIfChanged`: the state to ask of the window
|
||||
/// when the option `wanted` differs from what was last asked or adopted.
|
||||
pub fn request(&mut self, wanted: bool) -> Option<bool> {
|
||||
@@ -321,6 +388,132 @@ impl FullscreenState {
|
||||
}
|
||||
}
|
||||
|
||||
/// `VideoMode`: one display mode of a monitor, as `fullscreenResolution` in options.txt keeps it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct VideoMode {
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub red_bits: i32,
|
||||
pub green_bits: i32,
|
||||
pub blue_bits: i32,
|
||||
pub refresh_rate: f32,
|
||||
}
|
||||
|
||||
impl VideoMode {
|
||||
/// `VideoMode.read`: the JSON object `write` makes; any other string is None.
|
||||
pub fn read(text: &str) -> Option<Self> {
|
||||
let number = |key: &str| -> Option<f64> {
|
||||
let at = text.find(&format!("\"{key}\""))? + key.len() + 2;
|
||||
let rest = text[at..].trim_start().strip_prefix(':')?.trim_start();
|
||||
let end = rest
|
||||
.find(|c: char| !(c.is_ascii_digit() || matches!(c, '-' | '+' | '.' | 'e' | 'E')))
|
||||
.unwrap_or(rest.len());
|
||||
rest[..end].parse().ok()
|
||||
};
|
||||
if !text.trim_start().starts_with('{') {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
width: number("width")? as i32,
|
||||
height: number("height")? as i32,
|
||||
red_bits: number("red_bits")? as i32,
|
||||
green_bits: number("green_bits")? as i32,
|
||||
blue_bits: number("blue_bits")? as i32,
|
||||
refresh_rate: number("refresh_rate")? as f32,
|
||||
})
|
||||
}
|
||||
|
||||
/// `VideoMode.write`: the Codec's JSON through Gson (a float prints as `60.0`).
|
||||
pub fn write(&self) -> String {
|
||||
let rate = if self.refresh_rate == self.refresh_rate.trunc() {
|
||||
format!("{:.1}", self.refresh_rate)
|
||||
} else {
|
||||
self.refresh_rate.to_string()
|
||||
};
|
||||
format!(
|
||||
"{{\"width\":{},\"height\":{},\"red_bits\":{},\"green_bits\":{},\"blue_bits\":{},\"refresh_rate\":{rate}}}",
|
||||
self.width, self.height, self.red_bits, self.green_bits, self.blue_bits
|
||||
)
|
||||
}
|
||||
|
||||
/// `VideoMode.refreshRateLabel`: a whole rate without decimals, any other with two.
|
||||
pub fn refresh_rate_label(&self) -> String {
|
||||
if self.refresh_rate == self.refresh_rate.round_ties_even() {
|
||||
(self.refresh_rate as i32).to_string()
|
||||
} else {
|
||||
format!("{:.2}", self.refresh_rate)
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour depth `options.fullscreen.entry` shows.
|
||||
pub fn bits(&self) -> i32 {
|
||||
self.red_bits + self.green_bits + self.blue_bits
|
||||
}
|
||||
|
||||
/// A winit mode: its depth is the total, split evenly over the channels.
|
||||
pub fn from_winit(mode: &winit::monitor::VideoModeHandle) -> Self {
|
||||
let depth = i32::from(mode.bit_depth());
|
||||
let size = mode.size();
|
||||
Self {
|
||||
width: size.width as i32,
|
||||
height: size.height as i32,
|
||||
red_bits: depth / 3,
|
||||
green_bits: depth / 3,
|
||||
blue_bits: depth - 2 * (depth / 3),
|
||||
refresh_rate: mode.refresh_rate_millihertz() as f32 / 1000.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Monitor.videoModes`: the modes a monitor offers.
|
||||
pub fn monitor_video_modes(monitor: &winit::monitor::MonitorHandle) -> Vec<VideoMode> {
|
||||
monitor
|
||||
.video_modes()
|
||||
.map(|m| VideoMode::from_winit(&m))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `Monitor.currentMode`: the monitor's desktop mode; winit has no such query, so the mode of
|
||||
/// the monitor's own size with the highest refresh rate and depth.
|
||||
fn monitor_current_mode(
|
||||
monitor: &winit::monitor::MonitorHandle,
|
||||
) -> Option<winit::monitor::VideoModeHandle> {
|
||||
let size = monitor.size();
|
||||
monitor
|
||||
.video_modes()
|
||||
.filter(|m| m.size() == size)
|
||||
.max_by_key(|m| (m.refresh_rate_millihertz(), m.bit_depth()))
|
||||
}
|
||||
|
||||
/// `Window.applyFullscreen`: the fullscreen the window asks for. Exclusive fullscreen takes
|
||||
/// `Monitor.getPreferredVideoMode` (the preferred mode if the monitor has it, else the desktop
|
||||
/// mode); a monitor with no usable mode falls back to borderless, as vanilla does.
|
||||
pub fn fullscreen_request(
|
||||
window: &winit::window::Window,
|
||||
exclusive: bool,
|
||||
preferred: Option<&VideoMode>,
|
||||
) -> winit::window::Fullscreen {
|
||||
use winit::window::Fullscreen;
|
||||
if !exclusive {
|
||||
return Fullscreen::Borderless(None);
|
||||
}
|
||||
let Some(monitor) = window
|
||||
.current_monitor()
|
||||
.or_else(|| window.primary_monitor())
|
||||
else {
|
||||
return Fullscreen::Borderless(None);
|
||||
};
|
||||
let wanted = preferred.and_then(|p| {
|
||||
monitor
|
||||
.video_modes()
|
||||
.find(|m| VideoMode::from_winit(m) == *p)
|
||||
});
|
||||
match wanted.or_else(|| monitor_current_mode(&monitor)) {
|
||||
Some(mode) => Fullscreen::Exclusive(mode),
|
||||
None => Fullscreen::Borderless(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's icon for the window's title bar and the task bar (X11 and Windows; Wayland takes
|
||||
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
|
||||
pub fn icon() -> Option<winit::window::Icon> {
|
||||
@@ -340,11 +533,63 @@ pub fn icon() -> Option<winit::window::Icon> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn video_mode_round_trips_the_options_json() {
|
||||
let mode = VideoMode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
red_bits: 8,
|
||||
green_bits: 8,
|
||||
blue_bits: 8,
|
||||
refresh_rate: 60.0,
|
||||
};
|
||||
let text = mode.write();
|
||||
assert_eq!(
|
||||
text,
|
||||
r#"{"width":1920,"height":1080,"red_bits":8,"green_bits":8,"blue_bits":8,"refresh_rate":60.0}"#
|
||||
);
|
||||
assert_eq!(VideoMode::read(&text), Some(mode));
|
||||
assert_eq!(VideoMode::read("1920x1080"), None);
|
||||
assert_eq!(VideoMode::read(r#"{"width":1}"#), None);
|
||||
}
|
||||
#[test]
|
||||
fn refresh_rate_label_is_whole_or_two_decimals() {
|
||||
let mut mode = VideoMode::read(
|
||||
r#"{"width":1,"height":1,"red_bits":8,"green_bits":8,"blue_bits":8,"refresh_rate":144.0}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(mode.refresh_rate_label(), "144");
|
||||
mode.refresh_rate = 59.94;
|
||||
assert_eq!(mode.refresh_rate_label(), "59.94");
|
||||
assert_eq!(mode.bits(), 24);
|
||||
}
|
||||
#[test]
|
||||
fn the_window_icon_decodes() {
|
||||
assert!(icon().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclusive_fullscreen_reapplies_only_while_fullscreen_is_requested() {
|
||||
let mut f = FullscreenState::new(false);
|
||||
assert!(!f.set_exclusive(true));
|
||||
assert!(!f.set_exclusive(true));
|
||||
assert_eq!(f.request(true), Some(true));
|
||||
assert!(f.set_exclusive(false));
|
||||
assert!(!f.set_exclusive(false));
|
||||
}
|
||||
#[test]
|
||||
fn a_changed_video_mode_applies_once_when_fullscreen() {
|
||||
let mode = VideoMode::read(
|
||||
r#"{"width":1,"height":1,"red_bits":8,"green_bits":8,"blue_bits":8,"refresh_rate":60.0}"#,
|
||||
);
|
||||
let mut f = FullscreenState::new(false);
|
||||
f.set_preferred_mode(mode);
|
||||
assert!(!f.take_mode_change());
|
||||
f.request(true);
|
||||
f.set_preferred_mode(mode);
|
||||
assert!(f.take_mode_change());
|
||||
assert!(!f.take_mode_change());
|
||||
}
|
||||
#[test]
|
||||
fn fullscreen_follows_the_option_only_when_it_changes() {
|
||||
let mut f = FullscreenState::new(false);
|
||||
|
||||
@@ -23,6 +23,7 @@ horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
ab_glyph = "0.2.32"
|
||||
base64 = "0.22"
|
||||
bytemuck = { version = "1.24", features = ["derive"] }
|
||||
# Sound (ALSA on Linux); the `audio` feature.
|
||||
cpal = { version = "0.16", optional = true }
|
||||
@@ -36,6 +37,7 @@ png = "0.18"
|
||||
pollster = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10.9"
|
||||
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
|
||||
unicode-bidi = "0.3.18"
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
//! Which states tick is a table by state id, built by the game from the block
|
||||
//! registry; whether a slot is made is decided when the slot is promoted at
|
||||
//! the start of the next tick, from the block state then.
|
||||
use crate::chunk::block_entity::BlockEntity;
|
||||
use crate::chunk::block_entity::{BlockEntity, ModContainer};
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -70,6 +70,9 @@ pub struct BlockEntities {
|
||||
replaced: FxHashSet<Pos>,
|
||||
/// `Ticking` by block state id; empty until the game builds it.
|
||||
states: Vec<Ticking>,
|
||||
/// The containers of mod blocks' block entities, by block state id;
|
||||
/// built with `states`.
|
||||
mod_containers: FxHashMap<u32, Arc<ModContainer>>,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for BlockEntities {
|
||||
@@ -152,6 +155,13 @@ impl BlockEntities {
|
||||
pub fn ticking(&self, state: u32) -> Ticking {
|
||||
self.states.get(state as usize).copied().unwrap_or_default()
|
||||
}
|
||||
pub fn set_mod_containers(&mut self, containers: FxHashMap<u32, Arc<ModContainer>>) {
|
||||
self.mod_containers = containers;
|
||||
}
|
||||
/// The container of a mod block's block entity in block state `state`.
|
||||
pub fn mod_container(&self, state: u32) -> Option<&Arc<ModContainer>> {
|
||||
self.mod_containers.get(&state)
|
||||
}
|
||||
|
||||
fn next(&mut self) -> u32 {
|
||||
let n = self.next_slot;
|
||||
|
||||
@@ -627,6 +627,34 @@ pub fn perspective(
|
||||
m[11] = -1.0;
|
||||
m
|
||||
}
|
||||
/// `Frustum.getNearPlaneBounds`: the box around the four corners of the
|
||||
/// culling frustum's NDC `z = -1` plane, relative to the camera (the caller
|
||||
/// moves it to the eye). The culling projection is `Camera.update`'s
|
||||
/// `setPerspective(fov, aspect, 0.05, depthFar, zZeroToOne)`.
|
||||
pub fn near_plane_bounds(
|
||||
fov_degrees: f32,
|
||||
width: f32,
|
||||
height: f32,
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
far: f32,
|
||||
) -> ([f64; 3], [f64; 3]) {
|
||||
let projection =
|
||||
glam::Mat4::from_cols_array(&perspective(fov_degrees, width, height, 0.05, far, true));
|
||||
// Frustum.calculateFrustum: projection * modelView.
|
||||
let clip_to_world = (projection * view_matrix(yaw, pitch)).inverse();
|
||||
let mut min = [f64::INFINITY; 3];
|
||||
let mut max = [f64::NEG_INFINITY; 3];
|
||||
for (x, y) in [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)] {
|
||||
let p = clip_to_world * glam::Vec4::new(x, y, -1.0, 1.0);
|
||||
let p = [p.x / p.w, p.y / p.w, p.z / p.w];
|
||||
for k in 0..3 {
|
||||
min[k] = min[k].min(f64::from(p[k]));
|
||||
max[k] = max[k].max(f64::from(p[k]));
|
||||
}
|
||||
}
|
||||
(min, max)
|
||||
}
|
||||
/// `GameRenderer.render3dHud`: the first-person hand and held items are
|
||||
/// drawn with their own projection at `hudFov` (70 degrees, changed only by
|
||||
/// death and fluids), never the FOV option or the sprint, speed and bow
|
||||
@@ -834,6 +862,19 @@ pub fn spinning_effect_matrix(intensity: f32, angle_degrees: f32) -> glam::Mat4
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn near_plane_bounds_sit_half_a_near_distance_ahead_of_the_eye() {
|
||||
// Facing +Z (yaw 0): the zero-to-one culling projection puts NDC
|
||||
// z = -1 at -far * near / (2 far - near) in front of the camera.
|
||||
let far = 512.0;
|
||||
let (min, max) = super::near_plane_bounds(70.0, 1600.0, 900.0, 0.0, 0.0, far);
|
||||
let depth = f64::from(far * 0.05 / (2.0 * far - 0.05));
|
||||
assert!((min[2] - depth).abs() < 1e-4 && (max[2] - depth).abs() < 1e-4);
|
||||
let half_height = depth * 35.0f64.to_radians().tan();
|
||||
assert!((max[1] - half_height).abs() < 1e-4 && (min[1] + half_height).abs() < 1e-4);
|
||||
let half_width = half_height * 1600.0 / 900.0;
|
||||
assert!((max[0] - half_width).abs() < 1e-3 && (min[0] + half_width).abs() < 1e-3);
|
||||
}
|
||||
/// `moveCloak` trails a quarter of the way, jumps from 10 blocks away; the
|
||||
/// cape leans back as the player walks forward (south, body yaw 0).
|
||||
#[test]
|
||||
|
||||
@@ -80,9 +80,63 @@ pub enum BlockEntity {
|
||||
Shelf(Box<Shelf>),
|
||||
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
|
||||
ChiseledBookshelf(Box<ChiseledBookshelf>),
|
||||
/// A block entity of a mod (`native_block_entity`), kept as its saved
|
||||
/// compound for the mod to read and change.
|
||||
Mod(Box<ModBlockEntity>),
|
||||
/// `PotentSulfurBlockEntity` (`game/potent_sulfur.rs`).
|
||||
PotentSulfur(PotentSulfur),
|
||||
/// `StructureBlockEntity` (`structure_block.rs`).
|
||||
StructureBlock(Box<crate::structure_block::StructureBlock>),
|
||||
/// `JigsawBlockEntity` (`jigsaw_block.rs`).
|
||||
JigsawBlock(Box<crate::jigsaw_block::JigsawBlock>),
|
||||
/// `TestBlockEntity` (`test_block.rs`).
|
||||
TestBlock(Box<crate::test_block::TestBlock>),
|
||||
/// `TestInstanceBlockEntity` (`test_instance_block.rs`).
|
||||
TestInstanceBlock(Box<crate::test_instance_block::TestInstanceBlock>),
|
||||
}
|
||||
/// A mod's block entity: its type id (`mymod:machine`), its saved
|
||||
/// compound without `id`, `x`, `y`, `z` and `Items`, its item slots
|
||||
/// (`Items`) when its block has a container (`ModContainer`), and the data
|
||||
/// slots its menu shows (`ContainerData`: a furnace menu's flame and arrow),
|
||||
/// which the mod sets and nothing saves.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ModBlockEntity {
|
||||
pub id: String,
|
||||
pub nbt: Compound,
|
||||
pub items: Vec<Option<ItemStack>>,
|
||||
pub data: Vec<i32>,
|
||||
}
|
||||
/// The container of a mod block's block entity, as a `WorldlyContainer`
|
||||
/// says it: its slot count, the slots each face reaches
|
||||
/// (`getSlotsForFace`, by `Direction`: down, up, north, south, west, east),
|
||||
/// and the slots hoppers may put into (`canPlaceItemThroughFace`) and take
|
||||
/// from (`canTakeItemThroughFace`); and the vanilla menu that shows its
|
||||
/// slots when the block is used, if any (`MOD_MENUS`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ModContainer {
|
||||
pub slots: usize,
|
||||
pub faces: [Vec<usize>; 6],
|
||||
pub inputs: Vec<usize>,
|
||||
pub outputs: Vec<usize>,
|
||||
pub menu: Option<String>,
|
||||
}
|
||||
/// The vanilla menus a mod container can open, with the slot count each
|
||||
/// shows: slot grids, and the furnaces' (input, fuel, result), whose four
|
||||
/// data slots draw the flame (`data[0]` of `data[1]`) and the arrow
|
||||
/// (`data[2]` of `data[3]`), as `AbstractFurnaceBlockEntity.dataAccess`.
|
||||
pub const MOD_MENUS: [(&str, usize); 11] = [
|
||||
("minecraft:furnace", 3),
|
||||
("minecraft:blast_furnace", 3),
|
||||
("minecraft:smoker", 3),
|
||||
("minecraft:hopper", 5),
|
||||
("minecraft:generic_3x3", 9),
|
||||
("minecraft:generic_9x1", 9),
|
||||
("minecraft:generic_9x2", 18),
|
||||
("minecraft:generic_9x3", 27),
|
||||
("minecraft:generic_9x4", 36),
|
||||
("minecraft:generic_9x5", 45),
|
||||
("minecraft:generic_9x6", 54),
|
||||
];
|
||||
/// `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.
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
//! 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, PotentSulfur, Shelf, Skull, SkullSkin,
|
||||
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity, ModContainer,
|
||||
PotentSulfur, Shelf, Skull, SkullSkin,
|
||||
};
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
@@ -306,6 +307,27 @@ impl BlockEntity {
|
||||
if name == "minecraft:chiseled_bookshelf" {
|
||||
return Some(Self::ChiseledBookshelf(Box::default()));
|
||||
}
|
||||
if name == "minecraft:structure_block" {
|
||||
// The entity's mode is the state's (`StructureBlock.MODE`); the block's
|
||||
// state is not known here, so a new one starts in its default, SAVE.
|
||||
return Some(Self::StructureBlock(Box::new(
|
||||
crate::structure_block::StructureBlock::new(
|
||||
crate::structure_block::StructureMode::Save,
|
||||
),
|
||||
)));
|
||||
}
|
||||
if name == "minecraft:test_block" {
|
||||
// The mode is the state's, which is not known here.
|
||||
return Some(Self::TestBlock(Box::new(
|
||||
crate::test_block::TestBlock::new(crate::test_block::TestBlockMode::Fail),
|
||||
)));
|
||||
}
|
||||
if name == "minecraft:test_instance_block" {
|
||||
return Some(Self::TestInstanceBlock(Box::default()));
|
||||
}
|
||||
if name == "minecraft:jigsaw" {
|
||||
return Some(Self::JigsawBlock(Box::default()));
|
||||
}
|
||||
if name == "minecraft:potent_sulfur" {
|
||||
return Some(Self::PotentSulfur(PotentSulfur::default()));
|
||||
}
|
||||
@@ -319,7 +341,7 @@ impl BlockEntity {
|
||||
};
|
||||
Some(Self::Chest(vec![None; size]))
|
||||
}
|
||||
pub fn id(&self) -> &'static str {
|
||||
pub fn id(&self) -> &str {
|
||||
match self {
|
||||
Self::Chest(items) => match items.len() {
|
||||
HOPPER_SLOTS => "minecraft:hopper",
|
||||
@@ -352,7 +374,12 @@ impl BlockEntity {
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
|
||||
Self::Shelf(_) => "minecraft:shelf",
|
||||
Self::Mod(m) => &m.id,
|
||||
Self::PotentSulfur(_) => "minecraft:potent_sulfur",
|
||||
Self::StructureBlock(_) => "minecraft:structure_block",
|
||||
Self::JigsawBlock(_) => "minecraft:jigsaw",
|
||||
Self::TestBlock(_) => "minecraft:test_block",
|
||||
Self::TestInstanceBlock(_) => "minecraft:test_instance_block",
|
||||
}
|
||||
}
|
||||
pub fn contents(&self) -> Vec<ItemStack> {
|
||||
@@ -372,8 +399,13 @@ impl BlockEntity {
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::PotentSulfur(_)
|
||||
| Self::StructureBlock(_)
|
||||
| Self::JigsawBlock(_)
|
||||
| Self::TestBlock(_)
|
||||
| Self::TestInstanceBlock(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => Vec::new(),
|
||||
Self::Mod(m) => m.items.iter().flatten().cloned().collect(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
Self::Sculk(_) => Vec::new(),
|
||||
Self::Banner(_) => Vec::new(),
|
||||
@@ -397,11 +429,16 @@ impl BlockEntity {
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::PotentSulfur(_)
|
||||
| Self::StructureBlock(_)
|
||||
| Self::JigsawBlock(_)
|
||||
| Self::TestBlock(_)
|
||||
| Self::TestInstanceBlock(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => &[],
|
||||
Self::Crafter(c) => &c.items,
|
||||
Self::ChiseledBookshelf(b) => &b.items,
|
||||
Self::Shelf(b) => &b.items,
|
||||
Self::Mod(m) => &m.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
@@ -426,11 +463,16 @@ impl BlockEntity {
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::PotentSulfur(_)
|
||||
| Self::StructureBlock(_)
|
||||
| Self::JigsawBlock(_)
|
||||
| Self::TestBlock(_)
|
||||
| Self::TestInstanceBlock(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => &mut [],
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
Self::ChiseledBookshelf(b) => &mut b.items,
|
||||
Self::Shelf(b) => &mut b.items,
|
||||
Self::Mod(m) => &mut m.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
|
||||
@@ -496,8 +496,24 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `name` is one of the streaming pool's threads, vanilla's `Util` worker threads.
|
||||
fn is_worker_thread(name: Option<&str>) -> bool {
|
||||
name.is_some_and(|n| n.starts_with("horizon-worker-"))
|
||||
}
|
||||
|
||||
/// `Util.onThreadException`: a panic on a worker thread is made a report titled "Exception on
|
||||
/// worker thread", with the thread's name, as `BlockableEventLoop.relayDelayCrash` hands it to
|
||||
/// the main thread.
|
||||
pub fn worker_panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Report {
|
||||
Report {
|
||||
description: "Exception on worker thread".into(),
|
||||
..panic_report(info, side)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports panics: on the main thread a crash report (the panic then ends the game, exit code
|
||||
/// 101); on other threads the error goes to the log, as vanilla's uncaught exception handler
|
||||
/// 101); on a worker thread (`Util.onThreadException`) a crash report too, and the game ends
|
||||
/// with it; on other threads the error goes to the log, as vanilla's uncaught exception handler
|
||||
/// does. The default hook still prints the panic. A panic inside `contain` is only logged.
|
||||
pub fn install(home: PathBuf, side: &'static str) {
|
||||
let default = std::panic::take_hook();
|
||||
@@ -509,6 +525,10 @@ pub fn install(home: PathBuf, side: &'static str) {
|
||||
let main = std::thread::current().name() == Some("main");
|
||||
if main {
|
||||
deliver(&home, panic_report(info, side));
|
||||
} else if is_worker_thread(std::thread::current().name()) {
|
||||
deliver(&home, worker_panic_report(info, side));
|
||||
// The crash ends the game, as the relayed report does on vanilla's main thread.
|
||||
std::process::exit(101);
|
||||
} else {
|
||||
log::error!(
|
||||
"Caught previously unhandled exception in thread {}: {}",
|
||||
@@ -734,6 +754,13 @@ mod tests {
|
||||
assert!(first.contains("crash.rs"), "{}", debug.stack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_threads_are_the_streaming_pool() {
|
||||
assert!(is_worker_thread(Some("horizon-worker-3")));
|
||||
assert!(!is_worker_thread(Some("main")));
|
||||
assert!(!is_worker_thread(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_becomes_a_report() {
|
||||
// A real panic through a hook, as the game's would be.
|
||||
|
||||
@@ -618,6 +618,37 @@ pub fn enabled_ids(world: &Path, packs: &[DataPack]) -> Vec<String> {
|
||||
enabled
|
||||
}
|
||||
|
||||
/// The level stems of `packs` (`data/<namespace>/dimension/<path>.json`, lowest priority first,
|
||||
/// as `enabled_ids` orders them): the id `namespace:path` and the parsed JSON, a later pack's
|
||||
/// file for an id replacing an earlier one's. This is `context.datapackDimensions()` of
|
||||
/// `WorldOpenFlows.loadWorldStem`.
|
||||
pub fn dimension_stems(packs: &[&DataPack]) -> BTreeMap<String, serde_json::Value> {
|
||||
let mut out = BTreeMap::new();
|
||||
for pack in packs {
|
||||
for file in pack.list_kinds(&["dimension"]) {
|
||||
let Some(rest) = file.strip_prefix("data/") else {
|
||||
continue;
|
||||
};
|
||||
let Some((namespace, rest)) = rest.split_once('/') else {
|
||||
continue;
|
||||
};
|
||||
let Some(path) = rest
|
||||
.strip_prefix("dimension/")
|
||||
.and_then(|p| p.strip_suffix(".json"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(json) = pack
|
||||
.read(&file)
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
{
|
||||
out.insert(format!("{namespace}:{path}"), json);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---- worldgen ----------------------------------------------------------------
|
||||
|
||||
/// The worldgen entries of `packs` (lowest priority first, the built-in pack
|
||||
@@ -904,9 +935,17 @@ mod tests {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, t).unwrap();
|
||||
};
|
||||
write("fns", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
|
||||
write(
|
||||
"fns",
|
||||
"pack.mcmeta",
|
||||
r#"{"pack":{"min_format":121,"max_format":121}}"#,
|
||||
);
|
||||
write("fns", "data/x/function/f.mcfunction", "say hi");
|
||||
write("off", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
|
||||
write(
|
||||
"off",
|
||||
"pack.mcmeta",
|
||||
r#"{"pack":{"min_format":121,"max_format":121}}"#,
|
||||
);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(dir.join("old.zip")).unwrap());
|
||||
zip.start_file("pack.mcmeta", zip::write::SimpleFileOptions::default())
|
||||
|
||||
@@ -171,6 +171,9 @@ pub enum OmenTick {
|
||||
pub struct PlayerEffects {
|
||||
pub list: Vec<EffectInstance>,
|
||||
pub tick_count: i32,
|
||||
/// How many times `add` added or updated an effect: each is one
|
||||
/// `onEffectAdded` or `onEffectUpdated` (`EffectsChangedTrigger`).
|
||||
pub changes: u32,
|
||||
}
|
||||
impl PlayerEffects {
|
||||
pub fn get(&self, id: &str) -> Option<&EffectInstance> {
|
||||
@@ -181,13 +184,17 @@ impl PlayerEffects {
|
||||
}
|
||||
/// `LivingEntity.addEffect`; returns whether anything changed.
|
||||
pub fn add(&mut self, effect: EffectInstance) -> bool {
|
||||
match self.list.iter_mut().find(|e| e.id == effect.id) {
|
||||
let changed = match self.list.iter_mut().find(|e| e.id == effect.id) {
|
||||
Some(existing) => existing.update(&effect),
|
||||
None => {
|
||||
self.list.push(effect);
|
||||
true
|
||||
}
|
||||
};
|
||||
if changed {
|
||||
self.changes = self.changes.wrapping_add(1);
|
||||
}
|
||||
changed
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.list.clear();
|
||||
@@ -482,6 +489,19 @@ pub fn effect_modifier(effect: &str) -> Option<(&'static str, &'static str, f64,
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn adding_counts_a_change_only_when_the_effect_changed() {
|
||||
let mut fx = PlayerEffects::default();
|
||||
fx.add(EffectInstance::new("speed", 100, 0));
|
||||
assert_eq!(fx.changes, 1);
|
||||
// A longer duration updates the effect (`onEffectUpdated`).
|
||||
fx.add(EffectInstance::new("speed", 200, 0));
|
||||
assert_eq!(fx.changes, 2);
|
||||
// A shorter one with the same amplifier is hidden by nothing: no change.
|
||||
fx.add(EffectInstance::new("speed", 50, 0));
|
||||
assert_eq!(fx.changes, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_omen_in_a_village_becomes_raid_omen_over_two_ticks() {
|
||||
let mut fx = PlayerEffects::default();
|
||||
|
||||
@@ -75,6 +75,7 @@ pub mod compositor;
|
||||
mod cull;
|
||||
mod end_sky;
|
||||
mod flat;
|
||||
mod frame_gen;
|
||||
mod hdr;
|
||||
pub mod lightning;
|
||||
mod lod;
|
||||
@@ -153,14 +154,42 @@ fn world_pass_after_culling<'e>(
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
fn section_instance(origin: [f32; 3]) -> [f32; 4] {
|
||||
/// A section's instance: its camera-relative origin and its UV step, negated
|
||||
/// past its fade code while it fades in over far terrain (`lod::instance`).
|
||||
fn section_instance(origin: [f32; 3], fade: u32, visibility: f32) -> [f32; 4] {
|
||||
let step = 1.0 / crate::mesher::UV_STEPS;
|
||||
[
|
||||
origin[0],
|
||||
origin[1],
|
||||
origin[2],
|
||||
1.0 / crate::mesher::UV_STEPS,
|
||||
if fade == 0 {
|
||||
// The step is a power of two, so its mantissa is free: it holds the vanilla
|
||||
// chunk fade-in's `1 - visibility` in 23 bits (terrain.wgsl's `instance_step` and
|
||||
// `instance_visibility` read it back). A whole section is the bare step.
|
||||
let faded = ((1.0 - visibility) * 8_388_608.0)
|
||||
.round()
|
||||
.clamp(0.0, 8_388_607.0) as u32;
|
||||
f32::from_bits(step.to_bits() | faded)
|
||||
} else {
|
||||
-(fade as f32 + step)
|
||||
},
|
||||
]
|
||||
}
|
||||
/// `SectionRenderDispatcher.RenderSection.getVisibility`: how far through its fade-in a section
|
||||
/// uploaded `elapsed_ms` ago is.
|
||||
fn chunk_visibility(elapsed_ms: u64, fade_ms: u64) -> f32 {
|
||||
if elapsed_ms >= fade_ms {
|
||||
1.0
|
||||
} else {
|
||||
elapsed_ms as f32 / fade_ms as f32
|
||||
}
|
||||
}
|
||||
/// `Util.toMillis`: `Mth.floor(seconds * 1000.0)`.
|
||||
fn fade_millis(seconds: f32) -> u64 {
|
||||
(f64::from(seconds) * 1000.0).floor().max(0.0) as u64
|
||||
}
|
||||
/// `LevelRenderer.CHUNK_VISIBILITY_THRESHOLD`: a section's block entities wait for it.
|
||||
const CHUNK_VISIBILITY_THRESHOLD: f32 = 0.3;
|
||||
/// A new value for `Renderer::block_entity_revision`.
|
||||
fn next_block_entity_revision() -> u64 {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
@@ -209,6 +238,28 @@ struct SectionGpu {
|
||||
sorted_indices: Option<(u32, u64)>,
|
||||
/// Its id for flat draws (`flat::Flat::add`), when they are on.
|
||||
flat_id: Option<u32>,
|
||||
/// When it began to fade in over the far terrain it replaces, which
|
||||
/// stays whole behind it until then: a section new where far tiles are
|
||||
/// drawn. Meshed again, it stays as it was.
|
||||
fading: Option<std::time::Instant>,
|
||||
/// When it began to fade out, its column gone over far terrain that
|
||||
/// stays behind it (`Renderer::leave_section`); released once it has.
|
||||
leaving: Option<std::time::Instant>,
|
||||
}
|
||||
impl SectionGpu {
|
||||
/// Its fade code this frame (`lod::fade_code`), 0 once whole. `retiring`:
|
||||
/// its column has left the render distance, far terrain is coming in
|
||||
/// under it (`lod::HELD`).
|
||||
fn fade(&self, now: std::time::Instant, retiring: bool) -> u32 {
|
||||
match self.leaving {
|
||||
Some(since) => lod::fade_out_code(since, 0, now),
|
||||
None if retiring => lod::HELD,
|
||||
None => self
|
||||
.fading
|
||||
.and_then(|since| lod::fade_code(Some(since), 0, now))
|
||||
.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Line segments the hit outline can hold.
|
||||
const MAX_OUTLINE_EDGES: u64 = 256;
|
||||
@@ -339,6 +390,11 @@ pub struct Frame<'a> {
|
||||
/// `Screen.extractBlurredBackground`: blur the frame by this radius
|
||||
/// (`menuBackgroundBlurriness`) before the screen is drawn over it.
|
||||
pub menu_blur: Option<f32>,
|
||||
/// `Options.chunkSectionFadeInTime`, in seconds: how long a section takes to fade in once
|
||||
/// uploaded (0 draws it whole at once).
|
||||
pub chunk_fade: f32,
|
||||
/// `GameRenderer.requestedPostEffects`: the chains that run over the world.
|
||||
pub post_effects: crate::post_chain::Request<'a>,
|
||||
}
|
||||
/// One frame of the world border wall (`WorldBorderRenderer.render`).
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -514,6 +570,8 @@ pub struct Renderer {
|
||||
lod_cutout: crate::fast_hash::FxHashSet<(i32, i32)>,
|
||||
/// Removed sections of `lod_cutout` columns, released once tiles cover them.
|
||||
lod_retiring: crate::fast_hash::FxHashSet<SectionKey>,
|
||||
/// Removed sections fading out over far terrain (`leave_section`).
|
||||
lod_leaving: Vec<(SectionKey, std::time::Instant)>,
|
||||
/// Finished tile meshes `upload_lods` has yet to upload.
|
||||
lod_pending: Option<lod::Pending>,
|
||||
detail_columns: crate::fast_hash::FxHashMap<(i32, i32), SectionMask>,
|
||||
@@ -551,6 +609,9 @@ pub struct Renderer {
|
||||
shadow_runs: page_runs::PageRuns,
|
||||
/// Block-entity blocks per meshed section (world position, state id).
|
||||
block_entities: crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
|
||||
/// When each section was first uploaded (`RenderSection.uploadedTime`): kept across meshing
|
||||
/// it again, gone with its column. Sections that fade in over far terrain are not in it.
|
||||
section_uploaded: crate::fast_hash::FxHashMap<SectionKey, std::time::Instant>,
|
||||
/// Changes whenever `block_entities` does (unique across renderers).
|
||||
block_entity_revision: u64,
|
||||
/// VisGraph face connectivity per meshed section (cave culling).
|
||||
@@ -586,6 +647,8 @@ pub struct Renderer {
|
||||
pub hud: HudRenderer,
|
||||
/// Vanilla's menu background blur, made on first use.
|
||||
menu_blur: Option<crate::menu_blur::MenuBlur>,
|
||||
/// `post_effect` chains, made on first use.
|
||||
post_effects: Option<crate::post_chain::PostEffects>,
|
||||
/// Where a screenshot's frame is drawn when the swapchain cannot be read back.
|
||||
readback: readback::Readback,
|
||||
pub info: wgpu::AdapterInfo,
|
||||
@@ -2796,6 +2859,7 @@ impl Renderer {
|
||||
lods: HashMap::new(),
|
||||
lod_cutout: Default::default(),
|
||||
lod_retiring: Default::default(),
|
||||
lod_leaving: Vec::new(),
|
||||
lod_pending: None,
|
||||
detail_columns: Default::default(),
|
||||
detail_revision: 0,
|
||||
@@ -2819,6 +2883,7 @@ impl Renderer {
|
||||
phase2_runs: page_runs::PageRuns::default(),
|
||||
shadow_runs: page_runs::PageRuns::default(),
|
||||
block_entities: Default::default(),
|
||||
section_uploaded: 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,
|
||||
@@ -2836,6 +2901,7 @@ impl Renderer {
|
||||
pack_terrain_flat: false,
|
||||
hud,
|
||||
menu_blur: None,
|
||||
post_effects: None,
|
||||
readback: readback::Readback::default(),
|
||||
info,
|
||||
suspended: !drawable_size(size.width, size.height, max_side),
|
||||
@@ -3100,8 +3166,19 @@ impl Renderer {
|
||||
if !self.lod_retiring.is_empty() {
|
||||
self.lod_retiring.remove(&key);
|
||||
}
|
||||
let fading = match self.sections.find(key) {
|
||||
Some(old) => old.fading,
|
||||
None => (!self.lods.is_empty()).then(std::time::Instant::now),
|
||||
};
|
||||
if fading.is_none() {
|
||||
self.section_uploaded
|
||||
.entry(key)
|
||||
.or_insert_with(std::time::Instant::now);
|
||||
}
|
||||
self.release_section(key);
|
||||
let section = self.upload_mesh(mesh);
|
||||
let section = self
|
||||
.upload_mesh(mesh)
|
||||
.map(|section| SectionGpu { fading, ..section });
|
||||
self.set_detail(
|
||||
key,
|
||||
section.is_some() || (mesh.solid.is_empty() && mesh.translucent.is_empty()),
|
||||
@@ -3191,6 +3268,8 @@ impl Renderer {
|
||||
),
|
||||
sorted_indices,
|
||||
flat_id,
|
||||
fading: None,
|
||||
leaving: None,
|
||||
})
|
||||
}
|
||||
/// The section copies recorded since the last submission, closed.
|
||||
@@ -3255,6 +3334,28 @@ impl Renderer {
|
||||
pub fn block_entities(&self) -> impl Iterator<Item = &([i32; 3], u32)> {
|
||||
self.block_entities.values().flatten()
|
||||
}
|
||||
/// `block_entities` of the sections far enough into their fade-in to draw them
|
||||
/// (`LevelExtractor.extractVisibleBlockEntities`: `getVisibility(now, fade) >= 0.3`).
|
||||
pub fn visible_block_entities(
|
||||
&self,
|
||||
fade_seconds: f32,
|
||||
) -> impl Iterator<Item = &([i32; 3], u32)> {
|
||||
let fade = fade_millis(fade_seconds);
|
||||
let now = std::time::Instant::now();
|
||||
self.block_entities
|
||||
.iter()
|
||||
.filter(move |(key, _)| {
|
||||
chunk_visibility(self.section_elapsed_ms(**key, now), fade)
|
||||
>= CHUNK_VISIBILITY_THRESHOLD
|
||||
})
|
||||
.flat_map(|(_, blocks)| blocks)
|
||||
}
|
||||
/// Milliseconds since `key` was uploaded; a section not in `section_uploaded` is old.
|
||||
fn section_elapsed_ms(&self, key: SectionKey, now: std::time::Instant) -> u64 {
|
||||
self.section_uploaded.get(&key).map_or(u64::MAX, |t| {
|
||||
now.saturating_duration_since(*t).as_millis() as u64
|
||||
})
|
||||
}
|
||||
/// A value that changes whenever `block_entities` gives something else.
|
||||
pub fn block_entity_revision(&self) -> u64 {
|
||||
self.block_entity_revision
|
||||
@@ -3262,11 +3363,13 @@ impl Renderer {
|
||||
/// 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.section_uploaded.clear();
|
||||
self.block_entity_revision = next_block_entity_revision();
|
||||
self.sections.clear();
|
||||
self.lods.clear();
|
||||
self.lod_cutout.clear();
|
||||
self.lod_retiring.clear();
|
||||
self.lod_leaving.clear();
|
||||
self.lod_pending = None;
|
||||
self.detail_columns.clear();
|
||||
self.detail_revision += 1;
|
||||
@@ -3325,6 +3428,40 @@ impl Renderer {
|
||||
/// Every section of these columns goes: one lookup per column where the
|
||||
/// tables are by column, and the meshes' ranges freed in one batch. A
|
||||
/// teleport removes a few thousand columns in one frame.
|
||||
/// A section whose column far terrain now draws fades out over it, chunk by
|
||||
/// chunk as it came, and is released once it has (`release_left`): taken
|
||||
/// away at once, the column's detail dropped to the far terrain's in one
|
||||
/// frame.
|
||||
fn leave_section(&mut self, key: SectionKey) {
|
||||
let now = std::time::Instant::now();
|
||||
if let Some(section) = self.sections.find_mut(key)
|
||||
&& section.leaving.is_none()
|
||||
{
|
||||
section.leaving = Some(now);
|
||||
self.lod_leaving.push((key, now));
|
||||
}
|
||||
}
|
||||
/// Releases the sections that have faded out (`leave_section`).
|
||||
fn release_left(&mut self, now: std::time::Instant) {
|
||||
let mut done = Vec::new();
|
||||
self.lod_leaving.retain(|&(key, since)| {
|
||||
let left = now.saturating_duration_since(since) >= lod::FADE;
|
||||
if left {
|
||||
done.push((key, since));
|
||||
}
|
||||
!left
|
||||
});
|
||||
for (key, since) in done {
|
||||
// Meshed again since (the player came back): it stays.
|
||||
if self
|
||||
.sections
|
||||
.find(key)
|
||||
.is_some_and(|s| s.leaving == Some(since))
|
||||
{
|
||||
self.release_section(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn remove_columns(&mut self, columns: impl IntoIterator<Item = (i32, i32)>) {
|
||||
let mut released = Vec::new();
|
||||
let mut block_entities = false;
|
||||
@@ -3337,9 +3474,13 @@ impl Renderer {
|
||||
for sy in 0..self.layout.sections() as i32 {
|
||||
let key = (x, sy, z);
|
||||
block_entities |= self.block_entities.remove(&key).is_some();
|
||||
self.section_uploaded.remove(&key);
|
||||
if retiring {
|
||||
// Distant tiles still leave its column out: drawn until they don't.
|
||||
self.lod_retiring.insert(key);
|
||||
} else if !self.lods.is_empty() && self.sections.contains(key) {
|
||||
// Far terrain draws the column: it fades out over it.
|
||||
self.leave_section(key);
|
||||
} else if let Some(section) = self.sections.remove(key) {
|
||||
released.push(section);
|
||||
}
|
||||
@@ -4059,6 +4200,13 @@ impl Renderer {
|
||||
}
|
||||
let reachable = &self.occlusion;
|
||||
let cutout = &self.lod_cutout;
|
||||
// Sections fading out (`leave_section`) are past the render distance:
|
||||
// drawn there until they have, or they popped out at once.
|
||||
let leaving: crate::fast_hash::FxHashSet<(i32, i32)> = self
|
||||
.lod_leaving
|
||||
.iter()
|
||||
.map(|&((x, _, z), _)| (x, z))
|
||||
.collect();
|
||||
let mut visible = std::mem::take(&mut self.visible);
|
||||
visible.clear();
|
||||
crate::spans::time("visibility", || {
|
||||
@@ -4067,10 +4215,14 @@ impl Renderer {
|
||||
(frame.detail_distance / 16.0) as i32,
|
||||
&view,
|
||||
|k| !culling || reachable.contains(k),
|
||||
|x, z| !cutout.is_empty() && cutout.contains(&(x, z)),
|
||||
|x, z| {
|
||||
(!cutout.is_empty() && cutout.contains(&(x, z)))
|
||||
|| (!leaving.is_empty() && leaving.contains(&(x, z)))
|
||||
},
|
||||
&mut visible,
|
||||
)
|
||||
});
|
||||
let now = std::time::Instant::now();
|
||||
let mut visible_lods = std::mem::take(&mut self.visible_lods);
|
||||
visible_lods.clear();
|
||||
if frame.title {
|
||||
@@ -4079,7 +4231,7 @@ impl Renderer {
|
||||
visible_lods.extend(
|
||||
self.lods
|
||||
.iter()
|
||||
.filter_map(|(&key, tile)| tile.visible(key, camera, frame.distance, &view)),
|
||||
.filter_map(|(&key, tile)| tile.visible(key, camera, &view, now)),
|
||||
);
|
||||
visible_lods.sort_unstable_by_key(|v| (v.distance, v.key));
|
||||
}
|
||||
@@ -4132,12 +4284,34 @@ impl Renderer {
|
||||
});
|
||||
}
|
||||
self.origins.clear();
|
||||
let (sections, retiring) = (&self.sections, &self.lod_retiring);
|
||||
// `LevelRenderer.extractSectionDrawGroups`: each section's `getVisibility`.
|
||||
let fade_ms = fade_millis(frame.chunk_fade);
|
||||
let uploaded = &self.section_uploaded;
|
||||
let visibility = |key: SectionKey| {
|
||||
if fade_ms == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
uploaded.get(&key).map_or(1.0, |t| {
|
||||
chunk_visibility(
|
||||
now.saturating_duration_since(*t).as_millis() as u64,
|
||||
fade_ms,
|
||||
)
|
||||
})
|
||||
};
|
||||
self.origins.extend(
|
||||
visible
|
||||
.iter()
|
||||
.map(|v| section_instance(v.origin))
|
||||
.chain(visible_lods.iter().map(|v| lod::instance(v.origin)))
|
||||
.chain(shadow_visible.iter().map(|v| section_instance(v.origin))),
|
||||
.map(|v| {
|
||||
let code = sections.get(v.slot).fade(now, retiring.contains(&v.key));
|
||||
section_instance(v.origin, code, visibility(v.key))
|
||||
})
|
||||
.chain(visible_lods.iter().map(|v| lod::instance(v.origin, v.fade)))
|
||||
.chain(
|
||||
shadow_visible
|
||||
.iter()
|
||||
.map(|v| section_instance(v.origin, 0, visibility(v.key))),
|
||||
),
|
||||
);
|
||||
if !self.origins.is_empty() {
|
||||
self.uploads.buffer(
|
||||
@@ -4228,7 +4402,7 @@ impl Renderer {
|
||||
let tiles = self.draws.len();
|
||||
self.solid_runs.clear();
|
||||
for (i, v) in visible_lods.iter().enumerate() {
|
||||
let s = &self.lods[&v.key].mesh;
|
||||
let s = self.lods[&v.key].drawn();
|
||||
let start = self.draws.len();
|
||||
let instance = (visible.len() + i) as u32;
|
||||
facing_runs(&s.sides, eye, v.origin, |first, quads| {
|
||||
@@ -4297,7 +4471,7 @@ impl Renderer {
|
||||
for (i, v) in visible_lods.iter().enumerate() {
|
||||
let instance = visible.len() + i;
|
||||
let tile = self.lods.get_mut(&v.key).expect("visible resident tile");
|
||||
let s = &mut tile.mesh;
|
||||
let s = tile.drawn_mut();
|
||||
if let Some((index_page, index_start)) = s.sorted_indices {
|
||||
let scheduled = (instance + count - self.translucency_cursor) % count < budget;
|
||||
if (!s.sort.is_sorted() || scheduled)
|
||||
@@ -4331,8 +4505,26 @@ impl Renderer {
|
||||
}
|
||||
// Near water and distant water share one back-to-front draw order.
|
||||
if self.translucent_draws.len() > section_translucent {
|
||||
// Distant water goes after the near chunks', and replaced tiles'
|
||||
// after all: each is a hair behind the one before, so depth leaves
|
||||
// it only where that one has none. Back to front together, a far
|
||||
// tile's water under a near chunk's (one fading in, or brought
|
||||
// into view by a longer render distance) blended twice and looked
|
||||
// opaque. The cost: far water just behind near water shows no
|
||||
// surface of its own there.
|
||||
let tier = |instance: usize| match instance.checked_sub(visible.len()) {
|
||||
None => 0,
|
||||
Some(tile) if visible_lods[tile].old => 2,
|
||||
Some(_) => 1,
|
||||
};
|
||||
self.translucent_draws
|
||||
.sort_unstable_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
|
||||
.sort_unstable_by_key(|&(distance, instance, ..)| {
|
||||
(
|
||||
tier(instance),
|
||||
std::cmp::Reverse(distance),
|
||||
std::cmp::Reverse(instance),
|
||||
)
|
||||
});
|
||||
}
|
||||
for &(_, _, draw, pages) in &self.translucent_draws {
|
||||
self.draws.push(draw);
|
||||
@@ -4704,7 +4896,7 @@ impl Renderer {
|
||||
|| pack_terrain
|
||||
|| shadow_view.is_some()) =>
|
||||
{
|
||||
let sections = &self.sections;
|
||||
let (sections, retiring) = (&self.sections, &self.lod_retiring);
|
||||
let lods = &self.lods;
|
||||
let sorted = &self.sorted_indices;
|
||||
let phase2 = self.cull.as_ref().filter(|_| culling).map(|c| &c.phase2);
|
||||
@@ -4723,12 +4915,16 @@ impl Renderer {
|
||||
.iter()
|
||||
.chain(&shadow_visible)
|
||||
.filter_map(|v| {
|
||||
let id = sections.get(v.slot).flat_id?;
|
||||
Some((id, section_instance(v.origin)))
|
||||
let section = sections.get(v.slot);
|
||||
let code = section.fade(now, retiring.contains(&v.key));
|
||||
Some((
|
||||
section.flat_id?,
|
||||
section_instance(v.origin, code, visibility(v.key)),
|
||||
))
|
||||
})
|
||||
.chain(visible_lods.iter().filter_map(|v| {
|
||||
let id = lods[&v.key].mesh.flat_id?;
|
||||
Some((id, lod::instance(v.origin)))
|
||||
let id = lods[&v.key].drawn().flat_id?;
|
||||
Some((id, lod::instance(v.origin, v.fade)))
|
||||
})),
|
||||
|page| sorted.buffer(page).clone(),
|
||||
)
|
||||
@@ -5420,6 +5616,26 @@ impl Renderer {
|
||||
.screenshot
|
||||
.take_if(|request| request.size.is_some())
|
||||
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture));
|
||||
// GameRenderer.render: applyPostEffects, after the level and before the GUI.
|
||||
if !frame.title
|
||||
&& !frame.post_effects.effects.is_empty()
|
||||
&& target_texture
|
||||
.usage()
|
||||
.contains(wgpu::TextureUsages::COPY_SRC)
|
||||
{
|
||||
let device = &self.device;
|
||||
self.post_effects
|
||||
.get_or_insert_with(|| crate::post_chain::PostEffects::new(device))
|
||||
.apply(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&mut encoder,
|
||||
&target_texture,
|
||||
&view_target,
|
||||
[self.config.width, self.config.height],
|
||||
&frame.post_effects,
|
||||
);
|
||||
}
|
||||
if !frame.screen.is_empty() {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view_target,
|
||||
@@ -5634,7 +5850,7 @@ fn padded_row(width: u32) -> u32 {
|
||||
(width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
|
||||
}
|
||||
/// Encodes the copy of `texture`'s `size` pixels into a mappable buffer.
|
||||
fn copy_frame(
|
||||
pub(crate) fn copy_frame(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
@@ -6137,6 +6353,36 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn chunk_fade_is_vanillas_get_visibility() {
|
||||
use super::{chunk_visibility, fade_millis};
|
||||
assert_eq!(fade_millis(0.75), 750);
|
||||
assert_eq!(fade_millis(0.0), 0);
|
||||
assert_eq!(chunk_visibility(0, 750), 0.0_f32);
|
||||
assert_eq!(chunk_visibility(375, 750), 0.5);
|
||||
assert_eq!(chunk_visibility(750, 750), 1.0);
|
||||
assert_eq!(chunk_visibility(0, 0), 1.0, "Off: whole at once");
|
||||
assert_eq!(chunk_visibility(u64::MAX, 750), 1.0);
|
||||
}
|
||||
#[test]
|
||||
fn section_instance_carries_the_fade_in_the_step() {
|
||||
use super::section_instance;
|
||||
let step = 1.0 / crate::mesher::UV_STEPS;
|
||||
// Fully visible (the option Off) is exactly the record from before the fade.
|
||||
assert_eq!(
|
||||
section_instance([1.0, 2.0, 3.0], 0, 1.0),
|
||||
[1.0, 2.0, 3.0, step]
|
||||
);
|
||||
for visibility in [0.0_f32, 0.25, 0.5, 0.999] {
|
||||
let w = section_instance([0.0; 3], 0, visibility)[3];
|
||||
let bits = w.to_bits();
|
||||
assert_eq!(f32::from_bits(bits & 0xff80_0000), step);
|
||||
let read = 1.0 - (bits & 0x7f_ffff) as f32 / 8_388_608.0;
|
||||
assert!((read - visibility).abs() < 1e-6, "{visibility}: {read}");
|
||||
}
|
||||
// A section fading in over far terrain keeps its dither code unchanged.
|
||||
assert_eq!(section_instance([0.0; 3], 7, 0.5)[3], -(7.0 + step));
|
||||
}
|
||||
#[test]
|
||||
fn terrain_attributes_follow_the_terrain_vertex() {
|
||||
use super::TerrainVertex;
|
||||
use std::mem::offset_of;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Frame generation: FSR 3.1's frame interpolation will show a frame halfway between each
|
||||
//! two real ones. So far this holds its optical flow; the interpolation and the setting
|
||||
//! that runs them come next.
|
||||
|
||||
// Unused until the interpolation and the renderer use it.
|
||||
#[allow(dead_code)]
|
||||
mod optical_flow;
|
||||
@@ -0,0 +1,653 @@
|
||||
// This file is part of the FidelityFX SDK.
|
||||
//
|
||||
// Copyright (C) 2024 Advanced Micro Devices, Inc.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files(the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and /or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions :
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
//! FSR 3.1's optical flow (AMD FidelityFX SDK 1.1.4, `ffx_opticalflow*`, MIT), ported to
|
||||
//! WGSL (`optical_flow.wgsl`): block motion between the previous frame's colour and this
|
||||
//! one's, and scene change detection, for frame interpolation (`interpolation.rs`).
|
||||
//!
|
||||
//! As in `ffx_opticalflow.cpp`: luma at the display size and 6 halvings, ping-ponged
|
||||
//! between frames; a histogram of the luma for scene change detection; then from the
|
||||
//! coarsest of 7 levels down, a block search seeded by the level above, a 3x3 filter and
|
||||
//! a scale to the next level. The ported passes differ only where WGSL needs it: the luma
|
||||
//! levels are `R32Float` holding AMD's 8-bit integers, wave operations are workgroup
|
||||
//! atomics, and the luma pyramid is one dispatch per level instead of one for all (SPD).
|
||||
//! The luma textures aren't cleared on a reset (AMD's are): its first 6 frames report a
|
||||
//! scene change and no motion, and rewrite them.
|
||||
//!
|
||||
//! The vectors point from a block of this frame to where it was in the previous one, in
|
||||
//! display pixels: content moving right by 4 pixels gives (-4, 0).
|
||||
|
||||
/// The size in pixels of the square blocks the flow has one vector for.
|
||||
pub const BLOCK_SIZE: u32 = 8;
|
||||
/// `OpticalFlowMaxPyramidLevels`: the search's levels.
|
||||
const LEVELS: usize = 7;
|
||||
/// `HistogramBins * HistogramsPerDim²`: the scene change histograms.
|
||||
const HISTOGRAM_WIDTH: u64 = 256 * 9;
|
||||
|
||||
/// `optical_flow.wgsl`'s `Params`.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Params {
|
||||
display: [i32; 2],
|
||||
level: u32,
|
||||
level_count: u32,
|
||||
frame_index: u32,
|
||||
transfer_function: u32,
|
||||
min_max_luminance: [f32; 2],
|
||||
}
|
||||
|
||||
struct Pipelines {
|
||||
prepare_luma: wgpu::ComputePipeline,
|
||||
luma_pyramid: wgpu::ComputePipeline,
|
||||
scd_histogram: wgpu::ComputePipeline,
|
||||
scd_divergence: wgpu::ComputePipeline,
|
||||
search: wgpu::ComputePipeline,
|
||||
filter: wgpu::ComputePipeline,
|
||||
scale: wgpu::ComputePipeline,
|
||||
}
|
||||
|
||||
/// One frame parity's bindings: which luma pyramid is this frame's.
|
||||
struct Frame {
|
||||
/// This frame's luma at the display size, where `prepare_luma` writes.
|
||||
luma0: wgpu::TextureView,
|
||||
luma_pyramid: [wgpu::BindGroup; LEVELS - 1],
|
||||
scd_histogram: wgpu::BindGroup,
|
||||
search: [wgpu::BindGroup; LEVELS],
|
||||
scale: [wgpu::BindGroup; LEVELS - 1],
|
||||
}
|
||||
|
||||
pub struct OpticalFlow {
|
||||
display: [u32; 2],
|
||||
pipelines: Pipelines,
|
||||
/// One per level, the level's constants (the passes outside the search use level 0's).
|
||||
params: Vec<wgpu::Buffer>,
|
||||
/// The bindings of the frames whose luma lands in pyramid 0, and in pyramid 1.
|
||||
frames: [Frame; 2],
|
||||
filter: [wgpu::BindGroup; LEVELS],
|
||||
scd_divergence: wgpu::BindGroup,
|
||||
scd_histogram: wgpu::Buffer,
|
||||
scd_previous_histogram: wgpu::Buffer,
|
||||
scd_temp: wgpu::Buffer,
|
||||
scd_output: wgpu::Buffer,
|
||||
vectors: wgpu::TextureView,
|
||||
flow_sizes: [[u32; 2]; LEVELS],
|
||||
/// `resourceFrameIndex` and `frameIndex`.
|
||||
resource_frame: u32,
|
||||
frame_index: u32,
|
||||
first: bool,
|
||||
}
|
||||
|
||||
fn texture(
|
||||
device: &wgpu::Device,
|
||||
label: &str,
|
||||
size: [u32; 2],
|
||||
format: wgpu::TextureFormat,
|
||||
) -> wgpu::TextureView {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0].max(1),
|
||||
height: size[1].max(1),
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::STORAGE_BINDING
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
}
|
||||
|
||||
fn buffer(device: &wgpu::Device, label: &str, size: u64) -> wgpu::Buffer {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(label),
|
||||
size,
|
||||
usage: wgpu::BufferUsages::STORAGE
|
||||
| wgpu::BufferUsages::COPY_DST
|
||||
| wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Bind `entries` (binding number, resource) for `pipeline`'s own layout.
|
||||
fn bind(
|
||||
device: &wgpu::Device,
|
||||
pipeline: &wgpu::ComputePipeline,
|
||||
entries: &[(u32, wgpu::BindingResource)],
|
||||
) -> wgpu::BindGroup {
|
||||
let entries: Vec<_> = entries
|
||||
.iter()
|
||||
.map(|(binding, resource)| wgpu::BindGroupEntry {
|
||||
binding: *binding,
|
||||
resource: resource.clone(),
|
||||
})
|
||||
.collect();
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Optical flow"),
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &entries,
|
||||
})
|
||||
}
|
||||
|
||||
fn view(v: &wgpu::TextureView) -> wgpu::BindingResource<'_> {
|
||||
wgpu::BindingResource::TextureView(v)
|
||||
}
|
||||
|
||||
fn whole(b: &wgpu::Buffer) -> wgpu::BindingResource<'_> {
|
||||
b.as_entire_binding()
|
||||
}
|
||||
|
||||
impl OpticalFlow {
|
||||
/// For frames of `display` size.
|
||||
pub fn new(device: &wgpu::Device, display: [u32; 2]) -> Self {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Optical flow"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("optical_flow.wgsl").into()),
|
||||
});
|
||||
let pipeline = |entry: &str| {
|
||||
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some(entry),
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some(entry),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let pipelines = Pipelines {
|
||||
prepare_luma: pipeline("prepare_luma"),
|
||||
luma_pyramid: pipeline("luma_pyramid"),
|
||||
scd_histogram: pipeline("scd_histogram_pass"),
|
||||
scd_divergence: pipeline("scd_divergence_pass"),
|
||||
search: pipeline("search_pass"),
|
||||
filter: pipeline("filter_flow"),
|
||||
scale: pipeline("scale_pass"),
|
||||
};
|
||||
let params: Vec<_> = (0..LEVELS)
|
||||
.map(|_| {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Optical flow constants"),
|
||||
size: std::mem::size_of::<Params>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let luma: [Vec<wgpu::TextureView>; 2] = [0, 1].map(|_| {
|
||||
(0..LEVELS)
|
||||
.map(|k| {
|
||||
let size = display.map(|v| (v >> k).max(1));
|
||||
texture(
|
||||
device,
|
||||
"Optical flow luma",
|
||||
size,
|
||||
wgpu::TextureFormat::R32Float,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let mut flow_sizes = [[0; 2]; LEVELS];
|
||||
flow_sizes[0] = display.map(|v| v.div_ceil(BLOCK_SIZE));
|
||||
for l in 1..LEVELS {
|
||||
flow_sizes[l] = flow_sizes[l - 1].map(|v| v.div_ceil(2));
|
||||
}
|
||||
let flow = |label| -> Vec<wgpu::TextureView> {
|
||||
flow_sizes
|
||||
.iter()
|
||||
.map(|&size| texture(device, label, size, wgpu::TextureFormat::Rg32Sint))
|
||||
.collect()
|
||||
};
|
||||
// Per level: the scaled prediction from the level above, the search's vectors and
|
||||
// the filtered ones (level 0's are the output).
|
||||
let (predicted, searched, filtered) = (
|
||||
flow("Optical flow prediction"),
|
||||
flow("Optical flow search"),
|
||||
flow("Optical flow"),
|
||||
);
|
||||
let scd_histogram = buffer(device, "Scene change histogram", HISTOGRAM_WIDTH * 4);
|
||||
let scd_previous_histogram = buffer(
|
||||
device,
|
||||
"Scene change previous histogram",
|
||||
HISTOGRAM_WIDTH * 4,
|
||||
);
|
||||
let scd_temp = buffer(device, "Scene change temp", 12);
|
||||
let scd_output = buffer(device, "Scene change detection", 12);
|
||||
let frame = |current: usize| {
|
||||
let (cur, prev) = (&luma[current], &luma[1 - current]);
|
||||
Frame {
|
||||
luma0: cur[0].clone(),
|
||||
luma_pyramid: std::array::from_fn(|k| {
|
||||
bind(
|
||||
device,
|
||||
&pipelines.luma_pyramid,
|
||||
&[(2, view(&cur[k + 1])), (3, view(&cur[k]))],
|
||||
)
|
||||
}),
|
||||
scd_histogram: bind(
|
||||
device,
|
||||
&pipelines.scd_histogram,
|
||||
&[
|
||||
(0, whole(¶ms[0])),
|
||||
(3, view(&cur[0])),
|
||||
(7, whole(&scd_histogram)),
|
||||
],
|
||||
),
|
||||
search: std::array::from_fn(|l| {
|
||||
bind(
|
||||
device,
|
||||
&pipelines.search,
|
||||
&[
|
||||
(0, whole(¶ms[l])),
|
||||
(3, view(&cur[l])),
|
||||
(4, view(&prev[l])),
|
||||
(5, view(&predicted[l])),
|
||||
(6, view(&searched[l])),
|
||||
(10, whole(&scd_output)),
|
||||
],
|
||||
)
|
||||
}),
|
||||
scale: std::array::from_fn(|i| {
|
||||
let l = i + 1;
|
||||
bind(
|
||||
device,
|
||||
&pipelines.scale,
|
||||
&[
|
||||
(0, whole(¶ms[l])),
|
||||
(3, view(&cur[l])),
|
||||
(4, view(&prev[l])),
|
||||
(5, view(&filtered[l])),
|
||||
(6, view(&predicted[l - 1])),
|
||||
(10, whole(&scd_output)),
|
||||
],
|
||||
)
|
||||
}),
|
||||
}
|
||||
};
|
||||
let frames = [frame(0), frame(1)];
|
||||
let filter = std::array::from_fn(|l| {
|
||||
bind(
|
||||
device,
|
||||
&pipelines.filter,
|
||||
&[(5, view(&searched[l])), (6, view(&filtered[l]))],
|
||||
)
|
||||
});
|
||||
let scd_divergence = bind(
|
||||
device,
|
||||
&pipelines.scd_divergence,
|
||||
&[
|
||||
(7, whole(&scd_histogram)),
|
||||
(8, whole(&scd_previous_histogram)),
|
||||
(9, whole(&scd_temp)),
|
||||
(10, whole(&scd_output)),
|
||||
],
|
||||
);
|
||||
Self {
|
||||
display,
|
||||
pipelines,
|
||||
params,
|
||||
frames,
|
||||
filter,
|
||||
scd_divergence,
|
||||
scd_histogram,
|
||||
scd_previous_histogram,
|
||||
scd_temp,
|
||||
scd_output,
|
||||
vectors: filtered[0].clone(),
|
||||
flow_sizes,
|
||||
resource_frame: 0,
|
||||
frame_index: 0,
|
||||
first: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The flow from the previous call's `color` to this one's. `color`: `display` size,
|
||||
/// a float texture holding Horizon's frame (gamma-encoded RGB, the swapchain's
|
||||
/// values: AMD's SDR back buffer). `reset`: the previous frame is unrelated (first
|
||||
/// frame, resize, teleport).
|
||||
pub fn dispatch(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
color: &wgpu::TextureView,
|
||||
reset: bool,
|
||||
) {
|
||||
let reset = reset || self.first;
|
||||
self.first = false;
|
||||
self.frame_index = if reset { 0 } else { self.frame_index + 1 };
|
||||
if reset {
|
||||
for b in [
|
||||
&self.scd_temp,
|
||||
&self.scd_output,
|
||||
&self.scd_histogram,
|
||||
&self.scd_previous_histogram,
|
||||
] {
|
||||
encoder.clear_buffer(b, 0, None);
|
||||
}
|
||||
}
|
||||
for (level, buffer) in self.params.iter().enumerate() {
|
||||
let params = Params {
|
||||
display: self.display.map(|v| v as i32),
|
||||
level: level as u32,
|
||||
level_count: LEVELS as u32,
|
||||
frame_index: self.frame_index,
|
||||
transfer_function: 0,
|
||||
min_max_luminance: [0.0, 1.0],
|
||||
};
|
||||
queue.write_buffer(buffer, 0, bytemuck::bytes_of(¶ms));
|
||||
}
|
||||
let frame = &self.frames[(self.resource_frame & 1) as usize];
|
||||
let prepare = bind(
|
||||
device,
|
||||
&self.pipelines.prepare_luma,
|
||||
&[
|
||||
(0, whole(&self.params[0])),
|
||||
(1, view(color)),
|
||||
(2, view(&frame.luma0)),
|
||||
],
|
||||
);
|
||||
let p = &self.pipelines;
|
||||
let [w, h] = self.display;
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Optical flow"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
let mut run = |pipeline: &wgpu::ComputePipeline, group: &wgpu::BindGroup, x, y, z| {
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups(x, y, z);
|
||||
};
|
||||
run(
|
||||
&p.prepare_luma,
|
||||
&prepare,
|
||||
(w.div_ceil(2)).div_ceil(16),
|
||||
(h.div_ceil(2)).div_ceil(16),
|
||||
1,
|
||||
);
|
||||
for (k, group) in frame.luma_pyramid.iter().enumerate() {
|
||||
let size = self.display.map(|v| (v >> (k + 1)).max(1));
|
||||
run(
|
||||
&p.luma_pyramid,
|
||||
group,
|
||||
size[0].div_ceil(8),
|
||||
size[1].div_ceil(8),
|
||||
1,
|
||||
);
|
||||
}
|
||||
run(
|
||||
&p.scd_histogram,
|
||||
&frame.scd_histogram,
|
||||
((w / 4) / 3).div_ceil(32),
|
||||
16,
|
||||
9,
|
||||
);
|
||||
run(&p.scd_divergence, &self.scd_divergence, 9, 3, 1);
|
||||
for level in (0..LEVELS).rev() {
|
||||
let luma = self.display.map(|v| (v >> level).max(1));
|
||||
run(
|
||||
&p.search,
|
||||
&frame.search[level],
|
||||
(luma[0].div_ceil(4) * 16).div_ceil(64),
|
||||
luma[1].div_ceil(16),
|
||||
1,
|
||||
);
|
||||
let size = self.flow_sizes[level];
|
||||
run(
|
||||
&p.filter,
|
||||
&self.filter[level],
|
||||
size[0].div_ceil(16),
|
||||
size[1].div_ceil(4),
|
||||
1,
|
||||
);
|
||||
if level > 0 {
|
||||
let next = self.flow_sizes[level - 1];
|
||||
run(
|
||||
&p.scale,
|
||||
&frame.scale[level - 1],
|
||||
next[0].div_ceil(4),
|
||||
next[1].div_ceil(4),
|
||||
1,
|
||||
);
|
||||
}
|
||||
}
|
||||
drop(pass);
|
||||
self.resource_frame = (self.resource_frame + 1) % 16;
|
||||
}
|
||||
|
||||
/// This frame's vectors, one per `BLOCK_SIZE` block: `Rg32Sint`, the display size
|
||||
/// over `BLOCK_SIZE` rounded up,
|
||||
/// holding the values FSR's `R16G16_SINT` optical flow vector texture holds.
|
||||
pub fn vectors(&self) -> &wgpu::TextureView {
|
||||
&self.vectors
|
||||
}
|
||||
|
||||
/// FSR's scene change detection texture (`R32_UINT`, 3 texels) as a storage
|
||||
/// buffer of 3 `u32`: element `i` holds texel `i`'s value.
|
||||
pub fn scene_change(&self) -> &wgpu::Buffer {
|
||||
&self.scd_output
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shader_validates() {
|
||||
let module = naga::front::wgsl::parse_str(include_str!("optical_flow.wgsl")).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::empty(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A device with WebGPU's default limits (what every GPU wgpu runs on has), or `None`
|
||||
/// without a GPU.
|
||||
pub fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
/// Noise in 4x4 pixel cells, moved by `shift` pixels: what was at `p` is at `p + shift`.
|
||||
/// A `seed` of 100 or more gives cells of one primary each.
|
||||
pub fn pattern(size: [u32; 2], seed: u32, shift: [i32; 2]) -> Vec<u8> {
|
||||
let mut pixels = Vec::with_capacity((size[0] * size[1] * 4) as usize);
|
||||
for y in 0..size[1] as i32 {
|
||||
for x in 0..size[0] as i32 {
|
||||
let (cx, cy) = ((x - shift[0]).div_euclid(4), (y - shift[1]).div_euclid(4));
|
||||
let mut h = (cx as u32).wrapping_mul(0x9e37_79b9)
|
||||
^ (cy as u32).wrapping_mul(0x85eb_ca6b)
|
||||
^ seed.wrapping_mul(0xc2b2_ae35);
|
||||
h ^= h >> 15;
|
||||
h = h.wrapping_mul(0x2c1b_3c6d);
|
||||
h ^= h >> 12;
|
||||
let v = (h & 0xff) as u8;
|
||||
if seed >= 100 {
|
||||
// Primaries: no two neighbours' colours point the same way.
|
||||
let mut c = [0, 0, 0, 255];
|
||||
c[(h >> 8) as usize % 3] = 128 + v / 2;
|
||||
pixels.extend_from_slice(&c);
|
||||
} else {
|
||||
pixels.extend_from_slice(&[v, v.wrapping_add(40), v / 2, 255]);
|
||||
}
|
||||
}
|
||||
}
|
||||
pixels
|
||||
}
|
||||
|
||||
pub fn upload(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
size: [u32; 2],
|
||||
pixels: &[u8],
|
||||
) -> wgpu::TextureView {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
queue.write_texture(
|
||||
texture.as_image_copy(),
|
||||
pixels,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(size[0] * 4),
|
||||
rows_per_image: None,
|
||||
},
|
||||
texture.size(),
|
||||
);
|
||||
texture.create_view(&Default::default())
|
||||
}
|
||||
|
||||
/// `texture`'s texels (`texel` bytes each), row by row.
|
||||
pub fn read(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
texture: &wgpu::Texture,
|
||||
texel: u32,
|
||||
) -> Vec<u8> {
|
||||
let size = texture.size();
|
||||
let row = (size.width * texel).next_multiple_of(256);
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(row * size.height),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_texture_to_buffer(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(row),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
size,
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let mapped = buffer.slice(..).get_mapped_range().unwrap();
|
||||
mapped
|
||||
.chunks(row as usize)
|
||||
.flat_map(|r| r[..(size.width * texel) as usize].to_vec())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
flow: &mut OpticalFlow,
|
||||
color: &wgpu::TextureView,
|
||||
reset: bool,
|
||||
) {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
flow.dispatch(device, queue, &mut encoder, color, reset);
|
||||
queue.submit([encoder.finish()]);
|
||||
}
|
||||
|
||||
fn vectors(device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow) -> Vec<[i32; 2]> {
|
||||
read(device, queue, flow.vectors.texture(), 8)
|
||||
.chunks(8)
|
||||
.map(|c| {
|
||||
[
|
||||
i32::from_le_bytes(c[0..4].try_into().unwrap()),
|
||||
i32::from_le_bytes(c[4..8].try_into().unwrap()),
|
||||
]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A frame moved right by 16 and down by 8: the blocks inside report where they were
|
||||
/// (-16, -8), and nothing reads as a scene change. A much darker frame then does.
|
||||
#[test]
|
||||
fn finds_a_shift_and_a_scene_change() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
let size = [512, 288];
|
||||
let still = upload(&device, &queue, size, &pattern(size, 1, [0, 0]));
|
||||
let moved = upload(&device, &queue, size, &pattern(size, 1, [16, 8]));
|
||||
// Scene changes are told by the luma's histogram: a much darker frame.
|
||||
let dark: Vec<u8> = pattern(size, 2, [0, 0]).iter().map(|v| v / 5).collect();
|
||||
let other = upload(&device, &queue, size, &dark);
|
||||
let mut flow = OpticalFlow::new(&device, size);
|
||||
// The first 6 frames after a reset are scene changes.
|
||||
run(&device, &queue, &mut flow, &still, true);
|
||||
for _ in 0..6 {
|
||||
run(&device, &queue, &mut flow, &still, false);
|
||||
}
|
||||
run(&device, &queue, &mut flow, &moved, false);
|
||||
let [w, h] = flow.flow_sizes[0];
|
||||
let found = vectors(&device, &queue, &flow);
|
||||
let inside: Vec<_> = (4..h - 4)
|
||||
.flat_map(|y| (4..w - 4).map(move |x| (x, y)))
|
||||
.map(|(x, y)| found[(y * w + x) as usize])
|
||||
.collect();
|
||||
let right = inside.iter().filter(|v| **v == [-16, -8]).count();
|
||||
assert!(
|
||||
right * 10 >= inside.len() * 9,
|
||||
"{right} of {} blocks: {:?}",
|
||||
inside.len(),
|
||||
&inside[..8]
|
||||
);
|
||||
let scd = |device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow| {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 12,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_buffer_to_buffer(flow.scene_change(), 0, &buffer, 0, 12);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec();
|
||||
u32::from_le_bytes(bytes[4..8].try_into().unwrap())
|
||||
};
|
||||
assert_eq!(scd(&device, &queue, &flow) & 0xf, 0);
|
||||
run(&device, &queue, &mut flow, &other, false);
|
||||
assert_eq!(scd(&device, &queue, &flow) & 1, 1);
|
||||
assert!(vectors(&device, &queue, &flow).iter().all(|v| *v == [0, 0]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
// This file is part of the FidelityFX SDK.
|
||||
//
|
||||
// Copyright (C) 2024 Advanced Micro Devices, Inc.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files(the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and /or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions :
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
// FidelityFX SDK 1.1.4's optical flow (ffx_opticalflow_*.h, the GLSL path), ported to WGSL
|
||||
// for Horizon. What differs from AMD's (optical_flow.rs lists why):
|
||||
// - luma levels are r32float (AMD: r8ui); loads truncate to the same integers;
|
||||
// - the luma pyramid is one pass per level instead of SPD's single pass;
|
||||
// - wave sums and minimums are workgroup atomics, and the reductions AMD leaves to a
|
||||
// wave's lockstep have barriers;
|
||||
// - the scene change textures are storage buffers;
|
||||
// - the search reads its prediction from one texture and writes another (rg32sint can't
|
||||
// be read_write everywhere).
|
||||
|
||||
struct Params {
|
||||
// Display size (cbOF.iInputLumaResolution).
|
||||
display: vec2<i32>,
|
||||
level: u32,
|
||||
level_count: u32,
|
||||
frame_index: u32,
|
||||
transfer_function: u32,
|
||||
min_max_luminance: vec2<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var input_color: texture_2d<f32>;
|
||||
@group(0) @binding(2) var luma_out: texture_storage_2d<r32float, write>;
|
||||
// The current frame's luma at this level, and the previous frame's.
|
||||
@group(0) @binding(3) var luma_in: texture_2d<f32>;
|
||||
@group(0) @binding(4) var luma_previous: texture_2d<f32>;
|
||||
// Search: the prediction from the level above; filter: the search; scale: the filtered.
|
||||
@group(0) @binding(5) var flow_in: texture_2d<i32>;
|
||||
@group(0) @binding(6) var flow_out: texture_storage_2d<rg32sint, write>;
|
||||
@group(0) @binding(7) var<storage, read_write> scd_histogram: array<atomic<u32>>;
|
||||
@group(0) @binding(8) var<storage, read_write> scd_previous_histogram: array<f32>;
|
||||
@group(0) @binding(9) var<storage, read_write> scd_temp: array<atomic<u32>, 3>;
|
||||
@group(0) @binding(10) var<storage, read_write> scd_output: array<atomic<u32>, 3>;
|
||||
|
||||
const SCD_OUTPUT_SCENE_CHANGE_SLOT: u32 = 0u;
|
||||
const SCD_OUTPUT_HISTORY_BITS_SLOT: u32 = 1u;
|
||||
const SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT: u32 = 2u;
|
||||
|
||||
fn load_luma(t: texture_2d<f32>, p: vec2<i32>) -> u32 {
|
||||
let size = vec2<i32>(textureDimensions(t));
|
||||
if any(p < vec2(0)) || any(p >= size) {
|
||||
return 0u;
|
||||
}
|
||||
return u32(textureLoad(t, p, 0).x);
|
||||
}
|
||||
|
||||
fn load_flow(p: vec2<i32>) -> vec2<i32> {
|
||||
let size = vec2<i32>(textureDimensions(flow_in));
|
||||
if any(p < vec2(0)) || any(p >= size) {
|
||||
return vec2(0);
|
||||
}
|
||||
return textureLoad(flow_in, p, 0).xy;
|
||||
}
|
||||
|
||||
fn store_flow(p: vec2<i32>, v: vec2<i32>) {
|
||||
let size = vec2<i32>(textureDimensions(flow_out));
|
||||
if all(p >= vec2(0)) && all(p < size) {
|
||||
textureStore(flow_out, p, vec4(v, 0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
fn packed_luma(width: i32, x: i32, luma0: u32, luma1: u32, luma2: u32, luma3: u32) -> u32 {
|
||||
var packed = luma0 | (luma1 << 8u) | (luma2 << 16u) | (luma3 << 24u);
|
||||
if x < 0 {
|
||||
let filler = packed & 0xffu;
|
||||
if x <= -1 {
|
||||
packed = (packed << 8u) | filler;
|
||||
}
|
||||
if x <= -2 {
|
||||
packed = (packed << 8u) | filler;
|
||||
}
|
||||
if x <= -3 {
|
||||
packed = (packed << 8u) | filler;
|
||||
}
|
||||
} else if x > width - 4 {
|
||||
let filler = packed & 0xff000000u;
|
||||
if x >= width - 3 {
|
||||
packed = (packed >> 8u) | filler;
|
||||
}
|
||||
if x >= width - 2 {
|
||||
packed = (packed >> 8u) | filler;
|
||||
}
|
||||
if x >= width - 1 {
|
||||
packed = (packed >> 8u) | filler;
|
||||
}
|
||||
}
|
||||
return packed;
|
||||
}
|
||||
|
||||
fn load_packed(t: texture_2d<f32>, p: vec2<i32>) -> u32 {
|
||||
let width = params.display.x >> params.level;
|
||||
let height = params.display.y >> params.level;
|
||||
let q = vec2(clamp(p.x, 0, width - 4), clamp(p.y, 0, height - 1));
|
||||
return packed_luma(
|
||||
width,
|
||||
p.x,
|
||||
load_luma(t, q),
|
||||
load_luma(t, q + vec2(1, 0)),
|
||||
load_luma(t, q + vec2(2, 0)),
|
||||
load_luma(t, q + vec2(3, 0)),
|
||||
);
|
||||
}
|
||||
|
||||
fn sad(a: u32, b: u32) -> u32 {
|
||||
return u32(abs(i32(a & 0xffu) - i32(b & 0xffu))
|
||||
+ abs(i32((a >> 8u) & 0xffu) - i32((b >> 8u) & 0xffu))
|
||||
+ abs(i32((a >> 16u) & 0xffu) - i32((b >> 16u) & 0xffu))
|
||||
+ abs(i32((a >> 24u) & 0xffu) - i32((b >> 24u) & 0xffu)));
|
||||
}
|
||||
|
||||
fn qsad(a0_in: u32, a1_in: u32, b: u32) -> vec4<u32> {
|
||||
var a0 = a0_in;
|
||||
var a1 = a1_in;
|
||||
var s: vec4<u32>;
|
||||
s.x = sad(a0, b);
|
||||
a0 = (a0 >> 8u) | ((a1 & 0xffu) << 24u);
|
||||
a1 = a1 >> 8u;
|
||||
s.y = sad(a0, b);
|
||||
a0 = (a0 >> 8u) | ((a1 & 0xffu) << 24u);
|
||||
a1 = a1 >> 8u;
|
||||
s.z = sad(a0, b);
|
||||
a0 = (a0 >> 8u) | ((a1 & 0xffu) << 24u);
|
||||
s.w = sad(a0, b);
|
||||
return s;
|
||||
}
|
||||
|
||||
fn is_scene_changed() -> bool {
|
||||
if params.frame_index <= 5u {
|
||||
return true;
|
||||
}
|
||||
return (atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) & 0xfu) != 0u;
|
||||
}
|
||||
|
||||
// ffx_opticalflow_prepare_luma.h, for an SDR back buffer (transfer function 0: Horizon's
|
||||
// frames); the HDR transfer functions follow AMD's.
|
||||
fn luminance_to_perceived(luminance: f32) -> f32 {
|
||||
var perceived: f32;
|
||||
if luminance <= 216.0 / 24389.0 {
|
||||
perceived = luminance * (24389.0 / 27.0);
|
||||
} else {
|
||||
perceived = pow(luminance, 1.0 / 3.0) * 116.0 - 16.0;
|
||||
}
|
||||
return perceived * 0.01;
|
||||
}
|
||||
|
||||
fn linear_from_pq(x: vec3<f32>) -> vec3<f32> {
|
||||
let p = pow(x, vec3(0.0126833));
|
||||
return pow(clamp(p - vec3(0.835938), vec3(0.0), vec3(1.0)) / (vec3(18.8516) - 18.6875 * p), vec3(6.27739));
|
||||
}
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn prepare_luma(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
for (var y = 0; y < 2; y++) {
|
||||
for (var x = 0; x < 2; x++) {
|
||||
let pos = vec2<i32>(gid.xy) * 2 + vec2(x, y);
|
||||
if any(pos >= params.display) {
|
||||
continue;
|
||||
}
|
||||
let c = textureLoad(input_color, pos, 0).rgb;
|
||||
var y_lum = 0.0;
|
||||
if params.transfer_function == 0u {
|
||||
y_lum = dot(c, vec3(0.2126, 0.7152, 0.0722));
|
||||
} else if params.transfer_function == 1u {
|
||||
let linear = linear_from_pq(c) * (10000.0 / params.min_max_luminance.y);
|
||||
y_lum = luminance_to_perceived(dot(linear, vec3(0.2627, 0.678, 0.0593)));
|
||||
} else if params.transfer_function == 2u {
|
||||
let mn = params.min_max_luminance.x;
|
||||
let mx = params.min_max_luminance.y;
|
||||
let linear = (c - vec3(mn / 80.0)) / vec3((mx - mn) / 80.0);
|
||||
y_lum = luminance_to_perceived(dot(linear, vec3(0.2126, 0.7152, 0.0722)));
|
||||
}
|
||||
textureStore(luma_out, pos, vec4(f32(u32(y_lum * 255.0)), 0.0, 0.0, 0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ffx_opticalflow_compute_luminance_pyramid.h: SPD's average of 2x2, a level per pass.
|
||||
@compute @workgroup_size(8, 8)
|
||||
fn luma_pyramid(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if any(p >= vec2<i32>(textureDimensions(luma_out))) {
|
||||
return;
|
||||
}
|
||||
let s = p * 2;
|
||||
let v = (textureLoad(luma_in, s, 0).x + textureLoad(luma_in, s + vec2(1, 0), 0).x
|
||||
+ textureLoad(luma_in, s + vec2(0, 1), 0).x + textureLoad(luma_in, s + vec2(1, 1), 0).x) * 0.25;
|
||||
textureStore(luma_out, p, vec4(v, 0.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
// ffx_opticalflow_generate_scd_histogram.h
|
||||
const LBASE: u32 = 10u;
|
||||
var<workgroup> scd_buffer: array<atomic<u32>, 2560>;
|
||||
|
||||
@compute @workgroup_size(32, 8)
|
||||
fn scd_histogram_pass(
|
||||
@builtin(global_invocation_id) gid: vec3<u32>,
|
||||
@builtin(local_invocation_index) local_index: u32,
|
||||
) {
|
||||
let histograms_per_dim = 3u;
|
||||
let div = vec2<u32>(params.display) / histograms_per_dim;
|
||||
let nx = gid.z % histograms_per_dim;
|
||||
let ny = gid.z / histograms_per_dim;
|
||||
let start = div * vec2(nx, ny);
|
||||
let stop = start + div;
|
||||
let offset = local_index * LBASE;
|
||||
for (var i = 0u; i < LBASE; i++) {
|
||||
atomicStore(&scd_buffer[offset + i], 0u);
|
||||
}
|
||||
workgroupBarrier();
|
||||
var coord = vec2<i32>(i32(start.x + 4u * gid.x), i32(start.y + gid.y));
|
||||
if u32(coord.x) < stop.x {
|
||||
for (; u32(coord.y) < stop.y; coord.y += 128) {
|
||||
let color = vec4(
|
||||
load_luma(luma_in, coord),
|
||||
load_luma(luma_in, coord + vec2(1, 0)),
|
||||
load_luma(luma_in, coord + vec2(2, 0)),
|
||||
load_luma(luma_in, coord + vec2(3, 0)),
|
||||
) * LBASE;
|
||||
let scrambling = local_index % LBASE;
|
||||
atomicAdd(&scd_buffer[color.x + scrambling], 1u);
|
||||
atomicAdd(&scd_buffer[color.y + scrambling], 1u);
|
||||
atomicAdd(&scd_buffer[color.z + scrambling], 1u);
|
||||
atomicAdd(&scd_buffer[color.w + scrambling], 1u);
|
||||
}
|
||||
}
|
||||
workgroupBarrier();
|
||||
var value = 0u;
|
||||
for (var i = 0u; i < LBASE; i++) {
|
||||
value += atomicLoad(&scd_buffer[offset + i]);
|
||||
}
|
||||
let histogram_start = 256u * gid.z;
|
||||
atomicAdd(&scd_histogram[histogram_start + local_index], value);
|
||||
}
|
||||
|
||||
// ffx_opticalflow_compute_scd_divergence.h
|
||||
var<workgroup> source_histogram: array<f32, 256>;
|
||||
var<workgroup> filtered_histogram: array<f32, 256>;
|
||||
var<workgroup> temp_buffer: array<f32, 256>;
|
||||
var<workgroup> temp_buffer2: array<vec2<f32>, 256>;
|
||||
|
||||
@compute @workgroup_size(256)
|
||||
fn scd_divergence_pass(
|
||||
@builtin(global_invocation_id) gid: vec3<u32>,
|
||||
@builtin(local_invocation_index) local_index: u32,
|
||||
) {
|
||||
let factor = 1000000.0;
|
||||
let where_to_stop = 3u * 9u - 1u;
|
||||
let histogram_count = 9.0;
|
||||
let kernel = array(0.0088122291, 0.027143577, 0.065114059, 0.12164907, 0.17699835, 0.20056541);
|
||||
let li = i32(local_index);
|
||||
source_histogram[li] = f32(atomicLoad(&scd_histogram[gid.x]));
|
||||
workgroupBarrier();
|
||||
let read = li - 5;
|
||||
var val = 0.0;
|
||||
val += kernel[0] * source_histogram[clamp(read + 0, 0, 255)];
|
||||
val += kernel[1] * source_histogram[clamp(read + 1, 0, 255)];
|
||||
val += kernel[2] * source_histogram[clamp(read + 2, 0, 255)];
|
||||
val += kernel[3] * source_histogram[clamp(read + 3, 0, 255)];
|
||||
val += kernel[4] * source_histogram[clamp(read + 4, 0, 255)];
|
||||
val += kernel[5] * source_histogram[clamp(read + 5, 0, 255)];
|
||||
val += kernel[4] * source_histogram[clamp(read + 6, 0, 255)];
|
||||
val += kernel[3] * source_histogram[clamp(read + 7, 0, 255)];
|
||||
val += kernel[2] * source_histogram[clamp(read + 8, 0, 255)];
|
||||
val += kernel[1] * source_histogram[clamp(read + 9, 0, 255)];
|
||||
val += kernel[0] * source_histogram[clamp(read + 10, 0, 255)];
|
||||
val += 1.0;
|
||||
if gid.y == 0u {
|
||||
if li == 0 {
|
||||
filtered_histogram[255] = 1.0;
|
||||
} else {
|
||||
filtered_histogram[li - 1] = val;
|
||||
}
|
||||
} else if gid.y == 1u {
|
||||
filtered_histogram[li] = val;
|
||||
} else if gid.y == 2u {
|
||||
if li == 255 {
|
||||
filtered_histogram[0] = 1.0;
|
||||
} else {
|
||||
filtered_histogram[li + 1] = val;
|
||||
}
|
||||
}
|
||||
workgroupBarrier();
|
||||
temp_buffer[li] = filtered_histogram[li];
|
||||
workgroupBarrier();
|
||||
for (var stride = 128; stride > 0; stride >>= 1u) {
|
||||
if li < stride {
|
||||
temp_buffer[li] += temp_buffer[li + stride];
|
||||
}
|
||||
workgroupBarrier();
|
||||
}
|
||||
filtered_histogram[li] /= temp_buffer[0];
|
||||
let current = filtered_histogram[li];
|
||||
let previous = scd_previous_histogram[gid.x];
|
||||
temp_buffer2[li] = vec2(current * log(current / previous), previous * log(previous / current));
|
||||
workgroupBarrier();
|
||||
for (var stride = 128; stride > 0; stride >>= 1u) {
|
||||
if li < stride {
|
||||
temp_buffer2[li] += temp_buffer2[li + stride];
|
||||
}
|
||||
workgroupBarrier();
|
||||
}
|
||||
if li == 0 {
|
||||
let sum = temp_buffer2[0];
|
||||
let res_float = 1.0 - exp(-(abs(sum.x) + abs(sum.y)));
|
||||
let res_uint = u32((res_float / histogram_count) * factor);
|
||||
atomicAdd(&scd_temp[gid.y], res_uint);
|
||||
let finished = atomicAdd(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 1u);
|
||||
if finished == where_to_stop {
|
||||
let res0 = atomicLoad(&scd_temp[0]);
|
||||
let res1 = atomicLoad(&scd_temp[1]);
|
||||
let res2 = atomicLoad(&scd_temp[2]);
|
||||
let scene_change = f32(min(res0, min(res1, res2))) / factor;
|
||||
var history = atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) << 1u;
|
||||
if scene_change > 0.45 {
|
||||
history |= 1u;
|
||||
}
|
||||
atomicStore(&scd_output[SCD_OUTPUT_SCENE_CHANGE_SLOT], bitcast<u32>(scene_change));
|
||||
atomicStore(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT], history);
|
||||
atomicStore(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 0u);
|
||||
atomicStore(&scd_temp[0], 0u);
|
||||
atomicStore(&scd_temp[1], 0u);
|
||||
atomicStore(&scd_temp[2], 0u);
|
||||
}
|
||||
}
|
||||
if gid.y == 1u {
|
||||
scd_previous_histogram[gid.x] = current;
|
||||
atomicStore(&scd_histogram[gid.x], 0u);
|
||||
}
|
||||
}
|
||||
|
||||
// ffx_opticalflow_compute_optical_flow_v5.h: block matching of 8x8 blocks over +-8 pixels,
|
||||
// 2x2 blocks per workgroup of 64.
|
||||
var<workgroup> pixels: array<array<u32, 2>, 8>;
|
||||
// SearchBufferSizeY (24) rows of SearchBufferSizeX (6) packed lumas.
|
||||
var<workgroup> search_buffer: array<u32, 144>;
|
||||
var<workgroup> sad_map: array<array<array<u32, 5>, 16>, 4>;
|
||||
var<workgroup> wave_min: atomic<u32>;
|
||||
var<workgroup> wave_sum: atomic<u32>;
|
||||
var<workgroup> scene_changed: u32;
|
||||
|
||||
fn abs_2(v: vec2<i32>) -> vec2<u32> {
|
||||
let mask = v >> vec2(31u);
|
||||
return vec2<u32>((v + mask) ^ mask);
|
||||
}
|
||||
|
||||
// FFX_OPTICALFLOW_FIX_TOP_LEFT_BIAS: the nearest of equal SADs wins.
|
||||
fn encode_search_coord(coord: vec2<i32>) -> u32 {
|
||||
let a = abs_2(coord - 8);
|
||||
return (a.y << 12u) | (a.x << 8u) | (u32(coord.y) << 4u) | u32(coord.x);
|
||||
}
|
||||
|
||||
fn decode_search_coord(bits: u32) -> vec2<i32> {
|
||||
return vec2(i32(bits & 0xfu) - 8, i32((bits >> 4u) & 0xfu) - 8);
|
||||
}
|
||||
|
||||
fn calculate_qsads2(search_id: vec2<i32>) -> vec4<u32> {
|
||||
var s = vec4(0u);
|
||||
for (var dy = 0; dy < 8; dy++) {
|
||||
let row = (search_id.y + dy) * 6;
|
||||
let a0 = search_buffer[row + search_id.x];
|
||||
let a1 = search_buffer[row + search_id.x + 1];
|
||||
let a2 = search_buffer[row + search_id.x + 2];
|
||||
s += qsad(a0, a1, pixels[dy][0]);
|
||||
s += qsad(a1, a2, pixels[dy][1]);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn search_pass(
|
||||
@builtin(workgroup_id) group_id: vec3<u32>,
|
||||
@builtin(local_invocation_index) local_index: u32,
|
||||
) {
|
||||
let li = i32(local_index);
|
||||
let search_id = vec2(li & 3, (li >> 2u) & 15);
|
||||
let lane_block = ((li >> 1u) & 1) | (((li >> 5u) & 1) << 1u);
|
||||
let group = vec2<i32>(group_id.xy);
|
||||
let px = (group << vec2(4u)) + search_id * vec2(4, 1);
|
||||
if local_index == 0u {
|
||||
scene_changed = u32(is_scene_changed());
|
||||
}
|
||||
if workgroupUniformLoad(&scene_changed) != 0u {
|
||||
if (search_id.y & 7) == 0 && (search_id.x & 1) == 0 {
|
||||
store_flow(px >> vec2(3u), vec2(0));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let use_prediction = params.level != params.level_count - 1u;
|
||||
let packed = load_packed(luma_in, px);
|
||||
let sad_4blocks = sad(packed, load_packed(luma_previous, px));
|
||||
let of_group = group << vec2(1u);
|
||||
let pixel_group = group << vec2(4u);
|
||||
for (var by = 0; by < 2; by++) {
|
||||
for (var bx = 0; bx < 2; bx++) {
|
||||
let block = vec2(bx, by);
|
||||
var current = vec2(0);
|
||||
if use_prediction {
|
||||
current = load_flow(of_group + block);
|
||||
}
|
||||
if lane_block == by * 2 + bx {
|
||||
pixels[search_id.y & 7][search_id.x & 1] = packed;
|
||||
}
|
||||
// LoadSearchBuffer
|
||||
let base = pixel_group + block * 8 + current - vec2(8, 8);
|
||||
for (var id = li; id < 144; id += 64) {
|
||||
search_buffer[id] = load_packed(luma_previous, vec2(base.x + (id % 6) * 4, base.y + id / 6));
|
||||
}
|
||||
if local_index == 0u {
|
||||
atomicStore(&wave_min, 0xffffffffu);
|
||||
atomicStore(&wave_sum, 0u);
|
||||
}
|
||||
workgroupBarrier();
|
||||
let q = calculate_qsads2(search_id);
|
||||
// PrepareSadMap
|
||||
let sx = search_id.x;
|
||||
let sy = search_id.y;
|
||||
sad_map[0][sy][sx] = (q.x << 16u) | encode_search_coord(vec2(sx * 4 + 0, sy));
|
||||
sad_map[1][sy][sx] = (q.y << 16u) | encode_search_coord(vec2(sx * 4 + 1, sy));
|
||||
sad_map[2][sy][sx] = (q.z << 16u) | encode_search_coord(vec2(sx * 4 + 2, sy));
|
||||
sad_map[3][sy][sx] = (q.w << 16u) | encode_search_coord(vec2(sx * 4 + 3, sy));
|
||||
workgroupBarrier();
|
||||
// SadMapMinReduction256 and BlockSad64 (FFX_LOCAL_SEARCH_FALLBACK)
|
||||
let min01 = min(sad_map[0][sy][sx], sad_map[1][sy][sx]);
|
||||
let min23 = min(sad_map[2][sy][sx], sad_map[3][sy][sx]);
|
||||
atomicMin(&wave_min, min(min01, min23));
|
||||
atomicAdd(&wave_sum, select(0u, sad_4blocks, lane_block == bx + by * 2));
|
||||
workgroupBarrier();
|
||||
let min_sad = atomicLoad(&wave_min);
|
||||
let block_sad_sum = atomicLoad(&wave_sum);
|
||||
var v = current + decode_search_coord(min_sad);
|
||||
if params.level == 0u && block_sad_sum <= (min_sad >> 16u) {
|
||||
v = vec2(0);
|
||||
}
|
||||
if local_index == 0u {
|
||||
store_flow(of_group + block, v);
|
||||
}
|
||||
// The next block reuses the shared buffers and counters.
|
||||
workgroupBarrier();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ffx_opticalflow_filter_optical_flow_v5.h: the vector of the 3x3 nearest to all the others.
|
||||
@compute @workgroup_size(16, 4)
|
||||
fn filter_flow(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
var mv: array<vec2<i32>, 9>;
|
||||
var idx = 0;
|
||||
for (var xx = -1; xx < 2; xx++) {
|
||||
for (var yy = -1; yy < 2; yy++) {
|
||||
mv[idx] = load_flow(p + vec2(xx, yy));
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
var ret = 0xffffffffu;
|
||||
for (var i = 0; i < 9; i++) {
|
||||
var tmp = 0u;
|
||||
for (var j = 0; j < 9; j++) {
|
||||
let delta = mv[i] - mv[j];
|
||||
tmp = u32(delta.x * delta.x) + (u32(delta.y * delta.y) + tmp);
|
||||
}
|
||||
ret = min((tmp << 4u) | u32(i), ret);
|
||||
}
|
||||
store_flow(p, mv[ret & 0xfu]);
|
||||
}
|
||||
|
||||
// ffx_opticalflow_scale_optical_flow_advanced_v5.h: the next level's vectors, each the
|
||||
// best of its 4 nearest at this level.
|
||||
var<workgroup> nearest_vectors: array<array<array<vec2<i32>, 4>, 4>, 4>;
|
||||
var<workgroup> local_region: array<array<array<u32, 4>, 4>, 4>;
|
||||
var<workgroup> sads: array<array<array<u32, 4>, 4>, 4>;
|
||||
|
||||
@compute @workgroup_size(4, 4, 4)
|
||||
fn scale_pass(
|
||||
@builtin(global_invocation_id) gid: vec3<u32>,
|
||||
@builtin(local_invocation_id) lid: vec3<u32>,
|
||||
@builtin(local_invocation_index) local_index: u32,
|
||||
) {
|
||||
if local_index == 0u {
|
||||
scene_changed = u32(is_scene_changed());
|
||||
}
|
||||
if workgroupUniformLoad(&scene_changed) != 0u {
|
||||
store_flow(vec2<i32>(gid.xy), vec2(0));
|
||||
return;
|
||||
}
|
||||
let x_offset = i32(lid.z % 2u) - 1 + i32(gid.x % 2u);
|
||||
let y_offset = i32(lid.z / 2u) - 1 + i32(gid.y % 2u);
|
||||
let src = vec2(i32(gid.x / 2u) + x_offset, i32(gid.y / 2u) + y_offset);
|
||||
let nearest = load_flow(src);
|
||||
nearest_vectors[lid.z][lid.y][lid.x] = nearest * 2;
|
||||
let max_y = 4u;
|
||||
for (var n = lid.z; n < max_y; n += 4u) {
|
||||
let luma_pos = vec2(i32(gid.x) * 4, i32(gid.y * max_y + n));
|
||||
local_region[n][lid.y][lid.x] = load_packed(luma_in, luma_pos);
|
||||
}
|
||||
workgroupBarrier();
|
||||
var s = 0u;
|
||||
for (var n = 0u; n < max_y; n++) {
|
||||
let luma_pos = vec2(i32(gid.x) * 4, i32(gid.y * max_y + n)) + nearest;
|
||||
s += sad(local_region[n][lid.y][lid.x], load_packed(luma_previous, luma_pos));
|
||||
}
|
||||
sads[lid.z][lid.y][lid.x] = s;
|
||||
workgroupBarrier();
|
||||
if lid.z == 0u {
|
||||
var best_sad = 0xffffffffu;
|
||||
var best = 0u;
|
||||
for (var n = 0u; n < 4u; n++) {
|
||||
if sads[n][lid.y][lid.x] < best_sad {
|
||||
best_sad = sads[n][lid.y][lid.x];
|
||||
best = n;
|
||||
}
|
||||
}
|
||||
store_flow(vec2<i32>(gid.xy), nearest_vectors[best][lid.y][lid.x]);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
use super::{Frustum, Renderer, SectionGpu};
|
||||
use crate::{
|
||||
fast_hash::{FxHashMap, FxHashSet},
|
||||
lod::TileKey,
|
||||
lod::{MAX_LEVEL, TileKey},
|
||||
mesher::SectionMesh,
|
||||
};
|
||||
use glam::{DVec3, Vec3};
|
||||
@@ -45,6 +45,47 @@ impl Pending {
|
||||
pub(super) struct Tile {
|
||||
pub mesh: SectionGpu,
|
||||
bounds: Bounds,
|
||||
/// When it began to fade in; none while the rest of its job uploads.
|
||||
shown: Option<Instant>,
|
||||
/// When its replacements began to fade in; it goes once they have.
|
||||
dying: Option<Instant>,
|
||||
/// Dying with nothing to replace it (out of range): it fades out.
|
||||
alone: bool,
|
||||
/// Its mesh before near chunks took some of its columns, drawn in its
|
||||
/// place until they have faded in (from the job's end, the instant), so
|
||||
/// no hole shows through them.
|
||||
previous: Option<(SectionGpu, Option<Instant>)>,
|
||||
}
|
||||
|
||||
/// How long a tile takes to fade in over the ones it replaces.
|
||||
pub(super) const FADE: Duration = Duration::from_millis(600);
|
||||
|
||||
/// A tile or section fading in: 0 drawn whole, else 1 + `level` * 129 +
|
||||
/// `shown`, `shown` 1 to 63 sixty-fourths of `FADE` (`fade_out_code`: 65 to
|
||||
/// 128, fading out). Chunk by chunk (columns of its own grid, wider for
|
||||
/// coarse tiles: 2^`level` blocks), each starting in turn over the first half
|
||||
/// and fading over the second, as a fine ordered pattern fixed to the terrain
|
||||
/// (terrain.wgsl's `faded`). It is drawn a hair toward the camera, the newest
|
||||
/// most, over the solid tiles it replaces, which stay until it is whole
|
||||
/// (`upload_lods`): no pixel fought over, no hole. None: nothing to draw yet.
|
||||
pub(super) fn fade_code(shown: Option<Instant>, level: u8, now: Instant) -> Option<u32> {
|
||||
let faded = now.saturating_duration_since(shown?).as_secs_f32() / FADE.as_secs_f32();
|
||||
let shown = (faded * 64.0) as u32 + 1;
|
||||
Some(if shown >= 64 {
|
||||
0
|
||||
} else {
|
||||
1 + u32::from(level) * 129 + shown
|
||||
})
|
||||
}
|
||||
/// A section past the render distance that far terrain is coming in under:
|
||||
/// whole, a fading-out code at its start (terrain.wgsl's `fade_depth` draws
|
||||
/// leaving sections in front of far terrain).
|
||||
pub(super) const HELD: u32 = 1 + 64;
|
||||
/// `fade_code` for one leaving since `since`, chunk by chunk as it came, over
|
||||
/// what stays behind it; once `FADE` has passed it draws nothing.
|
||||
pub(super) fn fade_out_code(since: Instant, level: u8, now: Instant) -> u32 {
|
||||
let faded = now.saturating_duration_since(since).as_secs_f32() / FADE.as_secs_f32();
|
||||
1 + u32::from(level) * 129 + 64 + ((faded * 64.0) as u32 + 1).min(64)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -72,13 +113,13 @@ impl Bounds {
|
||||
Some(bounds)
|
||||
}
|
||||
|
||||
fn visible(self, origin: DVec3, distance: f32, frustum: &Frustum) -> Option<u64> {
|
||||
/// In view: the plan alone decides which tiles are in range (a circle),
|
||||
/// so a tile leaving it fades out (`upload_lods`). Cut here at the range,
|
||||
/// tiles popped out as the player moved, before the plan dropped them.
|
||||
fn visible(self, origin: DVec3, frustum: &Frustum) -> Option<u64> {
|
||||
let min = origin + self.min.as_dvec3();
|
||||
let max = origin + self.max.as_dvec3();
|
||||
let nearest = DVec3::ZERO.clamp(min, max);
|
||||
if nearest.x * nearest.x + nearest.z * nearest.z > f64::from(distance).powi(2)
|
||||
|| !frustum.intersects(min.as_vec3(), max.as_vec3())
|
||||
{
|
||||
if !frustum.intersects(min.as_vec3(), max.as_vec3()) {
|
||||
return None;
|
||||
}
|
||||
Some(((min + max) * 0.5).length_squared().to_bits())
|
||||
@@ -86,12 +127,19 @@ impl Bounds {
|
||||
}
|
||||
|
||||
/// A tile's instance: like a section's, with the UV step of merged faces.
|
||||
pub(super) fn instance(origin: [f32; 3]) -> [f32; 4] {
|
||||
/// A fading tile's step is negated past its `fade_code`, which the shaders
|
||||
/// read back as the whole part (they take the step as `fract(abs(w))`).
|
||||
pub(super) fn instance(origin: [f32; 3], fade: u32) -> [f32; 4] {
|
||||
let step = 1.0 / crate::mesher::TILED_UV_STEPS;
|
||||
[
|
||||
origin[0],
|
||||
origin[1],
|
||||
origin[2],
|
||||
1.0 / crate::mesher::TILED_UV_STEPS,
|
||||
if fade == 0 {
|
||||
step
|
||||
} else {
|
||||
-(fade as f32 + step)
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -99,45 +147,119 @@ pub(super) struct Visible {
|
||||
pub key: TileKey,
|
||||
pub distance: u64,
|
||||
pub origin: [f32; 3],
|
||||
pub fade: u32,
|
||||
/// Replaced, standing until its replacements are whole: its water goes
|
||||
/// after theirs (`Renderer::render`'s water tiers), so depth leaves it only
|
||||
/// where they have none yet.
|
||||
pub old: bool,
|
||||
}
|
||||
|
||||
impl Tile {
|
||||
/// The mesh drawn this frame: the previous one while it stands in.
|
||||
pub fn drawn(&self) -> &SectionGpu {
|
||||
self.previous.as_ref().map_or(&self.mesh, |(mesh, _)| mesh)
|
||||
}
|
||||
pub fn drawn_mut(&mut self) -> &mut SectionGpu {
|
||||
match &mut self.previous {
|
||||
Some((mesh, _)) => mesh,
|
||||
None => &mut self.mesh,
|
||||
}
|
||||
}
|
||||
pub fn visible(
|
||||
&self,
|
||||
key: TileKey,
|
||||
camera: DVec3,
|
||||
distance: f32,
|
||||
frustum: &Frustum,
|
||||
now: Instant,
|
||||
) -> Option<Visible> {
|
||||
let fade = match self.dying {
|
||||
Some(since) if self.alone => fade_out_code(since, key.level, now),
|
||||
_ => fade_code(self.shown, key.level, now)?,
|
||||
};
|
||||
let origin = DVec3::from(key.origin().map(f64::from)) - camera;
|
||||
Some(Visible {
|
||||
key,
|
||||
distance: self.bounds.visible(origin, distance, frustum)?,
|
||||
distance: self.bounds.visible(origin, frustum)?,
|
||||
origin: origin.as_vec3().to_array(),
|
||||
fade,
|
||||
old: self.dying.is_some() || self.previous.is_some(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
pub fn upload_lod(&mut self, key: TileKey, mesh: &SectionMesh) {
|
||||
self.remove_lod(key);
|
||||
if let Some(bounds) = Bounds::mesh(mesh)
|
||||
&& let Some(mesh) = self.upload_mesh(mesh)
|
||||
{
|
||||
self.lods.insert(key, Tile { mesh, bounds });
|
||||
/// A tile meshed again (finer detail) takes its place at once, but for
|
||||
/// one meshed around near chunks or finer tiles that are new (`keep`):
|
||||
/// its old mesh stands in while they fade in. A new one waits for the rest of its job
|
||||
/// (`upload_lods`), then fades in.
|
||||
pub fn upload_lod(&mut self, key: TileKey, mesh: &SectionMesh, keep: bool) {
|
||||
let (shown, previous) = match self.lods.remove(&key) {
|
||||
Some(old) if old.dying.is_none() => match old.previous {
|
||||
Some(previous) => {
|
||||
self.release_mesh(old.mesh);
|
||||
(old.shown, Some(previous))
|
||||
}
|
||||
None if keep => (old.shown, Some((old.mesh, None))),
|
||||
None => {
|
||||
self.release_mesh(old.mesh);
|
||||
(old.shown, None)
|
||||
}
|
||||
},
|
||||
Some(old) => {
|
||||
self.release_tile(old);
|
||||
(None, None)
|
||||
}
|
||||
None => (None, None),
|
||||
};
|
||||
match Bounds::mesh(mesh).zip(self.upload_mesh(mesh)) {
|
||||
Some((bounds, mesh)) => {
|
||||
self.lods.insert(
|
||||
key,
|
||||
Tile {
|
||||
mesh,
|
||||
bounds,
|
||||
shown,
|
||||
dying: None,
|
||||
alone: false,
|
||||
previous,
|
||||
},
|
||||
);
|
||||
}
|
||||
None => {
|
||||
if let Some((previous, _)) = previous {
|
||||
self.release_mesh(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn release_tile(&mut self, tile: Tile) {
|
||||
self.release_mesh(tile.mesh);
|
||||
if let Some((previous, _)) = tile.previous {
|
||||
self.release_mesh(previous);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_lod(&mut self, key: TileKey) {
|
||||
if let Some(tile) = self.lods.remove(&key) {
|
||||
self.release_mesh(tile.mesh);
|
||||
self.release_tile(tile);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_lods(&mut self) {
|
||||
self.lod_pending = None;
|
||||
// No far terrain left to fade out over.
|
||||
for (key, since) in std::mem::take(&mut self.lod_leaving) {
|
||||
if self
|
||||
.sections
|
||||
.find(key)
|
||||
.is_some_and(|s| s.leaving == Some(since))
|
||||
{
|
||||
self.release_section(key);
|
||||
}
|
||||
}
|
||||
for (_, tile) in std::mem::take(&mut self.lods) {
|
||||
self.release_mesh(tile.mesh);
|
||||
self.release_tile(tile);
|
||||
}
|
||||
self.set_lod_cutout(Default::default());
|
||||
}
|
||||
@@ -160,14 +282,134 @@ impl Renderer {
|
||||
}
|
||||
|
||||
/// Uploads queued tile meshes for about `budget` (at least one), then,
|
||||
/// once all are in, retires the tiles they replace.
|
||||
/// once all are in, fades them in over the tiles they replace.
|
||||
pub fn upload_lods(&mut self, budget: Duration) {
|
||||
let start = Instant::now();
|
||||
self.release_left(start);
|
||||
let faded: Vec<_> = self
|
||||
.lods
|
||||
.iter()
|
||||
.filter(|(_, tile)| tile.dying.is_some_and(|d| start - d >= FADE))
|
||||
.map(|(&key, _)| key)
|
||||
.collect();
|
||||
for key in faded {
|
||||
self.remove_lod(key);
|
||||
}
|
||||
let stood_in: Vec<_> = self
|
||||
.lods
|
||||
.values_mut()
|
||||
.filter_map(|tile| match tile.previous {
|
||||
Some((_, Some(since))) if start - since >= FADE => tile.previous.take(),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for (previous, _) in stood_in {
|
||||
self.release_mesh(previous);
|
||||
}
|
||||
// The columns near chunks take in this job, and the tiles holding the
|
||||
// job's new finer ones: a tile meshed again around them keeps its
|
||||
// old mesh until they are in, so no hole shows through them.
|
||||
let mut around_new = FxHashSet::default();
|
||||
let new = self
|
||||
.lod_pending
|
||||
.iter()
|
||||
.flat_map(|pending| pending.meshes.keys())
|
||||
.filter(|key| !self.lods.contains_key(key))
|
||||
.chain(
|
||||
self.lods
|
||||
.iter()
|
||||
.filter(|(_, tile)| tile.shown.is_none())
|
||||
.map(|(key, _)| key),
|
||||
);
|
||||
for &key in new {
|
||||
let mut key = key;
|
||||
while key.level < MAX_LEVEL && around_new.insert(key.parent()) {
|
||||
key = key.parent();
|
||||
}
|
||||
}
|
||||
let taken: Vec<_> = self
|
||||
.lod_pending
|
||||
.iter()
|
||||
.flat_map(|pending| pending.cutout.iter().flatten())
|
||||
.filter(|column| !self.lod_cutout.contains(column))
|
||||
.copied()
|
||||
.collect();
|
||||
while let Some(pending) = &mut self.lod_pending {
|
||||
let Some(&key) = pending.meshes.keys().next() else {
|
||||
let pending = self.lod_pending.take().expect("pending tiles");
|
||||
// A tile fades in over the ones it replaces. One over nothing
|
||||
// shows whole: its hidden chunks would open holes to the sky
|
||||
// or the sea bed (dark patches, as far terrain first loads).
|
||||
let replaced: FxHashSet<TileKey> = pending
|
||||
.removed
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|key| self.lods.contains_key(key))
|
||||
.collect();
|
||||
let mut above_replaced = FxHashSet::default();
|
||||
for &key in &replaced {
|
||||
let mut key = key;
|
||||
while key.level < MAX_LEVEL && above_replaced.insert(key.parent()) {
|
||||
key = key.parent();
|
||||
}
|
||||
}
|
||||
let over_replaced = |key: TileKey| {
|
||||
let mut ancestor = key;
|
||||
replaced.contains(&key)
|
||||
|| above_replaced.contains(&key)
|
||||
|| (key.level..MAX_LEVEL).any(|_| {
|
||||
ancestor = ancestor.parent();
|
||||
replaced.contains(&ancestor)
|
||||
})
|
||||
};
|
||||
// A removed tile no new tile overlaps, and no near chunk takes
|
||||
// a column of, leaves the plan (out of range): it fades out.
|
||||
// Over near chunks fading in it stays whole: two patterns
|
||||
// fading at once left holes between them.
|
||||
// Two tiles overlap when one is the other or its ancestor:
|
||||
// `kept` holds the new tiles and the near chunks' columns
|
||||
// (level 0), `above` their ancestors.
|
||||
let mut kept: FxHashSet<TileKey> = self
|
||||
.lods
|
||||
.iter()
|
||||
.filter(|(_, tile)| tile.shown.is_none())
|
||||
.map(|(&key, _)| key)
|
||||
.collect();
|
||||
let cut = pending.cutout.as_ref().unwrap_or(&self.lod_cutout);
|
||||
kept.extend(cut.iter().map(|&(x, z)| TileKey { x, z, level: 0 }));
|
||||
let mut above = FxHashSet::default();
|
||||
for &key in &kept {
|
||||
let mut key = key;
|
||||
while key.level < MAX_LEVEL && above.insert(key.parent()) {
|
||||
key = key.parent();
|
||||
}
|
||||
}
|
||||
let alone = |key: TileKey| {
|
||||
let mut ancestor = key;
|
||||
!kept.contains(&key)
|
||||
&& !above.contains(&key)
|
||||
&& !(key.level..MAX_LEVEL).any(|_| {
|
||||
ancestor = ancestor.parent();
|
||||
kept.contains(&ancestor)
|
||||
})
|
||||
};
|
||||
let whole = start.checked_sub(FADE).unwrap_or(start);
|
||||
for (&key, tile) in &mut self.lods {
|
||||
if tile.shown.is_none() {
|
||||
tile.shown = Some(if over_replaced(key) { start } else { whole });
|
||||
}
|
||||
if let Some((_, since)) = &mut tile.previous {
|
||||
since.get_or_insert(start);
|
||||
}
|
||||
}
|
||||
for key in pending.removed {
|
||||
self.remove_lod(key);
|
||||
let alone = alone(key);
|
||||
if let Some(tile) = self.lods.get_mut(&key)
|
||||
&& tile.dying.is_none()
|
||||
{
|
||||
tile.dying = Some(start);
|
||||
tile.alone = alone;
|
||||
}
|
||||
}
|
||||
if let Some(cutout) = pending.cutout {
|
||||
self.set_lod_cutout(cutout);
|
||||
@@ -175,7 +417,13 @@ impl Renderer {
|
||||
return;
|
||||
};
|
||||
let mesh = pending.meshes.remove(&key).expect("pending tile");
|
||||
self.upload_lod(key, &mesh);
|
||||
let [x, _, z] = key.origin();
|
||||
let size = key.size();
|
||||
let keep = around_new.contains(&key)
|
||||
|| taken.iter().any(|&(cx, cz)| {
|
||||
(x..x + size).contains(&(cx * 16)) && (z..z + size).contains(&(cz * 16))
|
||||
});
|
||||
self.upload_lod(key, &mesh, keep);
|
||||
if start.elapsed() >= budget {
|
||||
return;
|
||||
}
|
||||
@@ -194,7 +442,7 @@ impl Renderer {
|
||||
.collect();
|
||||
for key in released {
|
||||
self.lod_retiring.remove(&key);
|
||||
self.release_section(key);
|
||||
self.leave_section(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,6 +452,36 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::mesher::TerrainVertex;
|
||||
|
||||
#[test]
|
||||
fn tiles_fade_in_on_their_own_grid() {
|
||||
let start = Instant::now();
|
||||
let at = |ms| start + Duration::from_millis(ms);
|
||||
// terrain.wgsl's `faded` reads the level and sixty-fourths back.
|
||||
let decode = |code: u32| ((code - 1) / 129, (code - 1) % 129);
|
||||
assert_eq!(fade_code(None, 0, at(0)), None, "waits for its job");
|
||||
assert_eq!(fade_code(Some(start), 3, at(0)).map(decode), Some((3, 1)));
|
||||
let mut last = 0;
|
||||
for ms in [0, 100, 300, 500, 590] {
|
||||
let (level, shown) = decode(fade_code(Some(start), 10, at(ms)).unwrap());
|
||||
assert_eq!(level, 10);
|
||||
assert!(shown > last && shown < 64, "{shown} at {ms} ms");
|
||||
last = shown;
|
||||
}
|
||||
assert_eq!(fade_code(Some(start), 10, at(600)), Some(0), "whole");
|
||||
// Fading out: 65 to 128, then nothing drawn (128) until released.
|
||||
let mut last = 64;
|
||||
for ms in [0, 100, 300, 500, 590] {
|
||||
let (level, out) = decode(fade_out_code(start, 3, at(ms)));
|
||||
assert_eq!(level, 3);
|
||||
assert!(out > last && out < 128, "{out} at {ms} ms");
|
||||
last = out;
|
||||
}
|
||||
assert_eq!(decode(fade_out_code(start, 3, at(700))), (3, 128));
|
||||
// A section's largest code keeps its UV step exact (terrain_uv).
|
||||
let w = 129.0f32 + 1.0 / 32768.0;
|
||||
assert_eq!(w.fract(), 1.0 / 32768.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joined_jobs_apply_as_if_in_turn() {
|
||||
let key = |x| TileKey { x, z: 0, level: 0 };
|
||||
@@ -244,16 +522,17 @@ mod tests {
|
||||
* crate::camera::view_matrix(0.0, 0.0),
|
||||
),
|
||||
};
|
||||
// The tile origin is farther than the range, but its far edge reaches
|
||||
// the camera. Negative world origins therefore remain visible.
|
||||
// The tile origin is far behind, but its far edge reaches the camera.
|
||||
// Negative world origins therefore remain visible.
|
||||
assert!(
|
||||
bounds
|
||||
.visible(DVec3::new(-1000.0, -80.0, -512.0), 256.0, &view)
|
||||
.visible(DVec3::new(-1000.0, -80.0, -512.0), &view)
|
||||
.is_some()
|
||||
);
|
||||
// Wholly behind the camera: out of view.
|
||||
assert!(
|
||||
bounds
|
||||
.visible(DVec3::new(-2000.0, -80.0, -512.0), 256.0, &view)
|
||||
.visible(DVec3::new(-512.0, -80.0, -2000.0), &view)
|
||||
.is_none()
|
||||
);
|
||||
assert!(Bounds::mesh(&SectionMesh::default()).is_none());
|
||||
|
||||
@@ -176,7 +176,7 @@ impl PackAtlas {
|
||||
hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n \
|
||||
hz_scale = vec2({:?}, {:?});\n \
|
||||
hz_Vertex = vec4(hz_v_pos + hz_origin.xyz, 1.0);\n \
|
||||
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * hz_origin.w, 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
|
||||
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * (hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w))), 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
|
||||
hz_MultiTexCoord1 = vec4(float(hz_v_light.y), float(hz_v_light.x), 0.0, 1.0);\n \
|
||||
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
|
||||
hz_Color = vec4(hz_v_color.rgb, 1.0);\n \
|
||||
|
||||
@@ -57,6 +57,14 @@ impl<T> Resident<T> {
|
||||
pub fn values(&self) -> impl Iterator<Item = &T> {
|
||||
self.items.iter().map(|(_, v)| v)
|
||||
}
|
||||
/// `key`'s value, when it is resident.
|
||||
pub fn find(&self, key: SectionKey) -> Option<&T> {
|
||||
self.slots.get(&key).map(|&slot| self.get(slot))
|
||||
}
|
||||
pub fn find_mut(&mut self, key: SectionKey) -> Option<&mut T> {
|
||||
let slot = *self.slots.get(&key)?;
|
||||
Some(self.get_mut(slot))
|
||||
}
|
||||
/// The section in `slot` (`Visible::slot`).
|
||||
pub fn get(&self, slot: u32) -> &T {
|
||||
&self.items[slot as usize].1
|
||||
|
||||
@@ -455,18 +455,39 @@ impl HudAtlas {
|
||||
for folder in folders {
|
||||
walk(&textures.join(folder), &mut files);
|
||||
}
|
||||
// Content mods' equipment textures, which their equipment assets name as `<ns>:<path>`.
|
||||
if folders.contains(&"entity") {
|
||||
for namespace in horizon_items::content_namespaces::namespaces(root) {
|
||||
walk(
|
||||
&root.join(format!("assets/{namespace}/textures/entity/equipment")),
|
||||
&mut files,
|
||||
);
|
||||
}
|
||||
}
|
||||
let assets = root.join("assets");
|
||||
// Content mods' GUI textures, for their screens: `<namespace>:gui/...`.
|
||||
if full && let Ok(namespaces) = fs::read_dir(&assets) {
|
||||
for ns in namespaces.flatten() {
|
||||
if ns.file_name() != "minecraft" {
|
||||
walk(&ns.path().join("textures/gui"), &mut files);
|
||||
}
|
||||
}
|
||||
}
|
||||
files.retain(|p| {
|
||||
let s = p.to_string_lossy();
|
||||
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
|
||||
});
|
||||
type Decoded = (PackedImage, Option<TextureFlags>, Option<(u32, u32)>, bool);
|
||||
let decode_one = |path: &PathBuf| -> Option<Decoded> {
|
||||
let name = path
|
||||
.strip_prefix(&textures)
|
||||
.ok()?
|
||||
.with_extension("")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let name = match path.strip_prefix(&textures) {
|
||||
Ok(rel) => rel.with_extension("").to_string_lossy().replace('\\', "/"),
|
||||
Err(_) => {
|
||||
let rel = path.strip_prefix(&assets).ok()?.with_extension("");
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
let (namespace, rest) = rel.split_once("/textures/")?;
|
||||
format!("{namespace}:{rest}")
|
||||
}
|
||||
};
|
||||
let image = decode(path).ok()?;
|
||||
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
|
||||
let animation = animation_of(mcmeta.as_deref());
|
||||
@@ -674,6 +695,45 @@ pub struct HudVertex {
|
||||
/// unless `uv.x >= 2`.
|
||||
pub glint_uv: [f32; 2],
|
||||
}
|
||||
/// Sutherland-Hodgman against one half plane, interpolating every attribute.
|
||||
fn clip_polygon(poly: &[HudVertex], axis: usize, limit: f32, keep_below: bool) -> Vec<HudVertex> {
|
||||
let inside = |v: &HudVertex| {
|
||||
if keep_below {
|
||||
v.pos[axis] < limit
|
||||
} else {
|
||||
v.pos[axis] >= limit
|
||||
}
|
||||
};
|
||||
let lerp = |a: &HudVertex, b: &HudVertex, t: f32| {
|
||||
let mix = |p: f32, q: f32| p + (q - p) * t;
|
||||
let mut v = *a;
|
||||
v.pos = [mix(a.pos[0], b.pos[0]), mix(a.pos[1], b.pos[1])];
|
||||
v.uv = [mix(a.uv[0], b.uv[0]), mix(a.uv[1], b.uv[1])];
|
||||
v.depth = mix(a.depth, b.depth);
|
||||
v.glint_uv = [
|
||||
mix(a.glint_uv[0], b.glint_uv[0]),
|
||||
mix(a.glint_uv[1], b.glint_uv[1]),
|
||||
];
|
||||
v.pos[axis] = limit;
|
||||
v
|
||||
};
|
||||
let mut out = Vec::with_capacity(poly.len() + 2);
|
||||
for i in 0..poly.len() {
|
||||
let (a, b) = (&poly[i], &poly[(i + 1) % poly.len()]);
|
||||
let (ia, ib) = (inside(a), inside(b));
|
||||
if ia {
|
||||
out.push(*a);
|
||||
}
|
||||
if ia != ib {
|
||||
out.push(lerp(
|
||||
a,
|
||||
b,
|
||||
(limit - a.pos[axis]) / (b.pos[axis] - a.pos[axis]),
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The GUI Scale option (0 = auto).
|
||||
pub static GUI_SCALE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
/// One frame of overlay geometry in GUI pixels.
|
||||
@@ -913,8 +973,8 @@ impl HudBatch {
|
||||
}
|
||||
}
|
||||
/// Clip the quads drawn since `start` to `[x0, x1) x [y0, y1)` (the
|
||||
/// GUI scissor): axis-aligned quads are cut with their UVs, others are
|
||||
/// kept or dropped by their centre.
|
||||
/// GUI scissor): axis-aligned quads are cut with their UVs, others (item
|
||||
/// triangles) are clipped as polygons.
|
||||
/// Scale the vertices pushed since `start` about `(cx, cy)` (a GUI pose
|
||||
/// matrix of translate, scale, translate back).
|
||||
pub fn scale_since(&mut self, start: usize, cx: f32, cy: f32, sx: f32, sy: f32) {
|
||||
@@ -926,7 +986,11 @@ impl HudBatch {
|
||||
pub fn clip_since(&mut self, start: usize, x0: f32, y0: f32, x1: f32, y1: f32) {
|
||||
let quads: Vec<[HudVertex; 6]> = self.vertices[start..].as_chunks::<6>().0.to_vec();
|
||||
self.vertices.truncate(start);
|
||||
// Where each source quad now begins, to move the blend ranges (foil
|
||||
// items are an additive run) along with their vertices.
|
||||
let mut moved = Vec::with_capacity(quads.len() + 1);
|
||||
for q in quads {
|
||||
moved.push(self.vertices.len() as u32);
|
||||
let corners = [q[0], q[1], q[2], q[5]];
|
||||
let (mut x_lo, mut x_hi) = (f32::INFINITY, f32::NEG_INFINITY);
|
||||
let (mut y_lo, mut y_hi) = (f32::INFINITY, f32::NEG_INFINITY);
|
||||
@@ -945,10 +1009,26 @@ impl HudBatch {
|
||||
find(x_lo, y_hi),
|
||||
);
|
||||
let (Some(tl), Some(tr), Some(br), Some(bl)) = rect else {
|
||||
let cx = corners.iter().map(|v| v.pos[0]).sum::<f32>() / 4.0;
|
||||
let cy = corners.iter().map(|v| v.pos[1]).sum::<f32>() / 4.0;
|
||||
if cx >= x0 && cx < x1 && cy >= y0 && cy < y1 {
|
||||
self.vertices.extend_from_slice(&q);
|
||||
// Item geometry arrives as triangles: clip each against the
|
||||
// rectangle and re-emit the fan, so a triangle straddling the
|
||||
// edge keeps its visible part instead of vanishing whole.
|
||||
for tri in [[q[0], q[1], q[2]], [q[3], q[4], q[5]]] {
|
||||
let area = (tri[1].pos[0] - tri[0].pos[0]) * (tri[2].pos[1] - tri[0].pos[1])
|
||||
- (tri[2].pos[0] - tri[0].pos[0]) * (tri[1].pos[1] - tri[0].pos[1]);
|
||||
if area == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut poly = tri.to_vec();
|
||||
for (axis, limit, keep_below) in
|
||||
[(0, x0, false), (0, x1, true), (1, y0, false), (1, y1, true)]
|
||||
{
|
||||
poly = clip_polygon(&poly, axis, limit, keep_below);
|
||||
}
|
||||
for i in 1..poly.len().saturating_sub(1) {
|
||||
// A triangle as the quad `a, b, c, c`.
|
||||
let (a, b, c) = (poly[0], poly[i], poly[i + 1]);
|
||||
self.vertices.extend_from_slice(&[a, b, c, a, c, c]);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
};
|
||||
@@ -972,6 +1052,14 @@ impl HudBatch {
|
||||
tl.color,
|
||||
);
|
||||
}
|
||||
moved.push(self.vertices.len() as u32);
|
||||
let at = |v: u32| moved[((v as usize).saturating_sub(start) / 6).min(moved.len() - 1)];
|
||||
for (range, _) in &mut self.blends {
|
||||
if range.start as usize >= start {
|
||||
*range = at(range.start)..at(range.end);
|
||||
}
|
||||
}
|
||||
self.blends.retain(|(range, _)| !range.is_empty());
|
||||
}
|
||||
/// `blit` of the top-left `w x h` texels of a `tw x th` texture.
|
||||
pub fn sprite_region(
|
||||
@@ -1846,6 +1934,57 @@ impl HudRenderer {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clip_since_keeps_the_visible_part_of_a_straddling_triangle() {
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
let start = batch.vertices.len();
|
||||
// A right triangle (a quad `a, b, c, c`) whose centre lies outside the rect.
|
||||
batch.quad(
|
||||
[[0.0, 0.0], [16.0, 0.0], [0.0, 16.0], [0.0, 16.0]],
|
||||
[[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [0.0, 1.0]],
|
||||
u32::MAX,
|
||||
);
|
||||
batch.clip_since(start, 0.0, 0.0, 16.0, 4.0);
|
||||
assert!(!batch.vertices.is_empty());
|
||||
assert!(batch.vertices.iter().all(|v| v.pos[1] <= 4.0));
|
||||
let area: f32 = batch
|
||||
.vertices
|
||||
.as_chunks::<3>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|t| {
|
||||
((t[1].pos[0] - t[0].pos[0]) * (t[2].pos[1] - t[0].pos[1])
|
||||
- (t[2].pos[0] - t[0].pos[0]) * (t[1].pos[1] - t[0].pos[1]))
|
||||
.abs()
|
||||
/ 2.0
|
||||
})
|
||||
.sum();
|
||||
// Rows 0..4 of the triangle: 16 * 16 / 2 - 12 * 12 / 2.
|
||||
assert!((area - 56.0).abs() < 1e-3, "{area}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_since_moves_blend_ranges_with_their_vertices() {
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
let start = batch.vertices.len();
|
||||
let tri = |batch: &mut HudBatch, y: f32| {
|
||||
batch.quad(
|
||||
[[0.0, y], [8.0, y], [0.0, y + 8.0], [0.0, y + 8.0]],
|
||||
[[0.0; 2]; 4],
|
||||
u32::MAX,
|
||||
);
|
||||
};
|
||||
tri(&mut batch, 100.0); // outside, dropped
|
||||
let foil = batch.vertices.len();
|
||||
tri(&mut batch, 0.0); // straddles the bottom edge
|
||||
batch.blend_since(foil, Blend::Additive);
|
||||
batch.clip_since(start, 0.0, 0.0, 16.0, 4.0);
|
||||
assert_eq!(
|
||||
batch.blends,
|
||||
[(0..batch.vertices.len() as u32, Blend::Additive)]
|
||||
);
|
||||
}
|
||||
|
||||
/// Write a PNG `width x height` whose `frames` stacked rows are solid
|
||||
/// colours, then its mcmeta beside it.
|
||||
fn write_strip(path: &Path, width: u32, height: u32, frames: &[[u8; 4]], mcmeta: Option<&str>) {
|
||||
@@ -1888,6 +2027,34 @@ mod tests {
|
||||
atlas.pixels[at..at + 4].try_into().unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn content_mod_equipment_textures_pack_under_their_namespace() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-atlas-equip-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let equipment = root.join("assets/gems/textures/entity/equipment/humanoid");
|
||||
fs::create_dir_all(&equipment).unwrap();
|
||||
write_strip(
|
||||
&equipment.join("ruby.png"),
|
||||
64,
|
||||
32,
|
||||
&[[200, 0, 0, 255]],
|
||||
None,
|
||||
);
|
||||
let other = root.join("assets/gems/textures/item");
|
||||
fs::create_dir_all(&other).unwrap();
|
||||
write_strip(&other.join("ruby.png"), 16, 16, &[[0, 200, 0, 255]], None);
|
||||
fs::write(
|
||||
root.join(horizon_items::content_namespaces::MARKER),
|
||||
br#"["gems"]"#,
|
||||
)
|
||||
.unwrap();
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let rect = atlas.sprites["gems:entity/equipment/humanoid/ruby"];
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [200, 0, 0, 255]);
|
||||
// Only equipment: the mods' other textures reach the atlas through atlas definitions.
|
||||
assert!(!atlas.sprites.contains_key("gems:item/ruby"));
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn animated_block_sprites_register_and_pack_their_first_mcmeta_frame() {
|
||||
let root = animated_root();
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
@@ -2009,6 +2176,33 @@ mod tests {
|
||||
assert_eq!(((r.w, r.h), r.texels(size)), (gui, texels), "{name}");
|
||||
}
|
||||
}
|
||||
/// A content mod's GUI textures join the atlas under its namespace;
|
||||
/// its other textures and vanilla's names stay as they were.
|
||||
#[test]
|
||||
fn a_content_mods_gui_textures_are_sprites_under_its_namespace() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-mod-gui-{}", std::process::id()));
|
||||
write_png(&root, "gui/container/furnace", 256, 256);
|
||||
let png = |rel: &str, w: u32, h: u32| {
|
||||
let path = root.join(rel);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let mut encoder = png::Encoder::new(fs::File::create(path).unwrap(), w, h);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let pixels = vec![255; (w * h * 4) as usize];
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
};
|
||||
png("assets/mymod/textures/gui/container/machine.png", 176, 166);
|
||||
png("assets/mymod/textures/block/machine.png", 16, 16);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
assert_eq!(atlas.sprites["mymod:gui/container/machine"].w, 176);
|
||||
assert!(atlas.sprites.contains_key("gui/container/furnace"));
|
||||
assert!(!atlas.sprites.contains_key("mymod:block/machine"));
|
||||
}
|
||||
#[test]
|
||||
fn the_default_textures_keep_a_4096_wide_atlas() {
|
||||
let images = sized(400, 64, 64);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! `JigsawBlockEntity`: the jigsaw block's name, target, pool, joint, final
|
||||
//! state and priorities, with its save format (`saveAdditional` /
|
||||
//! `loadAdditional`).
|
||||
use crate::structure_block::parse_identifier;
|
||||
use horizon_client_core::nbt_value::{number, string, text};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `JigsawBlockEntity.EMPTY_ID` and `Pools.EMPTY`.
|
||||
pub const EMPTY_ID: &str = "minecraft:empty";
|
||||
|
||||
/// `JigsawBlockEntity.JointType`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum JointType {
|
||||
Rollable,
|
||||
Aligned,
|
||||
}
|
||||
|
||||
impl JointType {
|
||||
pub const ALL: [Self; 2] = [Self::Rollable, Self::Aligned];
|
||||
pub fn serialized_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Rollable => "rollable",
|
||||
Self::Aligned => "aligned",
|
||||
}
|
||||
}
|
||||
pub fn of_serialized_name(name: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|j| j.serialized_name() == name)
|
||||
}
|
||||
/// `JointType.getTranslatedName`'s lang key.
|
||||
pub fn label_key(self) -> String {
|
||||
format!("jigsaw_block.joint.{}", self.serialized_name())
|
||||
}
|
||||
/// `StructureTemplate.getDefaultJointType`: the joint of a block whose
|
||||
/// front faces `front` (the first word of its `orientation` value).
|
||||
pub fn default_for_front(front: &str) -> Self {
|
||||
if is_vertical_front(front) {
|
||||
Self::Rollable
|
||||
} else {
|
||||
Self::Aligned
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `JigsawBlock.getFrontFacing(state)` from the `orientation` value
|
||||
/// (`down_east`, `north_up`...): its front is the part before the `_`.
|
||||
pub fn front_of_orientation(orientation: &str) -> &str {
|
||||
orientation.split('_').next().unwrap_or(orientation)
|
||||
}
|
||||
|
||||
pub fn is_vertical_front(front: &str) -> bool {
|
||||
matches!(front, "up" | "down")
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct JigsawBlock {
|
||||
pub name: String,
|
||||
pub target: String,
|
||||
pub pool: String,
|
||||
pub joint: JointType,
|
||||
pub final_state: String,
|
||||
pub placement_priority: i32,
|
||||
pub selection_priority: i32,
|
||||
}
|
||||
|
||||
impl Default for JigsawBlock {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: EMPTY_ID.into(),
|
||||
target: EMPTY_ID.into(),
|
||||
pool: EMPTY_ID.into(),
|
||||
joint: JointType::Rollable,
|
||||
final_state: "minecraft:air".into(),
|
||||
placement_priority: 0,
|
||||
selection_priority: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JigsawBlock {
|
||||
/// `saveAdditional` (also `getUpdateTag`, which is `saveCustomOnly`).
|
||||
pub fn save(&self) -> Vec<(&'static str, Tag)> {
|
||||
vec![
|
||||
("name", string(&self.name)),
|
||||
("target", string(&self.target)),
|
||||
("pool", string(&self.pool)),
|
||||
("final_state", string(&self.final_state)),
|
||||
("joint", string(self.joint.serialized_name())),
|
||||
("placement_priority", Tag::Int(self.placement_priority)),
|
||||
("selection_priority", Tag::Int(self.selection_priority)),
|
||||
]
|
||||
}
|
||||
|
||||
/// `loadAdditional`. A missing joint takes `default_joint`
|
||||
/// (`StructureTemplate.getDefaultJointType` of the block's state).
|
||||
pub fn load(tag: &Tag, default_joint: JointType) -> Self {
|
||||
let id = |key: &str| {
|
||||
text(tag.get(key))
|
||||
.and_then(|s| parse_identifier(&s))
|
||||
.unwrap_or_else(|| EMPTY_ID.into())
|
||||
};
|
||||
Self {
|
||||
name: id("name"),
|
||||
target: id("target"),
|
||||
pool: id("pool"),
|
||||
final_state: text(tag.get("final_state")).unwrap_or_else(|| "minecraft:air".into()),
|
||||
joint: text(tag.get("joint"))
|
||||
.and_then(|j| JointType::of_serialized_name(&j))
|
||||
.unwrap_or(default_joint),
|
||||
placement_priority: number(tag.get("placement_priority")).unwrap_or(0) as i32,
|
||||
selection_priority: number(tag.get("selection_priority")).unwrap_or(0) as i32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn saves_and_loads_like_jigsaw_block_entity() {
|
||||
let block = JigsawBlock {
|
||||
name: "minecraft:bottom".into(),
|
||||
target: "test:top".into(),
|
||||
pool: "minecraft:village/plains/houses".into(),
|
||||
joint: JointType::Aligned,
|
||||
final_state: "minecraft:stone[x=1]".into(),
|
||||
placement_priority: 4,
|
||||
selection_priority: -1,
|
||||
};
|
||||
let tag = Tag::compound(block.save());
|
||||
assert_eq!(text(tag.get("joint")).as_deref(), Some("aligned"));
|
||||
assert_eq!(JigsawBlock::load(&tag, JointType::Rollable), block);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_defaults_like_vanilla() {
|
||||
let tag = Tag::compound(vec![
|
||||
("name", string("Bad Name")),
|
||||
("target", string("door")),
|
||||
]);
|
||||
let block = JigsawBlock::load(&tag, JointType::Aligned);
|
||||
assert_eq!(block.name, EMPTY_ID);
|
||||
assert_eq!(block.target, "minecraft:door");
|
||||
assert_eq!(block.pool, EMPTY_ID);
|
||||
assert_eq!(block.final_state, "minecraft:air");
|
||||
assert_eq!(block.joint, JointType::Aligned);
|
||||
assert_eq!(JointType::default_for_front("up"), JointType::Rollable);
|
||||
assert_eq!(
|
||||
JointType::default_for_front(front_of_orientation("north_up")),
|
||||
JointType::Aligned
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,261 @@ pub enum Reason {
|
||||
Other,
|
||||
}
|
||||
|
||||
/// `LevelLoadTracker.CLIENT_WAIT_TIMEOUT_MS`.
|
||||
pub const CLIENT_WAIT_TIMEOUT_MS: u64 = 30_000;
|
||||
/// `LevelLoadTracker.LEVEL_LOAD_CLOSE_DELAY_MS`: how long a new world's loading
|
||||
/// screen stays once the level is ready.
|
||||
pub const LEVEL_LOAD_CLOSE_DELAY_MS: u64 = 500;
|
||||
|
||||
/// `ClientPacketListener.determineLevelLoadingReason`.
|
||||
pub fn reason_for_respawn(player_died: bool, dimension: &str, old_dimension: &str) -> Reason {
|
||||
const NETHER: &str = "minecraft:the_nether";
|
||||
const END: &str = "minecraft:the_end";
|
||||
if player_died {
|
||||
Reason::Other
|
||||
} else if dimension == NETHER || old_dimension == NETHER {
|
||||
Reason::NetherPortal
|
||||
} else if dimension == END || old_dimension == END {
|
||||
Reason::EndPortal
|
||||
} else {
|
||||
Reason::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// `LevelLoadTracker.ClientState`, with the times in milliseconds.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ClientState {
|
||||
/// `WaitingForServer`: until the loading start game event.
|
||||
WaitingForServer { timeout_after: u64 },
|
||||
/// `WaitingForPlayerChunk`: until the section around the player is shown.
|
||||
WaitingForPlayerChunk {
|
||||
player_section_ready: bool,
|
||||
timeout_after: u64,
|
||||
},
|
||||
/// `ClientLevelReady`.
|
||||
Ready { ready_at: u64 },
|
||||
}
|
||||
|
||||
/// What `WaitingForPlayerChunk.isReady` asks of the player.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PlayerCheck {
|
||||
/// `ClientLevel.isOutsideBuildHeight(camera y)`.
|
||||
pub outside_build_height: bool,
|
||||
pub spectator: bool,
|
||||
pub alive: bool,
|
||||
}
|
||||
|
||||
/// The client half of `LevelLoadTracker`: what the loading screen waits for
|
||||
/// after a login or a respawn, and the screen it is shown on.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct LevelLoadTracker {
|
||||
state: Option<ClientState>,
|
||||
close_delay_ms: u64,
|
||||
/// `LevelLoadingScreen.reason`.
|
||||
pub reason: Reason,
|
||||
}
|
||||
impl LevelLoadTracker {
|
||||
pub fn new(close_delay_ms: u64) -> Self {
|
||||
Self {
|
||||
state: None,
|
||||
close_delay_ms,
|
||||
reason: Reason::Other,
|
||||
}
|
||||
}
|
||||
/// `startClientLoad`, for the screen `reason` (`startWaitingForNewLevel`).
|
||||
pub fn start_client_load(&mut self, now: u64, reason: Reason) {
|
||||
self.state = Some(ClientState::WaitingForServer {
|
||||
timeout_after: now + CLIENT_WAIT_TIMEOUT_MS,
|
||||
});
|
||||
self.reason = reason;
|
||||
}
|
||||
/// The tracker's work is over and the screen has closed
|
||||
/// (`ClientPacketListener.tick` drops it once the level is ready).
|
||||
pub fn finish(&mut self) {
|
||||
self.state = None;
|
||||
}
|
||||
/// A load is under way, so its screen is up.
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.state.is_some()
|
||||
}
|
||||
/// `loadingPacketsReceived`: `LEVEL_CHUNKS_LOAD_START` came.
|
||||
pub fn loading_packets_received(&mut self) {
|
||||
if let Some(ClientState::WaitingForServer { timeout_after }) = self.state {
|
||||
self.state = Some(ClientState::WaitingForPlayerChunk {
|
||||
player_section_ready: false,
|
||||
timeout_after,
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `getPlayerCompiledSectionCallback` is not null: the renderer is to say
|
||||
/// when the player's section is shown.
|
||||
pub fn wants_player_section(&self) -> bool {
|
||||
matches!(self.state, Some(ClientState::WaitingForPlayerChunk { .. }))
|
||||
}
|
||||
/// The callback: the player's section is compiled and visible.
|
||||
pub fn player_section_ready(&mut self) {
|
||||
if let Some(ClientState::WaitingForPlayerChunk {
|
||||
player_section_ready,
|
||||
..
|
||||
}) = &mut self.state
|
||||
{
|
||||
*player_section_ready = true;
|
||||
}
|
||||
}
|
||||
/// `tickClientLoad`.
|
||||
pub fn tick_client_load(&mut self, now: u64, player: PlayerCheck) {
|
||||
if let Some(ClientState::WaitingForPlayerChunk {
|
||||
player_section_ready,
|
||||
timeout_after,
|
||||
}) = self.state
|
||||
{
|
||||
let ready = if now > timeout_after {
|
||||
log::warn!(
|
||||
"Timed out while waiting for the client to load chunks, letting the player into the world anyway"
|
||||
);
|
||||
true
|
||||
} else if !player.outside_build_height && !player.spectator && player.alive {
|
||||
player_section_ready
|
||||
} else {
|
||||
true
|
||||
};
|
||||
if ready {
|
||||
self.state = Some(ClientState::Ready { ready_at: now });
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `isLevelReady`.
|
||||
pub fn is_level_ready(&self, now: u64) -> bool {
|
||||
matches!(self.state, Some(ClientState::Ready { ready_at }) if now >= ready_at + self.close_delay_ms)
|
||||
}
|
||||
}
|
||||
|
||||
/// `LevelLoadListener.Stage`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Stage {
|
||||
StartServer,
|
||||
PrepareGlobalSpawn,
|
||||
LoadInitialChunks,
|
||||
LoadPlayerChunks,
|
||||
}
|
||||
|
||||
/// `LevelLoadProgressTracker` (with `includePlayerChunks`).
|
||||
#[derive(Debug, Default)]
|
||||
struct ProgressTracker {
|
||||
total_weight: usize,
|
||||
finalized_weight: usize,
|
||||
segment_weight: usize,
|
||||
segment_fraction: f32,
|
||||
progress: f32,
|
||||
}
|
||||
impl ProgressTracker {
|
||||
fn tracks(stage: Stage) -> bool {
|
||||
matches!(stage, Stage::LoadInitialChunks | Stage::LoadPlayerChunks)
|
||||
}
|
||||
fn begin_segment(&mut self, weight: usize) {
|
||||
self.segment_weight = weight;
|
||||
self.segment_fraction = 0.0;
|
||||
self.update_progress();
|
||||
}
|
||||
fn finish_segment(&mut self) {
|
||||
self.finalized_weight += self.segment_weight;
|
||||
self.segment_weight = 0;
|
||||
self.update_progress();
|
||||
}
|
||||
fn update_progress(&mut self) {
|
||||
self.progress = if self.total_weight == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(self.finalized_weight as f32 + self.segment_fraction * self.segment_weight as f32)
|
||||
/ self.total_weight as f32
|
||||
};
|
||||
}
|
||||
fn start(&mut self, stage: Stage, total_chunks: usize) {
|
||||
match stage {
|
||||
Stage::LoadInitialChunks => {
|
||||
self.total_weight = PREPARE_WEIGHT + total_chunks + PLAYER_CHUNKS;
|
||||
self.begin_segment(PREPARE_WEIGHT);
|
||||
self.finish_segment();
|
||||
self.begin_segment(total_chunks);
|
||||
}
|
||||
Stage::LoadPlayerChunks => self.begin_segment(PLAYER_CHUNKS),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn update(&mut self, stage: Stage, current: usize, total: usize) {
|
||||
if Self::tracks(stage) {
|
||||
self.segment_fraction = if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
current as f32 / total as f32
|
||||
};
|
||||
self.update_progress();
|
||||
}
|
||||
}
|
||||
fn finish(&mut self, stage: Stage) {
|
||||
if Self::tracks(stage) {
|
||||
self.finish_segment();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The integrated server's side of `LevelLoadTracker`: what its
|
||||
/// `LevelLoadListener` reported (`serverProgress`, `hasProgress`) and the
|
||||
/// chunk status map around the load focus (`ChunkLoadStatusView`), which the
|
||||
/// server thread writes and the window's loading screen reads.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ServerLoadView {
|
||||
tracker: std::sync::Mutex<ProgressTracker>,
|
||||
/// `serverStage != null`.
|
||||
has_progress: std::sync::atomic::AtomicBool,
|
||||
/// `statusView.get(x, z)` for x and z in `0..=2 * STATUS_RADIUS`.
|
||||
cells: std::sync::Mutex<Vec<Option<Status>>>,
|
||||
}
|
||||
impl ServerLoadView {
|
||||
/// `LevelLoadListener.start`.
|
||||
pub fn start(&self, stage: Stage, total_chunks: usize) {
|
||||
Self::lock(&self.tracker).start(stage, total_chunks);
|
||||
// `serverStage = stage`, whatever the stage.
|
||||
self.has_progress
|
||||
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
/// `LevelLoadListener.update`.
|
||||
pub fn update(&self, stage: Stage, current: usize, total: usize) {
|
||||
Self::lock(&self.tracker).update(stage, current, total);
|
||||
}
|
||||
/// `LevelLoadListener.finish`.
|
||||
pub fn finish(&self, stage: Stage) {
|
||||
Self::lock(&self.tracker).finish(stage);
|
||||
}
|
||||
/// `LevelLoadTracker.hasProgress`.
|
||||
pub fn has_progress(&self) -> bool {
|
||||
self.has_progress.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
/// `LevelLoadTracker.serverProgress`.
|
||||
pub fn server_progress(&self) -> f32 {
|
||||
Self::lock(&self.tracker).progress
|
||||
}
|
||||
/// The map `ChunkLoadStatusView.get` reads, replaced as a whole.
|
||||
pub fn set_status(&self, cells: Vec<Option<Status>>) {
|
||||
*Self::lock(&self.cells) = cells;
|
||||
}
|
||||
/// `ChunkLoadStatusView.get`.
|
||||
pub fn status(&self, x: i32, z: i32) -> Option<Status> {
|
||||
let diameter = (STATUS_RADIUS * 2 + 1) as usize;
|
||||
let (x, z) = (usize::try_from(x).ok()?, usize::try_from(z).ok()?);
|
||||
if x >= diameter || z >= diameter {
|
||||
return None;
|
||||
}
|
||||
Self::lock(&self.cells)
|
||||
.get(x * diameter + z)
|
||||
.copied()
|
||||
.flatten()
|
||||
}
|
||||
fn lock<T>(m: &std::sync::Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
/// `LevelLoadProgressTracker.get` once the server is prepared, with `ready`
|
||||
/// of the player's 49 chunks loaded (no forced chunks to load first).
|
||||
pub fn progress(ready: usize) -> f32 {
|
||||
@@ -165,6 +420,118 @@ pub fn draw_connecting(
|
||||
}
|
||||
}
|
||||
|
||||
/// `FileFixerProgressScreen`'s Cancel button corner on a GUI `w` x `h`:
|
||||
/// ((width - 200) / 2, height / 2 + 100), 200 x 20.
|
||||
pub fn file_fixer_cancel(w: f32, h: f32) -> (f32, f32) {
|
||||
(((w as i32 - 200) / 2) as f32, (h as i32 / 2 + 100) as f32)
|
||||
}
|
||||
|
||||
/// `GuiGraphicsExtractor.centeredText` with the screen's grey (`-6250336`) or white.
|
||||
fn centered_line(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
text: &Text,
|
||||
x_center: i32,
|
||||
y: i32,
|
||||
color: u32,
|
||||
) {
|
||||
let tw = atlas.font.width_text(text) as i32;
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
text,
|
||||
(x_center - tw / 2) as f32,
|
||||
y as f32,
|
||||
color,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/// `FileFixerProgressScreen.extractProgressBar`.
|
||||
fn file_fixer_bar(batch: &mut HudBatch, atlas: &HudAtlas, x_center: i32, y: i32, progress: f32) {
|
||||
let left = x_center - 100;
|
||||
batch.fill(atlas, left as f32, y as f32, 200.0, 2.0, opaque(0));
|
||||
batch.fill(
|
||||
atlas,
|
||||
left as f32,
|
||||
y as f32,
|
||||
(progress * 200.0).round(),
|
||||
2.0,
|
||||
opaque(0x00FF00),
|
||||
);
|
||||
}
|
||||
|
||||
/// `FileFixerProgressScreen.extractRenderState` (with `extractProgress`,
|
||||
/// `extractProgressBar`, `extractFileStats`, `extractFileFixerCount`,
|
||||
/// `extractTypeText`, `extractTypeProgress` and `extractScanning`) over the menu background: the
|
||||
/// title, then either the scanning line or the running fixer's bar, the
|
||||
/// totals, the stage, the type's label and its bar and percentage; and
|
||||
/// Cancel, inactive once pressed.
|
||||
pub fn draw_file_fixer(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
progress: &horizon_world::datafix::progress::UpgradeProgress,
|
||||
cursor: (f32, f32),
|
||||
) {
|
||||
const GREY: u32 = 0xFFA0_A0A0;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
crate::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
|
||||
let (x_center, y_center) = ((w as i32) / 2, (h as i32) / 2);
|
||||
let text_top = y_center - 50;
|
||||
centered_line(
|
||||
batch,
|
||||
atlas,
|
||||
&Text::translate("upgradeWorld.title", &[]),
|
||||
x_center,
|
||||
text_top,
|
||||
u32::MAX,
|
||||
);
|
||||
let total = progress.total_file_fix_stats();
|
||||
if total.total_operations() > 0 {
|
||||
let of_type = progress.type_file_fix_stats();
|
||||
let running = progress.running_file_fixer_stats();
|
||||
let mut y = text_top + 9 + 3;
|
||||
file_fixer_bar(batch, atlas, x_center, y, running.progress());
|
||||
y += 7;
|
||||
// extractFileStats
|
||||
let converted = Text::tr(
|
||||
"upgradeWorld.info.converted",
|
||||
&[&total.finished_operations()],
|
||||
);
|
||||
centered_line(batch, atlas, &converted, x_center, y, GREY);
|
||||
let sum = Text::tr("upgradeWorld.info.total", &[&total.total_operations()]);
|
||||
centered_line(batch, atlas, &sum, x_center, y + 9 + 3, GREY);
|
||||
y += 9 * 2 + 6;
|
||||
// extractFileFixerCount
|
||||
let stage = Text::tr(
|
||||
"upgradeWorld.info.file_fix_stage",
|
||||
&[&running.finished_operations(), &running.total_operations()],
|
||||
);
|
||||
centered_line(batch, atlas, &stage, x_center, y, GREY);
|
||||
y += 9 + 30 - 5;
|
||||
// extractTypeText
|
||||
if let Some(ty) = progress.upgrade_type() {
|
||||
let label = Text::translate(&ty.label_key(), &[]);
|
||||
centered_line(batch, atlas, &label, x_center, y, GREY);
|
||||
}
|
||||
y += 9 + 3;
|
||||
file_fixer_bar(batch, atlas, x_center, y, of_type.progress());
|
||||
y += 7;
|
||||
// extractTypeProgress: Mth.floor(progress * 100)
|
||||
let percent = (of_type.progress() * 100.0).floor() as i32;
|
||||
let text = Text::tr("upgradeWorld.progress.percentage", &[&percent]);
|
||||
centered_line(batch, atlas, &text, x_center, y, GREY);
|
||||
} else {
|
||||
// extractScanning
|
||||
let scanning = Text::translate("upgradeWorld.info.scanning", &[]);
|
||||
centered_line(batch, atlas, &scanning, x_center, text_top + 9 + 3, GREY);
|
||||
}
|
||||
let (x, y) = file_fixer_cancel(w, h);
|
||||
let active = !progress.is_canceled();
|
||||
let hover = crate::ui::hovered(cursor, x, y, 200.0, 20.0);
|
||||
let label = Text::translate("gui.cancel", &[]);
|
||||
crate::ui::button(batch, atlas, x, y, 200.0, 20.0, &label, active, hover);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -179,6 +546,12 @@ mod tests {
|
||||
assert!((smooth(eased, 1.0) - 0.36).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_fixer_cancel_sits_below_the_centre() {
|
||||
assert_eq!(file_fixer_cancel(427.0, 240.0), (113.0, 220.0));
|
||||
assert_eq!(file_fixer_cancel(426.0, 241.0), (113.0, 220.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colours_are_opaque_rgba() {
|
||||
assert_eq!(opaque(Status::Full.color()), u32::MAX);
|
||||
@@ -190,3 +563,145 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tracker_tests {
|
||||
use super::*;
|
||||
|
||||
const ALIVE: PlayerCheck = PlayerCheck {
|
||||
outside_build_height: false,
|
||||
spectator: false,
|
||||
alive: true,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn server_progress_follows_the_listener_like_the_progress_tracker() {
|
||||
let view = ServerLoadView::default();
|
||||
assert!(!view.has_progress() && view.server_progress() == 0.0);
|
||||
// A stage the tracker ignores still counts as progress being made.
|
||||
view.start(Stage::StartServer, 0);
|
||||
assert!(view.has_progress() && view.server_progress() == 0.0);
|
||||
// prepareLevels with no forced chunks: the 10 of preparing, of 59.
|
||||
view.start(Stage::LoadInitialChunks, 0);
|
||||
view.finish(Stage::LoadInitialChunks);
|
||||
assert_eq!(view.server_progress(), 10.0 / 59.0);
|
||||
// PrepareSpawnTask: the 49 player chunks.
|
||||
view.start(Stage::LoadPlayerChunks, 49);
|
||||
view.update(Stage::LoadPlayerChunks, 20, 49);
|
||||
assert_eq!(view.server_progress(), (10.0 + 20.0) / 59.0);
|
||||
assert_eq!(view.server_progress(), progress(20));
|
||||
view.update(Stage::LoadPlayerChunks, 49, 49);
|
||||
view.finish(Stage::LoadPlayerChunks);
|
||||
assert_eq!(view.server_progress(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_status_map_is_read_from_the_north_west_corner() {
|
||||
let view = ServerLoadView::default();
|
||||
assert_eq!(view.status(0, 0), None);
|
||||
let diameter = (STATUS_RADIUS * 2 + 1) as usize;
|
||||
let mut cells = vec![None; diameter * diameter];
|
||||
cells[3 * diameter + 5] = Some(Status::Full);
|
||||
view.set_status(cells);
|
||||
assert_eq!(view.status(3, 5), Some(Status::Full));
|
||||
assert_eq!(view.status(5, 3), None);
|
||||
assert_eq!(view.status(-1, 0), None);
|
||||
assert_eq!(view.status(diameter as i32, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_load_waits_for_the_server_then_the_players_section() {
|
||||
let mut tracker = LevelLoadTracker::new(0);
|
||||
assert!(!tracker.is_active());
|
||||
tracker.start_client_load(1_000, Reason::NetherPortal);
|
||||
assert!(tracker.is_active() && tracker.reason == Reason::NetherPortal);
|
||||
// Nothing is asked of the renderer before the start event, and a
|
||||
// section shown early counts for nothing.
|
||||
assert!(!tracker.wants_player_section());
|
||||
tracker.player_section_ready();
|
||||
tracker.tick_client_load(1_050, ALIVE);
|
||||
assert!(!tracker.is_level_ready(1_050));
|
||||
tracker.loading_packets_received();
|
||||
assert!(tracker.wants_player_section());
|
||||
tracker.tick_client_load(1_100, ALIVE);
|
||||
assert!(!tracker.is_level_ready(1_100));
|
||||
tracker.player_section_ready();
|
||||
tracker.tick_client_load(1_150, ALIVE);
|
||||
assert!(tracker.is_level_ready(1_150));
|
||||
tracker.finish();
|
||||
assert!(!tracker.is_active() && !tracker.is_level_ready(2_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_player_who_cannot_see_the_section_does_not_wait_for_it() {
|
||||
for player in [
|
||||
PlayerCheck {
|
||||
outside_build_height: true,
|
||||
..ALIVE
|
||||
},
|
||||
PlayerCheck {
|
||||
spectator: true,
|
||||
..ALIVE
|
||||
},
|
||||
PlayerCheck {
|
||||
alive: false,
|
||||
..ALIVE
|
||||
},
|
||||
] {
|
||||
let mut tracker = LevelLoadTracker::new(0);
|
||||
tracker.start_client_load(0, Reason::Other);
|
||||
tracker.loading_packets_received();
|
||||
tracker.tick_client_load(10, player);
|
||||
assert!(tracker.is_level_ready(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wait_times_out_after_thirty_seconds_but_only_once_the_server_has_started() {
|
||||
let mut tracker = LevelLoadTracker::new(0);
|
||||
tracker.start_client_load(0, Reason::Other);
|
||||
tracker.tick_client_load(CLIENT_WAIT_TIMEOUT_MS + 1, ALIVE);
|
||||
assert!(!tracker.is_level_ready(CLIENT_WAIT_TIMEOUT_MS + 1));
|
||||
tracker.loading_packets_received();
|
||||
tracker.tick_client_load(CLIENT_WAIT_TIMEOUT_MS, ALIVE);
|
||||
assert!(!tracker.is_level_ready(CLIENT_WAIT_TIMEOUT_MS));
|
||||
tracker.tick_client_load(CLIENT_WAIT_TIMEOUT_MS + 1, ALIVE);
|
||||
assert!(tracker.is_level_ready(CLIENT_WAIT_TIMEOUT_MS + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_worlds_screen_stays_for_the_close_delay() {
|
||||
let mut tracker = LevelLoadTracker::new(LEVEL_LOAD_CLOSE_DELAY_MS);
|
||||
tracker.start_client_load(0, Reason::Other);
|
||||
tracker.loading_packets_received();
|
||||
tracker.player_section_ready();
|
||||
tracker.tick_client_load(100, ALIVE);
|
||||
assert!(!tracker.is_level_ready(599));
|
||||
assert!(tracker.is_level_ready(600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_respawn_names_its_screen_by_the_dimensions() {
|
||||
let (overworld, nether, end) = (
|
||||
"minecraft:overworld",
|
||||
"minecraft:the_nether",
|
||||
"minecraft:the_end",
|
||||
);
|
||||
assert_eq!(
|
||||
reason_for_respawn(false, nether, overworld),
|
||||
Reason::NetherPortal
|
||||
);
|
||||
assert_eq!(
|
||||
reason_for_respawn(false, overworld, nether),
|
||||
Reason::NetherPortal
|
||||
);
|
||||
assert_eq!(reason_for_respawn(false, end, overworld), Reason::EndPortal);
|
||||
assert_eq!(reason_for_respawn(false, end, nether), Reason::NetherPortal);
|
||||
assert_eq!(
|
||||
reason_for_respawn(false, overworld, overworld),
|
||||
Reason::Other
|
||||
);
|
||||
// A death never shows a portal.
|
||||
assert_eq!(reason_for_respawn(true, nether, overworld), Reason::Other);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ pub mod hud_layout;
|
||||
pub mod hud_overlays;
|
||||
pub mod inventory_effects;
|
||||
pub mod java_text;
|
||||
pub mod jigsaw_block;
|
||||
pub mod level_loading;
|
||||
pub mod light;
|
||||
pub mod lod;
|
||||
@@ -50,6 +51,7 @@ pub mod mipmap;
|
||||
pub mod mod_list;
|
||||
pub mod model_library;
|
||||
pub mod models;
|
||||
pub mod multiline_edit;
|
||||
pub mod options_file;
|
||||
pub mod options_fixes;
|
||||
pub mod options_ui;
|
||||
@@ -59,6 +61,7 @@ pub mod packs;
|
||||
pub mod par;
|
||||
pub mod particles;
|
||||
pub mod poi_sections;
|
||||
pub mod post_chain;
|
||||
pub mod potent_sulfur;
|
||||
pub mod providers;
|
||||
pub mod screen_draw;
|
||||
@@ -77,8 +80,11 @@ pub mod split_layout;
|
||||
mod sprite_cache;
|
||||
pub mod sprites;
|
||||
pub mod streaming;
|
||||
pub mod structure_block;
|
||||
pub mod tab_list;
|
||||
pub mod telemetry;
|
||||
pub mod test_block;
|
||||
pub mod test_instance_block;
|
||||
pub mod testgen;
|
||||
pub mod title_edition;
|
||||
pub mod trims;
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::{
|
||||
mesher::SectionMesh,
|
||||
streaming::{LodSource, WorkerContext},
|
||||
};
|
||||
pub mod cache;
|
||||
mod mesh;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
@@ -40,7 +41,14 @@ fn sampling_threads(cores: usize) -> usize {
|
||||
/// otherwise take the cores the near chunks and the frame need.
|
||||
const HELD_SAMPLE_STEP: std::time::Duration = std::time::Duration::from_millis(500);
|
||||
/// The coarsest tile level: `Plan` roots cover at most 1024 chunks around.
|
||||
const MAX_LEVEL: u8 = 10;
|
||||
pub(crate) const MAX_LEVEL: u8 = 10;
|
||||
/// The coarsest tile level whose chunks are generated in full (trees,
|
||||
/// structures) rather than sampled: cells of up to 4 blocks, out to about
|
||||
/// 1024 blocks at Full far detail.
|
||||
const DETAIL_LEVEL: u8 = 2;
|
||||
/// The most chunks one detail request asks for, one request each 250 ms:
|
||||
/// generated ones arrive at the decoration threads' pace, cached ones at once.
|
||||
const DETAIL_REQUEST: usize = 512;
|
||||
/// Top solid face and optional fluid face, in absolute block coordinates.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Surface {
|
||||
@@ -358,7 +366,7 @@ impl TileKey {
|
||||
pub fn origin(self) -> [i32; 3] {
|
||||
[self.x * self.size(), 0, self.z * self.size()]
|
||||
}
|
||||
fn parent(self) -> Self {
|
||||
pub(crate) fn parent(self) -> Self {
|
||||
Self {
|
||||
x: self.x.div_euclid(2),
|
||||
z: self.z.div_euclid(2),
|
||||
@@ -372,6 +380,12 @@ impl TileKey {
|
||||
level: self.level - 1,
|
||||
})
|
||||
}
|
||||
/// The squared horizontal distance from `camera` to the tile's nearest point.
|
||||
fn reach(self, camera: [i32; 2]) -> i64 {
|
||||
let [x, _, z] = self.origin();
|
||||
let gap = |c: i32, lo: i32| i64::from((lo - c).max(c - lo - self.size()).max(0));
|
||||
gap(camera[0], x).pow(2) + gap(camera[1], z).pow(2)
|
||||
}
|
||||
fn distance(self, camera: [i32; 2]) -> i32 {
|
||||
let [x, _, z] = self.origin();
|
||||
(x - camera[0])
|
||||
@@ -380,27 +394,6 @@ impl TileKey {
|
||||
.max(camera[1] - z - self.size())
|
||||
.max(0)
|
||||
}
|
||||
fn in_view(self, camera: [i32; 2], yaw: f32) -> bool {
|
||||
let [x, _, z] = self.origin();
|
||||
let (sin, cos) = yaw.sin_cos();
|
||||
let mut forward = f32::NEG_INFINITY;
|
||||
let mut left = f32::NEG_INFINITY;
|
||||
let mut right = f32::NEG_INFINITY;
|
||||
for (dx, dz) in [
|
||||
(0, 0),
|
||||
(self.size(), 0),
|
||||
(0, self.size()),
|
||||
(self.size(), self.size()),
|
||||
] {
|
||||
let (x, z) = ((x + dx - camera[0]) as f32, (z + dz - camera[1]) as f32);
|
||||
let f = -x * sin + z * cos;
|
||||
let side = x * cos + z * sin;
|
||||
forward = forward.max(f);
|
||||
left = left.max(f * 0.866 + side * 0.5);
|
||||
right = right.max(f * 0.866 - side * 0.5);
|
||||
}
|
||||
forward > 0. && left > 0. && right > 0.
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Plan {
|
||||
@@ -452,7 +445,9 @@ impl Plan {
|
||||
previous: &Self,
|
||||
reduced: bool,
|
||||
) {
|
||||
if key.distance(camera) > range {
|
||||
// The range is a circle, as the renderer's: a tile leaves it, and
|
||||
// fades out (gfx/lod.rs), as the player moves.
|
||||
if key.reach(camera) > i64::from(range) * i64::from(range) {
|
||||
return;
|
||||
}
|
||||
self.nodes.insert(key);
|
||||
@@ -645,7 +640,6 @@ pub struct Lod {
|
||||
/// Reduced far detail (`set_reduced_detail`).
|
||||
reduced_detail: bool,
|
||||
pending: FxHashSet<TileKey>,
|
||||
sample_nearby: bool,
|
||||
detail_masks: FxHashMap<TileKey, [u64; 4]>,
|
||||
detail_request_pending: bool,
|
||||
detail_request_at: std::time::Instant,
|
||||
@@ -750,7 +744,9 @@ impl Lod {
|
||||
.expect("far sampling worker"),
|
||||
);
|
||||
}
|
||||
let mut mesher = mesh::Mesher::default();
|
||||
// One mesher per thread: each keeps its material cache.
|
||||
let mut meshers: Vec<_> =
|
||||
(0..count.max(1)).map(|_| mesh::Mesher::default()).collect();
|
||||
'work: loop {
|
||||
for (key, columns) in sampler.drain_details() {
|
||||
if tx
|
||||
@@ -810,24 +806,48 @@ impl Lod {
|
||||
covered,
|
||||
bounds,
|
||||
} => {
|
||||
let mut meshes = Vec::with_capacity(keys.len());
|
||||
for key in keys {
|
||||
if worker.stop.load(Ordering::Relaxed) {
|
||||
break 'work;
|
||||
}
|
||||
let finer = finer.remove(&key).unwrap_or_default();
|
||||
let input = snapshot.mesh_input(key, finer);
|
||||
let mut mesh = mesher.mesh(
|
||||
input.key,
|
||||
&input.columns(),
|
||||
&covered,
|
||||
bounds,
|
||||
&mesh_context,
|
||||
&input.neighbors,
|
||||
&input.finer,
|
||||
);
|
||||
mesh.sort_solid();
|
||||
meshes.push((input.key, mesh));
|
||||
let work: Vec<_> = keys
|
||||
.into_iter()
|
||||
.map(|key| (key, finer.remove(&key).unwrap_or_default()))
|
||||
.collect();
|
||||
let per_thread = work.len().div_ceil(meshers.len()).max(1);
|
||||
let (inputs, cut) = (&snapshot, &covered);
|
||||
let (stop, context) = (&worker.stop, &mesh_context);
|
||||
let meshes: Vec<_> = thread::scope(|scope| {
|
||||
let threads: Vec<_> = work
|
||||
.chunks(per_thread)
|
||||
.zip(meshers.iter_mut())
|
||||
.map(|(work, mesher)| {
|
||||
scope.spawn(move || {
|
||||
let mut meshes = Vec::with_capacity(work.len());
|
||||
for (key, finer) in work {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let input = inputs.mesh_input(*key, finer.clone());
|
||||
let mut mesh = mesher.mesh(
|
||||
input.key,
|
||||
&input.columns(),
|
||||
cut,
|
||||
bounds,
|
||||
context,
|
||||
&input.neighbors,
|
||||
&input.finer,
|
||||
);
|
||||
mesh.sort_solid();
|
||||
meshes.push((input.key, mesh));
|
||||
}
|
||||
meshes
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
threads
|
||||
.into_iter()
|
||||
.flat_map(|thread| thread.join().unwrap_or_default())
|
||||
.collect()
|
||||
});
|
||||
if worker.stop.load(Ordering::Relaxed) {
|
||||
break 'work;
|
||||
}
|
||||
Done::Mesh {
|
||||
active: snapshot.active,
|
||||
@@ -840,7 +860,11 @@ impl Lod {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Under the lock the samplers wait on: one between its check
|
||||
// and its wait would miss the wake and never be joined.
|
||||
let jobs = worker.jobs.lock();
|
||||
worker.stop.store(true, Ordering::Relaxed);
|
||||
drop(jobs);
|
||||
worker.wake.notify_all();
|
||||
for handle in sampling {
|
||||
let _ = handle.join();
|
||||
@@ -861,7 +885,6 @@ impl Lod {
|
||||
reduced_detail: false,
|
||||
plan: Plan::default(),
|
||||
pending: FxHashSet::default(),
|
||||
sample_nearby: true,
|
||||
detail_masks: FxHashMap::default(),
|
||||
detail_request_pending: false,
|
||||
detail_request_at: std::time::Instant::now(),
|
||||
@@ -1119,7 +1142,7 @@ impl Lod {
|
||||
.active
|
||||
.iter()
|
||||
.filter(|key| {
|
||||
key.level <= 1
|
||||
key.level <= DETAIL_LEVEL
|
||||
&& !self.plan.split.contains(key)
|
||||
&& !(key.level == 0 && self.covered.contains(&(key.x, key.z)))
|
||||
&& self.detail_masks.get(key) != Some(&[u64::MAX; 4])
|
||||
@@ -1156,7 +1179,7 @@ impl Lod {
|
||||
}
|
||||
}
|
||||
}
|
||||
if chunks.len() >= 32 {
|
||||
if chunks.len() >= DETAIL_REQUEST {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1166,7 +1189,7 @@ impl Lod {
|
||||
let dz = i64::from(z * 16 + 8 - camera[1]);
|
||||
dx * dx + dz * dz
|
||||
});
|
||||
chunks.truncate(32);
|
||||
chunks.truncate(DETAIL_REQUEST);
|
||||
if !chunks.is_empty() {
|
||||
self.detail_request_pending = true;
|
||||
self.shared
|
||||
@@ -1183,7 +1206,6 @@ impl Lod {
|
||||
pub fn update(
|
||||
&mut self,
|
||||
camera: [f64; 3],
|
||||
yaw: f32,
|
||||
detail: i32,
|
||||
distance: u32,
|
||||
covered: Option<FxHashSet<(i32, i32)>>,
|
||||
@@ -1192,14 +1214,6 @@ impl Lod {
|
||||
(((camera[1] - f64::from(MIN_Y + crate::chunk::HEIGHT)).clamp(0., 16384.)) as i32 / 32)
|
||||
* 32;
|
||||
let camera = [camera[0].floor() as i32, camera[2].floor() as i32];
|
||||
// Fill the view ahead first; a nearest-only ring spends most work out
|
||||
// of sight while visible forests remain undecorated noise terrain.
|
||||
let ahead = (detail * 16 + 128).max(elevation * 2).min(4096) as f32;
|
||||
let yaw = yaw.to_radians();
|
||||
let focus = [
|
||||
camera[0] - (yaw.sin() * ahead) as i32,
|
||||
camera[1] + (yaw.cos() * ahead) as i32,
|
||||
];
|
||||
// Replan at chunk boundaries, not on every camera movement.
|
||||
let area = (
|
||||
[camera[0].div_euclid(16), camera[1].div_euclid(16)],
|
||||
@@ -1232,10 +1246,8 @@ impl Lod {
|
||||
if !self.meshing {
|
||||
crate::spans::time("lod_select", || self.select());
|
||||
}
|
||||
crate::spans::time("lod_request", || self.request_detail(focus));
|
||||
crate::spans::time("lod_wanted", || {
|
||||
self.request_samples(camera, focus, yaw, detail, elevation)
|
||||
});
|
||||
crate::spans::time("lod_request", || self.request_detail(camera));
|
||||
crate::spans::time("lod_wanted", || self.request_samples(camera));
|
||||
meshes
|
||||
}
|
||||
fn receive(&mut self, meshes: &mut Vec<(TileKey, SectionMesh)>) {
|
||||
@@ -1389,14 +1401,7 @@ impl Lod {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn request_samples(
|
||||
&mut self,
|
||||
camera: [i32; 2],
|
||||
focus: [i32; 2],
|
||||
yaw: f32,
|
||||
detail: i32,
|
||||
elevation: i32,
|
||||
) {
|
||||
fn request_samples(&mut self, camera: [i32; 2]) {
|
||||
// Every sample slot busy: no tile can be requested this frame.
|
||||
let free = if self.held() {
|
||||
usize::from(
|
||||
@@ -1417,80 +1422,36 @@ impl Lod {
|
||||
if self.held() {
|
||||
self.held_sample_at = std::time::Instant::now();
|
||||
}
|
||||
// Load desired nearby leaves directly, alongside broad fallback coverage.
|
||||
// Waiting for every ancestor/sibling made full detail take minutes to appear.
|
||||
let mut wanted = Vec::new();
|
||||
for &root in &self.plan.roots {
|
||||
if self.plan.nodes.contains(&root) && !self.ready.contains_key(&root) {
|
||||
wanted.push((0, 0, root));
|
||||
}
|
||||
}
|
||||
for &key in &self.plan.nodes {
|
||||
if !self.plan.split.contains(&key) && !self.ready.contains_key(&key) {
|
||||
wanted.push((
|
||||
if key.in_view(camera, yaw) { 1 } else { 3 },
|
||||
i64::from(key.distance(focus)) * 4 + i64::from(key.distance(camera)),
|
||||
key,
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut fallback = Vec::new();
|
||||
for &parent in &self.active {
|
||||
if self.plan.split.contains(&parent) {
|
||||
for child in parent.children() {
|
||||
if self.plan.nodes.contains(&child) && !self.ready.contains_key(&child) {
|
||||
// Covered near terrain bounds projected error. Using
|
||||
// zero distance for a parent touching the camera kept
|
||||
// refining it while huge visible walls waited elsewhere.
|
||||
let priority =
|
||||
i64::from(child.distance(camera).max((detail * 16).max(elevation)))
|
||||
* 1024
|
||||
/ i64::from(child.size());
|
||||
fallback.push((
|
||||
if child.in_view(camera, yaw) { 2 } else { 4 },
|
||||
priority,
|
||||
child,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only the first `free` keys not already pending are taken. A key is
|
||||
// listed at most twice (as a root and as a node), so the best
|
||||
// `2 * (sample_jobs + pending)` entries hold them, in order.
|
||||
let best = 2 * (self.sample_jobs + self.pending.len());
|
||||
// Only the tiles the plan keeps, each at the level its distance
|
||||
// calls for, nearest first, whichever way the player looks: far
|
||||
// terrain grows outward from them in rings. Coarse tiles first under
|
||||
// fine ones near the player cost a far tile's work for terrain that
|
||||
// fine tiles replace moments later.
|
||||
let mut wanted: Vec<_> = self
|
||||
.plan
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|key| !self.plan.split.contains(key) && !self.ready.contains_key(key))
|
||||
.map(|&key| (i64::from(key.distance(camera)), key))
|
||||
.collect();
|
||||
// Only the first `free` keys not already pending are taken: the best
|
||||
// `sample_jobs + pending` entries hold them, in order.
|
||||
let best = self.sample_jobs + self.pending.len();
|
||||
if wanted.len() > best {
|
||||
wanted.select_nth_unstable(best);
|
||||
wanted.truncate(best);
|
||||
}
|
||||
wanted.sort_unstable();
|
||||
// Alternate one nearby final tile with one broad refinement tile.
|
||||
if let Some(fallback) = fallback
|
||||
.into_iter()
|
||||
.filter(|(_, _, k)| !self.pending.contains(k))
|
||||
.min()
|
||||
{
|
||||
let index = if self.sample_nearby {
|
||||
wanted
|
||||
.iter()
|
||||
.position(|(_, _, k)| !self.pending.contains(k))
|
||||
.map_or(0, |i| i + 1)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
wanted.insert(index, fallback);
|
||||
}
|
||||
let mut unique = FxHashSet::default();
|
||||
let wanted: Vec<_> = wanted
|
||||
.into_iter()
|
||||
.map(|(_, _, key)| key)
|
||||
.map(|(_, key)| key)
|
||||
.filter(|key| !self.pending.contains(key) && unique.insert(*key))
|
||||
.collect();
|
||||
if !wanted.is_empty() {
|
||||
let mut jobs = self.shared.jobs.lock().unwrap();
|
||||
for key in wanted.into_iter().take(free) {
|
||||
self.pending.insert(key);
|
||||
self.sample_nearby = !self.sample_nearby;
|
||||
jobs.push_back(Job::Sample(key));
|
||||
}
|
||||
drop(jobs);
|
||||
@@ -1633,7 +1594,10 @@ impl Snapshot {
|
||||
}
|
||||
impl Drop for Lod {
|
||||
fn drop(&mut self) {
|
||||
// Under the jobs lock, as the waiters check it: see the samplers' stop.
|
||||
let jobs = self.shared.jobs.lock();
|
||||
self.shared.stop.store(true, Ordering::Relaxed);
|
||||
drop(jobs);
|
||||
self.shared.wake.notify_all();
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
@@ -1739,8 +1703,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
let (full, reduced) = (leaves(&full), leaves(&reduced));
|
||||
// At 64 chunks most tiles are the near area's finest, the
|
||||
// same in both: about half there, well under from 128.
|
||||
let bound = if distance == 64 { full * 21 / 20 } else { full };
|
||||
assert!(
|
||||
reduced * 2 < full,
|
||||
reduced * 2 < bound,
|
||||
"{reduced} of {full} tiles at {distance}"
|
||||
);
|
||||
}
|
||||
@@ -1875,8 +1842,9 @@ mod tests {
|
||||
let before = Plan::new([0, 0], 2, 256);
|
||||
let after = Plan::with_previous([16, 0], 2, 256, &before, 0, false);
|
||||
let mut retained = 0;
|
||||
let range = i64::from(256 * 16);
|
||||
for key in &before.split {
|
||||
if key.distance([16, 0]) < key.size() * 10 {
|
||||
if key.distance([16, 0]) < key.size() * 10 && key.reach([16, 0]) <= range * range {
|
||||
assert!(after.split.contains(key));
|
||||
retained += 1;
|
||||
}
|
||||
@@ -2303,7 +2271,7 @@ mod tests {
|
||||
let await_meshes = |lod: &mut Lod| {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
let meshes = lod.update(camera, 0.0, 2, 64, None);
|
||||
let meshes = lod.update(camera, 2, 64, None);
|
||||
if !meshes.is_empty() {
|
||||
return meshes;
|
||||
}
|
||||
@@ -2321,7 +2289,7 @@ mod tests {
|
||||
lod.ready.insert(child, lod.ready[&key].clone());
|
||||
}
|
||||
lod.reselect = true;
|
||||
assert!(lod.update(camera, 0.0, 2, 64, None).is_empty());
|
||||
assert!(lod.update(camera, 2, 64, None).is_empty());
|
||||
assert!(
|
||||
lod.active.contains(&key),
|
||||
"parent retired before child meshes"
|
||||
@@ -2351,7 +2319,7 @@ mod tests {
|
||||
lod.replanned = true;
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
lod.update(camera, 0., 2, 64, None);
|
||||
lod.update(camera, 2, 64, None);
|
||||
if !lod.meshing && lod.active.is_empty() {
|
||||
break;
|
||||
}
|
||||
@@ -2389,33 +2357,26 @@ mod tests {
|
||||
level: 0,
|
||||
})
|
||||
.collect();
|
||||
lod.sample_nearby = true;
|
||||
lod.detail_request_pending = true;
|
||||
lod.meshing = true;
|
||||
lod.update(camera, 0.0, 2, 64, Some(FxHashSet::default()));
|
||||
lod.update(camera, 2, 64, Some(FxHashSet::default()));
|
||||
let Some(Job::Sample(nearby)) = lod.shared.jobs.lock().unwrap().pop_front() else {
|
||||
panic!("nearby detail was not scheduled");
|
||||
};
|
||||
assert_eq!(nearby.level, 0);
|
||||
assert!(
|
||||
nearby.origin()[2] > -256 + 64,
|
||||
"detail should load ahead of the view"
|
||||
assert_eq!(
|
||||
nearby.distance([-256, -256]),
|
||||
0,
|
||||
"the nearest tiles load first"
|
||||
);
|
||||
lod.finish_sample(nearby, vec![Column::default(); 256]);
|
||||
lod.update(camera, 0.0, 2, 64, Some(FxHashSet::default()));
|
||||
let Some(Job::Sample(fallback)) = lod.shared.jobs.lock().unwrap().pop_front() else {
|
||||
panic!("fallback refinement was starved");
|
||||
};
|
||||
assert!(fallback.level > 0);
|
||||
lod.pending.remove(&fallback);
|
||||
lod.sample_nearby = true;
|
||||
lod.update(camera, 90.0, 2, 64, Some(FxHashSet::default()));
|
||||
let Some(Job::Sample(turned)) = lod.shared.jobs.lock().unwrap().pop_front() else {
|
||||
panic!("turned view was not scheduled");
|
||||
lod.update(camera, 2, 64, Some(FxHashSet::default()));
|
||||
let Some(Job::Sample(next)) = lod.shared.jobs.lock().unwrap().pop_front() else {
|
||||
panic!("the next ring was not scheduled");
|
||||
};
|
||||
assert!(
|
||||
turned.origin()[0] < -256 - 64,
|
||||
"loading priority should follow a turn"
|
||||
!lod.plan.split.contains(&next) && next.distance([-256, -256]) <= 64,
|
||||
"the next nearest final tile, never a coarse one under it: {next:?}"
|
||||
);
|
||||
lod.shared.jobs.lock().unwrap().clear();
|
||||
let near = TileKey {
|
||||
@@ -2448,17 +2409,19 @@ mod tests {
|
||||
level: 0,
|
||||
})
|
||||
.collect();
|
||||
lod.sample_nearby = false;
|
||||
lod.update(camera, 0., 2, 64, Some(FxHashSet::default()));
|
||||
let Some(Job::Sample(broad)) = lod.shared.jobs.lock().unwrap().pop_front() else {
|
||||
panic!("projected-error refinement missing")
|
||||
lod.update(camera, 2, 64, Some(FxHashSet::default()));
|
||||
let Some(Job::Sample(first)) = lod.shared.jobs.lock().unwrap().pop_front() else {
|
||||
panic!("no final tile scheduled")
|
||||
};
|
||||
assert_eq!(
|
||||
broad.level, 5,
|
||||
"a near tile touching the camera must not starve a huge visible distant tile"
|
||||
(first.level, first.parent()),
|
||||
(1, near),
|
||||
"the nearest parent's final tiles come first"
|
||||
);
|
||||
lod.shared.jobs.lock().unwrap().clear();
|
||||
lod.active = (0..40).map(|x| TileKey { x, z: 0, level: 0 }).collect();
|
||||
lod.active = (0..DETAIL_REQUEST as i32 + 8)
|
||||
.map(|x| TileKey { x, z: 0, level: 0 })
|
||||
.collect();
|
||||
lod.plan.split.clear();
|
||||
lod.detail_masks.clear();
|
||||
lod.covered = [(0, 0)].into_iter().collect();
|
||||
@@ -2471,7 +2434,9 @@ mod tests {
|
||||
};
|
||||
assert_eq!(
|
||||
chunks,
|
||||
(1..=32).map(|x| (x, 0)).collect::<Vec<_>>(),
|
||||
(1..=DETAIL_REQUEST as i32)
|
||||
.map(|x| (x, 0))
|
||||
.collect::<Vec<_>>(),
|
||||
"one unfinished fine tile must not starve neighbouring requests"
|
||||
);
|
||||
|
||||
@@ -2488,7 +2453,7 @@ mod tests {
|
||||
lod.set_near_loading(near_loading);
|
||||
lod.shared.jobs.lock().unwrap().clear();
|
||||
lod.pending.clear();
|
||||
lod.update([-256., 64., -256.], 0., 2, 64, Some(FxHashSet::default()));
|
||||
lod.update([-256., 64., -256.], 2, 64, Some(FxHashSet::default()));
|
||||
let jobs = lod.shared.jobs.lock().unwrap();
|
||||
jobs.iter()
|
||||
.filter(|job| matches!(job, Job::Sample(_)))
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
//! Decorated far-terrain chunks kept on disk, beside a dimension's save, so a
|
||||
//! world opened again shows them without generating them again. One append-only
|
||||
//! file per 32x32 chunk region; a file written by another build, block registry
|
||||
//! or format is cleared on first use.
|
||||
use super::{Column, Span, Surface};
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
fs::{File, OpenOptions},
|
||||
io::{Read, Seek, SeekFrom, Write},
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
const MAGIC: &[u8; 4] = b"HLOD";
|
||||
const FORMAT: u32 = 1;
|
||||
const HEADER: u64 = 16;
|
||||
/// A record: chunk x and z, then the deflated columns' length.
|
||||
const RECORD: u64 = 12;
|
||||
/// Columns of a chunk are a few kilobytes deflated; anything larger is damage.
|
||||
const MAX_RECORD: u32 = 1 << 22;
|
||||
|
||||
type Columns = Arc<[Column; 256]>;
|
||||
|
||||
struct Region {
|
||||
file: File,
|
||||
/// Each chunk's latest record: the offset of its deflated columns, and
|
||||
/// their length.
|
||||
index: FxHashMap<(i32, i32), (u64, u32)>,
|
||||
}
|
||||
|
||||
pub struct DiskCache {
|
||||
dir: PathBuf,
|
||||
key: u64,
|
||||
// ponytail: one lock for every region, with file reads under it; per-region
|
||||
// locks if the sampling threads ever wait on it.
|
||||
regions: Mutex<FxHashMap<(i32, i32), Option<Region>>>,
|
||||
}
|
||||
|
||||
impl DiskCache {
|
||||
/// `key`: what the cached columns depend on (`build_key`); files with
|
||||
/// another are cleared.
|
||||
pub fn new(dir: PathBuf, key: u64) -> Self {
|
||||
Self {
|
||||
dir,
|
||||
key,
|
||||
regions: Mutex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, x: i32, z: i32) -> Option<Columns> {
|
||||
let mut regions = self.regions.lock().ok()?;
|
||||
let region = self.region(&mut regions, x, z, false)?;
|
||||
let &(offset, len) = region.index.get(&(x, z))?;
|
||||
let mut bytes = vec![0; len as usize];
|
||||
region.file.seek(SeekFrom::Start(offset)).ok()?;
|
||||
region.file.read_exact(&mut bytes).ok()?;
|
||||
drop(regions);
|
||||
decode(&bytes).map(Arc::new)
|
||||
}
|
||||
|
||||
fn contains(&self, x: i32, z: i32) -> bool {
|
||||
let Ok(mut regions) = self.regions.lock() else {
|
||||
return false;
|
||||
};
|
||||
self.region(&mut regions, x, z, false)
|
||||
.is_some_and(|region| region.index.contains_key(&(x, z)))
|
||||
}
|
||||
|
||||
/// Writes a chunk not cached yet. One already cached stays as it is: the
|
||||
/// build key clears stale ones, and a generation batch hands back
|
||||
/// neighbours cached before, which appended again grew the files each
|
||||
/// session.
|
||||
pub fn put(&self, x: i32, z: i32, columns: &[Column; 256]) {
|
||||
if self.contains(x, z) {
|
||||
return;
|
||||
}
|
||||
let bytes = encode(columns);
|
||||
let Ok(mut regions) = self.regions.lock() else {
|
||||
return;
|
||||
};
|
||||
let Some(region) = self.region(&mut regions, x, z, true) else {
|
||||
return;
|
||||
};
|
||||
// Another sampler's thread may have written it while this one encoded.
|
||||
if region.index.contains_key(&(x, z)) {
|
||||
return;
|
||||
}
|
||||
let mut record = Vec::with_capacity(RECORD as usize + bytes.len());
|
||||
record.extend_from_slice(&x.to_le_bytes());
|
||||
record.extend_from_slice(&z.to_le_bytes());
|
||||
record.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
|
||||
record.extend_from_slice(&bytes);
|
||||
let Ok(end) = region.file.seek(SeekFrom::End(0)) else {
|
||||
return;
|
||||
};
|
||||
// One write, so a crash leaves at most a torn last record, which
|
||||
// the next open cuts off.
|
||||
if region.file.write_all(&record).is_ok() {
|
||||
region
|
||||
.index
|
||||
.insert((x, z), (end + RECORD, bytes.len() as u32));
|
||||
}
|
||||
}
|
||||
|
||||
fn region<'a>(
|
||||
&self,
|
||||
regions: &'a mut FxHashMap<(i32, i32), Option<Region>>,
|
||||
x: i32,
|
||||
z: i32,
|
||||
create: bool,
|
||||
) -> Option<&'a mut Region> {
|
||||
let at = (x >> 5, z >> 5);
|
||||
if create && matches!(regions.get(&at), Some(None)) {
|
||||
regions.remove(&at);
|
||||
}
|
||||
regions
|
||||
.entry(at)
|
||||
.or_insert_with(|| self.open(at, create))
|
||||
.as_mut()
|
||||
}
|
||||
|
||||
fn open(&self, (rx, rz): (i32, i32), create: bool) -> Option<Region> {
|
||||
let path = self.dir.join(format!("r.{rx}.{rz}.hlod"));
|
||||
if !create && !path.exists() {
|
||||
return None;
|
||||
}
|
||||
if create {
|
||||
std::fs::create_dir_all(&self.dir).ok()?;
|
||||
}
|
||||
let mut file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(create)
|
||||
.truncate(false)
|
||||
.open(&path)
|
||||
.ok()?;
|
||||
let mut header = [0; HEADER as usize];
|
||||
let current = file.read_exact(&mut header).is_ok()
|
||||
&& header[..4] == *MAGIC
|
||||
&& header[4..8] == FORMAT.to_le_bytes()
|
||||
&& header[8..] == self.key.to_le_bytes();
|
||||
let mut index = FxHashMap::default();
|
||||
if current {
|
||||
let size = file.metadata().ok()?.len();
|
||||
let mut at = HEADER;
|
||||
let mut record = [0; RECORD as usize];
|
||||
while at + RECORD <= size {
|
||||
file.seek(SeekFrom::Start(at)).ok()?;
|
||||
file.read_exact(&mut record).ok()?;
|
||||
let field = |i: usize| u32::from_le_bytes(record[i..i + 4].try_into().unwrap());
|
||||
let len = field(8);
|
||||
if len > MAX_RECORD || at + RECORD + u64::from(len) > size {
|
||||
break;
|
||||
}
|
||||
index.insert((field(0) as i32, field(4) as i32), (at + RECORD, len));
|
||||
at += RECORD + u64::from(len);
|
||||
}
|
||||
if at < size {
|
||||
file.set_len(at).ok()?;
|
||||
}
|
||||
} else {
|
||||
file.set_len(0).ok()?;
|
||||
file.seek(SeekFrom::Start(0)).ok()?;
|
||||
let mut header = Vec::with_capacity(HEADER as usize);
|
||||
header.extend_from_slice(MAGIC);
|
||||
header.extend_from_slice(&FORMAT.to_le_bytes());
|
||||
header.extend_from_slice(&self.key.to_le_bytes());
|
||||
file.write_all(&header).ok()?;
|
||||
}
|
||||
Some(Region { file, index })
|
||||
}
|
||||
}
|
||||
|
||||
/// What cached columns depend on: this build (its commit, or for a local
|
||||
/// build the executable's own time, so a rebuilt generator starts afresh),
|
||||
/// the block states and the biomes.
|
||||
pub fn build_key(states: usize, biomes: usize) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::hash::DefaultHasher::new();
|
||||
FORMAT.hash(&mut hasher);
|
||||
env!("CARGO_PKG_VERSION").hash(&mut hasher);
|
||||
match option_env!("HORIZON_COMMIT") {
|
||||
Some(commit) => commit.hash(&mut hasher),
|
||||
None => std::env::current_exe()
|
||||
.and_then(|exe| exe.metadata()?.modified())
|
||||
.ok()
|
||||
.hash(&mut hasher),
|
||||
}
|
||||
states.hash(&mut hasher);
|
||||
biomes.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn encode(columns: &[Column; 256]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
for column in columns {
|
||||
match column.surface {
|
||||
None => out.push(0),
|
||||
Some(s) => {
|
||||
out.push(1 + u8::from(s.water.is_some()));
|
||||
out.extend_from_slice(&s.height.to_le_bytes());
|
||||
out.extend_from_slice(&s.state.0.to_le_bytes());
|
||||
out.extend_from_slice(&s.biome.to_le_bytes());
|
||||
if let Some((height, state)) = s.water {
|
||||
out.extend_from_slice(&height.to_le_bytes());
|
||||
out.extend_from_slice(&state.0.to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&(column.spans.len() as u16).to_le_bytes());
|
||||
for span in &column.spans {
|
||||
out.extend_from_slice(&span.min.to_le_bytes());
|
||||
out.extend_from_slice(&span.max.to_le_bytes());
|
||||
out.extend_from_slice(&span.state.0.to_le_bytes());
|
||||
out.extend_from_slice(&span.biome.to_le_bytes());
|
||||
out.extend_from_slice(&span.light.to_le_bytes());
|
||||
}
|
||||
}
|
||||
let mut deflate =
|
||||
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
// Writing to a Vec cannot fail.
|
||||
deflate.write_all(&out).unwrap();
|
||||
deflate.finish().unwrap()
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> Option<[Column; 256]> {
|
||||
let mut raw = Vec::new();
|
||||
flate2::read::DeflateDecoder::new(bytes)
|
||||
.take(u64::from(MAX_RECORD) * 8)
|
||||
.read_to_end(&mut raw)
|
||||
.ok()?;
|
||||
let mut at = 0;
|
||||
let mut take = |n: usize| {
|
||||
let field = raw.get(at..at + n)?;
|
||||
at += n;
|
||||
Some(field)
|
||||
};
|
||||
let mut columns: [Column; 256] = std::array::from_fn(|_| Column::default());
|
||||
for column in &mut columns {
|
||||
let i32_ = |b: &[u8]| i32::from_le_bytes(b.try_into().unwrap());
|
||||
let u32_ = |b: &[u8]| u32::from_le_bytes(b.try_into().unwrap());
|
||||
let u16_ = |b: &[u8]| u16::from_le_bytes(b.try_into().unwrap());
|
||||
let i16_ = |b: &[u8]| i16::from_le_bytes(b.try_into().unwrap());
|
||||
let tag = take(1)?[0];
|
||||
if tag > 2 {
|
||||
return None;
|
||||
}
|
||||
if tag > 0 {
|
||||
let height = i32_(take(4)?);
|
||||
let state = BlockStateId(u32_(take(4)?));
|
||||
let biome = u16_(take(2)?);
|
||||
let water = if tag == 2 {
|
||||
Some((i32_(take(4)?), BlockStateId(u32_(take(4)?))))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
column.surface = Some(Surface {
|
||||
height,
|
||||
state,
|
||||
water,
|
||||
biome,
|
||||
});
|
||||
}
|
||||
let count = u16_(take(2)?);
|
||||
column.spans.reserve(count.into());
|
||||
for _ in 0..count {
|
||||
let span = Span {
|
||||
min: i16_(take(2)?),
|
||||
max: i16_(take(2)?),
|
||||
state: BlockStateId(u32_(take(4)?)),
|
||||
biome: u16_(take(2)?),
|
||||
light: u16_(take(2)?),
|
||||
};
|
||||
if span.min >= span.max {
|
||||
return None;
|
||||
}
|
||||
column.spans.push(span);
|
||||
}
|
||||
}
|
||||
(at == raw.len()).then_some(columns)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn columns(seed: u32) -> [Column; 256] {
|
||||
std::array::from_fn(|i| {
|
||||
let i = i as u32 + seed;
|
||||
Column {
|
||||
surface: (!i.is_multiple_of(3)).then(|| Surface {
|
||||
height: 60 + (i % 7) as i32,
|
||||
state: BlockStateId(i),
|
||||
water: (i.is_multiple_of(2)).then_some((63, BlockStateId(9))),
|
||||
biome: (i % 5) as u16,
|
||||
}),
|
||||
spans: (0..i % 4)
|
||||
.map(|n| Span {
|
||||
min: (n * 10) as i16 - 64,
|
||||
max: (n * 10 + 4) as i16 - 64,
|
||||
state: BlockStateId(n + i),
|
||||
biome: 2,
|
||||
light: 240,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunks_survive_reopening_and_a_torn_last_record() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-lod-cache-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let cache = DiskCache::new(dir.clone(), 7);
|
||||
assert!(cache.get(3, -40).is_none(), "nothing cached yet");
|
||||
cache.put(3, -40, &columns(0));
|
||||
cache.put(4, -40, &columns(1));
|
||||
let path = dir.join("r.0.-2.hlod");
|
||||
let size = std::fs::metadata(&path).unwrap().len();
|
||||
// A chunk cached already is not appended again.
|
||||
cache.put(3, -40, &columns(2));
|
||||
assert_eq!(*cache.get(3, -40).unwrap(), columns(0));
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().len(), size);
|
||||
drop(cache);
|
||||
|
||||
let file = OpenOptions::new().write(true).open(&path).unwrap();
|
||||
file.set_len(size + 7).unwrap();
|
||||
drop(file);
|
||||
let cache = DiskCache::new(dir.clone(), 7);
|
||||
assert_eq!(*cache.get(3, -40).unwrap(), columns(0));
|
||||
assert_eq!(*cache.get(4, -40).unwrap(), columns(1));
|
||||
assert_eq!(
|
||||
std::fs::metadata(&path).unwrap().len(),
|
||||
size,
|
||||
"torn tail cut"
|
||||
);
|
||||
drop(cache);
|
||||
|
||||
// Another build's files are cleared, not read.
|
||||
let cache = DiskCache::new(dir.clone(), 8);
|
||||
assert!(cache.get(3, -40).is_none());
|
||||
cache.put(5, -40, &columns(3));
|
||||
assert!(cache.get(4, -40).is_none());
|
||||
assert_eq!(*cache.get(5, -40).unwrap(), columns(3));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_records_read_as_missing() {
|
||||
let bytes = encode(&columns(0));
|
||||
assert_eq!(decode(&bytes).unwrap(), columns(0));
|
||||
assert!(decode(&bytes[..bytes.len() / 2]).is_none());
|
||||
assert!(decode(b"not deflate").is_none());
|
||||
}
|
||||
}
|
||||
@@ -74,10 +74,18 @@ impl Mesher {
|
||||
covered_bounds,
|
||||
neighbors,
|
||||
finer,
|
||||
|span, direction| self.material(span, direction, context),
|
||||
|span, direction, at| self.material(span, direction, at, context),
|
||||
)
|
||||
}
|
||||
fn material(&mut self, span: &Span, direction: Direction, context: &WorkerContext) -> Material {
|
||||
/// `at`: the block column the face covers, for the grass colours that
|
||||
/// change across a biome (swamps).
|
||||
fn material(
|
||||
&mut self,
|
||||
span: &Span,
|
||||
direction: Direction,
|
||||
at: [i32; 2],
|
||||
context: &WorkerContext,
|
||||
) -> Material {
|
||||
let styles = *self
|
||||
.styles
|
||||
.entry((span.state, direction as u8))
|
||||
@@ -179,7 +187,11 @@ impl Mesher {
|
||||
face.map(|face| {
|
||||
let rgb = match face.tint {
|
||||
Tint::None => 0xffffff,
|
||||
Tint::Grass => biome.grass,
|
||||
Tint::Grass => {
|
||||
context
|
||||
.biomes
|
||||
.grass(span.biome, f64::from(at[0]), f64::from(at[1]))
|
||||
}
|
||||
Tint::Foliage => biome.foliage,
|
||||
Tint::Dry => biome.dry_foliage,
|
||||
Tint::Water => biome.water,
|
||||
@@ -256,7 +268,7 @@ fn mesh_columns(
|
||||
columns: &[Column],
|
||||
covered: &FxHashSet<(i32, i32)>,
|
||||
covered_bounds: Option<[i32; 4]>,
|
||||
material: impl FnMut(&Span, Direction) -> Material,
|
||||
material: impl FnMut(&Span, Direction, [i32; 2]) -> Material,
|
||||
) -> SectionMesh {
|
||||
mesh_columns_with_neighbors(key, columns, covered, covered_bounds, &[], material)
|
||||
}
|
||||
@@ -267,7 +279,7 @@ fn mesh_columns_with_neighbors(
|
||||
covered: &FxHashSet<(i32, i32)>,
|
||||
covered_bounds: Option<[i32; 4]>,
|
||||
neighbors: &[Column],
|
||||
material: impl FnMut(&Span, Direction) -> Material,
|
||||
material: impl FnMut(&Span, Direction, [i32; 2]) -> Material,
|
||||
) -> SectionMesh {
|
||||
assert!(neighbors.is_empty() || neighbors.len() == 4 * CELLS);
|
||||
let stride = key.size() / CELLS as i32;
|
||||
@@ -293,7 +305,7 @@ fn mesh_columns_with_edges(
|
||||
covered_bounds: Option<[i32; 4]>,
|
||||
neighbors: &[Vec<EdgeColumn>; 4],
|
||||
finer: &[TileKey],
|
||||
mut material: impl FnMut(&Span, Direction) -> Material,
|
||||
mut material: impl FnMut(&Span, Direction, [i32; 2]) -> Material,
|
||||
) -> SectionMesh {
|
||||
assert_eq!(columns.len(), CELLS * CELLS);
|
||||
let mut count = CELLS * CELLS;
|
||||
@@ -325,17 +337,31 @@ fn mesh_columns_with_edges(
|
||||
neighbors,
|
||||
stride,
|
||||
|x, z| cell_step(x, z) == stride,
|
||||
|span| material(span, Direction::Up).fluid != 0,
|
||||
|span| material(span, Direction::Up, [ox, oz]).fluid != 0,
|
||||
);
|
||||
// A cell's faces take the colours at its centre. The tile's neighbours'
|
||||
// columns only hide faces, so their colours never show.
|
||||
let centre = |i: usize| {
|
||||
if i < CELLS * CELLS {
|
||||
[
|
||||
ox + (i % CELLS) as i32 * stride + stride / 2,
|
||||
oz + (i / CELLS) as i32 * stride + stride / 2,
|
||||
]
|
||||
} else {
|
||||
[ox, oz]
|
||||
}
|
||||
};
|
||||
let prepared: Vec<Vec<Segment<'_>>> = filled
|
||||
.iter()
|
||||
.map(|column| &**column)
|
||||
.chain(neighbors.iter().flatten().map(|edge| &edge.column))
|
||||
.map(|column| {
|
||||
.enumerate()
|
||||
.map(|(index, column)| {
|
||||
let at = centre(index);
|
||||
let materials: Vec<_> = column
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| material(s, Direction::Up))
|
||||
.map(|s| material(s, Direction::Up, at))
|
||||
.collect();
|
||||
column
|
||||
.spans
|
||||
@@ -425,7 +451,7 @@ fn mesh_columns_with_edges(
|
||||
u1: x1,
|
||||
v1: z1,
|
||||
},
|
||||
material(segment.span, Direction::Down),
|
||||
material(segment.span, Direction::Down, centre(z * CELLS + x)),
|
||||
);
|
||||
}
|
||||
for (direction, nx, nz, edge, nw, plane, u0, u1) in [
|
||||
@@ -470,21 +496,25 @@ fn mesh_columns_with_edges(
|
||||
x1,
|
||||
),
|
||||
] {
|
||||
let side = material(segment.span, direction);
|
||||
let side = material(segment.span, direction, centre(z * CELLS + x));
|
||||
// Near meshes may have culled against the real neighbor.
|
||||
// Keep a finite seal where their full-detail area begins.
|
||||
// Keep a finite seal where their full-detail area begins:
|
||||
// opaque only, as the near water carries on into the far
|
||||
// water and a seal shows through it as a wall.
|
||||
if is_covered(nw[0], nw[1]) {
|
||||
add_side(
|
||||
&mut faces,
|
||||
direction,
|
||||
plane,
|
||||
u0,
|
||||
u1,
|
||||
segment,
|
||||
&[],
|
||||
side,
|
||||
own_sky,
|
||||
);
|
||||
if segment.material.opaque {
|
||||
add_side(
|
||||
&mut faces,
|
||||
direction,
|
||||
plane,
|
||||
u0,
|
||||
u1,
|
||||
segment,
|
||||
&[],
|
||||
side,
|
||||
own_sky,
|
||||
);
|
||||
}
|
||||
} else if !edge {
|
||||
add_side(
|
||||
&mut faces, direction, plane, u0, u1, segment, column, side,
|
||||
@@ -597,7 +627,7 @@ mod refinement_tests {
|
||||
None,
|
||||
&std::array::from_fn(|_| Vec::new()),
|
||||
&[fine],
|
||||
|_, _| Material {
|
||||
|_, _, _| Material {
|
||||
faces: [
|
||||
Some(Paint {
|
||||
layer: 1,
|
||||
@@ -646,13 +676,15 @@ mod refinement_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The tile's columns with the gaps no neighbour's open air reaches filled
|
||||
/// with the material below them, or the fluid above them (kelp and seagrass
|
||||
/// leave gaps in water): a cave seen from nowhere adds no faces. A
|
||||
/// gap is open when a neighbouring column's top lies below the gap's top, so
|
||||
/// that column's open air looks into it. Unknown neighbours, and cells that
|
||||
/// `whole` rejects (they meet full-detail or finer terrain), count as open;
|
||||
/// those cells keep their gaps.
|
||||
/// The tile's columns with the gaps no open air reaches filled with the
|
||||
/// material below them, or the fluid above them (kelp and seagrass leave gaps
|
||||
/// in water): a cave seen from nowhere adds no faces. A gap is open when a
|
||||
/// neighbouring column's top lies below the gap's top, so that column's open
|
||||
/// air looks into it, when a gap of the tile's neighbour meets it, or when it
|
||||
/// meets an open gap of a neighbouring column: the air under a canopy reaches
|
||||
/// the sky through any clearing, where filling it raised pillars of the floor
|
||||
/// block. Unknown neighbours, and cells that `whole` rejects (they meet
|
||||
/// full-detail or finer terrain), count as open; those cells keep their gaps.
|
||||
fn fill_buried_gaps<'a>(
|
||||
columns: &'a [Column],
|
||||
neighbors: &[Vec<EdgeColumn>; 4],
|
||||
@@ -669,6 +701,18 @@ fn fill_buried_gaps<'a>(
|
||||
.find(|span| span.min < span.max)
|
||||
.map_or(i16::MIN, |span| span.max)
|
||||
};
|
||||
// Each column's gaps: the index of the span above, the gap's bottom and top.
|
||||
let gaps_of = |column: &Column| -> Vec<(usize, i16, i16)> {
|
||||
column
|
||||
.spans
|
||||
.windows(2)
|
||||
.enumerate()
|
||||
.filter(|(_, pair)| pair[0].max < pair[1].min)
|
||||
.map(|(k, pair)| (k + 1, pair[0].max, pair[1].min))
|
||||
.collect()
|
||||
};
|
||||
let meets =
|
||||
|(lo, hi): (i16, i16), (other_lo, other_hi): (i16, i16)| lo < other_hi && other_lo < hi;
|
||||
let tops: Vec<_> = (0..CELLS * CELLS)
|
||||
.map(|i| {
|
||||
if whole(i % CELLS, i / CELLS) {
|
||||
@@ -678,57 +722,110 @@ fn fill_buried_gaps<'a>(
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let edge = |side: usize, cell: usize| {
|
||||
let gaps: Vec<_> = (0..CELLS * CELLS)
|
||||
.map(|i| {
|
||||
if whole(i % CELLS, i / CELLS) {
|
||||
gaps_of(&columns[i])
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let touching = |side: usize, cell: usize| {
|
||||
let (start, end) = (cell as i32 * stride, (cell as i32 + 1) * stride);
|
||||
neighbors[side]
|
||||
.iter()
|
||||
.filter(|edge| edge.start < end && start < edge.end)
|
||||
.filter(move |edge| edge.start < end && start < edge.end)
|
||||
};
|
||||
let edge = |side: usize, cell: usize| {
|
||||
touching(side, cell)
|
||||
.map(|edge| top(&edge.column))
|
||||
.min()
|
||||
.unwrap_or(i16::MIN)
|
||||
};
|
||||
// A gap of the tile's neighbour may be open through tiles this one can't
|
||||
// see: it counts as open.
|
||||
let edge_gap = |side: usize, cell: usize, gap: (i16, i16)| {
|
||||
touching(side, cell).any(|edge| {
|
||||
gaps_of(&edge.column)
|
||||
.iter()
|
||||
.any(|&(_, lo, hi)| meets((lo, hi), gap))
|
||||
})
|
||||
};
|
||||
let around = |i: usize| {
|
||||
let (x, z) = (i % CELLS, i / CELLS);
|
||||
[
|
||||
(x > 0).then(|| i - 1),
|
||||
(x + 1 < CELLS).then(|| i + 1),
|
||||
(z > 0).then(|| i - CELLS),
|
||||
(z + 1 < CELLS).then(|| i + CELLS),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
};
|
||||
let mut open: Vec<Vec<bool>> = gaps.iter().map(|g| vec![false; g.len()]).collect();
|
||||
let mut reached = Vec::new();
|
||||
for i in 0..CELLS * CELLS {
|
||||
let (x, z) = (i % CELLS, i / CELLS);
|
||||
let lowest = [
|
||||
if x > 0 { tops[i - 1] } else { edge(0, z) },
|
||||
if x + 1 < CELLS {
|
||||
tops[i + 1]
|
||||
} else {
|
||||
edge(1, z)
|
||||
},
|
||||
if z > 0 { tops[i - CELLS] } else { edge(2, x) },
|
||||
if z + 1 < CELLS {
|
||||
tops[i + CELLS]
|
||||
} else {
|
||||
edge(3, x)
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.min()
|
||||
.unwrap_or(i16::MIN);
|
||||
for (g, &(_, lo, hi)) in gaps[i].iter().enumerate() {
|
||||
let gap = (lo, hi);
|
||||
if hi > lowest
|
||||
|| (x == 0 && edge_gap(0, z, gap))
|
||||
|| (x + 1 == CELLS && edge_gap(1, z, gap))
|
||||
|| (z == 0 && edge_gap(2, x, gap))
|
||||
|| (z + 1 == CELLS && edge_gap(3, x, gap))
|
||||
{
|
||||
open[i][g] = true;
|
||||
reached.push((i, g));
|
||||
}
|
||||
}
|
||||
}
|
||||
while let Some((i, g)) = reached.pop() {
|
||||
let (_, lo, hi) = gaps[i][g];
|
||||
for j in around(i) {
|
||||
for (h, &(_, other_lo, other_hi)) in gaps[j].iter().enumerate() {
|
||||
if !open[j][h] && meets((lo, hi), (other_lo, other_hi)) {
|
||||
open[j][h] = true;
|
||||
reached.push((j, h));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, column)| {
|
||||
let (x, z) = (i % CELLS, i / CELLS);
|
||||
if column.spans.len() < 2 || !whole(x, z) {
|
||||
return Cow::Borrowed(column);
|
||||
}
|
||||
let open = [
|
||||
if x > 0 { tops[i - 1] } else { edge(0, z) },
|
||||
if x + 1 < CELLS {
|
||||
tops[i + 1]
|
||||
} else {
|
||||
edge(1, z)
|
||||
},
|
||||
if z > 0 { tops[i - CELLS] } else { edge(2, x) },
|
||||
if z + 1 < CELLS {
|
||||
tops[i + CELLS]
|
||||
} else {
|
||||
edge(3, x)
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.min()
|
||||
.unwrap_or(i16::MIN);
|
||||
let buried = |below: &Span, above: &Span| below.max < above.min && above.min <= open;
|
||||
if !column
|
||||
.spans
|
||||
.windows(2)
|
||||
.any(|pair| buried(&pair[0], &pair[1]))
|
||||
{
|
||||
if open[i].iter().all(|&open| open) {
|
||||
return Cow::Borrowed(column);
|
||||
}
|
||||
let mut filled = Column {
|
||||
surface: column.surface,
|
||||
spans: Vec::with_capacity(column.spans.len()),
|
||||
};
|
||||
for &span in &column.spans {
|
||||
for (k, &span) in column.spans.iter().enumerate() {
|
||||
let mut span = span;
|
||||
if let Some(below) = filled.spans.last_mut()
|
||||
&& buried(below, &span)
|
||||
{
|
||||
let buried = gaps[i]
|
||||
.iter()
|
||||
.zip(&open[i])
|
||||
.any(|(&(above, _, _), &open)| above == k && !open);
|
||||
if buried && let Some(below) = filled.spans.last_mut() {
|
||||
if fluid(&span) {
|
||||
span.min = below.max;
|
||||
} else {
|
||||
@@ -919,7 +1016,7 @@ mod tests {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn material(span: &Span, _: Direction) -> Material {
|
||||
fn material(span: &Span, _: Direction, _: [i32; 2]) -> Material {
|
||||
let water = span.state.0 == 2;
|
||||
Material {
|
||||
faces: [
|
||||
@@ -1117,6 +1214,42 @@ mod tests {
|
||||
assert!(cave[0].iter().all(|v| v.light == 0));
|
||||
}
|
||||
#[test]
|
||||
fn air_under_a_canopy_stays_open_through_a_clearing() {
|
||||
let column = |spans: Vec<Span>| Column {
|
||||
surface: None,
|
||||
spans,
|
||||
};
|
||||
// The tile's neighbours are solid hills: no open air from the sides.
|
||||
let hill = || EdgeColumn {
|
||||
start: 0,
|
||||
end: CELLS as i32,
|
||||
column: column(vec![span(0, 20, 1)]),
|
||||
};
|
||||
let neighbors = [vec![hill()], vec![hill()], vec![hill()], vec![hill()]];
|
||||
let fill = |columns: &[Column]| -> Vec<Vec<Span>> {
|
||||
fill_buried_gaps(columns, &neighbors, 1, |_, _| true, |_| false)
|
||||
.iter()
|
||||
.map(|filled| filled.spans.clone())
|
||||
.collect()
|
||||
};
|
||||
let mut forest = vec![column(vec![span(0, 5, 1), span(10, 14, 3)]); CELLS * CELLS];
|
||||
// A closed canopy hides the floor: filling it under there shows nowhere.
|
||||
assert!(fill(&forest).iter().all(|spans| spans[0].max == 10));
|
||||
// One clearing lets the sky in: the air under every tree stays air,
|
||||
// not a pillar of the floor block up to the leaves.
|
||||
forest[5 * CELLS + 5] = column(vec![span(0, 5, 1)]);
|
||||
let filled = fill(&forest);
|
||||
for (filled, column) in filled.iter().zip(&forest) {
|
||||
assert_eq!(filled, &column.spans);
|
||||
}
|
||||
// A cave two columns wide sealed in rock is still filled.
|
||||
let mut rock = vec![column(vec![span(0, 20, 1)]); CELLS * CELLS];
|
||||
for cave in [5 * CELLS + 5, 5 * CELLS + 6] {
|
||||
rock[cave] = column(vec![span(0, 8, 1), span(12, 20, 1)]);
|
||||
}
|
||||
assert!(fill(&rock).iter().all(|spans| spans == &[span(0, 20, 1)]));
|
||||
}
|
||||
#[test]
|
||||
fn caves_no_neighbor_sees_into_add_no_faces() {
|
||||
let mut columns = columns();
|
||||
for column in &mut columns {
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
//! Messages go through the `log` facade. Horizon's own still print their plain text on stderr,
|
||||
//! as they did before the log existed; other crates' errors only reach the file (their
|
||||
//! warnings are mostly the Vulkan loader's startup chatter).
|
||||
use std::collections::VecDeque;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::{DateTime, Local, NaiveDate};
|
||||
|
||||
@@ -20,6 +21,11 @@ use chrono::{DateTime, Local, NaiveDate};
|
||||
pub const MAX_ARCHIVES_PER_DAY: u32 = 7;
|
||||
/// Formatted lines kept in memory for crash reports.
|
||||
const RECENT: usize = 50;
|
||||
/// Warnings and errors one line of code may log in a [`FLOOD_WINDOW`]; past that they are counted,
|
||||
/// not written, so a warning repeated every packet can't fill the disk. Vanilla has no such limit:
|
||||
/// below it the log is vanilla's, line for line.
|
||||
const FLOOD_LINES: u32 = 100;
|
||||
const FLOOD_WINDOW: Duration = Duration::from_secs(60);
|
||||
|
||||
/// The platform data folder (what the `dirs` crate's data_dir gives).
|
||||
pub fn data_dir() -> PathBuf {
|
||||
@@ -283,6 +289,59 @@ struct Logger {
|
||||
/// What the main thread is called in the log: "Render thread", or "Server thread" for the
|
||||
/// dedicated server, as in vanilla.
|
||||
main_thread: &'static str,
|
||||
floods: Mutex<Floods>,
|
||||
}
|
||||
|
||||
/// What the flood limit does with one warning or error.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Gate {
|
||||
/// Write it, after saying how many lines like it were left out in the window before.
|
||||
Write {
|
||||
left_out: u32,
|
||||
},
|
||||
/// Write it, then say the next ones from this line of code wait for the next window.
|
||||
Last,
|
||||
Skip,
|
||||
}
|
||||
|
||||
/// Warnings and errors per line of code, in windows of [`FLOOD_WINDOW`].
|
||||
#[derive(Default)]
|
||||
struct Floods {
|
||||
sites: HashMap<(String, u32), Flood>,
|
||||
}
|
||||
|
||||
struct Flood {
|
||||
since: Instant,
|
||||
written: u32,
|
||||
left_out: u32,
|
||||
}
|
||||
|
||||
impl Floods {
|
||||
fn gate(&mut self, file: &str, line: u32, now: Instant) -> Gate {
|
||||
let flood = self.sites.entry((file.to_owned(), line)).or_insert(Flood {
|
||||
since: now,
|
||||
written: 0,
|
||||
left_out: 0,
|
||||
});
|
||||
let mut left_out = 0;
|
||||
if now.duration_since(flood.since) >= FLOOD_WINDOW {
|
||||
left_out = flood.left_out;
|
||||
*flood = Flood {
|
||||
since: now,
|
||||
written: 0,
|
||||
left_out: 0,
|
||||
};
|
||||
}
|
||||
flood.written += 1;
|
||||
match flood.written.cmp(&FLOOD_LINES) {
|
||||
std::cmp::Ordering::Less => Gate::Write { left_out },
|
||||
std::cmp::Ordering::Equal => Gate::Last,
|
||||
std::cmp::Ordering::Greater => {
|
||||
flood.left_out += 1;
|
||||
Gate::Skip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static LOGGER: std::sync::OnceLock<Logger> = std::sync::OnceLock::new();
|
||||
@@ -308,8 +367,37 @@ impl log::Log for Logger {
|
||||
Some(name) => name,
|
||||
None => "unnamed",
|
||||
};
|
||||
let gate = if record.level() <= log::Level::Warn {
|
||||
let mut floods = self.floods.lock().unwrap_or_else(|e| e.into_inner());
|
||||
floods.gate(
|
||||
record.file().unwrap_or(record.target()),
|
||||
record.line().unwrap_or(0),
|
||||
Instant::now(),
|
||||
)
|
||||
} else {
|
||||
Gate::Write { left_out: 0 }
|
||||
};
|
||||
let level = record.level();
|
||||
match gate {
|
||||
Gate::Skip => return,
|
||||
Gate::Write { left_out } if left_out > 0 => self.write(
|
||||
thread,
|
||||
level,
|
||||
&format!(
|
||||
"{left_out} more lines like the next one were not logged in the last minute"
|
||||
),
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
let stderr = ours(record.target()).then(|| message.clone());
|
||||
self.send(thread, record.level(), &message, stderr);
|
||||
self.send(thread, level, &message, stderr);
|
||||
if gate == Gate::Last {
|
||||
self.write(
|
||||
thread,
|
||||
level,
|
||||
&format!("{FLOOD_LINES} lines like this one in a minute: the next ones are counted, not logged"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits (a second at most) until every line logged before is in the file.
|
||||
@@ -403,6 +491,7 @@ pub fn init(home: &Path, main_thread: &'static str) {
|
||||
out,
|
||||
recent: Mutex::new(VecDeque::new()),
|
||||
main_thread,
|
||||
floods: Mutex::new(Floods::default()),
|
||||
}
|
||||
});
|
||||
if log::set_logger(logger).is_ok() {
|
||||
@@ -478,6 +567,35 @@ pub fn newest_crash_report(home: &Path) -> Option<PathBuf> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_warning_repeated_every_packet_is_counted_past_a_hundred_a_minute() {
|
||||
let (mut floods, start) = (Floods::default(), Instant::now());
|
||||
let gates: Vec<Gate> = (0..140)
|
||||
.map(|i| floods.gate("lan_vehicles.rs", 345, start + Duration::from_millis(i * 7)))
|
||||
.collect();
|
||||
assert!(
|
||||
gates[..99]
|
||||
.iter()
|
||||
.all(|g| *g == Gate::Write { left_out: 0 })
|
||||
);
|
||||
assert_eq!(gates[99], Gate::Last);
|
||||
assert!(gates[100..].iter().all(|g| *g == Gate::Skip));
|
||||
// Another line of code has its own count.
|
||||
assert_eq!(
|
||||
floods.gate("lan_vehicles.rs", 330, start),
|
||||
Gate::Write { left_out: 0 }
|
||||
);
|
||||
// The next minute writes again, and says how many were left out.
|
||||
assert_eq!(
|
||||
floods.gate("lan_vehicles.rs", 345, start + FLOOD_WINDOW),
|
||||
Gate::Write { left_out: 40 }
|
||||
);
|
||||
assert_eq!(
|
||||
floods.gate("lan_vehicles.rs", 345, start + FLOOD_WINDOW),
|
||||
Gate::Write { left_out: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
fn day(d: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 10, d).unwrap()
|
||||
}
|
||||
|
||||
@@ -662,6 +662,15 @@ impl Padded {
|
||||
c.get(x & 15, wy, z & 15)
|
||||
};
|
||||
light[i] = c.light(x & 15, wy, z & 15);
|
||||
} else if let Some(c) = region[1][1] {
|
||||
// A neighbour not loaded continues this chunk's edge
|
||||
// column. As lit air, the outermost chunks' water drew
|
||||
// a wall toward it (fluids show both sides), a light
|
||||
// sheet in the water where far terrain carries on.
|
||||
// Vanilla draws no chunk without its neighbours.
|
||||
let (ex, ez) = (x.clamp(0, 15), z.clamp(0, 15));
|
||||
blocks[i] = c.get(ex, wy, ez);
|
||||
light[i] = c.light(ex, wy, ez);
|
||||
} else {
|
||||
light[i] = 0xF0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,780 @@
|
||||
//! `MultiLineEditBox` over `MultilineTextField` (and `AbstractTextAreaWidget`'s
|
||||
//! frame and scroll bar): a text area that wraps its text to the box, with a
|
||||
//! caret and selection that move by line, a character limit, a line limit and
|
||||
//! a scroll bar. Indices count characters, as `EditBox`'s do.
|
||||
use crate::{
|
||||
clipboard::{Clipboard, EditShortcut, filter_text},
|
||||
font::Font,
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
text::Style,
|
||||
ui::{self, Scroll, java_whitespace},
|
||||
};
|
||||
use std::time::Instant;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// `AbstractTextAreaWidget.INNER_PADDING`.
|
||||
const PADDING: f32 = 4.0;
|
||||
/// `Font.lineHeight`.
|
||||
const LINE: f32 = 9.0;
|
||||
/// `AbstractScrollArea.defaultSettings((int) (9.0 / 2.0))`'s scroll rate.
|
||||
const SCROLL_RATE: f32 = 4.0;
|
||||
|
||||
/// `StringSplitter.splitLines(value, width, Style.EMPTY, false, ..)`: each
|
||||
/// line's `[begin, end)`. A line ends at a `\n`, or once a character overflows
|
||||
/// the width (never the first visible one) at the last space so far, or before
|
||||
/// that character; the `\n` or space at the end is skipped.
|
||||
pub fn split_ranges(font: &Font, chars: &[char], width: f32) -> Vec<(usize, usize)> {
|
||||
let max_width = width.max(1.0);
|
||||
let style = Style::default();
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0;
|
||||
while start < chars.len() {
|
||||
let (mut line_width, mut had_visible, mut last_space) = (0.0f32, false, None);
|
||||
let mut line_break = None;
|
||||
for (i, &c) in chars.iter().enumerate().skip(start) {
|
||||
if c == '\n' {
|
||||
line_break = Some(i);
|
||||
break;
|
||||
}
|
||||
if c == ' ' {
|
||||
last_space = Some(i);
|
||||
}
|
||||
let advance = font.advance(c, &style);
|
||||
line_width += advance;
|
||||
if !had_visible || line_width <= max_width {
|
||||
had_visible |= advance != 0.0;
|
||||
} else {
|
||||
line_break = Some(last_space.unwrap_or(i));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(at) = line_break else {
|
||||
out.push((start, chars.len()));
|
||||
break;
|
||||
};
|
||||
out.push((start, at));
|
||||
start = if matches!(chars[at], '\n' | ' ') {
|
||||
at + 1
|
||||
} else {
|
||||
at
|
||||
};
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `MultiLineEditBox` with its `MultilineTextField`.
|
||||
pub struct MultiLineEdit {
|
||||
chars: Vec<char>,
|
||||
cursor: usize,
|
||||
select_cursor: usize,
|
||||
selecting: bool,
|
||||
/// `characterLimit` (`MultilineTextField.NO_LIMIT` is `None`).
|
||||
character_limit: Option<usize>,
|
||||
line_limit: Option<usize>,
|
||||
/// `displayLines`.
|
||||
lines: Vec<(usize, usize)>,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
pub focused: bool,
|
||||
focused_at: Instant,
|
||||
pub scroll: Scroll,
|
||||
}
|
||||
|
||||
impl MultiLineEdit {
|
||||
pub fn new(font: &Font, x: f32, y: f32, width: f32, height: f32) -> Self {
|
||||
let mut edit = Self {
|
||||
chars: Vec::new(),
|
||||
cursor: 0,
|
||||
select_cursor: 0,
|
||||
selecting: false,
|
||||
character_limit: None,
|
||||
line_limit: None,
|
||||
lines: Vec::new(),
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
focused: false,
|
||||
focused_at: Instant::now(),
|
||||
scroll: Scroll::default(),
|
||||
};
|
||||
edit.reflow(font);
|
||||
edit
|
||||
}
|
||||
|
||||
/// `AbstractTextAreaWidget.totalInnerPadding`.
|
||||
fn text_width(&self) -> f32 {
|
||||
self.width - 2.0 * PADDING
|
||||
}
|
||||
pub fn set_character_limit(&mut self, limit: usize) {
|
||||
self.character_limit = Some(limit);
|
||||
}
|
||||
pub fn set_line_limit(&mut self, limit: usize) {
|
||||
self.line_limit = Some(limit);
|
||||
}
|
||||
pub fn value(&self) -> String {
|
||||
self.chars.iter().collect()
|
||||
}
|
||||
pub fn cursor(&self) -> usize {
|
||||
self.cursor
|
||||
}
|
||||
pub fn line_count(&self) -> usize {
|
||||
self.lines.len()
|
||||
}
|
||||
pub fn lines(&self) -> &[(usize, usize)] {
|
||||
&self.lines
|
||||
}
|
||||
pub fn has_selection(&self) -> bool {
|
||||
self.select_cursor != self.cursor
|
||||
}
|
||||
/// `getSelected`.
|
||||
fn selected(&self) -> (usize, usize) {
|
||||
(
|
||||
self.select_cursor.min(self.cursor),
|
||||
self.select_cursor.max(self.cursor),
|
||||
)
|
||||
}
|
||||
pub fn selected_text(&self) -> String {
|
||||
let (begin, end) = self.selected();
|
||||
self.chars[begin..end].iter().collect()
|
||||
}
|
||||
|
||||
/// `setValue(value, false)`: kept as it was when the text has more lines
|
||||
/// than the limit allows.
|
||||
pub fn set_value(&mut self, font: &Font, value: &str) {
|
||||
let mut chars: Vec<char> = value.chars().collect();
|
||||
if let Some(limit) = self.character_limit {
|
||||
chars.truncate(limit);
|
||||
}
|
||||
if !self.overflows_line_limit(font, &chars) {
|
||||
self.chars = chars;
|
||||
self.cursor = self.chars.len();
|
||||
self.select_cursor = self.cursor;
|
||||
self.on_value_change(font);
|
||||
}
|
||||
}
|
||||
|
||||
/// `overflowsLineLimit`.
|
||||
fn overflows_line_limit(&self, font: &Font, value: &[char]) -> bool {
|
||||
let Some(limit) = self.line_limit else {
|
||||
return false;
|
||||
};
|
||||
let lines = split_ranges(font, value, self.text_width()).len();
|
||||
// `StringUtil.endsWithNewLine`.
|
||||
lines + usize::from(value.last().is_some_and(|c| matches!(c, '\n' | '\r'))) > limit
|
||||
}
|
||||
|
||||
/// `insertText`.
|
||||
pub fn insert_text(&mut self, font: &Font, input: &str) {
|
||||
if input.is_empty() && !self.has_selection() {
|
||||
return;
|
||||
}
|
||||
let mut text: Vec<char> = filter_text(input, true).chars().collect();
|
||||
if let Some(limit) = self.character_limit {
|
||||
text.truncate(limit.saturating_sub(self.chars.len()));
|
||||
}
|
||||
let (begin, end) = self.selected();
|
||||
let mut value = self.chars[..begin].to_vec();
|
||||
value.extend_from_slice(&text);
|
||||
value.extend_from_slice(&self.chars[end..]);
|
||||
if !self.overflows_line_limit(font, &value) {
|
||||
self.chars = value;
|
||||
self.cursor = begin + text.len();
|
||||
self.select_cursor = self.cursor;
|
||||
self.on_value_change(font);
|
||||
}
|
||||
}
|
||||
|
||||
/// `deleteText`.
|
||||
pub fn delete_text(&mut self, font: &Font, dir: isize) {
|
||||
if !self.has_selection() {
|
||||
self.select_cursor = self.cursor.saturating_add_signed(dir).min(self.chars.len());
|
||||
}
|
||||
self.insert_text(font, "");
|
||||
}
|
||||
|
||||
/// `onValueChange`: the lines again, and the cursor in view.
|
||||
fn on_value_change(&mut self, font: &Font) {
|
||||
self.reflow(font);
|
||||
self.scroll_to_cursor();
|
||||
}
|
||||
|
||||
/// `reflowDisplayLines`.
|
||||
fn reflow(&mut self, font: &Font) {
|
||||
self.lines = if self.chars.is_empty() {
|
||||
vec![(0, 0)]
|
||||
} else {
|
||||
let mut lines = split_ranges(font, &self.chars, self.text_width());
|
||||
if self.chars.last() == Some(&'\n') {
|
||||
lines.push((self.chars.len(), self.chars.len()));
|
||||
}
|
||||
lines
|
||||
};
|
||||
}
|
||||
|
||||
/// `getLineAtCursor`: -1 when no line holds the cursor.
|
||||
fn line_at_cursor(&self) -> isize {
|
||||
self.lines
|
||||
.iter()
|
||||
.position(|&(begin, end)| self.cursor >= begin && self.cursor <= end)
|
||||
.map_or(-1, |i| i as isize)
|
||||
}
|
||||
/// `getLineView`: the index clamped to the lines.
|
||||
fn line_view(&self, index: isize) -> (usize, usize) {
|
||||
self.lines[index.clamp(0, self.lines.len() as isize - 1) as usize]
|
||||
}
|
||||
/// `getCursorLineView(offset)`.
|
||||
fn cursor_line_view(&self, offset: isize) -> (usize, usize) {
|
||||
match self.line_at_cursor() {
|
||||
-1 => *self.lines.last().expect("a text field has a line"),
|
||||
line => self.line_view(line + offset),
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractScrollArea.contentHeight` of the text area.
|
||||
fn content_height(&self) -> f32 {
|
||||
LINE * self.lines.len() as f32 + 2.0 * PADDING
|
||||
}
|
||||
/// `setScrollAmount`.
|
||||
fn set_scroll(&mut self, amount: f32) {
|
||||
self.scroll.amount = amount;
|
||||
self.scroll.clamp(0.0, self.height, self.content_height());
|
||||
}
|
||||
|
||||
/// `MultiLineEditBox.scrollToCursor`, the text field's cursor listener.
|
||||
fn scroll_to_cursor(&mut self) {
|
||||
let mut amount = self.scroll.amount;
|
||||
let first = self.line_view((amount / LINE) as isize);
|
||||
let line = self.line_at_cursor() as f32;
|
||||
if self.cursor <= first.0 {
|
||||
amount = line * LINE;
|
||||
} else {
|
||||
let last = self.line_view(((amount + self.height) / LINE) as isize - 1);
|
||||
if self.cursor > last.1 {
|
||||
amount = line * LINE - self.height + LINE + 2.0 * PADDING;
|
||||
}
|
||||
}
|
||||
self.set_scroll(amount);
|
||||
}
|
||||
|
||||
/// `seekCursor` to `cursor`, then the cursor listener.
|
||||
fn seek_cursor(&mut self, cursor: usize) {
|
||||
self.cursor = cursor.min(self.chars.len());
|
||||
self.scroll_to_cursor();
|
||||
if !self.selecting {
|
||||
self.select_cursor = self.cursor;
|
||||
}
|
||||
}
|
||||
|
||||
/// `seekCursorLine`.
|
||||
fn seek_cursor_line(&mut self, font: &Font, offset: isize) {
|
||||
if offset == 0 {
|
||||
return;
|
||||
}
|
||||
let (begin, _) = self.cursor_line_view(0);
|
||||
let before: String = self.chars[begin..self.cursor].iter().collect();
|
||||
let old_left = font.width(&before) + 2.0;
|
||||
let (begin, end) = self.cursor_line_view(offset);
|
||||
let line: String = self.chars[begin..end].iter().collect();
|
||||
self.seek_cursor(begin + font.fit(&line, old_left));
|
||||
}
|
||||
|
||||
/// `seekCursorToPoint`: `x` and `y` from the top left of the text.
|
||||
fn seek_cursor_to_point(&mut self, font: &Font, x: f32, y: f32) {
|
||||
let left = x.floor();
|
||||
let top = (y / LINE).floor() as isize;
|
||||
let (begin, end) = self.line_view(top);
|
||||
let line: String = self.chars[begin..end].iter().collect();
|
||||
self.seek_cursor(begin + font.fit(&line, left));
|
||||
}
|
||||
|
||||
/// `getPreviousWord`: where the word starts and ends.
|
||||
fn previous_word(&self) -> (usize, usize) {
|
||||
if self.chars.is_empty() {
|
||||
return (0, 0);
|
||||
}
|
||||
let mut start = self.cursor.min(self.chars.len() - 1);
|
||||
while start > 0 && java_whitespace(self.chars[start - 1]) {
|
||||
start -= 1;
|
||||
}
|
||||
while start > 0 && !java_whitespace(self.chars[start - 1]) {
|
||||
start -= 1;
|
||||
}
|
||||
(start, self.word_end(start))
|
||||
}
|
||||
/// `getNextWord`.
|
||||
fn next_word(&self) -> (usize, usize) {
|
||||
if self.chars.is_empty() {
|
||||
return (0, 0);
|
||||
}
|
||||
let mut start = self.cursor.min(self.chars.len() - 1);
|
||||
while start < self.chars.len() && !java_whitespace(self.chars[start]) {
|
||||
start += 1;
|
||||
}
|
||||
while start < self.chars.len() && java_whitespace(self.chars[start]) {
|
||||
start += 1;
|
||||
}
|
||||
(start, self.word_end(start))
|
||||
}
|
||||
fn word_end(&self, from: usize) -> usize {
|
||||
let mut end = from;
|
||||
while end < self.chars.len() && !java_whitespace(self.chars[end]) {
|
||||
end += 1;
|
||||
}
|
||||
end
|
||||
}
|
||||
/// `selectWordAtCursor`.
|
||||
fn select_word_at_cursor(&mut self) {
|
||||
let (begin, end) = self.previous_word();
|
||||
self.seek_cursor(begin);
|
||||
self.selecting = true;
|
||||
self.seek_cursor(end);
|
||||
}
|
||||
|
||||
/// `MultilineTextField.keyPressed` for the keys that are not shortcuts.
|
||||
/// True when the key was used.
|
||||
pub fn key(&mut self, font: &Font, key: KeyCode, ctrl: bool, shift: bool) -> bool {
|
||||
self.selecting = shift;
|
||||
match key {
|
||||
KeyCode::Backspace if ctrl => {
|
||||
let (begin, _) = self.previous_word();
|
||||
self.delete_text(font, begin as isize - self.cursor as isize);
|
||||
}
|
||||
KeyCode::Backspace => self.delete_text(font, -1),
|
||||
KeyCode::Enter | KeyCode::NumpadEnter => self.insert_text(font, "\n"),
|
||||
KeyCode::Delete if ctrl => {
|
||||
let (begin, _) = self.next_word();
|
||||
self.delete_text(font, begin as isize - self.cursor as isize);
|
||||
}
|
||||
KeyCode::Delete => self.delete_text(font, 1),
|
||||
KeyCode::Home if ctrl => self.seek_cursor(0),
|
||||
KeyCode::Home => self.seek_cursor(self.cursor_line_view(0).0),
|
||||
KeyCode::PageUp => self.seek_cursor(0),
|
||||
KeyCode::End if ctrl => self.seek_cursor(self.chars.len()),
|
||||
KeyCode::End => self.seek_cursor(self.cursor_line_view(0).1),
|
||||
KeyCode::PageDown => self.seek_cursor(self.chars.len()),
|
||||
KeyCode::ArrowRight if ctrl => self.seek_cursor(self.next_word().0),
|
||||
KeyCode::ArrowRight => self.seek_cursor(self.cursor + 1),
|
||||
KeyCode::ArrowLeft if ctrl => self.seek_cursor(self.previous_word().0),
|
||||
KeyCode::ArrowLeft => self.seek_cursor(self.cursor.saturating_sub(1)),
|
||||
KeyCode::ArrowDown => {
|
||||
if !ctrl {
|
||||
self.seek_cursor_line(font, 1);
|
||||
}
|
||||
}
|
||||
KeyCode::ArrowUp => {
|
||||
if !ctrl {
|
||||
self.seek_cursor_line(font, -1);
|
||||
}
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Ctrl+A/C/V/X. True when the value changed.
|
||||
pub fn shortcut(
|
||||
&mut self,
|
||||
font: &Font,
|
||||
shortcut: EditShortcut,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
) -> bool {
|
||||
let before = self.chars.len();
|
||||
match shortcut {
|
||||
EditShortcut::SelectAll => {
|
||||
self.cursor = self.chars.len();
|
||||
self.select_cursor = 0;
|
||||
}
|
||||
EditShortcut::Copy => clipboard.set(&self.selected_text()),
|
||||
EditShortcut::Paste => self.insert_text(font, &clipboard.get()),
|
||||
EditShortcut::Cut => {
|
||||
clipboard.set(&self.selected_text());
|
||||
self.insert_text(font, "");
|
||||
}
|
||||
}
|
||||
self.focused_at = Instant::now();
|
||||
self.chars.len() != before
|
||||
}
|
||||
|
||||
/// `AbstractWidget.isMouseOver`: the box and its scroll bar.
|
||||
pub fn hovered(&self, (x, y): (f32, f32)) -> bool {
|
||||
x >= self.x && y >= self.y && x < self.x + self.width + 6.0 && y < self.y + self.height
|
||||
}
|
||||
|
||||
/// `AbstractTextAreaWidget.mouseClicked`: a press on the scroll bar
|
||||
/// holds it, else `onClick` places the cursor (`double`: selects the word).
|
||||
pub fn click(&mut self, font: &Font, cursor: (f32, f32), shift: bool, double: bool) {
|
||||
let bar = self.x + self.width;
|
||||
if self.scroll.press(
|
||||
cursor,
|
||||
bar,
|
||||
self.y,
|
||||
self.y + self.height,
|
||||
self.content_height(),
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if double {
|
||||
self.select_word_at_cursor();
|
||||
} else {
|
||||
self.selecting = shift;
|
||||
self.seek_screen(font, cursor);
|
||||
}
|
||||
}
|
||||
/// `onDrag`: the selection follows the pointer, or the held scroll bar does.
|
||||
pub fn drag(&mut self, font: &Font, cursor: (f32, f32), shift: bool) {
|
||||
if self.scroll.held() {
|
||||
self.scroll.drag(
|
||||
cursor.1,
|
||||
self.y,
|
||||
self.y + self.height,
|
||||
self.content_height(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.selecting = true;
|
||||
self.seek_screen(font, cursor);
|
||||
self.selecting = shift;
|
||||
}
|
||||
/// `onRelease`.
|
||||
pub fn release(&mut self) {
|
||||
self.scroll.release();
|
||||
}
|
||||
/// `mouseScrolled`.
|
||||
pub fn wheel(&mut self, delta: f32) {
|
||||
let amount = self.scroll.amount - delta * SCROLL_RATE;
|
||||
self.set_scroll(amount);
|
||||
}
|
||||
/// `seekCursorScreen`.
|
||||
fn seek_screen(&mut self, font: &Font, (x, y): (f32, f32)) {
|
||||
let mouse_x = x - self.x - PADDING;
|
||||
let mouse_y = y - self.y - PADDING + self.scroll.amount;
|
||||
self.seek_cursor_to_point(font, mouse_x, mouse_y);
|
||||
}
|
||||
|
||||
/// `setFocused`: the caret starts to blink.
|
||||
pub fn set_focused(&mut self, focused: bool) {
|
||||
if focused && !self.focused {
|
||||
self.focused_at = Instant::now();
|
||||
}
|
||||
self.focused = focused;
|
||||
}
|
||||
|
||||
/// `AbstractTextAreaWidget.withinContentAreaTopBottom`.
|
||||
fn within(&self, top: f32, bottom: f32) -> bool {
|
||||
bottom - self.scroll.amount >= self.y && top - self.scroll.amount <= self.y + self.height
|
||||
}
|
||||
|
||||
/// `extractWidgetRenderState`: the frame, the text under a scissor, the
|
||||
/// scroll bar and the character count.
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas) {
|
||||
crate::widgets::target_as(
|
||||
[self.x, self.y, self.width, self.height],
|
||||
crate::widgets::Kind::Field,
|
||||
);
|
||||
let sprite = if self.focused {
|
||||
"gui/sprites/widget/text_field_highlighted"
|
||||
} else {
|
||||
"gui/sprites/widget/text_field"
|
||||
};
|
||||
ui::nine_slice(
|
||||
batch,
|
||||
atlas,
|
||||
sprite,
|
||||
self.x,
|
||||
self.y,
|
||||
self.width,
|
||||
self.height,
|
||||
u32::MAX,
|
||||
);
|
||||
let start = batch.vertices.len();
|
||||
self.draw_contents(batch, atlas);
|
||||
batch.clip_since(
|
||||
start,
|
||||
self.x + 1.0,
|
||||
self.y + 1.0,
|
||||
self.x + self.width - 1.0,
|
||||
self.y + self.height - 1.0,
|
||||
);
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
self.x + self.width,
|
||||
self.y,
|
||||
self.y + self.height,
|
||||
self.content_height(),
|
||||
self.scroll.amount,
|
||||
);
|
||||
if let Some(limit) = self.character_limit {
|
||||
let count = crate::lang::tr(
|
||||
"gui.multiLineEditBox.character_limit",
|
||||
&[&self.chars.len(), &limit],
|
||||
);
|
||||
let width = atlas.font.width(&count);
|
||||
batch.text(
|
||||
atlas,
|
||||
&count,
|
||||
self.x + self.width - width,
|
||||
self.y + self.height + 4.0,
|
||||
rgba(160, 160, 160, 255),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `MultiLineEditBox.extractContents` (the placeholder is empty).
|
||||
fn draw_contents(&self, batch: &mut HudBatch, atlas: &HudAtlas) {
|
||||
if self.chars.is_empty() && !self.focused {
|
||||
return;
|
||||
}
|
||||
let font = &atlas.font;
|
||||
let text_color = rgba(224, 224, 224, 255);
|
||||
let cursor_color = rgba(208, 208, 208, 255);
|
||||
let slice =
|
||||
|begin: usize, end: usize| -> String { self.chars[begin..end].iter().collect() };
|
||||
let show_cursor =
|
||||
self.focused && (self.focused_at.elapsed().as_millis() / 300).is_multiple_of(2);
|
||||
let needs_valid_cursor_pos = self.focused;
|
||||
let insert_cursor = self.cursor < self.chars.len();
|
||||
let (inner_left, mut draw_top) = (self.x + PADDING, self.y + PADDING - self.scroll.amount);
|
||||
let (mut cursor_x, mut cursor_y) = (0.0f32, 0.0f32);
|
||||
let mut has_drawn_cursor = false;
|
||||
// The content is translated by the scroll amount; `within` compares
|
||||
// untranslated tops, so it is given the line's top before the shift.
|
||||
let shift = self.scroll.amount;
|
||||
for &(begin, end) in &self.lines {
|
||||
let within = self.within(draw_top + shift, draw_top + shift + LINE);
|
||||
if !has_drawn_cursor
|
||||
&& (needs_valid_cursor_pos || show_cursor)
|
||||
&& insert_cursor
|
||||
&& self.cursor >= begin
|
||||
&& self.cursor <= end
|
||||
{
|
||||
let before = slice(begin, self.cursor);
|
||||
let right = inner_left + font.width(&before);
|
||||
(cursor_x, cursor_y) = (right, draw_top);
|
||||
if within {
|
||||
let after = slice(self.cursor, end);
|
||||
batch.text(atlas, &before, inner_left, draw_top, text_color, true);
|
||||
batch.text(atlas, &after, right, draw_top, text_color, true);
|
||||
if show_cursor {
|
||||
batch.fill(atlas, cursor_x, cursor_y - 1.0, 1.0, 11.0, cursor_color);
|
||||
}
|
||||
}
|
||||
has_drawn_cursor = true;
|
||||
} else {
|
||||
let line = slice(begin, end);
|
||||
if within {
|
||||
batch.text(atlas, &line, inner_left, draw_top, text_color, true);
|
||||
}
|
||||
if (needs_valid_cursor_pos || show_cursor) && !insert_cursor {
|
||||
cursor_x = inner_left + font.width(&line);
|
||||
cursor_y = draw_top;
|
||||
}
|
||||
}
|
||||
draw_top += LINE;
|
||||
}
|
||||
if show_cursor && !insert_cursor && self.within(cursor_y + shift, cursor_y + shift + LINE) {
|
||||
batch.text(atlas, "_", cursor_x, cursor_y, cursor_color, true);
|
||||
}
|
||||
if self.has_selection() {
|
||||
let (sel_begin, sel_end) = self.selected();
|
||||
let draw_x = inner_left;
|
||||
let mut draw_top = self.y + PADDING - self.scroll.amount;
|
||||
for &(begin, end) in &self.lines {
|
||||
if sel_begin > end {
|
||||
draw_top += LINE;
|
||||
continue;
|
||||
}
|
||||
if begin > sel_end {
|
||||
break;
|
||||
}
|
||||
if self.within(draw_top + shift, draw_top + shift + LINE) {
|
||||
let draw_begin = font.width(&slice(begin, sel_begin.max(begin)));
|
||||
let draw_end = if sel_end > end {
|
||||
self.width - PADDING
|
||||
} else {
|
||||
font.width(&slice(begin, sel_end))
|
||||
};
|
||||
batch.text_highlight(
|
||||
atlas,
|
||||
(draw_x + draw_begin, draw_top, draw_end - draw_begin, LINE),
|
||||
true,
|
||||
);
|
||||
}
|
||||
draw_top += LINE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::hud::Rect;
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
fn font() -> Font {
|
||||
// The missing-glyph metrics let these tests run without game assets.
|
||||
Font::load(
|
||||
Path::new("/nonexistent-horizon-multiline-test-resources"),
|
||||
&HashMap::new(),
|
||||
&[],
|
||||
(1, 1),
|
||||
(0, 0),
|
||||
Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 1,
|
||||
h: 1,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// A box whose text is `chars` characters wide and `lines` lines tall.
|
||||
fn edit(font: &Font, chars: usize, lines: usize) -> MultiLineEdit {
|
||||
let advance = font.advance('a', &Style::default());
|
||||
MultiLineEdit::new(
|
||||
font,
|
||||
0.0,
|
||||
0.0,
|
||||
chars as f32 * advance + 8.0,
|
||||
LINE * lines as f32 + 8.0,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_break_at_newlines_and_at_the_last_space_that_fits() {
|
||||
let font = font();
|
||||
let split = |text: &str, chars: usize| {
|
||||
let advance = font.advance('a', &Style::default());
|
||||
let text: Vec<char> = text.chars().collect();
|
||||
split_ranges(&font, &text, chars as f32 * advance)
|
||||
};
|
||||
assert_eq!(split("abcd efgh", 4), [(0, 4), (5, 9)]);
|
||||
// No space to break at: before the character that overflows.
|
||||
assert_eq!(split("abcdefgh", 4), [(0, 4), (4, 8)]);
|
||||
assert_eq!(split("ab\ncd", 4), [(0, 2), (3, 5)]);
|
||||
// A trailing newline starts no line here; the text field adds it.
|
||||
assert_eq!(split("ab\n", 4), [(0, 2)]);
|
||||
assert_eq!(split("", 4), []);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trailing_newline_gets_an_empty_last_line() {
|
||||
let font = font();
|
||||
let mut edit = edit(&font, 8, 4);
|
||||
assert_eq!(edit.lines(), [(0, 0)]);
|
||||
edit.insert_text(&font, "ab\n");
|
||||
assert_eq!(edit.lines(), [(0, 2), (3, 3)]);
|
||||
assert_eq!(edit.cursor(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_character_limit_cuts_what_is_typed_and_set() {
|
||||
let font = font();
|
||||
let mut edit = edit(&font, 8, 4);
|
||||
edit.set_character_limit(5);
|
||||
edit.set_value(&font, "abcdefgh");
|
||||
assert_eq!(edit.value(), "abcde");
|
||||
edit.key(&font, KeyCode::Backspace, false, false);
|
||||
edit.insert_text(&font, "wxyz");
|
||||
assert_eq!(edit.value(), "abcdw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_line_limit_refuses_an_edit_that_adds_a_line() {
|
||||
let font = font();
|
||||
let mut edit = edit(&font, 8, 4);
|
||||
edit.set_line_limit(2);
|
||||
edit.set_value(&font, "a\nb");
|
||||
assert_eq!(edit.value(), "a\nb");
|
||||
// A third line, by a newline or by wrapping, changes nothing.
|
||||
edit.key(&font, KeyCode::Enter, false, false);
|
||||
edit.insert_text(&font, "\nc");
|
||||
assert_eq!(edit.value(), "a\nb");
|
||||
edit.insert_text(&font, " abcdefghijkl");
|
||||
assert_eq!(edit.value(), "a\nb");
|
||||
// A value over the limit is not set at all.
|
||||
edit.set_value(&font, "1\n2\n3");
|
||||
assert_eq!(edit.value(), "a\nb");
|
||||
// A trailing newline counts as a line.
|
||||
edit.set_value(&font, "a\n");
|
||||
edit.insert_text(&font, "\n");
|
||||
assert_eq!(edit.value(), "a\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_and_down_keep_the_column_and_home_and_end_stay_on_the_line() {
|
||||
let font = font();
|
||||
let mut edit = edit(&font, 8, 4);
|
||||
edit.set_value(&font, "abcd\nef\nghij");
|
||||
// From the end of `ghij` up to `ef`: the end of the shorter line.
|
||||
edit.key(&font, KeyCode::ArrowUp, false, false);
|
||||
assert_eq!(edit.cursor(), 7);
|
||||
edit.key(&font, KeyCode::ArrowUp, false, false);
|
||||
assert_eq!(edit.cursor(), 2);
|
||||
edit.key(&font, KeyCode::Home, false, false);
|
||||
assert_eq!(edit.cursor(), 0);
|
||||
edit.key(&font, KeyCode::End, false, false);
|
||||
assert_eq!(edit.cursor(), 4);
|
||||
edit.key(&font, KeyCode::End, true, false);
|
||||
assert_eq!(edit.cursor(), 12);
|
||||
edit.key(&font, KeyCode::PageUp, false, false);
|
||||
assert_eq!(edit.cursor(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_selects_and_typing_replaces_the_selection() {
|
||||
let font = font();
|
||||
let mut edit = edit(&font, 8, 4);
|
||||
edit.set_value(&font, "one two");
|
||||
edit.key(&font, KeyCode::ArrowLeft, true, true);
|
||||
assert_eq!(edit.selected_text(), "two");
|
||||
edit.insert_text(&font, "2");
|
||||
assert_eq!(edit.value(), "one 2");
|
||||
edit.set_value(&font, "one two");
|
||||
edit.key(&font, KeyCode::Backspace, true, false);
|
||||
assert_eq!(edit.value(), "one ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_box_scrolls_to_keep_the_cursor_in_view() {
|
||||
let font = font();
|
||||
let mut edit = edit(&font, 8, 2);
|
||||
edit.set_value(&font, "1\n2\n3\n4\n5");
|
||||
// Five lines in two lines' height: the end shows.
|
||||
assert_eq!(edit.scroll.amount, 9.0 * 5.0 + 8.0 - edit.height);
|
||||
edit.key(&font, KeyCode::PageUp, false, false);
|
||||
assert_eq!(edit.scroll.amount, 0.0);
|
||||
edit.wheel(-1.0);
|
||||
assert_eq!(edit.scroll.amount, SCROLL_RATE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_puts_the_cursor_on_the_line_and_character_under_it() {
|
||||
let font = font();
|
||||
let mut edit = edit(&font, 8, 4);
|
||||
edit.set_value(&font, "abcd\nefgh");
|
||||
let advance = font.advance('a', &Style::default());
|
||||
// The second line, after two characters.
|
||||
edit.click(
|
||||
&font,
|
||||
(4.0 + 2.0 * advance + 1.0, 4.0 + 9.0 + 1.0),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(edit.cursor(), 7);
|
||||
// Shift extends the selection.
|
||||
edit.click(&font, (4.0 + 1.0, 4.0 + 1.0), true, false);
|
||||
assert_eq!(edit.selected_text(), "abcd\nef");
|
||||
// A double click, after the plain click that starts it, selects the word.
|
||||
edit.click(&font, (4.0 + 1.0, 4.0 + 1.0), false, false);
|
||||
edit.click(&font, (4.0 + 1.0, 4.0 + 1.0), false, true);
|
||||
assert_eq!(edit.selected_text(), "abcd");
|
||||
}
|
||||
}
|
||||
@@ -141,8 +141,10 @@ impl Opt {
|
||||
| Opt::ChunkFade
|
||||
| Opt::ImprovedTransparency
|
||||
| Opt::ChunkBuilder
|
||||
// Saved, but the quit keys and the mouse don't read them yet.
|
||||
| Opt::QuitShortcuts
|
||||
// Saved, but the terrain sampler has no RGSS or anisotropic filtering yet.
|
||||
| Opt::TextureFiltering
|
||||
| Opt::MaxAnisotropy
|
||||
// Saved, but the mouse doesn't read it yet.
|
||||
| Opt::CtrlClickEmulatesRightClick
|
||||
)
|
||||
}
|
||||
@@ -202,6 +204,11 @@ impl Opt {
|
||||
Opt::MaxFps => "options.framerateLimit",
|
||||
Opt::Vsync => "options.vsync",
|
||||
Opt::Fullscreen => "options.fullscreen",
|
||||
Opt::ExclusiveFullscreen => "options.exclusiveFullscreen",
|
||||
Opt::ExclusiveMode => "options.fullscreen.exclusive.mode",
|
||||
Opt::GraphicsBackend => "options.graphicsApi",
|
||||
Opt::TextureFiltering => "options.textureFiltering",
|
||||
Opt::MaxAnisotropy => "options.maxAnisotropy",
|
||||
Opt::SmoothLighting => "options.ao",
|
||||
Opt::Clouds => "options.renderClouds",
|
||||
Opt::CloudRange => "options.renderCloudsDistance",
|
||||
@@ -333,6 +340,15 @@ impl Opt {
|
||||
Opt::BiomeBlend => unit(s.biome_blend as f32, 0.0, 7.0),
|
||||
Opt::Preset => unit(preset_index(&s.graphics_preset) as f32, 0.0, 3.0),
|
||||
Opt::Mipmaps => unit(s.mipmap_levels as f32, 0.0, 4.0),
|
||||
Opt::MaxAnisotropy => unit(s.file.int("maxAnisotropyBit") as f32, 1.0, 3.0),
|
||||
Opt::ExclusiveMode => {
|
||||
let last = s.video_modes.len() as f32 - 1.0;
|
||||
if last < 0.0 {
|
||||
Kind::Slider(0.0)
|
||||
} else {
|
||||
unit(s.video_mode_index() as f32, -1.0, last)
|
||||
}
|
||||
}
|
||||
Opt::WeatherRadius => unit(s.weather_radius as f32, 3.0, 10.0),
|
||||
Opt::ChunkFade => unit(s.chunk_fade, 0.0, 2.0),
|
||||
Opt::MenuBlur => unit(s.menu_blur as f32, 0.0, 10.0),
|
||||
@@ -366,6 +382,19 @@ impl Opt {
|
||||
"options.prioritizeChunkUpdates.byPlayer",
|
||||
"options.prioritizeChunkUpdates.nearby",
|
||||
][usize::from(s.prioritize_chunk_updates.min(2))],
|
||||
// Options.exclusiveFullscreen: a tooltip per value.
|
||||
Opt::ExclusiveFullscreen => {
|
||||
if s.file.bool("exclusiveFullscreen") {
|
||||
"options.exclusiveFullscreen.on"
|
||||
} else {
|
||||
"options.exclusiveFullscreen.off"
|
||||
}
|
||||
}
|
||||
Opt::TextureFiltering => [
|
||||
"options.textureFiltering.none",
|
||||
"options.textureFiltering.rgss",
|
||||
"options.textureFiltering.anisotropic",
|
||||
][s.file.int("textureFiltering").clamp(0, 2) as usize],
|
||||
_ => self.caption(),
|
||||
};
|
||||
format!("{key}.tooltip")
|
||||
@@ -470,6 +499,26 @@ impl Opt {
|
||||
)),
|
||||
),
|
||||
Opt::Preset => generic(c, tr(&format!("options.graphics.{}", s.graphics_preset))),
|
||||
Opt::MaxAnisotropy => match s.file.int("maxAnisotropyBit") {
|
||||
0 => generic(c, tr("options.off")),
|
||||
bit => generic(c, Text::tr("options.multiplier", &[&(1 << bit)])),
|
||||
},
|
||||
Opt::ExclusiveMode => match s.video_modes.get(s.video_mode_index().max(0) as usize) {
|
||||
None if s.video_modes.is_empty() => tr("options.fullscreen.unavailable"),
|
||||
Some(mode) if s.video_mode_index() >= 0 => generic(
|
||||
c,
|
||||
Text::tr(
|
||||
"options.fullscreen.entry",
|
||||
&[
|
||||
&mode.width,
|
||||
&mode.height,
|
||||
&mode.refresh_rate_label(),
|
||||
&mode.bits(),
|
||||
],
|
||||
),
|
||||
),
|
||||
_ => generic(c, tr("options.fullscreen.current")),
|
||||
},
|
||||
Opt::Mipmaps => {
|
||||
if s.mipmap_levels == 0 {
|
||||
generic(c, tr("options.off"))
|
||||
@@ -580,6 +629,17 @@ impl Opt {
|
||||
Opt::ViewBobbing => on_off(s.view_bobbing),
|
||||
Opt::Vsync => on_off(s.vsync),
|
||||
Opt::Fullscreen => on_off(s.fullscreen),
|
||||
Opt::ExclusiveFullscreen => on_off(s.file.bool("exclusiveFullscreen")),
|
||||
Opt::TextureFiltering => tr([
|
||||
"options.textureFiltering.none",
|
||||
"options.textureFiltering.rgss",
|
||||
"options.textureFiltering.anisotropic",
|
||||
][s.file.int("textureFiltering").clamp(0, 2) as usize]),
|
||||
Opt::GraphicsBackend => tr(match s.horizon.api {
|
||||
crate::graphics::GraphicsApi::OpenGl => "options.graphicsApi.opengl",
|
||||
crate::graphics::GraphicsApi::Vulkan => "options.graphicsApi.vulkan",
|
||||
_ => "options.graphicsApi.default",
|
||||
}),
|
||||
Opt::SmoothLighting => on_off(s.smooth_lighting),
|
||||
Opt::Clouds => tr(match s.clouds.as_str() {
|
||||
"false" => "options.off",
|
||||
@@ -731,6 +791,20 @@ impl Opt {
|
||||
Opt::BiomeBlend => s.biome_blend = step(0.0, 7.0) as i32,
|
||||
Opt::Preset => s.graphics_preset = PRESETS[step(0.0, 3.0) as usize].into(),
|
||||
Opt::Mipmaps => s.mipmap_levels = step(0.0, 4.0) as i32,
|
||||
Opt::MaxAnisotropy => s.file.set(
|
||||
"maxAnisotropyBit",
|
||||
crate::options_file::V::Int(step(1.0, 3.0) as i32),
|
||||
),
|
||||
Opt::ExclusiveMode => {
|
||||
// Window.setPreferredFullscreenVideoMode: -1 is the desktop's own mode.
|
||||
let Some(last) = s.video_modes.len().checked_sub(1) else {
|
||||
return;
|
||||
};
|
||||
let index = step(-1.0, last as f32) as i32;
|
||||
s.file.fullscreen_resolution = usize::try_from(index)
|
||||
.ok()
|
||||
.map(|i| s.video_modes[i].write());
|
||||
}
|
||||
Opt::WeatherRadius => s.weather_radius = step(3.0, 10.0) as i32,
|
||||
Opt::ChunkFade => s.chunk_fade = step(0.0, 40.0) / 20.0,
|
||||
Opt::MenuBlur => s.menu_blur = step(0.0, 10.0) as i32,
|
||||
@@ -770,6 +844,8 @@ impl Opt {
|
||||
| Opt::ImprovedTransparency
|
||||
| Opt::MenuBlur
|
||||
| Opt::ChunkBuilder
|
||||
| Opt::TextureFiltering
|
||||
| Opt::MaxAnisotropy
|
||||
)
|
||||
}
|
||||
/// Advance a cycle button (CycleButton.cycleValue).
|
||||
@@ -819,6 +895,45 @@ impl Opt {
|
||||
Opt::ViewBobbing => s.view_bobbing = !s.view_bobbing,
|
||||
Opt::Vsync => s.vsync = !s.vsync,
|
||||
Opt::Fullscreen => s.fullscreen = !s.fullscreen,
|
||||
Opt::ExclusiveFullscreen => {
|
||||
let on = s.file.bool("exclusiveFullscreen");
|
||||
s.file
|
||||
.set("exclusiveFullscreen", crate::options_file::V::Bool(!on));
|
||||
}
|
||||
Opt::TextureFiltering => {
|
||||
let n = next(s.file.int("textureFiltering").clamp(0, 2) as u8, 3);
|
||||
s.file.set(
|
||||
"textureFiltering",
|
||||
crate::options_file::V::Int(i32::from(n)),
|
||||
);
|
||||
}
|
||||
Opt::GraphicsBackend => {
|
||||
// PreferredGraphicsApi: Default, OpenGL, Vulkan; a platform lacking one skips it.
|
||||
use crate::graphics::GraphicsApi::{Auto, OpenGl, Vulkan};
|
||||
let offered: Vec<_> = [Auto, OpenGl, Vulkan]
|
||||
.into_iter()
|
||||
.filter(|a| crate::graphics::GraphicsApi::choices().contains(a))
|
||||
.collect();
|
||||
let at = offered
|
||||
.iter()
|
||||
.position(|a| *a == s.horizon.api)
|
||||
.unwrap_or(0);
|
||||
let count = offered.len();
|
||||
let api = offered[if back {
|
||||
(at + count - 1) % count
|
||||
} else {
|
||||
(at + 1) % count
|
||||
}];
|
||||
s.horizon.api = api;
|
||||
s.file.set_name(
|
||||
"preferredGraphicsBackend",
|
||||
match api {
|
||||
OpenGl => "opengl",
|
||||
Vulkan => "vulkan",
|
||||
_ => "default",
|
||||
},
|
||||
);
|
||||
}
|
||||
Opt::SmoothLighting => s.smooth_lighting = !s.smooth_lighting,
|
||||
Opt::Clouds => {
|
||||
let order = ["false", "fast", "true"];
|
||||
@@ -968,8 +1083,7 @@ fn drag_slider(opt: Opt, s: &mut Settings, v: f32, events: &mut Vec<Event>) {
|
||||
events.push(Event::Volume(source, after));
|
||||
}
|
||||
}
|
||||
/// GraphicsPreset.apply: the options Fast, Fancy and Fabulous set (texture
|
||||
/// filtering and anisotropy have no option here yet).
|
||||
/// GraphicsPreset.apply: the options Fast, Fancy and Fabulous set.
|
||||
pub fn apply_preset(s: &mut Settings) {
|
||||
let fabulous = s.graphics_preset == "fabulous";
|
||||
match s.graphics_preset.as_str() {
|
||||
@@ -989,6 +1103,7 @@ pub fn apply_preset(s: &mut Settings) {
|
||||
s.cutout_leaves = false;
|
||||
s.improved_transparency = false;
|
||||
s.weather_radius = 5;
|
||||
set_filtering(s, 1, 0);
|
||||
}
|
||||
"fancy" | "fabulous" => {
|
||||
s.biome_blend = 2;
|
||||
@@ -1006,10 +1121,40 @@ pub fn apply_preset(s: &mut Settings) {
|
||||
s.cutout_leaves = true;
|
||||
s.improved_transparency = fabulous;
|
||||
s.weather_radius = 10;
|
||||
// Fabulous takes anisotropic filtering (RGSS on a device with known issues, which
|
||||
// wgpu does not report).
|
||||
set_filtering(
|
||||
s,
|
||||
if fabulous { 2 } else { 1 },
|
||||
if fabulous { 2 } else { 1 },
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// The preset's `maxAnisotropyBit` and `textureFiltering` (0 none, 1 RGSS, 2 anisotropic).
|
||||
fn set_filtering(s: &mut Settings, anisotropy_bit: i32, filtering: i32) {
|
||||
use crate::options_file::V;
|
||||
s.file.set("maxAnisotropyBit", V::Int(anisotropy_bit));
|
||||
s.file.set("textureFiltering", V::Int(filtering));
|
||||
}
|
||||
|
||||
/// `Options.preferredGraphicsBackend`'s tooltip: the restart note once the choice differs from
|
||||
/// the startup one, the description, and Vulkan's warning.
|
||||
fn graphics_api_tooltip(s: &Settings) -> Text {
|
||||
let line = |key: &str| lang::get_or(key, key);
|
||||
let mut lines = Vec::new();
|
||||
if s.horizon.api != s.startup_api {
|
||||
lines.push(line("options.needsRestart"));
|
||||
lines.push(String::new());
|
||||
}
|
||||
lines.push(line("options.graphicsApi.tooltip"));
|
||||
if s.horizon.api == crate::graphics::GraphicsApi::Vulkan {
|
||||
lines.push(String::new());
|
||||
lines.push(line("options.graphicsApi.tooltip.vulkan"));
|
||||
}
|
||||
Text::literal(lines.join("\n"))
|
||||
}
|
||||
|
||||
/// `selected_key` while a native mod's key waits (`selected_native_key`).
|
||||
const NATIVE_KEY: &str = "horizon.nativeModKey";
|
||||
@@ -1648,13 +1793,16 @@ impl OptionsUi {
|
||||
)),
|
||||
)];
|
||||
rows.push(Row::Header("options.video.display.header"));
|
||||
rows.push(Row::Big(Opt::ExclusiveMode));
|
||||
rows.extend(pairs(&[
|
||||
Opt::MaxFps,
|
||||
Opt::Vsync,
|
||||
Opt::InactivityFps,
|
||||
Opt::GuiScale,
|
||||
Opt::Fullscreen,
|
||||
Opt::ExclusiveFullscreen,
|
||||
Opt::Gamma,
|
||||
Opt::GraphicsBackend,
|
||||
]));
|
||||
rows.push(Row::Header("options.video.quality.header"));
|
||||
rows.push(Row::Big(Opt::Preset));
|
||||
@@ -1673,6 +1821,8 @@ impl OptionsUi {
|
||||
Opt::CloudRange,
|
||||
Opt::CutoutLeaves,
|
||||
Opt::ImprovedTransparency,
|
||||
Opt::TextureFiltering,
|
||||
Opt::MaxAnisotropy,
|
||||
Opt::WeatherRadius,
|
||||
]));
|
||||
rows.push(Row::Header("options.video.preferences.header"));
|
||||
@@ -2001,6 +2151,21 @@ impl OptionsUi {
|
||||
});
|
||||
let row_left = cx - 170.0;
|
||||
let scrollbar_x = row_left + 340.0 + 6.0 + 2.0;
|
||||
// VideoSettingsScreen.addTitle: a changed graphics API needs a restart.
|
||||
if page == Page::Video && s.horizon.api != s.startup_api {
|
||||
out.push(Widget {
|
||||
x: 0.0,
|
||||
y: 15.0,
|
||||
w,
|
||||
h: 9.0,
|
||||
kind: WKind::Text {
|
||||
text: tr("options.restartRequired"),
|
||||
color: rgba(223, 80, 80, 255),
|
||||
centered: true,
|
||||
},
|
||||
list: false,
|
||||
});
|
||||
}
|
||||
let list_start = out.len();
|
||||
let mut y = top + 2.0 - self.scroll;
|
||||
let mut last_category = "";
|
||||
@@ -2490,6 +2655,13 @@ impl OptionsUi {
|
||||
if let Some(key) = key.filter(|key| lang::has(key)) {
|
||||
tooltip = Some(atlas.font.split(&tr(&key), 170.0));
|
||||
}
|
||||
if let WKind::Button {
|
||||
act: Act::Cycle(Opt::GraphicsBackend),
|
||||
..
|
||||
} = &wd.kind
|
||||
{
|
||||
tooltip = Some(atlas.font.split(&graphics_api_tooltip(s), 170.0));
|
||||
}
|
||||
if let WKind::Button {
|
||||
act: Act::Cycle(opt),
|
||||
..
|
||||
@@ -3433,6 +3605,17 @@ mod tests {
|
||||
assert!(loaded.toggle_attack && loaded.toggle_use);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quit_shortcuts_option_can_be_switched() {
|
||||
assert!(Opt::QuitShortcuts.available());
|
||||
let s = Settings::default();
|
||||
assert_eq!(Opt::QuitShortcuts.caption(), "options.quitShortcuts");
|
||||
assert_eq!(
|
||||
Opt::QuitShortcuts.tooltip_key(&s),
|
||||
"options.quitShortcuts.tooltip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operator_items_tab_option_can_be_switched() {
|
||||
// The creative screen reads it (CreativeModeInventoryScreen.hasPermissions).
|
||||
@@ -3724,6 +3907,183 @@ mod tests {
|
||||
assert_eq!(saved.keys_of("zoom"), vec![("zoom", "key.keyboard.r")]);
|
||||
}
|
||||
|
||||
fn mode(width: i32, rate: f32) -> crate::window::VideoMode {
|
||||
crate::window::VideoMode {
|
||||
width,
|
||||
height: width * 9 / 16,
|
||||
red_bits: 8,
|
||||
green_bits: 8,
|
||||
blue_bits: 8,
|
||||
refresh_rate: rate,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_page_lists_vanilla_display_and_quality_options_in_order() {
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Video);
|
||||
let rows = ui.list_rows(&Settings::default());
|
||||
let opts: Vec<Opt> = rows
|
||||
.iter()
|
||||
.flat_map(|r| match r {
|
||||
Row::Big(o) => vec![*o],
|
||||
Row::Pair(a, b) => std::iter::once(*a).chain(*b).collect(),
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
// VideoSettingsScreen.addOptions: the exclusive mode row, displayOptions, the preset,
|
||||
// qualityOptions, then the preferences.
|
||||
assert_eq!(
|
||||
opts[..9],
|
||||
[
|
||||
Opt::ExclusiveMode,
|
||||
Opt::MaxFps,
|
||||
Opt::Vsync,
|
||||
Opt::InactivityFps,
|
||||
Opt::GuiScale,
|
||||
Opt::Fullscreen,
|
||||
Opt::ExclusiveFullscreen,
|
||||
Opt::Gamma,
|
||||
Opt::GraphicsBackend,
|
||||
]
|
||||
);
|
||||
let at = |o| opts.iter().position(|x| *x == o).unwrap();
|
||||
assert_eq!(at(Opt::ImprovedTransparency) + 1, at(Opt::TextureFiltering));
|
||||
assert_eq!(at(Opt::TextureFiltering) + 1, at(Opt::MaxAnisotropy));
|
||||
assert_eq!(at(Opt::MaxAnisotropy) + 1, at(Opt::WeatherRadius));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclusive_fullscreen_mode_picks_a_monitor_mode_or_the_current_one() {
|
||||
let mut s = Settings::default();
|
||||
// No monitor mode: the slider changes nothing.
|
||||
Opt::ExclusiveMode.set_slider(&mut s, 1.0);
|
||||
assert_eq!(s.file.fullscreen_resolution, None);
|
||||
s.video_modes = vec![mode(1280, 60.0), mode(1920, 144.0), mode(2560, 59.94)];
|
||||
assert_eq!(s.video_mode_index(), -1);
|
||||
// IntRange(-1, 2): the slider's thirds are -1, 0, 1, 2.
|
||||
Opt::ExclusiveMode.set_slider(&mut s, 2.0 / 3.0);
|
||||
assert_eq!(s.video_mode_index(), 1);
|
||||
assert_eq!(Opt::ExclusiveMode.slider_position(&s), Some(2.0 / 3.0));
|
||||
Opt::ExclusiveMode.set_slider(&mut s, 1.0);
|
||||
assert_eq!(s.video_mode_index(), 2);
|
||||
Opt::ExclusiveMode.set_slider(&mut s, 0.0);
|
||||
assert_eq!(s.file.fullscreen_resolution, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclusive_fullscreen_and_its_tooltips_follow_the_option() {
|
||||
let mut s = Settings::default();
|
||||
assert!(!s.file.bool("exclusiveFullscreen"));
|
||||
assert_eq!(
|
||||
Opt::ExclusiveFullscreen.tooltip_key(&s),
|
||||
"options.exclusiveFullscreen.off.tooltip"
|
||||
);
|
||||
Opt::ExclusiveFullscreen.cycle(&mut s, false, [854, 480]);
|
||||
assert!(s.file.bool("exclusiveFullscreen"));
|
||||
assert_eq!(
|
||||
Opt::ExclusiveFullscreen.tooltip_key(&s),
|
||||
"options.exclusiveFullscreen.on.tooltip"
|
||||
);
|
||||
// The mode row's own tooltip says it only matters with Exclusive Fullscreen.
|
||||
assert_eq!(
|
||||
Opt::ExclusiveMode.tooltip_key(&s),
|
||||
"options.fullscreen.exclusive.mode.tooltip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_graphics_api_option_cycles_default_opengl_vulkan_and_asks_for_a_restart() {
|
||||
use crate::graphics::GraphicsApi;
|
||||
let mut s = Settings::default();
|
||||
s.startup_api = s.horizon.api;
|
||||
let offered = GraphicsApi::choices();
|
||||
let mut seen = vec![s.horizon.api];
|
||||
for _ in 1..offered.len() {
|
||||
Opt::GraphicsBackend.cycle(&mut s, false, [854, 480]);
|
||||
seen.push(s.horizon.api);
|
||||
}
|
||||
// Dx12 is Horizon's own Graphics page's; this option has the three of vanilla.
|
||||
assert!(!seen.contains(&GraphicsApi::Dx12));
|
||||
assert_eq!(seen[0], GraphicsApi::Auto);
|
||||
if offered.len() == 3 {
|
||||
assert_eq!(seen[1..], [GraphicsApi::OpenGl, GraphicsApi::Vulkan]);
|
||||
assert_eq!(s.file.name("preferredGraphicsBackend"), "vulkan");
|
||||
Opt::GraphicsBackend.cycle(&mut s, false, [854, 480]);
|
||||
assert_eq!(s.horizon.api, GraphicsApi::Auto);
|
||||
assert_eq!(s.file.name("preferredGraphicsBackend"), "default");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texture_filtering_rows_exist_and_the_presets_set_them() {
|
||||
let mut s = Settings::default();
|
||||
for (preset, bit, filtering) in [("fast", 1, 0), ("fancy", 1, 1), ("fabulous", 2, 2)] {
|
||||
s.graphics_preset = preset.into();
|
||||
apply_preset(&mut s);
|
||||
assert_eq!(s.file.int("maxAnisotropyBit"), bit, "{preset}");
|
||||
assert_eq!(s.file.int("textureFiltering"), filtering, "{preset}");
|
||||
}
|
||||
// Nothing samples the terrain atlas with them yet, so the buttons stay greyed out.
|
||||
assert!(!Opt::TextureFiltering.available());
|
||||
assert!(!Opt::MaxAnisotropy.available());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared language data in HORIZON_TEST_RESOURCES; run alone"]
|
||||
fn the_new_video_options_read_as_vanilla_words_them() {
|
||||
use crate::graphics::GraphicsApi;
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
|
||||
);
|
||||
lang::set(lang::Language::load(&[&root], "en_us"));
|
||||
let mut s = Settings::default();
|
||||
assert_eq!(Opt::ExclusiveMode.label(&s).plain(), "Setting unavailable");
|
||||
s.video_modes = vec![mode(1280, 60.0), mode(1920, 144.0), mode(2560, 59.94)];
|
||||
assert_eq!(
|
||||
Opt::ExclusiveMode.label(&s).plain(),
|
||||
"Exclusive Fullscreen Mode: Current"
|
||||
);
|
||||
Opt::ExclusiveMode.set_slider(&mut s, 2.0 / 3.0);
|
||||
assert_eq!(
|
||||
Opt::ExclusiveMode.label(&s).plain(),
|
||||
"Exclusive Fullscreen Mode: 1920x1080@144 (24bit)"
|
||||
);
|
||||
Opt::ExclusiveMode.set_slider(&mut s, 1.0);
|
||||
assert_eq!(
|
||||
Opt::ExclusiveMode.label(&s).plain(),
|
||||
"Exclusive Fullscreen Mode: [email protected] (24bit)"
|
||||
);
|
||||
s.horizon.api = GraphicsApi::Vulkan;
|
||||
assert_eq!(
|
||||
Opt::GraphicsBackend.label(&s).plain(),
|
||||
"Graphics API: Prefer Vulkan (Experimental)"
|
||||
);
|
||||
assert!(
|
||||
graphics_api_tooltip(&s)
|
||||
.plain()
|
||||
.starts_with("Requires a game restart to take effect.\n\n")
|
||||
);
|
||||
s.startup_api = GraphicsApi::Vulkan;
|
||||
assert!(
|
||||
graphics_api_tooltip(&s)
|
||||
.plain()
|
||||
.starts_with("Which graphics API")
|
||||
);
|
||||
s.file
|
||||
.set("maxAnisotropyBit", crate::options_file::V::Int(2));
|
||||
s.file
|
||||
.set("textureFiltering", crate::options_file::V::Int(2));
|
||||
assert_eq!(
|
||||
Opt::MaxAnisotropy.label(&s).plain(),
|
||||
"Anisotropic Filtering: 4x"
|
||||
);
|
||||
assert_eq!(
|
||||
Opt::TextureFiltering.label(&s).plain(),
|
||||
"Texture Filtering: Anisotropic"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_tooltip_keys_follow_value_dependent_descriptions() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -156,60 +156,223 @@ fn format_of(v: &serde_json::Value) -> Option<(i64, i64)> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// PackCompatibility: inside [min_format, max_format] (or the legacy
|
||||
/// `pack_format`/`supported_formats`).
|
||||
/// `PackFormat`: major and minor.
|
||||
type Format = (i64, i64);
|
||||
/// `PackFormat.lastPreMinorVersion`: the last major without minor versions,
|
||||
/// for the resource pack format (64) or the data pack one (81).
|
||||
fn last_pre_minor(current: Format) -> i64 {
|
||||
if current.0 == horizon_core::version::DATA_PACK_FORMAT.0 {
|
||||
81
|
||||
} else {
|
||||
64
|
||||
}
|
||||
}
|
||||
/// `PackFormat.fullCodec(default_minor)`: an int, or a list of 1 to 256
|
||||
/// non-negative ints whose second is the minor. None: it does not parse.
|
||||
fn format_codec(v: &serde_json::Value, default_minor: i64) -> Option<Format> {
|
||||
let non_negative =
|
||||
|v: &serde_json::Value| v.as_i64().filter(|n| *n >= 0 && *n <= i64::from(i32::MAX));
|
||||
match v {
|
||||
serde_json::Value::Array(a) if (1..=256).contains(&a.len()) => {
|
||||
let list: Option<Vec<i64>> = a.iter().map(non_negative).collect();
|
||||
let list = list?;
|
||||
Some((list[0], list.get(1).copied().unwrap_or(default_minor)))
|
||||
}
|
||||
serde_json::Value::Array(_) => None,
|
||||
v => Some((non_negative(v)?, default_minor)),
|
||||
}
|
||||
}
|
||||
/// `InclusiveRange.codec(Codec.INT)`: `[min, max]` or
|
||||
/// `{min_inclusive, max_inclusive}`, min not above max.
|
||||
fn int_range_codec(v: &serde_json::Value) -> Option<(i64, i64)> {
|
||||
let int = |v: &serde_json::Value| v.as_i64().filter(|n| i32::try_from(*n).is_ok());
|
||||
let (min, max) = match v {
|
||||
serde_json::Value::Array(a) if a.len() == 2 => (int(&a[0])?, int(&a[1])?),
|
||||
serde_json::Value::Object(o) => {
|
||||
(int(o.get("min_inclusive")?)?, int(o.get("max_inclusive")?)?)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
(min <= max).then_some((min, max))
|
||||
}
|
||||
/// An `optionalFieldOf`: absent is `Ok(None)`, one that does not parse an error.
|
||||
fn optional<T>(
|
||||
v: &serde_json::Value,
|
||||
parse: impl Fn(&serde_json::Value) -> Option<T>,
|
||||
) -> Result<Option<T>, ()> {
|
||||
if v.is_null() {
|
||||
Ok(None)
|
||||
} else {
|
||||
parse(v).map(Some).ok_or(())
|
||||
}
|
||||
}
|
||||
/// `PackFormat.IntermediaryFormat.validatePackFormatForRange`.
|
||||
fn validate_pack_format_for_range(main_format: i64, min: i64, max: i64) -> Option<String> {
|
||||
if main_format < min || main_format > max {
|
||||
Some(format!(
|
||||
"Pack declared support for versions {min} to {max} but declared main format is {main_format}"
|
||||
))
|
||||
} else if main_format < 15 {
|
||||
Some("Multi-version packs cannot support minimum version of less than 15, since this will leave versions in range unable to load pack.".into())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// `PackFormat` as `toString` prints it.
|
||||
fn format_text(f: Format) -> String {
|
||||
if f.1 == i64::from(i32::MAX) {
|
||||
format!("{}.*", f.0)
|
||||
} else {
|
||||
format!("{}.{}", f.0, f.1)
|
||||
}
|
||||
}
|
||||
/// `PackFormat.IntermediaryFormat.validate` for a pack's `pack` section
|
||||
/// (`hasPackFormatField`, no `requireOldField`): the supported range, or why
|
||||
/// vanilla refuses the metadata (it then falls back to a pack of unknown
|
||||
/// format).
|
||||
pub fn validate_pack_range(
|
||||
pack: &serde_json::Value,
|
||||
last_pre_minor: i64,
|
||||
) -> Result<(Format, Format), String> {
|
||||
const CONTEXT: &str = "Pack";
|
||||
const OLD: &str = "supported_formats";
|
||||
let bad = |field: &str| format!("{CONTEXT} field {field} does not parse");
|
||||
let min =
|
||||
optional(&pack["min_format"], |v| format_codec(v, 0)).map_err(|()| bad("min_format"))?;
|
||||
let max = optional(&pack["max_format"], |v| {
|
||||
format_codec(v, i64::from(i32::MAX))
|
||||
})
|
||||
.map_err(|()| bad("max_format"))?;
|
||||
let format = optional(&pack["pack_format"], |v| {
|
||||
v.as_i64().filter(|n| i32::try_from(*n).is_ok())
|
||||
})
|
||||
.map_err(|()| bad("pack_format"))?;
|
||||
let supported = optional(&pack[OLD], int_range_codec).map_err(|()| bad(OLD))?;
|
||||
if min.is_some() != max.is_some() {
|
||||
return Err(format!(
|
||||
"{CONTEXT} missing field, must declare both min_format and max_format"
|
||||
));
|
||||
}
|
||||
if let (Some(min), Some(max)) = (min, max) {
|
||||
return validate_new_format(min, max, format, supported, last_pre_minor);
|
||||
}
|
||||
if let Some(supported) = supported {
|
||||
return validate_old_format(supported, format, last_pre_minor);
|
||||
}
|
||||
match format {
|
||||
Some(main) if main > last_pre_minor => Err(format!(
|
||||
"{CONTEXT} declares support for version newer than {last_pre_minor}, but is missing mandatory fields min_format and max_format"
|
||||
)),
|
||||
Some(main) => Ok(((main, 0), (main, 0))),
|
||||
None => Err(format!(
|
||||
"{CONTEXT} could not be parsed, missing format version information"
|
||||
)),
|
||||
}
|
||||
}
|
||||
/// `PackFormat.IntermediaryFormat.validateNewFormat`.
|
||||
fn validate_new_format(
|
||||
min: Format,
|
||||
max: Format,
|
||||
format: Option<i64>,
|
||||
supported: Option<(i64, i64)>,
|
||||
last_pre_minor: i64,
|
||||
) -> Result<(Format, Format), String> {
|
||||
const OLD: &str = "supported_formats";
|
||||
let (major_min, major_max) = (min.0, max.0);
|
||||
if min > max {
|
||||
return Err(format!(
|
||||
"Pack min_format ({}) is greater than max_format ({})",
|
||||
format_text(min),
|
||||
format_text(max)
|
||||
));
|
||||
}
|
||||
if major_min > last_pre_minor {
|
||||
if supported.is_some() {
|
||||
return Err(format!(
|
||||
"Pack key {OLD} is deprecated starting from pack format {}. Remove {OLD} from your pack.mcmeta.",
|
||||
last_pre_minor + 1
|
||||
));
|
||||
}
|
||||
if let Some(main) = format
|
||||
&& let Some(error) = validate_pack_format_for_range(main, major_min, major_max)
|
||||
{
|
||||
return Err(error);
|
||||
}
|
||||
} else {
|
||||
let Some(old) = supported else {
|
||||
return Err(format!(
|
||||
"Pack declares support for format {major_min}, but game versions supporting formats 15 to {last_pre_minor} require a {OLD} field. Add \"{OLD}\": [{major_min}, {last_pre_minor}] or require a version greater or equal to {}.0.",
|
||||
last_pre_minor + 1
|
||||
));
|
||||
};
|
||||
if old.0 != major_min {
|
||||
return Err(format!(
|
||||
"Pack version declaration mismatch between {OLD} (from {}) and min_format ({})",
|
||||
old.0,
|
||||
format_text(min)
|
||||
));
|
||||
}
|
||||
if old.1 != major_max && old.1 != last_pre_minor {
|
||||
return Err(format!(
|
||||
"Pack version declaration mismatch between {OLD} (up to {}) and max_format ({})",
|
||||
old.1,
|
||||
format_text(max)
|
||||
));
|
||||
}
|
||||
let Some(main) = format else {
|
||||
return Err(format!(
|
||||
"Pack declares support for formats up to {last_pre_minor}, but game versions supporting formats 15 to {last_pre_minor} require a pack_format field. Add \"pack_format\": {major_min} or require a version greater or equal to {}.0.",
|
||||
last_pre_minor + 1
|
||||
));
|
||||
};
|
||||
if let Some(error) = validate_pack_format_for_range(main, major_min, major_max) {
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
Ok((min, max))
|
||||
}
|
||||
/// `PackFormat.IntermediaryFormat.validateOldFormat`.
|
||||
fn validate_old_format(
|
||||
supported: (i64, i64),
|
||||
format: Option<i64>,
|
||||
last_pre_minor: i64,
|
||||
) -> Result<(Format, Format), String> {
|
||||
let (min, max) = supported;
|
||||
if max > last_pre_minor {
|
||||
return Err(format!(
|
||||
"Pack declares support for version newer than {last_pre_minor}, but is missing mandatory fields min_format and max_format"
|
||||
));
|
||||
}
|
||||
let Some(main) = format else {
|
||||
return Err(format!(
|
||||
"Pack declares support for formats up to {last_pre_minor}, but game versions supporting formats 15 to {last_pre_minor} require a pack_format field. Add \"pack_format\": {min} or require a version greater or equal to {}.0.",
|
||||
last_pre_minor + 1
|
||||
));
|
||||
};
|
||||
if let Some(error) = validate_pack_format_for_range(main, min, max) {
|
||||
return Err(error);
|
||||
}
|
||||
Ok(((min, 0), (max, 0)))
|
||||
}
|
||||
/// PackCompatibility: inside the pack's declared range.
|
||||
pub fn compatible(meta: &serde_json::Value) -> bool {
|
||||
let pack = &meta["pack"];
|
||||
let current = RESOURCE_FORMAT;
|
||||
if let (Some(min), Some(max)) = (
|
||||
format_of(&pack["min_format"]),
|
||||
format_of(&pack["max_format"]),
|
||||
) {
|
||||
return min <= current
|
||||
&& current <= (max.0, max.1.max(if max.1 == 0 { i64::MAX } else { max.1 }));
|
||||
}
|
||||
if let Some(range) = pack["supported_formats"].as_array()
|
||||
&& let (Some(lo), Some(hi)) = (
|
||||
range.first().and_then(|v| v.as_i64()),
|
||||
range.get(1).and_then(|v| v.as_i64()),
|
||||
)
|
||||
{
|
||||
return lo <= current.0 && current.0 <= hi;
|
||||
}
|
||||
format_of(&pack["pack_format"]).is_some_and(|f| f.0 == current.0)
|
||||
compatibility(meta) == crate::pack_screen::Compat::Compatible
|
||||
}
|
||||
/// `PackCompatibility.forVersion`: too old, too new, compatible, or unknown
|
||||
/// when the metadata names no format.
|
||||
/// when the metadata names no (valid) format.
|
||||
pub fn compatibility(meta: &serde_json::Value) -> crate::pack_screen::Compat {
|
||||
compatibility_for(meta, RESOURCE_FORMAT)
|
||||
}
|
||||
/// `compatibility` against `current`, the resource or the data pack format.
|
||||
pub fn compatibility_for(
|
||||
meta: &serde_json::Value,
|
||||
current: (i64, i64),
|
||||
) -> crate::pack_screen::Compat {
|
||||
/// Metadata that fails `PackFormat.packCodec` parses as the fallback type,
|
||||
/// whose format is `Integer.MAX_VALUE`: unknown.
|
||||
pub fn compatibility_for(meta: &serde_json::Value, current: Format) -> crate::pack_screen::Compat {
|
||||
use crate::pack_screen::Compat;
|
||||
let pack = &meta["pack"];
|
||||
let range = if let (Some(min), Some(max)) = (
|
||||
format_of(&pack["min_format"]),
|
||||
format_of(&pack["max_format"]),
|
||||
) {
|
||||
Some((min, (max.0, if max.1 == 0 { i64::MAX } else { max.1 })))
|
||||
} else if let Some(r) = pack["supported_formats"].as_array()
|
||||
&& let (Some(lo), Some(hi)) = (
|
||||
r.first().and_then(|v| v.as_i64()),
|
||||
r.get(1).and_then(|v| v.as_i64()),
|
||||
)
|
||||
{
|
||||
Some(((lo, 0), (hi, i64::MAX)))
|
||||
} else {
|
||||
format_of(&pack["pack_format"]).map(|f| ((f.0, 0), (f.0, i64::MAX)))
|
||||
};
|
||||
match range {
|
||||
None => Compat::Unknown,
|
||||
Some((_, max)) if max < current => Compat::TooOld,
|
||||
Some((min, _)) if current < min => Compat::TooNew,
|
||||
_ => Compat::Compatible,
|
||||
match validate_pack_range(&meta["pack"], last_pre_minor(current)) {
|
||||
Err(_) => Compat::Unknown,
|
||||
Ok((_, max)) if max < current => Compat::TooOld,
|
||||
Ok((min, _)) if current < min => Compat::TooNew,
|
||||
Ok(_) => Compat::Compatible,
|
||||
}
|
||||
}
|
||||
/// Whether an `overlays.entries[]` item applies to the current format
|
||||
@@ -739,6 +902,12 @@ pub fn enabled(all: &[Pack], ids: &[String]) -> Vec<Pack> {
|
||||
.filter_map(|id| all.iter().find(|p| &p.id == id).cloned())
|
||||
.collect()
|
||||
}
|
||||
/// `PackRepository.isAbleToClearAnyPack`: clearing the selection
|
||||
/// (`rebuildSelected([])`, which keeps only the required vanilla pack)
|
||||
/// changes it, that is, some selected pack still exists.
|
||||
pub fn able_to_clear_any_pack(all: &[Pack], selected: &[String]) -> bool {
|
||||
!enabled(all, selected).is_empty()
|
||||
}
|
||||
/// Resource root for a pack selection: vanilla itself when no pack is on.
|
||||
pub fn resource_root(vanilla: &Path, packs: &[Pack], cache: &Path) -> io::Result<PathBuf> {
|
||||
if packs.is_empty() {
|
||||
@@ -957,6 +1126,134 @@ mod tests {
|
||||
assert!(read_meta(&Source::Dir(dir.clone())).is_none());
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
/// `PackFormat.IntermediaryFormat.validate` and its three helpers, with
|
||||
/// the resource pack's last pre-minor version, 64.
|
||||
#[test]
|
||||
fn pack_format_validation() {
|
||||
let v = |s: &str| serde_json::from_str::<serde_json::Value>(s).unwrap();
|
||||
let check = |s: &str| validate_pack_range(&v(s), 64);
|
||||
// validate: both min_format and max_format, or neither.
|
||||
assert!(
|
||||
check(r#"{"min_format":97}"#)
|
||||
.unwrap_err()
|
||||
.contains("both min_format and max_format")
|
||||
);
|
||||
assert!(check(r#"{"max_format":97}"#).is_err());
|
||||
assert!(check("{}").unwrap_err().contains("missing format version"));
|
||||
// A pack_format alone: fine up to 64, not above.
|
||||
assert_eq!(check(r#"{"pack_format":34}"#), Ok(((34, 0), (34, 0))));
|
||||
assert!(
|
||||
check(r#"{"pack_format":97}"#)
|
||||
.unwrap_err()
|
||||
.contains("newer than 64")
|
||||
);
|
||||
// validateNewFormat: min above max, the deprecated key above 64.
|
||||
assert!(
|
||||
check(r#"{"min_format":98,"max_format":97}"#)
|
||||
.unwrap_err()
|
||||
.contains("greater than max_format")
|
||||
);
|
||||
assert!(check(r#"{"min_format":[97,2],"max_format":[97,1]}"#).is_err());
|
||||
assert!(
|
||||
check(r#"{"min_format":97,"max_format":98,"supported_formats":[97,98]}"#)
|
||||
.unwrap_err()
|
||||
.contains("deprecated")
|
||||
);
|
||||
assert_eq!(
|
||||
check(r#"{"min_format":97,"max_format":98}"#),
|
||||
Ok(((97, 0), (98, i64::from(i32::MAX))))
|
||||
);
|
||||
assert_eq!(
|
||||
check(r#"{"min_format":[97,0],"max_format":[97,1]}"#),
|
||||
Ok(((97, 0), (97, 1)))
|
||||
);
|
||||
// validatePackFormatForRange: a pack_format outside the range.
|
||||
assert!(
|
||||
check(r#"{"min_format":97,"max_format":98,"pack_format":99}"#)
|
||||
.unwrap_err()
|
||||
.contains("declared main format is 99")
|
||||
);
|
||||
assert!(check(r#"{"min_format":97,"max_format":98,"pack_format":98}"#).is_ok());
|
||||
// Below 65 the old fields are required and must agree.
|
||||
assert!(
|
||||
check(r#"{"min_format":34,"max_format":70}"#)
|
||||
.unwrap_err()
|
||||
.contains("require a supported_formats")
|
||||
);
|
||||
assert!(
|
||||
check(
|
||||
r#"{"min_format":34,"max_format":70,"supported_formats":[35,64],"pack_format":35}"#
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("mismatch")
|
||||
);
|
||||
assert!(
|
||||
check(
|
||||
r#"{"min_format":34,"max_format":70,"supported_formats":[34,60],"pack_format":35}"#
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("mismatch")
|
||||
);
|
||||
assert!(
|
||||
check(r#"{"min_format":34,"max_format":70,"supported_formats":[34,64]}"#)
|
||||
.unwrap_err()
|
||||
.contains("require a pack_format")
|
||||
);
|
||||
assert!(
|
||||
check(
|
||||
r#"{"min_format":34,"max_format":70,"supported_formats":[34,64],"pack_format":33}"#
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
check(
|
||||
r#"{"min_format":34,"max_format":70,"supported_formats":[34,64],"pack_format":34}"#
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
// Multi-version packs below 15 cannot work.
|
||||
assert!(
|
||||
check(
|
||||
r#"{"min_format":10,"max_format":70,"supported_formats":[10,64],"pack_format":10}"#
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("less than 15")
|
||||
);
|
||||
// validateOldFormat: a range above 64, or no pack_format.
|
||||
assert!(
|
||||
check(r#"{"supported_formats":[34,70],"pack_format":34}"#)
|
||||
.unwrap_err()
|
||||
.contains("newer than 64")
|
||||
);
|
||||
assert!(
|
||||
check(r#"{"supported_formats":[34,60]}"#)
|
||||
.unwrap_err()
|
||||
.contains("require a pack_format")
|
||||
);
|
||||
assert_eq!(
|
||||
check(r#"{"supported_formats":[34,60],"pack_format":40}"#),
|
||||
Ok(((34, 0), (60, 0)))
|
||||
);
|
||||
assert!(
|
||||
check(
|
||||
r#"{"supported_formats":{"min_inclusive":34,"max_inclusive":60},"pack_format":40}"#
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(check(r#"{"supported_formats":[60,34],"pack_format":40}"#).is_err());
|
||||
// A refused pack is of unknown format.
|
||||
use crate::pack_screen::Compat;
|
||||
assert_eq!(
|
||||
compatibility(&v(r#"{"pack":{"pack_format":97}}"#)),
|
||||
Compat::Unknown
|
||||
);
|
||||
assert_eq!(
|
||||
compatibility(&v(
|
||||
r#"{"pack":{"min_format":97,"max_format":98,"pack_format":99}}"#
|
||||
)),
|
||||
Compat::Unknown
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn compatibility_ranges() {
|
||||
let v = |s: &str| serde_json::from_str::<serde_json::Value>(s).unwrap();
|
||||
@@ -969,7 +1266,6 @@ mod tests {
|
||||
assert!(!compatible(&v(
|
||||
r#"{"pack":{"min_format":[97,2],"max_format":99}}"#
|
||||
)));
|
||||
assert!(compatible(&v(r#"{"pack":{"pack_format":97}}"#)));
|
||||
assert!(!compatible(&v(r#"{"pack":{"pack_format":34}}"#)));
|
||||
assert_eq!(
|
||||
component(&v(r#"["a",{"text":"b","color":"red"}]"#)).plain(),
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,124 @@
|
||||
// The built-in stand-ins for the vanilla post shaders a `post_effect` chain can
|
||||
// name (`core/screenquad`, `post/rotscale`, `post/invert`, `post/blit`,
|
||||
// `post/bits`, `post/color_convolve`, `post/box_blur`, `post/spiderclip`).
|
||||
// Coordinates stay in the chain's own convention (v up from the bottom edge) and
|
||||
// are flipped only where a texture is read.
|
||||
|
||||
// OutSize, InSize | BlurSize, menu blur radius.
|
||||
@group(0) @binding(0) var<uniform> info: array<vec4<f32>, 2>;
|
||||
// The vertex and fragment stage's uniform block, packed as std140.
|
||||
@group(0) @binding(1) var<uniform> vs_cfg: array<vec4<f32>, 8>;
|
||||
@group(0) @binding(2) var<uniform> fs_cfg: array<vec4<f32>, 8>;
|
||||
@group(0) @binding(3) var in_tex: texture_2d<f32>;
|
||||
@group(0) @binding(4) var in_smp: sampler;
|
||||
@group(0) @binding(5) var blur_tex: texture_2d<f32>;
|
||||
@group(0) @binding(6) var blur_smp: sampler;
|
||||
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) scaled: vec2<f32>,
|
||||
}
|
||||
|
||||
fn vcf(i: u32) -> f32 {
|
||||
return vs_cfg[i / 4u][i % 4u];
|
||||
}
|
||||
fn fcf(i: u32) -> f32 {
|
||||
return fs_cfg[i / 4u][i % 4u];
|
||||
}
|
||||
fn read_in(uv: vec2<f32>) -> vec4<f32> {
|
||||
return textureSampleLevel(in_tex, in_smp, vec2(uv.x, 1.0 - uv.y), 0.0);
|
||||
}
|
||||
fn read_blur(uv: vec2<f32>) -> vec4<f32> {
|
||||
return textureSampleLevel(blur_tex, blur_smp, vec2(uv.x, 1.0 - uv.y), 0.0);
|
||||
}
|
||||
|
||||
fn corner(i: u32) -> vec2<f32> {
|
||||
return vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
}
|
||||
|
||||
@vertex fn vs_quad(@builtin(vertex_index) i: u32) -> Out {
|
||||
let p = corner(i);
|
||||
var out: Out;
|
||||
out.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
out.uv = p;
|
||||
out.scaled = p;
|
||||
return out;
|
||||
}
|
||||
|
||||
@vertex fn vs_rotscale(@builtin(vertex_index) i: u32) -> Out {
|
||||
let p = corner(i);
|
||||
var out: Out;
|
||||
out.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
out.uv = p;
|
||||
let radians = vcf(4u) * 0.0174532925;
|
||||
let c = cos(radians);
|
||||
let s = sin(radians);
|
||||
let rotated = vec2(p.x * c - p.y * s, p.y * c + p.x * s);
|
||||
out.scaled = rotated * vec2(vcf(0u), vcf(1u)) + vec2(vcf(2u), vcf(3u));
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment fn fs_invert(in: Out) -> @location(0) vec4<f32> {
|
||||
let d = read_in(in.uv);
|
||||
let mixed = mix(d, 1.0 - d, fcf(0u));
|
||||
return vec4(mixed.rgb, 1.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_blit(in: Out) -> @location(0) vec4<f32> {
|
||||
return read_in(in.uv) * vec4(fcf(0u), fcf(1u), fcf(2u), fcf(3u));
|
||||
}
|
||||
|
||||
@fragment fn fs_bits(in: Out) -> @location(0) vec4<f32> {
|
||||
let resolution = fcf(0u);
|
||||
let mosaic_in = info[0].zw / fcf(1u);
|
||||
let fract_pix = fract(in.uv * mosaic_in) / mosaic_in;
|
||||
var base = read_in(in.uv - fract_pix);
|
||||
let quantised = base.rgb - fract(base.rgb * resolution) / resolution;
|
||||
let luma = dot(quantised, vec3(0.3, 0.59, 0.11));
|
||||
let chroma = (quantised - luma) * 1.5;
|
||||
return vec4(luma + chroma, 1.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_color_convolve(in: Out) -> @location(0) vec4<f32> {
|
||||
let t = read_in(in.uv).rgb;
|
||||
let red = dot(t, vec3(fcf(0u), fcf(1u), fcf(2u)));
|
||||
let green = dot(t, vec3(fcf(4u), fcf(5u), fcf(6u)));
|
||||
let blue = dot(t, vec3(fcf(8u), fcf(9u), fcf(10u)));
|
||||
let rgb = vec3(red, green, blue);
|
||||
let luma = dot(rgb, vec3(0.3, 0.59, 0.11));
|
||||
return vec4((rgb - luma) * 1.8 + luma, 1.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_box_blur(in: Out) -> @location(0) vec4<f32> {
|
||||
let step = (1.0 / info[0].zw) * vec2(fcf(0u), fcf(1u));
|
||||
let configured = fcf(2u);
|
||||
var radius = info[1].z;
|
||||
if configured >= 0.5 {
|
||||
radius = round(configured);
|
||||
}
|
||||
var blurred = vec4(0.0);
|
||||
for (var a = -radius + 0.5; a <= radius; a += 2.0) {
|
||||
blurred += read_in(in.uv + step * a);
|
||||
}
|
||||
blurred += read_in(in.uv + step * radius) / 2.0;
|
||||
return blurred / (radius + 0.5);
|
||||
}
|
||||
|
||||
@fragment fn fs_spiderclip(in: Out) -> @location(0) vec4<f32> {
|
||||
let scaled_texel = read_in(in.scaled);
|
||||
let blur_texel = read_blur(in.uv);
|
||||
let scissor = vec4(fcf(0u), fcf(1u), fcf(2u), fcf(3u));
|
||||
let vignette = vec4(fcf(4u), fcf(5u), fcf(6u), fcf(7u));
|
||||
let s = in.scaled;
|
||||
var o = scaled_texel;
|
||||
if s.x < scissor.x { o = blur_texel; }
|
||||
if s.y < scissor.y { o = blur_texel; }
|
||||
if s.x > scissor.z { o = blur_texel; }
|
||||
if s.y > scissor.w { o = blur_texel; }
|
||||
if s.x < vignette.x { o = mix(blur_texel, o, (scissor.x - s.x) / (scissor.x - vignette.x)); }
|
||||
if s.y < vignette.y { o = mix(blur_texel, o, (scissor.y - s.y) / (scissor.y - vignette.y)); }
|
||||
if s.x > vignette.z { o = mix(blur_texel, o, (scissor.z - s.x) / (scissor.z - vignette.z)); }
|
||||
if s.y > vignette.w { o = mix(blur_texel, o, (scissor.w - s.y) / (scissor.w - vignette.w)); }
|
||||
return vec4(o.rgb, 1.0);
|
||||
}
|
||||
@@ -189,6 +189,10 @@ pub struct Settings {
|
||||
pub ctrl_click_emulates_right_click: bool,
|
||||
/// Horizon's own graphics settings (`horizon-graphics.txt`, not options.txt).
|
||||
pub horizon: crate::graphics::Graphics,
|
||||
/// The graphics API the game started with: `Options.preferredGraphicsBackendFromStartup`.
|
||||
pub startup_api: crate::graphics::GraphicsApi,
|
||||
/// `Monitor.videoModes` of the window's monitor, for the Video page (set by the game).
|
||||
pub video_modes: Vec<crate::window::VideoMode>,
|
||||
/// Controller settings (`horizon-controller.txt`, Horizon's own).
|
||||
pub controller: crate::gamepad::PadSettings,
|
||||
/// Horizon's optional extras (`horizon-extras.txt`), all off by default.
|
||||
@@ -275,6 +279,8 @@ impl Default for Settings {
|
||||
quit_shortcuts: true,
|
||||
ctrl_click_emulates_right_click: false,
|
||||
horizon: Default::default(),
|
||||
startup_api: Default::default(),
|
||||
video_modes: Vec::new(),
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
mods: Default::default(),
|
||||
@@ -308,6 +314,16 @@ fn put_s(f: &mut OptionsFile, key: &str, v: &str) {
|
||||
}
|
||||
}
|
||||
impl Settings {
|
||||
/// The index in `video_modes` of the preferred fullscreen mode, -1 (the desktop's own mode)
|
||||
/// when none is set or the monitor lacks it: `Monitor.indexOfMode`.
|
||||
pub fn video_mode_index(&self) -> i32 {
|
||||
self.file
|
||||
.fullscreen_resolution
|
||||
.as_deref()
|
||||
.and_then(crate::window::VideoMode::read)
|
||||
.and_then(|mode| self.video_modes.iter().position(|m| *m == mode))
|
||||
.map_or(-1, |i| i as i32)
|
||||
}
|
||||
pub fn load(path: &Path) -> Self {
|
||||
let horizon = path
|
||||
.parent()
|
||||
@@ -342,7 +358,11 @@ impl Settings {
|
||||
},
|
||||
};
|
||||
settings.describe_for_crashes();
|
||||
settings
|
||||
// Options.load: the backend asked for at startup, what a restart is measured from.
|
||||
Self {
|
||||
startup_api: settings.horizon.api,
|
||||
..settings
|
||||
}
|
||||
}
|
||||
/// The render settings and resource packs a crash report lists.
|
||||
fn describe_for_crashes(&self) {
|
||||
@@ -441,6 +461,8 @@ impl Settings {
|
||||
quit_shortcuts: f.bool("quitShortcuts"),
|
||||
ctrl_click_emulates_right_click: f.bool("ctrlClickEmulatesRightClick"),
|
||||
horizon: Default::default(),
|
||||
startup_api: Default::default(),
|
||||
video_modes: Vec::new(),
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
mods: Default::default(),
|
||||
@@ -767,6 +789,16 @@ pub enum Opt {
|
||||
SharePresence,
|
||||
DirectionalAudio,
|
||||
MusicToast,
|
||||
/// `Options.exclusiveFullscreen`.
|
||||
ExclusiveFullscreen,
|
||||
/// VideoSettingsScreen's "Exclusive Fullscreen Mode": the monitor's mode to take.
|
||||
ExclusiveMode,
|
||||
/// `Options.preferredGraphicsBackend`.
|
||||
GraphicsBackend,
|
||||
/// `Options.textureFiltering`.
|
||||
TextureFiltering,
|
||||
/// `Options.maxAnisotropyBit`.
|
||||
MaxAnisotropy,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -835,6 +867,17 @@ mod tests {
|
||||
assert_eq!(again.keys.get("key.jump"), "key.keyboard.j");
|
||||
}
|
||||
#[test]
|
||||
fn client_information_maps_the_stored_main_hand() {
|
||||
// The Skin Customization option is stored by name; ClientInformation
|
||||
// sends HumanoidArm (LEFT = 0, RIGHT = 1).
|
||||
for (stored, arm) in [("left", 0), ("right", 1)] {
|
||||
let s = Settings::parse(&format!("mainHand:{stored}\n"));
|
||||
assert_eq!(s.client_information(10).main_hand, arm);
|
||||
assert!(s.serialize().contains(&format!("mainHand:\"{stored}\"")));
|
||||
}
|
||||
assert_eq!(Settings::default().client_information(10).main_hand, 1);
|
||||
}
|
||||
#[test]
|
||||
fn no_options_txt_paces_frames_like_an_empty_one() {
|
||||
let (fresh, empty) = (Settings::default(), Settings::parse(""));
|
||||
assert_eq!(fresh.max_fps, 120);
|
||||
|
||||
@@ -32,45 +32,14 @@ pub const TEXTURE_URIS: [&str; 2] = [
|
||||
|
||||
/// `java.util.Base64.getDecoder().decode`.
|
||||
pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(text.len() / 4 * 3);
|
||||
let (mut bits, mut shift) = (0u32, 18i32);
|
||||
let bytes: Vec<char> = text.chars().collect();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
let c = bytes[i];
|
||||
i += 1;
|
||||
let b = match c {
|
||||
'A'..='Z' => c as u32 - 'A' as u32,
|
||||
'a'..='z' => c as u32 - 'a' as u32 + 26,
|
||||
'0'..='9' => c as u32 - '0' as u32 + 52,
|
||||
'+' => 62,
|
||||
'/' => 63,
|
||||
'=' => {
|
||||
if shift == 6 && (i == bytes.len() || bytes[i] != '=') || shift == 18 {
|
||||
return None;
|
||||
}
|
||||
if shift == 6 {
|
||||
i += 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
bits |= b << shift;
|
||||
shift -= 6;
|
||||
if shift < 0 {
|
||||
out.extend([(bits >> 16) as u8, (bits >> 8) as u8, bits as u8]);
|
||||
shift = 18;
|
||||
bits = 0;
|
||||
}
|
||||
}
|
||||
match shift {
|
||||
6 => out.push((bits >> 16) as u8),
|
||||
0 => out.extend([(bits >> 16) as u8, (bits >> 8) as u8]),
|
||||
12 => return None,
|
||||
_ => {}
|
||||
}
|
||||
(i >= bytes.len()).then_some(out)
|
||||
use base64::{
|
||||
Engine,
|
||||
engine::general_purpose::{STANDARD, STANDARD_NO_PAD},
|
||||
};
|
||||
STANDARD
|
||||
.decode(text)
|
||||
.or_else(|_| STANDARD_NO_PAD.decode(text))
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// `unpackTextures(property)`: the textures of a `textures` property's
|
||||
@@ -557,6 +526,39 @@ pub fn offline_cape() -> Option<&'static [u8]> {
|
||||
.as_deref()
|
||||
}
|
||||
|
||||
/// The skin the launcher gives an offline name (`HORIZON_OFFLINE_SKIN`, a
|
||||
/// PNG's path, and `HORIZON_OFFLINE_SKIN_MODEL`, `classic` or `slim`;
|
||||
/// launcher/src/skins.rs): its 64x64 RGBA as `process_legacy_skin` lays it
|
||||
/// out, and whether it is slim. Read once.
|
||||
pub fn offline_skin() -> Option<&'static (Vec<u8>, bool)> {
|
||||
static SKIN: std::sync::OnceLock<Option<(Vec<u8>, bool)>> = std::sync::OnceLock::new();
|
||||
SKIN.get_or_init(|| {
|
||||
let path = std::env::var_os("HORIZON_OFFLINE_SKIN").filter(|p| !p.is_empty())?;
|
||||
let model = std::env::var("HORIZON_OFFLINE_SKIN_MODEL").unwrap_or_default();
|
||||
read_offline_skin(std::path::Path::new(&path), &model)
|
||||
.inspect_err(|e| eprintln!("Horizon: no offline skin ({}): {e}", path.display()))
|
||||
.ok()
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
fn read_offline_skin(path: &std::path::Path, model: &str) -> Result<(Vec<u8>, bool), String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
let (w, h, rgba) = crate::hud::decode_bytes(&bytes).map_err(|e| e.to_string())?;
|
||||
let skin = process_legacy_skin(w, h, &rgba).ok_or("not a 64x64 or 64x32 skin")?;
|
||||
Ok((skin, model == "slim"))
|
||||
}
|
||||
|
||||
/// The signed-in account's own profile properties (`Minecraft.profileFuture`:
|
||||
/// `fetchProfile(id, true)`, so its `textures` property is signed), or empty
|
||||
/// without an answer. Blocks on the network.
|
||||
pub fn fetch_own_properties(id: u128) -> Vec<(String, String, Option<String>)> {
|
||||
crate::auth::discovery()
|
||||
.ok()
|
||||
.and_then(|doc| resolve_profile(&NameOrId::Id(id), &doc))
|
||||
.map(|p| p.properties)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Where a texture download stands.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Download {
|
||||
@@ -902,60 +904,49 @@ fn http_get(url: &str) -> Option<Vec<u8>> {
|
||||
|
||||
/// SHA-1 (FIPS 180-4).
|
||||
pub fn sha1(data: &[u8]) -> [u8; 20] {
|
||||
let mut h: [u32; 5] = [
|
||||
0x6745_2301,
|
||||
0xEFCD_AB89,
|
||||
0x98BA_DCFE,
|
||||
0x1032_5476,
|
||||
0xC3D2_E1F0,
|
||||
];
|
||||
let mut msg = data.to_vec();
|
||||
msg.push(0x80);
|
||||
while msg.len() % 64 != 56 {
|
||||
msg.push(0);
|
||||
}
|
||||
msg.extend((data.len() as u64 * 8).to_be_bytes());
|
||||
for block in msg.as_chunks::<64>().0 {
|
||||
let mut w = [0u32; 80];
|
||||
for (i, word) in block.as_chunks::<4>().0.iter().enumerate() {
|
||||
w[i] = u32::from_be_bytes(*word);
|
||||
}
|
||||
for i in 16..80 {
|
||||
w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
|
||||
}
|
||||
let [mut a, mut b, mut c, mut d, mut e] = h;
|
||||
for (i, wi) in w.iter().enumerate() {
|
||||
let (f, k) = match i {
|
||||
0..=19 => ((b & c) | (!b & d), 0x5A82_7999),
|
||||
20..=39 => (b ^ c ^ d, 0x6ED9_EBA1),
|
||||
40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1B_BCDC),
|
||||
_ => (b ^ c ^ d, 0xCA62_C1D6),
|
||||
};
|
||||
let t = a
|
||||
.rotate_left(5)
|
||||
.wrapping_add(f)
|
||||
.wrapping_add(e)
|
||||
.wrapping_add(k)
|
||||
.wrapping_add(*wi);
|
||||
e = d;
|
||||
d = c;
|
||||
c = b.rotate_left(30);
|
||||
b = a;
|
||||
a = t;
|
||||
}
|
||||
for (x, y) in h.iter_mut().zip([a, b, c, d, e]) {
|
||||
*x = x.wrapping_add(y);
|
||||
}
|
||||
}
|
||||
let mut out = [0u8; 20];
|
||||
for (i, x) in h.iter().enumerate() {
|
||||
out[i * 4..i * 4 + 4].copy_from_slice(&x.to_be_bytes());
|
||||
}
|
||||
out
|
||||
use sha1::{Digest, Sha1};
|
||||
Sha1::digest(data).into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// The launcher's offline skin: a 64x64 or legacy 64x32 PNG laid out as
|
||||
/// a downloaded skin, slim only for the `slim` model; anything else is
|
||||
/// refused and the player keeps their default skin.
|
||||
#[test]
|
||||
fn offline_skins_read_like_downloaded_ones() {
|
||||
let png = |w: u32, h: u32| {
|
||||
let rgba: Vec<u8> = (0..w * h * 4).map(|i| (i % 251) as u8).collect();
|
||||
let mut out = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut out, w, h);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&rgba)
|
||||
.unwrap();
|
||||
(out, rgba)
|
||||
};
|
||||
let dir = std::env::temp_dir().join(format!("horizon-offline-skin-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("steve.png");
|
||||
for (h, model, slim) in [(64, "slim", true), (32, "classic", false), (64, "", false)] {
|
||||
let (bytes, rgba) = png(64, h);
|
||||
std::fs::write(&file, bytes).unwrap();
|
||||
let (skin, is_slim) = super::read_offline_skin(&file, model).unwrap();
|
||||
assert_eq!(skin, super::process_legacy_skin(64, h, &rgba).unwrap());
|
||||
assert_eq!(skin.len(), 64 * 64 * 4);
|
||||
assert_eq!(is_slim, slim);
|
||||
}
|
||||
std::fs::write(&file, png(32, 32).0).unwrap();
|
||||
assert!(super::read_offline_skin(&file, "slim").is_err());
|
||||
std::fs::write(&file, b"not a png").unwrap();
|
||||
assert!(super::read_offline_skin(&file, "slim").is_err());
|
||||
assert!(super::read_offline_skin(&dir.join("missing.png"), "").is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `MinecraftProfileTexture.getMetadata("model")`: `slim` marks a slim
|
||||
/// skin; no metadata is the wide (classic) model.
|
||||
#[test]
|
||||
|
||||
@@ -58,8 +58,13 @@ pub enum Ticking {
|
||||
Bee { bee: u32, aggressive: bool },
|
||||
/// `GuardianAttackSoundInstance`.
|
||||
GuardianAttack { guardian: u32 },
|
||||
/// `SnifferSoundInstance`.
|
||||
Sniffer { sniffer: u32 },
|
||||
/// `ElytraOnPlayerSoundInstance`: ticks since it started.
|
||||
Elytra { time: i32 },
|
||||
/// `RidingEntitySoundInstance` for the player riding a happy ghast
|
||||
/// (`underwater_sound` false) or a nautilus (true).
|
||||
RidingEntity { entity: u32, underwater_sound: bool },
|
||||
}
|
||||
/// What a minecart's sound instances read about it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -83,6 +88,19 @@ pub struct MobSound {
|
||||
pub silent: bool,
|
||||
/// `Guardian.getAttackAnimationScale(0)`.
|
||||
pub attack_scale: f32,
|
||||
/// `getTarget() == null && Sniffer.canPlayDiggingSound()`.
|
||||
pub sniffer_digging: bool,
|
||||
}
|
||||
/// What a `RidingEntitySoundInstance` reads about the mob the player rides.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct RiddenSound {
|
||||
pub id: u32,
|
||||
/// `getDeltaMovement().length()`.
|
||||
pub speed: f64,
|
||||
/// `isSilent`.
|
||||
pub silent: bool,
|
||||
/// `Entity.isUnderWater`.
|
||||
pub underwater: bool,
|
||||
}
|
||||
/// What tickable instances read about the player and the entities they follow.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -103,6 +121,8 @@ pub struct TickContext {
|
||||
pub player_speed_sqr: f64,
|
||||
/// `isFallFlying()` of the player.
|
||||
pub player_fall_flying: bool,
|
||||
/// The mob the player rides, when it is not a minecart.
|
||||
pub ridden: Option<RiddenSound>,
|
||||
}
|
||||
/// `AbstractSoundInstance` and its subclasses' fields.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -345,6 +365,43 @@ impl SoundInstance {
|
||||
};
|
||||
s
|
||||
}
|
||||
/// `new RidingEntitySoundInstance(player, happyGhast, false,
|
||||
/// HAPPY_GHAST_RIDING, happyGhast.getSoundSource(), 0, 1, 5)`, from
|
||||
/// `LocalPlayer.startRiding`.
|
||||
pub fn riding_happy_ghast(ghast: &RiddenSound, random: LegacyRandom) -> Self {
|
||||
Self::riding_entity("minecraft:entity.happy_ghast.riding", ghast, false, random)
|
||||
}
|
||||
/// `new RidingEntitySoundInstance(player, nautilus, true,
|
||||
/// NAUTILUS_RIDING, nautilus.getSoundSource(), 0, 1, 5)`, from
|
||||
/// `LocalPlayer.startRiding`.
|
||||
pub fn riding_nautilus(nautilus: &RiddenSound, random: LegacyRandom) -> Self {
|
||||
Self::riding_entity("minecraft:entity.nautilus.riding", nautilus, true, random)
|
||||
}
|
||||
fn riding_entity(
|
||||
id: &str,
|
||||
entity: &RiddenSound,
|
||||
underwater_sound: bool,
|
||||
random: LegacyRandom,
|
||||
) -> Self {
|
||||
let mut s = Self::new(
|
||||
id,
|
||||
NEUTRAL,
|
||||
0.0,
|
||||
1.0,
|
||||
random,
|
||||
true,
|
||||
0,
|
||||
Attenuation::None,
|
||||
[0.0; 3],
|
||||
false,
|
||||
);
|
||||
s.can_start_silent = true;
|
||||
s.ticking = Ticking::RidingEntity {
|
||||
entity: entity.id,
|
||||
underwater_sound,
|
||||
};
|
||||
s
|
||||
}
|
||||
/// `new BeeFlyingSoundInstance(bee)` (or `BeeAggressiveSoundInstance`):
|
||||
/// silent until the bee moves.
|
||||
pub fn bee(bee: &MobSound, aggressive: bool, random: LegacyRandom) -> Self {
|
||||
@@ -398,6 +455,25 @@ impl SoundInstance {
|
||||
};
|
||||
s
|
||||
}
|
||||
/// `new SnifferSoundInstance(sniffer)`: the digging sound, which sits at
|
||||
/// the origin (`AbstractSoundInstance` leaves x, y, z at 0) until its
|
||||
/// first tick moves it to the sniffer.
|
||||
pub fn sniffer(sniffer: u32, random: LegacyRandom) -> Self {
|
||||
let mut s = Self::new(
|
||||
"minecraft:entity.sniffer.digging",
|
||||
NEUTRAL,
|
||||
1.0,
|
||||
1.0,
|
||||
random,
|
||||
false,
|
||||
0,
|
||||
Attenuation::Linear,
|
||||
[0.0; 3],
|
||||
false,
|
||||
);
|
||||
s.ticking = Ticking::Sniffer { sniffer };
|
||||
s
|
||||
}
|
||||
/// `new ElytraOnPlayerSoundInstance(player)`: looping at volume 0.1 until
|
||||
/// its first tick.
|
||||
pub fn elytra(position: [f64; 3], random: LegacyRandom) -> Self {
|
||||
@@ -551,6 +627,27 @@ impl SoundInstance {
|
||||
1.0
|
||||
};
|
||||
}
|
||||
Ticking::Sniffer { sniffer } => {
|
||||
// canPlaySound (`!isSilent`), then `tick`: follow the sniffer
|
||||
// while it is alive, has no target and still digs or searches.
|
||||
let id = *sniffer;
|
||||
let Some(sniffer) = ctx
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id && !m.silent && m.sniffer_digging)
|
||||
else {
|
||||
self.stop();
|
||||
return;
|
||||
};
|
||||
let [x, y, z] = sniffer.position;
|
||||
self.position = [
|
||||
f64::from(x as f32),
|
||||
f64::from(y as f32),
|
||||
f64::from(z as f32),
|
||||
];
|
||||
self.volume = 1.0;
|
||||
self.pitch = 1.0;
|
||||
}
|
||||
Ticking::GuardianAttack { guardian } => {
|
||||
let id = *guardian;
|
||||
// The client's guardian never has a target of its own, so
|
||||
@@ -569,6 +666,26 @@ impl SoundInstance {
|
||||
self.volume = 0.0 + 1.0 * scale * scale;
|
||||
self.pitch = 0.7 + 0.5 * scale;
|
||||
}
|
||||
Ticking::RidingEntity {
|
||||
entity,
|
||||
underwater_sound,
|
||||
} => {
|
||||
// RidingEntitySoundInstance.tick: canPlaySound, then while the
|
||||
// player still rides it, volumeMin (0) when
|
||||
// `underwaterSound != entity.isUnderWater()` and else
|
||||
// `volumeAmplifier * Mth.clampedLerp(speed, volumeMin, volumeMax)`.
|
||||
let (id, underwater_sound) = (*entity, *underwater_sound);
|
||||
let Some(entity) = ctx.ridden.filter(|r| r.id == id && !r.silent) else {
|
||||
self.stop();
|
||||
return;
|
||||
};
|
||||
let speed = entity.speed as f32;
|
||||
self.volume = if underwater_sound != entity.underwater || speed < 0.01 {
|
||||
0.0
|
||||
} else {
|
||||
5.0 * speed.clamp(0.0, 1.0)
|
||||
};
|
||||
}
|
||||
Ticking::RidingMinecart { cart, underwater } => {
|
||||
let (id, underwater) = (*cart, *underwater);
|
||||
let found = ctx.carts.iter().find(|c| c.id == id);
|
||||
@@ -1328,3 +1445,108 @@ pub mod test_backend {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn happy_ghast_riding_sound_follows_the_ghast_speed() {
|
||||
let ghast = RiddenSound {
|
||||
id: 7,
|
||||
speed: 0.1,
|
||||
silent: false,
|
||||
underwater: false,
|
||||
};
|
||||
let mut s = SoundInstance::riding_happy_ghast(&ghast, SoundEngine::unseeded());
|
||||
assert_eq!(s.volume, 0.0);
|
||||
let mut ctx = TickContext {
|
||||
ridden: Some(ghast),
|
||||
..TickContext::default()
|
||||
};
|
||||
// 5 * clampedLerp(speed, 0, 1).
|
||||
s.tick(&ctx);
|
||||
assert!((s.volume - 0.5).abs() < 1e-6, "{}", s.volume);
|
||||
ctx.ridden = Some(RiddenSound {
|
||||
speed: 3.0,
|
||||
..ghast
|
||||
});
|
||||
s.tick(&ctx);
|
||||
assert_eq!(s.volume, 5.0);
|
||||
ctx.ridden = Some(RiddenSound {
|
||||
speed: 0.005,
|
||||
..ghast
|
||||
});
|
||||
s.tick(&ctx);
|
||||
assert_eq!(s.volume, 0.0);
|
||||
// Under water the ghast's sound is not the underwater one.
|
||||
ctx.ridden = Some(RiddenSound {
|
||||
underwater: true,
|
||||
..ghast
|
||||
});
|
||||
s.tick(&ctx);
|
||||
assert_eq!(s.volume, 0.0);
|
||||
assert!(!s.stopped);
|
||||
// Off the ghast, it stops.
|
||||
ctx.ridden = None;
|
||||
s.tick(&ctx);
|
||||
assert!(s.stopped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniffer_digging_sound_follows_the_sniffer_and_stops() {
|
||||
let sniffer = MobSound {
|
||||
id: 3,
|
||||
position: [10.0, 64.0, -5.0],
|
||||
speed: 0.0,
|
||||
baby: false,
|
||||
angry: false,
|
||||
silent: false,
|
||||
attack_scale: 0.0,
|
||||
sniffer_digging: true,
|
||||
};
|
||||
let mut s = SoundInstance::sniffer(3, SoundEngine::unseeded());
|
||||
assert_eq!(s.position, [0.0; 3]);
|
||||
assert!(!s.looping);
|
||||
let mut ctx = TickContext {
|
||||
mobs: vec![sniffer],
|
||||
..TickContext::default()
|
||||
};
|
||||
s.tick(&ctx);
|
||||
assert_eq!(s.position, [10.0, 64.0, -5.0]);
|
||||
assert!(!s.stopped);
|
||||
// A target, a state other than digging or searching: stop.
|
||||
ctx.mobs[0].sniffer_digging = false;
|
||||
s.tick(&ctx);
|
||||
assert!(s.stopped);
|
||||
// Removed (missing): stop.
|
||||
let mut s = SoundInstance::sniffer(3, SoundEngine::unseeded());
|
||||
ctx.mobs.clear();
|
||||
s.tick(&ctx);
|
||||
assert!(s.stopped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nautilus_riding_sound_plays_only_under_water() {
|
||||
let nautilus = RiddenSound {
|
||||
id: 9,
|
||||
speed: 0.2,
|
||||
silent: false,
|
||||
underwater: true,
|
||||
};
|
||||
let mut s = SoundInstance::riding_nautilus(&nautilus, SoundEngine::unseeded());
|
||||
let mut ctx = TickContext {
|
||||
ridden: Some(nautilus),
|
||||
..TickContext::default()
|
||||
};
|
||||
s.tick(&ctx);
|
||||
assert!((s.volume - 1.0).abs() < 1e-6, "{}", s.volume);
|
||||
ctx.ridden = Some(RiddenSound {
|
||||
underwater: false,
|
||||
..nautilus
|
||||
});
|
||||
s.tick(&ctx);
|
||||
assert_eq!(s.volume, 0.0);
|
||||
assert!(!s.stopped);
|
||||
}
|
||||
}
|
||||
@@ -313,6 +313,9 @@ pub struct Streaming {
|
||||
decorates: bool,
|
||||
lighting: FxHashMap<(i32, i32), u64>,
|
||||
meshing: FxHashMap<(i32, i32, i32), u64>,
|
||||
/// The sequence each section's latest accepted mesh was dispatched at
|
||||
/// (`CompiledSectionMesh.compileTaskStartTimeNs`).
|
||||
compile_started: FxHashMap<(i32, i32, i32), u64>,
|
||||
/// Chunks whose light is current.
|
||||
lit: FxHashSet<(i32, i32)>,
|
||||
/// Columns whose sections have been meshed at least once.
|
||||
@@ -687,6 +690,7 @@ impl Streaming {
|
||||
decorates: generator.decorates(),
|
||||
lighting: Default::default(),
|
||||
meshing: Default::default(),
|
||||
compile_started: Default::default(),
|
||||
lit: Default::default(),
|
||||
meshed: Default::default(),
|
||||
waiting: Default::default(),
|
||||
@@ -730,6 +734,7 @@ impl Streaming {
|
||||
pub fn reload_resources(&mut self, world: &mut World, context: Arc<WorkerContext>) {
|
||||
*self.shared.context.write().unwrap() = context;
|
||||
self.meshing.clear();
|
||||
self.compile_started.clear();
|
||||
for &(x, z) in world.chunks.keys() {
|
||||
if self.lit.contains(&(x, z)) {
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
@@ -793,6 +798,7 @@ impl Streaming {
|
||||
// shrunk storage forgets thousands of columns in one frame.
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
self.meshing.remove(&(x, sy, z));
|
||||
self.compile_started.remove(&(x, sy, z));
|
||||
self.waiting.remove(&(x, sy, z));
|
||||
}
|
||||
world.dirty_sections.take_column(x, z);
|
||||
@@ -1002,6 +1008,17 @@ impl Streaming {
|
||||
return None;
|
||||
})
|
||||
}
|
||||
/// The sequence the next mesh job will be dispatched after: a clock for
|
||||
/// `compile_start`, whose jobs all snapshot the world as they are
|
||||
/// dispatched (`Util.getNanos()` at the start of a compile task).
|
||||
pub fn sequence(&self) -> u64 {
|
||||
self.sequence
|
||||
}
|
||||
/// When the section's latest accepted mesh started compiling, on the
|
||||
/// `sequence` clock (`CompiledSectionMesh.getCompileTaskStartTime`).
|
||||
pub fn compile_start(&self, section: (i32, i32, i32)) -> Option<u64> {
|
||||
self.compile_started.get(§ion).copied()
|
||||
}
|
||||
pub fn is_meshing(&self, section: (i32, i32, i32)) -> bool {
|
||||
self.meshing.contains_key(§ion)
|
||||
}
|
||||
@@ -1125,6 +1142,7 @@ impl Streaming {
|
||||
continue;
|
||||
}
|
||||
self.meshing.remove(&(x, sy, z));
|
||||
self.compile_started.insert((x, sy, z), seq);
|
||||
self.meshed_total += 1;
|
||||
uploads.push(((x, sy, z), mesh));
|
||||
}
|
||||
@@ -1156,6 +1174,8 @@ impl Streaming {
|
||||
if !far.is_empty() {
|
||||
let far: HashSet<(i32, i32)> = far.iter().copied().collect();
|
||||
self.meshing.retain(|k, _| !far.contains(&(k.0, k.2)));
|
||||
self.compile_started
|
||||
.retain(|k, _| !far.contains(&(k.0, k.2)));
|
||||
self.waiting.retain(|k| !far.contains(&(k.0, k.2)));
|
||||
world
|
||||
.dirty_sections
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
//! `StructureBlockEntity`: the structure block's name, offset, size, mode and
|
||||
//! options, with its save format (`saveAdditional` / `loadAdditional`), the
|
||||
//! corner scan (`getEnclosingBoundingBox`) and the box the editor shows
|
||||
//! (`getRenderableBox`).
|
||||
use horizon_client_core::nbt_value::{float, number, string, text};
|
||||
use horizon_world::{
|
||||
nbt::Tag,
|
||||
worldgen::structure::{Mirror, Rotation},
|
||||
};
|
||||
|
||||
pub const MAX_OFFSET_PER_AXIS: i32 = 48;
|
||||
pub const MAX_SIZE_PER_AXIS: i32 = 48;
|
||||
/// `SCAN_CORNER_BLOCKS_RANGE`.
|
||||
pub const SCAN_CORNER_BLOCKS_RANGE: i32 = 80;
|
||||
|
||||
/// `StructureMode`, in declaration order.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum StructureMode {
|
||||
Save,
|
||||
Load,
|
||||
Corner,
|
||||
Data,
|
||||
}
|
||||
|
||||
impl StructureMode {
|
||||
pub const ALL: [Self; 4] = [Self::Save, Self::Load, Self::Corner, Self::Data];
|
||||
/// `StructureMode.getSerializedName`: the block state's `mode` value.
|
||||
pub fn serialized_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Save => "save",
|
||||
Self::Load => "load",
|
||||
Self::Corner => "corner",
|
||||
Self::Data => "data",
|
||||
}
|
||||
}
|
||||
pub fn of_serialized_name(name: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|m| m.serialized_name() == name)
|
||||
}
|
||||
/// `StructureMode.LEGACY_CODEC`: `Enum.name()`.
|
||||
pub fn legacy_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Save => "SAVE",
|
||||
Self::Load => "LOAD",
|
||||
Self::Corner => "CORNER",
|
||||
Self::Data => "DATA",
|
||||
}
|
||||
}
|
||||
pub fn from_legacy_name(name: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|m| m.legacy_name() == name)
|
||||
}
|
||||
pub fn ordinal(self) -> i32 {
|
||||
self as i32
|
||||
}
|
||||
pub fn from_ordinal(ordinal: i32) -> Option<Self> {
|
||||
usize::try_from(ordinal)
|
||||
.ok()
|
||||
.and_then(|i| Self::ALL.get(i).copied())
|
||||
}
|
||||
/// `StructureMode.getDisplayName`'s lang key.
|
||||
pub fn info_key(self) -> String {
|
||||
format!("structure_block.mode_info.{}", self.serialized_name())
|
||||
}
|
||||
/// The mode button's label (`structure_block.mode.<name>`).
|
||||
pub fn label_key(self) -> String {
|
||||
format!("structure_block.mode.{}", self.serialized_name())
|
||||
}
|
||||
}
|
||||
|
||||
/// `StructureBlockEntity.UpdateType`, in declaration order.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum UpdateType {
|
||||
UpdateData,
|
||||
SaveArea,
|
||||
LoadArea,
|
||||
ScanArea,
|
||||
}
|
||||
|
||||
impl UpdateType {
|
||||
pub fn ordinal(self) -> i32 {
|
||||
self as i32
|
||||
}
|
||||
pub fn from_ordinal(ordinal: i32) -> Option<Self> {
|
||||
Some(match ordinal {
|
||||
0 => Self::UpdateData,
|
||||
1 => Self::SaveArea,
|
||||
2 => Self::LoadArea,
|
||||
3 => Self::ScanArea,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rotation_from_ordinal(ordinal: i32) -> Option<Rotation> {
|
||||
usize::try_from(ordinal)
|
||||
.ok()
|
||||
.and_then(|i| Rotation::ALL.get(i).copied())
|
||||
}
|
||||
pub fn rotation_ordinal(rotation: Rotation) -> i32 {
|
||||
rotation as i32
|
||||
}
|
||||
pub fn rotation_from_legacy_name(name: &str) -> Option<Rotation> {
|
||||
Rotation::ALL.into_iter().find(|r| r.saved_name() == name)
|
||||
}
|
||||
pub fn mirror_from_ordinal(ordinal: i32) -> Option<Mirror> {
|
||||
Some(match ordinal {
|
||||
0 => Mirror::None,
|
||||
1 => Mirror::LeftRight,
|
||||
2 => Mirror::FrontBack,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub fn mirror_ordinal(mirror: Mirror) -> i32 {
|
||||
mirror as i32
|
||||
}
|
||||
pub fn mirror_from_legacy_name(name: &str) -> Option<Mirror> {
|
||||
[Mirror::None, Mirror::LeftRight, Mirror::FrontBack]
|
||||
.into_iter()
|
||||
.find(|m| m.saved_name() == name)
|
||||
}
|
||||
|
||||
/// `BoundingBoxRenderable.Mode`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RenderMode {
|
||||
None,
|
||||
Box,
|
||||
BoxAndInvisibleBlocks,
|
||||
}
|
||||
|
||||
/// `BoundingBoxRenderable.RenderableBox`: an offset from the block and a
|
||||
/// size, in blocks.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct RenderableBox {
|
||||
pub offset: [i32; 3],
|
||||
pub size: [i32; 3],
|
||||
}
|
||||
|
||||
impl RenderableBox {
|
||||
/// `RenderableBox.fromCorners`.
|
||||
pub fn from_corners(x0: i32, y0: i32, z0: i32, x1: i32, y1: i32, z1: i32) -> Self {
|
||||
let (min_x, min_y, min_z) = (x0.min(x1), y0.min(y1), z0.min(z1));
|
||||
Self {
|
||||
offset: [min_x, min_y, min_z],
|
||||
size: [x0.max(x1) - min_x, y0.max(y1) - min_y, z0.max(z1) - min_z],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StructureBlock {
|
||||
/// The structure's id; an unparsable or empty name is none.
|
||||
pub name: Option<String>,
|
||||
pub author: String,
|
||||
pub metadata: String,
|
||||
pub pos: [i32; 3],
|
||||
pub size: [i32; 3],
|
||||
pub mirror: Mirror,
|
||||
pub rotation: Rotation,
|
||||
pub mode: StructureMode,
|
||||
pub ignore_entities: bool,
|
||||
pub strict: bool,
|
||||
pub powered: bool,
|
||||
pub show_air: bool,
|
||||
pub show_bounding_box: bool,
|
||||
pub integrity: f32,
|
||||
pub seed: i64,
|
||||
}
|
||||
|
||||
impl StructureBlock {
|
||||
/// `new StructureBlockEntity(pos, state)`: the mode is the state's.
|
||||
pub fn new(mode: StructureMode) -> Self {
|
||||
Self {
|
||||
name: None,
|
||||
author: String::new(),
|
||||
metadata: String::new(),
|
||||
pos: [0, 1, 0],
|
||||
size: [0; 3],
|
||||
mirror: Mirror::None,
|
||||
rotation: Rotation::None,
|
||||
mode,
|
||||
ignore_entities: true,
|
||||
strict: false,
|
||||
powered: false,
|
||||
show_air: false,
|
||||
show_bounding_box: true,
|
||||
integrity: 1.0,
|
||||
seed: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `StructureBlockEntity.getStructureName`.
|
||||
pub fn structure_name(&self) -> &str {
|
||||
self.name.as_deref().unwrap_or("")
|
||||
}
|
||||
|
||||
/// `setStructureName(String)`: empty or unparsable is none (`Identifier.tryParse`).
|
||||
pub fn set_structure_name(&mut self, name: &str) {
|
||||
self.name = if name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
parse_identifier(name)
|
||||
};
|
||||
}
|
||||
|
||||
/// `StructureBlockEntity.saveAdditional`.
|
||||
pub fn save(&self) -> Vec<(&'static str, Tag)> {
|
||||
let byte = |b: bool| Tag::Byte(i8::from(b));
|
||||
vec![
|
||||
("name", string(self.structure_name())),
|
||||
("author", string(&self.author)),
|
||||
("metadata", string(&self.metadata)),
|
||||
("posX", Tag::Int(self.pos[0])),
|
||||
("posY", Tag::Int(self.pos[1])),
|
||||
("posZ", Tag::Int(self.pos[2])),
|
||||
("sizeX", Tag::Int(self.size[0])),
|
||||
("sizeY", Tag::Int(self.size[1])),
|
||||
("sizeZ", Tag::Int(self.size[2])),
|
||||
("rotation", string(self.rotation.saved_name())),
|
||||
("mirror", string(self.mirror.saved_name())),
|
||||
("mode", string(self.mode.legacy_name())),
|
||||
("ignoreEntities", byte(self.ignore_entities)),
|
||||
("strict", byte(self.strict)),
|
||||
("powered", byte(self.powered)),
|
||||
("showair", byte(self.show_air)),
|
||||
("showboundingbox", byte(self.show_bounding_box)),
|
||||
("integrity", Tag::Float(self.integrity.to_bits())),
|
||||
("seed", Tag::Long(self.seed)),
|
||||
]
|
||||
}
|
||||
|
||||
/// `StructureBlockEntity.loadAdditional`.
|
||||
pub fn load(tag: &Tag) -> Self {
|
||||
let int = |key: &str, default: i32, lo: i32, hi: i32| {
|
||||
(number(tag.get(key)).unwrap_or(i64::from(default)) as i32).clamp(lo, hi)
|
||||
};
|
||||
let flag = |key: &str, default: bool| number(tag.get(key)).map_or(default, |v| v != 0);
|
||||
let o = MAX_OFFSET_PER_AXIS;
|
||||
let s = MAX_SIZE_PER_AXIS;
|
||||
let mut block = Self::new(
|
||||
text(tag.get("mode"))
|
||||
.and_then(|m| StructureMode::from_legacy_name(&m))
|
||||
.unwrap_or(StructureMode::Data),
|
||||
);
|
||||
block.set_structure_name(&text(tag.get("name")).unwrap_or_default());
|
||||
block.author = text(tag.get("author")).unwrap_or_default();
|
||||
block.metadata = text(tag.get("metadata")).unwrap_or_default();
|
||||
block.pos = [
|
||||
int("posX", 0, -o, o),
|
||||
int("posY", 1, -o, o),
|
||||
int("posZ", 0, -o, o),
|
||||
];
|
||||
block.size = [
|
||||
int("sizeX", 0, 0, s),
|
||||
int("sizeY", 0, 0, s),
|
||||
int("sizeZ", 0, 0, s),
|
||||
];
|
||||
block.rotation = text(tag.get("rotation"))
|
||||
.and_then(|r| rotation_from_legacy_name(&r))
|
||||
.unwrap_or(Rotation::None);
|
||||
block.mirror = text(tag.get("mirror"))
|
||||
.and_then(|m| mirror_from_legacy_name(&m))
|
||||
.unwrap_or(Mirror::None);
|
||||
block.ignore_entities = flag("ignoreEntities", true);
|
||||
block.strict = flag("strict", false);
|
||||
block.powered = flag("powered", false);
|
||||
block.show_air = flag("showair", false);
|
||||
block.show_bounding_box = flag("showboundingbox", true);
|
||||
block.integrity = float(tag.get("integrity")).map_or(1.0, |f| f as f32);
|
||||
block.seed = number(tag.get("seed")).unwrap_or(0);
|
||||
block
|
||||
}
|
||||
|
||||
/// `StructureBlockEntity.renderMode`.
|
||||
pub fn render_mode(&self) -> RenderMode {
|
||||
if self.mode != StructureMode::Save && self.mode != StructureMode::Load {
|
||||
RenderMode::None
|
||||
} else if self.mode == StructureMode::Save && self.show_air {
|
||||
RenderMode::BoxAndInvisibleBlocks
|
||||
} else if self.mode != StructureMode::Save && !self.show_bounding_box {
|
||||
RenderMode::None
|
||||
} else {
|
||||
RenderMode::Box
|
||||
}
|
||||
}
|
||||
|
||||
/// `StructureBlockEntity.getRenderableBox`.
|
||||
pub fn renderable_box(&self) -> RenderableBox {
|
||||
let [x_origin, y0, z_origin] = self.pos;
|
||||
let size = self.size;
|
||||
let y1 = y0 + size[1];
|
||||
let (x_diff, z_diff) = match self.mirror {
|
||||
Mirror::LeftRight => (size[0], -size[2]),
|
||||
Mirror::FrontBack => (-size[0], size[2]),
|
||||
Mirror::None => (size[0], size[2]),
|
||||
};
|
||||
let (x0, z0, x1, z1);
|
||||
match self.rotation {
|
||||
Rotation::Clockwise90 => {
|
||||
x0 = if z_diff < 0 { x_origin } else { x_origin + 1 };
|
||||
z0 = if x_diff < 0 { z_origin + 1 } else { z_origin };
|
||||
x1 = x0 - z_diff;
|
||||
z1 = z0 + x_diff;
|
||||
}
|
||||
Rotation::Clockwise180 => {
|
||||
x0 = if x_diff < 0 { x_origin } else { x_origin + 1 };
|
||||
z0 = if z_diff < 0 { z_origin } else { z_origin + 1 };
|
||||
x1 = x0 - x_diff;
|
||||
z1 = z0 - z_diff;
|
||||
}
|
||||
Rotation::Counterclockwise90 => {
|
||||
x0 = if z_diff < 0 { x_origin + 1 } else { x_origin };
|
||||
z0 = if x_diff < 0 { z_origin } else { z_origin + 1 };
|
||||
x1 = x0 + z_diff;
|
||||
z1 = z0 - x_diff;
|
||||
}
|
||||
Rotation::None => {
|
||||
x0 = if x_diff < 0 { x_origin + 1 } else { x_origin };
|
||||
z0 = if z_diff < 0 { z_origin + 1 } else { z_origin };
|
||||
x1 = x0 + x_diff;
|
||||
z1 = z0 + z_diff;
|
||||
}
|
||||
}
|
||||
RenderableBox::from_corners(x0, y0, z0, x1, y1, z1)
|
||||
}
|
||||
|
||||
/// `StructureBlockEntity.getEnclosingBoundingBox` over the corner blocks
|
||||
/// of this structure's name found in range (`corners`: every position of
|
||||
/// a corner-mode structure block with the same name), with `at` the block
|
||||
/// asking. Returns the inclusive min and max, none without a corner.
|
||||
pub fn enclosing_box(corners: &[[i32; 3]], at: [i32; 3]) -> Option<([i32; 3], [i32; 3])> {
|
||||
let mut found: Option<([i32; 3], [i32; 3])> = None;
|
||||
for c in corners {
|
||||
found = Some(match found {
|
||||
None => (*c, *c),
|
||||
Some((min, max)) => (
|
||||
[min[0].min(c[0]), min[1].min(c[1]), min[2].min(c[2])],
|
||||
[max[0].max(c[0]), max[1].max(c[1]), max[2].max(c[2])],
|
||||
),
|
||||
});
|
||||
}
|
||||
let (min, max) = found?;
|
||||
if corners.len() == 1 {
|
||||
return Some((
|
||||
[min[0].min(at[0]), min[1].min(at[1]), min[2].min(at[2])],
|
||||
[max[0].max(at[0]), max[1].max(at[1]), max[2].max(at[2])],
|
||||
));
|
||||
}
|
||||
Some((min, max))
|
||||
}
|
||||
|
||||
/// `detectSize`'s result for the box: the offset and size the block takes
|
||||
/// when every side is wider than the corner blocks themselves.
|
||||
pub fn detected(min: [i32; 3], max: [i32; 3], at: [i32; 3]) -> Option<([i32; 3], [i32; 3])> {
|
||||
let delta = [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
|
||||
if delta.iter().all(|d| *d > 1) {
|
||||
Some((
|
||||
[min[0] - at[0] + 1, min[1] - at[1] + 1, min[2] - at[2] + 1],
|
||||
[delta[0] - 1, delta[1] - 1, delta[2] - 1],
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Identifier.tryParse`: `namespace:path` (the namespace defaults to
|
||||
/// `minecraft`) with only legal characters, as its string form.
|
||||
pub fn parse_identifier(text: &str) -> Option<String> {
|
||||
let (namespace, path) = text.split_once(':').unwrap_or(("minecraft", text));
|
||||
let namespace = if namespace.is_empty() {
|
||||
"minecraft"
|
||||
} else {
|
||||
namespace
|
||||
};
|
||||
let namespace_ok = namespace
|
||||
.chars()
|
||||
.all(|c| matches!(c, '_' | '-' | '.' | '0'..='9' | 'a'..='z'));
|
||||
let path_ok = !path.is_empty()
|
||||
&& path
|
||||
.chars()
|
||||
.all(|c| matches!(c, '_' | '-' | '.' | '/' | '0'..='9' | 'a'..='z'));
|
||||
(namespace_ok && path_ok).then(|| format!("{namespace}:{path}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn saves_and_loads_like_structure_block_entity() {
|
||||
let mut block = StructureBlock::new(StructureMode::Save);
|
||||
block.set_structure_name("house");
|
||||
block.author = "levzzz".into();
|
||||
block.pos = [1, -2, 3];
|
||||
block.size = [4, 5, 6];
|
||||
block.rotation = Rotation::Clockwise90;
|
||||
block.mirror = Mirror::FrontBack;
|
||||
block.ignore_entities = false;
|
||||
block.strict = true;
|
||||
block.integrity = 0.25;
|
||||
block.seed = -9;
|
||||
let tag = Tag::compound(block.save());
|
||||
assert_eq!(text(tag.get("name")).as_deref(), Some("minecraft:house"));
|
||||
assert_eq!(text(tag.get("mode")).as_deref(), Some("SAVE"));
|
||||
assert_eq!(text(tag.get("rotation")).as_deref(), Some("CLOCKWISE_90"));
|
||||
assert_eq!(StructureBlock::load(&tag), block.clone());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_clamps_and_defaults() {
|
||||
let tag = Tag::compound(vec![
|
||||
("posX", Tag::Int(99)),
|
||||
("posY", Tag::Int(-99)),
|
||||
("sizeX", Tag::Int(-4)),
|
||||
("sizeY", Tag::Int(100)),
|
||||
("name", string("Bad Name")),
|
||||
]);
|
||||
let block = StructureBlock::load(&tag);
|
||||
assert_eq!(block.pos, [48, -48, 0]);
|
||||
assert_eq!(block.size, [0, 48, 0]);
|
||||
assert_eq!(block.name, None);
|
||||
assert_eq!(block.mode, StructureMode::Data);
|
||||
assert!(block.ignore_entities && block.show_bounding_box && !block.strict);
|
||||
assert_eq!(block.integrity, 1.0);
|
||||
// Pos defaults to (0, 1, 0).
|
||||
assert_eq!(
|
||||
StructureBlock::load(&Tag::compound(Vec::<(&str, Tag)>::new())).pos,
|
||||
[0, 1, 0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_mode_and_box_follow_get_renderable_box() {
|
||||
let mut block = StructureBlock::new(StructureMode::Save);
|
||||
block.pos = [0, 1, 0];
|
||||
block.size = [3, 2, 4];
|
||||
assert_eq!(block.render_mode(), RenderMode::Box);
|
||||
assert_eq!(
|
||||
block.renderable_box(),
|
||||
RenderableBox {
|
||||
offset: [0, 1, 0],
|
||||
size: [3, 2, 4]
|
||||
}
|
||||
);
|
||||
block.rotation = Rotation::Clockwise90;
|
||||
assert_eq!(
|
||||
block.renderable_box(),
|
||||
RenderableBox {
|
||||
offset: [-3, 1, 0],
|
||||
size: [4, 2, 3]
|
||||
}
|
||||
);
|
||||
block.rotation = Rotation::None;
|
||||
block.mirror = Mirror::LeftRight;
|
||||
assert_eq!(
|
||||
block.renderable_box(),
|
||||
RenderableBox {
|
||||
offset: [0, 1, -3],
|
||||
size: [3, 2, 4]
|
||||
}
|
||||
);
|
||||
block.show_air = true;
|
||||
assert_eq!(block.render_mode(), RenderMode::BoxAndInvisibleBlocks);
|
||||
block.mode = StructureMode::Load;
|
||||
block.show_bounding_box = false;
|
||||
assert_eq!(block.render_mode(), RenderMode::None);
|
||||
block.mode = StructureMode::Corner;
|
||||
assert_eq!(block.render_mode(), RenderMode::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_scan_matches_detect_size() {
|
||||
// Two corners: their box; one corner: the box with the asking block.
|
||||
let (min, max) = StructureBlock::enclosing_box(&[[0, 0, 0], [5, 4, 6]], [2, 0, 2]).unwrap();
|
||||
assert_eq!((min, max), ([0, 0, 0], [5, 4, 6]));
|
||||
assert_eq!(
|
||||
StructureBlock::detected(min, max, [2, 0, 2]),
|
||||
Some(([-1, 1, -1], [4, 3, 5]))
|
||||
);
|
||||
let one = StructureBlock::enclosing_box(&[[0, 0, 0]], [3, 3, 3]).unwrap();
|
||||
assert_eq!(one, ([0, 0, 0], [3, 3, 3]));
|
||||
assert_eq!(StructureBlock::enclosing_box(&[], [0, 0, 0]), None);
|
||||
// A box of one block thickness is no structure.
|
||||
assert_eq!(
|
||||
StructureBlock::detected([0, 0, 0], [1, 5, 5], [0, 0, 0]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifiers_parse_like_try_parse() {
|
||||
assert_eq!(parse_identifier("a/b").as_deref(), Some("minecraft:a/b"));
|
||||
assert_eq!(parse_identifier("my:house").as_deref(), Some("my:house"));
|
||||
assert_eq!(parse_identifier("Bad"), None);
|
||||
assert_eq!(parse_identifier("ns:"), None);
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,68 @@ struct TerrainOut {
|
||||
@location(4) cylindrical: f32,
|
||||
// The light the face emits, 0 to 15 (mesher.rs's `EMISSION_BITS`).
|
||||
@location(5) @interpolate(flat) emission: u32,
|
||||
@location(6) @interpolate(flat) fade: u32,
|
||||
// A distant tile's tile-relative position, for its fade (`faded`).
|
||||
@location(7) local: vec3<f32>,
|
||||
// terrain.vsh's chunkVisibility: the section's fade-in, from 16 blocks out.
|
||||
@location(8) visibility: f32,
|
||||
}
|
||||
// Whether a fading tile or section leaves this pixel out (gfx/lod.rs's
|
||||
// `fade_code`, `fade_out_code`). It fades in, or out, chunk by chunk: each
|
||||
// column of its own grid (`local`, the tile-relative position: 16 blocks,
|
||||
// 2^level for coarse tiles) starts in turn over the first half of the fade
|
||||
// and fades over the second, as a 4x4 ordered pattern in the face's plane,
|
||||
// its unit the power of two of a block nearest 1.5 pixels, so it stays on
|
||||
// the terrain as the camera moves. `dx` and `dy`: `local`'s screen
|
||||
// derivatives.
|
||||
fn faded(code: u32, local: vec3<f32>, dx: vec3<f32>, dy: vec3<f32>) -> bool {
|
||||
if code == 0u { return false; }
|
||||
// Sixty-fourths of the fade: 1 to 63 fading in, 65 to 128 fading out.
|
||||
let step = (code - 1u) % 129u;
|
||||
let out = step >= 64u;
|
||||
let progress = f32(select(step, step - 64u, out)) / 64.0;
|
||||
let size = exp2(f32(max((code - 1u) / 129u, 4u)));
|
||||
let cell = bitcast<vec2<u32>>(vec2<i32>(floor(local.xz / size)));
|
||||
var h = cell.x * 73856093u ^ cell.y * 19349663u;
|
||||
h = (h ^ (h >> 13u)) * 0x5bd1e995u;
|
||||
let start = f32((h ^ (h >> 15u)) & 1023u) / 2048.0;
|
||||
let ramp = clamp((progress - start) * 2.0, 0.0, 1.0);
|
||||
let shown = select(ramp, 1.0 - ramp, out);
|
||||
let n = abs(cross(dx, dy));
|
||||
var plane = local.xy;
|
||||
if n.x >= n.y && n.x >= n.z {
|
||||
plane = local.yz;
|
||||
} else if n.y >= n.z {
|
||||
plane = local.xz;
|
||||
}
|
||||
let pixel = max(length(dx), length(dy));
|
||||
let unit = exp2(ceil(log2(max(pixel * 1.5, 1.0 / 16.0))));
|
||||
let p = vec2<u32>(bitcast<vec2<u32>>(vec2<i32>(floor(plane / unit)))) & vec2(3u);
|
||||
let bayer = array<u32, 16>(0u, 8u, 2u, 10u, 12u, 4u, 14u, 6u, 3u, 11u, 1u, 9u, 15u, 7u, 13u, 5u);
|
||||
return (f32(bayer[p.y * 4u + p.x]) + 0.5) / 16.0 >= shown;
|
||||
}
|
||||
// A fading tile or section is drawn a hair toward the camera (depth is
|
||||
// reversed: nearer is larger), the newer the more, so it wins where it draws
|
||||
// over the solid tiles it replaces. Distant tiles go back by 0.2% of their
|
||||
// distance, so near chunks win over tiles still under them (gfx/lod.rs's
|
||||
// `previous`, columns not complete yet): near water lies a little below a
|
||||
// tile's (vanilla's fluid corner heights), and under a smaller step the
|
||||
// tile's drew over it, two waters, opaque. A tile's UV step is 1/32, a
|
||||
// section's 1/32768 (`terrain_uv`).
|
||||
fn fade_depth(clip: vec4<f32>, w: f32) -> vec4<f32> {
|
||||
let code = fade_code(w);
|
||||
let tile = instance_step(w) > 0.001;
|
||||
var scale = select(1.0, 1.0 - 2e-3, tile);
|
||||
if code != 0u && (code - 1u) % 129u < 64u {
|
||||
scale *= 1.0 + 5e-6 * f32(64u - (code - 1u) % 129u);
|
||||
} else if code != 0u && !tile {
|
||||
// A section leaving (gfx/lod.rs's `HELD`, `fade_out_code`) goes 2% of
|
||||
// its distance toward the camera: far terrain's coarser blocks stand a
|
||||
// little out of the real ones, and the tile coming in under it hid it
|
||||
// at once, before its fade.
|
||||
scale *= 1.02;
|
||||
}
|
||||
return vec4(clip.xy, clip.z * scale, clip.w);
|
||||
}
|
||||
// Horizon Extras' dynamic lights (dynamic_light.rs): the block light, in the vertex's units
|
||||
// (16 per level), a vertex at camera-relative `pos` gets from the frame's light sources, one
|
||||
@@ -95,15 +157,36 @@ fn sample_lightmap(sky: f32, block: f32) -> vec4<f32> {
|
||||
// mesher.rs's TerrainVertex holds the UV in fixed point; the instance's
|
||||
// origin.w is the step: 1/32768 per unit for sections (a power of two on an
|
||||
// integer below 2^24, so it decodes exactly), 1/32 per block for distant
|
||||
// terrain tiles (TerrainVertex::tiled).
|
||||
fn terrain_uv(packed: vec2<u32>, step: f32) -> vec2<f32> {
|
||||
return vec2<f32>(packed) * step;
|
||||
// terrain tiles (TerrainVertex::tiled). A fading tile negates it past its
|
||||
// dither code (gfx/lod.rs's `instance`): the step is the fraction either way.
|
||||
fn terrain_uv(packed: vec2<u32>, w: f32) -> vec2<f32> {
|
||||
return vec2<f32>(packed) * instance_step(w);
|
||||
}
|
||||
// A whole section's step (a power of two) carries its fade-in in the mantissa's 23 bits, as
|
||||
// `1 - visibility` (gfx.rs's `section_instance`): masked off, the step; the rest is
|
||||
// `RenderSection.getVisibility`. A fading one (negative) is fully visible.
|
||||
fn instance_step(w: f32) -> f32 {
|
||||
if w > 0.0 {
|
||||
return bitcast<f32>(bitcast<u32>(w) & 0xff800000u);
|
||||
}
|
||||
return fract(abs(w));
|
||||
}
|
||||
fn instance_visibility(w: f32) -> f32 {
|
||||
if w > 0.0 {
|
||||
return 1.0 - f32(bitcast<u32>(w) & 0x7fffffu) / 8388608.0;
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
// A fading distant tile's dither code (gfx/lod.rs's `fade_code`), 0 when whole.
|
||||
fn fade_code(w: f32) -> u32 {
|
||||
return u32(floor(max(-w, 0.0)));
|
||||
}
|
||||
@vertex fn vs_terrain(
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
|
||||
@location(5) origin: vec4<f32>) -> TerrainOut {
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz);
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w);
|
||||
}
|
||||
// Flat section draws (gfx/flat.rs): one draw per arena page over the frame's
|
||||
// index list. A section's vertices fill whole 256-vertex blocks of its page:
|
||||
@@ -116,13 +199,15 @@ fn terrain_uv(packed: vec2<u32>, step: f32) -> vec2<f32> {
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>
|
||||
) -> TerrainOut {
|
||||
let origin = slot_origins[block_slots[vertex >> 8u]];
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz);
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w);
|
||||
}
|
||||
fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>, origin: vec3<f32>) -> TerrainOut {
|
||||
light: vec2<u32>, origin: vec3<f32>, w: f32) -> TerrainOut {
|
||||
let pos = position + origin;
|
||||
var out: TerrainOut;
|
||||
out.clip = globals.view_proj * vec4(pos, 1.0);
|
||||
out.clip = fade_depth(globals.view_proj * vec4(pos, 1.0), w);
|
||||
out.local = position;
|
||||
out.uv = uv;
|
||||
out.layer = layer;
|
||||
let block = max(f32(light.y), dynamic_block_light(pos));
|
||||
@@ -130,6 +215,10 @@ fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f3
|
||||
out.emission = 15u - (u32(round(color.a * 255.0)) & 15u);
|
||||
out.spherical = length(pos);
|
||||
out.cylindrical = max(length(pos.xz), abs(pos.y));
|
||||
out.fade = fade_code(w);
|
||||
const CHUNK_FULLY_VISIBLE_RANGE: f32 = 16.0;
|
||||
out.visibility = mix(1.0, instance_visibility(w),
|
||||
clamp((length(pos) - CHUNK_FULLY_VISIBLE_RANGE) / CHUNK_FULLY_VISIBLE_RANGE, 0.0, 1.0));
|
||||
return out;
|
||||
}
|
||||
fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
@@ -143,8 +232,13 @@ fn apply_fog(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
|
||||
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
|
||||
}
|
||||
@fragment fn fs_solid(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
let dx = dpdx(in.local);
|
||||
let dy = dpdy(in.local);
|
||||
if faded(in.fade, in.local, dx, dy) { discard; }
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
let color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
|
||||
var color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
|
||||
// terrain.fsh: the fade-in mixes toward the fog colour.
|
||||
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
|
||||
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
|
||||
// Below 0.75, alpha tells HDR output (hdr.wgsl) a light source's bright
|
||||
// pixels may shine, and at which tier (mesher::hdr_tier): 0.525 at the
|
||||
@@ -152,8 +246,12 @@ fn apply_fog(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
|
||||
let emissive = select(1.0, 0.7 * f32(4u - min(in.emission, 4u)) / 4.0, in.emission > 0u);
|
||||
return apply_fog(vec4(color.rgb, emissive), in.spherical, in.cylindrical);
|
||||
}
|
||||
// Water, glass and other see-through faces draw whole as they fade in, the
|
||||
// layer drawn first in front (gfx.rs's water tiers): dithered, the faded-out
|
||||
// pixels showed the layer behind through the one in front, twice the water.
|
||||
@fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
let color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
|
||||
var color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
|
||||
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
|
||||
if color.a < 0.01 { discard; }
|
||||
return apply_fog(color, in.spherical, in.cylindrical);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//! `TestBlockEntity`: the test block's mode, message and powered flag, with
|
||||
//! its save format (`saveAdditional` / `loadAdditional`).
|
||||
use horizon_client_core::nbt_value::{number, string, text};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `TestBlockMode`, in id order.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TestBlockMode {
|
||||
Start,
|
||||
Log,
|
||||
Fail,
|
||||
Accept,
|
||||
}
|
||||
|
||||
impl TestBlockMode {
|
||||
pub const ALL: [Self; 4] = [Self::Start, Self::Log, Self::Fail, Self::Accept];
|
||||
/// `getSerializedName`: the block state's `mode` value.
|
||||
pub fn serialized_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Start => "start",
|
||||
Self::Log => "log",
|
||||
Self::Fail => "fail",
|
||||
Self::Accept => "accept",
|
||||
}
|
||||
}
|
||||
pub fn of_serialized_name(name: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|m| m.serialized_name() == name)
|
||||
}
|
||||
pub fn id(self) -> i32 {
|
||||
self as i32
|
||||
}
|
||||
/// `ByIdMap.continuous(.., OutOfBoundsStrategy.ZERO)`.
|
||||
pub fn by_id(id: i32) -> Self {
|
||||
usize::try_from(id)
|
||||
.ok()
|
||||
.and_then(|i| Self::ALL.get(i).copied())
|
||||
.unwrap_or(Self::Start)
|
||||
}
|
||||
/// `getDisplayName`'s lang key.
|
||||
pub fn label_key(self) -> String {
|
||||
format!("test_block.mode.{}", self.serialized_name())
|
||||
}
|
||||
/// `getDetailedMessage`'s lang key.
|
||||
pub fn info_key(self) -> String {
|
||||
format!("test_block.mode_info.{}", self.serialized_name())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TestBlock {
|
||||
pub mode: TestBlockMode,
|
||||
pub message: String,
|
||||
pub powered: bool,
|
||||
/// `triggered`: not saved; the game test reads and clears it.
|
||||
pub triggered: bool,
|
||||
}
|
||||
|
||||
/// What a `TestBlockEntity` call asks of the level.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Effects {
|
||||
/// `level.updateNeighborsAt(pos, block)`.
|
||||
pub update_neighbors: bool,
|
||||
/// `log()`.
|
||||
pub log: bool,
|
||||
}
|
||||
|
||||
impl TestBlock {
|
||||
/// `new TestBlockEntity(pos, state)`: the mode is the state's.
|
||||
pub fn new(mode: TestBlockMode) -> Self {
|
||||
Self {
|
||||
mode,
|
||||
message: String::new(),
|
||||
powered: false,
|
||||
triggered: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `reset`.
|
||||
pub fn reset(&mut self) -> Effects {
|
||||
self.triggered = false;
|
||||
let start = self.mode == TestBlockMode::Start;
|
||||
if start {
|
||||
self.powered = false;
|
||||
}
|
||||
Effects {
|
||||
update_neighbors: start,
|
||||
log: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `trigger`: a start block powers up and logs; the others log (LOG
|
||||
/// only) and remember they were triggered.
|
||||
pub fn trigger(&mut self) -> Effects {
|
||||
if self.mode == TestBlockMode::Start {
|
||||
self.powered = true;
|
||||
return Effects {
|
||||
update_neighbors: true,
|
||||
log: true,
|
||||
};
|
||||
}
|
||||
self.triggered = true;
|
||||
Effects {
|
||||
update_neighbors: false,
|
||||
log: self.mode == TestBlockMode::Log,
|
||||
}
|
||||
}
|
||||
|
||||
/// `TestBlock.neighborChanged`: `signal` is `hasNeighborSignal`; a start
|
||||
/// block ignores it, the others trigger on a rising signal.
|
||||
pub fn neighbor_changed(&mut self, signal: bool) -> Option<Effects> {
|
||||
if self.mode == TestBlockMode::Start {
|
||||
return None;
|
||||
}
|
||||
if signal && !self.powered {
|
||||
self.powered = true;
|
||||
Some(self.trigger())
|
||||
} else {
|
||||
if !signal && self.powered {
|
||||
self.powered = false;
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `TestBlock.ownSignal`: a powered start block gives 15.
|
||||
pub fn own_signal(&self) -> i32 {
|
||||
if self.mode == TestBlockMode::Start && self.powered {
|
||||
15
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// `saveAdditional` (also `getUpdateTag`, which is `saveCustomOnly`).
|
||||
pub fn save(&self) -> Vec<(&'static str, Tag)> {
|
||||
vec![
|
||||
("mode", string(self.mode.serialized_name())),
|
||||
("message", string(&self.message)),
|
||||
("powered", Tag::Byte(i8::from(self.powered))),
|
||||
]
|
||||
}
|
||||
|
||||
/// `loadAdditional`: a missing or unknown mode is FAIL.
|
||||
pub fn load(tag: &Tag) -> Self {
|
||||
Self {
|
||||
mode: text(tag.get("mode"))
|
||||
.and_then(|m| TestBlockMode::of_serialized_name(&m))
|
||||
.unwrap_or(TestBlockMode::Fail),
|
||||
message: text(tag.get("message")).unwrap_or_default(),
|
||||
powered: number(tag.get("powered")).is_some_and(|v| v != 0),
|
||||
triggered: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn saves_and_loads_like_test_block_entity() {
|
||||
let block = TestBlock {
|
||||
mode: TestBlockMode::Accept,
|
||||
message: "all done".into(),
|
||||
powered: true,
|
||||
triggered: false,
|
||||
};
|
||||
let tag = Tag::compound(block.save());
|
||||
assert_eq!(text(tag.get("mode")).as_deref(), Some("accept"));
|
||||
assert_eq!(TestBlock::load(&tag), block);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_blocks_power_up_and_the_others_trigger_on_a_rising_signal() {
|
||||
let mut start = TestBlock::new(TestBlockMode::Start);
|
||||
assert_eq!(start.neighbor_changed(true), None);
|
||||
assert_eq!(start.own_signal(), 0);
|
||||
let effects = start.trigger();
|
||||
assert!(effects.update_neighbors && effects.log && start.own_signal() == 15);
|
||||
assert!(start.reset().update_neighbors && start.own_signal() == 0);
|
||||
|
||||
let mut log = TestBlock::new(TestBlockMode::Log);
|
||||
let effects = log.neighbor_changed(true).unwrap();
|
||||
assert!(effects.log && !effects.update_neighbors && log.triggered && log.powered);
|
||||
// Still powered: no second trigger; a fall clears the flag.
|
||||
assert_eq!(log.neighbor_changed(true), None);
|
||||
assert_eq!(log.neighbor_changed(false), None);
|
||||
assert!(!log.powered && log.triggered);
|
||||
assert!(!log.reset().update_neighbors && !log.triggered);
|
||||
let mut accept = TestBlock::new(TestBlockMode::Accept);
|
||||
assert!(!accept.neighbor_changed(true).unwrap().log);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_defaults_to_fail_and_ids_wrap_to_start() {
|
||||
let block = TestBlock::load(&Tag::compound(Vec::<(&str, Tag)>::new()));
|
||||
assert_eq!(block, TestBlock::new(TestBlockMode::Fail));
|
||||
assert_eq!(TestBlockMode::by_id(3), TestBlockMode::Accept);
|
||||
assert_eq!(TestBlockMode::by_id(9), TestBlockMode::Start);
|
||||
assert_eq!(TestBlockMode::Log.id(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! `TestInstanceBlockEntity`: the test instance block's test, size, rotation
|
||||
//! and status, its error markers, and the boxes derived from them.
|
||||
use horizon_client_core::nbt_value::{number, string, text};
|
||||
use horizon_world::{
|
||||
nbt::Tag,
|
||||
worldgen::structure::{BoundingBox, Rotation},
|
||||
};
|
||||
|
||||
/// `STRUCTURE_OFFSET`: where the structure starts from the block.
|
||||
pub const STRUCTURE_OFFSET: [i32; 3] = [0, 1, 1];
|
||||
|
||||
/// `TestInstanceBlockEntity.Status`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
Cleared,
|
||||
Running,
|
||||
Finished,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
pub fn serialized_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Cleared => "cleared",
|
||||
Self::Running => "running",
|
||||
Self::Finished => "finished",
|
||||
}
|
||||
}
|
||||
pub fn of_serialized_name(name: &str) -> Option<Self> {
|
||||
[Self::Cleared, Self::Running, Self::Finished]
|
||||
.into_iter()
|
||||
.find(|s| s.serialized_name() == name)
|
||||
}
|
||||
pub fn index(self) -> i32 {
|
||||
self as i32
|
||||
}
|
||||
/// `Status.byIndex`: out of range is CLEARED.
|
||||
pub fn by_index(index: i32) -> Self {
|
||||
match index {
|
||||
1 => Self::Running,
|
||||
2 => Self::Finished,
|
||||
_ => Self::Cleared,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `TestInstanceBlockEntity.Data`. Components are kept as the NBT they are
|
||||
/// sent and saved as.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Data {
|
||||
/// The test's registry id.
|
||||
pub test: Option<String>,
|
||||
pub size: [i32; 3],
|
||||
pub rotation: Rotation,
|
||||
pub ignore_entities: bool,
|
||||
pub status: Status,
|
||||
pub error_message: Option<Tag>,
|
||||
}
|
||||
|
||||
impl Default for Data {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
test: None,
|
||||
size: [0; 3],
|
||||
rotation: Rotation::None,
|
||||
ignore_entities: false,
|
||||
status: Status::Cleared,
|
||||
error_message: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Data {
|
||||
/// `Data.CODEC` encoded.
|
||||
pub fn save(&self) -> Tag {
|
||||
let mut entries = Vec::new();
|
||||
if let Some(test) = &self.test {
|
||||
entries.push(("test", string(test)));
|
||||
}
|
||||
entries.push(("size", Tag::IntArray(self.size.to_vec())));
|
||||
entries.push(("rotation", string(self.rotation.serialized_name())));
|
||||
entries.push(("ignore_entities", Tag::Byte(i8::from(self.ignore_entities))));
|
||||
entries.push(("status", string(self.status.serialized_name())));
|
||||
if let Some(error) = &self.error_message {
|
||||
entries.push(("error_message", error.clone()));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
}
|
||||
|
||||
/// `Data.CODEC` decoded: size, rotation, ignore_entities and status are
|
||||
/// required, or the whole value is dropped.
|
||||
pub fn load(tag: &Tag) -> Option<Self> {
|
||||
let size = match tag.get("size")? {
|
||||
Tag::IntArray(v) if v.len() == 3 => [v[0], v[1], v[2]],
|
||||
_ => return None,
|
||||
};
|
||||
Some(Self {
|
||||
test: text(tag.get("test")),
|
||||
size,
|
||||
rotation: Rotation::from_name(&text(tag.get("rotation"))?)?,
|
||||
ignore_entities: number(tag.get("ignore_entities")).map(|v| v != 0)?,
|
||||
status: Status::of_serialized_name(&text(tag.get("status"))?)?,
|
||||
error_message: tag.get("error_message").cloned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `TestInstanceBlockEntity.ErrorMarker`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ErrorMarker {
|
||||
pub pos: [i32; 3],
|
||||
pub text: Tag,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TestInstanceBlock {
|
||||
pub data: Data,
|
||||
pub error_markers: Vec<ErrorMarker>,
|
||||
}
|
||||
|
||||
impl TestInstanceBlock {
|
||||
/// `saveAdditional` (also `getUpdateTag`, which is `saveCustomOnly`).
|
||||
pub fn save(&self) -> Vec<(&'static str, Tag)> {
|
||||
let mut entries = vec![("data", self.data.save())];
|
||||
if !self.error_markers.is_empty() {
|
||||
let markers = self
|
||||
.error_markers
|
||||
.iter()
|
||||
.map(|m| {
|
||||
Tag::compound([
|
||||
("pos", Tag::IntArray(m.pos.to_vec())),
|
||||
("text", m.text.clone()),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
entries.push(("errors", Tag::List(markers)));
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
/// `loadAdditional`: the data is replaced only when it decodes, and the
|
||||
/// markers are always replaced.
|
||||
pub fn load(&mut self, tag: &Tag) {
|
||||
if let Some(data) = tag.get("data").and_then(Data::load) {
|
||||
self.data = data;
|
||||
}
|
||||
self.error_markers.clear();
|
||||
if let Some(Tag::List(markers)) = tag.get("errors") {
|
||||
for m in markers {
|
||||
if let (Some(Tag::IntArray(p)), Some(text)) = (m.get("pos"), m.get("text"))
|
||||
&& p.len() == 3
|
||||
{
|
||||
self.error_markers.push(ErrorMarker {
|
||||
pos: [p[0], p[1], p[2]],
|
||||
text: text.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `getStructurePos(blockPos)`.
|
||||
pub fn structure_pos_of(block: [i32; 3]) -> [i32; 3] {
|
||||
[0, 1, 2].map(|i| block[i] + STRUCTURE_OFFSET[i])
|
||||
}
|
||||
|
||||
/// `getTransformedSize`: the size with the axes swapped for a quarter turn.
|
||||
pub fn transformed_size(size: [i32; 3], rotation: Rotation) -> [i32; 3] {
|
||||
if matches!(
|
||||
rotation,
|
||||
Rotation::Clockwise90 | Rotation::Counterclockwise90
|
||||
) {
|
||||
[size[2], size[1], size[0]]
|
||||
} else {
|
||||
size
|
||||
}
|
||||
}
|
||||
|
||||
/// `getRenderableBox`: the offset from the block and the size to draw,
|
||||
/// given the test's `padding` and rotation.
|
||||
pub fn renderable_box(&self, padding: i32, rotation: Rotation) -> ([i32; 3], [i32; 3]) {
|
||||
let offset = STRUCTURE_OFFSET.map(|o| o + padding);
|
||||
(offset, Self::transformed_size(self.data.size, rotation))
|
||||
}
|
||||
|
||||
/// `getStructureBoundingBox`.
|
||||
pub fn structure_box(&self, block: [i32; 3], padding: i32, rotation: Rotation) -> BoundingBox {
|
||||
let corner = Self::structure_pos_of([0, 1, 2].map(|i| block[i] + padding));
|
||||
let size = Self::transformed_size(self.data.size, rotation);
|
||||
BoundingBox::from_corners(corner, [0, 1, 2].map(|i| corner[i] + size[i] - 1))
|
||||
}
|
||||
|
||||
/// `getStartCorner`: the corner the template is placed from.
|
||||
pub fn start_corner(&self, block: [i32; 3], padding: i32, rotation: Rotation) -> [i32; 3] {
|
||||
let [x, _, z] = self.data.size;
|
||||
let c = Self::structure_pos_of([0, 1, 2].map(|i| block[i] + padding));
|
||||
match rotation {
|
||||
Rotation::None => c,
|
||||
Rotation::Clockwise90 => [c[0] + z - 1, c[1], c[2]],
|
||||
Rotation::Clockwise180 => [c[0] + x - 1, c[1], c[2] + z - 1],
|
||||
Rotation::Counterclockwise90 => [c[0], c[1], c[2] + x - 1],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> TestInstanceBlock {
|
||||
TestInstanceBlock {
|
||||
data: Data {
|
||||
test: Some("minecraft:always_pass".into()),
|
||||
size: [3, 4, 5],
|
||||
rotation: Rotation::Clockwise180,
|
||||
ignore_entities: true,
|
||||
status: Status::Finished,
|
||||
error_message: Some(string("boom")),
|
||||
},
|
||||
error_markers: vec![ErrorMarker {
|
||||
pos: [1, 2, 3],
|
||||
text: string("here"),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saves_and_loads_like_test_instance_block_entity() {
|
||||
let block = sample();
|
||||
let tag = Tag::compound(block.save());
|
||||
assert_eq!(
|
||||
text(tag.get("data").unwrap().get("rotation")).as_deref(),
|
||||
Some("180")
|
||||
);
|
||||
let mut loaded = TestInstanceBlock::default();
|
||||
loaded.load(&tag);
|
||||
assert_eq!(loaded, block);
|
||||
// No markers, no `errors` key; a bad `data` leaves the old one.
|
||||
let mut plain = block.clone();
|
||||
plain.error_markers.clear();
|
||||
assert!(Tag::compound(plain.save()).get("errors").is_none());
|
||||
let mut kept = block;
|
||||
kept.load(&Tag::compound([(
|
||||
"data",
|
||||
Tag::compound(Vec::<(&str, Tag)>::new()),
|
||||
)]));
|
||||
assert_eq!(kept.data.test.as_deref(), Some("minecraft:always_pass"));
|
||||
assert!(kept.error_markers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxes_follow_padding_and_rotation() {
|
||||
let block = sample();
|
||||
// A quarter turn swaps the horizontal sizes.
|
||||
let (offset, size) = block.renderable_box(2, Rotation::Clockwise90);
|
||||
assert_eq!((offset, size), ([2, 3, 3], [5, 4, 3]));
|
||||
let bounds = block.structure_box([10, 64, 10], 2, Rotation::None);
|
||||
assert_eq!(bounds.min, [12, 67, 13]);
|
||||
assert_eq!(bounds.max, [14, 70, 17]);
|
||||
assert_eq!(
|
||||
block.start_corner([10, 64, 10], 2, Rotation::Counterclockwise90),
|
||||
[12, 67, 15]
|
||||
);
|
||||
assert_eq!(Status::by_index(7), Status::Cleared);
|
||||
}
|
||||
}
|
||||
@@ -928,7 +928,7 @@ pub fn multiline_word_position(value: &str, dir: isize, from: usize) -> usize {
|
||||
}
|
||||
/// `Character.isWhitespace`: Unicode spaces and line breaks but not the no-break spaces, plus
|
||||
/// the ASCII separators U+001C to U+001F.
|
||||
fn java_whitespace(c: char) -> bool {
|
||||
pub(crate) fn java_whitespace(c: char) -> bool {
|
||||
(c.is_whitespace() && !matches!(c, '\u{85}' | '\u{a0}' | '\u{2007}' | '\u{202f}'))
|
||||
|| ('\u{1c}'..='\u{1f}').contains(&c)
|
||||
}
|
||||
|
||||
@@ -93,6 +93,10 @@ pub struct World {
|
||||
/// Every block position written since the log was last drained, kept
|
||||
/// only while the world is open to the network (see `lan`).
|
||||
pub block_log: Option<Vec<(i32, i32, i32)>>,
|
||||
/// Every write since the mobs last ticked (position, old and new state),
|
||||
/// for `ServerLevel.sendBlockUpdated`'s path recomputing; kept only while
|
||||
/// the mobs of this world tick (`game/creatures.rs`).
|
||||
pub path_writes: Option<Vec<((i32, i32, i32), u32, u32)>>,
|
||||
/// 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`).
|
||||
@@ -195,6 +199,7 @@ impl World {
|
||||
day_time: 1000,
|
||||
weather: Weather::default(),
|
||||
block_log: None,
|
||||
path_writes: None,
|
||||
light_log: None,
|
||||
moving_piston_fallback: Vec::new(),
|
||||
moving_pistons: Vec::new(),
|
||||
@@ -293,6 +298,9 @@ impl World {
|
||||
if let Some(log) = &mut self.block_log {
|
||||
log.push((x, y, z));
|
||||
}
|
||||
if let Some(writes) = &mut self.path_writes {
|
||||
writes.push(((x, y, z), previous, state.0));
|
||||
}
|
||||
self.modified.insert((x >> 4, z >> 4));
|
||||
// The mesher reads a 3x3x3 neighbourhood for AO, so a write marks
|
||||
// the sections it can reach: this section, the one above/below
|
||||
|
||||
@@ -22,6 +22,8 @@ horizon-world = { path = "../horizon-world" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
# server.properties, eula.txt and the user lists, shared with horizon-server.
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
|
||||
horizon-anticheat = { path = "../horizon-anticheat" }
|
||||
# Shader packs: reading them and turning their GLSL into naga modules.
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
|
||||
@@ -65,7 +67,8 @@ unicode-bidi = "0.3.18"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
|
||||
rustix = { version = "1", default-features = false, features = ["std", "time", "process"] }
|
||||
# `LevelStorageAccess.estimateDiskSpace` (statvfs) too.
|
||||
rustix = { version = "1", default-features = false, features = ["std", "time", "process", "fs"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# Use the registered URI handler without routing links through Explorer/cmd.
|
||||
|
||||
@@ -12,6 +12,7 @@ use horizon_client::{
|
||||
assets::Assets,
|
||||
blocks::BlockFacts,
|
||||
crafting::{Recipe, Recipes},
|
||||
enchant::Enchantments,
|
||||
items::{ItemStack, Items},
|
||||
mod_content::{self, ContentMod},
|
||||
stations::StationRecipes,
|
||||
@@ -81,6 +82,7 @@ fn main() -> Result<()> {
|
||||
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 enchantments = Enchantments::load(&root);
|
||||
let collisions =
|
||||
horizon_client::collisions::load(&root, registry).map_err(step("collisions"))?;
|
||||
let loaded = began.elapsed().as_secs_f64() - built;
|
||||
@@ -198,7 +200,35 @@ fn main() -> Result<()> {
|
||||
item_rows.insert(
|
||||
name.clone(),
|
||||
match items.items.get(name.as_str()) {
|
||||
Some(i) => json!({"max_stack_size": i.max_stack, "block": i.block.map(|b| b.0)}),
|
||||
Some(i) => {
|
||||
let props = enchantments.props(name);
|
||||
let repairable: Vec<String> = props
|
||||
.into_iter()
|
||||
.flat_map(|p| &p.repairable)
|
||||
.flat_map(|r| match r.strip_prefix('#') {
|
||||
Some(tag) => items.item_tag_order.get(tag).cloned().unwrap_or_default(),
|
||||
None => vec![r.clone()],
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"max_stack_size": i.max_stack,
|
||||
"block": i.block.map(|b| b.0),
|
||||
"max_damage": i.max_damage,
|
||||
"modifiers": i.modifiers.iter().map(|(attribute, id, amount, operation, slot)| {
|
||||
json!({"type": attribute, "id": id, "amount": amount, "operation": operation, "slot": slot})
|
||||
}).collect::<Vec<_>>(),
|
||||
"tool": i.tool_damage_per_block.map(|per_block| json!({
|
||||
"rules": i.tool.iter().map(|r| json!({"blocks": r.blocks, "speed": r.speed, "correct_for_drops": r.correct})).collect::<Vec<_>>(),
|
||||
"default_mining_speed": i.tool_default_speed,
|
||||
"damage_per_block": per_block,
|
||||
})),
|
||||
"enchantable": props.map_or(0, |p| p.enchantable),
|
||||
"repairable": repairable,
|
||||
"equip_slot": i.equip_slot,
|
||||
"equip_asset": i.equip_asset,
|
||||
"food": i.food.map(|(nutrition, saturation)| json!({"nutrition": nutrition, "saturation": saturation})),
|
||||
})
|
||||
}
|
||||
None => Value::Null,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -76,6 +76,7 @@ mod recipe_book_fixture;
|
||||
mod recipe_book_render_fixture;
|
||||
mod recipe_book_view_fixture;
|
||||
mod recipe_fixture;
|
||||
mod remote_attack_probe;
|
||||
mod scene_collision_probe;
|
||||
mod scene_motion_probe;
|
||||
mod scoreboard_fixture;
|
||||
@@ -171,6 +172,7 @@ probes! {
|
||||
recipe_book_render_fixture,
|
||||
recipe_book_view_fixture,
|
||||
recipe_fixture,
|
||||
remote_attack_probe,
|
||||
scene_collision_probe,
|
||||
scene_motion_probe,
|
||||
scoreboard_fixture,
|
||||
|
||||
@@ -25,7 +25,7 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::LegacyRandom,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::{chunk::BlockState, registry::BlockRegistry, registry::BlockStateId};
|
||||
@@ -59,6 +59,8 @@ struct Scenario {
|
||||
creative: bool,
|
||||
/// The tick from which the player faces another yaw.
|
||||
turn: Option<(i32, f32)>,
|
||||
/// The player rides a boat that moves this far each tick (x, z).
|
||||
boat: Option<[f64; 2]>,
|
||||
/// Creaking hearts placed when the scenario starts.
|
||||
hearts: Vec<Vec<String>>,
|
||||
/// `ServerLevel.isSpawningMonsters`.
|
||||
@@ -132,6 +134,9 @@ fn parse(text: &str) -> Vec<Scenario> {
|
||||
s.turn = Some((tick.parse().unwrap(), yaw.parse().unwrap()));
|
||||
} else if let Some(item) = opt.strip_prefix("hand=") {
|
||||
s.hand = Some(item.to_owned());
|
||||
} else if let Some(v) = opt.strip_prefix("boat=") {
|
||||
let (x, z) = v.split_once(',').unwrap();
|
||||
s.boat = Some([x.parse().unwrap(), z.parse().unwrap()]);
|
||||
} else {
|
||||
panic!("unknown player option {opt}");
|
||||
}
|
||||
@@ -1042,6 +1047,15 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
}
|
||||
m.random = LegacyRandom::new(spec.seed);
|
||||
if matches!(
|
||||
m.species(),
|
||||
Species::Cod | Species::Salmon | Species::TropicalFish
|
||||
) {
|
||||
// The oracle draws FollowFlockLeaderGoal's first delay again once seeded.
|
||||
let delay =
|
||||
horizon_client::mobs::aquatic::FollowFlockLeader::next_start_tick(&mut m);
|
||||
m.ai.goals.set_start_delay("FollowFlockLeaderGoal", delay);
|
||||
}
|
||||
if m.species() == Species::Bee {
|
||||
// The oracle draws the constructor's cooldowns again once seeded.
|
||||
horizon_client::mobs::bee::draw_cooldowns(&mut m);
|
||||
@@ -1115,10 +1129,11 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
sleeping: false,
|
||||
sleep_timer: 0,
|
||||
passenger: false,
|
||||
vehicle: None,
|
||||
}
|
||||
});
|
||||
let mut scratch = LegacyRandom::new(0);
|
||||
// The level's random for eating sounds: only goats and sniffers draw
|
||||
// The level random for eating sounds: only goats and sniffers draw
|
||||
// from it, and the scenarios feed neither.
|
||||
let mut level_random = LegacyRandom::new(0);
|
||||
for (i, m) in mobs.iter().enumerate() {
|
||||
@@ -1144,6 +1159,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
player: player.clone(),
|
||||
nearest_player: player.as_ref().filter(|p| !p.spectator).map(|p| p.position),
|
||||
others: $snapshot,
|
||||
leash_boats: &[],
|
||||
others_grid: $grid,
|
||||
others_index: None,
|
||||
daylight: (world.day_time.rem_euclid(24000)) < 12000,
|
||||
@@ -1182,6 +1198,30 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
BlockEntity::CreakingHeart(Box::new(h.clone())),
|
||||
);
|
||||
}
|
||||
if let (Some(p), Some([vx, vz]), Some(start)) = (player.as_mut(), s.boat, s.player) {
|
||||
// The boat the player rides, moved by the script (the oracle's does not tick).
|
||||
let at = [
|
||||
start[0] + vx * f64::from(t),
|
||||
start[1],
|
||||
start[2] + vz * f64::from(t),
|
||||
];
|
||||
p.position = Vec3::new(at[0], at[1], at[2]);
|
||||
p.bounds = Aabb::new(
|
||||
Vec3::new(at[0] - 0.3, at[1], at[2] - 0.3),
|
||||
Vec3::new(at[0] + 0.3, at[1] + 1.8, at[2] + 0.3),
|
||||
);
|
||||
p.eye = [at[0], at[1] + f64::from(1.62_f32), at[2]];
|
||||
p.passenger = true;
|
||||
p.riding = true;
|
||||
p.vehicle = Some(horizon_client::mobs::Vehicle {
|
||||
nautilus: false,
|
||||
bounds: Aabb::new(
|
||||
Vec3::new(at[0] - 0.6875, at[1], at[2] - 0.6875),
|
||||
Vec3::new(at[0] + 0.6875, at[1] + 0.5625, at[2] + 0.6875),
|
||||
),
|
||||
velocity: Vec3::new(vx, 0.0, vz),
|
||||
});
|
||||
}
|
||||
if let (Some(p), Some((tick, yaw))) = (player.as_mut(), s.turn)
|
||||
&& t >= tick
|
||||
{
|
||||
@@ -1478,6 +1518,24 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
})
|
||||
.collect();
|
||||
for (at, open) in doors {
|
||||
// DoorBlock.setOpen: the sound (a wooden door's), its pitch from the level's
|
||||
// random, when the door was not already so.
|
||||
let was = registry
|
||||
.state(world.block(at[0], at[1], at[2]))
|
||||
.is_some_and(|s| s.properties.get("open").map(String::as_str) == Some("true"));
|
||||
if was != open {
|
||||
let pitch = scratch.next_float() * 0.1 + 0.9;
|
||||
events.push(MobEvent::Sound(
|
||||
if open {
|
||||
"block.wooden_door.open"
|
||||
} else {
|
||||
"block.wooden_door.close"
|
||||
},
|
||||
[0.0; 3],
|
||||
1.0,
|
||||
pitch,
|
||||
));
|
||||
}
|
||||
let Some(half) = registry
|
||||
.state(world.block(at[0], at[1], at[2]))
|
||||
.and_then(|s| s.properties.get("half").cloned())
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//! The attacking client's side of a hit, as `Game::remote_player_attack` decides it, against
|
||||
//! VanillaRemoteAttackOracle's real `Player.attack` (tools/test_remote_attack_parity.py).
|
||||
#![forbid(unsafe_code)]
|
||||
use horizon_client::{
|
||||
Result,
|
||||
attack_strength::{ATTACKER_SLOWDOWN, AttackStrength, attacker_slows_after_hit},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
fn double(v: &Value) -> f64 {
|
||||
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
|
||||
}
|
||||
|
||||
pub fn main() -> Result<()> {
|
||||
let args: Vec<_> = crate::args().skip(1).collect();
|
||||
let mut out = BufWriter::new(File::create(&args[1])?);
|
||||
for line in BufReader::new(File::open(&args[0])?).lines() {
|
||||
let c: Value = serde_json::from_str(&line?)?;
|
||||
let mut attack = AttackStrength::default();
|
||||
attack.ticker = c["ticker"].as_i64().unwrap() as i32;
|
||||
let strength = attack.scale(0.5, double(&c["speed"]));
|
||||
let damage = match c["spin"].as_f64() {
|
||||
Some(spin) => spin as f32,
|
||||
None => double(&c["damage"]) as f32,
|
||||
};
|
||||
let mut sprinting = c["sprint"].as_bool().unwrap();
|
||||
let velocity: Vec<f64> = c["vel"].as_array().unwrap().iter().map(double).collect();
|
||||
let (mut x, y, mut z) = (velocity[0], velocity[1], velocity[2]);
|
||||
// `level_players` keeps a mob out of remote_player_attack: Entity.hurtClient is false.
|
||||
if c["target"] == "player"
|
||||
&& attacker_slows_after_hit(damage, double(&c["knockback"]) as f32, strength, sprinting)
|
||||
{
|
||||
x *= ATTACKER_SLOWDOWN;
|
||||
z *= ATTACKER_SLOWDOWN;
|
||||
sprinting = false;
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
"{:08x} {:016x} {:016x} {:016x} {}",
|
||||
strength.to_bits(),
|
||||
x.to_bits(),
|
||||
y.to_bits(),
|
||||
z.to_bits(),
|
||||
sprinting
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -479,6 +479,8 @@ pub struct EntityView {
|
||||
/// mainhand, offhand, head, chest, legs, feet, body.
|
||||
pub equipment: [Option<ItemView>; 7],
|
||||
pub vehicle: Option<Box<EntityView>>,
|
||||
/// `Entity.getPassengers`, for `PassengerPredicate`.
|
||||
pub passengers: Vec<EntityView>,
|
||||
/// Player: what it looks at within 100 blocks (`PlayerPredicate.lookingAt`).
|
||||
pub looking_at: Option<Box<EntityView>>,
|
||||
pub effects: Vec<(String, i32, i32)>,
|
||||
@@ -763,6 +765,11 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
.vehicle
|
||||
.as_ref()
|
||||
.is_some_and(|veh| entity(p, veh, w, origin)),
|
||||
// PassengerPredicate.matches: any passenger matches the nested predicate.
|
||||
"minecraft:passenger" | "passenger" => e
|
||||
.passengers
|
||||
.iter()
|
||||
.any(|passenger| entity(p, passenger, w, origin)),
|
||||
"minecraft:effects" | "effects" => p.as_object().is_some_and(|m| {
|
||||
m.iter().all(|(id, want)| {
|
||||
e.effects.iter().any(|(eid, amp, dur)| {
|
||||
@@ -1401,6 +1408,24 @@ mod tests {
|
||||
&& matches!(id, "minecraft:fireball" | "minecraft:arrow"))
|
||||
}
|
||||
}
|
||||
/// `PassengerPredicate.matches`: ride_a_boat_with_a_goat's vehicle holds a goat.
|
||||
#[test]
|
||||
fn passenger_predicate_needs_a_matching_passenger() {
|
||||
let want = serde_json::json!({
|
||||
"minecraft:entity_type": "minecraft:oak_boat",
|
||||
"minecraft:passenger": {"minecraft:entity_type": "minecraft:goat"}
|
||||
});
|
||||
let entity = |kind: &str| EntityView {
|
||||
kind: kind.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut boat = entity("minecraft:oak_boat");
|
||||
assert!(!super::entity(&want, &boat, &Flat, [0.0; 3]));
|
||||
boat.passengers = vec![entity("minecraft:player")];
|
||||
assert!(!super::entity(&want, &boat, &Flat, [0.0; 3]));
|
||||
boat.passengers.push(entity("minecraft:goat"));
|
||||
assert!(super::entity(&want, &boat, &Flat, [0.0; 3]));
|
||||
}
|
||||
/// Return to Sender's `killing_blow`: only the ghast's own fireball, as a
|
||||
/// projectile, kills it for the advancement; a sword or an arrow does not.
|
||||
#[test]
|
||||
|
||||
@@ -68,6 +68,42 @@ impl AttackStrength {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Player.attack`'s extra knockback: `getKnockback` (`weapon`, halved: the
|
||||
/// ATTACK_KNOCKBACK value, plus on a server the weapon's knockback enchantment)
|
||||
/// and a sprinting full-strength hit (0.5), and whether the hit is that
|
||||
/// sprinting knockback attack.
|
||||
pub fn extra_knockback(weapon: f32, strength: f32, sprinting: bool) -> (f32, bool) {
|
||||
let knockback_attack = strength > 0.9 && sprinting;
|
||||
let bonus = if knockback_attack { 0.5 } else { 0.0 };
|
||||
(weapon / 2.0 + bonus, knockback_attack)
|
||||
}
|
||||
|
||||
/// `causeExtraKnockback`'s cut of the attacker's horizontal speed.
|
||||
pub const ATTACKER_SLOWDOWN: f64 = 0.6;
|
||||
|
||||
/// What the attacking client's own `Player.attack` does to itself on a hit
|
||||
/// at another player, whose `RemotePlayer.hurtClient` lets the hit through:
|
||||
/// whether `causeExtraKnockback` cuts its horizontal speed and stops its
|
||||
/// sprint. This runs on the client's player, not the server's: held items and
|
||||
/// effects only change attributes on a server and neither of these two is
|
||||
/// synced, so `attack_damage` and `knockback` are the client's own ATTACK_DAMAGE
|
||||
/// (or a riptide spin's damage) and ATTACK_KNOCKBACK, whatever the weapon or
|
||||
/// Weakness. Enchantments don't count either: only `ServerPlayer` overrides
|
||||
/// `getEnchantedDamage`, and `getKnockback` asks for a `ServerLevel`.
|
||||
/// `strength` is `getAttackStrengthScale(0.5)`.
|
||||
pub fn attacker_slows_after_hit(
|
||||
attack_damage: f32,
|
||||
knockback: f32,
|
||||
strength: f32,
|
||||
sprinting: bool,
|
||||
) -> bool {
|
||||
let base = attack_damage * (0.2 + strength * strength * 0.8);
|
||||
let (knockback, _) = extra_knockback(knockback, strength, sprinting);
|
||||
// Player.attack stops before the hurt and the knockback when no damage is
|
||||
// left (`magicBoost` is 0 here).
|
||||
base > 0.0 && knockback > 0.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -107,4 +143,55 @@ mod tests {
|
||||
s.reset_attack();
|
||||
assert_eq!((s.ticker, s.swap_ticker), (0, 1));
|
||||
}
|
||||
/// A client's own ATTACK_DAMAGE: Player's base value, as no item or effect
|
||||
/// changes it there.
|
||||
const CLIENT_DAMAGE: f32 = 1.0;
|
||||
#[test]
|
||||
fn a_charged_sprint_hit_slows_the_attacker() {
|
||||
assert!(attacker_slows_after_hit(CLIENT_DAMAGE, 0.0, 1.0, true));
|
||||
assert_eq!(extra_knockback(0.0, 1.0, true), (0.5, true));
|
||||
}
|
||||
#[test]
|
||||
fn an_uncharged_sprint_hit_does_not() {
|
||||
assert!(!attacker_slows_after_hit(CLIENT_DAMAGE, 0.0, 0.5, true));
|
||||
assert!(
|
||||
!attacker_slows_after_hit(CLIENT_DAMAGE, 0.0, 0.9, true),
|
||||
"the bonus needs more than 0.9"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn a_hit_without_sprinting_or_knockback_does_not() {
|
||||
assert!(!attacker_slows_after_hit(CLIENT_DAMAGE, 0.0, 1.0, false));
|
||||
}
|
||||
#[test]
|
||||
fn a_synced_knockback_attribute_slows_even_uncharged() {
|
||||
assert!(attacker_slows_after_hit(CLIENT_DAMAGE, 1.0, 0.2, false));
|
||||
}
|
||||
#[test]
|
||||
fn a_hit_that_does_no_damage_stops_first() {
|
||||
// Only a server that sends ATTACK_DAMAGE can leave a client without any.
|
||||
assert!(!attacker_slows_after_hit(0.0, 0.0, 1.0, true));
|
||||
assert!(!attacker_slows_after_hit(0.0, 1.0, 1.0, true));
|
||||
}
|
||||
#[test]
|
||||
fn a_server_attack_speed_charges_at_once() {
|
||||
// 1.8-style servers raise ATTACK_SPEED: with 1024 the delay is under
|
||||
// a tick, so getAttackStrengthScale(0.5) is full right after a hit.
|
||||
let mut s = AttackStrength::default();
|
||||
s.reset_attack();
|
||||
assert_eq!(s.scale(0.5, 1024.0), 1.0);
|
||||
assert!(attacker_slows_after_hit(
|
||||
CLIENT_DAMAGE,
|
||||
0.0,
|
||||
s.scale(0.5, 1024.0),
|
||||
true
|
||||
));
|
||||
// A sword's own 1.6 leaves it uncharged.
|
||||
assert!(!attacker_slows_after_hit(
|
||||
CLIENT_DAMAGE,
|
||||
0.0,
|
||||
s.scale(0.5, 1.6),
|
||||
true
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! `IntegratedServer.reportChunkLoadFailure` / `reportChunkSaveFailure` /
|
||||
//! `sendLowDiskSpaceWarning`: the region storage runs on the server thread and
|
||||
//! its writer thread, and the toasts are the window's. Each failure is logged,
|
||||
//! checked for low disk space (`MinecraftServer.warnOnLowDiskSpace`) and queued
|
||||
//! here; the window drains the queue every frame (`Minecraft.execute`).
|
||||
//! `SystemToast.addOrUpdate` makes a repeat reset the toast it already shows,
|
||||
//! so only the newest chunk of each kind is kept.
|
||||
use crate::{lang, save, toasts::SystemToast};
|
||||
use std::{path::Path, sync::Mutex};
|
||||
|
||||
/// What the window has still to toast.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Pending {
|
||||
/// `SystemToast.onLowDiskSpace`.
|
||||
pub low_disk_space: bool,
|
||||
/// `SystemToast.onChunkLoadFailure`: the newest chunk.
|
||||
pub load_failure: Option<(i32, i32)>,
|
||||
/// `SystemToast.onChunkSaveFailure`: the newest chunk.
|
||||
pub save_failure: Option<(i32, i32)>,
|
||||
}
|
||||
|
||||
static PENDING: Mutex<Pending> = Mutex::new(Pending {
|
||||
low_disk_space: false,
|
||||
load_failure: None,
|
||||
save_failure: None,
|
||||
});
|
||||
|
||||
fn with(update: impl FnOnce(&mut Pending)) {
|
||||
let mut pending = PENDING.lock().unwrap_or_else(|e| e.into_inner());
|
||||
update(&mut pending);
|
||||
}
|
||||
|
||||
/// `MinecraftServer.warnOnLowDiskSpace` for the world folder `dir`.
|
||||
fn warn_on_low_disk_space(dir: &Path) {
|
||||
if save::low_disk_space(dir) {
|
||||
// `MinecraftServer.sendLowDiskSpaceWarning`.
|
||||
log::warn!("Low disk space! Might not be able to save the world.");
|
||||
with(|p| p.low_disk_space = true);
|
||||
}
|
||||
}
|
||||
|
||||
/// `reportChunkLoadFailure` for the chunk at (`x`, `z`) of the dimension in `dir`.
|
||||
pub fn report_load_failure(dir: &Path, x: i32, z: i32, error: &dyn std::fmt::Display) {
|
||||
log::error!("Failed to load chunk {x},{z}: {error}");
|
||||
warn_on_low_disk_space(dir);
|
||||
with(|p| p.load_failure = Some((x, z)));
|
||||
}
|
||||
|
||||
/// `reportChunkSaveFailure`.
|
||||
pub fn report_save_failure(dir: &Path, x: i32, z: i32, error: &dyn std::fmt::Display) {
|
||||
log::error!("Failed to save chunk {x},{z}: {error}");
|
||||
warn_on_low_disk_space(dir);
|
||||
with(|p| p.save_failure = Some((x, z)));
|
||||
}
|
||||
|
||||
/// `Minecraft.sendLowDiskSpaceWarning`: a server's `ClientboundLowDiskSpaceWarningPacket`
|
||||
/// (`ClientPacketListener.handleLowDiskSpaceWarning`) toasts like the local report.
|
||||
pub fn report_low_disk_space() {
|
||||
with(|p| p.low_disk_space = true);
|
||||
}
|
||||
|
||||
/// The reports since the last call.
|
||||
pub fn take() -> Pending {
|
||||
let mut pending = PENDING.lock().unwrap_or_else(|e| e.into_inner());
|
||||
std::mem::take(&mut *pending)
|
||||
}
|
||||
|
||||
/// `ChatFormatting.RED`.
|
||||
fn red() -> u32 {
|
||||
crate::hud::rgba(255, 85, 85, 255)
|
||||
}
|
||||
|
||||
/// `SystemToast.onLowDiskSpace` (`SystemToastId.LOW_DISK_SPACE`: ten seconds).
|
||||
pub fn low_disk_space_toast() -> SystemToast {
|
||||
SystemToast::new(
|
||||
lang::tr("chunk.toast.lowDiskSpace", &[]),
|
||||
Some(lang::tr("chunk.toast.lowDiskSpace.description", &[])),
|
||||
)
|
||||
.with_display_time(SystemToast::LONG_DISPLAY_TIME)
|
||||
}
|
||||
|
||||
fn chunk_toast(key: &str, x: i32, z: i32) -> SystemToast {
|
||||
// `Component.translationArg(ChunkPos)` is `ChunkPos.toString`.
|
||||
let pos = format!("[{x}, {z}]");
|
||||
SystemToast::new(
|
||||
lang::tr(key, &[&pos]),
|
||||
Some(lang::tr("chunk.toast.checkLog", &[])),
|
||||
)
|
||||
.with_title_color(red())
|
||||
}
|
||||
|
||||
/// `SystemToast.onChunkLoadFailure`.
|
||||
pub fn load_failure_toast(x: i32, z: i32) -> SystemToast {
|
||||
chunk_toast("chunk.toast.loadFailure", x, z)
|
||||
}
|
||||
|
||||
/// `SystemToast.onChunkSaveFailure`.
|
||||
pub fn save_failure_toast(x: i32, z: i32) -> SystemToast {
|
||||
chunk_toast("chunk.toast.saveFailure", x, z)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn failures_keep_the_newest_chunk_of_each_kind() {
|
||||
let _ = take();
|
||||
let missing = Path::new("/nonexistent-horizon-world");
|
||||
report_load_failure(missing, 1, 2, &"a");
|
||||
report_load_failure(missing, 3, 4, &"b");
|
||||
report_save_failure(missing, 5, 6, &"c");
|
||||
// A folder that cannot be asked has no low-space warning.
|
||||
assert_eq!(
|
||||
take(),
|
||||
Pending {
|
||||
low_disk_space: false,
|
||||
load_failure: Some((3, 4)),
|
||||
save_failure: Some((5, 6)),
|
||||
}
|
||||
);
|
||||
assert_eq!(take(), Pending::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_servers_warning_packet_toasts_low_disk_space() {
|
||||
let _ = take();
|
||||
report_low_disk_space();
|
||||
assert!(take().low_disk_space);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_disk_space_toast_stays_ten_seconds() {
|
||||
let toast = low_disk_space_toast();
|
||||
assert_eq!(toast.display_time(), 10000.0);
|
||||
assert_eq!(load_failure_toast(0, 0).display_time(), 5000.0);
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,18 @@ fn item(
|
||||
}
|
||||
}
|
||||
|
||||
/// `TextInputHandler.addControl`: the 20 high `EditBox`, or the
|
||||
/// `MultiLineEditBox` of the options' height, by default as tall as their most
|
||||
/// lines (4 without a limit) need, at most 512.
|
||||
pub fn text_height(multiline: Option<(Option<i32>, Option<i32>)>) -> f32 {
|
||||
match multiline {
|
||||
None => 20.0,
|
||||
Some((max_lines, height)) => {
|
||||
height.unwrap_or_else(|| (9 * max_lines.unwrap_or(4) + 8).min(512)) as f32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `InputControlHandlers`: the control for input `i`.
|
||||
fn input(font: &Font, i: usize, control: &Control) -> Element {
|
||||
match control {
|
||||
@@ -165,16 +177,18 @@ fn input(font: &Font, i: usize, control: &Control) -> Element {
|
||||
width,
|
||||
label,
|
||||
label_visible,
|
||||
multiline,
|
||||
..
|
||||
} => {
|
||||
let w = *width as f32;
|
||||
let h = text_height(*multiline);
|
||||
if !label_visible {
|
||||
return Element {
|
||||
w,
|
||||
h: 20.0,
|
||||
h,
|
||||
parts: vec![Part::Field {
|
||||
input: i,
|
||||
rect: [0.0, 0.0, w, 20.0],
|
||||
rect: [0.0, 0.0, w, h],
|
||||
}],
|
||||
};
|
||||
}
|
||||
@@ -187,12 +201,12 @@ fn input(font: &Font, i: usize, control: &Control) -> Element {
|
||||
}
|
||||
Element {
|
||||
w: w.max(font.width_text(label)),
|
||||
h: 9.0 + 4.0 + 20.0,
|
||||
h: 9.0 + 4.0 + h,
|
||||
parts: vec![
|
||||
Part::Line(shown, 0.0, 0.0),
|
||||
Part::Field {
|
||||
input: i,
|
||||
rect: [0.0, 13.0, w, 20.0],
|
||||
rect: [0.0, 13.0, w, h],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -439,6 +453,17 @@ mod tests {
|
||||
assert_eq!(one.w, 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_multiline_input_is_as_tall_as_its_lines_or_its_height() {
|
||||
assert_eq!(text_height(None), 20.0);
|
||||
// No options: four lines and the padding.
|
||||
assert_eq!(text_height(Some((None, None))), 44.0);
|
||||
assert_eq!(text_height(Some((Some(10), None))), 98.0);
|
||||
// Never over 512; a height wins over the lines.
|
||||
assert_eq!(text_height(Some((Some(100), None))), 512.0);
|
||||
assert_eq!(text_height(Some((Some(2), Some(70)))), 70.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_waiting_screens_button_counts_down() {
|
||||
assert!(!waiting_button(0).1);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Non-player entities simulated on the client world. Dropped items follow
|
||||
//! `ItemEntity.tick`: gravity 0.04, 0.98 drag (block friction on the ground),
|
||||
//! a ground bounce of -0.5, water buoyancy, pickup delay, 6000-tick despawn,
|
||||
//! a ground bounce of -0.5, slime landing restitution (0.8, including a moving
|
||||
//! piston that is carrying slime), water buoyancy, pickup delay, 6000-tick despawn,
|
||||
//! and `HoneyBlock.entityInside` when falling against a honey block's side.
|
||||
use crate::{
|
||||
blocks::{BlockFacts, Fluid},
|
||||
@@ -8,9 +9,51 @@ use crate::{
|
||||
items::ItemStack,
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_core::geometry::{Aabb, Direction, Vec3};
|
||||
use horizon_world::block_collision::BlockCollisionRegistry;
|
||||
|
||||
/// Gravity applied each tick when the item is not in a fluid (`ItemEntity.tick`).
|
||||
const ITEM_GRAVITY: f64 = 0.04;
|
||||
/// Non-living slime `bounceRestitution` (`1.0 * 0.8`) from
|
||||
/// `Entity.restituteMovementAfterCollisions`.
|
||||
const ITEM_SLIME_BOUNCE: f64 = 0.8;
|
||||
|
||||
/// After a vertical collision: slime bounces a dropped item by 0.8, matching
|
||||
/// non-living `Entity.restituteMovementAfterCollisions`. Anything else zeros Y.
|
||||
fn item_vertical_collision_velocity(wanted_y: f64, slime: bool) -> f64 {
|
||||
if slime && wanted_y < 0.0 && -wanted_y > ITEM_GRAVITY {
|
||||
-wanted_y * ITEM_SLIME_BOUNCE
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
fn slime_support_name(block: &str, moving_moved: Option<&str>) -> bool {
|
||||
block == "minecraft:slime_block" || moving_moved == Some("minecraft:slime_block")
|
||||
}
|
||||
|
||||
/// The block under an item, including `Entity.getOnPos` (~1e-5) as well as the
|
||||
/// friction sample (`y - 0.999999`). A piston-moved slime is `moving_piston`
|
||||
/// with `movedState = slime`, which has the bounce, not the moving piston.
|
||||
fn item_support_is_slime(world: &World, facts: &BlockFacts, position: Vec3) -> bool {
|
||||
let x = position.x.floor() as i32;
|
||||
let z = position.z.floor() as i32;
|
||||
let friction_y = (position.y - 0.999_999).floor() as i32;
|
||||
let on_y = (position.y - f64::from(1.0e-5_f32)).floor() as i32;
|
||||
slime_at(world, facts, x, friction_y, z)
|
||||
|| (on_y != friction_y && slime_at(world, facts, x, on_y, z))
|
||||
}
|
||||
|
||||
fn slime_at(world: &World, facts: &BlockFacts, x: i32, y: i32, z: i32) -> bool {
|
||||
let block = &*facts.get(world.block(x, y, z)).name;
|
||||
let moving_moved = world
|
||||
.moving_pistons
|
||||
.iter()
|
||||
.find(|(pos, _)| *pos == [x, y, z])
|
||||
.map(|(_, piston)| &*facts.get(piston.moved_state).name);
|
||||
slime_support_name(block, moving_moved)
|
||||
}
|
||||
|
||||
mod fluids;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -236,7 +279,11 @@ impl ItemEntity {
|
||||
self.velocity.z = 0.0;
|
||||
}
|
||||
if moved.y != wanted.y {
|
||||
self.velocity.y = 0.0;
|
||||
// Entity.move restitutes instead of zeroing Y. A slime block
|
||||
// (or a moving piston carrying one) bounces a dropped item;
|
||||
// MovingPistonBlock itself has no bounceRestitution.
|
||||
let slime = item_support_is_slime(world, facts, self.position);
|
||||
self.velocity.y = item_vertical_collision_velocity(wanted.y, slime);
|
||||
}
|
||||
// Entity.move -> applyEffectsFromBlocks: HoneyBlock.entityInside.
|
||||
self.apply_honey_slide(world, facts);
|
||||
@@ -263,6 +310,14 @@ impl ItemEntity {
|
||||
self.update_fluids(world, fluids);
|
||||
self.age < 6000 && self.position.y > -128.0
|
||||
}
|
||||
/// `PistonMovingBlockEntity.causeBounce` for a dropped item. Vanilla then
|
||||
/// runs `Entity.move(MoverType.PISTON)`, which leaves the ground on an
|
||||
/// upward push; without that, `ItemEntity.tick` skips movement 3 of 4 ticks
|
||||
/// while `onGround` with no horizontal speed and gravity eats the bounce.
|
||||
pub fn piston_slime_bounce(&mut self, movement: Direction) {
|
||||
self.velocity = horizon_world::piston_push::bounce_velocity(movement, self.velocity);
|
||||
self.on_ground = false;
|
||||
}
|
||||
/// `HoneyBlock.entityInside` for the honey blocks this item's box is in
|
||||
/// (`Entity.checkInsideBlocks`, deflated by `1.0E-5`). Slide sounds and
|
||||
/// entity event 53 are skipped (`doesEntityDoHoneyBlockSlideEffects` is
|
||||
@@ -1406,3 +1461,94 @@ mod honey_slide_tests {
|
||||
assert_eq!(slow.velocity.y, -0.04);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod slime_bounce_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn landing_on_slime_bounces_a_dropped_item() {
|
||||
assert_eq!(item_vertical_collision_velocity(-1.0, true), 0.8);
|
||||
assert_eq!(item_vertical_collision_velocity(-0.5, true), 0.4);
|
||||
assert_eq!(
|
||||
item_vertical_collision_velocity(-0.04, true),
|
||||
0.0,
|
||||
"gravity alone is not a bounce"
|
||||
);
|
||||
assert_eq!(item_vertical_collision_velocity(-0.041, true), 0.041 * 0.8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn landing_on_stone_or_hitting_a_ceiling_zeros_y() {
|
||||
assert_eq!(item_vertical_collision_velocity(-1.0, false), 0.0);
|
||||
assert_eq!(item_vertical_collision_velocity(1.0, true), 0.0);
|
||||
assert_eq!(item_vertical_collision_velocity(0.96, true), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_moving_piston_carrying_slime_still_counts_as_slime() {
|
||||
assert!(slime_support_name("minecraft:slime_block", None));
|
||||
assert!(slime_support_name(
|
||||
"minecraft:moving_piston",
|
||||
Some("minecraft:slime_block")
|
||||
));
|
||||
assert!(slime_support_name(
|
||||
"minecraft:air",
|
||||
Some("minecraft:slime_block")
|
||||
));
|
||||
assert!(!slime_support_name(
|
||||
"minecraft:moving_piston",
|
||||
Some("minecraft:stone")
|
||||
));
|
||||
assert!(!slime_support_name("minecraft:stone", None));
|
||||
assert!(!slime_support_name(
|
||||
"minecraft:honey_block",
|
||||
Some("minecraft:honey_block")
|
||||
));
|
||||
}
|
||||
|
||||
fn grounded_item() -> ItemEntity {
|
||||
let at = Vec3::new(0.5, 2.0, 0.5);
|
||||
ItemEntity {
|
||||
id: 1,
|
||||
thrower: None,
|
||||
target: None,
|
||||
stack: ItemStack {
|
||||
item: std::sync::Arc::from("minecraft:stone"),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
},
|
||||
health: 5,
|
||||
fire_ticks: 0,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: true,
|
||||
age: 10,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
ticks: 10,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_piston_slime_bounce_launches_the_item_off_the_ground() {
|
||||
let mut item = grounded_item();
|
||||
item.piston_slime_bounce(Direction::Up);
|
||||
assert_eq!(item.velocity, Vec3::new(0.0, 1.0, 0.0));
|
||||
assert!(!item.on_ground);
|
||||
item.piston_slime_bounce(Direction::East);
|
||||
assert_eq!(item.velocity, Vec3::new(1.0, 1.0, 0.0));
|
||||
assert!(!item.on_ground);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slime_landing_stays_upward_after_item_friction() {
|
||||
// ItemEntity then multiplies Y by 0.98 and applies -0.5 only if still
|
||||
// negative on the ground. A slime bounce must remain positive.
|
||||
let bounced = item_vertical_collision_velocity(-1.0, true) * 0.98;
|
||||
assert!(bounced > 0.0);
|
||||
assert_eq!(bounced, 0.8 * 0.98);
|
||||
}
|
||||
}
|
||||
@@ -1118,6 +1118,8 @@ pub fn put_brain(map: &mut Compound, m: &Mob, dimension: &str) {
|
||||
for &(name, value, ttl) in &m.extra.memories {
|
||||
let value = match name {
|
||||
"hunted_recently" => Tag::Byte(value as i8),
|
||||
// `Unit` memories save an empty compound.
|
||||
"is_pregnant" => Tag::Compound(Compound::new()),
|
||||
"home" => match m.extra.home {
|
||||
Some(p) => Tag::compound([
|
||||
("dimension", string(dimension)),
|
||||
@@ -1537,6 +1539,15 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
});
|
||||
}
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Frog
|
||||
&& tag
|
||||
.get("Brain")
|
||||
.and_then(|b| b.get("memories"))
|
||||
.and_then(|b| b.get("minecraft:is_pregnant"))
|
||||
.is_some()
|
||||
{
|
||||
crate::mobs::frog::set_pregnant(&mut m, true);
|
||||
}
|
||||
if crate::mobs::camel::is_camel(m.species()) {
|
||||
// The tick is a game time: `camel::finish_load` puts it on the mob's
|
||||
// clock once the world's time is known.
|
||||
|
||||
@@ -514,6 +514,12 @@ impl HandHeights {
|
||||
}
|
||||
}
|
||||
|
||||
/// `FirstPersonHandsAndItemsRenderer.submit`: the displayed stack's model
|
||||
/// scales the interpolated equip displacement independently for each hand.
|
||||
pub fn equip_displacement(height: f32, swap_animation_scale: f32) -> f32 {
|
||||
swap_animation_scale * (1.0 - height)
|
||||
}
|
||||
|
||||
/// `LocalPlayer.xBob` and `yBob`: the hands lag behind the view when it
|
||||
/// turns. `LocalPlayer.applyInput` eases them halfway to the rotation every
|
||||
/// tick; `FirstPersonHandsAndItemsRenderer` turns the hands by a tenth of
|
||||
@@ -910,6 +916,56 @@ impl Clicks {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn equip_displacement_uses_the_visible_items_scale() {
|
||||
use horizon_assets::item_model::{ClientItem, ItemModels, StackView};
|
||||
use serde_json::json;
|
||||
|
||||
let mut models = ItemModels::default();
|
||||
for (item, scale) in [("minecraft:spear", 1.95), ("minecraft:sword", 1.0)] {
|
||||
models.definitions.insert(
|
||||
item.into(),
|
||||
ClientItem::decode(&json!({
|
||||
"model": { "type": "minecraft:empty" },
|
||||
"swap_animation_scale": scale,
|
||||
}))
|
||||
.unwrap(),
|
||||
);
|
||||
models.prototypes.insert(
|
||||
item.into(),
|
||||
[("minecraft:item_model".into(), json!(item))].into(),
|
||||
);
|
||||
}
|
||||
let spear = StackView::new("minecraft:spear", 1);
|
||||
let sword = StackView::new("minecraft:sword", 1);
|
||||
let height = 0.5;
|
||||
// The model of the visible stack determines each hand's displacement.
|
||||
assert_eq!(
|
||||
equip_displacement(height, models.swap_animation_scale(&spear)),
|
||||
0.975
|
||||
);
|
||||
assert_eq!(
|
||||
equip_displacement(height, models.swap_animation_scale(&sword)),
|
||||
0.5
|
||||
);
|
||||
assert_eq!(
|
||||
equip_displacement(1.0, models.swap_animation_scale(&spear)),
|
||||
0.0
|
||||
);
|
||||
|
||||
// A component override (such as a resource-pack model) takes precedence.
|
||||
let mut overridden = spear;
|
||||
overridden.patch.insert(
|
||||
"minecraft:item_model".into(),
|
||||
Some(json!("minecraft:sword")),
|
||||
);
|
||||
assert_eq!(
|
||||
equip_displacement(height, models.swap_animation_scale(&overridden)),
|
||||
0.5
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawing_a_bow_or_holding_a_charged_crossbow_hides_a_hand() {
|
||||
let (bow, apple) = (
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -81,14 +81,17 @@ impl Toast {
|
||||
pub(super) struct TravelMarks {
|
||||
/// `startingToFallPosition`.
|
||||
pub(super) fall: Option<[f64; 3]>,
|
||||
/// `currentExplosionImpactPos`, and whether a wind charge made the blast.
|
||||
pub(super) blast: Option<([f64; 3], bool)>,
|
||||
/// `currentExplosionImpactPos` and `currentExplosionCause` (the entity
|
||||
/// that made the blast, if any).
|
||||
pub(super) blast: Option<([f64; 3], Option<EntityView>)>,
|
||||
/// `enteredLavaOnVehiclePosition`.
|
||||
pub(super) lava_ride: Option<[f64; 3]>,
|
||||
/// `enteredNetherPosition`.
|
||||
pub(super) nether: Option<[f64; 3]>,
|
||||
/// The (effect, amplifier) pairs `EffectsChangedTrigger` last saw.
|
||||
pub(super) effects: Vec<(&'static str, i32)>,
|
||||
/// `PlayerEffects::changes` as the last tick saw it.
|
||||
pub(super) effect_changes: u32,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct AdvState {
|
||||
@@ -407,6 +410,12 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map(|m| Box::new(self.mob_entity(m)))
|
||||
.or_else(|| {
|
||||
self.boats
|
||||
.iter()
|
||||
.find(|b| b.id == id)
|
||||
.map(|b| Box::new(self.boat_entity(b)))
|
||||
})
|
||||
});
|
||||
EntityView {
|
||||
kind: "minecraft:player".into(),
|
||||
@@ -429,6 +438,43 @@ impl Game<'_> {
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
/// The player as a passenger of the vehicle `id` (`Entity.getPassengers`).
|
||||
fn rider_entity(&self, id: u32) -> Option<EntityView> {
|
||||
let p = self.player.as_ref().filter(|_| self.riding == Some(id))?;
|
||||
Some(EntityView {
|
||||
kind: "minecraft:player".into(),
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
gamemode: Some(self.mode.name().to_owned()),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
/// A boat as the predicates see it: its type, and its passengers (the
|
||||
/// mobs seated on it, then the player riding it).
|
||||
pub(super) fn boat_entity(&self, b: &super::boats::Boat) -> EntityView {
|
||||
EntityView {
|
||||
kind: b.entity_type(),
|
||||
position: [b.position.x, b.position.y, b.position.z],
|
||||
on_ground: b.on_ground,
|
||||
passengers: self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.extra.mount == Some(b.id))
|
||||
.map(|m| self.mob_entity(m))
|
||||
.chain(self.rider_entity(b.id))
|
||||
.collect(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
/// A dropped item as the predicates see it.
|
||||
pub(super) fn item_entity_view(&self, e: &horizon_client::entities::ItemEntity) -> EntityView {
|
||||
EntityView {
|
||||
kind: "minecraft:item".into(),
|
||||
position: [e.position.x, e.position.y, e.position.z],
|
||||
on_ground: e.on_ground,
|
||||
on_fire: e.fire_ticks > 0,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
pub(super) fn mob_entity(&self, m: &horizon_client::mobs::Mob) -> EntityView {
|
||||
// mainhand, offhand, head, chest, legs, feet.
|
||||
let slot = |i: &Option<std::sync::Arc<str>>| {
|
||||
@@ -462,6 +508,13 @@ impl Game<'_> {
|
||||
.map(|(id, value)| (id.to_owned(), value))
|
||||
.into_iter()
|
||||
.collect(),
|
||||
passengers: self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|r| r.extra.mount == Some(m.id))
|
||||
.map(|r| self.mob_entity(r))
|
||||
.chain(self.rider_entity(m.id))
|
||||
.collect(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -773,10 +826,21 @@ impl Game<'_> {
|
||||
/// `KilledTrigger` (`player_killed_entity`): the entity killed and the
|
||||
/// killing blow's damage source (none: any `killing_blow` fails).
|
||||
pub(super) fn trigger_killed(&mut self, target: &EntityView, blow: Option<&adv::DamageView>) {
|
||||
self.trigger_killed_as("minecraft:player_killed_entity", target, blow);
|
||||
}
|
||||
/// `KilledTrigger.trigger` for any of its three instances
|
||||
/// (`player_killed_entity`, `entity_killed_player`,
|
||||
/// `kill_mob_near_sculk_catalyst`).
|
||||
pub(super) fn trigger_killed_as(
|
||||
&mut self,
|
||||
name: &str,
|
||||
target: &EntityView,
|
||||
blow: Option<&adv::DamageView>,
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
let target = target.clone();
|
||||
let blow = blow.cloned();
|
||||
self.trigger("minecraft:player_killed_entity", move |c, w| {
|
||||
self.trigger(name, move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
@@ -843,13 +907,13 @@ impl Game<'_> {
|
||||
match (falling, self.adv.marks.fall) {
|
||||
(true, None) => {
|
||||
self.adv.marks.fall = Some(at);
|
||||
// The blast a wind charge made is spent by the fall, and counts
|
||||
// only if the player was above where it hit.
|
||||
if let Some((impact, wind_charge)) = self.adv.marks.blast.take()
|
||||
&& wind_charge
|
||||
// `trackStartFallingPosition`: the last blast is spent by the
|
||||
// fall (whatever made it), and counts only if the player was
|
||||
// above where it hit.
|
||||
if let Some((impact, cause)) = self.adv.marks.blast.take()
|
||||
&& impact[1] <= at[1]
|
||||
{
|
||||
self.trigger_fall_after_explosion(impact);
|
||||
self.trigger_fall_after_explosion(impact, cause);
|
||||
}
|
||||
}
|
||||
// `resetFallDistance`: a fall ends by landing, climbing or water.
|
||||
@@ -874,17 +938,31 @@ impl Game<'_> {
|
||||
self.adv.marks.nether = None;
|
||||
self.trigger_distance("minecraft:nether_travel", start);
|
||||
}
|
||||
let effects: Vec<(&'static str, i32)> = self
|
||||
.potions
|
||||
let effects = self.effects_seen();
|
||||
// `onEffectAdded`, `onEffectUpdated` (an `update` that changed the
|
||||
// duration, amplifier or flags too) and `onEffectsRemoved`.
|
||||
let changes = self.potions.effects.changes;
|
||||
if effects != self.adv.marks.effects || changes != self.adv.marks.effect_changes {
|
||||
self.adv.marks.effects = effects;
|
||||
self.adv.marks.effect_changes = changes;
|
||||
self.trigger_effects_changed(None);
|
||||
}
|
||||
}
|
||||
/// The (effect, amplifier) pairs `marks.effects` compares.
|
||||
fn effects_seen(&self) -> Vec<(&'static str, i32)> {
|
||||
self.potions
|
||||
.effects
|
||||
.list
|
||||
.iter()
|
||||
.map(|e| (e.id, e.amplifier))
|
||||
.collect();
|
||||
if effects != self.adv.marks.effects {
|
||||
self.adv.marks.effects = effects;
|
||||
self.trigger_effects_changed(None);
|
||||
}
|
||||
.collect()
|
||||
}
|
||||
/// `EffectsChangedTrigger` for a change the caller knows the source of;
|
||||
/// the end-of-tick check then does not fire it a second time.
|
||||
pub(super) fn trigger_effects_changed_by(&mut self, source: Option<EntityView>) {
|
||||
self.adv.marks.effects = self.effects_seen();
|
||||
self.adv.marks.effect_changes = self.potions.effects.changes;
|
||||
self.trigger_effects_changed(source);
|
||||
}
|
||||
/// `DistanceTrigger`: the player's `player` condition, `start_position`
|
||||
/// at `start`, and the `distance` from `start` to the player.
|
||||
@@ -902,15 +980,12 @@ impl Game<'_> {
|
||||
&& (c["distance"].is_null() || adv::distance(&c["distance"], start, me.position))
|
||||
});
|
||||
}
|
||||
/// `FallAfterExplosionTrigger` for a wind charge's blast that hit the
|
||||
/// player at `impact`, measured to where the player is now.
|
||||
fn trigger_fall_after_explosion(&mut self, impact: [f64; 3]) {
|
||||
/// `FallAfterExplosionTrigger.trigger`: a blast that hit the player at
|
||||
/// `impact` (made by `cause`, or by nothing), measured to where the
|
||||
/// player is now. `matches` tests `cause` only when the instance has one,
|
||||
/// and a missing cause fails it.
|
||||
fn trigger_fall_after_explosion(&mut self, impact: [f64; 3], cause: Option<EntityView>) {
|
||||
let me = self.player_entity();
|
||||
let cause = EntityView {
|
||||
kind: "minecraft:wind_charge".into(),
|
||||
position: impact,
|
||||
..EntityView::default()
|
||||
};
|
||||
self.trigger("minecraft:fall_after_explosion", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
@@ -918,21 +993,24 @@ impl Game<'_> {
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let cctx = adv::Entities {
|
||||
this: Some(&cause),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::location(&c["start_position"], impact, w)
|
||||
&& (c["distance"].is_null() || adv::distance(&c["distance"], impact, me.position))
|
||||
&& adv::conditions(&c["cause"], &cctx, w)
|
||||
&& (c["cause"].is_null()
|
||||
|| cause.as_ref().is_some_and(|cause| {
|
||||
let cctx = adv::Entities {
|
||||
this: Some(cause),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["cause"], &cctx, w)
|
||||
}))
|
||||
});
|
||||
}
|
||||
/// `EffectsChangedTrigger`: the player's `effects`, and for an effect that
|
||||
/// `source` gave, that entity (`source`).
|
||||
pub(super) fn trigger_effects_changed(&mut self, source: Option<EntityView>) {
|
||||
fn trigger_effects_changed(&mut self, source: Option<EntityView>) {
|
||||
let me = self.player_entity();
|
||||
self.trigger("minecraft:effects_changed", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
@@ -961,15 +1039,25 @@ impl Game<'_> {
|
||||
adv::conditions(&c["player"], &pctx, w) && effects && source
|
||||
});
|
||||
}
|
||||
/// `ItemDurabilityTrigger`: a player's item takes damage (`ItemStack.applyDamage`),
|
||||
/// `stack` as it is before the damage. The durability and delta ranges are not
|
||||
/// read: no advancement in the data uses them.
|
||||
pub(super) fn trigger_item_durability(&mut self, stack: &ItemStack) {
|
||||
/// `ItemDurabilityTrigger.trigger` and `TriggerInstance.matches`: a player's
|
||||
/// item takes damage (`ItemStack.applyDamage`), `stack` as it is before the
|
||||
/// damage and `new_damage` the unclamped damage value it is about to get.
|
||||
/// `durability` tests `maxDamage - newDamage`, `delta` tests
|
||||
/// `damageValue - newDamage`.
|
||||
pub(super) fn trigger_item_durability(&mut self, stack: &ItemStack, new_damage: u32) {
|
||||
if self.remote.is_some() || self.no_player_here() {
|
||||
return;
|
||||
}
|
||||
let me = self.player_entity();
|
||||
let item = item_view(stack);
|
||||
let max = self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.map_or(0, |i| i.max_damage) as i64;
|
||||
// ItemStack.getDamageValue clamps to 0..=getMaxDamage.
|
||||
let damage = i64::from(stack.damage).clamp(0, max);
|
||||
let new_damage = i64::from(new_damage);
|
||||
self.trigger("minecraft:item_durability_changed", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
@@ -979,6 +1067,8 @@ impl Game<'_> {
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& (c["item"].is_null() || adv::item(&c["item"], &item, w))
|
||||
&& adv::range_i(&c["durability"], max - new_damage)
|
||||
&& adv::range_i(&c["delta"], damage - new_damage)
|
||||
});
|
||||
}
|
||||
/// `ServerPlayer.updateUsingItem`: USING_ITEM for the item in use, with
|
||||
|
||||
@@ -422,6 +422,7 @@ impl Game<'_> {
|
||||
Screen::ConfirmLink => "confirm-link",
|
||||
Screen::DialogWarning => "dialog-warning",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::BlockEditor => "block-editor",
|
||||
Screen::Win => "win",
|
||||
}
|
||||
}
|
||||
@@ -602,6 +603,17 @@ impl Game<'_> {
|
||||
Mode::BackupBeforeUpgrade { world, .. } => {
|
||||
json!({ "mode": "backup-before-upgrade", "folder": world.folder, "name": world.name })
|
||||
}
|
||||
Mode::OptimizeConfirm {
|
||||
world, erase_cache, ..
|
||||
} => {
|
||||
json!({ "mode": "optimize-world-confirm", "folder": world.folder, "name": world.name, "erase_cache": erase_cache })
|
||||
}
|
||||
Mode::Optimize { world } => {
|
||||
json!({ "mode": "optimize-world", "folder": world.folder, "name": world.name })
|
||||
}
|
||||
Mode::LowDiskSpace { world } => {
|
||||
json!({ "mode": "low-disk-space", "folder": world.folder, "name": world.name })
|
||||
}
|
||||
Mode::Recover { world, issues } => {
|
||||
let issue = |i: &Option<horizon_client::save::DatIssue>| match i {
|
||||
None => json!(null),
|
||||
@@ -625,6 +637,7 @@ impl Game<'_> {
|
||||
Mode::MultiplayerWarning { check } => {
|
||||
json!({ "mode": "multiplayer-warning", "check": check })
|
||||
}
|
||||
Mode::Onboarding(_) => json!({ "mode": "accessibility-onboarding" }),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,14 @@ impl ArmorStandAssets {
|
||||
}
|
||||
let mut equipment = HashMap::new();
|
||||
let mut trim_overrides = HashMap::new();
|
||||
if let Ok(entries) = std::fs::read_dir(root.join("assets/minecraft/equipment")) {
|
||||
// EquipmentAssetManager reads every namespace: vanilla's and the content mods'.
|
||||
let namespaces = std::iter::once("minecraft".to_owned())
|
||||
.chain(horizon_client::mod_content::namespaces(root));
|
||||
for namespace in namespaces {
|
||||
let Ok(entries) = std::fs::read_dir(root.join(format!("assets/{namespace}/equipment")))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
let Some(name) = path.file_stem().and_then(|s| s.to_str()) else {
|
||||
@@ -87,6 +94,7 @@ impl ArmorStandAssets {
|
||||
continue;
|
||||
};
|
||||
let Ok(v) = v else { continue };
|
||||
let id = format!("{namespace}:{name}");
|
||||
let mut kinds = HashMap::new();
|
||||
for kind in ["humanoid", "humanoid_leggings", "wings"] {
|
||||
// Every layer of the kind: leather's overlay is layer 1.
|
||||
@@ -95,22 +103,25 @@ impl ArmorStandAssets {
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|layer| {
|
||||
let tex = layer["texture"]
|
||||
.as_str()
|
||||
.unwrap_or(name)
|
||||
.trim_start_matches("minecraft:");
|
||||
let tex = layer["texture"].as_str().unwrap_or(&id);
|
||||
let (tex_namespace, tex) =
|
||||
tex.split_once(':').unwrap_or(("minecraft", tex));
|
||||
let sprite = match tex_namespace {
|
||||
"minecraft" => format!("entity/equipment/{kind}/{tex}"),
|
||||
other => format!("{other}:entity/equipment/{kind}/{tex}"),
|
||||
};
|
||||
let undyed = layer["dyeable"]["color_when_undyed"]
|
||||
.as_i64()
|
||||
.map(|c| c as u32 & 0xFF_FFFF);
|
||||
(format!("entity/equipment/{kind}/{tex}"), undyed)
|
||||
(sprite, undyed)
|
||||
})
|
||||
.collect();
|
||||
if !layers.is_empty() {
|
||||
kinds.insert(kind.to_owned(), layers);
|
||||
}
|
||||
}
|
||||
trim_overrides.insert(format!("minecraft:{name}"), TrimOverride::list(&v));
|
||||
equipment.insert(format!("minecraft:{name}"), kinds);
|
||||
trim_overrides.insert(id.clone(), TrimOverride::list(&v));
|
||||
equipment.insert(id, kinds);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
|
||||
@@ -14,6 +14,7 @@ use horizon_client::{
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::render::{DYE, DYE_NAMES},
|
||||
test_instance_block::Status,
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
|
||||
@@ -84,6 +85,30 @@ fn average(a: u32, b: u32) -> u32 {
|
||||
.fold(0, |acc, &s| acc | ((ch(a, s) + ch(b, s)) / 2) << s)
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `TestInstanceBlockEntity.getBeamSections`: grey while running, green
|
||||
/// for a pass, red for a failed required test and orange for an optional
|
||||
/// one; each section is one block tall and the last runs to the sky.
|
||||
fn test_instance_beam(&self, e: &BlockEntity) -> Option<Vec<(u32, i32)>> {
|
||||
let BlockEntity::TestInstanceBlock(b) = e else {
|
||||
return None;
|
||||
};
|
||||
let color = match b.data.status {
|
||||
Status::Cleared => return None,
|
||||
Status::Running => 0x80_8080,
|
||||
Status::Finished if b.data.error_message.is_none() => 0x00_FF00,
|
||||
Status::Finished => {
|
||||
let required = b
|
||||
.data
|
||||
.test
|
||||
.as_deref()
|
||||
.and_then(|key| self.test_def(key))
|
||||
.is_none_or(|t| t.required);
|
||||
if required { 0xFF_0000 } else { 0xFF_8000 }
|
||||
}
|
||||
};
|
||||
Some(vec![(color, 1)])
|
||||
}
|
||||
|
||||
/// The beam color of a `BeaconBeamBlock` (stained glass and panes, the beacon).
|
||||
fn beam_color(&self, name: &str) -> Option<u32> {
|
||||
if name == "minecraft:beacon" {
|
||||
@@ -598,16 +623,27 @@ impl Game<'_> {
|
||||
self.far_beacons.retain(|&pos, _| {
|
||||
let (x, _, z) = pos;
|
||||
!world.chunks.contains_key(&(x >> 4, z >> 4))
|
||||
|| world.block_entities.get(&pos).and_then(beam).is_some()
|
||||
|| world
|
||||
.block_entities
|
||||
.get(&pos)
|
||||
.is_some_and(|e| beam(e).is_some() || is_test_instance_beam(e))
|
||||
});
|
||||
} else {
|
||||
self.far_beacons.clear();
|
||||
}
|
||||
let test_beams: Vec<_> = world
|
||||
.block_entities
|
||||
.iter()
|
||||
.filter_map(|(k, e)| Some((*k, self.test_instance_beam(e)?)))
|
||||
.collect();
|
||||
for (k, e) in &world.block_entities {
|
||||
if let Some(sections) = beam(e) {
|
||||
self.far_beacons.insert(*k, sections.to_vec());
|
||||
}
|
||||
}
|
||||
for (k, sections) in test_beams {
|
||||
self.far_beacons.insert(k, sections);
|
||||
}
|
||||
for (k, sections) in &self.far_beacons {
|
||||
let base = glam::DVec3::new(f64::from(k.0) + 0.5, f64::from(k.1), f64::from(k.2) + 0.5);
|
||||
let offset = base - camera;
|
||||
@@ -628,6 +664,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether a test instance block shows a beam (`getBeamSections` not empty).
|
||||
fn is_test_instance_beam(e: &BlockEntity) -> bool {
|
||||
matches!(e, BlockEntity::TestInstanceBlock(b) if b.data.status != Status::Cleared)
|
||||
}
|
||||
/// The beam sections of an active beacon (`BeaconRenderer` draws no beam for
|
||||
/// a beacon without a pyramid or with all sections cleared).
|
||||
fn beam(e: &BlockEntity) -> Option<&[(u32, i32)]> {
|
||||
|
||||
@@ -0,0 +1,645 @@
|
||||
//! The edit screens of the game master blocks (`StructureBlockEditScreen`,
|
||||
//! `JigsawBlockEditScreen`, `TestBlockEditScreen`,
|
||||
//! `TestInstanceBlockEditScreen`): one screen that runs an [`Editor`], which
|
||||
//! lays out its widgets, takes their presses and builds the packet for the
|
||||
//! server. The screens change the client's copy of the block entity as they
|
||||
//! are used, as vanilla's do, so the box preview follows them.
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
hud::{HudBatch, rgba},
|
||||
text::Text,
|
||||
ui::{self, EditBox},
|
||||
};
|
||||
use horizon_protocol::packets::play::Serverbound as S;
|
||||
use winit::{
|
||||
event::{ElementState, KeyEvent},
|
||||
keyboard::{KeyCode, PhysicalKey},
|
||||
};
|
||||
|
||||
pub(super) type Pos = (i32, i32, i32);
|
||||
|
||||
/// `Screen.extractRenderState`'s grey for the labels: `-6250336`.
|
||||
pub(super) const LABEL: u32 = 0xFFA0_A0A0;
|
||||
|
||||
/// A widget of an edit screen at GUI pixels.
|
||||
pub(super) enum Item {
|
||||
Label {
|
||||
text: Text,
|
||||
x: f32,
|
||||
y: f32,
|
||||
/// Right-aligned at `x` (the labels beside the right-hand buttons).
|
||||
right: bool,
|
||||
},
|
||||
Title {
|
||||
text: Text,
|
||||
y: f32,
|
||||
},
|
||||
Edit {
|
||||
index: usize,
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
},
|
||||
Button {
|
||||
id: u8,
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
label: Text,
|
||||
active: bool,
|
||||
},
|
||||
/// A multi-line message (`FittingMultiLineTextWidget`), wrapped to `w`
|
||||
/// and cut to what fits `h`.
|
||||
Info {
|
||||
text: Text,
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
h: f32,
|
||||
},
|
||||
/// An `AbstractSliderButton`: `value` is 0..1.
|
||||
Slider {
|
||||
id: u8,
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
label: Text,
|
||||
value: f32,
|
||||
},
|
||||
}
|
||||
|
||||
/// What a press on a button does to the screen.
|
||||
pub(super) enum Outcome {
|
||||
Stay,
|
||||
/// Send the packet and close.
|
||||
Send(S),
|
||||
/// Send the packets in order and close.
|
||||
SendAll(Vec<S>),
|
||||
/// Close, after the editor reverted what it changed.
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// One edit screen's widgets and rules.
|
||||
pub(super) trait Editor {
|
||||
/// The text boxes, in tab order.
|
||||
fn edits(&mut self) -> &mut [EditBox];
|
||||
/// The widgets for the block entity as it is now, in a `w`×`h` screen.
|
||||
fn items(&self, entity: &BlockEntity, w: f32, h: f32) -> Vec<Item>;
|
||||
/// A button pressed (`shift` held), possibly changing the entity.
|
||||
fn press(
|
||||
&mut self,
|
||||
entity: &mut BlockEntity,
|
||||
pos: Pos,
|
||||
id: u8,
|
||||
shift: bool,
|
||||
alt: bool,
|
||||
) -> Outcome;
|
||||
/// The screen opened: packets it sends first (`init`).
|
||||
fn opened(&mut self, _entity: &BlockEntity, _pos: Pos) -> Vec<S> {
|
||||
Vec::new()
|
||||
}
|
||||
/// The text of box `edit` changed (`EditBox.setResponder`): packets to send.
|
||||
fn edited(&mut self, _edit: usize, _pos: Pos) -> Vec<S> {
|
||||
Vec::new()
|
||||
}
|
||||
/// A status from the server (`ClientboundTestInstanceBlockStatus`).
|
||||
fn status(&mut self, _entity: &BlockEntity, _status: Text, _size: Option<[i32; 3]>) {}
|
||||
/// A slider moved to `value` (0..1).
|
||||
fn slide(&mut self, _id: u8, _value: f32) {}
|
||||
/// Done: the packet that carries the screen's values.
|
||||
fn done(&mut self, entity: &mut BlockEntity, pos: Pos) -> Outcome;
|
||||
/// Cancel: put the block entity back.
|
||||
fn cancel(&mut self, entity: &mut BlockEntity);
|
||||
/// Whether the box takes the typed character (`EditBox.charTyped`).
|
||||
fn accepts(&self, _edit: usize, _current: &str, _cursor: usize, _c: char) -> bool {
|
||||
true
|
||||
}
|
||||
/// Whether Enter is Done (a screen with its own `keyPressed`).
|
||||
fn enter_confirms(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// The box that has the focus when the screen opens.
|
||||
fn initial_focus(&self) -> usize {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// The open edit screen.
|
||||
pub(super) struct BlockEditScreen {
|
||||
pub(super) pos: Pos,
|
||||
pub(super) editor: Box<dyn Editor>,
|
||||
/// The text box with the focus, and which of them show.
|
||||
focus: Option<usize>,
|
||||
/// The text of each box when the screen last looked, to see what changed.
|
||||
values: Vec<String>,
|
||||
/// The slider being dragged: its id and the x and width it is drawn at.
|
||||
dragging: Option<(u8, f32, f32)>,
|
||||
}
|
||||
|
||||
impl BlockEditScreen {
|
||||
pub(super) fn new(pos: Pos, editor: Box<dyn Editor>) -> Self {
|
||||
let focus = Some(editor.initial_focus());
|
||||
Self {
|
||||
pos,
|
||||
editor,
|
||||
focus,
|
||||
values: Vec::new(),
|
||||
dragging: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `LocalPlayer.open*Block`: the edit screen for the block entity at
|
||||
/// `pos`.
|
||||
pub(super) fn open_block_editor(&mut self, pos: Pos, editor: Box<dyn Editor>) {
|
||||
let mut screen = BlockEditScreen::new(pos, editor);
|
||||
let opening = self
|
||||
.world
|
||||
.block_entities
|
||||
.get(&pos)
|
||||
.map(|entity| screen.editor.opened(entity, pos))
|
||||
.unwrap_or_default();
|
||||
screen.values = screen
|
||||
.editor
|
||||
.edits()
|
||||
.iter()
|
||||
.map(|e| e.value.clone())
|
||||
.collect();
|
||||
self.block_editor = Some(screen);
|
||||
self.screen = Screen::BlockEditor;
|
||||
self.capture(false);
|
||||
for packet in opening {
|
||||
self.send(packet);
|
||||
}
|
||||
}
|
||||
|
||||
/// A use of a game master block on a server's client, which opens the
|
||||
/// screen itself (`BlockEntity.usedBy`) while the server only answers the
|
||||
/// click. The block at `pos`, when the player may use it.
|
||||
pub(super) fn open_block_editor_if_editable(&mut self, pos: Pos) {
|
||||
if !self.can_use_game_master_blocks() {
|
||||
return;
|
||||
}
|
||||
match self.world.block_entities.get(&pos) {
|
||||
Some(BlockEntity::StructureBlock(_)) => self.open_structure_block(pos),
|
||||
Some(BlockEntity::JigsawBlock(_)) => self.open_jigsaw_block(pos),
|
||||
Some(BlockEntity::TestBlock(_)) => self.open_test_block(pos),
|
||||
Some(BlockEntity::TestInstanceBlock(_)) => self.open_test_instance_block(pos),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn close_block_editor(&mut self) {
|
||||
self.block_editor = None;
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(true);
|
||||
}
|
||||
|
||||
/// Run `f` on the open screen with its block entity; the screen closes
|
||||
/// when the block is gone (`BlockEntity` removed).
|
||||
fn with_block_editor<R>(
|
||||
&mut self,
|
||||
f: impl FnOnce(&mut BlockEditScreen, &mut BlockEntity) -> R,
|
||||
) -> Option<R> {
|
||||
let mut screen = self.block_editor.take()?;
|
||||
let result = self
|
||||
.world
|
||||
.block_entities
|
||||
.get_mut(&screen.pos)
|
||||
.map(|entity| f(&mut screen, entity));
|
||||
if result.is_some() {
|
||||
self.block_editor = Some(screen);
|
||||
} else {
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(true);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn block_editor_outcome(&mut self, outcome: Outcome) {
|
||||
match outcome {
|
||||
Outcome::Stay => {}
|
||||
Outcome::Send(packet) => {
|
||||
self.send(packet);
|
||||
self.close_block_editor();
|
||||
}
|
||||
Outcome::SendAll(packets) => {
|
||||
for packet in packets {
|
||||
self.send(packet);
|
||||
}
|
||||
self.close_block_editor();
|
||||
}
|
||||
Outcome::Cancel => self.close_block_editor(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn draw_block_editor(&mut self, batch: &mut HudBatch) {
|
||||
let cursor = self.cursor;
|
||||
let Some(items) = self.with_block_editor(|s, e| {
|
||||
let items = s.editor.items(e, batch.width, batch.height);
|
||||
for (i, edit) in s.editor.edits().iter_mut().enumerate() {
|
||||
edit.focused = s.focus == Some(i);
|
||||
}
|
||||
items
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
batch.fill_gradient(
|
||||
atlas,
|
||||
(0.0, 0.0, w, h),
|
||||
rgba(0x10, 0x10, 0x10, 0xC0),
|
||||
rgba(0x10, 0x10, 0x10, 0xD0),
|
||||
);
|
||||
let Some(mut screen) = self.block_editor.take() else {
|
||||
return;
|
||||
};
|
||||
// Buttons first, then the labels and boxes, as `Screen` renders its
|
||||
// widgets before the screen draws its own.
|
||||
for item in &items {
|
||||
if let Item::Button {
|
||||
x,
|
||||
y,
|
||||
w: bw,
|
||||
label,
|
||||
active,
|
||||
..
|
||||
} = item
|
||||
{
|
||||
let hover = ui::hovered(cursor, *x, *y, *bw, 20.0);
|
||||
ui::button(batch, atlas, *x, *y, *bw, 20.0, label, *active, hover);
|
||||
}
|
||||
}
|
||||
for item in &items {
|
||||
match item {
|
||||
Item::Title { text, y } => {
|
||||
let tw = atlas.font.width_text(text);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
text,
|
||||
(w / 2.0 - tw / 2.0).floor(),
|
||||
*y,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
}
|
||||
Item::Label { text, x, y, right } => {
|
||||
let x = if *right {
|
||||
x - atlas.font.width_text(text)
|
||||
} else {
|
||||
*x
|
||||
};
|
||||
batch.text_styled(atlas, text, x, *y, LABEL, true);
|
||||
}
|
||||
Item::Edit { index, x, y, w: ew } => {
|
||||
if let Some(edit) = screen.editor.edits().get_mut(*index) {
|
||||
(edit.x, edit.y, edit.width, edit.height) = (*x, *y, *ew, 20.0);
|
||||
edit.draw(batch, atlas, None);
|
||||
}
|
||||
}
|
||||
Item::Button { .. } => {}
|
||||
Item::Info {
|
||||
text,
|
||||
x,
|
||||
y,
|
||||
w: iw,
|
||||
h: ih,
|
||||
} => {
|
||||
let lines = atlas.font.wrap_components(text, *iw as i32);
|
||||
for (i, line) in lines.iter().take((*ih / 9.0) as usize).enumerate() {
|
||||
batch.text_styled(atlas, line, *x, *y + i as f32 * 9.0, u32::MAX, true);
|
||||
}
|
||||
}
|
||||
Item::Slider {
|
||||
id,
|
||||
x,
|
||||
y,
|
||||
w: sw,
|
||||
label,
|
||||
value,
|
||||
} => {
|
||||
let hover = ui::hovered(cursor, *x, *y, *sw, 20.0);
|
||||
let dragging = screen.dragging.is_some_and(|(d, _, _)| d == *id);
|
||||
ui::slider(
|
||||
batch, atlas, *x, *y, *sw, label, *value, hover, true, dragging,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.block_editor = Some(screen);
|
||||
}
|
||||
|
||||
pub(super) fn block_editor_click(&mut self) {
|
||||
let size = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map_or([1, 1], |r| [r.config.width, r.config.height]);
|
||||
let b = HudBatch::new(size);
|
||||
let cursor = self.cursor;
|
||||
let shift = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let alt = self.keys.contains(&KeyCode::AltLeft);
|
||||
let atlas_click = |game: &Game<'_>, edit: &mut EditBox| {
|
||||
edit.click(&game.gui.atlas, cursor.0, false, false);
|
||||
};
|
||||
let Some(items) = self.with_block_editor(|s, e| s.editor.items(e, b.width, b.height))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// The widgets in the order they were added: the last on top.
|
||||
for item in items.iter().rev() {
|
||||
match item {
|
||||
Item::Button {
|
||||
id,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
active,
|
||||
..
|
||||
} if *active && ui::hovered(cursor, *x, *y, *w, 20.0) => {
|
||||
self.audio.play_button_click();
|
||||
let id = *id;
|
||||
if let Some(outcome) = self.with_block_editor(|s, e| {
|
||||
let pos = s.pos;
|
||||
s.editor.press(e, pos, id, shift, alt)
|
||||
}) {
|
||||
self.block_editor_outcome(outcome);
|
||||
}
|
||||
return;
|
||||
}
|
||||
Item::Slider { id, x, y, w, .. } if ui::hovered(cursor, *x, *y, *w, 20.0) => {
|
||||
let (id, x, w) = (*id, *x, *w);
|
||||
let value = ui::slider_value(x, w, cursor.0);
|
||||
if let Some(mut screen) = self.block_editor.take() {
|
||||
screen.dragging = Some((id, x, w));
|
||||
screen.editor.slide(id, value);
|
||||
self.block_editor = Some(screen);
|
||||
}
|
||||
return;
|
||||
}
|
||||
Item::Edit { index, x, y, w } if ui::hovered(cursor, *x, *y, *w, 20.0) => {
|
||||
let index = *index;
|
||||
if let Some(mut screen) = self.block_editor.take() {
|
||||
screen.focus = Some(index);
|
||||
if let Some(edit) = screen.editor.edits().get_mut(index) {
|
||||
(edit.x, edit.width) = (*x, *w);
|
||||
atlas_click(self, edit);
|
||||
}
|
||||
self.block_editor = Some(screen);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The cursor moved with a slider held (`AbstractSliderButton.onDrag`).
|
||||
pub(super) fn block_editor_drag(&mut self) {
|
||||
let x = self.cursor.0;
|
||||
if let Some(screen) = self.block_editor.as_mut()
|
||||
&& let Some((id, sx, sw)) = screen.dragging
|
||||
{
|
||||
screen.editor.slide(id, ui::slider_value(sx, sw, x));
|
||||
}
|
||||
}
|
||||
|
||||
/// The left button released: a held slider lets go.
|
||||
pub(super) fn block_editor_release(&mut self) {
|
||||
if let Some(screen) = self.block_editor.as_mut() {
|
||||
screen.dragging = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn block_editor_key(&mut self, event: &KeyEvent) {
|
||||
if event.state != ElementState::Pressed {
|
||||
return;
|
||||
}
|
||||
let PhysicalKey::Code(key) = event.physical_key else {
|
||||
return;
|
||||
};
|
||||
let shortcut = self.edit_shortcut(event);
|
||||
self.block_editor_key_code(key, shortcut, event.text.as_deref());
|
||||
}
|
||||
|
||||
/// A key pressed on an edit screen: Enter is Done, Escape cancels, Tab
|
||||
/// moves between the boxes, the rest edits the focused one.
|
||||
pub(super) fn block_editor_key_code(
|
||||
&mut self,
|
||||
key: KeyCode,
|
||||
shortcut: Option<horizon_client::clipboard::EditShortcut>,
|
||||
text: Option<&str>,
|
||||
) {
|
||||
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
|
||||
let shift = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let types_text = self.types_text();
|
||||
let size = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map_or([1, 1], |r| [r.config.width, r.config.height]);
|
||||
let b = HudBatch::new(size);
|
||||
match key {
|
||||
KeyCode::Escape => {
|
||||
let outcome = self
|
||||
.with_block_editor(|s, e| {
|
||||
s.editor.cancel(e);
|
||||
Outcome::Cancel
|
||||
})
|
||||
.unwrap_or(Outcome::Cancel);
|
||||
self.block_editor_outcome(outcome);
|
||||
}
|
||||
KeyCode::Enter | KeyCode::NumpadEnter => {
|
||||
let confirms = self
|
||||
.block_editor
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.editor.enter_confirms());
|
||||
if !confirms {
|
||||
return;
|
||||
}
|
||||
if let Some(outcome) = self.with_block_editor(|s, e| {
|
||||
let pos = s.pos;
|
||||
s.editor.done(e, pos)
|
||||
}) {
|
||||
self.block_editor_outcome(outcome);
|
||||
}
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
self.with_block_editor(|s, e| {
|
||||
// The boxes on screen, in the order they were added.
|
||||
let shown: Vec<usize> = s
|
||||
.editor
|
||||
.items(e, b.width, b.height)
|
||||
.iter()
|
||||
.filter_map(|i| match i {
|
||||
Item::Edit { index, .. } => Some(*index),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if shown.is_empty() {
|
||||
return;
|
||||
}
|
||||
let at = s.focus.and_then(|f| shown.iter().position(|&i| i == f));
|
||||
let next = match at {
|
||||
None => 0,
|
||||
Some(p) if shift => (p + shown.len() - 1) % shown.len(),
|
||||
Some(p) => (p + 1) % shown.len(),
|
||||
};
|
||||
s.focus = Some(shown[next]);
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
let Some(mut screen) = self.block_editor.take() else {
|
||||
return;
|
||||
};
|
||||
if let Some(focus) = screen.focus
|
||||
&& let Some(edit) = screen.editor.edits().get_mut(focus)
|
||||
{
|
||||
if let Some(shortcut) = shortcut {
|
||||
edit.shortcut(shortcut, &mut self.clipboard);
|
||||
} else if !edit.key(key, ctrl, shift) && types_text {
|
||||
for c in text.unwrap_or("").chars() {
|
||||
self.block_editor_type_into(&mut screen, focus, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.block_editor = Some(screen);
|
||||
self.block_editor_after_input();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What typing changed: the boxes whose text differs from before tell
|
||||
/// the editor, which may have packets to send.
|
||||
fn block_editor_after_input(&mut self) {
|
||||
let Some(mut screen) = self.block_editor.take() else {
|
||||
return;
|
||||
};
|
||||
let pos = screen.pos;
|
||||
let now: Vec<String> = screen
|
||||
.editor
|
||||
.edits()
|
||||
.iter()
|
||||
.map(|e| e.value.clone())
|
||||
.collect();
|
||||
let mut packets = Vec::new();
|
||||
for (i, value) in now.iter().enumerate() {
|
||||
if screen.values.get(i) != Some(value) {
|
||||
packets.extend(screen.editor.edited(i, pos));
|
||||
}
|
||||
}
|
||||
screen.values = now;
|
||||
self.block_editor = Some(screen);
|
||||
for packet in packets {
|
||||
self.send(packet);
|
||||
}
|
||||
}
|
||||
|
||||
/// A status for the open screen.
|
||||
pub(super) fn block_editor_status(&mut self, status: Text, size: Option<[i32; 3]>) {
|
||||
let Some(mut screen) = self.block_editor.take() else {
|
||||
return;
|
||||
};
|
||||
if let Some(entity) = self.world.block_entities.get(&screen.pos) {
|
||||
screen.editor.status(entity, status, size);
|
||||
}
|
||||
screen.values = screen
|
||||
.editor
|
||||
.edits()
|
||||
.iter()
|
||||
.map(|e| e.value.clone())
|
||||
.collect();
|
||||
self.block_editor = Some(screen);
|
||||
}
|
||||
|
||||
fn block_editor_type_into(&mut self, screen: &mut BlockEditScreen, focus: usize, c: char) {
|
||||
if c.is_control() {
|
||||
return;
|
||||
}
|
||||
let Some(edit) = screen.editor.edits().get(focus) else {
|
||||
return;
|
||||
};
|
||||
let (value, cursor) = (edit.value.clone(), edit.cursor);
|
||||
if !screen.editor.accepts(focus, &value, cursor, c) {
|
||||
return;
|
||||
}
|
||||
if let Some(edit) = screen.editor.edits().get_mut(focus) {
|
||||
edit.insert_text(&c.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Characters typed (`type TEXT` from the agent control): into the box
|
||||
/// with the focus.
|
||||
pub(super) fn block_editor_type(&mut self, typed: &str) {
|
||||
let Some(mut screen) = self.block_editor.take() else {
|
||||
return;
|
||||
};
|
||||
if let Some(focus) = screen.focus {
|
||||
for c in typed.chars() {
|
||||
self.block_editor_type_into(&mut screen, focus, c);
|
||||
}
|
||||
}
|
||||
self.block_editor = Some(screen);
|
||||
self.block_editor_after_input();
|
||||
}
|
||||
}
|
||||
|
||||
/// `Screen.isValidCharacterForName`: the characters an identifier takes,
|
||||
/// with one `:` before any `/`, and `/` only after it.
|
||||
pub(super) fn valid_character_for_name(current: &str, c: char, cursor: usize) -> bool {
|
||||
let colon = current.chars().position(|x| x == ':');
|
||||
let slash = current.chars().position(|x| x == '/');
|
||||
match c {
|
||||
':' => slash.is_none_or(|s| cursor <= s) && colon.is_none(),
|
||||
'/' => colon.map_or(-1, |c| c as i64) < cursor as i64,
|
||||
'_' | '-' | '.' | 'a'..='z' | '0'..='9' => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `CycleButton.cycleValue`: the value `delta` steps on in `values`, wrapping;
|
||||
/// a current value that is not in the list steps from -1.
|
||||
pub(super) fn cycle<T: Copy + PartialEq>(values: &[T], current: T, delta: i32) -> T {
|
||||
let at = values
|
||||
.iter()
|
||||
.position(|v| *v == current)
|
||||
.map_or(-1, |i| i as i32);
|
||||
let len = values.len() as i32;
|
||||
values[(at + delta).rem_euclid(len) as usize]
|
||||
}
|
||||
|
||||
/// `CommonComponents.OPTION_ON` / `OPTION_OFF`.
|
||||
pub(super) fn on_off(on: bool) -> Text {
|
||||
Text::translate(if on { "options.on" } else { "options.off" }, &[])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn name_characters_follow_screen_is_valid_character_for_name() {
|
||||
assert!(valid_character_for_name("", 'a', 0));
|
||||
assert!(valid_character_for_name("a", '_', 1));
|
||||
assert!(!valid_character_for_name("a", 'A', 1));
|
||||
assert!(!valid_character_for_name("a", ' ', 1));
|
||||
// One colon, not after a slash.
|
||||
assert!(valid_character_for_name("ns", ':', 2));
|
||||
assert!(!valid_character_for_name("ns:x", ':', 4));
|
||||
assert!(!valid_character_for_name("a/b", ':', 3));
|
||||
assert!(valid_character_for_name("a/b", ':', 1));
|
||||
// A slash only after the colon.
|
||||
assert!(valid_character_for_name("ns:a", '/', 4));
|
||||
assert!(!valid_character_for_name("ns:a", '/', 1));
|
||||
assert!(valid_character_for_name("a", '/', 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycling_wraps_and_steps_from_minus_one_off_the_list() {
|
||||
assert_eq!(cycle(&[1, 2, 3], 3, 1), 1);
|
||||
assert_eq!(cycle(&[1, 2, 3], 1, -1), 3);
|
||||
assert_eq!(cycle(&[1, 2, 3], 9, 1), 1);
|
||||
assert_eq!(cycle(&[1, 2, 3], 9, -1), 2);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ impl Game<'_> {
|
||||
if !self.world.block_entities.has_states() {
|
||||
let states = self.ticking_states();
|
||||
self.world.block_entities.set_states(states);
|
||||
let containers = self.native_containers();
|
||||
self.world.block_entities.set_mod_containers(containers);
|
||||
}
|
||||
// Only lingering cooldowns are dropped; a hopper that ticks checks
|
||||
// its own block. Once a second is soon enough for the rest.
|
||||
@@ -64,6 +66,7 @@ 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::Mod(_)) => self.tick_mod_block_entity(pos),
|
||||
Some(BlockEntity::PotentSulfur(_)) => {
|
||||
self.tick_potent_sulfur(pos)?;
|
||||
// A window that simulates the world itself is its own
|
||||
@@ -129,6 +132,7 @@ impl Game<'_> {
|
||||
if record { Ticking::Yes } else { Ticking::Idle }
|
||||
}
|
||||
n if FurnaceKind::from_block(n).is_some() => Ticking::Yes,
|
||||
n if !n.starts_with("minecraft:") && self.native_ticks(n) => Ticking::Yes,
|
||||
_ => Ticking::No,
|
||||
}
|
||||
})
|
||||
@@ -170,7 +174,7 @@ impl Game<'_> {
|
||||
{
|
||||
s.properties.insert("lit".into(), lit.to_string());
|
||||
if let Ok(id) = self.pack.assets.registry.resolve(&s) {
|
||||
self.world.set(pos.0, pos.1, pos.2, id)?;
|
||||
self.level_set([pos.0, pos.1, pos.2], id)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Control-socket block commands for scripted tests: `setblock`, `fill`,
|
||||
//! `getblock` and `container` (list or put items in a block entity).
|
||||
use super::Game;
|
||||
use horizon_client::{Result, containers::BlockEntity, items::ItemStack};
|
||||
use horizon_client::{Result, items::ItemStack};
|
||||
use horizon_world::{chunk::BlockState, registry::BlockStateId};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
@@ -50,7 +50,7 @@ impl Game<'_> {
|
||||
let name = self.res.facts().get(state).name.clone();
|
||||
self.world.block_entities.remove(&(pos[0], pos[1], pos[2]));
|
||||
self.level_set(pos, state)?;
|
||||
if let Some(entity) = BlockEntity::for_block(&name) {
|
||||
if let Some(entity) = self.new_block_entity(&name) {
|
||||
self.world
|
||||
.block_entities
|
||||
.insert((pos[0], pos[1], pos[2]), entity);
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
//! Leads on boats and rafts (`AbstractBoat implements Leashable`): putting a
|
||||
//! lead on one, taking it off, `Leashable.tickLeash` (the pull of the rope,
|
||||
//! the lead snapping past twelve blocks), saving who holds it, and dropping
|
||||
//! the lead when the boat breaks. The rope is drawn by `leads.rs`
|
||||
//! (`LeashFeatureRenderer`, four ropes from a boat to a happy ghast).
|
||||
//! A boat is also a holder (`Holder::Boat`): `Entity.interact`'s sneak click
|
||||
//! ties the player's mobs and boats to it.
|
||||
//!
|
||||
//! The elastic pull of a ridden boat: `Leashable.tickLeash` runs on the
|
||||
//! server only, and the server's boat that its rider's client drives takes
|
||||
//! that client's position back (`handleMoveVehicle`), so vanilla never moves
|
||||
//! it by the rope either. Here the pull lands on the server's copy and the
|
||||
//! rider's client overwrites it the same way (`tick_boats`).
|
||||
use super::{Game, boats::Boat};
|
||||
use horizon_client::mobs::leash::{self, Holder, HolderState, Pending};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `AbstractBoat`'s `getBbWidth` and `getBbHeight`.
|
||||
const WIDTH: f32 = 1.375;
|
||||
const HEIGHT: f32 = 0.5625;
|
||||
|
||||
/// Where the other end of a boat's lead is.
|
||||
enum Held {
|
||||
/// The holder is gone: the lead comes off (`canInteractWithLevel`).
|
||||
Gone,
|
||||
/// In another level or not loaded: nothing happens (`level() ==`).
|
||||
Elsewhere,
|
||||
/// Its state, and whether it holds a quad lead.
|
||||
At(HolderState, bool),
|
||||
}
|
||||
|
||||
fn center(b: &Boat) -> Vec3 {
|
||||
Vec3::new(
|
||||
b.position.x,
|
||||
b.position.y + f64::from(HEIGHT) / 2.0,
|
||||
b.position.z,
|
||||
)
|
||||
}
|
||||
/// A boat as a lead's holder (`HolderState`), with its id.
|
||||
pub(super) fn boat_state(b: &Boat) -> (u32, HolderState) {
|
||||
(
|
||||
b.id,
|
||||
HolderState {
|
||||
position: b.position,
|
||||
center: center(b),
|
||||
width: f64::from(WIDTH),
|
||||
height: f64::from(HEIGHT),
|
||||
yaw: b.yaw,
|
||||
velocity: b.velocity,
|
||||
},
|
||||
)
|
||||
}
|
||||
fn distance(a: Vec3, b: Vec3) -> f64 {
|
||||
((a.x - b.x).powi(2) + (a.y - b.y).powi(2) + (a.z - b.z).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The player whose lead this is: the lead's `leash_player`, else the
|
||||
/// local player.
|
||||
fn boat_leash_player(&self, b: &Boat) -> Option<[i32; 4]> {
|
||||
b.leash_player.or(self.level.player_uuid)
|
||||
}
|
||||
fn boat_holder(&self, holder: Holder, player: Option<[i32; 4]>) -> Held {
|
||||
match holder {
|
||||
Holder::Player => {
|
||||
let want = player.or(self.level.player_uuid);
|
||||
// A server's guest (single player's window included) first.
|
||||
let guest = self.lan.as_ref().and_then(|host| {
|
||||
let g = host.hub.guests.iter().find(|g| {
|
||||
Some(horizon_client::lan::playerdata::uuid_ints(g.uuid)) == want
|
||||
})?;
|
||||
Some((host, g))
|
||||
});
|
||||
if let Some((host, g)) = guest {
|
||||
if !host.hub.here(g) || !g.loaded {
|
||||
return Held::Elsewhere;
|
||||
}
|
||||
if g.dead || g.spectator() {
|
||||
return Held::Gone;
|
||||
}
|
||||
let bounds = g.bounds();
|
||||
let (lo, hi) = (bounds.min_position(), bounds.max_position());
|
||||
let moved = [0, 1, 2].map(|k| g.position[k] - g.previous_position[k]);
|
||||
return Held::At(
|
||||
HolderState {
|
||||
position: Vec3::new(g.position[0], g.position[1], g.position[2]),
|
||||
center: Vec3::new(
|
||||
(lo.x + hi.x) / 2.0,
|
||||
(lo.y + hi.y) / 2.0,
|
||||
(lo.z + hi.z) / 2.0,
|
||||
),
|
||||
width: hi.x - lo.x,
|
||||
height: hi.y - lo.y,
|
||||
yaw: g.yaw,
|
||||
velocity: Vec3::new(moved[0], moved[1], moved[2]),
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
if want != self.level.player_uuid {
|
||||
// A player who left: Entity.remove drops what they led.
|
||||
return Held::Gone;
|
||||
}
|
||||
let Some(v) = self.player_view() else {
|
||||
return Held::Elsewhere;
|
||||
};
|
||||
if !v.alive || v.spectator {
|
||||
return Held::Gone;
|
||||
}
|
||||
let lo = v.bounds.min_position();
|
||||
let size = v.bounds.max_position().subtract(lo);
|
||||
Held::At(
|
||||
HolderState {
|
||||
position: v.position,
|
||||
center: Vec3::new(v.position.x, lo.y + size.y / 2.0, v.position.z),
|
||||
width: size.x,
|
||||
height: size.y,
|
||||
yaw: v.yaw,
|
||||
velocity: v.velocity,
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
Holder::Mob(id) => {
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id && m.alive()) else {
|
||||
return Held::Gone;
|
||||
};
|
||||
let (lo, hi) = (m.bounds().min_position(), m.bounds().max_position());
|
||||
Held::At(
|
||||
HolderState {
|
||||
position: m.position,
|
||||
center: Vec3::new(
|
||||
(lo.x + hi.x) / 2.0,
|
||||
(lo.y + hi.y) / 2.0,
|
||||
(lo.z + hi.z) / 2.0,
|
||||
),
|
||||
width: hi.x - lo.x,
|
||||
height: hi.y - lo.y,
|
||||
yaw: m.yaw,
|
||||
velocity: m.velocity,
|
||||
},
|
||||
leash::quad_holder_offsets(m).is_some(),
|
||||
)
|
||||
}
|
||||
Holder::Boat(id) => match self.boats.iter().find(|o| o.id == id) {
|
||||
// supportQuadLeashAsHolder is the ghasts' alone.
|
||||
Some(o) => Held::At(boat_state(o).1, false),
|
||||
None => Held::Gone,
|
||||
},
|
||||
Holder::Knot(pos) => {
|
||||
let name = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(pos[0], pos[1], pos[2]));
|
||||
let fence = self
|
||||
.res
|
||||
.items()
|
||||
.block_tags
|
||||
.get("minecraft:fences")
|
||||
.is_some_and(|s| s.contains(&*name.name));
|
||||
if fence {
|
||||
Held::At(leash::knot_state(pos), false)
|
||||
} else {
|
||||
Held::Gone
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Leashable.dropLeash` / `removeLeash` on a boat: the lead falls as an
|
||||
/// item (`drop`). The change reaches clients through the entity tracker.
|
||||
fn release_boat_leash(&mut self, b: &mut Boat, drop: bool) {
|
||||
if b.leash.take().is_none() {
|
||||
return;
|
||||
}
|
||||
b.leash_player = None;
|
||||
if drop {
|
||||
self.drop_lead_item([b.position.x, b.position.y, b.position.z]);
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractBoat.remove`: a boat that is destroyed drops its lead.
|
||||
pub(super) fn boat_removed(&mut self, b: &Boat) {
|
||||
let mut b = b.clone();
|
||||
let pending = b.leash_pending.take().is_some();
|
||||
if b.leash.is_some() || pending {
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
b.leash = None;
|
||||
self.drop_lead_item(at);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Leashable.tickLeash` for a boat, at the end of `Entity.baseTick`.
|
||||
pub(super) fn tick_boat_leash(&mut self, b: &mut Boat) {
|
||||
b.age += 1;
|
||||
if let Some(pending) = b.leash_pending {
|
||||
self.restore_boat_leash(b, pending);
|
||||
}
|
||||
let Some(holder) = b.leash else {
|
||||
return;
|
||||
};
|
||||
let (state, quad) = match self.boat_holder(holder, b.leash_player) {
|
||||
Held::Elsewhere => return,
|
||||
// !leashHolder.canInteractWithLevel(): dropLeash with entity_drops
|
||||
// on, else removeLeash.
|
||||
Held::Gone => {
|
||||
let drops = self.ui.rules.bool("entity_drops");
|
||||
self.release_boat_leash(b, drops);
|
||||
return;
|
||||
}
|
||||
Held::At(state, quad) => (state, quad),
|
||||
};
|
||||
// Leashable.whenLeashedTo: `leashHolder.notifyLeashHolder(this)`; a
|
||||
// boat supports a quad lead.
|
||||
if let Holder::Mob(id) = holder
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
{
|
||||
horizon_client::mobs::happy_ghast::notify_leash_holder(m);
|
||||
}
|
||||
let away = distance(state.center, center(b));
|
||||
if away > leash::SNAP {
|
||||
let p = state.position;
|
||||
self.lead_sound("break", [p.x, p.y, p.z]);
|
||||
self.release_boat_leash(b, true);
|
||||
return;
|
||||
}
|
||||
if away > leash::ELASTIC - state.width - f64::from(WIDTH)
|
||||
&& let Some((force, torque)) = leash::elastic_pull(
|
||||
b.position,
|
||||
b.yaw,
|
||||
(f64::from(WIDTH), f64::from(HEIGHT)),
|
||||
&state,
|
||||
b.velocity,
|
||||
quad,
|
||||
)
|
||||
{
|
||||
b.leash_spin += 10.0 * torque;
|
||||
let relative = Vec3::new(
|
||||
state.velocity.x - b.velocity.x,
|
||||
state.velocity.y - b.velocity.y,
|
||||
state.velocity.z - b.velocity.z,
|
||||
);
|
||||
b.velocity = Vec3::new(
|
||||
b.velocity.x + force[0] * 0.8 + relative.x * 0.11,
|
||||
b.velocity.y + force[1] * 0.2 + relative.y * 0.11,
|
||||
b.velocity.z + force[2] * 0.8 + relative.z * 0.11,
|
||||
);
|
||||
}
|
||||
b.yaw = (f64::from(b.yaw) - b.leash_spin) as f32;
|
||||
b.leash_spin *= f64::from(self.boat_angular_friction(b));
|
||||
}
|
||||
|
||||
/// `Leashable.angularFriction`.
|
||||
fn boat_angular_friction(&self, b: &Boat) -> f32 {
|
||||
if b.on_ground {
|
||||
let (x, z) = (b.position.x.floor() as i32, b.position.z.floor() as i32);
|
||||
let y = (b.position.y - 0.500_001).floor() as i32;
|
||||
self.res.facts().get(self.world.block(x, y, z)).friction * 0.91
|
||||
} else if b.in_liquid() {
|
||||
0.8
|
||||
} else {
|
||||
0.91
|
||||
}
|
||||
}
|
||||
|
||||
/// `Leashable.restoreLeashFromSave`: a holder read from a save becomes
|
||||
/// real once its entity is found; one that never shows up in 100 ticks
|
||||
/// drops the lead.
|
||||
fn restore_boat_leash(&mut self, b: &mut Boat, pending: Pending) {
|
||||
let found = match pending {
|
||||
Pending::Knot(p) => Some((Holder::Knot(p), None)),
|
||||
Pending::Uuid(u) => {
|
||||
let guest = self.lan.as_ref().is_some_and(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.any(|g| horizon_client::lan::playerdata::uuid_ints(g.uuid) == u)
|
||||
});
|
||||
if guest || (self.level.player_uuid == Some(u) && self.player.is_some()) {
|
||||
Some((Holder::Player, Some(u)))
|
||||
} else {
|
||||
self.mob_by_uuid(u)
|
||||
.map(|id| (Holder::Mob(id), None))
|
||||
.or_else(|| self.boat_by_uuid(u).map(|id| (Holder::Boat(id), None)))
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some((holder, player)) = found {
|
||||
b.leash = Some(holder);
|
||||
b.leash_player = player;
|
||||
b.leash_pending = None;
|
||||
} else if b.age > 100 {
|
||||
b.leash_pending = None;
|
||||
self.drop_lead_item([b.position.x, b.position.y, b.position.z]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The `leash` tag of a boat about to be saved.
|
||||
pub(super) fn write_boat_leash(&mut self, tag: &mut Tag, index: usize) {
|
||||
let Tag::Compound(map) = tag else { return };
|
||||
map.remove(&"leash".into());
|
||||
let b = &self.boats[index];
|
||||
let (holder, pending, player) = (b.leash, b.leash_pending, self.boat_leash_player(b));
|
||||
let value = match (holder, pending) {
|
||||
(Some(Holder::Mob(id)), _) => {
|
||||
let uuid = self.mob_uuid(id);
|
||||
leash::to_nbt(Holder::Mob(id), Some(uuid))
|
||||
}
|
||||
(Some(Holder::Boat(id)), _) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
leash::to_nbt(Holder::Boat(id), Some(uuid))
|
||||
}
|
||||
(Some(h), _) => leash::to_nbt(h, player),
|
||||
(None, Some(Pending::Knot(p))) => leash::to_nbt(Holder::Knot(p), None),
|
||||
(None, Some(Pending::Uuid(u))) => leash::to_nbt(Holder::Player, Some(u)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(v) = value {
|
||||
map.insert("leash".into(), v);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the player acting holds this boat's lead.
|
||||
fn boat_led_by_player(&mut self, i: usize) -> bool {
|
||||
let me = self.acting_player_uuid();
|
||||
let b = &self.boats[i];
|
||||
b.leash == Some(Holder::Player) && self.boat_leash_player(b) == Some(me)
|
||||
}
|
||||
|
||||
/// The boat with this UUID (`ServerLevel.getEntity`).
|
||||
pub(super) fn boat_by_uuid(&self, uuid: [i32; 4]) -> Option<u32> {
|
||||
self.persist
|
||||
.uuids
|
||||
.iter()
|
||||
.find(|(id, u)| **u == uuid && self.boats.iter().any(|b| b.id == **id))
|
||||
.map(|(id, _)| *id)
|
||||
}
|
||||
|
||||
/// The boats on the acting player's lead within 32 blocks of a point
|
||||
/// (`Leashable.leashableInArea`).
|
||||
pub(super) fn player_led_boats(&mut self, around: glam::DVec3) -> Vec<usize> {
|
||||
(0..self.boats.len())
|
||||
.filter(|&i| {
|
||||
let c = center(&self.boats[i]);
|
||||
self.boat_led_by_player(i)
|
||||
&& (c.x - around.x).abs() <= 16.0 + f64::from(WIDTH) / 2.0
|
||||
&& (c.y - around.y).abs() <= 16.0
|
||||
&& (c.z - around.z).abs() <= 16.0 + f64::from(WIDTH) / 2.0
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The boats whose lead hangs on `holder`.
|
||||
pub(super) fn boats_on(&self, holder: Holder) -> Vec<usize> {
|
||||
(0..self.boats.len())
|
||||
.filter(|&i| self.boats[i].leash == Some(holder))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `Leashable.canHaveALeashAttachedTo` for the boat at `i` and an entity
|
||||
/// whose box centre is `at`.
|
||||
fn boat_can_attach(&self, i: usize, at: glam::DVec3) -> bool {
|
||||
let c = center(&self.boats[i]);
|
||||
distance(Vec3::new(at.x, at.y, at.z), c) <= leash::SNAP
|
||||
}
|
||||
|
||||
/// The acting player's boats on a lead go to `to` (`Entity.interact` of a
|
||||
/// sneak click on a mob, `LeadItem.bindPlayerMobs`, the knot's
|
||||
/// `interact`), the ones in reach of `at`. Whether any did.
|
||||
pub(super) fn tie_player_boats(&mut self, at: glam::DVec3, to: Holder) -> bool {
|
||||
let mut any = false;
|
||||
for i in self.player_led_boats(at) {
|
||||
// Leashable.setLeashedTo: `this != holder`.
|
||||
if to == Holder::Boat(self.boats[i].id) {
|
||||
continue;
|
||||
}
|
||||
if self.boat_can_attach(i, at) {
|
||||
self.boats[i].leash = Some(to);
|
||||
self.boats[i].leash_player = None;
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
any
|
||||
}
|
||||
|
||||
/// `LeashFenceKnotEntity.interact`: the boats on the knot go back on the
|
||||
/// acting player's lead. Whether any did.
|
||||
pub(super) fn take_knot_boats(&mut self, knot: [i32; 3]) -> bool {
|
||||
let me = self.acting_player_uuid();
|
||||
let player = self.player_center();
|
||||
let mut any = false;
|
||||
for i in self.boats_on(Holder::Knot(knot)) {
|
||||
if self.boat_can_attach(i, player) {
|
||||
self.boats[i].leash = Some(Holder::Player);
|
||||
self.boats[i].leash_player = Some(me);
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
any
|
||||
}
|
||||
|
||||
/// `Leashable.dropLeash` for each boat in `list`.
|
||||
pub(super) fn drop_boat_leashes(&mut self, list: &[usize]) {
|
||||
for &i in list {
|
||||
let mut b = self.boats[i].clone();
|
||||
self.release_boat_leash(&mut b, true);
|
||||
self.boats[i] = b;
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.interact`'s lead part for the boat at `i`; whether it took
|
||||
/// the click.
|
||||
pub(super) fn interact_boat_leash(&mut self, i: usize) -> bool {
|
||||
let item = self.held().map(|s| s.item.to_string());
|
||||
let c = center(&self.boats[i]);
|
||||
let at = [c.x, c.y, c.z];
|
||||
let id = self.boats[i].id;
|
||||
// The sneak branch: the player's mobs and boats on leads are re-tied
|
||||
// to this boat.
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
|
||||
if sneaking
|
||||
&& self.tie_player_leads(glam::DVec3::new(c.x, c.y, c.z), Holder::Boat(id), None)
|
||||
{
|
||||
self.lead_game_event("minecraft:entity_action", at);
|
||||
self.lead_sound("tied", at);
|
||||
return true;
|
||||
}
|
||||
// Entity.shearOffAllLeashConnections: its own lead and every one
|
||||
// hanging on it.
|
||||
if item.as_deref() == Some("minecraft:shears") {
|
||||
let held_mobs: Vec<u32> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.extra.leash == Some(Holder::Boat(id)))
|
||||
.map(|m| m.id)
|
||||
.collect();
|
||||
let mut held_boats = self.boats_on(Holder::Boat(id));
|
||||
if self.boats[i].leash.is_some() {
|
||||
held_boats.push(i);
|
||||
}
|
||||
if !held_mobs.is_empty() || !held_boats.is_empty() {
|
||||
for m in held_mobs {
|
||||
self.break_leash(m, true, false);
|
||||
}
|
||||
self.drop_boat_leashes(&held_boats);
|
||||
self.lead_game_event("minecraft:shear", at);
|
||||
self.audio
|
||||
.play("minecraft:item.shears.snip", Some(at), 1.0, 1.0);
|
||||
self.damage_held_tool();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if self.boat_led_by_player(i) {
|
||||
// hasInfiniteMaterials: removeLeash, else dropLeash.
|
||||
let drop = !self.creative;
|
||||
let mut b = self.boats[i].clone();
|
||||
self.release_boat_leash(&mut b, drop);
|
||||
self.boats[i] = b;
|
||||
self.lead_game_event("minecraft:entity_interact", at);
|
||||
self.lead_sound("untied", at);
|
||||
return true;
|
||||
}
|
||||
let taken_by_player = self.boats[i].leash == Some(Holder::Player);
|
||||
if item.as_deref() == Some("minecraft:lead") && !taken_by_player {
|
||||
let player = self.player_center();
|
||||
if self.boat_can_attach(i, player) {
|
||||
let mut b = self.boats[i].clone();
|
||||
self.release_boat_leash(&mut b, true);
|
||||
b.leash = Some(Holder::Player);
|
||||
b.leash_player = Some(self.acting_player_uuid());
|
||||
self.boats[i] = b;
|
||||
self.lead_sound("tied", at);
|
||||
// The lead is always used up.
|
||||
if let Some(stack) = self.held_mut().as_mut() {
|
||||
stack.count -= 1;
|
||||
if stack.count == 0 {
|
||||
*self.held_mut() = None;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The leads on boats that hang on a player or a mob, for the entity
|
||||
/// tracker: the holder's entity id (`ClientboundSetEntityLinkPacket`).
|
||||
pub(super) fn boat_leash_link(
|
||||
&self,
|
||||
b: &Boat,
|
||||
host_entity: Option<i32>,
|
||||
guests: &[(u128, i32)],
|
||||
) -> Option<i32> {
|
||||
match b.leash? {
|
||||
Holder::Player => {
|
||||
let uuid = self.boat_leash_player(b);
|
||||
match guests
|
||||
.iter()
|
||||
.find(|(u, _)| Some(horizon_client::lan::playerdata::uuid_ints(*u)) == uuid)
|
||||
{
|
||||
Some(&(_, id)) => Some(id),
|
||||
None if uuid == self.level.player_uuid => host_entity,
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
Holder::Mob(id) | Holder::Boat(id) => Some(id as i32),
|
||||
Holder::Knot(p) => self.knot_ids.get(&p).map(|&id| id as i32),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_boat_is_centred_half_its_height_up() {
|
||||
let b = Boat::new(1, "oak".into(), Vec3::new(1.0, 64.0, 2.0), 0.0);
|
||||
let c = center(&b);
|
||||
assert_eq!((c.x, c.z), (1.0, 2.0));
|
||||
assert!((c.y - 64.28125).abs() < 1e-9);
|
||||
}
|
||||
#[test]
|
||||
fn a_boat_holds_a_lead_from_its_centre() {
|
||||
let mut b = Boat::new(7, "oak".into(), Vec3::new(1.0, 64.0, 2.0), 90.0);
|
||||
b.velocity = Vec3::new(0.1, 0.0, 0.0);
|
||||
let (id, s) = boat_state(&b);
|
||||
assert_eq!(id, 7);
|
||||
assert!((s.center.y - 64.28125).abs() < 1e-9);
|
||||
assert!((s.width - 1.375).abs() < 1e-9 && (s.height - 0.5625).abs() < 1e-9);
|
||||
assert_eq!((s.yaw, s.velocity.x), (90.0, 0.1));
|
||||
}
|
||||
#[test]
|
||||
fn a_boat_holder_is_an_entity_not_a_player() {
|
||||
assert_eq!(Holder::Boat(7).who(), None);
|
||||
assert_ne!(Holder::Boat(7), Holder::Mob(7));
|
||||
}
|
||||
#[test]
|
||||
fn distances_are_euclidean() {
|
||||
let d = distance(Vec3::new(0.0, 0.0, 0.0), Vec3::new(3.0, 4.0, 0.0));
|
||||
assert!((d - 5.0).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,12 @@ use horizon_client::{
|
||||
collisions::{EntityCollisions, SceneCollisions},
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::{Part, anim::Poses, riders},
|
||||
mobs::{
|
||||
Part,
|
||||
anim::Poses,
|
||||
leash::{Holder, Pending},
|
||||
riders,
|
||||
},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
@@ -51,6 +56,16 @@ pub(super) struct Boat {
|
||||
/// `ChestBoat`: a chest with 27 slots in the back seat.
|
||||
pub chest: bool,
|
||||
pub items: Vec<Option<ItemStack>>,
|
||||
/// `AbstractBoat.leashData`: who holds its lead (`boat_leads.rs`).
|
||||
pub leash: Option<Holder>,
|
||||
/// The player a `Holder::Player` lead is tied to (their UUID).
|
||||
pub leash_player: Option<[i32; 4]>,
|
||||
/// A holder read from a save that is not in the world yet.
|
||||
pub leash_pending: Option<Pending>,
|
||||
/// `LeashData.angularMomentum`.
|
||||
pub leash_spin: f64,
|
||||
/// `Entity.tickCount`, for the 100 ticks a saved holder is waited for.
|
||||
pub age: i32,
|
||||
}
|
||||
/// `ChestBoat.CONTAINER_SIZE`.
|
||||
pub(super) const CHEST_SLOTS: usize = 27;
|
||||
@@ -81,6 +96,11 @@ impl Boat {
|
||||
on_ground: false,
|
||||
chest: false,
|
||||
items: Vec::new(),
|
||||
leash: None,
|
||||
leash_player: None,
|
||||
leash_pending: None,
|
||||
leash_spin: 0.0,
|
||||
age: 0,
|
||||
}
|
||||
}
|
||||
/// A chest boat or chest raft.
|
||||
@@ -101,6 +121,13 @@ impl Boat {
|
||||
pub fn under_water(&self) -> bool {
|
||||
matches!(self.status, Status::UnderWater | Status::UnderFlowingWater)
|
||||
}
|
||||
/// `Entity.isInLiquid`, for `Leashable.angularFriction`.
|
||||
pub fn in_liquid(&self) -> bool {
|
||||
matches!(
|
||||
self.status,
|
||||
Status::InWater | Status::UnderWater | Status::UnderFlowingWater
|
||||
)
|
||||
}
|
||||
/// `getMaxPassengers`: two, one beside a chest.
|
||||
pub fn max_passengers(&self) -> usize {
|
||||
if self.chest { 1 } else { 2 }
|
||||
@@ -349,6 +376,11 @@ impl Game<'_> {
|
||||
pub(super) fn tick_boat(&mut self, mut b: Boat, controlled: bool) -> Boat {
|
||||
b.previous = b.position;
|
||||
b.previous_yaw = b.yaw;
|
||||
// Entity.baseTick ends with Leashable.tickLeash, on a server only
|
||||
// (a ridden boat's lead ticks in `tick_boats`).
|
||||
if !controlled && self.remote.is_none() {
|
||||
self.tick_boat_leash(&mut b);
|
||||
}
|
||||
b.old_status = b.status;
|
||||
b.status = self.boat_status(&mut b);
|
||||
b.out_of_control = if matches!(b.status, Status::UnderWater | Status::UnderFlowingWater) {
|
||||
@@ -513,6 +545,12 @@ impl Game<'_> {
|
||||
|| driven.contains(&self.boats[i].id)
|
||||
|| !ticking(p.x.floor() as i32, p.z.floor() as i32)
|
||||
{
|
||||
// Its rider's client moves it, but the lead still ticks.
|
||||
if ticking(p.x.floor() as i32, p.z.floor() as i32) && self.remote.is_none() {
|
||||
let mut b = self.boats[i].clone();
|
||||
self.tick_boat_leash(&mut b);
|
||||
self.boats[i] = b;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let b = self.boats[i].clone();
|
||||
@@ -790,6 +828,11 @@ impl Game<'_> {
|
||||
if !self.before_block(d) {
|
||||
return false;
|
||||
}
|
||||
// AbstractBoat.interact starts with Entity.interact: the lead.
|
||||
if self.interact_boat_leash(i) {
|
||||
self.swing_arm();
|
||||
return true;
|
||||
}
|
||||
// ChestBoat.interact: sneaking (or a full seat) opens the chest.
|
||||
if self.boats[i].chest && (sneaking || self.riding.is_some() || !self.boat_has_room(i)) {
|
||||
let b = &self.boats[i];
|
||||
@@ -815,6 +858,8 @@ impl Game<'_> {
|
||||
}
|
||||
self.riding = Some(self.boats[i].id);
|
||||
self.yaw = self.boats[i].yaw;
|
||||
// ServerPlayer.startRiding.
|
||||
self.trigger_player("minecraft:started_riding");
|
||||
true
|
||||
}
|
||||
/// `VehicleEntity.hurtServer` from the player's attack.
|
||||
@@ -847,6 +892,8 @@ impl Game<'_> {
|
||||
self.eject_boat_mobs(i, true);
|
||||
let b = self.boats.remove(i);
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
// AbstractBoat.remove: a destroyed boat drops its lead.
|
||||
self.boat_removed(&b);
|
||||
if !creative && self.ui.rules.bool("entity_drops") {
|
||||
// ChestBoat.destroy: chestVehicleDestroyed spills the contents.
|
||||
for stack in b.items.iter().flatten() {
|
||||
|
||||
@@ -833,6 +833,23 @@ impl Game<'_> {
|
||||
self.lectern_menu_done(key, &menu);
|
||||
self.follow_lectern();
|
||||
}
|
||||
/// LecternScreen's Take Book: the menu's button 3. The book leaves the
|
||||
/// lectern, so the screen closes (the server closes the menu once the
|
||||
/// book is gone; a server's client closes at once rather than wait).
|
||||
fn take_lectern_book(&mut self, key: (i32, i32, i32)) {
|
||||
self.lectern_button(key, 3);
|
||||
if self.may_build()
|
||||
&& matches!(
|
||||
self.book,
|
||||
Some(BookScreen::View {
|
||||
lectern: Some(_),
|
||||
..
|
||||
})
|
||||
)
|
||||
{
|
||||
self.close_book();
|
||||
}
|
||||
}
|
||||
/// The `book` control command: a button of the open book or lectern
|
||||
/// screen (the page arrows, Take Book, Done), then its page.
|
||||
pub(super) fn book_control(
|
||||
@@ -849,7 +866,7 @@ impl Game<'_> {
|
||||
Some("next") => self.turn_page(true),
|
||||
Some("back") => self.turn_page(false),
|
||||
Some("take") => match lectern {
|
||||
Some(key) => self.lectern_button(key, 3),
|
||||
Some(key) => self.take_lectern_book(key),
|
||||
None => return Err("not a lectern".into()),
|
||||
},
|
||||
// The book and quill's buttons: Sign, then Sign and Close; Done
|
||||
@@ -1064,8 +1081,7 @@ impl Game<'_> {
|
||||
self.close_book();
|
||||
} else if inside(w / 2.0 + 2.0, 196.0, 98.0, 20.0) {
|
||||
click(self);
|
||||
// LecternScreen's Take Book: the menu's button 3.
|
||||
self.lectern_button(key, 3);
|
||||
self.take_lectern_book(key);
|
||||
}
|
||||
} else if inside((w - 200.0) / 2.0, 196.0, 200.0, 20.0) {
|
||||
click(self);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! screen draws the preview with its error and lock sprites.
|
||||
use super::{
|
||||
Game,
|
||||
maps::{MapData, map_id},
|
||||
maps::{MapData, map_id, shown_on_frame},
|
||||
};
|
||||
use horizon_client::{hud::HudBatch, items::ItemStack, menu::Maps};
|
||||
use std::{
|
||||
@@ -116,12 +116,13 @@ impl Game<'_> {
|
||||
);
|
||||
};
|
||||
let preview = |batch: &mut HudBatch, game: &mut Self, x: f32, y: f32, scale: f32| {
|
||||
if let Some(rect) = id
|
||||
if let Some((id, rect)) = id
|
||||
.filter(|_| state.is_some())
|
||||
.and_then(|id| game.map_texture(id))
|
||||
.and_then(|id| game.map_texture(id).map(|rect| (id, rect)))
|
||||
{
|
||||
let size = 128.0 * scale;
|
||||
batch.rect_uv(ox + x, oy + y, size, size, rect, u32::MAX);
|
||||
game.draw_map_decorations(batch, id, ox + x, oy + y, scale);
|
||||
}
|
||||
};
|
||||
if scaling && !locked {
|
||||
@@ -142,6 +143,41 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `GuiGraphicsExtractor.map`: map `id`'s decorations over its image at
|
||||
/// `(x, y)` drawn `scale` GUI pixels per map pixel. Only those with
|
||||
/// `renderOnFrame` (`MapDecorationType.showOnItemFrame`) are drawn: the
|
||||
/// screen's `map` skips the player arrows and the two markers.
|
||||
fn draw_map_decorations(&self, batch: &mut HudBatch, id: i32, x: f32, y: f32, scale: f32) {
|
||||
let Some(map) = self.maps.data.get(&id) else {
|
||||
return;
|
||||
};
|
||||
for d in map.decorations.iter().filter(|d| shown_on_frame(d.sprite)) {
|
||||
let Some(s) = self.gui.atlas.sprites.get(d.sprite).copied() else {
|
||||
continue;
|
||||
};
|
||||
batch.quad(
|
||||
decoration_corners(d, x, y, scale),
|
||||
[[s.u0, s.v0], [s.u1, s.v0], [s.u1, s.v1], [s.u0, s.v1]],
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decoration `d`'s corners on screen, for a map drawn at `(x, y)` with
|
||||
/// `scale` GUI pixels per map pixel: (-1, 1), (1, 1), (1, -1), (-1, -1)
|
||||
/// under `MapRenderer.render`'s pose.
|
||||
fn decoration_corners(d: &super::maps::Decoration, x: f32, y: f32, scale: f32) -> [[f32; 2]; 4] {
|
||||
let m = glam::Mat4::from_translation(glam::Vec3::new(x, y, 0.0))
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(scale, scale, 1.0))
|
||||
* super::maps::decoration_pose(d, 0.0);
|
||||
[(-1.0, 1.0), (1.0, 1.0), (1.0, -1.0), (-1.0, -1.0)].map(|(cx, cy)| {
|
||||
let p = m.transform_point3(glam::Vec3::new(cx, cy, 0.0));
|
||||
[p.x, p.y]
|
||||
})
|
||||
}
|
||||
|
||||
impl MapData {
|
||||
/// `MapItemSavedData.scaled`: a fresh map one scale up around the same centre.
|
||||
pub(super) fn scaled(&self) -> MapData {
|
||||
@@ -172,3 +208,59 @@ impl MapData {
|
||||
m
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game::maps::Decoration;
|
||||
|
||||
fn close(a: [[f32; 2]; 4], b: [[f32; 2]; 4]) {
|
||||
for (a, b) in a.iter().zip(&b) {
|
||||
assert!(
|
||||
(a[0] - b[0]).abs() < 1e-4 && (a[1] - b[1]).abs() < 1e-4,
|
||||
"{a:?} {b:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preview_skips_what_an_item_frame_skips() {
|
||||
// GuiGraphicsExtractor.map: `if (decoration.renderOnFrame)`.
|
||||
assert!(!shown_on_frame("map/decorations/player"));
|
||||
assert!(!shown_on_frame("map/decorations/red_marker"));
|
||||
assert!(shown_on_frame("map/decorations/frame"));
|
||||
assert!(shown_on_frame("map/decorations/red_x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_markers_sit_on_the_preview_like_on_a_held_map() {
|
||||
let player = |x, y, rot| Decoration {
|
||||
sprite: "map/decorations/player",
|
||||
x,
|
||||
y,
|
||||
rot,
|
||||
holder: None,
|
||||
};
|
||||
// The centre, facing north: 8 map pixels wide, nudged by (-0.5, 0.5).
|
||||
close(
|
||||
decoration_corners(&player(0, 0, 0), 0.0, 0.0, 1.0),
|
||||
[[59.5, 68.5], [67.5, 68.5], [67.5, 60.5], [59.5, 60.5]],
|
||||
);
|
||||
// A quarter turn (rotation 4 of 16) turns the quad about its marker.
|
||||
close(
|
||||
decoration_corners(&player(0, 0, 4), 0.0, 0.0, 1.0),
|
||||
[[59.5, 59.5], [59.5, 67.5], [67.5, 67.5], [67.5, 59.5]],
|
||||
);
|
||||
// The preview's offset and scale apply after: the top-left corner
|
||||
// marker on the 0.45 preview at (71, 17).
|
||||
close(
|
||||
decoration_corners(&player(-128, -128, 0), 71.0, 17.0, 0.45),
|
||||
[
|
||||
[71.0 - 4.5 * 0.45, 17.0 + 4.5 * 0.45],
|
||||
[71.0 + 3.5 * 0.45, 17.0 + 4.5 * 0.45],
|
||||
[71.0 + 3.5 * 0.45, 17.0 - 3.5 * 0.45],
|
||||
[71.0 - 4.5 * 0.45, 17.0 - 3.5 * 0.45],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,20 @@ pub struct ChatDraft {
|
||||
pub text: String,
|
||||
pub command: bool,
|
||||
}
|
||||
/// `ChatScreen.ExitReason`: why the chat screen went away.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(super) enum ChatExit {
|
||||
/// Another screen replaced it (death, a container, a disconnect).
|
||||
Interrupted,
|
||||
/// The player closed it (Escape).
|
||||
Intentional,
|
||||
/// A message was sent.
|
||||
Done,
|
||||
}
|
||||
/// `ChatScreen.shouldDiscardDraft`.
|
||||
fn should_discard_draft(reason: ChatExit, save_chat_drafts: bool) -> bool {
|
||||
reason != ChatExit::Interrupted && (reason != ChatExit::Intentional || !save_chat_drafts)
|
||||
}
|
||||
/// UI and command state that lives beside the game.
|
||||
#[derive(Default)]
|
||||
pub struct UiState {
|
||||
@@ -560,7 +574,7 @@ impl Game<'_> {
|
||||
self.right = false;
|
||||
self.capture(false);
|
||||
}
|
||||
pub(super) fn close_chat(&mut self) {
|
||||
pub(super) fn close_chat(&mut self, reason: ChatExit) {
|
||||
// InBedChatScreen.onClose: leaving the screen means getting up; the
|
||||
// screen goes once the player has woken (`tick_in_bed_chat`).
|
||||
if self.ui.in_bed_chat && self.sleeping.is_some() {
|
||||
@@ -572,7 +586,7 @@ impl Game<'_> {
|
||||
.chat_screen
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |s| s.value().to_owned());
|
||||
self.keep_chat_draft(&text);
|
||||
self.keep_chat_draft(&text, reason);
|
||||
self.ui.chat_draft_shown = false;
|
||||
self.ui.chat_screen = None;
|
||||
self.ui.chat.reset_scroll();
|
||||
@@ -581,13 +595,16 @@ 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) {
|
||||
/// `ChatScreen.removed`: the text left in the box becomes the draft unless
|
||||
/// `shouldDiscardDraft` (closed on purpose without Save Chat Drafts, or
|
||||
/// sent); blank text discards the draft. A draft still shown unchanged
|
||||
/// stays as it is.
|
||||
fn keep_chat_draft(&mut self, text: &str, reason: ChatExit) {
|
||||
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() {
|
||||
if should_discard_draft(reason, self.settings.file.bool("saveChatDrafts"))
|
||||
|| text.trim().is_empty()
|
||||
{
|
||||
self.ui.chat_draft = None;
|
||||
} else if !unchanged {
|
||||
self.ui.chat_draft = Some(ChatDraft {
|
||||
@@ -600,6 +617,19 @@ impl Game<'_> {
|
||||
/// woken (`onPlayerWokeUp`) it closes, or turns into the plain chat
|
||||
/// screen if something was typed.
|
||||
pub(super) fn tick_in_bed_chat(&mut self) {
|
||||
// Another screen took over the open chat (death, a container the
|
||||
// server opened): `ChatScreen.removed` runs with the INTERRUPTED reason.
|
||||
if self.screen != Screen::Chat && self.ui.chat_screen.is_some() {
|
||||
let text = self
|
||||
.ui
|
||||
.chat_screen
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |s| s.value().to_owned());
|
||||
self.keep_chat_draft(&text, ChatExit::Interrupted);
|
||||
self.ui.chat_draft_shown = false;
|
||||
self.ui.chat_screen = None;
|
||||
self.ui.chat.reset_scroll();
|
||||
}
|
||||
if self.screen != Screen::Chat {
|
||||
self.ui.in_bed_chat = false;
|
||||
}
|
||||
@@ -611,7 +641,7 @@ impl Game<'_> {
|
||||
.chat_screen
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |s| s.value().to_owned());
|
||||
self.close_chat();
|
||||
self.close_chat(ChatExit::Interrupted);
|
||||
if !text.is_empty() {
|
||||
self.open_chat(&text);
|
||||
}
|
||||
@@ -734,7 +764,7 @@ impl Game<'_> {
|
||||
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();
|
||||
self.close_chat(ChatExit::Intentional);
|
||||
}
|
||||
ChatAction::Submit(message) => {
|
||||
// InBedChatScreen sends without closing (`closeOnSubmit` is
|
||||
@@ -746,7 +776,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
// The screen is taken here, so close_chat sees no text: a sent
|
||||
// message discards the draft (DONE).
|
||||
self.close_chat();
|
||||
self.close_chat(ChatExit::Done);
|
||||
}
|
||||
if !message.is_empty() {
|
||||
self.handle_chat_input(&message);
|
||||
@@ -1416,6 +1446,16 @@ const LINK_COPY: usize = 1;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `ChatScreen.shouldDiscardDraft`.
|
||||
#[test]
|
||||
fn draft_discard_follows_the_exit_reason() {
|
||||
assert!(!should_discard_draft(ChatExit::Interrupted, false));
|
||||
assert!(!should_discard_draft(ChatExit::Interrupted, true));
|
||||
assert!(should_discard_draft(ChatExit::Intentional, false));
|
||||
assert!(!should_discard_draft(ChatExit::Intentional, true));
|
||||
assert!(should_discard_draft(ChatExit::Done, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_ids_are_listed_once_per_root() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-ids-{}", std::process::id()));
|
||||
|
||||
@@ -24,8 +24,51 @@ struct Entry {
|
||||
/// (`DamageSource.directEntity`: a potion with no thrower, a cloud).
|
||||
direct: Option<Text>,
|
||||
}
|
||||
/// What is stuck in a player's body: `LivingEntity.DATA_ARROW_COUNT_ID` and
|
||||
/// `DATA_STINGER_COUNT_ID` with the timers that pull them out
|
||||
/// (`removeArrowTime`, `removeStingerTime`). Kept with the combat record,
|
||||
/// which is swapped with the body it belongs to.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Stuck {
|
||||
pub(super) arrows: i32,
|
||||
pub(super) stingers: i32,
|
||||
remove_arrow_time: i32,
|
||||
remove_stinger_time: i32,
|
||||
}
|
||||
impl Stuck {
|
||||
/// `AbstractArrow.onHitEntity`: a hit that hurt, from an arrow that
|
||||
/// does not pierce, leaves it stuck.
|
||||
pub(super) fn add_arrow(&mut self) {
|
||||
self.arrows += 1;
|
||||
}
|
||||
/// `Bee.doHurtTarget`: a sting that hurt.
|
||||
pub(super) fn add_stinger(&mut self) {
|
||||
self.stingers += 1;
|
||||
}
|
||||
/// The server's part of `LivingEntity.tick`: one in a count's worth of
|
||||
/// `20 * (30 - count)` ticks falls out. Whether the counts changed.
|
||||
pub(super) fn tick(&mut self) -> bool {
|
||||
let (arrows, stingers) = (self.arrows, self.stingers);
|
||||
Self::decay(&mut self.arrows, &mut self.remove_arrow_time);
|
||||
Self::decay(&mut self.stingers, &mut self.remove_stinger_time);
|
||||
(arrows, stingers) != (self.arrows, self.stingers)
|
||||
}
|
||||
fn decay(count: &mut i32, remove_time: &mut i32) {
|
||||
if *count > 0 {
|
||||
if *remove_time <= 0 {
|
||||
*remove_time = 20 * (30 - *count);
|
||||
}
|
||||
*remove_time -= 1;
|
||||
if *remove_time <= 0 {
|
||||
*count -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct CombatTracker {
|
||||
/// The arrows and bee stingers stuck in the body.
|
||||
pub(super) stuck: Stuck,
|
||||
entries: Vec<Entry>,
|
||||
last_damage: i64,
|
||||
in_combat: bool,
|
||||
@@ -58,6 +101,9 @@ pub(super) struct CombatTracker {
|
||||
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
|
||||
/// fire decides whom the blast may hurt.
|
||||
pub(super) explosion_source: Option<u32>,
|
||||
/// The exploding mob as predicates see it (`ServerExplosion.source`,
|
||||
/// `ServerPlayer.currentExplosionCause`), for a mob already out of the list.
|
||||
pub(super) explosion_cause: Option<horizon_client::advancements::EntityView>,
|
||||
/// The exploding mob named itself (`MobEvent::ExplosionSource`), so the
|
||||
/// blast leaves it out (`getEntities(source, box)`). A source guessed
|
||||
/// from the mobs near the centre is still hurt: it may be a bystander.
|
||||
@@ -274,6 +320,48 @@ impl Game<'_> {
|
||||
damage.in_tag(id, tag) || damage.in_tag(id, &format!("minecraft:{tag}"))
|
||||
})
|
||||
}
|
||||
/// The killing blow as advancement predicates see it (`DamageSource` of the
|
||||
/// last combat entry): its type and the causing entity. The projectile
|
||||
/// damage types have the projectile as direct entity, not the shooter.
|
||||
pub(super) fn combat_killing_blow(&self) -> Option<horizon_client::advancements::DamageView> {
|
||||
use horizon_client::advancements::{DamageView, EntityView};
|
||||
let e = self.combat.entries.last()?;
|
||||
let source = e.attacker.as_ref().and_then(|(id, _)| {
|
||||
self.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == *id)
|
||||
.map(|m| self.mob_entity(m))
|
||||
});
|
||||
let projectile = matches!(
|
||||
e.damage_type.as_str(),
|
||||
"minecraft:arrow"
|
||||
| "minecraft:trident"
|
||||
| "minecraft:mob_projectile"
|
||||
| "minecraft:fireball"
|
||||
| "minecraft:unattributed_fireball"
|
||||
| "minecraft:wither_skull"
|
||||
| "minecraft:wind_charge"
|
||||
| "minecraft:thrown"
|
||||
| "minecraft:spit"
|
||||
);
|
||||
let direct = if projectile {
|
||||
Some(EntityView {
|
||||
kind: e.damage_type.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
source.clone()
|
||||
};
|
||||
Some(DamageView {
|
||||
kind: e.damage_type.clone(),
|
||||
direct,
|
||||
source,
|
||||
is_direct: !projectile,
|
||||
original: e.damage,
|
||||
actual: e.damage,
|
||||
blocked: false,
|
||||
})
|
||||
}
|
||||
/// The type of the mob that dealt the killing blow (`killed_by`).
|
||||
pub(super) fn combat_killer_kind(&self) -> Option<String> {
|
||||
let (id, _) = self.combat.entries.last()?.attacker.clone()?;
|
||||
@@ -527,3 +615,43 @@ fn mob_name(m: &Mob) -> Text {
|
||||
(None, None) => Text::translate(&format!("entity.{}", m.kind.id.replace(':', ".")), &[]),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stuck_tests {
|
||||
use super::Stuck;
|
||||
|
||||
/// `LivingEntity.tick`: with `n` stuck the timer starts at
|
||||
/// `20 * (30 - n)` and one falls out when it runs down.
|
||||
#[test]
|
||||
fn arrows_fall_out_after_twenty_ticks_times_the_gap_to_thirty() {
|
||||
let mut s = Stuck::default();
|
||||
s.add_arrow();
|
||||
s.add_arrow();
|
||||
let wait = 20 * (30 - 2);
|
||||
for _ in 0..wait - 1 {
|
||||
assert!(!s.tick());
|
||||
}
|
||||
assert!(s.tick());
|
||||
assert_eq!(s.arrows, 1);
|
||||
// The timer starts again from the new count.
|
||||
for _ in 0..20 * (30 - 1) - 1 {
|
||||
assert!(!s.tick());
|
||||
}
|
||||
assert!(s.tick());
|
||||
assert_eq!(s.arrows, 0);
|
||||
assert!(!s.tick());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stingers_have_their_own_timer() {
|
||||
let mut s = Stuck::default();
|
||||
s.add_stinger();
|
||||
s.add_arrow();
|
||||
for _ in 0..20 * 29 - 1 {
|
||||
assert!(!s.tick());
|
||||
}
|
||||
// Both timers run 580 ticks.
|
||||
assert!(s.tick());
|
||||
assert_eq!((s.arrows, s.stingers), (0, 0));
|
||||
}
|
||||
}
|
||||
@@ -31,12 +31,15 @@ type Pos = (i32, i32, i32);
|
||||
/// The open `CommandBlockEditScreen`.
|
||||
pub(super) struct CommandBlockScreen {
|
||||
pos: Pos,
|
||||
/// The command block minecart being edited (`MinecartCommandBlockEditScreen`);
|
||||
/// `pos` is unused then.
|
||||
cart: Option<u32>,
|
||||
/// The command box with its `CommandSuggestions`.
|
||||
command: ChatScreen,
|
||||
pub(super) command: ChatScreen,
|
||||
/// When the command box was focused, for its cursor's blink.
|
||||
opened: std::time::Instant,
|
||||
previous: EditBox,
|
||||
track_output: bool,
|
||||
pub(super) track_output: bool,
|
||||
mode: Mode,
|
||||
conditional: bool,
|
||||
auto: bool,
|
||||
@@ -62,23 +65,34 @@ enum Button {
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// `AbstractCommandBlockEditScreen.getPreviousY`.
|
||||
fn previous_y(cart: bool) -> f32 {
|
||||
if cart { 150.0 } else { 135.0 }
|
||||
}
|
||||
|
||||
/// `EditBox.textColorUneditable`.
|
||||
const UNEDITABLE: u32 = 0xFF70_7070;
|
||||
|
||||
impl CommandBlockScreen {
|
||||
/// `AbstractCommandBlockEditScreen.init` and `CommandBlockEditScreen`'s
|
||||
/// buttons, in GUI pixels of a `w`×`h` screen.
|
||||
fn buttons(w: f32, h: f32) -> [(Button, f32, f32, f32); 6] {
|
||||
/// A minecart's screen has no mode buttons (`getPreviousY` 150).
|
||||
fn buttons(w: f32, h: f32, cart: bool) -> Vec<(Button, f32, f32, f32)> {
|
||||
let mid = (w / 2.0).floor();
|
||||
let low = (h / 4.0).floor() + 132.0;
|
||||
[
|
||||
(Button::Output, mid + 130.0, 135.0, 20.0),
|
||||
(Button::Mode, mid - 154.0, 165.0, 100.0),
|
||||
(Button::Conditional, mid - 50.0, 165.0, 100.0),
|
||||
(Button::Auto, mid + 54.0, 165.0, 100.0),
|
||||
let mut out = vec![(Button::Output, mid + 130.0, previous_y(cart), 20.0)];
|
||||
if !cart {
|
||||
out.extend([
|
||||
(Button::Mode, mid - 154.0, 165.0, 100.0),
|
||||
(Button::Conditional, mid - 50.0, 165.0, 100.0),
|
||||
(Button::Auto, mid + 54.0, 165.0, 100.0),
|
||||
]);
|
||||
}
|
||||
out.extend([
|
||||
(Button::Done, mid - 154.0, low, 150.0),
|
||||
(Button::Cancel, mid + 4.0, low, 150.0),
|
||||
]
|
||||
]);
|
||||
out
|
||||
}
|
||||
fn label(&self, b: Button) -> Text {
|
||||
let tr = |key: &str| Text::translate(key, &[]);
|
||||
@@ -224,6 +238,14 @@ impl Game<'_> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The `BaseCommandBlock` an edit screen shows: the block entity's or the
|
||||
/// minecart's.
|
||||
fn edit_base(&self, s: &CommandBlockScreen) -> Option<&CommandBlock> {
|
||||
match s.cart {
|
||||
Some(id) => self.carts.iter().find(|c| c.id == id).map(|c| &c.command),
|
||||
None => self.command_block(s.pos),
|
||||
}
|
||||
}
|
||||
fn block_state_at(&self, pos: Pos) -> Option<&'static BlockState> {
|
||||
let registry = self.pack.assets.registry;
|
||||
state_of(registry, self.world.block(pos.0, pos.1, pos.2))
|
||||
@@ -246,7 +268,11 @@ impl Game<'_> {
|
||||
/// edit screen shows refreshes its fields. The mode and conditional live in
|
||||
/// the block state here (vanilla's `CommandBlockEntity.Mode`).
|
||||
pub(super) fn command_block_data_arrived(&mut self, pos: Pos) {
|
||||
if self.command_block_screen.as_ref().map(|s| s.pos) != Some(pos) {
|
||||
if self
|
||||
.command_block_screen
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.cart.is_some() || s.pos != pos)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let state = self.block_state_at(pos).map(|s| {
|
||||
@@ -425,17 +451,11 @@ impl Game<'_> {
|
||||
dimension: self.dim,
|
||||
level: None,
|
||||
block: Some(pos),
|
||||
cart: None,
|
||||
};
|
||||
// performPrefixedCommand: a leading slash is allowed.
|
||||
let line = command.strip_prefix('/').unwrap_or(&command).to_owned();
|
||||
let outer = self.cmds.results.replace(Vec::new());
|
||||
let level = std::mem::replace(&mut self.ui.registries.permission, 2);
|
||||
self.with_source(source, |g| g.run_command(&line));
|
||||
self.ui.registries.permission = level;
|
||||
let results = std::mem::replace(&mut self.cmds.results, outer).unwrap_or_default();
|
||||
let successes = results.iter().filter(|(success, _)| *success).count();
|
||||
let successes = self.run_as_game_master(&command, source);
|
||||
if let Some(c) = command_block_mut(&mut self.world, pos) {
|
||||
c.success_count = successes as i32;
|
||||
c.success_count = successes;
|
||||
}
|
||||
}
|
||||
if let Some(c) = command_block_mut(&mut self.world, pos) {
|
||||
@@ -447,6 +467,19 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `Commands.performPrefixedCommand` for a `BaseCommandBlock`: `command`
|
||||
/// from `source` at game master level, and how many of its results
|
||||
/// succeeded.
|
||||
pub(super) fn run_as_game_master(&mut self, command: &str, source: Source) -> i32 {
|
||||
// performPrefixedCommand: a leading slash is allowed.
|
||||
let line = command.strip_prefix('/').unwrap_or(command).to_owned();
|
||||
let outer = self.cmds.results.replace(Vec::new());
|
||||
let level = std::mem::replace(&mut self.ui.registries.permission, 2);
|
||||
self.with_source(source, |g| g.run_command(&line));
|
||||
self.ui.registries.permission = level;
|
||||
let results = std::mem::replace(&mut self.cmds.results, outer).unwrap_or_default();
|
||||
results.iter().filter(|(success, _)| *success).count() as i32
|
||||
}
|
||||
/// `CloseableCommandBlockSource.sendSystemMessage`: the block's last
|
||||
/// output, stamped with the time. Nothing when it doesn't track output
|
||||
/// (`CommandSource.NULL`).
|
||||
@@ -489,6 +522,18 @@ impl Game<'_> {
|
||||
let Some(state) = self.block_state_at(pos) else {
|
||||
return;
|
||||
};
|
||||
let mode = Mode::of_block(&state.name).unwrap_or(Mode::Redstone);
|
||||
self.open_edit_screen(pos, None, block, mode, conditional(state));
|
||||
}
|
||||
/// The screen over `block`: a command block's at `pos`, or a minecart's.
|
||||
pub(super) fn open_edit_screen(
|
||||
&mut self,
|
||||
pos: Pos,
|
||||
cart: Option<u32>,
|
||||
block: CommandBlock,
|
||||
mode: Mode,
|
||||
conditional: bool,
|
||||
) {
|
||||
let (w, h) = self.gui_size();
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
@@ -497,18 +542,19 @@ impl Game<'_> {
|
||||
};
|
||||
let mut command = ChatScreen::command_block(&m, &c, &block.command, w as i32, h as i32);
|
||||
command.auto_suggestions = self.settings.auto_suggestions;
|
||||
let mut previous = EditBox::new(0.0, 135.0, 276.0, 20.0);
|
||||
let mut previous = EditBox::new(0.0, previous_y(cart.is_some()), 276.0, 20.0);
|
||||
previous.max_length = 32500;
|
||||
previous.text_color = UNEDITABLE;
|
||||
previous.focused = false;
|
||||
let mut screen = CommandBlockScreen {
|
||||
pos,
|
||||
cart,
|
||||
command,
|
||||
opened: std::time::Instant::now(),
|
||||
previous,
|
||||
track_output: block.track_output,
|
||||
mode: Mode::of_block(&state.name).unwrap_or(Mode::Redstone),
|
||||
conditional: conditional(state),
|
||||
mode,
|
||||
conditional,
|
||||
auto: block.auto,
|
||||
};
|
||||
screen.show_previous(&block);
|
||||
@@ -525,8 +571,8 @@ impl Game<'_> {
|
||||
let Some(mut s) = self.command_block_screen.take() else {
|
||||
return;
|
||||
};
|
||||
// AbstractCommandBlockEditScreen.tick: the block is gone.
|
||||
if self.command_block(s.pos).is_none() {
|
||||
// AbstractCommandBlockEditScreen.tick: the block (or the cart) is gone.
|
||||
if self.edit_base(&s).is_none() {
|
||||
self.close_command_block();
|
||||
return;
|
||||
}
|
||||
@@ -556,11 +602,13 @@ impl Game<'_> {
|
||||
batch.text_styled(atlas, &label, mid - 149.0, 40.0, grey, true);
|
||||
if !s.previous.value.is_empty() {
|
||||
let label = Text::translate("advMode.previousOutput", &[]);
|
||||
batch.text_styled(atlas, &label, mid - 149.0, 125.0, grey, true);
|
||||
// 75 + 5 * 9 + 1 + getPreviousY() - 135, then the label's 4.
|
||||
let y = 125.0 + previous_y(s.cart.is_some()) - 135.0;
|
||||
batch.text_styled(atlas, &label, mid - 149.0, y, grey, true);
|
||||
s.previous.x = mid - 150.0;
|
||||
s.previous.draw(batch, atlas, None);
|
||||
}
|
||||
for (b, x, y, bw) in CommandBlockScreen::buttons(w, h) {
|
||||
for (b, x, y, bw) in CommandBlockScreen::buttons(w, h, s.cart.is_some()) {
|
||||
let hover = ui::hovered(self.cursor, x, y, bw, 20.0);
|
||||
ui::button(batch, atlas, x, y, bw, 20.0, &s.label(b), true, hover);
|
||||
}
|
||||
@@ -628,7 +676,7 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.map_or([1, 1], |r| [r.config.width, r.config.height]);
|
||||
let b = HudBatch::new(size);
|
||||
let hit = CommandBlockScreen::buttons(b.width, b.height)
|
||||
let hit = CommandBlockScreen::buttons(b.width, b.height, s.cart.is_some())
|
||||
.into_iter()
|
||||
.find(|(_, x, y, bw)| ui::hovered(self.cursor, *x, *y, *bw, 20.0))
|
||||
.map(|b| b.0);
|
||||
@@ -638,7 +686,7 @@ impl Game<'_> {
|
||||
match hit {
|
||||
Some(Button::Output) => {
|
||||
s.track_output = !s.track_output;
|
||||
if let Some(block) = self.command_block(s.pos) {
|
||||
if let Some(block) = self.edit_base(&s) {
|
||||
s.show_previous(block);
|
||||
}
|
||||
}
|
||||
@@ -747,6 +795,10 @@ impl Game<'_> {
|
||||
self.system_message(Text::translate("advMode.notAllowed", &[]));
|
||||
return;
|
||||
}
|
||||
if let Some(id) = s.cart {
|
||||
self.set_command_minecart(id, s);
|
||||
return;
|
||||
}
|
||||
let edit = CommandBlockEdit {
|
||||
pos: s.pos,
|
||||
command: s.command.value().to_owned(),
|
||||
@@ -887,3 +939,22 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_minecart_screen_has_no_mode_buttons_and_a_lower_output_row() {
|
||||
let cart = CommandBlockScreen::buttons(400.0, 240.0, true);
|
||||
let kinds: Vec<_> = cart.iter().map(|b| b.0).collect();
|
||||
assert!(kinds == [Button::Output, Button::Done, Button::Cancel]);
|
||||
// MinecartCommandBlockEditScreen.getPreviousY.
|
||||
assert_eq!(cart[0].2, 150.0);
|
||||
let block = CommandBlockScreen::buttons(400.0, 240.0, false);
|
||||
assert_eq!(block.len(), 6);
|
||||
assert_eq!(block[0].2, 135.0);
|
||||
// Done and Cancel sit at the same height on both.
|
||||
assert_eq!(cart[1].2, block[4].2);
|
||||
}
|
||||
}
|
||||
@@ -211,6 +211,15 @@ impl Game<'_> {
|
||||
"tick" => {
|
||||
self.chat_capture = Some(Vec::new());
|
||||
for _ in 0..op["n"].as_u64().unwrap_or(1) {
|
||||
// The oracle's profile resolver answers at once; the
|
||||
// game's asks the services (none here: they refuse at
|
||||
// once) on a thread, which a tick can outrun.
|
||||
let asked = Instant::now();
|
||||
while self.profile_fetches_pending()
|
||||
&& asked.elapsed() < Duration::from_secs(10)
|
||||
{
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
self.tick_server(false)?;
|
||||
}
|
||||
let messages = self.chat_capture.take().unwrap_or_default();
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! `MinecartCommandBlock`: its `BaseCommandBlock` run by a powered activator
|
||||
//! rail (`performCommand`, `CloseableCommandBlockSource`), the entity data
|
||||
//! that carries its command and last output to clients, and the edit screen's
|
||||
//! `ServerboundSetCommandMinecartPacket` with `handleSetCommandMinecart`.
|
||||
use super::{
|
||||
Game,
|
||||
command_blocks::CommandBlockScreen,
|
||||
commands::{Target, execute::Source},
|
||||
lan::LanHost,
|
||||
minecarts::{CartKind, Minecart},
|
||||
};
|
||||
use horizon_client::{
|
||||
command_block::Mode,
|
||||
text::{self, Text},
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
|
||||
impl Game<'_> {
|
||||
fn command_cart_mut(&mut self, id: u32) -> Option<&mut Minecart> {
|
||||
self.carts
|
||||
.iter_mut()
|
||||
.find(|c| c.id == id && c.kind == CartKind::CommandBlock)
|
||||
}
|
||||
/// `MinecartCommandBase.onUpdated`: the cart's command and last output go
|
||||
/// into its synched data (`DATA_ID_COMMAND_NAME`, `DATA_ID_LAST_OUTPUT`).
|
||||
fn cart_command_updated(&mut self, id: u32) {
|
||||
if let Some(c) = self.command_cart_mut(id) {
|
||||
c.synced_command = c.command.command.clone();
|
||||
c.synced_output = c.command.last_output_string();
|
||||
}
|
||||
}
|
||||
/// `BaseCommandBlock.performCommand` for the cart: false when it already
|
||||
/// ran this game tick.
|
||||
pub(super) fn perform_cart_command(&mut self, id: u32) -> bool {
|
||||
let game_time = self.world.game_time;
|
||||
let rules_work = self.ui.rules.bool("command_blocks_work");
|
||||
let Some(c) = self.command_cart_mut(id) else {
|
||||
return false;
|
||||
};
|
||||
if c.command.last_execution == game_time {
|
||||
return false;
|
||||
}
|
||||
if c.command.command.eq_ignore_ascii_case("Searge") {
|
||||
c.command.last_output = Some(Text::literal("#itzlipofutzli"));
|
||||
c.command.success_count = 1;
|
||||
return true;
|
||||
}
|
||||
c.command.success_count = 0;
|
||||
let command = c.command.command.clone();
|
||||
if rules_work && !command.is_empty() {
|
||||
c.command.last_output = None;
|
||||
// MinecartCommandBase.createCommandSourceStack: the cart's
|
||||
// position and rotation at game master level.
|
||||
let source = Source {
|
||||
entity: Some(Target::Cart(id)),
|
||||
pos: [c.position.x, c.position.y, c.position.z],
|
||||
rot: [c.yaw, 0.0],
|
||||
eyes: false,
|
||||
silent: false,
|
||||
store: None,
|
||||
dimension: self.dim,
|
||||
level: None,
|
||||
block: None,
|
||||
cart: Some(id),
|
||||
};
|
||||
let successes = self.run_as_game_master(&command, source);
|
||||
if let Some(c) = self.command_cart_mut(id) {
|
||||
c.command.success_count = successes;
|
||||
}
|
||||
}
|
||||
if let Some(c) = self.command_cart_mut(id) {
|
||||
c.command.last_execution = if c.command.update_last_execution {
|
||||
game_time
|
||||
} else {
|
||||
-1
|
||||
};
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `CloseableCommandBlockSource.sendSystemMessage` for the cart: its last
|
||||
/// output stamped with the time, then `onUpdated`. Nothing when it does
|
||||
/// not track output (`CommandSource.NULL`).
|
||||
pub(super) fn cart_command_output(&mut self, id: u32, message: Text) {
|
||||
let Some(c) = self.command_cart_mut(id) else {
|
||||
return;
|
||||
};
|
||||
if c.command.track_output {
|
||||
let time = chrono::Local::now().format("[%H:%M:%S] ").to_string();
|
||||
c.command.last_output = Some(Text::literal(time).append(message));
|
||||
self.cart_command_updated(id);
|
||||
}
|
||||
}
|
||||
/// `CommandSourceStack.sendSuccess` from a command minecart: its output
|
||||
/// when `send_command_feedback`, and `broadcastToAdmins` when
|
||||
/// `command_block_output`.
|
||||
pub(super) fn cart_command_success(&mut self, id: u32, message: Text) {
|
||||
let Some(name) = self
|
||||
.command_cart_mut(id)
|
||||
.filter(|c| c.command.track_output)
|
||||
.map(|c| c.command.name())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let feedback = self.ui.rules.bool("send_command_feedback");
|
||||
if feedback {
|
||||
self.cart_command_output(id, message.clone());
|
||||
}
|
||||
if feedback && self.ui.rules.bool("command_block_output") {
|
||||
let broadcast = Text::translate("chat.type.admin", &[name, message])
|
||||
.with_color(text::GRAY)
|
||||
.italic();
|
||||
self.system_message(broadcast);
|
||||
}
|
||||
}
|
||||
|
||||
/// `LocalPlayer.openMinecartCommandBlock`: the editor over the cart's
|
||||
/// command (`MinecartCommandBlockEditScreen`).
|
||||
pub(super) fn open_cart_command_block(&mut self, id: u32) {
|
||||
let Some(block) = self
|
||||
.carts
|
||||
.iter()
|
||||
.find(|c| c.id == id && c.kind == CartKind::CommandBlock)
|
||||
.map(|c| c.command.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.open_edit_screen((0, 0, 0), Some(id), block, Mode::Redstone, false);
|
||||
}
|
||||
/// The edit screen's Done for a cart (`populateAndSendPacket`, then
|
||||
/// `onDone` clears the last output of a cart that no longer tracks it): a
|
||||
/// server's client sends the packet, the window's own world applies it.
|
||||
pub(super) fn set_command_minecart(&mut self, id: u32, s: &CommandBlockScreen) {
|
||||
if !self.can_use_game_master_blocks() {
|
||||
self.system_message(Text::translate("advMode.notAllowed", &[]));
|
||||
return;
|
||||
}
|
||||
let command = s.command.value().to_owned();
|
||||
let track_output = s.track_output;
|
||||
// The output button set the cart's own flag as it was pressed.
|
||||
if let Some(c) = self.command_cart_mut(id) {
|
||||
c.command.track_output = track_output;
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
self.send(S::SetCommandMinecart(play::SetCommandMinecart {
|
||||
entity: id as i32,
|
||||
command,
|
||||
track_output,
|
||||
}));
|
||||
} else {
|
||||
self.apply_cart_command(id, command, track_output);
|
||||
}
|
||||
if !track_output && let Some(c) = self.command_cart_mut(id) {
|
||||
c.command.last_output = None;
|
||||
}
|
||||
}
|
||||
/// `ServerGamePacketListenerImpl.handleSetCommandMinecart` for a guest.
|
||||
pub(super) fn lan_set_command_minecart(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
p: play::SetCommandMinecart,
|
||||
) {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return;
|
||||
};
|
||||
let allowed = g.creative() && g.permission >= 2;
|
||||
self.as_guest(host, conn, |game| {
|
||||
if allowed {
|
||||
game.apply_cart_command(p.entity as u32, p.command, p.track_output);
|
||||
} else {
|
||||
game.system_message(Text::translate("advMode.notAllowed", &[]));
|
||||
}
|
||||
});
|
||||
}
|
||||
/// What `handleSetCommandMinecart` does once the player may edit: the
|
||||
/// command (which resets the success count), output tracking, the cleared
|
||||
/// last output, `onUpdated` while command blocks work, and the chat note.
|
||||
pub(super) fn apply_cart_command(&mut self, id: u32, command: String, track_output: bool) {
|
||||
let enabled = self.ui.rules.bool("command_blocks_work");
|
||||
let Some(c) = self.command_cart_mut(id) else {
|
||||
return;
|
||||
};
|
||||
c.command.set_command(&command);
|
||||
c.command.track_output = track_output;
|
||||
if !track_output {
|
||||
c.command.last_output = None;
|
||||
}
|
||||
if enabled {
|
||||
self.cart_command_updated(id);
|
||||
}
|
||||
if !command.is_empty() {
|
||||
let key = if enabled {
|
||||
"advMode.setCommand.success"
|
||||
} else {
|
||||
"advMode.setCommand.disabled"
|
||||
};
|
||||
self.system_message(Text::translate(key, &[Text::literal(command)]));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,10 @@ mod misc;
|
||||
mod place;
|
||||
mod players;
|
||||
pub(super) mod pregen;
|
||||
mod profiles;
|
||||
mod raid;
|
||||
mod terrain_cmds;
|
||||
mod test_runs;
|
||||
mod text_commands;
|
||||
mod world_cmds;
|
||||
|
||||
@@ -113,6 +115,25 @@ fn wrap_degrees(angle: f32) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The network id of a target that is no player: its entity id.
|
||||
fn non_player_entity_id(t: Target) -> Option<i32> {
|
||||
let (Target::Mob(id)
|
||||
| Target::Item(id)
|
||||
| Target::Orb(id)
|
||||
| Target::Projectile(id)
|
||||
| Target::Stand(id)
|
||||
| Target::Frame(id)
|
||||
| Target::Painting(id)
|
||||
| Target::Boat(id)
|
||||
| Target::Cart(id)
|
||||
| Target::Other(id)
|
||||
| Target::Crystal(id)
|
||||
| Target::Dragon(id)) = t
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
i32::try_from(id).ok()
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// Commands.performCommand for a line without its slash.
|
||||
pub(super) fn run_command(&mut self, command: &str) -> bool {
|
||||
@@ -201,8 +222,30 @@ impl Game<'_> {
|
||||
self.command_block_output(block, message);
|
||||
return;
|
||||
}
|
||||
if let Some(cart) = self.command_source.and_then(|s| s.cart) {
|
||||
self.cart_command_output(cart, message);
|
||||
return;
|
||||
}
|
||||
self.system_message(message);
|
||||
}
|
||||
/// `sendSuccess(message, false)`: the source is told (when it takes
|
||||
/// success feedback), the operators are not.
|
||||
pub(super) fn feedback_unbroadcast(&mut self, message: Text) {
|
||||
if self.command_source.is_some_and(|s| s.silent) {
|
||||
return;
|
||||
}
|
||||
if let Some(block) = self.command_source.and_then(|s| s.block) {
|
||||
self.command_block_success(block, message);
|
||||
return;
|
||||
}
|
||||
if let Some(cart) = self.command_source.and_then(|s| s.cart) {
|
||||
self.cart_command_success(cart, message);
|
||||
return;
|
||||
}
|
||||
if self.ui.rules.bool("send_command_feedback") {
|
||||
self.system_message(message);
|
||||
}
|
||||
}
|
||||
/// sendSuccess honouring `send_command_feedback`.
|
||||
pub(super) fn feedback(&mut self, message: Text) {
|
||||
if self.command_source.is_some_and(|s| s.silent) {
|
||||
@@ -212,6 +255,10 @@ impl Game<'_> {
|
||||
self.command_block_success(block, message);
|
||||
return;
|
||||
}
|
||||
if let Some(cart) = self.command_source.and_then(|s| s.cart) {
|
||||
self.cart_command_success(cart, message);
|
||||
return;
|
||||
}
|
||||
if self.ui.rules.bool("send_command_feedback") {
|
||||
self.system_message(message.clone());
|
||||
}
|
||||
@@ -923,6 +970,8 @@ impl Game<'_> {
|
||||
id if id.starts_with("dialog.") => self.cmd_dialog(id, ctx),
|
||||
id if id.starts_with("posteffect.") => self.cmd_posteffect(id, ctx),
|
||||
"kick" => self.cmd_kick(ctx),
|
||||
"op" | "deop" | "ban" | "pardon" => self.cmd_profiles(id, ctx),
|
||||
id if id.starts_with("whitelist.") => self.cmd_whitelist(id, ctx),
|
||||
"fetchprofile" => self.cmd_fetchprofile(ctx),
|
||||
"publish" => self.cmd_publish(ctx),
|
||||
"unpublish" => self.cmd_unpublish(),
|
||||
@@ -1482,6 +1531,54 @@ impl Game<'_> {
|
||||
});
|
||||
true
|
||||
}
|
||||
/// `ServerPlayer.lookAt`: besides turning the player, tell their client
|
||||
/// with a `ClientboundPlayerLookAtPacket` (`from` anchor, the point, and
|
||||
/// the entity with its anchor when the target is one). Only a connected
|
||||
/// player has a client to tell. The entity form is sent whenever the
|
||||
/// target is an entity with a network id (`LookAt.LookAtEntity.perform`);
|
||||
/// a player whose id is not known here (a guest's turn, with the host
|
||||
/// out of reach) falls back to the point, which a client takes the same
|
||||
/// unless the entity moves first.
|
||||
/// `Entity.getId` as the players' clients know it: a player's is the one the
|
||||
/// hub gave it, any other entity's is its own id.
|
||||
pub(super) fn network_entity_id(&self, t: Target) -> Option<i32> {
|
||||
let host = self.lan.as_ref();
|
||||
match t {
|
||||
Target::Player => match self.lan_acting {
|
||||
Some(conn) => host?.hub.guest(conn).map(|g| g.entity_id),
|
||||
None => host.map(|h| h.host_entity),
|
||||
},
|
||||
Target::Guest(conn) => host?.hub.guest(conn).map(|g| g.entity_id),
|
||||
other => non_player_entity_id(other),
|
||||
}
|
||||
}
|
||||
pub(super) fn send_player_look_at(
|
||||
&mut self,
|
||||
t: Target,
|
||||
from_eyes: bool,
|
||||
target: [f64; 3],
|
||||
entity: Option<(Target, bool)>,
|
||||
) {
|
||||
let packet = C::PlayerLookAt(play::PlayerLookAt {
|
||||
from: i32::from(from_eyes),
|
||||
target,
|
||||
entity: entity.and_then(|(e, eyes)| {
|
||||
Some(play::LookAtEntity {
|
||||
id: self.network_entity_id(e)?,
|
||||
anchor: i32::from(eyes),
|
||||
})
|
||||
}),
|
||||
});
|
||||
match t {
|
||||
Target::Player if self.lan_acting.is_some() => self.guest_packets.push(packet),
|
||||
Target::Guest(conn) => {
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn cmd_teleport(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
let targets = if id.starts_with("teleport.targets") {
|
||||
self.entities(ctx, "targets", false)?
|
||||
@@ -1540,12 +1637,14 @@ impl Game<'_> {
|
||||
};
|
||||
None
|
||||
});
|
||||
let mut facing_entity = None;
|
||||
let facing = match (facing, ctx.get("facingEntity")) {
|
||||
(Some(f), _) => Some(f),
|
||||
(None, Some(_)) => {
|
||||
let e = self.entities(ctx, "facingEntity", false)?[0];
|
||||
// EntityAnchorArgument.Anchor.apply: the feet, or the eyes.
|
||||
let eyes = matches!(ctx.get("facingAnchor"), Some(Arg::Anchor(true)));
|
||||
facing_entity = Some((e, eyes));
|
||||
let eye_height = if eyes { self.target_eye_height(e) } else { 0.0 };
|
||||
self.target_position(e)
|
||||
.map(|p| [p[0], p[1] + eye_height, p[2]])
|
||||
@@ -1588,6 +1687,10 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.teleport_to(t, pos, rotation);
|
||||
}
|
||||
// LookAt.perform after a successful teleportTo.
|
||||
if let Some(f) = facing {
|
||||
self.send_player_look_at(t, anchored_eyes, f, facing_entity);
|
||||
}
|
||||
}
|
||||
let [x, y, z] = pos.map(|v| Text::literal(format_double(v)));
|
||||
let message = if targets.len() == 1 {
|
||||
@@ -1830,7 +1933,13 @@ impl Game<'_> {
|
||||
Target::Stand(id) => self.armor_stands.retain(|a| a.id != id),
|
||||
Target::Frame(id) => self.item_frames.retain(|f| f.id != id),
|
||||
Target::Painting(id) => self.paintings.retain(|p| p.id != id),
|
||||
Target::Boat(id) => self.boats.retain(|b| b.id != id),
|
||||
Target::Boat(id) => {
|
||||
// AbstractBoat.remove: a killed boat drops its lead.
|
||||
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
let b = self.boats.remove(i);
|
||||
self.boat_removed(&b);
|
||||
}
|
||||
}
|
||||
Target::Cart(id) => self.carts.retain(|c| c.id != id),
|
||||
Target::Other(id) => self.others.retain(|o| o.id != id),
|
||||
Target::Crystal(id) => self.kill_crystal(id),
|
||||
@@ -2084,7 +2193,7 @@ impl Game<'_> {
|
||||
let landed = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if old != new
|
||||
&& self.res.facts().get(landed).name == new
|
||||
&& let Some(entity) = horizon_client::containers::BlockEntity::for_block(&new)
|
||||
&& let Some(entity) = self.new_block_entity(&new)
|
||||
{
|
||||
self.world.block_entities.insert(key, entity);
|
||||
}
|
||||
@@ -3388,3 +3497,28 @@ fn split_top_level(s: &str, sep: char) -> Vec<&str> {
|
||||
out.push(&s[start..]);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod look_at_tests {
|
||||
use super::{Target, non_player_entity_id};
|
||||
|
||||
#[test]
|
||||
fn look_at_entity_form_names_any_entity_by_its_id() {
|
||||
for t in [
|
||||
Target::Mob(7),
|
||||
Target::Item(7),
|
||||
Target::Orb(7),
|
||||
Target::Projectile(7),
|
||||
Target::Boat(7),
|
||||
Target::Cart(7),
|
||||
Target::Crystal(7),
|
||||
Target::Dragon(7),
|
||||
] {
|
||||
assert_eq!(non_player_entity_id(t), Some(7), "{t:?}");
|
||||
}
|
||||
// Players are the hub's to name; a dragon part's id is no entity id.
|
||||
assert_eq!(non_player_entity_id(Target::Player), None);
|
||||
assert_eq!(non_player_entity_id(Target::Guest(3)), None);
|
||||
assert_eq!(non_player_entity_id(Target::Mob(u32::MAX - 4096)), None);
|
||||
}
|
||||
}
|
||||
@@ -703,7 +703,7 @@ impl Game<'_> {
|
||||
|
||||
/// Spawn an entity from its compound (`EntityType.loadEntityRecursive`);
|
||||
/// returns what was created.
|
||||
pub(super) fn spawn_entity_from_nbt(&mut self, tag: Tag) -> Option<Target> {
|
||||
pub(in crate::game) fn spawn_entity_from_nbt(&mut self, tag: Tag) -> Option<Target> {
|
||||
let key = {
|
||||
let p = entity_nbt::position(&tag)?;
|
||||
((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4)
|
||||
|
||||
@@ -32,6 +32,8 @@ pub(in crate::game) struct Source {
|
||||
/// The command block whose output this source is
|
||||
/// (`BaseCommandBlock.CloseableCommandBlockSource`).
|
||||
pub block: Option<(i32, i32, i32)>,
|
||||
/// The command block minecart whose output this source is.
|
||||
pub cart: Option<u32>,
|
||||
}
|
||||
|
||||
/// Where `execute store` writes.
|
||||
@@ -94,6 +96,7 @@ impl Game<'_> {
|
||||
dimension: self.dim,
|
||||
level: None,
|
||||
block: None,
|
||||
cart: None,
|
||||
}
|
||||
}
|
||||
/// The server as a source (function ticks and schedules): the world spawn.
|
||||
@@ -108,9 +111,10 @@ impl Game<'_> {
|
||||
dimension: self.dim,
|
||||
level: None,
|
||||
block: None,
|
||||
cart: None,
|
||||
}
|
||||
}
|
||||
pub(super) fn target_rotation(&self, t: Target) -> [f32; 2] {
|
||||
pub(in crate::game) fn target_rotation(&self, t: Target) -> [f32; 2] {
|
||||
match t {
|
||||
Target::Player => [self.yaw, self.pitch],
|
||||
Target::Guest(conn) => self
|
||||
@@ -674,7 +678,8 @@ impl Game<'_> {
|
||||
self.target_by_uuid(horizon_client::lan::playerdata::ints_uuid(uuid))
|
||||
}
|
||||
horizon_client::mobs::leash::Holder::Mob(i) => Some(Target::Mob(i)),
|
||||
horizon_client::mobs::leash::Holder::Knot(_) => None,
|
||||
horizon_client::mobs::leash::Holder::Boat(_)
|
||||
| horizon_client::mobs::leash::Holder::Knot(_) => None,
|
||||
})
|
||||
.into_iter()
|
||||
.collect(),
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
use super::{Arg, Context, Game, R, Target, err};
|
||||
use horizon_client::{
|
||||
command::CommandError,
|
||||
text::{self, Style, Text},
|
||||
skins::{NameOrId, Resolve, ResolvedProfile},
|
||||
snbt,
|
||||
text::{self, ClickEvent, InlineObject, Style, Text},
|
||||
};
|
||||
use horizon_protocol::packets::{
|
||||
Empty,
|
||||
play::{self, Clientbound as C, DialogHolder},
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::{collections::HashMap, time::Instant};
|
||||
|
||||
/// Waypoint.Icon: the locator bar style and colour of a transmitter.
|
||||
@@ -29,11 +32,95 @@ impl Default for WaypointIcon {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `/fetchprofile` lookup in flight: what it resolves, the message ids it
|
||||
/// answers with (`commands.fetchprofile.<kind>.success|failure`), the
|
||||
/// argument they show, and the source to answer.
|
||||
pub(in crate::game) struct ProfileFetch {
|
||||
key: NameOrId,
|
||||
kind: &'static str,
|
||||
argument: Text,
|
||||
source: Option<super::execute::Source>,
|
||||
}
|
||||
|
||||
/// `ResolvableProfile.CODEC` of a resolved profile as NBT (`id` as an int
|
||||
/// array, `name`, and `properties` only when there are some, each with its
|
||||
/// `signature` only when signed), and the same as the JSON a head object
|
||||
/// draws from.
|
||||
fn encode_profile(profile: &ResolvedProfile) -> (Tag, serde_json::Value) {
|
||||
let ints = [
|
||||
(profile.id >> 96) as i32,
|
||||
(profile.id >> 64) as i32,
|
||||
(profile.id >> 32) as i32,
|
||||
profile.id as i32,
|
||||
];
|
||||
let mut fields = vec![
|
||||
("id", Tag::IntArray(ints.to_vec())),
|
||||
("name", Tag::String(profile.name.as_str().into())),
|
||||
];
|
||||
let mut json = serde_json::json!({ "id": ints, "name": profile.name });
|
||||
if !profile.properties.is_empty() {
|
||||
let mut list = Vec::new();
|
||||
let mut json_list = Vec::new();
|
||||
for (name, value, signature) in &profile.properties {
|
||||
let mut entry = vec![
|
||||
("name", Tag::String(name.as_str().into())),
|
||||
("value", Tag::String(value.as_str().into())),
|
||||
];
|
||||
let mut j = serde_json::json!({ "name": name, "value": value });
|
||||
if let Some(signature) = signature {
|
||||
entry.push(("signature", Tag::String(signature.as_str().into())));
|
||||
j["signature"] = signature.clone().into();
|
||||
}
|
||||
list.push(Tag::compound(entry));
|
||||
json_list.push(j);
|
||||
}
|
||||
fields.push(("properties", Tag::List(list)));
|
||||
json["properties"] = json_list.into();
|
||||
}
|
||||
(Tag::compound(fields), json)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod fetchprofile_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn unsigned_profile_without_properties_omits_them() {
|
||||
let profile = ResolvedProfile {
|
||||
id: 0x0000_0001_0000_0002_0000_0003_0000_0004,
|
||||
name: "Steve".into(),
|
||||
properties: Vec::new(),
|
||||
};
|
||||
let (tag, json) = encode_profile(&profile);
|
||||
assert_eq!(snbt::to_snbt(&tag), "{id:[I;1,2,3,4],name:\"Steve\"}");
|
||||
assert!(json.get("properties").is_none());
|
||||
}
|
||||
#[test]
|
||||
fn properties_keep_the_signature_only_when_signed() {
|
||||
let profile = ResolvedProfile {
|
||||
id: 1,
|
||||
name: "Alex".into(),
|
||||
properties: vec![
|
||||
("textures".into(), "v".into(), Some("s".into())),
|
||||
("other".into(), "w".into(), None),
|
||||
],
|
||||
};
|
||||
let (tag, _) = encode_profile(&profile);
|
||||
assert_eq!(
|
||||
snbt::to_snbt(&tag),
|
||||
"{id:[I;0,0,0,1],name:\"Alex\",properties:[{name:\"textures\",signature:\"s\",value:\"v\"},{name:\"other\",value:\"w\"}]}"
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Server-side player and server state these commands keep.
|
||||
#[derive(Default)]
|
||||
pub(in crate::game) struct CommandExtras {
|
||||
/// ServerPlayer.camera: the entity `/spectate` attached the view to.
|
||||
pub camera: Option<Target>,
|
||||
/// The cameras of the guests of a server, by connection
|
||||
/// (game/lan_camera.rs).
|
||||
pub guest_cameras: HashMap<u32, super::super::lan_camera::GuestCamera>,
|
||||
/// `/spectate` for a guest: connection and the camera's network id
|
||||
/// (None to stop), taken in the guests' tick.
|
||||
pub camera_requests: Vec<(u32, Option<i32>)>,
|
||||
/// ServerPlayer.postEffects, in insertion order.
|
||||
pub post_effects: Vec<String>,
|
||||
/// MinecraftServer's tick profiler (`/debug start`): when it started and
|
||||
@@ -41,8 +128,8 @@ pub(in crate::game) struct CommandExtras {
|
||||
pub profiler: Option<(Instant, u64)>,
|
||||
/// `debugCommandProfilerDelayStart`: the profiler starts with the next tick.
|
||||
pub profiler_delay: bool,
|
||||
/// Messages the server sends on a later tick (profile lookups).
|
||||
pub pending: Vec<Text>,
|
||||
/// `/fetchprofile name|id` lookups still running.
|
||||
pub profile_fetches: Vec<ProfileFetch>,
|
||||
/// The `/jfr start` recording: when it started and the tick count then.
|
||||
pub jfr: Option<(Instant, u64, String)>,
|
||||
/// Waypoint icons changed by `/waypoint modify`.
|
||||
@@ -101,7 +188,7 @@ fn civil(secs: i64) -> (i64, i64, i64, i64, i64, i64) {
|
||||
|
||||
impl Game<'_> {
|
||||
/// CommandSourceStack.getPlayerOrException.
|
||||
fn source_player(&self) -> R<Target> {
|
||||
pub(super) fn source_player(&self) -> R<Target> {
|
||||
match self.command_source.map(|s| s.entity) {
|
||||
None | Some(Some(Target::Player)) => Ok(Target::Player),
|
||||
_ => Err(err("permissions.requires.player", &[])),
|
||||
@@ -144,7 +231,19 @@ impl Game<'_> {
|
||||
&[self.target_name(t)],
|
||||
));
|
||||
}
|
||||
self.set_camera(target);
|
||||
// A guest's camera is the hub's: the view moves in the guests' tick.
|
||||
let guest = match player {
|
||||
Target::Player => self.lan_acting,
|
||||
Target::Guest(conn) => Some(conn),
|
||||
_ => None,
|
||||
};
|
||||
match guest {
|
||||
Some(conn) => {
|
||||
let camera = target.and_then(|t| self.network_entity_id(t));
|
||||
self.cmds.camera_requests.push((conn, camera));
|
||||
}
|
||||
None => self.set_camera(target),
|
||||
}
|
||||
match target {
|
||||
Some(t) => {
|
||||
let name = self.target_name(t);
|
||||
@@ -179,9 +278,7 @@ impl Game<'_> {
|
||||
self.cmds.profiler_delay = false;
|
||||
self.cmds.profiler = Some((Instant::now(), self.ticks));
|
||||
}
|
||||
for message in std::mem::take(&mut self.cmds.pending) {
|
||||
self.system_message(message);
|
||||
}
|
||||
self.tick_profile_fetches();
|
||||
self.tick_camera();
|
||||
self.tick_waypoints();
|
||||
}
|
||||
@@ -458,11 +555,53 @@ impl Game<'_> {
|
||||
|
||||
// ---- /posteffect ----------------------------------------------------
|
||||
|
||||
/// `ServerPlayer.postEffects` of a player target: a connected player's
|
||||
/// (told to their client at the end of their tick), or the host's own.
|
||||
fn post_effects_of(&self, t: Target) -> Vec<String> {
|
||||
let conn = match t {
|
||||
Target::Guest(conn) => Some(conn),
|
||||
Target::Player => self.lan_acting,
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
match conn {
|
||||
Some(conn) => self
|
||||
.command_guest(conn)
|
||||
.map(|g| g.post_effects.clone())
|
||||
.unwrap_or_default(),
|
||||
None => self.cmds.post_effects.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes a player target's `postEffects`; whether `change` did, which
|
||||
/// sets `postEffectsDirty` for the packet.
|
||||
fn change_post_effects(
|
||||
&mut self,
|
||||
t: Target,
|
||||
change: impl FnOnce(&mut Vec<String>) -> bool,
|
||||
) -> bool {
|
||||
let conn = match t {
|
||||
Target::Guest(conn) => Some(conn),
|
||||
Target::Player => self.lan_acting,
|
||||
_ => return false,
|
||||
};
|
||||
match conn {
|
||||
Some(conn) => {
|
||||
let Some(g) = self.lan.as_mut().and_then(|h| h.hub.guest_mut(conn)) else {
|
||||
return false;
|
||||
};
|
||||
let changed = change(&mut g.post_effects);
|
||||
g.post_effects_dirty |= changed;
|
||||
changed
|
||||
}
|
||||
None => change(&mut self.cmds.post_effects),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn cmd_posteffect(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
if id == "posteffect.list" {
|
||||
let t = self.one_entity(ctx, "target", true)?;
|
||||
let name = self.target_name(t);
|
||||
let effects = self.cmds.post_effects.clone();
|
||||
let effects = self.post_effects_of(t);
|
||||
if effects.is_empty() {
|
||||
self.feedback(Text::translate("commands.posteffect.list.empty", &[name]));
|
||||
} else {
|
||||
@@ -480,24 +619,27 @@ impl Game<'_> {
|
||||
};
|
||||
let mut changed: Vec<Target> = Vec::new();
|
||||
for t in &targets {
|
||||
let done = match id {
|
||||
let done = self.change_post_effects(*t, |list| match id {
|
||||
// ServerPlayer.addPostEffect.
|
||||
"posteffect.add" => {
|
||||
if self.cmds.post_effects.contains(&effect) {
|
||||
if list.contains(&effect) {
|
||||
false
|
||||
} else {
|
||||
self.cmds.post_effects.push(effect.clone());
|
||||
list.push(effect.clone());
|
||||
true
|
||||
}
|
||||
}
|
||||
"posteffect.remove" => {
|
||||
let before = self.cmds.post_effects.len();
|
||||
if let Some(i) = self.cmds.post_effects.iter().position(|e| *e == effect) {
|
||||
self.cmds.post_effects.remove(i);
|
||||
// ServerPlayer.removePostEffect.
|
||||
"posteffect.remove" => match list.iter().position(|e| *e == effect) {
|
||||
Some(i) => {
|
||||
list.remove(i);
|
||||
true
|
||||
}
|
||||
before != self.cmds.post_effects.len()
|
||||
}
|
||||
_ => !std::mem::take(&mut self.cmds.post_effects).is_empty(),
|
||||
};
|
||||
None => false,
|
||||
},
|
||||
// ServerPlayer.clearPostEffects.
|
||||
_ => !std::mem::take(list).is_empty(),
|
||||
});
|
||||
if done {
|
||||
changed.push(*t);
|
||||
}
|
||||
@@ -531,32 +673,16 @@ impl Game<'_> {
|
||||
|
||||
// ---- /test ----------------------------------------------------------
|
||||
|
||||
/// `TestCommand`. Horizon has no game test instances or test runner: every
|
||||
/// finder comes back empty, as in a world without test instance blocks.
|
||||
/// `TestCommand`: running, resetting and clearing tests is
|
||||
/// `test_runs`; `verify`, `create`, `locate` and `pos` are not ported and
|
||||
/// answer as in a world without test instance blocks.
|
||||
pub(super) fn cmd_test(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
match id {
|
||||
// run <tests>, runmultiple, verify: GameTestRunner.
|
||||
"test.run" | "test.verify" | "test.create" => Err(err("command.failed", &[])),
|
||||
// runthese/runclosest/runthat/runfailed: TestFinder finds nothing.
|
||||
"test.run.found" => {
|
||||
if ctx.literals.iter().any(|l| l == "runthat") {
|
||||
self.source_player()?;
|
||||
}
|
||||
self.feedback(Text::translate("commands.test.no_tests", &[]));
|
||||
Ok(0)
|
||||
"test.run" | "test.run.found" | "test.clear" | "test.stop" => {
|
||||
self.cmd_test_runs(id, ctx)
|
||||
}
|
||||
// reset*/clear*: TestCommand.reset throws CLEAR_NO_TESTS too.
|
||||
"test.clear" => {
|
||||
if ctx
|
||||
.literals
|
||||
.iter()
|
||||
.any(|l| l == "resetthat" || l == "clearthat")
|
||||
{
|
||||
self.source_player()?;
|
||||
}
|
||||
Err(err("commands.test.clear.error.no_tests", &[]))
|
||||
}
|
||||
"test.stop" => Ok(1),
|
||||
// verify, create: not ported.
|
||||
"test.verify" | "test.create" => Err(err("command.failed", &[])),
|
||||
"test.locate" => {
|
||||
// CommandSourceStack.sendSystemMessage: no feedback rule.
|
||||
if !self.command_source.is_some_and(|s| s.silent) {
|
||||
@@ -680,31 +806,37 @@ impl Game<'_> {
|
||||
|
||||
// ---- /fetchprofile --------------------------------------------------
|
||||
|
||||
/// `FetchProfileCommand`: Horizon resolves no profiles over the network,
|
||||
/// so name and id lookups fail like an unreachable profile service; an
|
||||
/// entity with a profile (the player) prints its resolved profile.
|
||||
/// `FetchProfileCommand.resolveName` and `resolveId`: the lookup runs on
|
||||
/// background threads (`Util.nonCriticalIoPool`, here the cached profile
|
||||
/// resolver's), and the answer is reported by a later tick;
|
||||
/// `printForEntity` reports the player's own profile at once.
|
||||
pub(super) fn cmd_fetchprofile(&mut self, ctx: &Context) -> R<i32> {
|
||||
if ctx.literals.iter().any(|l| l == "name") {
|
||||
let name = match ctx.get("name") {
|
||||
Some(Arg::Str(s)) => s.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
// Resolved off-thread; the answer arrives on a later tick.
|
||||
self.cmds.pending.push(
|
||||
Text::translate("commands.fetchprofile.name.failure", &[Text::literal(name)])
|
||||
.with_color(text::RED),
|
||||
);
|
||||
self.cmds.profile_fetches.push(ProfileFetch {
|
||||
key: NameOrId::Name(name.clone()),
|
||||
kind: "name",
|
||||
argument: Text::literal(name),
|
||||
source: self.command_source,
|
||||
});
|
||||
return Ok(1);
|
||||
}
|
||||
if ctx.literals.iter().any(|l| l == "id") {
|
||||
let id = match ctx.get("id") {
|
||||
Some(Arg::Str(s)) => s.to_ascii_lowercase(),
|
||||
_ => String::new(),
|
||||
};
|
||||
self.cmds.pending.push(
|
||||
Text::translate("commands.fetchprofile.id.failure", &[Text::literal(id)])
|
||||
.with_color(text::RED),
|
||||
);
|
||||
Some(Arg::Str(s)) => horizon_client::command::selector::java_uuid(s),
|
||||
_ => None,
|
||||
}
|
||||
.unwrap_or_default();
|
||||
// `Component.translationArg(id)`: the UUID's string form.
|
||||
self.cmds.profile_fetches.push(ProfileFetch {
|
||||
key: NameOrId::Id(id),
|
||||
kind: "id",
|
||||
argument: Text::literal(horizon_protocol::crypto::uuid_string(id)),
|
||||
source: self.command_source,
|
||||
});
|
||||
return Ok(1);
|
||||
}
|
||||
let t = self.one_entity(ctx, "entity", false)?;
|
||||
@@ -714,57 +846,116 @@ impl Game<'_> {
|
||||
&[self.target_name(t)],
|
||||
));
|
||||
}
|
||||
let profile = self.player_profile_snbt();
|
||||
let [a, b, c, d] = self.level.player_uuid.unwrap_or([0; 4]);
|
||||
let id = (u128::from(a as u32) << 96)
|
||||
| (u128::from(b as u32) << 64)
|
||||
| (u128::from(c as u32) << 32)
|
||||
| u128::from(d as u32);
|
||||
let name = super::super::chat::PLAYER_NAME;
|
||||
let head = format!("{{type:\"minecraft:player\",player:{profile}}}");
|
||||
let item = |key: &str, args: &[Text], run: Option<String>| {
|
||||
let inner = Text::translate(key, args).with_color(text::GREEN);
|
||||
let mut t = Text::translate("chat.square_brackets", &[inner]);
|
||||
if let Some(cmd) = run {
|
||||
t = t.with_style(&Style {
|
||||
click: None,
|
||||
self.report_resolved_profile(
|
||||
"commands.fetchprofile.entity.success",
|
||||
Text::literal(name),
|
||||
&ResolvedProfile {
|
||||
id,
|
||||
name: name.to_owned(),
|
||||
properties: Vec::new(),
|
||||
},
|
||||
);
|
||||
Ok(1)
|
||||
}
|
||||
/// The answers of the profile lookups that have come in
|
||||
/// (`server.execute(() -> result.ifPresentOrElse(...))`).
|
||||
/// Whether a `/fetchprofile` lookup is still in flight (the command
|
||||
/// harness waits for them, as vanilla's oracle resolves at once).
|
||||
pub(in crate::game) fn profile_fetches_pending(&mut self) -> bool {
|
||||
let mut resolver = self.skin_profiles.borrow_mut();
|
||||
self.cmds
|
||||
.profile_fetches
|
||||
.iter()
|
||||
.any(|f| matches!(resolver.state(&f.key), Resolve::Pending))
|
||||
}
|
||||
fn tick_profile_fetches(&mut self) {
|
||||
let fetches = std::mem::take(&mut self.cmds.profile_fetches);
|
||||
for fetch in fetches {
|
||||
let state = self.skin_profiles.borrow_mut().state(&fetch.key);
|
||||
match state {
|
||||
Resolve::Pending => self.cmds.profile_fetches.push(fetch),
|
||||
Resolve::Done(result) => {
|
||||
let outer = std::mem::replace(&mut self.command_source, fetch.source);
|
||||
match result {
|
||||
Some(profile) => self.report_resolved_profile(
|
||||
&format!("commands.fetchprofile.{}.success", fetch.kind),
|
||||
fetch.argument,
|
||||
&profile,
|
||||
),
|
||||
None => self.command_failure(Text::translate(
|
||||
&format!("commands.fetchprofile.{}.failure", fetch.kind),
|
||||
&[fetch.argument],
|
||||
)),
|
||||
}
|
||||
self.command_source = outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `FetchProfileCommand.reportResolvedProfile`: the profile as
|
||||
/// `ResolvableProfile.CODEC` encodes it (`id`, `name`, and `properties`
|
||||
/// only when there are some), four clickable actions and the head object,
|
||||
/// sent with `sendSuccess(..., false)`.
|
||||
fn report_resolved_profile(
|
||||
&mut self,
|
||||
message_id: &str,
|
||||
argument: Text,
|
||||
profile: &ResolvedProfile,
|
||||
) {
|
||||
let (profile_tag, json) = encode_profile(profile);
|
||||
let encoded_profile = snbt::to_snbt(&profile_tag);
|
||||
// `Component.object(new PlayerSprite(profile, true))`, encoded: `hat`
|
||||
// is left out at its default.
|
||||
let encoded_component = snbt::to_snbt(&Tag::compound([("player", profile_tag)]));
|
||||
let mut head = Text::literal("\u{FFFC}");
|
||||
head.spans[0].style.inline_object = Some(InlineObject::Player {
|
||||
profile: json,
|
||||
hat: true,
|
||||
fallback: format!("[{} head]", profile.name),
|
||||
});
|
||||
let item = |key: &str, args: &[Text], click: ClickEvent| {
|
||||
let inner = Text::translate(key, args)
|
||||
.with_color(text::GREEN)
|
||||
.with_style(&Style {
|
||||
click: Some(click),
|
||||
..Style::default()
|
||||
});
|
||||
let _ = cmd;
|
||||
}
|
||||
t
|
||||
Text::translate("chat.square_brackets", &[inner])
|
||||
};
|
||||
let placeholder = Text::literal("\u{FFFC}").with_color(text::WHITE);
|
||||
let mut clickable = item("commands.fetchprofile.copy_component", &[], None);
|
||||
let mut clickable = item(
|
||||
"commands.fetchprofile.copy_component",
|
||||
&[],
|
||||
ClickEvent::CopyToClipboard(encoded_profile.clone()),
|
||||
);
|
||||
clickable.push(" ");
|
||||
clickable.push(item(
|
||||
"commands.fetchprofile.give_item",
|
||||
&[],
|
||||
Some(format!("give @s minecraft:player_head[profile={profile}]")),
|
||||
ClickEvent::RunCommand(format!(
|
||||
"give @s minecraft:player_head[profile={encoded_profile}]"
|
||||
)),
|
||||
));
|
||||
clickable.push(" ");
|
||||
clickable.push(item(
|
||||
"commands.fetchprofile.summon_mannequin",
|
||||
&[],
|
||||
Some(format!(
|
||||
"summon minecraft:mannequin ~ ~ ~ {{profile:{profile}}}"
|
||||
ClickEvent::RunCommand(format!(
|
||||
"summon minecraft:mannequin ~ ~ ~ {{profile:{encoded_profile}}}"
|
||||
)),
|
||||
));
|
||||
clickable.push(" ");
|
||||
clickable.push(item(
|
||||
"commands.fetchprofile.copy_text",
|
||||
&[placeholder],
|
||||
None,
|
||||
&[head.with_color(text::WHITE)],
|
||||
ClickEvent::CopyToClipboard(encoded_component),
|
||||
));
|
||||
let _ = head;
|
||||
self.feedback(Text::translate(
|
||||
"commands.fetchprofile.entity.success",
|
||||
&[Text::literal(name), clickable],
|
||||
));
|
||||
Ok(1)
|
||||
}
|
||||
/// ResolvableProfile.CODEC of the player's profile as SNBT.
|
||||
fn player_profile_snbt(&self) -> String {
|
||||
let [a, b, c, d] = self.level.player_uuid.unwrap_or([0; 4]);
|
||||
format!(
|
||||
"{{id:[I;{a},{b},{c},{d}],name:\"{}\",properties:[]}}",
|
||||
super::super::chat::PLAYER_NAME
|
||||
)
|
||||
self.feedback_unbroadcast(Text::translate(message_id, &[argument, clickable]));
|
||||
}
|
||||
|
||||
// ---- /publish, /unpublish -------------------------------------------
|
||||
|
||||
@@ -16,7 +16,7 @@ fn num(n: impl std::fmt::Display) -> Text {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
fn state_transforms(&self) -> Option<&'static StateTransforms> {
|
||||
pub(in crate::game) fn state_transforms(&self) -> Option<&'static StateTransforms> {
|
||||
static TRANSFORMS: OnceLock<Option<StateTransforms>> = OnceLock::new();
|
||||
TRANSFORMS
|
||||
.get_or_init(|| {
|
||||
@@ -33,6 +33,16 @@ impl Game<'_> {
|
||||
/// A template by id: a structure block's save in the world, then the
|
||||
/// vanilla templates.
|
||||
fn load_template(&self, id: &str) -> Option<Arc<StructureTemplate>> {
|
||||
let tag = self.read_template_tag(id)?;
|
||||
Some(Arc::new(StructureTemplate::from_nbt(
|
||||
&tag,
|
||||
self.pack.assets.registry,
|
||||
self.state_transforms()?,
|
||||
)))
|
||||
}
|
||||
/// A template's tree by id, as `StructureTemplateManager` reads it from
|
||||
/// disk.
|
||||
pub(in crate::game) fn read_template_tag(&self, id: &str) -> Option<Tag> {
|
||||
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
let candidates = [
|
||||
self.world_root
|
||||
@@ -46,7 +56,6 @@ impl Game<'_> {
|
||||
.join(ns)
|
||||
.join(format!("{path}.nbt")),
|
||||
];
|
||||
let transforms = self.state_transforms()?;
|
||||
for file in candidates {
|
||||
let Ok(bytes) = std::fs::read(&file) else {
|
||||
continue;
|
||||
@@ -57,11 +66,7 @@ impl Game<'_> {
|
||||
let Ok((named, _)) = nbt::read_named(&raw, Limits::templates()) else {
|
||||
continue;
|
||||
};
|
||||
return Some(Arc::new(StructureTemplate::from_nbt(
|
||||
&named.tag,
|
||||
self.pack.assets.registry,
|
||||
transforms,
|
||||
)));
|
||||
return Some(named.tag);
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -152,7 +157,7 @@ impl Game<'_> {
|
||||
position: pos,
|
||||
};
|
||||
let placed = self.with_level(|b, level, ticks| {
|
||||
b.place_jigsaw(level, ticks, pos, || terrain.jigsaw_pieces(&query))
|
||||
b.place_jigsaw(level, ticks, pos, false, || terrain.jigsaw_pieces(&query))
|
||||
});
|
||||
self.process_level_events()
|
||||
.map_err(|_| err("command.failed", &[]))?;
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
//! `/op`, `/deop`, `/ban`, `/pardon` and `/whitelist` (OpCommand,
|
||||
//! DeOpCommands, BanPlayerCommands, PardonCommand, WhitelistCommand):
|
||||
//! commands of a dedicated server, run on its lists by an operator in game
|
||||
//! (the console has its own handlers in game/dedicated.rs).
|
||||
use super::{Arg, Context, Game, R, Target, err};
|
||||
use horizon_client::{
|
||||
server::user_lists::{BanEntry, NameAndId, OpEntry, WhiteEntry},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_protocol::text::translatable;
|
||||
|
||||
impl Game<'_> {
|
||||
/// Commands.CommandSelection.DEDICATED: on an integrated server these do
|
||||
/// not exist.
|
||||
fn dedicated_lists(
|
||||
&self,
|
||||
) -> R<std::sync::Arc<std::sync::Mutex<super::super::dedicated::Lists>>> {
|
||||
match (&self.server_lists, self.options.dedicated.is_some()) {
|
||||
(Some(lists), true) => Ok(lists.clone()),
|
||||
_ => Err(err("command.unknown.command", &[])),
|
||||
}
|
||||
}
|
||||
|
||||
/// `NameAndId` of a player target.
|
||||
fn target_profile(&self, t: Target) -> Option<NameAndId> {
|
||||
match t {
|
||||
Target::Player => Some(NameAndId {
|
||||
id: self.lan_acting_uuid?,
|
||||
name: self.acting_player_name().to_owned(),
|
||||
}),
|
||||
Target::Guest(conn) => self.command_guest(conn).map(|g| NameAndId {
|
||||
id: g.uuid,
|
||||
name: g.name.clone(),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `GameProfileArgument.getGameProfiles`: a selector's players
|
||||
/// (`SelectorResult.getNames`, no players is an error), else the name in
|
||||
/// the user cache.
|
||||
fn game_profiles(&mut self, ctx: &Context, name: &str) -> R<Vec<NameAndId>> {
|
||||
match ctx.get(name).cloned() {
|
||||
Some(Arg::Entity(s)) => {
|
||||
let players: Vec<Target> = self
|
||||
.select(&s)
|
||||
.into_iter()
|
||||
.filter(|t| t.is_player())
|
||||
.collect();
|
||||
if players.is_empty() {
|
||||
return Err(err("argument.entity.notfound.player", &[]));
|
||||
}
|
||||
Ok(players
|
||||
.into_iter()
|
||||
.filter_map(|t| self.target_profile(t))
|
||||
.collect())
|
||||
}
|
||||
Some(Arg::Str(text)) => self
|
||||
.resolve_name(&text)
|
||||
.map(|p| vec![p])
|
||||
.ok_or_else(|| err("argument.player.unknown", &[])),
|
||||
_ => Err(err("argument.player.unknown", &[])),
|
||||
}
|
||||
}
|
||||
|
||||
/// `CommandSourceStack.getTextName`.
|
||||
fn source_text_name(&self) -> String {
|
||||
if self.console_broadcast.is_some() {
|
||||
"Server".to_owned()
|
||||
} else {
|
||||
self.acting_player_name().to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// `OpCommand.opPlayers`, `DeOpCommands.deopPlayers`,
|
||||
/// `BanPlayerCommands.banPlayers` and `PardonCommand.pardonPlayers`.
|
||||
pub(super) fn cmd_profiles(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
let lists = self.dedicated_lists()?;
|
||||
let players = self.game_profiles(ctx, "targets")?;
|
||||
let lit = |s: &str| Text::literal(s.to_owned());
|
||||
let mut count = 0;
|
||||
match id {
|
||||
"op" => {
|
||||
for player in &players {
|
||||
let added = {
|
||||
let mut l = lists.lock().unwrap();
|
||||
let level = l.op_level;
|
||||
!l.ops.contains(player)
|
||||
&& l.ops.add(OpEntry {
|
||||
user: player.clone(),
|
||||
level,
|
||||
bypasses_player_limit: false,
|
||||
})
|
||||
};
|
||||
if added {
|
||||
self.sync_ops(&lists);
|
||||
count += 1;
|
||||
self.feedback(Text::translate("commands.op.success", &[lit(&player.name)]));
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return Err(err("commands.op.failed", &[]));
|
||||
}
|
||||
}
|
||||
"deop" => {
|
||||
for player in &players {
|
||||
let removed = {
|
||||
let mut l = lists.lock().unwrap();
|
||||
l.ops.contains(player) && l.ops.remove(player)
|
||||
};
|
||||
if removed {
|
||||
self.sync_ops(&lists);
|
||||
count += 1;
|
||||
self.feedback(Text::translate(
|
||||
"commands.deop.success",
|
||||
&[lit(&player.name)],
|
||||
));
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return Err(err("commands.deop.failed", &[]));
|
||||
}
|
||||
self.kick_unlisted_players(&lists);
|
||||
}
|
||||
"ban" => {
|
||||
let reason = match ctx.get("reason") {
|
||||
Some(Arg::Str(raw)) => {
|
||||
let raw = raw.clone();
|
||||
Some(self.expand_selectors(&raw).plain().to_string())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let source = self.source_text_name();
|
||||
for player in &players {
|
||||
let banned = lists.lock().unwrap().bans.contains(player);
|
||||
if banned {
|
||||
continue;
|
||||
}
|
||||
lists.lock().unwrap().bans.add(BanEntry::new(
|
||||
player.clone(),
|
||||
Some(source.clone()),
|
||||
None,
|
||||
reason.clone(),
|
||||
));
|
||||
count += 1;
|
||||
// BanListEntry.getReasonMessage.
|
||||
let shown = reason.as_deref().map_or_else(
|
||||
|| Text::translate("multiplayer.disconnect.banned.reason.default", &[]),
|
||||
lit,
|
||||
);
|
||||
self.feedback(Text::translate(
|
||||
"commands.ban.success",
|
||||
&[lit(&player.name), shown],
|
||||
));
|
||||
self.kick_uuid(
|
||||
player.id,
|
||||
translatable("multiplayer.disconnect.banned", &[]),
|
||||
);
|
||||
}
|
||||
if count == 0 {
|
||||
return Err(err("commands.ban.failed", &[]));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for player in &players {
|
||||
let removed = {
|
||||
let mut l = lists.lock().unwrap();
|
||||
l.bans.contains(player) && l.bans.remove(player)
|
||||
};
|
||||
if removed {
|
||||
count += 1;
|
||||
self.feedback(Text::translate(
|
||||
"commands.pardon.success",
|
||||
&[lit(&player.name)],
|
||||
));
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return Err(err("commands.pardon.failed", &[]));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// `WhitelistCommand`: `on`, `off`, `list`, `reload`, `add` and `remove`.
|
||||
pub(super) fn cmd_whitelist(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
let lists = self.dedicated_lists()?;
|
||||
let lit = |s: &str| Text::literal(s.to_owned());
|
||||
match id {
|
||||
"whitelist.reload" => {
|
||||
// PlayerList.reloadWhiteList.
|
||||
let _ = lists.lock().unwrap().white.load();
|
||||
self.feedback(Text::translate("commands.whitelist.reloaded", &[]));
|
||||
self.kick_unlisted_players(&lists);
|
||||
Ok(1)
|
||||
}
|
||||
"whitelist.on" => {
|
||||
if lists.lock().unwrap().use_whitelist {
|
||||
return Err(err("commands.whitelist.alreadyOn", &[]));
|
||||
}
|
||||
self.set_using_whitelist(&lists, true);
|
||||
self.feedback(Text::translate("commands.whitelist.enabled", &[]));
|
||||
self.kick_unlisted_players(&lists);
|
||||
Ok(1)
|
||||
}
|
||||
"whitelist.off" => {
|
||||
if !lists.lock().unwrap().use_whitelist {
|
||||
return Err(err("commands.whitelist.alreadyOff", &[]));
|
||||
}
|
||||
self.set_using_whitelist(&lists, false);
|
||||
self.feedback(Text::translate("commands.whitelist.disabled", &[]));
|
||||
Ok(1)
|
||||
}
|
||||
"whitelist.list" => {
|
||||
let names: Vec<String> = lists
|
||||
.lock()
|
||||
.unwrap()
|
||||
.white
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|e| e.user.name.clone())
|
||||
.collect();
|
||||
if names.is_empty() {
|
||||
self.feedback_unbroadcast(Text::translate("commands.whitelist.none", &[]));
|
||||
} else {
|
||||
self.feedback_unbroadcast(Text::translate(
|
||||
"commands.whitelist.list",
|
||||
&[lit(&names.len().to_string()), lit(&names.join(", "))],
|
||||
));
|
||||
}
|
||||
Ok(names.len() as i32)
|
||||
}
|
||||
_ => {
|
||||
let add = id == "whitelist.add";
|
||||
let targets = self.game_profiles(ctx, "targets")?;
|
||||
let mut count = 0;
|
||||
for target in &targets {
|
||||
let done = {
|
||||
let mut l = lists.lock().unwrap();
|
||||
if add {
|
||||
!l.white.contains(target)
|
||||
&& l.white.add(WhiteEntry {
|
||||
user: target.clone(),
|
||||
})
|
||||
} else {
|
||||
l.white.contains(target) && l.white.remove(target)
|
||||
}
|
||||
};
|
||||
if done {
|
||||
count += 1;
|
||||
let key = if add {
|
||||
"commands.whitelist.add.success"
|
||||
} else {
|
||||
"commands.whitelist.remove.success"
|
||||
};
|
||||
self.feedback(Text::translate(key, &[lit(&target.name)]));
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return Err(err(
|
||||
if add {
|
||||
"commands.whitelist.add.failed"
|
||||
} else {
|
||||
"commands.whitelist.remove.failed"
|
||||
},
|
||||
&[],
|
||||
));
|
||||
}
|
||||
if !add {
|
||||
self.kick_unlisted_players(&lists);
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
//! `TestCommand` over the test instance blocks of the level in place and the
|
||||
//! game's test instances: `run`, `runmultiple`, `runthese`, `runclosest`,
|
||||
//! `runthat`, `runfailed`, `resetclosest`/`these`/`that`,
|
||||
//! `clearthat`/`these`/`all` and `stop`, with `TestFinder`'s searches.
|
||||
//!
|
||||
//! Not done: `verify`, `locate`, `pos`, `create`, retries (`numberOfTimes`,
|
||||
//! `untilFailed`), tests of another dimension than the level in place.
|
||||
use super::{Arg, Context, Game, R, err};
|
||||
use crate::game::{
|
||||
game_tests::{Grid, Info},
|
||||
test_instances::TestDef,
|
||||
};
|
||||
use horizon_client::text::{self, Text};
|
||||
use horizon_world::worldgen::structure::Rotation;
|
||||
|
||||
type Pos = (i32, i32, i32);
|
||||
|
||||
/// `TestFinder.Builder`'s finders that the command picks.
|
||||
enum Finder {
|
||||
/// `radius(c, r)`.
|
||||
Radius(i32),
|
||||
/// `nearest(c)`.
|
||||
Nearest,
|
||||
/// `allNearby(c)`.
|
||||
AllNearby,
|
||||
/// `lookedAt(c)`.
|
||||
LookedAt,
|
||||
/// `failedTests(c, onlyRequiredTests)`.
|
||||
Failed(bool),
|
||||
/// `byResourceSelection`, `copies` times over.
|
||||
Selection { tests: Vec<String>, copies: i32 },
|
||||
}
|
||||
|
||||
/// `StructureUtils.getRotationForRotationSteps`.
|
||||
fn rotation_for_steps(steps: i32) -> Option<Rotation> {
|
||||
usize::try_from(steps)
|
||||
.ok()
|
||||
.and_then(|i| Rotation::ALL.get(i).copied())
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `CommandSourceStack.sendFailure`.
|
||||
fn test_failure(&mut self, message: Text) {
|
||||
if self.command_source.is_some_and(|s| s.silent) {
|
||||
return;
|
||||
}
|
||||
self.system_message(message.with_color(text::RED));
|
||||
}
|
||||
|
||||
/// `StructureUtils.findTestBlocks`: the test instance blocks within
|
||||
/// `radius` of `center` (the POI search is a sphere).
|
||||
fn find_test_instance_blocks(&self, center: Pos, radius: i32) -> Vec<Pos> {
|
||||
let limit = i64::from(radius) * i64::from(radius);
|
||||
let mut found: Vec<Pos> = self
|
||||
.world
|
||||
.block_entities
|
||||
.iter()
|
||||
.filter(|(_, e)| {
|
||||
matches!(
|
||||
e,
|
||||
horizon_client::containers::BlockEntity::TestInstanceBlock(_)
|
||||
)
|
||||
})
|
||||
.map(|(p, _)| *p)
|
||||
.filter(|p| {
|
||||
let d = [p.0 - center.0, p.1 - center.1, p.2 - center.2].map(i64::from);
|
||||
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] <= limit
|
||||
})
|
||||
.collect();
|
||||
found.sort_unstable();
|
||||
found
|
||||
}
|
||||
|
||||
/// The block of the source's position (`BlockPos.containing`).
|
||||
fn test_source_block(&self) -> Pos {
|
||||
let p = self.source_position();
|
||||
(
|
||||
p[0].floor() as i32,
|
||||
p[1].floor() as i32,
|
||||
p[2].floor() as i32,
|
||||
)
|
||||
}
|
||||
|
||||
/// `StructureUtils.lookedAtTestPos`: the test whose box the player's
|
||||
/// view ray (250 blocks) meets, the nearest to the source.
|
||||
fn looked_at_test(&self, center: Pos) -> Option<Pos> {
|
||||
let player = self.player.as_ref()?;
|
||||
let eye = [
|
||||
player.position.x,
|
||||
player.position.y + f64::from(player.dimensions.eye_height),
|
||||
player.position.z,
|
||||
];
|
||||
let (yaw, pitch) = (
|
||||
f64::from(self.yaw).to_radians(),
|
||||
f64::from(self.pitch).to_radians(),
|
||||
);
|
||||
let look = [
|
||||
-yaw.sin() * pitch.cos(),
|
||||
-pitch.sin(),
|
||||
yaw.cos() * pitch.cos(),
|
||||
];
|
||||
let end = [0, 1, 2].map(|i| eye[i] + look[i] * 250.0);
|
||||
self.find_test_instance_blocks(center, 250)
|
||||
.into_iter()
|
||||
.filter(|&p| {
|
||||
self.test_structure_box(p).is_some_and(|b| {
|
||||
let min = b.min.map(f64::from);
|
||||
let max = b.max.map(|v| f64::from(v) + 1.0);
|
||||
ray_hits_box(eye, end, min, max)
|
||||
})
|
||||
})
|
||||
.min_by_key(|p| {
|
||||
let d = [p.0 - center.0, p.1 - center.1, p.2 - center.2].map(i64::from);
|
||||
d[0] * d[0] + d[1] * d[1] + d[2] * d[2]
|
||||
})
|
||||
}
|
||||
|
||||
/// The test instance blocks a finder gives (`findTestPos`).
|
||||
fn finder_positions(&self, finder: &Finder) -> Vec<Pos> {
|
||||
let center = self.test_source_block();
|
||||
match finder {
|
||||
Finder::Radius(radius) => self.find_test_instance_blocks(center, *radius),
|
||||
Finder::Nearest => self
|
||||
.find_test_instance_blocks(center, 15)
|
||||
.into_iter()
|
||||
.min_by_key(|p| {
|
||||
(p.0 - center.0).abs() + (p.1 - center.1).abs() + (p.2 - center.2).abs()
|
||||
})
|
||||
.into_iter()
|
||||
.collect(),
|
||||
Finder::AllNearby => self.find_test_instance_blocks(center, 250),
|
||||
Finder::LookedAt => self.looked_at_test(center).into_iter().collect(),
|
||||
Finder::Failed(_) | Finder::Selection { .. } => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The tests a finder gives (`findTests`), by id.
|
||||
fn finder_tests(&self, finder: &Finder) -> Vec<(String, TestDef)> {
|
||||
let keys: Vec<String> = match finder {
|
||||
Finder::Failed(only_required) => self
|
||||
.game_tests
|
||||
.failed
|
||||
.iter()
|
||||
.filter(|k| !only_required || self.test_def(k).is_some_and(|def| def.required))
|
||||
.cloned()
|
||||
.collect(),
|
||||
Finder::Selection { tests, copies } => {
|
||||
(0..*copies).flat_map(|_| tests.iter().cloned()).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
keys.into_iter()
|
||||
.filter_map(|k| self.test_def(&k).map(|def| (k, def)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `verifyStructureExists`.
|
||||
fn verify_test_structure(&mut self, structure: &str) -> bool {
|
||||
if self.template_entry(structure).is_some() {
|
||||
return true;
|
||||
}
|
||||
self.test_failure(Text::translate(
|
||||
"commands.test.error.structure_not_found",
|
||||
&[Text::literal(structure.to_owned())],
|
||||
));
|
||||
false
|
||||
}
|
||||
|
||||
/// `createGameTestInfo`: the test of a test instance block.
|
||||
fn test_info_at(&mut self, pos: Pos) -> Option<Info> {
|
||||
let key = self.test_instance_at(pos).and_then(|b| b.data.test.clone());
|
||||
if self.test_instance_at(pos).is_none() {
|
||||
self.test_failure(Text::translate(
|
||||
"commands.test.error.test_instance_not_found.position",
|
||||
&[pos.0, pos.1, pos.2].map(|v| Text::literal(v.to_string())),
|
||||
));
|
||||
return None;
|
||||
}
|
||||
let Some(def) = key.as_deref().and_then(|k| self.test_def(k)) else {
|
||||
let name = self.test_name(pos);
|
||||
self.test_failure(Text::translate(
|
||||
"commands.test.error.non_existant_test",
|
||||
&[name],
|
||||
));
|
||||
return None;
|
||||
};
|
||||
let key = key.unwrap_or_default();
|
||||
let key = if key.contains(':') {
|
||||
key
|
||||
} else {
|
||||
format!("minecraft:{key}")
|
||||
};
|
||||
if !self.verify_test_structure(&def.structure) {
|
||||
return None;
|
||||
}
|
||||
let rotation = self.test_rotation(pos);
|
||||
let id = self.game_tests.next_id();
|
||||
Some(Info::new(id, key, def, Some(pos), self.dim, rotation))
|
||||
}
|
||||
|
||||
/// `toGameTestInfo`: a test without a block, in the level in place.
|
||||
fn test_info_for(&mut self, key: String, def: TestDef, steps: i32) -> R<Option<Info>> {
|
||||
if !self.verify_test_structure(&def.structure) {
|
||||
return Ok(None);
|
||||
}
|
||||
if def.dimension != self.dim.id() {
|
||||
self.test_failure(Text::literal(format!(
|
||||
"Could not resolve level for dimension: {}",
|
||||
def.dimension
|
||||
)));
|
||||
return Ok(None);
|
||||
}
|
||||
let rotation = rotation_for_steps(steps).ok_or_else(|| err("command.failed", &[]))?;
|
||||
let id = self.game_tests.next_id();
|
||||
Ok(Some(Info::new(id, key, def, None, self.dim, rotation)))
|
||||
}
|
||||
|
||||
/// `createTestPositionAround`: three blocks south of the source, on the
|
||||
/// surface of the level.
|
||||
fn test_position_around(&self) -> Pos {
|
||||
let p = self.source_position();
|
||||
let (x, z) = (p[0].floor() as i32, p[2].floor() as i32);
|
||||
let facts = self.res.facts();
|
||||
let surface = self.world.world_surface(x, z + 3, |s| facts.is_air(s));
|
||||
(x, surface, z + 3)
|
||||
}
|
||||
|
||||
/// `TestCommand.run`.
|
||||
fn run_tests(&mut self, finder: &Finder, steps: i32, per_row: i32) -> R<i32> {
|
||||
self.clear_game_tests();
|
||||
let mut infos = Vec::new();
|
||||
for pos in self.finder_positions(finder) {
|
||||
infos.extend(self.test_info_at(pos));
|
||||
}
|
||||
for (key, def) in self.finder_tests(finder) {
|
||||
infos.extend(self.test_info_for(key, def, steps)?);
|
||||
}
|
||||
if infos.is_empty() {
|
||||
self.feedback_unbroadcast(Text::translate("commands.test.no_tests", &[]));
|
||||
return Ok(0);
|
||||
}
|
||||
self.game_tests.failed.clear();
|
||||
self.feedback_unbroadcast(Text::translate(
|
||||
"commands.test.run.running",
|
||||
&[Text::literal(infos.len().to_string())],
|
||||
));
|
||||
let grid = Grid::new(self.test_position_around(), per_row);
|
||||
self.track_and_start_runner(infos, false, Some(grid));
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
/// `TestCommand.reset`.
|
||||
fn reset_tests(&mut self, finder: &Finder) -> R<i32> {
|
||||
self.clear_game_tests();
|
||||
let mut count = 0;
|
||||
for pos in self.finder_positions(finder) {
|
||||
if self.test_info_at(pos).is_some() {
|
||||
// resetGameTestInfo: `TestInstanceBlockEntity.resetTest`.
|
||||
self.reset_test(pos);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return Err(err("commands.test.clear.error.no_tests", &[]));
|
||||
}
|
||||
self.feedback(Text::translate(
|
||||
"commands.test.reset.success",
|
||||
&[Text::literal(count.to_string())],
|
||||
));
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// `TestCommand.clear`.
|
||||
fn clear_tests(&mut self, finder: &Finder) -> R<i32> {
|
||||
self.clear_game_tests();
|
||||
let tests = self.finder_positions(finder);
|
||||
for &pos in &tests {
|
||||
if let Some(area) = self.test_bounding_box(pos) {
|
||||
self.clear_test_space(&area);
|
||||
}
|
||||
self.remove_test_barriers(pos);
|
||||
// level.destroyBlock(pos, false)
|
||||
self.set_test_block_state(
|
||||
pos,
|
||||
"minecraft:air",
|
||||
horizon_world::level::flags::UPDATE_ALL,
|
||||
);
|
||||
}
|
||||
if tests.is_empty() {
|
||||
return Err(err("commands.test.clear.error.no_tests", &[]));
|
||||
}
|
||||
self.feedback(Text::translate(
|
||||
"commands.test.clear.success",
|
||||
&[Text::literal(tests.len().to_string())],
|
||||
));
|
||||
Ok(tests.len() as i32)
|
||||
}
|
||||
|
||||
/// The `/test` subcommands that run, reset and clear tests.
|
||||
pub(super) fn cmd_test_runs(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
let int = |name: &str| match ctx.get(name) {
|
||||
Some(Arg::Int(v)) => Some(*v),
|
||||
_ => None,
|
||||
};
|
||||
let has = |word: &str| ctx.literals.iter().any(|l| l == word);
|
||||
let looks = has("runthat") || has("resetthat") || has("clearthat");
|
||||
if looks {
|
||||
self.source_player()?;
|
||||
}
|
||||
match id {
|
||||
"test.stop" => {
|
||||
self.clear_game_tests();
|
||||
Ok(1)
|
||||
}
|
||||
"test.run" => {
|
||||
let tests = match ctx.get("tests") {
|
||||
Some(Arg::Str(pattern)) => horizon_client::command::args::select_resources(
|
||||
self.ui.registries.ids("minecraft:test_instance"),
|
||||
pattern,
|
||||
)
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let copies = if has("runmultiple") {
|
||||
int("amount").unwrap_or(1)
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let finder = Finder::Selection { tests, copies };
|
||||
self.run_tests(
|
||||
&finder,
|
||||
int("rotationSteps").unwrap_or(0),
|
||||
int("testsPerRow").unwrap_or(8),
|
||||
)
|
||||
}
|
||||
"test.run.found" => {
|
||||
let finder = if has("runclosest") {
|
||||
Finder::Nearest
|
||||
} else if has("runthat") {
|
||||
Finder::LookedAt
|
||||
} else if has("runfailed") {
|
||||
let only = matches!(ctx.get("onlyRequiredTests"), Some(Arg::Bool(true)));
|
||||
Finder::Failed(only)
|
||||
} else {
|
||||
Finder::AllNearby
|
||||
};
|
||||
self.run_tests(
|
||||
&finder,
|
||||
int("rotationSteps").unwrap_or(0),
|
||||
int("testsPerRow").unwrap_or(8),
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
let finder = if has("resetclosest") {
|
||||
Finder::Nearest
|
||||
} else if has("resetthese") || has("clearthese") {
|
||||
Finder::AllNearby
|
||||
} else if looks {
|
||||
Finder::LookedAt
|
||||
} else {
|
||||
// clearall [radius]: clamped to 0..=1024, 250 by default.
|
||||
Finder::Radius(int("radius").map_or(250, |r| r.clamp(0, 1024)))
|
||||
};
|
||||
if id == "test.clear"
|
||||
&& (has("resetclosest") || has("resetthese") || has("resetthat"))
|
||||
{
|
||||
self.reset_tests(&finder)
|
||||
} else {
|
||||
self.clear_tests(&finder)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `AABB.clip(from, to).isPresent()`: the segment meets the box.
|
||||
fn ray_hits_box(from: [f64; 3], to: [f64; 3], min: [f64; 3], max: [f64; 3]) -> bool {
|
||||
let (mut near, mut far) = (0.0f64, 1.0f64);
|
||||
for i in 0..3 {
|
||||
let d = to[i] - from[i];
|
||||
if d.abs() < 1.0e-7 {
|
||||
if from[i] < min[i] || from[i] > max[i] {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (a, b) = ((min[i] - from[i]) / d, (max[i] - from[i]) / d);
|
||||
let (lo, hi) = if a < b { (a, b) } else { (b, a) };
|
||||
near = near.max(lo);
|
||||
far = far.min(hi);
|
||||
if near > far {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rotation_steps_run_from_zero_to_three() {
|
||||
assert_eq!(rotation_for_steps(0), Some(Rotation::None));
|
||||
assert_eq!(rotation_for_steps(1), Some(Rotation::Clockwise90));
|
||||
assert_eq!(rotation_for_steps(3), Some(Rotation::Counterclockwise90));
|
||||
assert_eq!(rotation_for_steps(4), None);
|
||||
assert_eq!(rotation_for_steps(-1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ray_meets_a_box_it_crosses_and_misses_one_beside_it() {
|
||||
let (min, max) = ([0.0, 0.0, 0.0], [2.0, 2.0, 2.0]);
|
||||
assert!(ray_hits_box([-5.0, 1.0, 1.0], [5.0, 1.0, 1.0], min, max));
|
||||
assert!(!ray_hits_box([-5.0, 3.0, 1.0], [5.0, 3.0, 1.0], min, max));
|
||||
assert!(!ray_hits_box([-5.0, 1.0, 1.0], [-1.0, 1.0, 1.0], min, max));
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,59 @@ use super::{Arg, Context, Game, R, Target, err};
|
||||
use horizon_client::{hud_overlays::Titles, lan::data::component_nbt, text::Text};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
|
||||
/// `EntitySelector.COMPILABLE_CODEC` (`CompilableString.CommandParserHelper.apply`):
|
||||
/// the `selector` of a selector component and the `entity` of an nbt
|
||||
/// component are parsed when the component is decoded, and text left after
|
||||
/// the selector is `command.trailing_data`. Either failure makes the whole
|
||||
/// component invalid, with vanilla's message. (A score `name` is an `Either`
|
||||
/// that falls back to a plain name, so it never fails.)
|
||||
fn invalid_selector(
|
||||
v: &serde_json::Value,
|
||||
reg: &horizon_client::command::Registries,
|
||||
) -> Option<String> {
|
||||
match v {
|
||||
serde_json::Value::Array(a) => a.iter().find_map(|x| invalid_selector(x, reg)),
|
||||
serde_json::Value::Object(o) => {
|
||||
let source = o.get("selector").and_then(|s| s.as_str()).or_else(|| {
|
||||
o.contains_key("nbt")
|
||||
.then(|| o.get("entity").and_then(|s| s.as_str()))
|
||||
.flatten()
|
||||
});
|
||||
if let Some(source) = source
|
||||
&& let Some(why) = compile_selector(source, reg)
|
||||
{
|
||||
return Some(format!("Invalid selector component: {source}: {why}"));
|
||||
}
|
||||
o.values().find_map(|x| invalid_selector(x, reg))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `EntitySelectorParser.parse` on all of `source`; the error's
|
||||
/// `getMessage` on failure.
|
||||
fn compile_selector(source: &str, reg: &horizon_client::command::Registries) -> Option<String> {
|
||||
let mut reader = horizon_client::command::Reader::new(source);
|
||||
match horizon_client::command::selector::parse(&mut reader, reg, true) {
|
||||
Err(e) => Some(match e.context() {
|
||||
Some(context) => format!("{} at position {}: {context}", e.message.plain(), e.cursor),
|
||||
None => e.message.plain(),
|
||||
}),
|
||||
Ok(_) if reader.can_read() => {
|
||||
let parsed = &source[..reader.cursor];
|
||||
let leftovers = &source[reader.cursor..];
|
||||
Some(
|
||||
horizon_client::text::Text::tr(
|
||||
"command.trailing_data",
|
||||
&[&format!("{parsed}[{leftovers}]")],
|
||||
)
|
||||
.plain(),
|
||||
)
|
||||
}
|
||||
Ok(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// ComponentArgument with selectors resolved for the source
|
||||
/// (ComponentUtils.updateForEntity: names joined by ", ").
|
||||
@@ -12,6 +65,9 @@ impl Game<'_> {
|
||||
};
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(json).map_err(|e| err("argument.component.invalid", &[&e]))?;
|
||||
if let Some(message) = invalid_selector(&value, &self.ui.registries) {
|
||||
return Err(err("argument.component.invalid", &[&message]));
|
||||
}
|
||||
// NbtContents.resolve: the path's values in the entity, block or
|
||||
// storage data, rich-styled (or plain, or interpreted), ", "-joined.
|
||||
// ScoreContents.resolve: the holder's score in the objective.
|
||||
@@ -301,3 +357,46 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::invalid_selector;
|
||||
use horizon_client::command::Registries;
|
||||
|
||||
fn check(json: &str) -> Option<String> {
|
||||
invalid_selector(&serde_json::from_str(json).unwrap(), &Registries::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selector_component_is_compiled_when_decoded() {
|
||||
assert_eq!(check(r#"{"selector":"@a"}"#), None);
|
||||
let bad = check(r#"{"selector":"@a[x"}"#).unwrap();
|
||||
assert!(
|
||||
bad.starts_with("Invalid selector component: @a[x: "),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_after_the_selector_is_trailing_data() {
|
||||
let bad = check(r#"["x",{"extra":[{"selector":"@s foo"}]}]"#).unwrap();
|
||||
assert!(
|
||||
bad.starts_with("Invalid selector component: @s foo: "),
|
||||
"{bad}"
|
||||
);
|
||||
assert!(
|
||||
bad.contains("command.trailing_data") || bad.contains("Trailing"),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_nbt_entity_is_compiled_and_a_score_name_is_not() {
|
||||
assert!(check(r#"{"nbt":"a","entity":"@e[type"}"#).is_some());
|
||||
assert_eq!(check(r#"{"nbt":"a","storage":"x:y"}"#), None);
|
||||
assert_eq!(
|
||||
check(r#"{"score":{"name":"@a foo","objective":"o"}}"#),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -240,27 +240,43 @@ impl Game<'_> {
|
||||
return Ok(0);
|
||||
};
|
||||
let current = self.target_rotation(t);
|
||||
let mut look_at_target = None;
|
||||
let rot = if let Some(Arg::Coords(c)) = ctx.get("rotation") {
|
||||
// Relative parts are offsets from the entity's own rotation.
|
||||
c.rotation(current)
|
||||
} else {
|
||||
let mut faced_entity = None;
|
||||
let to = if ctx.has("entity") {
|
||||
let faced = self.entities(ctx, "facingEntity", false)?;
|
||||
let Some(f) = faced.first().copied() else {
|
||||
return Ok(0);
|
||||
};
|
||||
let mut p = self.target_position(f).unwrap_or(pos);
|
||||
if matches!(ctx.get("facingAnchor"), Some(Arg::Anchor(true))) {
|
||||
let eyes = matches!(ctx.get("facingAnchor"), Some(Arg::Anchor(true)));
|
||||
if eyes {
|
||||
p[1] += self.target_eye_height(f);
|
||||
}
|
||||
faced_entity = Some((f, eyes));
|
||||
p
|
||||
} else {
|
||||
self.position_arg(ctx, "facingLocation").unwrap_or(pos)
|
||||
};
|
||||
let from = [pos[0], pos[1] + self.target_eye_height(t), pos[2]];
|
||||
// LookAt.perform: Entity.lookAt from the source's anchor (the
|
||||
// feet, unless `anchored eyes`).
|
||||
let anchored_eyes = self.command_source.is_some_and(|s| s.eyes);
|
||||
let eye = if anchored_eyes {
|
||||
self.target_eye_height(t)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let from = [pos[0], pos[1] + eye, pos[2]];
|
||||
look_at_target = Some((anchored_eyes, to, faced_entity));
|
||||
look_at(from, to)
|
||||
};
|
||||
self.teleport_to(t, pos, Some(rot));
|
||||
if let Some((eyes, to, entity)) = look_at_target {
|
||||
self.send_player_look_at(t, eyes, to, entity);
|
||||
}
|
||||
if t == Target::Player {
|
||||
self.pitch = rot[1].clamp(-90.0, 90.0);
|
||||
}
|
||||
|
||||
@@ -348,7 +348,7 @@ impl Game<'_> {
|
||||
// Text screens consume raw presses; releases follow key binds.
|
||||
let typing = matches!(
|
||||
self.screen,
|
||||
Screen::Chat | Screen::Options | Screen::CommandBlock
|
||||
Screen::Chat | Screen::Options | Screen::CommandBlock | Screen::BlockEditor
|
||||
);
|
||||
let key = if typing && arg(2)? != "up" {
|
||||
key
|
||||
@@ -629,6 +629,7 @@ impl Game<'_> {
|
||||
match self.screen {
|
||||
Screen::Chat => self.chat_type(text),
|
||||
Screen::CommandBlock => self.command_block_type(text),
|
||||
Screen::BlockEditor => self.block_editor_type(text),
|
||||
Screen::Options => self.ui.options.type_text(&self.gui.atlas, text),
|
||||
// The port, or the game rules' search and number boxes.
|
||||
Screen::WorldOptions if text == "\\b" => self.world_options_text(None),
|
||||
@@ -819,7 +820,12 @@ impl Game<'_> {
|
||||
"dragon-hurt" => {
|
||||
// Test hook: damage the dragon's head as the player would.
|
||||
let amount: f32 = arg(1)?.parse()?;
|
||||
self.hit_special(super::dragon::SPECIAL, amount, true, false);
|
||||
self.hit_special(
|
||||
super::dragon::SPECIAL,
|
||||
amount,
|
||||
true,
|
||||
horizon_client::mobs::DamageKind::PlayerAttack,
|
||||
);
|
||||
ok()
|
||||
}
|
||||
"quit" => {
|
||||
@@ -866,6 +872,10 @@ impl Game<'_> {
|
||||
self.command_block_key_code(key, None, None);
|
||||
return Ok(());
|
||||
}
|
||||
if self.screen == Screen::BlockEditor {
|
||||
self.block_editor_key_code(key, None, None);
|
||||
return Ok(());
|
||||
}
|
||||
if self.keys.insert(key) {
|
||||
self.key_pressed(key)?;
|
||||
}
|
||||
@@ -896,6 +906,7 @@ impl Game<'_> {
|
||||
Screen::ConfirmLink => "confirm-link",
|
||||
Screen::DialogWarning => "dialog-warning",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::BlockEditor => "block-editor",
|
||||
Screen::Win => "win",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +269,28 @@ impl Game<'_> {
|
||||
sleeping: self.sleeping.is_some(),
|
||||
sleep_timer: self.sleep_timer,
|
||||
passenger: self.riding.is_some(),
|
||||
vehicle: self.riding.and_then(|id| {
|
||||
if let Some(b) = self.boats.iter().find(|b| b.id == id) {
|
||||
// A boat's box: 1.375 wide and 0.5625 high around its position.
|
||||
let w = 0.6875;
|
||||
return Some(horizon_client::mobs::Vehicle {
|
||||
nautilus: false,
|
||||
bounds: Aabb::new(
|
||||
Vec3::new(b.position.x - w, b.position.y, b.position.z - w),
|
||||
Vec3::new(b.position.x + w, b.position.y + 0.5625, b.position.z + w),
|
||||
),
|
||||
velocity: b.velocity,
|
||||
});
|
||||
}
|
||||
let m = self.mobs.iter().find(|m| m.id == id)?;
|
||||
matches!(m.species(), Species::Nautilus | Species::ZombieNautilus).then(|| {
|
||||
horizon_client::mobs::Vehicle {
|
||||
nautilus: true,
|
||||
bounds: m.bounds(),
|
||||
velocity: m.velocity,
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
}
|
||||
/// Biome facts at a position for finalizeSpawn and variant rules.
|
||||
@@ -529,6 +551,7 @@ impl Game<'_> {
|
||||
nearest_player: player.as_ref().filter(|p| !p.spectator).map(|p| p.position),
|
||||
player,
|
||||
others,
|
||||
leash_boats: &self.leash_boats,
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
daylight: self.dim != Dim::Nether && self.sky_darken() < 4,
|
||||
@@ -670,6 +693,12 @@ impl Game<'_> {
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let mut guest_events = Vec::new();
|
||||
// The block writes since the last tick: mobs re-path around them.
|
||||
let path_writes = self
|
||||
.world
|
||||
.path_writes
|
||||
.get_or_insert_with(Vec::new)
|
||||
.split_off(0);
|
||||
// The mobs whose bodies go this tick: (id, feet, width, height).
|
||||
let mut gone = Vec::new();
|
||||
let level_random;
|
||||
@@ -683,6 +712,11 @@ impl Game<'_> {
|
||||
horizon_client::mobs::seen_grid::SeenGrid::new(snapshot.iter().map(|s| s.bounds));
|
||||
// All but the snapshot is the same for every mob of the tick, so
|
||||
// build it once: the player view and the teams scan every UUID.
|
||||
self.leash_boats = self
|
||||
.boats
|
||||
.iter()
|
||||
.map(super::boat_leads::boat_state)
|
||||
.collect();
|
||||
let base = self.mob_world_base(&[]);
|
||||
// Each mob keeps its snapshot slot all tick; the first slot of an
|
||||
// id answers, as a scan would.
|
||||
@@ -744,6 +778,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
horizon_client::mobs::blocks_changed(m, &w, &path_writes);
|
||||
let from = events.len();
|
||||
let kept = m.tick(&w, &mut scratch, &mut events);
|
||||
// A killed mob's body is removed: its effects that act on
|
||||
@@ -915,7 +950,14 @@ impl Game<'_> {
|
||||
};
|
||||
if let Some(who) = who {
|
||||
self.for_player(who, |g| {
|
||||
g.trigger_player("minecraft:kill_mob_near_sculk_catalyst")
|
||||
// CatalystListener: trigger(player, killedEntity, damageSource).
|
||||
let view = g.mob_entity(m);
|
||||
let blow = m.last_damage.map(|(d, _)| g.damage_view(d));
|
||||
g.trigger_killed_as(
|
||||
"minecraft:kill_mob_near_sculk_catalyst",
|
||||
&view,
|
||||
blow.as_ref(),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -992,7 +1034,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// A mob's damage source as advancement predicates see it: its type, the
|
||||
/// causing entity, and the projectile when one dealt it.
|
||||
fn damage_view(&self, d: Damage) -> horizon_client::advancements::DamageView {
|
||||
pub(super) fn damage_view(&self, d: Damage) -> horizon_client::advancements::DamageView {
|
||||
let source = match d.attacker {
|
||||
Some(Who::Player) => Some(self.player_entity()),
|
||||
Some(Who::Mob(id)) => self
|
||||
@@ -1368,6 +1410,20 @@ impl Game<'_> {
|
||||
self.mob_hits_player(amount, from, "minecraft:mob_attack");
|
||||
}
|
||||
// doHurtTarget: the effect only when hurtServer took damage.
|
||||
// Bee.doHurtTarget: a sting that hurt leaves the stinger in the
|
||||
// body, then the poison.
|
||||
MobEvent::StingPlayer {
|
||||
amount,
|
||||
from,
|
||||
poison_ticks,
|
||||
} => {
|
||||
if self.mob_hits_player(amount, from, "minecraft:mob_attack") {
|
||||
self.combat.stuck.add_stinger();
|
||||
if poison_ticks > 0 {
|
||||
self.give_player_effect("minecraft:poison", poison_ticks, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::AttackPlayerWithEffect {
|
||||
amount,
|
||||
from,
|
||||
@@ -1467,6 +1523,17 @@ impl Game<'_> {
|
||||
}
|
||||
self.combat.explosion_source = source_mob;
|
||||
self.combat.explosion_source_named = noted;
|
||||
self.combat.explosion_cause = match species {
|
||||
Species::Creeper => Some("minecraft:creeper"),
|
||||
Species::Wither => Some("minecraft:wither"),
|
||||
Species::SulfurCube => Some("minecraft:sulfur_cube"),
|
||||
_ => None,
|
||||
}
|
||||
.map(|kind| horizon_client::advancements::EntityView {
|
||||
kind: kind.into(),
|
||||
position: at,
|
||||
..Default::default()
|
||||
});
|
||||
// Only a creeper or a wither empties the hives it blows up.
|
||||
// The creeper is already gone from the list, so the event
|
||||
// carries its kind.
|
||||
@@ -1477,6 +1544,7 @@ impl Game<'_> {
|
||||
};
|
||||
self.combat.explosion_source = None;
|
||||
self.combat.explosion_source_named = false;
|
||||
self.combat.explosion_cause = None;
|
||||
result?
|
||||
}
|
||||
MobEvent::AttackMob {
|
||||
@@ -1725,7 +1793,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
MobEvent::LevelEvent { id, pos, data } => self.level_event_at(id, pos, data),
|
||||
MobEvent::SetBlockName { pos, name, effect } => {
|
||||
MobEvent::SetBlockName {
|
||||
pos,
|
||||
name,
|
||||
effect,
|
||||
flags,
|
||||
} => {
|
||||
if effect {
|
||||
let old = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.destroy_particles(pos, old);
|
||||
@@ -1742,7 +1815,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
if let Some(state) = self.pack.assets.registry.default_state(name) {
|
||||
self.world.set(pos[0], pos[1], pos[2], state)?;
|
||||
self.level_set_with_flags(pos, state, flags)?;
|
||||
}
|
||||
}
|
||||
MobEvent::Infest {
|
||||
@@ -1760,7 +1833,7 @@ impl Game<'_> {
|
||||
};
|
||||
state.name = state.name.replacen(from, to, 1);
|
||||
if let Ok(id) = registry.resolve(&state) {
|
||||
self.world.set(pos[0], pos[1], pos[2], id)?;
|
||||
self.level_set(pos, id)?;
|
||||
}
|
||||
}
|
||||
if let Some(mob) = poof {
|
||||
@@ -1886,7 +1959,7 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|m| m.id == mob)
|
||||
.map(|m| self.mob_entity(m));
|
||||
self.trigger_effects_changed(source);
|
||||
self.trigger_effects_changed_by(source);
|
||||
}
|
||||
MobEvent::DolphinTreasure { mob } => {
|
||||
// ServerLevel.findNearestMapStructure(#dolphin_located, 50).
|
||||
@@ -1956,6 +2029,11 @@ impl Game<'_> {
|
||||
// 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::NotifyLeashHolder { holder } => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == holder) {
|
||||
horizon_client::mobs::happy_ghast::notify_leash_holder(m);
|
||||
}
|
||||
}
|
||||
MobEvent::Vibration { from, to, ticks } => {
|
||||
self.mob_vibration_particle(from, to, ticks)
|
||||
}
|
||||
@@ -2194,7 +2272,7 @@ impl Game<'_> {
|
||||
let mut child = parent.clone();
|
||||
child.baby = true;
|
||||
// TurtleBreedGoal.breed triggers it without a child.
|
||||
let turtle = p.species() == Species::Turtle;
|
||||
let turtle = matches!(p.species(), Species::Turtle | Species::Frog);
|
||||
if matches!(
|
||||
(
|
||||
p.species(),
|
||||
@@ -2230,7 +2308,7 @@ impl Game<'_> {
|
||||
}
|
||||
// TurtleBreedGoal.breed: the goal gave the mother her egg; only the
|
||||
// experience is left, and only with mob loot on.
|
||||
if p.species() == Species::Turtle {
|
||||
if matches!(p.species(), Species::Turtle | Species::Frog) {
|
||||
// One `new ExperienceOrb` of 1 to 7 from the mother's random (not
|
||||
// split up as `ExperienceOrb.award` does).
|
||||
if self.ui.rules.bool("mob_drops")
|
||||
@@ -2991,6 +3069,15 @@ impl Game<'_> {
|
||||
self.flush_lan_explosions();
|
||||
self.flush_wind_bursts()
|
||||
}
|
||||
/// `LivingEntity.hurtServer`'s `setLastHurtByPlayer` for a projectile a
|
||||
/// player owns (a fireball they turned back): the kill is theirs, so a
|
||||
/// guest's (the host on its server thread too) reaches their statistics
|
||||
/// and `player_killed_entity`, as the arrow's hit does.
|
||||
fn player_hurt_mob(&mut self, p: &Projectile, id: u32) {
|
||||
if p.owner == Some(Who::Player) {
|
||||
self.last_hurt_by_player.insert(id, self.lan_acting);
|
||||
}
|
||||
}
|
||||
/// `projectile_hit` for a projectile the guest `conn` shot, run as them;
|
||||
/// as the host's when they have left.
|
||||
fn guest_projectile_hit(&mut self, conn: u32, p: &Projectile, hit: Hit) -> Result<()> {
|
||||
@@ -3358,7 +3445,12 @@ impl Game<'_> {
|
||||
// it, else turned back (a perched dragon sets it alight first).
|
||||
let mut arrow = p.clone();
|
||||
let amount = self.shot_damage(&mut arrow, None);
|
||||
let outcome = match self.hit_special(id, amount, by_player, true) {
|
||||
let outcome = match self.hit_special(
|
||||
id,
|
||||
amount,
|
||||
by_player,
|
||||
horizon_client::mobs::DamageKind::Arrow,
|
||||
) {
|
||||
super::dragon::Hurt::Landed { .. } => EntityHit::Hurt,
|
||||
super::dragon::Hurt::Refused => EntityHit::Refused,
|
||||
super::dragon::Hurt::Shrugged => {
|
||||
@@ -3375,7 +3467,13 @@ impl Game<'_> {
|
||||
ProjectileKind::SmallFireball | ProjectileKind::LargeFireball { .. } => 6.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
self.hit_special(id, amount, by_player, false);
|
||||
let kind = match p.kind {
|
||||
ProjectileKind::SmallFireball | ProjectileKind::LargeFireball { .. } => {
|
||||
horizon_client::mobs::DamageKind::Fireball
|
||||
}
|
||||
_ => horizon_client::mobs::DamageKind::Thrown,
|
||||
};
|
||||
self.hit_special(id, amount, by_player, kind);
|
||||
// ThrowableProjectile.onHit goes on after onHitEntity: a pearl
|
||||
// still takes its owner to where it broke (into the crystal's
|
||||
// blast), an egg still hatches, a snowball still breaks.
|
||||
@@ -3464,11 +3562,19 @@ impl Game<'_> {
|
||||
}])?;
|
||||
}
|
||||
let hurt = match who {
|
||||
Who::Player => self.mob_hits_player(
|
||||
amount,
|
||||
[p.previous.x, p.previous.y, p.previous.z],
|
||||
"minecraft:arrow",
|
||||
),
|
||||
Who::Player => {
|
||||
let hurt = self.mob_hits_player(
|
||||
amount,
|
||||
[p.previous.x, p.previous.y, p.previous.z],
|
||||
"minecraft:arrow",
|
||||
);
|
||||
// AbstractArrow.onHitEntity: a hit that hurt leaves a
|
||||
// non-piercing arrow stuck in the body.
|
||||
if hurt && p.arrow.as_ref().is_some_and(|a| a.pierce == 0) {
|
||||
self.combat.stuck.add_arrow();
|
||||
}
|
||||
hurt
|
||||
}
|
||||
Who::Mob(id) => {
|
||||
let hurt = self
|
||||
.mobs
|
||||
@@ -3714,14 +3820,19 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Who::Mob(id) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
let hurt = self.mobs.iter_mut().find(|m| m.id == id).is_some_and(|m| {
|
||||
let remaining = m.fire_ticks;
|
||||
if !m.kind.flags.fire_immune {
|
||||
m.ignite_for_ticks(100);
|
||||
}
|
||||
if !m.damage(5.0, source) {
|
||||
let hurt = m.damage(5.0, source);
|
||||
if !hurt {
|
||||
m.fire_ticks = remaining;
|
||||
}
|
||||
hurt
|
||||
});
|
||||
if hurt {
|
||||
self.player_hurt_mob(p, id);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3756,8 +3867,13 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
Who::Mob(id) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.damage(6.0, source);
|
||||
if self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == id)
|
||||
.is_some_and(|m| m.damage(6.0, source))
|
||||
{
|
||||
self.player_hurt_mob(p, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5023,7 +5139,11 @@ impl Game<'_> {
|
||||
let outline = self.mob_outline(mob);
|
||||
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
|
||||
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
|
||||
if mob.invisible && outline.is_none() {
|
||||
// A player keeps its armour, elytra and held items
|
||||
// (`LivingEntityRenderer.submit` skips only the body).
|
||||
let is_player = &*mob.kind.id == "minecraft:player";
|
||||
let layers_only = mob.invisible && outline.is_none() && is_player;
|
||||
if mob.invisible && outline.is_none() && !layers_only {
|
||||
continue;
|
||||
}
|
||||
// LevelRenderer: the entity the view looks out of is not drawn
|
||||
@@ -5088,12 +5208,17 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
// Other players on a server: PlayerRenderer's HumanoidArmorLayer.
|
||||
let armor = if &*mob.kind.id == "minecraft:player" {
|
||||
let armor = if is_player {
|
||||
self.worn_armor_layers(mob)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
horizon_client::mobs::render::render_with(
|
||||
let render = if layers_only {
|
||||
horizon_client::mobs::render::render_layers
|
||||
} else {
|
||||
horizon_client::mobs::render::render_with
|
||||
};
|
||||
render(
|
||||
mob,
|
||||
alpha as f32,
|
||||
camera,
|
||||
@@ -5103,12 +5228,24 @@ impl Game<'_> {
|
||||
&armor,
|
||||
out,
|
||||
);
|
||||
if &*mob.kind.id == "minecraft:player" {
|
||||
if is_player {
|
||||
// PlayerRenderer's CapeLayer, WingsLayer and SpinAttackEffectLayer.
|
||||
// Only the cape hides with the body; WingsLayer and
|
||||
// SpinAttackEffectLayer have no `isInvisible` check.
|
||||
let chest = mob.extra.chest.as_deref();
|
||||
self.player_cape_geometry(mob, chest, camera, alpha as f32, packed, out);
|
||||
if !layers_only {
|
||||
self.player_cape_geometry(mob, chest, camera, alpha as f32, packed, out);
|
||||
}
|
||||
if let Some((_, name)) = self
|
||||
.remote_player_names()
|
||||
.into_iter()
|
||||
.find(|(id, _)| *id == mob.id)
|
||||
{
|
||||
self.player_ears_geometry(mob, &name, camera, alpha as f32, packed, out);
|
||||
}
|
||||
self.player_elytra_geometry(mob, chest, camera, alpha as f32, packed, out);
|
||||
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
|
||||
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
|
||||
}
|
||||
// CustomHeadLayer (a pumpkin, skull or head item) included.
|
||||
self.mob_block_geometry(mob, camera, alpha, packed, out);
|
||||
@@ -5263,7 +5400,7 @@ impl Game<'_> {
|
||||
a.kind,
|
||||
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge
|
||||
) {
|
||||
self.wind_charge_geometry(a, c, packed, out);
|
||||
self.wind_charge_geometry(a, c, packed, alpha as f32, out);
|
||||
continue;
|
||||
}
|
||||
// ShulkerBulletRenderer: the tumbling bullet model, then a larger
|
||||
@@ -5770,6 +5907,7 @@ mod pearl_tests {
|
||||
player: None,
|
||||
nearest_player: None,
|
||||
others: &[],
|
||||
leash_boats: &[],
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
daylight: true,
|
||||
|
||||
@@ -66,8 +66,17 @@ impl horizon_client::cushion::World for PlacementWorld<'_, '_> {
|
||||
.collect()
|
||||
}
|
||||
fn suffocating(&self, p: [i32; 3]) -> bool {
|
||||
let state = self.0.world.block(p[0], p[1], p[2]);
|
||||
let facts = self.0.res.facts().get(state);
|
||||
self.0
|
||||
.state_suffocating(self.0.world.block(p[0], p[1], p[2]))
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `BlockBehaviour.Properties.isSuffocating` of a state: the block's
|
||||
/// explicit predicate, else `causes_suffocation` with a full collision
|
||||
/// shape.
|
||||
pub(super) fn state_suffocating(&self, state: horizon_world::registry::BlockStateId) -> bool {
|
||||
let facts = self.res.facts().get(state);
|
||||
let name = facts.name.as_ref();
|
||||
// Blocks' explicit predicates, then the default causes_suffocation/full-shape rule.
|
||||
match name {
|
||||
@@ -76,7 +85,6 @@ impl horizon_client::cushion::World for PlacementWorld<'_, '_> {
|
||||
| "minecraft:mud"
|
||||
| "minecraft:soul_sand" => true,
|
||||
"minecraft:piston" | "minecraft:sticky_piston" => self
|
||||
.0
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
@@ -95,13 +103,43 @@ impl horizon_client::cushion::World for PlacementWorld<'_, '_> {
|
||||
_ => {
|
||||
facts.full_collision
|
||||
&& self
|
||||
.0
|
||||
.res
|
||||
.items()
|
||||
.block_in_tag("minecraft:causes_suffocation", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `BlockState.isViewBlocking(level, pos, nearPlaneBox)`: stairs and
|
||||
/// slabs (`NEAR_PLANE_INTERSECTS_OUTLINE`) block when the near plane
|
||||
/// touches a box of their shape, snow only at eight layers, anything else
|
||||
/// when it is suffocating.
|
||||
pub(super) fn state_view_blocking(
|
||||
&self,
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
pos: [i32; 3],
|
||||
near_plane: Aabb,
|
||||
) -> bool {
|
||||
let name = self.res.facts().get(state).name.as_ref();
|
||||
if name == "minecraft:snow" {
|
||||
return self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("layers"))
|
||||
.is_some_and(|l| l.parse::<i32>().is_ok_and(|l| l >= 8));
|
||||
}
|
||||
if (name.ends_with("_stairs") || name.ends_with("_slab"))
|
||||
&& name != "minecraft:petrified_oak_slab"
|
||||
{
|
||||
return self
|
||||
.outline_boxes(pos)
|
||||
.iter()
|
||||
.any(|b| aabb(([b[0], b[1], b[2]], [b[3], b[4], b[5]])).intersects(near_plane));
|
||||
}
|
||||
self.state_suffocating(state)
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
|
||||
@@ -93,7 +93,11 @@ impl Game<'_> {
|
||||
// releases still reach KeyboardHandler.
|
||||
let typing = matches!(
|
||||
self.screen,
|
||||
Screen::Chat | Screen::Options | Screen::WorldOptions | Screen::CommandBlock
|
||||
Screen::Chat
|
||||
| Screen::Options
|
||||
| Screen::WorldOptions
|
||||
| Screen::CommandBlock
|
||||
| Screen::BlockEditor
|
||||
) || self.sign_edit.is_some()
|
||||
|| self.book.is_some()
|
||||
|| (self.screen == Screen::Creative && self.creative_screen.tab().search)
|
||||
@@ -852,8 +856,9 @@ impl Game<'_> {
|
||||
self.clipboard.set(text);
|
||||
}
|
||||
/// The debug renderers' gizmos for a frame (`DebugRenderer.emitGizmos`).
|
||||
pub(super) fn debug_gizmos(&self, alpha: f32) -> Gizmos {
|
||||
pub(super) fn debug_gizmos(&self, alpha: f32, camera: [f64; 3]) -> Gizmos {
|
||||
let mut g = Gizmos::default();
|
||||
self.bounding_box_gizmos(camera, &mut g);
|
||||
if self.show_chunk_borders
|
||||
&& let Some(p) = &self.player
|
||||
{
|
||||
|
||||
@@ -165,6 +165,29 @@ pub(super) fn player_joined(id: u128, name: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `server.toml`'s `[anticheat]`, beside server.properties when horizon-server runs the game; off
|
||||
/// without the file or the section, or (with an error logged) when the section is wrong.
|
||||
fn load_anticheat() -> horizon_anticheat::Config {
|
||||
let Ok(text) = std::fs::read_to_string("server.toml") else {
|
||||
return Default::default();
|
||||
};
|
||||
match horizon_anticheat::Config::from_server_toml(&text) {
|
||||
Ok(config) => {
|
||||
if config.enabled {
|
||||
log("INFO", "Anticheat on (server.toml [anticheat])");
|
||||
}
|
||||
config
|
||||
}
|
||||
Err(e) => {
|
||||
log(
|
||||
"ERROR",
|
||||
&format!("server.toml: {e}; the anticheat stays off"),
|
||||
);
|
||||
Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Log4j's console pattern: `[HH:mm:ss] [thread/LEVEL]: message`.
|
||||
pub(super) fn log(level: &str, message: &str) {
|
||||
log_from("Server thread", level, message);
|
||||
@@ -252,6 +275,9 @@ pub(super) fn preflight(options: &mut Options) -> Result<bool> {
|
||||
for w in &props.warnings {
|
||||
log("WARN", w);
|
||||
}
|
||||
// A dedicated server's tree: /op, /ban, /whitelist... (single player's
|
||||
// server thread is an integrated one and never gets here).
|
||||
horizon_client::command::vanilla::set_dedicated_commands(true);
|
||||
// Horizon's own commands are the owner's choice, off as on vanilla.
|
||||
if props
|
||||
.raw
|
||||
@@ -312,6 +338,8 @@ pub(super) struct Lists {
|
||||
pub ip_bans: IpBanList,
|
||||
pub use_whitelist: bool,
|
||||
pub enforce_whitelist: bool,
|
||||
/// `op-permission-level`: what `/op` grants (operatorUserPermissions).
|
||||
pub op_level: i32,
|
||||
}
|
||||
impl Lists {
|
||||
fn load() -> Self {
|
||||
@@ -322,6 +350,7 @@ impl Lists {
|
||||
ip_bans: IpBanList::new("banned-ips.json"),
|
||||
use_whitelist: false,
|
||||
enforce_whitelist: false,
|
||||
op_level: 4,
|
||||
};
|
||||
// DedicatedPlayerList's constructor order and its LOGGER.warn lines.
|
||||
let warn = |what: &str, r: std::result::Result<(), String>| {
|
||||
@@ -660,7 +689,9 @@ impl Game<'_> {
|
||||
let mut lists = Lists::load();
|
||||
lists.use_whitelist = props.white_list;
|
||||
lists.enforce_whitelist = props.enforce_whitelist;
|
||||
lists.op_level = props.op_permission_level;
|
||||
let lists = Arc::new(Mutex::new(lists));
|
||||
self.server_lists = Some(lists.clone());
|
||||
log("INFO", &format!("Preparing level \"{level_name}\""));
|
||||
let folder = world_dir.to_string_lossy().into_owned();
|
||||
self.native_lifecycle_open_started();
|
||||
@@ -701,6 +732,13 @@ impl Game<'_> {
|
||||
gate_lists.lock().unwrap().can_login(address, &user)
|
||||
});
|
||||
let bypass_lists = lists.clone();
|
||||
// DedicatedServer's constructor: `enable-code-of-conduct` reads the
|
||||
// `codeofconduct` folder, and a folder it can't read stops the start.
|
||||
let code_of_conducts = if props.code_of_conduct {
|
||||
horizon_client::lan::code_of_conduct::read(std::path::Path::new("codeofconduct"))?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let favicon = std::fs::read("server-icon.png")
|
||||
.ok()
|
||||
.filter(|b| png_size(b) == Some((64, 64)));
|
||||
@@ -737,6 +775,7 @@ impl Game<'_> {
|
||||
log_ips: props.log_ips,
|
||||
}),
|
||||
resource_pack: props.resource_pack.clone(),
|
||||
code_of_conducts,
|
||||
// DedicatedServer.serverLinks: `bug-report-link` as the only link
|
||||
// (ServerLinks.KnownLinkType.BUG_REPORT is id 0).
|
||||
levels: self.level_shapes,
|
||||
@@ -758,8 +797,10 @@ impl Game<'_> {
|
||||
);
|
||||
Vec::new()
|
||||
},
|
||||
owner_profile: None,
|
||||
};
|
||||
let config = HubConfig {
|
||||
allow_flight: props.allow_flight,
|
||||
default_game_mode: game_mode,
|
||||
guest_commands: true,
|
||||
force_game_mode: props.force_game_mode,
|
||||
@@ -785,7 +826,9 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
self.sync_ops(&lists);
|
||||
let anticheat = load_anticheat();
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
host.hub.set_anticheat(anticheat);
|
||||
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);
|
||||
@@ -874,6 +917,13 @@ impl Game<'_> {
|
||||
{
|
||||
self.finish_report(recording, state.view, state.max_players);
|
||||
}
|
||||
// DedicatedServer.sendLowDiskSpaceWarning: the operators hear of it
|
||||
// (no window drains the toasts here).
|
||||
if horizon_client::chunk_io::take().low_disk_space
|
||||
&& let Some(host) = self.lan.as_ref()
|
||||
{
|
||||
host.hub.send_low_disk_space_warning();
|
||||
}
|
||||
if idle_timeout > 0 {
|
||||
self.kick_idle(idle_timeout);
|
||||
}
|
||||
@@ -980,7 +1030,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// Ops' permission levels for the hub (PlayerList.getProfilePermissions).
|
||||
fn sync_ops(&mut self, lists: &Arc<Mutex<Lists>>) {
|
||||
pub(super) fn sync_ops(&mut self, lists: &Arc<Mutex<Lists>>) {
|
||||
let Some(host) = self.lan.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -996,7 +1046,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// A player on the server, by name.
|
||||
fn online_player(&self, name: &str) -> Option<NameAndId> {
|
||||
pub(super) fn online_player(&self, name: &str) -> Option<NameAndId> {
|
||||
let hub = &self.lan.as_ref()?.hub;
|
||||
let g = hub
|
||||
.guests
|
||||
@@ -1012,7 +1062,18 @@ impl Game<'_> {
|
||||
/// service, else (offline) the offline player. None, told to the console:
|
||||
/// no such player.
|
||||
fn resolve(&self, name: &str) -> Option<NameAndId> {
|
||||
let found = USER_CACHE.lock().unwrap().as_mut().and_then(|cache| {
|
||||
let found = self.resolve_name(name);
|
||||
if found.is_none() {
|
||||
let unknown = Text::translate("argument.player.unknown", &[]);
|
||||
log("INFO", &format!("System chat: {}", unknown.plain()));
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// `Services.nameToIdCache().get(name)`: the user cache, else the profile
|
||||
/// service, else (offline) the offline player.
|
||||
pub(super) fn resolve_name(&self, name: &str) -> Option<NameAndId> {
|
||||
USER_CACHE.lock().unwrap().as_mut().and_then(|cache| {
|
||||
cache.get_by_name(name, |lower| {
|
||||
let found = profiles::find_by_name(&profiles::discovery_url(), lower);
|
||||
if found.is_none() {
|
||||
@@ -1020,12 +1081,7 @@ impl Game<'_> {
|
||||
}
|
||||
found
|
||||
})
|
||||
});
|
||||
if found.is_none() {
|
||||
let unknown = Text::translate("argument.player.unknown", &[]);
|
||||
log("INFO", &format!("System chat: {}", unknown.plain()));
|
||||
}
|
||||
found
|
||||
})
|
||||
}
|
||||
|
||||
/// MinecraftServer.sendSystemMessage: what the console is told; while
|
||||
@@ -1475,8 +1531,15 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `DedicatedServer.setUsingWhitelist`: the flag, and `white-list` in
|
||||
/// server.properties.
|
||||
pub(super) fn set_using_whitelist(&mut self, lists: &Arc<Mutex<Lists>>, on: bool) {
|
||||
lists.lock().unwrap().use_whitelist = on;
|
||||
self.store_property("white-list", on.to_string());
|
||||
}
|
||||
|
||||
/// DedicatedServerSettings.update: change one key and store the file.
|
||||
fn store_property(&mut self, key: &str, value: String) {
|
||||
pub(super) fn store_property(&mut self, key: &str, value: String) {
|
||||
let path = PathBuf::from("server.properties");
|
||||
if let Ok(p) = ServerProperties::from_file(&path, &mut SystemEntropy::default())
|
||||
&& let Ok(p) = p.update(key, value)
|
||||
@@ -1488,7 +1551,7 @@ impl Game<'_> {
|
||||
/// MinecraftServer.kickUnlistedPlayers: with `enforce-whitelist` and the
|
||||
/// whitelist on, whoever is online and not on the whitelist file is
|
||||
/// disconnected (operators are not spared here).
|
||||
fn kick_unlisted_players(&mut self, lists: &Arc<Mutex<Lists>>) {
|
||||
pub(super) fn kick_unlisted_players(&mut self, lists: &Arc<Mutex<Lists>>) {
|
||||
let unlisted: Vec<u128> = {
|
||||
let l = lists.lock().unwrap();
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
@@ -1517,7 +1580,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn kick_uuid(&mut self, uuid: u128, reason: Tag) {
|
||||
pub(super) fn kick_uuid(&mut self, uuid: u128, reason: Tag) {
|
||||
if let Some(h) = self.lan.as_mut() {
|
||||
let conns: Vec<u32> = h
|
||||
.hub
|
||||
|
||||
@@ -190,6 +190,7 @@ impl Game<'_> {
|
||||
"players": players,
|
||||
"levels": self.report_levels(),
|
||||
"memory": { "rss_mb": horizon_bench::rss_bytes(std::process::id()).map(|b| b >> 20) },
|
||||
"process": process_limits(),
|
||||
"cpu_cores_used": cpu,
|
||||
"config": {
|
||||
"view_distance": view.0,
|
||||
@@ -265,9 +266,49 @@ fn summary(r: &Value) -> String {
|
||||
"{mobs} mobs, {chunks} chunks, {} MB resident, {} cores of CPU",
|
||||
r["memory"]["rss_mb"], r["cpu_cores_used"]
|
||||
);
|
||||
let process = &r["process"];
|
||||
if let Some(threads) = process["threads"].as_u64() {
|
||||
out += &format!("\n{threads} threads");
|
||||
for (key, name) in [
|
||||
("cgroup_pids_current", "container tasks"),
|
||||
("cgroup_pids_max", "container task limit"),
|
||||
("max_processes", "max processes (soft / hard)"),
|
||||
] {
|
||||
if let Some(value) = process[key].as_str() {
|
||||
out += &format!(", {name} {value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What the system lets this process start: its threads now, and the limits
|
||||
/// a container puts on them and on memory. A server out of threads fails to
|
||||
/// start the ones it needs (EAGAIN). Read on Linux; null elsewhere.
|
||||
fn process_limits() -> Value {
|
||||
let read = |path: &str| std::fs::read_to_string(path).ok();
|
||||
let threads = read("/proc/self/status").and_then(|s| {
|
||||
s.lines()
|
||||
.find_map(|l| l.strip_prefix("Threads:"))
|
||||
.and_then(|v| v.trim().parse::<u64>().ok())
|
||||
});
|
||||
// "Max processes <soft> <hard> processes"
|
||||
let max_processes = read("/proc/self/limits").and_then(|s| {
|
||||
let line = s.lines().find(|l| l.starts_with("Max processes"))?;
|
||||
let words: Vec<&str> = line.split_whitespace().skip(2).take(2).collect();
|
||||
Some(words.join(" / "))
|
||||
});
|
||||
let cgroup = |name: &str| read(&format!("/sys/fs/cgroup/{name}")).map(|s| s.trim().to_owned());
|
||||
json!({
|
||||
"threads": threads,
|
||||
"max_processes": max_processes,
|
||||
"cgroup_pids_current": cgroup("pids.current"),
|
||||
"cgroup_pids_max": cgroup("pids.max"),
|
||||
"cgroup_memory_current": cgroup("memory.current"),
|
||||
"cgroup_memory_max": cgroup("memory.max"),
|
||||
})
|
||||
}
|
||||
|
||||
fn write_report(dir: &Path, report: &Value, text: &str) -> std::io::Result<PathBuf> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let stamp = chrono::Utc::now().format("%Y-%m-%d_%H-%M-%SZ");
|
||||
@@ -329,5 +370,18 @@ mod tests {
|
||||
text.ends_with("5 mobs, 9 chunks, 300 MB resident, 1.5 cores of CPU"),
|
||||
"{text}"
|
||||
);
|
||||
let text = summary(
|
||||
&json!({"ticks": ticks, "spans": spans, "worst_ticks": worst,
|
||||
"players": 3, "levels": [], "memory": {"rss_mb": 300}, "cpu_cores_used": 1.5,
|
||||
"process": {"threads": 140, "cgroup_pids_current": "160", "cgroup_pids_max": "512",
|
||||
"max_processes": "unlimited / unlimited"}}),
|
||||
);
|
||||
assert!(
|
||||
text.ends_with(
|
||||
"140 threads, container tasks 160, container task limit 512, \
|
||||
max processes (soft / hard) unlimited / unlimited"
|
||||
),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,10 @@ use horizon_client::{
|
||||
clipboard::EditShortcut,
|
||||
confirm_screen::Confirm,
|
||||
dialog::{AfterAction, Body, Click, Control, Dialog, InputValue, Kind},
|
||||
dialog_screen::{BOX, Layout, Part, WARNING_SPRITES, waiting_button},
|
||||
dialog_screen::{BOX, Layout, Part, WARNING_SPRITES, text_height, waiting_button},
|
||||
font::Font,
|
||||
hud::{HudBatch, rgba},
|
||||
multiline_edit::MultiLineEdit,
|
||||
text::{ClickEvent, Text},
|
||||
ui,
|
||||
};
|
||||
@@ -23,11 +25,54 @@ struct Open {
|
||||
dialog: Dialog,
|
||||
values: Vec<InputValue>,
|
||||
/// The box of each text input.
|
||||
fields: Vec<Option<ui::EditBox>>,
|
||||
fields: Vec<Option<Field>>,
|
||||
focus: Option<usize>,
|
||||
scroll: ui::Scroll,
|
||||
/// The number range input whose slider is held.
|
||||
dragging: Option<usize>,
|
||||
/// The multiline input the pointer is selecting in.
|
||||
selecting: Option<usize>,
|
||||
}
|
||||
|
||||
/// A text input's control: `EditBox`, or `MultiLineEditBox` for a multiline one.
|
||||
enum Field {
|
||||
Line(ui::EditBox),
|
||||
Multi(Box<MultiLineEdit>),
|
||||
}
|
||||
impl Field {
|
||||
fn value(&self) -> String {
|
||||
match self {
|
||||
Self::Line(edit) => edit.value.clone(),
|
||||
Self::Multi(edit) => edit.value(),
|
||||
}
|
||||
}
|
||||
fn insert_text(&mut self, font: &Font, text: &str) {
|
||||
match self {
|
||||
Self::Line(edit) => edit.insert_text(text),
|
||||
Self::Multi(edit) => edit.insert_text(font, text),
|
||||
}
|
||||
}
|
||||
fn key(&mut self, font: &Font, key: KeyCode, ctrl: bool, shift: bool) -> bool {
|
||||
match self {
|
||||
Self::Line(edit) => edit.key(key, ctrl, shift),
|
||||
Self::Multi(edit) => edit.key(font, key, ctrl, shift),
|
||||
}
|
||||
}
|
||||
fn shortcut(
|
||||
&mut self,
|
||||
font: &Font,
|
||||
shortcut: EditShortcut,
|
||||
clipboard: &mut dyn horizon_client::clipboard::Clipboard,
|
||||
) {
|
||||
match self {
|
||||
Self::Line(edit) => {
|
||||
edit.shortcut(shortcut, clipboard);
|
||||
}
|
||||
Self::Multi(edit) => {
|
||||
edit.shortcut(font, shortcut, clipboard);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog screens a server can open.
|
||||
@@ -100,10 +145,35 @@ impl Game<'_> {
|
||||
dialog.set_list(&entries.unwrap_or_default());
|
||||
}
|
||||
let values = dialog.inputs.iter().map(|i| i.control.initial()).collect();
|
||||
let font = &self.gui.atlas.font;
|
||||
let fields = dialog
|
||||
.inputs
|
||||
.iter()
|
||||
.map(|i| match &i.control {
|
||||
Control::Text {
|
||||
width,
|
||||
initial,
|
||||
max_length,
|
||||
multiline: Some((max_lines, _)),
|
||||
..
|
||||
} => {
|
||||
// TextInputHandler: the `MultiLineEditBox` with its limits.
|
||||
let height = text_height(Some((*max_lines, None)));
|
||||
let height = match &i.control {
|
||||
Control::Text {
|
||||
multiline: Some((_, Some(h))),
|
||||
..
|
||||
} => *h as f32,
|
||||
_ => height,
|
||||
};
|
||||
let mut edit = MultiLineEdit::new(font, 0.0, 0.0, *width as f32, height);
|
||||
edit.set_character_limit((*max_length).max(0) as usize);
|
||||
if let Some(lines) = max_lines {
|
||||
edit.set_line_limit((*lines).max(0) as usize);
|
||||
}
|
||||
edit.set_value(font, initial);
|
||||
Some(Field::Multi(Box::new(edit)))
|
||||
}
|
||||
Control::Text {
|
||||
width,
|
||||
initial,
|
||||
@@ -114,7 +184,7 @@ impl Game<'_> {
|
||||
edit.max_length = (*max_length).max(1) as usize;
|
||||
edit.set_value(initial);
|
||||
edit.focused = false;
|
||||
Some(edit)
|
||||
Some(Field::Line(edit))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
@@ -126,6 +196,7 @@ impl Game<'_> {
|
||||
focus: None,
|
||||
scroll: ui::Scroll::default(),
|
||||
dragging: None,
|
||||
selecting: None,
|
||||
});
|
||||
self.dialogs.waiting = None;
|
||||
self.capture(false);
|
||||
@@ -154,7 +225,7 @@ impl Game<'_> {
|
||||
fn dialog_values(open: &mut Open) {
|
||||
for (value, field) in open.values.iter_mut().zip(&open.fields) {
|
||||
if let Some(f) = field {
|
||||
*value = InputValue::Text(f.value.clone());
|
||||
*value = InputValue::Text(f.value());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,8 +276,20 @@ impl Game<'_> {
|
||||
}
|
||||
// ConfirmLinkScreen over the screen the dialog leaves.
|
||||
Some(Click::OpenUrl(url)) => self.click_url(url),
|
||||
// Screen.insertText: the screen after the dialog takes no text.
|
||||
Some(Click::SuggestCommand(_)) => {}
|
||||
// defaultHandleClickEvent: `activeScreen.insertText(command, true)`.
|
||||
// Only the chat screen (opened under the dialog) takes text.
|
||||
Some(Click::SuggestCommand(command)) => {
|
||||
if !self.dialog_open() && self.ui.chat_screen.is_some() {
|
||||
let m = horizon_client::screen_draw::Measure(&self.gui.atlas.font);
|
||||
let c = horizon_client::chat::Commands {
|
||||
dispatcher: super::chat::chat_tree(&self.remote),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
if let Some(screen) = self.ui.chat_screen.as_mut() {
|
||||
screen.suggest_command(&m, &c, &command);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(other) => log::error!("Don't know how to handle {other:?}"),
|
||||
None => {}
|
||||
}
|
||||
@@ -359,10 +442,19 @@ impl Game<'_> {
|
||||
batch.text_styled(atlas, &text, x, y, u32::MAX, true);
|
||||
}
|
||||
Part::Field { input, rect } => {
|
||||
if let Some(Some(edit)) = open.fields.get_mut(input) {
|
||||
[edit.x, edit.y, edit.width, edit.height] = rect;
|
||||
edit.focused = open.focus == Some(input);
|
||||
edit.draw(batch, atlas, None);
|
||||
let focused = open.focus == Some(input);
|
||||
match open.fields.get_mut(input) {
|
||||
Some(Some(Field::Line(edit))) => {
|
||||
[edit.x, edit.y, edit.width, edit.height] = rect;
|
||||
edit.focused = focused;
|
||||
edit.draw(batch, atlas, None);
|
||||
}
|
||||
Some(Some(Field::Multi(edit))) => {
|
||||
[edit.x, edit.y, edit.width, edit.height] = rect;
|
||||
edit.set_focused(focused);
|
||||
edit.draw(batch, atlas);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Part::Check { input, rect } => {
|
||||
@@ -555,11 +647,32 @@ impl Game<'_> {
|
||||
Part::Field {
|
||||
input,
|
||||
rect: [x, y, w, h],
|
||||
} if ui::hovered(cursor, x, y, w, h) => {
|
||||
} if ui::hovered(
|
||||
cursor,
|
||||
x,
|
||||
y,
|
||||
// A multiline box answers over its scroll bar too.
|
||||
w + if matches!(open.fields.get(input), Some(Some(Field::Multi(_)))) {
|
||||
6.0
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
h,
|
||||
) =>
|
||||
{
|
||||
open.focus = Some(input);
|
||||
if let Some(Some(edit)) = open.fields.get_mut(input) {
|
||||
edit.focused = true;
|
||||
edit.click(&self.gui.atlas, cursor.0, false, false);
|
||||
match open.fields.get_mut(input) {
|
||||
Some(Some(Field::Line(edit))) => {
|
||||
edit.focused = true;
|
||||
edit.click(&self.gui.atlas, cursor.0, false, false);
|
||||
}
|
||||
Some(Some(Field::Multi(edit))) => {
|
||||
[edit.x, edit.y, edit.width, edit.height] = [x, y, w, h];
|
||||
edit.set_focused(true);
|
||||
edit.click(&self.gui.atlas.font, cursor, shift, false);
|
||||
open.selecting = Some(input);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Part::Check {
|
||||
@@ -669,6 +782,30 @@ impl Game<'_> {
|
||||
cycle(open, input, if delta > 0.0 { -1 } else { 1 });
|
||||
return;
|
||||
}
|
||||
// AbstractScrollArea.mouseScrolled: a multiline box takes the wheel.
|
||||
let text = layout
|
||||
.placed(open.scroll.amount)
|
||||
.find_map(|(part, _)| match part {
|
||||
Part::Field { input, rect } => match open.fields.get(input) {
|
||||
Some(Some(Field::Multi(_)))
|
||||
if ui::hovered(
|
||||
cursor,
|
||||
rect[0],
|
||||
rect[1],
|
||||
rect[2] + 6.0,
|
||||
rect[3],
|
||||
) =>
|
||||
{
|
||||
Some(input)
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
});
|
||||
if let Some(Some(Field::Multi(edit))) = text.and_then(|i| open.fields.get_mut(i)) {
|
||||
edit.wheel(delta);
|
||||
return;
|
||||
}
|
||||
}
|
||||
open.scroll.wheel(delta, 10.0, ay, ay + ah, layout.content);
|
||||
}
|
||||
@@ -676,6 +813,14 @@ impl Game<'_> {
|
||||
|
||||
/// The pointer moved with a slider held (`AbstractSliderButton.onDrag`).
|
||||
pub(super) fn dialog_drag(&mut self) {
|
||||
let (cursor, shift) = (self.cursor, self.modifiers.shift_key());
|
||||
if let Some(open) = self.dialogs.open.as_mut()
|
||||
&& let Some(Some(Field::Multi(edit))) =
|
||||
open.selecting.and_then(|i| open.fields.get_mut(i))
|
||||
{
|
||||
// MultiLineEditBox.onDrag.
|
||||
edit.drag(&self.gui.atlas.font, cursor, shift);
|
||||
}
|
||||
let Some(input) = self.dialogs.open.as_ref().and_then(|o| o.dragging) else {
|
||||
return;
|
||||
};
|
||||
@@ -697,10 +842,15 @@ impl Game<'_> {
|
||||
|
||||
/// The left button released: a held slider lets go and clicks.
|
||||
pub(super) fn dialog_release(&mut self) {
|
||||
if let Some(open) = self.dialogs.open.as_mut()
|
||||
&& open.dragging.take().is_some()
|
||||
{
|
||||
self.audio.play_button_click();
|
||||
if let Some(open) = self.dialogs.open.as_mut() {
|
||||
if let Some(Some(Field::Multi(edit))) =
|
||||
open.selecting.take().and_then(|i| open.fields.get_mut(i))
|
||||
{
|
||||
edit.release();
|
||||
}
|
||||
if open.dragging.take().is_some() {
|
||||
self.audio.play_button_click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -765,11 +915,12 @@ impl Game<'_> {
|
||||
let Some(Some(edit)) = open.focus.and_then(|f| open.fields.get_mut(f)) else {
|
||||
return Ok(());
|
||||
};
|
||||
let font = &self.gui.atlas.font;
|
||||
if let Some(shortcut) = shortcut {
|
||||
edit.shortcut(shortcut, &mut self.clipboard);
|
||||
edit.shortcut(font, shortcut, &mut self.clipboard);
|
||||
return Ok(());
|
||||
}
|
||||
if edit.key(key, ctrl, shift) {
|
||||
if edit.key(font, key, ctrl, shift) {
|
||||
return Ok(());
|
||||
}
|
||||
let typed: String = text
|
||||
@@ -778,7 +929,7 @@ impl Game<'_> {
|
||||
.filter(|c| !c.is_control())
|
||||
.collect();
|
||||
if !typed.is_empty() {
|
||||
edit.insert_text(&typed);
|
||||
edit.insert_text(font, &typed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -796,7 +947,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
};
|
||||
open.focus = focus;
|
||||
edit.insert_text(text);
|
||||
edit.insert_text(&self.gui.atlas.font, text);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -815,7 +966,7 @@ impl Game<'_> {
|
||||
let mut values = open.values.clone();
|
||||
for (value, field) in values.iter_mut().zip(&open.fields) {
|
||||
if let Some(f) = field {
|
||||
*value = InputValue::Text(f.value.clone());
|
||||
*value = InputValue::Text(f.value());
|
||||
}
|
||||
}
|
||||
open.dialog.json(&values)
|
||||
|
||||
@@ -1545,7 +1545,8 @@ impl Game<'_> {
|
||||
};
|
||||
let target = (placed.x, placed.y, placed.z);
|
||||
let state = self.apply_item_block_state([target.0, target.1, target.2], state, &stack)?;
|
||||
if let Some(entity) = BlockEntity::for_block(&self.res.facts().get(state).name) {
|
||||
let name = self.res.facts().get(state).name.clone();
|
||||
if let Some(entity) = self.new_block_entity(&name) {
|
||||
self.world.block_entities.insert(target, entity);
|
||||
self.apply_placed_item([target.0, target.1, target.2], &stack, false);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use horizon_client::{
|
||||
hud::HudBatch,
|
||||
hud_layout, hud_overlays,
|
||||
mesher::Vertex,
|
||||
mobs::{Damage, DamageKind, Who},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
text::Text,
|
||||
};
|
||||
@@ -53,6 +54,10 @@ pub(super) struct Fight {
|
||||
/// The player behind the crystal blast under way, whose `explosion`
|
||||
/// damage source names them (`EndCrystal.hurtServer`).
|
||||
blast_by: Option<FightWho>,
|
||||
/// The last hit the dragon took (`die(source)`'s source, for the
|
||||
/// `player_killed_entity` trigger's `killing_blow`): its damage type and
|
||||
/// whether a player was behind it.
|
||||
last_blow: Option<(DamageKind, bool)>,
|
||||
dirty: bool,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -923,7 +928,7 @@ impl Game<'_> {
|
||||
if let Some(d) = self.dragon.as_mut() {
|
||||
if d.nearest_crystal == Some(index) {
|
||||
d.nearest_crystal = None;
|
||||
self.hurt_dragon(0, 10.0, true, by, false);
|
||||
self.hurt_dragon(0, 10.0, true, by, DamageKind::Explosion);
|
||||
} else if let Some(n) = d.nearest_crystal.as_mut()
|
||||
&& *n > index
|
||||
{
|
||||
@@ -976,6 +981,15 @@ impl Game<'_> {
|
||||
.find(|(s, _)| *s == CRYSTAL_IDS + i as u32)
|
||||
.map(|(_, b)| b)
|
||||
}
|
||||
/// `ServerLevel.dragonParts`: where the part with that entity id is
|
||||
/// and its box.
|
||||
pub(super) fn dragon_part(&self, id: u32) -> Option<(Vec3, Aabb)> {
|
||||
let d = self.dragon.as_ref().filter(|d| d.alive() && d.id != 0)?;
|
||||
(d.id + 1..=d.id + 8).contains(&id).then(|| {
|
||||
let i = (id - d.id - 1) as usize;
|
||||
(d.parts[i].0, d.part_box(i))
|
||||
})
|
||||
}
|
||||
/// Damage to a special id (from projectiles and melee): what the
|
||||
/// dragon part or crystal made of it. `arrow` for a direct arrow.
|
||||
pub(super) fn hit_special(
|
||||
@@ -983,7 +997,7 @@ impl Game<'_> {
|
||||
id: u32,
|
||||
amount: f32,
|
||||
by_player: bool,
|
||||
arrow: bool,
|
||||
kind: DamageKind,
|
||||
) -> Hurt {
|
||||
if id >= CRYSTAL_IDS {
|
||||
// EndCrystal.hurtServer: false while invulnerable, else it blows up.
|
||||
@@ -996,7 +1010,7 @@ impl Game<'_> {
|
||||
Hurt::Landed { full: true }
|
||||
} else if id >= SPECIAL {
|
||||
let by = by_player.then(|| self.acting_player());
|
||||
self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, by, arrow)
|
||||
self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, by, kind)
|
||||
} else {
|
||||
Hurt::Refused
|
||||
}
|
||||
@@ -1009,8 +1023,9 @@ impl Game<'_> {
|
||||
damage: f32,
|
||||
counts: bool,
|
||||
by: Option<FightWho>,
|
||||
arrow: bool,
|
||||
kind: DamageKind,
|
||||
) -> Hurt {
|
||||
let arrow = kind == DamageKind::Arrow;
|
||||
let Some(d) = self.dragon.as_mut() else {
|
||||
return Hurt::Refused;
|
||||
};
|
||||
@@ -1027,6 +1042,7 @@ impl Game<'_> {
|
||||
d.hurt_by_player = Some((who, d.ticks));
|
||||
}
|
||||
if !was_down && d.down() {
|
||||
self.fight.last_blow = Some((kind, by.is_some()));
|
||||
self.credit_dragon_kill();
|
||||
}
|
||||
let Some(d) = self.dragon.as_mut() else {
|
||||
@@ -1058,6 +1074,7 @@ impl Game<'_> {
|
||||
..Default::default()
|
||||
};
|
||||
let id = d.id;
|
||||
let blow = self.fight.last_blow.take();
|
||||
let guest = match who {
|
||||
FightWho::Guest(conn) if self.lan_acting.is_none() => Some(conn),
|
||||
_ => None,
|
||||
@@ -1067,21 +1084,43 @@ impl Game<'_> {
|
||||
return false;
|
||||
};
|
||||
let done = self
|
||||
.as_guest(&mut host, conn, |g| g.award_dragon_kill(id, &view))
|
||||
.as_guest(&mut host, conn, |g| g.award_dragon_kill(id, &view, blow))
|
||||
.is_some();
|
||||
self.lan = Some(host);
|
||||
done
|
||||
});
|
||||
if !credited {
|
||||
self.award_dragon_kill(id, &view);
|
||||
self.award_dragon_kill(id, &view, blow);
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.awardKillScore` for the dragon, on the killer's state.
|
||||
fn award_dragon_kill(&mut self, id: u32, view: &horizon_client::advancements::EntityView) {
|
||||
fn award_dragon_kill(
|
||||
&mut self,
|
||||
id: u32,
|
||||
view: &horizon_client::advancements::EntityView,
|
||||
blow: Option<(DamageKind, bool)>,
|
||||
) {
|
||||
self.stats.add("killed", "minecraft:ender_dragon", 1);
|
||||
self.stats.custom("mob_kills", 1);
|
||||
self.score_player_killed(super::commands::Target::Mob(id));
|
||||
self.trigger_killed(view, None);
|
||||
// KilledTrigger: the damage source `die` was given.
|
||||
let blow = blow.map(|(kind, by_player)| {
|
||||
let mut view = self.damage_view(Damage {
|
||||
kind,
|
||||
attacker: by_player.then_some(Who::Player),
|
||||
from: None,
|
||||
});
|
||||
// `damageSources().explosion(crystal, player)`: the crystal is the direct entity.
|
||||
if kind == DamageKind::Explosion {
|
||||
view.direct = Some(horizon_client::advancements::EntityView {
|
||||
kind: "minecraft:end_crystal".to_owned(),
|
||||
..Default::default()
|
||||
});
|
||||
view.is_direct = false;
|
||||
}
|
||||
view
|
||||
});
|
||||
self.trigger_killed(view, blow.as_ref());
|
||||
}
|
||||
/// Player melee on the dragon's parts or a crystal under the crosshair.
|
||||
/// The dragon part under the crosshair within reach: its id and distance.
|
||||
@@ -1128,7 +1167,7 @@ impl Game<'_> {
|
||||
.into_iter()
|
||||
.find(|(part, _)| *part == id)
|
||||
.map(|(_, b)| b);
|
||||
let hurt = self.hit_special(id, amount, true, false);
|
||||
let hurt = self.hit_special(id, amount, true, DamageKind::PlayerAttack);
|
||||
// The sweep and the sounds read the charge, which the hit then spends.
|
||||
if let (Some(area), Hurt::Landed { .. }) = (area, hurt) {
|
||||
self.attack_effects(area, None, super::mace::Spared::Nothing, None);
|
||||
@@ -2848,7 +2887,7 @@ impl Game<'_> {
|
||||
});
|
||||
let by = self.fight.blast_by;
|
||||
for (part, amount) in hits {
|
||||
self.hurt_dragon(part, amount, true, by, false);
|
||||
self.hurt_dragon(part, amount, true, by, DamageKind::Explosion);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2888,6 +2927,7 @@ impl Game<'_> {
|
||||
needs_sync: false,
|
||||
leash: None,
|
||||
spectator: false,
|
||||
watching: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 356 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user