210a6f5ddd
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
282 lines
14 KiB
Markdown
282 lines
14 KiB
Markdown
# 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.
|