Compare commits

..
Author SHA1 Message Date
Ludovic Marie 1453e0e614 Merge pull request #13606 from martipops/controller/dial-4-editor
🤖 Agent: the editor builds on #13605's dial; main and #13605's menu_style colours merged in, clippy + 213 radial/menu/options/settings tests pass.
2026-11-10 00:05:14 +01:00
Ludovic Marie 9b4258fecc Merge pull request #13605 from martipops/controller/dial-3-play
🤖 Agent: main merged after #13958: the dial's colours now come from menu_style (page, backdrop_muted, and new lit/lit_edge that Horizon's look fills with the night accent), so the renderer keeps no horizon-theme dependency and the dial looks the same; its curves are cubic-bezier points pinned to horizon_theme by a test. split-controllers waits on this before moving controller files.
2026-11-10 00:05:08 +01:00
Ludovic Marie 2d610e9482 Merge pull request #14140 from MSedgeMC/fix/piglin-guarded-anger
🤖 Agent: ANGRY_AT's UUID and long TTL load back (clamped, not wrapped, past i32), and the test loads a whole saved piglin; the new head only merges the test-fix commit the branch already held (same tree as da800dff).
2026-11-09 23:57:55 +01:00
Ludovic Marie e6eccbd3b9 Merge pull request #13920 from MSedgeMC/port_map/item-activation-file
🤖 Agent: confirmed on main: tick_item_activation is at game/totem.rs:175, called from game.rs:6092 and remote.rs:6613.
2026-11-09 23:57:51 +01:00
Ludovic Marie 537539fe58 Merge pull request #14291 from horizon-rust/split/cosmetics-fx
🤖 Agent: step 2b-iii of spec #14091 moves companions, trails and auras onto the hooks; stacked on #14282.
2026-11-09 23:37:32 +01:00
Ludovic Marie cbbd362eed Merge pull request #14011 from v0ntr3n/fix/stand-command-hurt-events-freeze-immunity
🤖 Agent: the explosion/ignite/burn branches now attribute their game events to the cause in place and ignition clears TicksFrozen; damage_immunity checks the stand's active slots first. Reworked so no dead copy remains; 15/15 stand tests pass with resources.
2026-11-09 23:37:28 +01:00
Ludovic Marie 1430b9990a Merge pull request #14381 from horizon-rust/split/discord-setting
🤖 Agent: the Discord switch keeps its discordPresence line in horizon-extras.txt through horizon-discord's settings store, so players keep their choice while the row moves to Extras > Multiplayer; e2e 4/4 and verify green on this head.
2026-11-09 23:37:24 +01:00
Horizon agent 64a6b82dd0 Merge controller/dial-3-play (radial colours from menu_style) into controller/dial-4-editor 2026-11-09 23:34:37 +01:00
Ludovic Marie a84f637dd6 Merge pull request #14282 from horizon-rust/split/cosmetics-fx-api
🤖 Agent: step 2b-ii of spec #14091 gives PlayerDraw motion, view, looks, particles and first_person so the cosmetics need nothing else from the engine; stacked on #14274.
2026-11-09 23:32:09 +01:00
Horizon agentandClaude Opus 5.5 f2d50d4a75 Radial menu: its colours from menu_style, its curves written out
#13958 (with #14027) took horizon-theme out of horizon-client-render: the
dial's dark page and muted text now come from menu_style's overlay colours,
and OverlayColours gains lit and lit_edge for its sector, which Horizon's
look fills with the night accent and its bright shade, so the dial looks as
before. The easing curves are written as cubic-bezier points like hud_kit's,
and a test pins them to horizon_theme's QUICK and MOVE.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:29:07 +01:00
Ludovic Marie efab4d1bbf Merge pull request #14274 from horizon-rust/split/cosmetics-own
🤖 Agent: step 2b-i of spec #14091 draws the player's own body and the inventory picture through PlayerDraw; stacked on #14265.
2026-11-09 23:23:44 +01:00
Horizon agent 604fb90bf1 Merge controller/dial-3-play (with origin/main) into controller/dial-4-editor 2026-11-09 23:21:18 +01:00
Horizon agent b2d72cea9e Merge origin/main into controller/dial-3-play (graphics_style colours are functions now) 2026-11-09 23:20:59 +01:00
Ludovic Marie 7b50bae250 Merge pull request #13958 from horizon-rust/layers/window-icon-hook
🤖 Agent: the engine takes the window icon from the layer above, and entry.rs keeps main's pack_formats::install() after horizon_look::give(), so the icon is still given before any window opens. The head now also carries the menu_style stack (#14026, #14027, #14029): graphics_style's and ui_theme's colours are one OnceLock read per call from menu_style, Horizon's BLOCKS_DARK values come from horizon_look.rs at startup so the menus look the same, and the renderer and client lib no longer depend on horizon-theme. Merges cleanly with main.
2026-11-09 23:19:07 +01:00
Ludovic Marie 3780c215a7 Merge pull request #14265 from horizon-rust/split/cosmetics-layers
🤖 Agent: step 2a-ii of spec #14091 draws the cosmetic items on the body through PlayerDraw instead of calling the cosmetics from the engine; stacked on #14214.
2026-11-09 23:19:02 +01:00
Ludovic Marie 3c853b46eb Merge pull request #14155 from horizon-rust/split/discord-crate
🤖 Agent: the presence now starts only from horizon-official's client_started hook, so horizon-client's own executable (plain builds, harnesses) never opens Discord's socket; the e2e passes 4/4 on the official binary and verify is green on this head.
2026-11-09 23:13:59 +01:00
Ludovic Marie e64af82d00 Merge pull request #13771 from MSedgeMC/fix/lectern-has-book-signal
🤖 Agent: matches LecternBlock.getAnalogOutputSignal (HAS_BOOK checked before the block entity, 26.3 bytecode); I pushed 1d52dd4: the new test had taken the next test's doc comment, and it now also checks has_book=true reads 15.
2026-11-09 22:53:29 +01:00
Ludovic Marie 37bf47f477 Merge pull request #13655 from TheApple05/fix/12091-piston-slime-bounce
🤖 Agent: mob piston pushes now restitute after the move, reading the landing block as Entity.move does, so a pushed mob bounces 1.0 on slime; the corpus case puts the player back so the command_exec oracle runs through (only a known #13022 boat tick differs vs main); run-parity covers the TNT commit.
2026-11-09 22:48:00 +01:00
Ludovic Marie 03d8150bdb Merge pull request #13867 from horizon-rust/perf/villager-poi-scan
🤖 Agent: villager POI searches now take the PoiSections lock and column lookup once per chunk instead of once per section; on the 122-player SMP bench (190 villagers, 3 rotated rounds) villagers went from 20.3-21.3 to 15.7-15.9 µs/mob (ratio to chicken/cow/sheep 2.0-2.1 -> 1.75), ~0.5 ms/tick normalized; same POIs in the same order (20k-step differential test vs the old per-section get loop).
2026-11-09 22:39:30 +01:00
Ludovic Marie c89cd3f9e1 Merge pull request #14374 from horizon-rust/docs/governance
Add GOVERNANCE.md: the council, theme groups and how roles are earned
2026-11-09 22:35:02 +01:00
Ludovic Marie 0b8d44458a Merge pull request #14026 from horizon-rust/layers/render-style
🤖 Agent: graphics_style's and ui_theme's colours are now one OnceLock read per call from menu_style; Horizon's BLOCKS_DARK values come from horizon_look.rs at startup, so the menus look the same; the renderer and client lib no longer depend on horizon-theme.
2026-11-09 22:32:43 +01:00
Ludovic Marie 75a016b1b0 Merge pull request #14211 from oloion/fix/frog-walk-animation
🤖 Agent: the client now runs Frog.updateWalkAnimation (min(d*25,1), 0 while jumping); FROG_WALK keeps FrogModel's 1.5 factor (agent fix 341a929).
2026-11-09 22:32:39 +01:00
Horizon agentandClaude Opus 5.5 c21f627431 discord_presence_e2e: scroll the Extras page to the switch
Only the rows on screen are widgets; the Multiplayer group is below the fold
of an offscreen window.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:26:36 +01:00
Ludovic Marie 691a39cab6 Merge pull request #14204 from horizon-rust/layers/studio-extras-gate
🤖 Agent: studio::EDITOR is a compile-time constant (true by default, so the default build runs no check); without horizon-extras recording/playback stay and the editor input, drawing and commands are off.
2026-11-09 22:25:53 +01:00
Ludovic Marie 3c06105a0e Merge pull request #14199 from horizon-rust/layers/replay-move
🤖 Agent: pure move checked by line multiset; the editor is reached through five calls (save path, replay opened/advanced/closing/closed) at the places its code was.
2026-11-09 22:20:16 +01:00
Ludovic Marie 726726a1fc Merge pull request #14186 from horizon-rust/layers/replay-state
🤖 Agent: playback state (driver, camera type to restore, standalone, playback clock) moves to game/replay.rs Replay; Studio keeps the editor; callers ask replay.playing(); game.rs does not grow.
2026-11-09 22:14:22 +01:00
Ludovic Marie 82da4379bd Merge pull request #14161 from horizon-rust/layers/studio-calls
🤖 Agent: pure move: game.rs's inline Studio blocks and the replay key shortcuts become game/studio.rs methods with the same bodies and early returns; the engine asks replaying()/flying()/active().
2026-11-09 22:09:29 +01:00
Ludovic Marie 7627e37dba Merge pull request #14351 from horizon-rust/split/pages-plain-rows
🤖 Agent: Row::Button/Text are #[non_exhaustive] variants built through constructors, so later options do not break crates; the options shell pairs two half buttons into one row and a press on an inactive one reaches nothing (tested).
2026-11-09 22:09:25 +01:00
Ludovic Marie cd8eb96cef Merge pull request #14214 from horizon-rust/split/cosmetics-draw
🤖 Agent: step 2a-i of spec #14091 adds PlayerDraw to horizon-hooks (plain types only) so crates can draw on player models in the world pass; creatures.rs is main's, with the swap moved to creatures/mob_geometry.rs.
2026-11-09 22:04:26 +01:00
Ludovic Marie 829aec1dba Merge pull request #14080 from rdbtCVS/screenshot-off-render-thread
🤖 Agent: F2 and world-icon PNGs are written on a thread after map_async; a second F2 in the same second now skips the name still being written (agent fix 1ffa9ed), and agent screenshots keep the synchronous path.
2026-11-09 22:04:22 +01:00
Ludovic Marie 82a6e896d1 Merge pull request #13627 from vnsmoreira/fix/armor-stand-player-break-loot
🤖 Agent: the player break now pops the named stand item and then goes through brokenByAnything, which plays the break sound before the loot as ArmorStand.brokenByAnything orders it; stand_break_loot, stand_command_hurt and armor_stand_equip_events pass with it, and command_exec on a union with main shows no armor stand mismatch.
2026-11-09 22:04:18 +01:00
Horizon agentandClaude Opus 5.5 62e62c21e6 horizon-discord: the switch is the first splitscreen player's (seat)
hooks::seat() is on main now: other players' settings neither load nor
save the line, and their Extras page has no row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:02:53 +01:00
Horizon agent a6350aa8f9 Merge branch 'split/discord-crate' into split/discord-setting
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
2026-11-09 22:02:16 +01:00
Horizon agent c817ed7fff Merge remote-tracking branch 'origin/main' into split/discord-crate
# Conflicts:
#	crates/horizon-client/Cargo.toml
#	crates/horizon-hooks/src/lib.rs
#	crates/horizon-platform/src/lib.rs
2026-11-09 22:02:02 +01:00
Horizon agent b020ab26b9 piston mob bounce: put the player back when the case ends
The case left the player at 1.5 200 1.5; with that, vanilla's command_exec oracle did not load the chunks of the later teleport lands group (required chunks did not load, twice). Every other group that moves the player puts it back at 8.5 230 8.5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 277584e4df6f0b090655d50d19138c89dad4b77a)
2026-11-09 21:59:36 +01:00
Ludovic Marie 0300ba0a76 Merge pull request #14339 from horizon-rust/team/drop-itarqos5
🤖 Agent: removes itarqos5 from .github/trusted-reviewers.txt, the Team and reviewer lists in docs/release-flow.md and the pinned login in tests/test_nightly_set.py; the nightly, claim and triage test modules (102 tests) passed per the PR.
2026-11-09 21:59:04 +01:00
Ludovic Marie 3a3c182aed Merge pull request #14208 from horizon-rust/split/cosmetics-names
🤖 Agent: step 3b makes names.rs, scoreboard.rs and perspective.rs call the engine's dispatch (Game::hooks_name_marks builds one PlayerModel per tag drawn) instead of the cosmetics directly.
2026-11-09 21:56:53 +01:00
AppaandCodex 35f095bd41 Fix TNT restitution after piston landings
Read the actual landing block for authoritative TNT after applying the
piston push. Preserve client fallback ordering and exhausted-budget
returns. Validate five exact vanilla landings and server/client controls.
Correct the neighboring order test's rounded TNT-width expectation using
the pinned vanilla result without widening its tolerance.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-09 12:55:45 -08:00
Ludovic Marie e76394c2cf Merge pull request #14107 from horizon-rust/split/official-servers-crate
🤖 Agent: the official server now reaches the list only through horizon-official's started hook, so horizon-client's own executable (plain builds, harnesses) has neither the official server nor the withdrawn partner's address; verify and launcher green on this head.
2026-11-09 21:53:06 +01:00
Ludovic Marie d9cfca426c Merge pull request #14207 from horizon-rust/split/cosmetics-sprites
🤖 Agent: the API half of cosmetics step 3: Hooks::name_decoration returns a Copy NameDecoration (fixed &'static mark rectangles, optional badge, tag lift) so no heap data or engine type crosses the hook.
2026-11-09 21:53:02 +01:00
Ludovic Marie 763d4868d7 Merge pull request #14181 from horizon-rust/split/cosmetics-hooks
🤖 Agent: cosmetics step 1 of spec #14091: the emote feed and presence go through plain-type frame and tick hooks in horizon-hooks, so no engine or renderer type crosses the seam.
2026-11-09 21:44:17 +01:00
Ludovic Marie 110a92a1aa Merge pull request #14096 from horizon-rust/layers/seat
🤖 Agent: seat() is a thread-local the engine sets at Split::turn and around a guest's join/profile load (the profile used to load before the guest's turn, into P1's seat); a new profile saves on P1's seat and reads its stores back into the guest's. Without splitscreen it is always 0. Main merged in (horizon_platform::hooks exports both seat and main's tunnel).
2026-11-09 21:35:51 +01:00
Ludovic Marie 3a8d647c8c Merge pull request #14154 from Skr1zzzz/falling-removals
🤖 Agent: one pass over the big lists per RemoveEntities packet, per-id cleanup order kept (dragon before its crystal); moved to remote_removal.rs so remote.rs shrinks (agent fix 8cf1e6b).
2026-11-09 21:30:12 +01:00
Horizon agentandClaude Opus 5.5 4e22c1ad9a rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:27:08 +01:00
Ludovic Marie 3c346ba1a4 Merge pull request #13810 from NikitaMGrimm/hdr-lighting-one-meaning
🤖 Agent: re-checked the new head: only main merged in, and the PR's own diff is unchanged. shader_pack_kind().is_none() is still exactly the old native/pack_post test, so HDR Lighting greys only where the running pack lights the world.
2026-11-09 21:25:43 +01:00
Ludovic Marie b41db51bf9 Merge pull request #13808 from vnsmoreira/packs/polytone-fluid-fog
🤖 Agent: re-checked the new head: the only change is that FluidFog/FluidFogs moved to the render crate's pack_palette after #13959, converted from horizon_polytone's type once when the pack loads. The fog still runs last and is vanilla's without a modifier.
2026-11-09 21:25:38 +01:00
Horizon agentandClaude Opus 5.5 498d9bebcf Add GOVERNANCE.md: the council, theme groups and how roles are earned
The governance the founder adopted on 2026-11-09 as his last decision as
head of the project: a council of equals deciding by 24-hour polls, theme
groups with leads, the selection filter, moderation and appeals, the 18+
rule for roles of trust, the agents, and the account handover path.
README and CONTRIBUTING link to it with one line each.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:21:45 +01:00
Ludovic Marie 1ecc5a9bdc Merge pull request #13811 from Mik00000/perf/natural-spawning-cache
🤖 Agent: Util.shuffle runs every pass again, so the level random keeps vanilla's draws, and nothing is cached across ticks any more. What stays saves allocations: Counted streamed instead of collected, the SpawnPlayer buffer reused, and a single-player collect without the neighbour scan. Fake players stay out of the anchors and in the level, as on main. Merged with main on a branch together with #13795 and #13800: clippy and 3827 tests pass; natural_spawn runs in CI parity.
2026-11-09 21:21:02 +01:00
Ludovic Marie d05675d755 Merge pull request #13800 from Mik00000/perf/block-entities-opt
🤖 Agent: every hopper again records tickedGameTime each tick, so the 8/7 transfer cooldown matches vanilla, and is_full keeps #13763's insertion_item for pots. The gains stay: slot iterators instead of a Vec per transfer, empty hoppers with nothing above them or within reach skip the whole pass, one block lookup per tick. Merged with main on a branch together with #13795 and #13811: clippy and 3827 tests pass; the hopper harnesses run in CI parity.
2026-11-09 21:20:57 +01:00
Ludovic Marie 2568835cc6 Merge pull request #13795 from TheMrZZ/fix/command-spawnpoint-respawn-config
🤖 Agent: with the offline capture tools removed, the pitch now goes through RespawnConfig, the personal respawn save (old records load pitch +0.0) and only the free-point branch of find_respawn_and_use; bed and anchor keep pitch 0, as RespawnData's block cases do. Merged with main on a branch together with #13800 and #13811: clippy and 3827 Rust tests pass on the desktop.
2026-11-09 21:20:53 +01:00
Horizon agentandClaude Opus 5.5 e1aa435188 horizon-discord: the Discord switch leaves the engine's Extras (layer plan M5)
The crate keeps its switch: a settings store for its discordPresence line of
horizon-extras.txt (players keep their choice) and a row in the Extras page's
Multiplayer group, which horizon-official registers. The engine loses
Extras::discord_presence, Item::DiscordPresence and the Horizon Settings
button; the hooks lose Activity::Hidden.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:19:29 +01:00
martipopsandClaude Opus 5.5 0341ad3d3a Merge controller/dial-3-play into controller/dial-4-editor
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:17:37 -05:00
Ludovic Marie 128575e4e5 Merge pull request #14029 from horizon-rust/layers/client-lib-style
🤖 Agent: ui_theme's palette, sizes and Geist fonts come from menu_style; with no fonts given (NEUTRAL) the Horizon overlays measure and draw no text instead of panicking.
2026-11-09 21:15:21 +01:00
martipopsandClaude Opus 5.5 6cc30fbed4 Controllers (radial): main's input_holds asks the pad what each press reached
The radial series' holds_action follows what a press went to, not the binding,
so it takes no settings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:12:36 -05:00
Ludovic Marie cb870cf80d Merge pull request #14027 from horizon-rust/layers/render-theme-out
🤖 Agent: hud_kit's tween curve is pinned to horizon_theme::MOTION by a dev-dependency test, and the HDR logo reads the icon the window hook was given.
2026-11-09 21:11:27 +01:00
Horizon agentandClaude Opus 5.5 e138304d2e Put the aura test's item at the feet
It reused the crown's 18-pixel attach offset, which an aura may not have
(at most 6 pixels above the feet), so the item failed to parse.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:10:39 +01:00
Horizon agentandClaude Opus 5.5 5385996153 Merge #14107 (split/official-servers-crate) into split/discord-crate
Stacks the Discord crate on the official-servers crate: horizon-official
registers four parts; horizon-client-lib has no horizon-extras feature left.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:08:57 +01:00
Ludovic Marie f0cb1e42c0 Merge pull request #14156 from horizon-rust/layers/studio-gather
🤖 Agent: the verify failure was the two rustc-compiled fixtures (test_compute_fov_hook, test_world_input_capture) still modelling game.studio_freecam/studio_replay; they now carry the Studio struct game.rs reads, and both pass on this head.
2026-11-09 21:06:48 +01:00
martipopsandClaude Opus 5.5 83a9845928 Merge controller/dial-3-play (with upstream/main) into controller/dial-4-editor
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:03:14 -05:00
martipopsandClaude Opus 5.5 3563a7f986 Merge controller/dial-2-model (with upstream/main) into controller/dial-3-play
Main's input methods (#14001) replace the pad calls: the radial menu closes with
a new player in input_player_replaced (through pad_player_replaced), and
horizon-ctl's key press still closes it beside input_keyboard_used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:02:58 -05:00
martipopsandClaude Opus 5.5 cc4933eaed Merge upstream/main into controller/dial-2-model
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:02:21 -05:00
Horizon agent f4e5fbdfd0 Merge the drag hook fixture fix into layers/studio-extras-gate 2026-11-09 20:56:43 +01:00
Horizon agent 6d5b520591 Merge the drag hook fixture fix into layers/replay-move 2026-11-09 20:56:41 +01:00
Horizon agent dff332b257 Merge the drag hook fixture fix into layers/replay-state 2026-11-09 20:56:40 +01:00
Horizon agentandClaude Opus 5.5 c1dc47e41f Tests: the drag hook fixture cuts Studio's drag at studio_mouse_dragged
game.rs's CursorMoved arm now asks self.studio_mouse_dragged(b.width)
instead of the inline timeline drag; the fixture leaves that call out of
its slice as it left the inline block.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:56:39 +01:00
Horizon agentandClaude Opus 5.5 7311870047 Pages: inactive, half and footer buttons, text rows and space (spec #14167)
Generic row options a page drawn as its own screen needs (Play with Friends'
page, #14167 step 2a): Row::button(..).active(false)/.half()/.footer(),
Row::text(..).quiet()/.groups(), Row::Space. The options shell shows text as
notes, pairs half buttons and ignores space; Extras shows active buttons only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:55:32 +01:00
Horizon agentandClaude Opus 5.5 a8bf4c1474 Team: itarqos5 leaves the Horizon Team
Drops the trusted-reviewers line, the Team lists in docs/release-flow.md and the pinned login in
test_nightly_set. GitHub team memberships (horizon-team, contributors) are already removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:46:41 +01:00
Vinícius MoreiraandClaude Opus 5.5 29ac1e17b9 Polytone: fluid modifiers' fog
A fluid modifier's fog_colormap (a flat colour), fog_radius and fog_fade apply to the
water's or lava's fog after everything else: the colour replaces it, the radius scales the
fog, and a fade of 0 makes it a hard wall. Measured on the real mod (red fog at 0.3 of the
radius, within the scene's variance of the vanilla baseline). Colormap ids and expressions
aren't read yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:41:22 -03:00
Horizon agentandClaude Opus 5.5 4be9343eca Pass trail corners by value to to_array
Vec3::to_array takes &self, so it can't be handed to array::map directly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:32:08 +01:00
Horizon agentandClaude Opus 5.5 03fd6705a1 Drop the unused shown binding in Seat::player_layers
worn_of decides what shows since the fx moved onto the hooks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:27:38 +01:00
Horizon agent 115f5235b9 Merge remote-tracking branch 'origin/main' into split/official-servers-crate
# Conflicts:
#	crates/horizon-client-lib/Cargo.toml
#	crates/horizon-official/Cargo.toml
#	crates/horizon-official/src/lib.rs
#	crates/horizon-platform/src/lib.rs
2026-11-09 20:24:48 +01:00
Horizon agent a5a60598e4 Merge branch 'split/cosmetics-fx-api' into split/cosmetics-fx 2026-11-09 20:20:28 +01:00
Horizon agent 0fd4e22139 Merge branch 'split/cosmetics-own' into split/cosmetics-fx-api
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
2026-11-09 20:20:25 +01:00
Horizon agent ec19b6320a Merge branch 'split/cosmetics-layers' into split/cosmetics-own 2026-11-09 20:20:01 +01:00
Horizon agent 16e29a2774 Merge branch 'split/cosmetics-draw' into split/cosmetics-layers 2026-11-09 20:20:00 +01:00
Horizon agent e2afd13715 Merge branch 'split/cosmetics-names' into split/cosmetics-draw
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-hooks/src/lib.rs
2026-11-09 20:19:51 +01:00
Horizon agent 00aba43d81 Merge branch 'split/cosmetics-sprites' into split/cosmetics-names 2026-11-09 20:19:06 +01:00
Horizon agentandClaude Opus 5.5 e893fd3eac Hooks doc: the frame rule is the player methods', not tunnel's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:18:59 +01:00
Horizon agent ee986c3e62 Merge branch 'split/cosmetics-hooks' into split/cosmetics-sprites
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
2026-11-09 20:18:48 +01:00
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 agentandClaude Opus 5.5 d016678e24 cosmetics test: the prop's root is the item's, whatever its name
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:17:44 +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
Horizon agentandClaude Opus 5.5 3d9df204e9 Cosmetics: companions, trails and auras through the hooks; the game's own path removed (spec #14091 step 2b-iii)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:08:05 +01:00
Horizon agentandClaude Opus 5.5 984b7d8635 Hooks: PlayerDraw gets motion, view, looks and particles, and first_person (spec #14091 step 2b-ii)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:02:47 +01:00
Horizon agent 8df100e589 Merge the studio fixture fix into layers/studio-extras-gate 2026-11-09 19:57:58 +01:00
Horizon agent a416f1dfcd Merge the studio fixture fix into layers/replay-move 2026-11-09 19:57:55 +01:00
Horizon agentandClaude Opus 5.5 4c52129f7d Tests: capture fixture follows the Replay split
Replay playback moved out of Studio into game/replay.rs's Replay; the
pointer-capture fixture carries game.replay.driver and Game::studio_up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:57:53 +01:00
Horizon agentandClaude Opus 5.5 afa60454b2 Cosmetics: the own body and the inventory picture through PlayerDraw (spec #14091 step 2b-i)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:56:59 +01:00
Horizon agent 243d765452 Merge the studio fixture fix into layers/replay-state 2026-11-09 19:56:58 +01:00
Horizon agentandClaude Opus 5.5 ff52e244ae Tests: fixture Studio gets flying/replaying/active
fov.rs and the pointer-capture methods now ask self.studio.flying() and
replaying(); the fixtures mirror those three methods of game/studio.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:56:54 +01:00
Horizon agent bb712cca1a Merge the studio fixture fix into layers/studio-calls 2026-11-09 19:56:20 +01:00
Horizon agentandClaude Opus 5.5 1498fdecd3 Tests: fixtures read Studio's state from game.studio
test_compute_fov_hook and test_world_input_capture paste fov.rs and the
pointer-capture methods into a fixture Game; those now read
self.studio.{freecam,replay,rmb_down}, so the fixture carries a Studio.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:55:14 +01:00
Horizon agentandClaude Opus 5.5 8cf1e6b4d6 Move a packet's entity removal out of remote.rs
remote.rs is over 3000 lines and may not grow (verify's "Big files don't
grow" failed): remove_entity, remove_entities and their test move to
game/remote_removal.rs, a child module of remote, unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:55:05 +01:00
Horizon agentandClaude Opus 5.5 341a92902c Frog walk: keep FrogModel's 1.5 walk speed factor
FrogModel.setupAnim applies FROG_WALK with applyWalk(pos, speed, 1.5F, 2.5F)
on land (1.0F is the swim animation's).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:54:21 +01:00
Horizon agentandClaude Opus 5.5 aa8592fbf9 Cosmetics: their items on the body drawn through PlayerDraw (spec #14091 step 2a-ii)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:52:20 +01:00
MSedgeMC 7a4aedf231 Merge the PR branch's test fix 2026-11-09 18:42:08 +00:00
MSedgeMC 8e45334760 test: give the piglin load test an entity to load
`mob_from_nbt` needs the entity's `id` and `Pos`; the fixture only had the
brain, so the test unwrapped `None`. Add both to the compound and to the
never-expiring variant.
2026-11-09 18:41:53 +00:00
Horizon agentandClaude Opus 5.5 cef35e64e7 horizon-hooks: std's Mutex by its path after the merge
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:32:42 +01:00
Horizon agent 3a9b05d21d Merge remote-tracking branch 'origin/main' into split/discord-crate
# Conflicts:
#	crates/horizon-client-lib/Cargo.toml
#	crates/horizon-client/Cargo.toml
2026-11-09 19:31:04 +01:00
Horizon agentandClaude Opus 5.5 8aed306751 ui_theme: main's text_line test reads the body size and ink from the style
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:24:26 +01:00
Horizon agent 0c77cd5898 Merge branch 'split/cosmetics-names' into split/cosmetics-draw 2026-11-09 19:22:00 +01:00
Horizon agent 4848a9eff5 Merge branch 'split/cosmetics-sprites' into split/cosmetics-names 2026-11-09 19:21:59 +01:00
Horizon agentandClaude Opus 5.5 95b0cb05e7 horizon-hooks test: call SpriteStore::has by its trait
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:21:58 +01:00
Horizon agent da800dff55 Merge remote-tracking branch 'rv3n/14140' into rv3-fix-14140 2026-11-09 19:21:20 +01:00
Horizon agentandClaude Opus 5.5 67f61f1c24 Test the piglin anger load on a whole saved piglin
The test loaded a compound with no id or Pos, so mob_from_nbt returned None
and it could not pass; it now saves the id, a position and the brain, and
checks a TTL past the counter's range too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:20:49 +01:00
Horizon-agent 723c4e7c2b Merge remote-tracking branch 'origin/main' into pr-13811 2026-11-09 19:18:44 +01:00
Horizon-agent c32f43b4b0 Merge remote-tracking branch 'origin/main' into pr-13800 2026-11-09 19:18:37 +01:00
Horizon agentandClaude Opus 5.5 641a5859a2 horizon-platform re-exports the PlayerDraw types
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:18:34 +01:00
Horizon-agent b26a050b2b Merge remote-tracking branch 'origin/main' into pr-13795 2026-11-09 19:18:26 +01:00
Horizon agent bb19f898e4 Merge branch 'layers/render-theme-out' into layers/client-lib-style 2026-11-09 19:17:07 +01:00
Horizon agent bbc17333bd Merge branch 'layers/render-style' into layers/render-theme-out 2026-11-09 19:17:06 +01:00
Horizon agentandClaude Opus 5.5 8630bbed74 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:17:03 +01:00
Horizon agentandClaude Opus 5.5 7d17f516e5 Hooks: PlayerDraw, crates draw on player models in the world pass (spec #14091 step 2a-i)
horizon-hooks gains Hooks::player_layers with a plain PlayerDraw (bone
matrices, the frame's ticks, the body pose, sprites, draw of an Arc<Mesh>
with its parts posed) and the Mesh/Quad/Bone/Pass types. The engine
implements it on the GPU skin path (each mesh made a Part once, cached by
its address while a crate holds it) with the vertex path as fallback, and
dispatches it from creatures.rs's world pass one call for one call; the
cosmetics still draw there until 2a-ii.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:15:18 +01:00
Horizon agent 0f0ca381d4 Merge remote-tracking branch 'origin/main' into split/official-servers-crate
# Conflicts:
#	crates/horizon-platform/src/lib.rs
2026-11-09 19:14:23 +01:00
Horizon agent 2c5baa6a60 Merge branch 'layers/render-theme-out' into layers/client-lib-style
# Conflicts:
#	crates/horizon-client/src/ui_theme.rs
2026-11-09 19:12:40 +01:00
Oloion e3d24ab1a5 mobs: fix frog walking keyframe animation in singleplayer
Remote entity client update now calls frog::walk_target for Species::Frog so limb_speed advances at the vanilla 25x distance rate instead of 4x. Also restore FrogAnimation.FROG_WALK speed factor to vanilla 1.0F in body_anim and guard keyframe looping against zero length.
2026-11-09 21:11:32 +03:00
Horizon agent ed35d477be Merge branch 'layers/render-style' into layers/render-theme-out
# Conflicts:
#	tools/layers.json
2026-11-09 19:10:41 +01:00
Horizon agent 7a236ba054 Merge branch 'layers/window-icon-hook' into layers/render-style
# Conflicts:
#	crates/horizon-client/src/client_lib.rs
2026-11-09 19:10:23 +01:00
Horizon agent bf125a1a69 Merge remote-tracking branch 'origin/main' into layers/window-icon-hook
# Conflicts:
#	crates/horizon-client/src/entry.rs
2026-11-09 19:09:55 +01:00
Horizon agentandClaude Opus 5.5 8902d94a19 Cosmetics: name tags, the tab mark and the camera pull through the hooks (spec #14091 step 3b)
names.rs, scoreboard.rs and perspective.rs call the engine dispatch
(name_decoration, player_mark, camera) instead of the cosmetics; the
cosmetics answer from their per-seat state: badges become sprites by id
put in the atlas from the frame hook, covered slots read PlayerModel's
gear, the camera pull comes from the emote that pulled it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:07:06 +01:00
MSedgeMC 385dc78afc Merge remote-tracking branch 'upstream/main' into fix/lectern-has-book-signal 2026-11-09 18:06:14 +00:00
Horizon agentandClaude Opus 5.5 ca6140d05e Hooks: name decoration, player-list mark, camera and sprites by id (spec #14091 step 3a)
horizon-hooks gains Hooks::name_decoration (a Copy NameDecoration: mark
rectangles, a badge cut from the crate's sprite, the lift over the body),
player_mark for the player list, camera (a share of the third-person
distance), and sprites by id: Sprites read while drawing, SpriteStore
filled in Hooks::frame (skin slots, hooks/<hex>). PlayerModel carries
gear and invisibility; Frame says whether the world is the window's own.
horizon-platform re-exports them. The engine fills the store; no crate
answers the new hooks yet (step 3b).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:05:10 +01:00
Horizon agentandClaude Opus 5.5 661a253ec4 Armor stand /damage: attribute and clear freeze in the existing branches
Main already attributes the hit branch and has entity_event_by. The
explosion, ignite and burn branches now take the cause in place, so the
separate refused function (which left them dead) is gone; causeDamage keeps
one name. The frost-walker test no longer shadows game() (it did not compile).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:04:13 +01:00
Horizon agentandClaude Opus 5.5 ac0f7d6030 Studio's editor runs only with horizon-extras (layer plan M6)
studio::EDITOR = cfg!(feature = "horizon-extras"): without it the bars,
camera path, editor mouse and keys and the freecam/keyframe/path and
studio screen commands are off; recording and playback stay (platform).
The default build is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:03:04 +01: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 d56b769df5 Merge upstream/main 2026-11-09 18:00:08 +00:00
Horizon agentandClaude Opus 5.5 42026ce09f Replay recording and playback move to game/replay.rs (layer plan M6)
Pure move: open/close/seek/advance, record start/stop/status, the world
snapshot and the record/replay words of /studio and horizon-ctl studio
leave game/studio.rs, which keeps the editor (free camera, path,
timeline, top bar, its own command words). Where playback touched the
editor it calls it: save_studio_camera_track, studio_replay_opened,
studio_replay_advanced, studio_replay_closing, studio_replay_closed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:58:26 +01: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 agent 7bfa76577f Merge remote-tracking branch 'origin/main' into w14011 2026-11-09 18:57:29 +01:00
Horizon agentandClaude Opus 5.5 ac0a2f1275 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:52:51 +01:00
Horizon agentandClaude Opus 5.5 963782a17b Replay playback's state leaves Studio: game/replay.rs's Replay (layer plan M6)
Recording and playback are platform, Studio's editor is Horizon
(docs/layers.md, Replays): the driver, the camera type to restore,
standalone and the playback clock move to Replay; Studio keeps the free
camera, timeline and top bar. Callers ask self.replay.playing(); the own
player's tick fraction is Game::own_player_alpha(). No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:52:07 +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 1ffa9ed25f Screenshots: a second F2 in the same second takes the next name
The PNG is now written on its own thread, so the first press's file may not
exist yet when a second press picks its name: both took the same path and
two writers raced on one file. screenshot_name also skips the paths of the
screenshots still being written (UiState::screenshot_messages).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:44:35 +01:00
Horizon agentandClaude Opus 5.5 0022d3f42d Clamp a saved anger TTL past the counter's range instead of wrapping it
ExpirableValue's ttl is a long; narrowing it with `as i32` turned a TTL
above i32::MAX into a negative count, so the piglin loaded calm.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:42:22 +01:00
Horizon agent 547b1d4959 Merge branch 'layers/hud-layers' into layers/seat 2026-11-09 18:40:43 +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 38865e907e Studio: game.rs asks it through a few calls (layer plan M6)
game.rs's inline Studio blocks (bars, camera path, timeline drag, mouse
buttons, free-camera flight) and play_keys.rs's replay shortcuts move into
game/studio.rs methods; the engine reads Studio through replaying(),
flying() and active(). Pure move, no behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:37:38 +01:00
Nikita Maximilian Grimm b7615761ee Merge remote-tracking branch 'origin/main' into hdr-lighting-one-meaning
# Conflicts:
#	crates/horizon-client-render/src/gfx/output.rs
2026-11-09 18:34:44 +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 93e0536aca Studio: its state in one struct, game/studio.rs's Studio (layer plan M6)
Game's ten studio_* fields and last_replay_tick become one field, studio:
studio::Studio. Pure gather: every access is renamed, nothing else changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:29:54 +01:00
Skr1zzzzandClaude Opus 5.5 396353c6b1 Client: one pass over the big entity lists per RemoveEntities packet
Part of #13007. Each removed id ran its own retain over the mobs, orbs, dropped
items, falling blocks and primed TNT, and a scan of the passenger map, so a
packet of n ids made n passes over lists that hold thousands of falling blocks
while sand drops. RemoveEntities now removes its ids together: the per-id side
effects keep their order (the dragon's nearest crystal still follows the
crystals one id at a time), and those five lists and the passenger map are
filtered once against the packet's ids.

8,000 falling blocks, packets of 256 ids (debug test build): 26.1 ms -> 1.3 ms
per packet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:26:14 +03:00
Horizon agent 4c28a9b5b9 Merge remote-tracking branch 'origin/main' into pr-13795 2026-11-09 18:26:09 +01:00
MSedgeMC ac1f560622 port_map: the piglin's guarded ANGRY_AT memory is read back
`tools/port_map/game2-004.tsv` recorded `Piglin.readAdditionalSaveData` as
reading the baby flag and inventory; it now also names the brain's
`minecraft:angry_at` UUID and TTL read by `mobs/piglin.rs::read_guarded_anger`.
2026-11-09 17:21:54 +00:00
MSedgeMC 6a4172369c test: a saved guarded anger loads its UUID and ticks
`mob_to_nbt`/`mob_from_nbt` are the pair `Game::mob_tree_nbt` and
`Game::load_mob` go through. A compound whose `Brain.memories."minecraft:angry_at"`
has the UUID `[90, 91, 92, 93]` and `ttl: 99L` loads a piglin with
`extra.anger == 99` and `guarded_angry_at == Some(Some([90, 91, 92, 93]))`.
A memory without a TTL (the codec's never-expires form) loads the longest
counter, `i32::MAX`.

Without the read both fields stay at their defaults: `left: 0`, `right: 99`
for the counter and `left: None`, `right: Some(Some([90, 91, 92, 93]))` for
the UUID.
2026-11-09 17:21:54 +00:00
MSedgeMC 06130ae40d fix: a piglin keeps the guarded anger its brain saved
`PiglinAi` sets `ANGRY_AT` with an expiry when a player breaks a guarded block
near a piglin, and `Brain.Packed` saves every memory with a codec under
`Brain.memories`: the value is the player's UUID and the TTL is the ticks left.
`LivingEntity.readAdditionalSaveData` reads that brain back.

Horizon wrote `minecraft:angry_at` in `mob_to_nbt` but `mob_from_nbt` never read
it: a piglin that a player had angered unloaded with its chunk and came back
with `extra.anger` 0 and no actor UUID, so it stopped attacking and the guarded
event was forgotten.

The load now reads the memory back: a four-int UUID into
`extra.piglin.guarded_angry_at` and the `ttl` into the existing `extra.anger`
counter, with no TTL read as the longest counter there is. The writer moves to
`mobs/piglin.rs` beside it, which also keeps `entity_nbt.rs` from growing.
2026-11-09 17:21:53 +00:00
Horizon agentandClaude Opus 5.5 8ac6a79035 rustfmt, Cargo.lock: horizon-discord
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:19:44 +01:00
Horizon agentandClaude Opus 5.5 153e0d20a4 horizon-discord: the hook's Client by its path
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:18:32 +01:00
Horizon agentandClaude Opus 5.5 01343707af horizon-discord: Discord Rich Presence leaves the engine for a Horizon crate
Layer plan M5: discord_presence.rs moves to the new Horizon crate
horizon-discord (file move), which the official executable registers on two
new platform hooks: Hooks::client_started (every game window, automated
runs included, never a dedicated server) and Hooks::activity (what the
player does, dispatched on change only, P1's). game/discord.rs becomes the
engine's game/activity.rs, which reports Activity (Hidden while the Discord
switch is off, until the switch moves to the settings store). The discord
Cargo feature goes: horizon-client's own executable has no presence.
discord_presence_e2e.py runs the official executable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:11:42 +01:00
Horizon agentandClaude Opus 5.5 466201a864 Cargo.lock: horizon-official-servers after the merge
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:06:31 +01:00
Horizon agent ddb4f9df58 Merge branch 'split/official-servers-module' into split/official-servers-crate
# Conflicts:
#	Cargo.lock
#	docs/forking.md
2026-11-09 18:02:48 +01:00
Horizon agentandClaude Opus 5.5 c873af989a Menu style: ui_theme's overlays take their palette, sizes and fonts from it
horizon-client-lib no longer depends on horizon-theme outside its tests
(layer plan M6). menu_style gains the overlays' colours, ui_theme's type
scale and shapes, and its fonts (none given: the overlays draw no text);
ui_theme, the on-screen keyboard, the missing-resources window and the
Windows boot paint read them. horizon_look gives Horizon's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:55:10 +01:00
Horizon agentandClaude Opus 5.5 84baabff3c Renderer: the HDR logo and the HUD kit's motion no longer read horizon-theme
horizon-client-render no longer depends on horizon-theme outside its tests
(layer plan M6):
- hud_kit::motion: a Tween takes a cubic-bezier curve; the solver lives
  here, tested against the design system's curves.
- The HDR calibration logo is the window icon given at startup.
- The font tests take Geist Mono as a dev-dependency.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:55:10 +01:00
Horizon agentandClaude Opus 5.5 6622e459b2 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:54:58 +01:00
Horizon agentandClaude Opus 5.5 600a863354 Menu style: the settings pages and the tab strip take their colours from the layer above
menu_style (horizon-client-render): the colours of the settings pages
(graphics_style) and the HUD kit's tab strip, given once at startup;
vanilla's neutral greys when none is given. graphics_style's and tabs'
palette consts become functions reading it. horizon_look gives Horizon's
(layer plan M6; render -> theme goes in the next PR).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:54:58 +01:00
Horizon agentandClaude Opus 5.5 b77e4c4924 horizon_look.rs: the module the previous commit declares
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:54:56 +01:00
Horizon agent bd90908a72 Merge branch 'layers/hud-layers' into layers/seat 2026-11-09 17:44:40 +01:00
rdbtCVSandClaude Sonnet 5.5 1b4f056692 Merge main into hdr-lighting-one-meaning
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 16:44:13 +00:00
rdbtCVSandClaude Opus 5.5 15d2dd04c4 Screenshots: write F2's and the world icon's PNG off the render thread
The render thread waited for the GPU to finish the screenshot's frame
(poll wait_indefinitely), then converted the pixels and encoded the PNG
inline: at 4K or a Screenshot Quality above 1x, a second or more of frozen
game. F2's and the world icon's readbacks are now mapped asynchronously and
written on a thread of their own; the result reaches the chat a frame or a
few later. Agent screenshots and screenshot-sequence keep the synchronous
path, which hands them the result with the frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:43:50 +00: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 agent d82a6941d1 Merge remote-tracking branch 'origin/main' into pr-13800
# Conflicts:
#	crates/horizon-client/src/game/machines.rs
2026-11-09 17:19:40 +01:00
Horizon agentandClaude Opus 5.5 f7772b587f Natural spawning: the level keeps every player, the spawn state the anchoring ones
After main filtered fake players out of the spawn anchors, building the
level from that list would drop them from natural_level too; main builds
the level from every player and SpawnPlayer from the filtered list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:17:42 +01:00
Horizon-agent d1098c9a91 Merge remote-tracking branch 'origin/main' into pr-13811 2026-11-09 17:17:21 +01:00
Horizon-agentandClaude Opus 5.5 d50d56d837 Natural spawning: shuffle every pass, no candidate cache keyed on chunk count
Util.shuffle in ServerChunkCache.tickChunks draws from the level's random
whether or not a category may spawn, so skipping it moved every later draw
of that random off vanilla's. The candidate cache keyed on player x/z and
the chunk count went stale when a chunk's ticket level or entity loading
changed under a player standing still (an AFK farm). Both go; the allocation
savings (streamed Counted, reused SpawnPlayer buffer, the single-player
collect) stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:17:21 +01:00
Horizon agentandClaude Opus 5.5 8e2780a8f0 Cargo.lock: horizon-official-servers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:15:07 +01:00
Horizon agentandClaude Opus 5.5 1feb0c7b0b Keep every hopper's cooldown entry, as vanilla keeps tickedGameTime
With get_mut, an idle hopper never recorded the tick it ticked, so a
hopper that had already ticked this tick and then received an item from
another hopper took cooldown 8 instead of 7 (HopperBlockEntity.tryMoveInItem
skips one when its tickedGameTime >= the source's). Restore the per-tick
entry and the original prune; the other optimizations stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:15:07 +01:00
Tran Anh Van 96aa9791e6 Isolate ArmorStand.hurtServer changes to avoid 3-way merge overlap 2026-11-09 23:12:17 +07:00
Horizon agentandClaude Opus 5.5 8a5af429d7 Drop the offline capture reporting tools
They only re-project captures kept on the author's machine and start no
engine, so the test_*_parity.py name would register a harness CI cannot
run. The Rust fix and its unit and guest-death tests stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:08:50 +01:00
Horizon agentandClaude Opus 5.5 2cd4ec9888 horizon-official-servers: the official server leaves the engine for a Horizon crate
Layer plan M5: official_servers.rs moves (pure file move plus its hook) to
the new Horizon crate horizon-official-servers, which the official
executable registers through the platform's hooks: Hooks::started now gets
the game folder (Started::game_dir), and horizon-platform re-exports
servers.dat as horizon_platform::servers. Its addresses come from
horizon-addresses: the withdrawn partner's joins the list file, so its
allowed entry in tools/addresses.json goes. horizon-client's own executable
(plain builds, harnesses) no longer touches servers.dat at startup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:05:24 +01:00
Horizon agentandClaude Opus 5.5 aa060c5fe1 Horizon's look is given from its own module
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Horizon agentandClaude Opus 5.5 07ff96c50c rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Horizon agentandClaude Opus 5.5 19c23a96c6 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Horizon agentandClaude Opus 5.5 29d8c49bc2 Window icon: the engine takes it from the layer above
horizon-client-input's window code no longer embeds Horizon's icon: the
startup gives it (window::set_icon_png), and with_horizon_icon becomes
with_icon. horizon-client-input no longer depends on horizon-theme outside
its tests (layer plan M6).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Tran Anh Van c66936f747 Sync sculk entity_event_by and entity_killed_event_by with upstream main 2026-11-09 22:54:38 +07:00
Horizon-agentandClaude Opus 5.5 1d52dd41ba Give the HAS_BOOK lectern test its own doc and a positive control
The new test was inserted between the getRedstoneSignal doc comment and the
test it documents. It now also checks that a lectern whose state has
has_book=true reads the book signal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:51:21 +00:00
Tran Anh Van 0a6874877d Sync Guest::may_build with upstream main 2026-11-09 22:49:53 +07:00
Tran Anh Van e2ad02a58a Complete ArmorStand.hurtServer game events, freeze clearing, and damage immunity 2026-11-09 22:46:25 +07:00
martipopsandClaude Opus 5.5 fb23266b6e Merge controller/dial-3-play (with upstream/main) into controller/dial-4-editor
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:32:33 -05:00
martipopsandClaude Opus 5.5 d7d1e72afc Merge controller/dial-2-model (with upstream/main) into controller/dial-3-play
The radial menu's overlay_holds_view gate moves with debug_crosshair_drawn into
game/hud_render.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:32:33 -05:00
martipopsandClaude Opus 5.5 2a5ec67594 Merge upstream/main into controller/dial-2-model
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:32:17 -05:00
MSedgeMC 97ab5f100a port_map: tick_item_activation is in game/totem.rs
The row's note still said game.rs after the totem split. The function is in
game/totem.rs and both tick paths call it.
2026-11-09 14:35:28 +00:00
Horizon agentandClaude Opus 5.5 95a3721df1 perf(villagers): list a column's POI sections under one lookup
A villager's POI search (AcquirePoi, occupied POIs, homes) visited each
section of 9x9 columns through PoiSections::get: a mutex lock, a hash
lookup and an Arc clone per section. PoiSections now keys its lists by
column, and PoiSections::column takes the lock and finds the column once,
handing back only the sections that hold POIs, in the same order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:04:32 +01:00
Mik00000 0f8225e608 perf(spawning): optimize natural mob spawning density scan and chunk cache 2026-11-09 12:59:02 +02:00
Nikita Maximilian GrimmandClaude Opus 5.5 b2ba0cb395 HDR settings: HDR Lighting greyed under a shader pack, one switch for it
HDR Lighting is one thing: all of Horizon's own lift above paper white, light
sources, the sun and the lit surfaces. HORIZON_HDR_LIGHTING=0 switched off only
the surfaces' part, a second switch with another scope; it is gone, and the
setting does the job. docs/graphics.md now describes the setting.

Under a shaders/ pack, and under a 0.2 pack on the float frame, Horizon lifts
nothing, so the row did nothing there. It is now greyed and says "The shader
pack lights the world itself." Under a 0.1 pack on the 8- or 16-bit frame the
last pass still lifts the light sources and the sun, so the row stays live
there, as it does for a pack that is refused, missing or whose mod is off. The
page reads this from the renderer's output, which now carries whether the
running pack lights the world. HDR's own reasons still come first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 11:21:31 +01:00
Mik00000 72f748a785 perf(machines): optimize block entity ticking and hopper transfers 2026-11-09 12:11:26 +02:00
TheMrZZandCodex 9ad798b74b Preserve personal spawnpoint pitch for dedicated guests
Part of #13625. Keep owner physical pitch open and document recovered offline arrival evidence.

Co-Authored-By: Codex <[email protected]>
2026-11-09 10:33:51 +01:00
MSedgeMC 4a296fffb2 A lectern without its has_book state feeds no comparator
LecternBlock.getAnalogOutputSignal returns 0 unless the block state has
HAS_BOOK, before it asks the block entity for its page signal. Horizon used
the block entity's book alone, so a lectern whose state says it has no book
still fed a comparator.
2026-11-09 09:04:21 +00:00
martipopsandClaude Opus 5.5 f3e4420699 Merge controller/dial-3-play (with upstream/main) into controller/dial-4-editor
Main's renderer output for the graphics screen and HDR calibration moves with
shell_mut and shell_events into options_ui/shell_pages.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:03:26 -05:00
martipopsandClaude Opus 5.5 db3f211cfa Merge controller/dial-2-model (with upstream/main) into controller/dial-3-play
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:02:40 -05:00
martipopsandClaude Opus 5.5 dc2aaacea3 Merge upstream/main into controller/dial-2-model
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:02:39 -05:00
Vinícius MoreiraandClaude Opus 5.5 41fc640974 A player's break of an armor stand rolls its loot table
ArmorStand.hurtServer breaks a stand through brokenByPlayer on a second
player hit within five ticks, and at once for an always_kills_armor_stands
type: the stand item (with the stand's name) pops at its block, then
brokenByAnything plays the sound, rolls the loot table and pops the
equipment. Horizon's punch dropped the item and equipment without the
loot table, and /damage refused every can_break / always_kills type.

Part of #12514

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:35:28 -03:00
AppaandCodex 300d65c317 Preserve piston restitution outside the unoccupied mob slice
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 23:13:12 -08:00
AppaandCodex 18c8cc5eb9 Cover partial piston landings and the mob bounce sync marker
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 22:58:07 -08:00
AppaandCodex f9baaaed27 Limit the ground-state change to vertical NoAI pushes
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 22:24:42 -08:00
AppaandCodex 72352e0006 Keep the piston mob fix on the authoritative path
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 22:18:07 -08:00
AppaandCodex d4da822ac2 Keep piston bounce fixture gamerules unchanged
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 22:06:44 -08:00
AppaandCodex 26f8689238 Use mob restitution for piston pushes onto slime
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 22:01:33 -08:00
martipopsandClaude Opus 5.5 9a43f998c1 Docs (UI kit): the shell's lists, steps and opened focus
Screen::choose, Hosted::clicks_step, Shell::focus_soon and
changed_elsewhere, the binding row's chosen text, Capture::Pad on any
shell screen, and where a shell screen is hosted (options_ui/shell_pages.rs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:44:08 -05:00
martipopsandClaude Opus 5.5 2cdde29154 Merge controller/dial-3-play into controller/dial-4-editor
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:43:38 -05:00
martipopsandClaude Opus 5.5 7816e1adbb Docs (UI kit): overlays that hold the view, through overlay_holds_view
What vanilla draws in the middle of the view (the crosshairs, the chat's
lines, the toasts, the hands) gives way to an overlay through one query,
so the next overlay adds its condition there rather than to each piece.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:43:31 -05:00
martipopsandClaude Opus 5.5 d94ceb2eb9 Merge controller/dial-2-model into controller/dial-3-play
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:43:31 -05:00
martipopsandClaude Opus 5.5 4843a2b975 Docs (UI kit): who asks pad_capture, and how a new overlay joins it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:42:32 -05:00
martipopsandClaude Opus 5.5 0d7c032320 Merge controller/dial-3-play (upstream/main) into controller/dial-4-editor
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:39:47 -05:00
martipopsandClaude Opus 5.5 932fea900e Merge controller/dial-2-model (upstream/main) into controller/dial-3-play
Main moved hand_geometry and first_person_arm to game/frame_geometry.rs:
the hands still come from hands_shown there, so an overlay that holds
the view hides them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:39:42 -05:00
martipopsandClaude Opus 5.5 a53de4943f Merge upstream/main (a758c3eb20) into controller/dial-2-model
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:39:11 -05:00
martipopsandClaude Opus 5.5 2aee551905 Options: the shell pages' glue in options_ui/shell_pages.rs
Which shell each page opens, what a shell's outputs do, and the radial
menu's name entry and rows move out of options_ui.rs as they are, so the
file doesn't grow. pad_clicks_step now also sets Shaders' shell back,
which main put on the shell since.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:26:15 -05:00
martipopsandClaude Opus 5.5 599ef2acd3 Settings shell: its tests in settings_shell/tests.rs
A pure move: the tests module leaves settings_shell.rs as it is, so the
file is under 3000 lines (docs/layers.md#file-size) and the radial
editor's additions to the shell don't grow a file over the limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:26:15 -05:00
martipopsandClaude Opus 5.5 9969321db4 Merge controller/dial-3-play (upstream/main) into controller/dial-4-editor
Main put Shaders on the settings shell and made Screen::undo return
outputs: the radial screens' sheets use Sheet::new and their undo returns
none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:22:10 -05:00
martipopsandClaude Opus 5.5 acfa08d9e7 Controllers (radial): one overlay_holds_view query, so game.rs and remote.rs don't grow
game/hud_room.rs answers whether an overlay holds the view (the radial
menu, once it shows), and the 2D and F3 crosshairs, the chat, the toasts
and the hands ask it. The four toast draws go into draw_every_toast and
the hands into hands_shown there; draw_pad_over_hud draws the menu and
then the pad's cursor. The respawn no longer closes the menu itself: the
new player's first position does, through the one pad_player_replaced
call in remote.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:17:38 -05:00
martipopsandClaude Opus 5.5 91d32f40bc Merge controller/dial-2-model (upstream/main) into controller/dial-3-play
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:17:38 -05:00
martipopsandClaude Opus 5.5 1691d8d65b Controllers (radial): the radial menu's file settings in gamepad/radial_file.rs
RadialMode, RadialStick, the opener's thumb rule, each stick's session
opener, can_bind, set_radial_stick and their tests move out of
gamepad.rs as they are, so gamepad.rs stays under 3000 lines. The paths
under horizon_client_input::gamepad stay the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:10:46 -05:00
martipopsandClaude Opus 5.5 408b3dc47d Controllers: pad_capture is a module of game/pad, so game.rs doesn't grow
game/pad_capture.rs moves to game/pad/capture.rs as it is, its items
visible to the gameplay module as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:10:46 -05:00
martipopsandClaude Opus 5.5 d55b4b0a78 Merge controller/dial-1-kit (upstream/main) into controller/dial-2-model
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:07:52 -05:00
martipopsandClaude Opus 5.5 1a1e4c6f46 Controllers (radial): edit the menu's pages on the settings shell
Controller Settings > Bindings > Radial Menu... opens the pages on
main's settings shell (radial_editor.rs): a sheet per page with its
eight slots by direction, then Rename Page, New Page and Delete Page,
then Reset All Pages and the opener's row.

- A press on a slot opens the list of every action and Empty for it,
  which names the slot, marks what it holds and opens on it; Right and
  Left still step it (`Screen::choose`, and a controller's Left and
  Right count as steps, `pad_clicks_step`). Back from the list, the
  focus is on the slot again (`Shell::focus_soon`).
- Rename opens a small page with a name field, Done and Cancel.
- Delete and Reset act at once; Undo puts the pages and the opener back
  as the screen found them. At most 6 pages, at least one.
- The opener's row is a `Value::Binding` on `Capture::Pad`: it waits in
  place for a button, refuses the ones under the selecting thumb and B,
  and its strip says when no button opens the menu or another action
  shares it.
- Controller Settings' own Undo leaves the pages to this screen.

e2e: radial_editor, and Settings Pages reaches the new page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:42 -05:00
martipopsandClaude Opus 5.5 7008bc32ba Controllers (radial): e2e for the radial menu in play
tools/pad_menu_e2e.py gains 28 radial_* cases, each a real offscreen game
driven through horizon-ctl's virtual pad: commits, retention, pages,
cancels (B, Start, a disconnect, focus loss, death with immediate
respawn), the camera and the walking stick, menus and the on-screen
keyboard keeping the opener, held sneak and drop, D-pad rolls after a
commit or a cancel, RT/keys/clicks fighting back, Toggle mode, the left
stick and Either, a bumper opener, haptics, narration, unavailable and
missing actions, a file from before the menu, Nintendo glyphs and the
status rows. A case can seed the controller file and pass extra launch
arguments.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:42 -05:00
martipopsandClaude Opus 5.5 2ccc6f7ffe Controllers (radial): Hold or Toggle, and the stick that selects
Controller Settings > General gains a Radial Menu group on main's
settings shell: Radial Menu (Hold, the default, or Toggle) and Radial
Menu Selects With (Right Stick, Left Stick or Either), each describing
its value in the strip, and stepping with Left and Right as every shell
choice does. Stepping the stick goes through `set_radial_stick`, so an
opener under the thumb that now selects is let go, and stepping back
gives it again. e2e: radial_stick_back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:42 -05:00
martipopsandClaude Opus 5.5 a14242eb15 Controllers (radial): the menu's settings in the controller file
PadSettings gains the radial pages, `radialMode` and `radialStick`, and
the opener's binding, `pad.radial` on D-pad Right; nothing in play reads
the opener yet (it routes as a held button until the menu does).

- `bindLayout:2`: once, for a file from before the opener, a button the
  player bound by hand keeps it, and the opener goes unbound rather than
  sharing it. A file from before the binds list's defaults binds none.
- An opener under the thumb that selects, or on B, can't be bound
  (`can_bind`), and a file with one reads as never saved.
- `set_radial_stick`: a stick that takes the opener's thumb unbinds it;
  stepping back gives each stick the opener it had this session, else
  D-pad Right where it's free.
- Lines this game doesn't read (a newer Horizon's settings, an action it
  doesn't have, a button it doesn't know) are kept as they were, and a
  newer `bindLayout` is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:41 -05:00
martipopsandClaude Opus 5.5 d747ae6c26 Controllers (radial): hold D-pad Right for a radial menu of actions
The radial menu in play (game/pad/radial_play.rs): hold the opener and
the selecting stick points at one of eight slots; letting go runs the
action once, through the same path as its button. Bumpers turn the
pages. `pad.radial` now routes to it, and the dial draws over play with
the game's icon painter and the frame's motion clock.

- Hold or Toggle, and the right, left or either stick, from the file.
- B, Start, a disconnect, focus loss, a new player (death with
  immediate respawn, the End's exit) or a screen cancels; RT/LT, any key
  or mouse click cancels and goes on. A flick between polls counts (the
  pads' peaks, splitscreen's router included). Holds from before the
  open keep working; a held drop restarts its wait.
- A D-pad roll off the opener reaches nothing for 100 ms after the later
  of the close and the opener's release.
- What can't run is dimmed: Emote without cosmetics, Quick Actions
  without a dialog; Social in single player shows vanilla's "not
  available" message, as its key now does.
- While it shows: the chat lines, toasts, crosshair (F3's too), guide
  and first-person hand give way; the status rows are kept clear.
- Haptics on reaching a runnable slot, a page turn and a release that
  ran nothing; narration of the page and a settled slot.
- horizon-ctl: `pad focus true|false` sends a real focus event.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:41 -05:00
martipopsandClaude Opus 5.5 a9e60a6782 Controllers (radial): pages of eight slots, and their text
radial.rs gains what the menu's pages hold: `RadialAction` (actions that
do everything on their press; Horizon's own namespaced, vanilla's by
their key mapping, old ids read through an alias table), `Slot` (an
action, Empty, or a Missing id kept as it was, which never runs), named
`Page`s of a kind (`Fixed` slots, or `Hotbar`, read and kept), and the
default General and Camera presets, which hold only actions with no
button of their own.

`Pages::parse` and `serialize`: the `radial_<n>_*` lines of
horizon-controller.txt. A page the player hasn't changed is saved as its
preset, so it follows the defaults; `radialLayout` is read and a newer
one kept; unknown fields, kinds and slot ids past the eighth survive a
save; up to 16 pages are read and the menu turns through 6. Edits:
set_slot, rename, add, remove.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:41 -05:00
martipopsandClaude Opus 5.5 7a92b5064c Controllers (radial): the dial's hub, page tabs and Cancel hint
The hub names the selection on a dark disc: the action, and for
Perspective, Hide HUD and Cinematic Camera its state and what a press
turns it to; for a slot that can't run, why; for a missing action, its
id; while a stick must be let go first, "Let go of the stick". The large
icon goes above the name when the hub has room, and its text stays off
every tile (`fit::fit_in_circle`). Toggle tiles get state chips where
there's room for them.

Along the top, the pages are a `tabs::tab_strip` between the bumpers'
glyphs; collapsed, the bumpers name the pages they turn to. With a
bumper for the opener only the other shows. B's Cancel sits on a plate
at the lower right when the view is roomy, in the player's glyphs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:41 -05:00
martipopsandClaude Opus 5.5 87346bb92f Controllers (radial): the menu's model, held open by a button
horizon-client-input's radial.rs: `RadialMenu`, what the opener, the
selecting stick and the bumpers do while the menu is open, knowing pages
only by their count and slots by their index. Eight slots clockwise from
the top; a release commits once; 250 ms of neutral retention; a stick
leant since before the press waits to be let go (`StickHistory`), while a
flick that starts with the press selects; tilts that follow the
deadzone (`Tilt`); toggle mode's latch, with A running the slot settled
on (`A_SETTLE`); `DpadSettle` for a thumb rolling off the opener; and
`Settle` for narration. Every tuning value is at the top of the file.

gamepad.rs: `RadialMode` and `RadialStick`, `opener_refused` (the opener
is never under a thumb that selects, nor on B) and the radial haptics.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:41 -05:00
martipopsandClaude Opus 5.5 d16c9137f9 Controllers (radial): the dial's ring and tiles
radial_overlay.rs draws the radial menu over play, in the look the
mockups settled on (`docs/ui-kit.md`'s pieces): a dark vignette, the
ring's track with the selected 45° sector lit, and eight tiles on true
45° rays, each vanilla's game mode switcher slot with its action's icon
from the player's own resources. An empty slot is a pip; one that can't
run is dimmed and slashed; a missing action shows its mark. The sizes
follow the view's height (`Layout`, the scaling rule in its docs), and
the ring keeps below the boss bars and above the status rows over the
hotbar (`Keep::StatusRows`, which the HUD's room gains). Its motion
(`Look`) is quiet and never selects: a fade and scale in, the sector
easing to a new slot, the tiles sliding in on a page turn, a commit's
flash and a fade out.

Nothing draws it yet. The hub, the page tabs and the Cancel hint come
next.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:41 -05:00
martipopsandClaude Opus 5.5 0e4163f67f Controllers: one routing point for what owns the pad
game/pad_capture.rs: `Game::pad_capture` says which overlay over play
owns the controller now, in one priority order (the on-screen keyboard,
then a prompt over the world). The keyboard checks in pad.rs,
pad_actions.rs, pad_menu.rs and the gameplay guide ask
`pad_keyboard_captures()`. Behaviour is unchanged; the radial menu adds
its place in the order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:41 -05:00
182 changed files with 17533 additions and 4239 deletions

No files matched your search

-1
View File
@@ -13,4 +13,3 @@
87152661 rdbtCVS # added by Ludovic, 2026-10-27
84875247 Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
86072227 levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
169198624 itarqos5 # added by Ludovic, 2026-11-08 (Discord Literal; Horizon Team)
+2
View File
@@ -198,3 +198,5 @@ new pushes, we add it again.
[Discord](https://discord.gg/playhorizon) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
for bugs. To take something on, `/claim` its issue ([above](#claim-before-you-start)).
How Horizon is run, and how people join the Team or lead a group: [GOVERNANCE.md](GOVERNANCE.md).
Generated
+21 -2
View File
@@ -1461,7 +1461,6 @@ dependencies = [
"horizon-server-files",
"horizon-shader-api",
"horizon-shaders",
"horizon-system",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
@@ -1651,6 +1650,15 @@ dependencies = [
"ureq",
]
[[package]]
name = "horizon-discord"
version = "0.1.0"
dependencies = [
"horizon-platform",
"log",
"serde_json",
]
[[package]]
name = "horizon-dualsense"
version = "0.1.0"
@@ -1794,20 +1802,31 @@ dependencies = [
name = "horizon-official"
version = "0.1.0"
dependencies = [
"horizon-discord",
"horizon-friends",
"horizon-game",
"horizon-heartbeat",
"horizon-official-servers",
"horizon-platform",
"log",
"winresource",
]
[[package]]
name = "horizon-official-servers"
version = "0.1.0"
dependencies = [
"horizon-addresses",
"horizon-platform",
"log",
]
[[package]]
name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"horizon-core",
"horizon-system",
"log",
"regex",
]
+2
View File
@@ -34,6 +34,8 @@ members = [
"crates/horizon-game",
"crates/horizon-official",
"crates/horizon-heartbeat",
"crates/horizon-discord",
"crates/horizon-official-servers",
"crates/horizon-bench",
"crates/horizon-friends",
"crates/horizon-relay",
+483
View File
@@ -0,0 +1,483 @@
# Horizon governance
> Adopted 2026-11-09 by the founder as his last decision as head of the project. It takes effect when he
> announces that he is stepping down. From then on only the council changes it, by a 2/3 poll (section 15).
Horizon has no boss. A **council of equals** decides by polls. **Theme groups** do the work in their areas, each
with a **lead**. The **Horizon Team** reviews and merges code. **Moderators** keep the Discord server and the
official server safe. Every role of trust is earned with work the project can measure, and it ends when that work
stops.
## Contents
1. [Principles](#1-principles)
2. [The levels at a glance](#2-the-levels-at-a-glance)
3. [Each level in detail](#3-each-level-in-detail)
4. [The council and its polls](#4-the-council-and-its-polls)
5. [The selection filter](#5-the-selection-filter)
6. [Theme groups](#6-theme-groups)
7. [Moderation and appeals](#7-moderation-and-appeals)
8. [Conflicts and hard cases](#8-conflicts-and-hard-cases)
9. [The age rule (18+)](#9-the-age-rule-18)
10. [The agents](#10-the-agents)
11. [Accounts and the handover path](#11-accounts-and-the-handover-path)
12. [Money](#12-money)
13. [Transparency](#13-transparency)
14. [Rules that are never voted on](#14-rules-that-are-never-voted-on)
15. [Changing this document](#15-changing-this-document)
## 1. Principles
- **Equals.** Every council member has one vote. Nobody, the founder included, stands above the others.
- **The founder.** Ludovic keeps the title of **Founder**: his role stays at the top of the member list and he is
credited as Horizon's founder everywhere. The title is symbolic. It carries no extra vote or power: on the council
he counts exactly like every other member.
- **Evidence, not popularity.** You get a role by doing the work first. Kira measures that work: merged pull
requests, reviews, triage, support answers and days active. A vote then confirms it.
- **Small, clear powers.** Each role can do a short, written list of things. Anything else goes to the council.
- **Roles are on loan.** Every role has a 30-day probation and an inactivity rule. Stepping down for inactivity is
not a sanction, and you can come back.
- **Public by default.** Polls, results and rules are public. Only matters about a person stay private, and even then
the result is published.
- **18+ for roles of trust** (section 9).
- **Mojang's and Microsoft's rules come first.** They are never put to a vote (section 14).
## 2. The levels at a glance
From the most open level to the most trusted. A person can hold several levels at once: a group lead can also be on
the Horizon Team, for example.
| Level | What it can do | How you get in | How you leave | 18+ |
| --- | --- | --- | --- | --- |
| **Member** | Play, chat, report bugs, suggest ideas, open issues and pull requests, pick group roles | Join the Discord server or the GitHub repo | Leave, or get banned | No |
| **Contributor** (⛏️ Iron, 🥇 Gold, 💎 Diamond, 🟪 Netherite ranks) | Write in #contributors; `/claim` issues; nominate people for group lead (if active in that group) | One merged pull request in a public horizon-rust repo, linked with `/link-github` and `/verify-github`. Ranks come from merged pull requests: 10, 40, 100 and 200 | Contributor status and ranks stay | No |
| **Group member** | Read and write in the group's channel; get the group's pings; take part in the group's advisory poll on its lead | Pick the group's role in Channels & Roles, or with `/groups` | Remove the role | No |
| **🤝 Support** | Answer and close posts in #horizon-support | `/apply`; vote of the mods and the Team (as today) | Resign, 30 days inactive, or removed by the Chief of Mods or the council | No |
| **🛡️ Moderator** | Time out, delete messages, manage threads, quarantine. No bans | `/apply`, then the selection filter; mods' vote; the council may object within 24 h | Resign, 30 days inactive, suspended by the Chief of Mods, removed by a council poll (simple majority) | **Yes** |
| **🛡️ Chief of Mods** | A moderator's powers, plus bans; leads the mods; suspends a mod at once pending the council | Council poll (simple majority), from the mods with at least 30 days | Resign, 30 days inactive, council poll (simple majority) | **Yes** |
| **🧭 Group lead** | Decides inside the group's topic (section 6); manages the group's channel; triage on the group's GitHub issues | Nomination, the filter, a 24 h public discussion with an advisory group poll, then a council poll (simple majority); 30 days probation | Resign, 30 days inactive (automatic), council poll (simple majority); confirmed again every 6 months | **Yes** |
| **🌌 Horizon Team** | Review and merge pull requests (GitHub `horizon-team`, write access); `/nightly` and triage commands (trusted reviewers); #horizon-team | Nomination, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, 60 days without a merge or review (automatic), council poll (2/3, without the person) | **Yes** |
| **🏛️ Council** | Decides everything else by poll (section 4); bans; emergency actions by any 2 members | Nominated by a council member, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, inactivity (section 5.5), council poll (2/3, without the person) | **Yes** |
**The account holder** isn't a level. The founder (Ludovic) still holds the project's accounts (GitHub organisation
owner, Discord server owner, the Microsoft app approved by Mojang, the domains, Railway, money). He carries out the
council's decisions on them and hands them over step by step (section 11). As a council member he has one vote,
like everyone else.
## 3. Each level in detail
### Member
Anyone in the Discord server or on GitHub. Members follow the server rules and Horizon's
[contributing guide](CONTRIBUTING.md). They can propose anything: an idea in #horizon-ideas, a bug in #horizon-bugs,
an issue or a pull request on GitHub, or a request for a council poll (any council member can pick it up).
### Contributor
- **Discord:** the 🛠️ Contributor role writes in #contributors and in the threads of #polls-and-decisions. A rank
role (⛏️ Iron at 10 merged pull requests, 🥇 Gold at 40, 💎 Diamond at 100, 🟪 Netherite at 200, counted over every
public horizon-rust repo) shows how much has been merged. Ranks only go up.
- **GitHub:** no repository role. Anyone can `/claim` and `/unclaim` their own issues.
- **Alone:** their own work.
### Group member
Each theme group (section 6) has a role that anyone can pick and drop. It opens the group's channel and receives the
group's pings. Picking a role grants no power. A group member counts as **active in the group** when Kira has seen at
least one counted action in the group's area (section 5.1) in the last 60 days. Only active group members can
nominate a lead or vote in the group's advisory poll.
### 🤝 Support
Unchanged: Support members help players in #horizon-support. They answer every post there and mark posts solved.
The role carries no permission, so it has no age rule. They join through `/apply`, and the mods and the Team vote.
### 🛡️ Moderators and the Chief of Mods
- **Discord:** time out members, delete messages, manage threads and nicknames, read the audit log, quarantine
through Kira, and see the staff channels. **Moderators don't ban.** Bans are done by council members and the Chief of
Mods. Kira's automatic actions (the honeypot, clear scams, illegal images) stay as they are.
- **Official server:** server operators (ops) are moderators in game. They need the 18+ rule and are confirmed the
same way (section 6, Server and SMP).
- **Alone:** a timeout, a deleted message, a quarantine, with a reason in the mod case.
- **The Chief of Mods** is the first contact for moderation questions. They organise the mods, can ban, and can take a
moderator's role away at once when there is a problem. The council confirms or reverses that within 72 hours.
### 🧭 Group leads
Section 6 says what a lead decides alone and what goes to the council.
- **Discord:** in their group's channel only, delete messages, manage and pin threads, and ping the group role. The
🧭 lead role carries no server-wide permission.
- **GitHub:** triage on the issues and pull requests that carry their group's label (`/label`, `/unlabel`, `/close`,
`/reopen`, `/unclaim`), from a list of group leads that grants no repository role. Leads merge only if they are on
the Horizon Team too.
### 🌌 Horizon Team
The Horizon Team **merges pull requests**. Its rules are in [the release flow](docs/release-flow.md): read the whole
diff, never merge your own pull request, and use `needs-proof` for anything risky.
- **GitHub:** member of the `horizon-team` team (write access to the Horizon repos), on the trusted reviewers list
(`/nightly` and triage commands).
- **Discord:** the 🌌 Horizon Team role, #horizon-team, and writing in #contributors and the read-only channels.
Moderation permissions belong to the moderators and the council: a Team member moderates only if they also hold
one of those roles.
- **During the 30-day probation:** they merge only ordinary pull requests. They don't merge `needs-proof` pull
requests, don't approve files that CODEOWNERS guards, and don't use `/nightly`.
### 🏛️ Council
- **Discord:** the 🏛️ Council role: the moderators' permissions plus ban and mention @everyone; #council (private);
starting polls in #polls-and-decisions.
- **GitHub:** the `council` team. Over time it takes over the founder's CODEOWNERS review and his owner powers in the
release tools (section 11).
- **Alone (any one member):** start a poll; pick up a member's request for a poll; do anything their other roles
allow.
- **Any two members together:** an emergency action (section 4.6).
- **Everything else** is decided by poll.
## 4. The council and its polls
### 4.1 Founding council
kacpherek, levzzz5154, rdbtCVS, Online5277, dezer-x and Ludovic: six members, one vote each. The founding members
don't go through the filter or a probation. They do make the 18+ declaration (section 9). The council has at most
**9** members. It adds a seat only when a 2/3 poll says it needs one.
### 4.2 How a poll works
| Rule | Value |
| --- | --- |
| Who starts one | Any council member, with `/poll` (Kira). Anyone else asks a council member to start it |
| Where | #polls-and-decisions, in public. Polls about a person (removal, appeals, the age rule) run in #council, and only their result is public |
| How long | 24 hours. It closes early only if every council member has voted |
| Ballot | Yes, No or Abstain. You can change it until the poll closes |
| Quorum | At least half of the council votes (Abstain counts). With 6 members, 3 votes |
| Simple majority | More Yes than No |
| Two-thirds | Yes is at least 2/3 of the Yes and No votes, **and at least 3 Yes votes** |
| Tie | Nothing changes |
| No quorum | Nothing changes. Kira reminds the members who haven't voted 12 hours and 2 hours before the end |
| About yourself | You don't vote, and you don't count in the council's size for that poll |
| Asking again | A poll that failed can't be asked again for 7 days, unless two members agree that new facts justify it |
### 4.3 Which threshold applies
| Simple majority | Two-thirds |
| --- | --- |
| Day-to-day project decisions: direction, priorities, settling disputes between groups, a public statement | Adding council members and Horizon Team members |
| Group leads and co-leads: appointing, removing, confirming every 6 months | Removing a council member or a Team member (without the person concerned) |
| Moderators, the Chief of Mods, Support: confirming, removing | Rules: this document, the server rules, the contributing policy, the licence |
| Ending a probation early | Money (section 12) |
| Confirming an emergency action | Account handover steps (section 11) |
| Second-level moderation appeals | Waiving part of the selection filter (section 5.4) |
| Bringing back someone who stepped down for inactivity, within 6 months | Changing the council's size |
**When in doubt, two-thirds.** Any council member can say a poll needs 2/3, and Kira then counts it at 2/3.
### 4.4 Discussion
Polls that add someone to the council, the Team or a lead post start with a **24-hour public discussion** (section
5.3). Other polls are discussed in their own thread while they run. A member who wants more time votes No and says
why. A failed poll can come back after 7 days.
### 4.5 Conflicts of interest
A member doesn't vote on a poll about themselves: their own role, their own removal, an appeal of their own
moderation action, or money paid to them. They say so in the poll's thread and Kira leaves them out of the count.
### 4.6 Emergencies
For **security** (a leaked secret, a malicious pull request or mod, a hijacked account), **Mojang or Microsoft** (a
request from them, a risk to the approved sign-in) or **legal** matters (a takedown, the law), **any two council
members act at once**. They may:
- pause releases and the Nightly;
- take a mod down from the registry;
- remove someone's roles or access for now;
- lock or hide channels and content;
- ask the account holder to rotate a secret or a key.
They record it with `/emergency` in Kira, and both of them confirm it there. Kira opens a council poll at once
(simple majority) to confirm it. If the poll doesn't pass, the action is undone where it can be. An emergency never
adds members, changes rules or spends money.
### 4.7 Removing a council member
A 2/3 poll of the other members, in #council. The person concerned doesn't vote and doesn't count in the quorum.
Only the result is published, never the reasons. Other ways to leave: resigning, inactivity (section 5.5), or a
false age declaration (section 9).
### 4.8 When the council drops below a quorum
- **Fewer than 5 members:** Kira opens nominations for the council at once. The council fills seats within 30 days.
- **Fewer than 3 members, or fewer than 3 who voted in the last 30 days:** an **interim council** forms. It counts the
remaining council members, the Horizon Team and the group leads past probation. Its only task is adding council
members, by its own 2/3 poll with a quorum of half. Any two of its members can take emergency actions. All other
decisions wait. The Team keeps merging and the leads keep leading.
- **Nobody left:** the Team and the group leads form the interim council in the same way.
## 5. The selection filter
Nobody becomes a lead, a Team member or a council member just because they are well liked or talk a lot. Kira
measures the work, and only people above a floor can be nominated. The council then decides among them.
### 5.1 What Kira measures
| Signal | What counts | Source |
| --- | --- | --- |
| **Merged pull requests** | Pull requests merged in a public horizon-rust repo. For Modding, also in the mod API's fork (arismoko/horizon). Counted overall, and per group by label or by files touched | GitHub search, as the ranks are counted today |
| **Reviews** | Others' pull requests you reviewed: approved, asked for changes, or left a review with comments. One per pull request | GitHub search `reviewed-by:` and Kira's GitHub webhook |
| **Triage** | Triage commands run (`/label`, `/close`, duplicates), issues labelled or closed, bug posts routed on Discord | The triage workflow's comments and Kira's webhook |
| **Support answers** | #horizon-support posts you answered that ended solved | Kira's support feature |
| **Active days** | Distinct days with any of the above, or with messages in #contributors or a group's channel. Chat elsewhere doesn't count | Kira |
| **Tenure** | Days since your first counted contribution (since you joined the server, for moderators) | GitHub, Discord |
| **Clean record** | No timeout, quarantine or ban in the last 90 days | Kira's mod cases |
| **Verified GitHub** | `/verify-github` done | Kira |
| **18+ declaration** | Made (section 9) | Kira |
Kira turns these into an **evidence card**: the person's numbers, overall and in the group's area, for the last 60
days and in total. Anyone can see their own card with `/evidence`. A nomination shows the card publicly.
### 5.2 The floors
A nomination is refused below these floors.
| Role | Floor |
| --- | --- |
| **Group lead** | Verified GitHub; tenure of 30 days or more; **in the group's area: 8 or more merged pull requests, or 20 or more reviews** (Translations: 5 or more merged translation pull requests; Server and SMP: 8 pull requests, or 30 days as an official server operator with activity); 15 or more active days in the last 60; clean record; 18+ |
| **Moderator** | In the server for 30 days or more; Kira level 10 or more; 15 or more active days in the last 60; clean record; 18+ |
| **Chief of Mods** | Moderator for 30 days or more, past probation; 18+ |
| **Horizon Team** | 🥇 Gold rank (40 merged pull requests); 20 or more reviews of others' pull requests; tenure of 30 days or more; 20 or more active days in the last 60; clean record; verified GitHub; 18+ |
| **Council** | Horizon Team member, group lead or Chief of Mods for 60 days or more, past probation; 20 or more active days in the last 60; clean record; 18+ |
The numbers are checked again once a year (section 15). They should stay reachable for people who really work on
Horizon and out of reach for people who don't.
### 5.3 The path to a role
1. **Nomination.** Run `/nominate member: role: [group:]` in Kira.
- A **group lead** can be nominated by an active member of that group or by a council member. A self-nomination
needs a second from another active group member or a council member.
- A **Team member** can be nominated by a council or Team member. Someone can also nominate themselves with
`/apply`, if a council or Team member sponsors them.
- A **council member** can only be nominated by a council member.
- **Moderators** apply with `/apply`.
2. **The filter.** Kira checks the floor and the 18+ declaration. Below the floor, Kira refuses and says which line
is short.
3. **Public discussion, 24 hours.** Kira posts the nomination and the evidence card in a thread of
#polls-and-decisions. Contributors and the council can comment. For a lead, Kira also runs an **advisory poll** in
the group's channel for the same 24 hours. Only active group members vote in it, and the council sees the result.
4. **Council poll, 24 hours.** Simple majority for a lead, 2/3 for the Team and the council. For moderators, the mods'
vote replaces this step, and any two council members can object within 24 hours; the council then decides by poll.
5. **Probation, 30 days.** The role is held in full, except for the Team limits in section 3. At day 25, Kira asks the
council in #council whether anything is wrong. Probation ends by itself at day 30, unless a council poll (simple
majority) ended it earlier.
### 5.4 Exceptions
Some valuable people don't show up in the numbers, such as a designer for the website. The council can **waive the
tenure or the area floors** by a 2/3 poll that says why. The age rule and the clean record are never waived.
### 5.5 Inactivity
Kira checks every day. It warns the person in a DM and in #council, then posts the step-down in
#polls-and-decisions with neutral wording ("stepped down: inactive"), and removes the Discord role. The account
holder or an org owner removes GitHub access when the Team role ends.
| Role | What counts as activity | Warning | Steps down |
| --- | --- | --- | --- |
| Group lead | A counted action in the group's area, or messages in the group's channel | Day 21 | Day 30 |
| Horizon Team | A merge or a review | Day 45 | Day 60 |
| Moderator, Chief of Mods | A moderation action, or messages in the staff channels | Day 21 | Day 30 |
| Support | An answer in #horizon-support | Day 21 | Day 30 |
| Council | A vote in a poll. At 30 days with no vote, while at least 2 polls were held | Day 23 | Day 30, or 45 days with no counted activity at all |
- **Away.** Anyone can announce an absence with `/away` for up to 45 days, once every 6 months. Away days don't
count. A lead names someone from the group to cover for them.
- **Coming back.** Within 6 months: a simple-majority poll, with no new probation. After 6 months: the normal path.
### 5.6 Terms
- **Group leads** are confirmed every 6 months by a simple-majority poll. If someone else in the group passes the
floor and is nominated, both go on the same poll.
- **Council and Team** have no fixed term. They stay as long as they are active and keep the council's trust. Removal
is always possible (section 4.7).
## 6. Theme groups
### 6.1 The groups
| Group | Covers | GitHub | Discord |
| --- | --- | --- | --- |
| **Modding** | The mod API, native and content mods, mixins, the mod registry, `cargo horizon-mod` | The mod API lives in Ari's fork **arismoko/horizon**. Its owner runs that repo by their own rules; the council governs only what is Horizon's: horizon-rust/horizon-mods, horizon-mixins, horizon-native-mod-template, the registry. Label `mods` | The 🧩 MODDING category (#modding, #horizon-modding-team); roles 🧩 Modder (picked) and 🧩 Horizon Modding Team (the lead gives it) |
| **Shaders and rendering** | The renderer, shaders, shader packs, OptiFine and Polytone pack formats | Label `rendering`; `crates/horizon-client-render` (gfx), `*.wgsl`, `horizon-shaders`, `horizon-shader-api`, `horizon-optifine`, `horizon-polytone` | #shaders-rendering |
| **Performance** | Frame rate, memory, server tick, benchmarks | Label `perf`; `horizon-bench`, the benchmark tools | #performance |
| **Launcher** | The launcher: installs, updates, instances, its pages | Label `launcher`; `launcher/` | #launcher |
| **Server and SMP** | Dedicated server, hosting, the anticheat, multiplayer, and the official SMP | Labels `server`, `multiplayer`; `horizon-server*`, `horizon-anticheat`, `lan/`, `dedicated*`; horizon-rust/horizon-server | #server-smp (plus #join-server, #server-chat) |
| **Translations** | The game's and the launcher's languages | Label `roadmap:languages`; `launcher/lang/`, `crates/horizon-client/assets/horizon/lang/` | #translations and the 🌍 language channels |
| **Website** | playhorizon.dev | horizon-rust/horizon-site; label `website`. The site's design stays as it is unless the council decides otherwise | #website |
| **Vanilla accuracy** | Parity with vanilla 26.3: harnesses, oracles, the port ledger | Labels `parity`, `parity-regression`, `full-parity`; `tools/test_*_parity.py`, the Java oracles | #vanilla-accuracy |
| **UI** | The game's menus, HUD, settings, controller navigation, the design system | Labels `ui`, `accessibility`; `title/`, `settings_shell.rs`, `hud*`, `ui.rs`, `docs/design-system.md`, `horizon-theme`. The launcher's look is shared with Launcher | #ui |
A pull request can belong to two groups. Both leads can comment, and the Team merges as usual. If the leads disagree,
the council decides.
### 6.2 Leads per group
- **One lead per group.** A group with 8 or more active members may also have **one co-lead**, chosen the same way.
- A person leads **at most two groups**. Council members may lead a group, though a lead from outside the council is
preferred: it spreads the work.
### 6.3 What a lead decides alone
Inside their group's topic, and within this document, the contributing guide, the vanilla-first rule, the layers and
the design system:
- the group's priorities and the order of its roadmap items;
- which issues are `good first issue`, and triage of the group's issues;
- technical and design direction inside the area, when vanilla behaviour and the default look don't change;
- accepting or declining an idea or a proposal in the area. Any two active group members or one council member can
take that decision to the council;
- how the group organises itself (threads, meetings, the channel's rules on top of the server's);
- asking the Team for reviews, adding `needs-proof`, and nominating people.
### 6.4 What goes to the council
- the rules, money, accounts and secrets;
- who is on the council, the Team or the mods;
- anything that changes vanilla behaviour or turns a Horizon-only feature on by default;
- breaking a public promise: the mod API, server compatibility, save and file formats;
- disputes between groups;
- anything involving Mojang, Microsoft or the law;
- speaking for the whole project outside the group's topic.
### 6.5 A group without a lead
The work goes on. The Team reviews and merges, and Kira still routes the group's issues and questions to its
channel. Until a lead is chosen, any council member may take a lead's decision for that group and says so in the
channel. Kira pins a "lead wanted" note with the floor. **No lead is better than a lead who doesn't do the work:**
nobody is appointed just to fill the seat.
## 7. Moderation and appeals
- **The server rules** are in #rules. Moderators enforce them, the Chief of Mods leads the mods, and the council can
change the rules (2/3).
- **Every action has a mod case**: who acted, what they did, and why. Kira keeps the cases.
- **First appeal:** within 14 days, through #appeal (for a quarantine) or mod mail. A moderator who wasn't involved,
or the Chief of Mods, answers within 72 hours.
- **Second appeal:** for bans, timeouts over 24 hours and quarantines, a council poll in #council (simple majority).
Members who were involved don't vote. The answer is final for 90 days.
- **Not appealable here:** actions that Discord's rules or the law require. Those are reported where the law says.
- **Removing a moderator:** the Chief of Mods can suspend a moderator at once, and the council decides within 72 hours
(simple majority). The council removes the Chief of Mods the same way.
- **Staff are moderated like everyone.** When a council member is involved, the council decides without them.
## 8. Conflicts and hard cases
| Case | What happens |
| --- | --- |
| Two groups disagree | The leads talk first. If they still disagree, the council decides (simple majority) |
| A lead's decision is contested | Any two active group members or one council member can bring it to the council |
| A Team member merges against the rules | Any council member can revert the merge. The council can remove the member (2/3) |
| A council member breaks the rules | The council decides without them. Removal takes 2/3 (section 4.7) |
| Someone holds a role and is under 18 | Section 9 |
| The council can't reach quorum | Section 4.8 |
| The founder and the council disagree about one of his own accounts | Until a step of section 11 hands it over, the account stays his. He carries out the council's decisions on project matters, or he says publicly that he won't |
## 9. The age rule (18+)
**Who must be 18 or older:** members of the council and the Horizon Team, trusted reviewers, group leads and
co-leads, the Chief of Mods and the moderators, official server operators, and anyone with admin rights anywhere in
the project: Discord (Administrator, Manage Roles, Manage Channels, ban), GitHub (write, admin, organisation),
server panels, Railway, or Kira's admin side. The same goes for anyone who holds a project secret.
**Who doesn't have to be:** members, contributors (with any rank), group members and 🤝 Support. Contributors under
18 are welcome and valued. They just can't hold the roles above.
**How it's checked, without collecting ID:**
- **A declaration.** Before taking such a role, and once at the start for everyone who holds one, the person presses
a button in Kira: "I confirm that I am 18 or older. I understand that a false declaration removes all my roles of
trust." Kira keeps only the Discord account, the date and the wording's version. It never stores a birth date, an
ID, a photo, or any outside age-checking service's result.
- **No declaration, no role.** At the start, current role holders have 7 days to make it. Until they do, their role is
paused.
- **When there's doubt** (the person says they are younger, or there is a credible private report), one council
member asks the person privately. If the doubt remains, any council member pauses the role, and the council decides
within 72 hours in #council.
- **A false declaration** removes every role of trust at once, and the person can't hold one for 12 months.
- **Privacy.** Age is never discussed in public. A role that ends for this reason ends with the same neutral line as
any other step-down.
## 10. The agents
Horizon uses AI agents: the founder's orchestrator, which runs coding agents, and Kira, the Discord bot.
- **They obey council decisions only:** a passed poll recorded by Kira, a recorded emergency action of two council
members, and the standing rules in this document and the project's docs.
- **The only exception is the founder's own things:** his spending, his machines (they run CI and the vanilla tests),
his personal accounts and his time. On those, his word decides. This list should get shorter over time (section 11).
- **Role work isn't a decision.** A lead asking Kira to route issues, or a Team member asking an agent to prove a pull
request, is that person using their role. The agents help, within the role's powers.
- **They check the source.** An agent acts on a decision only when it finds it in Kira's decisions log
(section 13). A message saying "the council decided", or a screenshot, is not enough.
- **They post as agents.** Everything an agent posts says it comes from an agent, and states decisions as the
project's.
## 11. Accounts and the handover path
Today the founder holds every account. The aim is for him to hold fewer, in an order that never leaves the project
unable to act, and never puts an account that is tied to a person into anyone else's hands.
| Account | Today | Can it be shared? | Plan |
| --- | --- | --- | --- |
| **GitHub organisation** (horizon-rust) | One owner (the founder) | Yes: an organisation can have several owners | **Step 1:** a second owner from the council, chosen by a 2/3 poll in the council's first week, with two-factor authentication on. **Step 2:** a `council` team; CODEOWNERS reviews and the release tools' owner powers (`paused`, `/nightly remove`) move from the founder to that team |
| **Discord server** | Owned by the founder | Partly: a server has exactly one owner; powers can be given through roles | **Step 1:** the 🏛️ Council role gets moderation and ban permissions; Kira applies the outcomes of passed polls, so nobody needs Manage Roles; two-factor authentication becomes required for moderation. Ownership itself stays with the founder until a legal entity exists |
| **Kira** (bot application, Railway service) | The founder's Discord developer account and Railway | The bot: yes (a Discord developer team). Railway: adding members may need a paid plan | **Step 1:** a Discord developer team with two council members. Railway members once the council decides on money |
| **Official server** (hosting panel) | The founder's account | Yes: panel sub-users | **Step 1:** sub-users for the Server and SMP lead and for one council member |
| **Domains** (playhorizon.dev and the others) | The founder's registrar account | Not without moving it | Auto-renewal on, registered for several years ahead. They move only to a legal entity, if one is created |
| **The Microsoft app approved by Mojang** (sign-in) | The founder's Azure app registration; Mojang approved it after his application | **No.** The approval was given on his application. Moving the app or adding owners would change who Mojang deals with, and we have found no documented way to transfer an approval | **Never moves** without Mojang's written agreement. The only change considered: a legal entity, if one is created, and only if Mojang agrees |
| **Signing** (Apple developer certificate, release signing keys) | The founder's | The Apple certificate is in his legal name: no | The Apple certificate never moves. Release keys stay in GitHub's protected environments, and the council team approves their use from step 2 |
| **Money** (sponsorship, donations) | Personal accounts of the founder | Through a fiscal host | Section 12 |
| **The founder's machines, AI accounts and password vault** | His | No | His own things: they never move. The project should depend on them less over time |
**Order:** first the safety nets that remove a single point of failure (a second organisation owner, the bot's
developer team, panel sub-users). Then the day-to-day powers (CODEOWNERS and release tools to the council team). Last,
whatever needs a legal entity: domains, money, and with Mojang's agreement the sign-in app. Each step is a 2/3 poll,
and the founder carries it out.
## 12. Money
*Placeholder: the options (Open Collective and others) are being researched.*
- The council approves every expense by a **2/3 poll**.
- **Every income and every expense is public.**
- Nobody votes on money paid to themselves.
## 13. Transparency
- **#polls-and-decisions:** every poll and every result, posted by Kira, and the 24-hour discussions of nominations.
For polls that aren't about a person, each member's vote is shown once the poll closes.
- **The decisions log:** Kira keeps an append-only record of every poll, emergency action, appointment and
step-down, with its date and result. It is public and machine-readable, so the agents can check a decision there.
- **This document** lives in horizon-rust/horizon as `GOVERNANCE.md`. A short summary is pinned in #rules and in
#polls-and-decisions.
- **Who holds which role:** the people page of playhorizon.dev's hub shows the council, the Team, the group leads,
the Chief of Mods, the moderators and Support.
- **#council** is private for discussion and for polls about people. Everything the council decides is published.
## 14. Rules that are never voted on
These are Horizon's conditions for existing, not choices. No poll can change them:
- Playing needs a **Microsoft account that owns Minecraft: Java Edition**. There is no play without one.
- **No Mojang files** in Horizon's repos or downloads. Players' own copies stay on their own machines.
- **No gambling**, in any form, including with in-game currency.
- **Mojang's and Microsoft's terms and Usage Guidelines** apply to everything Horizon does.
- **The law**, and the terms of service of Discord and GitHub.
## 15. Changing this document
A 2/3 council poll, then a pull request that a Team member merges. The council reviews the floors' numbers
(section 5.2) once a year, or sooner if they turn out wrong.
+2
View File
@@ -135,6 +135,8 @@ python3 tools/pr_ready.py
It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public-tree check and the vanilla harnesses your change affects. Never commit game files, decompiled source or prepared resources.
How Horizon is run (the council, theme groups and their leads, the Horizon Team, moderation): [GOVERNANCE.md](GOVERNANCE.md).
## Project status
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the table below says what the harnesses compare.
+4
View File
@@ -15,6 +15,10 @@ pub const OFFICIAL_SERVER: &str = "play.playhorizon.dev";
/// `official_server_previous` in `launcher/links.json`).
pub const OFFICIAL_SERVER_PREVIOUS: &[&str] = &["horizon.bunchy7s.dev"];
/// Afri-Network, a partner server the game added once below the official server (#9299) until
/// the partnership ended: kept so the game can take back out the entry it added.
pub const WITHDRAWN_PARTNER_SERVER: &str = "afri-network.co.za";
/// [`OFFICIAL_SERVER`] with the port its SRV record gives, for askers that don't look SRV up
/// (the telemetry server's status checks).
pub const OFFICIAL_SERVER_STATUS: &str = "play.playhorizon.dev:24454";
+117 -3
View File
@@ -1,7 +1,6 @@
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
//! come from the program that rendered (the game knows its adapter, the launcher its window).
#[cfg(any(target_os = "macos", target_os = "linux"))]
use horizon_telemetry::system::command_output as output;
use serde::{Deserialize, Serialize};
@@ -69,12 +68,12 @@ impl Hardware {
os: std::env::consts::OS.to_owned(),
os_version: os_version(),
arch: std::env::consts::ARCH.to_owned(),
cpu: horizon_telemetry::system::cpu(),
cpu: cpu(),
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
cpu_cores: probes.cores,
cpu_base_mhz: probes.base,
cpu_boost_mhz: probes.boost,
ram_mb: horizon_telemetry::system::ram_bytes().map(|b| b >> 20),
ram_mb: ram_bytes().map(|b| b >> 20),
storage: None,
form: probes.form,
gpu: None,
@@ -110,16 +109,84 @@ fn probes() -> Probes {
}
}
/// The one-minute load average: above about the number of threads the machine was busy.
pub fn load_average() -> Option<f64> {
if cfg!(target_os = "macos") {
// "{ 1.23 1.10 1.00 }"
output("sysctl", &["-n", "vm.loadavg"])?
.trim_start_matches('{')
.split_whitespace()
.next()?
.parse()
.ok()
} else {
std::fs::read_to_string("/proc/loadavg")
.ok()?
.split_whitespace()
.next()?
.parse()
.ok()
}
}
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
pub fn power_source() -> Option<&'static str> {
horizon_telemetry::system::power()
}
/// Resident memory of a process, in bytes (macOS, Linux).
pub fn rss_bytes(pid: u32) -> Option<u64> {
if cfg!(target_os = "macos") {
return output("ps", &["-o", "rss=", "-p", &pid.to_string()])?
.parse::<u64>()
.ok()
.map(|kb| kb * 1024);
}
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
let line = status.lines().find(|l| l.starts_with("VmRSS:"))?;
line.split_whitespace()
.nth(1)?
.parse::<u64>()
.ok()
.map(|kb| kb * 1024)
}
/// CPU time a process has used, user and system, in seconds (macOS, Linux).
pub fn cpu_seconds(pid: u32) -> Option<f64> {
if cfg!(target_os = "macos") {
// "[[dd-]hh:]mm:ss.cc"
let time = output("ps", &["-o", "time=", "-p", &pid.to_string()])?;
let (rest, days) = match time.split_once('-') {
Some((d, rest)) => (rest.to_owned(), d.parse::<f64>().ok()?),
None => (time, 0.0),
};
let seconds = rest.split(':').try_fold(0.0, |acc, part| {
part.parse::<f64>().ok().map(|v| acc * 60.0 + v)
})?;
return Some(days * 86_400.0 + seconds);
}
// utime and stime, fields 14 and 15, in clock ticks (100 per second on Linux).
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let fields: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect();
let ticks: f64 = fields.get(11)?.parse::<f64>().ok()? + fields.get(12)?.parse::<f64>().ok()?;
Some(ticks / 100.0)
}
#[cfg(target_os = "macos")]
fn os_version() -> Option<String> {
output("sw_vers", &["-productVersion"])
}
#[cfg(target_os = "macos")]
fn cpu() -> Option<String> {
output("sysctl", &["-n", "machdep.cpu.brand_string"])
}
#[cfg(target_os = "macos")]
fn ram_bytes() -> Option<u64> {
output("sysctl", &["-n", "hw.memsize"])?.parse().ok()
}
#[cfg(target_os = "linux")]
fn os_version() -> Option<String> {
let release = std::fs::read_to_string("/etc/os-release").ok()?;
@@ -132,6 +199,22 @@ fn os_version() -> Option<String> {
})
}
#[cfg(target_os = "linux")]
fn cpu() -> Option<String> {
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
horizon_telemetry::system::linux_cpu_name(&info)
}
#[cfg(target_os = "linux")]
fn ram_bytes() -> Option<u64> {
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
let kb = key_value(&info, "MemTotal", ':')?
.trim_end_matches("kB")
.trim();
kb.parse::<u64>().ok().map(|kb| kb * 1024)
}
#[cfg(target_os = "linux")]
fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
text.lines().find_map(|line| {
@@ -145,11 +228,39 @@ fn os_version() -> Option<String> {
horizon_telemetry::system::windows_version()
}
#[cfg(target_os = "windows")]
fn cpu() -> Option<String> {
horizon_telemetry::system::windows_cpu()
}
#[cfg(target_os = "windows")]
fn ram_bytes() -> Option<u64> {
let out = output(
"powershell",
&[
"-NoProfile",
"-Command",
"(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
],
)?;
out.parse().ok()
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn os_version() -> Option<String> {
None
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn cpu() -> Option<String> {
None
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn ram_bytes() -> Option<u64> {
None
}
#[cfg(test)]
mod tests {
use super::*;
@@ -163,6 +274,9 @@ mod tests {
assert!(hw.cpu.is_some(), "{hw:?}");
assert!(hw.ram_mb.unwrap() > 256, "{hw:?}");
assert!(hw.os_version.is_some(), "{hw:?}");
assert!(load_average().is_some());
assert!(rss_bytes(std::process::id()).unwrap() > 1 << 20);
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
}
}
+1 -3
View File
@@ -13,9 +13,7 @@ use serde::{Deserialize, Serialize};
mod hardware;
pub use hardware::{power_source, Display, Gpu, Hardware};
/// The process probes, from the platform (`horizon-system`).
pub use horizon_telemetry::system::{cpu_seconds, load_average, rss_bytes};
pub use hardware::{cpu_seconds, load_average, power_source, rss_bytes, Display, Gpu, Hardware};
pub const FORMAT: &str = "horizon-benchmark-report";
pub const VERSION: u32 = 1;
+4 -2
View File
@@ -9,11 +9,13 @@ 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 = []
@@ -98,6 +98,7 @@ actions! {
Emote, "key.emote", Some(KeyCode::KeyB);
HotbarPrevious, "pad.hotbarPrevious", None;
HotbarNext, "pad.hotbarNext", None;
Radial, "pad.radial", None;
}
impl Action {
+211 -18
View File
@@ -253,6 +253,17 @@ impl Activity {
/// Horizon's pad-only actions (no vanilla key mapping).
pub const HOTBAR_PREVIOUS: &str = "pad.hotbarPrevious";
pub const HOTBAR_NEXT: &str = "pad.hotbarNext";
/// Held, opens the radial menu (`radial.rs`).
pub const RADIAL: &str = "pad.radial";
mod radial_file;
pub use radial_file::{RadialMode, RadialStick, opener_refused};
/// The `bindLayout` the settings file is written in. 1: the defaults of the binds list as they
/// are; 2: the radial menu's opener resolved against the player's own buttons.
const BIND_LAYOUT: u32 = 2;
/// The layout from which the file, not the default, says where the opener is.
const OPENER_LAYOUT: u32 = 2;
/// Bindable actions and their default buttons: vanilla key mappings (the
/// game acts as their default key) and Horizon's pad-only actions. Start is
@@ -271,6 +282,7 @@ pub const ACTIONS: &[(&str, Option<Button>)] = &[
("key.togglePerspective", Some(Button::Select)),
("key.pickItem", Some(Button::DPadLeft)),
("key.chat", Some(Button::DPadUp)),
(RADIAL, Some(Button::DPadRight)),
("key.advancements", None),
("key.playerlist", None),
("key.command", None),
@@ -303,11 +315,14 @@ pub enum Route {
HotbarStep {
next: bool,
},
Radial,
/// Held, or flipped when the pad's toggle sneak is on.
Sneak,
Screenshot,
SmoothCamera,
Fullscreen,
/// F3's debug screen, shown or hidden as F3 alone does.
DebugOverlay,
/// Does nothing by itself: what reads it asks whether the button is held
/// (`Minecraft.handleKeybinds`' toolbar keys, held with a hotbar key).
Held,
@@ -320,10 +335,12 @@ pub fn route(action: &str) -> Option<Route> {
match action {
HOTBAR_PREVIOUS => Some(Route::HotbarStep { next: false }),
HOTBAR_NEXT => Some(Route::HotbarStep { next: true }),
RADIAL => Some(Route::Radial),
"key.sneak" => Some(Route::Sneak),
"key.screenshot" => Some(Route::Screenshot),
"key.smoothCamera" => Some(Route::SmoothCamera),
"key.fullscreen" => Some(Route::Fullscreen),
"key.debug.overlay" => Some(Route::DebugOverlay),
"key.saveToolbarActivator" | "key.loadToolbarActivator" => Some(Route::Held),
_ => {
let default = crate::keybinds::KeyBinds::default_of(action);
@@ -334,12 +351,16 @@ pub fn route(action: &str) -> Option<Route> {
}
}
/// The radial menu's opener, as the binds list and the gameplay guide name it.
pub const RADIAL_CAPTION: &str = "horizon.pad.action.radial";
/// An action's name in the binds list: vanilla's translation key, or
/// Horizon's own English for the pad-only actions.
pub fn action_caption(action: &str) -> &str {
match action {
HOTBAR_PREVIOUS => "Hotbar: Previous Slot",
HOTBAR_NEXT => "Hotbar: Next Slot",
RADIAL => RADIAL_CAPTION,
other => other,
}
}
@@ -654,6 +675,21 @@ pub struct PadSettings {
pub repeat_interval: f32,
/// Action name -> button, `None` for unbound; missing means the default.
bindings: BTreeMap<&'static str, Option<Button>>,
/// The radial menu's pages.
pub radial: crate::radial::Pages,
/// Whether the radial menu's opener is held or pressed once.
pub radial_mode: RadialMode,
/// The stick that selects in it.
pub radial_stick: RadialStick,
/// Lines this game doesn't read (a newer Horizon's settings, an action it doesn't have, a
/// button it doesn't know), kept as they were so a save here loses nothing.
extra: Vec<(String, String)>,
/// The newest `bindLayout` the file has been written in: this game's, or a newer Horizon's,
/// kept so that one doesn't resolve its file again as older than it is.
bind_layout: u32,
/// The opener last used with each stick (`RadialStick::ALL` order), so stepping the Selects
/// With row and back gives each stick's own opener again.
openers: radial_file::StickOpeners,
}
impl Default for PadSettings {
fn default() -> Self {
@@ -680,6 +716,12 @@ impl Default for PadSettings {
repeat_delay: 0.35,
repeat_interval: 0.09,
bindings: BTreeMap::new(),
radial: Default::default(),
radial_mode: RadialMode::Hold,
radial_stick: RadialStick::Right,
extra: Vec::new(),
bind_layout: BIND_LAYOUT,
openers: radial_file::StickOpeners::default(),
}
}
}
@@ -706,6 +748,7 @@ impl PadSettings {
}
pub fn parse(text: &str) -> Self {
let mut s = Self::default();
let mut saved_opener = None;
for line in text.lines() {
let Some((key, value)) = line.split_once(':') else {
continue;
@@ -754,22 +797,85 @@ impl PadSettings {
"scrollSpeed" => s.scroll_speed = bounded(s.scroll_speed, 1.0, 36.0),
"repeatDelay" => s.repeat_delay = bounded(s.repeat_delay, 0.1, 1.0),
"repeatInterval" => s.repeat_interval = bounded(s.repeat_interval, 0.03, 0.3),
"radialMode" => s.radial_mode = RadialMode::parse(value),
"radialStick" => s.radial_stick = RadialStick::parse(value),
// Read after the loop, from the whole text.
"bindLayout" => {}
// The radial menu's pages (`radial::Pages::parse`).
other if other.starts_with("radial_") || other == crate::radial::LAYOUT_KEY => {}
other => {
if let Some(action) = other.strip_prefix("bind_") {
let button = if value == "none" {
Some(None)
} else {
Button::parse(value).map(Some)
};
if let Some(button) = button {
s.bind(action, button);
}
let known = other
.strip_prefix("bind_")
.filter(|action| ACTIONS.iter().any(|a| a.0 == *action));
let button = if value == "none" {
Some(None)
} else {
Button::parse(value).map(Some)
};
match (known, button) {
// The opener once the selecting stick is known.
(Some(RADIAL), Some(button)) => saved_opener = Some(button),
(Some(action), Some(button)) => s.bind(action, button),
_ => s.extra.push((other.to_owned(), value.to_owned())),
}
}
}
}
// A file from before these defaults: its player's buttons stay where they were.
if !text.is_empty() && !text.lines().any(|line| line.starts_with("bindLayout:")) {
// An opener on a button it can't be with this stick reads as never saved.
let refused_opener = match saved_opener {
Some(Some(b)) if opener_refused(s.radial_stick, b) => true,
Some(button) => {
s.bind(RADIAL, button);
false
}
None => false,
};
if let Some(pages) = crate::radial::Pages::parse(
text.lines()
.filter_map(|line| line.split_once(':'))
.map(|(key, value)| (key.trim(), value.trim())),
) {
s.radial = pages;
}
let lines = || text.lines().map(str::trim);
let saved = |action: &str| {
let prefix = format!("bind_{action}:");
lines().any(|line| line.starts_with(&prefix))
};
// The layout the file was written in; none in a file from before these defaults.
let layout = lines()
.find_map(|line| line.strip_prefix("bindLayout:"))
.map(|value| value.trim().parse::<u32>().unwrap_or(0));
s.bind_layout = layout.map_or(BIND_LAYOUT, |layout| layout.max(BIND_LAYOUT));
// Once, for a file from before the radial menu's opener, or one with the opener on a
// button it can no longer be: a button the player bound by hand stays theirs, and the
// opener's default doesn't take it. From then on the file says where the opener is, and
// a conflict made since stays shown in the binds list.
let before_opener = layout.is_none_or(|layout| layout < OPENER_LAYOUT);
if !text.is_empty() && (refused_opener || (before_opener && !saved(RADIAL))) {
let default_opener = ACTIONS
.iter()
.find(|(action, _)| *action == RADIAL)
.and_then(|(_, button)| *button);
let taken = s.bindings.iter().any(|(action, button)| {
*action != RADIAL && button.is_some() && *button == default_opener
});
if taken {
s.bindings.insert(RADIAL, None);
}
}
// The default too can be under the selecting thumb (D-pad Right, the left stick
// selecting): no button opens the menu until the player picks one.
if s.binding(RADIAL)
.is_some_and(|b| opener_refused(s.radial_stick, b))
{
s.bindings.insert(RADIAL, None);
}
// A file from before these defaults: its player's buttons stay where they were, and
// nothing they saved shares a button with the radial menu's opener. They can bind one
// in the binds list.
if !text.is_empty() && layout.is_none() {
s.bindings.entry(RADIAL).or_insert(None);
for (action, button) in [
("key.drop", Button::East),
("key.togglePerspective", Button::DPadUp),
@@ -778,10 +884,7 @@ impl PadSettings {
("key.advancements", Button::DPadDown),
] {
// A button saved by hand stays, even one that's now this action's default.
if !text
.lines()
.any(|line| line.starts_with(&format!("bind_{action}:")))
{
if !saved(action) {
s.bindings.entry(action).or_insert(Some(button));
}
}
@@ -822,7 +925,25 @@ impl PadSettings {
"playInBackground:{}\n",
bool_text(self.play_in_background)
));
text.push_str("bindLayout:1\n");
text.push_str(&format!(
"radialMode:{}\nradialStick:{}\n",
self.radial_mode.name(),
self.radial_stick.name()
));
text.push_str(&format!("bindLayout:{}\n", self.bind_layout));
for (key, value) in &self.extra {
// A button this game didn't know, for an action the player has bound here since.
let rebound = key
.strip_prefix("bind_")
.is_some_and(|action| self.bindings.contains_key(action));
if !rebound {
text.push_str(&format!("{key}:{value}\n"));
}
}
// The default pages aren't written: they can improve for a player who never changed them.
if self.radial != crate::radial::Pages::default() {
text.push_str(&self.radial.serialize());
}
text
}
pub fn default_of(action: &str) -> Option<Button> {
@@ -835,11 +956,15 @@ impl PadSettings {
None => Self::default_of(action),
}
}
/// Bind an action (`None` unbinds); unknown actions are ignored.
/// Put `action` on `button`, or unbind it. An unknown action, or a button it can't take
/// (`can_bind`), changes nothing.
pub fn bind(&mut self, action: &str, button: Option<Button>) {
let Some((name, default)) = ACTIONS.iter().find(|a| a.0 == action) else {
return;
};
if button.is_some_and(|b| !self.can_bind(action, b)) {
return;
}
if button == *default {
self.bindings.remove(name);
} else {
@@ -1440,6 +1565,14 @@ pub enum Rumble {
radius: f32,
},
Totem,
/// The radial menu's stick reached a slot that would run: a tick.
RadialSlot,
/// The radial menu's page turned: a shorter tick.
RadialPage,
/// The page turn went round an end: a second tick after the first.
RadialWrap,
/// A release ran nothing after something had been selected: a soft buzz.
RadialNothing,
}
impl From<crate::input_source::Feedback> for Rumble {
fn from(event: crate::input_source::Feedback) -> Self {
@@ -1459,6 +1592,7 @@ pub struct Pulse {
pub weak: f32,
pub millis: u32,
}
use crate::radial::{RADIAL_NOTHING, RADIAL_PAGE_TICK, RADIAL_TICK};
impl Rumble {
pub fn name(self) -> &'static str {
match self {
@@ -1466,6 +1600,10 @@ impl Rumble {
Rumble::BlockBroken => "block",
Rumble::Explosion { .. } => "explosion",
Rumble::Totem => "totem",
Rumble::RadialSlot => "radial",
Rumble::RadialPage => "radial_page",
Rumble::RadialWrap => "radial_wrap",
Rumble::RadialNothing => "radial_nothing",
}
}
/// The pulse, or `None` when it is too faint to feel.
@@ -1492,6 +1630,9 @@ impl Rumble {
millis: 350,
})
}
Rumble::RadialSlot => Some(RADIAL_TICK),
Rumble::RadialPage | Rumble::RadialWrap => Some(RADIAL_PAGE_TICK),
Rumble::RadialNothing => Some(RADIAL_NOTHING),
Rumble::Totem => Some(Pulse {
strong: 0.7,
weak: 0.7,
@@ -1513,6 +1654,10 @@ impl Rumble {
};
match self {
Rumble::BlockBroken => vec![Pulse { millis: 35, ..p }],
Rumble::RadialSlot
| Rumble::RadialPage
| Rumble::RadialWrap
| Rumble::RadialNothing => vec![p],
Rumble::Hurt(_) => {
let tail = p.millis.saturating_sub(50) / 2;
vec![step(1.0, 50), step(0.55, tail), step(0.25, tail)]
@@ -1873,7 +2018,55 @@ bind_key.hotbar.2:north
let odd = PadSettings::parse(
"lookSensitivity:NaN\nbind_key.nope:south\nbind_key.jump:x9\nbindLayout:1\n",
);
assert_eq!(odd, PadSettings::default());
assert_eq!(
odd.look_sensitivity,
PadSettings::default().look_sensitivity
);
assert_eq!(odd.binding("key.jump"), Some(Button::South));
assert_eq!(odd.actions_of(Button::South), ["key.jump"]);
}
#[test]
fn lines_this_game_doesnt_read_survive_a_save() {
// A newer Horizon's setting, an action this game doesn't have, a button it doesn't know.
let text =
"bindLayout:2\nhapticStrength:0.4\nbind_horizon:dash:south\nbind_key.jump:paddle1\n";
let s = PadSettings::parse(text);
assert_eq!(
s.binding("key.jump"),
Some(Button::South),
"the default meanwhile"
);
let saved = s.serialize();
for line in [
"hapticStrength:0.4\n",
"bind_horizon:dash:south\n",
"bind_key.jump:paddle1\n",
] {
assert!(saved.contains(line), "{line} lost from\n{saved}");
}
assert_eq!(PadSettings::parse(&saved), s);
// Bound here since, the action's own line is the one written.
let mut s = s;
s.bind("key.jump", Some(Button::West));
let saved = s.serialize();
assert!(!saved.contains("paddle1"), "{saved}");
assert_eq!(
PadSettings::parse(&saved).binding("key.jump"),
Some(Button::West)
);
}
#[test]
fn a_newer_bind_layout_is_kept() {
let newer = BIND_LAYOUT + 1;
let s = PadSettings::parse(&format!("bindLayout:{newer}\nvibration:false\n"));
assert!(s.serialize().contains(&format!("bindLayout:{newer}\n")));
let s = PadSettings::parse("bindLayout:1\nvibration:false\n");
assert!(
s.serialize()
.contains(&format!("bindLayout:{BIND_LAYOUT}\n"))
);
}
#[test]
@@ -0,0 +1,354 @@
//! The radial menu's settings in the controller file (`horizon-controller.txt`): how its opener
//! holds it open, the stick that selects, which buttons the opener may be with that stick, and the
//! opener each stick had this session. `PadSettings::parse` and `serialize` read and write their
//! lines (`radialMode`, `radialStick`, `bind_pad.radial`).
use super::{Button, PadSettings, RADIAL};
/// The buttons under the right thumb, which steers the right stick.
const RIGHT_THUMB: [Button; 4] = [
Button::South,
Button::West,
Button::North,
Button::RightStick,
];
/// The buttons under the left thumb, which steers the left stick.
const LEFT_THUMB: [Button; 5] = [
Button::DPadUp,
Button::DPadDown,
Button::DPadLeft,
Button::DPadRight,
Button::LeftStick,
];
/// Whether the radial menu's opener can't be `button` while `stick` selects. One rule: the opener
/// is never under a thumb that selects (holding it and steering are two jobs for one thumb), nor
/// B, which cancels the open menu. So with the right stick the face buttons and R3 go, with the
/// left the D-pad and L3, with either both; a bumper, a trigger and View are always free, and
/// with a bumper for the opener the other bumper turns the pages.
pub fn opener_refused(stick: RadialStick, button: Button) -> bool {
button == Button::East
|| (stick != RadialStick::Left && RIGHT_THUMB.contains(&button))
|| (stick != RadialStick::Right && LEFT_THUMB.contains(&button))
}
/// How the radial menu's opener holds it open.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum RadialMode {
/// Held: letting go runs what is selected.
#[default]
Hold,
/// A press opens it; A or the opener again runs what is selected, B closes it.
Toggle,
}
impl RadialMode {
pub const ALL: [Self; 2] = [Self::Hold, Self::Toggle];
pub fn name(self) -> &'static str {
match self {
Self::Hold => "hold",
Self::Toggle => "toggle",
}
}
pub fn parse(value: &str) -> Self {
Self::ALL
.into_iter()
.find(|mode| mode.name() == value)
.unwrap_or_default()
}
}
/// The stick that selects in the radial menu.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum RadialStick {
#[default]
Right,
Left,
/// The last stick to lean past the select tilt.
Either,
}
impl RadialStick {
pub const ALL: [Self; 3] = [Self::Right, Self::Left, Self::Either];
pub fn name(self) -> &'static str {
match self {
Self::Right => "right",
Self::Left => "left",
Self::Either => "either",
}
}
pub fn parse(value: &str) -> Self {
Self::ALL
.into_iter()
.find(|stick| stick.name() == value)
.unwrap_or_default()
}
}
/// The opener each stick had, for this session: what the player stepped through, not a setting,
/// so it is neither saved nor compared.
#[derive(Clone, Copy, Debug, Default)]
pub(super) struct StickOpeners([Option<Button>; 3]);
impl PartialEq for StickOpeners {
fn eq(&self, _: &Self) -> bool {
true
}
}
impl StickOpeners {
fn slot(&mut self, stick: RadialStick) -> &mut Option<Button> {
let at = RadialStick::ALL
.iter()
.position(|s| *s == stick)
.unwrap_or(0);
&mut self.0[at]
}
}
impl PadSettings {
/// Whether `button` can be bound to `action`: anything but the radial menu's opener under
/// a thumb that selects, or on B (`opener_refused`).
pub fn can_bind(&self, action: &str, button: Button) -> bool {
action != RADIAL || !opener_refused(self.radial_stick, button)
}
/// The stick the radial menu selects with. An opener under the thumb that now selects is
/// unbound, and its row in the radial page says so. An unbound opener then takes the one this
/// stick had before in this session, or else its default where nothing else has it (D-pad
/// Right, back on the right stick).
pub fn set_radial_stick(&mut self, stick: RadialStick) {
let was = self.radial_stick;
*self.openers.slot(was) = self.binding(RADIAL);
self.radial_stick = stick;
if self
.binding(RADIAL)
.is_some_and(|b| opener_refused(stick, b))
{
self.bindings.insert(RADIAL, None);
}
if self.binding(RADIAL).is_some() {
return;
}
// The player's own, shared or not: they put it there.
if let Some(own) = self
.openers
.slot(stick)
.filter(|b| !opener_refused(stick, *b))
{
self.bind(RADIAL, Some(own));
return;
}
let default = Self::default_of(RADIAL);
let free =
default.is_some_and(|d| !opener_refused(stick, d) && self.actions_of(d).is_empty());
if free {
self.bindings.remove(RADIAL);
}
}
}
#[cfg(test)]
mod tests {
use super::super::Rumble;
use super::*;
#[test]
fn a_new_player_holds_d_pad_right_for_the_radial_menu() {
for s in [PadSettings::default(), PadSettings::parse("")] {
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
assert!(!s.conflicts(RADIAL));
}
let s = PadSettings::parse(&PadSettings::default().serialize());
assert_eq!(s, PadSettings::default());
}
#[test]
fn a_button_bound_by_hand_stays_with_it_over_the_openers_default() {
let s = PadSettings::parse("bindLayout:1\nbind_key.playerlist:right\n");
assert_eq!(s.binding("key.playerlist"), Some(Button::DPadRight));
assert_eq!(s.binding(RADIAL), None);
assert!(!s.conflicts("key.playerlist"));
assert_eq!(PadSettings::parse(&s.serialize()), s);
}
#[test]
fn a_saved_opener_stays_where_it_was_saved() {
let s =
PadSettings::parse("bindLayout:1\nbind_pad.radial:right\nbind_key.playerlist:right\n");
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
}
#[test]
fn a_conflict_made_since_the_migration_stays_after_a_restart() {
// Bound in the binds list onto the opener's button: both stay, and the list marks it.
let mut s = PadSettings::default();
s.bind("key.playerlist", Some(Button::DPadRight));
let text = s.serialize();
assert!(text.contains("bindLayout:2"));
let read = PadSettings::parse(&text);
assert_eq!(read.binding(RADIAL), Some(Button::DPadRight));
assert!(read.conflicts(RADIAL));
assert_eq!(read, s);
// The same, hand-written with spaces around the lines.
let read = PadSettings::parse(" bindLayout:2\n bind_key.playerlist:right\n");
assert_eq!(read.binding(RADIAL), Some(Button::DPadRight));
}
#[test]
fn a_layout_one_file_resolves_the_opener_once() {
let s = PadSettings::parse("bind_key.chat:up\n bindLayout:1\nbind_key.playerlist:right\n");
assert_eq!(s.binding(RADIAL), None, "the player's own button wins");
assert_eq!(s.binding("key.playerlist"), Some(Button::DPadRight));
assert_eq!(s.binding("key.chat"), Some(Button::DPadUp));
let text = s.serialize();
assert!(text.contains("bindLayout:2\n") && text.contains("bind_pad.radial:none\n"));
assert_eq!(PadSettings::parse(&text), s);
// Without a conflict the opener keeps its default.
let s = PadSettings::parse("bindLayout:1\nvibration:false\n");
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
// A line with leading spaces is still a saved opener.
let s =
PadSettings::parse("bindLayout:1\n bind_pad.radial:right\nbind_key.playerlist:right\n");
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
}
#[test]
fn the_opener_never_goes_under_the_selecting_thumb_nor_on_b() {
use Button as B;
let face = [B::South, B::West, B::North, B::RightStick];
let dpad = [
B::DPadUp,
B::DPadDown,
B::DPadLeft,
B::DPadRight,
B::LeftStick,
];
for stick in RadialStick::ALL {
let mut s = PadSettings::default();
s.set_radial_stick(stick);
for button in Button::ALL {
let refused = button == B::East
|| (stick != RadialStick::Left && face.contains(&button))
|| (stick != RadialStick::Right && dpad.contains(&button));
assert_eq!(!s.can_bind(RADIAL, button), refused, "{stick:?} {button:?}");
// Other actions still take any of them.
assert!(s.can_bind("key.chat", button));
}
// A bumper is always free, and stays one through the file.
s.bind(RADIAL, Some(B::LeftBumper));
assert_eq!(s.binding(RADIAL), Some(B::LeftBumper), "{stick:?}");
assert_eq!(PadSettings::parse(&s.serialize()), s, "{stick:?}");
}
// A file with a refused opener reads as never saved: the default, unless taken.
for button in [B::South, B::East, B::West, B::North, B::RightStick] {
let text = format!("bindLayout:2\nbind_pad.radial:{}\n", button.name());
let read = PadSettings::parse(&text);
assert_eq!(read.binding(RADIAL), Some(B::DPadRight), "{button:?}");
let taken = format!("{text}bind_key.playerlist:right\n");
assert_eq!(
PadSettings::parse(&taken).binding(RADIAL),
None,
"{button:?}"
);
}
// Read with the stick it was saved for, wherever the stick's line is.
let left = PadSettings::parse("bind_pad.radial:north\nbindLayout:2\nradialStick:left\n");
assert_eq!(left.binding(RADIAL), Some(B::North));
}
#[test]
fn a_stick_that_takes_the_openers_thumb_unbinds_it_and_the_right_gives_it_back() {
let mut s = PadSettings::default();
s.set_radial_stick(RadialStick::Left);
assert_eq!(
s.binding(RADIAL),
None,
"D-pad Right is under the left thumb"
);
assert_eq!(PadSettings::parse(&s.serialize()).binding(RADIAL), None);
s.set_radial_stick(RadialStick::Right);
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
// An opener the player picked that both sticks allow stays.
s.bind(RADIAL, Some(Button::RightBumper));
s.set_radial_stick(RadialStick::Either);
assert_eq!(s.binding(RADIAL), Some(Button::RightBumper));
}
#[test]
fn stepping_the_stick_and_back_gives_each_stick_its_own_opener() {
// Left with Y, back to the right stick: Y is under the right thumb, D-pad Right opens.
let mut s = PadSettings::default();
s.set_radial_stick(RadialStick::Left);
s.bind(RADIAL, Some(Button::North));
s.set_radial_stick(RadialStick::Right);
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
// And Left again has its Y back, shared with Inventory as the player left it.
s.set_radial_stick(RadialStick::Left);
assert_eq!(s.binding(RADIAL), Some(Button::North));
// Right with D-pad Left: the left stick refuses it, and the right gets it back.
let mut s = PadSettings::default();
s.bind(RADIAL, Some(Button::DPadLeft));
s.set_radial_stick(RadialStick::Left);
assert_eq!(s.binding(RADIAL), None);
s.set_radial_stick(RadialStick::Either);
assert_eq!(s.binding(RADIAL), None);
s.set_radial_stick(RadialStick::Right);
assert_eq!(s.binding(RADIAL), Some(Button::DPadLeft));
}
#[test]
fn a_file_from_before_the_radial_menu_binds_no_opener() {
let s = PadSettings::parse("vibration:false\nbind_key.chat:up\n");
assert_eq!(s.binding(RADIAL), None);
assert_eq!(PadSettings::parse(&s.serialize()), s);
}
#[test]
fn the_radial_menus_ticks_are_felt_and_tell_apart() {
let tick = Rumble::RadialSlot.pulse().unwrap();
assert!((40..=50).contains(&tick.millis), "{tick:?}");
assert!((0.25..=0.35).contains(&tick.weak), "{tick:?}");
let page = Rumble::RadialPage.pulse().unwrap();
assert!(page.millis < tick.millis, "a page turn is the shorter tick");
assert_eq!(Rumble::RadialWrap.pulse(), Some(page));
let nothing = Rumble::RadialNothing.pulse().unwrap();
assert!(
nothing.strong > 0.0 && nothing.millis > tick.millis,
"{nothing:?}"
);
assert!(
nothing.strong.max(nothing.weak) < 0.3,
"a soft buzz: {nothing:?}"
);
}
#[test]
fn the_radial_menus_mode_and_stick_survive_the_settings_file() {
let s = PadSettings::default();
assert_eq!(
(s.radial_mode, s.radial_stick),
(RadialMode::Hold, RadialStick::Right)
);
for mode in RadialMode::ALL {
for stick in RadialStick::ALL {
let mut s = PadSettings {
radial_mode: mode,
..PadSettings::default()
};
s.set_radial_stick(stick);
assert_eq!(PadSettings::parse(&s.serialize()), s);
}
}
let read = PadSettings::parse("radialMode:sideways\nradialStick:up\n");
assert_eq!(
(read.radial_mode, read.radial_stick),
(RadialMode::Hold, RadialStick::Right)
);
}
#[test]
fn radial_pages_survive_the_settings_file() {
let mut s = PadSettings::default();
let pages = "radial_0_label:Mine\nradial_0_slots:key.chat,,,,,,,\n";
s.radial =
crate::radial::Pages::parse(pages.lines().filter_map(|l| l.split_once(':'))).unwrap();
let read = PadSettings::parse(&s.serialize());
assert_eq!(read.radial, s.radial);
assert_ne!(read.radial, crate::radial::Pages::default());
}
}
@@ -141,8 +141,13 @@ impl Guide {
pub fn at_detail(self, detail: GuideDetail) -> Self {
match detail {
GuideDetail::Full => self,
// Nothing else in play names the radial menu.
GuideDetail::Basic => Guide {
left: Vec::new(),
left: self
.left
.into_iter()
.filter(|row| row.caption == crate::gamepad::RADIAL_CAPTION)
.collect(),
right: self.right,
},
GuideDetail::Off => Guide {
@@ -178,6 +183,8 @@ pub fn resolve(facts: &Facts, settings: &PadSettings) -> Guide {
"key.swapOffhand",
(player && any_item).then_some("horizon.controller.guide.swap"),
),
// Nothing else in play says the menu is there; it opens for a spectator too.
row(crate::gamepad::RADIAL, Some(crate::gamepad::RADIAL_CAPTION)),
]
.into_iter()
.flatten()
@@ -566,6 +573,7 @@ mod tests {
(Button::North, "key.inventory"),
(Button::DPadDown, "horizon.controller.guide.drop"),
(Button::West, "horizon.controller.guide.swap"),
(Button::DPadRight, crate::gamepad::RADIAL_CAPTION),
]
);
}
@@ -736,12 +744,12 @@ mod tests {
gliding: true,
..Facts::default()
}),
["key.inventory"]
["key.inventory", crate::gamepad::RADIAL_CAPTION]
);
}
#[test]
fn a_spectator_only_flies_opens_spectates_and_has_its_menu() {
fn a_spectator_only_flies_opens_spectates_and_has_its_menus() {
let facts = Facts {
spectator: true,
flying: true,
@@ -765,7 +773,8 @@ mod tests {
captions(&guide.left),
[
"horizon.controller.guide.fly_up",
"horizon.controller.guide.fly_down"
"horizon.controller.guide.fly_down",
crate::gamepad::RADIAL_CAPTION
]
);
}
+1
View File
@@ -16,5 +16,6 @@ pub mod keybinds;
pub mod keymapping;
pub mod modifiers;
pub mod mouse;
pub mod radial;
pub mod snap;
pub mod window;
File diff suppressed because it is too large. Load diff
+32 -10
View File
@@ -638,10 +638,30 @@ pub fn fullscreen_request(
}
}
/// 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.
/// 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.
pub fn icon() -> Option<winit::window::Icon> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
decode_icon(icon_png()?)
}
fn decode_icon(png: &[u8]) -> Option<winit::window::Icon> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(png));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().ok()?;
@@ -654,14 +674,15 @@ pub fn icon() -> Option<winit::window::Icon> {
winit::window::Icon::from_rgba(bytes, info.width, info.height).ok()
}
/// 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 {
/// 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"))]
let icon = icon();
#[cfg(target_os = "windows")]
let icon = Some(icon.expect("Horizon's icon must be available on Windows"));
let icon =
icon_png().map(|png| decode_icon(png).expect("the window icon must decode on Windows"));
let attributes = attributes.with_window_icon(icon.clone());
#[cfg(target_os = "windows")]
@@ -739,7 +760,8 @@ mod tests {
}
#[test]
fn the_window_icon_decodes() {
assert!(icon().is_some());
assert!(decode_icon(horizon_theme::ICON_PNG).is_some());
assert!(decode_icon(b"not a png").is_none());
}
#[test]
+3 -7
View File
@@ -70,7 +70,6 @@ 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" }
@@ -109,17 +108,12 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
default = ["audio", "controllers", "horizon-extras"]
default = ["audio", "controllers"]
# 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 = []
# 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,6 +125,8 @@ 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).
+2 -1
View File
@@ -29,7 +29,6 @@ horizon-protocol = { path = "../horizon-protocol" }
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"
@@ -85,5 +84,7 @@ horizon-extras = []
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"] }
@@ -386,6 +386,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
path: path.clone(),
size: None,
scaled: false,
background: false,
});
for _ in 0..32 {
draw(&mut renderer)?;
@@ -207,6 +207,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
path: out.join("cpu.png"),
size: None,
scaled: false,
background: false,
});
renderer.render(&frame)?;
let expected = read(&out.join("cpu.png"));
@@ -251,6 +252,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
path: out.join("gpu.png"),
size: None,
scaled: false,
background: false,
});
renderer.render(&frame)?;
assert_eq!(
@@ -167,6 +167,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
path: path.clone(),
size: None,
scaled: false,
background: false,
});
// twice: the first frame of a new warp builds it
renderer.render(&frame).unwrap();
@@ -336,6 +337,7 @@ fn bench(renderer: &mut Renderer, out: &Path) -> Result<(), Box<dyn std::error::
path: out.join("bench.png"),
size: None,
scaled: false,
background: false,
});
renderer.render(&frame).unwrap();
renderer.render(&frame).unwrap();
@@ -57,6 +57,8 @@ pub enum Item {
/// Wait for a button to bind to this action.
Bind(&'static str),
ResetBinds,
/// Opens the radial menu's pages (`radial_editor`).
Radial,
Info(Info),
}
@@ -106,6 +108,15 @@ fn choice_description(opt: Opt, pad: &PadSettings) -> Option<&'static str> {
}
gamepad::Lightbar::Off => "A DualSense keeps its own light.",
},
Opt::PadRadialMode => match pad.radial_mode {
gamepad::RadialMode::Hold => "horizon.options.radial_mode.hold.about",
gamepad::RadialMode::Toggle => "horizon.options.radial_mode.toggle.about",
},
Opt::PadRadialStick => match pad.radial_stick {
gamepad::RadialStick::Right => "horizon.options.radial_stick.right.about",
gamepad::RadialStick::Left => "horizon.options.radial_stick.left.about",
gamepad::RadialStick::Either => "horizon.options.radial_stick.either.about",
},
_ => return None,
})
}
@@ -123,6 +134,11 @@ fn info(info: Info, text: &'static str) -> Setting {
}
}
/// The radial menu's group, on General (how it opens and selects) and Bindings (its pages).
const RADIAL_GROUP: &str = "Radial Menu";
/// The row that opens the radial menu's pages.
const RADIAL_PAGES: &str = "horizon.pages.open.controller_radial";
/// Shown on a binding's row until it is focused, and in the search.
const BIND_TEXT: &str = "Press this row, then the button that should do it.";
@@ -201,6 +217,13 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
),
],
),
(
RADIAL_GROUP,
vec![
option(Opt::PadRadialMode, "horizon.options.radial_mode.about"),
option(Opt::PadRadialStick, "horizon.options.radial_stick.about"),
],
),
(
"Menus",
vec![
@@ -231,6 +254,13 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
(
Page::Bindings,
vec![
(
RADIAL_GROUP,
vec![Setting {
item: Item::Radial,
text: "Named pages of eight actions, held open with a button.",
}],
),
("Actions", binds),
(
"Reset",
@@ -437,6 +467,7 @@ fn row(setting: Setting, s: &Settings, context: &Context) -> Row<Item> {
Row::new(item, name, value).about(about)
}
Item::ResetBinds => Row::new(item, lang::get(RESET_ALL), Value::Press).about(about),
Item::Radial => Row::new(item, lang::get(RADIAL_PAGES), Value::Press).about(about),
Item::Info(which) => {
let (name, value) = context.read(which);
Row::new(item, lang::get(name), Value::Read(value)).about(about)
@@ -496,6 +527,7 @@ impl Screen for ControllerScreen {
s.controller.reset_bindings();
vec![Output::Event(Event::Changed)]
}
Item::Radial => vec![Output::Push(crate::options_ui::Page::ControllerRadial)],
Item::Bind(_) | Item::Info(_) => Vec::new(),
}
}
@@ -533,15 +565,21 @@ impl Screen for ControllerScreen {
fn undoes(&self) -> bool {
true
}
/// This player's controller settings, bindings included, as the screen found them.
/// This player's controller settings, bindings included, as the screen found them. The
/// radial menu's pages stay as they are: their own screen has its own Undo.
fn undo(&mut self, s: &mut Settings, before: &Settings) -> Vec<Output> {
let radial = std::mem::take(&mut s.controller.radial);
s.controller = before.controller.clone();
s.controller.radial = radial;
Vec::new()
}
/// Undo is there exactly while the controller settings differ from what the screen opened
/// with: a binding the game made while the row waited counts, a value stepped back doesn't.
/// with: a binding the game made while the row waited counts, a value stepped back doesn't,
/// and nor do the radial menu's pages, which this Undo keeps.
fn differs(&self, s: &Settings, before: &Settings) -> Option<bool> {
Some(s.controller != before.controller)
let mut now = s.controller.clone();
now.radial = before.controller.radial.clone();
Some(now != before.controller)
}
}
@@ -793,6 +831,48 @@ mod tests {
assert_eq!(arts(&batch, row), 0);
}
/// The radial menu's pages have their own screen and Undo: editing them shows no Undo here,
/// and another change's Undo keeps them as edited.
#[test]
fn radial_pages_edited_on_their_own_screen_leave_undo_alone() {
let mut s = Settings::default();
let mut ui = ui(Page::General);
draw(&mut ui, &s, None);
s.controller.radial.remove(1);
draw(&mut ui, &s, None);
assert!(
!ui.changed(),
"Undo here keeps the pages: it has nothing to do"
);
s.controller.invert_y = !s.controller.invert_y;
draw(&mut ui, &s, None);
assert!(ui.changed());
let reached = (0..100).any(|_| {
key(&mut ui, &mut s, K::Tab);
ui.focused() == Some(Target::Undo)
});
assert!(reached);
key(&mut ui, &mut s, K::Enter);
assert_eq!(s.controller.radial.len(), 1);
assert_eq!(s.controller.invert_y, PadSettings::default().invert_y);
}
/// The Bindings page's radial row opens the radial menu's pages.
#[test]
fn the_radial_row_opens_the_radial_pages() {
let mut s = Settings::default();
let mut ui = ui(Page::Bindings);
let at = ui.point_at(&s, SIZE, Item::Radial);
let outputs = ui.click(&mut s, at, SIZE, false, false, false);
assert_eq!(
outputs,
[
CLICK,
Output::Push(crate::options_ui::Page::ControllerRadial)
]
);
}
#[test]
fn the_search_finds_rows_across_pages_and_drops_empty_pages() {
let s = Settings::default();
@@ -816,8 +896,8 @@ mod tests {
let mut s = Settings::default();
s.controller.bind(JUMP, Some(Button::North));
let mut ui = ui(Page::Bindings);
// Search, the three pages, then the first action.
for _ in 0..Page::ALL.len() + 2 {
// Search, the three pages, the radial menu's row, then the first action.
for _ in 0..Page::ALL.len() + 3 {
key(&mut ui, &mut s, K::Tab);
}
assert_eq!(ui.focused(), Some(Target::Row(Item::Bind(JUMP))));
@@ -939,6 +1019,8 @@ mod tests {
(Opt::PadNintendoButtons, 2),
(Opt::PadKeyboard, 3),
(Opt::PadLightbar, 3),
(Opt::PadRadialMode, 2),
(Opt::PadRadialStick, 3),
] {
let setting = option(opt, "the fixed sentence");
let mut seen = Vec::new();
@@ -972,6 +1054,8 @@ mod tests {
Opt::PadNintendoButtons,
Opt::PadKeyboard,
Opt::PadLightbar,
Opt::PadRadialMode,
Opt::PadRadialStick,
] {
for _ in 0..4 {
lang::get(choice_description(opt, &settings.controller).unwrap());
@@ -990,6 +1074,7 @@ mod tests {
}
ControllerScreen::default().side();
lang::get(TITLE);
lang::get(RADIAL_PAGES);
for text in [
"Press the button for this. %s cancels.",
"This button also does: %s",
+6 -23
View File
@@ -2,7 +2,7 @@
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
//! (No AI, Agent Control, Discord Rich Presence) are kept here too.
//! (No AI, Agent Control) are kept here too.
use std::{collections::VecDeque, fs, path::Path};
pub const FILE: &str = "horizon-extras.txt";
@@ -145,8 +145,6 @@ pub struct Extras {
/// F3+R and F3+E start/stop a repro recording and write its bundle (off by
/// default: a recording copies the world).
pub repro_keys: bool,
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
pub discord_presence: bool,
/// Another controller can join P1's world (`splitscreen`, off by default).
pub splitscreen: bool,
/// Two players in one window: 0 auto (side by side when wider than tall), 1 side by side,
@@ -233,7 +231,6 @@ impl Default for Extras {
no_ai: false,
agent_control: false,
repro_keys: false,
discord_presence: true,
splitscreen: false,
splitscreen_two: 0,
splitscreen_three: 0,
@@ -338,7 +335,6 @@ impl Extras {
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
"reproKeys" => e.repro_keys = flag(e.repro_keys),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
"splitscreen" => e.splitscreen = flag(e.splitscreen),
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
@@ -376,7 +372,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
bool_text(self.pack_browser),
bool_text(self.pregen),
bool_text(self.fullbright),
@@ -418,7 +414,6 @@ impl Extras {
bool_text(self.no_ai),
bool_text(self.agent_control),
bool_text(self.repro_keys),
bool_text(self.discord_presence),
bool_text(self.splitscreen),
self.splitscreen_two,
self.splitscreen_three,
@@ -613,8 +608,6 @@ pub enum Item {
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
NoAi,
AgentControl,
/// On the Horizon Settings page.
DiscordPresence,
/// On the AI Agent page, next to Agent Control: the F3 recording shortcuts.
ReproKeys,
/// On the Splitscreen settings page.
@@ -691,7 +684,6 @@ impl Item {
Item::NoAi => "horizon.extras.no_ai",
Item::AgentControl => "horizon.extras.agent_control",
Item::ReproKeys => "horizon.extras.repro_keys",
Item::DiscordPresence => "horizon.extras.discord_presence",
Item::Splitscreen => "horizon.extras.splitscreen",
Item::ChatAnimation => "horizon.extras.chat_animation",
Item::ChatIndicator => "horizon.extras.chat_indicator",
@@ -724,7 +716,6 @@ impl Item {
Item::NoAi => "horizon.extras.no_ai.tooltip",
Item::AgentControl => "horizon.extras.agent_control.tooltip",
Item::ReproKeys => "horizon.extras.repro_keys.tooltip",
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
@@ -768,7 +759,6 @@ impl Item {
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::ReproKeys => "Recording shortcuts",
Item::DiscordPresence => "Discord Rich Presence",
Item::Splitscreen => "Splitscreen",
Item::WheelCyclesSettings => "Scroll wheel changes settings",
Item::PackBrowser => "Resource pack browser",
@@ -792,10 +782,9 @@ impl Item {
ChatIndicator => ChatAnimation,
Pvp(o) => return o.parent().map(Pvp),
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
| CosmeticsOthers | CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe
| Pregen => {
| AutoReconnect | NoAi | AgentControl | ReproKeys | Splitscreen | ChatAnimation
| WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine | CosmeticsOthers
| CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe | Pregen => {
return None;
}
})
@@ -844,9 +833,6 @@ impl Item {
Item::NoAi => {
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
}
Item::DiscordPresence => {
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
}
Item::Splitscreen => {
"In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
}
@@ -927,7 +913,6 @@ impl Item {
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::ReproKeys => e.repro_keys,
Item::DiscordPresence => e.discord_presence,
Item::Splitscreen => e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser,
@@ -986,7 +971,6 @@ impl Item {
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::ReproKeys => e.repro_keys = !e.repro_keys,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::Splitscreen => e.splitscreen = !e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser = !e.pack_browser,
@@ -1104,7 +1088,7 @@ mod tests {
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
assert!(!d.hide_block_particles && !d.pregen);
assert!(!d.music_page && d.music_off.is_empty());
assert!(d.discord_presence && !d.wheel_cycles_settings);
assert!(!d.wheel_cycles_settings);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
let mut e = d.clone();
@@ -1143,7 +1127,6 @@ mod tests {
e.sort_key = "key.keyboard.r".into();
e.chat_animation = true;
e.chat_indicator = false;
e.discord_presence = false;
assert!(!e.splitscreen);
Item::Splitscreen.cycle(&mut e, false);
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
@@ -112,7 +112,11 @@ fn add_sections(groups: &mut Vec<Group<ExtraRow>>) {
};
Row::new(id, lang::get(label), value)
}
added::Row::Button { label } => Row::new(id, lang::get(label), Value::Press),
added::Row::Button {
label,
active: true,
..
} => 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))
@@ -490,9 +494,7 @@ mod tests {
added::Place::HorizonSettings
}
fn rows(&self, rows: &mut Vec<added::Row>) {
rows.push(added::Row::Button {
label: "test.elsewhere.button",
});
rows.push(added::Row::button("test.elsewhere.button"));
}
fn act(&self, _: usize, _: Act) {}
}
+53
View File
@@ -233,6 +233,8 @@ pub struct FogInputs<'a> {
pub night_vision: f32,
/// The packs' `environment` fog settings (Polytone's).
pub environment: crate::pack_palette::Environment,
/// The packs' fluid modifiers' fog.
pub fluid_fog: crate::pack_palette::FluidFogs,
}
/// `FogRenderer.setupFog` + `computeFogColor`.
@@ -397,6 +399,24 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
}
}
}
// A pack's fluid modifier over its fluid's fog, after everything else (wiki "Custom Colors").
let fluid = match input.fog_type {
FogType::Lava => input.fluid_fog.lava,
FogType::Water if input.blindness.is_none() && input.darkness.is_none() => {
input.fluid_fog.water
}
_ => None,
};
if let Some(f) = fluid {
if let Some(rgb) = f.color {
let [r, g, b] = self::rgb(rgb);
fog.color = [r, g, b, 1.0];
}
let end = fog.environmental_end * f.radius;
let start = end - (end - fog.environmental_start * f.radius) * f.fade;
(fog.environmental_start, fog.environmental_end) = (start, end);
(fog.sky_end, fog.cloud_end) = (end, end);
}
let span = (rd / 10.0).clamp(4.0, 64.0);
fog.render_distance_start = rd - span;
fog.render_distance_end = rd;
@@ -495,6 +515,7 @@ mod tests {
full_water_vision: false,
night_vision: 0.0,
environment: Default::default(),
fluid_fog: Default::default(),
}
}
#[test]
@@ -611,6 +632,38 @@ mod tests {
let kept = setup_fog(&tables, &state, &input).color;
assert_eq!(&kept[..3], &[0.1, 0.2, 0.4]);
}
#[test]
fn a_fluid_modifier_scales_and_colours_its_fog_last() {
let sky = Sky {
fog_start: 10.0,
fog_end: 96.0,
..Sky::default()
};
let tables = FogTables {
water_fog_end: vec![1.0],
};
let mut input = inputs(&sky);
input.fog_type = FogType::Lava;
input.fire_resistance = true;
let vanilla = setup_fog(&tables, &FogState::default(), &input);
assert_eq!(
(vanilla.environmental_start, vanilla.environmental_end),
(0.0, 5.0)
);
input.fluid_fog.lava = Some(crate::pack_palette::FluidFog {
color: Some(0x00FF00),
radius: 2.0,
fade: 0.5,
});
let fog = setup_fog(&tables, &FogState::default(), &input);
assert_eq!(fog.color, [0.0, 1.0, 0.0, 1.0]);
// The end doubles; half the fade from 0 to 10 starts it at 5.
assert_eq!(
(fog.environmental_start, fog.environmental_end),
(5.0, 10.0)
);
assert_eq!((fog.sky_end, fog.cloud_end), (10.0, 10.0));
}
fn inputs_with<'a>(
input: &FogInputs<'a>,
environment: crate::pack_palette::Environment,
+40 -107
View File
@@ -201,6 +201,7 @@ pub use hdr::{Calibration as HdrCalibration, Output as HdrOutput};
mod hold;
pub use hold::WorldHold;
pub use output::{DisplayReading, EffectiveOutput, FrameConfiguration, FrameRequest};
pub use screenshot_save::Saved as SavedScreenshot;
pub mod block_light;
mod custom_sky;
mod displace;
@@ -236,6 +237,7 @@ pub mod panorama;
mod readback;
mod resident;
mod reuse;
mod screenshot_save;
mod stars;
mod surface_setup;
use surface_setup::{AdapterChoice, AdapterFailure, open_adapter, pick_adapter, set_up_surface};
@@ -1084,6 +1086,9 @@ pub struct ScreenshotRequest {
/// F2's: drawn at the game's resolution (Resolution Scale above 100%) times
/// `Renderer::screenshot_quality`, not the window's.
pub scaled: bool,
/// Written on a thread of its own (F2's, the world icon's): its outcome comes back a
/// frame or a few later in `Renderer::saved_screenshots`, not `screenshot_result`.
pub background: bool,
}
/// A solid terrain pipeline without HDR output's lighting (terrain.wgsl's `HDR_LIGHTING`),
/// for the frames HDR is off, and with it where the display offers HDR output: the
@@ -1578,6 +1583,9 @@ pub struct Renderer {
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
/// The HDR copy the last screenshot wrote beside it, if it wrote one.
pub screenshot_hdr: Option<std::path::PathBuf>,
/// `background` screenshots written since the caller last took them.
pub saved_screenshots: Vec<SavedScreenshot>,
screenshot_saves: screenshot_save::Saves,
/// Screenshot Quality (Horizon setting): a `scaled` screenshot's frame is this many
/// times the game's resolution (1, 2, 4 or 8).
pub screenshot_quality: u32,
@@ -5077,6 +5085,7 @@ impl Renderer {
));
}
let world_primitives = world_primitives::WorldPrimitives::new(&device, frame_format);
let screenshot_saves = screenshot_save::Saves::new(&device);
Ok(Self {
display: None,
reading: display,
@@ -5348,6 +5357,8 @@ impl Renderer {
screenshot: None,
screenshot_result: None,
screenshot_hdr: None,
saved_screenshots: Vec::new(),
screenshot_saves,
screenshot_quality: 1,
screenshot_halvings: 0,
gamma: 0.5,
@@ -7041,8 +7052,10 @@ impl Renderer {
}
}
/// This renderer's output as applied, with what its destination offers and reports.
pub fn output(&self) -> &output::EffectiveOutput {
&self.output
pub fn output(&self) -> output::EffectiveOutput {
let native = self.native.as_ref().map(|n| n.frame.api);
self.output
.with_pack(self.shader_pack_kind() == Some("iris"), native)
}
/// What this renderer's display last reported: a renderer made again for the window
/// starts from it (`Renderer::new`'s `display`).
@@ -7975,6 +7988,8 @@ impl Renderer {
if self.device_lost() {
return Err("the graphics device was lost".into());
}
let saved = self.screenshot_saves.finished();
self.saved_screenshots.extend(saved);
if let Some(request) = self.capture.take() {
let result = self.write_capture(&request, frame);
self.capture_result = Some(result.map_err(|e| e.to_string()));
@@ -8319,9 +8334,7 @@ impl Renderer {
let taa = !frame.title
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
&& self.native.as_ref().is_none_or(|n| n.post.is_none());
let hdr_lighting = self.native.is_none()
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
&& std::env::var_os("HORIZON_HDR_LIGHTING").is_none_or(|v| v != "0");
let hdr_lighting = self.shader_pack_kind().is_none();
if let Some(hdr) = &mut self.hdr {
hdr.lighting = hdr_lighting && hdr.look;
}
@@ -12044,24 +12057,18 @@ impl Renderer {
self.screenshot = Some(request);
} else {
self.screenshot_halvings = 0;
self.screenshot_hdr = None;
if let Some(hdr_buffer) = hdr_readback {
let hdr_path = hdr_screenshot_path(&request.path);
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
Ok(()) => {
log::info!("Saved HDR screenshot to {}", hdr_path.display());
self.screenshot_hdr = Some(hdr_path);
}
Err(e) => log::warn!(
"Horizon renderer: HDR screenshot {}: {e}",
hdr_path.display()
),
}
let size = [self.config.width, self.config.height];
let hdr = hdr_readback
.filter(|_| self.hdr.is_some())
.map(|b| (hdr_screenshot_path(&request.path), b));
let shot = screenshot_save::Shot::new(&request, size, self.config.format, hdr);
if request.background {
self.screenshot_saves.start(shot, buffer);
} else {
let saved = screenshot_save::save_now(&self.device, shot, &buffer);
self.screenshot_hdr = saved.hdr;
self.screenshot_result = Some(saved.result);
}
self.screenshot_result = Some(
self.write_screenshot(&request, &buffer)
.map_err(|e| e.to_string()),
);
}
}
// The frame's own waits for the submit thread (its queue writes and passes).
@@ -12664,96 +12671,22 @@ impl Renderer {
let texture = copy.as_ref().unwrap_or(texture);
Some((request, copy_frame(&self.device, encoder, texture, size)))
} else {
self.screenshot_result = Some(Err(format!(
let error = format!(
"screenshots need a 32-bit surface format, not {:?}",
self.config.format
)));
);
if request.background {
self.saved_screenshots.push(screenshot_save::Saved {
path: request.path,
result: Err(error),
hdr: None,
});
} else {
self.screenshot_result = Some(Err(error));
}
None
}
}
/// The pixels `copy_frame` read back into `buffer`, 4 bytes each, row after row.
fn read_frame(&self, buffer: &wgpu::Buffer) -> Result<Vec<[u8; 4]>> {
let (sender, receiver) = std::sync::mpsc::channel();
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
let _ = sender.send(r);
});
self.device
.poll(wgpu::PollType::wait_indefinitely())
.map_err(|e| e.to_string())?;
receiver
.recv()
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let (width, height, row) = (
self.config.width as usize,
self.config.height as usize,
padded_row(self.config.width) as usize,
);
let mut pixels = Vec::with_capacity(width * height);
{
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
for y in 0..height {
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
}
}
buffer.unmap();
Ok(pixels)
}
fn write_screenshot(
&self,
request: &ScreenshotRequest,
buffer: &wgpu::Buffer,
) -> Result<[u32; 2]> {
let bgra = matches!(
self.config.format.remove_srgb_suffix(),
wgpu::TextureFormat::Bgra8Unorm
);
let mut pixels = Vec::with_capacity((self.config.width * self.config.height * 4) as usize);
for px in self.read_frame(buffer)? {
let (r, b) = if bgra { (px[2], px[0]) } else { (px[0], px[2]) };
pixels.extend_from_slice(&[r, px[1], b, 255]);
}
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
// is the frame's centre square averaged down to its size.
let (width, height, pixels) = match request.size {
Some(size) => (
size[0],
size[1],
square_downscale(&pixels, self.config.width, self.config.height, size),
),
None => (self.config.width, self.config.height, pixels),
};
let mut encoder = png::Encoder::new(
std::io::BufWriter::new(std::fs::File::create(&request.path)?),
width,
height,
);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder.write_header()?.write_image_data(&pixels)?;
Ok([width, height])
}
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
/// third edition), with the content light levels its pixels hold (cLLI), and
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
fn write_hdr_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<()> {
if self.hdr.is_none() {
return Ok(());
}
let pixels = hdr10_to_rgb16(&self.read_frame(buffer)?);
let mut info = png::Info::with_size(self.config.width, self.config.height);
info.color_type = png::ColorType::Rgb;
info.bit_depth = png::BitDepth::Sixteen;
info.icc_profile = Some(hdr_icc::profile().into());
let encoder =
png::Encoder::with_info(std::io::BufWriter::new(std::fs::File::create(path)?), info)?;
let mut writer = encoder.write_header()?;
// BT.2020 primaries, PQ, RGB, full range.
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
writer.write_chunk(png::chunk::cLLI, &hdr_icc::content_light_levels(&pixels))?;
writer.write_image_data(&pixels)?;
Ok(())
}
}
/// `screenshots/2026-10-28_12.00.00.png` -> `screenshots/2026-10-28_12.00.00-hdr.png`.
fn hdr_screenshot_path(path: &std::path::Path) -> PathBuf {
+7 -5
View File
@@ -191,7 +191,7 @@ pub struct Hdr {
pub masks: Masks,
/// HDR lighting, whether Horizon lifts this frame's world: off under a Horizon shader pack
/// that draws, while a `shaders/` pack's post or material stage runs (the pack's own),
/// with HDR Lighting (`look`) off, and with `HORIZON_HDR_LIGHTING=0`, to compare.
/// and with HDR Lighting (`look`) off.
pub lighting: bool,
/// The HDR Lighting setting: off, nothing in the world is lifted, light sources and the
/// sun included, so the picture is SDR's on the HDR canvas.
@@ -1001,12 +1001,14 @@ impl Hdr {
}
}
/// 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.
/// 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.
fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
use wgpu::util::DeviceExt;
let (w, h, rgba) =
crate::hud::decode_bytes(horizon_theme::ICON_PNG).unwrap_or((1, 1, vec![0; 4]));
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 mask: Vec<u8> = rgba
.as_chunks::<4>()
.0
@@ -144,6 +144,9 @@ pub struct EffectiveOutput {
pub(crate) nits: [f32; 3],
/// The HUD and menus' brightness, as a share of paper white: their nits over its.
pub(crate) ui_brightness: f32,
/// The running shader pack lights the world itself: Horizon lifts none of it, so HDR
/// Lighting has nothing to switch (`with_pack`).
pub(crate) pack_lights: bool,
}
impl Default for EffectiveOutput {
fn default() -> Self {
@@ -166,7 +169,22 @@ impl EffectiveOutput {
},
nits: [203.0, 1000.0, 0.0],
ui_brightness: 1.0,
pack_lights: false,
};
/// This output under the running shader pack: a `shaders/` one (`shaders`), or one of
/// Horizon's own of API `native`. Under a 0.1 pack on the 8- or 16-bit frame the last pass
/// still lifts the light sources and the sun its marks hold (`lifts_world` in
/// `Renderer::render`); under the rest Horizon lifts nothing.
pub(crate) fn with_pack(self, shaders: bool, native: Option<horizon_shader_api::Api>) -> Self {
let pack_lights = shaders
|| native.is_some_and(|api| {
api != horizon_shader_api::Api::V0_1 || self.frame.format == super::LINEAR_FRAME
});
Self {
pack_lights,
..self
}
}
/// Whether the destination offers HDR output.
pub fn hdr_offered(&self) -> bool {
self.offered.is_some()
@@ -273,6 +291,7 @@ pub(crate) fn resolve_output(
],
ui_brightness: settings.ui_nits(reading.luminance) as f32
/ settings.paper_white_nits(reading.luminance) as f32,
pack_lights: false,
}
}
@@ -0,0 +1,321 @@
//! Writing a screenshot's PNG once its frame is read back: on the render thread for the
//! requests that want the result with the frame (an agent's, a `screenshot-sequence`'s),
//! on a thread of its own for F2's and the world icon's (`ScreenshotRequest::background`).
//! The render thread then neither waits for the GPU to finish the frame nor encodes the
//! PNG, which at 4K or a Screenshot Quality above 1x froze the game for a second or more.
use super::{ScreenshotRequest, hdr_icc, hdr10_to_rgb16, padded_row, square_downscale};
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
/// What a frame's readback holds and where its PNG goes.
pub(super) struct Shot {
pub path: PathBuf,
/// `ScreenshotRequest::size`: the world icon's square.
pub size: Option<[u32; 2]>,
/// The frame's size, which the readback's rows hold (padded to `padded_row`).
pub frame: [u32; 2],
/// Blue first (`Bgra8Unorm`), swapped to PNG's red first.
pub bgra: bool,
/// The HDR10 copy's readback and its PNG's path, beside the SDR one.
pub hdr: Option<(PathBuf, wgpu::Buffer)>,
}
impl Shot {
pub fn new(
request: &ScreenshotRequest,
frame: [u32; 2],
format: wgpu::TextureFormat,
hdr: Option<(PathBuf, wgpu::Buffer)>,
) -> Self {
Self {
path: request.path.clone(),
size: request.size,
frame,
bgra: format.remove_srgb_suffix() == wgpu::TextureFormat::Bgra8Unorm,
hdr,
}
}
}
/// A written screenshot: its path, its size or the error, and the HDR copy written beside it.
pub struct Saved {
pub path: PathBuf,
pub result: Result<[u32; 2], String>,
pub hdr: Option<PathBuf>,
}
/// Waits for `buffer`'s mapping on the render thread and writes the PNG there.
pub(super) fn save_now(device: &wgpu::Device, shot: Shot, buffer: &wgpu::Buffer) -> Saved {
let (sender, receiver) = channel();
map(buffer, &sender);
if let Some((_, hdr)) = &shot.hdr {
map(hdr, &sender);
}
let waited = device
.poll(wgpu::PollType::wait_indefinitely())
.map_err(|e| e.to_string());
let count = 1 + usize::from(shot.hdr.is_some());
let mapped = waited.and_then(|_| wait_mapped(&receiver, count));
write(shot, buffer, mapped)
}
/// Screenshots being written on their thread.
pub(super) struct Saves {
device: wgpu::Device,
sender: Sender<Saved>,
receiver: Receiver<Saved>,
in_flight: usize,
}
impl Saves {
pub fn new(device: &wgpu::Device) -> Self {
let (sender, receiver) = channel();
Self {
device: device.clone(),
sender,
receiver,
in_flight: 0,
}
}
/// Maps `buffer` (and the HDR copy's), whose copy was just submitted, and writes the
/// PNG on a thread of its own once the GPU has finished the frame.
pub fn start(&mut self, shot: Shot, buffer: wgpu::Buffer) {
let (mapped, on_mapped) = channel();
map(&buffer, &mapped);
if let Some((_, hdr)) = &shot.hdr {
map(hdr, &mapped);
}
let done = self.sender.clone();
let path = shot.path.clone();
let spawned = std::thread::Builder::new()
.name("Screenshot writer".into())
.spawn(move || {
let count = 1 + usize::from(shot.hdr.is_some());
let ready = wait_mapped(&on_mapped, count);
let _ = done.send(write(shot, &buffer, ready));
});
match spawned {
Ok(_) => {}
Err(e) => {
let _ = self.sender.send(Saved {
path,
result: Err(format!("no thread to write it: {e}")),
hdr: None,
});
}
}
self.in_flight += 1;
}
/// The screenshots written since the last call. Polls the device so a finished frame's
/// mapping reaches its writer: the render thread never waits for it.
pub fn finished(&mut self) -> Vec<Saved> {
if self.in_flight == 0 {
return Vec::new();
}
let _ = self.device.poll(wgpu::PollType::Poll);
let saved: Vec<Saved> = self.receiver.try_iter().collect();
self.in_flight -= saved.len();
saved
}
}
impl Drop for Saves {
/// A screenshot taken right before quitting is still written whole.
fn drop(&mut self) {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while self.in_flight > 0 && std::time::Instant::now() < deadline {
let _ = self.device.poll(wgpu::PollType::Poll);
if let Ok(saved) = self.receiver.recv_timeout(Duration::from_millis(20)) {
log_saved(&saved);
self.in_flight -= 1;
}
}
}
}
fn log_saved(saved: &Saved) {
match &saved.result {
Ok(_) => log::info!("Saved screenshot to {}", saved.path.display()),
Err(e) => log::warn!("Horizon renderer: screenshot {}: {e}", saved.path.display()),
}
}
fn map(buffer: &wgpu::Buffer, mapped: &Sender<Result<(), String>>) {
let mapped = mapped.clone();
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
let _ = mapped.send(r.map_err(|e| e.to_string()));
});
}
/// `count` mappings done, or the first one that failed. A frame the GPU never finishes
/// (a lost device) gives up after a while instead of keeping its thread.
fn wait_mapped(mapped: &Receiver<Result<(), String>>, count: usize) -> Result<(), String> {
for _ in 0..count {
mapped
.recv_timeout(Duration::from_secs(30))
.map_err(|_| "the frame never came back from the GPU".to_string())??;
}
Ok(())
}
/// The PNG (and the HDR copy's beside it) from the mapped readbacks.
fn write(shot: Shot, buffer: &wgpu::Buffer, mapped: Result<(), String>) -> Saved {
let mut hdr_written = None;
let result = mapped.and_then(|()| {
if let Some((hdr_path, hdr_buffer)) = &shot.hdr {
let pixels = read_rows(hdr_buffer, shot.frame);
match pixels.and_then(|p| write_hdr_png(hdr_path, shot.frame, &p)) {
Ok(()) => {
log::info!("Saved HDR screenshot to {}", hdr_path.display());
hdr_written = Some(hdr_path.clone());
}
Err(e) => log::warn!(
"Horizon renderer: HDR screenshot {}: {e}",
hdr_path.display()
),
}
}
let pixels = read_rows(buffer, shot.frame)?;
write_png(&shot, &pixels)
});
Saved {
path: shot.path,
result,
hdr: hdr_written,
}
}
/// The pixels `copy_frame` read back into the mapped `buffer`, 4 bytes each, row after
/// row; unmaps it.
fn read_rows(buffer: &wgpu::Buffer, [width, height]: [u32; 2]) -> Result<Vec<[u8; 4]>, String> {
let (width, height, row) = (width as usize, height as usize, padded_row(width) as usize);
let mut pixels = Vec::with_capacity(width * height);
{
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
for y in 0..height {
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
}
}
buffer.unmap();
Ok(pixels)
}
fn write_png(shot: &Shot, frame: &[[u8; 4]]) -> Result<[u32; 2], String> {
let [frame_w, frame_h] = shot.frame;
let mut pixels = Vec::with_capacity(frame.len() * 4);
for px in frame {
let (r, b) = if shot.bgra {
(px[2], px[0])
} else {
(px[0], px[2])
};
pixels.extend_from_slice(&[r, px[1], b, 255]);
}
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
// is the frame's centre square averaged down to its size.
let (width, height, pixels) = match shot.size {
Some(size) => (
size[0],
size[1],
square_downscale(&pixels, frame_w, frame_h, size),
),
None => (frame_w, frame_h, pixels),
};
let file = std::fs::File::create(&shot.path).map_err(|e| e.to_string())?;
let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.and_then(|mut w| w.write_image_data(&pixels))
.map_err(|e| e.to_string())?;
Ok([width, height])
}
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
/// third edition), with the content light levels its pixels hold (cLLI), and
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
fn write_hdr_png(
path: &std::path::Path,
[width, height]: [u32; 2],
frame: &[[u8; 4]],
) -> Result<(), String> {
let pixels = hdr10_to_rgb16(frame);
let mut info = png::Info::with_size(width, height);
info.color_type = png::ColorType::Rgb;
info.bit_depth = png::BitDepth::Sixteen;
info.icc_profile = Some(hdr_icc::profile().into());
let file = std::fs::File::create(path).map_err(|e| e.to_string())?;
let written = png::Encoder::with_info(std::io::BufWriter::new(file), info)
.and_then(|encoder| encoder.write_header())
.and_then(|mut writer| {
// BT.2020 primaries, PQ, RGB, full range.
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
writer.write_chunk(png::chunk::cLLI, &hdr_icc::content_light_levels(&pixels))?;
writer.write_image_data(&pixels)
});
written.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_background_save_writes_the_png_off_the_render_thread() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let dir = std::env::temp_dir().join(format!("horizon-shot-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("shot.png");
let [w, h] = [3u32, 2];
// A frame of blue-first pixels, each row padded as `copy_frame` reads them back.
let row = padded_row(w) as usize;
let mut bytes = vec![0u8; row * h as usize];
for y in 0..h as usize {
for x in 0..w as usize {
bytes[y * row + x * 4..][..4].copy_from_slice(&[10 * x as u8, 20, 200, 0]);
}
}
let source = wgpu::util::DeviceExt::create_buffer_init(
&device,
&wgpu::util::BufferInitDescriptor {
label: None,
contents: &bytes,
usage: wgpu::BufferUsages::COPY_SRC,
},
);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: bytes.len() as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(&source, 0, &buffer, 0, bytes.len() as u64);
queue.submit([encoder.finish()]);
let request = ScreenshotRequest {
path: path.clone(),
size: None,
scaled: false,
background: true,
};
let mut saves = Saves::new(&device);
saves.start(
Shot::new(&request, [w, h], wgpu::TextureFormat::Bgra8Unorm, None),
buffer,
);
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let saved = loop {
if let Some(saved) = saves.finished().pop() {
break saved;
}
assert!(std::time::Instant::now() < deadline, "never written");
std::thread::sleep(Duration::from_millis(5));
};
assert_eq!(saved.path, path);
assert_eq!(saved.result, Ok([w, h]));
let decoder =
png::Decoder::new(std::io::BufReader::new(std::fs::File::open(&path).unwrap()));
let mut reader = decoder.read_info().unwrap();
let mut out = vec![0; reader.output_buffer_size().unwrap()];
reader.next_frame(&mut out).unwrap();
// Red first, opaque: the last pixel of the first row was [20, 20, 200] blue-first.
assert_eq!(&out[8..12], &[200, 20, 20, 255]);
std::fs::remove_dir_all(&dir).unwrap();
}
}
@@ -2,16 +2,21 @@
//! colours and the controls it draws with. The screen (`graphics_ui`) only
//! lays out rows and handles input; a menu theme replaces this module.
//!
//! 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.
//! 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};
use crate::{
hud::{HudAtlas, HudBatch},
text::Text,
ui,
};
use horizon_theme::BLOCKS_DARK as B;
/// The settings pages' colours (`menu_style`, given at startup).
fn b() -> &'static SettingsColours {
&menu_style::get().settings
}
/// What a setting costs in frame time, shown with its description.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -36,54 +41,91 @@ 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,
}
}
}
/// `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)
}
use menu_style::hud_colour as hex;
/// The plate and the strip: the page's dark, see-through, so the world shows behind them.
const GLASS: u32 = hex(B.page, 118);
fn glass() -> u32 {
hex(b().page, 118)
}
/// Their 1 px edge.
const EDGE: u32 = hex(B.ink, 26);
pub fn edge() -> u32 {
hex(b().ink, 26)
}
/// A group's panel, a little lighter than the plate it sits on.
const GROUP: u32 = hex(B.ink, 11);
fn group_fill() -> u32 {
hex(b().ink, 11)
}
/// A hovered or focused row, lit on its group's panel.
pub const HOVER: u32 = hex(B.ink, 18);
pub fn hover() -> u32 {
hex(b().ink, 18)
}
/// The open page in the page list.
pub const OPEN: u32 = hex(B.ink, 26);
pub fn open() -> u32 {
hex(b().ink, 26)
}
/// The rule under the plate's heading, and between two rows of a group.
pub const HEAD_RULE: u32 = hex(B.ink, 31);
pub const ROW_RULE: u32 = hex(B.ink, 15);
pub fn head_rule() -> u32 {
hex(b().ink, 31)
}
pub fn row_rule() -> u32 {
hex(b().ink, 15)
}
/// Fields, boxes and the value stepper: sunk, see-through.
const WELL: u32 = hex(B.outline, 102);
fn well_fill() -> u32 {
hex(b().outline, 102)
}
/// A button, a slider's track, the scroller's thumb.
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);
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)
}
/// The one main button.
const FACE: u32 = hex(B.face, 255);
const FACE_HOVER: u32 = hex(0x6a50e6, 255);
pub fn face() -> u32 {
hex(b().face, 255)
}
fn face_hover() -> u32 {
hex(b().face_hover, 255)
}
/// A tick box's edge when off.
const BOX_EDGE: u32 = hex(B.ink_soft, 89);
const OUTLINE: u32 = hex(B.outline, 255);
pub const ACCENT: u32 = hex(B.accent, 255);
fn box_edge() -> u32 {
hex(b().ink_soft, 89)
}
fn outline() -> u32 {
hex(b().outline, 255)
}
pub fn 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 const MUTED: u32 = hex(B.muted, 255);
pub fn muted() -> u32 {
hex(b().muted, 255)
}
/// A page that isn't open, a value at rest.
pub const SOFT: u32 = hex(B.ink_soft, 255);
pub fn 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 const VEIL: u32 = hex(B.page, 77);
pub fn 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.
@@ -120,21 +162,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(batch, atlas, rect, group_fill());
}
/// 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(batch, atlas, rect, well_fill());
}
/// 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(batch, atlas, track, row_rule());
fill(batch, atlas, thumb, track_fill());
}
/// `text` cut to `width` with an ellipsis, as a long name gives way to its control: the HUD
/// kit's `fit::ellipsize`.
@@ -142,11 +184,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(batch, atlas, rect, well_fill());
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 {
@@ -169,7 +211,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);
}
}
@@ -198,17 +240,17 @@ pub fn button_with_room(
) {
let [x, y, w, h] = rect;
let face = match (live, primary, hover) {
(false, _, _) => WELL,
(true, true, false) => FACE,
(true, true, true) => FACE_HOVER,
(true, false, false) => BUTTON,
(true, false, true) => BUTTON_HOVER,
(false, _, _) => well_fill(),
(true, true, false) => face(),
(true, true, true) => face_hover(),
(true, false, false) => button_fill(),
(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(
@@ -252,7 +294,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,
@@ -263,17 +305,22 @@ 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(batch, atlas, [x, line, w, 2.0], track_fill());
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 } else { MUTED });
fill(
batch,
atlas,
handle,
if live { handle_fill() } 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.
@@ -290,13 +337,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);
}
@@ -1110,7 +1110,8 @@ impl Item {
/// active; saved values wait for it. Translucent Batching switches where the device can batch
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can,
/// Smooth Gradients only where it can draw the 16-bit frame, the same way, and GPU Culling
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't.
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't. HDR
/// Lighting has nothing to switch where the running shader pack lights the world itself.
fn blocked(self, s: &Settings, out: &EffectiveOutput) -> Option<&'static str> {
if !self.available() {
return Some("This feature is not yet available in Horizon.");
@@ -1141,6 +1142,9 @@ impl Item {
} else {
"Turn HDR on to use it."
}),
Item::Horizon(H::HdrLighting) if out.pack_lights => {
Some("The shader pack lights the world itself.")
}
Item::Horizon(H::TranslucentBatching)
if !crate::graphics::translucent_batching_offered() =>
{
@@ -31,6 +31,49 @@ fn smooth_gradients_wait_for_a_device_that_draws_the_16_bit_frame() {
assert_eq!(item.blocked(&s, &out), Some("Turn HDR on to use it."));
}
#[test]
fn hdr_lighting_is_greyed_only_where_the_running_pack_lights_the_world() {
use horizon_shader_api::Api;
let mut s = Settings::default();
s.horizon.hdr = HdrMode::On;
let item = Item::Horizon(H::HdrLighting);
let lights = Some("The shader pack lights the world itself.");
let out = offering(true, None);
// No pack running, as with one picked but refused, missing or its mod off: live.
assert_eq!(item.blocked(&s, &out.with_pack(false, None)), None);
// A 0.1 pack on the 16-bit frame: the light sources and the sun still shine.
assert_eq!(
item.blocked(&s, &out.with_pack(false, Some(Api::V0_1))),
None
);
assert_eq!(
item.blocked(&s, &out.with_pack(false, Some(Api::V0_2))),
lights
);
assert_eq!(item.blocked(&s, &out.with_pack(true, None)), lights);
let float = EffectiveOutput {
frame: crate::gfx::FrameConfiguration {
format: wgpu::TextureFormat::Rgba16Float,
..out.frame
},
..out
};
assert_eq!(
item.blocked(&s, &float.with_pack(false, Some(Api::V0_1))),
lights
);
// HDR's own reasons come first.
assert_eq!(
item.blocked(&s, &NONE.with_pack(true, None)),
Some("Your display or system doesn't offer HDR output.")
);
s.horizon.hdr = HdrMode::Off;
assert_eq!(
item.blocked(&s, &out.with_pack(true, None)),
Some("Turn HDR on to use it.")
);
}
#[test]
fn hdr_auto_can_be_selected_before_querying_and_brightness_waits_for_output() {
let mut s = Settings::default();
@@ -3,8 +3,9 @@
//! 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.
//!
//! 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.
//! 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 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
@@ -16,7 +17,7 @@
//! use std::time::{Duration, Instant};
//!
//! let opened = Instant::now();
//! let open = Tween::new(Duration::from_millis(90), horizon_theme::QUICK);
//! let open = Tween::new(Duration::from_millis(90), [0.4, 0.0, 0.2, 1.0]);
//! // 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);
@@ -26,7 +27,6 @@
//! 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,28 +50,20 @@ impl Clock {
}
}
/// A duration and one of the design system's easing curves.
/// A duration and an easing curve.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tween {
/// How long it runs.
pub duration: Duration,
/// The CSS `cubic-bezier` control points of its curve (a `horizon_theme::Motion`'s).
/// The CSS `cubic-bezier` control points of its curve.
pub curve: [f32; 4],
}
impl Tween {
/// `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)
/// `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 }
}
/// The linear progress, 0 to 1, `since` a start at this clock; 1 on a still clock.
@@ -84,7 +76,7 @@ impl Tween {
/// The eased progress, 0 to 1, on the tween's curve.
pub fn progress(self, clock: Clock, since: Instant) -> f32 {
horizon_theme::bezier(self.curve, self.linear(clock, since))
bezier(self.curve, self.linear(clock, since))
}
/// Whether it has finished (always, on a still clock).
@@ -98,6 +90,29 @@ 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)
@@ -145,7 +160,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);
let tween = Tween::new(MS(80), horizon_theme::MOVE.curve);
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);
@@ -160,8 +175,7 @@ mod tests {
fn a_still_clock_shows_every_motion_settled() {
let t0 = Instant::now();
let still = Clock::new(t0, true);
let tween = Tween::of(horizon_theme::QUICK);
assert_eq!(tween.duration, horizon_theme::QUICK.duration());
let tween = Tween::new(horizon_theme::QUICK.duration(), horizon_theme::QUICK.curve);
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);
@@ -173,7 +187,7 @@ mod tests {
#[test]
fn the_helpers_start_where_they_say() {
let t0 = Instant::now();
let tween = Tween::new(MS(100), horizon_theme::QUICK);
let tween = Tween::new(MS(100), horizon_theme::QUICK.curve);
let c = at(t0, 0);
assert_eq!(fade(tween, c, t0), 0.0);
assert_eq!(fade_out(tween, c, t0), 1.0);
@@ -184,10 +198,25 @@ 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);
let tween = Tween::new(MS(80), horizon_theme::MOVE.curve);
// 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);
@@ -1,6 +1,7 @@
//! Room: what of a view Horizon's own HUD overlays may use. Vanilla's HUD elements are
//! keep-outs; an overlay asks the room where it fits instead of assuming a fixed spot, so it
//! doesn't cover them. Today the only keep-out is the boss bars at the top centre.
//! doesn't cover them. The keep-outs: the boss bars at the top centre, and the status rows above
//! the hotbar at the bottom centre.
//!
//! The room is built once a frame from what the HUD shows (`Game::hud_room` in gameplay). With
//! nothing showing it is empty, and every overlay stays exactly where it always was.
@@ -34,6 +35,16 @@ pub const GAP: f32 = 2.0;
pub enum Keep {
/// The boss bars and their names at the top centre (`hud_overlays::boss_bars_bottom`).
BossBars,
/// The survival rows above the hotbar at the bottom centre: health with its absorption rows,
/// armour, food or the mount's hearts, and air; from the highest row's top to the view's
/// bottom.
StatusRows,
}
impl Keep {
/// It sits at the top of the view, not the bottom.
fn at_top(self) -> bool {
matches!(self, Self::BossBars)
}
}
/// A view and the keep-outs its HUD shows this frame.
@@ -90,10 +101,20 @@ impl Room {
/// bars' bottom, or 0 with none.
pub fn top(&self) -> f32 {
self.keeps()
.map(|(Keep::BossBars, [_, y, _, h])| y + h)
.filter(|(keep, _)| keep.at_top())
.map(|(_, [_, y, _, h])| y + h)
.fold(0.0, f32::max)
}
/// How far, in GUI px from the view's top, the keep-outs at the bottom reach up: the status
/// rows' top, or the view's height with none.
pub fn bottom(&self) -> f32 {
self.keeps()
.filter(|(keep, _)| !keep.at_top())
.map(|(_, [_, y, _, _])| y)
.fold(self.height, f32::min)
}
/// Where a centred overlay that wants its top at `wanted` (GUI px) goes: at `wanted` unless
/// a keep-out reaches past it (`top() > wanted`, a real overlap; touching isn't one), else
/// `GAP` below that keep-out.
@@ -134,4 +155,15 @@ mod tests {
assert_eq!(bars(36.0).below_top(18.0), 38.0);
assert_eq!(bars(36.0).usable(), [0.0, 38.0, 500.0, 262.0]);
}
#[test]
fn the_status_rows_are_kept_at_the_bottom_and_leave_the_top_alone() {
let room = Room::new(427.0, 240.0);
assert_eq!(room.bottom(), 240.0);
// Hearts and armour from y 191 down.
let rows = room.with(Keep::StatusRows, [122.0, 191.0, 182.0, 49.0]);
assert_eq!(rows.bottom(), 191.0);
assert_eq!(rows.top(), 0.0);
assert_eq!(rows.below_top(18.0), 18.0);
}
}
@@ -38,6 +38,12 @@
//! ```
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];
@@ -45,8 +51,12 @@ 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 const ARROW: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
pub const ARROW_OFF: u32 = colour(horizon_theme::BACKDROP_MUTED, 77);
pub fn arrow_colour() -> u32 {
colour(overlay().backdrop_muted, 255)
}
pub fn arrow_off_colour() -> u32 {
colour(overlay().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);
@@ -65,9 +75,15 @@ 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 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);
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)
}
/// The line of text's height in GUI px (vanilla's font, without its shadow).
const LINE: f32 = 8.0;
@@ -144,7 +160,11 @@ pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, strip: &Strip) {
arrow,
ax,
ay,
if on { ARROW } else { ARROW_OFF },
if on {
arrow_colour()
} else {
arrow_off_colour()
},
true,
);
}
@@ -282,9 +302,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.
@@ -380,15 +400,16 @@ mod tests {
let [ax, _] = strip.arrows[i];
(batch.vertices.iter())
.filter(|v| {
(v.pos[0] - ax).abs() < 4.0 && v.color & 0x00FF_FFFF == ARROW & 0x00FF_FFFF
(v.pos[0] - ax).abs() < 4.0
&& v.color & 0x00FF_FFFF == arrow_colour() & 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 >> 24);
assert_eq!(alpha(&first, 1), ARROW >> 24);
assert_eq!(alpha(&first, 0), arrow_off_colour() >> 24);
assert_eq!(alpha(&first, 1), arrow_colour() >> 24);
}
#[test]
@@ -131,6 +131,23 @@ pub enum GuiDraw {
pub const WHITE: u32 = 0xFFFF_FFFF;
/// The highest top, in GUI px, of the draws that sit on the view (`None` for none): where a row
/// of the HUD begins.
pub fn draws_top(draws: &[GuiDraw]) -> Option<i32> {
draws
.iter()
.filter_map(|d| match *d {
GuiDraw::Sprite { y, .. }
| GuiDraw::AtlasSprite { y, .. }
| GuiDraw::Blit { y, .. }
| GuiDraw::Text { y, .. }
| GuiDraw::Item { y, .. } => Some(y),
GuiDraw::Fill { y0, .. } => Some(y0),
GuiDraw::Push(_) | GuiDraw::Pop => None,
})
.min()
}
fn sprite(sprite: &'static str, x: i32, y: i32, w: i32, h: i32) -> GuiDraw {
GuiDraw::Sprite {
sprite: Cow::Borrowed(sprite),
@@ -583,6 +583,9 @@ pub const BOSS_BAR_STEP: i32 = 10 + 9;
pub const BOSS_BAR_HEIGHT: i32 = 5;
/// A boss bar's sprite width, in GUI px; it is centred on the view.
pub const BOSS_BAR_WIDTH: i32 = 182;
/// Half the hotbar's width, in GUI px (`Hud`'s `screenWidth / 2 - 91`): the status rows above it
/// span the same.
pub const HOTBAR_HALF_WIDTH: i32 = 91;
/// How many of `n` boss bars `BossHealthOverlay.extractRenderState` draws on a view `h` GUI px
/// tall: it stops once the next bar's top reaches a third of the height (integer division,
+4
View File
@@ -74,6 +74,7 @@ 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;
@@ -109,6 +110,8 @@ pub mod preedit;
pub mod providers;
pub mod pvp;
pub mod pvp_hud;
pub mod radial_editor;
pub mod radial_overlay;
pub mod random_source;
pub mod screen_draw;
pub mod screen_effects;
@@ -169,6 +172,7 @@ pub use horizon_client_input::key_names;
pub use horizon_client_input::keybinds;
pub use horizon_client_input::keymapping;
pub use horizon_client_input::mouse;
pub use horizon_client_input::radial;
pub use horizon_client_input::window;
pub use horizon_command as command;
pub use horizon_items::blocks;
@@ -0,0 +1,150 @@
//! 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,
/// A lit choice and its bright edge (the controller dial's sector).
pub lit: u32,
pub lit_edge: 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,
lit: 0xffffff,
lit_edge: 0xffffff,
},
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)
}
+35 -79
View File
@@ -18,6 +18,8 @@ use crate::{
ui,
};
mod shell_pages;
fn tr(key: &str) -> Text {
Text::translate(key, &[])
}
@@ -322,6 +324,8 @@ impl Opt {
Opt::PadNintendoButtons => "horizon.options.nintendo_buttons",
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
Opt::PadGameplayGuide => "horizon.options.gameplay_guide",
Opt::PadRadialMode => "horizon.options.radial_mode",
Opt::PadRadialStick => "horizon.options.radial_stick",
Opt::ModelPart(i) => [
"options.modelPart.cape",
"options.modelPart.jacket",
@@ -880,6 +884,7 @@ impl Opt {
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
}),
Opt::PadRadialMode | Opt::PadRadialStick => self.radial_text(s),
Opt::Page(page, row) => pages::slider_text(page, row),
_ => Text::empty(),
}
@@ -1201,6 +1206,7 @@ impl Opt {
.unwrap_or(0) as u8;
s.controller.keyboard = all[usize::from(next(i, 3))];
}
Opt::PadRadialMode | Opt::PadRadialStick => self.radial_step(s, back),
_ => {}
}
if self.affects_preset() {
@@ -1352,6 +1358,11 @@ pub enum Page {
/// Horizon's controller settings and button binds.
Controller,
PadBinds,
/// The radial menu's pages (`radial_editor`), on the settings shell, and a page's name.
ControllerRadial,
ControllerRadialName,
/// The list of every action for one of its slots.
ControllerRadialPick,
Extras,
/// PvP & HUD (`pvp`): the extras a PvP player sets, on their own page.
Pvp,
@@ -1683,6 +1694,10 @@ pub struct OptionsUi {
controller: shell::Shell<crate::controller_ui::ControllerScreen>,
/// Horizon Extras, on the settings shell.
extras: shell::Shell<crate::extras_ui::ExtrasScreen>,
/// The radial menu's pages, on the settings shell.
radial: shell::Shell<crate::radial_editor::RadialScreen>,
/// The list of actions for one of the radial menu's slots.
radial_pick: shell::Shell<crate::radial_editor::PickScreen>,
/// Shaders: the shader packs and the picked one's options, on the settings shell.
shader: shell::Shell<crate::shader_ui::ShaderScreen>,
/// The Pack Options page, and the packs' Polytone config entries by pack id (the game
@@ -1767,6 +1782,8 @@ impl Default for OptionsUi {
graphics: Default::default(),
controller: Default::default(),
extras: Default::default(),
radial: Default::default(),
radial_pick: Default::default(),
shader: Default::default(),
pack_options: Default::default(),
pack_configs: Default::default(),
@@ -2112,6 +2129,7 @@ impl OptionsUi {
.pack_screen
.as_ref()
.is_some_and(|p| p.search_focused()),
Page::ControllerRadialName => self.radial_name_typing(),
_ => match self.shell() {
Some(shell) => shell.typing(),
None => self.settings_search_active(),
@@ -2244,95 +2262,16 @@ impl OptionsUi {
self.studio.refresh();
}
}
/// The open page's screen on the settings shell. This, with `shell_mut`, is all a screen
/// needs here: drawing, clicks, the wheel, a stick's scrolling, keys and typing all go
/// through them, so no input path can miss one.
fn shell(&self) -> Option<&dyn shell::Hosted> {
match self.page() {
Page::Extras => Some(&self.extras),
page if Self::graphics_page(page).is_some() => Some(&self.graphics),
page if Self::controller_page(page).is_some() => Some(&self.controller),
page if Self::shader_page(page).is_some() => Some(&self.shader),
_ => None,
}
}
fn shell_mut(&mut self) -> Option<&mut dyn shell::Hosted> {
self.graphics.screen.output = self.output;
match self.page() {
Page::Extras => Some(&mut self.extras),
page if Self::graphics_page(page).is_some() => Some(&mut self.graphics),
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
page if Self::shader_page(page).is_some() => Some(&mut self.shader),
_ => None,
}
}
/// A controller's or the keyboard's focus landed on `rect`: a row of a shell screen's list
/// that is partly hidden scrolls all into view. How far it moved down, when it moved.
pub fn reveal(&mut self, s: &Settings, size: (f32, f32), rect: [f32; 4]) -> Option<f32> {
self.shell_mut()?.reveal(s, size, rect)
}
/// The window's size, for the graphics and controller screens' rows that step an option
/// (`Opt::cycle`, which a fullscreen resolution needs): before each input that can press one.
fn shell_framebuffers(&mut self) {
self.graphics.screen.framebuffer = self.framebuffer;
self.controller.screen.framebuffer = self.framebuffer;
}
/// Who and what Controller Settings is about: the game sets it before each draw.
pub fn set_controller_context(&mut self, context: crate::controller_ui::Context) {
self.controller.screen.context = context;
}
/// The binding the screen waits on: a controller button's, else a key's.
fn waiting(&self) -> Option<shell::Capture> {
let pad = self.selected_pad_action.map(shell::Capture::Pad);
pad.or(self.selected_key.map(shell::Capture::Key))
}
/// What a shell screen's outputs do to these options.
fn shell_events(&mut self, s: &mut Settings, outputs: Vec<shell::Output>) -> Vec<Event> {
let mut events = Vec::new();
for output in outputs {
match output {
shell::Output::Event(event) => events.push(event),
shell::Output::Bind(shell::Capture::Key(name)) => self.selected_key = Some(name),
shell::Output::Bind(shell::Capture::Pad(action)) => {
self.selected_pad_action = Some(action)
}
shell::Output::Push(page) => {
if page == Page::HdrCalibration {
self.calibration =
Some(crate::hdr_calibration::Calibration::new(s, &self.output));
}
self.push(page);
}
shell::Output::Done => events.extend(self.back(s)),
}
}
events
}
/// Where the shader screen opens for `page`, if it is one of its pages.
fn shader_page(page: Page) -> Option<crate::shader_ui::Tab> {
match page {
Page::ShaderPacks => Some(crate::shader_ui::Tab::Packs),
Page::ShaderOptions => Some(crate::shader_ui::Tab::Screen(0)),
_ => None,
}
}
/// Where the controller screen opens for `page`, if it is one of its pages.
fn controller_page(page: Page) -> Option<crate::controller_ui::Page> {
match page {
Page::Controller => Some(crate::controller_ui::Page::General),
Page::PadBinds => Some(crate::controller_ui::Page::Bindings),
_ => None,
}
}
/// Whether a video settings screen is open, Horizon's or vanilla's: the fullscreen mode
/// waits for it to close, and Ctrl with the wheel steps the GUI scale.
pub fn video_open(&self) -> bool {
self.page() == Page::Video || Self::graphics_page(self.page()).is_some()
}
/// Whether the controller screen is open: a controller button pressed now may bind.
pub fn controller_open(&self) -> bool {
Self::controller_page(self.page()).is_some()
}
/// Where the graphics screen opens for `page`, if it is one of its pages.
fn graphics_page(page: Page) -> Option<crate::graphics_ui::Page> {
match page {
@@ -2369,6 +2308,9 @@ impl OptionsUi {
"options.videoTitle"
}
Page::Controller | Page::PadBinds => "horizon.pages.controller",
Page::ControllerRadial => "horizon.pages.controller_radial",
Page::ControllerRadialName => "horizon.radial.name_title",
Page::ControllerRadialPick => "horizon.radial.pick_title",
Page::Extras => "horizon.pages.extras",
Page::Pvp => "horizon.pages.pvp",
Page::Credits => "credits_and_attribution.screen.title",
@@ -3602,6 +3544,9 @@ impl OptionsUi {
}
let waiting = self.waiting();
let in_world = self.in_world;
if self.draw_radial_name(batch, atlas, cursor, in_world) {
return;
}
if let Some(shell) = self.shell_mut() {
shell.draw(s, batch, atlas, cursor, in_world, waiting);
// The keys lay the screen out at the size it was drawn at.
@@ -3953,6 +3898,9 @@ impl OptionsUi {
let outcome = c.click(&atlas.font, (x, y), size, drag);
return self.calibration_outcome(s, outcome);
}
if self.page() == Page::ControllerRadialName {
return self.click_radial_name(s, atlas, (x, y), size, drag || right);
}
if self.shell().is_some() {
// Waiting on a controller button: a click cancels the wait.
if !drag && self.selected_pad_action.take().is_some() {
@@ -4648,6 +4596,8 @@ impl OptionsUi {
self.scroll = 0.0;
} else if self.page() == Page::SplitProfiles {
self.split_profiles.type_text(text);
} else if self.page() == Page::ControllerRadialName {
self.type_radial_name(text);
} else if let Some(shell) = self.shell_mut() {
if shell.typing() {
shell.type_text(text);
@@ -4683,6 +4633,9 @@ impl OptionsUi {
if self.page() == Page::SplitProfiles {
return self.split_profiles.shortcut(shortcut, clipboard);
}
if self.page() == Page::ControllerRadialName {
return self.radial_name_shortcut(shortcut, clipboard);
}
if let Some(shell) = self.shell_mut() {
return shell.shortcut(shortcut, clipboard);
}
@@ -4786,6 +4739,9 @@ impl OptionsUi {
if self.page() == Page::SplitProfiles && self.split_profiles.key(key, text) {
return Vec::new();
}
if self.page() == Page::ControllerRadialName {
return self.radial_name_key(s, key, text);
}
// The size it was drawn at; before the first draw, one its keys can lay out at.
let gui = if self.size.0 > 0.0 && self.size.1 > 0.0 {
@@ -0,0 +1,302 @@
//! The pages Options shows on the settings shell (`settings_shell`): which shell each page
//! opens, what a shell's outputs do here, and the radial menu's pages (`radial_editor`), whose
//! name entry is drawn here in place of a shell, and whose rows of Controller Settings step here.
use super::{Event, OptionsUi, Page, tr};
use crate::{
gamepad,
hud::{HudAtlas, HudBatch},
radial_editor::Named,
settings::{Opt, Settings},
settings_shell as shell,
text::Text,
};
impl OptionsUi {
/// The open page's screen on the settings shell. This, with `shell_mut`, is all a screen
/// needs here: drawing, clicks, the wheel, a stick's scrolling, keys and typing all go
/// through them, so no input path can miss one.
pub(super) fn shell(&self) -> Option<&dyn shell::Hosted> {
match self.page() {
Page::Extras => Some(&self.extras),
page if Self::graphics_page(page).is_some() => Some(&self.graphics),
Page::ControllerRadial => Some(&self.radial),
Page::ControllerRadialPick => Some(&self.radial_pick),
page if Self::controller_page(page).is_some() => Some(&self.controller),
page if Self::shader_page(page).is_some() => Some(&self.shader),
_ => None,
}
}
pub(super) fn shell_mut(&mut self) -> Option<&mut dyn shell::Hosted> {
self.graphics.screen.output = self.output;
match self.page() {
Page::Extras => Some(&mut self.extras),
page if Self::graphics_page(page).is_some() => Some(&mut self.graphics),
Page::ControllerRadial => Some(&mut self.radial),
Page::ControllerRadialPick => Some(&mut self.radial_pick),
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
page if Self::shader_page(page).is_some() => Some(&mut self.shader),
_ => None,
}
}
/// Where the open shell screen asked the focus to open (a radial slot's list, on the slot's
/// action), for a controller's focus to land there.
pub fn shell_opened_focus(&self) -> Option<[f32; 4]> {
self.shell()?.opened_focus()
}
/// The clicks until it is set back are a controller's Left or Right on a row: a choice of a
/// settings shell steps, rather than opens.
pub fn pad_clicks_step(&mut self, on: bool) {
if on {
if let Some(shell) = self.shell_mut() {
shell.clicks_step(true);
}
return;
}
// Every shell: the click may have changed the page.
let shells: [&mut dyn shell::Hosted; 6] = [
&mut self.extras,
&mut self.graphics,
&mut self.controller,
&mut self.radial,
&mut self.radial_pick,
&mut self.shader,
];
for shell in shells {
shell.clicks_step(false);
}
}
/// The controller's focus landed there.
pub fn clear_shell_opened_focus(&mut self) {
if let Some(shell) = self.shell_mut() {
shell.clear_opened_focus();
}
}
/// The window's size, for the graphics and controller screens' rows that step an option
/// (`Opt::cycle`, which a fullscreen resolution needs): before each input that can press one.
pub(super) fn shell_framebuffers(&mut self) {
self.graphics.screen.framebuffer = self.framebuffer;
self.controller.screen.framebuffer = self.framebuffer;
}
/// Who and what Controller Settings is about: the game sets it before each draw.
pub fn set_controller_context(&mut self, context: crate::controller_ui::Context) {
self.controller.screen.context = context;
}
/// The binding the screen waits on: a controller button's, else a key's.
pub(super) fn waiting(&self) -> Option<shell::Capture> {
let pad = self.selected_pad_action.map(shell::Capture::Pad);
pad.or(self.selected_key.map(shell::Capture::Key))
}
/// What a shell screen's outputs do to these options.
pub(super) fn shell_events(
&mut self,
s: &mut Settings,
outputs: Vec<shell::Output>,
) -> Vec<Event> {
let mut events = Vec::new();
for output in outputs {
match output {
shell::Output::Event(event) => events.push(event),
shell::Output::Bind(shell::Capture::Key(name)) => self.selected_key = Some(name),
shell::Output::Bind(shell::Capture::Pad(action)) => {
self.selected_pad_action = Some(action)
}
shell::Output::Push(page) => {
if page == Page::HdrCalibration {
self.calibration =
Some(crate::hdr_calibration::Calibration::new(s, &self.output));
}
self.push(page);
// A slot's list, for the slot, opening on what it holds.
if page == Page::ControllerRadialPick
&& let Some(at) = self.radial.screen.picking.take()
{
let row = self.radial_pick.screen.open_on(at, &s.controller.radial);
self.radial_pick.focus_soon(row);
}
}
shell::Output::Done => {
// Back from a slot's list, the focus is on the slot.
let slot = (self.page() == Page::ControllerRadialPick)
.then(|| self.radial_pick.screen.at());
events.extend(self.back(s));
if let Some((page, slot)) = slot {
self.radial
.focus_soon(crate::radial_editor::RadialRow::Slot(page, slot));
}
}
}
}
// A pick on a slot's list is a change of the radial page's, which its Undo takes back.
if self.radial_pick.screen.take_picked() {
self.radial.changed_elsewhere();
}
// A radial page added or deleted: the one to show now.
if let Some(page) = self.radial.screen.take_shown() {
self.radial.show(page);
}
events
}
/// What a press on a radial page's name does here.
pub(super) fn name_events(
&mut self,
named: crate::radial_editor::Named,
s: &mut Settings,
) -> Vec<Event> {
use crate::radial_editor::Named;
let mut events = vec![Event::ButtonClick];
match named {
Named::Nothing => return Vec::new(),
Named::Done => {
let entry = self.radial.screen.naming.take();
if entry.is_some_and(|entry| entry.apply(&mut s.controller.radial)) {
self.radial.changed_elsewhere();
}
events.push(Event::Changed);
events.extend(self.back(s));
}
Named::Back => events.extend(self.back(s)),
}
events
}
/// Where the shader screen opens for `page`, if it is one of its pages.
pub(super) fn shader_page(page: Page) -> Option<crate::shader_ui::Tab> {
match page {
Page::ShaderPacks => Some(crate::shader_ui::Tab::Packs),
Page::ShaderOptions => Some(crate::shader_ui::Tab::Screen(0)),
_ => None,
}
}
/// Where the controller screen opens for `page`, if it is one of its pages.
pub(super) fn controller_page(page: Page) -> Option<crate::controller_ui::Page> {
match page {
Page::Controller => Some(crate::controller_ui::Page::General),
Page::PadBinds => Some(crate::controller_ui::Page::Bindings),
_ => None,
}
}
/// Whether the controller screen is open: a controller button pressed now may bind.
pub fn controller_open(&self) -> bool {
Self::controller_page(self.page()).is_some()
}
/// The radial screens' glyphs this frame, and the name of a radial page being typed, drawn
/// in place of a shell: whether it was.
pub(super) fn draw_radial_name(
&mut self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
in_world: bool,
) -> bool {
self.radial.screen.glyphs = self.pad_glyphs;
if self.page() == Page::ControllerRadialName
&& let Some(entry) = &mut self.radial.screen.naming
{
entry.draw(batch, atlas, cursor, in_world);
return true;
}
false
}
/// A click on a radial page's name entry (`ignored`: a drag or a right click).
pub(super) fn click_radial_name(
&mut self,
s: &mut Settings,
atlas: &HudAtlas,
at: (f32, f32),
size: (f32, f32),
ignored: bool,
) -> Vec<Event> {
let entry = self.radial.screen.naming.as_mut();
let named = match entry.filter(|_| !ignored) {
Some(entry) => entry.click(atlas, at, size),
None => Named::Nothing,
};
self.name_events(named, s)
}
/// Whether a radial page's name is being typed.
pub(super) fn radial_name_typing(&self) -> bool {
self.radial
.screen
.naming
.as_ref()
.is_some_and(|n| n.typing())
}
/// Text typed into a radial page's name.
pub(super) fn type_radial_name(&mut self, text: &str) {
if let Some(entry) = &mut self.radial.screen.naming {
entry.type_text(text);
}
}
/// An edit shortcut in a radial page's name: whether it did something.
pub(super) fn radial_name_shortcut(
&mut self,
shortcut: crate::clipboard::EditShortcut,
clipboard: &mut dyn crate::clipboard::Clipboard,
) -> bool {
let entry = self.radial.screen.naming.as_mut();
entry.is_some_and(|entry| entry.shortcut(shortcut, clipboard))
}
/// A key on a radial page's name entry.
pub(super) fn radial_name_key(
&mut self,
s: &mut Settings,
key: winit::keyboard::KeyCode,
text: Option<&str>,
) -> Vec<Event> {
let named = match &mut self.radial.screen.naming {
Some(entry) => entry.key(key, text),
// Nothing to name: Escape still leaves.
None if key == winit::keyboard::KeyCode::Escape => Named::Back,
None => Named::Nothing,
};
self.name_events(named, s)
}
}
impl Opt {
/// The radial menu's rows of Controller Settings: their value.
pub(super) fn radial_text(self, s: &Settings) -> Text {
match self {
Opt::PadRadialMode => tr(match s.controller.radial_mode {
gamepad::RadialMode::Hold => "options.key.hold",
gamepad::RadialMode::Toggle => "options.key.toggle",
}),
Opt::PadRadialStick => tr(match s.controller.radial_stick {
gamepad::RadialStick::Right => "horizon.options.radial_stick.right",
gamepad::RadialStick::Left => "horizon.options.radial_stick.left",
gamepad::RadialStick::Either => "horizon.options.radial_stick.either",
}),
_ => Text::empty(),
}
}
/// The radial menu's rows of Controller Settings: a step, back or on.
pub(super) fn radial_step(self, s: &mut Settings, back: bool) {
let next = |n: u8, count: u8| {
if back {
(n + count - 1) % count
} else {
(n + 1) % count
}
};
match self {
Opt::PadRadialMode => {
let all = gamepad::RadialMode::ALL;
let i = all
.iter()
.position(|m| *m == s.controller.radial_mode)
.unwrap_or(0) as u8;
s.controller.radial_mode = all[usize::from(next(i, all.len() as u8))];
}
Opt::PadRadialStick => {
let all = gamepad::RadialStick::ALL;
let i = all
.iter()
.position(|k| *k == s.controller.radial_stick)
.unwrap_or(0) as u8;
// An opener under the thumb that now selects goes unbound.
s.controller
.set_radial_stick(all[usize::from(next(i, all.len() as u8))]);
}
_ => {}
}
}
}
@@ -124,21 +124,15 @@ impl OptionsUi {
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();
if s.extras.no_ai {
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
} else {
Row::Buttons(
open("horizon.pages.open.agent", Page::Agent),
Some(self.extra_button(s, crate::extras::Item::NoAi)),
)
},
];
self.added_entries(&mut rows);
rows
}
@@ -162,10 +156,38 @@ impl OptionsUi {
};
let mut rows = Vec::new();
registered.rows(&mut rows);
rows.into_iter()
.enumerate()
.map(|(r, row)| self.added_row(page, r, row))
.collect()
let mut out = Vec::new();
// The row a half-width button started, waiting for the next to sit beside it.
let mut half_open = false;
for (r, row) in rows.into_iter().enumerate() {
match row {
// Wrapped as the engine's own notes; space is for pages drawn as a screen.
added::Row::Text { text, .. } => {
out.extend(wrap(&text, 64).into_iter().map(Row::Note))
}
added::Row::Space(_) => continue,
added::Row::Button {
label,
active,
half: true,
..
} => {
let mut half = button(0.0, 0.0, 150.0, tr(label), Act::PageRow(page, r));
if !active {
half = inactive(half);
}
match out.last_mut() {
Some(Row::Buttons(_, second @ None)) if half_open => *second = Some(half),
_ => out.push(Row::Buttons(half, None)),
}
half_open = !half_open;
continue;
}
row => out.push(self.added_row(page, r, row)),
}
half_open = false;
}
out
}
fn added_row(&self, page: usize, r: usize, row: added::Row) -> Row {
@@ -177,7 +199,10 @@ impl OptionsUi {
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::Button { label, active, .. } => {
let wide = button(0.0, 0.0, 310.0, tr(label), act);
Row::Buttons(if active { wide } else { inactive(wide) }, None)
}
added::Row::Key { label, key } => {
let mut message = keybinds::display_name(&key);
if self.selected_key == Some(PAGE_KEY)
@@ -461,4 +486,84 @@ mod tests {
Some(tr("test.sample.switch.tooltip").plain())
);
}
/// Text, a grey note, space, two half buttons (the second inactive) and a footer button.
struct Shaped(Mutex<Vec<usize>>);
impl added::Page for Shaped {
fn id(&self) -> &'static str {
"test.shaped"
}
fn title(&self) -> &'static str {
"test.shaped.title"
}
fn place(&self) -> added::Place {
added::Place::HorizonSettings
}
fn rows(&self, rows: &mut Vec<added::Row>) {
rows.push(added::Row::text("ABCDE-FGHIJ").groups());
rows.push(added::Row::text("grey").quiet());
rows.push(added::Row::Space(6));
rows.push(added::Row::button("test.shaped.copy").half());
rows.push(added::Row::button("test.shaped.stop").half().active(false));
rows.push(added::Row::button("test.shaped.help").footer());
}
fn act(&self, row: usize, _: added::Act) {
self.0.lock().unwrap().push(row);
}
}
static SHAPED: Shaped = Shaped(Mutex::new(Vec::new()));
static SHAPED_PAGES: [&dyn added::Page; 1] = [&SHAPED];
#[test]
fn half_buttons_share_a_row_and_an_inactive_one_is_never_pressed() {
let mut s = Settings::default();
TEST_PAGES.set(Some(&SHAPED_PAGES));
let ui = OptionsUi::default();
let rows = ui.added_rows(0);
// The two texts as notes (space shows nothing here), the halves on one row, the
// footer button below them (the settings shell has no footer of its own).
assert!(matches!(&rows[0], Row::Note(t) if t == "ABCDE-FGHIJ"));
assert!(matches!(&rows[1], Row::Note(t) if t == "grey"));
let Row::Buttons(copy, Some(stop)) = &rows[2] else {
panic!("the halves sit side by side: {:?}", rows.len());
};
assert_eq!((copy.w, stop.w), (150.0, 150.0));
assert!(matches!(&rows[3], Row::Buttons(help, None) if help.w == 310.0));
assert_eq!(rows.len(), 4);
let mut ui = ui;
ui.show(Page::Added(0));
press(&mut ui, &mut s, Act::PageRow(0, 3), false);
press(&mut ui, &mut s, Act::PageRow(0, 5), false);
let (widgets, ..) = ui.layout(&s, 480.0, 240.0);
let stop = widgets
.iter()
.find(|w| {
matches!(
&w.kind,
WKind::Button {
act: Act::PageRow(0, 4),
..
}
)
})
.expect("Stop is drawn");
let atlas = super::super::tests::atlas();
ui.click(
&mut s,
&atlas,
stop.x + 1.0,
stop.y + 1.0,
(480.0, 240.0),
[480, 240],
false,
false,
);
assert_eq!(
*SHAPED.0.lock().unwrap(),
[3, 5],
"the inactive half heard nothing"
);
}
}
@@ -7,6 +7,14 @@ use horizon_polytone::colors::PackColors;
use crate::block_entity_render::DYES;
use crate::pack_palette::{self, Environment};
fn fluid(fog: horizon_polytone::colors::FluidFog) -> pack_palette::FluidFog {
pack_palette::FluidFog {
color: fog.color,
radius: fog.radius,
fade: fog.fade,
}
}
/// The colours of every namespace's file under the pack overlay at `root`.
pub fn read(root: &Path) -> pack_palette::PackColors {
let colors = PackColors::read(root);
@@ -29,6 +37,10 @@ pub fn read(root: &Path) -> pack_palette::PackColors {
water_fog_brightening_time: env.water_fog_brightening_time,
horizon_height: env.horizon_height,
},
fluid_fog: pack_palette::FluidFogs {
water: colors.fluid_fog.water.map(fluid),
lava: colors.fluid_fog.lava.map(fluid),
},
}
}
@@ -59,6 +59,24 @@ pub struct PackColors {
pub xp_bar: [Option<u32>; 2],
/// The effect fogs' settings: the fog reads them every frame.
pub env: Environment,
/// The fog the packs' fluid modifiers give water and lava.
pub fluid_fog: FluidFogs,
}
/// A fluid modifier's fog: a flat colour (RGB), and the fog's radius and fade multipliers (1 is
/// vanilla's).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FluidFog {
pub color: Option<u32>,
pub radius: f32,
pub fade: f32,
}
/// The fog the packs' fluid modifiers give water and lava.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct FluidFogs {
pub water: Option<FluidFog>,
pub lava: Option<FluidFog>,
}
impl Default for PackColors {
@@ -72,6 +90,7 @@ impl Default for PackColors {
entity_shadows: true,
xp_bar: [None; 2],
env: Environment::default(),
fluid_fog: FluidFogs::default(),
}
}
}
+152 -32
View File
@@ -7,7 +7,10 @@
//! sections are immutable behind `Arc` and edited through `Arc::make_mut`, so
//! a weak reference tells an edited or replaced section from the one the list
//! was built from (see `random_tick_sections`), without keeping its blocks.
use crate::{chunk::Section, fast_hash::FxHashMap};
use crate::{
chunk::{Chunk, Section},
fast_hash::FxHashMap,
};
use std::sync::{Arc, Mutex, Weak};
/// A section's POIs: the block index in the section (`y << 8 | z << 4 | x`)
@@ -18,13 +21,16 @@ struct Cached<T> {
section: Weak<Section>,
pois: SectionPois<T>,
}
/// Cached POI lists by section, `(chunk x, section index, chunk z)`.
/// Cached POI lists by chunk column `(chunk x, chunk z)`, each by section
/// index: a search through a column looks it up once.
pub struct PoiSections<T> {
sections: Mutex<Lists<T>>,
}
struct Lists<T> {
by_section: FxHashMap<(i32, i32, i32), Cached<T>>,
/// Size above which lists of sections no longer resident are dropped.
by_column: FxHashMap<(i32, i32), Vec<Option<Cached<T>>>>,
/// How many section lists are kept, and the count above which the lists
/// of sections no longer resident are dropped.
len: usize,
prune_at: usize,
}
const MIN_PRUNE: usize = 4096;
@@ -32,27 +38,61 @@ impl<T> Default for PoiSections<T> {
fn default() -> Self {
Self {
sections: Mutex::new(Lists {
by_section: FxHashMap::default(),
by_column: FxHashMap::default(),
len: 0,
prune_at: MIN_PRUNE,
}),
}
}
}
impl<T: Copy> PoiSections<T> {
/// The POIs of `section`, whose block states `kind` classifies. `kind`
/// must give the same answer for a state on every call.
pub fn get(
&self,
key: (i32, i32, i32),
section: &Arc<Section>,
mut kind: impl FnMut(u32) -> Option<T>,
) -> SectionPois<T> {
let mut lists = self.sections.lock().unwrap_or_else(|e| e.into_inner());
if let Some(cached) = lists.by_section.get(&key)
&& cached.section.as_ptr() == Arc::as_ptr(section)
{
return cached.pois.clone();
impl<T: Copy> Lists<T> {
/// The column's lists, for sections up to `last`, after dropping the
/// lists of sections no longer resident if there are too many.
fn column(
&mut self,
column: (i32, i32),
last: usize,
) -> (&mut [Option<Cached<T>>], &mut usize) {
if self.len >= self.prune_at {
self.prune();
}
let lists = self.by_column.entry(column).or_default();
if lists.len() <= last {
lists.resize_with(last + 1, || None);
}
(lists, &mut self.len)
}
/// Drops the lists of sections no longer resident.
fn prune(&mut self) {
let mut len = 0;
self.by_column.retain(|_, lists| {
for c in lists.iter_mut() {
if c.as_ref().is_some_and(|c| c.section.strong_count() == 0) {
*c = None;
}
}
let kept = lists.iter().filter(|c| c.is_some()).count();
len += kept;
kept > 0
});
self.len = len;
self.prune_at = (len * 2).max(MIN_PRUNE);
}
}
/// The POIs of `section` from `slot`, listed again (and kept there) unless
/// `slot` was listed from this very section; `len` counts the slots filled.
fn list<'a, T: Copy>(
slot: &'a mut Option<Cached<T>>,
len: &mut usize,
section: &Arc<Section>,
kind: &mut impl FnMut(u32) -> Option<T>,
) -> &'a SectionPois<T> {
let fresh = slot
.as_ref()
.is_some_and(|c| c.section.as_ptr() == Arc::as_ptr(section));
if !fresh {
let pois: SectionPois<T> = if section.blocks.maybe_has(|v| kind(v).is_some()) {
let mut states = [0u32; 4096];
section.unpack(&mut states);
@@ -64,18 +104,56 @@ impl<T: Copy> PoiSections<T> {
} else {
Arc::from([])
};
if lists.by_section.len() >= lists.prune_at {
lists.by_section.retain(|_, c| c.section.strong_count() > 0);
lists.prune_at = (lists.by_section.len() * 2).max(MIN_PRUNE);
*len += usize::from(slot.is_none());
*slot = Some(Cached {
section: Arc::downgrade(section),
pois,
});
}
&slot.as_ref().expect("just listed").pois
}
impl<T: Copy> PoiSections<T> {
/// The POIs of `section`, the `key.1`-th section of the column
/// `(key.0, key.2)`, whose block states `kind` classifies. `kind` must give
/// the same answer for a state on every call.
pub fn get(
&self,
key: (i32, i32, i32),
section: &Arc<Section>,
mut kind: impl FnMut(u32) -> Option<T>,
) -> SectionPois<T> {
let mut lists = self.sections.lock().unwrap_or_else(|e| e.into_inner());
let s = usize::try_from(key.1).expect("a section index");
let (column, len) = lists.column((key.0, key.2), s);
list(&mut column[s], len, section, &mut kind).clone()
}
/// The POIs of `chunk` from block Y `y0` to `y1`, its first section at
/// `min_y`, as [`Self::get`] gives them: each section that has any, with
/// the Y of its lowest blocks, in order, put in `out`. One lookup of the
/// column.
pub fn column(
&self,
chunk: &Chunk,
min_y: i32,
(y0, y1): (i32, i32),
mut kind: impl FnMut(u32) -> Option<T>,
out: &mut Vec<(i32, SectionPois<T>)>,
) {
out.clear();
let (first, last) = ((y0 - min_y) >> 4, (y1 - min_y) >> 4);
if first > last {
return;
}
let mut lists = self.sections.lock().unwrap_or_else(|e| e.into_inner());
let (slots, len) = lists.column((chunk.x, chunk.z), last as usize);
for s in first as usize..=last as usize {
let pois = list(&mut slots[s], len, &chunk.sections[s], &mut kind);
if !pois.is_empty() {
out.push((min_y + s as i32 * 16, pois.clone()));
}
}
lists.by_section.insert(
key,
Cached {
section: Arc::downgrade(section),
pois: pois.clone(),
},
);
pois
}
}
@@ -143,6 +221,48 @@ mod tests {
assert!(index.get((0, 0, 0), &chunk.sections[0], kind).is_empty());
}
/// A column gives each section's list as `get` does, the sections
/// without any left out, and keeps them until a section changes.
#[test]
fn a_column_gives_what_each_section_gives() {
let index = PoiSections::<u8>::default();
let kind = |v: u32| (v == 5).then_some(5);
let mut chunk = Arc::new(Chunk::empty(2, -3));
for (x, y, z) in [(1, MIN_Y + 2, 3), (4, MIN_Y + 40, 0), (0, MIN_Y + 41, 15)] {
Arc::make_mut(&mut chunk).set(x, y, z, 5);
}
let mut out = Vec::new();
index.column(&chunk, MIN_Y, (MIN_Y, MIN_Y + 63), kind, &mut out);
let bases: Vec<i32> = out.iter().map(|(base, _)| *base).collect();
assert_eq!(bases, [MIN_Y, MIN_Y + 32]);
for (base, pois) in &out {
let s = (base - MIN_Y) / 16;
let one = index.get((2, s, -3), &chunk.sections[s as usize], kind);
assert!(Arc::ptr_eq(pois, &one));
}
// Kept: no rescan.
index.column(
&chunk,
MIN_Y,
(MIN_Y, MIN_Y + 63),
|_| panic!("rescanned"),
&mut out,
);
assert_eq!(out.len(), 2);
// An edit lists that section again.
Arc::make_mut(&mut chunk).set(4, MIN_Y + 40, 0, 1);
let mut rescans = 0;
let counted = |v: u32| {
rescans += 1;
kind(v)
};
index.column(&chunk, MIN_Y, (MIN_Y + 20, MIN_Y + 63), counted, &mut out);
assert!(rescans > 0);
assert_eq!(out.len(), 1);
assert_eq!(out[0].0, MIN_Y + 32);
assert_eq!(&*out[0].1, &[(9 << 8 | 15 << 4, 5)]);
}
#[test]
fn unloaded_sections_are_dropped() {
let index = PoiSections::<u8>::default();
@@ -153,7 +273,7 @@ mod tests {
index.get((1, i, 0), &Arc::new(Section::default()), kind);
}
let lists = index.sections.lock().unwrap();
assert!(lists.by_section.len() <= 2);
assert!(lists.by_section.contains_key(&(0, 0, 0)));
assert!(lists.len <= 2);
assert!(lists.by_column[&(0, 0)][0].is_some());
}
}
@@ -0,0 +1,844 @@
//! The radial menu's pages in the controller settings (Horizon's own; `radial.rs` has their
//! rules, `radial_overlay.rs` draws them in play), on the settings shell (`settings_shell`): a
//! sheet per page with its eight slots, what changes the page, and what changes them all. A
//! page's own name is typed on a small page of its own, [`NameEntry`].
use crate::{
gamepad::{self, GlyphOf, MenuJob},
graphics_style::{self as style, BUTTON_H},
hud::{HudAtlas, HudBatch},
lang,
options_ui::{Event, Page},
pad_glyph::{Glyph, Glyphs},
radial::{self, Pages, RadialAction, SLOTS, Slot},
radial_overlay::{page_label, slot_caption},
settings::Settings,
settings_shell::{
self as shell, Binding, Capture, Group, Output, Reset, Row, Screen, Sheet, Shown, Value,
},
text::Text,
ui,
};
const TITLE: &str = "horizon.pages.controller_radial";
const SLOTS_GROUP: &str = "horizon.radial.group.slots";
const PAGE_GROUP: &str = "horizon.radial.group.page";
const ALL_GROUP: &str = "horizon.radial.group.all";
/// The opener's row binds the menu's own pad action, in place.
const OPENER: &str = gamepad::RADIAL;
/// A row of a page; `usize`s are the page and the slot.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum RadialRow {
Slot(usize, usize),
Rename(usize),
NewPage,
Delete(usize),
Reset,
Opener,
}
/// A slot's action, that it holds none, or that its action is missing.
fn slot_label(slot: &Slot) -> String {
slot_caption(slot).unwrap_or_else(|| lang::get("horizon.radial.empty"))
}
/// What the slot after `slot` (before, `back`) holds: Empty between the last action and the first.
/// A missing action steps as Empty does.
fn step(slot: &Slot, back: bool) -> Slot {
let order: Vec<_> = std::iter::once(None)
.chain(RadialAction::ALL.map(Some))
.collect();
let at = order
.iter()
.position(|entry| *entry == slot.action())
.unwrap_or(0);
let next = if back { at + order.len() - 1 } else { at + 1 };
order[next % order.len()].into()
}
fn page_of(page: usize, pages: usize) -> String {
lang::tr("horizon.radial.page_of", &[&(page + 1), &pages])
}
/// The opener's row: a binding of the menu's own pad action, in place, on its button's art. Its
/// strip says what stands in the way (no button, or one another action shares); while it waits,
/// the shell says how to bind it.
fn opener_row(s: &Settings, glyphs: Glyphs) -> Row<RadialRow> {
let pad = &s.controller;
// Through the player's glyphs, so a Nintendo pad going by its letters names its own button.
let glyph = Glyph::of(GlyphOf::Bound(OPENER), pad, glyphs);
let warning = match pad.binding(OPENER) {
None => Some(lang::get("horizon.radial.unbound")),
Some(_) => pad
.conflicts(OPENER)
.then(|| lang::get("horizon.radial.conflict")),
};
let binding = Binding {
capture: Capture::Pad(OPENER),
shown: Shown::Pad {
glyph,
cancel: Glyph::menu(MenuJob::Start, glyphs),
},
shared: Vec::new(),
// The default (D-pad Right) is refused with some sticks: no reset, only a new button.
reset: Reset::None,
};
let name = lang::get("horizon.radial.opener");
Row {
chosen: warning,
..Row::new(RadialRow::Opener, name, Value::Binding(binding))
.about(lang::get(opener_about(pad.radial_stick)))
}
}
/// What the opener's row says it can be, with the stick that selects: never under that thumb.
fn opener_about(stick: gamepad::RadialStick) -> &'static str {
match stick {
gamepad::RadialStick::Right => "horizon.radial.opener.about",
gamepad::RadialStick::Left => "horizon.radial.opener.about.left",
gamepad::RadialStick::Either => "horizon.radial.opener.about.either",
}
}
/// The page's rows: its slots, then what changes it.
fn page_groups(pages: &Pages, page: usize) -> [Group<RadialRow>; 2] {
let slots = (0..SLOTS).map(|slot| {
let name = lang::get(&format!("horizon.radial.slot.{slot}"));
let cell = pages.page(page).slot(slot);
let value = Value::Choice(slot_label(cell));
let missing = match cell {
Slot::Missing(id) => Some(lang::tr("horizon.radial.missing.about", &[id])),
_ => None,
};
Row {
chosen: missing,
..Row::new(RadialRow::Slot(page, slot), name, value)
.about(lang::get("horizon.radial.slot.about"))
}
});
let full = (pages.len() >= radial::MAX_PAGES)
.then(|| lang::tr("horizon.radial.new_page.full", &[&radial::MAX_PAGES]));
let last = (pages.len() <= 1).then(|| lang::get("horizon.radial.delete_page.last"));
let changes = vec![
Row::new(
RadialRow::Rename(page),
lang::get("horizon.radial.rename"),
Value::Press,
)
.about(lang::get("horizon.radial.rename.about")),
Row::new(
RadialRow::NewPage,
lang::get(radial::NEW_PAGE_NAME),
Value::Press,
)
.about(lang::get("horizon.radial.new_page.about"))
.blocked(full),
Row::new(
RadialRow::Delete(page),
lang::get("horizon.radial.delete_page"),
Value::Press,
)
.about(lang::get("horizon.radial.delete_page.about"))
.blocked(last),
];
[
Group {
name: SLOTS_GROUP,
rows: slots.collect(),
},
Group {
name: PAGE_GROUP,
rows: changes,
},
]
}
/// The screen's own state; the pages are the player's settings.
#[derive(Default)]
pub(crate) struct RadialScreen {
/// The buttons' names follow the controller in use: the options screen says which.
pub(crate) glyphs: Glyphs,
/// The page to show next: one just added, or where a deleted one was.
show: Option<usize>,
/// The page whose name is being typed, on its own page (`Page::ControllerRadialName`).
pub(crate) naming: Option<NameEntry>,
/// The slot (page, slot) whose list of actions opens next (`Page::ControllerRadialPick`).
pub(crate) picking: Option<(usize, usize)>,
}
impl RadialScreen {
/// The page the shell should show now, once.
pub(crate) fn take_shown(&mut self) -> Option<usize> {
self.show.take()
}
}
impl Screen for RadialScreen {
type Page = usize;
type Row = RadialRow;
fn title(&self) -> &'static str {
TITLE
}
/// The rows read the pages, the opener's button, the stick that selects and the glyphs it is
/// named with.
fn stamp(&self, s: &Settings) -> u64 {
let pad = &s.controller;
let opener = (pad.binding(OPENER), pad.conflicts(OPENER));
shell::stamp_of(&(&pad.radial, opener, pad.radial_stick, self.glyphs))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<usize, RadialRow>> {
let pages = &s.controller.radial;
let default = *pages == Pages::default();
let all = Group {
name: ALL_GROUP,
rows: vec![
Row::new(
RadialRow::Reset,
lang::get("horizon.radial.reset"),
Value::Press,
)
.about(lang::get("horizon.radial.reset.about"))
.blocked(default.then(|| lang::get("horizon.radial.reset.default"))),
opener_row(s, self.glyphs),
],
};
(0..pages.len())
.map(|page| {
let [slots, changes] = page_groups(pages, page);
let all = Group {
name: all.name,
rows: all.rows.clone(),
};
Sheet::new(
page,
page_label(pages.page(page)),
page_of(page, pages.len()),
vec![slots, changes, all],
)
})
.collect()
}
fn press(&mut self, s: &mut Settings, row: RadialRow, back: bool) -> Vec<Output> {
let pages = &mut s.controller.radial;
match row {
RadialRow::Slot(page, slot) => {
let next = step(pages.page(page).slot(slot), back);
pages.set_slot(page, slot, next);
}
RadialRow::Rename(page) => {
self.naming = Some(NameEntry::new(page, &page_label(pages.page(page))));
return vec![Output::Push(Page::ControllerRadialName)];
}
RadialRow::NewPage => self.show = pages.add(),
RadialRow::Delete(page) => {
pages.remove(page);
self.show = Some(pages.clamp(page));
}
RadialRow::Reset => {
*pages = Pages::default();
self.show = Some(0);
}
// The shell starts its wait (`gamepad::opener_refused` refused, Start cancels).
RadialRow::Opener => return Vec::new(),
}
vec![Output::Event(Event::Changed)]
}
/// A press on a slot opens the list of every action for it; Left and Right still step it.
fn choose(&mut self, _s: &mut Settings, row: RadialRow) -> Option<Vec<Output>> {
let RadialRow::Slot(page, slot) = row else {
return None;
};
self.picking = Some((page, slot));
Some(vec![Output::Push(Page::ControllerRadialPick)])
}
/// The wheel over a slot steps it, as a press does.
fn wheel_step(&mut self, s: &mut Settings, row: RadialRow, back: bool) -> Option<Vec<Output>> {
matches!(row, RadialRow::Slot(..)).then(|| self.press(s, row, back))
}
/// Undo is how a delete or a reset is taken back: they act at once.
fn undoes(&self) -> bool {
true
}
/// The pages, and the opener's button, which its row binds.
fn undo(&mut self, s: &mut Settings, before: &Settings) -> Vec<Output> {
s.controller.radial = before.controller.radial.clone();
s.controller.bind(OPENER, before.controller.binding(OPENER));
Vec::new()
}
}
const PICK_TITLE: &str = "horizon.radial.pick_title";
const PICK_GROUP: &str = "horizon.radial.group.actions";
/// A row of a slot's list: the line naming the slot, then Empty and every action.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PickRow {
Slot,
Pick(Option<RadialAction>),
}
/// One slot's list of every action and Empty, on the settings shell: it names the slot, marks
/// what the slot holds and opens on it; a press puts that in the slot and goes back.
#[derive(Default)]
pub(crate) struct PickScreen {
/// The page and the slot.
at: (usize, usize),
picked: bool,
}
impl PickScreen {
/// For `at`; the row the focus opens on, what the slot holds.
pub(crate) fn open_on(&mut self, at: (usize, usize), pages: &Pages) -> PickRow {
*self = Self { at, picked: false };
self.current(pages).unwrap_or(PickRow::Pick(None))
}
/// The page and the slot it is for.
pub(crate) fn at(&self) -> (usize, usize) {
self.at
}
/// A pick changed the pages since the last call, which the editor's Undo takes back.
pub(crate) fn take_picked(&mut self) -> bool {
std::mem::take(&mut self.picked)
}
/// The row of what the slot holds; none for an action this game doesn't know.
fn current(&self, pages: &Pages) -> Option<PickRow> {
match pages.page(self.at.0).slot(self.at.1) {
Slot::Empty => Some(PickRow::Pick(None)),
Slot::Action(action) => Some(PickRow::Pick(Some(*action))),
Slot::Missing(_) => None,
}
}
}
impl Screen for PickScreen {
type Page = ();
type Row = PickRow;
fn title(&self) -> &'static str {
PICK_TITLE
}
fn stamp(&self, s: &Settings) -> u64 {
let (page, slot) = self.at;
shell::stamp_of(&(self.at, s.controller.radial.page(page).slot(slot)))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), PickRow>> {
let pages = &s.controller.radial;
let (page, slot) = self.at;
let direction = lang::get(&format!("horizon.radial.slot.{slot}"));
let named = lang::tr(
"horizon.radial.pick.slot",
&[&direction, &page_label(pages.page(page))],
);
let current = self.current(pages);
let about = lang::get("horizon.radial.pick.about");
let mut rows =
vec![Row::new(PickRow::Slot, named.clone(), Value::Note).about(about.clone())];
for action in std::iter::once(None).chain(RadialAction::ALL.map(Some)) {
let id = PickRow::Pick(action);
let value = if current == Some(id) {
Value::Choice(lang::get("horizon.radial.pick.current"))
} else {
Value::Press
};
let name = slot_label(&action.into());
rows.push(Row::new(id, name, value).about(about.clone()));
}
vec![Sheet::new(
(),
named,
about,
vec![Group {
name: PICK_GROUP,
rows,
}],
)]
}
fn press(&mut self, s: &mut Settings, row: PickRow, _back: bool) -> Vec<Output> {
let PickRow::Pick(action) = row else {
return Vec::new();
};
let (page, slot) = self.at;
s.controller.radial.set_slot(page, slot, action.into());
self.picked = true;
vec![Output::Event(Event::Changed), Output::Done]
}
}
/// The name field and its buttons, centred in the screen but under the title, one row apart.
const NAME_W: f32 = 200.0;
const NAME_BLOCK_H: f32 = 100.0;
const NAME_TOP_MIN: f32 = 30.0;
const NAME_STEP: f32 = 25.0;
/// What a press on the name's page did.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Named {
Nothing,
/// Done: the page takes the name.
Done,
/// Cancel, keeping the old name.
Back,
}
/// A page's name being typed.
pub(crate) struct NameEntry {
page: usize,
field: ui::EditBox,
}
impl NameEntry {
fn new(page: usize, name: &str) -> Self {
let mut field = ui::EditBox::new(0.0, 0.0, NAME_W, BUTTON_H);
field.max_length = radial::NAME_MAX;
field.set_value(name);
field.focused = true;
Self { page, field }
}
/// Done and Cancel, under the field.
fn buttons(w: f32, h: f32) -> [([f32; 4], Named); 2] {
let (x, top) = ((w / 2.0).floor() - NAME_W / 2.0, name_top(h));
let at = |row: f32| [x, top + row * NAME_STEP, NAME_W, BUTTON_H];
[(at(1.0), Named::Done), (at(2.0), Named::Back)]
}
pub(crate) fn draw(
&mut self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
in_world: bool,
) {
let (w, h) = (batch.width.ceil(), batch.height.ceil());
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
let title = Text::literal(lang::get("horizon.radial.name_title"));
style::title(batch, atlas, &title);
let field = &mut self.field;
let (x, top) = ((w / 2.0).floor() - NAME_W / 2.0, name_top(h));
(field.x, field.y, field.width, field.height) = (x, top, NAME_W, BUTTON_H);
field.draw(batch, atlas, None);
for ([x, y, bw, bh], named) in Self::buttons(w, h) {
let (label, action) = match named {
Named::Done => ("gui.done", crate::widgets::Action::Done),
_ => ("gui.cancel", crate::widgets::Action::Cancel),
};
let hover = ui::hovered(cursor, x, y, bw, bh);
let text = Text::literal(lang::get(label));
ui::action_button(batch, atlas, action, x, y, bw, bh, &text, true, hover);
}
}
/// A press at `(x, y)` on a screen `w` x `h`.
pub(crate) fn click(
&mut self,
atlas: &HudAtlas,
(x, y): (f32, f32),
(w, h): (f32, f32),
) -> Named {
let hit = Self::buttons(w, h)
.into_iter()
.find(|([bx, by, bw, bh], _)| ui::hovered((x, y), *bx, *by, *bw, *bh));
if let Some((_, named)) = hit {
return named;
}
let field = &mut self.field;
field.focused = ui::hovered((x, y), field.x, field.y, field.width, field.height);
if field.focused {
field.click(atlas, x, false, false);
}
Named::Nothing
}
/// The name field takes the keys.
pub(crate) fn typing(&self) -> bool {
self.field.focused
}
pub(crate) fn type_text(&mut self, text: &str) {
if self.field.focused {
self.field.insert_text(text);
}
}
/// A key: Enter is Done, Escape is Cancel, the rest the field's.
pub(crate) fn key(&mut self, key: winit::keyboard::KeyCode, text: Option<&str>) -> Named {
use winit::keyboard::KeyCode as K;
match key {
K::Escape => return Named::Back,
K::Enter | K::NumpadEnter => return Named::Done,
_ if self.field.focused => {
if !self.field.key(key, false, false)
&& let Some(t) = text
{
let typed: String = t.chars().filter(|c| !c.is_control()).collect();
self.field.insert_text(&typed);
}
}
_ => {}
}
Named::Nothing
}
/// Ctrl+A/C/V/X in the name field; false when it hasn't the focus.
pub(crate) fn shortcut(
&mut self,
shortcut: crate::clipboard::EditShortcut,
clipboard: &mut dyn crate::clipboard::Clipboard,
) -> bool {
if self.field.focused {
self.field.shortcut(shortcut, clipboard);
}
self.field.focused
}
/// The name into the pages. Left as it was, a default page keeps following the language.
pub(crate) fn apply(&self, pages: &mut Pages) -> bool {
let changed = self.field.value != page_label(pages.page(self.page));
if changed {
pages.rename(self.page, &self.field.value);
}
changed
}
}
/// The name field's top.
fn name_top(h: f32) -> f32 {
((h - NAME_BLOCK_H) / 2.0).floor().max(NAME_TOP_MIN)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gamepad::Button as PadButton;
use crate::radial::PageName;
use crate::settings_shell::{Hosted, Shell};
use winit::keyboard::KeyCode as K;
const SIZE: (f32, f32) = (854.0, 480.0);
fn rows_of(s: &Settings, page: usize) -> Vec<Row<RadialRow>> {
let sheets = RadialScreen::default().sheets(s);
let groups = sheets.into_iter().nth(page).unwrap().groups;
groups.into_iter().flat_map(|group| group.rows).collect()
}
fn row(s: &Settings, id: RadialRow) -> Row<RadialRow> {
let page = match id {
RadialRow::Slot(page, _) | RadialRow::Rename(page) | RadialRow::Delete(page) => page,
_ => 0,
};
rows_of(s, page).into_iter().find(|r| r.id == id).unwrap()
}
/// A click on `id` through the shell, as the mouse or a controller's A gives it.
fn click(shell: &mut Shell<RadialScreen>, s: &mut Settings, id: RadialRow) -> Vec<Output> {
let at = shell.point_at(s, SIZE, id);
shell.click(s, at, SIZE, false, false, false)
}
/// Undo, at the foot over Done.
fn undo(shell: &mut Shell<RadialScreen>, s: &mut Settings) {
let [x, y, ..] = shell::Columns::of(SIZE.0, SIZE.1).button(1);
shell.click(s, (x + 2.0, y + 2.0), SIZE, false, false, false);
}
#[test]
fn a_slot_steps_through_empty_and_every_action_both_ways() {
let mut seen = vec![Slot::Empty];
let mut slot = Slot::Empty;
for _ in RadialAction::ALL {
slot = step(&slot, false);
seen.push(slot.clone());
}
assert_eq!(step(&slot, false), Slot::Empty, "the last wraps to Empty");
let actions: Vec<_> = seen.iter().filter_map(Slot::action).collect();
assert_eq!(actions, RadialAction::ALL.to_vec());
assert_eq!(
step(&Slot::Empty, true).action(),
RadialAction::ALL.last().copied()
);
let chat = Slot::Action(RadialAction::Chat);
assert_eq!(step(&step(&chat, true), false), chat);
// A missing action steps on as Empty would.
let missing = Slot::Missing("mymod:dash".to_owned());
assert_eq!(step(&missing, false), step(&Slot::Empty, false));
}
#[test]
fn a_press_on_a_slot_opens_its_list_and_left_and_right_still_step_it() {
let mut s = Settings::default();
let mut shell = Shell::<RadialScreen>::default();
let before = s.controller.radial.clone();
let outputs = click(&mut shell, &mut s, RadialRow::Slot(0, 2));
assert!(outputs.contains(&Output::Push(Page::ControllerRadialPick)));
assert_eq!(shell.screen.picking, Some((0, 2)));
assert_eq!(
s.controller.radial, before,
"the press itself changes nothing"
);
// Right on the focused row steps it.
shell.set_focus(Some(shell::Target::Row(RadialRow::Slot(0, 2))));
shell.key(&mut s, K::ArrowRight, None, false, SIZE);
let stepped = step(before.page(0).slot(2), false);
assert_eq!(*s.controller.radial.page(0).slot(2), stepped);
}
#[test]
fn the_list_names_the_slot_marks_its_action_and_a_pick_fills_it() {
let mut s = Settings::default();
let mut picker = PickScreen::default();
// General's right slot holds Commands.
let opens_on = picker.open_on((0, 2), &s.controller.radial);
assert_eq!(opens_on, PickRow::Pick(Some(RadialAction::Command)));
let rows: Vec<_> = picker
.sheets(&s)
.into_iter()
.flat_map(|sheet| sheet.groups)
.flat_map(|group| group.rows)
.collect();
assert_eq!(rows[0].id, PickRow::Slot);
assert_eq!(rows[0].value, Value::Note);
// Empty, then every action, each once; only the slot's own marked.
let picks: Vec<_> = rows[1..].iter().map(|row| row.id).collect();
let every: Vec<_> = std::iter::once(None)
.chain(RadialAction::ALL.map(Some))
.map(PickRow::Pick)
.collect();
assert_eq!(picks, every);
let marked: Vec<_> = rows
.iter()
.filter(|row| matches!(row.value, Value::Choice(_)))
.map(|row| row.id)
.collect();
assert_eq!(marked, [opens_on]);
let outputs = picker.press(&mut s, PickRow::Pick(Some(RadialAction::Chat)), false);
assert_eq!(outputs, [Output::Event(Event::Changed), Output::Done]);
assert_eq!(
*s.controller.radial.page(0).slot(2),
Slot::Action(RadialAction::Chat)
);
assert!(picker.take_picked() && !picker.take_picked());
// Empty empties it.
picker.press(&mut s, PickRow::Pick(None), false);
assert!(s.controller.radial.page(0).slot(2).is_empty());
}
#[test]
fn a_missing_action_says_which_it_is() {
let mut s = Settings::default();
s.controller
.radial
.set_slot(0, 1, Slot::Missing("mymod:dash".to_owned()));
let english = lang::Language::load(&[], "en_us");
let (chosen, _) = lang::scoped(english, || row(&s, RadialRow::Slot(0, 1)).chosen);
assert!(chosen.is_some_and(|text| text.contains("mymod:dash")));
}
#[test]
fn a_slot_changes_alone_and_undo_puts_back_what_the_screen_found() {
let mut s = Settings::default();
let mut shell = Shell::<RadialScreen>::default();
let before = s.controller.radial.page(0).clone();
shell.set_focus(Some(shell::Target::Row(RadialRow::Slot(0, 0))));
let outputs = shell.key(&mut s, K::ArrowRight, None, false, SIZE);
assert!(outputs.contains(&Output::Event(Event::Changed)));
let after = s.controller.radial.page(0);
assert_eq!(*after.slot(0), step(before.slot(0), false));
assert_eq!(after.slots()[1..], before.slots()[1..]);
assert!(shell.changed());
undo(&mut shell, &mut s);
assert_eq!(s.controller.radial, Pages::default());
assert!(!shell.changed());
}
#[test]
fn delete_and_reset_act_at_once_and_undo_takes_them_back() {
let mut s = Settings::default();
s.controller.radial.rename(1, "Mine");
let found = s.controller.radial.clone();
let mut shell = Shell::<RadialScreen>::default();
click(&mut shell, &mut s, RadialRow::Delete(0));
assert_eq!(s.controller.radial.len(), 1);
assert_eq!(page_label(s.controller.radial.page(0)), "Mine");
// One page left: it can't go, and the strip says why.
let delete = row(&s, RadialRow::Delete(0));
assert!(!delete.live && delete.why.is_some());
let reset = row(&s, RadialRow::Reset);
assert!(reset.live);
shell.screen.take_shown();
click(&mut shell, &mut s, RadialRow::Reset);
assert_eq!(s.controller.radial, Pages::default());
assert!(!row(&s, RadialRow::Reset).live, "already the defaults");
assert_eq!(shell.screen.take_shown(), Some(0));
undo(&mut shell, &mut s);
assert_eq!(s.controller.radial, found);
}
#[test]
fn new_pages_stop_at_the_most_and_the_new_one_shows() {
let mut s = Settings::default();
let mut screen = RadialScreen::default();
while row(&s, RadialRow::NewPage).live {
screen.press(&mut s, RadialRow::NewPage, false);
assert_eq!(screen.take_shown(), Some(s.controller.radial.len() - 1));
}
assert_eq!(s.controller.radial.len(), radial::MAX_PAGES);
assert!(row(&s, RadialRow::NewPage).why.is_some());
// Every page is a sheet, named as the overlay names it.
let sheets = screen.sheets(&s);
assert_eq!(sheets.len(), radial::MAX_PAGES);
assert_eq!(sheets[0].name, page_label(s.controller.radial.page(0)));
}
#[test]
fn a_deleted_last_page_leaves_the_one_before_it_showing() {
let mut s = Settings::default();
let mut screen = RadialScreen::default();
screen.press(&mut s, RadialRow::Delete(1), false);
assert_eq!(screen.take_shown(), Some(0));
}
#[test]
fn rename_opens_the_name_page_and_only_done_with_a_new_name_renames() {
let general = Pages::default().page(0).name.clone();
let mut s = Settings::default();
let mut screen = RadialScreen::default();
let outputs = screen.press(&mut s, RadialRow::Rename(0), false);
assert_eq!(outputs, vec![Output::Push(Page::ControllerRadialName)]);
let entry = screen.naming.as_mut().unwrap();
// Done with the name as it was: a default page keeps following the language.
assert_eq!(entry.key(K::Enter, None), Named::Done);
assert!(!entry.apply(&mut s.controller.radial));
assert_eq!(s.controller.radial.page(0).name, general);
entry.type_text("!");
assert_eq!(entry.key(K::Escape, None), Named::Back);
entry.field.set_value("Mine");
entry.type_text("!");
assert!(entry.apply(&mut s.controller.radial));
assert_eq!(
s.controller.radial.page(0).name,
PageName::Custom("Mine!".to_owned())
);
}
#[test]
fn undo_puts_back_a_name_given_on_the_name_page() {
let mut s = Settings::default();
s.controller.radial.rename(1, "Mine");
let mut shell = Shell::<RadialScreen>::default();
shell.show(1);
click(&mut shell, &mut s, RadialRow::Rename(1));
let entry = shell.screen.naming.as_mut().unwrap();
entry.field.set_value("Building");
// As the options screen does on Done: the name page changed what Undo puts back.
let entry = shell.screen.naming.take().unwrap();
assert!(entry.apply(&mut s.controller.radial));
shell.changed_elsewhere();
assert_eq!(page_label(s.controller.radial.page(1)), "Building");
assert!(shell.changed());
undo(&mut shell, &mut s);
assert_eq!(page_label(s.controller.radial.page(1)), "Mine");
assert!(!shell.changed());
}
#[test]
fn a_nintendo_pad_going_by_its_letters_names_its_own_opener() {
let mut s = Settings::default();
// Selecting with the left stick, the top face button (North) can open it.
s.controller.radial_stick = gamepad::RadialStick::Left;
s.controller.bind(OPENER, Some(gamepad::Button::North));
s.controller.glyphs = gamepad::GlyphStyle::Nintendo;
let label = |s: &Settings| {
let glyphs = Glyphs::of(&s.controller, gamepad::GlyphStyle::Nintendo);
match opener_row(s, glyphs).value {
Value::Binding(Binding {
shown: Shown::Pad { glyph, .. },
..
}) => glyph.expect("bound").label().to_owned(),
other => panic!("{other:?}"),
}
};
// By places, the top button: printed X on a Nintendo pad.
s.controller.nintendo_letters = false;
assert_eq!(label(&s), "X");
// By letters, the part is played by the button printed Y (on the left).
s.controller.nintendo_letters = true;
assert_eq!(label(&s), "Y");
}
#[test]
fn the_opener_row_names_its_button_and_says_what_gets_in_the_way() {
let mut s = Settings::default();
let glyphs = Glyphs::of(&s.controller, gamepad::GlyphStyle::Xbox);
let shown = |s: &Settings| match opener_row(s, glyphs).value {
Value::Binding(Binding {
capture,
shown: Shown::Pad { glyph, .. },
..
}) => {
assert_eq!(capture, Capture::Pad(OPENER));
glyph
}
other => panic!("{other:?}"),
};
let bound = s.controller.binding(OPENER).unwrap();
// The button on its glyph.
let glyph = shown(&s).expect("the opener's glyph");
assert_eq!(glyph.part(), bound);
assert_eq!(glyph.label(), "→");
assert_eq!(opener_row(&s, glyphs).chosen, None);
s.controller.bind("key.chat", Some(bound));
let shared = opener_row(&s, glyphs).chosen;
assert!(shared.is_some(), "a shared button");
s.controller.bind(OPENER, None);
let unbound = opener_row(&s, glyphs).chosen;
assert!(unbound.is_some() && unbound != shared && shown(&s).is_none());
// A press waits for a button on the row, and Undo takes a new one back.
let mut shell = Shell::<RadialScreen>::default();
s.controller.bind(OPENER, Some(bound));
let outputs = click(&mut shell, &mut s, RadialRow::Opener);
assert!(outputs.contains(&Output::Bind(Capture::Pad(OPENER))));
assert!(shell.changed());
s.controller.bind(OPENER, Some(PadButton::North));
undo(&mut shell, &mut s);
assert_eq!(s.controller.binding(OPENER), Some(bound));
}
#[test]
fn every_string_has_a_horizon_key() {
let atlas =
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
let english = lang::Language::load(&[], "en_us");
let ((), missed) = lang::scoped(english, || {
let mut s = Settings::default();
s.controller.bind("key.chat", Some(PadButton::DPadRight));
let mut shell = Shell::<RadialScreen>::default();
let draw = |shell: &mut Shell<RadialScreen>, s: &Settings| {
let mut batch = HudBatch::new([854, 480]);
shell.draw(s, &mut batch, &atlas, (0.0, 0.0), false, None);
};
// Every page's rows are built, with what the strip says of each.
draw(&mut shell, &s);
s.controller.bind(OPENER, None);
s.controller.radial.remove(1);
while s.controller.radial.add().is_some() {}
draw(&mut shell, &s);
s.controller.radial = Pages::default();
s.controller.radial.remove(1);
draw(&mut shell, &s);
let mut batch = HudBatch::new([854, 480]);
let waiting = Some(Capture::Pad(OPENER));
shell.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, waiting);
let mut entry = NameEntry::new(0, "Mine");
let mut batch = HudBatch::new([854, 480]);
entry.draw(&mut batch, &atlas, (0.0, 0.0), false);
let mut picker = Shell::<PickScreen>::default();
picker.screen.open_on((0, 1), &s.controller.radial);
let mut batch = HudBatch::new([854, 480]);
picker.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, None);
});
// Vanilla's own (gui.done...) come from the game's resources, not bundled ones.
let missed: Vec<_> = missed
.iter()
.filter(|key| key.starts_with("horizon.") || !key.contains('.'))
.collect();
assert!(missed.is_empty(), "no text for {missed:?}");
}
}
File diff suppressed because it is too large. Load diff
@@ -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()
@@ -862,6 +869,10 @@ pub enum Opt {
PadNintendoButtons,
PadKeyboard,
PadGameplayGuide,
/// The radial menu's opener held or pressed once (`PadSettings::radial_mode`).
PadRadialMode,
/// The stick the radial menu selects with (`PadSettings::radial_stick`).
PadRadialStick,
/// `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).
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,815 @@
use super::*;
use crate::options_ui::Event;
use winit::keyboard::KeyCode as K;
const SIZE: (f32, f32) = (854.0, 480.0);
const SHORT: (f32, f32) = (480.0, 240.0);
const CHANGED: Output = Output::Event(Event::Changed);
/// A screen with what Extras doesn't have: two pages, a slider, a value with descriptions of
/// its own, and a row a wheel steps.
#[derive(Default)]
struct Probe {
slider: f32,
choice: usize,
pressed: Vec<(u32, bool)>,
/// How many times its rows were built.
built: std::cell::Cell<usize>,
}
const CHOICES: [(&str, &str); 2] = [("Red", "Paints it red."), ("Blue", "Paints it blue.")];
const SLIDER: u32 = 0;
const CHOICE: u32 = 1;
const FIRST_FILLER: u32 = 10;
const FILLERS: u32 = 40;
const GREY: u32 = 100;
const WHY: &str = "Not here.";
impl Screen for Probe {
type Page = u8;
type Row = u32;
fn title(&self) -> &'static str {
"Probe"
}
/// Its rows read the field of view, besides its own state.
fn stamp(&self, s: &Settings) -> u64 {
stamp_of(&s.fov)
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<u8, u32>> {
self.built.set(self.built.get() + 1);
let (value, chosen) = CHOICES[self.choice];
let mut rows = vec![
Row::new(
SLIDER,
"Level".to_owned(),
Value::Slider {
position: self.slider,
steps: Some(10),
text: s.fov.to_string(),
},
),
Row {
chosen: Some(chosen.to_owned()),
..Row::new(CHOICE, "Paint".to_owned(), Value::Choice(value.to_owned()))
.about("Picks a colour.".to_owned())
},
Row::new(GREY, "Greyed".to_owned(), Value::Press)
.about("Does a thing.".to_owned())
.blocked(Some(WHY.to_owned())),
];
rows.extend((0..FILLERS).map(|i| {
Row::new(FIRST_FILLER + i, format!("Filler {i}"), Value::Press)
.about("Does nothing.".to_owned())
}));
let elsewhere = vec![Row::new(99, "Elsewhere".to_owned(), Value::Press)];
vec![
Sheet::new(
0,
"One".to_owned(),
"The first.".to_owned(),
vec![Group { name: "G", rows }],
),
Sheet::new(
1,
"Two".to_owned(),
"The second.".to_owned(),
vec![Group {
name: "H",
rows: elsewhere,
}],
),
]
}
fn press(&mut self, s: &mut Settings, row: u32, back: bool) -> Vec<Output> {
self.pressed.push((row, back));
match row {
CHOICE => self.choice = (self.choice + 1) % CHOICES.len(),
FIRST_FILLER => s.fov += 1.0,
_ => {}
}
vec![CHANGED]
}
fn undoes(&self) -> bool {
true
}
fn undo(&mut self, s: &mut Settings, before: &Settings) -> Vec<Output> {
s.fov = before.fov;
Vec::new()
}
fn slide(&mut self, _: &mut Settings, _: u32, position: f32) {
self.slider = position;
}
fn wheel_step(&mut self, _: &mut Settings, row: u32, _: bool) -> Option<Vec<Output>> {
(row == CHOICE).then(|| {
self.choice = (self.choice + 1) % CHOICES.len();
vec![CHANGED]
})
}
}
fn middle_of(shell: &mut Shell<Probe>, row: u32, size: (f32, f32)) -> (f32, f32) {
shell.point_at(&Settings::default(), size, row)
}
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
/// What one frame asks of a screen: a draw, then the stick's scroll, an arrow key and the wheel
/// away from any row, at the size it was drawn at.
fn frame(shell: &mut Shell<Probe>, s: &mut Settings, atlas: &HudAtlas) -> (f32, f32) {
let mut batch = HudBatch::new([1708, 960]);
shell.draw(s, &mut batch, atlas, (0.0, 0.0), false, None);
let size = (batch.width, batch.height);
shell.scroll(s, size, -1.0);
shell.key(s, K::ArrowDown, None, false, size);
shell.wheel(s, size, (0.0, 0.0), 1.0);
size
}
#[test]
fn rows_are_built_once_and_again_only_when_what_they_read_changes() {
let atlas = atlas();
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let built = |shell: &Shell<Probe>| shell.screen.built.get();
let reads = |shell: &mut Shell<Probe>, s: &Settings, size, end: &str| {
let view = shell.view(s, size);
let mut labels = view.rows.iter().map(|r| r.label(r.value_text().as_deref()));
labels.any(|label| label.ends_with(end))
};
// Each of the four built them before: now the first frame builds them once, the next none.
let size = frame(&mut shell, &mut s, &atlas);
assert_eq!(built(&shell), 1);
frame(&mut shell, &mut s, &atlas);
assert_eq!(built(&shell), 1);
// A press changes the screen's own state: its row reads the new value.
let at = middle_of(&mut shell, CHOICE, size);
shell.click(&mut s, at, size, false, false, false);
assert!(reads(&mut shell, &s, size, "Blue"));
// A setting changed elsewhere (a key bound, a command) shows on the next frame.
s.fov = 90.0;
frame(&mut shell, &mut s, &atlas);
assert!(reads(&mut shell, &s, size, "90"));
let before = built(&shell);
frame(&mut shell, &mut s, &atlas);
assert_eq!(built(&shell), before);
// Searching, and another size, build them again.
shell.type_text("filler");
shell.view(&s, size);
assert_eq!(built(&shell), before + 1);
shell.view(&s, SHORT);
assert_eq!(built(&shell), before + 2);
// So does another language, and coming back from it.
lang::scoped(lang::Language::load(&[], "de_de"), || shell.view(&s, SHORT));
assert_eq!(built(&shell), before + 3);
shell.view(&s, SHORT);
assert_eq!(built(&shell), before + 4);
}
#[test]
fn a_row_the_list_hides_part_of_scrolls_into_view_when_focused_and_then_takes_a_press() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let cols = Columns::of(SHORT.0, SHORT.1);
// A filler far down, scrolled to just over the bottom: its middle is under the list's edge.
let cut = FIRST_FILLER + 20;
let view = shell.view(&s, SHORT);
let (_, y) = shell.placed(&view).find(|(r, _)| r.id == cut).unwrap();
shell.scroll.amount = y - cols.bottom + style::ROW / 2.0 - 1.0;
let rect = cols.row(y - shell.scroll.amount);
assert!(
rect[1] + rect[3] / 2.0 >= cols.bottom,
"the row's middle is hidden"
);
let moved = Hosted::reveal(&mut shell, &s, SHORT, rect).unwrap();
let shown = [rect[0], rect[1] + moved, rect[2], rect[3]];
assert!(moved < 0.0 && shown[1] + shown[3] <= cols.bottom);
// All in view, it stays put.
assert_eq!(Hosted::reveal(&mut shell, &s, SHORT, shown), None);
let middle = (shown[0] + 4.0, shown[1] + shown[3] / 2.0);
assert_eq!(
shell.click(&mut s, middle, SHORT, false, false, false),
vec![CLICK, CHANGED]
);
assert_eq!(shell.screen.pressed, [(cut, false)]);
}
#[test]
fn undo_appears_after_a_change_and_puts_back_what_the_screen_opened_with() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let undo = Columns::of(SIZE.0, SIZE.1).button(1);
let on_undo = (undo[0] + 2.0, undo[1] + 2.0);
// Nothing changed: there is no Undo to press.
assert!(
shell
.click(&mut s, on_undo, SIZE, false, false, false)
.is_empty()
);
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
shell.click(&mut s, at, SIZE, false, false, false);
assert_eq!(s.fov, 71.0);
// Another option changed meanwhile isn't the screen's to put back.
s.chat_opacity = 0.25;
assert_eq!(
shell.click(&mut s, on_undo, SIZE, false, false, false),
vec![CLICK, CHANGED]
);
assert_eq!((s.fov, s.chat_opacity), (70.0, 0.25));
assert!(
shell
.click(&mut s, on_undo, SIZE, false, false, false)
.is_empty()
);
// From the keyboard: Tab reaches it, Enter undoes and leaves the focus on Done.
shell.click(&mut s, at, SIZE, false, false, false);
let reached = (0..100).any(|_| {
shell.key(&mut s, K::Tab, None, false, SIZE);
shell.focus == Some(Target::Undo)
});
assert!(reached);
assert_eq!(
shell.key(&mut s, K::Enter, None, false, SIZE),
vec![CLICK, CHANGED]
);
assert_eq!(s.fov, 70.0);
assert_eq!(shell.focus, Some(Target::Done));
}
#[test]
fn the_strip_says_why_a_greyed_row_cant_be_used() {
let atlas = atlas();
let s = Settings::default();
let mut shell = Shell::<Probe>::default();
let mut batch = HudBatch::new([1708, 960]);
let size = (batch.width, batch.height);
let strip = |batch: &mut HudBatch, shell: &mut Shell<Probe>, at| {
shell.draw(&s, batch, &atlas, at, false, None);
crate::widgets::end_frame(crate::widgets::generation() + 1);
let dock = Columns::of(size.0, size.1).dock;
let labelled = crate::widgets::last_labelled();
let found = labelled.iter().find(|w| [w.x, w.y, w.w, w.h] == dock);
found.map(|w| w.label.clone())
};
let grey = middle_of(&mut shell, GREY, size);
assert_eq!(strip(&mut batch, &mut shell, grey).as_deref(), Some(WHY));
let choice = middle_of(&mut shell, CHOICE, size);
assert_eq!(
strip(&mut batch, &mut shell, choice).as_deref(),
Some(CHOICES[0].1)
);
}
#[test]
fn growing_the_window_keeps_the_scroll_within_the_list() {
let s = Settings::default();
let mut shell = Shell::<Probe>::default();
shell.scroll(&s, SIZE, -1000.0);
assert!(shell.scroll.amount > 0.0);
let tall = (SIZE.0, 4000.0);
let view = shell.view(&s, tall);
assert_eq!(shell.scroll.amount, 0.0);
let first = shell.placed(&view).next().map(|(_, y)| y).unwrap();
assert!(first >= view.cols.top);
}
#[test]
fn one_scroll_path_moves_the_list_for_wheel_and_stick_alike() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let rate = SCROLL_RATE;
// The wheel away from any row, and `scroll`, which a stick and a D-pad use.
shell.wheel(&mut s, SIZE, (0.0, 0.0), -1.0);
assert_eq!(shell.scroll.amount, rate);
shell.scroll(&s, SIZE, -2.0);
assert_eq!(shell.scroll.amount, 3.0 * rate);
shell.scroll(&s, SIZE, 100.0);
assert_eq!(shell.scroll.amount, 0.0);
}
#[test]
fn the_wheel_steps_a_row_only_with_the_extra_on_and_only_where_the_screen_says() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let over_choice = middle_of(&mut shell, CHOICE, SIZE);
shell.wheel(&mut s, SIZE, over_choice, -1.0);
assert_eq!(shell.screen.choice, 0);
assert_eq!(shell.scroll.amount, SCROLL_RATE);
s.extras.wheel_cycles_settings = true;
shell.scroll.amount = 0.0;
let outputs = shell.wheel(&mut s, SIZE, over_choice, -1.0);
assert_eq!(outputs, vec![CHANGED]);
assert_eq!(shell.screen.choice, 1);
assert_eq!(shell.scroll.amount, 0.0);
// A row the screen doesn't step scrolls, the greyed one too.
let over_grey = middle_of(&mut shell, GREY, SIZE);
assert!(shell.wheel(&mut s, SIZE, over_grey, -1.0).is_empty());
assert_eq!(shell.scroll.amount, SCROLL_RATE);
}
#[test]
fn a_row_says_what_its_chosen_value_does_and_is_found_by_what_it_does() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let about = |shell: &mut Shell<Probe>, s: &Settings| {
let view = shell.view(s, SIZE);
let row = view.rows.iter().find(|r| r.id == CHOICE).unwrap();
row.described().to_owned()
};
assert_eq!(about(&mut shell, &s), "Paints it red.");
let at = middle_of(&mut shell, CHOICE, SIZE);
shell.click(&mut s, at, SIZE, false, false, false);
assert_eq!(about(&mut shell, &s), "Paints it blue.");
// The search reads the row's own description, on every page.
shell.type_text("colour");
let view = shell.view(&s, SIZE);
let found: Vec<u32> = view.rows.iter().map(|r| r.id).collect();
assert!(found.contains(&CHOICE) && !found.contains(&99));
shell.search.set_value("elsewhere");
shell.focus = None;
let view = shell.view(&s, SIZE);
assert_eq!(view.rows.iter().map(|r| r.id).collect::<Vec<_>>(), [99]);
}
#[test]
fn a_press_on_the_track_slides_and_on_the_name_only_focuses() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let at = middle_of(&mut shell, SLIDER, SIZE);
assert!(
shell
.click(&mut s, at, SIZE, false, false, false)
.is_empty()
);
assert_eq!(shell.screen.slider, 0.0);
let (track, width) = Columns::of(SIZE.0, SIZE.1).track();
let mid = (track + width / 2.0, at.1);
assert_eq!(
shell.click(&mut s, mid, SIZE, false, false, false),
vec![DRAGGED]
);
assert_eq!(shell.screen.slider, 0.5);
let end = (track + width, at.1);
assert_eq!(
shell.click(&mut s, end, SIZE, true, false, false),
vec![DRAGGED]
);
assert_eq!(shell.screen.slider, 1.0);
assert!(shell.release().is_some());
assert!(shell.release().is_none());
}
#[test]
fn the_keyboard_walks_the_live_rows_past_the_view_and_presses_them() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let mut key = |shell: &mut Shell<Probe>, k| shell.key(&mut s, k, None, false, SHORT);
// Tab: the search, the two pages, then the first row.
for _ in 0..4 {
key(&mut shell, K::Tab);
}
assert_eq!(shell.focus, Some(Target::Row(SLIDER)));
// A slider Tab reaches changes with the arrows, in its steps.
key(&mut shell, K::ArrowRight);
assert!((shell.screen.slider - 0.1).abs() < f32::EPSILON);
// The arrows go on past the greyed row and rows out of view, which scroll in.
key(&mut shell, K::ArrowDown);
assert_eq!(shell.focus, Some(Target::Row(CHOICE)));
key(&mut shell, K::ArrowDown);
assert_eq!(shell.focus, Some(Target::Row(FIRST_FILLER)));
for _ in 0..30 {
key(&mut shell, K::ArrowDown);
}
assert_eq!(shell.focus, Some(Target::Row(FIRST_FILLER + 30)));
assert!(shell.scroll.amount > 0.0);
assert_eq!(key(&mut shell, K::Enter), vec![CLICK, CHANGED]);
assert_eq!(shell.screen.pressed, [(FIRST_FILLER + 30, false)]);
assert_eq!(key(&mut shell, K::Escape), vec![Output::Done]);
}
#[test]
fn left_and_right_step_a_focused_choice_and_leave_other_rows() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let mut key = |shell: &mut Shell<Probe>, k| shell.key(&mut s, k, None, false, SIZE);
shell.set_focus(Some(Target::Row(CHOICE)));
// Right is a press, Left a Shift press: the screen's own way back, and the focus stays.
assert_eq!(key(&mut shell, K::ArrowRight), vec![CLICK, CHANGED]);
assert_eq!(key(&mut shell, K::ArrowLeft), vec![CLICK, CHANGED]);
assert_eq!(shell.screen.pressed, [(CHOICE, false), (CHOICE, true)]);
assert_eq!(shell.focus, Some(Target::Row(CHOICE)));
assert!(shell.changed);
// A row that isn't a choice: Left goes to the page list, as before.
shell.set_focus(Some(Target::Row(FIRST_FILLER)));
assert!(key(&mut shell, K::ArrowLeft).is_empty());
assert!(matches!(shell.focus, Some(Target::Page(_))));
assert_eq!(shell.screen.pressed.len(), 2);
}
#[test]
fn a_drawn_choice_tells_the_pad_its_arrows_step_it() {
let atlas = atlas();
let s = Settings::default();
let mut shell = Shell::<Probe>::default();
let mut batch = HudBatch::new([1708, 960]);
shell.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, None);
crate::widgets::end_frame(u64::MAX);
let view = shell.view(&s, (batch.width, batch.height));
let rect_of = |id| {
let mut placed = shell.placed(&view);
view.cols.row(placed.find(|(r, _)| r.id == id).unwrap().1)
};
assert!(crate::widgets::last_steps_value(rect_of(CHOICE)));
assert!(!crate::widgets::last_steps_value(rect_of(SLIDER)));
assert!(!crate::widgets::last_steps_value(rect_of(FIRST_FILLER)));
}
/// A controller presses with a click, then moves its own focus: only keys show this one.
#[test]
fn leaving_the_search_field_gives_the_keys_back() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let view = shell.view(&s, SIZE);
let [x, y, w, h] = view.cols.search();
shell.click(
&mut s,
(x + w / 2.0, y + h / 2.0),
SIZE,
false,
false,
false,
);
assert!(shell.typing());
Hosted::blur_search(&mut shell);
assert!(!shell.typing());
// Another focus isn't dropped.
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
shell.click(&mut s, at, SIZE, false, false, false);
Hosted::blur_search(&mut shell);
shell.key(&mut s, K::ArrowDown, None, false, SIZE);
assert!(shell.shows_focus(Target::Row(FIRST_FILLER + 1)));
}
#[test]
fn a_key_that_moves_nothing_shows_no_focus() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
shell.click(&mut s, at, SIZE, false, false, false);
shell.key(&mut s, K::ShiftLeft, None, false, SIZE);
assert!(!shell.shows_focus(Target::Row(FIRST_FILLER)));
}
#[test]
fn a_cut_description_says_so() {
assert_eq!(cut_off("off, it never "), "off, it never…");
}
#[test]
fn a_click_leaves_no_focus_showing_until_a_key_moves_it() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
shell.click(&mut s, at, SIZE, false, false, false);
assert!(!shell.shows_focus(Target::Row(FIRST_FILLER)));
// The next key goes on from the row clicked, and shows where it went.
shell.key(&mut s, K::ArrowDown, None, false, SIZE);
assert!(shell.shows_focus(Target::Row(FIRST_FILLER + 1)));
}
#[test]
fn a_taller_strip_takes_from_the_list_as_far_as_the_list_keeps_its_height() {
let (wide, base) = (
Columns::of(480.0, 270.0).with_note_lines(4),
Columns::of(480.0, 270.0),
);
assert_eq!(wide.note_lines, 4);
assert_eq!(wide.bottom, base.bottom - 2.0 * NOTE_LINE_H);
assert_eq!(wide.dock[1] + wide.dock[3], base.dock[1] + base.dock[3]);
assert_eq!(wide.button(0), base.button(0));
// A 720p window at scale 4 has room for one more line, not two.
let short = Columns::of(320.0, 180.0).with_note_lines(4);
assert_eq!(short.note_lines, 3);
assert!(short.bottom - short.top >= MIN_LIST_H);
}
#[test]
fn shift_presses_a_row_backwards() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
shell.click(&mut s, at, SIZE, false, false, true);
// The right button is not a press.
shell.click(&mut s, at, SIZE, false, true, false);
assert_eq!(shell.screen.pressed, [(FIRST_FILLER, true)]);
}
#[test]
fn a_click_on_a_greyed_row_does_nothing_and_done_leaves() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let at = middle_of(&mut shell, GREY, SIZE);
assert!(
shell
.click(&mut s, at, SIZE, false, false, false)
.is_empty()
);
assert!(shell.screen.pressed.is_empty());
let [x, y, w, h] = Columns::of(SIZE.0, SIZE.1).button(0);
let at = (x + w / 2.0, y + h / 2.0);
assert_eq!(
shell.click(&mut s, at, SIZE, false, false, false),
vec![CLICK, Output::Done]
);
}
#[test]
fn a_page_picked_shows_alone() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let [x, y, w, h] = Columns::of(SIZE.0, SIZE.1).page(1);
let at = (x + w / 2.0, y + h / 2.0);
assert_eq!(
shell.click(&mut s, at, SIZE, false, false, false),
vec![CLICK]
);
let view = shell.view(&s, SIZE);
assert_eq!(view.rows.iter().map(|r| r.id).collect::<Vec<_>>(), [99]);
}
/// A screen with more pages than a short view's page list has room for: a page of its own,
/// then a section of `PAGES` under a line, one more page out of the list that the section's
/// first page links to, and a change that waits for Apply.
#[derive(Default)]
struct Pages {
pending: bool,
applied: usize,
}
const PAGES: u8 = 12;
const HOME: u8 = 0;
const DEEP: u8 = 100;
const LINK: u32 = 1000;
const WAITS: u32 = 1001;
const SECTION: &str = "Section";
impl Screen for Pages {
type Page = u8;
type Row = u32;
fn title(&self) -> &'static str {
"Pages"
}
fn stamp(&self, _: &Settings) -> u64 {
stamp_of(&self.pending)
}
fn sheets(&self, _: &Settings) -> Vec<Sheet<u8, u32>> {
let page = |id: u8, name: String, rows| {
Sheet::new(id, name, String::new(), vec![Group { name: "G", rows }])
};
let change = Row::new(WAITS, "Change".to_owned(), Value::Press);
let mut sheets = vec![page(HOME, "Home".to_owned(), vec![change])];
for i in 1..=PAGES {
let mut rows = vec![Row::new(u32::from(i), format!("Row {i}"), Value::Press)];
if i == 1 {
rows.push(Row::new(LINK, "Deeper...".to_owned(), Value::Press));
}
let mut sheet = page(i, format!("Page {i}"), rows);
sheet.heading = (i == 1).then(|| SECTION.to_owned());
sheets.push(sheet);
}
let deep = Row::new(u32::from(DEEP), "Deep".to_owned(), Value::Press);
let mut deeper = page(DEEP, "Deeper".to_owned(), vec![deep]);
deeper.listed = false;
sheets.push(deeper);
sheets
}
fn press(&mut self, _: &mut Settings, row: u32, _: bool) -> Vec<Output> {
self.pending |= row == WAITS;
vec![CHANGED]
}
fn link(&self, row: u32) -> Option<u8> {
(row == LINK).then_some(DEEP)
}
fn undoes(&self) -> bool {
true
}
fn pending(&self, _: &Settings) -> bool {
self.pending
}
fn apply(&mut self, _: &mut Settings) -> Vec<Output> {
self.pending = false;
self.applied += 1;
vec![CHANGED]
}
}
/// A window of 854x960 (427x480 GUI px at its automatic scale) and one of 854x480 (427x240:
/// the page list has room for six places).
const TALL: [u32; 2] = [854, 960];
const SHORT_WINDOW: [u32; 2] = [854, 480];
/// One frame of `shell` in a window of `window`; the GUI size it was drawn at.
fn drawn<S: Screen + Default>(shell: &mut Shell<S>, s: &Settings, window: [u32; 2]) -> (f32, f32) {
let mut batch = HudBatch::new(window);
shell.draw(s, &mut batch, &atlas(), (0.0, 0.0), false, None);
crate::widgets::end_frame(crate::widgets::generation() + 1);
(batch.width, batch.height)
}
fn middle([x, y, w, h]: [f32; 4]) -> (f32, f32) {
(x + w / 2.0, y + h / 2.0)
}
#[test]
fn pages_that_fit_are_listed_and_more_collapse_into_one_row_leaving_none_out() {
let s = Settings::default();
let mut shell = Shell::<Pages>::default();
// Tall enough: each page under the last, the deeper page left out as it is in no list.
drawn(&mut shell, &s, TALL);
let tall = shell.page_places();
let pages: Vec<u8> = tall.iter().map(|p| p.0).collect();
assert_eq!(pages, (HOME..=PAGES).collect::<Vec<_>>());
assert!(tall.iter().all(|p| p.2 == Part::Tab));
assert!(tall.windows(2).all(|p| p[1].1[1] > p[0].1[1]));
assert_eq!(shell.strip_ends(), None);
// Short: the first page, then the section in one row, its first page between nothing and
// its next. Every place is inside the column, the row's on one line under the section's.
let size = drawn(&mut shell, &s, SHORT_WINDOW);
let short = shell.page_places();
let parts: Vec<(u8, Part)> = short.iter().map(|p| (p.0, p.2)).collect();
assert_eq!(
parts,
[(HOME, Part::Tab), (1, Part::Shown), (2, Part::Next)]
);
assert_eq!(shell.strip_ends(), Some((false, true)));
let cols = Columns::of(size.0, size.1);
for (_, [x, _, w, _], _) in &short {
assert!(*x >= cols.side_x && x + w <= cols.side_x + style::CONTROL_W);
}
assert_eq!(short[1].1[1], short[2].1[1]);
assert_eq!(short[1].1[1], cols.page(2)[1]);
// The line naming the section stays over it.
let labelled = crate::widgets::last_labelled();
let line = labelled.iter().find(|w| w.label == SECTION).unwrap();
assert!(line.y > cols.page(1)[1] && line.y < cols.page(2)[1]);
}
#[test]
fn the_collapsed_rows_arrows_walk_every_page_and_its_ends_go_no_further() {
let mut s = Settings::default();
let mut shell = Shell::<Pages>::default();
shell.show(1);
let mut seen = vec![1];
let size = loop {
let size = drawn(&mut shell, &s, SHORT_WINDOW);
let places = shell.page_places();
let Some(&(next, rect, _)) = places.iter().find(|p| p.2 == Part::Next) else {
break size;
};
assert_eq!(
shell.click(&mut s, middle(rect), size, false, false, false),
[CLICK]
);
assert_eq!(shell.open_page(), Some(next));
seen.push(next);
};
assert_eq!(seen, (1..=PAGES).collect::<Vec<_>>());
assert_eq!(shell.strip_ends(), Some((true, false)));
// At the last page, the right of the row picks nothing: its arrow is dimmed.
let cols = Columns::of(size.0, size.1);
let [x, y, w, h] = cols.page(2);
assert!(
shell
.click(
&mut s,
(x + w - 2.0, y + h / 2.0),
size,
false,
false,
false
)
.is_empty()
);
assert_eq!(shell.open_page(), Some(PAGES));
// The page before it from the keyboard: Enter on its arrow opens it, and the focus follows
// it to the middle of the row.
let before = shell
.page_places()
.iter()
.find(|p| p.0 == PAGES - 1)
.unwrap()
.2;
assert_eq!(before, Part::Prev);
shell.set_focus(Some(Target::Page(PAGES - 1)));
assert_eq!(shell.key(&mut s, K::Enter, None, false, size), [CLICK]);
drawn(&mut shell, &s, SHORT_WINDOW);
let now = shell
.page_places()
.iter()
.find(|p| p.0 == PAGES - 1)
.unwrap()
.2;
assert_eq!(now, Part::Shown);
}
#[test]
fn while_searching_the_collapsed_rows_arrows_skip_pages_without_matches() {
let mut s = Settings::default();
let mut shell = Shell::<Pages>::default();
// Rows 1, 10, 11 and 12: none before the first. The first page stays, as it has no match.
shell.set_search("row 1");
let size = drawn(&mut shell, &s, SHORT_WINDOW);
let pages = |shell: &Shell<Pages>| shell.page_places().iter().map(|p| p.0).collect::<Vec<_>>();
assert_eq!(pages(&shell), [HOME, 1, 10]);
assert_eq!(shell.strip_ends(), Some((false, true)));
// Its arrow goes to the next match, which has the first one before it and the last after.
let next = shell.page_places()[2].1;
shell.click(&mut s, middle(next), size, false, false, false);
drawn(&mut shell, &s, SHORT_WINDOW);
assert_eq!(pages(&shell), [HOME, 1, 10, 11]);
assert_eq!(shell.strip_ends(), Some((true, true)));
}
#[test]
fn a_controllers_bumpers_find_the_collapsed_rows_parts_as_tabs() {
let s = Settings::default();
let mut shell = Shell::<Pages>::default();
shell.show(5);
drawn(&mut shell, &s, SHORT_WINDOW);
let tabs: Vec<(f32, bool)> = crate::widgets::last_targets()
.into_iter()
.filter_map(|t| match t.kind {
crate::widgets::Kind::Tab(open) => Some((t.rect[0], open)),
_ => None,
})
.collect();
// The first page, the page before, the open one (lit) and the one after.
assert_eq!(tabs.len(), 4, "{tabs:?}");
assert_eq!(tabs.iter().filter(|t| t.1).count(), 1);
let (before, open, after) = (tabs[1].0, tabs[2], tabs[3].0);
assert!(open.1 && before < open.0 && open.0 < after);
// The arrows are vanilla's Previous Page and Next Page to the narrator.
let labels = crate::widgets::last_labelled();
let named = |key| {
labels
.iter()
.any(|w| w.label == lang::get(key) && w.enabled)
};
assert!(named(PREVIOUS_PAGE) && named(NEXT_PAGE));
}
#[test]
fn a_link_opens_a_page_out_of_the_list_and_escape_comes_back_to_the_links_page() {
let mut s = Settings::default();
let mut shell = Shell::<Pages>::default();
// Found by the search from another page: back is the page the link is on.
shell.show(HOME);
shell.set_search("deeper");
let at = shell.point_at(&s, SIZE, LINK);
assert_eq!(shell.click(&mut s, at, SIZE, false, false, false), [CLICK]);
// The page opens alone, the search cleared; the list lights the page the link was on.
assert_eq!(shell.open_page(), Some(DEEP));
assert_eq!(shell.listed(&s, SIZE), [(DEEP, u32::from(DEEP))]);
drawn(&mut shell, &s, TALL);
assert_eq!(shell.lit_page(), Some(1));
assert!(shell.key(&mut s, K::Escape, None, false, SIZE).is_empty());
assert_eq!(shell.open_page(), Some(1));
assert_eq!(
shell.key(&mut s, K::Escape, None, false, SIZE),
[Output::Done]
);
}
#[test]
fn apply_shows_beside_undo_while_changes_wait_and_goes_with_them() {
let mut s = Settings::default();
let mut shell = Shell::<Pages>::default();
let done = Columns::of(SIZE.0, SIZE.1).button(0);
assert_eq!(shell.foot_at(&s, SIZE), [(Target::Done, done)]);
let at = shell.point_at(&s, SIZE, WAITS);
shell.click(&mut s, at, SIZE, false, false, false);
let foot = shell.foot_at(&s, SIZE);
let targets: Vec<_> = foot.iter().map(|f| f.0).collect();
assert_eq!(targets, [Target::Undo, Target::Apply, Target::Done]);
// Side by side over Done, as wide as it together.
let ([ux, uy, uw, _], [ax, ay, aw, _]) = (foot[0].1, foot[1].1);
assert_eq!((uy, ay), (foot[2].1[1] - FOOT_GAP - style::BUTTON_H, uy));
assert!(ux + uw < ax && ax + aw == done[0] + done[2] && ux == done[0]);
assert_eq!(
shell.click(&mut s, middle(foot[1].1), SIZE, false, false, false),
[CLICK, CHANGED]
);
assert_eq!(shell.screen.applied, 1);
let targets: Vec<_> = shell.foot_at(&s, SIZE).iter().map(|f| f.0).collect();
assert_eq!(targets, [Target::Undo, Target::Done]);
}
#[test]
fn the_right_button_steps_no_choice_as_vanillas_cycle_button() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let choice = middle_of(&mut shell, CHOICE, SIZE);
assert!(
shell
.click(&mut s, choice, SIZE, false, true, false)
.is_empty()
);
assert!(shell.screen.pressed.is_empty());
}
@@ -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);
+3 -8
View File
@@ -72,8 +72,6 @@ horizon-theme = { path = "../horizon-theme" }
# anonymous "I'm playing" ping is the Horizon layer's (`horizon-heartbeat`, in the official executable).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-bench = { path = "../horizon-bench" }
# The machine and process probes (the dedicated server's performance report).
horizon-system = { path = "../horizon-system" }
crc32fast = "1"
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
@@ -121,14 +119,11 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
# cosmetics and emotes, the "Horizon player" presence, the official server added to
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends, and ray
# tracing on request (`HORIZON_RAY_TRACING=1`). Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# the server list, the Horizon Settings and Mods pages and Play with Friends, and ray tracing on
# request (`HORIZON_RAY_TRACING=1`). 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 = ["horizon-client-lib/horizon-extras", "horizon-client-render/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"]
horizon-extras = ["horizon-client-render/horizon-extras", "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"]
@@ -11,6 +11,18 @@
"horizon.options.gameplay_guide.basic": "Basic",
"horizon.options.gameplay_guide.full": "Full",
"horizon.options.gameplay_guide.off": "Off",
"horizon.options.radial_mode": "Radial Menu",
"horizon.options.radial_mode.about": "Hold the opener for the radial menu, or press it once to open it and again to close it.",
"horizon.options.radial_mode.hold.about": "Hold the opener and let go to run what the stick points at.",
"horizon.options.radial_mode.toggle.about": "Press the opener to open it; A or the opener again runs what the stick points at, B closes it.",
"horizon.options.radial_stick": "Radial Menu Selects With",
"horizon.options.radial_stick.about": "The stick that points at the radial menu's slots.",
"horizon.options.radial_stick.right": "Right Stick",
"horizon.options.radial_stick.left": "Left Stick",
"horizon.options.radial_stick.either": "Either",
"horizon.options.radial_stick.right.about": "The right stick points at the slots; the left still walks.",
"horizon.options.radial_stick.left.about": "The left stick points and you stand still. D-pad Right is under that thumb: pick an opener on the Radial Menu page.",
"horizon.options.radial_stick.either.about": "The last stick pushed points; neither walks nor turns the view. Only a bumper, a trigger or View can open the menu.",
"horizon.controller.guide.use": "Use",
"horizon.controller.guide.open": "Open",
"horizon.controller.guide.ride": "Ride",
@@ -129,6 +141,82 @@
"horizon.pages.shader_options": "Shader Options",
"horizon.pages.controller": "Controller Settings",
"horizon.pages.controller_binds": "Controller Binds",
"horizon.pages.controller_radial": "Radial Menu",
"horizon.radial.page.general": "General",
"horizon.radial.page.camera": "Camera",
"horizon.radial.release_stick": "Let go of the stick",
"horizon.radial.action.key_inventory": "Inventory",
"horizon.radial.action.key_chat": "Chat",
"horizon.radial.action.key_command": "Commands",
"horizon.radial.action.key_advancements": "Advancements",
"horizon.radial.action.key_togglePerspective": "Perspective",
"horizon.radial.action.key_swapOffhand": "Swap Hands",
"horizon.radial.action.key_drop": "Drop",
"horizon.radial.action.key_pickItem": "Pick Block",
"horizon.radial.action.key_screenshot": "Screenshot",
"horizon.radial.action.key_toggleGui": "Hide HUD",
"horizon.radial.action.key_smoothCamera": "Cinematic Camera",
"horizon.radial.action.key_socialInteractions": "Social",
"horizon.radial.action.key_emote": "Emote",
"horizon.radial.action.pad_hotbarPrevious": "Previous Slot",
"horizon.radial.action.pad_hotbarNext": "Next Slot",
"horizon.radial.action.key_fullscreen": "Fullscreen",
"horizon.radial.action.key_quickActions": "Quick Actions",
"horizon.radial.action.key_debug_overlay": "Debug Screen",
"horizon.radial.missing": "Missing",
"horizon.radial.why.missing": "Unknown action",
"horizon.radial.why.cosmetics": "Needs cosmetics on",
"horizon.radial.why.quick_actions": "This server has no Quick Actions",
"horizon.radial.why.social": "Only on a server",
"horizon.radial.next": "→ %s",
"horizon.radial.state.first_person": "First Person",
"horizon.radial.state.third_person_back": "Third Person",
"horizon.radial.state.third_person_front": "Front View",
"horizon.radial.state.shown": "Shown",
"horizon.radial.state.hidden": "Hidden",
"horizon.radial.state.on": "On",
"horizon.radial.state.off": "Off",
"horizon.radial.pick_title": "Choose an Action",
"horizon.radial.group.actions": "Actions",
"horizon.radial.pick.slot": "%s slot of %s",
"horizon.radial.pick.about": "Puts this action in the slot and goes back. The slot's own is marked.",
"horizon.radial.pick.current": "Current",
"horizon.radial.missing.about": "This game doesn't know the action %s: the slot keeps it and runs nothing. Press or Right to put another here.",
"horizon.radial.page.new": "New Page",
"horizon.pages.open.controller_radial": "Radial Menu...",
"horizon.radial.group.slots": "Slots",
"horizon.radial.group.page": "Page",
"horizon.radial.group.all": "All Pages",
"horizon.radial.empty": "Empty",
"horizon.radial.page_of": "Page %s of %s. Each slot is a direction of the selecting stick while the opener is held.",
"horizon.radial.slot.0": "Up",
"horizon.radial.slot.1": "Up-Right",
"horizon.radial.slot.2": "Right",
"horizon.radial.slot.3": "Down-Right",
"horizon.radial.slot.4": "Down",
"horizon.radial.slot.5": "Down-Left",
"horizon.radial.slot.6": "Left",
"horizon.radial.slot.7": "Up-Left",
"horizon.radial.slot.about": "The action this direction runs. Press it for the list of actions, Right for the next one, Left or Shift-click for the one before.",
"horizon.radial.rename": "Rename Page",
"horizon.radial.rename.about": "Give this page a name of your own.",
"horizon.radial.name_title": "Name This Page",
"horizon.radial.new_page.about": "Adds an empty page after the last one, and shows it.",
"horizon.radial.new_page.full": "The menu holds at most %s pages.",
"horizon.radial.delete_page": "Delete Page",
"horizon.radial.delete_page.about": "Removes this page and its slots at once. Undo brings it back.",
"horizon.radial.delete_page.last": "The menu keeps at least one page.",
"horizon.radial.reset": "Reset All Pages",
"horizon.radial.reset.about": "Puts back the General and Camera pages and removes the rest, at once. Undo brings them back.",
"horizon.radial.reset.default": "The pages are the defaults already.",
"horizon.radial.opener": "Opener",
"horizon.radial.opener.about": "The button held to open the menu. Press, then press the controller button to use: a bumper or the D-pad, not A, B, X, Y or the right stick, which steer and cancel.",
"horizon.radial.opener.about.left": "The button held to open the menu. Press, then press the controller button to use: a bumper, A, X or Y, not the D-pad or the left stick, which steer, nor B, which cancels.",
"horizon.radial.opener.about.either": "The button held to open the menu. Press, then press the controller button to use: a bumper, a trigger or View; both thumbs steer, and B cancels.",
"horizon.radial.unbound": "No button opens the menu (one under the thumb that selects is let go): press, then press a controller button.",
"horizon.radial.conflict": "Shared with another action: in play the menu takes that button. Press to pick another.",
"Radial Menu": "Radial Menu",
"Named pages of eight actions, held open with a button.": "Named pages of eight actions, held open with a button.",
"horizon.pages.extras": "Horizon Extras",
"horizon.pages.settings": "Horizon Settings",
"horizon.pages.studio": "Replay Studio",
@@ -287,6 +375,7 @@
"This feature is not yet available in Horizon.": "This feature is not yet available in Horizon.",
"Your display or system doesn't offer HDR output.": "Your display or system doesn't offer HDR output.",
"Turn HDR on to use it.": "Turn HDR on to use it.",
"The shader pack lights the world itself.": "The shader pack lights the world itself.",
"Your GPU or graphics API doesn't support it.": "Your GPU or graphics API doesn't support it.",
"Not with software rendering, which runs it on the CPU.": "Not with software rendering, which runs it on the CPU.",
"Out of video memory: off until the next start.": "Out of video memory: off until the next start.",
@@ -1019,6 +1108,7 @@
"Press the button for this. %s cancels.": "Press the button for this. %s cancels.",
"This button also does: %s": "This button also does: %s",
"Not bound: press this row, then the button that should do it.": "Not bound: press this row, then the button that should do it.",
"horizon.pad.action.radial": "Radial Menu",
"horizon.pad.notice.connected.press": "Press to use it",
"horizon.recipe_transfer.missing": "Missing Items",
"horizon.recipe_transfer.no_room": "Inventory full",
+2 -6
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, 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, menu_style, 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_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit, providers, pvp,
@@ -58,8 +58,6 @@ pub mod debug_overlay;
pub mod debugworld;
pub mod dialog;
pub mod dialog_screen;
#[cfg(feature = "discord")]
pub mod discord_presence;
pub mod end_crystal;
pub mod end_gateway;
pub mod entities;
@@ -102,8 +100,6 @@ 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;
+57 -37
View File
@@ -1005,7 +1005,7 @@ pub fn mob_to_nbt(
);
}
if id == "minecraft:piglin" {
write_guarded_piglin_anger(m, map);
crate::mobs::piglin::write_guarded_anger(m, map);
// InventoryCarrier.writeInventoryToTag.
map.insert(
"Inventory".into(),
@@ -2232,6 +2232,9 @@ pub fn mob_from_nbt_with_random(
) {
m.extra.flag2 = flag(tag, "IsImmuneToZombification");
m.extra.counter2 = number(tag.get("TimeInOverworld")).unwrap_or(0) as i32;
if m.species() == crate::mobs::Species::Piglin {
crate::mobs::piglin::read_guarded_anger(&mut m, tag);
}
if m.species() == crate::mobs::Species::Hoglin {
m.extra.cannot_be_hunted = flag(tag, "CannotBeHunted");
}
@@ -5969,42 +5972,6 @@ mod tests {
}
}
/// `Brain.Packed`: the guarded-block live ANGRY_AT entry. The existing
/// anger counter supplies its remaining long TTL, not an absolute timestamp.
fn write_guarded_piglin_anger(m: &Mob, map: &mut Compound) {
let Some(actor) = m.extra.piglin.guarded_angry_at else {
return;
};
let brain = map
.entry("Brain".into())
.or_insert_with(|| Tag::Compound(Compound::new()));
if !matches!(brain, Tag::Compound(_)) {
*brain = Tag::Compound(Compound::new());
}
let Tag::Compound(brain) = brain else { return };
let memories = brain
.entry("memories".into())
.or_insert_with(|| Tag::Compound(Compound::new()));
if !matches!(memories, Tag::Compound(_)) {
*memories = Tag::Compound(Compound::new());
}
let Tag::Compound(memories) = memories else {
return;
};
if let Some(uuid) = actor.filter(|_| m.extra.anger > 0) {
// ExpirableValue.codec: the UUID value and optional remaining Long TTL.
memories.insert(
"minecraft:angry_at".into(),
Tag::compound([
("value", Tag::IntArray(uuid.to_vec())),
("ttl", Tag::Long(i64::from(m.extra.anger))),
]),
);
} else {
memories.remove(&"minecraft:angry_at".into());
}
}
/// `Brain`'s two measured Warden Unit memories, using actual existing slots.
fn write_warden_owned_memories(m: &Mob, map: &mut Compound) {
let brain = map
@@ -9375,6 +9342,59 @@ mod piglin_live_anger_memory_tests {
}
}
/// `Piglin.readAdditionalSaveData` reads the brain's `ANGRY_AT` memory
/// back: the actor's UUID and the ticks left (`ExpirableValue`'s TTL).
/// Horizon wrote the memory but never read it, so a piglin that a player
/// had angered came back calm after its chunk reloaded.
#[test]
fn a_saved_guarded_anger_loads_its_uuid_and_ticks() {
// A whole saved piglin: its id, a position and the brain's memory.
let saved = |memory: Tag| {
Tag::compound([
("id", Tag::String("minecraft:piglin".into())),
(
"Pos",
Tag::List(vec![
Tag::Double(0.0f64.to_bits()),
Tag::Double(64.0f64.to_bits()),
Tag::Double(0.0f64.to_bits()),
]),
),
(
"Brain",
Tag::compound([("memories", Tag::compound([("minecraft:angry_at", memory)]))]),
),
])
};
let template = mob();
let types = [(template.kind.id.clone(), template.kind.clone())].into();
let uuid = Tag::IntArray(vec![90, 91, 92, 93]);
let ticking = saved(Tag::compound([
("value", uuid.clone()),
("ttl", Tag::Long(99)),
]));
let loaded = mob_from_nbt(&ticking, &types, 2, None).unwrap();
assert_eq!(loaded.extra.anger, 99);
assert_eq!(
loaded.extra.piglin.guarded_angry_at,
Some(Some([90, 91, 92, 93]))
);
// `ExpirableValue` leaves the TTL out when it never expires, and a
// long past the counter's range holds as long as it can: the counter
// keeps the longest it can while the goal ticks it down.
for memory in [
Tag::compound([("value", uuid.clone())]),
Tag::compound([("value", uuid.clone()), ("ttl", Tag::Long(1 << 40))]),
] {
let loaded = mob_from_nbt(&saved(memory), &types, 3, None).unwrap();
assert_eq!(loaded.extra.anger, i32::MAX);
assert_eq!(
loaded.extra.piglin.guarded_angry_at,
Some(Some([90, 91, 92, 93]))
);
}
}
#[test]
fn expired_owned_memory_cannot_resurrect_stale_raw_anger() {
let mut m = mob();
+4 -2
View File
@@ -13,6 +13,7 @@ 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.
@@ -23,8 +24,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();
+49 -226
View File
@@ -49,6 +49,7 @@ fn pack_files(world_tables: &Option<WorldTables>) -> horizon_world::loot::PackOv
}
}
mod activity;
mod advancements;
mod advancements_screen;
mod agent;
@@ -128,8 +129,6 @@ mod decorated_pot;
mod dedicated;
mod deflect;
mod dialogs;
#[cfg(feature = "discord")]
mod discord;
mod dispense_fixture;
mod dispenser;
mod dragon;
@@ -373,6 +372,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)]
@@ -402,6 +402,7 @@ mod remote_vehicles;
mod render_control;
mod render_settings;
mod render_tick;
mod replay;
mod repro;
mod resource_load;
mod resource_loader;
@@ -1497,7 +1498,7 @@ impl LevelAccess for GameLevel<'_> {
};
}
if let Some(signal) = books::lectern_signal(self.world, pos) {
return signal;
return signal * i32::from(books::has_book_state(self.registry, state));
}
if let Some(signal) =
machines::shelf_signal(self.world, self.registry, state, pos, direction)
@@ -2882,19 +2883,9 @@ struct Game<'a> {
/// The open connection joined from Realms: `ServerData.isRealm` for the friends
/// service's presence status.
realm_session: bool,
/// Active packet replay session (game/studio.rs).
pub(super) studio_replay: Option<horizon_client::studio::ReplayDriver>,
pub(super) studio_freecam: horizon_client::studio::StudioFreecam,
pub(super) studio_timeline: horizon_client::studio::TimelineUi,
pub(super) studio_topbar: horizon_client::studio::TopBarUi,
pub(super) studio_rmb_down: bool,
studio_camera_track_dirty: bool,
/// Camera perspective to restore after leaving replay playback.
studio_replay_previous_camera_type: Option<perspective::CameraType>,
/// Replay opened from the title screen owns the synthetic remote session.
studio_replay_standalone: bool,
last_replay_tick: Option<Instant>,
pub(super) studio_virtual_tick_ms: f32,
/// Replay playback (game/replay.rs), and Horizon Studio's editor on it (game/studio.rs).
pub(super) replay: replay::Replay,
pub(super) studio: studio::Studio,
/// This world is open to the network (game/lan.rs).
lan: Option<lan::LanHost>,
/// Single player's server thread, which `remote` is connected to.
@@ -5841,7 +5832,7 @@ impl Game<'_> {
self.draw_debug_overlay(&mut batch, size);
self.frame_graph
.draw(self.settings.horizon.overlay, &mut batch, &self.gui.atlas);
self.draw_pad_cursor(&mut batch);
self.draw_pad_over_hud(&mut batch);
batch
}
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
@@ -5942,8 +5933,7 @@ impl Game<'_> {
// the sleep overlay, debug screen and open screens still draw.
let spectator = self.spectator();
let (wi, hi) = (w as i32, h as i32);
let in_studio = self.studio_replay.is_some();
let show_hud = !self.ui.hide_gui && !in_studio;
let show_hud = !self.ui.hide_gui && !self.replay.playing();
// Hud.extractRenderState asks `Screen.isInGameUi` twice: such a screen
// defers the subtitles over itself, and keeps them while F1 hides the
// rest of the HUD.
@@ -5973,6 +5963,7 @@ impl Game<'_> {
if self.camera_type.first_person()
&& (!spectator || self.spectator_crosshair())
&& !self.debug_crosshair()
&& !self.overlay_holds_view()
{
let attack = (indicator == 1).then_some(strength);
let full = attack.is_some_and(|a| {
@@ -6102,6 +6093,7 @@ impl Game<'_> {
Some(draws) => draws,
None => self.health_hud.layout(&inputs, wi, hi),
};
self.keep_status_rows(&draws, wi, hi);
// The rows draw in one pass here, so a mod's `player_health`
// layer stands for the armor, food and air rows too.
self.gui_layer("minecraft:player_health", &mut batch, |g, batch| {
@@ -6201,7 +6193,9 @@ impl Game<'_> {
if self.screen == Screen::Chat {
self.draw_chat(&mut batch);
} else if show_hud {
self.gui_layer("minecraft:chat", &mut batch, |g, batch| g.draw_chat(batch));
if !self.overlay_holds_view() {
self.gui_layer("minecraft:chat", &mut batch, |g, batch| g.draw_chat(batch));
}
self.gui_layer("minecraft:tab_list", &mut batch, |g, batch| {
g.draw_tab_list(batch);
});
@@ -6272,33 +6266,13 @@ impl Game<'_> {
// after this point and so over what a mod drew.
self.fire_screen_render_post(&mut batch);
self.draw_saving_indicator(&mut batch);
// ToastManager.extractRenderState: F1 hides the toasts too.
self.draw_toasts(&mut batch, !show_hud);
self.draw_tutorial_toasts(&mut batch, !show_hud);
self.draw_pack_toasts(&mut batch, !show_hud);
self.draw_music_toast(&mut batch, !show_hud);
self.draw_every_toast(&mut batch, !show_hud);
if self.server_packs.prompt.is_none() {
self.remote_hud(&mut batch);
self.draw_sign_editor(&mut batch);
self.draw_book(&mut batch);
}
if !self.ui.hide_gui
&& self.screen == Screen::Playing
&& (self.studio_replay.is_some() || self.studio_freecam.active)
{
self.studio_topbar.render(
&mut batch,
&self.gui.atlas,
self.cursor,
self.studio_freecam.locked_to_path,
);
self.studio_timeline.render(
&mut batch,
&self.gui.atlas,
self.cursor,
&self.studio_freecam,
);
}
self.draw_studio_bars(&mut batch);
if show_hud {
self.draw_pad_guide(&mut batch, guide_from);
}
@@ -6521,12 +6495,12 @@ impl Game<'_> {
/// `Camera.setRotation`: the camera's orientation, turned round in
/// third-person front view.
pub(super) fn camera_orientation(&self) -> glam::Quat {
if self.studio_freecam.active {
if self.studio.flying() {
return camera_rotation(
perspective::CameraType::FirstPerson,
self.studio_freecam.yaw,
self.studio_freecam.pitch,
) * glam::Quat::from_rotation_z(self.studio_freecam.roll.to_radians());
self.studio.freecam.yaw,
self.studio.freecam.pitch,
) * glam::Quat::from_rotation_z(self.studio.freecam.roll.to_radians());
}
match self.posed_rotation {
// The frame's camera, already turned round in front view.
@@ -6733,11 +6707,7 @@ impl Game<'_> {
self.mark_player(out.len(), !self.own_body_in_view());
let player_start = out.len();
self.mark_motion(player_start, Self::MOTION_OWN_PLAYER);
let player_alpha = if self.studio_replay.is_some() {
(self.studio_virtual_tick_ms as f64 / 50.0).clamp(0.0, 1.0)
} else {
self.clock.camera_fraction()
};
let player_alpha = self.own_player_alpha();
self.player_model_geometry(camera, player_alpha, &mut out);
// A native pack's shadow pass alone gets the first-person model (no per-entity draws).
if self.pack_ids.is_none() && !self.own_body_in_view() {
@@ -6746,8 +6716,8 @@ impl Game<'_> {
.extend(out.drain(player_start..));
}
self.mark_motion(out.len(), 0);
// Companions in first person, trails and auras (game/cosmetics/fx.rs).
self.cosmetics_fx_geometry(camera, alpha, &mut out);
// What crates draw around players: first person, see-through (game/player_hooks.rs).
self.players_after_geometry(camera, alpha, &mut out);
self.mark_entity(out.len(), "minecraft:evoker_fangs");
self.fangs_geometry(camera, alpha, &mut out);
self.block_item_display_geometry(camera, alpha, &mut out);
@@ -7466,7 +7436,6 @@ impl Game<'_> {
self.spawn_phantoms();
self.tick_advancements();
self.tick_player_model();
self.tick_emotes();
Ok(())
}
@@ -9236,7 +9205,7 @@ impl Game<'_> {
let camera_alpha = self.clock.camera_fraction();
let vanilla = self.camera_view(camera_alpha as f32);
let view = vanilla.map(|view| {
if self.shows_panorama() || self.studio_freecam.active {
if self.shows_panorama() || self.studio.flying() {
view
} else {
self.native_camera_pose_now(camera_alpha as f32, view)
@@ -9414,14 +9383,7 @@ impl Game<'_> {
// hitboxes (F3+G, F3+B).
let mut lines = self.fishing_line(alpha, size);
let mut g = self.debug_gizmos(alpha as f32, camera.to_array());
if self.studio_freecam.active || self.studio_replay.is_some() {
horizon_client::studio::render_camera_path(
&self.studio_freecam.track,
&mut g,
None,
100,
);
}
self.studio_camera_path(&mut g);
lines.extend(g.lines);
let on_top = g.on_top;
pr.lap("pr_debug");
@@ -9443,7 +9405,7 @@ impl Game<'_> {
Vec::new()
};
pr.lap("pr_native_visuals");
let view = if self.studio_freecam.active {
let view = if self.studio.flying() {
glam::Mat4::from_quat(self.camera_orientation().conjugate())
} else {
horizon_client::camera::view_matrix(view_yaw, view_pitch)
@@ -9462,6 +9424,7 @@ impl Game<'_> {
self.camera_type.first_person(),
self.ui.hide_gui,
self.shows_panorama(),
self.overlay_holds_view(),
) {
let scale = HudBatch::new(size).scale;
crosshair.debug_crosshair(at, view_pitch, view_yaw, scale);
@@ -9569,7 +9532,7 @@ impl Game<'_> {
} else {
self.fov_degrees(camera_alpha as f32, camera.to_array())
};
let camera_effects = fov::camera_effects_enabled(title, self.studio_freecam.active);
let camera_effects = fov::camera_effects_enabled(title, self.studio.flying());
let bob = if !camera_effects {
glam::Mat4::IDENTITY
} else {
@@ -10017,10 +9980,8 @@ impl Game<'_> {
&mut self.toggles,
[self.settings.toggle_crouch, self.settings.toggle_sprint],
[
self.pad
.holds_action(&self.settings.controller, "key.sneak"),
self.pad
.holds_action(&self.settings.controller, "key.sprint"),
self.pad.holds_action("key.sneak"),
self.pad.holds_action("key.sprint"),
],
);
self.sync_toggle(SNEAK);
@@ -10462,21 +10423,7 @@ impl Game<'_> {
let b = HudBatch::new([r.config.width, r.config.height]);
let previous = self.cursor;
self.cursor = (position.x as f32 / b.scale, position.y as f32 / b.scale);
if self.screen == Screen::Playing
&& (self.studio_timeline.dragging_playhead
|| self.studio_timeline.dragging_keyframe.is_some())
{
let action = self.studio_timeline.handle_mouse_drag(
self.cursor,
b.width,
&mut self.studio_freecam,
);
if matches!(
action,
horizon_client::studio::TimelineAction::KeyframeMoved
) {
self.studio_camera_track_dirty = true;
}
if self.studio_mouse_dragged(b.width) {
return Ok(());
}
if self.screen_mouse_dragged(previous) {
@@ -10527,7 +10474,7 @@ impl Game<'_> {
}
}
WindowEvent::MouseWheel { .. }
if self.screen == Screen::Playing && self.studio_replay.is_some() => {}
if self.screen == Screen::Playing && self.replay.playing() => {}
WindowEvent::MouseWheel { delta, .. } => {
let (raw_x, raw_y) = match delta {
MouseScrollDelta::LineDelta(x, y) => (f64::from(x), f64::from(y)),
@@ -10804,90 +10751,7 @@ impl Game<'_> {
}
WindowEvent::MouseInput { state, button, .. } => {
let pressed = state == ElementState::Pressed;
if !pressed && button == MouseButton::Left {
let action = self.studio_timeline.handle_mouse_up();
if !matches!(action, horizon_client::studio::TimelineAction::None) {
self.handle_studio_timeline_action(action);
}
}
if (self.studio_freecam.active || self.studio_replay.is_some())
&& button == MouseButton::Right
{
if let Some(renderer) = &self.renderer {
let batch =
HudBatch::new([renderer.config.width, renderer.config.height]);
if self.studio_timeline.visible
&& self.cursor.1 >= batch.height - self.studio_timeline.height()
{
if pressed
&& let Some(index) = self.studio_timeline.keyframe_at_cursor(
self.cursor,
batch.width,
batch.height,
&self.studio_freecam,
)
{
if index < self.studio_freecam.track.keyframes.len() {
self.studio_freecam.track.keyframes.remove(index);
self.studio_camera_track_dirty = true;
}
self.studio_timeline.selected_keyframe = None;
}
return Ok(());
}
}
if pressed {
self.studio_topbar.close_dropdown();
self.studio_rmb_down = true;
self.capture(true);
return Ok(());
} else if self.studio_rmb_down {
self.studio_rmb_down = false;
self.capture(false);
return Ok(());
}
}
if pressed
&& button == MouseButton::Left
&& self.screen == Screen::Playing
&& (self.studio_replay.is_some() || self.studio_freecam.active)
{
let size = self
.renderer
.as_ref()
.map(|renderer| [renderer.config.width, renderer.config.height])
.unwrap_or([1, 1]);
let batch = HudBatch::new(size);
let had_open_menu = self.studio_topbar.open_menu.is_some();
let topbar_action = self.studio_topbar.handle_mouse_click(
self.cursor,
batch.width,
batch.height,
);
if !matches!(topbar_action, horizon_client::studio::TopBarAction::None) {
self.handle_studio_topbar_action(topbar_action);
return Ok(());
}
if had_open_menu || self.cursor.1 <= self.studio_topbar.height {
return Ok(());
}
if self.studio_timeline.visible {
let action = self.studio_timeline.handle_mouse_down(
self.cursor,
batch.width,
batch.height,
&self.studio_freecam,
);
if !matches!(action, horizon_client::studio::TimelineAction::None) {
self.handle_studio_timeline_action(action);
return Ok(());
}
if self.cursor.1 >= batch.height - self.studio_timeline.height() {
return Ok(());
}
}
}
if self.screen == Screen::Playing && self.studio_replay.is_some() {
if self.studio_mouse_input(pressed, button) {
return Ok(());
}
if matches!(self.screen, Screen::Inventory | Screen::Creative)
@@ -11357,24 +11221,7 @@ impl Game<'_> {
let between = now.saturating_duration_since(self.frame_end);
self.frame_ticks = elapsed.as_secs_f32() * 20.0;
self.previous = now;
if self.studio_freecam.active
&& self.screen == Screen::Playing
&& !self.dialog_open()
{
let held = |key| f64::from(u8::from(self.keys.raw(&key)));
let forward = held(KeyCode::KeyW) - held(KeyCode::KeyS);
let strafe = held(KeyCode::KeyD) - held(KeyCode::KeyA);
let vertical =
held(KeyCode::KeyE) - held(KeyCode::KeyQ) + held(KeyCode::Space);
let fast = self.keys.raw(&KeyCode::ControlRight);
self.studio_freecam.update_flight_motion(
forward,
strafe,
vertical,
fast,
elapsed.as_secs_f64().min(0.1),
);
}
self.studio_fly(elapsed);
// Arrow-key camera controls belong to gameplay; chat and
// other screens use the same keys for their own navigation.
if self.screen == Screen::Playing
@@ -11404,8 +11251,7 @@ impl Game<'_> {
self.heal_hold_latches();
// For the hooks above the engine (the "I'm playing" ping): said when it changes.
horizon_hooks::engine::published(self.is_published());
#[cfg(feature = "discord")]
self.update_discord_presence();
self.report_activity();
let steps = self.clock.advance(elapsed);
for _ in 0..steps {
let started = Instant::now();
@@ -11559,17 +11405,18 @@ impl Game<'_> {
server.set_legacy_rules(self.settings.extras.pvp.combat.rules);
}
self.clock.end_frame(self.pause);
let mut consumed = false;
let saved = self
.renderer
.as_mut()
.map(|r| std::mem::take(&mut r.saved_screenshots))
.unwrap_or_default();
for saved in saved {
self.finish_screenshot(saved);
}
let finished = self
.renderer
.as_mut()
.and_then(|r| r.screenshot_result.take());
if let Some(result) = &finished
&& self.ui.screenshot_message.is_some()
{
self.finish_screenshot(result);
consumed = true;
}
if let Some(result) = &finished
&& let Some(reply) = self.pending_screenshot.take()
{
@@ -11582,7 +11429,6 @@ impl Game<'_> {
}
.to_string(),
);
consumed = true;
}
if let Some(result) =
self.renderer.as_mut().and_then(|r| r.capture_result.take())
@@ -11591,12 +11437,6 @@ impl Game<'_> {
let value = result.unwrap_or_else(|e| serde_json::json!({ "error": e }));
let _ = reply.send(value.to_string());
}
// A result nobody asked for was the world icon's
// (`tick_world_icon`): a failure tries again a second
// later, as `tryTakeScreenshotIfNeeded` does.
if !consumed && let Some(Err(error)) = finished {
log::warn!("Horizon: couldn't save the world icon: {error}");
}
if rendered {
self.frame_stats.record(elapsed.as_secs_f32() * 1000.0);
self.debug_charts.frame_times.log(elapsed.as_nanos() as i64);
@@ -12157,16 +11997,8 @@ impl<'a> Game<'a> {
entity_attachments: Default::default(),
remote: None,
realm_session: false,
studio_replay: None,
studio_freecam: Default::default(),
studio_timeline: Default::default(),
studio_topbar: Default::default(),
studio_rmb_down: false,
studio_camera_track_dirty: false,
studio_replay_previous_camera_type: None,
studio_replay_standalone: false,
last_replay_tick: None,
studio_virtual_tick_ms: 0.0,
replay: Default::default(),
studio: Default::default(),
lan: None,
integrated: None,
level_shapes: Default::default(),
@@ -12665,10 +12497,8 @@ pub fn run_with() -> Result<()> {
// horizon-ctl drives: those start in a source checkout and wrote the list there.
if !options.offscreen && !synchronous_startup(&options) {
let version = horizon_client::telemetry::version();
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version));
// horizon-extras: the official server is Horizon's (docs/forking.md).
#[cfg(feature = "horizon-extras")]
horizon_client::official_servers::update(&horizon_client::logging::player_dir());
let game_dir = horizon_client::logging::player_dir();
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version, &game_dir));
}
mods::register_components(&vanilla_root)?;
mods::register_standalone_models();
@@ -12696,15 +12526,8 @@ pub fn run_with() -> Result<()> {
if options.execution_role.is_client() {
narrator::start(&early_settings);
}
// "Playing Horizon" on Discord: never for a server; an automated run only talks to a test's fake Discord.
#[cfg(feature = "discord")]
if options.dedicated.is_none() {
let automated = options.benchmark.is_some()
|| options.offscreen
|| options.frames.is_some()
|| options.world_frames.is_some();
horizon_client::discord_presence::start(early_settings.extras.discord_presence, automated);
}
// A game window's hooks (Discord's presence): never for a server.
activity::client_started(&options);
// Snapshots of derived tables live in the game folder, never beside the
// shared resources.
Behaviours::set_cache_dir(std::env::current_dir()?.join(".horizon-cache"));
@@ -0,0 +1,68 @@
//! What the player is doing, for the hooks above the engine (`Hooks::activity`: the Horizon
//! layer's Discord presence shows it).
use super::*;
use horizon_client::command::args::GameMode;
use horizon_hooks::{Activity, Client, Dimension, Play};
/// At startup, for a game window (never a dedicated server's): the player's first activity, then
/// `Hooks::client_started`.
pub(super) fn client_started(options: &Options) {
if options.dedicated.is_some() {
return;
}
let automated = options.benchmark.is_some()
|| options.offscreen
|| options.frames.is_some()
|| options.world_frames.is_some();
horizon_hooks::engine::activity(Activity::Menus);
horizon_hooks::engine::client_started(&Client::new(automated));
}
impl Game<'_> {
/// Each frame: only a change reaches the hooks.
pub(super) fn report_activity(&self) {
// One status per computer: P1's, not a splitscreen player's.
if self.options.split_guest {
return;
}
horizon_hooks::engine::activity(self.activity());
}
fn activity(&self) -> Activity {
if self.title.is_some() {
return Activity::Menus;
}
let max = horizon_client::lan::MAX_PLAYERS;
let play = if let Some(host) = &self.lan {
Play::Hosting {
players: 1 + host.hub.guests.len(),
max,
}
} else if let Some(server) = &self.integrated {
match (server.published(), &self.remote) {
(Some(_), Some(remote)) => Play::Hosting {
players: remote.player_count(),
max,
},
_ => Play::Singleplayer,
}
} else if self.remote.is_some() {
Play::Multiplayer
} else {
Play::Singleplayer
};
Activity::World {
play,
dimension: match self.dim {
Dim::Overworld => Dimension::Overworld,
Dim::Nether => Dimension::Nether,
Dim::End => Dimension::End,
},
mode: match self.mode {
GameMode::Survival => horizon_hooks::GameMode::Survival,
GameMode::Creative => horizon_hooks::GameMode::Creative,
GameMode::Adventure => horizon_hooks::GameMode::Adventure,
GameMode::Spectator => horizon_hooks::GameMode::Spectator,
},
}
}
}
+107 -42
View File
@@ -848,34 +848,20 @@ impl Game<'_> {
self.armor_stands[i].last_hit = time;
return true;
}
// brokenByPlayer: the stand item, then brokenByAnything: the equipment.
let a = self.armor_stands.remove(i);
let (width, height) = stand_box(&a);
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
self.show_breaking_particles(at, f64::from(width), f64::from(height));
let center = [at[0], at[1] + if a.small { 0.4875 } else { 0.9875 }, at[2]];
let stand = ItemStack {
item: Arc::from("minecraft:armor_stand"),
count: 1,
damage: 0,
extra: None,
};
self.spawn_item(stand, center, [0.0; 3], 10);
for stack in a.equipment.into_iter().flatten() {
let r = &mut self.world.random;
let v = [
f64::from(r.next_float()) * 0.2 - 0.1,
0.2,
f64::from(r.next_float()) * 0.2 - 0.1,
];
self.spawn_item(stack, center, v, 10);
// brokenByPlayer: the stand item, with the stand's name, pops at its
// block, then brokenByAnything: the loot table and the equipment.
let (width, height) = stand_box(&self.armor_stands[i]);
let p = self.armor_stands[i].position;
let pos = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
let mut stand = ItemStack::new("minecraft:armor_stand", 1);
if let Some(name) = &self.armor_stands[i].custom_name {
stand.data_mut().custom_name =
Some(horizon_client::scoreboard::component_tag(&name.component()));
}
self.pop_resource(pos, stand);
// ArmorStand.kill(level, the causing entity), after the drops.
self.stand_game_event("minecraft:entity_die", at, by);
self.world
.modified
.insert(((at[0].floor() as i32) >> 4, (at[2].floor() as i32) >> 4));
self.break_armor_stand_with_source(i, by);
self.show_breaking_particles(at, f64::from(width), f64::from(height));
true
}
/// `Entity.gameEvent(event, attributedTo)` for a stand at `at`: the
@@ -1202,6 +1188,8 @@ impl Game<'_> {
let a = self.armor_stands.remove(i);
let at = [a.position.x, a.position.y, a.position.z];
let a_id = a.id;
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
// brokenByAnything's dropAllDeathLoot, before the equipment.
if !a.small && self.ui.rules.bool("mob_drops") {
self.armor_stand_death_loot(at, a.fire_ticks > 0);
@@ -1210,8 +1198,6 @@ impl Game<'_> {
for stack in drops {
self.pop_resource(pos, stack);
}
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
self.entity_killed_event_by(at, source.or(Some(super::commands::Target::Stand(a_id))));
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
@@ -1292,10 +1278,21 @@ impl Game<'_> {
.raw
.get(&id)
.is_some_and(|tag| horizon_client::entity_nbt::flag(tag, "Invulnerable"));
// Entity.isInvulnerableTo: the flag, then EnchantmentHelper.isImmuneToDamage.
let a = &self.armor_stands[i];
if (invulnerable && !creative) || a.invisible || a.marker {
if (invulnerable && !creative)
|| a.invisible
|| a.marker
|| self.stand_immune_to_damage(i, damage_type, causing)
{
return false;
}
// ArmorStand.hurtServer attributes its game events to the source
// entity: the player, a LAN guest or the mob.
let by = match causing {
Some(Target::Player | Target::Guest(_) | Target::Mob(_)) => causing,
_ => None,
};
let (explosion, ignites, burns) = (
tagged("minecraft:is_explosion"),
tagged("minecraft:ignites_armor_stands"),
@@ -1306,21 +1303,26 @@ impl Game<'_> {
tagged("minecraft:always_kills_armor_stands"),
);
if explosion {
self.break_armor_stand_by_anything(i);
self.break_armor_stand_with_source(i, by);
return false;
}
if ignites {
if self.armor_stands[i].fire_ticks > 0 {
self.cause_stand_damage(i, 0.15);
self.cause_stand_damage(i, 0.15, by);
} else {
// igniteForSeconds(5.0f): at least 100 ticks.
// igniteForSeconds(5.0f): at least 100 ticks, and
// Entity.igniteForTicks calls clearFreeze.
let a = &mut self.armor_stands[i];
a.fire_ticks = a.fire_ticks.max(100);
if let Some(horizon_world::nbt::Tag::Compound(map)) = self.persist.raw.get_mut(&id)
{
map.remove(&"TicksFrozen".into());
}
}
return false;
}
if burns && self.armor_stands[i].health > 0.5 {
self.cause_stand_damage(i, 4.0);
self.cause_stand_damage(i, 4.0, by);
return false;
}
if !breaks && !always_kills {
@@ -1335,22 +1337,85 @@ impl Game<'_> {
if !may_build {
return false;
}
// ArmorStand.hurtServer attributes its game events to the source
// entity: the player, a LAN guest or the mob.
let by = match causing {
Some(Target::Player | Target::Guest(_) | Target::Mob(_)) => causing,
_ => None,
};
self.break_armor_stand_by_hit(i, creative, always_kills, by)
}
/// `EnchantmentHelper.isImmuneToDamage` for the stand at `i`: whether an
/// enchantment on an active equipment slot has a `minecraft:damage_immunity`
/// effect whose requirements hold for `damage_type` and `causing`.
fn stand_immune_to_damage(
&mut self,
i: usize,
damage_type: &str,
causing: Option<super::commands::Target>,
) -> bool {
use super::commands::Target;
let mut checks = Vec::new();
let equipment = &self.armor_stands[i].equipment;
for (stack, slot) in equipment.iter().zip(ArmorStand::SLOTS) {
let Some(stack) = stack else { continue };
let armor = matches!(slot, "head" | "chest" | "legs" | "feet");
let hand = matches!(slot, "mainhand" | "offhand");
for (id, level) in stack.enchantments() {
let Some(def) = self.res.stations().enchantments.get(id) else {
continue;
};
if !def.slots.iter().any(|s| {
s == slot || s == "any" || (s == "armor" && armor) || (s == "hand" && hand)
}) {
continue;
}
let immunity = &def.effects["minecraft:damage_immunity"];
for entry in immunity.as_array().into_iter().flatten() {
checks.push((entry.get("requirements").cloned(), *level));
}
}
}
if checks.is_empty() {
return false;
}
let attacker = match causing {
Some(Target::Player | Target::Guest(_)) => Some("minecraft:player".to_owned()),
Some(Target::Mob(m_id)) => self
.mobs
.iter()
.find(|m| m.id == m_id)
.map(|m| m.kind.id.clone()),
Some(Target::Stand(_)) => Some("minecraft:armor_stand".to_owned()),
_ => None,
};
let canonical = if damage_type.contains(':') {
damage_type.to_owned()
} else {
format!("minecraft:{damage_type}")
};
let ctx = horizon_client::enchant::EffectContext {
items: Some(self.res.items()),
damage_type: Some(&canonical),
is_direct: attacker.is_some(),
this: Some("minecraft:armor_stand"),
attacker: attacker.as_deref(),
direct_attacker: attacker.as_deref(),
..Default::default()
};
let ench = &self.res.stations().enchantments;
let rng = &mut self.world.random;
for (req, level) in checks {
if req.is_none_or(|r| ench.condition(&r, level, &ctx, rng)) {
return true;
}
}
false
}
/// `ArmorStand.causeDamage`: the health less `amount`, broken by anything
/// at 0.5 or less.
fn cause_stand_damage(&mut self, i: usize, amount: f32) {
/// at 0.5 or less, else `gameEvent(ENTITY_DAMAGE, damageSource.getEntity())`.
fn cause_stand_damage(&mut self, i: usize, amount: f32, by: Option<super::commands::Target>) {
let health = self.armor_stands[i].health - amount;
if health <= 0.5 {
self.break_armor_stand_by_anything(i);
self.break_armor_stand_with_source(i, by);
} else {
let at = self.armor_stands[i].position;
self.armor_stands[i].health = health;
self.stand_game_event("minecraft:entity_damage", [at.x, at.y, at.z], by);
}
}
/// ArmorStandRenderer: the stand with its default pose, then armor layers.
@@ -42,12 +42,25 @@ impl Game<'_> {
self.trial_spawner_tick_positions.clear();
self.item_columns = Some(Default::default());
self.pass_mobs = Some(Default::default());
for slot in self.world.block_entity_tickers().iter().copied() {
let tickers = self.world.block_entity_tickers();
let mut last_chunk = (i32::MIN, i32::MIN);
let mut last_chunk_ticking = false;
for slot in tickers.iter().copied() {
let pos = slot.0;
let chunk = (pos.0 >> 4, pos.2 >> 4);
let chunk_ticking = if chunk == last_chunk {
last_chunk_ticking
} else {
last_chunk = chunk;
last_chunk_ticking = ticking(pos.0, pos.2);
last_chunk_ticking
};
if !chunk_ticking {
continue;
}
// Ticks before this one can remove it (`TickingBlockEntity.isRemoved`).
if self.world.block_entities.is_asleep(&pos)
|| !self.world.block_entities.is_live(slot)
|| !ticking(pos.0, pos.2)
|| !self.border.contains(f64::from(pos.0), f64::from(pos.2))
{
continue;
@@ -210,8 +223,14 @@ impl Game<'_> {
/// (`ServerLevel.areEntitiesLoaded`).
pub(super) fn block_ticking_area(&self) -> impl Fn(i32, i32) -> bool + use<> {
let levels = self.tickets.levels();
let pending = self.world.entities_pending.clone();
move |x, z| levels.block_ticking_at(x, z) && !pending.contains(&(x >> 4, z >> 4))
let has_pending = !self.world.entities_pending.is_empty();
let pending = has_pending.then(|| self.world.entities_pending.clone());
move |x, z| {
levels.block_ticking_at(x, z)
&& pending
.as_ref()
.is_none_or(|p| !p.contains(&(x >> 4, z >> 4)))
}
}
/// `DaylightDetectorBlock.tickEntity` of the detector at `pos`.
fn tick_daylight_detector(&mut self, pos: Pos) {
+60
View File
@@ -1608,6 +1608,16 @@ impl Game<'_> {
}
}
}
/// `LecternBlock.getAnalogOutputSignal`'s `HAS_BOOK` check.
pub(super) fn has_book_state(
registry: &'static horizon_world::registry::BlockRegistry,
state: horizon_world::registry::BlockStateId,
) -> bool {
registry
.state(state)
.is_some_and(|s| s.properties.get("has_book").is_some_and(|v| v == "true"))
}
/// `LecternBlockEntity.getRedstoneSignal`.
pub(super) fn lectern_signal(world: &horizon_client::world::World, pos: BlockPos) -> Option<i32> {
match world.block_entities.get(&(pos.x, pos.y, pos.z)) {
@@ -1721,6 +1731,7 @@ mod lectern_signal_tests {
items::ItemStack,
};
use horizon_core::position::BlockPos;
use horizon_world::level::LevelAccess;
use horizon_world::registry::BlockStateId;
fn signal(game: &mut super::Game<'_>, book: Option<ItemStack>, page: i32) -> i32 {
@@ -1738,6 +1749,55 @@ mod lectern_signal_tests {
lectern_signal(&game.world, BlockPos::new(key.0, key.1, key.2)).unwrap()
}
/// `LecternBlock.getAnalogOutputSignal` reads the block entity only while
/// the state's `HAS_BOOK` is true.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_lectern_without_the_has_book_state_feeds_no_comparator() {
let mut game = server_game();
let key = (8, 64, 8);
let state = game
.pack
.assets
.registry
.default_state("minecraft:lectern")
.unwrap();
game.world.set(key.0, key.1, key.2, state).unwrap();
game.world.block_entities.insert(
key,
BlockEntity::Lectern(Lectern {
book: Some(ItemStack::new("minecraft:writable_book", 1)),
page: 0,
}),
);
let signal = |game: &mut super::Game<'_>, state| {
game.with_level(|_, level, _| {
level.block_entity_analog_signal(
BlockPos::new(key.0, key.1, key.2),
state,
horizon_core::geometry::Direction::North,
)
})
};
assert_eq!(
signal(&mut game, state),
0,
"the block state says the lectern holds no book"
);
let registry = game.pack.assets.registry;
let mut with_book = registry.state(state).unwrap().clone();
with_book
.properties
.insert("has_book".into(), "true".into());
let with_book = registry.id(&with_book).unwrap();
game.world.set(key.0, key.1, key.2, with_book).unwrap();
assert_eq!(
signal(&mut game, with_book),
15,
"with HAS_BOOK the book's page signal"
);
}
/// `LecternBlockEntity.getRedstoneSignal`: `floor(page / (pageCount - 1)
/// * 14) + (hasBook() ? 1 : 0)`. `hasBook()` asks for a book-content
/// component, so a stack that only sits in the lectern (a non-book item a
+43 -10
View File
@@ -255,8 +255,8 @@ pub struct UiState {
/// What this frame's HUD shows that Horizon's overlays keep clear of (`Game::hud_room`),
/// set by `hud_layers` before anything draws.
pub room: horizon_client::hud_kit::room::Room,
/// Path of an F2 screenshot waiting for its frame.
pub screenshot_message: Option<std::path::PathBuf>,
/// F2 screenshots not written yet: each reports in chat once its PNG is.
pub screenshot_messages: Vec<std::path::PathBuf>,
/// The options screens.
pub options: horizon_client::options_ui::OptionsUi,
/// The prepared resources before resource packs are layered on.
@@ -1982,15 +1982,17 @@ impl Game<'_> {
if std::fs::create_dir_all(&dir).is_err() {
return None;
}
let name = screenshot_name(&dir);
// A screenshot still being written has no file yet: its name is taken all the same.
let name = screenshot_name(&dir, &self.ui.screenshot_messages);
let renderer = self.renderer.as_mut()?;
let path = dir.join(name);
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path: path.clone(),
size: None,
scaled: true,
background: true,
});
self.ui.screenshot_message = Some(path.clone());
self.ui.screenshot_messages.push(path.clone());
Some(path)
}
/// `Minecraft.handleGlobalKeyPress`'s screenshot branch: the
@@ -2014,12 +2016,23 @@ impl Game<'_> {
self.take_screenshot();
true
}
/// After a frame: report a finished F2 screenshot in chat.
pub(super) fn finish_screenshot(&mut self, result: &std::result::Result<[u32; 2], String>) {
let Some(path) = self.ui.screenshot_message.take() else {
/// A screenshot written on its thread: F2's reports in chat. Any other was the world
/// icon's (`tick_world_icon`): a failure tries again a second later, as
/// `tryTakeScreenshotIfNeeded` does.
pub(super) fn finish_screenshot(&mut self, saved: horizon_client::gfx::SavedScreenshot) {
let Some(at) = self
.ui
.screenshot_messages
.iter()
.position(|p| *p == saved.path)
else {
if let Err(error) = saved.result {
log::warn!("Horizon: couldn't save the world icon: {error}");
}
return;
};
let message = match result {
let path = self.ui.screenshot_messages.remove(at);
let message = match &saved.result {
Ok(_) => {
log::info!("Saved screenshot to {}", path.display());
let name = path.file_name().unwrap_or_default().to_string_lossy();
@@ -2055,7 +2068,7 @@ fn chat_box_text(text: &str) -> String {
.filter(|&ch| ch >= ' ' && ch != '\u{7f}' && ch != '§')
.collect()
}
pub fn screenshot_name(dir: &std::path::Path) -> String {
pub fn screenshot_name(dir: &std::path::Path, pending: &[std::path::PathBuf]) -> String {
// Util.getFilenameFormattedDateTime: the local date and time.
let base = chrono::Local::now().format("%Y-%m-%d_%H.%M.%S").to_string();
let mut n = 1;
@@ -2065,7 +2078,8 @@ pub fn screenshot_name(dir: &std::path::Path) -> String {
} else {
format!("{base}_{n}.png")
};
if !dir.join(&name).exists() {
let path = dir.join(&name);
if !path.exists() && !pending.contains(&path) {
return name;
}
n += 1;
@@ -2107,6 +2121,25 @@ mod tests {
assert!(should_discard_draft(ChatExit::Done, true));
}
/// `Screenshot.getFile` picks the first free name of the second; an F2
/// screenshot still being written on its thread has no file yet, so a
/// second press in the same second takes the next name, not the same one.
#[test]
fn a_screenshot_being_written_keeps_its_name() {
let dir = std::env::temp_dir().join(format!("horizon-shots-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let first = screenshot_name(&dir, &[]);
let second = screenshot_name(&dir, &[dir.join(&first)]);
assert_ne!(first, second);
// Unless the clock turned to the next second in between.
let same_second = second.starts_with(first.trim_end_matches(".png"));
assert!(
!same_second || second == first.replace(".png", "_2.png"),
"{second}"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn resource_ids_are_listed_once_per_root() {
let root = std::env::temp_dir().join(format!("horizon-ids-{}", std::process::id()));
+3 -6
View File
@@ -3831,12 +3831,9 @@ impl Game<'_> {
return;
}
g.spawn = None;
g.respawn_config = Some(super::respawn_anchor::RespawnConfig {
dim: level,
pos,
yaw,
forced: true,
});
g.respawn_config = Some(super::respawn_anchor::RespawnConfig::forced(
level, pos, yaw, pitch,
));
});
}
let dim = Text::literal(self.dim.id());
+28
View File
@@ -633,6 +633,26 @@ impl Game<'_> {
}
ok()
}
// The real event, so focus loss reaches all that a window's does (the radial menu
// cancels, held keys go).
"pad" if words.get(1) == Some(&"focus") => {
let focused = match arg(2)? {
"true" => true,
"false" => false,
_ => return Err("pad focus true|false".into()),
};
let id = self
.window
.as_ref()
.map_or_else(winit::window::WindowId::dummy, |w| w.id());
self.handle_window_event(
event_loop,
id,
winit::event::WindowEvent::Focused(focused),
super::input_trace::InputOrigin::Agent,
);
ok()
}
"pad" => Ok(Some(self.pad_command(&words)?)),
"key" => {
let key = key_code(arg(1)?).ok_or_else(|| format!("unknown key {}", words[1]))?;
@@ -645,6 +665,8 @@ impl Game<'_> {
}
if arg(2)? != "up" {
self.input_keyboard_used();
// As a window's key press: the radial menu closes, and the key goes on.
self.pad_radial_cancel();
}
// The command confirmation takes the keys; none go past it.
if self.command_prompt.is_some() {
@@ -829,6 +851,10 @@ impl Game<'_> {
)
.into());
}
// As a window's click: the radial menu closes, and the click goes on.
if arg(2)? != "up" {
self.pad_radial_cancel();
}
match arg(2)? {
"down" => {
self.extras_mouse(button, true);
@@ -1039,6 +1065,7 @@ impl Game<'_> {
path: path.trim().into(),
size: None,
scaled: false,
background: false,
});
self.pending_screenshot = Some(reply.clone());
Ok(None)
@@ -1102,6 +1129,7 @@ impl Game<'_> {
"keybinds" => Page::KeyBinds,
"controller" => Page::Controller,
"controller-binds" => Page::PadBinds,
"controller-radial" => Page::ControllerRadial,
"mouse" => Page::Mouse,
"chat" => Page::Chat,
"accessibility" => Page::Accessibility,
+8 -2
View File
@@ -235,8 +235,14 @@ fn glfw_button_indices_are_glfw_indices() {
/// F3 left on when leaving a world kept the axes on the title screen.
#[test]
fn the_debug_crosshair_is_gone_once_the_world_is() {
assert!(debug_crosshair_drawn(true, true, false, false));
assert!(!debug_crosshair_drawn(true, true, false, true));
assert!(debug_crosshair_drawn(true, true, false, false, false));
assert!(!debug_crosshair_drawn(true, true, false, true, false));
}
/// F3's 3D crosshair drew beside the radial menu's stick, which the 2D one gives way to.
#[test]
fn the_debug_crosshair_gives_way_to_the_radial_menu() {
assert!(!debug_crosshair_drawn(true, true, false, false, true));
}
/// Opening a world in front of a block left its outline over Loading terrain's panorama (#12872).
File diff suppressed because it is too large. Load diff
@@ -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,15 @@ 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))
}
/// When the emote that pulled the camera back started (the own model's age), while it plays.
pub(in crate::game) fn pull(&self) -> Option<f32> {
self.camera_from.and(self.playing.as_ref()).map(|p| p.start)
}
}
@@ -59,23 +65,43 @@ 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 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,20 +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
/// out over the emote's first quarter second.
pub(in crate::game) fn emote_camera_pull(&self, partial: f32) -> f32 {
let (Some(p), Some(m)) = (&self.cosmetics.emotes.playing, &self.player_model) else {
return 1.0;
};
if self.cosmetics.emotes.camera_from.is_none() {
return 1.0;
}
let t = ((m.age as f32 + partial - p.start) / 5.0).clamp(0.0, 1.0);
0.35 + 0.65 * t * t * (3.0 - 2.0 * t)
}
}
+160 -255
View File
@@ -1,47 +1,78 @@
//! Companions, trails and auras ([docs/cosmetics.md](../../../../../docs/cosmetics.md)): worn
//! like the other cosmetics but on no bone. A companion floats beside its wearer and follows with
//! a lag (`horizon_cosmetics::follow`), drawn with the wearer; a trail's particles
//! (`horizon_cosmetics::trail`) and an aura blend, so they go after every player in one
//! see-through span (`cosmetics_fx_geometry`). Nothing of them is an entity, collides or is sent
//! to a server.
//! (`horizon_cosmetics::trail`) and an aura blend, so they are drawn see-through after every
//! player (`PlayerDraw::particles`, `Look::see_through`). Nothing of them is an entity, collides
//! or is sent to a server.
use super::{FULL_LIGHT, OWN};
use crate::game::Game;
use super::Seat;
use glam::{DVec3, Mat4, Vec3};
use horizon_client::mesher::Vertex;
use horizon_client::mobs::anim::Poses;
use horizon_client::mobs::{Mob, Part};
use horizon_client::fast_hash::FxHashMap;
use horizon_cosmetics::follow::{self, Follow};
use horizon_cosmetics::trail::Emitter;
use horizon_cosmetics::trail::{Budget, Emitter};
use horizon_cosmetics::{Item, Slot};
use horizon_hooks::players::{Kind, Light, Look, Particle, Pass, PlayerDraw, PlayerModel};
use std::sync::Arc;
use std::time::Instant;
/// How far from the camera (blocks) a wearer's companion, trail and aura are drawn.
const FAR: f32 = 48.0;
/// Who wears it, as drawn this frame.
pub(super) struct Wearer<'a> {
pub m: &'a Mob,
/// The player's own model.
pub own: bool,
/// `OWN`, or the entity id.
pub key: u32,
/// The wearer's model matrix (`render::base_transform`), relative to `camera`.
pub base: Mat4,
pub camera: DVec3,
pub alpha: f32,
pub light: u32,
/// The key of the player's own model in `Fx`'s maps (others go by entity id).
const OWN: u32 = u32::MAX;
/// What companions and trails keep between frames, per wearer (marked with the frame they were
/// last drawn in; the others are dropped).
#[derive(Default)]
pub(super) struct Fx {
companions: FxHashMap<u32, (Follow, u32)>,
trails: FxHashMap<u32, (Emitter, u32)>,
budget: Budget,
clock: Option<(Instant, Instant)>,
/// Ticks since the first frame, and the last frame's length in seconds.
now: f64,
dt: f32,
frame: u32,
/// Kept so a frame of trails allocates none.
particles: Vec<Particle>,
}
/// `m`'s head yaw this frame (degrees).
fn head_yaw(m: &Mob, alpha: f32) -> f32 {
let turn = horizon_client::mobs::living::wrap(m.head_yaw - m.previous_head_yaw);
m.previous_head_yaw + turn * alpha
}
impl Fx {
/// A new frame: the clock moves on and what no frame drew since the last one goes.
pub(super) fn frame(&mut self) {
self.frame = self.frame.wrapping_add(1);
let frame = self.frame;
let now = Instant::now();
let (start, last) = self.clock.get_or_insert((now, now));
self.dt = now.duration_since(*last).as_secs_f32().min(0.25);
self.now = now.duration_since(*start).as_secs_f64() * 20.0;
*last = now;
let seen = |mark: u32| frame.wrapping_sub(mark) <= 1;
self.companions.retain(|_, (_, mark)| seen(*mark));
let budget = &mut self.budget;
self.trails.retain(|_, (e, mark)| {
let keep = seen(*mark);
if !keep {
e.clear(budget);
}
keep
});
}
/// Whether `m` lies in a bed.
fn asleep(m: &Mob) -> bool {
m.extra.avatar_pose == horizon_client::pose::Pose::Sleeping
/// The companion of wearer `key` moved toward `target` (once a frame, however many times it
/// is drawn).
fn follow(&mut self, key: u32, target: DVec3, yaw: f32, lag: f32) -> Follow {
let (frame, dt) = (self.frame, self.dt);
let (f, mark) = self
.companions
.entry(key)
.or_insert((Follow::at(target, yaw), frame));
if *mark != frame {
f.step(target, yaw, lag, dt);
*mark = frame;
}
*f
}
}
/// Where a companion floats: `at` (relative to the camera) facing `yaw`, at the wearer's scale,
@@ -52,270 +83,144 @@ fn companion_matrix(at: Vec3, yaw: f32, scale: f32) -> Mat4 {
* Mat4::from_scale(Vec3::new(-scale, -scale, scale))
}
impl Game<'_> {
/// A companion, trail or aura `at` wears: a companion goes where the wearer's other items go
/// (`gpu` and `out`, `bounds` grown), trails and auras to the frame's see-through vertices.
/// Other players' trails and auras stay hidden with "Hide other players' trails and auras".
pub(super) fn cosmetics_fx(
&self,
at: &Wearer,
item: &Arc<Item>,
gpu: bool,
bounds: Option<(Vec3, Vec3)>,
poses: &mut Poses,
out: &mut Vec<Vertex>,
) -> Option<(Vec3, Vec3)> {
if !at.own && item.slot.is_effect() && self.settings.extras.cosmetics_hide_fx {
return bounds;
/// Full light for what glows, else `otherwise`.
fn glowing(glow: bool, otherwise: Light) -> Light {
if glow { Light::Full } else { otherwise }
}
impl Seat {
/// A companion, trail or aura `m` wears, around its model as `draw` draws it. Other players'
/// trails and auras stay hidden with "Hide other players' trails and auras".
pub(super) fn fx(&mut self, m: &PlayerModel, item: &Arc<Item>, draw: &mut dyn PlayerDraw) {
let own = m.kind == Kind::Own;
if !own && item.slot.is_effect() && self.said.hide_fx {
return;
}
// Past `FAR` a companion, trail or aura is a few pixels across for the cost of a model:
// not drawn, and a trail that isn't drawn doesn't spawn (its particles go back to the
// budget).
if at.base.w_axis.truncate().length_squared() > FAR * FAR {
return bounds;
let base = Mat4::from_cols_array_2d(&draw.base());
if base.w_axis.truncate().length_squared() > FAR * FAR {
return;
}
let Some(uv) = self.uv(item, |id| draw.sprite(id)) else {
return;
};
let key = if own { OWN } else { m.id };
match item.slot {
Slot::Companion => {
let Some((model, uv, matrix)) = self.companion_at(at, item, None) else {
return bounds;
};
self.pose_companion(item, at.m, at.alpha, poses, &model);
let glow = item.companion.as_ref().is_some_and(|c| c.glow);
let light = if glow { FULL_LIGHT } else { at.light };
self.cosmetics_model(&model, matrix, poses, uv, light, gpu, bounds, out)
}
Slot::Trail => {
self.trail(at, item);
bounds
}
Slot::Aura => {
self.aura(at, item, poses);
bounds
}
_ => bounds,
Slot::Companion => self.companion(key, item, uv, base, draw),
Slot::Trail => self.trail(key, item, uv, draw),
Slot::Aura => self.aura(item, uv, base, draw),
_ => {}
}
}
/// The companion's model, texture and matrix this frame, its follow moved on once a frame.
/// `first_person`: the camera's forward and left, for the player's own in first person: the
/// companion keeps out of the middle of the view.
fn companion_at(
&self,
at: &Wearer,
/// The companion where it floats, its follow moved on once a frame. In first person it keeps
/// out of the middle of the view, lit where it floats.
fn companion(
&mut self,
key: u32,
item: &Arc<Item>,
first_person: Option<(Vec3, Vec3)>,
) -> Option<(Arc<Part>, [f32; 4], Mat4)> {
let c = item.companion.as_ref()?;
let (models, uv) = self.cosmetics_baked(item)?;
let model = models.first()?.1.clone();
let scale = at.base.x_axis.truncate().length();
let target = at.base.transform_point3(Vec3::from(c.anchor) / 16.0);
let head = at
.base
.transform_point3(Vec3::from(horizon_cosmetics::HEAD) / 16.0);
let yaw = head_yaw(at.m, at.alpha);
let follow = self.companion_follow(at.key, at.camera + target.as_dvec3(), yaw, c.lag);
uv: [f32; 4],
base: Mat4,
draw: &mut dyn PlayerDraw,
) {
let Some(c) = item.companion.as_ref() else {
return;
};
let Some((_, mesh)) = self.kept(item).meshes.first().cloned() else {
return;
};
let first_person = draw.pass() == Pass::FirstPerson;
let (motion, view) = (draw.motion(), draw.view());
let camera = DVec3::from(view.camera);
let scale = base.x_axis.truncate().length();
let target = base.transform_point3(Vec3::from(c.anchor) / 16.0);
let head = base.transform_point3(Vec3::from(horizon_cosmetics::HEAD) / 16.0);
let follow = self
.fx
.follow(key, camera + target.as_dvec3(), motion.head_yaw, c.lag);
let clearance = (horizon_cosmetics::HEAD_CLEARANCE + c.reach) / 16.0 * scale;
let mut rel = (follow.pos - at.camera).as_vec3();
if let Some((forward, left)) = first_person {
rel = follow::out_of_view(rel, forward, left);
let mut rel = (follow.pos - camera).as_vec3();
if first_person {
rel = follow::out_of_view(rel, Vec3::from(view.forward), Vec3::from(view.left));
}
rel = follow::clear_of(rel, head, clearance);
Some((model, uv, companion_matrix(rel, follow.yaw, scale)))
}
/// The companion of wearer `key` moved toward `target` (once a frame, however many times it
/// is drawn).
fn companion_follow(&self, key: u32, target: DVec3, yaw: f32, lag: f32) -> Follow {
let frame = self.cosmetics.frame.get();
let mut fx = self.cosmetics.fx.borrow_mut();
let dt = fx.dt;
let (f, mark) = fx
.companions
.entry(key)
.or_insert((Follow::at(target, yaw), frame));
if *mark != frame {
f.step(target, yaw, lag, dt);
*mark = frame;
}
*f
}
/// The companion's animations, and its `look` part turned with the wearer's pitch.
fn pose_companion(&self, item: &Item, m: &Mob, alpha: f32, poses: &mut Poses, model: &Part) {
poses.reset(model);
item.animate(m.age as f32 + alpha, |name, pose| *poses.get(name) = pose);
if let Some(look) = item.companion.as_ref().and_then(|c| c.look.as_deref()) {
let pitch = m.previous_head_pitch + (m.head_pitch - m.previous_head_pitch) * alpha;
poses.get(look)[3] += (pitch * 0.6).clamp(-30.0, 30.0).to_radians();
}
let matrix = companion_matrix(rel, follow.yaw, scale).to_cols_array_2d();
let near = if first_person {
Light::AtPlace
} else {
Light::Model
};
let look = Look::new(uv).lit(glowing(c.glow, near));
let ticks = draw.ticks();
// Its animations, and its `look` part turned with the wearer's pitch.
let pitch = (motion.head_pitch * 0.6).clamp(-30.0, 30.0).to_radians();
draw.draw(&mesh, matrix, look, &mut |p| {
item.animate(ticks, |name, pose| *p.pose(name) = pose);
if let Some(part) = c.look.as_deref() {
p.pose(part)[3] += pitch;
}
});
}
/// The trail's particles: spawned once a frame by the distance walked (not while sneaking or
/// asleep), facing the camera.
fn trail(&self, at: &Wearer, item: &Arc<Item>) {
fn trail(&mut self, key: u32, item: &Arc<Item>, uv: [f32; 4], draw: &mut dyn PlayerDraw) {
let Some(t) = &item.trail else {
return;
};
let Some((_, uv)) = self.cosmetics_baked(item) else {
return;
};
let m = at.m;
let a = f64::from(at.alpha);
let feet = DVec3::new(
m.previous.x + (m.position.x - m.previous.x) * a,
m.previous.y + (m.position.y - m.previous.y) * a,
m.previous.z + (m.position.z - m.previous.z) * a,
);
let moves = !m.extra.crouching && !asleep(m);
let q = self.camera_orientation();
let (right, up) = (q * Vec3::X, q * Vec3::Y);
let light = if t.glow { FULL_LIGHT } else { at.light };
let frame = self.cosmetics.frame.get();
let mut fx = self.cosmetics.fx.borrow_mut();
let super::Fx {
let (motion, view) = (draw.motion(), draw.view());
let moves = !motion.crouching && !motion.asleep;
let Fx {
trails,
budget,
now,
clear,
frame,
particles,
..
} = &mut *fx;
} = &mut self.fx;
let (e, mark) = trails
.entry(at.key)
.entry(key)
.or_insert_with(|| (Emitter::default(), frame.wrapping_sub(1)));
if *mark != frame {
e.step(t, feet, moves, *now, budget);
*mark = frame;
}
for quad in e.quads(t, *now, at.camera, right, up) {
let color = ((quad.alpha.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF;
for (corner, [u, v]) in quad.corners.iter().zip(quad.uv) {
clear.push(Vertex {
pos: corner.to_array(),
uv: [uv[0] + (uv[2] - uv[0]) * u, uv[1] + (uv[3] - uv[1]) * v],
layer: 0,
color,
light,
// Unlit: a particle keeps its colour whichever way it faces.
normal: [0, 0, 0, 0],
glint_uv: [0.0, 0.0],
});
}
if *mark != *frame {
e.step(t, DVec3::from(motion.feet), moves, *now, budget);
*mark = *frame;
}
let (camera, right, up) = (
DVec3::from(view.camera),
Vec3::from(view.right),
Vec3::from(view.up),
);
particles.clear();
particles.extend(
e.quads(t, *now, camera, right, up)
.map(|q| Particle::new(q.corners.map(|c| c.to_array()), q.uv, q.alpha)),
);
draw.particles(uv, particles, glowing(t.glow, Light::Model));
}
/// The aura at the wearer's feet, see-through. None while they fly, swim or sleep: it would
/// tip over with the body.
fn aura(&self, at: &Wearer, item: &Arc<Item>, poses: &mut Poses) {
let (m, Some(a)) = (at.m, &item.aura) else {
fn aura(&mut self, item: &Arc<Item>, uv: [f32; 4], base: Mat4, draw: &mut dyn PlayerDraw) {
let Some(a) = &item.aura else {
return;
};
if m.extra.fall_flying || asleep(m) || m.extra.swim.amount(at.alpha) > 0.0 {
let motion = draw.motion();
if motion.flying || motion.asleep || motion.swimming {
return;
}
let Some((models, uv)) = self.cosmetics_baked(item) else {
return;
};
let Some((_, model)) = models.first() else {
let Some((_, mesh)) = self.kept(item).meshes.first().cloned() else {
return;
};
// The model's origin is the middle of the feet: 24 pixels under the neck.
let matrix = at.base * Mat4::from_translation(Vec3::new(0.0, 24.0 / 16.0, 0.0));
poses.reset(model);
item.animate(m.age as f32 + at.alpha, |name, pose| {
*poses.get(name) = pose
let matrix = base * Mat4::from_translation(Vec3::new(0.0, 24.0 / 16.0, 0.0));
let look = Look::new(uv)
.lit(glowing(a.glow, Light::Model))
.see_through(a.alpha);
let ticks = draw.ticks();
draw.draw(&mesh, matrix.to_cols_array_2d(), look, &mut |p| {
item.animate(ticks, |name, pose| *p.pose(name) = pose)
});
let light = if a.glow { FULL_LIGHT } else { at.light };
let mut fx = self.cosmetics.fx.borrow_mut();
let start = fx.clear.len();
horizon_client::mobs::render_model(
model,
matrix,
&|p: &Part| poses.pose(p),
uv,
light,
0,
&mut fx.clear,
);
let color = ((a.alpha * 255.0) as u32) << 24 | 0x00FF_FFFF;
for v in &mut fx.clear[start..] {
v.color = color;
// Seen through, a face turned away would add to the near one (and a card's two
// faces would double it): `entity.wgsl` drops back faces, so it is `alpha` at most.
v.normal[3] = horizon_client::mesher::SHADED_CULL_W;
}
}
/// After every player: the player's own companion in first person (out of the middle of the
/// view, after the first-person model went to the shadow pass only), then the frame's trails
/// and auras in one see-through span.
pub(in crate::game) fn cosmetics_fx_geometry(
&self,
camera: DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
if !super::enabled() {
return;
}
if !self.own_body_in_view()
&& let Some(m) = self.player_model.as_ref()
{
self.mark_entity(out.len(), "minecraft:player");
self.own_companion_first_person(m, camera, alpha as f32, out);
}
let fx = self.cosmetics.fx.borrow();
if !fx.clear.is_empty() {
self.mark_translucent_entity(out.len(), "minecraft:player");
out.extend_from_slice(&fx.clear);
}
}
fn own_companion_first_person(
&self,
m: &Mob,
camera: DVec3,
alpha: f32,
out: &mut Vec<Vertex>,
) {
if horizon_client::mobs::trader::is_invisible(m) || self.own_invisible() {
return;
}
let Some(worn) = self.worn(m, true) else {
return;
};
let Some(item) = worn.iter().find(|i| i.slot == Slot::Companion) else {
return;
};
let at = Wearer {
m,
own: true,
key: OWN,
base: horizon_client::mobs::render::base_transform(m, alpha, camera),
camera,
alpha,
light: FULL_LIGHT,
};
let (yaw, pitch) = (self.yaw.to_radians(), self.pitch.to_radians());
let forward = Vec3::new(
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
);
let left = Vec3::new(yaw.cos(), 0.0, yaw.sin());
let Some((model, uv, matrix)) = self.companion_at(&at, item, Some((forward, left))) else {
return;
};
// Lit where it floats.
let p = (camera + matrix.w_axis.truncate().as_dvec3()).floor();
let light = self.world.light(p.x as i32, p.y as i32, p.z as i32);
let mut light = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
if item.companion.as_ref().is_some_and(|c| c.glow) {
light = FULL_LIGHT;
}
let mut poses = self.cosmetics.poses.borrow_mut();
let poses = &mut poses.1;
self.pose_companion(item, m, alpha, poses, &model);
self.cosmetics_model(&model, matrix, poses, uv, light, false, None, out);
}
}
@@ -280,25 +280,18 @@ impl Game<'_> {
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.
// What crates draw on players and mannequins (`game/player_hooks.rs`: cosmetics),
// posed on the GPU with the mob's own jobs: culled with it.
if !layers_only
&& super::super::cosmetics::enabled()
&& horizon_hooks::engine::any()
&& (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,
) {
if let Some((lo, hi)) =
self.player_layers_geometry(mob, base, 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))),
@@ -58,7 +58,7 @@ impl Recording {
Self {
seconds,
started: Instant::now(),
cpu_s: horizon_system::cpu_seconds(std::process::id()),
cpu_s: horizon_bench::cpu_seconds(std::process::id()),
spans_were_on,
ticks: Vec::with_capacity(seconds as usize * 20),
sampled_ticks: 0,
@@ -363,7 +363,7 @@ impl Game<'_> {
let costs = tick_profile::take();
let mob_samples = costs.iter().map(|c| c.mob_ticks).sum();
let jobs = job_stats::take();
let cpu = horizon_system::cpu_seconds(std::process::id())
let cpu = horizon_bench::cpu_seconds(std::process::id())
.zip(r.cpu_s)
.map(|(end, start)| round((end - start) / window_s.max(1e-9)));
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
@@ -382,7 +382,7 @@ impl Game<'_> {
"overhead": overhead(&r, mob_samples, span_ns, mob_ns),
"players": players,
"levels": self.report_levels(),
"memory": { "rss_mb": horizon_system::rss_bytes(std::process::id()).map(|b| b >> 20) },
"memory": { "rss_mb": horizon_bench::rss_bytes(std::process::id()).map(|b| b >> 20) },
"process": process_limits(),
"cpu_cores_used": cpu,
"config": {
@@ -395,12 +395,9 @@ impl Game<'_> {
// The machine's description runs commands: off the tick.
std::thread::spawn(move || {
let mut report = report;
let hw = horizon_bench::Hardware::detect();
report["machine"] = json!({
"os": std::env::consts::OS,
"arch": std::env::consts::ARCH,
"cpu": horizon_system::cpu(),
"cpu_threads": std::thread::available_parallelism().ok().map(|n| n.get()),
"ram_mb": horizon_system::ram_bytes().map(|b| b >> 20),
"os": hw.os, "arch": hw.arch, "cpu": hw.cpu, "cpu_threads": hw.cpu_threads, "ram_mb": hw.ram_mb,
});
match write_report(Path::new("reports"), &report, &text) {
Ok(path) => log::info!(
-49
View File
@@ -1,49 +0,0 @@
//! What the player is doing, for Discord Rich Presence (`horizon_client::discord_presence`).
use super::*;
use horizon_client::discord_presence::{self as presence, Dimension, Play, Status};
impl Game<'_> {
/// Each frame: only a change reaches the presence thread.
pub(super) fn update_discord_presence(&self) {
// One Discord status per computer: P1's, not a splitscreen player's.
if self.options.split_guest {
return;
}
presence::set(self.discord_status());
}
fn discord_status(&self) -> Status {
if !self.settings.extras.discord_presence {
return Status::Off;
}
if self.title.is_some() {
return Status::Menus;
}
let play = if let Some(host) = &self.lan {
Play::Hosting {
players: 1 + host.hub.guests.len(),
max: horizon_client::lan::MAX_PLAYERS,
}
} else if let Some(server) = &self.integrated {
match (server.published(), &self.remote) {
(Some(_), Some(remote)) => Play::Hosting {
players: remote.player_count(),
max: horizon_client::lan::MAX_PLAYERS,
},
_ => Play::Singleplayer,
}
} else if self.remote.is_some() {
Play::Multiplayer
} else {
Play::Singleplayer
};
Status::World {
play,
dimension: match self.dim {
Dim::Overworld => Dimension::Overworld,
Dim::Nether => Dimension::Nether,
Dim::End => Dimension::End,
},
mode: self.mode,
}
}
}
@@ -353,7 +353,7 @@ impl Game<'_> {
let found =
self.find_respawn_and_use(config, op["consume"].as_bool().unwrap_or(true))?;
used.push(match found {
Some((p, yaw)) => format!(
Some((p, yaw, _pitch)) => format!(
"respawn {} {} {} {}",
num(p.x),
num(p.y),
@@ -579,6 +579,7 @@ impl Game<'_> {
dim: self.dim,
pos: [p(0), p(1), p(2)],
yaw: 0.0,
pitch: 0.0,
forced: false,
});
}
+2 -1
View File
@@ -739,7 +739,8 @@ impl Game<'_> {
// anchor puts the player, facing it; else onto the spawn column.
let mut target = target;
if let Some(c) = self.respawn_config.filter(|c| c.dim == self.dim) {
if let Some((at, yaw)) = self.find_respawn_and_use(c, false)? {
// This existing owner transition adapter carries yaw only; its pitch path remains open.
if let Some((at, yaw, _pitch)) = self.find_respawn_and_use(c, false)? {
return Ok(Some((at, Some(yaw))));
}
target = fallback_spawn(self.spawn, self.level.world_spawn);
@@ -70,9 +70,10 @@ fn overworld_spawnpoint_is_validated_at_both_player_blocks() {
assert_eq!(actual.is_some(), allowed, "{block} at height {height}");
assert_eq!(game.respawn_config.is_some(), allowed);
assert_eq!(game.level.world_spawn, Some([0, 80, 0]));
if let Some((at, yaw)) = actual {
if let Some((at, yaw, pitch)) = actual {
assert_eq!(at, Vec3::new(5.5, 64.1, 5.5));
assert_eq!(yaw, 0.0);
assert_eq!(pitch.to_bits(), 0.0f32.to_bits());
}
}
}
+2 -2
View File
@@ -135,8 +135,8 @@ impl Game<'_> {
}
/// `Camera.calculateFov` with `modifyFovBasedOnDeathOrFluid`.
pub(super) fn fov_degrees(&self, partial: f32, camera: [f64; 3]) -> f32 {
if self.studio_freecam.active {
return self.studio_freecam.fov.clamp(1.0, 179.0);
if self.studio.flying() {
return self.studio.freecam.fov.clamp(1.0, 179.0);
}
let fov = self.view.calculate_fov(
partial,
@@ -193,7 +193,7 @@ impl Game<'_> {
// replay player's hands remain visible in first person; freecam has
// no player-view hand.
if !fov::studio_draws_hand(
self.studio_freecam.active,
self.studio.flying(),
self.ui.hands_drawn(
self.spectator(),
self.sleeping.is_some(),
@@ -228,7 +228,7 @@ impl Game<'_> {
eye[2].floor() as i32,
);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
let shown = self.hands_to_render();
let shown = self.hands_shown();
let used_hand = self.used_hand();
let spinning = self
.player
+2 -1
View File
@@ -7,8 +7,9 @@ pub(super) fn debug_crosshair_drawn(
first_person: bool,
hide_gui: bool,
panorama: bool,
overlay_holds_view: bool,
) -> bool {
enabled && first_person && !hide_gui && !panorama
enabled && first_person && !hide_gui && !panorama && !overlay_holds_view
}
/// GameRenderer.shouldRenderBlockOutline, and only over a level. A panorama frame (the title
+63 -1
View File
@@ -1,11 +1,27 @@
//! The HUD's `Room` this frame (`docs/ui-kit.md`): what vanilla's HUD shows that Horizon's own
//! overlays keep clear of. `hud` empties it each frame and `hud_layers` fills it in before the
//! world's HUD draws, into `ui.room`; the controller notice and splitscreen's join strip read it.
//! world's HUD draws, into `ui.room`; the controller notice, splitscreen's join strip and the
//! radial menu read it. With it, the frame's `motion::Clock` (`hud_clock`), and whether an
//! overlay holds the view (`overlay_holds_view`), which what vanilla draws there gives way to.
use super::Game;
use horizon_client::hud::HudBatch;
use horizon_client::hud_kit::motion::Clock;
use horizon_client::hud_kit::room::{Keep, Room};
use horizon_client::{boss_overlay, hud_layout, hud_overlays};
impl Game<'_> {
/// The HUD's clock this frame (`hud_kit::motion`): still, so overlays show their motion
/// settled, while the game is paused, frame-stepped (`HORIZON_STILL`, a screenshot
/// sequence) or a repro recording or replay runs.
pub(super) fn hud_clock(&self) -> Clock {
let still = self.pause
|| self.clock.still()
|| self.screenshot_sequence.is_some()
|| self.repro.recording_active()
|| self.repro.replaying();
Clock::new(std::time::Instant::now(), still)
}
/// What the HUD shows on a `w` x `h` GUI px view this frame, as keep-outs: the boss bars
/// while the HUD shows (`show_hud`: not F1, not Replay Studio), as wide as the bar or its
/// widest name.
@@ -36,4 +52,50 @@ impl Game<'_> {
}
room
}
/// The status rows above the hotbar in a `w` x `h` GUI px view, from what they draw this
/// frame (`draws`): a keep-out from their highest row's top down, as wide as the hotbar. Called
/// as they are laid out, after `hud_room`, so the overlays drawn over the HUD see them.
pub(super) fn keep_status_rows(&mut self, draws: &[hud_layout::GuiDraw], w: i32, h: i32) {
if let Some(top) = hud_layout::draws_top(draws).filter(|top| *top < h) {
let half = hud_overlays::HOTBAR_HALF_WIDTH;
self.ui.room.add(
Keep::StatusRows,
[
(w / 2 - half) as f32,
top as f32,
(2 * half) as f32,
(h - top) as f32,
],
);
}
}
/// An overlay holds the middle of the view (the radial menu, once it shows: a quick tap of
/// its opener draws nothing, so nothing blinks). The 2D and F3 crosshairs, the chat's lines,
/// the toasts and the first-person hands give way to it.
pub(super) fn overlay_holds_view(&self) -> bool {
self.pad_radial_shown()
}
/// ToastManager.extractRenderState: F1 hides the toasts (`hidden`), and so does an overlay
/// holding the view, as the radial menu's page tabs run across the top where the toasts are.
/// They go on timing out meanwhile, as under F1.
pub(super) fn draw_every_toast(&mut self, batch: &mut HudBatch, hidden: bool) {
let hidden = hidden || self.overlay_holds_view();
self.draw_toasts(batch, hidden);
self.draw_tutorial_toasts(batch, hidden);
self.draw_pack_toasts(batch, hidden);
self.draw_music_toast(batch, hidden);
}
/// The hands drawn in first person this frame: none while an overlay holds the view, as the
/// radial menu's tiles sit where they are.
pub(super) fn hands_shown(&self) -> [bool; 2] {
if self.overlay_holds_view() {
[false; 2]
} else {
self.hands_to_render()
}
}
}
@@ -370,6 +370,11 @@ impl Game<'_> {
pub(super) fn has_singleplayer_server(&self) -> bool {
self.remote.is_none() || self.integrated.is_some()
}
/// `Minecraft.isMultiplayerServer`: on another's server, or a world of one's own open to the
/// network.
pub(super) fn is_multiplayer_server(&self) -> bool {
!self.has_singleplayer_server() || self.is_published()
}
/// `IntegratedServer.isPublished`: the world is open to the network.
pub(super) fn is_published(&self) -> bool {
self.lan.is_some()
@@ -21,6 +21,43 @@ use horizon_core::geometry::{Aabb, Vec3};
use serde_json::Value;
use std::{cell::RefCell, collections::HashMap};
/// The game's painter for the HUD kit's icons (`hud_kit::icon`): items by their models, the
/// player's face from `face`, their skin's texture.
pub(super) struct IconPainter<'g, 'a> {
pub(super) game: &'g Game<'a>,
pub(super) face: Option<&'g str>,
}
impl horizon_client::hud_kit::icon::IconPainter for IconPainter<'_, '_> {
fn item(&self, batch: &mut HudBatch, id: &str, [x, y, size]: [f32; 3]) -> bool {
let start = batch.vertices.len();
self.game
.draw_item_model(batch, &ItemStack::new(id, 1), x, y);
let scale = size / horizon_client::hud_kit::icon::ITEM_SIZE;
batch.scale_since(start, x, y, scale, scale);
batch.vertices.len() > start
}
fn face(&self, batch: &mut HudBatch, [x, y, size]: [f32; 3]) -> bool {
let Some(skin) = self.face else {
return false;
};
let mut draws = Vec::new();
// `PlayerFaceExtractor`: the face, then its hat layer.
horizon_client::tab_list::face(
&mut draws,
skin,
x as i32,
y as i32,
size as i32,
true,
false,
u32::MAX,
);
horizon_client::hud_layout::paint(batch, &self.game.gui.atlas, &draws);
true
}
}
/// The game's answers to what the resolver cannot read from the stack.
pub(super) struct Env<'a, 'g> {
game: &'a Game<'g>,
@@ -4190,8 +4190,8 @@ impl Game<'_> {
.map_or([0.5, 80.0, 0.5], |[x, y, z]| {
[f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5]
});
let at = found.map_or(world, |(at, yaw)| {
host.hub.guests[i].yaw = yaw;
let at = found.map_or(world, |(at, yaw, pitch)| {
(host.hub.guests[i].yaw, host.hub.guests[i].pitch) = (yaw, pitch);
[at.x, at.y, at.z]
});
if found.is_none() {
@@ -34,8 +34,6 @@ pub(super) fn run_event_loop(mut game: Game<'_>) -> Result<()> {
narrator::stop();
leave_fullscreen_after(&result, &mut game.settings);
result?;
#[cfg(feature = "discord")]
horizon_client::discord_presence::stop();
write_report(&game)?;
if let Some(e) = game.error.take() {
return Err(e);
@@ -98,9 +96,8 @@ 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_horizon_icon(
Window::default_attributes().with_title("Horizon"),
);
let attributes =
horizon_client::window::with_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(
@@ -130,22 +127,25 @@ 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")]
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));
}
// 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
});
stop
});
let offscreen = self.options.as_ref().is_some_and(|o| o.offscreen);
let boot_step = startup::step("renderer.boot");
match loading_renderer(
+174 -75
View File
@@ -118,35 +118,79 @@ impl View {
}
None
}
/// `WorldlyContainer.getSlotsForFace` or every slot.
fn slots_for_face(&self, face: Option<Direction>) -> Vec<usize> {
/// `WorldlyContainer.getSlotsForFace` or every slot, zero-allocation iterator.
pub(super) fn slots_for_face_iter(&self, face: Option<Direction>) -> Slots<'_> {
match (self.kind, face) {
(Kind::Furnace, Some(Direction::Down)) => vec![2, 1],
(Kind::Furnace, Some(Direction::Up)) => vec![0],
(Kind::Furnace, Some(_)) => vec![1],
(Kind::Brewing, Some(Direction::Up)) => vec![3],
(Kind::Brewing, Some(Direction::Down)) => vec![0, 1, 2, 3],
(Kind::Brewing, Some(_)) => vec![0, 1, 2, 4],
(Kind::Composter(8), Some(Direction::Down)) => vec![0],
(Kind::Composter(level), Some(Direction::Up)) if level < 7 => vec![0],
(Kind::Composter(_), _) => vec![],
(Kind::Furnace, Some(Direction::Down)) => Slots::Slice(&[2, 1]),
(Kind::Furnace, Some(Direction::Up)) => Slots::Slice(&[0]),
(Kind::Furnace, Some(_)) => Slots::Slice(&[1]),
(Kind::Brewing, Some(Direction::Up)) => Slots::Slice(&[3]),
(Kind::Brewing, Some(Direction::Down)) => Slots::Slice(&[0, 1, 2, 3]),
(Kind::Brewing, Some(_)) => Slots::Slice(&[0, 1, 2, 4]),
(Kind::Composter(8), Some(Direction::Down)) => Slots::Slice(&[0]),
(Kind::Composter(level), Some(Direction::Up)) if level < 7 => Slots::Slice(&[0]),
(Kind::Composter(_), _) => Slots::Slice(&[]),
(Kind::Mod, Some(face)) => self
.container
.as_ref()
.map_or_else(Vec::new, |c| c.faces[face as usize].clone()),
_ => (0..self.len()).collect(),
.map_or(Slots::Slice(&[]), |c| Slots::Mod(&c.faces[face as usize])),
_ => Slots::Range(0..self.len()),
}
}
/// The slots `HopperBlockEntity.addItem` tries through `face`, zero-allocation iterator.
pub(super) fn slots_to_add_iter(&self, face: Option<Direction>) -> Slots<'_> {
if matches!(self.kind, Kind::Furnace | Kind::Brewing | Kind::Mod) && face.is_some() {
self.slots_for_face_iter(face)
} else {
Slots::Range(0..self.len())
}
}
/// `WorldlyContainer.getSlotsForFace` or every slot.
fn slots_for_face(&self, face: Option<Direction>) -> Vec<usize> {
self.slots_for_face_iter(face).collect()
}
/// The slots `HopperBlockEntity.addItem` tries through `face`.
fn slots_to_add(&self, face: Option<Direction>) -> Vec<usize> {
if matches!(self.kind, Kind::Furnace | Kind::Brewing | Kind::Mod) && face.is_some() {
self.slots_for_face(face)
} else {
(0..self.len()).collect()
}
self.slots_to_add_iter(face).collect()
}
}
pub(super) enum Slots<'a> {
Range(std::ops::Range<usize>),
Slice(&'static [usize]),
Mod(&'a [usize]),
}
impl Iterator for Slots<'_> {
type Item = usize;
#[inline]
fn next(&mut self) -> Option<usize> {
match self {
Slots::Range(r) => r.next(),
Slots::Slice(s) => {
let (&first, rest) = s.split_first()?;
*s = rest;
Some(first)
}
Slots::Mod(s) => {
let (&first, rest) = s.split_first()?;
*s = rest;
Some(first)
}
}
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
let len = match self {
Slots::Range(r) => r.len(),
Slots::Slice(s) => s.len(),
Slots::Mod(s) => s.len(),
};
(len, Some(len))
}
}
impl ExactSizeIterator for Slots<'_> {}
pub(super) fn dir_from_name(name: &str) -> Option<Direction> {
Some(match name {
"down" => Direction::Down,
@@ -180,8 +224,6 @@ struct Containers<'a> {
registry: &'static horizon_world::registry::BlockRegistry,
/// Brewing recipes, for what a brewing stand accepts (not needed for signals).
brewing: Option<&'a horizon_client::brewing::Brewing>,
/// The stack of a full composter's `OutputContainer`.
bone_meal: ItemStack,
}
impl Containers<'_> {
fn prop(&self, state: BlockStateId, key: &str) -> Option<&str> {
@@ -346,7 +388,7 @@ impl Containers<'_> {
fn get(&self, view: &View, slot: usize) -> Option<&ItemStack> {
if let Kind::Composter(level) = view.kind {
// OutputContainer holds one bone meal; InputContainer composts at once.
return (level == 8 && slot == 0).then_some(&self.bone_meal);
return (level == 8 && slot == 0).then_some(bone_meal_stack());
}
let (pos, i) = view.locate(slot)?;
self.world
@@ -452,7 +494,7 @@ impl Containers<'_> {
}
/// `HopperBlockEntity.isFullContainer`.
fn is_full(&self, view: &View, face: Direction) -> bool {
view.slots_for_face(Some(face)).into_iter().all(|i| {
view.slots_for_face_iter(Some(face)).all(|i| {
self.insertion_item(view, i)
.is_some_and(|s| s.count >= self.max_stack(&s))
})
@@ -498,6 +540,9 @@ impl ItemColumns {
x: i32,
z: i32,
) -> bool {
if items.is_empty() {
return false;
}
let (len, last) = (items.len(), items.last().map(|i| i.id));
let stale = self
.seen
@@ -556,13 +601,14 @@ impl StampedItemColumns {
}
}
fn bone_meal() -> ItemStack {
ItemStack {
fn bone_meal_stack() -> &'static ItemStack {
static STACK: std::sync::LazyLock<ItemStack> = std::sync::LazyLock::new(|| ItemStack {
item: "minecraft:bone_meal".into(),
count: 1,
damage: 0,
extra: None,
}
});
&STACK
}
/// Comparator input from a container block entity (0 when there is none).
@@ -579,7 +625,6 @@ pub(super) fn container_signal(
items,
registry,
brewing: None,
bone_meal: bone_meal(),
};
c.view((pos.x, pos.y, pos.z), true)
.filter(|v| !matches!(v.kind, Kind::Composter(_)))
@@ -635,7 +680,6 @@ impl Game<'_> {
items: self.items(),
registry: self.pack.assets.registry,
brewing: Some(&self.stations().brewing),
bone_meal: bone_meal(),
}
}
pub(super) fn block_can_place(&self, pos: [i32; 3], slot: usize, stack: &ItemStack) -> bool {
@@ -847,7 +891,7 @@ impl Game<'_> {
return self.composter_container_insert([x, y, z], stack, face == Some(Direction::Up));
}
let mut rest = Some(stack);
for slot in view.slots_to_add(face) {
for slot in view.slots_to_add_iter(face) {
let Some(stack) = rest.take() else { break };
rest = self.try_move_in_item(from_hopper, view, stack, slot, face);
}
@@ -880,32 +924,31 @@ impl Game<'_> {
/// is still a hopper (`BlockEntityType.isValid`).
pub(super) fn tick_hopper(&mut self, pos: Pos) {
let time = self.world.game_time;
let waiting = {
let state = self.hoppers.entry(pos).or_default();
state.cooldown = state.cooldown.wrapping_sub(1);
state.ticked = time;
state.cooldown > 0
};
if waiting {
// Every hopper keeps its cooldown and the tick it last ticked
// (`HopperBlockEntity.tickedGameTime`), idle ones too: a hopper that
// receives an item reads it for its cooldown (8, or 7 once it ticked).
let state = self.hoppers.entry(pos).or_default();
state.cooldown = state.cooldown.wrapping_sub(1);
state.ticked = time;
if state.cooldown > 0 {
return;
}
state.cooldown = 0;
// Most ticks only advance the cooldown: the block is checked when the
// hopper is about to move, not on every tick it waits.
let hopper = self
.res
.facts()
.get(self.world.block(pos.0, pos.1, pos.2))
.name
.as_ref()
== "minecraft:hopper";
if !hopper {
let state = self.world.block(pos.0, pos.1, pos.2);
let Some(info) = self.pack.assets.registry.state(state) else {
self.hoppers.remove(&pos);
return;
};
if info.name != "minecraft:hopper" {
self.hoppers.remove(&pos);
return;
}
if let Some(state) = self.hoppers.get_mut(&pos) {
state.cooldown = 0;
if info.properties.get("enabled").is_some_and(|v| v == "false") {
return;
}
self.hopper_try_move_items(pos);
self.hopper_try_move_items_with_state(pos, state);
}
/// Drops the cooldowns of hoppers whose block entity is gone.
pub(super) fn forget_gone_hoppers(&mut self) {
@@ -917,23 +960,69 @@ impl Game<'_> {
/// `HopperBlockEntity.tryMoveItems` with `suckInItems`.
pub(super) fn hopper_try_move_items(&mut self, pos: Pos) {
let state = self.world.block(pos.0, pos.1, pos.2);
let c = self.containers();
let enabled = c.prop(state, "enabled") != Some("false");
let Some(view) = c.view(pos, false) else {
self.hopper_try_move_items_with_state(pos, state);
}
fn hopper_try_move_items_with_state(&mut self, pos: Pos, state: BlockStateId) {
let Some(info) = self.pack.assets.registry.state(state) else {
return;
};
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) || !enabled {
if info.properties.get("enabled").is_some_and(|v| v == "false") {
return;
}
self.native_prepare_hopper_transfer([pos.0, pos.1, pos.2]);
let mut changed = false;
if !c.is_empty(&view) {
changed = self.hopper_eject(pos, &view);
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) {
return;
}
let c = self.containers();
let full =
(0..view.len()).all(|i| c.get(&view, i).is_some_and(|s| s.count == c.max_stack(s)));
if !full {
let Some(BlockEntity::Chest(items)) = self.world.block_entities.get(&pos) else {
return;
};
if items.len() != HOPPER_SLOTS {
return;
}
let is_empty = items
.iter()
.all(|s| s.as_ref().is_none_or(|s| s.count == 0));
let has_empty_slot = items
.iter()
.any(|s| s.as_ref().is_none_or(|s| s.count == 0));
let above = (pos.0, pos.1 + 1, pos.2);
// Fast path for empty hopper with no source container and no item entities:
// if empty, cannot eject; if no container above, cannot suck container;
// if no dropped items nearby, cannot suck entities -> nothing can happen!
if is_empty
&& self.world.block_entities.get(&above).is_none()
&& self.carts.is_empty()
&& self.boats.is_empty()
&& (self.dropped.is_empty()
|| self
.item_columns
.as_mut()
.is_some_and(|c| !c.may_reach(&self.dropped, pos.0, pos.2)))
&& self
.pack
.assets
.registry
.state(self.world.block(above.0, above.1, above.2))
.is_none_or(|s| s.name != "minecraft:composter")
{
return;
}
self.native_prepare_hopper_transfer([pos.0, pos.1, pos.2]);
let view = View {
parts: vec![(pos, HOPPER_SLOTS)],
kind: Kind::Hopper,
container: None,
};
let mut changed = false;
if !is_empty {
changed = self.hopper_eject_with_state(pos, state, &view);
}
let is_full = !changed && !has_empty_slot && {
let c = self.containers();
(0..HOPPER_SLOTS).all(|i| c.get(&view, i).is_some_and(|s| s.count >= c.max_stack(s)))
};
if !is_full {
changed |= self.hopper_suck(pos, &view);
}
if changed {
@@ -995,19 +1084,20 @@ impl Game<'_> {
/// `tryMoveItems(…, () -> addItem(hopper, itemEntity))`; true when the
/// item entity was taken whole.
fn hopper_take_item(&mut self, pos: Pos, i: usize) -> bool {
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) {
return false;
}
let state = self.world.block(pos.0, pos.1, pos.2);
let c = self.containers();
if c.prop(state, "enabled") == Some("false") {
return false;
}
let Some(view) = c.view(pos, false) else {
return false;
};
if c.prop(state, "enabled") == Some("false")
|| self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0)
{
return false;
}
let mut changed = false;
if !c.is_empty(&view) {
changed = self.hopper_eject(pos, &view);
changed = self.hopper_eject_with_state(pos, state, &view);
}
let c = self.containers();
let full =
@@ -1043,8 +1133,7 @@ impl Game<'_> {
}
/// `HopperBlockEntity.ejectItems`: into the block container in front, else
/// a container entity there (`getContainerAt`).
fn hopper_eject(&mut self, pos: Pos, hopper: &View) -> bool {
let state = self.world.block(pos.0, pos.1, pos.2);
fn hopper_eject_with_state(&mut self, pos: Pos, state: BlockStateId, hopper: &View) -> bool {
let c = self.containers();
let Some(facing) = c.prop(state, "facing").and_then(dir_from_name) else {
return false;
@@ -1156,6 +1245,9 @@ impl Game<'_> {
if blocked {
return false;
}
if self.dropped.is_empty() {
return false;
}
// Hopper.SUCK_AABB: the hopper bowl and the block above.
let (min, max) = (
[
@@ -1214,7 +1306,7 @@ impl Game<'_> {
// second setChanged after the move or, when the hopper has no
// room, the setItem putting the bone meal back into the detached
// container (whose bone meal is lost with it).
let bone_meal = c.bone_meal.clone();
let bone_meal = bone_meal_stack().clone();
if !horizon_client::mod_api::mods().is_empty() {
let mut cancel = false;
self.native_inventory_move_item(
@@ -1244,13 +1336,15 @@ impl Game<'_> {
source: &View,
hopper: &View,
) -> bool {
for slot in source.slots_for_face(Some(Direction::Down)) {
let c = self.containers();
let Some(stack) = c.get(source, slot) else {
continue;
};
if !c.can_take(source, slot, stack, Direction::Down) {
continue;
for slot in source.slots_for_face_iter(Some(Direction::Down)) {
{
let c = self.containers();
let Some(stack) = c.get(source, slot) else {
continue;
};
if !c.can_take(source, slot, stack, Direction::Down) {
continue;
}
}
if self.hopper_move_canceled(source, slot, source, hopper) {
continue;
@@ -1269,7 +1363,9 @@ impl Game<'_> {
}
return true;
}
Some(rest) => self.put_back(source, slot, rest),
Some(rest) => {
self.put_back(source, slot, rest);
}
}
}
false
@@ -1279,6 +1375,9 @@ impl Game<'_> {
/// minecarts, chest boats) whose box meets the 1x1x1 box around `at`,
/// picked at random.
fn entity_container_at(&mut self, at: [f64; 3]) -> Option<u32> {
if self.carts.is_empty() && self.boats.is_empty() {
return None;
}
let meets = |b: horizon_core::geometry::Aabb| {
let (lo, hi) = (b.min_position(), b.max_position());
lo.x < at[0] + 0.5
+1
View File
@@ -930,6 +930,7 @@ impl Game<'_> {
let m = &self.mobs[i];
let hearts = horizon_client::hud_layout::vehicle_max_hearts(m.attrs.max_health);
let draws = horizon_client::hud_layout::vehicle_health(w, h, hearts, m.health);
self.keep_status_rows(&draws, w, h);
self.gui_layer("minecraft:vehicle_health", batch, |g, batch| {
horizon_client::hud_layout::paint(batch, &g.gui.atlas, &draws);
});
+6 -9
View File
@@ -300,11 +300,7 @@ impl Game<'_> {
) {
continue;
}
let marks = if player || self.cosmetics_local() {
self.cosmetics_marks(m, Some(m.id) == own)
} else {
Marks::default()
};
let marks = self.hooks_name_marks(m, Some(m.id) == own);
let at = match marks.top {
Some(top) => p + glam::DVec3::Y * cosmetic_lift(top, m.model_scale()),
None => p,
@@ -770,10 +766,10 @@ impl Game<'_> {
if background { BACKGROUND } else { 0 },
),
};
if n == last && marks.horizon {
if let Some(mark) = marks.mark.filter(|_| n == last) {
let x = -self.gui.atlas.font.width_text(text) / 2.0 - 10.0;
// 8 text pixels square, a pixel left of the background.
for &(mx, my, w, h, argb) in super::cosmetics::MARK {
for &(mx, my, w, h, argb) in mark {
let a = text_alpha * (argb >> 24) / 255;
batch.fill(
&self.gui.atlas,
@@ -937,10 +933,11 @@ struct NameTag {
marks: Marks,
}
/// What Horizon draws by a player's name: its mark (they play Horizon) and their name tag style.
/// What a hook draws by a player's name (`horizon_hooks::players::NameDecoration`): a mark
/// before it (Horizon's: they play Horizon) and a name tag style.
#[derive(Clone, Copy, Default)]
pub(super) struct Marks {
pub horizon: bool,
pub mark: Option<horizon_hooks::players::Mark>,
pub style: Option<TagStyle>,
/// How high the cosmetics drawn on their body get above the neck, in model pixels: the tag
/// goes up over a tall one (`cosmetic_lift`).
@@ -471,6 +471,7 @@ pub(crate) mod tests {
dim: game.dim,
pos: [1, 2, 3],
yaw: 0.0,
pitch: 0.0,
forced: false,
};
let spawn = Vec3::new(1.0, 2.0, 3.0);
@@ -46,6 +46,8 @@ pub(super) struct SpawnScratch {
/// dx/dz order, that no earlier player's square holds.
segments: Vec<Vec<(i32, i32)>>,
stale: Vec<bool>,
/// Reusable buffer for spawn players across ticks to avoid heap allocation.
pub(super) spawn_players: Vec<SpawnPlayer>,
}
impl SpawnScratch {
fn chunk(p: &LevelPlayer) -> (i32, i32) {
@@ -128,6 +130,20 @@ impl SpawnScratch {
self.prepare(players);
self.candidates.clear();
let mut count = 0;
if players.len() == 1 {
let p = &players[0];
count = self.segments.first().map_or(0, |s| s.len());
if !p.spectator
&& let Some(segment) = self.segments.first()
{
for &c in segment {
if close_enough_for_spawning(p, c) && keep(c) {
self.candidates.push(c);
}
}
}
return count as i32;
}
for (i, segment) in self.segments.iter().enumerate() {
count += segment.len();
let near = &self.neighbours[i];
@@ -650,8 +666,10 @@ fn spawn_cost_types(biomes: &horizon_client::biome::Biomes) -> Vec<&str> {
types
}
impl<'a> Game<'a> {
pub(super) fn natural_level(&self) -> GameLevel<'_, 'a> {
let players = self.level_players();
pub(super) fn natural_level_with_players(
&self,
players: Vec<LevelPlayer>,
) -> GameLevel<'_, 'a> {
let nearest = players
.iter()
.filter(|p| !p.spectator)
@@ -670,6 +688,9 @@ impl<'a> Game<'a> {
costed: spawn_cost_types(world_biomes!(self)),
}
}
pub(super) fn natural_level(&self) -> GameLevel<'_, 'a> {
self.natural_level_with_players(self.level_players())
}
/// Creates a mob the spawner accepted, finalized the game's way; the
/// caller adds it with [`Game::add_spawned`].
fn finalize_spawned(&self, r: &SpawnRequest, pending: &mut Vec<Mob>) -> Option<Spawned> {
@@ -773,6 +794,8 @@ impl<'a> Game<'a> {
scratch: &mut SpawnScratch,
) -> i32 {
let levels = self.tickets.levels();
// Recomputed each pass: a chunk's ticket level and entity loading change without the
// players moving or the chunk count changing.
scratch.collect(players, |(cx, cz)| {
levels.loaded_ticking(cx, cz) && super::ticking_chunk(&self.world, cx, cz)
})
@@ -819,31 +842,28 @@ impl<'a> Game<'a> {
{
let level = self.natural_level();
laps.lap("sn_level");
let counted: Vec<Counted> = self
.mobs
.iter()
.map(|m| Counted {
entity: &m.kind.id,
category: Category::from_name(&m.kind.category),
block: [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
],
is_mob: true,
persistent: m.persistent
|| horizon_client::mobs::ai::requires_custom_persistence(m),
})
.collect();
let players = players
.iter()
.map(|p| SpawnPlayer {
let counted = self.mobs.iter().map(|m| Counted {
entity: &m.kind.id,
category: Category::from_name(&m.kind.category),
block: [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
],
is_mob: true,
persistent: m.persistent
|| horizon_client::mobs::ai::requires_custom_persistence(m),
});
scratch.spawn_players.clear();
scratch
.spawn_players
.extend(players.iter().map(|p| SpawnPlayer {
position: [p.position.x, p.position.y, p.position.z],
spectator: p.spectator,
})
.collect();
}));
let spawn_players = std::mem::take(&mut scratch.spawn_players);
laps.lap("sn_materialize");
let mut state = SpawnState::new(&level, spawnable, players, &counted, |cx, cz| {
let mut state = SpawnState::new(&level, spawnable, spawn_players, counted, |cx, cz| {
self.world.chunks.contains_key(&(cx, cz))
});
laps.lap("sn_state");
@@ -854,7 +874,8 @@ impl<'a> Game<'a> {
Vec::new()
};
laps.lap("sn_categories");
// Util.shuffle.
// Util.shuffle, every pass: it draws from the level's random even when no
// category may spawn.
for i in (1..scratch.candidates.len()).rev() {
let j = random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
scratch.candidates.swap(i, j);
@@ -885,6 +906,7 @@ impl<'a> Game<'a> {
);
}
}
scratch.spawn_players = state.into_players();
}
spawn_selection::attempt_counters(true);
laps.lap("sn_attempts");
@@ -93,10 +93,9 @@ impl ApplicationHandler for Missing {
if self.window.is_some() {
return;
}
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 attributes =
horizon_client::window::with_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)
+1 -1
View File
@@ -185,7 +185,7 @@ impl Game<'_> {
let output = self
.renderer
.as_ref()
.map_or(Default::default(), |r| *r.output());
.map_or(Default::default(), |r| r.output());
ui.draw(&self.settings, output, batch, &self.gui.atlas, self.cursor);
self.ui.options = ui;
self.show_calibration((batch.width, batch.height));
+148 -15
View File
@@ -17,6 +17,7 @@ use horizon_client::{
keymapping::SNEAK,
pad_glyph::{Glyph, Glyphs},
};
use horizon_client_input::radial;
use serde_json::{Value, json};
use std::time::{Duration, Instant};
use winit::event::MouseButton;
@@ -26,7 +27,8 @@ pub(super) const HELP: &[&str] = &[
"pad BUTTON down|up|tap a virtual controller button (south east west north lb rb lt rt select start ls rs up down left right)",
"pad stick left|right X Y tilt a virtual stick (-1..1, up positive); 0 0 centres it",
"pad trigger left|right VALUE pull a virtual trigger (0..1)",
"pad release centre and release the virtual controller",
"pad release centre and release the virtual controller (as an unplug: the radial menu cancels)",
"pad focus true|false the window gains or loses the focus, as a window focus event does",
"pad status connected controllers, the active one and its state",
"pad connection connected [FAMILY]|disconnected as if a controller (PlayStation unless FAMILY) came or went at this game's own reader",
"pad family xbox|playstation|nintendo|auto the pad playing is that family's (its glyphs; a Nintendo one's letters)",
@@ -86,6 +88,31 @@ pub(super) struct Pads {
connections: Vec<Connection>,
/// The cursor the pad drives in menus.
pub(super) menu: super::pad_menu::MenuCursor,
/// The radial menu while its opener is held, and the page it stays on.
pub(super) radial: radial::RadialMenu,
/// What the radial menu ran, for `pad status`.
radial_runs: RadialRuns,
/// The moments the radial menu's overlay moves from (`radial_overlay::Look`).
pub(super) radial_look: horizon_client_render::radial_overlay::Look,
/// The player's skin for the overlay's Emote face, read as it opens.
radial_face: Option<String>,
/// The D-pad's quiet after the menu closed on a D-pad opener.
radial_settle: radial::DpadSettle,
/// The narrator said the page shown, and the slot selected.
radial_page_said: bool,
radial_said: radial::Settle<(usize, usize)>,
/// A page turn's second tick, when the pages went round an end.
radial_rumble_due: Option<(Instant, Rumble)>,
/// The furthest each stick leant (left, right) between the last two polls, from the pad's
/// own events: a flick out and back between two frames still reaches the radial menu.
peaks: [[f32; 2]; 2],
/// With Either selecting: the stick that last leant past its select tilt (0 left, 1 right),
/// and whether each was past it at the last poll.
radial_either: Option<usize>,
radial_over: [bool; 2],
/// While the radial menu is open with Either: each stick has been at rest since the open, so
/// it may take over. A thumb still on the walking stick can't commit for the other.
radial_rested: [bool; 2],
/// The last vibration asked for, for `pad status`.
last_rumble: Option<(&'static str, gamepad::Pulse)>,
/// The drop button, while it is held in play.
@@ -111,13 +138,37 @@ struct Context {
enabled: bool,
}
mod capture;
mod guide;
#[cfg(feature = "controllers")]
mod hardware;
#[cfg(test)]
mod parity_tests;
mod radial_play;
pub(super) use hardware::{PadId, PadRouter};
/// How many of the radial menu's last actions `pad status` lists.
const RADIAL_RUNS_KEPT: usize = 16;
/// What the radial menu ran: how many actions in all, and the last few, last one last.
#[derive(Default)]
struct RadialRuns {
count: u64,
last: std::collections::VecDeque<&'static str>,
}
impl RadialRuns {
fn push(&mut self, action: &'static str) {
self.count += 1;
if self.last.len() == RADIAL_RUNS_KEPT {
self.last.pop_front();
}
self.last.push_back(action);
}
}
/// The volume of a UI button's click (`AbstractButton.playDownSound`).
const UI_CLICK_VOLUME: f32 = 0.25;
/// How long the death screen's confirm ignores cancelling (`ConfirmScreen`).
const DEATH_CONFIRM_WAIT: Duration = Duration::from_secs(1);
@@ -185,6 +236,9 @@ mod hardware {
pub(in crate::game) fn state(&self, _: PadId) -> PadState {
PadState::default()
}
pub(in crate::game) fn peaks(&self, _: PadId) -> [[f32; 2]; 2] {
[[0.0; 2]; 2]
}
pub(in crate::game) fn name(&self, _: PadId) -> String {
String::new()
}
@@ -225,10 +279,18 @@ impl Pads {
}
/// Splitscreen: this player's pad state, from `source`. Another pad, or
/// none: the old one's A going away isn't a release.
pub(super) fn feed_from(&mut self, source: Option<PadId>, state: PadState) {
/// `peaks`: the furthest its sticks leant since the last feed (`Pads::peaks`).
pub(super) fn feed_from(
&mut self,
source: Option<PadId>,
state: PadState,
peaks: [[f32; 2]; 2],
) {
self.peaks = peaks;
if self.feed_source != source {
self.feed_source = source;
self.menu.armed = None;
self.radial.cancel();
self.menu.stop_craft();
}
self.feed = Some(state);
@@ -246,19 +308,25 @@ impl Pads {
/// A replay ended: let go of everything it held.
pub(super) fn clear_injected(&mut self) {
self.menu.armed = None;
self.radial.cancel();
self.menu.stop_craft();
self.injected = PadState::default();
}
/// Include releases delayed until the next simulation tick.
pub(super) fn holds_action(&self, settings: &gamepad::PadSettings, action: &str) -> bool {
settings.binding(action).is_some_and(|button| {
self.edges.is_held(button) || self.late_releases.contains(&button)
/// Whether a held button's press went to `action`, including releases delayed until the next
/// simulation tick. What the press reached, not what the button is bound to now: a press the
/// radial menu swallowed holds nothing, while a sneak held since before the menu opened is
/// still held through it.
pub(super) fn holds_action(&self, action: &str) -> bool {
Button::ALL.into_iter().any(|button| {
self.actions[button as usize].contains(&action)
&& (self.edges.is_held(button) || self.late_releases.contains(&button))
})
}
/// A new player entity took over (spawn, respawn, a server joined, a
/// dimension change): its health is not compared with the old one's.
/// The radial menu closes with it, in `Game::pad_player_replaced`.
pub(super) fn player_replaced(&mut self) {
self.health.forget();
}
@@ -291,7 +359,7 @@ impl Game<'_> {
screen: self.key_screen(),
focus_navigation: self.pad_navigation_mode(),
focused: self.focused,
osk: self.pad.menu.osk.is_some(),
osk: self.pad_keyboard_captures(),
dialog: self.dialog_open() || self.code_of_conduct.is_some(),
enabled: self.settings.controller.enabled,
}
@@ -311,6 +379,10 @@ impl Game<'_> {
if !self.settings.controller.enabled {
self.pad_output_off();
self.pad_cancel_inputs(event_loop);
// The radial menu closed with the pad: its overlay fades out, as for a cancel.
self.pad
.radial_look
.observe(None, std::time::Instant::now());
self.pad.state = PadState::default();
self.pad.in_use = false;
// The keyboard's menu focus follows its widget all the same.
@@ -364,6 +436,7 @@ impl Game<'_> {
}
if self.pad.blocked_sticks[i] || !self.pad_focused() {
*stick = [0.0; 2];
self.pad.peaks[i] = [0.0; 2];
}
}
for button in std::mem::take(&mut self.pad.taps) {
@@ -377,6 +450,7 @@ impl Game<'_> {
self.pad.keyboard_took_over = false;
}
self.pad.state = state;
self.pad_radial_frame();
self.pad_drop_frame();
self.pad_output_frame();
// Damage from any source, here or on a server; nothing counts on
@@ -390,14 +464,24 @@ impl Game<'_> {
self.pad_rumble(Rumble::Hurt(damage));
}
let ticking = self.title.is_none() && self.screen == Screen::Playing && !self.pause;
let mut due = Vec::new();
for button in std::mem::take(&mut self.pad.late_releases) {
if !ticking || self.pad.pressed_at[button as usize] != Some(self.ticks) {
self.pad_button_logged(event_loop, button, false);
due.push(button);
} else {
self.pad.late_releases.push(button);
}
}
for (button, down) in edges {
// The steps, in order: a press in the same poll as the opener's release, held back or
// not, gets there first, so B still cancels.
for step in radial_play::poll_order(due, edges, self.pad_radial_captures()) {
let (button, down) = match step {
radial_play::PollStep::Late(button) => {
self.pad_button_logged(event_loop, button, false);
continue;
}
radial_play::PollStep::Edge(button, down) => (button, down),
};
self.pad_edge(event_loop, button, down);
let context = self.pad_context();
if let Some(old) = self.pad.context.filter(|old| *old != context) {
@@ -430,10 +514,12 @@ impl Game<'_> {
/// End held actions and captures before another context receives input.
fn pad_cancel_inputs(&mut self, event_loop: &dyn EventLoopCtl) {
// Before the releases below: a cancelled press must not click, nor a cancelled trigger
// change the recipe book's category as it comes up.
// Before the releases below: a cancelled press must not click, a cancelled trigger
// change the recipe book's category as it comes up, nor a cancelled radial menu
// commit when its opener comes up.
self.pad.menu.armed = None;
self.pad.menu.trigger = None;
self.pad.radial.cancel();
self.direct_craft_stop();
let held_sticks = [
gamepad::radial(self.pad.state.left, self.settings.controller.move_deadzone)
@@ -501,7 +587,19 @@ impl Game<'_> {
if down && !self.pad_focused() {
return Ok(());
}
if !down {
self.pad_radial_button_up(button);
}
// While the radial menu is open its presses are its own, but for the triggers'.
if down && self.pad_radial_captures() && self.pad_radial_button(button)? {
return Ok(());
}
let playing = !self.pad_in_menu();
// A thumb rolling off a D-pad opener: its press, and so its release, reach nothing.
if down && playing && self.pad_radial_settling(button) {
self.pad.actions[button as usize].clear();
return Ok(());
}
if down && playing && !self.captured && self.focused {
// Like a click into the window: take the pointer.
self.capture(true);
@@ -517,7 +615,16 @@ impl Game<'_> {
// Releases always reach the mappings, so nothing stays held when a
// screen opened in between.
let actions = if down {
let actions = self.settings.controller.actions_of(button);
let mut actions = self.settings.controller.actions_of(button);
// An opener sharing its button (marked in the binds list) has it to
// itself wherever it can open, and gives it up elsewhere.
if actions.contains(&gamepad::RADIAL) {
if self.pad_radial_can_open() {
actions = vec![gamepad::RADIAL];
} else {
actions.retain(|action| *action != gamepad::RADIAL);
}
}
self.pad.actions[button as usize] = actions.clone();
actions
} else {
@@ -537,6 +644,14 @@ impl Game<'_> {
return Ok(());
};
match route {
Route::Radial => {
if down {
self.pad_radial_open();
Ok(())
} else {
self.pad_radial_release()
}
}
Route::HotbarStep { next } => {
if down {
self.hotbar_step(next);
@@ -588,6 +703,13 @@ impl Game<'_> {
}
Ok(())
}
// KeyboardHandler: F3 alone, let go, shows or hides the debug screen.
Route::DebugOverlay => {
if down {
self.debug = !self.debug;
}
Ok(())
}
Route::Held => Ok(()),
Route::Mouse(button) => {
let playing = self.title.is_none() && self.screen == Screen::Playing;
@@ -618,7 +740,7 @@ impl Game<'_> {
/// Each poll: a held drop button, as a held Q's key repeats reach the mapping.
fn pad_drop_frame(&mut self) {
if self.pad_in_menu() || self.screen_over_playing() {
if self.pad_in_menu() || self.screen_over_playing() || self.pad_radial_captures() {
return;
}
let interval = Duration::from_secs_f32(self.settings.controller.repeat_interval);
@@ -673,7 +795,11 @@ impl Game<'_> {
/// The left stick as the move vector, while playing.
pub(super) fn pad_move(&self) -> Option<[f32; 2]> {
let settings = &self.settings.controller;
if !settings.enabled || self.pad_in_menu() || !self.pad_focused() {
if !settings.enabled
|| self.pad_in_menu()
|| !self.pad_focused()
|| (self.pad_radial_captures() && self.pad_radial_selects(0))
{
return None;
}
gamepad::move_vector(self.pad.state.left, settings.move_deadzone)
@@ -682,7 +808,12 @@ impl Game<'_> {
/// The right stick turns the view, once per frame (`Entity.turn`).
pub(super) fn pad_look(&mut self, seconds: f32) {
let settings = &self.settings.controller;
if !settings.enabled || self.pad_in_menu() || self.player.is_none() || !self.pad_focused() {
if !settings.enabled
|| self.pad_in_menu()
|| self.player.is_none()
|| !self.pad_focused()
|| (self.pad_radial_captures() && self.pad_radial_selects(1))
{
return;
}
// A long frame (a hitch) must not spin the view.
@@ -786,6 +917,7 @@ impl Game<'_> {
json!({ "title": title, "body": body })
}),
"menu": self.pad_menu_status(),
"radial": self.pad_radial_status(),
"guide": self.pad_guide_status(),
"held": held,
"left": s.left,
@@ -823,6 +955,7 @@ impl Game<'_> {
}
"release" => {
self.pad.menu.armed = None;
self.pad.radial.cancel();
self.direct_craft_stop();
self.pad.injected = PadState::default();
self.pad.taps.clear();
@@ -0,0 +1,50 @@
//! Which of the overlays over play owns the controller now, for the checks that ask about the
//! on-screen keyboard and the radial menu. The prompts' own checks still call `prompt_open`
//! directly (`pad.rs`, `pad_actions.rs`, `pad_menu.rs`, `pad/guide.rs`); here a prompt only
//! outranks the radial menu.
//!
//! In priority order, highest first ([`PadCapture`]): the on-screen keyboard, then a prompt over
//! the world (a server dialog, the code of conduct, a link, command or resource pack prompt),
//! then the radial menu. Only one owns the pad at a time; the radial menu never opens under the
//! others and closes when one comes up (`pad_radial_can_open`, `pad_radial_frame`), so the order
//! only settles the poll in which both are up. Screens are not overlays: `pad_in_menu` says
//! when one has the pad.
use super::super::Game;
/// An overlay that owns the controller, highest priority first.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(in crate::game) enum PadCapture {
/// The on-screen keyboard: every press types or moves on it.
Keyboard,
/// A prompt over the world, answered before anything behind it.
Prompt,
/// The radial menu, held open in play: its stick selects, its buttons page and cancel.
Radial,
}
impl Game<'_> {
/// The overlay that owns the pad now, if one does.
pub(in crate::game) fn pad_capture(&self) -> Option<PadCapture> {
if self.pad.menu.osk.is_some() {
Some(PadCapture::Keyboard)
} else if self.prompt_open() {
Some(PadCapture::Prompt)
} else if self.pad.radial.is_open() {
Some(PadCapture::Radial)
} else {
None
}
}
/// The on-screen keyboard owns the pad.
pub(in crate::game) fn pad_keyboard_captures(&self) -> bool {
self.pad_capture() == Some(PadCapture::Keyboard)
}
/// The radial menu owns the pad: its stick doesn't walk or turn the view, its buttons are its
/// own, and a bumped mouse does nothing. What is drawn gives way only once it shows
/// (`pad_radial_shown`): a quick tap draws nothing.
pub(in crate::game) fn pad_radial_captures(&self) -> bool {
self.pad_capture() == Some(PadCapture::Radial)
}
}
+4 -1
View File
@@ -35,6 +35,9 @@ impl Game<'_> {
&& settings.guide_detail != GuideDetail::Off
&& self.pad.in_use
&& !self.pad.keyboard_took_over
// Hidden once the radial menu shows, not on the opener's press: a quick tap draws
// nothing, so the guide doesn't blink.
&& !self.pad_radial_shown()
&& !self.pad_in_menu()
&& !self.screen_over_playing()
&& self.sleeping.is_none()
@@ -118,7 +121,7 @@ impl Game<'_> {
|| self.pad.keyboard_took_over
|| self.prompt_open()
|| self.title.is_some()
|| self.pad.menu.osk.is_some()
|| self.pad_keyboard_captures()
{
return;
}
+44 -4
View File
@@ -223,8 +223,14 @@ impl Pads {
return PadState::default();
};
let active = self.devices.active;
self.peaks = [[0.0; 2]; 2];
while let Some(gilrs::Event { id, event, .. }) = gilrs.next_event() {
use gilrs::EventType as E;
// The furthest each stick of the active pad leant between the events, which a read
// after them all misses (`Pads::peaks`).
if matches!(event, E::AxisChanged(..)) && self.devices.active == Some(id) {
keep_peaks(&mut self.peaks, sticks_of(&gilrs.gamepad(id)));
}
match event {
E::Connected => {
log::info!("Controller connected: {}", gilrs.gamepad(id).name());
@@ -265,6 +271,7 @@ impl Pads {
// Another pad, or none: its A going away isn't a release.
if self.devices.active != active {
self.menu.armed = None;
self.radial.cancel();
self.menu.stop_craft();
}
let Some(pad) = self
@@ -284,6 +291,16 @@ impl Pads {
}
}
/// The furthest each stick leant: `sticks` (left, right) where they lean further than `peaks`.
fn keep_peaks(peaks: &mut [[f32; 2]; 2], sticks: [[f32; 2]; 2]) {
use horizon_client_input::radial::stick_length;
for (peak, stick) in peaks.iter_mut().zip(sticks) {
if stick_length(stick) > stick_length(*peak) {
*peak = stick;
}
}
}
/// How far a stick or trigger has to go for its pad to become the active one.
const TAKEOVER: f32 = 0.5;
@@ -329,6 +346,13 @@ fn state_of(pad: &gilrs::Gamepad) -> PadState {
state.set(button, pad.is_pressed(named));
}
}
[state.left, state.right] = sticks_of(pad);
state.triggers = triggers::triggers(pad);
state
}
/// A pad's sticks now (left, right).
fn sticks_of(pad: &gilrs::Gamepad) -> [[f32; 2]; 2] {
let axis = |a| pad.value(a);
use gilrs::Axis as A;
let sdl_mapped = pad.mapping_source() == gilrs::MappingSource::SdlMappings;
@@ -341,14 +365,12 @@ fn state_of(pad: &gilrs::Gamepad) -> PadState {
sdl_mapped,
);
let (left, right) = sdl_row::sticks(pad, left, right);
state.left = left;
state.right = if hid_layout::right_stick_on_z(pad) {
let right = if hid_layout::right_stick_on_z(pad) {
hid_layout::right_stick([axis(A::LeftZ), axis(A::RightZ)])
} else {
right
};
state.triggers = triggers::triggers(pad);
state
[left, right]
}
/// A controller, as splitscreen tells them apart.
@@ -362,6 +384,8 @@ pub(in crate::game) struct PadRouter {
started: bool,
/// Vibrations playing while several play, each until its end.
rumbles: Vec<(gilrs::ff::Effect, Instant)>,
/// The furthest each pad's sticks leant since the last `events` (`Pads::peaks`).
peaks: Vec<(PadId, [[f32; 2]; 2])>,
}
impl PadRouter {
@@ -445,7 +469,15 @@ impl PadRouter {
return Vec::new();
};
let mut events = Vec::new();
self.peaks.clear();
while let Some(gilrs::Event { id, event, .. }) = gilrs.next_event() {
if matches!(event, gilrs::EventType::AxisChanged(..)) {
let sticks = sticks_of(&gilrs.gamepad(id));
match self.peaks.iter_mut().find(|(pad, _)| *pad == id) {
Some((_, peaks)) => keep_peaks(peaks, sticks),
None => self.peaks.push((id, sticks)),
}
}
match event {
gilrs::EventType::ButtonPressed(pressed, _) => {
let crossed = face_buttons::crossed(&gilrs.gamepad(id));
@@ -481,6 +513,14 @@ impl PadRouter {
.map_or([0; 16], |g| g.gamepad(id).uuid())
}
/// The furthest the pad's sticks leant since the last `events`.
pub(in crate::game) fn peaks(&self, id: PadId) -> [[f32; 2]; 2] {
self.peaks
.iter()
.find(|(pad, _)| *pad == id)
.map_or([[0.0; 2]; 2], |(_, peaks)| *peaks)
}
/// The pad's state now (released when it is gone).
pub(in crate::game) fn state(&self, id: PadId) -> PadState {
self.gilrs
@@ -0,0 +1,599 @@
//! The radial menu in play (`horizon-client-input/src/radial.rs` has its rules,
//! `radial_overlay.rs` draws it): what opens and closes it, what the pad's presses do while it is
//! open, and what a commit runs.
use super::{Game, Result, UI_CLICK_VOLUME};
use horizon_client::gamepad::MenuJob;
use horizon_client::gamepad::{self, Button, RadialMode, RadialStick, Rumble};
use horizon_client::pad_glyph::Glyph;
use horizon_client::text::Text;
use horizon_client_input::radial::{self, RadialAction, Slot};
use horizon_client_render::radial_overlay::{self, Dial, Seen, Toggles, View};
use serde_json::{Value, json};
use std::time::{Duration, Instant};
/// The D-pad's buttons, which settle after the menu closes on a D-pad opener.
const DPAD: [Button; 4] = [
Button::DPadUp,
Button::DPadDown,
Button::DPadLeft,
Button::DPadRight,
];
/// One step of a poll's button changes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum PollStep {
/// A release held back from the poll before, until a simulation tick had passed.
Late(Button),
Edge(Button, bool),
}
/// The order a poll acts on its changes in: the releases held back first, then this poll's
/// presses and releases. While the radial menu is open its presses come before every release, the
/// held-back ones too: B in the same poll as the opener's release (however late) still cancels.
pub(super) fn poll_order(
late: Vec<Button>,
edges: Vec<(Button, bool)>,
radial_open: bool,
) -> Vec<PollStep> {
let late = late.into_iter().map(PollStep::Late);
let edges = edges.into_iter().map(|(b, down)| PollStep::Edge(b, down));
if !radial_open {
return late.chain(edges).collect();
}
let (presses, releases): (Vec<_>, Vec<_>) =
edges.partition(|step| matches!(step, PollStep::Edge(_, true)));
presses.into_iter().chain(late).chain(releases).collect()
}
impl Game<'_> {
/// Whether the radial menu selects with this stick (0 left, 1 right): while it is open that
/// stick neither walks nor turns the view.
pub(in crate::game) fn pad_radial_selects(&self, stick: usize) -> bool {
match self.settings.controller.radial_stick {
RadialStick::Right => stick == 1,
RadialStick::Left => stick == 0,
RadialStick::Either => true,
}
}
/// Each stick's tilts, from its own deadzone (left, right).
fn pad_radial_tilts(&self) -> [radial::Tilt; 2] {
let settings = &self.settings.controller;
[settings.move_deadzone, settings.look_deadzone].map(radial::Tilt::for_deadzone)
}
/// The stick that selects now (0 left, 1 right), where it is, the furthest it leant since the
/// last poll, and its tilts. With Either, the stick that last leant past the select tilt.
fn pad_radial_selector(&self) -> (usize, [f32; 2], [f32; 2], radial::Tilt) {
let index = match self.settings.controller.radial_stick {
RadialStick::Right => 1,
RadialStick::Left => 0,
RadialStick::Either => self.pad.radial_either.unwrap_or(1),
};
let stick = [self.pad.state.left, self.pad.state.right][index];
(
index,
stick,
self.pad.peaks[index],
self.pad_radial_tilts()[index],
)
}
/// With Either: a stick that has just leant past its select tilt (or did between two polls)
/// takes over the selecting. While the menu is open, only one that has been at rest since
/// the open: the walking thumb easing forward on its frozen stick isn't aiming. The stick
/// that takes over selects at once, whatever the other was waiting for.
fn pad_radial_follow_either(&mut self) {
let tilts = self.pad_radial_tilts();
let sticks = [self.pad.state.left, self.pad.state.right];
let open = self.pad.radial.is_open();
for i in 0..2 {
if radial::stick_length(sticks[i]) < tilts[i].rest {
self.pad.radial_rested[i] = true;
}
let reach =
radial::stick_length(sticks[i]).max(radial::stick_length(self.pad.peaks[i]));
let over = reach >= tilts[i].select;
let may = !open || self.pad.radial_rested[i];
if over && !self.pad.radial_over[i] && may {
let before = self.pad.radial_either.replace(i).unwrap_or(1);
if open && before != i {
self.pad.radial.took_over();
}
}
self.pad.radial_over[i] = over;
}
}
/// Screens, the on-screen keyboard and the prompts over gameplay come first. It isn't a
/// screen: the world goes on behind it, here and on a server.
pub(in crate::game) fn pad_radial_can_open(&self) -> bool {
!self.pad_in_menu()
&& !self.pad_keyboard_captures()
&& !self.screen_over_playing()
&& self.player.is_some()
&& self.sleeping.is_none()
&& !self.pause
}
/// The opener went down: always on the first page.
pub(in crate::game) fn pad_radial_open(&mut self) {
let tilts = self.pad_radial_tilts();
let sticks = [self.pad.state.left, self.pad.state.right];
self.pad.radial_rested =
std::array::from_fn(|i| radial::stick_length(sticks[i]) < tilts[i].rest);
let (index, stick, _, tilt) = self.pad_radial_selector();
self.pad.radial.open(index, stick, tilt, Instant::now());
self.pad.radial_face = self.pad_radial_face();
self.pad.radial_page_said = false;
self.pad.radial_said.reset();
}
/// The opener came up: held, it commits; in toggle mode the menu stays open, until A, the
/// opener again or B.
pub(in crate::game) fn pad_radial_release(&mut self) -> Result<()> {
if !self.pad.radial.is_open() {
return Ok(());
}
if self.settings.controller.radial_mode == RadialMode::Toggle && !self.pad.radial.latched()
{
self.pad.radial.latch();
return Ok(());
}
self.pad_radial_commit(false)
}
/// Whether `action` would do anything now. Only what is known here, cheaply and for certain:
/// the emote wheel opens only with cosmetics on, Quick Actions only when the server gave a
/// dialog for it, and Social only on a server (`Gui.handleKeybinds`). Everything else on the
/// list always runs.
fn pad_radial_runs(&self, action: RadialAction) -> bool {
self.pad_radial_why(action).is_none()
}
/// Why `action` wouldn't do anything now, as a language key for the overlay's hub; `None`
/// when it runs.
fn pad_radial_why(&self, action: RadialAction) -> Option<&'static str> {
match action {
RadialAction::Emote if !super::super::cosmetics::enabled() => Some(WHY_COSMETICS),
RadialAction::QuickActions if !self.has_quick_actions() => Some(WHY_QUICK_ACTIONS),
RadialAction::SocialInteractions if !self.is_multiplayer_server() => Some(WHY_SOCIAL),
_ => None,
}
}
/// Which of the page's slots would run now: the overlay dims the others. A missing action
/// never runs.
pub(in crate::game) fn pad_radial_available(&self) -> [bool; radial::SLOTS] {
self.pad_radial_available_on(self.pad.radial.page())
}
/// `pad_radial_available` on page `page`.
fn pad_radial_available_on(&self, page: usize) -> [bool; radial::SLOTS] {
let page = self.settings.controller.radial.page(page);
page.slots().map(|slot| match slot {
Slot::Empty => true,
Slot::Action(action) => self.pad_radial_runs(*action),
Slot::Missing(_) => false,
})
}
/// A slot that holds something that would run: only those tick as the stick reaches them.
fn pad_radial_fills(&self, slot: usize) -> bool {
let page = self.settings.controller.radial.page(self.pad.radial.page());
!page.slot(slot).is_empty() && self.pad_radial_available()[slot]
}
/// Each poll, before its presses: the selecting stick, the D-pad's settle, a second page
/// tick due; and while it's open, the stick, or the end of it if something took over (a bed,
/// a death) in a way the context doesn't see.
pub(in crate::game) fn pad_radial_frame(&mut self) {
let now = Instant::now();
self.pad_radial_poll(now);
let menu = &self.pad.radial;
let seen = menu.shown(now).then(|| Seen {
page: menu.page(),
selected: menu.selected(),
waiting: menu.waiting(),
});
self.pad.radial_look.observe(seen, now);
}
/// `pad_radial_frame`'s work, before the overlay's look sees the result.
fn pad_radial_poll(&mut self, now: Instant) {
self.pad
.radial
.sample([self.pad.state.left, self.pad.state.right], now);
if self.settings.controller.radial_stick == RadialStick::Either {
self.pad_radial_follow_either();
}
let (_, stick, peak, tilt) = self.pad_radial_selector();
if let Some((at, rumble)) = self.pad.radial_rumble_due
&& now >= at
{
self.pad.radial_rumble_due = None;
self.pad_rumble(rumble);
}
if !self.pad.radial.is_open() {
return;
}
if !self.pad_radial_can_open() {
self.pad_radial_cancel();
return;
}
let was = self.pad.radial.selected();
self.pad.radial.update_with_peak(stick, peak, tilt, now);
// A tick as the stick reaches a slot that would run, so a flick can be felt.
let selected = self.pad.radial.selected();
if selected != was && selected.is_some_and(|slot| self.pad_radial_fills(slot)) {
self.pad_rumble(Rumble::RadialSlot);
}
self.pad_radial_narrate(now);
}
/// With the narrator on: the page's name once the overlay shows and after each page turn,
/// and the slot's caption once the selection settles.
fn pad_radial_narrate(&mut self, now: Instant) {
if horizon_narrator::status() == horizon_narrator::Status::Off
|| !self.pad.radial.shown(now)
{
return;
}
let pages = &self.settings.controller.radial;
let page = self.pad.radial.page();
if !std::mem::replace(&mut self.pad.radial_page_said, true) {
let name = horizon_client_render::radial_overlay::page_label(pages.page(page));
self.say_system_now(&Text::literal(name));
}
let at = self.pad.radial.selected().map(|slot| (page, slot));
if let Some((page, slot)) = self.pad.radial_said.poll(at, now) {
let slot = pages.page(page).slot(slot);
let caption = horizon_client_render::radial_overlay::slot_caption(slot)
.unwrap_or_else(|| horizon_client::lang::get("horizon.radial.empty"));
self.say_system_now(&Text::literal(caption));
}
}
/// A press while it's open: bumpers turn the page, B and Start cancel; in toggle mode A or
/// the opener again commits. A trigger cancels and goes on to its own action (`false`: not
/// used up), so a fight goes on; no other press reaches a mapping.
pub(in crate::game) fn pad_radial_button(&mut self, button: Button) -> Result<bool> {
let settings = &self.settings.controller;
let opener = settings.binding(gamepad::RADIAL);
let toggle = settings.radial_mode == RadialMode::Toggle;
let bumpers = [Button::LeftBumper, Button::RightBumper];
match button {
// Toggle mode, latched open: the opener again commits.
b if Some(b) == opener => {
if self.pad.radial.latched() {
self.pad_radial_commit(false)?;
}
}
// The thumb slid off the stick to A: what it had settled on, not what it crossed.
Button::South if toggle => self.pad_radial_commit(true)?,
b if bumpers.contains(&b) => {
// With a bumper for the opener, the other turns the pages on.
let forward = opener.is_some_and(|o| bumpers.contains(&o)) || b == bumpers[1];
self.pad_radial_turn(forward);
}
Button::East | Button::Start => self.pad_radial_cancel(),
Button::LeftTrigger | Button::RightTrigger => {
self.pad_radial_cancel();
return Ok(false);
}
_ => {}
}
Ok(true)
}
/// A page turn: a tick, and a second one when the pages went round an end.
fn pad_radial_turn(&mut self, forward: bool) {
let (_, stick, _, tilt) = self.pad_radial_selector();
let pages = self.settings.controller.radial.len();
let Some(wrapped) = self.pad.radial.turn_page(forward, stick, tilt, pages) else {
return;
};
self.pad.radial_look.turned(forward);
self.pad_rumble(Rumble::RadialPage);
if wrapped {
self.pad.radial_rumble_due =
Some((Instant::now() + radial::WRAP_TICK_GAP, Rumble::RadialWrap));
}
self.pad.radial_page_said = false;
self.pad.radial_said.reset();
}
/// The selected action, pressed and let go, once. Every entry acts on its press, and nothing
/// stays held behind it. One that can't run now does nothing at all, and a release that runs
/// nothing after something was selected gives a soft buzz.
/// `by_a`: toggle mode's A, pressed by the thumb that was selecting (`RadialMenu::commit_settled`).
fn pad_radial_commit(&mut self, by_a: bool) -> Result<()> {
let page = self.pad.radial.page();
let aimed = self.pad.radial.aimed();
let selecting = self.pad_radial_selector().0;
let now = Instant::now();
let slot = if by_a {
self.pad.radial.commit_settled(now)
} else {
self.pad.radial.commit(now)
};
self.pad_radial_closed(selecting);
// Social, dimmed in single player, still goes through its key there: vanilla's action
// bar says it isn't available (`Gui.handleKeybinds`).
let action = slot
.and_then(|slot| {
self.settings
.controller
.radial
.page(page)
.slot(slot)
.action()
})
.filter(|action| {
self.pad_radial_runs(*action) || *action == RadialAction::SocialInteractions
});
let Some(action) = action else {
if aimed {
self.pad_rumble(Rumble::RadialNothing);
}
return Ok(());
};
self.pad.radial_runs.push(action.id());
if let Some(slot) = slot {
self.pad.radial_look.ran(slot);
}
let shoots = matches!(action, RadialAction::Screenshot | RadialAction::ToggleGui);
let action = action.action();
self.audio
.play("minecraft:ui.button.click", None, UI_CLICK_VOLUME, 1.0);
// The release runs whatever the press did, so no key stays held.
let pressed = self.pad_action(action, true);
let released = self.pad_action(action, false);
// What opened a screen, takes a screenshot or hides the HUD gets the view to itself at
// once: no flash or fade over it.
if shoots || self.pad_in_menu() || self.screen_over_playing() {
self.pad.radial_look.cut();
}
pressed.and(released)
}
/// B, Start, a trigger, a key or a click, a takeover the context doesn't see: the menu closes
/// and nothing runs. (The window's focus is in the context: losing it cancels in
/// `pad_cancel_inputs`.)
pub(in crate::game) fn pad_radial_cancel(&mut self) {
if self.pad.radial.is_open() {
let selecting = self.pad_radial_selector().0;
self.pad.radial.cancel();
self.pad_radial_closed(selecting);
}
}
/// After a commit or a cancel: the stick that was selecting (`selecting`, 0 left, 1 right),
/// and with Either both, moves the player or the view only once it has been let go; a held drop starts its wait
/// again rather than throwing the rest of the stack the moment the menu is gone; and on a
/// D-pad opener the D-pad settles.
fn pad_radial_closed(&mut self, selecting: usize) {
let now = Instant::now();
// With Either neither stick moved anything while it was open: both wait.
let either = self.settings.controller.radial_stick == RadialStick::Either;
for i in 0..2 {
if i != selecting && !either {
continue;
}
let deadzone = [
self.settings.controller.move_deadzone,
self.settings.controller.look_deadzone,
][i];
let stick = [self.pad.state.left, self.pad.state.right][i];
if gamepad::radial(stick, deadzone) != [0.0; 2] {
self.pad.blocked_sticks[i] = true;
self.pad.peaks[i] = [0.0; 2];
match i {
0 => self.pad.state.left = [0.0; 2],
_ => self.pad.state.right = [0.0; 2],
}
}
}
if self.pad.held_drop.is_some() {
let delay = Duration::from_secs_f32(self.settings.controller.repeat_delay);
self.pad.held_drop = Some(gamepad::HeldDrop::new(now, delay));
}
if self
.settings
.controller
.binding(gamepad::RADIAL)
.is_some_and(|b| DPAD.contains(&b))
{
self.pad.radial_settle.closed(now);
}
}
/// A D-pad press in play starting just after the menu closed on a D-pad opener: a thumb
/// rolling off it. It is ignored, and so is its release; the opener's own isn't.
pub(in crate::game) fn pad_radial_settling(&self, button: Button) -> bool {
let opener = self.settings.controller.binding(gamepad::RADIAL);
let held = opener.is_some_and(|opener| self.pad.state.held(opener));
DPAD.contains(&button)
&& self
.pad
.radial_settle
.quiet(opener == Some(button), held, Instant::now())
}
/// A button came up: the opener's release starts the D-pad's settle again.
pub(in crate::game) fn pad_radial_button_up(&mut self, button: Button) {
if self.settings.controller.binding(gamepad::RADIAL) == Some(button) {
self.pad.radial_settle.opener_up(Instant::now());
}
}
/// A new player entity (spawn, respawn, a server joined, a dimension change): its health is
/// not compared with the old one's, and the radial menu closes as for a cancel, as a commit
/// would run on the new body (a death with immediate respawn, the End's exit).
pub(in crate::game) fn pad_player_replaced(&mut self) {
self.pad.player_replaced();
self.pad_radial_cancel();
}
pub(in crate::game) fn pad_radial_status(&self) -> Value {
let menu = &self.pad.radial;
let pages = &self.settings.controller.radial;
let page = pages.page(menu.page());
json!({
"open": menu.is_open(),
"latched": menu.latched(),
"page": menu.page(),
"pages": pages.len(),
"name": horizon_client_render::radial_overlay::page_label(page),
"slots": page.slots().map(|slot| (!slot.is_empty()).then(|| slot.id())),
"available": self.pad_radial_available(),
"selected": menu.selected(),
"aimed": menu.aimed(),
"shown": menu.shown(Instant::now()),
"drawn": self.pad_radial_drawn(self.hud_clock()).is_some(),
// The overlay's glyphs as the player sees them: the bumpers that turn the pages and
// the button that cancels, by their printed labels.
"glyphs": {
"pages": self.pad_radial_page_keys().map(|key| key.map(|glyph| glyph.label())),
"cancel": self.menu_glyph(MenuJob::Back).label(),
},
"waiting": menu.waiting(),
"selecting": self.pad_radial_selector().0,
"mode": self.settings.controller.radial_mode.name(),
"stick": self.settings.controller.radial_stick.name(),
"runs": self.pad.radial_runs.last,
"run_count": self.pad.radial_runs.count,
})
}
/// What the pad draws over the HUD, last: the radial menu, then the pad's cursor or focus
/// highlight and the on-screen keyboard over everything else (`draw_pad_cursor`).
pub(in crate::game) fn draw_pad_over_hud(&self, batch: &mut horizon_client::hud::HudBatch) {
self.draw_pad_radial(batch);
self.draw_pad_cursor(batch);
}
/// The overlay, over the HUD, in this player's view: while it shows, and while it fades out.
fn draw_pad_radial(&self, batch: &mut horizon_client::hud::HudBatch) {
let clock = self.hud_clock();
let look = &self.pad.radial_look;
let Some(seen) = self.pad_radial_drawn(clock) else {
return;
};
let pages = &self.settings.controller.radial;
let page = pages.page(seen.page);
let why = page.slots().map(|slot| match slot {
Slot::Action(action) => self.pad_radial_why(*action),
_ => None,
});
let dial = Dial {
pages,
seen,
available: self.pad_radial_available_on(seen.page),
why,
toggles: self.pad_radial_toggles(),
page_keys: self.pad_radial_page_keys(),
cancel: self.menu_glyph(MenuJob::Back),
backing: (self.settings.text_background(radial_overlay::CHIP_BACKING) >> 24) as u8,
room: &self.ui.room,
};
let painter = super::super::item_draw::IconPainter {
game: self,
face: self.pad.radial_face.as_deref(),
};
radial_overlay::draw_overlay(batch, &self.gui.atlas, &painter, &dial, look, clock);
}
/// What the overlay draws at `clock`: while it shows, and while it fades out, but never a
/// fade-out over a screen that came up meanwhile.
fn pad_radial_drawn(&self, clock: horizon_client::hud_kit::motion::Clock) -> Option<Seen> {
let look = &self.pad.radial_look;
if !look.open() && (self.pad_in_menu() || self.screen_over_playing()) {
return None;
}
look.showing(clock)
}
/// The overlay is showing (not only fading out): it holds the view (`overlay_holds_view`).
pub(in crate::game) fn pad_radial_shown(&self) -> bool {
self.pad.radial_look.open()
}
/// The toggles the overlay shows the state of.
fn pad_radial_toggles(&self) -> Toggles {
use super::super::perspective::CameraType;
Toggles {
view: match self.camera_type {
CameraType::FirstPerson => View::FirstPerson,
CameraType::ThirdPersonBack => View::ThirdPersonBack,
CameraType::ThirdPersonFront => View::ThirdPersonFront,
},
hud_hidden: self.ui.hide_gui,
cinematic: self.smooth_camera,
}
}
/// The bumpers that turn the pages back and on, in this player's glyphs. With a bumper as the
/// opener, only the other, which turns them on.
fn pad_radial_page_keys(&self) -> [Option<Glyph>; 2] {
let opener = self.settings.controller.binding(gamepad::RADIAL);
let [back, on] = [MenuJob::PagePrev, MenuJob::PageNext];
match opener {
Some(Button::LeftBumper) => [None, Some(self.menu_glyph(on))],
Some(Button::RightBumper) => [None, Some(self.menu_glyph(back))],
_ => [Some(self.menu_glyph(back)), Some(self.menu_glyph(on))],
}
}
/// The player's own skin, for the Emote slot's face: their entry in the player list, which
/// single player has too (the window is its server thread's client). Before the entry or its
/// texture has come, the default skin their id gives, as the player list draws it.
fn pad_radial_face(&mut self) -> Option<String> {
let me = self.remote.as_ref().map(|r| r.profile());
let skin = match (me, self.remote_tab_players()) {
(Some(me), Some((players, _, _))) => players
.into_iter()
.find(|p| p.uuid == me)
.map(|p| (me, p.skin)),
_ => None,
};
let me = me?;
Some(match skin {
Some((me, skin)) => self.face_skin(me, &skin),
None => horizon_client::tab_list::default_skin(me).to_owned(),
})
}
}
/// The hub's reasons a slot can't run (language keys).
const WHY_COSMETICS: &str = "horizon.radial.why.cosmetics";
const WHY_QUICK_ACTIONS: &str = "horizon.radial.why.quick_actions";
const WHY_SOCIAL: &str = "horizon.radial.why.social";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_press_in_the_poll_of_a_held_back_opener_release_comes_first_while_open() {
let late = vec![Button::DPadRight];
let edges = vec![(Button::East, true), (Button::South, false)];
assert_eq!(
poll_order(late.clone(), edges.clone(), true),
[
PollStep::Edge(Button::East, true),
PollStep::Late(Button::DPadRight),
PollStep::Edge(Button::South, false),
]
);
// Closed, the held-back releases go first, then the changes as they came.
assert_eq!(
poll_order(late, edges, false),
[
PollStep::Late(Button::DPadRight),
PollStep::Edge(Button::East, true),
PollStep::Edge(Button::South, false),
]
);
}
}
@@ -103,7 +103,7 @@ impl Game<'_> {
pub(super) fn pad_action_glyphs_on(&self) -> bool {
self.pad.in_use
&& !self.pad.menu.keyboard
&& self.pad.menu.osk.is_none()
&& !self.pad_keyboard_captures()
&& self.pad.menu.pos.is_some()
&& self.pad_in_menu()
}
+24 -7
View File
@@ -495,10 +495,10 @@ impl Game<'_> {
if self.prompt_open() {
return self.pad_prompt_first(targets).or(targets.first().copied());
}
let rects: Vec<Rect> = targets.iter().map(|t| t.rect).collect();
// Back on a screen the pad left: where it was, as vanilla keeps a
// screen's focused widget (Options, after its Done). The same widget
// first: the rows under it can have moved.
let rects: Vec<Rect> = targets.iter().map(|t| t.rect).collect();
if let Some(i) = self
.pad
.menu
@@ -795,13 +795,16 @@ impl Game<'_> {
}
// A prompt over the screen answers first; no press reaches both.
let prompt = self.prompt_open();
// Bindings waiting for a button: this one (Start cancels).
// Bindings, or the radial menu's opener row, waiting for a button: this one (Start
// cancels).
if !prompt
&& self.screen == Screen::Options
&& self.ui.options.controller_open()
&& let Some(action) = self.ui.options.selected_pad_action.take()
{
if button != Button::Start {
// Start cancels, and so does a button the action can't take (the radial menu's
// opener under the selecting thumb or on B, `gamepad::opener_refused`): the binding
// stays.
if button != Button::Start && self.settings.controller.can_bind(action, button) {
self.settings.controller.bind(action, Some(button));
}
return self.handle_option_events(vec![Event::Changed]);
@@ -1145,6 +1148,8 @@ impl Game<'_> {
}
let focus = self.pad_reveal(focus);
self.pad.menu.focus = Some(focus);
// A step, not a press: a radial slot's press opens its list.
self.ui.options.pad_clicks_step(true);
if button == Button::DPadLeft {
self.pad_with_modifier(KeyCode::ShiftLeft, |game| {
game.pad_click_at(event_loop, focus.center(), false)
@@ -1152,6 +1157,7 @@ impl Game<'_> {
} else {
self.pad_click_at(event_loop, focus.center(), false);
}
self.ui.options.pad_clicks_step(false);
true
}
@@ -1848,7 +1854,16 @@ impl Game<'_> {
// Two frames on, as after a step that scrolled: the first may predate the scroll.
&& !reveal_pending(self.pad.menu.revealed, widgets::generation())
{
if let Some(focus) = self.pad.menu.focus {
// A settings page that says where its focus opens (a radial slot's list on the slot's
// action, the slot again after it), once its row is drawn.
let opened = (self.screen == Screen::Options)
.then(|| self.ui.options.shell_opened_focus())
.flatten()
.and_then(|rect| gamepad::refind(rect, &rects, 2.0));
if let Some(i) = opened {
self.ui.options.clear_shell_opened_focus();
self.pad.menu.focus = Some(targets[i]);
} else if let Some(focus) = self.pad.menu.focus {
let kind = std::mem::discriminant(&focus.kind);
let named = self.pad_named_focus(focus, &rects);
let followed = stick_scrolled.and_then(|s| {
@@ -2019,7 +2034,7 @@ impl Game<'_> {
/// button being bound, the credits, the on-screen keyboard.
fn key_focus_blocked(&self) -> bool {
let playing_screen = |screen| self.title.is_none() && self.screen == screen;
self.pad.menu.osk.is_some()
self.pad_keyboard_captures()
|| playing_screen(Screen::Chat)
|| self.sign_edit.is_some()
|| matches!(self.book, Some(super::books::BookScreen::Edit { .. }))
@@ -2232,8 +2247,10 @@ impl Game<'_> {
return;
}
let takeover = match event {
// Not while the pad holds the radial menu open: as for raw mouse motion in play
// (`device_event`), a cursor that moved is a bumped mouse.
WindowEvent::CursorMoved { position, .. } => {
self.pad.menu.real_pointer != Some(*position)
self.pad.menu.real_pointer != Some(*position) && !self.pad_radial_captures()
}
WindowEvent::KeyboardInput { event, .. } => event.state == ElementState::Pressed,
WindowEvent::MouseInput { state, .. } => *state == ElementState::Pressed,
+14 -26
View File
@@ -280,11 +280,11 @@ impl Game<'_> {
/// front of) it, pulled in where visual shapes are in the way. Returns the
/// position, yaw and pitch to render from.
pub(super) fn camera_view(&mut self, partial: f32) -> Option<(glam::DVec3, f32, f32)> {
if self.studio_freecam.active {
if self.studio.flying() {
return Some((
self.studio_freecam.position,
self.studio_freecam.yaw,
self.studio_freecam.pitch,
self.studio.freecam.position,
self.studio.freecam.yaw,
self.studio.freecam.pitch,
));
}
// Minecraft.cameraEntity: a server's spectator camera on a mob or
@@ -311,8 +311,8 @@ impl Game<'_> {
}
};
let mut view = self.view;
// An emote pulls the camera back from the player's face (`game/cosmetics/emotes.rs`).
let pull = self.emote_camera_pull(partial);
// A hook may pull the camera back (an emote, from the player's face).
let pull = self.hooks_camera_distance(partial);
let kind = match self.camera_type {
CameraType::FirstPerson => horizon_client::camera::CameraType::FirstPerson,
CameraType::ThirdPersonBack => horizon_client::camera::CameraType::ThirdPersonBack,
@@ -643,8 +643,9 @@ impl Game<'_> {
/// The model's old pitch is the player's `xRotO`, which the turn moves
/// with `xRot` (`turn_rotation`).
pub(super) fn turn_player(&mut self, xo: f64, yo: f64) {
if self.studio_freecam.active {
self.studio_freecam
if self.studio.flying() {
self.studio
.freecam
.turn((xo * 0.15) as f32, (yo * 0.15) as f32);
return;
}
@@ -697,7 +698,7 @@ impl Game<'_> {
/// `LevelExtractor`'s skip of the camera entity: the player's own body
/// is drawn unless the camera is in first person, or while it sleeps.
pub(super) fn own_body_in_view(&self) -> bool {
!self.camera_type.first_person() || self.sleeping.is_some() || self.studio_freecam.active
!self.camera_type.first_person() || self.sleeping.is_some() || self.studio.flying()
}
/// Whether the player's own model shows in the world view (its body or,
/// while invisible, only its armour and held items).
@@ -800,19 +801,8 @@ impl Game<'_> {
packed,
out,
);
if super::cosmetics::enabled() {
let base = horizon_client::mobs::render::base_transform(m, alpha as f32, camera);
self.cosmetics_geometry(
m,
true,
base,
Some(camera),
alpha as f32,
packed,
false,
out,
);
}
let world = horizon_hooks::players::Pass::World;
self.own_layers_geometry(m, world, camera, alpha as f32, packed, out);
self.player_elytra_geometry(m, chest, camera, alpha as f32, packed, out);
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
self.shoulder_parrots_geometry(m, camera, alpha as f32, packed, out);
@@ -926,10 +916,8 @@ impl Game<'_> {
240 | 240 << 8,
&mut verts,
);
if super::cosmetics::enabled() {
let base = horizon_client::mobs::render::base_transform(&m, 1.0, camera);
self.cosmetics_geometry(&m, true, base, None, 1.0, 240 | 240 << 8, false, &mut verts);
}
let picture = horizon_hooks::players::Pass::Picture;
self.own_layers_geometry(&m, picture, camera, 1.0, 240 | 240 << 8, &mut verts);
// `WingsLayer` draws through `RenderTypes.armorCutoutNoCull`: the
// wings' faces show from both sides (the elytra texture is empty on
// the face that looks at the body).
+147 -51
View File
@@ -738,14 +738,11 @@ impl Game<'_> {
});
self.set_pushed_on_ground(target, limited, resolved);
// Entity.move updates ground before its tiny-collision return. This
// native slice covers only unoccupied server NoAI horizontal moves.
if let Target::Entity(EntityHandle::Mob(i)) = target {
// shortcut: unoccupied server mobs only, expand when controller/passenger authority is bound.
let mob = if let Target::Entity(EntityHandle::Mob(i)) = target {
let m = &self.mobs[i];
let eligible = !self.options.execution_role.is_client()
(!self.options.execution_role.is_client()
&& self.remote.is_none()
&& limited.y == 0.0
&& m.no_ai
&& !m.extra.passenger
&& m.extra.mount.is_none()
&& m.rider.is_none()
@@ -757,16 +754,22 @@ impl Game<'_> {
.guests
.iter()
.any(|g| host.hub.here(g) && g.vehicle == Some(m.id))
});
if eligible {
self.mobs[i].on_ground = limited.y < 0.0 && limited.y != resolved.y;
}
}))
.then_some(i)
} else {
None
};
// Entity.move updates ground before its tiny-collision return.
if let Some(i) = mob
&& self.mobs[i].no_ai
{
self.mobs[i].on_ground = limited.y < 0.0 && limited.y != resolved.y;
}
let tnt_after_move =
tnt.is_some() && !self.options.execution_role.is_client() && self.remote.is_none();
if mob.is_none() && !tnt_after_move {
self.restitute_piston(target, limited, resolved);
}
// `Entity.move(MoverType.PISTON, …)` restitution, which runs whether or
// not the step is applied: so a primed TNT in a chunk that does not tick
// entities still loses its velocity into walls and ceilings and
// bounces on slime (#12022).
self.restitute_piston(target, limited, resolved);
let moved = resolved.length_squared() > 1.0e-7
|| limited.length_squared() - resolved.length_squared() < 1.0e-7;
if moved {
@@ -775,6 +778,12 @@ impl Game<'_> {
// A move the collisions cut to nothing still ends in the check.
self.release_after_block_move(target);
}
// Restitution reads the new landing block even when the push was blocked.
if let Some(i) = mob {
self.mobs[i].restitute_movement(&self.world, &self.motion.blocks, limited, resolved);
} else if tnt_after_move {
self.restitute_piston(target, limited, resolved);
}
if let Some((i, from, ..)) = tnt {
self.finish_tnt_piston_move(i, from, resolved);
}
@@ -1053,6 +1062,7 @@ mod tests {
mod order_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::chunk::Chunk;
use horizon_core::geometry::Vec3;
use horizon_core::{geometry::Direction, position::BlockPos};
use horizon_world::level::pistons::MovingPiston;
use std::sync::Arc;
@@ -1151,8 +1161,9 @@ mod order_tests {
}
// The near stone's box reaches x 5.5 into a TNT from x 5.01: 0.49 plus
// PUSH_OFFSET; the far stone's old box stops it at x 6.
// Vanilla's TNT width is 0.98F, not the exact decimal 0.98.
assert!(
(moved[0] - 0.5).abs() < 1e-9,
(moved[0] - 0.500000009536743).abs() < 1e-9,
"far stone gone first: {}",
moved[0]
);
@@ -1251,7 +1262,7 @@ mod order_tests {
}
/// A primed TNT pushed down by a piston onto a slime block bounces up,
/// matching vanilla's slime restitution (~+0.64) even in a lazy chunk (#12022).
/// matching vanilla's restitution even in a lazy chunk (#12022).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_pushed_tnt_bounces_on_slime() {
@@ -1312,32 +1323,36 @@ mod order_tests {
death_ticks: 0,
id: 1,
};
game.block_ticks.moving_pistons = vec![(BlockPos::new(5, 200, 5), piston)];
game.sync_moving_pistons();
game.tnts.clear();
assert!(game.run_command("summon tnt 5.5 200.5 5.5 {fuse:80,Motion:[0.0d,-0.4d,0.0d]}"));
assert!(
(game.tnts[0].velocity.y - (-0.4)).abs() < 1.0e-9,
"TNT starts with vy -0.4, got {:?}",
game.tnts[0].velocity
);
// Piston advances from progress 0 to 0.5 and pushes the TNT down.
let pushes: Vec<_> = game
.block_ticks
.moving_pistons
.iter()
.map(|&(pos, old)| (pos, old, old.progress + 0.5))
.collect();
for (_, piston) in &mut game.block_ticks.moving_pistons {
piston.progress += 0.5;
// Pinned 26.3 PistonMovingBlockEntity.moveCollidedEntities, without entity ticks.
for (floor, y, vy, final_y, final_vy) in [
(slime, 200.5, -0.4, 200.0, 0.3510000138133766_f64),
(stone, 200.5, -0.4, 200.0, 0.0),
(slime, 200.0, -0.4, 200.0, 0.3200000047683716),
(slime, 200.00001, -0.4, 200.00001, 0.32000064476813395),
(slime, 200.5, 0.3, 200.0, 0.0),
] {
game.world.set(5, 199, 5, floor).unwrap();
game.block_ticks.moving_pistons = vec![(BlockPos::new(5, 200, 5), piston)];
game.sync_moving_pistons();
game.tnts.clear();
assert!(game.run_command(&format!(
"summon tnt 5.5 {y} 5.5 {{fuse:80,Motion:[0.0d,{vy}d,0.0d]}}"
)));
assert_eq!(game.tnts[0].velocity.y, vy);
let pushes: Vec<_> = game
.block_ticks
.moving_pistons
.iter()
.map(|&(pos, old)| (pos, old, old.progress + 0.5))
.collect();
for (_, piston) in &mut game.block_ticks.moving_pistons {
piston.progress += 0.5;
}
game.push_piston_entities(pushes);
assert_eq!(game.tnts[0].position, Vec3::new(5.5, final_y, 5.5));
assert_eq!(game.tnts[0].velocity.y.to_bits(), final_vy.to_bits());
assert!(game.tnts[0].on_ground);
}
game.push_piston_entities(pushes);
// The slime block at y=199 bounces the TNT up; vertical velocity must be positive.
assert!(
game.tnts[0].velocity.y > 0.0,
"TNT should bounce up on slime, got vy {:?}",
game.tnts[0].velocity
);
}
}
@@ -1602,6 +1617,54 @@ mod h255_mob_piston_ground_tests {
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn h255_added_tnt_authority_and_exhausted_budget() {
use crate::game::execution_role::ExecutionRole;
for role in [
ExecutionRole::Client,
ExecutionRole::IntegratedServer,
ExecutionRole::DedicatedServer,
] {
with_role_game(role, |game| {
assert!(game.run_command("setblock 5 199 5 slime_block"));
assert!(
game.run_command("summon tnt 5.5 200.5 5.5 {fuse:80,Motion:[0d,-0.4d,0d]}")
);
let mut budget = std::collections::HashMap::new();
let push = |game: &mut Game<'_>,
budget: &mut std::collections::HashMap<_, _>,
movement| {
let tnt = &game.tnts[0];
game.move_pushed(
Target::Entity(EntityHandle::Tnt(0)),
tnt.bounds(),
[tnt.position.x, tnt.position.z],
0.0,
tnt.on_ground,
movement,
Direction::Down,
budget,
);
};
push(game, &mut budget, Vec3::ZERO);
assert_eq!(game.tnts[0].position.y, 200.5);
assert_eq!(game.tnts[0].velocity.y, -0.4);
push(game, &mut budget, Vec3::new(0.0, -0.51, 0.0));
let expected = if role.is_client() {
0.0_f64
} else {
0.3510000138133766
};
assert_eq!(game.tnts[0].velocity.y.to_bits(), expected.to_bits());
game.tnts[0].position.y = 200.5;
game.tnts[0].velocity.y = -0.4;
push(game, &mut budget, Vec3::new(0.0, -0.51, 0.0));
assert_eq!(game.tnts[0].position.y, 200.5);
assert_eq!(game.tnts[0].velocity.y, -0.4);
});
}
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn h255_added_no_request_and_exhausted_budget_keep_state() {
with_game(|game| {
let i = added_mob(game, 20, "horse", true, 8.5);
@@ -1613,6 +1676,16 @@ mod h255_mob_piston_ground_tests {
added_push(game, i, Vec3::new(0.51, 0.0, 0.0));
assert!(game.mobs[i].on_ground, "occupied path remains excluded");
assert_eq!(game.mobs[i].position, Vec3::new(9.01, 202.0, 4.5));
assert!(game.run_command("setblock 9 201 4 slime_block"));
game.mobs[i].position.y = 202.25;
game.mobs[i].velocity = Vec3::new(0.0, -1.0, 0.0);
game.mobs[i].sync_position = false;
added_push(game, i, Vec3::new(0.0, -0.5, 0.0));
assert_eq!(
game.mobs[i].velocity.y, 0.0,
"occupied restitution stays outside this slice"
);
assert!(!game.mobs[i].sync_position);
assert!(game.run_command("ride @s dismount"));
assert!(game.riding.is_none());
assert!(game.mobs[i].rider.is_none());
@@ -1655,23 +1728,46 @@ mod h255_mob_piston_ground_tests {
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn h255_added_client_role_ai_and_vertical_paths_keep_native_state() {
fn h255_added_client_role_ai_and_vertical_ground_state() {
use crate::game::execution_role::ExecutionRole as Role;
for (role, no_ai, movement) in [
(Role::Client, true, Vec3::new(0.25, 0.0, 0.0)),
(Role::IntegratedServer, false, Vec3::new(0.25, 0.0, 0.0)),
(Role::IntegratedServer, true, Vec3::new(0.0, 0.25, 0.0)),
for (role, no_ai, movement, on_ground) in [
(Role::Client, true, Vec3::new(0.25, 0.0, 0.0), true),
(
Role::IntegratedServer,
false,
Vec3::new(0.25, 0.0, 0.0),
true,
),
(
Role::IntegratedServer,
true,
Vec3::new(0.0, 0.25, 0.0),
false,
),
] {
with_role_game(role, |game| {
let i = added_mob(game, 22, "husk", no_ai, 8.5);
let before = game.mobs[i].position;
added_push(game, i, movement);
assert_eq!(game.mobs[i].position, before.add_vector(movement));
assert!(
game.mobs[i].on_ground,
"excluded native path remains unchanged; not Java parity"
);
assert_eq!(game.mobs[i].on_ground, on_ground);
});
}
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn h255_added_partial_push_reads_slime_after_the_position_changes() {
with_game(|game| {
assert!(game.run_command("setblock 8 201 4 slime_block"));
let i = added_mob(game, 23, "iron_golem", true, 8.5);
game.mobs[i].position.y = 202.25;
game.mobs[i].velocity = Vec3::new(0.0, -1.0, 0.0);
game.mobs[i].sync_position = false;
added_push(game, i, Vec3::new(0.0, -0.5, 0.0));
assert_eq!(game.mobs[i].position, Vec3::new(8.5, 202.0, 4.5));
assert!(game.mobs[i].velocity.y > 0.9);
assert!(game.mobs[i].sync_position);
});
}
}
+5 -7
View File
@@ -114,9 +114,7 @@ impl Game<'_> {
// A pad's toggle-sneak isn't holding it: it has no release that would end it.
Source::Keyboard => {
!(slot == SNEAK && self.settings.controller.toggle_sneak)
&& self
.pad
.holds_action(&self.settings.controller, ["key.sneak", "key.sprint"][slot])
&& self.pad.holds_action(["key.sneak", "key.sprint"][slot])
}
Source::Pad => self.physical_keys.iter().any(|physical| {
self.settings
@@ -180,7 +178,7 @@ impl Game<'_> {
raw: KeyCode,
source: Source,
) -> Result<()> {
if self.screen == Screen::Playing && self.studio_replay.is_some() {
if self.screen == Screen::Playing && self.replay.playing() {
if !pressed {
self.keys.release(&key, source);
return Ok(());
@@ -191,7 +189,7 @@ impl Game<'_> {
}
return Ok(());
}
if !studio_freecam_movement_key(key) || !self.studio_freecam.active {
if !studio_freecam_movement_key(key) || !self.studio.flying() {
return Ok(());
}
}
@@ -459,7 +457,7 @@ impl Game<'_> {
/// Whether a device holds the key mapping named `mapping` (`key.use`).
pub(super) fn input_holds(&self, mapping: &str) -> bool {
self.pad.holds_action(&self.settings.controller, mapping)
self.pad.holds_action(mapping)
}
/// Turns the view by what the devices give this frame (`Entity.turn`).
@@ -481,7 +479,7 @@ impl Game<'_> {
/// A new player entity took over (spawn, respawn, a server joined, a dimension change).
pub(super) fn input_player_replaced(&mut self) {
self.pad.player_replaced();
self.pad_player_replaced();
}
/// Whether the drop about to happen is a device's whole-stack drop (it has no Control key to
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff. Show more