Add wrapping keyboard and gamepad focus rings
Full build / Import + headless tests (push) Successful in 36s
Full build / Full desktop build + optional release (push) Skipped

- 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.
This commit is contained in:
Gig Line Dev committed 2026-11-02 16:05:53 +01:00
1 parent 9692f262f1
commit 8f65751975
17 files changed
+262 -76

No files matched your search

+3
View File
@@ -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.
+7 -1
View File
@@ -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
+5 -3
View File
@@ -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
+2 -1
View File
@@ -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.
+21 -2
View File
@@ -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.
+5
View File
@@ -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,
+21 -9
View File
@@ -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()
+12 -8
View File
@@ -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()
+9 -4
View File
@@ -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()
+20 -5
View File
@@ -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()
+11 -1
View File
@@ -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()
+39 -20
View File
@@ -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
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()
+39 -17
View File
@@ -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():
+45
View File
@@ -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
+1
View File
@@ -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
+8 -5
View File
@@ -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()
+14
View File
@@ -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