Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f4ac7e9aa | ||
|
|
294ac17333 |
No files matched your search
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 1004 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 1000 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 954 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 910 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 971 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 1.2 MiB |
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||