Compare commits

...
3 Commits
Author SHA1 Message Date
zwitschi 1b976bf351 Add body-camera timeline to the shift review
Full build / Import + headless tests (push) Successful in 30s
Full build / Full desktop build + optional release (push) Failing after 3m38s
- New src/game/ui/event_timeline.gd flattens every recorded incident event
  (ADR-0005) into one time-ordered list and shows time, person, and a one-line
  summary. Step with Left/Right or the buttons; Esc returns to the review. No live
  3D replay in this first version.
- Shift review gains a "Body camera timeline" button that hands the review to the
  timeline; the review dim is hidden while it is open (stacked overlays made the
  screen nearly black) and restored on close.
- ShiftCoordinator records started_at_ms so the review can stamp events as mm:ss.
- 8 new string keys for the timeline UI and the review button.

Tests/docs
- Headless suite 113 -> 120 (test_event_timeline.gd), new smoke_timeline.gd that
  opens the timeline from the review, steps through every event, and closes back.
- README, TODO section moved to DONE, MEMORY notes. Version 0.2.6.
2026-10-31 21:02:25 +01:00
zwitschi 921bab796e Add emergency aftermath: evacuation, investigation, review copy
Full build / Import + headless tests (push) Successful in 26s
Full build / Full desktop build + optional release (push) Failing after 4m20s
- Responder outcomes now reach the shift review
  (ShiftCoordinator.responder_outcomes), and the review screen names them.
- "evacuation" (emergency call, low danger, no weapon) actually clears the entrance:
  the door is locked, every NPC flees and is marked as leaving, and a
  security_failure reputation hit applies.
- An emergency call the situation did not justify (no weapon, danger <= 1) appends
  an investigation note, records investigation_opened, and applies the existing
  false_report hit. The review lists the investigations.
- Fix dispatch dying after the first shift: coordinator.setup() builds a new
  RadioSystem, so the responder_arrived connection made in _ready was lost on every
  venue switch/restart. _connect_radio() re-wires it beside _connect_crowd().

Tests/docs
- Headless suite 105 -> 113 (test_emergency_aftermath.gd), new smoke_emergency.gd
  covering the investigation, evacuation aftermath, and review copy.
- README controls/status/smoke checklist, TODO section moved to DONE, MEMORY notes.
- Version 0.2.5.
2026-10-31 20:59:06 +01:00
zwitschi 71b0b53705 Add crowd presentation, fix NPC navigation, refresh screenshots
Full build / Import + headless tests (push) Successful in 27s
Full build / Full desktop build + optional release (push) Failing after 3m37s
NPC navigation (previously dead in the live game)
- Only assign NavigationAgent3D.target_position when it changes: re-assigning every
  frame invalidated the path, so get_next_path_position() kept returning the current
  point and avatars never moved.
- Follow the first path point that is closer to the destination than the avatars are.
  Nav points sit on the navmesh plane (y=0) while bodies stand at y~0.5, so the old
  3D distance test always looked "already there"; path[0] is also the old start point,
  so a naive "far enough away" pick steered backwards and oscillated.
- Fix fsm.state -> fsm.current_state in the idle-lifetime check.

Crowd presentation
- Aggressive behavior, visible weapons, and responder arrivals make idle bystanders
  stop and face the incident for 12 s; someone waiting at the entrance keeps focus.
- Panic (mood >= 0.75) walks people away from the door at 2.4 m/s, then they despawn.
- Wire reactions to EventBus.crowd_changed: CrowdSystem.apply_event() emits on the bus,
  not on its own `changed` signal (the first wiring received nothing).
- Measured a 16-NPC entrance at ~1.2 ms/frame (824 fps, vsync off), so no LOD/culling.

World and HUD
- Skyline is now a ring of four alpha-scissored planes at 200 m instead of a single
  plane; PlaneMesh defaults to horizontal, which is why the backdrop rendered as a band
  overhead. Larger ground plane, mipmapped texture, scissor threshold 0.5 (0.3 left
  mip dashes in the sky), lifted albedo so the city is not pure black.
- Flashlight aims straight ahead and lights the person/door instead of the ground.
- Shorter HUD controls line so it stops overlapping itself.

Tests, tooling, docs
- Headless suite 98 -> 105 (new test_crowd_presentation.gd, 7 tests).
- New smoke_crowd.gd: rubbernecking, panic, and flee-target sanity.
- capture_screenshots.gd starts a shift from the venue menu and stages the night set
  properly; all six README screenshots regenerated.
- README, TODO/DONE (crowd section moved), MEMORY updated. Version 0.2.4.
2026-10-31 20:55:10 +01:00
42 changed files with 2068 additions and 77 deletions

No files matched your search

+33
View File
@@ -461,3 +461,36 @@ Completed 2026-10-31 (working tree). Headless **98/98**; `src/test/smoke_plan_ve
- [x] Blocking stations the guard between the person and the door (PLAN Level 3). Today it only - [x] Blocking stations the guard between the person and the door (PLAN Level 3). Today it only
sends `apply_player_action(BLOCKING)`. sends `apply_player_action(BLOCKING)`.
- [x] "Request reason for entry" as a Level 3 ask, with a short NPC reply from `dialogue_lines`. - [x] "Request reason for entry" as a Level 3 ask, with a short NPC reply from `dialogue_lines`.
## Crowd presentation
Completed 2026-10-31 (working tree). Headless **105/105**; `src/test/smoke_crowd.gd` verifies rubbernecking and panic clearing. A 16-NPC entrance measured ~1.2 ms/frame (824 fps, vsync off), so no LOD/culling was added at the current cap.
`CrowdSystem` is a mood/attention float. PLAN wants people watching, gathering, and panicking.
- [x] On `aggressive_behavior` / `emergency_responder`, nearby idle NPCs stop and face the
incident (rubberneck). On `crowd_panic`, they move away from the door.
- [x] Profile a busy entrance with textured materials and props; add LOD or culling for distant
NPCs if frame time drops.
## Emergency aftermath
Completed 2026-10-31 (working tree). Headless **113/113**; `src/test/smoke_emergency.gd` covers the unjustified-call investigation, the evacuation aftermath, and the review copy.
Responder outcomes (`command` / `restraint` / `evacuation` / `arrest`) are log lines. PLAN also
wants evacuation, a closed street, and an investigation when the call was unjustified.
- [x] `evacuation` outcome actually clears the entrance (NPCs leave, door stays locked).
- [x] Unjustified emergency (low danger, no weapon) writes an investigation note on the shift
review and applies the existing false-report / reputation hit.
- [x] Review copy names the responder outcome, not only battery and crowd %.
## Body-camera replay
Completed 2026-10-31 (working tree). Headless **120/120**; `src/test/smoke_timeline.gd` opens the timeline from the review, steps through every event, and returns to the review.
- [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.
+57 -2
View File
@@ -21,11 +21,11 @@ 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` crowd presentation (rubberneck + panicked NPCs), then emergency aftermath - Next work: `TODO.md` NPC theater poses (sit/photo/perform), then audio and accessibility.
(evacuation, unjustified-call investigation).
- 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
- Access artifacts: `src/core/access_loadout.gd` builds from event `override_access` + venue policy. - Access artifacts: `src/core/access_loadout.gd` builds from event `override_access` + venue policy.
Spawn signal is now `(profile, intent, npc_id, event)`. Recognition is not auto-attached to every Spawn signal is now `(profile, intent, npc_id, event)`. Recognition is not auto-attached to every
guest. `AccessVerifier.verify(..., already_screened=false)` so Joke Shop scoring uses guest. `AccessVerifier.verify(..., already_screened=false)` so Joke Shop scoring uses
@@ -69,11 +69,53 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
default, `B` toggles it, and a formal warning arms it and writes a `body_camera` incident event. default, `B` toggles it, and a formal warning arms it and writes a `body_camera` incident event.
Blocking also calls `_station_between()` to physically place the guard 0.9 m from the NPC toward Blocking also calls `_station_between()` to physically place the guard 0.9 m from the NPC toward
the entrance. `_request_reason()` logs the NPC's seeded `dialogue_lines` pick. the entrance. `_request_reason()` logs the NPC's seeded `dialogue_lines` pick.
- **NPC navigation was dead (fixed 2026-10-31):** avatars never moved in the live game, for three
stacked reasons. (1) `agent.target_position` was re-assigned every physics frame, which
invalidates the path so `get_next_path_position()` keeps returning the current point — only set
it when the target changes. (2) Nav path points sit on the navmesh plane (y=0) while bodies stand
at y≈0.5, so a 3D "are we there" test always looked 0.5 m away and the horizontal direction was
zero — compare horizontally. (3) `path[0]` is the old start point, so picking any point "far
enough away" can pick the point behind you and oscillate — pick the first point that is
**closer to the goal** than the current position. `_nav_try_frames`/`_move_direct` fallback is
still there.
- **Crowd reactions listen on the EventBus:** `CrowdSystem.apply_event()` emits
`event_bus.crowd_changed`; its own `changed` signal is never emitted. `GameManager._connect_crowd()`
therefore connects to `coordinator.event_bus.crowd_changed` (reconnect after every
`coordinator.setup()`, which recreates both crowd and event bus).
- **Crowd presentation:** `NpcAvatar.start_rubberneck(at)` (idle bystanders away from the door stop
and face the incident for 12 s) and `start_flee(from)` (panic: walk away at 2.4 m/s, then free
themselves). Someone `awaiting_interaction` within 3 m of the door is "engaged" and does not
rubberneck. Use `position` (not `global_position`) for that distance check so it also works in
headless tests outside the tree.
- **Perf:** a busy 16-NPC entrance measured ~1.2 ms/frame (824 fps with vsync off, 800 samples),
so LOD/culling is not warranted at `MAX_LIVE_NPCS = 16`. Re-measure before raising the cap.
- **Incident recording was dead:** `IncidentRecorder.start_incident()` was never called, so every - **Incident recording was dead:** `IncidentRecorder.start_incident()` was never called, so every
`add_event`/`add_access_evidence` silently no-opped and the review always showed 0 incidents. `add_event`/`add_access_evidence` silently no-opped and the review always showed 0 incidents.
`_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.
- **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`
gives the review an absolute start for mm:ss stamps. `GameManager._open_timeline` hides the
review dim (two stacked overlays made the screen nearly black) and `_timeline_from_review`
restores it on close. Smoke: `src/test/smoke_timeline.gd`.
- **Radio dispatch was dead after the first shift:** `coordinator.setup()` builds a new
`RadioSystem`, so the `responder_arrived` connection made in `_ready` was lost on every venue
switch/restart (responders never resolved anything). `_connect_radio()` now re-wires it next to
`_connect_crowd()` after every setup. Same class of bug as the crowd signal.
- **Emergency aftermath:** `ShiftCoordinator.responder_outcomes` / `investigations` feed the review.
`evacuation` (emergency call, low danger, no weapon) locks the door, makes every NPC flee, marks
them `leave_requested`, and applies a `security_failure` hit. An emergency placed while
`current_npc_truth` has no weapon and `danger_level <= 1` appends an investigation note, records
`investigation_opened`, and applies `false_report` -6.
- **Skyline backdrop (2026-10-31):** `PlaneMesh` defaults to a **horizontal** XZ plane — set
`mesh.orientation = PlaneMesh.FACE_Z` or the "backdrop" renders as a band overhead. The skyline
is now a ring of four 400x60 planes at 200 m (center y=30, so building bases meet the ground),
`TEXTURE_FILTER` default, `TRANSPARENCY_ALPHA_SCISSOR` with threshold **0.5** (0.3 left faint
mip dashes across the sky), unshaded with a lifted albedo (`Color(2.6,2.7,3.0)`) so it is not
pure black. Ground is 500x500 so no sky gap shows under the buildings. The `skyline.png` import
needs `mipmaps/generate=true`.
- **Asset gotcha:** `assets/textures/game_ready/` is *not* imported (no `.import`). Use the - **Asset gotcha:** `assets/textures/game_ready/` is *not* imported (no `.import`). Use the
imported `assets/textures/skyline.png` path. Untracked duplicate folder should not be referenced. imported `assets/textures/skyline.png` path. Untracked duplicate folder should not be referenced.
- Host sign-in follow-up remains deferred. PLAN.md + ADRs 0007–0010 still describe the intended UX. - Host sign-in follow-up remains deferred. PLAN.md + ADRs 0007–0010 still describe the intended UX.
@@ -165,6 +207,17 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
- NOTE: collaborators occasionally add uncommitted work to this shared tree (e.g. the radio - NOTE: collaborators occasionally add uncommitted work to this shared tree (e.g. the radio
easter egg); check `git status` before committing and leave foreign changes untouched unless asked. easter egg); check `git status` before committing and leave foreign changes untouched unless asked.
- Screen-design references (`tools/screen_designs.py`, tests in `tools/test_screen_designs.py`):
`generate` turns the screen descriptions in `docs/` (README captions, ADRs 0007–0010,
PLAN, architecture) into reference images under `docs/screens/designs/` — `--draft`
draws offline Pillow wireframes, `--ai` generates realistic 3D scenes / in-game UI via
OpenRouter (default model `openai/gpt-5-image-mini`). Screens are tagged `scene` or
`ui`; prompts get a shared art-direction suffix, and requests use a 3:2 landscape
source cropped to 16:9 (not a square centre-crop). `compare` checks `docs/screenshots/*.png` for aspect/size/
blank-frame and coarse layout/palette/detail drift. Drift distances are warnings, not
failures: a mockup never matches a real render, so semantic composition is a follow-up
vision-model judge (tracked in TODO).
## Known Issues / Notes ## Known Issues / Notes
- Reference cycles are fixed: `FSM` holds its owner via `WeakRef`, `ShiftCoordinator` is - Reference cycles are fixed: `FSM` holds its owner via `WeakRef`, `ShiftCoordinator` is
@@ -195,6 +248,8 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
## Open Questions ## Open Questions
- Add a vision-model (LLM) judge on top of the screen-design comparison: pixel and
perceptual metrics cannot decide whether a screenshot's composition is correct.
- Whether the responder avatar should be playable/controllable or purely scripted. - Whether the responder avatar should be playable/controllable or purely scripted.
- Localization target languages beyond English. - Localization target languages beyond English.
- macOS notarization approach for broad distribution. - macOS notarization approach for broad distribution.
+59 -7
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.3** — the PLAN verbs (eye contact, recognition as evidence, body camera, > **Latest: v0.2.6** — body-camera timeline in the shift review, stepping through every recorded
> blocking that stations the guard, request reason) and working incident recording. > incident event.
> 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
@@ -180,6 +180,39 @@ python3 -m unittest discover -s tools -p 'test_*.py'
The audit classifies measurable fit issues as `good` or `bad` based on role-specific dimensions, aspect ratio, alpha requirements, and tile-edge continuity. It lists perspective/projection as a visual review item because image semantics cannot be judged reliably by these deterministic checks. The audit classifies measurable fit issues as `good` or `bad` based on role-specific dimensions, aspect ratio, alpha requirements, and tile-edge continuity. It lists perspective/projection as a visual review item because image semantics cannot be judged reliably by these deterministic checks.
### Screen design references
Screen mockups and screenshot comparison use `tools/screen_designs.py`. It turns the
screen descriptions in `docs/` (README captions, ADRs 0007–0010, `PLAN.md`, and
`architecture.md`) into reference images under `docs/screens/designs/`, then uses
those images as the expected state to check the game's captures.
Each screen is tagged `scene` (a realistic first-person 3D world view) or `ui`
(an in-game panel overlaid on a softened scene), and the generator appends a
shared art-direction suffix so the frames read as one coherent game. Requests use
a 3:2 landscape source and crop to the 16:9 capture dimensions.
```bash
# Deterministic wireframes (offline, no API key)
python3 tools/screen_designs.py generate --draft
# Realistic 3D scenes and in-game screen designs via OpenRouter
# (requires OPENROUTER_API_KEY)
python3 tools/screen_designs.py generate
# Compare docs/screenshots/*.png against the designs
python3 tools/screen_designs.py compare
python3 tools/screen_designs.py compare --json
```
`compare` flags gross drift: wrong aspect ratio, undersized frame, near-blank
frame, and large layout/palette/detail distance. It is a regression gate, not a
semantic judge — pixel and perceptual metrics cannot decide whether a frame's
composition is *right*, so a vision-model pass is the documented follow-up (see
`TODO.md`). Regenerate the designs after changing screen layout, then re-run
`compare` after regenerating the game screenshots with
`src/test/capture_screenshots.gd`.
### Setup ### Setup
1. Download the Godot 4.3 editor (official build) and put `godot` on your PATH. 1. Download the Godot 4.3 editor (official build) and put `godot` on your PATH.
@@ -211,6 +244,15 @@ godot --headless --path . --script res://src/test/smoke_session_shell.gd
# PLAN-verb smoke: recognize, request reason, body camera, warning, blocking # PLAN-verb smoke: recognize, request reason, body camera, warning, blocking
godot --headless --path . --script res://src/test/smoke_plan_verbs.gd godot --headless --path . --script res://src/test/smoke_plan_verbs.gd
# Crowd smoke: rubbernecking on aggression, panic clearing the entrance
godot --headless --path . --script res://src/test/smoke_crowd.gd
# Emergency smoke: unjustified call, investigation note, evacuation aftermath
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
``` ```
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.
@@ -263,7 +305,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: **98 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) - 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)
- 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`)
@@ -272,10 +314,15 @@ Implemented so far:
- Routine traffic despawns after a while at its waypoint so the crowd cap no longer fills with idle standers - Routine traffic despawns after a while at its waypoint so the crowd cap no longer fills with idle standers
- PLAN verbs: eye contact (distinct Level 1 cue), recognizing a person as a player decision recorded as access evidence, a body-camera flag that a formal warning arms (and `B` toggles), blocking that physically stations the guard between the person and the door, and a Level 3 request-reason ask answered from the NPC's `dialogue_lines` - PLAN verbs: eye contact (distinct Level 1 cue), recognizing a person as a player decision recorded as access evidence, a body-camera flag that a formal warning arms (and `B` toggles), blocking that physically stations the guard between the person and the door, and a Level 3 request-reason ask answered from the NPC's `dialogue_lines`
- Incidents are now actually opened on focus and closed at shift end, so player actions, access evidence, escalation levels, and unresolved interactions reach the review (previously every recorded event was dropped) - Incidents are now actually opened on focus and closed at shift end, so player actions, access evidence, escalation levels, and unresolved interactions reach the review (previously every recorded event was dropped)
- Crowd presentation: aggressive behavior and responder arrivals make idle bystanders stop and face the incident; panic sends people away from the door (and they leave the scene). A busy 16-NPC entrance measured ~1.2 ms/frame, so no LOD/culling was needed at the current cap
- Distant skyline ring: four alpha-scissored planes at 200 m read as a hazy city on the horizon (the baked backdrop used to show as a black slab), with a larger ground plane so the buildings meet the street
- 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
- 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: body-camera replay timeline, crowd rubbernecking, emergency aftermath, 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
@@ -302,9 +349,14 @@ Not yet implemented: body-camera replay timeline, crowd rubbernecking, emergency
21. `Warn` turns the body camera on (REC overlay appears); `B` toggles it manually. 21. `Warn` turns the body camera on (REC overlay appears); `B` toggles it manually.
22. `Block` moves the guard between the person and the door. 22. `Block` moves the guard between the person and the door.
23. The shift review reports the incident count, including interactions left unresolved. 23. The shift review reports the incident count, including interactions left unresolved.
24. `Esc` with nothing open pauses the shift; resume / restart / settings / quit-to-menu all work, and settings changes persist across a restart. 24. Escalating on someone (or a responder arriving) makes idle bystanders stop and face the incident.
25. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run). 25. Repeated alarms panic the crowd and nearby people clear away from the door instead of standing around.
26. When the shift timer reaches 0, the shift review screen shows grade, incident summary, and persisted shift history; "Run another shift" returns to the venue menu. 26. `Esc` with nothing open pauses the shift; resume / restart / settings / quit-to-menu all work, and settings changes persist across a restart.
27. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run).
28. An unjustified emergency call (calm person, no weapon) logs an investigation and costs reputation.
29. After an evacuation outcome the door is locked and NPCs are leaving the entrance.
30. The shift review's "Body camera timeline" button steps through the recorded events (Left/Right or the buttons) and Esc returns to the review.
31. When the shift timer reaches 0, the shift review screen shows grade, incident summary, responder outcomes, investigations, and persisted shift history; "Run another shift" returns to the venue menu.
## Development Workflow ## Development Workflow
+15 -27
View File
@@ -1,35 +1,11 @@
# TODO # TODO
Godot 4.3 prototype. Release **v0.2.3** is tagged; CI builds the installers and AppImage. Godot 4.3 prototype. Release **v0.2.6** is tagged. Headless suite **120/120**, plus
Headless suite **98/98**, plus `smoke_session_shell.gd`, `smoke_plan_verbs.gd`, and the windowed `smoke_session_shell.gd`, `smoke_plan_verbs.gd`, `smoke_crowd.gd`, `smoke_emergency.gd`,
focus/radial smoke. `smoke_timeline.gd`, and the windowed focus/radial smoke.
Host `REQUIRE_SIGNIN_VIEW` stays deferred. Host `REQUIRE_SIGNIN_VIEW` stays deferred.
## Crowd presentation
`CrowdSystem` is a mood/attention float. PLAN wants people watching, gathering, and panicking.
- [ ] On `aggressive_behavior` / `emergency_responder`, nearby idle NPCs stop and face the
incident (rubberneck). On `crowd_panic`, they move away from the door.
- [ ] Profile a busy entrance with textured materials and props; add LOD or culling for distant
NPCs if frame time drops.
## Emergency aftermath
Responder outcomes (`command` / `restraint` / `evacuation` / `arrest`) are log lines. PLAN also
wants evacuation, a closed street, and an investigation when the call was unjustified.
- [ ] `evacuation` outcome actually clears the entrance (NPCs leave, door stays locked).
- [ ] Unjustified emergency (low danger, no weapon) writes an investigation note on the shift
review and applies the existing false-report / reputation hit.
- [ ] Review copy names the responder outcome, not only battery and crowd %.
## Body-camera replay
- [ ] 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 ## NPC theater
Intents `sit` / `photo` / `film` / `perform` already pick waypoints. ADR-0010 rig (face/hair) Intents `sit` / `photo` / `film` / `perform` already pick waypoints. ADR-0010 rig (face/hair)
@@ -75,6 +51,18 @@ Verified via API+host credentials (2026-10-31):
- [ ] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting). - [ ] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
## Screen design references (QA tooling)
- [x] `tools/screen_designs.py generate` writes one reference design per screen
(from the `docs/` descriptions) to `docs/screens/designs/`, with an offline
`--draft` wireframe mode and an OpenRouter `--ai` mockup mode.
- [x] `tools/screen_designs.py compare` checks `docs/screenshots/*.png` against the
designs (aspect ratio, size, blank-frame, and layout/palette/detail drift).
- [ ] Vision-model judge for semantic screenshot comparison. Pixel and perceptual
metrics cannot decide whether a frame's composition is correct; a follow-up
should send the capture + reference design to a vision model and ask whether
they depict the same screen.
## Tooling (when an editor workflow exists) ## Tooling (when an editor workflow exists)
- [ ] IDE/editor-based GUT integration. Until then the custom headless runner stays. - [ ] IDE/editor-based GUT integration. Until then the custom headless runner stays.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

+16 -2
View File
@@ -1,5 +1,5 @@
{ {
"hud.controls": "LMB/Space action menu · A/D or arrows highlight · Space confirm · Esc/RMB cancel · R emergency · E inspect · C wand · Q polite · B body camera · 1-6 shortcuts · Tab cycle · G light · L lock · V venue · F debug", "hud.controls": "LMB/Space wheel · A/D step · Space confirm · Esc cancel · R emergency · E inspect · C wand · Q polite · B camera · Tab cycle · G light · L lock · F debug",
"radial.raise_hand": "Stop", "radial.raise_hand": "Stop",
"radial.eye_contact": "Look", "radial.eye_contact": "Look",
"radial.point": "Point", "radial.point": "Point",
@@ -35,6 +35,9 @@
"radio.emergency_dispatched": "Emergency responder dispatched.", "radio.emergency_dispatched": "Emergency responder dispatched.",
"radio.easter_egg": "¿Dónde está la mota?", "radio.easter_egg": "¿Dónde está la mota?",
"responder.arrived": "Responder arrives and %ss the situation.", "responder.arrived": "Responder arrives and %ss the situation.",
"responder.evacuated": "The entrance is cleared and the door is locked while the block is secured.",
"investigation.unjustified": "Emergency call against %s reviewed: no weapon, low danger.",
"investigation.started": "Your emergency call is flagged for investigation.",
"react.comply": "%s complies and steps back.", "react.comply": "%s complies and steps back.",
"react.leave": "%s turns and leaves.", "react.leave": "%s turns and leaves.",
"react.argue": "%s argues with you.", "react.argue": "%s argues with you.",
@@ -62,5 +65,16 @@
"flashlight.on": "Flashlight on.", "flashlight.on": "Flashlight on.",
"flashlight.off": "Flashlight off.", "flashlight.off": "Flashlight off.",
"hud.venue_switched": "Venue: %s", "hud.venue_switched": "Venue: %s",
"wand.recharge": "Wand scanner is recharging…" "wand.recharge": "Wand scanner is recharging…",
"review.responder": "Responder outcomes: %s",
"review.investigations": "Investigations: %s",
"review.none": "none",
"timeline.title": "Body Camera Timeline",
"timeline.position": "Event %d of %d",
"timeline.empty": "No recorded events this shift.",
"timeline.prev": "◀ Previous",
"timeline.next": "Next ▶",
"timeline.close": "Close",
"timeline.hint": "Left/Right step through events · Esc closes · no live replay yet",
"review.timeline_button": "Body camera timeline"
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 953 KiB

After

Width:  |  Height:  |  Size: 986 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

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.3" config/version="0.2.6"
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")
+14
View File
@@ -45,6 +45,20 @@ func apply_event(event_type: String) -> void:
"event": event_type, "event": event_type,
}) })
## Presentation cue for the avatars after a crowd event (PLAN: Public Crowd
## Reaction). "watch" = rubberneck at the incident, "flee" = move away from the
## door, "" = no reaction. Pure so it is headless-testable.
static func reaction_for_event(event_type: String) -> String:
match event_type:
"aggressive_behavior", "visible_weapon", "emergency_responder":
return "watch"
_:
return ""
## True once the crowd is panicking hard enough that people should clear out.
func should_flee() -> bool:
return crowd_panic
func summary() -> Dictionary: func summary() -> Dictionary:
return { return {
"mood": mood, "mood": mood,
+12
View File
@@ -21,6 +21,8 @@ signal phase_changed(phase: int)
var scenario: VenueScenario = null var scenario: VenueScenario = null
var duration_s: float = 900.0 var duration_s: float = 900.0
var elapsed_s: float = 0.0 var elapsed_s: float = 0.0
## Wall-clock start, so the shift review can show event times as mm:ss.
var started_at_ms: int = 0
var current_phase: int = Rules.Phase.EARLY var current_phase: int = Rules.Phase.EARLY
var running := false var running := false
@@ -32,6 +34,10 @@ var crowd := CrowdSystem.new()
var scheduler := EventScheduler.new() var scheduler := EventScheduler.new()
var spawned_count := 0 var spawned_count := 0
## Emergency aftermath (PLAN.md: Level 5). Responder resolutions and any
## investigation notes for calls that were not justified.
var responder_outcomes: Array[String] = []
var investigations: Array[String] = []
var spawn_seed := 0 var spawn_seed := 0
var _shift_finished := false var _shift_finished := false
## The scheduler rolls once per simulated second. `tick()` runs at physics rate, ## The scheduler rolls once per simulated second. `tick()` runs at physics rate,
@@ -45,6 +51,8 @@ func setup(p_scenario: VenueScenario, p_duration_s: float = 900.0, p_seed: int =
spawn_seed = p_seed spawn_seed = p_seed
scheduler.setup(scenario, p_seed) scheduler.setup(scenario, p_seed)
event_bus = EventBus.new() event_bus = EventBus.new()
responder_outcomes.clear()
investigations.clear()
radio = RadioSystem.new() radio = RadioSystem.new()
reputation = ReputationEvaluator.new() reputation = ReputationEvaluator.new()
crowd = CrowdSystem.new() crowd = CrowdSystem.new()
@@ -59,6 +67,7 @@ func start() -> void:
return return
running = true running = true
_shift_finished = false _shift_finished = false
started_at_ms = Time.get_ticks_msec()
elapsed_s = 0.0 elapsed_s = 0.0
_spawn_accum_s = 0.0 _spawn_accum_s = 0.0
current_phase = _phase_for_time(0.0) current_phase = _phase_for_time(0.0)
@@ -131,6 +140,7 @@ func build_review() -> Dictionary:
"scenario_id": scenario.id if scenario else "", "scenario_id": scenario.id if scenario else "",
"duration_s": duration_s, "duration_s": duration_s,
"elapsed_s": elapsed_s, "elapsed_s": elapsed_s,
"started_at_ms": started_at_ms,
"phase_at_end": current_phase, "phase_at_end": current_phase,
"spawned_count": spawned_count, "spawned_count": spawned_count,
"incidents": recorder.to_dict(), "incidents": recorder.to_dict(),
@@ -140,6 +150,8 @@ func build_review() -> Dictionary:
"battery": radio.battery, "battery": radio.battery,
"emergency_cooldown_s": radio.emergency_cooldown_remaining, "emergency_cooldown_s": radio.emergency_cooldown_remaining,
}, },
"responder_outcomes": responder_outcomes.duplicate(),
"investigations": investigations.duplicate(),
"completed_at_ms": Time.get_ticks_msec(), "completed_at_ms": Time.get_ticks_msec(),
} }
+148 -5
View File
@@ -9,6 +9,7 @@ const AccessArtifact := preload("res://src/core/access_artifact.gd")
const AccessLoadout := preload("res://src/core/access_loadout.gd") const AccessLoadout := preload("res://src/core/access_loadout.gd")
const AccessVerifier := preload("res://src/core/access_verifier.gd") const AccessVerifier := preload("res://src/core/access_verifier.gd")
const DoorState := preload("res://src/core/door_state.gd") const DoorState := preload("res://src/core/door_state.gd")
const CrowdSystem := preload("res://src/core/crowd_system.gd")
const WandScreener := preload("res://src/core/wand_screener.gd") const WandScreener := preload("res://src/core/wand_screener.gd")
const RadioSystem := preload("res://src/core/radio_system.gd") const RadioSystem := preload("res://src/core/radio_system.gd")
const ScenarioFactory := preload("res://src/content/scenario_factory.gd") const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
@@ -26,6 +27,7 @@ const ShiftReview := preload("res://src/game/ui/shift_review.gd")
const StartMenu := preload("res://src/game/ui/start_menu.gd") const StartMenu := preload("res://src/game/ui/start_menu.gd")
const PauseOverlay := preload("res://src/game/ui/pause_overlay.gd") const PauseOverlay := preload("res://src/game/ui/pause_overlay.gd")
const SettingsPanel := preload("res://src/game/ui/settings_panel.gd") const SettingsPanel := preload("res://src/game/ui/settings_panel.gd")
const EventTimeline := preload("res://src/game/ui/event_timeline.gd")
var world_builder: WorldBuilder = null var world_builder: WorldBuilder = null
var door := DoorState.new() var door := DoorState.new()
@@ -69,6 +71,8 @@ var shift_review_panel: ShiftReview = null
var start_menu: StartMenu = null var start_menu: StartMenu = null
var pause_overlay: PauseOverlay = null var pause_overlay: PauseOverlay = null
var settings_panel: SettingsPanel = null var settings_panel: SettingsPanel = null
var event_timeline: EventTimeline = null
var _timeline_from_review := false
var _shift_running := false var _shift_running := false
var player_settings: Dictionary = {} var player_settings: Dictionary = {}
var _focused_avatar: Node3D = null var _focused_avatar: Node3D = null
@@ -133,7 +137,8 @@ func _ready() -> void:
coordinator.setup(selected_scenario, scenario_duration_s, 4242) coordinator.setup(selected_scenario, scenario_duration_s, 4242)
coordinator.npc_spawn_requested.connect(_spawn_npc) coordinator.npc_spawn_requested.connect(_spawn_npc)
coordinator.shift_ended.connect(_on_shift_ended) coordinator.shift_ended.connect(_on_shift_ended)
coordinator.radio.responder_arrived.connect(_on_responder_arrived) _connect_radio()
_connect_crowd()
_build_ui() _build_ui()
_last_phase = -1 _last_phase = -1
@@ -160,10 +165,13 @@ func _build_guard() -> GuardController:
flashlight.name = "Flashlight" flashlight.name = "Flashlight"
flashlight.visible = false flashlight.visible = false
flashlight.position = Vector3(0, -0.15, -0.4) flashlight.position = Vector3(0, -0.15, -0.4)
flashlight.rotation_degrees = Vector3(-10, 0, 0) # Aim straight ahead: tilting it down made the cone hit the ground right in
flashlight.spot_range = 18.0 # front of the guard instead of the person or door being checked.
flashlight.spot_angle = 28.0 flashlight.rotation_degrees = Vector3.ZERO
flashlight.light_energy = 2.5 flashlight.spot_range = 22.0
flashlight.spot_angle = 30.0
flashlight.light_energy = 4.0
flashlight.spot_attenuation = 0.6
head.add_child(flashlight) head.add_child(flashlight)
var collider := CollisionShape3D.new() var collider := CollisionShape3D.new()
var shape := CapsuleShape3D.new() var shape := CapsuleShape3D.new()
@@ -217,6 +225,8 @@ func _reset_shift_state() -> void:
hud.set_body_camera(false) hud.set_body_camera(false)
scenario_duration_s = selected_scenario.shift_duration_s scenario_duration_s = selected_scenario.shift_duration_s
coordinator.setup(selected_scenario, scenario_duration_s, 4242) coordinator.setup(selected_scenario, scenario_duration_s, 4242)
_connect_radio()
_connect_crowd()
_last_phase = -1 _last_phase = -1
_apply_phase_lighting() _apply_phase_lighting()
@@ -318,6 +328,7 @@ func _build_ui() -> void:
add_child(shift_review_panel) add_child(shift_review_panel)
shift_review_panel.hide() shift_review_panel.hide()
shift_review_panel.restart_requested.connect(_on_review_restart) shift_review_panel.restart_requested.connect(_on_review_restart)
shift_review_panel.timeline_requested.connect(_open_timeline)
shift_review_panel.visible = false shift_review_panel.visible = false
pause_overlay = PauseOverlay.new() pause_overlay = PauseOverlay.new()
@@ -333,6 +344,11 @@ func _build_ui() -> void:
settings_panel.hide() settings_panel.hide()
settings_panel.applied.connect(_on_settings_applied) settings_panel.applied.connect(_on_settings_applied)
event_timeline = EventTimeline.new()
add_child(event_timeline)
event_timeline.hide()
event_timeline.closed.connect(_on_timeline_closed)
start_menu = StartMenu.new() start_menu = StartMenu.new()
add_child(start_menu) add_child(start_menu)
start_menu.hide() start_menu.hide()
@@ -340,6 +356,23 @@ func _build_ui() -> void:
start_menu.settings_requested.connect(_open_settings) start_menu.settings_requested.connect(_open_settings)
start_menu.quit_requested.connect(func(): get_tree().quit()) start_menu.quit_requested.connect(func(): get_tree().quit())
## Body-camera timeline from the review screen (ADR-0005 events, no 3D replay).
func _open_timeline(review: Dictionary) -> void:
if event_timeline == null:
return
# Hide the review dim so the timeline is not stacked under two overlays.
_timeline_from_review = shift_review_panel != null and shift_review_panel.visible
if shift_review_panel:
shift_review_panel.visible = false
event_timeline.show_timeline(review)
_refresh_freeze()
func _on_timeline_closed() -> void:
if _timeline_from_review and shift_review_panel:
shift_review_panel.visible = true
_timeline_from_review = false
_refresh_freeze()
## "Run another shift" on the review screen returns to the venue menu. ## "Run another shift" on the review screen returns to the venue menu.
func _on_review_restart() -> void: func _on_review_restart() -> void:
_show_start_menu() _show_start_menu()
@@ -474,12 +507,96 @@ func _on_responder_arrived(call: Dictionary) -> void:
scene_root.add_child(avatar) scene_root.add_child(avatar)
npcs.append(avatar) npcs.append(avatar)
hud.log(Strings.fmt("responder.arrived", [outcome])) hud.log(Strings.fmt("responder.arrived", [outcome]))
coordinator.responder_outcomes.append(outcome)
coordinator.recorder.add_event(call.get("report", {}).get("npc_id", ""), { coordinator.recorder.add_event(call.get("report", {}).get("npc_id", ""), {
"type": "responder_action", "type": "responder_action",
"outcome": outcome, "outcome": outcome,
"call_type": call.get("type", "assist"), "call_type": call.get("type", "assist"),
}) })
coordinator.crowd.apply_event("emergency_responder" if call.get("type", "") == "emergency" else "calm_professionalism") coordinator.crowd.apply_event("emergency_responder" if call.get("type", "") == "emergency" else "calm_professionalism")
if outcome == "evacuation":
_evacuate_entrance()
## Responder outcome "evacuation": the entrance is cleared and the door stays
## locked while the block is secured (PLAN.md: Level 5 Emergency Response).
func _evacuate_entrance() -> void:
if door and not door.locked:
door.set_locked(true)
if world_builder:
world_builder.set_door_locked(true)
if hud:
hud.set_door(true)
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
for npc in npcs:
if not is_instance_valid(npc):
continue
if npc.has_method("start_flee"):
npc.start_flee(door_pos)
var controller: Object = npc.get_npc_controller() if npc.has_method("get_npc_controller") else null
if controller != null:
coordinator.recorder.add_event(controller.npc_id, {
"type": "evacuation",
"outcome": "evacuated",
})
if controller.leave_requested == false:
controller.leave_requested = true
coordinator.reputation.apply("security_failure", -3.0, "entrance evacuated by responder")
if hud:
hud.log(Strings.lookup("responder.evacuated"))
## Responder dispatch. `coordinator.setup()` builds a new RadioSystem for each
## shift, so the arrival signal must be re-connected after every setup or radio
## calls are silently resolved by nobody.
func _connect_radio() -> void:
if coordinator == null or coordinator.radio == null:
return
if not coordinator.radio.responder_arrived.is_connected(_on_responder_arrived):
coordinator.radio.responder_arrived.connect(_on_responder_arrived)
## Crowd presentation (PLAN: Public Crowd Reaction). The crowd system is a
## mood/attention float; this turns its events into avatar behavior.
func _connect_crowd() -> void:
# CrowdSystem routes its updates through the EventBus (`event_bus.crowd_changed`);
# its own `changed` signal is not emitted by apply_event().
if coordinator == null or coordinator.event_bus == null:
return
if not coordinator.event_bus.crowd_changed.is_connected(_on_crowd_changed):
coordinator.event_bus.crowd_changed.connect(_on_crowd_changed)
func _on_crowd_changed(payload: Dictionary) -> void:
var event_type := str(payload.get("event", ""))
var panic := bool(payload.get("panic", false))
# Panic wins: people clear away from the entrance.
if panic or event_type == "crowd_panic":
_flee_crowd()
return
if CrowdSystem.reaction_for_event(event_type) == "watch":
_rubberneck_crowd()
func _incident_point() -> Vector3:
if targeted_npc is Node3D and is_instance_valid(targeted_npc):
return (targeted_npc as Node3D).global_position
if guard:
return guard.global_position
return Vector3(0, 0, 1.2)
## Idle bystanders stop and face the incident instead of walking on.
func _rubberneck_crowd() -> void:
var point := _incident_point()
for npc in npcs:
if not is_instance_valid(npc) or not npc.has_method("start_rubberneck"):
continue
if npc == targeted_npc:
continue
npc.start_rubberneck(point)
## Panic: everyone nearby moves away from the door.
func _flee_crowd() -> void:
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
for npc in npcs:
if not is_instance_valid(npc) or not npc.has_method("start_flee"):
continue
npc.start_flee(door_pos)
func _responder_profile() -> NpcProfile: func _responder_profile() -> NpcProfile:
var p := NpcProfile.new() var p := NpcProfile.new()
@@ -687,6 +804,9 @@ func _close_top_modal() -> bool:
hud.set_action_hint("") hud.set_action_hint("")
_refresh_freeze() _refresh_freeze()
return true return true
if event_timeline and event_timeline.visible:
event_timeline.close()
return true
return false return false
func _open_radial() -> void: func _open_radial() -> void:
@@ -766,6 +886,8 @@ func _refresh_freeze() -> void:
freeze = true freeze = true
if settings_panel and settings_panel.visible: if settings_panel and settings_panel.visible:
freeze = true freeze = true
if event_timeline and event_timeline.visible:
freeze = true
if not _shift_running: if not _shift_running:
freeze = true freeze = true
if guard: if guard:
@@ -1022,12 +1144,33 @@ func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
if emergency: if emergency:
coordinator.crowd.apply_event("emergency_responder") coordinator.crowd.apply_event("emergency_responder")
hud.log(Strings.lookup("radio.emergency_dispatched")) hud.log(Strings.lookup("radio.emergency_dispatched"))
# A call that the situation did not justify is reviewed afterwards
# (PLAN.md: Level 5 consequences).
if _is_unjustified_emergency():
var who: String = "the person"
var controller: Object = _targeted_controller()
if controller and controller.profile:
who = str(controller.profile.display_name)
coordinator.investigations.append(Strings.fmt("investigation.unjustified", [who]))
coordinator.recorder.add_event(current_incident_npc_id, {
"type": "investigation_opened",
"reason": "unjustified emergency call",
})
coordinator.reputation.apply("false_report", -6.0, "unjustified emergency call")
hud.log(Strings.lookup("investigation.started"))
else: else:
coordinator.crowd.apply_event("calm_professionalism") coordinator.crowd.apply_event("calm_professionalism")
if audio: if audio:
audio.play_radio_response() audio.play_radio_response()
_maybe_radio_easter_egg() _maybe_radio_easter_egg()
## True when an emergency call is not supported by the situation: no weapon
## seen and low danger (PLAN.md: unjustified call consequences).
func _is_unjustified_emergency() -> bool:
var weapons := bool(current_npc_truth.get("weapons_seen", false))
var danger := int(current_npc_truth.get("danger_level", 0))
return not weapons and danger <= 1
## Rare hidden radio transmission: a stray voice asks "¿Dónde está la mota?" ## Rare hidden radio transmission: a stray voice asks "¿Dónde está la mota?"
## over the radio. Fires on a small random chance after a successful call. ## over the radio. Fires on a small random chance after a successful call.
func _maybe_radio_easter_egg() -> void: func _maybe_radio_easter_egg() -> void:
+105 -6
View File
@@ -17,6 +17,14 @@ var arrived := false
var idle_s := 0.0 var idle_s := 0.0
var lifetime_s := 90.0 var lifetime_s := 90.0
var ephemeral := true var ephemeral := true
## Crowd presentation (PLAN: Public Crowd Reaction). "watch" rubbernecks at the
## incident, "flee" clears away from it, "" is normal navigation.
var reaction_mode := ""
var reaction_point := Vector3.ZERO
var _reaction_timer := 0.0
const RUBBERNECK_S := 12.0
const FLEE_S := 20.0
const FLEE_SPEED := 2.4
var agent: NavigationAgent3D = null var agent: NavigationAgent3D = null
## Set once the nav agent proves it has a path; direct movement is the fallback. ## Set once the nav agent proves it has a path; direct movement is the fallback.
var _nav_usable := false var _nav_usable := false
@@ -225,10 +233,78 @@ func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) ->
mi.material_override = _material(color) mi.material_override = _material(color)
parent.add_child(mi) parent.add_child(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.
func start_rubberneck(at: Vector3) -> bool:
if not arrived or npc.leave_requested or leave_requested_by_lifetime:
return false
# Someone actually waiting at the entrance is being dealt with, not a bystander.
# Avatars live under the identity SceneRoot, so `position` matches world space
# and also works before the node enters the tree (tests).
if awaiting_interaction and (position - door_position).length() < 3.0:
return false
if reaction_mode == "flee":
return false
reaction_mode = "watch"
reaction_point = at
_reaction_timer = RUBBERNECK_S
velocity = Vector3.ZERO
return true
## Panic: clear away from the incident (usually the door) in a straight line.
func start_flee(from: Vector3) -> void:
if npc.leave_requested or leave_requested_by_lifetime:
return
reaction_mode = "flee"
reaction_point = from
_reaction_timer = FLEE_S
var away := global_position - from
away.y = 0.0
if away.length() < 0.5:
away = Vector3(0.0, 0.0, 1.0)
target_position = global_position + away.normalized() * 25.0
target_position.y = 0.5
arrived = false
awaiting_interaction = false
func reaction_is_active() -> bool:
return reaction_mode != ""
func _tick_reaction(delta: float) -> void:
if reaction_mode == "":
return
_reaction_timer -= delta
if reaction_mode == "flee":
if _reaction_timer <= 0.0 or (global_position - target_position).length() < 1.5:
reaction_mode = ""
queue_free()
return
if _reaction_timer <= 0.0:
reaction_mode = ""
## Turn the body toward the reaction point without moving.
func _face_reaction_point(delta: float) -> void:
var to_point := reaction_point - global_position
to_point.y = 0.0
if to_point.length() < 0.1:
return
var target_yaw := atan2(-to_point.x, -to_point.z)
rotation.y = lerp_angle(rotation.y, target_yaw, clampf(delta * 4.0, 0.0, 1.0))
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
if npc == null or npc.fsm == null: if npc == null or npc.fsm == null:
return return
_advance_fsm(delta) _advance_fsm(delta)
_tick_reaction(delta)
if reaction_mode == "watch":
velocity = Vector3.ZERO
_face_reaction_point(delta)
_animate(delta)
return
if reaction_mode == "flee":
_move_toward_target(delta)
_animate(delta)
return
_move_toward_target(delta) _move_toward_target(delta)
_animate(delta) _animate(delta)
@@ -264,7 +340,7 @@ func _animate(delta: float) -> void:
func _advance_fsm(delta: float) -> void: func _advance_fsm(delta: float) -> void:
# Routine traffic despawns after a while once it has finished its walk, so # Routine traffic despawns after a while once it has finished its walk, so
# the crowd cap is not permanently occupied by idle standers. # the crowd cap is not permanently occupied by idle standers.
if ephemeral and arrived and npc.fsm.state != "LEAVING": if ephemeral and arrived and npc.fsm.current_state != "LEAVING":
idle_s += delta idle_s += delta
if idle_s >= lifetime_s: if idle_s >= lifetime_s:
leave_requested_by_lifetime = true leave_requested_by_lifetime = true
@@ -291,7 +367,11 @@ func _move_toward_target(delta: float) -> void:
if agent == null: if agent == null:
_move_direct(delta) _move_direct(delta)
return return
agent.target_position = target_position # Re-assigning the target every physics frame invalidates the path, so
# get_next_path_position() keeps returning the current position and the
# avatar never moves. Only update the agent when the target changes.
if agent.target_position != target_position:
agent.target_position = target_position
if agent.is_navigation_finished(): if agent.is_navigation_finished():
if (global_position - target_position).length() < 3.0: if (global_position - target_position).length() < 3.0:
_arrive() _arrive()
@@ -308,14 +388,32 @@ func _move_toward_target(delta: float) -> void:
_move_direct(delta) _move_direct(delta)
return return
var next := agent.get_next_path_position() var next := agent.get_next_path_position()
# Godot reports the current position as the first path point (on the navmesh
# plane, so below the body) and only advances past it once the agent is within
# path_desired_distance. Steering at the point we are standing on gives a
# zero-length direction, and steering at any point merely "far enough" away can
# pick the old start behind us. Follow the first path point that is genuinely
# closer to the destination than we are.
var path := agent.get_current_navigation_path()
if path.size() >= 2:
var goal: Vector3 = path[path.size() - 1]
var here_to_goal := Vector2(goal.x - global_position.x, goal.z - global_position.z).length()
for i in range(1, path.size()):
var candidate: Vector3 = path[i]
candidate.y = global_position.y
var candidate_to_goal := Vector2(goal.x - candidate.x, goal.z - candidate.z).length()
if candidate_to_goal < here_to_goal - 0.05:
next = candidate
break
var dir := (next - global_position) var dir := (next - global_position)
dir.y = 0.0 dir.y = 0.0
if dir.length() < 0.2: if dir.length() < 0.2:
velocity = Vector3.ZERO velocity = Vector3.ZERO
return return
dir = dir.normalized() dir = dir.normalized()
velocity.x = dir.x * 1.3 var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3
velocity.z = dir.z * 1.3 velocity.x = dir.x * speed
velocity.z = dir.z * speed
velocity.y -= 9.8 * delta velocity.y -= 9.8 * delta
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP) look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
move_and_slide() move_and_slide()
@@ -329,8 +427,9 @@ func _move_direct(delta: float) -> void:
_arrive() _arrive()
return return
var dir := to_target.normalized() var dir := to_target.normalized()
velocity.x = dir.x * 1.3 var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3
velocity.z = dir.z * 1.3 velocity.x = dir.x * speed
velocity.z = dir.z * speed
velocity.y -= 9.8 * delta velocity.y -= 9.8 * delta
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP) look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
move_and_slide() move_and_slide()
+211
View File
@@ -0,0 +1,211 @@
class_name EventTimeline
extends CanvasLayer
## Body-camera timeline for the shift review (PLAN.md: Evidence and Body Camera).
## First version is a text timeline of the structured incident events ADR-0005
## already stores: step through them with the arrow keys or the buttons. No live
## 3D replay.
signal closed()
const Strings := preload("res://src/core/strings.gd")
var events: Array[Dictionary] = []
var index := 0
var _list_label: Label = null
var _detail_label: Label = null
var _counter_label: Label = null
var _root: Control = null
func _init() -> void:
name = "EventTimeline"
layer = 32
_build()
hide()
func _build() -> void:
_root = Control.new()
_root.name = "TimelineRoot"
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_root)
var dim := ColorRect.new()
dim.color = Color(0.02, 0.03, 0.05, 0.9)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
_root.add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.add_child(center)
var column := VBoxContainer.new()
column.custom_minimum_size = Vector2(760, 0)
column.add_theme_constant_override("separation", 8)
center.add_child(column)
var title := Label.new()
title.text = Strings.lookup("timeline.title")
title.add_theme_font_size_override("font_size", 26)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
column.add_child(title)
_counter_label = Label.new()
_counter_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_counter_label.add_theme_color_override("font_color", Color(0.72, 0.78, 0.9))
column.add_child(_counter_label)
_detail_label = Label.new()
_detail_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_detail_label.custom_minimum_size = Vector2(760, 48)
_detail_label.add_theme_font_size_override("font_size", 18)
column.add_child(_detail_label)
_list_label = Label.new()
_list_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
_list_label.autowrap_mode = TextServer.AUTOWRAP_OFF
_list_label.custom_minimum_size = Vector2(760, 0)
_list_label.add_theme_font_size_override("font_size", 13)
_list_label.add_theme_color_override("font_color", Color(0.8, 0.84, 0.9))
column.add_child(_list_label)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 10)
column.add_child(row)
var prev := Button.new()
prev.name = "PrevButton"
prev.text = Strings.lookup("timeline.prev")
prev.pressed.connect(func(): step(-1))
row.add_child(prev)
var next := Button.new()
next.name = "NextButton"
next.text = Strings.lookup("timeline.next")
next.pressed.connect(func(): step(1))
row.add_child(next)
var close := Button.new()
close.name = "CloseButton"
close.text = Strings.lookup("timeline.close")
close.pressed.connect(_on_close)
row.add_child(close)
var hint := Label.new()
hint.text = Strings.lookup("timeline.hint")
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
hint.add_theme_color_override("font_color", Color(0.55, 0.58, 0.65))
column.add_child(hint)
## Flatten every incident's event stream into one time-ordered list.
func flatten(review: Dictionary) -> Array[Dictionary]:
var out: Array[Dictionary] = []
var incidents: Dictionary = review.get("incidents", {})
var list: Array = incidents.get("incidents", [])
for incident in list:
var incident_id := str(incident.get("id", ""))
var npc_id := str(incident.get("npc_id", ""))
for event in incident.get("events", []):
var row: Dictionary = (event as Dictionary).duplicate()
row["incident_id"] = incident_id
row["npc_id"] = npc_id
out.append(row)
out.sort_custom(func(a, b): return int(a.get("at_ms", 0)) < int(b.get("at_ms", 0)))
return out
func show_timeline(review: Dictionary) -> void:
events = flatten(review)
index = 0
visible = true
_root.visible = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
_refresh()
func step(direction: int) -> void:
if events.is_empty():
return
index = clampi(index + direction, 0, events.size() - 1)
_refresh()
func close() -> void:
visible = false
_root.visible = false
closed.emit()
func _on_close() -> void:
close()
func _refresh() -> void:
if events.is_empty():
_counter_label.text = Strings.lookup("timeline.empty")
_detail_label.text = ""
_list_label.text = ""
return
var event: Dictionary = events[index]
_counter_label.text = Strings.fmt("timeline.position", [index + 1, events.size()])
_detail_label.text = describe(event)
var lines: Array[String] = []
var from := maxi(0, index - 6)
var to := mini(events.size(), from + 13)
for i in range(from, to):
var marker := ">" if i == index else " "
lines.append("%s %s" % [marker, describe(events[i])])
_list_label.text = "\n".join(lines)
## One-line summary of a recorded event. Public so it is headless-testable.
func describe(event: Dictionary) -> String:
var type := str(event.get("type", "event"))
var npc := str(event.get("npc_id", ""))
var stamp := _stamp(int(event.get("at_ms", 0)))
var detail := ""
match type:
"player_action":
detail = "%s (L%d)" % [str(event.get("action", "")), int(event.get("level", 0))]
"access_check":
var evidence: Dictionary = event.get("evidence", {})
detail = "decided %s (verified %s)" % [
"allow" if bool(evidence.get("decided", false)) else "deny",
"yes" if bool(evidence.get("verified_granted", false)) else "no",
]
"recognition_decision":
detail = "recognized"
"reason_requested":
var reply := str(event.get("reply", ""))
detail = "reason: \"%s\"" % (reply if reply != "" else "no answer")
"body_camera":
detail = "body camera %s (%s)" % [
"on" if bool(event.get("on", false)) else "off",
str(event.get("reason", "")),
]
"responder_action":
detail = "%s response: %s" % [str(event.get("call_type", "assist")), str(event.get("outcome", ""))]
"evacuation":
detail = "evacuated"
"investigation_opened":
detail = "investigation: %s" % str(event.get("reason", ""))
_:
detail = type
return "%s %s %s" % [stamp, npc, detail]
func _stamp(at_ms: int) -> String:
if at_ms <= 0:
return "--:--"
var total := at_ms / 1000
return "%02d:%02d" % [total / 60, total % 60]
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventKey and event.pressed and not event.echo:
match event.keycode:
KEY_LEFT, KEY_A:
step(-1)
get_viewport().set_input_as_handled()
KEY_RIGHT, KEY_D:
step(1)
get_viewport().set_input_as_handled()
KEY_ESCAPE:
close()
get_viewport().set_input_as_handled()
+18
View File
@@ -2,7 +2,13 @@ class_name ShiftReview
extends CanvasLayer extends CanvasLayer
## End-of-shift review screen (PLAN.md: Evidence and Body Camera, Shift Review). ## End-of-shift review screen (PLAN.md: Evidence and Body Camera, Shift Review).
const Strings := preload("res://src/core/strings.gd")
signal restart_requested() signal restart_requested()
signal timeline_requested(review: Dictionary)
## Kept so the timeline button can hand the full review back to the game manager.
var _review: Dictionary = {}
var report_label: Label var report_label: Label
@@ -32,6 +38,13 @@ func _init() -> void:
report_label.custom_minimum_size = Vector2(720, 0) report_label.custom_minimum_size = Vector2(720, 0)
center.add_child(report_label) center.add_child(report_label)
var timeline := Button.new()
timeline.name = "TimelineButton"
timeline.text = Strings.lookup("review.timeline_button")
timeline.pressed.connect(func(): timeline_requested.emit(_review))
timeline.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
center.add_child(timeline)
var restart := Button.new() var restart := Button.new()
restart.text = "Run another shift" restart.text = "Run another shift"
restart.pressed.connect(func(): restart_requested.emit()) restart.pressed.connect(func(): restart_requested.emit())
@@ -45,10 +58,13 @@ func _init() -> void:
center.add_child(quit_btn) center.add_child(quit_btn)
func show_review(review: Dictionary, scenario: Resource, history: Array = []) -> void: func show_review(review: Dictionary, scenario: Resource, history: Array = []) -> void:
_review = review
var reputation: Dictionary = review.get("reputation", {}) var reputation: Dictionary = review.get("reputation", {})
var incidents: Dictionary = review.get("incidents", {}) var incidents: Dictionary = review.get("incidents", {})
var crowd: Dictionary = review.get("crowd", {}) var crowd: Dictionary = review.get("crowd", {})
var radio: Dictionary = review.get("radio", {}) var radio: Dictionary = review.get("radio", {})
var outcomes: Array = review.get("responder_outcomes", [])
var investigations: Array = review.get("investigations", [])
var incident_list: Array = incidents.get("incidents", []) var incident_list: Array = incidents.get("incidents", [])
var peaceful := 0 var peaceful := 0
for inc in incident_list: for inc in incident_list:
@@ -78,6 +94,8 @@ func show_review(review: Dictionary, scenario: Resource, history: Array = []) ->
"NPCs encountered: %d" % review.get("spawned_count", 0), "NPCs encountered: %d" % review.get("spawned_count", 0),
"Radio battery: %.0f%% emergency cooldown: %.0fs" % [radio.get("battery", 0.0), radio.get("emergency_cooldown_s", 0.0)], "Radio battery: %.0f%% emergency cooldown: %.0fs" % [radio.get("battery", 0.0), radio.get("emergency_cooldown_s", 0.0)],
"Crowd mood: %.0f%% (panic: %s)" % [crowd.get("mood", 0.0) * 100.0, str(crowd.get("panic", false))], "Crowd mood: %.0f%% (panic: %s)" % [crowd.get("mood", 0.0) * 100.0, str(crowd.get("panic", false))],
Strings.fmt("review.responder", [", ".join(outcomes) if outcomes.size() > 0 else Strings.lookup("review.none")]),
Strings.fmt("review.investigations", [" | ".join(investigations) if investigations.size() > 0 else Strings.lookup("review.none")]),
"", "",
"Highlights:", "Highlights:",
] ]
+38 -16
View File
@@ -57,9 +57,11 @@ func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_sh
func _build_ground() -> void: func _build_ground() -> void:
var ground := StaticBody3D.new() var ground := StaticBody3D.new()
ground.name = "Ground" ground.name = "Ground"
_add_box(root, Vector3(160, 0.5, 160), Vector3(0, -0.25, 20), Color(0.22, 0.24, 0.26)) # Large enough that the ground meets the distant skyline ring (no gap of sky
# under the buildings when looking down the boulevard).
_add_box(root, Vector3(500, 0.5, 500), Vector3(0, -0.25, 20), Color(0.22, 0.24, 0.26))
var col := BoxShape3D.new() var col := BoxShape3D.new()
col.size = Vector3(160, 1, 160) col.size = Vector3(500, 1, 500)
var shape_owner := ground.create_shape_owner(ground) var shape_owner := ground.create_shape_owner(ground)
ground.shape_owner_add_shape(shape_owner, col) ground.shape_owner_add_shape(shape_owner, col)
root.add_child(ground) root.add_child(ground)
@@ -165,29 +167,49 @@ func _build_navmesh() -> void:
root.add_child(region) root.add_child(region)
func _build_skyline_backdrop() -> void: func _build_skyline_backdrop() -> void:
## Skyline silhouette backdrop behind the building. The texture has a ## Distant skyline surrounding the block. The texture's sky is transparent
## transparent sky, so only the building silhouettes show; the material must ## (alpha scissor), so only building silhouettes show. Four planes form a
## be set up for alpha or the plane renders as an opaque slab (ADR/TODO). ## ring: a single plane placed behind the building reads as a floating black
## band from the sidewalk.
var backdrop := Node3D.new() var backdrop := Node3D.new()
backdrop.name = "SkylineBackdrop" backdrop.name = "SkylineBackdrop"
var plane := MeshInstance3D.new()
var mesh := PlaneMesh.new()
mesh.size = Vector2(240, 60) # wide, low horizon
plane.mesh = mesh
var mat := StandardMaterial3D.new() var mat := StandardMaterial3D.new()
mat.albedo_texture = T_SKYLINE mat.albedo_texture = T_SKYLINE
mat.texture_repeat = true mat.texture_repeat = true
mat.uv1_scale = Vector3(2.0, 1.0, 1.0) mat.uv1_scale = Vector3(3.0, 1.0, 1.0)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
mat.alpha_scissor_threshold = 0.3 mat.alpha_scissor_threshold = 0.5
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.cull_mode = BaseMaterial3D.CULL_DISABLED mat.cull_mode = BaseMaterial3D.CULL_DISABLED
plane.material_override = mat # Unshaded silhouettes read as pure black at 255-scale; lift the albedo so the
plane.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF # distant skyline sits closer to a hazy daytime city.
plane.position = Vector3(0, 18, -60) # behind and above the building mat.albedo_color = Color(2.6, 2.7, 3.0, 1.0)
backdrop.add_child(plane)
root.add_child(backdrop)
_skyline_mat = mat _skyline_mat = mat
var mesh := PlaneMesh.new()
# PlaneMesh defaults to a horizontal (XZ) plane; the skyline must stand up.
mesh.orientation = PlaneMesh.FACE_Z
mesh.size = Vector2(400, 60)
# Far enough that the skyline reads as background and its top edge stays out
# of the middle of the sky.
const DIST := 200.0
# The texture keeps its buildings in the lower part of the image, so the plane
# center sits at half its height: the building bases meet the ground.
const CENTER_Y := 30.0
var placements := [
[Vector3(0, CENTER_Y, -DIST), 0.0],
[Vector3(0, CENTER_Y, DIST), 0.0],
[Vector3(-DIST, CENTER_Y, 0), PI / 2.0],
[Vector3(DIST, CENTER_Y, 0), PI / 2.0],
]
for placement in placements:
var plane := MeshInstance3D.new()
plane.mesh = mesh
plane.material_override = mat
plane.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
plane.position = placement[0]
plane.rotation.y = placement[1]
backdrop.add_child(plane)
root.add_child(backdrop)
var _skyline_mat: StandardMaterial3D = null var _skyline_mat: StandardMaterial3D = null
+18 -10
View File
@@ -31,6 +31,10 @@ func _process(_delta: float) -> bool:
quit(1) quit(1)
return true return true
if gm != null and not camera_staged: if gm != null and not camera_staged:
# The game boots into the venue menu now; start the Hollywood shift first.
var menu = gm.get("start_menu")
if menu != null and menu.visible:
gm.call("_on_venue_selected", "hollywood_building")
gm.guard.global_position = Vector3(0, 0.1, 10.5) gm.guard.global_position = Vector3(0, 0.1, 10.5)
gm.guard.rotation.y = 0.0 gm.guard.rotation.y = 0.0
gm.guard.head.rotation.x = deg_to_rad(2.0) gm.guard.head.rotation.x = deg_to_rad(2.0)
@@ -52,7 +56,8 @@ func _advance() -> void:
state = 1 state = 1
state_frames = 0 state_frames = 0
1: 1:
# Wait for a nearby NPC so the interaction HUD has a target. # Wait for a nearby NPC so the interaction HUD has a target. NPCs now
# walk in from the street, so this can take a few simulated seconds.
if gm != null and gm.npcs.size() > 0: if gm != null and gm.npcs.size() > 0:
gm.call("_cycle_nearby_target") gm.call("_cycle_nearby_target")
_face_target() _face_target()
@@ -83,24 +88,27 @@ func _advance() -> void:
# Close the radio form, then jump the shift to the LATE phase. # Close the radio form, then jump the shift to the LATE phase.
if gm != null: if gm != null:
gm.radio_panel.hide() gm.radio_panel.hide()
gm.guard.rotation.y = 0.0 gm.hud.set_action_hint("")
gm.guard.head.rotation.x = deg_to_rad(2.0)
gm.coordinator.elapsed_s = 750.0 gm.coordinator.elapsed_s = 750.0
state = 5 state = 5
state_frames = 0 state_frames = 0
5: 5:
# Let the phase transition (and street lamps / marquee) settle, then # Stage the avenue view down the boulevard (not into the wall) and let
# take the night avenue shot. # the night lighting and street lamps settle.
if state_frames == 1 and gm != null:
gm.guard.rotation.y = deg_to_rad(-70)
gm.guard.head.rotation.x = deg_to_rad(3.0)
if state_frames >= 90: if state_frames >= 90:
gm.guard.rotation.y = 0.0
gm.guard.head.rotation.x = deg_to_rad(2.0)
_shoot("05-late-night-avenue.png") _shoot("05-late-night-avenue.png")
if gm != null:
gm.call("_toggle_flashlight")
state = 6 state = 6
state_frames = 0 state_frames = 0
6: 6:
if state_frames >= 40: # Face the entrance again and switch the flashlight on for the night set.
if state_frames == 1 and gm != null:
gm.guard.rotation.y = 0.0
gm.guard.head.rotation.x = deg_to_rad(4.0)
gm.call("_toggle_flashlight")
if state_frames >= 30:
_shoot("06-night-flashlight.png") _shoot("06-night-flashlight.png")
state = 7 state = 7
quit(0) quit(0)
+114
View File
@@ -0,0 +1,114 @@
extends SceneTree
## Crowd-presentation smoke: starts a shift, waits for bystanders to settle, then
## checks that an aggressive crowd event makes idle NPCs stop and watch, and that
## panic sends them away from the door.
##
## Usage:
## godot --headless --path . --script res://src/test/smoke_crowd.gd
## Exit code 0 = pass, 1 = failure.
const MAX_FRAMES := 2400
var frame := 0
var gm: Node = null
var started := false
var phase := 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 before crowd reactions were observed")
return true
match phase:
0:
if frame < 300:
return false
# Wait until at least one settled bystander is away from the door (someone
# waiting at the entrance is being dealt with and must not rubberneck).
if _bystander_count() == 0:
return false
gm.coordinator.crowd.apply_event("aggressive_behavior")
var watching := _count_mode("watch")
_check(watching > 0, "aggressive behavior makes idle NPCs watch (%d watching)" % watching)
phase = 1
1:
if frame < 320:
return false
var door: Vector3 = gm.selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
for npc in gm.npcs:
if is_instance_valid(npc) and npc.has_method("reaction_is_active"):
pass
gm.coordinator.crowd.apply_event("visible_weapon")
gm.coordinator.crowd.apply_event("aggressive_behavior")
gm.coordinator.crowd.apply_event("aggressive_behavior")
gm.coordinator.crowd.apply_event("emergency_responder")
_check(gm.coordinator.crowd.should_flee(), "stacked alarms panic the crowd")
var fleeing := _count_mode("flee")
_check(fleeing > 0, "panic sends NPCs away from the door (%d fleeing)" % fleeing)
var away_ok := true
for npc in gm.npcs:
if not is_instance_valid(npc) or npc.reaction_mode != "flee":
continue
if (npc.target_position - door).length() <= (npc.global_position - door).length():
away_ok = false
_check(away_ok, "every fleeing NPC targets a point farther from the door")
phase = 2
_:
_finish()
return true
return false
func _bystander_count() -> int:
var n := 0
for npc in gm.npcs:
if not is_instance_valid(npc) or not npc.arrived:
continue
if npc.awaiting_interaction and npc.global_position.distance_to(npc.door_position) < 3.0:
continue
n += 1
return n
func _settled_count() -> int:
var n := 0
for npc in gm.npcs:
if is_instance_valid(npc) and npc.arrived:
n += 1
return n
func _count_mode(mode: String) -> int:
var n := 0
for npc in gm.npcs:
if is_instance_valid(npc) and npc.reaction_mode == mode:
n += 1
return n
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 rubberneck and panic flee")
quit(0)
else:
for message in failures:
printerr("SMOKE FAIL: " + message)
quit(1)
+94
View File
@@ -0,0 +1,94 @@
extends SceneTree
## Emergency-aftermath smoke: places an unjustified emergency call, checks the
## investigation note and reputation hit, forces the responder to arrive, and
## verifies the evacuation outcome locks the door and clears the entrance.
##
## Usage:
## godot --headless --path . --script res://src/test/smoke_emergency.gd
## Exit code 0 = pass, 1 = failure.
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 before the emergency aftermath ran")
return true
if frame < 200:
return false
if step == 0:
if gm.npcs.is_empty():
return false
gm.call("_on_guard_target", gm.npcs[0])
# A tourist: no weapon, low danger.
gm.current_npc_truth = {"weapons_seen": false, "danger_level": 1}
var rating_before: float = gm.coordinator.reputation.rating
gm.call("_on_radio_submitted", {
"npc_id": gm.current_incident_npc_id,
"location": "entrance",
"description": "tourist",
"behavior": "standing",
"weapons_seen": false,
"danger_level": 1,
}, true)
_check(gm.coordinator.investigations.size() == 1, "unjustified emergency opens one investigation")
_check(gm.coordinator.reputation.rating < rating_before, "unjustified emergency loses reputation")
_check(gm.coordinator.radio.pending_calls.size() == 1, "the emergency call is pending")
_check(not gm.door.locked, "the door is open before the responder arrives")
step = 1
if step == 1:
# Fast-forward the arrival delay.
gm.coordinator.radio.tick(30.0)
_check(gm.coordinator.radio.pending_calls.is_empty(), "the responder arrived")
_check(gm.coordinator.responder_outcomes.has("evacuation"), "low-danger emergency resolves as evacuation")
_check(gm.door.locked, "evacuation locks the door")
var clearing := 0
for npc in gm.npcs:
if is_instance_valid(npc) and (npc.reaction_mode == "flee" or npc.npc.leave_requested):
clearing += 1
_check(clearing > 0, "evacuation sends people away from the entrance (%d clearing)" % clearing)
var review: Dictionary = gm.coordinator.build_review()
_check((review.get("review", review).get("responder_outcomes", []) as Array).has("evacuation"),
"the review carries the responder outcome")
_check((review.get("investigations", []) as Array).size() == 1, "the review carries the investigation note")
step = 2
if step == 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: emergency aftermath (investigation, evacuation, review)")
quit(0)
else:
for message in failures:
printerr("SMOKE FAIL: " + message)
quit(1)
+83
View File
@@ -0,0 +1,83 @@
extends SceneTree
## Body-camera timeline smoke: records a few real interactions, opens the review's
## timeline, steps through every event, and closes back to the review.
##
## Usage:
## godot --headless --path . --script res://src/test/smoke_timeline.gd
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 < 200:
return false
match step:
0:
if gm.npcs.is_empty():
return false
gm.call("_on_guard_target", gm.npcs[0])
gm.call("_apply_interaction", 1, "eye contact")
gm.call("_request_reason")
gm.call("_apply_interaction", 2, "formal warning")
var review: Dictionary = gm.coordinator.build_review()
# Real flow: the review screen shows first, then its timeline button.
gm.shift_review_panel.show_review(review, gm.selected_scenario, [])
var button: Button = gm.shift_review_panel.find_child("TimelineButton", true, false) as Button
_check(button != null, "review offers a body-camera timeline button")
if button:
button.pressed.emit()
var count: int = gm.event_timeline.events.size()
_check(count >= 3, "timeline collected the interaction events (%d)" % count)
_check(gm.event_timeline.visible, "timeline is showing")
_check(not gm.shift_review_panel.visible, "review is hidden behind the timeline")
_check(gm.guard.input_locked, "the guard is frozen while the timeline is open")
for i in count + 2:
gm.event_timeline.step(1)
_check(gm.event_timeline.index == count - 1, "stepping clamps at the last event")
gm.event_timeline.close()
step = 1
1:
_check(not gm.event_timeline.visible, "timeline closes")
_check(gm.shift_review_panel.visible, "closing returns to the review")
_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: body-camera timeline")
quit(0)
else:
for message in failures:
printerr("SMOKE FAIL: " + message)
quit(1)
+106
View File
@@ -0,0 +1,106 @@
extends RefCounted
## Crowd presentation: rubbernecking on aggressive/emergency events and clearing
## out when the crowd panics (PLAN: Public Crowd Reaction).
const CrowdSystem := preload("res://src/core/crowd_system.gd")
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 _profile() -> NpcProfile:
var p := NpcProfile.new()
p.id = "bystander"
p.display_name = "Bystander"
p.npc_type = Rules.NpcType.TOURIST
return p
func _avatar() -> Node:
var avatar: Node = NpcAvatar.new()
avatar.setup(_profile(), "photo", 99, Vector3(4, 0.5, 8), Vector3(2, 0.5, 12))
return avatar
func test_reaction_for_event() -> bool:
var a := TestUtils.check_eq(CrowdSystem.reaction_for_event("aggressive_behavior"), "watch", "aggression draws watchers")
var b := TestUtils.check_eq(CrowdSystem.reaction_for_event("emergency_responder"), "watch", "responder draws watchers")
var c := TestUtils.check_eq(CrowdSystem.reaction_for_event("visible_weapon"), "watch", "a weapon draws watchers")
var d := TestUtils.check_eq(CrowdSystem.reaction_for_event("de_escalation"), "", "de-escalation is not a watch cue")
var e := TestUtils.check_eq(CrowdSystem.reaction_for_event("calm_professionalism"), "", "calm is not a watch cue")
return a and b and c and d and e
func test_panic_flag_and_flee_cue() -> bool:
var crowd := CrowdSystem.new()
var a := TestUtils.check(not crowd.should_flee(), "crowd starts calm")
crowd.apply_event("visible_weapon")
crowd.apply_event("aggressive_behavior")
crowd.apply_event("aggressive_behavior")
crowd.apply_event("emergency_responder")
var b := TestUtils.check(crowd.should_flee(), "stacked alarms reach panic")
var c := TestUtils.check_eq(crowd.summary().get("panic"), true, "summary reports panic")
var calm := CrowdSystem.new()
calm.apply_event("de_escalation")
calm.apply_event("calm_professionalism")
var d := TestUtils.check(not calm.should_flee(), "a calm crowd does not flee")
crowd = null
calm = null
return a and b and c and d
func test_idle_bystander_rubbernecks() -> bool:
var avatar := _avatar()
# Still walking: should refuse so traffic is not frozen mid-approach.
var walking := TestUtils.check(not avatar.start_rubberneck(Vector3(0, 1, 1)), "walkers keep walking")
avatar.arrived = true
var accepted := TestUtils.check(avatar.start_rubberneck(Vector3(0, 1, 1)), "idle bystander watches")
var mode := TestUtils.check_eq(avatar.reaction_mode, "watch", "watch mode is set")
var active := TestUtils.check(avatar.reaction_is_active(), "reaction is active")
avatar.free()
return walking and accepted and mode and active
func test_engaged_person_does_not_rubberneck() -> bool:
var avatar := _avatar()
avatar.arrived = true
avatar.awaiting_interaction = true
# Not in a tree, so use local position (global_position is a no-op here).
avatar.position = avatar.door_position
var ok := TestUtils.check(not avatar.start_rubberneck(Vector3.ZERO), "someone waiting at the door does not rubberneck")
# The same person further away is an ordinary bystander and does watch.
avatar.position = avatar.door_position + Vector3(8, 0, 0)
var far_ok := TestUtils.check(avatar.start_rubberneck(Vector3.ZERO), "a bystander away from the door still watches")
avatar.free()
return ok and far_ok
func test_panic_moves_people_away_from_the_door() -> bool:
var avatar := _avatar()
avatar.position = Vector3(1, 0.5, 2)
var door := Vector3(0, 0.5, 1.2)
avatar.start_flee(door)
var a := TestUtils.check_eq(avatar.reaction_mode, "flee", "flee mode is set")
var b := TestUtils.check((avatar.target_position - door).length() > (avatar.position - door).length(),
"flee target is farther from the door than the start")
var c := TestUtils.check(not avatar.awaiting_interaction, "fleeing cancels waiting at the entrance")
avatar.free()
return a and b and c
func test_crowd_updates_arrive_through_the_event_bus() -> bool:
## CrowdSystem.apply_event() emits EventBus.crowd_changed, not its own
## `changed` signal; wiring the wrong one silently disables crowd reactions.
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var ok := TestUtils.check(src.contains("event_bus.crowd_changed.connect(_on_crowd_changed)"),
"GameManager listens on EventBus.crowd_changed")
var bus: Object = load("res://src/core/event_bus.gd").new()
var seen: Array = []
bus.crowd_changed.connect(func(payload): seen.append(payload.get("event", "")))
var crowd := CrowdSystem.new()
crowd.set_event_bus(bus)
crowd.apply_event("aggressive_behavior")
var delivers := TestUtils.check_eq(seen, ["aggressive_behavior"], "apply_event reaches the bus listener")
bus = null
crowd = null
return ok and delivers
func test_leaving_npc_does_not_flee() -> bool:
var avatar := _avatar()
avatar.npc.leave_requested = true
avatar.start_flee(Vector3.ZERO)
var ok := TestUtils.check_eq(avatar.reaction_mode, "", "someone already leaving is left alone")
avatar.free()
return ok
+103
View File
@@ -0,0 +1,103 @@
extends RefCounted
## Emergency aftermath: responder outcomes reach the review, evacuation clears the
## entrance and locks the door, and an unjustified call opens an investigation.
const Rules := preload("res://src/core/rules.gd")
const RadioSystem := preload("res://src/core/radio_system.gd")
const Coordinator := preload("res://src/core/shift_coordinator.gd")
const Factory := preload("res://src/content/scenario_factory.gd")
const Strings := preload("res://src/core/strings.gd")
func _coordinator() -> Object:
var c := Coordinator.new()
c.setup(Factory.hollywood_building(), 60.0, 4242)
return c
func test_responder_outcomes_reach_the_review() -> bool:
var c := _coordinator()
c.responder_outcomes.append("arrest")
c.responder_outcomes.append("command")
var review: Dictionary = c.build_review()
var outcomes: Array = review.get("responder_outcomes", [])
var a := TestUtils.check_eq(outcomes, ["arrest", "command"], "responder outcomes are in the review")
var b := TestUtils.check(review.has("investigations"), "review carries an investigations list")
c = null
return a and b
func test_outcomes_reset_with_a_new_shift() -> bool:
var c := _coordinator()
c.responder_outcomes.append("evacuation")
c.investigations.append("note")
c.setup(Factory.hollywood_building(), 60.0, 1)
var a := TestUtils.check_eq(c.responder_outcomes.size(), 0, "outcomes reset on setup")
var b := TestUtils.check_eq(c.investigations.size(), 0, "investigations reset on setup")
c = null
return a and b
func test_evacuation_is_the_emergency_default() -> bool:
var call := {"type": "emergency", "report": {"danger_level": 2, "weapons_seen": false}}
var a := TestUtils.check_eq(RadioSystem.responder_outcome(call), "restraint", "danger 2 restrains")
var call2 := {"type": "emergency", "report": {"danger_level": 1, "weapons_seen": false}}
var b := TestUtils.check_eq(RadioSystem.responder_outcome(call2), "evacuation", "low-danger emergency evacuates")
var call3 := {"type": "assist", "report": {"danger_level": 1, "weapons_seen": false}}
var c := TestUtils.check_eq(RadioSystem.responder_outcome(call3), "command", "assistance just commands")
var call4 := {"type": "emergency", "report": {"danger_level": 3, "weapons_seen": true}}
var d := TestUtils.check_eq(RadioSystem.responder_outcome(call4), "arrest", "weapons arrest")
return a and b and c and d
func test_evacuation_wiring_locks_the_door_and_clears_npcs() -> bool:
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var a := TestUtils.check(src.contains("func _evacuate_entrance"), "evacuation has a handler")
var b := TestUtils.check(src.contains('if outcome == "evacuation":'), "arrival routes evacuation")
var c := TestUtils.check(src.contains("door.set_locked(true)"), "evacuation locks the door")
var d := TestUtils.check(src.contains("npc.start_flee(door_pos)"), "evacuation sends NPCs away")
var e := TestUtils.check(src.contains("controller.leave_requested = true"), "evacuated NPCs are marked as leaving")
return a and b and c and d and e
func test_unjustified_emergency_is_detected() -> bool:
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var a := TestUtils.check(src.contains("func _is_unjustified_emergency"), "unjustified check exists")
var b := TestUtils.check(src.contains("return not weapons and danger <= 1"), "no weapon and low danger is unjustified")
var c := TestUtils.check(src.contains("coordinator.investigations.append"), "an investigation note is recorded")
var d := TestUtils.check(src.contains('"type": "investigation_opened"'), "the incident log records the investigation")
var e := TestUtils.check(src.contains('coordinator.reputation.apply("false_report"'), "the false-report hit applies")
return a and b and c and d and e
func test_radio_arrival_reconnects_after_setup() -> bool:
## coordinator.setup() builds a new RadioSystem, so the arrival handler must be
## re-connected each shift or dispatched responders never resolve anything.
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var a := TestUtils.check(src.contains("func _connect_radio"), "radio wiring has a helper")
var b := TestUtils.check(src.contains("coordinator.radio.responder_arrived.connect(_on_responder_arrived)"),
"the helper connects the arrival handler")
var c := TestUtils.check(src.contains("_connect_radio()"), "shifts call the helper")
return a and b and c
func test_review_strings_exist() -> bool:
var a := TestUtils.check(Strings.lookup("review.responder") != "review.responder", "responder line exists")
var b := TestUtils.check(Strings.lookup("review.investigations") != "review.investigations", "investigation line exists")
var c := TestUtils.check(Strings.lookup("review.none") != "review.none", "empty placeholder exists")
var d := TestUtils.check(Strings.lookup("responder.evacuated") != "responder.evacuated", "evacuation log line exists")
var e := TestUtils.check(Strings.lookup("investigation.started") != "investigation.started", "investigation log line exists")
return a and b and c and d and e
func test_review_renders_outcomes_and_investigations() -> bool:
var Review := load("res://src/game/ui/shift_review.gd")
var panel: Node = Review.new()
var review := {
"scenario_id": "hollywood_building",
"reputation": {"rating": 61.0, "grade": "C", "modifiers": []},
"incidents": {"incidents": []},
"crowd": {"mood": 0.4, "panic": false},
"radio": {"battery": 70.0, "emergency_cooldown_s": 0.0},
"spawned_count": 3,
"responder_outcomes": ["evacuation"],
"investigations": ["Emergency call reviewed"],
}
panel.show_review(review, Factory.hollywood_building(), [])
var text: String = panel.report_label.text
var a := TestUtils.check(text.contains("evacuation"), "review names the responder outcome")
var b := TestUtils.check(text.contains("Emergency call reviewed"), "review shows the investigation note")
var c := TestUtils.check(text.contains("Investigations:"), "investigations line is labelled")
panel.free()
return a and b and c
+120
View File
@@ -0,0 +1,120 @@
extends RefCounted
## Body-camera timeline: the review screen steps through recorded incident events
## (ADR-0005). No live 3D replay in this version.
const EventTimeline := preload("res://src/game/ui/event_timeline.gd")
const Strings := preload("res://src/core/strings.gd")
const Coordinator := preload("res://src/core/shift_coordinator.gd")
const Factory := preload("res://src/content/scenario_factory.gd")
func _review() -> Dictionary:
return {
"started_at_ms": 1000,
"incidents": {
"open_count": 0,
"incidents": [
{
"id": "inc_a",
"npc_id": "npc_a",
"outcome": "peaceful",
"events": [
{"type": "player_action", "action": "eye contact", "level": 1, "at_ms": 3000},
{"type": "body_camera", "on": true, "reason": "formal warning", "at_ms": 8000},
],
},
{
"id": "inc_b",
"npc_id": "npc_b",
"outcome": "left",
"events": [
{"type": "reason_requested", "reply": "I have a ticket.", "at_ms": 5000},
{"type": "access_check", "evidence": {"decided": true, "verified_granted": true}, "at_ms": 9000},
],
},
],
},
"reputation": {"rating": 70.0, "grade": "C", "modifiers": []},
"crowd": {"mood": 0.1, "panic": false},
"radio": {"battery": 100.0, "emergency_cooldown_s": 0.0},
}
func test_flatten_orders_events_across_incidents() -> bool:
var timeline: Node = EventTimeline.new()
var flat: Array = timeline.flatten(_review())
var a := TestUtils.check_eq(flat.size(), 4, "every recorded event is flattened")
var stamps: Array[int] = []
for row in flat:
stamps.append(int(row.get("at_ms", 0)))
var sorted := stamps.duplicate()
sorted.sort()
var b := TestUtils.check_eq(stamps, sorted, "events are time ordered")
var c := TestUtils.check_eq(flat[0].get("npc_id", ""), "npc_a", "event keeps its NPC id")
var d := TestUtils.check_eq(flat[1].get("npc_id", ""), "npc_b", "second incident interleaves")
var e := TestUtils.check_eq(flat[0].get("incident_id", ""), "inc_a", "event keeps its incident id")
timeline.free()
return a and b and c and d and e
func test_describe_covers_event_types() -> bool:
var timeline: Node = EventTimeline.new()
var a := TestUtils.check(timeline.describe({"type": "player_action", "action": "eye contact", "level": 1, "at_ms": 3000}).contains("eye contact (L1)"), "player action line")
var b := TestUtils.check(timeline.describe({"type": "body_camera", "on": true, "reason": "formal warning"}).contains("body camera on"), "body camera line")
var c := TestUtils.check(timeline.describe({"type": "reason_requested", "reply": "I have a ticket."}).contains("I have a ticket."), "reason reply line")
var d := TestUtils.check(timeline.describe({"type": "access_check", "evidence": {"decided": false, "verified_granted": true}}).contains("deny"), "access decision line")
var e := TestUtils.check(timeline.describe({"type": "responder_action", "call_type": "emergency", "outcome": "evacuation"}).contains("evacuation"), "responder line")
var f := TestUtils.check(timeline.describe({"type": "investigation_opened", "reason": "unjustified"}).contains("investigation"), "investigation line")
timeline.free()
return a and b and c and d and e and f
func test_empty_timeline_is_safe() -> bool:
var timeline: Node = EventTimeline.new()
var flat: Array = timeline.flatten({})
var a := TestUtils.check_eq(flat.size(), 0, "no incidents means no events")
timeline.show_timeline({})
var b := TestUtils.check_eq(timeline.index, 0, "index stays at zero when empty")
timeline.step(1)
var c := TestUtils.check_eq(timeline.index, 0, "stepping an empty timeline does nothing")
timeline.free()
return a and b and c
func test_stepping_clamps() -> bool:
var timeline: Node = EventTimeline.new()
timeline.show_timeline(_review())
timeline.step(-1)
var a := TestUtils.check_eq(timeline.index, 0, "cannot step before the first event")
timeline.step(1)
var b := TestUtils.check_eq(timeline.index, 1, "next advances one event")
timeline.step(99)
var c := TestUtils.check_eq(timeline.index, 3, "cannot step past the last event")
timeline.free()
return a and b and c
func test_review_records_a_shift_start_stamp() -> bool:
var coordinator: Object = Coordinator.new()
coordinator.setup(Factory.hollywood_building(), 30.0, 7)
coordinator.start()
var review: Dictionary = coordinator.build_review()
var a := TestUtils.check(int(review.get("started_at_ms", 0)) > 0, "review carries the shift start stamp")
coordinator = null
return a
func test_timeline_strings_exist() -> bool:
var ok := true
for key in ["timeline.title", "timeline.position", "timeline.empty", "timeline.prev",
"timeline.next", "timeline.close", "timeline.hint", "review.timeline_button"]:
if Strings.lookup(key) == key:
ok = false
return TestUtils.check(ok, "timeline UI strings are localized")
func test_review_button_opens_the_timeline() -> bool:
var Review := load("res://src/game/ui/shift_review.gd")
var panel: Node = Review.new()
var got := {"review": {}}
panel.timeline_requested.connect(func(review): got["review"] = review)
panel.show_review(_review(), Factory.hollywood_building(), [])
var button: Button = panel.find_child("TimelineButton", true, false) as Button
var a := TestUtils.check(button != null, "review screen has a timeline button")
if button:
button.pressed.emit()
var b := TestUtils.check(not (got["review"] as Dictionary).is_empty(), "pressing it hands the review to the timeline")
panel.free()
return a and b
+1 -1
View File
@@ -221,7 +221,7 @@ def skyline():
x = -40 x = -40
while x < w + 40: while x < w + 40:
bw = rnd.randint(64, 170) bw = rnd.randint(64, 170)
bh = rnd.randint(150, 400) bh = rnd.randint(90, 165)
d.rectangle([x, h - bh, x + bw, h], fill=build_color) d.rectangle([x, h - bh, x + bw, h], fill=build_color)
# few lit windows # few lit windows
for _ in range(bw * bh // 900): for _ in range(bw * bh // 900):
+612
View File
@@ -0,0 +1,612 @@
#!/usr/bin/env python3
"""Generate screen-design reference images for Step Line and compare captures.
This tool turns the screen descriptions that already live in `docs/`
(README screenshot captions, ADRs 0007-0010, PLAN.md, architecture.md, and the
HUD strings in `content/strings.en.json`) into reference "design" images, then
uses those images as the expected state to check the game's captured
screenshots against.
Two commands:
generate write one reference image per screen to docs/screens/designs/.
Two modes:
--ai realistic 3D game scenes and in-game screen designs via
the OpenRouter image API (default; requires
OPENROUTER_API_KEY, same as tools/asset_pipeline.py).
--draft deterministic wireframe sketches drawn with Pillow
(no network or API key; good for CI and layout checks).
compare compare docs/screenshots/*.png against the reference designs and
flag gross drift. Semantic composition is judged with coarse,
Pillow-only signals (aspect ratio, frame size, blank-frame
contrast, grayscale MAE, colour-histogram distance, edge density).
These catch "wrong screen / blank frame / wrong time of day"
regressions; they are NOT a pixel-exact or semantic match. A
vision-model pass is the documented next step (see PLAN.md).
Run from the repo root:
python3 tools/screen_designs.py generate
python3 tools/screen_designs.py generate --draft
python3 tools/screen_designs.py compare
"""
import argparse
import os
import sys
from pathlib import Path
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageOps, ImageStat
import generate_textures
ROOT = Path(__file__).resolve().parent.parent
DESIGNS_DIR = ROOT / "docs" / "screens" / "designs"
SCREENSHOTS_DIR = ROOT / "docs" / "screenshots"
# Reference "design" dimensions — match the capture tool and the README gallery.
WIDTH, HEIGHT = 1280, 720
ASPECT = 16 / 9
# Default image model for screen mockups (validated role-aware model, per
# MEMORY.md; seedream rejects small sizes and ignores transparency, so we use
# the model asset_pipeline.py already trusts for non-alpha roles).
DEFAULT_MODEL = "openai/gpt-5-image-mini"
# Shared art direction, appended to every prompt so the generated frames read
# as one coherent game. "scene" entries are first-person 3D world views;
# "ui" entries are in-game panels overlaid on a softened scene.
SCENE_STYLE = (
"rendered in a modern 3D game engine, first-person perspective, cinematic "
"lighting, physically based materials, soft shadows, ambient occlusion, "
"subtle depth of field, photorealistic detail, no watermark or UI clutter"
)
UI_STYLE = (
"clean modern in-game user interface, dark translucent panels, readable "
"sans-serif labels, consistent game UI colour theme, crisp vector text"
)
# Landscape source ratio requested from the API before cropping to 16:9.
# 3:2 is the validated non-square ratio for the default model (see MEMORY.md).
SOURCE_ASPECT = "3:2"
# ---------------------------------------------------------------------------
# Screen catalogue. Each entry is grounded in a doc; `prompt` feeds --ai and
# `regions` feed --draft (normalised rects + a short label). `ref` names the
# captured screenshot, if one exists, so `compare` can pair them up.
# ---------------------------------------------------------------------------
SCREENS = [
{
"name": "venue_overview",
"kind": "scene",
"title": "Venue overview",
"source": "README screenshot 01",
"ref": "01-venue-overview.png",
"description": "First-person view from the guard post at the top of the "
"steps: Hollywood Boulevard entrance, wide concrete steps, "
"sidewalk, street lamps, planters, crosswalk, and building "
"signage/marquee under early-morning light.",
"prompt": (
"Realistic 3D first-person game scene: a security guard's view from "
"the top of wide concrete steps outside a grand Hollywood Boulevard "
"building entrance, early-morning golden light, sidewalk with street "
"lamps, planters, a crosswalk and distant pedestrians, detailed brick "
"facade with a striped awning and glowing marquee sign"),
"regions": [
(0.00, 0.55, 1.00, 1.00, "steps + sidewalk"),
(0.20, 0.08, 0.80, 0.60, "building facade / marquee"),
(0.05, 0.60, 0.20, 0.75, "street lamp"),
(0.78, 0.60, 0.95, 0.75, "street lamp"),
],
},
{
"name": "targeting_npc",
"kind": "scene",
"title": "Targeting an NPC",
"source": "README screenshot 02 · ADR-0008 cone focus + head ring",
"ref": "02-targeting-npc.png",
"description": "An approaching NPC is in the focus cone: a thin pale-gold "
"ring billboards around their head, a display name under "
"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"),
"regions": [
(0.35, 0.15, 0.65, 0.60, "NPC in focus"),
(0.44, 0.10, 0.56, 0.16, "head ring"),
(0.05, 0.85, 0.95, 0.95, "HUD: name + hint"),
],
},
{
"name": "access_inspection",
"kind": "ui",
"title": "Access inspection",
"source": "README screenshot 03 · ADR-0003 access verification",
"ref": "03-access-inspection.png",
"description": "An inspection panel shows the access artifact (wristband / "
"ID / ticket / credential / guest list) with its colour, "
"pattern, date, and event marking, plus allow and deny "
"prompts.",
"prompt": (
"Realistic 3D game screenshot with an in-game access inspection panel "
"overlaid: a centered card showing a wristband or ID credential with a "
"photo placeholder, colour band, date and event marking, and two "
"buttons labelled Allow and Deny, dark translucent panel over a "
"softly blurred city street background"),
"regions": [
(0.25, 0.10, 0.75, 0.90, "inspection panel"),
(0.30, 0.18, 0.70, 0.60, "artifact card"),
(0.30, 0.65, 0.48, 0.72, "allow"),
(0.52, 0.65, 0.70, 0.72, "deny"),
],
},
{
"name": "radio_report",
"kind": "ui",
"title": "Radio report",
"source": "README screenshot 04 · PLAN level 4 radio assistance",
"ref": "04-radio-report.png",
"description": "A radio dispatch panel with fields for location, "
"description, behaviour, whether weapons were seen, and "
"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"),
"regions": [
(0.30, 0.08, 0.70, 0.92, "radio panel"),
(0.34, 0.16, 0.66, 0.30, "location / description"),
(0.34, 0.34, 0.66, 0.56, "behaviour / weapons / danger"),
(0.42, 0.60, 0.58, 0.66, "send"),
],
},
{
"name": "late_night_avenue",
"kind": "scene",
"title": "Late night avenue",
"source": "README screenshot 05",
"ref": "05-late-night-avenue.png",
"description": "Night-time view down Hollywood Boulevard: street lamps "
"lit, marquee glowing, dark blue street, no flashlight.",
"prompt": (
"Realistic 3D first-person game scene at night: a dark Hollywood "
"Boulevard avenue receding into the distance, lit street lamps, a "
"glowing neon marquee sign, deep blue night sky, wet reflective "
"pavement, no flashlight"),
"regions": [
(0.00, 0.55, 1.00, 1.00, "dark avenue"),
(0.15, 0.25, 0.85, 0.55, "glowing marquee"),
(0.10, 0.45, 0.30, 0.70, "lit street lamp"),
(0.70, 0.45, 0.90, 0.70, "lit street lamp"),
],
},
{
"name": "night_flashlight",
"kind": "scene",
"title": "Night flashlight",
"source": "README screenshot 06",
"ref": "06-night-flashlight.png",
"description": "Night shift: the guard's flashlight beam cuts through the "
"dark toward the entrance, illuminating the steps and door.",
"prompt": (
"Realistic 3D first-person game scene at night: a flashlight beam "
"cutting through darkness toward a building entrance and steps, a "
"bright spotlight pool illuminating the door, dark surroundings and "
"deep shadows"),
"regions": [
(0.35, 0.30, 0.65, 0.80, "flashlight beam pool"),
(0.40, 0.20, 0.60, 0.35, "lit door"),
(0.00, 0.00, 1.00, 0.25, "dark sky"),
],
},
{
"name": "radial_menu",
"kind": "ui",
"title": "Radial action menu",
"source": "ADR-0009 · content/strings.en.json",
"ref": None,
"description": "A round doughnut menu centred on screen, 12 slices in "
"de-escalation order: Stop, Look, Point, No, Ask, Inspect, "
"Wand, Warn, Block, Reason, Recognize, Radio. The focused "
"person's display name sits in the centre.",
"prompt": (
"Realistic 3D game screenshot with an in-game radial action menu "
"overlaid: a round doughnut wheel centered on screen with twelve "
"labelled slices reading Stop, Look, Point, No, Ask, Inspect, Wand, "
"Warn, Block, Reason, Recognize, Radio, one slice highlighted, a name "
"in the center of the ring, over a softly blurred city street scene"),
"regions": [
(0.38, 0.12, 0.62, 0.88, "radial wheel (12 slices)"),
(0.46, 0.44, 0.54, 0.56, "name"),
(0.62, 0.12, 0.70, 0.20, "highlight"),
],
},
{
"name": "shift_review",
"kind": "ui",
"title": "Shift review",
"source": "PLAN reputation / shift review · architecture.md ShiftReview",
"ref": None,
"description": "End-of-shift performance review: access decisions, "
"escalation choices, incident list, battery and crowd "
"outcomes, and reputation/score feedback.",
"prompt": (
"Realistic 3D game end-of-shift review screen: a full-screen "
"performance summary panel listing incident decisions, escalation "
"choices, a reputation score, crowd reaction and battery use, with a "
"Continue button, dark themed game UI"),
"regions": [
(0.30, 0.06, 0.70, 0.94, "review panel"),
(0.36, 0.14, 0.64, 0.24, "score / reputation"),
(0.36, 0.28, 0.64, 0.70, "incident list"),
(0.42, 0.76, 0.58, 0.84, "continue"),
],
},
{
"name": "start_menu",
"kind": "ui",
"title": "Start menu",
"source": "MEMORY session shell (StartMenu, layer 20)",
"ref": None,
"description": "The boot screen: a venue-selection menu (Hollywood "
"Boulevard, The Joke Shop, TV Studio) before a shift.",
"prompt": (
"Realistic 3D game start menu screen: a game title and three venue "
"selection buttons labelled Hollywood Boulevard, The Joke Shop and TV "
"Studio, dark themed menu over a softly blurred city street backdrop"),
"regions": [
(0.25, 0.10, 0.75, 0.26, "title"),
(0.36, 0.36, 0.64, 0.44, "Hollywood Boulevard"),
(0.36, 0.48, 0.64, 0.56, "The Joke Shop"),
(0.36, 0.60, 0.64, 0.68, "TV Studio"),
],
},
{
"name": "pause_overlay",
"kind": "ui",
"title": "Pause overlay",
"source": "MEMORY session shell (PauseOverlay, layer 18)",
"ref": None,
"description": "Pause menu: resume, restart, settings, and quit-to-menu.",
"prompt": (
"Realistic 3D game pause menu overlay: buttons labelled Resume, "
"Restart, Settings and Quit to Menu, dark translucent overlay over a "
"paused, softly blurred city street scene"),
"regions": [
(0.38, 0.26, 0.62, 0.74, "pause menu"),
(0.42, 0.32, 0.58, 0.38, "Resume"),
(0.42, 0.42, 0.58, 0.48, "Restart"),
(0.42, 0.52, 0.58, 0.58, "Settings"),
(0.42, 0.62, 0.58, 0.68, "Quit to Menu"),
],
},
{
"name": "settings_panel",
"kind": "ui",
"title": "Settings",
"source": "MEMORY session shell (SettingsPanel, layer 22)",
"ref": None,
"description": "Settings: look sensitivity and HUD font scale controls.",
"prompt": (
"Realistic 3D game settings screen: sliders labelled look sensitivity "
"and HUD font scale, dark themed settings panel"),
"regions": [
(0.30, 0.16, 0.70, 0.84, "settings panel"),
(0.36, 0.30, 0.64, 0.40, "look sensitivity"),
(0.36, 0.46, 0.64, 0.56, "HUD font scale"),
],
},
{
"name": "emergency_radio",
"kind": "ui",
"title": "Emergency radio",
"source": "PLAN level 5 · strings radio.emergency_dispatched",
"ref": None,
"description": "Hold-to-confirm emergency radio panel with a confirmation "
"prompt and a cooldown indicator.",
"prompt": (
"Realistic 3D game emergency radio hold-to-confirm panel: a large "
"warning prompt with a hold progress bar and a cooldown label, red "
"accent, dark translucent overlay over a softly blurred city street "
"scene"),
"regions": [
(0.30, 0.28, 0.70, 0.72, "emergency panel"),
(0.36, 0.36, 0.64, 0.46, "confirm prompt"),
(0.38, 0.52, 0.62, 0.60, "hold progress"),
(0.36, 0.64, 0.64, 0.68, "cooldown"),
],
},
]
def resolve_path(value):
path = Path(value).expanduser()
return path if path.is_absolute() else ROOT / path
def _design_path(name):
return DESIGNS_DIR / f"{name}.png"
# ---------------------------------------------------------------------------
# Draft (wireframe) rendering — deterministic, offline.
# ---------------------------------------------------------------------------
def _draw_wireframe(screen, path):
image = Image.new("RGB", (WIDTH, HEIGHT), (24, 26, 38))
draw = ImageDraw.Draw(image)
title_font = generate_textures.font(52)
body_font = generate_textures.font(24)
label_font = generate_textures.font(26)
draw.text((40, 28), screen["title"], font=title_font, fill=(240, 240, 245))
draw.text((40, 98), screen["source"], font=body_font, fill=(150, 155, 175))
# wrap the description into a small caption block
words = screen["description"].split()
lines, line = [], ""
for word in words:
if draw.textlength(line + " " + word, font=body_font) < WIDTH - 80:
line = (line + " " + word).strip()
else:
lines.append(line)
line = word
lines.append(line)
for index, text in enumerate(lines[:4]):
draw.text((40, 132 + index * 30), text,
font=body_font, fill=(120, 125, 145))
for region in screen["regions"]:
x0, y0, x1, y1, label = region
box = (int(x0 * WIDTH), int(y0 * HEIGHT),
int(x1 * WIDTH), int(y1 * HEIGHT))
draw.rounded_rectangle(box, radius=10,
outline=(120, 130, 170), width=2)
cx = (box[0] + box[2]) / 2
cy = (box[1] + box[3]) / 2
draw.text((cx - draw.textlength(label, font=label_font) / 2,
cy - 14), label, font=label_font, fill=(170, 180, 210))
image.save(path)
shown = path.relative_to(ROOT) if path.is_relative_to(ROOT) else path
print(f"wrote {shown} {image.size}")
# ---------------------------------------------------------------------------
# AI generation (reuses the OpenRouter backend from generate_textures).
# ---------------------------------------------------------------------------
def _compose_prompt(screen):
"""Append the shared art direction to a screen's content prompt."""
style = UI_STYLE if screen.get("kind") == "ui" else SCENE_STYLE
return f"{screen['prompt']}. {style}. 16:9 widescreen composition."
def _generate_ai(screen, model, request_size):
prompt = _compose_prompt(screen)
# Request a landscape source and crop to the 16:9 capture dimensions rather
# than a square that loses framing when centre-cropped.
image = generate_textures.generate_image(
prompt, model, request_size, transparent=False,
aspect_ratio=SOURCE_ASPECT)
image = ImageOps.fit(image, (WIDTH, HEIGHT),
method=Image.Resampling.LANCZOS)
if image.mode in ("RGBA", "LA", "P"):
image = image.convert("RGB")
path = _design_path(screen["name"])
path.parent.mkdir(parents=True, exist_ok=True)
image.save(path)
shown = path.relative_to(ROOT) if path.is_relative_to(ROOT) else path
print(f"wrote {shown} {image.size}")
def generate(args):
DESIGNS_DIR.mkdir(parents=True, exist_ok=True)
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)
print("done")
# ---------------------------------------------------------------------------
# Comparison metrics (Pillow only; no numpy/scikit-image dependency).
# ---------------------------------------------------------------------------
def _load_rgb(path):
with Image.open(path) as image:
image.load()
return image.convert("RGB")
def _grayscale(image):
return ImageOps.grayscale(image)
def _mae(left, right):
"""Mean absolute error between two same-size grayscale images, 0..255."""
left_gray = _grayscale(left).resize((256, 144))
right_gray = _grayscale(right).resize((256, 144))
diff = ImageChops.difference(left_gray, right_gray)
return ImageStat.Stat(diff).mean[0]
def _histogram_distance(left, right):
"""Normalised L1 distance between 3x256-bin colour histograms, 0..1."""
left_hist = left.resize((256, 144)).histogram()
right_hist = right.resize((256, 144)).histogram()
total = sum(left_hist)
if total == 0:
return 1.0
distance = sum(abs(a - b) for a, b in zip(left_hist, right_hist))
return distance / (2 * total)
def _edge_density(image):
"""Mean brightness of an edge-detected frame; higher = more detail."""
edges = _grayscale(image).filter(ImageFilter.FIND_EDGES)
return ImageStat.Stat(edges).mean[0]
def _contrast(image):
"""Standard deviation of grayscale brightness; low = near-blank frame."""
return ImageStat.Stat(_grayscale(image)).stddev[0]
def _aspect_ok(image, tolerance=0.12):
width, height = image.size
actual = width / height
return abs(actual - ASPECT) / ASPECT <= tolerance
def compare_screenshot(reference, capture):
"""Compare one capture against its reference design; return a dict report."""
ref = _load_rgb(reference)
cap = _load_rgb(capture)
width, height = cap.size
report = {
"capture": str(capture),
"reference": str(reference),
"size": [width, height],
}
errors, warnings = [], []
if width < WIDTH or height < HEIGHT:
errors.append(f"too small: {width}x{height}; minimum {WIDTH}x{HEIGHT}")
if not _aspect_ok(cap):
errors.append(f"wrong aspect ratio: {width / height:.3f}; "
f"expected about {ASPECT:.3f}")
contrast = _contrast(cap)
report["contrast"] = round(contrast, 1)
if contrast < 5.0:
errors.append(f"near-blank frame: contrast {contrast:.1f} below 5.0")
mae = _mae(ref, cap)
hist = _histogram_distance(ref, cap)
edge_ref = _edge_density(ref)
edge_cap = _edge_density(cap)
report["mae"] = round(mae, 1)
report["hist_distance"] = round(hist, 3)
report["edge_density_ref"] = round(edge_ref, 1)
report["edge_density_cap"] = round(edge_cap, 1)
# Coarse drift warnings (not hard failures: an AI mockup will never match a
# real render pixel-for-pixel, but a wildly different frame will still
# stand out on these signals).
if mae > 90:
warnings.append(
f"grayscale MAE {mae:.1f} is high (very different layout)")
if hist > 0.75:
warnings.append(f"colour histogram distance {hist:.3f} is high "
"(very different palette / time of day)")
edge_delta = abs(edge_cap - edge_ref)
report["edge_delta"] = round(edge_delta, 1)
if edge_delta > 18:
warnings.append(f"edge density differs by {edge_delta:.1f} "
"(very different amount of detail)")
report["quality"] = "bad" if errors else "good"
report["errors"] = errors
report["warnings"] = warnings
return report
def compare(args):
if not SCREENSHOTS_DIR.is_dir():
sys.exit(f"error: no screenshots directory at {SCREENSHOTS_DIR}; "
"run src/test/capture_screenshots.gd first")
captures = sorted(SCREENSHOTS_DIR.glob("*.png"))
if not captures:
sys.exit(f"error: no .png screenshots found in {SCREENSHOTS_DIR}")
refs = {screen["name"]: screen["ref"]
for screen in SCREENS if screen["ref"]}
results = []
for capture in captures:
name = capture.name
# find the screen whose ref matches this capture filename
design_name = next((n for n, ref in refs.items() if ref == name), None)
if design_name is None:
results.append({"capture": str(capture), "reference": None,
"errors": [f"no design reference for {name}"],
"warnings": [], "quality": "bad"})
continue
reference = _design_path(design_name)
if not reference.is_file():
results.append({"capture": str(capture),
"reference": str(reference),
"errors": [f"missing reference design "
f"({reference.name}); run generate first"],
"warnings": [], "quality": "bad"})
continue
results.append(compare_screenshot(reference, capture))
if args.json:
import json
print(json.dumps(results, indent=2))
else:
good_count = sum(r["quality"] == "good" for r in results)
for result in results:
ref = (Path(result["reference"]).name
if result.get("reference") else "-")
size = ("x".join(map(str, result["size"]))
if result.get("size") else "unreadable")
print(f"{result['quality'].upper():4} {result['capture']}")
print(f" ref={ref} size={size} "
f"mae={result.get('mae', '-')} "
f"hist={result.get('hist_distance', '-')}")
for message in result["errors"] + result["warnings"]:
print(f" - {message}")
print(f"Summary: {good_count} good, {len(results) - good_count} bad")
return 1 if any(r["quality"] == "bad" for r in results) else 0
# ---------------------------------------------------------------------------
def build_parser():
parser = argparse.ArgumentParser(
description="Generate and compare Step Line screen-design references.")
commands = parser.add_subparsers(dest="command", required=True)
gen = commands.add_parser(
"generate", help="write reference design images to docs/screens/designs/")
mode = gen.add_mutually_exclusive_group()
mode.add_argument("--draft", action="store_true",
help="draw deterministic wireframes (no API/network)")
mode.add_argument("--ai", action="store_true",
help="generate mockups via OpenRouter (default)")
gen.add_argument("--model", default=DEFAULT_MODEL)
gen.add_argument("--request-size", type=int,
default=generate_textures.GEN_SIZE)
gen.set_defaults(handler=generate, draft=False)
cmp = commands.add_parser(
"compare", help="compare docs/screenshots/*.png against the designs")
cmp.add_argument("--json", action="store_true")
cmp.set_defaults(handler=compare)
return parser
def main(argv=None):
args = build_parser().parse_args(argv)
try:
result = args.handler(args)
return result if isinstance(result, int) else 0
except (OSError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+90
View File
@@ -0,0 +1,90 @@
import tempfile
import unittest
from pathlib import Path
from PIL import Image
import screen_designs
class ScreenDesignsTests(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.temp_dir.name)
def tearDown(self):
self.temp_dir.cleanup()
def test_catalogue_is_well_formed(self):
names = [screen["name"] for screen in screen_designs.SCREENS]
self.assertEqual(len(names), len(set(names)),
"screen names must be unique")
refs = [screen["ref"] for screen in screen_designs.SCREENS
if screen["ref"]]
self.assertEqual(len(refs), len(set(refs)),
"screenshot refs must be unique")
for screen in screen_designs.SCREENS:
self.assertTrue(screen["prompt"].strip())
self.assertIn(screen["kind"], ("scene", "ui"))
self.assertTrue(screen["regions"])
for region in screen["regions"]:
x0, y0, x1, y1, label = region
self.assertLess(x0, x1)
self.assertLess(y0, y1)
self.assertTrue(0 <= x0 <= 1 and 0 <= x1 <= 1)
self.assertTrue(0 <= y0 <= 1 and 0 <= y1 <= 1)
self.assertTrue(label.strip())
def test_draft_wireframe_matches_reference_dimensions(self):
output = self.root / "draft.png"
screen = screen_designs.SCREENS[0]
screen_designs._draw_wireframe(screen, output)
with Image.open(output) as image:
self.assertEqual(image.size,
(screen_designs.WIDTH, screen_designs.HEIGHT))
def test_compose_prompt_appends_kind_specific_style(self):
scene = {"kind": "scene", "prompt": "a street"}
ui = {"kind": "ui", "prompt": "a panel"}
self.assertIn(screen_designs.SCENE_STYLE,
screen_designs._compose_prompt(scene))
self.assertIn(screen_designs.UI_STYLE,
screen_designs._compose_prompt(ui))
self.assertIn("16:9", screen_designs._compose_prompt(scene))
def test_mae_is_zero_for_identical_images(self):
path = self.root / "identical.png"
Image.new("RGB", (1280, 720), (40, 80, 120)).save(path)
left = screen_designs._load_rgb(path)
right = screen_designs._load_rgb(path)
self.assertAlmostEqual(screen_designs._mae(left, right), 0.0, places=1)
def test_histogram_distance_is_zero_for_identical_images(self):
path = self.root / "identical.png"
Image.new("RGB", (1280, 720), (40, 80, 120)).save(path)
left = screen_designs._load_rgb(path)
right = screen_designs._load_rgb(path)
self.assertAlmostEqual(
screen_designs._histogram_distance(left, right), 0.0, places=3)
def test_blank_frame_is_flagged_as_bad(self):
reference = self.root / "ref.png"
capture = self.root / "cap.png"
Image.new("RGB", (1280, 720), (200, 200, 200)).save(reference)
Image.new("RGB", (1280, 720), (0, 0, 0)).save(capture)
report = screen_designs.compare_screenshot(reference, capture)
self.assertEqual(report["quality"], "bad")
self.assertTrue(any("near-blank" in e for e in report["errors"]))
def test_wrong_aspect_is_flagged_as_bad(self):
reference = self.root / "ref.png"
capture = self.root / "cap.png"
Image.new("RGB", (1280, 720), (100, 120, 140)).save(reference)
Image.new("RGB", (640, 640), (100, 120, 140)).save(capture)
report = screen_designs.compare_screenshot(reference, capture)
self.assertEqual(report["quality"], "bad")
self.assertTrue(any("aspect" in e for e in report["errors"]))
if __name__ == "__main__":
unittest.main()