From 8f6575197524a2572cde45f04f1a31782ebd4441 Mon Sep 17 00:00:00 2001 From: Gig Line Dev Date: Mon, 2 Nov 2026 16:05:53 +0100 Subject: [PATCH] Add wrapping keyboard and gamepad focus rings - Link every modal's enabled controls into a wrapping UiFocus ring and give each screen a default focus target. - Add gamepad A/B bindings for ui_accept/ui_cancel; include Start menu footer actions and Continue in its focus ring. - Skip hidden/disabled controls, close focus traps via per-modal cancel behavior, and return review to the menu on cancel. - Record the focus audit and add regression coverage. --- CHANGELOG.md | 3 ++ MEMORY.md | 8 +++- TODO.md | 8 ++-- docs/development-status.md | 3 +- docs/ui-copy.md | 23 +++++++++- src/game/guard_controller.gd | 5 +++ src/game/ui/event_timeline.gd | 30 +++++++++---- src/game/ui/help_overlay.gd | 20 +++++---- src/game/ui/pause_overlay.gd | 13 ++++-- src/game/ui/radio_panel.gd | 25 ++++++++--- src/game/ui/settings_panel.gd | 12 +++++- src/game/ui/shift_review.gd | 59 +++++++++++++++++--------- src/game/ui/start_menu.gd | 56 ++++++++++++++++-------- src/game/ui/ui_focus.gd | 45 ++++++++++++++++++++ src/test/tests/test_player_movement.gd | 1 + src/test/tests/test_session_shell.gd | 13 +++--- src/test/tests/test_ui_buttons.gd | 14 ++++++ 17 files changed, 262 insertions(+), 76 deletions(-) create mode 100644 src/game/ui/ui_focus.gd diff --git a/CHANGELOG.md b/CHANGELOG.md index c70938b..7f5dce0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ Semantic Versioning. - All session, settings, radio, Help, review and timeline action buttons now share explicit normal, hover, pressed, focus and disabled styles. Timeline Previous/Next disable at their boundaries. +- Keyboard and gamepad focus rings now wrap through every modal, with explicit + default focus and A/B accept/cancel bindings; hidden Continue and disabled + timeline controls are skipped. - NPC reason answers now use role-specific dialogue contexts, so staff, guests, delivery workers, performers and other archetypes answer instead of silently deflecting the Reason verb. diff --git a/MEMORY.md b/MEMORY.md index 72c9982..0a9c15b 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -61,6 +61,12 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o gallery is 13 good / 0 bad, the screen-design judge is 13/13 and a clean portrait measured a 0.732 vision gap against the photoreal concept. No release build, version bump or tag was created at this gate. +- **UI focus (2026-11-02):** `src/game/ui/ui_focus.gd` links visible/enabled + controls into a wrapping ring; each modal sets default focus on open. Start menu + uses its own focus-target list so Continue, venue rows and footer actions share + one ring. `GuardController._ensure_gamepad_bindings()` adds A/B to + `ui_accept`/`ui_cancel` so pad-only users can confirm/cancel; hidden Continue and + disabled timeline buttons never enter the ring. - **Button states (2026-11-02):** `src/game/ui/ui_styles.gd` owns the five action-button states and font colours for the dark session/UI shell. Use `UiStyles.apply_button()` for every new `Button`; CheckButton/OptionButton keep the engine theme. Timeline @@ -706,7 +712,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o - Venue `door_staff_lines` are logged through `GameManager` (inspect / wand / refuse). They are not a separate dialogue UI. - Access loadout is implemented; do not re-fix `override_access` as if it were still unread. - `Save.SETTINGS_FILE` is declared and unused; settings persistence goes through `Save.load_settings()` / `save_settings()`. -- Headless suite is **259/259**; the windowed smokes, screenshot compare and judge pass. Remaining visual follow-ups are in `TODO.md`. +- Headless suite is **260/260**; the windowed smokes, screenshot compare and judge pass. Remaining visual follow-ups are in `TODO.md`. - Typed spawn signal extra args: handlers must accept the 4th `event: Dictionary` or Godot will not call them (`test_shift_coordinator_runs_a_shift` failed until `_on_spawn` took four args). - GDScript inference trap that broke the main scene: a `var` typed as a base class (`radial: CanvasLayer`) exposes script members as `Variant`, so `var x := radial.is_open` is a diff --git a/TODO.md b/TODO.md index 3aed9d9..d56ebb0 100644 --- a/TODO.md +++ b/TODO.md @@ -7,7 +7,7 @@ see `AGENTS.md` for the workflow and `MEMORY.md` for the decisions behind these Baseline: **v0.9.2** (2026-11-02). Three playable venues, tutorial/campaign/checkpoint resume, difficulty/help, audio/accessibility settings, a 24-NPC budget baseline, a complete -escalation/access/radio loop, shift review, the Blender asset library, 259 headless + 97 Python +escalation/access/radio loop, shift review, the Blender asset library, 260 headless + 97 Python tests, a clean screen-design judge and an optional Forward+ Desktop HQ build. This is a strong release-candidate foundation, not yet a 0.10.0 release candidate. @@ -81,8 +81,10 @@ screen-design judge before any model/texture changes. No gameplay rules change. radio send/emergency hold and Help close. (Shared `UiStyles` states applied; timeline boundary disabling and the keyboard-only F1 skip are documented in `docs/ui-copy.md`.) -- [ ] Verify keyboard-only and gamepad-only navigation: focus order, wrapping, - default focus, confirm/cancel behavior and no focus traps. +- [x] Verify keyboard-only and gamepad-only navigation: focus order, wrapping, + default focus, confirm/cancel behavior and no focus traps. (Focus rings, + gamepad A/B UI bindings, default focus and per-modal cancel behavior are + recorded in `docs/ui-copy.md`.) - [ ] Verify every modal closes with Esc/right-click where documented, recaptures the mouse, and does not leave the guard frozen or the menu visible. - [ ] Test buttons at minimum/maximum HUD font scale and with 1.5–2× longer diff --git a/docs/development-status.md b/docs/development-status.md index 794e61e..be53327 100644 --- a/docs/development-status.md +++ b/docs/development-status.md @@ -5,7 +5,7 @@ Implemented so far: - Godot 4.7.2 project scaffold, input map, procedural world builder (CI/local/release unified) - Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem` - Three authored scenarios (El Capitan Entertainment Centre / Hollywood Boulevard, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools -- Headless unit tests: **259 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, input remaps, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material, player movement, NPC character layers/LOD/queue, input bindings) +- Headless unit tests: **260 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, input remaps, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material, player movement, NPC character layers/LOD/queue, input bindings) - Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event - First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review - Control pass: focus cone on NPC heads with a billboarded blue circle in front of the face and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`) @@ -51,6 +51,7 @@ Implemented so far: - Joke Shop / Sunset Strip map (map optimization): the second venue is now a stylized reconstruction of The Comedy Store block at 8433 Sunset Blvd, with a 27 m black hall, signature-covered sloping canopy, circular **THE JOKE SHOP** blade sign, red velvet curtain bays, a west-end entrance, cream and white low-rise neighbours, a dry hillside with stucco houses and palms behind the roofline, and a low retail/billboard frontage across Sunset. The same geometry serves day and late lighting through the phase system. Real-site references and limits are in [`joke-shop-map.md`](joke-shop-map.md); the map measures **22,680 triangles across 454 meshes** in the budget test. - Venue geometry (v0.7.0, Joke Shop frontage superseded by the map-optimization pass): the TV Studio builds a stone facade, bulb canopy, glossy black car and barrier line. `WorldBuilder.VENUE_SPECS` drives facade/street/nav dimensions, and `GameManager` rebuilds only the world container when the venue changes. - Gear fit (post-v0.6.0): Blender gear is measured against the avatar head/skirt silhouette and the checks run in both the Python tooling and the headless suite. Sunglasses, the shoulder bag and the firefighter helmet were fitted to the new faces; the Influencer portrait and gallery were recaptured with a before/after report. +- Keyboard/gamepad focus pass (0.9.3 work): `UiFocus` rings wrap through visible/enabled controls, hidden Continue and disabled timeline actions are skipped, each modal has a default focus, and A/B are bound to `ui_accept`/`ui_cancel` for pad-only navigation. Recorded in `docs/ui-copy.md`. - Button-state pass (0.9.3 work): a shared `UiStyles` helper now gives every action button explicit normal, hover, pressed, focus and disabled styles; the timeline disables Previous/Next at its boundaries, while hidden continue states and the keyboard-only tutorial skip remain intentional. Recorded in `docs/ui-copy.md`. - Dialogue pass (0.9.3 work): NPC reason answers now use the authored `reason` context, with an in-character answer for every shared archetype; Hollywood and Studio gained door-staff lines; radio cancellation, tutorial Ask terminology, help, start hints and campaign reset copy were clarified. Audit recorded in `docs/ui-copy.md`. - Pre-build NPC review (2026-11-02): all eight library characters were staged at portrait, conversation and crowd distance on Hollywood and the Joke Shop, in day and night lighting, with the normal crowd hidden (`src/test/capture_npc_review.gd`). No P0 issues were found. Two P1 gear-fit issues were fixed with runtime attach offsets: the authorized-guest lanyard badge now clears the jacket and the firefighter helmet sits above the eyes. Backpack straps, caps, hands and the intentionally low-poly faces were accepted for the stylized target; close-range flashlight wash remains an optional-lighting follow-up. The 24-NPC budget stayed at 6.93 ms, the gallery passed 13 good / 0 bad and the screen-design judge stayed 13/13, and a clean portrait scored a 0.732 vision gap against the photoreal concept. diff --git a/docs/ui-copy.md b/docs/ui-copy.md index 63b2a7a..8bb1cb5 100644 --- a/docs/ui-copy.md +++ b/docs/ui-copy.md @@ -1,6 +1,6 @@ # UI copy and button inventory -Status: dialogue and button-state passes complete; focus/layout audit in progress for 0.9.3. +Status: dialogue, button-state and keyboard/gamepad focus passes complete; layout/end-to-end audit in progress for 0.9.3. ## Screen inventory @@ -109,10 +109,29 @@ appeared when a custom dark button was disabled or focused by keyboard. | Radio | Send request, Send emergency, Cancel. Emergency hold uses press/release, so the action button stays enabled while holding. | All styled; disabling it during the hold would break confirmation. | | Toggles | CheckButton/OptionButton controls keep the engine theme. | Intentional: the shared style targets action buttons, not checkbox/radio affordances. | +## Keyboard and gamepad focus + +`UiFocus` builds an explicit focus ring for each modal, skips hidden and +disabled controls, and makes Up/Down, Tab/Shift+Tab and the D-pad/stick wrap. +`GuardController._ensure_gamepad_bindings()` adds A to `ui_accept` and B to +`ui_cancel`; the built-in directional UI actions already carry D-pad/stick +events. + +| Surface | Default focus | Cancellation / trap check | +| --- | --- | --- | +| Start menu | First visible target: Continue when present, otherwise the first venue. | A/Enter/Space activates the focused row; B/Esc quits; Continue, venue rows and footer actions share one wrapping ring. | +| Pause | Resume. | B/Esc resumes; the four actions wrap. | +| Settings | First slider. | B/Esc cancels; focus ring covers sliders, toggles, difficulty, rebinds, Apply and Cancel; sliders keep Left/Right for value adjustment. | +| Help | Search field. | B/Esc closes; Search and Close share a ring. | +| Shift review | Continue when available, otherwise the timeline. | B/Esc returns to the menu; all four actions are in the ring and hidden Continue is skipped. | +| Timeline | First enabled action: Next when Previous is disabled, Close when empty. | B/Esc closes; Previous/Next leave the ring when disabled so focus cannot land on a dead button. | +| Radio | Send action. | B/Esc cancels; form fields, action and Cancel are linked; A confirms the focused action. | +| Tutorial | None; F1 is the documented skip shortcut. | Keyboard-only by design, no focus trap because the card is mouse-transparent and non-modal. | + ## Remaining audit work - Player-facing labels are now routed through `content/strings.en.json`; numeric values, empty-state clears and debug-only text remain in code by design. -- Verify focus/hover/pressed/disabled states with keyboard, gamepad and mouse. +- Focus/hover/pressed/disabled states and keyboard/gamepad rings are recorded above. - Test long strings and 1.75x HUD font scale. - Capture every dialog/button state and run `screen_designs.py compare` plus the pinned judge. diff --git a/src/game/guard_controller.gd b/src/game/guard_controller.gd index b44525c..17a145e 100644 --- a/src/game/guard_controller.gd +++ b/src/game/guard_controller.gd @@ -96,6 +96,11 @@ static func _ensure_gamepad_bindings() -> void: var buttons := { "action_menu": JOY_BUTTON_A, "cancel": JOY_BUTTON_B, + # UI focus navigation uses Godot's built-in UI actions. Bind the pad + # accept/cancel buttons too, so every modal works without a mouse or + # keyboard. D-pad and stick navigation are already in ui_up/down/left/right. + "ui_accept": JOY_BUTTON_A, + "ui_cancel": JOY_BUTTON_B, "inspect": JOY_BUTTON_X, "radio_assist": JOY_BUTTON_Y, "walk_slow": JOY_BUTTON_LEFT_SHOULDER, diff --git a/src/game/ui/event_timeline.gd b/src/game/ui/event_timeline.gd index b95d3f1..8266013 100644 --- a/src/game/ui/event_timeline.gd +++ b/src/game/ui/event_timeline.gd @@ -10,6 +10,7 @@ signal closed() const Strings := preload("res://src/core/strings.gd") const UiArt := preload("res://src/game/ui/art.gd") const UiStyles := preload("res://src/game/ui/ui_styles.gd") +const UiFocus := preload("res://src/game/ui/ui_focus.gd") var events: Array[Dictionary] = [] var index := 0 @@ -18,6 +19,7 @@ var _detail_label: Label = null var _counter_label: Label = null var _prev_button: Button = null var _next_button: Button = null +var _close_button: Button = null var _root: Control = null func _init() -> void: @@ -107,12 +109,12 @@ func _build() -> void: UiStyles.apply_button(_next_button, 15, true) row.add_child(_next_button) - var close := Button.new() - close.name = "CloseButton" - close.text = Strings.lookup("timeline.close") - close.pressed.connect(_on_close) - UiStyles.apply_button(close, 15, true) - row.add_child(close) + _close_button = Button.new() + _close_button.name = "CloseButton" + _close_button.text = Strings.lookup("timeline.close") + _close_button.pressed.connect(_on_close) + UiStyles.apply_button(_close_button, 15, true) + row.add_child(_close_button) var hint := Label.new() hint.text = Strings.lookup("timeline.hint") @@ -144,6 +146,9 @@ func show_timeline(review: Dictionary) -> void: _root.visible = true Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) _refresh() + var first := UiFocus.first_enabled([_prev_button, _next_button, _close_button]) + if first: + first.grab_focus() func step(direction: int) -> void: if events.is_empty(): @@ -165,6 +170,12 @@ func _refresh() -> void: _prev_button.disabled = not has_events or index <= 0 if _next_button: _next_button.disabled = not has_events or index >= events.size() - 1 + UiFocus.link([_prev_button, _next_button, _close_button]) + # A disabled control cannot keep focus; move to the other end of the row. + if _prev_button and _prev_button.disabled and _prev_button.has_focus() and _close_button: + _close_button.grab_focus() + if _next_button and _next_button.disabled and _next_button.has_focus() and _close_button: + _close_button.grab_focus() if not has_events: _counter_label.text = Strings.lookup("timeline.empty") _detail_label.text = "" @@ -225,6 +236,10 @@ func _stamp(at_ms: int) -> String: func _unhandled_input(event: InputEvent) -> void: if not visible: return + if event.is_action_pressed("ui_cancel"): + close() + get_viewport().set_input_as_handled() + return if event is InputEventKey and event.pressed and not event.echo: match event.keycode: KEY_LEFT, KEY_A: @@ -233,6 +248,3 @@ func _unhandled_input(event: InputEvent) -> void: KEY_RIGHT, KEY_D: step(1) get_viewport().set_input_as_handled() - KEY_ESCAPE: - close() - get_viewport().set_input_as_handled() diff --git a/src/game/ui/help_overlay.gd b/src/game/ui/help_overlay.gd index 7fe2c20..e1b120f 100644 --- a/src/game/ui/help_overlay.gd +++ b/src/game/ui/help_overlay.gd @@ -5,11 +5,13 @@ extends CanvasLayer const Strings := preload("res://src/core/strings.gd") const InputBindings := preload("res://src/core/input_bindings.gd") const UiStyles := preload("res://src/game/ui/ui_styles.gd") +const UiFocus := preload("res://src/game/ui/ui_focus.gd") signal closed() var _root: Control = null var _search: LineEdit = null +var _close_button: Button = null var _body: Label = null var _policy: String = "" var _lines: Array[String] = [] @@ -67,12 +69,13 @@ func _build() -> void: _body.vertical_alignment = VERTICAL_ALIGNMENT_TOP column.add_child(_body) - var close := Button.new() - close.text = Strings.lookup("ui.help_close") - close.pressed.connect(func(): close_panel()) - close.size_flags_horizontal = Control.SIZE_SHRINK_CENTER - UiStyles.apply_button(close, 16) - column.add_child(close) + _close_button = Button.new() + _close_button.text = Strings.lookup("ui.help_close") + _close_button.pressed.connect(func(): close_panel()) + _close_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + UiStyles.apply_button(_close_button, 16) + column.add_child(_close_button) + UiFocus.link([_search, _close_button], false) func open(policy: String = "") -> void: _policy = policy @@ -80,6 +83,8 @@ func open(policy: String = "") -> void: _root.visible = true _search.text = "" _refresh("") + UiFocus.link([_search, _close_button], false) + _search.grab_focus() Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) func close_panel() -> void: @@ -106,7 +111,6 @@ func _refresh(filter: String) -> void: func _unhandled_input(event: InputEvent) -> void: if not visible: return - if event is InputEventKey and event.pressed and not event.echo \ - and event.keycode == KEY_ESCAPE: + if event.is_action_pressed("ui_cancel"): close_panel() get_viewport().set_input_as_handled() diff --git a/src/game/ui/pause_overlay.gd b/src/game/ui/pause_overlay.gd index 95d524a..4a9fcfd 100644 --- a/src/game/ui/pause_overlay.gd +++ b/src/game/ui/pause_overlay.gd @@ -11,10 +11,12 @@ signal quit_to_menu_requested() const UiArt := preload("res://src/game/ui/art.gd") const UiStyles := preload("res://src/game/ui/ui_styles.gd") +const UiFocus := preload("res://src/game/ui/ui_focus.gd") const Strings := preload("res://src/core/strings.gd") var _root: Control = null var _resume_button: Button = null +var _buttons: Array[Button] = [] func _ready() -> void: layer = 18 @@ -53,9 +55,11 @@ func _build() -> void: column.add_child(title) _resume_button = _add_button(column, Strings.lookup("ui.pause_resume"), "ResumeButton", func(): resume_requested.emit()) - _add_button(column, Strings.lookup("ui.pause_restart"), "RestartButton", func(): restart_requested.emit()) - _add_button(column, Strings.lookup("ui.pause_settings"), "SettingsButton", func(): settings_requested.emit()) - _add_button(column, Strings.lookup("ui.pause_menu"), "MenuButton", func(): quit_to_menu_requested.emit()) + _buttons.append(_add_button(column, Strings.lookup("ui.pause_restart"), "RestartButton", func(): restart_requested.emit())) + _buttons.append(_add_button(column, Strings.lookup("ui.pause_settings"), "SettingsButton", func(): settings_requested.emit())) + _buttons.append(_add_button(column, Strings.lookup("ui.pause_menu"), "MenuButton", func(): quit_to_menu_requested.emit())) + _buttons.push_front(_resume_button) + UiFocus.link(_buttons) var hint := Label.new() hint.text = Strings.lookup("ui.pause_hint") @@ -77,6 +81,7 @@ func open() -> void: visible = true _root.visible = true Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) + UiFocus.link(_buttons) if _resume_button: _resume_button.grab_focus() @@ -87,6 +92,6 @@ func close() -> void: func _unhandled_input(event: InputEvent) -> void: if not visible: return - if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE: + if event.is_action_pressed("ui_cancel"): resume_requested.emit() get_viewport().set_input_as_handled() diff --git a/src/game/ui/radio_panel.gd b/src/game/ui/radio_panel.gd index 213a4d6..4d1f200 100644 --- a/src/game/ui/radio_panel.gd +++ b/src/game/ui/radio_panel.gd @@ -11,6 +11,7 @@ const Rules := preload("res://src/core/rules.gd") const Strings := preload("res://src/core/strings.gd") const UiArt := preload("res://src/game/ui/art.gd") const UiStyles := preload("res://src/game/ui/ui_styles.gd") +const UiFocus := preload("res://src/game/ui/ui_focus.gd") var panel: PanelContainer var npc_name_label: Label @@ -19,6 +20,8 @@ var desc_edit: LineEdit var weapons_check: CheckBox var danger_option: OptionButton var action_button: Button +var cancel_button: Button +var _focus_controls: Array[Control] = [] var hold_hint: Label var status_label: Label @@ -110,11 +113,14 @@ func _init() -> void: action_button.pressed.connect(_on_action_pressed) UiStyles.apply_button(action_button, 16) buttons.add_child(action_button) - var cancel_btn := Button.new() - cancel_btn.text = Strings.lookup("ui.radio_cancel") - cancel_btn.pressed.connect(func(): cancelled.emit()) - UiStyles.apply_button(cancel_btn, 16) - buttons.add_child(cancel_btn) + cancel_button = Button.new() + cancel_button.text = Strings.lookup("ui.radio_cancel") + cancel_button.pressed.connect(func(): cancelled.emit()) + UiStyles.apply_button(cancel_button, 16) + buttons.add_child(cancel_button) + _focus_controls = [location_edit, desc_edit, weapons_check, danger_option, + action_button, cancel_button] + UiFocus.link(_focus_controls, false) var _title_label: Label @@ -148,6 +154,8 @@ func open(controller: Object, emergency: bool, truth: Dictionary) -> void: _holding = false _hold_time = 0.0 visible = true + UiFocus.link(_focus_controls, false) + action_button.grab_focus() ## Non-emergency requests are a normal click. Emergency calls require the ## press-and-hold path, so the click release must not submit early. @@ -200,3 +208,10 @@ func _danger_name(level: int) -> String: 2: return "elevated" 3: return "immediate threat" _: return "unknown" + +func _unhandled_input(event: InputEvent) -> void: + if not visible: + return + if event.is_action_pressed("ui_cancel"): + cancelled.emit() + get_viewport().set_input_as_handled() diff --git a/src/game/ui/settings_panel.gd b/src/game/ui/settings_panel.gd index e1c7439..9442e44 100644 --- a/src/game/ui/settings_panel.gd +++ b/src/game/ui/settings_panel.gd @@ -9,6 +9,7 @@ extends CanvasLayer const InputBindings := preload("res://src/core/input_bindings.gd") const UiArt := preload("res://src/game/ui/art.gd") const UiStyles := preload("res://src/game/ui/ui_styles.gd") +const UiFocus := preload("res://src/game/ui/ui_focus.gd") const Strings := preload("res://src/core/strings.gd") signal applied(settings: Dictionary) @@ -244,8 +245,17 @@ func open(current: Dictionary) -> void: _sync_labels() visible = true _root.visible = true + _link_focus() + if _sensitivity: + _sensitivity.grab_focus() Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) +func _link_focus() -> void: + var controls: Array = [] + for node in _root.find_children("*", "Control", true, false): + controls.append(node) + UiFocus.link(controls, false) + func close() -> void: visible = false _root.visible = false @@ -334,6 +344,6 @@ func _unhandled_input(event: InputEvent) -> void: if pressed and _apply_remap(event): get_viewport().set_input_as_handled() return - if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE: + if event.is_action_pressed("ui_cancel"): _on_cancel() get_viewport().set_input_as_handled() diff --git a/src/game/ui/shift_review.gd b/src/game/ui/shift_review.gd index 910f0ea..d90ac16 100644 --- a/src/game/ui/shift_review.gd +++ b/src/game/ui/shift_review.gd @@ -5,6 +5,7 @@ extends CanvasLayer const Strings := preload("res://src/core/strings.gd") const UiArt := preload("res://src/game/ui/art.gd") const UiStyles := preload("res://src/game/ui/ui_styles.gd") +const UiFocus := preload("res://src/game/ui/ui_focus.gd") signal restart_requested() signal continue_requested() @@ -16,6 +17,9 @@ var _review: Dictionary = {} var report_label: Label var _campaign_label: Label = null var _continue_button: Button = null +var _timeline_button: Button = null +var _restart_button: Button = null +var _quit_button: Button = null func _init() -> void: name = "ShiftReview" @@ -56,33 +60,36 @@ func _init() -> void: center.add_child(_campaign_label) _continue_button = Button.new() + _continue_button.name = "ContinueButton" _continue_button.text = Strings.lookup("review.continue_campaign") _continue_button.pressed.connect(func(): continue_requested.emit()) _continue_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER center.add_child(_continue_button) - var timeline := Button.new() - timeline.name = "TimelineButton" - timeline.text = Strings.lookup("review.timeline_button") - timeline.pressed.connect(func(): timeline_requested.emit(_review)) - timeline.size_flags_horizontal = Control.SIZE_SHRINK_CENTER - center.add_child(timeline) + _timeline_button = Button.new() + _timeline_button.name = "TimelineButton" + _timeline_button.text = Strings.lookup("review.timeline_button") + _timeline_button.pressed.connect(func(): timeline_requested.emit(_review)) + _timeline_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + center.add_child(_timeline_button) - var restart := Button.new() - restart.text = Strings.lookup("ui.review_return_menu") - restart.pressed.connect(func(): restart_requested.emit()) - restart.size_flags_horizontal = Control.SIZE_SHRINK_CENTER - center.add_child(restart) + _restart_button = Button.new() + _restart_button.name = "ReturnMenuButton" + _restart_button.text = Strings.lookup("ui.review_return_menu") + _restart_button.pressed.connect(func(): restart_requested.emit()) + _restart_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + center.add_child(_restart_button) - var quit_btn := Button.new() - quit_btn.text = Strings.lookup("ui.quit_game") - quit_btn.pressed.connect(func(): get_tree().quit()) - quit_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER - _style_review_button(timeline) + _quit_button = Button.new() + _quit_button.name = "QuitButton" + _quit_button.text = Strings.lookup("ui.quit_game") + _quit_button.pressed.connect(func(): get_tree().quit()) + _quit_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + _style_review_button(_timeline_button) _style_review_button(_continue_button) - _style_review_button(restart) - _style_review_button(quit_btn) - center.add_child(quit_btn) + _style_review_button(_restart_button) + _style_review_button(_quit_button) + center.add_child(_quit_button) func _style_review_button(button: Button) -> void: button.custom_minimum_size = Vector2(300, 52) @@ -154,4 +161,16 @@ func show_review(review: Dictionary, scenario: Resource, history: Array = [], _campaign_label.text = "" if _continue_button: _continue_button.visible = not campaign_complete and next_venue != "" - visible = true \ No newline at end of file + visible = true + var buttons: Array = [_continue_button, _timeline_button, _restart_button, _quit_button] + UiFocus.link(buttons) + var first := UiFocus.first_enabled(buttons) + if first: + first.grab_focus() + +func _unhandled_input(event: InputEvent) -> void: + if not visible: + return + if event.is_action_pressed("ui_cancel"): + restart_requested.emit() + get_viewport().set_input_as_handled() \ No newline at end of file diff --git a/src/game/ui/start_menu.gd b/src/game/ui/start_menu.gd index b1622f3..d70a983 100644 --- a/src/game/ui/start_menu.gd +++ b/src/game/ui/start_menu.gd @@ -25,6 +25,8 @@ const VENUES := [ var _index := 0 var _buttons: Array[Button] = [] +var _footer_buttons: Array[Button] = [] +var _focus_targets: Array[Button] = [] var _root: Control = null var _status: Label = null var _continue_button: Button = null @@ -139,6 +141,7 @@ func _build() -> void: help_button.pressed.connect(func(): help_requested.emit()) UiStyles.apply_button(help_button, 16) row.add_child(help_button) + _footer_buttons.append(help_button) var settings_button := Button.new() settings_button.name = "SettingsButton" @@ -146,6 +149,7 @@ func _build() -> void: settings_button.pressed.connect(func(): settings_requested.emit()) UiStyles.apply_button(settings_button, 16) row.add_child(settings_button) + _footer_buttons.append(settings_button) var quit_button := Button.new() quit_button.name = "QuitButton" @@ -153,6 +157,7 @@ func _build() -> void: quit_button.pressed.connect(func(): quit_requested.emit()) UiStyles.apply_button(quit_button, 16) row.add_child(quit_button) + _footer_buttons.append(quit_button) var hint := Label.new() hint.text = Strings.lookup("ui.start_hint") @@ -160,13 +165,28 @@ func _build() -> void: hint.add_theme_color_override("font_color", Color(0.55, 0.58, 0.65)) column.add_child(hint) + _refresh_focus_targets() _focus_index(0) +## Continue + venue rows + footer actions, in visual order. Hidden Continue is +## skipped so keyboard/gamepad focus never lands on an invisible control. +func _refresh_focus_targets() -> void: + _focus_targets.clear() + if _continue_button and _continue_button.visible: + _focus_targets.append(_continue_button) + _focus_targets.append_array(_buttons) + _focus_targets.append_array(_footer_buttons) + func _focus_index(i: int) -> void: - if _buttons.is_empty(): + if _focus_targets.is_empty(): return - _index = clampi(i, 0, _buttons.size() - 1) - _buttons[_index].grab_focus() + _index = wrapi(i, 0, _focus_targets.size()) + _focus_targets[_index].grab_focus() + +func _activate_focus() -> void: + if _focus_targets.is_empty(): + return + _focus_targets[_index].pressed.emit() func open(campaign: Dictionary = {}) -> void: _campaign = campaign.duplicate(true) @@ -184,6 +204,7 @@ func open(campaign: Dictionary = {}) -> void: var next_label := _venue_label(next_id) _continue_button.text = Strings.fmt("ui.continue_shift", [next_label]) if next_id != "" else Strings.lookup("ui.campaign_complete_short") _continue_button.visible = not complete + _refresh_focus_targets() _focus_index(_index) func _venue_label(id: String) -> String: @@ -203,20 +224,21 @@ func close() -> void: func _unhandled_input(event: InputEvent) -> void: if not visible: return - if event is InputEventKey and event.pressed and not event.echo: - match event.keycode: - KEY_UP, KEY_W: - _focus_index(_index - 1) - get_viewport().set_input_as_handled() - KEY_DOWN, KEY_S: - _focus_index(_index + 1) - get_viewport().set_input_as_handled() - KEY_ENTER, KEY_KP_ENTER, KEY_SPACE: - _on_venue_pressed(_index) - get_viewport().set_input_as_handled() - KEY_ESCAPE: - quit_requested.emit() - get_viewport().set_input_as_handled() + if event is InputEventKey and event.echo: + return + var key := event as InputEventKey + if event.is_action_pressed("ui_up") or (key != null and key.pressed and key.keycode == KEY_W): + _focus_index(_index - 1) + get_viewport().set_input_as_handled() + elif event.is_action_pressed("ui_down") or (key != null and key.pressed and key.keycode == KEY_S): + _focus_index(_index + 1) + get_viewport().set_input_as_handled() + elif event.is_action_pressed("ui_accept"): + _activate_focus() + get_viewport().set_input_as_handled() + elif event.is_action_pressed("ui_cancel"): + quit_requested.emit() + get_viewport().set_input_as_handled() func _on_venue_pressed(i: int) -> void: if i < 0 or i >= VENUES.size(): diff --git a/src/game/ui/ui_focus.gd b/src/game/ui/ui_focus.gd new file mode 100644 index 0000000..1fe6804 --- /dev/null +++ b/src/game/ui/ui_focus.gd @@ -0,0 +1,45 @@ +class_name UiFocus +extends RefCounted +## Small focus-ring helper for keyboard and gamepad navigation. +## +## Godot's default traversal stops at the end of a list. Linking every enabled +## control back to the other end makes Up/Down (and optionally Left/Right) and +## Tab/Shift+Tab wrap predictably inside a modal. + +static func link(controls: Array, include_horizontal: bool = true) -> void: + var valid: Array[Control] = [] + for node in controls: + var control := node as Control + if control == null or not control.visible or control.focus_mode == Control.FOCUS_NONE: + continue + if control is BaseButton and (control as BaseButton).disabled: + continue + if control.is_inside_tree() and control.get_viewport() != null: + valid.append(control) + if valid.is_empty(): + return + var count := valid.size() + for i in count: + var control := valid[i] + var previous: Control = valid[(i - 1 + count) % count] + var next: Control = valid[(i + 1) % count] + var prev_path := control.get_path_to(previous) + var next_path := control.get_path_to(next) + control.focus_neighbor_top = prev_path + control.focus_neighbor_bottom = next_path + control.focus_next = next_path + control.focus_previous = prev_path + if include_horizontal: + control.focus_neighbor_left = prev_path + control.focus_neighbor_right = next_path + +## First enabled control in reading order, or null when every control is off. +static func first_enabled(controls: Array) -> Control: + for node in controls: + var control := node as Control + if control == null or not control.visible or control.focus_mode == Control.FOCUS_NONE: + continue + if control is BaseButton and (control as BaseButton).disabled: + continue + return control + return null diff --git a/src/test/tests/test_player_movement.gd b/src/test/tests/test_player_movement.gd index 0dd5347..dbff07a 100644 --- a/src/test/tests/test_player_movement.gd +++ b/src/test/tests/test_player_movement.gd @@ -43,6 +43,7 @@ func test_gamepad_bindings_are_added_once() -> bool: if InputMap.action_get_deadzone(action) > 0.25: problems.append("%s deadzone too high" % action) var buttons := {"action_menu": JOY_BUTTON_A, "cancel": JOY_BUTTON_B, + "ui_accept": JOY_BUTTON_A, "ui_cancel": JOY_BUTTON_B, "inspect": JOY_BUTTON_X, "walk_slow": JOY_BUTTON_LEFT_SHOULDER} for action in buttons.keys(): var found := false diff --git a/src/test/tests/test_session_shell.gd b/src/test/tests/test_session_shell.gd index db7b9b0..71047cf 100644 --- a/src/test/tests/test_session_shell.gd +++ b/src/test/tests/test_session_shell.gd @@ -34,16 +34,19 @@ func test_start_menu_emits_selected_venue() -> bool: menu.free() return a and b -func test_start_menu_clamps_focus() -> bool: +func test_start_menu_wraps_focus() -> bool: var menu: Node = StartMenu.new() menu._build() - menu._focus_index(-5) + var targets: Array = menu._focus_targets + menu._focus_index(-1) var low: int = menu._index - menu._focus_index(99) + menu._focus_index(targets.size()) var high: int = menu._index menu.free() - return TestUtils.check_eq(low, 0, "focus clamps at the top") \ - and TestUtils.check_eq(high, StartMenu.VENUES.size() - 1, "focus clamps at the bottom") + return TestUtils.check_eq(low, targets.size() - 1, "focus wraps from the top to the bottom") \ + and TestUtils.check_eq(high, 0, "focus wraps from the bottom to the top") \ + and TestUtils.check(targets.size() > StartMenu.VENUES.size(), + "Continue and footer actions are in the keyboard focus ring") func test_pause_overlay_buttons_emit() -> bool: var overlay: Node = PauseOverlay.new() diff --git a/src/test/tests/test_ui_buttons.gd b/src/test/tests/test_ui_buttons.gd index dd3b030..c791de2 100644 --- a/src/test/tests/test_ui_buttons.gd +++ b/src/test/tests/test_ui_buttons.gd @@ -4,6 +4,7 @@ extends RefCounted ## theme when a control is focused or disabled. const UiStyles := preload("res://src/game/ui/ui_styles.gd") +const UiFocus := preload("res://src/game/ui/ui_focus.gd") func test_shared_button_states_are_complete() -> bool: var button := Button.new() @@ -24,3 +25,16 @@ func test_shared_button_states_are_complete() -> bool: "the disabled state is visually distinct from normal") button.free() return a and b and c + +func test_focus_helper_skips_hidden_and_disabled_controls() -> bool: + var hidden := Button.new() + hidden.visible = false + var disabled := Button.new() + disabled.disabled = true + var normal := Button.new() + var first := UiFocus.first_enabled([hidden, disabled, normal]) + var a := TestUtils.check_eq(first, normal, "focus skips hidden and disabled controls") + hidden.free() + disabled.free() + normal.free() + return a