Compare commits

...
2 Commits
Author SHA1 Message Date
zwitschi 3f4ac7e9aa 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.
2026-10-31 21:17:42 +01:00
zwitschi 294ac17333 Add procedural crowd ambience and radio crackle
Full build / Import + headless tests (push) Successful in 45s
Full build / Full desktop build + optional release (push) Failing after 3m53s
- AudioFeedback.start_crowd_bed() builds a 4 s looping AudioStreamWAV at runtime
  (one-pole filtered noise plus a 68/103 Hz drone, seeded) and set_crowd_level()
  maps crowd mood 0..1 to -34..-12 dB, so the street gets louder as it gets
  agitated or panics. The bed starts with the shift and stops at shift end.
- play_radio_open()/play_radio_close() push short filtered-noise bursts; wired to
  opening the radio panel, submitting a report, cancelling, and closing the panel.
- Still asset-free and headless-safe: with a dummy audio server the calls no-op.
  _free_player() returns null before _ready and callers handle it.

Tests/docs
- Headless suite 127 -> 134 (test_audio_feedback.gd).
- New smoke_audio.gd: bed playing, level follows mood events, radio crackles once
  per open/close, bed stops at shift end. Verified windowed (real audio server).
- README, TODO section moved to DONE, MEMORY. Version 0.2.8.
2026-10-31 21:08:36 +01:00
22 changed files with 414 additions and 32 deletions

No files matched your search

+19
View File
@@ -505,3 +505,22 @@ is in the working tree. Bodies still idle-stand at those spots.
- [x] Sit pose at `steps_end`. Photo: brief raise of the influencer/tourist prop. Performer - [x] Sit pose at `steps_end`. Photo: brief raise of the influencer/tourist prop. Performer
stays put and plays the hat/sign. stays put and plays the hat/sign.
## Audio
Completed 2026-10-31 (working tree). Headless **134/134**; `src/test/smoke_audio.gd` checks the bed follows mood and the radio crackles, headless and windowed (real audio server).
`AudioFeedback` is sine beeps. DONE called that "crowd ambience"; it is not.
- [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.
+12 -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. 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; - `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. CI builds the portable binaries, NSIS installer, and AppImage on the tag push.
- Next work: `TODO.md` audio (crowd bed rising with mood, radio crackle), then accessibility and - Next work: `TODO.md` scenario content gaps (Joke Shop / Studio / Building events), then the
scenario content. remaining polish and tooling items.
- Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg. - Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg.
## Current State ## Current State
@@ -95,6 +95,16 @@ 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 `_begin_incident()` now starts one per focused NPC (`has_open_incident()` guard), verbs call
`_ensure_incident()`, and `ShiftCoordinator._finish_shift()` closes leftovers as `unresolved` `_ensure_incident()`, and `ShiftCoordinator._finish_shift()` closes leftovers as `unresolved`
before building the review. 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
dummy (headless) audio server the calls are no-ops. `_free_player()` returns null when `_ready`
has not run (tests), and callers handle null.
- **NPC theater (2026-10-31):** `NpcAvatar.theater_pose()` maps `intent` + `arrived` to - **NPC theater (2026-10-31):** `NpcAvatar.theater_pose()` maps `intent` + `arrived` to
idle/sit/photo/film/perform. Pure helpers `crouch_for_pose`, `prop_lift_for_pose`, idle/sit/photo/film/perform. Pure helpers `crouch_for_pose`, `prop_lift_for_pose`,
`sway_for_pose` keep the math testable. Sit lowers the rig by 0.34 and swings the legs forward `sway_for_pose` keep the math testable. Sit lowers the rig by 0.34 and swings the legs forward
+12 -5
View File
@@ -28,8 +28,8 @@ The central design goal is simple:
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases> **All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
> **Latest: v0.2.7** — NPC theater: guests sit on the steps, tourists and influencers raise a > **Latest: v0.2.9** — accessibility pass: grayscale-safe escalation meter and a working HUD font
> camera, performers play their sign. > scale.
> Releases are built automatically when a `vX.Y.Z` tag is pushed > Releases are built automatically when a `vX.Y.Z` tag is pushed
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use > (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline > `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
@@ -253,6 +253,9 @@ godot --headless --path . --script res://src/test/smoke_emergency.gd
# Body-camera timeline smoke: review → timeline → step through → back to review # Body-camera timeline smoke: review → timeline → step through → back to review
godot --headless --path . --script res://src/test/smoke_timeline.gd godot --headless --path . --script res://src/test/smoke_timeline.gd
# Audio smoke: crowd bed follows mood, radio open/close crackle
godot --headless --path . --script res://src/test/smoke_audio.gd
``` ```
The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor. The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor.
@@ -305,7 +308,7 @@ Implemented so far:
- Godot 4.3 project scaffold, input map, procedural world builder - 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` - 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 - Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
- Headless unit tests: **127 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) - 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 - 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 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`) - 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`)
@@ -320,10 +323,12 @@ Implemented so far:
- Emergency aftermath: the `evacuation` outcome locks the door and clears the entrance, an emergency call that the situation did not justify opens an investigation and applies the false-report hit, and the shift review names the responder outcome and any investigations - Emergency aftermath: the `evacuation` outcome locks the door and clears the entrance, an emergency call that the situation did not justify opens an investigation and applies the false-report hit, and the shift review names the responder outcome and any investigations
- 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 - 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 - 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 - 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) - 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)
Not yet implemented: live 3D body-camera replay, ambience audio, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`). Not yet implemented: live 3D body-camera replay, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
### Smoke Test Checklist ### Smoke Test Checklist
@@ -357,7 +362,9 @@ Not yet implemented: live 3D body-camera replay, ambience audio, editor-based GU
28. An unjustified emergency call (calm person, no weapon) logs an investigation and costs reputation. 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. 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. 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 ## Development Workflow
+2 -15
View File
@@ -1,23 +1,10 @@
# TODO # TODO
Godot 4.3 prototype. Release **v0.2.7** is tagged. Headless suite **127/127**, 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, and windowed focus/radial smokes. PLAN-verb, crowd, emergency, timeline, audio, and windowed focus/radial smokes.
Host `REQUIRE_SIGNIN_VIEW` stays deferred. Host `REQUIRE_SIGNIN_VIEW` stays deferred.
## Audio
`AudioFeedback` is sine beeps. DONE called that "crowd ambience"; it is not.
- [ ] Loop a low crowd bed that rises with `CrowdSystem.mood`.
- [ ] Radio open/close crackle on the existing radio panel, still headless-safe (no-op without
an audio server).
## 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 ## Scenario content still missing vs PLAN lists
- [ ] Joke Shop: guest on the reservation list without ID; borrowed/mismatched ID; guest - [ ] Joke Shop: guest on the reservation list without ID; borrowed/mismatched ID; guest
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

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: 34 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: 44 KiB

After

Width:  |  Height:  |  Size: 1000 KiB

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

+1 -1
View File
@@ -8,7 +8,7 @@ config_version=5
config/name="Step Line" config/name="Step Line"
config/description="Workplace security simulation about observation, access verification, and proportionate escalation." config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
config/version="0.2.7" config/version="0.2.9"
run/main_scene="res://scenes/main.tscn" run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.3", "GL Compatibility") config/features=PackedStringArray("4.3", "GL Compatibility")
+99
View File
@@ -8,6 +8,11 @@ extends Node
const SAMPLE_RATE := 22050.0 const SAMPLE_RATE := 22050.0
var _players: Array[AudioStreamPlayer] = [] var _players: Array[AudioStreamPlayer] = []
var _bed_player: AudioStreamPlayer = null
## Observable state so headless tests and the HUD can see what was requested.
var crowd_level: float = 0.0
var bed_playing := false
var radio_crackles := 0
func _ready() -> void: func _ready() -> void:
# Pre-create a few players so overlapping cues don't cut each other off. # Pre-create a few players so overlapping cues don't cut each other off.
@@ -30,6 +35,43 @@ func play_escalation(level: int) -> void:
var freq := 440.0 - float(level) * 40.0 var freq := 440.0 - float(level) * 40.0
_play_tone(maxf(freq, 180.0), 0.18, 0.6) _play_tone(maxf(freq, 180.0), 0.18, 0.6)
## Start the looping crowd bed (low murmur). Called once per shift; safe to call
## again. Volume then follows `set_crowd_level`.
func start_crowd_bed() -> void:
if _bed_player == null:
_bed_player = AudioStreamPlayer.new()
_bed_player.name = "CrowdBed"
add_child(_bed_player)
_bed_player.stream = _build_crowd_stream()
_bed_player.volume_db = _level_to_db(crowd_level)
_bed_player.play()
bed_playing = true
func stop_crowd_bed() -> void:
if _bed_player:
_bed_player.stop()
bed_playing = false
## Crowd mood (0..1) drives the bed volume: quiet when calm, louder when the
## street is agitated or panicking.
func set_crowd_level(mood: float) -> void:
crowd_level = clampf(mood, 0.0, 1.0)
if _bed_player:
_bed_player.volume_db = _level_to_db(crowd_level)
static func _level_to_db(mood: float) -> float:
return lerpf(-34.0, -12.0, clampf(mood, 0.0, 1.0))
## Radio open cue: a short burst of filtered crackle.
func play_radio_open() -> void:
radio_crackles += 1
_play_noise(0.09, 0.35)
## Radio close cue: a lower, shorter click.
func play_radio_close() -> void:
radio_crackles += 1
_play_noise(0.06, 0.25, 1400.0)
## Play a denial/error cue (low buzz). ## Play a denial/error cue (low buzz).
func play_deny() -> void: func play_deny() -> void:
_play_tone(220.0, 0.20, 0.5) _play_tone(220.0, 0.20, 0.5)
@@ -53,7 +95,64 @@ func _play_tone(freq: float, duration: float, volume: float, delay: float = 0.0)
var sample := sin(TAU * freq * t) * 0.6 var sample := sin(TAU * freq * t) * 0.6
playback.push_frame(Vector2(sample, sample)) playback.push_frame(Vector2(sample, sample))
## Deterministic low murmur for the crowd bed: filtered noise plus a low drone.
func _build_crowd_stream() -> AudioStreamWAV:
const SECONDS := 4.0
var frames := int(SAMPLE_RATE * SECONDS)
var data := PackedByteArray()
data.resize(frames * 2)
var rng := RandomNumberGenerator.new()
rng.seed = 20261031
var smooth := 0.0
for i in frames:
var t := float(i) / SAMPLE_RATE
# Low-passed noise (a simple one-pole filter) sounds like distant talking.
smooth = lerpf(smooth, rng.randf_range(-1.0, 1.0), 0.02)
var drone := sin(TAU * 68.0 * t) * 0.25 + sin(TAU * 103.0 * t) * 0.12
var sample := (smooth * 2.2 + drone) * 0.35
var v := int(clampf(sample, -1.0, 1.0) * 32767.0)
data[i * 2] = v & 0xFF
data[i * 2 + 1] = (v >> 8) & 0xFF
var stream := AudioStreamWAV.new()
stream.format = AudioStreamWAV.FORMAT_16_BITS
stream.mix_rate = int(SAMPLE_RATE)
stream.stereo = false
stream.data = data
stream.loop_mode = AudioStreamWAV.LOOP_FORWARD
stream.loop_begin = 0
stream.loop_end = frames
return stream
## Short filtered noise burst used for the radio crackle.
func _play_noise(duration: float, volume: float, cutoff_hz: float = 3200.0) -> void:
var player := _free_player()
if player == null:
return
var frames := int(SAMPLE_RATE * duration)
var data := PackedByteArray()
data.resize(frames * 2)
var rng := RandomNumberGenerator.new()
rng.seed = 7
var smooth := 0.0
var alpha := clampf(cutoff_hz / SAMPLE_RATE, 0.01, 0.9)
for i in frames:
smooth = lerpf(smooth, rng.randf_range(-1.0, 1.0), alpha)
var env := 1.0 - float(i) / float(maxi(frames, 1))
var v := int(clampf(smooth * env, -1.0, 1.0) * 32767.0)
data[i * 2] = v & 0xFF
data[i * 2 + 1] = (v >> 8) & 0xFF
var stream := AudioStreamWAV.new()
stream.format = AudioStreamWAV.FORMAT_16_BITS
stream.mix_rate = int(SAMPLE_RATE)
stream.stereo = false
stream.data = data
player.stream = stream
player.volume_db = linear_to_db(volume)
player.play()
func _free_player() -> AudioStreamPlayer: func _free_player() -> AudioStreamPlayer:
if _players.is_empty():
return null
for player in _players: for player in _players:
if not player.playing: if not player.playing:
return player return player
+18
View File
@@ -141,6 +141,9 @@ func _ready() -> void:
_connect_crowd() _connect_crowd()
_build_ui() _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 _last_phase = -1
_apply_phase_lighting() _apply_phase_lighting()
# Boot into the venue menu; the shift starts when the player picks a venue. # Boot into the venue menu; the shift starts when the player picks a venue.
@@ -233,6 +236,9 @@ func _reset_shift_state() -> void:
## Begin (or resume) the shift clock and recapture the mouse. ## Begin (or resume) the shift clock and recapture the mouse.
func _start_shift() -> void: func _start_shift() -> void:
_shift_running = true _shift_running = true
if audio:
audio.start_crowd_bed()
audio.set_crowd_level(coordinator.crowd.mood)
coordinator.start() coordinator.start()
if hud: if hud:
hud.set_shift_state(coordinator, selected_scenario) hud.set_shift_state(coordinator, selected_scenario)
@@ -322,6 +328,8 @@ func _build_ui() -> void:
radio_panel.submitted.connect(_on_radio_submitted) radio_panel.submitted.connect(_on_radio_submitted)
radio_panel.cancelled.connect(func(): radio_panel.cancelled.connect(func():
radio_panel.hide() radio_panel.hide()
if audio:
audio.play_radio_close()
hud.set_action_hint("") hud.set_action_hint("")
_refresh_freeze()) _refresh_freeze())
shift_review_panel = ShiftReview.new() shift_review_panel = ShiftReview.new()
@@ -566,6 +574,8 @@ func _connect_crowd() -> void:
func _on_crowd_changed(payload: Dictionary) -> void: func _on_crowd_changed(payload: Dictionary) -> void:
var event_type := str(payload.get("event", "")) var event_type := str(payload.get("event", ""))
var panic := bool(payload.get("panic", false)) var panic := bool(payload.get("panic", false))
if audio:
audio.set_crowd_level(float(payload.get("mood", 0.0)))
# Panic wins: people clear away from the entrance. # Panic wins: people clear away from the entrance.
if panic or event_type == "crowd_panic": if panic or event_type == "crowd_panic":
_flee_crowd() _flee_crowd()
@@ -609,6 +619,8 @@ func _responder_profile() -> NpcProfile:
func _on_shift_ended(review: Dictionary) -> void: func _on_shift_ended(review: Dictionary) -> void:
_shift_running = false _shift_running = false
if audio:
audio.stop_crowd_bed()
# Persist the finished shift locally (Save autoload, user:// JSON). # Persist the finished shift locally (Save autoload, user:// JSON).
if Save: if Save:
Save.save_shift_result(review) Save.save_shift_result(review)
@@ -801,6 +813,8 @@ func _close_top_modal() -> bool:
return true return true
if radio_panel and radio_panel.visible: if radio_panel and radio_panel.visible:
radio_panel.hide() radio_panel.hide()
if audio:
audio.play_radio_close()
hud.set_action_hint("") hud.set_action_hint("")
_refresh_freeze() _refresh_freeze()
return true return true
@@ -1126,9 +1140,13 @@ func _open_radio(emergency: bool) -> void:
return return
radio_panel.open(controller, emergency, current_npc_truth) radio_panel.open(controller, emergency, current_npc_truth)
hud.set_action_hint("Fill in the radio report on screen.") hud.set_action_hint("Fill in the radio report on screen.")
if audio:
audio.play_radio_open()
_refresh_freeze() _refresh_freeze()
func _on_radio_submitted(report: Dictionary, emergency: bool) -> void: func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
if audio:
audio.play_radio_close()
hud.set_action_hint("") hud.set_action_hint("")
_refresh_freeze() _refresh_freeze()
var result: Dictionary = coordinator.radio.request_emergency(report) if emergency else coordinator.radio.request_assistance(report) var result: Dictionary = coordinator.radio.request_emergency(report) if emergency else coordinator.radio.request_assistance(report)
+24 -9
View File
@@ -49,6 +49,8 @@ var _debug_enabled := false
var _log_lines: Array[String] = [] var _log_lines: Array[String] = []
var _cam_overlay: Control = null var _cam_overlay: Control = null
var _body_camera_on := false var _body_camera_on := false
var _font_scale := 1.0
var _base_font_sizes: Dictionary = {}
func _init() -> void: func _init() -> void:
name = "HUD" name = "HUD"
@@ -300,16 +302,20 @@ func is_body_camera_on() -> bool:
return _body_camera_on return _body_camera_on
## Scale the whole HUD text block (accessibility settings). ## 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: func set_font_scale(scale: float) -> void:
var clamped := clampf(scale, 0.75, 1.75) var clamped := clampf(scale, 0.75, 1.75)
if top_label: _font_scale = clamped
top_label.scale = Vector2(clamped, clamped) var scaled: Array[Label] = [top_label, log_label, target_label, hint_label,
if log_label: time_label, mode_label, escalation_label, radio_cooldown_label, debug_label]
log_label.scale = Vector2(clamped, clamped) for label in scaled:
if target_label: if label == null:
target_label.scale = Vector2(clamped, clamped) continue
if hint_label: if not _base_font_sizes.has(label):
hint_label.scale = Vector2(clamped, clamped) _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: func _make_icon(texture: Texture2D) -> TextureRect:
var tr := TextureRect.new() var tr := TextureRect.new()
@@ -353,9 +359,18 @@ func set_action_hint(text: String) -> void:
## Color-coded escalation feedback (PLAN.md: Polish and Accessibility). ## Color-coded escalation feedback (PLAN.md: Polish and Accessibility).
## Green → yellow → orange → red as the escalation level rises. ## 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: func set_escalation(level: int) -> void:
var name: String = Rules.LEVEL_NAMES.get(level, "Observation") 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) var tint := Color(0.6, 0.9, 0.6)
match level: match level:
Rules.Level.OBSERVATION, Rules.Level.SIMPLE_DETERRENCE: Rules.Level.OBSERVATION, Rules.Level.SIMPLE_DETERRENCE:
+85
View File
@@ -0,0 +1,85 @@
extends SceneTree
## Audio smoke: starts a shift, verifies the crowd bed is playing and follows mood
## events, and that opening/closing the radio triggers crackle cues.
##
## Usage:
## godot --headless --path . --script res://src/test/smoke_audio.gd
## (Headless uses a dummy audio server; the calls must still be safe.)
const MAX_FRAMES := 900
var frame := 0
var gm: Node = null
var started := false
var step := 0
var failures: Array[String] = []
func _initialize() -> void:
root.size = Vector2i(1280, 720)
root.add_child(load("res://scenes/main.tscn").instantiate())
func _process(_delta: float) -> bool:
frame += 1
if gm == null:
gm = root.find_child("GameManager", true, false)
return false
if not started:
gm.call("_on_venue_selected", "hollywood_building")
started = true
return false
if frame > MAX_FRAMES:
_fail("timeout")
return true
if frame < 120:
return false
match step:
0:
_check(gm.audio.bed_playing, "the crowd bed is playing during the shift")
var before: float = gm.audio.crowd_level
gm.coordinator.crowd.apply_event("aggressive_behavior")
_check(gm.audio.crowd_level > before, "an aggressive event raises the bed level")
gm.coordinator.crowd.apply_event("emergency_responder")
_check(gm.audio.crowd_level > before, "the responder keeps it up")
step = 1
1:
if gm.npcs.is_empty():
return false
gm.call("_on_guard_target", gm.npcs[0])
var crackles: int = gm.audio.radio_crackles
gm.call("_open_radio", false)
_check(gm.audio.radio_crackles == crackles + 1, "opening the radio crackles once")
gm.call("_on_radio_submitted", {
"npc_id": gm.current_incident_npc_id,
"location": "entrance",
"description": "tourist",
"behavior": "standing",
"weapons_seen": false,
"danger_level": 1,
}, false)
_check(gm.audio.radio_crackles == crackles + 2, "closing the radio crackles once")
gm.coordinator.call("_finish_shift")
_check(not gm.audio.bed_playing, "the bed stops when the shift ends")
step = 2
2:
_finish()
return true
return false
func _check(condition: bool, message: String) -> void:
if condition:
print("ok: " + message)
else:
_fail(message)
func _fail(message: String) -> void:
failures.append(message)
_finish()
func _finish() -> void:
if failures.is_empty():
print("SMOKE OK: crowd bed and radio crackle")
quit(0)
else:
for message in failures:
printerr("SMOKE FAIL: " + message)
quit(1)
+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
+78
View File
@@ -0,0 +1,78 @@
extends RefCounted
## Audio feedback: a looping crowd bed that follows the crowd mood, and radio
## crackle on open/close. All procedural, no audio assets, headless-safe.
const AudioFeedback := preload("res://src/game/audio_feedback.gd")
const CrowdSystem := preload("res://src/core/crowd_system.gd")
func test_bed_volume_follows_mood() -> bool:
var quiet: float = AudioFeedback._level_to_db(0.0)
var loud: float = AudioFeedback._level_to_db(1.0)
var mid: float = AudioFeedback._level_to_db(0.5)
var a := TestUtils.check(quiet < -30.0, "a calm street keeps the bed quiet")
var b := TestUtils.check(loud > quiet, "panic raises the bed")
var c := TestUtils.check(mid > quiet and mid < loud, "the rise is gradual")
return a and b and c
func test_crowd_level_is_clamped() -> bool:
var audio: Node = AudioFeedback.new()
audio.set_crowd_level(-1.0)
var a := TestUtils.check_eq(audio.crowd_level, 0.0, "negative mood clamps to 0")
audio.set_crowd_level(3.0)
var b := TestUtils.check_eq(audio.crowd_level, 1.0, "over-unity mood clamps to 1")
audio.free()
return a and b
func test_crowd_stream_loops() -> bool:
var audio: Node = AudioFeedback.new()
var stream: AudioStreamWAV = audio._build_crowd_stream()
var a := TestUtils.check(stream != null, "a crowd stream is generated")
var b := TestUtils.check_eq(stream.loop_mode, AudioStreamWAV.LOOP_FORWARD, "the bed loops")
var c := TestUtils.check(stream.data.size() > 1000, "the bed has audio data")
var d := TestUtils.check(stream.data.size() == stream.loop_end * 2, "loop covers the whole buffer")
audio.free()
return a and b and c and d
func test_crowd_bed_is_headless_safe() -> bool:
# No tree, no audio server: none of these may crash.
var audio: Node = AudioFeedback.new()
audio.start_crowd_bed()
var a := TestUtils.check(audio.bed_playing, "start marks the bed as playing")
audio.set_crowd_level(0.78)
var b := TestUtils.check(is_equal_approx(audio.crowd_level, 0.78), "level is stored")
audio.stop_crowd_bed()
var c := TestUtils.check(not audio.bed_playing, "stop clears the playing flag")
audio.free()
return a and b and c
func test_radio_crackle_methods() -> bool:
var audio: Node = AudioFeedback.new()
audio.play_radio_open()
audio.play_radio_close()
var a := TestUtils.check_eq(audio.radio_crackles, 2, "open and close each count a crackle")
var b := TestUtils.check(audio._free_player() == null, "no players exist before _ready, and that is safe")
audio.free()
return a and b
func test_game_manager_wires_audio() -> bool:
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var a := TestUtils.check(src.contains("audio.start_crowd_bed()"), "the bed starts with the shift")
var b := TestUtils.check(src.contains("audio.set_crowd_level(float(payload.get("),
"crowd events drive the bed")
var c := TestUtils.check(src.contains("audio.stop_crowd_bed()"), "the bed stops at shift end")
var d := TestUtils.check(src.contains("audio.play_radio_open()"), "opening the radio crackles")
var e := TestUtils.check(src.contains("audio.play_radio_close()"), "closing the radio crackles")
return a and b and c and d and e
func test_mood_events_reach_the_bed() -> bool:
## The bed level is fed from the same payload the crowd reactions use.
var crowd := CrowdSystem.new()
var seen: Array = []
crowd.changed.connect(func(_p): pass)
crowd.apply_event("aggressive_behavior")
seen.append(crowd.mood)
crowd.apply_event("emergency_responder")
seen.append(crowd.mood)
var a := TestUtils.check(seen[1] > seen[0], "mood rises with a responder arrival")
crowd = null
return a