Compare commits

..
Author SHA1 Message Date
Horizon agent 19aabc08c2 Merge remote-tracking branch 'origin/layers/seat' into split/cosmetics-hooks
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
#	crates/horizon-platform/src/lib.rs
2026-11-09 20:18:27 +01:00
Horizon agent ca3649534d Merge remote-tracking branch 'origin/main' into layers/seat
# Conflicts:
#	crates/horizon-platform/src/lib.rs
2026-11-09 20:15:33 +01:00
Ludovic Marie a3bf6531e0 Merge pull request #14019 from MSedgeMC/fix/enchant-update-presence
🤖 Agent: EnchantmentHelper.updateEnchantments returns EMPTY when the component is absent, so enchant is now a no-op on a stack whose enchantments (or a book's stored ones) were removed.
2026-11-09 20:14:49 +01:00
Ludovic Marie 0ddd7ec41d Merge pull request #14012 from TheMrZZ/fix/play-ping-response
🤖 Agent: a play PingRequest is answered with PongResponse on its own connection (handlePingRequest) and kept through the held/handover filter; it doesn't touch last_action_tick. 5/5 ping tests pass with resources.
2026-11-09 20:14:44 +01:00
Ludovic Marie cea1a54e31 Merge pull request #14184 from horizon-rust/agent/telemetry-sqlite-3.54
🤖 Agent: rusqlite 0.41 with fallible_uint keeps the u64/usize conversions the server stores; fmt, clippy -D warnings and its 59 tests pass inside the telemetry workspace (pr_ready skips it).
2026-11-09 20:14:40 +01:00
Ludovic Marie 23dd21e928 Merge pull request #13999 from Mik00000/perf/spatial-entity-collision
🤖 Agent: SeenGrid cells stay sorted (binary insert on update), so candidates merges 1-4 cells without sort_unstable and still returns snapshot order (the 2000-round randomized test holds); main merged (world.rs), bench seed's hex grouping fixed for clippy.
2026-11-09 20:09:00 +01:00
Ludovic Marie 6cf95bb4d7 Merge pull request #14198 from horizon-rust/docs/mod-prs-to-ari-fork
🤖 Agent: docs only, one CONTRIBUTING paragraph pointing mod-related pull requests to arismoko/horizon (the project decision of 11-09); mods.md and native-mods.md never said where to send them, so they stay.
2026-11-09 20:08:56 +01:00
Ludovic Marie cf09a6610a Merge pull request #14159 from horizon-rust/layers/extras-sections
🤖 Agent: adds horizon_hooks::pages::Place::Extras, a page with no entry of its own whose rows join the Extras page group named by their title (or get a group of their own after the page's groups); stacked on #14153 (layers/pages-pause).
2026-11-09 20:08:51 +01:00
Ludovic Marie 094bb83dff Merge pull request #14153 from horizon-rust/layers/pages-pause
🤖 Agent: with no page registered for the pause menu, pause_rows is the split row alone (or empty), so the grid, icon row and focus ids are exactly as before; registered pages take ids 40+index and open the options screen at Page::Added like the split row does.
2026-11-09 20:05:51 +01:00
Ludovic Marie dfc91826bd Merge pull request #14164 from horizon-rust/split/telemetry-display
🤖 Agent: horizon-ctl status's display object is built by window::display now, the same width/height/refresh/scale fields as before, so tools reading it see no change.
2026-11-09 20:03:00 +01:00
Ludovic Marie 2782c5e810 Merge pull request #14110 from horizon-rust/layers/pages-slider
🤖 Agent: a registered slider is a real Opt (Opt::Page(page,row)), so drag, arrow keys, narration, focus and search come from the existing slider code; its arms read the page's rows only for that variant, and options_ui.rs still shrinks (horizon_page_tooltip moves out).
2026-11-09 20:00:45 +01:00
Ludovic Marie 86f1749664 Merge pull request #14081 from horizon-rust/layers/hud-layers
🤖 Agent: per-frame cost with no layer registered is one sealed-slice load and is_empty in draw_extras (no canvas, no corner pass); a layer's place() uses native_corner_shown, so F3 hides the same corners for it as for mods' boxes.
2026-11-09 20:00:41 +01:00
Ludovic Marie d149cc1dca Merge pull request #14139 from Joansitoh/fix/14125-nautilus-feed
🤖 Agent: matches TamableAnimal.feed(…, 2.0F, 1.0F): 2 x nutrition, 1 without a food component; reads the item's food, not a patched stack's.
2026-11-09 20:00:32 +01:00
Ludovic Marie ae963b0a98 Merge pull request #14057 from MSedgeMC/fix/book-sign-title
🤖 Agent: pushed one fix: the control socket's sign on a read-only book again answers 'not a book and quill' (the catch-all arm had turned it into a silent success).
2026-11-09 19:55:43 +01:00
Ludovic Marie c30911cddf Merge pull request #14023 from MSedgeMC/fix/attribute-modifiers-component
🤖 Agent: pushed one fix: the stack's modifier amount is read as a double (it went through float_value, so 0.1 became 0.10000000149), and the test now pins 0.1.
2026-11-09 19:55:37 +01:00
Ludovic Marie 38eac394be Merge pull request #14016 from horizon-rust/split/revert-render-pipeline-config
🤖 Agent: plain git revert -m 1 of #13968, which put the shaders/ pack-format config into an engine crate (horizon-render-pipeline); the layer rule puts that format in platform, and no layers.json allowed entry changes.
2026-11-09 19:55:32 +01:00
Ludovic Marie 5546650bd7 Merge pull request #14070 from horizon-rust/layers/pages-registry
🤖 Agent: with no page registered the options screen is unchanged (Horizon Settings gets no extra row, nothing is asked per frame); a registered page's rows are rebuilt only while it is open, and every act sends Event::Changed so the crate's settings store is saved.
2026-11-09 19:55:29 +01:00
Ludovic Marie c277636360 Merge pull request #14003 from iseaprojects/fix/12633-recipe-book-dye-imbue
🤖 Agent: crafting_dye and crafting_imbue entries follow DyeRecipe.display and ImbueRecipe.display exactly (OnlyWithComponent dye, DyedSlotDemo; WithAnyPotion source and result), checked against the 26.3 bytecode.
2026-11-09 19:49:57 +01:00
Ludovic Marie 68a01ee7ca Merge pull request #14033 from horizon-rust/layers/moderation-durations
🤖 Agent: pure move of 82 lines that puts the moderation durations into horizon-server-files, the first step of the chat filter's engine hook (#13942 is stacked on it).
2026-11-09 19:49:53 +01:00
Ludovic Marie 15e83578ee Merge pull request #14119 from bvironn/relay/nameplate-unknown-budget
🤖 Agent: only unknown codes spend the nameplate bucket, so once a code exists an attacker's joins stop draining it and a friend waits at most one refill (12 s at 5/min).
2026-11-09 19:49:48 +01:00
Ludovic Marie 66e8c0d04d Merge pull request #14151 from horizon-rust/agent/mob-geometry-move
🤖 Agent: pure move; mob_geometry and mob_geometry_inner go from creatures.rs to creatures/mob_geometry.rs, only super:: paths one level deeper and two rustfmt reflows.
2026-11-09 19:49:44 +01:00
Ludovic Marie 6bab737cae Merge pull request #14075 from Ragbie/refactor/spawn-egg-stack-config-module
🤖 Agent: verified pure move: every removed line reappears in creatures/spawn_eggs.rs once visibility and super::super:: are normalised; creatures.rs 8896 -> 8732 counted lines.
2026-11-09 19:43:40 +01:00
Ludovic Marie 7093c79eeb Merge pull request #14166 from horizon-rust/split/friends-join-official
🤖 Agent: joining a friend now works only in horizon-official (the shipped game); the friends harness was switched to that binary in the same PR, so CI's friends_integration run is the proof that release builds still join.
2026-11-09 19:43:36 +01:00
Ludovic Marie a4da356144 Merge pull request #14115 from horizon-rust/agent/particles-spawn-move
🤖 Agent: pure move; ParticleEngine::send_particles and create go unchanged from particles.rs to particles/spawn.rs, so particles.rs shrinks by 67 lines.
2026-11-09 19:39:40 +01:00
Ludovic Marie 6c2c706959 Merge pull request #13997 from horizon-rust/split/friends-screen-glue
🤖 Agent: pure move of the Friends screen out of the engine library; the build without Friends keeps naming the pause icon "Play with Friends" from friends_off.rs, so the widget tree and controller focus are unchanged.
2026-11-09 19:33:42 +01:00
Ludovic Marie 6769422c88 Merge pull request #14101 from horizon-rust/docs/fork-microsoft-client-id
🤖 Agent: docs only: docs/forking.md now says forks register their own Azure app and may not ship or fetch Horizon's client ID; nothing found to change.
2026-11-09 19:30:29 +01:00
Ludovic Marie ebe7b4a4fd Merge pull request #14112 from voduff/baby-speed-attribute
🤖 Agent: speed_bonus now reads finalize::transient, the same minecraft:baby modifier /attribute reports, so modifier remove slows the baby and a baby piglin gets 1 + 0.2f, not 1.5.
2026-11-09 19:30:24 +01:00
Ludovic Marie 1cce1cbf31 Merge pull request #14006 from Ragbie/fix/mob-fall-clip
🤖 Agent: Mob::do_move now casts Entity.move's FALLDAMAGE_RESETTING/WATER ray only when fall_distance != 0 and the move is a block or more, so it costs nothing on an ordinary tick; boats share the same helper and now query the water on a tagged block too.
2026-11-09 19:30:20 +01:00
Ludovic Marie ed5da43953 Merge pull request #13993 from MrCloudy2/fix/9325-piston-arrow-release
🤖 Agent: release_after_block_move runs AbstractArrow.move's shouldFall check after every non-SELF move, including a push limitPistonMovement or the collisions cut to nothing; #10243's part is already on main, so the net change is piston_push.rs plus the new harness.
2026-11-09 19:30:16 +01:00
Ludovic Marie 3295c54097 Merge pull request #13986 from MSedgeMC/fix/loot-seed-numeric
🤖 Agent: TagValueInput.getLongOr takes any NumericTag (longValue), so an Int LootTableSeed now seeds the roll as in vanilla instead of falling back to the position seed.
2026-11-09 19:30:11 +01:00
Ludovic Marie d0da7ec157 Merge pull request #14113 from MrCloudy2/fix/piston-push-entity-colliders
🤖 Agent: the extra cost is one O(mobs + boats) scan per body a moving piston or shulker lid pushes, the same set item_entity_collisions gives items; the new harness pins the snowball stopping at the boat's side.
2026-11-09 19:27:05 +01:00
Ludovic Marie 042d2ce46a Merge pull request #13950 from horizon-rust/split/friends-tunnel-hook
🤖 Agent: the engine keeps only the friend: address form; a build without a resolver refuses such an address with the same invalid_address key the old cfg(not(feature = "friends")) path used, so reconnect still never retries it.
2026-11-09 19:27:02 +01:00
Ludovic Marie 8f756e8487 Merge pull request #14090 from horizon-rust/fix/git-slim-test-deterministic
🤖 Agent: git 2.5x runs a detached geometric auto-maintenance after each commit that gc.auto=0 does not stop; the test now runs git with no user/system config and maintenance.auto=false, so the 12 packs it counts stay 12 (desktop: 10/10 failures before, 10/10 OK after).
2026-11-09 19:26:58 +01:00
Horizon agentandClaude Opus 5.5 b7b137a829 docs/layers.md: the exception table names Hooks::tunnel
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:18:39 +01:00
Horizon agent 725208d8de Merge branch 'split/friends-screen-glue' into split/friends-join-official
# Conflicts:
#	crates/horizon-hooks/Cargo.toml
#	crates/horizon-hooks/src/lib.rs
2026-11-09 19:18:32 +01:00
Ludovic Marie 3516aa9641 Merge branch 'split/friends-tunnel-hook' into split/friends-screen-glue 2026-11-09 19:18:13 +01:00
Horizon agent cd362fb354 Merge remote-tracking branch 'origin/main' into split/friends-tunnel-hook
# Conflicts:
#	crates/horizon-client-lib/Cargo.toml
2026-11-09 19:15:23 +01:00
Ludovic Marie 216eca2f51 Merge pull request #14030 from horizon-rust/layers/registries-spec
🤖 Agent: docs only (docs/layers.md): the Platform registries spec as built in #14036/#14070/#14081/#14096/#14110 (HUD place() returns None under F3, seat() for splitscreen), and the launcher/horizon-server exception rows removed after #13905.
2026-11-09 19:15:10 +01:00
Ludovic Marie 7dccd1fa39 Merge pull request #13629 from MSedgeMC/fix/bee-anger-target
🤖 Agent: the earlier verify failure was main's options_ui.rs growth measured against this branch's old base; with main merged in it passes, and mob_ai passed with this change on union19b, so a stung player stays the bee's angry_at through the chunk save as Bee.addAdditionalSaveData writes it.
2026-11-09 19:13:23 +01:00
Ludovic Marie e84e5b2dd3 Merge pull request #8230 from voidsipp/fix/8001-preparer-lock
🤖 Agent: the prepare lock now unlocks explicitly on drop, so a child that inherited the descriptor between fork and exec no longer holds the flock past the preparation (#8001); the new test holds a dup'd descriptor to show it.
2026-11-09 19:13:19 +01:00
Ludovic Marie 7b9f3f8e83 Merge pull request #13910 from Skr1zzzz/split-visual-clocks
🤖 Agent: pure move; pub(super) from render_tick.rs is narrower than it was in game.rs, but the only mention outside game/ is a doc comment in clock.rs, so nothing loses access.
2026-11-09 19:13:15 +01:00
Ludovic Marie c9d73d5bea Merge pull request #13954 from horizon-rust/split/official-servers-module
🤖 Agent: the official and partner server entries now live in official_servers.rs behind a lib horizon-extras feature, and servers.dat's lock moved to horizon_protocol::servers::lock(); a plain build adds no server and carries no afri-network address.
2026-11-09 19:13:11 +01:00
Horizon agentandClaude Opus 5.5 56b703899c hub.rs: keep the chat module's doc comment on it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:09:30 +01:00
MSedgeMC c51a5eec3f Merge remote-tracking branch 'upstream/main' into fix/enchant-update-presence 2026-11-09 18:01:53 +00:00
Horizon agentandClaude Opus 5.5 a7f07a08de rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:00:49 +01:00
MSedgeMC f2718e086d Merge remote-tracking branch 'upstream/main' into fix/enchant-update-presence 2026-11-09 17:58:35 +00:00
Horizon agentandClaude Opus 5.5 0e528f7287 Cosmetics: name the per-seat state Seat (the tests' Service is a transport)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:58:25 +01:00
Horizon agentandClaude Opus 5.5 dc382b898a CONTRIBUTING: mod-related pull requests go to arismoko/horizon
The project decided that mod-API work (native hooks, native, content and
wasm mods, mod builds and the registry) is reviewed and merged on Ari's
fork. Say so where contributors look first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:55:43 +01:00
MSedgeMC 1d2dd56a06 Merge remote-tracking branch 'upstream/main' into fix/enchant-update-presence 2026-11-09 17:52:56 +00:00
Horizon agentandClaude Opus 5.5 22f3da10c0 Telemetry server: bundle SQLite 3.54 through rusqlite 0.41
Its own workspace still resolved rusqlite 0.37 -> libsqlite3-sys 0.35,
which bundles a SQLite older than 3.54 (RUSTSEC-2026-0459). rusqlite 0.41
resolves libsqlite3-sys 0.39.1, as the root workspace already does.
rusqlite 0.38 moved the u64/usize conversions behind fallible_uint: turned
on, so the stored values convert as before (an error past i64::MAX).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:51:42 +01:00
Horizon agent 7ca4fd68bd Merge remote-tracking branch 'origin/layers/seat' into split/cosmetics-hooks 2026-11-09 18:48:40 +01:00
Horizon agentandClaude Opus 5.5 63dae1216c SeenGrid: group the bench seed's hex digits (clippy), say when candidates scans all
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:48:39 +01:00
Horizon agent 547b1d4959 Merge branch 'layers/hud-layers' into layers/seat 2026-11-09 18:40:43 +01:00
Horizon agent db8048a1b6 Merge branch 'layers/pages-registry' into layers/hud-layers 2026-11-09 18:40:42 +01:00
Horizon agent 7383ff1908 Merge branch 'layers/pages-slider' into layers/pages-pause 2026-11-09 18:40:40 +01:00
Horizon agent 4f4c87df64 Merge branch 'layers/pages-registry' into layers/pages-slider 2026-11-09 18:40:39 +01:00
Horizon agent 6676c3a9a9 Merge remote-tracking branch 'origin/main' into layers/pages-registry 2026-11-09 18:40:38 +01:00
Horizon agentandClaude Opus 5.5 e2918de5aa Seat: a splitscreen guest's profile loads into its own seat
Split::enter loaded a guest's profile before its turn began, so the
crates' settings stores read the guest's lines into P1's seat. The load
now runs on the guest's seat; a new profile is saved on P1's seat (the
stores write P1's values, as the rest of the profile copies P1's) and
its stores are read back into the guest's (Settings::load_stores).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:40:35 +01:00
Horizon agentandClaude Opus 5.5 e6b0a03270 Cosmetics: emote feed and presence behind frame/tick hooks (spec #14091 step 1)
horizon-hooks gains Hooks::frame and Hooks::tick with a plain player-model
view (PlayerModels, BodyPose moves here), re-exported by horizon-platform.
The cosmetics' client, emote feed, owner check and presence live in a
per-seat static behind those hooks; only a window's game calls them, so
the server thread no longer runs tick_emotes. net::Session gets a serial
that names the connection.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:40:24 +01:00
Horizon agentandClaude Opus 5.5 53d1afc5b1 Merge origin/main into #13999
world.rs: main's raycast cache and despawn view kept beside this PR's NearIter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:35:35 +01:00
Horizon agentandClaude Opus 5.5 a94b5a1b2e Extras sections: group_named's lifetime, rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:34:21 +01:00
Horizon agentandClaude Opus 5.5 b4c7f409d2 attribute_modifiers: read the amount as a double
AttributeModifier.amount is a double; going through float_value turned
0.1 into 0.10000000149. The test now uses 0.1 to pin it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:31:54 +01:00
Horizon agentandClaude Opus 5.5 3365fd6bc4 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:30:38 +01:00
Ludovic Marie 8f198d9cbd Merge origin/main into split/cosmetics-hooks 2026-11-09 18:30:32 +01:00
Horizon agentandClaude Opus 5.5 c0da1ac2e1 Control socket sign: a read-only book still answers "not a book and quill"
begin_signing does nothing for BookScreen::View, so the catch-all arm
turned that error into a silent success.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:30:28 +01:00
Horizon agentandClaude Opus 5.5 45cefe3278 Pages: Place::Extras, a section of the Extras page (layer plan M5)
A crate above the engine adds rows to Horizon Settings' Extras page: they
join the page's group the section's title names, else a group of its own.
Auto-reconnect moves out on it next.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:29:17 +01:00
Horizon agentandClaude Opus 5.5 032ae31f34 creatures.rs: mob_geometry moves to creatures/mob_geometry.rs (pure move)
mob_geometry and mob_geometry_inner, the per-frame drawing of mobs, players and
projectiles, move unchanged into a child module of creatures.rs, so a change
to them (#12276's client_entity_render) no longer grows creatures.rs, which is
over the 3000-line limit. Only the visibility (pub(in crate::game)), the
super:: paths one level deeper, and rustfmt's reflow of two lines follow the
new module.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:24:34 +01:00
Horizon agentandClaude Opus 5.5 47d55b5690 pack_post.rs: import BUFFERS instead of a local const
The revert re-added `const BUFFERS` and a blank line, so the file grew from
5859 to 5860 lines over the 3000-line limit (verify run 42650015942). The
import does the same in no extra line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:24:21 +01:00
rdbtCVSandClaude Sonnet 5.5 db4a145e6b cargo fmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 17:22:31 +00:00
Horizon agentandClaude Opus 5.5 c3c0e2d868 Pages pause: test imports, rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:20:47 +01:00
JoansitohandClaude Sonnet 5.5 528d98bf36 Nautilus: feeding heals 2 x the food's nutrition
TamableAnimal.feed heals 2.0 * nutrition (1.0 without a food component),
not a flat 2 HP.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 18:20:34 +01:00
Horizon agentandClaude Opus 5.5 7973f9935b Pages: Place::Pause, an entry in the pause menu
A page registered with Place::Pause gets a full-width button in the
pause menu, above Options and under Splitscreen's settings, labelled
with its title; it opens the options screen at the page. The split row
and the pages' rows share one list (pause_rows), so the grid, the icons
and controller focus place them alike. With nothing registered the
pause menu is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:11:43 +01:00
Horizon agentandClaude Opus 5.5 d119d6da2d horizon-friends game: drop an unused import
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:10:50 +01:00
Horizon agentandClaude Opus 5.5 4bc93d9a5f particles.rs: send_particles and create move to particles/spawn.rs (pure move)
The two entry points that bring particles into the engine move unchanged into
a child module, so a change to them (#12277's client_particle hook) no longer
grows particles.rs, which is over the 3000-line limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:10:42 +01:00
Horizon agentandClaude Opus 5.5 671af567f4 docs/layers.md registries: status with the PRs, Page::tooltip
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:09:15 +01:00
iseaprojectsandopencode 1f19e94d74 Send dye and imbue recipes to the recipe book
`ServerRecipeBook.addRecipes` sends one entry per display of a recipe.
`book_entries` had no writer for `crafting_dye` or `crafting_imbue`, so it
fell through to "no display": a learnt leather-dyeing or tipped-arrow
recipe never reached the player's book, though removing it sent the right
Remove packet.

- `crafting_dye` writes `DyeRecipe.display`: the target and the dyes kept
  to the DYE component, the `minecraft:dyed` demo of the two, then a
  crafting table; the placement is the target and the dye.
- `crafting_imbue` writes `ImbueRecipe.display`: the 3x3 shaped display of
  four materials, the `with_any_potion` source, four materials again, and
  the result wrapped in `with_any_potion`; the placement puts the source
  in the middle cell.

Both are single-display recipes, so they also get their ghost display now.
Covered by unit tests of the decoded entries and by the all-real-recipes
round trip, and the variants parity harness now grants and takes back
leather helmet dyeing and tipped arrows beside map cloning.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-09 13:08:47 -04:00
voduffandClaude Opus 5.5 fcbeaefd37 Baby mobs move by their minecraft:baby modifier, not a fixed 1.5x (#11994)
ai::speed_bonus hard-coded 1.5 for baby zombies and piglins, so a baby
whose minecraft:baby modifier was removed with /attribute kept running
fast, and a baby piglin ran at 1.5x where Piglin.SPEED_MODIFIER_BABY is
+0.2. It now applies finalize::transient's MovementSpeed modifier
(ADD_MULTIPLIED_BASE), the one /attribute reads.

Part of #11994

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:08:16 +00:00
Horizon agentandClaude Opus 5.5 8bf0614d9d Pages: slider test checks the position shown; rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:06:31 +01:00
Horizon agentandClaude Opus 5.5 83b3c9306f Docs: forks may not use Horizon's Microsoft client ID
Horizon's Azure app registration and Mojang's approval of it cover Horizon's
own builds only. Forks and derived launchers register their own app, seek
their own approval, and must not ship or fetch Horizon's client ID.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:03:45 +01:00
Horizon agent e6184afbe2 Merge remote-tracking branch 'origin/main' into split/official-servers-module
# Conflicts:
#	docs/forking.md
2026-11-09 18:01:41 +01:00
Horizon agentandClaude Opus 5.5 96c5616505 Pages: sliders and per-row tooltips
A registered page's Slider row is one of the options screen's own
sliders (Opt::Page(page, row)): dragging, the arrow keys, narration,
focus and search work as for the engine's options, and the page hears
Act::Slide. Page::tooltip (default none) gives a row's hover text, which
may follow its value.

The registered pages are read from horizon_hooks::pages directly (a
test sets its own per thread), and horizon_page_tooltip moves to
options_ui_pages.rs: options_ui.rs shrinks again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:00:36 +01:00
Bairon M. 7766619ecb Relay: cap unknown codes per nameplate
An invite code is eight Crockford symbols: four nameplate symbols the
relay routes by (20 bits) and four secret symbols the host checks (20
bits). The relay sees only the nameplate, so it can count joins whose
nameplate does not exist, not wrong secrets for one that does; the host
already retires a code after five of those.

This adds a budget for unknown codes for one nameplate (5 a minute,
burst 10), spent whichever address asked and checked before the lookup:
without it, one address set can take the whole global unknown budget
for a single short code. The check records nothing for a nameplate it
has not seen; only spending an unknown code creates the entry, so a
join refused by another budget leaves no state behind.

Verified: cargo test -p horizon-relay (31 tests) and clippy
--all-targets with -D warnings.
2026-11-09 13:58:12 -03:00
Horizon agentandClaude Opus 5.5 6e21ce9476 Joining a friend leaves the engine: horizon-friends offers the tunnel through Hooks::tunnel
The tunnel extension point moves to horizon-hooks (re-exported by horizon_platform::hooks),
offered by a hook instead of a startup registration. horizon-friends' new game feature holds the
resolver, and horizon-official registers it with the other Horizon parts. The friends harness
runs the official executable. Layer plan M5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:55:14 +01:00
MrCloudy2andClaude Opus 5.5 365a44ff91 Harness: a body a piston pushes stops at a boat in its way
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:52:33 +01:00
Horizon agent 07068153c6 Merge branch 'split/friends-tunnel-hook' into split/friends-screen-glue
# Conflicts:
#	docs/forking.md
2026-11-09 17:52:02 +01:00
MSedgeMC f5c6b7dfd1 port_map: BookSignScreen.init fills the title box with the kept title 2026-11-09 16:51:15 +00:00
Horizon agent 8d5dd89421 Merge remote-tracking branch 'origin/main' into split/friends-tunnel-hook
# Conflicts:
#	Cargo.lock
#	crates/horizon-client-lib/Cargo.toml
#	crates/horizon-client/Cargo.toml
#	crates/horizon-client/src/entry.rs
#	crates/horizon-client/src/game.rs
#	tools/layers.json
2026-11-09 17:51:01 +01:00
MrCloudy2andClaude Opus 5.5 6280d2630c A body a piston or a shulker lid pushes stops at a boat or a shulker
Entity.collide's getEntityCollisions: a pushed entity collides with the
entities it can collide with (canCollideWith: a boat, a living shulker, a
happy ghast held still), not only with blocks and the world border.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:50:23 +01:00
Horizon agentandClaude Opus 5.5 0568b2beff tests: keep git's background maintenance out of test_git_slim
Since git 2.5x, `git commit` starts `git maintenance run --auto` detached
after every commit, and its default strategy repacks geometrically even
with gc.auto=0. The packs the tests build were folded while they were
counted: 12 built, 3-6 found on the desktop (git 2.54, every run), and
intermittently in CI verify (run 42647063573 on #13970).

The tests now run git with no user or system config, no inherited GIT_*
variables, and gc.auto=0, maintenance.auto=false, no detached gc. The
assertions are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:47:23 +01:00
Horizon agent bd90908a72 Merge branch 'layers/hud-layers' into layers/seat 2026-11-09 17:44:40 +01:00
Horizon agentandClaude Opus 5.5 21860eefba HUD: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:44:06 +01:00
Horizon agentandClaude Opus 5.5 0ccd2a4397 docs/layers.md: the launcher and horizon-server reach the engine through platform (#13905)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:43:12 +01:00
Horizon agent dd8214d3a3 Merge remote-tracking branch 'origin/main' into layers/registries-spec 2026-11-09 17:43:11 +01:00
Horizon agentandClaude Opus 5.5 a8d445f56c Hooks: seat(), the splitscreen player whose turn it is
Splitscreen players share one process and thread, so a crate's statics
are shared by them. The engine now says whose turn it is
(horizon_hooks::seat(), re-exported in horizon_platform::hooks; 0 for
P1), set where split.rs switches turns and while a joining player's game
starts, so settings stores, pages and HUD layers can keep per-player
state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:33:09 +01:00
Horizon agentandClaude Opus 5.5 74eaac0c21 docs/layers.md registries: status, seats, place gives None under the debug screen
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:31:50 +01:00
Horizon agentandClaude Opus 5.5 b9efd702dc HUD: a registry for what crates draw on the HUD
horizon_hooks::hud (re-exported as horizon_platform::hud): a crate above
the engine registers a Layer that draws on a Canvas (size, tr,
text_width, text, fill, place). The extras' HUD pass draws the layers
after the minimap, PvP widgets and mods' boxes, stacked in the same
corners; a corner the debug screen covers gives None. With no layer
registered the engine builds no canvas.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:30:53 +01:00
Horizon agentandClaude Opus 5.5 79cd63b8bc Pages: rustfmt, clippy
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:27:08 +01:00
Horizon agent a2fb9bc76e Merge branch 'layers/pages-extract' into layers/pages-registry 2026-11-09 17:27:01 +01:00
MSedgeMC 678d6da047 The book signing screen reopens with the title it kept
BookSignScreen's constructor sets its title box to titleValue, the text saved
when Cancel left the screen. Horizon reopened the sign screen with an empty
box while its title field still held the old title, so the next keystroke
started from nothing and Enter signed a title the box did not show.

BookScreen::begin_signing now fills the title box from the kept title, as the
constructor does, and both ways into the sign screen use it.
2026-11-09 16:21:35 +00:00
Evrim CicekandClaude Opus 5.5 24c9ad202b Move the spawn egg functions out of creatures.rs
A pure move, for the file-size rule: spawn_egg_offspring,
spawn_egg_fluid_hit, spawn_egg_allowed, use_spawn_egg and
apply_spawn_egg_entity_data go to game/creatures/spawn_eggs.rs. Only
visibility and the `super::` paths change. creatures.rs goes from 8896
counted lines to 8732.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:21:11 +03:00
Horizon agentandClaude Opus 5.5 6020c75004 Pages: a registry for the settings pages crates add
horizon_hooks::pages (re-exported as horizon_platform::pages): a crate
above the engine registers a Page (rows: heading, note, toggle, cycle,
button, key) with its entry on Horizon Settings. The options screen draws
its rows with its own widgets, asks for them only while the page is open,
and hands presses, Shift-presses and bound keys back to the page. With
nothing registered, Horizon Settings is unchanged.

The Horizon Settings rows move to options_ui_pages.rs with the entries,
so options_ui.rs shrinks (8391 -> 8363 lines).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:16:43 +01:00
Horizon agent 1215645c4e Merge remote-tracking branch 'origin/layers/settings-store' into layers/pages-registry 2026-11-09 17:13:28 +01:00
Horizon agent c587403015 Merge remote-tracking branch 'origin/main' into split/official-servers-module
# Conflicts:
#	crates/horizon-client-lib/Cargo.toml
#	crates/horizon-client/Cargo.toml
#	crates/horizon-client/src/game.rs
2026-11-09 17:03:01 +01:00
Horizon agentandClaude Opus 5.5 3ebcf19eed docs/layers.md: spec of the platform registries (settings, pages, HUD)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:03:00 +01:00
Horizon agentandClaude Opus 5.5 f2f02e57bd Moderation durations move from horizon-chat-filter to horizon-server-files
Layer plan M6, a pure move before the chat filter's engine hook: /tempban (engine) and /tempmute
share parse_duration, duration_parts, duration_text and now_millis, which now live in the engine's
horizon-server-files (duration.rs). horizon-chat-filter re-exports them. Behaviour unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:02:55 +01:00
MSedgeMC c10aea9eb8 Equipment attribute modifiers come from the stack
ItemAttributeModifiers is a component, so a patched stack replaces the item's
prototype modifiers. Horizon's mob attack and armour comparisons read
ItemInfo.modifiers, ignoring the stack's own.

Test: a sword with a patched attack_damage +5 reports that modifier.
2026-11-09 16:00:18 +00:00
MSedgeMC bf22beb825 An absent enchantments component makes enchant a no-op
ItemStack.enchant uses EnchantmentHelper.updateEnchantments, which returns
EMPTY when the component is absent. Horizon added the enchantment to the
modelled list anyway, so enchant_randomly and enchant_with_levels enchanted a
stack whose !minecraft:enchantments patch removed the component.

Test: a sword with !minecraft:enchantments stays unenchanted after enchant;
an enchanted book with !minecraft:stored_enchantments does too.
2026-11-09 15:59:43 +00:00
Horizon agentandClaude Opus 5.5 10fb981317 Revert #13968: pack-format types back out of the engine
horizon-render-pipeline is an engine crate, and the config it held is the
shaders/ format's buffer and shadow configuration (colortex buffers, Iris
half-lives, shaders.properties settings). docs/layers.md: the engine never
holds a shader or pack format vanilla doesn't read; pack formats and the
shader API are platform. The M6 shader-pack move restarts from a spec.

This reverts merge 75b85d258f.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:57:43 +01:00
TheMrZZandCodex gpt-6.1-sol 7f67dc8333 fix(lan): answer play ping requests on the origin connection
Keep PingRequest through held/handover filtering, handle it in Hub transport,
and exclude it from action-time updates. Preserve the existing reverse Pong path.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-09 16:53:54 +01:00
Mik00000 3c710450fb mobs: optimize spatial SeenGrid candidate retrieval and others_near allocations 2026-11-09 17:40:49 +02:00
MrCloudy2andClaude Opus 5.5 6a6aa75cb9 A stuck arrow falls after every non-self move, not only a push that moved it
AbstractArrow.move checks shouldFall after every Entity.move with a mover
other than the arrow, including a push limitPistonMovement or the collisions
cut to nothing. #10243's release ran only in apply_push; it is now
release_after_block_move, called after every push.
Harness: tools/test_piston_arrow_fall_parity.py; unit test
a_stuck_arrow_whose_push_a_wall_stops_falls_when_nothing_holds_it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:39:01 +01:00
Evrim CicekandClaude Opus 5.5 9b72080c32 Share the fall-reset ray between mobs and vehicles, water first
Vanilla's clip queries the block shape and the fluid shape of every
block it visits. The vehicle ray skipped the water on a tagged block, so
a waterlogged ladder or vine never fixed the shared water shape there as
vanilla's first query does; the mob, vehicle and item rays now share one
helper that queries the water first, as the player's does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:36:15 +03:00
Horizon agentandClaude Opus 5.5 c93db2be5f Merge origin/main into the branch
The verify job compared big files against this branch's old base, so main's own growth of options_ui.rs since then failed the size check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:35:02 +01:00
MSedgeMC df6c3384fb A container's loot seed is read as any number
RandomizableContainer.tryLoadLootTable reads LootTableSeed with getLongOr,
which takes any numeric tag. Horizon's loot_seed matched only a Long tag and
rolled an unseeded container from its position instead.
2026-11-09 15:31:30 +00:00
Horizon agentandClaude Opus 5.5 987dc976d3 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:24:30 +01:00
Evrim CicekandClaude Opus 5.5 6187796e59 A mob resets its fall on a move of a block or more through a vine or water
Entity.move casts a ray along any move of a block or more
(ClipContext.Block.FALLDAMAGE_RESETTING, a tagged block as a full block;
ClipContext.Fluid.WATER) and a hit resets the fall before the move's
drop counts. Mob::do_move never cast it, so a mob falling fast past a
vine kept its whole fall and landed hurt (or dead).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:18:16 +03:00
Horizon agentandClaude Opus 5.5 70db97e662 Merge origin/main into #8230
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:09:45 +01:00
Horizon agentandClaude Opus 5.5 cf28dc7745 The Play with Friends screen leaves the engine library for the friends glue
friends_screen.rs moves from horizon-client-lib to game/friends/screen.rs, beside the host and
join glue; the pause menu asks friends_shown() and names the icon from game/friends (or
friends_off). Pure move, layer plan M5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:04:42 +01:00
Horizon agent e8196d58c4 Merge remote-tracking branch 'origin/main' into split/friends-tunnel-hook
# Conflicts:
#	Cargo.lock
#	tools/layers.json
2026-11-09 16:02:07 +01:00
Horizon agentandClaude Opus 5.5 0ef952921d rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:00:09 +01:00
MSedgeMC 9698eed74f Re-run checks: the previous run failed in an unrelated fixture / harness 2026-11-09 14:52:18 +00:00
Horizon agentandClaude Opus 5.5 f9dfc692e5 window::display: the screen a window is on leaves telemetry (layer plan M5)
monitor() moves from the game's telemetry module to horizon-client-input's
window module (engine), with display() giving its size, refresh rate and
scale as plain data. horizon-ctl status reads it there; telemetry's
note_display keeps the same values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:48:52 +01:00
Horizon agent 233e09221d Merge remote-tracking branch 'origin/main' into split/friends-tunnel-hook 2026-11-09 15:44:07 +01:00
Horizon agentandClaude Opus 5.5 af9edec8b9 Cargo.lock: horizon-client-lib no longer depends on horizon-friends
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:44:07 +01:00
Skr1zzzzandClaude Opus 5.5 067ca7dfb3 Move the frame's visual clocks out of game.rs into game/render_tick.rs
Part of #11857. visual_ticks, texture_clock and visual_millis join game/render_tick.rs. Methods that were private in game.rs become
pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:23:26 +03:00
Horizon agentandClaude Opus 5.5 b3dd537570 Play with Friends joins through an engine tunnel hook; horizon-client-lib drops horizon-friends
The engine keeps the friend: address form and the join; a resolver registered at startup
(net::tunnel) reads invite codes and opens the tunnel. Play with Friends registers its own
(game/friends/tunnel.rs) under the friends feature. Layer plan M5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:16:26 +01:00
Horizon agentandClaude Opus 5.5 d2dbe9227c official_servers: the official and partner server entries leave multiplayer.rs, behind horizon-extras
Layer plan M5 (preparation): Horizon's entries in the multiplayer list get
their own module in horizon-client-lib, gated by a new lib feature
horizon-extras (horizon-client's horizon-extras turns it on). It touches
servers.dat only through horizon_protocol::servers, whose new lock() replaces
multiplayer.rs's private SAVING mutex, so the module can later move to a
Horizon crate as a pure file move. A plain build now neither adds the
official server nor carries the withdrawn partner's address.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:14:30 +01:00
MSedgeMC 5ed7535acd port_map: a bee's pair of save methods
`tools/port_map/animal-1.tsv` had no answer for `Bee.addAdditionalSaveData`
or `Bee.readAdditionalSaveData`. Both now name `mobs/bee.rs::to_nbt` and
`from_nbt`: the hive and flower positions, the nectar and sting state, and
`NeutralMob`'s `anger_end_time` and `angry_at`.
2026-11-09 05:18:34 +00:00
MSedgeMC 1bb2149e88 test: an angry bee's target through the chunk save
`mob_to_nbt`/`mob_from_nbt` are the pair `Game::mob_tree_nbt` and
`Game::load_mob` go through. A bee angry at the player (`Bee.angry_at =
Some(Who::Player)`, its timer 500) saves `angry_at` as the offline player's
UUID; the reloaded bee has the same player target and the same timer. A calm
bee writes no `angry_at`, even over a compound that still names one.

Without the write the save has no `angry_at` (left `None`, right `Some(IntArray([...player...]))`),
and the reloaded bee has no target (left `None`, right `Some(Player)`).
2026-11-09 05:18:34 +00:00
MSedgeMC d59333ab3a fix: an angry bee keeps the player it stung in its chunk save
`Bee` implements `NeutralMob` and its `addAdditionalSaveData` ends with
`addPersistentAngerSaveData`, which writes `anger_end_time` and `angry_at`
(the UUID of the entity the bee is angry at). `readAdditionalSaveData` reads
both back with `readPersistentAngerSaveData`.

Horizon modelled the bee's target in `Bee.angry_at` and saved only
`anger_end_time`, so a bee that had stung a player came back after a chunk
reload with its anger timer still running but no `angry_at`:
`BeeBecomeAngryTargetGoal` asks for `Some(Who::Player)` there, saw none, and
the bee stopped attacking, while an anger at another mob was forgotten the
same way.

The save now writes `angry_at` as the target's UUID (the player's own, or the
level's answer for a mob id, nothing when there is no target), and the load
reads it back as the player or as a mob the level may not have loaded yet.
2026-11-09 05:18:34 +00:00
voidsipandCodex 4b502a3c48 fix(prepare): release locks before closing descriptors
Explicitly unlock when a preparation finishes so a descriptor retained by
a forked child cannot extend the lock lifetime. Cover retained duplicates
while preserving the existing contention test.

Co-Authored-By: Codex <[email protected]>
2026-11-03 07:36:34 -06:00
142 changed files with 6172 additions and 3047 deletions

No files matched your search

+8
View File
@@ -13,6 +13,14 @@ else (videos, Discord and other community material) lives elsewhere.
suggest something. A pull request for a roadmap item carries its `roadmap:<id>` label; ideas go to the suggestion
queue (the hub, or `/suggest` on Discord) rather than to issues, which are for bugs.
**Mod-related pull requests go to [arismoko/horizon](https://github.com/arismoko/horizon)**, the fork that carries the
mod API and reviews and merges that work. That covers the mod API, native hooks and events for mods, native mods,
content mods (`.hmod`), mixins, wasm mods, `cargo horizon-mod`, `horizon-mods`, mod builds and the registry, and
their docs ([mods](docs/mods.md), [native mods](docs/native-mods.md)). Open the pull request there from your usual
branch. Mod-related pull requests opened here are closed with a pointer to it. Engine extension points that Horizon's
own crates register (the [layer split](docs/layers.md)), resource and shader pack formats, and gameplay fixes stay
here.
## Build it
- **Prerequisites:** Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers
Generated
+3 -7
View File
@@ -1418,6 +1418,7 @@ dependencies = [
name = "horizon-chat-filter"
version = "0.1.0"
dependencies = [
"horizon-server-files",
"serde",
"serde_json",
"toml",
@@ -1528,7 +1529,6 @@ dependencies = [
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-friends",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
@@ -1592,7 +1592,6 @@ dependencies = [
"horizon-optifine",
"horizon-polytone",
"horizon-protocol",
"horizon-render-pipeline",
"horizon-shader-api",
"horizon-shaders",
"horizon-system",
@@ -1668,6 +1667,7 @@ dependencies = [
"getrandom 0.3.4",
"hkdf",
"horizon-addresses",
"horizon-platform",
"if-addrs",
"serde",
"serde_json",
@@ -1793,6 +1793,7 @@ dependencies = [
name = "horizon-official"
version = "0.1.0"
dependencies = [
"horizon-friends",
"horizon-game",
"horizon-heartbeat",
"horizon-platform",
@@ -1891,10 +1892,6 @@ dependencies = [
"ring",
]
[[package]]
name = "horizon-render-pipeline"
version = "0.1.0"
[[package]]
name = "horizon-server"
version = "0.1.0"
@@ -1957,7 +1954,6 @@ version = "0.1.0"
dependencies = [
"half",
"horizon-core",
"horizon-render-pipeline",
"naga",
"png",
"zip",
-1
View File
@@ -43,7 +43,6 @@ members = [
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
"crates/horizon-render-pipeline",
"crates/horizon-optifine",
"crates/horizon-polytone",
"crates/horizon-shader-api",
+2
View File
@@ -7,6 +7,8 @@ rust-version.workspace = true
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
[dependencies]
# The moderation durations, shared with `/tempban`.
horizon-server-files = { path = "../horizon-server-files" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1.1"
+3 -55
View File
@@ -10,6 +10,9 @@ use std::path::{Path, PathBuf};
mod normalise;
pub use horizon_server_files::duration::{
DurationUnit, duration_parts, duration_text, now_millis, parse_duration,
};
pub use normalise::{Run, Text};
/// The list built into the game, used when `words_file` is empty.
@@ -474,61 +477,6 @@ pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
writeln!(f, "{line}")
}
/// `/tempmute`'s duration in milliseconds: a positive whole number and a unit, `s`, `m`, `h`, `d` or
/// `w` (`10m`, `2h`, `1d`).
pub fn parse_duration(text: &str) -> Option<i64> {
let unit = match text.chars().last()? {
's' => 1_000,
'm' => MINUTE,
'h' => 60 * MINUTE,
'd' => 24 * 60 * MINUTE,
'w' => 7 * 24 * 60 * MINUTE,
_ => return None,
};
let count: i64 = text[..text.len() - 1].parse().ok()?;
(count > 0).then(|| count.checked_mul(unit)).flatten()
}
/// The unit used when presenting a moderation duration to a player.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DurationUnit {
Minute,
Hour,
Day,
}
/// A duration's rounded count and unit, shared by logs and translated replies.
pub fn duration_parts(millis: i64) -> (i64, DurationUnit) {
let (unit, kind) = if millis <= 60 * MINUTE {
(MINUTE, DurationUnit::Minute)
} else if millis <= 48 * 60 * MINUTE {
(60 * MINUTE, DurationUnit::Hour)
} else {
(24 * 60 * MINUTE, DurationUnit::Day)
};
(
millis.div_euclid(unit) + i64::from(millis.rem_euclid(unit) > 0),
kind,
)
}
/// `millis` for logs, rounded up: minutes up to an hour, hours up to two days, then days.
pub fn duration_text(millis: i64) -> String {
let (count, unit) = duration_parts(millis);
let unit = match unit {
DurationUnit::Minute => "minute",
DurationUnit::Hour => "hour",
DurationUnit::Day => "day",
};
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
}
pub fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64)
}
/// `YYYY-MM-DDTHH:MM:SSZ` for epoch milliseconds.
pub fn utc(millis: i64) -> String {
let secs = millis.div_euclid(1000);
+2 -4
View File
@@ -9,13 +9,11 @@ description = "The client's input layer: key bindings and their names, mouse, co
[dependencies]
horizon-client-core = { path = "../horizon-client-core" }
winit = "0.30.13"
# The window icon.
horizon-theme = { path = "../horizon-theme" }
png = "0.18"
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
[dev-dependencies]
# The window icon test decodes Horizon's icon.
horizon-theme = { path = "../horizon-theme" }
[features]
controllers = []
+43 -32
View File
@@ -638,30 +638,10 @@ pub fn fullscreen_request(
}
}
/// The PNG the windows show as their icon, given once at startup by the layer that brands the
/// game (the engine has none of its own).
static ICON_PNG: std::sync::OnceLock<&'static [u8]> = std::sync::OnceLock::new();
/// Give the windows their icon, a PNG: call it before the first window opens. A second call
/// keeps the first icon.
pub fn set_icon_png(png: &'static [u8]) {
let _ = ICON_PNG.set(png);
}
/// The icon given to [`set_icon_png`], if any.
pub fn icon_png() -> Option<&'static [u8]> {
ICON_PNG.get().copied()
}
/// The icon for the window's title bar and the task bar (X11 and Windows; Wayland takes it from
/// the `.desktop` entry, macOS from the app bundle). `None` with no icon given, or one that
/// can't be read.
/// Horizon's icon for the window's title bar and the task bar (X11 and Windows; Wayland takes
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
pub fn icon() -> Option<winit::window::Icon> {
decode_icon(icon_png()?)
}
fn decode_icon(png: &[u8]) -> Option<winit::window::Icon> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(png));
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().ok()?;
@@ -674,15 +654,14 @@ fn decode_icon(png: &[u8]) -> Option<winit::window::Icon> {
winit::window::Icon::from_rgba(bytes, info.width, info.height).ok()
}
/// Apply the icon given to [`set_icon_png`] to a window. Windows has a separate taskbar icon slot
/// from the small window icon; requiring a given icon to decode there prevents a generic taskbar
/// icon from appearing.
pub fn with_icon(attributes: winit::window::WindowAttributes) -> winit::window::WindowAttributes {
#[cfg(not(target_os = "windows"))]
/// Apply Horizon's icon to every window. Windows has a separate taskbar icon slot from the small
/// window icon; requiring the decoded icon there prevents a generic taskbar icon from appearing.
pub fn with_horizon_icon(
attributes: winit::window::WindowAttributes,
) -> winit::window::WindowAttributes {
let icon = icon();
#[cfg(target_os = "windows")]
let icon =
icon_png().map(|png| decode_icon(png).expect("the window icon must decode on Windows"));
let icon = Some(icon.expect("Horizon's icon must be available on Windows"));
let attributes = attributes.with_window_icon(icon.clone());
#[cfg(target_os = "windows")]
@@ -691,6 +670,39 @@ pub fn with_icon(attributes: winit::window::WindowAttributes) -> winit::window::
attributes
}
/// The screen a window is on: its size in physical pixels, its refresh rate where the OS gives
/// it, and its scale. For `horizon-ctl status` and the performance statistics' `system.display`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Display {
pub width: u32,
pub height: u32,
pub refresh_hz: Option<u32>,
pub scale: f64,
}
/// The screen `window` is on, else the primary one, else the only one: right after the window
/// is made, Wayland doesn't say yet which screen it is on.
pub fn monitor(window: &winit::window::Window) -> Option<winit::monitor::MonitorHandle> {
let only = || {
let mut all = window.available_monitors();
all.next().filter(|_| all.next().is_none())
};
window
.current_monitor()
.or_else(|| window.primary_monitor())
.or_else(only)
}
/// [`monitor`]'s size, refresh rate and scale.
pub fn display(window: &winit::window::Window) -> Option<Display> {
monitor(window).map(|m| Display {
width: m.size().width,
height: m.size().height,
refresh_hz: m.refresh_rate_millihertz().map(|r| r.div_ceil(1000)),
scale: m.scale_factor(),
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -727,8 +739,7 @@ mod tests {
}
#[test]
fn the_window_icon_decodes() {
assert!(decode_icon(horizon_theme::ICON_PNG).is_some());
assert!(decode_icon(b"not a png").is_none());
assert!(icon().is_some());
}
#[test]
+5 -7
View File
@@ -42,8 +42,6 @@ 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" }
# 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).
@@ -72,6 +70,7 @@ flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs",
mio = { version = "1.2.4", features = ["os-poll", "net"] }
# Shared chunk frames are found by one keyed 128-bit hash of their body (lan/conn.rs SharedFrames).
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).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-bench = { path = "../horizon-bench" }
@@ -110,16 +109,17 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
default = ["audio", "controllers", "discord", "friends"]
default = ["audio", "controllers", "horizon-extras"]
# 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", "horizon-client-input/controllers"]
# Horizon's own parts (docs/forking.md, docs/layers.md): the features below, and the official
# server in the list (`official_servers`).
horizon-extras = ["discord"]
# "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"]
# 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.
@@ -131,8 +131,6 @@ windows-segment-heap = []
workspace = true
[dev-dependencies]
# ui_theme's unit tests draw text in the design system's fonts.
horizon-theme = { path = "../horizon-theme" }
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
+1 -3
View File
@@ -26,10 +26,10 @@ horizon-narrator = { path = "../horizon-narrator" }
horizon-optifine = { path = "../horizon-optifine" }
horizon-polytone = { path = "../horizon-polytone" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-render-pipeline = { path = "../horizon-render-pipeline" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
horizon-system = { path = "../horizon-system" }
horizon-theme = { path = "../horizon-theme" }
horizon-threads = { path = "../horizon-threads" }
horizon-world = { path = "../horizon-world" }
ab_glyph = "0.2.32"
@@ -81,7 +81,5 @@ test-support = []
workspace = true
[dev-dependencies]
# Tests: a TrueType font to load, and the design system the menus' motion curves match.
horizon-theme = { path = "../horizon-theme" }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "glsl-out", "msl-out", "spv-out"] }
+288 -8
View File
@@ -1,5 +1,6 @@
//! 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`.
//! pressing them does. Each one's value and what it does stay in `extras::Item`; the sections crates
//! add (`horizon_hooks::pages::Place::Extras`) keep theirs.
use crate::{
extras::{self, Item},
lang,
@@ -7,6 +8,7 @@ use crate::{
settings::Settings,
settings_shell::{self as shell, Binding, Group, Output, Row, Screen, Sheet, Value},
};
use horizon_hooks::pages::{self as added, Act, Label};
const TITLE: &str = "horizon.pages.extras";
const ABOUT: &str = "horizon.pages.extras.tooltip";
@@ -31,6 +33,121 @@ pub(crate) enum ExtraRow {
/// 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,
/// Row `.1` of the section registered page `.0` adds (`Place::Extras`).
Added(usize, usize),
}
#[cfg(test)]
thread_local! {
/// A test's own pages, instead of the game's registered ones: each test runs on its thread.
static TEST_PAGES: std::cell::Cell<Option<&'static [&'static dyn added::Page]>> =
const { std::cell::Cell::new(None) };
}
/// The pages crates registered, all places.
fn pages() -> &'static [&'static dyn added::Page] {
#[cfg(test)]
if let Some(pages) = TEST_PAGES.get() {
return pages;
}
added::engine::all()
}
/// The sections of this page, by their index among the registered pages.
fn sections() -> impl Iterator<Item = (usize, &'static dyn added::Page)> {
let pages = pages().iter().copied().enumerate();
pages.filter(|(_, page)| page.place() == added::Place::Extras)
}
fn label_text(label: Label) -> String {
match label {
Label::Key(key) => lang::get(key),
Label::Value(text) => text,
}
}
/// The page's group named `name`, made at the end if it has none.
fn group_named<'a>(
groups: &'a mut Vec<Group<ExtraRow>>,
name: &'static str,
) -> &'a mut Group<ExtraRow> {
let at = groups.iter().position(|group| group.name == name);
let at = at.unwrap_or_else(|| {
groups.push(Group {
name,
rows: Vec::new(),
});
groups.len() - 1
});
&mut groups[at]
}
/// The sections' rows, in the groups their titles and headings name.
fn add_sections(groups: &mut Vec<Group<ExtraRow>>) {
for (page, section) in sections() {
let mut rows = Vec::new();
section.rows(&mut rows);
let mut name = section.title();
for (r, row) in rows.into_iter().enumerate() {
let id = ExtraRow::Added(page, r);
let row = match row {
added::Row::Heading(key) => {
name = key;
continue;
}
added::Row::Note(text) => Row::new(id, text, Value::Note).live(false),
added::Row::Toggle { label, on } => {
Row::new(id, lang::get(label), Value::Switch(on))
}
added::Row::Cycle { label, value } => {
Row::new(id, lang::get(label), Value::Choice(label_text(value)))
}
added::Row::Slider { label, value, text } => {
let position = value.clamp(0.0, 1.0);
let text = label_text(text);
let value = Value::Slider {
position,
steps: None,
text,
};
Row::new(id, lang::get(label), value)
}
added::Row::Button { label } => Row::new(id, lang::get(label), Value::Press),
added::Row::Key { label, key } => {
let key = crate::keybinds::display_name(&key).plain();
Row::new(id, lang::get(label), Value::Read(key))
}
// A row kind newer than this engine.
_ => continue,
};
let row = match section.tooltip(r) {
Some(about) => row.about(label_text(about)),
None => row,
};
group_named(groups, name).rows.push(row);
}
}
}
/// Row `r` of registered page `page` was acted on: the options screen saves when it closes.
fn act(page: usize, r: usize, act: Act) -> Vec<Output> {
let Some(section) = pages().get(page) else {
return Vec::new();
};
section.act(r, act);
vec![Output::Event(Event::Changed)]
}
/// Whether row `r` of registered page `page` steps (a switch or a cycle).
fn steps(page: usize, r: usize) -> bool {
let mut rows = Vec::new();
if let Some(section) = pages().get(page) {
section.rows(&mut rows);
}
matches!(
rows.get(r),
Some(added::Row::Toggle { .. } | added::Row::Cycle { .. })
)
}
fn item_row(item: Item, s: &Settings) -> Row<ExtraRow> {
@@ -85,9 +202,14 @@ impl Screen for ExtrasScreen {
fn note_lines(&self) -> usize {
NOTE_LINES
}
/// The rows read the extras, the mods and whether cosmetics are offered.
/// The rows read the extras, the mods, whether cosmetics are offered and the sections' rows.
fn stamp(&self, s: &Settings) -> u64 {
shell::stamp_of(&(&s.extras, &s.mods, horizon_cosmetics::enabled()))
let mut added = Vec::new();
for (_, section) in sections() {
section.rows(&mut added);
}
let cosmetics = horizon_cosmetics::enabled();
shell::stamp_of(&(&s.extras, &s.mods, cosmetics, added))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
let mut groups = vec![Group {
@@ -107,6 +229,7 @@ impl Screen for ExtrasScreen {
}
}
}
add_sections(&mut groups);
vec![Sheet::new((), lang::get(TITLE), lang::get(ABOUT), groups)]
}
fn press(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Vec<Output> {
@@ -125,16 +248,29 @@ impl Screen for ExtrasScreen {
Output::Event(Event::NativeSettings(i)),
],
ExtraRow::ZoomInstall => Vec::new(),
ExtraRow::Added(page, r) => {
act(page, r, if back { Act::PressBack } else { Act::Press })
}
}
}
fn slide(&mut self, _s: &mut Settings, row: ExtraRow, position: f32) {
if let ExtraRow::Added(page, r) = row {
act(page, r, Act::Slide(position));
}
}
/// 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)])
match row {
ExtraRow::Item(item) => {
item.cycle(&mut s.extras, back);
Some(vec![Output::Event(Event::Changed)])
}
ExtraRow::Added(page, r) if steps(page, r) => {
Some(act(page, r, if back { Act::PressBack } else { Act::Press }))
}
_ => None,
}
}
}
@@ -292,4 +428,148 @@ mod tests {
shell.key(&mut s, K::ArrowDown, None, false, size);
assert_eq!(shell.focused_row(), Some(first));
}
/// A section with a switch in the page's Multiplayer group, then a heading of its own with a
/// number that steps either way, a note and a volume.
struct Section {
on: std::sync::Mutex<bool>,
step: std::sync::Mutex<i32>,
volume: std::sync::Mutex<f32>,
}
impl added::Page for Section {
fn id(&self) -> &'static str {
"test.section"
}
fn title(&self) -> &'static str {
"horizon.extras.header.multiplayer"
}
fn place(&self) -> added::Place {
added::Place::Extras
}
fn rows(&self, rows: &mut Vec<added::Row>) {
rows.push(added::Row::Toggle {
label: "test.section.switch",
on: *self.on.lock().unwrap(),
});
rows.push(added::Row::Heading("test.section.heading"));
rows.push(added::Row::Cycle {
label: "test.section.step",
value: Label::Value(self.step.lock().unwrap().to_string()),
});
rows.push(added::Row::Note("a note".into()));
rows.push(added::Row::Slider {
label: "test.section.volume",
value: *self.volume.lock().unwrap(),
text: Label::Value(String::new()),
});
}
fn act(&self, row: usize, act: Act) {
match (row, act) {
(0, Act::Press | Act::PressBack) => {
let mut on = self.on.lock().unwrap();
*on = !*on;
}
(2, Act::Press) => *self.step.lock().unwrap() += 1,
(2, Act::PressBack) => *self.step.lock().unwrap() -= 1,
(4, Act::Slide(v)) => *self.volume.lock().unwrap() = v,
_ => {}
}
}
}
/// A page for Horizon Settings: never on Extras.
struct Elsewhere;
impl added::Page for Elsewhere {
fn id(&self) -> &'static str {
"test.elsewhere"
}
fn title(&self) -> &'static str {
"test.elsewhere"
}
fn place(&self) -> added::Place {
added::Place::HorizonSettings
}
fn rows(&self, rows: &mut Vec<added::Row>) {
rows.push(added::Row::Button {
label: "test.elsewhere.button",
});
}
fn act(&self, _: usize, _: Act) {}
}
fn with_section() -> &'static Section {
let section: &'static Section = Box::leak(Box::new(Section {
on: false.into(),
step: 0.into(),
volume: 0.5.into(),
}));
let pages: &'static [&'static dyn added::Page] = Box::leak(Box::new([
&Elsewhere as &dyn added::Page,
section as &dyn added::Page,
]));
TEST_PAGES.set(Some(pages));
section
}
/// The section's first row joins the page's own Multiplayer group, after its rows; its heading
/// starts a group after the page's; a page for elsewhere adds nothing.
#[test]
fn a_section_joins_the_group_its_title_names_and_heads_its_own() {
with_section();
let s = Settings::default();
let sheets = ExtrasScreen.sheets(&s);
let groups = &sheets[0].groups;
let ids = |name: &str| -> Vec<ExtraRow> {
let group = groups.iter().find(|g| g.name == name).unwrap();
group.rows.iter().map(|r| r.id).collect()
};
let multiplayer = ids("horizon.extras.header.multiplayer");
assert_eq!(multiplayer.last(), Some(&ExtraRow::Added(1, 0)));
assert_eq!(groups.last().unwrap().name, "test.section.heading");
let own = [
ExtraRow::Added(1, 2),
ExtraRow::Added(1, 3),
ExtraRow::Added(1, 4),
];
assert_eq!(ids("test.section.heading"), own);
let all = rows_of(&s);
assert!(!all.iter().any(|r| matches!(r.id, ExtraRow::Added(0, _))));
}
/// A click flips the switch, a Shift click steps the number back, the wheel steps it, and the
/// note takes no click; the rows show what the section holds after each.
#[test]
fn a_sections_rows_act_on_it() {
let section = with_section();
let mut s = Settings::default();
let mut shell = Shell::<ExtrasScreen>::default();
let switch = ExtraRow::Added(1, 0);
let at = shell.point_at(&s, SIZE, switch);
assert!(
!shell
.click(&mut s, at, SIZE, false, false, false)
.is_empty()
);
assert!(*section.on.lock().unwrap());
let value = |s: &Settings, id| rows_of(s).into_iter().find(|r| r.id == id).unwrap().value;
assert_eq!(value(&s, switch), Value::Switch(true));
let step = ExtraRow::Added(1, 2);
let mut screen = ExtrasScreen;
screen.press(&mut s, step, true);
assert_eq!(value(&s, step), Value::Choice("-1".into()));
assert!(screen.wheel_step(&mut s, step, false).is_some());
assert_eq!(*section.step.lock().unwrap(), 0);
assert_eq!(
screen.wheel_step(&mut s, ExtraRow::Added(1, 3), false),
None
);
let note = shell.point_at(&s, SIZE, ExtraRow::Added(1, 3));
assert!(
shell
.click(&mut s, note, SIZE, false, false, false)
.is_empty()
);
screen.slide(&mut s, ExtraRow::Added(1, 4), 0.25);
assert_eq!(*section.volume.lock().unwrap(), 0.25);
}
}
+2 -2
View File
@@ -15718,11 +15718,11 @@ mod shadow_caster_tests {
#[test]
fn the_shadow_pass_draws_terrain_outside_the_pack_s_planes() {
let kept = |near: Option<f32>, far: Option<f32>, y: f64, min: [f32; 3], depth: bool| {
let shadow = horizon_render_pipeline::Shadow {
let shadow = horizon_shaders::Shadow {
distance: 128.0,
near,
far,
..horizon_render_pipeline::Shadow::default()
..horizon_shaders::Shadow::default()
};
let camera = DVec3::new(13.5, y + 1.62, -13.5);
let view = pack_post::ShadowView {
+4 -6
View File
@@ -1001,14 +1001,12 @@ impl Hdr {
}
}
/// The windows' icon (`window::set_icon_png`: Horizon's in the official game) as a 1-bit mask:
/// its white strokes, one colour with hard edges, are the logo; everything else is not. No icon
/// given: an empty mask.
/// Horizon's icon (horizon-theme) as a 1-bit mask: its white strokes, one colour with hard
/// edges, are the logo; everything else is not.
fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
use wgpu::util::DeviceExt;
let (w, h, rgba) = horizon_client_input::window::icon_png()
.and_then(|png| crate::hud::decode_bytes(png).ok())
.unwrap_or((1, 1, vec![0; 4]));
let (w, h, rgba) =
crate::hud::decode_bytes(horizon_theme::ICON_PNG).unwrap_or((1, 1, vec![0; 4]));
let mask: Vec<u8> = rgba
.as_chunks::<4>()
.0
@@ -338,7 +338,7 @@ pub struct NativeFrame {
reflection_sampler: wgpu::Sampler,
/// The shadow map the stages read, and the camera that draws it when the pack asks for one.
pub shadow_map: super::native_shadow::ShadowMap,
shadow_camera: Option<horizon_render_pipeline::Shadow>,
shadow_camera: Option<horizon_shaders::Shadow>,
/// Whether the pack's material reads `reflection` (`NativePack::new`): else no mirror
/// is drawn and `has_reflection` stays 0.
pub reflects: bool,
@@ -1497,7 +1497,7 @@ pub fn frame_inputs(
size: [u32; 2],
seconds: f32,
index: u32,
shadow: Option<&horizon_render_pipeline::Shadow>,
shadow: Option<&horizon_shaders::Shadow>,
) -> FrameInputs {
let aspect = size[0] as f32 / size[1].max(1) as f32;
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
@@ -6,8 +6,8 @@
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
//! prelude's `shadow_position` undoes.
use horizon_render_pipeline::Shadow;
use horizon_shader_api::{Api, Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shaders::Shadow;
use super::native_material::TerrainShape;
use super::wgsl_prefix::{TERRAIN, prefixed, section};
@@ -4,8 +4,7 @@
use std::collections::BTreeMap;
use horizon_render_pipeline::{Format, Kind};
use horizon_shaders::{BufferObject, CustomImage};
use horizon_shaders::{BufferObject, CustomImage, Format, Kind};
pub(super) struct PackImage {
pub(super) def: CustomImage,
@@ -18,8 +18,7 @@
use std::collections::HashMap;
use glam::{DMat4, DVec3};
use horizon_render_pipeline::{BUFFERS, Format, Kind, SHADOW_COLORS};
use horizon_shaders::{Group, Post};
use horizon_shaders::{BUFFERS, Format, Group, Kind, Post, SHADOW_COLORS};
use smallvec::SmallVec;
use super::pack_draw::DrawUniforms;
@@ -893,7 +892,7 @@ fn depth_linear(interface: &horizon_shaders::Interface, linear: u8) -> [f32; 4]
}
fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool) -> Source {
let shadow_color = horizon_render_pipeline::shadow_color(name);
let shadow_color = horizon_shaders::shadow_color(name);
let shadow_depth = match name {
"shadowtex0" | "watershadow" | "shadowtex0HW" => Some(0),
"shadowtex1" | "shadowtex1HW" => Some(1),
@@ -912,7 +911,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
};
}
}
if shadow_space && horizon_render_pipeline::buffer_index(name).is_some() {
if shadow_space && horizon_shaders::buffer_index(name).is_some() {
// colortex buffers are not this pass's.
return Source::Dummy;
}
@@ -926,7 +925,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
"normals" => Source::Normals,
"specular" => Source::Specular,
horizon_shaders::WATER_MASK => Source::WaterMask,
n => horizon_render_pipeline::buffer_index(n).map_or(Source::Dummy, Source::Color),
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
}
}
@@ -1583,7 +1582,7 @@ fn bindings(
!unbound
&& (t.stage != pack_textures::stage(group)
|| released.0 != t.stage
|| horizon_render_pipeline::buffer_index(&t.name)
|| horizon_shaders::buffer_index(&t.name)
.is_none_or(|b| released.1 & 1 << b == 0))
});
let image = pack
@@ -2433,7 +2432,7 @@ impl PackPost {
let shadow_config = post.config.shadow.clone();
let noise_size = post.config.noise_resolution.unwrap_or(256);
let half_lives = post.config.half_lives;
for (name, ticks) in horizon_render_pipeline::HALF_LIVES.iter().zip(half_lives) {
for (name, ticks) in horizon_shaders::HALF_LIVES.iter().zip(half_lives) {
if let Some(t) = ticks {
notes.push(format!("`{name}` is {t} ticks"));
}
@@ -10,7 +10,7 @@
//! a fit to one driver's values, not a rule Iris states. `RGBA4` and `RGBA2` read back the `RGBA8`
//! bytes in the same probe, as Horizon's 8-bit buffers already do.
use horizon_render_pipeline::{Format, Kind};
use horizon_shaders::{Format, Kind};
use naga::{BinaryOperator, Expression, Literal, MathFunction, Statement, VectorSize};
/// The bits each channel keeps in a buffer of format `f`, for the formats that keep fewer than
@@ -3,7 +3,7 @@
//! with two sides that `shadowcomp` programs flip as composite programs flip colortex (6.1.6).
//! Square, `shadowMapResolution` pixels.
use horizon_render_pipeline::SHADOW_COLORS;
use horizon_shaders::SHADOW_COLORS;
type Ops<T> = wgpu::Operations<T>;
type Color = wgpu::Color;
@@ -97,11 +97,7 @@ pub(super) fn stand_in(device: &wgpu::Device) -> wgpu::TextureView {
}
impl ShadowTargets {
pub fn reset_history(
&mut self,
device: &wgpu::Device,
shadow: &horizon_render_pipeline::Shadow,
) {
pub fn reset_history(&mut self, device: &wgpu::Device, shadow: &horizon_shaders::Shadow) {
let mut colors = 0;
let mut doubled = 0;
let formats = std::array::from_fn(|n| match &self.color[n] {
@@ -129,7 +125,7 @@ impl ShadowTargets {
/// resolution is kept within the device's limit.
pub fn new(
device: &wgpu::Device,
shadow: &horizon_render_pipeline::Shadow,
shadow: &horizon_shaders::Shadow,
formats: [wgpu::TextureFormat; SHADOW_COLORS],
colors: u16,
doubled: u16,
@@ -1,8 +1,7 @@
//! A shader pack's custom textures (`texture.<stage>.<name>`, `texture.noise`) on the GPU, and
//! which of them a program's sampler reads.
use horizon_render_pipeline::Kind;
use horizon_shaders::{CustomTexture, Group, Texels, Texture, TextureDim, TextureSource};
use horizon_shaders::{CustomTexture, Group, Kind, Texels, Texture, TextureDim, TextureSource};
/// What a custom texture is once uploaded: an image of ours, or one the game already has.
pub(super) enum Image {
@@ -119,12 +118,12 @@ pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str)
} else {
wgpu::TextureViewDimension::D2
};
let buffer = horizon_render_pipeline::buffer_index(name);
let buffer = horizon_shaders::buffer_index(name);
let staged = textures.iter().position(|t| {
t.stage == stage(group)
&& t.dimension == dimension
&& (t.name == name
|| buffer.is_some() && horizon_render_pipeline::buffer_index(&t.name) == buffer)
|| buffer.is_some() && horizon_shaders::buffer_index(&t.name) == buffer)
});
staged.or_else(|| {
textures.iter().position(|t| {
@@ -28,7 +28,7 @@
//! [-w, w], and the depth buffers packs read hold GL window depth.
use glam::{DMat4, DVec3, Mat4, Vec3, Vec4};
use horizon_render_pipeline::Shadow;
use horizon_shaders::Shadow;
use super::Frame;
@@ -440,7 +440,7 @@ pub struct FrameUniforms {
center_depth: Option<f32>,
/// The pack's shadow settings (7.1).
pub shadow: Shadow,
/// The pack's smoothing half-lives in ticks (`horizon_render_pipeline::HALF_LIVES`).
/// The pack's smoothing half-lives in ticks (`horizon_shaders::HALF_LIVES`).
pub half_lives: [Option<f32>; 4],
}
@@ -2,21 +2,16 @@
//! colours and the controls it draws with. The screen (`graphics_ui`) only
//! lays out rows and handles input; a menu theme replaces this module.
//!
//! Its colours come from `menu_style`, given at startup (in the official game the design
//! system's Blocks palette, `docs/design-system.md`), in a lighter form: square panels of
//! dark glass with a thin edge, so the world shows through, flat controls, and the face on
//! the one main button, in vanilla's font.
use crate::menu_style::{self, SettingsColours};
//! It takes the design system's Blocks palette (`horizon_theme::BLOCKS_DARK`,
//! `docs/design-system.md`) in a lighter form: square panels of dark glass
//! with a thin edge, so the world shows through, flat controls, and the violet
//! face on the one main button, in vanilla's font.
use crate::{
hud::{HudAtlas, HudBatch},
text::Text,
ui,
};
/// The settings pages' colours (`menu_style`, given at startup).
fn b() -> &'static SettingsColours {
&menu_style::get().settings
}
use horizon_theme::BLOCKS_DARK as B;
/// What a setting costs in frame time, shown with its description.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -41,91 +36,54 @@ impl Cost {
/// `0xRRGGBB`: the palette's success, warn and danger.
pub fn rgb(self) -> u32 {
match self {
Cost::None => b().ink_soft,
Cost::Low | Cost::Saves => b().success,
Cost::Medium => b().warn,
Cost::High => b().danger,
Cost::None => B.ink_soft,
Cost::Low | Cost::Saves => B.success,
Cost::Medium => B.warn,
Cost::High => B.danger,
}
}
}
use menu_style::hud_colour as hex;
/// `0xRRGGBB` and an alpha in the HUD's colour encoding.
const fn hex(c: u32, a: u8) -> u32 {
(a as u32) << 24 | (c & 0xff) << 16 | (c >> 8 & 0xff) << 8 | (c >> 16 & 0xff)
}
/// The plate and the strip: the page's dark, see-through, so the world shows behind them.
fn glass() -> u32 {
hex(b().page, 118)
}
const GLASS: u32 = hex(B.page, 118);
/// Their 1 px edge.
pub fn edge() -> u32 {
hex(b().ink, 26)
}
const EDGE: u32 = hex(B.ink, 26);
/// A group's panel, a little lighter than the plate it sits on.
fn group_fill() -> u32 {
hex(b().ink, 11)
}
const GROUP: u32 = hex(B.ink, 11);
/// A hovered or focused row, lit on its group's panel.
pub fn hover() -> u32 {
hex(b().ink, 18)
}
pub const HOVER: u32 = hex(B.ink, 18);
/// The open page in the page list.
pub fn open() -> u32 {
hex(b().ink, 26)
}
pub const OPEN: u32 = hex(B.ink, 26);
/// The rule under the plate's heading, and between two rows of a group.
pub fn head_rule() -> u32 {
hex(b().ink, 31)
}
pub fn row_rule() -> u32 {
hex(b().ink, 15)
}
pub const HEAD_RULE: u32 = hex(B.ink, 31);
pub const ROW_RULE: u32 = hex(B.ink, 15);
/// Fields, boxes and the value stepper: sunk, see-through.
fn well_fill() -> u32 {
hex(b().outline, 102)
}
const WELL: u32 = hex(B.outline, 102);
/// A button, a slider's track, the scroller's thumb.
fn button_fill() -> u32 {
hex(b().ink, 20)
}
fn button_hover() -> u32 {
hex(b().ink, 41)
}
fn track_fill() -> u32 {
hex(b().ink, 64)
}
fn handle_fill() -> u32 {
hex(b().ink, 255)
}
const BUTTON: u32 = hex(B.ink, 20);
const BUTTON_HOVER: u32 = hex(B.ink, 41);
const TRACK: u32 = hex(B.ink, 64);
const HANDLE: u32 = hex(B.ink, 255);
/// The one main button.
pub fn face() -> u32 {
hex(b().face, 255)
}
fn face_hover() -> u32 {
hex(b().face_hover, 255)
}
const FACE: u32 = hex(B.face, 255);
const FACE_HOVER: u32 = hex(0x6a50e6, 255);
/// A tick box's edge when off.
fn box_edge() -> u32 {
hex(b().ink_soft, 89)
}
fn outline() -> u32 {
hex(b().outline, 255)
}
pub fn accent() -> u32 {
hex(b().accent, 255)
}
const BOX_EDGE: u32 = hex(B.ink_soft, 89);
const OUTLINE: u32 = hex(B.outline, 255);
pub const ACCENT: u32 = hex(B.accent, 255);
/// Vanilla's white text, and its grey for what can't be used now.
pub const WHITE: u32 = u32::MAX;
pub fn muted() -> u32 {
hex(b().muted, 255)
}
pub const MUTED: u32 = hex(B.muted, 255);
/// A page that isn't open, a value at rest.
pub fn soft() -> u32 {
hex(b().ink_soft, 255)
}
pub const SOFT: u32 = hex(B.ink_soft, 255);
/// Vanilla's grey for a tooltip's lines under its first (`ChatFormatting.GRAY`).
pub const GREY: u32 = hex(0xaaaaaa, 255);
/// A light veil over the backdrop, under the panels.
pub fn veil() -> u32 {
hex(b().page, 77)
}
pub const VEIL: u32 = hex(B.page, 77);
/// Sizes in GUI pixels.
/// Shorter screens than this (a 720p window at scale 3) keep a narrower margin on top.
@@ -162,21 +120,21 @@ pub fn ring(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, h]: [f32; 4], colo
}
/// The screen's plate, and the strip under it: dark glass with a thin edge.
pub fn panel(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, glass());
ring(batch, atlas, rect, edge());
fill(batch, atlas, rect, GLASS);
ring(batch, atlas, rect, EDGE);
}
/// A group's panel on the plate, its rows on it.
pub fn group(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, group_fill());
fill(batch, atlas, rect, GROUP);
}
/// A sunk well: the search field, the value stepper.
pub fn well(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, well_fill());
fill(batch, atlas, rect, WELL);
}
/// The scroller: a faint track and its thumb.
pub fn scroller(batch: &mut HudBatch, atlas: &HudAtlas, track: [f32; 4], thumb: [f32; 4]) {
fill(batch, atlas, track, row_rule());
fill(batch, atlas, thumb, track_fill());
fill(batch, atlas, track, ROW_RULE);
fill(batch, atlas, thumb, TRACK);
}
/// `text` cut to `width` with an ellipsis, as a long name gives way to its control: the HUD
/// kit's `fit::ellipsize`.
@@ -184,11 +142,11 @@ pub use crate::hud_kit::fit::ellipsize as fit;
/// A 12 px tick box ending at `right`, centred in the row at `y`: its edge in the accent when on.
pub fn checkbox(batch: &mut HudBatch, atlas: &HudAtlas, right: f32, y: f32, on: bool, live: bool) {
let rect = [right - 12.0, y + 4.0, 12.0, 12.0];
fill(batch, atlas, rect, well_fill());
fill(batch, atlas, rect, WELL);
let edge = match (on, live) {
(true, true) => accent(),
(true, false) => muted(),
(false, _) => box_edge(),
(true, true) => ACCENT,
(true, false) => MUTED,
(false, _) => BOX_EDGE,
};
ring(batch, atlas, rect, edge);
if on {
@@ -211,7 +169,7 @@ fn tick(batch: &mut HudBatch, atlas: &HudAtlas, x: f32, y: f32, color: u32) {
for (i, c) in row.chars().enumerate() {
if c == '#' {
let (px, py) = (x + i as f32, y + j as f32);
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], outline());
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], OUTLINE);
fill(batch, atlas, [px, py, 1.0, 1.0], color);
}
}
@@ -240,17 +198,17 @@ pub fn button_with_room(
) {
let [x, y, w, h] = rect;
let face = match (live, primary, hover) {
(false, _, _) => well_fill(),
(true, true, false) => face(),
(true, true, true) => face_hover(),
(true, false, false) => button_fill(),
(true, false, true) => button_hover(),
(false, _, _) => WELL,
(true, true, false) => FACE,
(true, true, true) => FACE_HOVER,
(true, false, false) => BUTTON,
(true, false, true) => BUTTON_HOVER,
};
fill(batch, atlas, rect, face);
if hover && live {
ring(batch, atlas, rect, WHITE);
}
let color = if live { WHITE } else { muted() };
let color = if live { WHITE } else { MUTED };
let ty = y + ((h - 8.0) / 2.0).floor();
let left = x + 2.0 + room;
scrolling_label(
@@ -294,7 +252,7 @@ pub fn slider(
(hover, live): (bool, bool),
) {
let [x, y, w, h] = rect;
let color = if live { WHITE } else { muted() };
let color = if live { WHITE } else { MUTED };
scrolling_label(
batch,
atlas,
@@ -305,22 +263,17 @@ pub fn slider(
);
let line = y + h - 3.0;
let at = x + (position.clamp(0.0, 1.0) * (w - 4.0)).round();
fill(batch, atlas, [x, line, w, 2.0], track_fill());
fill(batch, atlas, [x, line, w, 2.0], TRACK);
fill(
batch,
atlas,
[x, line, at - x, 2.0],
if live { accent() } else { muted() },
if live { ACCENT } else { MUTED },
);
let handle = [at, line - 3.0, 4.0, 8.0];
fill(
batch,
atlas,
handle,
if live { handle_fill() } else { muted() },
);
fill(batch, atlas, handle, if live { HANDLE } else { MUTED });
if hover && live {
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], accent());
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], ACCENT);
}
}
/// A value that a press steps through: in a well, between the arrows that say so.
@@ -337,13 +290,13 @@ pub fn cycler(
ring(batch, atlas, rect, WHITE);
}
let ty = y + ((h - 8.0) / 2.0).floor();
let arrows = if hover && live { WHITE } else { muted() };
let arrows = if hover && live { WHITE } else { MUTED };
batch.text_styled(atlas, &Text::literal("<"), x + 3.0, ty, arrows, true);
batch.text_styled(atlas, &Text::literal(">"), x + w - 8.0, ty, arrows, true);
let color = match (live, hover) {
(false, _) => muted(),
(false, _) => MUTED,
(true, true) => WHITE,
(true, false) => soft(),
(true, false) => SOFT,
};
scrolling_label(batch, atlas, value, [x + 9.0, ty, w - 18.0], (y, h), color);
}
@@ -3,9 +3,8 @@
//! where a tween is at the frame's [`Clock`]; nothing here holds state or runs on its own, and
//! no motion ever decides what input does.
//!
//! A [`Tween`] takes a duration and a CSS `cubic-bezier` curve: Horizon's overlays use the design
//! system's (`horizon_theme::QUICK`, `MOVE`), at the theme's duration or a shorter one of their
//! own.
//! The curves are the design system's (`horizon_theme::QUICK`, `MOVE`): a [`Tween`] takes one
//! of them, at the theme's duration or a shorter one of its own.
//!
//! The game builds the clock once a frame (`Clock::new(now, still)`). A still clock (the game is
//! paused, frame-stepped by `HORIZON_STILL` or a screenshot sequence, or a repro recording or
@@ -17,7 +16,7 @@
//! use std::time::{Duration, Instant};
//!
//! let opened = Instant::now();
//! let open = Tween::new(Duration::from_millis(90), [0.4, 0.0, 0.2, 1.0]);
//! let open = Tween::new(Duration::from_millis(90), horizon_theme::QUICK);
//! // Half way through: partly faded in, partly grown from 94%.
//! let clock = Clock::new(opened + Duration::from_millis(45), false);
//! let alpha = motion::fade(open, clock, opened);
@@ -27,6 +26,7 @@
//! let still = Clock::new(opened, true);
//! assert_eq!(motion::fade(open, still, opened), 1.0);
//! ```
use horizon_theme::Motion;
use std::time::{Duration, Instant};
/// The time a frame shows, built once a frame by the game.
@@ -50,20 +50,28 @@ impl Clock {
}
}
/// A duration and an easing curve.
/// A duration and one of the design system's easing curves.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tween {
/// How long it runs.
pub duration: Duration,
/// The CSS `cubic-bezier` control points of its curve.
/// The CSS `cubic-bezier` control points of its curve (a `horizon_theme::Motion`'s).
pub curve: [f32; 4],
}
impl Tween {
/// `curve` over `duration`: the dial's motions are shorter than the launcher's, on the same
/// curves.
pub const fn new(duration: Duration, curve: [f32; 4]) -> Self {
Self { duration, curve }
/// `motion`'s curve over `duration`: the dial's motions are shorter than the launcher's, on
/// the same curves.
pub const fn new(duration: Duration, motion: Motion) -> Self {
Self {
duration,
curve: motion.curve,
}
}
/// A theme motion as it is: its own duration and curve.
pub const fn of(motion: Motion) -> Self {
Self::new(motion.duration(), motion)
}
/// The linear progress, 0 to 1, `since` a start at this clock; 1 on a still clock.
@@ -76,7 +84,7 @@ impl Tween {
/// The eased progress, 0 to 1, on the tween's curve.
pub fn progress(self, clock: Clock, since: Instant) -> f32 {
bezier(self.curve, self.linear(clock, since))
horizon_theme::bezier(self.curve, self.linear(clock, since))
}
/// Whether it has finished (always, on a still clock).
@@ -90,29 +98,6 @@ impl Tween {
}
}
/// A CSS `cubic-bezier(x1, y1, x2, y2)` at `t` in 0..1: solves x for the curve parameter,
/// returns y.
pub fn bezier([x1, y1, x2, y2]: [f32; 4], t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
if t == 0.0 || t == 1.0 {
return t;
}
let at = |a: f32, b: f32, s: f32| {
let u = 1.0 - s;
3.0 * u * u * s * a + 3.0 * u * s * s * b + s * s * s
};
let (mut lo, mut hi) = (0f32, 1f32);
for _ in 0..24 {
let mid = (lo + hi) / 2.0;
if at(x1, x2, mid) < t {
lo = mid;
} else {
hi = mid;
}
}
at(y1, y2, (lo + hi) / 2.0)
}
/// An alpha from 0 to 1 as something fades in.
pub fn fade(tween: Tween, clock: Clock, since: Instant) -> f32 {
tween.progress(clock, since)
@@ -160,7 +145,7 @@ mod tests {
#[test]
fn a_tween_runs_from_its_start_to_its_end_and_holds() {
let t0 = Instant::now();
let tween = Tween::new(MS(80), horizon_theme::MOVE.curve);
let tween = Tween::new(MS(80), horizon_theme::MOVE);
assert_eq!(tween.progress(at(t0, 0), t0), 0.0);
assert_eq!(tween.progress(at(t0, 80), t0), 1.0);
assert_eq!(tween.progress(at(t0, 500), t0), 1.0);
@@ -175,7 +160,8 @@ mod tests {
fn a_still_clock_shows_every_motion_settled() {
let t0 = Instant::now();
let still = Clock::new(t0, true);
let tween = Tween::new(horizon_theme::QUICK.duration(), horizon_theme::QUICK.curve);
let tween = Tween::of(horizon_theme::QUICK);
assert_eq!(tween.duration, horizon_theme::QUICK.duration());
assert_eq!(fade(tween, still, t0), 1.0);
assert_eq!(fade_out(tween, still, t0), 0.0);
assert_eq!(scale_in(tween, still, t0, 0.94), 1.0);
@@ -187,7 +173,7 @@ mod tests {
#[test]
fn the_helpers_start_where_they_say() {
let t0 = Instant::now();
let tween = Tween::new(MS(100), horizon_theme::QUICK.curve);
let tween = Tween::new(MS(100), horizon_theme::QUICK);
let c = at(t0, 0);
assert_eq!(fade(tween, c, t0), 0.0);
assert_eq!(fade_out(tween, c, t0), 1.0);
@@ -198,25 +184,10 @@ mod tests {
assert!((flash(tween, at(t0, 25), t0) - 0.75).abs() < 1e-6);
}
#[test]
fn the_curve_is_the_design_systems() {
for motion in horizon_theme::MOTION {
for i in 0..=20 {
let t = i as f32 / 20.0;
assert_eq!(
bezier(motion.curve, t),
motion.ease(t),
"{} at {t}",
motion.name
);
}
}
}
#[test]
fn a_turn_takes_the_short_way_round() {
let t0 = Instant::now();
let tween = Tween::new(MS(80), horizon_theme::MOVE.curve);
let tween = Tween::new(MS(80), horizon_theme::MOVE);
// From up-left (7/8 of a turn) to up-right: through up, not round the bottom.
let from = 7.0 * FRAC_PI_4;
let half = turn(tween, at(t0, 1), t0, from, FRAC_PI_4);
@@ -38,12 +38,6 @@
//! ```
use crate::hud::{HudAtlas, HudBatch};
use crate::hud_kit::{colour, fit};
use crate::menu_style::{self, OverlayColours};
/// The overlays' colours (`menu_style`, given at startup).
fn overlay() -> &'static OverlayColours {
&menu_style::get().overlay
}
/// A pip under the strip: its size and the step from one to the next, in GUI px.
pub const PIP: [f32; 2] = [3.0, 2.0];
@@ -51,12 +45,8 @@ pub const PIP_STEP: f32 = 5.0;
/// How far, in GUI px, the arrows sit from the name.
pub const ARROW_GAP: f32 = 4.0;
/// An arrow with a page that way, and one at an end, with none.
pub fn arrow_colour() -> u32 {
colour(overlay().backdrop_muted, 255)
}
pub fn arrow_off_colour() -> u32 {
colour(overlay().backdrop_muted, 77)
}
pub const ARROW: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
pub const ARROW_OFF: u32 = colour(horizon_theme::BACKDROP_MUTED, 77);
/// The pips (the selected one is white).
pub const PIP_OFF: u32 = colour(0xFFFFFF, 89);
pub const PIP_ON: u32 = colour(0xFFFFFF, 255);
@@ -75,15 +65,9 @@ pub const EDGE_FADE: f32 = 12.0;
pub const FADE: [f32; 5] = [1.0, 0.7, 0.35, 0.15, 0.1];
/// The selected tab's fill and text, and the others'.
pub const SELECTED_FILL: u32 = colour(0xFFFFFF, 242);
pub fn selected_text() -> u32 {
colour(overlay().page, 255)
}
pub fn tab_fill() -> u32 {
colour(overlay().page, 191)
}
pub fn tab_text() -> u32 {
colour(overlay().backdrop_muted, 255)
}
pub const SELECTED_TEXT: u32 = colour(horizon_theme::DARK.page, 255);
pub const TAB_FILL: u32 = colour(horizon_theme::DARK.page, 191);
pub const TAB_TEXT: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
/// The line of text's height in GUI px (vanilla's font, without its shadow).
const LINE: f32 = 8.0;
@@ -160,11 +144,7 @@ pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, strip: &Strip) {
arrow,
ax,
ay,
if on {
arrow_colour()
} else {
arrow_off_colour()
},
if on { ARROW } else { ARROW_OFF },
true,
);
}
@@ -302,9 +282,9 @@ pub fn draw_tabs(batch: &mut HudBatch, atlas: &HudAtlas, strip: &TabStrip, alpha
for tab in &row.tabs {
let [tx, ty, tw, th] = tab.area;
let (fill, text) = if tab.selected {
(SELECTED_FILL, selected_text())
(SELECTED_FILL, SELECTED_TEXT)
} else {
(tab_fill(), tab_text())
(TAB_FILL, TAB_TEXT)
};
let tab_start = batch.vertices.len();
// Cut where the edges' fades start, so each fade runs over its own piece.
@@ -400,16 +380,15 @@ mod tests {
let [ax, _] = strip.arrows[i];
(batch.vertices.iter())
.filter(|v| {
(v.pos[0] - ax).abs() < 4.0
&& v.color & 0x00FF_FFFF == arrow_colour() & 0x00FF_FFFF
(v.pos[0] - ax).abs() < 4.0 && v.color & 0x00FF_FFFF == ARROW & 0x00FF_FFFF
})
.map(|v| v.color >> 24)
.max()
.unwrap_or(0)
};
let first = collapsed(&atlas, &pages, 0, AREA);
assert_eq!(alpha(&first, 0), arrow_off_colour() >> 24);
assert_eq!(alpha(&first, 1), arrow_colour() >> 24);
assert_eq!(alpha(&first, 0), ARROW_OFF >> 24);
assert_eq!(alpha(&first, 1), ARROW >> 24);
}
#[test]
-1
View File
@@ -75,7 +75,6 @@ pub mod light;
pub mod lod;
pub mod logging;
pub mod menu_blur;
pub mod menu_style;
pub mod mesher;
pub mod minimap;
pub mod mipmap;
@@ -1,145 +0,0 @@
//! The look of the screens vanilla doesn't have that the engine still draws: the settings pages
//! (`graphics_style`), the HUD kit's tab strip, and the overlays drawn with `ui_theme` (the
//! on-screen keyboard, the boot screen, the missing-resources window). Vanilla's screens never
//! read it.
//!
//! The layer above gives it once at startup ([`install`]), before the first frame; the
//! official game gives Horizon's design system (`docs/design-system.md`). With none given, the
//! screens draw in [`NEUTRAL`]: vanilla's greys and chat colours, and no text where they would
//! use their own fonts. A read is one atomic load, and only those screens make it.
use std::sync::OnceLock;
/// Colours as `0xRRGGBB`, sizes in dp (the launcher's logical pixels).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MenuStyle {
pub settings: SettingsColours,
pub overlay: OverlayColours,
pub sizes: Sizes,
/// The overlays' TrueType faces; none: they draw no text.
pub fonts: Option<Fonts>,
}
/// The settings pages: square panels of dark glass over the world, flat controls, one face.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SettingsColours {
/// The plate behind everything, drawn see-through.
pub page: u32,
/// Text, and the light the panels, rules, hovers and controls are drawn in.
pub ink: u32,
/// Secondary text and values.
pub ink_soft: u32,
/// Labels, descriptions, what can't be used.
pub muted: u32,
/// Wells and the dark edge of small marks.
pub outline: u32,
/// Focus, a checked box, a slider's fill.
pub accent: u32,
/// The one main button, and while hovered.
pub face: u32,
pub face_hover: u32,
pub success: u32,
pub warn: u32,
pub danger: u32,
}
/// Overlays drawn over the game (the HUD kit's tab strip, `ui_theme`'s overlays).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OverlayColours {
/// The dark page under text and tabs.
pub page: u32,
/// Muted text over a veiled backdrop.
pub backdrop_muted: u32,
/// Text and the light hover and pressed plates are drawn in.
pub ink: u32,
/// Secondary text and values.
pub ink_soft: u32,
/// One-pixel rules between rows.
pub rule: u32,
/// The main action, and its text.
pub accent: u32,
pub on_accent: u32,
}
/// `ui_theme`'s type scale and shapes, in dp.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Sizes {
pub caption: f32,
pub label: f32,
pub ui: f32,
pub body: f32,
pub title: f32,
/// The boot screen's wordmark at its smallest, and its console lines.
pub display: f32,
pub micro: f32,
/// Fields; plates (hover rows, selection); a large control's height.
pub radius: f32,
pub radius_plate: f32,
pub control_large: f32,
}
/// TrueType faces (`ui_theme::Face`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Fonts {
pub regular: &'static [u8],
pub medium: &'static [u8],
pub semibold: &'static [u8],
pub mono: &'static [u8],
}
/// The look with nothing given: vanilla's greys and chat colours.
pub const NEUTRAL: MenuStyle = MenuStyle {
settings: SettingsColours {
page: 0x101010,
ink: 0xffffff,
ink_soft: 0xe0e0e0,
muted: 0xa0a0a0,
outline: 0x000000,
accent: 0xffffff,
face: 0x555555,
face_hover: 0x6f6f6f,
success: 0x55ff55,
warn: 0xffff55,
danger: 0xff5555,
},
overlay: OverlayColours {
page: 0x101010,
backdrop_muted: 0xa0a0a0,
ink: 0xffffff,
ink_soft: 0xe0e0e0,
rule: 0x303030,
accent: 0xffffff,
on_accent: 0x000000,
},
sizes: Sizes {
caption: 12.0,
label: 13.0,
ui: 14.0,
body: 15.0,
title: 28.0,
display: 44.0,
micro: 11.0,
radius: 0.0,
radius_plate: 0.0,
control_large: 40.0,
},
fonts: None,
};
static STYLE: OnceLock<MenuStyle> = OnceLock::new();
/// Give the menus their look: call it once at startup. A second call keeps the first look and
/// returns false.
pub fn install(style: MenuStyle) -> bool {
STYLE.set(style).is_ok()
}
/// The look given to [`install`], or [`NEUTRAL`].
#[inline]
pub fn get() -> &'static MenuStyle {
STYLE.get().unwrap_or(&NEUTRAL)
}
/// `0xRRGGBB` and an alpha in the HUD's colour encoding (`0xAABBGGRR`).
pub const fn hud_colour(rgb: u32, alpha: u8) -> u32 {
(alpha as u32) << 24 | (rgb & 0xff) << 16 | (rgb >> 8 & 0xff) << 8 | (rgb >> 16 & 0xff)
}
+23 -65
View File
@@ -339,6 +339,7 @@ impl Opt {
Opt::SharePresence => "options.sharePresence",
Opt::DirectionalAudio => "options.directionalAudio",
Opt::MusicToast => "options.musicToast",
Opt::Page(page, row) => pages::slider_caption(page, row),
}
}
pub(crate) fn kind(self, s: &Settings) -> Kind {
@@ -402,6 +403,7 @@ impl Opt {
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
Opt::PadTriggerThreshold => unit(s.controller.trigger_threshold, 0.1, 0.9),
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
Opt::Page(page, row) => pages::slider_kind(page, row),
_ => Kind::Cycle,
}
}
@@ -878,6 +880,7 @@ impl Opt {
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
}),
Opt::Page(page, row) => pages::slider_text(page, row),
_ => Text::empty(),
}
}
@@ -952,6 +955,7 @@ impl Opt {
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
Opt::PadTriggerThreshold => s.controller.trigger_threshold = step(10.0, 90.0) / 100.0,
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
Opt::Page(page, row) => pages::slide(page, row, v),
_ => {}
}
if self.affects_preset() {
@@ -1331,6 +1335,8 @@ pub enum Page {
ModProject,
/// A native mod's settings page: its index in `mods.installed`.
NativeMod(usize),
/// A page a crate registered (`horizon_hooks::pages`): its index in `OptionsUi::pages`.
Added(usize),
/// Horizon's shader packs, either kind (`shader_pack_list`).
ShaderPacks,
/// The picked pack's options: the shader screen (`shader_ui`) at the pack's main screen.
@@ -1390,6 +1396,8 @@ enum Act {
/// A native mod's key (mod, key) waits for a key; a button of its page.
NativeKey(usize, usize),
NativeButton(usize, usize),
/// A registered page's row: (page, row).
PageRow(usize, usize),
OpenModFolder,
/// Browse mods: the page of listing `.0` of `mod_browser`.
ModProject(usize),
@@ -1617,7 +1625,8 @@ pub struct OptionsUi {
/// Key Binds: the mapping waiting for a key ([`NATIVE_KEY`]: a native
/// mod's, `selected_native_key`).
pub selected_key: Option<&'static str>,
/// The native mod's key waiting: (mod, key) in `mods.installed`.
/// The native mod's key waiting: (mod, key) in `mods.installed`, or a registered page's
/// (page, row) ([`pages::PAGE_KEY`]).
selected_native_key: Option<(usize, usize)>,
/// Bindings: the action waiting for a pad button.
pub selected_pad_action: Option<&'static str>,
@@ -1861,25 +1870,6 @@ fn wide_extras(s: &Settings, width: impl Fn(&str) -> f32) -> Vec<crate::extras::
})
.collect()
}
/// What a button opening one of Horizon's pages says on hover.
fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
Some(match page {
Page::Horizon => "horizon.pages.settings.tooltip",
Page::Studio => "horizon.pages.studio.tooltip",
Page::Graphics => "horizon.pages.graphics.tooltip",
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
Page::Controller => "horizon.pages.controller.tooltip",
Page::Extras => "horizon.pages.extras.tooltip",
Page::Pvp => "horizon.pages.pvp.tooltip",
Page::Agent => "horizon.pages.agent.tooltip",
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
Page::Mods => "horizon.pages.mods.tooltip",
Page::SplitSetup => "horizon.pages.split_setup.tooltip",
Page::Music => "horizon.pages.music.tooltip",
_ => return None,
})
}
/// Rows of an OptionsList: (big?, options) or a header.
enum Row {
Big(Opt),
@@ -1899,14 +1889,17 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
return None;
};
let key = opt.tooltip_key(s);
return lang::has(&key).then(|| tr(&key));
let Opt::Page(page, row) = opt else {
return lang::has(&key).then(|| tr(&key));
};
return pages::tooltip(*page, *row);
};
let mut tip = None;
if let Act::Extra(item) = act {
tip = Some(Text::literal(item.description_text()));
}
if let Act::Push(page) = act
&& let Some(text) = horizon_page_tooltip(*page)
&& let Some(text) = pages::horizon_page_tooltip(*page)
{
tip = Some(tr(text));
}
@@ -1926,6 +1919,7 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
}
match act {
Act::Cycle(opt) if !opt.available() => Some(tr("horizon.options.unavailable")),
Act::PageRow(page, row) => pages::tooltip(*page, *row),
Act::ToggleMod(i) => match s.mods.installed.get(*i) {
Some(installed) => {
let description = installed
@@ -2367,6 +2361,7 @@ impl OptionsUi {
Page::ModBrowser => "horizon.mods.browse.title",
Page::ModProject => "horizon.mods.browse.project",
Page::NativeMod(_) => "Mod Settings",
Page::Added(i) => pages::title(i),
Page::ShaderPacks => "horizon.pages.shader_packs",
Page::ShaderOptions => "horizon.pages.shader_options",
Page::PackOptions => "horizon.pack.options.title",
@@ -2647,47 +2642,7 @@ impl OptionsUi {
flush(&mut rows, &mut section);
rows
}
Page::Horizon => {
// Two to a row, as vanilla's OptionsList.addSmall lays out
// buttons; each opens one of Horizon's settings pages. Each page has
// one way in: graphics and shader packs are Video Settings' (the graphics
// screen), the controller is Controls', mods are the Options screen's.
let open =
|key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
vec![
Row::Buttons(
open("horizon.pages.open.extras", Page::Extras),
Some(open("horizon.pages.open.pvp", Page::Pvp)),
),
Row::Buttons(
open(
"horizon.pages.open.split_setup",
if self.split_guest {
Page::SplitProfiles
} else {
Page::SplitSetup
},
),
Some(open("horizon.pages.open.studio", Page::Studio)),
),
// No AI hides the AI Agent page and everything AI.
Row::Buttons(
self.extra_button(s, crate::extras::Item::DiscordPresence),
Some(if s.extras.no_ai {
self.extra_button(s, crate::extras::Item::NoAi)
} else {
open("horizon.pages.open.agent", Page::Agent)
}),
),
]
.into_iter()
.chain(
(!s.extras.no_ai).then(|| {
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
}),
)
.collect()
}
Page::Horizon => self.horizon_rows(s),
Page::Agent => {
let mut rows = vec![Row::Buttons(
self.extra_button(s, crate::extras::Item::AgentControl),
@@ -2970,6 +2925,7 @@ impl OptionsUi {
Page::ModBrowser => self.mod_browser_rows(),
Page::ModProject => self.mod_project_rows(s),
Page::NativeMod(i) => self.native_rows(s, i),
Page::Added(i) => self.added_rows(i),
_ => {
let _ = s;
Vec::new()
@@ -4238,6 +4194,7 @@ impl OptionsUi {
}
Act::NativeKey(i, k) => self.wait_native_key(i, k),
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
Act::PageRow(p, r) => events.extend(self.page_act(p, r, self.shift)),
Act::OpenModFolder => events.push(Event::OpenModFolder),
Act::ModProject(i) => {
if let Some(listing) = self.mod_browser.listings.get(i) {
@@ -4741,8 +4698,8 @@ impl OptionsUi {
let Some(name) = self.selected_key.take() else {
return Vec::new();
};
if name == NATIVE_KEY {
return self.bind_native_key(s, value);
if name == NATIVE_KEY || name == pages::PAGE_KEY {
return self.bind_native_key(s, name, value);
}
if !s.extras.set_key(name, value.clone()) {
s.keys.set(name, &value);
@@ -5126,6 +5083,7 @@ impl OptionsUi {
}
Act::NativeKey(i, k) => self.wait_native_key(i, k),
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
Act::PageRow(p, r) => events.extend(self.page_act(p, r, shift)),
Act::OpenModFolder => events.push(Event::OpenModFolder),
Act::ModProject(i) => {
if let Some(listing) = self.mod_browser.listings.get(i) {
@@ -1,8 +1,230 @@
//! The pages crates add to the options screens: a native mod's settings page (its keys and
//! buttons). Kept out of options_ui.rs, which may not grow (tools/check_file_size.py).
//! buttons), and the pages crates above the engine register (`horizon_hooks::pages`, docs/layers.md
//! "Platform registries") with their entries on Horizon Settings. Kept out of options_ui.rs, which
//! may not grow (tools/check_file_size.py).
use super::*;
use horizon_hooks::pages as added;
/// `selected_key` while a registered page's key waits (`selected_native_key`: page, row).
pub(super) const PAGE_KEY: &str = "horizon.pageKey";
#[cfg(test)]
thread_local! {
/// A test's own pages, instead of the game's registered ones: each test runs on its thread.
static TEST_PAGES: std::cell::Cell<Option<&'static [&'static dyn added::Page]>> =
const { std::cell::Cell::new(None) };
}
/// The pages crates registered.
fn registered() -> &'static [&'static dyn added::Page] {
#[cfg(test)]
if let Some(pages) = TEST_PAGES.get() {
return pages;
}
added::engine::all()
}
/// A registered page's title (a translation key).
pub(super) fn title(page: usize) -> &'static str {
registered().get(page).map_or("", |p| p.title())
}
/// Row `r` of a registered page, as it is now.
fn row(page: usize, r: usize) -> Option<added::Row> {
let mut rows = Vec::new();
registered().get(page)?.rows(&mut rows);
rows.into_iter().nth(r)
}
/// A registered page's slider ([`Opt::Page`]): its caption (a translation key).
pub(super) fn slider_caption(page: usize, r: usize) -> &'static str {
match row(page, r) {
Some(added::Row::Slider { label, .. }) => label,
_ => "",
}
}
/// A registered page's slider: its position.
pub(super) fn slider_kind(page: usize, r: usize) -> Kind {
match row(page, r) {
Some(added::Row::Slider { value, .. }) => Kind::Slider(value.clamp(0.0, 1.0)),
_ => Kind::Cycle,
}
}
/// A registered page's slider: the value its message shows after the caption.
pub(super) fn slider_text(page: usize, r: usize) -> Text {
match row(page, r) {
Some(added::Row::Slider { text, .. }) => label_text(text),
_ => Text::empty(),
}
}
/// A registered page's slider moved.
pub(super) fn slide(page: usize, r: usize, value: f32) {
if let Some(registered) = registered().get(page) {
registered.act(r, added::Act::Slide(value));
}
}
/// What a registered page's row says on hover.
pub(super) fn tooltip(page: usize, r: usize) -> Option<Text> {
registered().get(page)?.tooltip(r).map(label_text)
}
/// What a button opening one of Horizon's pages says on hover.
pub(super) fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
Some(match page {
Page::Horizon => "horizon.pages.settings.tooltip",
Page::Studio => "horizon.pages.studio.tooltip",
Page::Graphics => "horizon.pages.graphics.tooltip",
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
Page::Controller => "horizon.pages.controller.tooltip",
Page::Extras => "horizon.pages.extras.tooltip",
Page::Pvp => "horizon.pages.pvp.tooltip",
Page::Agent => "horizon.pages.agent.tooltip",
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
Page::Mods => "horizon.pages.mods.tooltip",
Page::SplitSetup => "horizon.pages.split_setup.tooltip",
Page::Music => "horizon.pages.music.tooltip",
_ => return None,
})
}
fn label_text(label: added::Label) -> Text {
match label {
added::Label::Key(key) => tr(key),
added::Label::Value(value) => Text::literal(value),
}
}
impl OptionsUi {
/// Horizon Settings: its pages, then the pages crates registered for it.
pub(super) fn horizon_rows(&self, s: &Settings) -> Vec<Row> {
// Two to a row, as vanilla's OptionsList.addSmall lays out
// buttons; each opens one of Horizon's settings pages. Each page has
// one way in: graphics and shader packs are Video Settings' (the graphics
// screen), the controller is Controls', mods are the Options screen's.
let open = |key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
let mut rows: Vec<Row> = vec![
Row::Buttons(
open("horizon.pages.open.extras", Page::Extras),
Some(open("horizon.pages.open.pvp", Page::Pvp)),
),
Row::Buttons(
open(
"horizon.pages.open.split_setup",
if self.split_guest {
Page::SplitProfiles
} else {
Page::SplitSetup
},
),
Some(open("horizon.pages.open.studio", Page::Studio)),
),
// No AI hides the AI Agent page and everything AI.
Row::Buttons(
self.extra_button(s, crate::extras::Item::DiscordPresence),
Some(if s.extras.no_ai {
self.extra_button(s, crate::extras::Item::NoAi)
} else {
open("horizon.pages.open.agent", Page::Agent)
}),
),
]
.into_iter()
.chain(
(!s.extras.no_ai)
.then(|| Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)),
)
.collect();
self.added_entries(&mut rows);
rows
}
/// The entries of the pages registered for Horizon Settings, two to a row like the engine's.
fn added_entries(&self, rows: &mut Vec<Row>) {
let mut entries = registered()
.iter()
.enumerate()
.filter(|(_, page)| page.place() == added::Place::HorizonSettings)
.map(|(i, page)| button(0.0, 0.0, 150.0, tr(page.title()), Act::Push(Page::Added(i))));
while let Some(first) = entries.next() {
rows.push(Row::Buttons(first, entries.next()));
}
}
/// A registered page's rows, asked of it each time the page is drawn.
pub(super) fn added_rows(&self, page: usize) -> Vec<Row> {
let Some(registered) = registered().get(page) else {
return Vec::new();
};
let mut rows = Vec::new();
registered.rows(&mut rows);
rows.into_iter()
.enumerate()
.map(|(r, row)| self.added_row(page, r, row))
.collect()
}
fn added_row(&self, page: usize, r: usize, row: added::Row) -> Row {
let act = Act::PageRow(page, r);
let wide = |label: Text| Row::Buttons(button(0.0, 0.0, 310.0, label, act.clone()), None);
match row {
added::Row::Heading(key) => Row::Heading(key.to_owned()),
added::Row::Note(text) => Row::Note(text),
added::Row::Toggle { label, on } => wide(generic(label, on_off(on))),
added::Row::Cycle { label, value } => wide(generic(label, label_text(value))),
added::Row::Slider { .. } => Row::Big(Opt::Page(page, r)),
added::Row::Button { label } => wide(tr(label)),
added::Row::Key { label, key } => {
let mut message = keybinds::display_name(&key);
if self.selected_key == Some(PAGE_KEY)
&& self.selected_native_key == Some((page, r))
{
message = Text::literal("> ")
.append(message.with_color(text::WHITE))
.append(" <")
.with_color(text::YELLOW);
}
let caption = button(0.0, 0.0, 150.0, tr(label), Act::None);
let change = button(0.0, 0.0, 150.0, message, act);
Row::Buttons(inactive(caption), Some(change))
}
// A row kind newer than this engine: shown empty.
_ => Row::Note(String::new()),
}
}
/// A registered page's row was pressed: a key row waits for a key, any other row hears it.
pub(super) fn page_act(&mut self, page: usize, r: usize, shift: bool) -> Vec<Event> {
let Some(registered) = registered().get(page) else {
return Vec::new();
};
let mut rows = Vec::new();
registered.rows(&mut rows);
match rows.get(r) {
Some(added::Row::Key { .. }) => {
self.selected_key = Some(PAGE_KEY);
self.selected_native_key = Some((page, r));
Vec::new()
}
Some(_) => {
registered.act(
r,
if shift {
added::Act::PressBack
} else {
added::Act::Press
},
);
vec![Event::Changed]
}
None => Vec::new(),
}
}
/// A native mod's page: its name, its keys as Key Binds rows (the
/// player's choice, else the default), then its buttons.
pub(super) fn native_rows(&self, s: &Settings, i: usize) -> Vec<Row> {
@@ -46,9 +268,21 @@ impl OptionsUi {
self.selected_native_key = Some((i, k));
}
/// The key pressed for the native mod's key that waited for one.
pub(super) fn bind_native_key(&mut self, s: &mut Settings, value: String) -> Vec<Event> {
/// The key pressed for the native mod's key, or the registered page's ([`PAGE_KEY`]), that
/// waited for one.
pub(super) fn bind_native_key(
&mut self,
s: &mut Settings,
name: &str,
value: String,
) -> Vec<Event> {
let (i, k) = self.selected_native_key.take().unwrap_or_default();
if name == PAGE_KEY {
if let Some(page) = registered().get(i) {
page.act(k, added::Act::Bind(value));
}
return vec![Event::Changed];
}
let Some(m) = s.mods.installed.get(i) else {
return Vec::new();
};
@@ -60,3 +294,171 @@ impl OptionsUi {
vec![Event::Changed]
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
/// A page with a heading, a switch, a number that steps either way, a key and a volume.
struct Sample {
on: Mutex<bool>,
step: Mutex<i32>,
key: Mutex<String>,
volume: Mutex<f32>,
}
impl added::Page for Sample {
fn id(&self) -> &'static str {
"test.sample"
}
fn title(&self) -> &'static str {
"test.sample.title"
}
fn place(&self) -> added::Place {
added::Place::HorizonSettings
}
fn rows(&self, rows: &mut Vec<added::Row>) {
rows.push(added::Row::Heading("test.sample.heading"));
rows.push(added::Row::Toggle {
label: "test.sample.switch",
on: *self.on.lock().unwrap(),
});
rows.push(added::Row::Cycle {
label: "test.sample.step",
value: added::Label::Value(self.step.lock().unwrap().to_string()),
});
rows.push(added::Row::Key {
label: "test.sample.key",
key: self.key.lock().unwrap().clone(),
});
let volume = *self.volume.lock().unwrap();
rows.push(added::Row::Slider {
label: "test.sample.volume",
value: volume,
text: added::Label::Value(format!("{}%", (volume * 100.0).round())),
});
}
fn act(&self, row: usize, act: added::Act) {
match (row, act) {
(1, added::Act::Press) => {
let mut on = self.on.lock().unwrap();
*on = !*on;
}
(2, added::Act::Press) => *self.step.lock().unwrap() += 1,
(2, added::Act::PressBack) => *self.step.lock().unwrap() -= 1,
(3, added::Act::Bind(key)) => *self.key.lock().unwrap() = key,
(4, added::Act::Slide(v)) => *self.volume.lock().unwrap() = v,
other => panic!("row and act the page doesn't expect: {other:?}"),
}
}
fn tooltip(&self, row: usize) -> Option<added::Label> {
(row == 1).then_some(added::Label::Key("test.sample.switch.tooltip"))
}
}
static SAMPLE: Sample = Sample {
on: Mutex::new(false),
step: Mutex::new(0),
key: Mutex::new(String::new()),
volume: Mutex::new(0.0),
};
static PAGES: [&dyn added::Page; 1] = [&SAMPLE];
/// Clicks the active button that does `act`, with Shift held or not.
fn press(ui: &mut OptionsUi, s: &mut Settings, act: Act, shift: bool) -> Vec<Event> {
let size = (480.0, 240.0);
let (widgets, ..) = ui.layout(s, size.0, size.1);
let (x, y) = widgets
.iter()
.find_map(|w| match &w.kind {
WKind::Button {
act: a,
active: true,
..
} if *a == act => Some((w.x + 1.0, w.y + 1.0)),
_ => None,
})
.unwrap_or_else(|| panic!("no button does {act:?}"));
ui.shift = shift;
let atlas = super::super::tests::atlas();
ui.click(s, &atlas, x, y, size, [480, 240], false, false)
}
#[test]
fn a_registered_page_opens_from_horizon_settings_and_hears_its_rows() {
let mut s = Settings::default();
TEST_PAGES.set(Some(&PAGES));
let mut ui = OptionsUi::default();
ui.show(Page::Horizon);
press(&mut ui, &mut s, Act::Push(Page::Added(0)), false);
assert_eq!(ui.page(), Page::Added(0));
// Rows: the heading (0), the switch (1), the step (2), the key (3).
let events = press(&mut ui, &mut s, Act::PageRow(0, 1), false);
assert_eq!(
events.last(),
Some(&Event::Changed),
"the settings get saved"
);
assert!(*SAMPLE.on.lock().unwrap());
press(&mut ui, &mut s, Act::PageRow(0, 2), false);
press(&mut ui, &mut s, Act::PageRow(0, 2), true);
press(&mut ui, &mut s, Act::PageRow(0, 2), true);
assert_eq!(*SAMPLE.step.lock().unwrap(), -1, "Shift steps back");
// A key row waits for the next key, as on Key Binds, and the page gets its name.
press(&mut ui, &mut s, Act::PageRow(0, 3), false);
let atlas = super::super::tests::atlas();
let events = ui.key(&mut s, &atlas, winit::keyboard::KeyCode::KeyZ, None);
assert_eq!(events, [Event::Changed]);
assert_eq!(*SAMPLE.key.lock().unwrap(), "key.keyboard.z");
assert_eq!(ui.selected_key, None);
// The volume is one of the screen's own sliders: dragged to its middle, then let go.
let size = (480.0, 240.0);
let (widgets, ..) = ui.layout(&s, size.0, size.1);
let slider = widgets
.iter()
.find(|w| {
matches!(
w.kind,
WKind::Slider {
opt: Opt::Page(0, 4),
..
}
)
})
.expect("the volume's slider");
let (x, y) = (slider.x + slider.w / 2.0, slider.y + 1.0);
ui.click(&mut s, &atlas, x, y, size, [480, 240], false, false);
let volume = *SAMPLE.volume.lock().unwrap();
assert!((0.45..0.55).contains(&volume), "{volume}");
assert!(ui.release().contains(&Event::Changed));
// The slider shows the page's value once let go.
let (widgets, ..) = ui.layout(&s, size.0, size.1);
let shown = widgets.iter().find_map(|w| match w.kind {
WKind::Slider {
opt: Opt::Page(0, 4),
value,
..
} => Some(value),
_ => None,
});
assert_eq!(shown, Some(volume));
// The page's tooltip for its switch.
let switch = widgets
.iter()
.find(|w| {
matches!(
&w.kind,
WKind::Button {
act: Act::PageRow(0, 1),
..
}
)
})
.expect("the switch");
assert_eq!(
tooltip_text(&s, switch).map(|t| t.plain()),
Some(tr("test.sample.switch.tooltip").plain())
);
}
}
+1 -67
View File
@@ -31,6 +31,7 @@ mod native;
pub mod polytone;
mod polytone_gpu;
mod polytone_model;
mod spawn;
pub use native::{ParticleController, ParticleUpdate};
/// `ParticleOptions` with the parameters of the option classes used here.
@@ -2026,73 +2027,6 @@ impl ParticleEngine {
(eye[0] - pos[0]).powi(2) + (eye[1] - pos[1]).powi(2) + (eye[2] - pos[2]).powi(2) <= 9.0
})
}
/// `ClientPacketListener.handleParticleEvent` for `ServerLevel.sendParticles`.
pub fn send_particles(
&mut self,
options: &ParticleOptions,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: u32,
) {
if count == 0 {
let v = [speed * spread[0], speed * spread[1], speed * spread[2]];
self.add(options, at, v);
return;
}
for _ in 0..count {
let r = &mut self.random;
let p = [
at[0] + r.next_gaussian() * spread[0],
at[1] + r.next_gaussian() * spread[1],
at[2] + r.next_gaussian() * spread[2],
];
let v = [
r.next_gaussian() * speed,
r.next_gaussian() * speed,
r.next_gaussian() * speed,
];
self.add(options, p, v);
}
}
/// `ParticleEngine.createParticle`: always spawned, but `ParticleEngine.add`
/// drops a spore blossom air particle once `ParticleLimit.SPORE_BLOSSOM` is full.
pub fn create(&mut self, options: &ParticleOptions, p: [f64; 3], v: [f64; 3]) {
if let Some(requests) = &mut self.created_requests {
requests.push((options.clone(), p, v));
return;
}
let seed = self.seeds.next_long();
if let Some(mut particle) = self.make(options, p, v, seed) {
*self.made.entry(options.ty.name()).or_default() += 1;
// Resource packs' Polytone particle modifiers (#11486), once as it spawns.
if let Some(registry) = &self.polytone
&& registry.modifies_particles()
{
let id = match &options.mod_particle {
Some(name) => name.clone(),
None => format!("minecraft:{}", options.ty.name()),
};
polytone::modify(registry, &id, options.source.as_deref(), &mut particle);
}
// The provider has drawn, so rejecting here keeps vanilla's RNG order.
// shortcut: counts live plus queued particles by scanning instead of
// vanilla's counted map; use a counter if spore spawning shows in profiles.
if particle.ty == ParticleType::SporeBlossomAir
&& self.particles.len() + self.pending.len() >= SPORE_BLOSSOM_LIMIT
&& self
.particles
.iter()
.chain(self.pending.iter())
.filter(|q| q.ty == ParticleType::SporeBlossomAir)
.count()
>= SPORE_BLOSSOM_LIMIT
{
return;
}
self.pending.push(particle);
}
}
/// How many particles of each type were made so far.
pub fn made(&self) -> &std::collections::BTreeMap<&'static str, u64> {
&self.made
@@ -0,0 +1,73 @@
//! Where particles come into the engine: a server's particle packet and a single particle made
//! where the game asks for one.
use super::*;
impl ParticleEngine {
/// `ClientPacketListener.handleParticleEvent` for `ServerLevel.sendParticles`.
pub fn send_particles(
&mut self,
options: &ParticleOptions,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: u32,
) {
if count == 0 {
let v = [speed * spread[0], speed * spread[1], speed * spread[2]];
self.add(options, at, v);
return;
}
for _ in 0..count {
let r = &mut self.random;
let p = [
at[0] + r.next_gaussian() * spread[0],
at[1] + r.next_gaussian() * spread[1],
at[2] + r.next_gaussian() * spread[2],
];
let v = [
r.next_gaussian() * speed,
r.next_gaussian() * speed,
r.next_gaussian() * speed,
];
self.add(options, p, v);
}
}
/// `ParticleEngine.createParticle`: always spawned, but `ParticleEngine.add`
/// drops a spore blossom air particle once `ParticleLimit.SPORE_BLOSSOM` is full.
pub fn create(&mut self, options: &ParticleOptions, p: [f64; 3], v: [f64; 3]) {
if let Some(requests) = &mut self.created_requests {
requests.push((options.clone(), p, v));
return;
}
let seed = self.seeds.next_long();
if let Some(mut particle) = self.make(options, p, v, seed) {
*self.made.entry(options.ty.name()).or_default() += 1;
// Resource packs' Polytone particle modifiers (#11486), once as it spawns.
if let Some(registry) = &self.polytone
&& registry.modifies_particles()
{
let id = match &options.mod_particle {
Some(name) => name.clone(),
None => format!("minecraft:{}", options.ty.name()),
};
polytone::modify(registry, &id, options.source.as_deref(), &mut particle);
}
// The provider has drawn, so rejecting here keeps vanilla's RNG order.
// shortcut: counts live plus queued particles by scanning instead of
// vanilla's counted map; use a counter if spore spawning shows in profiles.
if particle.ty == ParticleType::SporeBlossomAir
&& self.particles.len() + self.pending.len() >= SPORE_BLOSSOM_LIMIT
&& self
.particles
.iter()
.chain(self.pending.iter())
.filter(|q| q.ty == ParticleType::SporeBlossomAir)
.count()
>= SPORE_BLOSSOM_LIMIT
{
return;
}
self.pending.push(particle);
}
}
}
@@ -337,6 +337,13 @@ impl Settings {
.and_then(|mode| self.video_modes.iter().position(|m| *m == mode))
.map_or(-1, |i| i as i32)
}
/// Loads only the crates' settings stores (`horizon_hooks::settings`) from the files next to
/// `path`, into the seat whose turn it is (`horizon_hooks::seat`): a splitscreen player whose
/// settings were made from P1's rather than loaded.
pub fn load_stores(path: &Path) {
let dir = path.parent();
horizon_hooks::settings::engine::load(&mut |file| fs::read_to_string(dir?.join(file)).ok());
}
pub fn load(path: &Path) -> Self {
let mut horizon = path
.parent()
@@ -864,6 +871,8 @@ pub enum Opt {
PadGameplayGuide,
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
ModelPart(usize),
/// A slider of a page a crate registered (`horizon_hooks::pages`): (page, row).
Page(usize, usize),
/// 3D skin layers: the player's outer layer as extruded slabs.
SkinLayers3d,
MainHand,
@@ -237,17 +237,17 @@ impl<P: Copy, S: Copy> Lines<P, S> {
pub(crate) fn backdrop(batch: &mut HudBatch, atlas: &HudAtlas, in_world: bool) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
style::fill(batch, atlas, [0.0, 0.0, w, h], style::veil());
style::fill(batch, atlas, [0.0, 0.0, w, h], style::VEIL);
}
/// The mark and the screen's name over the search field.
pub(crate) fn mark(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, title: &str) {
let (x, y) = (cols.side_x, cols.side_y);
style::fill(batch, atlas, [x, y + 2.0, 6.0, 6.0], style::accent());
style::fill(batch, atlas, [x, y + 2.0, 6.0, 6.0], style::ACCENT);
let horizon = Text::literal("Horizon");
batch.text_styled(atlas, &horizon, x + 10.0, y, style::WHITE, true);
let title = Text::literal(title);
batch.text_styled(atlas, &title, x, y + 10.0, style::muted(), true);
batch.text_styled(atlas, &title, x, y + 10.0, style::MUTED, true);
}
/// A page in the page list: the open page on a plate with the accent's bar beside it, the
@@ -273,13 +273,13 @@ pub(crate) fn page_tab(
crate::widgets::target_as(rect, crate::widgets::Kind::Tab(open));
}
if open {
style::fill(batch, atlas, rect, style::open());
style::fill(batch, atlas, [px, py, 2.0, ph], style::accent());
style::fill(batch, atlas, rect, style::OPEN);
style::fill(batch, atlas, [px, py, 2.0, ph], style::ACCENT);
}
let color = match (open || hover, has) {
(true, _) => style::WHITE,
(false, true) => style::soft(),
(false, false) => style::muted(),
(false, true) => style::SOFT,
(false, false) => style::MUTED,
};
let ty = py + ((ph - 8.0) / 2.0).floor();
let name = Text::literal(style::fit(atlas, name, pw - 12.0));
@@ -312,12 +312,12 @@ pub(crate) fn search_field(
search.draw(batch, atlas, None);
if search.value.is_empty() && !focused {
let hint = Text::literal(search.message.clone());
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::muted(), true);
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::MUTED, true);
}
if !search.value.is_empty() {
let hover = ui::hovered(cursor, clear[0], clear[1], clear[2], clear[3]);
crate::widgets::widget_cursor(hover, true);
let color = if hover { style::WHITE } else { style::muted() };
let color = if hover { style::WHITE } else { style::MUTED };
let cy = y + ((h - 8.0) / 2.0).floor();
ui::centered_text(
batch,
@@ -344,7 +344,7 @@ pub(crate) fn plate(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, head
batch,
atlas,
[px + 8.0, py + 24.0, pw - 16.0, 1.0],
style::head_rule(),
style::HEAD_RULE,
);
}
@@ -383,9 +383,9 @@ pub(crate) fn list<P: Copy, S: Copy>(
}
let Some(setting) = line.setting else {
let (name, color, dy) = match line.group {
Some(("", _)) => (page_name(line.page), style::muted(), 3.0),
Some((name, _)) => (lang::get(name), style::muted(), 3.0),
None => (page_name(line.page), style::accent(), 4.0),
Some(("", _)) => (page_name(line.page), style::MUTED, 3.0),
Some((name, _)) => (lang::get(name), style::MUTED, 3.0),
None => (page_name(line.page), style::ACCENT, 4.0),
};
let name = Text::literal(name);
batch.text_styled(atlas, &name, cols.list_x + 2.0, y + dy, color, true);
@@ -393,7 +393,7 @@ pub(crate) fn list<P: Copy, S: Copy>(
};
if !after_name {
let rule = [cols.list_x + 8.0, y, cols.list_w - 16.0, 1.0];
style::fill(batch, atlas, rule, style::row_rule());
style::fill(batch, atlas, rule, style::ROW_RULE);
}
row(batch, setting, y);
}
@@ -404,7 +404,7 @@ pub(crate) fn list<P: Copy, S: Copy>(
&none,
cols.list_x + 2.0,
cols.top + 4.0,
style::muted(),
style::MUTED,
true,
);
}
@@ -499,7 +499,7 @@ pub(crate) fn row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32
let active = live && (hover || focused || held);
if active {
let lit = [rect[0] + 2.0, y + 1.0, rect[2] - 4.0, style::ROW - 2.0];
style::fill(batch, atlas, lit, style::hover());
style::fill(batch, atlas, lit, style::HOVER);
}
crate::widgets::widget_cursor(hover, live);
// The control socket's widget tree, the narrator and a controller's focus.
@@ -523,8 +523,8 @@ pub(crate) fn row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32
if live {
crate::widgets::target_as(rect, kind);
}
let color = if live { style::WHITE } else { style::muted() };
let soft = if live { style::soft() } else { style::muted() };
let color = if live { style::WHITE } else { style::MUTED };
let soft = if live { style::SOFT } else { style::MUTED };
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
let lit = live && (hover || held);
let left = rect[0] + style::INSET;
@@ -611,7 +611,7 @@ fn lit(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, _]: [f32; 4]) {
batch,
atlas,
[x + 2.0, y + 1.0, w - 4.0, style::ROW - 2.0],
style::hover(),
style::HOVER,
);
}
@@ -644,7 +644,7 @@ fn read_row(
let room = rect[2] - 2.0 * style::INSET - name_w - 8.0;
let value = style::fit(atlas, value, room);
let vx = rect[0] + rect[2] - style::INSET - atlas.font.width(&value);
batch.text_styled(atlas, &Text::literal(value), vx, ty, style::soft(), true);
batch.text_styled(atlas, &Text::literal(value), vx, ty, style::SOFT, true);
if focused {
style::ring(
batch,
@@ -749,7 +749,7 @@ fn pad_row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32, view:
batch.text_styled(atlas, &yellow(close), right.ceil(), ty, style::WHITE, true);
}
let left = rect[0] + style::INSET;
let color = if live { style::WHITE } else { style::muted() };
let color = if live { style::WHITE } else { style::MUTED };
let shown = Text::literal(style::fit(atlas, name, chip[0] - 6.0 - left));
batch.text_styled(atlas, &shown, left, ty, color, true);
if focused {
@@ -799,7 +799,7 @@ fn side_lines(
];
crate::widgets::record(line, rect, false);
let shown = Text::literal(style::fit(atlas, line, rect[2]));
batch.text_styled(atlas, &shown, rect[0], rect[1], style::muted(), true);
batch.text_styled(atlas, &shown, rect[0], rect[1], style::MUTED, true);
}
}
/// A line under the page list: its inset, and its height.
@@ -881,7 +881,7 @@ fn heading(batch: &mut HudBatch, atlas: &HudAtlas, text: &str, [x, y, w, _]: [f3
let rect = [x + SIDE_INSET, y + HEADING_Y, w - 2.0 * SIDE_INSET, 8.0];
crate::widgets::record(text, rect, false);
let shown = Text::literal(style::fit(atlas, text, rect[2]));
batch.text_styled(atlas, &shown, rect[0], rect[1], style::muted(), true);
batch.text_styled(atlas, &shown, rect[0], rect[1], style::MUTED, true);
}
/// Vanilla's scroller, inside the panel's right edge, sized as `ui::Scroll` drags it.
@@ -1896,8 +1896,8 @@ impl<S: Screen> Shell<S> {
// The row's page is open: lit as the list's open page is.
if list.lit == Some(*shown) {
let row @ [x, y, _, h] = strip.area;
style::fill(batch, atlas, row, style::open());
style::fill(batch, atlas, [x, y, 2.0, h], style::accent());
style::fill(batch, atlas, row, style::OPEN);
style::fill(batch, atlas, [x, y, 2.0, h], style::ACCENT);
}
tabs::draw(batch, atlas, strip);
}
@@ -2134,14 +2134,7 @@ impl<S: Screen> Shell<S> {
crate::widgets::narratable(|| crate::widgets::Narratable::text(row.name.clone(), rect));
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
let name = Text::literal(style::fit(atlas, &row.name, rect[2] - 2.0 * style::INSET));
batch.text_styled(
atlas,
&name,
rect[0] + style::INSET,
ty,
style::muted(),
true,
);
batch.text_styled(atlas, &name, rect[0] + style::INSET, ty, style::MUTED, true);
return;
}
let over = |[x, y, w, h]: [f32; 4]| pointer.is_some_and(|at| ui::hovered(at, x, y, w, h));
@@ -2247,7 +2240,7 @@ impl<S: Screen> Shell<S> {
// Labelled for the widget tree: what the strip says is what a test or an agent reads.
crate::widgets::record(about, cols.dock, false);
let color = if why.is_some() {
style::muted()
style::MUTED
} else {
style::GREY
};
@@ -304,19 +304,19 @@ impl StudioUi {
// Card background & highlight
if is_selected {
style::fill(batch, atlas, rect, style::open());
style::fill(batch, atlas, rect, style::OPEN);
} else if is_hovered {
style::fill(batch, atlas, rect, style::hover());
style::fill(batch, atlas, rect, style::HOVER);
} else {
style::well(batch, atlas, rect);
}
let border_color = if is_selected {
style::accent()
style::ACCENT
} else if is_hovered {
style::WHITE
} else {
style::row_rule()
style::ROW_RULE
};
style::ring(batch, atlas, rect, border_color);
+4 -3
View File
@@ -122,12 +122,13 @@ controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-duals
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
# client is best effort: CI builds only the default.
horizon-extras = ["discord", "friends"]
horizon-extras = ["horizon-client-lib/horizon-extras", "discord", "friends"]
# 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"]
# Play with Friends: sharing a world behind an invite code (game/friends.rs). Joining one is
# horizon-friends' `game` side, which horizon-official registers (`horizon_hooks::tunnel`).
friends = ["dep:horizon-friends"]
# 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
+4 -3
View File
@@ -17,8 +17,8 @@ pub use horizon_client_render::{
containers, controller_ui, crash, custom_sky, datapack, dynamic_light, effects, environment,
extras, fast_hash, fluid_render, fog, font, gfx, graphics, graphics_style, graphics_ui,
hdr_calibration, hud, hud_kit, hud_layout, hud_overlays, inventory_effects, java_text,
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, menu_style, mesher,
minimap, mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap,
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
options_file, options_fixes, options_ui, overlay, pack_browser, pack_formats, pack_hooks,
pack_palette, pack_screen, packs, pad_cursor, pad_glyph, par, particles, poi_sections,
polytone_colors, polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit,
@@ -70,7 +70,6 @@ pub mod falling_block;
pub mod fire_render;
pub mod first_person;
pub mod flatgen;
pub mod friends_screen;
pub mod furnace;
pub mod game_mode_switcher;
pub mod game_rules_screen;
@@ -103,6 +102,8 @@ pub mod native_data;
pub mod net;
pub mod nether_portal;
pub mod no_resources;
#[cfg(feature = "horizon-extras")]
pub mod official_servers;
pub mod offline_name;
pub mod old_world;
pub mod osk;
+1 -1
View File
@@ -84,7 +84,7 @@ fn slime_at(world: &World, facts: &BlockFacts, position: BlockPos) -> bool {
mod fluids;
mod item_fall;
mod item_no_physics;
pub use fluids::{fluid_flow, update_entity_fluids, water_clip};
pub use fluids::{fall_reset_hit, fluid_flow, update_entity_fluids, water_clip};
#[cfg(test)]
mod drop_motion_tests {
@@ -45,6 +45,30 @@ pub fn fluid_flow(world: &World, fluids: &FluidResources, pos: [i32; 3]) -> [f64
let flow = flow_at(&mut scene, state, at).unwrap_or(Vec3::ZERO);
[flow.x, flow.y, flow.z]
}
/// `Entity.move`'s fall-reset ray along a move from `from`, up to 8 blocks:
/// `ClipContext.Fluid.WATER` (the water's shape) and
/// `ClipContext.Block.FALLDAMAGE_RESETTING` (a block `tagged` says is a reset
/// block clips as a full block). Both shapes are queried for every visited
/// block, the water's first (its first query fixes the shared cached shape);
/// either one hitting ends the ray.
pub fn fall_reset_hit(
world: &World,
fluids: &FluidResources,
tagged: impl Fn(BlockPos) -> bool,
from: Vec3,
moved: Vec3,
) -> bool {
let to = from.add_vector(moved.normalize().scale(moved.length().min(8.0)));
let hit = horizon_core::geometry::traverse_blocks(from, to, 1_000_000, |p| {
let water = water_clip(world, fluids, from, to, p);
let reset = tagged(p)
&& horizon_core::geometry::VoxelShape::block()
.clip(from, to, p)
.is_some();
(water || reset).then_some(())
});
matches!(hit, Ok(Some(())))
}
/// `ClipContext.Fluid.WATER` in a fall-reset ray through `p`: the water's
/// shape from the shared `FlowingFluid.shapes` cache (the first query of a
/// fluid state fixes its height), as the player's own ray reads it.
+2 -2
View File
@@ -233,7 +233,7 @@ fn is_animal(id: &str) -> bool {
use crate::mobs::persistent_anger;
/// The single player's UUID (`offline_uuid("Player")`): the entity the
/// neutral mobs are angry at.
fn player_uuid() -> [i32; 4] {
pub(crate) fn player_uuid() -> [i32; 4] {
let u = horizon_protocol::crypto::offline_uuid("Player");
[
(u >> 96) as u32 as i32,
@@ -1297,7 +1297,7 @@ pub fn mob_to_nbt(
crate::mobs::nautilus::to_nbt(m, map);
}
"minecraft:sulfur_cube" => crate::mobs::sulfur_cube::to_nbt(m, map),
"minecraft:bee" => crate::mobs::bee::to_nbt(m, map),
"minecraft:bee" => crate::mobs::bee::to_nbt(m, map, &mut uuid_of),
"minecraft:sniffer" => crate::mobs::sniffer::to_nbt(m, map),
"minecraft:wither" => {
map.insert("Invul".into(), Tag::Int(m.extra.counter));
+3 -3
View File
@@ -13,7 +13,6 @@ pub fn main_with(mods: crate::mod_api::Mods) {
log::warn!("Trait mods already installed; ignoring the second list");
}
horizon_threads::configure_allocator();
crate::horizon_look::give();
// OptiFine's and Polytone's pack formats (platform, docs/layers.md), before any pack loads.
horizon_client::pack_formats::install();
// horizon-extras (docs/forking.md): without the feature, no Horizon Settings or Mods page.
@@ -24,8 +23,9 @@ pub fn main_with(mods: crate::mod_api::Mods) {
horizon_client::telemetry::mark_start();
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
crash::on_report(horizon_client::telemetry::keep_crash);
// Crates above the engine registered their hooks before this (horizon-official's `main`);
// none registers after this point.
// Crates above the engine registered their hooks before this (horizon-official's `main`),
// then the Horizon parts still in this crate; none registers after this point.
game::player_hooks::register();
horizon_hooks::engine::seal();
horizon_client::spans::startup::init(std::time::Instant::now());
horizon_client::spans::trace::start_from_env();
+119 -1
View File
@@ -1,9 +1,11 @@
//! HUD boxes in the corners: the minimap's place and mods' panels, small
//! boxes in GUI pixels stacked per corner (`docs/extras.md`).
//! boxes in GUI pixels stacked per corner (`docs/extras.md`), and the layers crates above the
//! engine register (`horizon_hooks::hud`).
use crate::{
extras::Corner,
hud::{HudAtlas, HudBatch, Rect, rgba},
};
use horizon_hooks::hud;
/// The gap between an overlay and the screen edge or the next overlay.
const MARGIN: f32 = 2.0;
@@ -125,6 +127,68 @@ pub fn draw_lines(
}
}
/// Draws the HUD layers crates registered, after the extras' and mods' boxes, which they stack
/// with. `shown`: whether each corner of `Corner::ALL` shows (the debug screen covers some).
pub fn draw_layers(
layers: &[&dyn hud::Layer],
batch: &mut HudBatch,
atlas: &HudAtlas,
corners: &mut Corners,
shown: [bool; 4],
) {
let mut canvas = Canvas {
batch,
atlas,
corners,
shown,
};
for layer in layers {
layer.draw(&mut canvas);
}
}
struct Canvas<'a> {
batch: &'a mut HudBatch,
atlas: &'a HudAtlas,
corners: &'a mut Corners,
shown: [bool; 4],
}
/// `0xAARRGGBB` as the batch's colour.
fn argb_color(argb: u32) -> u32 {
let [a, r, g, b] = argb.to_be_bytes();
rgba(r, g, b, a)
}
impl hud::Canvas for Canvas<'_> {
fn size(&self) -> (f32, f32) {
(self.batch.width, self.batch.height)
}
fn tr(&self, key: &str) -> String {
crate::lang::get(key)
}
fn text_width(&self, text: &str) -> f32 {
HudBatch::text_width(self.atlas, text)
}
fn text(&mut self, x: f32, y: f32, text: &str, argb: u32, shadow: bool) {
self.batch
.text(self.atlas, text, x, y, argb_color(argb), shadow);
}
fn fill(&mut self, x: f32, y: f32, w: f32, h: f32, argb: u32) {
self.batch.fill(self.atlas, x, y, w, h, argb_color(argb));
}
fn place(&mut self, corner: hud::Corner, w: f32, h: f32) -> Option<(f32, f32)> {
let corner = match corner {
hud::Corner::TopLeft => Corner::TopLeft,
hud::Corner::TopRight => Corner::TopRight,
hud::Corner::BottomLeft => Corner::BottomLeft,
hud::Corner::BottomRight => Corner::BottomRight,
};
let i = Corner::ALL.iter().position(|c| *c == corner)?;
self.shown[i].then(|| self.corners.place(corner, w, h))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -140,4 +204,58 @@ mod tests {
assert_eq!(c.place(Corner::BottomRight, 50.0, 10.0), (348.0, 274.0));
assert_eq!(c.place(Corner::BottomLeft, 10.0, 10.0), (2.0, 288.0));
}
/// Draws a box of one line in a corner and keeps where it went.
struct Panel {
corner: hud::Corner,
at: std::sync::Mutex<Vec<Option<(f32, f32)>>>,
}
impl hud::Layer for Panel {
fn draw(&self, canvas: &mut dyn hud::Canvas) {
let w = canvas.text_width("12 CPS") + 4.0;
let at = canvas.place(self.corner, w, 12.0);
if let Some((x, y)) = at {
canvas.fill(x, y, w, 12.0, 0x7800_0000);
canvas.text(x + 2.0, y + 2.0, "12 CPS", 0xFFFF_FFFF, true);
}
self.at.lock().unwrap().push(at);
}
}
#[test]
fn layers_stack_with_the_extras_and_skip_hidden_corners() {
let atlas =
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
let mut batch = HudBatch::new([800, 600]);
let (width, height) = (batch.width, batch.height);
let mut corners = Corners::new(width, height, 0.0);
// The minimap already took the top right.
corners.place(Corner::TopRight, 64.0, 64.0);
let right = Panel {
corner: hud::Corner::TopRight,
at: Default::default(),
};
let left = Panel {
corner: hud::Corner::TopLeft,
at: Default::default(),
};
let layers: [&dyn hud::Layer; 2] = [&right, &left];
let empty = batch.vertices.len();
draw_layers(
&layers,
&mut batch,
&atlas,
&mut corners,
[false, true, true, true],
);
let (_, y) = right.at.lock().unwrap()[0].expect("the top right shows");
assert_eq!(y, 2.0 + 64.0 + 2.0, "under the minimap");
assert_eq!(
*left.at.lock().unwrap(),
[None],
"the debug screen covers the top left"
);
assert!(batch.vertices.len() > empty, "the shown box is drawn");
}
}
+4 -39
View File
@@ -373,6 +373,7 @@ mod place_feature_tests;
mod play_input;
mod play_keys;
mod player_equip;
pub(crate) mod player_hooks;
mod player_spawn;
mod pointer_capture;
#[cfg(test)]
@@ -650,7 +651,7 @@ enum Screen {
/// WorldOptionsScreen (game/lan_screen.rs).
WorldOptions,
/// Play with Friends, from the pause menu's Friends button
/// (`friends_screen`, game/friends.rs).
/// (game/friends.rs, game/friends/screen.rs).
Friends,
/// `SocialInteractionsScreen` (`social_screen`, game/social.rs).
Social,
@@ -5490,34 +5491,6 @@ impl Game<'_> {
);
cancel
}
/// The ticks the frame shows (animated textures, the HUD, a shader pack's clock): the
/// client's, or under `HORIZON_STILL` (`FixedStep::still`) the world's game time, which
/// moves only while the world runs.
pub(super) fn visual_ticks(&self) -> u64 {
if self.clock.still() {
self.world.game_time.max(0) as u64
} else {
self.ticks
}
}
/// The clock of the animated textures: `Minecraft.runTick` ticks the `TextureManager`
/// only while the level runs normally. Under `HORIZON_STILL` it is the world's ticks.
pub(super) fn texture_clock(&self) -> u64 {
if self.clock.still() {
self.visual_ticks()
} else {
self.texture_ticks
}
}
/// `Util.getMillis` for what the frame shows (the world border's scroll, the hearts' blink,
/// the glint's under the switch): under `HORIZON_STILL`, the world's ticks in milliseconds.
pub(super) fn visual_millis(&self) -> i64 {
if self.clock.still() {
self.visual_ticks() as i64 * 50
} else {
self.started.elapsed().as_millis() as i64
}
}
/// `PresenceHandler.getPresenceStatus`: what an all-sharing player is doing, for
/// the friends service's `updatePresence`.
fn presence_status(&self) -> horizon_client::social::PresenceStatus {
@@ -7520,7 +7493,6 @@ impl Game<'_> {
self.spawn_phantoms();
self.tick_advancements();
self.tick_player_model();
self.tick_emotes();
Ok(())
}
@@ -12720,16 +12692,9 @@ pub fn run_with() -> Result<()> {
if !options.offscreen && !synchronous_startup(&options) {
let version = horizon_client::telemetry::version();
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version));
let player_dir = horizon_client::logging::player_dir();
// horizon-extras: the official server is Horizon's (docs/forking.md).
if cfg!(feature = "horizon-extras")
&& let Err(e) = horizon_client::multiplayer::add_official_server(&player_dir)
{
log::warn!("could not add the official server to servers.dat: {e}");
}
if let Err(e) = horizon_client::multiplayer::withdraw_partner_servers(&player_dir) {
log::warn!("could not take the withdrawn partner server out of servers.dat: {e}");
}
#[cfg(feature = "horizon-extras")]
horizon_client::official_servers::update(&horizon_client::logging::player_dir());
}
mods::register_components(&vanilla_root)?;
mods::register_standalone_models();
+8 -16
View File
@@ -686,23 +686,15 @@ impl Game<'_> {
if fall == 0.0 || length < 1.0 {
return fall;
}
let to = from.add_vector(moved.normalize().scale(moved.length().min(8.0)));
let reset = &self.res.motion().fall_reset;
let fluids = &self.res.motion().fluids;
let hit = horizon_core::geometry::traverse_blocks(from, to, 1_000_000, |p| {
// A tagged block clips as a full block, not its collider.
let name = &self.res.facts().get(self.world.block(p.x, p.y, p.z)).name;
if reset.contains(&**name) && VoxelShape::block().clip(from, to, p).is_some() {
return Some(());
}
// The water's shape, through the shared fluid shape cache.
horizon_client::entities::water_clip(&self.world, fluids, from, to, p).then_some(())
});
if matches!(hit, Ok(Some(()))) {
0.0
} else {
fall
}
let hit = horizon_client::entities::fall_reset_hit(
&self.world,
&self.res.motion().fluids,
|p| reset.contains(&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name),
from,
moved,
);
if hit { 0.0 } else { fall }
}
/// `AbstractBoat.checkFallDamage` after a move that went `moved_y` (with
/// `b` already where it landed): on the ground the fall resets; a move
+63 -3
View File
@@ -70,6 +70,23 @@ pub(super) enum BookScreen {
lectern: Option<(i32, i32, i32)>,
},
}
impl BookScreen {
/// `BookSignScreen`'s constructor: its title box opens with the title
/// kept from the last time, which Cancel stored (`titleValue`).
fn begin_signing(&mut self) {
if let Self::Edit {
signing,
title,
title_edit,
..
} = self
{
*signing = true;
title_edit.set_value(title.as_str());
title_edit.reset_blink();
}
}
}
/// The pages a book screen reads: a written book's components (so their
/// click events stay), else a writable book's text.
fn view_pages(data: Option<&StackData>) -> Vec<Text> {
@@ -988,7 +1005,7 @@ impl Game<'_> {
title,
..
}) if !title.trim().is_empty() => self.save_book(true),
Some(BookScreen::Edit { signing, .. }) => *signing = true,
Some(book @ BookScreen::Edit { .. }) => book.begin_signing(),
_ => return Err("not a book and quill".into()),
},
Some("done") => match self.book.as_mut() {
@@ -1190,8 +1207,8 @@ impl Game<'_> {
self.turn_page(false);
} else if inside(w / 2.0 - 100.0, 196.0, 98.0, 20.0) {
click(self);
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
*signing = true;
if let Some(book) = self.book.as_mut() {
book.begin_signing();
}
} else if inside(w / 2.0 + 2.0, 196.0, 98.0, 20.0) {
click(self);
@@ -1809,3 +1826,46 @@ mod lectern_reach_tests {
);
}
}
#[cfg(test)]
mod sign_tests {
use super::{BookScreen, book_page_box, book_title_box};
/// `BookSignScreen`'s constructor sets its title box to `titleValue`, what
/// Cancel kept; reopening the sign screen shows the title typed before,
/// not an empty box beside a stale `title`.
#[test]
fn the_sign_screen_opens_with_the_title_it_kept() {
let mut book = BookScreen::Edit {
slot: 0,
pages: vec!["page".into()],
page: 0,
signing: false,
title: "My Book".into(),
title_edit: book_title_box(),
page_edit: book_page_box(),
};
assert_eq!(book_title_value(&book), "");
book.begin_signing();
let BookScreen::Edit {
signing,
title,
title_edit,
..
} = &book
else {
panic!("the book stays open");
};
assert!(signing);
assert_eq!(title.as_str(), "My Book");
assert_eq!(title_edit.value, "My Book");
assert_eq!(title_edit.cursor, 7);
}
fn book_title_value(book: &BookScreen) -> String {
match book {
BookScreen::Edit { title_edit, .. } => title_edit.value.clone(),
BookScreen::View { .. } => String::new(),
}
}
}
+16 -14
View File
@@ -42,8 +42,8 @@ impl Reply {
}
pub(super) fn duration(millis: i64) -> Text {
use horizon_chat_filter::DurationUnit;
let (count, unit) = horizon_chat_filter::duration_parts(millis);
use horizon_server_files::duration::{DurationUnit, duration_parts};
let (count, unit) = duration_parts(millis);
let key = match (unit, count == 1) {
(DurationUnit::Minute, true) => "horizon.moderation.duration.minute",
(DurationUnit::Minute, false) => "horizon.moderation.duration.minutes",
@@ -93,7 +93,7 @@ pub(super) const MUTE_USAGES: [&str; 4] = [
fn mute_time(words: &[&str]) -> (Option<i64>, usize) {
match words
.get(2)
.and_then(|w| horizon_chat_filter::parse_duration(w))
.and_then(|w| horizon_server_files::duration::parse_duration(w))
{
Some(ms) => (Some(ms), 3),
None => (None, 2),
@@ -133,7 +133,7 @@ impl Game<'_> {
count: u32,
action: Action,
) {
let now = horizon_chat_filter::now_millis();
let now = horizon_server_files::duration::now_millis();
let summary = format!(
"[chat-filter] Refused {name}'s {} (hit {count}: {})",
place.name(),
@@ -281,7 +281,7 @@ impl Game<'_> {
let reason = |from: usize| (words.len() > from).then(|| words[from..].join(" "));
match words {
["mutelist"] => {
let now = horizon_chat_filter::now_millis();
let now = horizon_server_files::duration::now_millis();
let mutes = host.hub.mutes();
if mutes.is_empty() {
return vec![Reply::translated("horizon.moderation.mutelist.empty", &[])];
@@ -319,13 +319,15 @@ impl Game<'_> {
let (time, from) = mute_time(words);
vec![self.mute(host, name, time, reason(from), by)]
}
["tempmute", name, time, ..] => match horizon_chat_filter::parse_duration(time) {
Some(ms) => vec![self.mute(host, name, Some(ms), reason(3), by)],
None => vec![Reply::translated(
"horizon.moderation.invalid_duration",
&[Text::literal(*time)],
)],
},
["tempmute", name, time, ..] => {
match horizon_server_files::duration::parse_duration(time) {
Some(ms) => vec![self.mute(host, name, Some(ms), reason(3), by)],
None => vec![Reply::translated(
"horizon.moderation.invalid_duration",
&[Text::literal(*time)],
)],
}
}
["mute", ..] => vec![Reply::translated("horizon.moderation.usage.mute", &[])],
["tempmute", ..] => vec![Reply::translated("horizon.moderation.usage.tempmute", &[])],
["unmute", ..] => vec![Reply::translated("horizon.moderation.usage.unmute", &[])],
@@ -353,10 +355,10 @@ impl Game<'_> {
let Some((uuid, name)) = user else {
return Reply::no_player();
};
let until = time.map(|ms| horizon_chat_filter::now_millis() + ms);
let until = time.map(|ms| horizon_server_files::duration::now_millis() + ms);
let what = time.map_or_else(
|| "until unmuted".into(),
|ms| format!("for {}", horizon_chat_filter::duration_text(ms)),
|ms| format!("for {}", horizon_server_files::duration::duration_text(ms)),
);
let why = reason
.as_deref()
+5 -5
View File
@@ -1567,11 +1567,11 @@ impl Game<'_> {
"streaming": self.streaming.stages().into_iter().map(|(k, v)| (k.to_string(), v.into())).collect::<serde_json::Map<_, _>>(),
"dirty_light": self.world.dirty_light.len(),
"renderer": renderer,
"display": self.window.as_deref().and_then(horizon_client::telemetry::monitor).map(|m| json!({
"width": m.size().width,
"height": m.size().height,
"scale": m.scale_factor(),
"refresh_hz": m.refresh_rate_millihertz().map(|r| r.div_ceil(1000)),
"display": self.window.as_deref().and_then(horizon_client::window::display).map(|d| json!({
"width": d.width,
"height": d.height,
"scale": d.scale,
"refresh_hz": d.refresh_hz,
})),
"uptime_seconds": self.started.elapsed().as_secs_f64(),
"server": self.remote_status(),
@@ -623,3 +623,6 @@ fn stack_drop_requires_ctrl_even_with_sprint_latched() {
}
}
}
#[path = "play_ping_tests.rs"]
mod play_ping_tests;
+354 -195
View File
@@ -20,9 +20,10 @@ use horizon_cosmetics::follow::Follow;
use horizon_cosmetics::prefs::{self, Others};
use horizon_cosmetics::trail::{Budget, Emitter};
use horizon_cosmetics::{Item, Slot};
use horizon_hooks::players::{Frame, Kind, PlayerModel, PlayerModels};
use std::cell::{Cell, RefCell};
use std::sync::Arc;
use std::sync::mpsc::{Receiver, channel};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
/// Horizon's mark before a Horizon player's name, over their name tag and in the player list.
@@ -55,7 +56,8 @@ impl Baked {
#[derive(Default)]
pub(super) struct Cosmetics {
client: Option<horizon_cosmetics::Cosmetics>,
/// The hooks' client (`Seat`), taken once it started.
client: Option<Arc<horizon_cosmetics::Cosmetics>>,
/// By the item's address; each entry holds its item, so the address isn't reused.
baked: RefCell<FxHashMap<usize, Baked>>,
/// Items drawn whose texture isn't in the atlas yet.
@@ -65,16 +67,6 @@ pub(super) struct Cosmetics {
poses: RefCell<(Poses, Poses)>,
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins wear them too.
local: bool,
/// Show my cosmetics on its way to the service.
mine: AccountSync,
/// Show me as a Horizon player: sent at start, on each change and every hour while on.
listed: AccountSync,
/// Whether the signed-in account owns a cosmetic.
owner: OwnerCheck,
/// Which other players play an emote (`horizon_cosmetics::Feed`), started with the client.
feed: Option<horizon_cosmetics::Feed>,
/// My emote on its way to the service.
emote_out: EmoteOut,
/// Companions, trails and auras.
fx: RefCell<Fx>,
/// Emotes: those the wheel offers and the one playing (`cosmetics/emotes.rs`).
@@ -449,115 +441,302 @@ pub(super) fn enabled() -> bool {
cfg!(feature = "horizon-extras") && horizon_cosmetics::enabled()
}
/// What the cosmetics' hooks keep for one splitscreen player (`horizon_hooks::seat`): the
/// service's client, the emote feed and what the account tells the service.
#[derive(Default)]
struct Seat {
client: Option<Arc<horizon_cosmetics::Cosmetics>>,
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`).
local: bool,
/// Show my cosmetics on its way to the service.
mine: AccountSync,
/// Show me as a Horizon player: sent at start, on each change and every hour while on.
listed: AccountSync,
/// Whether the signed-in account owns a cosmetic.
owner: OwnerCheck,
/// Which other players play an emote (`horizon_cosmetics::Feed`), started with the client.
feed: Option<horizon_cosmetics::Feed>,
/// My emote on its way to the service.
emote_out: EmoteOut,
/// What the game's settings and the player's emote say (`Game::cosmetics_say`).
said: Said,
/// Kept between ticks so a tick allocates nothing: the player models in view.
models: Vec<PlayerModel>,
}
/// What the game tells the hooks before each call, until the settings move here (spec #14091
/// step 5a) and the player's emote with them (step 4).
#[derive(Default)]
struct Said {
mine: bool,
listed: bool,
/// Other players' cosmetics are shown (not "name tags only", not hidden).
others_shown: bool,
/// My emote while it plays: its id and whether it loops.
playing: Option<(String, bool)>,
/// A folder's first emote, which mannequins play.
first: Option<Arc<Item>>,
}
/// One [`Seat`] per splitscreen player.
static SERVICES: Mutex<Vec<Seat>> = Mutex::new(Vec::new());
/// Runs `f` on this seat's [`Seat`]. Never called inside itself.
fn service<R>(f: impl FnOnce(&mut Seat) -> R) -> R {
let seat = horizon_hooks::seat();
let mut all = SERVICES.lock().unwrap_or_else(PoisonError::into_inner);
if all.len() <= seat {
all.resize_with(seat + 1, Seat::default);
}
f(&mut all[seat])
}
/// The cosmetics' hooks, registered when [`enabled`].
pub(super) struct Hooks;
pub(super) static HOOKS: Hooks = Hooks;
impl horizon_hooks::Hooks for Hooks {
fn frame(&self, _frame: &Frame) {
service(Seat::frame);
}
fn tick(&self, models: &mut dyn PlayerModels) {
service(|s| s.tick(models));
}
}
impl Seat {
/// Once a frame: starts the service's client on first use, asks whether the account owns a
/// cosmetic, and tells the service what the account shows.
fn frame(&mut self) {
if self.client.is_none() {
let (client, local) = Cosmetics::client();
self.client = Some(Arc::new(client));
self.local = local;
// Others' emotes come from the service only (a local folder has no one playing).
if !local {
self.feed = Some(horizon_cosmetics::Feed::new(http()));
}
}
// Item files from a folder (the art, the bench): shown as to an owner, nothing asked.
if self.local {
self.owner.owns = true;
return;
}
// Whether the account owns a cosmetic: what the last run learnt, then the service.
let o = &mut self.owner;
if !o.started {
o.started = true;
o.owns = account()
.is_some_and(|(_, uuid, home)| prefs::owned(&home, &uuid).unwrap_or(false));
}
if let Some(done) = o.asking.as_ref().and_then(|rx| rx.try_recv().ok()) {
o.asking = None;
match done {
Ok(owns) => (o.owns, o.known, o.retry) = (owns, true, None),
Err(_) => o.retry = backoff(o.retry),
}
}
// One sign-in at a time: Mojang keeps one joined server id per account.
let due = !o.known && o.retry.is_none_or(|(at, _)| Instant::now() >= at);
if due && o.asking.is_none() && self.mine.sending.is_none() && self.listed.sending.is_none()
{
let (tx, rx) = channel();
o.asking = Some(rx);
std::thread::Builder::new()
.name("Cosmetics account".into())
.spawn(move || {
let _ = tx.send(check_owner());
})
.ok();
}
// Show my cosmetics: sent once per change of the wish, then retried until confirmed.
// Show me as a Horizon player: also said again every hour while on.
let every = horizon_cosmetics::account::PRESENCE_EVERY;
self.mine.settle(None);
self.listed.settle(Some(every));
// One sign-in at a time, as for the ownership check above.
let busy = self.owner.asking.is_some()
|| self.mine.sending.is_some()
|| self.listed.sending.is_some();
let said = &self.said;
if let Some(done) = self
.emote_out
.sending
.as_ref()
.and_then(|rx| rx.try_recv().ok())
{
self.emote_out.sending = None;
match done {
Ok(()) => self.emote_out.retry = None,
Err(_) => {
self.emote_out.retry = backoff(self.emote_out.retry);
self.emote_out.sent = None;
}
}
}
// My emote: only while I show myself as a Horizon player (playing one says so).
let playing = said.playing.as_ref();
let looping = playing.is_some_and(|(_, looping)| *looping);
let wish = playing.map(|(id, _)| id.as_str()).filter(|_| said.listed);
let o = &mut self.emote_out;
let changed = o.sent.as_ref().map(Option::as_deref) != Some(wish);
let again = looping && o.again.is_some_and(|at| Instant::now() >= at);
let waited = o.retry.is_none_or(|(at, _)| Instant::now() >= at);
// Nothing sent yet and nothing playing: nothing to say (a failed send forgets what
// was sent: the service ends a one-shot by itself, a loop within a minute).
let quiet = wish.is_none() && o.sent.is_none();
let emote_due = !quiet && waited && (changed || again);
// One sign-in at a time, the emote's included.
if busy || o.sending.is_some() {
} else if emote_due {
let wish = wish.map(str::to_owned);
o.sent = Some(wish.clone());
o.again = looping.then(|| Instant::now() + LOOP_AGAIN);
let (tx, rx) = channel();
o.sending = Some(rx);
let kept = o.kept.clone();
std::thread::Builder::new()
.name("Cosmetics emote".into())
.spawn(move || {
let _ = tx.send(send_emote(&kept, wish.as_deref()));
})
.ok();
} else if self.mine.due(said.mine) {
self.mine.start(said.mine, send_mine);
} else if self.listed.due(said.listed) {
self.listed.start(said.listed, send_listed);
}
}
/// Once a tick: names the Horizon players in view to the emote feed and plays what it says
/// they play on their models; one that moved ends theirs here at once. Others' emotes show
/// with "Other players' cosmetics" on Shown only; otherwise nothing is asked.
fn tick(&mut self, models: &mut dyn PlayerModels) {
let mut seen = std::mem::take(&mut self.models);
seen.clear();
models.for_each(&mut |m| {
if m.kind != Kind::Own {
seen.push(m);
}
});
self.play_others(models, &mut seen);
self.models = seen;
}
fn play_others(&self, models: &mut dyn PlayerModels, view: &mut Vec<PlayerModel>) {
let shown = self.said.others_shown;
// Items from a folder (the art, the bench): every mannequin plays its first emote, again
// and again.
if self.local && shown {
let Some(first) = &self.said.first else {
return;
};
for m in view.iter().filter(|m| m.kind == Kind::Mannequin) {
let age = m.age as f32;
let now = emotes::pose_playing(models.body_pose(m.id));
if now.is_none_or(|p| p.at(age).is_none()) {
let playing = horizon_cosmetics::Playing {
item: first.clone(),
// Out of step, so a crowd doesn't move as one.
start: age - (m.id % 40) as f32,
stop: None,
};
emotes::set_model_playing(models, m.id, Some(playing));
}
}
return;
}
let (Some(feed), Some(client)) = (&self.feed, &self.client) else {
return;
};
if !shown {
feed.ask([]);
for m in view.iter() {
emotes::set_model_playing(models, m.id, None);
}
return;
}
let mut frame = client.frame();
view.retain(|m| m.kind == Kind::Player && m.uuid.is_some_and(|u| frame.horizon(u)));
drop(frame);
feed.ask(view.iter().filter_map(|m| m.uuid));
let now = Instant::now();
for m in view.iter() {
let Some(uuid) = m.uuid else { continue };
let seen = feed.playing(uuid);
let age = m.age as f32;
let mut next = emotes::pose_playing(models.body_pose(m.id)).cloned();
match (seen, &mut next) {
(Some(seen), Some(p)) if Arc::ptr_eq(&p.item, &seen.item) => {
if m.moved && p.stop.is_none() {
p.stop = Some(age);
}
}
(Some(seen), slot) if !m.moved => {
let ticks = now.duration_since(seen.started).as_secs_f32() * 20.0;
*slot = Some(horizon_cosmetics::Playing {
item: seen.item,
start: age - ticks,
stop: None,
});
}
(_, Some(p)) if p.stop.is_none() => p.stop = Some(age),
_ => {}
}
emotes::set_model_playing(models, m.id, next.filter(|p| p.at(age).is_some()));
}
}
}
impl Game<'_> {
/// Once a frame, before drawing: starts the service's client on first use and puts the
/// Hands the hooks what the game's settings and the player's emote say (allocating only when
/// the emote changes).
pub(super) fn cosmetics_say(&mut self) {
if !enabled() {
return;
}
let first = if self.cosmetics.local {
service(|s| s.said.first.is_none())
.then(|| self.owned_emotes().first().cloned())
.flatten()
} else {
None
};
let extras = &self.settings.extras;
let playing = self.cosmetics.emotes.sending();
service(|s| {
let said = &mut s.said;
said.mine = extras.cosmetics_mine;
said.listed = extras.horizon_listed;
said.others_shown = extras.effective_others() == Others::Shown;
if said.playing.as_ref().map(|(id, l)| (id.as_str(), *l)) != playing {
said.playing = playing.map(|(id, l)| (id.to_owned(), l));
}
if first.is_some() {
said.first = first;
}
});
}
/// Once a frame, after the hooks: takes the service's client for drawing and puts the
/// textures the last frame wanted into the atlas.
pub(super) fn cosmetics_frame(&mut self) {
if !enabled() {
return;
}
let c = &mut self.cosmetics;
if c.client.is_none() {
let (client, local) = Cosmetics::client();
c.client = Some(client);
c.local = local;
// Others' emotes come from the service only (a local folder has no one playing).
if !local {
c.feed = Some(horizon_cosmetics::Feed::new(http()));
let owns = service(|s| {
if c.client.is_none() {
c.client = s.client.clone();
c.local = s.local;
}
}
s.owner.owns
});
// Read every frame: the settings button shows it, and it is never saved.
self.settings.extras.cosmetics_owner = owns;
c.frame.set(c.frame.get().wrapping_add(1));
c.fx.get_mut().frame(c.frame.get());
// Item files from a folder (the art, the bench): shown as to an owner, nothing asked.
if c.local {
c.owner.owns = true;
} else {
// Whether the account owns a cosmetic: what the last run learnt, then the service.
let o = &mut c.owner;
if !o.started {
o.started = true;
o.owns = account()
.is_some_and(|(_, uuid, home)| prefs::owned(&home, &uuid).unwrap_or(false));
}
if let Some(done) = o.asking.as_ref().and_then(|rx| rx.try_recv().ok()) {
o.asking = None;
match done {
Ok(owns) => (o.owns, o.known, o.retry) = (owns, true, None),
Err(_) => o.retry = backoff(o.retry),
}
}
// One sign-in at a time: Mojang keeps one joined server id per account.
let due = !o.known && o.retry.is_none_or(|(at, _)| Instant::now() >= at);
if due && o.asking.is_none() && c.mine.sending.is_none() && c.listed.sending.is_none() {
let (tx, rx) = channel();
o.asking = Some(rx);
std::thread::Builder::new()
.name("Cosmetics account".into())
.spawn(move || {
let _ = tx.send(check_owner());
})
.ok();
}
}
// Read every frame: the settings button shows it, and it is never saved.
self.settings.extras.cosmetics_owner = c.owner.owns;
// Show my cosmetics: sent once per change of the wish, then retried until confirmed.
// Show me as a Horizon player: also said again every hour while on.
if !c.local {
let every = horizon_cosmetics::account::PRESENCE_EVERY;
c.mine.settle(None);
c.listed.settle(Some(every));
// One sign-in at a time, as for the ownership check above.
let busy =
c.owner.asking.is_some() || c.mine.sending.is_some() || c.listed.sending.is_some();
let extras = &self.settings.extras;
if let Some(done) = c
.emote_out
.sending
.as_ref()
.and_then(|rx| rx.try_recv().ok())
{
c.emote_out.sending = None;
match done {
Ok(()) => c.emote_out.retry = None,
Err(_) => {
c.emote_out.retry = backoff(c.emote_out.retry);
c.emote_out.sent = None;
}
}
}
// My emote: only while I show myself as a Horizon player (playing one says so).
let playing = c.emotes.sending();
let looping = playing.as_ref().is_some_and(|(_, looping)| *looping);
let wish = playing.map(|(id, _)| id).filter(|_| extras.horizon_listed);
let o = &mut c.emote_out;
let changed = o.sent.as_ref() != Some(&wish);
let again = looping && o.again.is_some_and(|at| Instant::now() >= at);
let waited = o.retry.is_none_or(|(at, _)| Instant::now() >= at);
// Nothing sent yet and nothing playing: nothing to say (a failed send forgets what
// was sent: the service ends a one-shot by itself, a loop within a minute).
let quiet = wish.is_none() && o.sent.is_none();
let emote_due = !quiet && waited && (changed || again);
// One sign-in at a time, the emote's included.
if busy || o.sending.is_some() {
} else if emote_due {
o.sent = Some(wish.clone());
o.again = looping.then(|| Instant::now() + LOOP_AGAIN);
let (tx, rx) = channel();
o.sending = Some(rx);
let kept = o.kept.clone();
std::thread::Builder::new()
.name("Cosmetics emote".into())
.spawn(move || {
let _ = tx.send(send_emote(&kept, wish.as_deref()));
})
.ok();
} else if c.mine.due(extras.cosmetics_mine) {
c.mine.start(extras.cosmetics_mine, send_mine);
} else if c.listed.due(extras.horizon_listed) {
c.listed.start(extras.horizon_listed, send_listed);
}
}
let uploads = std::mem::take(&mut *c.uploads.borrow_mut());
for item in uploads {
let location = format!("cosmetics/{:x}", Arc::as_ptr(&item) as usize);
@@ -572,88 +751,6 @@ impl Game<'_> {
.retain(|_, b| Arc::strong_count(&b.item) > 1);
}
/// Once a tick: names the Horizon players in view to the emote feed and plays what it says
/// they play on their models; one that moved ends theirs here at once. Others' emotes show
/// with "Other players' cosmetics" on Shown only; otherwise nothing is asked.
pub(super) fn tick_others_emotes(&mut self) {
let shown = self.settings.extras.effective_others() == Others::Shown;
// Items from a folder (the art, the bench): every mannequin plays its first emote, again
// and again.
if self.cosmetics.local && shown {
let Some(first) = self.owned_emotes().first().cloned() else {
return;
};
for m in self.mobs.iter_mut() {
let age = m.age as f32;
if &*m.kind.id == "minecraft:mannequin"
&& emotes::playing(m).is_none_or(|p| p.at(age).is_none())
{
let playing = horizon_cosmetics::Playing {
item: first.clone(),
// Out of step, so a crowd doesn't move as one.
start: age - (m.id % 40) as f32,
stop: None,
};
emotes::set_playing(m, Some(playing));
}
}
return;
}
let (Some(feed), Some(client)) = (&self.cosmetics.feed, &self.cosmetics.client) else {
return;
};
if !shown {
feed.ask([]);
for m in self.mobs.iter_mut() {
emotes::set_playing(m, None);
}
return;
}
let mut frame = client.frame();
let mut horizon = Vec::new();
for m in self.mobs.iter() {
if &*m.kind.id == "minecraft:player"
&& let Some(uuid) = self.remote_player_uuid(m.id)
&& frame.horizon(uuid)
{
horizon.push((m.id, uuid));
}
}
drop(frame);
feed.ask(horizon.iter().map(|(_, u)| *u));
let now = Instant::now();
for (id, uuid) in horizon {
let seen = feed.playing(uuid);
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
continue;
};
let age = m.age as f32;
let moved = {
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
dx * dx + dz * dz > 1e-4
};
let mut next = emotes::playing(m).cloned();
match (seen, &mut next) {
(Some(seen), Some(p)) if Arc::ptr_eq(&p.item, &seen.item) => {
if moved && p.stop.is_none() {
p.stop = Some(age);
}
}
(Some(seen), slot) if !moved => {
let ticks = now.duration_since(seen.started).as_secs_f32() * 20.0;
*slot = Some(horizon_cosmetics::Playing {
item: seen.item,
start: age - ticks,
stop: None,
});
}
(_, Some(p)) if p.stop.is_none() => p.stop = Some(age),
_ => {}
}
emotes::set_playing(m, next.filter(|p| p.at(age).is_some()));
}
}
/// Asks the service which emotes the account owns, for the wheel: when it opens, at most
/// every 10 minutes (a folder's emotes need no asking; an offline account has none).
pub(super) fn refresh_owned_emotes(&mut self) {
@@ -669,7 +766,7 @@ impl Game<'_> {
}
let (tx, rx) = channel();
e.loading = Some(rx);
let kept = self.cosmetics.emote_out.kept.clone();
let kept = service(|s| s.emote_out.kept.clone());
std::thread::Builder::new()
.name("Cosmetics emotes owned".into())
.spawn(move || {
@@ -962,7 +1059,7 @@ impl Game<'_> {
mod tests {
use super::*;
use horizon_client::extras::Extras;
use std::sync::Mutex;
use horizon_client::mobs::anim::BodyPose;
/// A name tag style with a 1×1 mark.
const TAG: &str = r##"{"format": 1, "id": "tag", "slot": "name_tag", "parts": [],
@@ -1186,4 +1283,66 @@ mod tests {
assert_eq!(tag(top_of(&extras, patience)), VANILLA, "{others:?}");
}
}
/// Player models as the hooks see them, with the pose laid over each.
struct View(Vec<(PlayerModel, Option<Arc<dyn BodyPose>>)>);
impl PlayerModels for View {
fn connection(&self) -> u64 {
1
}
fn for_each(&self, f: &mut dyn FnMut(PlayerModel)) {
self.0.iter().for_each(|(m, _)| f(*m));
}
fn body_pose(&self, id: u32) -> Option<&Arc<dyn BodyPose>> {
self.0.iter().find(|(m, _)| m.id == id)?.1.as_ref()
}
fn set_body_pose(&mut self, id: u32, pose: Option<Arc<dyn BodyPose>>) {
if let Some((_, p)) = self.0.iter_mut().find(|(m, _)| m.id == id) {
*p = pose;
}
}
}
#[test]
fn with_a_folder_mannequins_play_its_first_emote_out_of_step_and_players_do_not() {
let item = Arc::new(
Item::parse(
br#"{"format": 1, "id": "up", "slot": "emote", "parts": [],
"emote": {"length": 20, "blend": 0, "tracks": [
{"part": "right_arm", "keys": [{"t": 10, "rotation": [0, 0, 90]}]}]}}"#,
)
.unwrap(),
);
let mut service = Seat {
local: true,
said: Said {
others_shown: true,
first: Some(item.clone()),
..Said::default()
},
..Seat::default()
};
let model = |id, kind, uuid| (PlayerModel::new(id, kind, uuid, 50, false), None);
let mut view = View(vec![
model(1, Kind::Own, None),
model(41, Kind::Mannequin, None),
model(2, Kind::Player, Some(7)),
]);
service.tick(&mut view);
let playing = emotes::pose_playing(view.0[1].1.as_ref()).expect("the mannequin plays");
assert!(Arc::ptr_eq(&playing.item, &item));
// Out of step by its id: 50 - 41 % 40.
assert_eq!(playing.start, 49.0);
assert!(view.0[0].1.is_none() && view.0[2].1.is_none());
// Still playing: the same pose stays.
let first = view.0[1].1.clone().unwrap();
service.tick(&mut view);
assert!(Arc::ptr_eq(&first, view.0[1].1.as_ref().unwrap()));
// Others' cosmetics not shown: a new mannequin plays nothing.
service.said.others_shown = false;
view.0.push(model(3, Kind::Mannequin, None));
service.tick(&mut view);
assert!(view.0[3].1.is_none());
}
}
@@ -10,6 +10,7 @@ use horizon_client::mobs::Mob;
use horizon_client::mobs::anim::BodyPose;
use horizon_cosmetics::account::OwnedEmote;
use horizon_cosmetics::{Item, Playing, Slot};
use horizon_hooks::players::PlayerModels;
use std::sync::Arc;
/// How far the player may drift (blocks) before the emote stops: a nudge, not a step.
@@ -37,10 +38,10 @@ pub(in crate::game) struct Emotes {
impl Emotes {
/// The emote to tell the service about, while it plays: its id and whether it loops.
pub(in crate::game) fn sending(&self) -> Option<(String, bool)> {
pub(in crate::game) fn sending(&self) -> Option<(&str, bool)> {
let p = self.playing.as_ref().filter(|p| p.stop.is_none())?;
let looping = p.item.emote.as_ref().is_some_and(|e| e.looping);
Some((p.item.id.clone(), looping))
Some((&p.item.id, looping))
}
}
@@ -59,23 +60,48 @@ impl BodyPose for EmotePose {
}
}
/// The emote `m` plays, if any.
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
let any: &dyn std::any::Any = &**m.emote.as_ref()?;
/// The emote a model's pose plays, if it is one.
pub(in crate::game) fn pose_playing(pose: Option<&Arc<dyn BodyPose>>) -> Option<&Playing> {
let any: &dyn std::any::Any = &**pose?;
any.downcast_ref::<EmotePose>().map(|e| &e.0)
}
/// Makes `m` play `next`, unless it plays that already (a new one allocates).
pub(in crate::game) fn set_playing(m: &mut Mob, next: Option<Playing>) {
let same = match (playing(m), &next) {
/// The emote `m` plays, if any.
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
pose_playing(m.emote.as_ref())
}
/// The pose that plays `next` where `now` is laid, unless it plays that already (None: keep
/// it; a new one allocates).
fn replaced(
now: Option<&Arc<dyn BodyPose>>,
next: Option<Playing>,
) -> Option<Option<Arc<dyn BodyPose>>> {
let same = match (pose_playing(now), &next) {
(None, None) => true,
(Some(a), Some(b)) => {
Arc::ptr_eq(&a.item, &b.item) && a.start == b.start && a.stop == b.stop
}
_ => false,
};
if !same {
m.emote = next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>);
(!same).then(|| next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>))
}
/// Makes `m` play `next`, unless it plays that already.
pub(in crate::game) fn set_playing(m: &mut Mob, next: Option<Playing>) {
if let Some(pose) = replaced(m.emote.as_ref(), next) {
m.emote = pose;
}
}
/// Makes model `id` play `next`, unless it plays that already.
pub(in crate::game) fn set_model_playing(
models: &mut dyn PlayerModels,
id: u32,
next: Option<Playing>,
) {
if let Some(pose) = replaced(models.body_pose(id), next) {
models.set_body_pose(id, pose);
}
}
@@ -229,7 +255,7 @@ impl Game<'_> {
}
}
/// Once a tick: my emote, then the other players'.
/// Once a tick: my emote (the others' are `cosmetics::Seat::tick`'s, a hook's).
pub(in crate::game) fn tick_emotes(&mut self) {
if let Some(done) = self
.cosmetics
@@ -250,7 +276,6 @@ impl Game<'_> {
}
}
self.tick_emote();
self.tick_others_emotes();
}
/// How far the camera stands back while an emote pulls it (1: the usual distance): it eases
+2 -783
View File
@@ -2,8 +2,10 @@
//! chunk-generation spawning (`NaturalSpawner`), mob events (attacks,
//! breeding, conversions, drops), projectiles, player interactions
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
mod mob_geometry;
mod projectile_world;
mod pufferfish_player;
mod spawn_eggs;
use super::{
Dim, Game, Hand, InteractTarget, entity_culling::EntityCullDecision,
@@ -7450,171 +7452,6 @@ impl Game<'_> {
}
Ok(true)
}
/// `Mob.checkAndHandleImportantInteractions` with a spawn egg of mob
/// `index`'s own kind (`SpawnEggItem.spawnOffspringFromSpawnEgg`): a baby
/// at the mob's feet, taking the parent's variant as `getBreedOffspring`
/// does, and one egg used. False (PASS) when the mob has no baby form.
fn spawn_egg_offspring(&mut self, index: usize) -> bool {
let Some(stack) = self.held().cloned() else {
return false;
};
let parent = &self.mobs[index];
if !parent.alive()
|| !horizon_client::mobs::finalize::spawn_egg_is_for(&stack.item, &parent.kind.id)
|| !horizon_client::mobs::species::can_be_a_baby(parent.species())
// WanderingTrader.getBreedOffspring returns null: no offspring, the
// click carries on to trading.
|| parent.species() == Species::WanderingTrader
{
return false;
}
let (kind, at) = (
parent.kind.id.clone(),
[parent.position.x, parent.position.y, parent.position.z],
);
let (species, variant, color) =
(parent.species(), parent.extra.variant, parent.extra.color);
let target = self.mob_entity(&self.mobs[index]);
// SpawnEggItem.spawnOffspringFromSpawnEgg: `type.create(level, SPAWN_ITEM_USE)`
// creates the baby without finalizeSpawn, and `snapTo(pos, 0, 0)` faces
// it south without a level-random draw.
let Some(id) =
self.spawn_mob_internal(&kind, at, false, false, false, false, SpawnYaw::Fixed(0.0))
else {
return false;
};
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
}
// `Mob.checkAndHandleImportantInteractions` then calls
// `onOffspringSpawnedFromEgg`: `Fox`'s makes the baby trust the player
// who used the egg (a fox egg used on a block trusts nobody).
if species == Species::Fox {
let spawner = self.acting_player_uuid();
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::fox::add_trusted_entity(child, spawner);
}
}
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
self.consume_held();
true
}
/// `SpawnEggItem.use`: the SOURCE_ONLY ray must hit a liquid block.
pub(super) fn spawn_egg_fluid_hit(&self) -> Option<super::Hit> {
let hit = self.bucket_hit()?;
matches!(
&*self.block_name(hit.pos),
"minecraft:water" | "minecraft:lava"
)
.then_some(hit)
}
/// Shared peaceful-mode eligibility for spawn eggs.
pub(super) fn spawn_egg_allowed(&self, item: &str) -> bool {
let kind = item.trim_end_matches("_spawn_egg");
self.ui.difficulty != 0
|| self
.res
.mob_types()
.get(kind)
.is_none_or(|t| horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species))
}
/// `SpawnEggItem.useOn` for a block, or `use` for a fluid source.
fn use_spawn_egg(&mut self, stack: &ItemStack) -> Result<bool> {
let fluid = matches!(self.guest_use, Some(super::GuestUse::Item(_)));
let Some(hit) = (if fluid {
self.spawn_egg_fluid_hit()
} else {
self.pick()
}) else {
return Ok(false);
};
let kind = stack.item.trim_end_matches("_spawn_egg").to_owned();
if !self.spawn_egg_allowed(&stack.item) {
self.use_failed = true;
return Ok(true);
}
if fluid
&& !self.may_build()
&& !self.can_place_on_in_adventure([
hit.pos[0] - hit.face[0],
hit.pos[1] - hit.face[1],
hit.pos[2] - hit.face[2],
])
{
self.use_failed = true;
return Ok(true);
}
// SpawnEggItem.useOn on a spawner sets its mob (and an op's egg data).
let (spawner_id, data) = horizon_client::block_entity_item::spawn_egg_on_spawner(
stack,
self.acting_player_is_op(),
);
if let Some(done) = self.use_spawn_egg_on_spawner(hit.pos, &spawner_id, data.as_ref()) {
if done {
self.swing_arm();
self.award_stat("used", &stack.item, 1);
self.consume_held();
}
return Ok(true);
}
// useOn: a clicked block without a collision shape (vines, grass)
// takes the mob itself, any other the one on the clicked face.
let p = if fluid || self.collision_empty(hit.pos) {
hit.pos
} else {
[
hit.pos[0] + hit.face[0],
hit.pos[1] + hit.face[1],
hit.pos[2] + hit.face[2],
]
};
let at = [
f64::from(p[0]) + 0.5,
f64::from(p[1]),
f64::from(p[2]) + 0.5,
];
// SpawnEggItem.spawnMob: `EntityType.spawn`.
let Some(id) = self.spawn_mob_spawned(&kind, at, SpawnYaw::Created) else {
return Ok(false);
};
// SpawnEggItem.spawnMob: `EntityType.create` finalizes the mob, then
// `PostSpawnProcessor` puts the egg's components on it.
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::entity_nbt::apply_item_components(m, stack);
}
self.apply_spawn_egg_entity_data(id, stack);
self.post_block_game_event("minecraft:entity_place", p, Some(Who::Player), None);
self.award_stat("used", &stack.item, 1);
self.swing_arm();
if !self.creative
&& let Some(s) = self.held_mut().as_mut()
{
s.count -= 1;
if s.count == 0 {
*self.held_mut() = None;
}
}
Ok(true)
}
/// `EntityType.updateCustomEntityTag`: the egg's `minecraft:entity_data` on
/// the mob it spawned, once the guards let it through, as
/// `TypedEntityData.loadInto`: the mob's own compound, the data merged over
/// it, loaded back into the same mob (`/data merge entity`'s loader).
pub(super) fn apply_spawn_egg_entity_data(&mut self, id: u32, stack: &ItemStack) {
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
return;
};
let kind = self.mobs[i].kind.id.clone();
let op = self.acting_player_is_op();
let Some(data) = horizon_client::block_entity_item::spawn_egg_entity_data(stack, &kind, op)
else {
return;
};
let own = self.mob_tree_nbt(i);
let tag = horizon_client::data_cmd::merged(&own, data);
// Nothing to report: the mob's own compound always loads back.
let _ = self.apply_entity_nbt(super::commands::Target::Mob(id), tag);
}
/// A beam target's centre (half its height), interpolated.
fn beam_target(&self, who: Option<Who>, alpha: f64) -> Option<glam::DVec3> {
match who? {
@@ -7785,624 +7622,6 @@ impl Game<'_> {
}
}
}
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
// The coarse mob bounds pay off only in dense async scenes; otherwise,
// keep the existing exact mesh-culling path.
if self.settings.horizon.entity_culling == EntityCullingMode::Asynchronous
&& self
.async_occlusion_culler
.as_ref()
.is_some_and(|culler| culler.camera_is_stationary())
&& self.settings.horizon.entity_culling_precheck != 0
&& self.mobs.len() >= self.settings.horizon.entity_culling_precheck as usize
&& !self.pack_shadow_casters.0
&& horizon_client::mod_api::mods().is_empty()
{
self.mob_geometry_inner::<true>(camera, alpha, out);
} else {
self.mob_geometry_inner::<false>(camera, alpha, out);
}
}
fn mob_geometry_inner<const PRECHECK: bool>(
&self,
camera: glam::DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = self.view_scale();
self.rendered_mobs.set(0);
let skinning = self.skinning();
// What custom entity model animations read of the frame.
horizon_client::mobs::cem_anim::set_frame(horizon_client::mobs::cem_anim::Frame {
time: self.world.game_time as f64 + alpha,
day_time: self.world.day_time as f64 + alpha,
dimension: match self.dim {
Dim::Overworld => 0.0,
Dim::Nether => -1.0,
Dim::End => 1.0,
},
player: self.player.as_ref().map_or(camera.to_array(), |p| {
[p.position.x, p.position.y, p.position.z]
}),
player_rot: [self.pitch, self.yaw],
paused: self.pause,
first_person: self.camera_type.first_person(),
third_person_reversed: self.camera_type
== super::perspective::CameraType::ThirdPersonFront,
..Default::default()
});
for mob in &self.mobs {
// LevelRenderer: the entity the view looks out of is not drawn
// in first person.
if self.hides_as_camera(mob.id) {
continue;
}
let is_player = &*mob.kind.id == "minecraft:player";
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
// mean edge, scaled by the view scale. RemotePlayer's box counts
// ten times.
let (w, h, _) = mob.dims();
let edge = (w + h + w) / 3.0 * if is_player { 10.0 } else { 1.0 };
let range = edge * 64.0 * scale;
if d.length_squared() >= range * range {
continue;
}
let beam = horizon_client::mobs::guardian::beam(mob, alpha as f32);
// EntityRenderer.shouldRender: the renderer's interpolated culling box,
// grown by 0.5 (GuardianRenderer also draws while its beam is out).
if let Some(view) = &self.view_frustum
&& beam.is_none()
{
let (lo, hi) = super::entity_culling::cull_box(mob, alpha);
let (lo, hi) = (
(lo - camera - glam::DVec3::splat(0.5)).as_vec3(),
(hi - camera + glam::DVec3::splat(0.5)).as_vec3(),
);
if !view.intersects(lo, hi) {
continue;
}
}
let outline = self.mob_outline(mob);
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
// A player keeps its armour, elytra and held items
// (`LivingEntityRenderer.submit` skips only the body).
let layers_only = mob.invisible && outline.is_none() && is_player;
if mob.invisible && outline.is_none() && !layers_only {
continue;
}
let culling = if PRECHECK
&& outline.is_none()
&& !is_player
&& mob.kind.id.starts_with("minecraft:")
&& [
&mob.extra.head,
&mob.extra.chest,
&mob.extra.legs,
&mob.extra.feet,
&mob.extra.main_hand,
&mob.extra.off_hand,
]
.iter()
.all(|item| item.is_none())
&& beam.is_none()
{
self.entity_cull_decision(mob, alpha)
} else {
EntityCullDecision::CheckVertices
};
if culling.hides_entity() {
continue;
}
let entity_start = out.len();
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
self.mark_entity(entity_start, &mob.kind.id);
self.rendered_mobs.set(self.rendered_mobs.get() + 1);
// `EntityRenderer.getPackedLightCoords`: the block the mob's light
// probe (its eye) is in, at the position this frame draws rather
// than this tick's.
let probe = mob.light_probe(alpha);
let light = self.world.light(probe[0], probe[1], probe[2]);
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
// EntityRenderer.getBlockLightLevel: a burning mob has 15 block
// light, and so do the Allay, Blaze, Magma Cube, Vex and Wither
// renderers' overrides; the sky light stays the level's.
if mob.fire_ticks > 0
|| matches!(
mob.species(),
Species::Allay
| Species::Blaze
| Species::MagmaCube
| Species::Vex
| Species::Wither
)
{
packed = (packed & 0xFF) | ((15 * 16) << 8);
}
// GlowSquidRenderer.getBlockLightLevel: the body brightens as the
// dark ticks run out (the block light is the second byte).
if mob.species() == Species::GlowSquid {
let lit = (packed >> 12) & 15;
let block = horizon_client::mobs::aquatic::glow_squid_block_light(mob, lit);
packed = (packed & 0xFF) | ((block * 16) << 8);
}
// A native mod's own model in place of the vanilla one
// (`native_mob_submit`), before any of the vanilla model's parts
// (a sulfur cube's swallowed block) are drawn.
// A player's own hook (`native_player_submit`) is asked for the
// other players in view.
let who = is_player
.then(|| self.remote_player_name(mob.id))
.flatten()
.map(|name| NativePlayerRender { name, local: false });
let modded = if layers_only {
false
} else if let Some(who) = who {
self.mod_player_geometry(mob, who, alpha as f32, camera, packed, out)
} else {
!is_player && self.mod_mob_geometry(mob, alpha as f32, camera, packed, out)
};
if modded {
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
if mob.invisible {
out.truncate(entity_start);
continue;
}
}
if outline.is_none() {
self.cull_entity_vertices(out, entity_start);
}
continue;
}
if mob.species() == Species::SulfurCube
&& !horizon_client::mobs::trader::is_invisible(mob)
{
self.sulfur_cube_body_geometry(
mob,
alpha as f32,
horizon_client::mobs::render::base_transform(mob, alpha as f32, camera),
packed,
out,
);
}
// Other players on a server: PlayerRenderer's HumanoidArmorLayer.
let armor = if is_player {
self.worn_armor_layers(mob)
} else {
Vec::new()
};
let body = out.len();
// The GPU path: an ordinary mob, no layers the CPU path changes after drawing them
// (a player's, the HDR tiers below, the sulfur cube's shell). A glowing mob's
// silhouette is posed too (`SkinFrame::outline_since`), unless it is invisible:
// then only the outline draws.
let silhouette_on_gpu = outline.is_none() || !mob.invisible;
let posable = skinning
&& !is_player
&& !layers_only
&& silhouette_on_gpu
&& armor.is_empty()
&& !matches!(
mob.species(),
Species::Blaze | Species::MagmaCube | Species::GlowSquid | Species::SulfurCube
);
let mut skinned = None;
if posable {
let mut models = self.skin_models.borrow_mut();
let mut frame = self.skin_frame.borrow_mut();
let mark = frame.mark();
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
horizon_client::mobs::render::render_skinned(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
out,
&mut skin,
);
skinned = Some((mark, skin.bounds));
} else {
let render = if layers_only {
horizon_client::mobs::render::render_layers
} else {
horizon_client::mobs::render::render_with
};
render(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
&armor,
out,
);
}
// HDR output: mobs that are light shine by their kind, as blocks do (entity.wgsl
// fs_hdr_mark); a tier's window lifts only their bright texels.
if let Some(tier) = match mob.species() {
Species::Blaze => Some(horizon_client::mesher::BRIGHT),
Species::MagmaCube | Species::GlowSquid => Some(horizon_client::mesher::LOW),
_ => None,
} {
for v in &mut out[body..] {
v.layer |= u32::from(tier) << 16;
}
}
// AvatarRenderer.shouldRenderLayers: a spectator draws no layers.
let layers = horizon_client::mobs::render::layers_shown(mob);
if is_player && layers {
// PlayerRenderer's CapeLayer, WingsLayer, ParrotOnShoulderLayer
// and SpinAttackEffectLayer. Only the cape hides with the body;
// the others have no `isInvisible` check.
let chest = mob.extra.chest.as_deref();
if !layers_only {
self.player_cape_geometry(mob, chest, camera, alpha as f32, packed, out);
}
if let Some((_, name)) = self
.remote_player_names()
.into_iter()
.find(|(id, _)| *id == mob.id)
{
self.player_ears_geometry(mob, &name, camera, alpha as f32, packed, out);
}
self.player_elytra_geometry(mob, chest, camera, alpha as f32, packed, out);
self.shoulder_parrots_geometry(mob, camera, alpha as f32, packed, out);
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
}
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
// with it.
if !layers_only
&& super::cosmetics::enabled()
&& (is_player || &*mob.kind.id == "minecraft:mannequin")
{
let mark = self.skin_frame.borrow().mark();
let base = horizon_client::mobs::render::base_transform(mob, alpha as f32, camera);
let gpu = skinning;
if let Some((lo, hi)) = self.cosmetics_geometry(
mob,
false,
base,
Some(camera),
alpha as f32,
packed,
gpu,
out,
) {
skinned = Some(match skinned {
Some((first, Some((l, h)))) => (first, Some((l.min(lo), h.max(hi)))),
Some((first, None)) => (first, Some((lo, hi))),
None => (mark, Some((lo, hi))),
});
}
}
// CustomHeadLayer (a pumpkin, skull or head item) included.
if layers {
// Held items join the GPU path's draws, their silhouette too.
let skin = (skinning && silhouette_on_gpu).then_some(&mut skinned);
self.mob_block_geometry(mob, camera, alpha, packed, skin, out);
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out);
}
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
if let Some(((first, _, _), _)) = skinned {
let color = horizon_client::mobs::render::abgr(rgb);
self.skin_frame.borrow_mut().outline_since(first, color);
}
if mob.invisible {
out.truncate(entity_start);
continue;
}
}
// Glowing outlines and a guardian's beam remain visible independently
// of whether the body is hidden behind terrain.
if outline.is_none() && culling.checks_vertices() && beam.is_none() {
// What the GPU draws of the mob counts by its bounds' corners.
self.cull_skinned(out, entity_start, skinned);
}
// GuardianRenderer: the laser to the locked target.
if let Some((time, scale)) = beam
&& let Some(target) = self.beam_target(mob.ai.target, alpha)
&& let Some(uv) = lookup("entity/guardian/guardian_beam")
{
let a = alpha;
let eye = glam::DVec3::new(
mob.previous.x + (mob.position.x - mob.previous.x) * a,
mob.previous.y + (mob.position.y - mob.previous.y) * a + mob.eye_height(),
mob.previous.z + (mob.position.z - mob.previous.z) * a,
) - camera;
horizon_client::mobs::guardian::beam_geometry(
eye.as_vec3(),
(target - camera).as_vec3(),
time,
scale,
uv,
out,
);
}
}
// What follows isn't a tracked mob: close the last one's span.
self.mark_motion(out.len(), 0);
self.elder_apparition_geometry(alpha, out);
self.spawner_geometry(camera, alpha, out);
// Projectiles: the baked vanilla arrow model, the rest as item billboards.
let (right, up) = thrown_item_axes(self.camera_type, self.yaw, self.pitch);
let live = self.creatures.projectiles.iter().map(|a| {
let at = [
a.previous.x + (a.position.x - a.previous.x) * alpha,
a.previous.y + (a.position.y - a.previous.y) * alpha,
a.previous.z + (a.position.z - a.previous.z) * alpha,
];
(a, at)
});
// ItemPickupParticle: a picked-up arrow flying to its collector.
let taken = self.pickups.live.iter().filter_map(|p| match &p.taken {
super::pickups::Taken::Arrow(a) => Some((&**a, super::pickups::position(p, alpha))),
_ => None,
});
// A glowing projectile's outline: what it drew up to the next one.
let mut glowing = None;
for (a, at) in live.chain(taken) {
if let Some((start, rgb)) = glowing.take() {
self.mark_outline(&out[start..], rgb);
}
if a.id != 0
&& let Some(rgb) = self.thing_outline(a.id, a.kind.id())
{
glowing = Some((out.len(), rgb));
}
let p = glam::DVec3::from_array(at) - camera;
let c = p.as_vec3();
// EntityRenderer.getPackedLightCoords: the block its light probe
// (the eye) is in, at the position this frame draws rather than
// this tick's, and not the one under its feet.
let light = self.world.light(
at[0].floor() as i32,
a.light_probe_y(alpha).floor() as i32,
at[2].floor() as i32,
);
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
// EntityRenderer.getBlockLightLevel: a shulker bullet and anything
// on fire are drawn at 15 block light (the fireball renderers'
// own override); the sky light stays the level's.
if let Some(block) = a.block_light_override() {
packed = (packed & 0xFF) | (u32::from(block) * 16) << 8;
}
if a.is_arrow() {
let Some(model) = self.arrow_model.as_ref() else {
continue;
};
// SpectralArrowRenderer, and TippableArrowRenderer's `getColor() > 0`.
let texture = if a.is_spectral_arrow() {
"entity/projectiles/arrow_spectral"
} else if a.arrow.as_ref().is_some_and(|s| s.effect_color > 0) {
"entity/projectiles/arrow_tipped"
} else {
"entity/projectiles/arrow"
};
let Some(r) = atlas.sprites.get(texture).copied() else {
continue;
};
// ArrowRenderer: `Mth.lerp(partialTick, state.yRotO, state.yRot)`
// and the same for the pitch, so a turning arrow is drawn
// between the two ticks it turned over.
let alpha32 = alpha as f32;
let yaw = a.previous_yaw + alpha32 * (a.yaw - a.previous_yaw);
let pitch = a.previous_pitch + alpha32 * (a.pitch - a.previous_pitch);
let m = glam::Mat4::from_translation(c)
* glam::Mat4::from_rotation_y((yaw - 90.0).to_radians())
* glam::Mat4::from_rotation_z(pitch.to_radians());
// ArrowRenderer: `state.shake = shakeTime - partialTicks`; ArrowModel.setupAnim
// rolls the root while the shake timer runs.
let shake = a
.arrow
.as_ref()
.map_or(0.0, |s| s.shake as f32 - alpha as f32);
let roll = if shake > 0.0 {
-(shake * 3.0).msin() * shake * (std::f32::consts::PI / 180.0)
} else {
0.0
};
let pose_for = |part: &horizon_client::mobs::Part| {
let mut pose = part.pose;
if std::ptr::eq(part, model) {
pose[5] += roll;
}
pose
};
horizon_client::mobs::render_model(
model,
m,
&pose_for,
[r.u0, r.v0, r.u1, r.v1],
packed,
0,
out,
);
if a.arrow.as_ref().is_some_and(|s| s.on_fire()) {
flame_geometry(atlas, c, 0.5, 0.5, self.yaw, light, out);
}
continue;
}
// ThrownTridentRenderer.
if matches!(a.kind, ProjectileKind::Trident) {
let Some(model) = self.trident_model.as_ref() else {
continue;
};
let Some(r) = atlas.sprites.get("entity/trident/trident").copied() else {
continue;
};
let m = glam::Mat4::from_translation(c)
* glam::Mat4::from_rotation_y((a.yaw - 90.0).to_radians())
* glam::Mat4::from_rotation_z((a.pitch + 90.0).to_radians());
let poses = horizon_client::mobs::anim::Poses::new(model);
horizon_client::mobs::render::draw(
model,
m,
&poses,
[r.u0, r.v0, r.u1, r.v1],
0xFFFF_FFFF,
packed,
0,
out,
);
continue;
}
// WindChargeRenderer.
if matches!(
a.kind,
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge
) {
self.wind_charge_geometry(a, c, packed, alpha as f32, out);
continue;
}
// ShulkerBulletRenderer: the tumbling bullet model, then a larger
// translucent copy of it (colour 0x26FFFFFF); always 15 block light.
if matches!(a.kind, ProjectileKind::ShulkerBullet { .. }) {
let model = &self.entity_models.shulker_bullet;
if let (Some(model), Some(r)) =
(model, atlas.sprites.get("entity/shulker/spark").copied())
{
use horizon_core::geometry::trig;
let tc = a.age as f32 + alpha as f32;
let mut poses = horizon_client::mobs::anim::Poses::new(model);
poses.yrot("main", a.yaw.to_radians());
poses.xrot("main", a.pitch.to_radians());
let light = (packed & 0xFF) | ((15 * 16) << 8);
let base = glam::Mat4::from_translation(c)
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.15, 0.0))
* glam::Mat4::from_rotation_y(
(trig::sin(f64::from(tc * 0.1)) * 180.0).to_radians(),
)
* glam::Mat4::from_rotation_x(
(trig::cos(f64::from(tc * 0.1)) * 180.0).to_radians(),
)
* glam::Mat4::from_rotation_z(
(trig::sin(f64::from(tc * 0.15)) * 360.0).to_radians(),
)
* glam::Mat4::from_scale(glam::Vec3::new(-0.5, -0.5, 0.5));
let uv = [r.u0, r.v0, r.u1, r.v1];
horizon_client::mobs::render::draw(
model,
base,
&poses,
uv,
0xFFFF_FFFF,
light,
0,
out,
);
// The translucent shell blends over the opaque core
// (`RenderTypes.entityTranslucent`).
let shell = out.len();
horizon_client::mobs::render::draw(
model,
base * glam::Mat4::from_scale(glam::Vec3::splat(1.5)),
&poses,
uv,
0x26FF_FFFF,
light,
0,
out,
);
horizon_client::gfx::mark_oit(&mut out[shell..]);
}
continue;
}
// WitherSkullRenderer: the skull model facing its flight.
if let Some(dangerous) = super::wither::skull_kind(a) {
let model = &self.entity_models.wither_skull;
let tex = if dangerous {
"entity/wither/wither_invulnerable"
} else {
"entity/wither/wither"
};
if let (Some(model), Some(r)) = (model, atlas.sprites.get(tex).copied()) {
let mut poses = horizon_client::mobs::anim::Poses::new(model);
poses.yrot("head", a.yaw.to_radians());
poses.xrot("head", a.pitch.to_radians());
let base = glam::Mat4::from_translation(c)
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0));
horizon_client::mobs::render::draw(
model,
base,
&poses,
[r.u0, r.v0, r.u1, r.v1],
0xFFFF_FFFF,
packed,
0,
out,
);
}
continue;
}
if let Some(stack) = a.drawn_item() {
let owner = super::item_draw::owner_at(at, 0.0, a.kind.id());
let state = self.resolve_item(&stack, DisplayContext::Ground, Some(&owner), 0);
let pose = glam::Mat4::from_translation(c)
* glam::Mat4::from_quat(self.camera_orientation());
self.submit_item_world(&state, pose, packed, out);
continue;
}
// `Entity.displayFireAnimation`: a thrown item that flew through a
// fire block burns (`ThrowableProjectile.tick`'s
// `applyEffectsFromBlocks`), and ThrownItemRenderer draws the flame
// over its box as any other entity renderer does.
if a.on_fire() {
let size = a.kind.size() as f32;
flame_geometry(atlas, c, size, size, self.yaw, light, out);
}
// `ThrownItemRenderer` for a fireball: the item `Fireball.getItem`
// returns (a fire charge until `setItem` gave it another), its model
// at the renderer's scale for the kind. `packed` is already 15, the
// renderer's full bright.
if let Some(stack) = a.item.as_ref() {
let owner = super::item_draw::owner_at(at, 0.0, a.kind.id());
let state = self.resolve_item(stack, DisplayContext::Ground, Some(&owner), 0);
let pose = glam::Mat4::from_translation(c)
* glam::Mat4::from_quat(self.camera_orientation())
* glam::Mat4::from_scale(glam::Vec3::splat(a.sprite_scale()));
self.submit_item_world(&state, pose, packed, out);
continue;
}
let Some(name) = a.item_sprite() else {
continue;
};
let Some(r) = atlas.sprites.get(name).copied() else {
continue;
};
let lift = is_item_billboard(&a.kind);
let quad = thrown_item_quad(c, right, up, a.sprite_scale(), lift);
for (v, uv) in quad
.iter()
.zip([[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]])
{
out.push(Vertex {
normal: [0; 4],
pos: v.to_array(),
uv,
layer: 0,
color: 0xFFFF_FFFF,
light: packed,
glint_uv: [0.0, 0.0],
});
}
}
if let Some((start, rgb)) = glowing {
self.mark_outline(&out[start..], rgb);
}
}
}
/// `ThrownItemRenderer.submit` turns the item by `CameraRenderState.orientation`
/// (`Camera.setRotation`): the billboard's right and up axes are the camera's.
@@ -0,0 +1,629 @@
//! Drawing the mobs, players and projectiles the client sees, each frame (`mob_geometry`).
use super::*;
impl Game<'_> {
pub(in crate::game) fn mob_geometry(
&self,
camera: glam::DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
// The coarse mob bounds pay off only in dense async scenes; otherwise,
// keep the existing exact mesh-culling path.
if self.settings.horizon.entity_culling == EntityCullingMode::Asynchronous
&& self
.async_occlusion_culler
.as_ref()
.is_some_and(|culler| culler.camera_is_stationary())
&& self.settings.horizon.entity_culling_precheck != 0
&& self.mobs.len() >= self.settings.horizon.entity_culling_precheck as usize
&& !self.pack_shadow_casters.0
&& horizon_client::mod_api::mods().is_empty()
{
self.mob_geometry_inner::<true>(camera, alpha, out);
} else {
self.mob_geometry_inner::<false>(camera, alpha, out);
}
}
fn mob_geometry_inner<const PRECHECK: bool>(
&self,
camera: glam::DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = self.view_scale();
self.rendered_mobs.set(0);
let skinning = self.skinning();
// What custom entity model animations read of the frame.
horizon_client::mobs::cem_anim::set_frame(horizon_client::mobs::cem_anim::Frame {
time: self.world.game_time as f64 + alpha,
day_time: self.world.day_time as f64 + alpha,
dimension: match self.dim {
Dim::Overworld => 0.0,
Dim::Nether => -1.0,
Dim::End => 1.0,
},
player: self.player.as_ref().map_or(camera.to_array(), |p| {
[p.position.x, p.position.y, p.position.z]
}),
player_rot: [self.pitch, self.yaw],
paused: self.pause,
first_person: self.camera_type.first_person(),
third_person_reversed: self.camera_type
== super::super::perspective::CameraType::ThirdPersonFront,
..Default::default()
});
for mob in &self.mobs {
// LevelRenderer: the entity the view looks out of is not drawn
// in first person.
if self.hides_as_camera(mob.id) {
continue;
}
let is_player = &*mob.kind.id == "minecraft:player";
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
// mean edge, scaled by the view scale. RemotePlayer's box counts
// ten times.
let (w, h, _) = mob.dims();
let edge = (w + h + w) / 3.0 * if is_player { 10.0 } else { 1.0 };
let range = edge * 64.0 * scale;
if d.length_squared() >= range * range {
continue;
}
let beam = horizon_client::mobs::guardian::beam(mob, alpha as f32);
// EntityRenderer.shouldRender: the renderer's interpolated culling box,
// grown by 0.5 (GuardianRenderer also draws while its beam is out).
if let Some(view) = &self.view_frustum
&& beam.is_none()
{
let (lo, hi) = super::super::entity_culling::cull_box(mob, alpha);
let (lo, hi) = (
(lo - camera - glam::DVec3::splat(0.5)).as_vec3(),
(hi - camera + glam::DVec3::splat(0.5)).as_vec3(),
);
if !view.intersects(lo, hi) {
continue;
}
}
let outline = self.mob_outline(mob);
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
// A player keeps its armour, elytra and held items
// (`LivingEntityRenderer.submit` skips only the body).
let layers_only = mob.invisible && outline.is_none() && is_player;
if mob.invisible && outline.is_none() && !layers_only {
continue;
}
let culling = if PRECHECK
&& outline.is_none()
&& !is_player
&& mob.kind.id.starts_with("minecraft:")
&& [
&mob.extra.head,
&mob.extra.chest,
&mob.extra.legs,
&mob.extra.feet,
&mob.extra.main_hand,
&mob.extra.off_hand,
]
.iter()
.all(|item| item.is_none())
&& beam.is_none()
{
self.entity_cull_decision(mob, alpha)
} else {
EntityCullDecision::CheckVertices
};
if culling.hides_entity() {
continue;
}
let entity_start = out.len();
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
self.mark_entity(entity_start, &mob.kind.id);
self.rendered_mobs.set(self.rendered_mobs.get() + 1);
// `EntityRenderer.getPackedLightCoords`: the block the mob's light
// probe (its eye) is in, at the position this frame draws rather
// than this tick's.
let probe = mob.light_probe(alpha);
let light = self.world.light(probe[0], probe[1], probe[2]);
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
// EntityRenderer.getBlockLightLevel: a burning mob has 15 block
// light, and so do the Allay, Blaze, Magma Cube, Vex and Wither
// renderers' overrides; the sky light stays the level's.
if mob.fire_ticks > 0
|| matches!(
mob.species(),
Species::Allay
| Species::Blaze
| Species::MagmaCube
| Species::Vex
| Species::Wither
)
{
packed = (packed & 0xFF) | ((15 * 16) << 8);
}
// GlowSquidRenderer.getBlockLightLevel: the body brightens as the
// dark ticks run out (the block light is the second byte).
if mob.species() == Species::GlowSquid {
let lit = (packed >> 12) & 15;
let block = horizon_client::mobs::aquatic::glow_squid_block_light(mob, lit);
packed = (packed & 0xFF) | ((block * 16) << 8);
}
// A native mod's own model in place of the vanilla one
// (`native_mob_submit`), before any of the vanilla model's parts
// (a sulfur cube's swallowed block) are drawn.
// A player's own hook (`native_player_submit`) is asked for the
// other players in view.
let who = is_player
.then(|| self.remote_player_name(mob.id))
.flatten()
.map(|name| NativePlayerRender { name, local: false });
let modded = if layers_only {
false
} else if let Some(who) = who {
self.mod_player_geometry(mob, who, alpha as f32, camera, packed, out)
} else {
!is_player && self.mod_mob_geometry(mob, alpha as f32, camera, packed, out)
};
if modded {
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
if mob.invisible {
out.truncate(entity_start);
continue;
}
}
if outline.is_none() {
self.cull_entity_vertices(out, entity_start);
}
continue;
}
if mob.species() == Species::SulfurCube
&& !horizon_client::mobs::trader::is_invisible(mob)
{
self.sulfur_cube_body_geometry(
mob,
alpha as f32,
horizon_client::mobs::render::base_transform(mob, alpha as f32, camera),
packed,
out,
);
}
// Other players on a server: PlayerRenderer's HumanoidArmorLayer.
let armor = if is_player {
self.worn_armor_layers(mob)
} else {
Vec::new()
};
let body = out.len();
// The GPU path: an ordinary mob, no layers the CPU path changes after drawing them
// (a player's, the HDR tiers below, the sulfur cube's shell). A glowing mob's
// silhouette is posed too (`SkinFrame::outline_since`), unless it is invisible:
// then only the outline draws.
let silhouette_on_gpu = outline.is_none() || !mob.invisible;
let posable = skinning
&& !is_player
&& !layers_only
&& silhouette_on_gpu
&& armor.is_empty()
&& !matches!(
mob.species(),
Species::Blaze | Species::MagmaCube | Species::GlowSquid | Species::SulfurCube
);
let mut skinned = None;
if posable {
let mut models = self.skin_models.borrow_mut();
let mut frame = self.skin_frame.borrow_mut();
let mark = frame.mark();
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
horizon_client::mobs::render::render_skinned(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
out,
&mut skin,
);
skinned = Some((mark, skin.bounds));
} else {
let render = if layers_only {
horizon_client::mobs::render::render_layers
} else {
horizon_client::mobs::render::render_with
};
render(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
&armor,
out,
);
}
// HDR output: mobs that are light shine by their kind, as blocks do (entity.wgsl
// fs_hdr_mark); a tier's window lifts only their bright texels.
if let Some(tier) = match mob.species() {
Species::Blaze => Some(horizon_client::mesher::BRIGHT),
Species::MagmaCube | Species::GlowSquid => Some(horizon_client::mesher::LOW),
_ => None,
} {
for v in &mut out[body..] {
v.layer |= u32::from(tier) << 16;
}
}
// AvatarRenderer.shouldRenderLayers: a spectator draws no layers.
let layers = horizon_client::mobs::render::layers_shown(mob);
if is_player && layers {
// PlayerRenderer's CapeLayer, WingsLayer, ParrotOnShoulderLayer
// and SpinAttackEffectLayer. Only the cape hides with the body;
// the others have no `isInvisible` check.
let chest = mob.extra.chest.as_deref();
if !layers_only {
self.player_cape_geometry(mob, chest, camera, alpha as f32, packed, out);
}
if let Some((_, name)) = self
.remote_player_names()
.into_iter()
.find(|(id, _)| *id == mob.id)
{
self.player_ears_geometry(mob, &name, camera, alpha as f32, packed, out);
}
self.player_elytra_geometry(mob, chest, camera, alpha as f32, packed, out);
self.shoulder_parrots_geometry(mob, camera, alpha as f32, packed, out);
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
}
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
// with it.
if !layers_only
&& super::super::cosmetics::enabled()
&& (is_player || &*mob.kind.id == "minecraft:mannequin")
{
let mark = self.skin_frame.borrow().mark();
let base = horizon_client::mobs::render::base_transform(mob, alpha as f32, camera);
let gpu = skinning;
if let Some((lo, hi)) = self.cosmetics_geometry(
mob,
false,
base,
Some(camera),
alpha as f32,
packed,
gpu,
out,
) {
skinned = Some(match skinned {
Some((first, Some((l, h)))) => (first, Some((l.min(lo), h.max(hi)))),
Some((first, None)) => (first, Some((lo, hi))),
None => (mark, Some((lo, hi))),
});
}
}
// CustomHeadLayer (a pumpkin, skull or head item) included.
if layers {
// Held items join the GPU path's draws, their silhouette too.
let skin = (skinning && silhouette_on_gpu).then_some(&mut skinned);
self.mob_block_geometry(mob, camera, alpha, packed, skin, out);
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out);
}
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
if let Some(((first, _, _), _)) = skinned {
let color = horizon_client::mobs::render::abgr(rgb);
self.skin_frame.borrow_mut().outline_since(first, color);
}
if mob.invisible {
out.truncate(entity_start);
continue;
}
}
// Glowing outlines and a guardian's beam remain visible independently
// of whether the body is hidden behind terrain.
if outline.is_none() && culling.checks_vertices() && beam.is_none() {
// What the GPU draws of the mob counts by its bounds' corners.
self.cull_skinned(out, entity_start, skinned);
}
// GuardianRenderer: the laser to the locked target.
if let Some((time, scale)) = beam
&& let Some(target) = self.beam_target(mob.ai.target, alpha)
&& let Some(uv) = lookup("entity/guardian/guardian_beam")
{
let a = alpha;
let eye = glam::DVec3::new(
mob.previous.x + (mob.position.x - mob.previous.x) * a,
mob.previous.y + (mob.position.y - mob.previous.y) * a + mob.eye_height(),
mob.previous.z + (mob.position.z - mob.previous.z) * a,
) - camera;
horizon_client::mobs::guardian::beam_geometry(
eye.as_vec3(),
(target - camera).as_vec3(),
time,
scale,
uv,
out,
);
}
}
// What follows isn't a tracked mob: close the last one's span.
self.mark_motion(out.len(), 0);
self.elder_apparition_geometry(alpha, out);
self.spawner_geometry(camera, alpha, out);
// Projectiles: the baked vanilla arrow model, the rest as item billboards.
let (right, up) = thrown_item_axes(self.camera_type, self.yaw, self.pitch);
let live = self.creatures.projectiles.iter().map(|a| {
let at = [
a.previous.x + (a.position.x - a.previous.x) * alpha,
a.previous.y + (a.position.y - a.previous.y) * alpha,
a.previous.z + (a.position.z - a.previous.z) * alpha,
];
(a, at)
});
// ItemPickupParticle: a picked-up arrow flying to its collector.
let taken = self.pickups.live.iter().filter_map(|p| match &p.taken {
super::super::pickups::Taken::Arrow(a) => {
Some((&**a, super::super::pickups::position(p, alpha)))
}
_ => None,
});
// A glowing projectile's outline: what it drew up to the next one.
let mut glowing = None;
for (a, at) in live.chain(taken) {
if let Some((start, rgb)) = glowing.take() {
self.mark_outline(&out[start..], rgb);
}
if a.id != 0
&& let Some(rgb) = self.thing_outline(a.id, a.kind.id())
{
glowing = Some((out.len(), rgb));
}
let p = glam::DVec3::from_array(at) - camera;
let c = p.as_vec3();
// EntityRenderer.getPackedLightCoords: the block its light probe
// (the eye) is in, at the position this frame draws rather than
// this tick's, and not the one under its feet.
let light = self.world.light(
at[0].floor() as i32,
a.light_probe_y(alpha).floor() as i32,
at[2].floor() as i32,
);
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
// EntityRenderer.getBlockLightLevel: a shulker bullet and anything
// on fire are drawn at 15 block light (the fireball renderers'
// own override); the sky light stays the level's.
if let Some(block) = a.block_light_override() {
packed = (packed & 0xFF) | (u32::from(block) * 16) << 8;
}
if a.is_arrow() {
let Some(model) = self.arrow_model.as_ref() else {
continue;
};
// SpectralArrowRenderer, and TippableArrowRenderer's `getColor() > 0`.
let texture = if a.is_spectral_arrow() {
"entity/projectiles/arrow_spectral"
} else if a.arrow.as_ref().is_some_and(|s| s.effect_color > 0) {
"entity/projectiles/arrow_tipped"
} else {
"entity/projectiles/arrow"
};
let Some(r) = atlas.sprites.get(texture).copied() else {
continue;
};
// ArrowRenderer: `Mth.lerp(partialTick, state.yRotO, state.yRot)`
// and the same for the pitch, so a turning arrow is drawn
// between the two ticks it turned over.
let alpha32 = alpha as f32;
let yaw = a.previous_yaw + alpha32 * (a.yaw - a.previous_yaw);
let pitch = a.previous_pitch + alpha32 * (a.pitch - a.previous_pitch);
let m = glam::Mat4::from_translation(c)
* glam::Mat4::from_rotation_y((yaw - 90.0).to_radians())
* glam::Mat4::from_rotation_z(pitch.to_radians());
// ArrowRenderer: `state.shake = shakeTime - partialTicks`; ArrowModel.setupAnim
// rolls the root while the shake timer runs.
let shake = a
.arrow
.as_ref()
.map_or(0.0, |s| s.shake as f32 - alpha as f32);
let roll = if shake > 0.0 {
-(shake * 3.0).msin() * shake * (std::f32::consts::PI / 180.0)
} else {
0.0
};
let pose_for = |part: &horizon_client::mobs::Part| {
let mut pose = part.pose;
if std::ptr::eq(part, model) {
pose[5] += roll;
}
pose
};
horizon_client::mobs::render_model(
model,
m,
&pose_for,
[r.u0, r.v0, r.u1, r.v1],
packed,
0,
out,
);
if a.arrow.as_ref().is_some_and(|s| s.on_fire()) {
flame_geometry(atlas, c, 0.5, 0.5, self.yaw, light, out);
}
continue;
}
// ThrownTridentRenderer.
if matches!(a.kind, ProjectileKind::Trident) {
let Some(model) = self.trident_model.as_ref() else {
continue;
};
let Some(r) = atlas.sprites.get("entity/trident/trident").copied() else {
continue;
};
let m = glam::Mat4::from_translation(c)
* glam::Mat4::from_rotation_y((a.yaw - 90.0).to_radians())
* glam::Mat4::from_rotation_z((a.pitch + 90.0).to_radians());
let poses = horizon_client::mobs::anim::Poses::new(model);
horizon_client::mobs::render::draw(
model,
m,
&poses,
[r.u0, r.v0, r.u1, r.v1],
0xFFFF_FFFF,
packed,
0,
out,
);
continue;
}
// WindChargeRenderer.
if matches!(
a.kind,
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge
) {
self.wind_charge_geometry(a, c, packed, alpha as f32, out);
continue;
}
// ShulkerBulletRenderer: the tumbling bullet model, then a larger
// translucent copy of it (colour 0x26FFFFFF); always 15 block light.
if matches!(a.kind, ProjectileKind::ShulkerBullet { .. }) {
let model = &self.entity_models.shulker_bullet;
if let (Some(model), Some(r)) =
(model, atlas.sprites.get("entity/shulker/spark").copied())
{
use horizon_core::geometry::trig;
let tc = a.age as f32 + alpha as f32;
let mut poses = horizon_client::mobs::anim::Poses::new(model);
poses.yrot("main", a.yaw.to_radians());
poses.xrot("main", a.pitch.to_radians());
let light = (packed & 0xFF) | ((15 * 16) << 8);
let base = glam::Mat4::from_translation(c)
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.15, 0.0))
* glam::Mat4::from_rotation_y(
(trig::sin(f64::from(tc * 0.1)) * 180.0).to_radians(),
)
* glam::Mat4::from_rotation_x(
(trig::cos(f64::from(tc * 0.1)) * 180.0).to_radians(),
)
* glam::Mat4::from_rotation_z(
(trig::sin(f64::from(tc * 0.15)) * 360.0).to_radians(),
)
* glam::Mat4::from_scale(glam::Vec3::new(-0.5, -0.5, 0.5));
let uv = [r.u0, r.v0, r.u1, r.v1];
horizon_client::mobs::render::draw(
model,
base,
&poses,
uv,
0xFFFF_FFFF,
light,
0,
out,
);
// The translucent shell blends over the opaque core
// (`RenderTypes.entityTranslucent`).
let shell = out.len();
horizon_client::mobs::render::draw(
model,
base * glam::Mat4::from_scale(glam::Vec3::splat(1.5)),
&poses,
uv,
0x26FF_FFFF,
light,
0,
out,
);
horizon_client::gfx::mark_oit(&mut out[shell..]);
}
continue;
}
// WitherSkullRenderer: the skull model facing its flight.
if let Some(dangerous) = super::super::wither::skull_kind(a) {
let model = &self.entity_models.wither_skull;
let tex = if dangerous {
"entity/wither/wither_invulnerable"
} else {
"entity/wither/wither"
};
if let (Some(model), Some(r)) = (model, atlas.sprites.get(tex).copied()) {
let mut poses = horizon_client::mobs::anim::Poses::new(model);
poses.yrot("head", a.yaw.to_radians());
poses.xrot("head", a.pitch.to_radians());
let base = glam::Mat4::from_translation(c)
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0));
horizon_client::mobs::render::draw(
model,
base,
&poses,
[r.u0, r.v0, r.u1, r.v1],
0xFFFF_FFFF,
packed,
0,
out,
);
}
continue;
}
if let Some(stack) = a.drawn_item() {
let owner = super::super::item_draw::owner_at(at, 0.0, a.kind.id());
let state = self.resolve_item(&stack, DisplayContext::Ground, Some(&owner), 0);
let pose = glam::Mat4::from_translation(c)
* glam::Mat4::from_quat(self.camera_orientation());
self.submit_item_world(&state, pose, packed, out);
continue;
}
// `Entity.displayFireAnimation`: a thrown item that flew through a
// fire block burns (`ThrowableProjectile.tick`'s
// `applyEffectsFromBlocks`), and ThrownItemRenderer draws the flame
// over its box as any other entity renderer does.
if a.on_fire() {
let size = a.kind.size() as f32;
flame_geometry(atlas, c, size, size, self.yaw, light, out);
}
// `ThrownItemRenderer` for a fireball: the item `Fireball.getItem`
// returns (a fire charge until `setItem` gave it another), its model
// at the renderer's scale for the kind. `packed` is already 15, the
// renderer's full bright.
if let Some(stack) = a.item.as_ref() {
let owner = super::super::item_draw::owner_at(at, 0.0, a.kind.id());
let state = self.resolve_item(stack, DisplayContext::Ground, Some(&owner), 0);
let pose = glam::Mat4::from_translation(c)
* glam::Mat4::from_quat(self.camera_orientation())
* glam::Mat4::from_scale(glam::Vec3::splat(a.sprite_scale()));
self.submit_item_world(&state, pose, packed, out);
continue;
}
let Some(name) = a.item_sprite() else {
continue;
};
let Some(r) = atlas.sprites.get(name).copied() else {
continue;
};
let lift = is_item_billboard(&a.kind);
let quad = thrown_item_quad(c, right, up, a.sprite_scale(), lift);
for (v, uv) in quad
.iter()
.zip([[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]])
{
out.push(Vertex {
normal: [0; 4],
pos: v.to_array(),
uv,
layer: 0,
color: 0xFFFF_FFFF,
light: packed,
glint_uv: [0.0, 0.0],
});
}
}
if let Some((start, rgb)) = glowing {
self.mark_outline(&out[start..], rgb);
}
}
}
@@ -0,0 +1,171 @@
//! Spawn eggs: a baby from an egg used on its own kind, an egg used on a block or a fluid, and the
//! egg's entity data on the mob it spawned.
use super::*;
impl Game<'_> {
/// `Mob.checkAndHandleImportantInteractions` with a spawn egg of mob
/// `index`'s own kind (`SpawnEggItem.spawnOffspringFromSpawnEgg`): a baby
/// at the mob's feet, taking the parent's variant as `getBreedOffspring`
/// does, and one egg used. False (PASS) when the mob has no baby form.
pub(super) fn spawn_egg_offspring(&mut self, index: usize) -> bool {
let Some(stack) = self.held().cloned() else {
return false;
};
let parent = &self.mobs[index];
if !parent.alive()
|| !horizon_client::mobs::finalize::spawn_egg_is_for(&stack.item, &parent.kind.id)
|| !horizon_client::mobs::species::can_be_a_baby(parent.species())
// WanderingTrader.getBreedOffspring returns null: no offspring, the
// click carries on to trading.
|| parent.species() == Species::WanderingTrader
{
return false;
}
let (kind, at) = (
parent.kind.id.clone(),
[parent.position.x, parent.position.y, parent.position.z],
);
let (species, variant, color) =
(parent.species(), parent.extra.variant, parent.extra.color);
let target = self.mob_entity(&self.mobs[index]);
// SpawnEggItem.spawnOffspringFromSpawnEgg: `type.create(level, SPAWN_ITEM_USE)`
// creates the baby without finalizeSpawn, and `snapTo(pos, 0, 0)` faces
// it south without a level-random draw.
let Some(id) =
self.spawn_mob_internal(&kind, at, false, false, false, false, SpawnYaw::Fixed(0.0))
else {
return false;
};
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
}
// `Mob.checkAndHandleImportantInteractions` then calls
// `onOffspringSpawnedFromEgg`: `Fox`'s makes the baby trust the player
// who used the egg (a fox egg used on a block trusts nobody).
if species == Species::Fox {
let spawner = self.acting_player_uuid();
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::fox::add_trusted_entity(child, spawner);
}
}
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
self.consume_held();
true
}
/// `SpawnEggItem.use`: the SOURCE_ONLY ray must hit a liquid block.
pub(in crate::game) fn spawn_egg_fluid_hit(&self) -> Option<super::super::Hit> {
let hit = self.bucket_hit()?;
matches!(
&*self.block_name(hit.pos),
"minecraft:water" | "minecraft:lava"
)
.then_some(hit)
}
/// Shared peaceful-mode eligibility for spawn eggs.
pub(in crate::game) fn spawn_egg_allowed(&self, item: &str) -> bool {
let kind = item.trim_end_matches("_spawn_egg");
self.ui.difficulty != 0
|| self
.res
.mob_types()
.get(kind)
.is_none_or(|t| horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species))
}
/// `SpawnEggItem.useOn` for a block, or `use` for a fluid source.
pub(super) fn use_spawn_egg(&mut self, stack: &ItemStack) -> Result<bool> {
let fluid = matches!(self.guest_use, Some(super::super::GuestUse::Item(_)));
let Some(hit) = (if fluid {
self.spawn_egg_fluid_hit()
} else {
self.pick()
}) else {
return Ok(false);
};
let kind = stack.item.trim_end_matches("_spawn_egg").to_owned();
if !self.spawn_egg_allowed(&stack.item) {
self.use_failed = true;
return Ok(true);
}
if fluid
&& !self.may_build()
&& !self.can_place_on_in_adventure([
hit.pos[0] - hit.face[0],
hit.pos[1] - hit.face[1],
hit.pos[2] - hit.face[2],
])
{
self.use_failed = true;
return Ok(true);
}
// SpawnEggItem.useOn on a spawner sets its mob (and an op's egg data).
let (spawner_id, data) = horizon_client::block_entity_item::spawn_egg_on_spawner(
stack,
self.acting_player_is_op(),
);
if let Some(done) = self.use_spawn_egg_on_spawner(hit.pos, &spawner_id, data.as_ref()) {
if done {
self.swing_arm();
self.award_stat("used", &stack.item, 1);
self.consume_held();
}
return Ok(true);
}
// useOn: a clicked block without a collision shape (vines, grass)
// takes the mob itself, any other the one on the clicked face.
let p = if fluid || self.collision_empty(hit.pos) {
hit.pos
} else {
[
hit.pos[0] + hit.face[0],
hit.pos[1] + hit.face[1],
hit.pos[2] + hit.face[2],
]
};
let at = [
f64::from(p[0]) + 0.5,
f64::from(p[1]),
f64::from(p[2]) + 0.5,
];
// SpawnEggItem.spawnMob: `EntityType.spawn`.
let Some(id) = self.spawn_mob_spawned(&kind, at, SpawnYaw::Created) else {
return Ok(false);
};
// SpawnEggItem.spawnMob: `EntityType.create` finalizes the mob, then
// `PostSpawnProcessor` puts the egg's components on it.
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::entity_nbt::apply_item_components(m, stack);
}
self.apply_spawn_egg_entity_data(id, stack);
self.post_block_game_event("minecraft:entity_place", p, Some(Who::Player), None);
self.award_stat("used", &stack.item, 1);
self.swing_arm();
if !self.creative
&& let Some(s) = self.held_mut().as_mut()
{
s.count -= 1;
if s.count == 0 {
*self.held_mut() = None;
}
}
Ok(true)
}
/// `EntityType.updateCustomEntityTag`: the egg's `minecraft:entity_data` on
/// the mob it spawned, once the guards let it through, as
/// `TypedEntityData.loadInto`: the mob's own compound, the data merged over
/// it, loaded back into the same mob (`/data merge entity`'s loader).
pub(in crate::game) fn apply_spawn_egg_entity_data(&mut self, id: u32, stack: &ItemStack) {
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
return;
};
let kind = self.mobs[i].kind.id.clone();
let op = self.acting_player_is_op();
let Some(data) = horizon_client::block_entity_item::spawn_egg_entity_data(stack, &kind, op)
else {
return;
};
let own = self.mob_tree_nbt(i);
let tag = horizon_client::data_cmd::merged(&own, data);
// Nothing to report: the mob's own compound always loads back.
let _ = self.apply_entity_nbt(super::super::commands::Target::Mob(id), tag);
}
}
+1 -1
View File
@@ -2671,7 +2671,7 @@ mod tests {
#[test]
fn a_temporarily_banned_player_is_refused_with_the_end_until_it_has_passed() {
use super::{BanEntry, BanList, Lists, NameAndId};
use horizon_chat_filter::now_millis;
use horizon_server_files::duration::now_millis;
let dir = std::env::temp_dir().join(format!("horizon-can-login-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let mut lists = Lists {
+6
View File
@@ -273,6 +273,12 @@ impl Game<'_> {
}
self.draw_pvp_hud(batch, &mut corners);
self.native_hud(batch, &mut corners);
// The layers crates registered (`horizon_hooks::hud`): none, no canvas.
let layers = horizon_hooks::hud::engine::all();
if !layers.is_empty() {
let shown = Corner::ALL.map(|corner| self.native_corner_shown(corner));
extras_hud::draw_layers(layers, batch, &self.gui.atlas, &mut corners, shown);
}
}
/// The height the status effect icons take at the top right
/// (`Hud.extractEffects`: beneficial at y 1, harmful at y 26).
+14 -9
View File
@@ -1,23 +1,23 @@
//! Play with friends, host side: "Start Sharing" on the Play with Friends
//! screen (`friends_screen`, the pause menu's Friends button) opens the world to LAN if
//! screen (`screen`, the pause menu's Friends button) opens the world to LAN if
//! it isn't (with Open to LAN's defaults) and shares that port behind an
//! invite code (horizon-friends). The code holds this computer's addresses
//! and the key, so friends join with no server of ours; a relay the player
//! configured adds a short code and a fallback. The integrated server is the
//! LAN one, unchanged: tunnelled guests are LAN guests that connect from the
//! loopback address.
//! loopback address. The joining side is horizon-friends' (`game`), registered by
//! horizon-official.
use super::{Game, lan::LanSettings};
use horizon_client::{
Result,
friends_screen::{self, Action, FriendsView},
hud::HudBatch,
lang,
text::Text,
};
use horizon_client::{Result, hud::HudBatch, lang, text::Text};
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
use horizon_protocol::{GAME_VERSION, PROTOCOL_VERSION};
use std::time::{Duration, Instant};
mod screen;
use screen::{self as friends_screen, Action, FriendsView};
pub use screen::{TITLE, title};
pub(super) struct Sharing {
host: Host,
/// Sharing opened the world to LAN, so stopping closes it again.
@@ -240,6 +240,11 @@ impl Game<'_> {
}
}
/// Whether the pause menu offers Play with Friends (a singleplayer world).
pub(super) fn friends_shown(&self) -> bool {
self.friends_view().shown
}
/// What Play with Friends shows.
pub(super) fn friends_view(&self) -> FriendsView {
let guests = match &self.lan {
@@ -1,9 +1,9 @@
//! Play with Friends: Horizon's own sharing of the open world behind an
//! invite code (`game/friends.rs`). The pause menu's Friends button, the icon
//! invite code (`game/friends.rs`), with the rest of Play with Friends' game glue. The pause menu's Friends button, the icon
//! vanilla's PauseScreen already has, opens it. It is drawn the way a mod adds
//! a screen to vanilla: the menu background, a centred title and text,
//! vanilla's buttons and Done in the footer.
use crate::{
use horizon_client::{
hud::{HudAtlas, HudBatch, rgba},
multiplayer::wrap,
text::Text,
@@ -14,7 +14,7 @@ use crate::{
pub const TITLE: &str = "Play with Friends";
/// A string of this screen: the language table's, or the English text.
fn tr(key: &str, english: &str) -> String {
crate::lang::get_or(key, english)
horizon_client::lang::get_or(key, english)
}
/// The title in the current language.
pub fn title() -> String {
@@ -245,8 +245,8 @@ pub fn draw(
for b in buttons {
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
if matches!(b.action, Action::Done) {
crate::widgets::declare_action(
crate::widgets::Action::Done,
horizon_client::widgets::declare_action(
horizon_client::widgets::Action::Done,
[b.x, b.y, b.w, 20.0],
b.active,
);
+10 -4
View File
@@ -1,11 +1,17 @@
//! 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
//! no world is shared, the pause menu has no Friends button (`friends_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};
use horizon_client::{Result, hud::HudBatch};
pub const HELP: &[&str] = &[];
/// The pause menu's Friends icon is vanilla's, drawn inactive: its name for the widget tree.
pub const TITLE: &str = "Play with Friends";
pub fn title() -> String {
horizon_client::lang::get_or("horizon.friends.title", TITLE)
}
impl Game<'_> {
pub(super) fn friends_lan_closed(&mut self) {}
@@ -18,8 +24,8 @@ impl Game<'_> {
pub(super) fn friends_screen_click(&mut self) {}
pub(super) fn friends_view(&self) -> FriendsView {
FriendsView::default()
pub(super) fn friends_shown(&self) -> bool {
false
}
/// `horizon-ctl friends ...`.
@@ -98,8 +98,9 @@ impl Loading {
.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_icon(Window::default_attributes().with_title("Horizon"));
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(
@@ -129,25 +130,22 @@ impl Loading {
// redraw the same design when they start. GDI is unsafe:
// horizon-threads holds it (this crate forbids unsafe code).
#[cfg(target_os = "windows")]
// In the fonts given to `menu_style`; with none, the window stays as it is.
let boot_paint_stop = hwnd(&window)
.zip(horizon_client::menu_style::get().fonts)
.map(|(hwnd, fonts)| {
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,
fonts.semibold,
fonts.mono,
&horizon_client::logging::recent_lines(),
);
std::thread::sleep(std::time::Duration::from_millis(300));
}
});
stop
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(
+6 -10
View File
@@ -157,7 +157,7 @@ impl Game<'_> {
/// The numbers `Mob.compareArmor` and `compareWeapons` read off `stack`
/// in `slot` for the mob `m`.
pub(super) fn stack_facts(&self, m: &Mob, stack: &ItemStack, slot: Slot) -> Facts {
let (armor, toughness, attack) = self.attributes(m, &stack.item, slot);
let (armor, toughness, attack) = self.attributes(m, stack, slot);
Facts {
armor,
armor_toughness: toughness,
@@ -173,22 +173,18 @@ impl Game<'_> {
/// `Mob.getApproximateAttributeWith` for the item `id` in `slot`: each
/// attribute starts from the mob's own value. (armour, toughness, damage)
fn attributes(&self, m: &Mob, id: &str, slot: Slot) -> (f64, f64, f64) {
let empty: &[(String, String, f64, String, String)] = &[];
let modifiers = self
.items()
.get(id)
.map_or(empty, |i| i.modifiers.as_slice());
fn attributes(&self, m: &Mob, stack: &ItemStack, slot: Slot) -> (f64, f64, f64) {
let modifiers = self.items().attribute_modifiers(stack);
(
equip::attribute_with(modifiers, "minecraft:armor", f64::from(m.kind.armor), slot),
equip::attribute_with(&modifiers, "minecraft:armor", f64::from(m.kind.armor), slot),
equip::attribute_with(
modifiers,
&modifiers,
"minecraft:armor_toughness",
f64::from(m.kind.armor_toughness),
slot,
),
equip::attribute_with(
modifiers,
&modifiers,
"minecraft:attack_damage",
f64::from(m.attrs.attack_damage),
slot,
@@ -10,11 +10,7 @@ use horizon_client::{
net::FRIEND,
};
use horizon_core::geometry::Vec3;
#[cfg(feature = "friends")]
use horizon_friends::invite::{self, Parsed};
use std::borrow::Cow;
#[cfg(feature = "friends")]
use std::net::SocketAddr;
use winit::keyboard::KeyCode;
/// HUD and first-person hand visibility. Other hand-render restrictions still apply.
@@ -109,28 +105,11 @@ impl NativeWorldId {
}
}
/// The part of a friend's invite anyone may see. A host's code is fresh each
/// time it shares, so a friend's world is known for that sharing only. The
/// relay is kept beside the addresses: two hosts on other networks can share
/// a private address and the fixed direct port.
#[cfg(feature = "friends")]
fn invite_public_part(code: &str) -> String {
match invite::parse(code) {
Some(Parsed::Short(code)) => code.nameplate,
Some(Parsed::Long(invite)) => {
let relay = invite
.relay
.map(|r| format!("{}/{}", r.address, r.code.nameplate));
let endpoints = invite.endpoints.iter().map(SocketAddr::to_string);
endpoints.chain(relay).collect::<Vec<_>>().join(",")
}
None => String::new(),
}
}
/// The part of a friend's invite anyone may see (`horizon_hooks::tunnel::Resolver::public_part`).
/// A host's code is fresh each time it shares, so a friend's world is known for that sharing only.
/// Without Play with Friends no invite is read: a `friend:` world shows no part of it.
#[cfg(not(feature = "friends"))]
fn invite_public_part(_code: &str) -> String {
String::new()
fn invite_public_part(code: &str) -> String {
horizon_hooks::engine::tunnel().map_or_else(String::new, |r| r.public_part(code))
}
/// A living entity known to the client, before interpolation. No authoritative writes.
@@ -558,8 +537,6 @@ impl Game<'_> {
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "friends")]
use horizon_friends::code::Code;
#[test]
fn a_world_is_its_save_in_single_player_and_its_address_on_a_server() {
@@ -573,52 +550,6 @@ mod tests {
);
}
#[test]
#[cfg(feature = "friends")]
fn friends_worlds_are_told_apart_by_their_hosts_never_by_their_secrets() {
const PORT: u16 = 50000;
const RELAY: &str = "relay.example.com:443";
// A short code: its nameplate, then its secret.
let short = |nameplate: &str, secret: &str| Code {
nameplate: nameplate.into(),
secret: secret.into(),
};
let invite = |secret: u8, host: &str, relay: Option<Code>| {
let code = invite::Invite {
secret: [secret; invite::SECRET],
port: PORT,
endpoints: vec![format!("{host}:{PORT}").parse().unwrap()],
relay: relay.map(|code| invite::RelayRef {
address: RELAY.into(),
code,
}),
}
.encode();
NativeWorldId::of(None, &format!("{FRIEND}{code}"))
};
assert_ne!(
invite(1, "192.168.1.5", None),
invite(1, "192.168.1.6", None),
"two friends"
);
assert_ne!(
invite(1, "192.168.1.5", Some(short("ABCD", "WXYZ"))),
invite(1, "192.168.1.5", Some(short("EFGH", "WXYZ"))),
"two friends on other networks behind the same private address"
);
assert_eq!(
invite(1, "192.168.1.5", Some(short("ABCD", "WXYZ"))),
invite(2, "192.168.1.5", Some(short("ABCD", "QRST"))),
"the invite's and the relay code's secrets take no part"
);
let typed = |code: Code| NativeWorldId::of(None, &format!("{FRIEND}{code}"));
assert_eq!(typed(short("ABCD", "WXYZ")), typed(short("ABCD", "QRST")));
assert_eq!(
typed(short("ABCD", "WXYZ")),
NativeWorldId::Server(format!("{FRIEND}ABCD"))
);
}
#[test]
fn a_hud_tells_chat_and_death_from_other_screens() {
assert_eq!(
+9 -1
View File
@@ -102,8 +102,16 @@ impl Game<'_> {
if food && m.health < m.attrs.max_health {
// feed(player, hand, stack, 2, 1).
let sound = nautilus::eat_sound(m);
// TamableAnimal.feed: heals 2 x the food's nutrition, 1 for an
// item with no food component.
let nutrition = self
.res
.items()
.get(item)
.and_then(|i| i.food.as_ref())
.map(|f| f.0 as i32);
let m = &mut self.mobs[index];
m.heal(2.0);
m.heal(nautilus::feed_heal(nutrition));
self.use_nautilus_food(item);
self.nautilus_sound(index, sound);
return true;
@@ -93,9 +93,10 @@ impl ApplicationHandler for Missing {
if self.window.is_some() {
return;
}
let attributes =
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"))
.with_inner_size(winit::dpi::LogicalSize::new(1040.0, 700.0));
let attributes = horizon_client::window::with_horizon_icon(
Window::default_attributes().with_title("Horizon"),
)
.with_inner_size(winit::dpi::LogicalSize::new(1040.0, 700.0));
let made = event_loop
.create_window(attributes)
.map_err(Into::into)
+102 -23
View File
@@ -226,6 +226,25 @@ impl Game<'_> {
});
}
/// `Entity.collide`'s `getEntityCollisions` for a body a piston or a
/// shulker lid pushes: the entities it can collide with
/// (`canCollideWith`, which for every mover but a vehicle is
/// `canBeCollidedWith`): a boat, a living shulker, a happy ghast held
/// still. A pushed boat or minecart collides by `canVehicleCollide`
/// instead, which this doesn't cover, and the player moves itself.
fn pushed_entity_colliders(&self, target: Target) -> Vec<Aabb> {
match target {
Target::Entity(EntityHandle::Boat(_) | EntityHandle::Cart(_)) => Vec::new(),
Target::Entity(handle) => {
let own = with_entity!(self, handle, entity => Entity::bounds(entity));
let mut boxes = self.item_entity_collisions();
boxes.retain(|b| *b != own);
boxes
}
_ => Vec::new(),
}
}
/// The collision view of the moving piston at `pos` takes the progress
/// its tick gave it. One no longer moving keeps its entry until
/// `sync_moving_pistons`.
@@ -284,6 +303,7 @@ impl Game<'_> {
self.shulker_push_player(movement);
return;
}
let entities = self.pushed_entity_colliders(target);
let Ok(moved) = pushed_movement(
SceneCollisions {
world: &self.world,
@@ -298,6 +318,7 @@ impl Game<'_> {
body.step_height,
body.on_ground,
None,
&entities,
) else {
return;
};
@@ -665,6 +686,7 @@ impl Game<'_> {
self.projectile_piston_block_effects(i, at);
}
self.piston_block_triggers(before, before);
self.release_after_block_move(target);
return;
}
// `Entity.move`: a projectile a cobweb or powder snow holds stops dead
@@ -695,6 +717,7 @@ impl Game<'_> {
}
_ => None,
};
let entities = self.pushed_entity_colliders(target);
let resolved = with_piston_noclip(piston_direction, || {
pushed_movement(
SceneCollisions {
@@ -709,6 +732,7 @@ impl Game<'_> {
step_height,
on_ground,
tnt.as_ref().map(|t| t.3),
&entities,
)
.unwrap_or(Vec3::ZERO)
});
@@ -747,6 +771,9 @@ impl Game<'_> {
|| limited.length_squared() - resolved.length_squared() < 1.0e-7;
if moved {
self.apply_push(target, resolved);
} else {
// A move the collisions cut to nothing still ends in the check.
self.release_after_block_move(target);
}
if let Some((i, from, ..)) = tnt {
self.finish_tnt_piston_move(i, from, resolved);
@@ -864,26 +891,37 @@ impl Game<'_> {
}
Target::Entity(handle) => {
with_entity_mut!(self, handle, entity => entity.move_by_piston(movement));
let Some(bounds) =
with_entity!(self, handle, entity => entity.piston_release_bounds())
else {
return;
};
let no_block_collision = {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
no_collision_empty(&mut scene, bounds)
};
with_entity_mut!(self, handle, entity => {
entity.release_after_piston(no_block_collision)
});
self.release_after_block_move(target);
}
}
}
/// `AbstractArrow.move` with a mover other than the arrow itself (a
/// piston, a shulker lid): after `Entity.move`, whether or not it went
/// anywhere, a stuck arrow or trident with nothing to collide with within
/// 0.06 starts falling (`shouldFall`, `startFalling`). Its own tick only
/// checks once the block at its position changes, a tick later.
fn release_after_block_move(&mut self, target: Target) {
let Target::Entity(handle) = target else {
return;
};
let Some(bounds) = with_entity!(self, handle, entity => entity.piston_release_bounds())
else {
return;
};
let no_block_collision = {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
no_collision_empty(&mut scene, bounds)
};
with_entity_mut!(self, handle, entity => {
entity.release_after_piston(no_block_collision)
});
}
/// `causeBounce`: a slime block sets the velocity along the movement axis.
fn bounce(&mut self, target: Target, movement: Direction) {
match target {
@@ -939,19 +977,16 @@ fn pushed_movement<W>(
step_height: f32,
on_ground: bool,
context: Option<CollisionContext<'_>>,
entities: &[Aabb],
) -> horizon_client::Result<Vec3>
where
W: BlockCollisionWorld<Error = std::io::Error> + EntityMovementWorld<Error = std::io::Error>,
{
// Without the entity's own context, the one `resolve` reads at its feet.
let context = context.unwrap_or_else(|| CollisionContext::position(before.min_position().y));
EntityCollisions::with_border(blocks, border, at).resolve_with_context(
before,
movement,
step_height,
on_ground,
context,
)
let mut collisions = EntityCollisions::with_border(blocks, border, at);
collisions.entities = entities;
collisions.resolve_with_context(before, movement, step_height, on_ground, context)
}
#[cfg(test)]
@@ -980,6 +1015,7 @@ mod tests {
0.0,
false,
None,
&[],
)
.expect("the push resolves");
// The wall stops the body 0.4 blocks short of the whole push.
@@ -1003,6 +1039,7 @@ mod tests {
0.0,
false,
None,
&[],
)
.expect("the push resolves");
assert!(
@@ -1521,6 +1558,48 @@ mod h255_mob_piston_ground_tests {
&mut std::collections::HashMap::new(),
);
}
/// `AbstractArrow.move` after a push the collisions cut to nothing: a stuck
/// arrow with nothing within 0.06 of it starts falling all the same.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_stuck_arrow_whose_push_a_wall_stops_falls_when_nothing_holds_it() {
with_game(|game| {
assert!(game.run_command("setblock 6 202 4 minecraft:stone"));
game.creatures.projectiles.clear();
assert!(game.run_command("summon minecraft:arrow 5.75 202.5 4.5 {NoGravity:0b}"));
let i = game
.creatures
.projectiles
.iter()
.position(|p| p.arrow.is_some())
.expect("the arrow");
{
let p = &mut game.creatures.projectiles[i];
p.velocity = Vec3::ZERO;
p.stuck = true;
p.arrow.as_mut().unwrap().in_ground = true;
}
let p = &game.creatures.projectiles[i];
let (bounds, at) = (p.bounds(), [p.position.x, p.position.z]);
let before = p.position;
game.move_pushed(
Target::Entity(EntityHandle::Projectile(i)),
bounds,
at,
0.0,
false,
Vec3::new(0.3, 0.0, 0.0),
Direction::East,
&mut std::collections::HashMap::new(),
);
let p = &game.creatures.projectiles[i];
assert_eq!(p.position, before, "the stone stops the push");
assert!(
!p.arrow.as_ref().unwrap().in_ground,
"nothing holds it, so it falls"
);
});
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn h255_added_no_request_and_exhausted_budget_keep_state() {
@@ -0,0 +1,334 @@
//! Registered play-Ping regression with the production sender-flush completion boundary.
use crate::game::{Game, lan::LanSettings};
use horizon_client::lan::{Joined, NetConfig, Out, PlayerSender, guest::Guest, hub::HubConfig};
use horizon_protocol::{
buf::Reader,
packets::{
configuration::ClientInformation,
play::{Clientbound as C, Serverbound as S},
status,
},
};
use std::sync::{Arc, atomic::AtomicI32, mpsc};
fn decode(packet: S) -> (S, usize) {
let bytes = packet.encode();
let mut reader = Reader::new(&bytes);
let id = reader.varint().unwrap();
(S::decode(id, reader.rest()).unwrap(), bytes.len())
}
fn output(rx: &mpsc::Receiver<Out>) -> Vec<C> {
rx.try_iter()
.flat_map(|out| match out {
Out::Packet(body) => vec![body],
Out::Packets(bodies) => bodies,
Out::Disconnect(reason) => panic!("unexpected disconnect: {reason:?}"),
})
.map(|body| {
println!("actual outbound play body={body:02x?}");
let mut reader = Reader::new(&body);
let id = reader.varint().unwrap();
C::decode(id, reader.rest()).unwrap()
})
.collect()
}
fn pass(game: &mut Game<'_>, conn: u32, packet: S) {
let (packet, bytes) = decode(packet);
if let S::PingRequest(p) = &packet {
println!(
"actual decoded play PingRequest conn={conn} value={} bits={:016x}",
p.time, p.time as u64
);
}
let hub = &game.lan.as_ref().unwrap().hub;
assert_eq!(hub.guests.len(), 2);
assert_ne!(hub.guests[0].uuid, hub.guests[1].uuid);
assert_ne!(hub.guests[0].conn, hub.guests[1].conn);
let registered = hub.guest(conn).expect("origin connection registered");
assert_eq!(registered.uuid, u128::from(conn));
assert!(!registered.fake && !registered.sender.is_fake());
assert!(registered.left_reason.is_empty());
assert_eq!(registered.dimension, "minecraft:overworld");
let guest = game.lan.as_mut().unwrap().hub.guest_mut(conn).unwrap();
guest.inbox.push_back((packet, bytes));
guest.inbox_bytes += bytes;
// Already registered connections: this is a second level pass, without polling sockets.
game.level_pass.first = false;
game.level_pass.packets_drained = false;
let clock_before = game.world.game_time;
game.tick_lan_packets();
// tick_lan_packets has ended its Holding scope; production flushes these senders later.
horizon_client::lan::flush_all(
game.lan
.as_ref()
.unwrap()
.hub
.guests
.iter()
.map(|g| &g.sender),
);
assert_eq!(
game.world.game_time, clock_before,
"sender completion adds no world tick"
);
println!("actual sender completion conn={conn} world_clock={clock_before}");
assert!(
game.level_pass.packets_drained,
"real Game packet phase completed"
);
assert!(
game.lan
.as_ref()
.unwrap()
.hub
.guest(conn)
.unwrap()
.inbox
.is_empty(),
"decoded request was drained by the actual Hub"
);
}
fn fixture() -> (Game<'static>, [mpsc::Receiver<Out>; 2]) {
let mut game = crate::game::native_machines::tests::server_game();
game.build_registries();
game.lan_host(
LanSettings {
port: 0,
guest_commands: false,
force_game_mode: false,
},
NetConfig {
announce: false,
..NetConfig::loopback(0, "play-ping-test".into(), 2)
},
HubConfig {
default_game_mode: 0,
guest_commands: false,
force_game_mode: false,
hardcore: false,
view_distance: 2,
simulation_distance: 2,
dimension: "minecraft:overworld".into(),
allow_flight: false,
seed: 0,
max_players: 2,
integrated: false,
levels: Default::default(),
},
)
.unwrap();
let receivers = [1, 2].map(|conn| {
let (tx, rx) = mpsc::channel();
let mut guest = Guest::new(
Joined {
conn,
uuid: u128::from(conn),
name: format!("PingGuest{conn}"),
properties: Vec::new(),
info: ClientInformation::new(2),
brand: "test".into(),
sender: PlayerSender::new(tx, Arc::default()),
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
expected: false,
configured: Default::default(),
},
conn as i32,
[0.5, 80.0, 0.5],
2,
);
guest.placed = true;
guest.loaded = true;
guest.loading = false;
guest.load_timeout = 0;
guest.last_action_tick = 7;
game.lan.as_mut().unwrap().hub.guests.push(guest);
rx
});
game.lan.as_mut().unwrap().hub.ticks = 123;
(game, receivers)
}
#[test]
#[ignore = "needs prepared resources and isolated scratch homes/cwd"]
fn registered_play_ping_echoes_exact_long_to_only_its_connection() {
let (mut game, receivers) = fixture();
for (turn, time) in [
1,
0,
-1,
i64::MIN,
i64::MAX,
0x0123_4567_89ab_cdef,
0xfedc_ba98_7654_3210_u64 as i64,
]
.into_iter()
.enumerate()
{
let index = turn % 2;
let conn = index as u32 + 1;
pass(&mut game, conn, S::PingRequest(status::Ping { time }));
let packets = output(&receivers[index]);
let echoed: Vec<_> = packets
.iter()
.filter_map(|p| match p {
C::PongResponse(p) => Some(p.time),
_ => None,
})
.collect();
assert_eq!(
echoed,
vec![time],
"missing or incorrect registered Game play-Ping reply"
);
assert_eq!(packets.len(), 1, "one request produces exactly one reply");
assert_eq!(
game.lan
.as_ref()
.unwrap()
.hub
.guest(conn)
.unwrap()
.last_action_tick,
7,
"transport ping does not count as a player action"
);
assert!(
output(&receivers[1 - index]).is_empty(),
"no reply to the other connection"
);
}
}
#[test]
#[ignore = "needs prepared resources and isolated scratch homes/cwd"]
fn dead_guest_play_ping_is_transport_not_an_action() {
let (mut game, receivers) = fixture();
game.lan.as_mut().unwrap().hub.guest_mut(1).unwrap().dead = true;
pass(
&mut game,
1,
S::PingRequest(status::Ping { time: i64::MIN }),
);
let packets = output(&receivers[0]);
assert!(matches!(packets.as_slice(), [C::PongResponse(p)] if p.time == i64::MIN));
assert!(output(&receivers[1]).is_empty());
assert_eq!(
game.lan
.as_ref()
.unwrap()
.hub
.guest(1)
.unwrap()
.last_action_tick,
7
);
}
#[test]
#[ignore = "needs prepared resources and isolated scratch homes/cwd"]
fn server_ping_client_pong_keeps_the_existing_prediction_route() {
let (mut game, receivers) = fixture();
let hub = &mut game.lan.as_mut().unwrap().hub;
let mut config = horizon_anticheat::Config::default();
config.log.clear();
config.strikes.clear();
config.prediction.enabled = true;
hub.set_anticheat(config);
hub.ticks = 123;
let holding = horizon_client::lan::Holding::start();
hub.anticheat_ping();
drop(holding);
// Ending Holding is not delivery: independently admit the existing flush boundary first.
for receiver in &receivers {
assert!(
output(receiver).is_empty(),
"actual server Ping is unavailable before flush"
);
}
println!("actual reverse Ping unavailable after Holding drop, before flush");
horizon_client::lan::flush_all(hub.guests.iter().map(|g| &g.sender));
for receiver in &receivers {
assert!(matches!(output(receiver).as_slice(), [C::Ping(p)] if p.id == 123));
}
pass(
&mut game,
1,
S::Pong(horizon_protocol::packets::configuration::Ping { id: 123 }),
);
let record = game
.lan
.as_mut()
.unwrap()
.hub
.guest_mut(1)
.unwrap()
.anticheat
.record
.as_deref_mut()
.expect("existing client Pong updates prediction");
assert_eq!(record.tick_end(0, false).seen, Some(123));
for receiver in &receivers {
assert!(output(receiver).is_empty());
}
}
#[test]
#[ignore = "needs prepared resources and isolated scratch homes/cwd"]
fn held_guest_play_ping_survives_the_acknowledgement_filter() {
let (mut game, receivers) = fixture();
let guest = game.lan.as_mut().unwrap().hub.guest_mut(1).unwrap();
guest.placed = false;
guest.held = true;
pass(&mut game, 1, S::PingRequest(status::Ping { time: -1 }));
assert!(matches!(output(&receivers[0]).as_slice(), [C::PongResponse(p)] if p.time == -1));
assert!(output(&receivers[1]).is_empty());
assert_eq!(
game.lan
.as_ref()
.unwrap()
.hub
.guest(1)
.unwrap()
.last_action_tick,
7
);
}
#[test]
#[ignore = "needs prepared resources and isolated scratch homes/cwd"]
fn handover_guest_play_ping_survives_the_acknowledgement_filter() {
let (mut game, receivers) = fixture();
let handover = horizon_client::lan::arrivals::Handover {
id: 0x1234,
since: 123,
record: serde_json::json!({"fixture": "handover-state-sentinel"}),
};
game.lan
.as_mut()
.unwrap()
.hub
.guest_mut(1)
.unwrap()
.handover = Some(handover.clone());
pass(
&mut game,
1,
S::PingRequest(status::Ping {
time: 0xfedc_ba98_7654_3210_u64 as i64,
}),
);
let packets = output(&receivers[0]);
assert!(
matches!(packets.as_slice(), [C::PongResponse(p)]
if p.time == 0xfedc_ba98_7654_3210_u64 as i64),
"missing matching handover play-Ping reply"
);
assert!(output(&receivers[1]).is_empty());
let guest = game.lan.as_ref().unwrap().hub.guest(1).unwrap();
assert_eq!(guest.last_action_tick, 7);
assert_eq!(guest.handover.as_ref(), Some(&handover));
}
@@ -0,0 +1,131 @@
//! Where the game hands the player models to crates above the engine
//! (`horizon_hooks::Hooks::frame` and `tick`, spec #14091): only a window's game calls them,
//! never the integrated server's thread or a dedicated server. With no hook registered nothing is
//! built.
use super::Game;
use horizon_client::join_seq::JoinList;
use horizon_client::mobs::Mob;
use horizon_client::mobs::anim::BodyPose;
use horizon_hooks::engine;
use horizon_hooks::players::{Frame, Kind, PlayerModel, PlayerModels};
use std::sync::Arc;
/// Registers the hooks of the Horizon parts still in this crate (cosmetics: spec #14091 step 5
/// moves them out). Before the hooks are sealed.
pub(crate) fn register() {
if super::cosmetics::enabled()
&& let Err(e) = horizon_hooks::register(&super::cosmetics::HOOKS)
{
log::error!("Cosmetics: their hooks came too late: {e}");
}
}
/// The models of the world a window shows: its own first, then the other players and mannequins.
struct Models<'a> {
own: Option<&'a mut Mob>,
mobs: &'a mut JoinList<Mob>,
remote: &'a super::remote::Remote,
}
impl Models<'_> {
fn find(&mut self, id: u32) -> Option<&mut Mob> {
match self.own.as_deref_mut() {
Some(m) if m.id == id => Some(m),
_ => self
.mobs
.iter_mut()
.find(|m| m.id == id && kind(m).is_some()),
}
}
}
/// What kind of player model `m` is, if it is one (the window's own model aside).
fn kind(m: &Mob) -> Option<Kind> {
match &*m.kind.id {
"minecraft:player" => Some(Kind::Player),
"minecraft:mannequin" => Some(Kind::Mannequin),
_ => None,
}
}
fn model(m: &Mob, kind: Kind, uuid: Option<u128>) -> PlayerModel {
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
PlayerModel::new(m.id, kind, uuid, m.age, dx * dx + dz * dz > 1e-4)
}
impl PlayerModels for Models<'_> {
fn connection(&self) -> u64 {
self.remote.session.serial()
}
fn for_each(&self, f: &mut dyn FnMut(PlayerModel)) {
if let Some(m) = self.own.as_deref() {
f(model(m, Kind::Own, None));
}
for m in self.mobs.iter() {
if let Some(kind) = kind(m) {
let uuid = (kind == Kind::Player)
.then(|| self.remote.player_uuid(m.id))
.flatten();
f(model(m, kind, uuid));
}
}
}
fn body_pose(&self, id: u32) -> Option<&Arc<dyn BodyPose>> {
match self.own.as_deref() {
Some(m) if m.id == id => m.emote.as_ref(),
_ => self
.mobs
.iter()
.find(|m| m.id == id && kind(m).is_some())?
.emote
.as_ref(),
}
}
fn set_body_pose(&mut self, id: u32, pose: Option<Arc<dyn BodyPose>>) {
if let Some(m) = self.find(id) {
m.emote = pose;
}
}
}
impl Game<'_> {
/// Once a frame, before drawing.
pub(super) fn client_hooks_frame(&mut self) {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
if !engine::any() {
return;
}
self.cosmetics_say();
let connection = self.remote.as_ref().map_or(0, |r| r.session.serial());
engine::frame(&Frame::new(connection));
self.cosmetics_frame();
}
/// Once a client tick in a world: the player's own emote, then the hooks.
pub(super) fn client_hooks_tick(&mut self) {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
self.tick_emotes();
if !engine::any() {
return;
}
self.cosmetics_say();
let Some(remote) = self.remote.as_ref() else {
return;
};
engine::tick(&mut Models {
own: self.player_model.as_mut(),
mobs: &mut self.mobs,
remote,
});
}
}
+8 -9
View File
@@ -6731,7 +6731,7 @@ impl Game<'_> {
if self.studio_replay.is_none() {
self.tick_player_model();
}
self.tick_emotes();
self.client_hooks_tick();
if !dead {
self.remote_tick_using();
}
@@ -10714,17 +10714,11 @@ impl Game<'_> {
/// The profile name of another player the server sent, by entity id.
/// The UUID of another player's entity in the level.
pub(super) fn remote_player_uuid(&self, id: u32) -> Option<u128> {
let r = self.remote.as_ref()?;
let uuid = *r.level_players.get(&(id as i32))?;
(uuid != r.profile).then_some(uuid)
self.remote.as_ref()?.player_uuid(id)
}
pub(super) fn remote_player_name(&self, id: u32) -> Option<&str> {
let r = self.remote.as_ref()?;
let uuid = r.level_players.get(&(id as i32))?;
if *uuid == r.profile {
return None;
}
r.level_player_name(id as i32, uuid)
r.level_player_name(id as i32, &r.player_uuid(id)?)
}
/// Status for `horizon-ctl status`.
/// The other players' entities in the level and their names (their
@@ -11505,6 +11499,11 @@ impl Remote {
pub(super) fn profile(&self) -> u128 {
self.profile
}
/// Another player's profile UUID, by their entity id (None for the local player).
pub(super) fn player_uuid(&self, id: u32) -> Option<u128> {
let uuid = *self.level_players.get(&(id as i32))?;
(uuid != self.profile).then_some(uuid)
}
/// The entity id of a player in the level, by profile UUID.
pub(super) fn player_entity(&self, uuid: u128) -> Option<u32> {
self.level_players
@@ -726,6 +726,37 @@ impl Game<'_> {
}
}
}
/// The ticks the frame shows (animated textures, the HUD, a shader pack's clock): the
/// client's, or under `HORIZON_STILL` (`FixedStep::still`) the world's game time, which
/// moves only while the world runs.
pub(super) fn visual_ticks(&self) -> u64 {
if self.clock.still() {
self.world.game_time.max(0) as u64
} else {
self.ticks
}
}
/// The clock of the animated textures: `Minecraft.runTick` ticks the `TextureManager`
/// only while the level runs normally. Under `HORIZON_STILL` it is the world's ticks.
pub(super) fn texture_clock(&self) -> u64 {
if self.clock.still() {
self.visual_ticks()
} else {
self.texture_ticks
}
}
/// `Util.getMillis` for what the frame shows (the world border's scroll, the hearts' blink,
/// the glint's under the switch): under `HORIZON_STILL`, the world's ticks in milliseconds.
pub(super) fn visual_millis(&self) -> i64 {
if self.clock.still() {
self.visual_ticks() as i64 * 50
} else {
self.started.elapsed().as_millis() as i64
}
}
}
impl Game<'_> {
/// What the packs' Polytone animation modes read this tick (wiki "Variant Textures"): the
+1 -1
View File
@@ -823,7 +823,7 @@ impl Game<'_> {
for (location, rgba) in ready {
self.store_skin(location, rgba);
}
self.cosmetics_frame();
self.client_hooks_frame();
}
/// `Hud.extractTabList`: the player list while the player-list key is
/// held. A local world shows it with a joined LAN guest or a `list`
+91 -52
View File
@@ -141,11 +141,12 @@ fn pause_buttons(
remote: bool,
links: bool,
additions: bool,
split: bool,
rows: &[(&'static str, u8)],
) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
let row3 = links || additions;
let (x0, y0) = pause_grid(w, h, u8::from(row3) + u8::from(split));
let down = 24.0 * f32::from(u8::from(row3) + u8::from(split));
let extra = u8::from(row3) + rows.len() as u8;
let (x0, y0) = pause_grid(w, h, extra);
let down = 24.0 * f32::from(extra);
let mut v = vec![
(x0 + 4.0, y0 + 50.0, 204.0, "menu.returnToGame", 0, true),
(x0 + 4.0, y0 + 74.0, 98.0, "gui.advancements", 4, true),
@@ -159,9 +160,9 @@ fn pause_buttons(
// CommonDialogData.computeExternalTitle of Dialogs.SERVER_LINKS.
v.push((x0 + 4.0, y0 + 122.0, 204.0, "menu.server_links", 11, true));
}
if split {
let y = y0 + 122.0 + if row3 { 24.0 } else { 0.0 };
v.push((x0 + 4.0, y, 204.0, SPLIT_SETTINGS, 30, true));
for (k, &(key, id)) in rows.iter().enumerate() {
let y = y0 + 122.0 + 24.0 * (f32::from(u8::from(row3)) + k as f32);
v.push((x0 + 4.0, y, 204.0, key, id, true));
}
v.extend([
(x0 + 4.0, y0 + 122.0 + down, 98.0, "menu.options", 1, true),
@@ -228,14 +229,17 @@ fn pause_icon_focus_ids(friends: bool) -> Vec<u8> {
.collect()
}
const SPLIT_SETTINGS: &str = "horizon.pages.split_settings";
/// The pause menu's first id for the pages crates registered there: id `ADDED_PAGES + i` opens
/// registered page `i`.
const ADDED_PAGES: u8 = 40;
fn pause_icons(
w: f32,
h: f32,
links: bool,
additions: bool,
split: bool,
rows: &[(&'static str, u8)],
) -> [(f32, f32, &'static str); 4] {
let (x0, y0) = pause_grid(w, h, u8::from(links || additions) + u8::from(split));
let (x0, y0) = pause_grid(w, h, u8::from(links || additions) + rows.len() as u8);
let (x0, y) = (x0 + 60.0, y0 + 98.0);
[
(x0, y, "gui/sprites/pause_menu/bug"),
@@ -307,7 +311,7 @@ impl Game<'_> {
self.remote.is_some() && self.integrated.is_none()
}
pub(super) fn draw_pause_menu(&mut self, batch: &mut HudBatch) {
let friends = self.friends_view().shown;
let friends = self.friends_shown();
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
@@ -331,7 +335,7 @@ impl Game<'_> {
self.external_server(),
links,
additions.is_some(),
self.options.split_guest || self.split_hud.menu,
&self.pause_rows(),
) {
let is_focused = self.pause_focus == Some(id);
let hover = active && (ui::hovered(self.cursor, x, y, bw, 20.0) || is_focused);
@@ -358,15 +362,9 @@ impl Game<'_> {
tooltip = Some(Text::translate("menu.custom_options.tooltip", &[]));
}
}
for (i, (x, y, sprite)) in pause_icons(
w,
h,
links,
additions.is_some(),
self.options.split_guest || self.split_hud.menu,
)
.into_iter()
.enumerate()
for (i, (x, y, sprite)) in pause_icons(w, h, links, additions.is_some(), &self.pause_rows())
.into_iter()
.enumerate()
{
let active = icon_active(i, friends);
let is_focused = (self.pause_focus == Some(10) && i == FRIENDS_ICON)
@@ -375,11 +373,7 @@ impl Game<'_> {
// The icon has no label: name it for controller focus and the
// control socket's widget tree.
if i == FRIENDS_ICON {
horizon_client::widgets::record(
horizon_client::friends_screen::TITLE,
[x, y, 20.0, 20.0],
active,
);
horizon_client::widgets::record(super::friends::TITLE, [x, y, 20.0, 20.0], active);
}
ui::button(
batch,
@@ -416,7 +410,7 @@ impl Game<'_> {
batch.sprite_scaled(atlas, sprite, x + 3.0, y + 3.0, 15.0, 15.0, u32::MAX);
if hover {
tooltip = Some(if i == FRIENDS_ICON {
Text::literal(horizon_client::friends_screen::title())
Text::literal(super::friends::title())
} else {
Text::translate(ICON_LABELS[i], &[])
});
@@ -456,6 +450,10 @@ impl Game<'_> {
// PauseScreen: `new SocialInteractionsScreen(this)`.
15 => self.open_social(true),
30 => self.open_split_settings(),
id @ ADDED_PAGES.. => {
let page = usize::from(id - ADDED_PAGES);
self.open_options_at(false, horizon_client::options_ui::Page::Added(page));
}
// addCustomDialogButtons: DialogConnectionAccess.openDialog of the
// additions dialog, from the pause menu.
14 => {
@@ -471,6 +469,23 @@ impl Game<'_> {
}
Ok(())
}
/// The pause menu's full-width rows above Options: Splitscreen's settings, then the pages
/// crates registered for the pause menu (`horizon_hooks::pages`), by their title.
fn pause_rows(&self) -> Vec<(&'static str, u8)> {
let split = self.options.split_guest || self.split_hud.menu;
let pages = horizon_hooks::pages::engine::all().iter().enumerate();
let added = pages
.filter(|(i, page)| {
page.place() == horizon_hooks::pages::Place::Pause
&& *i < usize::from(u8::MAX - ADDED_PAGES)
})
.map(|(i, page)| (page.title(), ADDED_PAGES + i as u8));
split
.then_some((SPLIT_SETTINGS, 30))
.into_iter()
.chain(added)
.collect()
}
pub(super) fn pause_click(&mut self) -> Result<()> {
let (w, h) = {
let size = self
@@ -482,13 +497,7 @@ impl Game<'_> {
};
let links = !self.server_links().is_empty();
let additions = self.pause_screen_additions();
let icons = pause_icons(
w,
h,
links,
additions.is_some(),
self.options.split_guest || self.split_hud.menu,
);
let icons = pause_icons(w, h, links, additions.is_some(), &self.pause_rows());
let friends = icons[FRIENDS_ICON];
let hit = pause_buttons(
w,
@@ -496,7 +505,7 @@ impl Game<'_> {
self.external_server(),
links,
additions.is_some(),
self.options.split_guest || self.split_hud.menu,
&self.pause_rows(),
)
.into_iter()
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
@@ -509,9 +518,8 @@ impl Game<'_> {
.map(|i| 12 + i as u8)
})
.or_else(|| {
(self.friends_view().shown
&& ui::hovered(self.cursor, friends.0, friends.1, 20.0, 20.0))
.then_some(10)
(self.friends_shown() && ui::hovered(self.cursor, friends.0, friends.1, 20.0, 20.0))
.then_some(10)
});
if let Some(id) = hit {
self.pause_focus = Some(id);
@@ -536,10 +544,10 @@ impl Game<'_> {
self.external_server(),
links,
self.pause_screen_additions().is_some(),
self.options.split_guest || self.split_hud.menu,
&self.pause_rows(),
);
let mut active_ids: Vec<u8> = buttons.into_iter().filter(|b| b.5).map(|b| b.4).collect();
let friends = self.friends_view().shown;
let friends = self.friends_shown();
active_ids.extend(pause_icon_focus_ids(friends));
if active_ids.is_empty() {
return Ok(());
@@ -584,20 +592,20 @@ impl Game<'_> {
self.external_server(),
links,
self.pause_screen_additions().is_some(),
self.options.split_guest || self.split_hud.menu,
&self.pause_rows(),
);
let mut rects: Vec<(u8, f32, f32, f32)> = buttons
.into_iter()
.filter(|b| b.5)
.map(|b| (b.4, b.0, b.1, b.2))
.collect();
let friends = self.friends_view().shown;
let friends = self.friends_shown();
for (i, (x, y, _)) in pause_icons(
w,
h,
links,
self.pause_screen_additions().is_some(),
self.options.split_guest || self.split_hud.menu,
&self.pause_rows(),
)
.into_iter()
.enumerate()
@@ -915,7 +923,10 @@ impl Game<'_> {
#[cfg(test)]
mod tests {
use super::{Screen, death_buttons, pause_buttons, pause_icon_focus_ids, pause_icons};
use super::{
ADDED_PAGES, SPLIT_SETTINGS, Screen, death_buttons, pause_buttons, pause_icon_focus_ids,
pause_icons,
};
#[test]
fn death_buttons_sit_on_whole_gui_pixels() {
@@ -998,7 +1009,7 @@ mod tests {
#[test]
fn pause_menu_uses_disconnect_for_servers_and_keeps_world_options() {
for (remote, label) in [(false, "menu.returnToMenu"), (true, "menu.disconnect")] {
let buttons = pause_buttons(427.0, 240.0, remote, false, false, false);
let buttons = pause_buttons(427.0, 240.0, remote, false, false, &[]);
let quit = buttons.iter().find(|b| b.4 == 2).unwrap();
assert_eq!(quit.3, label);
assert!(quit.5);
@@ -1023,14 +1034,14 @@ mod tests {
#[test]
fn server_links_adds_a_full_width_row_above_options() {
let with = pause_buttons(427.0, 240.0, true, true, false, false);
let without = pause_buttons(427.0, 240.0, true, false, false, false);
let with = pause_buttons(427.0, 240.0, true, true, false, &[]);
let without = pause_buttons(427.0, 240.0, true, false, false, &[]);
let links = with.iter().find(|b| b.4 == 11).unwrap();
assert_eq!(links.2, 204.0);
assert_eq!(links.3, "menu.server_links");
assert!(links.5);
// The icon row sits above it; Options and Disconnect move down 24.
let icons = pause_icons(427.0, 240.0, true, false, false);
let icons = pause_icons(427.0, 240.0, true, false, &[]);
let y0_icons = ((240.0f32 - 190.0) * 0.25).floor() + 98.0;
assert_eq!(icons[0].1, y0_icons);
assert!(links.1 > icons[0].1);
@@ -1046,8 +1057,8 @@ mod tests {
fn pause_screen_additions_takes_the_server_links_row() {
// getCustomAdditions: one additions button in row 3, never next to the
// links button, so the grid keeps one extra row either way.
let with = pause_buttons(427.0, 240.0, true, true, true, false);
let links = pause_buttons(427.0, 240.0, true, true, false, false);
let with = pause_buttons(427.0, 240.0, true, true, true, &[]);
let links = pause_buttons(427.0, 240.0, true, true, false, &[]);
assert!(with.iter().all(|b| b.4 != 11));
let button = with.iter().find(|b| b.4 == 14).unwrap();
assert_eq!(
@@ -1060,14 +1071,42 @@ mod tests {
assert_eq!(add.1, link.1);
}
assert_eq!(
pause_icons(427.0, 240.0, true, true, false)[0].1,
pause_icons(427.0, 240.0, true, false, false)[0].1
pause_icons(427.0, 240.0, true, true, &[])[0].1,
pause_icons(427.0, 240.0, true, false, &[])[0].1
);
}
#[test]
fn registered_pages_stack_above_options_in_the_pause_menu() {
let rows = [
(SPLIT_SETTINGS, 30),
("test.page.a", ADDED_PAGES),
("test.page.b", ADDED_PAGES + 2),
];
let buttons = pause_buttons(427.0, 300.0, true, false, false, &rows);
let y = |id| buttons.iter().find(|b| b.4 == id).unwrap().1;
assert_eq!(y(ADDED_PAGES), y(30) + 24.0);
assert_eq!(y(ADDED_PAGES + 2), y(ADDED_PAGES) + 24.0);
assert_eq!(
y(1),
y(ADDED_PAGES + 2) + 24.0,
"Options under the last one"
);
// The grid stays centred: three more rows start it higher.
let without = pause_buttons(427.0, 300.0, true, false, false, &[]);
let back = |b: &[(f32, f32, f32, &str, u8, bool)]| b.iter().find(|b| b.4 == 0).unwrap().1;
assert!(back(&buttons) < back(&without));
}
#[test]
fn split_settings_preserves_dialog_rows_and_icon_actions() {
for (links, additions) in [(false, false), (true, false), (false, true), (true, true)] {
let buttons = pause_buttons(427.0, 300.0, true, links, additions, true);
let buttons = pause_buttons(
427.0,
300.0,
true,
links,
additions,
&[(SPLIT_SETTINGS, 30)],
);
let split = buttons.iter().find(|b| b.4 == 30).unwrap();
let options = buttons.iter().find(|b| b.4 == 1).unwrap();
assert_eq!(options.1, split.1 + 24.0);
@@ -1080,7 +1119,7 @@ mod tests {
}
assert!(buttons.iter().all(|b| b.4 != 12 && b.4 != 13));
assert!(
!pause_buttons(427.0, 300.0, true, links, additions, false)
!pause_buttons(427.0, 300.0, true, links, additions, &[])
.iter()
.any(|b| b.4 == 30)
);
+2
View File
@@ -261,6 +261,8 @@ impl<'a> Split<'a> {
.get(i.wrapping_sub(1))
.map(|g| g.options.clone()),
);
let seat = self.guests.get(i.wrapping_sub(1)).map_or(1, |g| g.seat);
horizon_hooks::engine::set_seat(seat - 1);
self.turn = i;
}
@@ -126,6 +126,7 @@ impl Split<'_> {
// Until it has a profile, a scratch options file: never P1's.
let options = Self::folder()?.join(".joining/options.txt");
OPTIONS.set(Some(options.clone()));
horizon_hooks::engine::set_seat(seat - 1);
let mut settings = self.host.settings.clone();
if pad.is_some() {
// The Controller setting is P1's own: a player who pressed a pad to join plays
@@ -135,6 +136,7 @@ impl Split<'_> {
}
let game = self.start_guest(seat, &options, settings);
OPTIONS.set(None);
horizon_hooks::engine::set_seat(0);
let mut game = game?;
if let Some(pad) = pad {
// The press that joined it doesn't act in its game.
@@ -299,16 +301,31 @@ impl Split<'_> {
}
let profile = profiles.join(&name);
let options = profile.join("options.txt");
let mut settings = if options.exists() {
horizon_client::settings::Settings::load(&options)
let made = if options.exists() {
None
} else {
std::fs::create_dir_all(&profile)?;
let mut new = self.host.settings.clone();
// The Controller setting is P1's, as on the profile page (`join`).
new.controller.enabled |= self.guests[i].pad.is_some();
// On P1's seat, so the crates' settings stores write P1's values too.
let turn = horizon_hooks::seat();
horizon_hooks::engine::set_seat(0);
new.save(&options);
new
horizon_hooks::engine::set_seat(turn);
Some(new)
};
// The crates' stores load into the guest's seat, before its turn starts below.
let turn = horizon_hooks::seat();
horizon_hooks::engine::set_seat(self.guests[i].seat - 1);
let mut settings = match made {
Some(new) => {
horizon_client::settings::Settings::load_stores(&options);
new
}
None => horizon_client::settings::Settings::load(&options),
};
horizon_hooks::engine::set_seat(turn);
// A profile an earlier join saved with P1's off, or the player's pad would be dead.
settings.controller.enabled |= self.guests[i].pad.is_some();
// P1's own world through memory (`Ok`), else P1's server over the network (`Err`).
@@ -516,9 +516,10 @@ impl<'a> Split<'a> {
continue;
}
// Opened without the focus: whoever types in the game's window keeps the keyboard.
let attributes = horizon_client::window::with_icon(Window::default_attributes())
.with_active(false)
.with_visible(window_shown(true, self.offscreen));
let attributes =
horizon_client::window::with_horizon_icon(Window::default_attributes())
.with_active(false)
.with_visible(window_shown(true, self.offscreen));
// A monitor's window covers it, borderless. On macOS not the system's fullscreen, a
// Space of its own that hides the other windows as it slides in.
let placement = self.placement(*key).cloned();
+1 -1
View File
@@ -6,10 +6,10 @@
//! the time left. Vanilla has no such commands: they reach operators' command trees (completions included)
//! as a native mod's commands do (`moderation_commands`), and the game runs them itself.
use super::{Game, chat_filter::Reply, dedicated, lan::LanHost};
use horizon_chat_filter::{duration_text, now_millis, parse_duration};
use horizon_client::{mod_api::NativeCommand, text::Text};
use horizon_protocol::packets::play::{self, Clientbound as C};
use horizon_protocol::text::translatable;
use horizon_server_files::duration::{duration_text, now_millis, parse_duration};
use horizon_server_files::user_lists::{BanEntry, BanList, NameAndId, format_date};
/// The operator level `/tempban` needs, as `/ban`'s.
@@ -273,8 +273,9 @@ impl ApplicationHandler for Game<'_> {
let fullscreen = self.settings.fullscreen || self.options.fullscreen;
let size = self.options.window_size.map(f64::from);
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
let attributes =
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"));
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(
-56
View File
@@ -1,56 +0,0 @@
//! Horizon's look, given to the engine's extension points at startup (docs/layers.md): the
//! windows' icon and the colours, sizes and fonts of the menus vanilla doesn't have
//! (`docs/design-system.md`).
//! Its own module so it can leave the engine crate as a pure file move, for the official
//! executable to call.
use horizon_client::menu_style::{Fonts, MenuStyle, OverlayColours, SettingsColours, Sizes};
use horizon_theme as theme;
/// Give the engine Horizon's look; call it once, before the first window opens.
pub(crate) fn give() {
horizon_client::window::set_icon_png(theme::ICON_PNG);
let (blocks, dark) = (theme::BLOCKS_DARK, theme::DARK);
horizon_client::menu_style::install(MenuStyle {
settings: SettingsColours {
page: blocks.page,
ink: blocks.ink,
ink_soft: blocks.ink_soft,
muted: blocks.muted,
outline: blocks.outline,
accent: blocks.accent,
face: blocks.face,
// The face's hover, lighter than `face`: the settings screen's own shade.
face_hover: 0x6a50e6,
success: blocks.success,
warn: blocks.warn,
danger: blocks.danger,
},
overlay: OverlayColours {
page: dark.page,
backdrop_muted: theme::BACKDROP_MUTED,
ink: dark.ink,
ink_soft: dark.ink_soft,
rule: dark.rule,
accent: dark.accent,
on_accent: dark.on_accent,
},
sizes: Sizes {
caption: theme::CAPTION.px,
label: theme::LABEL.px,
ui: theme::UI.px,
body: theme::BODY.px,
title: theme::TITLE.px,
display: theme::DISPLAY.px,
micro: theme::MICRO.px,
radius: theme::RADIUS,
radius_plate: theme::RADIUS_PLATE,
control_large: theme::CONTROL_LARGE,
},
fonts: Some(Fonts {
regular: theme::fonts::GEIST_REGULAR,
medium: theme::fonts::GEIST_MEDIUM,
semibold: theme::fonts::GEIST_SEMIBOLD,
mono: theme::fonts::GEIST_MONO,
}),
});
}
+171 -1
View File
@@ -504,6 +504,69 @@ impl ServerAdvancements {
w.f32(r["experience"].as_f64().unwrap_or(0.0) as f32);
(cooking_category(kind, r), vec![&r["ingredient"]])
}
// DyeRecipe.display: the target, then the dyes kept to those with
// the DYE component, shown as the demo of dyeing that target.
// Its placement is the target and the dye.
"minecraft:crafting_dye" => {
let Some(dye_component) = sync.id("minecraft:data_component_type", "minecraft:dye")
else {
return Vec::new();
};
let (target, dye) = (&r["target"], &r["dye"]);
w.varint(
sync.id("minecraft:recipe_display", "minecraft:crafting_shapeless")
.unwrap_or(0),
);
w.varint(2);
w.varint(slot("minecraft:tag"));
holder_set(&mut w, target, &item);
w.varint(slot("minecraft:only_with_component"));
w.varint(slot("minecraft:tag"));
holder_set(&mut w, dye, &item);
w.varint(dye_component);
w.varint(slot("minecraft:dyed"));
w.varint(slot("minecraft:only_with_component"));
w.varint(slot("minecraft:tag"));
holder_set(&mut w, dye, &item);
w.varint(dye_component);
w.varint(slot("minecraft:tag"));
holder_set(&mut w, target, &item);
station(&mut w, "minecraft:crafting_table");
(
crafting_category(r["category"].as_str().unwrap_or("")).name(),
vec![target, dye],
)
}
// ImbueRecipe.display: four materials, the source kept to any
// potion, four materials again, and the potion-wrapped result.
// Its placement names the source in the middle cell.
"minecraft:crafting_imbue" => {
let (source, material) = (&r["source"], &r["material"]);
w.varint(
sync.id("minecraft:recipe_display", "minecraft:crafting_shaped")
.unwrap_or(0),
);
w.varint(3);
w.varint(3);
w.varint(9);
for cell in 0..9 {
if cell == 4 {
w.varint(slot("minecraft:with_any_potion"));
}
w.varint(slot("minecraft:tag"));
holder_set(&mut w, if cell == 4 { source } else { material }, &item);
}
w.varint(slot("minecraft:with_any_potion"));
result(&mut w);
station(&mut w, "minecraft:crafting_table");
(
crafting_category(r["category"].as_str().unwrap_or("")).name(),
vec![
material, material, material, material, source, material, material,
material, material,
],
)
}
_ => return Vec::new(),
};
self.entry_tail(&mut w, r, sync, category, &placed, flags);
@@ -756,7 +819,15 @@ mod tests {
"minecraft:redstone_ore": {"protocol_id": 2},
"minecraft:redstone": {"protocol_id": 3},
"minecraft:furnace": {"protocol_id": 4},
"minecraft:blast_furnace": {"protocol_id": 5}
"minecraft:blast_furnace": {"protocol_id": 5},
"minecraft:leather_helmet": {"protocol_id": 6},
"minecraft:crafting_table": {"protocol_id": 7},
"minecraft:arrow": {"protocol_id": 8},
"minecraft:lingering_potion": {"protocol_id": 9},
"minecraft:tipped_arrow": {"protocol_id": 10}
}},
"minecraft:data_component_type": {"entries": {
"minecraft:dye": {"protocol_id": 46}
}},
"minecraft:slot_display": {"entries": {
"minecraft:empty": {"protocol_id": 0},
@@ -962,6 +1033,105 @@ mod tests {
std::fs::remove_dir_all(&root).unwrap();
}
/// DyeRecipe.display: the target, then the dyes kept to what carries the
/// DYE component, the demo of dyeing the target with one, a crafting
/// table; its placement is the target and the dye.
#[test]
fn a_dye_recipe_reaches_the_book_as_its_dyed_demo() {
let root = resources(
"dye_recipe",
&[(
"leather_helmet_dyed",
r##"{"type":"minecraft:crafting_dye","dye":"#minecraft:dyes",
"group":"dyed_armor","result":{"id":"minecraft:leather_helmet"},
"target":"minecraft:leather_helmet"}"##,
)],
);
let (sync, e) = entry(&root, "minecraft:leather_helmet_dyed");
let item = |n: &str| sync.id("minecraft:item", n).unwrap();
let leather = item("minecraft:leather_helmet");
let dye = SlotDisplay::OnlyWithComponent(
Box::new(SlotDisplay::Tag(HolderSet::Tag("minecraft:dyes".into()))),
sync.id("minecraft:data_component_type", "minecraft:dye")
.unwrap(),
);
assert_eq!(
e.display,
RecipeDisplay::Shapeless {
ingredients: vec![
SlotDisplay::Tag(HolderSet::Items(vec![leather])),
dye.clone(),
],
result: SlotDisplay::Dyed {
dye: Box::new(dye),
target: Box::new(SlotDisplay::Tag(HolderSet::Items(vec![leather]))),
},
station: SlotDisplay::Item(item("minecraft:crafting_table")),
}
);
assert_eq!(
e.requirements,
Some(vec![
HolderSet::Items(vec![leather]),
HolderSet::Tag("minecraft:dyes".into()),
])
);
let _ = std::fs::remove_dir_all(&root);
}
/// ImbueRecipe.display: a 3×3 of four materials, the source kept to any
/// potion, four materials again; the result is potion-wrapped, and the
/// source sits in the middle of the placement.
#[test]
fn an_imbue_recipe_reaches_the_book_with_any_potion() {
let root = resources(
"imbue_recipe",
&[(
"tipped_arrow",
r#"{"type":"minecraft:crafting_imbue","material":"minecraft:arrow",
"result":{"count":8,"id":"minecraft:tipped_arrow"},
"source":"minecraft:lingering_potion"}"#,
)],
);
let (sync, e) = entry(&root, "minecraft:tipped_arrow");
let item = |n: &str| sync.id("minecraft:item", n).unwrap();
let material = SlotDisplay::Tag(HolderSet::Items(vec![item("minecraft:arrow")]));
let source = SlotDisplay::Tag(HolderSet::Items(vec![item("minecraft:lingering_potion")]));
let mut cells = vec![material.clone(); 4];
cells.push(SlotDisplay::WithAnyPotion(Box::new(source.clone())));
cells.extend(std::iter::repeat_n(material, 4));
assert_eq!(
e.display,
RecipeDisplay::Shaped {
width: 3,
height: 3,
ingredients: cells,
result: SlotDisplay::WithAnyPotion(Box::new(SlotDisplay::ItemStack(Stack::new(
item("minecraft:tipped_arrow"),
8,
)))),
station: SlotDisplay::Item(item("minecraft:crafting_table")),
}
);
let arrow = HolderSet::Items(vec![item("minecraft:arrow")]);
let potion = HolderSet::Items(vec![item("minecraft:lingering_potion")]);
assert_eq!(
e.requirements,
Some(vec![
arrow.clone(),
arrow.clone(),
arrow.clone(),
arrow.clone(),
potion,
arrow.clone(),
arrow.clone(),
arrow.clone(),
arrow,
])
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_recipe_with_an_unknown_ingredient_kind_stays_out_of_the_book() {
let root = resources(
+5 -36
View File
@@ -38,6 +38,7 @@ use horizon_world::{
};
use std::sync::{Arc, Mutex, atomic::Ordering};
mod acknowledgements;
/// GameEvent types (ClientboundGameEventPacket).
pub mod chat;
pub mod chat_filter;
@@ -2416,41 +2417,6 @@ impl Hub {
events
}
/// Keeps, in a frozen player's inbox, the packets that answer the server
/// (teleport, chunk batch, loaded, keep-alive, ping, tick end, client
/// information) and drops the rest. A chat message or signed command is
/// dropped too, but the messages it says the client has seen are taken
/// first (`unpackAndApplyLastSeen`), and a chat acknowledgement is handled
/// here, in the order they came: the client counts them as acknowledged,
/// and its next message after the freeze would not validate otherwise.
pub(crate) fn keep_acknowledgements(&mut self, i: usize, events: &mut Vec<HostEvent>) {
let inbox = std::mem::take(&mut self.guests[i].inbox);
let mut bytes = 0;
for (packet, size) in inbox {
match &packet {
S::Chat(c) => {
self.apply_last_seen(i, &c.last_seen, events);
}
S::ChatCommandSigned(c) => {
self.apply_last_seen(i, &c.last_seen, events);
}
S::ChatAck(c) => self.chat_ack(i, c.offset, events),
S::AcceptTeleportation(_)
| S::PlayerLoaded(_)
| S::ChunkBatchReceived(_)
| S::ClientInformation(_)
| S::ClientTickEnd(_)
| S::KeepAlive(_)
| S::Pong(_) => {
bytes += size;
self.guests[i].inbox.push_back((packet, size));
}
_ => {}
}
}
self.guests[i].inbox_bytes = bytes;
}
/// Handle the players in this level's packets (`ServerGamePacketListenerImpl`
/// for each), before the level's scheduled block ticks so a place landed in
/// the same tick as a falling block's schedule still sees the support
@@ -4069,7 +4035,8 @@ impl Hub {
| S::RecipeBookSeenRecipe(_)
| S::ClientTickEnd(_)
| S::KeepAlive(_)
| S::Pong(_) => false,
| S::Pong(_)
| S::PingRequest(_) => false,
S::MovePlayerPos(p) => self.moved_far(i, [p.x, p.y, p.z]),
S::MovePlayerPosRot(p) => self.moved_far(i, [p.x, p.y, p.z]),
S::PlayerInput(_) => self.guests[i].client_loaded(),
@@ -4167,6 +4134,8 @@ impl Hub {
g.max_unacknowledged = 10;
}
S::Pong(p) => self.anticheat_pong(i, p.id),
// ServerGamePacketListenerImpl.handlePingRequest: echo on this connection.
S::PingRequest(p) => self.send_to(conn, &C::PongResponse(p)),
// handleAttack is the game's too: a move queued behind an attack would move
// the player (or land them on the ground, losing a falling attack's critical
// hit) before the game checks their reach and state.
@@ -0,0 +1,40 @@
//! Transport acknowledgements retained while a guest is held or handed over.
use super::*;
impl Hub {
/// Keeps, in a frozen player's inbox, the packets that answer the server
/// (teleport, chunk batch, loaded, keep-alive, ping, tick end, client
/// information) and drops the rest. A chat message or signed command is
/// dropped too, but the messages it says the client has seen are taken
/// first (`unpackAndApplyLastSeen`), and a chat acknowledgement is handled
/// here, in the order they came: the client counts them as acknowledged,
/// and its next message after the freeze would not validate otherwise.
pub(crate) fn keep_acknowledgements(&mut self, i: usize, events: &mut Vec<HostEvent>) {
let inbox = std::mem::take(&mut self.guests[i].inbox);
let mut bytes = 0;
for (packet, size) in inbox {
match &packet {
S::Chat(c) => {
self.apply_last_seen(i, &c.last_seen, events);
}
S::ChatCommandSigned(c) => {
self.apply_last_seen(i, &c.last_seen, events);
}
S::ChatAck(c) => self.chat_ack(i, c.offset, events),
S::AcceptTeleportation(_)
| S::PlayerLoaded(_)
| S::ChunkBatchReceived(_)
| S::ClientInformation(_)
| S::ClientTickEnd(_)
| S::KeepAlive(_)
| S::Pong(_)
| S::PingRequest(_) => {
bytes += size;
self.guests[i].inbox.push_back((packet, size));
}
_ => {}
}
}
self.guests[i].inbox_bytes = bytes;
}
}
-1
View File
@@ -6,7 +6,6 @@ extern crate self as horizon_client;
pub use horizon_client_lib::*;
pub mod entry;
pub mod game;
mod horizon_look;
pub mod mod_api;
#[cfg(test)]
+48 -12
View File
@@ -2362,19 +2362,13 @@ fn never_removed_when_far(s: Species) -> bool {
| Species::Wither
)
}
/// Movement speed bonus multiplier (baby zombies are 50% faster, etc.).
/// The movement speed multiplier of the mob's transient `minecraft:baby`
/// modifier (`Zombie.SPEED_MODIFIER_BABY` +0.5, `Piglin.SPEED_MODIFIER_BABY`
/// +0.2, both `ADD_MULTIPLIED_BASE`): the same modifier `/attribute` reads, so
/// one taken away with `modifier remove` no longer speeds the baby up.
pub fn speed_bonus(m: &Mob) -> f64 {
match m.species() {
Species::Zombie
| Species::Husk
| Species::Drowned
| Species::ZombieVillager
| Species::ZombifiedPiglin
| Species::Piglin
if m.is_baby() =>
{
1.5
}
match super::finalize::transient(m, super::finalize::Attr::MovementSpeed) {
Some((amount, super::finalize::Op::AddMultipliedBase)) => 1.0 + amount,
_ => 1.0,
}
}
@@ -6290,3 +6284,45 @@ mod hurt_teleport_contact_tests {
);
}
}
#[cfg(test)]
mod baby_speed_tests {
use super::*;
use crate::mobs::{finalize, sensor_scan_tests::kind};
use horizon_core::geometry::Vec3;
use std::sync::Arc;
fn baby(species: Species, id: &str) -> Mob {
let mut k = kind();
let km = Arc::make_mut(&mut k);
km.id = id.into();
km.species = species;
let mut m = Mob::new(1, k, Vec3::ZERO, 0.0);
finalize::set_baby(&mut m, true);
m
}
/// #11994: movement reads the transient `minecraft:baby` modifier itself:
/// `Zombie.SPEED_MODIFIER_BABY` +0.5 and `Piglin.SPEED_MODIFIER_BABY` +0.2
/// (`ADD_MULTIPLIED_BASE`), and none once `/attribute ... modifier remove`
/// took it away, until the next `setBaby`.
#[test]
fn a_babys_speed_follows_its_baby_modifier() {
let mut zombie = baby(Species::Zombie, "minecraft:zombie");
assert_eq!(speed_bonus(&zombie), 1.5);
zombie
.extra
.attributes
.get_or_insert_default()
.baby_speed_removed = true;
assert_eq!(speed_bonus(&zombie), 1.0, "removed with /attribute");
finalize::set_baby(&mut zombie, true);
assert_eq!(speed_bonus(&zombie), 1.5, "setBaby adds it back");
// The piglin's is 0.2F widened to a double, not the zombie's 0.5.
let piglin = baby(Species::Piglin, "minecraft:piglin");
assert_eq!(speed_bonus(&piglin), 1.0 + f64::from(0.2f32));
let mut adult = baby(Species::Zombie, "minecraft:zombie");
finalize::set_baby(&mut adult, false);
assert_eq!(speed_bonus(&adult), 1.0);
}
}
+3 -20
View File
@@ -771,26 +771,9 @@ fn swim(p: &mut Poses, pos: f32, h: &Hum) {
lerp(amount, x, 0.3 * (pos * 0.333_333_34).mcos()),
);
}
/// The player model's parts a [`BodyPose`] poses, in its order.
pub const BODY_PARTS: [&str; 6] = [
"head",
"body",
"right_arm",
"left_arm",
"right_leg",
"left_leg",
];
/// A pose a higher layer lays over the player model's body after its vanilla animation (Horizon's
/// emotes, docs/cosmetics.md), handed to the model through [`Mob::emote`]. The engine names no
/// type of that layer (docs/layers.md); it downcasts its own back through [`std::any::Any`].
pub trait BodyPose: std::any::Any + std::fmt::Debug + Send + Sync {
/// Poses the [`BODY_PARTS`] at `age` (the mob's ticks): `at_rest` holds each part before the
/// animation, `poses` as the animation left it; a part the model lacks holds the identity in
/// both and is not written back. False when it poses nothing at `age` (`poses` is then
/// ignored).
fn pose(&self, age: f32, at_rest: &[[f32; 9]; 6], poses: &mut [[f32; 9]; 6]) -> bool;
}
/// A pose laid over the player model's body after its vanilla animation, and the parts it poses:
/// the hook's own types (`horizon_hooks::players`), kept here under their old path.
pub use horizon_hooks::players::{BODY_PARTS, BodyPose};
/// Apply the mob's model animation to `p` (at rest), then the pose laid over it, if any.
pub fn setup(
+64 -2
View File
@@ -1218,8 +1218,13 @@ impl Goal for Wander {
pub fn stable_destination(w: &MobWorld, p: [i32; 3]) -> bool {
!w.facts.is_air(w.world.block(p[0], p[1] - 1, p[2]))
}
/// `Bee.addAdditionalSaveData` (and NeutralMob's `anger_end_time`).
pub fn to_nbt(m: &Mob, map: &mut horizon_world::nbt::Compound) {
/// `Bee.addAdditionalSaveData` (and NeutralMob's `anger_end_time` and
/// `angry_at`).
pub fn to_nbt(
m: &Mob,
map: &mut horizon_world::nbt::Compound,
uuid_of: &mut impl FnMut(u32) -> Option<[i32; 4]>,
) {
use horizon_world::nbt::Tag;
let b = get(m).cloned().unwrap_or_default();
for (key, pos) in [("hive_pos", b.hive), ("flower_pos", b.flower)] {
@@ -1241,6 +1246,20 @@ pub fn to_nbt(m: &Mob, map: &mut horizon_world::nbt::Compound) {
map.insert("CannotEnterHiveTicks".into(), Tag::Int(b.stay_out_of_hive));
map.insert("CropsGrownSincePollination".into(), Tag::Int(b.crops_grown));
map.insert("anger_end_time".into(), Tag::Long(b.anger_end));
// `NeutralMob.addPersistentAngerSaveData`: `storeNullable`, nothing when
// the bee has no target. A mob target read from the save but not in the
// level (`Who::Mob(0)`) keeps the loaded UUID.
match b.angry_at {
Some(Who::Mob(0)) => {}
target => match target.and_then(|who| crate::entity_nbt::who_uuid(who, uuid_of)) {
Some(uuid) => {
map.insert("angry_at".into(), Tag::IntArray(uuid.to_vec()));
}
None => {
map.remove(&"angry_at".into());
}
},
}
}
/// `Bee.readAdditionalSaveData`.
pub fn from_nbt(m: &mut Mob, tag: &horizon_world::nbt::Tag) {
@@ -1260,6 +1279,13 @@ pub fn from_nbt(m: &mut Mob, tag: &horizon_world::nbt::Tag) {
Some(Tag::Long(v)) => *v,
_ => -1,
};
// `EntityReference.read`: the player's UUID is the player, any other one a
// mob the level may not have loaded (`Who::Mob(0)`).
let angry_at = match crate::save::uuid_from_tag(tag.get("angry_at")) {
Some(uuid) if uuid == crate::entity_nbt::player_uuid() => Some(Who::Player),
Some(_) => Some(Who::Mob(0)),
None => None,
};
let b = st(m);
b.ticks_without_nectar = int("TicksSincePollination").unwrap_or(0);
b.stay_out_of_hive = int("CannotEnterHiveTicks").unwrap_or(0);
@@ -1267,6 +1293,7 @@ pub fn from_nbt(m: &mut Mob, tag: &horizon_world::nbt::Tag) {
b.hive = pos("hive_pos");
b.flower = pos("flower_pos");
b.anger_end = anger_end;
b.angry_at = angry_at;
}
#[cfg(test)]
@@ -1419,4 +1446,39 @@ mod tests {
}
});
}
/// `NeutralMob.addPersistentAngerSaveData` and
/// `readPersistentAngerSaveData`: `anger_end_time` and `angry_at`, the
/// reference to the player the bee is angry at (`Bee` keeps the anger
/// itself). `mob_to_nbt`/`mob_from_nbt` are the pair `Game::mob_tree_nbt`
/// and `Game::load_mob` go through. Horizon modelled the target in
/// `Bee.angry_at` and wrote only the timer, so a bee that had stung a
/// player came back with its anger still counting but no one to attack.
#[test]
fn a_bees_anger_target_survives_a_save() {
use crate::entity_nbt::{mob_from_nbt, mob_to_nbt, player_uuid};
use horizon_world::nbt::Tag;
let mut m = bee();
{
let b = super::st(&mut m);
b.anger_end = 500;
b.angry_at = Some(Who::Player);
}
let saved = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(
saved.get("angry_at"),
Some(&Tag::IntArray(player_uuid().to_vec())),
"the save names the player"
);
let types = std::collections::HashMap::from([(m.kind.id.clone(), m.kind.clone())]);
let back = mob_from_nbt(&saved, &types, 2, None).unwrap();
let b = super::get(&back).unwrap();
assert_eq!(b.angry_at, Some(Who::Player), "the reloaded target");
assert_eq!(b.anger_end, 500, "the reloaded timer");
// `storeNullable`: a calm bee writes no `angry_at`, even over a
// compound that still names one.
let calm = bee();
let saved = mob_to_nbt(&calm, [1, 2, 3, 4], Some(&saved), None, |_| None);
assert_eq!(saved.get("angry_at"), None, "no target, no key");
}
}
+20 -14
View File
@@ -151,20 +151,26 @@ pub fn damage_bonus(levels: &[(Arc<str>, u32)], target: Option<Species>) -> f32
/// to the attribute map when the item is equipped.
pub fn attack_damage(m: &Mob, items: Option<&crate::items::Items>) -> f32 {
let base = f64::from(m.attrs.attack_damage);
let modifiers = m
.extra
.main_hand
.as_deref()
.and_then(|id| items?.get(id))
.map(|i| i.modifiers.as_slice());
modifiers.map_or(base, |modifiers| {
super::equip::attribute_with(
modifiers,
"minecraft:attack_damage",
base,
super::equip::Slot::MainHand,
)
}) as f32
let modifiers = match items {
Some(items) => m.extra.main_hand_stack().map_or_else(
|| {
m.extra
.main_hand
.as_deref()
.and_then(|id| items.get(id))
.map(|i| i.modifiers.clone())
.unwrap_or_default()
},
|stack| items.attribute_modifiers(stack),
),
None => Vec::new(),
};
super::equip::attribute_with(
&modifiers,
"minecraft:attack_damage",
base,
super::equip::Slot::MainHand,
) as f32
}
/// The extra damage of the mob's held weapon against a target species.
pub fn melee_bonus(m: &Mob, target: Option<Species>) -> f32 {
+21
View File
@@ -830,6 +830,12 @@ impl Mob {
let length = moved.x * moved.x + moved.y * moved.y + moved.z * moved.z;
let wanted_length = wanted.x * wanted.x + wanted.y * wanted.y + wanted.z * wanted.z;
if length > 1.0e-7 || wanted_length - length < 1.0e-7 {
// A move of a block or more through water or a reset block (a
// vine, a ladder) resets the fall before the move's drop counts.
if self.fall_distance != 0.0 && length >= 1.0 && fall_reset_hit(w, self.position, moved)
{
self.fall_distance = 0.0;
}
self.position = Vec3::new(
self.position.x + moved.x,
self.position.y + moved.y,
@@ -2189,3 +2195,18 @@ impl RestitutionWorld for MobRestitution<'_, '_> {
#[cfg(test)]
#[path = "living_tests.rs"]
mod tests;
/// `Entity.move`'s fall-reset ray for a mob, with the world's
/// `minecraft:fall_damage_resetting` blocks.
fn fall_reset_hit(w: &MobWorld, from: Vec3, moved: Vec3) -> bool {
let reset = w.block_tags.get("minecraft:fall_damage_resetting");
crate::entities::fall_reset_hit(
w.world,
w.fluid_tables,
|p| {
let name = &w.facts.get(w.world.block(p.x, p.y, p.z)).name;
reset.is_some_and(|t| t.contains(&**name))
},
from,
moved,
)
}
@@ -90,6 +90,11 @@ pub fn is_food(m: &Mob, item: &str, taming: &[String]) -> bool {
m.kind.food.iter().any(|t| t == item)
}
}
/// `TamableAnimal.feed`'s heal: twice the food's nutrition, or 1 for an item
/// without a food component (a fish bucket).
pub fn feed_heal(nutrition: Option<i32>) -> f32 {
nutrition.map_or(1.0, |n| 2.0 * n as f32)
}
/// The ambient, hurt and death sounds by age and whether it is under water.
pub fn sounds(m: &Mob) -> [&'static str; 3] {
let under = m.fluids.eye_in_water;
@@ -1122,3 +1127,22 @@ mod cooldown_memory_tests {
);
}
}
#[cfg(test)]
mod feed_heal_tests {
use super::feed_heal;
#[test]
fn heals_twice_the_nutrition_of_vanilla_foods() {
// Foods.java: cod 2, pufferfish 1, cooked cod 5, cooked salmon 6.
assert_eq!(feed_heal(Some(2)), 4.0);
assert_eq!(feed_heal(Some(1)), 2.0);
assert_eq!(feed_heal(Some(5)), 10.0);
assert_eq!(feed_heal(Some(6)), 12.0);
}
#[test]
fn item_without_food_component_heals_one() {
assert_eq!(feed_heal(None), 1.0);
}
}
+204 -12
View File
@@ -48,39 +48,144 @@ impl SeenGrid {
return;
}
if let Some(list) = self.cells.get_mut(&old)
&& let Some(k) = list.iter().position(|&j| j == i as u32)
&& let Ok(k) = list.binary_search(&(i as u32))
{
list.swap_remove(k);
list.remove(k);
}
self.cells.entry(at).or_default().push(i as u32);
let list = self.cells.entry(at).or_default();
let idx = match list.binary_search(&(i as u32)) {
Ok(k) | Err(k) => k,
};
list.insert(idx, i as u32);
}
/// The indices, ascending, of every entry whose box can meet `area`
/// (a superset: callers still test it). False when the area spans more
/// columns than the grid holds (or is not finite): scan them all.
/// columns than the grid has entries (at least 64), or is not finite:
/// scan them all.
pub fn candidates(&self, area: Aabb, out: &mut Vec<u32>) -> bool {
out.clear();
// A box meets `area` only if its minimum corner is below area.max
// and within `reach` (plus a margin for rounding) of area.min.
let margin = self.reach + 1.0;
let lo = column(area.min(Axis::X) - margin, area.min(Axis::Z) - margin);
let hi = column(area.max(Axis::X), area.max(Axis::Z));
let span = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
let (nx, nz) = (span(lo.0, hi.0), span(lo.1, hi.1));
if nx < 1 || nz < 1 || nx.saturating_mul(nz) > self.cells.len() as i64 {
let min_x = area.min(Axis::X) - margin;
let min_z = area.min(Axis::Z) - margin;
let max_x = area.max(Axis::X);
let max_z = area.max(Axis::Z);
if !min_x.is_finite() || !min_z.is_finite() || !max_x.is_finite() || !max_z.is_finite() {
return false;
}
let lo = column(min_x, min_z);
let hi = column(max_x, max_z);
let span = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
let (nx, nz) = (span(lo.0, hi.0), span(lo.1, hi.1));
let max_cells = (self.cell_of.len() as i64).max(64);
if nx < 1 || nz < 1 || nx.saturating_mul(nz) > max_cells {
return false;
}
let mut slices = [&[][..]; 16];
let mut num_slices = 0;
let mut overflow = false;
for x in lo.0..=hi.0 {
for z in lo.1..=hi.1 {
if let Some(list) = self.cells.get(&(x, z)) {
out.extend_from_slice(list);
if let Some(list) = self.cells.get(&(x, z))
&& !list.is_empty()
{
if num_slices < slices.len() {
slices[num_slices] = list.as_slice();
num_slices += 1;
} else {
overflow = true;
out.extend_from_slice(list);
}
}
}
}
out.sort_unstable();
if overflow {
for s in &slices[..num_slices] {
out.extend_from_slice(s);
}
out.sort_unstable();
return true;
}
merge_slices(&slices[..num_slices], out);
true
}
}
fn merge_2(s0: &[u32], s1: &[u32], out: &mut Vec<u32>) {
out.reserve(s0.len() + s1.len());
let (mut i, mut j) = (0, 0);
while i < s0.len() && j < s1.len() {
if s0[i] < s1[j] {
out.push(s0[i]);
i += 1;
} else {
out.push(s1[j]);
j += 1;
}
}
out.extend_from_slice(&s0[i..]);
out.extend_from_slice(&s1[j..]);
}
fn merge_slices(slices: &[&[u32]], out: &mut Vec<u32>) {
match slices.len() {
0 => {}
1 => out.extend_from_slice(slices[0]),
2 => merge_2(slices[0], slices[1], out),
3 => {
let (mut i, mut j, mut k) = (0, 0, 0);
let (s0, s1, s2) = (slices[0], slices[1], slices[2]);
out.reserve(s0.len() + s1.len() + s2.len());
while i < s0.len() || j < s1.len() || k < s2.len() {
let v0 = if i < s0.len() { s0[i] } else { u32::MAX };
let v1 = if j < s1.len() { s1[j] } else { u32::MAX };
let v2 = if k < s2.len() { s2[k] } else { u32::MAX };
if v0 <= v1 && v0 <= v2 {
out.push(v0);
i += 1;
} else if v1 <= v2 {
out.push(v1);
j += 1;
} else {
out.push(v2);
k += 1;
}
}
}
4 => {
let (mut i, mut j, mut k, mut l) = (0, 0, 0, 0);
let (s0, s1, s2, s3) = (slices[0], slices[1], slices[2], slices[3]);
out.reserve(s0.len() + s1.len() + s2.len() + s3.len());
while i < s0.len() || j < s1.len() || k < s2.len() || l < s3.len() {
let v0 = if i < s0.len() { s0[i] } else { u32::MAX };
let v1 = if j < s1.len() { s1[j] } else { u32::MAX };
let v2 = if k < s2.len() { s2[k] } else { u32::MAX };
let v3 = if l < s3.len() { s3[l] } else { u32::MAX };
if v0 <= v1 && v0 <= v2 && v0 <= v3 {
out.push(v0);
i += 1;
} else if v1 <= v2 && v1 <= v3 {
out.push(v1);
j += 1;
} else if v2 <= v3 {
out.push(v2);
k += 1;
} else {
out.push(v3);
l += 1;
}
}
}
_ => {
for s in slices {
out.extend_from_slice(s);
}
out.sort_unstable();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -145,4 +250,91 @@ mod tests {
);
assert!(!grid.candidates(huge, &mut out));
}
#[test]
fn candidates_merges_multiple_cells_in_strict_order() {
// Place boxes across 4 adjacent grid cells: (0, 0), (4, 0), (0, 4), (4, 4)
// With interleaving snapshot indices to test 1, 2, 3, and 4-way merges.
let boxes = vec![
boxed(0.5, 64.0, 0.5, 0.6), // 0: cell (0, 0)
boxed(4.5, 64.0, 0.5, 0.6), // 1: cell (1, 0)
boxed(0.5, 64.0, 4.5, 0.6), // 2: cell (0, 1)
boxed(4.5, 64.0, 4.5, 0.6), // 3: cell (1, 1)
boxed(0.8, 64.0, 0.8, 0.6), // 4: cell (0, 0)
boxed(4.8, 64.0, 0.8, 0.6), // 5: cell (1, 0)
boxed(0.8, 64.0, 4.8, 0.6), // 6: cell (0, 1)
boxed(4.8, 64.0, 4.8, 0.6), // 7: cell (1, 1)
];
let grid = SeenGrid::new(boxes.into_iter());
let mut out = Vec::new();
// Query covering only cell (0, 0): indices 0, 4
let q1 = boxed(0.1, 64.0, 0.1, 1.5);
assert!(grid.candidates(q1, &mut out));
assert_eq!(out, vec![0, 4]);
// Query covering cells (0, 0) and (1, 0): indices 0, 1, 4, 5
let q2 = Aabb::new(Vec3::new(0.0, 64.0, 0.0), Vec3::new(6.0, 66.0, 1.5));
assert!(grid.candidates(q2, &mut out));
assert_eq!(out, vec![0, 1, 4, 5]);
// Query covering all 4 cells: indices 0..8 in order
let q4 = Aabb::new(Vec3::new(0.0, 64.0, 0.0), Vec3::new(6.0, 66.0, 6.0));
assert!(grid.candidates(q4, &mut out));
assert_eq!(out, vec![0, 1, 2, 3, 4, 5, 6, 7]);
}
#[test]
#[ignore]
fn bench_dense_farm() {
let n = 300;
let mut seed = 0x1234_5678_9abc_def0_u64;
let mut next = |range: f64| {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
(seed >> 11) as f64 / (1u64 << 53) as f64 * range
};
// 300 mobs in a 1.5 x 1.5 block pen around (3.5, 64.0, 3.5) crossing cell boundary at 4.0
let mut boxes: Vec<Aabb> = (0..n)
.map(|_| {
let x = 3.5 + next(1.5);
let z = 3.5 + next(1.5);
boxed(x, 64.0, z, 0.6)
})
.collect();
let mut grid = SeenGrid::new(boxes.iter().copied());
let mut out = Vec::new();
let t = std::time::Instant::now();
let rounds = 100;
let mut fallback_count = 0;
let mut query_dur = std::time::Duration::ZERO;
let mut update_dur = std::time::Duration::ZERO;
for _ in 0..rounds {
for i in 0..n {
let area = boxes[i];
let t0 = std::time::Instant::now();
if !grid.candidates(area, &mut out) {
fallback_count += 1;
out.clear();
out.extend(0..n as u32);
}
query_dur += t0.elapsed();
// small jitter
let x = 3.5 + next(1.5);
let z = 3.5 + next(1.5);
boxes[i] = boxed(x, 64.0, z, 0.6);
let t1 = std::time::Instant::now();
grid.update(i, boxes[i]);
update_dur += t1.elapsed();
}
}
let elapsed = t.elapsed();
println!(
"{rounds} ticks of {n} mobs in 1.5x1.5 pen: {elapsed:?} (query: {:.3} ms, update: {:.3} ms, per tick: {:.3} ms, fallback: {fallback_count}/{})",
query_dur.as_secs_f64() * 1e3 / rounds as f64,
update_dur.as_secs_f64() * 1e3 / rounds as f64,
elapsed.as_secs_f64() * 1e3 / rounds as f64,
rounds * n
);
}
}
+88 -7
View File
@@ -30,6 +30,63 @@ pub struct TargetInfo {
/// `isPassenger`.
pub passenger: bool,
}
thread_local! {
static NEAR_BUF_POOL: std::cell::RefCell<Vec<Vec<u32>>> = const { std::cell::RefCell::new(Vec::new()) };
}
pub struct NearIter<'a> {
others: &'a [Seen],
indices: Option<Vec<u32>>,
pos: usize,
fallback: Option<std::slice::Iter<'a, Seen>>,
}
impl<'a> Iterator for NearIter<'a> {
type Item = &'a Seen;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
if let Some(indices) = &self.indices
&& self.pos < indices.len()
{
let idx = indices[self.pos];
self.pos += 1;
return Some(&self.others[idx as usize]);
}
if let Some(all) = &mut self.fallback {
return all.next();
}
None
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
if let Some(indices) = &self.indices {
let rem = indices.len().saturating_sub(self.pos);
(rem, Some(rem))
} else if let Some(all) = &self.fallback {
all.size_hint()
} else {
(0, Some(0))
}
}
}
impl Drop for NearIter<'_> {
fn drop(&mut self) {
if let Some(mut buf) = self.indices.take() {
buf.clear();
NEAR_BUF_POOL.with(|pool| {
if let Ok(mut pool) = pool.try_borrow_mut()
&& pool.len() < 8
{
pool.push(buf);
}
});
}
}
}
/// Chunk and section cache for raycast block traversals.
/// Caches the current section to avoid section index checks and hashmap lookups
/// for consecutive voxels in the same 16x16x16 section.
@@ -512,15 +569,39 @@ impl MobWorld<'_> {
}
/// The other mobs whose boxes may meet `area` (callers test the boxes),
/// in snapshot order: through the grid when there is one, else all.
pub fn others_near(&self, area: Aabb) -> impl Iterator<Item = &Seen> {
let mut near = Vec::new();
pub fn others_near(&self, area: Aabb) -> NearIter<'_> {
let mut buf = NEAR_BUF_POOL.with(|pool| {
pool.try_borrow_mut()
.ok()
.and_then(|mut p| p.pop())
.unwrap_or_default()
});
let indexed = self
.others_grid
.is_some_and(|grid| grid.candidates(area, &mut near));
let all = (!indexed).then(|| self.others.iter());
near.into_iter()
.map(|i| &self.others[i as usize])
.chain(all.into_iter().flatten())
.is_some_and(|grid| grid.candidates(area, &mut buf));
if indexed {
NearIter {
others: self.others,
indices: Some(buf),
pos: 0,
fallback: None,
}
} else {
buf.clear();
NEAR_BUF_POOL.with(|pool| {
if let Ok(mut pool) = pool.try_borrow_mut()
&& pool.len() < 8
{
pool.push(buf);
}
});
NearIter {
others: self.others,
indices: None,
pos: 0,
fallback: Some(self.others.iter()),
}
}
}
pub fn seen(&self, id: u32) -> Option<&Seen> {
match self.others_index {
+5 -258
View File
@@ -330,7 +330,7 @@ fn ping_all(servers: &[ServerEntry], pings: &Pings, icons: &Icons, file: &Path)
/// Save the list after a move or delete, keeping the icons pings stored in
/// servers.dat since it was loaded, and the Code of Conduct accepted on a server.
fn save_servers(file: &Path, servers: &mut [ServerEntry]) {
let _guard = SAVING.lock();
let _guard = servers::lock();
let saved = servers::load(file);
for entry in servers.iter_mut() {
if let Some(s) = saved.iter().find(|s| s.ip == entry.ip) {
@@ -340,11 +340,9 @@ fn save_servers(file: &Path, servers: &mut [ServerEntry]) {
}
let _ = servers::save(file, servers);
}
/// Held while servers.dat is read and written back.
static SAVING: Mutex<()> = Mutex::new(());
/// Store `icon` on the entries for `address` (OnlineServerEntry.updateServerList).
fn save_icon(file: &Path, address: &str, icon: Option<String>) {
let _guard = SAVING.lock();
let _guard = servers::lock();
let mut list = servers::load(file);
let mut changed = false;
for entry in list.iter_mut().filter(|e| e.ip == address) {
@@ -364,113 +362,6 @@ pub fn save_server_icon(saves: &Path, address: &str, png: &[u8]) {
}
}
/// Horizon's official survival server: a name and an address like any entry. The address is
/// `horizon_telemetry::server_status::OFFICIAL_ADDRESS`, the one place it is kept.
pub const OFFICIAL_SERVER: (&str, &str) = (
"Horizon Official",
horizon_telemetry::server_status::OFFICIAL_ADDRESS,
);
/// Beside servers.dat once the official server was offered, so a player who
/// deletes it never gets it back: the address offered, so a new one moves the
/// list once. The launcher writes the same file.
const OFFICIAL_MARKER: &str = "horizon_official_server.txt";
/// An address as the game connects to it: any case, 25565 when no port is given.
fn address_key(ip: &str) -> Option<(String, u16)> {
servers::parse_address(ip).map(|(h, p)| (h.to_ascii_lowercase(), p))
}
/// Puts the official server at the top of the list in `game_dir`, once: not
/// again after the player deletes it, and not when the list has its address.
/// When it moved (the marker names another address), the entries for its
/// earlier addresses move to the new one, once (`move_official`).
pub fn add_official_server(game_dir: &Path) -> std::io::Result<()> {
let (name, address) = OFFICIAL_SERVER;
let marker = game_dir.join(OFFICIAL_MARKER);
// A marker that is there but can't be read: offered already, at an address not known.
let offered = match std::fs::read_to_string(&marker) {
Ok(text) => Some(text),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(_) => Some(String::new()),
};
if offered
.as_deref()
.is_some_and(|a| address_key(a) == address_key(address))
{
return Ok(());
}
let file = game_dir.join("servers.dat");
let _guard = SAVING.lock();
let mut list = servers::load(&file);
let previous = horizon_telemetry::server_status::OFFICIAL_PREVIOUS;
if move_official(&mut list, address, previous) {
servers::save(&file, &list)?;
}
if offered.is_none()
&& !list
.iter()
.any(|s| address_key(&s.ip) == address_key(address))
{
servers::prepend(&file, &ServerEntry::new(name, address))?;
}
std::fs::write(marker, format!("{address}\n"))
}
/// Afri-Network, which the game added once below the official server (#9299) until the
/// partnership ended: the name and address the game gave its entry.
const WITHDRAWN_PARTNER: (&str, &str) = ("Afri-Network (South Africa)", "afri-network.co.za");
/// Beside servers.dat: the partner addresses the game offered, one per line (#9299).
const PARTNER_MARKER: &str = "horizon_partner_servers.txt";
/// Takes the withdrawn partner server back out of the list in `game_dir`, once, where the game
/// added it: the marker says it was offered and the entry still has the name the game gave it
/// (an entry the player made or renamed keeps its place). Then the marker goes.
pub fn withdraw_partner_servers(game_dir: &Path) -> std::io::Result<()> {
let marker = game_dir.join(PARTNER_MARKER);
let offered = match std::fs::read_to_string(&marker) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
};
let (name, address) = WITHDRAWN_PARTNER;
if offered
.lines()
.any(|l| address_key(l) == address_key(address))
{
let _guard = SAVING.lock();
servers::remove_where(&game_dir.join("servers.dat"), |n, ip| {
n == name && address_key(ip) == address_key(address)
})?;
}
std::fs::remove_file(marker)
}
/// Points the entries for an earlier official address at `address`, each
/// keeping the player's name and fields. One entry for the server at most: an
/// old one goes when the list already has `address`. Whether anything changed.
fn move_official(list: &mut Vec<ServerEntry>, address: &str, previous: &[&str]) -> bool {
let new = address_key(address);
let old = |ip: &str| {
let ip = address_key(ip);
ip.is_some() && ip != new && previous.iter().any(|p| address_key(p) == ip)
};
let mut present = list.iter().any(|s| address_key(&s.ip) == new);
let mut changed = false;
list.retain_mut(|s| {
if !old(&s.ip) {
return true;
}
changed = true;
if present {
return false;
}
present = true;
s.ip = address.to_owned();
true
});
changed
}
/// The address and kind of list row `index`: saved servers, then discovered LAN games.
fn entry_address(servers: &[ServerEntry], lan: &Scanner, index: usize) -> Option<(String, bool)> {
match servers.get(index) {
@@ -2100,7 +1991,7 @@ pub fn remember_direct_join(saves: &Path, address: &str) {
return;
}
let file = servers_file(saves);
let _guard = SAVING.lock();
let _guard = servers::lock();
let (shown, hidden) = servers::load_all(&file);
if shown.iter().chain(&hidden).any(|s| s.ip == address) {
return;
@@ -2297,22 +2188,10 @@ mod tests {
));
}
#[cfg(feature = "friends")]
#[test]
fn direct_connection_joins_a_pasted_invite_and_keeps_host_names() {
let invite = horizon_friends::invite::Invite {
secret: *b"0123456789",
port: 25580,
endpoints: vec!["192.168.1.20:25580".parse().unwrap()],
relay: None,
}
.encode();
assert_eq!(
direct_address(&format!(" {invite} ")),
format!("{}{invite}", crate::net::FRIEND)
);
fn direct_connection_keeps_host_names() {
for host in ["localhost", "example.org:25566", "abcdefgh", "[::1]:25568"] {
assert_eq!(direct_address(host), host);
assert_eq!(direct_address(&format!(" {host} ")), host);
}
}
@@ -2546,138 +2425,6 @@ mod tests {
assert!(favicon_bytes(&serde_json::json!({ "favicon": encoded })).is_none());
}
#[test]
fn the_official_server_is_added_once_at_the_top() {
let dir = std::env::temp_dir().join(format!("horizon-mp-official-{}", std::process::id()));
let (name, address) = OFFICIAL_SERVER;
let file = dir.join("servers.dat");
let ips = || -> Vec<String> { servers::load(&file).into_iter().map(|s| s.ip).collect() };
let fresh = |list: &[ServerEntry]| {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
if !list.is_empty() {
servers::save(&file, list).unwrap();
}
};
// A fresh list: just the official server, named like it.
fresh(&[]);
add_official_server(&dir).unwrap();
assert_eq!(servers::load(&file), [ServerEntry::new(name, address)]);
// An existing list: on top, the player's servers kept in their order.
let mine = [
ServerEntry::new("Mine", "127.0.0.1:2"),
ServerEntry::new("Other", "example.org"),
];
fresh(&mine);
add_official_server(&dir).unwrap();
assert_eq!(ips(), [address, "127.0.0.1:2", "example.org"]);
// Once only: a second start adds nothing.
add_official_server(&dir).unwrap();
assert_eq!(ips(), [address, "127.0.0.1:2", "example.org"]);
// Already there under the player's own name and spelling: not duplicated.
fresh(&[
ServerEntry::new("SMP", "Play.PlayHorizon.dev:25565"),
mine[0].clone(),
]);
add_official_server(&dir).unwrap();
assert_eq!(ips(), ["Play.PlayHorizon.dev:25565", "127.0.0.1:2"]);
// Deleted by the player: never back.
fresh(&mine);
add_official_server(&dir).unwrap();
servers::save(&file, &mine).unwrap();
add_official_server(&dir).unwrap();
assert_eq!(ips(), ["127.0.0.1:2", "example.org"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_withdrawn_partner_server_leaves_lists_the_game_put_it_in_once() {
let dir = std::env::temp_dir().join(format!("horizon-mp-partner-{}", std::process::id()));
let official = OFFICIAL_SERVER.1;
let file = dir.join("servers.dat");
let marker = dir.join(PARTNER_MARKER);
let ips = || -> Vec<String> { servers::load(&file).into_iter().map(|s| s.ip).collect() };
let fresh = |list: &[ServerEntry], offered: bool| {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
servers::save(&file, list).unwrap();
if offered {
std::fs::write(&marker, "afri-network.co.za\n").unwrap();
}
};
let added = ServerEntry::new("Afri-Network (South Africa)", "afri-network.co.za");
// The game added it: it goes, the rest stays in order, and only once.
fresh(
&[
ServerEntry::new("Horizon Official", official),
added.clone(),
ServerEntry::new("Mine", "127.0.0.1:2"),
],
true,
);
withdraw_partner_servers(&dir).unwrap();
assert_eq!(ips(), [official, "127.0.0.1:2"]);
assert!(!marker.exists());
servers::save(&file, std::slice::from_ref(&added)).unwrap();
withdraw_partner_servers(&dir).unwrap();
assert_eq!(ips(), ["afri-network.co.za"]);
// The player's own entry for that address (their name) stays.
fresh(&[ServerEntry::new("ZA", "Afri-Network.co.za:25565")], true);
withdraw_partner_servers(&dir).unwrap();
assert_eq!(ips(), ["Afri-Network.co.za:25565"]);
// Never offered (no marker): nothing changes.
fresh(&[added], false);
withdraw_partner_servers(&dir).unwrap();
assert_eq!(ips(), ["afri-network.co.za"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_moved_official_server_moves_the_old_entry_once() {
let dir = std::env::temp_dir().join(format!("horizon-mp-moved-{}", std::process::id()));
let address = OFFICIAL_SERVER.1;
let file = dir.join("servers.dat");
let marker = dir.join(OFFICIAL_MARKER);
let ips = || -> Vec<String> { servers::load(&file).into_iter().map(|s| s.ip).collect() };
let offered_before = |list: &[ServerEntry]| {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
servers::save(&file, list).unwrap();
std::fs::write(&marker, "horizon.bunchy7s.dev\n").unwrap();
};
// The old address under the player's own name and spelling: moved, name and place kept.
let mut old = ServerEntry::new("Our SMP", "Horizon.Bunchy7s.dev:25565");
old.icon = Some("AAAA".into());
offered_before(&[ServerEntry::new("Mine", "127.0.0.1:2"), old]);
add_official_server(&dir).unwrap();
let list = servers::load(&file);
assert_eq!(ips(), ["127.0.0.1:2", address]);
assert_eq!(
(list[1].name.as_str(), list[1].icon.as_deref()),
("Our SMP", Some("AAAA"))
);
assert_eq!(
std::fs::read_to_string(&marker).unwrap(),
format!("{address}\n")
);
// Once: the old address added back afterwards is the player's.
servers::prepend(&file, &ServerEntry::new("Old", "horizon.bunchy7s.dev")).unwrap();
add_official_server(&dir).unwrap();
assert_eq!(ips(), ["horizon.bunchy7s.dev", "127.0.0.1:2", address]);
// The new address already listed: no second entry.
offered_before(&[
ServerEntry::new("Old", "horizon.bunchy7s.dev"),
ServerEntry::new("New", address),
]);
add_official_server(&dir).unwrap();
assert_eq!(ips(), [address]);
// Deleted by the player before the move: not offered again; another port is left alone.
offered_before(&[ServerEntry::new("Test", "horizon.bunchy7s.dev:25570")]);
add_official_server(&dir).unwrap();
assert_eq!(ips(), ["horizon.bunchy7s.dev:25570"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn arrow_keys_select_servers_and_shift_moves_them() {
let dir = std::env::temp_dir().join(format!("horizon-mp-keys-{}", std::process::id()));
+53 -80
View File
@@ -30,6 +30,8 @@ use std::{
time::Duration,
};
use horizon_hooks::tunnel;
/// `CommonListenerCookie.serverCookies`: cookies the server asked the client
/// to store (`ClientboundStoreCookiePacket`), answered to
/// `ClientboundCookieRequestPacket`. A `transfer` carries them to the next
@@ -147,8 +149,20 @@ pub struct Session {
far: Arc<crate::lod_net::Inbox>,
/// The recording running, if any.
recorder: RecorderSlot,
/// This connection's number, new for each one ([`Session::serial`]).
serial: u64,
}
/// The next [`Session::serial`].
static NEXT_SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
fn next_serial() -> u64 {
NEXT_SERIAL.fetch_add(1, Ordering::Relaxed)
}
impl Session {
/// A number of its own for each connection, never 0: entity ids are per connection, so what
/// is kept by id goes when it changes (`horizon_hooks::players::PlayerModels::connection`).
pub fn serial(&self) -> u64 {
self.serial
}
/// The negotiated wire release, once the connection has been established.
pub fn server_version(&self) -> horizon_protocol::adapter::ServerVersion {
self.writer.lock().unwrap().as_ref().map_or(
@@ -254,6 +268,7 @@ impl Session {
debug: false,
far: Arc::new(crate::lod_net::Inbox::default()),
recorder: Arc::new(Mutex::new(None)),
serial: next_serial(),
}
}
fn start(
@@ -331,6 +346,7 @@ impl Session {
debug,
far,
recorder,
serial: next_serial(),
}
}
/// Everything received since the last poll.
@@ -496,62 +512,36 @@ impl Drop for Session {
}
/// An address of the form `friend:K7QM-3XPD` joins a friend's world through
/// its invite code (horizon-friends): the session goes through a tunnel the
/// client reaches on a loopback port, so everything after is a plain join.
#[cfg(feature = "friends")]
pub const FRIEND: &str = horizon_friends::ADDRESS_PREFIX;
/// Without the `friends` feature such an address is refused (`join_friend`).
#[cfg(not(feature = "friends"))]
pub const FRIEND: &str = "friend:";
/// its invite code (Play with Friends, through a hook's [`tunnel::Resolver`]):
/// the session goes through a tunnel the client reaches on a loopback port, so
/// everything after is a plain join.
pub const FRIEND: &str = tunnel::PREFIX;
/// Whether `code` is an invite code; with `full`, only a full one (a short
/// relay code could also be a host name).
#[cfg(feature = "friends")]
/// relay code could also be a host name). Without a resolver nothing is one.
pub fn is_invite(code: &str, full: bool) -> bool {
use horizon_friends::invite::{Parsed, parse};
match parse(code) {
Some(Parsed::Long(_)) => true,
Some(Parsed::Short(_)) => !full,
None => false,
}
}
/// Without the `friends` feature nothing is one.
#[cfg(not(feature = "friends"))]
pub fn is_invite(_code: &str, _full: bool) -> bool {
false
horizon_hooks::engine::tunnel().is_some_and(|r| r.is_code(code, full))
}
/// An address as players and logs see it: a friend's invite code stays
/// out of it (its secret is in there).
pub fn shown_address(address: &str) -> String {
match address.strip_prefix(FRIEND) {
#[cfg(feature = "friends")]
Some(code) => match horizon_friends::invite::parse(code) {
Some(horizon_friends::invite::Parsed::Short(c)) => {
format!("Friend's world ({}-****)", c.nameplate)
}
_ => "Friend's world".to_owned(),
Some(code) => match horizon_hooks::engine::tunnel().and_then(|r| r.nameplate(code)) {
Some(nameplate) => format!("Friend's world ({nameplate}-****)"),
None => "Friend's world".to_owned(),
},
#[cfg(not(feature = "friends"))]
Some(_) => "Friend's world".to_owned(),
None => address.to_owned(),
}
}
/// The relay the player set (`friendsRelay` in horizon.txt, beside
/// options.txt), else `HORIZON_RELAY_URL`. None by default.
/// options.txt), else the resolver's (`HORIZON_RELAY_URL`). None by default.
pub fn friends_relay_setting() -> Option<String> {
let dir = std::env::current_dir().ok()?;
crate::ui_theme::preference(&dir, "friendsRelay")
.filter(|r| !r.is_empty())
.or_else(relay_from_env)
}
#[cfg(feature = "friends")]
use horizon_friends::relay_url as relay_from_env;
/// `HORIZON_RELAY_URL` is the friends feature's: none without it.
#[cfg(not(feature = "friends"))]
fn relay_from_env() -> Option<String> {
None
.or_else(|| horizon_hooks::engine::tunnel().and_then(|r| r.relay_from_env()))
}
/// Remember the relay typed on Join a friend (empty forgets it).
pub fn save_friends_relay(relay: &str) {
@@ -562,55 +552,35 @@ pub fn save_friends_relay(relay: &str) {
/// Reach the friend's world from the code (directly, or through a relay)
/// and open the tunnel: the loopback address to connect to, and the token to send on it first.
#[cfg(feature = "friends")]
/// Without a resolver (a build without Play with Friends) the address is refused, never retried.
fn join_friend(
code: &str,
lang: &Language,
send: &Sender<Event>,
) -> Result<(String, u16, Vec<u8>), Reason> {
use horizon_friends::{
PathMode,
guest::{self, GuestConfig},
let Some(resolver) = horizon_hooks::engine::tunnel() else {
return Err(Reason::keyed_arg(
"horizon.multiplayer.invalid_address",
lang,
&shown_address(&format!("{FRIEND}{code}")),
));
};
status(send, &tr(lang, "horizon.multiplayer.finding_friend"));
let tunnel = guest::join(&GuestConfig {
code: code.to_owned(),
relay: friends_relay_setting(),
protocol: PROTOCOL_VERSION,
game: horizon_protocol::GAME_VERSION.to_owned(),
path: PathMode::from_env(),
})
.map_err(|e| Reason::plain(e.to_string()))?;
status(
send,
&if tunnel.direct {
tr(lang, "horizon.multiplayer.connected_friend_direct")
} else {
tr(lang, "horizon.multiplayer.connected_friend_relay")
},
);
let loopback = tunnel
.serve_loopback()
.map_err(|e| Reason::plain(e.to_string()))?;
Ok((
"127.0.0.1".to_owned(),
loopback.port,
loopback.token.to_vec(),
))
}
/// A build without Play with Friends: the address is refused, never retried.
#[cfg(not(feature = "friends"))]
fn join_friend(
code: &str,
lang: &Language,
_: &Sender<Event>,
) -> Result<(String, u16, Vec<u8>), Reason> {
Err(Reason::keyed_arg(
"horizon.multiplayer.invalid_address",
lang,
&shown_address(&format!("{FRIEND}{code}")),
))
let mut progress = |p: tunnel::Progress| {
let key = match p {
tunnel::Progress::Finding => "horizon.multiplayer.finding_friend",
tunnel::Progress::Connected { direct: true } => {
"horizon.multiplayer.connected_friend_direct"
}
tunnel::Progress::Connected { direct: false } => {
"horizon.multiplayer.connected_friend_relay"
}
};
status(send, &tr(lang, key));
};
let opened = resolver
.open(code, friends_relay_setting(), &mut progress)
.map_err(Reason::plain)?;
Ok(("127.0.0.1".to_owned(), opened.port, opened.token))
}
fn status(send: &Sender<Event>, text: &str) {
@@ -1873,6 +1843,7 @@ mod tests {
debug: false,
far: Arc::default(),
recorder: Arc::new(Mutex::new(None)),
serial: next_serial(),
};
let started = std::time::Instant::now();
if explicit_disconnect {
@@ -1976,6 +1947,7 @@ mod tests {
debug: false,
far: Arc::default(),
recorder: Arc::new(Mutex::new(None)),
serial: next_serial(),
};
let (thread_writer, thread_configuring, thread_recorder) = (
session.writer.clone(),
@@ -2259,6 +2231,7 @@ mod tests {
debug: false,
far: Arc::default(),
recorder: Arc::new(Mutex::new(None)),
serial: next_serial(),
};
let recorder = TestRecorder::default();
session.start_recording(recorder.clone(), |_| {});
+27 -43
View File
@@ -46,11 +46,11 @@ impl NoResources {
let x = ((ui.w - width) / 2.0).round();
let lines: Vec<Vec<String>> = PARAGRAPHS
.iter()
.map(|p| wrap(ui, Face::Regular, body(), p, width))
.map(|p| wrap(ui, Face::Regular, BODY, p, width))
.collect();
let text_h = lines.iter().map(|l| l.len() as f32 * LINE).sum::<f32>()
+ (lines.len() - 1) as f32 * PARAGRAPH_GAP;
let height = TITLE_LINE + 16.0 + text_h + 28.0 + control() + 20.0 + SMALL_LINE;
let height = TITLE_LINE + 16.0 + text_h + 28.0 + CONTROL + 20.0 + SMALL_LINE;
let y = ((ui.h - height) / 2.0).max(24.0).round();
let buttons_y = y + TITLE_LINE + 16.0 + text_h + 28.0;
let mut buttons = Vec::new();
@@ -59,7 +59,7 @@ impl NoResources {
("Download the launcher", Action::Download),
("Quit", Action::Quit),
] {
let w = (ui.measure(Face::Medium, button_text(), label) + 40.0).round();
let w = (ui.measure(Face::Medium, BUTTON_TEXT, label) + 40.0).round();
buttons.push(Button {
label,
action,
@@ -80,29 +80,21 @@ impl NoResources {
pub fn draw(&self, ui: &mut Ui) {
let layout = self.layout(ui);
ui.rounded(0.0, 0.0, ui.w, ui.h, 0.0, color::bg());
ui.rounded(0.0, 0.0, ui.w, ui.h, 0.0, color::BG);
let mut y = layout.y;
ui.text_line(
Face::Medium,
title_size(),
TITLE_SIZE,
TITLE_LINE,
layout.x,
y,
color::text(),
color::TEXT,
TITLE,
);
y += TITLE_LINE + 16.0;
for paragraph in &layout.lines {
for line in paragraph {
ui.text_line(
Face::Regular,
body(),
LINE,
layout.x,
y,
color::text(),
line,
);
ui.text_line(Face::Regular, BODY, LINE, layout.x, y, color::TEXT, line);
y += LINE;
}
y += PARAGRAPH_GAP;
@@ -110,20 +102,20 @@ impl NoResources {
for button in &layout.buttons {
let hovered = button.contains(ui.cursor);
let (plate, ink) = match (button.action, hovered) {
(Action::Download, false) => (color::accent(), color::on_accent()),
(Action::Download, false) => (color::ACCENT, color::ON_ACCENT),
(Action::Download, true) => {
(color::with_alpha(color::accent(), 0.85), color::on_accent())
(color::with_alpha(color::ACCENT, 0.85), color::ON_ACCENT)
}
(Action::Quit, false) => (color::raised(), color::text()),
(Action::Quit, true) => (color::hover(), color::text()),
(Action::Quit, false) => (color::RAISED, color::TEXT),
(Action::Quit, true) => (color::HOVER, color::TEXT),
};
let h = control();
let h = CONTROL;
ui.rounded(button.x, button.y, button.w, h, h / 2.0, plate);
let lw = ui.measure(Face::Medium, button_text(), button.label);
let lw = ui.measure(Face::Medium, BUTTON_TEXT, button.label);
let lx = button.x + ((button.w - lw) / 2.0).round();
ui.text_line(
Face::Medium,
button_text(),
BUTTON_TEXT,
h,
lx,
button.y,
@@ -131,11 +123,11 @@ impl NoResources {
button.label,
);
}
let small_y = layout.buttons[0].y + control() + 20.0;
let small_y = layout.buttons[0].y + CONTROL + 20.0;
for (i, line) in wrap(
ui,
Face::Regular,
ui_theme::small(),
ui_theme::SMALL,
&self.looked_in,
layout.width,
)
@@ -145,11 +137,11 @@ impl NoResources {
let line_y = small_y + i as f32 * SMALL_LINE;
ui.text_line(
Face::Regular,
ui_theme::small(),
ui_theme::SMALL,
SMALL_LINE,
layout.x,
line_y,
color::muted(),
color::MUTED,
line,
);
}
@@ -165,22 +157,14 @@ impl NoResources {
}
}
fn title_size() -> f32 {
ui_theme::sizes().title
}
const TITLE_SIZE: f32 = horizon_theme::TITLE.px;
const TITLE_LINE: f32 = 40.0;
fn body() -> f32 {
ui_theme::sizes().body
}
const BODY: f32 = horizon_theme::BODY.px;
const LINE: f32 = 24.0;
const PARAGRAPH_GAP: f32 = 10.0;
fn button_text() -> f32 {
ui_theme::sizes().ui
}
const BUTTON_TEXT: f32 = horizon_theme::UI.px;
const SMALL_LINE: f32 = 18.0;
fn control() -> f32 {
ui_theme::sizes().control_large
}
const CONTROL: f32 = horizon_theme::CONTROL_LARGE;
struct Layout {
x: f32,
@@ -200,7 +184,7 @@ struct Button {
impl Button {
fn contains(&self, (cx, cy): (f32, f32)) -> bool {
cx >= self.x && cx < self.x + self.w && cy >= self.y && cy < self.y + control()
cx >= self.x && cx < self.x + self.w && cy >= self.y && cy < self.y + CONTROL
}
}
@@ -248,7 +232,7 @@ mod tests {
for size in [[1280, 720], [800, 600], [2560, 1440]] {
let (download, quit, density) = with_ui(size, (0.0, 0.0), |ui| {
let layout = screen.layout(ui);
let center = |b: &Button| (b.x + b.w / 2.0, b.y + control() / 2.0);
let center = |b: &Button| (b.x + b.w / 2.0, b.y + CONTROL / 2.0);
(
center(&layout.buttons[0]),
center(&layout.buttons[1]),
@@ -271,7 +255,7 @@ mod tests {
let layout = screen.layout(ui);
let last = layout.buttons.last().unwrap();
assert!(
last.x + last.w <= ui.w && last.y + control() <= ui.h,
last.x + last.w <= ui.w && last.y + CONTROL <= ui.h,
"{size:?}"
);
});
@@ -282,10 +266,10 @@ mod tests {
fn text_wraps_within_the_column_and_keeps_every_word() {
with_ui([1280, 720], (0.0, 0.0), |ui| {
for paragraph in PARAGRAPHS {
let lines = wrap(ui, Face::Regular, body(), paragraph, 300.0);
let lines = wrap(ui, Face::Regular, BODY, paragraph, 300.0);
assert!(lines.len() > 1, "{paragraph}");
for line in &lines {
assert!(ui.measure(Face::Regular, body(), line) <= 300.0, "{line}");
assert!(ui.measure(Face::Regular, BODY, line) <= 300.0, "{line}");
}
assert_eq!(lines.join(" "), paragraph);
}
@@ -0,0 +1,262 @@
//! Horizon's own entries in the multiplayer list (docs/layers.md: Horizon layer): the official
//! server, added once at the top, and the withdrawn partner server, taken back out once. Behind
//! `horizon-extras` (docs/forking.md), so a plain build adds no server and holds no Horizon
//! address. It reads and writes servers.dat only through `horizon_protocol::servers`, ready to
//! leave the engine crates for a Horizon crate.
use horizon_protocol::servers::{self, ServerEntry};
use std::path::Path;
/// At startup of a player's game: the official server into the list in `game_dir` and the
/// withdrawn partner server out of it, a failure only logged.
pub fn update(game_dir: &Path) {
if let Err(e) = add_official_server(game_dir) {
log::warn!("could not add the official server to servers.dat: {e}");
}
if let Err(e) = withdraw_partner_servers(game_dir) {
log::warn!("could not take the withdrawn partner server out of servers.dat: {e}");
}
}
/// Horizon's official survival server: a name and an address like any entry. The address is
/// `horizon_telemetry::server_status::OFFICIAL_ADDRESS`, the one place it is kept.
pub const OFFICIAL_SERVER: (&str, &str) = (
"Horizon Official",
horizon_telemetry::server_status::OFFICIAL_ADDRESS,
);
/// Beside servers.dat once the official server was offered, so a player who
/// deletes it never gets it back: the address offered, so a new one moves the
/// list once. The launcher writes the same file.
const OFFICIAL_MARKER: &str = "horizon_official_server.txt";
/// An address as the game connects to it: any case, 25565 when no port is given.
fn address_key(ip: &str) -> Option<(String, u16)> {
servers::parse_address(ip).map(|(h, p)| (h.to_ascii_lowercase(), p))
}
/// Puts the official server at the top of the list in `game_dir`, once: not
/// again after the player deletes it, and not when the list has its address.
/// When it moved (the marker names another address), the entries for its
/// earlier addresses move to the new one, once (`move_official`).
pub fn add_official_server(game_dir: &Path) -> std::io::Result<()> {
let (name, address) = OFFICIAL_SERVER;
let marker = game_dir.join(OFFICIAL_MARKER);
// A marker that is there but can't be read: offered already, at an address not known.
let offered = match std::fs::read_to_string(&marker) {
Ok(text) => Some(text),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(_) => Some(String::new()),
};
if offered
.as_deref()
.is_some_and(|a| address_key(a) == address_key(address))
{
return Ok(());
}
let file = game_dir.join("servers.dat");
let _guard = servers::lock();
let mut list = servers::load(&file);
let previous = horizon_telemetry::server_status::OFFICIAL_PREVIOUS;
if move_official(&mut list, address, previous) {
servers::save(&file, &list)?;
}
if offered.is_none()
&& !list
.iter()
.any(|s| address_key(&s.ip) == address_key(address))
{
servers::prepend(&file, &ServerEntry::new(name, address))?;
}
std::fs::write(marker, format!("{address}\n"))
}
/// Afri-Network, which the game added once below the official server (#9299) until the
/// partnership ended: the name and address the game gave its entry.
const WITHDRAWN_PARTNER: (&str, &str) = ("Afri-Network (South Africa)", "afri-network.co.za");
/// Beside servers.dat: the partner addresses the game offered, one per line (#9299).
const PARTNER_MARKER: &str = "horizon_partner_servers.txt";
/// Takes the withdrawn partner server back out of the list in `game_dir`, once, where the game
/// added it: the marker says it was offered and the entry still has the name the game gave it
/// (an entry the player made or renamed keeps its place). Then the marker goes.
pub fn withdraw_partner_servers(game_dir: &Path) -> std::io::Result<()> {
let marker = game_dir.join(PARTNER_MARKER);
let offered = match std::fs::read_to_string(&marker) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
};
let (name, address) = WITHDRAWN_PARTNER;
if offered
.lines()
.any(|l| address_key(l) == address_key(address))
{
let _guard = servers::lock();
servers::remove_where(&game_dir.join("servers.dat"), |n, ip| {
n == name && address_key(ip) == address_key(address)
})?;
}
std::fs::remove_file(marker)
}
/// Points the entries for an earlier official address at `address`, each
/// keeping the player's name and fields. One entry for the server at most: an
/// old one goes when the list already has `address`. Whether anything changed.
fn move_official(list: &mut Vec<ServerEntry>, address: &str, previous: &[&str]) -> bool {
let new = address_key(address);
let old = |ip: &str| {
let ip = address_key(ip);
ip.is_some() && ip != new && previous.iter().any(|p| address_key(p) == ip)
};
let mut present = list.iter().any(|s| address_key(&s.ip) == new);
let mut changed = false;
list.retain_mut(|s| {
if !old(&s.ip) {
return true;
}
changed = true;
if present {
return false;
}
present = true;
s.ip = address.to_owned();
true
});
changed
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_official_server_is_added_once_at_the_top() {
let dir = std::env::temp_dir().join(format!("horizon-mp-official-{}", std::process::id()));
let (name, address) = OFFICIAL_SERVER;
let file = dir.join("servers.dat");
let ips = || -> Vec<String> { servers::load(&file).into_iter().map(|s| s.ip).collect() };
let fresh = |list: &[ServerEntry]| {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
if !list.is_empty() {
servers::save(&file, list).unwrap();
}
};
// A fresh list: just the official server, named like it.
fresh(&[]);
add_official_server(&dir).unwrap();
assert_eq!(servers::load(&file), [ServerEntry::new(name, address)]);
// An existing list: on top, the player's servers kept in their order.
let mine = [
ServerEntry::new("Mine", "127.0.0.1:2"),
ServerEntry::new("Other", "example.org"),
];
fresh(&mine);
add_official_server(&dir).unwrap();
assert_eq!(ips(), [address, "127.0.0.1:2", "example.org"]);
// Once only: a second start adds nothing.
add_official_server(&dir).unwrap();
assert_eq!(ips(), [address, "127.0.0.1:2", "example.org"]);
// Already there under the player's own name and spelling: not duplicated.
fresh(&[
ServerEntry::new("SMP", "Play.PlayHorizon.dev:25565"),
mine[0].clone(),
]);
add_official_server(&dir).unwrap();
assert_eq!(ips(), ["Play.PlayHorizon.dev:25565", "127.0.0.1:2"]);
// Deleted by the player: never back.
fresh(&mine);
add_official_server(&dir).unwrap();
servers::save(&file, &mine).unwrap();
add_official_server(&dir).unwrap();
assert_eq!(ips(), ["127.0.0.1:2", "example.org"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_withdrawn_partner_server_leaves_lists_the_game_put_it_in_once() {
let dir = std::env::temp_dir().join(format!("horizon-mp-partner-{}", std::process::id()));
let official = OFFICIAL_SERVER.1;
let file = dir.join("servers.dat");
let marker = dir.join(PARTNER_MARKER);
let ips = || -> Vec<String> { servers::load(&file).into_iter().map(|s| s.ip).collect() };
let fresh = |list: &[ServerEntry], offered: bool| {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
servers::save(&file, list).unwrap();
if offered {
std::fs::write(&marker, "afri-network.co.za\n").unwrap();
}
};
let added = ServerEntry::new("Afri-Network (South Africa)", "afri-network.co.za");
// The game added it: it goes, the rest stays in order, and only once.
fresh(
&[
ServerEntry::new("Horizon Official", official),
added.clone(),
ServerEntry::new("Mine", "127.0.0.1:2"),
],
true,
);
withdraw_partner_servers(&dir).unwrap();
assert_eq!(ips(), [official, "127.0.0.1:2"]);
assert!(!marker.exists());
servers::save(&file, std::slice::from_ref(&added)).unwrap();
withdraw_partner_servers(&dir).unwrap();
assert_eq!(ips(), ["afri-network.co.za"]);
// The player's own entry for that address (their name) stays.
fresh(&[ServerEntry::new("ZA", "Afri-Network.co.za:25565")], true);
withdraw_partner_servers(&dir).unwrap();
assert_eq!(ips(), ["Afri-Network.co.za:25565"]);
// Never offered (no marker): nothing changes.
fresh(&[added], false);
withdraw_partner_servers(&dir).unwrap();
assert_eq!(ips(), ["afri-network.co.za"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_moved_official_server_moves_the_old_entry_once() {
let dir = std::env::temp_dir().join(format!("horizon-mp-moved-{}", std::process::id()));
let address = OFFICIAL_SERVER.1;
let file = dir.join("servers.dat");
let marker = dir.join(OFFICIAL_MARKER);
let ips = || -> Vec<String> { servers::load(&file).into_iter().map(|s| s.ip).collect() };
let offered_before = |list: &[ServerEntry]| {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
servers::save(&file, list).unwrap();
std::fs::write(&marker, "horizon.bunchy7s.dev\n").unwrap();
};
// The old address under the player's own name and spelling: moved, name and place kept.
let mut old = ServerEntry::new("Our SMP", "Horizon.Bunchy7s.dev:25565");
old.icon = Some("AAAA".into());
offered_before(&[ServerEntry::new("Mine", "127.0.0.1:2"), old]);
add_official_server(&dir).unwrap();
let list = servers::load(&file);
assert_eq!(ips(), ["127.0.0.1:2", address]);
assert_eq!(
(list[1].name.as_str(), list[1].icon.as_deref()),
("Our SMP", Some("AAAA"))
);
assert_eq!(
std::fs::read_to_string(&marker).unwrap(),
format!("{address}\n")
);
// Once: the old address added back afterwards is the player's.
servers::prepend(&file, &ServerEntry::new("Old", "horizon.bunchy7s.dev")).unwrap();
add_official_server(&dir).unwrap();
assert_eq!(ips(), ["horizon.bunchy7s.dev", "127.0.0.1:2", address]);
// The new address already listed: no second entry.
offered_before(&[
ServerEntry::new("Old", "horizon.bunchy7s.dev"),
ServerEntry::new("New", address),
]);
add_official_server(&dir).unwrap();
assert_eq!(ips(), [address]);
// Deleted by the player before the move: not offered again; another port is left alone.
offered_before(&[ServerEntry::new("Test", "horizon.bunchy7s.dev:25570")]);
add_official_server(&dir).unwrap();
assert_eq!(ips(), ["horizon.bunchy7s.dev:25570"]);
let _ = std::fs::remove_dir_all(&dir);
}
}
+12 -12
View File
@@ -293,16 +293,16 @@ impl Keyboard {
y0 - 12.0,
width + 24.0,
height,
ui_theme::radius_plate(),
color::with_alpha(color::bg(), 0.94),
ui_theme::RADIUS_PLATE,
color::with_alpha(color::BG, 0.94),
);
ui.border(
x0 - 12.0,
y0 - 12.0,
width + 24.0,
height,
ui_theme::radius_plate(),
color::border(),
ui_theme::RADIUS_PLATE,
color::BORDER,
);
for (r, row) in self.rows().iter().enumerate() {
let y = y0 + r as f32 * (KEY_H + GAP);
@@ -314,13 +314,13 @@ impl Keyboard {
let lit = matches!(key, Key::Shift) && (self.shift || self.caps)
|| matches!(key, Key::Layer) && self.symbols;
let (plate, ink) = if focused {
(color::accent(), color::on_accent())
(color::ACCENT, color::ON_ACCENT)
} else if lit {
(color::hover(), color::text())
(color::HOVER, color::TEXT)
} else {
(color::raised(), color::text())
(color::RAISED, color::TEXT)
};
ui.rounded(x, y, w, KEY_H, ui_theme::radius(), plate);
ui.rounded(x, y, w, KEY_H, ui_theme::RADIUS, plate);
let label = match key {
Key::Char(ch) => self.letter(*ch),
Key::Shift if self.caps => "CAPS".into(),
@@ -331,10 +331,10 @@ impl Keyboard {
Key::Backspace => tr("horizon.osk.delete", "Delete"),
Key::Done => tr("gui.done", "Done"),
};
let lw = ui.measure(Face::Medium, ui_theme::body(), &label);
let lw = ui.measure(Face::Medium, ui_theme::BODY, &label);
ui.text_line(
Face::Medium,
ui_theme::body(),
ui_theme::BODY,
KEY_H,
x + ((w - lw) / 2.0).round(),
y,
@@ -359,10 +359,10 @@ impl Keyboard {
.collect();
ui.text_centered(
Face::Regular,
ui_theme::small(),
ui_theme::SMALL,
x0 + width / 2.0,
y0 + 5.0 * (KEY_H + GAP) + 2.0,
color::muted(),
color::MUTED,
&line.join(" "),
);
}
+13 -4
View File
@@ -232,10 +232,7 @@ pub fn roll_container(
/// A saved `LootTableSeed`, if any.
pub fn loot_seed(nbt: &Tag) -> Option<i64> {
match nbt.get("LootTableSeed") {
Some(Tag::Long(seed)) => Some(*seed),
_ => None,
}
horizon_client_core::nbt_value::number(nbt.get("LootTableSeed"))
}
fn text(nbt: &Compound, key: &str) -> Option<String> {
@@ -318,6 +315,18 @@ mod tests {
use super::*;
use horizon_world::loot::{ExplorationMap, LootStack};
#[test]
fn a_loot_seed_is_any_number() {
assert_eq!(
loot_seed(&Tag::compound([("LootTableSeed", Tag::Int(42))])),
Some(42)
);
assert_eq!(
loot_seed(&Tag::compound([("LootTableSeed", Tag::Long(7))])),
Some(7)
);
}
#[test]
fn a_saved_mod_sign_uses_its_vanilla_entity_type() {
for hanging in [false, true] {
@@ -408,7 +408,7 @@ impl TimelineUi {
let tx = start_x + sec * px_per_sec;
if tx <= start_x + w {
// Major tick line
style::fill(batch, atlas, [tx, y + 8.0, 1.0, 8.0], style::head_rule());
style::fill(batch, atlas, [tx, y + 8.0, 1.0, 8.0], style::HEAD_RULE);
// Label every 2 or 5 seconds depending on width
let is_labeled = if total_secs > 60.0 {
(sec as u32).is_multiple_of(10)
@@ -424,7 +424,7 @@ impl TimelineUi {
"horizon.studio.timeline.seconds",
&[Text::literal(seconds)],
);
batch.text_styled(atlas, &label, tx + 2.0, y + 1.0, style::soft(), true);
batch.text_styled(atlas, &label, tx + 2.0, y + 1.0, style::SOFT, true);
}
}
sec += sec_step;
@@ -459,7 +459,7 @@ impl TimelineUi {
// Base lane well
let lane_rect = [start_x, y, track_w, bar_h];
style::fill(batch, atlas, lane_rect, 0xFF14_171F);
style::ring(batch, atlas, lane_rect, style::row_rule());
style::ring(batch, atlas, lane_rect, style::ROW_RULE);
let total_dur = self.duration_ms.max(1) as f32;
@@ -514,7 +514,7 @@ impl TimelineUi {
&scene_title,
seg_x + 5.0,
y + 5.0,
style::muted(),
style::MUTED,
true,
);
}
@@ -577,7 +577,7 @@ impl TimelineUi {
let px = start_x + norm * track_w;
// Scrubber line from ruler through bottom of tracks
style::fill(batch, atlas, [px - 1.0, y, 2.0, h], style::accent());
style::fill(batch, atlas, [px - 1.0, y, 2.0, h], style::ACCENT);
// Inverted triangle marker at ruler top
let tri_color = style::WHITE;
+4 -8
View File
@@ -178,7 +178,7 @@ impl TopBarUi {
);
}
let color = if open {
style::accent()
style::ACCENT
} else if hovered {
style::WHITE
} else {
@@ -220,7 +220,7 @@ impl TopBarUi {
if pause_hovered {
style::WHITE
} else {
style::muted()
style::MUTED
},
true,
);
@@ -339,11 +339,7 @@ impl TopBarUi {
&Text::translate(key, &[]),
x + 8.0,
y + 6.0,
if hovered {
style::WHITE
} else {
style::muted()
},
if hovered { style::WHITE } else { style::MUTED },
true,
);
if !shortcut.is_empty() {
@@ -354,7 +350,7 @@ impl TopBarUi {
&shortcut,
x + menu_width - shortcut_width - 8.0,
y + 6.0,
style::soft(),
style::SOFT,
true,
);
}
+3 -21
View File
@@ -60,28 +60,10 @@ pub fn set_display(width: u32, height: u32, refresh_hz: u32, scale: f64) {
});
}
/// The screen `window` is on, else the primary one, else the only one: right after the window
/// is made, Wayland doesn't say yet which screen it is on.
pub fn monitor(window: &winit::window::Window) -> Option<winit::monitor::MonitorHandle> {
let only = || {
let mut all = window.available_monitors();
all.next().filter(|_| all.next().is_none())
};
window
.current_monitor()
.or_else(|| window.primary_monitor())
.or_else(only)
}
/// Keeps [`monitor`]'s size, refresh rate and scale for the items' `system.display`.
/// Keeps the screen `window` is on ([`crate::window::display`]) for the items' `system.display`.
pub fn note_display(window: &winit::window::Window) {
if let Some(m) = monitor(window) {
set_display(
m.size().width,
m.size().height,
m.refresh_rate_millihertz().map_or(0, |r| r.div_ceil(1000)),
m.scale_factor(),
);
if let Some(d) = crate::window::display(window) {
set_display(d.width, d.height, d.refresh_hz.unwrap_or(0), d.scale);
}
}
+46 -111
View File
@@ -1,6 +1,6 @@
//! The design system's widgets drawn in the game: palette, type scale, radii,
//! control sizes and fonts from `menu_style`, given at startup (in the official
//! game `crates/horizon-theme`, `docs/design-system.md`). Every menu is vanilla's (`ui`); what is left
//! The design system's widgets drawn in the game (`crates/horizon-theme`:
//! palette, type scale, radii, control sizes, motion and the bundled fonts;
//! `docs/design-system.md`). Every menu is vanilla's (`ui`); what is left
//! here draws Horizon's own overlays that vanilla has no screen for: the
//! on-screen keyboard a controller types with (`osk`), plus the
//! `horizon.txt` preferences.
@@ -39,76 +39,44 @@ pub(crate) fn set_preference(dir: &Path, key: &str, value: Option<&str>) {
let _ = std::fs::write(dir.join("horizon.txt"), out);
}
/// The overlays' palette (`menu_style`) in the HUD's colour encoding, plus the
/// The dark palette (`horizon_theme::DARK`) in the HUD's colour encoding, plus the
/// translucent plates the overlays draw over the game.
pub(crate) mod color {
use horizon_client_render::menu_style::{self, OverlayColours};
pub use menu_style::hud_colour as hex;
fn p() -> &'static OverlayColours {
&menu_style::get().overlay
use horizon_theme::DARK as P;
pub const fn hex(c: u32, a: u8) -> u32 {
(a as u32) << 24 | (c & 0xff) << 16 | (c >> 8 & 0xff) << 8 | (c >> 16 & 0xff)
}
/// The page, under every veil.
pub fn bg() -> u32 {
hex(p().page, 255)
}
pub const BG: u32 = hex(P.page, 255);
/// Hover plates, secondary buttons, fields, the segmented track: the
/// dark palette's soft plate, as ink over the veil so the plates read the
/// same over any backdrop (over the page colour they are `plate_soft`).
pub fn raised() -> u32 {
hex(p().ink, 20)
}
pub const RAISED: u32 = hex(P.ink, 20);
/// Pressed plates and a switch that is off (`plate`, the same way).
pub fn hover() -> u32 {
hex(p().ink, 36)
}
pub const HOVER: u32 = hex(P.ink, 36);
/// One-pixel rules between rows, slider tracks.
pub fn border() -> u32 {
hex(p().rule, 255)
}
pub const BORDER: u32 = hex(P.rule, 255);
/// Text and selection (`ink`).
pub fn text() -> u32 {
hex(p().ink, 255)
}
pub const TEXT: u32 = hex(P.ink, 255);
/// Values and secondary text (`ink_soft`).
pub fn muted() -> u32 {
hex(p().ink_soft, 255)
}
pub fn accent() -> u32 {
hex(p().accent, 255)
}
pub fn on_accent() -> u32 {
hex(p().on_accent, 255)
}
pub const MUTED: u32 = hex(P.ink_soft, 255);
pub const ACCENT: u32 = hex(P.accent, 255);
pub const ON_ACCENT: u32 = hex(P.on_accent, 255);
pub(crate) fn with_alpha(c: u32, a: f32) -> u32 {
let base = (c >> 24) as f32;
(c & 0x00ff_ffff) | ((base * a.clamp(0.0, 1.0)).round() as u32) << 24
}
}
/// The type scale and shapes (`menu_style`), in dp.
pub(crate) fn sizes() -> &'static horizon_client_render::menu_style::Sizes {
&horizon_client_render::menu_style::get().sizes
}
pub(crate) fn small() -> f32 {
sizes().caption
}
pub(crate) fn body() -> f32 {
sizes().label
}
/// The boot screen's wordmark (`display`) and console lines (`micro`).
fn display() -> f32 {
sizes().display
}
fn micro() -> f32 {
sizes().micro
}
/// Fields; plates (hover rows, selection).
pub(crate) fn radius() -> f32 {
sizes().radius
}
pub(crate) fn radius_plate() -> f32 {
sizes().radius_plate
}
/// The launcher's type scale and shapes (`horizon_theme`), in dp.
pub(crate) const SMALL: f32 = horizon_theme::CAPTION.px;
pub(crate) const BODY: f32 = horizon_theme::LABEL.px;
/// The boot screen's wordmark (`DISPLAY`) and console lines (`MICRO`).
pub(crate) const DISPLAY: f32 = horizon_theme::DISPLAY.px;
pub(crate) const MICRO: f32 = horizon_theme::MICRO.px;
/// Fields; plates (hover rows, selection); pills (buttons, segments).
pub(crate) const RADIUS: f32 = horizon_theme::RADIUS;
pub(crate) const RADIUS_PLATE: f32 = horizon_theme::RADIUS_PLATE;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum Face {
@@ -119,34 +87,20 @@ pub(crate) enum Face {
/// Counts, timestamps and the boot screen's console lines.
Mono,
}
#[cfg(not(test))]
const TEST_FONTS: Option<horizon_client_render::menu_style::Fonts> = None;
#[cfg(test)]
const TEST_FONTS: Option<horizon_client_render::menu_style::Fonts> =
Some(horizon_client_render::menu_style::Fonts {
regular: horizon_theme::fonts::GEIST_REGULAR,
medium: horizon_theme::fonts::GEIST_MEDIUM,
semibold: horizon_theme::fonts::GEIST_SEMIBOLD,
mono: horizon_theme::fonts::GEIST_MONO,
});
struct Fonts {
faces: [FontRef<'static>; 4],
}
impl Fonts {
/// The faces given in `menu_style`; none given, none (unit tests take the design system's).
fn load() -> Option<Self> {
let given = horizon_client_render::menu_style::get()
.fonts
.or(TEST_FONTS)?;
let f = |b: &'static [u8]| FontRef::try_from_slice(b).expect("given font");
Some(Self {
fn load() -> Self {
let f = |b: &'static [u8]| FontRef::try_from_slice(b).expect("bundled font");
Self {
faces: [
f(given.regular),
f(given.medium),
f(given.semibold),
f(given.mono),
f(horizon_theme::fonts::GEIST_REGULAR),
f(horizon_theme::fonts::GEIST_MEDIUM),
f(horizon_theme::fonts::GEIST_SEMIBOLD),
f(horizon_theme::fonts::GEIST_MONO),
],
})
}
}
fn face(&self, f: Face) -> &FontRef<'static> {
&self.faces[f as usize]
@@ -188,8 +142,7 @@ struct Cached {
}
/// Glyphs and corner masks rasterized into the atlas region `horizon/ui-text`.
struct Cache {
/// None: no fonts were given, and text draws nothing.
fonts: Option<Fonts>,
fonts: Fonts,
entries: HashMap<Key, Cached>,
shelf: (u32, u32, u32),
pending: Vec<crate::font::Patch>,
@@ -277,16 +230,9 @@ impl Cache {
if let Some(c) = self.entries.get(&key) {
return *c;
}
let Some(fonts) = &self.fonts else {
return Cached {
rect: None,
left: 0.0,
top: 0.0,
};
};
let scale = fonts.scale(face, f32::from(size) / 4.0);
let scale = self.fonts.scale(face, f32::from(size) / 4.0);
let glyph = id.with_scale_and_position(scale, ab_glyph::point(0.0, 0.0));
let made = match fonts.face(face).outline_glyph(glyph) {
let made = match self.fonts.face(face).outline_glyph(glyph) {
Some(outline) => {
let b = outline.px_bounds();
let (w, h) = (b.width() as u32, b.height() as u32);
@@ -513,17 +459,14 @@ impl<'a> Ui<'a> {
let atlas = self.atlas;
let gui = self.scale;
with_cache(atlas, |c| {
let Some(fonts) = &c.fonts else {
return (0.0, 0.0, 0.0, Vec::new());
};
let scale = fonts.scale(face, px);
let scale = c.fonts.scale(face, px);
let (ascent, descent) = {
let f = fonts.face(face).as_scaled(scale);
let f = c.fonts.face(face).as_scaled(scale);
(f.ascent(), f.descent())
};
let chars: Vec<(char, Option<crate::font::Glyph>)> = unformatted(text)
.map(|ch| {
let pack = (!ch.is_whitespace() && fonts.face(face).glyph_id(ch).0 == 0)
let pack = (!ch.is_whitespace() && c.fonts.face(face).glyph_id(ch).0 == 0)
.then(|| atlas.font.glyph(ch))
// The pack lacks it too (its missing glyph): Geist's box, like vanilla's.
.filter(|g| g.rect.is_some() || g.right <= 0.0);
@@ -548,10 +491,7 @@ impl<'a> Ui<'a> {
continue;
}
let (id, advance, kern) = {
let Some(fonts) = &c.fonts else {
break;
};
let font = fonts.face(face);
let font = c.fonts.face(face);
let id = font.glyph_id(ch);
let f = font.as_scaled(scale);
(id, f.h_advance(id), prev.map_or(0.0, |p| f.kern(p, id)))
@@ -666,8 +606,8 @@ pub fn draw_boot(atlas: &HudAtlas, batch: &mut HudBatch, logs: &[String]) {
/// The wordmark with its tagline, centred.
fn wordmark(atlas: &HudAtlas, batch: &mut HudBatch) {
let mut ui = Ui::new(atlas, batch, (0.0, 0.0));
let size = (ui.w / 16.0).clamp(display(), 96.0);
let small = (size * 0.25).clamp(micro(), 18.0);
let size = (ui.w / 16.0).clamp(DISPLAY, 96.0);
let small = (size * 0.25).clamp(MICRO, 18.0);
let block = size * 1.45 + small * 0.6 + small * 1.5;
let top = (ui.h - block) / 2.0;
ui.text_centered(Face::SemiBold, size, ui.w / 2.0, top, BOOT_TEXT, "Horizon");
@@ -689,7 +629,7 @@ pub fn draw_boot_log(atlas: &HudAtlas, batch: &mut HudBatch, logs: &[String]) {
return;
}
let mut ui = Ui::new(atlas, batch, (0.0, 0.0));
let (size, line, pad) = (micro(), micro() * 1.5, 16.0);
let (size, line, pad) = (MICRO, MICRO * 1.5, 16.0);
let max_w = ui.w / 2.0;
let max_rows = (((ui.h - pad) / line) as usize).clamp(1, 24);
let mut y = ui.h - pad - line;
@@ -756,12 +696,7 @@ mod tests {
let atlas = HudAtlas::build(Path::new("/nonexistent/horizon-test-assets")).unwrap();
reset_atlas();
let draw = |cache: &mut Cache| {
let glyph = cache
.fonts
.as_ref()
.unwrap()
.face(Face::Regular)
.glyph_id('A');
let glyph = cache.fonts.face(Face::Regular).glyph_id('A');
assert!(
cache
.glyph(&atlas, Face::Regular, glyph, 16.0)
@@ -847,7 +782,7 @@ mod tests {
let lines = wrapping_lines(
&ui,
Face::Mono,
micro(),
MICRO,
"the quick brown fox jumps over the lazy dog",
90.0,
);
@@ -855,7 +790,7 @@ mod tests {
assert!(
lines
.iter()
.all(|l| ui.measure(Face::Mono, micro(), l) <= 90.0),
.all(|l| ui.measure(Face::Mono, MICRO, l) <= 90.0),
"every wrapped line fits"
);
assert_eq!(
@@ -877,7 +812,7 @@ mod tests {
fn drawn(atlas: &HudAtlas, text: &str) -> (f32, Vec<([f32; 2], [f32; 2])>) {
let mut batch = HudBatch::new([1280, 720]);
let mut ui = Ui::new(atlas, &mut batch, (0.0, 0.0));
let w = ui.text_line(Face::Medium, body(), 20.0, 10.0, 10.0, color::text(), text);
let w = ui.text_line(Face::Medium, BODY, 20.0, 10.0, 10.0, color::TEXT, text);
let quads = batch
.vertices
.iter()
+9 -3
View File
@@ -70,7 +70,8 @@ struct Shared {
/// The game's handle. Dropping it stops the thread.
pub struct Cosmetics {
shared: Arc<Shared>,
worker: Option<Box<dyn FnMut() + Send>>,
/// Behind a lock so one client can be shared (`Arc<Cosmetics>` is `Send + Sync`).
worker: Mutex<Option<Box<dyn FnMut() + Send>>>,
}
impl Cosmetics {
@@ -78,7 +79,12 @@ impl Cosmetics {
/// `cache` when given.
pub fn new(transport: impl Transport, cache: Option<PathBuf>) -> Self {
let mut c = Self::manual(transport, cache, Arc::new(Instant::now));
let mut work = c.worker.take().expect("worker");
let mut work = c
.worker
.get_mut()
.unwrap_or_else(PoisonError::into_inner)
.take()
.expect("worker");
let shared = c.shared.clone();
let _ = std::thread::Builder::new()
.name("Cosmetics".into())
@@ -125,7 +131,7 @@ impl Cosmetics {
});
Self {
shared,
worker: Some(worker),
worker: Mutex::new(Some(worker)),
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ fn clock() -> (Clock, Arc<AtomicU64>) {
}
fn run(c: &mut Cosmetics) {
(c.worker.as_mut().unwrap())();
(c.worker.get_mut().unwrap().as_mut().unwrap())();
}
fn ids(worn: Option<Worn>) -> Vec<String> {
+7
View File
@@ -8,6 +8,8 @@ description = "Play with friends: invite codes, the relay protocol, the end-to-e
[dependencies]
horizon-addresses = { path = "../horizon-addresses" }
# The game's side (`game`): the platform's hooks, for horizon-official.
horizon-platform = { path = "../horizon-platform", optional = true }
# The key exchange keyed by the code's secret, then the frames.
spake2 = { version = "0.4", features = ["getrandom"] }
chacha20poly1305 = "0.10"
@@ -21,5 +23,10 @@ if-addrs = "0.15"
serde = { version = "1", features = ["derive"] }
serde_json = "1.0.151"
[features]
# Play with Friends in the game: the resolver of `friend:` addresses on the platform's hooks
# (`game.rs`), which horizon-official registers. The launcher and the relay don't need it.
game = ["dep:horizon-platform"]
[lints]
workspace = true
+174
View File
@@ -0,0 +1,174 @@
//! Play with Friends in the game, joining side (the `game` feature): the engine's `friend:`
//! addresses (`horizon_platform::hooks::tunnel`) read as invite codes, and joined through this
//! crate's tunnel. `horizon-official` registers [`HOOKS`]; the game's own executable has none, so
//! there no invite code joins.
use crate::{
PathMode,
guest::{self, GuestConfig},
invite::{Parsed, parse},
};
use horizon_platform::{
hooks::{
Hooks,
tunnel::{Opened, Progress, Resolver},
},
version::{PROTOCOL_VERSION, VANILLA_VERSION},
};
use std::net::SocketAddr;
/// What `horizon-official` registers: Play with Friends' hooks.
pub static HOOKS: Friends = Friends;
pub struct Friends;
impl Hooks for Friends {
fn tunnel(&self) -> Option<&dyn Resolver> {
Some(&Invites)
}
}
struct Invites;
impl Resolver for Invites {
fn is_code(&self, code: &str, full: bool) -> bool {
match parse(code) {
Some(Parsed::Long(_)) => true,
Some(Parsed::Short(_)) => !full,
None => false,
}
}
fn nameplate(&self, code: &str) -> Option<String> {
match parse(code) {
Some(Parsed::Short(c)) => Some(c.nameplate),
_ => None,
}
}
/// The relay is kept beside the addresses: two hosts on other networks can
/// share a private address and the fixed direct port.
fn public_part(&self, code: &str) -> String {
match parse(code) {
Some(Parsed::Short(code)) => code.nameplate,
Some(Parsed::Long(invite)) => {
let relay = invite
.relay
.map(|r| format!("{}/{}", r.address, r.code.nameplate));
let endpoints = invite.endpoints.iter().map(SocketAddr::to_string);
endpoints.chain(relay).collect::<Vec<_>>().join(",")
}
None => String::new(),
}
}
fn relay_from_env(&self) -> Option<String> {
crate::relay_url()
}
fn open(
&self,
code: &str,
relay: Option<String>,
progress: &mut dyn FnMut(Progress),
) -> Result<Opened, String> {
progress(Progress::Finding);
let tunnel = guest::join(&GuestConfig {
code: code.to_owned(),
relay,
protocol: PROTOCOL_VERSION,
game: VANILLA_VERSION.to_owned(),
path: PathMode::from_env(),
})
.map_err(|e| e.to_string())?;
progress(Progress::Connected {
direct: tunnel.direct,
});
let loopback = tunnel.serve_loopback().map_err(|e| e.to_string())?;
Ok(Opened {
port: loopback.port,
token: loopback.token.to_vec(),
})
}
}
#[cfg(test)]
mod tests {
use super::{Invites, Resolver};
use crate::{
code::Code,
invite::{self, Invite},
};
use horizon_platform::hooks::tunnel;
fn short(nameplate: &str, secret: &str) -> Code {
Code {
nameplate: nameplate.into(),
secret: secret.into(),
}
}
#[test]
fn the_engine_and_the_codes_name_the_same_addresses() {
assert_eq!(tunnel::PREFIX, crate::ADDRESS_PREFIX);
}
#[test]
fn invites_are_read_and_their_secret_never_shown() {
let invite = Invite {
secret: *b"0123456789",
port: 25580,
endpoints: vec!["192.168.1.20:25580".parse().unwrap()],
relay: None,
}
.encode();
// Direct Connection takes a full invite; a short code needs Join a Friend.
assert!(Invites.is_code(&invite, true));
let short = short("K7QM", "3XPD").to_string();
assert!(Invites.is_code(&short, false));
assert!(!Invites.is_code(&short, true));
// A short code could be a host name, so Direct Connection keeps host names.
for host in ["localhost", "example.org:25566", "abcdefgh", "[::1]:25568"] {
assert!(!Invites.is_code(host, true), "{host}");
}
// Only a short code's name may be shown; a full invite is all secret.
assert_eq!(Invites.nameplate(&invite), None);
assert_eq!(Invites.nameplate(&short).as_deref(), Some("K7QM"));
}
#[test]
fn friends_worlds_are_told_apart_by_their_hosts_never_by_their_secrets() {
const PORT: u16 = 50000;
const RELAY: &str = "relay.example.com:443";
let invite = |secret: u8, host: &str, relay: Option<Code>| {
let code = Invite {
secret: [secret; invite::SECRET],
port: PORT,
endpoints: vec![format!("{host}:{PORT}").parse().unwrap()],
relay: relay.map(|code| invite::RelayRef {
address: RELAY.into(),
code,
}),
}
.encode();
Invites.public_part(&code)
};
assert_ne!(
invite(1, "192.168.1.5", None),
invite(1, "192.168.1.6", None),
"two friends"
);
assert_ne!(
invite(1, "192.168.1.5", Some(short("ABCD", "WXYZ"))),
invite(1, "192.168.1.5", Some(short("EFGH", "WXYZ"))),
"two friends on other networks behind the same private address"
);
assert_eq!(
invite(1, "192.168.1.5", Some(short("ABCD", "WXYZ"))),
invite(2, "192.168.1.5", Some(short("ABCD", "QRST"))),
"the invite's and the relay code's secrets take no part"
);
let typed = |code: Code| Invites.public_part(&code.to_string());
assert_eq!(typed(short("ABCD", "WXYZ")), typed(short("ABCD", "QRST")));
assert_eq!(typed(short("ABCD", "WXYZ")), "ABCD");
}
}
+2
View File
@@ -11,6 +11,8 @@
pub mod code;
pub mod crypto;
pub mod direct;
#[cfg(feature = "game")]
pub mod game;
pub mod guest;
pub mod host;
pub mod invite;
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff. Show more