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.
This commit is contained in:
1 parent
9692f262f1
commit
8f65751975
17 files changed
+262
-76
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user