mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
316
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af5528ef25 | ||
|
|
f4c1010c64 | ||
|
|
edbeffbcf3 | ||
|
|
8a2e5cb55f | ||
|
|
1c7eb12800 | ||
|
|
02c48952b8 | ||
|
|
0ddaf69abc | ||
|
|
0e060bb2ac | ||
|
|
754440dd55 | ||
|
|
cd79f2e326 | ||
|
|
b4ad459449 | ||
|
|
a3726f4bf8 | ||
|
|
d17163d417 | ||
|
|
a83ac6ac1c | ||
|
|
701168f1e5 | ||
|
|
15a18c3a5e | ||
|
|
7af000900b | ||
|
|
f7ce36c8e1 | ||
|
|
f6154f9ebb | ||
|
|
f5eb5a18f3 | ||
|
|
71e6b60d96 | ||
|
|
f533c3af7d | ||
|
|
25e03754e2 | ||
|
|
00c9b04674 | ||
|
|
20703366f9 | ||
|
|
d7cb3537ea | ||
|
|
59e71f00ef | ||
|
|
fcf76afc05 | ||
|
|
b12a499a9a | ||
|
|
59526900a0 | ||
|
|
4783b8d552 | ||
|
|
ab6851d5a8 | ||
|
|
a03ce15c4e | ||
|
|
d60c8d4e0f | ||
|
|
152a0db8c3 | ||
|
|
b4a7018864 | ||
|
|
c7e33a9163 | ||
|
|
7a4f7d5c99 | ||
|
|
c9f49f3b54 | ||
|
|
8e93b54a41 | ||
|
|
9e50f5618c | ||
|
|
ce5086e2d3 | ||
|
|
9ab3cee2e8 | ||
|
|
ff8bd6a91f | ||
|
|
9c825e67df | ||
|
|
00448b2ea0 | ||
|
|
81410769f5 | ||
|
|
1df5237769 | ||
|
|
9ca78f1309 | ||
|
|
e37351ed75 | ||
|
|
0d6db579ec | ||
|
|
e749114173 | ||
|
|
05bc171f13 | ||
|
|
b7d754a60a | ||
|
|
1c8532cbe1 | ||
|
|
ed6d97cbbb | ||
|
|
d52d37f944 | ||
|
|
d39eb5d638 | ||
|
|
91352551d6 | ||
|
|
848c0b10d4 | ||
|
|
e23b4b7d8f | ||
|
|
396f456556 | ||
|
|
e0cb05bfce | ||
|
|
6f6650a68f | ||
|
|
768d20d998 | ||
|
|
b070f41dda | ||
|
|
8f376a4712 | ||
|
|
63db88b844 | ||
|
|
076c41fc27 | ||
|
|
3f1a45313e | ||
|
|
9d0473d748 | ||
|
|
f6a757ac2e | ||
|
|
ca9195fb02 | ||
|
|
c1c646a326 | ||
|
|
fca2169de9 | ||
|
|
aa906ecbe7 | ||
|
|
c505204c24 | ||
|
|
eeb1d3773d | ||
|
|
bdc422ad8c | ||
|
|
91c77af9ae | ||
|
|
594b50b510 | ||
|
|
1da354b67a | ||
|
|
d0a7c52171 | ||
|
|
92f69c1fca | ||
|
|
7c6bbc1cfd | ||
|
|
12299b07c9 | ||
|
|
0e780d404f | ||
|
|
4ee9816b82 | ||
|
|
d4740f8621 | ||
|
|
20b0fd4eca | ||
|
|
0ad879b601 | ||
|
|
842028904a | ||
|
|
da1f0fb3a4 | ||
|
|
c3139d6de7 | ||
|
|
e296924abb | ||
|
|
c62cba3f17 | ||
|
|
4d3ee1ace0 | ||
|
|
a434ad7ab9 | ||
|
|
33743fb052 | ||
|
|
762ce6114c | ||
|
|
cbbcb4323b | ||
|
|
d2c159e74d | ||
|
|
526c5f0b5b | ||
|
|
61dc245f09 | ||
|
|
8fb961c146 | ||
|
|
0b2ed9162a | ||
|
|
2c80e75d81 | ||
|
|
942e3b2ded | ||
|
|
f1807de201 | ||
|
|
fdc4a8b80e | ||
|
|
381a1ec66e | ||
|
|
53259f6b07 | ||
|
|
e6f5246fdf | ||
|
|
67e69ee447 | ||
|
|
a814e9d5a4 | ||
|
|
36c489208b | ||
|
|
fae78ab1d0 | ||
|
|
0f8f59ec22 | ||
|
|
b513f1b152 | ||
|
|
988049b64f | ||
|
|
a712692527 | ||
|
|
6f9467703a | ||
|
|
edc5593c76 | ||
|
|
65b0155b8e | ||
|
|
702f03a4b9 | ||
|
|
f83505a484 | ||
|
|
9a644f3e72 | ||
|
|
a2d6d33d30 | ||
|
|
1ab895ea01 | ||
|
|
f646dc1e0d | ||
|
|
41d70dc6c5 | ||
|
|
da5895b445 | ||
|
|
7cee64a389 | ||
|
|
1bb3647ed6 | ||
|
|
f47cec589b | ||
|
|
6286de3abd | ||
|
|
17e5640d94 | ||
|
|
c30f519c16 | ||
|
|
11d1936a5e | ||
|
|
865ad0b89d | ||
|
|
93bab2181c | ||
|
|
6e5d821eeb | ||
|
|
bf016d1c54 | ||
|
|
b5ad463d2d | ||
|
|
83f723f666 | ||
|
|
bcdcc9a460 | ||
|
|
6ca4b32d06 | ||
|
|
d8b0934f65 | ||
|
|
a2429b8864 | ||
|
|
06460237d9 | ||
|
|
22263abf91 | ||
|
|
83a84b2637 | ||
|
|
2fa88ac250 | ||
|
|
8682bd5781 | ||
|
|
0da29407d1 | ||
|
|
2e2c11005a | ||
|
|
bf5b9b089e | ||
|
|
3ecec2ca7b | ||
|
|
6b44f2ba99 | ||
|
|
f8f276748b | ||
|
|
736d22009e | ||
|
|
32bb54e90c | ||
|
|
94a93ab7aa | ||
|
|
8a806d23e0 | ||
|
|
85dbd835d6 | ||
|
|
fd21d6ee82 | ||
|
|
c274dad88d | ||
|
|
69ec4ccdd6 | ||
|
|
217fb7af0d | ||
|
|
8a196eeb77 | ||
|
|
1e14e33704 | ||
|
|
7ee787f8de | ||
|
|
737d74f592 | ||
|
|
1a37932efd | ||
|
|
5866611176 | ||
|
|
fe8c64382d | ||
|
|
71571ed2cc | ||
|
|
9d23d4e542 | ||
|
|
2c39ef7cbf | ||
|
|
21ecb7f80b | ||
|
|
5f7338178c | ||
|
|
1c27a0c66d | ||
|
|
cfcd6c9112 | ||
|
|
c172ab7dae | ||
|
|
cf5290e835 | ||
|
|
a1dc00d466 | ||
|
|
fb51619741 | ||
|
|
2c74f84b97 | ||
|
|
cc1e38ef22 | ||
|
|
7090cd2d38 | ||
|
|
66cc52fd9d | ||
|
|
adc6e8626e | ||
|
|
3bf35d81c8 | ||
|
|
191fc3302d | ||
|
|
4e86e200a5 | ||
|
|
835733c7fc | ||
|
|
5d4016ca61 | ||
|
|
48055aa98f | ||
|
|
01c6de15a3 | ||
|
|
e66a24edd4 | ||
|
|
df71f965b0 | ||
|
|
37f449f91f | ||
|
|
9432147489 | ||
|
|
824953813a | ||
|
|
04cbf91533 | ||
|
|
3b584979a8 | ||
|
|
d5fd62c298 | ||
|
|
350d5dbe2d | ||
|
|
91aa9c790f | ||
|
|
ccb85be48e | ||
|
|
0917285a33 | ||
|
|
b886586ca6 | ||
|
|
2061f797ba | ||
|
|
3f690a811c | ||
|
|
381e2e9a84 | ||
|
|
f4b7a899b8 | ||
|
|
dff7cc1dda | ||
|
|
fa27fb4088 | ||
|
|
d59d8e7147 | ||
|
|
b2ba13377b | ||
|
|
3379afe807 | ||
|
|
1a4ee0e7ee | ||
|
|
360582703d | ||
|
|
cb0be4adbe | ||
|
|
24c323960e | ||
|
|
0b0a91e4d4 | ||
|
|
5ee04257c5 | ||
|
|
8c1f50f616 | ||
|
|
c449d797bd | ||
|
|
152258bb83 | ||
|
|
51cd5409da | ||
|
|
e9808d0d13 | ||
|
|
a8a6485243 | ||
|
|
1f9fdd9129 | ||
|
|
8d3541a364 | ||
|
|
ed279f27d5 | ||
|
|
0d598edee8 | ||
|
|
0b2e9da854 | ||
|
|
e701c9f458 | ||
|
|
720381e3cf | ||
|
|
6fb33260ae | ||
|
|
0559503258 | ||
|
|
69d060efe6 | ||
|
|
0c9e6293ee | ||
|
|
0db93570be | ||
|
|
f448660e4c | ||
|
|
d77cca177a | ||
|
|
0a353d57c9 | ||
|
|
1f21c74b01 | ||
|
|
fecb404485 | ||
|
|
7f19d9f95b | ||
|
|
54bb09a261 | ||
|
|
739185e2c9 | ||
|
|
c8477304a3 | ||
|
|
c97f4da629 | ||
|
|
dc5695dfa7 | ||
|
|
6ec46d1a81 | ||
|
|
925580fe3d | ||
|
|
8aeb55f0cf | ||
|
|
17383e6fe0 | ||
|
|
68cfbd61da | ||
|
|
c61911ffcd | ||
|
|
a9e1f452df | ||
|
|
e0a1b471ff | ||
|
|
90db82ba0e | ||
|
|
f250e84ecf | ||
|
|
a1454e00a2 | ||
|
|
994820e697 | ||
|
|
75b37506ab | ||
|
|
8ef51c4b69 | ||
|
|
c2bd40f270 | ||
|
|
245ea982b9 | ||
|
|
6528d566a1 | ||
|
|
89d4b1ce31 | ||
|
|
2010ef412f | ||
|
|
0e42a29fda | ||
|
|
df440f90b2 | ||
|
|
791ac3d928 | ||
|
|
6b01498127 | ||
|
|
5983bae947 | ||
|
|
ab82d71a96 | ||
|
|
c3daaa8762 | ||
|
|
3b3560d71d | ||
|
|
29d24f48df | ||
|
|
ae72e1f6ca | ||
|
|
164cfd6b11 | ||
|
|
09a9735630 | ||
|
|
f86c946a3c | ||
|
|
c4f7b4f964 | ||
|
|
a7771347e6 | ||
|
|
83b958a824 | ||
|
|
148d592567 | ||
|
|
0a72ffbf5c | ||
|
|
72317bb0c6 | ||
|
|
8e445b4c3d | ||
|
|
f5f5532c19 | ||
|
|
cb5743dcc8 | ||
|
|
eea99bb586 | ||
|
|
295871fcab | ||
|
|
ae43424882 | ||
|
|
d5ae9945fa | ||
|
|
c20247d708 | ||
|
|
08947cd922 | ||
|
|
0e18c68b4d | ||
|
|
1b0b0a8149 | ||
|
|
ac22c4db04 | ||
|
|
e204d3c028 | ||
|
|
3246732087 | ||
|
|
c215a48c91 | ||
|
|
6aabf3f618 | ||
|
|
36ba5c65a6 | ||
|
|
a3489d33b4 | ||
|
|
9c16d40db1 | ||
|
|
730184c64f | ||
|
|
75267d2fc6 | ||
|
|
9b88df32d3 |
No files matched your search
@@ -233,9 +233,9 @@ jobs:
|
||||
# before the Nightly-only switch keep (they don't read Nightlies).
|
||||
gh release create nightly --prerelease --latest=false --target "$SHA" \
|
||||
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Nightly game files: Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-source.tar.gz#Source of this Nightly, commit ${SHA:0:7} (horizon-source.tar.gz; kept for good as the tag $SOURCE_TAG)" \
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
|
||||
@@ -47,7 +47,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
|
||||
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation (vanilla's heights and biomes for now).
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation, taller levels included (vanilla's biomes for now).
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** content mods add blocks, items and data, and native mods change the game's own Rust code, built by the launcher on your copy, with no Rust to install. Horizon's own (Zoom, FPS Counter, Keystrokes, Minimap, Recipes and Coordinates HUD) are native mods. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
|
||||
+4
-4
@@ -76,9 +76,9 @@ Owner: [levzzz5154](https://github.com/levzzz5154) · Tracking: [#3911](https://
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. The WebAssembly runtime is removed ([#3350](https://github.com/horizon-rust/horizon/issues/3350), [#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353)). [docs/native-mods.md](docs/native-mods.md).
|
||||
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. [docs/native-mods.md](docs/native-mods.md).
|
||||
|
||||
More: [#3360](https://github.com/horizon-rust/horizon/issues/3360) · [#1596](https://github.com/horizon-rust/horizon/issues/1596) · [#1635](https://github.com/horizon-rust/horizon/issues/1635)
|
||||
More: [#3351](https://github.com/horizon-rust/horizon/issues/3351) · [#3353](https://github.com/horizon-rust/horizon/issues/3353) · [#3360](https://github.com/horizon-rust/horizon/issues/3360) · [#1596](https://github.com/horizon-rust/horizon/issues/1596) · [#1635](https://github.com/horizon-rust/horizon/issues/1635)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -490,7 +490,7 @@ Small mods written as scripts, in the style of KubeJS, with no compiler.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is removed. A script layer over the native hook points is to be designed.
|
||||
Waits for native mods to settle: a script layer over the native hook points is to be designed.
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
@@ -718,7 +718,7 @@ Versions of Horizon for Android, iOS and web browsers. Nothing official yet; con
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/horizon-rust/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/horizon-rust/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/horizon-rust/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
|
||||
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/horizon-rust/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/horizon-rust/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/horizon-rust/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
--server HOST:PORT default 127.0.0.1:25565
|
||||
--bots N default 10
|
||||
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed.
|
||||
stand: stays on the ground where the server puts it (/tp), for a survival test.
|
||||
One abusive client, to test a server's limits (run it beside a normal crowd):
|
||||
flood-move (--flood moves a tick, each 64 to 256 blocks away, ignoring chunks),
|
||||
flood-acks (--flood chunk batch acks asking for 64 chunks a tick, while exploring),
|
||||
@@ -59,6 +60,9 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
into a writable book), flood-sign (four 384-character lines on the block
|
||||
below), flood-recipe (recipe book placements)
|
||||
--flood N packets a tick for the flood behaviours; default 100
|
||||
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test);
|
||||
the report gives each bot's last health
|
||||
--attack-after S start attacking S seconds after joining; default 5
|
||||
--no-read stop reading from the server once spawned (a client that lets the
|
||||
server's replies pile up)
|
||||
--join-every SECONDS default 1
|
||||
@@ -67,12 +71,14 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
--seed N default 12345
|
||||
--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
|
||||
--report FILE a JSON summary at the end (- prints it after the per-second lines)
|
||||
";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Behaviour {
|
||||
Idle,
|
||||
Stand,
|
||||
Walk,
|
||||
Explore,
|
||||
FloodMove,
|
||||
@@ -91,6 +97,7 @@ impl Behaviour {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Idle => "idle",
|
||||
Self::Stand => "stand",
|
||||
Self::Walk => "walk",
|
||||
Self::Explore => "explore",
|
||||
Self::FloodMove => "flood-move",
|
||||
@@ -117,9 +124,14 @@ struct Options {
|
||||
view_distance: i8,
|
||||
seed: u64,
|
||||
explore_speed: f64,
|
||||
/// The player to attack, and how long after joining to start.
|
||||
attack: Option<String>,
|
||||
attack_after: f64,
|
||||
flood: usize,
|
||||
no_read: bool,
|
||||
name: String,
|
||||
/// The `minecraft:brand` to send, if any.
|
||||
brand: Option<String>,
|
||||
report: Option<String>,
|
||||
}
|
||||
|
||||
@@ -134,10 +146,13 @@ fn options() -> Result<Options, String> {
|
||||
view_distance: 10,
|
||||
seed: 12345,
|
||||
explore_speed: 22.0,
|
||||
attack: None,
|
||||
attack_after: 5.0,
|
||||
flood: 100,
|
||||
no_read: false,
|
||||
name: "Bot".into(),
|
||||
report: None,
|
||||
brand: None,
|
||||
};
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
@@ -167,16 +182,20 @@ fn options() -> Result<Options, String> {
|
||||
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
|
||||
}
|
||||
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
|
||||
"--attack" => o.attack = Some(value),
|
||||
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
|
||||
"--name" => o.name = value,
|
||||
"--report" => o.report = Some(value),
|
||||
"--brand" => o.brand = Some(value),
|
||||
_ => return Err(format!("unknown option {arg}; --help lists them")),
|
||||
}
|
||||
}
|
||||
const BEHAVIOURS: [&str; 14] = [
|
||||
const BEHAVIOURS: [&str; 15] = [
|
||||
"idle",
|
||||
"walk",
|
||||
"explore",
|
||||
"mixed",
|
||||
"stand",
|
||||
"flood-move",
|
||||
"flood-acks",
|
||||
"flood-packets",
|
||||
@@ -219,6 +238,11 @@ struct Bot {
|
||||
joined_after_ms: AtomicU64,
|
||||
/// The bot stopped reading (--no-read).
|
||||
deaf: AtomicBool,
|
||||
/// `--attack`: the target's UUID and, once AddEntity showed it, its entity id.
|
||||
target: Mutex<(Option<u128>, Option<i32>)>,
|
||||
attacks: AtomicU64,
|
||||
/// The last SetHealth.
|
||||
health: Mutex<Option<f32>>,
|
||||
}
|
||||
|
||||
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
|
||||
@@ -398,6 +422,19 @@ fn join(o: &Options, name: &str) -> Result<Connection, String> {
|
||||
.encode(),
|
||||
)
|
||||
.map_err(err)?;
|
||||
// ClientBrandRetriever: vanilla's client says "vanilla", Horizon's "horizon".
|
||||
if let Some(brand) = &o.brand {
|
||||
let mut w = Writer::new();
|
||||
w.string(brand);
|
||||
c.send(
|
||||
&configuration::Serverbound::CustomPayload(configuration::CustomPayload {
|
||||
channel: "minecraft:brand".into(),
|
||||
data: w.data,
|
||||
})
|
||||
.encode(),
|
||||
)
|
||||
.map_err(err)?;
|
||||
}
|
||||
loop {
|
||||
let (id, body) = c.recv().map_err(err)?;
|
||||
let reply = match id {
|
||||
@@ -425,8 +462,9 @@ fn join(o: &Options, name: &str) -> Result<Connection, String> {
|
||||
payload: None,
|
||||
})
|
||||
}
|
||||
configuration::Clientbound::Disconnect(_) => {
|
||||
return Err("disconnected during configuration".into());
|
||||
configuration::Clientbound::Disconnect(p) => {
|
||||
let reason = horizon_protocol::text::plain(&p.reason, &Default::default());
|
||||
return Err(format!("disconnected during configuration: {reason}"));
|
||||
}
|
||||
configuration::Clientbound::FinishConfiguration(_) => {
|
||||
c.send(&configuration::Serverbound::FinishConfiguration(Empty).encode())
|
||||
@@ -531,6 +569,21 @@ fn read(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
cb::ADD_ENTITY => {
|
||||
if let Ok(play::Clientbound::AddEntity(p)) = play::Clientbound::decode(id, &body) {
|
||||
let mut target = lock(&bot.target);
|
||||
if target.0 == Some(p.uuid) {
|
||||
target.1 = Some(p.id);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
cb::SET_HEALTH => {
|
||||
if let Ok(play::Clientbound::SetHealth(p)) = play::Clientbound::decode(id, &body) {
|
||||
*lock(&bot.health) = Some(p.health);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
cb::CHUNK_BATCH_START => {
|
||||
batch.started = Instant::now();
|
||||
Ok(())
|
||||
@@ -618,6 +671,7 @@ fn run_bot(
|
||||
};
|
||||
let (reader, writer) = connection.split();
|
||||
let writer = Arc::new(Mutex::new(writer));
|
||||
lock(&bot.target).0 = o.attack.as_deref().map(offline_uuid);
|
||||
bot.online.store(true, Ordering::SeqCst);
|
||||
{
|
||||
let (writer, bot) = (writer.clone(), bot.clone());
|
||||
@@ -665,6 +719,7 @@ fn run_bot(
|
||||
let heading = random.unit() * std::f64::consts::TAU;
|
||||
let mut target = pos;
|
||||
let mut next = Instant::now();
|
||||
let (joined, mut tick) = (Instant::now(), 0u64);
|
||||
loop {
|
||||
if lock(&until).is_some_and(|u| Instant::now() >= u) || !bot.online.load(Ordering::SeqCst) {
|
||||
break;
|
||||
@@ -675,7 +730,9 @@ fn run_bot(
|
||||
}
|
||||
let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
|
||||
// The abusive client doesn't wait for its chunk, as a real client must.
|
||||
if behaviour == Behaviour::FloodMove {
|
||||
if behaviour == Behaviour::Stand {
|
||||
// Where the server put it, on the ground.
|
||||
} else if behaviour == Behaviour::FloodMove {
|
||||
// The last of these moves goes out below, with the tick's end.
|
||||
pos[1] = cruise;
|
||||
for _ in 1..o.flood.max(1) {
|
||||
@@ -711,7 +768,7 @@ fn run_bot(
|
||||
pos[1] = (pos[1] + 0.5).min(cruise);
|
||||
} else {
|
||||
match behaviour {
|
||||
Behaviour::Idle => {}
|
||||
Behaviour::Idle | Behaviour::Stand => {}
|
||||
Behaviour::Walk => {
|
||||
let (dx, dz) = (target[0] - pos[0], target[2] - pos[2]);
|
||||
let distance = dx.hypot(dz);
|
||||
@@ -776,12 +833,21 @@ fn run_bot(
|
||||
send(body);
|
||||
}
|
||||
}
|
||||
let target = lock(&bot.target).1;
|
||||
if let Some(id) = target
|
||||
&& joined.elapsed().as_secs_f64() >= o.attack_after
|
||||
&& tick % 10 == 0
|
||||
{
|
||||
send(play::Serverbound::Attack(play::Attack { id }).encode());
|
||||
bot.attacks.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
tick += 1;
|
||||
let ok = send(
|
||||
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
|
||||
x: pos[0],
|
||||
y: pos[1],
|
||||
z: pos[2],
|
||||
flags: 0,
|
||||
flags: u8::from(behaviour == Behaviour::Stand),
|
||||
})
|
||||
.encode(),
|
||||
) && send(raw(sb::CLIENT_TICK_END, |_| {}));
|
||||
@@ -805,6 +871,7 @@ fn run_bot(
|
||||
fn behaviour_of(mode: &str, index: usize) -> Behaviour {
|
||||
match mode {
|
||||
"idle" => Behaviour::Idle,
|
||||
"stand" => Behaviour::Stand,
|
||||
"walk" => Behaviour::Walk,
|
||||
"explore" => Behaviour::Explore,
|
||||
"flood-move" => Behaviour::FloodMove,
|
||||
@@ -918,6 +985,8 @@ fn main() {
|
||||
"pulled_back": b.pulled_back.load(Ordering::Relaxed),
|
||||
"waited_ticks": b.waited.load(Ordering::Relaxed),
|
||||
"error": *lock(&b.error),
|
||||
"attacks": b.attacks.load(Ordering::Relaxed),
|
||||
"health": *lock(&b.health),
|
||||
})).collect::<Vec<_>>(),
|
||||
});
|
||||
if o.report.as_deref() == Some("-") {
|
||||
|
||||
@@ -237,7 +237,7 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
// HoverEvent.ShowText and ShowItem.
|
||||
// HoverEvent.ShowText, ShowItem and ShowEntity.
|
||||
if let Some(e) = o.get("hover_event").and_then(Value::as_object) {
|
||||
match e.get("action").and_then(Value::as_str) {
|
||||
Some("show_text") => {
|
||||
@@ -248,6 +248,9 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
Some("show_item") if e.get("id").is_some_and(Value::is_string) => {
|
||||
style.hover_item = Some(Box::new(Value::Object(e.clone())));
|
||||
}
|
||||
Some("show_entity") if e.get("id").is_some_and(Value::is_string) => {
|
||||
style.hover_entity = Some(Box::new(Value::Object(e.clone())));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,8 @@ pub struct Style {
|
||||
pub hover: Option<Box<Text>>,
|
||||
/// `HoverEvent.ShowItem`: the event's `{id, count?, components?}` object.
|
||||
pub hover_item: Option<Box<Value>>,
|
||||
/// `HoverEvent.ShowEntity`: the event's `{id, uuid, name?}` object.
|
||||
pub hover_entity: Option<Box<Value>>,
|
||||
/// `Style.insertion`: the text a Shift-click puts in the chat box.
|
||||
pub insertion: Option<String>,
|
||||
/// The font (`FontDescription.Resource`), a full id like
|
||||
@@ -127,11 +129,12 @@ impl Style {
|
||||
if self.click.is_none() {
|
||||
self.click.clone_from(&base.click);
|
||||
}
|
||||
if self.hover.is_none() {
|
||||
// One HoverEvent: its three kinds are inherited together, so a style
|
||||
// with its own event never picks up another kind from `base`.
|
||||
if self.hover.is_none() && self.hover_item.is_none() && self.hover_entity.is_none() {
|
||||
self.hover.clone_from(&base.hover);
|
||||
}
|
||||
if self.hover_item.is_none() {
|
||||
self.hover_item.clone_from(&base.hover_item);
|
||||
self.hover_entity.clone_from(&base.hover_entity);
|
||||
}
|
||||
if self.insertion.is_none() {
|
||||
self.insertion.clone_from(&base.insertion);
|
||||
@@ -591,6 +594,10 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
let mut event = item.clone();
|
||||
event.insert("action".into(), "show_item".into());
|
||||
out.push(("hover_event".to_owned(), Value::Object(event)));
|
||||
} else if let Some(Value::Object(entity)) = style.hover_entity.as_deref() {
|
||||
let mut event = entity.clone();
|
||||
event.insert("action".into(), "show_entity".into());
|
||||
out.push(("hover_event".to_owned(), Value::Object(event)));
|
||||
}
|
||||
if let Some(insertion) = &style.insertion {
|
||||
out.push(("insertion".to_owned(), insertion.clone().into()));
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
use crate::Result;
|
||||
use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
fs,
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GrassModifier {
|
||||
@@ -102,6 +107,9 @@ pub struct BiomeInfo {
|
||||
/// The biome's own fog colour attribute; otherwise the dimension's applies.
|
||||
pub fog: Option<u32>,
|
||||
pub water_fog: u32,
|
||||
/// `BiomeGenerationSettings.features`: the placed feature ids of each
|
||||
/// `GenerationStep.Decoration` step, in order.
|
||||
pub features: Vec<Vec<String>>,
|
||||
}
|
||||
impl BiomeInfo {
|
||||
/// `Biome.getHeightAdjustedTemperature`: the frozen modifier, then cooler
|
||||
@@ -130,10 +138,27 @@ impl BiomeInfo {
|
||||
.then(|| self.temperature_at(BlockPos::new(x, y, z), 63) < 0.15)
|
||||
}
|
||||
}
|
||||
/// The game's biomes, by id. The built-in (vanilla) biomes come first, in
|
||||
/// file order, and keep their ids whatever a world's data packs do; a pack's
|
||||
/// new biomes follow them (`with_packs`). Those are the client's own ids:
|
||||
/// the network registry's are `registry_order`'s.
|
||||
#[derive(Clone)]
|
||||
pub struct Biomes {
|
||||
pub list: Vec<BiomeInfo>,
|
||||
pub index: HashMap<String, u16>,
|
||||
info_noise: SimplexNoise,
|
||||
colormaps: Arc<Colormaps>,
|
||||
/// The built-in biomes, the first of `list` unless packs replaced them.
|
||||
builtin: Arc<Vec<BiomeInfo>>,
|
||||
/// The data packs' biome files (new biomes and replaced built-in ones),
|
||||
/// for the tables that read more of a biome than `BiomeInfo` keeps.
|
||||
packs: BTreeMap<String, Arc<Value>>,
|
||||
}
|
||||
/// The grass, foliage and dry foliage colormaps.
|
||||
struct Colormaps {
|
||||
grass: Vec<u32>,
|
||||
foliage: Vec<u32>,
|
||||
dry: Vec<u32>,
|
||||
}
|
||||
fn color(v: &Value) -> Option<u32> {
|
||||
match v {
|
||||
@@ -166,11 +191,90 @@ fn sample(map: &[u32], temperature: f32, downfall: f32, fallback: u32) -> u32 {
|
||||
let index = (y << 8 | x) as usize;
|
||||
map.get(index).copied().unwrap_or(fallback)
|
||||
}
|
||||
impl BiomeInfo {
|
||||
/// A biome file's (`worldgen/biome/<name>.json`) values, the colours it
|
||||
/// leaves out sampled from the colormaps.
|
||||
fn from_json(name: String, v: &Value, maps: &Colormaps) -> Self {
|
||||
let effects = &v["effects"];
|
||||
let attributes = &v["attributes"];
|
||||
let temperature = v["temperature"].as_f64().unwrap_or(0.5) as f32;
|
||||
let downfall = v["downfall"].as_f64().unwrap_or(0.5) as f32;
|
||||
let modifier = match effects["grass_color_modifier"].as_str() {
|
||||
Some("dark_forest") => GrassModifier::DarkForest,
|
||||
Some("swamp") => GrassModifier::Swamp,
|
||||
_ => GrassModifier::None,
|
||||
};
|
||||
let mut spawns: HashMap<String, Vec<SpawnEntry>> = HashMap::new();
|
||||
let natural = &attributes["minecraft:gameplay/natural_mob_spawns"];
|
||||
// A modifier object ({argument, modifier: overlay}) or the plain value;
|
||||
// the overlay over the dimension's empty default is the biome's value.
|
||||
let natural = if natural["argument"].is_object() {
|
||||
&natural["argument"]
|
||||
} else {
|
||||
natural
|
||||
};
|
||||
let categories = &natural["spawns_by_category"];
|
||||
for (category, entries) in categories.as_object().into_iter().flatten() {
|
||||
let list = parse_entries(entries);
|
||||
spawns.insert(category.clone(), list);
|
||||
}
|
||||
BiomeInfo {
|
||||
spawns,
|
||||
spawn_costs: parse_costs(&natural["spawn_costs"]),
|
||||
creature_probability:
|
||||
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
|
||||
.as_f64()
|
||||
.unwrap_or(0.1) as f32,
|
||||
increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"]
|
||||
.as_bool()
|
||||
.unwrap_or(false),
|
||||
temperature,
|
||||
frozen: v["temperature_modifier"].as_str() == Some("frozen"),
|
||||
downfall,
|
||||
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
|
||||
grass: color(&effects["grass_color"])
|
||||
.unwrap_or_else(|| sample(&maps.grass, temperature, downfall, 0xFF00FF)),
|
||||
modifier,
|
||||
foliage: color(&effects["foliage_color"])
|
||||
.unwrap_or_else(|| sample(&maps.foliage, temperature, downfall, 0x48B518)),
|
||||
dry_foliage: color(&effects["dry_foliage_color"])
|
||||
.unwrap_or_else(|| sample(&maps.dry, temperature, downfall, 0x5D4930)),
|
||||
water: color(&effects["water_color"]).unwrap_or(0x3F76E4),
|
||||
sky: color(&attributes["minecraft:visual/sky_color"]),
|
||||
fog: color(&attributes["minecraft:visual/fog_color"]),
|
||||
water_fog: color(&attributes["minecraft:visual/water_fog_color"]).unwrap_or(0x050533),
|
||||
features: v["features"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|step| {
|
||||
step.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
})
|
||||
.collect(),
|
||||
name,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Identifier.compareTo`: the path first, then the namespace.
|
||||
pub fn identifier_order(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
fn split(id: &str) -> (&str, &str) {
|
||||
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
(path, ns)
|
||||
}
|
||||
split(a).cmp(&split(b))
|
||||
}
|
||||
impl Biomes {
|
||||
pub fn load(root: &Path) -> Result<Self> {
|
||||
let grass = colormap(root, "grass")?;
|
||||
let foliage = colormap(root, "foliage")?;
|
||||
let dry = colormap(root, "dry_foliage")?;
|
||||
let maps = Colormaps {
|
||||
grass: colormap(root, "grass")?,
|
||||
foliage: colormap(root, "foliage")?,
|
||||
dry: colormap(root, "dry_foliage")?,
|
||||
};
|
||||
let mut list = Vec::new();
|
||||
let mut names: Vec<_> = fs::read_dir(root.join("data/minecraft/worldgen/biome"))?
|
||||
.filter_map(|e| e.ok())
|
||||
@@ -186,57 +290,7 @@ impl Biomes {
|
||||
.ok_or("bad biome")?
|
||||
);
|
||||
let v: Value = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
let effects = &v["effects"];
|
||||
let attributes = &v["attributes"];
|
||||
let temperature = v["temperature"].as_f64().unwrap_or(0.5) as f32;
|
||||
let downfall = v["downfall"].as_f64().unwrap_or(0.5) as f32;
|
||||
let modifier = match effects["grass_color_modifier"].as_str() {
|
||||
Some("dark_forest") => GrassModifier::DarkForest,
|
||||
Some("swamp") => GrassModifier::Swamp,
|
||||
_ => GrassModifier::None,
|
||||
};
|
||||
let mut spawns: HashMap<String, Vec<SpawnEntry>> = HashMap::new();
|
||||
let natural = &attributes["minecraft:gameplay/natural_mob_spawns"];
|
||||
// A modifier object ({argument, modifier: overlay}) or the plain value;
|
||||
// the overlay over the dimension's empty default is the biome's value.
|
||||
let natural = if natural["argument"].is_object() {
|
||||
&natural["argument"]
|
||||
} else {
|
||||
natural
|
||||
};
|
||||
let categories = &natural["spawns_by_category"];
|
||||
for (category, entries) in categories.as_object().into_iter().flatten() {
|
||||
let list = parse_entries(entries);
|
||||
spawns.insert(category.clone(), list);
|
||||
}
|
||||
list.push(BiomeInfo {
|
||||
spawns,
|
||||
spawn_costs: parse_costs(&natural["spawn_costs"]),
|
||||
creature_probability:
|
||||
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
|
||||
.as_f64()
|
||||
.unwrap_or(0.1) as f32,
|
||||
increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"]
|
||||
.as_bool()
|
||||
.unwrap_or(false),
|
||||
temperature,
|
||||
frozen: v["temperature_modifier"].as_str() == Some("frozen"),
|
||||
downfall,
|
||||
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
|
||||
grass: color(&effects["grass_color"])
|
||||
.unwrap_or_else(|| sample(&grass, temperature, downfall, 0xFF00FF)),
|
||||
modifier,
|
||||
foliage: color(&effects["foliage_color"])
|
||||
.unwrap_or_else(|| sample(&foliage, temperature, downfall, 0x48B518)),
|
||||
dry_foliage: color(&effects["dry_foliage_color"])
|
||||
.unwrap_or_else(|| sample(&dry, temperature, downfall, 0x5D4930)),
|
||||
water: color(&effects["water_color"]).unwrap_or(0x3F76E4),
|
||||
sky: color(&attributes["minecraft:visual/sky_color"]),
|
||||
fog: color(&attributes["minecraft:visual/fog_color"]),
|
||||
water_fog: color(&attributes["minecraft:visual/water_fog_color"])
|
||||
.unwrap_or(0x050533),
|
||||
name,
|
||||
});
|
||||
list.push(BiomeInfo::from_json(name, &v, &maps));
|
||||
}
|
||||
let index = list
|
||||
.iter()
|
||||
@@ -244,11 +298,65 @@ impl Biomes {
|
||||
.map(|(i, b)| (b.name.clone(), i as u16))
|
||||
.collect();
|
||||
Ok(Self {
|
||||
builtin: Arc::new(list.clone()),
|
||||
list,
|
||||
index,
|
||||
colormaps: Arc::new(maps),
|
||||
packs: BTreeMap::new(),
|
||||
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
|
||||
})
|
||||
}
|
||||
/// The built-in biomes with a world's data packs' biome files (id →
|
||||
/// JSON) over them, as `RegistryDataLoader` builds the registry: a file
|
||||
/// for a built-in biome replaces it, keeping its id, and new biomes join
|
||||
/// after the built-in ones, in registry order. Starts from the built-in
|
||||
/// table whatever packs `self` has.
|
||||
pub fn with_packs<'a>(&self, files: impl IntoIterator<Item = (&'a str, &'a Value)>) -> Self {
|
||||
let mut out = self.builtin_only();
|
||||
let mut files: Vec<(&str, &Value)> = files.into_iter().collect();
|
||||
files.sort_by(|a, b| identifier_order(a.0, b.0));
|
||||
for (name, json) in files {
|
||||
let info = BiomeInfo::from_json(name.to_owned(), json, &out.colormaps);
|
||||
match out.index.get(name) {
|
||||
Some(&id) => out.list[usize::from(id)] = info,
|
||||
None if out.list.len() < usize::from(u16::MAX) => {
|
||||
out.index.insert(name.to_owned(), out.list.len() as u16);
|
||||
out.list.push(info);
|
||||
}
|
||||
None => continue,
|
||||
}
|
||||
out.packs.insert(name.to_owned(), Arc::new(json.clone()));
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The built-in biomes alone: this table without what packs gave it.
|
||||
pub fn builtin_only(&self) -> Self {
|
||||
let mut out = self.clone();
|
||||
if self.packs.is_empty() {
|
||||
return out;
|
||||
}
|
||||
out.list = self.builtin.to_vec();
|
||||
let builtin = out.list.len();
|
||||
out.index.retain(|_, id| usize::from(*id) < builtin);
|
||||
out.packs.clear();
|
||||
out
|
||||
}
|
||||
/// The data pack file a world's biome `name` came from, if a pack added
|
||||
/// or replaced it.
|
||||
pub fn pack_file(&self, name: &str) -> Option<&Value> {
|
||||
self.packs.get(name).map(|v| &**v)
|
||||
}
|
||||
/// Whether data packs added or replaced any biome.
|
||||
pub fn has_packs(&self) -> bool {
|
||||
!self.packs.is_empty()
|
||||
}
|
||||
/// The biome names in the network registry's order: `RegistryDataLoader`
|
||||
/// sorts a registry's files by `Identifier` (path, then namespace).
|
||||
pub fn registry_order(&self) -> Vec<&str> {
|
||||
let mut names: Vec<&str> = self.list.iter().map(|b| b.name.as_str()).collect();
|
||||
names.sort_by(|a, b| identifier_order(a, b));
|
||||
names
|
||||
}
|
||||
pub fn id(&self, name: &str) -> u16 {
|
||||
self.index
|
||||
.get(name)
|
||||
@@ -344,6 +452,88 @@ pub fn blend_rgb(weights: &[(u16, f64)], mut value: impl FnMut(u16) -> [f32; 3])
|
||||
result.unwrap_or([0.0; 3])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pack_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// A built-in table of `names` (no colormaps: sampled colours fall back).
|
||||
fn builtin(names: &[&str]) -> Biomes {
|
||||
let maps = Colormaps {
|
||||
grass: Vec::new(),
|
||||
foliage: Vec::new(),
|
||||
dry: Vec::new(),
|
||||
};
|
||||
let list: Vec<BiomeInfo> = names
|
||||
.iter()
|
||||
.map(|n| BiomeInfo::from_json((*n).to_owned(), &json!({}), &maps))
|
||||
.collect();
|
||||
Biomes {
|
||||
builtin: Arc::new(list.clone()),
|
||||
index: list
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| (b.name.clone(), i as u16))
|
||||
.collect(),
|
||||
list,
|
||||
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
|
||||
colormaps: Arc::new(maps),
|
||||
packs: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_order_is_path_then_namespace() {
|
||||
use std::cmp::Ordering::*;
|
||||
assert_eq!(identifier_order("minecraft:a", "minecraft:b"), Less);
|
||||
assert_eq!(identifier_order("z:a", "a:b"), Less);
|
||||
assert_eq!(identifier_order("b:same", "a:same"), Greater);
|
||||
assert_eq!(identifier_order("minecraft:a", "minecraft:a_b"), Less);
|
||||
assert_eq!(identifier_order("minecraft:a-b", "minecraft:a"), Greater);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packs_replace_in_place_and_add_after_the_builtin_biomes() {
|
||||
let base = builtin(&["minecraft:meadow", "minecraft:plains", "minecraft:river"]);
|
||||
let red = json!({"temperature": 2.0, "effects": {"water_color": "#ff0000"}});
|
||||
let hot = json!({"effects": {"grass_color": 0x123456}});
|
||||
let files = [
|
||||
("pack:zeta", &hot),
|
||||
("minecraft:molten_peaks", &hot),
|
||||
("minecraft:river", &red),
|
||||
];
|
||||
let world = base.with_packs(files);
|
||||
// The built-in ids stay; the river takes the pack's values.
|
||||
assert_eq!(world.id("minecraft:plains"), 1);
|
||||
assert_eq!(world.id("minecraft:river"), 2);
|
||||
assert_eq!(world.get(2).water, 0xFF0000);
|
||||
assert_eq!(world.get(2).temperature, 2.0);
|
||||
// New biomes follow, in registry order.
|
||||
assert_eq!(world.id("minecraft:molten_peaks"), 3);
|
||||
assert_eq!(world.id("pack:zeta"), 4);
|
||||
assert_eq!(world.get(3).grass, 0x123456);
|
||||
assert!(world.pack_file("minecraft:river").is_some());
|
||||
assert!(world.pack_file("minecraft:plains").is_none());
|
||||
// The network registry sorts every name by path, then namespace.
|
||||
assert_eq!(
|
||||
world.registry_order(),
|
||||
[
|
||||
"minecraft:meadow",
|
||||
"minecraft:molten_peaks",
|
||||
"minecraft:plains",
|
||||
"minecraft:river",
|
||||
"pack:zeta"
|
||||
]
|
||||
);
|
||||
// Another world starts again from the built-in biomes.
|
||||
let other = world.with_packs([]);
|
||||
assert_eq!(other.list.len(), 3);
|
||||
assert!(!other.contains("minecraft:molten_peaks"));
|
||||
assert_eq!(other.get(2).water, 0x3F76E4);
|
||||
assert!(!other.has_packs());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod blend_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1114,7 +1114,11 @@ impl BlockEntityRenderer {
|
||||
pub fn renders(&self, state: u32) -> bool {
|
||||
!matches!(self.kind(state), Kind::None)
|
||||
}
|
||||
/// ChestRenderer and BannerRenderer submit their animated models for crumbling.
|
||||
/// `submitCrumblingOverlay` of ChestRenderer.submit, BannerRenderer.submit,
|
||||
/// SkullBlockRenderer.submitSkull, ShulkerBoxRenderer.submit,
|
||||
/// BellRenderer.submit, LecternRenderer.submit (the book) and
|
||||
/// CopperGolemStatueBlockRenderer.submit: the model drawn again under the
|
||||
/// destroy stage.
|
||||
pub fn render_breaking(
|
||||
&self,
|
||||
scene: &Scene,
|
||||
@@ -1123,7 +1127,16 @@ impl BlockEntityRenderer {
|
||||
stage: [f32; 2],
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
if !matches!(self.kind(state), Kind::Banner { .. } | Kind::Chest { .. }) {
|
||||
if !matches!(
|
||||
self.kind(state),
|
||||
Kind::Banner { .. }
|
||||
| Kind::Chest { .. }
|
||||
| Kind::Skull { .. }
|
||||
| Kind::Shulker { .. }
|
||||
| Kind::Bell
|
||||
| Kind::Lectern { .. }
|
||||
| Kind::CopperGolem { .. }
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let origin = (DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
|
||||
|
||||
@@ -36,6 +36,22 @@ pub fn potion_name_key(item: &str, potion: Option<&str>) -> String {
|
||||
let suffix = potion.and_then(self::potion).map_or("empty", |p| p.name);
|
||||
format!("item.minecraft.{prefix}.effect.{suffix}")
|
||||
}
|
||||
/// `PotionContents.getAllEffects`: the potion's effects, then the stack's
|
||||
/// `custom_effects` (id, duration, amplifier). Unknown effect ids are dropped.
|
||||
pub fn contents_effects(stack: &ItemStack) -> Vec<(&'static str, i32, i32)> {
|
||||
let potion = stack.potion().and_then(self::potion);
|
||||
let custom = stack.custom_potion_effects();
|
||||
potion
|
||||
.map_or(&[][..], |d| d.effects)
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(
|
||||
custom
|
||||
.iter()
|
||||
.filter_map(|(id, duration, amp)| Some((effect(id)?.id, *duration, *amp))),
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
/// Roman amplifier suffix (`potion.potency.N`) and duration lines of a potion.
|
||||
pub fn tooltip(
|
||||
potion: Option<&str>,
|
||||
|
||||
@@ -21,6 +21,12 @@ const COMMAND_HISTORY_FILE: &str = "command_history.txt";
|
||||
const MAX_PERSISTED_COMMAND_HISTORY: usize = 50;
|
||||
/// GuiMessageTag.systemSinglePlayer's indicator colour.
|
||||
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
|
||||
/// A player message's signature (MessageSignature), which names it to a
|
||||
/// server's deletion.
|
||||
pub type Signature = horizon_protocol::secure_chat::Signature;
|
||||
/// How long a message shows before a deletion replaces it
|
||||
/// (`ChatComponent.deleteMessageOrDelay`: `addedTime + 60`).
|
||||
const DELETION_DELAY: u64 = 60;
|
||||
|
||||
/// `ChatVisiblity`: the Chat option's three values.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -165,6 +171,8 @@ struct Message {
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
source: ChatSource,
|
||||
/// `GuiMessage.signature`: a signed player message's, for deletion.
|
||||
signature: Option<Box<Signature>>,
|
||||
}
|
||||
/// A wrapped line (`GuiMessage.Line`).
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -205,6 +213,9 @@ pub struct Chat {
|
||||
pub received: u64,
|
||||
/// The Chat option's `visibleMessageFilter` (`set_abilities`).
|
||||
abilities: ChatAbilities,
|
||||
/// `ChatComponent.messageDeletionQueue`: deletions of messages still
|
||||
/// too new to replace, with the tick from which they may be.
|
||||
deletions: Vec<(Box<Signature>, u64)>,
|
||||
}
|
||||
impl Chat {
|
||||
pub fn lines_per_page(&self, focused: bool) -> usize {
|
||||
@@ -227,6 +238,38 @@ impl Chat {
|
||||
) {
|
||||
self.add_message(&atlas.font, content, indicator, source, ticks, focused);
|
||||
}
|
||||
/// `add` of a player message with its signature, which a server's
|
||||
/// deletion names it by (`GuiMessage.signature`).
|
||||
pub fn add_signed(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
content: Text,
|
||||
signature: Option<Signature>,
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
self.add_signed_message(&atlas.font, content, signature, ticks, focused);
|
||||
}
|
||||
fn add_signed_message(
|
||||
&mut self,
|
||||
font: &crate::font::Font,
|
||||
content: Text,
|
||||
signature: Option<Signature>,
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
self.push_message(
|
||||
font,
|
||||
Message {
|
||||
added: ticks,
|
||||
content,
|
||||
indicator: None,
|
||||
source: ChatSource::Player,
|
||||
signature: signature.map(Box::new),
|
||||
},
|
||||
focused,
|
||||
);
|
||||
}
|
||||
fn add_message(
|
||||
&mut self,
|
||||
font: &crate::font::Font,
|
||||
@@ -236,20 +279,28 @@ impl Chat {
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
if !self.abilities.shows(source) {
|
||||
self.push_message(
|
||||
font,
|
||||
Message {
|
||||
added: ticks,
|
||||
content,
|
||||
indicator,
|
||||
source,
|
||||
signature: None,
|
||||
},
|
||||
focused,
|
||||
);
|
||||
}
|
||||
fn push_message(&mut self, font: &crate::font::Font, message: Message, focused: bool) {
|
||||
if !self.abilities.shows(message.source) {
|
||||
return;
|
||||
}
|
||||
// ChatComponent.logChatMessage: every message shown is in the log.
|
||||
crate::logging::write_only(log::Level::Info, &format!("[CHAT] {}", content.plain()));
|
||||
let plain = message.content.plain();
|
||||
crate::logging::write_only(log::Level::Info, &format!("[CHAT] {plain}"));
|
||||
if self.log {
|
||||
println!("[CHAT] {}", content.plain());
|
||||
println!("[CHAT] {plain}");
|
||||
}
|
||||
let message = Message {
|
||||
added: ticks,
|
||||
content,
|
||||
indicator,
|
||||
source,
|
||||
};
|
||||
self.add_lines(font, &message, focused);
|
||||
self.messages.insert(0, message);
|
||||
self.messages.truncate(100);
|
||||
@@ -319,8 +370,66 @@ impl Chat {
|
||||
pub fn clear(&mut self) {
|
||||
self.messages.clear();
|
||||
self.lines.clear();
|
||||
self.deletions.clear();
|
||||
self.scroll = 0;
|
||||
}
|
||||
/// `ChatComponent.deleteMessage`: the server deleted a message. One
|
||||
/// shown for 60 ticks becomes the grey "deleted" marker now; a newer
|
||||
/// one waits until then (`messageDeletionQueue`).
|
||||
pub fn delete_message(&mut self, atlas: &HudAtlas, signature: &Signature, ticks: u64) {
|
||||
self.delete_message_with(&atlas.font, signature, ticks);
|
||||
}
|
||||
fn delete_message_with(&mut self, font: &crate::font::Font, signature: &Signature, ticks: u64) {
|
||||
if let Some(delayed) = self.delete_or_delay(font, signature, ticks) {
|
||||
self.deletions.push((Box::new(*signature), delayed));
|
||||
}
|
||||
}
|
||||
/// `ChatComponent.tick` (`processMessageDeletionQueue`): the delayed
|
||||
/// deletions whose message has shown long enough.
|
||||
pub fn tick(&mut self, atlas: &HudAtlas, ticks: u64) {
|
||||
self.process_deletions(&atlas.font, ticks);
|
||||
}
|
||||
fn process_deletions(&mut self, font: &crate::font::Font, ticks: u64) {
|
||||
let mut deletions = std::mem::take(&mut self.deletions);
|
||||
deletions.retain(|(signature, after)| {
|
||||
ticks < *after || self.delete_or_delay(font, signature, ticks).is_some()
|
||||
});
|
||||
self.deletions = deletions;
|
||||
}
|
||||
/// `ChatComponent.deleteMessageOrDelay`: the newest message with this
|
||||
/// signature, replaced by `createDeletedMarker` once 60 ticks have
|
||||
/// passed since it was added; otherwise the tick to retry from.
|
||||
fn delete_or_delay(
|
||||
&mut self,
|
||||
font: &crate::font::Font,
|
||||
signature: &Signature,
|
||||
ticks: u64,
|
||||
) -> Option<u64> {
|
||||
let message = self
|
||||
.messages
|
||||
.iter_mut()
|
||||
.find(|m| m.signature.as_deref() == Some(signature))?;
|
||||
let deletable = message.added + DELETION_DELAY;
|
||||
if ticks < deletable {
|
||||
return Some(deletable);
|
||||
}
|
||||
*message = Message {
|
||||
added: message.added,
|
||||
content: Self::deleted_marker(),
|
||||
indicator: Some(SYSTEM_INDICATOR),
|
||||
source: ChatSource::Server,
|
||||
signature: None,
|
||||
};
|
||||
// refreshTrimmedMessages.
|
||||
self.rewrap(font);
|
||||
None
|
||||
}
|
||||
/// `ChatComponent.DELETED_CHAT_MESSAGE`: grey, italic.
|
||||
fn deleted_marker() -> Text {
|
||||
Text::translate("chat.deleted_marker", &[])
|
||||
.with_color(text::GRAY)
|
||||
.italic()
|
||||
}
|
||||
/// `new CommandHistory(gameFolder)`: the commands kept from earlier runs
|
||||
/// start the chat's history (`ChatComponent`'s constructor).
|
||||
pub fn load_history(&mut self, game_folder: &std::path::Path) {
|
||||
|
||||
@@ -519,3 +519,62 @@ fn a_guests_line_follows_the_player_message_permission() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChatComponent.deleteMessageOrDelay`: a deleted message shows for 60
|
||||
/// ticks from when it was added, then becomes the grey italic
|
||||
/// `chat.deleted_marker` line with the system bar; the other lines stay.
|
||||
#[test]
|
||||
fn a_deleted_message_shows_60_ticks_then_becomes_the_marker() {
|
||||
let font = font();
|
||||
let mut chat = Chat::default();
|
||||
let secret = [7; 256];
|
||||
chat.add_signed_message(
|
||||
&font,
|
||||
Text::literal("<Steve> secret"),
|
||||
Some(secret),
|
||||
100,
|
||||
false,
|
||||
);
|
||||
chat.add_signed_message(
|
||||
&font,
|
||||
Text::literal("<Alex> hi"),
|
||||
Some([8; 256]),
|
||||
110,
|
||||
false,
|
||||
);
|
||||
let lines =
|
||||
|chat: &Chat| -> Vec<String> { chat.lines.iter().map(|l| l.content.plain()).collect() };
|
||||
let marker = Chat::deleted_marker().plain();
|
||||
chat.delete_message_with(&font, &secret, 130);
|
||||
assert_eq!(
|
||||
lines(&chat),
|
||||
["<Alex> hi", "<Steve> secret"],
|
||||
"too new: delayed"
|
||||
);
|
||||
chat.process_deletions(&font, 159);
|
||||
assert_eq!(lines(&chat), ["<Alex> hi", "<Steve> secret"]);
|
||||
chat.process_deletions(&font, 160);
|
||||
assert_eq!(lines(&chat), ["<Alex> hi".to_owned(), marker.clone()]);
|
||||
assert!(chat.deletions.is_empty());
|
||||
let line = &chat.lines[1];
|
||||
assert_eq!(line.indicator, Some(SYSTEM_INDICATOR));
|
||||
assert_eq!(line.added, 100, "the marker keeps the message's time");
|
||||
assert!(
|
||||
line.content
|
||||
.spans
|
||||
.iter()
|
||||
.all(|s| s.style.color == Some(text::GRAY) && s.style.is_italic())
|
||||
);
|
||||
assert_eq!(
|
||||
chat.messages[1].content.translation_key(),
|
||||
Some("chat.deleted_marker")
|
||||
);
|
||||
// A message shown 60 ticks already is replaced at once; an unknown or
|
||||
// already deleted signature changes nothing.
|
||||
chat.delete_message_with(&font, &[8; 256], 170);
|
||||
assert_eq!(lines(&chat), [marker.clone(), marker.clone()]);
|
||||
chat.delete_message_with(&font, &secret, 200);
|
||||
chat.delete_message_with(&font, &[9; 256], 200);
|
||||
assert_eq!(lines(&chat), [marker.clone(), marker]);
|
||||
assert!(chat.deletions.is_empty());
|
||||
}
|
||||
@@ -86,6 +86,43 @@ impl LevelShape {
|
||||
}
|
||||
}
|
||||
|
||||
/// The shapes of a world's three levels, from its dimension types.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct LevelShapes {
|
||||
pub overworld: LevelShape,
|
||||
pub nether: LevelShape,
|
||||
pub end: LevelShape,
|
||||
}
|
||||
impl LevelShapes {
|
||||
pub const VANILLA: Self = Self {
|
||||
overworld: LevelShape::OVERWORLD,
|
||||
nether: LevelShape::NETHER,
|
||||
end: LevelShape::END,
|
||||
};
|
||||
/// The shape of the level `id` (`minecraft:overworld`,
|
||||
/// `minecraft:the_nether`, `minecraft:the_end`); others get the
|
||||
/// Overworld's.
|
||||
pub fn get(&self, id: &str) -> LevelShape {
|
||||
match id {
|
||||
"minecraft:the_nether" => self.nether,
|
||||
"minecraft:the_end" => self.end,
|
||||
_ => self.overworld,
|
||||
}
|
||||
}
|
||||
pub fn get_mut(&mut self, id: &str) -> &mut LevelShape {
|
||||
match id {
|
||||
"minecraft:the_nether" => &mut self.nether,
|
||||
"minecraft:the_end" => &mut self.end,
|
||||
_ => &mut self.overworld,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Default for LevelShapes {
|
||||
fn default() -> Self {
|
||||
Self::VANILLA
|
||||
}
|
||||
}
|
||||
|
||||
/// The span of block Y a chunk holds: from `min_y`, `sections` sections up
|
||||
/// (`LevelShape::column`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -206,6 +243,47 @@ pub fn free_columns<T: Send + 'static>(columns: T, count: usize) {
|
||||
pub fn set_block_states(states: usize) {
|
||||
let bits = horizon_core::paletted::ceil_log2(states).max(16);
|
||||
BLOCK_BITS.store(bits, std::sync::atomic::Ordering::Relaxed);
|
||||
forget_shared();
|
||||
}
|
||||
|
||||
/// One allocation per kind of uniform section (a single block, a single
|
||||
/// biome and one light value), shared by every column that has it, keyed by
|
||||
/// (block, biome, light). A tall column is mostly such sections: air with
|
||||
/// full sky light above the terrain. Edits copy them (`Arc::make_mut`).
|
||||
type SharedSections = std::collections::HashMap<(u32, u32, u8), Arc<Section>>;
|
||||
static SHARED: std::sync::Mutex<Option<SharedSections>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// Replaces the uniform sections of `sections` with the shared ones.
|
||||
pub fn share_uniform(sections: &mut [Arc<Section>]) {
|
||||
let key = |s: &Section| -> Option<(u32, u32, u8)> {
|
||||
let Light::Uniform(light) = s.light else {
|
||||
return None;
|
||||
};
|
||||
if s.blocks.bits() != 0 || s.biomes.bits() != 0 {
|
||||
return None;
|
||||
}
|
||||
Some((s.blocks.uniform()?, s.biomes.uniform()?, light))
|
||||
};
|
||||
let Ok(mut shared) = SHARED.lock() else {
|
||||
return;
|
||||
};
|
||||
let shared = shared.get_or_insert_with(Default::default);
|
||||
for section in sections {
|
||||
let Some(k) = key(section) else {
|
||||
continue;
|
||||
};
|
||||
let one = shared.entry(k).or_insert_with(|| section.clone());
|
||||
if !Arc::ptr_eq(one, section) {
|
||||
*section = one.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets the shared sections, when the palette changes.
|
||||
fn forget_shared() {
|
||||
if let Ok(mut shared) = SHARED.lock() {
|
||||
*shared = None;
|
||||
}
|
||||
}
|
||||
/// Packed light per block: sky light in the high nibble, block light in the low one.
|
||||
#[derive(Clone)]
|
||||
@@ -337,13 +415,14 @@ impl Chunk {
|
||||
Self::with_layout(x, z, ColumnLayout::VANILLA)
|
||||
}
|
||||
pub fn with_layout(x: i32, z: i32, layout: ColumnLayout) -> Self {
|
||||
let mut one = [Arc::new(Section::default())];
|
||||
share_uniform(&mut one);
|
||||
let [empty] = one;
|
||||
Self {
|
||||
x,
|
||||
z,
|
||||
min_y: layout.min_y(),
|
||||
sections: (0..layout.sections())
|
||||
.map(|_| Arc::new(Section::default()))
|
||||
.collect(),
|
||||
sections: (0..layout.sections()).map(|_| empty.clone()).collect(),
|
||||
surface: OnceLock::new(),
|
||||
motion_blocking: OnceLock::new(),
|
||||
lit: false,
|
||||
|
||||
@@ -296,8 +296,9 @@ fn memory() -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The report's details: Horizon's and the machine's, then what the game set as it ran.
|
||||
pub fn system_details() -> Vec<(String, String)> {
|
||||
/// The report's details: Horizon's and the machine's, then what the game set as it ran. A dedicated
|
||||
/// server renders nothing: it leaves out the render settings it read from options.txt.
|
||||
pub fn system_details(side: &str) -> Vec<(String, String)> {
|
||||
let mut out = vec![
|
||||
("Horizon Version".to_owned(), version()),
|
||||
(
|
||||
@@ -308,13 +309,21 @@ pub fn system_details() -> Vec<(String, String)> {
|
||||
("CPU".to_owned(), cpu()),
|
||||
("Memory".to_owned(), memory()),
|
||||
];
|
||||
out.extend(details().into_iter().map(|(k, v)| (k.to_owned(), v)));
|
||||
out.extend(
|
||||
details()
|
||||
.into_iter()
|
||||
.filter(|(k, _)| side != "server" || *k != "Render Settings")
|
||||
.map(|(k, v)| (k.to_owned(), v)),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
fn thread_name() -> String {
|
||||
/// The thread's name as vanilla's reports say it: the main thread is the client's "Render thread"
|
||||
/// and the dedicated server's "Server thread".
|
||||
fn thread_name(side: &str) -> String {
|
||||
let current = std::thread::current();
|
||||
match current.name() {
|
||||
Some("main") | None if side == "server" => "Server thread".into(),
|
||||
Some("main") | None => "Render thread".into(),
|
||||
Some(name) => name.into(),
|
||||
}
|
||||
@@ -369,9 +378,9 @@ pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Opti
|
||||
description: "Watching Server".into(),
|
||||
error: format!("{message} (Watching Server)"),
|
||||
stack: String::new(),
|
||||
thread: thread_name(),
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details: system_details(),
|
||||
details: system_details(side),
|
||||
log: crate::logging::recent_lines(),
|
||||
},
|
||||
)
|
||||
@@ -480,9 +489,9 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
|
||||
&std::backtrace::Backtrace::force_capture().to_string(),
|
||||
true,
|
||||
),
|
||||
thread: thread_name(),
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details: system_details(),
|
||||
details: system_details(side),
|
||||
log: crate::logging::recent_lines(),
|
||||
}
|
||||
}
|
||||
@@ -503,7 +512,7 @@ pub fn install(home: PathBuf, side: &'static str) {
|
||||
} else {
|
||||
log::error!(
|
||||
"Caught previously unhandled exception in thread {}: {}",
|
||||
thread_name(),
|
||||
thread_name(side),
|
||||
panic_message(info)
|
||||
);
|
||||
}
|
||||
@@ -552,9 +561,9 @@ pub fn report_error(
|
||||
description: DebugCrash::MESSAGE.into(),
|
||||
error: DebugCrash::MESSAGE.into(),
|
||||
stack: debug.stack.clone(),
|
||||
thread: thread_name(),
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details: system_details(),
|
||||
details: system_details(side),
|
||||
log: crate::logging::recent_lines(),
|
||||
},
|
||||
);
|
||||
@@ -572,9 +581,9 @@ pub fn report_error(
|
||||
description: "Fatal error".into(),
|
||||
error: message,
|
||||
stack: String::new(),
|
||||
thread: thread_name(),
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details: system_details(),
|
||||
details: system_details(side),
|
||||
log: crate::logging::recent_lines(),
|
||||
},
|
||||
)
|
||||
@@ -672,6 +681,26 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dedicated_servers_report_names_its_server_thread_and_no_render_settings() {
|
||||
let unnamed = |side: &'static str| {
|
||||
std::thread::spawn(move || thread_name(side))
|
||||
.join()
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(unnamed("server"), "Server thread");
|
||||
assert_eq!(unnamed("client"), "Render thread");
|
||||
set_detail("Render Settings", "render distance 12");
|
||||
let has = |side| {
|
||||
system_details(side)
|
||||
.iter()
|
||||
.any(|(k, _)| k == "Render Settings")
|
||||
};
|
||||
assert!(has("client"));
|
||||
assert!(!has("server"));
|
||||
clear_detail("Render Settings");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacks_start_at_the_panicking_frame() {
|
||||
let bt = format!(
|
||||
|
||||
@@ -636,11 +636,6 @@ pub fn worldgen_overrides(
|
||||
let read = || String::from_utf8(pack.read(&file)?).ok();
|
||||
match out.add_file(&pack.id, &file, read) {
|
||||
PackFile::Read | PackFile::Other => {}
|
||||
PackFile::DimensionType { id, min_y, height } => warnings.push(format!(
|
||||
"{}: dimension type {id} (min_y {min_y}, height {height}) is not used: \
|
||||
the dimension keeps its vanilla build height",
|
||||
pack.id
|
||||
)),
|
||||
PackFile::NotRead(kind) => {
|
||||
*not_read.entry(format!("{}: {kind}", pack.id)).or_default() += 1
|
||||
}
|
||||
@@ -713,10 +708,13 @@ mod tests {
|
||||
);
|
||||
let tag_packs: Vec<&str> = o.tags("biome_tags").map(|(_, p, _)| p).collect();
|
||||
assert_eq!(tag_packs, ["file/b", "file/a"]);
|
||||
assert_eq!(warnings.len(), 3, "{warnings:?}");
|
||||
assert!(warnings[0].contains("height 2096"));
|
||||
assert!(warnings[1].contains("minecraft:new_peaks"));
|
||||
assert!(warnings[2].contains("worldgen/world_preset"));
|
||||
assert_eq!(warnings.len(), 2, "{warnings:?}");
|
||||
assert!(warnings[0].contains("minecraft:new_peaks"));
|
||||
assert!(warnings[1].contains("worldgen/world_preset"));
|
||||
assert_eq!(
|
||||
o.level_height("minecraft:overworld").map(|h| h.height),
|
||||
Some(2096)
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
use horizon_world::nbt::parse_snbt;
|
||||
|
||||
@@ -148,6 +148,15 @@ pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `MobEffectInstance.withScaledDuration`: max(floor(d * scale), 1), leaving
|
||||
/// an infinite (-1) or zero duration alone.
|
||||
pub fn scaled_duration(duration: i32, scale: f32) -> i32 {
|
||||
if duration == -1 || duration == 0 {
|
||||
duration
|
||||
} else {
|
||||
((duration as f32 * scale).floor() as i32).max(1)
|
||||
}
|
||||
}
|
||||
/// What an omen's `applyEffectTick` decided.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum OmenTick {
|
||||
@@ -308,19 +317,29 @@ impl PlayerEffects {
|
||||
pub fn potion_effects(
|
||||
potion: Option<&str>,
|
||||
scale: f32,
|
||||
) -> (Vec<EffectInstance>, Vec<EffectAction>) {
|
||||
let effects = potion
|
||||
.and_then(potions::potion)
|
||||
.map_or(&[][..], |d| d.effects);
|
||||
Self::contents_effects(effects, scale)
|
||||
}
|
||||
/// `PotionContents.forEachEffect`'s effects, as `potion_effects`.
|
||||
pub fn contents_effects(
|
||||
effects: &[(&'static str, i32, i32)],
|
||||
scale: f32,
|
||||
) -> (Vec<EffectInstance>, Vec<EffectAction>) {
|
||||
let mut lasting = Vec::new();
|
||||
let mut now = Vec::new();
|
||||
if let Some(def) = potion.and_then(potions::potion) {
|
||||
for (id, duration, amp) in def.effects {
|
||||
let id = potions::effect(id).map_or("speed", |e| e.id);
|
||||
if potions::effect(id).is_some_and(|e| e.instant) {
|
||||
now.extend(instant(id, *amp, 1.0));
|
||||
} else {
|
||||
// withScaledDuration: max(floor(d * scale), 1).
|
||||
let d = ((*duration as f32) * scale).floor() as i32;
|
||||
lasting.push(EffectInstance::new(id, d.max(1), *amp));
|
||||
}
|
||||
for (id, duration, amp) in effects {
|
||||
let id = potions::effect(id).map_or("speed", |e| e.id);
|
||||
if potions::effect(id).is_some_and(|e| e.instant) {
|
||||
now.extend(instant(id, *amp, 1.0));
|
||||
} else {
|
||||
lasting.push(EffectInstance::new(
|
||||
id,
|
||||
scaled_duration(*duration, scale),
|
||||
*amp,
|
||||
));
|
||||
}
|
||||
}
|
||||
(lasting, now)
|
||||
|
||||
@@ -311,10 +311,14 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
fog.cloud_end = fog.environmental_end;
|
||||
}
|
||||
FogType::PowderSnow => {
|
||||
fog.environmental_start = 0.0;
|
||||
fog.environmental_end = 2.0;
|
||||
fog.sky_end = 2.0;
|
||||
fog.cloud_end = 2.0;
|
||||
// PowderedSnowFogEnvironment.setupFog.
|
||||
(fog.environmental_start, fog.environmental_end) = if input.spectator {
|
||||
(-8.0, rd * 0.5)
|
||||
} else {
|
||||
(0.0, 2.0)
|
||||
};
|
||||
fog.sky_end = fog.environmental_end;
|
||||
fog.cloud_end = fog.environmental_end;
|
||||
}
|
||||
_ if input.blindness.is_some() => {
|
||||
let (duration, infinite) = input.blindness.unwrap_or((0, true));
|
||||
|
||||
@@ -131,8 +131,20 @@ impl Style {
|
||||
value: f32,
|
||||
hover: bool,
|
||||
active: bool,
|
||||
dragging: bool,
|
||||
) {
|
||||
ui::slider(batch, atlas, x, y, w, label, value, hover && active, active);
|
||||
ui::slider(
|
||||
batch,
|
||||
atlas,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
label,
|
||||
value,
|
||||
hover && active,
|
||||
active,
|
||||
dragging && active,
|
||||
);
|
||||
}
|
||||
/// The cost tag and the description beside a control, wrapped to two lines.
|
||||
pub fn description(
|
||||
|
||||
@@ -855,6 +855,7 @@ impl GraphicsUi {
|
||||
value,
|
||||
hover || self.dragging == Some(item),
|
||||
live,
|
||||
self.dragging == Some(item),
|
||||
),
|
||||
None => STYLE.button(
|
||||
batch,
|
||||
|
||||
@@ -168,6 +168,31 @@ fn strip_frame(pixels: &[u8], stride: u32, x: u32, y: u32, w: u32, h: u32) -> Ve
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The `animation` section of a texture's `.mcmeta`.
|
||||
fn animation_of(mcmeta: Option<&str>) -> Option<AnimationMetadata> {
|
||||
let meta: serde_json::Value = serde_json::from_str(mcmeta?).ok()?;
|
||||
AnimationMetadata::from_json(&meta.get("animation")?.to_string()).ok()
|
||||
}
|
||||
/// An animated strip's first frame: the region the mcmeta's frame size picks
|
||||
/// (`AnimationMetadataSection.frameSize`), which is the top-left square unless
|
||||
/// it names other dimensions or the frames run along a row.
|
||||
fn first_frame(
|
||||
(w, h, pixels): (u32, u32, Vec<u8>),
|
||||
animation: Option<&AnimationMetadata>,
|
||||
) -> (u32, u32, Vec<u8>) {
|
||||
let [fw, fh] = animation.map_or([w.min(h) as i32; 2], |meta| {
|
||||
meta.frame_size(w as i32, h as i32)
|
||||
});
|
||||
match (u32::try_from(fw), u32::try_from(fh)) {
|
||||
(Ok(fw), Ok(fh))
|
||||
if fw > 0 && fh > 0 && w % fw == 0 && h % fh == 0 && (fw, fh) != (w, h) =>
|
||||
{
|
||||
let side = fw.max(fh);
|
||||
(side, side, strip_frame(&pixels, w, 0, 0, fw, fh))
|
||||
}
|
||||
_ => (w, h, pixels),
|
||||
}
|
||||
}
|
||||
/// `image` inside a one-texel border copied from its opposite edges, the
|
||||
/// texels repeat addressing reads past each edge.
|
||||
fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
|
||||
@@ -184,8 +209,9 @@ fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
|
||||
}
|
||||
/// Sprites the atlas definitions (`atlases/*.json`) add beyond the texture
|
||||
/// files already packed: generated ones (`paletted_permutations` such as the
|
||||
/// armour trim sprites, `unstitch` regions) and files listed under another id.
|
||||
fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
|
||||
/// armour trim sprites, `unstitch` regions) and files listed under another id,
|
||||
/// such as other namespaces' item textures; and the animated ones' names.
|
||||
fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<String>) {
|
||||
use crate::atlas_sources::{self, SpriteOrigin};
|
||||
let have: std::collections::HashSet<&str> = packed.iter().map(|i| i.0.as_str()).collect();
|
||||
let textures = atlas_sources::list_textures(root);
|
||||
@@ -229,29 +255,45 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
|
||||
};
|
||||
let first = |(name, origins): &(String, Vec<(usize, SpriteOrigin)>)| {
|
||||
origins.iter().find_map(|(order, origin)| {
|
||||
let mut animated = false;
|
||||
let image = match origin {
|
||||
SpriteOrigin::File(texture) => {
|
||||
let path = texture.strip_prefix("minecraft:").unwrap_or(texture);
|
||||
load(texture, "textures").map(|(w, h, p)| {
|
||||
// Animated strips keep their first frame, like the packed files.
|
||||
if h > w && (path.starts_with("block/") || path.starts_with("item/")) {
|
||||
(w, w, p[..(w * w * 4) as usize].to_vec())
|
||||
// Its path under `textures/` in any namespace (a server
|
||||
// pack's `lobby:item/...` as well as vanilla's).
|
||||
let path = texture.split_once(':').map_or(texture.as_str(), |(_, p)| p);
|
||||
let file = atlas_sources::texture_path(root, texture, "textures");
|
||||
decode(&file).ok().map(|image| {
|
||||
// Animated strips keep their first frame and animate,
|
||||
// like the packed files.
|
||||
if path.starts_with("block/") || path.starts_with("item/") {
|
||||
let mcmeta = fs::read_to_string(file.with_extension("png.mcmeta")).ok();
|
||||
let animation = animation_of(mcmeta.as_deref());
|
||||
animated = animation.is_some();
|
||||
first_frame(image, animation.as_ref())
|
||||
} else {
|
||||
(w, h, p)
|
||||
image
|
||||
}
|
||||
})
|
||||
}
|
||||
generated => atlas_sources::generate(generated, &load),
|
||||
};
|
||||
image.map(|(w, h, pixels)| (*order, (name.clone(), w, h, pixels)))
|
||||
image.map(|(w, h, pixels)| (*order, (name.clone(), w, h, pixels), animated))
|
||||
})
|
||||
};
|
||||
let mut out: Vec<_> = crate::par::par_map(&names, first)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
out.sort_unstable_by_key(|(order, _)| *order);
|
||||
out.into_iter().map(|(_, image)| image).collect()
|
||||
out.sort_unstable_by_key(|(order, ..)| *order);
|
||||
let animated = out
|
||||
.iter()
|
||||
.filter(|(.., animated)| *animated)
|
||||
.map(|(_, image, _)| image.0.clone())
|
||||
.collect();
|
||||
(
|
||||
out.into_iter().map(|(_, image, _)| image).collect(),
|
||||
animated,
|
||||
)
|
||||
}
|
||||
impl HudAtlas {
|
||||
/// Keep a ready profile skin when another source only has its default.
|
||||
@@ -425,37 +467,15 @@ impl HudAtlas {
|
||||
.with_extension("")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let (mut w, mut h, pixels) = decode(path).ok()?;
|
||||
let image = decode(path).ok()?;
|
||||
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
|
||||
let animation = mcmeta
|
||||
.as_deref()
|
||||
.and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok())
|
||||
.and_then(|meta| {
|
||||
meta.get("animation")
|
||||
.and_then(|section| AnimationMetadata::from_json(§ion.to_string()).ok())
|
||||
});
|
||||
// An animated strip keeps its first frame: the region the mcmeta's
|
||||
// frame size picks (`AnimationMetadataSection.frameSize`), which is
|
||||
// the top-left square unless it names other dimensions or the
|
||||
// frames run along a row.
|
||||
let animation = animation_of(mcmeta.as_deref());
|
||||
// An animated strip keeps its first frame.
|
||||
let frames = name.starts_with("block/") || name.starts_with("item/");
|
||||
let pixels = if frames {
|
||||
let stride = w;
|
||||
let [fw, fh] = animation.as_ref().map_or([w.min(h) as i32; 2], |meta| {
|
||||
meta.frame_size(w as i32, h as i32)
|
||||
});
|
||||
match (u32::try_from(fw), u32::try_from(fh)) {
|
||||
(Ok(fw), Ok(fh))
|
||||
if fw > 0 && fh > 0 && w % fw == 0 && h % fh == 0 && (fw, fh) != (w, h) =>
|
||||
{
|
||||
let side = fw.max(fh);
|
||||
(w, h) = (side, side);
|
||||
strip_frame(&pixels, stride, 0, 0, fw, fh)
|
||||
}
|
||||
_ => pixels,
|
||||
}
|
||||
let (w, h, pixels) = if frames {
|
||||
first_frame(image, animation.as_ref())
|
||||
} else {
|
||||
pixels
|
||||
image
|
||||
};
|
||||
// The default texture's size, when a pack's differs from it.
|
||||
let gui_size = default_textures
|
||||
@@ -507,7 +527,9 @@ impl HudAtlas {
|
||||
images.push(image);
|
||||
}
|
||||
if full {
|
||||
images.extend(atlas_sprites(root, &images));
|
||||
let (more, more_animated) = atlas_sprites(root, &images);
|
||||
images.extend(more);
|
||||
animated.extend(more_animated);
|
||||
}
|
||||
// The title screen's "JAVA EDITION" banner reads "RUST EDITION".
|
||||
if let Some(edition) = images
|
||||
@@ -579,7 +601,15 @@ impl HudAtlas {
|
||||
let Some(rect) = sprites.get(&name).copied() else {
|
||||
continue;
|
||||
};
|
||||
let layer = loaded.insert(root, &format!("minecraft:{name}"))?;
|
||||
// Another namespace's sprite is named with it (`lobby:item/...`).
|
||||
let id = if name.contains(':') {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("minecraft:{name}")
|
||||
};
|
||||
let Ok(layer) = loaded.insert(root, &id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(animation) = loaded.animations.iter().find(|a| a.layer == layer).cloned()
|
||||
else {
|
||||
continue;
|
||||
@@ -1890,6 +1920,39 @@ mod tests {
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 255, 0, 255]);
|
||||
}
|
||||
#[test]
|
||||
fn another_namespaces_animated_item_texture_packs_its_first_frame_and_animates() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-ns-anim-{}", std::process::id()));
|
||||
let atlases = root.join("assets/minecraft/atlases");
|
||||
let item = root.join("assets/lobby/textures/item/npc");
|
||||
fs::create_dir_all(&atlases).unwrap();
|
||||
fs::create_dir_all(&item).unwrap();
|
||||
fs::write(
|
||||
atlases.join("items.json"),
|
||||
r#"{"sources": [{"type": "directory", "source": "item", "prefix": "item/"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
// A server pack's NPC skin: two 16x16 frames, green then red.
|
||||
write_strip(
|
||||
&item.join("skin.png"),
|
||||
16,
|
||||
32,
|
||||
&[[0, 255, 0, 255], [255, 0, 0, 255]],
|
||||
Some(r#"{"animation": {"frametime": 3}}"#),
|
||||
);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let rect = atlas.sprites["lobby:item/npc/skin"];
|
||||
// One frame, not the whole strip, which a model's UVs would span.
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 15), [0, 255, 0, 255]);
|
||||
let (_, animation) = atlas
|
||||
.sprite_animations
|
||||
.iter()
|
||||
.find(|(r, _)| r.u0 == rect.u0 && r.v0 == rect.v0)
|
||||
.expect("the namespaced sprite animates");
|
||||
assert_eq!(animation.frame_count(), 2);
|
||||
}
|
||||
/// `count` images of `w x h`; layout reads only their sizes.
|
||||
fn sized(count: usize, w: u32, h: u32) -> Vec<PackedImage> {
|
||||
(0..count)
|
||||
|
||||
@@ -467,8 +467,15 @@ fn pack_lighting(
|
||||
/// edits and immutable snapshots retained by other workers.
|
||||
pub fn apply_lighting(chunk: &mut Chunk, lights: Vec<Light>) {
|
||||
for (section, light) in chunk.sections.iter_mut().zip(lights) {
|
||||
// A shared section already lit so stays as it is.
|
||||
if let (Light::Uniform(a), Light::Uniform(b)) = (§ion.light, &light)
|
||||
&& a == b
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Arc::make_mut(section).light = light;
|
||||
}
|
||||
crate::chunk::share_uniform(&mut chunk.sections);
|
||||
}
|
||||
|
||||
/// `apply_lighting` for a chunk lit before: writes only the sections whose
|
||||
@@ -535,6 +542,7 @@ pub fn light_from_layers<L: AsRef<[u8]>>(
|
||||
section.light = Light::Full(data);
|
||||
section.compact();
|
||||
}
|
||||
crate::chunk::share_uniform(&mut chunk.sections);
|
||||
chunk.lit = true;
|
||||
chunk.light_loaded = true;
|
||||
}
|
||||
@@ -658,6 +666,12 @@ impl<'w> Region<'w> {
|
||||
let slot = self.slot(x, y, z)?;
|
||||
Some(self.slots[slot].section.get(index(x, y, z)))
|
||||
}
|
||||
/// The one state of the section holding `y`, when it has a single one.
|
||||
fn uniform_state(&mut self, x: i32, y: i32, z: i32) -> Option<u32> {
|
||||
let slot = self.slot(x, y, z)?;
|
||||
let section = &self.slots[slot].section;
|
||||
(section.blocks.bits() == 0).then(|| section.get(0))
|
||||
}
|
||||
/// Packed light (sky << 4 | block), None outside loaded, lit chunks.
|
||||
fn light(&mut self, x: i32, y: i32, z: i32) -> Option<u8> {
|
||||
let slot = self.slot(x, y, z)?;
|
||||
@@ -826,6 +840,15 @@ fn lowest_sky_source(world: &mut Region, tables: &LightTables, x: i32, z: i32) -
|
||||
return min_y;
|
||||
};
|
||||
while y > min_y {
|
||||
// A section of one state that lets light through itself (the air of
|
||||
// a tall column) holds no source edge: go to its bottom block.
|
||||
if let Some(state) = world.uniform_state(x, y - 1, z)
|
||||
&& state == top
|
||||
&& !tables.edge_occluded(top as usize, top as usize)
|
||||
{
|
||||
y = ((y - 1 - min_y) & !15) + min_y;
|
||||
continue;
|
||||
}
|
||||
let Some(bottom) = world.state(x, y - 1, z) else {
|
||||
break;
|
||||
};
|
||||
@@ -1352,6 +1375,14 @@ mod tests {
|
||||
assert_eq!(chunk.light(8, 1601, 8) >> 4, 15);
|
||||
assert_eq!(chunk.light(8, 1599, 8) >> 4, 0, "under the roof");
|
||||
assert_eq!(chunk.light(8, 2031, 8), 0xF0);
|
||||
// The empty sections above are one shared allocation.
|
||||
let top = chunk.sections.len() - 1;
|
||||
assert!(Arc::ptr_eq(&chunk.sections[top], &chunk.sections[top - 10]));
|
||||
let other = Chunk::with_layout(1, 0, layout);
|
||||
apply_lighting(&mut other.clone(), vec![Light::Uniform(0xF0); 131]);
|
||||
let mut lit = other.clone();
|
||||
apply_lighting(&mut lit, vec![Light::Uniform(0xF0); 131]);
|
||||
assert!(Arc::ptr_eq(&chunk.sections[top], &lit.sections[top]));
|
||||
}
|
||||
#[test]
|
||||
fn a_tall_column_relights_as_its_full_light_and_keys_its_sections() {
|
||||
@@ -1626,9 +1657,10 @@ mod tests {
|
||||
.iter()
|
||||
.all(|v| matches!(v, Light::Uniform(n) if *n == if sky { 0xF0 } else { 0 }))
|
||||
);
|
||||
let section = Arc::as_ptr(&chunk.sections[0]);
|
||||
apply_lighting(&mut chunk, lights);
|
||||
assert_eq!(section, Arc::as_ptr(&chunk.sections[0]));
|
||||
// Uniform sections are one shared allocation (`share_uniform`).
|
||||
let first = &chunk.sections[0];
|
||||
assert!(chunk.sections.iter().all(|s| Arc::ptr_eq(s, first)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2551,6 +2551,7 @@ impl OptionsUi {
|
||||
shown,
|
||||
hover || dragging,
|
||||
true,
|
||||
dragging,
|
||||
);
|
||||
}
|
||||
WKind::Text {
|
||||
@@ -3672,6 +3673,57 @@ mod tests {
|
||||
assert_eq!(ui.page(), Page::NativeMod(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_native_mods_key_shows_and_keeps_the_key_pressed() {
|
||||
// #3539: Zoom's key set to R showed another key.
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
let size = (480.0, 240.0);
|
||||
s.mods.installed.push(crate::mod_list::Installed {
|
||||
id: "zoom".into(),
|
||||
title: "Zoom 2.0.0".into(),
|
||||
description: String::new(),
|
||||
problem: None,
|
||||
dev: false,
|
||||
native: Some(crate::mod_list::NativePage {
|
||||
keys: vec![["zoom".into(), "Zoom key".into(), "key.keyboard.z".into()]],
|
||||
buttons: Vec::new(),
|
||||
}),
|
||||
});
|
||||
ui.show(Page::NativeMod(0));
|
||||
let key_label = |ui: &mut OptionsUi, s: &Settings| {
|
||||
let (widgets, ..) = ui.layout(s, size.0, size.1);
|
||||
widgets.into_iter().find_map(|w| match w.kind {
|
||||
WKind::Button {
|
||||
label,
|
||||
act: Act::NativeKey(0, 0),
|
||||
..
|
||||
} => Some((label.plain(), w.x, w.y)),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let (label, x, y) = key_label(&mut ui, &s).expect("the key's row");
|
||||
assert_eq!(label, "Z");
|
||||
ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
x + 1.0,
|
||||
y + 1.0,
|
||||
size,
|
||||
[480, 240],
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
ui.key(&mut s, &atlas, winit::keyboard::KeyCode::KeyR, Some("r")),
|
||||
vec![Event::Changed]
|
||||
);
|
||||
assert_eq!(key_label(&mut ui, &s).unwrap().0, "R");
|
||||
let saved = crate::mod_list::ModList::parse(&s.mods.serialize());
|
||||
assert_eq!(saved.keys_of("zoom"), vec![("zoom", "key.keyboard.r")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_tooltip_keys_follow_value_dependent_descriptions() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -278,7 +278,8 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
|
||||
Source::Dir(d) => fs::read(d.join("pack.mcmeta")).ok()?,
|
||||
Source::Zip(z) => Zip::open(z).ok()?.read("pack.mcmeta").ok()?,
|
||||
};
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
// Gson reads past a UTF-8 byte order mark; serde_json rejects it.
|
||||
serde_json::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&bytes)).ok()
|
||||
}
|
||||
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
|
||||
pack_for(id, title, source, RESOURCE_FORMAT)
|
||||
@@ -938,6 +939,25 @@ impl SelectionModel {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn mcmeta_with_utf8_bom_is_read() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-bom-{}", std::process::id()));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let mut meta = b"\xEF\xBB\xBF".to_vec();
|
||||
meta.extend_from_slice(br#"{"pack":{"description":"d","pack_format":97}}"#);
|
||||
fs::write(dir.join("pack.mcmeta"), &meta).unwrap();
|
||||
assert!(read_meta(&Source::Dir(dir.clone())).is_some());
|
||||
// Without a BOM the same JSON still reads; other leading bytes still fail.
|
||||
fs::write(dir.join("pack.mcmeta"), &meta[3..]).unwrap();
|
||||
assert!(read_meta(&Source::Dir(dir.clone())).is_some());
|
||||
fs::write(
|
||||
dir.join("pack.mcmeta"),
|
||||
[b"\xEF\xBB".as_slice(), &meta[3..]].concat(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(read_meta(&Source::Dir(dir.clone())).is_none());
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
#[test]
|
||||
fn compatibility_ranges() {
|
||||
let v = |s: &str| serde_json::from_str::<serde_json::Value>(s).unwrap();
|
||||
assert!(compatible(&v(
|
||||
|
||||
@@ -1042,6 +1042,8 @@ pub struct ParticleEngine {
|
||||
level_random: LegacyRandom,
|
||||
/// `SpellParticle.RANDOM`.
|
||||
spell_random: LegacyRandom,
|
||||
/// The local player's eye while it looks through a spyglass in first person.
|
||||
scope_eye: Option<[f64; 3]>,
|
||||
/// Seeds for each particle's own random (`RandomSource.create()`).
|
||||
seeds: LegacyRandom,
|
||||
/// When set, particles ask the level for other particles here instead
|
||||
@@ -1080,6 +1082,7 @@ impl ParticleEngine {
|
||||
random: LegacyRandom::new(0x7061_7274),
|
||||
level_random: LegacyRandom::new(0x6c65_7665),
|
||||
spell_random: LegacyRandom::new(0x7370_656c),
|
||||
scope_eye: None,
|
||||
seeds: LegacyRandom::new(0x7365_6564),
|
||||
requested: None,
|
||||
names: Vec::new(),
|
||||
@@ -1437,6 +1440,16 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Set each frame to the local player's eye while it scopes in first person.
|
||||
pub fn set_scope_eye(&mut self, eye: Option<[f64; 3]>) {
|
||||
self.scope_eye = eye;
|
||||
}
|
||||
/// `SpellParticle.isCloseToScopingPlayer`: within 3 blocks of that eye.
|
||||
fn close_to_scoping_player(&self, pos: [f64; 3]) -> bool {
|
||||
self.scope_eye.is_some_and(|eye| {
|
||||
(eye[0] - pos[0]).powi(2) + (eye[1] - pos[1]).powi(2) + (eye[2] - pos[2]).powi(2) <= 9.0
|
||||
})
|
||||
}
|
||||
/// `ClientPacketListener.handleParticleEvent` for `ServerLevel.sendParticles`.
|
||||
pub fn send_particles(
|
||||
&mut self,
|
||||
@@ -2636,6 +2649,12 @@ impl ParticleEngine {
|
||||
q.size *= 0.75;
|
||||
q.lifetime = (8.0 / (f64::from(r.next_float()) * 0.8 + 0.2)) as i32;
|
||||
q.has_physics = false;
|
||||
// SpellParticle's constructor: setAlpha(0.0F), which also sets originalAlpha.
|
||||
// MobEffectProvider sets the option's alpha after it, so an entity
|
||||
// effect starts visible and the first tick hides it.
|
||||
if self.close_to_scoping_player(q.position) {
|
||||
q.rgba[3] = 0.0;
|
||||
}
|
||||
match ty {
|
||||
T::Witch => {
|
||||
let b = self.random.next_float() * 0.5 + 0.35;
|
||||
@@ -3743,7 +3762,12 @@ impl ParticleEngine {
|
||||
Behaviour::Spell { original_alpha } => {
|
||||
p.base_tick(level);
|
||||
self.sprite_from_age(p);
|
||||
p.rgba[3] += (original_alpha - p.rgba[3]) * 0.05;
|
||||
// SpellParticle.tick: alpha 0 while close, else eased back to originalAlpha.
|
||||
if self.close_to_scoping_player(p.position) {
|
||||
p.rgba[3] = 0.0;
|
||||
} else {
|
||||
p.rgba[3] += (original_alpha - p.rgba[3]) * 0.05;
|
||||
}
|
||||
}
|
||||
Behaviour::Cloud => {
|
||||
p.base_tick(level);
|
||||
|
||||
@@ -50,6 +50,9 @@ pub fn sound_source(event: &str) -> usize {
|
||||
index("ambient")
|
||||
} else if e.starts_with("music_disc.") {
|
||||
index("record")
|
||||
} else if e.starts_with("item.goat_horn.") {
|
||||
// InstrumentItem.play: SoundSource.RECORDS.
|
||||
index("record")
|
||||
} else if e.starts_with("music.") {
|
||||
index("music")
|
||||
} else if e.starts_with("weather.") || e.starts_with("entity.lightning_bolt") {
|
||||
|
||||
@@ -27,6 +27,10 @@ pub struct Span {
|
||||
pub fn enable(on: bool) {
|
||||
ENABLED.store(on, Ordering::Relaxed);
|
||||
}
|
||||
/// Whether spans are being recorded.
|
||||
pub fn enabled() -> bool {
|
||||
ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
/// Run `f`, recording its duration under `name` when spans are enabled.
|
||||
#[inline]
|
||||
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
|
||||
|
||||
@@ -390,6 +390,7 @@ pub fn slider(
|
||||
value: f32,
|
||||
hover: bool,
|
||||
active: bool,
|
||||
dragging: bool,
|
||||
) {
|
||||
let kind = crate::widgets::Kind::Slider {
|
||||
value,
|
||||
@@ -401,17 +402,8 @@ pub fn slider(
|
||||
// A controller's D-pad can focus it (Horizon's controller support).
|
||||
crate::widgets::target_as([x, y, w, 20.0], kind);
|
||||
}
|
||||
let track = if hover && active {
|
||||
"gui/sprites/widget/slider_highlighted"
|
||||
} else {
|
||||
"gui/sprites/widget/slider"
|
||||
};
|
||||
let (track, handle) = slider_sprites(hover, active, dragging);
|
||||
nine_slice(batch, atlas, track, x, y, w, 20.0, u32::MAX);
|
||||
let handle = if hover && active {
|
||||
"gui/sprites/widget/slider_handle_highlighted"
|
||||
} else {
|
||||
"gui/sprites/widget/slider_handle"
|
||||
};
|
||||
nine_slice(
|
||||
batch,
|
||||
atlas,
|
||||
@@ -429,6 +421,21 @@ pub fn slider(
|
||||
};
|
||||
centered_text(batch, atlas, label, x + 2.0, y + 6.0, w - 4.0, color);
|
||||
}
|
||||
/// Track and handle sprites: the track outline shows only while the slider is
|
||||
/// dragged, hovering highlights just the handle.
|
||||
fn slider_sprites(hover: bool, active: bool, dragging: bool) -> (&'static str, &'static str) {
|
||||
let track = if dragging {
|
||||
"gui/sprites/widget/slider_highlighted"
|
||||
} else {
|
||||
"gui/sprites/widget/slider"
|
||||
};
|
||||
let handle = if hover && active {
|
||||
"gui/sprites/widget/slider_handle_highlighted"
|
||||
} else {
|
||||
"gui/sprites/widget/slider_handle"
|
||||
};
|
||||
(track, handle)
|
||||
}
|
||||
/// Slider value for a click at `cx` (AbstractSliderButton.setValueFromMouse).
|
||||
pub fn slider_value(x: f32, w: f32, cx: f32) -> f32 {
|
||||
((cx - (x + 4.0)) / (w - 8.0)).clamp(0.0, 1.0)
|
||||
@@ -1344,6 +1351,18 @@ mod tests {
|
||||
parse_nine_slice(&serde_json::from_str(json).unwrap()).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn slider_track_is_outlined_only_while_dragged() {
|
||||
let (track, handle) = slider_sprites(true, true, false);
|
||||
assert_eq!(track, "gui/sprites/widget/slider");
|
||||
assert_eq!(handle, "gui/sprites/widget/slider_handle_highlighted");
|
||||
let (track, handle) = slider_sprites(true, true, true);
|
||||
assert_eq!(track, "gui/sprites/widget/slider_highlighted");
|
||||
assert_eq!(handle, "gui/sprites/widget/slider_handle_highlighted");
|
||||
let (track, handle) = slider_sprites(false, true, false);
|
||||
assert_eq!(track, "gui/sprites/widget/slider");
|
||||
assert_eq!(handle, "gui/sprites/widget/slider_handle");
|
||||
}
|
||||
#[test]
|
||||
fn separator_tiles_sample_both_rows_and_clip_the_last_column() {
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
tile(
|
||||
|
||||
@@ -65,7 +65,7 @@ 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"] }
|
||||
rustix = { version = "1", default-features = false, features = ["std", "time", "process"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# Use the registered URI handler without routing links through Explorer/cmd.
|
||||
|
||||
@@ -153,6 +153,9 @@ pub fn main() -> Result<()> {
|
||||
bits(position),
|
||||
bits(velocity)
|
||||
)),
|
||||
ArrowEvent::PotionPuff { id, color } => {
|
||||
events.push(format!("potion-puff {id} {color:08x}"))
|
||||
}
|
||||
ArrowEvent::Sound {
|
||||
sound,
|
||||
volume,
|
||||
|
||||
@@ -94,6 +94,13 @@ pub enum ArrowEvent {
|
||||
position: Vec3,
|
||||
velocity: Vec3,
|
||||
},
|
||||
/// `Level.broadcastEntityEvent(arrow, 0)`: a tipped arrow stuck for 600
|
||||
/// ticks. The clients burst its particles (`Arrow.handleEntityEvent`);
|
||||
/// the arrow itself is a plain one from now on.
|
||||
PotionPuff {
|
||||
id: u32,
|
||||
color: u32,
|
||||
},
|
||||
Sound {
|
||||
sound: ArrowSound,
|
||||
volume: f32,
|
||||
@@ -235,6 +242,12 @@ impl ArrowState {
|
||||
hit_speed: 0.0,
|
||||
}
|
||||
}
|
||||
/// `Arrow.getColor` of the server's own arrow: the colour of the potion on
|
||||
/// its pickup item, None for a plain arrow (`PotionContents.EMPTY`).
|
||||
pub fn potion_color(&self) -> Option<u32> {
|
||||
let potion = self.pickup_item.potion()?;
|
||||
Some(crate::brewing::potion_color(Some(potion)))
|
||||
}
|
||||
/// The `AbstractArrow` constructor's `pickupItemStack`: the drawn ammo
|
||||
/// (one item). Ammo drawn without being used up (`INTANGIBLE_PROJECTILE`)
|
||||
/// makes the arrow CREATIVE_ONLY; vanilla takes the component off the
|
||||
@@ -634,6 +647,19 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
let at = self.position();
|
||||
self.apply_effects_from_blocks(at, at);
|
||||
}
|
||||
// Arrow.tick on the server: a tipped arrow stuck for 600 ticks sends
|
||||
// entity event 0 (the clients' burst of particles in its colour,
|
||||
// `handleEntityEvent`) and turns into a plain arrow. The flight
|
||||
// and ground particles are the clients' own (`Level.isClientSide`).
|
||||
if self.st.in_ground_time >= 600
|
||||
&& let Some(color) = self.st.potion_color()
|
||||
{
|
||||
self.st.events.push(ArrowEvent::PotionPuff {
|
||||
id: self.p.id,
|
||||
color,
|
||||
});
|
||||
self.st.pickup_item = crate::items::ItemStack::new("minecraft:arrow", 1);
|
||||
}
|
||||
return Hit::None;
|
||||
}
|
||||
self.st.in_ground_time = 0;
|
||||
|
||||
@@ -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 and 6000-tick despawn.
|
||||
//! a ground bounce of -0.5, water buoyancy, pickup delay, 6000-tick despawn,
|
||||
//! and `HoneyBlock.entityInside` when falling against a honey block's side.
|
||||
use crate::{
|
||||
blocks::{BlockFacts, Fluid},
|
||||
collisions::SceneCollisions,
|
||||
@@ -237,6 +238,8 @@ impl ItemEntity {
|
||||
if moved.y != wanted.y {
|
||||
self.velocity.y = 0.0;
|
||||
}
|
||||
// Entity.move -> applyEffectsFromBlocks: HoneyBlock.entityInside.
|
||||
self.apply_honey_slide(world, facts);
|
||||
let mut friction = 0.98;
|
||||
if self.on_ground {
|
||||
let below = world.block(
|
||||
@@ -260,6 +263,51 @@ impl ItemEntity {
|
||||
self.update_fluids(world, fluids);
|
||||
self.age < 6000 && self.position.y > -128.0
|
||||
}
|
||||
/// `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
|
||||
/// false for an item).
|
||||
fn apply_honey_slide(&mut self, world: &World, facts: &BlockFacts) {
|
||||
let b = self.bounds().deflate(Vec3::new(1e-5, 1e-5, 1e-5));
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
for x in lo.x.floor() as i32..=hi.x.floor() as i32 {
|
||||
for y in lo.y.floor() as i32..=hi.y.floor() as i32 {
|
||||
for z in lo.z.floor() as i32..=hi.z.floor() as i32 {
|
||||
if &*facts.get(world.block(x, y, z)).name == "minecraft:honey_block" {
|
||||
self.honey_block_inside([x, y, z]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `HoneyBlock.isSlidingDown` then `doSlideMovement`. `getOldDeltaY` /
|
||||
/// `getNewDeltaY` wrap the stored velocity the same way they do for every
|
||||
/// entity (`vy / 0.98F + 0.08`).
|
||||
pub(crate) fn honey_block_inside(&mut self, pos: [i32; 3]) {
|
||||
let old_delta_y = |dy: f64| dy / f64::from(0.98_f32) + 0.08;
|
||||
let new_delta_y = |dy: f64| (dy - 0.08) * f64::from(0.98_f32);
|
||||
let [x, y, z] = pos.map(f64::from);
|
||||
if self.on_ground
|
||||
|| self.position.y > y + 0.9375 - 1.0e-7
|
||||
|| old_delta_y(self.velocity.y) >= -0.08
|
||||
{
|
||||
return;
|
||||
}
|
||||
let dx = (x + 0.5 - self.position.x).abs();
|
||||
let dz = (z + 0.5 - self.position.z).abs();
|
||||
let overlap = 0.4375 + Self::WIDTH / 2.0;
|
||||
if dx + 1.0e-7 <= overlap && dz + 1.0e-7 <= overlap {
|
||||
return;
|
||||
}
|
||||
let v = self.velocity;
|
||||
let old = old_delta_y(v.y);
|
||||
self.velocity = if old < -0.13 {
|
||||
let factor = -0.05 / old;
|
||||
Vec3::new(v.x * factor, new_delta_y(-0.05), v.z * factor)
|
||||
} else {
|
||||
Vec3::new(v.x, new_delta_y(-0.05), v.z)
|
||||
};
|
||||
}
|
||||
/// `ItemEntity.isMergable` at `age`: a stack below its size whose pickup
|
||||
/// delay isn't infinite, before its lifetime ends.
|
||||
fn mergeable(&self, age: u32, max_stack: u32) -> bool {
|
||||
@@ -1272,3 +1320,89 @@ mod merge_tests {
|
||||
assert_eq!(items[1].stack.count, 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod honey_slide_tests {
|
||||
use super::*;
|
||||
|
||||
fn falling_item(x: f64, y: f64, z: f64, vx: f64, vy: f64, vz: f64) -> ItemEntity {
|
||||
let at = Vec3::new(x, y, z);
|
||||
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::new(vx, vy, vz),
|
||||
on_ground: false,
|
||||
age: 10,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
ticks: 10,
|
||||
}
|
||||
}
|
||||
|
||||
fn old_delta_y(dy: f64) -> f64 {
|
||||
dy / f64::from(0.98_f32) + 0.08
|
||||
}
|
||||
fn new_delta_y(dy: f64) -> f64 {
|
||||
(dy - 0.08) * f64::from(0.98_f32)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_falling_item_beside_honey_slides_down() {
|
||||
// Item width 0.25: overlap 0.5625. Honey at (0, 10, 0); centre 0.5.
|
||||
// x = -0.0625 is against the west face (inset 1/16).
|
||||
let mut item = falling_item(-0.0625, 10.4, 0.5, 0.12, -0.20, 0.04);
|
||||
assert!(old_delta_y(-0.20) < -0.08);
|
||||
assert!(old_delta_y(-0.20) >= -0.13);
|
||||
item.honey_block_inside([0, 10, 0]);
|
||||
assert_eq!(item.velocity.x, 0.12);
|
||||
assert_eq!(item.velocity.z, 0.04);
|
||||
assert_eq!(item.velocity.y, new_delta_y(-0.05));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fast_fall_scales_the_horizontal_speed() {
|
||||
let mut item = falling_item(-0.0625, 10.4, 0.5, 0.2, -0.30, -0.1);
|
||||
let old = old_delta_y(-0.30);
|
||||
assert!(old < -0.13);
|
||||
item.honey_block_inside([0, 10, 0]);
|
||||
let factor = -0.05 / old;
|
||||
assert_eq!(item.velocity.x, 0.2 * factor);
|
||||
assert_eq!(item.velocity.z, -0.1 * factor);
|
||||
assert_eq!(item.velocity.y, new_delta_y(-0.05));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_on_the_ground_or_on_top_does_not_slide() {
|
||||
let mut grounded = falling_item(-0.0625, 10.4, 0.5, 0.0, -0.20, 0.0);
|
||||
grounded.on_ground = true;
|
||||
grounded.honey_block_inside([0, 10, 0]);
|
||||
assert_eq!(grounded.velocity.y, -0.20);
|
||||
|
||||
let mut on_top = falling_item(0.5, 10.9375, 0.5, 0.0, -0.20, 0.0);
|
||||
on_top.honey_block_inside([0, 10, 0]);
|
||||
assert_eq!(on_top.velocity.y, -0.20, "standing on the honey");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_centred_or_slow_item_does_not_slide() {
|
||||
let mut centred = falling_item(0.5, 10.4, 0.5, 0.0, -0.20, 0.0);
|
||||
centred.honey_block_inside([0, 10, 0]);
|
||||
assert_eq!(centred.velocity.y, -0.20, "inside, not on a side");
|
||||
|
||||
let mut slow = falling_item(-0.0625, 10.4, 0.5, 0.0, -0.04, 0.0);
|
||||
assert!(old_delta_y(-0.04) >= -0.08);
|
||||
slow.honey_block_inside([0, 10, 0]);
|
||||
assert_eq!(slow.velocity.y, -0.04);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,12 @@ pub enum ItemPose {
|
||||
Bow {
|
||||
ticks_used: f32,
|
||||
},
|
||||
/// A brush in use (`ItemUseAnimation.BRUSH`): the ticks left of the use
|
||||
/// and the partial tick.
|
||||
Brush {
|
||||
remaining: f32,
|
||||
partial: f32,
|
||||
},
|
||||
/// A spear being charged (`ItemUseAnimation.SPEAR`): the time held and
|
||||
/// the ticks since its last hit's feedback.
|
||||
Spear {
|
||||
@@ -78,6 +84,9 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
|
||||
if matches!(pose, ItemPose::Using) {
|
||||
return m;
|
||||
}
|
||||
if let ItemPose::Brush { remaining, partial } = pose {
|
||||
return m * brush_transform(left, remaining, partial);
|
||||
}
|
||||
// The spear's own place in the hand, without the equip height.
|
||||
if let ItemPose::Spear {
|
||||
kinetic,
|
||||
@@ -177,6 +186,30 @@ impl MapHold {
|
||||
}
|
||||
}
|
||||
|
||||
/// `FirstPersonHandsAndItemsRenderer.applyBrushTransform`: the brush in use,
|
||||
/// swept back and forth by the ticks left of its use (`remaining`).
|
||||
fn brush_transform(left: bool, remaining: f32, partial: f32) -> glam::Mat4 {
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_core::geometry::trig::Mth;
|
||||
let ticks = remaining % 10.0;
|
||||
let delta = ticks - partial + 1.0;
|
||||
let scaled = 1.0 - delta / 10.0;
|
||||
let swipe = -15.0 + 75.0 * (scaled * 2.0 * std::f32::consts::PI).mcos();
|
||||
let sweep = Mat4::from_rotation_x(swipe.to_radians());
|
||||
if left {
|
||||
Mat4::from_translation(Vec3::new(0.1, 0.83, 0.35))
|
||||
* Mat4::from_rotation_x((-80.0f32).to_radians())
|
||||
* Mat4::from_rotation_y((-90.0f32).to_radians())
|
||||
* sweep
|
||||
* Mat4::from_translation(Vec3::new(-0.3, 0.22, 0.35))
|
||||
} else {
|
||||
Mat4::from_translation(Vec3::new(-0.25, 0.22, 0.35))
|
||||
* Mat4::from_rotation_x((-80.0f32).to_radians())
|
||||
* Mat4::from_rotation_y(90.0f32.to_radians())
|
||||
* sweep
|
||||
}
|
||||
}
|
||||
|
||||
/// `renderPlayerArm` up to `renderPlayerHand`: the pose of the bare `left`
|
||||
/// or right arm.
|
||||
pub fn player_arm_pose(left: bool, inverse_height: f32, attack: f32) -> glam::Mat4 {
|
||||
|
||||
@@ -3,7 +3,11 @@ use crate::{
|
||||
chunk::{Chunk, LevelShape},
|
||||
streaming::ChunkGenerator,
|
||||
};
|
||||
use horizon_world::{nbt::Tag, registry::BlockRegistry};
|
||||
use horizon_world::{
|
||||
nbt::Tag,
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
worldgen::feature::{BasicFeature, FeatureBlocks, FeatureWorld},
|
||||
};
|
||||
|
||||
/// The layers of a flat generator, bottom up from the world's minimum Y.
|
||||
pub struct FlatTerrain {
|
||||
@@ -13,11 +17,59 @@ pub struct FlatTerrain {
|
||||
layers: Vec<u32>,
|
||||
biome: u16,
|
||||
surface: Option<crate::lod::Surface>,
|
||||
/// The blocks of `minecraft:void_start_platform`, when the adjusted
|
||||
/// generation settings place it (the Void preset).
|
||||
void_platform: Option<FeatureBlocks>,
|
||||
}
|
||||
/// `GenerationStep.Decoration` ordinals `adjustGenerationSettings` leaves out.
|
||||
const LAKES: usize = 1;
|
||||
const UNDERGROUND_STRUCTURES: usize = 3;
|
||||
const SURFACE_STRUCTURES: usize = 4;
|
||||
/// FlatLevelGeneratorSettings.adjustGenerationSettings for the world's own
|
||||
/// biome: its placed features, by step, when `features` is on and the world
|
||||
/// isn't only air (`voidGen`) or its biome is the_void. The structure steps
|
||||
/// are left out, and the lakes step when `lakes` puts the lava lakes there.
|
||||
fn decoration_features(
|
||||
biome_features: &[Vec<String>],
|
||||
features: bool,
|
||||
lakes: bool,
|
||||
void_gen: bool,
|
||||
the_void: bool,
|
||||
) -> impl Iterator<Item = &str> {
|
||||
let decorate = (!void_gen || the_void) && features;
|
||||
biome_features
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(move |&(step, _)| {
|
||||
decorate
|
||||
&& step != UNDERGROUND_STRUCTURES
|
||||
&& step != SURFACE_STRUCTURES
|
||||
&& !(lakes && step == LAKES)
|
||||
})
|
||||
.flat_map(|(_, step)| step.iter().map(String::as_str))
|
||||
}
|
||||
/// The proto chunk as the world a feature writes in.
|
||||
struct ChunkWorld<'a>(&'a mut Chunk);
|
||||
impl FeatureWorld for ChunkWorld<'_> {
|
||||
type Error = std::convert::Infallible;
|
||||
fn block(&mut self, [x, y, z]: [i32; 3]) -> Result<BlockStateId, Self::Error> {
|
||||
Ok(self.0.block(x, y, z))
|
||||
}
|
||||
fn set_block(
|
||||
&mut self,
|
||||
[x, y, z]: [i32; 3],
|
||||
state: BlockStateId,
|
||||
_: i32,
|
||||
) -> Result<bool, Self::Error> {
|
||||
self.0.set(x, y, z, state.0);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
impl FlatTerrain {
|
||||
/// The overworld generator of `world_gen_settings` (`data.dimensions`), if it
|
||||
/// is flat. Its `features` and `lakes` decorate the world; a world without
|
||||
/// them (the default) gets only its layers, as FlatLevelSource places them.
|
||||
/// is flat. Its layers, as FlatLevelSource places them, and of the features
|
||||
/// its settings decorate the world with, `void_start_platform`; the other
|
||||
/// features and the lakes are not placed yet.
|
||||
pub fn from_settings(
|
||||
settings: &Tag,
|
||||
registry: &BlockRegistry,
|
||||
@@ -43,6 +95,29 @@ impl FlatTerrain {
|
||||
}
|
||||
}
|
||||
let biome = biomes.id(&biome);
|
||||
// FlatLevelGeneratorSettings.updateLayers: every layer is Blocks.AIR.
|
||||
let void_gen = layers.iter().all(|&id| {
|
||||
registry
|
||||
.state(BlockStateId(id))
|
||||
.is_some_and(|s| s.name == "minecraft:air")
|
||||
});
|
||||
let flag = |key| crate::save::number(s.get(key)).is_some_and(|v| v != 0);
|
||||
let info = biomes.get(biome);
|
||||
let void_platform = if decoration_features(
|
||||
&info.features,
|
||||
flag("features"),
|
||||
flag("lakes"),
|
||||
void_gen,
|
||||
info.name == "minecraft:the_void",
|
||||
)
|
||||
.any(|f| f == "minecraft:void_start_platform")
|
||||
{
|
||||
FeatureBlocks::from_registry(registry)
|
||||
.inspect_err(|e| log::warn!("Horizon: no void_start_platform: {e}"))
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut water = None;
|
||||
let mut surface = None;
|
||||
for (i, &id) in layers.iter().take(shape.height as usize).enumerate().rev() {
|
||||
@@ -72,6 +147,7 @@ impl FlatTerrain {
|
||||
layers,
|
||||
biome,
|
||||
surface,
|
||||
void_platform,
|
||||
})
|
||||
}
|
||||
/// FlatLevelSource.getSpawnHeight: the top of the layers.
|
||||
@@ -211,6 +287,21 @@ impl ChunkGenerator for FlatTerrain {
|
||||
}
|
||||
}
|
||||
}
|
||||
// ChunkGenerator.applyBiomeDecoration at the chunk's section origin.
|
||||
// VoidStartPlatformFeature writes only inside its own chunk and draws
|
||||
// no random numbers, so placing it here gives the FEATURES result.
|
||||
if let Some(blocks) = self.void_platform {
|
||||
let origin = [cx * 16, self.shape.min_y, cz * 16];
|
||||
let mut random = horizon_core::random::LegacyRandom::new(0);
|
||||
if let Err(e) = BasicFeature::VoidStartPlatform.place(
|
||||
&mut ChunkWorld(&mut chunk),
|
||||
&mut random,
|
||||
origin,
|
||||
blocks,
|
||||
) {
|
||||
log::warn!("Horizon: placing void_start_platform failed: {e}");
|
||||
}
|
||||
}
|
||||
for s in &mut chunk.sections {
|
||||
std::sync::Arc::make_mut(s).compact();
|
||||
}
|
||||
@@ -237,6 +328,106 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// A world_gen_settings with a flat overworld of `layers` (block, height).
|
||||
fn flat_world(biome: &str, features: bool, layers: &[(&str, i32)]) -> Tag {
|
||||
let layers = layers
|
||||
.iter()
|
||||
.map(|&(block, height)| {
|
||||
Tag::compound([
|
||||
("block", Tag::String(block.into())),
|
||||
("height", Tag::Int(height)),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
let settings = Tag::compound([
|
||||
("biome", Tag::String(biome.into())),
|
||||
("features", Tag::Byte(i8::from(features))),
|
||||
("lakes", Tag::Byte(0)),
|
||||
("layers", Tag::List(layers)),
|
||||
]);
|
||||
let generator = Tag::compound([
|
||||
("type", Tag::String("minecraft:flat".into())),
|
||||
("settings", settings),
|
||||
]);
|
||||
Tag::compound([(
|
||||
"dimensions",
|
||||
Tag::compound([(
|
||||
"minecraft:overworld",
|
||||
Tag::compound([("generator", generator)]),
|
||||
)]),
|
||||
)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_void_preset_places_the_start_platform() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let registry = assets.registry;
|
||||
let biomes = crate::biome::Biomes::load(&root).unwrap();
|
||||
let terrain = |biome, features, layers: &[(&str, i32)]| {
|
||||
FlatTerrain::from_settings(
|
||||
&flat_world(biome, features, layers),
|
||||
registry,
|
||||
&biomes,
|
||||
LevelShape::OVERWORLD,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let block = |terrain: &FlatTerrain, [x, y, z]: [i32; 3]| {
|
||||
let chunk = terrain.finish(terrain.proto(&mut (), x >> 4, z >> 4));
|
||||
registry.state(chunk.block(x, y, z)).unwrap().name.clone()
|
||||
};
|
||||
// The Void preset (flat_level_generator_preset/the_void): the_void's
|
||||
// only feature, VoidStartPlatformFeature, at the minimum Y + 3: a
|
||||
// 33×33 stone square around 8, 8 with cobblestone at its centre, in
|
||||
// the chunks within one of chunk 0, 0.
|
||||
let void = terrain("minecraft:the_void", true, &[("minecraft:air", 1)]);
|
||||
for (pos, expected) in [
|
||||
([8, -61, 8], "minecraft:cobblestone"),
|
||||
([0, -61, 0], "minecraft:stone"),
|
||||
([-8, -61, -8], "minecraft:stone"),
|
||||
([24, -61, 24], "minecraft:stone"),
|
||||
([24, -61, -8], "minecraft:stone"),
|
||||
([-9, -61, 8], "minecraft:air"),
|
||||
([25, -61, 8], "minecraft:air"),
|
||||
([8, -60, 8], "minecraft:air"),
|
||||
([8, -62, 8], "minecraft:air"),
|
||||
([40, -61, 8], "minecraft:air"),
|
||||
] {
|
||||
assert_eq!(block(&void, pos), expected, "{pos:?}");
|
||||
}
|
||||
// The decoration is off without `features`.
|
||||
let plain = terrain("minecraft:the_void", false, &[("minecraft:air", 1)]);
|
||||
assert_eq!(block(&plain, [8, -61, 8]), "minecraft:air");
|
||||
// the_void over solid layers still gets its platform.
|
||||
let floor = terrain("minecraft:the_void", true, &[("minecraft:bedrock", 1)]);
|
||||
assert_eq!(block(&floor, [8, -64, 8]), "minecraft:bedrock");
|
||||
assert_eq!(block(&floor, [8, -61, 8]), "minecraft:cobblestone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjusted_generation_settings_keep_the_biome_features_outside_structures() {
|
||||
let steps: Vec<Vec<String>> = (0..11).map(|step| vec![format!("f{step}")]).collect();
|
||||
let kept = |features, lakes, void_gen, the_void| {
|
||||
decoration_features(&steps, features, lakes, void_gen, the_void)
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let all_but_structures = ["f0", "f1", "f2", "f5", "f6", "f7", "f8", "f9", "f10"];
|
||||
assert_eq!(kept(true, false, false, false), all_but_structures);
|
||||
// The lava lakes take the LAKES step's place.
|
||||
assert_eq!(kept(true, true, false, false)[1..], all_but_structures[2..]);
|
||||
// An all-air world is decorated only in the_void.
|
||||
assert!(kept(true, false, true, false).is_empty());
|
||||
assert_eq!(kept(true, false, true, true), all_but_structures);
|
||||
assert!(kept(false, false, false, true).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generator_settings_read_as_flat_level_generator_settings() {
|
||||
// server.properties' default: the preset's own layers, and the codec's error.
|
||||
|
||||
@@ -159,15 +159,18 @@ mod music;
|
||||
mod names;
|
||||
mod native_hooks;
|
||||
mod native_lifecycle;
|
||||
mod native_menus;
|
||||
mod native_panel;
|
||||
mod native_panel_recipes;
|
||||
mod native_recipes;
|
||||
mod native_server;
|
||||
mod native_shaders;
|
||||
mod native_world_read;
|
||||
mod natural_spawning;
|
||||
mod nautili;
|
||||
mod net_harness;
|
||||
mod nether_portal;
|
||||
mod no_resources;
|
||||
mod ominous_item_spawner;
|
||||
mod options;
|
||||
mod packs;
|
||||
@@ -766,6 +769,15 @@ struct GameLevel<'w> {
|
||||
client_side: bool,
|
||||
/// Only the integrated or dedicated server dispatches native observations.
|
||||
native_authoritative: bool,
|
||||
native_particles: &'w mut Vec<(
|
||||
&'static str,
|
||||
horizon_client::particles::ParticleOptions,
|
||||
Option<BlockStateId>,
|
||||
[f64; 3],
|
||||
[f64; 3],
|
||||
f64,
|
||||
i32,
|
||||
)>,
|
||||
}
|
||||
struct PlayerCollision {
|
||||
y: f64,
|
||||
@@ -830,6 +842,11 @@ impl GameLevel<'_> {
|
||||
.map(|s| s.bounds()),
|
||||
)
|
||||
}
|
||||
/// `LilyPadBlock.entityInside`: the boxes of the boats that can break a pad.
|
||||
fn boat_trigger_boxes(&self) -> impl Iterator<Item = Aabb> + '_ {
|
||||
let b = self.blockers;
|
||||
b.boats.iter().map(boats::Boat::bounds)
|
||||
}
|
||||
/// The non-living entities that trigger plates and tripwires (boats,
|
||||
/// crystals, primed TNT, falling blocks, item entities, experience orbs and
|
||||
/// projectiles).
|
||||
@@ -2029,6 +2046,9 @@ struct Game<'a> {
|
||||
cart_models: minecarts::CartModels,
|
||||
/// The boat the player rides.
|
||||
riding: Option<u32>,
|
||||
/// The vehicle the player's file names, waiting for it to be in the world
|
||||
/// (`ServerPlayer.loadAndSpawnParentVehicle`).
|
||||
pending_vehicle: Option<[i32; 4]>,
|
||||
/// The ender inventory (27 slots) and the ender chest showing it.
|
||||
ender_items: Vec<Option<ItemStack>>,
|
||||
ender_open: Option<(i32, i32, i32)>,
|
||||
@@ -2111,6 +2131,8 @@ struct Game<'a> {
|
||||
lan: Option<lan::LanHost>,
|
||||
/// Single player's server thread, which `remote` is connected to.
|
||||
integrated: Option<integrated::Integrated>,
|
||||
/// The shapes of the open world's levels, from its dimension types.
|
||||
level_shapes: horizon_client::chunk::LevelShapes,
|
||||
/// This game is that server thread's (game/integrated.rs).
|
||||
serving: Option<integrated::Serving>,
|
||||
/// The open world is shared behind an invite code (game/friends.rs).
|
||||
@@ -2267,7 +2289,7 @@ impl Dim {
|
||||
_ => Self::Overworld,
|
||||
}
|
||||
}
|
||||
/// The level's shape, from vanilla's dimension types.
|
||||
/// The level's shape in a world of vanilla's dimension types.
|
||||
fn shape(self) -> horizon_client::save::LevelShape {
|
||||
use horizon_client::save::LevelShape;
|
||||
match self {
|
||||
@@ -2276,18 +2298,6 @@ impl Dim {
|
||||
Self::End => LevelShape::END,
|
||||
}
|
||||
}
|
||||
/// `Level.getMinY`: the dimension type's minimum y.
|
||||
fn min_y(self) -> i32 {
|
||||
self.shape().min_y
|
||||
}
|
||||
/// `DimensionType.logicalHeight`.
|
||||
fn logical_height(self) -> i32 {
|
||||
self.shape().logical_height
|
||||
}
|
||||
/// `Level.getMaxY`: the dimension type's minimum y plus height, less one.
|
||||
fn max_y(self) -> i32 {
|
||||
self.shape().max_y() - 1
|
||||
}
|
||||
}
|
||||
fn facing_offset(facing: &str) -> [i32; 3] {
|
||||
match facing {
|
||||
@@ -3300,9 +3310,13 @@ impl Game<'_> {
|
||||
lid_openers: &self.lid_openers,
|
||||
client_side: self.remote.is_some(),
|
||||
native_authoritative: self.serving.is_some() || self.options.dedicated.is_some(),
|
||||
native_particles: &mut self.creatures.lan_particles,
|
||||
};
|
||||
let result = f(self.res.behaviours(), &mut level, &mut self.block_ticks);
|
||||
self.flush_spill();
|
||||
if self.lan.is_some() {
|
||||
self.flush_lan_particles();
|
||||
}
|
||||
result
|
||||
}
|
||||
/// Level.setBlock(pos, state, UPDATE_ALL) with neighbour/shape updates.
|
||||
@@ -3561,6 +3575,21 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.with_level(|b, level, ticks| {
|
||||
// LilyPadBlock.entityInside: a boat inside a lily pad breaks it.
|
||||
let mut boat_cells: Vec<BlockPos> = Vec::new();
|
||||
for bounds in level.boat_trigger_boxes() {
|
||||
let (lo, hi) = (bounds.min_position(), bounds.max_position());
|
||||
for x in (lo.x + 1e-7).floor() as i32..=(hi.x - 1e-7).floor() as i32 {
|
||||
for y in (lo.y + 1e-7).floor() as i32..=(hi.y - 1e-7).floor() as i32 {
|
||||
for z in (lo.z + 1e-7).floor() as i32..=(hi.z - 1e-7).floor() as i32 {
|
||||
boat_cells.push(BlockPos::new(x, y, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for pos in boat_cells {
|
||||
b.boat_inside(level, ticks, pos);
|
||||
}
|
||||
// The mobs and the other entities (items, orbs, projectiles...) too.
|
||||
let mut touched: Vec<BlockPos> = Vec::new();
|
||||
for bounds in level.mob_trigger_boxes().chain(level.other_trigger_boxes()) {
|
||||
@@ -4514,6 +4543,10 @@ impl Game<'_> {
|
||||
if !self.debug_stick_can_destroy(pos)? {
|
||||
return Ok(());
|
||||
}
|
||||
// ServerPlayerGameMode.destroyBlock.
|
||||
if self.block_action_restricted(pos) {
|
||||
return Ok(());
|
||||
}
|
||||
if !self
|
||||
.res
|
||||
.items()
|
||||
@@ -4757,9 +4790,13 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let group = horizon_client::cooldowns::group(stack, default.as_ref());
|
||||
self.cooldowns.add(Arc::clone(&group), component.ticks());
|
||||
self.add_cooldown(group, component.ticks());
|
||||
}
|
||||
/// `ItemCooldowns.addCooldown` (a guest also gets the packet).
|
||||
pub(super) fn add_cooldown(&mut self, group: Arc<str>, ticks: i32) {
|
||||
self.cooldowns.add(Arc::clone(&group), ticks);
|
||||
if self.lan_acting.is_some() {
|
||||
self.guest_cooldowns.push((group, component.ticks()));
|
||||
self.guest_cooldowns.push((group, ticks));
|
||||
}
|
||||
}
|
||||
/// One hand's turn in `startUseItem`: `useItemOn` the block aimed at,
|
||||
@@ -4929,6 +4966,21 @@ impl Game<'_> {
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
// Native mods, where ItemStack.useOn and Item.use start: the block
|
||||
// aimed at had its turn, and the item is off cooldown. A guest's
|
||||
// UseItemOn reaches only useOn, its UseItem only use.
|
||||
let on_block = matches!(self.guest_use, Some(GuestUse::OnBlock(_)));
|
||||
let mut claimed = false;
|
||||
if let Some(hit) = block_hit {
|
||||
self.native_use_item_on(&stack, hit.pos, hit.face, &mut claimed);
|
||||
}
|
||||
if !claimed && !on_block {
|
||||
self.native_use_item(&stack, &mut claimed);
|
||||
}
|
||||
if claimed {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
// ItemStack.useOn: after the block's own useItemOn/useWithoutItem
|
||||
// passed. DebugStickItem.useOn cycles the selected property.
|
||||
if let Some(hit) = block_hit
|
||||
@@ -4952,7 +5004,6 @@ impl Game<'_> {
|
||||
}
|
||||
// A guest's UseItemOn stops at ItemStack.useOn: what Item.use does
|
||||
// (throw, drink, eat, raise, equip) waits for its UseItem.
|
||||
let on_block = matches!(self.guest_use, Some(GuestUse::OnBlock(_)));
|
||||
let spawn_egg = stack.item.ends_with("_spawn_egg");
|
||||
use horizon_client::first_person::UseResult;
|
||||
let swings = self.swings_started;
|
||||
@@ -4966,6 +5017,12 @@ impl Game<'_> {
|
||||
UseResult::Fail => return Ok(true),
|
||||
UseResult::Pass => {}
|
||||
}
|
||||
// PotionItem.useOn comes before PotionItem.use's drink.
|
||||
if let Some(hit) = block_hit
|
||||
&& self.use_water_bottle_on_block(hit.pos, hit.face, &stack)?
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
if (!on_block && self.use_potion_item(&stack)?)
|
||||
|| ((block_hit.is_some() || !spawn_egg)
|
||||
&& (!on_block || spawn_egg)
|
||||
@@ -5008,6 +5065,10 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if !on_block && &*stack.item == "minecraft:goat_horn" {
|
||||
self.use_instrument(&stack);
|
||||
return Ok(true);
|
||||
}
|
||||
if !on_block && &*stack.item == "minecraft:bow" {
|
||||
if self.using.is_none()
|
||||
&& (self.creative || self.projectile_slot("minecraft:bow").is_some())
|
||||
@@ -5039,13 +5100,29 @@ impl Game<'_> {
|
||||
if self.use_ender_eye(&stack, block_hit.is_some())? {
|
||||
return Ok(true);
|
||||
}
|
||||
let Some(hit) = block_hit else {
|
||||
return Ok(false);
|
||||
// PlaceOnWaterBlockItem.use: the lily pad and frogspawn aim at the block
|
||||
// or water source the SOURCE_ONLY ray meets and are placed on the
|
||||
// position above it.
|
||||
let water_hit;
|
||||
let hit = if matches!(&*stack.item, "minecraft:lily_pad" | "minecraft:frogspawn") {
|
||||
let Some(h) = self.pick_fluid() else {
|
||||
return Ok(false);
|
||||
};
|
||||
water_hit = Hit {
|
||||
pos: [h.pos[0], h.pos[1] + 1, h.pos[2]],
|
||||
..h
|
||||
};
|
||||
&water_hit
|
||||
} else {
|
||||
let Some(hit) = block_hit else {
|
||||
return Ok(false);
|
||||
};
|
||||
hit
|
||||
};
|
||||
if info.block.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
if !self.may_build() {
|
||||
if !self.may_build() && !self.can_place_on_in_adventure(hit.pos) {
|
||||
// ItemStack.useOn passes in adventure mode; useItem eats.
|
||||
return Ok(!on_block && self.start_eating(&info));
|
||||
}
|
||||
@@ -5156,6 +5233,34 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `InstrumentItem.use` for a goat horn: the player holds it up for the
|
||||
/// instrument's `use_duration`, its sound plays at the player
|
||||
/// (`SoundSource.RECORDS`, volume `range / 16`), `INSTRUMENT_PLAY` is
|
||||
/// posted, the used statistic counts and the horn is on cooldown as long.
|
||||
/// A horn whose instrument is unknown fails, which still ends the click.
|
||||
fn use_instrument(&mut self, stack: &ItemStack) {
|
||||
let Some(instrument) = self.res.items().instrument(stack) else {
|
||||
return;
|
||||
};
|
||||
if self.using.is_none() {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
}
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
self.audio
|
||||
.play(&instrument.sound, at, instrument.range / 16.0, 1.0);
|
||||
self.post_entity_event(
|
||||
"minecraft:instrument_play",
|
||||
horizon_client::mobs::Who::Player,
|
||||
);
|
||||
if instrument.use_duration > 0.0 {
|
||||
let group = horizon_client::cooldowns::group(stack, None);
|
||||
self.add_cooldown(group, instrument.use_ticks());
|
||||
}
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
}
|
||||
fn apply_consume_effect(&mut self, effect: &horizon_client::items::ConsumeEffect) {
|
||||
use horizon_client::items::ConsumeEffect;
|
||||
match effect {
|
||||
@@ -5289,6 +5394,16 @@ impl Game<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(duration) = self
|
||||
.res
|
||||
.items()
|
||||
.instrument(&stack)
|
||||
.map(|i| i.use_ticks().max(0) as u32)
|
||||
{
|
||||
// InstrumentItem.getUseDuration: held up that long, nothing more.
|
||||
self.using = (ticks + 1 < duration).then_some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
if &*stack.item == "minecraft:brush" {
|
||||
self.tick_brush(slot, ticks);
|
||||
return;
|
||||
@@ -5400,8 +5515,9 @@ impl Game<'_> {
|
||||
self.mining = None;
|
||||
return Ok(());
|
||||
};
|
||||
// GameType.isBlockPlacingRestricted: adventure and spectator.
|
||||
if !self.may_build()
|
||||
// Player.blockActionRestricted: spectators, and adventure players
|
||||
// without a matching can_break item.
|
||||
if self.block_action_restricted(hit.pos)
|
||||
|| !self
|
||||
.res
|
||||
.items()
|
||||
@@ -5698,10 +5814,10 @@ impl Game<'_> {
|
||||
self.ticks += 1;
|
||||
self.track_experience_display();
|
||||
// `/tick freeze` stops the world but not the player.
|
||||
let running = self.clock.world_runs();
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
self.native_lifecycle_server_tick_start(running);
|
||||
}
|
||||
let running = match self.level_pass.running {
|
||||
Some(running) => running,
|
||||
None => self.clock.world_runs(),
|
||||
};
|
||||
// GameRenderer.tick: a wither's bar darkens the world, then fades;
|
||||
// the activated item counts down.
|
||||
self.boss_overlay.tick_darken(running);
|
||||
@@ -6127,6 +6243,8 @@ impl Game<'_> {
|
||||
horizon_client::spans::time("touch_arrows", || self.touch_arrows());
|
||||
}
|
||||
horizon_client::spans::time("spawn_loaded_entities", || self.spawn_loaded_entities());
|
||||
// The ride the player's file names, once the vehicle is in the world.
|
||||
self.restore_root_vehicle();
|
||||
if running {
|
||||
horizon_client::spans::time("tick_mobs", || self.tick_mobs())?;
|
||||
horizon_client::spans::time("recheck_golem_openers", || self.recheck_golem_openers());
|
||||
@@ -8782,6 +8900,15 @@ impl Game<'_> {
|
||||
horizon_client::shadows::shadow(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
&|state| {
|
||||
self.res
|
||||
.collision()
|
||||
.cached_shapes()
|
||||
.cached_shape(state)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|shape| shape.bounds())
|
||||
},
|
||||
sprite,
|
||||
camera,
|
||||
pos,
|
||||
@@ -8804,7 +8931,7 @@ impl Game<'_> {
|
||||
texts: None,
|
||||
partial: alpha as f32,
|
||||
game_time: self.world.game_time,
|
||||
max_y: self.dim.max_y(),
|
||||
max_y: self.world.shape.max_y() - 1,
|
||||
world: &self.world,
|
||||
sprites: &self.gui.atlas.sprites,
|
||||
nether: self.dim == Dim::Nether,
|
||||
@@ -9239,7 +9366,7 @@ impl Game<'_> {
|
||||
// the offset is the one from the player to the particle.
|
||||
let origin = from.unwrap_or(at);
|
||||
let spread = [at[0] - origin[0], 0.0, at[2] - origin[2]];
|
||||
self.lan_send_particles(&arc, at, spread, 0.0, 0);
|
||||
self.lan_send_particles(self.dim.id(), &arc, None, at, spread, 0.0, 0);
|
||||
}
|
||||
/// `Player.attack`'s sounds when a vehicle takes the hit (it always does,
|
||||
/// and can't be hit critically): knockback when sprinting at full
|
||||
@@ -9469,11 +9596,13 @@ impl Game<'_> {
|
||||
);
|
||||
// The players a server serves are sent them too.
|
||||
self.lan_send_particles(
|
||||
self.dim.id(),
|
||||
&indicator,
|
||||
None,
|
||||
[feet[0], feet[1] + height * 0.5, feet[2]],
|
||||
[0.1, 0.0, 0.1],
|
||||
0.2,
|
||||
(actual * 0.5) as u32,
|
||||
(actual * 0.5) as u32 as i32,
|
||||
);
|
||||
}
|
||||
self.last_hurt_by_player
|
||||
@@ -9742,6 +9871,7 @@ impl Game<'_> {
|
||||
self.reset_tutorial();
|
||||
let world_loading::PreparedWorld {
|
||||
save_root,
|
||||
shapes,
|
||||
level,
|
||||
world,
|
||||
streaming,
|
||||
@@ -9751,6 +9881,7 @@ impl Game<'_> {
|
||||
locator,
|
||||
} = prepared;
|
||||
let dim = Dim::from_id(&level.dimension);
|
||||
self.level_shapes = shapes;
|
||||
self.world_root = save_root.clone();
|
||||
horizon_client::crash::set_detail(
|
||||
"World",
|
||||
@@ -9833,6 +9964,8 @@ impl Game<'_> {
|
||||
self.world = world;
|
||||
self.inventory = inventory;
|
||||
self.save = save;
|
||||
// The ride the player's file names, once the vehicle is in the world.
|
||||
self.pending_vehicle = level.root_vehicle;
|
||||
self.level = level;
|
||||
// ServerRecipeBook.loadUntrusted: recipes the manager does not have are dropped.
|
||||
let index = &world_stations!(self).recipe_index;
|
||||
@@ -9911,6 +10044,7 @@ impl Game<'_> {
|
||||
&self.settings,
|
||||
world_root,
|
||||
dim,
|
||||
self.level_shapes.get(dim.id()),
|
||||
seed,
|
||||
)?;
|
||||
self.end.set_locator(locator);
|
||||
@@ -9958,7 +10092,7 @@ impl Game<'_> {
|
||||
let (streaming, save) =
|
||||
self.make_streaming(&self.world_root.clone(), target, self.level.seed)?;
|
||||
let mut world = World::new(self.res.facts().air, self.level.seed);
|
||||
world.shape = target.shape();
|
||||
world.shape = self.level_shapes.get(target.id());
|
||||
world.fast_lava = target == Dim::Nether;
|
||||
world.game_time = self.world.game_time;
|
||||
world.day_time = self.world.day_time;
|
||||
@@ -11464,6 +11598,11 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
"minecraft:trident" if using => ItemPose::Trident { ticks_used },
|
||||
// ItemUseAnimation.BRUSH: applyBrushTransform.
|
||||
"minecraft:brush" if using => ItemPose::Brush {
|
||||
remaining: owner.use_item_remaining_ticks as f32,
|
||||
partial: a,
|
||||
},
|
||||
_ if using => {
|
||||
let components = self
|
||||
.pack
|
||||
@@ -12271,6 +12410,8 @@ impl Game<'_> {
|
||||
self.level.game_rules = self.ui.rules.by_name();
|
||||
self.level.selected_slot = self.selected as i32;
|
||||
let mode = self.mode_id();
|
||||
// The vehicle the player rides (`ServerPlayer.saveParentVehicle`).
|
||||
let vehicle = self.root_vehicle_uuid(self.riding);
|
||||
let level = &mut self.level;
|
||||
level.creative = self.creative;
|
||||
level.game_mode = mode;
|
||||
@@ -12287,6 +12428,7 @@ impl Game<'_> {
|
||||
level.xp_level = self.experience.level;
|
||||
level.xp_progress = self.experience.progress;
|
||||
level.xp_total = self.experience.total;
|
||||
level.root_vehicle = vehicle.or(self.pending_vehicle);
|
||||
if let Some(p) = &self.player {
|
||||
level.position = Some([p.position.x, p.position.y, p.position.z]);
|
||||
level.health = p.health.health();
|
||||
@@ -12646,11 +12788,17 @@ impl Game<'_> {
|
||||
let mut outline_after_terrain = false;
|
||||
// GameRenderer.shouldRenderBlockOutline: not with the HUD hidden (F1), and only for a
|
||||
// player who may build. Adventure players only get one with a CAN_BREAK or
|
||||
// CAN_PLACE_ON item and spectators with a menu block; Horizon reads neither the
|
||||
// components nor opens menus for spectators, so those never show it. An open
|
||||
// CAN_PLACE_ON item matching the block, and spectators with a menu block; Horizon
|
||||
// does not open menus for spectators, so those never show it. An open
|
||||
// screen does not hide it: vanilla checks the HUD and the game mode, not the screen.
|
||||
let outline = if !self.ui.hide_gui && self.may_build() {
|
||||
let adventure = self.mode == horizon_client::command::args::GameMode::Adventure;
|
||||
let outline = if !self.ui.hide_gui && (self.may_build() || adventure) {
|
||||
self.pick()
|
||||
.filter(|h| {
|
||||
self.may_build()
|
||||
|| !self.block_action_restricted(h.pos)
|
||||
|| self.can_place_on_in_adventure(h.pos)
|
||||
})
|
||||
.map(|h| {
|
||||
let state = self.world.block(h.pos[0], h.pos[1], h.pos[2]);
|
||||
let f = self.res.facts().get(state);
|
||||
@@ -14978,6 +15126,7 @@ impl<'a> Game<'a> {
|
||||
trim_slots: Vec::new(),
|
||||
cart_models: prepared.cart_models,
|
||||
riding: None,
|
||||
pending_vehicle: None,
|
||||
ender_items: vec![None; 27],
|
||||
ender_open: None,
|
||||
pending: Default::default(),
|
||||
@@ -15025,6 +15174,7 @@ impl<'a> Game<'a> {
|
||||
remote: None,
|
||||
lan: None,
|
||||
integrated: None,
|
||||
level_shapes: Default::default(),
|
||||
serving: None,
|
||||
world_options: None,
|
||||
options_to_world_options: false,
|
||||
@@ -16510,9 +16660,11 @@ fn missing_resources(root: &std::path::Path) -> Option<String> {
|
||||
let shown = std::path::absolute(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
Some(format!(
|
||||
"Horizon's prepared game files are missing: no horizon-resources.json in {}\n\
|
||||
Start Horizon from the Horizon launcher, which downloads and prepares them, \
|
||||
or prepare them with: horizon-prepare --prepare (or pass --resources <folder>)",
|
||||
shown.display()
|
||||
Horizon is started from its launcher: download it at {} (Download). It needs your \
|
||||
own Minecraft: Java Edition; the launcher signs you in and prepares the game files.\n\
|
||||
Developers: horizon-prepare --prepare, or pass --resources <folder>",
|
||||
shown.display(),
|
||||
horizon_client::no_resources::DOWNLOAD_URL
|
||||
))
|
||||
}
|
||||
|
||||
@@ -16595,6 +16747,23 @@ pub fn run() -> Result<()> {
|
||||
// 10 to 200 ms to get one back.
|
||||
horizon_threads::favour_current();
|
||||
}
|
||||
if options.execution_role.is_client() {
|
||||
horizon_threads::catch_stop_signals();
|
||||
}
|
||||
let started = Instant::now();
|
||||
horizon_client::spans::enable(options.spikes.is_some());
|
||||
let vanilla_root = options.resources.clone();
|
||||
if let Some(error) = missing_resources(&vanilla_root) {
|
||||
// The game build started by hand (the release's zip), not by the
|
||||
// launcher: a window says where to get it. Scripts and servers get
|
||||
// the error.
|
||||
if !options.offscreen && !synchronous_startup(&options) {
|
||||
log::error!("Horizon: {error}");
|
||||
let settings = Settings::load(&std::env::current_dir()?.join("options.txt"));
|
||||
return no_resources::show(&vanilla_root, &settings);
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
// Someone is playing: the anonymous ping behind the launcher's "N playing now", and the
|
||||
// official server in their list (servers.dat in the game folder, the working directory). Not
|
||||
// for a server, a benchmark, a run that quits by itself, a harness's fixture or a run that
|
||||
@@ -16606,15 +16775,6 @@ pub fn run() -> Result<()> {
|
||||
log::warn!("could not add the official server to servers.dat: {e}");
|
||||
}
|
||||
}
|
||||
if options.execution_role.is_client() {
|
||||
horizon_threads::catch_stop_signals();
|
||||
}
|
||||
let started = Instant::now();
|
||||
horizon_client::spans::enable(options.spikes.is_some());
|
||||
let vanilla_root = options.resources.clone();
|
||||
if let Some(error) = missing_resources(&vanilla_root) {
|
||||
return Err(error.into());
|
||||
}
|
||||
let early_settings = Settings::load(&std::env::current_dir()?.join("options.txt"));
|
||||
// "Playing Horizon" on Discord: never for a server; an automated run only talks to a test's fake Discord.
|
||||
if options.dedicated.is_none() {
|
||||
|
||||
@@ -47,14 +47,34 @@ impl Game<'_> {
|
||||
.items()
|
||||
.get(&held)
|
||||
.map_or(64, |i| i.max_stack as i32);
|
||||
// GoToWantedItem / wantsToPickUp: the same item within 32.
|
||||
// GoToWantedItem / wantsToPickUp: the same item within 32 that the
|
||||
// allay sees (NearestItemSensor's hasLineOfSight, eye to eye).
|
||||
let wanted = if griefing && carried < max && self.mobs[i].extra.ints[3] <= 0 {
|
||||
let w = self.mob_world_base(&[]);
|
||||
let eye = horizon_core::geometry::Vec3::new(pos.x, self.mobs[i].eye_y(), pos.z);
|
||||
// The sensor looks in the allay's box grown by (32, 16, 32).
|
||||
let b = self.mobs[i].bounds();
|
||||
let (low, high) = (b.min_position().y - 16.0, b.max_position().y + 16.0);
|
||||
self.dropped
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, e)| e.stack.item == held && e.pickup_delay == 0)
|
||||
.map(|(j, e)| (j, e.position.distance_squared(pos)))
|
||||
.filter(|(_, d)| *d <= 32.0 * 32.0)
|
||||
.filter(|(_, d)| *d < 32.0 * 32.0)
|
||||
.filter(|(j, _)| {
|
||||
let y = self.dropped[*j].position.y;
|
||||
y <= high && y + 0.25 >= low
|
||||
})
|
||||
.filter(|(j, _)| {
|
||||
// ItemEntity's eye height is 0.2125.
|
||||
let e = &self.dropped[*j];
|
||||
let at = horizon_core::geometry::Vec3::new(
|
||||
e.position.x,
|
||||
e.position.y + 0.2125,
|
||||
e.position.z,
|
||||
);
|
||||
w.line_of_sight(eye, at)
|
||||
})
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -39,8 +39,8 @@ pub(super) struct ArmorStandAssets {
|
||||
pub layers: HashMap<(&'static str, bool), Part>,
|
||||
/// The player's armour models by slot (helmet, chestplate, leggings, boots).
|
||||
pub player: HashMap<&'static str, Part>,
|
||||
/// asset id -> layer kind (humanoid / humanoid_leggings) -> (texture, undyed colour).
|
||||
pub equipment: HashMap<String, HashMap<String, (String, Option<u32>)>>,
|
||||
/// asset id -> layer kind (humanoid / humanoid_leggings) -> each layer's (texture, undyed colour), in order.
|
||||
pub equipment: HashMap<String, HashMap<String, Vec<(String, Option<u32>)>>>,
|
||||
/// asset id -> `trim_overrides`.
|
||||
pub trim_overrides: HashMap<String, Vec<TrimOverride>>,
|
||||
pub trims: TrimRegistry,
|
||||
@@ -89,18 +89,24 @@ impl ArmorStandAssets {
|
||||
let Ok(v) = v else { continue };
|
||||
let mut kinds = HashMap::new();
|
||||
for kind in ["humanoid", "humanoid_leggings", "wings"] {
|
||||
if let Some(layer) = v["layers"][kind].get(0) {
|
||||
let tex = layer["texture"]
|
||||
.as_str()
|
||||
.unwrap_or(name)
|
||||
.trim_start_matches("minecraft:");
|
||||
let undyed = layer["dyeable"]["color_when_undyed"]
|
||||
.as_i64()
|
||||
.map(|c| c as u32 & 0xFF_FFFF);
|
||||
kinds.insert(
|
||||
kind.to_owned(),
|
||||
(format!("entity/equipment/{kind}/{tex}"), undyed),
|
||||
);
|
||||
// Every layer of the kind: leather's overlay is layer 1.
|
||||
let layers: Vec<(String, Option<u32>)> = v["layers"][kind]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|layer| {
|
||||
let tex = layer["texture"]
|
||||
.as_str()
|
||||
.unwrap_or(name)
|
||||
.trim_start_matches("minecraft:");
|
||||
let undyed = layer["dyeable"]["color_when_undyed"]
|
||||
.as_i64()
|
||||
.map(|c| c as u32 & 0xFF_FFFF);
|
||||
(format!("entity/equipment/{kind}/{tex}"), undyed)
|
||||
})
|
||||
.collect();
|
||||
if !layers.is_empty() {
|
||||
kinds.insert(kind.to_owned(), layers);
|
||||
}
|
||||
}
|
||||
trim_overrides.insert(format!("minecraft:{name}"), TrimOverride::list(&v));
|
||||
@@ -195,19 +201,14 @@ impl Game<'_> {
|
||||
} else {
|
||||
"humanoid"
|
||||
};
|
||||
let Some((texture, undyed)) = assets.equipment.get(&asset).and_then(|k| k.get(kind))
|
||||
else {
|
||||
let Some(layers) = assets.equipment.get(&asset).and_then(|k| k.get(kind)) else {
|
||||
continue;
|
||||
};
|
||||
let (Some(r), Some(part)) = (
|
||||
self.gui.atlas.sprites.get(texture.as_str()),
|
||||
assets.player.get(layer),
|
||||
) else {
|
||||
let Some(part) = assets.player.get(layer) else {
|
||||
continue;
|
||||
};
|
||||
let color = layer_color(*undyed, stack);
|
||||
// EquipmentLayerRenderer.renderLayers: NO_OVERLAY, the armour
|
||||
// glint on the layer and its trim when `stack.hasFoil()`.
|
||||
// glint on the first layer and on the trim when `stack.hasFoil()`.
|
||||
let item_glint = self
|
||||
.res
|
||||
.items()
|
||||
@@ -220,8 +221,20 @@ impl Game<'_> {
|
||||
} else {
|
||||
0
|
||||
};
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], color, overlay));
|
||||
// EquipmentLayerRenderer: the trim layer over the armour.
|
||||
// Every layer of the asset, the leather overlay too, then the
|
||||
// trim layer over the armour.
|
||||
for (i, (texture, undyed)) in layers.iter().enumerate() {
|
||||
let Some(r) = self.gui.atlas.sprites.get(texture.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let color = layer_color(*undyed, stack);
|
||||
out.push((
|
||||
part,
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
color,
|
||||
if i == 0 { overlay } else { 0 },
|
||||
));
|
||||
}
|
||||
if let Some(r) = stack
|
||||
.and_then(|s| assets.trim_texture(&asset, kind, s))
|
||||
.and_then(|t| self.gui.atlas.sprites.get(&t.sprite()))
|
||||
@@ -337,7 +350,11 @@ impl Game<'_> {
|
||||
/// `ArmorStandItem.useOn` up to its `FAIL`s, which a client checks too:
|
||||
/// not on a bottom face, and nothing in the new stand's box. The block it
|
||||
/// stands in and the stand.
|
||||
pub(super) fn armor_stand_spawn(&self, hit: &super::Hit) -> Option<([i32; 3], ArmorStand)> {
|
||||
pub(super) fn armor_stand_spawn(
|
||||
&self,
|
||||
hit: &super::Hit,
|
||||
stack: &ItemStack,
|
||||
) -> Option<([i32; 3], ArmorStand)> {
|
||||
if hit.face == [0, -1, 0] {
|
||||
return None;
|
||||
}
|
||||
@@ -358,7 +375,11 @@ impl Game<'_> {
|
||||
);
|
||||
let yaw =
|
||||
(((self.yaw - 180.0 + 180.0).rem_euclid(360.0) - 180.0 + 22.5) / 45.0).floor() * 45.0;
|
||||
let stand = ArmorStand::new(self.next_entity + 1, foot, yaw);
|
||||
let mut stand = ArmorStand::new(self.next_entity + 1, foot, yaw);
|
||||
// Entity.applyImplicitComponents: a name from an anvil names the stand.
|
||||
if let Some(name) = stack.custom_name() {
|
||||
stand.custom_name = Some(horizon_client::text::Text::literal(name));
|
||||
}
|
||||
let b = stand.bounds();
|
||||
// Touching the floor is not a collision.
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
@@ -386,7 +407,7 @@ impl Game<'_> {
|
||||
if &*stack.item != "minecraft:armor_stand" {
|
||||
return false;
|
||||
}
|
||||
let Some((pos, stand)) = self.armor_stand_spawn(hit) else {
|
||||
let Some((pos, stand)) = self.armor_stand_spawn(hit, stack) else {
|
||||
return false;
|
||||
};
|
||||
let foot = stand.position;
|
||||
@@ -559,6 +580,37 @@ impl Game<'_> {
|
||||
}
|
||||
hurt
|
||||
}
|
||||
/// `ArmorStand.showBreakingParticles`: ten oak plank crumbs from the box,
|
||||
/// spread by a quarter of its width and height, sent like `sendParticles`.
|
||||
fn show_breaking_particles(&mut self, at: [f64; 3], width: f64, height: f64) {
|
||||
let Some(state) = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:oak_planks")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let block = [
|
||||
at[0].floor() as i32,
|
||||
at[1].floor() as i32,
|
||||
at[2].floor() as i32,
|
||||
];
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, block) else {
|
||||
return;
|
||||
};
|
||||
let options = horizon_client::particles::ParticleOptions::block(
|
||||
horizon_client::particles::ParticleType::Block,
|
||||
sprite,
|
||||
tint,
|
||||
);
|
||||
// Entity.getY(2/3): the box's height is scaled by 2/3 from the feet.
|
||||
let centre = [at[0], at[1] + height * (2.0 / 3.0), at[2]];
|
||||
let spread = [width / 4.0, height / 4.0, width / 4.0];
|
||||
self.particles
|
||||
.send_particles(&options, centre, spread, 0.05, 10);
|
||||
self.lan_level_particles(&options, centre, spread, 0.05, 10, Some(state));
|
||||
}
|
||||
/// `ArmorStand.hurtServer` for a player's attack (also a sweep's): a
|
||||
/// creative player breaks it, otherwise the first hit wobbles it and a
|
||||
/// second within five ticks breaks it.
|
||||
@@ -567,9 +619,11 @@ impl Game<'_> {
|
||||
let at = self.armor_stands[i].position;
|
||||
let at = [at.x, at.y, at.z];
|
||||
if self.creative {
|
||||
let (width, height) = stand_box(&self.armor_stands[i]);
|
||||
self.armor_stands.remove(i);
|
||||
self.audio
|
||||
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
|
||||
self.show_breaking_particles(at, f64::from(width), f64::from(height));
|
||||
return true;
|
||||
}
|
||||
if time - self.armor_stands[i].last_hit > 5 {
|
||||
@@ -581,8 +635,10 @@ impl Game<'_> {
|
||||
}
|
||||
// brokenByPlayer: the stand item, then brokenByAnything: the equipment.
|
||||
let a = self.armor_stands.remove(i);
|
||||
let (width, height) = stand_box(&a);
|
||||
self.audio
|
||||
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
|
||||
self.show_breaking_particles(at, f64::from(width), f64::from(height));
|
||||
let center = [at[0], at[1] + if a.small { 0.4875 } else { 0.9875 }, at[2]];
|
||||
let stand = ItemStack {
|
||||
item: Arc::from("minecraft:armor_stand"),
|
||||
@@ -776,16 +832,12 @@ impl Game<'_> {
|
||||
} else {
|
||||
"humanoid"
|
||||
};
|
||||
let Some((texture, undyed)) =
|
||||
assets.equipment.get(&asset).and_then(|k| k.get(kind))
|
||||
else {
|
||||
let Some(layers) = assets.equipment.get(&asset).and_then(|k| k.get(kind)) else {
|
||||
continue;
|
||||
};
|
||||
let (Some(uv), Some(part)) = (uv_of(texture), assets.layers.get(&(layer, a.small)))
|
||||
else {
|
||||
let Some(part) = assets.layers.get(&(layer, a.small)) else {
|
||||
continue;
|
||||
};
|
||||
let color = layer_color(*undyed, Some(stack));
|
||||
// ArmorStandRenderer's HumanoidArmorLayer: the armour glint
|
||||
// when `stack.hasFoil()`, as on a living entity.
|
||||
let item_glint = self
|
||||
@@ -803,16 +855,24 @@ impl Game<'_> {
|
||||
// ArmorStandArmorModel.setupAnim: the same pose as the stand.
|
||||
let mut layer_poses = Poses::new(part);
|
||||
apply_pose(&mut layer_poses, &a.pose);
|
||||
horizon_client::mobs::render::draw(
|
||||
part,
|
||||
base,
|
||||
&layer_poses,
|
||||
uv,
|
||||
color,
|
||||
packed,
|
||||
overlay,
|
||||
out,
|
||||
);
|
||||
// EquipmentLayerRenderer: every layer of the asset, the leather overlay
|
||||
// too; the shader glint goes on the first layer only (renderShaderGlint).
|
||||
for (i, (texture, undyed)) in layers.iter().enumerate() {
|
||||
let Some(uv) = uv_of(texture) else {
|
||||
continue;
|
||||
};
|
||||
let color = layer_color(*undyed, Some(stack));
|
||||
horizon_client::mobs::render::draw(
|
||||
part,
|
||||
base,
|
||||
&layer_poses,
|
||||
uv,
|
||||
color,
|
||||
packed,
|
||||
if i == 0 { overlay } else { 0 },
|
||||
out,
|
||||
);
|
||||
}
|
||||
// EquipmentLayerRenderer: the trim layer over the armour.
|
||||
if let Some(trim) = assets
|
||||
.trim_texture(&asset, kind, stack)
|
||||
@@ -1047,6 +1107,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ArmorStand.getBbWidth` and `getBbHeight`: a marker has no box, a small stand is half size.
|
||||
fn stand_box(a: &ArmorStand) -> (f32, f32) {
|
||||
match (a.marker, a.small) {
|
||||
(true, _) => (0.0, 0.0),
|
||||
(false, true) => (0.25, 0.9875),
|
||||
(false, false) => (0.5, 1.975),
|
||||
}
|
||||
}
|
||||
/// `AbstractSkullBlock.getType` of a skull item.
|
||||
pub(super) fn skull_kind(item: &str) -> Option<&'static str> {
|
||||
Some(match item {
|
||||
|
||||
@@ -43,7 +43,19 @@ impl Game<'_> {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let facts = self.res.facts().get(state);
|
||||
if &*facts.item == "minecraft:air" {
|
||||
// PistonHeadBlock.getCloneItemStack: the piston the head belongs to.
|
||||
let head = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.filter(|s| s.name.as_str() == "minecraft:piston_head")
|
||||
.map(|s| match s.properties.get("type") {
|
||||
Some(t) if t == "sticky" => "minecraft:sticky_piston",
|
||||
_ => "minecraft:piston",
|
||||
});
|
||||
let item = head.unwrap_or(&*facts.item);
|
||||
if item == "minecraft:air" {
|
||||
return None;
|
||||
}
|
||||
// FlowerPotBlock.getCloneItemStack: a potted plant picks as that plant
|
||||
@@ -56,7 +68,7 @@ impl Game<'_> {
|
||||
.flatten()
|
||||
.map(|block| behaviours.block_data(block).item.clone())
|
||||
.filter(|item| &**item != "minecraft:air");
|
||||
let mut stack = ItemStack::new(plant_item.as_ref().unwrap_or(&facts.item), 1);
|
||||
let mut stack = ItemStack::new(plant_item.as_deref().unwrap_or(item), 1);
|
||||
let mut patch = Compound::new();
|
||||
// BeehiveBlock.getCloneItemStack: the honey level as the item's block state.
|
||||
if include_data
|
||||
|
||||
@@ -12,6 +12,7 @@ use horizon_client::{
|
||||
screen_draw::Measure,
|
||||
text::{ClickEvent, Style, Text},
|
||||
};
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::{Path, PathBuf},
|
||||
@@ -162,6 +163,61 @@ fn resource_ids(root: &Path) -> Arc<ResourceIds> {
|
||||
ids
|
||||
}
|
||||
|
||||
/// Every block's properties and their values, as `/setblock` completes them.
|
||||
type BlockProperties = BTreeMap<String, Vec<(String, Vec<String>)>>;
|
||||
static BLOCK_PROPERTIES: Mutex<Vec<(usize, Arc<BlockProperties>)>> = Mutex::new(Vec::new());
|
||||
|
||||
/// `BlockProperties` of a block registry, listed once: a registry never
|
||||
/// changes once built, and walking its tens of thousands of states took
|
||||
/// about 30 ms of every world opening.
|
||||
fn block_properties(registry: &'static BlockRegistry) -> Arc<BlockProperties> {
|
||||
let key = std::ptr::from_ref(registry) as usize;
|
||||
let mut cache = BLOCK_PROPERTIES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((_, properties)) = cache.iter().find(|(cached, _)| *cached == key) {
|
||||
return properties.clone();
|
||||
}
|
||||
let mut blocks: BTreeMap<String, BTreeMap<String, Vec<String>>> = BTreeMap::new();
|
||||
for id in 0..registry.len() {
|
||||
let Some(state) = registry.state(BlockStateId(id as u32)) else {
|
||||
continue;
|
||||
};
|
||||
let props = blocks.entry(state.name.clone()).or_default();
|
||||
for (k, v) in &state.properties {
|
||||
let values = props.entry(k.clone()).or_default();
|
||||
if !values.contains(v) {
|
||||
values.push(v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let properties: Arc<BlockProperties> = Arc::new(
|
||||
blocks
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, v.into_iter().collect()))
|
||||
.collect(),
|
||||
);
|
||||
cache.push((key, properties.clone()));
|
||||
properties
|
||||
}
|
||||
|
||||
static COMMAND_EXPORTS: Mutex<Vec<(PathBuf, Option<Arc<serde_json::Value>>)>> =
|
||||
Mutex::new(Vec::new());
|
||||
|
||||
/// A folder's `command-registries.json` (vanilla's built-in registries),
|
||||
/// read once like the resource ids: parsing its 1.6 MB again took about
|
||||
/// 15 ms of every world opening.
|
||||
fn command_export(dir: &Path) -> Option<Arc<serde_json::Value>> {
|
||||
let mut cache = COMMAND_EXPORTS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((_, export)) = cache.iter().find(|(cached, _)| cached == dir) {
|
||||
return export.clone();
|
||||
}
|
||||
let export = std::fs::read(dir.join("command-registries.json"))
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
|
||||
.map(Arc::new);
|
||||
cache.push((dir.to_owned(), export.clone()));
|
||||
export
|
||||
}
|
||||
|
||||
/// The `minecraft:` ids of the `extension` files under `root`, sorted.
|
||||
fn ids_under(root: &Path, extension: &str) -> Vec<String> {
|
||||
ids_in(root, extension, "minecraft")
|
||||
@@ -306,25 +362,7 @@ impl Game<'_> {
|
||||
.map(|k| horizon_client::command::args::normalize(k))
|
||||
.collect();
|
||||
reg.block_tags.sort();
|
||||
let registry = self.pack.assets.registry;
|
||||
let mut blocks: BTreeMap<String, BTreeMap<String, Vec<String>>> = BTreeMap::new();
|
||||
for id in 0..registry.len() {
|
||||
let Some(state) = registry.state(horizon_world::registry::BlockStateId(id as u32))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let props = blocks.entry(state.name.clone()).or_default();
|
||||
for (k, v) in &state.properties {
|
||||
let values = props.entry(k.clone()).or_default();
|
||||
if !values.contains(v) {
|
||||
values.push(v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
reg.blocks = blocks
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, v.into_iter().collect()))
|
||||
.collect();
|
||||
reg.blocks = BlockProperties::clone(&block_properties(self.pack.assets.registry));
|
||||
let mut summonable: Vec<String> = self
|
||||
.res
|
||||
.mob_types()
|
||||
@@ -438,10 +476,8 @@ impl Game<'_> {
|
||||
}
|
||||
// Vanilla's built-in registries (mob effects, components, dialogs...).
|
||||
for dir in [&self.options.resources, &self.ui.vanilla_root] {
|
||||
if let Ok(bytes) = std::fs::read(dir.join("command-registries.json"))
|
||||
&& let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes)
|
||||
{
|
||||
self.ui.registries.fill_from_export(&v);
|
||||
if let Some(export) = command_export(dir) {
|
||||
self.ui.registries.fill_from_export(&export);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -979,6 +1015,41 @@ impl Game<'_> {
|
||||
.chat
|
||||
.draw(batch, &self.gui.atlas, self.ticks, focused);
|
||||
}
|
||||
/// `HoverEvent.EntityTooltipInfo.getTooltipLines`: the name when the
|
||||
/// event has one, the entity type and the UUID.
|
||||
fn entity_tooltip_lines(entity: &serde_json::Value) -> Vec<(Text, u32)> {
|
||||
let mut lines = Vec::new();
|
||||
if let Some(name) = entity.get("name") {
|
||||
let name = horizon_client::component::to_text(name, &mut |s| Text::literal(s));
|
||||
lines.push((name, u32::MAX));
|
||||
}
|
||||
let id = entity
|
||||
.get("id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let kind = Text::translate(&format!("entity.{}", id.replace(':', ".")), &[]);
|
||||
lines.push((
|
||||
Text::translate("gui.entity_tooltip.type", &[kind]),
|
||||
u32::MAX,
|
||||
));
|
||||
if let Some(uuid) = entity.get("uuid").and_then(Self::entity_uuid) {
|
||||
lines.push((Text::literal(uuid), u32::MAX));
|
||||
}
|
||||
lines
|
||||
}
|
||||
/// The UUID of a ShowEntity event: its string, or the int array
|
||||
/// `UUIDUtil.CODEC` writes (most significant word first).
|
||||
fn entity_uuid(v: &serde_json::Value) -> Option<String> {
|
||||
if let Some(s) = v.as_str() {
|
||||
return Some(s.to_owned());
|
||||
}
|
||||
let words = v.as_array().filter(|w| w.len() == 4)?;
|
||||
let mut bits = 0u128;
|
||||
for w in words {
|
||||
bits = (bits << 32) | u128::from(w.as_i64()? as u32);
|
||||
}
|
||||
Some(horizon_protocol::crypto::uuid_string(bits))
|
||||
}
|
||||
pub(super) fn draw_chat_screen(&mut self, batch: &mut HudBatch) {
|
||||
let Some(mut screen) = self.ui.chat_screen.take() else {
|
||||
return;
|
||||
@@ -1029,6 +1100,13 @@ impl Game<'_> {
|
||||
let (cx, cy) = (f64::from(self.cursor.0), f64::from(self.cursor.1));
|
||||
let wrap = (batch.width as i32 / 2).max(200);
|
||||
if let Some(style) = self.ui.chat.style_at(&self.gui.atlas.font, height, cx, cy) {
|
||||
// DrawingFocusedGraphicsAccess.handleMessage: the pointing hand over
|
||||
// insertion text, which ChatScreen only asks for while Shift is held.
|
||||
if style.insertion.is_some() && self.has_shift_down() {
|
||||
horizon_client::widgets::request_cursor(
|
||||
horizon_client::widgets::Cursor::PointingHand,
|
||||
);
|
||||
}
|
||||
if let Some(hover) = style.hover {
|
||||
let lines = self.wrapped_rows(&hover, wrap);
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
@@ -1039,6 +1117,14 @@ impl Game<'_> {
|
||||
.filter(|s| self.items().get(&s.item).is_some())
|
||||
{
|
||||
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(entity) = style
|
||||
.hover_entity
|
||||
.as_deref()
|
||||
.filter(|_| self.settings.file.bool("advancedItemTooltips"))
|
||||
{
|
||||
// ChatScreen: a ShowEntity tooltip only with advanced tooltips on.
|
||||
let lines = Self::entity_tooltip_lines(entity);
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
} else if self.ui.chat.indicator_at(height, cx, cy) {
|
||||
// GuiMessageTag.systemSinglePlayer's text, split at 210.
|
||||
@@ -1345,4 +1431,18 @@ mod tests {
|
||||
assert!(Arc::ptr_eq(&first, &resource_ids(&root)));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// The export is read once per folder; a folder without one stays without.
|
||||
#[test]
|
||||
fn command_exports_are_read_once_per_folder() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-export-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("command-registries.json"), r#"{"effects":["a"]}"#).unwrap();
|
||||
let first = command_export(&dir).unwrap();
|
||||
assert_eq!(first["effects"][0], "a");
|
||||
std::fs::write(dir.join("command-registries.json"), "{}").unwrap();
|
||||
assert!(Arc::ptr_eq(&first, &command_export(&dir).unwrap()));
|
||||
assert!(command_export(&dir.join("missing")).is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
//! The player's `CombatTracker`: the damage taken lately, who dealt it and
|
||||
//! where the player fell from, turned into the vanilla death message.
|
||||
use super::{Game, chat::PLAYER_NAME};
|
||||
use super::{Game, chat::PLAYER_NAME, commands::Target};
|
||||
use horizon_client::{
|
||||
health::HealthEvent,
|
||||
mobs::{Mob, Species},
|
||||
motion::MotionEvent,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
text::Text,
|
||||
@@ -72,6 +73,10 @@ pub(super) struct CombatTracker {
|
||||
/// a head yet (`Creeper.droppedSkulls`): they are gone from the mob list,
|
||||
/// but the mobs they killed still ask for their loot.
|
||||
pub(super) charged_exploders: Vec<u32>,
|
||||
/// The mobs whose blast went off this tick and that left the mob list
|
||||
/// (`MobEvent::ExplosionSource`): their names, for the death messages of
|
||||
/// the mobs the blast killed.
|
||||
pub(super) exploded: Vec<(u32, Text)>,
|
||||
/// The player's fall distance before this tick's movement (as of the
|
||||
/// end of the last one).
|
||||
fall_before: f64,
|
||||
@@ -391,4 +396,134 @@ impl Game<'_> {
|
||||
// template that names a killer then shows as written).
|
||||
Text::translate(&key, &[me])
|
||||
}
|
||||
/// `TamableAnimal.die`: with `showDeathMessages`, a tame wolf, cat,
|
||||
/// parrot or nautilus's owner, when online in any dimension
|
||||
/// (`EntityReference.getEntity` looks through every level), is sent how it
|
||||
/// died. `killer` is the player who last hurt it (`None` inside for the
|
||||
/// host), from `last_hurt_by_player`.
|
||||
pub(super) fn tell_owner_pet_died(&mut self, m: &Mob, killer: Option<Option<u32>>) {
|
||||
let tamable = matches!(
|
||||
m.species(),
|
||||
Species::Wolf
|
||||
| Species::Cat
|
||||
| Species::Parrot
|
||||
| Species::Nautilus
|
||||
| Species::ZombieNautilus
|
||||
);
|
||||
let Some(owner) = m.extra.owner.filter(|_| tamable && m.extra.tame) else {
|
||||
return;
|
||||
};
|
||||
if !self.ui.rules.bool("show_death_messages") {
|
||||
return;
|
||||
}
|
||||
let host = self.player.is_some()
|
||||
&& self.options.dedicated.is_none()
|
||||
&& horizon_client::mobs::tame::owned_by(
|
||||
Some(owner),
|
||||
self.level.player_uuid.unwrap_or_default(),
|
||||
);
|
||||
let uuid = horizon_client::lan::playerdata::ints_uuid(owner);
|
||||
let guest = !host
|
||||
&& self
|
||||
.lan
|
||||
.as_ref()
|
||||
.is_some_and(|l| l.hub.guests.iter().any(|g| g.placed && g.uuid == uuid));
|
||||
if !host && !guest {
|
||||
return;
|
||||
}
|
||||
let message = self.mob_death_message(m, killer);
|
||||
if host {
|
||||
self.system_message(message);
|
||||
} else if let Some(lan) = &self.lan {
|
||||
let content = horizon_client::lan::data::component_nbt(&message.component());
|
||||
lan.hub.system_message_to_player(uuid, content);
|
||||
}
|
||||
}
|
||||
/// `CombatTracker.getDeathMessage` for a mob, from the hits it recorded
|
||||
/// (`Mob::combat`), told the way the player's own tracker tells them.
|
||||
pub(super) fn mob_death_message(&mut self, m: &Mob, killer: Option<Option<u32>>) -> Text {
|
||||
use horizon_client::mobs::{DamageKind, Who};
|
||||
let me = mob_name(m);
|
||||
// The player behind a `Who::Player` hit: the last one to hurt it, or
|
||||
// the one in place (a server with nobody acting has none).
|
||||
let player = match killer {
|
||||
Some(Some(conn)) => Some((u32::MAX - 1 - conn, self.target_name(Target::Guest(conn)))),
|
||||
Some(None) => Some((u32::MAX, self.target_name(Target::Player))),
|
||||
None if self.player.is_some() || self.lan_acting.is_some() => {
|
||||
Some((u32::MAX, self.target_name(Target::Player)))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
// The cause's display name: a mob that is gone (a creeper that blew
|
||||
// up) is still named by its blast.
|
||||
let name = |g: &Self, who: Who| match who {
|
||||
Who::Player => player.clone(),
|
||||
Who::Mob(id) => g
|
||||
.mob_display_name(id)
|
||||
.or_else(|| {
|
||||
g.combat
|
||||
.exploded
|
||||
.iter()
|
||||
.find(|(e, _)| *e == id)
|
||||
.map(|(_, n)| n.clone())
|
||||
})
|
||||
.map(|n| (id, n)),
|
||||
};
|
||||
let entries = m
|
||||
.combat
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let attacker = e.source.attacker.and_then(|w| name(self, w));
|
||||
// AbstractArrow.onHitEntity: an arrow nobody shot is its own
|
||||
// cause.
|
||||
let direct = (attacker.is_none() && e.source.kind == DamageKind::Arrow)
|
||||
.then(|| Text::translate("entity.minecraft.arrow", &[]));
|
||||
Entry {
|
||||
// DamageSources.explosion: player_explosion whenever
|
||||
// something caused the blast (the only kind that asks).
|
||||
damage_type: super::creatures::damage_type(
|
||||
e.source.kind,
|
||||
e.source.attacker.is_some(),
|
||||
)
|
||||
.to_owned(),
|
||||
damage: e.damage,
|
||||
fall_location: e.in_water.then_some("water"),
|
||||
fall_distance: e.fall_distance,
|
||||
attacker,
|
||||
direct,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// LivingEntity.getKillCredit: the player who hurt it lately, else the
|
||||
// last mob to hurt it within 100 ticks.
|
||||
let credit = if m.ai.last_hurt_by_player > 0 && player.is_some() {
|
||||
player.clone()
|
||||
} else {
|
||||
m.ai.last_hurt_by
|
||||
.filter(|_| m.age as i32 - m.ai.last_hurt_by_time <= 100)
|
||||
.and_then(|w| name(self, w))
|
||||
};
|
||||
let now = self.world.game_time;
|
||||
let mut tracker = CombatTracker {
|
||||
entries,
|
||||
last_damage: now,
|
||||
in_combat: m.combat.in_combat,
|
||||
last_hurt_by: credit.map(|c| (c, now)),
|
||||
..CombatTracker::default()
|
||||
};
|
||||
std::mem::swap(&mut self.combat, &mut tracker);
|
||||
let text = self.combat_death_message_for(me);
|
||||
std::mem::swap(&mut self.combat, &mut tracker);
|
||||
text
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.getDisplayName` of a mob: its custom name or its type's name.
|
||||
fn mob_name(m: &Mob) -> Text {
|
||||
match (&m.custom_name_text, &m.custom_name) {
|
||||
(Some(text), _) => text.clone(),
|
||||
(None, Some(name)) => Text::literal(name.clone()),
|
||||
(None, None) => Text::translate(&format!("entity.{}", m.kind.id.replace(':', ".")), &[]),
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ use horizon_client::{
|
||||
text::{self, Style, Text},
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
pub(super) type R<T> = Result<T, CommandError>;
|
||||
@@ -87,6 +88,30 @@ fn java_float(v: f64) -> String {
|
||||
fn format_double(v: f64) -> String {
|
||||
format!("{v:.6}")
|
||||
}
|
||||
/// `EntitySelectorParser.createRotationPredicate`: the wrapped angle lies in
|
||||
/// the wrapped range, which runs through 180 when its minimum is above its
|
||||
/// maximum (defaults 0 and 359).
|
||||
fn rotation_in((lo, hi): (Option<f64>, Option<f64>), angle: f32) -> bool {
|
||||
let min = wrap_degrees(lo.unwrap_or(0.0) as f32);
|
||||
let max = wrap_degrees(hi.unwrap_or(359.0) as f32);
|
||||
let angle = wrap_degrees(angle);
|
||||
if min > max {
|
||||
angle >= min || angle <= max
|
||||
} else {
|
||||
angle >= min && angle <= max
|
||||
}
|
||||
}
|
||||
/// `Mth.wrapDegrees`.
|
||||
fn wrap_degrees(angle: f32) -> f32 {
|
||||
let wrapped = angle % 360.0;
|
||||
if wrapped >= 180.0 {
|
||||
wrapped - 360.0
|
||||
} else if wrapped < -180.0 {
|
||||
wrapped + 360.0
|
||||
} else {
|
||||
wrapped
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Commands.performCommand for a line without its slash.
|
||||
@@ -653,6 +678,18 @@ impl Game<'_> {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// x_rotation is the pitch, y_rotation the yaw (getXRot, getYRot).
|
||||
let [yaw, pitch] = self.target_rotation(t);
|
||||
if let Some(range) = s.x_rotation
|
||||
&& !rotation_in(range, pitch)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Some(range) = s.y_rotation
|
||||
&& !rotation_in(range, yaw)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if s.delta.iter().any(Option::is_some) {
|
||||
let d = s.delta.map(|v| v.unwrap_or(0.0));
|
||||
for i in 0..3 {
|
||||
@@ -1449,7 +1486,10 @@ impl Game<'_> {
|
||||
let targets = if id.starts_with("teleport.targets") {
|
||||
self.entities(ctx, "targets", false)?
|
||||
} else {
|
||||
vec![self.implied_player()]
|
||||
vec![
|
||||
self.source_entity()
|
||||
.ok_or_else(|| err("permissions.requires.entity", &[]))?,
|
||||
]
|
||||
};
|
||||
if id.ends_with("destination") {
|
||||
let dest = self.entities(ctx, "destination", false)?[0];
|
||||
@@ -1762,6 +1802,16 @@ impl Game<'_> {
|
||||
}
|
||||
Target::Mob(id) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
// LivingEntity.kill: a generic_kill hit of Float.MAX_VALUE,
|
||||
// which its death message tells.
|
||||
if m.alive() {
|
||||
m.record_damage(
|
||||
horizon_client::mobs::Damage::of(
|
||||
horizon_client::mobs::DamageKind::GenericKill,
|
||||
),
|
||||
f32::MAX,
|
||||
);
|
||||
}
|
||||
m.health = 0.0;
|
||||
if m.death_time == 0 {
|
||||
m.death_time = 1;
|
||||
@@ -2648,6 +2698,15 @@ impl Game<'_> {
|
||||
// Peaceful removes hostile mobs.
|
||||
self.mobs.retain(|m| !m.hostile());
|
||||
}
|
||||
// MinecraftServer.setDifficulty: every player, guests included, is
|
||||
// sent the new difficulty.
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
host.hub
|
||||
.broadcast(&C::ChangeDifficulty(play::ChangeDifficulty {
|
||||
difficulty: i32::from(i),
|
||||
locked: self.level.difficulty_locked,
|
||||
}));
|
||||
}
|
||||
self.feedback(Text::translate("commands.difficulty.success", &[name(i)]));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
@@ -310,12 +310,19 @@ impl Game<'_> {
|
||||
None => (0, None),
|
||||
};
|
||||
let count = Self::int(ctx, "count").unwrap_or(1).max(1) as u32;
|
||||
Ok(Some(ItemStack {
|
||||
let stack = ItemStack {
|
||||
item: id.as_str().into(),
|
||||
count,
|
||||
damage,
|
||||
extra,
|
||||
}))
|
||||
};
|
||||
// ItemInput.createItemStack: a count above the stack's max (its
|
||||
// max_stack_size component, else the item's) is refused.
|
||||
let max_stack = self.stack_max_size(&stack);
|
||||
if count > max_stack {
|
||||
return Err(err("arguments.item.overstacked", &[&id, &max_stack]));
|
||||
}
|
||||
Ok(Some(stack))
|
||||
}
|
||||
/// One item modifier (a typed object, a list or a datapack reference)
|
||||
/// applied to a stack; `None` when the stack ends up empty.
|
||||
|
||||
@@ -232,6 +232,16 @@ impl Game<'_> {
|
||||
self.world.dirty_sections.insert((key.0, sy, key.1));
|
||||
}
|
||||
}
|
||||
// ChunkMap.resendBiomesForChunks: the players who see the changed
|
||||
// columns are sent their new biomes, in the order the columns were
|
||||
// gathered (z, then x).
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
let columns: Vec<(i32, i32)> = ((lo[2] >> 4)..=(hi[2] >> 4))
|
||||
.flat_map(|cz| ((lo[0] >> 4)..=(hi[0] >> 4)).map(move |cx| (cx, cz)))
|
||||
.filter(|key| touched.contains(key))
|
||||
.collect();
|
||||
host.hub.resend_biomes(&self.world, &columns);
|
||||
}
|
||||
self.feedback(Text::translate(
|
||||
"commands.fillbiome.success.count",
|
||||
&[
|
||||
|
||||
@@ -113,7 +113,8 @@ impl Game<'_> {
|
||||
"explosion" | "player_explosion" | "bad_respawn_point" => DamageKind::Explosion,
|
||||
"fall" | "ender_pearl" | "fly_into_wall" => DamageKind::Fall,
|
||||
"on_fire" => DamageKind::OnFire,
|
||||
"in_fire" | "hot_floor" => DamageKind::InFire,
|
||||
"in_fire" => DamageKind::InFire,
|
||||
"hot_floor" => DamageKind::HotFloor,
|
||||
"campfire" => DamageKind::Campfire,
|
||||
"lava" => DamageKind::Lava,
|
||||
"drown" => DamageKind::Drown,
|
||||
|
||||
@@ -128,7 +128,7 @@ impl Game<'_> {
|
||||
if !self.world.loaded(pos.x, pos.z) {
|
||||
return false;
|
||||
}
|
||||
while pos.y > self.dim.min_y() {
|
||||
while pos.y > self.world.shape.min_y {
|
||||
pos.y -= 1;
|
||||
let state = self.world.block(pos.x, pos.y, pos.z);
|
||||
if self.state_in_tag("minecraft:entities_can_teleport_to", state) {
|
||||
@@ -296,10 +296,10 @@ impl TeleportConsumeWorld for Teleporter<'_, '_> {
|
||||
&mut self.0.world.random
|
||||
}
|
||||
fn min_y(&mut self, level: Dim) -> Result<i32, Infallible> {
|
||||
Ok(level.min_y())
|
||||
Ok(self.0.level_shapes.get(level.id()).min_y)
|
||||
}
|
||||
fn logical_height(&mut self, level: Dim) -> Result<i32, Infallible> {
|
||||
Ok(level.logical_height())
|
||||
Ok(self.0.level_shapes.get(level.id()).logical_height)
|
||||
}
|
||||
fn is_passenger(&mut self) -> Result<bool, Infallible> {
|
||||
Ok(self.0.riding.is_some())
|
||||
|
||||
@@ -36,6 +36,8 @@ impl Game<'_> {
|
||||
self.world.chest_openers.remove(&key);
|
||||
}
|
||||
self.block_entity_event(chest, 1, count.max(0));
|
||||
// The guests get the same `ClientboundBlockEventPacket` (the lid).
|
||||
self.lan_block_event(chest, 1, count.max(0));
|
||||
}
|
||||
/// A copper golem took from or filled a chest slot.
|
||||
pub(super) fn golem_chest_slot(
|
||||
@@ -100,6 +102,7 @@ impl Game<'_> {
|
||||
o.0 = count;
|
||||
}
|
||||
self.block_entity_event(p, 1, count);
|
||||
self.lan_block_event(p, 1, count);
|
||||
}
|
||||
}
|
||||
/// `ChestBlockEntity.playSound` for a chest the recheck closed.
|
||||
|
||||
@@ -92,11 +92,14 @@ impl Game<'_> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// Runs `f` on the heart at `key` with the game's mobs and level random,
|
||||
/// then applies what the heart reported.
|
||||
/// then applies what the heart reported. `others`: whether `f` reads the
|
||||
/// other mobs through the mob world (without, it holds none: building them
|
||||
/// costs a pass over every mob).
|
||||
fn with_heart(
|
||||
&mut self,
|
||||
key: Pos,
|
||||
heart: &mut CreakingHeart,
|
||||
others: bool,
|
||||
f: impl FnOnce(
|
||||
&mut CreakingHeart,
|
||||
&horizon_client::mobs::MobWorld,
|
||||
@@ -112,7 +115,11 @@ impl Game<'_> {
|
||||
let spawning = self.spawning_monsters();
|
||||
let creaking = self.res.mob_types().get("minecraft:creaking").cloned();
|
||||
let player_uuid = self.level.player_uuid;
|
||||
let snapshot: Vec<_> = mobs.iter().map(|m| seen(m, player_uuid)).collect();
|
||||
let snapshot: Vec<_> = if others {
|
||||
mobs.iter().map(|m| seen(m, player_uuid)).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut events = Vec::new();
|
||||
let added = {
|
||||
let w = self.mob_world_base(&snapshot);
|
||||
@@ -168,7 +175,7 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let _ = self.with_heart(key, &mut heart, |h, w, lr, host, out| {
|
||||
let _ = self.with_heart(key, &mut heart, true, |h, w, lr, host, out| {
|
||||
creaking_heart::creaking_hurt(h, home, w, lr, host, out);
|
||||
});
|
||||
self.world
|
||||
@@ -232,7 +239,7 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let _ = self.with_heart(key, &mut heart, |h, _, _, host, out| {
|
||||
let _ = self.with_heart(key, &mut heart, false, |h, _, _, host, out| {
|
||||
creaking_heart::remove_protector(h, host, true, out);
|
||||
});
|
||||
let state = self.world.block(key.0, key.1, key.2);
|
||||
@@ -270,7 +277,7 @@ impl Game<'_> {
|
||||
if &*self.res.facts().get(state).name != "minecraft:creaking_heart" {
|
||||
// The block is gone: preRemoveSideEffects.
|
||||
self.world.block_entities.forget(&key);
|
||||
self.with_heart(key, &mut heart, |h, _, _, host, out| {
|
||||
self.with_heart(key, &mut heart, false, |h, _, _, host, out| {
|
||||
creaking_heart::remove_protector(h, host, false, out);
|
||||
})?;
|
||||
return Ok(());
|
||||
@@ -278,7 +285,8 @@ impl Game<'_> {
|
||||
// getTicker: none once uprooted.
|
||||
if self.heart_state_name(state) != "uprooted" {
|
||||
let pos = [key.0, key.1, key.2];
|
||||
self.with_heart(key, &mut heart, |h, w, lr, host, out| {
|
||||
let others = creaking_heart::tick_updates(&heart);
|
||||
self.with_heart(key, &mut heart, others, |h, w, lr, host, out| {
|
||||
creaking_heart::server_tick(h, pos, w, lr, host, out);
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ use horizon_client::{
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Direction, Vec3, traverse_blocks},
|
||||
geometry::{Aabb, Direction, Vec3, traverse_blocks, trig::Mth},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
@@ -57,8 +57,9 @@ pub(super) struct Creatures {
|
||||
/// (game/lan_pvp.rs).
|
||||
pub sweeps: Vec<super::lan_pvp::Sweep>,
|
||||
/// Explosion packets from a guest's turn, waiting for it to end
|
||||
/// (centre, small, radius, blocks hit; game/lan_particles.rs).
|
||||
pub lan_explosions: Vec<([f64; 3], bool, f32, usize)>,
|
||||
/// (centre, small, radius, blocks hit, the players it pushed;
|
||||
/// game/lan_particles.rs).
|
||||
pub lan_explosions: Vec<([f64; 3], bool, f32, usize, Vec<(u32, [f64; 3])>)>,
|
||||
/// Ticks left of the elder guardian's apparition (MobAppearanceParticle).
|
||||
pub elder_apparition: i32,
|
||||
/// The wandering trader spawner's clock.
|
||||
@@ -70,11 +71,13 @@ pub(super) struct Creatures {
|
||||
/// Particles from a guest's turn, waiting for it to end
|
||||
/// (`lan_send_particles`: options, centre, spread, speed, count).
|
||||
pub lan_particles: Vec<(
|
||||
&'static str,
|
||||
horizon_client::particles::ParticleOptions,
|
||||
Option<horizon_world::registry::BlockStateId>,
|
||||
[f64; 3],
|
||||
[f64; 3],
|
||||
f64,
|
||||
u32,
|
||||
i32,
|
||||
)>,
|
||||
}
|
||||
pub(super) struct ProjWorld<'a, 'w> {
|
||||
@@ -636,8 +639,10 @@ impl Game<'_> {
|
||||
self.died.insert(m.id);
|
||||
self.mob_died(&m);
|
||||
}
|
||||
// Their victims have been looted: no charged creeper is waiting any more.
|
||||
// Their victims have been looted and mourned: no charged creeper is
|
||||
// waiting any more.
|
||||
self.combat.charged_exploders.clear();
|
||||
self.combat.exploded.clear();
|
||||
let uuid = self.level.player_uuid;
|
||||
let mut snapshot: Vec<Seen> = self.mobs.iter().map(|m| seen(m, uuid)).collect();
|
||||
let mut events = Vec::new();
|
||||
@@ -847,6 +852,7 @@ impl Game<'_> {
|
||||
self.tick_sniffer_effects();
|
||||
self.piglin_pickups();
|
||||
self.sulfur_cube_pickups();
|
||||
self.sulfur_cube_power_priming();
|
||||
// Hurt and death sounds of damage dealt outside the mob ticks.
|
||||
let mut queued = Vec::new();
|
||||
for m in &mut self.mobs {
|
||||
@@ -880,6 +886,8 @@ impl Game<'_> {
|
||||
self.set_bed_occupied(bed, false);
|
||||
}
|
||||
let killer = self.last_hurt_by_player.remove(&m.id);
|
||||
// TamableAnimal.die, before the rest of LivingEntity.die.
|
||||
self.tell_owner_pet_died(m, killer);
|
||||
self.love_cause.remove(&m.id);
|
||||
let by_player = killer.is_some() || m.ai.last_hurt_by_player > 0;
|
||||
let mut dying = m.clone();
|
||||
@@ -1321,7 +1329,7 @@ impl Game<'_> {
|
||||
let options = horizon_client::particles::ParticleOptions::simple(ty);
|
||||
self.particles
|
||||
.send_particles(&options, at, spread, speed, count);
|
||||
self.lan_level_particles(&options, at, spread, speed, count);
|
||||
self.lan_level_particles(&options, at, spread, speed, count, None);
|
||||
}
|
||||
}
|
||||
MobEvent::Sound(name, at, volume, pitch)
|
||||
@@ -1435,6 +1443,7 @@ impl Game<'_> {
|
||||
self.combat.explosion_source = Some(mob);
|
||||
// The creeper causes the blast's damage, but it is out of
|
||||
// the mob list already: its name comes with the event.
|
||||
self.combat.exploded.push((mob, name.clone()));
|
||||
self.note_named_attacker("minecraft:explosion", mob, name);
|
||||
if charged {
|
||||
self.combat.charged_exploders.push(mob);
|
||||
@@ -1994,6 +2003,11 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
MobEvent::PandaSneezed { mob } => self.panda_sneezed(mob),
|
||||
MobEvent::FallToPassengers {
|
||||
mob,
|
||||
distance,
|
||||
modifier,
|
||||
} => self.fall_to_passengers(mob, distance, modifier),
|
||||
MobEvent::ShedScute { mob, at, pitch } => {
|
||||
// The mob already checked `shouldDropLoot` (a baby, `mob_drops`).
|
||||
let table = "minecraft:gameplay/armadillo_shed";
|
||||
@@ -2227,7 +2241,9 @@ impl Game<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let coin = self.world.random.next_int_bound(2).unwrap_or(0) == 0;
|
||||
// (Mooshroom draws its own coin in getOffspringVariant.)
|
||||
let coin = p.species() != Species::Mooshroom
|
||||
&& self.world.random.next_int_bound(2).unwrap_or(0) == 0;
|
||||
// Axolotl.getBreedOffspring: useRareVariant (one in 1200) makes it blue,
|
||||
// else either parent's variant. The draws come from the mother's random.
|
||||
let axolotl = if p.species() == Species::Axolotl {
|
||||
@@ -2246,6 +2262,24 @@ impl Game<'_> {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// MushroomCow.getOffspringVariant, from the mother's random: the other
|
||||
// colour 1 time in 1024 when both parents share one, otherwise either
|
||||
// parent's colour by a coin flip.
|
||||
let mooshroom = if p.species() == Species::Mooshroom {
|
||||
let mate = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant);
|
||||
let own = p.extra.variant;
|
||||
self.mobs.iter_mut().find(|m| m.id == parent).map(|m| {
|
||||
if own == mate && m.random.next_int_bound(1024).unwrap_or(1) == 0 {
|
||||
if own == 1 { 0 } else { 1 }
|
||||
} else if m.random.next_boolean() {
|
||||
own
|
||||
} else {
|
||||
mate
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// A horse and a donkey have a mule.
|
||||
let kind = match (p.species(), q.as_ref().map(|q| q.species())) {
|
||||
(Species::Horse, Some(Species::Donkey)) | (Species::Donkey, Some(Species::Horse)) => {
|
||||
@@ -2393,6 +2427,9 @@ impl Game<'_> {
|
||||
{
|
||||
child.extra.variant = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant);
|
||||
}
|
||||
if let Some(variant) = mooshroom {
|
||||
child.extra.variant = variant;
|
||||
}
|
||||
// Sheep.getBreedOffspring: the lamb's wool mixes both parents' dyes.
|
||||
if p.species() == Species::Sheep {
|
||||
let other = q.as_ref().unwrap_or(&p);
|
||||
@@ -3019,6 +3056,23 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AmethystBlock.onProjectileHit`: a projectile that strikes an amethyst
|
||||
/// block rings `block.amethyst_block.chime` there, pitched by the level random.
|
||||
fn amethyst_projectile_hit(&mut self, pos: [i32; 3]) {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if &*self.res.facts().get(state).name != "minecraft:amethyst_block" {
|
||||
return;
|
||||
}
|
||||
let pitch = 0.5 + self.world.random.next_float() * 1.2;
|
||||
self.level_events
|
||||
.push(horizon_world::level::LevelEvent::Sound {
|
||||
pos: BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
sound: "minecraft:block.amethyst_block.chime".into(),
|
||||
volume: 1.0,
|
||||
pitch,
|
||||
});
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
/// `onProjectileHit` of chorus flowers, decorated pots and speleothems
|
||||
/// (pointed dripstone, sulfur spikes): an impact projectile
|
||||
/// (`#impact_projectiles`) breaks the block when `Projectile.mayInteract`
|
||||
@@ -3091,6 +3145,11 @@ impl Game<'_> {
|
||||
[position.x, position.y, position.z],
|
||||
[velocity.x, velocity.y, velocity.z],
|
||||
),
|
||||
// Arrow.tick: `broadcastEntityEvent(this, 0)`. The window and the
|
||||
// guests burst the particles themselves (`remote_arrow_puff`).
|
||||
ArrowEvent::PotionPuff { id, .. } => {
|
||||
self.lan_entity_event(id as i32, 0, [at.x, at.y, at.z]);
|
||||
}
|
||||
ArrowEvent::Sound {
|
||||
sound,
|
||||
volume,
|
||||
@@ -3236,6 +3295,7 @@ impl Game<'_> {
|
||||
&& face != [0; 3]
|
||||
{
|
||||
self.bell_projectile_hit(p, pos, at, face);
|
||||
self.amethyst_projectile_hit(pos);
|
||||
self.projectile_breaks_block(p, pos);
|
||||
}
|
||||
self.combat.projectile_owner = match p.owner {
|
||||
@@ -3391,7 +3451,7 @@ impl Game<'_> {
|
||||
}
|
||||
// AbstractArrow.onHitBlock plays its sound itself (ArrowEvent).
|
||||
(ProjectileKind::Trident, Hit::Block { .. }) => {}
|
||||
(ProjectileKind::Arrow { effect, .. }, Hit::Entity { who, .. }) => {
|
||||
(ProjectileKind::Arrow { .. }, Hit::Entity { who, .. }) => {
|
||||
let mut arrow = p.clone();
|
||||
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
|
||||
let player_fire = self.player.as_ref().map(|pl| pl.fire_ticks);
|
||||
@@ -3404,17 +3464,11 @@ impl Game<'_> {
|
||||
}])?;
|
||||
}
|
||||
let hurt = match who {
|
||||
Who::Player => {
|
||||
let hurt = self.mob_hits_player(
|
||||
amount,
|
||||
[p.previous.x, p.previous.y, p.previous.z],
|
||||
"minecraft:arrow",
|
||||
);
|
||||
if hurt && let Some((e, t)) = effect {
|
||||
self.give_player_effect(e, *t, 0);
|
||||
}
|
||||
hurt
|
||||
}
|
||||
Who::Player => self.mob_hits_player(
|
||||
amount,
|
||||
[p.previous.x, p.previous.y, p.previous.z],
|
||||
"minecraft:arrow",
|
||||
),
|
||||
Who::Mob(id) => {
|
||||
let hurt = self
|
||||
.mobs
|
||||
@@ -3533,6 +3587,9 @@ impl Game<'_> {
|
||||
velocity: [p.velocity.x * k, 0.1, p.velocity.z * k],
|
||||
}])?;
|
||||
}
|
||||
if hurt {
|
||||
self.arrow_post_hurt_effects(p, who);
|
||||
}
|
||||
match (hurt, who) {
|
||||
(true, _) => self.finish_arrow_hit(arrow, EntityHit::Hurt),
|
||||
// What the player took no damage from (a raised shield)
|
||||
@@ -4858,8 +4915,14 @@ impl Game<'_> {
|
||||
f64::from(p[1]),
|
||||
f64::from(p[2]) + 0.5,
|
||||
];
|
||||
if self.spawn_mob_spawned(&kind, at).is_none() {
|
||||
let Some(mob_id) = self.spawn_mob_spawned(&kind, at) else {
|
||||
return Ok(false);
|
||||
};
|
||||
// Entity.applyImplicitComponents: a name from an anvil names the mob.
|
||||
if let Some(name) = stack.custom_name()
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob_id)
|
||||
{
|
||||
m.custom_name = Some(name.to_owned());
|
||||
}
|
||||
self.swing_arm();
|
||||
if !self.creative
|
||||
@@ -5139,10 +5202,28 @@ impl Game<'_> {
|
||||
let m = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_rotation_y((a.yaw - 90.0).to_radians())
|
||||
* glam::Mat4::from_rotation_z(a.pitch.to_radians());
|
||||
// ArrowRenderer: `state.shake = shakeTime - partialTicks`; ArrowModel.setupAnim
|
||||
// rolls the root while the shake timer runs.
|
||||
let shake = a
|
||||
.arrow
|
||||
.as_ref()
|
||||
.map_or(0.0, |s| s.shake as f32 - alpha as f32);
|
||||
let roll = if shake > 0.0 {
|
||||
-(shake * 3.0).msin() * shake * (std::f32::consts::PI / 180.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let pose_for = |part: &horizon_client::mobs::Part| {
|
||||
let mut pose = part.pose;
|
||||
if std::ptr::eq(part, model) {
|
||||
pose[5] += roll;
|
||||
}
|
||||
pose
|
||||
};
|
||||
horizon_client::mobs::render_model(
|
||||
model,
|
||||
m,
|
||||
&|part| part.pose,
|
||||
&pose_for,
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
packed,
|
||||
0,
|
||||
@@ -5418,6 +5499,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
|
||||
K::Spit => "minecraft:spit",
|
||||
K::Stalagmite => "minecraft:stalagmite",
|
||||
K::Starve => "minecraft:starve",
|
||||
K::HotFloor => "minecraft:hot_floor",
|
||||
K::SulfurCubeHot => "minecraft:sulfur_cube_hot",
|
||||
K::WindCharge => "minecraft:wind_charge",
|
||||
K::Wither => "minecraft:wither",
|
||||
|
||||
@@ -67,6 +67,9 @@ impl Game<'_> {
|
||||
/// own handling. Returns true when it is used up (a debug combination,
|
||||
/// F3 itself, F3+Escape).
|
||||
pub(super) fn debug_key(&mut self, key: KeyCode, action: KeyAction) -> bool {
|
||||
if self.title.is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(name) = keybinds::key_name(key) else {
|
||||
return false;
|
||||
};
|
||||
@@ -275,8 +278,8 @@ impl Game<'_> {
|
||||
/// entries, over everything the frame drew (menus too), at the debug GUI
|
||||
/// scale. F1 hides it unless a screen is open.
|
||||
pub(super) fn draw_debug_overlay(&self, batch: &mut HudBatch, size: [u32; 2]) {
|
||||
let screen_open = self.title.is_some() || self.screen != Screen::Playing;
|
||||
if !self.debug || (self.ui.hide_gui && !screen_open) {
|
||||
let screen_open = self.screen != Screen::Playing;
|
||||
if self.title.is_some() || !self.debug || (self.ui.hide_gui && !screen_open) {
|
||||
return;
|
||||
}
|
||||
let framebuffer = size.map(|v| v as i32);
|
||||
|
||||
@@ -34,6 +34,7 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
mod report;
|
||||
mod status;
|
||||
|
||||
/// What the tick loop tells `ServerWatchdog`: when the tick in progress was
|
||||
@@ -505,6 +506,11 @@ struct ServerState {
|
||||
broadcast_console: bool,
|
||||
/// `op-permission-level`: what `/op` grants (operatorUserPermissions).
|
||||
op_level: i32,
|
||||
/// `horizon-report`: the ticks being recorded.
|
||||
report: Option<report::Recording>,
|
||||
/// View and simulation distance, and the player limit, for the report.
|
||||
view: (u32, u32),
|
||||
max_players: usize,
|
||||
}
|
||||
|
||||
/// The dedicated-only commands' replies vanilla sends with `broadcast`
|
||||
@@ -733,6 +739,11 @@ impl Game<'_> {
|
||||
resource_pack: props.resource_pack.clone(),
|
||||
// DedicatedServer.serverLinks: `bug-report-link` as the only link
|
||||
// (ServerLinks.KnownLinkType.BUG_REPORT is id 0).
|
||||
levels: self.level_shapes,
|
||||
horizon_clients_only: props
|
||||
.raw
|
||||
.get(horizon_client::lan::HORIZON_CLIENTS_ONLY)
|
||||
.is_some_and(|v| v.trim().eq_ignore_ascii_case("true")),
|
||||
server_links: if props.bug_report_link.is_empty() {
|
||||
Vec::new()
|
||||
} else if super::server_links::valid_untrusted_uri(&props.bug_report_link) {
|
||||
@@ -756,6 +767,7 @@ impl Game<'_> {
|
||||
view_distance: props.view_distance.clamp(2, 32),
|
||||
simulation_distance: props.simulation_distance.clamp(2, 32),
|
||||
dimension: self.dim.id().to_owned(),
|
||||
levels: self.level_shapes,
|
||||
seed: self.level.seed,
|
||||
max_players: props.max_players.max(0) as usize,
|
||||
dedicated: true,
|
||||
@@ -803,6 +815,12 @@ impl Game<'_> {
|
||||
paused: false,
|
||||
broadcast_console: props.broadcast_console_to_ops,
|
||||
op_level: props.op_permission_level,
|
||||
report: None,
|
||||
view: (
|
||||
props.view_distance.clamp(2, 32) as u32,
|
||||
props.simulation_distance.clamp(2, 32) as u32,
|
||||
),
|
||||
max_players: props.max_players.max(0) as usize,
|
||||
};
|
||||
let pause_after = props.pause_when_empty_seconds * 20;
|
||||
let idle_timeout = props.player_idle_timeout;
|
||||
@@ -845,7 +863,17 @@ impl Game<'_> {
|
||||
self.ticks = self.ticks.max(1);
|
||||
}
|
||||
// MinecraftServer.tickServer: while paused only the connections run.
|
||||
self.tick_server_timed(paused)?;
|
||||
match &mut state.report {
|
||||
Some(recording) => self.tick_server_spans(paused, |wall, cpu, spans| {
|
||||
recording.add(wall, cpu, spans)
|
||||
})?,
|
||||
None => self.tick_server_timed(paused)?,
|
||||
}
|
||||
if state.report.as_ref().is_some_and(report::Recording::done)
|
||||
&& let Some(recording) = state.report.take()
|
||||
{
|
||||
self.finish_report(recording, state.view, state.max_players);
|
||||
}
|
||||
if idle_timeout > 0 {
|
||||
self.kick_idle(idle_timeout);
|
||||
}
|
||||
@@ -1043,6 +1071,33 @@ impl Game<'_> {
|
||||
self.console_reply(Text::translate("commands.stop.stopping", &[]));
|
||||
state.stop = true;
|
||||
}
|
||||
// Horizon's own, on the console only: a performance report (report.rs).
|
||||
"horizon-report" => {
|
||||
let seconds = match rest.first().map(|s| s.parse::<u32>()) {
|
||||
None => Ok(60),
|
||||
Some(Ok(s)) if (1..=report::MAX_SECONDS).contains(&s) => Ok(s),
|
||||
Some(_) => Err(()),
|
||||
};
|
||||
match (seconds, &state.report) {
|
||||
(Err(()), _) => log(
|
||||
"INFO",
|
||||
&format!(
|
||||
"horizon-report [SECONDS]: 1 to {} seconds, 60 by default",
|
||||
report::MAX_SECONDS
|
||||
),
|
||||
),
|
||||
(Ok(_), Some(_)) => {
|
||||
log("INFO", "A performance report is already being recorded")
|
||||
}
|
||||
(Ok(seconds), None) => {
|
||||
state.report = Some(report::Recording::start(seconds));
|
||||
log(
|
||||
"INFO",
|
||||
&format!("Recording a performance report for {seconds} s (reports/)"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
"save-all" => {
|
||||
self.console_reply(Text::translate("commands.save.saving", &[]));
|
||||
match self.save_game() {
|
||||
@@ -1501,10 +1556,13 @@ impl Game<'_> {
|
||||
self.native_lifecycle.begin_close();
|
||||
log("INFO", "Stopping server");
|
||||
log("INFO", "Saving players");
|
||||
if let Some(h) = self.lan.as_mut() {
|
||||
for g in &h.hub.guests {
|
||||
horizon_client::lan::playerdata::save(g, &self.world_root);
|
||||
if let Some(host) = self.lan.take() {
|
||||
for g in &host.hub.guests {
|
||||
// The vehicle the guest rides, for the file that names it.
|
||||
let vehicle = self.root_vehicle_uuid(g.vehicle);
|
||||
horizon_client::lan::playerdata::save(g, &self.world_root, vehicle);
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
if let Some(h) = self.lan.as_ref() {
|
||||
for g in &h.hub.guests {
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
//! `horizon-report [SECONDS]` on the dedicated server's console (and `horizon-server report`): records every
|
||||
//! tick's time and spans for a window (60 s unless told), then writes a performance report to share,
|
||||
//! `reports/perf-<time>.json` and a short `.txt` beside it in the server's folder. It says where the ticks'
|
||||
//! time went (the spans of `HORIZON_SPIKES`), shows the worst ticks with their spans, counts the entities by
|
||||
//! type and the chunks of each level, and gives the memory, the CPU and the view settings. It holds no player
|
||||
//! names, UUIDs or addresses, no seed and no world name.
|
||||
use super::super::{Dim, Game};
|
||||
use horizon_client::spans::{self, Span};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::{Path, PathBuf},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// The longest window a report records.
|
||||
pub(super) const MAX_SECONDS: u32 = 600;
|
||||
/// The worst ticks a report lists with their spans.
|
||||
const WORST: usize = 10;
|
||||
|
||||
struct Tick {
|
||||
wall_ms: f32,
|
||||
cpu_ms: Option<f32>,
|
||||
spans: Vec<Span>,
|
||||
}
|
||||
|
||||
/// A report being recorded.
|
||||
pub(super) struct Recording {
|
||||
seconds: u32,
|
||||
started: Instant,
|
||||
cpu_s: Option<f64>,
|
||||
/// Whether spans were on before (`HORIZON_SPIKES`): they stay on after.
|
||||
spans_were_on: bool,
|
||||
ticks: Vec<Tick>,
|
||||
}
|
||||
|
||||
impl Recording {
|
||||
pub(super) fn start(seconds: u32) -> Self {
|
||||
let spans_were_on = spans::enabled();
|
||||
spans::enable(true);
|
||||
Self {
|
||||
seconds,
|
||||
started: Instant::now(),
|
||||
cpu_s: horizon_bench::cpu_seconds(std::process::id()),
|
||||
spans_were_on,
|
||||
ticks: Vec::with_capacity(seconds as usize * 20),
|
||||
}
|
||||
}
|
||||
pub(super) fn add(&mut self, wall_ms: f32, cpu_ms: Option<f32>, spans: Vec<Span>) {
|
||||
self.ticks.push(Tick {
|
||||
wall_ms,
|
||||
cpu_ms,
|
||||
spans,
|
||||
});
|
||||
}
|
||||
pub(super) fn done(&self) -> bool {
|
||||
self.started.elapsed() >= Duration::from_secs(u64::from(self.seconds))
|
||||
}
|
||||
}
|
||||
|
||||
fn pct(sorted: &[f32], q: f64) -> f64 {
|
||||
let i = ((sorted.len() as f64 * q) as usize).min(sorted.len().saturating_sub(1));
|
||||
sorted.get(i).map_or(0.0, |&v| f64::from(v))
|
||||
}
|
||||
fn round(v: f64) -> f64 {
|
||||
(v * 100.0).round() / 100.0
|
||||
}
|
||||
|
||||
/// The tick times and spans of a recording, as the report's `ticks` and `spans`.
|
||||
fn tick_summary(r: &Recording, window_s: f64) -> (Value, Value, Value) {
|
||||
let mut walls: Vec<f32> = r.ticks.iter().map(|t| t.wall_ms).collect();
|
||||
walls.sort_by(f32::total_cmp);
|
||||
let n = r.ticks.len().max(1) as f64;
|
||||
let mean = |f: &dyn Fn(&Tick) -> f64| r.ticks.iter().map(f).sum::<f64>() / n;
|
||||
let ticks = json!({
|
||||
"count": r.ticks.len(),
|
||||
"tps": round(r.ticks.len() as f64 / window_s.max(1e-9)),
|
||||
"mspt": {
|
||||
"average": round(mean(&|t| f64::from(t.wall_ms))),
|
||||
"p50": round(pct(&walls, 0.5)),
|
||||
"p95": round(pct(&walls, 0.95)),
|
||||
"p99": round(pct(&walls, 0.99)),
|
||||
"max": round(walls.last().copied().map_or(0.0, f64::from)),
|
||||
},
|
||||
"cpu_ms_average": round(mean(&|t| f64::from(t.cpu_ms.unwrap_or(t.wall_ms)))),
|
||||
"over_50_ms": walls.iter().filter(|&&w| w > 50.0).count(),
|
||||
"over_100_ms": walls.iter().filter(|&&w| w > 100.0).count(),
|
||||
});
|
||||
let mut totals: BTreeMap<&str, (f64, f64, usize)> = BTreeMap::new();
|
||||
for t in &r.ticks {
|
||||
for s in &t.spans {
|
||||
let e = totals.entry(s.name).or_default();
|
||||
e.0 += f64::from(s.ms);
|
||||
e.1 += f64::from(s.cpu_ms);
|
||||
e.2 += 1;
|
||||
}
|
||||
}
|
||||
let mut by_time: Vec<_> = totals.into_iter().collect();
|
||||
by_time.sort_by(|a, b| b.1.0.total_cmp(&a.1.0));
|
||||
let tick_ms = mean(&|t| f64::from(t.wall_ms)).max(1e-9);
|
||||
let spans = Value::Array(
|
||||
by_time
|
||||
.iter()
|
||||
.map(|(name, (ms, cpu, seen))| {
|
||||
json!({
|
||||
"name": name,
|
||||
"ms_per_tick": round(ms / n),
|
||||
"cpu_ms_per_tick": round(cpu / n),
|
||||
"share_of_tick": round(ms / n / tick_ms),
|
||||
"ticks_seen": seen,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let mut worst: Vec<&Tick> = r.ticks.iter().collect();
|
||||
worst.sort_by(|a, b| b.wall_ms.total_cmp(&a.wall_ms));
|
||||
let worst = Value::Array(
|
||||
worst
|
||||
.into_iter()
|
||||
.take(WORST)
|
||||
.map(|t| {
|
||||
json!({
|
||||
"ms": round(f64::from(t.wall_ms)),
|
||||
"cpu_ms": t.cpu_ms.map(|c| round(f64::from(c))),
|
||||
"spans": t.spans.iter().take(8).map(|s| json!([s.name, round(f64::from(s.ms))])).collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
(ticks, spans, worst)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The level in place and the parked ones: their chunks and their entities by type.
|
||||
fn report_levels(&self) -> Value {
|
||||
let mut levels = Vec::new();
|
||||
let here = |dim: Dim,
|
||||
world: &horizon_client::world::World,
|
||||
mobs: &[horizon_client::mobs::Mob]| {
|
||||
let mut by_type: BTreeMap<&str, usize> = BTreeMap::new();
|
||||
for m in mobs.iter().filter(|m| !m.removed) {
|
||||
*by_type.entry(&m.kind.id).or_default() += 1;
|
||||
}
|
||||
let mut by_type: Vec<_> = by_type.into_iter().collect();
|
||||
by_type.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
|
||||
json!({
|
||||
"dimension": dim.id(),
|
||||
"chunks": world.chunks.len(),
|
||||
"block_entities": world.block_entities.len(),
|
||||
"mobs": mobs.iter().filter(|m| !m.removed).count(),
|
||||
"mobs_by_type": by_type.into_iter().map(|(k, v)| json!([k, v])).collect::<Vec<_>>(),
|
||||
})
|
||||
};
|
||||
let mut current = here(self.dim, &self.world, &self.mobs);
|
||||
current["item_entities"] = json!(self.dropped.len());
|
||||
current["experience_orbs"] = json!(self.orbs.len());
|
||||
current["projectiles"] = json!(self.creatures.projectiles.len());
|
||||
current["minecarts"] = json!(self.carts.len());
|
||||
current["boats"] = json!(self.boats.len());
|
||||
levels.push(current);
|
||||
for dim in self.levels.parked_dims() {
|
||||
if let (Some(world), Some(mobs)) =
|
||||
(self.levels.parked_world(dim), self.levels.parked_mobs(dim))
|
||||
{
|
||||
levels.push(here(dim, world, mobs));
|
||||
}
|
||||
}
|
||||
Value::Array(levels)
|
||||
}
|
||||
|
||||
/// The report of a finished recording: written to `reports/` in the server's folder, its summary logged.
|
||||
pub(super) fn finish_report(&mut self, r: Recording, view: (u32, u32), max_players: usize) {
|
||||
if !r.spans_were_on {
|
||||
spans::enable(false);
|
||||
}
|
||||
let window_s = r.started.elapsed().as_secs_f64();
|
||||
let (ticks, spans, worst) = tick_summary(&r, window_s);
|
||||
let cpu = horizon_bench::cpu_seconds(std::process::id())
|
||||
.zip(r.cpu_s)
|
||||
.map(|(end, start)| round((end - start) / window_s.max(1e-9)));
|
||||
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
|
||||
let report = json!({
|
||||
"format": 1,
|
||||
"horizon": horizon_client::crash::version(),
|
||||
"minecraft": horizon_core::VANILLA_VERSION,
|
||||
"window_s": round(window_s),
|
||||
"ticks": ticks,
|
||||
"spans": spans,
|
||||
"worst_ticks": worst,
|
||||
"players": players,
|
||||
"levels": self.report_levels(),
|
||||
"memory": { "rss_mb": horizon_bench::rss_bytes(std::process::id()).map(|b| b >> 20) },
|
||||
"cpu_cores_used": cpu,
|
||||
"config": {
|
||||
"view_distance": view.0,
|
||||
"simulation_distance": view.1,
|
||||
"max_players": max_players,
|
||||
},
|
||||
});
|
||||
let text = summary(&report);
|
||||
// The machine's description runs commands: off the tick.
|
||||
std::thread::spawn(move || {
|
||||
let mut report = report;
|
||||
let hw = horizon_bench::Hardware::detect();
|
||||
report["machine"] = json!({
|
||||
"os": hw.os, "arch": hw.arch, "cpu": hw.cpu, "cpu_threads": hw.cpu_threads, "ram_mb": hw.ram_mb,
|
||||
});
|
||||
match write_report(Path::new("reports"), &report, &text) {
|
||||
Ok(path) => log::info!(
|
||||
"Horizon: performance report written to {}\n{text}",
|
||||
path.display()
|
||||
),
|
||||
Err(e) => log::error!("Horizon: could not write the performance report: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// A few lines for the console: the tick rate and times, the five costliest spans, the worst tick.
|
||||
fn summary(r: &Value) -> String {
|
||||
let t = &r["ticks"];
|
||||
let m = &t["mspt"];
|
||||
let mut out = format!(
|
||||
"{} players, TPS {}, MSPT average {} ms, P50 {}, P95 {}, P99 {}, max {} ({} ticks over 50 ms, {} over 100 ms)\n",
|
||||
r["players"],
|
||||
t["tps"],
|
||||
m["average"],
|
||||
m["p50"],
|
||||
m["p95"],
|
||||
m["p99"],
|
||||
m["max"],
|
||||
t["over_50_ms"],
|
||||
t["over_100_ms"],
|
||||
);
|
||||
let spans: Vec<String> = r["spans"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.take(5)
|
||||
.map(|s| {
|
||||
format!(
|
||||
"{} {} ms",
|
||||
s["name"].as_str().unwrap_or(""),
|
||||
s["ms_per_tick"]
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
out += &format!("per tick: {}\n", spans.join(", "));
|
||||
if let Some(w) = r["worst_ticks"].get(0) {
|
||||
out += &format!("worst tick {} ms: {}\n", w["ms"], w["spans"]);
|
||||
}
|
||||
let mobs: u64 = r["levels"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|l| l["mobs"].as_u64())
|
||||
.sum();
|
||||
let chunks: u64 = r["levels"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|l| l["chunks"].as_u64())
|
||||
.sum();
|
||||
out += &format!(
|
||||
"{mobs} mobs, {chunks} chunks, {} MB resident, {} cores of CPU",
|
||||
r["memory"]["rss_mb"], r["cpu_cores_used"]
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
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");
|
||||
let path = dir.join(format!("perf-{stamp}.json"));
|
||||
std::fs::write(&path, serde_json::to_string_pretty(report)? + "\n")?;
|
||||
std::fs::write(path.with_extension("txt"), format!("{text}\n"))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn span(name: &'static str, ms: f32) -> Span {
|
||||
Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms: ms,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticks_and_spans_are_summed_per_tick() {
|
||||
let mut r = Recording {
|
||||
seconds: 1,
|
||||
started: Instant::now(),
|
||||
cpu_s: None,
|
||||
spans_were_on: true,
|
||||
ticks: Vec::new(),
|
||||
};
|
||||
for i in 0..20 {
|
||||
let wall = if i == 7 { 120.0 } else { 10.0 };
|
||||
r.add(
|
||||
wall,
|
||||
Some(wall - 1.0),
|
||||
vec![span("tick_mobs", wall / 2.0), span("tick_lan", 2.0)],
|
||||
);
|
||||
}
|
||||
let (ticks, spans, worst) = tick_summary(&r, 1.0);
|
||||
assert_eq!(ticks["count"], 20);
|
||||
assert_eq!(ticks["tps"], 20.0);
|
||||
assert_eq!(ticks["over_100_ms"], 1);
|
||||
assert_eq!(ticks["mspt"]["max"], 120.0);
|
||||
assert_eq!(spans[0]["name"], "tick_mobs");
|
||||
assert_eq!(spans[0]["ms_per_tick"], 7.75);
|
||||
assert_eq!(spans[1]["ms_per_tick"], 2.0);
|
||||
assert_eq!(worst[0]["ms"], 120.0);
|
||||
assert_eq!(worst[0]["spans"][0], json!(["tick_mobs", 60.0]));
|
||||
let text = summary(
|
||||
&json!({"ticks": ticks, "spans": spans, "worst_ticks": worst, "players": 3,
|
||||
"levels": [{"mobs": 5, "chunks": 9}], "memory": {"rss_mb": 300}, "cpu_cores_used": 1.5}),
|
||||
);
|
||||
assert!(text.starts_with("3 players, TPS 20.0"), "{text}");
|
||||
assert!(
|
||||
text.contains("per tick: tick_mobs 7.75 ms, tick_lan 2.0 ms"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.ends_with("5 mobs, 9 chunks, 300 MB resident, 1.5 cores of CPU"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -422,6 +422,7 @@ impl Game<'_> {
|
||||
position,
|
||||
hover || held,
|
||||
true,
|
||||
held,
|
||||
);
|
||||
}
|
||||
Part::Item { body, rect } => {
|
||||
|
||||
@@ -908,6 +908,12 @@ impl Game<'_> {
|
||||
let Some(id) = self.spawn_mob_at(&kind, front, facing != Direction::Up) else {
|
||||
return Ok((Some(stack), CLICK));
|
||||
};
|
||||
// Entity.applyImplicitComponents: a name from an anvil names the mob.
|
||||
if let Some(name) = stack.custom_name()
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
{
|
||||
m.custom_name = Some(name.to_owned());
|
||||
}
|
||||
self.mob_ambient(id)?;
|
||||
// SpawnEggItemBehavior: the entity was placed by the dispenser.
|
||||
self.post_block_game_event("minecraft:entity_place", [pos.0, pos.1, pos.2], None, None);
|
||||
@@ -1051,8 +1057,12 @@ impl Game<'_> {
|
||||
// EntityType.create rolls a yaw from the level random before the stack config turns it.
|
||||
let _ = self.world.random.next_float();
|
||||
self.next_entity += 1;
|
||||
let stand =
|
||||
let mut stand =
|
||||
horizon_client::entities::ArmorStand::new(self.next_entity, foot, y_rot(facing));
|
||||
// Entity.applyImplicitComponents: a name from an anvil names the stand.
|
||||
if let Some(name) = stack.custom_name() {
|
||||
stand.custom_name = Some(horizon_client::text::Text::literal(name));
|
||||
}
|
||||
self.world.modified.insert((front.0 >> 4, front.2 >> 4));
|
||||
self.armor_stands.push(stand);
|
||||
(shrink(stack), CLICK)
|
||||
|
||||
@@ -219,7 +219,7 @@ fn armor_equipment_loads_without_an_entity_model_document() {
|
||||
assert!(assets.layers.is_empty());
|
||||
assert_eq!(
|
||||
assets.equipment["minecraft:leather"]["humanoid"],
|
||||
("entity/equipment/humanoid/leather".into(), Some(0x123456))
|
||||
vec![("entity/equipment/humanoid/leather".into(), Some(0x123456))]
|
||||
);
|
||||
assert!(!assets.equipment.contains_key("minecraft:invalid"));
|
||||
}
|
||||
|
||||
@@ -247,20 +247,24 @@ impl Game<'_> {
|
||||
}
|
||||
set.entries().map(|(k, _)| k.0).collect()
|
||||
}
|
||||
/// `ServerExplosion.hurtEntities` (player, mobs, items, primed TNT, minecarts);
|
||||
/// the player takes `damage_type`.
|
||||
/// `ServerExplosion.hurtEntities` for the player: damage scaled by
|
||||
/// distance and exposure, through a raised shield (`hurtServer` blocks
|
||||
/// explosions like any attack from where the blast is), then the push
|
||||
/// (less with explosion knockback resistance; none for spectators or a
|
||||
/// creative player flying).
|
||||
///
|
||||
/// Returns the push their client adds from the explosion packet
|
||||
/// (`hitPlayers`), unless a hurt marked their velocity: that goes to them
|
||||
/// whole, this push in it, once their server copy has travelled
|
||||
/// (Entity.markHurt, then ServerEntity.sendChanges), and replaces what the
|
||||
/// packet would add.
|
||||
pub(super) fn explosion_hurt_player(
|
||||
&mut self,
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
damage_type: &str,
|
||||
exploding: Option<u32>,
|
||||
) {
|
||||
) -> Option<[f64; 3]> {
|
||||
let diameter = f64::from(radius * 2.0);
|
||||
// Blast protection: `EXPLOSION_KNOCKBACK_RESISTANCE` (0.15 per level and
|
||||
// piece, so two Blast Protection IV pieces cancel it).
|
||||
@@ -269,9 +273,7 @@ impl Game<'_> {
|
||||
.as_mut()
|
||||
.and_then(|p| p.attribute("minecraft:explosion_knockback_resistance").ok())
|
||||
.unwrap_or(0.0);
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let p = self.player.as_ref()?;
|
||||
let feet = [p.position.x, p.position.y, p.position.z];
|
||||
let dist = ((feet[0] - center[0]).powi(2)
|
||||
+ (feet[1] - center[1]).powi(2)
|
||||
@@ -279,7 +281,7 @@ impl Game<'_> {
|
||||
.sqrt()
|
||||
/ diameter;
|
||||
if dist > 1.0 || p.health.dead_or_dying() {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
// ServerPlayer.onExplosionHit: the blast that hit the player is theirs.
|
||||
self.adv.marks.blast = Some((feet, false));
|
||||
@@ -314,6 +316,7 @@ impl Game<'_> {
|
||||
let (creative, spectator) = (self.creative, self.spectator());
|
||||
let flying = self.player.as_ref().is_some_and(|p| p.flying);
|
||||
let mut outcome = horizon_client::health::BlockOutcome::default();
|
||||
let mut pushed = None;
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& let Some(id) = self.motion.health.damage.id(damage_type)
|
||||
{
|
||||
@@ -340,27 +343,33 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if !spectator && !(creative && flying) {
|
||||
let push = [dir[0] * power, dir[1] * power, dir[2] * power];
|
||||
p.velocity = Vec3::new(
|
||||
p.velocity.x + dir[0] * power,
|
||||
p.velocity.y + dir[1] * power,
|
||||
p.velocity.z + dir[2] * power,
|
||||
p.velocity.x + push[0],
|
||||
p.velocity.y + push[1],
|
||||
p.velocity.z + push[2],
|
||||
);
|
||||
pushed = (!p.health.sync_velocity).then_some(push);
|
||||
}
|
||||
}
|
||||
if raised {
|
||||
self.after_block(outcome, amount, None, false);
|
||||
}
|
||||
pushed
|
||||
}
|
||||
|
||||
/// `ServerExplosion.hurtEntities` (player, mobs, items, primed TNT, minecarts);
|
||||
/// the player takes `damage_type`. Returns the players whose clients add a
|
||||
/// push from the explosion packet, and how much (`explosion_hurt_player`).
|
||||
fn explosion_hurt_entities(
|
||||
&mut self,
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
damage_type: &str,
|
||||
interaction: BlockInteraction,
|
||||
) {
|
||||
) -> Vec<(u32, [f64; 3])> {
|
||||
if radius < 1.0e-5 {
|
||||
return;
|
||||
return Vec::new();
|
||||
}
|
||||
self.explosion_hits_special(center, radius);
|
||||
let diameter = f64::from(radius * 2.0);
|
||||
@@ -394,8 +403,10 @@ impl Game<'_> {
|
||||
})
|
||||
};
|
||||
// The player, then every other player in the level (LAN guests),
|
||||
// each swapped in as the player.
|
||||
self.explosion_hurt_player(center, radius, damage_type, exploding);
|
||||
// each swapped in as the player. In a guest's turn (their hit on an
|
||||
// end crystal) the player is that guest.
|
||||
let own = self.explosion_hurt_player(center, radius, damage_type, exploding);
|
||||
let mut pushed: Vec<_> = self.lan_acting.zip(own).into_iter().collect();
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
let guests: Vec<u32> = host
|
||||
.hub
|
||||
@@ -405,9 +416,12 @@ impl Game<'_> {
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in guests {
|
||||
self.guest_hit_by(&mut host, conn, |g| {
|
||||
g.explosion_hurt_player(center, radius, damage_type, exploding);
|
||||
let push = self.as_guest(&mut host, conn, |g| {
|
||||
g.explosion_hurt_player(center, radius, damage_type, exploding)
|
||||
});
|
||||
if let Some(Some(push)) = push {
|
||||
pushed.push((conn, push));
|
||||
}
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
@@ -552,6 +566,7 @@ impl Game<'_> {
|
||||
// here are neither destroyed nor pushed by the same blast.
|
||||
self.explosion_breaks_frames(center, diameter, interaction);
|
||||
self.explosion_breaks_decorations(center, diameter, interaction);
|
||||
pushed
|
||||
}
|
||||
/// `blast_spares_decorations` for the blast going off now: the mob that
|
||||
/// owns its projectile or explodes itself is the source's entity.
|
||||
@@ -675,7 +690,7 @@ impl Game<'_> {
|
||||
let ctx = horizon_client::sculk::Context::default();
|
||||
self.post_game_event("minecraft:explode", center, ctx);
|
||||
let mut targets = self.exploded_positions(center, radius, resists);
|
||||
self.explosion_hurt_entities(center, radius, damage_type, interaction);
|
||||
let pushed = self.explosion_hurt_entities(center, radius, damage_type, interaction);
|
||||
if interaction != BlockInteraction::Keep {
|
||||
// Util.shuffle(list, level.random)
|
||||
for i in (2..=targets.len()).rev() {
|
||||
@@ -724,7 +739,7 @@ impl Game<'_> {
|
||||
.explosion(center, small, block_count, radius, &|b| {
|
||||
facts.is_air(world.block(b[0], b[1], b[2]))
|
||||
});
|
||||
self.lan_explode(center, small, radius, block_count);
|
||||
self.lan_explode(center, small, radius, block_count, pushed);
|
||||
Ok(())
|
||||
}
|
||||
/// `BlockBehaviour.onExplosionHit` (TNT: `wasExploded` primes a short fuse).
|
||||
|
||||
@@ -21,6 +21,12 @@ use horizon_core::{
|
||||
|
||||
pub(super) const FIREWORK_EXPLODE_EVENT: i8 = 17;
|
||||
|
||||
/// What `ProjectileUtil.getHitResultOnMoveVector` found along a rocket's move.
|
||||
enum RocketHit {
|
||||
Entity(Who),
|
||||
Block,
|
||||
}
|
||||
|
||||
pub(super) struct Rocket {
|
||||
/// The entity id a server tracks it by (0 until it is tracked).
|
||||
pub id: u32,
|
||||
@@ -43,8 +49,7 @@ pub(super) struct Rocket {
|
||||
/// `DATA_ATTACHED_TO_TARGET`: the server entity the rocket follows;
|
||||
/// boosting happens when it is the local player.
|
||||
pub(super) attached_to: Option<u32>,
|
||||
/// `DATA_SHOT_AT_ANGLE` (dispensers): constant velocity, exploding on
|
||||
/// the first block it hits.
|
||||
/// `DATA_SHOT_AT_ANGLE` (dispensers, crossbows): constant velocity.
|
||||
pub(super) angled: bool,
|
||||
/// `DATA_ID_FIREWORKS_ITEM`: the stack it was launched from.
|
||||
pub(super) item: ItemStack,
|
||||
@@ -118,9 +123,8 @@ impl Rocket {
|
||||
}
|
||||
}
|
||||
/// `FireworkRocketEntity.tick` for a rocket attached to no entity: speed
|
||||
/// up (an angled one keeps its motion) and rise, then move. True when a
|
||||
/// block stopped it.
|
||||
fn free_flight(&mut self, scene: &mut SceneCollisions) -> bool {
|
||||
/// up (an angled one keeps its motion) and rise, then move.
|
||||
fn free_flight(&mut self, scene: &mut SceneCollisions) {
|
||||
let h = 0.125;
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
|
||||
@@ -132,11 +136,8 @@ impl Rocket {
|
||||
);
|
||||
let wanted = self.next_motion();
|
||||
let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) else {
|
||||
return false;
|
||||
return;
|
||||
};
|
||||
let blocked = (moved.x - wanted.x).abs() > 1e-7
|
||||
|| (moved.y - wanted.y).abs() > 1e-7
|
||||
|| (moved.z - wanted.z).abs() > 1e-7;
|
||||
let horizontal = (moved.x - wanted.x).abs() > 1e-7 || (moved.z - wanted.z).abs() > 1e-7;
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
@@ -148,7 +149,6 @@ impl Rocket {
|
||||
} else {
|
||||
wanted
|
||||
};
|
||||
blocked
|
||||
}
|
||||
/// Position and motion (the dispenser parity fixture).
|
||||
pub(super) fn motion(&self) -> (Vec3, Vec3) {
|
||||
@@ -334,15 +334,14 @@ impl Game<'_> {
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
if r.free_flight(&mut scene) && r.angled {
|
||||
// hitTargetOrDeflectSelf: explode on the block.
|
||||
r.life = r.lifetime;
|
||||
}
|
||||
r.free_flight(&mut scene);
|
||||
}
|
||||
// hitTargetOrDeflectSelf on an entity: a breeze turns it back
|
||||
// (ProjectileDeflection.REVERSE), anything else sets it off.
|
||||
// onHitBlock: only a rocket with explosions (hasExplosion) goes
|
||||
// off on a block; a plain one flies on to its lifetime.
|
||||
match hit {
|
||||
Some(Who::Mob(id))
|
||||
Some(RocketHit::Entity(Who::Mob(id)))
|
||||
if self
|
||||
.mobs
|
||||
.iter()
|
||||
@@ -350,8 +349,9 @@ impl Game<'_> {
|
||||
{
|
||||
r.velocity = r.velocity.scale(-0.5);
|
||||
}
|
||||
Some(_) => r.life = r.lifetime,
|
||||
None => {}
|
||||
Some(RocketHit::Entity(_)) => r.life = r.lifetime,
|
||||
Some(RocketHit::Block) if !r.explosions.is_empty() => r.life = r.lifetime,
|
||||
Some(RocketHit::Block) | None => {}
|
||||
}
|
||||
let at = [r.position.x, r.position.y, r.position.z];
|
||||
if r.life == 0 {
|
||||
@@ -510,25 +510,26 @@ impl Game<'_> {
|
||||
targets.extend(self.lan_projectile_targets());
|
||||
targets
|
||||
}
|
||||
/// `ProjectileUtil.getHitResultOnMoveVector`'s entity half: the nearest
|
||||
/// target on the move, short of the block it clips, each box grown by
|
||||
/// `computeMargin` (`tickCount` is the rocket's life plus one).
|
||||
/// `ProjectileUtil.getHitResultOnMoveVector`: the nearest target on the
|
||||
/// move, short of the block it clips (`ClipContext.Block.COLLIDER`), each
|
||||
/// box grown by `computeMargin` (`tickCount` is the rocket's life plus
|
||||
/// one); else that block.
|
||||
fn rocket_struck(
|
||||
&self,
|
||||
targets: &[(Who, Aabb)],
|
||||
r: &Rocket,
|
||||
motion: Vec3,
|
||||
skip: Option<Who>,
|
||||
) -> Option<Who> {
|
||||
) -> Option<RocketHit> {
|
||||
let from = r.position;
|
||||
let mut to = from.add_vector(motion);
|
||||
let w = self.mob_world_base(&[]);
|
||||
if let Some((at, ..)) = (super::creatures::ProjWorld {
|
||||
let block = (super::creatures::ProjWorld {
|
||||
w: &w,
|
||||
targets: Vec::new(),
|
||||
})
|
||||
.clip(from, to)
|
||||
{
|
||||
.clip(from, to);
|
||||
if let Some((at, ..)) = block {
|
||||
to = at;
|
||||
}
|
||||
let margin = (f64::from(r.life - 1) / 20.0).clamp(0.0, 0.3);
|
||||
@@ -542,7 +543,8 @@ impl Game<'_> {
|
||||
Some((*who, from.distance_squared(at)))
|
||||
})
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(who, _)| who)
|
||||
.map(|(who, _)| RocketHit::Entity(who))
|
||||
.or(block.map(|_| RocketHit::Block))
|
||||
}
|
||||
/// FireworkRocketEntity.handleEntityEvent: use the synchronized item data.
|
||||
pub(super) fn remote_firework_explosion(&mut self, id: i32) {
|
||||
|
||||
@@ -476,7 +476,15 @@ impl Game<'_> {
|
||||
self.particles
|
||||
.send_particles(&options, at, spread, speed, count);
|
||||
// The players a server serves are sent them too.
|
||||
self.lan_send_particles(&options, at, spread, speed, count);
|
||||
self.lan_send_particles(
|
||||
self.dim.id(),
|
||||
&options,
|
||||
None,
|
||||
at,
|
||||
spread,
|
||||
speed,
|
||||
count as i32,
|
||||
);
|
||||
}
|
||||
/// `FishingHook.tick`.
|
||||
pub(super) fn tick_fishing(&mut self) {
|
||||
|
||||
@@ -138,7 +138,15 @@ impl Game<'_> {
|
||||
&& horizon_client::save::number(v.get("Invisible")).unwrap_or(0) != 0
|
||||
})
|
||||
});
|
||||
let pos = std::array::from_fn(|i| hit.pos[i] + hit.face[i]);
|
||||
let pos: [i32; 3] = std::array::from_fn(|i| hit.pos[i] + hit.face[i]);
|
||||
// `ItemFrameItem.mayPlace`: `Level.isInsideBuildHeight(blockPos)`, so a frame is not
|
||||
// placed on the top face of the highest block or under the lowest one.
|
||||
if !(horizon_client::chunk::MIN_Y
|
||||
..horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT)
|
||||
.contains(&pos[1])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let frame = ItemFrame {
|
||||
id: self.next_entity + 1,
|
||||
pos,
|
||||
|
||||
@@ -73,6 +73,105 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `ItemStack.canBreakBlockInAdventureMode` / `canPlaceOnBlockInAdventureMode`
|
||||
/// for the main-hand stack: some predicate of its `can_break` or
|
||||
/// `can_place_on` component (`AdventureModePredicate`: one block predicate
|
||||
/// or a list) matches the block at `pos`. Predicates with `nbt` or
|
||||
/// `components` need the block entity's data and never match.
|
||||
fn adventure_predicate(&self, component: &str, pos: [i32; 3]) -> bool {
|
||||
use horizon_world::nbt::Tag;
|
||||
let Some(tag) = self
|
||||
.held()
|
||||
.and_then(|s| s.data())
|
||||
.and_then(|d| d.other(component))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let name = &self.res.facts().get(state).name;
|
||||
let properties = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.map(|s| &s.properties);
|
||||
let matches = |p: &Tag| {
|
||||
if p.get("nbt").is_some() || p.get("components").is_some() {
|
||||
return false;
|
||||
}
|
||||
let block_ok = match p.get("blocks") {
|
||||
None => true,
|
||||
Some(Tag::String(id)) => self.block_in_holder_set(&id.to_string_lossy(), name),
|
||||
Some(Tag::List(ids)) => ids.iter().any(|id| match id {
|
||||
Tag::String(id) => self.block_in_holder_set(&id.to_string_lossy(), name),
|
||||
_ => false,
|
||||
}),
|
||||
Some(_) => false,
|
||||
};
|
||||
// StatePropertiesPredicate: every property exact, or in a min/max range.
|
||||
let state_ok = match p.get("state") {
|
||||
None => true,
|
||||
Some(Tag::Compound(want)) => want.iter().all(|(key, matcher)| {
|
||||
let Some(have) = properties.and_then(|p| p.get(&key.to_string_lossy())) else {
|
||||
return false;
|
||||
};
|
||||
match matcher {
|
||||
Tag::String(value) => *have == value.to_string_lossy(),
|
||||
Tag::Compound(_) => {
|
||||
let bound = |k: &str| match matcher.get(k) {
|
||||
Some(Tag::String(v)) => Some(v.to_string_lossy()),
|
||||
_ => None,
|
||||
};
|
||||
let in_range = |have: &str, v: Option<String>, above: bool| {
|
||||
v.is_none_or(|v| match (have.parse::<i64>(), v.parse::<i64>()) {
|
||||
(Ok(h), Ok(b)) if above => h >= b,
|
||||
(Ok(h), Ok(b)) => h <= b,
|
||||
_ => false,
|
||||
})
|
||||
};
|
||||
in_range(have, bound("min"), true)
|
||||
&& in_range(have, bound("max"), false)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}),
|
||||
Some(_) => false,
|
||||
};
|
||||
block_ok && state_ok
|
||||
};
|
||||
match tag {
|
||||
Tag::List(list) => list.iter().any(matches),
|
||||
single => matches(single),
|
||||
}
|
||||
}
|
||||
/// A `HolderSet<Block>` entry: a block id or a `#tag`.
|
||||
fn block_in_holder_set(&self, entry: &str, block: &str) -> bool {
|
||||
let namespaced = |id: &str| {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
};
|
||||
match entry.strip_prefix('#') {
|
||||
Some(tag) => self.res.items().block_in_tag(&namespaced(tag), block),
|
||||
None => namespaced(entry) == block,
|
||||
}
|
||||
}
|
||||
/// `Player.blockActionRestricted` for the block at `pos`: spectators never
|
||||
/// act on blocks, adventure players only with a main-hand item whose
|
||||
/// `can_break` matches it.
|
||||
pub(super) fn block_action_restricted(&self, pos: [i32; 3]) -> bool {
|
||||
if self.spectator() {
|
||||
return true;
|
||||
}
|
||||
!self.may_build() && !self.adventure_predicate("minecraft:can_break", pos)
|
||||
}
|
||||
/// `ItemStack.useOn` in adventure mode: the used item's `can_place_on`
|
||||
/// matches the clicked block (`Player.mayUseItemAt`).
|
||||
pub(super) fn can_place_on_in_adventure(&self, clicked: [i32; 3]) -> bool {
|
||||
self.adventure_predicate("minecraft:can_place_on", clicked)
|
||||
}
|
||||
/// `ServerPlayer.setGameMode(SPECTATOR)`: `stopRiding`.
|
||||
fn dismount_for_spectator(&mut self) {
|
||||
let Some(id) = self.riding else {
|
||||
|
||||
@@ -93,7 +93,63 @@ pub(super) fn matches_for_swap(a: Option<&ItemStack>, b: Option<&ItemStack>) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// `AttackRange.isInRange(attacker, box, buffer)` with the reach of
|
||||
/// `attack_reach` (or `AttackRange.defaultFor`'s, from the entity interaction
|
||||
/// range): the eye's distance to the box, inclusive.
|
||||
fn in_attack_range(distance: f64, (min, max, margin): (f32, f32, f32), buffer: f64) -> bool {
|
||||
distance >= f64::from(min - margin) - buffer && distance <= f64::from(max + margin) + buffer
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `getAttributeValue` of an interaction range of the player in place (a
|
||||
/// guest swapped in on a server): creative's modifiers are on it
|
||||
/// (`update_player_attributes`), as are `/attribute`'s and the items'.
|
||||
fn interaction_range(&mut self, attribute: &str, survival: f64, creative: f64) -> f64 {
|
||||
let otherwise = if self.creative { creative } else { survival };
|
||||
self.player
|
||||
.as_mut()
|
||||
.and_then(|p| p.attribute(attribute).ok())
|
||||
.unwrap_or(otherwise)
|
||||
}
|
||||
|
||||
/// ServerGamePacketListenerImpl.handleAttack's
|
||||
/// `Player.isWithinAttackRange(mainHandItem, box, 3.0)` for the player in
|
||||
/// place: the held item's `attack_range`, else `AttackRange.defaultFor`
|
||||
/// (0 to the entity interaction range, no margin). Out of range, vanilla
|
||||
/// drops the attack without a word.
|
||||
pub(super) fn within_attack_range(&mut self, target: &Aabb) -> bool {
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return false;
|
||||
};
|
||||
let range = self.held().and_then(|stack| {
|
||||
self.pack
|
||||
.item_renderer
|
||||
.models
|
||||
.components(&horizon_client::item_render::stack_view(stack))
|
||||
.get("minecraft:attack_range")
|
||||
.cloned()
|
||||
});
|
||||
let reach = match &range {
|
||||
Some(r) => attack_reach(r, self.creative),
|
||||
None => {
|
||||
let range = self.interaction_range("minecraft:entity_interaction_range", 3.0, 5.0);
|
||||
(0.0, range as f32, 0.0)
|
||||
}
|
||||
};
|
||||
let distance = target.distance_squared_to_point(v3(eye)).sqrt();
|
||||
in_attack_range(distance, reach, 3.0)
|
||||
}
|
||||
|
||||
/// `Player.isWithinEntityInteractionRange(box, buffer)` for the player in
|
||||
/// place (ServerGamePacketListenerImpl.handleInteract, with 3).
|
||||
pub(super) fn within_entity_interaction_range(&mut self, target: &Aabb, buffer: f64) -> bool {
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return false;
|
||||
};
|
||||
let range = self.interaction_range("minecraft:entity_interaction_range", 3.0, 5.0) + buffer;
|
||||
target.distance_squared_to_point(v3(eye)) < range * range
|
||||
}
|
||||
|
||||
/// A slot of the inventory (`OFFHAND_SLOT` for the off hand) was set to
|
||||
/// a new stack: the hand holding it compares it as another `ItemStack`
|
||||
/// (`shows_held_stack`) until the hand shows the held stack again.
|
||||
@@ -616,8 +672,8 @@ impl HandWorld for GameHands<'_, '_> {
|
||||
fn attack_entity(&mut self) -> bool {
|
||||
self.game.attack()
|
||||
}
|
||||
fn block_action_restricted(&mut self, _: [i32; 3]) -> bool {
|
||||
!self.game.may_build()
|
||||
fn block_action_restricted(&mut self, pos: [i32; 3]) -> bool {
|
||||
self.game.block_action_restricted(pos)
|
||||
}
|
||||
fn within_border(&mut self, _: [i32; 3]) -> bool {
|
||||
true
|
||||
@@ -717,6 +773,24 @@ mod tests {
|
||||
assert_eq!(reach_pick(&boxes, eye, look, Vec3::ZERO, reach, wall), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_server_takes_attacks_within_vanilla_reach_only() {
|
||||
// handleAttack: AttackRange.defaultFor (0 to the entity interaction
|
||||
// range, no margin) plus 3, inclusive: 6 in survival, 8 in creative.
|
||||
let survival = (0.0, 3.0, 0.0);
|
||||
assert!(in_attack_range(6.0, survival, 3.0));
|
||||
assert!(!in_attack_range(6.01, survival, 3.0));
|
||||
assert!(!in_attack_range(50.0, survival, 3.0));
|
||||
let creative = (0.0, 5.0, 0.0);
|
||||
assert!(in_attack_range(8.0, creative, 3.0));
|
||||
assert!(!in_attack_range(8.01, creative, 3.0));
|
||||
// A spear's own range, with its margin, from the server's side too.
|
||||
let spear = serde_json::json!({"min_reach": 2.0, "max_reach": 4.5, "hitbox_margin": 0.125});
|
||||
let reach = attack_reach(&spear, false);
|
||||
assert!(in_attack_range(7.625, reach, 3.0));
|
||||
assert!(!in_attack_range(7.7, reach, 3.0));
|
||||
}
|
||||
|
||||
fn stack(item: &str, count: u32, damage: u32) -> Option<ItemStack> {
|
||||
Some(ItemStack {
|
||||
item: item.into(),
|
||||
|
||||
@@ -84,7 +84,7 @@ impl Game<'_> {
|
||||
&s,
|
||||
self.pack.assets.registry,
|
||||
self.res.biomes(),
|
||||
super::Dim::Overworld.shape(),
|
||||
self.level_shapes.overworld,
|
||||
)
|
||||
})
|
||||
{
|
||||
|
||||
@@ -469,6 +469,7 @@ impl Game<'_> {
|
||||
view_distance,
|
||||
simulation_distance: self.settings.simulation_distance.max(2),
|
||||
dimension: self.dim.id().to_owned(),
|
||||
levels: self.level_shapes,
|
||||
seed: self.level.seed,
|
||||
max_players: MAX_PLAYERS,
|
||||
// No player of its own: the hub runs without a host.
|
||||
@@ -486,6 +487,7 @@ impl Game<'_> {
|
||||
(uuid != owner && published.published.load(Ordering::Relaxed) == 0)
|
||||
.then(|| translatable("multiplayer.disconnect.not_whitelisted", &[]))
|
||||
})),
|
||||
levels: self.level_shapes,
|
||||
// IntegratedServer.initServer: the MOTD names the owner and the world.
|
||||
..NetConfig::lan(
|
||||
0,
|
||||
|
||||
@@ -93,6 +93,9 @@ pub(super) struct LanHost {
|
||||
pub commands: Vec<super::lan_actions::GuestCommand>,
|
||||
/// Mods' quiet commands waiting to run a few a tick (game/lan_quiet.rs).
|
||||
pub quiet: std::collections::VecDeque<super::lan_quiet::QuietBatch>,
|
||||
/// Native mods' commands and the level each needs, once asked
|
||||
/// (game/native_server.rs).
|
||||
pub native_commands: Option<HashMap<String, u8>>,
|
||||
/// The entities the guests track, described again every tick
|
||||
/// (`lan_entities`) over last tick's states.
|
||||
states: lan::entity::States,
|
||||
@@ -847,6 +850,14 @@ fn mob_meta(
|
||||
}
|
||||
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// Bogged.DATA_SHEARED (the first entry after Mob's): the mushrooms
|
||||
// on its head are gone.
|
||||
"minecraft:bogged" => {
|
||||
if e.sheared {
|
||||
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two).
|
||||
"minecraft:wandering_trader" => unhappy_counter(m, meta, defaults),
|
||||
// Hoglin.DATA_IMMUNE_TO_ZOMBIFICATION (after AgeableMob's two) and
|
||||
@@ -1147,12 +1158,14 @@ impl Game<'_> {
|
||||
view_distance: self.settings.render_distance.clamp(2, 32),
|
||||
simulation_distance: self.settings.render_distance.clamp(2, 32),
|
||||
dimension: self.dim.id().to_owned(),
|
||||
levels: self.level_shapes,
|
||||
seed: self.level.seed,
|
||||
max_players: MAX_PLAYERS,
|
||||
dedicated: false,
|
||||
integrated: false,
|
||||
};
|
||||
let net = NetConfig {
|
||||
levels: self.level_shapes,
|
||||
online: horizon_client::lan::OnlineMode::lan(),
|
||||
..NetConfig::lan(settings.port, motd, MAX_PLAYERS)
|
||||
};
|
||||
@@ -1229,6 +1242,7 @@ impl Game<'_> {
|
||||
border_sent: None,
|
||||
commands: Vec::new(),
|
||||
quiet: std::collections::VecDeque::new(),
|
||||
native_commands: None,
|
||||
states: Default::default(),
|
||||
});
|
||||
self.audio.capture = Some(Vec::new());
|
||||
@@ -1283,15 +1297,53 @@ impl Game<'_> {
|
||||
/// Save a guest when they leave or the world closes: an integrated
|
||||
/// server's owner with the world (`save_game`), anyone else in their file.
|
||||
pub(super) fn save_guest(&mut self, host: &LanHost, guest: &horizon_client::lan::guest::Guest) {
|
||||
// The vehicle the guest rides, for the file that names it.
|
||||
let vehicle = self.root_vehicle_uuid(guest.vehicle);
|
||||
if host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) {
|
||||
if guest.placed {
|
||||
playerdata::store(guest, &mut self.level);
|
||||
playerdata::store(guest, &mut self.level, vehicle);
|
||||
}
|
||||
} else {
|
||||
playerdata::save(guest, &self.world_root);
|
||||
playerdata::save(guest, &self.world_root, vehicle);
|
||||
}
|
||||
self.save_guest_records(host, guest);
|
||||
}
|
||||
/// `ServerPlayer.loadAndSpawnParentVehicle`: a guest whose file names the
|
||||
/// vehicle it rode rides it again, once the vehicle is in the world.
|
||||
fn restore_guest_vehicles(&mut self, host: &mut LanHost) {
|
||||
let waiting: Vec<(usize, [i32; 4])> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, g)| Some((i, g.saved_vehicle?)))
|
||||
.filter(|(i, _)| {
|
||||
let g = &host.hub.guests[*i];
|
||||
let chunk = (
|
||||
(g.position[0].floor() as i32).div_euclid(16),
|
||||
(g.position[2].floor() as i32).div_euclid(16),
|
||||
);
|
||||
g.placed
|
||||
&& g.vehicle.is_none()
|
||||
&& g.dimension == self.dim.id()
|
||||
&& g.sent_chunks.contains(&chunk)
|
||||
})
|
||||
.collect();
|
||||
for (i, uuid) in waiting {
|
||||
let raw = host.hub.guests[i].saved.clone();
|
||||
let Some(id) = self.restore_saved_vehicle(uuid, raw.as_ref()) else {
|
||||
continue;
|
||||
};
|
||||
let g = &mut host.hub.guests[i];
|
||||
if let Some(horizon_world::nbt::Tag::Compound(p)) = &mut g.saved {
|
||||
p.remove(&"RootVehicle".into());
|
||||
}
|
||||
g.vehicle = Some(id);
|
||||
g.saved_vehicle = None;
|
||||
// Pair the stationary restored entities before their passenger packet.
|
||||
g.moved = true;
|
||||
}
|
||||
}
|
||||
/// The file name of a player's advancements and statistics: an
|
||||
/// integrated server's owner keeps single player's.
|
||||
fn records_uuid(&self, host: &LanHost, uuid: u128) -> Option<[i32; 4]> {
|
||||
@@ -1362,14 +1414,28 @@ impl Game<'_> {
|
||||
}
|
||||
/// The integrated server's owner into the world's player, for `Level::write`.
|
||||
pub(super) fn store_owner(&mut self) {
|
||||
let Some(uuid) = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.and_then(|h| h.owner.as_ref())
|
||||
.map(|o| o.uuid)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let riding = self.lan.as_ref().and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.uuid == uuid && g.placed)
|
||||
.and_then(|g| g.vehicle)
|
||||
});
|
||||
// The vehicle the owner rides, by the UUID it is saved with.
|
||||
let vehicle = self.root_vehicle_uuid(riding);
|
||||
let Some(host) = &self.lan else {
|
||||
return;
|
||||
};
|
||||
let Some(uuid) = host.owner.as_ref().map(|o| o.uuid) else {
|
||||
return;
|
||||
};
|
||||
if let Some(g) = host.hub.guests.iter().find(|g| g.uuid == uuid && g.placed) {
|
||||
playerdata::store(g, &mut self.level);
|
||||
playerdata::store(g, &mut self.level, vehicle);
|
||||
self.save_guest_records(host, g);
|
||||
}
|
||||
}
|
||||
@@ -1397,6 +1463,25 @@ impl Game<'_> {
|
||||
host.settings.force_game_mode = force;
|
||||
host.hub.config.guest_commands = effective;
|
||||
host.hub.config.force_game_mode = force;
|
||||
// MinecraftServer.enforceGameTypeForPlayers: the guests online
|
||||
// take the default mode (`setGameMode` does nothing when it is
|
||||
// already theirs). A world open to LAN is an IntegratedServer,
|
||||
// whose `updateGameModeBasedOnPermission` leaves out the players
|
||||
// who have game master permission; a dedicated server does not.
|
||||
if force {
|
||||
let mode = host.hub.config.default_game_mode;
|
||||
let spares_operators = !host.hub.config.dedicated || host.hub.config.integrated;
|
||||
let stale: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.game_mode != mode && !(spares_operators && g.permission >= 2))
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in stale {
|
||||
host.hub.set_game_mode(conn, mode);
|
||||
}
|
||||
}
|
||||
host.hub.resend_commands();
|
||||
}
|
||||
}
|
||||
@@ -1534,6 +1619,7 @@ impl Game<'_> {
|
||||
};
|
||||
// What the tick sends a guest goes to its connection at the end.
|
||||
let holding = lan::Holding::start();
|
||||
self.register_native_commands(&mut host);
|
||||
// The host's own commands' (and command blocks') players moving
|
||||
// to another level, and their broadcastToAdmins lines.
|
||||
if !self.level_teleports.is_empty() {
|
||||
@@ -1553,6 +1639,8 @@ impl Game<'_> {
|
||||
m.rider = None;
|
||||
}
|
||||
}
|
||||
// A guest whose file names the vehicle it rode rides it again.
|
||||
self.restore_guest_vehicles(&mut host);
|
||||
// A dedicated server runs the hub without a host player.
|
||||
if state.is_some() || host.hub.config.dedicated {
|
||||
let has_guests = !host.hub.guests.is_empty();
|
||||
@@ -1763,6 +1851,14 @@ impl Game<'_> {
|
||||
.take()
|
||||
.expect("the hub is back after the quiet commands");
|
||||
}
|
||||
if !host.hub.native_menu_events.is_empty() {
|
||||
self.lan = Some(host);
|
||||
self.run_native_menu_events();
|
||||
host = self
|
||||
.lan
|
||||
.take()
|
||||
.expect("the hub is back after the menu events");
|
||||
}
|
||||
self.lan_flush_effects(&mut host);
|
||||
if !paused {
|
||||
self.tick_guests(&mut host);
|
||||
@@ -1945,6 +2041,11 @@ impl Game<'_> {
|
||||
if event == 21 && self.options.execution_role.is_client() {
|
||||
self.start_guardian_sound(id as u32);
|
||||
}
|
||||
// Event 53: a mob sliding down a honey block sheds honey particles
|
||||
// (HoneyBlock.showSlideParticles), drawn by the client that runs the world.
|
||||
if event == 53 && self.options.execution_role.is_client() {
|
||||
self.honey_slide_particles(at);
|
||||
}
|
||||
// Event 2: a mob's spear hit felt; the same window hears its own mob's hit.
|
||||
if event == 2 && self.options.execution_role.is_client() {
|
||||
self.remote_kinetic_hit(id);
|
||||
|
||||
@@ -954,6 +954,8 @@ impl Game<'_> {
|
||||
};
|
||||
horizon_client::containers::display_name(custom(k), key)
|
||||
}
|
||||
// Opened by a mod (`native_open_menu`), never here.
|
||||
Slots::Native => return false,
|
||||
};
|
||||
let Some(items) = host
|
||||
.hub
|
||||
@@ -1138,7 +1140,7 @@ impl Game<'_> {
|
||||
let _ = g.break_block(pos);
|
||||
return true;
|
||||
}
|
||||
if g.may_build() {
|
||||
if !g.block_action_restricted(pos) {
|
||||
let _ = g.attack_block(pos);
|
||||
g.mining = Some(Mining {
|
||||
pos,
|
||||
@@ -1255,12 +1257,29 @@ impl Game<'_> {
|
||||
}
|
||||
S::UseItemOn(p) => {
|
||||
host.hub.ack(conn, p.sequence);
|
||||
// handleUseItemOn: within reach, and the hit point within the block.
|
||||
// handleUseItemOn: within reach, then the hit point within
|
||||
// a block of the block's centre (logged when it isn't).
|
||||
if !host.hub.guests[gi].may_reach_block(p.pos) {
|
||||
return;
|
||||
}
|
||||
let inside = p
|
||||
.cursor
|
||||
.iter()
|
||||
.all(|&c| (f64::from(c) - 0.5).abs() < 1.0000001);
|
||||
if !(inside && host.hub.guests[gi].may_reach_block(p.pos)) {
|
||||
if !inside {
|
||||
let d = horizon_client::java_text::double_to_string;
|
||||
let at = |k: usize| d(f64::from(p.pos[k]) + f64::from(p.cursor[k]));
|
||||
let line = format!(
|
||||
"Rejecting UseItemOnPacket from {}: Location ({}, {}, {}) too far away from hit block BlockPos{{x={}, y={}, z={}}}.",
|
||||
host.hub.guests[gi].name,
|
||||
at(0),
|
||||
at(1),
|
||||
at(2),
|
||||
p.pos[0],
|
||||
p.pos[1],
|
||||
p.pos[2]
|
||||
);
|
||||
self.lan_log_event(horizon_client::lan::hub::HostEvent::Warn(line));
|
||||
return;
|
||||
}
|
||||
let face = direction(p.face);
|
||||
@@ -1319,17 +1338,37 @@ impl Game<'_> {
|
||||
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
|
||||
let g = &mut host.hub.guests[gi];
|
||||
(g.yaw, g.pitch) = (p.yaw, p.pitch);
|
||||
let swung = self.as_guest(host, conn, |g| {
|
||||
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
|
||||
let used = self.as_guest(host, conn, |g| {
|
||||
// A horn's sound, inline instruments included.
|
||||
let horn = g.with_hand(hand, |g| {
|
||||
let held = g.held()?;
|
||||
g.res.items().instrument(held).map(|i| i.sound)
|
||||
});
|
||||
g.guest_use = Some(GuestUse::Item(hand));
|
||||
let swings = g.swings_started;
|
||||
let _ = g.use_item();
|
||||
g.guest_use = None;
|
||||
g.swings_started != swings
|
||||
(g.swings_started != swings, horn)
|
||||
});
|
||||
let (swung, horn) = match used {
|
||||
Some((swung, horn)) => (Some(swung), horn),
|
||||
None => (None, None),
|
||||
};
|
||||
self.lan_swing_back(host, conn, swung);
|
||||
self.guest_instrument_sound(host, conn, heard, horn);
|
||||
}
|
||||
S::Attack(p) => self.guest_attack(host, conn, p.id),
|
||||
S::Interact(p) => {
|
||||
// handleInteract: within the entity interaction range plus 3
|
||||
// of the target's box, or nothing happens.
|
||||
if let Some(target) = self.guest_target_box(host, gi, p.id)
|
||||
&& self.as_guest(host, conn, |g| {
|
||||
g.within_entity_interaction_range(&target, 3.0)
|
||||
}) != Some(true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let before = host.hub.guests[gi].vehicle;
|
||||
if self
|
||||
.look_at_entity(host, gi, p.id, Some(p.location))
|
||||
@@ -1917,6 +1956,42 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Level.getEntityOrPart(id).getBoundingBox()` for a guest's attack or
|
||||
/// interaction: the host, another player, a mob, a vehicle or object, a
|
||||
/// dragon part or crystal, a projectile. None for an id the level doesn't
|
||||
/// have (the handlers then find nothing either).
|
||||
fn guest_target_box(&self, host: &LanHost, gi: usize, id: i32) -> Option<Aabb> {
|
||||
let conn = host.hub.guests[gi].conn;
|
||||
if id == host.host_entity {
|
||||
return self.player.as_ref().map(|p| p.bounds());
|
||||
}
|
||||
if let Some(g) = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.entity_id == id && g.conn != conn && host.hub.here(g))
|
||||
{
|
||||
return Some(g.bounds());
|
||||
}
|
||||
if let Some(m) = self.mobs.iter().find(|m| m.id as i32 == id) {
|
||||
return Some(m.bounds());
|
||||
}
|
||||
if let Some((lo, hi, _)) = self.object_target(id) {
|
||||
return Some(Aabb::new(
|
||||
Vec3::new(lo[0], lo[1], lo[2]),
|
||||
Vec3::new(hi[0], hi[1], hi[2]),
|
||||
));
|
||||
}
|
||||
if let Some(b) = self.tracked_special_box(id) {
|
||||
return Some(b);
|
||||
}
|
||||
self.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
.find(|p| p.id != 0 && p.id as i32 == id)
|
||||
.map(|p| p.bounds())
|
||||
}
|
||||
|
||||
fn guest_attack(&mut self, host: &mut LanHost, conn: u32, id: i32) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
@@ -1924,6 +1999,13 @@ impl Game<'_> {
|
||||
if host.hub.guests[gi].spectator() {
|
||||
return;
|
||||
}
|
||||
// handleAttack: `isWithinAttackRange(mainHandItem, box, 3.0)` before
|
||||
// anything else, players, mobs, objects and fireballs alike.
|
||||
if let Some(target) = self.guest_target_box(host, gi, id)
|
||||
&& self.as_guest(host, conn, |g| g.within_attack_range(&target)) != Some(true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(victim) = Self::pvp_victim(host, conn, id) {
|
||||
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike())
|
||||
&& self.pvp_land(host, victim, &strike)
|
||||
@@ -2014,11 +2096,26 @@ impl Game<'_> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A native mod's command, for every player its level allows,
|
||||
// whether the world allows commands or not (game/native_server.rs).
|
||||
let integrated = host.hub.config.integrated;
|
||||
if let Some(level) = Self::native_command_level(host, command.trim_start_matches('/'))
|
||||
&& let Some(permission) = host.hub.guest(conn).map(|g| g.permission)
|
||||
&& permission >= level
|
||||
{
|
||||
host.commands.push(GuestCommand {
|
||||
conn,
|
||||
command: command.trim_start_matches('/').to_owned(),
|
||||
permission,
|
||||
integrated,
|
||||
quiet: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// An integrated server runs every player's commands at their own
|
||||
// permission level (`CommandSourceStack.permissions`); a LAN world
|
||||
// follows its Allow Commands, and a dedicated server leaves it to
|
||||
// the ops list (the guest's permission level).
|
||||
let integrated = host.hub.config.integrated;
|
||||
let allowed = self.options.dedicated.is_some() || self.allow_commands();
|
||||
if !integrated && (!host.settings.guest_commands || !allowed) {
|
||||
host.hub.send_to(
|
||||
@@ -2072,7 +2169,9 @@ impl Game<'_> {
|
||||
self.lan = Some(host);
|
||||
self.chat_capture = Some(Vec::new());
|
||||
let level = std::mem::replace(&mut self.ui.registries.permission, c.permission);
|
||||
let ok = if c.quiet {
|
||||
let ok = if self.perform_native_command(c.conn, c.permission, &c.command) {
|
||||
true
|
||||
} else if c.quiet {
|
||||
self.run_command_quietly(&c.command)
|
||||
} else {
|
||||
self.run_command(&c.command)
|
||||
@@ -3498,6 +3597,30 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `InstrumentItem.play`'s `level.playSound(player, ...)`: the others hear
|
||||
/// a guest's horn now, the guest's own client played it as it used it.
|
||||
fn guest_instrument_sound(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
heard: usize,
|
||||
horn: Option<String>,
|
||||
) {
|
||||
let (Some(horn), Some(capture)) = (horn, self.audio.capture.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
let mut i = heard.min(capture.len());
|
||||
while i < capture.len() {
|
||||
if capture[i].0 == horn {
|
||||
let (name, at, volume, pitch) = capture.remove(i);
|
||||
let source = horizon_client::settings::sound_source(&name) as i32;
|
||||
host.hub.sound(&name, at, volume, pitch, source, Some(conn));
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Positional sounds and level events of the tick, for the guests.
|
||||
pub(super) fn lan_broadcast_effects(&mut self, host: &mut LanHost) {
|
||||
let sounds = self.audio.capture.take().unwrap_or_default();
|
||||
|
||||
@@ -467,17 +467,44 @@ impl Game<'_> {
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
// handleContainerClick: broadcastFullState only for a click on an old
|
||||
// state, else only what the client got wrong. The whole menu after every
|
||||
// click would undo the clicks it has predicted since.
|
||||
let mut agreed = false;
|
||||
// A table that is no longer valid takes no clicks (handleContainerClick).
|
||||
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
|
||||
&& !host.hub.guests[gi].spectator()
|
||||
&& self.lan_table_valid(&host.hub.guests[gi], pos)
|
||||
{
|
||||
self.guest_menu_op(host, gi, &mut menu, |menu, env| {
|
||||
let changed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
|
||||
let before: Vec<_> = (0..menu.slot_count())
|
||||
.map(|s| menu.slot(s).cloned())
|
||||
.collect();
|
||||
let _ = menu.click(env, i32::from(p.slot), i32::from(p.button), input(p.input));
|
||||
(0..menu.slot_count())
|
||||
.filter(|&s| menu.slot(s) != before[s].as_ref())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
agreed = p.state == state
|
||||
&& host.hub.click_agreed(
|
||||
gi,
|
||||
p,
|
||||
menu.slot_count(),
|
||||
&|s| menu.slot(s).cloned(),
|
||||
&changed,
|
||||
);
|
||||
if agreed {
|
||||
// The inventory menu's sync isn't to send what the click settled.
|
||||
let g = &mut host.hub.guests[gi];
|
||||
for &s in changed.iter().filter(|&&s| s >= 10) {
|
||||
g.menu_sent[s - 1] = menu.slot(s).cloned();
|
||||
}
|
||||
}
|
||||
}
|
||||
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu, pos));
|
||||
self.lan_send_table_content(host, conn);
|
||||
if !agreed {
|
||||
self.lan_send_table_content(host, conn);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -549,6 +576,7 @@ impl Game<'_> {
|
||||
carried,
|
||||
}),
|
||||
);
|
||||
host.hub.guests[gi].inventory_told();
|
||||
}
|
||||
|
||||
/// `AbstractContainerMenu.removed` of a crafting menu: the closed window
|
||||
|
||||
@@ -83,7 +83,8 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// `ServerLevel.sendParticles` of a mob's particles: a packet to each
|
||||
/// player within 32 blocks. The window draws its own copy.
|
||||
/// player within 32 blocks. The window draws its own copy. `state` is the
|
||||
/// block of a block particle.
|
||||
pub(super) fn lan_level_particles(
|
||||
&self,
|
||||
options: &ParticleOptions,
|
||||
@@ -91,8 +92,10 @@ impl Game<'_> {
|
||||
spread: [f64; 3],
|
||||
speed: f64,
|
||||
count: u32,
|
||||
state: Option<BlockStateId>,
|
||||
) {
|
||||
let (Some(host), Some(particle)) = (&self.lan, self.network_particle(options, None)) else {
|
||||
let (Some(host), Some(particle)) = (&self.lan, self.network_particle(options, state))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let packet = C::LevelParticles(play::LevelParticles {
|
||||
@@ -364,24 +367,32 @@ impl Game<'_> {
|
||||
/// the players (a guest's turn through `as_guest`, a host's hit on a
|
||||
/// guest) the packet waits for the turn to end so that it reaches every
|
||||
/// player, the guest too.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn lan_send_particles(
|
||||
&mut self,
|
||||
dimension: &'static str,
|
||||
options: &ParticleOptions,
|
||||
state: Option<BlockStateId>,
|
||||
at: [f64; 3],
|
||||
spread: [f64; 3],
|
||||
speed: f64,
|
||||
count: u32,
|
||||
count: i32,
|
||||
) {
|
||||
if self.lan.is_none() {
|
||||
// The server is running (its sounds are captured for the guests).
|
||||
if self.audio.capture.is_some() {
|
||||
self.creatures
|
||||
.lan_particles
|
||||
.push((options.clone(), at, spread, speed, count));
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
self.creatures.lan_particles.push((
|
||||
dimension,
|
||||
options.clone(),
|
||||
state,
|
||||
at,
|
||||
spread,
|
||||
speed,
|
||||
count,
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(particle) = self.network_particle(options, None) else {
|
||||
let Some(particle) = self.network_particle(options, state) else {
|
||||
return;
|
||||
};
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
@@ -394,11 +405,16 @@ impl Game<'_> {
|
||||
pos: at,
|
||||
dist: spread.map(|d| d as f32),
|
||||
max_speed: [speed as f32; 3],
|
||||
count: count as i32,
|
||||
count,
|
||||
randomization: 0,
|
||||
})
|
||||
.encode();
|
||||
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
|
||||
for g in host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed && g.dimension == dimension)
|
||||
{
|
||||
// BlockPos.closerToCenterThan(at, 32): from the centre of the
|
||||
// player's block.
|
||||
let d: f64 = (0..3)
|
||||
@@ -412,21 +428,25 @@ impl Game<'_> {
|
||||
|
||||
/// The particles guests' turns put off.
|
||||
pub(super) fn flush_lan_particles(&mut self) {
|
||||
for (options, at, spread, speed, count) in std::mem::take(&mut self.creatures.lan_particles)
|
||||
for (dimension, options, state, at, spread, speed, count) in
|
||||
std::mem::take(&mut self.creatures.lan_particles)
|
||||
{
|
||||
self.lan_send_particles(&options, at, spread, speed, count);
|
||||
self.lan_send_particles(dimension, &options, state, at, spread, speed, count);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerLevel.explode`'s packet to each player within 64 blocks. The
|
||||
/// sound and the push reach them on their own (the level's sounds, the
|
||||
/// motion a hurt player is sent), so the packet carries neither.
|
||||
/// `ServerLevel.explode`'s packet to each player within 64 blocks, with
|
||||
/// the push `pushed` holds for them (`ServerExplosion.hitPlayers`: a
|
||||
/// creative player, whom nothing hurt). The sound reaches them on its own
|
||||
/// (the level's sounds), and so does a hurt player's push (the motion
|
||||
/// they are sent).
|
||||
pub(super) fn lan_explode(
|
||||
&mut self,
|
||||
center: [f64; 3],
|
||||
small: bool,
|
||||
radius: f32,
|
||||
block_count: usize,
|
||||
pushed: Vec<(u32, [f64; 3])>,
|
||||
) {
|
||||
// In a guest's turn (their hit on an end crystal or a minecart) the
|
||||
// host holds the players (`as_guest`): the packet waits for the turn
|
||||
@@ -434,7 +454,7 @@ impl Game<'_> {
|
||||
if self.lan_acting.is_some() && self.lan.is_none() {
|
||||
self.creatures
|
||||
.lan_explosions
|
||||
.push((center, small, radius, block_count));
|
||||
.push((center, small, radius, block_count, pushed));
|
||||
return;
|
||||
}
|
||||
let emitter = if small {
|
||||
@@ -446,16 +466,16 @@ impl Game<'_> {
|
||||
if let Some(packet) =
|
||||
self.explosion_packet(center, radius, block_count, emitter, sound, true)
|
||||
{
|
||||
self.send_explosion(packet, &[]);
|
||||
self.send_explosion(packet, &pushed);
|
||||
}
|
||||
}
|
||||
|
||||
/// The explosions guests' turns put off.
|
||||
pub(super) fn flush_lan_explosions(&mut self) {
|
||||
for (center, small, radius, block_count) in
|
||||
for (center, small, radius, block_count, pushed) in
|
||||
std::mem::take(&mut self.creatures.lan_explosions)
|
||||
{
|
||||
self.lan_explode(center, small, radius, block_count);
|
||||
self.lan_explode(center, small, radius, block_count, pushed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -438,6 +438,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
host.hub.send_to(conn, &packet);
|
||||
host.hub.guests[gi].inventory_told();
|
||||
for packet in data {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
|
||||
@@ -89,11 +89,14 @@ pub(super) struct Parked {
|
||||
|
||||
/// Which passes of the server tick the level in place runs: the first
|
||||
/// reads the network (`Hub::poll`), the last ends the tick for every player
|
||||
/// (`Hub::end_tick`). A tick with one level is both.
|
||||
/// (`Hub::end_tick`). A tick with one level is both. `running` is the
|
||||
/// tick rate manager's answer for this server tick, asked once before the
|
||||
/// levels (`MinecraftServer.tickServer`); `None` outside `tick_server`.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct Pass {
|
||||
pub first: bool,
|
||||
pub last: bool,
|
||||
pub running: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for Pass {
|
||||
@@ -101,6 +104,7 @@ impl Default for Pass {
|
||||
Self {
|
||||
first: true,
|
||||
last: true,
|
||||
running: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,6 +126,11 @@ impl Levels {
|
||||
self.parked.get_mut(&dim)
|
||||
}
|
||||
|
||||
/// The mobs of a parked level.
|
||||
pub(super) fn parked_mobs(&self, dim: Dim) -> Option<&[Mob]> {
|
||||
self.parked.get(&dim).map(|level| level.mobs.as_slice())
|
||||
}
|
||||
|
||||
/// The levels that are not in place, by dimension.
|
||||
pub(super) fn parked_dims(&self) -> Vec<Dim> {
|
||||
self.parked.keys().copied().collect()
|
||||
@@ -138,10 +147,11 @@ impl Game<'_> {
|
||||
&self.settings,
|
||||
&self.world_root,
|
||||
dim,
|
||||
self.level_shapes.get(dim.id()),
|
||||
self.level.seed,
|
||||
)?;
|
||||
let mut world = World::new(self.res.facts().air, self.level.seed);
|
||||
world.shape = dim.shape();
|
||||
world.shape = self.level_shapes.get(dim.id());
|
||||
world.fast_lava = dim == Dim::Nether;
|
||||
Ok(Parked {
|
||||
world,
|
||||
@@ -338,8 +348,19 @@ impl Game<'_> {
|
||||
/// and loaded and its world ticked. The first pass reads the network and runs the
|
||||
/// tick's shared parts (the clock, the weather, functions, the autosave);
|
||||
/// the next ones only their level. While `paused`, only the connections
|
||||
/// run.
|
||||
/// run. The native server tick hook brackets it (`ServerTickStart` before
|
||||
/// the first level, `ServerTickEnd` after the last).
|
||||
pub(super) fn tick_server(&mut self, paused: bool) -> Result<()> {
|
||||
// `tickRateManager.tick()` once per server tick, before any level
|
||||
// (each `ServerLevel.tick` only reads `runsNormally`), and the native
|
||||
// `ServerTickStart` before that level work, where NeoForge fires
|
||||
// `ServerTickEvent.Pre`. A paused pass and a held server tick nothing.
|
||||
let running = (!paused && !self.agent.hold_server).then(|| self.clock.world_runs());
|
||||
if let Some(running) = running
|
||||
&& (self.serving.is_some() || self.options.dedicated.is_some())
|
||||
{
|
||||
self.native_lifecycle_server_tick_start(running);
|
||||
}
|
||||
let dims = self.levels_to_tick();
|
||||
let n = dims.len();
|
||||
let mut result = Ok(());
|
||||
@@ -351,6 +372,7 @@ impl Game<'_> {
|
||||
self.level_pass = Pass {
|
||||
first: k == 0,
|
||||
last: k + 1 == n,
|
||||
running,
|
||||
};
|
||||
self.update_dedicated_streaming();
|
||||
self.update_world();
|
||||
@@ -360,7 +382,7 @@ impl Game<'_> {
|
||||
} else if k == 0 {
|
||||
self.tick()
|
||||
} else {
|
||||
let running = self.clock.world_runs();
|
||||
let running = running.unwrap_or_else(|| self.clock.world_runs());
|
||||
if running {
|
||||
self.tick_weather_effects();
|
||||
}
|
||||
@@ -388,26 +410,39 @@ impl Game<'_> {
|
||||
/// `tick_server` as a server thread runs it. With `HORIZON_SPIKES=MS`, a
|
||||
/// tick longer than MS is logged with its thread CPU time and spans.
|
||||
pub(super) fn tick_server_timed(&mut self, paused: bool) -> Result<()> {
|
||||
let Some(threshold) = self.options.spikes else {
|
||||
self.tick_server_spans(paused, |_, _, _| {})
|
||||
}
|
||||
|
||||
/// `tick_server_timed`, handing the tick's wall time, thread CPU time
|
||||
/// and spans (milliseconds) to `record` while spans are on: a dedicated
|
||||
/// server's performance report turns them on to record its ticks.
|
||||
pub(super) fn tick_server_spans(
|
||||
&mut self,
|
||||
paused: bool,
|
||||
record: impl FnOnce(f32, Option<f32>, Vec<horizon_client::spans::Span>),
|
||||
) -> Result<()> {
|
||||
if self.options.spikes.is_none() && !horizon_client::spans::enabled() {
|
||||
return self.tick_server(paused);
|
||||
};
|
||||
}
|
||||
let (started, cpu) = (Instant::now(), horizon_client::spans::thread_cpu());
|
||||
let result = self.tick_server(paused);
|
||||
let wall = started.elapsed().as_secs_f32() * 1000.0;
|
||||
let cpu = horizon_client::spans::thread_cpu()
|
||||
.zip(cpu)
|
||||
.map(|(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
// Taken every tick: nothing else takes this thread's spans.
|
||||
let spans = horizon_client::spans::take();
|
||||
if wall > threshold {
|
||||
let cpu = horizon_client::spans::thread_cpu()
|
||||
.zip(cpu)
|
||||
.map_or_else(String::new, |(end, start)| {
|
||||
format!(", cpu {:.1} ms", (end - start).as_secs_f32() * 1000.0)
|
||||
});
|
||||
if let Some(threshold) = self.options.spikes
|
||||
&& wall > threshold
|
||||
{
|
||||
let cpu = cpu.map_or_else(String::new, |c| format!(", cpu {c:.1} ms"));
|
||||
log::info!(
|
||||
"Horizon: slow tick {wall:.1} ms{cpu} ({} mobs): {}",
|
||||
self.mobs.len(),
|
||||
horizon_client::spans::describe(&spans)
|
||||
);
|
||||
}
|
||||
record(wall, cpu, spans);
|
||||
result
|
||||
}
|
||||
|
||||
|
||||
@@ -1504,7 +1504,7 @@ impl Game<'_> {
|
||||
texts: None,
|
||||
partial: 0.0,
|
||||
game_time: self.world.game_time,
|
||||
max_y: self.dim.max_y(),
|
||||
max_y: self.world.shape.max_y() - 1,
|
||||
world: &self.world,
|
||||
sprites: &self.gui.atlas.sprites,
|
||||
nether: self.dim == super::Dim::Nether,
|
||||
|
||||
@@ -197,6 +197,7 @@ impl Game<'_> {
|
||||
self.worn_block_geometry(mob, camera, alpha, light, out);
|
||||
self.head_item_geometry(mob, camera, alpha, light, true, out);
|
||||
self.golem_flower_geometry(mob, camera, alpha, light, out);
|
||||
self.antenna_block_geometry(mob, camera, alpha, light, out);
|
||||
if mob.species() == Species::Enderman
|
||||
&& let Some(state) = mob.extra.carried
|
||||
{
|
||||
@@ -208,6 +209,43 @@ impl Game<'_> {
|
||||
self.block_model_vertices(state, 42, t, light, true, out);
|
||||
}
|
||||
}
|
||||
/// `BlockDecorationLayer.submit`: the block item in a copper golem's
|
||||
/// antenna slot (a flower, or any block item), drawn at the antenna by
|
||||
/// `CopperGolemModel.applyBlockOnAntennaTransform` with `NO_OVERLAY`. Only
|
||||
/// the block's default state is drawn, not the item's block state component.
|
||||
fn antenna_block_geometry(
|
||||
&self,
|
||||
mob: &Mob,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
use glam::{Mat4, Vec3};
|
||||
if mob.species() != Species::CopperGolem || horizon_client::mobs::trader::is_invisible(mob)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(stack) = mob.saddle_item() else {
|
||||
return;
|
||||
};
|
||||
let Some(state) = self.pack.assets.registry.default_state(&stack.item) else {
|
||||
return;
|
||||
};
|
||||
let Some(head) =
|
||||
render::part_transform(mob, alpha as f32, camera, self.res.mob_data(), "head")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// UNIT_CUBE_BOTTOM_CENTER_TO_ANTENNA_CENTER: translation(-0.5, 0, -0.5)
|
||||
// then rotateAround(Z 180, 0.5, 0.5, 0.5), which JOML post-multiplies.
|
||||
let unit = Mat4::from_translation(Vec3::new(-0.5, 0.0, -0.5))
|
||||
* Mat4::from_translation(Vec3::splat(0.5))
|
||||
* Mat4::from_rotation_z(180.0f32.to_radians())
|
||||
* Mat4::from_translation(Vec3::splat(-0.5));
|
||||
let t = head * Mat4::from_translation(Vec3::new(0.0, -1.75, 0.0)) * unit;
|
||||
self.block_model_vertices(state, 42, t, light, true, out);
|
||||
}
|
||||
/// `IronGolemFlowerLayer`: the poppy in the right arm while the golem
|
||||
/// offers it (`getOfferFlowerTick() > 0`), drawn with `NO_OVERLAY`.
|
||||
fn golem_flower_geometry(
|
||||
@@ -327,9 +365,19 @@ impl Game<'_> {
|
||||
for (item, off_hand) in [(&mob.extra.main_hand, false), (&mob.extra.off_hand, true)] {
|
||||
let Some(item) = item else { continue };
|
||||
let left = off_hand != mob.main_arm_left();
|
||||
let Some(t) =
|
||||
// PlayerItemInHandLayer.submitArmWithItem (avatars only): a spyglass
|
||||
// in use, with no swing in progress, is held to the eye (`heldOnHead`).
|
||||
let spyglass_to_eye = &**item == "minecraft:spyglass"
|
||||
&& (&*mob.kind.id == "minecraft:player" || mob.species() == Species::Mannequin)
|
||||
&& off_hand == mob.extra.using_off_hand
|
||||
&& mob.extra.using_item > 0
|
||||
&& horizon_client::mobs::anim::state(mob, alpha as f32).swing < 1.0e-5;
|
||||
let t = if spyglass_to_eye {
|
||||
render::held_to_eye_matrix(mob, alpha as f32, camera, self.res.mob_data(), left)
|
||||
} else {
|
||||
render::held_item_transform(mob, alpha as f32, camera, self.res.mob_data(), left)
|
||||
else {
|
||||
};
|
||||
let Some(t) = t else {
|
||||
continue;
|
||||
};
|
||||
let slot = if off_hand {
|
||||
@@ -350,6 +398,12 @@ impl Game<'_> {
|
||||
continue;
|
||||
};
|
||||
stack.clone()
|
||||
} else if mob.species() == Species::Witch
|
||||
&& horizon_client::mobs::witch::holds_potion(item)
|
||||
&& &**item != "minecraft:potion"
|
||||
{
|
||||
// A drinking witch's main hand holds the potion's type, drawn as a potion item.
|
||||
ItemStack::new("minecraft:potion", 1).with_potion(item)
|
||||
} else {
|
||||
ItemStack::new(item, 1)
|
||||
};
|
||||
@@ -383,7 +437,9 @@ impl Game<'_> {
|
||||
// PandaHoldsItemLayer and FoxHeldItemLayer show the item as it lies
|
||||
// on the ground.
|
||||
let (context, ordinal) = match (mob.species(), left) {
|
||||
(Species::Panda | Species::Fox, _) => (Context::Ground, 7),
|
||||
// The spyglass held to the eye is the HEAD display.
|
||||
_ if spyglass_to_eye => (Context::Head, 5),
|
||||
(Species::Panda | Species::Fox | Species::Witch, _) => (Context::Ground, 7),
|
||||
(_, true) => (Context::ThirdPersonLeftHand, 1),
|
||||
(_, false) => (Context::ThirdPersonRightHand, 2),
|
||||
};
|
||||
@@ -409,9 +465,7 @@ impl Game<'_> {
|
||||
lit: bool,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
if horizon_client::mobs::trader::is_invisible(mob) {
|
||||
return;
|
||||
}
|
||||
// The layer also draws on an invisible mob.
|
||||
let species = mob.species();
|
||||
if species == Species::SnowGolem
|
||||
|| horizon_client::mobs::species::body_slot(species)
|
||||
@@ -455,7 +509,46 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
if let Some(kind) = super::armor_stands::skull_kind(&stack.item) {
|
||||
let pose = head * glam::Mat4::from_scale(glam::Vec3::splat(1.1875));
|
||||
// Only the renderers that add a `CustomHeadLayer` draw a skull:
|
||||
// the humanoid mobs, the illagers, the villagers, the copper golem
|
||||
// and the avatars.
|
||||
if !(&*mob.kind.id == "minecraft:player"
|
||||
|| matches!(
|
||||
species,
|
||||
Species::Zombie
|
||||
| Species::Husk
|
||||
| Species::Drowned
|
||||
| Species::ZombieVillager
|
||||
| Species::Skeleton
|
||||
| Species::Stray
|
||||
| Species::Bogged
|
||||
| Species::Parched
|
||||
| Species::WitherSkeleton
|
||||
| Species::Piglin
|
||||
| Species::PiglinBrute
|
||||
| Species::ZombifiedPiglin
|
||||
| Species::Pillager
|
||||
| Species::Vindicator
|
||||
| Species::Evoker
|
||||
| Species::Illusioner
|
||||
| Species::Villager
|
||||
| Species::WanderingTrader
|
||||
| Species::CopperGolem
|
||||
| Species::Mannequin
|
||||
))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// translateToHead, the renderer's `skullYOffset` (the villager's
|
||||
// `CUSTOM_HEAD_TRANSFORMS`), then the skull's SKULL_SCALE.
|
||||
let y_offset = if species == Species::Villager {
|
||||
-0.074_218_75
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let pose = head
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, y_offset, 0.0))
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(1.1875));
|
||||
let special = horizon_assets::item_model::SpecialModel {
|
||||
kind: if kind == "player" {
|
||||
horizon_assets::item_model::SpecialKind::PlayerHead
|
||||
|
||||
@@ -4,12 +4,18 @@
|
||||
//! mount's health and jump bar on the HUD.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
hud::HudBatch,
|
||||
items::ItemStack,
|
||||
mobs::{MobEvent, Species, Who, equine},
|
||||
motion::{MotionWorld, PlayerMotion},
|
||||
pose::{Dimensions, Pose},
|
||||
sprint::Keys,
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3, VoxelShape, trig},
|
||||
random::RandomSource,
|
||||
};
|
||||
|
||||
/// `passengerAttachments` minus the player's 0.6 vehicle attachment.
|
||||
fn seat(s: Species) -> f64 {
|
||||
@@ -364,12 +370,10 @@ impl Game<'_> {
|
||||
let jump = self.charge_mount_jump(i, keys);
|
||||
let m = &mut self.mobs[i];
|
||||
if jump > 0.0 {
|
||||
// onPlayerJump: rear while jumping (horses).
|
||||
if matches!(
|
||||
m.species(),
|
||||
Species::Horse | Species::SkeletonHorse | Species::ZombieHorse
|
||||
) {
|
||||
m.extra.counter = 20;
|
||||
// AbstractHorse.onPlayerJump: slide on the keys and rear (horses,
|
||||
// donkeys and mules).
|
||||
if equine::is_equine(m.species()) {
|
||||
equine::on_player_jump(m);
|
||||
}
|
||||
// The nautilus plays its dash sound itself.
|
||||
if equine::is_equine(m.species()) {
|
||||
@@ -413,18 +417,15 @@ impl Game<'_> {
|
||||
let mut at = Vec3::new(m.position.x, m.position.y + seat(m.species()), m.position.z);
|
||||
if horizon_client::mobs::camel::is_camel(m.species()) {
|
||||
// Camel.getPassengerAttachmentPoint: the driver half a block
|
||||
// forward, at the body anchor (lower while sitting).
|
||||
let scale = if m.is_baby() { 0.6 } else { 1.0 };
|
||||
let anchor = if horizon_client::mobs::camel::sitting(m) {
|
||||
(2.375 - 1.43) * scale - if m.is_baby() { 0.09375 } else { 0.375 } + 0.2 * scale
|
||||
} else {
|
||||
2.375 * scale - if m.is_baby() { 0.09375 } else { 0.375 }
|
||||
};
|
||||
let yaw = m.yaw.to_radians();
|
||||
// forward, at the body anchor (lower while sitting), turned with
|
||||
// the camel.
|
||||
let a = horizon_client::mobs::camel::passenger_attachment(m);
|
||||
let turned = Vec3::new(a[0], a[1], a[2])
|
||||
.rotate_y(-m.yaw * (std::f64::consts::PI / 180.0) as f32);
|
||||
at = Vec3::new(
|
||||
m.position.x - f64::from(0.5 * scale * yaw.sin()),
|
||||
m.position.y + f64::from(anchor) - 0.6,
|
||||
m.position.z + f64::from(0.5 * scale * yaw.cos()),
|
||||
m.position.x + turned.x,
|
||||
m.position.y + turned.y - 0.6,
|
||||
m.position.z + turned.z,
|
||||
);
|
||||
}
|
||||
if m.species() == Species::HappyGhast {
|
||||
@@ -450,6 +451,12 @@ impl Game<'_> {
|
||||
m.position.z + z * yaw.cos(),
|
||||
);
|
||||
}
|
||||
// AbstractHorse.getPassengerAttachmentPoint: the rider slides back and
|
||||
// up while the horse rears.
|
||||
let rear = equine::rear_offset(m);
|
||||
let rear = Vec3::new(rear[0], rear[1], rear[2])
|
||||
.rotate_y(-m.yaw * (std::f64::consts::PI / 180.0) as f32);
|
||||
at = Vec3::new(at.x + rear.x, at.y + rear.y, at.z + rear.z);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
p.position = at;
|
||||
@@ -471,34 +478,32 @@ impl Game<'_> {
|
||||
self.dismount_happy_ghast(id);
|
||||
return;
|
||||
}
|
||||
let yaw = m.yaw.to_radians();
|
||||
let side = Vec3::new(f64::from(yaw.cos()), 0.0, f64::from(yaw.sin()));
|
||||
let w = f64::from(m.width()) / 2.0 + 0.4;
|
||||
let base = m.position;
|
||||
let candidates = [
|
||||
Vec3::new(base.x - side.x * w, base.y, base.z - side.z * w),
|
||||
Vec3::new(base.x + side.x * w, base.y, base.z + side.z * w),
|
||||
Vec3::new(base.x, base.y + f64::from(m.height()), base.z),
|
||||
];
|
||||
let spot = candidates
|
||||
.into_iter()
|
||||
.find(|c| {
|
||||
let f = |dy: i32| {
|
||||
self.res
|
||||
.facts()
|
||||
.get(self.world.block(
|
||||
c.x.floor() as i32,
|
||||
c.y.floor() as i32 + dy,
|
||||
c.z.floor() as i32,
|
||||
))
|
||||
.full_collision
|
||||
};
|
||||
!f(0) && !f(1)
|
||||
})
|
||||
.unwrap_or(candidates[2]);
|
||||
let mount = Mount {
|
||||
species: m.species(),
|
||||
position: m.position,
|
||||
bounds: m.bounds(),
|
||||
width: m.width(),
|
||||
height: m.height(),
|
||||
yaw: m.yaw,
|
||||
};
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let rider = Rider {
|
||||
yaw: self.yaw,
|
||||
width: p.dimensions.width,
|
||||
poses: rider_poses(p),
|
||||
};
|
||||
let (spot, pose) = dismount_location(&mount, &rider, &GameDismountWorld(self));
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = spot;
|
||||
p.velocity = Vec3::ZERO;
|
||||
// LivingEntity.setPose: the dismount pose that fit.
|
||||
if let Some(pose) = pose
|
||||
&& let Err(e) = p.set_pose(pose)
|
||||
{
|
||||
log::warn!("dismount pose: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn mount_event(&mut self, e: &MobEvent) {
|
||||
@@ -551,6 +556,62 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `Entity.propagateFallToPassengers`: whoever rides the mob `mob` takes
|
||||
/// `causeFallDamage` of its fall, as the mount's own damage does. The local
|
||||
/// player, the server players and the mob on its back each get it, with
|
||||
/// their own safe distance and armour.
|
||||
pub(super) fn fall_to_passengers(&mut self, mob: u32, distance: f64, modifier: f32) {
|
||||
if self.riding == Some(mob) {
|
||||
self.rider_fall_damage(distance, modifier);
|
||||
}
|
||||
let guests: Vec<u32> = self.lan.as_ref().map_or_else(Vec::new, |h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.vehicle == Some(mob) && Some(g.conn) != self.lan_acting)
|
||||
.map(|g| g.conn)
|
||||
.collect()
|
||||
});
|
||||
if !guests.is_empty()
|
||||
&& let Some(mut host) = self.lan.take()
|
||||
{
|
||||
for conn in guests {
|
||||
self.guest_hit_by(&mut host, conn, |g| g.rider_fall_damage(distance, modifier));
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
let rider = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == mob)
|
||||
.and_then(|m| m.extra.rider);
|
||||
if let Some(rider) = rider
|
||||
&& let Some(r) = self.mobs.iter_mut().find(|m| m.id == rider)
|
||||
{
|
||||
let mut events = Vec::new();
|
||||
r.cause_fall_damage_from(distance, modifier, &mut events);
|
||||
let _ = self.handle_mob_events(events);
|
||||
}
|
||||
}
|
||||
/// The player this game runs (the local one, or the server player it acts
|
||||
/// as) takes the fall of the mount under it. `cause_mount_fall` runs the
|
||||
/// landing code's own fall damage for the player: creative flight is
|
||||
/// exempt, and the statistic, the sounds and the damage records wait in the
|
||||
/// player's events for its next tick, as a landing of its own leaves them.
|
||||
fn rider_fall_damage(&mut self, distance: f64, modifier: f32) {
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let _ = MotionWorld {
|
||||
entities: &[],
|
||||
player,
|
||||
scene: &mut self.world,
|
||||
assets: &self.pack.assets,
|
||||
collision_binding: self.collision,
|
||||
resources: self.motion,
|
||||
}
|
||||
.cause_mount_fall(distance, modifier);
|
||||
}
|
||||
/// `PlayerRideableJumping`: horses, camels and nautili (the dash).
|
||||
fn jumps(species: Species) -> bool {
|
||||
equine::is_equine(species) || horizon_client::mobs::nautilus::is_nautilus(species)
|
||||
@@ -598,3 +659,289 @@ impl Game<'_> {
|
||||
hearts
|
||||
}
|
||||
}
|
||||
|
||||
/// The mount a rider gets off: its kind, where it stands, its box,
|
||||
/// `getBbWidth`, `getBbHeight` and body yaw.
|
||||
struct Mount {
|
||||
species: Species,
|
||||
position: Vec3,
|
||||
bounds: Aabb,
|
||||
width: f32,
|
||||
height: f32,
|
||||
yaw: f32,
|
||||
}
|
||||
/// The rider getting off: `getYRot`, `getBbWidth`, and
|
||||
/// `getLocalBoundsForPose` for each of `Player.getDismountPoses`.
|
||||
struct Rider {
|
||||
yaw: f32,
|
||||
width: f32,
|
||||
poses: Vec<(Pose, Dimensions)>,
|
||||
}
|
||||
/// `Player.getDismountPoses`: standing, crouching, then crawling.
|
||||
const DISMOUNT_POSES: [Pose; 3] = [Pose::Standing, Pose::Crouching, Pose::Swimming];
|
||||
/// The rider's box in each dismount pose, at their `minecraft:scale`.
|
||||
fn rider_poses(p: &PlayerMotion) -> Vec<(Pose, Dimensions)> {
|
||||
// EntityDimensions.scale: every player pose scales alike, so the
|
||||
// standing width gives the scale without a mutable attribute lookup.
|
||||
let scale = p.dimensions.width / Pose::Standing.dimensions(1.0).width;
|
||||
DISMOUNT_POSES
|
||||
.iter()
|
||||
.map(|&pose| (pose, pose.dimensions(scale)))
|
||||
.collect()
|
||||
}
|
||||
/// The level queries a dismount makes.
|
||||
trait DismountWorld {
|
||||
/// A full collision block (the generic rule's test).
|
||||
fn full_collision(&self, p: [i32; 3]) -> bool;
|
||||
/// `getFluidState(pos).is(FluidTags.LAVA)`.
|
||||
fn lava(&self, p: [i32; 3]) -> bool;
|
||||
/// `BlockGetter.getBlockFloorHeight(pos)`.
|
||||
fn floor_height(&self, p: [i32; 3]) -> f64;
|
||||
/// `DismountHelper.canDismountTo(level, passenger, box)`.
|
||||
fn can_dismount_to(&self, bounds: Aabb) -> bool;
|
||||
}
|
||||
struct GameDismountWorld<'a, 'g>(&'a Game<'g>);
|
||||
impl DismountWorld for GameDismountWorld<'_, '_> {
|
||||
fn full_collision(&self, p: [i32; 3]) -> bool {
|
||||
let g = self.0;
|
||||
g.res
|
||||
.facts()
|
||||
.get(g.world.block(p[0], p[1], p[2]))
|
||||
.full_collision
|
||||
}
|
||||
fn lava(&self, p: [i32; 3]) -> bool {
|
||||
let g = self.0;
|
||||
g.res.facts().get(g.world.block(p[0], p[1], p[2])).fluid == Fluid::Lava
|
||||
}
|
||||
fn floor_height(&self, p: [i32; 3]) -> f64 {
|
||||
let shape = self.0.block_collision_shape(p);
|
||||
let below = std::cell::OnceCell::<VoxelShape>::new();
|
||||
horizon_core::geometry::block_floor_height(&shape, || {
|
||||
below.get_or_init(|| self.0.block_collision_shape([p[0], p[1] - 1, p[2]]))
|
||||
})
|
||||
}
|
||||
fn can_dismount_to(&self, bounds: Aabb) -> bool {
|
||||
self.0.can_dismount_to(bounds)
|
||||
}
|
||||
}
|
||||
/// `getDismountLocationForPassenger`, and the pose the rider takes there.
|
||||
fn dismount_location(
|
||||
mount: &Mount,
|
||||
rider: &Rider,
|
||||
world: &impl DismountWorld,
|
||||
) -> (Vec3, Option<Pose>) {
|
||||
if mount.species == Species::Strider {
|
||||
return strider_dismount_location(mount, rider, world);
|
||||
}
|
||||
// Beside the mount, else on top of it.
|
||||
let yaw = mount.yaw.to_radians();
|
||||
let side = Vec3::new(f64::from(yaw.cos()), 0.0, f64::from(yaw.sin()));
|
||||
let w = f64::from(mount.width) / 2.0 + 0.4;
|
||||
let base = mount.position;
|
||||
let candidates = [
|
||||
Vec3::new(base.x - side.x * w, base.y, base.z - side.z * w),
|
||||
Vec3::new(base.x + side.x * w, base.y, base.z + side.z * w),
|
||||
Vec3::new(base.x, base.y + f64::from(mount.height), base.z),
|
||||
];
|
||||
let spot = candidates
|
||||
.into_iter()
|
||||
.find(|c| {
|
||||
let f = |dy: i32| {
|
||||
world.full_collision([
|
||||
c.x.floor() as i32,
|
||||
c.y.floor() as i32 + dy,
|
||||
c.z.floor() as i32,
|
||||
])
|
||||
};
|
||||
!f(0) && !f(1)
|
||||
})
|
||||
.unwrap_or(candidates[2]);
|
||||
(spot, None)
|
||||
}
|
||||
/// `Entity.getCollisionHorizontalEscapeVector`: out of the vehicle's box
|
||||
/// along `yaw`, to the edge of the square it spans.
|
||||
fn escape_vector(vehicle_width: f64, passenger_width: f64, yaw: f32) -> Vec3 {
|
||||
// 1.0E-5F: a float literal widened to double.
|
||||
let distance = (vehicle_width + passenger_width + f64::from(1.0e-5_f32)) / 2.0;
|
||||
let radians = f64::from(yaw * 0.017_453_292);
|
||||
let (sin, cos) = (-trig::sin(radians), trig::cos(radians));
|
||||
let max = f64::from(sin.abs().max(cos.abs()));
|
||||
Vec3::new(
|
||||
f64::from(sin) * distance / max,
|
||||
0.0,
|
||||
f64::from(cos) * distance / max,
|
||||
)
|
||||
}
|
||||
/// `Strider.getDismountLocationForPassenger`: the columns along the rider's
|
||||
/// facing and 22.5 and 45 degrees either side of it, each from the top of
|
||||
/// the strider's box down to half a block below its feet. The first block
|
||||
/// with no lava, a valid floor (`DismountHelper.isBlockFloorValid`) and room
|
||||
/// for one of the rider's dismount poses (`canDismountTo`) wins, with that
|
||||
/// pose; without one, on top of the strider with the pose unchanged.
|
||||
fn strider_dismount_location(
|
||||
strider: &Mount,
|
||||
rider: &Rider,
|
||||
world: &impl DismountWorld,
|
||||
) -> (Vec3, Option<Pose>) {
|
||||
let (vehicle, passenger) = (f64::from(strider.width), f64::from(rider.width));
|
||||
let top = strider.bounds.max_position().y;
|
||||
let bottom = strider.bounds.min_position().y - 0.5;
|
||||
// A LinkedHashSet: each block once, in the order first reached.
|
||||
let mut blocks: Vec<[i32; 3]> = Vec::new();
|
||||
for turn in [0.0, -22.5, 22.5, -45.0, 45.0] {
|
||||
let offset = escape_vector(vehicle, passenger, rider.yaw + turn);
|
||||
let (x, z) = (
|
||||
(strider.position.x + offset.x).floor() as i32,
|
||||
(strider.position.z + offset.z).floor() as i32,
|
||||
);
|
||||
let mut y = top;
|
||||
let mut block_y = top.floor() as i32;
|
||||
while y > bottom {
|
||||
if !blocks.contains(&[x, block_y, z]) {
|
||||
blocks.push([x, block_y, z]);
|
||||
}
|
||||
block_y -= 1;
|
||||
y -= 1.0;
|
||||
}
|
||||
}
|
||||
for p in blocks {
|
||||
if world.lava(p) {
|
||||
continue;
|
||||
}
|
||||
// DismountHelper.isBlockFloorValid.
|
||||
let floor = world.floor_height(p);
|
||||
if floor.is_infinite() || floor >= 1.0 {
|
||||
continue;
|
||||
}
|
||||
// Vec3.upFromBottomCenterOf.
|
||||
let at = Vec3::new(
|
||||
f64::from(p[0]) + 0.5,
|
||||
f64::from(p[1]) + floor,
|
||||
f64::from(p[2]) + 0.5,
|
||||
);
|
||||
for (pose, dimensions) in &rider.poses {
|
||||
if world.can_dismount_to(dimensions.bounds(at)) {
|
||||
return (at, Some(*pose));
|
||||
}
|
||||
}
|
||||
}
|
||||
(Vec3::new(strider.position.x, top, strider.position.z), None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A lava sea up to y 30 with full blocks placed in it; air above.
|
||||
struct Sea {
|
||||
blocks: HashMap<[i32; 3], ()>,
|
||||
}
|
||||
impl Sea {
|
||||
fn with(blocks: &[[i32; 3]]) -> Self {
|
||||
Self {
|
||||
blocks: blocks.iter().map(|&b| (b, ())).collect(),
|
||||
}
|
||||
}
|
||||
fn solid(&self, p: [i32; 3]) -> bool {
|
||||
self.blocks.contains_key(&p)
|
||||
}
|
||||
}
|
||||
impl DismountWorld for Sea {
|
||||
fn full_collision(&self, p: [i32; 3]) -> bool {
|
||||
self.solid(p)
|
||||
}
|
||||
fn lava(&self, p: [i32; 3]) -> bool {
|
||||
p[1] <= 30 && !self.solid(p)
|
||||
}
|
||||
fn floor_height(&self, p: [i32; 3]) -> f64 {
|
||||
if self.solid(p) {
|
||||
1.0
|
||||
} else if self.solid([p[0], p[1] - 1, p[2]]) {
|
||||
0.0
|
||||
} else {
|
||||
f64::NEG_INFINITY
|
||||
}
|
||||
}
|
||||
fn can_dismount_to(&self, bounds: Aabb) -> bool {
|
||||
let (lo, hi) = (bounds.min_position(), bounds.max_position());
|
||||
let cells = |a: f64, b: f64| a.floor() as i32..=(b - 1.0e-7).floor() as i32;
|
||||
!cells(lo.x, hi.x).any(|x| {
|
||||
cells(lo.y, hi.y).any(|y| cells(lo.z, hi.z).any(|z| self.solid([x, y, z])))
|
||||
})
|
||||
}
|
||||
}
|
||||
/// The top of a lava source at y 30 (`FlowingFluid.getOwnHeight`, 8/9).
|
||||
const SURFACE: f64 = 30.0 + 8.0 / 9.0;
|
||||
/// A strider (0.9 by 1.7) standing on the lava at x, z, facing south.
|
||||
fn strider_at(x: f64, z: f64) -> Mount {
|
||||
Mount {
|
||||
species: Species::Strider,
|
||||
position: Vec3::new(x, SURFACE, z),
|
||||
bounds: Aabb::new(
|
||||
Vec3::new(x - 0.45, SURFACE, z - 0.45),
|
||||
Vec3::new(x + 0.45, SURFACE + 1.7, z + 0.45),
|
||||
),
|
||||
width: 0.9,
|
||||
height: 1.7,
|
||||
yaw: 0.0,
|
||||
}
|
||||
}
|
||||
fn rider(yaw: f32) -> Rider {
|
||||
Rider {
|
||||
yaw,
|
||||
width: 0.6,
|
||||
poses: DISMOUNT_POSES
|
||||
.iter()
|
||||
.map(|&pose| (pose, pose.dimensions(1.0)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
fn block_of(at: Vec3) -> [i32; 3] {
|
||||
[
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_strider_in_open_lava_puts_its_rider_on_its_back() {
|
||||
// #3513: getting off in a lava sea used to put the player beside the
|
||||
// strider, in the lava; with no floor in reach vanilla puts the rider
|
||||
// on top of the strider instead.
|
||||
let sea = Sea::with(&[]);
|
||||
let (at, pose) = dismount_location(&strider_at(0.5, 0.5), &rider(0.0), &sea);
|
||||
assert!(!sea.lava(block_of(at)), "dismounted into lava at {at:?}");
|
||||
assert_eq!(at, Vec3::new(0.5, SURFACE + 1.7, 0.5));
|
||||
assert_eq!(pose, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_strider_by_the_shore_puts_its_rider_on_the_shore() {
|
||||
// Netherrack 45 degrees to the right of a rider facing south (+z);
|
||||
// the straight-ahead and 22.5 degree columns are lava.
|
||||
let sea = Sea::with(&[[-1, 30, 1]]);
|
||||
let (at, pose) = dismount_location(&strider_at(0.5, 0.5), &rider(0.0), &sea);
|
||||
assert_eq!(at, Vec3::new(-0.5, 31.0, 1.5));
|
||||
assert_eq!(pose, Some(Pose::Standing));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_low_ceiling_on_the_shore_makes_the_rider_crawl_off() {
|
||||
let sea = Sea::with(&[[0, 30, 1], [0, 32, 1]]);
|
||||
let (at, pose) = dismount_location(&strider_at(0.5, 0.5), &rider(0.0), &sea);
|
||||
assert_eq!(at, Vec3::new(0.5, 31.0, 1.5));
|
||||
assert_eq!(pose, Some(Pose::Swimming));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_escape_vector_reaches_the_edge_of_the_square() {
|
||||
let edge = (0.9 + 0.6 + f64::from(1.0e-5_f32)) / 2.0;
|
||||
let v = escape_vector(0.9, 0.6, 0.0);
|
||||
assert!((v.z - edge).abs() < 1.0e-6 && v.x.abs() < 1.0e-6);
|
||||
// Facing west of south: the corner of the square.
|
||||
let v = escape_vector(0.9, 0.6, 45.0);
|
||||
assert!((v.x + edge).abs() < 1.0e-3 && (v.z - edge).abs() < 1.0e-3);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,22 @@
|
||||
//! Horizon's own HUD extras do, and keep a mod's settings where the
|
||||
//! WebAssembly mod of the same id kept them.
|
||||
//!
|
||||
//! API 13 adds using an item (`native_use_item_on`, `native_use_item`).
|
||||
//!
|
||||
//! API 12 adds item tooltips (`native_tooltip`).
|
||||
//!
|
||||
//! API 11 adds server particle emission (`native_send_particles`).
|
||||
//!
|
||||
//! API 10 adds server menus (`native_menu_clicked`, `native_menu_closed`,
|
||||
//! and `native_open_menu`, `native_exchange_items`... in `native_server.rs`).
|
||||
//!
|
||||
//! API 9 adds the acting player's plain items (`native_held`,
|
||||
//! `native_count_items`, `native_take_items`, `native_give_items`... in
|
||||
//! `native_server.rs`).
|
||||
//!
|
||||
//! API 8 adds server commands and players (`native_register_commands`,
|
||||
//! `native_perform_command`, and the helpers in `native_server.rs`).
|
||||
//!
|
||||
//! API 7 adds block-change observations (`native_block_changed`).
|
||||
//!
|
||||
//! API 6 adds neighbour-change observations (`native_neighbor_changed`).
|
||||
@@ -31,16 +47,20 @@
|
||||
//! either (`tests::hook_points_stay_hookable`).
|
||||
use super::native_lifecycle::{NativeLifecycleContext, ServerTickPhase, WorldPhase};
|
||||
use super::{
|
||||
Game, GameLevel, LevelDimension, Screen, execution_role::ExecutionRole,
|
||||
Game, GameLevel, LevelDimension, Screen,
|
||||
execution_role::ExecutionRole,
|
||||
native_panel_recipes::RecipePage,
|
||||
native_server::{NativeCommand, NativeServerPlayer},
|
||||
};
|
||||
use horizon_client::{
|
||||
chat::ChatSource,
|
||||
extras::Corner,
|
||||
extras_hud::Corners,
|
||||
hud::{HudBatch, rgba},
|
||||
items::ItemStack,
|
||||
keybinds,
|
||||
mod_list::{self, Installed, NativePage},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
@@ -51,7 +71,7 @@ use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
/// The hook points' API version. The launcher reads this line from the
|
||||
/// source it builds; keep its form.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const NATIVE_HOOKS_API: u32 = 7;
|
||||
pub(crate) const NATIVE_HOOKS_API: u32 = 13;
|
||||
|
||||
impl GameLevel<'_> {
|
||||
/// An authoritative server committed a changed state through `Level.setBlock`.
|
||||
@@ -326,8 +346,11 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (context, phase, error_text);
|
||||
}
|
||||
/// The authoritative server tick's phases, read-only. A paused pass
|
||||
/// fires nothing; a frozen world carries `world_running` false except
|
||||
/// The authoritative server tick's phases: `ServerTickStart` before any
|
||||
/// level is ticked (NeoForge's `ServerTickEvent.Pre`), `ServerTickEnd`
|
||||
/// after the last (`.Post`). Read blocks with `native_server_block`, by
|
||||
/// dimension: which level `self.world` holds here is not defined. A paused
|
||||
/// pass fires nothing; a frozen world carries `world_running` false except
|
||||
/// during a requested step.
|
||||
pub(super) fn native_server_tick(
|
||||
&mut self,
|
||||
@@ -386,9 +409,137 @@ impl Game<'_> {
|
||||
pub(super) fn native_panel_event(&mut self, mod_id: &str, event: &NativePanelEvent) {
|
||||
let _ = (mod_id, event);
|
||||
}
|
||||
/// The commands native mods add to the server's command tree: push
|
||||
/// yours. Asked once when a world opens to players (a dedicated
|
||||
/// server, a LAN host, single player's server thread); every player's
|
||||
/// chat box then completes them as vanilla's, within their level. A
|
||||
/// name vanilla has is left out. API 8.
|
||||
pub(super) fn native_register_commands(&mut self, commands: &mut Vec<NativeCommand>) {
|
||||
let _ = commands;
|
||||
}
|
||||
/// A player's command on the server (`Commands.performCommand`),
|
||||
/// without its slash, for a command a mod registered and the player's
|
||||
/// level allows: set `ran` when it is yours. The player's state is
|
||||
/// swapped in for the call, so `native_player` and the other helpers
|
||||
/// read that player; `native_feedback` answers them. API 8.
|
||||
pub(super) fn native_perform_command(
|
||||
&mut self,
|
||||
player: &NativeServerPlayer,
|
||||
command: &str,
|
||||
ran: &mut bool,
|
||||
) {
|
||||
let _ = (player, command, ran);
|
||||
}
|
||||
/// A click in a menu a mod opened (`native_open_menu`), which moved
|
||||
/// nothing: the menu's `slot` (its own first, then the player's
|
||||
/// inventory, `native_menu_inventory_slot`; -999 outside), the `button`
|
||||
/// and the click's `mode` (`ClickType`'s id) of the player `uuid`.
|
||||
/// No player's state is swapped in: the menu helpers take the player's
|
||||
/// UUID. API 10.
|
||||
pub(super) fn native_menu_clicked(
|
||||
&mut self,
|
||||
uuid: u128,
|
||||
window: i32,
|
||||
slot: i32,
|
||||
button: i32,
|
||||
mode: i32,
|
||||
) {
|
||||
let _ = (uuid, window, slot, button, mode);
|
||||
}
|
||||
/// A menu a mod opened closed: the player closed it, the server or a mod
|
||||
/// opened another, or the player left. API 10.
|
||||
pub(super) fn native_menu_closed(&mut self, uuid: u128, window: i32) {
|
||||
let _ = (uuid, window);
|
||||
}
|
||||
/// An item's tooltip, wherever the game shows one (slots, the creative
|
||||
/// tabs, the recipe book, chat's hover): the stack, with its components
|
||||
/// (`stack.data()`: a shulker box's contents...), and its lines, the name
|
||||
/// first, each with a colour as `rgba` packs it (`u32::MAX` keeps the
|
||||
/// text's own). Add, change or take out lines; `Text::legacy` reads `§`
|
||||
/// codes. Asked each frame the tooltip shows, never for one the stack
|
||||
/// hides. API 12.
|
||||
pub(super) fn native_tooltip(&self, stack: &ItemStack, lines: &mut Vec<(Text, u32)>) {
|
||||
let _ = (stack, lines);
|
||||
}
|
||||
/// A player uses `stack` (right click) with `self.hand` on the block at
|
||||
/// `pos`, `face` the face clicked (`[0, 1, 0]` is the top), once the
|
||||
/// block itself let the click through (a chest still opens): where
|
||||
/// `ItemStack.useOn` starts. Set `claimed` to take the use: the hand
|
||||
/// swings and neither the item's `useOn` nor its `use` runs.
|
||||
/// Server-side, for every player of the world (the helpers read the
|
||||
/// acting one); an item on cooldown is never used. API 13.
|
||||
pub(super) fn native_use_item_on(
|
||||
&mut self,
|
||||
stack: &ItemStack,
|
||||
pos: [i32; 3],
|
||||
face: [i32; 3],
|
||||
claimed: &mut bool,
|
||||
) {
|
||||
let _ = (stack, pos, face, claimed);
|
||||
}
|
||||
/// A player uses `stack` (right click) with `self.hand`: in the air, or
|
||||
/// on a block once `native_use_item_on` let it through, where `Item.use`
|
||||
/// starts. Each click reaches it once. Set `claimed` to take the use: the
|
||||
/// hand swings and the item's own use doesn't run. Server-side, for every
|
||||
/// player of the world; an item on cooldown is never used. API 13.
|
||||
pub(super) fn native_use_item(&mut self, stack: &ItemStack, claimed: &mut bool) {
|
||||
let _ = (stack, claimed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helpers for native mods, kept as stable as the hook points.
|
||||
/// Server-level helpers for native mods.
|
||||
#[allow(dead_code)]
|
||||
impl GameLevel<'_> {
|
||||
/// Queue particles in this authoritative level. A zero count sends one
|
||||
/// directional particle. The return value means accepted, not delivered.
|
||||
/// Block particle types require their block state; item, trail and
|
||||
/// vibration particles are not supported by the current network dispatch.
|
||||
pub(super) fn native_send_particles(
|
||||
&mut self,
|
||||
options: &ParticleOptions,
|
||||
state: Option<BlockStateId>,
|
||||
at: [f64; 3],
|
||||
spread: [f64; 3],
|
||||
speed: f64,
|
||||
count: i32,
|
||||
) -> bool {
|
||||
use ParticleType as T;
|
||||
let supported = match options.ty {
|
||||
T::Block | T::BlockMarker | T::FallingDust | T::DustPillar | T::BlockCrumble => {
|
||||
state.is_some_and(|id| self.registry.state(id).is_some())
|
||||
}
|
||||
T::Item | T::Trail | T::Vibration => false,
|
||||
T::Geyser | T::GeyserPlume | T::GeyserBase | T::GeyserPoof => options
|
||||
.geyser
|
||||
.is_some_and(|(_, impulse)| impulse.is_finite()),
|
||||
_ => true,
|
||||
};
|
||||
if !self.native_authoritative
|
||||
|| !supported
|
||||
|| !at.into_iter().all(f64::is_finite)
|
||||
|| !spread
|
||||
.into_iter()
|
||||
.all(|v| v.is_finite() && v.abs() <= f32::MAX as f64)
|
||||
|| !speed.is_finite()
|
||||
|| speed.abs() > f32::MAX as f64
|
||||
|| !options.color.into_iter().all(f32::is_finite)
|
||||
|| !options.to_color.into_iter().all(f32::is_finite)
|
||||
|| !options.scale.is_finite()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let dimension = match self.dimension {
|
||||
LevelDimension::Overworld => "minecraft:overworld",
|
||||
LevelDimension::Nether => "minecraft:the_nether",
|
||||
LevelDimension::End => "minecraft:the_end",
|
||||
};
|
||||
self.native_particles
|
||||
.push((dimension, options.clone(), state, at, spread, speed, count));
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
|
||||
@@ -394,55 +394,6 @@ mod tests {
|
||||
assert!(lifecycle.world_close_final().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedicated_open_and_close_sites_bound_the_server_lifecycle() {
|
||||
let source = include_str!("dedicated.rs")
|
||||
.split("#[cfg(test)]")
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
let open = source
|
||||
.find("self.native_lifecycle_open_started();")
|
||||
.unwrap();
|
||||
let load = source.find("self.load_world(WorldRequest::new(").unwrap();
|
||||
let ready = source.find("self.native_lifecycle_world_ready();").unwrap();
|
||||
let tick = source.find("self.tick_server_timed(paused)?;").unwrap();
|
||||
assert!(open < load && load < ready && ready < tick);
|
||||
|
||||
let stop = source.split("fn stop_dedicated").nth(1).unwrap();
|
||||
let begin = stop.find("self.native_lifecycle.begin_close();").unwrap();
|
||||
let save = stop.find("self.save_game()?;").unwrap();
|
||||
let close = stop.find("self.native_lifecycle_world_closed();").unwrap();
|
||||
let release = stop.find("self.lan_close(false);").unwrap();
|
||||
assert!(begin < save && save < close && close < release);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_server_ticks_cancel_before_end() {
|
||||
let tick = include_str!("levels.rs")
|
||||
.split("fn tick_server(&mut self, paused: bool)")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split("fn tick_server_timed")
|
||||
.next()
|
||||
.unwrap();
|
||||
let first_cancel = tick
|
||||
.find("self.native_lifecycle.cancel_server_tick();")
|
||||
.unwrap();
|
||||
let result = tick.find("result?;").unwrap();
|
||||
let close = tick
|
||||
.find("if let Err(error) = self.close_idle_levels()")
|
||||
.unwrap();
|
||||
let second_cancel = tick
|
||||
.rfind("self.native_lifecycle.cancel_server_tick();")
|
||||
.unwrap();
|
||||
let end = tick
|
||||
.find("self.native_lifecycle_server_tick_end(paused);")
|
||||
.unwrap();
|
||||
assert!(
|
||||
first_cancel < result && result < close && close < second_cancel && second_cancel < end
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_delivery_stays_off_the_deprecated_in_frame_path() {
|
||||
let game = include_str!("../game.rs")
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Server menus for native mods (docs/native-mods.md, "Server menus", API
|
||||
//! 10): a vanilla chest menu (`generic_9x1` to `generic_9x6`) a mod opens
|
||||
//! for a player and fills with items to look at. No click moves anything:
|
||||
//! each reaches the mod (`native_menu_clicked`), as does the menu's closing
|
||||
//! (`native_menu_closed`), and the client shows the menu as the server sends
|
||||
//! it again. A stock client shows it; nothing on the player's side.
|
||||
//! `native_exchange_items` swaps two players' stacks in one step, checked
|
||||
//! first, so a trade built on these menus never moves an item before both
|
||||
//! sides are settled.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
lan::{data::component_nbt, menu::NativeMenuEvent},
|
||||
text::Text,
|
||||
};
|
||||
|
||||
/// The inventory slot (0..9 the hotbar, 9..36 the rest) a click on `slot`
|
||||
/// of a native menu of `size` slots landed on, when it is the player's
|
||||
/// inventory below the menu (`ChestMenu`: the menu's slots, then the
|
||||
/// inventory's 27, then the hotbar's 9).
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn native_menu_inventory_slot(size: usize, slot: i32) -> Option<usize> {
|
||||
let n = usize::try_from(slot).ok()?.checked_sub(size)?;
|
||||
match n {
|
||||
0..27 => Some(n + 9),
|
||||
27..36 => Some(n - 27),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds every stack of `gets` to the 36 slots of `inventory`, onto matching
|
||||
/// stacks first, then into empty slots, hotbar first; false when one doesn't
|
||||
/// fit whole. `max` is an item's stack size.
|
||||
fn fit(
|
||||
inventory: &mut [Option<ItemStack>],
|
||||
gets: &[ItemStack],
|
||||
max: &impl Fn(&ItemStack) -> u32,
|
||||
) -> bool {
|
||||
for stack in gets {
|
||||
let mut left = stack.count;
|
||||
let max = max(stack).max(1);
|
||||
for slot in inventory.iter_mut().take(36).flatten() {
|
||||
if left > 0 && slot.same_item_same_components(stack) && slot.count < max {
|
||||
let n = (max - slot.count).min(left);
|
||||
slot.count += n;
|
||||
left -= n;
|
||||
}
|
||||
}
|
||||
for slot in inventory.iter_mut().take(36) {
|
||||
if left > 0 && slot.is_none() {
|
||||
let n = max.min(left);
|
||||
*slot = Some(ItemStack {
|
||||
count: n,
|
||||
..stack.clone()
|
||||
});
|
||||
left -= n;
|
||||
}
|
||||
}
|
||||
if left > 0 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Two players' offers swapped, or nothing changed: each offer is the
|
||||
/// inventory slots it takes with the stack each must still hold, exactly.
|
||||
/// The stacks leave their slots, then go to the other side where they fit
|
||||
/// whole; an Err (which side, why) leaves both inventories as they were.
|
||||
fn exchange(
|
||||
a: &mut Vec<Option<ItemStack>>,
|
||||
a_offer: &[(usize, ItemStack)],
|
||||
b: &mut Vec<Option<ItemStack>>,
|
||||
b_offer: &[(usize, ItemStack)],
|
||||
max: impl Fn(&ItemStack) -> u32,
|
||||
) -> Result<(), String> {
|
||||
fn take(
|
||||
inv: &[Option<ItemStack>],
|
||||
offer: &[(usize, ItemStack)],
|
||||
who: &str,
|
||||
) -> Result<(Vec<Option<ItemStack>>, Vec<ItemStack>), String> {
|
||||
let mut left = inv.to_vec();
|
||||
let mut taken = Vec::new();
|
||||
for (slot, expected) in offer {
|
||||
match left.get_mut(*slot).filter(|_| *slot < 36) {
|
||||
Some(s) if s.as_ref() == Some(expected) => taken.push(s.take().expect("checked")),
|
||||
_ => return Err(format!("{who} no longer holds their offer")),
|
||||
}
|
||||
}
|
||||
Ok((left, taken))
|
||||
}
|
||||
let (mut a_after, a_gives) = take(a, a_offer, "the first player")?;
|
||||
let (mut b_after, b_gives) = take(b, b_offer, "the second player")?;
|
||||
if !fit(&mut a_after, &b_gives, &max) {
|
||||
return Err("the first player has no room".into());
|
||||
}
|
||||
if !fit(&mut b_after, &a_gives, &max) {
|
||||
return Err("the second player has no room".into());
|
||||
}
|
||||
*a = a_after;
|
||||
*b = b_after;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Server menus, for server mods (API 10). The players are named by UUID;
|
||||
/// none of these runs while a player's state is swapped in (inside
|
||||
/// `native_perform_command`, the menus can be opened and filled, but
|
||||
/// `native_player_stacks` and `native_exchange_items` refuse: call them from
|
||||
/// `native_menu_clicked` or `native_server_tick`).
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
fn native_conn(&self, uuid: u128) -> Option<u32> {
|
||||
let host = self.lan.as_ref()?;
|
||||
let g = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.placed && g.uuid == uuid)?;
|
||||
Some(g.conn)
|
||||
}
|
||||
/// Opens a chest menu of `rows` rows (1 to 6) titled `title` for the
|
||||
/// player, showing `items` (by slot, padded with empty ones), in place of
|
||||
/// the menu they had open: its window id.
|
||||
pub(super) fn native_open_menu(
|
||||
&mut self,
|
||||
uuid: u128,
|
||||
rows: u8,
|
||||
title: Text,
|
||||
items: Vec<Option<ItemStack>>,
|
||||
) -> Option<i32> {
|
||||
let conn = self.native_conn(uuid)?;
|
||||
let host = self.lan.as_mut()?;
|
||||
host.hub
|
||||
.open_native_menu(conn, rows, component_nbt(&title.component()), items)
|
||||
}
|
||||
/// Replaces what the player's open native menu shows; sent with the
|
||||
/// tick's container sync.
|
||||
pub(super) fn native_set_menu_items(&mut self, uuid: u128, items: Vec<Option<ItemStack>>) {
|
||||
if let Some(conn) = self.native_conn(uuid)
|
||||
&& let Some(host) = self.lan.as_mut()
|
||||
{
|
||||
host.hub.set_native_menu(conn, items);
|
||||
}
|
||||
}
|
||||
/// Closes the player's native menu (a `native_menu_closed` follows).
|
||||
pub(super) fn native_close_menu(&mut self, uuid: u128) {
|
||||
if let Some(conn) = self.native_conn(uuid)
|
||||
&& let Some(host) = self.lan.as_mut()
|
||||
&& host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.and_then(|g| g.menu.as_ref())
|
||||
.is_some_and(|m| m.slots == horizon_client::lan::menu::Slots::Native)
|
||||
{
|
||||
host.hub.close_container(conn);
|
||||
}
|
||||
}
|
||||
/// The player's 36 inventory slots (0..9 the hotbar), to show or offer
|
||||
/// them; None for a player not here, dead, or whose state is swapped in.
|
||||
pub(super) fn native_player_stacks(&self, uuid: u128) -> Option<Vec<Option<ItemStack>>> {
|
||||
let host = self.lan.as_ref()?;
|
||||
let g = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.placed && g.uuid == uuid)?;
|
||||
(!g.dead && self.lan_acting != Some(g.conn)).then(|| {
|
||||
let mut stacks = g.inventory.clone();
|
||||
stacks.resize(36, None);
|
||||
stacks
|
||||
})
|
||||
}
|
||||
/// Swaps two players' offers (inventory slots, each with the stack it
|
||||
/// must still hold, exactly) in one step: the stacks leave their slots
|
||||
/// and go to the other player where they fit whole, or nothing moves and
|
||||
/// the Err says why. Both must be here and alive, neither with their
|
||||
/// state swapped in. Their screens follow with the tick's sync.
|
||||
pub(super) fn native_exchange_items(
|
||||
&mut self,
|
||||
a: u128,
|
||||
a_offer: &[(usize, ItemStack)],
|
||||
b: u128,
|
||||
b_offer: &[(usize, ItemStack)],
|
||||
) -> Result<(), String> {
|
||||
if a == b {
|
||||
return Err("a player can't trade with themselves".into());
|
||||
}
|
||||
let acting = self.lan_acting;
|
||||
let items = self.res.items();
|
||||
let Some(host) = self.lan.as_mut() else {
|
||||
return Err("no server".into());
|
||||
};
|
||||
let find = |uuid: u128| {
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.position(|g| g.placed && !g.dead && g.uuid == uuid)
|
||||
.filter(|&i| acting != Some(host.hub.guests[i].conn))
|
||||
};
|
||||
let (Some(ia), Some(ib)) = (find(a), find(b)) else {
|
||||
return Err("both players must be here".into());
|
||||
};
|
||||
let mut inv_a = std::mem::take(&mut host.hub.guests[ia].inventory);
|
||||
let mut inv_b = std::mem::take(&mut host.hub.guests[ib].inventory);
|
||||
let result = exchange(&mut inv_a, a_offer, &mut inv_b, b_offer, |s| {
|
||||
items.get(&s.item).map_or(64, |i| i.max_stack)
|
||||
});
|
||||
host.hub.guests[ia].inventory = inv_a;
|
||||
host.hub.guests[ib].inventory = inv_b;
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the game calls the menu hook points.
|
||||
impl Game<'_> {
|
||||
/// The clicks on and closings of native menus the hub collected, each
|
||||
/// told to the mods with the hub back in `self.lan`, no player's state
|
||||
/// swapped in.
|
||||
pub(super) fn run_native_menu_events(&mut self) {
|
||||
loop {
|
||||
let Some(host) = self.lan.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if host.hub.native_menu_events.is_empty() {
|
||||
return;
|
||||
}
|
||||
match host.hub.native_menu_events.remove(0) {
|
||||
NativeMenuEvent::Click {
|
||||
uuid,
|
||||
window,
|
||||
slot,
|
||||
button,
|
||||
mode,
|
||||
} => self.native_menu_clicked(uuid, window, slot, button, mode),
|
||||
NativeMenuEvent::Closed { uuid, window } => self.native_menu_closed(uuid, window),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn stack(item: &str, count: u32) -> ItemStack {
|
||||
ItemStack {
|
||||
item: item.into(),
|
||||
count,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
}
|
||||
}
|
||||
fn inventory(stacks: &[(usize, ItemStack)]) -> Vec<Option<ItemStack>> {
|
||||
let mut inv = vec![None; 36];
|
||||
for (i, s) in stacks {
|
||||
inv[*i] = Some(s.clone());
|
||||
}
|
||||
inv
|
||||
}
|
||||
fn max(s: &ItemStack) -> u32 {
|
||||
if &*s.item == "minecraft:diamond_sword" {
|
||||
1
|
||||
} else {
|
||||
64
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_clicks_map_to_the_inventory_below() {
|
||||
// A 6-row chest: 54 slots, then the inventory's 27, then the hotbar.
|
||||
assert_eq!(native_menu_inventory_slot(54, 3), None);
|
||||
assert_eq!(native_menu_inventory_slot(54, 54), Some(9));
|
||||
assert_eq!(native_menu_inventory_slot(54, 80), Some(35));
|
||||
assert_eq!(native_menu_inventory_slot(54, 81), Some(0));
|
||||
assert_eq!(native_menu_inventory_slot(54, 89), Some(8));
|
||||
assert_eq!(native_menu_inventory_slot(54, 90), None);
|
||||
assert_eq!(native_menu_inventory_slot(54, -999), None);
|
||||
assert_eq!(native_menu_inventory_slot(9, 9), Some(9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exchange_swaps_both_offers() {
|
||||
let mut a = inventory(&[
|
||||
(0, stack("minecraft:diamond", 5)),
|
||||
(1, stack("minecraft:dirt", 64)),
|
||||
]);
|
||||
let mut b = inventory(&[
|
||||
(4, stack("minecraft:diamond_sword", 1)),
|
||||
(5, stack("minecraft:diamond", 60)),
|
||||
]);
|
||||
let a_offer = [(0, stack("minecraft:diamond", 5))];
|
||||
let b_offer = [(4, stack("minecraft:diamond_sword", 1))];
|
||||
exchange(&mut a, &a_offer, &mut b, &b_offer, max).unwrap();
|
||||
// A gets the sword in the slot their diamonds left; B's diamonds
|
||||
// fill their stack to 64, the rest in the first empty slot.
|
||||
assert_eq!(a[0], Some(stack("minecraft:diamond_sword", 1)));
|
||||
assert_eq!(a[1], Some(stack("minecraft:dirt", 64)));
|
||||
assert_eq!(b[0], Some(stack("minecraft:diamond", 1)));
|
||||
assert_eq!(b[4], None);
|
||||
assert_eq!(b[5], Some(stack("minecraft:diamond", 64)));
|
||||
let count = |inv: &[Option<ItemStack>], item: &str| -> u32 {
|
||||
inv.iter()
|
||||
.flatten()
|
||||
.filter(|s| &*s.item == item)
|
||||
.map(|s| s.count)
|
||||
.sum()
|
||||
};
|
||||
// Nothing made, nothing lost.
|
||||
assert_eq!(
|
||||
count(&a, "minecraft:diamond") + count(&b, "minecraft:diamond"),
|
||||
65
|
||||
);
|
||||
assert_eq!(
|
||||
count(&a, "minecraft:diamond_sword") + count(&b, "minecraft:diamond_sword"),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exchange_that_cant_complete_moves_nothing() {
|
||||
let a0 = inventory(&[(0, stack("minecraft:diamond", 5))]);
|
||||
let b0 = inventory(&[(2, stack("minecraft:emerald", 3))]);
|
||||
// The offer changed since it was made (moved, dropped, used).
|
||||
let (mut a, mut b) = (a0.clone(), b0.clone());
|
||||
let stale = [(0, stack("minecraft:diamond", 6))];
|
||||
assert!(exchange(&mut a, &stale, &mut b, &[], max).is_err());
|
||||
assert_eq!((&a, &b), (&a0, &b0));
|
||||
// An empty or out-of-range slot.
|
||||
assert!(
|
||||
exchange(
|
||||
&mut a,
|
||||
&[(7, stack("minecraft:diamond", 5))],
|
||||
&mut b,
|
||||
&[],
|
||||
max
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
exchange(
|
||||
&mut a,
|
||||
&[(36, stack("minecraft:diamond", 5))],
|
||||
&mut b,
|
||||
&[],
|
||||
max
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!((&a, &b), (&a0, &b0));
|
||||
// No room: B's inventory is full of other things.
|
||||
let full: Vec<_> = (0..36).map(|_| Some(stack("minecraft:dirt", 64))).collect();
|
||||
let mut b = full.clone();
|
||||
let offer = [(0, stack("minecraft:diamond", 5))];
|
||||
assert!(exchange(&mut a, &offer, &mut b, &[], max).is_err());
|
||||
assert_eq!((&a, &b), (&a0, &full));
|
||||
// The same slot offered twice is the second time empty.
|
||||
let twice = [
|
||||
(0, stack("minecraft:diamond", 5)),
|
||||
(0, stack("minecraft:diamond", 5)),
|
||||
];
|
||||
let mut b = b0.clone();
|
||||
assert!(exchange(&mut a, &twice, &mut b, &[], max).is_err());
|
||||
assert_eq!((&a, &b), (&a0, &b0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
//! Server commands and players for native mods (docs/native-mods.md,
|
||||
//! "Server commands and players", API 8): the commands mods add to the
|
||||
//! server's command tree (`native_register_commands`), a player's command
|
||||
//! reaching them (`native_perform_command`), and the helpers a server mod
|
||||
//! answers with: the players online, a line to one of them or to all, the
|
||||
//! command's feedback and the world's folder; and (API 9) the acting
|
||||
//! player's plain items: count, take, give. They act on the server's own
|
||||
//! players (the hub's: a dedicated server's, a LAN host's, single player's
|
||||
//! server thread's), never on a client's.
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::{items::ItemStack, lan::data::component_nbt, text::Text};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
/// A player on the server, as a server hook sees them.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct NativeServerPlayer {
|
||||
pub(crate) uuid: u128,
|
||||
pub(crate) name: String,
|
||||
/// The operator level, 0 to 4 (`ops.json`; single player's owner with
|
||||
/// cheats on is 4).
|
||||
pub(crate) permission: u8,
|
||||
/// `minecraft:overworld`, `minecraft:the_nether`, `minecraft:the_end`.
|
||||
pub(crate) dimension: String,
|
||||
pub(crate) position: [f64; 3],
|
||||
/// 0 survival, 1 creative, 2 adventure, 3 spectator.
|
||||
pub(crate) game_mode: i32,
|
||||
}
|
||||
|
||||
/// A command a native mod adds, as one usage: words, then arguments
|
||||
/// `<name:kind>`, optional ones last in brackets `[name:kind]`:
|
||||
/// `pay <player:player> <amount:integer>`, `balance [player:player]`,
|
||||
/// `eco give <player:player> <amount:integer>`. Kinds: `word`, `text` (the
|
||||
/// rest of the line, last), `integer`, `number`, `boolean`, `player`,
|
||||
/// `item`, `block_pos`. Usages of one command share their words; the
|
||||
/// server merges them into one tree.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct NativeCommand {
|
||||
pub(crate) usage: String,
|
||||
/// The operator level a player needs to see and run it (0: everyone).
|
||||
pub(crate) level: u8,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl NativeCommand {
|
||||
/// A usage every player may run.
|
||||
pub(crate) fn new(usage: &str) -> Self {
|
||||
Self {
|
||||
usage: usage.to_owned(),
|
||||
level: 0,
|
||||
}
|
||||
}
|
||||
/// The same, for operators of `level` and above.
|
||||
pub(crate) fn op(usage: &str, level: u8) -> Self {
|
||||
Self {
|
||||
usage: usage.to_owned(),
|
||||
level: level.min(4),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The brigadier parser of an argument kind, and its properties.
|
||||
fn parser(kind: &str) -> Option<(&'static str, Value)> {
|
||||
Some(match kind {
|
||||
"word" => ("brigadier:string", json!({"type": "word"})),
|
||||
"text" => ("brigadier:string", json!({"type": "greedy"})),
|
||||
"integer" => ("brigadier:integer", Value::Null),
|
||||
"number" => ("brigadier:double", Value::Null),
|
||||
"boolean" => ("brigadier:bool", Value::Null),
|
||||
"player" => (
|
||||
"minecraft:entity",
|
||||
json!({"amount": "single", "type": "players"}),
|
||||
),
|
||||
"item" => ("minecraft:item_stack", Value::Null),
|
||||
"block_pos" => ("minecraft:block_pos", Value::Null),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The report's name of an operator level (`commands.json`'s
|
||||
/// `permissions`).
|
||||
const LEVELS: [&str; 5] = ["", "moderators", "gamemasters", "admins", "owners"];
|
||||
|
||||
/// One parsed word of a usage: its key in the tree and its node, without
|
||||
/// children.
|
||||
fn usage_nodes(usage: &str) -> Result<Vec<(String, Value, bool)>, String> {
|
||||
let mut out = Vec::new();
|
||||
let mut optional_seen = false;
|
||||
let mut greedy_seen = false;
|
||||
for word in usage.split_whitespace() {
|
||||
if greedy_seen {
|
||||
return Err(format!("`{usage}`: a text argument must be last"));
|
||||
}
|
||||
let (inner, optional) = if let Some(w) = word.strip_prefix('<') {
|
||||
(w.strip_suffix('>'), false)
|
||||
} else if let Some(w) = word.strip_prefix('[') {
|
||||
(w.strip_suffix(']'), true)
|
||||
} else {
|
||||
if optional_seen {
|
||||
return Err(format!("`{usage}`: `{word}` follows an optional argument"));
|
||||
}
|
||||
let literal = !word.is_empty()
|
||||
&& word
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "_-".contains(c));
|
||||
if !literal {
|
||||
return Err(format!("`{usage}`: `{word}` is not a command word"));
|
||||
}
|
||||
out.push((word.to_owned(), json!({"type": "literal"}), false));
|
||||
continue;
|
||||
};
|
||||
let Some((name, kind)) = inner.and_then(|w| w.split_once(':')) else {
|
||||
return Err(format!("`{usage}`: `{word}` is not `<name:kind>`"));
|
||||
};
|
||||
if optional_seen && !optional {
|
||||
return Err(format!("`{usage}`: `{word}` follows an optional argument"));
|
||||
}
|
||||
let Some((parser, properties)) = parser(kind) else {
|
||||
return Err(format!("`{usage}`: unknown kind `{kind}`"));
|
||||
};
|
||||
optional_seen |= optional;
|
||||
greedy_seen |= kind == "text";
|
||||
let mut node = json!({"type": "argument", "parser": parser});
|
||||
if !properties.is_null() {
|
||||
node["properties"] = properties;
|
||||
}
|
||||
out.push((name.to_owned(), node, optional));
|
||||
}
|
||||
if out.first().is_none_or(|n| n.1["type"] != "literal") {
|
||||
return Err(format!("`{usage}`: a command starts with its name"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Native mods' commands as a root in the commands report's format
|
||||
/// (`lan::commands`, which sends each player what their level allows), and
|
||||
/// each command's level: the lowest of its usages. A node is runnable where
|
||||
/// only optional arguments follow it. A command vanilla has (`vanilla`
|
||||
/// answers) or a usage that doesn't parse is left out, with why.
|
||||
pub(super) fn command_tree(
|
||||
commands: &[NativeCommand],
|
||||
vanilla: impl Fn(&str) -> bool,
|
||||
) -> (Value, HashMap<String, u8>, Vec<String>) {
|
||||
let mut root = Map::new();
|
||||
let mut levels: HashMap<String, u8> = HashMap::new();
|
||||
let mut problems = Vec::new();
|
||||
for command in commands {
|
||||
let nodes = match usage_nodes(&command.usage) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
problems.push(e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if vanilla(&nodes[0].0) {
|
||||
problems.push(format!("`{}`: vanilla has /{}", command.usage, nodes[0].0));
|
||||
continue;
|
||||
}
|
||||
let mut children = &mut root;
|
||||
for (i, (name, node, _)) in nodes.iter().enumerate() {
|
||||
let runnable = nodes.get(i + 1).is_none_or(|n| n.2);
|
||||
let entry = children.entry(name.clone()).or_insert_with(|| {
|
||||
let mut n = node.clone();
|
||||
n["executable"] = false.into();
|
||||
n["level"] = 4.into();
|
||||
n
|
||||
});
|
||||
if entry["type"] != node["type"] || entry["parser"] != node["parser"] {
|
||||
problems.push(format!(
|
||||
"`{}`: `{name}` is another kind in an earlier usage",
|
||||
command.usage
|
||||
));
|
||||
break;
|
||||
}
|
||||
if runnable {
|
||||
entry["executable"] = true.into();
|
||||
}
|
||||
let level = entry["level"]
|
||||
.as_u64()
|
||||
.unwrap_or(4)
|
||||
.min(command.level.into());
|
||||
entry["level"] = level.into();
|
||||
if !entry["children"].is_object() {
|
||||
entry["children"] = Value::Object(Map::new());
|
||||
}
|
||||
children = entry["children"].as_object_mut().expect("made above");
|
||||
}
|
||||
let level = levels.entry(nodes[0].0.clone()).or_insert(4);
|
||||
*level = (*level).min(command.level);
|
||||
}
|
||||
let mut root = Value::Object(root);
|
||||
finish(&mut root);
|
||||
let tree = if levels.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
json!({"type": "root", "children": root})
|
||||
};
|
||||
(tree, levels, problems)
|
||||
}
|
||||
|
||||
/// Turns the working `level` into the report's `permissions`, and drops
|
||||
/// empty child lists.
|
||||
fn finish(children: &mut Value) {
|
||||
let Some(map) = children.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
for node in map.values_mut() {
|
||||
let level = node["level"].as_u64().unwrap_or(0) as usize;
|
||||
let object = node.as_object_mut().expect("a node");
|
||||
object.remove("level");
|
||||
if level > 0 {
|
||||
object.insert(
|
||||
"permissions".into(),
|
||||
json!({"type": "require", "permission": {"level": LEVELS[level.min(4)]}}),
|
||||
);
|
||||
}
|
||||
if object
|
||||
.get("children")
|
||||
.is_some_and(|c| c.as_object().is_some_and(Map::is_empty))
|
||||
{
|
||||
object.remove("children");
|
||||
} else if let Some(c) = object.get_mut("children") {
|
||||
finish(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helpers for server mods, kept as stable as the hook points.
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// The players on this server, in the order they joined. Empty on a
|
||||
/// client and in a world nobody else can join.
|
||||
pub(super) fn native_server_players(&self) -> Vec<NativeServerPlayer> {
|
||||
let Some(host) = &self.lan else {
|
||||
return Vec::new();
|
||||
};
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed)
|
||||
.map(|g| NativeServerPlayer {
|
||||
uuid: g.uuid,
|
||||
name: g.name.clone(),
|
||||
permission: g.permission,
|
||||
dimension: g.dimension.clone(),
|
||||
position: g.position,
|
||||
game_mode: g.game_mode,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// A system line in that player's chat; false when they aren't on.
|
||||
pub(super) fn native_tell(&self, uuid: u128, line: Text) -> bool {
|
||||
let Some(host) = &self.lan else {
|
||||
return false;
|
||||
};
|
||||
let Some(g) = host.hub.guests.iter().find(|g| g.placed && g.uuid == uuid) else {
|
||||
return false;
|
||||
};
|
||||
host.hub.send_to(g.conn, &system_chat(&line));
|
||||
true
|
||||
}
|
||||
/// A system line in every player's chat and the server's log.
|
||||
pub(super) fn native_broadcast(&self, line: Text) {
|
||||
let plain = line.plain();
|
||||
if self.options.dedicated.is_some() {
|
||||
super::dedicated::log("INFO", &plain);
|
||||
} else {
|
||||
log::info!("{plain}");
|
||||
}
|
||||
let Some(host) = &self.lan else {
|
||||
return;
|
||||
};
|
||||
let packet = system_chat(&line);
|
||||
for g in host.hub.guests.iter().filter(|g| g.placed) {
|
||||
host.hub.send_to(g.conn, &packet);
|
||||
}
|
||||
}
|
||||
/// A line of the command's answer, inside `native_perform_command`:
|
||||
/// sent to the player whose command it is after the call, as vanilla
|
||||
/// commands' feedback is.
|
||||
pub(super) fn native_feedback(&mut self, line: Text) {
|
||||
match &mut self.chat_capture {
|
||||
Some(lines) => lines.push(line),
|
||||
None => self.system_message(line),
|
||||
}
|
||||
}
|
||||
/// The open world's folder (`world/`, beside its `level.dat`): a server
|
||||
/// mod keeps its data in a folder of its own there, so the world's
|
||||
/// backups carry it. None when no world is open.
|
||||
pub(super) fn native_world_dir(&self) -> Option<PathBuf> {
|
||||
(!self.world_root.as_os_str().is_empty()).then(|| self.world_root.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain stacks: no components beyond the count (no damage, name,
|
||||
/// enchantment...), the only ones the item helpers count, take or give, so
|
||||
/// a mod never prices a worn or enchanted item as a new one.
|
||||
fn plain(stack: &ItemStack) -> bool {
|
||||
stack.damage == 0 && stack.extra.is_none()
|
||||
}
|
||||
|
||||
/// The slots the item helpers look at, in `Inventory.add`'s order: the
|
||||
/// selected slot, the off hand, then the hotbar and the main inventory.
|
||||
fn order(selected: usize) -> impl Iterator<Item = usize> {
|
||||
std::iter::once(selected)
|
||||
.chain(std::iter::once(OFFHAND))
|
||||
.chain((0..36).filter(move |&i| i != selected))
|
||||
}
|
||||
const OFFHAND: usize = 36;
|
||||
|
||||
/// The 36 inventory slots and the off hand as one list ([`OFFHAND`] last).
|
||||
fn slots<'a>(
|
||||
inventory: &'a mut [Option<ItemStack>],
|
||||
offhand: &'a mut Option<ItemStack>,
|
||||
) -> Vec<&'a mut Option<ItemStack>> {
|
||||
inventory
|
||||
.iter_mut()
|
||||
.take(36)
|
||||
.chain(std::iter::once(offhand))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How many of plain `item` the slots hold.
|
||||
fn count_in(slots: &[&mut Option<ItemStack>], item: &str) -> u32 {
|
||||
slots
|
||||
.iter()
|
||||
.filter_map(|s| s.as_ref())
|
||||
.filter(|s| &*s.item == item && plain(s))
|
||||
.map(|s| s.count)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Takes up to `count` plain `item`, selected slot first; what it took.
|
||||
fn take_from(
|
||||
mut slots: Vec<&mut Option<ItemStack>>,
|
||||
selected: usize,
|
||||
item: &str,
|
||||
count: u32,
|
||||
) -> u32 {
|
||||
let mut taken = 0;
|
||||
for i in order(selected) {
|
||||
let slot = &mut *slots[i];
|
||||
if let Some(s) = slot
|
||||
&& &*s.item == item
|
||||
&& plain(s)
|
||||
{
|
||||
let n = s.count.min(count - taken);
|
||||
s.count -= n;
|
||||
taken += n;
|
||||
if s.count == 0 {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
if taken == count {
|
||||
break;
|
||||
}
|
||||
}
|
||||
taken
|
||||
}
|
||||
|
||||
/// How many plain `item` (stacking to `max`) still fit.
|
||||
fn room_in(slots: &[&mut Option<ItemStack>], item: &str, max: u32) -> u32 {
|
||||
slots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, s)| match s.as_ref() {
|
||||
// getFreeSlot: never into an empty off hand.
|
||||
None if i == OFFHAND => 0,
|
||||
None => max,
|
||||
Some(s) if &*s.item == item && plain(s) => max.saturating_sub(s.count),
|
||||
Some(_) => 0,
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Adds up to `count` plain `item`, onto its stacks first
|
||||
/// (`getSlotWithRemainingSpace`: the off hand's too), then into empty slots
|
||||
/// of the inventory (`getFreeSlot`), in [`order`]; what it added. Nothing
|
||||
/// is dropped.
|
||||
fn add_to(
|
||||
mut slots: Vec<&mut Option<ItemStack>>,
|
||||
selected: usize,
|
||||
item: &str,
|
||||
max: u32,
|
||||
count: u32,
|
||||
) -> u32 {
|
||||
let mut added = 0;
|
||||
for pass in 0..2 {
|
||||
for i in order(selected) {
|
||||
let slot = &mut *slots[i];
|
||||
let n = match slot {
|
||||
Some(s) if pass == 0 && &*s.item == item && plain(s) => {
|
||||
let n = max.saturating_sub(s.count).min(count - added);
|
||||
s.count += n;
|
||||
n
|
||||
}
|
||||
None if pass == 1 && i != OFFHAND => {
|
||||
let n = max.min(count - added);
|
||||
*slot = Some(ItemStack {
|
||||
item: item.into(),
|
||||
count: n,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
});
|
||||
n
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
added += n;
|
||||
if added == count {
|
||||
return added;
|
||||
}
|
||||
}
|
||||
}
|
||||
added
|
||||
}
|
||||
|
||||
/// The acting player's items, for server mods (API 9): the player whose
|
||||
/// action or command a hook hears (`native_perform_command`), as their
|
||||
/// state is swapped in. Only plain stacks count; the inventory's 36 slots
|
||||
/// and the off hand, never the armour. Each change reaches the player's
|
||||
/// screen as any change to their inventory does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// The main hand's stack: its item, count, and whether it is plain.
|
||||
pub(super) fn native_held(&self) -> Option<(String, u32, bool)> {
|
||||
let s = self.inventory.get(self.selected)?.as_ref()?;
|
||||
Some((s.item.to_string(), s.count, plain(s)))
|
||||
}
|
||||
/// How many of plain `item` (`minecraft:diamond`) the player carries.
|
||||
pub(super) fn native_count_items(&mut self, item: &str) -> u32 {
|
||||
count_in(&slots(&mut self.inventory, &mut self.offhand), item)
|
||||
}
|
||||
/// Every plain item the player carries, with how many, by id.
|
||||
pub(super) fn native_carried_items(&self) -> Vec<(String, u32)> {
|
||||
let mut out: Vec<(String, u32)> = Vec::new();
|
||||
let held = self
|
||||
.inventory
|
||||
.iter()
|
||||
.take(36)
|
||||
.chain(std::iter::once(&self.offhand));
|
||||
for s in held.flatten().filter(|s| plain(s)) {
|
||||
match out.iter_mut().find(|(i, _)| *i == *s.item) {
|
||||
Some((_, n)) => *n += s.count,
|
||||
None => out.push((s.item.to_string(), s.count)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Takes up to `count` plain `item`, the main hand first; what it took.
|
||||
pub(super) fn native_take_items(&mut self, item: &str, count: u32) -> u32 {
|
||||
if self.player.is_none() {
|
||||
return 0;
|
||||
}
|
||||
let selected = self.selected;
|
||||
take_from(
|
||||
slots(&mut self.inventory, &mut self.offhand),
|
||||
selected,
|
||||
item,
|
||||
count,
|
||||
)
|
||||
}
|
||||
/// How many plain `item` still fit; 0 for an unknown item.
|
||||
pub(super) fn native_room_for(&mut self, item: &str) -> u32 {
|
||||
let Some(max) = self.res.items().get(item).map(|i| i.max_stack) else {
|
||||
return 0;
|
||||
};
|
||||
room_in(
|
||||
&slots(&mut self.inventory, &mut self.offhand),
|
||||
item,
|
||||
max.max(1),
|
||||
)
|
||||
}
|
||||
/// Gives up to `count` plain `item` where they fit, as `Inventory.add`
|
||||
/// places them; what it gave (nothing is dropped). 0 for an unknown item.
|
||||
pub(super) fn native_give_items(&mut self, item: &str, count: u32) -> u32 {
|
||||
let Some(max) = self.res.items().get(item).map(|i| i.max_stack) else {
|
||||
return 0;
|
||||
};
|
||||
if self.player.is_none() {
|
||||
return 0;
|
||||
}
|
||||
let selected = self.selected;
|
||||
add_to(
|
||||
slots(&mut self.inventory, &mut self.offhand),
|
||||
selected,
|
||||
item,
|
||||
max.max(1),
|
||||
count,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn system_chat(line: &Text) -> C {
|
||||
C::SystemChat(play::SystemChat {
|
||||
content: component_nbt(&line.component()),
|
||||
overlay: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Where the game calls the server hook points.
|
||||
impl Game<'_> {
|
||||
/// Asks the mods for their commands once per hub, and puts them in every
|
||||
/// player's command tree.
|
||||
pub(super) fn register_native_commands(&mut self, host: &mut LanHost) {
|
||||
if host.native_commands.is_some() {
|
||||
return;
|
||||
}
|
||||
let mut commands = Vec::new();
|
||||
self.native_register_commands(&mut commands);
|
||||
let (tree, levels, problems) =
|
||||
command_tree(&commands, |name| host.hub.vanilla_command(name));
|
||||
for p in problems {
|
||||
log::warn!("Horizon: a native mod's command was left out: {p}");
|
||||
}
|
||||
host.hub.set_mod_commands(0, tree);
|
||||
host.native_commands = Some(levels);
|
||||
}
|
||||
|
||||
/// The level a guest needs for a mod's command (`line`, without its
|
||||
/// slash), when a mod registered it.
|
||||
pub(super) fn native_command_level(host: &LanHost, line: &str) -> Option<u8> {
|
||||
let name = line.split(' ').next().unwrap_or_default();
|
||||
host.native_commands.as_ref()?.get(name).copied()
|
||||
}
|
||||
|
||||
/// A guest's command for the mods, inside their swap
|
||||
/// (`run_guest_command`): true when one ran it.
|
||||
pub(super) fn perform_native_command(&mut self, conn: u32, permission: u8, line: &str) -> bool {
|
||||
let Some(host) = &self.lan else {
|
||||
return false;
|
||||
};
|
||||
if Self::native_command_level(host, line).is_none_or(|level| level > permission) {
|
||||
return false;
|
||||
}
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return false;
|
||||
};
|
||||
let player = NativeServerPlayer {
|
||||
uuid: g.uuid,
|
||||
name: g.name.clone(),
|
||||
permission,
|
||||
dimension: g.dimension.clone(),
|
||||
position: g.position,
|
||||
game_mode: g.game_mode,
|
||||
};
|
||||
let mut ran = false;
|
||||
self.native_perform_command(&player, line, &mut ran);
|
||||
if !ran {
|
||||
// Registered, but no mod took it: vanilla's unknown command.
|
||||
self.native_feedback(
|
||||
Text::translate("command.unknown.command", &[])
|
||||
.with_color(horizon_client::text::RED),
|
||||
);
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tree(commands: &[NativeCommand]) -> (Value, HashMap<String, u8>, Vec<String>) {
|
||||
command_tree(commands, |name| name == "give")
|
||||
}
|
||||
|
||||
fn stack(item: &str, count: u32) -> Option<ItemStack> {
|
||||
Some(ItemStack {
|
||||
item: item.into(),
|
||||
count,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_are_taken_from_the_hand_first_and_only_plain() {
|
||||
let mut inv = vec![None; 36];
|
||||
inv[0] = stack("minecraft:diamond", 10);
|
||||
inv[4] = stack("minecraft:diamond", 5);
|
||||
inv[8] = stack("minecraft:diamond", 7);
|
||||
inv[8].as_mut().unwrap().damage = 3;
|
||||
let mut off = stack("minecraft:diamond", 2);
|
||||
assert_eq!(
|
||||
count_in(&slots(&mut inv, &mut off), "minecraft:diamond"),
|
||||
17
|
||||
);
|
||||
assert_eq!(
|
||||
take_from(slots(&mut inv, &mut off), 4, "minecraft:diamond", 6),
|
||||
6
|
||||
);
|
||||
assert!(inv[4].is_none(), "the selected slot first");
|
||||
assert_eq!(off.as_ref().unwrap().count, 1, "then the off hand");
|
||||
assert_eq!(
|
||||
take_from(slots(&mut inv, &mut off), 4, "minecraft:diamond", 100),
|
||||
11
|
||||
);
|
||||
assert_eq!(inv[8].as_ref().unwrap().count, 7, "a damaged stack stays");
|
||||
assert!(inv[0].is_none() && off.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_are_given_only_where_they_fit() {
|
||||
let mut inv = vec![stack("minecraft:dirt", 64); 36];
|
||||
inv[3] = stack("minecraft:diamond", 60);
|
||||
inv[9] = None;
|
||||
let mut off = None;
|
||||
assert_eq!(
|
||||
room_in(&slots(&mut inv, &mut off), "minecraft:diamond", 64),
|
||||
4 + 64
|
||||
);
|
||||
assert_eq!(
|
||||
add_to(slots(&mut inv, &mut off), 0, "minecraft:diamond", 64, 200),
|
||||
68
|
||||
);
|
||||
assert_eq!(inv[3].as_ref().unwrap().count, 64, "onto its stack first");
|
||||
assert!(off.is_none(), "never into an empty off hand (getFreeSlot)");
|
||||
assert_eq!(inv[9].as_ref().unwrap().count, 64);
|
||||
assert_eq!(
|
||||
room_in(&slots(&mut inv, &mut off), "minecraft:diamond", 64),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
add_to(slots(&mut inv, &mut off), 0, "minecraft:diamond", 64, 1),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usages_merge_into_one_tree_with_runnable_nodes() {
|
||||
let (t, levels, problems) = tree(&[
|
||||
NativeCommand::new("balance [player:player]"),
|
||||
NativeCommand::new("pay <player:player> <amount:integer>"),
|
||||
NativeCommand::op("eco give <player:player> <amount:integer>", 3),
|
||||
NativeCommand::op("eco take <player:player> <amount:integer>", 3),
|
||||
]);
|
||||
assert!(problems.is_empty(), "{problems:?}");
|
||||
let c = &t["children"];
|
||||
assert_eq!(c["balance"]["executable"], true);
|
||||
assert_eq!(c["balance"]["children"]["player"]["executable"], true);
|
||||
assert_eq!(
|
||||
c["balance"]["children"]["player"]["parser"],
|
||||
"minecraft:entity"
|
||||
);
|
||||
assert_eq!(c["pay"]["executable"], false);
|
||||
assert_eq!(c["pay"]["children"]["player"]["executable"], false);
|
||||
let amount = &c["pay"]["children"]["player"]["children"]["amount"];
|
||||
assert_eq!(amount["executable"], true);
|
||||
assert_eq!(amount["parser"], "brigadier:integer");
|
||||
assert!(amount.get("children").is_none());
|
||||
assert_eq!(c["eco"]["permissions"]["permission"]["level"], "admins");
|
||||
assert!(c["eco"]["children"]["give"].is_object());
|
||||
assert!(c["eco"]["children"]["take"].is_object());
|
||||
assert!(c["pay"].get("permissions").is_none());
|
||||
assert_eq!(levels["eco"], 3);
|
||||
assert_eq!(levels["pay"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_takes_its_lowest_level() {
|
||||
let (t, levels, _) = tree(&[
|
||||
NativeCommand::new("warp [name:word]"),
|
||||
NativeCommand::op("warp set <name:word>", 2),
|
||||
]);
|
||||
assert_eq!(levels["warp"], 0);
|
||||
assert!(t["children"]["warp"].get("permissions").is_none());
|
||||
let set = &t["children"]["warp"]["children"]["set"];
|
||||
assert_eq!(set["permissions"]["permission"]["level"], "gamemasters");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_usages_and_vanilla_names_are_left_out() {
|
||||
let (t, levels, problems) = tree(&[
|
||||
NativeCommand::new("give <x:integer>"),
|
||||
NativeCommand::new("a [x:integer] <y:integer>"),
|
||||
NativeCommand::new("b <x:text> <y:word>"),
|
||||
NativeCommand::new("c <x:colour>"),
|
||||
NativeCommand::new("<x:integer>"),
|
||||
NativeCommand::new("Bad"),
|
||||
NativeCommand::new("d <x:integer>"),
|
||||
NativeCommand::new("d <x:word>"),
|
||||
]);
|
||||
assert_eq!(problems.len(), 7, "{problems:?}");
|
||||
assert_eq!(levels.keys().collect::<Vec<_>>(), ["d"]);
|
||||
assert_eq!(
|
||||
t["children"]["d"]["children"]["x"]["parser"],
|
||||
"brigadier:integer"
|
||||
);
|
||||
let (none, levels, _) = tree(&[]);
|
||||
assert!(none.is_null() && levels.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -41,20 +41,19 @@ impl Game<'_> {
|
||||
self.levels.parked_world(dim)
|
||||
}
|
||||
.ok_or(NativeBlockReadError::UnavailableDimension)?;
|
||||
read_block(world, dim, self.pack.assets.registry, pos)
|
||||
read_block(world, self.pack.assets.registry, pos)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an existing world state, preserving unloaded and invalid states.
|
||||
fn read_block(
|
||||
world: &World,
|
||||
dimension: Dim,
|
||||
registry: &BlockRegistry,
|
||||
[x, y, z]: [i32; 3],
|
||||
) -> Result<BlockState, NativeBlockReadError> {
|
||||
if !(-30_000_000..30_000_000).contains(&x)
|
||||
|| !(-30_000_000..30_000_000).contains(&z)
|
||||
|| !(dimension.min_y()..=dimension.max_y()).contains(&y)
|
||||
|| !(world.shape.min_y..world.shape.max_y()).contains(&y)
|
||||
{
|
||||
return Err(NativeBlockReadError::OutOfBounds);
|
||||
}
|
||||
@@ -84,30 +83,32 @@ mod tests {
|
||||
let mut world = World::new(BlockStateId(0), 0);
|
||||
world.chunks.insert((-1, 0), Arc::new(Chunk::empty(-1, 0)));
|
||||
world.set(-1, 64, 1, BlockStateId(1)).unwrap();
|
||||
let block = read_block(&world, Dim::Overworld, ®istry, [-1, 64, 1]).unwrap();
|
||||
// The Nether's build range starts at y 0 (its level's shape).
|
||||
let mut nether = World::new(BlockStateId(0), 0);
|
||||
nether.shape = horizon_client::chunk::LevelShape::NETHER;
|
||||
nether.chunks.insert((-1, 0), Arc::new(Chunk::empty(-1, 0)));
|
||||
let block = read_block(&world, ®istry, [-1, 64, 1]).unwrap();
|
||||
assert_eq!(block.name, "test:pillar");
|
||||
assert_eq!(block.properties["axis"], "x");
|
||||
assert_eq!(
|
||||
read_block(&world, Dim::Overworld, ®istry, [-1, 65, 1])
|
||||
.unwrap()
|
||||
.name,
|
||||
read_block(&world, ®istry, [-1, 65, 1]).unwrap().name,
|
||||
"minecraft:air"
|
||||
);
|
||||
assert_eq!(
|
||||
read_block(&world, Dim::Overworld, ®istry, [0, 64, 1]),
|
||||
read_block(&world, ®istry, [0, 64, 1]),
|
||||
Err(NativeBlockReadError::Unloaded)
|
||||
);
|
||||
assert_eq!(
|
||||
read_block(&world, Dim::Nether, ®istry, [-1, -1, 1]),
|
||||
read_block(&nether, ®istry, [-1, -1, 1]),
|
||||
Err(NativeBlockReadError::OutOfBounds)
|
||||
);
|
||||
assert_eq!(
|
||||
read_block(&world, Dim::Overworld, ®istry, [30_000_000, 64, 1]),
|
||||
read_block(&world, ®istry, [30_000_000, 64, 1]),
|
||||
Err(NativeBlockReadError::OutOfBounds)
|
||||
);
|
||||
world.set(-1, 64, 1, BlockStateId(99)).unwrap();
|
||||
assert_eq!(
|
||||
read_block(&world, Dim::Overworld, ®istry, [-1, 64, 1]),
|
||||
read_block(&world, ®istry, [-1, 64, 1]),
|
||||
Err(NativeBlockReadError::InvalidState)
|
||||
);
|
||||
assert_eq!(world.chunks.len(), 1, "reads must not load chunks");
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
//! A game started without its prepared files (the game build run by hand, not
|
||||
//! through the launcher): a window that says where to get the launcher
|
||||
//! (`horizon_client::no_resources`) and quits when it is closed. No loader, no
|
||||
//! crash report: nothing went wrong in the game.
|
||||
use super::*;
|
||||
use horizon_client::no_resources::{Action, DOWNLOAD_URL, NoResources};
|
||||
use winit::event::MouseButton;
|
||||
|
||||
struct Missing {
|
||||
window: Option<Arc<Window>>,
|
||||
renderer: Option<Renderer>,
|
||||
gui: UiResources,
|
||||
screen: NoResources,
|
||||
/// The pointer in physical pixels.
|
||||
pointer: (f64, f64),
|
||||
api: horizon_client::graphics::GraphicsApi,
|
||||
error: Option<Box<dyn std::error::Error>>,
|
||||
}
|
||||
|
||||
/// Shows the screen until the player closes it.
|
||||
pub(super) fn show(looked_in: &std::path::Path, settings: &Settings) -> Result<()> {
|
||||
let mut app = Missing {
|
||||
window: None,
|
||||
renderer: None,
|
||||
gui: UiResources {
|
||||
atlas: HudAtlas::build(looked_in)?,
|
||||
panorama: Arc::new(horizon_client::gfx::panorama::Faces {
|
||||
size: 1,
|
||||
pixels: [0u8; 4].repeat(6),
|
||||
}),
|
||||
},
|
||||
screen: NoResources::new(looked_in),
|
||||
pointer: (0.0, 0.0),
|
||||
api: settings.horizon.api,
|
||||
error: None,
|
||||
};
|
||||
horizon_client::hud::GUI_SCALE.store(settings.gui_scale, Ordering::Relaxed);
|
||||
EventLoop::new()?.run_app(&mut app)?;
|
||||
app.error.map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
impl Missing {
|
||||
fn size(&self) -> [u32; 2] {
|
||||
self.renderer
|
||||
.as_ref()
|
||||
.map_or([1280, 720], |r| [r.config.width, r.config.height])
|
||||
}
|
||||
|
||||
/// `f` over a Ui of the window's size, the cursor where the pointer is.
|
||||
fn with_ui<R>(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
f: impl FnOnce(&mut horizon_client::ui_theme::Ui) -> R,
|
||||
) -> R {
|
||||
let cursor = (
|
||||
self.pointer.0 as f32 / batch.scale,
|
||||
self.pointer.1 as f32 / batch.scale,
|
||||
);
|
||||
let mut ui = horizon_client::ui_theme::Ui::new(&self.gui.atlas, batch, cursor);
|
||||
let result = f(&mut ui);
|
||||
ui.finish();
|
||||
result
|
||||
}
|
||||
|
||||
fn draw(&mut self) {
|
||||
let mut batch = HudBatch::new(self.size());
|
||||
self.with_ui(&mut batch, |ui| self.screen.draw(ui));
|
||||
let Some(renderer) = &mut self.renderer else {
|
||||
return;
|
||||
};
|
||||
match renderer.render(&loading_frame(&batch)) {
|
||||
Ok(true) => return,
|
||||
Ok(false) => {}
|
||||
Err(e) => log::warn!("Horizon: frame failed: {e}"),
|
||||
}
|
||||
// The frame was not shown (the first one often isn't): the glyphs it
|
||||
// rasterized never reached the atlas, so draw them again next frame.
|
||||
horizon_client::ui_theme::reset_atlas();
|
||||
if let Some(window) = &self.window {
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
fn click(&mut self, event_loop: &ActiveEventLoop) {
|
||||
let mut batch = HudBatch::new(self.size());
|
||||
match self.with_ui(&mut batch, |ui| self.screen.click(ui)) {
|
||||
Some(Action::Download) => packs::open_uri(DOWNLOAD_URL),
|
||||
Some(Action::Quit) => event_loop.exit(),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplicationHandler for Missing {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.window.is_some() {
|
||||
return;
|
||||
}
|
||||
let attributes = Window::default_attributes()
|
||||
.with_title("Horizon")
|
||||
.with_window_icon(horizon_client::window::icon())
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(1040.0, 700.0));
|
||||
let made = event_loop
|
||||
.create_window(attributes)
|
||||
.map_err(Into::into)
|
||||
.and_then(|window| {
|
||||
let window = Arc::new(window);
|
||||
let renderer =
|
||||
loading_renderer(window.clone(), false, Some(&self.gui), None, self.api)?;
|
||||
Ok((window, renderer))
|
||||
});
|
||||
match made {
|
||||
Ok((window, renderer)) => {
|
||||
window.request_redraw();
|
||||
self.window = Some(window);
|
||||
self.renderer = Some(renderer);
|
||||
}
|
||||
Err(e) => {
|
||||
self.error = Some(e);
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
|
||||
let Some(window) = self.window.clone().filter(|w| w.id() == id) else {
|
||||
return;
|
||||
};
|
||||
match event {
|
||||
WindowEvent::CloseRequested => event_loop.exit(),
|
||||
WindowEvent::Resized(size) => {
|
||||
if let Some(r) = &mut self.renderer {
|
||||
r.resize(size.width, size.height);
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
WindowEvent::RedrawRequested => self.draw(),
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
self.pointer = (position.x, position.y);
|
||||
window.request_redraw();
|
||||
}
|
||||
WindowEvent::MouseInput {
|
||||
state: ElementState::Released,
|
||||
button: MouseButton::Left,
|
||||
..
|
||||
} => self.click(event_loop),
|
||||
WindowEvent::KeyboardInput { event, .. }
|
||||
if event.state == ElementState::Pressed
|
||||
&& event.physical_key == PhysicalKey::Code(KeyCode::Escape) =>
|
||||
{
|
||||
event_loop.exit()
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if horizon_threads::stop_requests() > 0 {
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
// Wake now and then for Ctrl+C in the console; redraws come with events.
|
||||
event_loop.set_control_flow(ControlFlow::WaitUntil(
|
||||
Instant::now() + Duration::from_millis(250),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,10 @@ pub struct EntityMemory {
|
||||
/// the dragon): the UUID and compound they were loaded with, found again by
|
||||
/// type and position (an entity that has moved since loading is saved afresh).
|
||||
anon: HashMap<(String, [u64; 3]), (Vec3, Option<[i32; 4]>, Tag)>,
|
||||
/// UUIDs of the vehicles `restore_saved_vehicle` brought in from the
|
||||
/// player's file: their chunk copies must not spawn a second one. Cleared
|
||||
/// with the rest when a world closes.
|
||||
restored: HashSet<[i32; 4]>,
|
||||
}
|
||||
|
||||
fn anon_key(kind: &str, p: Vec3) -> (String, [u64; 3]) {
|
||||
@@ -249,6 +253,9 @@ impl Game<'_> {
|
||||
for tag in tags {
|
||||
let kind = entity_nbt::kind(&tag).unwrap_or_default();
|
||||
let uuid = entity_nbt::uuid(&tag);
|
||||
if uuid.is_some_and(|u| self.restored_vehicle(u).is_some()) {
|
||||
continue;
|
||||
}
|
||||
if matches!(
|
||||
kind.as_str(),
|
||||
"minecraft:experience_orb"
|
||||
@@ -471,8 +478,11 @@ impl Game<'_> {
|
||||
}
|
||||
/// A saved mob and, under `Passengers`, the mobs riding it.
|
||||
pub(super) fn load_mob(&mut self, tag: Tag, mount: Option<u32>) -> Option<u32> {
|
||||
self.next_entity += 1;
|
||||
let uuid = entity_nbt::uuid(&tag);
|
||||
if let Some(id) = uuid.and_then(|u| self.restored_vehicle(u)) {
|
||||
return Some(id);
|
||||
}
|
||||
self.next_entity += 1;
|
||||
// Structure mobs that generation left to finish: saves from before
|
||||
// they were finalized at placement, or a placement that could not.
|
||||
let mut tag = tag;
|
||||
@@ -644,6 +654,86 @@ impl Game<'_> {
|
||||
}
|
||||
i
|
||||
}
|
||||
/// `RootVehicle.Attach` names the immediate mount, not the root of its tree.
|
||||
pub(super) fn root_vehicle_uuid(&mut self, id: Option<u32>) -> Option<[i32; 4]> {
|
||||
Some(self.uuid_for(id?))
|
||||
}
|
||||
/// The vehicle with this UUID, among the ones in the world.
|
||||
pub(super) fn vehicle_by_uuid(&self, uuid: [i32; 4]) -> Option<u32> {
|
||||
let id = *self
|
||||
.persist
|
||||
.uuids
|
||||
.iter()
|
||||
.find(|(_, u)| **u == uuid)
|
||||
.map(|(id, _)| id)?;
|
||||
let there = self.mobs.iter().any(|m| m.id == id && m.alive())
|
||||
|| self.boats.iter().any(|b| b.id == id)
|
||||
|| self.carts.iter().any(|c| c.id == id);
|
||||
there.then_some(id)
|
||||
}
|
||||
/// A vehicle already in the world that was restored from the player's file
|
||||
/// during this load (not any entity whose UUID is remembered).
|
||||
fn restored_vehicle(&self, uuid: [i32; 4]) -> Option<u32> {
|
||||
if !self.persist.restored.contains(&uuid) {
|
||||
return None;
|
||||
}
|
||||
self.vehicle_by_uuid(uuid)
|
||||
}
|
||||
/// Vanilla stores the root entity in the player's file, outside chunk entities.
|
||||
/// Keep the raw wrapper until attachment succeeds, including unsupported trees.
|
||||
pub(super) fn restore_saved_vehicle(
|
||||
&mut self,
|
||||
uuid: [i32; 4],
|
||||
raw: Option<&Tag>,
|
||||
) -> Option<u32> {
|
||||
if let Some(id) = self.vehicle_by_uuid(uuid) {
|
||||
return Some(id);
|
||||
}
|
||||
let tag = raw?.get("RootVehicle")?.get("Entity")?.clone();
|
||||
let root_uuid = entity_nbt::uuid(&tag)?;
|
||||
let kind = entity_nbt::kind(&tag)?;
|
||||
if !self.res.mob_types().contains_key(&kind)
|
||||
&& super::boats::any_boat_wood(&kind).is_none()
|
||||
&& super::minecarts::CartKind::from_id(&kind).is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if self.vehicle_by_uuid(root_uuid).is_none() {
|
||||
let at = entity_nbt::position(&tag)?;
|
||||
let key = (
|
||||
(at.x.floor() as i32).div_euclid(16),
|
||||
(at.z.floor() as i32).div_euclid(16),
|
||||
);
|
||||
self.persist.restored.insert(root_uuid);
|
||||
self.world.loaded_entities.push((key, vec![tag]));
|
||||
self.spawn_loaded_entities();
|
||||
}
|
||||
self.persist.restored.insert(uuid);
|
||||
self.vehicle_by_uuid(uuid)
|
||||
}
|
||||
/// `ServerPlayer.loadAndSpawnParentVehicle`: the ride the player's file
|
||||
/// names, once the vehicle is in the world.
|
||||
pub(super) fn restore_root_vehicle(&mut self) {
|
||||
if self.serving.is_some()
|
||||
|| self.options.dedicated.is_some()
|
||||
|| self.riding.is_some()
|
||||
|| self.player.is_none()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(uuid) = self.pending_vehicle else {
|
||||
return;
|
||||
};
|
||||
let raw = self.level.player_extra.clone();
|
||||
let Some(id) = self.restore_saved_vehicle(uuid, raw.as_ref()) else {
|
||||
return;
|
||||
};
|
||||
if let Some(Tag::Compound(p)) = &mut self.level.player_extra {
|
||||
p.remove(&"RootVehicle".into());
|
||||
}
|
||||
self.pending_vehicle = None;
|
||||
self.riding = Some(id);
|
||||
}
|
||||
/// A mob's entity tag with the mobs riding it under `Passengers`.
|
||||
pub(super) fn mob_tree_nbt(&mut self, i: usize) -> Tag {
|
||||
let id = self.mobs[i].id;
|
||||
|
||||
@@ -24,7 +24,11 @@ use horizon_world::{
|
||||
registry::BlockStateId,
|
||||
world_ray::{RayBlockMode, RayFluidMode, RayWorld, clip_world},
|
||||
};
|
||||
use std::{io, path::Path, sync::Arc};
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
/// `Level.clip` with `ClipContext.Block.VISUAL` and no fluids:
|
||||
/// `BlockBehaviour.getVisualShape` is the collision shape except for the
|
||||
@@ -137,8 +141,25 @@ fn own_invisible(effects: &horizon_client::effects::PlayerEffects, shared_flag:
|
||||
fn body_visible(invisible: bool, team: Option<&horizon_client::scoreboard::Team>) -> bool {
|
||||
!horizon_client::scoreboard::invisible_to(team, team, false, invisible)
|
||||
}
|
||||
/// The player's model type (`PlayerModel` with the default skin).
|
||||
/// The player's model type for each resources root it was asked for.
|
||||
static PLAYER_TYPES: Mutex<Vec<(PathBuf, Option<Arc<MobType>>)>> = Mutex::new(Vec::new());
|
||||
|
||||
/// The player's model type (`PlayerModel` with the default skin), read once
|
||||
/// per resources root: `entity-models.json` doesn't change while the game
|
||||
/// runs, and parsing it again held every world join for about 100 ms. The
|
||||
/// world resources read it as the game starts (`load_world_rest`), so a join
|
||||
/// finds it; a resource pack change's new root is read once.
|
||||
pub(super) fn player_type(root: &Path) -> Option<Arc<MobType>> {
|
||||
let mut cache = PLAYER_TYPES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((_, kind)) = cache.iter().find(|(cached, _)| cached == root) {
|
||||
return kind.clone();
|
||||
}
|
||||
let kind = load_player_type(root);
|
||||
cache.push((root.to_owned(), kind.clone()));
|
||||
kind
|
||||
}
|
||||
/// `player_type` from the document: the model and its layers from one parse.
|
||||
fn load_player_type(root: &Path) -> Option<Arc<MobType>> {
|
||||
let models = horizon_client::mobs::EntityModelDocument::load(root).ok()?;
|
||||
models.model("minecraft:player#main").map(|model| {
|
||||
Arc::new(MobType {
|
||||
@@ -786,7 +807,12 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let assets = &self.armor_stand_assets;
|
||||
let Some((texture, _)) = assets.equipment.get(asset).and_then(|k| k.get("wings")) else {
|
||||
let Some((texture, _)) = assets
|
||||
.equipment
|
||||
.get(asset)
|
||||
.and_then(|k| k.get("wings"))
|
||||
.and_then(|layers| layers.first())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let sprites = &self.gui.atlas.sprites;
|
||||
|
||||
@@ -200,14 +200,25 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let pos = m.position;
|
||||
// NearestItemSensor: the nearest wanted item within 32 that the piglin
|
||||
// sees (eye to eye); GoToWantedItem then walks to it within 9 blocks.
|
||||
let w = self.mob_world_base(&[]);
|
||||
let eye = Vec3::new(pos.x, m.eye_y(), pos.z);
|
||||
let best = self
|
||||
.dropped
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(j, e)| (j, e.position.distance_squared(pos)))
|
||||
.filter(|(_, d)| *d <= 81.0)
|
||||
.filter(|(_, d)| *d < 32.0 * 32.0)
|
||||
.filter(|&(j, _)| self.piglin_wants(m, &self.dropped[j].stack))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1));
|
||||
.filter(|&(j, _)| {
|
||||
// ItemEntity's eye height is 0.2125.
|
||||
let e = &self.dropped[j];
|
||||
let at = Vec3::new(e.position.x, e.position.y + 0.2125, e.position.z);
|
||||
w.line_of_sight(eye, at)
|
||||
})
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.filter(|(_, d)| *d <= 81.0);
|
||||
let Some((j, _)) = best else {
|
||||
self.mobs[i].extra.point = None;
|
||||
continue;
|
||||
|
||||
@@ -18,7 +18,9 @@ use horizon_client::{
|
||||
hud_layout, hud_overlays, inventory_effects,
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::{Damage, DamageKind, Who},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
projectiles::{Projectile, ProjectileKind},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{
|
||||
@@ -512,6 +514,47 @@ impl Game<'_> {
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
/// `PotionItem.useOn`: a water bottle used on a block that turns to mud
|
||||
/// (`#convertible_to_mud`), not on its underside, is poured out and the
|
||||
/// block becomes mud.
|
||||
pub(super) fn use_water_bottle_on_block(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
face: [i32; 3],
|
||||
stack: &ItemStack,
|
||||
) -> horizon_client::Result<bool> {
|
||||
if &*stack.item != "minecraft:potion"
|
||||
|| stack.potion() != Some("minecraft:water")
|
||||
|| face == [0, -1, 0]
|
||||
|| !self.block_in_tag("minecraft:convertible_to_mud", &self.block_name(pos))
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let centre = pos.map(|v| f64::from(v) + 0.5);
|
||||
self.audio
|
||||
.play("minecraft:entity.generic.splash", Some(centre), 1.0, 1.0);
|
||||
self.fill_held(ItemStack::new("minecraft:glass_bottle", 1));
|
||||
// ServerLevel.sendParticles: five splashes above the block.
|
||||
for _ in 0..5 {
|
||||
let r = &mut self.world.random;
|
||||
let at = [
|
||||
f64::from(pos[0]) + r.next_double(),
|
||||
f64::from(pos[1] + 1),
|
||||
f64::from(pos[2]) + r.next_double(),
|
||||
];
|
||||
self.particles
|
||||
.add(&ParticleOptions::simple(ParticleType::Splash), at, [0.0; 3]);
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:item.bottle.empty", Some(centre), 1.0, 1.0);
|
||||
self.post_block_game_event("minecraft:fluid_place", pos, None, None);
|
||||
if let Some(mud) = self.pack.assets.registry.default_state("minecraft:mud") {
|
||||
self.level_set(pos, mud)?;
|
||||
}
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.swing_arm();
|
||||
Ok(true)
|
||||
}
|
||||
/// `ItemUtils.createFilledResult`: replace one of the held stack.
|
||||
fn fill_held(&mut self, filled: ItemStack) {
|
||||
if self.creative {
|
||||
@@ -554,7 +597,12 @@ impl Game<'_> {
|
||||
self.drink_ominous_bottle(slot, stack);
|
||||
return true;
|
||||
}
|
||||
let (lasting, now) = PlayerEffects::potion_effects(stack.potion(), 1.0);
|
||||
// PotionContents.onConsume: the potion's and the custom effects, at the
|
||||
// stack's POTION_DURATION_SCALE.
|
||||
let (lasting, now) = PlayerEffects::contents_effects(
|
||||
&brewing::contents_effects(stack),
|
||||
stack.potion_duration_scale(),
|
||||
);
|
||||
for e in lasting {
|
||||
self.add_player_effect(e);
|
||||
}
|
||||
@@ -577,6 +625,83 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `Arrow.doPostHurtEffects` and `SpectralArrow.doPostHurtEffects`: the
|
||||
/// arrow's item gives its target Glowing (spectral) or its potion contents
|
||||
/// at the stack's duration scale (a tipped arrow's default is an eighth);
|
||||
/// a mob's arrow without such an item (a stray's) carries its own effect.
|
||||
/// Instant effects act at once: harming hurts and healing heals, the
|
||||
/// other way round for the undead.
|
||||
pub(super) fn arrow_post_hurt_effects(&mut self, p: &Projectile, who: Who) {
|
||||
let from_item = p.arrow.as_ref().map(|a| &a.pickup_item);
|
||||
let mut effects: Vec<(&'static str, i32, i32)> = match from_item {
|
||||
Some(s) if &*s.item == "minecraft:spectral_arrow" => vec![("glowing", 200, 0)],
|
||||
Some(s) => {
|
||||
let scale = s.potion_duration_scale();
|
||||
brewing::contents_effects(s)
|
||||
.into_iter()
|
||||
.map(|(id, d, amp)| {
|
||||
(id, horizon_client::effects::scaled_duration(d, scale), amp)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
if effects.is_empty()
|
||||
&& let ProjectileKind::Arrow {
|
||||
effect: Some((id, ticks)),
|
||||
..
|
||||
} = p.kind
|
||||
&& let Some(e) = brewing::effect(id)
|
||||
{
|
||||
effects.push((e.id, ticks, 0));
|
||||
}
|
||||
if effects.is_empty() {
|
||||
return;
|
||||
}
|
||||
match who {
|
||||
Who::Player => {
|
||||
for (id, duration, amp) in effects {
|
||||
if brewing::effect(id).is_some_and(|e| e.instant) {
|
||||
if let Some(a) = horizon_client::effects::instant(id, amp, 1.0) {
|
||||
self.apply_effect_action(a);
|
||||
}
|
||||
} else {
|
||||
self.add_player_effect(EffectInstance::new(id, duration, amp));
|
||||
}
|
||||
}
|
||||
}
|
||||
Who::Mob(id) => {
|
||||
let tags = &self.res.stations().enchantments.entity_tags;
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id && m.alive()) else {
|
||||
return;
|
||||
};
|
||||
let inverted = tags
|
||||
.get("minecraft:inverted_healing_and_harm")
|
||||
.is_some_and(|t| t.contains(&m.kind.id));
|
||||
for (effect, duration, amp) in effects {
|
||||
match effect {
|
||||
"instant_damage" | "instant_health" => {
|
||||
// `LivingEntity.addEffect` refuses nothing here; the
|
||||
// tick hurts with magic (6 << amp) or heals (4 << amp).
|
||||
if (effect == "instant_damage") != inverted {
|
||||
m.damage((6 << amp.min(30)) as f32, Damage::of(DamageKind::Magic));
|
||||
} else {
|
||||
m.health =
|
||||
(m.health + (4 << amp.min(30)) as f32).min(m.attrs.max_health);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if mob_can_be_affected(tags, &m.kind.id, effect)
|
||||
&& let Some(name) = mob_effect_id(effect)
|
||||
{
|
||||
m.add_effect(name, duration, amp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn apply_effect_action(&mut self, action: EffectAction) {
|
||||
let creative = self.creative;
|
||||
let rules = self.world.health_rules;
|
||||
|
||||
@@ -36,6 +36,7 @@ use horizon_protocol::{
|
||||
play::{self, Clientbound as C, Serverbound as S},
|
||||
},
|
||||
registry::{Builtin, DimensionType, Dynamic, ceil_log2},
|
||||
secure_chat::Signature,
|
||||
text::{Language, plain},
|
||||
};
|
||||
use horizon_world::{
|
||||
@@ -63,6 +64,13 @@ fn menu_title(tag: &Tag) -> horizon_client::text::Text {
|
||||
remote_text(tag).resolve_legacy()
|
||||
}
|
||||
|
||||
/// A player message Chat Delay holds back, and its signature.
|
||||
type QueuedChat = (
|
||||
horizon_client::text::Text,
|
||||
horizon_client::chat::ChatSource,
|
||||
Option<Signature>,
|
||||
);
|
||||
|
||||
/// Streaming needs a generator; a server world never generates.
|
||||
struct NoTerrain;
|
||||
impl ChunkGenerator for NoTerrain {
|
||||
@@ -183,8 +191,12 @@ pub(super) struct Remote {
|
||||
pub chat: Vec<(String, Instant)>,
|
||||
/// Lines received since joining, for the control socket's events.
|
||||
pub chat_received: u64,
|
||||
/// `ChatListener.delayedMessageQueue`: player chat held back by Chat Delay.
|
||||
chat_queue: VecDeque<(horizon_client::text::Text, horizon_client::chat::ChatSource)>,
|
||||
/// `ChatListener.delayedMessageQueue`: player chat held back by Chat
|
||||
/// Delay, with the signature a deletion names it by.
|
||||
chat_queue: VecDeque<QueuedChat>,
|
||||
/// `ClientPacketListener.messageSignatureCache`: the signatures packed
|
||||
/// ones index, as the server's copy for this connection holds them.
|
||||
signatures: horizon_protocol::secure_chat::SignatureCache,
|
||||
/// `ChatListener.previousMessageTime`: when a message last showed, if one has.
|
||||
chat_previous: Option<Instant>,
|
||||
/// The server's command tree (ClientboundCommandsPacket).
|
||||
@@ -537,6 +549,7 @@ impl Game<'_> {
|
||||
chat: Vec::new(),
|
||||
chat_received: 0,
|
||||
chat_queue: VecDeque::new(),
|
||||
signatures: Default::default(),
|
||||
chat_previous: None,
|
||||
commands: None,
|
||||
custom_completions: Default::default(),
|
||||
@@ -793,23 +806,39 @@ impl Game<'_> {
|
||||
&mut self,
|
||||
content: horizon_client::text::Text,
|
||||
source: horizon_client::chat::ChatSource,
|
||||
) -> bool {
|
||||
self.signed_chat_line(content, source, None)
|
||||
}
|
||||
/// `chat_line` of a player message, keeping its signature for a
|
||||
/// deletion (`GuiMessage.signature`).
|
||||
fn signed_chat_line(
|
||||
&mut self,
|
||||
content: horizon_client::text::Text,
|
||||
source: horizon_client::chat::ChatSource,
|
||||
signature: Option<Signature>,
|
||||
) -> bool {
|
||||
if !self.ui.chat.shows(source) {
|
||||
return false;
|
||||
}
|
||||
let text = content.plain();
|
||||
// addServerSystemMessage and addClientSystemMessage both carry
|
||||
// GuiMessageTag.systemSinglePlayer: the grey bar at the line's left.
|
||||
let indicator = (source != horizon_client::chat::ChatSource::Player)
|
||||
.then_some(horizon_client::chat::SYSTEM_INDICATOR);
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
content,
|
||||
indicator,
|
||||
source,
|
||||
self.ticks,
|
||||
self.screen == Screen::Chat,
|
||||
);
|
||||
let focused = self.screen == Screen::Chat;
|
||||
if source == horizon_client::chat::ChatSource::Player {
|
||||
self.ui
|
||||
.chat
|
||||
.add_signed(&self.gui.atlas, content, signature, self.ticks, focused);
|
||||
} else {
|
||||
// addServerSystemMessage and addClientSystemMessage both carry
|
||||
// GuiMessageTag.systemSinglePlayer: the grey bar at the line's left.
|
||||
let indicator = Some(horizon_client::chat::SYSTEM_INDICATOR);
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
content,
|
||||
indicator,
|
||||
source,
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.chat.push((text, Instant::now()));
|
||||
r.chat_received += 1;
|
||||
@@ -822,24 +851,54 @@ impl Game<'_> {
|
||||
|
||||
/// `ChatListener.handleMessage` for player and disguised chat: held in the
|
||||
/// queue while Chat Delay is set, shown at once otherwise.
|
||||
pub(super) fn handle_player_chat(&mut self, line: horizon_client::text::Text) {
|
||||
pub(super) fn handle_player_chat(
|
||||
&mut self,
|
||||
line: horizon_client::text::Text,
|
||||
signature: Option<Signature>,
|
||||
) {
|
||||
let source = horizon_client::chat::ChatSource::Player;
|
||||
if self.chat_delay_ms() > 0 {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.chat_queue.push_back((line, source));
|
||||
r.chat_queue.push_back((line, source, signature));
|
||||
}
|
||||
} else {
|
||||
self.accept_chat(line, source);
|
||||
self.accept_chat(line, source, signature);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.handleDeleteChat`: the packed signature names a
|
||||
/// message; one still held back by Chat Delay never shows
|
||||
/// (`ChatListener.removeFromDelayedMessageQueue`), a shown one is
|
||||
/// replaced (`ChatComponent.deleteMessage`). A signature the cache
|
||||
/// doesn't hold is an invalid packet.
|
||||
fn handle_delete_chat(&mut self, packed: &(i32, Option<Vec<u8>>)) -> Result<()> {
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(signature) = r.signatures.unpack(packed) else {
|
||||
let reason =
|
||||
horizon_client::text::Text::translate("multiplayer.disconnect.invalid_packet", &[]);
|
||||
return self.leave_server(Some(reason.plain()));
|
||||
};
|
||||
let queued = r.chat_queue.len();
|
||||
r.chat_queue
|
||||
.retain(|(_, _, s)| s.as_ref() != Some(&signature));
|
||||
if r.chat_queue.len() == queued {
|
||||
self.ui
|
||||
.chat
|
||||
.delete_message(&self.gui.atlas, &signature, self.ticks);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A message's `accept`: shown, it starts the Chat Delay wait again.
|
||||
fn accept_chat(
|
||||
&mut self,
|
||||
line: horizon_client::text::Text,
|
||||
source: horizon_client::chat::ChatSource,
|
||||
signature: Option<Signature>,
|
||||
) -> bool {
|
||||
let shown = self.chat_line(line, source);
|
||||
let shown = self.signed_chat_line(line, source, signature);
|
||||
if shown && let Some(r) = self.remote.as_mut() {
|
||||
r.chat_previous = Some(Instant::now());
|
||||
}
|
||||
@@ -885,9 +944,10 @@ impl Game<'_> {
|
||||
}
|
||||
// A message the Chat option hides is dropped and the next one is tried,
|
||||
// until one shows.
|
||||
while let Some((line, source)) = self.remote.as_mut().and_then(|r| r.chat_queue.pop_front())
|
||||
while let Some((line, source, signature)) =
|
||||
self.remote.as_mut().and_then(|r| r.chat_queue.pop_front())
|
||||
{
|
||||
if self.accept_chat(line, source) {
|
||||
if self.accept_chat(line, source, signature) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -903,8 +963,8 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for (line, source) in queue {
|
||||
self.chat_line(line, source);
|
||||
for (line, source, signature) in queue {
|
||||
self.signed_chat_line(line, source, signature);
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.chat_previous = None;
|
||||
@@ -1091,6 +1151,7 @@ impl Game<'_> {
|
||||
r.players.clear();
|
||||
r.tab_players.clear();
|
||||
r.listed.clear();
|
||||
r.signatures = Default::default();
|
||||
r.custom_completions.clear();
|
||||
}
|
||||
self.ui.registries.custom_completions.clear();
|
||||
@@ -1232,7 +1293,9 @@ impl Game<'_> {
|
||||
.unwrap_or_default();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
let bits = ceil_log2(biomes.len());
|
||||
r.chunk_format = Arc::new(ChunkFormat::new(&r.dimension, biomes, bits));
|
||||
let column = r.chunk_format.layout();
|
||||
r.chunk_format =
|
||||
Arc::new(ChunkFormat::new(&r.dimension, biomes, bits).in_column(column));
|
||||
r.registries = registries;
|
||||
}
|
||||
}
|
||||
@@ -1242,9 +1305,21 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let dimension = r.registries.dimension(spawn.dimension_type);
|
||||
let shape = super::Dim::from_id(&dimension.name).shape();
|
||||
// The server's dimension type gives the level's shape, and its
|
||||
// chunks the shape's column.
|
||||
let shape = horizon_client::chunk::LevelShape::new(
|
||||
dimension.min_y,
|
||||
dimension.height,
|
||||
dimension.logical_height,
|
||||
dimension.has_skylight,
|
||||
)
|
||||
.unwrap_or_else(|_| super::Dim::from_id(&dimension.name).shape());
|
||||
let same = r.dimension.name == dimension.name && keep_world;
|
||||
r.chunk_format = Arc::new(r.chunk_format.with_dimension(&dimension));
|
||||
r.chunk_format = Arc::new(
|
||||
r.chunk_format
|
||||
.with_dimension(&dimension)
|
||||
.in_column(shape.column()),
|
||||
);
|
||||
r.dimension = dimension;
|
||||
r.game_mode = spawn.game_type;
|
||||
r.flat = spawn.flat;
|
||||
@@ -2127,6 +2202,7 @@ impl Game<'_> {
|
||||
C::SetCamera(p) => self.remote_set_camera(p.camera_id),
|
||||
C::LevelChunkWithLight(p) => self.remote_chunk(&p, None),
|
||||
C::LightUpdate(p) => self.remote_light_update(&p),
|
||||
C::ChunksBiomes(p) => self.remote_chunks_biomes(&p),
|
||||
C::ForgetLevelChunk(p) => {
|
||||
let [x, z] = p.pos;
|
||||
self.streaming.forget(&mut self.world, x, z);
|
||||
@@ -2713,6 +2789,10 @@ impl Game<'_> {
|
||||
self.remote_firework_explosion(p.id);
|
||||
}
|
||||
self.remote_fangs_event(p.id as u32, p.event);
|
||||
// Arrow.handleEntityEvent 0: a tipped arrow's burst of particles.
|
||||
if p.event == 0 {
|
||||
self.remote_arrow_puff(p.id as u32);
|
||||
}
|
||||
// LivingEntity.handleEntityEvent 2: a spear's hit felt.
|
||||
if p.event == 2 {
|
||||
self.remote_kinetic_hit(p.id);
|
||||
@@ -3035,9 +3115,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
C::ContainerSetContent(p) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.state_id = p.state;
|
||||
}
|
||||
self.remote_menu_state(p.container, p.state);
|
||||
for (slot, item) in p.items.iter().enumerate() {
|
||||
let stack = self.to_stack(item);
|
||||
self.set_menu_slot(p.container, slot as i32, stack);
|
||||
@@ -3046,9 +3124,7 @@ impl Game<'_> {
|
||||
self.remote_synced();
|
||||
}
|
||||
C::ContainerSetSlot(p) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.state_id = p.state;
|
||||
}
|
||||
self.remote_menu_state(p.container, p.state);
|
||||
let stack = self.to_stack(&p.item);
|
||||
self.tutorial_get_item(stack.as_ref());
|
||||
// ClientPacketListener.handleContainerSetSlot: a hotbar slot that
|
||||
@@ -3195,18 +3271,43 @@ impl Game<'_> {
|
||||
}
|
||||
C::PlayerChat(p) => {
|
||||
use horizon_client::scoreboard::tag_to_json;
|
||||
// SignedMessageBody.Packed.unpack: the last seen it names must
|
||||
// be in the cache; then the message joins it
|
||||
// (MessageSignatureCache.push).
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let last_seen: Option<Vec<Signature>> =
|
||||
p.last_seen.iter().map(|s| r.signatures.unpack(s)).collect();
|
||||
let Some(last_seen) = last_seen else {
|
||||
log::error!(
|
||||
"Message from player with ID {:032x} referenced unrecognized signature id",
|
||||
p.sender
|
||||
);
|
||||
let reason = horizon_client::text::Text::translate(
|
||||
"multiplayer.disconnect.invalid_packet",
|
||||
&[],
|
||||
);
|
||||
return self.leave_server(Some(reason.plain()));
|
||||
};
|
||||
let signature = p
|
||||
.signature
|
||||
.as_deref()
|
||||
.and_then(horizon_protocol::secure_chat::signature);
|
||||
r.signatures.push(&last_seen, signature.as_ref());
|
||||
let content = p
|
||||
.unsigned_content
|
||||
.as_ref()
|
||||
.map_or_else(|| serde_json::Value::from(p.content.as_str()), tag_to_json);
|
||||
let line = self.format_chat(&p.chat_type, content);
|
||||
self.handle_player_chat(line);
|
||||
self.handle_player_chat(line, signature);
|
||||
}
|
||||
C::DisguisedChat(p) => {
|
||||
let content = horizon_client::scoreboard::tag_to_json(&p.message);
|
||||
let line = self.format_chat(&p.chat_type, content);
|
||||
self.handle_player_chat(line);
|
||||
self.handle_player_chat(line, None);
|
||||
}
|
||||
C::DeleteChat(p) => self.handle_delete_chat(&p.signature)?,
|
||||
C::PlayerInfoUpdate(p) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
// handlePlayerInfoUpdate: new entries first, then every
|
||||
@@ -4138,6 +4239,8 @@ impl Game<'_> {
|
||||
|
||||
fn tick_remote_level(&mut self) -> Result<()> {
|
||||
self.ticks += 1;
|
||||
// Hud.tick ends with ChatComponent.tick.
|
||||
self.ui.chat.tick(&self.gui.atlas, self.ticks);
|
||||
// AbstractSignEditScreen.tick: the editor closes once its sign is removed.
|
||||
if let Some(edit) = &self.sign_edit
|
||||
&& !matches!(
|
||||
@@ -4623,7 +4726,8 @@ impl Game<'_> {
|
||||
// server never sends the fluid contact, and the renderers read it (cod, salmon and
|
||||
// tropical fish lie on their side without `in_water`, #1591).
|
||||
// A frozen level ticks only the players, as below.
|
||||
let (fluids, thundering): (Vec<_>, bool) = {
|
||||
// Entity.isInRain for the wolves, which dry off by their own `Wolf.tick`.
|
||||
let (fluids, rain, thundering): (Vec<_>, Vec<_>, bool) = {
|
||||
let w = self.mob_world_base(&[]);
|
||||
let players = self.remote.as_ref().map(|r| &r.level_players);
|
||||
let fluids = self
|
||||
@@ -4634,7 +4738,15 @@ impl Game<'_> {
|
||||
.then(|| w.fluids(m.bounds(), m.eye_y()))
|
||||
})
|
||||
.collect();
|
||||
(fluids, w.thundering)
|
||||
let rain = self
|
||||
.mobs
|
||||
.iter()
|
||||
.map(|m| {
|
||||
m.species() == horizon_client::mobs::Species::Wolf
|
||||
&& horizon_client::mobs::tame::in_rain(m, &w)
|
||||
})
|
||||
.collect();
|
||||
(fluids, rain, w.thundering)
|
||||
};
|
||||
for (m, fluids) in self.mobs.iter_mut().zip(fluids) {
|
||||
if let Some(fluids) = fluids {
|
||||
@@ -4682,7 +4794,7 @@ impl Game<'_> {
|
||||
let players = &r.level_players;
|
||||
let mut using = Vec::new();
|
||||
let mut mob_fx = Vec::new();
|
||||
for m in &mut self.mobs {
|
||||
for (m, rain) in self.mobs.iter_mut().zip(rain) {
|
||||
let player = players.contains_key(&(m.id as i32));
|
||||
if (!running && !player) || driven == Some(m.id) {
|
||||
continue;
|
||||
@@ -4723,8 +4835,11 @@ impl Game<'_> {
|
||||
}
|
||||
// The particles these mobs' own ticks draw on the client side.
|
||||
{
|
||||
use horizon_client::mobs::{Species as S, cube, illusioner, nautilus, wither};
|
||||
use horizon_client::mobs::{
|
||||
Species as S, cube, illusioner, nautilus, tame, wither,
|
||||
};
|
||||
match m.species() {
|
||||
S::Wolf => tame::client_tick(m, rain, &mut mob_fx),
|
||||
S::Slime | S::MagmaCube | S::SulfurCube => cube::client_tick(m, &mut mob_fx),
|
||||
S::Nautilus | S::ZombieNautilus => nautilus::client_tick(m, &mut mob_fx),
|
||||
S::Illusioner => illusioner::client_tick(m, &mut mob_fx),
|
||||
@@ -4826,8 +4941,13 @@ impl Game<'_> {
|
||||
horizon_client::mobs::aquatic::squid_client_step(m, &mut mob_fx);
|
||||
// WalkAnimationState.update from the horizontal distance moved.
|
||||
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
|
||||
let target = (((dx * dx + dz * dz).sqrt() * 4.0) as f32).min(1.0);
|
||||
m.limb_speed += (target - m.limb_speed) * 0.4;
|
||||
let (target, factor) = if horizon_client::mobs::camel::is_camel(m.species()) {
|
||||
// Camel.updateWalkAnimation.
|
||||
horizon_client::mobs::camel::walk_target(m, (dx * dx + dz * dz).sqrt() as f32)
|
||||
} else {
|
||||
((((dx * dx + dz * dz).sqrt() * 4.0) as f32).min(1.0), 0.4)
|
||||
};
|
||||
m.limb_speed += (target - m.limb_speed) * factor;
|
||||
m.limb_position += m.limb_speed;
|
||||
if m.species() == horizon_client::mobs::Species::Rabbit {
|
||||
horizon_client::mobs::rabbit::post_tick(m);
|
||||
@@ -5026,11 +5146,17 @@ impl Game<'_> {
|
||||
// Creative inventory edits are sent slot by slot (SetCreativeModeSlot).
|
||||
let mut creative_slots = Vec::new();
|
||||
if self.creative {
|
||||
// A server's open menu takes clicks, which it applies itself: what
|
||||
// this client predicts for them isn't an edit to send (it would
|
||||
// overwrite the server's stacks whenever the prediction is wrong).
|
||||
let in_menu = r.menu.is_some();
|
||||
let stacks = creative_sync_stacks(&self.inventory, &self.offhand, &self.armor);
|
||||
for (i, stack) in stacks.into_iter().enumerate() {
|
||||
if stack != r.synced[i] {
|
||||
r.synced[i] = stack.clone();
|
||||
creative_slots.push((creative_sync_slot(i), stack));
|
||||
if !in_menu {
|
||||
creative_slots.push((creative_sync_slot(i), stack));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5448,7 +5574,9 @@ impl Game<'_> {
|
||||
let stand_fits = held
|
||||
.as_ref()
|
||||
.is_some_and(|s| &*s.item == "minecraft:armor_stand")
|
||||
&& self.armor_stand_spawn(&hit).is_some();
|
||||
&& held
|
||||
.as_ref()
|
||||
.is_some_and(|s| self.armor_stand_spawn(&hit, s).is_some());
|
||||
let seq = self.next_sequence();
|
||||
let cursor = [
|
||||
(hit.point[0] - f64::from(hit.pos[0])) as f32,
|
||||
@@ -5976,6 +6104,12 @@ impl Game<'_> {
|
||||
}
|
||||
// SpyglassItem.use (its sound is the server's).
|
||||
"minecraft:spyglass" => true,
|
||||
// InstrumentItem.use: the horn plays here too (`playSound(player, ...)`
|
||||
// skips the player's own client), the server's copy tells the others.
|
||||
"minecraft:goat_horn" => {
|
||||
self.remote_use_instrument(held);
|
||||
return;
|
||||
}
|
||||
// EnderEyeItem.use: startUsingItem unless an End portal frame is
|
||||
// aimed at, with or without a stronghold to throw the eye at.
|
||||
"minecraft:ender_eye" => !self.aims_at_end_portal_frame(),
|
||||
@@ -5991,6 +6125,27 @@ impl Game<'_> {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
}
|
||||
}
|
||||
/// `InstrumentItem.use` on this client: unless the horn is on cooldown,
|
||||
/// it is held up, plays its sound at the player and starts its cooldown.
|
||||
fn remote_use_instrument(&mut self, held: &ItemStack) {
|
||||
let Some(instrument) = self.res.items().instrument(held) else {
|
||||
return;
|
||||
};
|
||||
let group = horizon_client::cooldowns::group(held, None);
|
||||
if self.cooldowns.is_on_cooldown(&group) {
|
||||
return;
|
||||
}
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
self.audio
|
||||
.play(&instrument.sound, at, instrument.range / 16.0, 1.0);
|
||||
if instrument.use_duration > 0.0 {
|
||||
self.add_cooldown(group, instrument.use_ticks());
|
||||
}
|
||||
}
|
||||
/// Whether `stack` stays in use until the button lets go (bows,
|
||||
/// crossbows, tridents, spyglasses, shields, eyes of ender, whose
|
||||
/// `getUseDuration` 0 never counts down to the end): its use only counts
|
||||
@@ -6090,6 +6245,15 @@ impl Game<'_> {
|
||||
self.using = in_hand.then_some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
// A goat horn is held up for its instrument's use duration.
|
||||
if let Some(duration) = stack
|
||||
.as_ref()
|
||||
.and_then(|s| self.res.items().instrument(s))
|
||||
.map(|i| i.use_ticks().max(0) as u32)
|
||||
{
|
||||
self.using = (in_hand && ticks + 1 < duration).then_some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
let consumable = stack
|
||||
.as_ref()
|
||||
.and_then(|s| self.items().get(&s.item))
|
||||
@@ -6539,6 +6703,14 @@ impl Game<'_> {
|
||||
}
|
||||
self.remote_menu_ops(&[(Some(internal), i32::from(right), input)]);
|
||||
}
|
||||
/// `AbstractContainerMenu.setItem`/`initializeContents`: the state id a
|
||||
/// click answers with is the open menu's, not another window's.
|
||||
fn remote_menu_state(&mut self, container: i32, state: i32) {
|
||||
let open = self.remote_menu().0;
|
||||
if let Some(r) = self.remote.as_mut().filter(|_| container == open) {
|
||||
r.state_id = state;
|
||||
}
|
||||
}
|
||||
/// The server's open menu: its window id and menu type (0, "inventory"
|
||||
/// when none is open).
|
||||
pub(super) fn remote_menu(&self) -> (i32, String) {
|
||||
|
||||
@@ -9,7 +9,7 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::paletted::{PalettedContainer, Strategy};
|
||||
use horizon_protocol::{
|
||||
chunk::{LightData, SectionLight},
|
||||
chunk::{LightData, Paletted, SectionLight},
|
||||
packets::play::{self, Clientbound as C},
|
||||
registry::DimensionType,
|
||||
};
|
||||
@@ -44,6 +44,14 @@ impl ChunkFormat {
|
||||
biome_bits,
|
||||
}
|
||||
}
|
||||
/// Decodes into chunks of `column`.
|
||||
pub(super) fn in_column(self, column: ColumnLayout) -> Self {
|
||||
Self { column, ..self }
|
||||
}
|
||||
/// The column decoded chunks hold.
|
||||
pub(super) fn layout(&self) -> ColumnLayout {
|
||||
self.column
|
||||
}
|
||||
pub(super) fn with_dimension(&self, dimension: &DimensionType) -> Self {
|
||||
Self {
|
||||
column: self.column,
|
||||
@@ -60,20 +68,8 @@ impl ChunkFormat {
|
||||
let mut chunk = Chunk::with_layout(p.x, p.z, self.column);
|
||||
let offset = (self.min_y - self.column.min_y()).div_euclid(16);
|
||||
let column = self.column.sections() as i32;
|
||||
// Biome table index per quart cell of the dimension's own sections.
|
||||
let mut biome_cells: Vec<[u32; 64]> = Vec::with_capacity(sections.len());
|
||||
for (i, s) in sections.iter().enumerate() {
|
||||
let target = offset + i as i32;
|
||||
let mut cells = [0u32; 64];
|
||||
for (j, cell) in cells.iter_mut().enumerate() {
|
||||
*cell = u32::from(
|
||||
self.biome_map
|
||||
.get(s.biomes.get(j) as usize)
|
||||
.copied()
|
||||
.unwrap_or(0),
|
||||
);
|
||||
}
|
||||
biome_cells.push(cells);
|
||||
if !(0..column).contains(&target) {
|
||||
continue;
|
||||
}
|
||||
@@ -104,30 +100,14 @@ impl ChunkFormat {
|
||||
}
|
||||
let mut section = Section {
|
||||
blocks,
|
||||
biomes: PalettedContainer::from_values(BIOME_STRATEGY, &cells),
|
||||
biomes: PalettedContainer::new(BIOME_STRATEGY, 0),
|
||||
light: Light::Uniform(0xF0),
|
||||
};
|
||||
section.compact();
|
||||
chunk.sections[target as usize] = Arc::new(section);
|
||||
}
|
||||
// Sections outside the dimension's range take its nearest biomes
|
||||
// (the column's top or bottom cell layer repeated).
|
||||
if !biome_cells.is_empty() {
|
||||
let last = biome_cells.len() - 1;
|
||||
for si in 0..column {
|
||||
let i = si - offset;
|
||||
if (0..sections.len() as i32).contains(&i) {
|
||||
continue;
|
||||
}
|
||||
let (edge, layer) = if i < 0 { (0, 0) } else { (last, 3) };
|
||||
let mut cells = [0u32; 64];
|
||||
for (j, cell) in cells.iter_mut().enumerate() {
|
||||
*cell = biome_cells[edge][layer * 16 + j % 16];
|
||||
}
|
||||
Arc::make_mut(&mut chunk.sections[si as usize]).biomes =
|
||||
PalettedContainer::from_values(BIOME_STRATEGY, &cells);
|
||||
}
|
||||
}
|
||||
let biomes: Vec<_> = sections.iter().map(|s| &s.biomes).collect();
|
||||
self.put_biomes(&mut chunk, &biomes);
|
||||
// ClientPacketListener.applyLightData: the column keeps the light
|
||||
// the server sent; nothing is relit.
|
||||
let (layers, above) = self.light(&p.light);
|
||||
@@ -135,6 +115,51 @@ impl ChunkFormat {
|
||||
Ok(chunk)
|
||||
}
|
||||
|
||||
/// The dimension's sections' biome containers (in the server's
|
||||
/// registry) into the column's sections. Sections outside the
|
||||
/// dimension's range take its nearest biomes (the column's top or bottom
|
||||
/// cell layer repeated).
|
||||
fn put_biomes(&self, chunk: &mut Chunk, biomes: &[&Paletted]) {
|
||||
if biomes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let offset = (self.min_y - self.column.min_y()).div_euclid(16);
|
||||
// Biome table index per quart cell of the dimension's own sections.
|
||||
let biome_cells: Vec<[u32; 64]> = biomes
|
||||
.iter()
|
||||
.map(|b| {
|
||||
std::array::from_fn(|j| {
|
||||
u32::from(self.biome_map.get(b.get(j) as usize).copied().unwrap_or(0))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let last = biome_cells.len() - 1;
|
||||
for si in 0..self.column.sections() as i32 {
|
||||
let i = si - offset;
|
||||
let cells = if (0..biome_cells.len() as i32).contains(&i) {
|
||||
biome_cells[i as usize]
|
||||
} else {
|
||||
let (edge, layer) = if i < 0 { (0, 0) } else { (last, 3) };
|
||||
std::array::from_fn(|j| biome_cells[edge][layer * 16 + j % 16])
|
||||
};
|
||||
let mut container = PalettedContainer::from_values(BIOME_STRATEGY, &cells);
|
||||
if let Some(v) = container.uniform() {
|
||||
container = PalettedContainer::new(BIOME_STRATEGY, v);
|
||||
}
|
||||
Arc::make_mut(&mut chunk.sections[si as usize]).biomes = container;
|
||||
}
|
||||
}
|
||||
|
||||
/// `LevelChunk.replaceBiomes`: each of the dimension's sections reads
|
||||
/// its biome container from the packet's buffer, in order.
|
||||
fn replace_biomes(&self, chunk: &mut Chunk, data: &play::ChunkBiomeData) -> Result<(), String> {
|
||||
let biomes = data
|
||||
.biomes(self.sections, self.biome_bits)
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.put_biomes(chunk, &biomes.iter().collect::<Vec<_>>());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.readSectionList`: the sky and block layers of a
|
||||
/// light packet, by section of the client's column, and the sky light
|
||||
/// above the column's top section (from the light section just above
|
||||
@@ -274,6 +299,43 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.handleChunksBiomes`: each loaded column takes
|
||||
/// its new biomes (`ClientChunkCache.replaceBiomes`), then every section
|
||||
/// of it and of its neighbours is drawn again, as their colours blend
|
||||
/// across columns (`LevelExtractor.setSectionDirty`).
|
||||
pub(super) fn remote_chunks_biomes(&mut self, p: &play::ChunksBiomes) {
|
||||
let Some(format) = self.remote.as_ref().map(|r| r.chunk_format.clone()) else {
|
||||
return;
|
||||
};
|
||||
for data in &p.chunks {
|
||||
let [x, z] = data.pos;
|
||||
if !self.remote_chunk_in_range(x, z) {
|
||||
log::warn!("Ignoring chunk since it's not in the view range: {x}, {z}");
|
||||
continue;
|
||||
}
|
||||
let Some(chunk) = self.world.chunks.get_mut(&(x, z)) else {
|
||||
log::warn!("Ignoring chunk since it's not present: {x}, {z}");
|
||||
continue;
|
||||
};
|
||||
if let Err(e) = format.replace_biomes(Arc::make_mut(chunk), data) {
|
||||
log::warn!("Horizon: biomes of chunk {x} {z}: {e}");
|
||||
}
|
||||
}
|
||||
for data in &p.chunks {
|
||||
let [x, z] = data.pos;
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
if !self.world.chunks.contains_key(&(x + dx, z + dz)) {
|
||||
continue;
|
||||
}
|
||||
for sy in 0..self.world.layout().sections() as i32 {
|
||||
self.world.dirty_sections.insert((x + dx, sy, z + dz));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `handleLightUpdatePacket`: the sections it names take its light.
|
||||
pub(super) fn remote_light_update(&mut self, p: &play::LightUpdate) {
|
||||
let Some(format) = self.remote.as_ref().map(|r| r.chunk_format.clone()) else {
|
||||
@@ -360,4 +422,45 @@ mod tests {
|
||||
}
|
||||
assert!(matches!(decoder.next(), Next::Decoding));
|
||||
}
|
||||
|
||||
/// `LevelChunk.replaceBiomes`: the packet's containers go to the
|
||||
/// dimension's sections (y 0 to 255 in the Nether), mapped to our biome
|
||||
/// table; the blocks stay, and the sections outside the dimension take
|
||||
/// its nearest biomes as a chunk packet's do.
|
||||
#[test]
|
||||
fn chunks_biomes_replace_a_columns_biomes() {
|
||||
let nether = DimensionType::vanilla("minecraft:the_nether");
|
||||
let format = ChunkFormat::new(&nether, vec![0, 7, 9], 2);
|
||||
let mut chunk = Chunk::empty(2, -3);
|
||||
chunk.set(3, 70, 4, 5);
|
||||
// Wire biome 1 everywhere, with 2 in the lowest and the highest cells.
|
||||
let containers: Vec<Paletted> = (0..nether.sections())
|
||||
.map(|i| {
|
||||
let mut cells = [1; 64];
|
||||
if i == 0 {
|
||||
cells[0] = 2;
|
||||
}
|
||||
if i == nether.sections() - 1 {
|
||||
cells[63] = 2;
|
||||
}
|
||||
Paletted::from_values(&cells, Wiring::Biomes, 2)
|
||||
})
|
||||
.collect();
|
||||
let data = play::ChunkBiomeData::from_biomes([2, -3], &containers);
|
||||
format.replace_biomes(&mut chunk, &data).unwrap();
|
||||
assert_eq!(chunk.get(3, 70, 4), 5);
|
||||
assert_eq!(chunk.biome(8, 70, 8), 7);
|
||||
assert_eq!(chunk.biome(0, 0, 0), 9);
|
||||
assert_eq!(chunk.biome(4, 0, 0), 7);
|
||||
assert_eq!(chunk.biome(15, 255, 15), 9);
|
||||
// Below and above the Nether: its bottom and top cell layers.
|
||||
assert_eq!(chunk.biome(0, -64, 0), 9);
|
||||
assert_eq!(chunk.biome(15, 319, 15), 9);
|
||||
assert_eq!(chunk.biome(0, 319, 0), 7);
|
||||
// A buffer short of the dimension's sections changes nothing.
|
||||
let short = play::ChunkBiomeData::from_biomes([2, -3], &containers[..3]);
|
||||
let mut other = Chunk::empty(2, -3);
|
||||
assert!(format.replace_biomes(&mut other, &short).is_err());
|
||||
assert_eq!(other.biome(8, 70, 8), 0);
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,8 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
}
|
||||
// SnowGolem.DATA_PUMPKIN_ID: 16 while the pumpkin is on.
|
||||
(Species::SnowGolem, 16, Value::Byte(flags)) => m.extra.sheared = flags & 16 == 0,
|
||||
// Bogged.DATA_SHEARED: the mushrooms on its head are gone.
|
||||
(Species::Bogged, 16, Value::Bool(sheared)) => m.extra.sheared = *sheared,
|
||||
// Tadpole.AGE_LOCKED (after AbstractFish's FROM_BUCKET).
|
||||
(Species::Tadpole, 17, Value::Bool(locked)) => m.extra.age_locked = *locked,
|
||||
// MushroomCow.DATA_TYPE (after AgeableMob's DATA_BABY_ID and
|
||||
|
||||
@@ -114,8 +114,10 @@ fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
|
||||
fn remote_arrow_tick(p: &mut Projectile, w: &impl ProjectileWorld) {
|
||||
p.previous = p.position;
|
||||
p.age += 1;
|
||||
// AbstractArrow.tick: `inGroundTime` counts the ticks in a block.
|
||||
// AbstractArrow.tick: the shake timer runs down, and `inGroundTime` counts
|
||||
// the ticks in a block.
|
||||
if let Some(a) = p.arrow.as_mut() {
|
||||
a.shake = (a.shake - 1).max(0);
|
||||
a.in_ground_time = if p.stuck { a.in_ground_time + 1 } else { 0 };
|
||||
}
|
||||
if p.stuck {
|
||||
@@ -130,6 +132,12 @@ fn remote_arrow_tick(p: &mut Projectile, w: &impl ProjectileWorld) {
|
||||
p.position = at;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.stuck = true;
|
||||
// AbstractArrow.onSyncedDataUpdated: IN_GROUND turning true starts the shake.
|
||||
if let Some(a) = p.arrow.as_mut()
|
||||
&& a.shake <= 0
|
||||
{
|
||||
a.shake = 7;
|
||||
}
|
||||
return;
|
||||
}
|
||||
p.position = to;
|
||||
@@ -164,8 +172,7 @@ impl Game<'_> {
|
||||
/// fifth tick in a block; a spectral arrow an effect sparkle at its
|
||||
/// position while flying.
|
||||
fn arrow_effect_particles(&mut self) {
|
||||
// The arrow's box: `EntityType.ARROW` is 0.5 x 0.5.
|
||||
const SIZE: f64 = 0.5;
|
||||
let mut puffs: Vec<(Vec3, i32, u32)> = Vec::new();
|
||||
for p in &self.creatures.projectiles {
|
||||
let Some(a) = p.arrow.as_ref().filter(|_| p.is_arrow()) else {
|
||||
continue;
|
||||
@@ -178,30 +185,54 @@ impl Game<'_> {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let color = a.effect_color;
|
||||
let count = match (p.stuck, a.in_ground_time % 5) {
|
||||
(true, 0) => 1,
|
||||
(true, _) => 0,
|
||||
(false, _) => 2,
|
||||
};
|
||||
if color == -1 {
|
||||
continue;
|
||||
}
|
||||
// ColorParticleOption.create(ENTITY_EFFECT, colour): ARGB as floats.
|
||||
let channel = |shift: u32| (color as u32 >> shift & 255) as f32 / 255.0;
|
||||
let options = ParticleOptions::colored(
|
||||
ParticleType::EntityEffect,
|
||||
[channel(16), channel(8), channel(0), channel(24)],
|
||||
);
|
||||
for _ in 0..count {
|
||||
// getRandomX(0.5), getRandomY, getRandomZ(0.5).
|
||||
let r = self.particles.random();
|
||||
let x = at.x + SIZE * (2.0 * r.next_double() - 1.0) * 0.5;
|
||||
let y = at.y + SIZE * r.next_double();
|
||||
let z = at.z + SIZE * (2.0 * r.next_double() - 1.0) * 0.5;
|
||||
self.particles.add(&options, [x, y, z], [0.0; 3]);
|
||||
}
|
||||
puffs.push((at, a.effect_color, count));
|
||||
}
|
||||
for (at, color, count) in puffs {
|
||||
self.arrow_makeparticle(at, color, count);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Arrow.makeParticle`: `count` entity-effect swirls in `color` (an ARGB
|
||||
/// int, -1 for none) inside the arrow's 0.5 x 0.5 box.
|
||||
fn arrow_makeparticle(&mut self, at: Vec3, color: i32, count: u32) {
|
||||
// `EntityType.ARROW` is 0.5 x 0.5.
|
||||
const SIZE: f64 = 0.5;
|
||||
if color == -1 {
|
||||
return;
|
||||
}
|
||||
// ColorParticleOption.create(ENTITY_EFFECT, colour): ARGB as floats.
|
||||
let channel = |shift: u32| (color as u32 >> shift & 255) as f32 / 255.0;
|
||||
let options = ParticleOptions::colored(
|
||||
ParticleType::EntityEffect,
|
||||
[channel(16), channel(8), channel(0), channel(24)],
|
||||
);
|
||||
for _ in 0..count {
|
||||
// getRandomX(0.5), getRandomY, getRandomZ(0.5).
|
||||
let r = self.particles.random();
|
||||
let x = at.x + SIZE * (2.0 * r.next_double() - 1.0) * 0.5;
|
||||
let y = at.y + SIZE * r.next_double();
|
||||
let z = at.z + SIZE * (2.0 * r.next_double() - 1.0) * 0.5;
|
||||
self.particles.add(&options, [x, y, z], [0.0; 3]);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Arrow.handleEntityEvent(0)`: a tipped arrow stuck for 600 ticks bursts
|
||||
/// into twenty particles in its colour. The server turns it into a plain
|
||||
/// arrow and does not send the colour again, so it stops here.
|
||||
pub(super) fn remote_arrow_puff(&mut self, id: u32) {
|
||||
let Some(p) = self.creatures.projectiles.iter_mut().find(|p| p.id == id) else {
|
||||
return;
|
||||
};
|
||||
let Some(a) = p.arrow.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let (at, color) = (p.position, std::mem::replace(&mut a.effect_color, -1));
|
||||
self.arrow_makeparticle(at, color, 20);
|
||||
}
|
||||
|
||||
/// `AbstractHurtingProjectile.createParticleTrail`: one smoke puff half a
|
||||
@@ -424,6 +455,16 @@ impl Game<'_> {
|
||||
*critical = flags & 1 != 0;
|
||||
}
|
||||
(ProjectileKind::Arrow { .. }, 10, Value::Bool(in_ground)) => {
|
||||
// AbstractArrow.onSyncedDataUpdated: IN_GROUND turning true after
|
||||
// the first tick starts the shake.
|
||||
if *in_ground
|
||||
&& !p.stuck
|
||||
&& p.age > 0
|
||||
&& let Some(a) = p.arrow.as_mut()
|
||||
&& a.shake <= 0
|
||||
{
|
||||
a.shake = 7;
|
||||
}
|
||||
p.stuck = *in_ground;
|
||||
}
|
||||
// Arrow.ID_EFFECT_COLOR.
|
||||
|
||||
@@ -111,6 +111,11 @@ impl Game<'_> {
|
||||
};
|
||||
let vehicle_id = vehicle as u32;
|
||||
for m in &mut self.mobs {
|
||||
// `Entity.isVehicle` (the saddle reins of a ridden horse): the
|
||||
// server sends no rider on the mob itself, only this list.
|
||||
if m.id == vehicle_id {
|
||||
m.extra.rider = passengers.first().map(|&p| p as u32);
|
||||
}
|
||||
if m.extra.mount == Some(vehicle_id) && !passengers.contains(&(m.id as i32)) {
|
||||
m.extra.mount = None;
|
||||
m.extra.passenger = false;
|
||||
@@ -291,6 +296,11 @@ impl Game<'_> {
|
||||
let strafe = f32::from(u8::from(keys.left)) - f32::from(u8::from(keys.right));
|
||||
let forward = f32::from(u8::from(keys.forward)) - f32::from(u8::from(keys.backward));
|
||||
let m = &mut self.mobs[i];
|
||||
if jump > 0.0 && equine::is_equine(m.species()) {
|
||||
// LocalPlayer.aiStep also runs `onPlayerJump` here: the mount slides
|
||||
// on the rider's keys and rears while the server does the same.
|
||||
equine::on_player_jump(m);
|
||||
}
|
||||
// playerJumpPendingScale waits for the ground.
|
||||
let pending = m.rider.map_or(0.0, |r| r.jump);
|
||||
m.rider = Some(equine::Rider {
|
||||
|
||||
@@ -169,7 +169,7 @@ impl Game<'_> {
|
||||
fn level_min_y(&self) -> i32 {
|
||||
match self.remote.as_ref() {
|
||||
Some(r) => r.dimension.min_y,
|
||||
None => self.dim.min_y(),
|
||||
None => self.world.shape.min_y,
|
||||
}
|
||||
}
|
||||
/// `SkyRenderer.shouldRenderDarkDisc`: eye below the horizon and not under
|
||||
@@ -489,6 +489,20 @@ impl Game<'_> {
|
||||
},
|
||||
);
|
||||
}
|
||||
// SpellParticle.isCloseToScopingPlayer: the eye of a local player looking
|
||||
// through a spyglass in first person.
|
||||
let scope_eye = self
|
||||
.player
|
||||
.as_ref()
|
||||
.filter(|_| self.camera_type.first_person() && self.scoping())
|
||||
.map(|p| {
|
||||
[
|
||||
p.position.x,
|
||||
p.position.y + f64::from(p.dimensions.eye_height),
|
||||
p.position.z,
|
||||
]
|
||||
});
|
||||
self.particles.set_scope_eye(scope_eye);
|
||||
let level = ParticleLevel {
|
||||
world,
|
||||
facts,
|
||||
|
||||
@@ -349,6 +349,12 @@ impl Game<'_> {
|
||||
if self.block_in_tag("minecraft:climbable", name) || open_trapdoor {
|
||||
return VoxelShape::empty();
|
||||
}
|
||||
self.block_collision_shape(p)
|
||||
}
|
||||
/// `BlockState.getCollisionShape(level, pos)`: the shape under the empty
|
||||
/// collision context.
|
||||
pub(super) fn block_collision_shape(&self, p: [i32; 3]) -> VoxelShape {
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
let binding = self.collision;
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
@@ -364,6 +370,22 @@ impl Game<'_> {
|
||||
)
|
||||
.unwrap_or_else(|_| VoxelShape::empty())
|
||||
}
|
||||
/// `DismountHelper.canDismountTo(level, entity, box)`: no block collides
|
||||
/// with the box and it is inside the world border.
|
||||
pub(super) fn can_dismount_to(&self, bounds: Aabb) -> bool {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
if !no_collision_empty(&mut scene, bounds) {
|
||||
return false;
|
||||
}
|
||||
// WorldBorder.isWithinBounds(box).
|
||||
let (lo, hi) = (bounds.min(Axis::X), bounds.max(Axis::X) - 1.0e-5);
|
||||
let (lz, hz) = (bounds.min(Axis::Z), bounds.max(Axis::Z) - 1.0e-5);
|
||||
self.border.contains(lo, lz) && self.border.contains(hi, hz)
|
||||
}
|
||||
pub(super) fn block_in_tag(&self, tag: &str, block: &str) -> bool {
|
||||
self.res
|
||||
.items()
|
||||
@@ -427,12 +449,7 @@ impl Game<'_> {
|
||||
position.z + half,
|
||||
),
|
||||
);
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
if !no_collision_empty(&mut scene, bounds) {
|
||||
if !self.can_dismount_to(bounds) {
|
||||
return None;
|
||||
}
|
||||
let name = |q: [i32; 3]| {
|
||||
@@ -450,10 +467,7 @@ impl Game<'_> {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// WorldBorder.isWithinBounds(box).
|
||||
let (lo, hi) = (bounds.min(Axis::X), bounds.max(Axis::X) - 1.0e-5);
|
||||
let (lz, hz) = (bounds.min(Axis::Z), bounds.max(Axis::Z) - 1.0e-5);
|
||||
(self.border.contains(lo, lz) && self.border.contains(hi, hz)).then_some(position)
|
||||
Some(position)
|
||||
}
|
||||
/// `Block.isPossibleToRespawnInThis`: neither solid nor liquid; banners,
|
||||
/// signs and pressure plates override it to always.
|
||||
|
||||
@@ -284,11 +284,12 @@ impl Loading {
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
// The loader sends `Done` before it returns: it panicked.
|
||||
let reason = match self.handle.take().map(JoinHandle::join) {
|
||||
Some(Err(payload)) => format!(": {}", panic_text(payload.as_ref())),
|
||||
_ => String::new(),
|
||||
Some(Err(payload)) => panic_text(payload.as_ref()).to_owned(),
|
||||
Some(Ok(())) => "it returned without a result".into(),
|
||||
None => "no loader thread was running".into(),
|
||||
};
|
||||
return Err(
|
||||
format!("resource loader stopped before finishing{reason}").into()
|
||||
format!("resource loader stopped before finishing: {reason}").into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -887,7 +888,7 @@ mod tests {
|
||||
let mut loading = loading(receiver);
|
||||
assert_eq!(
|
||||
loading.poll_menu_resources().unwrap_err().to_string(),
|
||||
"resource loader stopped before finishing"
|
||||
"resource loader stopped before finishing: no loader thread was running"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,123 @@ impl Kind {
|
||||
}
|
||||
}
|
||||
|
||||
/// `CyclingSlotBackground`: the icons an empty slot cycles through, one every
|
||||
/// 30 ticks, the next fading in over 4 (`CyclingSlotBackground.tick` in
|
||||
/// `advance`, `CyclingSlotBackground.extractRenderState` in `shown`).
|
||||
#[derive(Default)]
|
||||
struct CyclingSlot {
|
||||
icons: Vec<&'static str>,
|
||||
tick: u32,
|
||||
index: usize,
|
||||
/// The client tick it was last advanced to.
|
||||
at: Option<u64>,
|
||||
}
|
||||
impl CyclingSlot {
|
||||
const CHANGE_TICKS: u32 = 30;
|
||||
const FADE_TICKS: f32 = 4.0;
|
||||
/// `CyclingSlotBackground.tick(newIcons)` for each client tick since the last call.
|
||||
fn advance(&mut self, now: u64, icons: &[&'static str]) {
|
||||
let ticks = self.at.map_or(0, |at| now.saturating_sub(at).min(600));
|
||||
self.at = Some(now);
|
||||
if self.icons != icons {
|
||||
self.icons = icons.to_vec();
|
||||
self.index = 0;
|
||||
}
|
||||
for _ in 0..ticks {
|
||||
if !self.icons.is_empty() {
|
||||
self.tick += 1;
|
||||
if self.tick.is_multiple_of(Self::CHANGE_TICKS) {
|
||||
self.index = (self.index + 1) % self.icons.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What `CyclingSlotBackground.extractRenderState` draws for an empty slot: the icon before
|
||||
/// this one fading out while this one fades in, with their alphas.
|
||||
fn shown(&self, partial: f32) -> Vec<(&'static str, f32)> {
|
||||
if self.icons.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let fading = self.icons.len() > 1 && self.tick >= Self::CHANGE_TICKS;
|
||||
let alpha = if fading {
|
||||
(((self.tick % Self::CHANGE_TICKS) as f32 + partial).min(Self::FADE_TICKS))
|
||||
/ Self::FADE_TICKS
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let mut shown = Vec::new();
|
||||
if alpha < 1.0 {
|
||||
let previous = (self.index + self.icons.len() - 1) % self.icons.len();
|
||||
shown.push((self.icons[previous], 1.0 - alpha));
|
||||
}
|
||||
shown.push((self.icons[self.index], alpha));
|
||||
shown
|
||||
}
|
||||
}
|
||||
/// `SmithingScreen`'s `EMPTY_SLOT_SMITHING_TEMPLATES`.
|
||||
const EMPTY_TEMPLATE_ICONS: [&str; 2] = [
|
||||
"smithing_template_armor_trim",
|
||||
"smithing_template_netherite_upgrade",
|
||||
];
|
||||
/// The base and additions slot icons of the template in the smithing table:
|
||||
/// `SmithingTemplateItem.createNetheriteUpgradeTemplate` (the 11 tools and
|
||||
/// armour of `SmithingTemplateItem.createNetheriteUpgradeIconList`, then the
|
||||
/// ingot) and `SmithingTemplateItem.createArmorTrimTemplate` (the four armour
|
||||
/// pieces, then the seven trim materials), as `getBaseSlotEmptyIcons` and
|
||||
/// `getAdditionalSlotEmptyIcons` give them.
|
||||
fn smithing_slot_icons(
|
||||
template: Option<&str>,
|
||||
) -> ([&'static str; 11], usize, [&'static str; 7], usize) {
|
||||
let none = ([""; 11], 0, [""; 7], 0);
|
||||
match template.map(|t| t.trim_start_matches("minecraft:")) {
|
||||
Some("netherite_upgrade_smithing_template") => (
|
||||
[
|
||||
"helmet",
|
||||
"sword",
|
||||
"chestplate",
|
||||
"pickaxe",
|
||||
"leggings",
|
||||
"axe",
|
||||
"boots",
|
||||
"hoe",
|
||||
"shovel",
|
||||
"nautilus_armor",
|
||||
"spear",
|
||||
],
|
||||
11,
|
||||
["ingot", "", "", "", "", "", ""],
|
||||
1,
|
||||
),
|
||||
Some(t) if t.ends_with("_armor_trim_smithing_template") => (
|
||||
[
|
||||
"helmet",
|
||||
"chestplate",
|
||||
"leggings",
|
||||
"boots",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
],
|
||||
4,
|
||||
[
|
||||
"ingot",
|
||||
"redstone_dust",
|
||||
"lapis_lazuli",
|
||||
"quartz",
|
||||
"diamond",
|
||||
"emerald",
|
||||
"amethyst_shard",
|
||||
],
|
||||
7,
|
||||
),
|
||||
_ => none,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Station {
|
||||
pub(super) kind: Kind,
|
||||
pub(super) pos: [i32; 3],
|
||||
@@ -161,6 +278,8 @@ pub(super) struct Station {
|
||||
pub(super) beacon_levels: Option<i32>,
|
||||
/// The loom's patterns, selection and scroller (game/loom.rs).
|
||||
pub(super) loom: super::loom::LoomState,
|
||||
/// The smithing table's three `CyclingSlotBackground`s: the template, base and additions slots.
|
||||
cycling: [CyclingSlot; 3],
|
||||
}
|
||||
impl Station {
|
||||
fn new(kind: Kind, pos: [i32; 3]) -> Self {
|
||||
@@ -186,6 +305,7 @@ impl Station {
|
||||
beacon: [None; 2],
|
||||
beacon_levels: None,
|
||||
loom: Default::default(),
|
||||
cycling: Default::default(),
|
||||
}
|
||||
}
|
||||
pub(super) fn new_kind(kind: Kind, pos: [i32; 3]) -> Self {
|
||||
@@ -1221,6 +1341,21 @@ impl Game<'_> {
|
||||
if self.station.is_none() && !brewing {
|
||||
return false;
|
||||
}
|
||||
// `SmithingScreen.containerTick`: the icons of the empty slots follow the template.
|
||||
let (now, partial) = (self.ticks, self.clock.camera_fraction() as f32);
|
||||
if let Some(s) = self.station.as_mut().filter(|s| s.kind == Kind::Smithing) {
|
||||
let template = s.inputs[0].as_ref().map(|t| t.item.clone());
|
||||
let (base, base_len, additions, additions_len) =
|
||||
smithing_slot_icons(template.as_deref());
|
||||
let lists: [&[&'static str]; 3] = [
|
||||
&EMPTY_TEMPLATE_ICONS,
|
||||
&base[..base_len],
|
||||
&additions[..additions_len],
|
||||
];
|
||||
for (cycling, icons) in s.cycling.iter_mut().zip(lists) {
|
||||
cycling.advance(now, icons);
|
||||
}
|
||||
}
|
||||
let (ox, oy) = self.inventory_origin(batch);
|
||||
let local = (self.cursor.0 - ox, self.cursor.1 - oy);
|
||||
let mut tooltip: Option<Vec<(horizon_client::text::Text, u32)>> = None;
|
||||
@@ -1609,15 +1744,23 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Kind::Smithing => {
|
||||
let icons = ["smithing_template_netherite_upgrade", "", ""];
|
||||
for (i, icon) in icons.iter().enumerate() {
|
||||
if s.inputs[i].is_none() && !icon.is_empty() {
|
||||
let (sx, sy) = kind.input_pos(i);
|
||||
batch.sprite(
|
||||
// `CyclingSlotBackground.extractRenderState`: an empty slot shows the
|
||||
// icons of what the template in the table takes.
|
||||
for (i, cycling) in s.cycling.iter().enumerate() {
|
||||
if s.inputs[i].is_some() {
|
||||
continue;
|
||||
}
|
||||
let (sx, sy) = kind.input_pos(i);
|
||||
for (icon, alpha) in cycling.shown(partial) {
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
&format!("gui/sprites/container/slot/{icon}"),
|
||||
ox + sx,
|
||||
oy + sy,
|
||||
16.0,
|
||||
16.0,
|
||||
((alpha.clamp(0.0, 1.0) * 255.0).round() as u32) << 24
|
||||
| 0x00FF_FFFF,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2189,6 +2332,11 @@ impl Game<'_> {
|
||||
));
|
||||
}
|
||||
}
|
||||
// Native mods last, once getTooltipLines has built every line; a
|
||||
// tooltip the stack hides stays hidden.
|
||||
if !nested && !lines.is_empty() {
|
||||
self.native_tooltip(stack, &mut lines);
|
||||
}
|
||||
lines
|
||||
}
|
||||
/// Menu state for the control socket.
|
||||
@@ -2605,3 +2753,45 @@ mod tests {
|
||||
assert!(!s.stonecutter_scrolls());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cycling_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_empty_slot_cycles_its_icons_every_30_ticks_and_fades_the_next_in_over_4() {
|
||||
let icons = ["helmet", "sword", "chestplate"];
|
||||
let mut slot = CyclingSlot::default();
|
||||
slot.advance(100, &icons);
|
||||
// The first icon, fully shown, until the 30th tick.
|
||||
assert_eq!(slot.shown(0.5), vec![("helmet", 1.0)]);
|
||||
slot.advance(129, &icons);
|
||||
assert_eq!(slot.shown(0.5), vec![("helmet", 1.0)]);
|
||||
// The 30th tick moves to the next icon, which fades in over 4 ticks while the
|
||||
// one before fades out.
|
||||
slot.advance(130, &icons);
|
||||
assert_eq!(slot.shown(0.0), vec![("helmet", 1.0), ("sword", 0.0)]);
|
||||
assert_eq!(slot.shown(2.0), vec![("helmet", 0.5), ("sword", 0.5)]);
|
||||
assert_eq!(slot.shown(4.0), vec![("sword", 1.0)]);
|
||||
// A new list starts again from its first icon, keeping the tick count.
|
||||
slot.advance(131, &["ingot"]);
|
||||
assert_eq!(slot.shown(0.0), vec![("ingot", 1.0)]);
|
||||
// No icons, nothing shown.
|
||||
slot.advance(132, &[]);
|
||||
assert!(slot.shown(0.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_icons_of_a_smithing_slot_follow_the_template() {
|
||||
let (base, n, additions, m) =
|
||||
smithing_slot_icons(Some("minecraft:netherite_upgrade_smithing_template"));
|
||||
assert_eq!(base[..n].len(), 11);
|
||||
assert_eq!(additions[..m], ["ingot"]);
|
||||
let (base, n, additions, m) =
|
||||
smithing_slot_icons(Some("minecraft:coast_armor_trim_smithing_template"));
|
||||
assert_eq!(base[..n], ["helmet", "chestplate", "leggings", "boots"]);
|
||||
assert_eq!(additions[..m].len(), 7);
|
||||
assert_eq!(smithing_slot_icons(Some("minecraft:stick")).1, 0);
|
||||
assert_eq!(smithing_slot_icons(None).3, 0);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use horizon_client::{
|
||||
mesher::Vertex,
|
||||
mobs::{Mob, Species, sulfur_cube},
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
|
||||
fn block_transform(base: glam::Mat4, baby: bool) -> glam::Mat4 {
|
||||
base * glam::Mat4::from_scale(glam::Vec3::new(1.0, -1.0, -1.0))
|
||||
@@ -150,6 +150,34 @@ impl Game<'_> {
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `SulfurCube.primeWhenOnPoweredPosition`: an explosive cube standing in
|
||||
/// or next to redstone power lights its fuse (`getBestOwnOrNeighbourSignal`;
|
||||
/// no block has an own signal, so the neighbour signal is all there is).
|
||||
pub(super) fn sulfur_cube_power_priming(&mut self) {
|
||||
if !self.ui.rules.bool("tnt_explodes") {
|
||||
return;
|
||||
}
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if m.species() != Species::SulfurCube
|
||||
|| !sulfur_cube::archetype(m).is_some_and(|a| a.explosion.is_some())
|
||||
|| !m.alive()
|
||||
|| sulfur_cube::primed(m)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let at = BlockPos::new(
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
);
|
||||
let signal =
|
||||
self.with_level(|b, level, ticks| b.best_neighbor_signal(level, ticks, at));
|
||||
if signal != 0 {
|
||||
sulfur_cube::prime(&mut self.mobs[i], false);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `SulfurCubeSearchForItemsGoal` and `pickUpItem`: an adult with nothing
|
||||
/// inside hops to a block it can swallow within 8 and takes it in.
|
||||
pub(super) fn sulfur_cube_pickups(&mut self) {
|
||||
|
||||
@@ -223,6 +223,24 @@ impl Game<'_> {
|
||||
((d.brightness as u32 >> 20 & 15) * 16) | (((d.brightness as u32 >> 4 & 15) * 16) << 8)
|
||||
}
|
||||
}
|
||||
/// `Display.shouldRenderAtSqrDistance` and, when width and height are set,
|
||||
/// `EntityRenderer.shouldRender` with `Display.getInterpolatedBoundingBox`
|
||||
/// inflated by 0.5. Width or height 0 is `noCulling`: distance only.
|
||||
fn display_in_view(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
origin: glam::DVec3,
|
||||
data: &TextDisplayData,
|
||||
view_scale: f64,
|
||||
) -> bool {
|
||||
let range = f64::from(data.view_range) * 64.0 * view_scale;
|
||||
if text_display::no_culling(data.width, data.height) {
|
||||
return (origin - camera).length_squared() < range * range;
|
||||
}
|
||||
let (lo, hi) = text_display::culling_box(origin, data.width, data.height);
|
||||
self.entity_in_view(camera, origin, range, lo, hi)
|
||||
}
|
||||
|
||||
/// `DisplayRenderer.BlockDisplayRenderer` and `ItemDisplayRenderer`: the
|
||||
/// block model or the item under the display's transformation, lit like
|
||||
/// any entity and never tinted by a hurt overlay.
|
||||
@@ -246,8 +264,7 @@ impl Game<'_> {
|
||||
display.last_progress = display.progress(partial as f32);
|
||||
let d = display.shown.blend(&display.from, display.last_progress);
|
||||
let position = display.previous.lerp(display.position, partial);
|
||||
let range = f64::from(d.view_range) * 64.0 * scale;
|
||||
if position.distance_squared(camera) >= range * range {
|
||||
if !self.display_in_view(camera, position, &d, scale) {
|
||||
continue;
|
||||
}
|
||||
let rotation = std::array::from_fn(|i| {
|
||||
@@ -295,18 +312,15 @@ impl Game<'_> {
|
||||
) {
|
||||
self.text_display_vertices = 0;
|
||||
self.mark_translucent_entity(normal.len(), "minecraft:text_display");
|
||||
let atlas = &self.gui.atlas;
|
||||
let angles = self.display_camera_angles();
|
||||
let scale = self.view_scale();
|
||||
for display in self.text_displays.values_mut() {
|
||||
if display.shows != Shows::Text {
|
||||
continue;
|
||||
}
|
||||
let background = self.settings.text_background(0.25);
|
||||
let mut displays = std::mem::take(&mut self.text_displays);
|
||||
for display in displays.values_mut().filter(|d| d.shows == Shows::Text) {
|
||||
display.last_progress = display.progress(partial as f32);
|
||||
let d = display.shown.blend(&display.from, display.last_progress);
|
||||
let position = display.previous.lerp(display.position, partial);
|
||||
let range = f64::from(d.view_range) * 64.0 * scale;
|
||||
if position.distance_squared(camera) >= range * range || d.text.is_empty() {
|
||||
if d.text.is_empty() || !self.display_in_view(camera, position, &d, scale) {
|
||||
continue;
|
||||
}
|
||||
let rotation = std::array::from_fn(|i| {
|
||||
@@ -315,7 +329,7 @@ impl Game<'_> {
|
||||
});
|
||||
let lines = display
|
||||
.lines
|
||||
.get_or_insert_with(|| Lines::split(&atlas.font, &d.text, d.line_width));
|
||||
.get_or_insert_with(|| Lines::split(&self.gui.atlas.font, &d.text, d.line_width));
|
||||
let matrix = glam::Mat4::from_translation((position - camera).as_vec3())
|
||||
* d.matrix(rotation, angles)
|
||||
* lines.pose();
|
||||
@@ -323,13 +337,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
text_display::draw(
|
||||
&mut batch,
|
||||
atlas,
|
||||
&d,
|
||||
lines,
|
||||
self.settings.text_background(0.25),
|
||||
);
|
||||
text_display::draw(&mut batch, &self.gui.atlas, &d, lines, background);
|
||||
let light = Self::display_light(&self.world, &d, position);
|
||||
// Font.DisplayMode: SEE_THROUGH over everything, else POLYGON_OFFSET.
|
||||
let through = d.flags & 2 != 0;
|
||||
@@ -358,6 +366,7 @@ impl Game<'_> {
|
||||
self.text_display_vertices += batch.vertices.len() / 6 * 4;
|
||||
self.map_patches.extend(batch.patches);
|
||||
}
|
||||
self.text_displays = displays;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//! controls and sorting, both off by default: while an extra is off its
|
||||
//! hooks return false and the screen clicks exactly as vanilla's. Each action is made of ordinary slot clicks
|
||||
//! (the menu engine's, or `ContainerClick`s on a server), so it works
|
||||
//! wherever vanilla's clicks do. They run in the player inventory and the
|
||||
//! wherever vanilla's clicks do. They run in the player inventory, the
|
||||
//! chest-like containers (chests, barrels, ender chests, shulker boxes,
|
||||
//! hoppers, dispensers).
|
||||
//! hoppers, dispensers), furnaces and crafting tables.
|
||||
//!
|
||||
//! - Right-drag holding a stack: one item into each slot entered, at once.
|
||||
//! - Left-drag holding a stack, before vanilla's drag has spread over a
|
||||
@@ -12,8 +12,10 @@
|
||||
//! the hand (when it all fits); with Shift, those slots are quick-moved.
|
||||
//! - Shift + left-drag with an empty hand: every slot entered is quick-moved.
|
||||
//! - The wheel over a slot, with an empty hand: down moves one of its items
|
||||
//! to the other side (container and inventory, or hotbar and inventory),
|
||||
//! up brings one of the same item back.
|
||||
//! where a Shift click would send it (`quickMoveStack`: fuel to the fuel
|
||||
//! slot, a smeltable to the input), up brings one of the same item from the
|
||||
//! other side. Down over a result slot takes one craft or the whole furnace
|
||||
//! output to the inventory; nothing ever goes into a result slot.
|
||||
//!
|
||||
//! Sorting draws a small button by the container's title and one by the
|
||||
//! inventory's (an optional key sorts the part under the cursor); the
|
||||
@@ -39,6 +41,23 @@ pub(super) struct Tweaks {
|
||||
/// Wheel steps not yet acted on (a trackpad scrolls in fractions).
|
||||
wheel: f32,
|
||||
}
|
||||
/// A server's menu the tweaks run in: their clicks are vanilla's in these,
|
||||
/// and the menu engine knows which slot takes what. Not the menus whose slots
|
||||
/// cost or trigger something (anvils, enchanting, merchants, beacons, crafters).
|
||||
pub(super) fn tweak_menu(name: &str) -> bool {
|
||||
name == "inventory"
|
||||
|| name.starts_with("minecraft:generic_9x")
|
||||
|| matches!(
|
||||
name,
|
||||
"minecraft:shulker_box"
|
||||
| "minecraft:hopper"
|
||||
| "minecraft:generic_3x3"
|
||||
| "minecraft:furnace"
|
||||
| "minecraft:blast_furnace"
|
||||
| "minecraft:smoker"
|
||||
| "minecraft:crafting"
|
||||
)
|
||||
}
|
||||
/// A drag the tweaks follow.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Drag {
|
||||
@@ -62,7 +81,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
self.remote_storage_open()
|
||||
self.remote_menu_type().is_none_or(tweak_menu)
|
||||
} else {
|
||||
matches!(self.container_kind(), None | Some(BlockEntity::Chest(_)))
|
||||
}
|
||||
@@ -80,6 +99,28 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Puts the hand's stack (`right`: one of it) in `slot`, as a one-slot
|
||||
/// drag (`QUICK_CRAFT`) and not a click: a server that has the hand empty
|
||||
/// (the stack it was to come from just burned as fuel) ignores a drag, but
|
||||
/// a click would pick up what's in `slot`.
|
||||
fn tweak_put(&mut self, slot: usize, right: bool) -> bool {
|
||||
let kind = i32::from(right) << 2;
|
||||
let ops = [
|
||||
(None, kind, Input::QuickCraft),
|
||||
(Some(slot), kind | 1, Input::QuickCraft),
|
||||
(None, kind | 2, Input::QuickCraft),
|
||||
];
|
||||
if self.remote.is_some() {
|
||||
return self.remote_menu_ops(&ops);
|
||||
}
|
||||
if !self.menu_ops(&ops) {
|
||||
return false;
|
||||
}
|
||||
if let Some(key) = self.container {
|
||||
self.container_changed(key);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// A press on `slot`: true when the tweaks took it (a right-drag
|
||||
/// placing), else vanilla's press runs and the tweaks watch the drag.
|
||||
pub(super) fn tweak_press(&mut self, slot: Option<usize>, right: bool, shift: bool) -> bool {
|
||||
@@ -92,7 +133,8 @@ impl Game<'_> {
|
||||
}
|
||||
let held = self.carried.clone();
|
||||
if right {
|
||||
if shift || held.is_none() {
|
||||
// A result slot takes nothing: its press stays vanilla's.
|
||||
if shift || held.is_none() || self.tweak_result(slot) {
|
||||
return false;
|
||||
}
|
||||
// RMB: the press places at once, then the drag goes on.
|
||||
@@ -123,11 +165,7 @@ impl Game<'_> {
|
||||
let Some(held) = drag.held.clone() else {
|
||||
return;
|
||||
};
|
||||
let accepts = match self.slot_view(slot) {
|
||||
None => true,
|
||||
Some(s) => s.same_item_same_components(&held) && s.count < self.max_stack(&s),
|
||||
};
|
||||
if accepts && self.tweak_click(slot, 1, Input::Pickup) {
|
||||
if self.tweak_takes_one(slot, &held) && self.tweak_put(slot, true) {
|
||||
drag.held = (held.count > 1).then(|| ItemStack {
|
||||
count: held.count - 1,
|
||||
..held
|
||||
@@ -192,7 +230,7 @@ impl Game<'_> {
|
||||
if !self.settings.extras.mouse_tweaks_wheel || !self.tweaks_on() || self.carried.is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(slot) = slot.filter(|&s| s != 200) else {
|
||||
let Some(slot) = slot else {
|
||||
self.tweaks.wheel = 0.0;
|
||||
return false;
|
||||
};
|
||||
@@ -208,73 +246,111 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// The slots across from `slot`, in quick-move order: a container's
|
||||
/// go to the inventory (hotbar first, from the right), the inventory's
|
||||
/// to the container; without one, hotbar and main inventory trade.
|
||||
/// `slot` is a result slot of the open menu.
|
||||
fn tweak_result(&mut self, slot: usize) -> bool {
|
||||
let mut result = false;
|
||||
self.menu_session(|menu, _, to_menu| {
|
||||
result = to_menu(slot).is_some_and(|i| menu.is_result(i));
|
||||
Some(false)
|
||||
});
|
||||
result
|
||||
}
|
||||
/// `slot` takes one more of `stack`, by the open menu's rules.
|
||||
fn tweak_takes_one(&mut self, slot: usize, stack: &ItemStack) -> bool {
|
||||
let mut takes = false;
|
||||
self.menu_session(|menu, env, to_menu| {
|
||||
takes = to_menu(slot).is_some_and(|i| menu.takes_one(env, i, stack));
|
||||
Some(false)
|
||||
});
|
||||
takes
|
||||
}
|
||||
/// Where vanilla's quick move would put `slot`'s items, tried on a copy
|
||||
/// of the menu that's dropped (`Some(false)` keeps nothing).
|
||||
fn tweak_landing(&mut self, slot: usize) -> Vec<usize> {
|
||||
let slots = self.slots();
|
||||
let mut landing = Vec::new();
|
||||
self.menu_session(|menu, env, to_menu| {
|
||||
let from = to_menu(slot)?;
|
||||
landing = menu
|
||||
.quick_move_landing(env, from)
|
||||
.into_iter()
|
||||
.filter_map(|i| slots.iter().copied().find(|&g| to_menu(g) == Some(i)))
|
||||
.collect();
|
||||
Some(false)
|
||||
});
|
||||
landing
|
||||
}
|
||||
/// The slots across from `slot`: a container's or crafting grid's face
|
||||
/// the inventory (hotbar first, from the right), the inventory's face
|
||||
/// them; without one, hotbar and main inventory trade.
|
||||
fn other_side(&self, slot: usize) -> Vec<usize> {
|
||||
let container: Vec<usize> = self
|
||||
let table = self.craft_size == 3;
|
||||
let own: Vec<usize> = self
|
||||
.slots()
|
||||
.into_iter()
|
||||
.filter(|s| (300..500).contains(s))
|
||||
.filter(|s| (300..500).contains(s) || (table && (100..109).contains(s)))
|
||||
.collect();
|
||||
let player = || (0..9).rev().chain((9..36).rev());
|
||||
match slot {
|
||||
300..500 => player().collect(),
|
||||
0..36 if !container.is_empty() => container,
|
||||
100..109 | 300..500 => player().collect(),
|
||||
0..36 if !own.is_empty() => own,
|
||||
0..9 => (9..36).collect(),
|
||||
9..36 => (0..9).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
/// Wheel down: one item of `slot` to the other side, where a stack of
|
||||
/// it has room, else to the first empty slot (`moveItemStackTo`).
|
||||
/// Wheel down: one item of `slot` to where a quick move sends it. A
|
||||
/// result can't be split or put back: one craft (or the furnace's whole
|
||||
/// output) goes, when it all fits.
|
||||
fn tweak_push(&mut self, slot: usize) {
|
||||
let Some(stack) = self.slot_view(slot) else {
|
||||
return;
|
||||
};
|
||||
let max = self.max_stack(&stack);
|
||||
let side = self.other_side(slot);
|
||||
let views: Vec<(usize, Option<ItemStack>)> =
|
||||
side.iter().map(|&s| (s, self.slot_view(s))).collect();
|
||||
let target = views
|
||||
.iter()
|
||||
.find(|(_, v)| {
|
||||
v.as_ref()
|
||||
.is_some_and(|v| v.same_item_same_components(&stack) && v.count < max)
|
||||
})
|
||||
.or_else(|| views.iter().find(|(_, v)| v.is_none()))
|
||||
.map(|(s, _)| *s);
|
||||
let Some(target) = target else {
|
||||
let landing = self.tweak_landing(slot);
|
||||
let Some(&target) = landing.first() else {
|
||||
return;
|
||||
};
|
||||
if self.tweak_result(slot) {
|
||||
if self.tweak_click(slot, 0, Input::Pickup) {
|
||||
for target in landing {
|
||||
self.tweak_put(target, false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.tweak_click(slot, 0, Input::Pickup) {
|
||||
self.tweak_click(target, 1, Input::Pickup);
|
||||
self.tweak_put(target, true);
|
||||
if stack.count > 1 {
|
||||
self.tweak_click(slot, 0, Input::Pickup);
|
||||
self.tweak_put(slot, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Wheel up: one item of the same kind from the other side into `slot`.
|
||||
/// A result slot takes none and gives none.
|
||||
fn tweak_pull(&mut self, slot: usize) {
|
||||
let Some(stack) = self.slot_view(slot) else {
|
||||
return;
|
||||
};
|
||||
if stack.count >= self.max_stack(&stack) {
|
||||
if !self.tweak_takes_one(slot, &stack) {
|
||||
return;
|
||||
}
|
||||
let side = self.other_side(slot);
|
||||
let source = side.into_iter().find_map(|s| {
|
||||
self.slot_view(s)
|
||||
.filter(|v| v.same_item_same_components(&stack))
|
||||
.map(|v| (s, v.count))
|
||||
});
|
||||
let mut source = None;
|
||||
for s in self.other_side(slot) {
|
||||
if let Some(v) = self.slot_view(s)
|
||||
&& v.same_item_same_components(&stack)
|
||||
&& !self.tweak_result(s)
|
||||
{
|
||||
source = Some((s, v.count));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some((source, count)) = source else {
|
||||
return;
|
||||
};
|
||||
if self.tweak_click(source, 0, Input::Pickup) {
|
||||
self.tweak_click(slot, 1, Input::Pickup);
|
||||
self.tweak_put(slot, true);
|
||||
if count > 1 {
|
||||
self.tweak_click(source, 0, Input::Pickup);
|
||||
self.tweak_put(source, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -411,3 +487,39 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::tweak_menu;
|
||||
|
||||
#[test]
|
||||
fn the_tweaks_run_in_the_menus_with_plain_click_rules() {
|
||||
for menu in [
|
||||
"inventory",
|
||||
"minecraft:generic_9x1",
|
||||
"minecraft:generic_9x6",
|
||||
"minecraft:shulker_box",
|
||||
"minecraft:hopper",
|
||||
"minecraft:generic_3x3",
|
||||
"minecraft:furnace",
|
||||
"minecraft:blast_furnace",
|
||||
"minecraft:smoker",
|
||||
"minecraft:crafting",
|
||||
] {
|
||||
assert!(tweak_menu(menu), "{menu}");
|
||||
}
|
||||
for menu in [
|
||||
"minecraft:anvil",
|
||||
"minecraft:enchantment",
|
||||
"minecraft:merchant",
|
||||
"minecraft:beacon",
|
||||
"minecraft:grindstone",
|
||||
"minecraft:stonecutter",
|
||||
"minecraft:loom",
|
||||
"minecraft:brewing_stand",
|
||||
"minecraft:crafter_3x3",
|
||||
] {
|
||||
assert!(!tweak_menu(menu), "{menu}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ pub(super) struct WorldRequest {
|
||||
|
||||
pub(super) struct PreparedWorld {
|
||||
pub save_root: PathBuf,
|
||||
/// The levels' shapes, from the world's dimension types.
|
||||
pub shapes: horizon_client::chunk::LevelShapes,
|
||||
pub level: Level,
|
||||
pub world: World,
|
||||
pub streaming: Streaming,
|
||||
@@ -115,11 +117,14 @@ impl WorldRequest {
|
||||
level.position = None;
|
||||
}
|
||||
let dim = Dim::from_id(&level.dimension);
|
||||
let (mut streaming, save, locator) =
|
||||
make_streaming(res, pack, options, settings, &save_root, dim, level.seed)?;
|
||||
let shapes = level_shapes(&save_root, res.biomes());
|
||||
let shape = shapes.get(dim.id());
|
||||
let (mut streaming, save, locator) = make_streaming(
|
||||
res, pack, options, settings, &save_root, dim, shape, level.seed,
|
||||
)?;
|
||||
streaming.radius = settings.render_distance;
|
||||
let mut world = World::new(res.facts().air, level.seed);
|
||||
world.shape = dim.shape();
|
||||
world.shape = shape;
|
||||
world.fast_lava = dim == Dim::Nether;
|
||||
world.game_time = level.time;
|
||||
world.day_time = options.time.unwrap_or(level.day_time);
|
||||
@@ -146,6 +151,7 @@ impl WorldRequest {
|
||||
}
|
||||
Ok(PreparedWorld {
|
||||
save_root,
|
||||
shapes,
|
||||
level,
|
||||
world,
|
||||
streaming,
|
||||
@@ -337,6 +343,46 @@ fn world_worldgen_overrides(
|
||||
overrides
|
||||
}
|
||||
|
||||
/// The shapes of the world's levels: their dimension types, the world's
|
||||
/// enabled data packs' over vanilla's. A type the codec refuses keeps the
|
||||
/// vanilla shape, with a warning.
|
||||
pub(super) fn level_shapes(
|
||||
world_root: &std::path::Path,
|
||||
biomes: &horizon_client::biome::Biomes,
|
||||
) -> horizon_client::chunk::LevelShapes {
|
||||
use horizon_client::chunk::{LevelShape, LevelShapes};
|
||||
let packs = horizon_client::datapack::discover(world_root);
|
||||
let enabled = horizon_client::datapack::enabled_ids(world_root, &packs);
|
||||
let enabled: Vec<_> = enabled
|
||||
.iter()
|
||||
.filter_map(|id| packs.iter().find(|p| &p.id == id))
|
||||
.collect();
|
||||
let mut shapes = LevelShapes::VANILLA;
|
||||
if enabled.is_empty() {
|
||||
return shapes;
|
||||
}
|
||||
// What the packs leave out is logged when the generator reads them.
|
||||
let (overrides, _) =
|
||||
horizon_client::datapack::worldgen_overrides(&enabled, |b| biomes.contains(b));
|
||||
for level in [
|
||||
"minecraft:overworld",
|
||||
"minecraft:the_nether",
|
||||
"minecraft:the_end",
|
||||
] {
|
||||
let shape = overrides.level_height(level).and_then(|h| {
|
||||
LevelShape::new(h.min_y, h.height, h.logical_height, h.has_skylight).ok()
|
||||
});
|
||||
match shape {
|
||||
Some(shape) => *shapes.get_mut(level) = shape,
|
||||
None => log::warn!(
|
||||
"Horizon: data pack worldgen: the dimension type of {level} is not one the \
|
||||
game accepts; the level keeps the vanilla build height"
|
||||
),
|
||||
}
|
||||
}
|
||||
shapes
|
||||
}
|
||||
|
||||
pub(super) fn make_streaming(
|
||||
res: &Resources,
|
||||
pack: &PackResources,
|
||||
@@ -344,11 +390,12 @@ pub(super) fn make_streaming(
|
||||
settings: &Settings,
|
||||
world_root: &std::path::Path,
|
||||
dim: Dim,
|
||||
shape: horizon_client::chunk::LevelShape,
|
||||
seed: i64,
|
||||
) -> Result<(Streaming, Arc<SaveDir>, Option<Arc<VanillaTerrain>>)> {
|
||||
let save = Arc::new(SaveDir::new(
|
||||
horizon_client::save::dimension_dir(world_root, dim.id()),
|
||||
dim.shape(),
|
||||
shape,
|
||||
));
|
||||
let mut locator = None;
|
||||
let flat = (dim == Dim::Overworld)
|
||||
@@ -359,7 +406,7 @@ pub(super) fn make_streaming(
|
||||
&s,
|
||||
pack.assets.registry,
|
||||
res.biomes(),
|
||||
dim.shape(),
|
||||
shape,
|
||||
)
|
||||
});
|
||||
let mut streaming = if let Some(inner) = flat {
|
||||
|
||||
@@ -13,7 +13,7 @@ use horizon_protocol::{
|
||||
chunk::{
|
||||
BIOMES, BLOCKS, BlockEntityInfo, ChunkData, Heightmaps, LightData, Paletted, Strategy,
|
||||
},
|
||||
packets::play::LevelChunkWithLight,
|
||||
packets::play::{ChunkBiomeData, LevelChunkWithLight},
|
||||
registry::{Builtin, ceil_log2},
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
@@ -263,15 +263,6 @@ impl SectionCoder {
|
||||
values.push(v as i32);
|
||||
}
|
||||
}
|
||||
let biome_values: Vec<i32> = (0..BIOMES)
|
||||
.map(|j| {
|
||||
let internal = section.biomes.get(j) as u16;
|
||||
self.biome_ids
|
||||
.get(usize::from(internal))
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.collect();
|
||||
(
|
||||
horizon_protocol::chunk::Section {
|
||||
non_empty,
|
||||
@@ -288,11 +279,26 @@ impl SectionCoder {
|
||||
crate::chunk::block_strategy().global_bits,
|
||||
),
|
||||
},
|
||||
biomes: Paletted::from_values(&biome_values, Strategy::Biomes, self.biome_bits),
|
||||
biomes: self.biomes(section),
|
||||
},
|
||||
entities,
|
||||
)
|
||||
}
|
||||
|
||||
/// The section's biome container (`PalettedContainerRO.write`'s), in
|
||||
/// network biome ids.
|
||||
fn biomes(&self, section: &Section) -> Paletted {
|
||||
let values: Vec<i32> = (0..BIOMES)
|
||||
.map(|j| {
|
||||
let internal = section.biomes.get(j) as u16;
|
||||
self.biome_ids
|
||||
.get(usize::from(internal))
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.collect();
|
||||
Paletted::from_values(&values, Strategy::Biomes, self.biome_bits)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sections kept before the ones no longer resident are dropped.
|
||||
@@ -453,6 +459,16 @@ impl ChunkEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClientboundChunksBiomesPacket.ChunkBiomeData`: the column's biome
|
||||
/// containers, one per section of the level (`extractChunkData`).
|
||||
pub fn biome_data(&self, chunk: &Chunk) -> ChunkBiomeData {
|
||||
let biomes: Vec<Paletted> = chunk.sections[self.sections.clone()]
|
||||
.iter()
|
||||
.map(|s| self.coder.biomes(s))
|
||||
.collect();
|
||||
ChunkBiomeData::from_biomes([chunk.x, chunk.z], &biomes)
|
||||
}
|
||||
|
||||
/// The client heightmaps (Heightmap.prime over the column), from the
|
||||
/// cache while the column's sections are unchanged.
|
||||
fn heightmaps(&self, chunk: &Chunk, tables: &crate::save::SaveTables) -> ColumnHeightmaps {
|
||||
|
||||
@@ -84,6 +84,11 @@ impl CommandTree {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether vanilla has a command of this name.
|
||||
pub fn has(&self, name: &str) -> bool {
|
||||
self.root["children"].get(name).is_some()
|
||||
}
|
||||
|
||||
/// The ClientboundCommandsPacket for a player with `level` permissions.
|
||||
pub fn packet(&self, level: u8, dedicated: bool, builtin: &Builtin) -> Commands {
|
||||
self.packet_with(level, dedicated, builtin, &Value::Null)
|
||||
|
||||
@@ -861,6 +861,21 @@ fn login_and_configure(
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
// horizon-clients-only: the brand the client sent ("vanilla" when it sent
|
||||
// none) must be Horizon's.
|
||||
if shared.config.horizon_clients_only && brand != "horizon" {
|
||||
let text = super::HORIZON_CLIENTS_ONLY_REASON;
|
||||
let reason = horizon_world::nbt::Tag::String(horizon_world::nbt::JavaString::from(text));
|
||||
let _ = send(c, cfg::Clientbound::Disconnect(cfg::Disconnect { reason }));
|
||||
if let Some(log) = &shared.config.log {
|
||||
let line = format!(
|
||||
"Disconnecting {name}: its client is \"{brand}\", not Horizon (horizon-clients-only)"
|
||||
);
|
||||
(log.write)("Server thread", "INFO", &line);
|
||||
}
|
||||
lost(shared, &format!("{name} ({})", uuid_string(uuid)), text);
|
||||
return Ok(None);
|
||||
}
|
||||
// A Horizon client hears that this server runs its mods' quiet commands
|
||||
// (horizon_protocol::quiet); a vanilla one is told nothing.
|
||||
if brand == "horizon" {
|
||||
@@ -910,7 +925,7 @@ fn login_and_configure(
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
for packet in sync.registry_packets(known_pack) {
|
||||
for packet in sync.registry_packets(known_pack, &shared.config.levels) {
|
||||
send(c, cfg::Clientbound::RegistryData(packet))?;
|
||||
}
|
||||
send(c, cfg::Clientbound::UpdateTags(sync.tags.clone()))?;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! level events, animations and block containers with their clicks.
|
||||
use super::{
|
||||
hub::Hub,
|
||||
menu::{self, Menu, Rules, Slots, View},
|
||||
menu::{self, Menu, NativeMenuEvent, Rules, Slots, View},
|
||||
};
|
||||
use crate::{containers::BlockEntity, items::ItemStack, world::World};
|
||||
use horizon_protocol::{
|
||||
@@ -245,6 +245,62 @@ impl Hub {
|
||||
self.send_menu_content(i, items);
|
||||
}
|
||||
|
||||
/// Opens a native mod's menu of `rows` rows (1 to 6, a chest's
|
||||
/// `generic_9xN`) showing `items` (padded to the rows' slots), over the
|
||||
/// guest's inventory: no click moves anything, each is a
|
||||
/// [`NativeMenuEvent`]. The window's id, or None for an unknown guest.
|
||||
pub fn open_native_menu(
|
||||
&mut self,
|
||||
conn: u32,
|
||||
rows: u8,
|
||||
title: Tag,
|
||||
mut items: Vec<Option<ItemStack>>,
|
||||
) -> Option<i32> {
|
||||
let rows = rows.clamp(1, 6);
|
||||
let kind = self
|
||||
.sync
|
||||
.builtin
|
||||
.get("minecraft:menu")
|
||||
.and_then(|m| m.id(&format!("minecraft:generic_9x{rows}")))?;
|
||||
let i = self.guests.iter().position(|g| g.conn == conn)?;
|
||||
self.close_container_at(i);
|
||||
let size = usize::from(rows) * 9;
|
||||
items.resize(size, None);
|
||||
let g = &mut self.guests[i];
|
||||
g.native_menu = items.clone();
|
||||
g.next_window = g.next_window % 100 + 1;
|
||||
let window = g.next_window;
|
||||
g.send(&C::OpenScreen(play::OpenScreen {
|
||||
container: window,
|
||||
menu: kind,
|
||||
title,
|
||||
}));
|
||||
g.menu = Some(Menu {
|
||||
window,
|
||||
kind,
|
||||
pos: None,
|
||||
slots: Slots::Native,
|
||||
size,
|
||||
state: 0,
|
||||
sent: Vec::new(),
|
||||
drag: None,
|
||||
sent_data: Vec::new(),
|
||||
});
|
||||
self.send_menu_content(i, items);
|
||||
Some(window)
|
||||
}
|
||||
|
||||
/// Replaces what a guest's open native menu shows; the changed slots go
|
||||
/// out with the tick's container sync.
|
||||
pub fn set_native_menu(&mut self, conn: u32, mut items: Vec<Option<ItemStack>>) {
|
||||
if let Some(g) = self.guest_mut(conn)
|
||||
&& let Some(m) = g.menu.as_ref().filter(|m| m.slots == Slots::Native)
|
||||
{
|
||||
items.resize(m.size, None);
|
||||
g.native_menu = items;
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a trading window for a guest (`Merchant.openTradingScreen`):
|
||||
/// its id, or None when the menu type is unknown.
|
||||
pub fn open_merchant_window(&mut self, conn: u32, title: Tag) -> Option<i32> {
|
||||
@@ -309,6 +365,7 @@ impl Hub {
|
||||
});
|
||||
menu.sent = items;
|
||||
g.send(&packet);
|
||||
g.inventory_told();
|
||||
}
|
||||
|
||||
/// `AbstractContainerMenu.sendAllDataToRemote` for the guest's open
|
||||
@@ -335,6 +392,12 @@ impl Hub {
|
||||
g.send(&C::ContainerClose(play::ContainerClose {
|
||||
container: menu.window,
|
||||
}));
|
||||
if menu.slots == Slots::Native {
|
||||
g.native_menu.clear();
|
||||
let (uuid, window) = (g.uuid, menu.window);
|
||||
self.native_menu_events
|
||||
.push(NativeMenuEvent::Closed { uuid, window });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,7 +416,13 @@ impl Hub {
|
||||
if let Some(g) = self.guest_mut(conn)
|
||||
&& g.menu.as_ref().is_some_and(|m| m.window == window)
|
||||
{
|
||||
g.menu = None;
|
||||
let native = g.menu.take().is_some_and(|m| m.slots == Slots::Native);
|
||||
if native {
|
||||
g.native_menu.clear();
|
||||
let uuid = g.uuid;
|
||||
self.native_menu_events
|
||||
.push(NativeMenuEvent::Closed { uuid, window });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,6 +474,19 @@ impl Hub {
|
||||
self.resend_menu(conn, p.container, world);
|
||||
return Vec::new();
|
||||
}
|
||||
// A native mod's menu: the click is the mod's to answer, and the
|
||||
// whole menu and the cursor go back as they were.
|
||||
if in_menu && self.guests[i].menu.as_ref().map(|m| m.slots) == Some(Slots::Native) {
|
||||
self.native_menu_events.push(NativeMenuEvent::Click {
|
||||
uuid: self.guests[i].uuid,
|
||||
window: p.container,
|
||||
slot: i32::from(p.slot),
|
||||
button: i32::from(p.button),
|
||||
mode: p.input,
|
||||
});
|
||||
self.resend_menu(conn, p.container, world);
|
||||
return Vec::new();
|
||||
}
|
||||
// A menu that is no longer valid takes no clicks.
|
||||
if let Some(m) = self.guests[i].menu.as_ref().filter(|_| in_menu)
|
||||
&& !m.slots.still_valid(world, &self.guests[i], m.pos)
|
||||
@@ -507,6 +589,28 @@ impl Hub {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the client predicted for click `p` is what the server has, for a
|
||||
/// menu of `total` slots read through `view`.
|
||||
pub fn click_agreed(
|
||||
&mut self,
|
||||
i: usize,
|
||||
p: &play::ContainerClick,
|
||||
total: usize,
|
||||
view: &dyn Fn(usize) -> Option<ItemStack>,
|
||||
changed: &[usize],
|
||||
) -> bool {
|
||||
let carried = self.guests[i].carried.clone();
|
||||
if !self.hashed_matches(&p.carried, &carried) {
|
||||
return false;
|
||||
}
|
||||
let claimed: HashMap<usize, &Option<Hashed>> =
|
||||
p.changed.iter().map(|(s, h)| (*s as usize, h)).collect();
|
||||
(0..total).all(|slot| match claimed.get(&slot) {
|
||||
Some(c) => self.hashed_matches(c, &view(slot)),
|
||||
None => !changed.contains(&slot),
|
||||
})
|
||||
}
|
||||
|
||||
/// After a click: tell the client about every slot it got wrong, or that
|
||||
/// it did not claim, and the carried stack.
|
||||
fn answer_click(
|
||||
@@ -517,6 +621,29 @@ impl Hub {
|
||||
changed: &[usize],
|
||||
) {
|
||||
let in_menu = p.container != 0;
|
||||
// handleContainerClick: a click made without the latest state gets the
|
||||
// whole menu (`broadcastFullState`). Answering it slot by slot can leave
|
||||
// the client with a correction of a click it has already gone past.
|
||||
let g = &self.guests[i];
|
||||
let open = g
|
||||
.menu
|
||||
.as_ref()
|
||||
.filter(|_| in_menu)
|
||||
.map(|m| (m.size, m.state));
|
||||
let stale = match open {
|
||||
Some((_, state)) => p.state != state,
|
||||
None => !in_menu && p.state != g.menu_state,
|
||||
};
|
||||
if stale {
|
||||
match open {
|
||||
Some((size, _)) => {
|
||||
let items = (0..size).map(|s| self.view_slot(i, world, s)).collect();
|
||||
self.send_menu_content(i, items);
|
||||
}
|
||||
None => self.send_inventory(i),
|
||||
}
|
||||
return;
|
||||
}
|
||||
let claimed: HashMap<usize, Option<Hashed>> = p
|
||||
.changed
|
||||
.iter()
|
||||
@@ -566,6 +693,13 @@ impl Hub {
|
||||
// What the client now believes equals the server's state.
|
||||
let items: Vec<_> = (0..total).map(|s| self.view_slot(i, world, s)).collect();
|
||||
if in_menu {
|
||||
// Nor is the inventory menu's sync to send what this click settled:
|
||||
// ServerPlayer.tick broadcasts only the open menu.
|
||||
let own = open.map_or(0, |(size, _)| size);
|
||||
let settled = claimed.keys().copied().chain(changed.iter().copied());
|
||||
for slot in settled.filter(|&s| s >= own && s < own + 36) {
|
||||
self.guests[i].menu_sent[slot - own + 9] = items[slot].clone();
|
||||
}
|
||||
if let Some(m) = self.guests[i].menu.as_mut() {
|
||||
m.sent = items;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! network order, with their data for clients that do not know the vanilla
|
||||
//! pack) and the tags of every registry, loaded from the prepared resources
|
||||
//! (`data/minecraft/...`) and the registries report.
|
||||
use crate::chunk::LevelShapes;
|
||||
use horizon_protocol::{
|
||||
packets::configuration::{RegistryData, RegistryEntry, RegistryTags, TagEntry, UpdateTags},
|
||||
registry::{Builtin, Dynamic},
|
||||
@@ -184,7 +185,10 @@ impl SyncData {
|
||||
|
||||
/// ClientboundRegistryDataPacket per registry. With `known_pack` the
|
||||
/// client rebuilds the entries from its own data, so none is sent.
|
||||
pub fn registry_packets(&self, known_pack: bool) -> Vec<RegistryData> {
|
||||
/// The registries for a client, with the data of a dimension type the
|
||||
/// world's data packs changed (`levels`) even when it knows the vanilla
|
||||
/// pack.
|
||||
pub fn registry_packets(&self, known_pack: bool, levels: &LevelShapes) -> Vec<RegistryData> {
|
||||
self.registries
|
||||
.iter()
|
||||
.map(|(key, e)| RegistryData {
|
||||
@@ -193,13 +197,18 @@ impl SyncData {
|
||||
.names
|
||||
.iter()
|
||||
.zip(&e.json)
|
||||
.map(|(name, json)| RegistryEntry {
|
||||
id: name.clone(),
|
||||
data: if known_pack {
|
||||
None
|
||||
} else {
|
||||
Some(json_to_nbt(json))
|
||||
},
|
||||
.map(|(name, json)| {
|
||||
let changed = (key == "minecraft:dimension_type")
|
||||
.then(|| changed_dimension_type(name, json, levels))
|
||||
.flatten();
|
||||
RegistryEntry {
|
||||
id: name.clone(),
|
||||
data: match changed {
|
||||
Some(json) => Some(json_to_nbt(&json)),
|
||||
None if known_pack => None,
|
||||
None => Some(json_to_nbt(json)),
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
@@ -300,6 +309,27 @@ impl SyncData {
|
||||
}
|
||||
}
|
||||
|
||||
/// The vanilla dimension type `name` (its JSON `json`) with the shape the
|
||||
/// world gives the level of that name, if its data packs changed it.
|
||||
fn changed_dimension_type(name: &str, json: &Value, levels: &LevelShapes) -> Option<Value> {
|
||||
if !matches!(
|
||||
name,
|
||||
"minecraft:overworld" | "minecraft:the_nether" | "minecraft:the_end"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let shape = levels.get(name);
|
||||
if shape == LevelShapes::VANILLA.get(name) {
|
||||
return None;
|
||||
}
|
||||
let mut json = json.clone();
|
||||
json["min_y"] = shape.min_y.into();
|
||||
json["height"] = shape.height.into();
|
||||
json["logical_height"] = shape.logical_height.into();
|
||||
json["has_skylight"] = shape.sky_light.into();
|
||||
Some(json)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -312,6 +342,33 @@ mod tests {
|
||||
assert!(matches!(t.get("c"), Some(Tag::String(_))));
|
||||
assert!(matches!(t.get("b"), Some(Tag::List(l)) if l.len() == 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_changed_dimension_type_is_sent_with_its_shape() {
|
||||
let vanilla: Value =
|
||||
serde_json::from_str(r#"{"min_y":-64,"height":384,"logical_height":384,"has_skylight":true,"ultrawarm":false}"#)
|
||||
.unwrap();
|
||||
let mut levels = LevelShapes::VANILLA;
|
||||
assert_eq!(
|
||||
changed_dimension_type("minecraft:overworld", &vanilla, &levels),
|
||||
None
|
||||
);
|
||||
levels.overworld = crate::chunk::LevelShape::new(-64, 2096, 2096, true).unwrap();
|
||||
let sent = changed_dimension_type("minecraft:overworld", &vanilla, &levels).unwrap();
|
||||
assert_eq!(
|
||||
(sent["min_y"].as_i64(), sent["height"].as_i64()),
|
||||
(Some(-64), Some(2096))
|
||||
);
|
||||
assert_eq!(sent["ultrawarm"], false, "the rest of the type is kept");
|
||||
assert_eq!(
|
||||
changed_dimension_type("minecraft:the_nether", &vanilla, &levels),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
changed_dimension_type("minecraft:overworld_caves", &vanilla, &levels),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -169,6 +169,9 @@ pub struct Guest {
|
||||
/// What the player's file says of the body (`Entity.load`: the fire
|
||||
/// and air ticks and the fall distance), for the first body it gets.
|
||||
pub loaded_body: Option<SavedBody>,
|
||||
/// The vehicle the player's file names (`ServerPlayer.saveParentVehicle`'s
|
||||
/// "RootVehicle"), for the ride to come back on the next login.
|
||||
pub saved_vehicle: Option<[i32; 4]>,
|
||||
/// The hurt and attack logs of the body it had before it respawned,
|
||||
/// until the new body takes them over (the control socket's `hurtlog`
|
||||
/// reads across a death).
|
||||
@@ -188,6 +191,8 @@ pub struct Guest {
|
||||
pub craft_result: Option<ItemStack>,
|
||||
/// The ender chest inventory (`PlayerEnderChestContainer`, 27 slots).
|
||||
pub ender_items: Vec<Option<ItemStack>>,
|
||||
/// What a native mod's menu shows (`Slots::Native`), while one is open.
|
||||
pub native_menu: Vec<Option<ItemStack>>,
|
||||
pub health: f32,
|
||||
/// The saved `AbsorptionAmount`, until the player's first body on the
|
||||
/// server takes it (the body holds the live amount).
|
||||
@@ -334,6 +339,7 @@ impl Guest {
|
||||
tracker: Tracker::default(),
|
||||
motion: None,
|
||||
loaded_body: None,
|
||||
saved_vehicle: None,
|
||||
logs_kept: None,
|
||||
inventory: vec![None; 36],
|
||||
armor: [None, None, None, None],
|
||||
@@ -343,6 +349,7 @@ impl Guest {
|
||||
craft_grid: Default::default(),
|
||||
craft_result: None,
|
||||
ender_items: vec![None; 27],
|
||||
native_menu: Vec::new(),
|
||||
health: 20.0,
|
||||
saved_absorption: None,
|
||||
food: 20,
|
||||
@@ -490,13 +497,19 @@ impl Guest {
|
||||
/// `Player.isWithinBlockInteractionRange(pos, 1.0)`, which the server
|
||||
/// checks a block action against (ServerPlayerGameMode.handleBlockBreakAction,
|
||||
/// ServerGamePacketListenerImpl.handleUseItemOn): from the eyes of the
|
||||
/// guest's body in its pose (crouching, swimming, gliding).
|
||||
pub fn may_reach_block(&self, pos: [i32; 3]) -> bool {
|
||||
/// guest's body in its pose (crouching, swimming, gliding), with its
|
||||
/// `block_interaction_range` (4.5, 5 in creative, more with `/attribute`).
|
||||
pub fn may_reach_block(&mut self, pos: [i32; 3]) -> bool {
|
||||
let eye = self
|
||||
.motion
|
||||
.as_ref()
|
||||
.map_or_else(|| self.eye_height(), |m| f64::from(m.dimensions.eye_height));
|
||||
let range = if self.creative() { 5.0 } else { 4.5 };
|
||||
let otherwise = if self.creative() { 5.0 } else { 4.5 };
|
||||
let range = self
|
||||
.motion
|
||||
.as_mut()
|
||||
.and_then(|m| m.attribute("minecraft:block_interaction_range").ok())
|
||||
.unwrap_or(otherwise);
|
||||
within_block_range(
|
||||
[self.position[0], self.position[1] + eye, self.position[2]],
|
||||
range + 1.0,
|
||||
@@ -517,6 +530,14 @@ impl Guest {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The open menu's packets told the client every inventory slot: the
|
||||
/// inventory menu's own sync has nothing to add. Sent again, it could land
|
||||
/// on clicks the client has predicted since.
|
||||
pub fn inventory_told(&mut self) {
|
||||
for slot in 9..45 {
|
||||
self.menu_sent[slot] = self.menu_slot(slot);
|
||||
}
|
||||
}
|
||||
pub fn set_menu_slot(&mut self, slot: usize, item: Option<ItemStack>) {
|
||||
match slot {
|
||||
0 => self.craft_result = item,
|
||||
|
||||
@@ -64,6 +64,8 @@ pub struct HubConfig {
|
||||
/// the owner's level from `ops`, the others' from Allow Commands and
|
||||
/// the guests' command access, and the integrated-only commands.
|
||||
pub integrated: bool,
|
||||
/// The shapes of the world's levels (their dimension types).
|
||||
pub levels: crate::chunk::LevelShapes,
|
||||
}
|
||||
|
||||
/// The host player as the other players see them.
|
||||
@@ -168,6 +170,8 @@ pub enum HostEvent {
|
||||
}
|
||||
|
||||
pub struct Hub {
|
||||
/// Clicks on and closings of native mods' menus, for the game.
|
||||
pub native_menu_events: Vec<super::menu::NativeMenuEvent>,
|
||||
pub lan: Lan,
|
||||
pub config: HubConfig,
|
||||
pub encoder: ChunkEncoder,
|
||||
@@ -361,8 +365,7 @@ impl Hub {
|
||||
.unwrap_or(0);
|
||||
let clock_ids = (0..sync.dynamic.len("minecraft:world_clock") as i32).collect();
|
||||
let mut encoder = encoder;
|
||||
let d = horizon_protocol::registry::DimensionType::vanilla(&config.dimension);
|
||||
encoder.set_dimension(level_shape(&d));
|
||||
encoder.set_dimension(config.levels.get(&config.dimension));
|
||||
Self {
|
||||
lan,
|
||||
config,
|
||||
@@ -394,6 +397,7 @@ impl Hub {
|
||||
entities: Default::default(),
|
||||
entity_out: Vec::new(),
|
||||
mod_commands: (serde_json::Value::Null, None),
|
||||
native_menu_events: Vec::new(),
|
||||
teams: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -516,6 +520,18 @@ impl Hub {
|
||||
g.sender.send(body.clone());
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.sendSystemMessage` to the guest with `uuid`, in any
|
||||
/// dimension.
|
||||
pub fn system_message_to_player(&self, uuid: u128, content: Tag) {
|
||||
let body = C::SystemChat(play::SystemChat {
|
||||
content,
|
||||
overlay: false,
|
||||
})
|
||||
.encode();
|
||||
for g in self.guests.iter().filter(|g| g.placed && g.uuid == uuid) {
|
||||
g.sender.send(body.clone());
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.sendOverlayMessage` to every guest (the action bar).
|
||||
pub fn overlay_message(&self, content: Tag) {
|
||||
self.broadcast(&C::SystemChat(play::SystemChat {
|
||||
@@ -941,6 +957,11 @@ impl Hub {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether vanilla's command tree has a command of this name.
|
||||
pub fn vanilla_command(&self, name: &str) -> bool {
|
||||
self.sync.commands.as_ref().is_some_and(|t| t.has(name))
|
||||
}
|
||||
|
||||
/// The key [`Hub::set_mod_commands`] last had: the game compares it
|
||||
/// with its server mods' each tick.
|
||||
pub fn mod_commands_key(&self) -> Option<u64> {
|
||||
@@ -1323,6 +1344,17 @@ impl Hub {
|
||||
|
||||
fn remove_guest(&mut self, i: usize, reason: &str) -> Guest {
|
||||
let mut g = self.guests.remove(i);
|
||||
if let Some(m) = g
|
||||
.menu
|
||||
.as_ref()
|
||||
.filter(|m| m.slots == super::menu::Slots::Native)
|
||||
{
|
||||
self.native_menu_events
|
||||
.push(super::menu::NativeMenuEvent::Closed {
|
||||
uuid: g.uuid,
|
||||
window: m.window,
|
||||
});
|
||||
}
|
||||
if g.left_reason.is_empty() {
|
||||
g.left_reason = reason.to_owned();
|
||||
}
|
||||
@@ -1565,6 +1597,26 @@ impl Hub {
|
||||
(!batch.is_empty()).then_some(batch)
|
||||
}
|
||||
|
||||
/// `ChunkMap.resendBiomesForChunks`: each player in the level is sent,
|
||||
/// in one packet, the biomes of those of `columns` it has been sent
|
||||
/// (`getPlayers(pos, false)`), in the order given.
|
||||
pub fn resend_biomes(&self, world: &World, columns: &[(i32, i32)]) {
|
||||
let data: Vec<_> = columns
|
||||
.iter()
|
||||
.filter_map(|key| Some((*key, self.encoder.biome_data(world.chunks.get(key)?))))
|
||||
.collect();
|
||||
for g in self.guests.iter().filter(|g| self.here(g)) {
|
||||
let chunks: Vec<_> = data
|
||||
.iter()
|
||||
.filter(|(key, _)| g.sent_chunks.contains(key))
|
||||
.map(|(_, d)| d.clone())
|
||||
.collect();
|
||||
if !chunks.is_empty() {
|
||||
g.send(&C::ChunksBiomes(play::ChunksBiomes { chunks }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChunkHolder.broadcastChanges`' light part: the light sections that
|
||||
/// changed in a column go to the players for whom it is on the border of
|
||||
/// what they track (`ChunkMap.isChunkOnTrackedBorder`); the others light
|
||||
@@ -2784,8 +2836,8 @@ impl Hub {
|
||||
.sync
|
||||
.id("minecraft:dimension_type", dimension)
|
||||
.unwrap_or(0);
|
||||
let d = horizon_protocol::registry::DimensionType::vanilla(dimension);
|
||||
self.encoder.set_dimension(level_shape(&d));
|
||||
self.encoder
|
||||
.set_dimension(self.config.levels.get(dimension));
|
||||
}
|
||||
|
||||
/// ServerPlayer.teleport(TeleportTransition) into the level the hub now
|
||||
@@ -2975,16 +3027,6 @@ fn shared_flags(sneaking: bool, sprinting: bool, body: &BodyFlags) -> i8 {
|
||||
shared
|
||||
}
|
||||
|
||||
/// The shape a level of the dimension type `d` has.
|
||||
fn level_shape(d: &horizon_protocol::registry::DimensionType) -> crate::chunk::LevelShape {
|
||||
crate::chunk::LevelShape {
|
||||
sky_light: d.has_skylight,
|
||||
min_y: d.min_y,
|
||||
height: d.height,
|
||||
logical_height: d.height,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -37,6 +37,24 @@ pub struct Menu {
|
||||
pub sent_data: Vec<i16>,
|
||||
}
|
||||
|
||||
/// What a guest did with a native mod's menu (`Slots::Native`), for the game
|
||||
/// to tell the mod once the hub is back in its hands.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum NativeMenuEvent {
|
||||
/// A click (`ServerboundContainerClickPacket`): the menu's slot (its own
|
||||
/// first, then the player's 36; -999 outside), the button and the mode.
|
||||
Click {
|
||||
uuid: u128,
|
||||
window: i32,
|
||||
slot: i32,
|
||||
button: i32,
|
||||
mode: i32,
|
||||
},
|
||||
/// The menu closed: by the player, by the server, another opened, or
|
||||
/// the player left.
|
||||
Closed { uuid: u128, window: i32 },
|
||||
}
|
||||
|
||||
/// The y of the key a chest minecart's or chest boat's items are kept under
|
||||
/// while a menu shows them.
|
||||
pub const VEHICLE_KEY_Y: i32 = -100_000;
|
||||
@@ -51,6 +69,9 @@ pub enum Slots {
|
||||
Double((i32, i32, i32), (i32, i32, i32)),
|
||||
/// The player's own ender chest inventory (`PlayerEnderChestContainer`).
|
||||
Ender,
|
||||
/// A native mod's menu (`Guest::native_menu`): items to look at, which no
|
||||
/// click moves; the clicks go to the mod.
|
||||
Native,
|
||||
}
|
||||
|
||||
impl Slots {
|
||||
@@ -58,7 +79,7 @@ impl Slots {
|
||||
match self {
|
||||
Slots::Block(k) => vec![k],
|
||||
Slots::Double(a, b) => vec![a, b],
|
||||
Slots::Ender => Vec::new(),
|
||||
Slots::Ender | Slots::Native => Vec::new(),
|
||||
}
|
||||
}
|
||||
/// Whether the block entities behind the slots are still there
|
||||
@@ -74,6 +95,7 @@ impl Slots {
|
||||
pub fn still_valid(self, world: &World, guest: &Guest, at: Option<(i32, i32, i32)>) -> bool {
|
||||
let blocks = match self {
|
||||
Slots::Ender => at.into_iter().collect(),
|
||||
Slots::Native => Vec::new(),
|
||||
_ => self.halves(),
|
||||
};
|
||||
// A vehicle's container is kept under a key, not a block: the game
|
||||
@@ -88,6 +110,9 @@ impl Slots {
|
||||
if self == Slots::Ender {
|
||||
return guest.ender_items.get(n).cloned().flatten();
|
||||
}
|
||||
if self == Slots::Native {
|
||||
return guest.native_menu.get(n).cloned().flatten();
|
||||
}
|
||||
for k in self.halves() {
|
||||
let slots = world.block_entities.get(&k)?.slots();
|
||||
if n < slots.len() {
|
||||
@@ -102,6 +127,9 @@ impl Slots {
|
||||
if self == Slots::Ender {
|
||||
return Some(guest.ender_items.clone());
|
||||
}
|
||||
if self == Slots::Native {
|
||||
return Some(guest.native_menu.clone());
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
for k in self.halves() {
|
||||
items.extend_from_slice(world.block_entities.get(&k)?.slots());
|
||||
@@ -114,6 +142,10 @@ impl Slots {
|
||||
guest.ender_items = items;
|
||||
return;
|
||||
}
|
||||
if self == Slots::Native {
|
||||
// Only the mod changes what it shows.
|
||||
return;
|
||||
}
|
||||
let mut rest = items.into_iter();
|
||||
for k in self.halves() {
|
||||
if let Some(e) = world.block_entities.get_mut(&k) {
|
||||
|
||||
Loaded 100 of 234 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user