Add accessibility pass: escalation meter and working HUD font scale
Full build / Import + headless tests (push) Successful in 33s
Full build / Full desktop build + optional release (push) Failing after 3m31s

- Escalation cue now includes a shape meter and numeric level
  ("Escalation: Blocking [###--] L3/5") in addition to the colour and name, so it
  reads in grayscale. Hud.escalation_meter(level) is pure and tested.
- HUD font scale from settings actually works: set_font_scale() resizes fonts via
  theme overrides instead of Control.scale, which pushed the right-anchored clock
  label off-screen. It now covers the top, log, target, hint, time, mode,
  escalation, cooldown, and debug labels.
- Settings load before the HUD exists, so _ready() re-applies player settings after
  _build_ui(); previously a saved font scale was ignored until the next apply.

Tests/docs
- Headless suite 134 -> 139 (test_accessibility.gd).
- Verified visually at 1.4x scale: no overflow, meter readable.
- README, TODO section moved to DONE, MEMORY. Version 0.2.9.
This commit is contained in:
zwitschi committed 2026-10-31 21:17:42 +01:00
1 parent 294ac17333
commit 3f4ac7e9aa
15 files changed
+115 -21

No files matched your search

+8
View File
@@ -516,3 +516,11 @@ Completed 2026-10-31 (working tree). Headless **134/134**; `src/test/smoke_audio
- [x] Loop a low crowd bed that rises with `CrowdSystem.mood`.
- [x] Radio open/close crackle on the existing radio panel, still headless-safe (no-op without
an audio server).
## Accessibility
Completed 2026-10-31 (working tree). Headless **139/139**; verified visually at 1.4x font scale with the shape meter showing.
- [x] Escalation colors that still read in grayscale (shape or label, not hue alone).
- [x] HUD font scale in settings.
+7 -2
View File
@@ -21,8 +21,8 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
Windowed control smoke: `godot --path . --script res://src/test/smoke_focus_radial.gd` → exit 0.
- `main` is in sync with origin. **v0.2.2** tags the session-shell + world-follow-up commit;
CI builds the portable binaries, NSIS installer, and AppImage on the tag push.
- Next work: `TODO.md` accessibility (grayscale-safe escalation cues, HUD font scale in settings),
then scenario content gaps.
- Next work: `TODO.md` scenario content gaps (Joke Shop / Studio / Building events), then the
remaining polish and tooling items.
- Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg.
## Current State
@@ -95,6 +95,11 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
`_begin_incident()` now starts one per focused NPC (`has_open_incident()` guard), verbs call
`_ensure_incident()`, and `ShiftCoordinator._finish_shift()` closes leftovers as `unresolved`
before building the review.
- **Accessibility (2026-10-31):** escalation text is `"<name> [###--] L3/5"` via
`Hud.escalation_meter(level)` (pure, tested) so the cue survives grayscale. HUD font scale uses
`add_theme_font_size_override` on the core labels (`set_font_scale`), **not** `Control.scale`,
which pushed the right-anchored clock label off-screen. Settings load before `_build_ui()`, so
`_ready()` re-applies player settings after the HUD exists.
- **Audio (2026-10-31):** `AudioFeedback.start_crowd_bed()` builds a 4 s looping `AudioStreamWAV`
(one-pole-filtered noise + 68/103 Hz drone, seeded) and `set_crowd_level(mood)` maps 0..1 to
-34..-12 dB. `play_radio_open/close` push short filtered-noise bursts. All procedural; with a
+7 -3
View File
@@ -28,7 +28,8 @@ The central design goal is simple:
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
> **Latest: v0.2.8** — procedural crowd ambience driven by crowd mood, and radio crackle.
> **Latest: v0.2.9** — accessibility pass: grayscale-safe escalation meter and a working HUD font
> scale.
> Releases are built automatically when a `vX.Y.Z` tag is pushed
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
@@ -307,7 +308,7 @@ Implemented so far:
- Godot 4.3 project scaffold, input map, procedural world builder
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
- Headless unit tests: **134 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, 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)
- Headless unit tests: **139 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, 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)
- 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 gold ring 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`)
@@ -323,6 +324,7 @@ Implemented so far:
- Body-camera timeline: the shift review opens a text timeline of every recorded incident event (time, person, action/outcome) that you step through with the arrow keys or buttons. No live 3D replay yet
- NPC theater: seated guests sit on the steps with bent legs, influencers and tourists raise a camera prop while photographing, performers sway their sign, and filming visitors pan slowly — all once they reach their intent's waypoint
- Audio: a looping procedural crowd bed (filtered murmur) whose volume follows `CrowdSystem.mood`, plus radio open/close crackle. All generated at runtime, no audio assets, and safe with a dummy audio server
- Accessibility: the escalation cue shows a shape meter plus the numeric level (`Escalation: Blocking [###--] L3/5`) so it reads in grayscale, and the HUD font scale from settings is applied at boot by resizing fonts (not Control.scale, which broke the right-aligned layout)
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
@@ -360,7 +362,9 @@ Not yet implemented: live 3D body-camera replay, editor-based GUT integration, a
28. An unjustified emergency call (calm person, no weapon) logs an investigation and costs reputation.
29. After an evacuation outcome the door is locked and NPCs are leaving the entrance.
30. The shift review's "Body camera timeline" button steps through the recorded events (Left/Right or the buttons) and Esc returns to the review.
31. When the shift timer reaches 0, the shift review screen shows grade, incident summary, responder outcomes, investigations, and persisted shift history; "Run another shift" returns to the venue menu.
31. The escalation label shows a `[###--] L3/5` shape meter as well as the level name, and stays readable in grayscale.
32. Changing "HUD font scale" in Settings resizes the HUD text and persists across a restart.
33. When the shift timer reaches 0, the shift review screen shows grade, incident summary, responder outcomes, investigations, and persisted shift history; "Run another shift" returns to the venue menu.
## Development Workflow
+1 -6
View File
@@ -1,15 +1,10 @@
# TODO
Godot 4.3 prototype. Release **v0.2.8** is tagged. Headless suite **134/134**, plus the session,
Godot 4.3 prototype. Release **v0.2.9** is tagged. Headless suite **139/139**, plus the session,
PLAN-verb, crowd, emergency, timeline, audio, and windowed focus/radial smokes.
Host `REQUIRE_SIGNIN_VIEW` stays deferred.
## Accessibility
- [ ] Escalation colors that still read in grayscale (shape or label, not hue alone).
- [ ] HUD font scale in settings.
## Scenario content still missing vs PLAN lists
- [ ] Joke Shop: guest on the reservation list without ID; borrowed/mismatched ID; guest
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 1004 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 954 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 910 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 971 KiB

+1 -1
View File
@@ -8,7 +8,7 @@ config_version=5
config/name="Step Line"
config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
config/version="0.2.8"
config/version="0.2.9"
run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.3", "GL Compatibility")
+3
View File
@@ -141,6 +141,9 @@ func _ready() -> void:
_connect_crowd()
_build_ui()
# The HUD does not exist yet when settings are loaded, so push them again now
# that it does (otherwise the saved font scale is ignored until the next apply).
_apply_settings(player_settings)
_last_phase = -1
_apply_phase_lighting()
# Boot into the venue menu; the shift starts when the player picks a venue.
+24 -9
View File
@@ -49,6 +49,8 @@ var _debug_enabled := false
var _log_lines: Array[String] = []
var _cam_overlay: Control = null
var _body_camera_on := false
var _font_scale := 1.0
var _base_font_sizes: Dictionary = {}
func _init() -> void:
name = "HUD"
@@ -300,16 +302,20 @@ func is_body_camera_on() -> bool:
return _body_camera_on
## Scale the whole HUD text block (accessibility settings).
## Resize HUD text by changing font sizes (not Control.scale, which overflows
## anchored labels and breaks the right-aligned layout).
func set_font_scale(scale: float) -> void:
var clamped := clampf(scale, 0.75, 1.75)
if top_label:
top_label.scale = Vector2(clamped, clamped)
if log_label:
log_label.scale = Vector2(clamped, clamped)
if target_label:
target_label.scale = Vector2(clamped, clamped)
if hint_label:
hint_label.scale = Vector2(clamped, clamped)
_font_scale = clamped
var scaled: Array[Label] = [top_label, log_label, target_label, hint_label,
time_label, mode_label, escalation_label, radio_cooldown_label, debug_label]
for label in scaled:
if label == null:
continue
if not _base_font_sizes.has(label):
_base_font_sizes[label] = label.get_theme_font_size("font_size")
var base: int = int(_base_font_sizes[label])
label.add_theme_font_size_override("font_size", maxi(8, int(round(float(base) * clamped))))
func _make_icon(texture: Texture2D) -> TextureRect:
var tr := TextureRect.new()
@@ -353,9 +359,18 @@ func set_action_hint(text: String) -> void:
## Color-coded escalation feedback (PLAN.md: Polish and Accessibility).
## Green → yellow → orange → red as the escalation level rises.
## Grayscale-safe escalation meter: filled/empty blocks plus the level number, so
## the cue does not rely on hue alone (accessibility).
static func escalation_meter(level: int) -> String:
var clamped := clampi(level, 0, 5)
var filled := ""
for i in 5:
filled += "#" if i < clamped else "-"
return "[%s] L%d/5" % [filled, clamped]
func set_escalation(level: int) -> void:
var name: String = Rules.LEVEL_NAMES.get(level, "Observation")
escalation_label.text = Strings.fmt("hud.escalation_prefix", [name])
escalation_label.text = "%s %s" % [Strings.fmt("hud.escalation_prefix", [name]), escalation_meter(level)]
var tint := Color(0.6, 0.9, 0.6)
match level:
Rules.Level.OBSERVATION, Rules.Level.SIMPLE_DETERRENCE:
+64
View File
@@ -0,0 +1,64 @@
extends RefCounted
## Accessibility: escalation cues that survive grayscale (shape + level, not hue
## alone) and the HUD font scale from settings.
const Hud := preload("res://src/game/ui/hud.gd")
const Rules := preload("res://src/core/rules.gd")
func test_meter_shape_and_level() -> bool:
var a := TestUtils.check_eq(Hud.escalation_meter(0), "[-----] L0/5", "calm shows an empty meter")
var b := TestUtils.check_eq(Hud.escalation_meter(3), "[###--] L3/5", "three filled blocks at blocking")
var c := TestUtils.check_eq(Hud.escalation_meter(5), "[#####] L5/5", "emergency fills the meter")
var d := TestUtils.check_eq(Hud.escalation_meter(99), "[#####] L5/5", "levels clamp at 5")
var e := TestUtils.check_eq(Hud.escalation_meter(-2), "[-----] L0/5", "levels clamp at 0")
return a and b and c and d and e
func test_meter_is_not_hue_only() -> bool:
## Every level must differ in shape/text, independent of colour.
var seen: Array[String] = []
for level in 6:
var meter: String = Hud.escalation_meter(level)
if seen.has(meter):
return TestUtils.fail("levels %d and %d share a meter" % [level, seen.find(meter)])
seen.append(meter)
return TestUtils.check(seen.size() == 6, "all six levels have distinct meters")
func test_escalation_label_includes_meter_and_name() -> bool:
var hud: Node = Hud.new()
hud.set_escalation(Rules.Level.FORMAL_WARNING)
var text: String = hud.escalation_label.text
var a := TestUtils.check(text.contains("Formal Warning"), "label names the level")
var b := TestUtils.check(text.contains("[##---]"), "label shows the shape meter")
var c := TestUtils.check(text.contains("L2/5"), "label shows the numeric level")
hud.free()
return a and b and c
func test_font_scale_applies_to_core_labels() -> bool:
var hud: Node = Hud.new()
var base_top: int = hud.top_label.get_theme_font_size("font_size")
var base_log: int = hud.log_label.get_theme_font_size("font_size")
var base_esc: int = hud.escalation_label.get_theme_font_size("font_size")
var base_mode: int = hud.mode_label.get_theme_font_size("font_size")
hud.set_font_scale(1.4)
var a := TestUtils.check(hud.top_label.get_theme_font_size("font_size") > base_top, "top label grows")
var b := TestUtils.check(hud.log_label.get_theme_font_size("font_size") > base_log, "log grows")
var c := TestUtils.check(hud.escalation_label.get_theme_font_size("font_size") > base_esc, "escalation label grows")
var d := TestUtils.check(hud.mode_label.get_theme_font_size("font_size") > base_mode, "mode label grows")
# Scaling is by font size, not Control.scale, so anchored labels keep layout.
var e := TestUtils.check(is_equal_approx(hud.top_label.scale.x, 1.0), "labels are not Control-scaled")
hud.set_font_scale(9.0)
var capped: int = hud.top_label.get_theme_font_size("font_size")
var f := TestUtils.check(capped <= int(round(float(base_top) * 1.75)) + 1, "scale clamps at 1.75")
hud.free()
return a and b and c and d and e and f
func test_settings_font_scale_is_applied_after_ui_exists() -> bool:
## The HUD is created after settings load, so the manager must re-apply.
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var a := TestUtils.check(src.contains("_apply_settings(player_settings)"), "settings are applied on boot")
var build_idx := src.find("_build_ui()")
var apply_idx := src.find("_apply_settings(player_settings)", build_idx)
var b := TestUtils.check(apply_idx > build_idx, "the second apply happens after the HUD is built")
var c := TestUtils.check(src.contains("hud.set_font_scale(float(settings.get("),
"settings drive the HUD font scale")
return a and b and c