Compare commits

..
2 Commits
Author SHA1 Message Date
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
zwitschi e2c2a0bf4c Add NPC theater poses: sit, photo, film, perform
Full build / Import + headless tests (push) Successful in 29s
Full build / Full desktop build + optional release (push) Failing after 3m44s
Once an NPC reaches its intent waypoint it now does something instead of standing
in an idle T-pose:

- sit: the rig settles 0.34 m into a seat and the legs swing forward on the steps
- photo/film: influencers and tourists raise their camera prop briefly (a sine
  pulse, so it reads as taking a shot rather than a permanently raised arm)
- perform: performers sway the sign board they carry

Pose selection lives in NpcAvatar.theater_pose() (intent + arrived); the math is in
pure helpers (crouch_for_pose, prop_lift_for_pose, sway_for_pose) so it is
headless-testable. Prop/leg nodes are captured at build time and animated from
their rest offsets. Performers also get a hand-held board and post.

Tests/docs
- Headless suite 120 -> 127 (test_npc_theater.gd).
- Verified visually with a staged sit/photo/perform lineup in front of the entrance.
- README, TODO section moved to DONE, MEMORY. Version 0.2.7.
2026-10-31 21:05:59 +01:00
17 changed files with 520 additions and 42 deletions

No files matched your search

+22
View File
@@ -494,3 +494,25 @@ Completed 2026-10-31 (working tree). Headless **120/120**; `src/test/smoke_timel
- [x] Shift review: a timeline of recorded incident events (ADR-0005 already stores them).
Scrub or step through; no live 3D replay required in the first version.
## NPC theater
Completed 2026-10-31 (working tree). Headless **127/127**; verified visually with a staged sit/photo/perform lineup.
Intents `sit` / `photo` / `film` / `perform` already pick waypoints. ADR-0010 rig (face/hair)
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
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).
+13 -1
View File
@@ -21,7 +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` NPC theater poses (sit/photo/perform), then audio and accessibility.
- Next work: `TODO.md` accessibility (grayscale-safe escalation cues, HUD font scale in settings),
then scenario content gaps.
- Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg.
## Current State
@@ -94,6 +95,17 @@ 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.
- **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
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
(negative X pitch; positive looked like kneeling). `_photo_prop` is set for influencer/tourist
cameras, `_sign_prop` for protester/performer boards; their base Y values are captured at build
time so the animation offsets from rest.
- **Body-camera timeline (2026-10-31):** `src/game/ui/event_timeline.gd` flattens
`review.incidents.incidents[].events` into one time-ordered list (`flatten`), formats each row
with `describe`, and steps with Left/Right or the buttons. `ShiftCoordinator.started_at_ms`
+7 -3
View File
@@ -28,8 +28,7 @@ The central design goal is simple:
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
> **Latest: v0.2.6** — body-camera timeline in the shift review, stepping through every recorded
> incident event.
> **Latest: v0.2.8** — procedural crowd ambience driven by crowd mood, and radio crackle.
> 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
@@ -253,6 +252,9 @@ godot --headless --path . --script res://src/test/smoke_emergency.gd
# Body-camera timeline smoke: review → timeline → step through → back to review
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.
@@ -305,7 +307,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: **120 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)
- 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)
- 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`)
@@ -319,6 +321,8 @@ Implemented so far:
- Flashlight aims straight ahead and lights the person/door being checked instead of the ground in front of the guard
- 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
- 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
- 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)
+2 -19
View File
@@ -1,27 +1,10 @@
# TODO
Godot 4.3 prototype. Release **v0.2.6** is tagged. Headless suite **120/120**, plus
`smoke_session_shell.gd`, `smoke_plan_verbs.gd`, `smoke_crowd.gd`, `smoke_emergency.gd`,
`smoke_timeline.gd`, and the windowed focus/radial smoke.
Godot 4.3 prototype. Release **v0.2.8** is tagged. Headless suite **134/134**, plus the session,
PLAN-verb, crowd, emergency, timeline, audio, and windowed focus/radial smokes.
Host `REQUIRE_SIGNIN_VIEW` stays deferred.
## NPC theater
Intents `sit` / `photo` / `film` / `perform` already pick waypoints. ADR-0010 rig (face/hair)
is in the working tree. Bodies still idle-stand at those spots.
- [ ] Sit pose at `steps_end`. Photo: brief raise of the influencer/tourist prop. Performer
stays put and plays the hat/sign.
## 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).
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: 34 KiB

After

Width:  |  Height:  |  Size: 1.0 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: 41 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

+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.6"
config/version="0.2.8"
run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.3", "GL Compatibility")
+99
View File
@@ -8,6 +8,11 @@ extends Node
const SAMPLE_RATE := 22050.0
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:
# 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
_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).
func play_deny() -> void:
_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
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:
if _players.is_empty():
return null
for player in _players:
if not player.playing:
return player
+15
View File
@@ -233,6 +233,9 @@ func _reset_shift_state() -> void:
## Begin (or resume) the shift clock and recapture the mouse.
func _start_shift() -> void:
_shift_running = true
if audio:
audio.start_crowd_bed()
audio.set_crowd_level(coordinator.crowd.mood)
coordinator.start()
if hud:
hud.set_shift_state(coordinator, selected_scenario)
@@ -322,6 +325,8 @@ func _build_ui() -> void:
radio_panel.submitted.connect(_on_radio_submitted)
radio_panel.cancelled.connect(func():
radio_panel.hide()
if audio:
audio.play_radio_close()
hud.set_action_hint("")
_refresh_freeze())
shift_review_panel = ShiftReview.new()
@@ -566,6 +571,8 @@ func _connect_crowd() -> void:
func _on_crowd_changed(payload: Dictionary) -> void:
var event_type := str(payload.get("event", ""))
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.
if panic or event_type == "crowd_panic":
_flee_crowd()
@@ -609,6 +616,8 @@ func _responder_profile() -> NpcProfile:
func _on_shift_ended(review: Dictionary) -> void:
_shift_running = false
if audio:
audio.stop_crowd_bed()
# Persist the finished shift locally (Save autoload, user:// JSON).
if Save:
Save.save_shift_result(review)
@@ -801,6 +810,8 @@ func _close_top_modal() -> bool:
return true
if radio_panel and radio_panel.visible:
radio_panel.hide()
if audio:
audio.play_radio_close()
hud.set_action_hint("")
_refresh_freeze()
return true
@@ -1126,9 +1137,13 @@ func _open_radio(emergency: bool) -> void:
return
radio_panel.open(controller, emergency, current_npc_truth)
hud.set_action_hint("Fill in the radio report on screen.")
if audio:
audio.play_radio_open()
_refresh_freeze()
func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
if audio:
audio.play_radio_close()
hud.set_action_hint("")
_refresh_freeze()
var result: Dictionary = coordinator.radio.request_emergency(report) if emergency else coordinator.radio.request_assistance(report)
+80 -7
View File
@@ -91,6 +91,13 @@ var _bob_phase := 0.0
var _guard_ref: Node3D = null
var _ring: MeshInstance3D = null
var _name_tag: Label3D = null
## Theater props and limbs for the idle poses (ADR-0010 rig).
var _photo_prop: MeshInstance3D = null
var _sign_prop: MeshInstance3D = null
var _legs: Array[MeshInstance3D] = []
var _theater_t := 0.0
var _photo_prop_base_y := 0.0
var _sign_prop_base_y := 0.0
const SKIN_TONE := Color(0.88, 0.72, 0.60)
const PANTS_COLOR := Color(0.22, 0.22, 0.26)
const HAIR_COLORS := [Color(0.12, 0.1, 0.08), Color(0.35, 0.22, 0.12), Color(0.55, 0.42, 0.28), Color(0.15, 0.15, 0.18)]
@@ -111,8 +118,8 @@ func _build_body(npc_type: int) -> void:
# Torso
_add_capsule(_rig, 0.30, 0.95, Vector3(0, 1.05, 0), base_color)
# Legs
_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), PANTS_COLOR)
_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), PANTS_COLOR)
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), PANTS_COLOR))
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), PANTS_COLOR))
# Arms
_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0)
_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0)
@@ -170,7 +177,7 @@ func set_focus_visual(focused: bool, menu_open: bool = false) -> void:
if _name_tag:
_name_tag.visible = focused
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> void:
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> MeshInstance3D:
var mi := MeshInstance3D.new()
var mesh := CapsuleMesh.new()
mesh.radius = radius
@@ -180,6 +187,7 @@ func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, co
mi.rotation_degrees.z = z_rot
mi.material_override = _material(color)
parent.add_child(mi)
return mi
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color) -> void:
var mi := MeshInstance3D.new()
@@ -201,7 +209,8 @@ func _add_props(npc_type: int) -> void:
var chest_y := 1.25
match npc_type:
Rules.NpcType.INFLUENCER:
_add_box_prop(_rig, Vector3(0.16, 0.1, 0.09), Vector3(0.3, chest_y, 0.22), Color(0.1, 0.1, 0.1))
_photo_prop = _add_box_prop(_rig, Vector3(0.16, 0.1, 0.09), Vector3(0.3, chest_y, 0.22), Color(0.1, 0.1, 0.1))
_photo_prop_base_y = _photo_prop.position.y
_add_box_prop(_rig, Vector3(0.05, 0.05, 0.06), Vector3(0.34, chest_y + 0.06, 0.30), Color(0.4, 0.7, 1.0))
Rules.NpcType.DELIVERY:
_add_box_prop(_rig, Vector3(0.34, 0.26, 0.28), Vector3(0.0, 1.02, 0.3), Color(0.55, 0.4, 0.25))
@@ -216,15 +225,22 @@ func _add_props(npc_type: int) -> void:
cone.material_override = hat
cone.position = Vector3(0, 0.26, 0)
_head.add_child(cone)
# Hat in one hand and a small board in the other: the performer plays
# to the sidewalk once at the waypoint.
_add_box_prop(_rig, Vector3(0.06, 0.5, 0.06), Vector3(-0.42, 1.3, 0.14), Color(0.45, 0.35, 0.25))
_sign_prop = _add_box_prop(_rig, Vector3(0.42, 0.32, 0.03), Vector3(-0.46, 1.6, 0.14), Color(0.95, 0.9, 0.75))
_sign_prop_base_y = _sign_prop.position.y
Rules.NpcType.PROTESTER:
_add_box_prop(_rig, Vector3(0.05, 1.2, 0.05), Vector3(0.28, 1.45, 0.18), Color(0.4, 0.36, 0.32))
_add_box_prop(_rig, Vector3(0.5, 0.42, 0.04), Vector3(0.48, 1.55, 0.18), Color(0.93, 0.93, 0.95))
_sign_prop = _add_box_prop(_rig, Vector3(0.5, 0.42, 0.04), Vector3(0.48, 1.55, 0.18), Color(0.93, 0.93, 0.95))
_sign_prop_base_y = _sign_prop.position.y
Rules.NpcType.TOURIST:
_add_box_prop(_rig, Vector3(0.3, 0.36, 0.14), Vector3(-0.34, 1.15, -0.18), Color(0.25, 0.3, 0.45))
_photo_prop = _add_box_prop(_rig, Vector3(0.3, 0.36, 0.14), Vector3(-0.34, 1.15, -0.18), Color(0.25, 0.3, 0.45))
_photo_prop_base_y = _photo_prop.position.y
Rules.NpcType.CONFUSED_VISITOR:
_add_box_prop(_rig, Vector3(0.3, 0.05, 0.34), Vector3(0, 0.92, 0.06), Color(0.9, 0.88, 0.8))
func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) -> void:
func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) -> MeshInstance3D:
var mi := MeshInstance3D.new()
var mesh := BoxMesh.new()
mesh.size = size
@@ -232,6 +248,7 @@ func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) ->
mi.position = pos
mi.material_override = _material(color)
parent.add_child(mi)
return mi
## Rubberneck: idle people stop and turn toward the incident. Returns false when
## this NPC is too engaged (walking, being talked to, already leaving) to watch.
@@ -309,14 +326,70 @@ func _physics_process(delta: float) -> void:
_animate(delta)
## Procedural walk bob + head-toward-guard cue when the NPC is aware.
## Theater pose for the current intent once the NPC has stopped walking.
## "sit" at the steps, "photo" for camera props, "perform" for the hat/sign,
## "film" for a slow pan, otherwise "idle".
func theater_pose() -> String:
if not arrived:
return "idle"
match npc.intent:
"sit":
return "sit"
"photo":
return "photo"
"film":
return "film"
"perform":
return "perform"
_:
return "idle"
## How far the rig drops for a seated pose. Pure so it is headless-testable.
static func crouch_for_pose(pose: String) -> float:
return -0.34 if pose == "sit" else 0.0
## Vertical lift of a held camera prop. `phase` is seconds; the raise is brief so
## it reads as taking a photo rather than a permanently raised arm.
static func prop_lift_for_pose(pose: String, phase: float) -> float:
if pose != "photo" and pose != "film":
return 0.0
return maxf(0.0, sin(phase * 1.7)) * 0.16
## Sway amount for a performer playing to the sidewalk.
static func sway_for_pose(pose: String, phase: float) -> float:
if pose != "perform":
return 0.0
return sin(phase * 2.2) * 0.09
func _animate(delta: float) -> void:
if _rig == null:
return
var speed := Vector2(velocity.x, velocity.z).length()
var pose := theater_pose()
var crouch := crouch_for_pose(pose)
if speed > 0.3:
_bob_phase += delta * (6.0 + speed)
_rig.position.y = sin(_bob_phase) * 0.035
_rig.rotation.x = sin(_bob_phase * 0.5) * 0.04
elif pose != "idle" or not is_zero_approx(_rig.position.y):
# Idle theater: seated, photographing, filming, or performing.
_theater_t += delta
_bob_phase = 0.0
_rig.position.y = lerpf(_rig.position.y, crouch, 0.12)
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
var lift := prop_lift_for_pose(pose, _theater_t)
if _photo_prop:
_photo_prop.position.y = _photo_prop_base_y + lift
_photo_prop.rotation.x = -lift * 1.4
var sway := sway_for_pose(pose, _theater_t)
if _sign_prop:
_sign_prop.rotation.z = sway
_sign_prop.position.y = _sign_prop_base_y + absf(sway) * 0.25
if _legs.size() == 2:
# Negative pitch swings the legs forward (toward the way the body faces).
var leg_angle := deg_to_rad(-78.0) if pose == "sit" else 0.0
_legs[0].rotation.x = lerpf(_legs[0].rotation.x, leg_angle, 0.12)
_legs[1].rotation.x = lerpf(_legs[1].rotation.x, leg_angle, 0.12)
else:
_bob_phase = 0.0
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)
+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)
+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
+98
View File
@@ -0,0 +1,98 @@
extends RefCounted
## NPC theater: seated guests, camera raises, and performers playing to the
## sidewalk once their intent's waypoint is reached (PLAN.md: NPC Behavior Types).
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
const Rules := preload("res://src/core/rules.gd")
func _avatar(intent: String, npc_type: int = Rules.NpcType.TOURIST) -> Node:
var p := NpcProfile.new()
p.id = "theater"
p.display_name = "Theater"
p.npc_type = npc_type
var avatar: Node = NpcAvatar.new()
avatar.setup(p, intent, 1234, Vector3(2, 0.5, 8), Vector3(0, 0.5, 6))
return avatar
func test_pose_per_intent() -> bool:
var cases := {
"sit": "sit",
"photo": "photo",
"film": "film",
"perform": "perform",
"enter": "idle",
}
var ok := true
for intent in cases.keys():
var avatar := _avatar(str(intent))
avatar.arrived = true
if avatar.theater_pose() != cases[intent]:
ok = false
avatar.free()
return TestUtils.check(ok, "each intent maps to its theater pose once arrived")
func test_walking_npc_has_no_pose() -> bool:
var avatar := _avatar("sit")
var a := TestUtils.check_eq(avatar.theater_pose(), "idle", "a walking NPC does not sit yet")
avatar.arrived = true
var b := TestUtils.check_eq(avatar.theater_pose(), "sit", "arriving starts the pose")
avatar.free()
return a and b
func test_only_sit_crouches() -> bool:
var a := TestUtils.check(is_equal_approx(NpcAvatar.crouch_for_pose("sit"), -0.34), "sit lowers the rig")
var b := TestUtils.check_eq(NpcAvatar.crouch_for_pose("photo"), 0.0, "photo keeps standing")
var c := TestUtils.check_eq(NpcAvatar.crouch_for_pose("perform"), 0.0, "perform keeps standing")
var d := TestUtils.check_eq(NpcAvatar.crouch_for_pose("idle"), 0.0, "idle keeps standing")
return a and b and c and d
func test_photo_lift_is_brief() -> bool:
var raised := 0.0
var flat := 0.0
for i in 120:
var phase := float(i) / 60.0
var lift: float = NpcAvatar.prop_lift_for_pose("photo", phase)
raised = maxf(raised, lift)
if i == 0:
flat = lift
var a := TestUtils.check(raised > 0.1, "the camera prop lifts while photographing")
var b := TestUtils.check(raised <= 0.16 + 0.001, "the lift stays small")
var c := TestUtils.check_eq(flat, 0.0, "the lift starts at rest")
var d := TestUtils.check_eq(NpcAvatar.prop_lift_for_pose("sit", 0.5), 0.0, "seated guests do not lift a prop")
return a and b and c and d
func test_performer_sways_only_when_performing() -> bool:
var peak := 0.0
for i in 120:
peak = maxf(peak, absf(NpcAvatar.sway_for_pose("perform", float(i) / 60.0)))
var a := TestUtils.check(peak > 0.05, "the performer's sign sways")
var b := TestUtils.check_eq(NpcAvatar.sway_for_pose("photo", 1.0), 0.0, "photographers do not sway")
var c := TestUtils.check_eq(NpcAvatar.sway_for_pose("sit", 1.0), 0.0, "seated guests do not sway")
return a and b and c
func test_photo_prop_is_wired_for_camera_archetypes() -> bool:
var influencer := _avatar("photo", Rules.NpcType.INFLUENCER)
var tourist := _avatar("photo", Rules.NpcType.TOURIST)
var performer := _avatar("perform", Rules.NpcType.PERFORMER)
var a := TestUtils.check(influencer._photo_prop != null, "influencer carries a camera prop")
var b := TestUtils.check(tourist._photo_prop != null, "tourist carries a camera prop")
var c := TestUtils.check(performer._sign_prop != null, "performer carries a sign prop")
influencer.free()
tourist.free()
performer.free()
return a and b and c
func test_sitting_rig_actually_lowers() -> bool:
var avatar := _avatar("sit")
avatar.arrived = true
for i in 40:
avatar._animate(0.016)
var a := TestUtils.check(avatar._rig.position.y < -0.2, "the rig settles into the crouch")
var legs_bent := TestUtils.check(absf(avatar._legs[0].rotation.x) > deg_to_rad(30.0), "legs bend for the seat")
avatar.arrived = false
for i in 60:
avatar._animate(0.016)
var b := TestUtils.check(avatar._rig.position.y > -0.1, "standing back up lifts the rig")
avatar.free()
return a and legs_bent and b
+20 -11
View File
@@ -112,10 +112,10 @@ SCREENS = [
"the ring, and the HUD shows a target line with the "
"interaction hint.",
"prompt": (
"Realistic 3D first-person game scene: a pedestrian NPC walking "
"toward the camera on a city sidewalk, a thin pale-gold ring floating "
"around the NPC's head as a selection marker, a small name label "
"under the ring, minimal HUD target text at the bottom, daytime"),
"Realistic 3D first-person game scene: a friendly pedestrian "
"standing on a city sidewalk, a thin pale-gold ring floating around "
"their head as a game selection marker, a small name label under the "
"ring, minimal HUD target text at the bottom, daytime"),
"regions": [
(0.35, 0.15, 0.65, 0.60, "NPC in focus"),
(0.44, 0.10, 0.56, 0.16, "head ring"),
@@ -156,9 +156,9 @@ SCREENS = [
"whether anyone is in immediate danger, plus a send action.",
"prompt": (
"Realistic 3D game screenshot with an in-game radio dispatch panel "
"overlaid: form fields for location, description, behaviour, weapons "
"seen and immediate danger, and a Send button, dark translucent panel "
"over a softly blurred city street background"),
"overlaid: form fields for location, description, behaviour, a threat "
"assessment and whether anyone is in danger, and a Send button, dark "
"translucent panel over a softly blurred city street background"),
"regions": [
(0.30, 0.08, 0.70, 0.92, "radio panel"),
(0.34, 0.16, 0.66, 0.30, "location / description"),
@@ -407,13 +407,22 @@ def _generate_ai(screen, model, request_size):
def generate(args):
DESIGNS_DIR.mkdir(parents=True, exist_ok=True)
failed = []
for screen in SCREENS:
print(f"generating {screen['name']} ...")
if args.draft:
_draw_wireframe(screen, _design_path(screen["name"]))
else:
_generate_ai(screen, args.model, args.request_size)
try:
if args.draft:
_draw_wireframe(screen, _design_path(screen["name"]))
else:
_generate_ai(screen, args.model, args.request_size)
except SystemExit as exc:
failed.append(screen["name"])
print(f" FAILED {screen['name']}: {exc}", file=sys.stderr)
print("done")
if failed:
print(f"failed: {', '.join(failed)}", file=sys.stderr)
return 1
return 0
# ---------------------------------------------------------------------------