mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
405
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5de5b6fd91 | ||
|
|
23c3faedc1 | ||
|
|
ba3c3feebf | ||
|
|
ec298576f6 | ||
|
|
97c9d1a14f | ||
|
|
9099175f8a | ||
|
|
e1e39361db | ||
|
|
ef19179033 | ||
|
|
b189c72fcc | ||
|
|
3a229da6b3 | ||
|
|
e22ccc17d2 | ||
|
|
493dd75acf | ||
|
|
f4481233b0 | ||
|
|
63cbec701e | ||
|
|
a597387a66 | ||
|
|
2d306dc52c | ||
|
|
4f6be12c2e | ||
|
|
8c93f81143 | ||
|
|
a20e9ce2d0 | ||
|
|
aba1a90e6f | ||
|
|
73a3c0348d | ||
|
|
c95f24bc32 | ||
|
|
ca0b945857 | ||
|
|
33be2a611a | ||
|
|
1199c117f2 | ||
|
|
2e749948f4 | ||
|
|
f5f96b1be4 | ||
|
|
522634ca7f | ||
|
|
f7543ccffa | ||
|
|
1fd2f02081 | ||
|
|
8d8c250f48 | ||
|
|
d21bc19b84 | ||
|
|
3afc51e740 | ||
|
|
17778b9722 | ||
|
|
d1c1684fb5 | ||
|
|
fdc87aecdb | ||
|
|
52343380b3 | ||
|
|
e2680a5450 | ||
|
|
5e7d248148 | ||
|
|
67f34e0fdd | ||
|
|
17a68d7b97 | ||
|
|
90b30ee17b | ||
|
|
5578753b58 | ||
|
|
6faf3ef3f3 | ||
|
|
ab44611814 | ||
|
|
8c612bc687 | ||
|
|
9debdf26d4 | ||
|
|
27a2eb565e | ||
|
|
d7ae75b441 | ||
|
|
bc017eec9b | ||
|
|
693178dc36 | ||
|
|
b19634003c | ||
|
|
8711aabd75 | ||
|
|
76a5bd8a22 | ||
|
|
453d8b89ce | ||
|
|
845132a56b | ||
|
|
051e3dd546 | ||
|
|
ab5dfae985 | ||
|
|
19a6e9b81b | ||
|
|
de118feb4e | ||
|
|
e508e7919f | ||
|
|
f222616f7e | ||
|
|
0f89fd7e00 | ||
|
|
6889d9366b | ||
|
|
0804e2219a | ||
|
|
1c6a22db1a | ||
|
|
8a9a6d5e3f | ||
|
|
d17e8fa343 | ||
|
|
6f374959b7 | ||
|
|
15a8b2a77c | ||
|
|
1206818cde | ||
|
|
12157a2d67 | ||
|
|
b029b9d04f | ||
|
|
9b89ff00ee | ||
|
|
2e13076a93 | ||
|
|
9974322832 | ||
|
|
b21f0c92f2 | ||
|
|
31545199d4 | ||
|
|
9a8781468e | ||
|
|
1117b98d15 | ||
|
|
8fe0e40d68 | ||
|
|
f68c335eb2 | ||
|
|
12dad67f8c | ||
|
|
922f00f52e | ||
|
|
1fe21fb861 | ||
|
|
68b03b5184 | ||
|
|
200615a30a | ||
|
|
1ba0db3c3c | ||
|
|
0ce0e67282 | ||
|
|
00d1e1a46e | ||
|
|
1ee069b6ba | ||
|
|
c30dfd55e5 | ||
|
|
0f8cf58043 | ||
|
|
2ebf00a028 | ||
|
|
821426be75 | ||
|
|
7325d8811b | ||
|
|
5bbfe0f311 | ||
|
|
f5c936af5e | ||
|
|
0a898f21b0 | ||
|
|
6cc93d13ce | ||
|
|
9a292609eb | ||
|
|
fd4d709dc2 | ||
|
|
c34114751a | ||
|
|
dd93b0164a | ||
|
|
ffef7b9c7e | ||
|
|
7622c02706 | ||
|
|
744fcd7e01 | ||
|
|
e760415d7b | ||
|
|
95f1756a8d | ||
|
|
e8c56045c9 | ||
|
|
1d63e0a5a3 | ||
|
|
8cd24bc4e5 | ||
|
|
c78187dfff | ||
|
|
fc2868b2b2 | ||
|
|
e414a7a14b | ||
|
|
2b8d23af24 | ||
|
|
6560641ba5 | ||
|
|
295a73028d | ||
|
|
ad89bb3123 | ||
|
|
8c3f88a194 | ||
|
|
ac02477732 | ||
|
|
b893b6ae31 | ||
|
|
6416c415a7 | ||
|
|
29804afe17 | ||
|
|
0628aa5870 | ||
|
|
fb03dbf6e3 | ||
|
|
915f58a8c5 | ||
|
|
2557cb185b | ||
|
|
db2bc09336 | ||
|
|
23af2bfc62 | ||
|
|
f6980a83e6 | ||
|
|
dd64878f38 | ||
|
|
f0e0d96b61 | ||
|
|
45cc5a91e0 | ||
|
|
132aa6b682 | ||
|
|
fcc57182ae | ||
|
|
fcc6416b34 | ||
|
|
c85011b04d | ||
|
|
d93bf565f4 | ||
|
|
9c5a3734ea | ||
|
|
2ff274b96c | ||
|
|
13d03ec87f | ||
|
|
4ea806a2db | ||
|
|
303255f2d8 | ||
|
|
a6339e67b5 | ||
|
|
1cd80a0bcb | ||
|
|
4ebee0d01e | ||
|
|
0ebe0d40de | ||
|
|
30ee97bbc7 | ||
|
|
2a57a30ae2 | ||
|
|
b3f9eddfd3 | ||
|
|
f5a6eb9835 | ||
|
|
dd3f458ba1 | ||
|
|
d4006f0770 | ||
|
|
bed953b207 | ||
|
|
0e674b827c | ||
|
|
2f0b066f38 | ||
|
|
7d8bb9e69b | ||
|
|
dd3c1d17a2 | ||
|
|
d5f43e0967 | ||
|
|
d372562a73 | ||
|
|
99ddbc629e | ||
|
|
37f69234e7 | ||
|
|
8fd21e02d4 | ||
|
|
32191d5b6a | ||
|
|
8331bedf2b | ||
|
|
37656cc775 | ||
|
|
ae0ccc199c | ||
|
|
4c1d1990e1 | ||
|
|
bc0ddfbff0 | ||
|
|
330d3db811 | ||
|
|
a7a2cf9305 | ||
|
|
1087b5a6ff | ||
|
|
fb3e73d3e2 | ||
|
|
951fc0f0cb | ||
|
|
2f88719668 | ||
|
|
25a1add0a2 | ||
|
|
4e1c0d68be | ||
|
|
7753a08ca9 | ||
|
|
34a9bfaa88 | ||
|
|
5fdf9fb0ac | ||
|
|
6e19f95786 | ||
|
|
cf2ab15de3 | ||
|
|
68ec463175 | ||
|
|
35d60b45b5 | ||
|
|
a4df22049c | ||
|
|
4bf0560d40 | ||
|
|
18840db914 | ||
|
|
4c4728ead5 | ||
|
|
ea55e8156c | ||
|
|
320d55a88c | ||
|
|
3b7dde0cd5 | ||
|
|
4df3bd4db0 | ||
|
|
f16cc3d6d0 | ||
|
|
9495790d0b | ||
|
|
301765dc69 | ||
|
|
0748416e9e | ||
|
|
f64b1b9d55 | ||
|
|
59dff021a1 | ||
|
|
1748b489eb | ||
|
|
ba61774567 | ||
|
|
5eeb4d5678 | ||
|
|
4f2f4078d1 | ||
|
|
6ac83b92d7 | ||
|
|
7fcc1c900a | ||
|
|
6cbece0baa | ||
|
|
99901a6359 | ||
|
|
b6ab7d72b5 | ||
|
|
725f9bb17d | ||
|
|
db673e9b76 | ||
|
|
5564c36339 | ||
|
|
0eba976485 | ||
|
|
28cb958bb2 | ||
|
|
9fa7986397 | ||
|
|
765f388607 | ||
|
|
0007d5c2a8 | ||
|
|
f58b34cd26 | ||
|
|
536b3316bd | ||
|
|
3aaef65c5d | ||
|
|
5c18aa614e | ||
|
|
f7ba58871b | ||
|
|
017aee7bcf | ||
|
|
09ec3f2452 | ||
|
|
d89ca171f3 | ||
|
|
59f6b76699 | ||
|
|
5fe63f13e8 | ||
|
|
e8d2cf4ee2 | ||
|
|
199ef4ad95 | ||
|
|
f2658dff92 | ||
|
|
07b7935bae | ||
|
|
b9bc67509a | ||
|
|
70f9c6e829 | ||
|
|
b984398cbc | ||
|
|
3baa4f56ae | ||
|
|
59bafaf8b6 | ||
|
|
c76534d9f1 | ||
|
|
2b9cb6ba26 | ||
|
|
bddd5ebf76 | ||
|
|
07ed5dac67 | ||
|
|
bda6cb38ae | ||
|
|
0787a9e7ff | ||
|
|
22e1582558 | ||
|
|
9b524815b9 | ||
|
|
4b1561050d | ||
|
|
8d50119388 | ||
|
|
6f4a6a3865 | ||
|
|
ffa3a4d413 | ||
|
|
aaccb9b51e | ||
|
|
1edfd2f7ea | ||
|
|
1301820f0d | ||
|
|
b888cebdf6 | ||
|
|
337c5bbd4d | ||
|
|
aef2dc6528 | ||
|
|
65c57efffe | ||
|
|
446e36c1a6 | ||
|
|
4efc3f6bb1 | ||
|
|
aae27797d9 | ||
|
|
c59cb034a2 | ||
|
|
fa733b3efa | ||
|
|
ccb85ce454 | ||
|
|
3cb56882d9 | ||
|
|
eba31f178c | ||
|
|
e539cd328f | ||
|
|
f71a05a062 | ||
|
|
48886daeb6 | ||
|
|
f423aeea61 | ||
|
|
e494a9d1f5 | ||
|
|
3fc2ed796f | ||
|
|
0da0d2b260 | ||
|
|
be8d3d59d9 | ||
|
|
818c598db9 | ||
|
|
d59814a196 | ||
|
|
955c1ba290 | ||
|
|
34d078e57b | ||
|
|
1ddc0b8a4d | ||
|
|
868f88a2ce | ||
|
|
69ceb53897 | ||
|
|
ecd3764d93 | ||
|
|
747b958bd9 | ||
|
|
7f1eef46b2 | ||
|
|
a0d291e2bb | ||
|
|
733d3abd7e | ||
|
|
71b527b7c2 | ||
|
|
85a97ed37c | ||
|
|
c14c063507 | ||
|
|
fe28bf9738 | ||
|
|
99a593a6bf | ||
|
|
c752fee870 | ||
|
|
3e484d6adf | ||
|
|
3ba815a17a | ||
|
|
d2d4e99101 | ||
|
|
a92071933a | ||
|
|
786bc6d8ca | ||
|
|
254d2c8e10 | ||
|
|
5a6a6e4e77 | ||
|
|
476bb33916 | ||
|
|
73737f9e6c | ||
|
|
37b174ada3 | ||
|
|
39f7f1bf42 | ||
|
|
75a078b0ce | ||
|
|
58859564a8 | ||
|
|
b37e409c9c | ||
|
|
3874099e52 | ||
|
|
dfbfc81aa9 | ||
|
|
df010b6204 | ||
|
|
83d3c8afa5 | ||
|
|
a3d9d8f2c3 | ||
|
|
332f312b38 | ||
|
|
4ee0e8dd33 | ||
|
|
9634732468 | ||
|
|
bb24c87d5d | ||
|
|
197eb01adf | ||
|
|
176caeb5fd | ||
|
|
1e7af3a2c5 | ||
|
|
9dac406ddc | ||
|
|
40ffd36b11 | ||
|
|
0fe1d0e15d | ||
|
|
3f38b2d0bd | ||
|
|
1088c588e4 | ||
|
|
c6735e2d8e | ||
|
|
98afd549d4 | ||
|
|
b9526c300c | ||
|
|
399171f0e0 | ||
|
|
1a43ca3439 | ||
|
|
070a8019e0 | ||
|
|
daefbd84e7 | ||
|
|
33ec06ab12 | ||
|
|
147c6bceef | ||
|
|
e35010eb7d | ||
|
|
014f25363a | ||
|
|
b0286fb598 | ||
|
|
cf73e1d25f | ||
|
|
62298d7c36 | ||
|
|
266040ff8e | ||
|
|
a123c03b16 | ||
|
|
90b9df2899 | ||
|
|
0b0a58af3e | ||
|
|
afd49ac9c2 | ||
|
|
c81dc55a1c | ||
|
|
335326296c | ||
|
|
6e0b26dbc2 | ||
|
|
7ba6892f93 | ||
|
|
b02930a2f2 | ||
|
|
35e7942094 | ||
|
|
84efc873cf | ||
|
|
2abb7c86a1 | ||
|
|
00879f19d7 | ||
|
|
a4ffae3402 | ||
|
|
701af0a693 | ||
|
|
e41c28a92b | ||
|
|
edc6751798 | ||
|
|
f214ffcc8a | ||
|
|
a82ad5eed6 | ||
|
|
1a6fba23c7 | ||
|
|
81bcb3950f | ||
|
|
58f31cdf25 | ||
|
|
2e779eed59 | ||
|
|
8578927d70 | ||
|
|
d9b7730a6f | ||
|
|
eb649daff5 | ||
|
|
1716888f1c | ||
|
|
9267013546 | ||
|
|
e95b6a26c5 | ||
|
|
cfab7464ed | ||
|
|
597adf33bd | ||
|
|
9d141f2e48 | ||
|
|
790d68e500 | ||
|
|
c72e75d8cf | ||
|
|
3d47396e9b | ||
|
|
155bca35fd | ||
|
|
8e05ff9b80 | ||
|
|
c05ef4bc91 | ||
|
|
a04eee71ba | ||
|
|
c29349aaf0 | ||
|
|
8e35638625 | ||
|
|
1cfaf0bc96 | ||
|
|
23dcce8ab0 | ||
|
|
e58593bb50 | ||
|
|
426786c862 | ||
|
|
3e90d34739 | ||
|
|
a7954d4bdc | ||
|
|
b67b146635 | ||
|
|
04f7aa963b | ||
|
|
6fa16423ac | ||
|
|
712305de32 | ||
|
|
94b5bf1aa0 | ||
|
|
fc8db41b0d | ||
|
|
b07b8f865b | ||
|
|
0c0d33cb29 | ||
|
|
edc49a0118 | ||
|
|
27d81ba1b9 | ||
|
|
e5e49e6242 | ||
|
|
e08c7a3ee0 | ||
|
|
e2b9c29ff0 | ||
|
|
d3e25901ff | ||
|
|
54bef9b2fb | ||
|
|
4e294c46f1 | ||
|
|
67268cb5b4 | ||
|
|
6bd3217727 | ||
|
|
9a344e0c31 | ||
|
|
d48b349d72 | ||
|
|
3568cc4cc7 | ||
|
|
d68ada1e13 | ||
|
|
c367cb95a2 | ||
|
|
25d9cc84c2 |
No files matched your search
@@ -121,7 +121,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
Source paths remain in `crates/horizon-client/`. Measure actual harness selection on refreshed
|
||||
records with `tools/parity.py --changed-files paths.txt --list`; historical counts are not a guarantee.
|
||||
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio, controllers, `discord`, `friends` and `heartbeat` (the server build).
|
||||
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
|
||||
`target/fast/horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --offscreen --wait --resources $PWD/.local/horizon-client/26.3`,
|
||||
then `horizon-ctl --socket … state|keys|turn|command|screenshot|quit`
|
||||
@@ -152,23 +152,23 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
|
||||
| Subsystem | Files |
|
||||
|---|---|
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `fn tick`, `fn tick_world`, `fn render`; G: `window_handler.rs` (`impl ApplicationHandler for Game`: `window_event`, `about_to_wait`); L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs`, `screen_backgrounds.rs` (panorama, menu blur, border wall) |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `world_entry.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
|
||||
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
|
||||
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
|
||||
| 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`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.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` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn tick_mining`, `fn break_block`); G: `item_actions.rs` (`fn use_item`), `melee.rs` (`fn attack`), `hand.rs`, `combat.rs`, `consume.rs`, `using_item.rs` (an item in use, tick by tick), `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.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`, `inventory_input.rs` (keys and clicks over a container), `inventory_slots.rs` (slot layout and drawing) |
|
||||
| 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` |
|
||||
| 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`, `loading_screen.rs` (the loading screen, the pre-game `App`), `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs`, `creative_screen.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`, `horizon-anticheat` (L: `lan/anticheat.rs`) |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| 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), `world_session.rs` (opening and closing a world); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`; G: `play_keys.rs` (`fn key_pressed`), `play_input.rs` (`fn play_mouse`, toggled keys, the hotbar wheel); crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs`, `pointer_capture.rs` (mouse look, pointer grab) |
|
||||
| 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`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; 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)) |
|
||||
@@ -191,7 +191,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
|
||||
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
|
||||
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
|
||||
(`pack`), both in game.rs.
|
||||
(`pack`), both in game.rs, their methods in `game/resource_tables.rs`.
|
||||
|
||||
## Single player runs on a server thread
|
||||
|
||||
|
||||
@@ -61,6 +61,10 @@ another project's source may also be read and used, **only when that project's l
|
||||
"source available" licenses, all rights reserved (OptiFine), and any project with no license or an unclear one.
|
||||
Don't read, copy, translate or paraphrase their source, and don't send code an AI tool wrote from it. Being able
|
||||
to download a mod or a pack does not give you the right to read its files.
|
||||
- **Java's own library is GPL too.** OpenJDK's source (`java.lang.Math`, `StrictMath`, `FdLibm`, `HashMap`, the
|
||||
sorts, `Double.toString`, HotSpot's intrinsics...) is GPL-2.0, so it is off limits like the rest. Reproduce Java's
|
||||
behaviour from its API documentation (the Javadoc) and from what a JVM returns, or from a permissive original such
|
||||
as Sun's fdlibm C sources (see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)); see #6741.
|
||||
- **Check the license yourself.** Read the LICENSE file at the commit you use and the header of each file you take:
|
||||
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one at
|
||||
your commit counts. In doubt, don't: ask in #contributors first.
|
||||
|
||||
Generated
+52
-26
@@ -147,6 +147,15 @@ dependencies = [
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arcstr"
|
||||
version = "1.2.0"
|
||||
@@ -243,6 +252,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
@@ -1554,7 +1569,7 @@ name = "horizon-client-render"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"cpal",
|
||||
@@ -1623,7 +1638,7 @@ dependencies = [
|
||||
name = "horizon-cosmetics"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"glam",
|
||||
"png",
|
||||
"serde",
|
||||
@@ -1651,7 +1666,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"socket2",
|
||||
"socket2 0.6.5",
|
||||
"spake2",
|
||||
]
|
||||
|
||||
@@ -1819,7 +1834,7 @@ dependencies = [
|
||||
name = "horizon-release-sign"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"ring",
|
||||
]
|
||||
|
||||
@@ -1828,7 +1843,7 @@ name = "horizon-server"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"getrandom 0.3.4",
|
||||
"horizon-anticheat",
|
||||
@@ -1859,7 +1874,7 @@ dependencies = [
|
||||
name = "horizon-server-files"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"horizon-core",
|
||||
"horizon-protocol",
|
||||
"horizon-world",
|
||||
@@ -2138,7 +2153,7 @@ version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
|
||||
dependencies = [
|
||||
"socket2",
|
||||
"socket2 0.6.5",
|
||||
"widestring",
|
||||
"windows-registry",
|
||||
"windows-result 0.4.1",
|
||||
@@ -2392,9 +2407,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.16.4"
|
||||
version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
|
||||
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
@@ -2493,10 +2508,11 @@ checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
|
||||
|
||||
[[package]]
|
||||
name = "mysql"
|
||||
version = "27.0.0"
|
||||
version = "28.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "090068bc0a24762663338da78414148333d1ee246a7293c10a517ddf1548b291"
|
||||
checksum = "ca3de8a73b7c70f76dbcdd4b6abc9b53bbb6c4f7bd6c79d8e8a7ea2157c02487"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"bufstream",
|
||||
"bytes",
|
||||
"crossbeam-queue",
|
||||
@@ -2511,7 +2527,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"socket2",
|
||||
"socket2 0.6.5",
|
||||
"twox-hash",
|
||||
"url",
|
||||
"webpki",
|
||||
@@ -2520,11 +2536,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mysql_common"
|
||||
version = "0.36.2"
|
||||
version = "0.37.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20e7f6a15265801679ddf1ddb38fd74b4555a677a78378d4561fbe5f0b1d4d72"
|
||||
checksum = "0f27695f286b461da077b8c2f72f47feaa04ce3c3f9c0976257410e90e21208a"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bitflags 2.13.2",
|
||||
"btoi",
|
||||
"byteorder",
|
||||
@@ -3140,7 +3156,7 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffb9bf5222606eb712d3bb30e01bc9420545b00859970897e70c682353a034f2"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"cbc",
|
||||
"cms",
|
||||
"der",
|
||||
@@ -3192,11 +3208,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.23.1",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
@@ -3447,7 +3463,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash 2.1.3",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"socket2 0.6.5",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -3485,7 +3501,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"socket2 0.6.5",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -3603,7 +3619,7 @@ dependencies = [
|
||||
"itoa",
|
||||
"percent-encoding",
|
||||
"ryu",
|
||||
"socket2",
|
||||
"socket2 0.6.5",
|
||||
"url",
|
||||
"xxhash-rust",
|
||||
]
|
||||
@@ -4055,6 +4071,16 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
@@ -4368,14 +4394,14 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.2"
|
||||
version = "1.53.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
|
||||
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"socket2 0.6.5",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -4549,7 +4575,7 @@ version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
|
||||
+15
@@ -82,6 +82,21 @@ libmimalloc-sys = { path = "third_party/libmimalloc-sys" }
|
||||
[profile.dev]
|
||||
debug = "line-tables-only"
|
||||
|
||||
# The native mod API is generated by a build script that parses every file of game/ with syn on
|
||||
# each gameplay edit; unoptimized that took ~6 s of every rebuild, optimized ~1.5 s.
|
||||
[profile.dev.package.horizon-mod-api-gen]
|
||||
opt-level = 3
|
||||
[profile.dev.package.syn]
|
||||
opt-level = 3
|
||||
[profile.dev.package.proc-macro2]
|
||||
opt-level = 3
|
||||
[profile.release.package.horizon-mod-api-gen]
|
||||
opt-level = 3
|
||||
[profile.release.package.syn]
|
||||
opt-level = 3
|
||||
[profile.release.package.proc-macro2]
|
||||
opt-level = 3
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
@@ -49,6 +49,18 @@ Everything below is in the current release.
|
||||
|
||||
<sub>Nightly 2026-11-05 on a Mac mini (Apple M4 Pro, Metal): vanilla's textures, no mods or shader packs, render distance 32 with distant terrain on.</sub>
|
||||
|
||||
## Performance
|
||||
|
||||
**Horizon draws more frames per second than Java Edition, and loads faster, on the same machine, world and path.** `tools/perf_bench.py` runs each game in turn, from a fresh world (seed 1234), waiting for the terrain and then flying it over a village, a forest, an ocean and a mountain. Measured on 2026-10-29/30 on an idle Windows 11 machine (Ryzen 7 9800X3D, RTX 5080), Horizon v0.2.97 as the launcher installs it, 1280x720 in a window, VSync off, three games per figure. Frames per second, mean of the four scenes, with the slowest 1% of frames in brackets:
|
||||
|
||||
| Render distance | Horizon v0.2.97 | Vanilla 26.3 | Vanilla + mods |
|
||||
| --- | --- | --- | --- |
|
||||
| 8 chunks | 3,289 (1,327) | 2,437 (640) | 2,375 (709) |
|
||||
| 16 chunks | 2,074 (598) | 1,661 (375) | 1,586 (432) |
|
||||
| 32 chunks | 1,207 (333) | 1,031 (176) | 879 (264) |
|
||||
|
||||
"Vanilla + mods" is Sodium, Lithium, FerriteCore and ImmediatelyFast. Horizon reaches the title screen in 2.9–3.2 s against vanilla's 7.5–7.7 s (the modded client's 10.8–11.1 s), loads a scene's chunks 2.9–3.8 times faster, and playing holds a steady working set of 1.1–1.7 GB against 1.9–2.9 GB. Every scene with its confidence interval and the run's load log: [the 2026-10-29 reports](benchmarks/perf/windows-9800x3d-5080-2026-10-29.md). Method, and how to measure your own machine: [docs/benchmark.md](docs/benchmark.md). [playhorizon.dev/benchmarks](https://playhorizon.dev/benchmarks) puts these runs beside a 2016 laptop, a Mac mini and a gaming desktop.
|
||||
|
||||
## Download
|
||||
|
||||
Get **the Horizon launcher** from [playhorizon.dev](https://playhorizon.dev), or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher&expanded=true):
|
||||
|
||||
+53
-1
@@ -1,5 +1,36 @@
|
||||
# Third-party notices
|
||||
|
||||
## Code removed for its license
|
||||
|
||||
Horizon once shipped three pieces of code under MIT that came from projects whose licenses don't allow that. All
|
||||
three are gone from main. They are listed here for anyone who holds an older commit, tag or release: in those
|
||||
versions, that code is not covered by Horizon's MIT license. It stays under its authors' copyright and its original
|
||||
license.
|
||||
|
||||
- **Shader packs written from Iris's source.** `crates/horizon-shaderpack` and the render stages that used it were
|
||||
written from the source of [Iris](https://github.com/IrisShaders/Iris) (LGPL-3.0, copyright the Iris contributors).
|
||||
They were added from 15 October 2026 (`b22583d057`), shipped in the game releases `v2026.10.16.02` to `v0.2.46`,
|
||||
and were removed on 24 October 2026 ([#1561](https://github.com/horizon-rust/horizon/pull/1561)) after a developer
|
||||
of Iris showed us. Shader packs came back in `crates/horizon-shaders`, first written from a description of the
|
||||
pack format.
|
||||
- **Math functions translated from OpenJDK.** In `crates/horizon-core/src/jmath.rs`, `fdlibm::{log, log10, cbrt,
|
||||
hypot}` were translated from OpenJDK's `java/lang/FdLibm.java` and `round_f32` from its `java/lang/Math.java`
|
||||
(GPL-2.0 with the Classpath Exception), and `aarch64::{sin, cos}` from HotSpot's `macroAssembler_aarch64_trig.cpp`
|
||||
(GPL-2.0); copyright Oracle and/or its affiliates, and Cavium for that file. The `fdlibm` and `aarch64` functions
|
||||
were added on 13 October 2026 (`558d140fca`, first released in `v2026.10.13`). `round_f32` was added on 14 October
|
||||
2026 as `java_round` in `crates/horizon-client/src/mipmap.rs` (`95db0fce9d`) and moved into `jmath.rs` on 3
|
||||
November ([#7770](https://github.com/horizon-rust/horizon/pull/7770)). All of them were replaced in
|
||||
[#11944](https://github.com/horizon-rust/horizon/pull/11944) by code written without OpenJDK's sources, from Sun's
|
||||
fdlibm C sources, the Java SE API documentation and a description of the behaviour, and checked against a JVM
|
||||
([#6741](https://github.com/horizon-rust/horizon/issues/6741)).
|
||||
- **Java's `HashMap` translated from OpenJDK.** `crates/horizon-world/src/java_hash_map.rs`, which reproduces the
|
||||
iteration order of `java.util.HashMap`, was translated from OpenJDK's `java/util/HashMap.java` (GPL-2.0 with the
|
||||
Classpath Exception; copyright Oracle and/or its affiliates). It was added on 7 October 2026 (`118e93ea51` and
|
||||
`194db0dbb4`, first released in `v2026.10.12`). It was replaced in
|
||||
[#12043](https://github.com/horizon-rust/horizon/pull/12043) by code written without OpenJDK's sources, from a
|
||||
description of the behaviour, the Java SE API documentation and the textbook red-black tree, and checked against a
|
||||
JVM ([#6741](https://github.com/horizon-rust/horizon/issues/6741)).
|
||||
|
||||
## Catnip / Ponder
|
||||
|
||||
`crates/horizon-client-render/src/fluid_render.rs` translates the still-fluid face tiling and
|
||||
@@ -225,7 +256,9 @@ THE SOFTWARE.
|
||||
|
||||
## Just Enough Items
|
||||
|
||||
The Recipes settings icon is from [Just Enough Items 31.17.0.108](https://github.com/mezz/JustEnoughItems) by mezz and contributors. It is distributed under the MIT license in `crates/horizon-client-render/src/jei_recipes/LICENSE.txt`.
|
||||
The Recipes GUI sprites and settings icon under `crates/horizon-client-render/src/jei_recipes` are from
|
||||
[Just Enough Items 31.17.0.108](https://github.com/mezz/JustEnoughItems/tree/0371fa6fafa14635a356019e4604f5580b26a34f)
|
||||
by mezz and contributors, under the MIT license in that directory (`LICENSE.txt`).
|
||||
|
||||
## Flywheel native model directional shading
|
||||
|
||||
@@ -254,3 +287,22 @@ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## fdlibm
|
||||
|
||||
`crates/horizon-core/src/jmath/fdlibm.rs` and the `fdlibm_asin`, `fdlibm_atan` and
|
||||
`fdlibm_atan2` functions in `crates/horizon-core/src/geometry/trig.rs` are Rust ports of
|
||||
[fdlibm](https://www.netlib.org/fdlibm/) 5.3, Sun's freely distributable math library, whose
|
||||
results the Java SE API specifies for `StrictMath`. `fdlibm.rs` ports `e_log.c`, `e_log10.c`,
|
||||
`s_cbrt.c` and `e_hypot.c`, and, with fused multiply-adds, `s_sin.c`, `s_cos.c`, `k_sin.c`,
|
||||
`k_cos.c` and `e_rem_pio2.c`; `trig.rs` ports `e_asin.c`, `s_atan.c` and `e_atan2.c`. This
|
||||
notice also heads both files:
|
||||
|
||||
```
|
||||
Copyright (C) 1993 by Sun Microsystems, Inc. All rights reserved.
|
||||
|
||||
Developed at SunSoft, a Sun Microsystems, Inc. business.
|
||||
Permission to use, copy, modify, and distribute this
|
||||
software is freely granted, provided that this notice
|
||||
is preserved.
|
||||
```
|
||||
@@ -490,6 +490,37 @@ impl GlyphStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// How much the gameplay guide says.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum GuideDetail {
|
||||
#[default]
|
||||
Full,
|
||||
/// Only what can be done to what's in front of the player or under the crosshair.
|
||||
Basic,
|
||||
Off,
|
||||
}
|
||||
impl GuideDetail {
|
||||
pub const ALL: [GuideDetail; 3] = [GuideDetail::Full, GuideDetail::Basic, GuideDetail::Off];
|
||||
|
||||
/// The name in `horizon-controller.txt`.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
GuideDetail::Full => "full",
|
||||
GuideDetail::Basic => "basic",
|
||||
GuideDetail::Off => "off",
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a name, or the `true` and `false` this option first saved. Anything else is full.
|
||||
pub fn parse(value: &str) -> Self {
|
||||
match value {
|
||||
"basic" => GuideDetail::Basic,
|
||||
"off" | "false" => GuideDetail::Off,
|
||||
_ => GuideDetail::Full,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The on-screen keyboard A opens on a text field in focus mode.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum TextKeyboard {
|
||||
@@ -537,6 +568,8 @@ pub fn steam_keyboard(choice: TextKeyboard) -> bool {
|
||||
pub struct PadSettings {
|
||||
pub enabled: bool,
|
||||
pub vibration: bool,
|
||||
/// The pad keeps driving the game while its window has no focus.
|
||||
pub play_in_background: bool,
|
||||
/// A DualSense's triggers push back (Horizon's; docs/controllers.md).
|
||||
pub adaptive_triggers: bool,
|
||||
pub lightbar: Lightbar,
|
||||
@@ -556,6 +589,8 @@ pub struct PadSettings {
|
||||
/// by default on the Switch only.
|
||||
pub nintendo_letters: bool,
|
||||
pub keyboard: TextKeyboard,
|
||||
/// The gameplay guide: what the buttons do now, in the HUD's corners while the pad plays.
|
||||
pub guide_detail: GuideDetail,
|
||||
/// Menu pointer multiplier, scroll lines/second, and repeat times in seconds.
|
||||
pub pointer_speed: f32,
|
||||
pub scroll_speed: f32,
|
||||
@@ -569,6 +604,7 @@ impl Default for PadSettings {
|
||||
Self {
|
||||
enabled: true,
|
||||
vibration: true,
|
||||
play_in_background: false,
|
||||
adaptive_triggers: true,
|
||||
lightbar: Lightbar::Health,
|
||||
look_sensitivity: 0.5,
|
||||
@@ -581,6 +617,7 @@ impl Default for PadSettings {
|
||||
glyphs: GlyphStyle::Auto,
|
||||
nintendo_letters: cfg!(target_os = "horizon"),
|
||||
keyboard: TextKeyboard::Auto,
|
||||
guide_detail: GuideDetail::Full,
|
||||
pointer_speed: 1.0,
|
||||
scroll_speed: 12.0,
|
||||
repeat_delay: 0.35,
|
||||
@@ -635,6 +672,7 @@ impl PadSettings {
|
||||
match key.trim() {
|
||||
"enabled" => s.enabled = flag(s.enabled),
|
||||
"vibration" => s.vibration = flag(s.vibration),
|
||||
"playInBackground" => s.play_in_background = flag(s.play_in_background),
|
||||
"adaptiveTriggers" => s.adaptive_triggers = flag(s.adaptive_triggers),
|
||||
"lightbar" => s.lightbar = Lightbar::parse(value),
|
||||
"lookSensitivity" => s.look_sensitivity = unit(s.look_sensitivity),
|
||||
@@ -649,6 +687,7 @@ impl PadSettings {
|
||||
"glyphs" => s.glyphs = GlyphStyle::parse(value),
|
||||
"nintendoLetters" => s.nintendo_letters = flag(s.nintendo_letters),
|
||||
"keyboard" => s.keyboard = TextKeyboard::parse(value),
|
||||
"gameplayGuide" => s.guide_detail = GuideDetail::parse(value),
|
||||
"pointerSpeed" => s.pointer_speed = bounded(s.pointer_speed, 0.25, 3.0),
|
||||
"scrollSpeed" => s.scroll_speed = bounded(s.scroll_speed, 1.0, 36.0),
|
||||
"repeatDelay" => s.repeat_delay = bounded(s.repeat_delay, 0.1, 1.0),
|
||||
@@ -706,7 +745,8 @@ impl PadSettings {
|
||||
self.keyboard.name(),
|
||||
);
|
||||
text.push_str(&format!(
|
||||
"pointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
|
||||
"gameplayGuide:{}\npointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
|
||||
self.guide_detail.name(),
|
||||
self.pointer_speed, self.scroll_speed, self.repeat_delay, self.repeat_interval,
|
||||
));
|
||||
for (action, button) in &self.bindings {
|
||||
@@ -715,6 +755,10 @@ impl PadSettings {
|
||||
button.map_or("none", Button::name)
|
||||
));
|
||||
}
|
||||
text.push_str(&format!(
|
||||
"playInBackground:{}\n",
|
||||
bool_text(self.play_in_background)
|
||||
));
|
||||
text.push_str("bindLayout:1\n");
|
||||
text
|
||||
}
|
||||
@@ -786,7 +830,8 @@ pub fn stick_axes(
|
||||
{
|
||||
// Default map: RightStickX is the stick's Y, LeftZ its X; RightStickY
|
||||
// is the right trigger and must not drive look.
|
||||
(left, [left_z, right[0]])
|
||||
// gilrs turns over only the axes it calls Y, and this Y is on one it doesn't.
|
||||
(left, [left_z, -right[0]])
|
||||
} else {
|
||||
(left, right)
|
||||
}
|
||||
@@ -1752,6 +1797,15 @@ bind_key.hotbar.2:north
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playing_in_the_background_is_off_until_saved_on() {
|
||||
assert!(!PadSettings::parse("vibration:false\n").play_in_background);
|
||||
let mut on = PadSettings::default();
|
||||
assert!(!on.play_in_background);
|
||||
on.play_in_background = true;
|
||||
assert!(PadSettings::parse(&on.serialize()).play_in_background);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_from_before_these_defaults_keeps_its_buttons() {
|
||||
// Saved settings that never bound D-pad down: it stays on the advancements.
|
||||
@@ -1793,6 +1847,7 @@ bind_key.hotbar.2:north
|
||||
s.look_sensitivity = 0.75;
|
||||
s.glyphs = GlyphStyle::PlayStation;
|
||||
s.nintendo_letters = !s.nintendo_letters;
|
||||
s.guide_detail = GuideDetail::Basic;
|
||||
let text = s.serialize();
|
||||
assert!(text.contains(&format!("nintendoLetters:{}\n", s.nintendo_letters)));
|
||||
assert!(text.contains("bind_key.jump:east\n"));
|
||||
@@ -1820,7 +1875,11 @@ bind_key.hotbar.2:north
|
||||
false,
|
||||
);
|
||||
assert_eq!(left, [0.2, 0.3]);
|
||||
assert_eq!(right, [-0.5, 0.7], "X from LeftZ, Y from RightStickX");
|
||||
assert_eq!(
|
||||
right,
|
||||
[-0.5, -0.7],
|
||||
"X from LeftZ, Y from RightStickX, turned over"
|
||||
);
|
||||
let kept = stick_axes(
|
||||
[0.2, 0.3],
|
||||
[0.7, -0.9],
|
||||
|
||||
@@ -0,0 +1,962 @@
|
||||
//! The gameplay guide: what the buttons do right now, in the corners of the HUD while a pad plays
|
||||
//! and of an inventory it drives (Horizon's; vanilla has no gamepad input). The game reads the facts
|
||||
//! from its own hit results and capabilities (`game/pad/guide.rs`); this turns them into rows, and
|
||||
//! places the rows around what was drawn.
|
||||
use crate::gamepad::{Button, GuideDetail, PadSettings, Rect};
|
||||
|
||||
/// What a use does, by what takes it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Verb {
|
||||
Use,
|
||||
Open,
|
||||
Ride,
|
||||
Trade,
|
||||
Feed,
|
||||
Place,
|
||||
Eat,
|
||||
Drink,
|
||||
Shoot,
|
||||
Load,
|
||||
Throw,
|
||||
Raise,
|
||||
Equip,
|
||||
Fish,
|
||||
Zoom,
|
||||
Boost,
|
||||
}
|
||||
impl Verb {
|
||||
fn caption(self) -> &'static str {
|
||||
match self {
|
||||
Verb::Use => "horizon.controller.guide.use",
|
||||
Verb::Open => "horizon.controller.guide.open",
|
||||
Verb::Ride => "horizon.controller.guide.ride",
|
||||
Verb::Trade => "horizon.controller.guide.trade",
|
||||
Verb::Feed => "horizon.controller.guide.feed",
|
||||
Verb::Place => "horizon.controller.guide.place",
|
||||
Verb::Eat => "horizon.controller.guide.eat",
|
||||
Verb::Drink => "horizon.controller.guide.drink",
|
||||
Verb::Shoot => "horizon.controller.guide.shoot",
|
||||
Verb::Load => "horizon.controller.guide.load",
|
||||
Verb::Throw => "horizon.controller.guide.throw",
|
||||
Verb::Raise => "horizon.controller.guide.raise",
|
||||
Verb::Equip => "horizon.controller.guide.equip",
|
||||
Verb::Fish => "horizon.controller.guide.fish",
|
||||
Verb::Zoom => "horizon.controller.guide.zoom",
|
||||
Verb::Boost => "horizon.controller.guide.boost",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Minecraft.hitResult`, with what each button would do to it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Target {
|
||||
#[default]
|
||||
None,
|
||||
Block {
|
||||
breakable: bool,
|
||||
/// The block takes the use before the held item gets its turn.
|
||||
takes: Option<Verb>,
|
||||
},
|
||||
Entity {
|
||||
attackable: bool,
|
||||
takes: Option<Verb>,
|
||||
},
|
||||
}
|
||||
|
||||
/// An item in use, by what letting go of it does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Active {
|
||||
Shoot,
|
||||
Throw,
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// What the guide knows of the player this moment.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Facts {
|
||||
pub spectator: bool,
|
||||
pub target: Target,
|
||||
/// What `Item.use` does on the target or in the air: the main hand's, else the off hand's.
|
||||
pub item_use: Option<Verb>,
|
||||
/// Pick Block finds something it can select or, in creative, add.
|
||||
pub can_pick: bool,
|
||||
pub using: Option<Active>,
|
||||
/// `LocalPlayer.isHandsBusy`: paddling a boat.
|
||||
pub hands_busy: bool,
|
||||
pub riding: bool,
|
||||
/// The vehicle jumps when told (a saddled horse).
|
||||
pub vehicle_jumps: bool,
|
||||
pub on_ground: bool,
|
||||
pub in_water: bool,
|
||||
pub flying: bool,
|
||||
pub may_fly: bool,
|
||||
pub gliding: bool,
|
||||
/// In the air with a glider on: a jump would start gliding.
|
||||
pub can_glide: bool,
|
||||
/// Sneaking that the button latched, so the next press stands up.
|
||||
pub sneak_latched: bool,
|
||||
pub can_sprint: bool,
|
||||
pub main_hand_item: bool,
|
||||
pub off_hand_item: bool,
|
||||
}
|
||||
|
||||
/// One button's line: the button and the translation key of what it does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Row {
|
||||
/// Shown before `button`: held with it, or its other half (LB and RB for pages).
|
||||
pub with: Option<Button>,
|
||||
pub button: Button,
|
||||
pub caption: &'static str,
|
||||
}
|
||||
impl Row {
|
||||
fn new(button: Button, caption: &'static str) -> Self {
|
||||
Row {
|
||||
with: None,
|
||||
button,
|
||||
caption,
|
||||
}
|
||||
}
|
||||
fn pair(with: Button, button: Button, caption: &'static str) -> Self {
|
||||
Row {
|
||||
with: Some(with),
|
||||
button,
|
||||
caption,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The two columns, most useful row first.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Guide {
|
||||
/// Top left: movement and the player's own actions.
|
||||
pub left: Vec<Row>,
|
||||
/// Top right: break, use and pick block.
|
||||
pub right: Vec<Row>,
|
||||
}
|
||||
|
||||
impl Guide {
|
||||
/// The rows this detail keeps. Basic is the right column: what the buttons do to what's in
|
||||
/// front of the player, or the plain face buttons in an inventory.
|
||||
pub fn at_detail(self, detail: GuideDetail) -> Self {
|
||||
match detail {
|
||||
GuideDetail::Full => self,
|
||||
GuideDetail::Basic => Guide {
|
||||
left: Vec::new(),
|
||||
right: self.right,
|
||||
},
|
||||
GuideDetail::Off => Guide {
|
||||
left: Vec::new(),
|
||||
right: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows for these facts on these bindings. An unbound action has no row, and neither has one
|
||||
/// on Start, which is always pause.
|
||||
pub fn resolve(facts: &Facts, settings: &PadSettings) -> Guide {
|
||||
let row = |action: &str, caption: Option<&'static str>| {
|
||||
let button = settings
|
||||
.binding(action)
|
||||
.filter(|button| *button != Button::Start)?;
|
||||
Some(Row::new(button, caption?))
|
||||
};
|
||||
let player = !facts.spectator;
|
||||
let any_item = facts.main_hand_item || facts.off_hand_item;
|
||||
Guide {
|
||||
left: [
|
||||
row("key.jump", jump_caption(facts)),
|
||||
row("key.sneak", sneak_caption(facts)),
|
||||
row("key.sprint", facts.can_sprint.then_some("key.sprint")),
|
||||
row("key.inventory", player.then_some("key.inventory")),
|
||||
row(
|
||||
"key.drop",
|
||||
(player && facts.main_hand_item).then_some("horizon.controller.guide.drop"),
|
||||
),
|
||||
row(
|
||||
"key.swapOffhand",
|
||||
(player && any_item).then_some("horizon.controller.guide.swap"),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
right: [
|
||||
row("key.attack", attack_caption(facts)),
|
||||
row("key.use", use_caption(facts)),
|
||||
row("key.pickItem", pick_caption(facts)),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn jump_caption(f: &Facts) -> Option<&'static str> {
|
||||
if f.riding {
|
||||
f.vehicle_jumps.then_some("key.jump")
|
||||
} else if f.flying {
|
||||
Some("horizon.controller.guide.fly_up")
|
||||
} else if f.gliding {
|
||||
None
|
||||
} else if f.in_water {
|
||||
Some("horizon.controller.guide.swim_up")
|
||||
} else if f.on_ground {
|
||||
Some("key.jump")
|
||||
} else if f.can_glide {
|
||||
Some("horizon.controller.guide.glide")
|
||||
} else {
|
||||
f.may_fly.then_some("horizon.controller.guide.fly")
|
||||
}
|
||||
}
|
||||
|
||||
fn sneak_caption(f: &Facts) -> Option<&'static str> {
|
||||
if f.riding {
|
||||
Some("horizon.controller.guide.dismount")
|
||||
} else if f.flying {
|
||||
Some("horizon.controller.guide.fly_down")
|
||||
} else if f.gliding {
|
||||
None
|
||||
} else if f.in_water && !f.on_ground {
|
||||
Some("horizon.controller.guide.swim_down")
|
||||
} else if f.sneak_latched {
|
||||
Some("horizon.controller.guide.stand")
|
||||
} else {
|
||||
Some("key.sneak")
|
||||
}
|
||||
}
|
||||
|
||||
/// `Minecraft.handleKeybinds` spends attack clicks while an item is in use, and
|
||||
/// `startAttack` does nothing with busy hands.
|
||||
fn attack_caption(f: &Facts) -> Option<&'static str> {
|
||||
if f.using.is_some() || f.hands_busy {
|
||||
return None;
|
||||
}
|
||||
match f.target {
|
||||
Target::Entity {
|
||||
attackable: true, ..
|
||||
} if f.spectator => Some("horizon.controller.guide.spectate"),
|
||||
Target::Entity {
|
||||
attackable: true, ..
|
||||
} => Some("horizon.controller.guide.attack"),
|
||||
Target::Block {
|
||||
breakable: true, ..
|
||||
} => Some("horizon.controller.guide.break"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `Minecraft.startUseItem`: what is aimed at first, then the item.
|
||||
fn use_caption(f: &Facts) -> Option<&'static str> {
|
||||
match f.using {
|
||||
Some(Active::Shoot) => return Some("horizon.controller.guide.release_shoot"),
|
||||
Some(Active::Throw) => return Some("horizon.controller.guide.release_throw"),
|
||||
Some(Active::Stop) => return Some("horizon.controller.guide.release_stop"),
|
||||
None => {}
|
||||
}
|
||||
if f.hands_busy {
|
||||
return None;
|
||||
}
|
||||
let takes = match f.target {
|
||||
Target::Block { takes, .. } | Target::Entity { takes, .. } => takes,
|
||||
Target::None => None,
|
||||
};
|
||||
takes.or(f.item_use).map(Verb::caption)
|
||||
}
|
||||
|
||||
fn pick_caption(f: &Facts) -> Option<&'static str> {
|
||||
if f.spectator {
|
||||
// key.spectatorHotbar is this button: it opens the teleport menu.
|
||||
return Some("horizon.controller.guide.spectator_menu");
|
||||
}
|
||||
(f.can_pick && f.using.is_none()).then_some("key.pickItem")
|
||||
}
|
||||
|
||||
/// What the crosshair is on in an inventory.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Under {
|
||||
#[default]
|
||||
Nothing,
|
||||
/// A slot. `same` is the carried stack being its kind; `result` a crafting result, which is
|
||||
/// only ever taken whole.
|
||||
Slot {
|
||||
item: bool,
|
||||
same: bool,
|
||||
result: bool,
|
||||
/// The carried stack can go here.
|
||||
accepts: bool,
|
||||
},
|
||||
Recipe {
|
||||
direct_craft: bool,
|
||||
alternatives: bool,
|
||||
},
|
||||
/// One of the recipe book's other controls.
|
||||
Control,
|
||||
}
|
||||
|
||||
/// What an inventory's rows are resolved from.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct MenuFacts {
|
||||
pub under: Under,
|
||||
pub carrying: bool,
|
||||
/// The recipe book is showing: the bumpers turn its pages, the triggers its categories.
|
||||
pub book: bool,
|
||||
/// The bumpers switch tabs.
|
||||
pub tabs: bool,
|
||||
}
|
||||
|
||||
/// An inventory's rows, by what they ask of the player: at the bottom right one face button, at the
|
||||
/// bottom left anything else (a trigger held with a button, the bumpers, View, a stick click). What
|
||||
/// the crosshair is on comes first in its column, the screen's own rows after, except Close, which
|
||||
/// stays lowest so the rest don't move it. Menu buttons aren't rebindable, so these name them.
|
||||
pub fn resolve_menu(f: &MenuFacts) -> Guide {
|
||||
use Button::{East, LeftBumper, LeftStick, LeftTrigger, North, RightBumper, RightTrigger};
|
||||
use Button::{Select, South, West};
|
||||
const TAKE: &str = "horizon.controller.guide.take";
|
||||
const MOVE: &str = "horizon.controller.guide.quick_move";
|
||||
let under = match f.under {
|
||||
// A result takes only into empty hands or the same stack.
|
||||
Under::Slot {
|
||||
item: true,
|
||||
result: true,
|
||||
same,
|
||||
..
|
||||
} if !f.carrying || same => vec![Row::new(South, TAKE), Row::new(North, MOVE)],
|
||||
Under::Slot { result: true, .. } => Vec::new(),
|
||||
Under::Slot { accepts: false, .. } if f.carrying => Vec::new(),
|
||||
Under::Slot { item, same, .. } if f.carrying => vec![
|
||||
Row::new(
|
||||
South,
|
||||
if item && !same {
|
||||
"horizon.controller.guide.swap_stack"
|
||||
} else {
|
||||
"horizon.controller.guide.put"
|
||||
},
|
||||
),
|
||||
Row::new(West, "horizon.controller.guide.put_one"),
|
||||
],
|
||||
Under::Slot { item: true, .. } => vec![
|
||||
Row::new(South, TAKE),
|
||||
Row::new(West, "horizon.controller.guide.take_half"),
|
||||
Row::new(North, MOVE),
|
||||
Row::new(Select, "horizon.controller.guide.drop"),
|
||||
Row::new(LeftStick, "horizon.controller.guide.drop_stack"),
|
||||
],
|
||||
Under::Nothing | Under::Slot { .. } => Vec::new(),
|
||||
Under::Recipe {
|
||||
direct_craft,
|
||||
alternatives,
|
||||
} => [
|
||||
Some(Row::new(South, "horizon.controller.guide.place_recipe")),
|
||||
direct_craft.then_some(Row::pair(
|
||||
LeftTrigger,
|
||||
South,
|
||||
"horizon.controller.direct_craft",
|
||||
)),
|
||||
Some(Row::new(North, "horizon.controller.guide.place_recipe_all")),
|
||||
alternatives.then_some(Row::new(West, "horizon.controller.guide.alternatives")),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
Under::Control => vec![Row::new(South, "horizon.controller.guide.select")],
|
||||
};
|
||||
let screen = [
|
||||
f.book.then_some(Row::pair(
|
||||
LeftBumper,
|
||||
RightBumper,
|
||||
"horizon.controller.guide.pages",
|
||||
)),
|
||||
f.book.then_some(Row::pair(
|
||||
LeftTrigger,
|
||||
RightTrigger,
|
||||
"horizon.controller.guide.categories",
|
||||
)),
|
||||
(f.tabs && !f.book).then_some(Row::pair(
|
||||
LeftBumper,
|
||||
RightBumper,
|
||||
"horizon.controller.guide.tabs",
|
||||
)),
|
||||
];
|
||||
let close = Row::new(East, "horizon.controller.guide.close");
|
||||
let rows = std::iter::once(close)
|
||||
.chain(under)
|
||||
.chain(screen.into_iter().flatten());
|
||||
let (right, left) =
|
||||
rows.partition(|r| r.with.is_none() && matches!(r.button, South | East | West | North));
|
||||
Guide { left, right }
|
||||
}
|
||||
|
||||
/// A row's height and the view's margin, in GUI pixels.
|
||||
pub const ROW_HEIGHT: f32 = 11.0;
|
||||
const MARGIN: f32 = 2.0;
|
||||
|
||||
fn overlap(a: &Rect, b: &Rect) -> bool {
|
||||
a[0] < b[0] + b[2] && b[0] < a[0] + a[2] && a[1] < b[1] + b[3] && b[1] < a[1] + a[3]
|
||||
}
|
||||
|
||||
/// Where a column's rows go, down from a top corner of the `[width, height]` view: under whatever
|
||||
/// a row would overlap in `taken`, or nowhere when that leaves the view's upper three quarters (the
|
||||
/// row is dropped: the rows come in the order they matter). The placed rows join `taken`.
|
||||
pub fn place_column(
|
||||
view: [f32; 2],
|
||||
left: bool,
|
||||
widths: &[f32],
|
||||
taken: &mut Vec<Rect>,
|
||||
) -> Vec<Option<Rect>> {
|
||||
place(view, left, false, widths, taken)
|
||||
}
|
||||
|
||||
/// `place_column`'s rows up from a bottom corner, over what they would overlap, within the view's
|
||||
/// lower quarter: the first row is the lowest.
|
||||
pub fn place_column_up(
|
||||
view: [f32; 2],
|
||||
left: bool,
|
||||
widths: &[f32],
|
||||
taken: &mut Vec<Rect>,
|
||||
) -> Vec<Option<Rect>> {
|
||||
place(view, left, true, widths, taken)
|
||||
}
|
||||
|
||||
fn place(
|
||||
view: [f32; 2],
|
||||
left: bool,
|
||||
up: bool,
|
||||
widths: &[f32],
|
||||
taken: &mut Vec<Rect>,
|
||||
) -> Vec<Option<Rect>> {
|
||||
let mut y = if up {
|
||||
view[1] - MARGIN - ROW_HEIGHT
|
||||
} else {
|
||||
MARGIN
|
||||
};
|
||||
widths
|
||||
.iter()
|
||||
.map(|&width| {
|
||||
if width > view[0] - 2.0 * MARGIN {
|
||||
return None;
|
||||
}
|
||||
let x = if left {
|
||||
MARGIN
|
||||
} else {
|
||||
view[0] - MARGIN - width
|
||||
};
|
||||
let mut at = y;
|
||||
loop {
|
||||
let outside = if up {
|
||||
at < view[1] * 0.75
|
||||
} else {
|
||||
at + ROW_HEIGHT > view[1] * 0.75
|
||||
};
|
||||
if outside {
|
||||
return None;
|
||||
}
|
||||
let rect = [x, at, width, ROW_HEIGHT];
|
||||
let hit = taken.iter().filter(|t| overlap(&rect, t));
|
||||
let past = if up {
|
||||
hit.map(|t| t[1]).fold(f32::INFINITY, f32::min)
|
||||
} else {
|
||||
hit.map(|t| t[1] + t[3]).fold(f32::NEG_INFINITY, f32::max)
|
||||
};
|
||||
if past.is_infinite() {
|
||||
taken.push(rect);
|
||||
y = if up {
|
||||
at - ROW_HEIGHT - 1.0
|
||||
} else {
|
||||
at + ROW_HEIGHT + 1.0
|
||||
};
|
||||
return Some(rect);
|
||||
}
|
||||
at = if up {
|
||||
past.floor() - ROW_HEIGHT - 1.0
|
||||
} else {
|
||||
past.ceil() + 1.0
|
||||
};
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn walking() -> Facts {
|
||||
Facts {
|
||||
on_ground: true,
|
||||
can_sprint: true,
|
||||
main_hand_item: true,
|
||||
..Facts::default()
|
||||
}
|
||||
}
|
||||
fn on_block(f: Facts, breakable: bool, takes: Option<Verb>) -> Facts {
|
||||
Facts {
|
||||
target: Target::Block { breakable, takes },
|
||||
..f
|
||||
}
|
||||
}
|
||||
fn rows(rows: &[Row]) -> Vec<(Button, &'static str)> {
|
||||
rows.iter().map(|r| (r.button, r.caption)).collect()
|
||||
}
|
||||
fn captions(rows: &[Row]) -> Vec<&'static str> {
|
||||
rows.iter().map(|r| r.caption).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn survival_on_a_block_shows_break_use_and_pick_on_the_default_buttons() {
|
||||
let facts = Facts {
|
||||
can_pick: true,
|
||||
item_use: Some(Verb::Place),
|
||||
..on_block(walking(), true, None)
|
||||
};
|
||||
let guide = resolve(&facts, &PadSettings::default());
|
||||
assert_eq!(
|
||||
rows(&guide.right),
|
||||
[
|
||||
(Button::RightTrigger, "horizon.controller.guide.break"),
|
||||
(Button::LeftTrigger, "horizon.controller.guide.place"),
|
||||
(Button::DPadLeft, "key.pickItem"),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&guide.left),
|
||||
[
|
||||
(Button::South, "key.jump"),
|
||||
(Button::RightStick, "key.sneak"),
|
||||
(Button::LeftStick, "key.sprint"),
|
||||
(Button::North, "key.inventory"),
|
||||
(Button::DPadDown, "horizon.controller.guide.drop"),
|
||||
(Button::West, "horizon.controller.guide.swap"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rebound_action_moves_its_row_and_an_unbound_one_loses_it() {
|
||||
let mut settings = PadSettings::default();
|
||||
settings.bind("key.attack", Some(Button::North));
|
||||
settings.bind("key.pickItem", None);
|
||||
settings.bind("key.jump", Some(Button::Start));
|
||||
let facts = Facts {
|
||||
can_pick: true,
|
||||
..on_block(walking(), true, None)
|
||||
};
|
||||
let guide = resolve(&facts, &settings);
|
||||
assert_eq!(
|
||||
rows(&guide.right),
|
||||
[(Button::North, "horizon.controller.guide.break")]
|
||||
);
|
||||
// Start only ever pauses.
|
||||
assert!(!captions(&guide.left).contains(&"key.jump"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_a_block_or_entity_takes_comes_before_the_held_item() {
|
||||
let settings = PadSettings::default();
|
||||
let chest = Facts {
|
||||
item_use: Some(Verb::Place),
|
||||
..on_block(walking(), true, Some(Verb::Open))
|
||||
};
|
||||
assert_eq!(
|
||||
captions(&resolve(&chest, &settings).right),
|
||||
[
|
||||
"horizon.controller.guide.break",
|
||||
"horizon.controller.guide.open"
|
||||
]
|
||||
);
|
||||
let villager = Facts {
|
||||
item_use: Some(Verb::Eat),
|
||||
target: Target::Entity {
|
||||
attackable: true,
|
||||
takes: Some(Verb::Trade),
|
||||
},
|
||||
..walking()
|
||||
};
|
||||
assert_eq!(
|
||||
captions(&resolve(&villager, &settings).right),
|
||||
[
|
||||
"horizon.controller.guide.attack",
|
||||
"horizon.controller.guide.trade"
|
||||
]
|
||||
);
|
||||
// A mob the item means nothing to: the item is used on.
|
||||
let cow = Facts {
|
||||
target: Target::Entity {
|
||||
attackable: true,
|
||||
takes: None,
|
||||
},
|
||||
..villager
|
||||
};
|
||||
assert_eq!(
|
||||
captions(&resolve(&cow, &settings).right)[1],
|
||||
"horizon.controller.guide.eat"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_do_leaves_the_right_column_empty() {
|
||||
let settings = PadSettings::default();
|
||||
assert!(resolve(&walking(), &settings).right.is_empty());
|
||||
// An unbreakable block too (bedrock, a creative sword, adventure without can_break).
|
||||
assert!(
|
||||
resolve(&on_block(walking(), false, None), &settings)
|
||||
.right
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_in_use_leaves_only_letting_go() {
|
||||
let facts = Facts {
|
||||
using: Some(Active::Shoot),
|
||||
can_pick: true,
|
||||
..on_block(walking(), true, Some(Verb::Open))
|
||||
};
|
||||
assert_eq!(
|
||||
captions(&resolve(&facts, &PadSettings::default()).right),
|
||||
["horizon.controller.guide.release_shoot"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn busy_hands_neither_attack_nor_use() {
|
||||
let facts = Facts {
|
||||
hands_busy: true,
|
||||
riding: true,
|
||||
item_use: Some(Verb::Eat),
|
||||
..on_block(Facts::default(), true, None)
|
||||
};
|
||||
assert!(resolve(&facts, &PadSettings::default()).right.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movement_follows_what_the_player_is_doing() {
|
||||
let settings = PadSettings::default();
|
||||
let first_two = |f: Facts| {
|
||||
let left = resolve(&f, &settings).left;
|
||||
captions(&left).into_iter().take(2).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(
|
||||
first_two(Facts {
|
||||
flying: true,
|
||||
may_fly: true,
|
||||
..Facts::default()
|
||||
}),
|
||||
[
|
||||
"horizon.controller.guide.fly_up",
|
||||
"horizon.controller.guide.fly_down"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
first_two(Facts {
|
||||
in_water: true,
|
||||
..Facts::default()
|
||||
}),
|
||||
[
|
||||
"horizon.controller.guide.swim_up",
|
||||
"horizon.controller.guide.swim_down"
|
||||
]
|
||||
);
|
||||
// In the air, only a glider or creative flight makes a jump do anything.
|
||||
assert_eq!(first_two(Facts::default()), ["key.sneak", "key.inventory"]);
|
||||
assert_eq!(
|
||||
first_two(Facts {
|
||||
can_glide: true,
|
||||
..Facts::default()
|
||||
})[0],
|
||||
"horizon.controller.guide.glide"
|
||||
);
|
||||
assert_eq!(
|
||||
first_two(Facts {
|
||||
sneak_latched: true,
|
||||
on_ground: true,
|
||||
..Facts::default()
|
||||
})[1],
|
||||
"horizon.controller.guide.stand"
|
||||
);
|
||||
// A boat can't jump; a saddled horse can; both dismount.
|
||||
assert_eq!(
|
||||
first_two(Facts {
|
||||
riding: true,
|
||||
on_ground: true,
|
||||
..Facts::default()
|
||||
})[0],
|
||||
"horizon.controller.guide.dismount"
|
||||
);
|
||||
assert_eq!(
|
||||
first_two(Facts {
|
||||
riding: true,
|
||||
vehicle_jumps: true,
|
||||
..Facts::default()
|
||||
}),
|
||||
["key.jump", "horizon.controller.guide.dismount"]
|
||||
);
|
||||
// Gliding: neither jump nor sneak does anything.
|
||||
assert_eq!(
|
||||
first_two(Facts {
|
||||
gliding: true,
|
||||
..Facts::default()
|
||||
}),
|
||||
["key.inventory"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spectator_only_flies_opens_spectates_and_has_its_menu() {
|
||||
let facts = Facts {
|
||||
spectator: true,
|
||||
flying: true,
|
||||
may_fly: true,
|
||||
main_hand_item: true,
|
||||
target: Target::Entity {
|
||||
attackable: true,
|
||||
takes: None,
|
||||
},
|
||||
..Facts::default()
|
||||
};
|
||||
let guide = resolve(&facts, &PadSettings::default());
|
||||
assert_eq!(
|
||||
captions(&guide.right),
|
||||
[
|
||||
"horizon.controller.guide.spectate",
|
||||
"horizon.controller.guide.spectator_menu"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
captions(&guide.left),
|
||||
[
|
||||
"horizon.controller.guide.fly_up",
|
||||
"horizon.controller.guide.fly_down"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_stack_from_their_corner() {
|
||||
let mut taken = Vec::new();
|
||||
let left = place_column([200.0, 200.0], true, &[50.0, 30.0], &mut taken);
|
||||
assert_eq!(left[0], Some([2.0, 2.0, 50.0, 11.0]));
|
||||
assert_eq!(left[1], Some([2.0, 14.0, 30.0, 11.0]));
|
||||
let right = place_column([200.0, 200.0], false, &[40.0], &mut taken);
|
||||
assert_eq!(right[0], Some([158.0, 2.0, 40.0, 11.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_goes_under_what_it_would_overlap() {
|
||||
// The effect icons in the top right corner.
|
||||
let mut taken = vec![[150.0, 1.0, 50.0, 24.0]];
|
||||
let right = place_column([200.0, 200.0], false, &[40.0, 40.0], &mut taken);
|
||||
assert_eq!(right[0], Some([158.0, 26.0, 40.0, 11.0]));
|
||||
assert_eq!(right[1], Some([158.0, 38.0, 40.0, 11.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_columns_of_a_narrow_view_never_overlap_and_late_rows_drop() {
|
||||
// One quarter of a small window, a boss bar across its top: the rows have 31..67 left.
|
||||
let view = [160.0, 90.0];
|
||||
let bar = [0.0, 12.0, 160.0, 19.0];
|
||||
let mut taken = vec![bar];
|
||||
let right = place_column(view, false, &[90.0, 90.0, 90.0], &mut taken);
|
||||
let left = place_column(view, true, &[90.0; 6], &mut taken);
|
||||
let placed: Vec<Rect> = right.iter().chain(&left).flatten().copied().collect();
|
||||
for (i, a) in placed.iter().enumerate() {
|
||||
assert!(a[1] + a[3] <= 67.5 && a[0] >= 0.0 && a[0] + a[2] <= 160.0);
|
||||
assert!(!overlap(a, &bar));
|
||||
for b in &placed[i + 1..] {
|
||||
assert!(!overlap(a, b), "{a:?} overlaps {b:?}");
|
||||
}
|
||||
}
|
||||
// Three rows' worth of room, and the right column came first.
|
||||
assert_eq!(right.iter().flatten().count(), 3);
|
||||
assert_eq!(left.iter().flatten().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_stack_up_from_a_bottom_corner_and_drop_above_its_quarter() {
|
||||
let view = [200.0, 100.0];
|
||||
let mut taken = vec![[0.0, 80.0, 60.0, 5.0]];
|
||||
let left = place_column_up(view, true, &[50.0, 50.0], &mut taken);
|
||||
assert_eq!(left[0], Some([2.0, 87.0, 50.0, 11.0]));
|
||||
// Over the panel at 80 would be 68, under the lower quarter's 75.
|
||||
assert_eq!(left[1], None);
|
||||
let right = place_column_up(view, false, &[40.0], &mut taken);
|
||||
assert_eq!(right[0], Some([158.0, 87.0, 40.0, 11.0]));
|
||||
}
|
||||
|
||||
fn short(rows: &[Row]) -> Vec<&str> {
|
||||
rows.iter()
|
||||
.map(|r| r.caption.rsplit('.').next().unwrap_or(""))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inventorys_face_buttons_go_right_and_the_rest_left() {
|
||||
let on = |under, carrying| {
|
||||
resolve_menu(&MenuFacts {
|
||||
under,
|
||||
carrying,
|
||||
..MenuFacts::default()
|
||||
})
|
||||
};
|
||||
let slot = |item, same, result| Under::Slot {
|
||||
item,
|
||||
same,
|
||||
result,
|
||||
accepts: true,
|
||||
};
|
||||
let close_only = ["close"];
|
||||
let empty: [&str; 0] = [];
|
||||
for quiet in [Under::Nothing, slot(false, false, false)] {
|
||||
let guide = on(quiet, false);
|
||||
assert_eq!(short(&guide.right), close_only);
|
||||
assert_eq!(short(&guide.left), empty);
|
||||
}
|
||||
let item = on(slot(true, false, false), false);
|
||||
assert_eq!(
|
||||
short(&item.right),
|
||||
["close", "take", "take_half", "quick_move"]
|
||||
);
|
||||
assert_eq!(short(&item.left), ["drop", "drop_stack"]);
|
||||
assert_eq!(
|
||||
short(&on(slot(false, false, false), true).right),
|
||||
["close", "put", "put_one"]
|
||||
);
|
||||
assert_eq!(
|
||||
short(&on(slot(true, false, false), true).right),
|
||||
["close", "swap_stack", "put_one"]
|
||||
);
|
||||
assert_eq!(
|
||||
short(&on(slot(true, true, false), true).right),
|
||||
["close", "put", "put_one"]
|
||||
);
|
||||
assert_eq!(
|
||||
short(&on(slot(true, false, true), false).right),
|
||||
["close", "take", "quick_move"]
|
||||
);
|
||||
let recipe = on(
|
||||
Under::Recipe {
|
||||
direct_craft: true,
|
||||
alternatives: true,
|
||||
},
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
short(&recipe.right),
|
||||
["close", "place_recipe", "place_recipe_all", "alternatives"]
|
||||
);
|
||||
assert_eq!(recipe.left.len(), 1);
|
||||
assert_eq!(recipe.left[0].with, Some(Button::LeftTrigger));
|
||||
assert_eq!(recipe.left[0].button, Button::South);
|
||||
let plain = on(
|
||||
Under::Recipe {
|
||||
direct_craft: false,
|
||||
alternatives: false,
|
||||
},
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
short(&plain.right),
|
||||
["close", "place_recipe", "place_recipe_all"]
|
||||
);
|
||||
assert!(plain.left.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inventorys_screen_rows_follow_its_book_and_tabs() {
|
||||
let left = |book, tabs| {
|
||||
resolve_menu(&MenuFacts {
|
||||
book,
|
||||
tabs,
|
||||
..MenuFacts::default()
|
||||
})
|
||||
.left
|
||||
};
|
||||
assert!(left(false, false).is_empty());
|
||||
assert_eq!(short(&left(true, false)), ["pages", "categories"]);
|
||||
assert_eq!(short(&left(false, true)), ["tabs"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_wider_than_the_view_is_dropped() {
|
||||
let mut taken = Vec::new();
|
||||
assert_eq!(
|
||||
place_column([50.0, 200.0], true, &[60.0, 20.0], &mut taken),
|
||||
[None, Some([2.0, 2.0, 20.0, 11.0])]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod detail_tests {
|
||||
use super::*;
|
||||
|
||||
fn rows() -> Guide {
|
||||
Guide {
|
||||
left: vec![Row::new(Button::South, "left")],
|
||||
right: vec![Row::new(Button::East, "right")],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_keeps_both_columns() {
|
||||
let guide = rows().at_detail(GuideDetail::Full);
|
||||
assert_eq!((guide.left.len(), guide.right.len()), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_keeps_the_right_column() {
|
||||
let guide = rows().at_detail(GuideDetail::Basic);
|
||||
assert_eq!((guide.left.len(), guide.right.len()), (0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_keeps_nothing() {
|
||||
let guide = rows().at_detail(GuideDetail::Off);
|
||||
assert_eq!((guide.left.len(), guide.right.len()), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_saved_with_the_old_switch_keeps_its_meaning() {
|
||||
assert_eq!(GuideDetail::parse("true"), GuideDetail::Full);
|
||||
assert_eq!(GuideDetail::parse("false"), GuideDetail::Off);
|
||||
assert_eq!(GuideDetail::parse("basic"), GuideDetail::Basic);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod slot_tests {
|
||||
use super::*;
|
||||
|
||||
/// The rows for the slot: South or West, whichever column they're in.
|
||||
fn rows(item: bool, same: bool, result: bool, accepts: bool, carrying: bool) -> usize {
|
||||
let guide = resolve_menu(&MenuFacts {
|
||||
under: Under::Slot {
|
||||
item,
|
||||
same,
|
||||
result,
|
||||
accepts,
|
||||
},
|
||||
carrying,
|
||||
..MenuFacts::default()
|
||||
});
|
||||
[guide.left, guide.right]
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|row| matches!(row.button, Button::South | Button::West))
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slot_that_refuses_the_carried_stack_offers_nothing() {
|
||||
assert_eq!(rows(false, false, false, false, true), 0);
|
||||
assert_eq!(rows(true, true, false, false, true), 0);
|
||||
assert!(rows(false, false, false, true, true) > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_result_is_taken_only_into_empty_hands_or_the_same_stack() {
|
||||
assert!(rows(true, false, true, true, false) > 0);
|
||||
assert!(rows(true, true, true, true, true) > 0);
|
||||
assert_eq!(rows(true, false, true, true, true), 0);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
pub mod actions;
|
||||
pub mod clipboard;
|
||||
pub mod gamepad;
|
||||
pub mod gameplay_guide;
|
||||
pub mod held;
|
||||
pub mod input;
|
||||
pub mod interaction_input;
|
||||
|
||||
@@ -42,7 +42,8 @@ horizon-chat-filter = { path = "../horizon-chat-filter" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-friends = { path = "../horizon-friends" }
|
||||
# Play with Friends: invite codes and their tunnel; the `friends` feature.
|
||||
horizon-friends = { path = "../horizon-friends", optional = true }
|
||||
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
|
||||
horizon-narrator = { path = "../horizon-narrator" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
@@ -73,8 +74,8 @@ mio = { version = "1.2.4", features = ["os-poll", "net"] }
|
||||
xxhash-rust = { version = "0.8.19", features = ["xxh3"] }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
|
||||
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
|
||||
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`), the `heartbeat` feature.
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
crc32fast = "1"
|
||||
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
|
||||
@@ -113,12 +114,18 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers"]
|
||||
default = ["audio", "controllers", "discord", "friends", "heartbeat"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["horizon-client-render/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
|
||||
discord = []
|
||||
# Play with Friends, joining side (`net.rs`): without it no invite code joins.
|
||||
friends = ["dep:horizon-friends"]
|
||||
# The anonymous "I'm playing" ping (`heartbeat.rs`, docs/telemetry.md).
|
||||
heartbeat = ["horizon-telemetry/live"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
# Compatibility with earlier experiment commands: main already uses mimalloc.
|
||||
|
||||
@@ -26,7 +26,7 @@ horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
|
||||
@@ -2071,6 +2071,11 @@ impl ChatScreen {
|
||||
for i in 0..limit {
|
||||
let y = ry + 12 * i as i32;
|
||||
d::fill(out, rx, y, rx + rw, y + 12, self.layout.fill);
|
||||
// A row for the pad: nothing else can pick a suggestion without Tab.
|
||||
crate::widgets::target_as(
|
||||
[rx as f32, y as f32, rw as f32, 12.0],
|
||||
crate::widgets::Kind::Row,
|
||||
);
|
||||
if mouse.0 > rx && mouse.0 < rx + rw && mouse.1 > y && mouse.1 < y + 12 {
|
||||
if moved {
|
||||
self.select(l, (i + l.offset) as isize);
|
||||
|
||||
@@ -65,6 +65,44 @@ pub struct Setting {
|
||||
pub item: Item,
|
||||
pub text: &'static str,
|
||||
}
|
||||
/// What the chosen value does, for a setting whose values each do something different.
|
||||
fn choice_description(opt: Opt, pad: &PadSettings) -> Option<&'static str> {
|
||||
Some(match opt {
|
||||
Opt::PadGameplayGuide => match pad.guide_detail {
|
||||
gamepad::GuideDetail::Full => {
|
||||
"Shows what your buttons do right now, in play and in inventories."
|
||||
}
|
||||
gamepad::GuideDetail::Basic => {
|
||||
"Shows only what you can do with what you're looking at, and the main inventory buttons."
|
||||
}
|
||||
gamepad::GuideDetail::Off => "Doesn't show what your buttons do.",
|
||||
},
|
||||
Opt::PadGlyphs => match pad.glyphs {
|
||||
GlyphStyle::Auto => "Shows the buttons of the controller you're using.",
|
||||
GlyphStyle::Xbox => "Always shows Xbox buttons.",
|
||||
GlyphStyle::PlayStation => "Always shows PlayStation buttons.",
|
||||
GlyphStyle::Nintendo => "Always shows Nintendo buttons.",
|
||||
},
|
||||
Opt::PadKeyboard => match pad.keyboard {
|
||||
gamepad::TextKeyboard::Auto => {
|
||||
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise."
|
||||
}
|
||||
gamepad::TextKeyboard::Horizon => "Always opens Horizon's keyboard.",
|
||||
gamepad::TextKeyboard::Steam => "Always opens Steam's keyboard.",
|
||||
},
|
||||
Opt::PadLightbar => match pad.lightbar {
|
||||
gamepad::Lightbar::Health => {
|
||||
"A DualSense's light shows your health, from green through amber to red."
|
||||
}
|
||||
gamepad::Lightbar::Dimension => {
|
||||
"A DualSense's light changes with the dimension you're in."
|
||||
}
|
||||
gamepad::Lightbar::Off => "A DualSense keeps its own light.",
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn option(opt: Opt, text: &'static str) -> Setting {
|
||||
Setting {
|
||||
item: Item::Option(opt),
|
||||
@@ -97,10 +135,16 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
vec![
|
||||
(
|
||||
"Controller",
|
||||
vec![option(
|
||||
Opt::PadEnabled,
|
||||
"Off, the game ignores every controller for this player.",
|
||||
)],
|
||||
vec![
|
||||
option(
|
||||
Opt::PadEnabled,
|
||||
"Off, the game ignores every controller for this player.",
|
||||
),
|
||||
option(
|
||||
Opt::PadPlayInBackground,
|
||||
"The controller keeps playing while this window isn't focused, and losing focus won't pause.",
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Look",
|
||||
@@ -135,10 +179,16 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
),
|
||||
(
|
||||
"Play",
|
||||
vec![option(
|
||||
Opt::PadTriggerThreshold,
|
||||
"How far a trigger is pulled before it counts as pressed.",
|
||||
)],
|
||||
vec![
|
||||
option(
|
||||
Opt::PadGameplayGuide,
|
||||
"Show the buttons for what you can do, while you play with a controller.",
|
||||
),
|
||||
option(
|
||||
Opt::PadTriggerThreshold,
|
||||
"How far a trigger is pulled before it counts as pressed.",
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Menus",
|
||||
@@ -279,6 +329,7 @@ impl Item {
|
||||
match self {
|
||||
Item::Option(Opt::PadEnabled) => Some(c.enabled),
|
||||
Item::Option(Opt::PadVibration) => Some(c.vibration),
|
||||
Item::Option(Opt::PadPlayInBackground) => Some(c.play_in_background),
|
||||
Item::Option(Opt::PadAdaptiveTriggers) => Some(c.adaptive_triggers),
|
||||
Item::Option(Opt::PadInvertX) => Some(c.invert_x),
|
||||
Item::Option(Opt::PadInvertY) => Some(c.invert_y),
|
||||
@@ -604,6 +655,7 @@ impl ControllerUi {
|
||||
hover,
|
||||
focused,
|
||||
held: self.dragging == Some(item),
|
||||
key: None,
|
||||
};
|
||||
shell::row(batch, atlas, &cols, y, &view);
|
||||
}
|
||||
@@ -818,8 +870,12 @@ impl ControllerUi {
|
||||
/// What a row does. A binding names the controller's own buttons, and what else its button
|
||||
/// does when it shares it.
|
||||
fn description(&self, setting: Setting, s: &Settings) -> String {
|
||||
let Item::Bind(action) = setting.item else {
|
||||
return lang::get(setting.text);
|
||||
let action = match setting.item {
|
||||
Item::Bind(action) => action,
|
||||
Item::Option(opt) => {
|
||||
return lang::get(choice_description(opt, &s.controller).unwrap_or(setting.text));
|
||||
}
|
||||
_ => return lang::get(setting.text),
|
||||
};
|
||||
let pad = &s.controller;
|
||||
let style = self.context.style;
|
||||
@@ -1265,6 +1321,7 @@ mod tests {
|
||||
for opt in [
|
||||
Opt::PadEnabled,
|
||||
Opt::PadVibration,
|
||||
Opt::PadPlayInBackground,
|
||||
Opt::PadAdaptiveTriggers,
|
||||
Opt::PadLightbar,
|
||||
Opt::PadLookSensitivity,
|
||||
@@ -1372,6 +1429,30 @@ mod tests {
|
||||
assert!((s.controller.move_deadzone - before - 0.01).abs() < 0.001);
|
||||
}
|
||||
|
||||
/// What the screen draws at the bottom of the page changes with the choice, in every one of
|
||||
/// these settings.
|
||||
#[test]
|
||||
fn each_choice_describes_itself() {
|
||||
let ui = ControllerUi::default();
|
||||
let mut s = Settings::default();
|
||||
for (opt, choices) in [
|
||||
(Opt::PadGameplayGuide, 3),
|
||||
(Opt::PadGlyphs, 4),
|
||||
(Opt::PadKeyboard, 3),
|
||||
(Opt::PadLightbar, 3),
|
||||
] {
|
||||
let setting = option(opt, "the fixed sentence");
|
||||
let mut seen = Vec::new();
|
||||
for _ in 0..choices {
|
||||
let text = ui.description(setting, &s);
|
||||
assert_ne!(text, "the fixed sentence", "{opt:?}");
|
||||
assert!(!seen.contains(&text), "{opt:?} repeats {text}");
|
||||
seen.push(text);
|
||||
opt.cycle(&mut s, false, FB);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_text_is_in_the_language_table() {
|
||||
let (_, missed) = lang::scoped(lang::Language::load(&[], "de_de"), || {
|
||||
@@ -1385,6 +1466,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut settings = Settings::default();
|
||||
for opt in [
|
||||
Opt::PadGameplayGuide,
|
||||
Opt::PadGlyphs,
|
||||
Opt::PadKeyboard,
|
||||
Opt::PadLightbar,
|
||||
] {
|
||||
for _ in 0..4 {
|
||||
lang::get(choice_description(opt, &settings.controller).unwrap());
|
||||
opt.cycle(&mut settings, false, FB);
|
||||
}
|
||||
}
|
||||
for text in [
|
||||
"Controller",
|
||||
"Player profile",
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
//! The Horizon Extras page, on the settings shell (`settings_shell`): which rows it has and what
|
||||
//! pressing them does. Each one's value and what it does stay in `extras::Item`.
|
||||
use crate::{
|
||||
extras::{self, Item},
|
||||
lang,
|
||||
options_ui::{Event, Page},
|
||||
settings::Settings,
|
||||
settings_shell::{Group, Output, Row, Screen, Sheet, Value},
|
||||
};
|
||||
|
||||
const TITLE: &str = "horizon.pages.extras";
|
||||
const ABOUT: &str = "horizon.pages.extras.tooltip";
|
||||
/// The group of the mod that replaced an extra.
|
||||
const MODS: &str = "horizon.pages.mods";
|
||||
const ZOOM: &str = "horizon.extras.zoom";
|
||||
const ZOOM_INSTALL: &str = "horizon.extras.zoom_mod_install";
|
||||
const MOD_SETTINGS: &str = "horizon.mods.settings";
|
||||
/// Its descriptions run long, as tooltips they were: most need more than two lines.
|
||||
const NOTE_LINES: usize = 5;
|
||||
/// The official native mod that took over from the Zoom extra (#8713).
|
||||
const ZOOM_ID: &str = "zoom";
|
||||
|
||||
/// A row of the page.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ExtraRow {
|
||||
Item(Item),
|
||||
/// The Zoom mod, at this place among the installed ones, on or off.
|
||||
Zoom(usize),
|
||||
/// Its own settings page.
|
||||
ZoomSettings(usize),
|
||||
/// It isn't installed: where to get it. A note, not a button: Options > Mods already has the
|
||||
/// one way into the Mods page (`every_setting_and_page_is_reached_from_one_place`).
|
||||
ZoomInstall,
|
||||
}
|
||||
|
||||
fn item_row(item: Item, s: &Settings) -> Row<ExtraRow> {
|
||||
let e = &s.extras;
|
||||
let value = match (item.key_name(), item.switch(e)) {
|
||||
(Some(key), _) => Value::Key(key, item.value(e)),
|
||||
(None, Some(on)) => Value::Switch(on),
|
||||
(None, None) => Value::Choice(item.value(e)),
|
||||
};
|
||||
// A setting of a switch that is off does nothing, so it is greyed out.
|
||||
let live = item
|
||||
.parent()
|
||||
.is_none_or(|parent| parent.switch(e) != Some(false));
|
||||
Row::new(ExtraRow::Item(item), item.caption_text(), value)
|
||||
.about(item.description_text())
|
||||
.live(live)
|
||||
}
|
||||
|
||||
/// Zoom, the FPS counter, Keystrokes and the Minimap are official mods now; Extras keeps a way to
|
||||
/// the one players look for here.
|
||||
fn zoom_rows(s: &Settings) -> Vec<Row<ExtraRow>> {
|
||||
let installed = &s.mods.installed;
|
||||
let found = installed
|
||||
.iter()
|
||||
.position(|m| m.id == ZOOM_ID && m.native.is_some());
|
||||
let Some(i) = found else {
|
||||
let note = Row::new(ExtraRow::ZoomInstall, lang::get(ZOOM_INSTALL), Value::Note);
|
||||
return vec![note.live(false)];
|
||||
};
|
||||
let about = installed[i].problem.as_ref();
|
||||
let about = about.unwrap_or(&installed[i].description).clone();
|
||||
let on = Value::Switch(s.mods.is_on(i));
|
||||
let mut rows = vec![Row::new(ExtraRow::Zoom(i), lang::get(ZOOM), on).about(about)];
|
||||
let page = installed[i].native.as_ref();
|
||||
if page.is_some_and(|page| !page.keys.is_empty() || !page.buttons.is_empty()) {
|
||||
let name = lang::get(MOD_SETTINGS);
|
||||
rows.push(Row::new(ExtraRow::ZoomSettings(i), name, Value::Press));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// The page itself holds nothing: everything it shows is in the settings.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ExtrasScreen;
|
||||
|
||||
impl Screen for ExtrasScreen {
|
||||
type Page = ();
|
||||
type Row = ExtraRow;
|
||||
fn title(&self) -> &'static str {
|
||||
TITLE
|
||||
}
|
||||
fn note_lines(&self) -> usize {
|
||||
NOTE_LINES
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
|
||||
let mut groups = vec![Group {
|
||||
name: MODS,
|
||||
rows: zoom_rows(s),
|
||||
}];
|
||||
for row in extras::rows() {
|
||||
match row {
|
||||
Err(name) => groups.push(Group {
|
||||
name,
|
||||
rows: Vec::new(),
|
||||
}),
|
||||
Ok(item) => {
|
||||
if let Some(group) = groups.last_mut() {
|
||||
group.rows.push(item_row(item, s));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
vec![Sheet {
|
||||
id: (),
|
||||
name: lang::get(TITLE),
|
||||
about: lang::get(ABOUT),
|
||||
groups,
|
||||
}]
|
||||
}
|
||||
fn press(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Vec<Output> {
|
||||
match row {
|
||||
ExtraRow::Item(item) => match item.key_name() {
|
||||
Some(key) => vec![Output::BindKey(key)],
|
||||
None => {
|
||||
item.cycle(&mut s.extras, back);
|
||||
vec![Output::Event(Event::Changed)]
|
||||
}
|
||||
},
|
||||
ExtraRow::Zoom(i) => {
|
||||
s.mods.toggle(i);
|
||||
vec![Output::Event(Event::Changed)]
|
||||
}
|
||||
ExtraRow::ZoomSettings(i) => vec![
|
||||
Output::Event(Event::ButtonClick),
|
||||
Output::Push(Page::NativeMod(i)),
|
||||
Output::Event(Event::NativeSettings(i)),
|
||||
],
|
||||
ExtraRow::ZoomInstall => Vec::new(),
|
||||
}
|
||||
}
|
||||
/// With the wheel extra on, the wheel over an item changes it, without a click; the mod's rows
|
||||
/// let the list scroll.
|
||||
fn wheel_step(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Option<Vec<Output>> {
|
||||
let ExtraRow::Item(item) = row else {
|
||||
return None;
|
||||
};
|
||||
item.cycle(&mut s.extras, back);
|
||||
Some(vec![Output::Event(Event::Changed)])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::settings_shell::{Hosted, Shell};
|
||||
|
||||
const SIZE: (f32, f32) = (854.0, 480.0);
|
||||
|
||||
fn rows_of(s: &Settings) -> Vec<Row<ExtraRow>> {
|
||||
let sheets = ExtrasScreen.sheets(s);
|
||||
let groups = sheets.into_iter().flat_map(|sheet| sheet.groups);
|
||||
groups.flat_map(|group| group.rows).collect()
|
||||
}
|
||||
|
||||
/// Greyed exactly while its own switch is off, so it takes no press then.
|
||||
#[test]
|
||||
fn a_setting_of_a_switch_that_is_off_is_greyed_and_takes_no_press() {
|
||||
let mut s = Settings::default();
|
||||
let live = |s: &Settings| {
|
||||
let wheel = ExtraRow::Item(Item::MouseTweaksWheel);
|
||||
rows_of(s).iter().find(|r| r.id == wheel).unwrap().live
|
||||
};
|
||||
s.extras.mouse_tweaks = false;
|
||||
assert!(!live(&s));
|
||||
let mut shell = Shell::<ExtrasScreen>::default();
|
||||
let row = ExtraRow::Item(Item::MouseTweaksWheel);
|
||||
let at = shell.point_at(&s, SIZE, row);
|
||||
let before = s.extras.mouse_tweaks_wheel;
|
||||
assert!(
|
||||
shell
|
||||
.click(&mut s, at, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(s.extras.mouse_tweaks_wheel, before);
|
||||
s.extras.mouse_tweaks = true;
|
||||
assert!(live(&s));
|
||||
assert!(
|
||||
!shell
|
||||
.click(&mut s, at, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
assert_ne!(s.extras.mouse_tweaks_wheel, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_three_way_cosmetics_row_turns_both_ways() {
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
let mut s = Settings::default();
|
||||
let mut screen = ExtrasScreen;
|
||||
let row = ExtraRow::Item(Item::CosmeticsOthers);
|
||||
// Not chosen, nothing owned: it shows Hidden, and forward wraps to Shown.
|
||||
assert_eq!(s.extras.effective_others(), Others::Hidden);
|
||||
screen.press(&mut s, row, false);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::Shown));
|
||||
screen.press(&mut s, row, true);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::Hidden));
|
||||
screen.press(&mut s, row, true);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::NameTags));
|
||||
}
|
||||
|
||||
/// The sort key binds instead of cycling; the wheel leaves it be.
|
||||
#[test]
|
||||
fn the_key_row_waits_for_a_key() {
|
||||
let mut s = Settings::default();
|
||||
let mut screen = ExtrasScreen;
|
||||
let row = ExtraRow::Item(Item::SortKey);
|
||||
let key = s.extras.sort_key.clone();
|
||||
assert_eq!(
|
||||
screen.press(&mut s, row, false),
|
||||
vec![Output::BindKey(extras::SORT_KEY)]
|
||||
);
|
||||
screen.wheel_step(&mut s, row, true);
|
||||
assert_eq!(s.extras.sort_key, key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wheel_flips_a_switch_and_leaves_the_mod_to_scroll() {
|
||||
let mut s = Settings::default();
|
||||
let mut screen = ExtrasScreen;
|
||||
let row = ExtraRow::Item(Item::Sort);
|
||||
assert_eq!(
|
||||
screen.wheel_step(&mut s, row, true),
|
||||
Some(vec![Output::Event(Event::Changed)])
|
||||
);
|
||||
assert!(s.extras.sort);
|
||||
assert_eq!(screen.wheel_step(&mut s, ExtraRow::Zoom(0), true), None);
|
||||
}
|
||||
}
|
||||
@@ -6669,6 +6669,7 @@ impl Renderer {
|
||||
post,
|
||||
self.frame_format,
|
||||
world,
|
||||
&std::sync::atomic::AtomicBool::new(false),
|
||||
)
|
||||
});
|
||||
self.pack_loading = None;
|
||||
@@ -6785,6 +6786,8 @@ impl Renderer {
|
||||
.map(|f| f.draw_layout.clone());
|
||||
let terrain = self.pack_atlas.is_some();
|
||||
let clouds = self.cloud_bindings.clone();
|
||||
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let cancelled = cancel.clone();
|
||||
let build = move || {
|
||||
let instance = wgpu::vertex_attr_array![5=>Float32x4];
|
||||
let vertex = wgpu::VertexBufferLayout {
|
||||
@@ -6819,7 +6822,7 @@ impl Renderer {
|
||||
weather: true,
|
||||
hand: true,
|
||||
};
|
||||
pack_post::PackPost::new(&device, &queue, &adapter, pack, format, world)
|
||||
pack_post::PackPost::new(&device, &queue, &adapter, pack, format, world, &cancelled)
|
||||
};
|
||||
// A pack is not the game's code: a panic building it turns the pack off (#3525).
|
||||
let thread = std::thread::Builder::new()
|
||||
@@ -6834,9 +6837,15 @@ impl Renderer {
|
||||
thread,
|
||||
label,
|
||||
started,
|
||||
_cancel: CancelOnDrop(cancel),
|
||||
});
|
||||
}
|
||||
Err(e) => log::error!("shader pack {label}: no thread to build it on: {e}"),
|
||||
Err(e) => {
|
||||
log::error!("shader pack {label}: no thread to build it on: {e}");
|
||||
// Off for this dimension, as a pack that fails, not tried again every frame.
|
||||
self.pack_loading = None;
|
||||
self.pack_post = Some((dimension, None));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The pack `load_shader_pack` builds, once its thread is done: it runs from now on, as
|
||||
@@ -14563,8 +14572,12 @@ mod shadow_caster_tests {
|
||||
|
||||
/// A `shaders/` pack building on its own thread (`Renderer::load_shader_pack`): the dimension it
|
||||
/// is for, the thread (a panic in it contained), and what the game named it with and when.
|
||||
/// Dropping it (another pack, none, or another dimension) cancels the build: the thread stops at
|
||||
/// the next program instead of compiling the rest for nothing.
|
||||
struct PackLoading {
|
||||
dimension: i32,
|
||||
/// Held to be dropped with it: that cancels the build.
|
||||
_cancel: CancelOnDrop,
|
||||
#[allow(clippy::type_complexity)]
|
||||
thread: std::thread::JoinHandle<
|
||||
std::result::Result<std::result::Result<pack_post::PackPost, pack_post::Refused>, String>,
|
||||
@@ -14572,3 +14585,12 @@ struct PackLoading {
|
||||
label: String,
|
||||
started: std::time::Instant,
|
||||
}
|
||||
|
||||
/// Sets its flag when dropped: `PackLoading`'s cancel, which `PackPost::new` checks per program.
|
||||
struct CancelOnDrop(std::sync::Arc<std::sync::atomic::AtomicBool>);
|
||||
|
||||
impl Drop for CancelOnDrop {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,7 @@ void main() {
|
||||
post,
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
world,
|
||||
&Default::default(),
|
||||
)
|
||||
.expect("the pack runs");
|
||||
let mut pass = post
|
||||
|
||||
@@ -381,12 +381,13 @@ impl Gpu {
|
||||
}
|
||||
}
|
||||
|
||||
/// A pass's pipeline: a draw, or a compute dispatch.
|
||||
/// A pass's pipeline: a draw, or a compute dispatch. Its slot is filled when the pack's
|
||||
/// pipelines are compiled (`compile`), all of them at once once the pack is laid out.
|
||||
#[derive(Clone)]
|
||||
enum Pipeline {
|
||||
Render(wgpu::RenderPipeline),
|
||||
Render(Slot<wgpu::RenderPipeline>),
|
||||
Compute {
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
pipeline: Slot<wgpu::ComputePipeline>,
|
||||
/// `workGroups`, else `workGroupsRender` and the local size.
|
||||
groups: Option<[u32; 3]>,
|
||||
render: [f32; 2],
|
||||
@@ -401,7 +402,7 @@ struct Pass {
|
||||
pipeline: Pipeline,
|
||||
/// The same program as vanilla blends the sky's parts (`PostProgram::vanilla_sky_blends`):
|
||||
/// without blending for the disc and the void, then with `STARS_BLEND` for the stars.
|
||||
sky_parts: Option<[wgpu::RenderPipeline; 2]>,
|
||||
sky_parts: Option<[Slot<wgpu::RenderPipeline>; 2]>,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
ubo: wgpu::Buffer,
|
||||
block: Vec<u8>,
|
||||
@@ -472,7 +473,7 @@ impl Pass {
|
||||
|
||||
fn render(&self) -> &wgpu::RenderPipeline {
|
||||
match &self.pipeline {
|
||||
Pipeline::Render(p) => p,
|
||||
Pipeline::Render(p) => p.get().expect("compiled with its pack"),
|
||||
Pipeline::Compute { .. } => unreachable!("a compute pass draws nothing"),
|
||||
}
|
||||
}
|
||||
@@ -515,6 +516,7 @@ struct Targets {
|
||||
}
|
||||
|
||||
/// What a pack's gbuffers programs read and draw from the game's world.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct World<'a> {
|
||||
/// The block atlas and its size.
|
||||
pub atlas: Option<(wgpu::TextureView, [u32; 2])>,
|
||||
@@ -1243,6 +1245,7 @@ fn block_ids(
|
||||
|
||||
/// The game's geometry a gbuffers program draws: its vertex buffers and, for flat draws
|
||||
/// (flat.rs), the layout of their bind group 1.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Geometry<'a> {
|
||||
pub buffers: &'a [Option<wgpu::VertexBufferLayout<'a>>],
|
||||
pub table: Option<&'a wgpu::BindGroupLayout>,
|
||||
@@ -1286,6 +1289,177 @@ struct Resources<'a> {
|
||||
/// Bit n: shadowtex n read through a plain sampler filters linearly (`shadowtexNNearest`
|
||||
/// unset): the translator's helpers blend a gather of it (`hz_depth_linear`).
|
||||
shadow_linear: u8,
|
||||
/// While the pack is laid out for `compile`: the pipelines its programs need. `None`
|
||||
/// compiles each as its program is built.
|
||||
jobs: Option<std::cell::RefCell<Vec<Job>>>,
|
||||
}
|
||||
|
||||
impl Resources<'_> {
|
||||
/// The slot of the render pipeline `job` makes: filled now, or by `compile` when the pack
|
||||
/// is laid out.
|
||||
fn render_pipeline(&self, device: &wgpu::Device, job: RenderJob) -> Slot<wgpu::RenderPipeline> {
|
||||
let slot = Slot::default();
|
||||
let job = Job::Render(slot.clone(), job);
|
||||
match &self.jobs {
|
||||
Some(jobs) => jobs.borrow_mut().push(job),
|
||||
None => job.create(device),
|
||||
}
|
||||
slot
|
||||
}
|
||||
/// As `render_pipeline`, for a compute pipeline.
|
||||
fn compute_pipeline(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
job: ComputeJob,
|
||||
) -> Slot<wgpu::ComputePipeline> {
|
||||
let slot = Slot::default();
|
||||
let job = Job::Compute(slot.clone(), job);
|
||||
match &self.jobs {
|
||||
Some(jobs) => jobs.borrow_mut().push(job),
|
||||
None => job.create(device),
|
||||
}
|
||||
slot
|
||||
}
|
||||
}
|
||||
|
||||
/// A pipeline, made once: `Pipeline`'s, filled by its `Job`.
|
||||
type Slot<T> = std::sync::Arc<std::sync::OnceLock<T>>;
|
||||
|
||||
/// What a render pipeline of a pack's program is made of, kept to make it later.
|
||||
#[derive(Clone)]
|
||||
struct RenderJob {
|
||||
label: String,
|
||||
layout: wgpu::PipelineLayout,
|
||||
vs: wgpu::ShaderModule,
|
||||
fs: wgpu::ShaderModule,
|
||||
/// The vertex buffers: stride, step mode and attributes.
|
||||
buffers: Vec<Option<(u64, wgpu::VertexStepMode, Vec<wgpu::VertexAttribute>)>>,
|
||||
primitive: wgpu::PrimitiveState,
|
||||
depth_stencil: Option<wgpu::DepthStencilState>,
|
||||
targets: Vec<Option<wgpu::ColorTargetState>>,
|
||||
}
|
||||
|
||||
/// What a compute pipeline of a pack's program is made of.
|
||||
struct ComputeJob {
|
||||
label: String,
|
||||
layout: wgpu::PipelineLayout,
|
||||
module: wgpu::ShaderModule,
|
||||
}
|
||||
|
||||
/// A pipeline to make, and the slot it goes in.
|
||||
enum Job {
|
||||
Render(Slot<wgpu::RenderPipeline>, RenderJob),
|
||||
Compute(Slot<wgpu::ComputePipeline>, ComputeJob),
|
||||
}
|
||||
|
||||
impl Job {
|
||||
/// Makes the pipeline (the driver compiles its shaders here) into its slot.
|
||||
fn create(&self, device: &wgpu::Device) {
|
||||
match self {
|
||||
Job::Render(slot, j) => {
|
||||
let buffers: Vec<Option<wgpu::VertexBufferLayout>> = j
|
||||
.buffers
|
||||
.iter()
|
||||
.map(|b| {
|
||||
b.as_ref()
|
||||
.map(|(stride, step, attributes)| wgpu::VertexBufferLayout {
|
||||
array_stride: *stride,
|
||||
step_mode: *step,
|
||||
attributes,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&j.label),
|
||||
layout: Some(&j.layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &j.vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &buffers,
|
||||
},
|
||||
primitive: j.primitive,
|
||||
depth_stencil: j.depth_stencil.clone(),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &j.fs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &j.targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let _ = slot.set(pipeline);
|
||||
}
|
||||
Job::Compute(slot, j) => {
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some(&j.label),
|
||||
layout: Some(&j.layout),
|
||||
module: &j.module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let _ = slot.set(pipeline);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn label(&self) -> &str {
|
||||
match self {
|
||||
Job::Render(_, j) => &j.label,
|
||||
Job::Compute(_, j) => &j.label,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes the pipelines of `jobs` on as many threads as the machine has cores (up to 8): the
|
||||
/// driver compiles a big pack's programs for tens of seconds one at a time, on one core. The
|
||||
/// first one wgpu or the driver refuses, with why; `None` when all are made.
|
||||
///
|
||||
/// A refusal doesn't stop the others: every job is made, so that the serial rebuild `new` then
|
||||
/// runs finds the driver's shader cache warm instead of compiling the rest cold, one at a time.
|
||||
/// `cancel` set stops handing out jobs: the caller then drops the pack (`PackPost::new`).
|
||||
fn compile(
|
||||
device: &wgpu::Device,
|
||||
jobs: &[Job],
|
||||
cancel: &std::sync::atomic::AtomicBool,
|
||||
) -> Option<(String, String)> {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let threads = std::thread::available_parallelism()
|
||||
.map_or(1, |n| n.get())
|
||||
.clamp(1, 8)
|
||||
.min(jobs.len().max(1));
|
||||
let next = AtomicUsize::new(0);
|
||||
let refused = std::sync::Mutex::new(None);
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..threads {
|
||||
scope.spawn(|| {
|
||||
while !cancel.load(Ordering::Relaxed)
|
||||
&& let Some(job) = jobs.get(next.fetch_add(1, Ordering::Relaxed))
|
||||
{
|
||||
if let Err(e) = checked(device, || job.create(device)) {
|
||||
let mut refused = refused.lock().unwrap_or_else(|e| e.into_inner());
|
||||
refused.get_or_insert((job.label().to_owned(), e));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
refused.into_inner().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Vertex buffer layouts owned, for a `RenderJob`.
|
||||
fn owned_buffers(
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
) -> Vec<Option<(u64, wgpu::VertexStepMode, Vec<wgpu::VertexAttribute>)>> {
|
||||
buffers
|
||||
.iter()
|
||||
.map(|b| {
|
||||
b.as_ref()
|
||||
.map(|b| (b.array_stride, b.step_mode, b.attributes.to_vec()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A program's bind group layout (its uniform block, samplers and images) and how each sampler
|
||||
@@ -1894,60 +2068,47 @@ fn build_pass(
|
||||
packed::quantise_outputs(&mut fragment, &packed_outputs);
|
||||
packed::bitcast_outputs(&mut fragment, &int_outputs);
|
||||
let (vs, fs) = (module(program.vertex), module(fragment));
|
||||
let descriptor = |targets: &[Option<wgpu::ColorTargetState>]| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&p.name),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
// A geometry stage reads its vertices itself.
|
||||
buffers: geometry
|
||||
.filter(|_| p.geometry_triangles == 0)
|
||||
.map_or(&[], |g| g.buffers),
|
||||
let job = RenderJob {
|
||||
label: p.name.clone(),
|
||||
layout: pipeline_layout,
|
||||
vs,
|
||||
fs,
|
||||
// A geometry stage reads its vertices itself.
|
||||
buffers: geometry
|
||||
.filter(|_| p.geometry_triangles == 0)
|
||||
.map_or_else(Vec::new, |g| owned_buffers(g.buffers)),
|
||||
// Rows flipped to GL's turn front faces clockwise.
|
||||
primitive: wgpu::PrimitiveState {
|
||||
front_face: wgpu::FrontFace::Cw,
|
||||
cull_mode: geometry
|
||||
.filter(|g| g.cull && p.geometry_triangles == 0)
|
||||
.map(|_| wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: geometry.map(|g| wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(g.depth_write),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: if p.name == "gbuffers_damagedblock" {
|
||||
wgpu::DepthBiasState {
|
||||
constant: -10,
|
||||
slope_scale: -1.0,
|
||||
clamp: 0.0,
|
||||
}
|
||||
} else {
|
||||
Default::default()
|
||||
},
|
||||
// Rows flipped to GL's turn front faces clockwise.
|
||||
primitive: wgpu::PrimitiveState {
|
||||
front_face: wgpu::FrontFace::Cw,
|
||||
cull_mode: geometry
|
||||
.filter(|g| g.cull && p.geometry_triangles == 0)
|
||||
.map(|_| wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: geometry.map(|g| wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(g.depth_write),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: if p.name == "gbuffers_damagedblock" {
|
||||
wgpu::DepthBiasState {
|
||||
constant: -10,
|
||||
slope_scale: -1.0,
|
||||
clamp: 0.0,
|
||||
}
|
||||
} else {
|
||||
Default::default()
|
||||
},
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}),
|
||||
targets: targets.to_vec(),
|
||||
};
|
||||
let pipeline = descriptor(&targets);
|
||||
// The program's own blend replaced by vanilla's for the part; a buffer's own
|
||||
// `blend.<program>.<buffer>` still applies (Iris 1.11.7 blended the sky disc into that
|
||||
// buffer only, with a probe pack of ours).
|
||||
// buffer only, with a probe pack of ours). Two more pipelines of the program, made
|
||||
// with the others (`compile`).
|
||||
let sky_parts = p.vanilla_sky_blends.then(|| {
|
||||
[None, Some(blend_state(horizon_shaders::STARS_BLEND))].map(|part| {
|
||||
let targets: Vec<_> = (targets.iter().enumerate())
|
||||
let targets: Vec<_> = (job.targets.iter().enumerate())
|
||||
.map(|(l, t)| {
|
||||
let (own, blendable) = own_blends.get(l).copied().unwrap_or((None, false));
|
||||
t.clone().map(|t| wgpu::ColorTargetState {
|
||||
@@ -1956,10 +2117,16 @@ fn build_pass(
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
descriptor(&targets)
|
||||
pack.render_pipeline(
|
||||
device,
|
||||
RenderJob {
|
||||
targets,
|
||||
..job.clone()
|
||||
},
|
||||
)
|
||||
})
|
||||
});
|
||||
(layout, pipeline, sky_parts)
|
||||
(layout, pack.render_pipeline(device, job), sky_parts)
|
||||
});
|
||||
let (layout, pipeline, sky_parts) = match created {
|
||||
Ok(created) => created,
|
||||
@@ -2099,15 +2266,12 @@ fn build_compute(
|
||||
label: Some(&name),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some(&name),
|
||||
layout: Some(&pipeline_layout),
|
||||
module: &module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
(layout, pipeline)
|
||||
let job = ComputeJob {
|
||||
label: name.clone(),
|
||||
layout: pipeline_layout,
|
||||
module,
|
||||
};
|
||||
(layout, pack.compute_pipeline(device, job))
|
||||
});
|
||||
let (layout, pipeline) = match created {
|
||||
Ok(created) => created,
|
||||
@@ -2176,17 +2340,73 @@ pub struct Refused {
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl Refused {
|
||||
/// The reason of a build stopped by its `cancel` flag (`PackPost::new`).
|
||||
const CANCELLED: &str = "cancelled";
|
||||
fn cancelled(notes: Vec<String>) -> Self {
|
||||
Self {
|
||||
reason: Self::CANCELLED.into(),
|
||||
notes,
|
||||
}
|
||||
}
|
||||
/// Whether the build was cancelled (another pack or dimension asked for): not the pack's
|
||||
/// fault, so nothing to report to the player.
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.reason == Self::CANCELLED
|
||||
}
|
||||
}
|
||||
|
||||
impl PackPost {
|
||||
/// Builds the pipelines of `post` for a screen of `screen` format. An error when the pack
|
||||
/// can't show anything on the screen: the game turns it off, as a pack that fails to load.
|
||||
///
|
||||
/// The pack is laid out first and its pipelines are made after, all at once (`compile`).
|
||||
/// When wgpu or the driver refuses one, the pack is built again a program at a time, so that
|
||||
/// the program is skipped with its note as it always was.
|
||||
///
|
||||
/// `cancel` set (the pack isn't wanted any more) stops the build between programs: a refusal
|
||||
/// whose `is_cancelled` is true.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
adapter: &wgpu::Adapter,
|
||||
post: Post,
|
||||
screen: wgpu::TextureFormat,
|
||||
world: World<'_>,
|
||||
cancel: &std::sync::atomic::AtomicBool,
|
||||
) -> Result<Self, Refused> {
|
||||
use std::sync::atomic::Ordering;
|
||||
let spare = (post.clone(), world.clone());
|
||||
let (built, jobs) = Self::build(device, queue, adapter, post, screen, world, true, cancel)?;
|
||||
let refused = compile(device, &jobs, cancel);
|
||||
// Some pipelines may be left unmade: the pack can't draw.
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(Refused::cancelled(built.notes));
|
||||
}
|
||||
let Some((program, refused)) = refused else {
|
||||
return Ok(built);
|
||||
};
|
||||
log::warn!(
|
||||
"shader pack: {program} was refused ({refused}): building it again a program at a time"
|
||||
);
|
||||
drop((built, jobs));
|
||||
let (post, world) = spare;
|
||||
Self::build(device, queue, adapter, post, screen, world, false, cancel)
|
||||
.map(|(built, _)| built)
|
||||
}
|
||||
|
||||
/// `new`'s work: with `defer`, the pipelines are left to make (the jobs returned).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
adapter: &wgpu::Adapter,
|
||||
mut post: Post,
|
||||
screen: wgpu::TextureFormat,
|
||||
world: World<'_>,
|
||||
) -> Result<Self, Refused> {
|
||||
defer: bool,
|
||||
cancel: &std::sync::atomic::AtomicBool,
|
||||
) -> Result<(Self, Vec<Job>), Refused> {
|
||||
// What the pack's shaders need of its storage buffers, before its parts move out.
|
||||
let minimums = post.buffer_minimums();
|
||||
let features = device.features();
|
||||
@@ -2338,8 +2558,13 @@ impl PackPost {
|
||||
shadow_linear: (0..2)
|
||||
.filter(|&n| !shadow_config.nearest[n])
|
||||
.fold(0, |m, n| m | 1 << n),
|
||||
jobs: defer.then(Default::default),
|
||||
};
|
||||
let mut build = |mut p: horizon_shaders::PostProgram, geometry: Option<&Geometry<'_>>| {
|
||||
// Not wanted any more: the rest is skipped and the build refused below.
|
||||
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return None;
|
||||
}
|
||||
let in_shadow = p.group.shadow();
|
||||
let mut compute = std::mem::take(&mut p.compute);
|
||||
if !gpu.compute {
|
||||
@@ -2688,6 +2913,9 @@ impl PackPost {
|
||||
.filter(|p| !skip.split(',').any(|s| s == p.name))
|
||||
.filter_map(|p| build(p, None))
|
||||
.collect();
|
||||
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return Err(Refused::cancelled(notes));
|
||||
}
|
||||
let stand_ins = pack_images::stand_ins(device, stand_ins.take());
|
||||
// Terrain in two parts draws both or neither: one alone would leave the other part's
|
||||
// terrain undrawn. Without either, the game draws all terrain, as when the terrain
|
||||
@@ -3021,7 +3249,10 @@ impl PackPost {
|
||||
let white = [255u8; 4];
|
||||
let black = [0u8; 4];
|
||||
let flat_normal = [128u8, 128, 255, 255];
|
||||
Ok(Self {
|
||||
let jobs = (pack.jobs.as_ref())
|
||||
.map(std::cell::RefCell::take)
|
||||
.unwrap_or_default();
|
||||
let built = Self {
|
||||
notes,
|
||||
setup_pending: true,
|
||||
passes,
|
||||
@@ -3190,7 +3421,8 @@ impl PackPost {
|
||||
u
|
||||
},
|
||||
shadow_cache: Default::default(),
|
||||
})
|
||||
};
|
||||
Ok((built, jobs))
|
||||
}
|
||||
|
||||
/// `Renderer::finish_sequence_frame`: the centre depth the finished frame copied.
|
||||
@@ -3518,6 +3750,7 @@ impl PackPost {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let pipeline = pipeline.get().expect("compiled with its pack");
|
||||
let groups = groups.unwrap_or_else(|| {
|
||||
let n = |k: usize| (size[k] as f32 * render[k] / local[k] as f32).ceil() as u32;
|
||||
[n(0), n(1), 1]
|
||||
@@ -3732,8 +3965,8 @@ impl PackPost {
|
||||
pass.sky_parts
|
||||
.as_ref()
|
||||
.and_then(|[plain, stars]| match part.stage {
|
||||
"SKY" | "VOID" => Some(plain),
|
||||
"STARS" => Some(stars),
|
||||
"SKY" | "VOID" => plain.get(),
|
||||
"STARS" => stars.get(),
|
||||
_ => None,
|
||||
});
|
||||
rp.set_pipeline(part_pipeline.unwrap_or(pass.render()));
|
||||
@@ -5418,6 +5651,57 @@ mod tests {
|
||||
assert_eq!(used, [1, 2, 3]);
|
||||
}
|
||||
|
||||
/// A pack's pipelines are made together on several threads: each lands in its pass's slot,
|
||||
/// and one wgpu refuses comes back by name (the pack is then built a program at a time).
|
||||
#[test]
|
||||
fn pipelines_compile_together_and_a_refused_one_is_named() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let layout = device.create_pipeline_layout(&Default::default());
|
||||
let module = |source: &str| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
})
|
||||
};
|
||||
let job = |label: &str, module: wgpu::ShaderModule| {
|
||||
let slot = Slot::default();
|
||||
let job = ComputeJob {
|
||||
label: label.into(),
|
||||
layout: layout.clone(),
|
||||
module,
|
||||
};
|
||||
(slot.clone(), Job::Compute(slot, job))
|
||||
};
|
||||
let plain = "@compute @workgroup_size(1) fn main() {}";
|
||||
let go = std::sync::atomic::AtomicBool::new(false);
|
||||
let (slots, jobs): (Vec<_>, Vec<_>) = (0..12)
|
||||
.map(|i| job(&format!("composite{i}"), module(plain)))
|
||||
.unzip();
|
||||
// Cancelled before it starts: nothing is made (`new` then drops the pack).
|
||||
assert_eq!(compile(&device, &jobs, &true.into()), None);
|
||||
assert!(slots.iter().all(|s| s.get().is_none()));
|
||||
assert_eq!(compile(&device, &jobs, &go), None);
|
||||
assert!(slots.iter().all(|s| s.get().is_some()));
|
||||
// A binding the layout doesn't have: wgpu refuses the pipeline.
|
||||
let bound = "@group(0) @binding(0) var<storage, read_write> b: array<u32>;\n\
|
||||
@compute @workgroup_size(1) fn main() { b[0] = 1u; }";
|
||||
let (_, bad) = job("deferred3", module(bound));
|
||||
let (slots, mut jobs): (Vec<_>, Vec<_>) =
|
||||
(0..4).map(|i| job(&format!("c{i}"), module(plain))).unzip();
|
||||
jobs.insert(2, bad);
|
||||
let (program, _why) = compile(&device, &jobs, &go).expect("the pipeline is refused");
|
||||
assert_eq!(program, "deferred3");
|
||||
// The refusal stops none of the others: they are all made.
|
||||
assert!(slots.iter().all(|s| s.get().is_some()));
|
||||
}
|
||||
|
||||
/// #3525: naga's SPIR-V writer panicked on Photon's gbuffers programs as the pipeline was
|
||||
/// made. Such a panic skips the pass, as a validation error does, and the device goes on.
|
||||
#[test]
|
||||
@@ -5507,6 +5791,7 @@ mod tests {
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
jobs: None,
|
||||
};
|
||||
let reads = |group: Group| {
|
||||
let (_, samplers, ..) = bindings(
|
||||
@@ -5601,6 +5886,7 @@ mod tests {
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
jobs: None,
|
||||
packed: [None; BUFFERS],
|
||||
};
|
||||
let (_, samplers, ..) = bindings(
|
||||
@@ -5684,6 +5970,7 @@ mod tests {
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
jobs: None,
|
||||
};
|
||||
let mut notes = Vec::new();
|
||||
let (.., images) = bindings(
|
||||
@@ -5826,6 +6113,7 @@ mod tests {
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
jobs: None,
|
||||
};
|
||||
let draws = DrawUniforms::new(&device);
|
||||
let mut notes = Vec::new();
|
||||
|
||||
@@ -99,6 +99,7 @@ fn the_atlas_blends_its_two_nearest_levels() {
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
&Default::default(),
|
||||
)
|
||||
.expect("the pack runs");
|
||||
for p in gpu.render(&mut post, [4, 4]) {
|
||||
|
||||
@@ -83,6 +83,7 @@ impl Gpu {
|
||||
post,
|
||||
T::Rgba8Unorm,
|
||||
world,
|
||||
&Default::default(),
|
||||
)
|
||||
.expect("the pack runs");
|
||||
assert!(
|
||||
|
||||
@@ -244,6 +244,7 @@ impl Fixture {
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
&Default::default(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -187,6 +187,7 @@ fn pack(
|
||||
weather: true,
|
||||
hand: false,
|
||||
},
|
||||
&Default::default(),
|
||||
)
|
||||
.expect("weather pack runs");
|
||||
assert!(
|
||||
|
||||
@@ -304,6 +304,7 @@ fn pack_with(
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
&Default::default(),
|
||||
)
|
||||
.expect("the pack runs");
|
||||
assert!(post.notes.is_empty(), "{:?}", post.notes);
|
||||
|
||||
@@ -1663,6 +1663,7 @@ impl GraphicsUi {
|
||||
hover,
|
||||
focused,
|
||||
held: self.dragging == Some(item),
|
||||
key: None,
|
||||
};
|
||||
shell::row(batch, atlas, cols, y, &view);
|
||||
}
|
||||
|
||||
@@ -1333,6 +1333,36 @@ impl HudAtlas {
|
||||
}
|
||||
}
|
||||
if full {
|
||||
// Official Recipes artwork from Just Enough Items, under MIT.
|
||||
for (name, png) in [
|
||||
(
|
||||
"button_enabled_v2",
|
||||
include_bytes!("jei_recipes/button_enabled_v2.png").as_slice(),
|
||||
),
|
||||
(
|
||||
"button_disabled_v2",
|
||||
include_bytes!("jei_recipes/button_disabled_v2.png").as_slice(),
|
||||
),
|
||||
(
|
||||
"button_highlight_v2",
|
||||
include_bytes!("jei_recipes/button_highlight_v2.png").as_slice(),
|
||||
),
|
||||
(
|
||||
"icons/arrow_previous_v2",
|
||||
include_bytes!("jei_recipes/icons/arrow_previous_v2.png").as_slice(),
|
||||
),
|
||||
(
|
||||
"icons/arrow_next_v2",
|
||||
include_bytes!("jei_recipes/icons/arrow_next_v2.png").as_slice(),
|
||||
),
|
||||
(
|
||||
"search_background_v2",
|
||||
include_bytes!("jei_recipes/search_background_v2.png").as_slice(),
|
||||
),
|
||||
] {
|
||||
let (width, height, rgba) = decode_bytes(png)?;
|
||||
images.push((format!("horizon/jei/{name}"), width, height, rgba));
|
||||
}
|
||||
Self::slots(&mut images, crate::trims::slot_size(root), mod_image);
|
||||
// MissingTextureAtlasSprite.generateMissingImage: 16x16, magenta
|
||||
// and black quarters.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 200 B |
Binary file not shown.
|
After Width: | Height: | Size: 276 B |
Binary file not shown.
|
After Width: | Height: | Size: 284 B |
Binary file not shown.
|
After Width: | Height: | Size: 113 B |
Binary file not shown.
|
After Width: | Height: | Size: 107 B |
Binary file not shown.
|
After Width: | Height: | Size: 111 B |
@@ -42,6 +42,7 @@ pub mod dynamic_light;
|
||||
pub mod effects;
|
||||
pub mod environment;
|
||||
pub mod extras;
|
||||
pub(crate) mod extras_ui;
|
||||
pub mod fast_hash;
|
||||
pub mod fluid_render;
|
||||
pub mod fog;
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::{
|
||||
keybinds::{self, CATEGORIES, KeyBinds, all_mappings},
|
||||
lang::{self, LanguageInfo},
|
||||
settings::{SOUND_SOURCES, Settings},
|
||||
settings_shell as shell,
|
||||
text::{self, Style, Text},
|
||||
ui,
|
||||
};
|
||||
@@ -293,6 +294,7 @@ impl Opt {
|
||||
// the namespace the client bundles (assets/horizon/lang).
|
||||
Opt::PadEnabled => "horizon.options.controller",
|
||||
Opt::PadVibration => "horizon.options.vibration",
|
||||
Opt::PadPlayInBackground => "horizon.options.play_in_background",
|
||||
Opt::PadAdaptiveTriggers => "horizon.options.adaptive_triggers",
|
||||
Opt::PadLightbar => "horizon.options.lightbar",
|
||||
Opt::PadLookSensitivity => "horizon.options.look_sensitivity",
|
||||
@@ -308,6 +310,7 @@ impl Opt {
|
||||
Opt::PadTriggerThreshold => "horizon.options.trigger_threshold",
|
||||
Opt::PadGlyphs => "horizon.options.button_glyphs",
|
||||
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
|
||||
Opt::PadGameplayGuide => "horizon.options.gameplay_guide",
|
||||
Opt::ModelPart(i) => [
|
||||
"options.modelPart.cape",
|
||||
"options.modelPart.jacket",
|
||||
@@ -828,7 +831,13 @@ impl Opt {
|
||||
Opt::JapaneseGlyphs => on_off(s.japanese_glyphs),
|
||||
Opt::PadEnabled => on_off(s.controller.enabled),
|
||||
Opt::PadVibration => on_off(s.controller.vibration),
|
||||
Opt::PadPlayInBackground => on_off(s.controller.play_in_background),
|
||||
Opt::PadAdaptiveTriggers => on_off(s.controller.adaptive_triggers),
|
||||
Opt::PadGameplayGuide => tr(match s.controller.guide_detail {
|
||||
gamepad::GuideDetail::Full => "horizon.options.gameplay_guide.full",
|
||||
gamepad::GuideDetail::Basic => "horizon.options.gameplay_guide.basic",
|
||||
gamepad::GuideDetail::Off => "horizon.options.gameplay_guide.off",
|
||||
}),
|
||||
Opt::PadInvertX => on_off(s.controller.invert_x),
|
||||
Opt::PadInvertY => on_off(s.controller.invert_y),
|
||||
Opt::PadToggleSneak => tr(if s.controller.toggle_sneak {
|
||||
@@ -1125,6 +1134,9 @@ impl Opt {
|
||||
Opt::JapaneseGlyphs => s.japanese_glyphs = !s.japanese_glyphs,
|
||||
Opt::PadEnabled => s.controller.enabled = !s.controller.enabled,
|
||||
Opt::PadVibration => s.controller.vibration = !s.controller.vibration,
|
||||
Opt::PadPlayInBackground => {
|
||||
s.controller.play_in_background = !s.controller.play_in_background
|
||||
}
|
||||
Opt::PadAdaptiveTriggers => {
|
||||
s.controller.adaptive_triggers = !s.controller.adaptive_triggers
|
||||
}
|
||||
@@ -1146,6 +1158,14 @@ impl Opt {
|
||||
.unwrap_or(0) as u8;
|
||||
s.controller.glyphs = GlyphStyle::ALL[usize::from(next(i, 4))];
|
||||
}
|
||||
Opt::PadGameplayGuide => {
|
||||
let all = gamepad::GuideDetail::ALL;
|
||||
let i = all
|
||||
.iter()
|
||||
.position(|d| *d == s.controller.guide_detail)
|
||||
.unwrap_or(0) as u8;
|
||||
s.controller.guide_detail = all[usize::from(next(i, 3))];
|
||||
}
|
||||
Opt::PadKeyboard => {
|
||||
let all = gamepad::TextKeyboard::ALL;
|
||||
let i = all
|
||||
@@ -1619,6 +1639,8 @@ pub struct OptionsUi {
|
||||
center_language: bool,
|
||||
pub graphics: crate::graphics_ui::GraphicsUi,
|
||||
pub controller: crate::controller_ui::ControllerUi,
|
||||
/// Horizon Extras, on the settings shell.
|
||||
extras: shell::Shell<crate::extras_ui::ExtrasScreen>,
|
||||
pub shader: crate::shader_ui::ShaderUi,
|
||||
pub split_profiles: crate::split_profiles::SplitProfiles,
|
||||
/// HDR calibration while its page is open.
|
||||
@@ -1651,7 +1673,7 @@ pub struct OptionsUi {
|
||||
/// The list's rows as last laid out: each row's top and bottom (GUI
|
||||
/// pixels), for the narration's row under the mouse.
|
||||
row_bands: Vec<[f32; 2]>,
|
||||
/// The Extras or PvP & HUD items that take a whole row (`wide_extras`), measured
|
||||
/// The PvP & HUD items that take a whole row (`wide_extras`), measured
|
||||
/// when the page is drawn.
|
||||
wide_extras: Vec<crate::extras::Item>,
|
||||
}
|
||||
@@ -1697,6 +1719,7 @@ impl Default for OptionsUi {
|
||||
center_language: false,
|
||||
graphics: Default::default(),
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
shader: Default::default(),
|
||||
split_profiles: Default::default(),
|
||||
calibration: None,
|
||||
@@ -1765,24 +1788,23 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
|
||||
Kind::Cycle => button(x, y, w, opt.label(s), Act::Cycle(opt)),
|
||||
}
|
||||
}
|
||||
/// The Extras or PvP & HUD page's rows: its items under their headers.
|
||||
fn extras_rows(page: Page) -> Vec<Result<crate::extras::Item, &'static str>> {
|
||||
match page {
|
||||
Page::Pvp => crate::pvp::rows()
|
||||
.into_iter()
|
||||
.map(|r| r.map(crate::extras::Item::Pvp))
|
||||
.collect(),
|
||||
_ => crate::extras::rows(),
|
||||
}
|
||||
/// The least GUI size a shell screen lays its keys out for, before it has been drawn at its own.
|
||||
const MIN_KEY_GUI: (f32, f32) = (480.0, 240.0);
|
||||
/// The PvP & HUD page's rows: its items under their headers.
|
||||
fn pvp_rows() -> Vec<Result<crate::extras::Item, &'static str>> {
|
||||
crate::pvp::rows()
|
||||
.into_iter()
|
||||
.map(|r| r.map(crate::extras::Item::Pvp))
|
||||
.collect()
|
||||
}
|
||||
/// The items of `page`'s rows whose label, with any of their first values, is wider than a
|
||||
/// The items of PvP & HUD's rows whose label, with any of their first values, is wider than a
|
||||
/// half-width button holds (`width` measures text with the screen's font): those take the
|
||||
/// whole row, so no label slides past its edges, in any language.
|
||||
fn wide_extras(s: &Settings, page: Page, width: impl Fn(&str) -> f32) -> Vec<crate::extras::Item> {
|
||||
fn wide_extras(s: &Settings, width: impl Fn(&str) -> f32) -> Vec<crate::extras::Item> {
|
||||
// A button's label keeps 2 px off each edge (`AbstractButton.renderString`'s margin),
|
||||
// past which it slides back and forth.
|
||||
let room = 150.0 - 4.0;
|
||||
extras_rows(page)
|
||||
pvp_rows()
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
.filter(|&item| {
|
||||
@@ -2006,6 +2028,9 @@ impl OptionsUi {
|
||||
if !self.focus_from_keyboard {
|
||||
self.focus = None;
|
||||
}
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.blur_search();
|
||||
}
|
||||
}
|
||||
/// A switch of `horizon-extras.txt` as the Extras page draws it: "Caption: On".
|
||||
fn extra_button(&self, s: &Settings, item: crate::extras::Item) -> Widget {
|
||||
@@ -2058,7 +2083,10 @@ impl OptionsUi {
|
||||
}
|
||||
Page::Controller | Page::PadBinds => self.controller.typing(),
|
||||
_ if Self::shader_page(self.page()).is_some() => self.shader.typing(),
|
||||
_ => self.settings_search_active(),
|
||||
_ => match self.shell() {
|
||||
Some(shell) => shell.typing(),
|
||||
None => self.settings_search_active(),
|
||||
},
|
||||
}
|
||||
}
|
||||
/// Jump straight to a page (control socket / tests).
|
||||
@@ -2174,6 +2202,9 @@ impl OptionsUi {
|
||||
if let Some(at) = Self::shader_page(page) {
|
||||
self.shader.open(at);
|
||||
}
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.open();
|
||||
}
|
||||
if page == Page::SplitSetup {
|
||||
self.split_setup.reset();
|
||||
}
|
||||
@@ -2184,6 +2215,34 @@ impl OptionsUi {
|
||||
self.studio.refresh();
|
||||
}
|
||||
}
|
||||
/// The open page's screen on the settings shell. This, with `shell_mut`, is all a screen
|
||||
/// needs here: drawing, clicks, the wheel, a stick's scrolling, keys and typing all go
|
||||
/// through them, so no input path can miss one.
|
||||
fn shell(&self) -> Option<&dyn shell::Hosted> {
|
||||
match self.page() {
|
||||
Page::Extras => Some(&self.extras),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn shell_mut(&mut self) -> Option<&mut dyn shell::Hosted> {
|
||||
match self.page() {
|
||||
Page::Extras => Some(&mut self.extras),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// What a shell screen's outputs do to these options.
|
||||
fn shell_events(&mut self, s: &mut Settings, outputs: Vec<shell::Output>) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
for output in outputs {
|
||||
match output {
|
||||
shell::Output::Event(event) => events.push(event),
|
||||
shell::Output::BindKey(name) => self.selected_key = Some(name),
|
||||
shell::Output::Push(page) => self.push(page),
|
||||
shell::Output::Done => events.extend(self.back(s)),
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
/// What the graphics screen's actions do to these options.
|
||||
fn graphics_events(
|
||||
&mut self,
|
||||
@@ -2534,41 +2593,6 @@ impl OptionsUi {
|
||||
}
|
||||
rows
|
||||
}
|
||||
/// The row that reaches the Zoom mod that replaced the extra: its switch and settings page
|
||||
/// when it is built in, else a note saying where it is installed (Options > Mods). Zoom
|
||||
/// became an official native mod, so Extras has no setting of its own for it (#8713).
|
||||
fn zoom_mod_row(s: &Settings) -> Row {
|
||||
let Some(i) = s
|
||||
.mods
|
||||
.installed
|
||||
.iter()
|
||||
.position(|m| m.id == "zoom" && m.native.is_some())
|
||||
else {
|
||||
// A note, not a button: Options > Mods already has the one way into the Mods page
|
||||
// (`every_setting_and_page_is_reached_from_one_place`).
|
||||
return Row::Note(lang::get("horizon.extras.zoom_mod_install"));
|
||||
};
|
||||
let label = || Text::option_name_value(tr("horizon.extras.zoom"), on_off(s.mods.is_on(i)));
|
||||
let toggle = |width| button(0.0, 0.0, width, label(), Act::ToggleMod(i));
|
||||
let settings = s.mods.installed[i]
|
||||
.native
|
||||
.as_ref()
|
||||
.is_some_and(|page| !page.keys.is_empty() || !page.buttons.is_empty());
|
||||
if settings {
|
||||
Row::Buttons(
|
||||
toggle(150.0),
|
||||
Some(button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
tr("horizon.mods.settings"),
|
||||
Act::ModSettings(i),
|
||||
)),
|
||||
)
|
||||
} else {
|
||||
Row::Buttons(toggle(310.0), None)
|
||||
}
|
||||
}
|
||||
/// A native mod's page: its name, its keys as Key Binds rows (the
|
||||
/// player's choice, else the default), then its buttons.
|
||||
fn native_rows(&self, s: &Settings, i: usize) -> Vec<Row> {
|
||||
@@ -2609,7 +2633,7 @@ impl OptionsUi {
|
||||
fn list_rows(&self, s: &Settings) -> Vec<Row> {
|
||||
match self.page() {
|
||||
Page::SplitSetup => Vec::new(),
|
||||
Page::Extras | Page::Pvp => {
|
||||
Page::Pvp => {
|
||||
// Each section's switches two to a row, as vanilla's
|
||||
// OptionsList.addSmall lays out an options screen; one whose label would run
|
||||
// past half a row in this language (`wide_extras`) gets the whole row.
|
||||
@@ -2637,21 +2661,12 @@ impl OptionsUi {
|
||||
widget
|
||||
}
|
||||
};
|
||||
// PvP & HUD opens with its preset; Extras doesn't repeat it, nor the way there
|
||||
// (Horizon Settings has it).
|
||||
// PvP & HUD opens with its preset (Horizon Settings has the way there).
|
||||
let preset = button(0.0, 0.0, 150.0, tr("horizon.pvp.preset"), Act::PvpPreset);
|
||||
let mut rows = Vec::new();
|
||||
if self.page() == Page::Extras {
|
||||
// Zoom, the FPS counter, Keystrokes and the Minimap are official mods now;
|
||||
// Extras keeps a way to the one players look for here (#8713).
|
||||
rows.push(Self::zoom_mod_row(s));
|
||||
}
|
||||
if self.page() == Page::Pvp {
|
||||
// The editor moves what the game shows: only from a world.
|
||||
let edit = button(0.0, 0.0, 150.0, tr("horizon.pvp.editor"), Act::HudEditor);
|
||||
let edit = if self.in_world { edit } else { inactive(edit) };
|
||||
rows.push(Row::Buttons(preset, Some(edit)));
|
||||
}
|
||||
// The editor moves what the game shows: only from a world.
|
||||
let edit = button(0.0, 0.0, 150.0, tr("horizon.pvp.editor"), Act::HudEditor);
|
||||
let edit = if self.in_world { edit } else { inactive(edit) };
|
||||
let mut rows = vec![Row::Buttons(preset, Some(edit))];
|
||||
let mut section = Vec::new();
|
||||
let flush = |rows: &mut Vec<Row>, section: &mut Vec<crate::extras::Item>| {
|
||||
let mut half = None;
|
||||
@@ -2667,7 +2682,7 @@ impl OptionsUi {
|
||||
}
|
||||
rows.extend(half.map(|h| Row::Buttons(extra(h, 150.0), None)));
|
||||
};
|
||||
for row in extras_rows(self.page()) {
|
||||
for row in pvp_rows() {
|
||||
match row {
|
||||
Err(header) => {
|
||||
flush(&mut rows, &mut section);
|
||||
@@ -3649,6 +3664,12 @@ impl OptionsUi {
|
||||
self.shader.draw(s, batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
}
|
||||
let waiting = self.selected_key;
|
||||
let in_world = self.in_world;
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.draw(s, batch, atlas, cursor, in_world, waiting);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::SplitSetup {
|
||||
self.split_setup.on = s.extras.splitscreen;
|
||||
// The controller picks a tile up between presses: the screen's size from the draw.
|
||||
@@ -3677,8 +3698,8 @@ impl OptionsUi {
|
||||
crate::screen_draw::paint(batch, atlas, &draws);
|
||||
return;
|
||||
}
|
||||
if matches!(self.page(), Page::Extras | Page::Pvp) {
|
||||
self.wide_extras = wide_extras(s, self.page(), |t| atlas.font.width(t));
|
||||
if self.page() == Page::Pvp {
|
||||
self.wide_extras = wide_extras(s, |t| atlas.font.width(t));
|
||||
}
|
||||
let (widgets, top, bottom, content) = self.layout(s, w, h);
|
||||
self.size = (w, h);
|
||||
@@ -4015,6 +4036,22 @@ impl OptionsUi {
|
||||
.click(s, (x, y), size, framebuffer, drag, right, self.shift);
|
||||
return self.graphics_events(s, actions);
|
||||
}
|
||||
if self.shell().is_some() {
|
||||
// Waiting on a key: a mouse button binds, as on Key Binds.
|
||||
if self.selected_key.is_some() && !drag {
|
||||
let button = if right {
|
||||
winit::event::MouseButton::Right
|
||||
} else {
|
||||
winit::event::MouseButton::Left
|
||||
};
|
||||
return self.bind_mouse(s, button);
|
||||
}
|
||||
let shift = self.shift;
|
||||
let outputs = self.shell_mut().map_or_else(Vec::new, |shell| {
|
||||
shell.click(s, (x, y), size, drag, right, shift)
|
||||
});
|
||||
return self.shell_events(s, outputs);
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
// Waiting on a button: a click cancels the wait.
|
||||
if !drag && self.selected_pad_action.take().is_some() {
|
||||
@@ -4291,7 +4328,9 @@ impl OptionsUi {
|
||||
// release, even if the pointer has left the widget or its value stayed.
|
||||
let slider = self.dragging.take().is_some();
|
||||
let (shader_slider, shader_pack) = self.shader.release();
|
||||
let graphics_slider = self.graphics.release() | self.controller.release() | shader_slider;
|
||||
let shell_slider = self.shell_mut().is_some_and(|shell| shell.release());
|
||||
let graphics_slider =
|
||||
self.graphics.release() | self.controller.release() | shader_slider | shell_slider;
|
||||
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
|
||||
let split_drop = self.split_setup.release();
|
||||
if slider || graphics_slider || calibration_slider {
|
||||
@@ -4369,6 +4408,13 @@ impl OptionsUi {
|
||||
) -> Vec<Event> {
|
||||
self.size = size;
|
||||
self.framebuffer = framebuffer;
|
||||
if self.resource_error.is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
let outputs = shell.wheel(s, size, cursor, amount);
|
||||
return self.shell_events(s, outputs);
|
||||
}
|
||||
if Self::graphics_page(self.page()).is_some() {
|
||||
let actions = self.graphics.wheel(s, size, cursor, framebuffer, amount);
|
||||
return self.graphics_events(s, actions);
|
||||
@@ -4460,6 +4506,10 @@ impl OptionsUi {
|
||||
if self.resource_error.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.scroll(s, size, amount);
|
||||
return;
|
||||
}
|
||||
if Self::graphics_page(self.page()).is_some() {
|
||||
self.graphics.scroll(s, size, amount);
|
||||
return;
|
||||
@@ -4656,6 +4706,10 @@ impl OptionsUi {
|
||||
self.scroll = 0.0;
|
||||
} else if self.page() == Page::SplitProfiles {
|
||||
self.split_profiles.type_text(text);
|
||||
} else if let Some(shell) = self.shell_mut() {
|
||||
if shell.typing() {
|
||||
shell.type_text(text);
|
||||
}
|
||||
} else if Self::graphics_page(self.page()).is_some() && self.graphics.typing() {
|
||||
self.graphics.type_text(text);
|
||||
} else if Self::controller_page(self.page()).is_some() && self.controller.typing() {
|
||||
@@ -4693,6 +4747,9 @@ impl OptionsUi {
|
||||
if self.page() == Page::SplitProfiles {
|
||||
return self.split_profiles.shortcut(shortcut, clipboard);
|
||||
}
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
return shell.shortcut(shortcut, clipboard);
|
||||
}
|
||||
if Self::graphics_page(self.page()).is_some() {
|
||||
return self.graphics.shortcut(shortcut, clipboard);
|
||||
}
|
||||
@@ -4826,6 +4883,14 @@ impl OptionsUi {
|
||||
}
|
||||
return events;
|
||||
}
|
||||
let gui = (
|
||||
self.size.0.max(MIN_KEY_GUI.0),
|
||||
self.size.1.max(MIN_KEY_GUI.1),
|
||||
);
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
let outputs = shell.key(s, key, text, shift, gui);
|
||||
return self.shell_events(s, outputs);
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
let size = (self.size.0.max(480.0), self.size.1.max(240.0));
|
||||
let fb = [self.framebuffer[0].max(480), self.framebuffer[1].max(240)];
|
||||
@@ -5184,6 +5249,7 @@ impl OptionsUi {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::extras_ui::ExtraRow;
|
||||
|
||||
/// The window allows the input method only while `typing` says a search
|
||||
/// box has the focus (`Game::text_field_focused`), so a page without one
|
||||
@@ -5629,6 +5695,9 @@ mod tests {
|
||||
if ui.controller_open() {
|
||||
texts.extend(ui.controller.row_names(&s));
|
||||
}
|
||||
if ui.page() == Page::Extras {
|
||||
texts.extend(ui.extras.row_labels(&s));
|
||||
}
|
||||
}
|
||||
texts
|
||||
})
|
||||
@@ -5997,7 +6066,7 @@ mod tests {
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Extras);
|
||||
ui.show(Page::Pvp);
|
||||
let (widgets, _, _, _) = ui.layout(&s, 480.0, 480.0);
|
||||
let extra = widgets
|
||||
.iter()
|
||||
@@ -6007,7 +6076,7 @@ mod tests {
|
||||
WKind::Button {
|
||||
act: Act::Extra(item),
|
||||
..
|
||||
} if !matches!(item, crate::extras::Item::SortKey)
|
||||
} if item.key_name().is_none()
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
@@ -6053,19 +6122,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn extras_reaches_the_zoom_mod_that_replaced_the_extra() {
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Extras);
|
||||
let english = || lang::Language::load(&[], "en_us");
|
||||
// Without the mod, Extras says where it is installed (a note, not a second way into
|
||||
// Options > Mods).
|
||||
let (widgets, _, _, _) = lang::scoped(english(), || ui.layout(&s, 480.0, 4000.0)).0;
|
||||
let labels = lang::scoped(english(), || ui.extras.row_labels(&s)).0;
|
||||
assert!(
|
||||
widgets.iter().any(|w| matches!(
|
||||
&w.kind,
|
||||
WKind::Text { text, .. } if text.plain() == "Zoom: install from Mods"
|
||||
)),
|
||||
"Extras has no hint for the Zoom mod"
|
||||
labels.iter().any(|l| l == "Zoom: install from Mods"),
|
||||
"Extras has no hint for the Zoom mod: {labels:?}"
|
||||
);
|
||||
// Built in, Zoom's settings live on its page: Extras opens that page.
|
||||
s.mods.installed.push(crate::mod_list::Installed {
|
||||
@@ -6079,30 +6146,28 @@ mod tests {
|
||||
buttons: Vec::new(),
|
||||
}),
|
||||
});
|
||||
let (widgets, _, _, _) = lang::scoped(english(), || ui.layout(&s, 480.0, 4000.0)).0;
|
||||
assert!(
|
||||
widgets.iter().any(|w| matches!(
|
||||
&w.kind,
|
||||
WKind::Button {
|
||||
act: Act::ModSettings(0),
|
||||
label,
|
||||
..
|
||||
} if label.plain() == "Settings"
|
||||
)),
|
||||
"Extras doesn't open the Zoom mod's settings"
|
||||
let labels = lang::scoped(english(), || ui.extras.row_labels(&s)).0;
|
||||
assert!(labels.iter().any(|l| l == "Settings"), "{labels:?}");
|
||||
// The mod's switch is beside it, so Extras keeps the Zoom setting it had.
|
||||
assert!(labels.iter().any(|l| l.starts_with("Zoom: ")), "{labels:?}");
|
||||
let size = (480.0, 480.0);
|
||||
let on = s.mods.is_on(0);
|
||||
let at = ui.extras.point_at(&s, size, ExtraRow::Zoom(0));
|
||||
assert_eq!(
|
||||
ui.click(&mut s, &atlas, at.0, at.1, size, [960, 960], false, false),
|
||||
vec![Event::ButtonClick, Event::Changed]
|
||||
);
|
||||
// The mod's switch is on the same row, so Extras keeps the Zoom setting it had.
|
||||
assert!(
|
||||
widgets.iter().any(|w| matches!(
|
||||
&w.kind,
|
||||
WKind::Button {
|
||||
act: Act::ToggleMod(0),
|
||||
label,
|
||||
..
|
||||
} if label.plain().starts_with("Zoom")
|
||||
)),
|
||||
"Extras doesn't switch the Zoom mod"
|
||||
assert_ne!(s.mods.is_on(0), on);
|
||||
let at = ui.extras.point_at(&s, size, ExtraRow::ZoomSettings(0));
|
||||
assert_eq!(
|
||||
ui.click(&mut s, &atlas, at.0, at.1, size, [960, 960], false, false),
|
||||
vec![
|
||||
Event::ButtonClick,
|
||||
Event::ButtonClick,
|
||||
Event::NativeSettings(0)
|
||||
]
|
||||
);
|
||||
assert_eq!(ui.page(), Page::NativeMod(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -6446,32 +6511,27 @@ mod tests {
|
||||
);
|
||||
assert_eq!(ui.page(), page);
|
||||
}
|
||||
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
|
||||
assert_eq!(
|
||||
widgets
|
||||
.iter()
|
||||
.filter(|w| matches!(
|
||||
w.kind,
|
||||
WKind::Button {
|
||||
act: Act::Extra(_),
|
||||
..
|
||||
}
|
||||
))
|
||||
.count(),
|
||||
crate::extras::rows().iter().filter(|r| r.is_ok()).count()
|
||||
ui.extras.row_labels(&s).len(),
|
||||
// And the note about the Zoom mod over them.
|
||||
1 + crate::extras::rows().iter().filter(|r| r.is_ok()).count()
|
||||
);
|
||||
let press = |ui: &mut OptionsUi, s: &mut Settings, item: crate::extras::Item| {
|
||||
let at = ui.extras.point_at(s, size, ExtraRow::Item(item));
|
||||
ui.click(s, &atlas, at.0, at.1, size, [960, 960], false, false)
|
||||
};
|
||||
assert_eq!(
|
||||
click(&mut ui, &mut s, Act::Extra(crate::extras::Item::Sort)),
|
||||
press(&mut ui, &mut s, crate::extras::Item::Sort),
|
||||
vec![Event::ButtonClick, Event::Changed]
|
||||
);
|
||||
assert!(s.extras.sort);
|
||||
assert_eq!(
|
||||
click(&mut ui, &mut s, Act::Extra(crate::extras::Item::Pregen)),
|
||||
press(&mut ui, &mut s, crate::extras::Item::Pregen),
|
||||
vec![Event::ButtonClick, Event::Changed]
|
||||
);
|
||||
assert!(s.extras.pregen);
|
||||
assert_eq!(
|
||||
click(&mut ui, &mut s, Act::Extra(crate::extras::Item::SortKey)),
|
||||
press(&mut ui, &mut s, crate::extras::Item::SortKey),
|
||||
vec![Event::ButtonClick]
|
||||
);
|
||||
assert_eq!(ui.selected_key, Some(crate::extras::SORT_KEY));
|
||||
@@ -6484,12 +6544,6 @@ mod tests {
|
||||
crate::extras::Extras::parse(&s.extras.serialize()),
|
||||
s.extras
|
||||
);
|
||||
// In a window too short for the page, the wheel scrolls to its end.
|
||||
let short = (480.0, 180.0);
|
||||
ui.scroll(&s, short, -1000.0);
|
||||
let (_, top, bottom, content) = ui.layout(&s, short.0, short.1);
|
||||
assert!(content > bottom - top);
|
||||
assert_eq!(ui.scroll, content - (bottom - top));
|
||||
assert_eq!(
|
||||
ui.key(&mut s, &atlas, winit::keyboard::KeyCode::Escape, None),
|
||||
vec![Event::Changed]
|
||||
@@ -6497,6 +6551,24 @@ mod tests {
|
||||
assert_eq!(ui.page(), Page::Horizon);
|
||||
}
|
||||
|
||||
/// Every way a pad or a wheel scrolls a page on the shell moves the same list: Replay Studio's
|
||||
/// took the wheel and missed `scroll_at`, so a stick couldn't scroll it.
|
||||
#[test]
|
||||
fn the_wheel_and_the_stick_scroll_a_page_on_the_shell_alike() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Extras);
|
||||
// Too short for the page.
|
||||
let short = (480.0, 180.0);
|
||||
ui.wheel(&mut s, short, (0.0, 0.0), [960, 360], -1.0);
|
||||
let one = ui.extras.scroll_amount();
|
||||
assert!(one > 0.0);
|
||||
ui.scroll_at(&s, short, (0.0, 0.0), -1.0);
|
||||
assert_eq!(ui.extras.scroll_amount(), 2.0 * one);
|
||||
ui.scroll_at(&s, short, (0.0, 0.0), 2.0);
|
||||
assert_eq!(ui.extras.scroll_amount(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_graphics_presets_set_the_distances() {
|
||||
let atlas = atlas();
|
||||
@@ -7327,7 +7399,7 @@ mod tests {
|
||||
/// An Extras or PvP & HUD label wider than half a row takes the whole row, so every label
|
||||
/// fits its button: "Keep channels for UI sounds: Off" did not.
|
||||
#[test]
|
||||
fn extras_labels_fit_their_buttons() {
|
||||
fn pvp_labels_fit_their_buttons() {
|
||||
// Vanilla's font: 6 px a glyph, a few narrower ones.
|
||||
let width = |t: &str| {
|
||||
t.chars()
|
||||
@@ -7342,9 +7414,9 @@ mod tests {
|
||||
};
|
||||
let s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
for page in [Page::Extras, Page::Pvp] {
|
||||
ui.stack = vec![Page::Main, page];
|
||||
ui.wide_extras = wide_extras(&s, page, width);
|
||||
{
|
||||
ui.stack = vec![Page::Main, Page::Pvp];
|
||||
ui.wide_extras = wide_extras(&s, width);
|
||||
let rows = ui.list_rows(&s);
|
||||
let buttons: Vec<&Widget> = rows
|
||||
.iter()
|
||||
@@ -7358,20 +7430,11 @@ mod tests {
|
||||
continue;
|
||||
};
|
||||
let label = label.plain();
|
||||
assert!(width(&label) <= b.w - 4.0, "{page:?}: {label} on {}", b.w);
|
||||
assert!(width(&label) <= b.w - 4.0, "{label} on {}", b.w);
|
||||
}
|
||||
// Short ones keep vanilla's two to a row.
|
||||
assert!(buttons.iter().any(|b| b.w == 150.0), "{page:?}");
|
||||
assert!(buttons.iter().any(|b| b.w == 150.0));
|
||||
}
|
||||
ui.stack = vec![Page::Main, Page::Extras];
|
||||
ui.wide_extras = wide_extras(&s, Page::Extras, width);
|
||||
for item in [
|
||||
crate::extras::Item::UiSoundReserve,
|
||||
crate::extras::Item::WheelCyclesSettings,
|
||||
] {
|
||||
assert!(ui.wide_extras.contains(&item), "{item:?}");
|
||||
}
|
||||
assert!(!ui.wide_extras.contains(&crate::extras::Item::AutoReconnect));
|
||||
}
|
||||
|
||||
/// Every setting a player reaches, from the Options screen through every button that opens
|
||||
@@ -7787,20 +7850,9 @@ mod tests {
|
||||
s.extras.wheel_cycles_settings = true;
|
||||
ui.show(Page::Extras);
|
||||
let size = (480.0, 600.0);
|
||||
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
|
||||
let sort_wd = widgets
|
||||
.iter()
|
||||
.find(|w| {
|
||||
matches!(
|
||||
w.kind,
|
||||
WKind::Button {
|
||||
act: Act::Extra(crate::extras::Item::Sort),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let cursor = (sort_wd.x + 5.0, sort_wd.y + 5.0);
|
||||
let cursor = ui
|
||||
.extras
|
||||
.point_at(&s, size, ExtraRow::Item(crate::extras::Item::Sort));
|
||||
assert!(!s.extras.sort);
|
||||
ui.wheel(&mut s, size, cursor, [854, 480], 1.0);
|
||||
assert!(s.extras.sort);
|
||||
@@ -8283,55 +8335,5 @@ mod tests {
|
||||
ui.click(&mut s, &atlas, px, py, size, [854, 480], false, false);
|
||||
assert_eq!(s.particles, 0);
|
||||
ui.shift = false;
|
||||
|
||||
// Also verify Act::Extra on Page::Extras
|
||||
ui.show(Page::Extras);
|
||||
let (widgets, top, _, _) = ui.layout(&s, size.0, size.1);
|
||||
if let Some(extra_btn) = widgets.iter().find(|wd| {
|
||||
matches!(
|
||||
wd.kind,
|
||||
WKind::Button {
|
||||
active: true,
|
||||
act: Act::Extra(crate::extras::Item::CosmeticsOthers),
|
||||
..
|
||||
}
|
||||
)
|
||||
}) {
|
||||
// Extras has enough rows to scroll: click the cycle button inside
|
||||
// the list's viewport, as a player would after scrolling to it.
|
||||
ui.scroll = (extra_btn.y - top).max(0.0);
|
||||
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
|
||||
let extra_btn = widgets
|
||||
.iter()
|
||||
.find(|wd| {
|
||||
matches!(
|
||||
wd.kind,
|
||||
WKind::Button {
|
||||
act: Act::Extra(crate::extras::Item::CosmeticsOthers),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let (ex, ey) = (extra_btn.x + 1.0, extra_btn.y + 1.0);
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
// Not chosen, nothing owned: shows Hidden.
|
||||
assert_eq!(s.extras.cosmetics_others, None);
|
||||
assert_eq!(s.extras.effective_others(), Others::Hidden);
|
||||
|
||||
// Forward (wraps to Shown), now a choice
|
||||
ui.click(&mut s, &atlas, ex, ey, size, [854, 480], false, false);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::Shown));
|
||||
|
||||
// Backward with shift
|
||||
ui.shift = true;
|
||||
ui.click(&mut s, &atlas, ex, ey, size, [854, 480], false, false);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::Hidden));
|
||||
|
||||
// Backward again
|
||||
ui.click(&mut s, &atlas, ex, ey, size, [854, 480], false, false);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::NameTags));
|
||||
ui.shift = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -830,6 +830,7 @@ pub enum Opt {
|
||||
/// Horizon's controller settings (`horizon-controller.txt`).
|
||||
PadEnabled,
|
||||
PadVibration,
|
||||
PadPlayInBackground,
|
||||
PadAdaptiveTriggers,
|
||||
PadLightbar,
|
||||
PadLookSensitivity,
|
||||
@@ -845,6 +846,7 @@ pub enum Opt {
|
||||
PadTriggerThreshold,
|
||||
PadGlyphs,
|
||||
PadKeyboard,
|
||||
PadGameplayGuide,
|
||||
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
|
||||
ModelPart(usize),
|
||||
/// 3D skin layers: the player's outer layer as extruded slabs.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -644,6 +644,7 @@ impl ShaderUi {
|
||||
hover,
|
||||
focused,
|
||||
held: self.dragging == Some(item),
|
||||
key: None,
|
||||
};
|
||||
shell::row(batch, atlas, &cols, y, &view);
|
||||
},
|
||||
|
||||
@@ -1126,18 +1126,29 @@ impl Streaming {
|
||||
pub fn lod_source(&self) -> Arc<dyn LodSource> {
|
||||
self.saves.clone()
|
||||
}
|
||||
/// Resource packs reloaded (`LevelRenderer.allChanged`): mesh with the new
|
||||
/// Resource packs reloaded (`LevelExtractor.allChanged`): mesh with the new
|
||||
/// models and sprites, and rebuild every loaded section. Meshes in flight
|
||||
/// were made with the old resources; their results are dropped.
|
||||
pub fn reload_resources(&mut self, world: &mut World, context: Arc<WorkerContext>) {
|
||||
*self.shared.context.write().unwrap() = context;
|
||||
self.reload_sections(world);
|
||||
}
|
||||
/// `LevelExtractor.allChanged`: discard mesh work made before the reload and
|
||||
/// rebuild every loaded, lit section.
|
||||
pub fn reload_sections(&mut self, world: &mut World) {
|
||||
self.meshing.clear();
|
||||
self.compile_started.clear();
|
||||
Self::mark_loaded_sections_dirty(world, &self.lit);
|
||||
}
|
||||
/// The resident sections `LevelExtractor.allChanged` queues again. An
|
||||
/// unlit column will queue itself when its light job completes.
|
||||
fn mark_loaded_sections_dirty(world: &mut World, lit: &FxHashSet<(i32, i32)>) {
|
||||
for &(x, z) in world.chunks.keys() {
|
||||
if self.lit.contains(&(x, z)) {
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
world.dirty_sections.insert((x, sy, z));
|
||||
}
|
||||
if !lit.contains(&(x, z)) {
|
||||
continue;
|
||||
}
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
world.dirty_sections.insert((x, sy, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4462,3 +4473,38 @@ mod cover_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod reload_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reload_marks_every_section_of_loaded_lit_columns() {
|
||||
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
|
||||
world.chunks.insert((2, -3), Arc::new(Chunk::empty(2, -3)));
|
||||
world.chunks.insert((4, 5), Arc::new(Chunk::empty(4, 5)));
|
||||
let lit = FxHashSet::from_iter([(2, -3), (99, 99)]);
|
||||
|
||||
Streaming::mark_loaded_sections_dirty(&mut world, &lit);
|
||||
|
||||
let sections = world.layout().sections() as i32;
|
||||
assert_eq!(world.dirty_sections.len(), sections as usize);
|
||||
for sy in 0..sections {
|
||||
assert!(world.dirty_sections.contains(&(2, sy, -3)));
|
||||
assert!(!world.dirty_sections.contains(&(4, sy, 5)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_keeps_existing_work_and_ignores_unloaded_lit_columns() {
|
||||
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
|
||||
world.chunks.insert((4, 5), Arc::new(Chunk::empty(4, 5)));
|
||||
world.dirty_sections.insert((4, 3, 5));
|
||||
let lit = FxHashSet::from_iter([(99, 99)]);
|
||||
|
||||
Streaming::mark_loaded_sections_dirty(&mut world, &lit);
|
||||
|
||||
assert_eq!(world.dirty_sections.len(), 1);
|
||||
assert!(world.dirty_sections.contains(&(4, 3, 5)));
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,10 @@ fn scaling(sprite: &str, r: &Rect) -> NineSlice {
|
||||
width: r.w,
|
||||
height: r.h,
|
||||
border: match sprite {
|
||||
"horizon/jei/button_enabled_v2"
|
||||
| "horizon/jei/button_disabled_v2"
|
||||
| "horizon/jei/button_highlight_v2" => [6; 4],
|
||||
"horizon/jei/search_background_v2" => [6; 4],
|
||||
"gui/sprites/widget/button"
|
||||
| "gui/sprites/widget/button_highlighted"
|
||||
| "gui/sprites/widget/button_disabled" => [3; 4],
|
||||
@@ -1636,10 +1640,19 @@ impl EditBox {
|
||||
}
|
||||
/// Draw the field; `format` styles the visible text (command colouring).
|
||||
pub fn draw(&mut self, batch: &mut HudBatch, atlas: &HudAtlas, format: Option<&Text>) {
|
||||
self.draw_with_skin(batch, atlas, format, None);
|
||||
}
|
||||
|
||||
pub fn draw_with_skin(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
format: Option<&Text>,
|
||||
skin: Option<&str>,
|
||||
) {
|
||||
self.layout(atlas);
|
||||
crate::widgets::target_as(
|
||||
self.frame
|
||||
.unwrap_or([self.x, self.y, self.width, self.height]),
|
||||
[self.x, self.y, self.width, self.height],
|
||||
crate::widgets::Kind::Field,
|
||||
);
|
||||
crate::widgets::narratable(|| {
|
||||
@@ -1651,11 +1664,11 @@ impl EditBox {
|
||||
.focused(self.focused)
|
||||
});
|
||||
if self.bordered {
|
||||
let sprite = if self.focused {
|
||||
let sprite = skin.unwrap_or(if self.focused {
|
||||
"gui/sprites/widget/text_field_highlighted"
|
||||
} else {
|
||||
"gui/sprites/widget/text_field"
|
||||
};
|
||||
});
|
||||
nine_slice(
|
||||
batch,
|
||||
atlas,
|
||||
|
||||
@@ -37,7 +37,8 @@ horizon-chat-filter = { path = "../horizon-chat-filter" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-friends = { path = "../horizon-friends" }
|
||||
# Play with Friends: invite codes and their tunnel; the `friends` feature.
|
||||
horizon-friends = { path = "../horizon-friends", optional = true }
|
||||
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
|
||||
horizon-narrator = { path = "../horizon-narrator" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
@@ -65,9 +66,9 @@ log = "0.4"
|
||||
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
|
||||
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
|
||||
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md). The
|
||||
# anonymous "I'm playing" ping is horizon-client-lib's (`heartbeat.rs`), the `heartbeat` feature.
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
crc32fast = "1"
|
||||
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
|
||||
@@ -108,12 +109,19 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers"]
|
||||
default = ["audio", "controllers", "discord", "friends", "heartbeat"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
|
||||
# on Discord (game/discord.rs).
|
||||
discord = ["horizon-client-lib/discord"]
|
||||
# Play with Friends: sharing a world behind an invite code (game/friends.rs) and joining one.
|
||||
friends = ["horizon-client-lib/friends", "dep:horizon-friends"]
|
||||
# The anonymous "I'm playing" ping (docs/telemetry.md).
|
||||
heartbeat = ["horizon-client-lib/heartbeat"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
|
||||
# Rust's allocations go to the system heap instead of mimalloc, where heap profilers that replace
|
||||
|
||||
@@ -5,7 +5,60 @@
|
||||
"horizon.options.controller": "Controller",
|
||||
"horizon.options.three_d_skin_layers": "3D Skin Layers",
|
||||
"horizon.options.vibration": "Vibration",
|
||||
"horizon.options.play_in_background": "Play in Background",
|
||||
"horizon.options.adaptive_triggers": "Adaptive Triggers",
|
||||
"horizon.options.gameplay_guide": "Gameplay Guide",
|
||||
"horizon.options.gameplay_guide.basic": "Basic",
|
||||
"horizon.options.gameplay_guide.full": "Full",
|
||||
"horizon.options.gameplay_guide.off": "Off",
|
||||
"horizon.controller.guide.use": "Use",
|
||||
"horizon.controller.guide.open": "Open",
|
||||
"horizon.controller.guide.ride": "Ride",
|
||||
"horizon.controller.guide.trade": "Trade",
|
||||
"horizon.controller.guide.feed": "Feed",
|
||||
"horizon.controller.guide.place": "Place",
|
||||
"horizon.controller.guide.eat": "Eat",
|
||||
"horizon.controller.guide.drink": "Drink",
|
||||
"horizon.controller.guide.shoot": "Shoot",
|
||||
"horizon.controller.guide.load": "Load",
|
||||
"horizon.controller.guide.throw": "Throw",
|
||||
"horizon.controller.guide.raise": "Raise",
|
||||
"horizon.controller.guide.equip": "Equip",
|
||||
"horizon.controller.guide.fish": "Fish",
|
||||
"horizon.controller.guide.zoom": "Zoom",
|
||||
"horizon.controller.guide.boost": "Boost",
|
||||
"horizon.controller.guide.break": "Break",
|
||||
"horizon.controller.guide.attack": "Attack",
|
||||
"horizon.controller.guide.spectate": "Spectate",
|
||||
"horizon.controller.guide.spectator_menu": "Spectator Menu",
|
||||
"horizon.controller.guide.release_shoot": "Release to Shoot",
|
||||
"horizon.controller.guide.release_throw": "Release to Throw",
|
||||
"horizon.controller.guide.release_stop": "Release to Stop",
|
||||
"horizon.controller.guide.fly": "Fly",
|
||||
"horizon.controller.guide.fly_up": "Fly Up",
|
||||
"horizon.controller.guide.fly_down": "Fly Down",
|
||||
"horizon.controller.guide.swim_up": "Swim Up",
|
||||
"horizon.controller.guide.swim_down": "Swim Down",
|
||||
"horizon.controller.guide.glide": "Glide",
|
||||
"horizon.controller.guide.dismount": "Dismount",
|
||||
"horizon.controller.guide.stand": "Stand Up",
|
||||
"horizon.controller.guide.drop": "Drop Item",
|
||||
"horizon.controller.guide.swap": "Swap Hands",
|
||||
"horizon.controller.guide.take": "Take",
|
||||
"horizon.controller.guide.take_half": "Take Half",
|
||||
"horizon.controller.guide.put": "Put Down",
|
||||
"horizon.controller.guide.put_one": "Put One",
|
||||
"horizon.controller.guide.swap_stack": "Swap",
|
||||
"horizon.controller.guide.quick_move": "Quick Move",
|
||||
"horizon.controller.guide.drop_stack": "Drop Stack",
|
||||
"horizon.controller.guide.place_recipe": "Place Recipe",
|
||||
"horizon.controller.guide.place_recipe_all": "Place All",
|
||||
"horizon.controller.guide.alternatives": "Alternatives",
|
||||
"horizon.controller.guide.select": "Select",
|
||||
"horizon.controller.guide.close": "Close",
|
||||
"horizon.controller.guide.pages": "Pages",
|
||||
"horizon.controller.guide.categories": "Categories",
|
||||
"horizon.controller.guide.tabs": "Tabs",
|
||||
"horizon.options.lightbar": "Lightbar",
|
||||
"horizon.options.look_sensitivity": "Look Sensitivity",
|
||||
"horizon.options.invert_x": "Invert Look X",
|
||||
@@ -886,6 +939,20 @@
|
||||
"Hold the button to sneak, or press it once to start and again to stop.": "Hold the button to sneak, or press it once to start and again to stop.",
|
||||
"How far the left stick moves before you walk. Raise it if you drift.": "How far the left stick moves before you walk. Raise it if you drift.",
|
||||
"How far a trigger is pulled before it counts as pressed.": "How far a trigger is pulled before it counts as pressed.",
|
||||
"Shows what your buttons do right now, in play and in inventories.": "Shows what your buttons do right now, in play and in inventories.",
|
||||
"Shows only what you can do with what you're looking at, and the main inventory buttons.": "Shows only what you can do with what you're looking at, and the main inventory buttons.",
|
||||
"Doesn't show what your buttons do.": "Doesn't show what your buttons do.",
|
||||
"Shows the buttons of the controller you're using.": "Shows the buttons of the controller you're using.",
|
||||
"Always shows Xbox buttons.": "Always shows Xbox buttons.",
|
||||
"Always shows PlayStation buttons.": "Always shows PlayStation buttons.",
|
||||
"Always shows Nintendo buttons.": "Always shows Nintendo buttons.",
|
||||
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.",
|
||||
"Always opens Horizon's keyboard.": "Always opens Horizon's keyboard.",
|
||||
"Always opens Steam's keyboard.": "Always opens Steam's keyboard.",
|
||||
"A DualSense's light shows your health, from green through amber to red.": "A DualSense's light shows your health, from green through amber to red.",
|
||||
"A DualSense's light changes with the dimension you're in.": "A DualSense's light changes with the dimension you're in.",
|
||||
"A DualSense keeps its own light.": "A DualSense keeps its own light.",
|
||||
"Show the buttons for what you can do, while you play with a controller.": "Show the buttons for what you can do, while you play with a controller.",
|
||||
"How fast the stick moves the crosshair in inventories.": "How fast the stick moves the crosshair in inventories.",
|
||||
"How fast the right stick scrolls a list.": "How fast the right stick scrolls a list.",
|
||||
"How long a held direction waits before it repeats.": "How long a held direction waits before it repeats.",
|
||||
@@ -899,6 +966,7 @@
|
||||
"Follow the controller, or always show one family's buttons.": "Follow the controller, or always show one family's buttons.",
|
||||
"The buttons menus show right now.": "The buttons menus show right now.",
|
||||
"Rumble when something happens to you.": "Rumble when something happens to you.",
|
||||
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "The controller keeps playing while this window isn't focused, and losing focus won't pause.",
|
||||
"A DualSense's triggers push back on bows and tools.": "A DualSense's triggers push back on bows and tools.",
|
||||
"What a DualSense's light shows, over USB or Bluetooth.": "What a DualSense's light shows, over USB or Bluetooth.",
|
||||
"Player profile": "Player profile",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"horizon.extras.header.world": "Mundo",
|
||||
"horizon.extras.pregen": "Pregeneración de chunks",
|
||||
"horizon.extras.pregen.tooltip": "Activa /pregen en tus mundos de un jugador y de LAN. Con los trucos activados, usa /pregen start para generar una zona antes de explorarla. No activa el comando en los servidores a los que te unes.",
|
||||
"horizon.options.controller": "Mando",
|
||||
"horizon.options.three_d_skin_layers": "Capas de skin 3D",
|
||||
"horizon.options.vibration": "Vibración",
|
||||
"horizon.options.adaptive_triggers": "Gatillos adaptativos",
|
||||
"horizon.options.lightbar": "Barra de luz",
|
||||
@@ -9,6 +13,7 @@
|
||||
"horizon.options.sneak_button": "Botón de agacharse",
|
||||
"horizon.options.move_deadzone": "Zona muerta de movimiento",
|
||||
"horizon.options.look_deadzone": "Zona muerta de vista",
|
||||
"horizon.options.trigger_threshold": "Umbral del gatillo",
|
||||
"horizon.options.button_glyphs": "Símbolos de botones",
|
||||
"horizon.options.on_screen_keyboard": "Teclado en pantalla",
|
||||
"horizon.options.chat_delay": "Retardo del chat",
|
||||
@@ -23,37 +28,99 @@
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "Esta función aún no está disponible en Horizon.",
|
||||
"horizon.options.search": "Buscar ajustes...",
|
||||
"horizon.studio.record.usage": "Uso: /studio record <start [nombre]|stop|status>",
|
||||
"horizon.studio.record.started": "Grabación de Studio iniciada: %s",
|
||||
"horizon.studio.record.saved": "Grabación de Studio guardada: %s",
|
||||
"horizon.studio.record.error": "Error de grabación de Studio: %s",
|
||||
"horizon.studio.record.status.active": "Grabación de Studio activa — %s s, %s paquetes, %s KB (%s)",
|
||||
"horizon.studio.record.status.inactive": "Grabación de Studio inactiva",
|
||||
"horizon.pages.mods": "Mods",
|
||||
"horizon.pages.shader_packs": "Paquetes de shaders",
|
||||
"horizon.pages.shader_options": "Opciones de shaders",
|
||||
"horizon.pages.controller": "Ajustes del mando",
|
||||
"horizon.pages.controller_binds": "Asignaciones del mando",
|
||||
"horizon.pages.extras": "Extras de Horizon",
|
||||
"horizon.pages.settings": "Ajustes de Horizon",
|
||||
"horizon.pages.studio": "Replay Studio",
|
||||
"horizon.pages.agent": "Agente de IA",
|
||||
"horizon.split.leave": "Salir",
|
||||
"horizon.pages.split_settings": "Ajustes de pantalla dividida",
|
||||
"horizon.pages.split_profiles": "¿Quién juega?",
|
||||
"horizon.pages.split_setup": "Configuración de pantalla dividida",
|
||||
"horizon.pages.music": "Música",
|
||||
"horizon.pages.music.menu": "Menú",
|
||||
"horizon.pages.music.overworld": "Superficie",
|
||||
"horizon.pages.music.creative": "Creativo",
|
||||
"horizon.pages.music.underwater": "Bajo el agua",
|
||||
"horizon.pages.music.end": "El End",
|
||||
"horizon.pages.music.boss": "Jefe",
|
||||
"horizon.pages.music.credits": "Créditos",
|
||||
"horizon.pages.music.overworld_biomes": "Biomas de la Superficie",
|
||||
"horizon.pages.music.nether_biomes": "Biomas del Inframundo",
|
||||
"horizon.pages.music.end_biomes": "Biomas del End",
|
||||
"horizon.pages.music.other": "Otra música",
|
||||
"horizon.pages.music.empty": "No hay pistas de música: no se han cargado los sonidos del juego.",
|
||||
"horizon.pages.music.tooltip": "Las pistas integradas, por situación: reproduce una ahora o desactiva cualquiera para que el juego deje de elegirla.",
|
||||
"horizon.pages.settings.tooltip": "Los ajustes propios de Horizon: gráficos, mandos, extras y mods.",
|
||||
"horizon.pages.studio.tooltip": "Explora las grabaciones guardadas y sus metadatos.",
|
||||
"horizon.pages.graphics.tooltip": "Gráficos, sin complicaciones: cada ajuste dice lo que cuesta.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Todos los ajustes gráficos, incluidos los de Horizon, con lo que cuesta cada uno.",
|
||||
"horizon.pages.controller.tooltip": "Mandos: sus botones, palancas, vibración y navegación de menús.",
|
||||
"horizon.pages.extras.tooltip": "Añadidos opcionales, todos desactivados por defecto: el juego sigue siendo vanilla hasta que actives uno.",
|
||||
"horizon.pages.agent.tooltip": "Deja que tu propio agente de IA juegue: horizon-ctl y clientes MCP en este equipo.",
|
||||
"horizon.pages.split_profiles.tooltip": "Quién juega: un perfil guardado en esta carpeta del juego.",
|
||||
"horizon.pages.split_setup.tooltip": "Las ventanas y los monitores, y quién juega dónde.",
|
||||
"horizon.pages.shader_packs.tooltip": "Paquetes de shaders de la carpeta shaderpacks: los propios de Horizon y los del formato shaders/. Se ejecuta uno a la vez.",
|
||||
"horizon.pages.mods.tooltip": "Mods hechos para Horizon, de la carpeta horizon-mods. Cada uno se ejecuta en un entorno aislado y sigue apagado hasta que lo actives.",
|
||||
"horizon.pages.open.graphics": "Gráficos...",
|
||||
"horizon.pages.open.graphics_advanced": "Gráficos avanzados...",
|
||||
"horizon.pages.open.shader_packs": "Paquetes de shaders...",
|
||||
"horizon.pages.open.controller": "Ajustes del mando...",
|
||||
"horizon.pages.open.split_setup": "Pantalla dividida...",
|
||||
"horizon.pages.open.extras": "Extras...",
|
||||
"horizon.pages.open.mods": "Mods...",
|
||||
"horizon.pages.open.agent": "Agente de IA...",
|
||||
"horizon.pages.open.horizon_graphics": "Gráficos de Horizon...",
|
||||
"horizon.pages.open.controller_binds": "Asignaciones del mando...",
|
||||
"horizon.pages.open.horizon_settings": "Ajustes de Horizon...",
|
||||
"horizon.pages.open.studio": "Replay Studio...",
|
||||
"horizon.studio.saved_recordings": "Grabaciones guardadas",
|
||||
"horizon.studio.empty": "No se han encontrado grabaciones en la carpeta de grabaciones.",
|
||||
"horizon.studio.empty_hint": "Usa /studio record start <nombre> en un mundo o deja archivos .mcpr allí.",
|
||||
"horizon.studio.open_folder": "Abrir grabaciones",
|
||||
"horizon.studio.refresh": "Actualizar",
|
||||
"horizon.studio.unknown_server": "Desconocido",
|
||||
"horizon.pages.open.music": "Música...",
|
||||
"horizon.mods.empty": "No hay mods: pon archivos .hmod en la carpeta horizon-mods",
|
||||
"horizon.mods.stopped": " (Detenido)",
|
||||
"horizon.mods.not_built": " (Sin compilar)",
|
||||
"horizon.mods.dev": " (Desarrollo)",
|
||||
"horizon.mods.settings": "Ajustes",
|
||||
"horizon.mods.in_world": " (sal del mundo para cambiarlo)",
|
||||
"horizon.mods.open_folder": "Abrir carpeta de mods",
|
||||
"horizon.mods.browse": "Explorar mods...",
|
||||
"horizon.mods.browse.title": "Explorar mods",
|
||||
"horizon.mods.browse.search": "Buscar mods...",
|
||||
"horizon.mods.browse.project": "Mod",
|
||||
"horizon.mods.browse.loading": "Leyendo el registro de mods de Horizon...",
|
||||
"horizon.mods.browse.failed": "No se pudo leer el registro de mods: %s",
|
||||
"horizon.mods.browse.none": "No se ha encontrado ningún mod",
|
||||
"horizon.mods.browse.has_update": " (actualización)",
|
||||
"horizon.mods.browse.is_installed": " (instalado)",
|
||||
"horizon.mods.browse.by": "de %s",
|
||||
"horizon.mods.browse.installing": "Instalando...",
|
||||
"horizon.mods.browse.install": "Instalar",
|
||||
"horizon.mods.browse.update": "Actualizar",
|
||||
"horizon.mods.browse.installed": "Instalado",
|
||||
"horizon.mods.browse.open_page": "Abrir página",
|
||||
"horizon.mods.browse.enabled": "Mod",
|
||||
"horizon.mods.browse.changelog": "Registro de cambios",
|
||||
"horizon.mods.browse.done": "%s %s se ha instalado y está activado.",
|
||||
"horizon.mods.browse.install_failed": "No se pudo instalar: %s",
|
||||
"horizon.mods.switch.worldgen": "cambia la generación del mundo",
|
||||
"horizon.mods.switch.registries": "añade bloques, objetos u otras entradas del registro",
|
||||
"horizon.mods.switch.restart": "Reinicia el juego para aplicar %s: añaden bloques, objetos o generación del mundo.",
|
||||
"horizon.shader_options.profile": "Perfil",
|
||||
"horizon.agent.copy_details": "Copiar datos de conexión",
|
||||
"horizon.multiplayer.join": "Unirse",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Cancelar reconexión",
|
||||
@@ -64,6 +131,7 @@
|
||||
"LOD Distance": "Distancia LOD",
|
||||
"Far Detail": "Detalle lejano",
|
||||
"Cave Culling": "Oclusión de cuevas",
|
||||
"GPU Culling": "Descarte en GPU",
|
||||
"Entity Culling": "Oclusión de entidades",
|
||||
"Chunk Updates": "Actualizaciones de chunks",
|
||||
"Upload Budget": "Presupuesto de subida",
|
||||
@@ -81,8 +149,13 @@
|
||||
"Peak Brightness": "Brillo máximo",
|
||||
"Black Level": "Nivel de negro",
|
||||
"UI Brightness": "Brillo de la interfaz",
|
||||
"HDR Lighting": "Iluminación HDR",
|
||||
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Las fuentes de luz, el sol y la luz solar brillan más allá del blanco. Desactivado: como SDR.",
|
||||
"Smooth Gradients": "Degradados suaves",
|
||||
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "Fotograma de 16 bits: los cielos y las sombras oscuros no se escalonan. Algo más de VRAM. Próximo inicio.",
|
||||
"Battery Mode": "Modo batería",
|
||||
"Graphics API": "API de gráficos",
|
||||
"Translucent Batching": "Agrupación de translúcidos",
|
||||
"Performance Overlay": "Superposición de rendimiento",
|
||||
"Full Detail Distance": "Distancia de detalle completo",
|
||||
"On": "Activado",
|
||||
@@ -121,8 +194,12 @@
|
||||
"Done": "Hecho",
|
||||
"This feature is not yet available in Horizon.": "Esta función aún no está disponible en Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Tu pantalla o sistema no ofrece salida HDR.",
|
||||
"Only applies to asynchronous entity culling.": "Solo se aplica a la oclusión asíncrona de entidades.",
|
||||
"Turn HDR on to use it.": "Activa el HDR para usarlo.",
|
||||
"Your GPU or graphics API doesn't support it.": "Tu GPU o API de gráficos no lo admite.",
|
||||
"Not with software rendering, which runs it on the CPU.": "No con renderizado por software, que lo ejecuta en la CPU.",
|
||||
"Out of video memory: off until the next start.": "Sin memoria de vídeo: desactivado hasta el próximo inicio.",
|
||||
"The graphics device was lost: off until the next start.": "Se ha perdido el dispositivo gráfico: desactivado hasta el próximo inicio.",
|
||||
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Aún no en DirectX 12: prueba la API de gráficos Vulkan.",
|
||||
"%s chunks": "%s chunks",
|
||||
"%s per frame": "%s por fotograma",
|
||||
"%s MiB/frame": "%s MiB/fotograma",
|
||||
@@ -145,6 +222,7 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Hasta dónde siguen funcionando mobs, cultivos y máquinas. Tiempo de CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "A qué distancia se dibujan mobs y objetos, como proporción de lo normal.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Omite el terreno oculto tras secciones cerradas. Desactivarlo cuesta mucho.",
|
||||
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Descarta el terreno cercano en la GPU: menos CPU con mucha distancia de renderizado, más GPU.",
|
||||
"Hides entities behind opaque blocks.": "Oculta entidades tras bloques opacos.",
|
||||
"No entity occlusion culling.": "Sin oclusión de entidades.",
|
||||
"CPU occlusion test runs this frame.": "La CPU comprueba la oclusión en este fotograma.",
|
||||
@@ -182,21 +260,15 @@
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "Límite de 60 FPS (30 en menús o sin foco), la mitad de los hilos: menos consumo.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Hilos que generan, iluminan y mallan chunks. Próximo inicio.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan y, donde falla, DirectX 12 (Windows) u OpenGL (Linux). Próximo inicio.",
|
||||
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Agua y cristal en un mismo búfer: menos llamadas de dibujado, algo más de VRAM. Próximo inicio.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Datos de malla enviados a la GPU por fotograma. Menos es más fluido durante la carga.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Fotogramas por segundo y tiempos de fotograma sobre el juego, con un gráfico.",
|
||||
"horizon.multiplayer.finding_friend": "Buscando el mundo de tu amigo…",
|
||||
"horizon.multiplayer.not_signed_in": "Este servidor está en modo en línea. Inicia sesión con tu cuenta de Microsoft en el launcher de Horizon para unirte.",
|
||||
"horizon.realms.not_signed_in": "Inicia sesión con tu cuenta de Microsoft en el launcher de Horizon para jugar en Realms.",
|
||||
"horizon.multiplayer.invalid_address": "Dirección de servidor no válida: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Conectado directamente con tu amigo…",
|
||||
"horizon.multiplayer.connected_friend_relay": "Conectado con tu amigo a través del relé…",
|
||||
"horizon.pages.shader_options": "Opciones de sombreadores",
|
||||
"horizon.pages.split_profiles": "¿Quién juega?",
|
||||
"horizon.pages.split_profiles.tooltip": "Quién juega: un perfil guardado en esta carpeta del juego.",
|
||||
"horizon.shader_options.profile": "Perfil",
|
||||
"Particle Culling": "Particle Culling",
|
||||
"Draws every particle.": "Draws every particle.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles.": "Skips offscreen, subpixel and terrain-hidden particles.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.",
|
||||
"General": "General",
|
||||
"Advanced": "Avanzado",
|
||||
"Undo": "Deshacer",
|
||||
@@ -223,6 +295,317 @@
|
||||
"Brighter-than-white output on an HDR display.": "Salida más brillante que el blanco en una pantalla HDR.",
|
||||
"Results for \"%s\"": "Resultados de \"%s\"",
|
||||
"No settings match.": "Ningún ajuste coincide.",
|
||||
"horizon.title.not_affiliated": "No está afiliado a Mojang ni a Microsoft",
|
||||
"horizon.extras.header.lighting": "Iluminación",
|
||||
"horizon.extras.header.third_person": "Tercera persona",
|
||||
"horizon.extras.header.particles": "Partículas",
|
||||
"horizon.extras.header.inventory": "Inventario",
|
||||
"horizon.extras.header.chat": "Chat",
|
||||
"horizon.extras.header.multiplayer": "Multijugador",
|
||||
"horizon.extras.header.sound": "Sonido",
|
||||
"horizon.extras.header.menus": "Menús",
|
||||
"horizon.extras.fullbright": "Fullbright",
|
||||
"horizon.extras.dynamic_lights": "Luces dinámicas",
|
||||
"horizon.extras.own_name_tag": "Etiqueta con tu nombre",
|
||||
"horizon.extras.hide_block_particles": "Ocultar partículas al romper bloques",
|
||||
"horizon.extras.auto_reconnect": "Reconexión automática",
|
||||
"horizon.extras.ui_sound_reserve": "Reservar canales para los sonidos de la interfaz",
|
||||
"horizon.extras.music_page": "Página de música",
|
||||
"horizon.extras.mouse_tweaks": "Controles de Mouse Tweaks",
|
||||
"horizon.extras.mouse_tweaks_wheel": "La rueda mueve objetos",
|
||||
"horizon.extras.sort": "Botones de ordenar",
|
||||
"horizon.extras.sort_key": "Tecla de ordenar",
|
||||
"horizon.extras.no_ai": "Sin IA",
|
||||
"horizon.extras.agent_control": "Control por agente",
|
||||
"horizon.extras.discord_presence": "Presencia enriquecida de Discord",
|
||||
"horizon.extras.zoom": "Zoom",
|
||||
"horizon.extras.zoom_mod_install": "Zoom: instálalo desde Mods",
|
||||
"horizon.extras.splitscreen": "Pantalla dividida",
|
||||
"horizon.extras.chat_animation": "Chat fluido",
|
||||
"horizon.extras.chat_indicator": "Indicador de mensajes",
|
||||
"horizon.extras.wheel_cycles_list_settings.tooltip": "La rueda sobre un botón de una lista de ajustes cambia su valor. Desactivado, como en vanilla, la rueda desplaza la lista.",
|
||||
"horizon.extras.wheel_cycles_settings": "La rueda cambia los ajustes",
|
||||
"horizon.extras.pack_browser": "Explorador de paquetes de recursos",
|
||||
"horizon.extras.fullbright.tooltip": "Muestra bloques y entidades con luz máxima, incluso en las zonas oscuras.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Las antorchas y otras fuentes de luz que llevas, que has soltado o que arden iluminan los bloques a su alrededor.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Muestra tu nombre sobre tu cabeza en tercera persona, como lo ven los demás jugadores. Se oculta cuando el servidor oculta los nombres de tu equipo o cuando eres invisible.",
|
||||
"horizon.extras.hide_block_particles.tooltip": "Oculta las partículas de escombros que salen de un bloque roto y se desprenden del que minas. Las demás partículas se mantienen.",
|
||||
"horizon.extras.auto_reconnect.tooltip": "Vuelve a un servidor tras perder la conexión o tras un reinicio, nunca tras una expulsión o un baneo.",
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "Los sonidos del mundo dejan libres 16 de los 247 canales de sonido, para que una avalancha de ellos (una gran explosión) no silencie los clics de los menús.",
|
||||
"horizon.extras.music_page.tooltip": "Música y sonidos gana una página Música...: reproduce cualquier pista integrada ahora o desactiva una para que el juego deje de elegirla. Nada cambia hasta que desactives una pista.",
|
||||
"horizon.extras.mouse_tweaks.tooltip": "Arrastrar con el botón derecho coloca uno por ranura, arrastrar con el izquierdo reúne el objeto que llevas y Mayús + arrastrar con el izquierdo mueve todas las ranuras.",
|
||||
"horizon.extras.mouse_tweaks_wheel.tooltip": "Abajo envía un objeto de la ranura al otro lado; arriba devuelve uno.",
|
||||
"horizon.extras.sort.tooltip": "Ordena el inventario, los cofres, los barriles, los cofres del End y las cajas de shulker en el orden de las pestañas del modo creativo, combinando pilas.",
|
||||
"horizon.extras.sort_key.tooltip": "Ordena la parte que hay bajo el cursor. Haz clic y luego pulsa una tecla; Escape la desasigna.",
|
||||
"horizon.extras.no_ai.tooltip": "Oculta y desactiva todas las funciones de IA: el control por agente, horizon-ctl y los servidores MCP. También el interruptor del launcher.",
|
||||
"horizon.extras.agent_control.tooltip": "Tu propio agente de IA puede jugar a este juego mediante horizon-ctl o un cliente MCP, solo en este equipo.",
|
||||
"horizon.extras.discord_presence.tooltip": "Muestra «Jugando a Horizon» en tu perfil de Discord mientras Discord está abierto: menús, un jugador o multijugador, la dimensión y el tiempo jugado. Nunca el nombre de un mundo ni la dirección de un servidor.",
|
||||
"Calibrate...": "Calibrar...",
|
||||
"Calibrate Anyway...": "Calibrar de todos modos...",
|
||||
"Use Automatic Setup": "Usar la configuración automática",
|
||||
"Shortcut: %s": "Atajo: %s",
|
||||
"Next": "Siguiente",
|
||||
"HDR Calibration %s/%s: %s": "Calibración HDR %s/%s: %s",
|
||||
"Sets the three below full screen, one test picture at a time.": "Configura las tres opciones siguientes a pantalla completa, con una imagen de prueba cada vez.",
|
||||
"A key that opens this screen from the game. Not bound at first.": "Una tecla que abre esta pantalla desde el juego. Sin asignar al principio.",
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.": "Tu pantalla no indicó cuánto brilla: calíbrala para configurarla.",
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong.": "Ya está configurado para tu pantalla. Calibra solo si el HDR se ve mal.",
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.": "Devuelve Brillo máximo y Blanco de papel a la configuración de tu pantalla.",
|
||||
"Set up for your display. Leave on Auto unless it looks wrong.": "Configurado para tu pantalla. Déjalo en Automático salvo que se vea mal.",
|
||||
"Lower it until the logo just disappears into the black.": "Bájalo hasta que el logo acabe de desaparecer en el negro.",
|
||||
"Raise it until the logo just disappears into the bright square.": "Súbelo hasta que el logo acabe de desaparecer en el cuadrado brillante.",
|
||||
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Haz que la tarjeta sea un blanco cómodo de mirar durante mucho tiempo, con la luz con la que juegas.",
|
||||
"horizon.splitscreen.profiles.forget": "Olvidar",
|
||||
"horizon.splitscreen.profiles.create": "Crear",
|
||||
"horizon.splitscreen.profiles.you": " - tú",
|
||||
"horizon.splitscreen.profiles.forget_named": "Olvidar a %s",
|
||||
"horizon.splitscreen.profiles.previous": "Anterior",
|
||||
"horizon.splitscreen.profiles.next": "Siguiente",
|
||||
"horizon.splitscreen.profiles.new_button": "Nuevo perfil...",
|
||||
"horizon.splitscreen.profiles.forget_title": "¿Olvidar el perfil?",
|
||||
"horizon.splitscreen.profiles.forget_leaves": "Sale de la lista de esta instancia.",
|
||||
"horizon.splitscreen.profiles.forget_kept": "Se conservan el progreso del mundo y los ajustes.",
|
||||
"horizon.splitscreen.profiles.new_title": "Nuevo perfil",
|
||||
"horizon.splitscreen.profiles.name_hint": "Nombre (de 3 a 16 letras, dígitos, _)",
|
||||
"horizon.splitscreen.profiles.switch_title": "Cambiar de perfil",
|
||||
"horizon.splitscreen.profiles.offline_note": "Los perfiles sin conexión pertenecen a esta instancia",
|
||||
"horizon.splitscreen.profiles.move": "Mover dónde juegas",
|
||||
"horizon.splitscreen.profiles.move_place": "Mover dónde juegas: %s",
|
||||
"horizon.friends.title": "Jugar con amigos",
|
||||
"horizon.friends.hint": "Tus amigos pegan el código tras el botón Amigos de la pantalla de título, en Conexión directa o en la página Jugar del launcher.",
|
||||
"horizon.friends.copied": "Copiado",
|
||||
"horizon.friends.copy_code": "Copiar código",
|
||||
"horizon.friends.stop_sharing": "Dejar de compartir",
|
||||
"horizon.friends.none_yet": "Aún no hay amigos aquí.",
|
||||
"horizon.friends.here_now": "Aquí ahora: %s",
|
||||
"horizon.friends.share_intro": "Comparte este mundo con un código de invitación: se abre a la LAN y tus amigos de internet se unen con el código. No interviene ningún servidor nuestro.",
|
||||
"horizon.friends.start_sharing": "Empezar a compartir",
|
||||
"horizon.friends.troubleshooting": "Solución de problemas...",
|
||||
"horizon.friends.stopped": "Se ha dejado de compartir el código de invitación.",
|
||||
"horizon.friends.short_code_changed": "Alguien intentó varias veces un código corto incorrecto, así que ha cambiado.",
|
||||
"horizon.friends.wrong_code": "Alguien ha probado un código incorrecto.",
|
||||
"horizon.friends.relay_stopped": "Demasiados códigos cortos incorrectos: el relé queda apagado hasta que vuelvas a compartir. Tus amigos aún pueden unirse con el código completo.",
|
||||
"horizon.friends.code_ready": "Tu código de invitación está listo: Menú del juego, Jugar con amigos. Cópialo y envíaselo a tus amigos.",
|
||||
"horizon.friends.share_failed": "No se pudo compartir el mundo: %s",
|
||||
"horizon.friends.finding_route": "Buscando cómo pueden llegar tus amigos hasta ti…",
|
||||
"horizon.osk.shift": "Mayús",
|
||||
"horizon.osk.space": "Espacio",
|
||||
"horizon.osk.delete": "Borrar",
|
||||
"horizon.osk.type": "Escribir",
|
||||
"horizon.osk.symbols": "Símbolos",
|
||||
"horizon.osk.close": "Cerrar",
|
||||
"horizon.extras.chat_animation.tooltip": "Cada línea nueva del chat entra deslizándose desde la izquierda mientras aparece, en lugar de aparecer entera.",
|
||||
"horizon.extras.chat_indicator.tooltip": "La barra de color a la izquierda de cada línea del chat.",
|
||||
"horizon.extras.pack_browser.tooltip": "Seleccionar paquetes de recursos gana un botón Añadir que busca en Modrinth e instala un paquete en tu carpeta de paquetes. Desactivado, el juego nunca contacta con Modrinth.",
|
||||
"horizon.extras.splitscreen.tooltip": "Dentro de un mundo, otro mando puede unirse como jugador nuevo: pulsa A cuando lo pida y luego elige un perfil y dónde jugar.",
|
||||
"horizon.splitscreen.setup.monitor": "Monitor %s",
|
||||
"horizon.splitscreen.setup.monitor_unknown": "un monitor",
|
||||
"horizon.splitscreen.setup.window": "Ventana %s",
|
||||
"horizon.splitscreen.setup.new_window": "Nueva ventana",
|
||||
"horizon.splitscreen.setup.fill_monitor": "Llenar %s",
|
||||
"horizon.splitscreen.setup.done": "Hecho",
|
||||
"horizon.splitscreen.setup.switch": "%s: %s",
|
||||
"horizon.splitscreen.setup.on": "Activada",
|
||||
"horizon.splitscreen.setup.off": "Desactivada",
|
||||
"horizon.splitscreen.setup.player": "Jugador %s",
|
||||
"horizon.splitscreen.setup.revert": "Revertir",
|
||||
"horizon.splitscreen.setup.apply": "Aplicar",
|
||||
"horizon.splitscreen.setup.warning.empty_main": "Nadie juega en la ventana 1: se oculta",
|
||||
"horizon.splitscreen.setup.warning.empty_close": "Nadie juega en la ventana %s: se cerrará al aplicar",
|
||||
"horizon.splitscreen.setup.warning.empty_new": "Nadie juega en la ventana %s: no se abrirá",
|
||||
"horizon.splitscreen.setup.warning.on_main": "La ventana %s ocupa el monitor de la ventana 1: sus jugadores juegan en la ventana 1",
|
||||
"horizon.splitscreen.setup.warning.unplugged": "El monitor de la ventana %s no está conectado: sus jugadores juegan en la ventana 1",
|
||||
"horizon.splitscreen.setup.status.off": "La pantalla dividida está desactivada.",
|
||||
"horizon.splitscreen.setup.status.enable_hint": "Actívala y luego pulsa un botón en otro mando dentro de un mundo.",
|
||||
"horizon.splitscreen.setup.status.join_hint": "Pulsa A para unirte y elegir un perfil.",
|
||||
"horizon.splitscreen.setup.status.idle": "Pantalla dividida inactiva",
|
||||
"horizon.splitscreen.setup.status.alone": "No hay otros jugadores locales.",
|
||||
"horizon.splitscreen.setup.status.solo_controls": "Los controles individuales y la respuesta del mando no cambian.",
|
||||
"horizon.splitscreen.setup.window_empty": "Ventana %s: vacía",
|
||||
"horizon.splitscreen.setup.note.select": "Haz clic en un jugador o arrástralo a una ventana o a un monitor.",
|
||||
"horizon.splitscreen.setup.note.apply": "Aplica para jugar así; Revertir para deshacer.",
|
||||
"horizon.splitscreen.setup.note.place": "¿Dónde debería jugar %s?",
|
||||
"horizon.splitscreen.setup.this_player": "este jugador",
|
||||
"horizon.splitscreen.setup.switch_disabled": "La pantalla dividida se desactiva cuando los demás se han ido (desde su menú de pausa).",
|
||||
"horizon.splitscreen.setup.window_place": "Ventana %s (%s de %s)",
|
||||
"horizon.splitscreen.setup.names": "Nombres",
|
||||
"horizon.splitscreen.setup.identify": "Identificar",
|
||||
"horizon.splitscreen.setup.layout.two": "2 compartiendo: %s",
|
||||
"horizon.splitscreen.setup.layout.three": "3 compartiendo: %s",
|
||||
"horizon.splitscreen.setup.layout.two.0": "Automático",
|
||||
"horizon.splitscreen.setup.layout.two.1": "Lado a lado",
|
||||
"horizon.splitscreen.setup.layout.two.2": "Arriba y abajo",
|
||||
"horizon.splitscreen.setup.layout.three.0": "Uno ancho arriba",
|
||||
"horizon.splitscreen.setup.layout.three.1": "Uno alto a la izquierda",
|
||||
"horizon.splitscreen.setup.layout.three.2": "Lado a lado",
|
||||
"horizon.splitscreen.setup.help.player": "Haz clic o arrastra a %s para moverlo.",
|
||||
"horizon.splitscreen.setup.help.layout_auto": "%s (lado a lado cuando la ventana es ancha). Pulsa para cambiar.",
|
||||
"horizon.splitscreen.setup.help.layout": "%s. Pulsa para cambiar.",
|
||||
"horizon.splitscreen.setup.help.switch": "Pantalla dividida en este equipo, activada o desactivada.",
|
||||
"horizon.splitscreen.setup.help.identify": "La vista de cada jugador muestra en qué ventana está.",
|
||||
"horizon.splitscreen.setup.help.names": "El nombre de cada jugador en la esquina de su vista.",
|
||||
"horizon.splitscreen.setup.help.revert": "Deshace los cambios aún no aplicados.",
|
||||
"horizon.splitscreen.setup.help.apply": "Juega como muestra el mapa.",
|
||||
"horizon.splitscreen.setup.help.unchanged": "Aún no hay cambios.",
|
||||
"horizon.splitscreen.setup.give_up_pad": "Ceder el mando",
|
||||
"horizon.splitscreen.prompt.controller_connected": "Mando conectado",
|
||||
"horizon.splitscreen.prompt.controller_disconnected": "Mando desconectado",
|
||||
"horizon.splitscreen.prompt.controller_disconnected.hint": "Vuelve a conectarlo o pulsa un botón en otro",
|
||||
"horizon.splitscreen.prompt.join.one": "%s quiere unirse",
|
||||
"horizon.splitscreen.prompt.join.many": "%s mandos quieren unirse",
|
||||
"horizon.splitscreen.prompt.join.yes": "Unirse",
|
||||
"horizon.splitscreen.prompt.join.as_p1": "Jugar como J1",
|
||||
"horizon.splitscreen.prompt.join.no": "No",
|
||||
"horizon.splitscreen.prompt.lost": "%s: mando desconectado",
|
||||
"horizon.splitscreen.prompt.lost.use_another": "Usar otro",
|
||||
"horizon.splitscreen.prompt.lost.leave": "Salir",
|
||||
"horizon.splitscreen.setup.help.give_up_pad": "Tu mando deja de controlarte y se ofrece para unirse (A).",
|
||||
"horizon.splitscreen.setup.note.held_at": "%s a %s: A lo suelta aquí, B lo devuelve.",
|
||||
"horizon.splitscreen.setup.note.held": "%s recogido: izquierda/derecha eligen dónde, A lo suelta, B lo devuelve.",
|
||||
"horizon.splitscreen.online_only": "Este servidor está en modo en línea: los perfiles sin conexión no pueden unirse; cada jugador necesita su propia cuenta de Minecraft.",
|
||||
"horizon.splitscreen.own_account": "Este servidor requiere la cuenta de Minecraft de cada jugador",
|
||||
"horizon.splitscreen.local_only": "Otros jugadores solo pueden unirse a este servidor con su propia cuenta de Minecraft: los perfiles sin conexión juegan en tu mundo, en LAN o en un servidor de tu red local.",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Oculta modelos tras bloques opacos. Asíncrono comprueba fuera del hilo; las entidades se actualizan.",
|
||||
"Block Entities": "Entidades de bloque",
|
||||
"Shulker Boxes": "Cajas de shulker",
|
||||
"Reuses settled boxes; lid movement stays animated.": "Reutiliza las cajas quietas; el movimiento de la tapa sigue animado.",
|
||||
"Signs": "Carteles",
|
||||
"Reuses sign text until text, light, or font changes.": "Reutiliza el texto de los carteles hasta que cambian el texto, la luz o la fuente.",
|
||||
"Decorated Pots": "Vasijas decoradas",
|
||||
"Reuses still pots; break wobble and sherd changes redraw.": "Reutiliza las vasijas quietas; el temblor al romperlas y los cambios de fragmentos se redibujan.",
|
||||
"Bells": "Campanas",
|
||||
"Reuses still bells; ringing stays animated.": "Reutiliza las campanas quietas; el repique sigue animado.",
|
||||
"Chests": "Cofres",
|
||||
"Reuses closed chests; moving lids stay animated.": "Reutiliza los cofres cerrados; las tapas en movimiento siguen animadas.",
|
||||
"Banners": "Estandartes",
|
||||
"Reuses poles; flags and pattern layers still update.": "Reutiliza los mástiles; las banderas y las capas del estampado siguen actualizándose.",
|
||||
"Copper Golem Statues": "Estatuas de gólem de cobre",
|
||||
"Reuses statue geometry until its block state changes.": "Reutiliza la geometría de la estatua hasta que cambia el estado de su bloque.",
|
||||
"Leaf Culling": "Descarte de hojas",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "Todas las caras de las hojas. Las hojas detalladas de vanilla.",
|
||||
"Distant Plant Dither": "Tramado de plantas lejanas",
|
||||
"Keeps fine plants in high-res packs from fading far away.": "Evita que las plantas finas de los paquetes de alta resolución se difuminen a lo lejos.",
|
||||
"Screenshot Quality": "Calidad de las capturas",
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Capturas con F2 a este múltiplo de la resolución del juego. Más alto tarda más.",
|
||||
"Frame Generation": "Generación de fotogramas",
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Muestra un fotograma inventado entre cada dos reales: más fluido, algo más de latencia.",
|
||||
"Shadow Cache": "Caché de sombras",
|
||||
"Shaders": "Shaders",
|
||||
"Shader packs, Horizon's and shaders/ folder ones, and their options.": "Paquetes de shaders, los de Horizon y los de la carpeta shaders/, y sus opciones.",
|
||||
"Shader packs": "Paquetes de shaders",
|
||||
"Installed": "Instalados",
|
||||
"Apply": "Aplicar",
|
||||
"Reset all options": "Restablecer todas las opciones",
|
||||
"A Horizon shader pack.": "Un paquete de shaders de Horizon.",
|
||||
"An OptiFine or Iris shader pack.": "Un paquete de shaders de OptiFine o Iris.",
|
||||
"In use: press to turn shaders off.": "En uso: pulsa para desactivar los shaders.",
|
||||
"Press to use it.": "Pulsa para usarlo.",
|
||||
"Put shader pack .zip files or folders in it.": "Pon en ella archivos .zip o carpetas de paquetes de shaders.",
|
||||
"Puts every option of this pack back to its default.": "Devuelve todas las opciones de este paquete a sus valores por defecto.",
|
||||
"Sets many options at once, as the pack groups them.": "Ajusta muchas opciones a la vez, tal como las agrupa el paquete.",
|
||||
"No shader packs yet: open the pack folder and put some in it.": "Aún no hay paquetes de shaders: abre la carpeta de paquetes y pon algunos en ella.",
|
||||
"Pick a shader pack. Picking the one in use turns shaders off.": "Elige un paquete de shaders. Elegir el que está en uso desactiva los shaders.",
|
||||
"Changed options apply with Apply, or when you leave.": "Las opciones cambiadas se aplican con Aplicar, o al salir.",
|
||||
"Pack settings": "Ajustes del paquete",
|
||||
"Built-in settings": "Ajustes integrados",
|
||||
"%s settings": "Ajustes de %s",
|
||||
"Settings made by the pack's author: they change only this pack.": "Ajustes creados por el autor del paquete: solo cambian este paquete.",
|
||||
"Vanilla's own preset: sets only vanilla's options, as Minecraft does.": "El preajuste de vanilla: ajusta solo las opciones de vanilla, como hace Minecraft.",
|
||||
"Fullscreen takes the display over, at the mode below.": "La pantalla completa se apodera del monitor, con el modo de abajo.",
|
||||
"The display mode exclusive fullscreen uses. Applies when you leave.": "El modo de pantalla que usa la pantalla completa exclusiva. Se aplica al salir.",
|
||||
"Blurs the world behind menus.": "Desenfoca el mundo tras los menús.",
|
||||
"How textures are sampled: sharper at a slant with Anisotropic.": "Cómo se muestrean las texturas: más nítidas en ángulo con Anisotrópico.",
|
||||
"How far Anisotropic filtering goes. Higher is sharper at a slant.": "Hasta dónde llega el filtrado anisotrópico. Más alto es más nítido en ángulo.",
|
||||
"Only with Anisotropic texture filtering.": "Solo con filtrado anisotrópico de texturas.",
|
||||
"Shader Packs...": "Paquetes de shaders...",
|
||||
"Picks a shader pack, Horizon's or a shaders/ folder one, and sets its options.": "Elige un paquete de shaders, de Horizon o de la carpeta shaders/, y ajusta sus opciones.",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Reutiliza el mapa de sombras del paquete cuando coinciden el sol, la vista y el mundo. Desactivado por defecto.",
|
||||
"Find BlueMap": "Buscar BlueMap",
|
||||
"On servers, finds their BlueMap web map for far terrain. Next join.": "En servidores, busca su mapa web BlueMap para el terreno lejano. Próxima conexión.",
|
||||
"BlueMap Radius": "Radio de BlueMap",
|
||||
"How far around you BlueMap tiles are downloaded. Next join.": "Hasta dónde se descargan los mosaicos de BlueMap a tu alrededor. Próxima conexión.",
|
||||
"Synchronous": "Síncrono",
|
||||
"Asynchronous": "Asíncrono",
|
||||
"Hidden Faces": "Caras ocultas",
|
||||
"Drops leaf faces behind two leaf layers: faster, same crowns.": "Descarta las caras de hojas que hay tras dos capas de hojas: más rápido, las mismas copas.",
|
||||
"Solid": "Sólido",
|
||||
"Solid Aggressive": "Sólido agresivo",
|
||||
"Hollow": "Hueco",
|
||||
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Las hojas totalmente dentro de una copa se dibujan sólidas: menos capas transparentes.",
|
||||
"Leaves with one open side draw solid too: faster, chunkier edges.": "Las hojas con un lado abierto también se dibujan sólidas: más rápido, bordes más toscos.",
|
||||
"Drops faces between leaves: faster, but gaps look through.": "Descarta las caras entre hojas: más rápido, pero se ve a través de los huecos.",
|
||||
"Hotbar: Previous Slot": "Barra rápida: ranura anterior",
|
||||
"Hotbar: Next Slot": "Barra rápida: ranura siguiente",
|
||||
"horizon.agent.connected.one": "Conectado: 1 conexión de agente",
|
||||
"horizon.agent.connected.many": "Conectado: %s conexiones de agente",
|
||||
"horizon.agent.connected.one_ago": "Conectado: 1 conexión de agente, último comando hace %s s",
|
||||
"horizon.agent.connected.many_ago": "Conectado: %s conexiones de agente, último comando hace %s s",
|
||||
"horizon.agent.waiting": "Esperando a un agente.",
|
||||
"horizon.agent.off": "Desactivado: los agentes no pueden llegar a este juego.",
|
||||
"horizon.agent.session_opened": "Abierto para esta sesión por el programa que inició el juego.",
|
||||
"horizon.agent.socket": "Socket: %s (copia los datos para ver su ruta completa)",
|
||||
"horizon.agent.local_only": "Solo local: agentes en este equipo, nunca la red.",
|
||||
"%s nits": "%s nits",
|
||||
"Auto (%s nits)": "Automático (%s nits)",
|
||||
"%.2f nits": "%.2f nits",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Oculta modelos tras bloques opacos. Las entidades se actualizan. Desactivado con paquetes personalizados.",
|
||||
"Entity Culling Precheck": "Comprobación previa de oclusión de entidades",
|
||||
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Ejecuta la comprobación previa del AABB de los mobs tras esta cantidad de mobs cargados. Cero la desactiva.",
|
||||
"%s mobs": "%s mobs",
|
||||
"Only applies to asynchronous entity culling.": "Solo se aplica a la oclusión asíncrona de entidades.",
|
||||
"horizon.extras.repro_keys": "Atajos de grabación",
|
||||
"horizon.extras.repro_keys.tooltip": "F3+R inicia o detiene una grabación de repro, y F3+E escribe su paquete. Desactivado por defecto: una grabación copia el mundo.",
|
||||
"horizon.agent.record_session": "Iniciar sesión de grabación",
|
||||
"horizon.agent.record_stop": "Detener sesión de grabación",
|
||||
"horizon.agent.export_repro": "Exportar paquete de repro",
|
||||
"horizon.repro.started": "Grabación iniciada; F3+R la detiene y escribe el paquete.",
|
||||
"horizon.repro.stopped": "Grabación detenida: %s",
|
||||
"horizon.repro.bundle": "Paquete de repro: %s",
|
||||
"horizon.repro.error": "Repro: %s",
|
||||
"horizon.repro.no_recording": "Aún no hay ninguna grabación que exportar.",
|
||||
"horizon.repro.replay_started": "Reproducción iniciada: %s",
|
||||
"horizon.splitscreen.profiles.offline": " (Sin conexión)",
|
||||
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
|
||||
"horizon.splitscreen.profiles.sign_in": "Vuelve a iniciar sesión en el launcher",
|
||||
"horizon.splitscreen.profiles.online": "Microsoft en línea",
|
||||
"horizon.splitscreen.account_unavailable": "%s está sin conexión. Vuelve a iniciar sesión en el launcher y reinicia el juego.",
|
||||
"horizon.splitscreen.account_expired": "La sesión de Microsoft de %s ha caducado. Vuelve a iniciar sesión en el launcher y reinicia el juego.",
|
||||
"Particle Culling": "Descarte de partículas",
|
||||
"Draws every particle.": "Dibuja todas las partículas.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles.": "Omite las partículas fuera de pantalla, subpíxel y ocultas por el terreno.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Omite las partículas fuera de pantalla, subpíxel y ocultas por el terreno con menos trabajo del hilo de renderizado.",
|
||||
"horizon.pack.add": "+",
|
||||
"horizon.pack.addInfo": "Añadir un paquete de recursos desde Modrinth",
|
||||
"horizon.pack.addWorking": "Consultando Modrinth...",
|
||||
"horizon.pack.addNone": "No se han encontrado paquetes de recursos",
|
||||
"horizon.pack.addTitle": "Añadir un paquete de recursos",
|
||||
"horizon.pack.install": "Instalar",
|
||||
"horizon.pack.installing": "Instalando",
|
||||
"horizon.pack.installed": "Instalado",
|
||||
"horizon.pack.search": "Buscar",
|
||||
"horizon.pack.searchHint": "Buscar en Modrinth...",
|
||||
"horizon.pack.filters": "Filtros",
|
||||
"horizon.pack.filter.realistic": "Realista",
|
||||
"horizon.pack.filter.simplistic": "Sencillo",
|
||||
"horizon.pack.filter.vanillaLike": "Estilo vanilla",
|
||||
"horizon.pack.filter.x16": "16x",
|
||||
"horizon.pack.filter.x32": "32x",
|
||||
"horizon.pack.filter.x64": "64x",
|
||||
"horizon.pack.addFailed": "No se pudo añadir el paquete: %s",
|
||||
"horizon.extras.header.cosmetics": "Cosméticos",
|
||||
"horizon.extras.cosmetics_mine": "Mis cosméticos",
|
||||
"horizon.extras.cosmetics_mine.tooltip": "Oculto: todos los jugadores de Horizon te ven sin tus cosméticos, tú incluido. Es el mismo interruptor que el Vestuario del launcher.",
|
||||
"key.emote": "Rueda de emotes",
|
||||
"horizon.emote_wheel.title": "Emotes",
|
||||
"horizon.emote_wheel.empty": "Aún no hay emotes",
|
||||
"horizon.emote_wheel.help": "Suelta o haz clic para reproducir · 1–8 · Esc cierra",
|
||||
"horizon.extras.cosmetics_others": "Cosméticos de otros jugadores",
|
||||
"horizon.extras.cosmetics_others.tooltip": "Lo que llevan otros jugadores de Horizon: todo, solo el estilo de sus etiquetas de nombre o nada. Oculto salvo que tengas cosméticos, hasta que elijas. Oculto, el juego nunca pregunta qué llevan.",
|
||||
"horizon.extras.cosmetics_hide_fx": "Ocultar estelas y auras de otros jugadores",
|
||||
"horizon.extras.cosmetics_hide_fx.tooltip": "Las estelas y auras de otros jugadores permanecen ocultas; sus demás cosméticos se siguen viendo. Desactivado por defecto.",
|
||||
"horizon.extras.cosmetics.shown": "Mostrados",
|
||||
"horizon.extras.cosmetics.hidden": "Ocultos",
|
||||
"horizon.extras.cosmetics.name_tags": "Solo etiquetas de nombre",
|
||||
"horizon.moderation.duration.minute": "%s minuto",
|
||||
"horizon.moderation.duration.minutes": "%s minutos",
|
||||
"horizon.moderation.duration.hour": "%s hora",
|
||||
@@ -252,22 +635,266 @@
|
||||
"horizon.tempban.list.empty": "No hay baneos temporales",
|
||||
"horizon.tempban.list.count": "Hay %s baneo(s) temporal(es):",
|
||||
"horizon.tempban.list.entry": "%1$s fue baneado por %2$s %3$s (hasta %4$s)",
|
||||
"Shader Packs...": "Paquetes de shaders...",
|
||||
"Installed": "Instalados",
|
||||
"Apply": "Aplicar",
|
||||
"Reset all options": "Restablecer todas las opciones",
|
||||
"A Horizon shader pack.": "Un paquete de shaders de Horizon.",
|
||||
"An OptiFine or Iris shader pack.": "Un paquete de shaders de OptiFine o Iris.",
|
||||
"In use: press to turn shaders off.": "En uso: pulsa para desactivar los shaders.",
|
||||
"Press to use it.": "Pulsa para usarlo.",
|
||||
"Put shader pack .zip files or folders in it.": "Pon en ella archivos .zip o carpetas de paquetes de shaders.",
|
||||
"Puts every option of this pack back to its default.": "Devuelve todas las opciones de este paquete a sus valores por defecto.",
|
||||
"Sets many options at once, as the pack groups them.": "Ajusta muchas opciones a la vez, tal como las agrupa el paquete.",
|
||||
"No shader packs yet: open the pack folder and put some in it.": "Aún no hay paquetes de shaders: abre la carpeta de paquetes y pon algunos en ella.",
|
||||
"Pick a shader pack. Picking the one in use turns shaders off.": "Elige un paquete de shaders. Elegir el que está en uso desactiva los shaders.",
|
||||
"Changed options apply with Apply, or when you leave.": "Las opciones cambiadas se aplican con Aplicar, o al salir.",
|
||||
"Pack settings": "Ajustes del paquete",
|
||||
"Built-in settings": "Ajustes integrados",
|
||||
"%s settings": "Ajustes de %s",
|
||||
"Settings made by the pack's author: they change only this pack.": "Ajustes creados por el autor del paquete: solo cambian este paquete."
|
||||
"horizon.options.pointer_speed": "Velocidad del puntero en los menús",
|
||||
"horizon.options.scroll_speed": "Velocidad de desplazamiento en los menús",
|
||||
"horizon.options.repeat_delay": "Retardo de repetición al navegar",
|
||||
"horizon.options.repeat_interval": "Intervalo de repetición al navegar",
|
||||
"horizon.options.milliseconds": "%s ms",
|
||||
"horizon.extras.cosmetics_solo": "En un jugador",
|
||||
"horizon.extras.cosmetics_solo.tooltip": "Muestra tus cosméticos y la insignia de tu etiqueta de nombre también en tus mundos de un jugador (tu etiqueta con Etiqueta con tu nombre). Desactivado: solo en servidores.",
|
||||
"horizon.extras.horizon_marks": "Logos de Horizon",
|
||||
"horizon.extras.horizon_marks.tooltip": "Muestra el logo de Horizon junto a los jugadores de Horizon: un pequeño logo antes del nombre de los demás jugadores que usan Horizon, sobre sus cabezas y en la lista de jugadores. Solo lo ven los jugadores de Horizon: los servidores no reciben nada.",
|
||||
"horizon.extras.horizon_me": "Mostrarme como Horizon",
|
||||
"horizon.extras.horizon_me.tooltip": "Muéstrame como jugador de Horizon: los demás jugadores de Horizon ven el logo junto a tu nombre. Activado, el servicio responde a quien pregunte por ti que has jugado a Horizon en las últimas 3 horas; desactivado, nunca te informa.",
|
||||
"horizon.pages.pvp": "PvP y HUD",
|
||||
"horizon.pages.pvp.tooltip": "Punto de mira, HUD y ajustes de PvP, todos desactivados por defecto. Nada de aquí muestra lo que vanilla no muestra.",
|
||||
"horizon.pages.open.pvp": "PvP y HUD...",
|
||||
"horizon.pvp.header.crosshair": "Punto de mira",
|
||||
"horizon.pvp.crosshair": "Punto de mira personalizado",
|
||||
"horizon.pvp.crosshair_shape": "Forma",
|
||||
"horizon.pvp.crosshair_size": "Tamaño",
|
||||
"horizon.pvp.crosshair_gap": "Separación",
|
||||
"horizon.pvp.crosshair_thickness": "Grosor",
|
||||
"horizon.pvp.crosshair_colour": "Color",
|
||||
"horizon.pvp.crosshair_outline": "Contorno",
|
||||
"horizon.pvp.crosshair_dot": "Punto central",
|
||||
"horizon.pvp.crosshair_hover": "Color sobre entidades",
|
||||
"horizon.pvp.crosshair.tooltip": "Dibuja tu propio punto de mira en lugar del de vanilla. Sus primeros ajustes se parecen a los de vanilla.",
|
||||
"horizon.pvp.crosshair_shape.tooltip": "Una cruz, una T, una X, un círculo, un cuadrado o solo un punto.",
|
||||
"horizon.pvp.crosshair_size.tooltip": "La longitud de cada brazo, en píxeles de la interfaz.",
|
||||
"horizon.pvp.crosshair_gap.tooltip": "El espacio vacío del centro.",
|
||||
"horizon.pvp.crosshair_thickness.tooltip": "Lo ancha que es cada línea.",
|
||||
"horizon.pvp.crosshair_colour.tooltip": "Invertido muestra invertido el color que hay debajo, como hace el punto de mira de vanilla.",
|
||||
"horizon.pvp.crosshair_outline.tooltip": "Un borde negro fino, para que se vea sobre cualquier fondo.",
|
||||
"horizon.pvp.crosshair_dot.tooltip": "Un punto en el centro del punto de mira.",
|
||||
"horizon.pvp.crosshair_hover.tooltip": "Otro color mientras el punto de mira está sobre un mob o un jugador al que podrías golpear, tal como lo sabe el indicador de ataque de vanilla.",
|
||||
"horizon.pvp.colour.inverted": "Invertido",
|
||||
"horizon.pvp.colour.white": "Blanco",
|
||||
"horizon.pvp.colour.red": "Rojo",
|
||||
"horizon.pvp.colour.orange": "Naranja",
|
||||
"horizon.pvp.colour.yellow": "Amarillo",
|
||||
"horizon.pvp.colour.green": "Verde",
|
||||
"horizon.pvp.colour.cyan": "Cian",
|
||||
"horizon.pvp.colour.blue": "Azul",
|
||||
"horizon.pvp.colour.magenta": "Magenta",
|
||||
"horizon.pvp.colour.black": "Negro",
|
||||
"horizon.pvp.shape.cross": "Cruz",
|
||||
"horizon.pvp.shape.t": "T",
|
||||
"horizon.pvp.shape.x": "X",
|
||||
"horizon.pvp.shape.circle": "Círculo",
|
||||
"horizon.pvp.shape.square": "Cuadrado",
|
||||
"horizon.pvp.shape.dot": "Punto",
|
||||
"horizon.pvp.outline": "Contorno de bloque personalizado",
|
||||
"horizon.pvp.outline.tooltip": "Cambia el contorno del bloque que miras.",
|
||||
"horizon.pvp.outline_colour": "Color",
|
||||
"horizon.pvp.outline_colour.tooltip": "El color del contorno. Negro al 40 % es el de vanilla.",
|
||||
"horizon.pvp.outline_opacity": "Opacidad",
|
||||
"horizon.pvp.outline_opacity.tooltip": "Lo sólido que es el contorno.",
|
||||
"horizon.pvp.outline_thickness": "Grosor",
|
||||
"horizon.pvp.outline_thickness.tooltip": "El ancho del contorno, en veces el de vanilla.",
|
||||
"horizon.pvp.outline_fill": "Relleno",
|
||||
"horizon.pvp.outline_fill.tooltip": "Tiñe ligeramente las caras del bloque que miras.",
|
||||
"horizon.pvp.hit": "Color de golpe personalizado",
|
||||
"horizon.pvp.hit.tooltip": "Cambia el color con el que parpadean los mobs y los jugadores al recibir daño. Rojo al 30 % es el de vanilla.",
|
||||
"horizon.pvp.hit_colour": "Color",
|
||||
"horizon.pvp.hit_colour.tooltip": "El color con el que parpadean los mobs y los jugadores heridos.",
|
||||
"horizon.pvp.hit_opacity": "Opacidad",
|
||||
"horizon.pvp.hit_opacity.tooltip": "Cuánto los cubre el color.",
|
||||
"horizon.pvp.header.outline": "Contorno de bloque",
|
||||
"horizon.pvp.header.hit": "Color de golpe",
|
||||
"horizon.pvp.hud_armour": "Armadura",
|
||||
"horizon.pvp.hud_armour.tooltip": "La armadura que llevas, empezando por la cabeza, con los usos que le quedan a cada pieza.",
|
||||
"horizon.pvp.hud_armour_corner": "Esquina de la armadura",
|
||||
"horizon.pvp.hud_armour_corner.tooltip": "Dónde se muestra la armadura.",
|
||||
"horizon.pvp.hud_armour_percent": "Durabilidad en %",
|
||||
"horizon.pvp.hud_armour_percent.tooltip": "Los usos restantes como porcentaje del máximo de la pieza en lugar de un número.",
|
||||
"horizon.pvp.hud_effects": "Efectos",
|
||||
"horizon.pvp.hud_effects.tooltip": "Tus efectos con sus nombres, niveles y tiempo restante, en lugar de los iconos de arriba a la derecha.",
|
||||
"horizon.pvp.hud_effects_corner": "Esquina de los efectos",
|
||||
"horizon.pvp.hud_effects_corner.tooltip": "Dónde se muestran los efectos.",
|
||||
"horizon.pvp.hud_held": "Cantidad del objeto en la mano",
|
||||
"horizon.pvp.hud_held.tooltip": "Cuántas unidades del objeto que llevas en la mano hay en tu inventario; flechas con arco o ballesta.",
|
||||
"horizon.pvp.hud_held_corner": "Esquina de la cantidad",
|
||||
"horizon.pvp.hud_held_corner.tooltip": "Dónde se muestra la cantidad.",
|
||||
"horizon.pvp.hud_background": "Fondos de los widgets",
|
||||
"horizon.pvp.hud_background.tooltip": "Una caja oscura detrás de cada widget.",
|
||||
"horizon.pvp.hud_opacity": "Opacidad del fondo",
|
||||
"horizon.pvp.hud_opacity.tooltip": "Lo oscuras que son las cajas detrás de los widgets.",
|
||||
"horizon.pvp.hud_text_colour": "Color del texto",
|
||||
"horizon.pvp.hud_text_colour.tooltip": "El color del texto de los widgets. La durabilidad sigue yendo de verde a rojo.",
|
||||
"horizon.pvp.hud_shadow": "Sombra del texto",
|
||||
"horizon.pvp.hud_shadow.tooltip": "Una sombra bajo el texto de los widgets, como la tiene el texto del HUD de vanilla.",
|
||||
"horizon.pvp.header.hud": "Widgets del HUD",
|
||||
"horizon.pvp.corner.top_left": "Arriba a la izquierda",
|
||||
"horizon.pvp.corner.top_right": "Arriba a la derecha",
|
||||
"horizon.pvp.corner.bottom_left": "Abajo a la izquierda",
|
||||
"horizon.pvp.corner.bottom_right": "Abajo a la derecha",
|
||||
"horizon.pvp.hud_ping": "Ping",
|
||||
"horizon.pvp.hud_ping.tooltip": "La latencia que el servidor informa para ti, en milisegundos. Solo multijugador.",
|
||||
"horizon.pvp.hud_ping_corner": "Esquina del ping",
|
||||
"horizon.pvp.hud_ping_corner.tooltip": "Dónde se muestra el ping.",
|
||||
"horizon.pvp.hud_coords": "Coordenadas",
|
||||
"horizon.pvp.hud_coords.tooltip": "Tu posición en bloques y la dirección a la que miras, como las muestra F3. Se oculta cuando el mundo o el servidor limitan F3.",
|
||||
"horizon.pvp.hud_coords_corner": "Esquina de las coordenadas",
|
||||
"horizon.pvp.hud_coords_corner.tooltip": "Dónde se muestran las coordenadas.",
|
||||
"horizon.pvp.hud_server": "Dirección del servidor",
|
||||
"horizon.pvp.hud_server.tooltip": "La dirección del servidor en el que juegas. Solo multijugador.",
|
||||
"horizon.pvp.hud_server_corner": "Esquina del servidor",
|
||||
"horizon.pvp.hud_server_corner.tooltip": "Dónde se muestra la dirección.",
|
||||
"horizon.pvp.hud_combo": "Contador de combo",
|
||||
"horizon.pvp.hud_combo.tooltip": "Los golpes seguidos que el servidor confirmó, hasta que recibes daño o pasan 3 segundos sin ninguno.",
|
||||
"horizon.pvp.hud_combo_corner": "Esquina del combo",
|
||||
"horizon.pvp.hud_combo_corner.tooltip": "Dónde se muestra el combo.",
|
||||
"horizon.pvp.hud.ping": "Ping: %s ms",
|
||||
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
|
||||
"horizon.pvp.hud.facing": "Mirando: %s",
|
||||
"horizon.pvp.hud.combo": "Combo: %s",
|
||||
"horizon.pvp.hud.direction.north": "Norte",
|
||||
"horizon.pvp.hud.direction.south": "Sur",
|
||||
"horizon.pvp.hud.direction.east": "Este",
|
||||
"horizon.pvp.hud.direction.west": "Oeste",
|
||||
"horizon.pvp.hud_fps": "FPS",
|
||||
"horizon.pvp.hud_fps.tooltip": "Los fotogramas dibujados en el último segundo.",
|
||||
"horizon.pvp.hud_fps_corner": "Esquina de los FPS",
|
||||
"horizon.pvp.hud_fps_corner.tooltip": "Dónde se muestran los FPS.",
|
||||
"horizon.pvp.hud_cps": "Clics por segundo",
|
||||
"horizon.pvp.hud_cps.tooltip": "Tus clics izquierdo y derecho en el último segundo.",
|
||||
"horizon.pvp.hud_cps_corner": "Esquina de los clics",
|
||||
"horizon.pvp.hud_cps_corner.tooltip": "Dónde se muestran los clics por segundo.",
|
||||
"horizon.pvp.hud_keys": "Teclas y botones",
|
||||
"horizon.pvp.hud_keys.tooltip": "Tus teclas de movimiento, botones del ratón y salto, iluminados mientras los mantienes, con los clics por segundo.",
|
||||
"horizon.pvp.hud_keys_corner": "Esquina de las teclas",
|
||||
"horizon.pvp.hud_keys_corner.tooltip": "Dónde se muestran las teclas.",
|
||||
"horizon.pvp.hud_clock": "Reloj",
|
||||
"horizon.pvp.hud_clock.tooltip": "La hora de tu ordenador.",
|
||||
"horizon.pvp.hud_clock_corner": "Esquina del reloj",
|
||||
"horizon.pvp.hud_clock_corner.tooltip": "Dónde se muestra el reloj.",
|
||||
"horizon.pvp.hud_reach": "Alcance",
|
||||
"horizon.pvp.hud_reach.tooltip": "A qué distancia de tus ojos cayó tu último golpe, durante 3 segundos. Solo mide: tu alcance sigue siendo el de vanilla.",
|
||||
"horizon.pvp.hud_reach_corner": "Esquina del alcance",
|
||||
"horizon.pvp.hud_reach_corner.tooltip": "Dónde se muestra el alcance.",
|
||||
"horizon.pvp.hud_speed": "Velocidad",
|
||||
"horizon.pvp.hud_speed.tooltip": "A qué velocidad te desplazas por el suelo, en bloques por segundo.",
|
||||
"horizon.pvp.hud_speed_corner": "Esquina de la velocidad",
|
||||
"horizon.pvp.hud_speed_corner.tooltip": "Dónde se muestra la velocidad.",
|
||||
"horizon.pvp.hud.fps": "%s FPS",
|
||||
"horizon.pvp.hud.cps": "CPS: %s | %s",
|
||||
"horizon.pvp.hud.reach": "Alcance: %s",
|
||||
"horizon.pvp.hud.speed": "Velocidad: %s b/s",
|
||||
"horizon.pvp.hud.keys.attack": "Clic izq.",
|
||||
"horizon.pvp.hud.keys.use": "Clic der.",
|
||||
"horizon.pvp.hud.keys.cps": "%s CPS",
|
||||
"horizon.pvp.numeric_enchants": "Niveles de encantamiento como números",
|
||||
"horizon.pvp.numeric_enchants.tooltip": "Los tooltips y la mesa de encantamientos muestran Filo 5 en lugar de Filo V.",
|
||||
"horizon.pvp.header.items": "Objetos",
|
||||
"horizon.pvp.fire_lower": "Fuego más bajo",
|
||||
"horizon.pvp.fire_lower.tooltip": "Baja las llamas que ves mientras ardes, para que cubran menos pantalla.",
|
||||
"horizon.pvp.shield_size": "Tamaño del escudo",
|
||||
"horizon.pvp.shield_size.tooltip": "El tamaño del escudo en tu mano, en primera persona.",
|
||||
"horizon.pvp.totem_size": "Tamaño de la animación del tótem",
|
||||
"horizon.pvp.totem_size.tooltip": "El tamaño del tótem que llena la pantalla cuando te salva.",
|
||||
"horizon.pvp.glint_colour": "Color del brillo de encantamiento",
|
||||
"horizon.pvp.glint_colour.tooltip": "El color del brillo de los objetos y armaduras encantados. Desactivado es el morado de vanilla.",
|
||||
"horizon.pvp.clear_water": "Agua clara",
|
||||
"horizon.pvp.clear_water.tooltip": "Bajo el agua, ve tan lejos como te deja vanilla tras 30 segundos allí, desde el principio.",
|
||||
"horizon.pvp.clear_lava": "Lava clara",
|
||||
"horizon.pvp.clear_lava.tooltip": "En la lava, ve tan lejos como te deja la Resistencia al fuego de vanilla, sin el efecto.",
|
||||
"horizon.pvp.header.visual": "Ajustes visuales",
|
||||
"horizon.pvp.view_time": "Hora del día",
|
||||
"horizon.pvp.view_time.tooltip": "La hora del día que ves: solo el cielo y su luz. La hora del mundo sigue y no se envía nada al servidor.",
|
||||
"horizon.pvp.view_weather": "Clima",
|
||||
"horizon.pvp.view_weather.tooltip": "El clima que ves: solo la lluvia, su luz y la niebla. El clima del mundo sigue y no se envía nada al servidor.",
|
||||
"horizon.pvp.header.view": "Tu vista",
|
||||
"horizon.pvp.view.world": "La del mundo",
|
||||
"horizon.pvp.time.morning": "Mañana",
|
||||
"horizon.pvp.time.noon": "Mediodía",
|
||||
"horizon.pvp.time.sunset": "Atardecer",
|
||||
"horizon.pvp.time.midnight": "Medianoche",
|
||||
"horizon.pvp.weather.clear": "Despejado",
|
||||
"horizon.pvp.weather.rain": "Lluvia",
|
||||
"horizon.pvp.weather.thunder": "Tormenta",
|
||||
"horizon.chat.timestamp": "[%s] ",
|
||||
"horizon.chat.repeats": " (x%s)",
|
||||
"horizon.pvp.header.chat": "Chat",
|
||||
"horizon.chat.copied": "Mensaje copiado",
|
||||
"horizon.chat.search": "Buscar en el chat:",
|
||||
"horizon.pvp.chat_timestamps": "Marcas de tiempo",
|
||||
"horizon.pvp.chat_timestamps.tooltip": "La hora a la que llegó cada mensaje, delante de él.",
|
||||
"horizon.pvp.chat_compact": "Chat compacto",
|
||||
"horizon.pvp.chat_compact.tooltip": "Un mensaje repetido seguido se muestra una vez, con cuántas veces llegó: (x3).",
|
||||
"horizon.pvp.chat_copy": "Copiar al hacer clic",
|
||||
"horizon.pvp.chat_copy.tooltip": "Hacer clic en un mensaje del chat abierto copia su texto, salvo que el clic haga otra cosa.",
|
||||
"horizon.pvp.chat_search": "Búsqueda en el chat",
|
||||
"horizon.pvp.chat_search.tooltip": "Ctrl+F en el chat abierto: lo que escribas deja solo los mensajes que lo contienen. Intro o Ctrl+F de nuevo lo detiene.",
|
||||
"horizon.pvp.preset": "Preajuste de PvP",
|
||||
"horizon.pvp.preset.tooltip": "Activa el punto de mira personalizado, los widgets de armadura y efectos, el sprint y agacharse alternos, el fuego más bajo y, si están instalados, los mods de contador de FPS y de teclas. Tus ajustes de cada uno se mantienen.",
|
||||
"horizon.pvp.hud_editor_key": "Tecla del editor de HUD",
|
||||
"horizon.pvp.hud_editor_key.tooltip": "Abre el editor de HUD desde el juego. Haz clic y luego pulsa una tecla; Escape la desasigna.",
|
||||
"horizon.pvp.editor": "Editar HUD",
|
||||
"horizon.pvp.editor.tooltip": "Mueve los widgets sobre el juego: arrástralos a cualquier parte, cambia su tamaño con la rueda y haz clic derecho en uno para devolverlo a su esquina.",
|
||||
"horizon.pvp.editor.hint": "Arrastra los widgets · Rueda: tamaño · Clic derecho: a su esquina",
|
||||
"horizon.pvp.combat_feel": "Sensación de combate",
|
||||
"horizon.pvp.combat_feel.tooltip": "Legacy dibuja las peleas como en 1.8.9: el objeto en la mano, su balanceo, comer, el arco y una espada alzada mientras mantienes usar, sin la bajada tras cada golpe ni el indicador de ataque. Solo el aspecto: lo que envías al servidor no cambia.",
|
||||
"horizon.pvp.combat_indicator": "Indicador de ataque en Legacy",
|
||||
"horizon.pvp.combat_indicator.tooltip": "Mantiene el indicador de ataque en Legacy, que 1.8.9 no tenía.",
|
||||
"horizon.pvp.combat_feel.modern": "Moderno",
|
||||
"horizon.pvp.combat_feel.legacy": "Legacy (1.8.9)",
|
||||
"horizon.pvp.header.combat": "Sensación de combate",
|
||||
"horizon.pvp.combat_sounds": "Sonidos de golpe de 1.8",
|
||||
"horizon.pvp.combat_sounds.tooltip": "En Legacy, omite los sonidos de ataque que añadió 1.9 (fuerte, débil, barrido, crítico...). Los sonidos de daño se mantienen.",
|
||||
"horizon.pvp.combat_rules": "Combate de 1.8 en los mundos que alojas",
|
||||
"horizon.pvp.combat_rules.tooltip": "El modo un jugador y los mundos que abres a la LAN pelean con las reglas de 1.8.9: sin enfriamiento de ataque ni barrido, daño de arma de 1.8, Fuerza, Filo y golpes críticos. Los servidores a los que te unes mantienen sus propias reglas.",
|
||||
"Bindings": "Asignaciones",
|
||||
"Play": "Jugar",
|
||||
"Device": "Dispositivo",
|
||||
"Look": "Vista",
|
||||
"Movement": "Movimiento",
|
||||
"Menus": "Menús",
|
||||
"Actions": "Acciones",
|
||||
"Reset": "Restablecer",
|
||||
"Detected": "Detectado",
|
||||
"Glyphs": "Símbolos",
|
||||
"Feedback": "Respuesta",
|
||||
"Controller": "Mando",
|
||||
"How the sticks turn and move you, and how menus repeat and scroll.": "Cómo te giran y te mueven las palancas, y cómo repiten y desplazan los menús.",
|
||||
"Which button does what. Each player has their own.": "Qué hace cada botón. Cada jugador tiene las suyas.",
|
||||
"The controller the game sees, its button glyphs and its feedback.": "El mando que ve el juego, sus símbolos de botones y su respuesta.",
|
||||
"Press this row, then the button that should do it.": "Pulsa esta fila y luego el botón que deba hacerlo.",
|
||||
"Off, the game ignores every controller for this player.": "Desactivado, el juego ignora todos los mandos de este jugador.",
|
||||
"How fast the right stick turns the view.": "La rapidez con la que la palanca derecha gira la vista.",
|
||||
"Turn the view the other way, left and right.": "Gira la vista al revés, de izquierda a derecha.",
|
||||
"Turn the view the other way, up and down.": "Gira la vista al revés, de arriba abajo.",
|
||||
"How far the right stick moves before the view turns. Raise it if the view drifts.": "Cuánto se mueve la palanca derecha antes de que gire la vista. Súbelo si la vista se desplaza sola.",
|
||||
"Hold the button to sneak, or press it once to start and again to stop.": "Mantén el botón para agacharte, o pulsa una vez para empezar y otra para parar.",
|
||||
"How far the left stick moves before you walk. Raise it if you drift.": "Cuánto se mueve la palanca izquierda antes de que camines. Súbelo si te desplazas solo.",
|
||||
"How far a trigger is pulled before it counts as pressed.": "Cuánto hay que apretar un gatillo para que cuente como pulsado.",
|
||||
"How fast the stick moves the crosshair in inventories.": "La rapidez con la que la palanca mueve el cursor en los inventarios.",
|
||||
"How fast the right stick scrolls a list.": "La rapidez con la que la palanca derecha desplaza una lista.",
|
||||
"How long a held direction waits before it repeats.": "Cuánto espera una dirección mantenida antes de repetirse.",
|
||||
"How long a held direction takes between repeats.": "Cuánto tarda una dirección mantenida entre repeticiones.",
|
||||
"Which on-screen keyboard opens when a text field is pressed.": "Qué teclado en pantalla se abre al pulsar un campo de texto.",
|
||||
"Every action back on its default button.": "Devuelve cada acción a su botón por defecto.",
|
||||
"These settings belong to this player's profile.": "Estos ajustes pertenecen al perfil de este jugador.",
|
||||
"The controller driving this player, or else the first one connected.": "El mando que controla a este jugador o, si no, el primero conectado.",
|
||||
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "El fabricante y el producto USB que informa el sistema. Útil al reportar un mando que falla.",
|
||||
"The glyph family the game picked from the controller's maker.": "La familia de símbolos que el juego eligió según el fabricante del mando.",
|
||||
"Follow the controller, or always show one family's buttons.": "Sigue al mando o muestra siempre los botones de una familia.",
|
||||
"The buttons menus show right now.": "Los botones que muestran los menús ahora mismo.",
|
||||
"Rumble when something happens to you.": "Vibra cuando te pasa algo.",
|
||||
"A DualSense's triggers push back on bows and tools.": "Los gatillos de un DualSense ofrecen resistencia con arcos y herramientas.",
|
||||
"What a DualSense's light shows, over USB or Bluetooth.": "Lo que muestra la luz de un DualSense, por USB o Bluetooth.",
|
||||
"Player profile": "Perfil de jugador",
|
||||
"Vendor and product": "Fabricante y producto",
|
||||
"Detected glyphs": "Símbolos detectados",
|
||||
"Glyphs in use": "Símbolos en uso",
|
||||
"Unknown": "Desconocido",
|
||||
"No controller": "Sin mando",
|
||||
"Editing: %s": "Editando: %s",
|
||||
"Press the button for this. %s cancels.": "Pulsa el botón para esto. %s cancela.",
|
||||
"This button also does: %s": "Este botón también hace: %s",
|
||||
"horizon.pad.notice.connected.press": "Pulsa para usarlo",
|
||||
"horizon.recipe_transfer.missing": "Faltan objetos",
|
||||
"horizon.recipe_transfer.no_room": "Inventario lleno",
|
||||
"horizon.recipe_transfer.unavailable": "La receta no cabe en este menú",
|
||||
"horizon.recipe_transfer.unsupported": "El servidor requiere una receta conocida"
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Brewing parity (tools/test_brewing_parity.py): reads "input reagent
|
||||
//! candidate" lines of item SNBT and prints the three brewing-slot filters,
|
||||
//! the matching recipe and its assembled result; "tick" lines build a
|
||||
//! BrewingStand, run it for their tick count, and print the same state the
|
||||
//! real BrewingStandBlockEntity.serverTick oracle prints.
|
||||
//!
|
||||
//! The first input line is `#resources RESOURCES`; usage: brewing_fixture INPUT OUTPUT
|
||||
use horizon_client::{
|
||||
brewing::{Brewing, BrewingStand, brewing_fuel},
|
||||
item_data::{canon_stack, parse_stack},
|
||||
items::{ItemStack, Items},
|
||||
};
|
||||
use horizon_client_render::providers::Providers;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::sync::Arc;
|
||||
use std::{fmt::Write as _, fs};
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let input = fs::read_to_string(args.get(1).ok_or("missing INPUT")?)?;
|
||||
let root = input
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("#resources "))
|
||||
.map(std::path::PathBuf::from)
|
||||
.ok_or("no #resources line")?;
|
||||
let items = Items::catalog(&root, &PackOverlay::NONE)?;
|
||||
let brewing = Brewing::load(&root, &items, &PackOverlay::NONE);
|
||||
let providers = Providers::load(&root);
|
||||
let mut out = String::new();
|
||||
for (n, line) in input
|
||||
.lines()
|
||||
.filter(|line| !line.starts_with('#'))
|
||||
.enumerate()
|
||||
{
|
||||
let f: Vec<&str> = line.split(' ').collect();
|
||||
if f[0] == "tick" {
|
||||
let ticks: u32 = f[1].parse()?;
|
||||
let mut slots: [Option<ItemStack>; 5] = Default::default();
|
||||
for i in 0..5 {
|
||||
slots[i] = parse_stack(f[2 + i])?;
|
||||
}
|
||||
let mut stand = BrewingStand::new();
|
||||
stand.items = slots;
|
||||
stand.brew_time = f[7].parse()?;
|
||||
stand.fuel = f[8].parse()?;
|
||||
stand.total_brew_time = f[9].parse()?;
|
||||
stand.total_fuel = f[10].parse()?;
|
||||
stand.speed = f[11].parse()?;
|
||||
stand.ingredient = (f[12] != "-").then(|| Arc::<str>::from(f[12]));
|
||||
let mut brews = 0;
|
||||
for _ in 0..ticks {
|
||||
brews += stand.tick(&brewing, &items, &providers).brewed as u32;
|
||||
}
|
||||
let bits: String = stand.items[..3]
|
||||
.iter()
|
||||
.map(|s| if s.is_some() { '1' } else { '0' })
|
||||
.collect();
|
||||
writeln!(
|
||||
out,
|
||||
"{n} s0={} s1={} s2={} s3={} s4={} time={} fuel={} total={} totalfuel={} speed={:.3} bits={bits} ing={} brews={brews}",
|
||||
canon_stack(stand.items[0].as_ref()),
|
||||
canon_stack(stand.items[1].as_ref()),
|
||||
canon_stack(stand.items[2].as_ref()),
|
||||
canon_stack(stand.items[3].as_ref()),
|
||||
canon_stack(stand.items[4].as_ref()),
|
||||
stand.brew_time,
|
||||
stand.fuel,
|
||||
stand.total_brew_time,
|
||||
stand.total_fuel,
|
||||
stand.speed,
|
||||
stand.ingredient.as_deref().unwrap_or("-"),
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
let input = parse_stack(f[0])?;
|
||||
let reagent = parse_stack(f[1])?;
|
||||
let candidate = parse_stack(f[2])?;
|
||||
let potion_slot = candidate.as_ref().is_some_and(|s| brewing.is_bottle(s));
|
||||
let reagent_slot = candidate.as_ref().is_some_and(|s| brewing.is_reagent(s));
|
||||
let fuel_slot = candidate
|
||||
.as_ref()
|
||||
.is_some_and(|s| brewing_fuel(&items, s).is_some());
|
||||
let found = match (input.as_ref(), reagent.as_ref()) {
|
||||
(Some(input), Some(reagent)) => brewing.find(input, reagent),
|
||||
_ => None,
|
||||
};
|
||||
let result = found.map_or_else(|| "-".to_owned(), |r| canon_stack(Some(&r.output)));
|
||||
let id = found.map_or_else(|| "-".to_owned(), |r| r.id.clone());
|
||||
writeln!(
|
||||
out,
|
||||
"{n} {potion_slot} {reagent_slot} {fuel_slot} {result} {id}"
|
||||
)?;
|
||||
}
|
||||
fs::write(args.get(2).ok_or("missing OUTPUT")?, out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -24,6 +24,7 @@ mod block_particles_fixture;
|
||||
mod block_tint_probe;
|
||||
mod block_transform_fixture;
|
||||
mod breaking_fixture;
|
||||
mod brewing_fixture;
|
||||
mod camera_probe;
|
||||
mod cauldron_fixture;
|
||||
mod chat_screen_fixture;
|
||||
@@ -127,6 +128,7 @@ probes! {
|
||||
block_entity_tickers_fixture,
|
||||
block_particles_fixture,
|
||||
block_transform_fixture,
|
||||
brewing_fixture,
|
||||
breaking_fixture,
|
||||
block_tint_probe,
|
||||
camera_probe,
|
||||
|
||||
@@ -1197,6 +1197,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
others: $snapshot,
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
who_lookup: None,
|
||||
others_grid: $grid,
|
||||
others_index: None,
|
||||
others_villagers: None,
|
||||
|
||||
@@ -11,6 +11,10 @@ use horizon_client::{
|
||||
MobTypes,
|
||||
data::MobData,
|
||||
spawn_rules::{self, Dimension, Reason, SpawnLevel},
|
||||
spawn_selection::{
|
||||
self, Heightmap, MobType as SelectionMobType, NaturalLevel, SpawnCost, StructureAt,
|
||||
StructureSpawns, spawn_aabb,
|
||||
},
|
||||
},
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
@@ -44,6 +48,9 @@ struct Column<'a> {
|
||||
slime: f32,
|
||||
/// A player position and whether it is survival or adventure.
|
||||
player: Option<([f64; 3], bool)>,
|
||||
/// `LevelReader.isUnobstructed` for the obstruction family.
|
||||
unobstructed: bool,
|
||||
structure_spawns: StructureSpawns,
|
||||
}
|
||||
impl SpawnLevel for Column<'_> {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
|
||||
@@ -104,6 +111,73 @@ impl SpawnLevel for Column<'_> {
|
||||
})
|
||||
}
|
||||
}
|
||||
impl NaturalLevel for Column<'_> {
|
||||
fn min_y(&self) -> i32 {
|
||||
-64
|
||||
}
|
||||
fn max_y(&self) -> i32 {
|
||||
320
|
||||
}
|
||||
fn ambient_light(&self) -> f32 {
|
||||
0.0
|
||||
}
|
||||
fn has_ceiling(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn mob_type(&self, _entity: &str) -> Option<SelectionMobType> {
|
||||
None
|
||||
}
|
||||
fn biome_spawns(
|
||||
&self,
|
||||
_x: i32,
|
||||
_y: i32,
|
||||
_z: i32,
|
||||
) -> Option<&HashMap<String, Vec<horizon_client::biome::SpawnEntry>>> {
|
||||
None
|
||||
}
|
||||
fn spawn_cost(&self, _entity: &str, _x: i32, _y: i32, _z: i32) -> Option<SpawnCost> {
|
||||
None
|
||||
}
|
||||
fn creature_probability(&self, _x: i32, _y: i32, _z: i32) -> f32 {
|
||||
0.0
|
||||
}
|
||||
fn structures_at(
|
||||
&self,
|
||||
_x: i32,
|
||||
_y: i32,
|
||||
_z: i32,
|
||||
_wanted: &dyn Fn(&str) -> bool,
|
||||
) -> Vec<StructureAt> {
|
||||
Vec::new()
|
||||
}
|
||||
fn structure_spawns(&self) -> &StructureSpawns {
|
||||
&self.structure_spawns
|
||||
}
|
||||
fn nearest_player_positions(&self) -> &[[f64; 3]] {
|
||||
&[]
|
||||
}
|
||||
fn respawn(&self) -> Option<[i32; 3]> {
|
||||
None
|
||||
}
|
||||
fn can_spawn_in_chunk(&self, _cx: i32, _cz: i32) -> bool {
|
||||
true
|
||||
}
|
||||
fn no_collision(&self, _bounds: horizon_core::geometry::Aabb) -> bool {
|
||||
true
|
||||
}
|
||||
fn unobstructed(&self, _bounds: horizon_core::geometry::Aabb) -> bool {
|
||||
self.unobstructed
|
||||
}
|
||||
fn spawn_mobs(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn heightmap(&self, _kind: Heightmap, _x: i32, _z: i32) -> i32 {
|
||||
self.surface
|
||||
}
|
||||
fn pathfindable_land(&self, _x: i32, _y: i32, _z: i32) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// `name[key=value,...]`.
|
||||
fn parse_state(spec: &str) -> BlockState {
|
||||
@@ -213,7 +287,24 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
biome: get("biome", "minecraft:plains"),
|
||||
slime: get("slime", "0.0").parse()?,
|
||||
player,
|
||||
unobstructed: !get("unobstructed", "1").eq("0"),
|
||||
structure_spawns: StructureSpawns::default(),
|
||||
};
|
||||
if c.get("mode") == Some(&"obstruction") {
|
||||
let s = kind.species;
|
||||
let bounds = spawn_aabb(
|
||||
s,
|
||||
f64::from(x0) + 0.5,
|
||||
f64::from(y),
|
||||
f64::from(z0) + 0.5,
|
||||
kind.width as f32,
|
||||
kind.height as f32,
|
||||
);
|
||||
let obstruction =
|
||||
spawn_selection::spawn_obstruction(s, &level, bounds, Some(&[]), x0, y, z0);
|
||||
let _ = writeln!(out, "{name} {obstruction}");
|
||||
continue;
|
||||
}
|
||||
let mut random = LegacyRandom::new(get("seed", "0").parse()?);
|
||||
let s = kind.species;
|
||||
let ok = spawn_rules::is_spawn_position_ok(s, &level, x0, y, z0);
|
||||
|
||||
@@ -343,8 +343,17 @@ impl Tree {
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
pub fn find_id(&self, id: &str) -> Option<usize> {
|
||||
self.by_id.get(id).copied().or_else(|| {
|
||||
if let Some(rest) = id.strip_prefix("minecraft:") {
|
||||
self.by_id.get(rest).copied()
|
||||
} else {
|
||||
self.by_id.get(full(id).as_ref()).copied()
|
||||
}
|
||||
})
|
||||
}
|
||||
pub fn get(&self, id: &str) -> Option<&Advancement> {
|
||||
self.by_id.get(id).map(|&i| &self.list[i])
|
||||
self.find_id(id).map(|i| &self.list[i])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,7 +476,7 @@ fn parse(id: &str, v: &Value) -> Option<Advancement> {
|
||||
};
|
||||
Some(Advancement {
|
||||
id: id.to_owned(),
|
||||
parent: v["parent"].as_str().map(str::to_owned),
|
||||
parent: v["parent"].as_str().map(|p| full(p).into_owned()),
|
||||
display,
|
||||
criteria,
|
||||
requirements,
|
||||
@@ -1652,8 +1661,8 @@ impl Tree {
|
||||
pending.retain(|&i| {
|
||||
let parent = match &self.list[i].parent {
|
||||
None => None,
|
||||
Some(p) => match self.by_id.get(p) {
|
||||
Some(&p) if inserted[p] => Some(p),
|
||||
Some(p) => match self.find_id(p) {
|
||||
Some(p) if inserted[p] => Some(p),
|
||||
_ => return true,
|
||||
},
|
||||
};
|
||||
@@ -3101,4 +3110,165 @@ mod tests {
|
||||
);
|
||||
assert_eq!(date_millis("not a date"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simulate_progression_advancements() {
|
||||
let mut tree = Tree::default();
|
||||
let advs = [
|
||||
("minecraft:story/root", None),
|
||||
("minecraft:story/mine_stone", Some("story/root")),
|
||||
(
|
||||
"minecraft:story/upgrade_tools",
|
||||
Some("minecraft:story/mine_stone"),
|
||||
),
|
||||
("minecraft:story/smelt_iron", Some("story/upgrade_tools")),
|
||||
(
|
||||
"minecraft:story/obtain_armor",
|
||||
Some("minecraft:story/smelt_iron"),
|
||||
),
|
||||
(
|
||||
"minecraft:story/lava_bucket",
|
||||
Some("minecraft:story/smelt_iron"),
|
||||
),
|
||||
(
|
||||
"minecraft:story/iron_tools",
|
||||
Some("minecraft:story/smelt_iron"),
|
||||
),
|
||||
(
|
||||
"minecraft:story/deflect_arrow",
|
||||
Some("minecraft:story/obtain_armor"),
|
||||
),
|
||||
(
|
||||
"minecraft:story/form_obsidian",
|
||||
Some("minecraft:story/lava_bucket"),
|
||||
),
|
||||
(
|
||||
"minecraft:story/mine_diamond",
|
||||
Some("minecraft:story/iron_tools"),
|
||||
),
|
||||
(
|
||||
"minecraft:story/enter_the_nether",
|
||||
Some("minecraft:story/form_obsidian"),
|
||||
),
|
||||
(
|
||||
"minecraft:story/shiny_gear",
|
||||
Some("minecraft:story/mine_diamond"),
|
||||
),
|
||||
(
|
||||
"minecraft:story/enchant_item",
|
||||
Some("minecraft:story/mine_diamond"),
|
||||
),
|
||||
(
|
||||
"minecraft:story/cure_zombie_villager",
|
||||
Some("minecraft:story/enter_the_nether"),
|
||||
),
|
||||
(
|
||||
"minecraft:story/follow_ender_eye",
|
||||
Some("minecraft:story/enter_the_nether"),
|
||||
),
|
||||
(
|
||||
"minecraft:story/enter_the_end",
|
||||
Some("minecraft:story/follow_ender_eye"),
|
||||
),
|
||||
];
|
||||
for (id, parent) in advs {
|
||||
let a = parse(
|
||||
id,
|
||||
&serde_json::json!({
|
||||
"parent": parent,
|
||||
"criteria": {"crit": {"trigger": "minecraft:tick"}},
|
||||
"display": {"icon": {"id": "minecraft:stone"}, "title": "t", "description": "d"}
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
tree.by_id.insert(id.to_string(), tree.list.len());
|
||||
tree.list.push(a);
|
||||
}
|
||||
for i in 16..124 {
|
||||
let id = format!("minecraft:custom/{i}");
|
||||
let a = parse(
|
||||
&id,
|
||||
&serde_json::json!({
|
||||
"criteria": {"crit": {"trigger": "minecraft:tick"}},
|
||||
"display": {"icon": {"id": "minecraft:stone"}, "title": "t", "description": "d"}
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
tree.by_id.insert(id, tree.list.len());
|
||||
tree.list.push(a);
|
||||
}
|
||||
|
||||
fn get_list(tree: &Tree, target: Option<&str>, mode: &str) -> Vec<usize> {
|
||||
match target {
|
||||
None => (0..tree.list.len()).collect(),
|
||||
Some(id) => {
|
||||
let i = *tree.by_id.get(id).unwrap();
|
||||
let mut out = Vec::new();
|
||||
if mode == "until" || mode == "through" {
|
||||
let mut p = tree.list[i].parent.as_ref();
|
||||
while let Some(&pi) = p.and_then(|pid| tree.by_id.get(pid)) {
|
||||
out.push(pi);
|
||||
p = tree.list[pi].parent.as_ref();
|
||||
}
|
||||
}
|
||||
out.push(i);
|
||||
if mode == "from" || mode == "through" {
|
||||
let kids = tree.children();
|
||||
fn add(kids: &[Vec<usize>], i: usize, out: &mut Vec<usize>) {
|
||||
for &c in &kids[i] {
|
||||
out.push(c);
|
||||
add(kids, c, out);
|
||||
}
|
||||
}
|
||||
add(&kids, i, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn grant(tree: &Tree, prog: &mut Progress, list: &[usize]) -> i32 {
|
||||
let mut count = 0;
|
||||
for &i in list {
|
||||
let a = &tree.list[i];
|
||||
if prog.done(a) {
|
||||
continue;
|
||||
}
|
||||
for c in &a.criteria {
|
||||
prog.grant(&a.id, &c.name, "now");
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
fn revoke(tree: &Tree, prog: &mut Progress, list: &[usize]) -> i32 {
|
||||
let mut count = 0;
|
||||
for &i in list {
|
||||
let a = &tree.list[i];
|
||||
let mut any = false;
|
||||
for c in &a.criteria {
|
||||
any |= prog.revoke(&a.id, &c.name);
|
||||
}
|
||||
if any {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
let mut p = Progress::default();
|
||||
let until = get_list(&tree, Some("minecraft:story/smelt_iron"), "until");
|
||||
assert_eq!(grant(&tree, &mut p, &until), 4);
|
||||
|
||||
let from = get_list(&tree, Some("minecraft:story/root"), "from");
|
||||
assert_eq!(grant(&tree, &mut p, &from), 12);
|
||||
|
||||
let through = get_list(&tree, Some("minecraft:story/iron_tools"), "through");
|
||||
assert_eq!(revoke(&tree, &mut p, &through), 8);
|
||||
|
||||
let all = get_list(&tree, None, "everything");
|
||||
let granted_everything = grant(&tree, &mut p, &all);
|
||||
assert_eq!(granted_everything, 116);
|
||||
}
|
||||
}
|
||||
@@ -172,6 +172,8 @@ pub struct ArrowState {
|
||||
pub pickup: Pickup,
|
||||
/// Its entity type is `SpectralArrow` (the type is its own, not the pickup item's).
|
||||
pub spectral: bool,
|
||||
/// `SpectralArrow` saved duration; its constructor default is 200.
|
||||
pub spectral_duration: i32,
|
||||
/// `pickupItemStack`: what picking the arrow up gives.
|
||||
pub pickup_item: crate::items::ItemStack,
|
||||
/// `AbstractArrow.firedFromWeapon`: the crossbow or bow it was shot from.
|
||||
@@ -240,6 +242,7 @@ impl ArrowState {
|
||||
last_state: None,
|
||||
pickup: Pickup::Disallowed,
|
||||
spectral: false,
|
||||
spectral_duration: 200,
|
||||
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
|
||||
fired_from_weapon: None,
|
||||
no_physics: false,
|
||||
|
||||
@@ -56,6 +56,7 @@ pub mod debug_overlay;
|
||||
pub mod debugworld;
|
||||
pub mod dialog;
|
||||
pub mod dialog_screen;
|
||||
#[cfg(feature = "discord")]
|
||||
pub mod discord_presence;
|
||||
pub mod end_crystal;
|
||||
pub mod end_gateway;
|
||||
@@ -74,6 +75,7 @@ pub mod game_rules_screen;
|
||||
pub mod gizmos;
|
||||
pub mod gossip;
|
||||
pub mod health;
|
||||
#[cfg(feature = "heartbeat")]
|
||||
pub mod heartbeat;
|
||||
pub mod hotbar;
|
||||
pub mod hunger;
|
||||
@@ -164,6 +166,7 @@ pub use horizon_client_core::text;
|
||||
pub use horizon_client_input::actions;
|
||||
pub use horizon_client_input::clipboard;
|
||||
pub use horizon_client_input::gamepad;
|
||||
pub use horizon_client_input::gameplay_guide;
|
||||
pub use horizon_client_input::held;
|
||||
pub use horizon_client_input::input;
|
||||
pub use horizon_client_input::interaction_input;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+194
-6969
File diff suppressed because it is too large.
Load diff
@@ -12,6 +12,29 @@ use horizon_core::random::RandomSource;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The client's `Allay.mobInteract` runs before the server's response.
|
||||
/// Whether `predict_allay_interaction` would act, on a copy of the allay.
|
||||
pub(super) fn allay_would_take(
|
||||
&self,
|
||||
index: usize,
|
||||
hand: Hand,
|
||||
offered: Option<ItemStack>,
|
||||
) -> bool {
|
||||
let duplicates = offered.as_ref().is_some_and(|s| {
|
||||
self.items()
|
||||
.item_tags
|
||||
.get("minecraft:duplicates_allays")
|
||||
.is_some_and(|tag| tag.contains(&*s.item))
|
||||
});
|
||||
allay::interaction::predict(
|
||||
&mut self.mobs[index].clone(),
|
||||
offered.as_ref(),
|
||||
hand == Hand::Main,
|
||||
duplicates,
|
||||
self.acting_uuid(),
|
||||
)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub(super) fn predict_allay_interaction(
|
||||
&mut self,
|
||||
index: usize,
|
||||
|
||||
@@ -856,6 +856,8 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
|
||||
self.show_breaking_particles(at, f64::from(width), f64::from(height));
|
||||
// ArmorStand.kill(level, player).
|
||||
self.entity_killed_event(at, true);
|
||||
return true;
|
||||
}
|
||||
if time - self.armor_stands[i].last_hit > 5 {
|
||||
@@ -888,6 +890,8 @@ impl Game<'_> {
|
||||
];
|
||||
self.spawn_item(stack, center, v, 10);
|
||||
}
|
||||
// ArmorStand.kill(level, player), after the drops.
|
||||
self.entity_killed_event(at, true);
|
||||
self.world
|
||||
.modified
|
||||
.insert(((at[0].floor() as i32) >> 4, (at[2].floor() as i32) >> 4));
|
||||
@@ -1087,6 +1091,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
|
||||
self.entity_killed_event(at, false);
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
}
|
||||
/// ArmorStandRenderer: the stand with its default pose, then armor layers.
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! The world's save on a timer (`MinecraftServer.computeNextAutosaveInterval`,
|
||||
//! `onTickRateChanged`), the players' autosaves, and the world's metadata (level.dat).
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn save_game(&mut self) -> Result<()> {
|
||||
self.save_game_with(true)
|
||||
}
|
||||
/// `MinecraftServer.computeNextAutosaveInterval`: three hundred seconds
|
||||
/// of ticks at the rate the server runs at now - its measured rate while
|
||||
/// a `/tick sprint` runs - never fewer than a hundred.
|
||||
pub(super) fn next_autosave_interval(&self) -> i64 {
|
||||
let rate = if self.clock.sprinting() {
|
||||
1.0e9 / (self.clock.average_nanos() + 1) as f32
|
||||
} else {
|
||||
self.clock.rate()
|
||||
};
|
||||
autosave_interval(rate)
|
||||
}
|
||||
/// `MinecraftServer.onTickRateChanged`: a rate that asks for a shorter
|
||||
/// wait than the one already running shortens it.
|
||||
pub(super) fn on_tick_rate_changed(&mut self) {
|
||||
let interval = self.next_autosave_interval();
|
||||
if interval < self.ticks_until_autosave {
|
||||
self.ticks_until_autosave = interval;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn finish_player_autosaves(&mut self, wait: bool) -> Result<bool> {
|
||||
let saved = self.player_autosaves.completed(wait);
|
||||
if !saved.is_empty() {
|
||||
let directory = horizon_client::lan::playerdata::directory(&self.world_root);
|
||||
for player in saved {
|
||||
self.native_player_save_to_file(player.uuid, &player.name, &directory);
|
||||
}
|
||||
}
|
||||
self.player_autosaves.writes_finished()
|
||||
}
|
||||
pub(super) fn save_game_metadata(&mut self) -> Result<()> {
|
||||
self.save_maps();
|
||||
self.save_random_sequences();
|
||||
self.save_fight();
|
||||
self.save_wandering_trader();
|
||||
self.save_portal_arrivals();
|
||||
self.save_stats();
|
||||
self.save_advancements();
|
||||
self.save_scoreboard();
|
||||
self.save_bossbars();
|
||||
self.save_schedule();
|
||||
self.save_warden_tracker();
|
||||
self.sync_level();
|
||||
// Level::write can synchronously replace the integrated owner file.
|
||||
let _ = self.finish_player_autosaves(true);
|
||||
let stored_owner = self.store_owner();
|
||||
write_level_for_role(
|
||||
&mut self.level,
|
||||
&self.world_root,
|
||||
self.options.execution_role,
|
||||
)?;
|
||||
// PlayerEvent.SaveToFile for the owner, once level.dat is written.
|
||||
if let Some((uuid, name)) = stored_owner {
|
||||
let directory = horizon_client::lan::playerdata::directory(&self.world_root);
|
||||
self.native_player_save_to_file(uuid, &name, &directory);
|
||||
}
|
||||
// PlayerList.saveAll: every placed player saves with the server, to
|
||||
// their own file (`PlayerDataStorage.save`); the owner is also in the
|
||||
// file level.dat names, which is the one it loads from
|
||||
// (`playerdata::load_owner`).
|
||||
if self.options.dedicated.is_some()
|
||||
&& !self.options.execution_role.is_client()
|
||||
&& let Some(host) = self.lan.take()
|
||||
{
|
||||
// The vehicle tree goes into a player's file when they leave
|
||||
// (`take_parent_vehicle`); here it stays in the chunk saves.
|
||||
let mut host = host;
|
||||
for g in host.hub.guests.iter_mut().filter(|g| g.placed) {
|
||||
// Read-only fixture input, correlated with the real save receipt.
|
||||
// Keep this identical on the untouched/fixed selector builds.
|
||||
if std::env::var_os("HORIZON_DEV_ADVANCEMENT_SELECTOR_AUDIT").is_some() {
|
||||
eprintln!(
|
||||
"advancement selector model raw: {}",
|
||||
serde_json::json!({"conn":g.conn,"name":g.name,
|
||||
"uuid":horizon_protocol::crypto::uuid_string(g.uuid),
|
||||
"game_time":self.world.game_time,
|
||||
"progress":g.advancements.to_json(&self.adv.tree, horizon_world::DATA_VERSION)})
|
||||
);
|
||||
}
|
||||
self.guest_attachments_into(g);
|
||||
self.player_autosaves.enqueue(g, &self.world_root);
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
self.save_world_state();
|
||||
self.message = Some(("Saved the game".into(), Instant::now()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Block particles: the sprite and tint of a block's faces and particle material, honey slides,
|
||||
//! and the burst a destroyed block leaves (`ClientLevel.addDestroyBlockEffect`).
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The block's first up face (or any face) and its fixed tint, for block
|
||||
/// models drawn as entities (falling blocks, carried blocks).
|
||||
pub(super) fn block_face_sprite(&self, state: BlockStateId) -> Option<(String, u32)> {
|
||||
let pack = &self.pack;
|
||||
let models = pack
|
||||
.render
|
||||
.model_catalog
|
||||
.collect(state, horizon_core::position::BlockPos::new(0, 0, 0))
|
||||
.ok()?;
|
||||
let face = models
|
||||
.iter()
|
||||
.flat_map(|(_, m)| m.faces.iter())
|
||||
.find(|f| f.quad.facing == horizon_assets::model::Direction::Up)
|
||||
.or_else(|| models.iter().flat_map(|(_, m)| m.faces.iter()).next())?;
|
||||
let name = pack.layer_names.get(&face.layer)?.clone();
|
||||
let tint = if face.quad.tint_index >= 0 {
|
||||
let n = &self.res.facts().get(state).name;
|
||||
if n.contains("leaves") || n.contains("vine") {
|
||||
0x48B518
|
||||
} else {
|
||||
0x91BD59
|
||||
}
|
||||
} else {
|
||||
0xFFFFFF
|
||||
};
|
||||
Some((name, tint))
|
||||
}
|
||||
/// `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.
|
||||
pub(super) fn honey_slide_particles(&mut self, at: [f64; 3]) {
|
||||
self.honey_entity_particles(at, 53);
|
||||
}
|
||||
/// `HoneyBlock.showParticles`: the crumb count of entity event 53 or 54.
|
||||
pub(super) fn honey_entity_particles(&mut self, at: [f64; 3], event: i8) {
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
let Some(count) = horizon_client::landing::honey_particle_count(event) else {
|
||||
return;
|
||||
};
|
||||
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..count {
|
||||
self.particles.add(
|
||||
&ParticleOptions::block(ParticleType::Block, sprite.clone(), tint),
|
||||
at,
|
||||
[0.0; 3],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `BlockStateModelSet.getParticleMaterial`: the sprite of the model's
|
||||
/// `particle` texture, else its first face (entity-rendered blocks may
|
||||
/// have a particle texture without faces, and faces without one).
|
||||
pub(super) fn block_particle_sprite(
|
||||
pack: &PackResources,
|
||||
state: BlockStateId,
|
||||
) -> Option<String> {
|
||||
let catalog = &pack.render.model_catalog;
|
||||
let layer = catalog.particle(state).or_else(|| {
|
||||
let models = catalog
|
||||
.collect(state, horizon_core::position::BlockPos::new(0, 0, 0))
|
||||
.ok()?;
|
||||
models
|
||||
.iter()
|
||||
.flat_map(|(_, m)| m.faces.iter())
|
||||
.next()
|
||||
.map(|f| f.layer)
|
||||
})?;
|
||||
pack.layer_names.get(&layer).cloned()
|
||||
}
|
||||
pub(super) fn particle_shape(&self, pos: [i32; 3], state: BlockStateId) -> Vec<[f64; 6]> {
|
||||
let f = self.res.facts().get(state);
|
||||
let offset = if f.outline_offset {
|
||||
self.pack
|
||||
.context
|
||||
.tables
|
||||
.offset(state, pos[0], pos[2])
|
||||
.unwrap_or([0.0; 3])
|
||||
} else {
|
||||
[0.0; 3]
|
||||
};
|
||||
f.outline
|
||||
.iter()
|
||||
.map(|b| std::array::from_fn(|i| b[i] + offset[i % 3]))
|
||||
.collect()
|
||||
}
|
||||
/// ClientLevel.addDestroyBlockEffect: a burst per outline box.
|
||||
pub(super) fn destroy_particles(&mut self, pos: [i32; 3], state: BlockStateId) {
|
||||
if self.settings.extras.hide_block_particles {
|
||||
return;
|
||||
}
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, pos) else {
|
||||
return;
|
||||
};
|
||||
let boxes = self.particle_shape(pos, state);
|
||||
horizon_client::particles::destroy_positions(pos, &boxes, |p, v| {
|
||||
let particle = Particle::terrain(self.particles.random(), p, v, sprite.clone(), tint);
|
||||
self.particles.push(particle);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1519,8 +1519,12 @@ impl Game<'_> {
|
||||
for stack in b.items.iter().flatten() {
|
||||
self.spawn_item(stack.clone(), at, super::dispenser::new_item_motion(), 10);
|
||||
}
|
||||
// `VehicleEntity.destroy(level, dropItem)` drops the boat item on
|
||||
// the death path only, and only with `entity_drops` on.
|
||||
// `VehicleEntity.destroy(level, dropItem)`: `kill` (the boat's own
|
||||
// ENTITY_DIE), then the boat item on the death path only, and only
|
||||
// with `entity_drops` on.
|
||||
if !creative {
|
||||
self.entity_killed_event(at, false);
|
||||
}
|
||||
if !creative && self.ui.rules.bool("entity_drops") {
|
||||
let item = b.entity_type();
|
||||
self.spawn_item(
|
||||
|
||||
@@ -46,6 +46,9 @@ fn book_page_box() -> ui::EditBox {
|
||||
edit
|
||||
}
|
||||
|
||||
/// Where a book's text is, from its top left corner, above its page buttons.
|
||||
const BOOK_TEXT: [f32; 4] = [36.0, 16.0, 120.0, 136.0];
|
||||
|
||||
pub(super) enum BookScreen {
|
||||
Edit {
|
||||
/// Inventory slot of the book (the selected hotbar slot).
|
||||
@@ -1220,6 +1223,18 @@ impl Game<'_> {
|
||||
r.v1 = r.v0 + 192.0 * dv;
|
||||
batch.rect_uv(left, top, 192.0, 192.0, r, u32::MAX);
|
||||
}
|
||||
// The page, or the signing title on it: a field to a controller, which opens its keyboard.
|
||||
if matches!(book, BookScreen::Edit { .. }) {
|
||||
widgets::target_as(
|
||||
[
|
||||
left + BOOK_TEXT[0],
|
||||
top + BOOK_TEXT[1],
|
||||
BOOK_TEXT[2],
|
||||
BOOK_TEXT[3],
|
||||
],
|
||||
widgets::Kind::Field,
|
||||
);
|
||||
}
|
||||
let tr = |key: &str, fallback: &str| self.translate(key, fallback);
|
||||
let page_buttons = |batch: &mut HudBatch, back: bool, forward: bool| {
|
||||
for (show, x, name) in [
|
||||
|
||||
@@ -356,7 +356,7 @@ mod tests {
|
||||
y,
|
||||
rot,
|
||||
name: None,
|
||||
key: Key::Item,
|
||||
key: Key::Remote,
|
||||
};
|
||||
// The centre, facing north: 8 map pixels wide, nudged by (-0.5, 0.5).
|
||||
close(
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Using an item on a cauldron (`CauldronInteraction`): what the click does, and doing it.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// What the cauldron at `pos` answers to `held` on a client, which only
|
||||
/// asks (`CauldronInteractions` acts `if (!level.isClientSide())`). A
|
||||
/// sneak with something in hand skips `useItemOn`
|
||||
/// (`Player.isSecondaryUseActive`).
|
||||
pub(super) fn cauldron_outcome(
|
||||
&self,
|
||||
pos: [i32; 3],
|
||||
held: Option<&ItemStack>,
|
||||
) -> horizon_client::cauldron::Outcome {
|
||||
use horizon_client::cauldron::{self, Outcome};
|
||||
let Some(held) = held else {
|
||||
return Outcome::Pass;
|
||||
};
|
||||
if self.keys.held(Mapping::Sneak) {
|
||||
return Outcome::Pass;
|
||||
}
|
||||
let Some((state, under_water)) = self.cauldron_at(pos) else {
|
||||
return Outcome::Pass;
|
||||
};
|
||||
let removes_dye = cauldron::removes_dye(self.items(), &held.item);
|
||||
cauldron::outcome(&state, under_water, removes_dye, held)
|
||||
}
|
||||
/// `CauldronInteractions` for the held item aimed at a cauldron.
|
||||
pub(super) fn use_on_cauldron(&mut self, pos: [i32; 3]) -> Result<bool> {
|
||||
use horizon_client::cauldron::{self, Outcome};
|
||||
struct Hand<'g, 'a>(&'g mut Game<'a>);
|
||||
impl cauldron::Player for Hand<'_, '_> {
|
||||
fn creative(&self) -> bool {
|
||||
self.0.creative
|
||||
}
|
||||
fn hand(&mut self) -> &mut Option<ItemStack> {
|
||||
self.0.held_mut()
|
||||
}
|
||||
fn add(&mut self, stack: &mut ItemStack) -> bool {
|
||||
let before = stack.count;
|
||||
match self.0.give(stack.clone()) {
|
||||
None => stack.count = 0,
|
||||
Some(rest) => stack.count = rest.count,
|
||||
}
|
||||
stack.count < before
|
||||
}
|
||||
fn contains(&self, stack: &ItemStack) -> bool {
|
||||
self.0
|
||||
.inventory
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|have| have.same_item_same_components(stack))
|
||||
}
|
||||
fn drop_stack(&mut self, stack: ItemStack) {
|
||||
if let Some(eye) = self.0.eye_position() {
|
||||
// ItemUtils.createFilledResult: `player.drop(stack, false)`.
|
||||
self.0.throw_from_eyes(stack, eye, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
let registry = self.pack.assets.registry;
|
||||
let Some((state, under_water)) = self.cauldron_at(pos) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(held) = self.held() else {
|
||||
return Ok(false);
|
||||
};
|
||||
// Player.isSecondaryUseActive with an item in hand skips useItemOn.
|
||||
if self.keys.held(Mapping::Sneak) {
|
||||
return Ok(false);
|
||||
}
|
||||
let removes_dye = cauldron::removes_dye(self.items(), &held.item);
|
||||
let result = cauldron::interact(&state, under_water, removes_dye, &mut Hand(self));
|
||||
if result.outcome == Outcome::Pass {
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(stat) = result.stat {
|
||||
self.award_custom_stat(stat, 1);
|
||||
}
|
||||
if let Some(block) = result.block
|
||||
&& let Ok(new) = registry.resolve(&block)
|
||||
{
|
||||
self.level_set(pos, new)?;
|
||||
if result.lower_fill_level {
|
||||
self.post_block_game_event("minecraft:block_change", pos, None, Some(new));
|
||||
}
|
||||
}
|
||||
if let Some(sound) = result.sound {
|
||||
self.audio.play(
|
||||
sound,
|
||||
Some([
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,8 @@ pub struct UiState {
|
||||
pub difficulty: u8,
|
||||
/// F1: hide the HUD.
|
||||
pub hide_gui: bool,
|
||||
/// Native HUD mode: keep hands visible while the HUD is hidden.
|
||||
pub show_hand_when_hidden: bool,
|
||||
/// Death message key set by the cause of death (e.g. /kill).
|
||||
pub death_cause: Option<String>,
|
||||
/// Path of an F2 screenshot waiting for its frame.
|
||||
@@ -183,6 +185,46 @@ pub struct UiState {
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
pub fn hud_mode(&self) -> super::native_client_services::HudMode {
|
||||
use super::native_client_services::HudMode;
|
||||
match (self.hide_gui, self.show_hand_when_hidden) {
|
||||
(false, _) => HudMode::AllVisible,
|
||||
(true, true) => HudMode::NoHud,
|
||||
(true, false) => HudMode::AllHidden,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_hud_mode(&mut self, mode: super::native_client_services::HudMode) {
|
||||
use super::native_client_services::HudMode;
|
||||
self.hide_gui = mode != HudMode::AllVisible;
|
||||
self.show_hand_when_hidden = mode == HudMode::NoHud;
|
||||
}
|
||||
|
||||
pub fn hand_hidden(&self) -> bool {
|
||||
self.hide_gui && !self.show_hand_when_hidden
|
||||
}
|
||||
|
||||
/// The effective first-person hand gate, before per-hand mod cancellation.
|
||||
pub fn hands_drawn(
|
||||
&self,
|
||||
spectator: bool,
|
||||
sleeping: bool,
|
||||
first_person: bool,
|
||||
alive: bool,
|
||||
dead_screen: bool,
|
||||
) -> bool {
|
||||
!spectator && !self.hand_hidden() && !sleeping && first_person && alive && !dead_screen
|
||||
}
|
||||
|
||||
fn toggle_hide_gui(&mut self) {
|
||||
use super::native_client_services::HudMode;
|
||||
self.set_hud_mode(if self.hide_gui {
|
||||
HudMode::AllVisible
|
||||
} else {
|
||||
HudMode::AllHidden
|
||||
});
|
||||
}
|
||||
|
||||
/// The state a window starts with: the chat remembers the commands kept
|
||||
/// in the game folder (`ChatComponent`'s constructor).
|
||||
pub fn new() -> Self {
|
||||
@@ -194,6 +236,138 @@ impl UiState {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hud_mode_tests {
|
||||
use super::UiState;
|
||||
use crate::mod_api::{self, ClientTickPreEvent, Hooks, Host, HudMode};
|
||||
|
||||
#[test]
|
||||
fn hud_modes_drive_the_effective_hud_and_hand_gate() {
|
||||
let mut ui = UiState::default();
|
||||
for (mode, hidden, hand) in [
|
||||
(HudMode::AllVisible, false, true),
|
||||
(HudMode::NoHud, true, true),
|
||||
(HudMode::AllHidden, true, false),
|
||||
] {
|
||||
ui.set_hud_mode(mode);
|
||||
assert_eq!(ui.hud_mode(), mode);
|
||||
// This is the same flag that every HUD gate and API 410 read.
|
||||
assert_eq!(ui.hide_gui, hidden);
|
||||
assert_eq!(ui.hands_drawn(false, false, true, true, false), hand);
|
||||
// No mode can bypass the other first-person hand restrictions.
|
||||
assert!(!ui.hands_drawn(true, false, true, true, false));
|
||||
assert!(!ui.hands_drawn(false, true, true, true, false));
|
||||
assert!(!ui.hands_drawn(false, false, false, true, false));
|
||||
assert!(!ui.hands_drawn(false, false, true, false, false));
|
||||
assert!(!ui.hands_drawn(false, false, true, true, true));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_f1_remains_two_state_and_clears_the_hand_override() {
|
||||
let mut ui = UiState::default();
|
||||
assert_eq!(ui.hud_mode(), HudMode::AllVisible);
|
||||
ui.toggle_hide_gui();
|
||||
assert_eq!(ui.hud_mode(), HudMode::AllHidden);
|
||||
assert!(ui.hand_hidden());
|
||||
ui.toggle_hide_gui();
|
||||
assert_eq!(ui.hud_mode(), HudMode::AllVisible);
|
||||
ui.set_hud_mode(HudMode::NoHud);
|
||||
ui.toggle_hide_gui();
|
||||
assert_eq!(ui.hud_mode(), HudMode::AllVisible);
|
||||
ui.toggle_hide_gui();
|
||||
assert_eq!(ui.hud_mode(), HudMode::AllHidden);
|
||||
// Existing hidden state also hides hands without a separate setting.
|
||||
let ui = UiState {
|
||||
hide_gui: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(ui.hud_mode(), HudMode::AllHidden);
|
||||
assert!(ui.hand_hidden());
|
||||
}
|
||||
|
||||
struct Client(UiState);
|
||||
impl Host for Client {
|
||||
fn hud_mode(&self) -> HudMode {
|
||||
self.0.hud_mode()
|
||||
}
|
||||
fn set_hud_mode(&mut self, mode: HudMode) {
|
||||
self.0.set_hud_mode(mode);
|
||||
}
|
||||
}
|
||||
struct ThreeState;
|
||||
impl Hooks for ThreeState {
|
||||
fn client_tick_pre(&self, host: &mut dyn Host, _: &mut ClientTickPreEvent) {
|
||||
host.set_hud_mode(match host.hud_mode() {
|
||||
HudMode::AllVisible => HudMode::NoHud,
|
||||
HudMode::NoHud => HudMode::AllHidden,
|
||||
HudMode::AllHidden => HudMode::AllVisible,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_dispatch_exposes_hud_mode_helpers_to_existing_callbacks() {
|
||||
let mods: Vec<Box<dyn Hooks>> = vec![Box::new(ThreeState)];
|
||||
let mut client = Client(UiState::default());
|
||||
for (mode, hidden, hand) in [
|
||||
(HudMode::NoHud, true, true),
|
||||
(HudMode::AllHidden, true, false),
|
||||
(HudMode::AllVisible, false, true),
|
||||
] {
|
||||
mod_api::dispatch_client_tick_pre(&mut client, &mods);
|
||||
assert_eq!(client.hud_mode(), mode);
|
||||
assert_eq!(client.0.hide_gui, hidden);
|
||||
assert_eq!(client.0.hands_drawn(false, false, true, true, false), hand);
|
||||
}
|
||||
mod_api::dispatch_client_tick_pre(&mut client, &[]);
|
||||
assert_eq!(client.hud_mode(), HudMode::AllVisible);
|
||||
}
|
||||
|
||||
/// Exercise the generated Host bridge against a real Game with silent audio
|
||||
/// and no window, renderer, server loop or network connection.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn native_hud_mode_game_bridge_respects_client_and_guest_guards() {
|
||||
use crate::game::{execution_role::ExecutionRole, native_machines::tests::server_game};
|
||||
let mut game = server_game();
|
||||
assert!(game.window.is_none());
|
||||
assert!(game.renderer.is_none());
|
||||
let mods: Vec<Box<dyn Hooks>> = vec![Box::new(ThreeState)];
|
||||
for role in [
|
||||
ExecutionRole::IntegratedServer,
|
||||
ExecutionRole::DedicatedServer,
|
||||
] {
|
||||
game.options.execution_role = role;
|
||||
mod_api::dispatch_client_tick_pre(&mut game, &mods);
|
||||
assert_eq!(Host::hud_mode(&game), HudMode::AllVisible);
|
||||
}
|
||||
game.options.execution_role = ExecutionRole::Client;
|
||||
mod_api::dispatch_client_tick_pre(&mut game, &mods);
|
||||
assert_eq!(Host::hud_mode(&game), HudMode::NoHud);
|
||||
assert!(game.ui.hide_gui);
|
||||
assert!(game.ui.hands_drawn(false, false, true, true, false));
|
||||
|
||||
// A server callback borrowing another actor cannot change the window's mode.
|
||||
game.lan_acting = Some(17);
|
||||
mod_api::dispatch_client_tick_pre(&mut game, &mods);
|
||||
assert_eq!(Host::hud_mode(&game), HudMode::NoHud);
|
||||
game.lan_acting = None;
|
||||
mod_api::dispatch_client_tick_pre(&mut game, &mods);
|
||||
assert_eq!(Host::hud_mode(&game), HudMode::AllHidden);
|
||||
assert!(!game.ui.hands_drawn(false, false, true, true, false));
|
||||
|
||||
game.toggle_hide_gui();
|
||||
assert_eq!(Host::hud_mode(&game), HudMode::AllVisible);
|
||||
Host::set_hud_mode(&mut game, HudMode::NoHud);
|
||||
game.toggle_hide_gui();
|
||||
assert_eq!(Host::hud_mode(&game), HudMode::AllVisible);
|
||||
game.toggle_hide_gui();
|
||||
assert_eq!(Host::hud_mode(&game), HudMode::AllHidden);
|
||||
assert!(game.ui.hand_hidden());
|
||||
}
|
||||
}
|
||||
|
||||
/// The ids `build_registries` lists from the prepared resources' folders.
|
||||
/// The folders don't change while the game runs, so each root is read once
|
||||
/// instead of at every world opening (thousands of files).
|
||||
@@ -1680,7 +1854,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// F1 hides the HUD (see `hud`); the screenshot key still works.
|
||||
pub(super) fn toggle_hide_gui(&mut self) {
|
||||
self.ui.hide_gui = !self.ui.hide_gui;
|
||||
self.ui.toggle_hide_gui();
|
||||
}
|
||||
/// F2: ScreenShot.grab into `screenshots/` with vanilla's file name. The
|
||||
/// file it will write, `None` without a window to draw.
|
||||
|
||||
@@ -294,6 +294,36 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
"projectile_saved_type" => {
|
||||
let uuid = u128::from_str_radix(
|
||||
&op["uuid"]
|
||||
.as_str()
|
||||
.ok_or("projectile type: missing UUID")?
|
||||
.replace('-', ""),
|
||||
16,
|
||||
)?;
|
||||
let state = self.harness_owner(|g| {
|
||||
let Some(super::commands::Target::Projectile(id)) = g.target_by_uuid(uuid) else {
|
||||
return Err("projectile type: missing loaded projectile".into());
|
||||
};
|
||||
let actual = *g.persist.uuids.get(&id).ok_or("projectile type: missing actual UUID")?;
|
||||
if horizon_client::lan::playerdata::ints_uuid(actual) != uuid {
|
||||
return Err("projectile type: UUID mismatch".into());
|
||||
}
|
||||
let p = g.creatures.projectiles.iter().find(|p| p.id == id)
|
||||
.ok_or("projectile type: missing model")?;
|
||||
// Read the live save adapter's type, not the generic selector alias.
|
||||
// No Duration value is supplied by this admission observation.
|
||||
let saved = horizon_client::entity_nbt::arrow_to_nbt(p, actual, None, None, None)
|
||||
.ok_or("projectile type: not an actual arrow")?;
|
||||
Ok(json!({"uuid":actual,"kind":horizon_client::entity_nbt::kind(&saved)
|
||||
.ok_or("projectile type: missing saved type")?,
|
||||
"pos":[p.position.x,p.position.y,p.position.z],"no_gravity":p.no_gravity}))
|
||||
})?;
|
||||
out.push_str(&json!({"cmd":format!("#projectile_saved_type {}",op["label"].as_str().unwrap_or("")),
|
||||
"messages":[],"results":[],"state":state}).to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
"raid_state" => {
|
||||
let state = self.harness_owner(|g| g.raid_fixture_state(&op))?;
|
||||
let row = json!({"cmd":format!("#raid_state {} {}",op["label"].as_str().unwrap_or(""),op["id"]),
|
||||
@@ -726,6 +756,12 @@ impl Game<'_> {
|
||||
);
|
||||
out.push('\n');
|
||||
}
|
||||
"map_carried" => {
|
||||
self.harness_owner(|g| {
|
||||
g.tick_maps();
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
"craft_menu_click" | "craft_menu_state" | "craft_menu_close" => {
|
||||
let owner = self.serving.as_ref().ok_or("no server")?.owner;
|
||||
let state = self.in_owner_level(|g| -> Result<Value> {
|
||||
@@ -824,10 +860,21 @@ impl Game<'_> {
|
||||
let id = op["id"].as_i64().ok_or("command harness: map id")? as i32;
|
||||
let value = self.harness_owner(|g| {
|
||||
Ok(match g.map(id) {
|
||||
Some(m) => format!(
|
||||
"scale={},locked={},center={},{}",
|
||||
m.scale, m.locked, m.center[0], m.center[1]
|
||||
),
|
||||
Some(m) => {
|
||||
let mut value = format!(
|
||||
"scale={},locked={},center={},{}",
|
||||
m.scale, m.locked, m.center[0], m.center[1]
|
||||
);
|
||||
if op["red_x"].as_bool().unwrap_or(false) {
|
||||
let count = m
|
||||
.decorations
|
||||
.iter()
|
||||
.filter(|d| d.sprite == "map/decorations/red_x")
|
||||
.count();
|
||||
value.push_str(&format!(",red_x={count}"));
|
||||
}
|
||||
value
|
||||
}
|
||||
None => "missing".to_owned(),
|
||||
})
|
||||
})?;
|
||||
|
||||
@@ -93,6 +93,7 @@ pub(in crate::game) struct OtherEntity {
|
||||
pub position: Vec3,
|
||||
pub yaw: f32,
|
||||
pub pitch: f32,
|
||||
pub item_transformation: Option<super::native_entities::ItemDisplayTransformation>,
|
||||
}
|
||||
pub(super) fn err(key: &str, args: &[&dyn std::fmt::Display]) -> CommandError {
|
||||
CommandError::tr(key, args)
|
||||
@@ -1517,7 +1518,7 @@ impl Game<'_> {
|
||||
self.feedback(message);
|
||||
Ok(targets.len() as i32)
|
||||
}
|
||||
fn teleport_to(
|
||||
pub(in crate::game) fn teleport_to(
|
||||
&mut self,
|
||||
t: Target,
|
||||
pos: [f64; 3],
|
||||
@@ -2281,6 +2282,30 @@ impl Game<'_> {
|
||||
let _ = self.in_target_level(t, |g| g.kill_target_in_level(t));
|
||||
}
|
||||
fn kill_target_in_level(&mut self, t: Target) {
|
||||
// Entity.kill (BlockAttachedEntity.kill; ArmorStand's generic_kill hit,
|
||||
// which bypasses its invulnerability; a vehicle is no LivingEntity):
|
||||
// removed, then ENTITY_DIE by the entity itself. Mobs post theirs as
|
||||
// they die, the crystal and the dragon on their own paths, and an item
|
||||
// entity can dampen it (ItemEntity.dampensVibrations), so it is not here.
|
||||
let died = matches!(
|
||||
t,
|
||||
Target::Stand(_)
|
||||
| Target::Frame(_)
|
||||
| Target::Painting(_)
|
||||
| Target::Boat(_)
|
||||
| Target::Cart(_)
|
||||
| Target::Orb(_)
|
||||
| Target::Projectile(_)
|
||||
| Target::Fangs(_)
|
||||
| Target::Tnt(_)
|
||||
| Target::Falling(_)
|
||||
| Target::Rocket(_)
|
||||
| Target::Trident(_)
|
||||
| Target::Spawner(_)
|
||||
| Target::Cloud(_)
|
||||
)
|
||||
.then(|| self.target_position(t))
|
||||
.flatten();
|
||||
match t {
|
||||
Target::Guest(_) => {
|
||||
self.for_player(t, |g| g.kill_target(Target::Player));
|
||||
@@ -2356,6 +2381,9 @@ impl Game<'_> {
|
||||
}
|
||||
// The orbs marked for removal are gone (Entity.kill removes them).
|
||||
self.orbs.retain(|o| o.age < u32::MAX / 2);
|
||||
if let Some(at) = died {
|
||||
self.entity_killed_event(at, false);
|
||||
}
|
||||
self.native_leaves_flush();
|
||||
}
|
||||
fn cmd_kill(&mut self, ctx: &Context) -> R<i32> {
|
||||
@@ -3118,9 +3146,7 @@ impl Game<'_> {
|
||||
let key = if grant { "grant" } else { "revoke" };
|
||||
let index = match ctx.get("advancement") {
|
||||
Some(Arg::Resource(id)) => Some(
|
||||
*tree
|
||||
.by_id
|
||||
.get(id)
|
||||
tree.find_id(id)
|
||||
.ok_or_else(|| err("advancement.advancementNotFound", &[id]))?,
|
||||
),
|
||||
_ => None,
|
||||
@@ -3165,7 +3191,7 @@ impl Game<'_> {
|
||||
let mut out = Vec::new();
|
||||
if ctx.has("until") || ctx.has("through") {
|
||||
let mut p = tree.list[i].parent.as_ref();
|
||||
while let Some(&pi) = p.and_then(|id| tree.by_id.get(id)) {
|
||||
while let Some(pi) = p.and_then(|id| tree.find_id(id)) {
|
||||
out.push(pi);
|
||||
p = tree.list[pi].parent.as_ref();
|
||||
}
|
||||
@@ -3462,22 +3488,13 @@ impl Game<'_> {
|
||||
if cancel {
|
||||
return;
|
||||
}
|
||||
if level == Dim::Overworld {
|
||||
g.spawn = Some(Vec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
));
|
||||
g.respawn_config = None;
|
||||
} else {
|
||||
g.spawn = None;
|
||||
g.respawn_config = Some(super::respawn_anchor::RespawnConfig {
|
||||
dim: level,
|
||||
pos,
|
||||
yaw,
|
||||
forced: true,
|
||||
});
|
||||
}
|
||||
g.spawn = None;
|
||||
g.respawn_config = Some(super::respawn_anchor::RespawnConfig {
|
||||
dim: level,
|
||||
pos,
|
||||
yaw,
|
||||
forced: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
let dim = Text::literal(self.dim.id());
|
||||
@@ -4560,6 +4577,21 @@ mod teleport_rider_tests {
|
||||
rider2.yaw, 80.0,
|
||||
"a vehicle turned by `facing` leaves its rider"
|
||||
);
|
||||
// Native rotation preserves a rider's mount, position and motion.
|
||||
game.native_lifecycle
|
||||
.begin_open(horizon_client::mod_api::NativeAuthority::IntegratedServer);
|
||||
let rider = game.mobs.iter_mut().find(|m| m.id == 51).unwrap();
|
||||
rider.velocity = Vec3::new(0.2, 0.3, 0.4);
|
||||
let position = rider.position;
|
||||
let velocity = rider.velocity;
|
||||
let handle = game.native_entity(51).unwrap();
|
||||
assert!(game.native_entity_set_rotation(handle, -135.0, 120.0));
|
||||
let rider = game.mobs.iter().find(|m| m.id == 51).unwrap();
|
||||
assert_eq!(rider.extra.mount, Some(50));
|
||||
assert_eq!(rider.position, position);
|
||||
assert_eq!(rider.velocity, velocity);
|
||||
assert_eq!((rider.yaw, rider.head_pitch), (-135.0, 90.0));
|
||||
assert!(!game.native_entity_set_rotation(handle, f32::NAN, 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4720,3 +4752,87 @@ mod query_feedback_tests {
|
||||
assert!(lines[0].plain().contains("1234"), "{:?}", lines[0].plain());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod forced_respawn_tests {
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn overworld_spawnpoint_is_validated_at_both_player_blocks() {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.player = Some(
|
||||
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(5.5, 64.0, 5.5))
|
||||
.unwrap(),
|
||||
);
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.level.world_spawn = Some([0, 80, 0]);
|
||||
let registry = game.pack.assets.registry;
|
||||
let air = registry.default_state("minecraft:air").unwrap();
|
||||
// Liquid is a block property: aquatic plants such as kelp still permit
|
||||
// a forced point, while a bubble column does not, at feet or head.
|
||||
for (block, allowed) in [
|
||||
("air", true),
|
||||
("water", false),
|
||||
("lava", false),
|
||||
("bubble_column", false),
|
||||
("kelp", true),
|
||||
("stone", false),
|
||||
] {
|
||||
let state = registry
|
||||
.default_state(&format!("minecraft:{block}"))
|
||||
.unwrap();
|
||||
for height in [0, 1] {
|
||||
game.world.set(5, 64, 5, air).unwrap();
|
||||
game.world.set(5, 65, 5, air).unwrap();
|
||||
game.world.set(5, 64 + height, 5, state).unwrap();
|
||||
assert!(game.run_command("spawnpoint @s 5 64 5"));
|
||||
let config = game
|
||||
.respawn_config
|
||||
.expect("Overworld commands must retain a forced configuration");
|
||||
assert!(config.forced);
|
||||
assert_eq!(config.pos, [5, 64, 5]);
|
||||
let actual = game.resolve_server_respawn(config);
|
||||
assert_eq!(actual.is_some(), allowed, "{block} at height {height}");
|
||||
assert_eq!(game.respawn_config.is_some(), allowed);
|
||||
assert_eq!(game.level.world_spawn, Some([0, 80, 0]));
|
||||
if let Some((at, yaw)) = actual {
|
||||
assert_eq!(at, Vec3::new(5.5, 64.1, 5.5));
|
||||
assert_eq!(yaw, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! block entity and storage data accessors over the game state.
|
||||
use super::{Arg, Context, Game, R, Target, err};
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
data_cmd::{self, Op, path_error},
|
||||
entity_nbt,
|
||||
nbt_path::NbtPath,
|
||||
@@ -117,7 +118,12 @@ impl Game<'_> {
|
||||
Target::Item(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
let e = self.dropped.iter().find(|e| e.id == id)?;
|
||||
entity_nbt::item_entity_to_nbt(e, uuid, self.persist.raw.get(&id))
|
||||
entity_nbt::item_entity_to_nbt_with_items(
|
||||
e,
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
self.items(),
|
||||
)
|
||||
}
|
||||
Target::Stand(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
@@ -173,6 +179,21 @@ impl Game<'_> {
|
||||
}
|
||||
Target::Projectile(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
// A homing bullet's `Target` is the entity's, not the raw
|
||||
// compound's, as `save_projectile` writes it.
|
||||
let who = self
|
||||
.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
.find(|p| p.id == id)
|
||||
.and_then(|p| match &p.kind {
|
||||
horizon_client::projectiles::ProjectileKind::ShulkerBullet {
|
||||
target: Some(who),
|
||||
..
|
||||
} => Some(*who),
|
||||
_ => None,
|
||||
});
|
||||
let target = who.and_then(|who| self.save_who_uuid(who));
|
||||
let p = self.creatures.projectiles.iter().find(|p| p.id == id)?;
|
||||
let owner = if p.owner == Some(horizon_client::mobs::Who::Player) {
|
||||
player_uuid
|
||||
@@ -190,7 +211,7 @@ impl Game<'_> {
|
||||
entity_nbt::arrow_to_nbt(p, uuid, p.owner_uuid.or(owner), in_block, raw)
|
||||
.or_else(|| entity_nbt::fireball_to_nbt(p, uuid, owner, raw))
|
||||
.or_else(|| entity_nbt::throwable_to_nbt(p, uuid, owner, raw))
|
||||
.or_else(|| entity_nbt::spit_or_bullet_to_nbt(p, uuid, owner, raw))?
|
||||
.or_else(|| entity_nbt::spit_or_bullet_to_nbt(p, uuid, owner, target, raw))?
|
||||
}
|
||||
Target::Crystal(id) => {
|
||||
let c = self.crystals.iter().find(|c| c.id == id)?;
|
||||
@@ -244,6 +265,7 @@ impl Game<'_> {
|
||||
&super::OtherEntity {
|
||||
id,
|
||||
kind: "minecraft:area_effect_cloud".into(),
|
||||
item_transformation: None,
|
||||
position: horizon_core::geometry::Vec3::new(x, y, z),
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
@@ -742,7 +764,18 @@ impl Game<'_> {
|
||||
if let Some((_, mut new)) = save::decode_block_entity(&tag) {
|
||||
self.take_loot_table(key, &tag, &mut new);
|
||||
self.load_block_name(key, &new, &tag);
|
||||
self.world.block_entities.insert(key, new);
|
||||
if let (Some(BlockEntity::Mod(current)), BlockEntity::Mod(loaded)) =
|
||||
(self.world.block_entities.get_mut(&key), &mut new)
|
||||
&& current.id == loaded.id
|
||||
{
|
||||
// BlockDataAccessor.setData loads the existing entity.
|
||||
// Replace its saved fields, preserving runtime data and
|
||||
// identity; DerefMut still invalidates content caches.
|
||||
current.nbt = std::mem::take(&mut loaded.nbt);
|
||||
current.items = std::mem::take(&mut loaded.items);
|
||||
} else {
|
||||
self.world.block_entities.insert(key, new);
|
||||
}
|
||||
self.world.set_block_lock(key, &tag);
|
||||
self.update_spawner_display(key);
|
||||
// BlockDataAccessor.setData: setChanged and sendBlockUpdated(3),
|
||||
@@ -874,13 +907,15 @@ impl Game<'_> {
|
||||
}
|
||||
self.cmd_data_in(id, ctx, target)
|
||||
}
|
||||
/// `DataCommands.mergeData`: literal values keep the modern argument
|
||||
/// parser's decoded Tag contract through execution.
|
||||
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 {
|
||||
return Ok(0);
|
||||
};
|
||||
let (nbt, _) = parse_snbt(nbt).map_err(|e| err("argument.nbt.invalid", &[&e]))?;
|
||||
let nbt = horizon_client::command::snbt::parse_fully(nbt)?;
|
||||
let old = self.data_of(&target)?;
|
||||
if horizon_client::nbt_path::too_deep(&nbt, 0) {
|
||||
return Err(err("arguments.nbtpath.too_deep", &[]));
|
||||
@@ -934,6 +969,8 @@ impl Game<'_> {
|
||||
fn system_feedback(&mut self, message: Text) {
|
||||
self.feedback(message);
|
||||
}
|
||||
/// `DataCommands.manipulateData`: literal sources use the same SNBT
|
||||
/// decode contract as command admission; stored sources are unchanged.
|
||||
fn cmd_data_modify(&mut self, ctx: &Context, target: DataRef) -> R<i32> {
|
||||
let target_path = self.path_arg(ctx, "targetPath")?;
|
||||
let after_modify: Vec<&String> = ctx
|
||||
@@ -959,11 +996,7 @@ impl Game<'_> {
|
||||
let Some(Arg::Str(text)) = ctx.get("value") else {
|
||||
return Ok(0);
|
||||
};
|
||||
vec![
|
||||
parse_snbt(text)
|
||||
.map_err(|e| err("argument.nbt.invalid", &[&e]))?
|
||||
.0,
|
||||
]
|
||||
vec![horizon_client::command::snbt::parse_fully(text)?]
|
||||
} else {
|
||||
let source = self.data_ref(ctx, 1)?;
|
||||
let data = self.data_of(&source)?;
|
||||
@@ -1546,6 +1579,12 @@ mod loot_table_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod saved_tag_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod mod_block_entity_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod arrow_owner_tests {
|
||||
use super::Target;
|
||||
@@ -1654,3 +1693,123 @@ mod arrow_owner_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod modern_literal_tests {
|
||||
use super::*;
|
||||
use crate::game::commands::execute::ExecState;
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
|
||||
fn admitted_modern_literals_reach_actual_storage_as_typed_tags() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
let run = |g: &mut Game<'_>, line: &str| {
|
||||
g.exec = ExecState::default();
|
||||
let source = g.player_source();
|
||||
g.execute_line(line, source)
|
||||
};
|
||||
let cases = [
|
||||
("hex", "0x10", "16", Tag::Int(16)),
|
||||
("underscore", "1_000", "1000", Tag::Int(1000)),
|
||||
("unicode4", r#""\u0041""#, r#""A""#, Tag::String("A".into())),
|
||||
("hex2", r#""\x41""#, r#""A""#, Tag::String("A".into())),
|
||||
(
|
||||
"unicode8",
|
||||
r#""\U00000041""#,
|
||||
r#""A""#,
|
||||
Tag::String("A".into()),
|
||||
),
|
||||
(
|
||||
"trailing",
|
||||
"{n:3,}",
|
||||
"{n:3}",
|
||||
Tag::compound([("n", Tag::Int(3))]),
|
||||
),
|
||||
("plain", "plain", r#""plain""#, Tag::String("plain".into())),
|
||||
];
|
||||
let clock = game.world.game_time;
|
||||
let mut differences = Vec::new();
|
||||
for (name, modern, plain, expected) in &cases {
|
||||
for route in ["merge", "modify"] {
|
||||
let target = format!("h189:{route}_{name}");
|
||||
let reference = format!("h189:ref_{route}_{name}");
|
||||
let seed = ["data merge storage ", &target, r#" {v:"seed"}"#].concat();
|
||||
assert_eq!(run(&mut game, &seed).unwrap(), 1);
|
||||
assert_eq!(
|
||||
game.score.storage.get(&target).get("v"),
|
||||
Some(&Tag::String("seed".into()))
|
||||
);
|
||||
let canonical = ["data merge storage ", &reference, " {v:", plain, "}"].concat();
|
||||
assert_eq!(run(&mut game, &canonical).unwrap(), 1);
|
||||
assert_eq!(game.score.storage.get(&reference).get("v"), Some(expected));
|
||||
let command = if route == "merge" {
|
||||
["data merge storage ", &target, " {v:", modern, "}"].concat()
|
||||
} else {
|
||||
["data modify storage ", &target, " v set value ", modern].concat()
|
||||
};
|
||||
let dispatcher = horizon_client::command::vanilla::game_dispatcher_with_features(
|
||||
&game.level.enabled_features,
|
||||
);
|
||||
assert!(
|
||||
dispatcher
|
||||
.parse(&command, &game.ui.registries)
|
||||
.executable()
|
||||
.is_ok(),
|
||||
"admission: {route}/{name}"
|
||||
);
|
||||
let result = run(&mut game, &command);
|
||||
let actual = game.score.storage.get(&target).get("v").cloned();
|
||||
if result.as_ref().ok() != Some(&1) || actual.as_ref() != Some(expected) {
|
||||
differences.push(format!("{route}/{name}: result={result:?}, stored={actual:?}, expected={expected:?}"));
|
||||
}
|
||||
// Copy actual canonical stored data back: equality is a real
|
||||
// no-change failure, independent of a formatter or parser stub.
|
||||
let copy = [
|
||||
"data modify storage ",
|
||||
&target,
|
||||
" v set from storage ",
|
||||
&reference,
|
||||
" v",
|
||||
]
|
||||
.concat();
|
||||
if run(&mut game, ©).is_ok() {
|
||||
differences.push(format!(
|
||||
"{route}/{name}: canonical copy changed the stored Tag"
|
||||
));
|
||||
}
|
||||
assert_eq!(game.score.storage.get(&target).get("v"), Some(expected));
|
||||
}
|
||||
}
|
||||
assert_eq!(game.world.game_time, clock);
|
||||
assert!(differences.is_empty(), "{}", differences.join("\n"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Actual command execution with prepared resources. No native mod bundle or
|
||||
//! process-wide trait hooks are installed; this is a generic content fixture.
|
||||
use super::*;
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::{
|
||||
chunk::Chunk,
|
||||
containers::{ModBlockEntity, ModRevision},
|
||||
mod_content::{ContentMod, build},
|
||||
world::World,
|
||||
};
|
||||
use horizon_mods::content::{Block, Content};
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
const KEY: (i32, i32, i32) = (2, 64, 2);
|
||||
const POS: [i32; 3] = [2, 64, 2];
|
||||
const BLOCK: &str = "fixture:machine";
|
||||
const ENTITY: &str = "fixture:machine_entity";
|
||||
|
||||
fn entity<'a>(game: &'a Game<'_>) -> &'a ModBlockEntity {
|
||||
let Some(BlockEntity::Mod(entity)) = game.world.block_entities.get(&KEY) else {
|
||||
panic!("fixture mod block entity missing");
|
||||
};
|
||||
entity
|
||||
}
|
||||
fn changed(game: &Game<'_>, before: ModRevision) {
|
||||
let entity = entity(game);
|
||||
assert_ne!(
|
||||
entity.revision(),
|
||||
before,
|
||||
"saved data invalidates content caches"
|
||||
);
|
||||
assert_eq!(
|
||||
entity.data,
|
||||
[71, -3, 907],
|
||||
"runtime menu data survives loading"
|
||||
);
|
||||
assert!(game.world.modified.contains(&(0, 0)), "chunk must be dirty");
|
||||
assert!(
|
||||
game.world.block_log.as_ref().unwrap().contains(&KEY),
|
||||
"normal update must be queued"
|
||||
);
|
||||
}
|
||||
fn run(game: &mut Game<'_>, command: &str) -> ModRevision {
|
||||
let before = entity(game).revision();
|
||||
game.world.modified.clear();
|
||||
game.world.block_log.as_mut().unwrap().clear();
|
||||
assert!(game.run_command(command), "{command}");
|
||||
before
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires private prepared 26.3 resources in HORIZON_TEST_RESOURCES"]
|
||||
fn actual_data_commands_preserve_same_mod_entity_runtime_state() {
|
||||
let base = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let root =
|
||||
PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap()).join("data-command-resources");
|
||||
let mut content = Content::declared("fixture");
|
||||
content
|
||||
.declare(
|
||||
&[Block {
|
||||
id: BLOCK.into(),
|
||||
template: "minecraft:stone".into(),
|
||||
..Default::default()
|
||||
}],
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
build(
|
||||
&base,
|
||||
&[ContentMod {
|
||||
id: "fixture".into(),
|
||||
content,
|
||||
}],
|
||||
&root,
|
||||
)
|
||||
.unwrap();
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&base,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.world = World::new(game.res.facts().air, 1);
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
let state = game.pack.assets.registry.default_state(BLOCK).unwrap();
|
||||
game.world.set(POS[0], POS[1], POS[2], state).unwrap();
|
||||
let initial = parse_snbt(
|
||||
r#"{id:"fixture:machine_entity",x:2,y:64,z:2,fuel:4,Opaque:{typed:[L;7L,-9L]},Items:[{Slot:0b,id:"minecraft:coal",count:2}]}"#,
|
||||
).unwrap().0;
|
||||
let (_, initial) = save::decode_block_entity(&initial).unwrap();
|
||||
game.world.block_entities.insert(KEY, initial);
|
||||
let Some(BlockEntity::Mod(initial)) = game.world.block_entities.get_mut(&KEY) else {
|
||||
panic!()
|
||||
};
|
||||
initial.data = vec![71, -3, 907];
|
||||
let transient_allocation = initial.data.as_ptr();
|
||||
game.world.block_log = Some(Vec::new());
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
|
||||
let before = run(
|
||||
&mut game,
|
||||
r#"data merge block 2 64 2 {fuel:41,Opaque:{added:9s},Items:[{Slot:1b,id:"minecraft:diamond",count:3,components:{"minecraft:custom_data":{stamp:8b}}}]}"#,
|
||||
);
|
||||
changed(&game, before);
|
||||
let loaded = entity(&game);
|
||||
assert_eq!(loaded.id, ENTITY);
|
||||
assert_eq!(loaded.data.as_ptr(), transient_allocation);
|
||||
assert_eq!(loaded.nbt.get(&"fuel".into()), Some(&Tag::Int(41)));
|
||||
assert_eq!(
|
||||
loaded.nbt.get(&"Opaque".into()),
|
||||
Some(&Tag::compound([
|
||||
("typed", Tag::LongArray(vec![7, -9])),
|
||||
("added", Tag::Short(9)),
|
||||
]))
|
||||
);
|
||||
assert_eq!(loaded.items.len(), 2);
|
||||
assert!(
|
||||
loaded.items[0].is_none(),
|
||||
"saved inventory replaces the old slots"
|
||||
);
|
||||
let stack = loaded.items[1].as_ref().unwrap();
|
||||
assert_eq!(stack.item.as_ref(), "minecraft:diamond");
|
||||
assert_eq!(stack.count, 3);
|
||||
assert_eq!(
|
||||
stack.data().unwrap().other("minecraft:custom_data"),
|
||||
Some(&Tag::compound([("stamp", Tag::Byte(8))]))
|
||||
);
|
||||
|
||||
let before = run(&mut game, "data remove block 2 64 2 Opaque.typed");
|
||||
changed(&game, before);
|
||||
assert_eq!(
|
||||
entity(&game).nbt.get(&"Opaque".into()),
|
||||
Some(&Tag::compound([("added", Tag::Short(9))]))
|
||||
);
|
||||
assert_eq!(entity(&game).items[1].as_ref().unwrap().count, 3);
|
||||
let before = run(&mut game, "data remove block 2 64 2 Items");
|
||||
changed(&game, before);
|
||||
assert!(
|
||||
entity(&game).items.is_empty(),
|
||||
"removed inventory must not resurrect"
|
||||
);
|
||||
assert!(!entity(&game).nbt.contains_key(&"Items".into()));
|
||||
let before = run(&mut game, "data remove block 2 64 2 Opaque");
|
||||
changed(&game, before);
|
||||
assert!(!entity(&game).nbt.contains_key(&"Opaque".into()));
|
||||
|
||||
// Unknown, non-inventory Items belongs to the mod's opaque saved data.
|
||||
let before = run(&mut game, "data merge block 2 64 2 {Items:{opaque:5b}}");
|
||||
changed(&game, before);
|
||||
assert!(entity(&game).items.is_empty());
|
||||
assert_eq!(
|
||||
entity(&game).nbt.get(&"Items".into()),
|
||||
Some(&Tag::compound([("opaque", Tag::Byte(5))]))
|
||||
);
|
||||
let before = run(&mut game, "data remove block 2 64 2 Items");
|
||||
changed(&game, before);
|
||||
assert!(!entity(&game).nbt.contains_key(&"Items".into()));
|
||||
|
||||
// Keep the pre-existing type-changing behavior: this patch adds no new
|
||||
// block/type validation policy to /data, and a new type has fresh state.
|
||||
let before = run(
|
||||
&mut game,
|
||||
r#"data merge block 2 64 2 {id:"fixture:replacement",fuel:12}"#,
|
||||
);
|
||||
assert_ne!(entity(&game).revision(), before);
|
||||
assert!(entity(&game).data.is_empty());
|
||||
assert_eq!(entity(&game).id, "fixture:replacement");
|
||||
assert_eq!(entity(&game).nbt.get(&"fuel".into()), Some(&Tag::Int(12)));
|
||||
assert_eq!(game.world.block(POS[0], POS[1], POS[2]), state);
|
||||
|
||||
// Persistence/chunk loading still constructs a fresh live entity; neither
|
||||
// the identity nor runtime menu data is encoded in the normal saved tag.
|
||||
let saved = game.data_of(&DataRef::Block(POS)).unwrap();
|
||||
let before = entity(&game).revision();
|
||||
let (_, decoded) = save::decode_block_entity(&saved).unwrap();
|
||||
game.world.block_entities.load_chunk(vec![(KEY, decoded)]);
|
||||
assert_ne!(entity(&game).revision(), before);
|
||||
assert!(entity(&game).data.is_empty());
|
||||
assert_eq!(game.data_of(&DataRef::Block(POS)).unwrap(), saved);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
use super::*;
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::motion::PlayerMotion;
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn tag_commands_and_explicit_empty_loads_save_without_empty_tags() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let scratch = std::env::temp_dir().join(format!(
|
||||
"horizon-saved-tags-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&scratch).unwrap();
|
||||
game.world_root = scratch.clone();
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(10.5, 200.0, 0.5)).unwrap());
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
let start = game.world.game_time;
|
||||
let uuid = "00000148-0000-0000-0000-000000000001";
|
||||
assert!(game.run_command("summon pig 4.5 200 4.5 {UUID:[I;328,0,0,1],Health:10f,NoAI:1b,NoGravity:1b,PortalCooldown:17}"));
|
||||
let target = game.target_with_uuid([328, 0, 0, 1]).unwrap();
|
||||
assert!(matches!(target, Target::Mob(_)));
|
||||
assert!(game.tags_of(target).is_empty());
|
||||
assert!(
|
||||
game.data_of(&DataRef::Entity(target))
|
||||
.unwrap()
|
||||
.get("Tags")
|
||||
.is_none()
|
||||
);
|
||||
for tagged in [true, false, true, false] {
|
||||
assert!(game.run_command(&format!(
|
||||
"tag {uuid} {} audit",
|
||||
if tagged { "add" } else { "remove" }
|
||||
)));
|
||||
let expected: Vec<String> = if tagged { vec!["audit".into()] } else { vec![] };
|
||||
assert_eq!(game.tags_of(target), expected);
|
||||
for _ in 0..2 {
|
||||
let saved = game.data_of(&DataRef::Entity(target)).unwrap();
|
||||
assert_eq!(saved.get("UUID"), Some(&Tag::IntArray(vec![328, 0, 0, 1])));
|
||||
assert_eq!(saved.get("Health"), Some(&Tag::Float(10.0_f32.to_bits())));
|
||||
assert_eq!(saved.get("NoAI"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(saved.get("NoGravity"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(saved.get("PortalCooldown"), Some(&Tag::Int(17)));
|
||||
assert_eq!(
|
||||
saved.get("Tags"),
|
||||
if tagged {
|
||||
Some(Tag::List(vec![Tag::String("audit".into())]))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.as_ref()
|
||||
);
|
||||
assert_eq!(game.tags_of(target), expected);
|
||||
}
|
||||
}
|
||||
assert!(game.run_command(
|
||||
"summon pig 8.5 200 4.5 {UUID:[I;328,0,0,2],NoAI:1b,Tags:[],PortalCooldown:23}"
|
||||
));
|
||||
let empty = game.target_with_uuid([328, 0, 0, 2]).unwrap();
|
||||
assert!(game.tags_of(empty).is_empty());
|
||||
let saved = game.data_of(&DataRef::Entity(empty)).unwrap();
|
||||
assert!(saved.get("Tags").is_none());
|
||||
assert_eq!(saved.get("PortalCooldown"), Some(&Tag::Int(23)));
|
||||
assert!(game.run_command("tag 00000148-0000-0000-0000-000000000002 add keep"));
|
||||
assert_eq!(game.tags_of(empty), ["keep"]);
|
||||
assert_eq!(
|
||||
game.data_of(&DataRef::Entity(empty)).unwrap().get("Tags"),
|
||||
Some(&Tag::List(vec![Tag::String("keep".into())]))
|
||||
);
|
||||
assert_eq!(game.world.game_time, start);
|
||||
drop(game);
|
||||
std::fs::remove_dir_all(scratch).unwrap();
|
||||
}
|
||||
@@ -52,6 +52,20 @@ pub(in crate::game) enum Owner {
|
||||
Block([i32; 3]),
|
||||
}
|
||||
|
||||
/// The standard equipment slots with existing whole-stack storage.
|
||||
fn mob_stack_slot(key: &str) -> Option<usize> {
|
||||
use horizon_client::mobs::finalize;
|
||||
Some(match key {
|
||||
"mainhand" => finalize::MAINHAND,
|
||||
"offhand" => finalize::OFFHAND,
|
||||
"head" => finalize::HEAD,
|
||||
"chest" => finalize::CHEST,
|
||||
"legs" => finalize::LEGS,
|
||||
"feet" => finalize::FEET,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The player's slot names and the inventory index each addresses.
|
||||
fn player_slot_names() -> Vec<(String, usize)> {
|
||||
let mut v = Vec::new();
|
||||
@@ -390,6 +404,20 @@ impl Game<'_> {
|
||||
if *key == "saddle" {
|
||||
return m.saddle_item();
|
||||
}
|
||||
if let Some(slot) = mob_stack_slot(key) {
|
||||
if let Some(stack) = horizon_client::mobs::finalize::stored_stack(m, slot) {
|
||||
return Some(stack);
|
||||
}
|
||||
let worn = ["head", "chest", "legs", "feet"]
|
||||
.iter()
|
||||
.position(|name| name == key);
|
||||
if let Some(stack) = worn.and_then(|i| m.extra.armor[i].clone()).filter(|s| {
|
||||
horizon_client::mobs::finalize::item(m, slot).as_ref() == Some(&s.item)
|
||||
}) {
|
||||
return Some(stack);
|
||||
}
|
||||
return horizon_client::mobs::finalize::item_stack(m, slot);
|
||||
}
|
||||
let item = match *key {
|
||||
"mainhand" => &m.extra.main_hand,
|
||||
"offhand" => &m.extra.off_hand,
|
||||
@@ -569,9 +597,50 @@ impl Game<'_> {
|
||||
None => false,
|
||||
},
|
||||
Slot::Mob(id, key) => {
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == *id) else {
|
||||
let Some(index) = self.mobs.iter().position(|m| m.id == *id) else {
|
||||
return false;
|
||||
};
|
||||
let m = &self.mobs[index];
|
||||
let first_tick = m.first_tick;
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
let old = match *key {
|
||||
"mainhand" => m
|
||||
.extra
|
||||
.main_hand_stack()
|
||||
.cloned()
|
||||
.or_else(|| m.extra.main_hand.as_ref().map(|i| ItemStack::new(i, 1))),
|
||||
"offhand" => m.extra.off_hand.as_ref().map(|i| ItemStack::new(i, 1)),
|
||||
"head" => m.extra.armor[0]
|
||||
.clone()
|
||||
.or_else(|| m.extra.head.as_ref().map(|i| ItemStack::new(i, 1))),
|
||||
"chest" => m.extra.armor[1]
|
||||
.clone()
|
||||
.or_else(|| m.extra.chest.as_ref().map(|i| ItemStack::new(i, 1))),
|
||||
"legs" => m.extra.armor[2]
|
||||
.clone()
|
||||
.or_else(|| m.extra.legs.as_ref().map(|i| ItemStack::new(i, 1))),
|
||||
"feet" => m.extra.armor[3]
|
||||
.clone()
|
||||
.or_else(|| m.extra.feet.as_ref().map(|i| ItemStack::new(i, 1))),
|
||||
"body" => m.extra.body.as_ref().map(|i| ItemStack::new(i, 1)),
|
||||
"saddle" => m.saddle_item(),
|
||||
_ => None,
|
||||
};
|
||||
let same = match (old.as_ref(), stack.as_ref()) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => a.same_item_same_components(b),
|
||||
_ => false,
|
||||
};
|
||||
let equippable = stack.as_ref().is_some_and(|s| {
|
||||
let data = s.data();
|
||||
!data.is_some_and(|d| d.other("!minecraft:equippable").is_some())
|
||||
&& (data.is_some_and(|d| d.other("minecraft:equippable").is_some())
|
||||
|| self
|
||||
.items()
|
||||
.get(&s.item)
|
||||
.is_some_and(|i| i.equip_slot.is_some()))
|
||||
});
|
||||
let m = &mut self.mobs[index];
|
||||
let item = stack.as_ref().map(|s| s.item.clone());
|
||||
// The worn armour stack goes with the id: a kept stack of the
|
||||
// previous piece would otherwise still be what the mob wears.
|
||||
@@ -585,6 +654,13 @@ impl Game<'_> {
|
||||
if let Some(i) = worn {
|
||||
m.extra.armor[i] = stack.clone();
|
||||
}
|
||||
if let Some(slot) = mob_stack_slot(key) {
|
||||
if let Some(stack) = stack.as_ref() {
|
||||
horizon_client::mobs::finalize::set_item_stack(m, slot, stack.clone());
|
||||
} else {
|
||||
horizon_client::mobs::finalize::set_slot_enchantments(m, slot, Vec::new());
|
||||
}
|
||||
}
|
||||
match *key {
|
||||
"mainhand" => m.extra.set_main_hand_stack(stack),
|
||||
"offhand" => m.extra.off_hand = item,
|
||||
@@ -596,6 +672,9 @@ impl Game<'_> {
|
||||
"saddle" => m.set_saddle(stack),
|
||||
_ => return false,
|
||||
}
|
||||
if !first_tick && !same && self.remote.is_none() {
|
||||
self.mob_equip_event(*id, at, equippable);
|
||||
}
|
||||
true
|
||||
}
|
||||
Slot::Stand(id, i) => {
|
||||
@@ -1714,3 +1793,190 @@ mod mob_equipment_command_tests {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod mob_equipment_stack_tests {
|
||||
use crate::game::commands::{Target, data::DataRef};
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::{chunk::Chunk, mobs::finalize};
|
||||
use horizon_core::position::ChunkPos;
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::sync::Arc;
|
||||
fn with_game(test: impl FnOnce(&mut Game<'_>)) {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.game_dir = Some(
|
||||
std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap())
|
||||
.join(format!("h023-{:?}", std::thread::current().id())),
|
||||
);
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
assert!(game.run_command("difficulty normal"));
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
test(&mut game);
|
||||
}
|
||||
|
||||
fn actor(game: &mut Game<'_>, kind: &str, n: i32) -> (String, u32) {
|
||||
let uuid = format!("00000017-0000-0000-0000-{n:012x}");
|
||||
assert!(game.run_command(&format!("summon {kind} 4.5 200 5.5 {{UUID:[I;23,0,0,{n}],NoAI:1b,NoGravity:1b,Silent:1b,h023_raw:7,Tags:[h023]}}")), "{kind}/{n}: {:?}", game.ui.chat.recent_lines(3));
|
||||
let mob = game
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| game.persist.uuid_of(m.id) == Some([23, 0, 0, n]))
|
||||
.unwrap();
|
||||
assert!(mob.no_ai);
|
||||
(uuid, mob.id)
|
||||
}
|
||||
|
||||
fn saved(game: &mut Game<'_>, id: u32) -> Tag {
|
||||
game.data_of(&DataRef::Entity(Target::Mob(id))).unwrap()
|
||||
}
|
||||
fn audit(stack: &horizon_client::items::ItemStack, value: i32) {
|
||||
assert_eq!(
|
||||
stack.custom_data().and_then(|c| c.get(&"audit".into())),
|
||||
Some(&Tag::Int(value))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn offhand_commands_adopt_and_copy_the_whole_stack() {
|
||||
use super::Slot;
|
||||
with_game(|game| {
|
||||
let (uuid, id) = actor(game, "husk", 1);
|
||||
assert!(game.run_command("setblock 4 200 4 chest"));
|
||||
assert!(game.slot_get(&Slot::Mob(id, "offhand")).is_none());
|
||||
assert!(game.run_command(&format!(
|
||||
"item replace entity {uuid} weapon.mainhand with stone"
|
||||
)));
|
||||
assert!(game.run_command(&format!(
|
||||
"item replace entity {uuid} weapon.offhand with stick[custom_data={{audit:7}}] 3"
|
||||
)));
|
||||
let mob = game.mobs.iter().find(|m| m.id == id).unwrap();
|
||||
assert_eq!(mob.extra.off_hand.as_deref(), Some("minecraft:stick"));
|
||||
let adopted = finalize::item_stack(mob, finalize::OFFHAND).unwrap();
|
||||
assert_eq!(
|
||||
adopted.count, 3,
|
||||
"actual command adoption must retain count"
|
||||
);
|
||||
audit(&adopted, 7);
|
||||
let tag = saved(game, id);
|
||||
assert_eq!(
|
||||
tag.get("equipment")
|
||||
.unwrap()
|
||||
.get("offhand")
|
||||
.unwrap()
|
||||
.get("count"),
|
||||
Some(&Tag::Int(3))
|
||||
);
|
||||
assert_eq!(
|
||||
tag.get("equipment")
|
||||
.unwrap()
|
||||
.get("mainhand")
|
||||
.unwrap()
|
||||
.get("id"),
|
||||
Some(&Tag::String("minecraft:stone".into()))
|
||||
);
|
||||
assert!(game.run_command(&format!(
|
||||
"item replace block 4 200 4 container.0 from entity {uuid} weapon.offhand"
|
||||
)));
|
||||
let copied = game.slot_get(&Slot::Block([4, 200, 4], 0)).unwrap();
|
||||
assert_eq!(copied.count, 3);
|
||||
audit(&copied, 7);
|
||||
assert!(game.run_command(&format!(
|
||||
"item replace entity {uuid} weapon.offhand with air"
|
||||
)));
|
||||
assert!(game.slot_get(&Slot::Mob(id, "offhand")).is_none());
|
||||
assert!(game.run_command(&format!(
|
||||
"item replace entity {uuid} weapon.offhand with stick"
|
||||
)));
|
||||
let plain = game.slot_get(&Slot::Mob(id, "offhand")).unwrap();
|
||||
assert_eq!(plain.count, 1);
|
||||
assert!(
|
||||
plain.custom_data().is_none(),
|
||||
"stale components must not return for the same id"
|
||||
);
|
||||
});
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn armor_command_copy_keeps_the_already_adopted_stack() {
|
||||
use super::Slot;
|
||||
with_game(|game| {
|
||||
let (uuid, id) = actor(game, "husk", 2);
|
||||
assert!(game.run_command("setblock 4 200 4 chest"));
|
||||
assert!(game.run_command(&format!("item replace entity {uuid} armor.head with iron_helmet[damage=3,custom_data={{audit:8}}]")));
|
||||
let mob = game.mobs.iter().find(|m| m.id == id).unwrap();
|
||||
let adopted = mob.extra.armor[0].as_ref().unwrap();
|
||||
assert_eq!(
|
||||
adopted.damage, 3,
|
||||
"existing armor state already adopts the full stack"
|
||||
);
|
||||
audit(adopted, 8);
|
||||
assert!(game.run_command(&format!(
|
||||
"item replace block 4 200 4 container.0 from entity {uuid} armor.head"
|
||||
)));
|
||||
let copied = game.slot_get(&Slot::Block([4, 200, 4], 0)).unwrap();
|
||||
assert_eq!(
|
||||
copied.damage, 3,
|
||||
"actual command copy must use the full stack"
|
||||
);
|
||||
audit(&copied, 8);
|
||||
let tag = saved(game, id);
|
||||
let components = tag
|
||||
.get("equipment")
|
||||
.unwrap()
|
||||
.get("head")
|
||||
.unwrap()
|
||||
.get("components")
|
||||
.unwrap();
|
||||
assert_eq!(components.get("minecraft:damage"), Some(&Tag::Int(3)));
|
||||
assert_eq!(
|
||||
components
|
||||
.get("minecraft:custom_data")
|
||||
.unwrap()
|
||||
.get("audit"),
|
||||
Some(&Tag::Int(8))
|
||||
);
|
||||
assert!(game.run_command(&format!(
|
||||
"item replace entity {uuid} armor.head with iron_helmet"
|
||||
)));
|
||||
let plain = game.slot_get(&Slot::Mob(id, "head")).unwrap();
|
||||
assert_eq!(plain.damage, 0);
|
||||
assert!(plain.custom_data().is_none());
|
||||
assert!(game.run_command(&format!("item replace entity {uuid} armor.head with air")));
|
||||
assert!(game.slot_get(&Slot::Mob(id, "head")).is_none());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -531,6 +531,7 @@ impl Game<'_> {
|
||||
/// `LootCommand.dropBlockLoot`: copy the actual block entity's components
|
||||
/// into the mine result without removing the source block or its contents.
|
||||
pub(super) fn cmd_loot(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
let keep_empty = ctx.literals.iter().any(|literal| literal == "replace");
|
||||
// The drops.
|
||||
let (drops, table): (Vec<ItemStack>, Option<String>) = match id {
|
||||
"loot.loot" | "loot.fish" => {
|
||||
@@ -553,16 +554,18 @@ impl Game<'_> {
|
||||
let drops = match json["random_sequence"].as_str() {
|
||||
Some(sequence) => {
|
||||
let random = self.rand_sequences.get(sequence, self.level.seed);
|
||||
world_items!(self).inline_loot_table_with_tool(
|
||||
world_items!(self).inline_loot_table_for_command(
|
||||
&json,
|
||||
held.as_ref(),
|
||||
random,
|
||||
keep_empty,
|
||||
)
|
||||
}
|
||||
None => world_items!(self).inline_loot_table_with_tool(
|
||||
None => world_items!(self).inline_loot_table_for_command(
|
||||
&json,
|
||||
held.as_ref(),
|
||||
&mut self.world.random,
|
||||
keep_empty,
|
||||
),
|
||||
};
|
||||
(drops, None)
|
||||
@@ -578,13 +581,20 @@ impl Game<'_> {
|
||||
let drops = match world_items!(self).loot_sequence(&table) {
|
||||
Some(seq) => {
|
||||
let random = self.rand_sequences.get(&seq, self.level.seed);
|
||||
world_items!(self).loot_table(&table, false, held.as_ref(), random)
|
||||
world_items!(self).loot_table_for_command(
|
||||
&table,
|
||||
false,
|
||||
held.as_ref(),
|
||||
random,
|
||||
keep_empty,
|
||||
)
|
||||
}
|
||||
None => world_items!(self).loot_table(
|
||||
None => world_items!(self).loot_table_for_command(
|
||||
&table,
|
||||
false,
|
||||
held.as_ref(),
|
||||
&mut self.world.random,
|
||||
keep_empty,
|
||||
),
|
||||
};
|
||||
(drops, None)
|
||||
@@ -613,13 +623,20 @@ impl Game<'_> {
|
||||
let drops = match world_items!(self).loot_sequence(&table) {
|
||||
Some(seq) => {
|
||||
let random = self.rand_sequences.get(&seq, self.level.seed);
|
||||
world_items!(self).entity_drops(&ty, by_player, held.as_ref(), random)
|
||||
world_items!(self).entity_drops_for_command(
|
||||
&ty,
|
||||
by_player,
|
||||
held.as_ref(),
|
||||
random,
|
||||
keep_empty,
|
||||
)
|
||||
}
|
||||
None => world_items!(self).entity_drops(
|
||||
None => world_items!(self).entity_drops_for_command(
|
||||
&ty,
|
||||
by_player,
|
||||
held.as_ref(),
|
||||
&mut self.world.random,
|
||||
keep_empty,
|
||||
),
|
||||
};
|
||||
(drops, Some(table))
|
||||
@@ -649,18 +666,20 @@ impl Game<'_> {
|
||||
let mut drops = match world_items!(self).loot_sequence(&table) {
|
||||
Some(seq) => {
|
||||
let random = self.rand_sequences.get(&seq, self.level.seed);
|
||||
world_items!(self).block_drops(
|
||||
world_items!(self).block_drops_for_command(
|
||||
self.pack.assets.registry,
|
||||
state,
|
||||
tool.as_ref(),
|
||||
random,
|
||||
keep_empty,
|
||||
)
|
||||
}
|
||||
None => world_items!(self).block_drops(
|
||||
None => world_items!(self).block_drops_for_command(
|
||||
self.pack.assets.registry,
|
||||
state,
|
||||
tool.as_ref(),
|
||||
&mut self.world.random,
|
||||
keep_empty,
|
||||
),
|
||||
};
|
||||
if has_block_entity {
|
||||
@@ -671,7 +690,10 @@ impl Game<'_> {
|
||||
(drops, Some(table))
|
||||
}
|
||||
};
|
||||
let drops: Vec<ItemStack> = drops.into_iter().filter(|d| d.count > 0).collect();
|
||||
let drops: Vec<ItemStack> = drops
|
||||
.into_iter()
|
||||
.filter(|d| keep_empty || d.count > 0)
|
||||
.collect();
|
||||
// Where they go.
|
||||
let mut used: Vec<ItemStack> = Vec::new();
|
||||
let literals = &ctx.literals;
|
||||
@@ -795,7 +817,7 @@ impl Game<'_> {
|
||||
None => first.first().cloned().filter(|_| i == 0),
|
||||
};
|
||||
let Some(slot) = slot else { continue };
|
||||
let item = drops.get(i).cloned();
|
||||
let item = drops.get(i).filter(|drop| drop.count > 0).cloned();
|
||||
if self.slot_accepts(&slot, item.as_ref()) && self.slot_set(&slot, item.clone())
|
||||
{
|
||||
used.push(item.unwrap_or(ItemStack {
|
||||
@@ -820,7 +842,7 @@ impl Game<'_> {
|
||||
false,
|
||||
None,
|
||||
|h, _| {
|
||||
let n = counts.iter().copied().find(|c| *c > 0).unwrap_or(1);
|
||||
let n = counts.iter().copied().find(|c| *c > 0).unwrap_or(0);
|
||||
match &table {
|
||||
Some(t) => Text::translate(
|
||||
"commands.drop.success.single_with_table",
|
||||
@@ -1064,3 +1086,108 @@ mod loot_mine_component_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod loot_position_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn actual_loot_replacement_retains_the_emitted_zero_slot() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
|
||||
game.set_allow_commands(true);
|
||||
game.world.chunks.insert(
|
||||
(0, 0),
|
||||
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
|
||||
);
|
||||
game.block_ticks
|
||||
.add_empty_chunk(horizon_core::position::ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
assert!(game.run_command("setblock 4 200 4 chest"));
|
||||
let slot = |game: &mut Game<'_>, i| {
|
||||
game.slot_get(&crate::game::commands::items::Slot::Block([4, 200, 4], i))
|
||||
};
|
||||
let reset = |game: &mut Game<'_>| {
|
||||
assert!(game.run_command("item replace block 4 200 4 container.0 with diamond 3"));
|
||||
assert!(game.run_command("item replace block 4 200 4 container.1 with emerald 4"));
|
||||
assert!(game.run_command("item replace block 4 200 4 container.2 with gold_ingot 5"));
|
||||
assert_eq!(
|
||||
slot(game, 0).map(|s| (s.item.to_string(), s.count)),
|
||||
Some(("minecraft:diamond".into(), 3))
|
||||
);
|
||||
assert_eq!(
|
||||
slot(game, 1).map(|s| (s.item.to_string(), s.count)),
|
||||
Some(("minecraft:emerald".into(), 4))
|
||||
);
|
||||
};
|
||||
let table = |counts: &[i32]| {
|
||||
serde_json::json!({"pools":counts.iter().map(|count| serde_json::json!({
|
||||
"rolls":1,"entries":[{"type":"minecraft:item","name":"minecraft:iron_ingot",
|
||||
"modifier":{"type":"minecraft:set_count","count":count}}]
|
||||
})).collect::<Vec<_>>()})
|
||||
.to_string()
|
||||
};
|
||||
let time = game.world.game_time;
|
||||
reset(&mut game);
|
||||
assert!(game.run_command(&format!(
|
||||
"loot replace block 4 200 4 container.0 loot {}",
|
||||
table(&[1])
|
||||
)));
|
||||
assert_eq!(
|
||||
slot(&mut game, 0).map(|s| (s.item.to_string(), s.count)),
|
||||
Some(("minecraft:iron_ingot".into(), 1))
|
||||
);
|
||||
reset(&mut game);
|
||||
assert!(game.run_command(&format!(
|
||||
"loot replace block 4 200 4 container.0 loot {}",
|
||||
table(&[0, 1])
|
||||
)));
|
||||
assert!(
|
||||
slot(&mut game, 0).is_none(),
|
||||
"the emitted zero occupies and clears slot0"
|
||||
);
|
||||
assert_eq!(
|
||||
slot(&mut game, 1).map(|s| (s.item.to_string(), s.count)),
|
||||
Some(("minecraft:iron_ingot".into(), 1))
|
||||
);
|
||||
assert_eq!(slot(&mut game, 2).unwrap().count, 5);
|
||||
reset(&mut game);
|
||||
assert!(game.run_command(&format!(
|
||||
"loot replace block 4 200 4 container.0 2 loot {}",
|
||||
table(&[])
|
||||
)));
|
||||
assert!(slot(&mut game, 0).is_none());
|
||||
assert!(slot(&mut game, 1).is_none());
|
||||
assert_eq!(slot(&mut game, 2).unwrap().count, 5);
|
||||
assert_eq!(game.world.game_time, time);
|
||||
}
|
||||
}
|
||||
@@ -502,6 +502,10 @@ impl Game<'_> {
|
||||
for (mob, target) in reinforcements {
|
||||
self.call_reinforcements(mob, target);
|
||||
}
|
||||
// Enderman.hurtServer's teleport, refused hit or not, before the command returns.
|
||||
if let Err(e) = self.settle_hurt_teleports() {
|
||||
log::warn!("Horizon: /damage: enderman teleport: {e}");
|
||||
}
|
||||
hurt
|
||||
}
|
||||
|
||||
|
||||
@@ -242,7 +242,7 @@ impl Game<'_> {
|
||||
self.world_options_text(None);
|
||||
return Ok(());
|
||||
}
|
||||
Screen::Inventory if self.mod_browser_typing() => {
|
||||
Screen::Inventory | Screen::Creative if self.mod_browser_typing() => {
|
||||
self.mod_browser_text(None, true);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -301,7 +301,7 @@ impl Game<'_> {
|
||||
Screen::Options => self.ui.options.type_text(&self.gui.atlas, text),
|
||||
// The port, or the game rules' search and number boxes.
|
||||
Screen::WorldOptions => self.world_options_text(Some(text)),
|
||||
Screen::Inventory if self.mod_browser_typing() => {
|
||||
Screen::Inventory | Screen::Creative if self.mod_browser_typing() => {
|
||||
self.mod_browser_text(Some(text), false)
|
||||
}
|
||||
Screen::Inventory if self.recipe_search_focused() => {
|
||||
@@ -608,6 +608,9 @@ impl Game<'_> {
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if arg(2)? != "up" {
|
||||
self.pad.keyboard_took_over = true;
|
||||
}
|
||||
// The command confirmation takes the keys; none go past it.
|
||||
if self.command_prompt.is_some() {
|
||||
if arg(2)? != "up" {
|
||||
|
||||
@@ -314,8 +314,7 @@ impl Game<'_> {
|
||||
let antenna = self.mobs[index].saddle_item();
|
||||
self.mobs[index].set_saddle(None);
|
||||
let id = self.mobs[index].id;
|
||||
let ctx = self.who_context(Some(horizon_client::mobs::Who::Mob(id)), None);
|
||||
self.post_game_event("minecraft:unequip", at, ctx);
|
||||
self.mob_equip_event(id, at, false);
|
||||
if let Some(a) = antenna {
|
||||
let p = self.mobs[index].position;
|
||||
self.spawn_at_location(p, 1.5, a);
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
//! Unit tests that lived in game.rs's inline `tests` module.
|
||||
use super::*;
|
||||
use horizon_client::container_screen::{Button, Clicks, Release, Target};
|
||||
use horizon_client::keymapping::{KeyAction, SNEAK, SPRINT, ToggleKeys};
|
||||
|
||||
/// `ClipContext.Block.OUTLINE` for the SOURCE_ONLY ray (#8180): a ray
|
||||
/// through the empty part of a torch's cell is not stopped by it.
|
||||
#[test]
|
||||
fn the_fluid_ray_passes_through_the_hollow_of_a_torch() {
|
||||
// A torch: a 2x10x2 pillar in the middle of its cell, at (0, 0, 0).
|
||||
let torch = || vec![[0.4375, 0.0, 0.4375, 0.5625, 0.625, 0.5625]];
|
||||
let dir = [0.0, 0.0, 1.0];
|
||||
// Beside the pillar: no hit, the ray goes on to the water behind.
|
||||
assert!(outline_stop([0.1, 0.3, -1.0], dir, 5.0, [0, 0, 0], [0, 0, -1], torch).is_none());
|
||||
// Above the pillar (a torch is 10/16 tall): no hit either.
|
||||
assert!(outline_stop([0.5, 0.8, -1.0], dir, 5.0, [0, 0, 0], [0, 0, -1], torch).is_none());
|
||||
// Through the pillar: stopped on its front face, not at the cell's edge.
|
||||
let hit = outline_stop([0.5, 0.3, -1.0], dir, 5.0, [0, 0, 0], [0, 0, -1], torch)
|
||||
.expect("the pillar stops the ray");
|
||||
assert_eq!(hit.face, [0, 0, -1]);
|
||||
assert!((hit.point[2] - 0.4375).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_death_drops_the_off_hand_before_the_armour() {
|
||||
let stack = |item: &str| Some(ItemStack::new(item, 1));
|
||||
let mut inventory = vec![stack("minecraft:diamond"), None, stack("minecraft:stick")];
|
||||
let mut offhand = stack("minecraft:shield");
|
||||
let mut armor = [
|
||||
stack("minecraft:iron_helmet"),
|
||||
stack("minecraft:iron_chestplate"),
|
||||
stack("minecraft:iron_leggings"),
|
||||
stack("minecraft:iron_boots"),
|
||||
];
|
||||
let dropped: Vec<String> = death_drops(&mut inventory, &mut offhand, &mut armor)
|
||||
.into_iter()
|
||||
.map(|s| s.item.to_string())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
dropped,
|
||||
[
|
||||
"minecraft:diamond",
|
||||
"minecraft:stick",
|
||||
"minecraft:shield",
|
||||
"minecraft:iron_boots",
|
||||
"minecraft:iron_leggings",
|
||||
"minecraft:iron_chestplate",
|
||||
"minecraft:iron_helmet",
|
||||
]
|
||||
);
|
||||
assert!(inventory.iter().all(Option::is_none));
|
||||
assert!(offhand.is_none() && armor.iter().all(Option::is_none));
|
||||
}
|
||||
|
||||
/// `MinecraftServer.computeNextAutosaveInterval`: three hundred seconds of
|
||||
/// ticks at the rate the server runs at, never fewer than a hundred.
|
||||
#[test]
|
||||
fn the_autosave_waits_for_three_hundred_seconds_of_ticks() {
|
||||
// 20 ticks a second is the six thousand ticks a world autosaved on
|
||||
// before the rate could be changed (`/tick rate`).
|
||||
assert_eq!(autosave_interval(20.0), 6000);
|
||||
assert_eq!(autosave_interval(10.0), 3000);
|
||||
assert_eq!(autosave_interval(40.0), 12000);
|
||||
assert_eq!(autosave_interval(1000.0), 300_000);
|
||||
// `Math.max(100, ...)`: a very low rate still autosaves every hundred
|
||||
// ticks, and a frozen clock (0) does too.
|
||||
assert_eq!(autosave_interval(0.5), 150);
|
||||
assert_eq!(autosave_interval(0.0), 100);
|
||||
}
|
||||
|
||||
/// `Minecraft.<init>`: only an explicit `startedCleanly: false` is an
|
||||
/// unclean start, and it leaves fullscreen; the packs stay selected.
|
||||
#[test]
|
||||
fn an_unclean_start_leaves_fullscreen_and_keeps_the_packs() {
|
||||
let fullscreen = || Settings {
|
||||
fullscreen: true,
|
||||
resource_packs: vec!["pack".into()],
|
||||
..Default::default()
|
||||
};
|
||||
// The key absent (a first boot) is a clean start.
|
||||
let mut settings = fullscreen();
|
||||
assert!(!unclean_start_recovery(&mut settings));
|
||||
assert!(settings.fullscreen);
|
||||
let mut settings = fullscreen();
|
||||
settings.file.fullscreen_resolution = Some("1920x1080@60:24".into());
|
||||
settings.file.set(
|
||||
"startedCleanly",
|
||||
horizon_client::options_file::V::Bool(false),
|
||||
);
|
||||
assert!(unclean_start_recovery(&mut settings));
|
||||
assert!(!settings.fullscreen);
|
||||
assert_eq!(settings.file.fullscreen_resolution, None);
|
||||
assert_eq!(settings.resource_packs, ["pack"]);
|
||||
let mut settings = fullscreen();
|
||||
settings.file.set(
|
||||
"startedCleanly",
|
||||
horizon_client::options_file::V::Bool(true),
|
||||
);
|
||||
assert!(!unclean_start_recovery(&mut settings));
|
||||
assert!(settings.fullscreen);
|
||||
}
|
||||
|
||||
/// `NativeScreenView.id` (the `screen_render_post` hook) is a mod's
|
||||
/// contract: every `Screen` has its own id, and it does not change.
|
||||
/// `Screen::id`'s match makes a new variant give itself one; this list
|
||||
/// makes the test cover it too.
|
||||
#[test]
|
||||
fn screen_ids_are_distinct_and_stable() {
|
||||
use std::collections::BTreeSet;
|
||||
let all = [
|
||||
Screen::Playing,
|
||||
Screen::GameModeSwitcher,
|
||||
Screen::EmoteWheel,
|
||||
Screen::Paused,
|
||||
Screen::Options,
|
||||
Screen::Chat,
|
||||
Screen::Creative,
|
||||
Screen::Inventory,
|
||||
Screen::Dead,
|
||||
Screen::Stats,
|
||||
Screen::Advancements,
|
||||
Screen::WorldOptions,
|
||||
Screen::Friends,
|
||||
Screen::Social,
|
||||
Screen::CommandBlock,
|
||||
Screen::BlockEditor,
|
||||
Screen::ServerLinks,
|
||||
Screen::ConfirmLink,
|
||||
Screen::DialogWarning,
|
||||
Screen::Win,
|
||||
Screen::DebugOptions,
|
||||
];
|
||||
let ids: Vec<&'static str> = all.iter().map(|screen| screen.id()).collect();
|
||||
let unique: BTreeSet<&'static str> = ids.iter().copied().collect();
|
||||
assert_eq!(unique.len(), all.len(), "two screens share an id");
|
||||
for id in &ids {
|
||||
assert!(!id.is_empty(), "an empty screen id");
|
||||
assert!(
|
||||
id.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
|
||||
"{id} is not a snake_case id"
|
||||
);
|
||||
}
|
||||
// The ones a mod can match on in the hook's docs.
|
||||
assert_eq!(Screen::Playing.id(), "playing");
|
||||
assert_eq!(Screen::Inventory.id(), "inventory");
|
||||
assert_eq!(Screen::Win.id(), "win");
|
||||
}
|
||||
|
||||
/// The `modifiers` a native mod's input hooks hand over are GLFW's bits
|
||||
/// (`GLFW_MOD_SHIFT` 1, `GLFW_MOD_CONTROL` 2, `GLFW_MOD_ALT` 4,
|
||||
/// `GLFW_MOD_SUPER` 8), each on its own and all together.
|
||||
#[test]
|
||||
fn glfw_modifier_bits_are_glfw_modifier_bits() {
|
||||
use winit::keyboard::ModifiersState;
|
||||
for (state, bits) in [
|
||||
(ModifiersState::empty(), 0),
|
||||
(ModifiersState::SHIFT, 1),
|
||||
(ModifiersState::CONTROL, 2),
|
||||
(ModifiersState::ALT, 4),
|
||||
(ModifiersState::SUPER, 8),
|
||||
(ModifiersState::SHIFT | ModifiersState::ALT, 5),
|
||||
(ModifiersState::CONTROL | ModifiersState::SUPER, 10),
|
||||
(ModifiersState::SHIFT | ModifiersState::CONTROL, 3),
|
||||
(
|
||||
ModifiersState::SHIFT
|
||||
| ModifiersState::CONTROL
|
||||
| ModifiersState::ALT
|
||||
| ModifiersState::SUPER,
|
||||
15,
|
||||
),
|
||||
] {
|
||||
assert_eq!(glfw_modifiers(state), bits, "{state:?}");
|
||||
}
|
||||
// Every modifier GLFW has a bit for, and no other.
|
||||
assert_eq!(glfw_modifiers(ModifiersState::all()), 15);
|
||||
}
|
||||
|
||||
/// The `button` a native mod's mouse hooks hand over is GLFW's index (0
|
||||
/// left, 1 right, 2 middle, 3 back, 4 forward, else the button's number),
|
||||
/// the one `keybinds::mouse_name` names (`key.mouse.<index + 1>` from the
|
||||
/// back button on).
|
||||
#[test]
|
||||
fn glfw_button_indices_are_glfw_indices() {
|
||||
for (button, index, name) in [
|
||||
(MouseButton::Left, 0, "key.mouse.left"),
|
||||
(MouseButton::Right, 1, "key.mouse.right"),
|
||||
(MouseButton::Middle, 2, "key.mouse.middle"),
|
||||
(MouseButton::Back, 3, "key.mouse.4"),
|
||||
(MouseButton::Forward, 4, "key.mouse.5"),
|
||||
] {
|
||||
assert_eq!(glfw_button(button), index);
|
||||
assert_eq!(horizon_client::keybinds::mouse_name(button), name);
|
||||
}
|
||||
for n in 5..12u16 {
|
||||
assert_eq!(glfw_button(MouseButton::Other(n)), i32::from(n));
|
||||
assert_eq!(
|
||||
horizon_client::keybinds::mouse_name(MouseButton::Other(n)),
|
||||
format!("key.mouse.{}", n + 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// F3 left on when leaving a world kept the axes on the title screen.
|
||||
#[test]
|
||||
fn the_debug_crosshair_is_gone_once_the_world_is() {
|
||||
assert!(debug_crosshair_drawn(true, true, false, false));
|
||||
assert!(!debug_crosshair_drawn(true, true, false, true));
|
||||
}
|
||||
|
||||
/// Snuffing a candle or a candle cake draws one smoke per
|
||||
/// `getParticleOffsets`: by `candles` for a candle, a block up for a cake.
|
||||
#[test]
|
||||
fn a_snuffed_candle_and_candle_cake_smoke_at_vanillas_offsets() {
|
||||
assert_eq!(
|
||||
Game::candle_offsets("minecraft:candle", Some(1)),
|
||||
[[8u8, 8, 8]].as_slice()
|
||||
);
|
||||
assert_eq!(
|
||||
Game::candle_offsets("minecraft:red_candle", Some(3)),
|
||||
[[8u8, 5, 10], [6, 7, 8], [9, 8, 7]].as_slice()
|
||||
);
|
||||
assert_eq!(
|
||||
Game::candle_offsets("minecraft:candle_cake", None),
|
||||
[[8u8, 16, 8]].as_slice()
|
||||
);
|
||||
}
|
||||
|
||||
/// Held items apply their main-hand and off-hand modifiers to the player (#4118, #4777).
|
||||
#[test]
|
||||
fn equipment_modifiers_follow_their_slot_group() {
|
||||
assert!(equipment_modifier_applies("mainhand", "mainhand"));
|
||||
assert!(equipment_modifier_applies("hand", "mainhand"));
|
||||
assert!(equipment_modifier_applies("any", "mainhand"));
|
||||
assert!(!equipment_modifier_applies("armor", "mainhand"));
|
||||
assert!(!equipment_modifier_applies("mainhand", "head"));
|
||||
assert!(equipment_modifier_applies("armor", "chest"));
|
||||
assert!(!equipment_modifier_applies("hand", "feet"));
|
||||
assert!(equipment_modifier_applies("offhand", "offhand"));
|
||||
assert!(equipment_modifier_applies("hand", "offhand"));
|
||||
assert!(equipment_modifier_applies("any", "offhand"));
|
||||
assert!(!equipment_modifier_applies("armor", "offhand"));
|
||||
assert!(!equipment_modifier_applies("mainhand", "offhand"));
|
||||
assert!(!equipment_modifier_applies("offhand", "mainhand"));
|
||||
}
|
||||
|
||||
/// A guest's interact packet names its entity and the server keeps it:
|
||||
/// `ServerGamePacketListenerImpl.handleInteract` resolves
|
||||
/// `packet.entityId()` once and `Player.interactOn` clicks that entity,
|
||||
/// while a fresh pick would take the nearest one. Two cows stand one
|
||||
/// behind the other; the packet names the far one.
|
||||
#[test]
|
||||
fn an_interact_packet_clicks_the_entity_it_names_not_the_nearest() {
|
||||
// (server id, distance from the guest) in the level's own order.
|
||||
let cows = [(17u32, 1.5f64), (23, 2.5)];
|
||||
assert_eq!(Game::named_entity(&cows, 23, |c| c.0), Some(1));
|
||||
// The near cow is reachable too, when that is the one named.
|
||||
assert_eq!(Game::named_entity(&cows, 17, |c| c.0), Some(0));
|
||||
// An id the level does not hold clicks nothing (Level.getEntity).
|
||||
assert_eq!(Game::named_entity(&cows, 31, |c| c.0), None);
|
||||
}
|
||||
|
||||
/// The id path keeps the crosshair pick's filters, so a packet cannot name
|
||||
/// a mob the pick would refuse: one in its death animation, the one the
|
||||
/// player rides, or the camera entity. Without them a guest could shear,
|
||||
/// feed or leash a dying mob that `mob_hit` never returns.
|
||||
#[test]
|
||||
fn an_interact_packet_cannot_name_a_mob_the_pick_would_refuse() {
|
||||
use super::creatures::named_mob_pickable;
|
||||
assert!(named_mob_pickable(true, 17, None, None));
|
||||
// Dead: `Mob.alive()` is health > 0.
|
||||
assert!(!named_mob_pickable(false, 17, None, None));
|
||||
// Ridden, and the camera entity (`remote_camera_id`).
|
||||
assert!(!named_mob_pickable(true, 17, Some(17), None));
|
||||
assert!(!named_mob_pickable(true, 17, None, Some(17)));
|
||||
// Another mob being ridden or watched does not block this one.
|
||||
assert!(named_mob_pickable(true, 17, Some(23), Some(29)));
|
||||
}
|
||||
|
||||
/// The packet's location is `EntityHitResult.getLocation() -
|
||||
/// entity.position()`, the point the view ray met the box at measured
|
||||
/// from the mob's feet (`MultiPlayerGameMode.interact`). The old fixed
|
||||
/// [0, 1, 0] was a point inside every mob, which the server's
|
||||
/// `interactAt` paths (armour stands, saddles, leashes) read as the click.
|
||||
#[test]
|
||||
fn an_interact_packet_carries_the_point_the_ray_hit() {
|
||||
let feet = Vec3::new(0.5, 0.0, 2.0);
|
||||
let eye = [0.5, 1.5, 1.0];
|
||||
let dir = [0.0, 0.0, 1.0];
|
||||
let box_at = Aabb::new(feet, Vec3::new(1.5, 2.0, 3.0));
|
||||
let t = ray_box(eye, dir, 3.0, box_at).unwrap();
|
||||
let point = Vec3::new(
|
||||
eye[0] + dir[0] * t,
|
||||
eye[1] + dir[1] * t,
|
||||
eye[2] + dir[2] * t,
|
||||
);
|
||||
// The ray enters the box's near face halfway up it, directly above
|
||||
// the feet: not the old centre point of the entity.
|
||||
assert_eq!(interact_location(point, feet), [0.0, 1.5, 0.0]);
|
||||
}
|
||||
|
||||
/// A mod screen on a hopper or dispenser menu owns its slots: the stock
|
||||
/// grid stands back so `slot_position` reaches the mod's own slots
|
||||
/// (`native_screen_slot`) and its `inventory_y` (#5141). Without a mod
|
||||
/// screen the stock layout answers exactly as before, which is what keeps
|
||||
/// this invisible to players who run no native mods.
|
||||
#[test]
|
||||
fn a_mod_screen_on_a_machine_menu_takes_the_stock_layout_out() {
|
||||
let stock = Some((8.0, 109.0));
|
||||
// A mod screen is open: the stock grid does not answer.
|
||||
assert_eq!(Game::stock_machine_slot(stock, true), None);
|
||||
// No mod screen: the hopper and dispenser layouts are untouched.
|
||||
assert_eq!(Game::stock_machine_slot(stock, false), stock);
|
||||
// Other menus have no stock layout to stand back from.
|
||||
assert_eq!(Game::stock_machine_slot(None, true), None);
|
||||
assert_eq!(Game::stock_machine_slot(None, false), None);
|
||||
}
|
||||
|
||||
/// A key bound to key.attack keeps attacking while it is held: each
|
||||
/// repeat clicks like the press (#3243).
|
||||
#[test]
|
||||
fn a_held_key_bound_to_a_button_repeats_its_clicks() {
|
||||
assert_eq!(keyboard_button(true, KeyAction::Press), Some(true));
|
||||
assert_eq!(keyboard_button(true, KeyAction::Repeat), Some(true));
|
||||
assert_eq!(keyboard_button(true, KeyAction::Release), Some(false));
|
||||
for action in [KeyAction::Press, KeyAction::Repeat, KeyAction::Release] {
|
||||
assert_eq!(keyboard_button(false, action), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// A server dialog, a book, a sign editor or a prompt over the world is a
|
||||
/// screen: held keys and the mouse buttons are released
|
||||
/// (`KeyMapping.releaseAll`), and toggled sneak comes back when it closes
|
||||
/// (#4974, #9729).
|
||||
#[test]
|
||||
fn a_dialog_releases_held_keys_like_a_screen() {
|
||||
assert!(gameplay_has_keyboard(Screen::Playing, false, false));
|
||||
assert!(!gameplay_has_keyboard(Screen::Playing, false, true));
|
||||
assert!(!gameplay_has_keyboard(Screen::Playing, true, false));
|
||||
assert!(!gameplay_has_keyboard(Screen::Chat, false, false));
|
||||
|
||||
let mut keys = horizon_client::held::HeldKeys::from_iter([KeyCode::KeyW]);
|
||||
let mut left = true;
|
||||
let mut right = true;
|
||||
drop_held_behind_dialog(false, &mut keys, &mut left, &mut right);
|
||||
assert!(keys.raw(&KeyCode::KeyW) && left && right);
|
||||
drop_held_behind_dialog(true, &mut keys, &mut left, &mut right);
|
||||
assert!(keys.is_empty() && !left && !right);
|
||||
|
||||
let options = [true, false, false, false];
|
||||
let mut toggles = ToggleKeys::default();
|
||||
toggles.key(SNEAK, KeyAction::Press, false, true);
|
||||
toggles.key(SNEAK, KeyAction::Release, false, true);
|
||||
assert!(toggles.is_down(SNEAK));
|
||||
toggles.screen_opened(options);
|
||||
assert!(!toggles.is_down(SNEAK));
|
||||
toggles.screen_closed(options);
|
||||
assert!(toggles.is_down(SNEAK));
|
||||
}
|
||||
/// A key bound to key.pickItem clones over a container screen, as the
|
||||
/// middle button does; a key bound to attack or use does nothing there (#5378).
|
||||
#[test]
|
||||
fn a_key_bound_to_pick_block_clones_behind_a_container_screen() {
|
||||
assert!(container_key_clones(MouseButton::Middle, KeyAction::Press));
|
||||
assert!(container_key_clones(MouseButton::Middle, KeyAction::Repeat));
|
||||
assert!(!container_key_clones(
|
||||
MouseButton::Middle,
|
||||
KeyAction::Release
|
||||
));
|
||||
for button in [MouseButton::Left, MouseButton::Right] {
|
||||
assert!(!container_key_clones(button, KeyAction::Press));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn sleep_fade_lasts_ten_ticks_after_waking() {
|
||||
let alpha = |timer| match hud_overlays::sleep_overlay(320, 240, timer).first() {
|
||||
Some(hud_layout::GuiDraw::Fill { color, .. }) => color >> 24,
|
||||
None => 0,
|
||||
_ => panic!("sleep overlay must be a full-screen fill"),
|
||||
};
|
||||
assert_eq!(tick_sleep_timer(0, false), 0);
|
||||
let mut timer = 0;
|
||||
for _ in 0..50 {
|
||||
timer = tick_sleep_timer(timer, true);
|
||||
}
|
||||
assert_eq!(alpha(timer as i32), 110);
|
||||
for _ in 0..100 {
|
||||
timer = tick_sleep_timer(timer, true);
|
||||
}
|
||||
assert_eq!(timer, 100);
|
||||
assert_eq!(alpha(timer as i32), 220);
|
||||
for _ in 0..5 {
|
||||
timer = tick_sleep_timer(timer, false);
|
||||
}
|
||||
assert_eq!(alpha(timer as i32), 110);
|
||||
for _ in 0..5 {
|
||||
timer = tick_sleep_timer(timer, false);
|
||||
}
|
||||
assert_eq!(timer, 0);
|
||||
assert!(hud_overlays::sleep_overlay(320, 240, timer as i32).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_rest_poll_does_not_wait_for_a_blocked_worker() {
|
||||
let (release, wait) = std::sync::mpsc::channel();
|
||||
let loader = WorldRestLoader {
|
||||
progress: Progress::new(),
|
||||
handle: std::thread::spawn(move || {
|
||||
wait.recv().unwrap();
|
||||
Ok(())
|
||||
}),
|
||||
};
|
||||
assert!(!loader.ready().unwrap());
|
||||
release.send(()).unwrap();
|
||||
loader.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_rest_poll_waits_for_a_slow_worker() {
|
||||
let (release, wait) = std::sync::mpsc::channel();
|
||||
let loader = WorldRestLoader {
|
||||
progress: Progress::new(),
|
||||
handle: std::thread::spawn(move || {
|
||||
wait.recv().unwrap();
|
||||
Ok(())
|
||||
}),
|
||||
};
|
||||
// However long it takes, a worker still loading is not an error.
|
||||
assert!(!loader.ready().unwrap());
|
||||
release.send(()).unwrap();
|
||||
while !loader.ready().unwrap() {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
loader.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_rest_worker_failure_reaches_the_client() {
|
||||
let loader = WorldRestLoader {
|
||||
progress: Progress::new(),
|
||||
handle: std::thread::spawn(|| Err("missing world table".to_owned())),
|
||||
};
|
||||
assert_eq!(
|
||||
loader.finish().unwrap_err().to_string(),
|
||||
"missing world table"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_rest_worker_panic_reaches_the_client() {
|
||||
let loader = WorldRestLoader {
|
||||
progress: Progress::new(),
|
||||
handle: std::thread::spawn(|| panic!("broken loader")),
|
||||
};
|
||||
assert_eq!(
|
||||
loader.finish().unwrap_err().to_string(),
|
||||
"world resource loader panicked"
|
||||
);
|
||||
}
|
||||
|
||||
/// An integrated server thread shares the loaded resources with the client.
|
||||
#[test]
|
||||
fn shared_resources_can_cross_threads() {
|
||||
fn shared<T: Send + Sync>() {}
|
||||
shared::<Resources>();
|
||||
shared::<PackResources>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_creative_tabs_does_not_drop_the_carried_stack_on_release() {
|
||||
let mut creative = Creative::from_json(&serde_json::json!({
|
||||
"schema": 2,
|
||||
"tabs": [
|
||||
{"type": "CATEGORY", "items": ["{count:1,id:\"minecraft:stone\"}"]},
|
||||
{"type": "INVENTORY"}
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
for button in [Button::Left, Button::Right] {
|
||||
let mut clicks = Clicks::default();
|
||||
for tab in [1, 1, 0, 1] {
|
||||
select_creative_tab(&mut creative, &mut clicks, tab);
|
||||
assert_eq!(creative.selected, tab);
|
||||
assert_eq!(creative.tab().inventory, tab == 1);
|
||||
assert_eq!(
|
||||
clicks.release(
|
||||
&Target::<usize>::Outside,
|
||||
button,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
),
|
||||
Release::Nothing
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eyes under water: the ray hits the source the eyes are in, at the eyes
|
||||
/// (#3787), whichever way it looks.
|
||||
#[test]
|
||||
fn a_ray_starting_inside_a_source_hits_it_at_the_start() {
|
||||
let eye = [0.5, 65.62, 0.5];
|
||||
let down = [0.0, -1.0, 0.0];
|
||||
assert_eq!(
|
||||
clip_start_source(eye, down, 4.5, [0, 65, 0], true),
|
||||
Some(([0, 1, 0], eye))
|
||||
);
|
||||
// Without water above, 0.62 is still below the surface (8/9).
|
||||
assert_eq!(
|
||||
clip_start_source(eye, [0.0, 1.0, 0.0], 4.5, [0, 65, 0], false),
|
||||
Some(([0, -1, 0], eye))
|
||||
);
|
||||
// Looking ahead, slightly down: SOUTH's dot product is largest.
|
||||
let ahead = [0.0, -0.2f64, 0.98f64.sqrt()];
|
||||
assert_eq!(
|
||||
clip_start_source(eye, ahead, 4.5, [0, 65, 0], true),
|
||||
Some(([0, 0, -1], eye))
|
||||
);
|
||||
}
|
||||
|
||||
/// Eyes above a surface source's 8/9: a ray going down enters it through
|
||||
/// the top inside the block; level or rising rays miss it.
|
||||
#[test]
|
||||
fn a_ray_starting_above_a_source_surface_enters_through_the_top() {
|
||||
let eye = [0.5, 65.95, 0.5];
|
||||
let hit = clip_start_source(eye, [0.0, -1.0, 0.0], 4.5, [0, 65, 0], false);
|
||||
let (face, point) = hit.unwrap();
|
||||
assert_eq!(face, [0, 1, 0]);
|
||||
assert!((point[1] - (65.0 + f64::from(8.0f32 / 9.0f32))).abs() < 1e-12);
|
||||
assert_eq!(
|
||||
clip_start_source(eye, [1.0, 0.0, 0.0], 4.5, [0, 65, 0], false),
|
||||
None
|
||||
);
|
||||
// Leaves the block sideways before it comes down to the surface.
|
||||
let shallow = [0.995f64.sqrt(), -0.0707, 0.0];
|
||||
assert_eq!(
|
||||
clip_start_source(eye, shallow, 4.5, [0, 65, 0], false),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_drop_requires_ctrl_even_with_sprint_latched() {
|
||||
for sprint in [false, true] {
|
||||
let mut toggles = ToggleKeys::default();
|
||||
if sprint {
|
||||
toggles.key(SPRINT, KeyAction::Press, false, true);
|
||||
toggles.key(SPRINT, KeyAction::Release, false, true);
|
||||
}
|
||||
assert_eq!(toggles.is_down(SPRINT), sprint);
|
||||
|
||||
for held in 0..4 {
|
||||
let mut modifiers = winit::keyboard::ModifiersState::empty();
|
||||
let mut physical_keys = HashSet::from([KeyCode::ShiftLeft, KeyCode::KeyQ]);
|
||||
let sides = [KeyCode::ControlLeft, KeyCode::ControlRight];
|
||||
for (bit, key) in sides.into_iter().enumerate() {
|
||||
if held & (1 << bit) != 0 {
|
||||
horizon_client_input::modifiers::set_key(
|
||||
&mut modifiers,
|
||||
&mut physical_keys,
|
||||
key,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(drop_whole_stack(modifiers), held != 0);
|
||||
for key in sides {
|
||||
horizon_client_input::modifiers::set_key(
|
||||
&mut modifiers,
|
||||
&mut physical_keys,
|
||||
key,
|
||||
false,
|
||||
);
|
||||
}
|
||||
assert!(!drop_whole_stack(modifiers));
|
||||
assert_eq!(toggles.is_down(SPRINT), sprint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
//! The creative inventory screen (`CreativeModeInventoryScreen`): its tabs, pages, tooltips,
|
||||
//! trash slot, search box and clicks.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `CreativeModeInventoryScreen.hasPermissions` (`canUseGameMasterBlocks`
|
||||
/// and the Operator Items Tab option) given to `tryRefreshInvalidatedTabs`,
|
||||
/// as the screen's constructor and `containerTick` do.
|
||||
pub(super) fn refresh_creative_tabs(&mut self) {
|
||||
let permissions = self.can_use_game_master_blocks() && self.settings.operator_items_tab;
|
||||
self.creative_screen
|
||||
.set_permissions(permissions, &self.pack.names);
|
||||
}
|
||||
pub(super) fn creative_inventory_open(&self) -> bool {
|
||||
self.screen == Screen::Creative && self.creative_screen.tab().inventory
|
||||
}
|
||||
/// An item tab of the creative screen (its ItemPickerMenu).
|
||||
pub(super) fn creative_tab_open(&self) -> bool {
|
||||
self.screen == Screen::Creative && !self.creative_screen.tab().inventory
|
||||
}
|
||||
|
||||
pub(super) fn creative_origin(batch: &HudBatch) -> (f32, f32) {
|
||||
batch.centered_origin(Creative::WIDTH, Creative::HEIGHT)
|
||||
}
|
||||
/// A creative tab's title: `itemGroup.*` for vanilla's; a mod tab's
|
||||
/// `itemGroup.<mod>.<name>`, else its `title` as a lang key (a mod's own
|
||||
/// `itemGroup.<mod>`), else `title` as English text.
|
||||
pub(super) fn creative_tab_title(&self, tab: &horizon_client::creative::Tab) -> String {
|
||||
match horizon_client::creative::item_group_key(&tab.id) {
|
||||
Some(key) => self.translate(key, &tab.title),
|
||||
None => {
|
||||
let own = self.translate(&tab.title, &tab.title);
|
||||
self.translate(&format!("itemGroup.{}", tab.id.replace(':', ".")), &own)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Vanilla tooltip: dark box with a purple gradient border, laid out and
|
||||
/// drawn like an item tooltip (4 px of padding on every side).
|
||||
pub(super) fn tooltip(&self, batch: &mut HudBatch, text: &str, x: f32, y: f32) {
|
||||
let atlas = &self.gui.atlas;
|
||||
let lines = vec![(horizon_client::text::Text::literal(text), u32::MAX)];
|
||||
let (x, y, w, h) =
|
||||
Self::tooltip_geometry(atlas, &lines, (x, y), (batch.width, batch.height), [0.0; 2]);
|
||||
self.tooltip_box(batch, None, x, y, w, h);
|
||||
Self::tooltip_text(batch, atlas, &lines, x, y);
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.getTooltipFromContainerItem`: the lines
|
||||
/// that follow the item's name, none for a category tab's own grid slots
|
||||
/// (`grid`): the names of the tabs containing the stack, in blue, then on
|
||||
/// the search tab's grid the tags a `#` search shows, in dark purple.
|
||||
pub(super) fn creative_tooltip_tabs(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
grid: bool,
|
||||
) -> Vec<(horizon_client::text::Text, u32)> {
|
||||
let screen = &self.creative_screen;
|
||||
let tab = screen.tab();
|
||||
let category = !tab.search && !tab.inventory;
|
||||
if !horizon_client::creative::tooltip_lists_tabs(category, grid) {
|
||||
return Vec::new();
|
||||
}
|
||||
let blue = rgba(85, 85, 255, 255);
|
||||
let dark_purple = rgba(170, 0, 170, 255);
|
||||
let mut lines: Vec<(horizon_client::text::Text, u32)> = screen
|
||||
.tabs_containing(stack)
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
let title = self.creative_tab_title(&screen.tabs[i]);
|
||||
(horizon_client::text::Text::literal(title), blue)
|
||||
})
|
||||
.collect();
|
||||
if tab.search && grid {
|
||||
for tag in screen.search_tag_lines(stack) {
|
||||
lines.push((horizon_client::text::Text::literal(tag), dark_purple));
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.extractRenderState`: item slots share the
|
||||
/// container screen's hover target for their highlight and tooltip.
|
||||
pub(super) fn draw_creative(&mut self, batch: &mut HudBatch) {
|
||||
// CreativeModeInventoryScreen: `CommonComponents.EMPTY` for a title.
|
||||
screen_info::begin_container("creative");
|
||||
let (ox, oy) = Self::creative_origin(batch);
|
||||
let selected = self.creative_screen.selected;
|
||||
// Only what is drawn of the tabs: their stacks stay where they are.
|
||||
let tabs = self.creative_screen.tab_headers();
|
||||
let tab = &tabs[selected.min(tabs.len() - 1)];
|
||||
let tab_sprite = |t: &horizon_client::creative::Tab, on: bool| {
|
||||
format!(
|
||||
"gui/sprites/container/creative_inventory/tab_{}_{}_{}",
|
||||
if t.top { "top" } else { "bottom" },
|
||||
if on { "selected" } else { "unselected" },
|
||||
t.column + 1
|
||||
)
|
||||
};
|
||||
self.render_transparent_background("creative", batch);
|
||||
// Unselected tabs behind the panel, then the panel, then the selected tab.
|
||||
for (i, t) in tabs.iter().enumerate() {
|
||||
if i != selected && self.creative_screen.on_page(t) {
|
||||
let (tx, ty) = Creative::tab_position(t);
|
||||
batch.sprite(&self.gui.atlas, &tab_sprite(t, false), ox + tx, oy + ty);
|
||||
let mouse = (self.cursor.0 - ox, self.cursor.1 - oy);
|
||||
if Creative::tab_wants_hand(t, false, mouse) {
|
||||
horizon_client::widgets::request_cursor(
|
||||
horizon_client::widgets::Cursor::PointingHand,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let background = if tab.search {
|
||||
"gui/container/creative_inventory/tab_item_search"
|
||||
} else if tab.inventory {
|
||||
"gui/container/creative_inventory/tab_inventory"
|
||||
} else {
|
||||
"gui/container/creative_inventory/tab_items"
|
||||
};
|
||||
if let Some(mut r) = self.gui.atlas.sprites.get(background).copied() {
|
||||
r.u1 = r.u0 + (r.u1 - r.u0) * Creative::WIDTH / r.w as f32;
|
||||
r.v1 = r.v0 + (r.v1 - r.v0) * Creative::HEIGHT / r.h as f32;
|
||||
batch.rect_uv(ox, oy, Creative::WIDTH, Creative::HEIGHT, r, u32::MAX);
|
||||
}
|
||||
let (tx, ty) = Creative::tab_position(tab);
|
||||
batch.sprite(&self.gui.atlas, &tab_sprite(tab, true), ox + tx, oy + ty);
|
||||
for t in tabs.iter().filter(|t| self.creative_screen.on_page(t)) {
|
||||
let (tx, ty) = Creative::tab_position(t);
|
||||
let stack = ItemStack {
|
||||
item: Arc::from(t.icon.as_str()),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
self.draw_item(
|
||||
batch,
|
||||
&stack,
|
||||
ox + tx + 5.0,
|
||||
oy + ty + if t.top { 9.0 } else { 7.0 },
|
||||
);
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
if !tab.inventory {
|
||||
let title = self.creative_tab_title(tab);
|
||||
batch.text(
|
||||
atlas,
|
||||
&title,
|
||||
ox + 8.0,
|
||||
oy + 6.0,
|
||||
rgba(64, 64, 64, 255),
|
||||
false,
|
||||
);
|
||||
}
|
||||
if tab.search {
|
||||
// CreativeModeInventoryScreen: an unbordered EditBox of 50 at (82, 6).
|
||||
let search = &mut self.creative_screen.search;
|
||||
search.x = ox + 82.0;
|
||||
search.y = oy + 6.0;
|
||||
search.width = 80.0;
|
||||
search.bordered = false;
|
||||
search.focused = true;
|
||||
search.text_color = u32::MAX;
|
||||
search.max_length = clipboard::CREATIVE_SEARCH_CHARS;
|
||||
if horizon_client::widgets::narrating() {
|
||||
search.message = screen_info::tr("itemGroup.search", &[]);
|
||||
}
|
||||
search.draw(batch, atlas, None);
|
||||
}
|
||||
// The hovered stack, and whether it is a grid slot (`CustomCreativeSlot`).
|
||||
let mut hovered: Option<(ItemStack, bool)> = None;
|
||||
// Use the same padded slot bounds as clicks and the other menus, so
|
||||
// highlights and tooltips remain visible between adjacent items.
|
||||
let hovered_slot = self.slot_at(batch, self.cursor.0, self.cursor.1);
|
||||
if tab.inventory {
|
||||
self.draw_player_preview(
|
||||
batch,
|
||||
ox + 73.0,
|
||||
oy + 6.0,
|
||||
ox + 105.0,
|
||||
oy + 49.0,
|
||||
20.0,
|
||||
0.0625,
|
||||
);
|
||||
let trash = Self::creative_trash_hovered(batch, self.cursor);
|
||||
let (tx, ty) = Creative::TRASH_POSITION;
|
||||
if trash {
|
||||
self.draw_slot_highlight(batch, false, ox + tx, oy + ty);
|
||||
}
|
||||
self.draw_hovered_slot_highlight(batch, false, ox, oy);
|
||||
self.draw_inventory_slots(batch, ox, oy);
|
||||
if trash {
|
||||
self.draw_slot_highlight(batch, true, ox + tx, oy + ty);
|
||||
}
|
||||
self.draw_hovered_slot_highlight(batch, true, ox, oy);
|
||||
} else {
|
||||
let items = self.creative_screen.items(&self.pack.names);
|
||||
let first = self.creative_screen.first_row(&self.pack.names);
|
||||
for r in 0..5 {
|
||||
for c in 0..9 {
|
||||
let Some(stack) = items.get((first + r) * 9 + c).filter(|s| s.count > 0) else {
|
||||
continue;
|
||||
};
|
||||
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 18.0 + r as f32 * 18.0);
|
||||
let over = hovered_slot == Some(Creative::DISPLAY + r * 9 + c);
|
||||
if over {
|
||||
self.draw_slot_highlight(batch, false, x, y);
|
||||
}
|
||||
self.draw_item(batch, stack, x, y);
|
||||
if over {
|
||||
self.draw_slot_highlight(batch, true, x, y);
|
||||
hovered = Some((stack.clone(), true));
|
||||
}
|
||||
}
|
||||
}
|
||||
let drag = self.drag_view();
|
||||
for c in 0..9 {
|
||||
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 112.0);
|
||||
let over = hovered_slot == Some(c);
|
||||
if over {
|
||||
self.draw_slot_highlight(batch, false, x, y);
|
||||
}
|
||||
self.draw_slot_item(batch, drag.as_ref(), c, &self.inventory[c], x, y);
|
||||
if over {
|
||||
self.draw_slot_highlight(batch, true, x, y);
|
||||
hovered = self.inventory[c].clone().map(|s| (s, false));
|
||||
}
|
||||
}
|
||||
let scrollable = self.creative_screen.rows(&self.pack.names) > 5;
|
||||
let scroller = if scrollable {
|
||||
"gui/sprites/container/creative_inventory/scroller"
|
||||
} else {
|
||||
"gui/sprites/container/creative_inventory/scroller_disabled"
|
||||
};
|
||||
batch.sprite(
|
||||
&self.gui.atlas,
|
||||
scroller,
|
||||
ox + Creative::SCROLL_X,
|
||||
oy + Creative::SCROLL_Y + self.creative_screen.scroller_offset(),
|
||||
);
|
||||
}
|
||||
let effect_tip = self.draw_inventory_effects(batch, (ox, oy), Creative::WIDTH as i32);
|
||||
self.draw_creative_pages(batch, ox, oy);
|
||||
for t in tabs.iter().filter(|t| self.creative_screen.on_page(t)) {
|
||||
let (tx, ty) = Creative::tab_position(t);
|
||||
if self.cursor.0 >= ox + tx
|
||||
&& self.cursor.0 < ox + tx + 26.0
|
||||
&& self.cursor.1 >= oy + ty
|
||||
&& self.cursor.1 < oy + ty + 32.0
|
||||
{
|
||||
self.tooltip(
|
||||
batch,
|
||||
&self.creative_tab_title(t),
|
||||
self.cursor.0,
|
||||
self.cursor.1,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.draw_carried(batch);
|
||||
if tab.inventory {
|
||||
if Self::creative_trash_hovered(batch, self.cursor) {
|
||||
// `CreativeModeInventoryScreen`: the destroy slot's tooltip is
|
||||
// `inventory.binSlot`, not an English literal.
|
||||
let destroy = self.translate("inventory.binSlot", "Destroy Item");
|
||||
self.tooltip(batch, &destroy, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1)
|
||||
&& let Some(stack) = self.slot_view(slot)
|
||||
&& self.tooltip_with_carried(&stack)
|
||||
{
|
||||
let tabs = self.creative_tooltip_tabs(&stack, false);
|
||||
self.draw_stack_tooltip_with(
|
||||
batch,
|
||||
&stack,
|
||||
false,
|
||||
self.cursor.0,
|
||||
self.cursor.1,
|
||||
tabs,
|
||||
);
|
||||
}
|
||||
} else if let Some((item, grid)) = hovered
|
||||
&& self.tooltip_with_carried(&item)
|
||||
{
|
||||
let tabs = self.creative_tooltip_tabs(&item, grid);
|
||||
// A `CustomCreativeSlot`: TooltipFlag.asCreative.
|
||||
self.draw_stack_tooltip_with(batch, &item, grid, self.cursor.0, self.cursor.1, tabs);
|
||||
}
|
||||
if let Some(lines) = effect_tip {
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
}
|
||||
pub(super) fn creative_trash_hovered(batch: &HudBatch, cursor: (f32, f32)) -> bool {
|
||||
let (ox, oy) = Self::creative_origin(batch);
|
||||
let (x, y) = Creative::TRASH_POSITION;
|
||||
cursor.0 >= ox + x
|
||||
&& cursor.0 < ox + x + 16.0
|
||||
&& cursor.1 >= oy + y
|
||||
&& cursor.1 < oy + y + 16.0
|
||||
}
|
||||
/// NeoForge's creative page buttons, 20 square, 50 above the screen at
|
||||
/// its left and right edges: -1 or 1 for the one at `cursor`, when there
|
||||
/// is more than one page.
|
||||
pub(super) fn creative_page_button(
|
||||
ox: f32,
|
||||
oy: f32,
|
||||
cursor: (f32, f32),
|
||||
pages: u32,
|
||||
) -> Option<i64> {
|
||||
if pages <= 1 {
|
||||
return None;
|
||||
}
|
||||
let y = oy - 50.0;
|
||||
let inside =
|
||||
|x: f32| cursor.0 >= x && cursor.0 < x + 20.0 && cursor.1 >= y && cursor.1 < y + 20.0;
|
||||
if inside(ox) {
|
||||
Some(-1)
|
||||
} else if inside(ox + Creative::WIDTH - 20.0) {
|
||||
Some(1)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// The page buttons and "page / pages" between them.
|
||||
pub(super) fn draw_creative_pages(&self, batch: &mut HudBatch, ox: f32, oy: f32) {
|
||||
let pages = self.creative_screen.pages();
|
||||
if pages <= 1 {
|
||||
return;
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
let y = oy - 50.0;
|
||||
for (x, label, step) in [(ox, "<", -1), (ox + Creative::WIDTH - 20.0, ">", 1)] {
|
||||
let hover = Self::creative_page_button(ox, oy, self.cursor, pages) == Some(step);
|
||||
let page = self.creative_screen.page as i64 + step;
|
||||
let active = (0..pages as i64).contains(&page);
|
||||
let sprite = match (active, hover) {
|
||||
(false, _) => "gui/sprites/widget/button_disabled",
|
||||
(true, true) => "gui/sprites/widget/button_highlighted",
|
||||
(true, false) => "gui/sprites/widget/button",
|
||||
};
|
||||
horizon_client::ui::blit_sprite(batch, atlas, sprite, x, y, 20.0, 20.0, u32::MAX);
|
||||
let color = if active {
|
||||
u32::MAX
|
||||
} else {
|
||||
rgba(160, 160, 160, 255)
|
||||
};
|
||||
let w = HudBatch::text_width(atlas, label);
|
||||
batch.text(atlas, label, x + (20.0 - w) / 2.0, y + 6.0, color, true);
|
||||
}
|
||||
let text = format!("{} / {}", self.creative_screen.page + 1, pages);
|
||||
let w = HudBatch::text_width(atlas, &text);
|
||||
batch.text(
|
||||
atlas,
|
||||
&text,
|
||||
ox + (Creative::WIDTH - w) / 2.0,
|
||||
oy - 44.0,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
}
|
||||
pub(super) fn click_creative(&mut self, right: bool) {
|
||||
if self.mod_browser_press(right) {
|
||||
return;
|
||||
}
|
||||
let Some(size) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|r| [r.config.width, r.config.height])
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let batch = HudBatch::new(size);
|
||||
let (ox, oy) = Self::creative_origin(&batch);
|
||||
let (cx, cy) = self.cursor;
|
||||
// NeoForge's page buttons, when mods add tabs.
|
||||
if let Some(step) =
|
||||
Self::creative_page_button(ox, oy, (cx, cy), self.creative_screen.pages())
|
||||
{
|
||||
// Page buttons are outside the inventory panel; their release must
|
||||
// not throw a carried stack, including when the button is disabled.
|
||||
self.inv_clicks.skip_release();
|
||||
let page = self.creative_screen.page as i64 + step;
|
||||
if (0..self.creative_screen.pages() as i64).contains(&page) {
|
||||
self.audio.play_button_click();
|
||||
self.creative_screen.set_page(page as u32);
|
||||
self.refresh_saved_hotbars();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let tabs = self.creative_screen.tabs.clone();
|
||||
for (i, t) in tabs.iter().enumerate() {
|
||||
if !self.creative_screen.on_page(t) {
|
||||
continue;
|
||||
}
|
||||
let (tx, ty) = Creative::tab_position(t);
|
||||
if cx >= ox + tx && cx < ox + tx + 26.0 && cy >= oy + ty && cy < oy + ty + 32.0 {
|
||||
select_creative_tab(&mut self.creative_screen, &mut self.inv_clicks, i);
|
||||
self.refresh_saved_hotbars();
|
||||
self.inv_drag = None;
|
||||
self.last_inv_click = None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if self.creative_inventory_open() {
|
||||
if Self::creative_trash_hovered(&batch, self.cursor) {
|
||||
if self.has_shift_down() {
|
||||
let before = self.player_equipment();
|
||||
self.inventory.fill(None);
|
||||
self.armor.fill(None);
|
||||
self.offhand = None;
|
||||
self.craft_grid[..4].fill(None);
|
||||
self.player_equipment_changed(&before);
|
||||
self.apply_equipment();
|
||||
} else {
|
||||
self.carried = None;
|
||||
}
|
||||
self.inv_drag = None;
|
||||
self.inv_clicks = Default::default();
|
||||
} else if self.slot_at(&batch, cx, cy).is_some() || self.clicked_outside(&batch) {
|
||||
self.inventory_press(right);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !right && self.creative_search_click(ox, oy) {
|
||||
return;
|
||||
}
|
||||
// Holding the scrollbar scrolls as the mouse moves (mouseDragged).
|
||||
if !right
|
||||
&& self
|
||||
.creative_screen
|
||||
.press_scrollbar(cx - ox, cy - oy, &self.pack.names)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self.slot_at(&batch, cx, cy).is_some() || self.clicked_outside(&batch) {
|
||||
self.inventory_press(right);
|
||||
}
|
||||
}
|
||||
/// A click on the search tab's box (`EditBox.onClick`).
|
||||
pub(super) fn creative_search_click(&mut self, ox: f32, oy: f32) -> bool {
|
||||
if !self.creative_screen.tab().search {
|
||||
return false;
|
||||
}
|
||||
let (x, y, w, h) = (ox + 82.0, oy + 6.0, 80.0, 9.0);
|
||||
let (mx, my) = self.cursor;
|
||||
if mx < x || my < y || mx >= x + w || my >= y + h {
|
||||
return false;
|
||||
}
|
||||
let shift = self.has_shift_down();
|
||||
let search = &mut self.creative_screen.search;
|
||||
search.x = x;
|
||||
search.y = y;
|
||||
search.width = w;
|
||||
search.click(&self.gui.atlas, mx, shift, false);
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -955,6 +955,13 @@ impl Game<'_> {
|
||||
} else {
|
||||
None
|
||||
},
|
||||
// A warden's away suspects are UUIDs: on the server side, where the
|
||||
// entity UUIDs live, they resolve back to the mobs that are here.
|
||||
who_lookup: if self.remote.is_none() {
|
||||
Some(self)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
border: Some(self.border.geometry()),
|
||||
// The first player in the level: a server has no player of its own.
|
||||
difficulty: self.local_difficulty(
|
||||
@@ -1014,6 +1021,9 @@ impl Game<'_> {
|
||||
}
|
||||
/// Advance every mob one tick, then apply their events.
|
||||
pub(super) fn tick_creatures(&mut self) -> Result<()> {
|
||||
// Hurts dealt since the last creature tick (explosions, commands, the
|
||||
// systems after it) teleport an enderman before it ticks, not in its AI step.
|
||||
self.settle_hurt_teleports()?;
|
||||
self.attack_ticks = self.attack_ticks.saturating_add(1);
|
||||
self.refresh_gear_knockback_resistance();
|
||||
self.tick_leads();
|
||||
@@ -1513,6 +1523,8 @@ impl Game<'_> {
|
||||
queued.append(&mut m.outbox);
|
||||
}
|
||||
self.handle_mob_events(queued)?;
|
||||
// The enderman teleports those hurts asked for (`Enderman.hurtServer`).
|
||||
self.settle_hurt_teleports()?;
|
||||
laps.lap("creature_outbox");
|
||||
Ok(())
|
||||
}
|
||||
@@ -2350,7 +2362,8 @@ impl Game<'_> {
|
||||
// A mob's own `gameEvent(..)` calls: Armadillo.rollUp/rollOut's
|
||||
// ENTITY_ACTION, Creeper.tick's PRIME_FUSE, Mob.ate's EAT, the
|
||||
// shulker's lid (Shulker.setRawPeekAmount), an evoker's vexes
|
||||
// (ENTITY_PLACE), Enderman.teleport's TELEPORT where it was.
|
||||
// (ENTITY_PLACE), Enderman.teleport's TELEPORT,
|
||||
// LivingEntity.onEquipItem's EQUIP/UNEQUIP.
|
||||
MobEvent::GameEvent {
|
||||
event, at, source, ..
|
||||
} if matches!(
|
||||
@@ -2362,6 +2375,8 @@ impl Game<'_> {
|
||||
| "minecraft:container_close"
|
||||
| "minecraft:entity_place"
|
||||
| "minecraft:teleport"
|
||||
| "minecraft:equip"
|
||||
| "minecraft:unequip"
|
||||
) =>
|
||||
{
|
||||
let ctx = self.who_context(Some(Who::Mob(source)), None);
|
||||
@@ -5572,6 +5587,7 @@ impl Game<'_> {
|
||||
pl.position = to;
|
||||
pl.velocity = Vec3::ZERO;
|
||||
pl.fall_distance = 0.0;
|
||||
pl.impulse.reset();
|
||||
pl.teleported = true;
|
||||
}
|
||||
if self.lan_acting.is_some() {
|
||||
@@ -6171,6 +6187,7 @@ impl Game<'_> {
|
||||
let center = m.center();
|
||||
// Wolf armor the owner takes off with shears or leaves a dropped stack of.
|
||||
let mut armor_drop: Option<ItemStack> = None;
|
||||
let mut equip_event: Option<bool> = None;
|
||||
let mut consume = false;
|
||||
let mut give: Option<&str> = None;
|
||||
let mut sound: Option<&str> = None;
|
||||
@@ -6319,6 +6336,7 @@ impl Game<'_> {
|
||||
m.extra.body = None;
|
||||
m.extra.body_damage = 0;
|
||||
eat = Some(("item.armor.unequip_wolf", 1.0, 1.0));
|
||||
equip_event = Some(false);
|
||||
self.damage_held_tool();
|
||||
}
|
||||
// Wolf.mobInteract: heal, dye the collar or sit/stand when tame;
|
||||
@@ -6348,6 +6366,7 @@ impl Game<'_> {
|
||||
m.extra.body_extra = held_extra;
|
||||
consume = true;
|
||||
eat = Some(("item.armor.equip_wolf", 1.0, 1.0));
|
||||
equip_event = Some(true);
|
||||
}
|
||||
// Wolf.mobInteract: an armadillo scute repairs a sitting wolf's
|
||||
// armor by an eighth of its durability.
|
||||
@@ -6796,6 +6815,11 @@ impl Game<'_> {
|
||||
self.trigger_item_entity("minecraft:player_sheared_equipment", &stack, &target);
|
||||
self.spawn_item(stack, [center[0], center[1] + 0.5, center[2]], [0.0; 3], 10);
|
||||
}
|
||||
if let Some(equip) = equip_event {
|
||||
let id = self.mobs[index].id;
|
||||
let pos = self.mobs[index].position;
|
||||
self.mob_equip_event(id, [pos.x, pos.y, pos.z], equip);
|
||||
}
|
||||
if let Some((at, height)) = shear_puff {
|
||||
self.mooshroom_shear_puff(at, height);
|
||||
}
|
||||
@@ -7282,6 +7306,9 @@ impl Game<'_> {
|
||||
match renderer.and_then(|r| r.skinned_models()) {
|
||||
Some(stored) => {
|
||||
let mut models = self.skin_models.borrow_mut();
|
||||
// Reuse helps CPU-bound crowds, but regressed TAA frame tails in
|
||||
// windowed measurements. Keep the original arithmetic with TAA on.
|
||||
models.set_rotation_cache_enabled(!self.settings.horizon.taa);
|
||||
models.begin(&mut self.skin_frame.borrow_mut(), stored);
|
||||
models.tagged = renderer.is_some_and(|r| r.skins_tagged());
|
||||
}
|
||||
@@ -8376,6 +8403,78 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn ender_pearl_clears_impulse_before_next_fall() {
|
||||
use super::{Game, Hit, Projectile, Vec3, Who};
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::motion::{MotionWorld, PlayerMotion};
|
||||
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut player = PlayerMotion::new(game.motion, Vec3::new(0.0, 64.0, 0.0)).unwrap();
|
||||
player
|
||||
.impulse
|
||||
.set_ignore_fall_damage(true, Some(player.position));
|
||||
player.fall_distance = 4.0;
|
||||
game.player = Some(player);
|
||||
let destination = Vec3::new(0.0, 80.0, 0.0);
|
||||
let mut pearl = Projectile::new(ProjectileKind::EnderPearl, destination, Some(Who::Player));
|
||||
pearl.previous = destination;
|
||||
game.projectile_hit(
|
||||
&pearl,
|
||||
Hit::Block {
|
||||
pos: [0, 80, 0],
|
||||
at: destination,
|
||||
face: [0; 3],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let player = game.player.as_mut().unwrap();
|
||||
assert_eq!(player.position, destination);
|
||||
assert_eq!(player.fall_distance, 0.0);
|
||||
let damaged = MotionWorld {
|
||||
entities: &[],
|
||||
player,
|
||||
scene: &mut game.world,
|
||||
assets: &game.pack.assets,
|
||||
collision_binding: game.collision,
|
||||
resources: game.motion,
|
||||
health: &game.health_resources,
|
||||
}
|
||||
.cause_mount_fall(10.0, 1.0)
|
||||
.unwrap();
|
||||
assert!(
|
||||
damaged,
|
||||
"a ten-block fall after the teleport must cause damage"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nautilus_breath_lands_in_creative_as_ambient() {
|
||||
// AbstractNautilus.applyEffects: MobEffectInstance(BREATH_OF_THE_NAUTILUS,
|
||||
@@ -8689,6 +8788,7 @@ mod pearl_tests {
|
||||
others: &[],
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
who_lookup: None,
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
others_villagers: None,
|
||||
|
||||
@@ -492,8 +492,8 @@ mod tests {
|
||||
|
||||
/// `AbstractArrow`'s constructor runs `EnchantmentHelper.onProjectileSpawned`
|
||||
/// for the weapon the arrow is fired from, bow or crossbow, so a Flame
|
||||
/// crossbow shoots burning arrows: Flame's 5 second ignite, 100 ticks, on
|
||||
/// every arrow of a Multishot volley (#10482).
|
||||
/// crossbow shoots burning arrows: Flame's `Ignite(constant(100))` is 100
|
||||
/// seconds, 2000 ticks, on every arrow of a Multishot volley (#10482).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_flame_crossbow_shoots_burning_arrows() {
|
||||
@@ -516,10 +516,10 @@ mod tests {
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(shoot(&[], 1), vec![0]);
|
||||
assert_eq!(shoot(&["minecraft:flame"], 1), vec![100]);
|
||||
assert_eq!(shoot(&["minecraft:flame"], 1), vec![2000]);
|
||||
assert_eq!(
|
||||
shoot(&["minecraft:flame", "minecraft:multishot"], 3),
|
||||
vec![100; 3]
|
||||
vec![2000; 3]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -387,6 +387,8 @@ impl Game<'_> {
|
||||
if self.riding == Some(c.id) {
|
||||
self.dismount_removed_vehicle(p.y);
|
||||
}
|
||||
// BlockAttachedEntity.kill, before dropItem.
|
||||
self.entity_killed_event([p.x, p.y, p.z], by_player);
|
||||
self.audio.play(
|
||||
"minecraft:entity.cushion.break",
|
||||
Some([p.x, p.y, p.z]),
|
||||
|
||||
@@ -1534,10 +1534,11 @@ impl Game<'_> {
|
||||
}
|
||||
let tag = if let Some(i) = self.dropped.iter().position(|e| e.id == id) {
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag = horizon_client::entity_nbt::item_entity_to_nbt(
|
||||
let tag = horizon_client::entity_nbt::item_entity_to_nbt_with_items(
|
||||
&self.dropped[i],
|
||||
uuid,
|
||||
self.persist_raw(id),
|
||||
self.items(),
|
||||
);
|
||||
// A gateway keeps the item in its level (teleportSameDimension
|
||||
// moves it): it doesn't leave, so no native_entity_leave_level.
|
||||
|
||||
@@ -11,6 +11,50 @@ use horizon_world::{
|
||||
};
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Enderman.hurtServer` teleports inside the hurt (`teleport()`,
|
||||
/// `repeatedlyTryToTeleport()`). `Mob::damage` has no world, so it leaves
|
||||
/// the tries in `counter2`; a hurt the game layer deals runs them here,
|
||||
/// right after it, not on the enderman's next AI step.
|
||||
pub(super) fn settle_hurt_teleports(&mut self) -> Result<()> {
|
||||
let pending: Vec<u32> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.species() == Species::Enderman && m.extra.counter2 > 0)
|
||||
.map(|m| m.id)
|
||||
.collect();
|
||||
for id in pending {
|
||||
// What `LivingEntity.hurtServer` raised comes first, where the
|
||||
// enderman was hurt and from its random's next draws: the hurt
|
||||
// sound, `InfestedMobEffect.onMobHurt`'s silverfish.
|
||||
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let hurt = std::mem::take(&mut self.mobs[i].outbox);
|
||||
if !hurt.is_empty() {
|
||||
self.handle_mob_events(hurt)?;
|
||||
}
|
||||
// The world is read while the enderman moves: take it out, then
|
||||
// put it back where it was (the mob order stays).
|
||||
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let mut mob = self.mobs.swap_remove(i);
|
||||
let mut events = Vec::new();
|
||||
{
|
||||
let world = self.mob_world_base(&[]);
|
||||
horizon_client::mobs::enderman::run_pending_teleports(
|
||||
&mut mob,
|
||||
&world,
|
||||
&mut events,
|
||||
);
|
||||
}
|
||||
self.mobs.push(mob);
|
||||
let last = self.mobs.len() - 1;
|
||||
self.mobs.swap(i, last);
|
||||
self.handle_mob_events(events)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `EndermanLeaveBlockGoal.tick`: normalize, check, place, then clear.
|
||||
pub(super) fn enderman_place_block(
|
||||
&mut self,
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! `Enderman.hurtServer` teleports inside the hurt the game layer deals.
|
||||
use super::{
|
||||
Audio, Game, Options, Progress, Settings, explosion::BlockInteraction, load_resources,
|
||||
};
|
||||
use horizon_client::{
|
||||
chunk::Chunk,
|
||||
mobs::{Mob, MobEvent},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::Vec3,
|
||||
position::ChunkPos,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A game over 5x5 empty chunks around (0, 0) with a stone floor at y = 3:
|
||||
/// every enderman teleport from (8.5, 4, 8.5) lands on it or fails.
|
||||
fn floored_game(root: &std::path::Path) -> Game<'static> {
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.to_path_buf(),
|
||||
None,
|
||||
root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.to_path_buf();
|
||||
options.execution_role = super::execution_role::ExecutionRole::IntegratedServer;
|
||||
let resources = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
for cx in -2..=2 {
|
||||
for cz in -2..=2 {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(cx, cz))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
let stone = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap();
|
||||
for x in -32..48 {
|
||||
for z in -32..48 {
|
||||
game.world.set(x, 3, z, stone).unwrap();
|
||||
}
|
||||
}
|
||||
game
|
||||
}
|
||||
|
||||
fn enderman(game: &Game, id: u32, at: Vec3, seed: i64) -> Mob {
|
||||
let kind = game
|
||||
.res
|
||||
.mob_types()
|
||||
.get("minecraft:enderman")
|
||||
.unwrap()
|
||||
.clone();
|
||||
let mut mob = Mob::new(id, kind, at, 0.0);
|
||||
mob.random = LegacyRandom::new(seed);
|
||||
mob.persistent = true;
|
||||
mob
|
||||
}
|
||||
|
||||
/// `ServerExplosion.hurtEntities` -> `Enderman.hurtServer`: an enderman the
|
||||
/// blast hurts has teleported (or drawn not to) when the explosion returns.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn an_explosion_teleports_endermen_before_it_returns() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let mut game = floored_game(&root);
|
||||
let center = Vec3::new(8.5, 4.0, 8.5);
|
||||
for k in 0..12 {
|
||||
let angle = f64::from(k) * std::f64::consts::TAU / 12.0;
|
||||
let at = Vec3::new(
|
||||
center.x + 2.5 * angle.cos(),
|
||||
center.y,
|
||||
center.z + 2.5 * angle.sin(),
|
||||
);
|
||||
let mob = enderman(&game, 500 + k as u32, at, 900 + i64::from(k));
|
||||
game.mobs.push(mob);
|
||||
}
|
||||
let before: Vec<Vec3> = game.mobs.iter().map(|m| m.position).collect();
|
||||
game.server_explode(
|
||||
[center.x, center.y, center.z],
|
||||
4.0,
|
||||
BlockInteraction::Keep,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let pending: Vec<u32> = game
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.extra.counter2 > 0)
|
||||
.map(|m| m.id)
|
||||
.collect();
|
||||
assert!(
|
||||
pending.is_empty(),
|
||||
"teleports left for the AI step: {pending:?}"
|
||||
);
|
||||
let moved = game
|
||||
.mobs
|
||||
.iter()
|
||||
.zip(&before)
|
||||
.filter(|(m, b)| m.position.distance(**b) > 3.0)
|
||||
.count();
|
||||
assert!(moved > 0, "no enderman teleported");
|
||||
}
|
||||
|
||||
/// `LivingEntity.hurtServer` runs `InfestedMobEffect.onMobHurt` before
|
||||
/// `Enderman.hurtServer` teleports: the silverfish come out where the enderman
|
||||
/// was hurt, from its random's next draws.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn an_infested_endermans_silverfish_come_out_where_it_was_hurt() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let mut game = floored_game(&root);
|
||||
let at = Vec3::new(8.5, 4.0, 8.5);
|
||||
// A seed whose first draw passes the effect's 1-in-10 chance (`nextFloat() <= 0.1`).
|
||||
let seed = (0..)
|
||||
.find(|&s| LegacyRandom::new(s).next_float() <= 0.1)
|
||||
.unwrap();
|
||||
let mut mob = enderman(&game, 600, at, seed);
|
||||
mob.add_effect("minecraft:infested", 200, 0);
|
||||
// What `Mob::damage` leaves for a projectile's hit: the effect's spawn and
|
||||
// `repeatedlyTryToTeleport`.
|
||||
mob.outbox.push(MobEvent::Infested { mob: 600 });
|
||||
mob.extra.counter2 = 64;
|
||||
game.mobs.push(mob);
|
||||
game.settle_hurt_teleports().unwrap();
|
||||
let ender = game.mobs.iter().find(|m| m.id == 600).unwrap();
|
||||
assert!(
|
||||
ender.position.distance(at) > 3.0,
|
||||
"the enderman did not teleport"
|
||||
);
|
||||
let silverfish: Vec<Vec3> = game
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.kind.id == "minecraft:silverfish")
|
||||
.map(|m| m.position)
|
||||
.collect();
|
||||
assert!(!silverfish.is_empty(), "no silverfish");
|
||||
for p in silverfish {
|
||||
assert!(p.distance(at) < 3.0, "silverfish at {p:?}, hurt at {at:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! `Entity.applyEffectsFromBlocks` runs in the entity's own tick, and
|
||||
//! `ServerLevel.tick` ticks only the entities in entity-ticking chunks: a mob
|
||||
//! past the simulation distance presses no plate, however long it stands on it.
|
||||
|
||||
use super::Game;
|
||||
use super::native_machines::tests::server_game;
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_client::mobs::Mob;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_mob_presses_a_plate_only_inside_the_simulation_distance() {
|
||||
let mut game = server_game();
|
||||
game.settings.simulation_distance = 4;
|
||||
game.settings.render_distance = 12;
|
||||
let registry = game.pack.assets.registry;
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
let plate = registry
|
||||
.default_state("minecraft:stone_pressure_plate")
|
||||
.unwrap();
|
||||
let zombie = game
|
||||
.res
|
||||
.mob_types()
|
||||
.get("minecraft:zombie")
|
||||
.unwrap()
|
||||
.clone();
|
||||
// The player in chunk (0, 0); a zombie on a plate in chunk (2, 0), inside
|
||||
// the simulation distance, and another in chunk (8, 0): loaded and in
|
||||
// view, but past it.
|
||||
game.player = Some(
|
||||
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(8.5, 65.0, 8.5)).unwrap(),
|
||||
);
|
||||
for (cx, x, id) in [(2, 40, 1), (8, 136, 2)] {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((cx, 0), Arc::new(Chunk::empty(cx, 0)));
|
||||
game.world.set(x, 63, 8, stone).unwrap();
|
||||
game.world.set(x, 64, 8, plate).unwrap();
|
||||
game.mobs.push(Mob::new(
|
||||
id,
|
||||
zombie.clone(),
|
||||
Vec3::new(f64::from(x) + 0.5, 64.0, 8.5),
|
||||
0.0,
|
||||
));
|
||||
}
|
||||
game.update_chunk_tickets();
|
||||
let ticking = game.entity_ticking_area();
|
||||
assert!(ticking(40, 8), "chunk (2, 0) ticks entities");
|
||||
assert!(
|
||||
!ticking(136, 8),
|
||||
"chunk (8, 0) is past the simulation distance"
|
||||
);
|
||||
for _ in 0..3 {
|
||||
game.tick_blocks().unwrap();
|
||||
}
|
||||
let powered = |game: &Game<'_>, x: i32| {
|
||||
let state = game.world.block(x, 64, 8);
|
||||
registry
|
||||
.state(state)
|
||||
.unwrap()
|
||||
.properties
|
||||
.get("powered")
|
||||
.cloned()
|
||||
};
|
||||
assert_eq!(powered(&game, 40).as_deref(), Some("true"));
|
||||
assert_eq!(powered(&game, 136).as_deref(), Some("false"));
|
||||
}
|
||||
@@ -672,6 +672,11 @@ impl Game<'_> {
|
||||
// its base is 0 for every mob type, so iron golems and
|
||||
// ravagers are pushed.
|
||||
hurt_mob(m, hurtable, cause, (amount, source));
|
||||
// Enderman.hurtServer teleports inside the hurt, before
|
||||
// the push (the mob order stays as it was).
|
||||
if let Err(e) = self.settle_hurt_teleports() {
|
||||
log::warn!("Horizon: explosion: enderman teleport: {e}");
|
||||
}
|
||||
}
|
||||
// `ItemEntity.hurtServer`: the damage comes off `Health`
|
||||
// (5 to start) in f32 and the item is destroyed at zero, so
|
||||
|
||||
@@ -27,6 +27,10 @@ impl Game<'_> {
|
||||
impact.amount,
|
||||
mob_kind,
|
||||
);
|
||||
// Enderman.hurtServer: a falling block is no living attacker.
|
||||
if let Err(e) = self.settle_hurt_teleports() {
|
||||
log::warn!("Horizon: falling block: enderman teleport: {e}");
|
||||
}
|
||||
let kind = if stalactite {
|
||||
"minecraft:falling_stalactite"
|
||||
} else if anvil {
|
||||
|
||||
@@ -363,6 +363,10 @@ impl Game<'_> {
|
||||
);
|
||||
if !with_frame {
|
||||
self.item_frames[i].item = None;
|
||||
} else {
|
||||
// BlockAttachedEntity.kill, before dropItem. A frame holding an
|
||||
// item only gives the item up (ItemFrame.hurtServer).
|
||||
self.entity_killed_event(at, true);
|
||||
}
|
||||
self.drop_frame_items(&f, with_frame, true);
|
||||
self.world.modified.insert((f.pos[0] >> 4, f.pos[2] >> 4));
|
||||
@@ -470,6 +474,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let f = self.item_frames.remove(i);
|
||||
self.entity_killed_event(center(&f), false);
|
||||
self.audio
|
||||
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
|
||||
self.drop_frame_items(&f, true, false);
|
||||
@@ -497,6 +502,7 @@ impl Game<'_> {
|
||||
if !self.frame_survives(&f) && !self.hanging_break_cancelled(f.kind(), f.pos, false)
|
||||
{
|
||||
self.item_frames.remove(i);
|
||||
self.entity_killed_event(center(&f), false);
|
||||
self.audio
|
||||
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
|
||||
self.drop_frame_items(&f, true, false);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Play with Friends left out of the build (the `friends` feature off, as in the server build):
|
||||
//! no world is shared, the pause menu has no Friends button (`FriendsView::shown` is false, as on
|
||||
//! another server) and `horizon-ctl friends` says it isn't there. game/friends.rs is the real one.
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{Result, friends_screen::FriendsView, hud::HudBatch};
|
||||
|
||||
pub const HELP: &[&str] = &[];
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn friends_lan_closed(&mut self) {}
|
||||
|
||||
pub(super) fn tick_friends(&mut self) {}
|
||||
|
||||
/// Only reached by naming the screen (`horizon-ctl`): back to the pause menu.
|
||||
pub(super) fn draw_friends_screen(&mut self, _batch: &mut HudBatch) {
|
||||
self.screen = Screen::Paused;
|
||||
}
|
||||
|
||||
pub(super) fn friends_screen_click(&mut self) {}
|
||||
|
||||
pub(super) fn friends_view(&self) -> FriendsView {
|
||||
FriendsView::default()
|
||||
}
|
||||
|
||||
/// `horizon-ctl friends ...`.
|
||||
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
|
||||
if words[0] != "friends" {
|
||||
return Ok(None);
|
||||
}
|
||||
Err("this build has no Play with Friends (the `friends` feature)".into())
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,9 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play("minecraft:entity.happy_ghast.equip", Some(at), 1.0, 1.0);
|
||||
self.consume_held();
|
||||
let id = self.mobs[index].id;
|
||||
let pos = self.mobs[index].position;
|
||||
self.mob_equip_event(id, [pos.x, pos.y, pos.z], true);
|
||||
return true;
|
||||
}
|
||||
// Equippable.canBeSheared: shears take the harness off again.
|
||||
@@ -86,6 +89,9 @@ impl Game<'_> {
|
||||
.play("minecraft:entity.happy_ghast.unequip", Some(at), 1.0, 1.0);
|
||||
self.spawn_item(ItemStack::new(&harness, 1), at, [0.0, 0.2, 0.0], 10);
|
||||
self.damage_held_tool();
|
||||
let id = self.mobs[index].id;
|
||||
let pos = self.mobs[index].position;
|
||||
self.mob_equip_event(id, [pos.x, pos.y, pos.z], false);
|
||||
return true;
|
||||
}
|
||||
if harnessed && !sneaking {
|
||||
|
||||
@@ -652,9 +652,34 @@ impl Game<'_> {
|
||||
{
|
||||
return menu;
|
||||
}
|
||||
let Some(hit) = self.pick() else {
|
||||
self.pick()
|
||||
.is_some_and(|hit| self.block_interactable_for_spectator(&hit))
|
||||
}
|
||||
/// `BlockBehaviour.showAsInteractableInSpectatorMode`: a block with a menu, or a portal.
|
||||
fn block_interactable_for_spectator(&self, hit: &super::Hit) -> bool {
|
||||
let block = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
matches!(
|
||||
&*self.res.facts().get(block).name,
|
||||
"minecraft:nether_portal" | "minecraft:end_portal" | "minecraft:end_gateway"
|
||||
) || self.block_opens_menu(hit)
|
||||
}
|
||||
/// A lectern with no book (`LecternBlock.useWithoutItem` consumes the click and does nothing).
|
||||
pub(super) fn block_is_empty_lectern(&self, hit: &super::Hit) -> bool {
|
||||
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
&*self.res.facts().get(state).name == "minecraft:lectern"
|
||||
&& self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("has_book"))
|
||||
.is_none_or(|v| v != "true")
|
||||
}
|
||||
/// `BlockState.getMenuProvider`: the block's use opens a screen.
|
||||
pub(super) fn block_opens_menu(&self, hit: &super::Hit) -> bool {
|
||||
if self.block_is_empty_lectern(hit) {
|
||||
return false;
|
||||
};
|
||||
}
|
||||
let id = &*self
|
||||
.res
|
||||
.facts()
|
||||
@@ -662,12 +687,11 @@ impl Game<'_> {
|
||||
.name;
|
||||
let name = id.trim_start_matches("minecraft:");
|
||||
match name {
|
||||
// BlockBehaviour.getMenuProvider and `instanceof Portal`.
|
||||
// BlockBehaviour.getMenuProvider.
|
||||
"crafting_table" | "enchanting_table" | "anvil" | "chipped_anvil" | "damaged_anvil"
|
||||
| "stonecutter" | "loom" | "cartography_table" | "grindstone" | "smithing_table"
|
||||
| "barrel" | "furnace" | "blast_furnace" | "smoker" | "brewing_stand" | "hopper"
|
||||
| "dispenser" | "dropper" | "crafter" | "lectern" | "beacon" | "nether_portal"
|
||||
| "end_portal" | "end_gateway" => true,
|
||||
| "dispenser" | "dropper" | "crafter" | "lectern" | "beacon" => true,
|
||||
// ChestBlock.getMenuProvider: none while a redstone conductor
|
||||
// sits on top of either half (a sitting cat aside). Copper
|
||||
// chests are `ChestBlock`s too.
|
||||
|
||||
@@ -33,7 +33,7 @@ impl Game<'_> {
|
||||
// screen's search (OptionsUi::typing).
|
||||
Screen::Options => self.ui.options.typing(),
|
||||
Screen::WorldOptions => self.world_options_typing(),
|
||||
Screen::Creative => self.creative_screen.tab().search,
|
||||
Screen::Creative => self.mod_browser_typing() || self.creative_screen.tab().search,
|
||||
Screen::BlockEditor => self.block_editor.as_ref().is_some_and(|s| s.text_focused()),
|
||||
Screen::Inventory => {
|
||||
self.mod_browser_typing() || self.recipe_search_focused() || self.station_typing()
|
||||
@@ -138,11 +138,11 @@ impl Game<'_> {
|
||||
Screen::DebugOptions => self.debug_options_type(&text),
|
||||
Screen::Options => self.ui.options.type_text(&self.gui.atlas, &text),
|
||||
Screen::WorldOptions => self.world_options_text(Some(&text)),
|
||||
Screen::Creative => self.creative_search_edit(Some(&text)),
|
||||
Screen::BlockEditor => self.block_editor_type(&text),
|
||||
Screen::Inventory if self.mod_browser_typing() => {
|
||||
Screen::Inventory | Screen::Creative if self.mod_browser_typing() => {
|
||||
self.mod_browser_text(Some(&text), false)
|
||||
}
|
||||
Screen::Creative => self.creative_search_edit(Some(&text)),
|
||||
Screen::Inventory if self.recipe_search_focused() => {
|
||||
self.recipe_text(Some(&text), false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
//! Keys and mouse buttons over an open container screen: hotbar and pick keys, modifier keys,
|
||||
//! drags, throws and the press/release of a slot click.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `CreativeModeInventoryScreen.keyPressed` on the search tab: a hotbar
|
||||
/// key over a slot swaps before the search box sees it.
|
||||
pub(super) fn search_tab_hotbar_key(&mut self, code: KeyCode) -> bool {
|
||||
use horizon_client::creative::{Hover, search_key_swaps};
|
||||
let numeric = HOTBAR_DIGITS.contains(&code) || NUMERIC_KEYS.contains(&code);
|
||||
if !numeric {
|
||||
return false;
|
||||
}
|
||||
let Some(key) = self.settings.keys.container_hotbar_key(code) else {
|
||||
return false;
|
||||
};
|
||||
let Some(size) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|r| [r.config.width, r.config.height])
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let batch = HudBatch::new(size);
|
||||
let hover = match self.slot_at(&batch, self.cursor.0, self.cursor.1) {
|
||||
None => Hover::Nothing,
|
||||
Some(slot) if slot >= Creative::DISPLAY => {
|
||||
let first = self.creative_screen.first_row(&self.pack.names);
|
||||
Hover::Display {
|
||||
filled: self
|
||||
.creative_screen
|
||||
.shown(&self.pack.names, first * 9 + slot - Creative::DISPLAY)
|
||||
.is_some(),
|
||||
}
|
||||
}
|
||||
Some(_) => Hover::Slot,
|
||||
};
|
||||
search_key_swaps(numeric, true, hover, self.carried.is_some()) && self.inventory_hotkey(key)
|
||||
}
|
||||
/// Number keys, F and Q over a slot of the open menu: SWAP with a hotbar
|
||||
/// slot or the off hand, THROW one or the whole stack.
|
||||
pub(super) fn inventory_hotkey(&mut self, key: KeyCode) -> bool {
|
||||
use horizon_client::menu::Input;
|
||||
let Some(size) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|r| [r.config.width, r.config.height])
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let batch = HudBatch::new(size);
|
||||
let Some(slot) = self.slot_at(&batch, self.cursor.0, self.cursor.1) else {
|
||||
return false;
|
||||
};
|
||||
let digit = HOTBAR_DIGITS.iter().position(|k| *k == key);
|
||||
let done = match (digit, key) {
|
||||
(Some(n), _) => self.menu_click(Some(slot), n as i32, Input::Swap),
|
||||
(None, KeyCode::KeyF) => self.menu_click(Some(slot), 40, Input::Swap),
|
||||
(None, KeyCode::KeyQ) => {
|
||||
let all = drop_whole_stack(self.modifiers);
|
||||
self.menu_click(Some(slot), i32::from(all), Input::Throw)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if done && let Some(key) = self.container {
|
||||
self.container_changed(key);
|
||||
}
|
||||
done
|
||||
}
|
||||
/// The pick block key bound to a keyboard key, over a slot of the open
|
||||
/// menu (`AbstractContainerScreen.keyPressed`): `CLONE` with button 0. The
|
||||
/// search tab's box takes the key instead.
|
||||
pub(super) fn inventory_pick_key(&mut self) {
|
||||
let menu = (self.screen == Screen::Inventory
|
||||
|| (self.screen == Screen::Creative && !self.creative_screen.tab().search))
|
||||
&& !self.dialog_open();
|
||||
let Some(size) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|r| [r.config.width, r.config.height])
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let batch = HudBatch::new(size);
|
||||
let Some(slot) = self
|
||||
.slot_at(&batch, self.cursor.0, self.cursor.1)
|
||||
.filter(|_| menu)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self.menu_click(Some(slot), 0, horizon_client::menu::Input::Clone)
|
||||
&& let Some(key) = self.container
|
||||
{
|
||||
self.container_changed(key);
|
||||
}
|
||||
}
|
||||
/// The plain inventory mouse press: starts a quick craft drag when a
|
||||
/// stack is held, gathers on a double click, else the normal click.
|
||||
pub(super) fn inventory_press(&mut self, right: bool) {
|
||||
use horizon_client::container_screen::Target;
|
||||
use horizon_client::menu::Input;
|
||||
if self.mod_browser_press(right) {
|
||||
return;
|
||||
}
|
||||
if !right && self.sort_press() {
|
||||
return;
|
||||
}
|
||||
if self.native_screen_press(right) {
|
||||
self.inv_clicks.skip_release();
|
||||
return;
|
||||
}
|
||||
// AbstractRecipeBookScreen lets the book handle its buttons before
|
||||
// AbstractContainerScreen starts a drag or chooses an outside slot.
|
||||
// Toggling it can move the inventory under the pointer before release.
|
||||
if self.recipe_book_click(right) {
|
||||
self.inv_clicks.skip_release();
|
||||
return;
|
||||
}
|
||||
let (slot, outside) = self.inventory_target();
|
||||
let shift = self.has_shift_down();
|
||||
let target = Target::of(slot, outside);
|
||||
let input = self.inv_clicks.press(
|
||||
&target,
|
||||
Self::screen_button(right),
|
||||
self.carried.is_none(),
|
||||
shift,
|
||||
false,
|
||||
);
|
||||
if outside {
|
||||
// slotId -999: an empty hand throws nothing; a carried stack
|
||||
// starts a drag whose release drops it.
|
||||
match input {
|
||||
Some(input) => self.throw_click(right, input),
|
||||
None if self.inv_clicks.quick_crafting().is_some() => {
|
||||
self.inv_drag = Some((i32::from(right), Vec::new()));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.tweak_press(slot, right, shift) {
|
||||
return;
|
||||
}
|
||||
if let Some(slot) = slot
|
||||
&& shift
|
||||
&& slot != 200
|
||||
{
|
||||
let now = std::time::Instant::now();
|
||||
let double = self.last_inv_click.is_some_and(|(at, last, was_right)| {
|
||||
last == slot && was_right == right && now.duration_since(at).as_millis() < 250
|
||||
});
|
||||
self.last_inv_click = Some((now, slot, right));
|
||||
// mouseClicked's QUICK_MOVE (and Horizon's at-press QUICK_MOVE
|
||||
// of a carried stack, mouseReleased's in vanilla) remember the
|
||||
// stack it moves.
|
||||
if input == Some(Input::QuickMove) || (self.carried.is_some() && !double) {
|
||||
self.last_quick_moved = self.slot_view(slot);
|
||||
}
|
||||
if double && !right {
|
||||
// mouseReleased: every stack of lastQuickMoved's item in the
|
||||
// clicked slot's container is quick-moved.
|
||||
if let Some(input) = input
|
||||
&& !self.menu_click(Some(slot), 0, input)
|
||||
{
|
||||
// A screen the menu engine doesn't model: its own click.
|
||||
self.click_inventory(right);
|
||||
return;
|
||||
}
|
||||
if let Some(moved) = self.last_quick_moved.clone() {
|
||||
// The creative screen clicks its own menu, on a server too.
|
||||
if self.remote.is_some() && self.screen != Screen::Creative {
|
||||
self.remote_quick_move_all(slot, &moved);
|
||||
} else {
|
||||
self.menu_session(|menu, env, to_menu| {
|
||||
Some(menu.quick_move_all(env, to_menu(slot)?, &moved).is_ok())
|
||||
});
|
||||
if self.remote.is_some() {
|
||||
self.send_creative_slots();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.last_inv_click = None;
|
||||
self.inv_clicks.skip_release();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(slot) = slot
|
||||
&& slot != 200
|
||||
{
|
||||
// An item tab's display slots take no drags or PICKUP_ALL.
|
||||
let display = slot >= Creative::DISPLAY;
|
||||
let now = std::time::Instant::now();
|
||||
let double = !shift
|
||||
&& !right
|
||||
&& !display
|
||||
&& self.carried.is_some()
|
||||
&& self.last_inv_click.is_some_and(|(at, last, was_right)| {
|
||||
last == slot && !was_right && now.duration_since(at).as_millis() < 250
|
||||
});
|
||||
self.last_inv_click = Some((now, slot, right));
|
||||
if double && self.menu_click(Some(slot), 0, Input::PickupAll) {
|
||||
self.last_inv_click = None;
|
||||
// The double click's own release (`mouseReleased`'s PICKUP_ALL).
|
||||
self.inv_clicks.skip_release();
|
||||
return;
|
||||
}
|
||||
if self.carried.is_some() {
|
||||
let ty = i32::from(right);
|
||||
// `mouseClicked` starts a quick craft drag whenever the hand holds a stack,
|
||||
// Shift or not: `mouseDragged` fills the slots the cursor crosses, so a
|
||||
// Shift held over the drag spreads the same way (#4166). The drag starts
|
||||
// empty on a Shift press: a release with nothing spread is `mouseReleased`'s
|
||||
// click on the hovered slot (Pickup, or QuickMove under Shift).
|
||||
if !shift && !display && self.quick_craft_accepts(slot, ty, 0) {
|
||||
self.inv_drag = Some((ty, vec![slot]));
|
||||
} else {
|
||||
self.inv_drag = Some((ty, Vec::new()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if self.creative_tab_open() {
|
||||
// CreativeModeInventoryScreen goes through the same mouseClicked:
|
||||
// a click now, or a drag that its release ends.
|
||||
match input {
|
||||
Some(input) => {
|
||||
self.menu_click(slot, i32::from(right), input);
|
||||
}
|
||||
None if self.inv_clicks.quick_crafting().is_some() => {
|
||||
self.inv_drag = Some((i32::from(right), Vec::new()));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// mouseClicked: an empty hand's PICKUP is the menu's click at once
|
||||
// (a right click on a bundle takes its first stack out).
|
||||
if let (Some(input), Some(_)) = (input, slot)
|
||||
&& self.remote.is_none()
|
||||
&& self.menu_click(slot, i32::from(right), input)
|
||||
{
|
||||
if let Some(key) = self.container {
|
||||
self.container_changed(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.click_inventory(right);
|
||||
}
|
||||
/// Mouse movement while a quick craft drag is running.
|
||||
pub(super) fn inventory_drag_move(&mut self) {
|
||||
if self.tweaks_on() && self.tweak_drag_move(self.inventory_target().0) {
|
||||
return;
|
||||
}
|
||||
let Some((ty, slots)) = self.inv_drag.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(r) = self.renderer.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let batch = HudBatch::new([r.config.width, r.config.height]);
|
||||
if let Some(slot) = self.slot_at(&batch, self.cursor.0, self.cursor.1)
|
||||
&& slot != 200
|
||||
&& slot < Creative::DISPLAY
|
||||
&& !slots.contains(&slot)
|
||||
&& self.quick_craft_accepts(slot, ty, slots.len())
|
||||
&& let Some((_, slots)) = self.inv_drag.as_mut()
|
||||
{
|
||||
slots.push(slot);
|
||||
}
|
||||
}
|
||||
/// The hovered slot and whether the cursor is outside the window.
|
||||
pub(super) fn inventory_target(&self) -> (Option<usize>, bool) {
|
||||
let Some(r) = self.renderer.as_ref() else {
|
||||
return (None, false);
|
||||
};
|
||||
let batch = HudBatch::new([r.config.width, r.config.height]);
|
||||
(
|
||||
self.slot_at(&batch, self.cursor.0, self.cursor.1),
|
||||
self.clicked_outside(&batch),
|
||||
)
|
||||
}
|
||||
pub(super) fn screen_button(right: bool) -> horizon_client::container_screen::Button {
|
||||
use horizon_client::container_screen::Button;
|
||||
if right { Button::Right } else { Button::Left }
|
||||
}
|
||||
/// `Screen.hasShiftDown` / `InputWithModifiers.hasShiftDown`: 26.3 keeps
|
||||
/// no key cache — every event answers with its own modifiers bit
|
||||
/// (`InputWithModifiers.java:54-56`, `keys = 3`), and the pointer and
|
||||
/// keyboard events carry the OS's live word, refreshed at the focus. The
|
||||
/// window's `ModifiersChanged` is that word here; key releases can die
|
||||
/// on the way from the OS (a stuck Shift then kept every screen's Shift
|
||||
/// and a sneak the key no longer held: #4166's in-game report), so a
|
||||
/// cached key set is not vanilla's answer.
|
||||
pub(super) fn has_shift_down(&self) -> bool {
|
||||
self.modifiers.shift_key()
|
||||
}
|
||||
/// The same live word for Control (`InputWithModifiers.hasControlDown`).
|
||||
pub(super) fn has_control_down(&self) -> bool {
|
||||
self.modifiers.control_key()
|
||||
}
|
||||
/// The same live word for Alt (`InputWithModifiers.hasAltDown`).
|
||||
pub(super) fn has_alt_down(&self) -> bool {
|
||||
self.modifiers.alt_key()
|
||||
}
|
||||
/// Real events word the modifiers by their own `ModifiersChanged`; a
|
||||
/// control socket's `key` is synthesized, so it words the bit directly
|
||||
/// and every reader can ask the one live state.
|
||||
pub(super) fn press_modifier_key(&mut self, key: KeyCode, down: bool) {
|
||||
horizon_client_input::modifiers::set_key(
|
||||
&mut self.modifiers,
|
||||
&mut self.physical_keys,
|
||||
key,
|
||||
down,
|
||||
);
|
||||
}
|
||||
/// `hasClickedOutside` of the open screen at the cursor.
|
||||
pub(super) fn clicked_outside(&self, batch: &HudBatch) -> bool {
|
||||
use horizon_client::container_screen::{Book, clicked_outside};
|
||||
let image = self.container_image(batch);
|
||||
let book = self
|
||||
.recipe_book_area(batch)
|
||||
.map(|(visible, tab_hovered)| Book {
|
||||
visible,
|
||||
selected_tab_hovered: tab_hovered,
|
||||
});
|
||||
clicked_outside(
|
||||
f64::from(self.cursor.0),
|
||||
f64::from(self.cursor.1),
|
||||
image,
|
||||
book,
|
||||
)
|
||||
}
|
||||
/// The open container screen's window: its place and size.
|
||||
pub(super) fn container_image(
|
||||
&self,
|
||||
batch: &HudBatch,
|
||||
) -> horizon_client::container_screen::Image {
|
||||
let (ox, oy) = self.inventory_origin(batch);
|
||||
let (shift, width, height) = if self.screen == Screen::Creative {
|
||||
(0.0, Creative::WIDTH as i32, Creative::HEIGHT as i32)
|
||||
} else {
|
||||
let (shift, width) = match self.station.as_ref().map(|s| s.kind) {
|
||||
Some(stations::Kind::Merchant) => (-50.0, 276),
|
||||
Some(stations::Kind::Beacon) => (beacon::SHIFT, 230),
|
||||
_ => (0.0, self.native_screen_width().map_or(176, |w| w as i32)),
|
||||
};
|
||||
(shift, width, self.screen_height() as i32)
|
||||
};
|
||||
horizon_client::container_screen::Image {
|
||||
left: (ox + shift) as i32,
|
||||
top: oy as i32,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
/// A click outside the window (slot id -999): `THROW` does nothing; a
|
||||
/// `PICKUP` drops the carried stack (left) or one of it (right), thrown
|
||||
/// from the hand (`AbstractContainerMenu.doClick`, `LivingEntity.drop`).
|
||||
pub(super) fn throw_click(&mut self, right: bool, input: horizon_client::menu::Input) {
|
||||
use horizon_client::menu::Input;
|
||||
let button = i32::from(right);
|
||||
if self.remote.is_some() {
|
||||
if self.screen == Screen::Creative {
|
||||
self.remote_creative_drop(right);
|
||||
} else {
|
||||
self.remote_throw(right, input);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let before = self.carried.clone();
|
||||
if !self.menu_click(None, button, input) && input == Input::Pickup {
|
||||
// A screen the menu engine doesn't model: the same doClick.
|
||||
if let Some(c) = self.carried.as_mut() {
|
||||
let thrown = if right {
|
||||
let one = ItemStack {
|
||||
count: 1,
|
||||
..c.clone()
|
||||
};
|
||||
c.count -= 1;
|
||||
if c.count == 0 {
|
||||
self.carried = None;
|
||||
}
|
||||
one
|
||||
} else {
|
||||
self.carried.take().expect("some")
|
||||
};
|
||||
self.player_drop(thrown);
|
||||
}
|
||||
}
|
||||
if self.carried != before {
|
||||
self.swing_arm();
|
||||
}
|
||||
if let Some(key) = self.container {
|
||||
self.container_changed(key);
|
||||
}
|
||||
}
|
||||
/// Mouse release over an inventory screen: `mouseReleased` ends or
|
||||
/// cancels the drag, drops a stack released outside, or clicks.
|
||||
pub(super) fn inventory_mouse_release(&mut self, right: bool) {
|
||||
use horizon_client::container_screen::{Release, Target};
|
||||
self.tweak_release();
|
||||
let (slot, outside) = self.inventory_target();
|
||||
let dragged = self.inv_drag.as_ref().is_some_and(|(_, s)| !s.is_empty());
|
||||
let release = self.inv_clicks.release(
|
||||
&Target::of(slot, outside),
|
||||
Self::screen_button(right),
|
||||
self.carried.is_none(),
|
||||
dragged,
|
||||
self.has_shift_down(),
|
||||
false,
|
||||
);
|
||||
match (release, Target::of(slot, outside)) {
|
||||
(Release::QuickCraft, _) => self.inventory_release(),
|
||||
(Release::Click(input), Target::Outside) => {
|
||||
self.inv_drag = None;
|
||||
self.throw_click(right, input);
|
||||
}
|
||||
(Release::Click(input), Target::Slot(slot)) if self.inv_drag.is_some() => {
|
||||
// The drag never reached a slot: a plain click where it ends.
|
||||
self.inv_drag = None;
|
||||
if !self.menu_click(Some(slot), i32::from(right), input) {
|
||||
self.click_inventory(right);
|
||||
}
|
||||
if let Some(key) = self.container {
|
||||
self.container_changed(key);
|
||||
}
|
||||
}
|
||||
_ => self.inv_drag = None,
|
||||
}
|
||||
}
|
||||
/// A press of the pick block button (`keyPickItem`, the middle button by
|
||||
/// default) over an inventory screen: `mouseClicked` with `cloning`
|
||||
/// (infinite materials). An empty hand clones the hovered stack at its
|
||||
/// full size (`CLONE`), a carried stack starts a quick craft drag of type
|
||||
/// 2 that fills every slot it crosses; without infinite materials the
|
||||
/// press does nothing.
|
||||
pub(super) fn inventory_pick_press(&mut self) {
|
||||
use horizon_client::container_screen::{Button, Target};
|
||||
let (slot, outside) = self.inventory_target();
|
||||
let was_crafting = self.inv_clicks.quick_crafting().is_some();
|
||||
let input = self.inv_clicks.press(
|
||||
&Target::of(slot, outside),
|
||||
Button::Middle,
|
||||
self.carried.is_none(),
|
||||
self.has_shift_down(),
|
||||
self.creative,
|
||||
);
|
||||
match (input, slot) {
|
||||
// Slot -999 (outside the window) clones nothing.
|
||||
(Some(input), Some(slot)) if !outside => {
|
||||
self.menu_click(Some(slot), 2, input);
|
||||
}
|
||||
(None, _) if !was_crafting && self.inv_clicks.quick_crafting().is_some() => {
|
||||
// Item tab display slots take no drags.
|
||||
let first = slot.filter(|&s| {
|
||||
s != 200 && s < Creative::DISPLAY && self.quick_craft_accepts(s, 2, 0)
|
||||
});
|
||||
self.inv_drag = Some((2, first.into_iter().collect()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// The pick block button's release (`mouseReleased`): ends its quick
|
||||
/// craft drag, cancels another button's, or clones onto the slot under a
|
||||
/// carried stack (`CLONE`, which only an item tab's display slot takes).
|
||||
pub(super) fn inventory_pick_release(&mut self) {
|
||||
use horizon_client::container_screen::{Button, Release, Target};
|
||||
let (slot, outside) = self.inventory_target();
|
||||
let dragged = self.inv_drag.as_ref().is_some_and(|(_, s)| !s.is_empty());
|
||||
let release = self.inv_clicks.release(
|
||||
&Target::of(slot, outside),
|
||||
Button::Middle,
|
||||
self.carried.is_none(),
|
||||
dragged,
|
||||
self.has_shift_down(),
|
||||
true,
|
||||
);
|
||||
match (release, slot) {
|
||||
(Release::QuickCraft, _) => self.inventory_release(),
|
||||
(Release::Click(input), Some(slot)) if !outside => {
|
||||
self.inv_drag = None;
|
||||
self.menu_click(Some(slot), 2, input);
|
||||
}
|
||||
_ => self.inv_drag = None,
|
||||
}
|
||||
}
|
||||
/// Mouse release: finishes a quick craft drag (one slot is a plain click).
|
||||
pub(super) fn inventory_release(&mut self) {
|
||||
use horizon_client::menu::Input;
|
||||
let Some((ty, slots)) = self.inv_drag.take() else {
|
||||
return;
|
||||
};
|
||||
if slots.len() == 1 {
|
||||
if !self.menu_click(Some(slots[0]), ty, Input::Pickup) {
|
||||
self.click_inventory(ty == 1);
|
||||
}
|
||||
} else {
|
||||
let mask = |header: i32| header | (ty << 2);
|
||||
let mut ops = vec![(None, mask(0), Input::QuickCraft)];
|
||||
ops.extend(
|
||||
slots
|
||||
.into_iter()
|
||||
.map(|slot| (Some(slot), mask(1), Input::QuickCraft)),
|
||||
);
|
||||
ops.push((None, mask(2), Input::QuickCraft));
|
||||
if !self.menu_ops(&ops) {
|
||||
// Not a menu the engine models: the drag is a plain click.
|
||||
self.click_inventory(ty == 1);
|
||||
}
|
||||
}
|
||||
if let Some(key) = self.container {
|
||||
self.container_changed(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
//! Container screen slots: item and decoration drawing, the carried stack and quick craft drag,
|
||||
//! slot positions and hit tests, highlights, and the crafting result slot.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn draw_item(&self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) {
|
||||
// `GuiGraphics.renderItem`: the resolved item model (tints, foil and
|
||||
// special renderers included), then the decorations.
|
||||
self.draw_item_model(batch, stack, x, y);
|
||||
self.draw_item_decorations(batch, stack, x, y, None);
|
||||
}
|
||||
/// `extractSlot` of slot `slot` holding `stack` at (x, y): during a quick
|
||||
/// craft drag a dragged slot shows what it would hold over a white
|
||||
/// highlight (its count yellow when the slot caps it), and hides while
|
||||
/// it is the drag's only slot.
|
||||
pub(super) fn draw_slot_item(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
drag: Option<&DragView>,
|
||||
slot: usize,
|
||||
stack: &Option<ItemStack>,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) {
|
||||
let carried;
|
||||
let (stack, capped) = match drag.and_then(|d| d.slots.get(&slot)) {
|
||||
Some(None) => return,
|
||||
Some(Some((count, capped))) => {
|
||||
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
|
||||
carried = self
|
||||
.carried
|
||||
.clone()
|
||||
.map(|c| ItemStack { count: *count, ..c });
|
||||
(carried.as_ref(), *capped)
|
||||
}
|
||||
None => (stack.as_ref(), false),
|
||||
};
|
||||
if let Some(stack) = stack {
|
||||
self.draw_item_model(batch, stack, x, y);
|
||||
let text = capped.then(|| format!("\u{a7}e{}", stack.count));
|
||||
self.draw_item_decorations(batch, stack, x, y, text);
|
||||
}
|
||||
}
|
||||
/// `extractCarriedItem`: the carried stack at the cursor, showing what a
|
||||
/// quick craft drag over several slots leaves in hand.
|
||||
pub(super) fn draw_carried(&mut self, batch: &mut HudBatch) {
|
||||
let Some(mut stack) = self.carried.clone() else {
|
||||
return;
|
||||
};
|
||||
if let Some(left) = self.drag_view().and_then(|d| d.left) {
|
||||
if left == 0 {
|
||||
return;
|
||||
}
|
||||
stack.count = left;
|
||||
}
|
||||
self.draw_item(batch, &stack, self.cursor.0 - 8.0, self.cursor.1 - 8.0);
|
||||
}
|
||||
/// The quick craft drag in progress, as the menu engine previews it.
|
||||
pub(super) fn drag_view(&mut self) -> Option<DragView> {
|
||||
let (ty, slots) = self.inv_drag.clone()?;
|
||||
let mut view = None;
|
||||
self.menu_session(|menu, env, to_menu| {
|
||||
let menu_slots: Option<Vec<usize>> = slots.iter().map(|&g| to_menu(g)).collect();
|
||||
let shown = match menu.quick_craft_preview(env, ty, &menu_slots?) {
|
||||
Some((shown, left)) => {
|
||||
let slots = slots.iter().copied().zip(shown).collect();
|
||||
DragView {
|
||||
slots,
|
||||
left: Some(left),
|
||||
}
|
||||
}
|
||||
// A single dragged slot is hidden.
|
||||
None => DragView {
|
||||
slots: slots.iter().map(|&g| (g, None)).collect(),
|
||||
left: None,
|
||||
},
|
||||
};
|
||||
view = Some(shown);
|
||||
Some(false)
|
||||
});
|
||||
view
|
||||
}
|
||||
/// Whether a quick craft drag takes `slot`
|
||||
/// (`shouldAddSlotToQuickCraft`); screens the menu engine doesn't
|
||||
/// model take every slot.
|
||||
pub(super) fn quick_craft_accepts(&mut self, slot: usize, ty: i32, dragged: usize) -> bool {
|
||||
let mut accepts = true;
|
||||
self.menu_session(|menu, env, to_menu| {
|
||||
accepts = to_menu(slot).is_some_and(|s| menu.quick_craft_accepts(env, s, ty, dragged));
|
||||
Some(false)
|
||||
});
|
||||
accepts
|
||||
}
|
||||
/// `GuiGraphicsExtractor.itemDecorations`: the count (or `count_text`)
|
||||
/// and damage bar.
|
||||
pub(super) fn draw_item_decorations(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
stack: &ItemStack,
|
||||
x: f32,
|
||||
y: f32,
|
||||
count_text: Option<String>,
|
||||
) {
|
||||
// GuiGraphicsExtractor.itemDecorations, laid out at the origin and
|
||||
// moved to (x, y): the damage bar, the cooldown shade and the count.
|
||||
let info = self.items().get(&stack.item);
|
||||
// ItemStack.isBarVisible: isDamaged, which an unbreakable stack never is.
|
||||
let max_damage = if horizon_client::stations::is_damageable(self.items(), stack) {
|
||||
info.map_or(0, |info| info.max_damage as i32)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let cooldown =
|
||||
horizon_client::cooldowns::group(stack, info.and_then(|i| i.use_cooldown.as_ref()));
|
||||
let partial = self.clock.fraction() as f32;
|
||||
// BundleItem overrides the bar with its fullness.
|
||||
let mut bar = horizon_client::bundle::Contents::of(self.items(), stack)
|
||||
.map(|c| c.bar())
|
||||
.unwrap_or_else(|| hud_overlays::damage_bar(stack.damage as i32, max_damage));
|
||||
// Client-only: a mod may replace or hide the bar vanilla just chose.
|
||||
self.native_item_bar(stack, &mut bar);
|
||||
let decorations = hud_overlays::ItemDecorations {
|
||||
count: stack.count,
|
||||
count_text,
|
||||
bar,
|
||||
cooldown: self.cooldowns.percent(&cooldown, partial),
|
||||
};
|
||||
let mut draws = vec![hud_layout::GuiDraw::Push(vec![
|
||||
hud_layout::PoseOp::Translate(x, y),
|
||||
])];
|
||||
draws.extend(hud_overlays::item_decorations(
|
||||
0,
|
||||
0,
|
||||
&decorations,
|
||||
&self.gui.atlas.font,
|
||||
));
|
||||
draws.push(hud_layout::GuiDraw::Pop);
|
||||
hud_layout::paint(batch, &self.gui.atlas, &draws);
|
||||
self.native_item_decorations(stack, x, y, batch);
|
||||
}
|
||||
|
||||
pub(super) fn inventory_origin(&self, batch: &HudBatch) -> (f32, f32) {
|
||||
if self.screen == Screen::Creative {
|
||||
return Self::creative_origin(batch);
|
||||
}
|
||||
let width = self.native_screen_width().unwrap_or(176.0);
|
||||
let (x, y) = batch.centered_origin(width, self.screen_height());
|
||||
(x + self.recipe_book_shift(batch.width), y)
|
||||
}
|
||||
|
||||
/// InventoryMenu / CraftingMenu slot layouts.
|
||||
pub(super) fn slot_position(&self, i: usize) -> (f32, f32) {
|
||||
if self.creative_inventory_open()
|
||||
&& let Some(position) = Creative::inventory_slot_position(i)
|
||||
{
|
||||
return position;
|
||||
}
|
||||
if self.creative_tab_open()
|
||||
&& let Some(position) = Creative::tab_slot_position(i)
|
||||
{
|
||||
return position;
|
||||
}
|
||||
if let Some(p) = self
|
||||
.station_slot_position(i)
|
||||
.or_else(|| {
|
||||
Self::stock_machine_slot(self.machine_slot_position(i), self.native_screen_shown())
|
||||
})
|
||||
.or_else(|| self.crafter_slot_position(i))
|
||||
.or_else(|| self.mount_slot_position(i))
|
||||
{
|
||||
return p;
|
||||
}
|
||||
let table = self.craft_size == 3;
|
||||
// ContainerScreen (3 rows) shifts the player inventory down by one pixel.
|
||||
// ChestMenu: the inventory sits `18 * (rows - 3) + 1` lower than the
|
||||
// 3-row layout of the other menus.
|
||||
let shift = match (self.chest_rows(), self.native_screen_inventory_y()) {
|
||||
(_, Some(y)) => y - 84.0,
|
||||
(Some(rows), None) => 1.0 + (rows as f32 - 3.0) * 18.0,
|
||||
(None, None) => 0.0,
|
||||
};
|
||||
let inventory_x = self.native_screen_inventory_x().unwrap_or(8.0);
|
||||
match i {
|
||||
0..9 => (inventory_x + i as f32 * 18.0, 142.0 + shift),
|
||||
9..36 => (
|
||||
inventory_x + ((i - 9) % 9) as f32 * 18.0,
|
||||
84.0 + shift + ((i - 9) / 9) as f32 * 18.0,
|
||||
),
|
||||
500..504 => (8.0, 8.0 + (i - 500) as f32 * 18.0),
|
||||
// InventoryMenu: the shield slot.
|
||||
504 => (77.0, 62.0),
|
||||
300..500 if let Some(p) = self.native_screen_slot(i - 300) => p,
|
||||
300..500 => match self.container_kind() {
|
||||
Some(BlockEntity::Furnace(_)) => {
|
||||
[(56.0, 17.0), (56.0, 53.0), (116.0, 35.0)][(i - 300).min(2)]
|
||||
}
|
||||
_ => (
|
||||
8.0 + ((i - 300) % 9) as f32 * 18.0,
|
||||
18.0 + ((i - 300) / 9) as f32 * 18.0,
|
||||
),
|
||||
},
|
||||
200 => {
|
||||
if table {
|
||||
(124.0, 35.0)
|
||||
} else {
|
||||
(154.0, 28.0)
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let g = i - 100;
|
||||
let (r, c) = (g / self.craft_size, g % self.craft_size);
|
||||
if table {
|
||||
(30.0 + c as f32 * 18.0, 17.0 + r as f32 * 18.0)
|
||||
} else {
|
||||
(98.0 + c as f32 * 18.0, 18.0 + r as f32 * 18.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The stock hopper/dispenser slot layout, unless a mod screen is shown for
|
||||
/// that menu: a mod screen places its own container slots
|
||||
/// (`native_screen_slot`) and the player's inventory (`native_screen_inventory_y`),
|
||||
/// and this layout answers first in `slot_position`, which kept a mod's screen
|
||||
/// on a hopper or a dispenser on the stock grid (#5141). The rest of those
|
||||
/// menus is already the mod's: `draw_native_screen` owns the background and
|
||||
/// `screen_height` its height.
|
||||
pub(super) fn stock_machine_slot(
|
||||
stock: Option<(f32, f32)>,
|
||||
mod_screen_shown: bool,
|
||||
) -> Option<(f32, f32)> {
|
||||
if mod_screen_shown { None } else { stock }
|
||||
}
|
||||
|
||||
pub(super) fn slot_at(&self, batch: &HudBatch, x: f32, y: f32) -> Option<usize> {
|
||||
if self.book_covers_menu(batch.width) {
|
||||
return None;
|
||||
}
|
||||
let (ox, oy) = self.inventory_origin(batch);
|
||||
self.slots().into_iter().find(|&i| {
|
||||
let (sx, sy) = self.slot_position(i);
|
||||
// AbstractContainerScreen.isHovering: the 16x16 slot grows by one
|
||||
// pixel on every side, so the gaps between slots still hover one.
|
||||
x >= ox + sx - 1.0 && x < ox + sx + 17.0 && y >= oy + sy - 1.0 && y < oy + sy + 17.0
|
||||
})
|
||||
}
|
||||
/// `AbstractContainerScreen.extractSlotHighlightBack` and
|
||||
/// `AbstractContainerScreen.extractSlotHighlightFront`: the 24 x 24
|
||||
/// `container/slot_highlight_back` sprite behind the slot at `x`, `y` (its
|
||||
/// 16 x 16 item box), the `_front` one over its item.
|
||||
pub(super) fn draw_slot_highlight(&self, batch: &mut HudBatch, front: bool, x: f32, y: f32) {
|
||||
let sprite = if front {
|
||||
"gui/sprites/container/slot_highlight_front"
|
||||
} else {
|
||||
"gui/sprites/container/slot_highlight_back"
|
||||
};
|
||||
horizon_client::ui::blit_sprite(
|
||||
batch,
|
||||
&self.gui.atlas,
|
||||
sprite,
|
||||
x - 4.0,
|
||||
y - 4.0,
|
||||
24.0,
|
||||
24.0,
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
/// The highlight of the slot under the cursor, for the screens whose
|
||||
/// slots all sit in `slot_at`'s grid. `Slot.isHighlightable` is false
|
||||
/// for the crafter's result (`NonInteractiveResultSlot`).
|
||||
pub(super) fn draw_hovered_slot_highlight(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
front: bool,
|
||||
ox: f32,
|
||||
oy: f32,
|
||||
) {
|
||||
let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1) else {
|
||||
return;
|
||||
};
|
||||
if slot == 309 && matches!(self.container_kind(), Some(BlockEntity::Crafter(_))) {
|
||||
return;
|
||||
}
|
||||
let (sx, sy) = self.slot_position(slot);
|
||||
self.draw_slot_highlight(batch, front, ox + sx, oy + sy);
|
||||
}
|
||||
pub(super) fn slot_mut(&mut self, i: usize) -> &mut Option<ItemStack> {
|
||||
if i >= mount_menu::SADDLE {
|
||||
return self.mount_slot_mut(i);
|
||||
}
|
||||
if i >= stations::INPUT {
|
||||
return self.station_slot_mut(i);
|
||||
}
|
||||
if (500..504).contains(&i) {
|
||||
return &mut self.armor[i - 500];
|
||||
}
|
||||
if i == 504 {
|
||||
return &mut self.offhand;
|
||||
}
|
||||
if i >= 300 {
|
||||
return self.container_slot_mut(i - 300);
|
||||
}
|
||||
if (100..109).contains(&i) {
|
||||
&mut self.craft_grid[i - 100]
|
||||
} else {
|
||||
&mut self.inventory[i]
|
||||
}
|
||||
}
|
||||
/// What a slot shows: the crafting result slots are computed.
|
||||
pub(super) fn slot_view(&mut self, i: usize) -> Option<ItemStack> {
|
||||
match i {
|
||||
200 => self.craft_result(),
|
||||
309 if matches!(self.container_kind(), Some(BlockEntity::Crafter(_))) => {
|
||||
self.crafter_result()
|
||||
}
|
||||
_ => self.slot_mut(i).clone(),
|
||||
}
|
||||
}
|
||||
pub(super) fn craft_result(&self) -> Option<ItemStack> {
|
||||
// A server's result slot is the server's (`CraftingMenu.slotChangedCraftingGrid`).
|
||||
if let Some(r) = &self.remote {
|
||||
return r.craft_result.clone();
|
||||
}
|
||||
self.craft_recipe().map(|(_, result)| result)
|
||||
}
|
||||
/// The recipe id and result of the crafting grid.
|
||||
/// `RecipeCraftingHolder.awardUsedRecipes`: a crafted recipe becomes known
|
||||
/// (special ones never do).
|
||||
pub(super) fn award_crafted(&mut self, crafts: &[(String, Vec<Option<ItemStack>>)]) {
|
||||
for (recipe, ingredients) in crafts {
|
||||
// The whole id: the advancements' `recipe_crafted` criteria name
|
||||
// `minecraft:x`, and the crafting copy names the recipe bare.
|
||||
let recipe = horizon_client::crafting::qualified(recipe);
|
||||
self.trigger_recipe_crafted(&recipe, ingredients);
|
||||
}
|
||||
let recipes: Vec<String> = crafts
|
||||
.iter()
|
||||
.map(|(r, _)| horizon_client::crafting::qualified(r))
|
||||
.collect();
|
||||
let index = &world_stations!(self).recipe_index;
|
||||
let (_, unlocked) = self.level.recipe_book.add_recipes(index, &recipes);
|
||||
self.recipe_unlocked(&unlocked);
|
||||
}
|
||||
pub(super) fn craft_recipe(&self) -> Option<(String, ItemStack)> {
|
||||
let n = self.craft_size;
|
||||
let grid: Vec<Option<ItemStack>> = (0..n * n).map(|i| self.craft_grid[i].clone()).collect();
|
||||
let scale = |id: i32| self.maps.data.get(&id).map(|m| m.scale);
|
||||
// The world's table, the one taking the result (`menu_click`) crafts from.
|
||||
self.recipes()
|
||||
.find_with(&grid, n, &scale)
|
||||
.map(|(name, result)| (name.to_owned(), result))
|
||||
}
|
||||
pub(super) fn draw_inventory_slots(&mut self, batch: &mut HudBatch, ox: f32, oy: f32) {
|
||||
let drag = self.drag_view();
|
||||
for i in self.slots() {
|
||||
let (sx, sy) = self.slot_position(i);
|
||||
if i == 504 && self.offhand.is_none() {
|
||||
batch.sprite(
|
||||
&self.gui.atlas,
|
||||
"gui/sprites/container/slot/shield",
|
||||
ox + sx,
|
||||
oy + sy,
|
||||
);
|
||||
}
|
||||
if (500..504).contains(&i) && self.armor[i - 500].is_none() {
|
||||
let icon = ["helmet", "chestplate", "leggings", "boots"][i - 500];
|
||||
batch.sprite(
|
||||
&self.gui.atlas,
|
||||
&format!("gui/sprites/container/slot/{icon}"),
|
||||
ox + sx,
|
||||
oy + sy,
|
||||
);
|
||||
}
|
||||
let stack = self.slot_view(i);
|
||||
self.draw_slot_item(batch, drag.as_ref(), i, &stack, ox + sx, oy + sy);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! A click that uses the held item at nothing or at an entity (`Minecraft.startUseItem`,
|
||||
//! `Player.interactOn`), the context a block's use sees, and the mod hooks that cancel a placement
|
||||
//! or an emptied bucket.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The hand whose use ended the click, if any.
|
||||
pub(super) fn use_item(&mut self) -> Result<Option<Hand>> {
|
||||
self.with_player_actor(|game| game.use_item_as_player())
|
||||
}
|
||||
/// `Minecraft.startUseItem`: the main hand, then the off hand.
|
||||
pub(super) fn use_item_as_player(&mut self) -> Result<Option<Hand>> {
|
||||
// Spectators only look (MultiPlayerGameMode.useItemOn/useItem pass),
|
||||
// but on a server a spectator's use on a block opens the block's
|
||||
// menu (ServerPlayerGameMode.useItemOn).
|
||||
if self.spectator() {
|
||||
if matches!(self.guest_use, Some(GuestUse::OnBlock(_)))
|
||||
&& let Some(hit) = self.pick()
|
||||
{
|
||||
self.spectator_open_menu(hit.pos);
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
// A carrot or warped fungus on a stick boosts the ridden pig or strider.
|
||||
// A guest's use packets name one hand and touch no entity: the client
|
||||
// sends one per hand and part of its click, and Interact for entities.
|
||||
let guest = self.guest_use;
|
||||
if let Some(GuestUse::Entity(hand, target)) = guest {
|
||||
return Ok(self
|
||||
.with_hand(hand, |g| g.interact_entity(Some(target)))?
|
||||
.then_some(hand));
|
||||
}
|
||||
let on_block = matches!(guest, Some(GuestUse::OnBlock(_)));
|
||||
if !on_block
|
||||
&& self.riding.is_some()
|
||||
&& let Some(item) = self.held().map(|s| s.item.clone())
|
||||
&& self.use_steering_stick(&item)
|
||||
{
|
||||
return Ok(Some(Hand::Main));
|
||||
}
|
||||
if guest.is_none() && self.interact_entity(None)? {
|
||||
return Ok(Some(Hand::Main));
|
||||
}
|
||||
// An EntityHitResult may fall through to Item.use, never to useItemOn.
|
||||
let block_hit = match guest {
|
||||
Some(GuestUse::OnBlock(_)) => self.pick(),
|
||||
Some(GuestUse::Item(_)) => None,
|
||||
_ if self.entity_targeted() => None,
|
||||
_ => self.pick(),
|
||||
};
|
||||
let hands = match guest {
|
||||
Some(GuestUse::OnBlock(hand) | GuestUse::Item(hand) | GuestUse::Entity(hand, _)) => {
|
||||
vec![hand]
|
||||
}
|
||||
None => Hand::BOTH.to_vec(),
|
||||
};
|
||||
let entity = guest.is_none() && self.entity_targeted();
|
||||
for hand in hands {
|
||||
// The off hand's turn starts on the entity too (Minecraft.startUseItem).
|
||||
if entity && hand == Hand::Off && self.with_hand(hand, |g| g.interact_entity(None))? {
|
||||
return Ok(Some(hand));
|
||||
}
|
||||
// A click on the fence block: `LeadItem.useOn`. A client whose crosshair
|
||||
// met the knot first sends Interact with the knot's id instead.
|
||||
if on_block && self.with_hand(hand, Game::interact_knot) {
|
||||
return Ok(Some(hand));
|
||||
}
|
||||
if self.with_hand(hand, |game| game.use_hand(block_hit.as_ref()))? {
|
||||
return Ok(Some(hand));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
/// The index in `list` of the entity an interact packet's `target` names,
|
||||
/// by its server id (`Level.getEntity(id)`), or None for an id this level
|
||||
/// does not hold: `handleInteract` finds nothing then either.
|
||||
pub(super) fn named_entity<T>(
|
||||
list: &[T],
|
||||
target: i32,
|
||||
id: impl Fn(&T) -> u32,
|
||||
) -> Option<usize> {
|
||||
list.iter().position(|e| id(e) as i32 == target)
|
||||
}
|
||||
/// `Player.interactOn` with the acting hand: whether it took the click. A
|
||||
/// guest's interact packet names its entity (`target`), which the helpers
|
||||
/// use as it is; the local player picks from the crosshair instead, the
|
||||
/// way `Minecraft.hitResult` does. `native_entity_interact_specific` and
|
||||
/// `native_interact_entity` hear it first; a claim swings the hand and
|
||||
/// ends the click.
|
||||
pub(super) fn interact_entity(&mut self, target: Option<InteractTarget>) -> Result<bool> {
|
||||
// Native mods, where Player.interactOn starts: before the entity's
|
||||
// own interact and the item's interactLivingEntity. The entity is the
|
||||
// one the packet named, or the one the crosshair picked.
|
||||
let (named, distance) = match target {
|
||||
Some(t) => (Some(t.id), None),
|
||||
None => match self.pick_entity_hit_at() {
|
||||
Some((distance, id)) => (id, Some(distance)),
|
||||
None => (None, None),
|
||||
},
|
||||
};
|
||||
if let Some(id) = named
|
||||
&& let Some(entity) = self.level_entity(id)
|
||||
{
|
||||
// The point clicked, measured from the entity's position: a
|
||||
// guest's packet carries it, and the local player's is where the
|
||||
// crosshair's ray met the entity (`EntityHitResult.getLocation()`).
|
||||
let location = match target {
|
||||
Some(t) => Some(t.location),
|
||||
None => distance
|
||||
.zip(self.aim())
|
||||
.zip(self.target_position(entity))
|
||||
.map(|((d, (eye, dir)), at)| {
|
||||
std::array::from_fn(|k| eye[k] + dir[k] * d - at[k])
|
||||
}),
|
||||
};
|
||||
let kind = self.target_type(entity);
|
||||
let stack = self.held().cloned();
|
||||
let mut claimed = false;
|
||||
if let Some(location) = location {
|
||||
self.native_entity_interact_specific(
|
||||
id,
|
||||
&kind,
|
||||
stack.as_ref(),
|
||||
location,
|
||||
&mut claimed,
|
||||
);
|
||||
}
|
||||
if !claimed {
|
||||
self.native_interact_entity(id, &kind, stack.as_ref(), &mut claimed);
|
||||
self.native_interact_entity_v2(id, &kind, self.hand, stack.as_ref(), &mut claimed);
|
||||
}
|
||||
if claimed {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(self.interact_frame(target)
|
||||
|| self.interact_armor_stand(target)
|
||||
|| self.interact_cushion(target)
|
||||
|| self.interact_boat(target)
|
||||
|| self.interact_cart(target)
|
||||
|| self.interact_mob(target)?
|
||||
// A guest's Interact naming a knot's id goes to `use_knot` before this.
|
||||
|| target.is_none() && self.interact_knot()
|
||||
|| self.item_interact_mob(target))
|
||||
}
|
||||
|
||||
/// Asks the mods whether the acting guest may place `stack` where and as
|
||||
/// `BlockItem.place` would for `ctx` (`native_block_place`): true when
|
||||
/// one canceled it. A game's own player is not asked, nor a placement
|
||||
/// vanilla refuses.
|
||||
pub(super) fn block_place_canceled(&mut self, stack: &ItemStack, ctx: &PlaceContext) -> bool {
|
||||
let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) else {
|
||||
return false;
|
||||
};
|
||||
let Some((pos, state)) =
|
||||
self.with_level(|b, level, ticks| b.placement_state(level, ticks, ctx))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let mut cancel = false;
|
||||
self.native_block_place(
|
||||
uuid,
|
||||
&name,
|
||||
stack,
|
||||
[pos.x, pos.y, pos.z],
|
||||
state,
|
||||
&mut cancel,
|
||||
);
|
||||
cancel
|
||||
}
|
||||
/// Asks the mods whether the acting guest may empty `stack` at `pos`
|
||||
/// (`native_bucket_empty`): true when one cancelled it. A game's own
|
||||
/// player is not asked.
|
||||
pub(super) fn bucket_empty_cancelled(&mut self, stack: &ItemStack, pos: [i32; 3]) -> bool {
|
||||
let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) else {
|
||||
return false;
|
||||
};
|
||||
let fluid = match &*stack.item {
|
||||
"minecraft:lava_bucket" => "minecraft:lava",
|
||||
"minecraft:powder_snow_bucket" => "minecraft:powder_snow",
|
||||
_ => "minecraft:water",
|
||||
};
|
||||
let mut cancel = false;
|
||||
self.native_bucket_empty(uuid, &name, pos, fluid, &mut cancel);
|
||||
cancel
|
||||
}
|
||||
/// The player as a block's `useItemOn` / `useWithoutItem` sees them.
|
||||
pub(super) fn use_context(&self) -> PlayerContext {
|
||||
PlayerContext {
|
||||
yaw: self.yaw,
|
||||
pitch: self.pitch,
|
||||
may_build: self.may_build(),
|
||||
can_eat: self.creative
|
||||
|| self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.health.food.food < 20),
|
||||
game_master: self.can_use_game_master_blocks(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Key and button codes as GLFW and vanilla's `InputConstants` number them, and the small rules
|
||||
//! that decide which device or screen a key press goes to.
|
||||
use super::*;
|
||||
|
||||
pub(super) fn follow_named_keymap(
|
||||
logical_key: &winit::keyboard::Key,
|
||||
physical_key: &mut PhysicalKey,
|
||||
) {
|
||||
use winit::keyboard::{Key, NamedKey};
|
||||
if let Key::Named(named) = logical_key {
|
||||
let code = match named {
|
||||
NamedKey::Escape => KeyCode::Escape,
|
||||
NamedKey::CapsLock => KeyCode::CapsLock,
|
||||
NamedKey::F1 => KeyCode::F1,
|
||||
NamedKey::F2 => KeyCode::F2,
|
||||
NamedKey::F3 => KeyCode::F3,
|
||||
NamedKey::F4 => KeyCode::F4,
|
||||
NamedKey::F5 => KeyCode::F5,
|
||||
NamedKey::F6 => KeyCode::F6,
|
||||
NamedKey::F7 => KeyCode::F7,
|
||||
NamedKey::F8 => KeyCode::F8,
|
||||
NamedKey::F9 => KeyCode::F9,
|
||||
NamedKey::F10 => KeyCode::F10,
|
||||
NamedKey::F11 => KeyCode::F11,
|
||||
NamedKey::F12 => KeyCode::F12,
|
||||
NamedKey::F13 => KeyCode::F13,
|
||||
NamedKey::F14 => KeyCode::F14,
|
||||
NamedKey::F15 => KeyCode::F15,
|
||||
NamedKey::F16 => KeyCode::F16,
|
||||
NamedKey::F17 => KeyCode::F17,
|
||||
NamedKey::F18 => KeyCode::F18,
|
||||
NamedKey::F19 => KeyCode::F19,
|
||||
NamedKey::F20 => KeyCode::F20,
|
||||
NamedKey::F21 => KeyCode::F21,
|
||||
NamedKey::F22 => KeyCode::F22,
|
||||
NamedKey::F23 => KeyCode::F23,
|
||||
NamedKey::F24 => KeyCode::F24,
|
||||
NamedKey::F25 => KeyCode::F25,
|
||||
NamedKey::F26 => KeyCode::F26,
|
||||
NamedKey::F27 => KeyCode::F27,
|
||||
NamedKey::F28 => KeyCode::F28,
|
||||
NamedKey::F29 => KeyCode::F29,
|
||||
NamedKey::F30 => KeyCode::F30,
|
||||
NamedKey::F31 => KeyCode::F31,
|
||||
NamedKey::F32 => KeyCode::F32,
|
||||
NamedKey::F33 => KeyCode::F33,
|
||||
NamedKey::F34 => KeyCode::F34,
|
||||
NamedKey::F35 => KeyCode::F35,
|
||||
_ => return,
|
||||
};
|
||||
*physical_key = PhysicalKey::Code(code);
|
||||
}
|
||||
}
|
||||
|
||||
/// The modifier `modifiers` stands for, as GLFW words it in the native mods'
|
||||
/// input hooks: `GLFW_MOD_SHIFT` 1, `GLFW_MOD_CONTROL` 2, `GLFW_MOD_ALT` 4,
|
||||
/// `GLFW_MOD_SUPER` 8 — the word a NeoForge listener reads off its event
|
||||
/// (`getModifiers`). The lock keys are not modifiers there.
|
||||
pub(super) fn glfw_modifiers(modifiers: winit::keyboard::ModifiersState) -> u32 {
|
||||
let mut bits = 0;
|
||||
if modifiers.shift_key() {
|
||||
bits |= 1;
|
||||
}
|
||||
if modifiers.control_key() {
|
||||
bits |= 2;
|
||||
}
|
||||
if modifiers.alt_key() {
|
||||
bits |= 4;
|
||||
}
|
||||
if modifiers.super_key() {
|
||||
bits |= 8;
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
/// The GLFW mouse button index `button` is, as the native mods' mouse hooks
|
||||
/// hand it over: 0 left, 1 right, 2 middle, 3 back, 4 forward. `Other(n)` is
|
||||
/// winit's n, the platform's own number (X11 or evdev on Linux), so a button
|
||||
/// past 4 is not GLFW's index everywhere. Buttons 0–4 are the numbering
|
||||
/// `keybinds::mouse_name` names (`key.mouse.<index + 1>`) and vanilla's
|
||||
/// `InputConstants` reads.
|
||||
pub(super) fn glfw_button(button: MouseButton) -> i32 {
|
||||
match button {
|
||||
MouseButton::Left => 0,
|
||||
MouseButton::Right => 1,
|
||||
MouseButton::Middle => 2,
|
||||
MouseButton::Back => 3,
|
||||
MouseButton::Forward => 4,
|
||||
MouseButton::Other(n) => i32::from(n),
|
||||
}
|
||||
}
|
||||
|
||||
/// `KeyboardHandler.keyPress` for a key bound to a mouse mapping (key.attack,
|
||||
/// key.use, key.pickItem): in game, a press and every repeat of the held key
|
||||
/// reach `KeyMapping.set(key, true)` and `KeyMapping.click` alike, so a held
|
||||
/// key keeps attacking; a release lets go. `None` behind a screen, which takes
|
||||
/// the key.
|
||||
pub(super) fn keyboard_button(playing: bool, action: KeyAction) -> Option<bool> {
|
||||
playing.then_some(action != KeyAction::Release)
|
||||
}
|
||||
|
||||
/// Gameplay owns the keyboard: `Screen::Playing`, no title, and nothing open
|
||||
/// over the world (`screen_over_playing`: a server dialog, a book or lectern,
|
||||
/// a sign editor, a prompt). Each is a `Screen` in vanilla, so `Gui.setScreen`
|
||||
/// runs `KeyMapping.releaseAll` and `Minecraft.tick` skips `handleKeybinds`
|
||||
/// while it is up: a mouse button held into it is let go of (#9729).
|
||||
pub(super) fn gameplay_has_keyboard(screen: Screen, title: bool, screen_over: bool) -> bool {
|
||||
screen == Screen::Playing && !title && !screen_over
|
||||
}
|
||||
|
||||
/// A dialog drops held keys and mouse buttons it does not see (#4974).
|
||||
pub(super) fn drop_held_behind_dialog(
|
||||
dialog: bool,
|
||||
keys: &mut horizon_client::held::HeldKeys,
|
||||
left: &mut bool,
|
||||
right: &mut bool,
|
||||
) {
|
||||
if dialog {
|
||||
keys.clear();
|
||||
*left = false;
|
||||
*right = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractContainerScreen.keyPressed` for a key bound to a mouse mapping: a press or repeat of
|
||||
/// key.pickItem's key clones the hovered slot; keys bound to key.attack and key.use do nothing
|
||||
/// there (#5378).
|
||||
pub(super) fn container_key_clones(button: MouseButton, action: KeyAction) -> bool {
|
||||
button == MouseButton::Middle && action != KeyAction::Release
|
||||
}
|
||||
@@ -274,16 +274,19 @@ pub(super) fn field_click(rect: Rect) -> (f32, f32) {
|
||||
/// (`AbstractSelectionList`) and of a focused `EditBox`. Buttons, sliders
|
||||
/// and tabs show their highlighted sprite, drawn under the hidden pointer.
|
||||
pub(super) fn draw_outline(atlas: &HudAtlas, batch: &mut HudBatch, focus: Target) {
|
||||
let [x, y, w, h] = focus.rect;
|
||||
if matches!(focus.kind, Kind::Row | Kind::Field) {
|
||||
let white = rgba(255, 255, 255, 255);
|
||||
batch.fill(atlas, x, y, w, 1.0, white);
|
||||
batch.fill(atlas, x, y + h - 1.0, w, 1.0, white);
|
||||
batch.fill(atlas, x, y, 1.0, h, white);
|
||||
batch.fill(atlas, x + w - 1.0, y, 1.0, h, white);
|
||||
draw_white_outline(atlas, batch, focus.rect);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn draw_white_outline(atlas: &HudAtlas, batch: &mut HudBatch, [x, y, w, h]: Rect) {
|
||||
let white = rgba(255, 255, 255, 255);
|
||||
batch.fill(atlas, x, y, w, 1.0, white);
|
||||
batch.fill(atlas, x, y + h - 1.0, w, 1.0, white);
|
||||
batch.fill(atlas, x, y, 1.0, h, white);
|
||||
batch.fill(atlas, x + w - 1.0, y, 1.0, h, white);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -4929,11 +4929,34 @@ impl Game<'_> {
|
||||
g.moves_from_ground
|
||||
};
|
||||
if g.spectator() {
|
||||
// Spectators' effects still run (LivingEntity.baseTick).
|
||||
// ServerPlayer.doTick runs the player's whole tick for a spectator
|
||||
// too, unless its box touches an unloaded chunk
|
||||
// (`touchingUnloadedChunk`): its effects (LivingEntity.baseTick),
|
||||
// Player.aiStep's tickRegeneration and foodData.tick go on while
|
||||
// it watches (#11312). The hazards, the travel and the movement
|
||||
// statistics below are what a spectator's body skips.
|
||||
let (at, body) = (g.position, g.dimensions());
|
||||
let loaded =
|
||||
!touching_unloaded_chunk(at, body, |x, z| self.world.chunks.contains_key(&(x, z)));
|
||||
if !g.effects.list.is_empty() {
|
||||
self.as_guest(host, conn, |game| game.tick_mob_effects());
|
||||
}
|
||||
self.as_guest(host, conn, |game| game.handle_shoulder_entities());
|
||||
self.as_guest(host, conn, |game| {
|
||||
game.handle_shoulder_entities();
|
||||
if !loaded {
|
||||
return;
|
||||
}
|
||||
let rules = game.world.health_rules;
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
return;
|
||||
};
|
||||
// Entity.baseTick's clocks (hurtTime, invulnerableTime) run too.
|
||||
player.health.advance_clock();
|
||||
let _ = player.peaceful_regeneration(rules);
|
||||
let _ = player.food_tick(&game.health_resources, rules);
|
||||
let events = std::mem::take(&mut player.events);
|
||||
game.record_combat(&events);
|
||||
});
|
||||
return;
|
||||
}
|
||||
let in_wall = !g.sleeping && self.in_wall(g.position, g.dimensions());
|
||||
@@ -6012,6 +6035,27 @@ fn swing_packet(id: i32, hand: Hand, animation: SwingAnimation) -> C {
|
||||
})
|
||||
}
|
||||
|
||||
/// `Entity.touchingUnloadedChunk`: the box at `at` inflated by one block
|
||||
/// reaches a chunk that is not loaded (`Level.hasChunksAt` over the floor of
|
||||
/// its minimum and the ceiling of its maximum, as section coordinates).
|
||||
fn touching_unloaded_chunk(
|
||||
at: [f64; 3],
|
||||
body: Dimensions,
|
||||
loaded: impl Fn(i32, i32) -> bool,
|
||||
) -> bool {
|
||||
let half = f64::from(body.width) / 2.0 + 1.0;
|
||||
let section = |v: f64| (v as i32) >> 4;
|
||||
let (x0, x1) = (
|
||||
section((at[0] - half).floor()),
|
||||
section((at[0] + half).ceil()),
|
||||
);
|
||||
let (z0, z1) = (
|
||||
section((at[2] - half).floor()),
|
||||
section((at[2] + half).ceil()),
|
||||
);
|
||||
!(x0..=x1).all(|x| (z0..=z1).all(|z| loaded(x, z)))
|
||||
}
|
||||
|
||||
/// The block columns `Entity.isInWall` looks at: `AABB.ofSize` around the
|
||||
/// eyes, 0.8 of the body's width across (`BlockPos.betweenClosedStream`).
|
||||
fn eye_columns(at: [f64; 3], body: Dimensions) -> [std::ops::RangeInclusive<i32>; 2] {
|
||||
@@ -6787,3 +6831,51 @@ mod swing_echo_tests {
|
||||
assert_eq!(swings_back(), 1, "a bed's use is swung back");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod unloaded_chunk_tests {
|
||||
use super::touching_unloaded_chunk;
|
||||
use horizon_client::pose::Dimensions;
|
||||
|
||||
/// `Entity.touchingUnloadedChunk` (#11312): the box inflated by one block
|
||||
/// is checked chunk by chunk; a player in the middle of a loaded chunk
|
||||
/// touches none, one within a block of its edge touches the neighbour.
|
||||
#[test]
|
||||
fn a_box_within_a_block_of_a_chunk_edge_touches_the_next_chunk() {
|
||||
let body = Dimensions {
|
||||
width: 0.6,
|
||||
height: 1.8,
|
||||
eye_height: 1.62,
|
||||
};
|
||||
let only_origin = |x: i32, z: i32| (x, z) == (0, 0);
|
||||
assert!(!touching_unloaded_chunk(
|
||||
[8.0, 64.0, 8.0],
|
||||
body,
|
||||
only_origin
|
||||
));
|
||||
// 15.5 + 0.3 + 1 reaches 16.8, in chunk 1.
|
||||
assert!(touching_unloaded_chunk(
|
||||
[15.5, 64.0, 8.0],
|
||||
body,
|
||||
only_origin
|
||||
));
|
||||
// 14.0 + 1.3 = 15.3: Mth.ceil gives 16, the first block of chunk 1.
|
||||
assert!(touching_unloaded_chunk(
|
||||
[14.0, 64.0, 8.0],
|
||||
body,
|
||||
only_origin
|
||||
));
|
||||
// 13.5 + 1.3 = 14.8: ceil 15, still chunk 0.
|
||||
assert!(!touching_unloaded_chunk(
|
||||
[13.5, 64.0, 8.0],
|
||||
body,
|
||||
only_origin
|
||||
));
|
||||
// Everything loaded: nothing is touched anywhere.
|
||||
assert!(!touching_unloaded_chunk(
|
||||
[15.9, 64.0, -0.5],
|
||||
body,
|
||||
|_, _| true
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -496,12 +496,16 @@ impl Game<'_> {
|
||||
let running = (!paused && !self.agent.hold_server).then(|| self.clock.world_runs());
|
||||
let server = self.serving.is_some() || self.options.dedicated.is_some();
|
||||
// A config edited while the server is paused (empty) still reloads.
|
||||
// The parts outside every level get spans of their own, so that a slow
|
||||
// tick's line and `horizon-report` say when a mod's tick hook took it.
|
||||
if server {
|
||||
let _span = horizon_client::spans::guard("mod_configs_poll");
|
||||
self.native_configs_poll();
|
||||
}
|
||||
if let Some(running) = running
|
||||
&& server
|
||||
{
|
||||
let _span = horizon_client::spans::guard("mods_server_tick_start");
|
||||
self.native_lifecycle_server_tick_start(running);
|
||||
}
|
||||
let observe_level =
|
||||
@@ -578,13 +582,20 @@ impl Game<'_> {
|
||||
self.native_lifecycle.cancel_server_tick();
|
||||
}
|
||||
result?;
|
||||
if let Err(error) = self.close_idle_levels() {
|
||||
let closed = {
|
||||
let _span = horizon_client::spans::guard("close_idle_levels");
|
||||
self.close_idle_levels()
|
||||
};
|
||||
if let Err(error) = closed {
|
||||
self.native_lifecycle.cancel_server_tick();
|
||||
return Err(error);
|
||||
}
|
||||
// `ServerTickEnd`: an errored tick or an idle-close failure (at
|
||||
// the `?` above) never reaches this site.
|
||||
self.native_lifecycle_server_tick_end(paused);
|
||||
{
|
||||
let _span = horizon_client::spans::guard("mods_server_tick_end");
|
||||
self.native_lifecycle_server_tick_end(paused);
|
||||
}
|
||||
// The packets counted for `native_server_packets` (only while a mod counts them).
|
||||
if server {
|
||||
self.diagnostics_packets_tick();
|
||||
|
||||
@@ -119,12 +119,18 @@ impl Game<'_> {
|
||||
.play("minecraft:item.llama_carpet.unequip", Some(at), 1.0, 1.0);
|
||||
self.spawn_item(ItemStack::new(&carpet, 1), at, [0.0, 0.2, 0.0], 10);
|
||||
self.damage_held_tool();
|
||||
let id = self.mobs[index].id;
|
||||
let pos = self.mobs[index].position;
|
||||
self.mob_equip_event(id, [pos.x, pos.y, pos.z], false);
|
||||
return true;
|
||||
}
|
||||
if carpet(item) && m.extra.body.is_none() {
|
||||
self.mobs[index].extra.body = Some(item.into());
|
||||
self.llama_sound(index, "entity.llama.swag", 1.0);
|
||||
self.consume_held();
|
||||
let id = self.mobs[index].id;
|
||||
let pos = self.mobs[index].position;
|
||||
self.mob_equip_event(id, [pos.x, pos.y, pos.z], true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
//! The loading screen while the resources load (`Loading`), the window's app before the game
|
||||
//! takes over (`App`), and the checks a start makes first: missing or damaged prepared files,
|
||||
//! an unclean last exit, a fullscreen the window system could not show.
|
||||
use super::*;
|
||||
|
||||
/// The window system ended the event loop with a failure (on Linux, a compositor dropping the
|
||||
/// connection after a protocol error) while fullscreen was on: the next start opens windowed, so a
|
||||
/// fullscreen the system can't show doesn't crash every launch (#2904).
|
||||
pub(super) fn leave_fullscreen_after(
|
||||
result: &std::result::Result<(), EventLoopError>,
|
||||
settings: &mut Settings,
|
||||
) {
|
||||
if matches!(result, Err(EventLoopError::ExitFailure(_))) && settings.fullscreen {
|
||||
settings.fullscreen = false;
|
||||
settings.save(&Game::options_path());
|
||||
log::warn!(
|
||||
"the window system closed the game in fullscreen; the next start opens windowed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run_event_loop(mut game: Game<'_>) -> Result<()> {
|
||||
if game.options.headless {
|
||||
// No display to open winit's loop on: the game's own loop.
|
||||
game.run_headless();
|
||||
// narrator::start ran for it too (options.txt's narrator setting).
|
||||
narrator::stop();
|
||||
write_report(&game)?;
|
||||
return game.error.take().map_or(Ok(()), Err);
|
||||
}
|
||||
let mut split = split::Split::new(game);
|
||||
let result = EventLoop::new()?.run_app(&mut split);
|
||||
game = split.host;
|
||||
narrator::stop();
|
||||
leave_fullscreen_after(&result, &mut game.settings);
|
||||
result?;
|
||||
#[cfg(feature = "discord")]
|
||||
horizon_client::discord_presence::stop();
|
||||
write_report(&game)?;
|
||||
if let Some(e) = game.error.take() {
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The loading screen's state while the worker loads the resources.
|
||||
pub(super) struct Loading {
|
||||
pub(super) window: Option<Arc<Window>>,
|
||||
pub(super) renderer: Option<Renderer>,
|
||||
/// The boot screen's atlas, until the pack's GUI atlas replaces it.
|
||||
pub(super) boot: horizon_client::hud::HudAtlas,
|
||||
pub(super) gui: Option<UiResources>,
|
||||
pub(super) settings: Settings,
|
||||
pub(super) fullscreen: horizon_client::window::FullscreenState,
|
||||
pub(super) video_modes: horizon_client::window::VideoModeList,
|
||||
pub(super) options: Option<Options>,
|
||||
pub(super) vanilla_root: PathBuf,
|
||||
pub(super) pack_error: Option<String>,
|
||||
pub(super) started: Instant,
|
||||
pub(super) panorama_rotation: horizon_client::gfx::panorama::Rotation,
|
||||
pub(super) progress: Progress,
|
||||
pub(super) receiver: Receiver<LoaderMsg>,
|
||||
pub(super) loaded: Option<std::result::Result<Loaded, String>>,
|
||||
/// The loader's `Done` arrived: its channel closing afterwards is expected.
|
||||
pub(super) delivered: bool,
|
||||
pub(super) handle: Option<JoinHandle<()>>,
|
||||
pub(super) shown: bool,
|
||||
/// When the menu bundle arrived (`set_ui`), and whether a title frame with
|
||||
/// it was presented since: the world renderer waits for that frame
|
||||
/// (`ready_for_handoff`).
|
||||
pub(super) ui_at: Option<Instant>,
|
||||
pub(super) title_drawn: bool,
|
||||
pub(super) menu: startup_menu::StartupMenu,
|
||||
pub(super) error: Option<Box<dyn std::error::Error>>,
|
||||
}
|
||||
/// How long the handoff waits for the first title frame before building the
|
||||
/// world renderer anyway (a hidden window may never be asked to redraw).
|
||||
pub(super) const TITLE_FRAME_WAIT: Duration = Duration::from_secs(1);
|
||||
impl Loading {
|
||||
/// The world renderer is built on the main thread, so building it before
|
||||
/// the title is on the screen holds the title back by that build. Wait for
|
||||
/// one title frame first, unless a world is waiting to open.
|
||||
pub(super) fn ready_for_handoff(&self) -> bool {
|
||||
self.title_drawn
|
||||
|| self.menu.pending.is_some()
|
||||
|| self.ui_at.is_none_or(|at| at.elapsed() >= TITLE_FRAME_WAIT)
|
||||
}
|
||||
pub(super) fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.window.is_some() {
|
||||
return;
|
||||
}
|
||||
// The Window constructor: created fullscreen when options.txt or --fullscreen says so.
|
||||
let fullscreen =
|
||||
self.settings.fullscreen || self.options.as_ref().is_some_and(|o| o.fullscreen);
|
||||
let size = self
|
||||
.options
|
||||
.as_ref()
|
||||
.map_or([1280, 720], |o| o.window_size)
|
||||
.map(f64::from);
|
||||
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
|
||||
let attributes = horizon_client::window::with_horizon_icon(
|
||||
Window::default_attributes().with_title("Horizon"),
|
||||
);
|
||||
// Wayland names the window by its app id, which the horizon.desktop entry matches.
|
||||
#[cfg(target_os = "linux")]
|
||||
let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name(
|
||||
attributes, "horizon", "horizon",
|
||||
);
|
||||
// `--offscreen` renders into a texture: its window would only show white.
|
||||
let window_step = startup::step("window.create");
|
||||
let window = match event_loop.create_window(
|
||||
attributes
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(size[0], size[1]))
|
||||
.with_fullscreen(fullscreen.then_some(winit::window::Fullscreen::Borderless(None)))
|
||||
.with_visible(!self.options.as_ref().is_some_and(|o| o.offscreen)),
|
||||
) {
|
||||
Ok(w) => Arc::new(w),
|
||||
Err(e) => {
|
||||
self.error = Some(e.into());
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
};
|
||||
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
|
||||
drop(window_step);
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
// The renderer takes seconds to build and winit's window sits white
|
||||
// until the first frame presents, so paint the boot screen on it and
|
||||
// keep repainting it as the console's lines arrive; the wgpu frames
|
||||
// redraw the same design when they start. GDI is unsafe:
|
||||
// horizon-threads holds it (this crate forbids unsafe code).
|
||||
#[cfg(target_os = "windows")]
|
||||
let boot_paint_stop = hwnd(&window).map(|hwnd| {
|
||||
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let repaint_stop = stop.clone();
|
||||
std::thread::spawn(move || {
|
||||
while !repaint_stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
horizon_threads::boot_paint::paint(
|
||||
hwnd,
|
||||
horizon_theme::fonts::GEIST_SEMIBOLD,
|
||||
horizon_theme::fonts::GEIST_MONO,
|
||||
&horizon_client::logging::recent_lines(),
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
}
|
||||
});
|
||||
stop
|
||||
});
|
||||
let offscreen = self.options.as_ref().is_some_and(|o| o.offscreen);
|
||||
let boot_step = startup::step("renderer.boot");
|
||||
match loading_renderer(
|
||||
window.clone(),
|
||||
offscreen,
|
||||
Some(&self.boot),
|
||||
None,
|
||||
None,
|
||||
self.settings.horizon.api,
|
||||
) {
|
||||
Ok(r) => self.renderer = Some(r),
|
||||
Err(e) => {
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(stop) = &boot_paint_stop {
|
||||
stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
self.error = Some(e);
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
}
|
||||
drop(boot_step);
|
||||
// The wgpu frames take the window over: stop the boot paint thread
|
||||
// before the first one presents.
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(stop) = &boot_paint_stop {
|
||||
stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
horizon_client::hud::GUI_SCALE.store(self.settings.gui_scale, Ordering::Relaxed);
|
||||
self.window = Some(window);
|
||||
}
|
||||
pub(super) fn window_event(
|
||||
&mut self,
|
||||
event_loop: &ActiveEventLoop,
|
||||
id: WindowId,
|
||||
mut event: WindowEvent,
|
||||
) {
|
||||
if self.window.as_ref().is_none_or(|w| w.id() != id) {
|
||||
return;
|
||||
}
|
||||
follow_keymap(&mut event);
|
||||
if let Err(error) = self.menu_event(event_loop, event) {
|
||||
self.error = Some(error);
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
/// The menu bundle arrived: rebuild the loading screen with the real atlas
|
||||
/// and title panorama.
|
||||
pub(super) fn set_ui(&mut self, gui: UiResources, root: PathBuf) {
|
||||
log::info!(
|
||||
"Horizon: loading the title artwork after {} ms",
|
||||
self.started.elapsed().as_millis()
|
||||
);
|
||||
self.menu.set_root(root, &self.settings);
|
||||
self.gui = Some(gui);
|
||||
self.title_drawn = false;
|
||||
horizon_client::ui_theme::reset_atlas();
|
||||
self.apply_menu_settings();
|
||||
let Some(window) = self.window.clone() else {
|
||||
return;
|
||||
};
|
||||
let offscreen = self.options.as_ref().is_some_and(|o| o.offscreen);
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
renderer.release_surface();
|
||||
}
|
||||
let bundle = self.gui.as_ref().expect("the menu bundle was just set");
|
||||
let _step = startup::step("renderer.title_artwork");
|
||||
match loading_renderer(
|
||||
window,
|
||||
offscreen,
|
||||
Some(&bundle.atlas),
|
||||
Some(&bundle.panorama),
|
||||
self.renderer.as_ref(),
|
||||
self.settings.horizon.api,
|
||||
) {
|
||||
Ok(r) => {
|
||||
horizon_client::hud::GUI_SCALE.store(self.settings.gui_scale, Ordering::Relaxed);
|
||||
self.renderer = Some(r);
|
||||
}
|
||||
Err(e) => self.error = Some(e),
|
||||
}
|
||||
// The title frame's wait starts once its renderer is built.
|
||||
self.ui_at = Some(Instant::now());
|
||||
}
|
||||
pub(super) fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
// The loop may run once more after exit(): keep the first error, the
|
||||
// one that ended loading.
|
||||
if self.error.is_some() {
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
if horizon_threads::stop_requests() > 0 {
|
||||
// No world is open yet: nothing to save.
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
if let Err(error) = self.poll_menu_resources() {
|
||||
self.error = Some(error);
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
self.poll_menu_realms(event_loop);
|
||||
if let Some(w) = &self.window {
|
||||
if self.options.as_ref().is_some_and(|o| o.offscreen) {
|
||||
self.draw();
|
||||
} else {
|
||||
w.request_redraw();
|
||||
}
|
||||
}
|
||||
event_loop.set_control_flow(ControlFlow::Poll);
|
||||
}
|
||||
pub(super) fn draw(&mut self) {
|
||||
let Some(size) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|r| [r.config.width, r.config.height])
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut batch = HudBatch::new(size);
|
||||
self.draw_menu(&mut batch);
|
||||
self.menu.frame_drawn((batch.width, batch.height));
|
||||
let mut frame = loading_frame(&batch);
|
||||
frame.yaw = self.panorama_rotation.update(
|
||||
self.started.elapsed().as_secs_f32(),
|
||||
self.settings.file.double("panoramaScrollSpeed") as f32,
|
||||
);
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
match renderer.render(&frame) {
|
||||
Ok(true) if self.gui.is_some() && self.menu.pending.is_none() => {
|
||||
self.title_drawn = true;
|
||||
self.menu.perf.title_shown()
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("Horizon: loading screen frame failed: {e}"),
|
||||
}
|
||||
}
|
||||
if !self.shown {
|
||||
self.shown = true;
|
||||
startup::mark("loading_screen.shown");
|
||||
log::info!(
|
||||
"Horizon: loading screen shown after {} ms",
|
||||
self.started.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
}
|
||||
pub(super) fn finish(&mut self, loaded: Loaded) -> Result<Option<split::Split<'static>>> {
|
||||
let handoff_at = Instant::now();
|
||||
let mut options = self.options.take().ok_or("loading already finished")?;
|
||||
options.resources = loaded.root.clone();
|
||||
// StartupMenu owns world selection until it can hand it to Game.
|
||||
options.world = None;
|
||||
options.server = None;
|
||||
let gui = self.gui.take().ok_or("missing menu bundle")?;
|
||||
let window = self.window.clone().ok_or("window closed")?;
|
||||
// Keep the loader's device and swap in the real textures in one step.
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
renderer.release_surface();
|
||||
}
|
||||
let renderer_at = Instant::now();
|
||||
let renderer = startup::time("renderer.world", || {
|
||||
build_renderer(
|
||||
window.clone(),
|
||||
options.offscreen,
|
||||
&loaded.pack,
|
||||
&gui,
|
||||
self.renderer.as_ref(),
|
||||
loaded.renderer_cpu,
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
self.settings.horizon.wide_frame(),
|
||||
shader_packs::picked_pack_shape(&self.settings),
|
||||
(
|
||||
self.settings.file.int("textureFiltering"),
|
||||
self.settings.file.int("maxAnisotropyBit"),
|
||||
),
|
||||
)
|
||||
})?;
|
||||
log::info!(
|
||||
"Horizon: world renderer ready after {} ms",
|
||||
self.started.elapsed().as_millis()
|
||||
);
|
||||
let renderer_ms = renderer_at.elapsed().as_millis();
|
||||
// Audio is created on the main thread, after the core is ready.
|
||||
let audio = if !options.execution_role.is_client() {
|
||||
Audio::silent(&options.resources)
|
||||
} else {
|
||||
Audio::with_device(&options.resources, &self.settings.sound_device)
|
||||
};
|
||||
let pack_error = loaded.pack_error.or_else(|| self.pack_error.take());
|
||||
// The game borrows its shared resources for the process's lifetime,
|
||||
// like the single copy an integrated server would share.
|
||||
let res: &'static Arc<Resources> = Box::leak(Box::new(loaded.resources));
|
||||
let game_at = Instant::now();
|
||||
let game_step = startup::step("handoff.start_game");
|
||||
let Some(mut game) = start_game(
|
||||
res,
|
||||
loaded.pack,
|
||||
gui,
|
||||
options,
|
||||
pack_error,
|
||||
audio,
|
||||
self.vanilla_root.clone(),
|
||||
self.started,
|
||||
loaded.rest_loader,
|
||||
Some(self.settings.clone()),
|
||||
loaded.game_cpu,
|
||||
loaded.entity_models,
|
||||
)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
drop(game_step);
|
||||
let game_ms = game_at.elapsed().as_millis();
|
||||
game.window = Some(window);
|
||||
game.renderer = Some(renderer);
|
||||
game.world_renderer_ready = true;
|
||||
game.fullscreen = self.fullscreen;
|
||||
game.panorama_rotation = self.panorama_rotation;
|
||||
self.menu.handoff(&mut game);
|
||||
game.apply_settings();
|
||||
game.previous = Instant::now();
|
||||
// Minecraft.onGameLoadFinished: a start that made it this far is clean.
|
||||
game.settings.file.set(
|
||||
"startedCleanly",
|
||||
horizon_client::options_file::V::Bool(true),
|
||||
);
|
||||
game.settings.save(&Game::options_path());
|
||||
log::info!(
|
||||
"Horizon: startup handoff {} ms (renderer {} ms, start_game {} ms)",
|
||||
handoff_at.elapsed().as_millis(),
|
||||
renderer_ms,
|
||||
game_ms
|
||||
);
|
||||
Ok(Some(split::Split::new(game)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The application: the loading screen until the resources are ready, then
|
||||
/// the game itself, on the same event loop and window.
|
||||
pub(super) enum App {
|
||||
Loading(Box<Loading>),
|
||||
Ready(Box<split::Split<'static>>),
|
||||
}
|
||||
impl ApplicationHandler for App {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
match self {
|
||||
App::Loading(loading) => loading.resumed(event_loop),
|
||||
App::Ready(game) => game.resumed(event_loop),
|
||||
}
|
||||
}
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
|
||||
match self {
|
||||
App::Loading(loading) => loading.window_event(event_loop, id, event),
|
||||
App::Ready(game) => game.window_event(event_loop, id, event),
|
||||
}
|
||||
}
|
||||
fn device_event(&mut self, event_loop: &ActiveEventLoop, id: DeviceId, event: DeviceEvent) {
|
||||
if let App::Ready(game) = self {
|
||||
game.device_event(event_loop, id, event);
|
||||
}
|
||||
}
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
enum Step {
|
||||
Waiting,
|
||||
Ready(Box<split::Split<'static>>),
|
||||
Done,
|
||||
Failed(Box<dyn std::error::Error>),
|
||||
}
|
||||
let step = match self {
|
||||
App::Ready(game) => {
|
||||
game.about_to_wait(event_loop);
|
||||
return;
|
||||
}
|
||||
App::Loading(loading) => {
|
||||
loading.about_to_wait(event_loop);
|
||||
if loading.error.is_some() {
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
match if loading.menu.reload_packs || !loading.ready_for_handoff() {
|
||||
None
|
||||
} else {
|
||||
loading.loaded.take()
|
||||
} {
|
||||
None => Step::Waiting,
|
||||
Some(Err(e)) => Step::Failed(e.into()),
|
||||
Some(Ok(loaded)) => match loading.finish(loaded) {
|
||||
Ok(Some(game)) => Step::Ready(Box::new(game)),
|
||||
Ok(None) => Step::Done,
|
||||
Err(e) => Step::Failed(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
match step {
|
||||
Step::Waiting => {}
|
||||
Step::Ready(game) => *self = App::Ready(game),
|
||||
Step::Done => event_loop.exit(),
|
||||
Step::Failed(e) => {
|
||||
if let App::Loading(loading) = self {
|
||||
loading.error = Some(e);
|
||||
}
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn exiting(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if let App::Ready(game) = self {
|
||||
game.exiting(event_loop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every launch reads the prepared game files (`horizon-prepare`'s output, whose
|
||||
/// manifest is `horizon-resources.json`): without them, say where they were
|
||||
/// looked for and how to get them, before a window or a loader starts.
|
||||
pub(super) fn missing_resources(root: &std::path::Path) -> Option<String> {
|
||||
let shown = std::path::absolute(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
if !root.join("horizon-resources.json").is_file() {
|
||||
return Some(format!(
|
||||
"Horizon's prepared game files are missing: no horizon-resources.json in {}\n\
|
||||
Horizon is started from its launcher: download it at {} (Download). It needs your \
|
||||
own Minecraft: Java Edition; the launcher signs you in and prepares the game files.\n\
|
||||
Developers: horizon-prepare --prepare, or pass --resources <folder>",
|
||||
shown.display(),
|
||||
horizon_client::no_resources::DOWNLOAD_URL
|
||||
));
|
||||
}
|
||||
damaged_prepared(root).map(|problem| {
|
||||
format!(
|
||||
"Horizon's prepared game files are damaged: {problem} in {}\n\
|
||||
Prepare them again with the launcher, or: horizon-prepare --prepare",
|
||||
shown.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Which top-level prepared file a killed preparation or an interrupted copy
|
||||
/// left unusable. The loader parses these before it can start; naming one
|
||||
/// beats serde's bare "expected value at line 1 column 1".
|
||||
pub(super) fn damaged_prepared(root: &std::path::Path) -> Option<String> {
|
||||
let manifest = root.join("horizon-resources.json");
|
||||
let bytes = std::fs::read(&manifest).ok()?;
|
||||
if bytes.iter().all(u8::is_ascii_whitespace) {
|
||||
return Some("horizon-resources.json is empty".into());
|
||||
}
|
||||
if serde_json::from_slice::<serde_json::Value>(&bytes).is_err() {
|
||||
return Some("horizon-resources.json is not valid JSON".into());
|
||||
}
|
||||
for entry in std::fs::read_dir(root).ok()?.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension() != Some(std::ffi::OsStr::new("json")) {
|
||||
continue;
|
||||
}
|
||||
if std::fs::metadata(&path).is_ok_and(|meta| meta.len() == 0) {
|
||||
return Some(format!("{} is empty", entry.file_name().to_string_lossy()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `Minecraft.<init>`: `startedCleanly` left false means the previous start
|
||||
/// died before `onGameLoadFinished`. Vanilla then leaves fullscreen
|
||||
/// ("resetting fullscreen mode") so a start that died on the display mode can
|
||||
/// come back; the resource packs are rolled back only when their load fails
|
||||
/// (`rollbackResourcePacks`), not here. True when the last start was unclean.
|
||||
pub(super) fn unclean_start_recovery(settings: &mut Settings) -> bool {
|
||||
// Vanilla's field defaults true: only an explicit `false` is an unclean
|
||||
// start; an options file that never had the key is not one.
|
||||
let unclean = matches!(
|
||||
settings.file.get("startedCleanly"),
|
||||
Some(horizon_client::options_file::V::Bool(false))
|
||||
);
|
||||
if unclean {
|
||||
settings.fullscreen = false;
|
||||
settings.file.fullscreen_resolution = None;
|
||||
}
|
||||
unclean
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
//! The window's first renderer and frames: the renderer built from the prepared textures, the
|
||||
//! boot screen's frame, and `start_game`, which turns the loaded resources into a `Game`.
|
||||
use super::*;
|
||||
|
||||
/// CPU mip chains for the block sprites and world border.
|
||||
pub(super) fn prepare_renderer_textures(
|
||||
pack: &PackResources,
|
||||
) -> horizon_client::gfx::PreparedTextures {
|
||||
let assets = &pack.render;
|
||||
horizon_client::gfx::PreparedTextures::new(
|
||||
&assets.pixels.get(),
|
||||
assets.sprite_size,
|
||||
assets.texture_layers,
|
||||
&assets.sprite_mips,
|
||||
(&pack.forcefield.2, pack.forcefield.0),
|
||||
)
|
||||
.with_pbr(
|
||||
&assets.pbr_normals,
|
||||
&assets.pbr_specular,
|
||||
assets.pbr_size,
|
||||
assets.texture_layers,
|
||||
)
|
||||
}
|
||||
|
||||
/// Upload pack textures and create the renderer on the window's device.
|
||||
pub(super) fn build_renderer(
|
||||
window: Arc<Window>,
|
||||
offscreen: bool,
|
||||
pack: &PackResources,
|
||||
gui: &UiResources,
|
||||
previous: Option<&Renderer>,
|
||||
prepared: Option<horizon_client::gfx::PreparedTextures>,
|
||||
api: horizon_client::graphics::GraphicsApi,
|
||||
translucent_batching: bool,
|
||||
// HDR with Smooth Gradients (`Graphics::wide_frame`): a 16-bit frame where HDR output is
|
||||
// offered (`Renderer::new`).
|
||||
wide_frame: bool,
|
||||
shader_pack: shader_packs::PickedPack,
|
||||
filtering: (i32, i32),
|
||||
) -> Result<Renderer> {
|
||||
let assets = &pack.render;
|
||||
let atlas = &gui.atlas;
|
||||
// The new renderer's atlas texture starts from `HudAtlas.pixels`, which
|
||||
// holds the textures but never a glyph rasterized at run time. The font's
|
||||
// cache still answers with the rects it drew into the previous renderer's
|
||||
// texture, so forget them: the glyphs that are drawn again are placed into
|
||||
// this texture (as `ui_theme` does when its atlas changes). Without this,
|
||||
// Chinese and Japanese menu labels go blank when the loading screen hands
|
||||
// over to the game.
|
||||
atlas.font.reset_cache();
|
||||
// Made here when the worker did not: the renderer's own fallback has no LabPBR maps.
|
||||
let prepared = Some(prepared.unwrap_or_else(|| prepare_renderer_textures(pack)));
|
||||
let atlas_pixels = atlas.pixels();
|
||||
let mut renderer = pollster::block_on(Renderer::new(
|
||||
window,
|
||||
offscreen,
|
||||
&assets.pixels.get(),
|
||||
assets.sprite_size,
|
||||
assets.sprite_minimum_size,
|
||||
assets.texture_layers,
|
||||
&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.clone(),
|
||||
prepared,
|
||||
previous,
|
||||
api,
|
||||
translucent_batching,
|
||||
wide_frame,
|
||||
shader_pack.draws_flat,
|
||||
shader_pack.linear,
|
||||
shader_pack.hdr_float,
|
||||
))?;
|
||||
renderer.hud.sprite_animations = atlas
|
||||
.sprite_animations
|
||||
.iter()
|
||||
.map(|(rect, animation)| (*rect, animation.clone(), (usize::MAX, 0, 0)))
|
||||
.collect();
|
||||
renderer
|
||||
.hud
|
||||
.set_text_shader(&renderer.device, atlas.text_shader.as_deref());
|
||||
pack.item_renderer
|
||||
.prepare_glint_atlas(renderer.device.limits().max_texture_dimension_2d, filtering)?;
|
||||
renderer.set_lines_shader(atlas.lines_shader.as_deref());
|
||||
renderer.set_custom_sky(&pack.custom_sky);
|
||||
let cit_glints = pack.item_renderer.cit.as_ref().map(|c| &c.glint_textures);
|
||||
renderer
|
||||
.hud
|
||||
.set_cit_glints(&renderer.device, &renderer.queue, cit_glints);
|
||||
Ok(renderer)
|
||||
}
|
||||
|
||||
/// The loading screen's renderer: one atlas (the boot screen's until the
|
||||
/// pack's GUI atlas is ready) over one opaque white block-sprite layer, an
|
||||
/// empty border texture and the title panorama once the menu bundle is ready.
|
||||
pub(super) fn loading_renderer(
|
||||
window: Arc<Window>,
|
||||
offscreen: bool,
|
||||
atlas: Option<&horizon_client::hud::HudAtlas>,
|
||||
panorama: Option<&Arc<horizon_client::gfx::panorama::Faces>>,
|
||||
previous: Option<&Renderer>,
|
||||
api: horizon_client::graphics::GraphicsApi,
|
||||
) -> 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 panorama = panorama
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Arc::new(horizon_client::gfx::panorama::Faces::colour([0; 4])));
|
||||
let (pixels, width, height) = atlas.map_or((EMPTY_HUD.as_slice(), 1, 1), |a| {
|
||||
(a.pixels.as_slice(), a.width, a.height)
|
||||
});
|
||||
let (glint_pixels, glint_width, glint_height) = atlas
|
||||
.map_or((EMPTY_GLINT.as_slice(), 1, 1), |a| {
|
||||
(a.glint.0.as_slice(), a.glint.1, a.glint.2)
|
||||
});
|
||||
// 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`).
|
||||
if let Some(atlas) = atlas {
|
||||
atlas.font.reset_cache();
|
||||
}
|
||||
let mut renderer = pollster::block_on(Renderer::new(
|
||||
window,
|
||||
offscreen,
|
||||
&[255, 255, 255, 255],
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
&[],
|
||||
&[],
|
||||
(pixels, width, height),
|
||||
(glint_pixels, glint_width, glint_height),
|
||||
(glint_pixels, glint_width, glint_height),
|
||||
(&FORCEFIELD[..], 1),
|
||||
panorama,
|
||||
None,
|
||||
previous,
|
||||
api,
|
||||
// It draws no terrain.
|
||||
false,
|
||||
// An 8-bit frame, which HDR output takes as well.
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
if let Some(atlas) = atlas {
|
||||
renderer.hud.sprite_animations = atlas
|
||||
.sprite_animations
|
||||
.iter()
|
||||
.map(|(rect, animation)| (*rect, animation.clone(), (usize::MAX, 0, 0)))
|
||||
.collect();
|
||||
}
|
||||
Ok(renderer)
|
||||
}
|
||||
|
||||
pub(super) fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
Frame {
|
||||
weather: &[],
|
||||
weather_time: horizon_client::weather::Time::new(0, 0.0),
|
||||
camera: glam::DVec3::new(0.5, 100.0, 0.5),
|
||||
yaw: 0.0,
|
||||
pitch: 10.0,
|
||||
fov_degrees: 85.0,
|
||||
eye: glam::DVec3::new(0.5, 100.0, 0.5),
|
||||
eye_rotation: [0.0, 10.0],
|
||||
first_person: true,
|
||||
bob: glam::Mat4::IDENTITY,
|
||||
spin: glam::Mat4::IDENTITY,
|
||||
sky: horizon_client::environment::Sky::default(),
|
||||
shadow_scene_revision: (0, 0),
|
||||
shadow_cache: false,
|
||||
distance: 12.0,
|
||||
detail_distance: 12.0,
|
||||
depth_far: 1000.0,
|
||||
underwater: false,
|
||||
water_plane: None,
|
||||
cave_culling: false,
|
||||
title: true,
|
||||
sky_blocked: false,
|
||||
skybox: false,
|
||||
end_sky: false,
|
||||
end_sky_sprite: None,
|
||||
boss_world_fog: false,
|
||||
environment_fog: None,
|
||||
water_fog: [0.0; 3],
|
||||
fog: None,
|
||||
dark_disc: false,
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
pvp: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
hud,
|
||||
glint_alpha: 0.75,
|
||||
glint_ticks: 0.0,
|
||||
nether_lighting: false,
|
||||
entities: &[],
|
||||
static_block_entities: &[],
|
||||
visible_static_block_entity_sections: &[],
|
||||
static_block_entity_stats: Default::default(),
|
||||
entity_motion: &[],
|
||||
entity_motion_posed: &[],
|
||||
entity_spans: &[],
|
||||
skin: None,
|
||||
glowing: &[],
|
||||
dynamic_lights: &[],
|
||||
colored_lights: &[],
|
||||
emissive_layers: &[],
|
||||
clouds: None,
|
||||
lightning: Default::default(),
|
||||
additive: &[],
|
||||
see_through: &[],
|
||||
world_primitives: Default::default(),
|
||||
shadow_only: &[],
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_items: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
border: None,
|
||||
game_time: 0.0,
|
||||
shader_game_time: 0.0,
|
||||
end_portal_sprites: [[0.0; 4]; 2],
|
||||
crumbling_sprite: [0.0; 4],
|
||||
ticks: 0,
|
||||
darkness: (0.0, 0.0),
|
||||
night_vision: 0.0,
|
||||
block_flicker: 0.0,
|
||||
boss_darken: 0.0,
|
||||
chunk_fade: 0.0,
|
||||
post_effects: horizon_client::post_chain::Request::none(),
|
||||
menu_blur: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The post-load setup once the resources are ready: `Game::new` and the
|
||||
/// options, packs, mods, control socket, benchmark and world selection the
|
||||
/// old `run` did after loading. `None` means a harness or dedicated run
|
||||
/// finished here instead.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn start_game<'a>(
|
||||
res: &'a Arc<Resources>,
|
||||
pack: PackResources,
|
||||
gui: UiResources,
|
||||
options: Options,
|
||||
pack_error: Option<String>,
|
||||
audio: Audio,
|
||||
vanilla_root: PathBuf,
|
||||
started: Instant,
|
||||
rest_loader: Option<WorldRestLoader>,
|
||||
settings: Option<Settings>,
|
||||
prepared: Option<GameCpuResources>,
|
||||
entity_models: Option<Arc<horizon_client::mobs::EntityModelDocument>>,
|
||||
) -> Result<Option<Game<'a>>> {
|
||||
let handoff = prepared.is_some();
|
||||
let mut settings = match settings {
|
||||
Some(settings) => settings,
|
||||
None => Settings::load(&Game::options_path()),
|
||||
};
|
||||
if let Some(d) = options.distance_override {
|
||||
settings.render_distance = d;
|
||||
}
|
||||
// A synchronous launch has no handoff worker: build its CPU resources
|
||||
// here, from the same entity-model document the rest worker read.
|
||||
let prepared = match prepared {
|
||||
Some(prepared) => Some(prepared),
|
||||
None => Some(GameCpuResources::load(
|
||||
&options.resources,
|
||||
entity_models.as_deref(),
|
||||
)?),
|
||||
};
|
||||
let mut game = Game::new(res, pack, gui, options, settings, audio, prepared)?;
|
||||
game.rest_loader = rest_loader;
|
||||
game.ui.vanilla_root = vanilla_root;
|
||||
if let Some(error) = pack_error {
|
||||
// Minecraft.clearResourcePacksOnError: the selection is cleared and
|
||||
// saved, so the next launch starts without the packs.
|
||||
game.settings.resource_packs.clear();
|
||||
game.settings.save(&Game::options_path());
|
||||
game.open_options_at(true, horizon_client::options_ui::Page::Packs);
|
||||
game.ui.options.resource_error = Some(error);
|
||||
}
|
||||
if game.options.benchmark.is_some() {
|
||||
// The same conditions on every machine: no VSync, no frame cap, no
|
||||
// pause or idle limit without focus. In memory only, never saved.
|
||||
game.settings.vsync = false;
|
||||
game.settings.max_fps = 260;
|
||||
game.settings.inactivity_fps_limit = "minimized".into();
|
||||
game.options.no_pause = true;
|
||||
}
|
||||
game.apply_settings();
|
||||
// Benchmarks measure the game alone.
|
||||
if game.options.benchmark.is_none() {
|
||||
game.mods.dev = game.options.dev_mods.clone();
|
||||
game.start_mods();
|
||||
}
|
||||
// After the mods: their templates are among the ids it lists.
|
||||
game.preload_registries();
|
||||
game.start_agent_control()?;
|
||||
if let Some(scenario) = game.options.benchmark.clone() {
|
||||
let control = game
|
||||
.control
|
||||
.get_or_insert_with(horizon_client::control::Server::local);
|
||||
horizon_client::benchmark::start(
|
||||
control.sender(),
|
||||
horizon_client::benchmark::Setup {
|
||||
scenario,
|
||||
started,
|
||||
app: horizon_bench::App {
|
||||
name: "horizon-client".into(),
|
||||
version: horizon_client::crash::version(),
|
||||
commit: option_env!("HORIZON_COMMIT").map(str::to_owned),
|
||||
mods: Vec::new(),
|
||||
},
|
||||
simulation_distance: game.settings.simulation_distance as u32,
|
||||
vsync: false,
|
||||
fps_cap: None,
|
||||
offscreen: game.options.offscreen,
|
||||
game_dir: std::env::current_dir()?,
|
||||
},
|
||||
);
|
||||
}
|
||||
if let Some(directory) = std::env::var_os("HORIZON_NATIVE_CLIENT_PARTICLES_FIXTURE") {
|
||||
game.run_native_client_particles_fixture(std::path::Path::new(&directory))?;
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(directory) = std::env::var_os("HORIZON_BOAT_RIDER_FIXTURE") {
|
||||
game.run_boat_rider_fixture(std::path::Path::new(&directory))?;
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(directory) = std::env::var_os("HORIZON_BOAT_AUTHORITY_FIXTURE") {
|
||||
game.run_boat_authority_fixture(std::path::Path::new(&directory))?;
|
||||
return Ok(None);
|
||||
}
|
||||
if let Ok(spec) = std::env::var("HORIZON_DISPENSE_FIXTURE") {
|
||||
game.run_component_harness(integrated::ComponentHarness::Dispenser(spec))?;
|
||||
return Ok(None);
|
||||
}
|
||||
if let Ok(spec) = std::env::var("HORIZON_SCULK_FIXTURE") {
|
||||
game.run_component_harness(integrated::ComponentHarness::Sculk(spec))?;
|
||||
return Ok(None);
|
||||
}
|
||||
if game.options.execution_role == execution_role::ExecutionRole::DedicatedServer {
|
||||
game.run_dedicated()?;
|
||||
return Ok(None);
|
||||
}
|
||||
match game.options.world.clone() {
|
||||
_ if game.options.server.is_some() => {
|
||||
let (address, name) = (
|
||||
game.options.server.clone().unwrap_or_default(),
|
||||
game.options.player_name(),
|
||||
);
|
||||
game.connect_server(&address, &name)?;
|
||||
if let Some(report) = std::env::var_os("HORIZON_NET_REPORT") {
|
||||
game.run_net_harness(report.as_ref())?;
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Some(folder) => {
|
||||
let (seed, creative) = (game.options.seed, game.options.creative);
|
||||
if std::env::var_os("HORIZON_COMMAND_SCRIPT").is_some() {
|
||||
// The loaded-command fixture pins the server's valid minimum.
|
||||
// Client options reject simulationDistance2 (their UI starts at5),
|
||||
// so an options.txt value cannot establish this prerequisite.
|
||||
if std::env::var_os("HORIZON_HARNESS_LOADED_FIXTURE").is_some() {
|
||||
game.settings.simulation_distance = 2;
|
||||
}
|
||||
// Headless, but with the same server and memory connection as play.
|
||||
game.open_integrated(&folder, None, Some(seed), creative, None)?;
|
||||
} else {
|
||||
// Start after the window is handed over, so --world uses the
|
||||
// same responsive initialization path as the title screen.
|
||||
game.title = Some(
|
||||
Title::new(
|
||||
std::env::current_dir()?.join("saves"),
|
||||
&game.options.resources,
|
||||
)
|
||||
.with_user(|| game.options.player_name()),
|
||||
);
|
||||
game.pending_entry = Some(PendingWorldEntry::Local {
|
||||
folder,
|
||||
name: None,
|
||||
seed: Some(seed),
|
||||
creative,
|
||||
create: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
None if handoff => {}
|
||||
None => {
|
||||
let mut title = Title::new(
|
||||
std::env::current_dir()?.join("saves"),
|
||||
&game.options.resources,
|
||||
)
|
||||
.with_user(|| game.options.player_name());
|
||||
title.last_server = game.settings.file.string("lastServer");
|
||||
title.skip_multiplayer_warning = game.settings.file.bool("skipMultiplayerWarning");
|
||||
// Gui.addInitialScreens: the accessibility onboarding comes first.
|
||||
if game.settings.file.bool("onboardAccessibility") {
|
||||
title.start_onboarding();
|
||||
}
|
||||
game.title = Some(title);
|
||||
}
|
||||
}
|
||||
if std::env::var_os("HORIZON_COMMAND_SCRIPT").is_some()
|
||||
&& std::env::var_os("HORIZON_COMMAND_OUTPUT").is_some()
|
||||
{
|
||||
game.wait_command_harness()?;
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(game))
|
||||
}
|
||||
@@ -11,19 +11,20 @@ use horizon_client::{
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
use horizon_world::nbt::{JavaString, Tag};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// What a decoration is filed under in `MapItemSavedData.decorations` (a
|
||||
/// `LinkedHashMap<String, MapDecoration>`): a player's marker by their
|
||||
/// connection, the banner at a position (`MapBanner.getId`), a framed map's
|
||||
/// frame by its entity (`getFrameKey`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) enum Key {
|
||||
Player(u32),
|
||||
/// An item's `minecraft:map_decorations` (an explorer map's target), or a
|
||||
/// decoration a server sent (`addClientSideDecorations`).
|
||||
Item,
|
||||
/// A named entry of an item's `minecraft:map_decorations`.
|
||||
Item(JavaString),
|
||||
/// A decoration a server sent (`addClientSideDecorations`).
|
||||
Remote,
|
||||
Banner([i32; 3]),
|
||||
Frame(i32),
|
||||
}
|
||||
@@ -301,8 +302,8 @@ impl MapData {
|
||||
self.holders.retain(|h| h.holds(now));
|
||||
let holders = &self.holders;
|
||||
let before = self.decorations.len();
|
||||
self.decorations.retain(|d| match d.key {
|
||||
Key::Player(w) => holders.iter().any(|h| h.who == w),
|
||||
self.decorations.retain(|d| match &d.key {
|
||||
Key::Player(w) => holders.iter().any(|h| h.who == *w),
|
||||
_ => true,
|
||||
});
|
||||
if self.decorations.len() != before {
|
||||
@@ -365,7 +366,11 @@ impl MapData {
|
||||
name,
|
||||
key,
|
||||
};
|
||||
match self.decorations.iter().position(|d| d.key == key) {
|
||||
match self
|
||||
.decorations
|
||||
.iter()
|
||||
.position(|d| d.key == decoration.key)
|
||||
{
|
||||
Some(i) if self.decorations[i] == decoration => {}
|
||||
Some(i) => {
|
||||
self.decorations[i] = decoration;
|
||||
@@ -377,6 +382,24 @@ impl MapData {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `tickCarriedBy` adds only missing component keys. Another carried
|
||||
/// copy without those entries must not erase an explorer map's target.
|
||||
fn add_item_decorations(&mut self, targets: Vec<StaticDecoration>, game_time: i64) {
|
||||
for (name, sprite, x, z, rotation) in targets {
|
||||
let key = Key::Item(name);
|
||||
if !self.decorations.iter().any(|d| d.key == key) {
|
||||
self.add_decoration(
|
||||
sprite,
|
||||
Some(game_time),
|
||||
key,
|
||||
x,
|
||||
z,
|
||||
f64::from(rotation),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `MapItemSavedData.tickCarriedBy`'s check on every holder but `who`, the
|
||||
/// player ticking: one wearing map invisibility gear has no marker.
|
||||
fn hide_invisible(&mut self, who: u32) {
|
||||
@@ -785,9 +808,11 @@ fn decoration_type(sprite: &str) -> Option<&'static str> {
|
||||
.find(|(_, s)| *s == sprite)
|
||||
.map(|(name, _)| *name)
|
||||
}
|
||||
/// `minecraft:map_decorations` of a map item: (sprite, x, z, rotation), in
|
||||
/// the component's order.
|
||||
fn static_decorations(stack: &ItemStack) -> Vec<(&'static str, f64, f64, f32)> {
|
||||
/// A named `minecraft:map_decorations` entry: key, sprite, x, z, rotation.
|
||||
type StaticDecoration = (JavaString, &'static str, f64, f64, f32);
|
||||
|
||||
/// `minecraft:map_decorations` of a map item, in the component's order.
|
||||
fn static_decorations(stack: &ItemStack) -> Vec<StaticDecoration> {
|
||||
let Some(Tag::Compound(entries)) = stack.data().and_then(|d| {
|
||||
d.other
|
||||
.iter()
|
||||
@@ -803,13 +828,14 @@ fn static_decorations(stack: &ItemStack) -> Vec<(&'static str, f64, f64, f32)> {
|
||||
_ => None,
|
||||
};
|
||||
entries
|
||||
.values()
|
||||
.filter_map(|e| {
|
||||
.iter()
|
||||
.filter_map(|(key, e)| {
|
||||
let sprite = match e.get("type") {
|
||||
Some(Tag::String(s)) => decoration_sprite(&s.to_string_lossy())?,
|
||||
_ => return None,
|
||||
};
|
||||
Some((
|
||||
key.clone(),
|
||||
sprite,
|
||||
number(e.get("x"))?,
|
||||
number(e.get("z"))?,
|
||||
@@ -908,7 +934,7 @@ impl Game<'_> {
|
||||
.name
|
||||
.as_ref()
|
||||
.map(|t| horizon_client::entity_nbt::name_text(t).plain()),
|
||||
key: Key::Item,
|
||||
key: Key::Remote,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -1337,7 +1363,7 @@ impl Game<'_> {
|
||||
.flatten()
|
||||
.filter_map(|i| self.stack_in(i).and_then(map_id))
|
||||
.collect();
|
||||
let carried: Vec<(i32, Vec<(&'static str, f64, f64, f32)>)> = self
|
||||
let carried: Vec<(i32, Vec<StaticDecoration>)> = self
|
||||
.inventory
|
||||
.iter()
|
||||
.chain(std::iter::once(&self.offhand))
|
||||
@@ -1345,7 +1371,7 @@ impl Game<'_> {
|
||||
.filter_map(|s| Some((map_id(s)?, static_decorations(s))))
|
||||
.collect();
|
||||
let (yaw, dim) = (self.yaw, self.dimension_id());
|
||||
let (nether, game_time) = (self.dim == super::Dim::Nether, self.world.game_time);
|
||||
let game_time = self.world.game_time;
|
||||
let invisible = self.map_invisibility_worn(&self.armor);
|
||||
// The guest whose turn it is, else the host.
|
||||
let me = self.lan_acting.unwrap_or(HOST);
|
||||
@@ -1370,39 +1396,7 @@ impl Game<'_> {
|
||||
m.remove_decoration(Key::Player(me));
|
||||
}
|
||||
m.hide_invisible(me);
|
||||
// The item's MAP_DECORATIONS (an explorer map's target), added
|
||||
// after the players as addDecoration would.
|
||||
let s = (1 << m.scale) as f32;
|
||||
let mut items: Vec<Decoration> = Vec::new();
|
||||
for (sprite, x, z, rotation) in targets {
|
||||
let dx = (x - f64::from(m.center[0])) as f32 / s;
|
||||
let dz = (z - f64::from(m.center[1])) as f32 / s;
|
||||
let inside = (-63.0..=63.0).contains(&dx) && (-63.0..=63.0).contains(&dz);
|
||||
if !inside && !m.unlimited {
|
||||
continue;
|
||||
}
|
||||
let d = Decoration {
|
||||
sprite,
|
||||
x: clamp_map_coordinate(dx),
|
||||
y: clamp_map_coordinate(dz),
|
||||
rot: decoration_rotation(nether, game_time, f64::from(rotation)),
|
||||
name: None,
|
||||
key: Key::Item,
|
||||
};
|
||||
if !items.contains(&d) {
|
||||
items.push(d);
|
||||
}
|
||||
}
|
||||
if !m
|
||||
.decorations
|
||||
.iter()
|
||||
.filter(|d| d.key == Key::Item)
|
||||
.eq(items.iter())
|
||||
{
|
||||
m.decorations.retain(|d| d.key != Key::Item);
|
||||
m.decorations.extend(items);
|
||||
m.decorations_changed();
|
||||
}
|
||||
m.add_item_decorations(targets, game_time);
|
||||
}
|
||||
for id in held {
|
||||
self.update_map(id);
|
||||
@@ -1965,7 +1959,7 @@ mod tests {
|
||||
y: -30,
|
||||
rot: 4,
|
||||
name: None,
|
||||
key: Key::Item,
|
||||
key: Key::Remote,
|
||||
};
|
||||
assert_eq!(
|
||||
decoration_pose(&vanilla, -0.02),
|
||||
@@ -2018,13 +2012,47 @@ mod tests {
|
||||
));
|
||||
assert_eq!(map_id(&stack), Some(3));
|
||||
let found = static_decorations(&stack);
|
||||
assert_eq!(found, vec![("map/decorations/red_x", 120.0, -300.0, 180.0)]);
|
||||
assert_eq!(
|
||||
found,
|
||||
vec![("+".into(), "map/decorations/red_x", 120.0, -300.0, 180.0)]
|
||||
);
|
||||
// calculateRotation(180): (byte) (188 * 16 / 360) = 8.
|
||||
assert_eq!(decoration_rotation(false, 0, 180.0), 8);
|
||||
assert_eq!(decoration_rotation(false, 0, -90.0), 12);
|
||||
assert_eq!(clamp_map_coordinate(-70.0), -128);
|
||||
assert_eq!(clamp_map_coordinate(10.25), 21);
|
||||
}
|
||||
#[test]
|
||||
fn an_undecorated_copy_does_not_erase_the_treasure_target() {
|
||||
let mut map = MapData::fresh(0.0, 0.0, 0, "minecraft:overworld");
|
||||
let mut treasure = ItemStack::new("minecraft:filled_map", 1);
|
||||
treasure.data_mut().map_id = Some(7);
|
||||
treasure.data_mut().other.push((
|
||||
"minecraft:map_decorations".into(),
|
||||
target_decoration("minecraft:red_x", [20, 40, 30]),
|
||||
));
|
||||
let mut copy = ItemStack::new("minecraft:filled_map", 1);
|
||||
copy.data_mut().map_id = Some(7);
|
||||
map.add_item_decorations(static_decorations(&treasure), 0);
|
||||
map.add_item_decorations(static_decorations(©), 0);
|
||||
assert_eq!(map.decorations.len(), 1);
|
||||
assert_eq!(map.decorations[0].sprite, "map/decorations/red_x");
|
||||
assert_eq!((map.decorations[0].x, map.decorations[0].y), (40, 60));
|
||||
}
|
||||
#[test]
|
||||
fn named_map_targets_are_added_once_without_replacing_other_targets() {
|
||||
let mut map = MapData::fresh(0.0, 0.0, 0, "minecraft:overworld");
|
||||
let target = |key: JavaString, x| (key, "map/decorations/red_x", x, 30.0, 180.0);
|
||||
let first_key = JavaString(vec![0xd800]);
|
||||
let second_key = JavaString(vec![0xd801]);
|
||||
map.add_item_decorations(vec![target(first_key.clone(), 20.0)], 0);
|
||||
map.add_item_decorations(vec![target(first_key, 40.0), target(second_key, 25.0)], 1);
|
||||
assert_eq!(map.decorations.len(), 2);
|
||||
assert_eq!((map.decorations[0].x, map.decorations[1].x), (40, 50));
|
||||
// Both entries are off the map: neither may remove an existing target.
|
||||
map.add_item_decorations(vec![target("absent".into(), 200.0)], 2);
|
||||
assert_eq!(map.decorations.len(), 2);
|
||||
}
|
||||
|
||||
/// `ItemStack.useOn` / `MapItem.useOn`: in adventure mode, a map whose
|
||||
/// `can_place_on` component includes the banner block toggles that banner's
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//! A player's melee attack (`Player.attack`): the attack strength, critical and sweep attacks, and
|
||||
//! what a hit on a target that is not a mob does.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Player.getAttackStrengthScale(0.5)`.
|
||||
pub(super) fn attack_strength_scale(&self) -> f32 {
|
||||
// 1.8.9 had no attack cooldown.
|
||||
if self.legacy_rules() {
|
||||
return 1.0;
|
||||
}
|
||||
let delay = horizon_client::attack_strength::delay(self.main_hand_attack_speed());
|
||||
((self.attack_ticks as f32 + 0.5) / delay).clamp(0.0, 1.0)
|
||||
}
|
||||
/// `Player.isSweepAttack`: a charged, grounded sword hit below the movement limit.
|
||||
pub(super) fn sweep_attack_position(&mut self, scale: f32, crit: bool) -> Option<[f64; 3]> {
|
||||
// The sweep came with 1.9.
|
||||
if self.legacy_rules() {
|
||||
return None;
|
||||
}
|
||||
// Player.getSpeed: the movement speed attribute as it is now (the
|
||||
// field is only refreshed by the local movement step, never for a
|
||||
// server's player).
|
||||
let p = self.player.as_mut()?;
|
||||
if let Ok(speed) = p.attribute("minecraft:movement_speed") {
|
||||
p.speed = speed as f32;
|
||||
}
|
||||
let p = self.player.as_ref()?;
|
||||
let sword = self.held().is_some_and(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:swords")
|
||||
.is_some_and(|tag| tag.contains(&*s.item))
|
||||
});
|
||||
if !p.is_sweep_attack(scale, crit, sword) {
|
||||
return None;
|
||||
}
|
||||
// Mth.sin / Mth.cos of yRot * (PI / 180), from its sine table.
|
||||
use horizon_core::geometry::trig;
|
||||
let yaw = f64::from(self.yaw * trig::DEG_TO_RAD);
|
||||
Some([
|
||||
p.position.x - f64::from(trig::sin(yaw)),
|
||||
p.position.y + f64::from(p.dimensions.height) * 0.5,
|
||||
p.position.z + f64::from(trig::cos(yaw)),
|
||||
])
|
||||
}
|
||||
/// `Player.doSweepAttack` after a successful hit, in vanilla's order: the
|
||||
/// sweep sound, the victims around `target` (its box, and the mob that
|
||||
/// was hit, when there is one) and then the arc.
|
||||
pub(super) fn sweep_attack(
|
||||
&mut self,
|
||||
at: [f64; 3],
|
||||
from: Option<[f64; 3]>,
|
||||
target: Option<(Aabb, mace::Spared)>,
|
||||
scale: f32,
|
||||
) {
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.sweep", from, 1.0, 1.0);
|
||||
if let Some((area, mob)) = target {
|
||||
self.sweep_damage(area, mob, scale);
|
||||
}
|
||||
let arc = horizon_client::particles::ParticleOptions::simple(
|
||||
horizon_client::particles::ParticleType::SweepAttack,
|
||||
);
|
||||
self.particles.add(&arc, at, [0.0; 3]);
|
||||
// Player.sweepAttack's `sendParticles(SWEEP_ATTACK, ..., 0, dx, 0, dz, 0)`:
|
||||
// the offset is the one from the player to the particle.
|
||||
let origin = from.unwrap_or(at);
|
||||
let spread = [at[0] - origin[0], 0.0, at[2] - origin[2]];
|
||||
self.lan_send_particles(lan_particles::QueuedParticle {
|
||||
dimension: self.dim.id(),
|
||||
options: arc,
|
||||
state: None,
|
||||
at,
|
||||
spread,
|
||||
speed: [0.0; 3],
|
||||
count: 0,
|
||||
randomization: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// `Player.attack` after a target that is not a mob took the hit:
|
||||
/// the sprint knockback sound, a living target's critical hit (`living`:
|
||||
/// its entity id; `crit`: whether `melee_damage` decided the hit critical,
|
||||
/// mods' `critical_hit` included), the sweep around the target's box `area`
|
||||
/// and the strong or weak sound.
|
||||
pub(super) fn attack_effects(
|
||||
&mut self,
|
||||
area: Aabb,
|
||||
living: Option<i32>,
|
||||
crit: bool,
|
||||
spared: mace::Spared,
|
||||
magic: Option<i32>,
|
||||
) {
|
||||
let scale = self.attack_strength_scale();
|
||||
let full = scale > 0.9;
|
||||
let player = self.player.as_ref();
|
||||
let from = player.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
if full && player.is_some_and(|p| p.sprinting) {
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.knockback", from, 1.0, 1.0);
|
||||
}
|
||||
let crit = living.is_some() && crit;
|
||||
let sweep = self.sweep_attack_position(scale, crit);
|
||||
if let Some(at) = sweep {
|
||||
self.sweep_attack(at, from, Some((area, spared)), scale);
|
||||
}
|
||||
if crit {
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.crit", from, 1.0, 1.0);
|
||||
if let Some(id) = living {
|
||||
self.area_critical_hit(id, area, false);
|
||||
}
|
||||
} else if sweep.is_none() {
|
||||
let sound = if full {
|
||||
"minecraft:entity.player.attack.strong"
|
||||
} else {
|
||||
"minecraft:entity.player.attack.weak"
|
||||
};
|
||||
self.audio.play(sound, from, 1.0, 1.0);
|
||||
}
|
||||
// Player.magicCrit: the enchantments added damage.
|
||||
if let Some(id) = magic {
|
||||
self.area_critical_hit(id, area, true);
|
||||
}
|
||||
}
|
||||
/// `critical_hit` around the box `area` of entity `id` (`magic`: the
|
||||
/// enchanted hit instead of the crit).
|
||||
pub(super) fn area_critical_hit(&mut self, id: i32, area: Aabb, magic: bool) {
|
||||
let (feet, size) = (area.bottom_center(), area.size());
|
||||
self.critical_hit(
|
||||
self.lan_acting,
|
||||
id,
|
||||
[feet.x, feet.y, feet.z],
|
||||
size.x,
|
||||
size.y,
|
||||
magic,
|
||||
);
|
||||
}
|
||||
/// `Player.canCriticalAttack` for a living target at attack strength
|
||||
/// `scale`: falling, off the ground, not in water, not on a ladder, not blind (mobility
|
||||
/// restricted), not riding, not sprinting.
|
||||
pub(super) fn critical_attack(&self, scale: f32) -> bool {
|
||||
let player = self.player.as_ref();
|
||||
let falling = player.is_some_and(|p| {
|
||||
!p.ground.on_ground
|
||||
&& p.fall_distance > 0.0
|
||||
&& !p.fluids.was_touching_water
|
||||
&& !p.on_climbable
|
||||
&& !p.sprinting
|
||||
});
|
||||
let restricted = self.potions.effects.get("blindness").is_some() || self.riding.is_some();
|
||||
scale > 0.9 && falling && !restricted
|
||||
}
|
||||
/// Player.attack with the attack-strength charge, crits and knockback.
|
||||
pub(super) fn attack(&mut self) -> bool {
|
||||
if self.spectator() {
|
||||
return false;
|
||||
}
|
||||
// A painting nearer than the targeted block and mob breaks instead.
|
||||
if let Some((i, d)) = self.painting_hit()
|
||||
&& self.mob_hit().is_none_or(|m| d < m.1)
|
||||
&& self.before_block(d)
|
||||
{
|
||||
self.break_painting(i, true);
|
||||
return true;
|
||||
}
|
||||
// A ghast's fireball or a wind charge in front: turned back.
|
||||
if self.attack_redirectable() {
|
||||
return true;
|
||||
}
|
||||
if self.attack_frame()
|
||||
|| self.attack_armor_stand()
|
||||
|| self.attack_cushion()
|
||||
|| self.attack_boat()
|
||||
|| self.attack_cart()
|
||||
|| self.attack_dragon()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let Some((index, distance, _)) = self.mob_hit() else {
|
||||
return false;
|
||||
};
|
||||
if !self.before_block(distance) || !self.held_range_allows(distance) {
|
||||
return false;
|
||||
}
|
||||
self.attack_mob(index);
|
||||
// Minecraft.startAttack swings after the attack.
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -2394,6 +2394,11 @@ impl Game<'_> {
|
||||
self.carts.insert(i, c);
|
||||
} else {
|
||||
let at = [c.position.x, c.position.y, c.position.z];
|
||||
// VehicleEntity.destroy: `kill` (the cart's own ENTITY_DIE),
|
||||
// then the drops; a creative hit only discards it.
|
||||
if !creative {
|
||||
self.entity_killed_event(at, false);
|
||||
}
|
||||
if self.ui.rules.bool("entity_drops") {
|
||||
for s in c.items.iter().flatten() {
|
||||
self.spawn_item(s.clone(), at, [0.0, 0.1, 0.0], 10);
|
||||
|
||||
@@ -123,9 +123,11 @@ impl Game<'_> {
|
||||
set_drop_chance(m, slot.index(), 2.0);
|
||||
m.persistent = true;
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
let id = m.id;
|
||||
if drop_old && let Some(old) = old {
|
||||
self.spawn_item(old, at, super::dispenser::new_item_motion(), 10);
|
||||
}
|
||||
self.mob_equip_event(id, at, true);
|
||||
Some(taken)
|
||||
}
|
||||
|
||||
|
||||
@@ -289,6 +289,15 @@ impl Game<'_> {
|
||||
})
|
||||
};
|
||||
let m = &mut self.mobs[index];
|
||||
let id = m.id;
|
||||
let pos = m.position;
|
||||
let saddle_off = m.extra.saddled && saddle.is_none();
|
||||
let body_off = body
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.is_none() && m.extra.body.is_some());
|
||||
let body_on = body
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.is_some() && m.extra.body.is_none());
|
||||
m.set_saddle(saddle);
|
||||
if let Some(body) = body {
|
||||
m.extra.body = body.map(|a| a.item);
|
||||
@@ -300,7 +309,12 @@ impl Game<'_> {
|
||||
let (x, z) = (at[0].floor() as i32 >> 4, at[2].floor() as i32 >> 4);
|
||||
self.world.modified.insert((x, z));
|
||||
if let Some(sound) = sound {
|
||||
self.audio.play(&sound, Some(at), 0.5, 1.0);
|
||||
self.audio.play(&sound, Some(at), 1.0, 1.0);
|
||||
}
|
||||
if saddle_put_on || body_on {
|
||||
self.mob_equip_event(id, [pos.x, pos.y, pos.z], true);
|
||||
} else if saddle_off || body_off {
|
||||
self.mob_equip_event(id, [pos.x, pos.y, pos.z], false);
|
||||
}
|
||||
}
|
||||
pub(super) fn mount_slots(&self) -> Option<Vec<usize>> {
|
||||
|
||||
@@ -170,6 +170,7 @@ impl Game<'_> {
|
||||
self.mobs[index].set_saddle(None);
|
||||
let context = self.who_context(Some(Who::Player), None);
|
||||
self.post_game_event("minecraft:shear", [at.x, at.y, at.z], context);
|
||||
self.mob_equip_event(id, [at.x, at.y, at.z], false);
|
||||
self.spawn_at_location(at, offset, saddle.clone());
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
self.trigger_item_entity("minecraft:player_sheared_equipment", &saddle, &target);
|
||||
@@ -190,8 +191,11 @@ impl Game<'_> {
|
||||
} else {
|
||||
"minecraft:entity.pig.saddle"
|
||||
};
|
||||
self.audio.play(sound, Some(at), 0.5, 1.0);
|
||||
self.audio.play(sound, Some(at), 1.0, 1.0);
|
||||
self.consume_held();
|
||||
let id = self.mobs[index].id;
|
||||
let pos = self.mobs[index].position;
|
||||
self.mob_equip_event(id, [pos.x, pos.y, pos.z], true);
|
||||
self.world
|
||||
.modified
|
||||
.insert((at[0].floor() as i32 >> 4, at[2].floor() as i32 >> 4));
|
||||
@@ -361,8 +365,15 @@ impl Game<'_> {
|
||||
if let Some(sound) = sound {
|
||||
let pitch =
|
||||
(self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0;
|
||||
self.audio.play(sound, Some(at), 0.5, pitch);
|
||||
let chest = item == "minecraft:chest";
|
||||
self.audio
|
||||
.play(sound, Some(at), if chest { 0.5 } else { 1.0 }, pitch);
|
||||
self.consume_held();
|
||||
if !chest {
|
||||
let id = self.mobs[index].id;
|
||||
let pos = self.mobs[index].position;
|
||||
self.mob_equip_event(id, [pos.x, pos.y, pos.z], true);
|
||||
}
|
||||
self.world
|
||||
.modified
|
||||
.insert((at[0].floor() as i32 >> 4, at[2].floor() as i32 >> 4));
|
||||
|
||||
Loaded 100 of 431 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user