Files
horizon-airdef/docs/controllers.md
T
2026-10-15 03:10:15 +02:00

282 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Controllers
Horizon plays with PlayStation (DualShock 4, DualSense), Xbox (360, One,
Series) and Nintendo Switch Pro controllers, and on the Steam Deck with its
built-in controls. Keyboard and mouse keep working exactly as before: with no
controller connected, or with controllers turned off, nothing changes.
## What vanilla has
Nothing. Minecraft Java 26.3 never calls GLFW's gamepad or joystick API, has
no controller options and ships no button glyphs. Its only non-mouse UI input
is keyboard focus navigation (`InputType.KEYBOARD_ARROW` / `KEYBOARD_TAB`:
arrows and Tab move a focus ring between widgets). So controller support is
Horizon's own feature. It follows what players already know from Minecraft on
consoles (Bedrock Edition's default controller layout) and Horizon's menu
design for any new screen.
## Input layer
[gilrs](https://crates.io/crates/gilrs) reads the pads: evdev on Linux (with
libudev for hot-plugging), IOKit on macOS, Windows.Gaming.Input on Windows.
Its SDL mapping database normalizes every pad to one positional layout
(South/East/West/North face buttons, bumpers, triggers, sticks, D-pad,
Select/Start). No second windowing or event stack: gilrs is polled once per
loop iteration next to winit's events.
- The pure logic (mapping table, deadzones and curves, button edges, the
settings file, glyph choice) lives in `crates/horizon-client/src/gamepad.rs`
and is unit-tested. The game owns a thin gilrs backend and applies actions.
- Controller actions go through the same path a keyboard key takes after
rebinding: an action names a vanilla key mapping (`key.jump`, `key.attack`,
...), and the game handles it as that mapping's default key or mouse button.
Toggle sneak/sprint/attack/use, screens releasing held keys, and the
multiplayer client all behave as they do on the keyboard.
- Movement is analog: the left stick's direction and tilt feed the player's
move vector directly (a half-tilted stick walks slowly, like Bedrock),
instead of being rounded to WASD. The forward/back/left/right flags other
code reads (sprint start, boats, minecarts, the server's input packet) are
set when the stick passes a threshold.
- The pad last touched is the active one. Several pads are not merged.
- `horizon-ctl pad ...` injects a virtual pad (buttons, sticks, triggers) into
a running game, for tests without hardware.
## Default mapping
| Action | Xbox | PlayStation | Mapping |
|---|---|---|---|
| Move | Left stick | Left stick | analog `key.forward/back/left/right` |
| Look | Right stick | Right stick | camera |
| Jump / fly up | A | ✕ | `key.jump` |
| Drop item | B | ○ | `key.drop` |
| Swap offhand | X | □ | `key.swapOffhand` |
| Inventory | Y | △ | `key.inventory` |
| Hotbar left / right | LB / RB | L1 / R1 | Horizon `pad.hotbarPrevious/Next` |
| Use / place | LT | L2 | `key.use` |
| Attack / break | RT | R2 | `key.attack` |
| Sprint | Left stick click | L3 | `key.sprint` |
| Sneak (toggles) | Right stick click | R3 | `key.sneak` |
| Change camera | D-pad up | D-pad up | `key.togglePerspective` |
| Pick block | D-pad left | D-pad left | `key.pickItem` |
| Chat | D-pad right | D-pad right | `key.chat` |
| Advancements | D-pad down | D-pad down | `key.advancements` |
| Player list (hold) | View | Create / Share | `key.playerlist` |
| Pause / back | Menu | Options | Escape (fixed) |
The Guide / PS / Steam button is left to the system. Sneak on a stick click
toggles by default (holding a stick in while steering the camera is
awkward, which is why Bedrock does the same); the controls options can make it
hold-to-sneak.
## Menus and inventories
Vanilla's screens are all mouse-driven, and Horizon draws them 1:1. A pad
drives them in one of two ways, picked in the controller options ("Menu
Navigation: Focus / Pointer", `menuNavigation:focus|pointer`):
- **Focus** (the default): a console-style highlight that the D-pad and the
left stick move between buttons, sliders, switches, text fields, list
rows, tabs and inventory slots.
- **Pointer**: a virtual cursor the left stick moves, as Bedrock's classic
inventory UI and Java's controller mods do.
Focus is the default because it is what a pad player expects from a console
game, it needs no aiming (every widget is one D-pad press away, which also
suits the Steam Deck's small screen), and it works from the couch. The
pointer stays one option away, and a **right stick click swaps the two for
the open screen**, for the few places a pointer is better (dragging the
advancements map, painting a crafting grid with a held stack).
Both modes feed the screens the same pointer events a mouse sends: in focus
mode the (hidden) pointer sits on the focused widget. So every screen keeps
its mouse behaviour (drags, sliders, scrollbars, hover highlights) with no
controller code of its own, and both modes reach every screen. Moving the
real mouse hands control back at once.
### Focus navigation
Every interactive rectangle a screen draws registers itself as a focus
target with its kind (`widgets::target_as`): classic buttons, sliders, edit
boxes and list rows (worlds, servers, languages), every Horizon-style widget
(`ui_theme::Ui::interact`: sidebar items, switches, segments, sliders,
fields, buttons), inventory and container slots, creative and recipe-book
tabs, and the stations' own buttons (trades, loom patterns, beacon effects,
enchantments, stonecutter recipes, the recipe book's recipes, pages and
filter). A screen that registers nothing falls back to the pointer.
The step itself is **vanilla's arrow-key navigation**
(`ContainerEventHandler.nextFocusPathInDirection`, 26.3), applied to every
target of the screen at once: from a strip one pixel past the focused edge,
the nearest target that overlaps it on the other axis wins (ties go to the
leftmost or topmost); without one, the target whose facing edge's centre is
nearest the focused edge's centre (`nextFocusPathVaguelyInDirection`). That
gives vanilla-like jumps in containers too: hotbar to inventory, the grid to
the crafting result, armour to the player's grid.
| Input | Focus mode |
|---|---|
| D-pad, left stick | move focus (held: repeats after 0.35 s, every 0.09 s) |
| A | press the focused widget (held: drag); on a text field, type with the on-screen keyboard |
| B, Menu | back (Escape) |
| X | right click (inventories: take half, place one) |
| Y | quick move (shift-click) in inventories |
| View | throw one item from the focused slot (Q) |
| Left stick click | throw the focused stack (Ctrl+Q) |
| LB / RB | previous / next tab (creative, recipe book, the Horizon options' sections) |
| Right stick | scroll |
| Right stick click | swap focus and pointer for this screen |
- **Sliders**: left and right move the value (one pixel per press like
vanilla's arrow keys, faster while held); A does nothing on a slider, as a
click would jump the handle.
- **Lists** follow focus: a step past the last visible row scrolls the list
and lands on the next row once it is drawn.
- **Inventories**: the carried stack is drawn on the focused slot (vanilla
draws it under the pointer). A takes or places, X splits or places one, Y
quick-moves, View and the left stick click throw.
- **Where focus lands** when a screen opens: the held hotbar slot in an
inventory, else the first button.
How it looks follows the menu style:
- **Classic screens** look as vanilla's own keyboard focus does: buttons,
sliders and slots show their highlighted sprite (`isHoveredOrFocused`,
drawn because the hidden pointer is over them), and list rows and text
fields get vanilla's white one-pixel outline (`AbstractSelectionList`'s
focused selection, `EditBox`'s highlighted border).
- **Horizon-style screens** get the design system's focus colour: a two-dp
ring in the accent colour around the focused widget, pill-shaped on
buttons, tabs and sliders.
### Pointer
- Left stick moves the cursor with acceleration; in container screens the
cursor is gently pulled toward the nearest slot centre.
- D-pad steps to the nearest target that way (a slot, a button) and snaps to
it.
- A = left click, X = right click, Y = quick move, B = back; View and the left
stick click throw, as in focus mode.
- LB / RB switch tabs; right stick scrolls.
- Sliders: hold A on the slider and move the stick, or nudge with the D-pad.
The cursor is drawn only while the pad is the last-used input.
### Text fields
A on a focused text field (A in pointer mode clicks into it) opens the
**on-screen keyboard**: a QWERTY grid and a symbols grid in Horizon's design
language, at the bottom of the screen (at the top when the field is in the
lower half, as chat is). D-pad or stick to move, A types, X deletes, Y is a
space, LT is Shift (twice: caps lock), RB switches to symbols and accents,
Menu is Enter, B closes it. It opens by itself when the pad opens chat or a
sign. It types into every field the keyboard reaches: chat (local and
server), signs, books, the title screens' fields, the options' searches, the
creative and recipe-book searches, the anvil, the LAN port.
On a Steam Deck under Steam (`SteamDeck=1`), A asks Steam for its own
keyboard instead (`steam://open/keyboard`), which types like a real keyboard;
Steam + X opens it at any time. "On-Screen Keyboard: Auto / Horizon / Steam"
(`keyboard:`) picks one.
### Screens covered
Both modes reach every screen through the pointer events. Focus mode
registers targets on: the title menus (main, world list and its search, create
and edit world, delete, multiplayer list and its edit screens), every options
page (classic pages and the Horizon-style sections, key and controller binds,
language list), pause, death, chat, signs and books (their buttons), the
survival and creative inventories, every container and station (chests,
furnaces, brewing, crafter, crafting table with its recipe book, anvil,
grindstone, smithing, stonecutter, loom, cartography, enchanting, beacon,
villager and wandering-trader trades, mount inventories), and the LAN /
world options. Where a screen draws something clickable no target names
(the advancements map, the statistics lists, the pack screen's rows, the
book's page text), the right stick click gives the pointer for that screen.
## Glyphs
Horizon draws its own button glyphs in the HUD and screens (no Mojang
assets, nothing new from the player's jar): a small round or pill shape with
the label in the game font, sized to the GUI scale, with two styles:
- **Xbox / Steam Deck / generic**: A B X Y in their usual colours, LB RB LT
RT, View, Menu, stick icons.
- **PlayStation**: × ○ □ △ in their colours (× rather than ✕, which the game fonts lack), L1 R1 L2 R2,
Create/Share, Options.
- **Nintendo**: B A Y X by position.
The style comes from the pad's USB vendor (Sony 054c, Microsoft 045e,
Nintendo 057e, Valve 28de) unless the controls options force one. Glyphs
replace key names where a prompt names a key (the controls list, tutorial
hints, "Press X to dismount") only while the pad is the last-used input.
## Options and remapping
Controller settings are Horizon's own and live in `horizon-controller.txt`
next to `options.txt`, like `horizon-graphics.txt`: `options.txt` stays
exactly vanilla's, so a vanilla client can share the game folder.
```
enabled:true
vibration:true
lookSensitivity:0.5
invertX:false
invertY:false
moveDeadzone:0.15
lookDeadzone:0.12
triggerThreshold:0.4
toggleSneak:true
glyphs:auto
bind_key.jump:south
bind_pad.hotbarNext:rb
```
Only bindings that differ from the defaults are written. The controls
options get a Controller page: on/off, vibration, look sensitivity, invert
X/Y, deadzones, glyph style, sneak toggle, and a binding list like the key
binds screen (pick an action, press a button; conflicts marked as vanilla
does; reset).
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
aim near the centre, up to 200°/s yaw and 140°/s pitch at the default
sensitivity, slower while scoping with a spyglass.
## Rumble
Vibration (on by default, off in the options) through gilrs force feedback:
taking damage (stronger with the damage), a block broken, an explosion nearby
(scaled by distance), a totem of undying, an elytra boost, a fish biting.
Short, low-intensity pulses, as on Bedrock. Linux (evdev force feedback,
including Steam Input's virtual pad) and Windows support it; macOS gilrs has
no force feedback, so it is silently skipped there.
## Steam Deck
- In Game Mode, or any game launched through Steam, Steam Input presents the
Deck's controls as a virtual Xbox pad: Horizon sees it like any Xbox pad
and uses Xbox-letter glyphs (the Deck's face buttons are A B X Y). Steam's
default "Gamepad" layout works with no setup; the trackpads stay mouse.
- Started outside Steam on the Deck, the kernel's `hid-steam` driver exposes
the controls as a gamepad too (while Steam isn't holding them), so it also
works out of the box.
- Text entry uses Steam's keyboard (Steam + X).
## PR plan
The first round (input layer, menus, glyphs and options, rumble) shipped in
PRs #120, #129, #145 and #149. The second round, each shippable alone:
1. **Focus navigation** (this section above): the menu navigation option,
focus targets and vanilla's arrow-key step, the focus highlight in both
menu styles, the on-screen keyboard.
2. **Button prompts**: contextual hints with the pad's glyphs in menus and
optionally in game, and rebinding from them.
3. **DualSense output**: adaptive triggers, lightbar, player LEDs and richer
rumble over HID, off-safe.
4. **Launcher**: the same focus navigation in the gpui launcher.
Each is tested with unit tests (focus steps, prompt contexts, report bytes),
the injected pad and vscreen screenshots; each PR says what still needs a
real controller.