Compare commits

..
Author SHA1 Message Date
levzzz 1405044d09 Merge pull request #14569 from Ragbie/fix/discord-socket-test-cfg
horizon-discord: #[cfg(unix)] back on the socket test, so the tests build on Windows
2026-11-10 21:01:13 +05:00
Ludovic Marie 8933e8c2d8 Merge pull request #14741 from horizon-rust/governance/elections-tiers
🤖 Agent: founder-imposed governance amendment of 2026-11-10 (Ludovic's go, ~09:55Z): three kinds of action, elections by tier, the permissions table checked against the live Discord, panel, ops list and GitHub, and P41 with a 24 h veto; docs only, verify green on this head. The council can overturn it by poll.
2026-11-10 11:25:36 +01:00
Horizon agentandClaude Opus 5.5 226a707d0d GOVERNANCE.md: three kinds of action, elections by tier, who may do what, P41
Founder-imposed amendment of 2026-11-10. Free actions need no vote; a role
with power or a body's own decision is a 24-hour body vote with a 24-hour
council veto; everything else stays a council poll (48 h only for this
document, the licence, money and org ownership). Adds the election table,
its hard cases and Kira's reminders (5.3), the permissions table checked
against the live Discord, panel, ops list and GitHub teams (2.2), and P41:
the mods decide moderation matters, council veto 24 h (section 7).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 11:06:36 +01:00
Evrim CicekandClaude Opus 5.5 5f0ea2347a horizon-discord: #[cfg(unix)] back on the socket test
Fixes #14568: 62e62c21e6 put the switch test between the socket test's
attribute and the test, so the socket test (std::os::unix) compiled on
Windows and the switch test ran only on unix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 03:13:09 +03:00
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
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
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
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
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
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
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
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 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
MSedgeMC d56b769df5 Merge upstream/main 2026-11-09 18:00:08 +00: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
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
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
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
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
54 changed files with 9462 additions and 1245 deletions

No files matched your search

+145 -45
View File
@@ -2,6 +2,9 @@
> 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).
>
> Amended 2026-11-10 by the founder (three kinds of action, elections by tier, who may do what, and council poll P41:
> the mods decide moderation matters). The council can overturn this amendment by poll.
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
@@ -50,13 +53,13 @@ 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** |
| **Contributor** (⛏️ Iron, 🥇 Gold, 💎 Diamond, 🟪 Netherite ranks) | Write in #contributors; `/claim` issues; vote in their group's elections (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; vote in the group's elections and votes when active | Pick the group's role in Channels & Roles, or with `/groups` | Remove the role | No |
| **🤝 Support** | Answer and close posts in #horizon-support | Given by any moderator (a free action, section 2.1) | Resign, 30 days inactive, or removed by a moderator, the Chief of Mods or the council | No |
| **🛡️ Moderator** | Time out, delete messages, manage threads, quarantine. No bans | Elected by the mods (section 5.3); council veto within 24 h | Resign, 30 days inactive, suspended by the Chief of Mods, recalled by the 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 | Elected by the mods, from the mods with at least 30 days (section 5.3); council veto within 24 h | Resign, 30 days inactive, recalled by the mods, 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 | Elected by the group's active members and the Team (section 5.3); council veto within 24 h; 30 days probation | Resign, 30 days inactive (automatic), recalled by the same electorate, 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 | Elected by the Team, 2/3 (section 5.3); council veto within 24 h; 30 days probation | Resign, 60 days without a merge or review (automatic), recalled by the Team (2/3), 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
@@ -64,6 +67,55 @@ owner, Discord server owner, the Microsoft app approved by Mojang, the domains,
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.
### 2.1 Three kinds of action
Most things need no vote at all. A vote is for power over other people, and for decisions that bind a whole body.
| Kind | What | How |
| --- | --- | --- |
| **Free** | Everyday actions inside your own role (the list below, and anything of the same weight) | Just do it. Kira logs the ones she sees in #staff-log. The tier above can reverse it; if people disagree, the tier above votes. A pattern of abuse is a recall |
| **Body vote** | Giving or taking a role that carries power (an election), or a decision that belongs to one body: the mods, the Team, a group | `/elect` or `/vote body:` in Kira (section 5.3). Any council member can call a veto within 24 hours |
| **Council poll** | Rules, money, accounts and ownership, the council's own members, vetoes, anything that spans several bodies | `/poll` (section 4) |
Free actions, for example:
- a lead gives or removes their group's helper role (🧩 Horizon Modding Team), through Kira;
- a moderator gives or removes 🤝 Support, through Kira;
- a moderator times out, deletes, quarantines, or changes a channel's slowmode or topic; the Chief of Mods bans and
unbans;
- the Team merges and labels;
- a server operator restarts the official server, or prunes backups and chunks by the written rule;
- a lead pins, renames or creates threads in their group's channels;
- Kira's automatic actions.
**The 24-hour rule.** Every vote lasts 24 hours at most, and closes earlier once its result can't change. Only council
polls on this document, the licence, money and the GitHub organisation's ownership last 48 hours. A veto is called
within 24 hours of a result.
### 2.2 Who may do what
What each post decides, and what it holds on Discord, on the official server and on GitHub. Anything not listed is a
council matter.
| Post | Decides | Discord | Official server | GitHub |
| --- | --- | --- | --- | --- |
| **Member** | — | Chat, threads, picks group roles | Play | Issues, pull requests, `/claim` |
| **Group helper** (🧩 Horizon Modding Team) | — | Writes in the group's team channel | — | — |
| **🤝 Support** | Closes support posts | Manages threads in #horizon-support | — | — |
| **🛡️ Moderator** | Moderation matters, by the mods' vote (section 7) | Time out, delete messages, manage threads and nicknames, audit log, voice mute and move; quarantine through Kira; the staff channels | In-game op if also elected operator | — |
| **🛡️ Chief of Mods** | A moderator's, plus bans and unbans; suspends a mod at once; breaks ties in the mods' votes | Plus Ban | As a moderator | — |
| **Server operator** | In-game moderation by the server rules | — | Op (the server's ops list); no panel, no `server.toml` | — |
| **🧭 Server & SMP lead** | The server's settings, by the written rules (backup first, warning in game) | As every lead | A panel sub-user once a council accounts poll gives one | As every lead |
| **🧭 Group lead** | The group's free actions; gives and removes its helper role | In the group's channels: delete messages, manage and pin threads, ping the group; the helper role through Kira | — | Triage on the group's labels through Kira (no repository role) |
| **🌌 Horizon Team** | Merges; elects Team members | #horizon-team and the staff channels. The role still carries timeout and delete; whether it keeps them is a mod matter | — | `horizon-team`: write on horizon, horizon-mods, horizon-mixins, horizon-native-mod-template and horizon-server; also kira and horizon-site if P37 passes |
| **🔧 Server Manager** | — | Manage Channels and Manage Roles (the server's layout); given by council poll | — | — |
| **🏛️ Council** | Council polls, vetoes, emergencies (any 2) | A moderator's permissions plus Ban, mention @everyone and pin; #council | Panel sub-users for two members if P38 passes | The `council` team: code-owner reviews and the release tools' owner powers if P35 passes, security managers if P36 passes; a second organisation owner if P28 passes |
| **Account holder** (the founder) | Carries out decisions on his accounts (section 11) | Server owner | The panel's owner account | Organisation owner |
Kira applies a passed election everywhere she can: Discord roles herself, GitHub team membership when the council has
given her a token for it. Whatever she can't do (a panel sub-user, the ops list, a GitHub team without a token) she
posts as a list of clicks for a named person.
## 3. Each level in detail
### Member
@@ -84,13 +136,13 @@ an issue or a pull request on GitHub, or a request for a council poll (any counc
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.
least one counted action in the group's area (section 5.1) in the last 60 days. Active group members vote in the
group's elections and decisions (section 5.3).
### 🤝 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.
The role carries no permission, so it has no age rule. A moderator gives it, and takes it back, through Kira.
### 🛡️ Moderators and the Chief of Mods
@@ -151,7 +203,7 @@ don't go through the filter or a probation. They do make the 18+ declaration (se
| --- | --- |
| 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 |
| How long | 24 hours; 48 hours for this document, the licence, money and the GitHub organisation's ownership. 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 |
@@ -165,20 +217,20 @@ don't go through the filter or a probation. They do make the 18+ declaration (se
| 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 |
| Day-to-day project decisions: direction, priorities, settling disputes between groups, a public statement | Adding council members |
| Vetoing a body's vote or election (section 5.3) | Removing a council member or a Team member (without the person concerned) |
| Removing a group lead, a moderator, the Chief of Mods or Support (their bodies can also recall them) | 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) |
| Deciding for a body that has fewer than 3 voters | 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
Polls that add someone to the council start with a **24-hour public discussion**. Other polls, and elections, 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
@@ -220,7 +272,7 @@ false age declaration (section 9).
## 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.
measures the work, and only people above a floor can be nominated. Their body then elects them (section 5.3).
### 5.1 What Kira measures
@@ -254,25 +306,63 @@ A nomination is refused below these floors.
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
### 5.3 Elections
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.
The body that works with a person elects them. Kira runs it: `/elect post: member: [group:]`, and `action: remove`
for a recall.
| Post | Who votes | Passes with | Who can start it |
| --- | --- | --- | --- |
| **🛡️ Moderator** | The mods (🛡️ Mod and 🛡️ Chief of Mods) | Simple majority; the Chief of Mods breaks a tie | Any mod, a council member |
| **🛡️ Chief of Mods** | The mods; candidates are mods for 30 days or more | Simple majority | Any mod, a council member |
| **Server operator** | The mods and the 🧭 Server lead | Simple majority | The Server lead, the Chief of Mods, a council member |
| **🧭 Group lead** (a co-lead once the group has 8 active members) | The group's active members, and the Team | Simple majority | The current lead, a Team member, a council member |
| **🌌 Horizon Team** | The Team | Two thirds, and at least 3 Yes | A Team member, a council member |
| **🏛️ Council** | The council, by poll after a 24-hour discussion (section 4) | Two thirds, and at least 3 Yes | A council member |
| 🤝 Support, a group's helper role | Nobody: a free action (section 2.1) | — | A moderator; the group's lead |
An **active group member** has held the group's role for 14 days or more (Kira counts from when she first saw it)
and has at least one merged pull request or review with the group's label in the last 60 days.
1. **Start.** Kira checks the floor (5.2) and the 18+ declaration. If the candidate falls short, the person starting
gets a private "not now", and the candidate a private message saying what is missing. Nothing is posted and no
reason is ever shown to anyone else. Kira checks what she can measure (verified GitHub, merged pull requests and
reviews, overall and by the group's label, rank, days in the server, Kira level, clean record, the declaration);
the voters judge the rest.
2. **Vote, 24 hours,** in the body's channel, discussed in the vote's thread. Yes, No or Abstain, changeable while it
runs. Kira shows how many voted, never who. It closes early once the result can't change.
3. **Result,** applied at once. Kira gives or takes the Discord role and, with a token, the GitHub team; she lists the
rest as clicks for a named person. Only the result is published, never the ballots.
4. **Veto, 24 hours.** Any council member can call it with the button under the result. A council poll (simple
majority, without the people involved) decides; if it passes, Kira undoes the result.
5. **Probation, 30 days,** for leads and Team members. It ends by itself unless someone who can vote starts a recall.
The council can always remove someone by its own poll. **Body votes** work the same way for a body's own decisions:
`/vote body:mods`, `body:team` or `body:group`, simple majority, 24 hours, the same veto.
#### Hard cases
| Case | What happens |
| --- | --- |
| Fewer than 3 can vote | The council decides by poll |
| Quorum | A third of the electorate, and at least 3 ballots (Abstain counts). Without it, nothing changes |
| Tie | Fails, and nothing changes. In the mods' votes, the Chief of Mods' ballot breaks it |
| The candidate | Doesn't vote on themselves, and can decline (the election ends). The person who started it votes normally |
| Conflicts | Whoever the vote is about, or who gave or received the moderation action in question, doesn't vote |
| Several candidates for one seat | Nominations within the first 12 hours join one approval ballot. The seat goes to the most approvals if that is at least half of the voters; otherwise a 24-hour run-off between the top two |
| A seat that is held | Changes only by a recall or by the 6-month confirmation |
| The council is already voting on it | No election starts for the same person and post |
| Cooldown | A failed or vetoed election can't be started again for the same person and post for 30 days. At most 3 elections run at once in a body |
| Vacancy | When a lead or the Chief of Mods leaves, Kira opens the seat's election herself: 24 hours of nominations, then the vote with everyone nominated. Nobody nominated: the seat stays open, and the tier above acts meanwhile. Kira never picks a candidate |
| Abuse of a free action | The tier above reverses it; a disagreement goes to its vote. A pattern is a recall |
| Emergency | Section 4.6. The Chief of Mods can still suspend a mod at once |
| Kira is down | The body may run the same vote as a Discord poll in its channel for 24 hours; Kira records the result when she is back |
#### Reminders, not nominations
Once a month in a group's channel, Kira says how to propose someone, only when the group has no lead or someone in it
meets a post's floor. When a seat opens, she posts once in the electorate's channel. Once a month in #council, she
lists privately the people who meet a floor. These are suggestions: only people start elections.
### 5.4 Exceptions
@@ -299,8 +389,8 @@ holder or an org owner removes GitHub access when the Team role ends.
### 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.
- **Group leads** are confirmed every 6 months by an election in their group. If someone else in the group passes the
floor and is nominated within its first 12 hours, both go on the same ballot.
- **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).
@@ -339,8 +429,9 @@ the design system:
- 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;
- giving and removing the group's helper role, through Kira (a free action);
- 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.
- asking the Team for reviews, adding `needs-proof`, and starting elections in the group.
### 6.4 What goes to the council
@@ -356,21 +447,28 @@ the design system:
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:**
channel. Kira posts once that the seat is open and how to start its election (section 5.3). **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 mods decide moderation matters** (council poll P41, imposed by the founder on 2026-11-10): who joins or
leaves the mod team, the mods' permissions and the staff channels, bans and unbans, and channel settings for
moderation. Everyday actions are free (section 2.1). Anything bigger is a mods' vote (`/vote body:mods`, simple
majority, 24 hours), and the Chief of Mods breaks ties. **The council keeps a veto:** within 24 hours of a mods'
decision, any council member can call a council poll, and a simple majority overturns it.
- **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.
- **Second appeal:** for bans, timeouts over 24 hours and quarantines, a mods' vote in #mod-chat. Mods who were
involved don't vote, and the council's veto applies. 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.
- **Removing a moderator:** the Chief of Mods can suspend a moderator at once, and the mods decide within 72 hours by
a recall vote. The mods recall the Chief of Mods the same way. The council can also remove either by poll.
- **Council members keep the ban permission** for emergencies and when no moderator is around. They log the ban like
any other, and the mods can reverse it by vote.
- **Staff are moderated like everyone.** When a council member is involved, the council decides without them.
## 8. Conflicts and hard cases
@@ -458,7 +556,9 @@ and the founder carries it out.
## 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.
For polls that aren't about a person, each member's vote is shown once the poll closes. The results of elections
and body votes are posted there too; for elections, only the result.
- **#staff-log:** the free actions Kira sees (roles given or taken, bans, unbans), so the tier above can check them.
- **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
@@ -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
@@ -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",
@@ -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);
}
}
@@ -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,
+3
View File
@@ -110,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;
@@ -170,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;
@@ -58,6 +58,9 @@ pub struct OverlayColours {
/// 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.
@@ -109,6 +112,8 @@ pub const NEUTRAL: MenuStyle = MenuStyle {
rule: 0x303030,
accent: 0xffffff,
on_accent: 0x000000,
lit: 0xffffff,
lit_edge: 0xffffff,
},
sizes: Sizes {
caption: 12.0,
+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))]);
}
_ => {}
}
}
}
@@ -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
@@ -869,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());
}
@@ -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",
@@ -1020,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",
+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();
+10 -11
View File
@@ -5832,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 {
@@ -5963,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| {
@@ -6092,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| {
@@ -6191,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);
});
@@ -6262,11 +6266,7 @@ 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);
@@ -9424,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);
@@ -9979,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);
+27
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);
@@ -1103,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).
@@ -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>,
+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);
});
+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,
+3 -5
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
@@ -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
+10 -1
View File
@@ -146,7 +146,16 @@ impl Game<'_> {
// an active use in the same tick.
self.release_use_for_keybind_screen();
}
// key.socialInteractions.
// key.socialInteractions (Gui.handleKeybinds): only on a server; in single player
// the action bar says so, and the narrator.
KeyCode::KeyP if self.screen == Screen::Playing && !self.is_multiplayer_server() => {
let message = horizon_client::text::Text::translate(
"multiplayer.socialInteractions.not_available",
&[],
);
self.titles.set_overlay(message.clone(), false);
self.say_system_now(&message);
}
KeyCode::KeyP if self.screen == Screen::Playing => {
self.open_social(false);
self.release_use_for_keybind_screen();
+2 -4
View File
@@ -917,10 +917,8 @@ impl Game<'_> {
self.drag_button = None;
self.modifiers = Default::default();
// A press this player's pad holds goes on, as for its keys.
self.left = self
.pad
.holds_action(&self.settings.controller, "key.attack");
self.right = self.pad.holds_action(&self.settings.controller, "key.use");
self.left = self.pad.holds_action("key.attack");
self.right = self.pad.holds_action("key.use");
self.inventory_mouse_release(false);
self.inventory_mouse_release(true);
self.inventory_pick_release();
+10 -2
View File
@@ -130,6 +130,12 @@ impl Split<'_> {
}
}
/// The furthest a player's pad leant its sticks since the last frame: nothing for a pad just
/// joined whose buttons are still being let go.
fn pad_peaks(&self, pad: Option<PadId>) -> [[f32; 2]; 2] {
pad.filter(|id| !self.fresh.contains(id))
.map_or([[0.0; 2]; 2], |id| self.pads.peaks(id))
}
/// The pad's state for its player: nothing while the press that gave it is still held.
fn pad_state(&mut self, pad: Option<PadId>) -> PadState {
let Some(id) = pad else {
@@ -282,14 +288,16 @@ impl Split<'_> {
self.pads.report_status(&mut self.host.pad, owner);
}
let state = self.pad_state(owner);
self.host.pad.feed_from(owner, state);
let peaks = self.pad_peaks(owner);
self.host.pad.feed_from(owner, state, peaks);
for i in 0..self.guests.len() {
let pad = self.guests[i].pad;
if self.guests[i].game.control.is_some() {
self.pads.report_status(&mut self.guests[i].game.pad, pad);
}
let state = self.pad_state(pad);
self.guests[i].game.pad.feed_from(pad, state);
let peaks = self.pad_peaks(pad);
self.guests[i].game.pad.feed_from(pad, state, peaks);
}
joins
}
@@ -111,6 +111,18 @@ impl Game<'_> {
{
self.input_keyboard_used();
}
// A key or a mouse button pressed while the pad holds the radial menu open closes it,
// and goes on to do what it does. Moving the mouse does neither (`device_event`).
let pressed = match &event {
WindowEvent::KeyboardInput { event, .. } => {
event.state == ElementState::Pressed && !event.repeat
}
WindowEvent::MouseInput { state, .. } => *state == ElementState::Pressed,
_ => false,
};
if pressed && !self.pad.menu.synthetic {
self.pad_radial_cancel();
}
if let WindowEvent::MouseInput { state, button, .. } = &event {
if *state == ElementState::Pressed {
self.physical_buttons.insert(*button);
@@ -351,6 +363,13 @@ impl ApplicationHandler for Game<'_> {
&& self.screen == Screen::Playing
&& let DeviceEvent::MouseMotion { delta: (x, y) } = event
{
// While the pad holds the radial menu open the mouse does nothing, as the right stick
// doesn't turn the view: it neither cancels the menu nor takes control back, nor
// turns the view. A thumb is on the opener, so the motion is a bumped desk.
if self.pad_radial_captures() {
return;
}
// Moving the mouse hands control back from the pad.
if x != 0.0 || y != 0.0 {
self.pad_yield_input(event_loop);
}
@@ -33,6 +33,8 @@ pub(crate) fn give() {
rule: dark.rule,
accent: dark.accent,
on_accent: dark.on_accent,
lit: theme::night::NIGHT.accent,
lit_edge: theme::night::NIGHT.accent_hi,
},
sizes: Sizes {
caption: theme::CAPTION.px,
+61
View File
@@ -2205,6 +2205,67 @@ pub fn hoglin_anim(m: &Mob, s: &State, p: &mut Poses) {
p.xrot("left_hind_leg", pos.mcos() * 1.2 * sp);
}
/// `Brain.Packed`: the guarded-block live ANGRY_AT entry. The existing
/// anger counter supplies its remaining long TTL, not an absolute timestamp.
pub fn write_guarded_anger(m: &Mob, map: &mut horizon_world::nbt::Compound) {
use horizon_world::nbt::{Compound, Tag};
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());
}
}
/// `Piglin.readAdditionalSaveData` through `Brain.Packed`: the guarded-block
/// `ANGRY_AT` entry is an entity UUID with the ticks left as its TTL. Vanilla
/// reads the memory with the brain, and an expired one no longer angers the
/// piglin; the counter is what Horizon's goals ask.
pub fn read_guarded_anger(m: &mut Mob, tag: &horizon_world::nbt::Tag) {
use horizon_world::nbt::Tag;
let memory = tag
.get("Brain")
.and_then(|b| b.get("memories"))
.and_then(|b| b.get("minecraft:angry_at"));
let Some(memory) = memory else { return };
let Some(uuid) = crate::save::uuid_from_tag(memory.get("value")) else {
return;
};
// The TTL is a long: one past the counter's range holds as long as it can.
let ttl = match memory.get("ttl") {
Some(Tag::Long(v)) => (*v).clamp(0, i64::from(i32::MAX)) as i32,
Some(t) => crate::nbt_path::int_value(t).unwrap_or(i32::MAX),
None => i32::MAX,
};
m.extra.anger = ttl.max(0);
m.extra.piglin.guarded_angry_at = Some(Some(uuid));
}
#[cfg(test)]
mod brute_sound_tests {
use super::*;
+2 -2
View File
@@ -709,8 +709,6 @@ mod tests {
assert_eq!(limit.wait(t + WINDOW), Duration::from_secs(1));
}
/// A fake Discord: answers the handshake with READY, each command with its nonce, after a ping.
#[cfg(unix)]
/// The switch as the player's settings and the Extras page see it. The only test that
/// touches the presence's statics.
#[test]
@@ -742,6 +740,8 @@ mod tests {
SECTION.act(0, Act::Press);
}
/// A fake Discord: answers the handshake with READY, each command with its nonce, after a ping.
#[cfg(unix)]
#[test]
fn talks_to_discord_over_its_socket() {
let dir = std::env::temp_dir().join(format!("horizon-discord-{}", std::process::id()));
-1
View File
@@ -7,7 +7,6 @@ To play, use [the launcher](../launcher/README.md); the project site is [playhor
- [AGENTS.md](../AGENTS.md) and [Architecture](architecture.md): where each part of the code lives, how `Game` is organised, which harness checks which area.
- [How Horizon is built](how-horizon-is-built.md): who writes Horizon, how changes are checked, and what is safe to run.
- [Release flow](release-flow.md): pull request checks, merging and the automatic Nightly releases that reach players.
- [Operations](operations.md): running Horizon by hand when no agent runs and the maintainer's machines are off: merging, releases, offline runners, the mods registry, Kira, the official server, security.
- [Build from source](../README.md#build-from-source): your own copy of the game, resource preparation, `cargo run`.
- [Build times](build-times.md): `tools/build_times.py` measures cold and incremental builds per crate and the critical path, writes a JSON report and a table, and compares two reports; the first numbers, Windows and Linux.
- [Native client](native-client.md): options, controls, the dedicated server, `horizon-ctl` scripting, packaging, architecture.
+190 -10
View File
@@ -64,11 +64,13 @@ once per loop iteration next to winit's events.
| Change camera | View | Create / Share | `key.togglePerspective` |
| Pick block | D-pad left | D-pad left | `key.pickItem` |
| Chat | D-pad up | D-pad up | `key.chat` |
| Radial menu (hold) | D-pad right | D-pad right | Horizon `pad.radial` |
| Pause / back | Menu | Options | Escape (fixed) |
Advancements and the player list have no button of their own, and are
bindable. A settings file from before these defaults keeps its buttons where
they were.
Advancements has no button of its own: it is on the radial menu's first page,
and bindable, as the player list is. A settings file from before these defaults
keeps its buttons where they were and leaves the menu unbound (Controller Settings > Bindings
offers it).
Also bindable, with no default button: fullscreen, player social, quick actions, the save and load
toolbar activators and hotbar slots 1 to 9. They act in play only: in a menu or a prompt the button
@@ -90,6 +92,179 @@ awkward, which is why Bedrock does the same) and acts like vanilla's toggle
crouch: a screen stands you up while it is open and closing it crouches you
again. The controls options can make it hold-to-sneak.
## Radial menu
Hold the opener and the right stick points at one of eight slots, clockwise
from the top; letting the opener go runs the selected action, once. It isn't a
screen: the world goes on behind it, and neither the selecting stick nor the mouse
turns the view while it is open (the stick not until it has been let go). With
the default opener, D-pad Right, the left thumb is on the D-pad, so the player
stands still while picking; with the opener on a bumper, a trigger or View the
stick that doesn't select still walks or turns the view.
Every hold opens on the first page, so a flick aimed blind always lands on the
same slot.
Two settings in Controller Settings > General, under Radial Menu, change how it
is held and aimed, saved as `radialMode` and `radialStick` in
`horizon-controller.txt`:
- Radial Menu: Hold (the default) or Toggle (`radialMode:hold|toggle`). In
Toggle a press opens it, shown at once, and it stays open with the opener up:
A or a second press of the opener runs what is selected, B cancels, and with
nothing selected nothing runs.
- Radial Menu Selects With: Right Stick (the default), Left Stick or Either
(`radialStick:right|left|either`). The selecting stick neither walks nor
turns the view while the menu is open; with Either, the last stick to lean
past the select tilt selects, and neither moves anything. While the menu is
open a stick takes over only once it has been at rest since the open, so a
thumb left on the walking stick can't commit for the other. Its tilts follow
that stick's own deadzone.
- The overlay shows once the opener has been held 120 ms (`DRAW_DELAY`); the
stick counts from the press, so a quick flick-and-release works without it,
and a tap that selected nothing shows nothing and does nothing. A flick
between two frames still counts: the pad's own events give the furthest the
stick leant since the last poll.
- The slots are eight tiles (vanilla's game mode switcher slots, each with its
action's icon from the player's own resources) on a ring at true 45 degree
steps, where the stick selects them; the selected sector lights up behind
its tile. The hub in the middle names the selection; for Perspective, Hide
HUD and Cinematic Camera it gives the state and what a press turns it to,
and for a slot that can't run, why (`Needs cosmetics on`). An empty slot is
a pip, one that can't run is dimmed and slashed, and a missing action (an id
this game doesn't know) says Missing. Along the top, the pages are tabs
between the bumpers' glyphs, the current one white; on a narrow view they
collapse to the name between arrows, with the bumpers naming the pages they
turn to. With a bumper for the opener only the other shows, and with one page
only its name. B's Cancel sits at the lower right when the view has room. The
header stays below vanilla's boss bars and the ring above the status rows
over the hotbar (armour, health with absorption, food, a mount's hearts);
while the menu shows, the chat lines, the toasts, the crosshair, the guide
and the first-person hand give way (none of them blink on a quick tap,
which draws nothing). Sizes follow the
view's height (`radial_overlay::Layout`), so 1280x720, 854x480 and a quarter
of a large window look the same, and smaller views drop the state chips and
the Cancel hint first. Its motion is quiet and never selects: a quick fade in,
the lit sector easing to a new slot, the tiles sliding in on a page turn, the
chosen tile flashing on a commit (`hud_kit::motion`, settled while the game
is paused or recording).
- Bumpers change the page, around the ends, and clear the selection; with a
bumper for the opener, the other one turns the pages on. A stick held through
the page turn selects nothing until it moves. One that had leant past rest
for 90 ms before the opener went down (`LEAN_BEFORE_OPEN`, a stick turning the
camera) selects nothing until it has been at rest, page turns included; one
that moved only just before is a flick aimed at the menu, and selects.
Meanwhile the sector stays dark and the hub says "Let go of the stick".
- A selected slot stays selected a little past its wedge and under the tilt, so
a light hold or a stick on a boundary doesn't flicker. The tilts are tuned at
the default look deadzone and keep their share of the travel past a raised
one (`radial::Tilt`): a selection always lets go above the deadzone, so a
drifting stick never keeps one, and no slot ever needs more than a 0.93 lean,
which a worn stick still reaches.
- With vibration on: reaching a slot that would run gives a tick (45 ms on the
light motor), a page turn a shorter one, twice when the pages go round an
end, and a release that runs nothing after something was selected a soft
buzz. A commit that runs an action plays the button click.
- With the narrator on, it says the page's name as the overlay shows and after
each page turn, and a slot's caption once the selection has stayed 150 ms
(`NARRATE_SETTLE`), so a sweep across slots says only where it stops.
- A stick let go keeps its selection for 250 ms (`NEUTRAL_RETENTION`), so it can
spring back before the opener comes up; then the selection clears. Latched
open in Toggle the selection stays with the stick let go, so the thumb can
move to A: only another slot, a page turn or a cancel changes it. A slot the
thumb crosses on its way to A, selected less than 90 ms before A goes down
(`A_SETTLE`), doesn't count: A runs the one it had settled on.
- A release with nothing selected, or on an empty slot, does nothing. An empty
slot is a faint outline with no caption; only the selected action takes the
accent.
- An action that can't run now is drawn dimmed. It can be selected, but its
release does nothing: no action, no click. The game decides which: Emote
opens the emote wheel only with cosmetics on, Quick Actions only when the
server gave a dialog for it, and Social only on a server. Social is the one
exception to doing nothing: in single player its release goes through its key,
which shows vanilla's "not available" message on the action bar, as
`Gui.handleKeybinds` does (pressing its key does the same). A Missing action
never runs. The others always run.
- B, Start, a disconnect, losing the window's focus (with Play in Background on
too), the player being replaced (a death with immediate respawn, the End's
exit) or anything that opens over gameplay cancels, also during those 250 ms.
RT or LT pressed while it is open cancels it and goes on to attack or use in
the same poll; so does any key or mouse button, which isn't swallowed. No
other press made while it is open reaches a mapping, and a press in the same
poll as the opener's release, held back to the next tick or not, comes first,
so B still cancels. Buttons held since before it opened keep their action and
release it as usual, so a held sneak stays held. Moving the mouse neither
cancels it nor takes control back from the pad, nor turns the view.
- After it closes on a D-pad opener, a press of another D-pad button that starts
within 100 ms reaches nothing, and nor does its release (`DPAD_SETTLE`): a
thumb rolling off the opener doesn't drop or open the chat. The opener itself
opens the menu again at once. A drop held through it starts
its wait again as it closes, rather than throwing the rest of the stack. F3's
3D crosshair gives way to it, as the 2D one does.
- Menus, the on-screen keyboard and prompts come first: there the opener's
button does what the screen says (or its other action, if it shares one).
- The opener is never under a thumb that selects, as holding it and steering
are two jobs for one thumb, nor on B, which cancels (`gamepad::opener_refused`):
selecting with the right stick, not A, X, Y or R3; with the left, not the D-pad
or L3 (A, X and Y are free); with either, neither set, leaving the bumpers,
the triggers and View. In the binds list a refused button cancels the capture
as Start does, and a file with one reads as if it had no opener saved (D-pad
Right, unless another action is there or the stick refuses it). Changing the
stick so that the opener is under the selecting thumb unbinds it, and the
opener's row says no button opens the menu and which ones can; back on the
right stick an unbound opener takes D-pad Right again if it is free. A bumper
can be the opener; the other bumper then turns the pages on, round the end.
The default pages hold only actions with no button of their own that always
run, so the menu adds to the pad instead of repeating it, on the straight
directions: General has Advancements (up), Commands (right) and Social (down);
Camera has Screenshot (up), Hide HUD (right) and Cinematic Camera (down). The
rest start empty. Slots hold actions from a fixed list
(`radial::RadialAction`), those that do everything on their press (Fullscreen,
Quick Actions and the F3 debug screen besides those of the default pages), each shown
by a short caption (`horizon.radial.action.*`). Its rules are in
`horizon-client-input/src/radial.rs`, the overlay in
`horizon-client-render/src/radial_overlay.rs`.
Pages live in `horizon-controller.txt`, written once they differ from the
defaults, so each splitscreen profile has its own, after `radialLayout:1` (the
version of the default page list they were written against). A page the player
hasn't changed is written as its preset, `radial_<page>_preset:general` or
`camera`, and reads back as that default page as it is now: changing one slot
of General leaves Camera following the defaults. Setting a slot or a name to
what it already is changes nothing, and a page edited back to its default (or
read from its slots as that) is its preset again; an edited page drops a preset
id this game doesn't know, which would otherwise replace it. A page of the player's own is
`radial_<page>_slots` (eight ids, by commas) and its name as
`radial_<page>_name` (a name Horizon gave it, by language key) or
`radial_<page>_label` (the player's own text, kept as typed);
`radial_<page>_kind:hotbar` is read and kept for a hotbar page, which this
version shows with empty slots. Slot ids are vanilla key mappings by name
(`key.chat`) and Horizon's own in its namespace (`horizon:emote`,
`horizon:hotbar_next`, `horizon:hotbar_previous`); the ids they had before
(`key.emote`, `pad.hotbarNext`, `pad.hotbarPrevious`) read through an alias
table. An id this game doesn't know (a newer Horizon's, a mod's) stays in its
slot, drawn as Missing and dimmed, and never runs, so a slot can't run a
command. Fields and kinds it doesn't read stay too. A file can hold up to 16
pages (`PARSE_MAX`), and the lines of any past them are kept; the menu and the
editor show the first six, and a page deleted there brings the next forward.
Removing an action empties its slot and moves no other.
Controller Settings > Bindings > Radial Menu edits them, on the settings shell
(`horizon-client-render/src/radial_editor.rs`): the page list is the pages, by
name, and each page has its eight slots by direction (A or a click opens the list
of every action and Empty for that slot, which names the slot, marks what it holds
and opens on it; Right steps a slot to the next action, Empty between the last and
the first, and Left or Shift-click back, as on every choice of the settings shell),
then Rename Page, New Page
and Delete Page, then Reset All Pages and the opener's button, drawn with its
glyph. A press on the opener's row waits there for a controller button, as a key
row does: A, B, X, Y and the right stick's click are refused, Start cancels, and
the row's description says when no button opens the menu or another action
shares it. Delete and Reset act at once: Undo, there once something changed, puts
the pages and the opener back as the screen found them. Rename opens a page with
a name field (A on it opens the on-screen keyboard), Done and Cancel.
A Nintendo pad's face buttons can go by their places, as the table and SDL's
layout have them (B, at the bottom, jumps and confirms), or by their letters,
as a Switch's own menus and keyboard do: A (right) jumps and confirms, B goes
@@ -469,9 +644,9 @@ The picture's texture is 32 pixels tall, drawn 16 GUI pixels tall with a clear e
glyph shows 12 tall, as a text glyph does: a square picture 12 wide, a bumper or trigger 20. A declared
button's label leaves room for it.
Art shows on declared buttons and a focused field's A, in notices, the gameplay guide and splitscreen's
strips. What runs inside a line of text stays text: the recipe tooltip, the on-screen keyboard's hint line and the buttons on
the Bindings page. Vanilla's own messages that name a key (tutorial hints) keep the keyboard's names.
Art shows on declared buttons and a focused field's A, in notices, the gameplay guide, the radial
menu's hints and splitscreen's strips. What runs inside a line of text stays text: the recipe tooltip,
the on-screen keyboard's hint line and the buttons on the Bindings page. Vanilla's own messages that name a key (tutorial hints) keep the keyboard's names.
The inventory crosshair is `crosshair_inv` in the same folder, drawn at 16 GUI pixels on the point it
clicks (`pad_cursor.rs`).
@@ -491,7 +666,8 @@ Extras and Graphics, with a search field and General, Bindings and Device pages.
describes the pointed or focused row, and what a choice's value does. Left and Right step a choice both
ways, as its `<` `>` show. The search gives the keys back as soon as the focus leaves it.
Bindings is a list like the key binds screen. Each action's row shows its button's art in the player's
Bindings starts with the Radial Menu row, which opens the radial menu's pages (described above), then
Actions, a list like the key binds screen. Each action's row shows its button's art in the player's
glyphs. Press the row and then a button; Start, a click or a key cancels, and the keyboard's Escape
unbinds. Meanwhile the row reads "> A <" and the strip names the button that cancels. A button that does
more than one action is marked "[ ]" in yellow, as vanilla marks conflicts, and the strip lists what else
@@ -525,11 +701,13 @@ bind_key.jump:south
craftOnServers:false
gameplayGuide:full
bind_pad.hotbarNext:rb
bindLayout:1
bindLayout:2
```
Only bindings that differ from the defaults are written. A file without `bindLayout` comes from before the
current defaults and keeps its buttons where they were.
current defaults and keeps its buttons where they were. A file below `bindLayout:2` comes from before the radial
menu's opener: on its first load the opener stays off D-pad Right if the player had put another action there.
That happens once; from layout 2 on, a conflict the player makes in the binds list stays as they made it.
Deadzones are radial (the stick's length, so diagonals aren't clipped) and the
remaining range is rescaled to 0..1. Look uses a squared curve for precise
@@ -552,7 +730,9 @@ button, has no row. The mouse or keyboard taking over hides them, as do a menu,
Gameplay Guide option. They read what a click would do from the client's own answers
(`remote_block_reacts`, `remote_mob_takes`), and a use only a fluid ray or the server can tell (an
empty bucket) shows nothing. The rows step around what the HUD drew (effect icons, boss bars, the
sidebar, chat, toasts, splitscreen's badge), and in a small view the last rows drop.
sidebar, chat, toasts, splitscreen's badge), and in a small view the last rows drop. The left
column's last row names the radial menu's opener, as nothing else in play says it's there (for a
spectator too, who can open it).
An inventory or creative screen the pad drives has rows of its own, from its bottom corners, under
the same option, and what's under the crosshair decides them: a slot, by whether it holds anything
-133
View File
@@ -1,133 +0,0 @@
# Operations: running Horizon by hand
How the Horizon Team and the council keep Horizon working when no AI agent is running and the maintainer's machines
are off. Each section says what normally happens by itself, who can do it by hand, and what to do when it breaks.
How decisions are made is in [GOVERNANCE.md](../GOVERNANCE.md); how a change reaches players is in
[release-flow.md](release-flow.md). This page only says how to operate them.
## Who can do what
| Who | Can |
| --- | --- |
| **Horizon Team** (GitHub team `horizon-team`, write access) | Review, label and merge pull requests; approve a fork's workflow runs; re-run failed jobs; start workflows by hand (`gh workflow run`); add `release-blocker` to an issue; merge in horizon-mods (form pull requests too) and upload its `staging` files |
| **Trusted reviewers** (`.github/trusted-reviewers.txt`) | `/nightly`, `/close`, `/reopen`, `/label`, `/unlabel`, `/unclaim` by comment ([release-flow.md](release-flow.md#trusted-reviewers)) |
| **Council** (🏛️ Council on Discord) | Decide by poll (`/vote`, `/poll start`); emergency actions (GOVERNANCE.md 4.6) |
| **Organisation owners** (today: Ludovic only) | Repository variables and secrets (`SELF_HOSTED_CI`, `RELEASES_PAUSED`), branch protection, adding people to GitHub teams, merging past a missing review, the security advisories |
| **Code owners** (`.github/CODEOWNERS`, today: Ludovic only) | Approve pull requests that touch `.github/`, `tools/ci/`, the release, signing and update code |
The council's first poll gives the organisation a second owner (GOVERNANCE.md section 11). Until then, anything in
the last two rows waits for Ludovic.
## Merging a pull request
Nobody merges automatically. A Team member:
1. Reads the whole diff. For a fork, do it before approving its workflow runs (they wait under **Actions**:
"Approve and run") and before adding `run-parity`.
2. Waits for `verify`, the one required check. A pull request that changes no Rust finishes it in about a minute.
3. Merges with a merge commit once `verify` passed, no change request is open and every comment has been read. A
"don't merge" from anyone holds it. Never merge your own pull request, never use `gh pr merge --auto`, never
force-push a contributor's branch (merge main into it instead).
Risky pull requests (saves, the network or server thread, world generation, the launcher's installs and updates,
the harnesses, anything over ~500 lines) get `needs-proof` and wait until someone has played the change: both
single-player paths, a LAN guest, a screenshot for anything visible. The list is in
[release-flow.md](release-flow.md#merging).
A pull request that touches a CODEOWNERS path needs a code owner's approving review: GitHub keeps its merge button
locked until then. Leave it open and say in the thread that it waits for a code owner.
Pull requests opened by the maintainer's agents (author `ludovic111`, body starting "Opened by Ludovic's AI agent")
are ordinary pull requests: review and merge them like any other, or close them if they went stale. Closing keeps
the branch.
## Releases
Everything below runs by itself on GitHub; you only step in when something is wrong.
- **The Nightly** is the only build players get. The train (`release.yml`, every 10 minutes) starts one after 5
player-facing merges, or when the oldest has waited 6 hours, or at once for a pull request labelled
`release-now`. Only a commit where both `checks` and `parity-main` passed ships. Who may add `release-now` is the
council's choice; until it decides, leave the label to the maintainer.
- **Stopping Nightlies:** open an issue about the problem and label it `release-blocker`. Nothing is published
while it is open, forced builds included. Close it when the fix is on main.
- **Rolling back:** revert the pull request and merge the revert. Players can pick an earlier build on the
launcher's Versions page meanwhile.
- **The reviewers' picks:** `/nightly status` shows the set, `/nightly clear` builds main alone.
- **Launcher releases** follow the same train (`launcher.yml`).
The Nightly's macOS builds, its smoke test and parity-main run on the maintainer's Mac mini. While it is off,
**no Nightly and no launcher release can be published**: players keep the last ones, which keep working. See below.
## Self-hosted runners are offline
Some jobs run only on the maintainer's machines, because they need his own copy of Minecraft or a Mac
([ci-runners.md](ci-runners.md)). When those machines are off, those jobs wait up to a day and then fail. The
**runner watch** opens one issue, "CI: self-hosted runners are not taking jobs", within about 2 hours, listing what
is waiting, and closes it once the runners are back.
| Runner label | What waits | What to do |
| --- | --- | --- |
| `horizon-light` (bots) | Triage and claim commands, labels, overlap notes, the Mojang check | An organisation owner sets `SELF_HOSTED_CI` to `off` (Settings → Secrets and variables → Actions → Variables). Then everything runs on GitHub |
| `horizon-checks` | `verify` of this repository's own pull requests ending in 0 or 1 | Same switch (`off` or `light`), then re-run the waiting checks. Forks' pull requests never use these runners |
| `horizon-game` | A pull request's own parity job | It isn't required: merge on `verify`, and label pull requests that touch gameplay `needs-proof` |
| `horizon-parity-main` | parity-main on main | Nothing can be done on GitHub. Nightlies wait for the last commit it passed on |
| `horizon-nightly`, `horizon-release` | The Nightly's and the launcher's macOS builds | Nothing can be done on GitHub. No Nightly or launcher release until the Mac is back |
What keeps working without them: `verify` and merging, triage and labels (once `SELF_HOSTED_CI=off`), Kira, the
website, the mods registry and its checks, the official server.
What the council decides about it (which pull requests may still merge without the vanilla harnesses, and who could
host a runner with their own Minecraft) is a poll, not a rule on this page.
## Mojang republished the version
The hourly `mojang republish` workflow opens an issue "Mojang republished 26.3: <hash>" when Mojang changes the
version file the game is pinned to. If its text says the jars are unchanged, a pull request that pastes the snippet
from the issue into `research-lock.json` fixes it. New players can't prepare the game until a Nightly with that fix
is out, so label that pull request `release-now` (or ask the council to) once it merged.
## The mods registry (horizon-rust/horizon-mods)
- **Submissions from the website** arrive as pull requests authored by `ludovic111` (the registry's account). A
Team member reviews the mod (what it does, its licence, no sign-in or token handling, no Mojang files; see
`MOD-POLICY.md` there), approves, and squash-merges once `check` passed. Publishing to the registry is hourly
(`publish.yml`).
- **Fork pull requests:** approve their workflow run, and upload the mod's file to the `staging` release as
`<sha256>.<extension>` after checking its hash.
- **Taking a mod down:** a pull request adding it to `revoked.toml`. That file is a code-owner path: today only
Ludovic can approve it. For malware, two council members act at once as an emergency (GOVERNANCE.md 4.6), post
in #mod-log, and ask Ludovic to approve.
## Kira
Kira runs on Railway and needs nobody's machine.
- **Questions Kira can't answer** ("passed to the team") are listed in #kira-handoffs. Answer the member in their
thread.
- **Switches anyone on staff can use:** `/kira-ai off|on` (AI answers), the triage pause.
- **Polls:** `/vote question:` (council members) starts a 24-hour poll; `/poll start` for the other kinds. The
results are in #polls-and-decisions and in `https://kira-production-500e.up.railway.app/decisions.json`.
- **If Kira is down:** only the account holder can reach Railway today. Post in #council so someone tells him.
## The official server
The server (play.playhorizon.dev) runs on its host's panel, not on the maintainer's machines, and keeps running the
engine it has. Engine updates, backups and disk clean-ups are done by people with panel access; who has it, and the
steps, are in the council's private notes. Players' reports go to #mod-chat as usual. If the server is down, a
council member tells the panel holders and the host.
## Security problems
- **Reports** come through GitHub's private vulnerability reporting ([security.md](security.md#reporting-a-security-problem)).
- **A leaked secret, a malicious pull request or mod, a hijacked account:** any two council members act at once
(GOVERNANCE.md 4.6): label an issue `release-blocker` to stop Nightlies, `/nightly clear` the picks, close the pull
request, take the mod down, remove the account's roles, and ask the account holder to rotate the secret. Record it
with `/emergency` in Kira once that command exists, else in #council.
## Who to ask
- Code and merges: the Horizon Team, in #horizon-team.
- Decisions: the council, by poll.
- Accounts (GitHub organisation, Discord ownership, Railway, the server panel, domains): the account holders; the
list is in GOVERNANCE.md section 11.
+72 -3
View File
@@ -14,7 +14,7 @@ The kit grows a piece at a time, each with something on screen that needs it:
| Piece | What it gives |
|---|---|
| `hud_kit::room` | `Room`: what of a view an overlay may use, clear of vanilla's boss bars |
| `hud_kit::room` | `Room`: what of a view an overlay may use, clear of vanilla's boss bars and status rows |
| `hud_kit::plate` | `Plate`: the box behind a notice's text, or a guide row's backing |
| `hud_kit::shapes` | triangles, fans, arcs, discs and radial gradients for `HudBatch`; `alpha_since`, `translate_since` |
| `hud_kit::motion` | `Clock` and `Tween`: quiet motion as pure functions of the frame's time |
@@ -32,11 +32,12 @@ All sizes are GUI px from the view's top left, and an area is `[x, y, w, h]`.
### Room
A `Room` is a view's size plus the keep-outs its HUD shows this frame. Each keep-out is a `Keep`
and an area. Today there is one kind:
and an area. There are two kinds:
| `Keep` | Where it comes from |
|---|---|
| `BossBars` | `hud_overlays::boss_bars_bottom(h, n)`: vanilla's `BossHealthOverlay` layout, y from 12 in steps of 19 until a third of the view's height; as wide as the 182 px bar or its widest name |
| `StatusRows` | the survival rows above the hotbar (health with its absorption rows, armour, food or the mount's hearts, air), from the highest row's top to the view's bottom, as `hud_layout::draws_top` reports them; `room.bottom()` is the lowest y an overlay may reach |
`Game::hud_room` (`crates/horizon-client/src/game/hud_room.rs`) gathers what is showing.
Nothing shows while F1 or Replay Studio hides the HUD, or off a world. `hud` empties `ui.room` at
@@ -64,6 +65,23 @@ The other HUD boxes (the minimap, the PvP & HUD widgets, mods' corner panels) do
yet. Chat, the hotbar, effect icons and subtitles aren't keep-outs yet either. #10125 asks for
these push-aside points for mods.
### Overlays that hold the view
Some overlays take the middle of the view for themselves, as the radial menu does once it shows.
What vanilla draws there gives way to them through one query, `Game::overlay_holds_view()`
(`crates/horizon-client/src/game/hud_room.rs`), not a check per overlay in each piece:
| Gives way | Where |
|---|---|
| the 2D crosshair and F3's crosshair | `hud_layers`, `debug_crosshair_drawn` (`game.rs`) |
| the chat's lines (an open chat still draws) | `hud_layers` (`game.rs`) |
| every toast: advancements, recipes, tutorials, packs, music | `Game::draw_every_toast` (`hud_room.rs`); they go on timing out, as under F1 |
| the first-person hands | `Game::hands_shown` (`hud_room.rs`), which `hand_geometry` reads (`game/frame_geometry.rs`) |
A new overlay that holds the view adds its own condition to `overlay_holds_view`. Ask from the moment
it shows, not from when it owns the pad ([Pad capture](#pad-capture)): a quick tap of the radial
menu's opener owns the pad for a frame and draws nothing, so nothing should blink.
### Plates
`hud_kit::plate::draw(batch, atlas, plate, area)` fills an area with a plate's look, then draws
@@ -207,6 +225,30 @@ keeps the list, the arrows walk every page, search skips pages without matches,
targets), and `tools/pad_menu_e2e.py --only shader_screens` (RB through every screen of a
twelve-screen pack in a real game).
## Pad capture
Several overlays over play take the controller for themselves. `Game::pad_capture`
(`crates/horizon-client/src/game/pad/capture.rs`) is the one place that says which owns it now,
in one priority order:
| `PadCapture` | Owns the pad while |
|---|---|
| `Keyboard` | the on-screen keyboard is up |
| `Prompt` | a prompt is over the world (a server dialog, the code of conduct, a link, command or resource pack prompt) |
| `Radial` | the radial menu is held open |
Code that routes the pad's presses and sticks (`pad.rs`, `pad_actions.rs`, `pad_menu.rs`,
`window_handler.rs`), and the controller guide, which gives way to the keyboard, ask
`pad_radial_captures()` or `pad_keyboard_captures()` rather than the overlays themselves. A new
overlay that takes the pad adds its variant here, in its place in the order, and a check that asks
about it gets a `pad_<overlay>_captures()` beside these. What vanilla draws under an overlay gives
way through `overlay_holds_view` instead ([Overlays that hold the view](#overlays-that-hold-the-view)).
Screens are not overlays: `pad_in_menu` says when one has the pad.
One change came with it: a prompt that arrives while the radial menu is open owns the pad from that
poll, so a B in the same poll answers the prompt instead of cancelling the menu (which the prompt
closes anyway). Before, the menu took that B.
## Glyphs
Code asks for a glyph by what the button does, never by a button. The chain runs from the job to the art:
@@ -382,7 +424,9 @@ pages read from a shader pack, rows that link to pages out of the page list, and
included. **Apply**, for changes that wait: `pending` shows it beside Undo, and `apply` does them.
6. **Host it**: a `Shell` of it on `OptionsUi`, named in `OptionsUi::shell` and `shell_mut`, and opened
at its page with `Shell::show`. Every input goes through those two, so a screen can't take the wheel
and miss the stick.
and miss the stick. They live in `options_ui/shell_pages.rs` with the rest of the hosting glue
(`shell_events`, `pad_clicks_step`, `shell_opened_focus`), so hosting a screen touches that file,
not `options_ui.rs`.
7. **Delete** the screen's own drawing, scrolling, focus, search and click code.
What the old screen's tests checked moves to tests on the `Shell` of the new one: press a row with
@@ -402,6 +446,11 @@ or what else its button does, and draws the row's reset.
- `shared`: what else the same button does, which the strip lists and the row marks "[ ]" in yellow.
- `reset`: `Reset::None` (no button), `Reset::Default` (greyed) or `Reset::Changed` (live) for a reset
button at the row's end; pressed (a click, or Right then Enter or A), it calls `Screen::reset`.
- The strip says the binding's note (unbound, or what else its button does) unless the row has a
`chosen` of its own: that outranks the note except while the host waits, so a binding can say why it
has no button or shares one (the radial menu's opener says what that means for opening the menu).
- A `Capture::Pad` row waits on any screen of the shell, not only Controller Settings: the game binds
the next button for whatever `selected_pad_action` names.
A press gives `Output::Bind(capture)`; the host's `shell_events` starts the wait, and passes what it waits
on back to `Hosted::draw`.
@@ -425,6 +474,26 @@ let binding = Binding {
Row::new(Item::Bind(action), name, Value::Binding(binding))
```
### Lists, steps and where the focus opens
A screen whose choice has too many values to step through can open a list of them instead, as the
radial menu's editor does for a slot's action:
- `Screen::choose(s, row)`: a press on a `Choice` row (A, Enter or a click; not Left, Right or a
Shift press). `None`, the default, steps it as Right does; outputs (a `Push` of the list's page) open
the list instead. Left and Right still step it.
- `Hosted::clicks_step(on)`: the host marks the clicks that are a controller's D-pad Left or Right
(`OptionsUi::pad_clicks_step`, from `game/pad_menu.rs`), so on such a choice they step rather than
open the list.
- `Shell::focus_soon(row)`: put the focus on a row at the next draw, scrolled into view, as the
keyboard would. The host reads where it went (`Hosted::opened_focus`, `OptionsUi::shell_opened_focus`)
so a controller's focus lands there too: the list opens on the slot's current action, and back on
the slot after a pick.
- `Shell::changed_elsewhere()`: a page pushed over the screen (the list) changed what its Undo puts
back.
- A screen whose pages are the player's (`Sheet`s that come and go) can delete the open one: the shell
then shows the first page.
### The page list
A screen of two pages or more has a page list down the left, a page under the last. A `Sheet` with
+21 -14
View File
@@ -493,9 +493,12 @@ mod input_trace {
pub enum InputOrigin { User }
pub enum TraceKind { Motion { x: f64, y: f64 } }
}
struct Radial(bool);
impl Radial { fn is_open(&self) -> bool { self.0 } }
struct Pad { radial: Radial }
struct Game {
pending_save: Option<()>, captured: bool, focused: bool, keyboard_mouse: bool, split_window: bool,
options: Options, screen: Screen, mouse: MouseLook, last_input: Instant, scripted: bool,
options: Options, screen: Screen, mouse: MouseLook, last_input: Instant, pad: Pad, scripted: bool,
}
impl Game {
fn scripted_run(&self) -> bool { self.scripted }
@@ -503,26 +506,30 @@ impl Game {
fn note_input(&mut self, _: input_trace::InputOrigin, _: input_trace::TraceKind) {}
// Handing input back from the controller does not change the turn either.
fn pad_yield_input(&mut self, _: &ActiveEventLoop) {}
// `game/pad/capture.rs`: the radial menu owns the pad while it is open.
fn pad_radial_captures(&self) -> bool { self.pad.radial.is_open() }
METHOD
}
fn main() {
// (offscreen renderer, compositor, keyboard/mouse owner, active, gameplay, saving,
// a scripted run).
for (offscreen, split, owner, active, playing, saving, scripted, turns) in [
(false, false, true, true, true, false, false, true),
(true, true, true, true, true, false, false, true),
(true, true, false, true, true, false, false, false),
(true, false, true, true, true, false, false, false),
(true, true, true, false, true, false, false, false),
(true, true, true, true, false, false, false, false),
(true, true, true, true, true, true, false, false),
(false, false, true, true, true, false, true, false),
// (offscreen renderer, compositor, keyboard/mouse owner, active, gameplay, saving, the pad's
// radial menu open, a scripted run).
for (offscreen, split, owner, active, playing, saving, radial, scripted, turns) in [
(false, false, true, true, true, false, false, false, true),
(true, true, true, true, true, false, false, false, true),
(true, true, false, true, true, false, false, false, false),
(true, false, true, true, true, false, false, false, false),
(true, true, true, false, true, false, false, false, false),
(true, true, true, true, false, false, false, false, false),
(true, true, true, true, true, true, false, false, false),
(false, false, true, true, true, false, true, false, false),
(false, false, true, true, true, false, false, true, false),
] {
let mut game = Game {
pending_save: saving.then_some(()), captured: true, focused: active,
keyboard_mouse: owner, split_window: split, options: Options { offscreen },
screen: if playing { Screen::Playing } else { Screen::Paused },
mouse: MouseLook::default(), last_input: Instant::now(), scripted,
mouse: MouseLook::default(), last_input: Instant::now(), pad: Pad { radial: Radial(radial) },
scripted,
};
game.device_event(&ActiveEventLoop, DeviceId, DeviceEvent::MouseMotion { delta: (0.0, 0.0) });
game.device_event(&ActiveEventLoop, DeviceId, DeviceEvent::MouseMotion { delta: (20.0, -10.0) });
@@ -532,7 +539,7 @@ fn main() {
rotation.turn(x, y);
let want = if turns { (3.0, -1.5) } else { (0.0, 0.0) };
assert!((rotation.y_rot - want.0).abs() < 0.00001 && (rotation.x_rot - want.1).abs() < 0.00001,
"rotation=({}, {}), offscreen={offscreen}, split={split}, owner={owner}, active={active}, playing={playing}, saving={saving}, scripted={scripted}", rotation.y_rot, rotation.x_rot);
"rotation=({}, {}), offscreen={offscreen}, split={split}, owner={owner}, active={active}, playing={playing}, saving={saving}, radial={radial}, scripted={scripted}", rotation.y_rot, rotation.x_rot);
}
}
'''
+1118 -3
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -25,7 +25,7 @@ AbstractPiglin.addAdditionalSaveData port crates/horizon-client/src/entity_nbt.r
AbstractPiglin.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_from_nbt reads immunity, time in overworld and the pick-up flag
AbstractPiglin.customServerAiStep port crates/horizon-client/src/mobs/piglin.rs::ai_step gap: minor: zombification runs outside the Nether by the nether flag, not the PIGLINS_ZOMBIFY attribute
AbstractPiglin.finishConversion port crates/horizon-client/src/mobs/piglin.rs::ai_step spawns the zombified piglin with nausea and removes the old one
Piglin.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_from_nbt gap: minor: CannotHunt is not read, so hunting ignores it; baby and inventory are read
Piglin.readAdditionalSaveData port crates/horizon-client/src/mobs/piglin.rs::read_guarded_anger baby and inventory are read in entity_nbt::mob_from_nbt; the brain's minecraft:angry_at UUID and TTL come back through read_guarded_anger (CannotHunt still not read)
Piglin.getSlot missing item commands stop at the open container: a piglin's own inventory slots (300 and up) are not addressable
Piglin.defineSynchedData skip:java synched-data registration; Horizon keeps baby, crossbow and dancing state in Mob fields
Piglin.createAttributes skip:data attribute defaults (health 16, speed 0.35, attack 5) live in data tables
1 Evoker.EvokerWololoSpellGoal.canContinueToUse port crates/horizon-client/src/mobs/illager.rs::UseSpell::can_continue wololo branch: sheep target still set and warm-up left
25 AbstractPiglin.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_from_nbt reads immunity, time in overworld and the pick-up flag
26 AbstractPiglin.customServerAiStep port crates/horizon-client/src/mobs/piglin.rs::ai_step gap: minor: zombification runs outside the Nether by the nether flag, not the PIGLINS_ZOMBIFY attribute
27 AbstractPiglin.finishConversion port crates/horizon-client/src/mobs/piglin.rs::ai_step spawns the zombified piglin with nausea and removes the old one
28 Piglin.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_from_nbt crates/horizon-client/src/mobs/piglin.rs::read_guarded_anger gap: minor: CannotHunt is not read, so hunting ignores it; baby and inventory are read baby and inventory are read in entity_nbt::mob_from_nbt; the brain's minecraft:angry_at UUID and TTL come back through read_guarded_anger (CannotHunt still not read)
29 Piglin.getSlot missing item commands stop at the open container: a piglin's own inventory slots (300 and up) are not addressable
30 Piglin.defineSynchedData skip:java synched-data registration; Horizon keeps baby, crossbow and dancing state in Mob fields
31 Piglin.createAttributes skip:data attribute defaults (health 16, speed 0.35, attack 5) live in data tables
+1 -1
View File
@@ -108,7 +108,7 @@ FirstPersonHandsAndItems.extractRenderState inline crates/horizon-client/src/gam
FirstPersonHandsAndItems.shouldInstantlyReplaceVisibleItem port crates/horizon-client/src/game/hand.rs::shows_held_stack the same stack object, or an equal one, which plays no swap animation; the slot a replaced hand changed is the exception
FirstPersonHandsAndItems.extractMapRenderState inline crates/horizon-client/src/game/maps.rs::Game::held_map the MAP_ID component picks the map data drawn in the hand, its decorations included, and decides the checkerboard background
FirstPersonHandsAndItems.isChargedCrossbow inline crates/horizon-client/src/first_person.rs::hands_to_render
ItemActivation.tick port crates/horizon-client/src/item_activation.rs::ItemActivation::tick the count down to 0, where the item goes; game.rs::tick_item_activation runs it
ItemActivation.tick port crates/horizon-client/src/item_activation.rs::ItemActivation::tick the count down to 0, where the item goes; game/totem.rs::tick_item_activation runs it, called from the game tick and the remote tick
ItemActivation.activate port crates/horizon-client/src/item_activation.rs::ItemActivation::activate a copy of the stack, the 40 ticks and two offsets in [-1, 1); game/totem.rs sets it from findTotem
KeyboardInput.calculateImpulse inline crates/horizon-client-input/src/input.rs::keyboard_direction
LocalPlayer.<init> inline crates/horizon-client/src/ambience.rs::Ambience::new gap: minor: the three ambient handlers are the Ambience loops; no connection set-up
1 OptionInstance.SliderableEnum.toSliderValue port crates/horizon-client-render/src/options_ui.rs::Opt::kind slider position of the graphics preset (index / 3)
108 FirstPersonHandsAndItems.shouldInstantlyReplaceVisibleItem port crates/horizon-client/src/game/hand.rs::shows_held_stack the same stack object, or an equal one, which plays no swap animation; the slot a replaced hand changed is the exception
109 FirstPersonHandsAndItems.extractMapRenderState inline crates/horizon-client/src/game/maps.rs::Game::held_map the MAP_ID component picks the map data drawn in the hand, its decorations included, and decides the checkerboard background
110 FirstPersonHandsAndItems.isChargedCrossbow inline crates/horizon-client/src/first_person.rs::hands_to_render
111 ItemActivation.tick port crates/horizon-client/src/item_activation.rs::ItemActivation::tick the count down to 0, where the item goes; game.rs::tick_item_activation runs it the count down to 0, where the item goes; game/totem.rs::tick_item_activation runs it, called from the game tick and the remote tick
112 ItemActivation.activate port crates/horizon-client/src/item_activation.rs::ItemActivation::activate a copy of the stack, the 40 ticks and two offsets in [-1, 1); game/totem.rs sets it from findTotem
113 KeyboardInput.calculateImpulse inline crates/horizon-client-input/src/input.rs::keyboard_direction
114 LocalPlayer.<init> inline crates/horizon-client/src/ambience.rs::Ambience::new gap: minor: the three ambient handlers are the Ambience loops; no connection set-up