mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
362
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
535132df77 | ||
|
|
3a379a3a25 | ||
|
|
295b42c813 | ||
|
|
3132f00b92 | ||
|
|
ab099b2701 | ||
|
|
c75c2d6c98 | ||
|
|
00ac19c727 | ||
|
|
4a8abbc03b | ||
|
|
bf0b572cd6 | ||
|
|
e76557a9b4 | ||
|
|
7de7148ca3 | ||
|
|
3c3f79c399 | ||
|
|
02d7b51fbe | ||
|
|
f25904a14c | ||
|
|
7ce0ceedbe | ||
|
|
7e2388a7ba | ||
|
|
580238a97b | ||
|
|
a0b56f9f7f | ||
|
|
ae86b4c1d1 | ||
|
|
44bf73f545 | ||
|
|
15e250796d | ||
|
|
4281a04419 | ||
|
|
359c3ed4b0 | ||
|
|
6291dce546 | ||
|
|
07922f7d61 | ||
|
|
d5632dff8f | ||
|
|
bf24926abc | ||
|
|
d788f6c879 | ||
|
|
e031729321 | ||
|
|
0c7291a1ef | ||
|
|
3c2110f5f7 | ||
|
|
365de3179b | ||
|
|
ac0f6ecf7c | ||
|
|
06c5b0793c | ||
|
|
dc13bdc1b7 | ||
|
|
7aa9bfd8f8 | ||
|
|
0269f59a5c | ||
|
|
583e6a08b9 | ||
|
|
9d51f087e6 | ||
|
|
cc6abf23d9 | ||
|
|
a338cb6a8b | ||
|
|
816fded3f9 | ||
|
|
c029c1d75b | ||
|
|
19007dd7c5 | ||
|
|
f079421319 | ||
|
|
08aae6ff95 | ||
|
|
f78c62dc79 | ||
|
|
f73b366237 | ||
|
|
fa7f79836f | ||
|
|
01421a2ebe | ||
|
|
43de279bc7 | ||
|
|
e891eb498b | ||
|
|
5a83d2a954 | ||
|
|
308c4ee5b7 | ||
|
|
82c9fa195c | ||
|
|
7a44918872 | ||
|
|
7018d4d830 | ||
|
|
c8cb00eb5f | ||
|
|
4a65763916 | ||
|
|
a1d3693ff5 | ||
|
|
03d8ffe2c6 | ||
|
|
6cb5e4954e | ||
|
|
2ba33bd146 | ||
|
|
4f6e4e72b1 | ||
|
|
91e332a5eb | ||
|
|
a8fe843daa | ||
|
|
0ee20da35c | ||
|
|
37d31da543 | ||
|
|
c5165a98cf | ||
|
|
b7749dd0d2 | ||
|
|
ddfa71a46f | ||
|
|
e1f7ad93ee | ||
|
|
7731b4274a | ||
|
|
7df621fdb5 | ||
|
|
18b544770f | ||
|
|
21fd3a6b06 | ||
|
|
3eb3d1fc5f | ||
|
|
3052cc41db | ||
|
|
43f5388038 | ||
|
|
620dfc6e0d | ||
|
|
08492a3007 | ||
|
|
3d0ef4da75 | ||
|
|
dfb28e42d0 | ||
|
|
a2f62b1545 | ||
|
|
cb20bb508b | ||
|
|
4168590221 | ||
|
|
2a7b79dfb6 | ||
|
|
f1f587e7c2 | ||
|
|
51447edda4 | ||
|
|
4018fa9559 | ||
|
|
95e7cd69dd | ||
|
|
5a6ae76ec3 | ||
|
|
4a523b5ce8 | ||
|
|
eae5b13fc6 | ||
|
|
1ad1e957a3 | ||
|
|
b5f1e65162 | ||
|
|
39910efabf | ||
|
|
b0103bd62f | ||
|
|
44eb7008f1 | ||
|
|
179e410923 | ||
|
|
b0a6e63ce7 | ||
|
|
4517d75a65 | ||
|
|
4b042eae28 | ||
|
|
30c6a4ac7b | ||
|
|
ce73764585 | ||
|
|
59d0b45349 | ||
|
|
7373ee8da5 | ||
|
|
a6af0d647f | ||
|
|
8b87d128e6 | ||
|
|
8a2c7eee4a | ||
|
|
8a572c9239 | ||
|
|
6ea9f9580c | ||
|
|
e9bcd10869 | ||
|
|
3bd504c6c4 | ||
|
|
a0343bceab | ||
|
|
25ad1db6d3 | ||
|
|
65cc4c6f6b | ||
|
|
8892f0fabf | ||
|
|
d6fc0f6e43 | ||
|
|
517cbe76bd | ||
|
|
236371d06d | ||
|
|
06c2371360 | ||
|
|
b3a1f66c35 | ||
|
|
cca3b735cc | ||
|
|
e338690bf9 | ||
|
|
0ab24d0805 | ||
|
|
dfa907d28e | ||
|
|
d9dc1c866b | ||
|
|
ec8d5f1e06 | ||
|
|
a9dcea59e7 | ||
|
|
4ba8105ecc | ||
|
|
226b42a046 | ||
|
|
12381ec668 | ||
|
|
8376200697 | ||
|
|
2dd5792ae3 | ||
|
|
94d063684c | ||
|
|
9d8ecc533d | ||
|
|
e2428e8735 | ||
|
|
bad8c9eba4 | ||
|
|
70cba407b5 | ||
|
|
291239c90c | ||
|
|
dfd0aa0266 | ||
|
|
f6e156a4ef | ||
|
|
b8b353c92e | ||
|
|
e28bcba3d3 | ||
|
|
e91678b994 | ||
|
|
4482ff63f3 | ||
|
|
a9e5214ae4 | ||
|
|
9e359f230d | ||
|
|
2a7420651e | ||
|
|
c3a12bf792 | ||
|
|
d601715126 | ||
|
|
9cb2779608 | ||
|
|
783577d2de | ||
|
|
e70ead5bfe | ||
|
|
8458872d07 | ||
|
|
aa6fdac501 | ||
|
|
cd520ce61d | ||
|
|
cac63dedca | ||
|
|
9ae37fcb50 | ||
|
|
b39c4b8dd4 | ||
|
|
3c257bb3e6 | ||
|
|
2ef155348f | ||
|
|
8866e618b5 | ||
|
|
7732ce986f | ||
|
|
f12e457a25 | ||
|
|
e5c3d7cff7 | ||
|
|
ddaa514f3a | ||
|
|
55cc84718a | ||
|
|
07869cd3ea | ||
|
|
fde2dfffef | ||
|
|
155f52af58 | ||
|
|
fd6fdcbf75 | ||
|
|
0d5ed8122e | ||
|
|
fd1d6a2762 | ||
|
|
4fe8f71530 | ||
|
|
ea0cc65aea | ||
|
|
a0ef14f1e2 | ||
|
|
4b761abcb2 | ||
|
|
0a75323caa | ||
|
|
4f5ed6a19b | ||
|
|
e8891849a4 | ||
|
|
d12dc5d1e1 | ||
|
|
c70e2fae74 | ||
|
|
64f9ff4529 | ||
|
|
f5b7bdba1d | ||
|
|
ee0af20e16 | ||
|
|
90f2755245 | ||
|
|
a76456b036 | ||
|
|
6087b01f66 | ||
|
|
1d0d827f3a | ||
|
|
844441874a | ||
|
|
b6dd6c35ae | ||
|
|
1af4d0e432 | ||
|
|
68c9597237 | ||
|
|
bedba4a3d9 | ||
|
|
9ab0e2b6a8 | ||
|
|
498de72da2 | ||
|
|
0db65d7a61 | ||
|
|
7250944a7c | ||
|
|
0104eeaf33 | ||
|
|
04672c9613 | ||
|
|
fa4f38f824 | ||
|
|
9cebcc8d7f | ||
|
|
ac33fb74ec | ||
|
|
729a224eef | ||
|
|
14dac749e3 | ||
|
|
10d0132a53 | ||
|
|
209fa169cd | ||
|
|
17fa6e826c | ||
|
|
66bec37b55 | ||
|
|
0fe747ad51 | ||
|
|
e38e25c1a8 | ||
|
|
5be7c02d0c | ||
|
|
e11471cda0 | ||
|
|
731a35612a | ||
|
|
22054eee90 | ||
|
|
b1450f1ef2 | ||
|
|
5ac1c82eb9 | ||
|
|
abdaf71ecd | ||
|
|
e184ab7a42 | ||
|
|
6384234e37 | ||
|
|
3caf28c4d6 | ||
|
|
52f0e38d41 | ||
|
|
4c9075a072 | ||
|
|
7ae20a5b29 | ||
|
|
67b6e75c0b | ||
|
|
64cd55cf74 | ||
|
|
8909e0510c | ||
|
|
5b5ebfcce3 | ||
|
|
17f8419acd | ||
|
|
1b1571ed6a | ||
|
|
a891037c9f | ||
|
|
ef0714a99a | ||
|
|
66607c9eab | ||
|
|
1a80673d50 | ||
|
|
266c7a2cd9 | ||
|
|
faeff96cf9 | ||
|
|
43e407562f | ||
|
|
bafd010865 | ||
|
|
a55c825467 | ||
|
|
6c716f457b | ||
|
|
c5534f0d7b | ||
|
|
024415e09d | ||
|
|
d3a662b760 | ||
|
|
3f0e70882c | ||
|
|
54ae097faa | ||
|
|
1b0937abff | ||
|
|
1fe0f5fcb9 | ||
|
|
34f57dbcac | ||
|
|
83b544f04f | ||
|
|
b90f9ec1ef | ||
|
|
c4d0cb1ec0 | ||
|
|
1258c2473b | ||
|
|
3bb6e40304 | ||
|
|
eab8d0f911 | ||
|
|
d7fece5825 | ||
|
|
bab4251715 | ||
|
|
9eb913affe | ||
|
|
c808fae288 | ||
|
|
951b88a3c2 | ||
|
|
84f8205bdd | ||
|
|
65c60ab996 | ||
|
|
56bf45cee5 | ||
|
|
abb4e7b5cd | ||
|
|
0e96fb6d2a | ||
|
|
f4a235e027 | ||
|
|
dca6ebba0e | ||
|
|
3755bb7d77 | ||
|
|
94748162bc | ||
|
|
06b046d8fc | ||
|
|
6af81b600f | ||
|
|
c85f6f2e39 | ||
|
|
34dabe4afd | ||
|
|
36c06d6133 | ||
|
|
ad6f2320b0 | ||
|
|
4201fd23c0 | ||
|
|
46b71954e1 | ||
|
|
b92a53e47c | ||
|
|
ffc343aadd | ||
|
|
4cf9ffd847 | ||
|
|
e23fcb1040 | ||
|
|
27b81d9a8f | ||
|
|
7b3595b8f2 | ||
|
|
c417556834 | ||
|
|
bf62ccdabc | ||
|
|
41d8b9e48a | ||
|
|
0e658e3e62 | ||
|
|
041ecb7b7a | ||
|
|
41ca489d4e | ||
|
|
82d2b61a4a | ||
|
|
f9b8b40a7d | ||
|
|
08cd05ed6e | ||
|
|
f312aae7db | ||
|
|
317f30427e | ||
|
|
91f1f839ab | ||
|
|
591b7b74da | ||
|
|
d78238f765 | ||
|
|
303281ed09 | ||
|
|
1c6c6c0b97 | ||
|
|
18f8a6868b | ||
|
|
43d1f4b801 | ||
|
|
a1761dcb65 | ||
|
|
cf6b91a710 | ||
|
|
a08f3eb2fa | ||
|
|
96b538d48f | ||
|
|
29eae3a1af | ||
|
|
4efa49ae82 | ||
|
|
85db9a23a9 | ||
|
|
00cb813dc8 | ||
|
|
9e848670db | ||
|
|
dbd24e2e16 | ||
|
|
5a70691d63 | ||
|
|
e7e6e5bc07 | ||
|
|
4ebfaef0d3 | ||
|
|
7112d0cd64 | ||
|
|
818b377c02 | ||
|
|
2b2aecd975 | ||
|
|
766bfbcdfa | ||
|
|
61df807b33 | ||
|
|
ec81bea5bf | ||
|
|
1eb7a04a4e | ||
|
|
55a066e88a | ||
|
|
8c2ef15171 | ||
|
|
705e3d10cf | ||
|
|
edc4a58397 | ||
|
|
d75f767925 | ||
|
|
76d748caf8 | ||
|
|
a4764f5b0d | ||
|
|
d62a1e5d20 | ||
|
|
1862395e4b | ||
|
|
2c8b933be4 | ||
|
|
077da2e06b | ||
|
|
5589edb118 | ||
|
|
f661443fa9 | ||
|
|
442d52a038 | ||
|
|
004ad993d9 | ||
|
|
42a57b8286 | ||
|
|
7a3a19697a | ||
|
|
de141bb7cb | ||
|
|
8526305faa | ||
|
|
5179f97582 | ||
|
|
b37528f5d6 | ||
|
|
2a98f584df | ||
|
|
e50eb7c71f | ||
|
|
cfd2dc8392 | ||
|
|
c987778e97 | ||
|
|
7ac90ff0b8 | ||
|
|
9841ce7824 | ||
|
|
83a6f5c7b9 | ||
|
|
9e71837df9 | ||
|
|
5eef2bea0c | ||
|
|
02897ac731 | ||
|
|
0a116d9523 | ||
|
|
ebf3c1567c | ||
|
|
2f6f0e3e0f | ||
|
|
d338bcd070 | ||
|
|
f98528bbfc | ||
|
|
408a75aeb3 | ||
|
|
121731f261 | ||
|
|
574d65a03c | ||
|
|
c5bb51bb05 |
No files matched your search
@@ -10,7 +10,8 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
`tools/check_public_tree.py` rejects them; `git config core.hooksPath .githooks` runs it on commit.
|
||||
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
|
||||
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
|
||||
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`. Never write to
|
||||
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
|
||||
launcher, `HORIZON_LAUNCHER_TRASH` and `HORIZON_LAUNCHER_CLIPBOARD` pointing into the scratch home. Never write to
|
||||
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
|
||||
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
|
||||
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
|
||||
@@ -34,7 +35,9 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
|
||||
| Crate | Owns |
|
||||
|---|---|
|
||||
| `horizon-client` | The game: a lib (`src/lib.rs`, ~190 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
|
||||
| `horizon-client` | The game: a lib (`src/lib.rs`, ~180 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
|
||||
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
|
||||
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
|
||||
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
|
||||
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
|
||||
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
|
||||
@@ -118,16 +121,16 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`, `items.rs`, `item_data.rs` |
|
||||
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
|
||||
| Commands | parse: L: `command/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`, `nbt_path.rs`; G: `functions.rs`, `command_blocks.rs` |
|
||||
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
|
||||
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
|
||||
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
|
||||
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
|
||||
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `block_entity_nbt.rs`, `options_file.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; L: `keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `osk.rs`, `clipboard.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `lang.rs`; crate `horizon-assets`; G: `packs.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
|
||||
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
|
||||
|
||||
Generated
+19
@@ -1372,6 +1372,8 @@ dependencies = [
|
||||
"glam",
|
||||
"horizon-assets",
|
||||
"horizon-bench",
|
||||
"horizon-client-core",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
"horizon-dualsense",
|
||||
"horizon-friends",
|
||||
@@ -1403,6 +1405,23 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-client-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-world",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-command"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-client-core",
|
||||
"horizon-protocol",
|
||||
"horizon-world",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-core"
|
||||
version = "0.1.0"
|
||||
|
||||
+40
-1
@@ -1,6 +1,45 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-hdr", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "crates/horizon-mod-sdk/examples/beacons", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask", "mods/official/recipes"]
|
||||
members = [
|
||||
"crates/horizon-core",
|
||||
"crates/horizon-dualsense",
|
||||
"crates/horizon-threads",
|
||||
"crates/horizon-hdr",
|
||||
"crates/horizon-world",
|
||||
"crates/horizon-client",
|
||||
"crates/horizon-client-core",
|
||||
"crates/horizon-command",
|
||||
"crates/horizon-server-files",
|
||||
"crates/horizon-server",
|
||||
"crates/horizon-bots",
|
||||
"crates/horizon-assets",
|
||||
"crates/horizon-protocol",
|
||||
"crates/horizon-theme",
|
||||
"crates/horizon-prepare",
|
||||
"crates/horizon-telemetry",
|
||||
"crates/horizon-bench",
|
||||
"crates/horizon-friends",
|
||||
"crates/horizon-relay",
|
||||
"crates/horizon-mod-sdk",
|
||||
"crates/horizon-mods",
|
||||
"crates/horizon-mods/tests/guest",
|
||||
"crates/horizon-mods/tests/ui_guest",
|
||||
"crates/cargo-horizon-mod",
|
||||
"crates/horizon-port-pack",
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"crates/horizon-mod-sdk/examples/coords-hud",
|
||||
"crates/horizon-mod-sdk/examples/predator-missile",
|
||||
"crates/horizon-mod-sdk/examples/welcome",
|
||||
"crates/horizon-mod-sdk/examples/beacons",
|
||||
"mods/official/fps-overlay",
|
||||
"mods/official/keystrokes",
|
||||
"mods/official/minimap",
|
||||
"mods/official/zoom",
|
||||
"xtask",
|
||||
"mods/official/recipes",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -200,7 +200,7 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
return Err(format!("it didn't start: {reason}"));
|
||||
}
|
||||
let client: Vec<String> = mods.commands().map(str::to_owned).collect();
|
||||
let server: Vec<String> = mods.server_commands().map(str::to_owned).collect();
|
||||
let server: Vec<String> = mods.server_commands().map(|c| c.name.clone()).collect();
|
||||
report.commands = [client.clone(), server.clone()].concat();
|
||||
// A server mod plays with one player on, who joins, chats and leaves.
|
||||
let steve = ServerPlayer {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "horizon-client-core"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
description = "The client's lowest layer: chat text and translations, SNBT and NBT paths, the scoreboard, boss bars, game rules and the registry and particle tables commands name"
|
||||
|
||||
[dependencies]
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
+12
-5
@@ -61,6 +61,16 @@ fn f32_tag(v: f32) -> Tag {
|
||||
Tag::Float(v.to_bits())
|
||||
}
|
||||
|
||||
/// `TheEndGatewayBlockEntity.loadAdditional`: BlockPos.CODEC and Level.isInSpawnableBounds.
|
||||
pub fn gateway_exit(tag: &Tag) -> Option<[i32; 3]> {
|
||||
let Tag::IntArray(v) = tag else { return None };
|
||||
let p: [i32; 3] = v.as_slice().try_into().ok()?;
|
||||
((-30_000_000..30_000_000).contains(&p[0])
|
||||
&& (-20_000_000..20_000_000).contains(&p[1])
|
||||
&& (-30_000_000..30_000_000).contains(&p[2]))
|
||||
.then_some(p)
|
||||
}
|
||||
|
||||
/// What a block entity reads and writes, in `saveAdditional` order.
|
||||
struct Fields<'a> {
|
||||
input: &'a Tag,
|
||||
@@ -259,7 +269,7 @@ fn is_pos(tag: &Tag) -> bool {
|
||||
matches!(tag, Tag::IntArray(v) if v.len() == 3)
|
||||
}
|
||||
/// The loaded `SignText` of one side, or the empty text.
|
||||
pub(crate) fn sign_side(tag: Option<&Tag>) -> Tag {
|
||||
pub fn sign_side(tag: Option<&Tag>) -> Tag {
|
||||
let empty = || string("");
|
||||
let mut messages: Vec<Tag> = match tag.and_then(|t| t.get("messages")) {
|
||||
Some(Tag::List(list)) => list.iter().filter_map(component).collect(),
|
||||
@@ -424,10 +434,7 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
|
||||
}
|
||||
"minecraft:end_gateway" => {
|
||||
f.long("Age", 0);
|
||||
f.keep("exit_portal", |t| {
|
||||
is_pos(t)
|
||||
&& matches!(t, Tag::IntArray(v) if v[1].abs() <= 30_000_000 && v[0].abs() <= 30_000_000 && v[2].abs() <= 30_000_000)
|
||||
});
|
||||
f.keep("exit_portal", |t| gateway_exit(t).is_some());
|
||||
if flag(tag.get("ExactTeleport")) == Some(true) {
|
||||
f.put("ExactTeleport", boolean(true));
|
||||
}
|
||||
File renamed without changes.
+1
-1
@@ -252,7 +252,7 @@ pub fn to_text_with(
|
||||
} else if let Some(sel) = o.get("selector").and_then(Value::as_str) {
|
||||
resolve(sel)
|
||||
} else if let Some(key) = o.get("keybind").and_then(Value::as_str) {
|
||||
crate::keybinds::display_name(key)
|
||||
crate::key_display::display_name(key)
|
||||
} else {
|
||||
Text::empty()
|
||||
};
|
||||
File renamed without changes.
@@ -292,27 +292,4 @@ mod tests {
|
||||
assert_eq!(GameRules::load(&dir), rules);
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
/// The world's save (Level.write) writes the rules as they are now, and a
|
||||
/// world opened again reads them back (#74).
|
||||
#[test]
|
||||
fn level_save_keeps_changed_rules() {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-gamerules-level-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
let mut level = crate::save::Level::default();
|
||||
level.write(&dir).unwrap();
|
||||
// Rules changed while playing, then saved and loaded again.
|
||||
let mut rules = GameRules::load(&dir);
|
||||
assert_eq!(rules, GameRules::default());
|
||||
assert!(rules.set("advance_time", Value::Bool(false)));
|
||||
assert!(rules.set("advance_weather", Value::Bool(false)));
|
||||
assert!(rules.set("random_tick_speed", Value::Int(0)));
|
||||
level.game_rules = rules.by_name();
|
||||
level.write(&dir).unwrap();
|
||||
assert_eq!(GameRules::load(&dir), rules);
|
||||
let read = crate::save::Level::read(&dir).unwrap();
|
||||
assert_eq!(read.game_rules, rules.by_name());
|
||||
assert_eq!(read.game_rule("advance_time"), 0);
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Resource identifiers (`Identifier`).
|
||||
|
||||
/// Identifier.parse: `ns:path` or `path`, validated.
|
||||
pub fn parse_identifier(raw: &str) -> Option<String> {
|
||||
let (ns, path) = match raw.split_once(':') {
|
||||
Some(("", path)) => ("minecraft", path),
|
||||
Some((ns, path)) => (ns, path),
|
||||
None => ("minecraft", raw),
|
||||
};
|
||||
let ns_ok = ns != ".."
|
||||
&& ns
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-'));
|
||||
let path_ok = path.chars().all(|c| {
|
||||
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/')
|
||||
});
|
||||
(ns_ok && path_ok).then(|| format!("{ns}:{path}"))
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Key display names (`InputConstants.Key.getDisplayName`), shared by key
|
||||
//! mappings and `keybind` text components.
|
||||
use crate::text::Text;
|
||||
|
||||
/// InputConstants.Key.getDisplayName: one-character keys as the upper-case
|
||||
/// character, others by translation.
|
||||
pub fn display_name(key: &str) -> Text {
|
||||
if let Some(rest) = key.strip_prefix("key.keyboard.")
|
||||
&& rest.chars().count() == 1
|
||||
{
|
||||
let c = rest.to_uppercase();
|
||||
return Text::literal(c);
|
||||
}
|
||||
let symbol = match key {
|
||||
"key.keyboard.minus" => Some("-"),
|
||||
"key.keyboard.equal" => Some("="),
|
||||
"key.keyboard.left.bracket" => Some("["),
|
||||
"key.keyboard.right.bracket" => Some("]"),
|
||||
"key.keyboard.backslash" => Some("\\"),
|
||||
"key.keyboard.semicolon" => Some(";"),
|
||||
"key.keyboard.apostrophe" => Some("'"),
|
||||
"key.keyboard.grave.accent" => Some("`"),
|
||||
"key.keyboard.comma" => Some(","),
|
||||
"key.keyboard.period" => Some("."),
|
||||
"key.keyboard.slash" => Some("/"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(s) = symbol {
|
||||
return Text::literal(s);
|
||||
}
|
||||
if let Some(n) = key.strip_prefix("key.mouse.")
|
||||
&& n.parse::<u32>().is_ok()
|
||||
&& !crate::lang::has(key)
|
||||
{
|
||||
return Text::translate("key.mouse", &[Text::literal(n)]);
|
||||
}
|
||||
Text::translate(key, &[])
|
||||
}
|
||||
@@ -42,8 +42,12 @@ struct Bundled;
|
||||
impl ResourceSource for Bundled {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
let body = match path {
|
||||
"assets/horizon/lang/en_us.json" => include_str!("../assets/horizon/lang/en_us.json"),
|
||||
"assets/horizon/lang/ru_ru.json" => include_str!("../assets/horizon/lang/ru_ru.json"),
|
||||
"assets/horizon/lang/en_us.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
|
||||
}
|
||||
"assets/horizon/lang/ru_ru.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/ru_ru.json")
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(body.as_bytes().to_vec())
|
||||
@@ -238,6 +242,13 @@ mod tests {
|
||||
assert_eq!(en.get("horizon.options.controller"), Some("Controller"));
|
||||
let ru = Language::load(&[&empty], "ru_ru");
|
||||
assert_eq!(ru.get("horizon.options.controller"), Some("Геймпад"));
|
||||
// Every bundled string carries both languages: a key added to one file
|
||||
// and forgotten in the other shows as a key name on screen.
|
||||
for key in en.map().keys() {
|
||||
assert!(ru.get(key).is_some(), "{key} has no ru_ru value");
|
||||
}
|
||||
assert_eq!(en.get("horizon.pages.mods"), Some("Mods"));
|
||||
assert_eq!(ru.get("horizon.pages.mods"), Some("Моды"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -0,0 +1,23 @@
|
||||
//! The client's lowest layer, below commands and gameplay: chat text and
|
||||
//! translations, SNBT and NBT paths, text components, the scoreboard, boss
|
||||
//! bars, game rules, block entity NBT tables, and the registry, particle and
|
||||
//! sound source tables commands name. horizon-client re-exports each module
|
||||
//! under its old path (`horizon_client::text`, ...).
|
||||
#![forbid(unsafe_code)]
|
||||
pub mod block_entity_nbt;
|
||||
pub mod bossbar;
|
||||
pub mod component;
|
||||
pub mod fastutil_order;
|
||||
pub mod gamerules;
|
||||
pub mod identifier;
|
||||
pub mod key_display;
|
||||
pub mod lang;
|
||||
pub mod nbt_path;
|
||||
pub mod particle_type;
|
||||
pub mod registries;
|
||||
pub mod scoreboard;
|
||||
pub mod snbt;
|
||||
pub mod sound_source;
|
||||
pub mod text;
|
||||
pub mod uuid;
|
||||
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
File renamed without changes.
@@ -0,0 +1,209 @@
|
||||
//! Particle types (`ParticleTypes`): the vanilla particle types with a client
|
||||
//! implementation, by registry name, and their per-type flags.
|
||||
|
||||
macro_rules! particle_types {
|
||||
($($variant:ident = $name:literal),* $(,)?) => {
|
||||
/// Vanilla particle types with a client implementation here.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum ParticleType { $($variant),* }
|
||||
impl ParticleType {
|
||||
pub const ALL: &'static [ParticleType] = &[$(ParticleType::$variant),*];
|
||||
/// Registry path (`minecraft:<name>`), also the sprite-set file name.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self { $(ParticleType::$variant => $name),* }
|
||||
}
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
let name = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
match name { $($name => Some(ParticleType::$variant),)* _ => None }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
particle_types! {
|
||||
Block = "block",
|
||||
BlockCrumble = "block_crumble",
|
||||
Trail = "trail",
|
||||
DustPillar = "dust_pillar",
|
||||
FallingDust = "falling_dust",
|
||||
Smoke = "smoke",
|
||||
LargeSmoke = "large_smoke",
|
||||
WhiteSmoke = "white_smoke",
|
||||
Flame = "flame",
|
||||
SoulFireFlame = "soul_fire_flame",
|
||||
CopperFireFlame = "copper_fire_flame",
|
||||
SmallFlame = "small_flame",
|
||||
Lava = "lava",
|
||||
DrippingWater = "dripping_water",
|
||||
FallingWater = "falling_water",
|
||||
DrippingLava = "dripping_lava",
|
||||
FallingLava = "falling_lava",
|
||||
LandingLava = "landing_lava",
|
||||
DrippingHoney = "dripping_honey",
|
||||
FallingHoney = "falling_honey",
|
||||
LandingHoney = "landing_honey",
|
||||
FallingNectar = "falling_nectar",
|
||||
FallingSporeBlossom = "falling_spore_blossom",
|
||||
SporeBlossomAir = "spore_blossom_air",
|
||||
DrippingObsidianTear = "dripping_obsidian_tear",
|
||||
FallingObsidianTear = "falling_obsidian_tear",
|
||||
LandingObsidianTear = "landing_obsidian_tear",
|
||||
DrippingDripstoneWater = "dripping_dripstone_water",
|
||||
FallingDripstoneWater = "falling_dripstone_water",
|
||||
DrippingDripstoneLava = "dripping_dripstone_lava",
|
||||
FallingDripstoneLava = "falling_dripstone_lava",
|
||||
Splash = "splash",
|
||||
Rain = "rain",
|
||||
Bubble = "bubble",
|
||||
SulfurBubbles = "sulfur_bubbles",
|
||||
BubblePop = "bubble_pop",
|
||||
BubbleColumnUp = "bubble_column_up",
|
||||
CurrentDown = "current_down",
|
||||
Underwater = "underwater",
|
||||
Portal = "portal",
|
||||
ReversePortal = "reverse_portal",
|
||||
Enchant = "enchant",
|
||||
Nautilus = "nautilus",
|
||||
VaultConnection = "vault_connection",
|
||||
Crit = "crit",
|
||||
EnchantedHit = "enchanted_hit",
|
||||
DamageIndicator = "damage_indicator",
|
||||
Heart = "heart",
|
||||
AngryVillager = "angry_villager",
|
||||
HappyVillager = "happy_villager",
|
||||
Composter = "composter",
|
||||
Mycelium = "mycelium",
|
||||
Dolphin = "dolphin",
|
||||
EggCrack = "egg_crack",
|
||||
Poof = "poof",
|
||||
Explosion = "explosion",
|
||||
ExplosionEmitter = "explosion_emitter",
|
||||
CampfireCosySmoke = "campfire_cosy_smoke",
|
||||
CampfireSignalSmoke = "campfire_signal_smoke",
|
||||
Note = "note",
|
||||
Dust = "dust",
|
||||
TotemOfUndying = "totem_of_undying",
|
||||
Firework = "firework",
|
||||
EndRod = "end_rod",
|
||||
Ash = "ash",
|
||||
WhiteAsh = "white_ash",
|
||||
CrimsonSpore = "crimson_spore",
|
||||
WarpedSpore = "warped_spore",
|
||||
CherryLeaves = "cherry_leaves",
|
||||
PaleOakLeaves = "pale_oak_leaves",
|
||||
TintedLeaves = "tinted_leaves",
|
||||
Effect = "effect",
|
||||
InstantEffect = "instant_effect",
|
||||
EntityEffect = "entity_effect",
|
||||
Witch = "witch",
|
||||
Infested = "infested",
|
||||
Cloud = "cloud",
|
||||
Sneeze = "sneeze",
|
||||
Snowflake = "snowflake",
|
||||
Soul = "soul",
|
||||
SculkSoul = "sculk_soul",
|
||||
DragonBreath = "dragon_breath",
|
||||
Gust = "gust",
|
||||
SmallGust = "small_gust",
|
||||
GustEmitterLarge = "gust_emitter_large",
|
||||
GustEmitterSmall = "gust_emitter_small",
|
||||
DustColorTransition = "dust_color_transition",
|
||||
SculkCharge = "sculk_charge",
|
||||
SculkChargePop = "sculk_charge_pop",
|
||||
Shriek = "shriek",
|
||||
SonicBoom = "sonic_boom",
|
||||
Vibration = "vibration",
|
||||
SweepAttack = "sweep_attack",
|
||||
Glow = "glow",
|
||||
WaxOn = "wax_on",
|
||||
WaxOff = "wax_off",
|
||||
ElectricSpark = "electric_spark",
|
||||
Scrape = "scrape",
|
||||
SquidInk = "squid_ink",
|
||||
GlowSquidInk = "glow_squid_ink",
|
||||
Item = "item",
|
||||
ItemSlime = "item_slime",
|
||||
ItemSnowball = "item_snowball",
|
||||
ItemCobweb = "item_cobweb",
|
||||
SulfurCubeGoo = "sulfur_cube_goo",
|
||||
Flash = "flash",
|
||||
Firefly = "firefly",
|
||||
TrialSpawnerDetection = "trial_spawner_detection",
|
||||
TrialSpawnerDetectionOminous = "trial_spawner_detection_ominous",
|
||||
TrialOmen = "trial_omen",
|
||||
RaidOmen = "raid_omen",
|
||||
OminousSpawning = "ominous_spawning",
|
||||
Spit = "spit",
|
||||
Fishing = "fishing",
|
||||
DustPlume = "dust_plume",
|
||||
RedPoplarLeaves = "red_poplar_leaves",
|
||||
OrangePoplarLeaves = "orange_poplar_leaves",
|
||||
YellowPoplarLeaves = "yellow_poplar_leaves",
|
||||
PauseMobGrowth = "pause_mob_growth",
|
||||
ResetMobGrowth = "reset_mob_growth",
|
||||
BlockMarker = "block_marker",
|
||||
}
|
||||
impl ParticleType {
|
||||
/// `ParticleType.getOverrideLimiter` (`ParticleTypes.register(name, true)`):
|
||||
/// spawned regardless of distance.
|
||||
pub fn override_limiter(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::BlockMarker
|
||||
| Self::DamageIndicator
|
||||
| Self::ExplosionEmitter
|
||||
| Self::Explosion
|
||||
| Self::Gust
|
||||
| Self::GustEmitterLarge
|
||||
| Self::GustEmitterSmall
|
||||
| Self::SonicBoom
|
||||
| Self::SculkCharge
|
||||
| Self::SculkChargePop
|
||||
| Self::Vibration
|
||||
| Self::Poof
|
||||
| Self::Spit
|
||||
| Self::SquidInk
|
||||
| Self::SweepAttack
|
||||
| Self::CampfireCosySmoke
|
||||
| Self::CampfireSignalSmoke
|
||||
| Self::GlowSquidInk
|
||||
| Self::Glow
|
||||
| Self::WaxOn
|
||||
| Self::WaxOff
|
||||
| Self::ElectricSpark
|
||||
| Self::Scrape
|
||||
| Self::TrialSpawnerDetection
|
||||
| Self::TrialSpawnerDetectionOminous
|
||||
| Self::VaultConnection
|
||||
| Self::OminousSpawning
|
||||
)
|
||||
}
|
||||
/// `SingleQuadParticle.Layer.translucent`: types whose vanilla particle class
|
||||
/// hardcodes the translucent layer, drawn after translucent terrain.
|
||||
/// `TerrainParticle`, `BreakingItemParticle` and `BlockMarker` resolve their
|
||||
/// layer from the sprite instead (`Layer.bySprite`); see the `translucent`
|
||||
/// sprite set on the engine.
|
||||
pub fn translucent_layer(self) -> bool {
|
||||
match self {
|
||||
// CampfireSmokeParticle.
|
||||
Self::CampfireCosySmoke | Self::CampfireSignalSmoke
|
||||
// PlayerCloudParticle.
|
||||
| Self::Cloud | Self::Sneeze
|
||||
// SpellParticle.
|
||||
| Self::Effect | Self::InstantEffect | Self::EntityEffect
|
||||
| Self::Witch | Self::Infested | Self::TrialOmen | Self::RaidOmen
|
||||
// EmissiveRisingParticle.
|
||||
| Self::Soul | Self::SculkSoul
|
||||
// SculkChargeParticle, SculkChargePopParticle, ShriekParticle,
|
||||
// VibrationSignalParticle, FireflyParticle, OverlayParticle.
|
||||
| Self::SculkCharge | Self::SculkChargePop | Self::Shriek
|
||||
| Self::Vibration | Self::Firefly | Self::Flash
|
||||
// SimpleAnimatedParticle and its subclasses: EndRodParticle,
|
||||
// SparkParticle (firework), SquidInkParticle, TotemParticle.
|
||||
| Self::EndRod | Self::Firework | Self::SquidInk | Self::GlowSquidInk
|
||||
| Self::TotemOfUndying
|
||||
// FlyTowardsPositionParticle: its LifetimeAlpha(0, 0.6, 0.25, 1) is not opaque.
|
||||
| Self::VaultConnection => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! Registry contents and source state that commands validate and complete
|
||||
//! arguments against (what the server's registry access holds).
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Registry contents and source state used to validate and complete arguments.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Registries {
|
||||
/// Permission level of the command source.
|
||||
pub permission: u8,
|
||||
pub items: Vec<String>,
|
||||
pub item_tags: Vec<String>,
|
||||
/// Block id -> (property, allowed values).
|
||||
pub blocks: BTreeMap<String, Vec<(String, Vec<String>)>>,
|
||||
pub block_tags: Vec<String>,
|
||||
/// Entity types that `summon` can create.
|
||||
pub summonable: Vec<String>,
|
||||
/// Every entity type (selector `type=`).
|
||||
pub entity_types: Vec<String>,
|
||||
pub effects: Vec<String>,
|
||||
pub enchantments: Vec<String>,
|
||||
pub structures: Vec<String>,
|
||||
pub biomes: Vec<String>,
|
||||
pub dimensions: Vec<String>,
|
||||
pub clocks: Vec<String>,
|
||||
pub timelines: Vec<String>,
|
||||
pub time_markers: Vec<String>,
|
||||
pub advancements: Vec<String>,
|
||||
/// Game rule name -> integer-valued.
|
||||
pub game_rules: Vec<(String, bool)>,
|
||||
pub players: Vec<String>,
|
||||
/// Scoreboard objective names, team names and tracked score holders.
|
||||
pub objectives: Vec<String>,
|
||||
pub teams: Vec<String>,
|
||||
pub tracked: Vec<String>,
|
||||
/// Loaded datapack functions (`ns:path`) and function tags (`ns:path`).
|
||||
pub functions: Vec<String>,
|
||||
/// `/data ... storage` ids.
|
||||
pub storages: Vec<String>,
|
||||
/// Boss bar ids and datapack predicate ids.
|
||||
pub bossbars: Vec<String>,
|
||||
pub predicates: Vec<String>,
|
||||
pub item_modifiers: Vec<String>,
|
||||
pub schedules: Vec<String>,
|
||||
pub stopwatches: Vec<String>,
|
||||
pub recipes: Vec<String>,
|
||||
pub sounds: Vec<String>,
|
||||
pub damage_types: Vec<String>,
|
||||
pub attributes: Vec<String>,
|
||||
pub loot_tables: Vec<String>,
|
||||
pub templates: Vec<String>,
|
||||
pub random_sequences: Vec<String>,
|
||||
pub datapacks_enabled: Vec<String>,
|
||||
pub datapacks_disabled: Vec<String>,
|
||||
pub function_tags: Vec<String>,
|
||||
/// The block the source is looking at (relevant coordinates).
|
||||
pub target_block: Option<[i32; 3]>,
|
||||
/// Every registry's element and tag ids (`minecraft:item` -> ...), as
|
||||
/// the server's registry access and reloadable registries hold them.
|
||||
pub by_registry: BTreeMap<String, RegistryIds>,
|
||||
/// Blocks whose default state has a block entity.
|
||||
pub block_entities: std::collections::BTreeSet<String>,
|
||||
/// Block tag -> member blocks.
|
||||
pub block_tag_members: BTreeMap<String, Vec<String>>,
|
||||
/// Data component types with a codec (not transient).
|
||||
pub persistent_components: Vec<String>,
|
||||
/// Recipes `/recipe` can give (not special).
|
||||
pub learnable_recipes: Vec<String>,
|
||||
/// Advancement id -> criterion names.
|
||||
pub advancement_criteria: BTreeMap<String, Vec<String>>,
|
||||
/// World clock -> time markers shown in commands.
|
||||
pub clock_time_markers: BTreeMap<String, Vec<String>>,
|
||||
/// Trigger objectives the source may use (`/trigger` suggestions).
|
||||
pub trigger_objectives: Vec<String>,
|
||||
/// Objectives with the `trigger` criterion -> holder -> whether its score is locked.
|
||||
pub trigger_scores: BTreeMap<String, BTreeMap<String, bool>>,
|
||||
}
|
||||
/// The ids and tag ids of one registry.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct RegistryIds {
|
||||
pub ids: Vec<String>,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
const EMPTY: &[String] = &[];
|
||||
impl Registries {
|
||||
/// Fill what the game did not build itself from vanilla's exported
|
||||
/// registries (`command-registries.json`, written by
|
||||
/// `VanillaCommandParseOracle registries`): built-in registries such as
|
||||
/// mob effects, data component types and dialogs, block entities and tag
|
||||
/// members. Registries that data packs reload stay with the game.
|
||||
pub fn fill_from_export(&mut self, r: &serde_json::Value) {
|
||||
fn strings(v: &serde_json::Value) -> Vec<String> {
|
||||
v.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|x| x.as_str().map(str::to_owned))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
let fill = |field: &mut Vec<String>, key: &str| {
|
||||
if field.is_empty() {
|
||||
*field = strings(&r[key]);
|
||||
}
|
||||
};
|
||||
fill(&mut self.effects, "effects");
|
||||
fill(&mut self.enchantments, "enchantments");
|
||||
fill(&mut self.attributes, "attributes");
|
||||
fill(&mut self.damage_types, "damage_types");
|
||||
fill(&mut self.templates, "templates");
|
||||
fill(&mut self.persistent_components, "persistent_components");
|
||||
fill(&mut self.learnable_recipes, "learnable_recipes");
|
||||
const RELOADED: [&str; 5] = [
|
||||
"minecraft:advancement",
|
||||
"minecraft:recipe",
|
||||
"minecraft:loot_table",
|
||||
"minecraft:predicate",
|
||||
"minecraft:item_modifier",
|
||||
];
|
||||
for (name, entry) in r["by_registry"].as_object().into_iter().flatten() {
|
||||
if RELOADED.contains(&name.as_str()) || self.by_registry.contains_key(name) {
|
||||
continue;
|
||||
}
|
||||
self.by_registry.insert(
|
||||
name.clone(),
|
||||
RegistryIds {
|
||||
ids: strings(&entry["ids"]),
|
||||
tags: strings(&entry["tags"]),
|
||||
},
|
||||
);
|
||||
}
|
||||
if self.block_entities.is_empty() {
|
||||
self.block_entities = strings(&r["block_entities"]).into_iter().collect();
|
||||
}
|
||||
if self.block_tag_members.is_empty() {
|
||||
for (tag, members) in r["block_tag_members"].as_object().into_iter().flatten() {
|
||||
self.block_tag_members.insert(tag.clone(), strings(members));
|
||||
}
|
||||
}
|
||||
if self.clock_time_markers.is_empty() {
|
||||
for (clock, markers) in r["clock_time_markers"].as_object().into_iter().flatten() {
|
||||
self.clock_time_markers
|
||||
.insert(clock.clone(), strings(markers));
|
||||
}
|
||||
}
|
||||
if self.advancement_criteria.is_empty() {
|
||||
for (id, criteria) in r["advancement_criteria"].as_object().into_iter().flatten() {
|
||||
self.advancement_criteria
|
||||
.insert(id.clone(), strings(criteria));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The element ids of a registry (sorted).
|
||||
pub fn ids(&self, registry: &str) -> &[String] {
|
||||
if let Some(r) = self.by_registry.get(registry) {
|
||||
return &r.ids;
|
||||
}
|
||||
match registry {
|
||||
"minecraft:item" => &self.items,
|
||||
"minecraft:entity_type" => &self.entity_types,
|
||||
"minecraft:mob_effect" => &self.effects,
|
||||
"minecraft:enchantment" => &self.enchantments,
|
||||
"minecraft:worldgen/structure" => &self.structures,
|
||||
"minecraft:worldgen/biome" => &self.biomes,
|
||||
"minecraft:world_clock" => &self.clocks,
|
||||
"minecraft:timeline" => &self.timelines,
|
||||
"minecraft:attribute" => &self.attributes,
|
||||
"minecraft:damage_type" => &self.damage_types,
|
||||
"minecraft:sound_event" => &self.sounds,
|
||||
"minecraft:predicate" => &self.predicates,
|
||||
"minecraft:item_modifier" => &self.item_modifiers,
|
||||
"minecraft:loot_table" => &self.loot_tables,
|
||||
"minecraft:recipe" => &self.recipes,
|
||||
"minecraft:advancement" => &self.advancements,
|
||||
_ => EMPTY,
|
||||
}
|
||||
}
|
||||
/// The tag ids of a registry (sorted).
|
||||
pub fn tags(&self, registry: &str) -> &[String] {
|
||||
if let Some(r) = self.by_registry.get(registry) {
|
||||
return &r.tags;
|
||||
}
|
||||
match registry {
|
||||
"minecraft:item" => &self.item_tags,
|
||||
"minecraft:block" => &self.block_tags,
|
||||
_ => EMPTY,
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -2,9 +2,9 @@
|
||||
//! `ObjectiveCriteria`, `NumberFormat`) and its saved data
|
||||
//! (`data/minecraft/scoreboard.dat`, `ScoreboardSaveData.Packed`).
|
||||
use crate::{
|
||||
command::Registries,
|
||||
component,
|
||||
fastutil_order::{HashSetOrder, OpenOrder},
|
||||
registries::Registries,
|
||||
text::{self, Style, Text},
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
@@ -1266,7 +1266,7 @@ fn entity_uuids(tag: &mut Tag) {
|
||||
})
|
||||
.collect::<Option<Vec<i32>>>(),
|
||||
Tag::String(s) => {
|
||||
crate::save::uuid_from_text(&s.to_string_lossy()).map(Vec::from)
|
||||
crate::uuid::uuid_from_text(&s.to_string_lossy()).map(Vec::from)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
File renamed without changes.
@@ -0,0 +1,7 @@
|
||||
//! Sound sources (`SoundSource`), by name.
|
||||
|
||||
/// SoundSource names in vanilla order.
|
||||
pub const SOUND_SOURCES: [&str; 11] = [
|
||||
"master", "music", "record", "weather", "block", "hostile", "neutral", "player", "ambient",
|
||||
"voice", "ui",
|
||||
];
|
||||
@@ -67,7 +67,7 @@ pub struct Style {
|
||||
}
|
||||
/// A style's `font` field: an identifier (`alt` is `minecraft:alt`).
|
||||
pub fn font_id(v: &Value) -> Option<String> {
|
||||
crate::command::args::parse_identifier(v.as_str()?)
|
||||
crate::identifier::parse_identifier(v.as_str()?)
|
||||
}
|
||||
impl Style {
|
||||
pub fn color(c: u32) -> Self {
|
||||
@@ -0,0 +1,24 @@
|
||||
//! UUIDs as vanilla's four-int form, to and from their text form.
|
||||
|
||||
pub fn uuid_text(u: [i32; 4]) -> String {
|
||||
let hex: String = u.iter().map(|v| format!("{:08x}", *v as u32)).collect();
|
||||
format!(
|
||||
"{}-{}-{}-{}-{}",
|
||||
&hex[0..8],
|
||||
&hex[8..12],
|
||||
&hex[12..16],
|
||||
&hex[16..20],
|
||||
&hex[20..32]
|
||||
)
|
||||
}
|
||||
pub fn uuid_from_text(s: &str) -> Option<[i32; 4]> {
|
||||
let hex: String = s.chars().filter(|c| *c != '-').collect();
|
||||
if hex.len() != 32 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0i32; 4];
|
||||
for (i, v) in out.iter_mut().enumerate() {
|
||||
*v = u32::from_str_radix(&hex[i * 8..i * 8 + 8], 16).ok()? as i32;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
@@ -9,6 +9,8 @@ description = "Horizon native development client"
|
||||
|
||||
[dependencies]
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
|
||||
@@ -11,5 +11,57 @@
|
||||
"horizon.options.look_deadzone": "Look Deadzone",
|
||||
"horizon.options.button_glyphs": "Button Glyphs",
|
||||
"horizon.options.menu_navigation": "Menu Navigation",
|
||||
"horizon.options.on_screen_keyboard": "On-Screen Keyboard"
|
||||
"horizon.options.on_screen_keyboard": "On-Screen Keyboard",
|
||||
"horizon.options.chat_delay": "Chat Delay",
|
||||
"horizon.options.lightbar.health": "Health",
|
||||
"horizon.options.lightbar.dimension": "Dimension",
|
||||
"horizon.options.glyphs.auto": "Auto",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Focus",
|
||||
"horizon.options.menu_pointer": "Pointer",
|
||||
"horizon.options.keyboard.auto": "Auto",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "This feature is not yet available in Horizon.",
|
||||
"horizon.options.search": "Search settings...",
|
||||
"horizon.pages.mods": "Mods",
|
||||
"horizon.pages.shader_packs": "Shader Packs",
|
||||
"horizon.pages.controller": "Controller Settings",
|
||||
"horizon.pages.controller_binds": "Controller Binds",
|
||||
"horizon.pages.extras": "Horizon Extras",
|
||||
"horizon.pages.settings": "Horizon Settings",
|
||||
"horizon.pages.agent": "AI Agent",
|
||||
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
|
||||
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Every graphics setting, Horizon's included, with what each costs.",
|
||||
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
|
||||
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
|
||||
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
|
||||
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
|
||||
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
|
||||
"horizon.pages.open.graphics": "Graphics...",
|
||||
"horizon.pages.open.graphics_advanced": "Advanced Graphics...",
|
||||
"horizon.pages.open.shader_packs": "Shader Packs...",
|
||||
"horizon.pages.open.controller": "Controller Settings...",
|
||||
"horizon.pages.open.extras": "Extras...",
|
||||
"horizon.pages.open.mods": "Mods...",
|
||||
"horizon.pages.open.agent": "AI Agent...",
|
||||
"horizon.pages.open.horizon_graphics": "Horizon Graphics...",
|
||||
"horizon.pages.open.controller_binds": "Controller Binds...",
|
||||
"horizon.pages.open.horizon_settings": "Horizon Settings...",
|
||||
"horizon.mods.empty": "No mods: put .hmod files in the horizon-mods folder",
|
||||
"horizon.mods.stopped": " (Stopped)",
|
||||
"horizon.mods.dev": " (Dev)",
|
||||
"horizon.mods.settings": "Settings",
|
||||
"horizon.mods.open_folder": "Open Mod Folder",
|
||||
"horizon.shader_packs.empty": "No shader packs: put them in the shaderpacks folder",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.agent.copy_details": "Copy Connection Details",
|
||||
"horizon.multiplayer.join": "Join",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Cancel Reconnecting",
|
||||
"horizon.multiplayer.join_friend": "Join a Friend",
|
||||
"horizon.multiplayer.invite_code": "Invite Code",
|
||||
"horizon.multiplayer.relay": "Relay (optional)"
|
||||
}
|
||||
@@ -11,5 +11,57 @@
|
||||
"horizon.options.look_deadzone": "Мёртвая зона обзора",
|
||||
"horizon.options.button_glyphs": "Символы кнопок",
|
||||
"horizon.options.menu_navigation": "Навигация по меню",
|
||||
"horizon.options.on_screen_keyboard": "Экранная клавиатура"
|
||||
"horizon.options.on_screen_keyboard": "Экранная клавиатура",
|
||||
"horizon.options.chat_delay": "Задержка чата",
|
||||
"horizon.options.lightbar.health": "Здоровье",
|
||||
"horizon.options.lightbar.dimension": "Измерение",
|
||||
"horizon.options.glyphs.auto": "Авто",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Фокус",
|
||||
"horizon.options.menu_pointer": "Указатель",
|
||||
"horizon.options.keyboard.auto": "Авто",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "Эта возможность в Horizon пока недоступна.",
|
||||
"horizon.options.search": "Поиск настроек...",
|
||||
"horizon.pages.mods": "Моды",
|
||||
"horizon.pages.shader_packs": "Шейдерпаки",
|
||||
"horizon.pages.controller": "Настройки контроллера",
|
||||
"horizon.pages.controller_binds": "Назначения кнопок контроллера",
|
||||
"horizon.pages.extras": "Дополнения Horizon",
|
||||
"horizon.pages.settings": "Настройки Horizon",
|
||||
"horizon.pages.agent": "ИИ-агент",
|
||||
"horizon.pages.settings.tooltip": "Собственные настройки Horizon: графика, контроллеры, дополнения и моды.",
|
||||
"horizon.pages.graphics.tooltip": "Графика без лишнего: у каждой настройки написано, чего она стоит.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Все графические настройки, включая собственные настройки Horizon, и их цена.",
|
||||
"horizon.pages.controller.tooltip": "Контроллеры: кнопки, стики, вибрация и навигация по меню.",
|
||||
"horizon.pages.extras.tooltip": "Дополнительные возможности, все выключены по умолчанию: игра остаётся ванильной, пока вы не включите что-то.",
|
||||
"horizon.pages.agent.tooltip": "Пусть играет ваш ИИ-агент: horizon-ctl и MCP-клиенты на этом компьютере.",
|
||||
"horizon.pages.shader_packs.tooltip": "Шейдерпаки из папки shaderpacks: собственные паки Horizon и паки в формате shaders/. Одновременно работает один.",
|
||||
"horizon.pages.mods.tooltip": "Моды для Horizon из папки horizon-mods. Каждый работает в песочнице и выключен, пока вы его не включите.",
|
||||
"horizon.pages.open.graphics": "Графика...",
|
||||
"horizon.pages.open.graphics_advanced": "Продвинутая графика...",
|
||||
"horizon.pages.open.shader_packs": "Шейдерпаки...",
|
||||
"horizon.pages.open.controller": "Настройки контроллера...",
|
||||
"horizon.pages.open.extras": "Дополнения...",
|
||||
"horizon.pages.open.mods": "Моды...",
|
||||
"horizon.pages.open.agent": "ИИ-агент...",
|
||||
"horizon.pages.open.horizon_graphics": "Графика Horizon...",
|
||||
"horizon.pages.open.controller_binds": "Назначения кнопок...",
|
||||
"horizon.pages.open.horizon_settings": "Настройки Horizon...",
|
||||
"horizon.mods.empty": "Модов нет: положите файлы .hmod в папку horizon-mods",
|
||||
"horizon.mods.stopped": " (остановлен)",
|
||||
"horizon.mods.dev": " (разработка)",
|
||||
"horizon.mods.settings": "Настройки",
|
||||
"horizon.mods.open_folder": "Открыть папку модов",
|
||||
"horizon.shader_packs.empty": "Шейдерпаков нет: положите их в папку shaderpacks",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.agent.copy_details": "Скопировать данные подключения",
|
||||
"horizon.multiplayer.join": "Подключиться",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Отменить переподключение",
|
||||
"horizon.multiplayer.join_friend": "Играть с другом",
|
||||
"horizon.multiplayer.invite_code": "Код приглашения",
|
||||
"horizon.multiplayer.relay": "Ретранслятор (необязательно)"
|
||||
}
|
||||
@@ -163,6 +163,8 @@ pub fn main() -> Result<()> {
|
||||
ArrowSound::HitGround => "hit",
|
||||
ArrowSound::Splash => "splash",
|
||||
ArrowSound::Extinguish => "extinguish",
|
||||
ArrowSound::TridentHitGround => "trident_hit_ground",
|
||||
ArrowSound::TridentHit => "trident_hit",
|
||||
},
|
||||
volume.to_bits(),
|
||||
pitch.to_bits()
|
||||
|
||||
@@ -592,6 +592,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
pose,
|
||||
num("maxCrossbowChageDuration"),
|
||||
num("ticksUsingItem"),
|
||||
false,
|
||||
);
|
||||
}
|
||||
Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator => {
|
||||
|
||||
@@ -142,8 +142,8 @@ const GENES: [&str; 7] = [
|
||||
"aggressive",
|
||||
];
|
||||
const MEMORIES: [&str; 7] = [
|
||||
"long_jump_cooldown",
|
||||
"ram_cooldown",
|
||||
"long_jump_cooling_down",
|
||||
"ram_cooldown_ticks",
|
||||
"attack_target_cooldown",
|
||||
"hunted_recently",
|
||||
"dig_cooldown",
|
||||
@@ -154,11 +154,11 @@ const MEMORIES: [&str; 7] = [
|
||||
fn variant(m: &Mob, regs: &SpawnRegistries, v: &mut Vec<String>) {
|
||||
use Species as S;
|
||||
let s = m.species();
|
||||
let named = |reg: &finalize::VariantRegistry, i: u8| {
|
||||
format!(
|
||||
"minecraft:{}",
|
||||
let named = |reg: &finalize::VariantRegistry, i: Option<u8>| {
|
||||
let name = i.map_or("classic", |i| {
|
||||
reg.names.get(i as usize).map_or("?", |n| n.as_str())
|
||||
)
|
||||
});
|
||||
format!("minecraft:{name}")
|
||||
};
|
||||
let var = format!("minecraft:{}", finalize::variant_name(s, m.extra.variant));
|
||||
match s {
|
||||
@@ -506,6 +506,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
difficulty,
|
||||
data: &data,
|
||||
created: Vec::new(),
|
||||
jockey_rider: None,
|
||||
};
|
||||
group = finalize::finalize(&mut cx, &mut mob, reason, group);
|
||||
let created = std::mem::take(&mut cx.created);
|
||||
|
||||
@@ -792,7 +792,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
chunk,
|
||||
&mut gen_random,
|
||||
&mut rules,
|
||||
&mut spawn,
|
||||
&mut |l, r, _| spawn(l, r).is_some(),
|
||||
&mut |e| match e {
|
||||
GenEvent::Column(x, z) => log.push(format!("col {x} {z}")),
|
||||
GenEvent::Spawn(t, p, yaw) => spawns.push(format!(
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
//!
|
||||
//! usage: recipe_book_fixture RESOURCES CASES OUTPUT
|
||||
use horizon_client::recipe_book::{BookType, RecipeIndex, Settings};
|
||||
use horizon_protocol::{
|
||||
buf::{Codec, Reader, Writer},
|
||||
packets::recipes::RecipeBookChangeSettings,
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{collections::BTreeSet, fs, path::Path};
|
||||
@@ -93,8 +97,23 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
_ => BookType::Smoker,
|
||||
}
|
||||
.index();
|
||||
book.open[i] = op["open"].as_bool().unwrap_or(false);
|
||||
book.filtering[i] = op["filtering"].as_bool().unwrap_or(false);
|
||||
let packet = RecipeBookChangeSettings {
|
||||
book_type: i as i32,
|
||||
open: op["open"].as_bool().unwrap_or(false),
|
||||
filtering: op["filtering"].as_bool().unwrap_or(false),
|
||||
};
|
||||
let mut wire = Writer::new();
|
||||
RecipeBookChangeSettings::write(&packet, &mut wire);
|
||||
extra = format!(
|
||||
" packet={}",
|
||||
wire.data
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>()
|
||||
);
|
||||
let decoded = RecipeBookChangeSettings::read(&mut Reader::new(&wire.data))?;
|
||||
book.open[decoded.book_type as usize] = decoded.open;
|
||||
book.filtering[decoded.book_type as usize] = decoded.filtering;
|
||||
}
|
||||
"save" => {
|
||||
let tag = book.write();
|
||||
|
||||
@@ -35,8 +35,9 @@ enum Animate {
|
||||
Campfire {
|
||||
spawn_particles: bool,
|
||||
},
|
||||
/// Leaves: optional falling-leaf particle and chance, tinted by foliage.
|
||||
Leaves(Option<(T, f32)>),
|
||||
/// Leaves: optional falling-leaf particle and chance; a tinted leaf takes
|
||||
/// the biome foliage colour unless a fixed colour is given (azalea).
|
||||
Leaves(Option<(T, f32)>, Option<u32>),
|
||||
NetherPortal,
|
||||
EnchantingTable,
|
||||
EndRod([i32; 3]),
|
||||
@@ -49,6 +50,8 @@ enum Animate {
|
||||
EnderChest,
|
||||
/// FallingBlock with its dust colour.
|
||||
Falling(u32),
|
||||
/// A beehive or bee nest full of honey (`BeehiveBlock.animateTick`).
|
||||
Beehive,
|
||||
RedstoneOre,
|
||||
RedstoneWire {
|
||||
power: u8,
|
||||
@@ -104,10 +107,17 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
spawn_particles: false,
|
||||
},
|
||||
"oak_leaves" | "birch_leaves" | "jungle_leaves" | "acacia_leaves" | "dark_oak_leaves"
|
||||
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01))),
|
||||
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1))),
|
||||
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02))),
|
||||
"spruce_leaves" | "azalea_leaves" | "flowering_azalea_leaves" => Animate::Leaves(None),
|
||||
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01)), None),
|
||||
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None),
|
||||
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None),
|
||||
"red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None),
|
||||
"orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None),
|
||||
"yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None),
|
||||
// The azalea blocks drop tinted leaves of the fixed colour -9399763.
|
||||
"azalea_leaves" | "flowering_azalea_leaves" => {
|
||||
Animate::Leaves(Some((T::TintedLeaves, 0.01)), Some(0x0070_922D))
|
||||
}
|
||||
"spruce_leaves" => Animate::Leaves(None, None),
|
||||
"nether_portal" => Animate::NetherPortal,
|
||||
"enchanting_table" => Animate::EnchantingTable,
|
||||
"end_rod" => Animate::EndRod(facing()),
|
||||
@@ -119,6 +129,14 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
"sand" => Animate::Falling(0xDBD3A0),
|
||||
"red_sand" => Animate::Falling(0xA95821),
|
||||
"gravel" => Animate::Falling(0x807C7B),
|
||||
// AnvilBlock/ConcretePowderBlock.getDustColor is the map colour;
|
||||
// DragonEggBlock's is black.
|
||||
"anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7),
|
||||
"dragon_egg" => Animate::Falling(0x000000),
|
||||
_ if name.ends_with("_concrete_powder") => Animate::Falling(concrete_powder_color(
|
||||
name.trim_end_matches("_concrete_powder"),
|
||||
)),
|
||||
"beehive" | "bee_nest" if prop("honey_level").as_deref() == Some("5") => Animate::Beehive,
|
||||
"redstone_ore" | "deepslate_redstone_ore" if lit => Animate::RedstoneOre,
|
||||
"redstone_wire" => {
|
||||
let power = prop("power").and_then(|p| p.parse().ok()).unwrap_or(0);
|
||||
@@ -161,6 +179,27 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
_ => Animate::None,
|
||||
}
|
||||
}
|
||||
/// `DyeColor.getMapColor` of a concrete powder colour.
|
||||
fn concrete_powder_color(dye: &str) -> u32 {
|
||||
match dye {
|
||||
"white" => 0xFFFFFF,
|
||||
"orange" => 0xD87F33,
|
||||
"magenta" => 0xB24CD8,
|
||||
"light_blue" => 0x6699D8,
|
||||
"yellow" => 0xE5E533,
|
||||
"lime" => 0x7FCC19,
|
||||
"pink" => 0xF27FA5,
|
||||
"gray" => 0x4C4C4C,
|
||||
"light_gray" => 0x999999,
|
||||
"cyan" => 0x4C7F99,
|
||||
"purple" => 0x7F3FB2,
|
||||
"blue" => 0x334CB2,
|
||||
"brown" => 0x664C33,
|
||||
"green" => 0x667F33,
|
||||
"red" => 0x993333,
|
||||
_ => 0x191919,
|
||||
}
|
||||
}
|
||||
/// `RedstoneWireBlock.COLORS`.
|
||||
fn redstone_color(power: u8) -> [f32; 3] {
|
||||
let f = f32::from(power) / 15.0;
|
||||
@@ -504,6 +543,53 @@ fn try_spawn_drip(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `BeehiveBlock.trySpawnDripParticles`.
|
||||
fn beehive_drip(
|
||||
tables: &AnimateTables,
|
||||
engine: &mut ParticleEngine,
|
||||
ctx: &AnimateContext,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
) {
|
||||
if ctx.facts.get(state).fluid != Fluid::None || engine.random().next_float() < 0.3 {
|
||||
return;
|
||||
}
|
||||
let Some((bottom, top, bounds)) = ctx.shape_y(pos) else {
|
||||
return;
|
||||
};
|
||||
if top < 1.0
|
||||
|| tables
|
||||
.impermeable
|
||||
.get(state.0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let y = if bottom > 0.0 {
|
||||
f64::from(pos[1]) + bottom - 0.05
|
||||
} else {
|
||||
let below = [pos[0], pos[1] - 1, pos[2]];
|
||||
let below_top = ctx.shape_y(below).map_or(1.0, |s| s.1);
|
||||
// `belowTop < 1 || !isCollisionShapeFullBlock` and no fluid below.
|
||||
if (below_top < 1.0 || !ctx.sturdy(below))
|
||||
&& ctx.facts.get(ctx.state(below)).fluid == Fluid::None
|
||||
{
|
||||
f64::from(pos[1]) - 0.05
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let r = engine.random();
|
||||
let (a, b) = (r.next_double(), r.next_double());
|
||||
let (fx, fz) = (f64::from(pos[0]), f64::from(pos[2]));
|
||||
let p = [
|
||||
fx + bounds[0] + (bounds[1] - bounds[0]) * a,
|
||||
y,
|
||||
fz + bounds[2] + (bounds[3] - bounds[2]) * b,
|
||||
];
|
||||
add(engine, T::DrippingHoney, p, [0.0; 3]);
|
||||
}
|
||||
/// `WaterFluid.animateTick` / `LavaFluid.animateTick`.
|
||||
fn fluid_animate_tick(
|
||||
engine: &mut ParticleEngine,
|
||||
@@ -685,7 +771,7 @@ fn block_animate_tick(
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::Leaves(falling) => {
|
||||
Animate::Leaves(falling, fixed_tint) => {
|
||||
let below = [pos[0], pos[1] - 1, pos[2]];
|
||||
// LeavesBlock.makeDrippingWaterParticles.
|
||||
if (ctx.raining_at)([pos[0], pos[1] + 1, pos[2]]) && next_int(engine.random(), 15) == 1
|
||||
@@ -705,7 +791,7 @@ fn block_animate_tick(
|
||||
let r = engine.random();
|
||||
let p = [fx + r.next_double(), fy - 0.05, fz + r.next_double()];
|
||||
let options = if ty == T::TintedLeaves {
|
||||
let c = ctx.foliage(pos);
|
||||
let c = fixed_tint.unwrap_or_else(|| ctx.foliage(pos));
|
||||
let f = |s: u32| ((c >> s) & 255) as f32 / 255.0;
|
||||
ParticleOptions::colored(ty, [f(16), f(8), f(0), 1.0])
|
||||
} else {
|
||||
@@ -977,6 +1063,13 @@ fn block_animate_tick(
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::Beehive => {
|
||||
// BeehiveBlock.animateTick: nextInt(1) + 1 attempts.
|
||||
let attempts = next_int(engine.random(), 1) + 1;
|
||||
for _ in 0..attempts {
|
||||
beehive_drip(tables, engine, ctx, state, pos);
|
||||
}
|
||||
}
|
||||
Animate::RedstoneOre => {
|
||||
for d in [
|
||||
[0, -1, 0],
|
||||
@@ -1209,21 +1302,32 @@ fn candle(engine: &mut ParticleEngine, p: [f64; 3]) {
|
||||
}
|
||||
add(engine, T::SmallFlame, p, [0.0; 3]);
|
||||
}
|
||||
/// `CampfireBlockEntity.particleTick` for every lit campfire (no cooking items).
|
||||
/// `CampfireBlockEntity.particleTick` for every lit campfire; `cooking` says
|
||||
/// which of a campfire's four slots hold an item.
|
||||
pub fn campfire_particles<'a>(
|
||||
tables: &AnimateTables,
|
||||
engine: &mut ParticleEngine,
|
||||
ctx: &AnimateContext,
|
||||
blocks: impl Iterator<Item = &'a ([i32; 3], u32)>,
|
||||
cooking: &dyn Fn([i32; 3]) -> [bool; 4],
|
||||
) {
|
||||
for &(pos, state) in blocks {
|
||||
if !tables.is_lit_campfire(state) {
|
||||
continue;
|
||||
}
|
||||
let signal = ctx
|
||||
let properties = ctx
|
||||
.registry
|
||||
.state(BlockStateId(state))
|
||||
.is_some_and(|s| s.properties.get("signal_fire").map(String::as_str) == Some("true"));
|
||||
.map(|s| &s.properties);
|
||||
let prop = |k: &str| properties.and_then(|p| p.get(k)).map(String::as_str);
|
||||
let signal = prop("signal_fire") == Some("true");
|
||||
// Direction.get2DDataValue: south, west, north, east.
|
||||
let rotation = match prop("facing") {
|
||||
Some("west") => 1,
|
||||
Some("north") => 2,
|
||||
Some("east") => 3,
|
||||
_ => 0,
|
||||
};
|
||||
let r = engine.random();
|
||||
if r.next_float() < 0.11 {
|
||||
let count = next_int(r, 2) + 2;
|
||||
@@ -1231,6 +1335,28 @@ pub fn campfire_particles<'a>(
|
||||
make_campfire_smoke(engine, pos, signal, false);
|
||||
}
|
||||
}
|
||||
// The smoke over each cooking item, at the slot's corner of the fire.
|
||||
let occupied = cooking(pos);
|
||||
for (slot, _) in occupied.iter().enumerate().filter(|(_, held)| **held) {
|
||||
if engine.random().next_float() >= 0.2 {
|
||||
continue;
|
||||
}
|
||||
// Direction.from2DDataValue((slot + rotation) % 4) and its clockwise side.
|
||||
let (step, clockwise) = match (slot + rotation) % 4 {
|
||||
0 => ([0.0, 1.0], [-1.0, 0.0]),
|
||||
1 => ([-1.0, 0.0], [0.0, -1.0]),
|
||||
2 => ([0.0, -1.0], [1.0, 0.0]),
|
||||
_ => ([1.0, 0.0], [0.0, 1.0]),
|
||||
};
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5 - step[0] * 0.3125 + clockwise[0] * 0.3125,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5 - step[1] * 0.3125 + clockwise[1] * 0.3125,
|
||||
];
|
||||
for _ in 0..4 {
|
||||
add(engine, T::Smoke, at, [0.0, 5.0e-4, 0.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `CampfireBlock.makeParticles` (always visible, overriding the limiter);
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::{
|
||||
collisions::SceneCollisions,
|
||||
mobs::Who,
|
||||
motion::{FluidResources, LAVA, WATER},
|
||||
projectiles::{Hit, Projectile},
|
||||
projectiles::{Hit, Projectile, ProjectileKind},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
@@ -132,6 +132,10 @@ pub enum ArrowSound {
|
||||
HitGround,
|
||||
/// `entity.generic.extinguish_fire`.
|
||||
Extinguish,
|
||||
/// A thrown trident's `getDefaultHitGroundSoundEvent` (`item.trident.hit_ground`).
|
||||
TridentHitGround,
|
||||
/// `ThrownTrident.onHitEntity`'s `item.trident.hit`.
|
||||
TridentHit,
|
||||
}
|
||||
/// What a burning arrow does to the block it hits (`onProjectileHit`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -169,6 +173,9 @@ pub struct ArrowState {
|
||||
pub was_eye_in_water: bool,
|
||||
pub first_tick: bool,
|
||||
pub has_been_shot: bool,
|
||||
/// `ThrownTrident.dealtDamage`: it has hit an entity (or sat in a block
|
||||
/// for over 4 ticks) and hits no more.
|
||||
pub dealt_damage: bool,
|
||||
pub removed: bool,
|
||||
/// The arrow's own random (`Entity.random`).
|
||||
pub random: LegacyRandom,
|
||||
@@ -211,6 +218,7 @@ impl ArrowState {
|
||||
was_eye_in_water: false,
|
||||
first_tick: true,
|
||||
has_been_shot: false,
|
||||
dealt_damage: false,
|
||||
removed: false,
|
||||
random,
|
||||
events: Vec::new(),
|
||||
@@ -451,12 +459,14 @@ pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: Entity
|
||||
let Some(mut st) = p.arrow.take() else {
|
||||
return false;
|
||||
};
|
||||
let trident = matches!(p.kind, ProjectileKind::Trident);
|
||||
let mut ticker = Ticker {
|
||||
p,
|
||||
st: &mut st,
|
||||
scene,
|
||||
};
|
||||
match outcome {
|
||||
EntityHit::Hurt | EntityHit::Refused if trident => ticker.trident_bounce(),
|
||||
EntityHit::Hurt => {
|
||||
ticker.hit_sound();
|
||||
// A piercing arrow flies on (`getPierceLevel() <= 0` discards).
|
||||
@@ -501,7 +511,16 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
p.z,
|
||||
))
|
||||
}
|
||||
/// Whether this is a `ThrownTrident` (`AbstractArrow` with its own water
|
||||
/// inertia, sounds and hit).
|
||||
fn trident(&self) -> bool {
|
||||
matches!(self.p.kind, ProjectileKind::Trident)
|
||||
}
|
||||
fn tick(&mut self, targets: &[(Who, Aabb)]) -> Hit {
|
||||
// ThrownTrident.tick.
|
||||
if self.trident() && self.st.in_ground_time > 4 {
|
||||
self.st.dealt_damage = true;
|
||||
}
|
||||
let physics = !self.st.no_physics;
|
||||
let movement = self.p.velocity;
|
||||
let block_pos = self.block_position();
|
||||
@@ -549,8 +568,9 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
self.st.in_ground_time = 0;
|
||||
let original = self.position();
|
||||
if self.st.was_touching_water {
|
||||
// getWaterInertia, then the bubbles behind it.
|
||||
self.p.velocity = self.p.velocity.scale(f64::from(0.6f32));
|
||||
// getWaterInertia (0.99 for a trident), then the bubbles behind it.
|
||||
let inertia = if self.trident() { 0.99f32 } else { 0.6f32 };
|
||||
self.p.velocity = self.p.velocity.scale(f64::from(inertia));
|
||||
let v = self.p.velocity;
|
||||
for _ in 0..4 {
|
||||
self.st.events.push(ArrowEvent::Particle {
|
||||
@@ -687,6 +707,8 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
.expand_towards(self.p.velocity)
|
||||
.inflate(Vec3::new(1.0, 1.0, 1.0));
|
||||
let mut best: Option<(Who, Vec3, f64)> = None;
|
||||
// ThrownTrident.findHitEntity: none once it has dealt damage.
|
||||
let targets = if self.st.dealt_damage { &[] } else { targets };
|
||||
for (who, b) in targets {
|
||||
if Some(*who) == self.p.owner && !self.p.left_owner || !b.intersects(area) {
|
||||
continue;
|
||||
@@ -757,12 +779,33 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
/// `playSound(soundEvent, 1, 1.2 / (random * 0.2 + 0.9))`.
|
||||
fn hit_sound(&mut self) {
|
||||
let pitch = 1.2 / (self.st.random.next_float() * 0.2 + 0.9);
|
||||
let sound = if self.trident() {
|
||||
ArrowSound::TridentHitGround
|
||||
} else {
|
||||
ArrowSound::HitGround
|
||||
};
|
||||
self.st.events.push(ArrowEvent::Sound {
|
||||
sound: ArrowSound::HitGround,
|
||||
sound,
|
||||
volume: 1.0,
|
||||
pitch,
|
||||
});
|
||||
}
|
||||
/// `ThrownTrident.onHitEntity` after the hurt, whether it landed or not:
|
||||
/// the hit sound, then `deflect(REVERSE, ..., (0.02, 0.2, 0.02))`. The
|
||||
/// trident keeps flying (it has dealt its damage) and falls.
|
||||
fn trident_bounce(&mut self) {
|
||||
self.st.dealt_damage = true;
|
||||
self.st.events.push(ArrowEvent::Sound {
|
||||
sound: ArrowSound::TridentHit,
|
||||
volume: 1.0,
|
||||
pitch: 1.0,
|
||||
});
|
||||
// ProjectileDeflection.REVERSE.
|
||||
let rotation = 170.0 + self.st.random.next_float() * 20.0;
|
||||
let v = self.p.velocity;
|
||||
self.p.velocity = Vec3::new(v.x * (-0.02 * 0.5), v.y * (-0.2 * 0.5), v.z * (-0.02 * 0.5));
|
||||
self.p.yaw += rotation;
|
||||
}
|
||||
/// `onHitEntity` when the entity took no damage: `deflect(REVERSE, 0.2)`,
|
||||
/// a half turn give or take ten degrees at a tenth of the speed; one
|
||||
/// left almost still drops its pickup (ALLOWED) and is discarded.
|
||||
|
||||
@@ -179,7 +179,7 @@ fn agent() -> ureq::Agent {
|
||||
}
|
||||
|
||||
/// The discovery document, fetched once per game (a failure is retried next time).
|
||||
fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
|
||||
pub(crate) fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
|
||||
static CACHE: Mutex<Option<Arc<serde_json::Value>>> = Mutex::new(None);
|
||||
if let Some(d) = CACHE.lock().unwrap().clone() {
|
||||
return Ok(d);
|
||||
|
||||
@@ -450,6 +450,54 @@ mod tests {
|
||||
assert_eq!(view_chunks(32), 2573);
|
||||
assert_eq!(view_chunks(0), 0);
|
||||
}
|
||||
/// The status a fake control answers with: the player is where it should
|
||||
/// be, nothing is queued, and the world holds `chunks` resident chunks.
|
||||
fn status(chunks: u64) -> String {
|
||||
serde_json::json!({
|
||||
"player": {"position": [0.0, 64.0, 0.0]},
|
||||
"pending_chunks": 0,
|
||||
"screen": "playing",
|
||||
"render_distance": 12,
|
||||
"chunks": chunks,
|
||||
"generated_chunks": chunks,
|
||||
"meshed_sections": 0,
|
||||
"streaming": {},
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_load_waits_for_the_chunks_a_teleport_starts() {
|
||||
// Right after a teleport the game reports steady counts and nothing
|
||||
// queued for over a second, before the integrated server has sent the
|
||||
// first chunk: the quiet rule alone would call that loaded, and the
|
||||
// scene would be measured over an empty view (#1749).
|
||||
const LATE: usize = 30;
|
||||
let (tx, rx) = mpsc::channel::<Request>();
|
||||
let polls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let counted = std::sync::Arc::clone(&polls);
|
||||
let server = thread::spawn(move || {
|
||||
let mut seen = 0;
|
||||
while let Ok(request) = rx.recv() {
|
||||
if request.line == "status" {
|
||||
let chunks = if seen < LATE { 0 } else { 400 };
|
||||
seen += 1;
|
||||
counted.store(seen, std::sync::atomic::Ordering::SeqCst);
|
||||
let _ = request.reply.send(status(chunks));
|
||||
} else {
|
||||
let _ = request.reply.send("{}".to_owned());
|
||||
}
|
||||
}
|
||||
});
|
||||
let control = Control(tx);
|
||||
wait_loaded(&control, |_| true).expect("the scene loads");
|
||||
assert!(
|
||||
polls.load(std::sync::atomic::Ordering::SeqCst) > LATE,
|
||||
"the load finished before the chunks arrived"
|
||||
);
|
||||
drop(control);
|
||||
let _ = server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaw_matches_vanilla() {
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
//! (`entity-models.json`); transforms, animation and textures follow each
|
||||
//! vanilla renderer. Faces are lit like the `entity` shader: the lightmap at
|
||||
//! the block plus `minecraft_mix_light` with the level's two diffuse lights.
|
||||
use crate::{hud::Rect, mesher::Vertex, world::World};
|
||||
use crate::{
|
||||
hud::Rect,
|
||||
mesher::{CULL_BACK, Vertex},
|
||||
world::World,
|
||||
};
|
||||
use glam::{DVec3, Mat4, Quat, Vec3};
|
||||
use horizon_core::geometry::{
|
||||
Direction,
|
||||
@@ -149,6 +153,8 @@ pub fn draw_part(
|
||||
layer: 0,
|
||||
color,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -648,6 +654,8 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
layer: 0,
|
||||
color: c,
|
||||
light: call.light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -734,7 +742,7 @@ pub struct Scene<'a> {
|
||||
/// The skin sprite a `player_head` shows at a position (`SkullBlockRenderer`
|
||||
/// profile → skin): a downloaded `skins/<sha1>` slot or the profile's
|
||||
/// `DefaultPlayerSkin` fallback; None keeps `SKIN_BY_TYPE` (`DefaultPlayerSkin.getDefaultTexture`, slim steve).
|
||||
pub skull: &'a dyn Fn([i32; 3]) -> Option<&'a str>,
|
||||
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
|
||||
/// Font glyph cell (none for blank glyphs such as the space) and advance.
|
||||
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
|
||||
}
|
||||
@@ -1286,11 +1294,12 @@ impl BlockEntityRenderer {
|
||||
};
|
||||
// SkullBlockRenderer.getRenderType: the profile's skin when it
|
||||
// resolves, `SKIN_BY_TYPE` (steve) otherwise.
|
||||
let texture = if *kind == SkullType::Player {
|
||||
(scene.skull)(pos).unwrap_or(texture)
|
||||
let skin = if *kind == SkullType::Player {
|
||||
(scene.skull)(pos)
|
||||
} else {
|
||||
texture
|
||||
None
|
||||
};
|
||||
let texture = skin.as_deref().unwrap_or(texture);
|
||||
let (Some(model), Some(paint)) =
|
||||
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
|
||||
else {
|
||||
@@ -1385,6 +1394,7 @@ impl BlockEntityRenderer {
|
||||
m *= around(Mat4::from_rotation_y(turn * decay), pivot);
|
||||
}
|
||||
}
|
||||
let first = out.len();
|
||||
if let Some(paint) =
|
||||
paint("entity/decorated_pot/decorated_pot_base", 0xFFFFFF, packed)
|
||||
{
|
||||
@@ -1402,6 +1412,11 @@ impl BlockEntityRenderer {
|
||||
draw_part(part, side, m, &none, paint, out);
|
||||
}
|
||||
}
|
||||
// `DecoratedPotRenderer` submits every part with `entitySolid`, which culls
|
||||
// back faces: the flat `top` and `bottom` planes show one side each.
|
||||
for vertex in &mut out[first..] {
|
||||
vertex.normal = CULL_BACK;
|
||||
}
|
||||
}
|
||||
Kind::EndPortal => {
|
||||
// TheEndPortalRenderer.TRANSFORMATION: y from 6/16 to 12/16; only the
|
||||
@@ -1714,6 +1729,8 @@ impl BeamPart {
|
||||
layer: 6,
|
||||
color,
|
||||
light: self.wrap,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1784,6 +1801,7 @@ fn portal_cube(m: Mat4, faces: &[Direction], layer: u32, out: &mut Vec<Vertex>)
|
||||
layer,
|
||||
color: 0,
|
||||
light: 0,
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2108,4 +2126,70 @@ mod tests {
|
||||
assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
|
||||
assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
|
||||
}
|
||||
/// `DecoratedPotRenderer` submits every part with `RenderTypes.entitySolid`,
|
||||
/// whose pipeline culls back faces. The pot's `top` and `bottom` are flat
|
||||
/// boxes, so without that cull both sides of each plane are drawn and they
|
||||
/// z-fight: the pot renders wrong.
|
||||
#[test]
|
||||
fn the_decorated_pot_culls_its_back_faces() {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("entity-models.json").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let registry = assets.registry;
|
||||
let renderer = BlockEntityRenderer::load(&root, registry).unwrap();
|
||||
let state = registry.default_state("minecraft:decorated_pot").unwrap();
|
||||
let sprites: HashMap<String, Rect> = [
|
||||
"entity/decorated_pot/decorated_pot_base",
|
||||
"entity/decorated_pot/decorated_pot_side",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
(
|
||||
name.to_owned(),
|
||||
Rect {
|
||||
u0: 0.25,
|
||||
v0: 0.125,
|
||||
u1: 0.375,
|
||||
v1: 0.25,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let world = World::new(BlockStateId(0), 1);
|
||||
let scene = Scene {
|
||||
camera: DVec3::ZERO,
|
||||
orientation: Quat::IDENTITY,
|
||||
anim: &|_| BlockAnim::default(),
|
||||
texts: None,
|
||||
partial: 0.5,
|
||||
game_time: 0,
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
renderer.render_block(&scene, [0; 3], state.0, Mat4::IDENTITY, 0xff, &mut verts);
|
||||
// Vanilla submits 28 quads for a plain pot, and the cull keeps them all.
|
||||
assert_eq!(
|
||||
verts.len() / 4,
|
||||
28,
|
||||
"the pot submits all of vanilla's quads"
|
||||
);
|
||||
assert!(
|
||||
verts.iter().all(|v| v.normal == CULL_BACK),
|
||||
"entitySolid culls the pot's back faces"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,7 @@ pub const MAP_COLORS: [u32; 62] = [
|
||||
14200723, 8365974,
|
||||
];
|
||||
/// The 12 edges of the unit cube (outline data predating the export).
|
||||
fn cube_edges() -> Vec<[f64; 6]> {
|
||||
pub fn cube_edges() -> Vec<[f64; 6]> {
|
||||
let mut edges = Vec::new();
|
||||
for a in 0..3 {
|
||||
for (u, v) in [(0., 0.), (1., 0.), (0., 1.), (1., 1.)] {
|
||||
|
||||
@@ -49,6 +49,8 @@ pub fn geometry(
|
||||
color: 0xffff_ffff,
|
||||
light: stage_light(stage),
|
||||
normal: [0; 4],
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! `BundleContents.Mutable`: what a bundle takes in and gives back, by
|
||||
//! weight. The menus (`menu`, `lan::menu`) run `BundleItem`'s click
|
||||
//! overrides on it.
|
||||
use crate::item_data::BundleSelected;
|
||||
use crate::items::{ItemStack, Items};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
@@ -97,6 +98,8 @@ pub fn can_be_in_bundle(stack: &ItemStack) -> bool {
|
||||
pub struct Contents {
|
||||
items: Vec<ItemStack>,
|
||||
weight: Weight,
|
||||
/// `selectedItem`: an index into `items`, -1 for none.
|
||||
selected: i32,
|
||||
}
|
||||
|
||||
impl Contents {
|
||||
@@ -104,9 +107,11 @@ impl Contents {
|
||||
pub fn of(items: &Items, stack: &ItemStack) -> Option<Self> {
|
||||
let inside = contents_of(items, stack)?.to_vec();
|
||||
let weight = contents_weight(items, &inside);
|
||||
let selected = stack.data().map_or(-1, |d| d.bundle_selected.0);
|
||||
Some(Self {
|
||||
items: inside,
|
||||
weight,
|
||||
selected,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -154,18 +159,26 @@ impl Contents {
|
||||
amount
|
||||
}
|
||||
|
||||
/// `removeOne`: the first stack (Horizon keeps no selected item).
|
||||
/// `removeOne`: the selected stack, else the first; nothing is selected after.
|
||||
pub fn remove_one(&mut self, items: &Items) -> Option<ItemStack> {
|
||||
if self.items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let stack = self.items.remove(0);
|
||||
let at =
|
||||
usize::try_from(self.selected).map_or(0, |i| if i < self.items.len() { i } else { 0 });
|
||||
let stack = self.items.remove(at);
|
||||
self.weight = self
|
||||
.weight
|
||||
.sub(item_weight(items, &stack).times(stack.count));
|
||||
self.selected = -1;
|
||||
Some(stack)
|
||||
}
|
||||
|
||||
/// `getSelectedItemIndex`.
|
||||
pub fn selected(&self) -> i32 {
|
||||
self.selected
|
||||
}
|
||||
|
||||
/// The stacks inside, in display order (the first is the next out).
|
||||
pub fn items(&self) -> &[ItemStack] {
|
||||
&self.items
|
||||
@@ -200,11 +213,28 @@ impl Contents {
|
||||
|
||||
/// `toImmutable`, written back into the bundle's component.
|
||||
pub fn store(self, bundle: &mut ItemStack) {
|
||||
bundle.data_mut().bundle_contents = self.items;
|
||||
let data = bundle.data_mut();
|
||||
data.bundle_contents = self.items;
|
||||
data.bundle_selected = BundleSelected(self.selected);
|
||||
bundle.tidy();
|
||||
}
|
||||
}
|
||||
|
||||
/// `BundleItem.toggleSelectedItem`: select `index`, or nothing when it is the
|
||||
/// selected one already or outside the contents.
|
||||
pub fn toggle_selected(bundle: &mut ItemStack, index: i32) {
|
||||
let Some(data) = bundle.extra.as_deref_mut() else {
|
||||
return;
|
||||
};
|
||||
let inside = (0..data.bundle_contents.len() as i32).contains(&index);
|
||||
let selected = if data.bundle_selected.0 != index && inside {
|
||||
index
|
||||
} else {
|
||||
-1
|
||||
};
|
||||
data.bundle_selected = BundleSelected(selected);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Weight;
|
||||
@@ -232,8 +262,10 @@ pub struct TooltipLayout {
|
||||
/// The component's own width (96) and height, the text's excluded.
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
/// Each slot's top-left and the stack in it, in reading order.
|
||||
/// Each slot's top-left and the stack in it, in drawing order.
|
||||
pub slots: Vec<([f32; 2], ItemStack)>,
|
||||
/// The position in `slots` of the selected item, which is drawn highlighted.
|
||||
pub selected: Option<usize>,
|
||||
/// The `+N` of the items past the grid: its top-left and the count.
|
||||
pub overflow: Option<([f32; 2], u32)>,
|
||||
/// The progress bar's top-left, its fill's width out of 94, and whether
|
||||
@@ -266,6 +298,7 @@ pub fn tooltip_layout(
|
||||
width: 96.0,
|
||||
height: description_height + 13.0 + 8.0,
|
||||
slots: Vec::new(),
|
||||
selected: None,
|
||||
overflow: None,
|
||||
bar: [x + offset, y + description_height + 4.0],
|
||||
fill: 0.0,
|
||||
@@ -282,6 +315,7 @@ pub fn tooltip_layout(
|
||||
let x_start = x + offset + 96.0;
|
||||
let y_start = y + grid_height;
|
||||
let mut slots = Vec::with_capacity(shown.len());
|
||||
let mut selected = None;
|
||||
let mut overflow = None;
|
||||
// Rows fill bottom-up and columns right-to-left, as vanilla draws them.
|
||||
let mut slot = 0;
|
||||
@@ -290,8 +324,13 @@ pub fn tooltip_layout(
|
||||
let at = [x_start - column as f32 * 24.0, y_start - row as f32 * 24.0];
|
||||
if hidden > 0 && row == 1 && column == 1 {
|
||||
overflow = Some((at, hidden));
|
||||
} else if let Some(stack) = shown.get(slot) {
|
||||
slots.push((at, stack.clone()));
|
||||
} else if slot < shown.len() {
|
||||
// `extractSlot`: the cell of slot number n holds item `size - n`.
|
||||
let index = shown.len() - 1 - slot;
|
||||
if usize::try_from(contents.selected()) == Ok(index) {
|
||||
selected = Some(slots.len());
|
||||
}
|
||||
slots.push((at, shown[index].clone()));
|
||||
slot += 1;
|
||||
}
|
||||
}
|
||||
@@ -301,6 +340,7 @@ pub fn tooltip_layout(
|
||||
width: 96.0,
|
||||
height: grid_height + 13.0 + 8.0,
|
||||
slots,
|
||||
selected,
|
||||
overflow,
|
||||
bar: [x + offset, y + grid_height + 4.0],
|
||||
// `Mth.mulAndTruncate(weight, 94)`, kept within the bar.
|
||||
@@ -349,8 +389,8 @@ mod tooltip_tests {
|
||||
assert_eq!(layout.slots.len(), 12);
|
||||
assert_eq!(layout.overflow, None);
|
||||
assert_eq!(layout.height, 3.0 * 24.0 + 13.0 + 8.0);
|
||||
// Rows fill bottom-up and columns right-to-left: the first item is in
|
||||
// the bottom-right cell, the twelfth in the top-left.
|
||||
// Rows fill bottom-up and columns right-to-left: the twelfth item is in
|
||||
// the bottom-right cell, the first in the top-left.
|
||||
assert_eq!(layout.slots[0].0, [96.0 - 24.0, 3.0 * 24.0 - 24.0]);
|
||||
assert_eq!(&*layout.slots[0].1.item, "minecraft:stone");
|
||||
assert_eq!(layout.slots[11].0, [0.0, 0.0]);
|
||||
|
||||
@@ -76,14 +76,11 @@ impl<'a> IntoIterator for &'a ChunkMap {
|
||||
}
|
||||
}
|
||||
impl crate::light::LightWorld for ChunkMap {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> Option<u32> {
|
||||
self.columns.state(x, y, z)
|
||||
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<crate::chunk::Section>> {
|
||||
self.columns.section(cx, s, cz)
|
||||
}
|
||||
fn light(&self, x: i32, y: i32, z: i32) -> Option<u8> {
|
||||
self.columns.light(x, y, z)
|
||||
}
|
||||
fn set_light(&mut self, x: i32, y: i32, z: i32, packed: u8) {
|
||||
self.columns.set_light(x, y, z, packed);
|
||||
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: crate::chunk::Light) {
|
||||
self.columns.set_section_light(cx, s, cz, light);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +103,13 @@ mod tests {
|
||||
for column in map.values_mut() {
|
||||
Arc::make_mut(column).light_loaded = true;
|
||||
}
|
||||
crate::light::LightWorld::set_light(&mut map, 0, 0, 0, 0);
|
||||
crate::light::LightWorld::set_section_light(
|
||||
&mut map,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
crate::chunk::Light::Uniform(0),
|
||||
);
|
||||
assert_eq!(inserted, map.membership_revision());
|
||||
map.remove(&(0, 0));
|
||||
map.insert((1, 0), chunk);
|
||||
|
||||
@@ -179,6 +179,8 @@ impl Mesh {
|
||||
layer: 4,
|
||||
color: u32::MAX,
|
||||
light: shade,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -54,6 +54,27 @@ fn strider_liquid_shape() -> VoxelShape {
|
||||
VoxelShape::from_cube([1, 2, 1], &[true, false]).expect("a valid half-block column")
|
||||
}
|
||||
|
||||
// Vanilla scopes this direction to Entity.move while a piston pushes it.
|
||||
thread_local! {
|
||||
static PISTON_NOCLIP: std::cell::Cell<Option<horizon_core::geometry::Direction>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Suppress the moving part of pistons moving in this direction during a push.
|
||||
pub fn with_piston_noclip<T>(
|
||||
direction: horizon_core::geometry::Direction,
|
||||
f: impl FnOnce() -> T,
|
||||
) -> T {
|
||||
struct Reset(Option<horizon_core::geometry::Direction>);
|
||||
impl Drop for Reset {
|
||||
fn drop(&mut self) {
|
||||
PISTON_NOCLIP.with(|value| value.set(self.0));
|
||||
}
|
||||
}
|
||||
let previous = PISTON_NOCLIP.with(|value| value.replace(Some(direction)));
|
||||
let _reset = Reset(previous);
|
||||
f()
|
||||
}
|
||||
|
||||
pub struct SceneCollisions<'w, 'r> {
|
||||
pub world: &'w World,
|
||||
pub binding: &'w BlockCollisionRegistry<'r>,
|
||||
@@ -140,8 +161,21 @@ impl<W: EntityMovementWorld<Error = io::Error>> EntityMovementWorld for EntityCo
|
||||
border.collision_shape().map(Some).map_err(io::Error::other)
|
||||
}
|
||||
}
|
||||
impl EntityCollisions<'_, SceneCollisions<'_, '_>> {
|
||||
/// `SceneCollisions::resolve` that also stops at the entity boxes.
|
||||
impl<'a, W> EntityCollisions<'a, W>
|
||||
where
|
||||
W: BlockCollisionWorld<Error = io::Error> + EntityMovementWorld<Error = io::Error>,
|
||||
{
|
||||
/// `EntityCollisions` for a body the world border also stops: the border
|
||||
/// and the body's own x and z (`WorldBorder.isInsideCloseToBorder`).
|
||||
pub fn with_border(blocks: W, border: &'a BorderGeometry, at: [f64; 2]) -> Self {
|
||||
Self {
|
||||
blocks,
|
||||
entities: &[],
|
||||
border: Some((border, at)),
|
||||
}
|
||||
}
|
||||
/// `SceneCollisions::resolve` that also stops at the entity boxes and,
|
||||
/// with a border, at the world border's walls.
|
||||
pub fn resolve(
|
||||
&mut self,
|
||||
bounds: Aabb,
|
||||
@@ -149,12 +183,11 @@ impl EntityCollisions<'_, SceneCollisions<'_, '_>> {
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
) -> Result<Vec3> {
|
||||
let context = self.blocks.context(bounds);
|
||||
Ok(resolve_world_movement(
|
||||
self,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context,
|
||||
context: CollisionContext::position(bounds.min_position().y),
|
||||
bounds,
|
||||
movement,
|
||||
max_step,
|
||||
@@ -324,7 +357,7 @@ impl DynamicSupportWorld for SceneCollisions<'_, '_> {
|
||||
return Ok(None);
|
||||
};
|
||||
pistons
|
||||
.collision_shape(self, p, piston, None)
|
||||
.collision_shape(self, p, piston, PISTON_NOCLIP.with(|value| value.get()))
|
||||
.map(Some)
|
||||
.map_err(io::Error::other)
|
||||
}
|
||||
@@ -388,6 +421,42 @@ impl PowderSnowWorld for SceneCollisions<'_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A collision world without blocks: no chunk is resident, so only the
|
||||
/// world border stops a body.
|
||||
pub struct EmptyCollisions;
|
||||
|
||||
impl BlockCollisionWorld for EmptyCollisions {
|
||||
type Error = io::Error;
|
||||
type Chunk = ChunkPos;
|
||||
fn collision_chunk(&mut self, _: ChunkPos) -> io::Result<Option<ChunkPos>> {
|
||||
Ok(None)
|
||||
}
|
||||
fn collision_block(&mut self, _: ChunkPos, _: BlockPos) -> io::Result<CollisionBlock> {
|
||||
Err(io::Error::other("no chunk is resident"))
|
||||
}
|
||||
fn is_suffocating(&mut self, _: ChunkPos, _: CollisionBlock, _: BlockPos) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
fn context_shape(
|
||||
&mut self,
|
||||
_: CollisionBlock,
|
||||
_: BlockPos,
|
||||
_: &CollisionContext<'_>,
|
||||
) -> io::Result<VoxelShape> {
|
||||
Ok(VoxelShape::empty())
|
||||
}
|
||||
}
|
||||
|
||||
impl EntityMovementWorld for EmptyCollisions {
|
||||
type Error = io::Error;
|
||||
fn entity_collisions(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Vec<VoxelShape>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn movement_border(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Option<VoxelShape>> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Collision queries over any block lookup (test worlds without chunks):
|
||||
/// every chunk is present, nothing is suffocating, no entities collide.
|
||||
pub struct LookupCollisions<'w, 'r> {
|
||||
@@ -475,3 +544,70 @@ impl PowderSnowWorld for LookupCollisions<'_, '_> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod piston_noclip_tests {
|
||||
use super::*;
|
||||
use horizon_core::geometry::Direction;
|
||||
|
||||
#[test]
|
||||
fn piston_noclip_is_scoped_and_restored_after_unwind() {
|
||||
assert_eq!(PISTON_NOCLIP.with(|value| value.get()), None);
|
||||
with_piston_noclip(Direction::East, || {
|
||||
assert_eq!(
|
||||
PISTON_NOCLIP.with(|value| value.get()),
|
||||
Some(Direction::East)
|
||||
);
|
||||
let _ =
|
||||
std::panic::catch_unwind(|| with_piston_noclip(Direction::West, || panic!("test")));
|
||||
assert_eq!(
|
||||
PISTON_NOCLIP.with(|value| value.get()),
|
||||
Some(Direction::East)
|
||||
);
|
||||
});
|
||||
assert_eq!(PISTON_NOCLIP.with(|value| value.get()), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod border_movement_tests {
|
||||
use super::*;
|
||||
|
||||
/// A border 10 wide around the origin: its wall's inner face is at x = 5.
|
||||
fn border() -> BorderGeometry {
|
||||
BorderGeometry::new([0.0, 0.0], 10.0, 29_999_984).expect("a border")
|
||||
}
|
||||
|
||||
/// `WorldBorder.isInsideCloseToBorder` and `WorldBorder.getCollisionShape`:
|
||||
/// a body inside close to the border collides with the border's walls.
|
||||
#[test]
|
||||
fn a_body_next_to_the_border_stops_at_its_wall() {
|
||||
// A body 0.6 wide, 0.4 blocks short of the wall, moved east into it.
|
||||
let bounds = Aabb::new(Vec3::new(4.0, 0.0, 0.0), Vec3::new(4.6, 1.8, 0.6));
|
||||
let border = border();
|
||||
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [4.3, 0.3]);
|
||||
let moved = collisions
|
||||
.resolve(bounds, Vec3::new(0.51, 0.0, 0.0), 0.0, false)
|
||||
.expect("the movement resolves");
|
||||
// The wall stops the body 0.4 blocks short of the whole move.
|
||||
assert!(
|
||||
(moved.x - 0.4).abs() < 1.0e-9,
|
||||
"the body was not stopped by the border: {moved:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Away from the border, the same body moves the whole way.
|
||||
#[test]
|
||||
fn a_body_away_from_the_border_moves_whole() {
|
||||
let bounds = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.6, 1.8, 0.6));
|
||||
let border = border();
|
||||
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [0.3, 0.3]);
|
||||
let moved = collisions
|
||||
.resolve(bounds, Vec3::new(0.51, 0.0, 0.0), 0.0, false)
|
||||
.expect("the movement resolves");
|
||||
assert!(
|
||||
(moved.x - 0.51).abs() < 1.0e-9,
|
||||
"the body did not move the whole way: {moved:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,18 @@ impl Clicks {
|
||||
self.skip_next_release = true;
|
||||
}
|
||||
|
||||
/// The screen that takes the pointer over starts with its own state:
|
||||
/// `AbstractContainerScreen` holds `isQuickCrafting` and the drag it
|
||||
/// belongs to on the screen, so a screen that opens (or closes) drops
|
||||
/// them. A press whose release never arrived -- the pointer left the
|
||||
/// window -- would otherwise keep the drag running, and `press` answers
|
||||
/// `None` for every later press, so the quick move and the clicks go
|
||||
/// dead on every screen after it.
|
||||
pub fn cancel_drag(&mut self) {
|
||||
self.quick_crafting = None;
|
||||
self.skip_next_release = false;
|
||||
}
|
||||
|
||||
/// `mouseClicked` (no double click): the `slotClicked` input it sends on
|
||||
/// `target`, if any. A press holding a stack starts a quick craft drag
|
||||
/// instead. `cloning` is `keyPickItem` with infinite materials.
|
||||
@@ -301,4 +313,35 @@ mod tests {
|
||||
Some(Input::Pickup)
|
||||
);
|
||||
}
|
||||
|
||||
/// #1737: a press whose release never arrives (the pointer left the
|
||||
/// window) leaves `isQuickCrafting` set, and `press` answers `None` for
|
||||
/// every later press, so the Shift quick move and the clicks on the
|
||||
/// inventory go dead. The drag belongs to the screen that has it
|
||||
/// (`AbstractContainerScreen` holds `isQuickCrafting`), so the screen
|
||||
/// that takes the pointer over drops it and the quick move works again.
|
||||
#[test]
|
||||
fn a_lost_release_does_not_wedge_the_quick_move() {
|
||||
let mut clicks = Clicks::default();
|
||||
let slot = Target::<usize>::Slot(3);
|
||||
// Pressed with a stack carried: a quick craft drag starts, and its
|
||||
// release never arrives.
|
||||
assert_eq!(clicks.press(&slot, Button::Left, false, false, false), None);
|
||||
assert!(clicks.quick_crafting().is_some());
|
||||
// The stuck drag swallows the next press: the Shift quick move stops
|
||||
// working and the inventory stops answering.
|
||||
assert_eq!(clicks.press(&slot, Button::Right, true, true, false), None);
|
||||
// The screen that takes the pointer over starts with its own state.
|
||||
clicks.cancel_drag();
|
||||
assert!(clicks.quick_crafting().is_none());
|
||||
// The quick move works again.
|
||||
assert_eq!(
|
||||
clicks.press(&slot, Button::Right, true, true, false),
|
||||
Some(Input::QuickMove)
|
||||
);
|
||||
assert_eq!(
|
||||
clicks.press(&slot, Button::Left, true, false, false),
|
||||
Some(Input::Pickup)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -98,10 +98,19 @@ pub enum BlockEntity {
|
||||
Campfire(Box<Campfire>),
|
||||
/// `SkullBlockEntity` of every skull and head block.
|
||||
Skull(Skull),
|
||||
/// `TheEndGatewayBlockEntity`: its age and configured destination.
|
||||
EndGateway(EndGateway),
|
||||
/// `DaylightDetectorBlockEntity`: no data, only the ticker
|
||||
/// (`DaylightDetectorBlock.tickEntity`).
|
||||
DaylightDetector,
|
||||
}
|
||||
/// `TheEndGatewayBlockEntity`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct EndGateway {
|
||||
pub age: i64,
|
||||
pub exit_portal: Option<[i32; 3]>,
|
||||
pub exact_teleport: bool,
|
||||
}
|
||||
/// `CampfireBlockEntity`: four cooking slots, each with its
|
||||
/// `cookingProgress` and `cookingTime` (the recipe's).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -190,6 +199,9 @@ impl Skull {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SkullSkin {
|
||||
pub download: Option<(String, String)>,
|
||||
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
|
||||
/// an id only): the key `skins::Resolver` resolves it by.
|
||||
pub dynamic: Option<crate::skins::NameOrId>,
|
||||
pub fallback: &'static str,
|
||||
}
|
||||
impl SkullSkin {
|
||||
@@ -198,10 +210,21 @@ impl SkullSkin {
|
||||
let location = crate::skins::skin_location(&crate::skins::texture_hash(&url)?);
|
||||
Some((url, location))
|
||||
});
|
||||
// A `Dynamic` profile (a name or id only) resolves through the
|
||||
// services when the skull is drawn.
|
||||
let dynamic = crate::skins::dynamic_nbt(profile);
|
||||
// DefaultPlayerSkin.get(id), or getDefaultSkin (slim steve) without an id.
|
||||
let fallback = crate::skins::profile_uuid_nbt(profile)
|
||||
.or_else(|| {
|
||||
crate::skins::nbt_text(profile.get("name"))
|
||||
.map(|name| horizon_protocol::crypto::offline_uuid(&name))
|
||||
})
|
||||
.map_or("entity/player/slim/steve", crate::tab_list::default_skin);
|
||||
Self { download, fallback }
|
||||
Self {
|
||||
download,
|
||||
dynamic,
|
||||
fallback,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The skull and head blocks (`SkullBlock`, `WallSkullBlock`): every
|
||||
@@ -366,6 +389,9 @@ impl BlockEntity {
|
||||
if is_skull(name) {
|
||||
return Some(Self::Skull(Skull::default()));
|
||||
}
|
||||
if name == "minecraft:end_gateway" {
|
||||
return Some(Self::EndGateway(EndGateway::default()));
|
||||
}
|
||||
if name == "minecraft:daylight_detector" {
|
||||
return Some(Self::DaylightDetector);
|
||||
}
|
||||
@@ -418,6 +444,7 @@ impl BlockEntity {
|
||||
Self::Beehive(_) => "minecraft:beehive",
|
||||
Self::CommandBlock(_) => "minecraft:command_block",
|
||||
Self::Skull(_) => "minecraft:skull",
|
||||
Self::EndGateway(_) => "minecraft:end_gateway",
|
||||
Self::DaylightDetector => "minecraft:daylight_detector",
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
}
|
||||
@@ -436,6 +463,7 @@ impl BlockEntity {
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => Vec::new(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
@@ -456,6 +484,7 @@ impl BlockEntity {
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &[],
|
||||
Self::Crafter(c) => &c.items,
|
||||
@@ -480,6 +509,7 @@ impl BlockEntity {
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &mut [],
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
|
||||
@@ -545,6 +545,32 @@ impl Enchantments {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `EnchantmentHelper.forEachModifier` as an item's tooltip lists them: the
|
||||
/// `minecraft:attributes` effects of `stack`'s enchantments as rows of
|
||||
/// (attribute, id, amount, operation, slot group), one per slot group the
|
||||
/// enchantment lists.
|
||||
pub fn modifier_rows(&self, stack: &ItemStack) -> Vec<(String, String, f64, String, String)> {
|
||||
let mut rows = Vec::new();
|
||||
for (id, level) in stack.enchantments() {
|
||||
let Some(e) = self.get(id) else { continue };
|
||||
for a in e.effects["minecraft:attributes"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
for slot in &e.slots {
|
||||
rows.push((
|
||||
a["attribute"].as_str().unwrap_or("").to_owned(),
|
||||
a["id"].as_str().unwrap_or("").to_owned(),
|
||||
f64::from(level_value(&a["amount"], *level)),
|
||||
a["operation"].as_str().unwrap_or("add_value").to_owned(),
|
||||
slot.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
/// Sum of `add_value` amounts of one attribute from enchantments on `stack`.
|
||||
pub fn attribute_add(&self, stack: Option<&ItemStack>, attribute: &str) -> f64 {
|
||||
stack.map_or(0.0, |s| {
|
||||
|
||||
@@ -14,7 +14,8 @@ struct Globals {
|
||||
// Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png.
|
||||
end_portal_sprites: array<vec4<f32>, 2>,
|
||||
// x: vanilla's GameTime ((game time % 24000 + partial tick) / 24000)
|
||||
// y: Nether cardinal lighting, z: how many dynamic_lights are set.
|
||||
// y: Nether cardinal lighting, z: how many dynamic_lights are set,
|
||||
// w: the glint scroll clock (`Glint::scroll_ticks`).
|
||||
time: vec4<f32>,
|
||||
crumbling_sprite: vec4<f32>,
|
||||
// Selection outline parameters (read by terrain.wgsl).
|
||||
@@ -27,6 +28,12 @@ struct Globals {
|
||||
@group(0) @binding(2) var atlas_sampler: sampler;
|
||||
@group(0) @binding(3) var lightmap: texture_2d<f32>;
|
||||
@group(0) @binding(4) var lightmap_sampler: sampler;
|
||||
// enchanted_glint_item on its own repeating texture (vanilla's GlintSampler).
|
||||
@group(0) @binding(5) var glint_tex: texture_2d<f32>;
|
||||
@group(0) @binding(6) var glint_sampler: sampler;
|
||||
// The armour layers' GlintSampler: vanilla samples enchanted_glint_armor with
|
||||
// the same repeating, linearly filtered sampler as the item glint.
|
||||
@group(0) @binding(7) var glint_armor_tex: texture_2d<f32>;
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@@ -44,6 +51,11 @@ struct Out {
|
||||
// entity.vsh's lightMapColor, multiplied in after the overlay.
|
||||
@location(9) light_color: vec4<f32>,
|
||||
@location(10) @interpolate(flat) weather_rect: vec4<f32>,
|
||||
// The `GLINT_TEXTURING` sample for overlay 7 (item foil), or the model UV
|
||||
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 13 (armour).
|
||||
@location(11) glint_uv: vec2<f32>,
|
||||
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
|
||||
@location(12) @interpolate(flat) cull: u32,
|
||||
}
|
||||
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
|
||||
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
|
||||
@@ -92,7 +104,10 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
// minecraft_mix_light: the vertex colour times the cardinal light only.
|
||||
var c = color;
|
||||
var back = c;
|
||||
if normal.w != 0.0 {
|
||||
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
|
||||
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
|
||||
out.cull = select(0u, 1u, normal.w > 0.0 && normal.w < 0.5);
|
||||
if abs(normal.w) >= 0.5 {
|
||||
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
|
||||
back = select(c, vec4(back.rgb * entity_light(-normal.xyz, globals.time.y != 0.0), back.a), normal.w < 0.0);
|
||||
}
|
||||
@@ -103,18 +118,23 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
out.spherical = select(length(position), 0.0, overlay >= 2u && overlay <= 4u);
|
||||
out.cylindrical = select(max(length(position.xz), abs(position.y)), 0.0, overlay >= 2u && overlay <= 4u);
|
||||
out.overlay = overlay;
|
||||
out.glint_uv = vec2(0.0);
|
||||
if overlay == 4u { out.spherical = length(position); }
|
||||
return out;
|
||||
}
|
||||
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>) -> Out {
|
||||
return entity_vertex(position, uv, overlay, color, light, normal);
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
|
||||
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
|
||||
var out = entity_vertex(position, uv, overlay, color, light, normal);
|
||||
out.glint_uv = glint_uv;
|
||||
return out;
|
||||
}
|
||||
// Retained bolt-local geometry, translated using the same f64 camera
|
||||
// subtraction as the former CPU path before adding each local f32 position.
|
||||
// The bolt offset is instance attribute 7: 6 is the entity vertex's glint UV.
|
||||
@vertex fn vs_lightning(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>,
|
||||
@location(6) offset: vec3<f32>) -> Out {
|
||||
@location(7) offset: vec3<f32>) -> Out {
|
||||
return entity_vertex(offset + position, uv, overlay, color, light, normal);
|
||||
}
|
||||
struct CloudInfo {
|
||||
@@ -166,6 +186,27 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
if d >= end { return 1.0; }
|
||||
return (d - start) / (end - start);
|
||||
}
|
||||
// TextureTransform.setupGlintTexturing(0.16), ARMOR_ENTITY_GLINT_TEXTURING.
|
||||
// globals.time.w is (Util.getMillis() * glintSpeed * 8) modulo 330000, the
|
||||
// common period of both offsets.
|
||||
fn armor_glint_uv(model: vec2<f32>) -> vec2<f32> {
|
||||
let ticks = globals.time.w;
|
||||
let offset0 = (ticks % 110000.0) / 110000.0;
|
||||
let offset1 = (ticks % 30000.0) / 30000.0;
|
||||
let angle = 0.17453292;
|
||||
let c = cos(angle);
|
||||
let s = sin(angle);
|
||||
let scaled = model * 0.16;
|
||||
return vec2(scaled.x * c - scaled.y * s - offset0,
|
||||
scaled.x * s + scaled.y * c + offset1);
|
||||
}
|
||||
// RenderPipelines.GLINT: the source colour is scaled by itself, so `GlintAlpha`
|
||||
// is squared along with the sample.
|
||||
fn glint_over(color: vec4<f32>, glint: vec4<f32>) -> vec4<f32> {
|
||||
let alpha = globals.fog_ends.w;
|
||||
let g = alpha * glint;
|
||||
return vec4(color.rgb + g.rgb * g.rgb, max(color.a, alpha));
|
||||
}
|
||||
// CRUMBLING_UV_BEGIN
|
||||
fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>, dimensions: vec2<u32>) -> vec2<f32> {
|
||||
let size = vec2<f32>(dimensions);
|
||||
@@ -198,30 +239,43 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
// clouds.fsh: alpha fades out towards the cloud fog end.
|
||||
return vec4(color.rgb, color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y)));
|
||||
}
|
||||
if color.a < 0.1 { discard; }
|
||||
if color.a < 0.1 || (in.cull != 0u && !front) { discard; }
|
||||
// entity.fsh calculateFinalColor: the overlay (hurt red, TNT white) over the
|
||||
// lit texture, then the lightmap, then the glint and fog.
|
||||
color = vec4(mix(in.overlay_color.rgb, color.rgb, in.overlay_color.a), color.a);
|
||||
color *= in.light_color;
|
||||
if in.overlay == 7u || in.overlay == 8u {
|
||||
// Enchantment glint (overlay 7, 8 with the hurt tint): the purple
|
||||
// enchanted_glint sheen drifting diagonally over the item's shape.
|
||||
let px = in.uv * vec2<f32>(textureDimensions(atlas));
|
||||
let t = globals.misc.y * 0.05;
|
||||
let band = sin((px.x + px.y) * 0.9 - t * 6.0) * 0.5 + 0.5;
|
||||
let band2 = sin((px.x - px.y) * 0.6 + t * 4.0) * 0.5 + 0.5;
|
||||
let shimmer = pow(band * 0.6 + band2 * 0.4, 2.0);
|
||||
let glint = vec3(0.50, 0.25, 0.80) * (0.25 + 0.75 * shimmer);
|
||||
color = vec4(min(color.rgb + glint * 0.6, vec3(1.0)), color.a);
|
||||
// Enchantment glint: item.fsh's GLINT path, sampled from
|
||||
// enchanted_glint_item at the `GLINT_TEXTURING` UV.
|
||||
color = glint_over(color, textureSample(glint_tex, glint_sampler, in.glint_uv));
|
||||
} else if in.overlay == 13u {
|
||||
// Enchanted armour: enchanted_glint_armor at the scrolling model UV.
|
||||
color = glint_over(
|
||||
color,
|
||||
textureSample(glint_armor_tex, glint_sampler, armor_glint_uv(in.glint_uv)),
|
||||
);
|
||||
}
|
||||
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
|
||||
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
|
||||
}
|
||||
|
||||
fn celestial_color(in: Out) -> vec4<f32> {
|
||||
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
}
|
||||
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
return color;
|
||||
return celestial_color(in);
|
||||
}
|
||||
|
||||
// The sky's celestial pass (sun, moon, the End sky): fs_celestial's light,
|
||||
// premultiplied for an additive (one, one) blend, and as alpha how much of it
|
||||
// the texel adds, which the blend takes off the frame's alpha. HDR output
|
||||
// (hdr.wgsl) lifts only where that left it low: the sun's and moon's discs, not
|
||||
// the dark rest of their quads or the dim End sky.
|
||||
@fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = celestial_color(in);
|
||||
let added = color.rgb * color.a;
|
||||
return vec4(added, smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
|
||||
}
|
||||
|
||||
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
|
||||
|
||||
@@ -186,6 +186,8 @@ mod tests {
|
||||
color: u32::MAX,
|
||||
light: 0,
|
||||
normal: [0; 4],
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
};
|
||||
let prior = vertex([0.0; 3]);
|
||||
let body = [vertex([8.0, -1.0, -1.0]), vertex([10.0, 1.0, 1.0])];
|
||||
|
||||
@@ -88,6 +88,11 @@ impl Base<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The mobs that save `Age` and `AgeLocked`.
|
||||
fn saves_age(s: crate::mobs::Species) -> bool {
|
||||
use crate::mobs::{Species, drops::can_be_baby, finalize::ageable};
|
||||
s == Species::Tadpole || (ageable(s) && can_be_baby(s))
|
||||
}
|
||||
/// `LivingEntity` + `Mob` (+ `AgeableMob`, creeper and skeleton specifics).
|
||||
/// The mobs whose class extends vanilla's `Animal` (and so save `InLove`):
|
||||
/// not allays, villagers, golems, dolphins or fish.
|
||||
@@ -180,7 +185,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
("Silent", m.silent),
|
||||
("NoGravity", m.no_gravity),
|
||||
("Glowing", m.glowing),
|
||||
("Invulnerable", m.invulnerable_flag),
|
||||
] {
|
||||
if on {
|
||||
map.insert(key.into(), Tag::Byte(1));
|
||||
@@ -188,6 +192,26 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.remove(&horizon_world::nbt::JavaString::from(key));
|
||||
}
|
||||
}
|
||||
map.insert(
|
||||
"Invulnerable".into(),
|
||||
Tag::Byte(i8::from(m.invulnerable_flag)),
|
||||
);
|
||||
map.insert(
|
||||
"LeftHanded".into(),
|
||||
Tag::Byte(i8::from(m.extra.left_handed)),
|
||||
);
|
||||
// Keys of older formats that structure templates still carry.
|
||||
for key in [
|
||||
"HandItems",
|
||||
"ArmorItems",
|
||||
"HandDropChances",
|
||||
"ArmorDropChances",
|
||||
"Dimension",
|
||||
"HurtByTimestamp",
|
||||
"Leashed",
|
||||
] {
|
||||
map.remove(&horizon_world::nbt::JavaString::from(key));
|
||||
}
|
||||
if m.ticks_frozen > 0 {
|
||||
map.insert("TicksFrozen".into(), Tag::Int(m.ticks_frozen));
|
||||
} else {
|
||||
@@ -197,7 +221,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
("AbsorptionAmount", Tag::Float(0)),
|
||||
("FallFlying", Tag::Byte(0)),
|
||||
("CanPickUpLoot", Tag::Byte(i8::from(m.can_pick_up_loot()))),
|
||||
("LeftHanded", Tag::Byte(0)),
|
||||
("current_impulse_context_reset_grace_time", Tag::Int(0)),
|
||||
(
|
||||
"Brain",
|
||||
Tag::compound([("memories", Tag::Compound(Compound::new()))]),
|
||||
@@ -206,26 +230,65 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
let id = m.kind.id.as_str();
|
||||
// Only breedable animals have an age; a loaded creature that was saved without
|
||||
// one (a frog) stays without.
|
||||
let aged = raw.is_none_or(|r| r.get("Age").is_some());
|
||||
if m.kind.category == "creature" && aged {
|
||||
// AgeableMob.addAdditionalSaveData, only if canBeABaby (not a frog or a
|
||||
// parrot); a tadpole saves its own age.
|
||||
let species = m.species();
|
||||
if saves_age(species) {
|
||||
map.insert("Age".into(), Tag::Int(m.extra.age));
|
||||
map.insert("AgeLocked".into(), Tag::Byte(i8::from(m.extra.age_locked)));
|
||||
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
|
||||
for (key, value) in [
|
||||
("Age", Tag::Int(0)),
|
||||
("ForcedAge", Tag::Int(0)),
|
||||
("AgeLocked", Tag::Byte(0)),
|
||||
] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
if species != crate::mobs::Species::Tadpole {
|
||||
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
|
||||
}
|
||||
}
|
||||
// Animal.addAdditionalSaveData: the love ticks left, whatever the save had.
|
||||
if is_animal(id) {
|
||||
map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0)));
|
||||
}
|
||||
let species = m.species();
|
||||
if let Some((_, variant)) =
|
||||
crate::mobs::finalize::species_variant_component(species, m.extra.variant)
|
||||
{
|
||||
map.insert("variant".into(), string(&variant));
|
||||
}
|
||||
if species == crate::mobs::Species::Chicken {
|
||||
map.insert("EggLayTime".into(), Tag::Int(m.extra.egg_time));
|
||||
}
|
||||
let sounds = crate::mobs::finalize::sound_variants(species);
|
||||
if !sounds.is_empty() {
|
||||
let sound = m.extra.sound_variant.and_then(|i| sounds.get(i as usize));
|
||||
let sound = sound.copied().unwrap_or("classic");
|
||||
map.insert(
|
||||
"sound_variant".into(),
|
||||
string(&format!("minecraft:{sound}")),
|
||||
);
|
||||
}
|
||||
match id {
|
||||
// Fox.addAdditionalSaveData; Horizon models only the type.
|
||||
"minecraft:fox" => {
|
||||
let kind = if m.extra.variant == 1 { "snow" } else { "red" };
|
||||
map.insert("Type".into(), string(kind));
|
||||
for (key, value) in [
|
||||
("Trusted", Tag::List(Vec::new())),
|
||||
("Sleeping", Tag::Byte(0)),
|
||||
("Sitting", Tag::Byte(0)),
|
||||
("Crouching", Tag::Byte(0)),
|
||||
] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
}
|
||||
"minecraft:rabbit" => {
|
||||
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
|
||||
map.entry("MoreCarrotTicks".into()).or_insert(Tag::Int(0));
|
||||
}
|
||||
"minecraft:goat" => {
|
||||
map.insert(
|
||||
"IsScreamingGoat".into(),
|
||||
Tag::Byte(i8::from(m.extra.screaming)),
|
||||
);
|
||||
let [left, right] = m.extra.lost_horns;
|
||||
map.insert("HasLeftHorn".into(), Tag::Byte(i8::from(!left)));
|
||||
map.insert("HasRightHorn".into(), Tag::Byte(i8::from(!right)));
|
||||
}
|
||||
"minecraft:creeper" => {
|
||||
for (key, value) in [("Fuse", Tag::Short(30)), ("ExplosionRadius", Tag::Byte(3))] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
@@ -241,10 +304,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
|
||||
map.insert("CollarColor".into(), Tag::Byte(m.extra.color as i8));
|
||||
let variant = crate::mobs::tame::CAT_VARIANTS[(m.extra.variant as usize).min(10)];
|
||||
map.insert("variant".into(), string(&format!("minecraft:{variant}")));
|
||||
map.entry("sound_variant".into())
|
||||
.or_insert_with(|| string("minecraft:classic"));
|
||||
}
|
||||
"minecraft:wolf" => {
|
||||
if let Some(owner) = m.extra.owner {
|
||||
@@ -254,12 +313,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
|
||||
map.insert("CollarColor".into(), Tag::Byte(m.extra.color as i8));
|
||||
let variant = crate::mobs::tame::WOLF_VARIANTS[(m.extra.variant as usize).min(8)].0;
|
||||
map.insert("variant".into(), string(&format!("minecraft:{variant}")));
|
||||
map.entry("sound_variant".into())
|
||||
.or_insert_with(|| string("minecraft:classic"));
|
||||
}
|
||||
"minecraft:piglin" | "minecraft:piglin_brute" | "minecraft:hoglin" => {
|
||||
// Only Piglin.addAdditionalSaveData writes CannotHunt.
|
||||
if id == "minecraft:piglin_brute" {
|
||||
map.remove(&"CannotHunt".into());
|
||||
}
|
||||
map.insert(
|
||||
"IsImmuneToZombification".into(),
|
||||
Tag::Byte(i8::from(m.extra.flag2)),
|
||||
@@ -288,6 +347,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
| "minecraft:trader_llama" => {
|
||||
map.insert("Tame".into(), Tag::Byte(i8::from(m.extra.tame)));
|
||||
map.insert("Temper".into(), Tag::Int(i32::from(m.extra.variant3)));
|
||||
// AbstractHorse.addAdditionalSaveData; Horizon models neither.
|
||||
for key in ["EatingHaystack", "Bred"] {
|
||||
map.entry(key.into()).or_insert(Tag::Byte(0));
|
||||
}
|
||||
if let Some(owner) = m.extra.owner {
|
||||
map.insert("Owner".into(), Tag::IntArray(owner.to_vec()));
|
||||
}
|
||||
@@ -394,6 +457,8 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
"minecraft:villager" => {
|
||||
map.insert("VillagerData".into(), villager_data(m));
|
||||
// A loaded villager with VillagerData is finalized.
|
||||
map.insert("VillagerDataFinalized".into(), Tag::Byte(1));
|
||||
let t = m.extra.trade.as_deref().cloned().unwrap_or_default();
|
||||
map.insert("Xp".into(), Tag::Int(t.xp));
|
||||
map.insert("LastRestock".into(), Tag::Long(t.last_restock));
|
||||
@@ -498,8 +563,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
)])
|
||||
});
|
||||
}
|
||||
"minecraft:zombie" => {
|
||||
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
|
||||
"minecraft:zombie" | "minecraft:husk" | "minecraft:drowned" => {
|
||||
for (key, value) in [
|
||||
("DrownedConversionTime", Tag::Int(-1)),
|
||||
("InWaterTime", Tag::Int(-1)),
|
||||
@@ -532,6 +596,76 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
Tag::Byte(i8::from(m.extra.can_break_doors)),
|
||||
);
|
||||
}
|
||||
if matches!(
|
||||
id,
|
||||
"minecraft:zombie"
|
||||
| "minecraft:husk"
|
||||
| "minecraft:drowned"
|
||||
| "minecraft:zombie_villager"
|
||||
| "minecraft:zombified_piglin"
|
||||
| "minecraft:piglin"
|
||||
| "minecraft:zoglin"
|
||||
) {
|
||||
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
|
||||
}
|
||||
// NeutralMob.addPersistentAngerSaveData; the bee saves its own.
|
||||
if matches!(
|
||||
id,
|
||||
"minecraft:iron_golem"
|
||||
| "minecraft:wolf"
|
||||
| "minecraft:polar_bear"
|
||||
| "minecraft:zombified_piglin"
|
||||
| "minecraft:enderman"
|
||||
) {
|
||||
map.entry("anger_end_time".into()).or_insert(Tag::Long(-1));
|
||||
}
|
||||
if let Some(a) = m.extra.attributes.as_deref() {
|
||||
put_attributes(map, m, a);
|
||||
}
|
||||
// The attribute their load makes, which AttributeMap.pack then saves:
|
||||
// Zombie and Piglin.setBaby (even for an adult) the speed, Rabbit.setVariant
|
||||
// the attack damage.
|
||||
use crate::mobs::finalize::Attr;
|
||||
let made = if crate::mobs::doors::is_zombie(species) || species == crate::mobs::Species::Piglin
|
||||
{
|
||||
Some(Attr::MovementSpeed)
|
||||
} else if species == crate::mobs::Species::Rabbit {
|
||||
Some(Attr::AttackDamage)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(attr) = made {
|
||||
let base = crate::mobs::finalize::base_value(m, attr);
|
||||
let mut list = match map.get(&"attributes".into()) {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let attr_id = format!("minecraft:{}", attr.name());
|
||||
if !list
|
||||
.iter()
|
||||
.any(|t| text(t.get("id")).as_deref() == Some(attr_id.as_str()))
|
||||
{
|
||||
list.push(Tag::compound([
|
||||
("id", string(&attr_id)),
|
||||
("base", Tag::Double(base.to_bits())),
|
||||
]));
|
||||
map.insert("attributes".into(), Tag::List(list));
|
||||
}
|
||||
}
|
||||
// Mob.addAdditionalSaveData: the chances that are not the default.
|
||||
if let Some(chances) = &m.extra.drop_chances {
|
||||
let set: Vec<(&str, Tag)> = crate::mobs::drops::SLOTS
|
||||
.iter()
|
||||
.zip(chances.iter())
|
||||
.filter(|(_, c)| **c != crate::mobs::drops::DEFAULT_DROP_CHANCE)
|
||||
.map(|(slot, c)| (*slot, Tag::Float(c.to_bits())))
|
||||
.collect();
|
||||
if set.is_empty() {
|
||||
map.remove(&"drop_chances".into());
|
||||
} else {
|
||||
map.insert("drop_chances".into(), Tag::compound(set));
|
||||
}
|
||||
}
|
||||
crate::mobs::raider::write_nbt(m, map);
|
||||
crate::mobs::riders::write_nbt(m, map);
|
||||
// LivingEntity equipment: what the mob holds now.
|
||||
@@ -614,6 +748,89 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
tag
|
||||
}
|
||||
/// `AttributeMap.pack` for the attributes with a base or permanent modifier
|
||||
/// finalizeSpawn set, over the saved list.
|
||||
fn put_attributes(map: &mut Compound, m: &Mob, a: &crate::mobs::finalize::Attributes) {
|
||||
use crate::mobs::finalize::{self, Attr, Op};
|
||||
let mut list = match map.get(&"attributes".into()) {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
for attr in Attr::ALL {
|
||||
let modifiers: Vec<Tag> = a
|
||||
.modifiers
|
||||
.iter()
|
||||
.filter(|x| x.attr == attr)
|
||||
.map(|x| {
|
||||
let op = match x.op {
|
||||
Op::AddValue => "add_value",
|
||||
Op::AddMultipliedBase => "add_multiplied_base",
|
||||
Op::AddMultipliedTotal => "add_multiplied_total",
|
||||
};
|
||||
Tag::compound([
|
||||
("id", string(x.id)),
|
||||
("amount", Tag::Double(x.amount.to_bits())),
|
||||
("operation", string(op)),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
if modifiers.is_empty() && !a.bases.iter().any(|(b, _)| *b == attr) {
|
||||
continue;
|
||||
}
|
||||
let id = format!("minecraft:{}", attr.name());
|
||||
let mut entry = vec![
|
||||
("id", string(&id)),
|
||||
("base", Tag::Double(finalize::base_value(m, attr).to_bits())),
|
||||
];
|
||||
if !modifiers.is_empty() {
|
||||
entry.push(("modifiers", Tag::List(modifiers)));
|
||||
}
|
||||
let entry = Tag::compound(entry);
|
||||
match list
|
||||
.iter_mut()
|
||||
.find(|t| text(t.get("id")).as_deref() == Some(id.as_str()))
|
||||
{
|
||||
Some(t) => *t = entry,
|
||||
None => list.push(entry),
|
||||
}
|
||||
}
|
||||
map.insert("attributes".into(), Tag::List(list));
|
||||
}
|
||||
/// `Brain.Packed`: the memories finalizeSpawn set, over the saved ones.
|
||||
pub fn put_brain(map: &mut Compound, m: &Mob, dimension: &str) {
|
||||
if m.extra.memories.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut memories = match map.get(&"Brain".into()).and_then(|b| b.get("memories")) {
|
||||
Some(Tag::Compound(c)) => c.clone(),
|
||||
_ => Compound::new(),
|
||||
};
|
||||
for &(name, value, ttl) in &m.extra.memories {
|
||||
let value = match name {
|
||||
"hunted_recently" => Tag::Byte(value as i8),
|
||||
"home" => match m.extra.home {
|
||||
Some(p) => Tag::compound([
|
||||
("dimension", string(dimension)),
|
||||
("pos", Tag::IntArray(p.to_vec())),
|
||||
]),
|
||||
None => continue,
|
||||
},
|
||||
_ => Tag::Int(value as i32),
|
||||
};
|
||||
let mut memory = vec![("value", value)];
|
||||
if ttl != i64::MAX {
|
||||
memory.push(("ttl", Tag::Long(ttl)));
|
||||
}
|
||||
memories.insert(
|
||||
format!("minecraft:{name}").as_str().into(),
|
||||
Tag::compound(memory),
|
||||
);
|
||||
}
|
||||
map.insert(
|
||||
"Brain".into(),
|
||||
Tag::compound([("memories", Tag::Compound(memories))]),
|
||||
);
|
||||
}
|
||||
/// `CustomName`: a text component saved as a string or a `{text: ..}` compound.
|
||||
fn custom_name(tag: &Tag) -> Option<String> {
|
||||
match tag.get("CustomName")? {
|
||||
@@ -633,6 +850,22 @@ pub fn name_text(tag: &Tag) -> Text {
|
||||
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
|
||||
}
|
||||
}
|
||||
/// Mob.finalizeSpawn's follow range bonus in the saved attributes.
|
||||
pub(crate) fn spawn_bonus(tag: &Tag) -> Option<f64> {
|
||||
let Some(Tag::List(attrs)) = tag.get("attributes") else {
|
||||
return None;
|
||||
};
|
||||
let range = attrs
|
||||
.iter()
|
||||
.find(|a| text(a.get("id")).as_deref() == Some("minecraft:follow_range"))?;
|
||||
let Some(Tag::List(modifiers)) = range.get("modifiers") else {
|
||||
return None;
|
||||
};
|
||||
let bonus = modifiers
|
||||
.iter()
|
||||
.find(|x| text(x.get("id")).as_deref() == Some("minecraft:random_spawn_bonus"))?;
|
||||
float(bonus.get("amount"))
|
||||
}
|
||||
pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -> Option<Mob> {
|
||||
let kind = types.get(&self::kind(tag)?)?.clone();
|
||||
let position = position(tag)?;
|
||||
@@ -658,7 +891,18 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(bonus) = spawn_bonus(tag) {
|
||||
use crate::mobs::finalize::{Attr, Op, add_modifier};
|
||||
let id = "minecraft:random_spawn_bonus";
|
||||
add_modifier(&mut m, Attr::FollowRange, id, bonus, Op::AddMultipliedBase);
|
||||
}
|
||||
m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32);
|
||||
// LivingEntity.setHealth clamps to the max health: a horse or llama is made
|
||||
// with the type's and saved before it drops to the one it rolled. (Only
|
||||
// the base is read here, so the clamp would cut a leader zombie's bonus.)
|
||||
if crate::mobs::equine::is_equine(m.species()) {
|
||||
m.health = m.health.min(m.attrs.max_health);
|
||||
}
|
||||
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
|
||||
// What it holds (equipment.mainhand / offhand), when saved.
|
||||
if let Some(eq) = tag.get("equipment") {
|
||||
@@ -699,7 +943,17 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.pick_up_loot = flag(tag, "CanPickUpLoot");
|
||||
m.custom_name = custom_name(tag);
|
||||
m.custom_name_text = tag.get("CustomName").map(name_text);
|
||||
if m.kind.category == "creature" {
|
||||
m.extra.left_handed = flag(tag, "LeftHanded");
|
||||
if let Some(Tag::Compound(c)) = tag.get("drop_chances") {
|
||||
let mut chances = [crate::mobs::drops::DEFAULT_DROP_CHANCE; 8];
|
||||
for (chance, slot) in chances.iter_mut().zip(crate::mobs::drops::SLOTS) {
|
||||
if let Some(Tag::Float(v)) = c.get(&slot.into()) {
|
||||
*chance = f32::from_bits(*v);
|
||||
}
|
||||
}
|
||||
m.extra.drop_chances = Some(Box::new(chances));
|
||||
}
|
||||
if saves_age(m.species()) {
|
||||
m.extra.age = number(tag.get("Age")).unwrap_or(0) as i32;
|
||||
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
|
||||
m.extra.age_locked = flag(tag, "AgeLocked");
|
||||
@@ -793,20 +1047,45 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.vector[0] = 0.7;
|
||||
}
|
||||
}
|
||||
if crate::mobs::finalize::species_variant_component(m.species(), 0).is_some()
|
||||
&& let Some(v) = text(tag.get("variant"))
|
||||
{
|
||||
m.extra.variant = crate::mobs::finalize::variant_index(m.species(), &v);
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Chicken
|
||||
&& let Some(t) = number(tag.get("EggLayTime"))
|
||||
{
|
||||
m.extra.egg_time = t as i32;
|
||||
}
|
||||
if let Some(v) = text(tag.get("sound_variant")) {
|
||||
let v = v.trim_start_matches("minecraft:");
|
||||
let sounds = crate::mobs::finalize::sound_variants(m.species());
|
||||
m.extra.sound_variant = sounds.iter().position(|n| *n == v).map(|i| i as u8);
|
||||
}
|
||||
match m.species() {
|
||||
crate::mobs::Species::Fox => {
|
||||
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("snow"));
|
||||
}
|
||||
crate::mobs::Species::Rabbit => {
|
||||
// Rabbit.Variant.byId: an unknown id is brown.
|
||||
m.extra.variant = match number(tag.get("RabbitType")).unwrap_or(0) {
|
||||
v @ (0..=5 | 99) => v as u8,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
crate::mobs::Species::Goat => {
|
||||
m.extra.screaming = flag(tag, "IsScreamingGoat");
|
||||
let horn = |key| number(tag.get(key)).is_some_and(|v| v == 0);
|
||||
m.extra.lost_horns = [horn("HasLeftHorn"), horn("HasRightHorn")];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Cat {
|
||||
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
|
||||
m.extra.tame = m.extra.owner.is_some();
|
||||
m.extra.sitting = flag(tag, "Sitting");
|
||||
m.extra.color =
|
||||
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
|
||||
if let Some(Tag::String(v)) = tag.get("variant") {
|
||||
let v = v.to_string().unwrap_or_default();
|
||||
let v = v.trim_start_matches("minecraft:");
|
||||
m.extra.variant = crate::mobs::tame::CAT_VARIANTS
|
||||
.iter()
|
||||
.position(|n| *n == v)
|
||||
.unwrap_or(0) as u8;
|
||||
}
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Wolf {
|
||||
let health = m.health;
|
||||
@@ -817,14 +1096,6 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.sitting = flag(tag, "Sitting");
|
||||
m.extra.color =
|
||||
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
|
||||
if let Some(Tag::String(v)) = tag.get("variant") {
|
||||
let v = v.to_string().unwrap_or_default();
|
||||
let v = v.trim_start_matches("minecraft:");
|
||||
m.extra.variant = crate::mobs::tame::WOLF_VARIANTS
|
||||
.iter()
|
||||
.position(|(n, _)| *n == v)
|
||||
.unwrap_or(0) as u8;
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
m.species(),
|
||||
@@ -2425,6 +2696,150 @@ mod sheep_nbt_tests {
|
||||
let back = mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn farm_animals_keep_their_variant_and_sound_variant() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
types["minecraft:pig"].clone(),
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
assert_eq!(
|
||||
text(tag.get("variant")).as_deref(),
|
||||
Some("minecraft:temperate")
|
||||
);
|
||||
assert_eq!(
|
||||
text(tag.get("sound_variant")).as_deref(),
|
||||
Some("minecraft:classic")
|
||||
);
|
||||
// Spawned in a warm biome with the mini sounds.
|
||||
(m.extra.variant, m.extra.sound_variant) = (1, Some(2));
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:warm"));
|
||||
assert_eq!(
|
||||
text(tag.get("sound_variant")).as_deref(),
|
||||
Some("minecraft:mini")
|
||||
);
|
||||
let back = mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
assert_eq!((back.extra.variant, back.extra.sound_variant), (1, Some(2)));
|
||||
let mut hen = Mob::new(
|
||||
3,
|
||||
types["minecraft:chicken"].clone(),
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
(hen.extra.variant, hen.extra.egg_time) = (2, 7795);
|
||||
let tag = mob_to_nbt(&hen, [1, 2, 3, 4], None);
|
||||
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:cold"));
|
||||
let back = mob_from_nbt(&tag, &types, 4).unwrap();
|
||||
assert_eq!((back.extra.variant, back.extra.egg_time), (2, 7795));
|
||||
// The sound variants are indices into the registries finalizeSpawn draws from.
|
||||
use crate::mobs::{Species, finalize};
|
||||
let registries = finalize::SpawnRegistries::load(&root);
|
||||
for (species, registry) in [
|
||||
(Species::Pig, ®istries.pig_sound),
|
||||
(Species::Cow, ®istries.cow_sound),
|
||||
(Species::Chicken, ®istries.chicken_sound),
|
||||
(Species::Cat, ®istries.cat_sound),
|
||||
(Species::Wolf, ®istries.wolf_sound),
|
||||
] {
|
||||
assert_eq!(
|
||||
finalize::sound_variants(species),
|
||||
registry.names,
|
||||
"{species:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baby_villager_keeps_its_age() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let saved = Tag::compound([
|
||||
("id", string("minecraft:villager")),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
("Age", Tag::Int(-21359)),
|
||||
]);
|
||||
let m = mob_from_nbt(&saved, &types, 1).unwrap();
|
||||
assert_eq!(m.extra.age, -21359);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
assert_eq!(number(tag.get("Age")), Some(-21359));
|
||||
assert_eq!(number(tag.get("AgeLocked")), Some(0));
|
||||
assert_eq!(number(tag.get("VillagerDataFinalized")), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foxes_rabbits_and_goats_keep_their_kind() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let new = |kind: &str| Mob::new(1, types[kind].clone(), Vec3::new(0.0, 4.0, 0.0), 0.0);
|
||||
let round_trip = |m: &Mob| {
|
||||
let tag = mob_to_nbt(m, [1, 2, 3, 4], None);
|
||||
(mob_from_nbt(&tag, &types, 2).unwrap(), tag)
|
||||
};
|
||||
let mut fox = new("minecraft:fox");
|
||||
fox.extra.variant = 1;
|
||||
let (back, tag) = round_trip(&fox);
|
||||
assert_eq!(text(tag.get("Type")).as_deref(), Some("snow"));
|
||||
assert_eq!(number(tag.get("Sleeping")), Some(0));
|
||||
assert_eq!(back.extra.variant, 1);
|
||||
let mut rabbit = new("minecraft:rabbit");
|
||||
rabbit.extra.variant = 4;
|
||||
let (back, tag) = round_trip(&rabbit);
|
||||
assert_eq!(number(tag.get("RabbitType")), Some(4));
|
||||
assert_eq!(back.extra.variant, 4);
|
||||
let mut goat = new("minecraft:goat");
|
||||
(goat.extra.screaming, goat.extra.lost_horns) = (true, [false, true]);
|
||||
let (back, tag) = round_trip(&goat);
|
||||
assert_eq!(number(tag.get("HasRightHorn")), Some(0));
|
||||
assert_eq!(
|
||||
(back.extra.screaming, back.extra.lost_horns),
|
||||
(true, [false, true])
|
||||
);
|
||||
// A goat saved without horn flags has both, as vanilla reads it.
|
||||
let bare = Tag::compound([
|
||||
("id", string("minecraft:goat")),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
]);
|
||||
assert_eq!(
|
||||
mob_from_nbt(&bare, &types, 3).unwrap().extra.lost_horns,
|
||||
[false; 2]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -35,6 +35,18 @@ impl Damage {
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
/// A block that starts to fall: anvils hurt, and a brushable block
|
||||
/// breaks where it lands (BrushableBlock.tick: disableDrop).
|
||||
pub fn falling(block: &str) -> Self {
|
||||
if block.ends_with("anvil") {
|
||||
Self::anvil()
|
||||
} else {
|
||||
Self {
|
||||
cancel_drop: block.starts_with("minecraft:suspicious_"),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn from_nbt(tag: &Tag, anvil: bool) -> Self {
|
||||
Self {
|
||||
hurt_entities: number(tag.get("HurtEntities")).map_or(anvil, |v| v != 0),
|
||||
@@ -181,6 +193,14 @@ pub fn hurt_mobs(mobs: &mut [Mob], position: Vec3, amount: f32) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn only_brushable_blocks_break_where_they_land() {
|
||||
assert!(Damage::falling("minecraft:suspicious_gravel").cancel_drop);
|
||||
assert!(Damage::falling("minecraft:suspicious_sand").cancel_drop);
|
||||
assert!(!Damage::falling("minecraft:gravel").cancel_drop);
|
||||
assert!(Damage::falling("minecraft:chipped_anvil").hurt_entities);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn impact_uses_ceil_distance_capped_damage_and_positive_damage_wear() {
|
||||
let damage = Damage::anvil();
|
||||
|
||||
@@ -48,6 +48,8 @@ pub fn flames(
|
||||
color: 0xffff_ffff,
|
||||
light: 0xf000 | (u32::from(scene_light >> 4) * 16),
|
||||
normal: [0; 4],
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
remaining -= 0.45;
|
||||
|
||||
@@ -18,6 +18,7 @@ mod boats;
|
||||
mod books;
|
||||
mod bossbars;
|
||||
mod breezes;
|
||||
mod bundles;
|
||||
mod campfire;
|
||||
mod campfire_douse;
|
||||
mod cartography;
|
||||
@@ -132,6 +133,7 @@ mod persist;
|
||||
mod perspective;
|
||||
mod pickups;
|
||||
mod piglins;
|
||||
mod piston_push;
|
||||
mod player_equip;
|
||||
mod postprocess;
|
||||
mod potions;
|
||||
@@ -156,6 +158,7 @@ mod scoreboard;
|
||||
mod screens;
|
||||
mod sculk;
|
||||
mod sculk_fixture;
|
||||
mod server_links;
|
||||
mod server_mods;
|
||||
mod server_packs;
|
||||
mod server_travel;
|
||||
@@ -375,7 +378,7 @@ impl Options {
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --in-frame Run single player's world in the frame, not on its server thread\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --in-frame Run single player's world in the frame, not on its server thread\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
horizon_core::VANILLA_VERSION
|
||||
);
|
||||
return Ok(None);
|
||||
@@ -430,6 +433,16 @@ impl Options {
|
||||
"--width" => o.window_size[0] = value.parse::<u32>()?.max(1),
|
||||
"--height" => o.window_size[1] = value.parse::<u32>()?.max(1),
|
||||
"--world" => o.world = Some(value),
|
||||
// Vanilla's quick play (Main, QuickPlay.joinSingleplayerWorld): only a
|
||||
// world that exists opens; else the title screen. --world also creates one.
|
||||
"--quickPlaySingleplayer" => {
|
||||
let folder = std::env::current_dir()?.join("saves").join(&value);
|
||||
if folder.join("level.dat").is_file() {
|
||||
o.world = Some(value);
|
||||
} else {
|
||||
eprintln!("Quick play: there is no world {value} in saves/");
|
||||
}
|
||||
}
|
||||
"--dev-mod" => o.dev_mods.push(std::path::absolute(&value)?),
|
||||
"--benchmark" => {
|
||||
if horizon_client::benchmark::scenes(&value).is_none() {
|
||||
@@ -440,7 +453,7 @@ impl Options {
|
||||
}
|
||||
o.benchmark = Some(value);
|
||||
}
|
||||
"--server" => o.server = Some(value),
|
||||
"--server" | "--quickPlayMultiplayer" => o.server = Some(value),
|
||||
"--name" => o.player_name = Some(value),
|
||||
"--session-file" => session_file = Some(value),
|
||||
"--control-socket" => o.control = Some(value.into()),
|
||||
@@ -497,6 +510,12 @@ enum Screen {
|
||||
Friends,
|
||||
/// CommandBlockEditScreen (game/command_blocks.rs).
|
||||
CommandBlock,
|
||||
/// ServerLinksDialogScreen: a server's links (game/server_links.rs).
|
||||
ServerLinks,
|
||||
/// ConfirmLinkScreen for a server link (game/server_links.rs).
|
||||
ConfirmLink,
|
||||
/// DialogScreen.WarningScreen: the note about custom screens.
|
||||
DialogWarning,
|
||||
/// WinScreen: the end poem and the credits (game/win.rs).
|
||||
Win,
|
||||
/// A `ui` mod's own screen, or its settings page from Options > Mods
|
||||
@@ -1265,6 +1284,8 @@ struct Game<'a> {
|
||||
inv_clicks: horizon_client::container_screen::Clicks,
|
||||
/// The Mouse Tweaks extra's drag and wheel (`tweaks`).
|
||||
tweaks: tweaks::Tweaks,
|
||||
/// The wheel and hovered slot of `BundleMouseActions`.
|
||||
bundle_mouse: bundles::BundleMouse,
|
||||
/// The last inventory click, for the double click that gathers a stack.
|
||||
last_inv_click: Option<(std::time::Instant, usize, bool)>,
|
||||
/// `lastQuickMoved`: what the last Shift click moved, which a Shift
|
||||
@@ -1432,6 +1453,10 @@ struct Game<'a> {
|
||||
titles: titles::Titles,
|
||||
/// `Hud.toolHighlightTimer`: the selected item name.
|
||||
tool_highlight: horizon_client::hud_overlays::ToolHighlight,
|
||||
/// `Hud.autosaveIndicatorValue`.
|
||||
saving_indicator: horizon_client::hud_overlays::SavingIndicator,
|
||||
/// This game's world is autosaving this tick (`MinecraftServer.isSaving`).
|
||||
autosaving: bool,
|
||||
applied_modifiers: Vec<(String, String)>,
|
||||
experience: Experience,
|
||||
orbs: Vec<ExperienceOrb>,
|
||||
@@ -1545,6 +1570,9 @@ struct Game<'a> {
|
||||
/// Player skin downloads, and which skin each atlas skin slot holds
|
||||
/// (least recently shown first).
|
||||
skin_downloads: RefCell<horizon_client::skins::Downloader>,
|
||||
/// `ProfileResolver.Cached`: `Dynamic` profiles (name- or id-only heads
|
||||
/// and mannequins) resolve through the services in the background.
|
||||
skin_profiles: RefCell<horizon_client::skins::Resolver>,
|
||||
skin_slots: Vec<(String, usize)>,
|
||||
/// Atlas trim slots in use: (sprite name, slot), least recently drawn first.
|
||||
trim_slots: Vec<(String, usize)>,
|
||||
@@ -1658,12 +1686,13 @@ struct Game<'a> {
|
||||
guest_use: Option<GuestUse>,
|
||||
/// Level events of this tick for the guests, and the guest acting now.
|
||||
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
|
||||
lan_cushion_breaks: Vec<(horizon_world::registry::BlockStateId, [f64; 3])>,
|
||||
/// Block events (position, b0, b1, state) and entity events (id, event,
|
||||
/// position) for the guests.
|
||||
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
|
||||
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
|
||||
/// Successful crits (attacking guest, hit entity), queued while the hub is taken.
|
||||
lan_critical_hits: Vec<(Option<u32>, i32)>,
|
||||
lan_critical_hits: Vec<(Option<u32>, i32, bool)>,
|
||||
/// Global level events for every guest (`ServerLevel.globalLevelEvent`).
|
||||
lan_global_events: Vec<(i32, [i32; 3], i32)>,
|
||||
/// `ContainerOpenersCounter`: how many players (the host and the guests)
|
||||
@@ -1681,6 +1710,9 @@ struct Game<'a> {
|
||||
/// The acting guest's cooldowns, as (item, ticks): their packet needs the
|
||||
/// hub's item registry, which the guest's turn does not hold.
|
||||
guest_cooldowns: Vec<(Arc<str>, i32)>,
|
||||
/// The acting guest's own entity events (`ServerPlayer.connection.send`),
|
||||
/// sent with their entity id, which the guest's turn does not hold.
|
||||
guest_entity_events: Vec<i8>,
|
||||
/// `broadcastToAdmins` lines a guest's command queued, sent once it
|
||||
/// returns: the line, whether operators get it and whether the console
|
||||
/// logs it.
|
||||
@@ -1715,6 +1747,8 @@ struct Game<'a> {
|
||||
restore_player_state: bool,
|
||||
stats_ui: stats_screen::StatsUi,
|
||||
adv_ui: advancements_screen::AdvUi,
|
||||
/// The Server Links screens' state (game/server_links.rs).
|
||||
links_ui: server_links::LinksUi,
|
||||
camera_type: perspective::CameraType,
|
||||
fangs: fangs::FangState,
|
||||
/// The local player as a model (third person, the inventory).
|
||||
@@ -1811,9 +1845,46 @@ impl Game<'_> {
|
||||
fn reach(&self) -> f64 {
|
||||
if self.creative { 5.0 } else { 4.5 }
|
||||
}
|
||||
/// Outline shapes that depend on more than the block state, as one box
|
||||
/// in block space: `LightBlock` and `ScaffoldingBlock.getShape` are a full
|
||||
/// block while the main hand holds the block's own item
|
||||
/// (`CollisionContext.isHoldingItem`), and `ShulkerBoxBlock.getShape`
|
||||
/// grows with the lid (`ShulkerBoxBlockEntity.getBoundingBox`).
|
||||
fn context_outline(&self, pos: [i32; 3], state: BlockStateId) -> Option<[f64; 6]> {
|
||||
let name = &self.res.facts().get(state).name;
|
||||
if matches!(&**name, "minecraft:light" | "minecraft:scaffolding") {
|
||||
return self
|
||||
.held()
|
||||
.is_some_and(|s| *s.item == **name)
|
||||
.then_some([0.0, 0.0, 0.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
if !name.ends_with("shulker_box") {
|
||||
return None;
|
||||
}
|
||||
let progress = self.be_anims.shulkers.get(&pos).map_or(0.0, |s| s.progress);
|
||||
let facing = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("facing").cloned());
|
||||
let d = facing_offset(facing.as_deref().unwrap_or("up"));
|
||||
let mut b = [0.0, 0.0, 0.0, 1.0, 1.0, 1.0];
|
||||
for a in 0..3 {
|
||||
match d[a] {
|
||||
1 => b[a + 3] += 0.5 * f64::from(progress),
|
||||
-1 => b[a] -= 0.5 * f64::from(progress),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(b)
|
||||
}
|
||||
/// A block's outline boxes in world space (`getShape` plus the random offset).
|
||||
fn outline_boxes(&self, pos: [i32; 3]) -> Vec<[f64; 6]> {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if let Some(b) = self.context_outline(pos, state) {
|
||||
return vec![std::array::from_fn(|i| f64::from(pos[i % 3]) + b[i])];
|
||||
}
|
||||
let f = self.res.facts().get(state);
|
||||
let o = if f.outline_offset {
|
||||
self.pack
|
||||
@@ -2108,6 +2179,33 @@ impl Game<'_> {
|
||||
}
|
||||
/// `TerrainParticle` at `pos`: the model's particle sprite and tint source
|
||||
/// 0's `colorAsTerrainParticle` (white when the block has none).
|
||||
/// `HoneyBlock.showSlideParticles` (entity event 53): five honey block
|
||||
/// particles at the entity.
|
||||
fn honey_slide_particles(&mut self, at: [f64; 3]) {
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
let Some(honey) = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:honey_block")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let block = [
|
||||
at[0].floor() as i32,
|
||||
at[1].floor() as i32,
|
||||
at[2].floor() as i32,
|
||||
];
|
||||
if let Some((sprite, tint)) = self.particle_sprite(honey, block) {
|
||||
for _ in 0..5 {
|
||||
self.particles.add(
|
||||
&ParticleOptions::block(ParticleType::Block, sprite.clone(), tint),
|
||||
at,
|
||||
[0.0; 3],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn particle_sprite(&self, state: BlockStateId, pos: [i32; 3]) -> Option<(String, u32)> {
|
||||
let sprite = Self::block_particle_sprite(&self.pack, state)?;
|
||||
let chunks = &self.world.chunks;
|
||||
@@ -2325,11 +2423,18 @@ impl Game<'_> {
|
||||
yaw.cos() * pitch.cos() * 0.3 + spread.sin() * strength,
|
||||
];
|
||||
self.spawn_item(thrown, [eye[0], eye[1] - 0.3, eye[2]], v, 40);
|
||||
if thrown_from_hand && self.lan_acting.is_none() {
|
||||
let uuid = self.level.ensure_player_uuid();
|
||||
if thrown_from_hand {
|
||||
let uuid = self.acting_player_uuid();
|
||||
self.dropped.last_mut().unwrap().thrower = Some(uuid);
|
||||
}
|
||||
}
|
||||
/// The UUID of the player acting: the guest whose turn it is, else the host.
|
||||
pub(super) fn acting_player_uuid(&mut self) -> [i32; 4] {
|
||||
match self.lan_acting_uuid {
|
||||
Some(guest) => horizon_client::lan::playerdata::uuid_ints(guest),
|
||||
None => self.level.ensure_player_uuid(),
|
||||
}
|
||||
}
|
||||
fn tick_items(&mut self) {
|
||||
let mut items = std::mem::take(&mut self.dropped);
|
||||
// ServerLevel.tick: only entities in entity-ticking chunks tick.
|
||||
@@ -2826,20 +2931,29 @@ impl Game<'_> {
|
||||
b.run_block_events(level, ticks);
|
||||
b.tick_block_entities(level, ticks);
|
||||
});
|
||||
// `PistonMovingBlockEntity.tick` pushes what its block just ran into,
|
||||
// with the progress it is about to take.
|
||||
let pushes = std::mem::take(&mut self.block_ticks.piston_pushes);
|
||||
self.push_piston_entities(pushes);
|
||||
self.process_level_events()?;
|
||||
self.sync_moving_pistons();
|
||||
self.process_level_events()
|
||||
Ok(())
|
||||
}
|
||||
/// Copies the moving-piston block entities into the world, where the
|
||||
/// collision queries and the renderer read them.
|
||||
fn sync_moving_pistons(&mut self) {
|
||||
let pistons = &mut self.world.moving_pistons;
|
||||
pistons.clear();
|
||||
pistons.extend(
|
||||
self.block_ticks
|
||||
.moving_pistons
|
||||
.iter()
|
||||
.map(|(p, piston)| ([p.x, p.y, p.z], piston.shape_state())),
|
||||
);
|
||||
let previous = std::mem::take(&mut self.world.moving_pistons);
|
||||
let current: Vec<_> = self
|
||||
.block_ticks
|
||||
.moving_pistons
|
||||
.iter()
|
||||
.map(|(p, piston)| ([p.x, p.y, p.z], piston.shape_state()))
|
||||
.collect();
|
||||
self.world.moving_piston_fallback = previous
|
||||
.into_iter()
|
||||
.filter(|(pos, _)| !current.iter().any(|(next, _)| next == pos))
|
||||
.collect();
|
||||
self.world.moving_pistons = current;
|
||||
}
|
||||
/// `ServerLevel.tick`'s scheduled block then fluid ticks, one at a time:
|
||||
/// the side effects of each (a shrieker summoning a warden, drops, game
|
||||
@@ -2939,6 +3053,17 @@ impl Game<'_> {
|
||||
pos,
|
||||
data,
|
||||
} => self.smash_dust(pos, data),
|
||||
// LevelEventHandler 2002 and 2007: a potion's splash.
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2002,
|
||||
pos,
|
||||
data,
|
||||
} => self.potion_splash_particles(pos, data, false),
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2007,
|
||||
pos,
|
||||
data,
|
||||
} => self.potion_splash_particles(pos, data, true),
|
||||
LevelEvent::LevelEvent {
|
||||
id: id @ 3002..=3005,
|
||||
pos,
|
||||
@@ -2974,6 +3099,8 @@ impl Game<'_> {
|
||||
1044 => Some(("minecraft:block.smithing_table.use", 1.0)),
|
||||
// LevelEvent.SOUND_PAGE_TURN (a lectern's page).
|
||||
1043 => Some(("minecraft:item.book.page_turn", 1.0)),
|
||||
// LevelEvent.SOUND_SPLASH_POTION and its instant twin.
|
||||
1053 | 1054 => Some(("minecraft:entity.splash_potion.break", 1.0)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((name, volume)) = sound {
|
||||
@@ -2983,7 +3110,7 @@ impl Game<'_> {
|
||||
- self.world.random.next_float())
|
||||
* 0.8
|
||||
}
|
||||
1029 | 1030 | 1042 | 1043 | 1044 => {
|
||||
1029 | 1030 | 1042 | 1043 | 1044 | 1053 | 1054 => {
|
||||
self.world.random.next_float() * 0.1 + 0.9
|
||||
}
|
||||
1019 | 1021 => {
|
||||
@@ -2993,7 +3120,7 @@ impl Game<'_> {
|
||||
}
|
||||
_ => 1.0,
|
||||
};
|
||||
if matches!(id, 1019 | 1021 | 1029 | 1030) {
|
||||
if matches!(id, 1019 | 1021 | 1029 | 1030 | 1053 | 1054) {
|
||||
// Clients play the anvil sound from its level event;
|
||||
// capturing it also sends a duplicate sound packet.
|
||||
self.audio.play_unshared(name, c, volume, pitch);
|
||||
@@ -3042,11 +3169,9 @@ impl Game<'_> {
|
||||
drop_item: true,
|
||||
fall_distance: 0.0,
|
||||
stuck: Vec3::ZERO,
|
||||
damage: if self.res.facts().get(state).name.ends_with("anvil") {
|
||||
horizon_client::falling_block::Damage::anvil()
|
||||
} else {
|
||||
Default::default()
|
||||
},
|
||||
damage: horizon_client::falling_block::Damage::falling(
|
||||
&self.res.facts().get(state).name,
|
||||
),
|
||||
saved: None,
|
||||
});
|
||||
}
|
||||
@@ -3149,6 +3274,31 @@ impl Game<'_> {
|
||||
LevelEvent::BlockEntityEvent { pos, a, b, .. } => {
|
||||
self.block_entity_event([pos.x, pos.y, pos.z], a, b)
|
||||
}
|
||||
// NoteBlock.triggerEvent: a NOTE particle above, coloured by
|
||||
// the note (`note / 24.0` in the x speed).
|
||||
LevelEvent::BlockEvent {
|
||||
pos,
|
||||
state,
|
||||
kind: "note_particle",
|
||||
} => {
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
let note = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("note")?.parse::<f64>().ok())
|
||||
.unwrap_or(0.0);
|
||||
self.particles.add(
|
||||
&ParticleOptions::simple(ParticleType::Note),
|
||||
[
|
||||
f64::from(pos.x) + 0.5,
|
||||
f64::from(pos.y) + 1.2,
|
||||
f64::from(pos.z) + 0.5,
|
||||
],
|
||||
[note / 24.0, 0.0, 0.0],
|
||||
);
|
||||
}
|
||||
LevelEvent::GrowTree { .. }
|
||||
| LevelEvent::PlaceFeature { .. }
|
||||
| LevelEvent::BlockEvent { .. } => {}
|
||||
@@ -3524,7 +3674,7 @@ impl Game<'_> {
|
||||
} else if let Some(tag) = spill::saved_entity(&self.world, pos) {
|
||||
self.pending.record_saved(pos, &tag);
|
||||
}
|
||||
self.creaking_heart_broken(pos);
|
||||
self.creaking_heart_broken(pos, true);
|
||||
let hive = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::Beehive(h)) => self.hive_state(pos).map(|s| ((**h).clone(), s)),
|
||||
_ => None,
|
||||
@@ -4083,6 +4233,7 @@ impl Game<'_> {
|
||||
if &*stack.item == "minecraft:spyglass" {
|
||||
// SpyglassItem.USE_DURATION, then finishUsingItem.
|
||||
if ticks + 1 >= 1200 {
|
||||
self.send_use_item_complete();
|
||||
self.stop_spyglass();
|
||||
self.using = None;
|
||||
} else {
|
||||
@@ -4104,6 +4255,7 @@ impl Game<'_> {
|
||||
}
|
||||
if ticks >= duration {
|
||||
self.using = None;
|
||||
self.send_use_item_complete();
|
||||
// ItemStack.finishUsingItem's applyAfterUseComponentSideEffects.
|
||||
self.apply_use_cooldown(&stack);
|
||||
self.finish_consuming(&info, consumable, &stack);
|
||||
@@ -4316,6 +4468,12 @@ impl Game<'_> {
|
||||
// The world's spawn from the start, not only once saved: a bed set
|
||||
// before the first save must not become it.
|
||||
self.level.world_spawn.get_or_insert([x, y, z]);
|
||||
// The "Bonus Chest" create-world option, placed once with the spawn.
|
||||
if horizon_client::save::WorldGen::read(&self.world_root)
|
||||
.is_some_and(|g| g.bonus_chest)
|
||||
{
|
||||
self.place_bonus_chest([x, y, z])?;
|
||||
}
|
||||
}
|
||||
let s = self
|
||||
.level
|
||||
@@ -4419,7 +4577,11 @@ impl Game<'_> {
|
||||
self.world.weather.tick_levels();
|
||||
self.tick_weather_effects();
|
||||
}
|
||||
if self.ticks.is_multiple_of(6000) {
|
||||
self.autosaving = self.ticks.is_multiple_of(6000);
|
||||
if let Some(serving) = &self.serving {
|
||||
serving.set_saving(self.autosaving);
|
||||
}
|
||||
if self.autosaving {
|
||||
self.save_game_with(false)?;
|
||||
}
|
||||
if running {
|
||||
@@ -4630,9 +4792,32 @@ impl Game<'_> {
|
||||
};
|
||||
self.audio.play(name, Some(feet), volume, pitch);
|
||||
}
|
||||
// LivingEntity.checkFallDamage: the block's crumbs where the fall landed.
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Particles {
|
||||
state,
|
||||
position,
|
||||
count,
|
||||
speed,
|
||||
}) => self.landing_dust(None, state, position, count, speed),
|
||||
MotionEvent::Fall(event) => self.landing_block_updates(&event),
|
||||
MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity),
|
||||
MotionEvent::EnterBlock { state } => self.trigger_enter_block(state),
|
||||
MotionEvent::HoneySlide {
|
||||
sound, particles, ..
|
||||
} => {
|
||||
if sound {
|
||||
self.audio
|
||||
.play("minecraft:block.honey_block.slide", Some(feet), 1.0, 1.0);
|
||||
}
|
||||
if particles {
|
||||
self.honey_slide_particles(feet);
|
||||
// The server's event 53 reaches the guests too.
|
||||
let id = self.lan.as_ref().map(|h| h.host_entity);
|
||||
if let Some(id) = id {
|
||||
self.lan_entity_event(id, 53, feet);
|
||||
}
|
||||
}
|
||||
}
|
||||
MotionEvent::InsidePortal { pos } => self.player_inside_nether_portal(pos),
|
||||
MotionEvent::CauldronExtinguish { pos } => self.cauldron_extinguish(pos),
|
||||
MotionEvent::InsideWitherRose => self.wither_rose_contact(),
|
||||
@@ -4735,6 +4920,7 @@ impl Game<'_> {
|
||||
/// What the tick runs after the local player's own update: effects, the
|
||||
/// world's entities and blocks, and the network hub.
|
||||
fn tick_world(&mut self, running: bool) -> Result<()> {
|
||||
self.share_spawn_conditions();
|
||||
self.update_chunk_tickets();
|
||||
horizon_client::spans::time("tick_effects", || self.tick_effects(running));
|
||||
// Player.tick's ItemCooldowns.tick for the window's player (a
|
||||
@@ -4742,6 +4928,7 @@ impl Game<'_> {
|
||||
self.shield_cooldown = self.shield_cooldown.saturating_sub(1);
|
||||
self.cooldowns.tick();
|
||||
horizon_client::spans::time("tick_using", || self.tick_using());
|
||||
self.tick_bundle_hover();
|
||||
horizon_client::spans::time("tick_hud", || self.tick_hud());
|
||||
// The player's digging comes in with its packets, before the level
|
||||
// ticks; ServerLevel.tick then runs the scheduled and random block
|
||||
@@ -4944,6 +5131,9 @@ impl Game<'_> {
|
||||
| Screen::Advancements
|
||||
| Screen::WorldOptions
|
||||
| Screen::Friends
|
||||
| Screen::ServerLinks
|
||||
| Screen::ConfirmLink
|
||||
| Screen::DialogWarning
|
||||
| Screen::CommandBlock
|
||||
) || self.book.is_some()
|
||||
|| self.win_screen_pauses())
|
||||
@@ -4965,10 +5155,13 @@ impl Game<'_> {
|
||||
if capture && !self.focused {
|
||||
return;
|
||||
}
|
||||
if capture {
|
||||
// The inventory's screens are behind us: no drag survives them.
|
||||
self.reset_inventory_clicks();
|
||||
} else {
|
||||
// The container screen's press state belongs to the screen: a screen
|
||||
// that takes the pointer over starts with its own state, as
|
||||
// `AbstractContainerScreen` does, so a press whose release never
|
||||
// arrived (the pointer left the window) cannot wedge the next
|
||||
// screen's quick move and clicks.
|
||||
self.reset_inventory_clicks();
|
||||
if !capture {
|
||||
self.stop_mining();
|
||||
}
|
||||
// The real pointer takes over from an agent's.
|
||||
@@ -5427,6 +5620,9 @@ impl Game<'_> {
|
||||
Screen::Advancements => self.draw_advancements(&mut batch),
|
||||
Screen::WorldOptions => self.draw_world_options(&mut batch),
|
||||
Screen::Friends => self.draw_friends_screen(&mut batch),
|
||||
Screen::ServerLinks => self.draw_server_links(&mut batch),
|
||||
Screen::ConfirmLink => self.draw_confirm_link(&mut batch),
|
||||
Screen::DialogWarning => self.draw_dialog_warning(&mut batch),
|
||||
Screen::CommandBlock => self.draw_command_block(&mut batch),
|
||||
Screen::Inventory => {
|
||||
self.draw_inventory(&mut batch);
|
||||
@@ -5439,6 +5635,7 @@ impl Game<'_> {
|
||||
Screen::Mod => self.draw_mod_screen(&mut batch),
|
||||
Screen::Playing | Screen::Options | Screen::Win => {}
|
||||
}
|
||||
self.draw_saving_indicator(&mut batch);
|
||||
self.draw_toasts(&mut batch);
|
||||
self.draw_pack_toasts(&mut batch);
|
||||
self.remote_hud(&mut batch);
|
||||
@@ -5495,6 +5692,7 @@ impl Game<'_> {
|
||||
| BlockEntity::Vault(_)
|
||||
| BlockEntity::Beehive(_)
|
||||
| BlockEntity::CommandBlock(_)
|
||||
| BlockEntity::EndGateway(_)
|
||||
| BlockEntity::Skull(_)
|
||||
| BlockEntity::DaylightDetector
|
||||
| BlockEntity::Campfire(_),
|
||||
@@ -5767,6 +5965,7 @@ impl Game<'_> {
|
||||
| BlockEntity::Vault(_)
|
||||
| BlockEntity::Beehive(_)
|
||||
| BlockEntity::CommandBlock(_)
|
||||
| BlockEntity::EndGateway(_)
|
||||
| BlockEntity::Skull(_)
|
||||
| BlockEntity::DaylightDetector
|
||||
| BlockEntity::Campfire(_),
|
||||
@@ -5813,12 +6012,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
|
||||
/// belongs to on the screen; Horizon keeps them on the game, so they are
|
||||
/// dropped when a screen closes. A press whose release never reaches the
|
||||
/// screen (the pointer left the window, or the screen closed first) would
|
||||
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
|
||||
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
|
||||
/// changes hands: a screen that opens or closes starts with its own
|
||||
/// state, and a press whose release never reached the screen -- the
|
||||
/// pointer left the window, or the screen closed first -- would
|
||||
/// otherwise leave every later press on every later screen swallowed.
|
||||
pub(super) fn reset_inventory_clicks(&mut self) {
|
||||
self.inv_clicks = Default::default();
|
||||
self.inv_clicks.cancel_drag();
|
||||
self.inv_drag = None;
|
||||
self.last_inv_click = None;
|
||||
}
|
||||
@@ -5939,6 +6140,12 @@ impl Game<'_> {
|
||||
/// Several clicks on one menu instance (a quick craft drag is three).
|
||||
fn menu_ops(&mut self, ops: &[(Option<usize>, i32, horizon_client::menu::Input)]) -> bool {
|
||||
use horizon_client::menu::Input;
|
||||
// BundleMouseActions.onSlotClicked: a Shift click or a swap clears the bundle's selection.
|
||||
for &(slot, _, input) in ops {
|
||||
if let (Some(slot), Input::QuickMove | Input::Swap) = (slot, input) {
|
||||
self.unselect_bundle(slot);
|
||||
}
|
||||
}
|
||||
if self.remote.is_some() && self.screen == Screen::Inventory {
|
||||
// On a server the clicks are the server's: predicted and sent.
|
||||
return self.remote_menu_ops(ops);
|
||||
@@ -7264,7 +7471,12 @@ impl Game<'_> {
|
||||
self.see_through_vertices = see_through;
|
||||
// Entity shadows (EntityRenderer.extractShadow).
|
||||
self.mark_translucent_entity(out.len(), "minecraft:entity_shadow");
|
||||
if let Some(sprite) = self.gui.atlas.sprites.get("misc/shadow").copied() {
|
||||
let shadow_sprite = self
|
||||
.settings
|
||||
.entity_shadows
|
||||
.then(|| self.gui.atlas.sprites.get("misc/shadow").copied())
|
||||
.flatten();
|
||||
if let Some(sprite) = shadow_sprite {
|
||||
let ambient = if self.dim == Dim::Nether { 0.1 } else { 0.0 };
|
||||
let darken = self.sky_darken();
|
||||
let a = alpha;
|
||||
@@ -7442,6 +7654,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7663,7 +7877,7 @@ impl Game<'_> {
|
||||
/// strength and whether it is a critical hit): the weapon and effects
|
||||
/// scaled by the attack strength, a mace's smash, the critical hit, then
|
||||
/// enchantments.
|
||||
pub(super) fn melee_damage(&mut self, target_kind: &str) -> (f32, f32, bool) {
|
||||
pub(super) fn melee_damage(&mut self, target_kind: &str) -> (f32, f32, bool, bool) {
|
||||
let hand_damage = 1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage);
|
||||
// Riptide's spin replaces the attack damage attribute.
|
||||
let spin = self.player.as_ref().map_or(0.0, |p| p.auto_spin_attack_dmg);
|
||||
@@ -7691,8 +7905,9 @@ impl Game<'_> {
|
||||
amount *= 1.5;
|
||||
}
|
||||
// Enchantment damage (sharpness, smite, bane) scaled by the attack strength.
|
||||
amount += self.enchant_damage_bonus(target_kind) * scale;
|
||||
(amount, scale, crit)
|
||||
let magic_boost = self.enchant_damage_bonus(target_kind) * scale;
|
||||
amount += magic_boost;
|
||||
(amount, scale, crit, magic_boost > 0.0)
|
||||
}
|
||||
/// `Player.getAttackStrengthScale(0.5)`.
|
||||
fn attack_strength_scale(&self) -> f32 {
|
||||
@@ -7879,7 +8094,7 @@ impl Game<'_> {
|
||||
pub(super) fn attack_mob(&mut self, index: usize) {
|
||||
let target_kind = self.mobs[index].kind.id.clone();
|
||||
let target_kind = format!("minecraft:{}", target_kind.trim_start_matches("minecraft:"));
|
||||
let (amount, scale, crit) = self.melee_damage(&target_kind);
|
||||
let (amount, scale, crit, magic) = self.melee_damage(&target_kind);
|
||||
let sweep = self.sweep_attack_position(scale, crit);
|
||||
let (knockback, knockback_attack) = self.extra_knockback(&target_kind, scale);
|
||||
self.attack_ticks = 0;
|
||||
@@ -7942,6 +8157,18 @@ impl Game<'_> {
|
||||
feet,
|
||||
width,
|
||||
height,
|
||||
false,
|
||||
);
|
||||
}
|
||||
// Player.attackVisualEffects: magicCrit when the enchantments added damage.
|
||||
if magic {
|
||||
self.critical_hit(
|
||||
self.lan_acting,
|
||||
self.mobs[index].id as i32,
|
||||
feet,
|
||||
width,
|
||||
height,
|
||||
true,
|
||||
);
|
||||
}
|
||||
if actual > 2.0 {
|
||||
@@ -8064,6 +8291,8 @@ impl Game<'_> {
|
||||
layer: 2,
|
||||
color,
|
||||
light: 0,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -8170,6 +8399,7 @@ impl Game<'_> {
|
||||
);
|
||||
self.session_lock = Some(session_lock);
|
||||
self.end.set_locator(locator);
|
||||
self.share_spawn_conditions();
|
||||
self.score = Default::default();
|
||||
self.fns = Default::default();
|
||||
self.bosses = Default::default();
|
||||
@@ -8308,6 +8538,7 @@ impl Game<'_> {
|
||||
seed,
|
||||
)?;
|
||||
self.end.set_locator(locator);
|
||||
self.share_spawn_conditions();
|
||||
Ok((streaming, save))
|
||||
}
|
||||
/// Change dimension through a portal (Entity.changeDimension with
|
||||
@@ -9603,7 +9834,7 @@ impl Game<'_> {
|
||||
velocity,
|
||||
on_ground,
|
||||
);
|
||||
self.creatures.projectiles.push(arrow);
|
||||
self.add_projectile(arrow);
|
||||
self.player_projectile_shot();
|
||||
let pitch = 1.0 / (self.world.random.next_float() * 0.4 + 1.2) + power * 0.5;
|
||||
self.audio
|
||||
@@ -10647,6 +10878,21 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
// A server pack's reload covers the frame with its progress
|
||||
// (`LoadingOverlay`) until `tick_server_packs` applies it.
|
||||
if let Some(reload) = &self.server_packs.reload {
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]);
|
||||
horizon_client::level_loading::draw_progress(
|
||||
&mut hud,
|
||||
&self.gui.atlas,
|
||||
reload.progress.line(),
|
||||
reload.progress.fraction(),
|
||||
);
|
||||
return renderer.render(&loading_frame(&hud));
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(size) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
@@ -10792,6 +11038,17 @@ impl Game<'_> {
|
||||
[0.0; 3]
|
||||
};
|
||||
let p = h.pos.map(f64::from);
|
||||
if let Some(b) = self.context_outline(h.pos, state) {
|
||||
return horizon_client::blocks::cube_edges()
|
||||
.iter()
|
||||
.map(|e| {
|
||||
std::array::from_fn(|i| {
|
||||
let k = i % 3;
|
||||
p[k] + b[k] + e[i] * (b[k + 3] - b[k])
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
f.outline_edges
|
||||
.iter()
|
||||
.map(|e| std::array::from_fn(|i| p[i % 3] + o[i % 3] + e[i]))
|
||||
@@ -10956,6 +11213,8 @@ impl Game<'_> {
|
||||
debug_lines_on_top: &debug_lines_on_top,
|
||||
debug_crosshair: &debug_crosshair,
|
||||
hud: &hud,
|
||||
glint_alpha: self.settings.file.double("glintStrength") as f32,
|
||||
glint_ticks: self.glint_ticks(),
|
||||
clouds: (!title
|
||||
&& self.dim == Dim::Overworld
|
||||
&& horizon_client::clouds::visible(&self.settings.clouds, sky.cloud_color[3]))
|
||||
@@ -11183,6 +11442,10 @@ impl Game<'_> {
|
||||
Screen::GameModeSwitcher => self.close_gamemode_switcher(false),
|
||||
Screen::Dead => {}
|
||||
Screen::Stats | Screen::Friends => self.screen = Screen::Paused,
|
||||
Screen::ServerLinks => self.screen = Screen::Paused,
|
||||
// ConfirmScreen.keyPressed: Escape answers no; both return to
|
||||
// the Server Links screen they came from.
|
||||
Screen::ConfirmLink | Screen::DialogWarning => self.screen = Screen::ServerLinks,
|
||||
Screen::Advancements => self.close_advancements(),
|
||||
Screen::WorldOptions => self.world_options_escape(),
|
||||
Screen::CommandBlock => self.close_command_block(),
|
||||
@@ -11251,8 +11514,10 @@ impl Game<'_> {
|
||||
// key.swapOffhand: on a server, ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND
|
||||
// (the server swaps the hands and sends both slots back; spectators can't).
|
||||
KeyCode::KeyF if self.screen == Screen::Playing && self.remote.is_some() => {
|
||||
if !self.spectator() {
|
||||
self.remote_swap_hands();
|
||||
if !self.spectator()
|
||||
&& let Some(r) = self.remote.as_mut()
|
||||
{
|
||||
r.pending_keys.push(key);
|
||||
}
|
||||
}
|
||||
KeyCode::KeyF if self.screen == Screen::Playing => {
|
||||
@@ -11423,6 +11688,7 @@ impl Game<'_> {
|
||||
self.tick_pregen();
|
||||
let (uploads, unloaded) =
|
||||
horizon_client::spans::time("streaming", || self.streaming.update(&mut self.world));
|
||||
self.spawn_fresh_chunks();
|
||||
if let Some(lod) = &mut self.lod {
|
||||
self.lod_observe
|
||||
.extend(uploads.iter().map(|(key, _)| (key.0, key.2)));
|
||||
@@ -11696,6 +11962,19 @@ impl ApplicationHandler for Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
// The pack reload's progress overlay takes no input
|
||||
// (`LoadingOverlay`): `start_pack_reload` released what was held.
|
||||
if self.server_packs.reload.is_some()
|
||||
&& !matches!(
|
||||
&event,
|
||||
WindowEvent::CloseRequested
|
||||
| WindowEvent::Focused(_)
|
||||
| WindowEvent::Resized(_)
|
||||
| WindowEvent::RedrawRequested
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self.pending_save.is_some() {
|
||||
match event {
|
||||
WindowEvent::CloseRequested => {
|
||||
@@ -11852,6 +12131,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
if self.screen == Screen::Inventory
|
||||
&& d != 0.0
|
||||
&& !self.bundle_scroll(scaled(raw_x), scaled(raw_y))
|
||||
&& !self.tweak_scroll(d, self.inventory_target().0)
|
||||
{
|
||||
self.station_scroll(d);
|
||||
@@ -11862,6 +12142,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
if self.screen == Screen::WorldOptions && d != 0.0 {
|
||||
self.world_options_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::ServerLinks && d != 0.0 {
|
||||
self.server_links_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::Advancements {
|
||||
self.advancements_scroll(scaled(raw_x) as f32, d);
|
||||
}
|
||||
@@ -12053,6 +12336,15 @@ impl ApplicationHandler for Game<'_> {
|
||||
Screen::WorldOptions if pressed && button == MouseButton::Left => {
|
||||
self.world_options_click()
|
||||
}
|
||||
Screen::ServerLinks if pressed && button == MouseButton::Left => {
|
||||
self.server_links_click()?
|
||||
}
|
||||
Screen::ConfirmLink if pressed && button == MouseButton::Left => {
|
||||
self.confirm_link_click()
|
||||
}
|
||||
Screen::DialogWarning if pressed && button == MouseButton::Left => {
|
||||
self.dialog_warning_click()?
|
||||
}
|
||||
Screen::WorldOptions if button == MouseButton::Left => {
|
||||
self.world_options_release()
|
||||
}
|
||||
@@ -12641,6 +12933,7 @@ impl<'a> Game<'a> {
|
||||
craft_table: None,
|
||||
inv_drag: None,
|
||||
tweaks: Default::default(),
|
||||
bundle_mouse: Default::default(),
|
||||
inv_clicks: Default::default(),
|
||||
last_inv_click: None,
|
||||
last_quick_moved: None,
|
||||
@@ -12702,7 +12995,14 @@ impl<'a> Game<'a> {
|
||||
level,
|
||||
session_lock: None,
|
||||
audio,
|
||||
music: Default::default(),
|
||||
music: {
|
||||
// The manager starts with the saved option (MusicManager's constructor).
|
||||
let mut music = horizon_client::music::MusicManager::default();
|
||||
music.frequency = horizon_client::music::MusicFrequency::parse(
|
||||
&settings.file.name("musicFrequency"),
|
||||
);
|
||||
music
|
||||
},
|
||||
ambience: Default::default(),
|
||||
sound_paused: false,
|
||||
cart_sounds: HashSet::new(),
|
||||
@@ -12746,6 +13046,8 @@ impl<'a> Game<'a> {
|
||||
command_source: None,
|
||||
titles: Default::default(),
|
||||
tool_highlight: Default::default(),
|
||||
saving_indicator: Default::default(),
|
||||
autosaving: false,
|
||||
applied_modifiers: Vec::new(),
|
||||
experience: Experience::default(),
|
||||
orbs: Vec::new(),
|
||||
@@ -12824,6 +13126,7 @@ impl<'a> Game<'a> {
|
||||
.unwrap_or_default()
|
||||
.join("assets/skins"),
|
||||
)),
|
||||
skin_profiles: RefCell::new(horizon_client::skins::Resolver::new()),
|
||||
skin_slots: Vec::new(),
|
||||
trim_slots: Vec::new(),
|
||||
cart_models: prepared.cart_models,
|
||||
@@ -12890,6 +13193,7 @@ impl<'a> Game<'a> {
|
||||
forced_hit: None,
|
||||
guest_use: None,
|
||||
lan_events: Vec::new(),
|
||||
lan_cushion_breaks: Vec::new(),
|
||||
lan_block_events: Vec::new(),
|
||||
lan_entity_events: Vec::new(),
|
||||
lan_critical_hits: Vec::new(),
|
||||
@@ -12898,6 +13202,7 @@ impl<'a> Game<'a> {
|
||||
lan_acting: None,
|
||||
guest_packets: Vec::new(),
|
||||
guest_cooldowns: Vec::new(),
|
||||
guest_entity_events: Vec::new(),
|
||||
lan_acting_name: None,
|
||||
lan_acting_uuid: None,
|
||||
chat_capture: None,
|
||||
@@ -12924,6 +13229,7 @@ impl<'a> Game<'a> {
|
||||
restore_player_state: true,
|
||||
stats_ui: Default::default(),
|
||||
adv_ui: Default::default(),
|
||||
links_ui: Default::default(),
|
||||
camera_type: Default::default(),
|
||||
fangs: Default::default(),
|
||||
player_model: None,
|
||||
@@ -13166,7 +13472,6 @@ const NUMERIC_KEYS: [KeyCode; 11] = [
|
||||
KeyCode::Numpad9,
|
||||
];
|
||||
|
||||
const STAGES: u32 = 10;
|
||||
const STAGE_LINES: [&str; 10] = [
|
||||
"Loading menu",
|
||||
"Loading assets",
|
||||
@@ -13182,19 +13487,28 @@ const STAGE_LINES: [&str; 10] = [
|
||||
|
||||
/// The background loader's checkpoint, read by the loading screen each frame.
|
||||
#[derive(Clone)]
|
||||
struct Progress(Arc<AtomicU32>);
|
||||
struct Progress {
|
||||
value: Arc<AtomicU32>,
|
||||
lines: &'static [&'static str],
|
||||
}
|
||||
impl Progress {
|
||||
fn new() -> Self {
|
||||
Self(Arc::new(AtomicU32::new(0)))
|
||||
Self::with(&STAGE_LINES)
|
||||
}
|
||||
fn with(lines: &'static [&'static str]) -> Self {
|
||||
Self {
|
||||
value: Arc::new(AtomicU32::new(0)),
|
||||
lines,
|
||||
}
|
||||
}
|
||||
fn set(&self, stage: u32) {
|
||||
self.0.store(stage, Ordering::Relaxed);
|
||||
self.value.store(stage, Ordering::Relaxed);
|
||||
}
|
||||
fn fraction(&self) -> f32 {
|
||||
self.0.load(Ordering::Relaxed) as f32 / (STAGES - 1) as f32
|
||||
self.value.load(Ordering::Relaxed) as f32 / (self.lines.len() - 1) as f32
|
||||
}
|
||||
fn line(&self) -> &'static str {
|
||||
STAGE_LINES[(self.0.load(Ordering::Relaxed) as usize).min(STAGE_LINES.len() - 1)]
|
||||
self.lines[(self.value.load(Ordering::Relaxed) as usize).min(self.lines.len() - 1)]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13554,16 +13868,22 @@ fn build_renderer(
|
||||
&assets.sprite_mips,
|
||||
&assets.animations,
|
||||
(&atlas.pixels, atlas.width, atlas.height),
|
||||
(&atlas.glint.0, atlas.glint.1, atlas.glint.2),
|
||||
(
|
||||
&atlas.glint_armor.0,
|
||||
atlas.glint_armor.1,
|
||||
atlas.glint_armor.2,
|
||||
),
|
||||
(&pack.forcefield.2, pack.forcefield.0),
|
||||
&gui.panorama,
|
||||
prepared,
|
||||
previous,
|
||||
api,
|
||||
))?;
|
||||
renderer.hud.fire_animations = atlas
|
||||
.fire_animations
|
||||
renderer.hud.sprite_animations = atlas
|
||||
.sprite_animations
|
||||
.iter()
|
||||
.map(|(rect, animation)| (*rect, animation.clone(), usize::MAX))
|
||||
.map(|(rect, animation)| (*rect, animation.clone(), (usize::MAX, 0, 0)))
|
||||
.collect();
|
||||
Ok(renderer)
|
||||
}
|
||||
@@ -13580,6 +13900,7 @@ fn loading_renderer(
|
||||
) -> Result<Renderer> {
|
||||
static FORCEFIELD: [u8; 4] = [0; 4];
|
||||
static EMPTY_HUD: [u8; 4] = [0, 0, 0, 255];
|
||||
static EMPTY_GLINT: [u8; 4] = [0; 4];
|
||||
let empty_panorama = horizon_client::gfx::panorama::Faces {
|
||||
size: 1,
|
||||
pixels: [0u8; 4].repeat(6),
|
||||
@@ -13590,6 +13911,10 @@ fn loading_renderer(
|
||||
Some(atlas) => (atlas.pixels.as_slice(), atlas.width, atlas.height),
|
||||
None => (EMPTY_HUD.as_slice(), 1, 1),
|
||||
};
|
||||
let (glint_pixels, glint_width, glint_height) = match atlas {
|
||||
Some(atlas) => (atlas.glint.0.as_slice(), atlas.glint.1, atlas.glint.2),
|
||||
None => (EMPTY_GLINT.as_slice(), 1, 1),
|
||||
};
|
||||
// A glyph rasterized into an earlier texture is not in these pixels; the
|
||||
// new texture gets it again when the character is drawn (see
|
||||
// `build_renderer`).
|
||||
@@ -13606,6 +13931,8 @@ fn loading_renderer(
|
||||
&[],
|
||||
&[],
|
||||
(pixels, width, height),
|
||||
(glint_pixels, glint_width, glint_height),
|
||||
(glint_pixels, glint_width, glint_height),
|
||||
(&FORCEFIELD[..], 1),
|
||||
panorama,
|
||||
None,
|
||||
@@ -13613,10 +13940,10 @@ fn loading_renderer(
|
||||
api,
|
||||
))?;
|
||||
if let Some(atlas) = atlas {
|
||||
renderer.hud.fire_animations = atlas
|
||||
.fire_animations
|
||||
renderer.hud.sprite_animations = atlas
|
||||
.sprite_animations
|
||||
.iter()
|
||||
.map(|(rect, animation)| (*rect, animation.clone(), usize::MAX))
|
||||
.map(|(rect, animation)| (*rect, animation.clone(), (usize::MAX, 0, 0)))
|
||||
.collect();
|
||||
}
|
||||
Ok(renderer)
|
||||
@@ -13652,6 +13979,8 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
hud,
|
||||
glint_alpha: 0.75,
|
||||
glint_ticks: 0.0,
|
||||
nether_lighting: false,
|
||||
entities: &[],
|
||||
entity_spans: &[],
|
||||
|
||||
@@ -419,6 +419,9 @@ impl Game<'_> {
|
||||
Screen::Advancements => "advancements",
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::ServerLinks => "server-links",
|
||||
Screen::ConfirmLink => "confirm-link",
|
||||
Screen::DialogWarning => "dialog-warning",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
Screen::Mod => "mod",
|
||||
@@ -561,10 +564,26 @@ impl Game<'_> {
|
||||
"hardcore": options.hardcore,
|
||||
"difficulty": horizon_client::title::DIFFICULTIES[options.difficulty()],
|
||||
"allow_commands": options.commands(*creative),
|
||||
"superflat": options.flat,
|
||||
"world_type": options.world_type.id(),
|
||||
"generate_structures": options.structures,
|
||||
"bonus_chest": options.bonus_chest(),
|
||||
}),
|
||||
Mode::CreateFlat {
|
||||
name,
|
||||
seed,
|
||||
options,
|
||||
flat,
|
||||
..
|
||||
} => json!({
|
||||
"mode": "customize-flat",
|
||||
"fields": { "name": name, "seed": seed },
|
||||
"superflat": options.world_type == horizon_client::save::WorldPreset::Flat,
|
||||
"biome": flat.settings().biome,
|
||||
"layers": flat.settings().layers.iter().map(|layer| json!({
|
||||
"block": layer.block,
|
||||
"height": layer.height,
|
||||
})).collect::<Vec<_>>(),
|
||||
}),
|
||||
Mode::Edit {
|
||||
world,
|
||||
name,
|
||||
@@ -707,9 +726,12 @@ impl Game<'_> {
|
||||
// the recipe buttons of the page, page arrows, filter and tabs.
|
||||
let book = self.recipe_book_targets();
|
||||
if !book.is_empty() {
|
||||
let (open, filtering) = self.recipe_book_flags().unwrap_or_default();
|
||||
v["recipe_book"] = json!({
|
||||
"targets": book.iter().map(|t| t.rect).collect::<Vec<_>>(),
|
||||
"known": self.recipe_book_known(),
|
||||
"open": open,
|
||||
"filtering": filtering,
|
||||
});
|
||||
}
|
||||
v["slot_ids"] = json!(
|
||||
|
||||
@@ -711,6 +711,20 @@ impl Game<'_> {
|
||||
let color = undyed.map_or(0xFFFF_FFFF, |c| {
|
||||
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
|
||||
});
|
||||
// ArmorStandRenderer's HumanoidArmorLayer: the armour glint
|
||||
// when `stack.hasFoil()`, as on a living entity.
|
||||
let item_glint = self
|
||||
.res
|
||||
.items()
|
||||
.enchantments
|
||||
.props
|
||||
.get(&stack.item)
|
||||
.is_some_and(|p| p.glint);
|
||||
let overlay = if stack.has_foil(item_glint) {
|
||||
horizon_client::mobs::render::ARMOR_GLINT
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let layer_poses = Poses::new(part);
|
||||
horizon_client::mobs::render::draw(
|
||||
part,
|
||||
@@ -719,7 +733,7 @@ impl Game<'_> {
|
||||
uv,
|
||||
color,
|
||||
packed,
|
||||
0,
|
||||
overlay,
|
||||
out,
|
||||
);
|
||||
// EquipmentLayerRenderer: the trim layer over the armour.
|
||||
@@ -734,7 +748,7 @@ impl Game<'_> {
|
||||
trim,
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
overlay,
|
||||
out,
|
||||
);
|
||||
}
|
||||
@@ -877,6 +891,7 @@ impl Game<'_> {
|
||||
lights: Some(horizon_client::block_entity_render::diffuse_lights(
|
||||
self.dim == super::Dim::Nether,
|
||||
)),
|
||||
glint: self.glint(),
|
||||
};
|
||||
let profile = stack
|
||||
.extra
|
||||
|
||||
@@ -725,21 +725,38 @@ impl Game<'_> {
|
||||
/// the player head at `pos`. The skin download is started here; while it
|
||||
/// is in flight (or when it cannot be got) the profile's default skin
|
||||
/// stands in. A head with no profile answers None: `SKIN_BY_TYPE`.
|
||||
pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option<&str> {
|
||||
pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option<String> {
|
||||
let Some(BlockEntity::Skull(skull)) =
|
||||
self.world.block_entities.get(&(pos[0], pos[1], pos[2]))
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let skin = skull.skin.as_ref()?;
|
||||
if let Some((url, location)) = &skin.download {
|
||||
self.skin_downloads
|
||||
.borrow_mut()
|
||||
.state(skins::Kind::Skin, url);
|
||||
if self.gui.atlas.sprites.contains_key(location) {
|
||||
// The texture a packed or resolved `textures` property fetches: the
|
||||
// download of a static profile, or the one the services answer for a
|
||||
// `Dynamic` (name- or id-only) profile.
|
||||
let download = skin.download.clone().or_else(|| {
|
||||
let key = skin.dynamic.as_ref()?;
|
||||
let skins::Resolve::Done(Some(profile)) = self.skin_profiles.borrow_mut().state(key)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let (value, signature) = profile.textures()?;
|
||||
let url = skins::unpack(&value, signature, &skins::TEXTURE_URIS).skin?;
|
||||
let location = skins::skin_location(&skins::texture_hash(&url)?);
|
||||
Some((url, location))
|
||||
});
|
||||
if let Some((url, location)) = download {
|
||||
let mut downloads = self.skin_downloads.borrow_mut();
|
||||
if self.gui.atlas.sprites.contains_key(&location) {
|
||||
let _ = downloads.state(skins::Kind::Skin, &url);
|
||||
return Some(location);
|
||||
}
|
||||
// Its slot was evicted: offer the image again.
|
||||
if downloads.state(skins::Kind::Skin, &url) == skins::Download::Ready {
|
||||
downloads.again(&location);
|
||||
}
|
||||
}
|
||||
Some(skin.fallback)
|
||||
Some(skin.fallback.to_owned())
|
||||
}
|
||||
}
|
||||
@@ -862,7 +862,7 @@ impl Game<'_> {
|
||||
let a = alpha as f32;
|
||||
let range = super::entity_culling::default_range(
|
||||
glam::DVec3::new(WIDTH, HEIGHT, WIDTH),
|
||||
self.settings.entity_distance,
|
||||
self.view_scale(),
|
||||
);
|
||||
for b in &self.boats {
|
||||
let entity_start = out.len();
|
||||
|
||||
@@ -1009,9 +1009,12 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
let indicator = |batch: &mut HudBatch, page: usize, count: usize| {
|
||||
let text = tr("book.pageIndicator", "Page %s of %s")
|
||||
.replacen("%s", &(page + 1).to_string(), 1)
|
||||
.replacen("%s", &count.max(1).to_string(), 1);
|
||||
// BookViewScreen.getPageNumberMessage: 26.3's en_us template is
|
||||
// positional ("Page %1$s of %2$s").
|
||||
let text = horizon_protocol::text::format_java(
|
||||
&tr("book.pageIndicator", "Page %1$s of %2$s"),
|
||||
&[(page + 1).to_string(), count.max(1).to_string()],
|
||||
);
|
||||
let tw = HudBatch::text_width(atlas, &text);
|
||||
batch.text(atlas, &text, left + 148.0 - tw, top + 16.0, BLACK, false);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! `BundleMouseActions`: the wheel over a bundle in a container screen selects
|
||||
//! one of the items it shows, which a click that takes one out of it then takes
|
||||
//! (`BundleContents.Mutable.removeOne`). Moving off the bundle clears the
|
||||
//! selection. On a server the selection is sent to it (`ServerboundSelectBundleItemPacket`).
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
bundle::{Contents, toggle_selected},
|
||||
mouse::ScrollWheelHandler,
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct BundleMouse {
|
||||
wheel: ScrollWheelHandler,
|
||||
/// The slot under the cursor at the last tick (`AbstractContainerScreen.hoveredSlot`).
|
||||
hovered: Option<usize>,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A slot a bundle can be selected in: the player's inventory, the off hand and a
|
||||
/// container's own slots. A server only knows these in the inventory and chest-like menus.
|
||||
fn bundle_slot(&mut self, slot: usize) -> bool {
|
||||
(slot < 36 || slot == 504 || (300..500).contains(&slot))
|
||||
&& (self.remote.is_none() || self.remote_storage_open())
|
||||
&& (slot < 36 || slot == 504 || self.slots().contains(&slot))
|
||||
}
|
||||
|
||||
/// `BundleMouseActions.onMouseScrolled`: the wheel over a bundle moves the selection
|
||||
/// through the items its tooltip shows. True when the bundle took the wheel.
|
||||
pub(super) fn bundle_scroll(&mut self, x: f64, y: f64) -> bool {
|
||||
let Some(slot) = self.inventory_target().0 else {
|
||||
return false;
|
||||
};
|
||||
if !self.bundle_slot(slot) {
|
||||
return false;
|
||||
}
|
||||
let stack = self.slot_mut(slot).clone();
|
||||
let items = self.res.items();
|
||||
let Some(bundle) = stack.filter(|s| items.get(&s.item).is_some_and(|i| i.bundle)) else {
|
||||
return false;
|
||||
};
|
||||
let shown = Contents::of(items, &bundle).map_or(0, |c| c.shown_count());
|
||||
if shown == 0 {
|
||||
return false;
|
||||
}
|
||||
let (wheel_x, wheel_y) = self.bundle_mouse.wheel.on_mouse_scroll(x, y);
|
||||
let wheel = if wheel_y == 0 { -wheel_x } else { wheel_y };
|
||||
if wheel != 0 {
|
||||
let selected = bundle.data().map_or(-1, |d| d.bundle_selected.0);
|
||||
let next = ScrollWheelHandler::next_selection(f64::from(wheel), selected, shown as i32);
|
||||
if next != selected {
|
||||
self.select_bundle(slot, next);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `BundleMouseActions.toggleSelectedBundleItem`: the stack here and, on a server, there.
|
||||
fn select_bundle(&mut self, slot: usize, index: i32) {
|
||||
if let Some(stack) = self.slot_mut(slot) {
|
||||
toggle_selected(stack, index);
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
let (_, menu) = self.remote_menu();
|
||||
if let Some(menu_slot) = Self::menu_slot_index(&menu, slot) {
|
||||
self.send(S::SelectBundleItem(play::SelectBundleItem {
|
||||
slot: menu_slot as i32,
|
||||
index,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `BundleMouseActions.unselectedBundleItem` for the bundle in `slot`, if it has a selection.
|
||||
pub(super) fn unselect_bundle(&mut self, slot: usize) {
|
||||
if !self.bundle_slot(slot) {
|
||||
return;
|
||||
}
|
||||
let selected = self
|
||||
.slot_mut(slot)
|
||||
.as_ref()
|
||||
.and_then(|s| s.data())
|
||||
.is_some_and(|d| d.bundle_selected.0 >= 0);
|
||||
if selected {
|
||||
self.select_bundle(slot, -1);
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractContainerScreen.onStopHovering` (and `onClose`): leaving a slot, or the
|
||||
/// screen, clears the bundle's selection.
|
||||
pub(super) fn tick_bundle_hover(&mut self) {
|
||||
let now = if self.screen == Screen::Inventory {
|
||||
self.inventory_target().0
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(left) = std::mem::replace(&mut self.bundle_mouse.hovered, now)
|
||||
&& Some(left) != now
|
||||
{
|
||||
self.unselect_bundle(left);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,12 @@
|
||||
//! on the game that has the window, in-frame or following a server, never on
|
||||
//! a server: nothing of them goes over the network.
|
||||
use super::Game;
|
||||
use horizon_client::blocks::RenderShape;
|
||||
use horizon_client::mobs::{MobEvent, ambient};
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
use horizon_client::motion::{LAVA, WATER};
|
||||
use horizon_client::particles::{ParticleEngine, ParticleOptions, ParticleType};
|
||||
use horizon_client::pose::Pose;
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn tick_client_ambient(&mut self) {
|
||||
@@ -33,5 +37,209 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.eye_particles();
|
||||
self.sprint_dust();
|
||||
self.effect_swirls();
|
||||
}
|
||||
|
||||
/// `Entity.baseTick`'s `spawnSprintParticle` on a client: dust of the
|
||||
/// block under a sprinting player's feet streams back from them every
|
||||
/// tick, for the window's own player and for the other players it sees.
|
||||
fn sprint_dust(&mut self) {
|
||||
// Each runner's feet, movement and supporting block.
|
||||
let mut runners: Vec<(Vec3, Vec3, Option<[i32; 3]>)> = Vec::new();
|
||||
// Entity.canSpawnSprintParticle.
|
||||
if let Some(p) = self.player.as_ref()
|
||||
&& p.sprinting
|
||||
&& !p.spectator
|
||||
&& p.pose != Pose::Crouching
|
||||
&& !p.health.dead_or_dying()
|
||||
&& !p.fluids.was_touching_water
|
||||
&& !p.is_in_lava()
|
||||
&& p.fluid_height(WATER) <= 0.0
|
||||
&& p.fluid_height(LAVA) <= 0.0
|
||||
{
|
||||
let support = p.ground.supporting_block.map(|b| [b.x, b.y, b.z]);
|
||||
runners.push((p.position, p.velocity, support));
|
||||
}
|
||||
let guests = self.lan.iter().flat_map(|host| host.models.values());
|
||||
for m in self.mobs.iter().chain(guests) {
|
||||
// Another player's fluids aren't known here: none where they stand.
|
||||
let feet = [
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
];
|
||||
if m.extra.sprinting
|
||||
&& !m.extra.crouching
|
||||
&& m.alive()
|
||||
&& &*m.kind.id == "minecraft:player"
|
||||
&& self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(feet[0], feet[1], feet[2]))
|
||||
.fluid_level
|
||||
== 0
|
||||
{
|
||||
runners.push((m.position, m.position.subtract(m.previous), None));
|
||||
}
|
||||
}
|
||||
for (position, movement, support) in runners {
|
||||
// Entity.getOnPosLegacy: getOnPos(0.2).
|
||||
let below = (position.y - f64::from(0.2f32)).floor() as i32;
|
||||
let tall = |game: &Self, s: [i32; 3]| {
|
||||
let name = game.block_name(s);
|
||||
[
|
||||
"minecraft:fences",
|
||||
"minecraft:walls",
|
||||
"minecraft:fence_gates",
|
||||
]
|
||||
.iter()
|
||||
.any(|tag| game.block_in_tag(tag, &name))
|
||||
};
|
||||
let at = match support {
|
||||
Some(s) if tall(self, s) => s,
|
||||
Some(s) => [s[0], below, s[2]],
|
||||
None => [position.x.floor() as i32, below, position.z.floor() as i32],
|
||||
};
|
||||
let state = self.world.block(at[0], at[1], at[2]);
|
||||
if self.res.facts().get(state).render_shape == RenderShape::Invisible {
|
||||
continue;
|
||||
}
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
|
||||
continue;
|
||||
};
|
||||
let width = f64::from(0.6f32);
|
||||
let r = self.particles.random();
|
||||
let mut x = position.x + (r.next_double() - 0.5) * width;
|
||||
let mut z = position.z + (r.next_double() - 0.5) * width;
|
||||
// Clamped into the block when the feet are in another column.
|
||||
if position.x.floor() as i32 != at[0] {
|
||||
x = x.clamp(f64::from(at[0]), f64::from(at[0]) + 1.0);
|
||||
}
|
||||
if position.z.floor() as i32 != at[2] {
|
||||
z = z.clamp(f64::from(at[2]), f64::from(at[2]) + 1.0);
|
||||
}
|
||||
self.particles.add(
|
||||
&ParticleOptions::block(ParticleType::Block, sprite, tint),
|
||||
[x, position.y + 0.1, z],
|
||||
[movement.x * -4.0, 1.5, movement.z * -4.0],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.tickEffects` on a client: the swirls around an entity
|
||||
/// with visible effects, from the particles its synched data lists. The
|
||||
/// window's player has its own effects, a server's mobs and players the
|
||||
/// list the server sent, and the mobs and guests of a game in the frame
|
||||
/// theirs.
|
||||
fn effect_swirls(&mut self) {
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
let effects = &self.potions.effects.list;
|
||||
let list: Vec<_> = effects
|
||||
.iter()
|
||||
.filter(|e| e.visible)
|
||||
.filter_map(|e| effect_particle(e.id, e.ambient))
|
||||
.collect();
|
||||
let ambient = effects.iter().filter(|e| e.visible).all(|e| e.ambient);
|
||||
let invisible = effects.iter().any(|e| e.id == "invisibility");
|
||||
let size = (
|
||||
f64::from(p.dimensions.width),
|
||||
f64::from(p.dimensions.height),
|
||||
);
|
||||
swirl(
|
||||
&mut self.particles,
|
||||
p.position,
|
||||
size,
|
||||
&list,
|
||||
ambient,
|
||||
invisible,
|
||||
);
|
||||
}
|
||||
let guests = self.lan.iter().flat_map(|host| host.models.values());
|
||||
for m in self.mobs.iter().chain(guests) {
|
||||
if !m.alive() {
|
||||
continue;
|
||||
}
|
||||
let size = (f64::from(m.width()), f64::from(m.height()));
|
||||
let invisible = m.invisible || m.has_effect("minecraft:invisibility");
|
||||
if m.extra.effect_particles.is_empty() {
|
||||
// A mob of this game: its own effects, visible and not ambient.
|
||||
if m.extra.effects.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let list: Vec<_> = m
|
||||
.extra
|
||||
.effects
|
||||
.iter()
|
||||
.filter(|e| !m.extra.hidden_effects.contains(&e.0))
|
||||
.filter_map(|e| effect_particle(e.0, false))
|
||||
.collect();
|
||||
swirl(
|
||||
&mut self.particles,
|
||||
m.position,
|
||||
size,
|
||||
&list,
|
||||
false,
|
||||
invisible,
|
||||
);
|
||||
} else {
|
||||
let list = &m.extra.effect_particles;
|
||||
let ambient = m.extra.effect_ambient;
|
||||
swirl(
|
||||
&mut self.particles,
|
||||
m.position,
|
||||
size,
|
||||
list,
|
||||
ambient,
|
||||
invisible,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `MobEffect.particleFactory`: the omens have particles of their own, any
|
||||
/// other effect an entity effect in its colour, faint when ambient.
|
||||
pub(super) fn effect_particle(effect: &str, ambient: bool) -> Option<ParticleOptions> {
|
||||
Some(match effect.trim_start_matches("minecraft:") {
|
||||
"trial_omen" => ParticleOptions::simple(ParticleType::TrialOmen),
|
||||
"raid_omen" => ParticleOptions::simple(ParticleType::RaidOmen),
|
||||
other => {
|
||||
let color = horizon_client::brewing::effect(other)?.color;
|
||||
let channel = |shift: u32| ((color >> shift) & 255) as f32 / 255.0;
|
||||
// MobEffect.AMBIENT_ALPHA is 38 of 255.
|
||||
let alpha = if ambient { 38.0 / 255.0 } else { 1.0 };
|
||||
ParticleOptions::colored(
|
||||
ParticleType::EntityEffect,
|
||||
[channel(16), channel(8), channel(0), alpha],
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// One tick of `LivingEntity.tickEffects`' client branch: a one in four
|
||||
/// chance (one in fifteen while invisible, a fifth of either when all the
|
||||
/// effects are ambient) of one of the particles at a random spot of the box.
|
||||
fn swirl(
|
||||
particles: &mut ParticleEngine,
|
||||
feet: Vec3,
|
||||
size: (f64, f64),
|
||||
list: &[ParticleOptions],
|
||||
ambient: bool,
|
||||
invisible: bool,
|
||||
) {
|
||||
if list.is_empty() {
|
||||
return;
|
||||
}
|
||||
let bound = if invisible { 15 } else { 4 } * if ambient { 5 } else { 1 };
|
||||
let r = particles.random();
|
||||
let Ok(0) = r.next_int_bound(bound) else {
|
||||
return;
|
||||
};
|
||||
// Util.getRandom, then getRandomX(0.5), getRandomY and getRandomZ.
|
||||
let pick = r.next_int_bound(list.len() as i32).unwrap_or_default() as usize;
|
||||
let x = feet.x + size.0 * (2.0 * r.next_double() - 1.0) * 0.5;
|
||||
let y = feet.y + size.1 * r.next_double();
|
||||
let z = feet.z + size.0 * (2.0 * r.next_double() - 1.0) * 0.5;
|
||||
particles.add(&list[pick], [x, y, z], [1.0; 3]);
|
||||
}
|
||||
@@ -10,6 +10,9 @@ struct Entry {
|
||||
fall_distance: f32,
|
||||
/// The causing entity: its id (for comparisons) and display name.
|
||||
attacker: Option<(u32, Text)>,
|
||||
/// The direct entity's display name when no entity caused the damage
|
||||
/// (`DamageSource.directEntity`: a potion with no thrower, a cloud).
|
||||
direct: Option<Text>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct CombatTracker {
|
||||
@@ -19,6 +22,8 @@ pub(super) struct CombatTracker {
|
||||
/// Attackers of damage dealt this tick, claimed by the matching
|
||||
/// `RecordedDamage` event.
|
||||
pending: Vec<(String, (u32, Text))>,
|
||||
/// `pending` for a direct entity with no causing entity: only its name.
|
||||
pending_direct: Vec<(String, Text)>,
|
||||
/// `LivingEntity.lastHurtByMob` and its timestamp (the kill credit).
|
||||
last_hurt_by: Option<((u32, Text), i64)>,
|
||||
/// The mob the player last hurt (`getLastHurtMob`) and when.
|
||||
@@ -66,6 +71,14 @@ impl Game<'_> {
|
||||
.pending
|
||||
.push((damage_type.to_owned(), (id, name)));
|
||||
}
|
||||
/// The `DamageSource.directEntity` of the next `damage_type` damage when
|
||||
/// nothing caused it (a splash potion nobody threw): the death message
|
||||
/// names it instead (`getLocalizedDeathMessage`). It is no kill credit.
|
||||
pub(super) fn note_direct_entity(&mut self, damage_type: &str, name: Text) {
|
||||
self.combat
|
||||
.pending_direct
|
||||
.push((damage_type.to_owned(), name));
|
||||
}
|
||||
/// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the
|
||||
/// source's `DamageEffects` (`Player.getHurtSound`).
|
||||
pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) {
|
||||
@@ -94,6 +107,15 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.position(|(t, _)| *t == name)
|
||||
.map(|i| self.combat.pending.remove(i).1);
|
||||
let direct = match attacker {
|
||||
Some(_) => None,
|
||||
None => self
|
||||
.combat
|
||||
.pending_direct
|
||||
.iter()
|
||||
.position(|(t, _)| *t == name)
|
||||
.map(|i| self.combat.pending_direct.remove(i).1),
|
||||
};
|
||||
let (fall_location, fall_distance) = self.fall_location();
|
||||
let now = self.world.game_time;
|
||||
if let Some(a) = &attacker {
|
||||
@@ -111,10 +133,12 @@ impl Game<'_> {
|
||||
fall_location,
|
||||
fall_distance,
|
||||
attacker,
|
||||
direct,
|
||||
});
|
||||
self.combat.last_damage = now;
|
||||
}
|
||||
self.combat.pending.clear();
|
||||
self.combat.pending_direct.clear();
|
||||
self.combat.fall_before = self.player.as_ref().map_or(0.0, |p| p.fall_distance);
|
||||
}
|
||||
/// `FallLocation.getCurrentFallLocation` and the fall distance so far.
|
||||
@@ -224,6 +248,7 @@ impl Game<'_> {
|
||||
let entries = std::mem::take(&mut self.combat.entries);
|
||||
let killing = entries.last().expect("checked above");
|
||||
let killer = killing.attacker.clone();
|
||||
let direct = killing.direct.clone();
|
||||
if def.1 == "fall_variants"
|
||||
&& let Some(knock) = knock.and_then(|i| entries.get(i))
|
||||
{
|
||||
@@ -248,15 +273,22 @@ impl Game<'_> {
|
||||
.append(Text::literal("]"));
|
||||
return Text::translate(&format!("{key}.message"), &[me, link]);
|
||||
}
|
||||
// `getLocalizedDeathMessage`: the causing entity's name, or the
|
||||
// direct entity's when nothing caused the damage.
|
||||
if let Some((_, name)) = killer {
|
||||
return Text::translate(&key, &[me, name]);
|
||||
}
|
||||
if let Some(name) = direct {
|
||||
return Text::translate(&key, &[me, name]);
|
||||
}
|
||||
// getKillCredit: the last mob that hurt the player in the past 100 ticks.
|
||||
if let Some(((_, name), at)) = &self.combat.last_hurt_by
|
||||
&& self.world.game_time - at <= 100
|
||||
{
|
||||
return Text::translate(&format!("{key}.player"), &[me, name.clone()]);
|
||||
}
|
||||
// No entity at all: the one-argument message, as vanilla sends it (a
|
||||
// template that names a killer then shows as written).
|
||||
Text::translate(&key, &[me])
|
||||
}
|
||||
}
|
||||
@@ -42,9 +42,10 @@ pub(super) enum Target {
|
||||
Guest(u32),
|
||||
Mob(u32),
|
||||
Item(u32),
|
||||
Orb(usize),
|
||||
/// By index in `creatures.projectiles`.
|
||||
Projectile(usize),
|
||||
/// An experience orb, by its entity id.
|
||||
Orb(u32),
|
||||
/// A projectile, by its entity id.
|
||||
Projectile(u32),
|
||||
Stand(u32),
|
||||
Frame(u32),
|
||||
Painting(u32),
|
||||
@@ -53,6 +54,10 @@ pub(super) enum Target {
|
||||
/// An entity of a type Horizon does not simulate (markers, displays,
|
||||
/// interactions...): kept as its saved data at a position.
|
||||
Other(u32),
|
||||
/// An End crystal, by its entity id (`minecraft:end_crystal`).
|
||||
Crystal(u32),
|
||||
/// The Ender Dragon, by its entity id (`minecraft:ender_dragon`).
|
||||
Dragon(u32),
|
||||
}
|
||||
/// An entity of a type Horizon does not simulate: its compound lives in the
|
||||
/// entity memory (`persist.raw`), its position and rotation here.
|
||||
@@ -340,14 +345,16 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|i| i.id == id)
|
||||
.map(|i| [i.position.x, i.position.y, i.position.z]),
|
||||
Target::Orb(i) => self
|
||||
Target::Orb(id) => self
|
||||
.orbs
|
||||
.get(i)
|
||||
.iter()
|
||||
.find(|o| o.id == id)
|
||||
.map(|o| [o.position.x, o.position.y, o.position.z]),
|
||||
Target::Projectile(i) => self
|
||||
Target::Projectile(id) => self
|
||||
.creatures
|
||||
.projectiles
|
||||
.get(i)
|
||||
.iter()
|
||||
.find(|a| a.id == id)
|
||||
.map(|a| [a.position.x, a.position.y, a.position.z]),
|
||||
Target::Stand(id) => self
|
||||
.armor_stands
|
||||
@@ -385,6 +392,16 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|o| o.id == id)
|
||||
.map(|o| [o.position.x, o.position.y, o.position.z]),
|
||||
Target::Crystal(id) => self
|
||||
.crystals
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| [c.position.x, c.position.y, c.position.z]),
|
||||
Target::Dragon(id) => self
|
||||
.dragon
|
||||
.as_ref()
|
||||
.filter(|d| d.id == id)
|
||||
.map(|d| [d.position.x, d.position.y, d.position.z]),
|
||||
}
|
||||
}
|
||||
pub(super) fn target_type(&self, t: Target) -> String {
|
||||
@@ -399,10 +416,11 @@ impl Game<'_> {
|
||||
}),
|
||||
Target::Item(_) => "minecraft:item".into(),
|
||||
Target::Orb(_) => "minecraft:experience_orb".into(),
|
||||
Target::Projectile(i) => self
|
||||
Target::Projectile(id) => self
|
||||
.creatures
|
||||
.projectiles
|
||||
.get(i)
|
||||
.iter()
|
||||
.find(|p| p.id == id)
|
||||
.map_or_else(String::new, |p| p.kind.id().into()),
|
||||
Target::Stand(_) => "minecraft:armor_stand".into(),
|
||||
Target::Frame(id) => {
|
||||
@@ -428,6 +446,8 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|o| o.id == id)
|
||||
.map_or_else(String::new, |o| o.kind.clone()),
|
||||
Target::Crystal(_) => "minecraft:end_crystal".into(),
|
||||
Target::Dragon(_) => "minecraft:ender_dragon".into(),
|
||||
}
|
||||
}
|
||||
/// The name of the player the game acts for: a guest's own while their
|
||||
@@ -512,51 +532,72 @@ impl Game<'_> {
|
||||
s.pos[2].unwrap_or(p[2]),
|
||||
]
|
||||
};
|
||||
let mut candidates: Vec<Target> = Vec::new();
|
||||
let mut source_candidates: Vec<Target> = Vec::new();
|
||||
match (s.kind, self.command_source) {
|
||||
// @s: the executing entity (none for a server source).
|
||||
('s', Some(source)) => candidates.extend(source.entity),
|
||||
('s', None) => candidates.extend(self.source_entity()),
|
||||
('s', Some(source)) => source_candidates.extend(source.entity),
|
||||
('s', None) => source_candidates.extend(self.source_entity()),
|
||||
// A guest in place is among the players, in join order.
|
||||
_ if self.player.is_some() && self.lan_acting.is_none() => {
|
||||
candidates.push(Target::Player);
|
||||
source_candidates.push(Target::Player);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if s.kind != 's' {
|
||||
use horizon_client::projectiles::ProjectileKind as K;
|
||||
candidates.extend(self.player_candidates(s));
|
||||
candidates.extend(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.death_time == 0)
|
||||
.map(|m| Target::Mob(m.id)),
|
||||
);
|
||||
candidates.extend(self.dropped.iter().map(|i| Target::Item(i.id)));
|
||||
candidates.extend((0..self.orbs.len()).map(Target::Orb));
|
||||
candidates.extend(
|
||||
self.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
// The kinds /data can write (entity_nbt).
|
||||
.filter(|(_, p)| {
|
||||
p.arrow.is_some()
|
||||
|| matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
|
||||
})
|
||||
.map(|(i, _)| Target::Projectile(i)),
|
||||
);
|
||||
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
|
||||
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
|
||||
candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id)));
|
||||
candidates.extend(self.boats.iter().map(|b| Target::Boat(b.id)));
|
||||
candidates.extend(self.carts.iter().map(|c| Target::Cart(c.id)));
|
||||
candidates.extend(self.others.iter().map(|o| Target::Other(o.id)));
|
||||
source_candidates.extend(self.player_candidates(s));
|
||||
}
|
||||
let source_dim = self.dim;
|
||||
// A selector with a spatial constraint reaches the level the command
|
||||
// runs in only; an unbounded one reaches every loaded level
|
||||
// (EntitySelector.findEntities).
|
||||
let dims = if s.world_limited() {
|
||||
vec![source_dim]
|
||||
} else {
|
||||
self.loaded_dims()
|
||||
};
|
||||
let mut out: Vec<(Target, f64)> = Vec::new();
|
||||
for dim in dims {
|
||||
let back = self.enter_command_level(dim);
|
||||
let mut candidates = Vec::new();
|
||||
if dim == source_dim {
|
||||
candidates.append(&mut source_candidates);
|
||||
}
|
||||
if s.kind != 's' {
|
||||
candidates.extend(self.level_candidates());
|
||||
}
|
||||
out.extend(self.select_in_level(candidates, s, origin));
|
||||
if let Some(back) = back {
|
||||
self.enter_command_level(back);
|
||||
}
|
||||
}
|
||||
match s.sort {
|
||||
Sort::Nearest => out.sort_by(|a, b| a.1.total_cmp(&b.1)),
|
||||
Sort::Furthest => out.sort_by(|a, b| b.1.total_cmp(&a.1)),
|
||||
Sort::Random => {
|
||||
for i in (1..out.len()).rev() {
|
||||
let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
|
||||
out.swap(i, j);
|
||||
}
|
||||
}
|
||||
Sort::Arbitrary => {}
|
||||
}
|
||||
out.into_iter()
|
||||
.take(s.limit.max(1))
|
||||
.map(|(t, _)| t)
|
||||
.collect()
|
||||
}
|
||||
/// The candidates of the level in place that pass the selector
|
||||
/// (EntitySelector.getPredicate).
|
||||
fn select_in_level(
|
||||
&mut self,
|
||||
candidates: Vec<Target>,
|
||||
s: &Selector,
|
||||
origin: [f64; 3],
|
||||
) -> Vec<(Target, f64)> {
|
||||
let level = self.experience.level;
|
||||
let creative = self.creative;
|
||||
let passing = self.selector_extra_filter(&candidates, s);
|
||||
let mut out: Vec<(Target, f64)> = candidates
|
||||
candidates
|
||||
.into_iter()
|
||||
.filter_map(|t| {
|
||||
let p = self.target_position(t)?;
|
||||
@@ -628,23 +669,103 @@ impl Game<'_> {
|
||||
}
|
||||
Some((t, d2))
|
||||
})
|
||||
.collect();
|
||||
match s.sort {
|
||||
Sort::Nearest => out.sort_by(|a, b| a.1.total_cmp(&b.1)),
|
||||
Sort::Furthest => out.sort_by(|a, b| b.1.total_cmp(&a.1)),
|
||||
Sort::Random => {
|
||||
for i in (1..out.len()).rev() {
|
||||
let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
|
||||
out.swap(i, j);
|
||||
}
|
||||
}
|
||||
Sort::Arbitrary => {}
|
||||
}
|
||||
out.into_iter()
|
||||
.take(s.limit.max(1))
|
||||
.map(|(t, _)| t)
|
||||
.collect()
|
||||
}
|
||||
/// The entities of the level in place a selector can find.
|
||||
fn level_candidates(&self) -> Vec<Target> {
|
||||
use horizon_client::projectiles::ProjectileKind as K;
|
||||
let mut candidates = Vec::new();
|
||||
candidates.extend(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.death_time == 0)
|
||||
.map(|m| Target::Mob(m.id)),
|
||||
);
|
||||
candidates.extend(self.dropped.iter().map(|i| Target::Item(i.id)));
|
||||
candidates.extend(self.orbs.iter().map(|o| Target::Orb(o.id)));
|
||||
candidates.extend(
|
||||
self.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
// The kinds /data can write (entity_nbt).
|
||||
.filter(|p| {
|
||||
p.arrow.is_some()
|
||||
|| matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
|
||||
})
|
||||
.map(|p| Target::Projectile(p.id)),
|
||||
);
|
||||
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
|
||||
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
|
||||
candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id)));
|
||||
candidates.extend(self.boats.iter().map(|b| Target::Boat(b.id)));
|
||||
candidates.extend(self.carts.iter().map(|c| Target::Cart(c.id)));
|
||||
candidates.extend(self.others.iter().map(|o| Target::Other(o.id)));
|
||||
// The End crystals and the dragon (EndCrystal and EnderDragon
|
||||
// are entities of their own).
|
||||
candidates.extend(self.crystals.iter().map(|c| Target::Crystal(c.id)));
|
||||
if let Some(d) = &self.dragon {
|
||||
candidates.push(Target::Dragon(d.id));
|
||||
}
|
||||
candidates
|
||||
}
|
||||
/// The loaded levels, in the order the server holds them: the
|
||||
/// overworld, the nether, the end (MinecraftServer.getAllLevels).
|
||||
fn loaded_dims(&self) -> Vec<Dim> {
|
||||
let mut dims = vec![self.dim];
|
||||
dims.extend(self.levels.parked_dims());
|
||||
dims.sort_by_key(|d| match d {
|
||||
Dim::Overworld => 0,
|
||||
Dim::Nether => 1,
|
||||
Dim::End => 2,
|
||||
});
|
||||
dims
|
||||
}
|
||||
/// Whether the level in place holds the entity a target names.
|
||||
fn level_holds(&self, t: Target) -> bool {
|
||||
match t {
|
||||
// A player is in no level of its own.
|
||||
Target::Player | Target::Guest(_) => true,
|
||||
Target::Mob(id) => self.mobs.iter().any(|m| m.id == id),
|
||||
Target::Item(id) => self.dropped.iter().any(|i| i.id == id),
|
||||
Target::Orb(id) => self.orbs.iter().any(|o| o.id == id),
|
||||
Target::Projectile(id) => self.creatures.projectiles.iter().any(|p| p.id == id),
|
||||
Target::Stand(id) => self.armor_stands.iter().any(|a| a.id == id),
|
||||
Target::Frame(id) => self.item_frames.iter().any(|f| f.id == id),
|
||||
Target::Painting(id) => self.paintings.iter().any(|p| p.id == id),
|
||||
Target::Boat(id) => self.boats.iter().any(|b| b.id == id),
|
||||
Target::Cart(id) => self.carts.iter().any(|c| c.id == id),
|
||||
Target::Other(id) => self.others.iter().any(|o| o.id == id),
|
||||
Target::Crystal(id) => self.crystals.iter().any(|c| c.id == id),
|
||||
Target::Dragon(id) => self.dragon.as_ref().is_some_and(|d| d.id == id),
|
||||
}
|
||||
}
|
||||
/// Run `f` with the level that holds the entity a target names in place
|
||||
/// (the entity ids are unique across the loaded levels) and put the
|
||||
/// level back afterwards. `f` does not run when no level holds the
|
||||
/// entity.
|
||||
pub(in crate::game) fn in_target_level<R>(
|
||||
&mut self,
|
||||
t: Target,
|
||||
f: impl FnOnce(&mut Self) -> R,
|
||||
) -> Option<R> {
|
||||
if self.level_holds(t) {
|
||||
return Some(f(self));
|
||||
}
|
||||
for dim in self.levels.parked_dims() {
|
||||
let back = self.enter_command_level(dim);
|
||||
if self.level_holds(t) {
|
||||
let r = f(self);
|
||||
if let Some(back) = back {
|
||||
self.enter_command_level(back);
|
||||
}
|
||||
return Some(r);
|
||||
}
|
||||
if let Some(back) = back {
|
||||
self.enter_command_level(back);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
fn entity_tag_matches(&self, tag: &str, ty: &str) -> bool {
|
||||
let path = self.options.resources.join(format!(
|
||||
"data/minecraft/tags/entity_type/{}.json",
|
||||
@@ -1104,6 +1225,10 @@ impl Game<'_> {
|
||||
Ok(targets.len() as i32)
|
||||
}
|
||||
fn teleport_to(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) {
|
||||
// An entity in another level is teleported in the level that holds it.
|
||||
let _ = self.in_target_level(t, |g| g.teleport_to_in_level(t, pos, rotation));
|
||||
}
|
||||
fn teleport_to_in_level(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) {
|
||||
let v = Vec3::new(pos[0], pos[1], pos[2]);
|
||||
match t {
|
||||
Target::Guest(_) => {
|
||||
@@ -1152,14 +1277,14 @@ impl Game<'_> {
|
||||
i.previous = v;
|
||||
}
|
||||
}
|
||||
Target::Orb(i) => {
|
||||
if let Some(o) = self.orbs.get_mut(i) {
|
||||
Target::Orb(id) => {
|
||||
if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id) {
|
||||
o.position = v;
|
||||
o.previous = v;
|
||||
}
|
||||
}
|
||||
Target::Projectile(i) => {
|
||||
if let Some(a) = self.creatures.projectiles.get_mut(i) {
|
||||
Target::Projectile(id) => {
|
||||
if let Some(a) = self.creatures.projectiles.iter_mut().find(|a| a.id == id) {
|
||||
a.position = v;
|
||||
a.previous = v;
|
||||
}
|
||||
@@ -1199,6 +1324,17 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Target::Crystal(id) => {
|
||||
if let Some(c) = self.crystals.iter_mut().find(|c| c.id == id) {
|
||||
c.position = v;
|
||||
}
|
||||
}
|
||||
Target::Dragon(id) => {
|
||||
if let Some(d) = self.dragon.as_mut().filter(|d| d.id == id) {
|
||||
d.position = v;
|
||||
d.previous = v;
|
||||
}
|
||||
}
|
||||
// Wall-mounted entities do not move.
|
||||
Target::Frame(_) | Target::Painting(_) => {}
|
||||
}
|
||||
@@ -1558,6 +1694,10 @@ impl Game<'_> {
|
||||
Ok(duration.unwrap_or(-1))
|
||||
}
|
||||
fn kill_target(&mut self, t: Target) {
|
||||
// An entity in another level is killed in the level that holds it.
|
||||
let _ = self.in_target_level(t, |g| g.kill_target_in_level(t));
|
||||
}
|
||||
fn kill_target_in_level(&mut self, t: Target) {
|
||||
match t {
|
||||
Target::Guest(_) => {
|
||||
self.for_player(t, |g| g.kill_target(Target::Player));
|
||||
@@ -1577,16 +1717,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Target::Item(id) => self.dropped.retain(|i| i.id != id),
|
||||
Target::Orb(i) => {
|
||||
if i < self.orbs.len() {
|
||||
self.orbs[i].age = u32::MAX / 2;
|
||||
Target::Orb(id) => {
|
||||
if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id) {
|
||||
o.age = u32::MAX / 2;
|
||||
}
|
||||
}
|
||||
// cmd_kill goes from the highest index down.
|
||||
Target::Projectile(i) => {
|
||||
if i < self.creatures.projectiles.len() {
|
||||
self.creatures.projectiles.remove(i);
|
||||
}
|
||||
Target::Projectile(id) => {
|
||||
self.creatures.projectiles.retain(|p| p.id != id);
|
||||
}
|
||||
Target::Stand(id) => self.armor_stands.retain(|a| a.id != id),
|
||||
Target::Frame(id) => self.item_frames.retain(|f| f.id != id),
|
||||
@@ -1594,7 +1731,11 @@ impl Game<'_> {
|
||||
Target::Boat(id) => self.boats.retain(|b| b.id != id),
|
||||
Target::Cart(id) => self.carts.retain(|c| c.id != id),
|
||||
Target::Other(id) => self.others.retain(|o| o.id != id),
|
||||
Target::Crystal(id) => self.kill_crystal(id),
|
||||
Target::Dragon(id) => self.kill_dragon(id),
|
||||
}
|
||||
// The orbs marked for removal are gone (Entity.kill removes them).
|
||||
self.orbs.retain(|o| o.age < u32::MAX / 2);
|
||||
}
|
||||
fn cmd_kill(&mut self, ctx: &Context) -> R<i32> {
|
||||
let targets = if ctx.get("targets").is_some() {
|
||||
@@ -1602,19 +1743,18 @@ impl Game<'_> {
|
||||
} else {
|
||||
vec![self.implied_player()]
|
||||
};
|
||||
let names: Vec<Text> = targets.iter().map(|&t| self.target_name(t)).collect();
|
||||
// Remove indexed entities from the back so earlier indices stay valid.
|
||||
let mut ordered = targets.clone();
|
||||
ordered.sort_by_key(|t| {
|
||||
std::cmp::Reverse(match t {
|
||||
Target::Orb(i) | Target::Projectile(i) => *i,
|
||||
_ => 0,
|
||||
})
|
||||
});
|
||||
for t in ordered {
|
||||
let mut names: Vec<Text> = Vec::new();
|
||||
for &t in &targets {
|
||||
// The name of an entity in another level is read there.
|
||||
let name = match self.in_target_level(t, |g| g.target_name(t)) {
|
||||
Some(name) => name,
|
||||
None => self.target_name(t),
|
||||
};
|
||||
names.push(name);
|
||||
}
|
||||
for &t in &targets {
|
||||
self.kill_target(t);
|
||||
}
|
||||
self.orbs.retain(|o| o.age < u32::MAX / 2);
|
||||
let message = if targets.len() == 1 {
|
||||
Text::translate("commands.kill.success.single", &[names[0].clone()])
|
||||
} else {
|
||||
@@ -1706,6 +1846,20 @@ impl Game<'_> {
|
||||
self.spawn_bolt(pos, false);
|
||||
name = Text::translate("entity.minecraft.lightning_bolt", &[]);
|
||||
}
|
||||
"end_crystal" => {
|
||||
// EndCrystal's constructor takes an id from the entity
|
||||
// counter, and the crystal is shown with its base.
|
||||
self.next_entity += 1;
|
||||
self.crystals.push(super::dragon::Crystal {
|
||||
id: self.next_entity,
|
||||
position: Vec3::new(pos[0], pos[1], pos[2]),
|
||||
show_bottom: true,
|
||||
time: self.world.random.next_int_bound(100_000).unwrap_or(0),
|
||||
beam: None,
|
||||
invulnerable: false,
|
||||
});
|
||||
name = Text::translate("entity.minecraft.end_crystal", &[]);
|
||||
}
|
||||
k if super::minecarts::CartKind::from_id(&kind).is_some() => {
|
||||
let cart_kind = super::minecarts::CartKind::from_id(&kind)
|
||||
.unwrap_or(super::minecarts::CartKind::Rideable);
|
||||
@@ -1896,8 +2050,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
self.world.block_entities.insert(key, new);
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let _ = self.world.set(pos[0], pos[1], pos[2], state);
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
true
|
||||
}
|
||||
/// RandomizableContainer.tryLoadLootTable for a tag a command gives a
|
||||
|
||||
@@ -31,8 +31,13 @@ fn set_key(tag: &mut Tag, key: &str, value: Tag) {
|
||||
impl Game<'_> {
|
||||
// ---- entity data ---------------------------------------------------
|
||||
|
||||
/// `NbtPredicate.getEntityTagToCompare`: the entity as saved.
|
||||
/// `NbtPredicate.getEntityTagToCompare`: the entity as saved. An entity
|
||||
/// in another level is read in the level that holds it.
|
||||
pub(in crate::game) fn entity_nbt(&mut self, t: Target) -> Option<Tag> {
|
||||
self.in_target_level(t, |g| g.entity_nbt_in_level(t))
|
||||
.flatten()
|
||||
}
|
||||
fn entity_nbt_in_level(&mut self, t: Target) -> Option<Tag> {
|
||||
let player_uuid = self.level.player_uuid;
|
||||
Some(match t {
|
||||
Target::Guest(_) => {
|
||||
@@ -113,9 +118,11 @@ impl Game<'_> {
|
||||
let o = self.others.iter().find(|o| o.id == id)?.clone();
|
||||
self.other_to_nbt(&o, uuid)
|
||||
}
|
||||
Target::Orb(i) => entity_nbt::orb_to_nbt(self.orbs.get(i)?, random_uuid()),
|
||||
Target::Projectile(i) => {
|
||||
let p = self.creatures.projectiles.get(i)?;
|
||||
Target::Orb(id) => {
|
||||
entity_nbt::orb_to_nbt(self.orbs.iter().find(|o| o.id == id)?, random_uuid())
|
||||
}
|
||||
Target::Projectile(id) => {
|
||||
let p = self.creatures.projectiles.iter().find(|p| p.id == id)?;
|
||||
let owner = if p.owner == Some(horizon_client::mobs::Who::Player) {
|
||||
player_uuid
|
||||
} else {
|
||||
@@ -129,6 +136,16 @@ impl Game<'_> {
|
||||
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)
|
||||
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))?
|
||||
}
|
||||
Target::Crystal(id) => {
|
||||
let c = self.crystals.iter().find(|c| c.id == id)?;
|
||||
let (uuid, raw) = self.persist_anon("minecraft:end_crystal", c.position);
|
||||
c.to_nbt(uuid.unwrap_or_else(random_uuid), raw)
|
||||
}
|
||||
Target::Dragon(id) => {
|
||||
let d = self.dragon.as_ref().filter(|d| d.id == id)?;
|
||||
let (uuid, raw) = self.persist_anon("minecraft:ender_dragon", d.position);
|
||||
d.to_nbt(uuid.unwrap_or_else(random_uuid), raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
/// `Entity.load` of new data (`EntityDataAccessor.setData`): the entity is
|
||||
@@ -397,6 +414,19 @@ impl Game<'_> {
|
||||
}
|
||||
pub(super) fn cmd_data(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
let target = self.data_ref(ctx, 0)?;
|
||||
// An entity in another level is read and written in the level that
|
||||
// holds it.
|
||||
if let DataRef::Entity(t) = &target {
|
||||
let t = *t;
|
||||
let target = target.clone();
|
||||
return match self.in_target_level(t, |g| g.cmd_data_in(id, ctx, target)) {
|
||||
Some(r) => r,
|
||||
None => Err(err("argument.entity.notfound.entity", &[])),
|
||||
};
|
||||
}
|
||||
self.cmd_data_in(id, ctx, target)
|
||||
}
|
||||
fn cmd_data_in(&mut self, id: &str, ctx: &Context, target: DataRef) -> R<i32> {
|
||||
match id {
|
||||
"data.merge" => {
|
||||
let Some(Arg::Str(nbt)) = ctx.get("nbt") else {
|
||||
|
||||
@@ -91,7 +91,12 @@ impl Game<'_> {
|
||||
}
|
||||
Target::Mob(id) => match self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
Some(m) => {
|
||||
m.add_effect(namespaced(effect.id), duration, amplifier);
|
||||
let name = namespaced(effect.id);
|
||||
m.add_effect(name, duration, amplifier);
|
||||
m.extra.hidden_effects.retain(|h| *h != name);
|
||||
if !particles {
|
||||
m.extra.hidden_effects.push(name);
|
||||
}
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
@@ -154,6 +159,10 @@ impl Game<'_> {
|
||||
m.extra
|
||||
.effects
|
||||
.retain(|x| e.is_some_and(|e| x.0 != namespaced(e.id)));
|
||||
let left = &m.extra.effects;
|
||||
m.extra
|
||||
.hidden_effects
|
||||
.retain(|h| left.iter().any(|x| x.0 == *h));
|
||||
m.extra.effects.len() != before
|
||||
}
|
||||
None => false,
|
||||
|
||||
@@ -145,9 +145,42 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map_or(0.0, |m| m.eye_y() - m.position.y),
|
||||
_ => 0.0,
|
||||
// ArmorStand.getDefaultDimensions: a marker's are empty, a small
|
||||
// stand's are BABY_DIMENSIONS, whose eye height is 0.9875F.
|
||||
Target::Stand(id) => match self.armor_stands.iter().find(|a| a.id == id) {
|
||||
Some(a) if a.marker => 0.0,
|
||||
Some(a) if a.small => f64::from(0.9875f32),
|
||||
_ => self.type_eye_height(&self.target_type(t)),
|
||||
},
|
||||
// Entity.getEyeHeight: the type's dimensions.
|
||||
_ => self.type_eye_height(&self.target_type(t)),
|
||||
}
|
||||
}
|
||||
/// `EntityType.getDimensions().eyeHeight()` from `entity-types.json`,
|
||||
/// read once: a float, widened as `Entity.getEyeY` does.
|
||||
fn type_eye_height(&self, ty: &str) -> f64 {
|
||||
static EYE_HEIGHTS: std::sync::OnceLock<std::collections::HashMap<String, f64>> =
|
||||
std::sync::OnceLock::new();
|
||||
EYE_HEIGHTS
|
||||
.get_or_init(|| {
|
||||
std::fs::read(self.options.resources.join("entity-types.json"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
|
||||
.and_then(|v| {
|
||||
v.as_object().map(|o| {
|
||||
o.iter()
|
||||
.filter_map(|(k, t)| {
|
||||
Some((k.clone(), f64::from(t["eye_height"].as_f64()? as f32)))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.get(ty)
|
||||
.copied()
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
/// EntityAnchorArgument.Anchor.apply.
|
||||
pub(super) fn anchor_position(&self, s: &Source) -> [f64; 3] {
|
||||
match s.entity {
|
||||
@@ -467,9 +500,10 @@ impl Game<'_> {
|
||||
"positioned" if c.has("as") => targets(self)?
|
||||
.into_iter()
|
||||
.filter_map(|t| {
|
||||
// withPosition keeps the anchor (only `positioned <pos>`
|
||||
// resets it to the feet).
|
||||
Some(Source {
|
||||
pos: self.target_position(t)?,
|
||||
eyes: false,
|
||||
..s
|
||||
})
|
||||
})
|
||||
@@ -606,6 +640,73 @@ impl Game<'_> {
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
// Leashable.getLeashHolder: a fence knot is not an entity here.
|
||||
Some("leasher") => match entity {
|
||||
Target::Mob(id) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.and_then(|m| m.extra.leash.map(|holder| (holder, m.extra.leash_player)))
|
||||
.and_then(|(holder, player)| match holder {
|
||||
// The player who tied it (the host's when unrecorded).
|
||||
horizon_client::mobs::leash::Holder::Player => {
|
||||
let uuid = player.or(self.level.player_uuid)?;
|
||||
self.target_by_uuid(horizon_client::lan::playerdata::ints_uuid(uuid))
|
||||
}
|
||||
horizon_client::mobs::leash::Holder::Mob(i) => Some(Target::Mob(i)),
|
||||
horizon_client::mobs::leash::Holder::Knot(_) => None,
|
||||
})
|
||||
.into_iter()
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
// TraceableEntity.getOwner: who shot a projectile, who threw an
|
||||
// item, who summoned a vex.
|
||||
Some("origin") => match entity {
|
||||
Target::Projectile(id) => self
|
||||
.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
.find(|p| p.id == id)
|
||||
.and_then(|p| match (p.owner?, p.guest) {
|
||||
// A guest's shot: `Who::Player` is them.
|
||||
(horizon_client::mobs::Who::Player, Some(conn))
|
||||
if self.lan_acting == Some(conn) =>
|
||||
{
|
||||
Some(Target::Player)
|
||||
}
|
||||
(horizon_client::mobs::Who::Player, Some(conn)) => {
|
||||
Some(Target::Guest(conn))
|
||||
}
|
||||
(horizon_client::mobs::Who::Player, None) => Some(Target::Player),
|
||||
(horizon_client::mobs::Who::Mob(id), _) => Some(Target::Mob(id)),
|
||||
})
|
||||
.into_iter()
|
||||
.collect(),
|
||||
Target::Item(id) => self
|
||||
.dropped
|
||||
.iter()
|
||||
.find(|d| d.id == id)
|
||||
.and_then(|d| d.thrower)
|
||||
.and_then(|words| {
|
||||
let uuid = words
|
||||
.iter()
|
||||
.fold(0u128, |all, w| all << 32 | u128::from(*w as u32));
|
||||
self.target_by_uuid(uuid)
|
||||
})
|
||||
.into_iter()
|
||||
.collect(),
|
||||
Target::Mob(id) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id && m.kind.id == "minecraft:vex")
|
||||
.map(|m| m.extra.counter2 as u32)
|
||||
.filter(|owner| *owner != 0)
|
||||
.map(Target::Mob)
|
||||
.into_iter()
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
Some("vehicle") => self.ride_vehicle(entity).into_iter().collect(),
|
||||
Some("controller") | Some("passengers") => match entity {
|
||||
Target::Mob(id) if self.mobs.iter().any(|m| m.id == id && m.rider.is_some()) => {
|
||||
@@ -807,10 +908,12 @@ impl Game<'_> {
|
||||
.coords(c, "pos")
|
||||
.map(|co| co.block(self.source_position(), self.source_rotation()));
|
||||
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
|
||||
return Ok(flag(
|
||||
dimension == self.dim
|
||||
&& p.is_some_and(|p| self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4))),
|
||||
));
|
||||
// ExecuteCommand.isChunkLoaded: ENTITY_TICKING, which is past lit.
|
||||
let loaded = |p: [i32; 3]| {
|
||||
let key = (p[0] >> 4, p[2] >> 4);
|
||||
self.world.chunks.contains_key(&key) && self.streaming.is_lit(key.0, key.1)
|
||||
};
|
||||
return Ok(flag(dimension == self.dim && p.is_some_and(loaded)));
|
||||
}
|
||||
if c.has("dimension") {
|
||||
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
|
||||
|
||||
@@ -244,7 +244,11 @@ impl Game<'_> {
|
||||
/// hand them the next jobs as soon as each one is done, rather than once
|
||||
/// a tick.
|
||||
pub(in crate::game) fn pump_pregen(&mut self, until: Instant) {
|
||||
while self.pregen_runs_here() {
|
||||
// Force-loaded chunks too: decorating in vanilla's order, a chunk
|
||||
// waits for its neighbours far more often than once a tick allows.
|
||||
while self.pregen_runs_here()
|
||||
|| (!self.streaming.forced.is_empty() && self.streaming.busy())
|
||||
{
|
||||
let now = Instant::now();
|
||||
if now >= until {
|
||||
break;
|
||||
|
||||
@@ -120,6 +120,9 @@ impl Game<'_> {
|
||||
tracker.track(&holder(chunk_text(x, z)), i32::from(changed));
|
||||
}
|
||||
}
|
||||
if add {
|
||||
self.streaming.rank_forced(cx0, cz0, cx1, cz1);
|
||||
}
|
||||
let (lo, hi) = (chunk_text(cx0, cz0), chunk_text(cx1, cz1));
|
||||
let verb = if add { "added" } else { "removed" };
|
||||
let (message, total) = tracker.message(
|
||||
|
||||
@@ -14,10 +14,11 @@ impl Game<'_> {
|
||||
serde_json::from_str(json).map_err(|e| err("argument.component.invalid", &[&e]))?;
|
||||
// NbtContents.resolve: the path's values in the entity, block or
|
||||
// storage data, rich-styled (or plain, or interpreted), ", "-joined.
|
||||
// ScoreContents.resolve: the holder's score in the objective.
|
||||
let nbt_values = std::cell::RefCell::new(Vec::new());
|
||||
{
|
||||
let mut collect = |o: &serde_json::Map<String, serde_json::Value>| {
|
||||
if o.contains_key("nbt") {
|
||||
if o.contains_key("nbt") || o.contains_key("score") {
|
||||
nbt_values.borrow_mut().push(o.clone());
|
||||
}
|
||||
};
|
||||
@@ -40,7 +41,11 @@ impl Game<'_> {
|
||||
}
|
||||
let mut resolved: Vec<(serde_json::Map<String, serde_json::Value>, Text)> = Vec::new();
|
||||
for o in nbt_values.into_inner() {
|
||||
let t = self.resolve_nbt_component(&o);
|
||||
let t = if o.contains_key("score") {
|
||||
self.resolve_score_component(&o)?
|
||||
} else {
|
||||
self.resolve_nbt_component(&o)
|
||||
};
|
||||
resolved.push((o, t));
|
||||
}
|
||||
let mut resolve = |raw: &str| -> Text {
|
||||
@@ -73,6 +78,54 @@ impl Game<'_> {
|
||||
},
|
||||
))
|
||||
}
|
||||
/// The text of a `score` component (ScoreContents.resolve): the named
|
||||
/// holder's score, `*` being the player the command runs for.
|
||||
fn resolve_score_component(
|
||||
&mut self,
|
||||
o: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> R<Text> {
|
||||
let score = o.get("score");
|
||||
let field = |k: &str| score.and_then(|s| s.get(k)).and_then(|v| v.as_str());
|
||||
let (name, objective) = (
|
||||
field("name").unwrap_or(""),
|
||||
field("objective").unwrap_or(""),
|
||||
);
|
||||
// findTargetName: a selector that matches one entity is that entity,
|
||||
// one that matches none is just the name.
|
||||
let mut reader = horizon_client::command::Reader::new(name);
|
||||
let ty = horizon_client::command::args::ArgType::Entity {
|
||||
single: false,
|
||||
players: false,
|
||||
};
|
||||
let mut holder = name.to_owned();
|
||||
if let Ok(Arg::Entity(sel)) = ty.parse(&mut reader, &self.ui.registries)
|
||||
&& !reader.can_read()
|
||||
{
|
||||
let found = self.select(&sel);
|
||||
if found.len() > 1 {
|
||||
return Err(err("argument.entity.toomany", &[]));
|
||||
}
|
||||
if let Some(t) = found.first().and_then(|t| self.holder_name(*t)) {
|
||||
holder = t;
|
||||
}
|
||||
}
|
||||
if holder == "*" {
|
||||
holder = self.acting_player_name().to_owned();
|
||||
}
|
||||
let board = &self.score.board;
|
||||
let (Some(objective), Some(score)) =
|
||||
(board.objective(objective), board.score(&holder, objective))
|
||||
else {
|
||||
return Ok(Text::empty());
|
||||
};
|
||||
// numberFormatOrDefault(StyledFormat.NO_STYLE).
|
||||
let format = (score.number_format.clone())
|
||||
.or_else(|| objective.number_format.clone())
|
||||
.unwrap_or(horizon_client::scoreboard::NumberFormat::Styled(
|
||||
serde_json::json!({}),
|
||||
));
|
||||
Ok(format.format(score.value))
|
||||
}
|
||||
/// The text of an `nbt` component.
|
||||
fn resolve_nbt_component(&mut self, o: &serde_json::Map<String, serde_json::Value>) -> Text {
|
||||
use horizon_world::nbt::Tag;
|
||||
@@ -136,28 +189,40 @@ impl Game<'_> {
|
||||
}
|
||||
out
|
||||
}
|
||||
fn player_targets(&mut self, ctx: &Context) -> R<usize> {
|
||||
// Only the local player can receive messages and titles.
|
||||
/// The players the command reaches: the window's own and the LAN guests.
|
||||
fn player_targets(&mut self, ctx: &Context) -> R<Vec<Target>> {
|
||||
let targets = self.entities(ctx, "targets", true)?;
|
||||
Ok(targets.iter().filter(|t| **t == Target::Player).count())
|
||||
Ok(targets
|
||||
.into_iter()
|
||||
.filter(|t| matches!(t, Target::Player | Target::Guest(_)))
|
||||
.collect())
|
||||
}
|
||||
pub(super) fn cmd_tellraw(&mut self, ctx: &Context) -> R<i32> {
|
||||
let n = self.player_targets(ctx)?;
|
||||
let players = self.player_targets(ctx)?;
|
||||
let message = self.component_arg(ctx, "message")?;
|
||||
if n > 0 {
|
||||
self.system_message(message);
|
||||
// Each player's chat (a guest's through the connection).
|
||||
for &t in &players {
|
||||
self.for_player(t, |g| g.system_message(message.clone()));
|
||||
}
|
||||
Ok(n as i32)
|
||||
Ok(players.len() as i32)
|
||||
}
|
||||
pub(super) fn cmd_title(&mut self, ctx: &Context) -> R<i32> {
|
||||
let n = self.player_targets(ctx)?;
|
||||
let name = self.target_name(Target::Player);
|
||||
let players = self.player_targets(ctx)?;
|
||||
let n = players.len();
|
||||
let name = players
|
||||
.first()
|
||||
.map_or_else(Text::empty, |t| self.target_name(*t));
|
||||
let single = n == 1;
|
||||
let key = if ctx.has("clear") {
|
||||
self.titles_or_packet(Titles::clear, C::ClearTitles(play::Bool1 { value: false }));
|
||||
self.titles_or_packet(
|
||||
&players,
|
||||
Titles::clear,
|
||||
C::ClearTitles(play::Bool1 { value: false }),
|
||||
);
|
||||
"cleared"
|
||||
} else if ctx.has("reset") {
|
||||
self.titles_or_packet(
|
||||
&players,
|
||||
|t| {
|
||||
t.clear();
|
||||
t.reset_times();
|
||||
@@ -172,6 +237,7 @@ impl Game<'_> {
|
||||
};
|
||||
let (fade_in, stay, fade_out) = (get("fadeIn"), get("stay"), get("fadeOut"));
|
||||
self.titles_or_packet(
|
||||
&players,
|
||||
|t| t.set_times(fade_in, stay, fade_out),
|
||||
C::SetTitlesAnimation(play::TitlesAnimation {
|
||||
fade_in,
|
||||
@@ -186,13 +252,17 @@ impl Game<'_> {
|
||||
text: component_nbt(&text.component()),
|
||||
};
|
||||
if ctx.has("subtitle") {
|
||||
self.titles_or_packet(|t| t.set_subtitle(text), C::SetSubtitleText(sent));
|
||||
self.titles_or_packet(&players, |t| t.set_subtitle(text), C::SetSubtitleText(sent));
|
||||
"show.subtitle"
|
||||
} else if ctx.has("actionbar") {
|
||||
self.titles_or_packet(|t| t.set_overlay(text, false), C::SetActionBarText(sent));
|
||||
self.titles_or_packet(
|
||||
&players,
|
||||
|t| t.set_overlay(text, false),
|
||||
C::SetActionBarText(sent),
|
||||
);
|
||||
"show.actionbar"
|
||||
} else {
|
||||
self.titles_or_packet(|t| t.set_title(text), C::SetTitleText(sent));
|
||||
self.titles_or_packet(&players, |t| t.set_title(text), C::SetTitleText(sent));
|
||||
"show.title"
|
||||
}
|
||||
};
|
||||
@@ -207,13 +277,27 @@ impl Game<'_> {
|
||||
self.feedback(message);
|
||||
Ok(n as i32)
|
||||
}
|
||||
/// The player the title is for: the guest acting now is sent the packet
|
||||
/// (TitleCommand's `connection.send`), the window's own player shows it.
|
||||
fn titles_or_packet(&mut self, local: impl FnOnce(&mut Titles), packet: C) {
|
||||
if self.lan_acting.is_some() {
|
||||
self.guest_packets.push(packet);
|
||||
} else {
|
||||
local(&mut self.titles);
|
||||
/// TitleCommand's `connection.send` for each player: a guest is sent the
|
||||
/// packet, the window's own player shows it.
|
||||
fn titles_or_packet(&mut self, players: &[Target], local: impl FnOnce(&mut Titles), packet: C) {
|
||||
let mut local = Some(local);
|
||||
for &t in players {
|
||||
match t {
|
||||
Target::Player if self.lan_acting.is_some() => {
|
||||
self.guest_packets.push(packet.clone());
|
||||
}
|
||||
Target::Player => {
|
||||
if let Some(local) = local.take() {
|
||||
local(&mut self.titles);
|
||||
}
|
||||
}
|
||||
Target::Guest(conn) => {
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,27 +141,37 @@ impl Game<'_> {
|
||||
Some(Arg::Entity(_)) => self.entities(ctx, "entity", false)?.first().copied(),
|
||||
_ => None,
|
||||
};
|
||||
let cause = match ctx.get("cause") {
|
||||
Some(Arg::Entity(_)) => self.entities(ctx, "cause", false)?.first().copied(),
|
||||
_ => None,
|
||||
};
|
||||
let from = match ctx.get("location") {
|
||||
Some(Arg::Coords(_)) => self.position_arg(ctx, "location"),
|
||||
_ => by.and_then(|t| self.target_position(t)),
|
||||
};
|
||||
let hurt = match target {
|
||||
// The causing mob is the one the hit is credited to and thorns
|
||||
// answer; the source position gives the knockback its direction.
|
||||
Target::Player => {
|
||||
if self.creative || self.spectator() {
|
||||
// `DamageSource(type, cause, by)`: the death message names the
|
||||
// causing entity, else the direct one. A mob is noted by
|
||||
// `hurt_player_from`, a player here.
|
||||
if let Some(t @ (Target::Player | Target::Guest(_))) = cause.or(by) {
|
||||
let name = self.target_name(t);
|
||||
self.note_direct_entity(&ty, name);
|
||||
}
|
||||
if self.spectator() {
|
||||
false
|
||||
} else if let Some(player) = self.player.as_mut() {
|
||||
match self.motion.health.damage.id(&ty) {
|
||||
Some(id) => player
|
||||
.hurt(&self.motion.health, self.world.health_rules, id, amount)
|
||||
.unwrap_or(false),
|
||||
None => false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
let attacker = match cause.or(by) {
|
||||
Some(Target::Mob(i)) => Some(Some(i)),
|
||||
_ => Some(None),
|
||||
};
|
||||
self.hurt_player_from(amount, from, &ty, attacker)
|
||||
}
|
||||
}
|
||||
Target::Mob(id) => {
|
||||
let attacker = by.and_then(|t| match t {
|
||||
let attacker = cause.or(by).and_then(|t| match t {
|
||||
Target::Player => Some(Who::Player),
|
||||
Target::Mob(i) => Some(Who::Mob(i)),
|
||||
_ => None,
|
||||
@@ -302,9 +312,18 @@ impl Game<'_> {
|
||||
float("minVolume", 0.0),
|
||||
);
|
||||
let range = if volume > 1.0 { 16.0 * volume } else { 16.0 };
|
||||
// PlaySoundCommand: one seed for every player.
|
||||
let seed = self.world.random.next_long();
|
||||
let here = self.dim.id();
|
||||
let mut tracker = Tracker::default();
|
||||
for t in targets {
|
||||
if t != Target::Player {
|
||||
if !matches!(t, Target::Player | Target::Guest(_)) {
|
||||
continue;
|
||||
}
|
||||
// Only players in the source's level hear it.
|
||||
if let Target::Guest(conn) = t
|
||||
&& self.command_guest(conn).is_none_or(|g| g.dimension != here)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(p) = self.target_position(t) else {
|
||||
@@ -328,8 +347,26 @@ impl Game<'_> {
|
||||
} else {
|
||||
(pos, volume)
|
||||
};
|
||||
self.audio.play_on(source, &sound, Some(at), v, pitch);
|
||||
tracker.mark(&holder(Text::literal(self.acting_player_name())));
|
||||
// The connection is sent the sound; the window's own player
|
||||
// plays it.
|
||||
let guest = match t {
|
||||
Target::Guest(conn) => Some(conn),
|
||||
_ => self.lan_acting,
|
||||
};
|
||||
match guest.and_then(|conn| Some((conn, self.lan.as_ref()?))) {
|
||||
Some((conn, host)) => {
|
||||
let packet = host
|
||||
.hub
|
||||
.sound_packet(&sound, at, v, pitch, source as i32, seed);
|
||||
if t == Target::Player {
|
||||
self.guest_packets.push(packet);
|
||||
} else {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
}
|
||||
None => self.audio.play_on(source, &sound, Some(at), v, pitch),
|
||||
}
|
||||
tracker.mark(&holder(self.target_name(t)));
|
||||
}
|
||||
let name = Text::literal(sound.clone());
|
||||
let (message, total) = tracker.message(
|
||||
@@ -606,8 +643,10 @@ impl Game<'_> {
|
||||
let reached = reached.len() as i32;
|
||||
if !local {
|
||||
} else if count <= 0 {
|
||||
// No count: one particle with the delta as velocity.
|
||||
self.particles.create(&options, pos, delta);
|
||||
// handleParticleEvent, count 0: one particle, the speed times
|
||||
// the delta as velocity (both sent as floats, multiplied as floats).
|
||||
self.particles
|
||||
.create(&options, pos, delta.map(|d| f64::from(speed * d as f32)));
|
||||
} else {
|
||||
for _ in 0..count {
|
||||
let r = self.particles.random();
|
||||
|
||||
@@ -19,7 +19,17 @@ use std::convert::Infallible;
|
||||
|
||||
type ItemConsumable = Consumable<String, ConsumeEffect>;
|
||||
|
||||
/// `EntityEvent.USE_ITEM_COMPLETE`: the server finished the player's use.
|
||||
pub(super) const USE_ITEM_COMPLETE_EVENT: i8 = 9;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ServerPlayer.completeUsingItem`: the player's own client is told
|
||||
/// first, and completes the use itself (eating the food there too).
|
||||
pub(super) fn send_use_item_complete(&mut self) {
|
||||
if self.lan_acting.is_some() {
|
||||
self.guest_entity_events.push(USE_ITEM_COMPLETE_EVENT);
|
||||
}
|
||||
}
|
||||
/// `Consumable.emitParticlesAndSounds(user.getRandom(), user, stack, count)`.
|
||||
pub(super) fn emit_consume(
|
||||
&mut self,
|
||||
|
||||
@@ -878,6 +878,9 @@ impl Game<'_> {
|
||||
Screen::Advancements => "advancements",
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::ServerLinks => "server-links",
|
||||
Screen::ConfirmLink => "confirm-link",
|
||||
Screen::DialogWarning => "dialog-warning",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
Screen::Mod => "mod",
|
||||
@@ -1083,6 +1086,9 @@ impl Game<'_> {
|
||||
"flashes": self.lightning_seen[1], "flash": self.flash});
|
||||
// Primed TNT, falling blocks, projectiles, tridents and end crystals.
|
||||
status["projectiles"] = json!(self.projectiles_status());
|
||||
let mut fangs_vertices = Vec::new();
|
||||
self.fangs_geometry(glam::DVec3::ZERO, 0.5, &mut fangs_vertices);
|
||||
status["fangs_vertices"] = json!(fangs_vertices.len());
|
||||
status["maps"] = json!(self.maps_status());
|
||||
// The particles made so far, by type.
|
||||
status["particles"] = json!(self.particles.made());
|
||||
|
||||
@@ -218,8 +218,9 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
/// `CreakingHeartBlock.playerWillDestroy`: the heart's creaking twitches to
|
||||
/// death and a natural heart drops experience.
|
||||
pub(super) fn creaking_heart_broken(&mut self, pos: [i32; 3]) {
|
||||
/// death and a natural heart drops experience. `onExplosionHit` does the
|
||||
/// same for the creaking (`by_player` false), without the experience.
|
||||
pub(super) fn creaking_heart_broken(&mut self, pos: [i32; 3], by_player: bool) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
if !matches!(
|
||||
self.world.block_entities.get(&key),
|
||||
@@ -242,7 +243,7 @@ impl Game<'_> {
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("natural").cloned())
|
||||
.is_some_and(|v| v == "true");
|
||||
if natural && !self.creative && !self.spectator() {
|
||||
if by_player && natural && !self.creative && !self.spectator() {
|
||||
let xp = 20 + self.world.random.next_int_bound(5).unwrap_or(0) as u32;
|
||||
self.spawn_orbs(
|
||||
[
|
||||
|
||||
@@ -18,6 +18,7 @@ use horizon_client::{
|
||||
spawning::{self, BiomeFacts},
|
||||
},
|
||||
projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3, traverse_blocks},
|
||||
@@ -145,6 +146,8 @@ fn flame_geometry(
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
h -= 0.45;
|
||||
@@ -430,6 +433,12 @@ impl Game<'_> {
|
||||
),
|
||||
}
|
||||
}
|
||||
/// What the generator finalizes this level's structure mobs with.
|
||||
pub(super) fn share_spawn_conditions(&self) {
|
||||
if let Some(terrain) = self.end.locator() {
|
||||
terrain.set_spawn_conditions(self.difficulty_id() as u8, self.world.day_time);
|
||||
}
|
||||
}
|
||||
/// `ServerLevel.getCurrentDifficultyAt`.
|
||||
pub(super) fn local_difficulty(&self, at: [f64; 3]) -> spawning::LocalDifficulty {
|
||||
use horizon_world::food::Difficulty;
|
||||
@@ -451,9 +460,9 @@ impl Game<'_> {
|
||||
};
|
||||
spawning::LocalDifficulty::new(base, self.world.day_time, inhabited, moon)
|
||||
}
|
||||
/// Advance every mob one tick, then apply their events.
|
||||
pub(super) fn tick_creatures(&mut self) -> Result<()> {
|
||||
self.attack_ticks = self.attack_ticks.saturating_add(1);
|
||||
/// Generation's animals for the chunks generated since, once lit: chunk
|
||||
/// generation places them whether the level ticks or not.
|
||||
pub(super) fn spawn_fresh_chunks(&mut self) {
|
||||
let fresh: Vec<(i32, i32)> = self
|
||||
.world
|
||||
.chunks
|
||||
@@ -467,6 +476,10 @@ impl Game<'_> {
|
||||
}
|
||||
self.spawn_generation_creatures(key.0, key.1);
|
||||
}
|
||||
}
|
||||
/// Advance every mob one tick, then apply their events.
|
||||
pub(super) fn tick_creatures(&mut self) -> Result<()> {
|
||||
self.attack_ticks = self.attack_ticks.saturating_add(1);
|
||||
self.tick_leads();
|
||||
self.spawn_natural();
|
||||
// ServerChunkCache.tickChunks: the custom spawners tick only with spawn_mobs.
|
||||
@@ -526,6 +539,8 @@ impl Game<'_> {
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let mut guest_events = Vec::new();
|
||||
// The mobs whose bodies go this tick: (id, feet, width, height).
|
||||
let mut gone = Vec::new();
|
||||
let level_random;
|
||||
{
|
||||
// The level random, lent to the mobs (brain behaviours draw from
|
||||
@@ -598,6 +613,11 @@ impl Game<'_> {
|
||||
let from = events.len();
|
||||
let kept = m.tick(&w, &mut scratch, &mut events);
|
||||
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
|
||||
// LivingEntity.tickDeath: after twenty ticks the body is removed.
|
||||
if !kept && !m.removed && !m.alive() && m.death_time >= 20 {
|
||||
let (width, height) = (f64::from(m.width()), f64::from(m.height()));
|
||||
gone.push((m.id, m.position, width, height));
|
||||
}
|
||||
if let Some(conn) = seen_guest {
|
||||
// ShulkerBullet.finalTarget is the guest's entity: the
|
||||
// bullet homes on it while the projectiles tick, where
|
||||
@@ -656,6 +676,12 @@ impl Game<'_> {
|
||||
}
|
||||
self.mobs.append(&mut mobs);
|
||||
self.died = died;
|
||||
// ... and entity event 60 goes out as it is: the poof.
|
||||
for (id, at, width, height) in gone {
|
||||
let at = [at.x, at.y, at.z];
|
||||
self.lan_entity_event(id as i32, 60, at);
|
||||
self.death_poof(at, width, height);
|
||||
}
|
||||
self.sync_riders();
|
||||
self.tick_skeleton_traps();
|
||||
self.tick_apparition();
|
||||
@@ -682,6 +708,22 @@ impl Game<'_> {
|
||||
self.handle_mob_events(queued)?;
|
||||
Ok(())
|
||||
}
|
||||
/// `LivingEntity.makePoofParticles` (entity event 60) about a mob's box.
|
||||
pub(super) fn death_poof(&mut self, at: [f64; 3], width: f64, height: f64) {
|
||||
let options = horizon_client::particles::ParticleOptions::simple(
|
||||
horizon_client::particles::ParticleType::Poof,
|
||||
);
|
||||
for _ in 0..20 {
|
||||
let r = self.particles.level_random();
|
||||
let xa = r.next_gaussian() * 0.02;
|
||||
let ya = r.next_gaussian() * 0.02;
|
||||
let za = r.next_gaussian() * 0.02;
|
||||
let x = at[0] + width * (2.0 * r.next_double() - 1.0) - xa * 10.0;
|
||||
let y = at[1] + height * r.next_double() - ya * 10.0;
|
||||
let z = at[2] + width * (2.0 * r.next_double() - 1.0) - za * 10.0;
|
||||
self.particles.add(&options, [x, y, z], [xa, ya, za]);
|
||||
}
|
||||
}
|
||||
/// LivingEntity.die / dropAllDeathLoot.
|
||||
fn mob_died(&mut self, m: &Mob) {
|
||||
let killer = self.last_hurt_by_player.remove(&m.id);
|
||||
@@ -892,6 +934,15 @@ impl Game<'_> {
|
||||
self.spawn_item(d, at, [0.0, 0.2, 0.0], 10);
|
||||
}
|
||||
}
|
||||
/// Adds a projectile, giving it an id from the entity counter
|
||||
/// (`Entity.id`, the same counter as the rest) when it has none.
|
||||
pub(super) fn add_projectile(&mut self, mut p: horizon_client::projectiles::Projectile) {
|
||||
if p.id == 0 {
|
||||
self.next_entity += 1;
|
||||
p.id = self.next_entity;
|
||||
}
|
||||
self.creatures.projectiles.push(p);
|
||||
}
|
||||
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
|
||||
for e in events {
|
||||
match e {
|
||||
@@ -1093,7 +1144,12 @@ impl Game<'_> {
|
||||
self.next_entity += 1;
|
||||
p.id = self.next_entity;
|
||||
let (owner, at) = (p.owner, [p.position.x, p.position.y, p.position.z]);
|
||||
self.creatures.projectiles.push(p);
|
||||
// LlamaSpit.recreateFromPacket: the spit's burst on the client.
|
||||
if p.kind == ProjectileKind::LlamaSpit {
|
||||
let v = p.velocity;
|
||||
self.particles.llama_spit_burst(at, [v.x, v.y, v.z]);
|
||||
}
|
||||
self.add_projectile(p);
|
||||
self.projectile_event("minecraft:projectile_shoot", owner, at);
|
||||
}
|
||||
MobEvent::Spawn(mut m) => {
|
||||
@@ -1655,11 +1711,34 @@ impl Game<'_> {
|
||||
pub(super) fn mob_hits_player_kind(&mut self, amount: f32, from: [f64; 3], kind: &str) {
|
||||
self.mob_hits_player(amount, from, kind);
|
||||
}
|
||||
/// Damage whose causing mob is known (`cause`, or none): not guessed
|
||||
/// from the mob nearest to `from`.
|
||||
pub(super) fn mob_hits_player_caused(
|
||||
&mut self,
|
||||
amount: f32,
|
||||
from: [f64; 3],
|
||||
kind: &str,
|
||||
cause: Option<u32>,
|
||||
) {
|
||||
self.hurt_player_from(amount, Some(from), kind, Some(cause));
|
||||
}
|
||||
pub(super) fn mob_hits_player(
|
||||
&mut self,
|
||||
amount: f32,
|
||||
from: [f64; 3],
|
||||
damage_type: &str,
|
||||
) -> bool {
|
||||
self.hurt_player_from(amount, Some(from), damage_type, None)
|
||||
}
|
||||
/// `hurtServer` on the player. `from` is the source's position (none: no
|
||||
/// direction), `known`: the causing mob when it is given (`/damage`),
|
||||
/// else the melee attacker is the mob nearest to `from`.
|
||||
pub(super) fn hurt_player_from(
|
||||
&mut self,
|
||||
amount: f32,
|
||||
from: Option<[f64; 3]>,
|
||||
damage_type: &str,
|
||||
known: Option<Option<u32>>,
|
||||
) -> bool {
|
||||
// ServerPlayer.canHarmPlayer refused another player's projectile.
|
||||
if self.creative || self.combat.refuse_player_hit {
|
||||
@@ -1672,23 +1751,26 @@ impl Game<'_> {
|
||||
.damage_tags
|
||||
.get("minecraft:is_projectile")
|
||||
.is_some_and(|t| t.contains(damage_type));
|
||||
let blocking = self.player_blocking(Some(from), damage_type, false);
|
||||
let blocking = self.player_blocking(from, damage_type, false);
|
||||
let raised = blocking.is_some();
|
||||
// Melee: the attacker stands at `from` (another player's hit has no mob behind it).
|
||||
let attacker = (!projectile && damage_type != "minecraft:player_attack")
|
||||
.then(|| {
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive())
|
||||
.min_by(|a, b| {
|
||||
let d = |m: &horizon_client::mobs::Mob| {
|
||||
(m.position.x - from[0]).powi(2) + (m.position.z - from[2]).powi(2)
|
||||
};
|
||||
d(a).total_cmp(&d(b))
|
||||
})
|
||||
.map(|m| m.id)
|
||||
})
|
||||
.flatten();
|
||||
let attacker = match known {
|
||||
Some(known) => known,
|
||||
None => from
|
||||
.filter(|_| !projectile && damage_type != "minecraft:player_attack")
|
||||
.and_then(|from| {
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive())
|
||||
.min_by(|a, b| {
|
||||
let d = |m: &horizon_client::mobs::Mob| {
|
||||
(m.position.x - from[0]).powi(2) + (m.position.z - from[2]).powi(2)
|
||||
};
|
||||
d(a).total_cmp(&d(b))
|
||||
})
|
||||
.map(|m| m.id)
|
||||
}),
|
||||
};
|
||||
// The causing entity for the death message.
|
||||
let owner = self.combat.projectile_owner.take();
|
||||
if let Some(a) = attacker.or(owner) {
|
||||
@@ -1716,7 +1798,10 @@ impl Game<'_> {
|
||||
// LivingEntity.hurtServer's dealDefaultKnockback: not inside the
|
||||
// hurt cooldown (a stronger hit there only adds its difference).
|
||||
if o.knockback {
|
||||
let (xd, zd) = (from[0] - player.position.x, from[2] - player.position.z);
|
||||
// No source position: a random direction.
|
||||
let (xd, zd) = from.map_or((0.0, 0.0), |from| {
|
||||
(from[0] - player.position.x, from[2] - player.position.z)
|
||||
});
|
||||
self.knock_back_player(f64::from(0.4_f32), xd, zd);
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& let Some(r) = p.hurt_log.last_mut()
|
||||
@@ -2054,6 +2139,33 @@ impl Game<'_> {
|
||||
None => self.projectile_hit(p, hit),
|
||||
}
|
||||
}
|
||||
/// What an arrow or a thrown trident hits an entity of type `victim`
|
||||
/// for (`AbstractArrow.onHitEntity`, `ThrownTrident.onHitEntity`): a
|
||||
/// trident's 8 through `EnchantmentHelper.modifyDamage` of the trident
|
||||
/// (Impaling's `minecraft:damage` on `#sensitive_to_impaling`).
|
||||
fn shot_damage(&mut self, shot: &mut Projectile, victim: Option<&str>) -> f32 {
|
||||
if !matches!(shot.kind, ProjectileKind::Trident) {
|
||||
return arrow_damage(shot, &mut self.world.random) + shot.bonus.damage;
|
||||
}
|
||||
let Some(trident) = shot.arrow.as_ref().map(|a| a.pickup_item.clone()) else {
|
||||
return 8.0;
|
||||
};
|
||||
let ctx = horizon_client::enchant::EffectContext {
|
||||
this: victim,
|
||||
attacker: (shot.owner == Some(Who::Player)).then_some("minecraft:player"),
|
||||
direct_attacker: Some("minecraft:trident"),
|
||||
damage_type: Some("minecraft:trident"),
|
||||
items: Some(self.res.items()),
|
||||
..Default::default()
|
||||
};
|
||||
self.res.stations().enchantments.modify_value(
|
||||
Some(&trident),
|
||||
"minecraft:damage",
|
||||
&ctx,
|
||||
&mut self.world.random,
|
||||
8.0,
|
||||
)
|
||||
}
|
||||
/// What an arrow's tick asked for: its particles and sounds.
|
||||
fn arrow_event(&mut self, at: Vec3, event: horizon_client::arrows::ArrowEvent) {
|
||||
use horizon_client::arrows::{ArrowEvent, ArrowParticle, ArrowSound, BlockReaction};
|
||||
@@ -2082,6 +2194,8 @@ impl Game<'_> {
|
||||
ArrowSound::Splash => "minecraft:entity.generic.splash",
|
||||
ArrowSound::HitGround => "minecraft:entity.arrow.hit",
|
||||
ArrowSound::Extinguish => "minecraft:entity.generic.extinguish_fire",
|
||||
ArrowSound::TridentHitGround => "minecraft:item.trident.hit_ground",
|
||||
ArrowSound::TridentHit => "minecraft:item.trident.hit",
|
||||
};
|
||||
self.audio
|
||||
.play(name, Some([at.x, at.y, at.z]), volume, pitch);
|
||||
@@ -2175,7 +2289,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if keep {
|
||||
self.creatures.projectiles.push(arrow);
|
||||
self.add_projectile(arrow);
|
||||
}
|
||||
}
|
||||
/// `TargetBlock.onProjectileHit`: a target block takes its power from
|
||||
@@ -2231,11 +2345,11 @@ impl Game<'_> {
|
||||
&& id >= super::dragon::SPECIAL
|
||||
{
|
||||
let by_player = p.owner == Some(Who::Player);
|
||||
if let ProjectileKind::Arrow { .. } = p.kind {
|
||||
if p.arrow.is_some() {
|
||||
// AbstractArrow.onHitEntity: spent if the part or crystal took
|
||||
// it, else turned back (a perched dragon sets it alight first).
|
||||
let mut arrow = p.clone();
|
||||
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
|
||||
let amount = self.shot_damage(&mut arrow, None);
|
||||
let outcome = match self.hit_special(id, amount, by_player, true) {
|
||||
super::dragon::Hurt::Landed { .. } => EntityHit::Hurt,
|
||||
super::dragon::Hurt::Refused => EntityHit::Refused,
|
||||
@@ -2256,17 +2370,15 @@ impl Game<'_> {
|
||||
self.hit_special(id, amount, by_player, false);
|
||||
return Ok(());
|
||||
}
|
||||
if let (
|
||||
ProjectileKind::Arrow { .. },
|
||||
Hit::Entity {
|
||||
if p.arrow.is_some()
|
||||
&& let Hit::Entity {
|
||||
who: Who::Mob(id), ..
|
||||
},
|
||||
) = (&p.kind, hit)
|
||||
} = hit
|
||||
&& let Some(i) = self.carts.iter().position(|c| c.id == id)
|
||||
{
|
||||
// AbstractArrow.onHitEntity on a minecart.
|
||||
let mut arrow = p.clone();
|
||||
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
|
||||
let amount = self.shot_damage(&mut arrow, None);
|
||||
let burning = p.arrow.as_ref().is_some_and(|a| a.on_fire());
|
||||
let creative = p.owner == Some(Who::Player) && self.creative;
|
||||
self.arrow_hits_cart(i, amount, p.velocity, burning, creative);
|
||||
@@ -2277,7 +2389,7 @@ impl Game<'_> {
|
||||
// Projectile.onHit: the landing is heard.
|
||||
self.projectile_event("minecraft:projectile_land", p.owner, atp);
|
||||
let source_kind = match p.kind {
|
||||
ProjectileKind::Arrow { .. } => DamageKind::Arrow,
|
||||
ProjectileKind::Arrow { .. } | ProjectileKind::Trident => DamageKind::Arrow,
|
||||
ProjectileKind::SmallFireball | ProjectileKind::LargeFireball { .. } => {
|
||||
DamageKind::Fireball
|
||||
}
|
||||
@@ -2289,6 +2401,40 @@ impl Game<'_> {
|
||||
from: Some([p.previous.x, p.previous.y, p.previous.z]),
|
||||
};
|
||||
match (&p.kind, hit) {
|
||||
// ThrownTrident.onHitEntity: 8 damage (Impaling on aquatic mobs),
|
||||
// dealt whether or not the hurt lands, then it bounces off.
|
||||
(ProjectileKind::Trident, Hit::Entity { who, .. }) => {
|
||||
let mut trident = p.clone();
|
||||
let victim = match who {
|
||||
Who::Mob(id) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map(|m| m.kind.id.clone()),
|
||||
Who::Player => Some("minecraft:player".to_owned()),
|
||||
};
|
||||
let amount = self.shot_damage(&mut trident, victim.as_deref());
|
||||
let hurt = match who {
|
||||
Who::Player => self.mob_hits_player(
|
||||
amount,
|
||||
[p.previous.x, p.previous.y, p.previous.z],
|
||||
"minecraft:trident",
|
||||
),
|
||||
Who::Mob(id) => self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == id)
|
||||
.is_some_and(|m| m.damage(amount, source)),
|
||||
};
|
||||
let outcome = if hurt {
|
||||
EntityHit::Hurt
|
||||
} else {
|
||||
EntityHit::Refused
|
||||
};
|
||||
self.finish_arrow_hit(trident, outcome);
|
||||
}
|
||||
// AbstractArrow.onHitBlock plays its sound itself (ArrowEvent).
|
||||
(ProjectileKind::Trident, Hit::Block { .. }) => {}
|
||||
(ProjectileKind::Arrow { effect, .. }, Hit::Entity { who, .. }) => {
|
||||
let mut arrow = p.clone();
|
||||
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
|
||||
@@ -2510,14 +2656,30 @@ impl Game<'_> {
|
||||
if d2 < 16.0 && !self.creative {
|
||||
let scale = 1.0 - d2.sqrt() / 4.0;
|
||||
let t = (f64::from(*ticks) * scale + 0.5) as i32;
|
||||
if let Some(owner) = self.combat.projectile_owner {
|
||||
self.note_attacker("minecraft:indirect_magic", owner);
|
||||
}
|
||||
match *effect {
|
||||
"minecraft:instant_damage" => self.hurt_player_by(
|
||||
"minecraft:indirect_magic",
|
||||
6.0 * (1 << *amplifier) as f32 * scale as f32,
|
||||
),
|
||||
"minecraft:instant_damage" => {
|
||||
// indirectMagic(potion, owner), built where the
|
||||
// hurt happens: the thrower, or the potion
|
||||
// itself when it has none. A status effect
|
||||
// deals no damage, so it records no source.
|
||||
let owner = self
|
||||
.combat
|
||||
.projectile_owner
|
||||
.filter(|id| self.mob_display_name(*id).is_some());
|
||||
match owner {
|
||||
Some(owner) => {
|
||||
self.note_attacker("minecraft:indirect_magic", owner)
|
||||
}
|
||||
None => self.note_direct_entity(
|
||||
"minecraft:indirect_magic",
|
||||
Text::translate("entity.minecraft.splash_potion", &[]),
|
||||
),
|
||||
}
|
||||
self.hurt_player_by(
|
||||
"minecraft:indirect_magic",
|
||||
6.0 * (1 << *amplifier) as f32 * scale as f32,
|
||||
)
|
||||
}
|
||||
e if t > 20 => self.give_player_effect(e, t, *amplifier),
|
||||
_ => {}
|
||||
}
|
||||
@@ -3177,7 +3339,7 @@ impl Game<'_> {
|
||||
velocity,
|
||||
on_ground,
|
||||
);
|
||||
self.creatures.projectiles.push(proj);
|
||||
self.add_projectile(proj);
|
||||
self.player_projectile_shot();
|
||||
// ItemStack.use's applyAfterUseComponentSideEffects: the pearl's
|
||||
// second is the `use_cooldown` component, as for any thrown item.
|
||||
@@ -3274,7 +3436,7 @@ impl Game<'_> {
|
||||
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
let atlas = &self.gui.atlas;
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
let scale = f64::from(self.settings.entity_distance);
|
||||
let scale = self.view_scale();
|
||||
for mob in &self.mobs {
|
||||
let entity_start = out.len();
|
||||
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws.
|
||||
@@ -3283,9 +3445,16 @@ impl Game<'_> {
|
||||
}
|
||||
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
|
||||
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
|
||||
// mean edge, scaled by the entity distance option.
|
||||
// mean edge, scaled by the view scale. RemotePlayer's box counts
|
||||
// ten times.
|
||||
let (w, h, _) = mob.dims();
|
||||
let range = (w + h + w) / 3.0 * 64.0 * scale;
|
||||
let edge = (w + h + w) / 3.0
|
||||
* if &*mob.kind.id == "minecraft:player" {
|
||||
10.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let range = edge * 64.0 * scale;
|
||||
if d.length_squared() >= range * range {
|
||||
continue;
|
||||
}
|
||||
@@ -3426,6 +3595,30 @@ impl Game<'_> {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// ThrownTridentRenderer.
|
||||
if matches!(a.kind, ProjectileKind::Trident) {
|
||||
let Some(model) = self.trident_model.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let Some(r) = atlas.sprites.get("entity/trident/trident").copied() else {
|
||||
continue;
|
||||
};
|
||||
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 + 90.0).to_radians());
|
||||
let poses = horizon_client::mobs::anim::Poses::new(model);
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
m,
|
||||
&poses,
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// WindChargeRenderer.
|
||||
if matches!(
|
||||
a.kind,
|
||||
@@ -3495,6 +3688,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ impl Game<'_> {
|
||||
a.pierce = pierce;
|
||||
}
|
||||
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
|
||||
self.creatures.projectiles.push(arrow);
|
||||
self.add_projectile(arrow);
|
||||
}
|
||||
self.player_projectile_shot();
|
||||
let pitch = if index == 0 {
|
||||
|
||||
@@ -348,11 +348,15 @@ impl Game<'_> {
|
||||
let Some(state) = self.pack.assets.registry.default_state(&name) else {
|
||||
return;
|
||||
};
|
||||
let at = [
|
||||
c.position.x.floor() as i32,
|
||||
c.position.y.floor() as i32,
|
||||
c.position.z.floor() as i32,
|
||||
];
|
||||
let p = c.position;
|
||||
// Entity.getY(2/3) is relative to the cushion's quarter-block height.
|
||||
let particle_at = [p.x, p.y + 0.25 * (2.0 / 3.0), p.z];
|
||||
// Guest actions temporarily take self.lan; flush once the hub is back.
|
||||
// Queue before resolving a sprite: a dedicated server has no renderer.
|
||||
if self.audio.capture.is_some() {
|
||||
self.lan_cushion_breaks.push((state, particle_at));
|
||||
}
|
||||
let at = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
|
||||
return;
|
||||
};
|
||||
@@ -361,14 +365,8 @@ impl Game<'_> {
|
||||
sprite,
|
||||
tint,
|
||||
);
|
||||
let p = c.position;
|
||||
self.particles.send_particles(
|
||||
&options,
|
||||
[p.x, p.y + 0.666_666_7, p.z],
|
||||
[0.25, 0.0625, 0.25],
|
||||
0.05,
|
||||
10,
|
||||
);
|
||||
self.particles
|
||||
.send_particles(&options, particle_at, [0.25, 0.0625, 0.25], 0.05, 10);
|
||||
}
|
||||
|
||||
/// `BlockAttachedEntity.tick`: survive and burn checks after the post-increment reaches 100.
|
||||
@@ -455,8 +453,7 @@ impl Game<'_> {
|
||||
glam::DVec3::new(lo.x, lo.y, lo.z),
|
||||
glam::DVec3::new(hi.x, hi.y, hi.z),
|
||||
);
|
||||
let range =
|
||||
super::entity_culling::default_range(hi - lo, self.settings.entity_distance);
|
||||
let range = super::entity_culling::default_range(hi - lo, self.view_scale());
|
||||
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ use horizon_client::{
|
||||
use horizon_protocol::text::translatable;
|
||||
use horizon_world::nbt::{JavaString, Tag};
|
||||
use std::{
|
||||
io::Write,
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex, OnceLock, mpsc},
|
||||
time::{Duration, Instant},
|
||||
@@ -89,7 +90,9 @@ fn log_from(thread: &str, level: &str, message: &str) {
|
||||
}
|
||||
});
|
||||
// Log4j's %d{HH:mm:ss}: the local time, as in logs/latest.log.
|
||||
println!(
|
||||
// The launcher can close its stdout pipe while the server keeps running.
|
||||
let _ = writeln!(
|
||||
std::io::stdout().lock(),
|
||||
"[{}] [{thread}/{level}]: {message}",
|
||||
chrono::Local::now().format("%H:%M:%S")
|
||||
);
|
||||
@@ -295,14 +298,14 @@ impl Lists {
|
||||
}
|
||||
}
|
||||
|
||||
/// The world presets of 26.3's `worldgen/world_preset`.
|
||||
const WORLD_PRESETS: [&str; 6] = [
|
||||
"minecraft:normal",
|
||||
"minecraft:flat",
|
||||
"minecraft:large_biomes",
|
||||
"minecraft:amplified",
|
||||
"minecraft:single_biome_surface",
|
||||
"minecraft:debug_all_block_states",
|
||||
/// DedicatedServerProperties.LEGACY_PRESET_NAMES: the old `level-type`
|
||||
/// names, before the presets had their own ids.
|
||||
const LEGACY_PRESET_NAMES: [(&str, horizon_client::save::WorldPreset); 2] = [
|
||||
("default", horizon_client::save::WorldPreset::Normal),
|
||||
(
|
||||
"largebiomes",
|
||||
horizon_client::save::WorldPreset::LargeBiomes,
|
||||
),
|
||||
];
|
||||
|
||||
/// Identifier.tryParse: `namespace:path`, or a path in `minecraft`.
|
||||
@@ -319,11 +322,18 @@ impl Game<'_> {
|
||||
/// DedicatedServerProperties.createDimensions for a world that doesn't
|
||||
/// exist yet: `level-type`'s preset, a flat world's `generator-settings`
|
||||
/// and `generate-structures`, written before the world opens so that
|
||||
/// streaming reads them. Horizon generates the normal and flat presets.
|
||||
/// streaming reads them.
|
||||
fn write_world_gen(&self, dir: &std::path::Path, props: &ServerProperties) -> Result<()> {
|
||||
let level_type = &props.level_type;
|
||||
let preset = parse_identifier(level_type)
|
||||
.filter(|id| WORLD_PRESETS.contains(&id.as_str()))
|
||||
.as_deref()
|
||||
.and_then(horizon_client::save::WorldPreset::of_id)
|
||||
.or_else(|| {
|
||||
LEGACY_PRESET_NAMES
|
||||
.iter()
|
||||
.find(|(name, _)| *name == level_type)
|
||||
.map(|(_, preset)| *preset)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
log_from(
|
||||
"Worker-Main-1",
|
||||
@@ -332,18 +342,9 @@ impl Game<'_> {
|
||||
"Failed to parse level-type {level_type}, defaulting to minecraft:normal"
|
||||
),
|
||||
);
|
||||
"minecraft:normal".to_owned()
|
||||
horizon_client::save::WorldPreset::Normal
|
||||
});
|
||||
let flat = preset == "minecraft:flat";
|
||||
if !flat && preset != "minecraft:normal" {
|
||||
log_from(
|
||||
"Worker-Main-1",
|
||||
"WARN",
|
||||
&format!(
|
||||
"Horizon doesn't generate the {preset} world preset yet: using minecraft:normal"
|
||||
),
|
||||
);
|
||||
}
|
||||
let flat = preset == horizon_client::save::WorldPreset::Flat;
|
||||
let settings = if flat {
|
||||
let biomes = self.res.biomes();
|
||||
let (settings, errors) = horizon_client::flatgen::settings_from_json(
|
||||
@@ -359,7 +360,7 @@ impl Game<'_> {
|
||||
None
|
||||
};
|
||||
let options = horizon_client::save::WorldGen {
|
||||
flat,
|
||||
preset,
|
||||
structures: props.generate_structures,
|
||||
bonus_chest: false,
|
||||
};
|
||||
@@ -572,7 +573,8 @@ impl Game<'_> {
|
||||
self.ui.difficulty = props.difficulty as u8;
|
||||
self.settings.render_distance = props.view_distance.clamp(2, 32);
|
||||
self.settings.simulation_distance = props.simulation_distance.clamp(2, 32);
|
||||
self.streaming.radius = self.settings.render_distance;
|
||||
// Nothing is kept around the spawn while nobody is on.
|
||||
self.streaming.radius = -1;
|
||||
self.settle_spawn()?;
|
||||
let gate_lists = lists.clone();
|
||||
let gate: LoginGate = Arc::new(move |address, uuid, name| {
|
||||
@@ -618,6 +620,22 @@ impl Game<'_> {
|
||||
log_ips: props.log_ips,
|
||||
}),
|
||||
resource_pack: props.resource_pack.clone(),
|
||||
// DedicatedServer.serverLinks: `bug-report-link` as the only link
|
||||
// (ServerLinks.KnownLinkType.BUG_REPORT is id 0).
|
||||
server_links: if props.bug_report_link.is_empty() {
|
||||
Vec::new()
|
||||
} else if super::server_links::valid_untrusted_uri(&props.bug_report_link) {
|
||||
vec![horizon_protocol::packets::configuration::ServerLink {
|
||||
kind: horizon_protocol::packets::configuration::ServerLinkKind::Known(0),
|
||||
link: props.bug_report_link.clone(),
|
||||
}]
|
||||
} else {
|
||||
log(
|
||||
"WARN",
|
||||
&format!("Failed to parse bug link {}", props.bug_report_link),
|
||||
);
|
||||
Vec::new()
|
||||
},
|
||||
};
|
||||
let config = HubConfig {
|
||||
default_game_mode: game_mode,
|
||||
@@ -654,6 +672,9 @@ impl Game<'_> {
|
||||
// DedicatedServer.initServer ends with saveEverything(false, true, true).
|
||||
self.save_game()?;
|
||||
self.log_saved_levels();
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
host.hub.entity_range_percent = props.entity_broadcast_range_percentage;
|
||||
}
|
||||
let mut state = ServerState {
|
||||
lists,
|
||||
console,
|
||||
@@ -713,9 +734,33 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// Load the spawn area and find the world spawn as single player does,
|
||||
/// without keeping a player there.
|
||||
/// without keeping a player there. A dedicated server only finds the world
|
||||
/// spawn (MinecraftServer.setInitialSpawn): a player's spawn is found as
|
||||
/// one joins.
|
||||
pub(super) fn settle_spawn(&mut self) -> Result<()> {
|
||||
let deadline = Instant::now() + Duration::from_secs(600);
|
||||
if self.options.dedicated.is_some() {
|
||||
if self.level.spawn.is_none() {
|
||||
let [x, y, z] = loop {
|
||||
self.update_world();
|
||||
if let Some(pos) = self.find_world_spawn()? {
|
||||
break pos;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
return Err("the spawn area did not load".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
};
|
||||
self.level.spawn = Some([f64::from(x), f64::from(y), f64::from(z)]);
|
||||
self.level.world_spawn.get_or_insert([x, y, z]);
|
||||
self.streaming.extra.clear();
|
||||
}
|
||||
self.spawn = self
|
||||
.level
|
||||
.spawn
|
||||
.map(|s| horizon_core::geometry::Vec3::new(s[0] + 0.5, s[1], s[2] + 0.5));
|
||||
return Ok(());
|
||||
}
|
||||
while self.player.is_none() {
|
||||
self.update_world();
|
||||
self.spawn_player()?;
|
||||
@@ -728,24 +773,28 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The chunks to keep loaded: the players' (lan_update_streaming), or
|
||||
/// the spawn chunk while nobody is online.
|
||||
/// The chunks to keep loaded: the players' (lan_update_streaming). While
|
||||
/// nobody is online a dedicated server keeps none (vanilla has no spawn
|
||||
/// ticket), an integrated one the spawn chunk's.
|
||||
pub(super) fn update_dedicated_streaming(&mut self) {
|
||||
let center = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| h.hub.here(g))
|
||||
.map(|g| g.chunk())
|
||||
})
|
||||
.or_else(|| {
|
||||
self.spawn
|
||||
.map(|s| ((s.x.floor() as i32) >> 4, (s.z.floor() as i32) >> 4))
|
||||
});
|
||||
if let Some(c) = center {
|
||||
let guest = self.lan.as_ref().and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| h.hub.here(g))
|
||||
.map(|g| g.chunk())
|
||||
});
|
||||
if self.options.dedicated.is_some() {
|
||||
self.streaming.radius = if guest.is_some() {
|
||||
self.settings.render_distance
|
||||
} else {
|
||||
-1
|
||||
};
|
||||
}
|
||||
let spawn = self
|
||||
.spawn
|
||||
.map(|s| ((s.x.floor() as i32) >> 4, (s.z.floor() as i32) >> 4));
|
||||
if let Some(c) = guest.or(spawn) {
|
||||
self.streaming.center = c;
|
||||
}
|
||||
}
|
||||
@@ -1308,3 +1357,110 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let h = u32::from_be_bytes(bytes[20..24].try_into().ok()?);
|
||||
Some((w, h))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{capture_log, log, log_from};
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, BufRead, BufReader, Read, Write},
|
||||
process::{Command, Stdio},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
const LOGIN: &str = "Player[/127.0.0.1:0] logged in with entity id 2 at (0.5, 115.0, 0.5)";
|
||||
const WARNING: &str = "Authentication warning";
|
||||
const READY: &str = "horizon-console-log-ready\n";
|
||||
const CHILD_HOME: &str = "HORIZON_TEST_CONSOLE_LOG_HOME";
|
||||
|
||||
fn assert_log_lines(text: &str) {
|
||||
let expected = [
|
||||
format!("] [Server thread/INFO]: {LOGIN}"),
|
||||
format!("] [User Authenticator #1/WARN]: {WARNING}"),
|
||||
];
|
||||
assert_eq!(text.lines().count(), expected.len());
|
||||
for (line, suffix) in text.lines().zip(expected) {
|
||||
assert!(line.starts_with('['));
|
||||
assert!(chrono::NaiveTime::parse_from_str(&line[1..9], "%H:%M:%S").is_ok());
|
||||
assert_eq!(&line[9..], suffix);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn console_logging_survives_closed_stdout() {
|
||||
if let Some(home) = std::env::var_os(CHILD_HOME) {
|
||||
horizon_client::logging::init(std::path::Path::new(&home), "Server thread");
|
||||
// Let the parent close the real stdout pipe only after libtest's startup output.
|
||||
io::stdout().write_all(READY.as_bytes()).unwrap();
|
||||
io::stdout().flush().unwrap();
|
||||
io::stdin().read_exact(&mut [0]).unwrap();
|
||||
std::thread::Builder::new()
|
||||
.name("Server thread".into())
|
||||
.spawn(|| {
|
||||
let captured = capture_log(|| {
|
||||
log("INFO", LOGIN);
|
||||
log_from("User Authenticator #1", "WARN", WARNING);
|
||||
});
|
||||
assert_eq!(captured, [LOGIN, WARNING]);
|
||||
horizon_client::logging::flush();
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
// Libtest's final status also writes to stdout; only the game logger is under test.
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
for closed_stdout in [false, true] {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let home = std::env::temp_dir().join(format!(
|
||||
"horizon-console-log-test-{}-{unique}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir(&home).unwrap();
|
||||
let mut child = Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"game::dedicated::tests::console_logging_survives_closed_stdout",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD_HOME, &home)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
assert!(
|
||||
stdout.read_line(&mut line).unwrap() > 0,
|
||||
"child was not ready"
|
||||
);
|
||||
if line.ends_with(READY) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if closed_stdout {
|
||||
drop(stdout); // No reader remains: the next console write gets EPIPE.
|
||||
} else {
|
||||
child.stdout = Some(stdout.into_inner());
|
||||
}
|
||||
child.stdin.take().unwrap().write_all(&[1]).unwrap();
|
||||
let output = child.wait_with_output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"closed_stdout={closed_stdout}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
if !closed_stdout {
|
||||
assert_log_lines(std::str::from_utf8(&output.stdout).unwrap());
|
||||
}
|
||||
assert_log_lines(&fs::read_to_string(home.join("logs/latest.log")).unwrap());
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -982,7 +982,7 @@ impl Game<'_> {
|
||||
| "minecraft:lingering_potion" => {
|
||||
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
|
||||
shot.shoot(&mut own, dir, power, uncertainty);
|
||||
self.launch_thrown(one, at, shot.velocity);
|
||||
self.launch_thrown(one, at, shot.velocity, None);
|
||||
}
|
||||
"minecraft:firework_rocket" => {
|
||||
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
|
||||
@@ -1034,7 +1034,7 @@ impl Game<'_> {
|
||||
a.pickup = horizon_client::arrows::Pickup::Allowed;
|
||||
}
|
||||
shot.shoot(&mut own, dir, power, uncertainty);
|
||||
self.creatures.projectiles.push(shot);
|
||||
self.add_projectile(shot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -828,8 +828,10 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
|
||||
// EndCrystal's constructor takes an id from the entity counter.
|
||||
self.next_entity += 1;
|
||||
self.crystals.push(Crystal {
|
||||
id: 0,
|
||||
id: self.next_entity,
|
||||
position: at,
|
||||
show_bottom: true,
|
||||
time,
|
||||
@@ -2157,11 +2159,14 @@ impl Game<'_> {
|
||||
}
|
||||
/// Damage from the dragon, which is its causing entity
|
||||
/// (`DamageSources.mobAttack(dragon)`, `indirectMagic(cloud, dragon)`): the
|
||||
/// death message names it.
|
||||
/// death message names it, or the cloud once the dragon is gone.
|
||||
fn dragon_hurts_player(&mut self, damage_type: &str, amount: f32) {
|
||||
if let Some(id) = self.dragon.as_ref().map(|d| d.id) {
|
||||
let name = Text::translate("entity.minecraft.ender_dragon", &[]);
|
||||
self.note_named_attacker(damage_type, id, name);
|
||||
} else if damage_type == "minecraft:indirect_magic" {
|
||||
let name = Text::translate("entity.minecraft.area_effect_cloud", &[]);
|
||||
self.note_direct_entity(damage_type, name);
|
||||
}
|
||||
self.hurt_player_by(damage_type, amount);
|
||||
}
|
||||
@@ -2482,6 +2487,8 @@ impl Game<'_> {
|
||||
layer: 2,
|
||||
color: c,
|
||||
light: 0,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2547,6 +2554,8 @@ impl Game<'_> {
|
||||
layer: 6,
|
||||
color: c,
|
||||
light: wrap_light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
(last_sin, last_cos, last_u) = (sin, cos, uu);
|
||||
@@ -2592,13 +2601,17 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: 240 | 240 << 8,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn fight_ready(&mut self) -> bool {
|
||||
self.load_fight();
|
||||
self.fight.loaded
|
||||
// spawnExitPortal runs from scanState and the dragon's death, both
|
||||
// only with players in the fight.
|
||||
self.fight.loaded && self.fight.players
|
||||
}
|
||||
pub(super) fn exit_portal(&self) -> Option<[i32; 3]> {
|
||||
self.fight.exit_portal
|
||||
@@ -2830,8 +2843,11 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
|
||||
// EndCrystal's constructor takes an id from the entity counter, so
|
||||
// the crystal is an entity a command can name (`@e[type=end_crystal]`).
|
||||
self.next_entity += 1;
|
||||
self.crystals.push(Crystal {
|
||||
id: 0,
|
||||
id: self.next_entity,
|
||||
position: Vec3::new(x + 0.5, y, z + 0.5),
|
||||
show_bottom: false,
|
||||
time,
|
||||
@@ -2848,6 +2864,21 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// `EndCrystal.kill` (`/kill`): the crystal is removed, without a blast
|
||||
/// (`Entity.kill` does not hurt it).
|
||||
pub(super) fn kill_crystal(&mut self, id: u32) {
|
||||
if let Some(i) = self.crystals.iter().position(|c| c.id == id) {
|
||||
self.destroy_crystal(i, None, CrystalDamage::Explosion);
|
||||
}
|
||||
}
|
||||
/// `EnderDragon.kill` (`/kill`): `Entity.kill` removes the entity, it
|
||||
/// does not hurt it (the dragon flies its death flight when its health
|
||||
/// runs out).
|
||||
pub(super) fn kill_dragon(&mut self, id: u32) {
|
||||
if self.dragon.as_ref().is_some_and(|d| d.id == id) {
|
||||
self.dragon = None;
|
||||
}
|
||||
}
|
||||
/// Any entity touching `room` (`Level.getEntities(null, box)`).
|
||||
fn entity_in(&self, room: Aabb) -> bool {
|
||||
let touches = |lo: Vec3, hi: Vec3| {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
containers::{BlockEntity, EndGateway},
|
||||
environment::Environment,
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
@@ -19,7 +20,7 @@ use horizon_core::{
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
/// ServerLevel.END_SPAWN_POINT.
|
||||
const END_SPAWN: [i32; 3] = [100, 50, 0];
|
||||
pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
|
||||
/// EnderDragonFight gateway ring radius and height.
|
||||
const GATEWAY_RADIUS: f64 = 96.0;
|
||||
const GATEWAY_Y: i32 = 75;
|
||||
@@ -27,14 +28,64 @@ const GATEWAY_Y: i32 = 75;
|
||||
const GATEWAY_FILE: &str = "horizon-end-gateways.json";
|
||||
|
||||
/// A gateway teleport waiting for its destination chunks.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum GatewayJob {
|
||||
/// TheEndGatewayBlockEntity.findOrCreateValidTeleportPos from this gateway.
|
||||
Find { gateway: [i32; 3] },
|
||||
/// `exact` is the gateway's own `ExactTeleport`, read while its chunk is loaded.
|
||||
Find { gateway: [i32; 3], exact: bool },
|
||||
/// A known exit (exact, or findExitPosition around it).
|
||||
Known { exit: [i32; 3], exact: bool },
|
||||
}
|
||||
|
||||
impl GatewayJob {
|
||||
/// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks.
|
||||
fn for_gateway(
|
||||
gateway: [i32; 3],
|
||||
entity: Option<&BlockEntity>,
|
||||
legacy: Option<([i32; 3], bool)>,
|
||||
) -> Self {
|
||||
let configured = match entity {
|
||||
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)),
|
||||
_ => None,
|
||||
};
|
||||
match configured.or(legacy) {
|
||||
Some((exit, exact)) => Self::Known { exit, exact },
|
||||
// Worldgen return gateways (end_gateway_return) lead exactly to the End spawn.
|
||||
None if f64::from(gateway[0]).hypot(f64::from(gateway[2])) > 512.0 => Self::Known {
|
||||
exit: END_SPAWN,
|
||||
exact: true,
|
||||
},
|
||||
None => Self::Find {
|
||||
gateway,
|
||||
exact: exact_teleport(entity),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `TheEndGatewayBlockEntity.exactTeleport` of the block entity at a gateway
|
||||
/// (false without one).
|
||||
fn exact_teleport(entity: Option<&BlockEntity>) -> bool {
|
||||
matches!(entity, Some(BlockEntity::EndGateway(g)) if g.exact_teleport)
|
||||
}
|
||||
|
||||
/// Moves an exit that only the side file knew into the gateway's block entity, unless the block
|
||||
/// entity has its own; true when it took the saved one.
|
||||
fn adopt_saved_exit(entity: &mut EndGateway, (exit, exact): ([i32; 3], bool)) -> bool {
|
||||
if entity.exit_portal.is_some() {
|
||||
return false;
|
||||
}
|
||||
entity.exit_portal = Some(exit);
|
||||
entity.exact_teleport = exact;
|
||||
true
|
||||
}
|
||||
|
||||
/// The end of `getPortalPosition`: the exit itself when the teleport is exact,
|
||||
/// else the spot [`Game::resolve_gateway`] finds around it (`findExitPosition`).
|
||||
fn exit_target(exit: [i32; 3], exact: bool, find: impl FnOnce([i32; 3]) -> [i32; 3]) -> [i32; 3] {
|
||||
if exact { exit } else { find(exit) }
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct EndState {
|
||||
/// Gateway position -> (exit, exact teleport); loaded lazily from the save.
|
||||
@@ -247,6 +298,8 @@ impl Game<'_> {
|
||||
layer: 2,
|
||||
color,
|
||||
light: 0,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -754,6 +807,11 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.world
|
||||
.block_entities
|
||||
.get_or_insert_with((origin[0], origin[1], origin[2]), || {
|
||||
BlockEntity::EndGateway(Default::default())
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -775,8 +833,7 @@ impl Game<'_> {
|
||||
self.end.exits.as_mut().expect("loaded exits")
|
||||
}
|
||||
|
||||
fn set_gateway_exit(&mut self, gateway: [i32; 3], exit: [i32; 3], exact: bool) -> Result<()> {
|
||||
self.gateway_exits().insert(gateway, (exit, exact));
|
||||
fn save_gateway_exits(&mut self) -> Result<()> {
|
||||
let list: Vec<_> = self
|
||||
.gateway_exits()
|
||||
.iter()
|
||||
@@ -788,6 +845,35 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drops a gateway's entry from the side file: its block entity is the source of truth.
|
||||
fn forget_gateway_exit(&mut self, gateway: [i32; 3]) -> Result<()> {
|
||||
if self.gateway_exits().remove(&gateway).is_some() {
|
||||
self.save_gateway_exits()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_gateway_exit(&mut self, gateway: [i32; 3], exit: [i32; 3], exact: bool) -> Result<()> {
|
||||
// TheEndGatewayBlockEntity.setExitPosition (also EndGatewayFeature.knownExit).
|
||||
let key = (gateway[0], gateway[1], gateway[2]);
|
||||
let chunk = (gateway[0] >> 4, gateway[2] >> 4);
|
||||
// The gateway's chunk can be unloaded by now (travel waits for the destination): the
|
||||
// exit then waits in the side file until the gateway is used again (`gateway_job`).
|
||||
if self.world.chunks.contains_key(&chunk)
|
||||
&& let BlockEntity::EndGateway(g) = self
|
||||
.world
|
||||
.block_entities
|
||||
.get_or_insert_with(key, || BlockEntity::EndGateway(Default::default()))
|
||||
{
|
||||
g.exit_portal = Some(exit);
|
||||
g.exact_teleport = exact;
|
||||
self.world.modified.insert(chunk);
|
||||
return self.forget_gateway_exit(gateway);
|
||||
}
|
||||
self.gateway_exits().insert(gateway, (exit, exact));
|
||||
self.save_gateway_exits()
|
||||
}
|
||||
|
||||
/// EndGatewayBlock.entityInside and TheEndGatewayBlockEntity.getPortalPosition:
|
||||
/// travel within the End, waiting for the destination chunks to load.
|
||||
fn enter_gateway(&mut self, gateway: [i32; 3]) -> Result<()> {
|
||||
@@ -807,25 +893,39 @@ impl Game<'_> {
|
||||
/// its exit, or the search for one, and the spot to load before either
|
||||
/// is resolved ([`Game::resolve_gateway`]).
|
||||
pub(super) fn gateway_job(&mut self, gateway: [i32; 3]) -> (GatewayJob, Vec3) {
|
||||
let known = self.gateway_exits().get(&gateway).copied();
|
||||
let job = match known {
|
||||
Some((exit, exact)) => GatewayJob::Known { exit, exact },
|
||||
// Worldgen return gateways (end_gateway_return) lead exactly to the End spawn.
|
||||
None if f64::from(gateway[0]).hypot(f64::from(gateway[2])) > 512.0 => {
|
||||
GatewayJob::Known {
|
||||
exit: END_SPAWN,
|
||||
exact: true,
|
||||
}
|
||||
let mut known = self.gateway_exits().get(&gateway).copied();
|
||||
// Once the gateway's chunk is loaded its block entity is the source of truth: an exit the
|
||||
// side file kept for it moves there, and the entry is dropped, so removing or replacing
|
||||
// the exit (`/data remove block`) is not undone by an old entry.
|
||||
let key = (gateway[0], gateway[1], gateway[2]);
|
||||
let chunk = (gateway[0] >> 4, gateway[2] >> 4);
|
||||
if let Some(saved) = known
|
||||
&& self.world.chunks.contains_key(&chunk)
|
||||
&& let BlockEntity::EndGateway(g) = self
|
||||
.world
|
||||
.block_entities
|
||||
.get_or_insert_with(key, || BlockEntity::EndGateway(Default::default()))
|
||||
{
|
||||
if adopt_saved_exit(g, saved) {
|
||||
self.world.modified.insert(chunk);
|
||||
}
|
||||
None => GatewayJob::Find { gateway },
|
||||
};
|
||||
known = None;
|
||||
let _ = self.forget_gateway_exit(gateway);
|
||||
}
|
||||
let job = GatewayJob::for_gateway(
|
||||
gateway,
|
||||
self.world
|
||||
.block_entities
|
||||
.get(&(gateway[0], gateway[1], gateway[2])),
|
||||
known,
|
||||
);
|
||||
let destination = match job {
|
||||
GatewayJob::Known { exit, .. } => Vec3::new(
|
||||
f64::from(exit[0]) + 0.5,
|
||||
f64::from(exit[1]),
|
||||
f64::from(exit[2]) + 0.5,
|
||||
),
|
||||
GatewayJob::Find { gateway } => {
|
||||
GatewayJob::Find { gateway, .. } => {
|
||||
let d = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2]))
|
||||
.normalize_or_zero()
|
||||
* 1024.0;
|
||||
@@ -882,12 +982,10 @@ impl Game<'_> {
|
||||
[p[0], p[1] + 1, p[2]]
|
||||
};
|
||||
match job {
|
||||
GatewayJob::Known { exit, exact } => Ok(bottom_center(if exact {
|
||||
exit
|
||||
} else {
|
||||
find_exit(self, exit)
|
||||
})),
|
||||
GatewayJob::Find { gateway } => {
|
||||
GatewayJob::Known { exit, exact } => Ok(bottom_center(exit_target(exit, exact, |e| {
|
||||
find_exit(self, e)
|
||||
}))),
|
||||
GatewayJob::Find { gateway, exact } => {
|
||||
// findExitPortalXZPosTentative over the loaded chunks.
|
||||
let dir = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2]))
|
||||
.normalize_or_zero();
|
||||
@@ -947,8 +1045,11 @@ impl Game<'_> {
|
||||
let exit = [tallest[0], tallest[1] + 10, tallest[2]];
|
||||
self.place_gateway(exit)?;
|
||||
self.set_gateway_exit(exit, gateway, false)?;
|
||||
self.set_gateway_exit(gateway, exit, false)?;
|
||||
Ok(bottom_center(find_exit(self, exit)))
|
||||
// setExitPosition keeps the gateway's own exactTeleport.
|
||||
self.set_gateway_exit(gateway, exit, exact)?;
|
||||
Ok(bottom_center(exit_target(exit, exact, |e| {
|
||||
find_exit(self, e)
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1240,8 +1341,103 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: full,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::containers::EndGateway;
|
||||
|
||||
#[test]
|
||||
fn a_saved_exit_moves_into_a_gateway_without_one() {
|
||||
let saved = ([8, 100, 0], true);
|
||||
let mut bare = EndGateway::default();
|
||||
assert!(adopt_saved_exit(&mut bare, saved));
|
||||
assert_eq!(bare.exit_portal, Some([8, 100, 0]));
|
||||
assert!(bare.exact_teleport);
|
||||
// The block entity's own exit wins over a stale entry.
|
||||
let mut own = EndGateway {
|
||||
exit_portal: Some([1, 2, 3]),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!adopt_saved_exit(&mut own, saved));
|
||||
assert_eq!(own.exit_portal, Some([1, 2, 3]));
|
||||
assert!(!own.exact_teleport);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_exit_keeps_the_gateways_exact_teleport() {
|
||||
let exact = BlockEntity::EndGateway(EndGateway {
|
||||
exact_teleport: true,
|
||||
..Default::default()
|
||||
});
|
||||
let plain = BlockEntity::EndGateway(EndGateway::default());
|
||||
assert!(exact_teleport(Some(&exact)));
|
||||
assert!(!exact_teleport(Some(&plain)));
|
||||
assert!(!exact_teleport(None));
|
||||
assert!(!exact_teleport(Some(&BlockEntity::DaylightDetector)));
|
||||
let exit = [8, 100, 0];
|
||||
// Exact: the exit itself, nothing is searched for.
|
||||
assert_eq!(exit_target(exit, true, |_| panic!("searched")), exit);
|
||||
assert_eq!(
|
||||
exit_target(exit, false, |e| [e[0], e[1] + 11, e[2]]),
|
||||
[8, 111, 0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_gateway_job_prefers_block_entity_then_legacy_then_heuristic() {
|
||||
let gateway = [702, 100, 0];
|
||||
let exit = [8, 100, 0];
|
||||
for exact in [true, false] {
|
||||
let entity = BlockEntity::EndGateway(EndGateway {
|
||||
exit_portal: Some(exit),
|
||||
exact_teleport: exact,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(gateway, Some(&entity), Some((END_SPAWN, !exact))),
|
||||
GatewayJob::Known { exit, exact },
|
||||
);
|
||||
}
|
||||
let empty = BlockEntity::EndGateway(EndGateway::default());
|
||||
for entity in [None, Some(&empty)] {
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(gateway, entity, Some((exit, false))),
|
||||
GatewayJob::Known { exit, exact: false }
|
||||
);
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(gateway, entity, None),
|
||||
GatewayJob::Known {
|
||||
exit: END_SPAWN,
|
||||
exact: true
|
||||
}
|
||||
);
|
||||
let nearby = [96, 75, 0];
|
||||
let exact = BlockEntity::EndGateway(EndGateway {
|
||||
exact_teleport: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(nearby, Some(&exact), None),
|
||||
GatewayJob::Find {
|
||||
gateway: nearby,
|
||||
exact: true
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(nearby, entity, None),
|
||||
GatewayJob::Find {
|
||||
gateway: nearby,
|
||||
exact: false
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,12 +60,27 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Entity.setViewScale (LevelExtractor.extractVisibleEntities): the entity
|
||||
/// distance option times the render distance's boost — 1 at 8 chunks, 2.5
|
||||
/// at 20 or more. The render distance is the client setting, capped by
|
||||
/// the server's view distance when connected
|
||||
/// (Options.getEffectiveRenderDistance).
|
||||
pub(super) fn view_scale(&self) -> f64 {
|
||||
let distance = match &self.remote {
|
||||
Some(r) => self.settings.render_distance.min(r.view_distance),
|
||||
None => self.settings.render_distance,
|
||||
};
|
||||
(f64::from(distance) / 8.0).clamp(1.0, 2.5) * f64::from(self.settings.entity_distance)
|
||||
}
|
||||
}
|
||||
|
||||
/// Entity.shouldRenderAtSqrDistance's default range: 64 times the bounding
|
||||
/// box's mean edge, scaled by the entity distance option.
|
||||
pub(super) fn default_range(size: glam::DVec3, view_scale: f32) -> f64 {
|
||||
/// box's mean edge, scaled by the view scale.
|
||||
pub(super) fn default_range(size: glam::DVec3, view_scale: f64) -> f64 {
|
||||
let edge = (size.x + size.y + size.z) / 3.0;
|
||||
let edge = if edge.is_nan() { 1.0 } else { edge };
|
||||
edge * 64.0 * f64::from(view_scale)
|
||||
edge * 64.0 * view_scale
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
|
||||
@@ -572,16 +572,16 @@ impl Game<'_> {
|
||||
let pitch = (1.0 + (client_float() - client_float()) * 0.2) * 0.7;
|
||||
self.audio
|
||||
.play("minecraft:entity.generic.explode", Some(center), 4.0, pitch);
|
||||
let block_count = if interaction == BlockInteraction::Keep {
|
||||
0
|
||||
} else {
|
||||
targets.len()
|
||||
};
|
||||
// ServerLevel.explode sends toBlow.size() whatever the interaction, and
|
||||
// `ServerExplosion.isSmall` also covers an explosion that keeps blocks.
|
||||
let block_count = targets.len();
|
||||
let small = radius < 2.0 || interaction == BlockInteraction::Keep;
|
||||
let (world, facts) = (&self.world, self.res.facts());
|
||||
self.particles.explosion(center, radius, block_count, &|b| {
|
||||
facts.is_air(world.block(b[0], b[1], b[2]))
|
||||
});
|
||||
self.lan_explode(center, radius, block_count);
|
||||
self.particles
|
||||
.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);
|
||||
Ok(())
|
||||
}
|
||||
/// `BlockBehaviour.onExplosionHit` (TNT: `wasExploded` primes a short fuse).
|
||||
@@ -598,6 +598,11 @@ impl Game<'_> {
|
||||
}
|
||||
let name = self.res.facts().get(state).name.clone();
|
||||
let is_tnt = &*name == "minecraft:tnt";
|
||||
// CreakingHeartBlock.onExplosionHit: its creaking dies (no
|
||||
// experience, as Horizon does not track a TNT's owner).
|
||||
if &*name == "minecraft:creaking_heart" {
|
||||
self.creaking_heart_broken(pos, false);
|
||||
}
|
||||
// Block.onExplosionHit: the drops copy from the block entity, which
|
||||
// then spills its contents as the block turns to air.
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
|
||||
@@ -64,6 +64,25 @@ impl Game<'_> {
|
||||
pos: BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
data: 0,
|
||||
});
|
||||
} else if self
|
||||
.res
|
||||
.facts()
|
||||
.get(block.state)
|
||||
.name
|
||||
.starts_with("minecraft:suspicious_")
|
||||
{
|
||||
// BrushableBlock.onBrokenAfterFall.
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.level_events.push(LevelEvent::LevelEvent {
|
||||
id: 2001,
|
||||
pos: at,
|
||||
data: block.state.0 as i32,
|
||||
});
|
||||
self.level_events.push(LevelEvent::GameEvent {
|
||||
pos: at,
|
||||
event: "minecraft:block_destroy",
|
||||
state: None,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7,12 +7,13 @@ use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct Fangs {
|
||||
pos: [f64; 3],
|
||||
yaw: f32,
|
||||
pub id: u32,
|
||||
pub pos: [f64; 3],
|
||||
pub yaw: f32,
|
||||
warmup: i32,
|
||||
life: i32,
|
||||
pub life: i32,
|
||||
owner: u32,
|
||||
started: bool,
|
||||
pub started: bool,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct FangState {
|
||||
@@ -21,6 +22,11 @@ pub(super) struct FangState {
|
||||
}
|
||||
impl Game<'_> {
|
||||
pub(super) fn add_fangs(&mut self, at: [f64; 3], yaw: f32, delay: i32, owner: u32) {
|
||||
self.next_entity += 1;
|
||||
self.insert_fangs(self.next_entity, at, yaw, delay, owner);
|
||||
}
|
||||
|
||||
pub(super) fn insert_fangs(&mut self, id: u32, at: [f64; 3], yaw: f32, delay: i32, owner: u32) {
|
||||
if self.fangs.model.is_none() {
|
||||
self.fangs.model = horizon_client::mobs::load_model(
|
||||
&self.options.resources,
|
||||
@@ -29,6 +35,7 @@ impl Game<'_> {
|
||||
.map(Arc::new);
|
||||
}
|
||||
self.fangs.list.push(Fangs {
|
||||
id,
|
||||
pos: at,
|
||||
yaw,
|
||||
warmup: delay,
|
||||
@@ -37,6 +44,30 @@ impl Game<'_> {
|
||||
started: false,
|
||||
});
|
||||
}
|
||||
/// `EvokerFangs.handleEntityEvent`: only event 4 starts a client attack.
|
||||
pub(super) fn remote_fangs_event(&mut self, id: u32, event: i8) {
|
||||
if event == 4
|
||||
&& let Some(f) = self.fangs.list.iter_mut().find(|f| f.id == id)
|
||||
{
|
||||
f.started = true;
|
||||
self.audio.play_unshared(
|
||||
"minecraft:entity.evoker_fangs.attack",
|
||||
Some(f.pos),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `EvokerFangs.tick`: clients only advance animation, never damage or discard.
|
||||
pub(super) fn tick_remote_fangs(&mut self) {
|
||||
for f in &mut self.fangs.list {
|
||||
if f.started {
|
||||
f.life -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `EvokerFangs.tick`.
|
||||
pub(super) fn tick_fangs(&mut self) {
|
||||
let mut list = std::mem::take(&mut self.fangs.list);
|
||||
@@ -50,7 +81,8 @@ impl Game<'_> {
|
||||
}
|
||||
if !f.started {
|
||||
f.started = true;
|
||||
self.audio.play(
|
||||
self.lan_entity_event(f.id as i32, 4, f.pos);
|
||||
self.audio.play_unshared(
|
||||
"minecraft:entity.evoker_fangs.attack",
|
||||
Some(f.pos),
|
||||
1.0,
|
||||
@@ -71,12 +103,23 @@ impl Game<'_> {
|
||||
let b = horizon_core::geometry::Aabb::new(lo, hi);
|
||||
// Every player in the box, the server's players too.
|
||||
let pos = f.pos;
|
||||
// `EvokerFangs.dealDamageTo`: indirectMagic(fangs, owner) while the
|
||||
// owner lives, plain magic (nobody to name) once it is gone.
|
||||
let owner = self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|m| m.id == f.owner && m.alive())
|
||||
.then_some(f.owner);
|
||||
let bite = move |g: &mut Self| {
|
||||
if let Some(p) = g.player_view()
|
||||
&& p.alive
|
||||
&& p.bounds.intersects(b)
|
||||
{
|
||||
g.mob_hits_player_kind(6.0, pos, "minecraft:indirect_magic");
|
||||
let kind = match owner {
|
||||
Some(_) => "minecraft:indirect_magic",
|
||||
None => "minecraft:magic",
|
||||
};
|
||||
g.mob_hits_player_caused(6.0, pos, kind, owner);
|
||||
}
|
||||
};
|
||||
bite(self);
|
||||
|
||||
@@ -643,6 +643,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,8 +311,9 @@ impl Game<'_> {
|
||||
let sprites = &self.gui.atlas.sprites;
|
||||
let mut maps: Vec<(i32, glam::Mat4, u32)> = Vec::new();
|
||||
let mut items: Vec<(ItemStack, u32, glam::Mat4, u8)> = Vec::new();
|
||||
// ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at 100%.
|
||||
let range = 16.0 * 64.0 * f64::from(self.settings.entity_distance);
|
||||
// ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at a view scale
|
||||
// of 1.
|
||||
let range = 16.0 * 64.0 * self.view_scale();
|
||||
for f in &self.item_frames {
|
||||
let entity_start = out.len();
|
||||
let map = f.item.as_ref().and_then(super::maps::map_id);
|
||||
@@ -377,6 +378,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -20,7 +20,8 @@ use horizon_core::{
|
||||
};
|
||||
use horizon_world::{
|
||||
block_collision::BlockCollisionRegistry, collision_context::CollisionContext,
|
||||
heightmap::HeightmapType, worldgen::terrain::TerrainBlockStates,
|
||||
heightmap::HeightmapType, level::flags::UPDATE_CLIENTS, registry::BlockStateId,
|
||||
worldgen::terrain::TerrainBlockStates,
|
||||
};
|
||||
|
||||
/// The loaded world as the spawn searches query it.
|
||||
@@ -109,7 +110,17 @@ impl Game<'_> {
|
||||
while let Some((dx, dz)) = self.spawn_spiral.current() {
|
||||
if self.spawn_spiral.tested() {
|
||||
let (cx, cz) = (ox + dx, oz + dz);
|
||||
if !self.streaming.is_lit(cx, cz) {
|
||||
if self.options.dedicated.is_some() {
|
||||
// getChunk(FULL) of the tested chunk alone: its 3x3 is decorated.
|
||||
if !self.world.chunks.contains_key(&(cx, cz)) {
|
||||
let area = [((cx, cz), -1)];
|
||||
if self.streaming.extra != area {
|
||||
self.streaming.extra = area.to_vec();
|
||||
self.streaming.rank_full_task(cx, cz);
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
} else if !self.streaming.is_lit(cx, cz) {
|
||||
self.streaming.center = (cx, cz);
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -121,6 +132,96 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(Some([ox * 16 + 8, height, oz * 16 + 8]))
|
||||
}
|
||||
/// MinecraftServer.setInitialSpawn's bonus chest: BonusChestFeature.place
|
||||
/// in the spawn chunk, a chest with the spawn bonus loot and a torch on
|
||||
/// each side that can hold one, on the first free column of a shuffled
|
||||
/// walk over the chunk. Skipped while the chunk is not loaded.
|
||||
pub(super) fn place_bonus_chest(&mut self, origin: [i32; 3]) -> Result<()> {
|
||||
use horizon_client::containers::BlockEntity;
|
||||
let (cx, cz) = (origin[0] >> 4, origin[2] >> 4);
|
||||
let registry = &self.pack.assets.registry;
|
||||
let (Some(chest), Some(torch)) = (
|
||||
registry.default_state("minecraft:chest"),
|
||||
registry.default_state("minecraft:torch"),
|
||||
) else {
|
||||
return Ok(());
|
||||
};
|
||||
if !self.streaming.is_lit(cx, cz) {
|
||||
return Ok(());
|
||||
}
|
||||
// `level.getRandom()`. Util.toShuffledList of the chunk's x and z.
|
||||
let random = &mut self.world.random;
|
||||
let mut shuffled = |min: i32| -> [i32; 16] {
|
||||
let mut values: [i32; 16] = std::array::from_fn(|i| min + i as i32);
|
||||
for end in (2..=16usize).rev() {
|
||||
let other = random.next_int_bound(end as i32).unwrap_or(0) as usize;
|
||||
values.swap(end - 1, other);
|
||||
}
|
||||
values
|
||||
};
|
||||
let (xs, zs) = (shuffled(cx * 16), shuffled(cz * 16));
|
||||
for x in xs {
|
||||
for z in zs {
|
||||
let y = self
|
||||
.spawn_columns()
|
||||
.height(HeightmapType::MotionBlockingNoLeaves, x, z);
|
||||
if self.world.block(x, y, z) != self.world.air && !self.collision_empty([x, y, z]) {
|
||||
continue;
|
||||
}
|
||||
self.set_block_clients([x, y, z], chest)?;
|
||||
let seed = self.world.random.next_long();
|
||||
self.world
|
||||
.block_entities
|
||||
.insert((x, y, z), BlockEntity::Chest(Vec::new()));
|
||||
self.load_block_entity_nbt(
|
||||
[x, y, z],
|
||||
&format!(
|
||||
"{{LootTable:\"minecraft:chests/spawn_bonus_chest\",LootTableSeed:{seed}L}}"
|
||||
),
|
||||
);
|
||||
for [dx, dz] in [[0, -1], [1, 0], [0, 1], [-1, 0]] {
|
||||
let at = [x + dx, y, z + dz];
|
||||
let survives = self.with_level(|b, level, ticks| {
|
||||
b.can_survive(level, ticks, BlockPos::new(at[0], at[1], at[2]), torch)
|
||||
});
|
||||
if survives {
|
||||
self.set_block_clients(at, torch)?;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `Level.setBlock(pos, state, UPDATE_CLIENTS)`: no neighbour updates.
|
||||
fn set_block_clients(&mut self, pos: [i32; 3], state: BlockStateId) -> Result<()> {
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.set_block(
|
||||
level,
|
||||
ticks,
|
||||
BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
state,
|
||||
UPDATE_CLIENTS,
|
||||
);
|
||||
});
|
||||
self.process_level_events()
|
||||
}
|
||||
/// The block at `pos` has no collision shape.
|
||||
fn collision_empty(&self, pos: [i32; 3]) -> bool {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
self.collision
|
||||
.collision_shape_with_context(
|
||||
&mut scene,
|
||||
self.world.block(pos[0], pos[1], pos[2]),
|
||||
BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
&CollisionContext::empty(false),
|
||||
)
|
||||
.is_ok_and(|shape| shape.is_empty())
|
||||
}
|
||||
/// PlayerSpawnFinder.findSpawn around the world spawn: the feet of a
|
||||
/// player who has no position yet. None while a candidate's chunk loads.
|
||||
pub(super) fn find_player_spawn(&mut self, suggestion: [i32; 3]) -> Result<Option<Vec3>> {
|
||||
|
||||
@@ -52,6 +52,8 @@ struct Flags {
|
||||
/// server follows while it runs (IntegratedServer.tickServer).
|
||||
render_distance: AtomicI32,
|
||||
simulation_distance: AtomicI32,
|
||||
/// `MinecraftServer.isCurrentlySaving`: the server is autosaving.
|
||||
saving: AtomicBool,
|
||||
}
|
||||
|
||||
/// Work the window hands its server thread (`MinecraftServer.execute`).
|
||||
@@ -69,6 +71,10 @@ impl Integrated {
|
||||
pub fn smoothed_tick_ms(&self) -> f32 {
|
||||
f32::from_bits(self.flags.smoothed_tick_ms.load(Ordering::Relaxed))
|
||||
}
|
||||
/// `MinecraftServer.isCurrentlySaving`.
|
||||
pub fn is_saving(&self) -> bool {
|
||||
self.flags.saving.load(Ordering::Relaxed)
|
||||
}
|
||||
/// `IntegratedServer.tickServer` skips the world while the game is paused.
|
||||
pub fn set_paused(&self, paused: bool) {
|
||||
self.flags.paused.store(paused, Ordering::Relaxed);
|
||||
@@ -148,6 +154,12 @@ pub(super) struct Serving {
|
||||
/// is out of its place: applied after the tick.
|
||||
pending: Option<Publish>,
|
||||
}
|
||||
impl Serving {
|
||||
/// The window reads this as `MinecraftServer.isCurrentlySaving`.
|
||||
pub(super) fn set_saving(&self, saving: bool) {
|
||||
self.flags.saving.store(saving, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
enum Publish {
|
||||
Open(TcpListener, LanSettings),
|
||||
Close,
|
||||
@@ -408,6 +420,8 @@ impl Game<'_> {
|
||||
s.flags.published.store(0, Ordering::Relaxed);
|
||||
}
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
// PlayerList.removeAll: the owner and every guest leave the game.
|
||||
host.award_leave_game_to_all();
|
||||
for g in &host.hub.guests {
|
||||
self.save_guest(&host, g);
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ pub(super) struct Host<'a> {
|
||||
banners: &'a horizon_client::banner::Banners,
|
||||
sprites: &'a HashMap<String, Rect>,
|
||||
skins: &'a RefCell<horizon_client::skins::Downloader>,
|
||||
profiles: &'a RefCell<horizon_client::skins::Resolver>,
|
||||
}
|
||||
impl SpecialHost for Host<'_> {
|
||||
fn banner_texture(&self, pattern: &str) -> Option<String> {
|
||||
@@ -144,14 +145,14 @@ impl SpecialHost for Host<'_> {
|
||||
fn player_skin(&self, profile: &Value) -> Option<String> {
|
||||
// SkullBlockRenderer.getPlayerSkin: SkinManager.getInsecureSkinInformation
|
||||
// accepts a texture property without a signature, unlike the tab list.
|
||||
let url = horizon_client::skins::profile_skin_url(profile)?;
|
||||
let location =
|
||||
horizon_client::skins::skin_location(&horizon_client::skins::texture_hash(&url)?);
|
||||
let _ = self
|
||||
.skins
|
||||
.borrow_mut()
|
||||
.state(horizon_client::skins::Kind::Skin, &url);
|
||||
self.sprites.contains_key(&location).then_some(location)
|
||||
// A `Dynamic` profile (a name or id only) resolves through the
|
||||
// services (`PlayerSkinRenderCache.getOrDefault`).
|
||||
Some(horizon_client::skins::head_skin(
|
||||
profile,
|
||||
&mut self.profiles.borrow_mut(),
|
||||
&mut self.skins.borrow_mut(),
|
||||
|location| self.sprites.contains_key(location),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +195,7 @@ impl Game<'_> {
|
||||
banners: &self.res.stations().banners,
|
||||
sprites: &self.gui.atlas.sprites,
|
||||
skins: &self.skin_downloads,
|
||||
profiles: &self.skin_profiles,
|
||||
}
|
||||
}
|
||||
/// The client level as item models read it: worked out once a frame (a
|
||||
@@ -296,8 +298,32 @@ impl Game<'_> {
|
||||
x,
|
||||
y,
|
||||
self.started.elapsed().as_secs_f32(),
|
||||
self.settings.file.double("glintSpeed") as f32,
|
||||
self.settings.file.double("glintStrength") as f32,
|
||||
);
|
||||
}
|
||||
/// The `GLINT_TEXTURING` inputs the atlas and options provide.
|
||||
pub(super) fn glint(&self) -> Option<item_render::Glint> {
|
||||
self.gui
|
||||
.atlas
|
||||
.sprites
|
||||
.get("misc/enchanted_glint_item")
|
||||
.copied()
|
||||
.map(|_| item_render::Glint {
|
||||
speed: self.settings.file.double("glintSpeed") as f32,
|
||||
alpha: self.settings.file.double("glintStrength") as f32,
|
||||
millis: self.started.elapsed().as_secs_f32() * 1000.0,
|
||||
scale: 0.5,
|
||||
})
|
||||
}
|
||||
/// The glint scroll phase shared by world items and armour
|
||||
/// (`item_render::Glint::scroll_ticks`).
|
||||
pub(super) fn glint_ticks(&self) -> f32 {
|
||||
item_render::Glint::scroll_ticks(
|
||||
self.started.elapsed().as_secs_f32() * 1000.0,
|
||||
self.settings.file.double("glintSpeed") as f32,
|
||||
)
|
||||
}
|
||||
/// Submit a resolved item under `pose` (camera-relative) at `light`
|
||||
/// (packed lightmap coordinates).
|
||||
pub(super) fn submit_item_world(
|
||||
@@ -311,6 +337,7 @@ impl Game<'_> {
|
||||
out,
|
||||
light,
|
||||
lights: Some(diffuse_lights(self.dim == Dim::Nether)),
|
||||
glint: self.glint(),
|
||||
};
|
||||
state.submit(
|
||||
&self.pack.item_renderer,
|
||||
@@ -332,6 +359,7 @@ impl Game<'_> {
|
||||
out,
|
||||
light,
|
||||
lights: None,
|
||||
glint: self.glint(),
|
||||
};
|
||||
state.submit(
|
||||
&self.pack.item_renderer,
|
||||
|
||||
@@ -11,7 +11,7 @@ use horizon_client::{
|
||||
chunks::ChunkEncoder,
|
||||
data::SyncData,
|
||||
entity::{EntityState, States},
|
||||
hub::{HostEvent, HostState, Hub, HubConfig, TickCtx},
|
||||
hub::{BodyFlags, HostEvent, HostState, Hub, HubConfig, TickCtx},
|
||||
playerdata,
|
||||
},
|
||||
mobs::{Mob, Who, leash::Holder},
|
||||
@@ -21,6 +21,7 @@ use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::{
|
||||
crypto::offline_uuid,
|
||||
entity_data::{Value, serializer},
|
||||
packets::play::{self, Clientbound as C},
|
||||
};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
@@ -91,6 +92,33 @@ pub(super) struct LanHost {
|
||||
states: lan::entity::States,
|
||||
}
|
||||
|
||||
impl LanHost {
|
||||
/// `PlayerList.remove`: `player.awardStat(Stats.LEAVE_GAME)` before the
|
||||
/// player is saved. Once per guest, however often they are saved.
|
||||
pub(super) fn award_leave_game(&mut self, conn: u32) {
|
||||
if let Some(play) = self.play.get_mut(&conn)
|
||||
&& !play.left_game
|
||||
{
|
||||
play.left_game = true;
|
||||
play.stats.custom("leave_game", 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// `PlayerList.removeAll`: every player who joined leaves with the world.
|
||||
pub(super) fn award_leave_game_to_all(&mut self) {
|
||||
let conns: Vec<u32> = self
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed)
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in conns {
|
||||
self.award_leave_game(conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The player who owns an integrated server's world: a guest whose state is
|
||||
/// the world's own player (`players/data/<singleplayer_uuid>.dat`), as in
|
||||
/// single player.
|
||||
@@ -191,6 +219,17 @@ fn mob_meta(
|
||||
meta.push((9, serializer::FLOAT, Value::Float(health)));
|
||||
}
|
||||
defaults.push((9, serializer::FLOAT, Value::Float(1.0)));
|
||||
// LivingEntity.DATA_EFFECT_PARTICLES and DATA_EFFECT_AMBIENCE_ID: the
|
||||
// swirls of its effects (the ones a mob has are visible, not ambient).
|
||||
meta.extend(
|
||||
hub.effect_meta(
|
||||
(m.extra.effects.iter())
|
||||
.filter(|e| !m.extra.hidden_effects.contains(&e.0))
|
||||
.map(|e| (e.0, false)),
|
||||
),
|
||||
);
|
||||
defaults.push((10, serializer::PARTICLES, Value::Particles(Vec::new())));
|
||||
defaults.push((11, serializer::BOOLEAN, Value::Bool(false)));
|
||||
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2, aggressive 4 (what
|
||||
// draws a vindicator's axe, a skeleton's drawn bow, a piglin's raised
|
||||
// weapon).
|
||||
@@ -236,6 +275,27 @@ fn mob_meta(
|
||||
}
|
||||
defaults.push((16, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// SnowGolem.DATA_PUMPKIN_ID: 16 while the pumpkin is on (the default).
|
||||
"minecraft:snow_golem" => {
|
||||
if e.sheared {
|
||||
meta.push((16, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
defaults.push((16, serializer::BYTE, Value::Byte(16)));
|
||||
}
|
||||
// MushroomCow.DATA_TYPE: 0 red, 1 brown.
|
||||
"minecraft:mooshroom" => {
|
||||
if e.variant == 1 {
|
||||
meta.push((17, serializer::INT, Value::Int(1)));
|
||||
}
|
||||
defaults.push((17, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
// Piglin.DATA_IS_DANCING (after the baby flag and the crossbow charge).
|
||||
"minecraft:piglin" => {
|
||||
if e.scratch[0] > 0 {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 when sheared.
|
||||
"minecraft:sheep" => {
|
||||
let value = (e.color & 15) | if e.sheared { 16 } else { 0 };
|
||||
@@ -350,6 +410,14 @@ fn mob_meta(
|
||||
}
|
||||
defaults.push((17, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// PolarBear.DATA_STANDING_ID (after AgeableMob's two): the bear
|
||||
// rears up; the client animates it.
|
||||
"minecraft:polar_bear" => {
|
||||
if e.flag {
|
||||
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// SpellcasterIllager.DATA_SPELL_CASTING_ID: the casting pose.
|
||||
"minecraft:evoker" | "minecraft:illusioner" => {
|
||||
if e.variant2 != 0 {
|
||||
@@ -664,6 +732,7 @@ impl Game<'_> {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
host.award_leave_game_to_all();
|
||||
for g in &host.hub.guests {
|
||||
self.save_guest(&host, g);
|
||||
}
|
||||
@@ -675,6 +744,7 @@ impl Game<'_> {
|
||||
self.audio.capture = None;
|
||||
self.lan_events.clear();
|
||||
self.lan_critical_hits.clear();
|
||||
self.lan_cushion_breaks.clear();
|
||||
self.streaming.extra.clear();
|
||||
if announce {
|
||||
self.system_message(Text::translate(
|
||||
@@ -845,6 +915,16 @@ impl Game<'_> {
|
||||
living_flags: living_flags(self.using)
|
||||
| if p.auto_spin_attack_ticks > 0 { 4 } else { 0 },
|
||||
bed: self.sleeping,
|
||||
body: BodyFlags {
|
||||
gliding: p.fall_flying,
|
||||
swimming: p.swimming,
|
||||
crawling: p.pose == horizon_client::pose::Pose::Swimming && !p.swimming,
|
||||
crouching: p.pose == horizon_client::pose::Pose::Crouching,
|
||||
spin_attack: p.auto_spin_attack_ticks > 0,
|
||||
on_fire: p.fire_ticks > 0,
|
||||
invisible: self.potions.effects.get("invisibility").is_some(),
|
||||
glowing: self.potions.effects.get("glowing").is_some(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -962,6 +1042,16 @@ impl Game<'_> {
|
||||
host.hub.block_event(pos, b0, b1, state);
|
||||
}
|
||||
let entities = states.as_slice();
|
||||
// An entity that left the level since the last tick (a snowball
|
||||
// that hit) is dropped by the tracker below: its event goes out
|
||||
// first, as `Level.broadcastEntityEvent` precedes the discard.
|
||||
let (gone, later): (Vec<_>, Vec<_>) = std::mem::take(&mut self.lan_entity_events)
|
||||
.into_iter()
|
||||
.partition(|(id, ..)| !entities.iter().any(|e| e.id == *id));
|
||||
self.lan_entity_events = later;
|
||||
for (id, event, at) in gone {
|
||||
host.hub.entity_event(id, event, at);
|
||||
}
|
||||
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
|
||||
let events = {
|
||||
let clock_rate = self.network_clock_rate();
|
||||
@@ -1054,6 +1144,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.lan_crafting_left(&mut host, &mut guest);
|
||||
if guest.placed {
|
||||
host.award_leave_game(guest.conn);
|
||||
}
|
||||
self.save_guest(&host, &guest);
|
||||
host.models.remove(&guest.conn);
|
||||
host.play.remove(&guest.conn);
|
||||
@@ -1243,8 +1336,9 @@ impl Game<'_> {
|
||||
self.lan_entity_events.push((id, event, at));
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.crit`: this window's emitter, and an animation for the
|
||||
/// attacker's client and observers once the hub is back in place.
|
||||
/// `ServerPlayer.crit` (or `magicCrit` when `magic`): this window's
|
||||
/// emitter, and an animation for the attacker's client and observers once
|
||||
/// the hub is back in place.
|
||||
pub(super) fn critical_hit(
|
||||
&mut self,
|
||||
attacker: Option<u32>,
|
||||
@@ -1252,21 +1346,50 @@ impl Game<'_> {
|
||||
feet: [f64; 3],
|
||||
width: f64,
|
||||
height: f64,
|
||||
magic: bool,
|
||||
) {
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
self.particles.tracking_emitter(
|
||||
ParticleOptions::simple(ParticleType::Crit),
|
||||
feet,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
let particle = if magic {
|
||||
ParticleType::EnchantedHit
|
||||
} else {
|
||||
ParticleType::Crit
|
||||
};
|
||||
self.particles
|
||||
.tracking_emitter(ParticleOptions::simple(particle), feet, width, height);
|
||||
if self.audio.capture.is_some() {
|
||||
self.lan_critical_hits.push((attacker, id));
|
||||
self.lan_critical_hits.push((attacker, id, magic));
|
||||
}
|
||||
}
|
||||
/// Send the tick's level events, sounds and mob animations.
|
||||
fn lan_flush_effects(&mut self, host: &mut LanHost) {
|
||||
let critical_hits = std::mem::take(&mut self.lan_critical_hits);
|
||||
for (state, at) in std::mem::take(&mut self.lan_cushion_breaks) {
|
||||
if let Some(id) = host
|
||||
.hub
|
||||
.registry_id("minecraft:particle_type", "minecraft:block")
|
||||
{
|
||||
host.hub.broadcast_near(
|
||||
at,
|
||||
32.0,
|
||||
&C::LevelParticles(play::LevelParticles {
|
||||
particle: horizon_protocol::entity_data::Particle {
|
||||
id,
|
||||
data: Some(horizon_protocol::entity_data::ParticleData::State(
|
||||
state.0 as i32,
|
||||
)),
|
||||
},
|
||||
override_limiter: false,
|
||||
always_show: false,
|
||||
pos: at,
|
||||
dist: [0.25, 0.0625, 0.25],
|
||||
max_speed: [0.05; 3],
|
||||
count: 10,
|
||||
randomization: 0,
|
||||
}),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (kind, pos, data, acting) in std::mem::take(&mut self.lan_events) {
|
||||
if kind == 2001 {
|
||||
host.broken
|
||||
@@ -1339,13 +1462,13 @@ impl Game<'_> {
|
||||
seen.insert(m.id, (m.hurt_time, dead, swing));
|
||||
}
|
||||
host.mob_state.insert(self.dim, seen);
|
||||
for (attacker, target) in critical_hits {
|
||||
for (attacker, target, magic) in critical_hits {
|
||||
let Some(attacker) = attacker.map_or(Some(host.host_entity), |conn| {
|
||||
host.hub.guest(conn).map(|g| g.entity_id)
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
host.hub.critical_hit(attacker, target);
|
||||
host.hub.critical_hit(attacker, target, magic);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1428,6 +1551,13 @@ impl Game<'_> {
|
||||
.each_ref()
|
||||
.map(|s| s.as_ref().map(|s| s.item.clone()));
|
||||
m.extra.crouching = g.sneaking;
|
||||
m.extra.sprinting = g.sprinting;
|
||||
// LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects.
|
||||
let visible = || g.effects.list.iter().filter(|e| e.visible);
|
||||
m.extra.effect_particles = visible()
|
||||
.filter_map(|e| super::client_ambient::effect_particle(e.id, e.ambient))
|
||||
.collect();
|
||||
m.extra.effect_ambient = visible().all(|e| e.ambient);
|
||||
// Lying in its bed (LivingEntity.getBedOrientation).
|
||||
m.extra.ints[1] = g.bed.map_or(0, |b| self.bed_facing(b));
|
||||
// LivingEntity.updateSwingTime: the arm swing lan_swings started.
|
||||
|
||||
@@ -74,6 +74,9 @@ pub(super) struct GuestPlay {
|
||||
potions: super::potions::BodyPotions,
|
||||
/// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw.
|
||||
pub(super) stats: super::stats::Stats,
|
||||
/// `Stats.LEAVE_GAME` is counted: a guest is saved more than once on the
|
||||
/// way out, the statistic is awarded once (`LanHost::award_leave_game`).
|
||||
pub(super) left_game: bool,
|
||||
last_inventory: u64,
|
||||
/// `Entity.portalProcess` and `portalCooldown` (game/server_travel.rs).
|
||||
pub(super) portal: horizon_client::nether_portal::PortalState,
|
||||
@@ -165,6 +168,8 @@ pub(super) struct GuestSwap {
|
||||
acting: (Option<u32>, Option<String>, Option<u128>),
|
||||
/// The packets queued for that guest.
|
||||
packets: Vec<play::Clientbound>,
|
||||
/// The entity events queued for that guest.
|
||||
entity_events: Vec<i8>,
|
||||
/// The host's count of swings started, kept while the guest acts.
|
||||
swings: u32,
|
||||
/// How many projectiles there were: those added since are the guest's.
|
||||
@@ -389,6 +394,7 @@ impl Game<'_> {
|
||||
self.lan_acting_uuid.replace(g.uuid),
|
||||
);
|
||||
let packets = std::mem::take(&mut self.guest_packets);
|
||||
let entity_events = std::mem::take(&mut self.guest_entity_events);
|
||||
let swings = self.swings_started;
|
||||
let projectiles = self.creatures.projectiles.len();
|
||||
let modifiers = std::mem::replace(
|
||||
@@ -449,6 +455,7 @@ impl Game<'_> {
|
||||
death_cause,
|
||||
acting,
|
||||
packets,
|
||||
entity_events,
|
||||
swings,
|
||||
projectiles,
|
||||
modifiers,
|
||||
@@ -508,6 +515,7 @@ impl Game<'_> {
|
||||
combat,
|
||||
acting,
|
||||
packets,
|
||||
entity_events,
|
||||
swings,
|
||||
projectiles,
|
||||
modifiers,
|
||||
@@ -529,6 +537,17 @@ impl Game<'_> {
|
||||
for packet in std::mem::replace(&mut self.guest_packets, packets) {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
// ServerPlayer.connection.send: the guest's own entity events.
|
||||
let entity_id = host.hub.guests[i].entity_id;
|
||||
for event in std::mem::replace(&mut self.guest_entity_events, entity_events) {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::EntityEvent(play::EntityEvent {
|
||||
id: entity_id,
|
||||
event,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// ServerItemCooldowns.onCooldownStarted: the guest's own cooldown.
|
||||
for (group, duration) in std::mem::take(&mut self.guest_cooldowns) {
|
||||
host.hub.send_to(
|
||||
@@ -1216,6 +1235,12 @@ impl Game<'_> {
|
||||
{
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
S::SelectBundleItem(p) => {
|
||||
if !spectator && let Ok(slot) = usize::try_from(p.slot) {
|
||||
host.hub
|
||||
.select_bundle_item(conn, slot, p.index, &mut self.world);
|
||||
}
|
||||
}
|
||||
S::ContainerClick(p) => {
|
||||
let rules = ItemRules(self.res.items());
|
||||
let slots = host.hub.guests[gi]
|
||||
@@ -1910,21 +1935,8 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let play = host.play.entry(conn).or_default();
|
||||
// Player.tick's ItemCooldowns.tick, once a tick for each player
|
||||
// (the shield an axe disabled, a thrown pearl).
|
||||
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
|
||||
// ServerGamePacketListenerImpl.tick's dropSpamThrottler.
|
||||
play.creative_drops = play.creative_drops.saturating_sub(1);
|
||||
// ServerItemCooldowns.onCooldownEnded: a finished entry is told to
|
||||
// the guest (a duration of 0 removes it).
|
||||
for group in play.cooldowns.tick() {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::Cooldown(play::Cooldown {
|
||||
group: group.to_string(),
|
||||
duration: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
let digging = play.digging;
|
||||
let using = play.using.is_some();
|
||||
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
|
||||
@@ -1961,6 +1973,24 @@ impl Game<'_> {
|
||||
play.digging = None;
|
||||
}
|
||||
}
|
||||
// Player.tick's ItemCooldowns.tick, once a tick for each player
|
||||
// (the shield an axe disabled, a thrown pearl), after
|
||||
// LivingEntity.tick's use: an entry a finished use starts has 20
|
||||
// ticks left when the next tick's packets are handled, as the
|
||||
// guest's own client counts.
|
||||
let play = host.play.entry(conn).or_default();
|
||||
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
|
||||
// ServerItemCooldowns.onCooldownEnded: a finished entry is told to
|
||||
// the guest (a duration of 0 removes it).
|
||||
for group in play.cooldowns.tick() {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::Cooldown(play::Cooldown {
|
||||
group: group.to_string(),
|
||||
duration: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Player.tick: a different main-hand item resets the attack
|
||||
// strength, and detectEquipmentUpdates gives the guest its modifiers.
|
||||
let g = &host.hub.guests[gi];
|
||||
@@ -2319,7 +2349,35 @@ impl Game<'_> {
|
||||
for event in events {
|
||||
match event {
|
||||
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at),
|
||||
// LivingEntity.checkFallDamage: the crumbs reach every player.
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Particles {
|
||||
state,
|
||||
position,
|
||||
count,
|
||||
speed,
|
||||
}) => self.landing_dust(Some(host), state, position, count, speed),
|
||||
MotionEvent::Fall(event) => self.landing_block_updates(&event),
|
||||
// HoneyBlock.maybeDoSlideEffects: the sound reaches the
|
||||
// others (Player.playSound excludes the player, whose client
|
||||
// plays it), the entity event 53 everyone around.
|
||||
MotionEvent::HoneySlide {
|
||||
sound, particles, ..
|
||||
} => {
|
||||
let Some((id, at)) = host.hub.guest(conn).map(|g| (g.entity_id, g.position))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if sound {
|
||||
let name = "minecraft:block.honey_block.slide";
|
||||
let source = horizon_client::settings::sound_source(name) as i32;
|
||||
self.audio.play_unshared(name, Some(at), 1.0, 1.0);
|
||||
host.hub.sound(name, at, 1.0, 1.0, source, Some(conn));
|
||||
}
|
||||
if particles {
|
||||
self.honey_slide_particles(at);
|
||||
host.hub.entity_event(id, 53, at);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,16 +576,18 @@ impl Game<'_> {
|
||||
/// A guest left: what their crafting grids and workstation held goes back into their
|
||||
/// inventory before it is saved (dropped where they stood if full).
|
||||
pub(super) fn lan_crafting_left(&mut self, host: &mut LanHost, g: &mut Guest) {
|
||||
let play = host.play.remove(&g.conn);
|
||||
// The guest's play state stays: their statistics are awarded and saved
|
||||
// after this (`HostEvent::Left`), which removes it.
|
||||
let play = host.play.get_mut(&g.conn);
|
||||
let mut grid: Vec<ItemStack> = g.craft_grid.iter_mut().filter_map(Option::take).collect();
|
||||
g.craft_result = None;
|
||||
if let Some(play) = play {
|
||||
if let Some((_, _, menu, _)) = play.crafting.table {
|
||||
if let Some((_, _, menu, _)) = play.crafting.table.take() {
|
||||
grid.extend((1..10).filter_map(|s| menu.slot(s).cloned()));
|
||||
}
|
||||
// A workstation's inputs too.
|
||||
if let Some(station) = &play.station {
|
||||
grid.extend(super::lan_stations::station_inputs(station));
|
||||
if let Some(station) = play.station.take() {
|
||||
grid.extend(super::lan_stations::station_inputs(&station));
|
||||
}
|
||||
}
|
||||
if grid.is_empty() {
|
||||
|
||||
@@ -304,6 +304,9 @@ impl Game<'_> {
|
||||
None => 0,
|
||||
};
|
||||
let at = |p: horizon_core::geometry::Vec3| [p.x, p.y, p.z];
|
||||
for f in &self.fangs.list {
|
||||
out.push(state(f.id, "minecraft:evoker_fangs", f.pos, f.yaw, false));
|
||||
}
|
||||
// LightningBolt: each client draws and flashes its own.
|
||||
for b in self.bolts.iter().filter(|b| b.id != 0) {
|
||||
out.push(state(b.id, "minecraft:lightning_bolt", b.at, 0.0, false));
|
||||
@@ -354,6 +357,20 @@ impl Game<'_> {
|
||||
a.is_some_and(|a| a.in_ground) || p.stuck,
|
||||
));
|
||||
}
|
||||
// ThrownTrident.ID_LOYALTY (none for a mob's) and ID_FOIL, on
|
||||
// AbstractArrow's flags.
|
||||
K::Trident => {
|
||||
let a = p.arrow.as_deref();
|
||||
e.meta.extend(arrow_meta(
|
||||
false,
|
||||
a.is_some_and(|a| a.no_physics),
|
||||
0,
|
||||
a.is_some_and(|a| a.in_ground) || p.stuck,
|
||||
));
|
||||
if a.is_some_and(|a| a.pickup_item.has_any_enchantments()) {
|
||||
e.meta.push((12, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
// WitherSkull.DATA_DANGEROUS.
|
||||
K::WitherSkull { dangerous: true } => {
|
||||
e.meta.push((8, serializer::BOOLEAN, Value::Bool(true)));
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Particles a server sends its players and its clients draw:
|
||||
//! `ClientboundLevelParticlesPacket` (`ServerLevel.sendParticles`, which
|
||||
//! `/particle` uses) and `ClientboundExplodePacket` (`ServerLevel.explode`).
|
||||
use super::Game;
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::lan::hub::Hub;
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use horizon_protocol::{
|
||||
@@ -81,6 +82,66 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.checkFallDamage`'s `ServerLevel.sendParticles` of the
|
||||
/// landing block's crumbs: drawn in this window, when it has one, and
|
||||
/// sent to each player within 32 blocks. `host` is the hub of a guest's
|
||||
/// turn, which is out of `self.lan` meanwhile.
|
||||
pub(super) fn landing_dust(
|
||||
&mut self,
|
||||
host: Option<&LanHost>,
|
||||
state: BlockStateId,
|
||||
at: Vec3,
|
||||
count: i32,
|
||||
speed: f64,
|
||||
) {
|
||||
let at = [at.x, at.y, at.z];
|
||||
if self.serving.is_none() && self.options.dedicated.is_none() {
|
||||
let block = [
|
||||
at[0].floor() as i32,
|
||||
(at[1] - 0.2).floor() as i32,
|
||||
at[2].floor() as i32,
|
||||
];
|
||||
if let Some((sprite, tint)) = self.particle_sprite(state, block) {
|
||||
self.particles.send_particles(
|
||||
&ParticleOptions::block(ParticleType::Block, sprite, tint),
|
||||
at,
|
||||
[0.0; 3],
|
||||
speed,
|
||||
count as u32,
|
||||
);
|
||||
}
|
||||
}
|
||||
let Some(hub) = host.or(self.lan.as_ref()).map(|h| &h.hub) else {
|
||||
return;
|
||||
};
|
||||
let Some(particle) = Self::hub_particle(
|
||||
hub,
|
||||
&ParticleOptions::simple(ParticleType::Block),
|
||||
Some(state),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let packet = C::LevelParticles(play::LevelParticles {
|
||||
particle,
|
||||
override_limiter: false,
|
||||
always_show: false,
|
||||
pos: at,
|
||||
dist: [0.0; 3],
|
||||
max_speed: [speed as f32; 3],
|
||||
count,
|
||||
randomization: 0,
|
||||
});
|
||||
for g in hub.guests.iter().filter(|g| hub.here(g)) {
|
||||
// ServerLevel compares the player's block centre, strictly within 32.
|
||||
let distance: f64 = (0..3)
|
||||
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
|
||||
.sum();
|
||||
if distance < 32.0 * 32.0 {
|
||||
g.send(&packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ParticleTypes.STREAM_CODEC`'s value for `options`: the type's
|
||||
/// network id and the data it carries (`state` for the block particles).
|
||||
/// None while not serving, or for a particle whose data isn't sent from
|
||||
@@ -89,13 +150,19 @@ impl Game<'_> {
|
||||
&self,
|
||||
options: &ParticleOptions,
|
||||
state: Option<BlockStateId>,
|
||||
) -> Option<Particle> {
|
||||
Self::hub_particle(&self.lan.as_ref()?.hub, options, state)
|
||||
}
|
||||
|
||||
/// `network_particle` for a hub that is out of `self.lan` meanwhile (a
|
||||
/// guest's turn).
|
||||
fn hub_particle(
|
||||
hub: &Hub,
|
||||
options: &ParticleOptions,
|
||||
state: Option<BlockStateId>,
|
||||
) -> Option<Particle> {
|
||||
let name = format!("minecraft:{}", options.ty.name());
|
||||
let id = self
|
||||
.lan
|
||||
.as_ref()?
|
||||
.hub
|
||||
.registry_id("minecraft:particle_type", &name)?;
|
||||
let id = hub.registry_id("minecraft:particle_type", &name)?;
|
||||
use ParticleType as T;
|
||||
let c = options.color;
|
||||
let data = match options.ty {
|
||||
@@ -254,8 +321,14 @@ impl Game<'_> {
|
||||
/// `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.
|
||||
pub(super) fn lan_explode(&self, center: [f64; 3], radius: f32, block_count: usize) {
|
||||
let emitter = if radius < 2.0 {
|
||||
pub(super) fn lan_explode(
|
||||
&self,
|
||||
center: [f64; 3],
|
||||
small: bool,
|
||||
radius: f32,
|
||||
block_count: usize,
|
||||
) {
|
||||
let emitter = if small {
|
||||
"minecraft:explosion"
|
||||
} else {
|
||||
"minecraft:explosion_emitter"
|
||||
|
||||
@@ -37,6 +37,8 @@ pub(super) struct Strike {
|
||||
amount: f32,
|
||||
scale: f32,
|
||||
crit: bool,
|
||||
/// `Player.attack`'s `magicBoost > 0`: the enchantments added damage.
|
||||
magic: bool,
|
||||
from: [f64; 3],
|
||||
sweep: Option<[f64; 3]>,
|
||||
/// `causeExtraKnockback`: its strength and direction (the attacker's
|
||||
@@ -109,7 +111,7 @@ impl Game<'_> {
|
||||
pub(super) fn pvp_strike(&mut self) -> Option<Strike> {
|
||||
let p = self.player.as_ref()?;
|
||||
let from = [p.position.x, p.position.y, p.position.z];
|
||||
let (amount, scale, crit) = self.melee_damage("minecraft:player");
|
||||
let (amount, scale, crit, magic) = self.melee_damage("minecraft:player");
|
||||
let sweep = self.sweep_attack_position(scale, crit);
|
||||
let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale);
|
||||
let (xd, zd) = self.extra_knockback_direction();
|
||||
@@ -125,6 +127,7 @@ impl Game<'_> {
|
||||
amount,
|
||||
scale,
|
||||
crit,
|
||||
magic,
|
||||
from,
|
||||
sweep,
|
||||
knockback: (strength, xd, zd),
|
||||
@@ -257,7 +260,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
if hurt && s.crit {
|
||||
if hurt && (s.crit || s.magic) {
|
||||
let target = match victim {
|
||||
Victim::Host => self.player.as_ref().map(|p| (host.host_entity, p)),
|
||||
Victim::Guest(conn) => host
|
||||
@@ -267,13 +270,17 @@ impl Game<'_> {
|
||||
};
|
||||
if let Some((id, p)) = target {
|
||||
let d = p.dimensions;
|
||||
self.critical_hit(
|
||||
s.attacker,
|
||||
id,
|
||||
let (feet, width, height) = (
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
f64::from(d.width),
|
||||
f64::from(d.height),
|
||||
);
|
||||
if s.crit {
|
||||
self.critical_hit(s.attacker, id, feet, width, height, false);
|
||||
}
|
||||
if s.magic {
|
||||
self.critical_hit(s.attacker, id, feet, width, height, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
// `Enchantment.doPostAttack`: thorns hurt the attacking player.
|
||||
|
||||
@@ -104,6 +104,9 @@ impl Game<'_> {
|
||||
};
|
||||
let m = &mut self.mobs[i];
|
||||
if let Some(h) = holder {
|
||||
if h == Holder::Player {
|
||||
m.extra.leash_player = Some(player_uuid);
|
||||
}
|
||||
m.extra.leash = Some(h);
|
||||
m.extra.leash_pending = None;
|
||||
} else if m.age > 100 {
|
||||
@@ -201,7 +204,9 @@ impl Game<'_> {
|
||||
if self.mobs[index].extra.leash.is_some() {
|
||||
self.break_leash(id, true, false);
|
||||
}
|
||||
let holder = self.acting_player_uuid();
|
||||
self.mobs[index].extra.leash = Some(Holder::Player);
|
||||
self.mobs[index].extra.leash_player = Some(holder);
|
||||
self.lead_sound("tied", at);
|
||||
// The lead is always used up.
|
||||
if let Some(stack) = self.held_mut().as_mut() {
|
||||
@@ -381,6 +386,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax
|
||||
//! off (3004) and scraping (3005).
|
||||
use super::Game;
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_client::particles::{self, ParticleOptions, ParticleType};
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
use horizon_world::level::LevelEvent;
|
||||
@@ -140,6 +141,68 @@ impl Game<'_> {
|
||||
// Each guest plays it from the global packet.
|
||||
self.audio.play_unshared(sound, at, volume, 1.0);
|
||||
}
|
||||
/// `LevelEventHandler.potionSplashParticles` (2002 with `Effect`, 2007
|
||||
/// with `InstantEffect`): eight splash potion crumbs, and a hundred
|
||||
/// spell particles in the potion's colour `data`, flung out in a disc.
|
||||
pub(super) fn potion_splash_particles(&mut self, pos: BlockPos, data: i32, instant: bool) {
|
||||
let ty = if instant {
|
||||
ParticleType::InstantEffect
|
||||
} else {
|
||||
ParticleType::Effect
|
||||
};
|
||||
// Vec3.atBottomCenterOf.
|
||||
let at = [
|
||||
f64::from(pos.x) + 0.5,
|
||||
f64::from(pos.y),
|
||||
f64::from(pos.z) + 0.5,
|
||||
];
|
||||
let crumb = self.item_particle(&ItemStack::new("minecraft:splash_potion", 1));
|
||||
let r = self.particles.level_random();
|
||||
let crumbs: Vec<[f64; 3]> = (0..8)
|
||||
.map(|_| {
|
||||
[
|
||||
r.next_gaussian() * 0.15,
|
||||
r.next_double() * 0.2,
|
||||
r.next_gaussian() * 0.15,
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
for v in crumbs {
|
||||
self.particles.add(&crumb, at, v);
|
||||
}
|
||||
let channel = |shift: i32| ((data >> shift) & 0xFF) as f32 / 255.0;
|
||||
let color = [channel(16), channel(8), channel(0)];
|
||||
for _ in 0..100 {
|
||||
let r = self.particles.level_random();
|
||||
let dist = r.next_double() * 4.0;
|
||||
let angle = r.next_double() * std::f64::consts::PI * 2.0;
|
||||
let velocity = [
|
||||
angle.cos() * dist,
|
||||
0.01 + r.next_double() * 0.5,
|
||||
angle.sin() * dist,
|
||||
];
|
||||
let brightness = 0.75 + r.next_float() * 0.25;
|
||||
let options = ParticleOptions {
|
||||
color: [
|
||||
color[0] * brightness,
|
||||
color[1] * brightness,
|
||||
color[2] * brightness,
|
||||
1.0,
|
||||
],
|
||||
scale: dist as f32,
|
||||
..ParticleOptions::simple(ty)
|
||||
};
|
||||
self.particles.add(
|
||||
&options,
|
||||
[
|
||||
at[0] + velocity[0] * 0.1,
|
||||
at[1] + 0.3,
|
||||
at[2] + velocity[2] * 0.1,
|
||||
],
|
||||
velocity,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Queue and play level event `id` at `pos` (`Level.levelEvent`).
|
||||
pub(super) fn level_event_at(&mut self, id: i32, pos: [i32; 3], data: i32) {
|
||||
self.level_events.push(LevelEvent::LevelEvent {
|
||||
|
||||
@@ -75,7 +75,7 @@ pub(super) struct Parked {
|
||||
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
|
||||
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
|
||||
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
|
||||
lan_critical_hits: Vec<(Option<u32>, i32)>,
|
||||
lan_critical_hits: Vec<(Option<u32>, i32, bool)>,
|
||||
/// Only the per-level parts of these are used (`swap_level`).
|
||||
anims: BlockEntityAnims,
|
||||
raiding: Raiding,
|
||||
@@ -120,6 +120,10 @@ impl Levels {
|
||||
.iter_mut()
|
||||
.map(|(d, p)| (*d, &mut p.world.chunks))
|
||||
}
|
||||
/// The levels that are not in place, by dimension.
|
||||
pub(super) fn parked_dims(&self) -> Vec<Dim> {
|
||||
self.parked.keys().copied().collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -192,6 +196,7 @@ impl Game<'_> {
|
||||
let mut locator = self.end.locator();
|
||||
swap(&mut locator, &mut p.locator);
|
||||
self.end.set_locator(locator);
|
||||
self.share_spawn_conditions();
|
||||
swap(&mut self.save, &mut p.save);
|
||||
swap(&mut self.block_ticks, &mut p.block_ticks);
|
||||
swap(&mut self.tick_chunks, &mut p.tick_chunks);
|
||||
@@ -399,7 +404,8 @@ impl Game<'_> {
|
||||
}
|
||||
*next += self.clock.tick_duration();
|
||||
if *next > now {
|
||||
// A `/pregen` task keeps the workers fed until the next tick.
|
||||
// A `/pregen` task or force-loaded chunks keep the workers fed
|
||||
// until the next tick.
|
||||
self.pump_pregen(*next);
|
||||
let now = Instant::now();
|
||||
if *next > now {
|
||||
|
||||
@@ -1133,6 +1133,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1180,6 +1182,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1251,6 +1255,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ pub(super) struct Minecart {
|
||||
pub yaw: f32,
|
||||
pub previous_yaw: f32,
|
||||
pub flipped: bool,
|
||||
on_ground: bool,
|
||||
pub(super) on_ground: bool,
|
||||
horizontal_collision: bool,
|
||||
fall_distance: f64,
|
||||
pub hurt_time: i32,
|
||||
@@ -90,6 +90,8 @@ pub(super) struct Minecart {
|
||||
/// Chest minecarts: the loot table rolled into `items` on load, saved
|
||||
/// again while the items are still that roll.
|
||||
pub loot: Option<horizon_client::save::RolledLoot>,
|
||||
/// `Entity.firstTick`: not ticked yet (saved as `HasTicked`).
|
||||
pub first_tick: bool,
|
||||
}
|
||||
pub(super) const WIDTH: f64 = 0.98;
|
||||
pub(super) const HEIGHT: f64 = 0.7;
|
||||
@@ -186,6 +188,7 @@ impl Minecart {
|
||||
fuse: -1,
|
||||
enabled: true,
|
||||
loot: None,
|
||||
first_tick: true,
|
||||
}
|
||||
}
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
@@ -536,6 +539,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `AbstractMinecart.tick` for one cart; returns false once it is gone.
|
||||
fn tick_cart(&mut self, c: &mut Minecart, intent: [f64; 2]) -> bool {
|
||||
c.first_tick = false;
|
||||
c.previous = c.position;
|
||||
c.previous_yaw = c.yaw;
|
||||
c.hurt_time = (c.hurt_time - 1).max(0);
|
||||
@@ -1297,6 +1301,7 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color,
|
||||
light,
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1340,9 +1345,8 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's mean
|
||||
// edge, scaled by the entity distance option.
|
||||
let range =
|
||||
(WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * f64::from(self.settings.entity_distance);
|
||||
// edge, scaled by the view scale.
|
||||
let range = (WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * self.view_scale();
|
||||
for c in &self.carts {
|
||||
let entity_start = out.len();
|
||||
let Some(model) = self.cart_models.models.get(&c.kind) else {
|
||||
@@ -1509,6 +1513,10 @@ impl Minecart {
|
||||
map.insert(JavaString::from(k), v);
|
||||
};
|
||||
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
|
||||
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
|
||||
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
|
||||
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
|
||||
put("Fire", Tag::Short(0));
|
||||
match self.kind {
|
||||
CartKind::Chest | CartKind::Hopper => {
|
||||
put("Items", horizon_client::save::encode_items(&self.items));
|
||||
@@ -1558,6 +1566,7 @@ impl Minecart {
|
||||
_ => 0.0,
|
||||
};
|
||||
c.flipped = int("FlippedRotation").unwrap_or(0) != 0;
|
||||
c.first_tick = int("HasTicked").unwrap_or(0) != 0;
|
||||
if kind.slots() > 0 {
|
||||
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
|
||||
}
|
||||
@@ -1582,4 +1591,30 @@ mod tests {
|
||||
assert_eq!(display_offset(CartKind::CommandBlock), 6.0);
|
||||
assert_eq!(display_offset(CartKind::Rideable), 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cart_saves_as_never_ticked_until_it_ticks() {
|
||||
use horizon_world::nbt::Tag;
|
||||
let generated = Tag::compound([
|
||||
("id", Tag::String("minecraft:chest_minecart".into())),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(
|
||||
[0.5f64, 40.5, 0.5]
|
||||
.map(|v| Tag::Double(v.to_bits()))
|
||||
.to_vec(),
|
||||
),
|
||||
),
|
||||
("HasTicked", Tag::Byte(1)),
|
||||
("Fire", Tag::Short(0)),
|
||||
]);
|
||||
let mut cart = Minecart::from_nbt(&generated, 1, CartKind::Chest).unwrap();
|
||||
let saved = cart.to_nbt([0; 4], Some(&generated));
|
||||
assert_eq!(saved.get("HasTicked"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(saved.get("Fire"), Some(&Tag::Short(0)));
|
||||
cart.first_tick = false;
|
||||
let saved = cart.to_nbt([0; 4], Some(&generated));
|
||||
assert_eq!(saved.get("HasTicked"), Some(&Tag::Byte(0)));
|
||||
assert_eq!(saved.get("Fire"), Some(&Tag::Short(0)));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Blocks mobs carry: block items in a hand (`ItemInHandLayer`) and the
|
||||
//! block an enderman holds (`CarriedBlockLayer`), drawn as shaded cubes like
|
||||
//! dropped block items.
|
||||
//! dropped block items. Also the block models mobs wear (`SnowGolemHeadLayer`,
|
||||
//! `MushroomCowMushroomLayer`).
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
@@ -203,6 +204,8 @@ impl Game<'_> {
|
||||
layer: overlay,
|
||||
color,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -218,6 +221,7 @@ impl Game<'_> {
|
||||
) {
|
||||
self.captain_banner_geometry(mob, camera, alpha, light, out);
|
||||
self.held_item_geometry(mob, camera, alpha, light, true, out);
|
||||
self.worn_block_geometry(mob, camera, alpha, light, out);
|
||||
if mob.species() == Species::Enderman
|
||||
&& let Some(state) = mob.extra.carried
|
||||
&& let Some((sprite, tint)) = self.block_face_sprite(state)
|
||||
@@ -244,6 +248,76 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `SnowGolemHeadLayer` and `MushroomCowMushroomLayer`: the carved pumpkin
|
||||
/// on a snow golem's head, and the mushrooms on an adult mooshroom's back
|
||||
/// and head, as baked block models.
|
||||
fn worn_block_geometry(
|
||||
&self,
|
||||
mob: &Mob,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
use glam::{Mat4, Vec3};
|
||||
let species = mob.species();
|
||||
let on_head = match species {
|
||||
Species::SnowGolem => !mob.extra.sheared,
|
||||
Species::Mooshroom => !mob.is_baby(),
|
||||
_ => false,
|
||||
};
|
||||
// The layers draw nothing on an invisible mob (the glow outline is not drawn).
|
||||
if !on_head || horizon_client::mobs::trader::is_invisible(mob) {
|
||||
return;
|
||||
}
|
||||
let registry = &self.pack.assets.registry;
|
||||
let block = match species {
|
||||
Species::SnowGolem => "minecraft:carved_pumpkin",
|
||||
_ if mob.extra.variant == 1 => "minecraft:brown_mushroom",
|
||||
_ => "minecraft:red_mushroom",
|
||||
};
|
||||
let Some(state) = registry.default_state(block) else {
|
||||
return;
|
||||
};
|
||||
let alpha = alpha as f32;
|
||||
let Some(head) = render::part_transform(mob, alpha, camera, self.res.mob_data(), "head")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let unit = Mat4::from_translation(Vec3::splat(-0.5));
|
||||
let flip = Mat4::from_scale(Vec3::new(-1.0, -1.0, 1.0));
|
||||
let at = |x: f32, y: f32, z: f32| Mat4::from_translation(Vec3::new(x, y, z));
|
||||
let spin = |degrees: f32| Mat4::from_rotation_y(degrees.to_radians());
|
||||
let transforms = if species == Species::SnowGolem {
|
||||
vec![
|
||||
head * at(0.0, -0.34375, 0.0)
|
||||
* spin(180.0)
|
||||
* Mat4::from_scale(Vec3::new(0.625, -0.625, -0.625))
|
||||
* unit,
|
||||
]
|
||||
} else {
|
||||
let base = render::base_transform(mob, alpha, camera);
|
||||
vec![
|
||||
base * at(0.2, -0.35, 0.5) * spin(-48.0) * flip * unit,
|
||||
base * at(0.2, -0.35, 0.5)
|
||||
* spin(42.0)
|
||||
* at(0.1, 0.0, -0.6)
|
||||
* spin(-48.0)
|
||||
* flip
|
||||
* unit,
|
||||
head * at(0.0, -0.7, -0.2) * spin(-78.0) * flip * unit,
|
||||
]
|
||||
};
|
||||
// The hurt tint reaches these blocks as it does the body.
|
||||
let overlay = horizon_client::overlay::living(0.0, mob.hurt_time > 0 || mob.death_time > 0);
|
||||
for t in transforms {
|
||||
let start = out.len();
|
||||
self.block_model_vertices(state, 0, t, light, true, out);
|
||||
for vertex in &mut out[start..] {
|
||||
vertex.layer = overlay;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ItemInHandLayer`: both hands through the item model pipeline; `lit`
|
||||
/// applies the level's diffuse lighting (off for GUI previews, which
|
||||
/// light the projected quads themselves).
|
||||
|
||||
@@ -371,7 +371,10 @@ impl Game<'_> {
|
||||
}
|
||||
/// The chat's tree after the running mods changed.
|
||||
fn sync_mod_commands(&mut self) {
|
||||
let mut names = self.mods.mods.commands().peekable();
|
||||
// In-frame, the window runs server mods too: their names, free text
|
||||
// here (the server parses their arguments).
|
||||
let server = self.mods.mods.server_commands().map(|c| c.name.as_str());
|
||||
let mut names = self.mods.mods.commands().chain(server).peekable();
|
||||
self.mods.tree = names
|
||||
.peek()
|
||||
.is_some()
|
||||
|
||||
@@ -126,6 +126,19 @@ impl Game<'_> {
|
||||
if !equine::is_equine(species) || !m.alive() || Some(m.id) == self.riding {
|
||||
return false;
|
||||
}
|
||||
// AbstractHorse.mobInteract: a horse a mob rides (`isVehicle`) has no
|
||||
// seat, inventory or feeding to give. Camels seat two and check their
|
||||
// passengers themselves.
|
||||
let id = m.id;
|
||||
if !matches!(species, Species::Camel | Species::CamelHusk)
|
||||
&& self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|p| p.extra.mount == Some(id) && !p.removed && p.alive())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let m = &self.mobs[index];
|
||||
// AbstractHorse.mobInteract: shift + use opens a tamed adult's inventory.
|
||||
if !m.is_baby()
|
||||
&& m.extra.tame
|
||||
|
||||
@@ -14,7 +14,9 @@ impl Game<'_> {
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let partial = alpha as f32;
|
||||
for &(p, piston) in &self.world.moving_pistons {
|
||||
let mut states = self.world.moving_pistons.clone();
|
||||
states.extend(self.world.moving_piston_fallback.iter().copied());
|
||||
for (p, piston) in states {
|
||||
let Some((block, base)) = self.moving_piston_blocks(piston, partial) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
Loaded 100 of 391 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user