Skip to content

Add a keyboard mapping tab to the options screen - #472

Merged
tomconder merged 5 commits into
mainfrom
feat/key-mapping-screen
Sep 2, 2026
Merged

Add a keyboard mapping tab to the options screen#472
tomconder merged 5 commits into
mainfrom
feat/key-mapping-screen

Conversation

@tomconder

@tomconder tomconder commented Aug 31, 2026

Copy link
Copy Markdown
Owner

What

Splits the options screen into two tabs, Display and Keyboard, one layer each, with a shared tab strip at the top. Switch tabs with Q/E, the gamepad shoulder buttons, or by clicking a tab.

[Q]   • Display     Keyboard   [E]

The active tab takes the red fill the menu rows use for a selected row ({0.84, 0.04, 0.04, 0.14}) and a small white dot before its label. No border, no rounded corners, so the strip stays lighter than a menu row. The dot is the shared Quad with a half-width corner radius, so it costs no new primitive.

The Keyboard tab lists the rebindable actions with their current key, plus Reset to Defaults and Return.

How rebinding works

The layer asks for a rebind by setting an atomic flag. The key capture, the binding write and the settings write all run on the main thread inside InputManager::update(), because Settings is not thread safe and menu layers run on the render thread. The capture frame resolves no actions, so the captured key cannot also fire the action it is being bound to.

Overrides persist as input.<action> and are re-applied at the end of buildDefaultBindings(). 0 means "no override", so Reset to Defaults writes zeros and the defaults survive.

Deliberate limits, marked in the source:

  • Menu navigation is not rebindable. Losing it strands the player on the screen.
  • Escape cancels a capture, so nothing can be bound back to Escape by hand. Reset to Defaults restores the ones that had it.
  • No conflict check: two actions may share a key.
  • Keyboard only. Gamepad and axis bindings are untouched.

Fix that came with the split

ExitLayer, IntroLayer and MazeLayer each asked whether the options screen was open by testing OptionLayer::isActive(). With the screen split across two layers that test read as "closed" on the Keyboard tab, so those layers woke up behind it: ExitLayer swallowed the first Escape, and from the intro menu, Enter-to-rebind would have started a new game. All five call sites now go through Maze::isOptionsOpen().

Gamepad mode no longer bounces back

Using the gamepad in a menu switched the prompts straight back to keyboard glyphs. InputManager::updateActiveDevice() claimed the device for KeyboardMouse on held key state. The Xbox 360 driver aliases the D-pad to arrow keys, so the ghost key was still down on the frame after the gamepad button released, and the device flipped on every press.

Keyboard and mouse now claim the device on a fresh press edge or real cursor motion. A key or mouse button still takes the device from the gamepad the moment you press it. The special cases for Escape and the arrow keys only ordered themselves against the gamepad check, which the generic loop already covers, so they are gone. 19 lines out, 6 in.

Assets

keyboard_q, keyboard_e, xbox_lb and xbox_rb from Kenney Input Prompts (CC0), taken from the same Default folders as the sprites already in the tree. The prompt sprite cache moved out of keyhints.cpp into game::ui::promptSprite() so the tab strip and the hint bar share one cache.

Testing

cmake --build out/build/ci-windows-release --target game and pre-commit run --all-files are both clean.

I drove the app end to end with synthesised key events, then checked the rendered frame and the observable state: the settings file, the window rect, and whether the process exits.

  • Tab strip renders with the active tab filled and dotted, the inactive one dimmed, and the prompts swapping with the device. The strip clears the first row at 1920x1080.
  • Tab switching. E reaches the Keyboard tab, Q returns to Display.
  • Rebinding. Enter on Move Forward shows "Press a key"; K then writes input.moveForward = 75. Escape cancels and restores the previous key.
  • Reset to Defaults. Clears every override back to 0.
  • Overrides load on restart. Bound Full Screen to K, quit, relaunched: in game, K toggled the window 1936x1119 → 1920x1080 → back, so the stored override was re-applied over the default F.
  • The Escape fix. One press from the Keyboard tab returns to the intro menu, confirmed by the next Down+Enter selecting Quit and the process exiting, which happens only when the intro menu has control.

Gamepad stickiness was measured against a physical Xbox 360 pad, since no virtual-pad driver is installed. A watcher sampled the window every 0.4 s, logging the XInputGetState button word, GetCursorPos deltas and GetAsyncKeyState, and classified the hint bar by template diff against a known keyboard frame and a known gamepad frame:

  • D-pad press gave gamepad glyphs, and the mode held for 29 consecutive samples (~11.6 s) with no keyboard, mouse or pad input. Before the fix it reverted within a frame.
  • The same log shows the root cause: one sample carries btn=0x0001 (D-pad Up) and GetAsyncKeyState(VK_UP) true at the same instant.
  • One run did revert at once, but the cursor was moving throughout it (mouseMoved= 7, 18, 20, 296…), which is the intended switch.

I reset the bindings to defaults afterwards, so no test state is left behind.

The tab strip starts at the first row's left edge, so it lines up with the menu block rather than centring on the window. Callers pass that edge in from their own Yoga layout, which keeps the strip and the rows together on resize.

Checked at 1920x1080 windowed. Other resolutions are untested. The gamepad prompts do render, confirmed in the stickiness run above.

Split the options screen into two tabs, Display and Keyboard, one layer
each, with a shared tab strip that switches between them on Q/E or the
gamepad shoulder buttons.

The Keyboard tab lists the rebindable actions with their current key and
captures a new one on confirm. Rebinding is requested from the layer as an
atomic flag; the key capture, the binding write and the settings write all
run on the main thread inside InputManager::update(). The capture frame
resolves no actions, so the captured key cannot also fire the action it is
being bound to. Overrides persist as input.<action> and reapply after the
defaults are built; 0 means no override, so Reset to Defaults just writes
zeros.

Menu navigation is not rebindable: losing it strands the player on the
screen. Escape cancels a capture, so an action cannot be bound back to
Escape by hand.

Layers that asked whether the options screen was open by testing
OptionLayer::isActive() now ask Maze::isOptionsOpen(); with the screen
split in two, the old test read as closed on the Keyboard tab and woke the
intro, exit and maze layers behind it.

Prompt sprites for Q, E, LB and RB come from Kenney Input Prompts (CC0),
from the same folders as the sprites already in the tree. The sprite cache
moves out of keyhints.cpp so the tab strip and the hint bar share it.
@github-actions github-actions Bot added sponge Sponge engine changes game Game changes labels Aug 31, 2026
The active device switched back to KeyboardMouse on held key state. The
Xbox 360 driver aliases the D-pad to arrow keys, so the ghost key was
still down on the frame after a gamepad button released and flipped the
device on every press.

Claim the device on a fresh key or mouse button edge instead. The special
cases for Escape and the arrow keys were only there to order against the
gamepad check and are now covered by the generic loop.
The active tab now carries the same red fill the menu rows use for the
selected row, with a small dot before the label, and the red underline is
gone. No border and no rounded corners, so the strip stays lighter than a
menu row.

Widen the gaps between the prompt icons and the labels, and between the
labels themselves, so the strip does not read as one run of text.
Cut a comment that restated the line under it, and shorten two others.
@tomconder
tomconder merged commit f1e268b into main Sep 2, 2026
9 checks passed
@tomconder
tomconder deleted the feat/key-mapping-screen branch September 2, 2026 01:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

game Game changes sponge Sponge engine changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant