Unify action-button states across the UI
Full build / Import + headless tests (push) Successful in 39s
Full build / Full desktop build + optional release (push) Skipped

- Add UiStyles with explicit normal, hover, pressed, focus and disabled styles and apply it to every action button.
- Disable timeline Previous/Next at the first/last event and keep Close always available.
- Style settings reset-dialog buttons and cover the button-state audit in docs/ui-copy.md.
- Add shared-state and timeline-boundary regression tests.
This commit is contained in:
Gig Line Dev committed 2026-11-02 15:04:38 +01:00
1 parent 98c31fe44c
commit 9692f262f1
15 files changed
+172 -75

No files matched your search

+3
View File
@@ -8,6 +8,9 @@ Semantic Versioning.
### Changed ### Changed
- 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.
- NPC reason answers now use role-specific dialogue contexts, so staff, guests, - NPC reason answers now use role-specific dialogue contexts, so staff, guests,
delivery workers, performers and other archetypes answer instead of silently delivery workers, performers and other archetypes answer instead of silently
deflecting the Reason verb. deflecting the Reason verb.
+5 -1
View File
@@ -61,6 +61,10 @@ 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 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 portrait measured a 0.732 vision gap against the photoreal concept. No
release build, version bump or tag was created at this gate. release build, version bump or tag was created at this gate.
- **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
Previous/Next disable at the ends; F1 tutorial skip is keyboard-only by design.
- **Version and roadmap renumber (2026-11-02):** source is now **0.9.2**; the - **Version and roadmap renumber (2026-11-02):** source is now **0.9.2**; the
completed map/NPC work is the patch content. Postponed work is renumbered to completed map/NPC work is the patch content. Postponed work is renumbered to
**0.9.3 UI**, **0.9.4 NPC**, **0.9.5 textures**, **0.9.6 integration**, then the **0.9.3 UI**, **0.9.4 NPC**, **0.9.5 textures**, **0.9.6 integration**, then the
@@ -702,7 +706,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. - 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. - 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()`. - `Save.SETTINGS_FILE` is declared and unused; settings persistence goes through `Save.load_settings()` / `save_settings()`.
- Headless suite is **258/258**; the windowed smokes, screenshot compare and judge pass. Remaining visual follow-ups are in `TODO.md`. - Headless suite is **259/259**; 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). - 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 - 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 (`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, 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 difficulty/help, audio/accessibility settings, a 24-NPC budget baseline, a complete
escalation/access/radio loop, shift review, the Blender asset library, 258 headless + 97 Python escalation/access/radio loop, shift review, the Blender asset library, 259 headless + 97 Python
tests, a clean screen-design judge and an optional Forward+ Desktop HQ build. This is a strong 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. release-candidate foundation, not yet a 0.10.0 release candidate.
@@ -76,9 +76,11 @@ screen-design judge before any model/texture changes. No gameplay rules change.
### Button and panel behavior ### Button and panel behavior
- [ ] Audit normal/hover/pressed/focus/disabled states on every button, including - [x] Audit normal/hover/pressed/focus/disabled states on every button, including
tutorial skip, venue continue, settings apply/cancel/reset, judge/review, tutorial skip, venue continue, settings apply/cancel/reset, judge/review,
radio send/emergency hold and Help close. 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, - [ ] Verify keyboard-only and gamepad-only navigation: focus order, wrapping,
default focus, confirm/cancel behavior and no focus traps. default focus, confirm/cancel behavior and no focus traps.
- [ ] Verify every modal closes with Esc/right-click where documented, recaptures - [ ] Verify every modal closes with Esc/right-click where documented, recaptures
+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) - 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` - 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 - 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: **258 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: **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)
- 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 - 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 - 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`) - 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. - 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. - 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. - 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.
- 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`. - 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. - 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.
+20 -1
View File
@@ -1,6 +1,6 @@
# UI copy and button inventory # UI copy and button inventory
Status: dialogue pass complete; button/focus/font-scale audit in progress for 0.9.3. Status: dialogue and button-state passes complete; focus/layout audit in progress for 0.9.3.
## Screen inventory ## Screen inventory
@@ -90,6 +90,25 @@ silent profiles can still return an empty context line.
| Repetition | Shared tourist/confused/delivery lines recur across venues by design; role-specific reason lines prevent every archetype sounding alike. | Accepted for the shared-archetype model. | | Repetition | Shared tourist/confused/delivery lines recur across venues by design; role-specific reason lines prevent every archetype sounding alike. | Accepted for the shared-archetype model. |
| Localization length | Longest authored NPC/door line is 56 characters; longest UI string is `hud.controls` at 172 characters. | Dialogue fits the HUD log; the 1.5–2× string/font-scale layout test remains a separate 0.9.3 task. | | Localization length | Longest authored NPC/door line is 56 characters; longest UI string is `hud.controls` at 172 characters. | Dialogue fits the HUD log; the 1.5–2× string/font-scale layout test remains a separate 0.9.3 task. |
## Button state audit
All `Button` controls now use `src/game/ui/ui_styles.gd`, which defines explicit
**normal**, **hover**, **pressed**, **focus** and **disabled** styles plus the
matching font colours. This removes the engine-theme fallback that previously
appeared when a custom dark button was disabled or focused by keyboard.
| Surface | States / behavior checked | Result |
| --- | --- | --- |
| Start menu | Venue rows, Continue, Help, Settings, Quit; focus clamped to the list; Continue hidden when the campaign is complete. | All buttons styled; hidden state is intentional, not a disabled state. |
| Tutorial | F1 skip is a keyboard shortcut, not a button; its hint is non-interactive. | Keyboard-only by design; no hover/focus state to style. |
| Pause | Resume, Restart, Settings, Quit to menu; Resume receives focus on open. | All four states styled; focus is distinct from hover. |
| Settings | Replay tutorial, Reset campaign, key-binding rows, Apply settings, Cancel; rebinding shows “Press…” while listening. | All action buttons styled; reset-confirmation dialog OK/Cancel styled; fields keep the engine control theme. |
| Help | Close; Esc still closes. | Styled; no disabled state is used. |
| Shift review | Continue campaign, Body camera timeline, Return to menu, Quit game; Continue hidden when the campaign is complete. | All styled; hidden state is intentional. |
| Timeline | Previous/Next/Close; Previous is disabled at the first event, Next at the last, and both when empty. | Disabled state verified in `test_event_timeline.gd`; Close stays enabled. |
| 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. |
## Remaining audit work ## Remaining audit work
- Player-facing labels are now routed through `content/strings.en.json`; numeric - Player-facing labels are now routed through `content/strings.en.json`; numeric
+22 -11
View File
@@ -9,12 +9,15 @@ signal closed()
const Strings := preload("res://src/core/strings.gd") const Strings := preload("res://src/core/strings.gd")
const UiArt := preload("res://src/game/ui/art.gd") const UiArt := preload("res://src/game/ui/art.gd")
const UiStyles := preload("res://src/game/ui/ui_styles.gd")
var events: Array[Dictionary] = [] var events: Array[Dictionary] = []
var index := 0 var index := 0
var _list_label: Label = null var _list_label: Label = null
var _detail_label: Label = null var _detail_label: Label = null
var _counter_label: Label = null var _counter_label: Label = null
var _prev_button: Button = null
var _next_button: Button = null
var _root: Control = null var _root: Control = null
func _init() -> void: func _init() -> void:
@@ -90,22 +93,25 @@ func _build() -> void:
row.add_theme_constant_override("separation", 10) row.add_theme_constant_override("separation", 10)
column.add_child(row) column.add_child(row)
var prev := Button.new() _prev_button = Button.new()
prev.name = "PrevButton" _prev_button.name = "PrevButton"
prev.text = Strings.lookup("timeline.prev") _prev_button.text = Strings.lookup("timeline.prev")
prev.pressed.connect(func(): step(-1)) _prev_button.pressed.connect(func(): step(-1))
row.add_child(prev) UiStyles.apply_button(_prev_button, 15, true)
row.add_child(_prev_button)
var next := Button.new() _next_button = Button.new()
next.name = "NextButton" _next_button.name = "NextButton"
next.text = Strings.lookup("timeline.next") _next_button.text = Strings.lookup("timeline.next")
next.pressed.connect(func(): step(1)) _next_button.pressed.connect(func(): step(1))
row.add_child(next) UiStyles.apply_button(_next_button, 15, true)
row.add_child(_next_button)
var close := Button.new() var close := Button.new()
close.name = "CloseButton" close.name = "CloseButton"
close.text = Strings.lookup("timeline.close") close.text = Strings.lookup("timeline.close")
close.pressed.connect(_on_close) close.pressed.connect(_on_close)
UiStyles.apply_button(close, 15, true)
row.add_child(close) row.add_child(close)
var hint := Label.new() var hint := Label.new()
@@ -154,7 +160,12 @@ func _on_close() -> void:
close() close()
func _refresh() -> void: func _refresh() -> void:
if events.is_empty(): var has_events := not events.is_empty()
if _prev_button:
_prev_button.disabled = not has_events or index <= 0
if _next_button:
_next_button.disabled = not has_events or index >= events.size() - 1
if not has_events:
_counter_label.text = Strings.lookup("timeline.empty") _counter_label.text = Strings.lookup("timeline.empty")
_detail_label.text = "" _detail_label.text = ""
_list_label.text = "" _list_label.text = ""
+2
View File
@@ -4,6 +4,7 @@ extends CanvasLayer
const Strings := preload("res://src/core/strings.gd") const Strings := preload("res://src/core/strings.gd")
const InputBindings := preload("res://src/core/input_bindings.gd") const InputBindings := preload("res://src/core/input_bindings.gd")
const UiStyles := preload("res://src/game/ui/ui_styles.gd")
signal closed() signal closed()
@@ -70,6 +71,7 @@ func _build() -> void:
close.text = Strings.lookup("ui.help_close") close.text = Strings.lookup("ui.help_close")
close.pressed.connect(func(): close_panel()) close.pressed.connect(func(): close_panel())
close.size_flags_horizontal = Control.SIZE_SHRINK_CENTER close.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
UiStyles.apply_button(close, 16)
column.add_child(close) column.add_child(close)
func open(policy: String = "") -> void: func open(policy: String = "") -> void:
+2 -13
View File
@@ -10,6 +10,7 @@ signal settings_requested()
signal quit_to_menu_requested() signal quit_to_menu_requested()
const UiArt := preload("res://src/game/ui/art.gd") const UiArt := preload("res://src/game/ui/art.gd")
const UiStyles := preload("res://src/game/ui/ui_styles.gd")
const Strings := preload("res://src/core/strings.gd") const Strings := preload("res://src/core/strings.gd")
var _root: Control = null var _root: Control = null
@@ -67,19 +68,7 @@ func _add_button(parent: Control, text: String, node_name: String, cb: Callable)
button.name = node_name button.name = node_name
button.text = text button.text = text
button.custom_minimum_size = Vector2(380, 52) button.custom_minimum_size = Vector2(380, 52)
button.add_theme_font_size_override("font_size", 22) UiStyles.apply_button(button, 22)
var normal := StyleBoxFlat.new()
normal.bg_color = Color(0.115, 0.125, 0.15, 0.96)
normal.corner_radius_top_left = 12
normal.corner_radius_top_right = 12
normal.corner_radius_bottom_left = 12
normal.corner_radius_bottom_right = 12
var hover := normal.duplicate() as StyleBoxFlat
hover.bg_color = Color(0.16, 0.175, 0.21, 0.98)
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("focus", hover)
button.add_theme_stylebox_override("pressed", hover)
button.pressed.connect(cb) button.pressed.connect(cb)
parent.add_child(button) parent.add_child(button)
return button return button
+3
View File
@@ -10,6 +10,7 @@ signal cancelled()
const Rules := preload("res://src/core/rules.gd") const Rules := preload("res://src/core/rules.gd")
const Strings := preload("res://src/core/strings.gd") const Strings := preload("res://src/core/strings.gd")
const UiArt := preload("res://src/game/ui/art.gd") const UiArt := preload("res://src/game/ui/art.gd")
const UiStyles := preload("res://src/game/ui/ui_styles.gd")
var panel: PanelContainer var panel: PanelContainer
var npc_name_label: Label var npc_name_label: Label
@@ -107,10 +108,12 @@ func _init() -> void:
action_button.button_down.connect(func(): _start_hold()) action_button.button_down.connect(func(): _start_hold())
action_button.button_up.connect(func(): _stop_hold(false)) action_button.button_up.connect(func(): _stop_hold(false))
action_button.pressed.connect(_on_action_pressed) action_button.pressed.connect(_on_action_pressed)
UiStyles.apply_button(action_button, 16)
buttons.add_child(action_button) buttons.add_child(action_button)
var cancel_btn := Button.new() var cancel_btn := Button.new()
cancel_btn.text = Strings.lookup("ui.radio_cancel") cancel_btn.text = Strings.lookup("ui.radio_cancel")
cancel_btn.pressed.connect(func(): cancelled.emit()) cancel_btn.pressed.connect(func(): cancelled.emit())
UiStyles.apply_button(cancel_btn, 16)
buttons.add_child(cancel_btn) buttons.add_child(cancel_btn)
var _title_label: Label var _title_label: Label
+8
View File
@@ -8,6 +8,7 @@ extends CanvasLayer
const InputBindings := preload("res://src/core/input_bindings.gd") const InputBindings := preload("res://src/core/input_bindings.gd")
const UiArt := preload("res://src/game/ui/art.gd") const UiArt := preload("res://src/game/ui/art.gd")
const UiStyles := preload("res://src/game/ui/ui_styles.gd")
const Strings := preload("res://src/core/strings.gd") const Strings := preload("res://src/core/strings.gd")
signal applied(settings: Dictionary) signal applied(settings: Dictionary)
@@ -135,11 +136,13 @@ func _build() -> void:
replay.name = "ReplayTutorialButton" replay.name = "ReplayTutorialButton"
replay.text = Strings.lookup("ui.settings_replay") replay.text = Strings.lookup("ui.settings_replay")
replay.pressed.connect(func(): replay_tutorial_requested.emit()) replay.pressed.connect(func(): replay_tutorial_requested.emit())
UiStyles.apply_button(replay, 16)
replay_row.add_child(replay) replay_row.add_child(replay)
var reset_campaign := Button.new() var reset_campaign := Button.new()
reset_campaign.name = "ResetCampaignButton" reset_campaign.name = "ResetCampaignButton"
reset_campaign.text = Strings.lookup("ui.settings_reset_campaign") reset_campaign.text = Strings.lookup("ui.settings_reset_campaign")
reset_campaign.pressed.connect(_confirm_reset_campaign) reset_campaign.pressed.connect(_confirm_reset_campaign)
UiStyles.apply_button(reset_campaign, 16)
replay_row.add_child(reset_campaign) replay_row.add_child(reset_campaign)
var controls_title := Label.new() var controls_title := Label.new()
@@ -160,6 +163,7 @@ func _build() -> void:
var button := Button.new() var button := Button.new()
button.custom_minimum_size = Vector2(130, 0) button.custom_minimum_size = Vector2(130, 0)
button.pressed.connect(_on_rebind.bind(action)) button.pressed.connect(_on_rebind.bind(action))
UiStyles.apply_button(button, 14, true)
grid.add_child(button) grid.add_child(button)
_bind_buttons[action] = button _bind_buttons[action] = button
@@ -172,12 +176,14 @@ func _build() -> void:
save_button.name = "ApplyButton" save_button.name = "ApplyButton"
save_button.text = Strings.lookup("ui.apply_settings") save_button.text = Strings.lookup("ui.apply_settings")
save_button.pressed.connect(_on_apply) save_button.pressed.connect(_on_apply)
UiStyles.apply_button(save_button, 16)
row.add_child(save_button) row.add_child(save_button)
var cancel_button := Button.new() var cancel_button := Button.new()
cancel_button.name = "CancelButton" cancel_button.name = "CancelButton"
cancel_button.text = Strings.lookup("ui.settings_cancel") cancel_button.text = Strings.lookup("ui.settings_cancel")
cancel_button.pressed.connect(_on_cancel) cancel_button.pressed.connect(_on_cancel)
UiStyles.apply_button(cancel_button, 16)
row.add_child(cancel_button) row.add_child(cancel_button)
func _add_label_row(parent: Control, text: String) -> Label: func _add_label_row(parent: Control, text: String) -> Label:
@@ -273,6 +279,8 @@ func _confirm_reset_campaign() -> void:
dialog.dialog_text = Strings.lookup("ui.settings_reset_confirm") dialog.dialog_text = Strings.lookup("ui.settings_reset_confirm")
dialog.confirmed.connect(func(): reset_campaign_requested.emit()) dialog.confirmed.connect(func(): reset_campaign_requested.emit())
add_child(dialog) add_child(dialog)
UiStyles.apply_button(dialog.get_ok_button(), 16)
UiStyles.apply_button(dialog.get_cancel_button(), 16)
dialog.popup_centered() dialog.popup_centered()
dialog.confirmed.connect(dialog.queue_free) dialog.confirmed.connect(dialog.queue_free)
+2 -13
View File
@@ -4,6 +4,7 @@ extends CanvasLayer
const Strings := preload("res://src/core/strings.gd") const Strings := preload("res://src/core/strings.gd")
const UiArt := preload("res://src/game/ui/art.gd") const UiArt := preload("res://src/game/ui/art.gd")
const UiStyles := preload("res://src/game/ui/ui_styles.gd")
signal restart_requested() signal restart_requested()
signal continue_requested() signal continue_requested()
@@ -85,19 +86,7 @@ func _init() -> void:
func _style_review_button(button: Button) -> void: func _style_review_button(button: Button) -> void:
button.custom_minimum_size = Vector2(300, 52) button.custom_minimum_size = Vector2(300, 52)
button.add_theme_font_size_override("font_size", 22) UiStyles.apply_button(button, 22)
var normal := StyleBoxFlat.new()
normal.bg_color = Color(0.115, 0.125, 0.15, 0.96)
normal.corner_radius_top_left = 10
normal.corner_radius_top_right = 10
normal.corner_radius_bottom_left = 10
normal.corner_radius_bottom_right = 10
var hover := normal.duplicate() as StyleBoxFlat
hover.bg_color = Color(0.16, 0.175, 0.21, 0.98)
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("focus", hover)
button.add_theme_stylebox_override("pressed", hover)
func show_review(review: Dictionary, scenario: Resource, history: Array = [], func show_review(review: Dictionary, scenario: Resource, history: Array = [],
campaign_summary: Dictionary = {}) -> void: campaign_summary: Dictionary = {}) -> void:
+6 -31
View File
@@ -11,6 +11,7 @@ signal settings_requested()
signal quit_requested() signal quit_requested()
const UiArt := preload("res://src/game/ui/art.gd") const UiArt := preload("res://src/game/ui/art.gd")
const UiStyles := preload("res://src/game/ui/ui_styles.gd")
const Strings := preload("res://src/core/strings.gd") const Strings := preload("res://src/core/strings.gd")
const VENUES := [ const VENUES := [
@@ -108,7 +109,7 @@ func _build() -> void:
_continue_button.name = "ContinueButton" _continue_button.name = "ContinueButton"
_continue_button.custom_minimum_size = Vector2(460, 52) _continue_button.custom_minimum_size = Vector2(460, 52)
_continue_button.pressed.connect(_on_continue_pressed) _continue_button.pressed.connect(_on_continue_pressed)
_style_venue_button(_continue_button) UiStyles.apply_button(_continue_button, 18)
column.add_child(_continue_button) column.add_child(_continue_button)
for i in VENUES.size(): for i in VENUES.size():
@@ -119,7 +120,7 @@ func _build() -> void:
button.alignment = HORIZONTAL_ALIGNMENT_LEFT button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.custom_minimum_size = Vector2(460, 62) button.custom_minimum_size = Vector2(460, 62)
button.pressed.connect(_on_venue_pressed.bind(i)) button.pressed.connect(_on_venue_pressed.bind(i))
_style_venue_button(button) UiStyles.apply_button(button, 18)
column.add_child(button) column.add_child(button)
_buttons.append(button) _buttons.append(button)
@@ -136,18 +137,21 @@ func _build() -> void:
help_button.name = "HelpButton" help_button.name = "HelpButton"
help_button.text = Strings.lookup("ui.start_help") help_button.text = Strings.lookup("ui.start_help")
help_button.pressed.connect(func(): help_requested.emit()) help_button.pressed.connect(func(): help_requested.emit())
UiStyles.apply_button(help_button, 16)
row.add_child(help_button) row.add_child(help_button)
var settings_button := Button.new() var settings_button := Button.new()
settings_button.name = "SettingsButton" settings_button.name = "SettingsButton"
settings_button.text = Strings.lookup("ui.start_settings") settings_button.text = Strings.lookup("ui.start_settings")
settings_button.pressed.connect(func(): settings_requested.emit()) settings_button.pressed.connect(func(): settings_requested.emit())
UiStyles.apply_button(settings_button, 16)
row.add_child(settings_button) row.add_child(settings_button)
var quit_button := Button.new() var quit_button := Button.new()
quit_button.name = "QuitButton" quit_button.name = "QuitButton"
quit_button.text = Strings.lookup("ui.quit_game") quit_button.text = Strings.lookup("ui.quit_game")
quit_button.pressed.connect(func(): quit_requested.emit()) quit_button.pressed.connect(func(): quit_requested.emit())
UiStyles.apply_button(quit_button, 16)
row.add_child(quit_button) row.add_child(quit_button)
var hint := Label.new() var hint := Label.new()
@@ -158,35 +162,6 @@ func _build() -> void:
_focus_index(0) _focus_index(0)
## Match the dark, rounded rows in the `start_menu` mockup.
func _style_venue_button(button: Button) -> void:
button.add_theme_font_size_override("font_size", 18)
button.add_theme_color_override("font_color", Color(0.94, 0.94, 0.92))
button.add_theme_color_override("font_hover_color", Color.WHITE)
var normal := StyleBoxFlat.new()
normal.bg_color = Color(0.115, 0.125, 0.15, 0.96)
normal.corner_radius_top_left = 12
normal.corner_radius_top_right = 12
normal.corner_radius_bottom_left = 12
normal.corner_radius_bottom_right = 12
normal.content_margin_left = 22
normal.content_margin_right = 22
normal.content_margin_top = 10
normal.content_margin_bottom = 10
var hover := normal.duplicate() as StyleBoxFlat
hover.bg_color = Color(0.16, 0.175, 0.21, 0.98)
var focus := normal.duplicate() as StyleBoxFlat
focus.bg_color = Color(0.14, 0.155, 0.19, 0.98)
focus.border_color = Color(0.85, 0.86, 0.88)
focus.border_width_left = 2
focus.border_width_top = 2
focus.border_width_right = 2
focus.border_width_bottom = 2
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("focus", focus)
button.add_theme_stylebox_override("pressed", hover)
func _focus_index(i: int) -> void: func _focus_index(i: int) -> void:
if _buttons.is_empty(): if _buttons.is_empty():
return return
+58
View File
@@ -0,0 +1,58 @@
class_name UiStyles
extends RefCounted
## Shared button states for the dark session/UI shell.
##
## Keeping one style table means every normal/hover/pressed/focused/disabled
## state is explicit. Buttons that use the Godot default theme otherwise look
## different from the custom start-menu, pause and review rows as soon as they
## are disabled or receive keyboard focus.
const TEXT := Color(0.94, 0.94, 0.92)
const TEXT_HOVER := Color.WHITE
const TEXT_DISABLED := Color(0.48, 0.50, 0.56)
const SURFACE := Color(0.115, 0.125, 0.15, 0.96)
const SURFACE_HOVER := Color(0.16, 0.175, 0.21, 0.98)
const SURFACE_PRESSED := Color(0.075, 0.085, 0.105, 0.98)
const SURFACE_DISABLED := Color(0.075, 0.08, 0.095, 0.72)
const ACCENT := Color(0.78, 0.82, 0.88, 0.9)
static func apply_button(button: Button, font_size: int = 0, compact: bool = false) -> void:
if button == null:
return
if font_size > 0:
button.add_theme_font_size_override("font_size", font_size)
var margin_h := 12 if compact else 18
var margin_v := 7 if compact else 10
var normal := _box(SURFACE, margin_h, margin_v)
var hover := _box(SURFACE_HOVER, margin_h, margin_v)
var pressed := _box(SURFACE_PRESSED, margin_h, margin_v)
var disabled := _box(SURFACE_DISABLED, margin_h, margin_v)
var focus := _box(Color(0, 0, 0, 0), margin_h, margin_v)
focus.border_color = ACCENT
focus.border_width_left = 2
focus.border_width_top = 2
focus.border_width_right = 2
focus.border_width_bottom = 2
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("pressed", pressed)
button.add_theme_stylebox_override("focus", focus)
button.add_theme_stylebox_override("disabled", disabled)
button.add_theme_color_override("font_color", TEXT)
button.add_theme_color_override("font_hover_color", TEXT_HOVER)
button.add_theme_color_override("font_pressed_color", TEXT_HOVER)
button.add_theme_color_override("font_focus_color", TEXT_HOVER)
button.add_theme_color_override("font_disabled_color", TEXT_DISABLED)
static func _box(color: Color, margin_h: int, margin_v: int) -> StyleBoxFlat:
var box := StyleBoxFlat.new()
box.bg_color = color
box.corner_radius_top_left = 10
box.corner_radius_top_right = 10
box.corner_radius_bottom_left = 10
box.corner_radius_bottom_right = 10
box.content_margin_left = margin_h
box.content_margin_right = margin_h
box.content_margin_top = margin_v
box.content_margin_bottom = margin_v
return box
+8 -1
View File
@@ -79,14 +79,21 @@ func test_empty_timeline_is_safe() -> bool:
func test_stepping_clamps() -> bool: func test_stepping_clamps() -> bool:
var timeline: Node = EventTimeline.new() var timeline: Node = EventTimeline.new()
timeline.show_timeline(_review()) timeline.show_timeline(_review())
var prev: Button = timeline.find_child("PrevButton", true, false) as Button
var next: Button = timeline.find_child("NextButton", true, false) as Button
var start_prev_disabled := prev != null and prev.disabled
var start_next_enabled := next != null and not next.disabled
timeline.step(-1) timeline.step(-1)
var a := TestUtils.check_eq(timeline.index, 0, "cannot step before the first event") var a := TestUtils.check_eq(timeline.index, 0, "cannot step before the first event")
timeline.step(1) timeline.step(1)
var b := TestUtils.check_eq(timeline.index, 1, "next advances one event") var b := TestUtils.check_eq(timeline.index, 1, "next advances one event")
timeline.step(99) timeline.step(99)
var c := TestUtils.check_eq(timeline.index, 3, "cannot step past the last event") var c := TestUtils.check_eq(timeline.index, 3, "cannot step past the last event")
var end_next_disabled := next != null and next.disabled
var d := TestUtils.check(start_prev_disabled and start_next_enabled and end_next_disabled,
"timeline boundary buttons disable at the ends")
timeline.free() timeline.free()
return a and b and c return a and b and c and d
func test_review_records_a_shift_start_stamp() -> bool: func test_review_records_a_shift_start_stamp() -> bool:
var coordinator: Object = Coordinator.new() var coordinator: Object = Coordinator.new()
+26
View File
@@ -0,0 +1,26 @@
extends RefCounted
## Shared UI button states: every normal/hover/pressed/focus/disabled state is
## explicitly styled so the custom dark panels do not fall back to the engine
## theme when a control is focused or disabled.
const UiStyles := preload("res://src/game/ui/ui_styles.gd")
func test_shared_button_states_are_complete() -> bool:
var button := Button.new()
UiStyles.apply_button(button, 16)
var states := ["normal", "hover", "pressed", "focus", "disabled"]
var missing: Array[String] = []
for state in states:
if not button.has_theme_stylebox_override(state):
missing.append(state)
var a := TestUtils.check(missing.is_empty(),
"all five button states have explicit styles (missing: %s)" % str(missing))
var b := TestUtils.check_eq(button.get_theme_font_size("font_size"), 16,
"the shared style applies the requested font size")
var disabled := button.get_theme_stylebox("disabled")
var normal := button.get_theme_stylebox("normal")
var c := TestUtils.check(disabled != null and normal != null
and disabled.bg_color != normal.bg_color,
"the disabled state is visually distinct from normal")
button.free()
return a and b and c