Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
294ac17333 | ||
|
|
e2c2a0bf4c | ||
|
|
1b976bf351 | ||
|
|
921bab796e |
No files matched your search
@@ -473,3 +473,46 @@ Completed 2026-10-31 (working tree). Headless **105/105**; `src/test/smoke_crowd
|
||||
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.
|
||||
|
||||
## NPC theater
|
||||
|
||||
Completed 2026-10-31 (working tree). Headless **127/127**; verified visually with a staged sit/photo/perform lineup.
|
||||
|
||||
|
||||
Intents `sit` / `photo` / `film` / `perform` already pick waypoints. ADR-0010 rig (face/hair)
|
||||
is in the working tree. Bodies still idle-stand at those spots.
|
||||
|
||||
- [x] Sit pose at `steps_end`. Photo: brief raise of the influencer/tourist prop. Performer
|
||||
stays put and plays the hat/sign.
|
||||
|
||||
## Audio
|
||||
|
||||
Completed 2026-10-31 (working tree). Headless **134/134**; `src/test/smoke_audio.gd` checks the bed follows mood and the radio crackles, headless and windowed (real audio server).
|
||||
|
||||
|
||||
`AudioFeedback` is sine beeps. DONE called that "crowd ambience"; it is not.
|
||||
|
||||
- [x] Loop a low crowd bed that rises with `CrowdSystem.mood`.
|
||||
- [x] Radio open/close crackle on the existing radio panel, still headless-safe (no-op without
|
||||
an audio server).
|
||||
@@ -21,11 +21,12 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
||||
Windowed control smoke: `godot --path . --script res://src/test/smoke_focus_radial.gd` → exit 0.
|
||||
- `main` is in sync with origin. **v0.2.2** tags the session-shell + world-follow-up commit;
|
||||
CI builds the portable binaries, NSIS installer, and AppImage on the tag push.
|
||||
- Next work: `TODO.md` emergency aftermath (evacuation, unjustified-call investigation), then
|
||||
body-camera replay timeline and NPC theater poses.
|
||||
- Next work: `TODO.md` accessibility (grayscale-safe escalation cues, HUD font scale in settings),
|
||||
then scenario content gaps.
|
||||
- Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg.
|
||||
|
||||
## Current State
|
||||
|
||||
- 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
|
||||
guest. `AccessVerifier.verify(..., already_screened=false)` so Joke Shop scoring uses
|
||||
@@ -94,6 +95,32 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
||||
`_begin_incident()` now starts one per focused NPC (`has_open_incident()` guard), verbs call
|
||||
`_ensure_incident()`, and `ShiftCoordinator._finish_shift()` closes leftovers as `unresolved`
|
||||
before building the review.
|
||||
- **Audio (2026-10-31):** `AudioFeedback.start_crowd_bed()` builds a 4 s looping `AudioStreamWAV`
|
||||
(one-pole-filtered noise + 68/103 Hz drone, seeded) and `set_crowd_level(mood)` maps 0..1 to
|
||||
-34..-12 dB. `play_radio_open/close` push short filtered-noise bursts. All procedural; with a
|
||||
dummy (headless) audio server the calls are no-ops. `_free_player()` returns null when `_ready`
|
||||
has not run (tests), and callers handle null.
|
||||
- **NPC theater (2026-10-31):** `NpcAvatar.theater_pose()` maps `intent` + `arrived` to
|
||||
idle/sit/photo/film/perform. Pure helpers `crouch_for_pose`, `prop_lift_for_pose`,
|
||||
`sway_for_pose` keep the math testable. Sit lowers the rig by 0.34 and swings the legs forward
|
||||
(negative X pitch; positive looked like kneeling). `_photo_prop` is set for influencer/tourist
|
||||
cameras, `_sign_prop` for protester/performer boards; their base Y values are captured at build
|
||||
time so the animation offsets from rest.
|
||||
- **Body-camera timeline (2026-10-31):** `src/game/ui/event_timeline.gd` flattens
|
||||
`review.incidents.incidents[].events` into one time-ordered list (`flatten`), formats each row
|
||||
with `describe`, and steps with Left/Right or the buttons. `ShiftCoordinator.started_at_ms`
|
||||
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),
|
||||
@@ -192,6 +219,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
|
||||
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
|
||||
|
||||
- Reference cycles are fixed: `FSM` holds its owner via `WeakRef`, `ShiftCoordinator` is
|
||||
@@ -222,6 +260,8 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
||||
|
||||
## 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.
|
||||
- Localization target languages beyond English.
|
||||
- macOS notarization approach for broad distribution.
|
||||
|
||||
@@ -28,8 +28,7 @@ The central design goal is simple:
|
||||
|
||||
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
|
||||
|
||||
> **Latest: v0.2.4** — crowd presentation (rubbernecking and panic), the distant skyline ring,
|
||||
> working NPC navigation, and a refreshed screenshot gallery.
|
||||
> **Latest: v0.2.8** — procedural crowd ambience driven by crowd mood, and radio crackle.
|
||||
> Releases are built automatically when a `vX.Y.Z` tag is pushed
|
||||
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
|
||||
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
|
||||
@@ -180,6 +179,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.
|
||||
|
||||
### 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
|
||||
|
||||
1. Download the Godot 4.3 editor (official build) and put `godot` on your PATH.
|
||||
@@ -214,6 +246,15 @@ 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
|
||||
|
||||
# Audio smoke: crowd bed follows mood, radio open/close crackle
|
||||
godot --headless --path . --script res://src/test/smoke_audio.gd
|
||||
```
|
||||
|
||||
The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor.
|
||||
@@ -266,7 +307,7 @@ Implemented so far:
|
||||
- Godot 4.3 project scaffold, input map, procedural world builder
|
||||
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
|
||||
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
|
||||
- Headless unit tests: **105 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)
|
||||
- Headless unit tests: **134 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio)
|
||||
- Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event
|
||||
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
|
||||
- Control pass: focus cone on NPC heads with a billboarded gold ring and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`)
|
||||
@@ -278,10 +319,14 @@ Implemented so far:
|
||||
- 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
|
||||
- NPC theater: seated guests sit on the steps with bent legs, influencers and tourists raise a camera prop while photographing, performers sway their sign, and filming visitors pan slowly — all once they reach their intent's waypoint
|
||||
- Audio: a looping procedural crowd bed (filtered murmur) whose volume follows `CrowdSystem.mood`, plus radio open/close crackle. All generated at runtime, no audio assets, and safe with a dummy audio server
|
||||
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
|
||||
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
|
||||
|
||||
Not yet implemented: body-camera replay timeline, 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
|
||||
|
||||
@@ -312,7 +357,10 @@ Not yet implemented: body-camera replay timeline, emergency aftermath, editor-ba
|
||||
25. Repeated alarms panic the crowd and nearby people clear away from the door instead of standing around.
|
||||
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. 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.
|
||||
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
|
||||
|
||||
|
||||
@@ -1,42 +1,10 @@
|
||||
# TODO
|
||||
|
||||
Godot 4.3 prototype. Release **v0.2.4** is tagged; CI builds the installers and AppImage.
|
||||
Headless suite **105/105**, plus `smoke_session_shell.gd`, `smoke_plan_verbs.gd`,
|
||||
`smoke_crowd.gd`, and the windowed focus/radial smoke.
|
||||
Godot 4.3 prototype. Release **v0.2.8** is tagged. Headless suite **134/134**, plus the session,
|
||||
PLAN-verb, crowd, emergency, timeline, audio, and windowed focus/radial smokes.
|
||||
|
||||
Host `REQUIRE_SIGNIN_VIEW` stays deferred.
|
||||
|
||||
## 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
|
||||
|
||||
Intents `sit` / `photo` / `film` / `perform` already pick waypoints. ADR-0010 rig (face/hair)
|
||||
is in the working tree. Bodies still idle-stand at those spots.
|
||||
|
||||
- [ ] Sit pose at `steps_end`. Photo: brief raise of the influencer/tourist prop. Performer
|
||||
stays put and plays the hat/sign.
|
||||
|
||||
## Audio
|
||||
|
||||
`AudioFeedback` is sine beeps. DONE called that "crowd ambience"; it is not.
|
||||
|
||||
- [ ] Loop a low crowd bed that rises with `CrowdSystem.mood`.
|
||||
- [ ] Radio open/close crackle on the existing radio panel, still headless-safe (no-op without
|
||||
an audio server).
|
||||
|
||||
## Accessibility
|
||||
|
||||
- [ ] Escalation colors that still read in grayscale (shape or label, not hue alone).
|
||||
@@ -66,6 +34,18 @@ Verified via API+host credentials (2026-10-31):
|
||||
|
||||
- [ ] 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)
|
||||
|
||||
- [ ] IDE/editor-based GUT integration. Until then the custom headless runner stays.
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
"radio.emergency_dispatched": "Emergency responder dispatched.",
|
||||
"radio.easter_egg": "¿Dónde está la mota?",
|
||||
"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.leave": "%s turns and leaves.",
|
||||
"react.argue": "%s argues with you.",
|
||||
@@ -62,5 +65,16 @@
|
||||
"flashlight.on": "Flashlight on.",
|
||||
"flashlight.off": "Flashlight off.",
|
||||
"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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 1000 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -8,7 +8,7 @@ config_version=5
|
||||
|
||||
config/name="Step Line"
|
||||
config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
|
||||
config/version="0.2.4"
|
||||
config/version="0.2.8"
|
||||
run/main_scene="res://scenes/main.tscn"
|
||||
config/features=PackedStringArray("4.3", "GL Compatibility")
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ signal phase_changed(phase: int)
|
||||
var scenario: VenueScenario = null
|
||||
var duration_s: float = 900.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 running := false
|
||||
|
||||
@@ -32,6 +34,10 @@ var crowd := CrowdSystem.new()
|
||||
var scheduler := EventScheduler.new()
|
||||
|
||||
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 _shift_finished := false
|
||||
## 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
|
||||
scheduler.setup(scenario, p_seed)
|
||||
event_bus = EventBus.new()
|
||||
responder_outcomes.clear()
|
||||
investigations.clear()
|
||||
radio = RadioSystem.new()
|
||||
reputation = ReputationEvaluator.new()
|
||||
crowd = CrowdSystem.new()
|
||||
@@ -59,6 +67,7 @@ func start() -> void:
|
||||
return
|
||||
running = true
|
||||
_shift_finished = false
|
||||
started_at_ms = Time.get_ticks_msec()
|
||||
elapsed_s = 0.0
|
||||
_spawn_accum_s = 0.0
|
||||
current_phase = _phase_for_time(0.0)
|
||||
@@ -131,6 +140,7 @@ func build_review() -> Dictionary:
|
||||
"scenario_id": scenario.id if scenario else "",
|
||||
"duration_s": duration_s,
|
||||
"elapsed_s": elapsed_s,
|
||||
"started_at_ms": started_at_ms,
|
||||
"phase_at_end": current_phase,
|
||||
"spawned_count": spawned_count,
|
||||
"incidents": recorder.to_dict(),
|
||||
@@ -140,6 +150,8 @@ func build_review() -> Dictionary:
|
||||
"battery": radio.battery,
|
||||
"emergency_cooldown_s": radio.emergency_cooldown_remaining,
|
||||
},
|
||||
"responder_outcomes": responder_outcomes.duplicate(),
|
||||
"investigations": investigations.duplicate(),
|
||||
"completed_at_ms": Time.get_ticks_msec(),
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ extends Node
|
||||
const SAMPLE_RATE := 22050.0
|
||||
|
||||
var _players: Array[AudioStreamPlayer] = []
|
||||
var _bed_player: AudioStreamPlayer = null
|
||||
## Observable state so headless tests and the HUD can see what was requested.
|
||||
var crowd_level: float = 0.0
|
||||
var bed_playing := false
|
||||
var radio_crackles := 0
|
||||
|
||||
func _ready() -> void:
|
||||
# Pre-create a few players so overlapping cues don't cut each other off.
|
||||
@@ -30,6 +35,43 @@ func play_escalation(level: int) -> void:
|
||||
var freq := 440.0 - float(level) * 40.0
|
||||
_play_tone(maxf(freq, 180.0), 0.18, 0.6)
|
||||
|
||||
## Start the looping crowd bed (low murmur). Called once per shift; safe to call
|
||||
## again. Volume then follows `set_crowd_level`.
|
||||
func start_crowd_bed() -> void:
|
||||
if _bed_player == null:
|
||||
_bed_player = AudioStreamPlayer.new()
|
||||
_bed_player.name = "CrowdBed"
|
||||
add_child(_bed_player)
|
||||
_bed_player.stream = _build_crowd_stream()
|
||||
_bed_player.volume_db = _level_to_db(crowd_level)
|
||||
_bed_player.play()
|
||||
bed_playing = true
|
||||
|
||||
func stop_crowd_bed() -> void:
|
||||
if _bed_player:
|
||||
_bed_player.stop()
|
||||
bed_playing = false
|
||||
|
||||
## Crowd mood (0..1) drives the bed volume: quiet when calm, louder when the
|
||||
## street is agitated or panicking.
|
||||
func set_crowd_level(mood: float) -> void:
|
||||
crowd_level = clampf(mood, 0.0, 1.0)
|
||||
if _bed_player:
|
||||
_bed_player.volume_db = _level_to_db(crowd_level)
|
||||
|
||||
static func _level_to_db(mood: float) -> float:
|
||||
return lerpf(-34.0, -12.0, clampf(mood, 0.0, 1.0))
|
||||
|
||||
## Radio open cue: a short burst of filtered crackle.
|
||||
func play_radio_open() -> void:
|
||||
radio_crackles += 1
|
||||
_play_noise(0.09, 0.35)
|
||||
|
||||
## Radio close cue: a lower, shorter click.
|
||||
func play_radio_close() -> void:
|
||||
radio_crackles += 1
|
||||
_play_noise(0.06, 0.25, 1400.0)
|
||||
|
||||
## Play a denial/error cue (low buzz).
|
||||
func play_deny() -> void:
|
||||
_play_tone(220.0, 0.20, 0.5)
|
||||
@@ -53,7 +95,64 @@ func _play_tone(freq: float, duration: float, volume: float, delay: float = 0.0)
|
||||
var sample := sin(TAU * freq * t) * 0.6
|
||||
playback.push_frame(Vector2(sample, sample))
|
||||
|
||||
## Deterministic low murmur for the crowd bed: filtered noise plus a low drone.
|
||||
func _build_crowd_stream() -> AudioStreamWAV:
|
||||
const SECONDS := 4.0
|
||||
var frames := int(SAMPLE_RATE * SECONDS)
|
||||
var data := PackedByteArray()
|
||||
data.resize(frames * 2)
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = 20261031
|
||||
var smooth := 0.0
|
||||
for i in frames:
|
||||
var t := float(i) / SAMPLE_RATE
|
||||
# Low-passed noise (a simple one-pole filter) sounds like distant talking.
|
||||
smooth = lerpf(smooth, rng.randf_range(-1.0, 1.0), 0.02)
|
||||
var drone := sin(TAU * 68.0 * t) * 0.25 + sin(TAU * 103.0 * t) * 0.12
|
||||
var sample := (smooth * 2.2 + drone) * 0.35
|
||||
var v := int(clampf(sample, -1.0, 1.0) * 32767.0)
|
||||
data[i * 2] = v & 0xFF
|
||||
data[i * 2 + 1] = (v >> 8) & 0xFF
|
||||
var stream := AudioStreamWAV.new()
|
||||
stream.format = AudioStreamWAV.FORMAT_16_BITS
|
||||
stream.mix_rate = int(SAMPLE_RATE)
|
||||
stream.stereo = false
|
||||
stream.data = data
|
||||
stream.loop_mode = AudioStreamWAV.LOOP_FORWARD
|
||||
stream.loop_begin = 0
|
||||
stream.loop_end = frames
|
||||
return stream
|
||||
|
||||
## Short filtered noise burst used for the radio crackle.
|
||||
func _play_noise(duration: float, volume: float, cutoff_hz: float = 3200.0) -> void:
|
||||
var player := _free_player()
|
||||
if player == null:
|
||||
return
|
||||
var frames := int(SAMPLE_RATE * duration)
|
||||
var data := PackedByteArray()
|
||||
data.resize(frames * 2)
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = 7
|
||||
var smooth := 0.0
|
||||
var alpha := clampf(cutoff_hz / SAMPLE_RATE, 0.01, 0.9)
|
||||
for i in frames:
|
||||
smooth = lerpf(smooth, rng.randf_range(-1.0, 1.0), alpha)
|
||||
var env := 1.0 - float(i) / float(maxi(frames, 1))
|
||||
var v := int(clampf(smooth * env, -1.0, 1.0) * 32767.0)
|
||||
data[i * 2] = v & 0xFF
|
||||
data[i * 2 + 1] = (v >> 8) & 0xFF
|
||||
var stream := AudioStreamWAV.new()
|
||||
stream.format = AudioStreamWAV.FORMAT_16_BITS
|
||||
stream.mix_rate = int(SAMPLE_RATE)
|
||||
stream.stereo = false
|
||||
stream.data = data
|
||||
player.stream = stream
|
||||
player.volume_db = linear_to_db(volume)
|
||||
player.play()
|
||||
|
||||
func _free_player() -> AudioStreamPlayer:
|
||||
if _players.is_empty():
|
||||
return null
|
||||
for player in _players:
|
||||
if not player.playing:
|
||||
return player
|
||||
|
||||
@@ -27,6 +27,7 @@ const ShiftReview := preload("res://src/game/ui/shift_review.gd")
|
||||
const StartMenu := preload("res://src/game/ui/start_menu.gd")
|
||||
const PauseOverlay := preload("res://src/game/ui/pause_overlay.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 door := DoorState.new()
|
||||
@@ -70,6 +71,8 @@ var shift_review_panel: ShiftReview = null
|
||||
var start_menu: StartMenu = null
|
||||
var pause_overlay: PauseOverlay = null
|
||||
var settings_panel: SettingsPanel = null
|
||||
var event_timeline: EventTimeline = null
|
||||
var _timeline_from_review := false
|
||||
var _shift_running := false
|
||||
var player_settings: Dictionary = {}
|
||||
var _focused_avatar: Node3D = null
|
||||
@@ -134,7 +137,7 @@ func _ready() -> void:
|
||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||
coordinator.npc_spawn_requested.connect(_spawn_npc)
|
||||
coordinator.shift_ended.connect(_on_shift_ended)
|
||||
coordinator.radio.responder_arrived.connect(_on_responder_arrived)
|
||||
_connect_radio()
|
||||
_connect_crowd()
|
||||
|
||||
_build_ui()
|
||||
@@ -222,6 +225,7 @@ func _reset_shift_state() -> void:
|
||||
hud.set_body_camera(false)
|
||||
scenario_duration_s = selected_scenario.shift_duration_s
|
||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||
_connect_radio()
|
||||
_connect_crowd()
|
||||
_last_phase = -1
|
||||
_apply_phase_lighting()
|
||||
@@ -229,6 +233,9 @@ func _reset_shift_state() -> void:
|
||||
## Begin (or resume) the shift clock and recapture the mouse.
|
||||
func _start_shift() -> void:
|
||||
_shift_running = true
|
||||
if audio:
|
||||
audio.start_crowd_bed()
|
||||
audio.set_crowd_level(coordinator.crowd.mood)
|
||||
coordinator.start()
|
||||
if hud:
|
||||
hud.set_shift_state(coordinator, selected_scenario)
|
||||
@@ -318,12 +325,15 @@ func _build_ui() -> void:
|
||||
radio_panel.submitted.connect(_on_radio_submitted)
|
||||
radio_panel.cancelled.connect(func():
|
||||
radio_panel.hide()
|
||||
if audio:
|
||||
audio.play_radio_close()
|
||||
hud.set_action_hint("")
|
||||
_refresh_freeze())
|
||||
shift_review_panel = ShiftReview.new()
|
||||
add_child(shift_review_panel)
|
||||
shift_review_panel.hide()
|
||||
shift_review_panel.restart_requested.connect(_on_review_restart)
|
||||
shift_review_panel.timeline_requested.connect(_open_timeline)
|
||||
shift_review_panel.visible = false
|
||||
|
||||
pause_overlay = PauseOverlay.new()
|
||||
@@ -339,6 +349,11 @@ func _build_ui() -> void:
|
||||
settings_panel.hide()
|
||||
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()
|
||||
add_child(start_menu)
|
||||
start_menu.hide()
|
||||
@@ -346,6 +361,23 @@ func _build_ui() -> void:
|
||||
start_menu.settings_requested.connect(_open_settings)
|
||||
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.
|
||||
func _on_review_restart() -> void:
|
||||
_show_start_menu()
|
||||
@@ -480,12 +512,51 @@ func _on_responder_arrived(call: Dictionary) -> void:
|
||||
scene_root.add_child(avatar)
|
||||
npcs.append(avatar)
|
||||
hud.log(Strings.fmt("responder.arrived", [outcome]))
|
||||
coordinator.responder_outcomes.append(outcome)
|
||||
coordinator.recorder.add_event(call.get("report", {}).get("npc_id", ""), {
|
||||
"type": "responder_action",
|
||||
"outcome": outcome,
|
||||
"call_type": call.get("type", "assist"),
|
||||
})
|
||||
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.
|
||||
@@ -500,6 +571,8 @@ func _connect_crowd() -> void:
|
||||
func _on_crowd_changed(payload: Dictionary) -> void:
|
||||
var event_type := str(payload.get("event", ""))
|
||||
var panic := bool(payload.get("panic", false))
|
||||
if audio:
|
||||
audio.set_crowd_level(float(payload.get("mood", 0.0)))
|
||||
# Panic wins: people clear away from the entrance.
|
||||
if panic or event_type == "crowd_panic":
|
||||
_flee_crowd()
|
||||
@@ -543,6 +616,8 @@ func _responder_profile() -> NpcProfile:
|
||||
|
||||
func _on_shift_ended(review: Dictionary) -> void:
|
||||
_shift_running = false
|
||||
if audio:
|
||||
audio.stop_crowd_bed()
|
||||
# Persist the finished shift locally (Save autoload, user:// JSON).
|
||||
if Save:
|
||||
Save.save_shift_result(review)
|
||||
@@ -735,9 +810,14 @@ func _close_top_modal() -> bool:
|
||||
return true
|
||||
if radio_panel and radio_panel.visible:
|
||||
radio_panel.hide()
|
||||
if audio:
|
||||
audio.play_radio_close()
|
||||
hud.set_action_hint("")
|
||||
_refresh_freeze()
|
||||
return true
|
||||
if event_timeline and event_timeline.visible:
|
||||
event_timeline.close()
|
||||
return true
|
||||
return false
|
||||
|
||||
func _open_radial() -> void:
|
||||
@@ -817,6 +897,8 @@ func _refresh_freeze() -> void:
|
||||
freeze = true
|
||||
if settings_panel and settings_panel.visible:
|
||||
freeze = true
|
||||
if event_timeline and event_timeline.visible:
|
||||
freeze = true
|
||||
if not _shift_running:
|
||||
freeze = true
|
||||
if guard:
|
||||
@@ -1055,9 +1137,13 @@ func _open_radio(emergency: bool) -> void:
|
||||
return
|
||||
radio_panel.open(controller, emergency, current_npc_truth)
|
||||
hud.set_action_hint("Fill in the radio report on screen.")
|
||||
if audio:
|
||||
audio.play_radio_open()
|
||||
_refresh_freeze()
|
||||
|
||||
func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
|
||||
if audio:
|
||||
audio.play_radio_close()
|
||||
hud.set_action_hint("")
|
||||
_refresh_freeze()
|
||||
var result: Dictionary = coordinator.radio.request_emergency(report) if emergency else coordinator.radio.request_assistance(report)
|
||||
@@ -1073,12 +1159,33 @@ func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
|
||||
if emergency:
|
||||
coordinator.crowd.apply_event("emergency_responder")
|
||||
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:
|
||||
coordinator.crowd.apply_event("calm_professionalism")
|
||||
if audio:
|
||||
audio.play_radio_response()
|
||||
_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?"
|
||||
## over the radio. Fires on a small random chance after a successful call.
|
||||
func _maybe_radio_easter_egg() -> void:
|
||||
|
||||
@@ -91,6 +91,13 @@ var _bob_phase := 0.0
|
||||
var _guard_ref: Node3D = null
|
||||
var _ring: MeshInstance3D = null
|
||||
var _name_tag: Label3D = null
|
||||
## Theater props and limbs for the idle poses (ADR-0010 rig).
|
||||
var _photo_prop: MeshInstance3D = null
|
||||
var _sign_prop: MeshInstance3D = null
|
||||
var _legs: Array[MeshInstance3D] = []
|
||||
var _theater_t := 0.0
|
||||
var _photo_prop_base_y := 0.0
|
||||
var _sign_prop_base_y := 0.0
|
||||
const SKIN_TONE := Color(0.88, 0.72, 0.60)
|
||||
const PANTS_COLOR := Color(0.22, 0.22, 0.26)
|
||||
const HAIR_COLORS := [Color(0.12, 0.1, 0.08), Color(0.35, 0.22, 0.12), Color(0.55, 0.42, 0.28), Color(0.15, 0.15, 0.18)]
|
||||
@@ -111,8 +118,8 @@ func _build_body(npc_type: int) -> void:
|
||||
# Torso
|
||||
_add_capsule(_rig, 0.30, 0.95, Vector3(0, 1.05, 0), base_color)
|
||||
# Legs
|
||||
_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), PANTS_COLOR)
|
||||
_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), PANTS_COLOR)
|
||||
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), PANTS_COLOR))
|
||||
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), PANTS_COLOR))
|
||||
# Arms
|
||||
_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0)
|
||||
_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0)
|
||||
@@ -170,7 +177,7 @@ func set_focus_visual(focused: bool, menu_open: bool = false) -> void:
|
||||
if _name_tag:
|
||||
_name_tag.visible = focused
|
||||
|
||||
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> void:
|
||||
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> MeshInstance3D:
|
||||
var mi := MeshInstance3D.new()
|
||||
var mesh := CapsuleMesh.new()
|
||||
mesh.radius = radius
|
||||
@@ -180,6 +187,7 @@ func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, co
|
||||
mi.rotation_degrees.z = z_rot
|
||||
mi.material_override = _material(color)
|
||||
parent.add_child(mi)
|
||||
return mi
|
||||
|
||||
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color) -> void:
|
||||
var mi := MeshInstance3D.new()
|
||||
@@ -201,7 +209,8 @@ func _add_props(npc_type: int) -> void:
|
||||
var chest_y := 1.25
|
||||
match npc_type:
|
||||
Rules.NpcType.INFLUENCER:
|
||||
_add_box_prop(_rig, Vector3(0.16, 0.1, 0.09), Vector3(0.3, chest_y, 0.22), Color(0.1, 0.1, 0.1))
|
||||
_photo_prop = _add_box_prop(_rig, Vector3(0.16, 0.1, 0.09), Vector3(0.3, chest_y, 0.22), Color(0.1, 0.1, 0.1))
|
||||
_photo_prop_base_y = _photo_prop.position.y
|
||||
_add_box_prop(_rig, Vector3(0.05, 0.05, 0.06), Vector3(0.34, chest_y + 0.06, 0.30), Color(0.4, 0.7, 1.0))
|
||||
Rules.NpcType.DELIVERY:
|
||||
_add_box_prop(_rig, Vector3(0.34, 0.26, 0.28), Vector3(0.0, 1.02, 0.3), Color(0.55, 0.4, 0.25))
|
||||
@@ -216,15 +225,22 @@ func _add_props(npc_type: int) -> void:
|
||||
cone.material_override = hat
|
||||
cone.position = Vector3(0, 0.26, 0)
|
||||
_head.add_child(cone)
|
||||
# Hat in one hand and a small board in the other: the performer plays
|
||||
# to the sidewalk once at the waypoint.
|
||||
_add_box_prop(_rig, Vector3(0.06, 0.5, 0.06), Vector3(-0.42, 1.3, 0.14), Color(0.45, 0.35, 0.25))
|
||||
_sign_prop = _add_box_prop(_rig, Vector3(0.42, 0.32, 0.03), Vector3(-0.46, 1.6, 0.14), Color(0.95, 0.9, 0.75))
|
||||
_sign_prop_base_y = _sign_prop.position.y
|
||||
Rules.NpcType.PROTESTER:
|
||||
_add_box_prop(_rig, Vector3(0.05, 1.2, 0.05), Vector3(0.28, 1.45, 0.18), Color(0.4, 0.36, 0.32))
|
||||
_add_box_prop(_rig, Vector3(0.5, 0.42, 0.04), Vector3(0.48, 1.55, 0.18), Color(0.93, 0.93, 0.95))
|
||||
_sign_prop = _add_box_prop(_rig, Vector3(0.5, 0.42, 0.04), Vector3(0.48, 1.55, 0.18), Color(0.93, 0.93, 0.95))
|
||||
_sign_prop_base_y = _sign_prop.position.y
|
||||
Rules.NpcType.TOURIST:
|
||||
_add_box_prop(_rig, Vector3(0.3, 0.36, 0.14), Vector3(-0.34, 1.15, -0.18), Color(0.25, 0.3, 0.45))
|
||||
_photo_prop = _add_box_prop(_rig, Vector3(0.3, 0.36, 0.14), Vector3(-0.34, 1.15, -0.18), Color(0.25, 0.3, 0.45))
|
||||
_photo_prop_base_y = _photo_prop.position.y
|
||||
Rules.NpcType.CONFUSED_VISITOR:
|
||||
_add_box_prop(_rig, Vector3(0.3, 0.05, 0.34), Vector3(0, 0.92, 0.06), Color(0.9, 0.88, 0.8))
|
||||
|
||||
func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) -> void:
|
||||
func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) -> MeshInstance3D:
|
||||
var mi := MeshInstance3D.new()
|
||||
var mesh := BoxMesh.new()
|
||||
mesh.size = size
|
||||
@@ -232,6 +248,7 @@ func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) ->
|
||||
mi.position = pos
|
||||
mi.material_override = _material(color)
|
||||
parent.add_child(mi)
|
||||
return mi
|
||||
|
||||
## Rubberneck: idle people stop and turn toward the incident. Returns false when
|
||||
## this NPC is too engaged (walking, being talked to, already leaving) to watch.
|
||||
@@ -309,14 +326,70 @@ func _physics_process(delta: float) -> void:
|
||||
_animate(delta)
|
||||
|
||||
## Procedural walk bob + head-toward-guard cue when the NPC is aware.
|
||||
## Theater pose for the current intent once the NPC has stopped walking.
|
||||
## "sit" at the steps, "photo" for camera props, "perform" for the hat/sign,
|
||||
## "film" for a slow pan, otherwise "idle".
|
||||
func theater_pose() -> String:
|
||||
if not arrived:
|
||||
return "idle"
|
||||
match npc.intent:
|
||||
"sit":
|
||||
return "sit"
|
||||
"photo":
|
||||
return "photo"
|
||||
"film":
|
||||
return "film"
|
||||
"perform":
|
||||
return "perform"
|
||||
_:
|
||||
return "idle"
|
||||
|
||||
## How far the rig drops for a seated pose. Pure so it is headless-testable.
|
||||
static func crouch_for_pose(pose: String) -> float:
|
||||
return -0.34 if pose == "sit" else 0.0
|
||||
|
||||
## Vertical lift of a held camera prop. `phase` is seconds; the raise is brief so
|
||||
## it reads as taking a photo rather than a permanently raised arm.
|
||||
static func prop_lift_for_pose(pose: String, phase: float) -> float:
|
||||
if pose != "photo" and pose != "film":
|
||||
return 0.0
|
||||
return maxf(0.0, sin(phase * 1.7)) * 0.16
|
||||
|
||||
## Sway amount for a performer playing to the sidewalk.
|
||||
static func sway_for_pose(pose: String, phase: float) -> float:
|
||||
if pose != "perform":
|
||||
return 0.0
|
||||
return sin(phase * 2.2) * 0.09
|
||||
|
||||
func _animate(delta: float) -> void:
|
||||
if _rig == null:
|
||||
return
|
||||
var speed := Vector2(velocity.x, velocity.z).length()
|
||||
var pose := theater_pose()
|
||||
var crouch := crouch_for_pose(pose)
|
||||
if speed > 0.3:
|
||||
_bob_phase += delta * (6.0 + speed)
|
||||
_rig.position.y = sin(_bob_phase) * 0.035
|
||||
_rig.rotation.x = sin(_bob_phase * 0.5) * 0.04
|
||||
elif pose != "idle" or not is_zero_approx(_rig.position.y):
|
||||
# Idle theater: seated, photographing, filming, or performing.
|
||||
_theater_t += delta
|
||||
_bob_phase = 0.0
|
||||
_rig.position.y = lerpf(_rig.position.y, crouch, 0.12)
|
||||
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
|
||||
var lift := prop_lift_for_pose(pose, _theater_t)
|
||||
if _photo_prop:
|
||||
_photo_prop.position.y = _photo_prop_base_y + lift
|
||||
_photo_prop.rotation.x = -lift * 1.4
|
||||
var sway := sway_for_pose(pose, _theater_t)
|
||||
if _sign_prop:
|
||||
_sign_prop.rotation.z = sway
|
||||
_sign_prop.position.y = _sign_prop_base_y + absf(sway) * 0.25
|
||||
if _legs.size() == 2:
|
||||
# Negative pitch swings the legs forward (toward the way the body faces).
|
||||
var leg_angle := deg_to_rad(-78.0) if pose == "sit" else 0.0
|
||||
_legs[0].rotation.x = lerpf(_legs[0].rotation.x, leg_angle, 0.12)
|
||||
_legs[1].rotation.x = lerpf(_legs[1].rotation.x, leg_angle, 0.12)
|
||||
else:
|
||||
_bob_phase = 0.0
|
||||
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)
|
||||
|
||||
@@ -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()
|
||||
@@ -2,7 +2,13 @@ class_name ShiftReview
|
||||
extends CanvasLayer
|
||||
## 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 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
|
||||
|
||||
@@ -32,6 +38,13 @@ func _init() -> void:
|
||||
report_label.custom_minimum_size = Vector2(720, 0)
|
||||
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()
|
||||
restart.text = "Run another shift"
|
||||
restart.pressed.connect(func(): restart_requested.emit())
|
||||
@@ -45,10 +58,13 @@ func _init() -> void:
|
||||
center.add_child(quit_btn)
|
||||
|
||||
func show_review(review: Dictionary, scenario: Resource, history: Array = []) -> void:
|
||||
_review = review
|
||||
var reputation: Dictionary = review.get("reputation", {})
|
||||
var incidents: Dictionary = review.get("incidents", {})
|
||||
var crowd: Dictionary = review.get("crowd", {})
|
||||
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 peaceful := 0
|
||||
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),
|
||||
"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))],
|
||||
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:",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
extends SceneTree
|
||||
## Audio smoke: starts a shift, verifies the crowd bed is playing and follows mood
|
||||
## events, and that opening/closing the radio triggers crackle cues.
|
||||
##
|
||||
## Usage:
|
||||
## godot --headless --path . --script res://src/test/smoke_audio.gd
|
||||
## (Headless uses a dummy audio server; the calls must still be safe.)
|
||||
|
||||
const MAX_FRAMES := 900
|
||||
|
||||
var frame := 0
|
||||
var gm: Node = null
|
||||
var started := false
|
||||
var step := 0
|
||||
var failures: Array[String] = []
|
||||
|
||||
func _initialize() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
root.add_child(load("res://scenes/main.tscn").instantiate())
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
frame += 1
|
||||
if gm == null:
|
||||
gm = root.find_child("GameManager", true, false)
|
||||
return false
|
||||
if not started:
|
||||
gm.call("_on_venue_selected", "hollywood_building")
|
||||
started = true
|
||||
return false
|
||||
if frame > MAX_FRAMES:
|
||||
_fail("timeout")
|
||||
return true
|
||||
if frame < 120:
|
||||
return false
|
||||
match step:
|
||||
0:
|
||||
_check(gm.audio.bed_playing, "the crowd bed is playing during the shift")
|
||||
var before: float = gm.audio.crowd_level
|
||||
gm.coordinator.crowd.apply_event("aggressive_behavior")
|
||||
_check(gm.audio.crowd_level > before, "an aggressive event raises the bed level")
|
||||
gm.coordinator.crowd.apply_event("emergency_responder")
|
||||
_check(gm.audio.crowd_level > before, "the responder keeps it up")
|
||||
step = 1
|
||||
1:
|
||||
if gm.npcs.is_empty():
|
||||
return false
|
||||
gm.call("_on_guard_target", gm.npcs[0])
|
||||
var crackles: int = gm.audio.radio_crackles
|
||||
gm.call("_open_radio", false)
|
||||
_check(gm.audio.radio_crackles == crackles + 1, "opening the radio crackles once")
|
||||
gm.call("_on_radio_submitted", {
|
||||
"npc_id": gm.current_incident_npc_id,
|
||||
"location": "entrance",
|
||||
"description": "tourist",
|
||||
"behavior": "standing",
|
||||
"weapons_seen": false,
|
||||
"danger_level": 1,
|
||||
}, false)
|
||||
_check(gm.audio.radio_crackles == crackles + 2, "closing the radio crackles once")
|
||||
gm.coordinator.call("_finish_shift")
|
||||
_check(not gm.audio.bed_playing, "the bed stops when the shift ends")
|
||||
step = 2
|
||||
2:
|
||||
_finish()
|
||||
return true
|
||||
return false
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
if condition:
|
||||
print("ok: " + message)
|
||||
else:
|
||||
_fail(message)
|
||||
|
||||
func _fail(message: String) -> void:
|
||||
failures.append(message)
|
||||
_finish()
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("SMOKE OK: crowd bed and radio crackle")
|
||||
quit(0)
|
||||
else:
|
||||
for message in failures:
|
||||
printerr("SMOKE FAIL: " + message)
|
||||
quit(1)
|
||||
@@ -0,0 +1,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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,78 @@
|
||||
extends RefCounted
|
||||
## Audio feedback: a looping crowd bed that follows the crowd mood, and radio
|
||||
## crackle on open/close. All procedural, no audio assets, headless-safe.
|
||||
|
||||
const AudioFeedback := preload("res://src/game/audio_feedback.gd")
|
||||
const CrowdSystem := preload("res://src/core/crowd_system.gd")
|
||||
|
||||
func test_bed_volume_follows_mood() -> bool:
|
||||
var quiet: float = AudioFeedback._level_to_db(0.0)
|
||||
var loud: float = AudioFeedback._level_to_db(1.0)
|
||||
var mid: float = AudioFeedback._level_to_db(0.5)
|
||||
var a := TestUtils.check(quiet < -30.0, "a calm street keeps the bed quiet")
|
||||
var b := TestUtils.check(loud > quiet, "panic raises the bed")
|
||||
var c := TestUtils.check(mid > quiet and mid < loud, "the rise is gradual")
|
||||
return a and b and c
|
||||
|
||||
func test_crowd_level_is_clamped() -> bool:
|
||||
var audio: Node = AudioFeedback.new()
|
||||
audio.set_crowd_level(-1.0)
|
||||
var a := TestUtils.check_eq(audio.crowd_level, 0.0, "negative mood clamps to 0")
|
||||
audio.set_crowd_level(3.0)
|
||||
var b := TestUtils.check_eq(audio.crowd_level, 1.0, "over-unity mood clamps to 1")
|
||||
audio.free()
|
||||
return a and b
|
||||
|
||||
func test_crowd_stream_loops() -> bool:
|
||||
var audio: Node = AudioFeedback.new()
|
||||
var stream: AudioStreamWAV = audio._build_crowd_stream()
|
||||
var a := TestUtils.check(stream != null, "a crowd stream is generated")
|
||||
var b := TestUtils.check_eq(stream.loop_mode, AudioStreamWAV.LOOP_FORWARD, "the bed loops")
|
||||
var c := TestUtils.check(stream.data.size() > 1000, "the bed has audio data")
|
||||
var d := TestUtils.check(stream.data.size() == stream.loop_end * 2, "loop covers the whole buffer")
|
||||
audio.free()
|
||||
return a and b and c and d
|
||||
|
||||
func test_crowd_bed_is_headless_safe() -> bool:
|
||||
# No tree, no audio server: none of these may crash.
|
||||
var audio: Node = AudioFeedback.new()
|
||||
audio.start_crowd_bed()
|
||||
var a := TestUtils.check(audio.bed_playing, "start marks the bed as playing")
|
||||
audio.set_crowd_level(0.78)
|
||||
var b := TestUtils.check(is_equal_approx(audio.crowd_level, 0.78), "level is stored")
|
||||
audio.stop_crowd_bed()
|
||||
var c := TestUtils.check(not audio.bed_playing, "stop clears the playing flag")
|
||||
audio.free()
|
||||
return a and b and c
|
||||
|
||||
func test_radio_crackle_methods() -> bool:
|
||||
var audio: Node = AudioFeedback.new()
|
||||
audio.play_radio_open()
|
||||
audio.play_radio_close()
|
||||
var a := TestUtils.check_eq(audio.radio_crackles, 2, "open and close each count a crackle")
|
||||
var b := TestUtils.check(audio._free_player() == null, "no players exist before _ready, and that is safe")
|
||||
audio.free()
|
||||
return a and b
|
||||
|
||||
func test_game_manager_wires_audio() -> bool:
|
||||
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
||||
var a := TestUtils.check(src.contains("audio.start_crowd_bed()"), "the bed starts with the shift")
|
||||
var b := TestUtils.check(src.contains("audio.set_crowd_level(float(payload.get("),
|
||||
"crowd events drive the bed")
|
||||
var c := TestUtils.check(src.contains("audio.stop_crowd_bed()"), "the bed stops at shift end")
|
||||
var d := TestUtils.check(src.contains("audio.play_radio_open()"), "opening the radio crackles")
|
||||
var e := TestUtils.check(src.contains("audio.play_radio_close()"), "closing the radio crackles")
|
||||
return a and b and c and d and e
|
||||
|
||||
func test_mood_events_reach_the_bed() -> bool:
|
||||
## The bed level is fed from the same payload the crowd reactions use.
|
||||
var crowd := CrowdSystem.new()
|
||||
var seen: Array = []
|
||||
crowd.changed.connect(func(_p): pass)
|
||||
crowd.apply_event("aggressive_behavior")
|
||||
seen.append(crowd.mood)
|
||||
crowd.apply_event("emergency_responder")
|
||||
seen.append(crowd.mood)
|
||||
var a := TestUtils.check(seen[1] > seen[0], "mood rises with a responder arrival")
|
||||
crowd = null
|
||||
return a
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,98 @@
|
||||
extends RefCounted
|
||||
## NPC theater: seated guests, camera raises, and performers playing to the
|
||||
## sidewalk once their intent's waypoint is reached (PLAN.md: NPC Behavior Types).
|
||||
|
||||
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
|
||||
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
|
||||
func _avatar(intent: String, npc_type: int = Rules.NpcType.TOURIST) -> Node:
|
||||
var p := NpcProfile.new()
|
||||
p.id = "theater"
|
||||
p.display_name = "Theater"
|
||||
p.npc_type = npc_type
|
||||
var avatar: Node = NpcAvatar.new()
|
||||
avatar.setup(p, intent, 1234, Vector3(2, 0.5, 8), Vector3(0, 0.5, 6))
|
||||
return avatar
|
||||
|
||||
func test_pose_per_intent() -> bool:
|
||||
var cases := {
|
||||
"sit": "sit",
|
||||
"photo": "photo",
|
||||
"film": "film",
|
||||
"perform": "perform",
|
||||
"enter": "idle",
|
||||
}
|
||||
var ok := true
|
||||
for intent in cases.keys():
|
||||
var avatar := _avatar(str(intent))
|
||||
avatar.arrived = true
|
||||
if avatar.theater_pose() != cases[intent]:
|
||||
ok = false
|
||||
avatar.free()
|
||||
return TestUtils.check(ok, "each intent maps to its theater pose once arrived")
|
||||
|
||||
func test_walking_npc_has_no_pose() -> bool:
|
||||
var avatar := _avatar("sit")
|
||||
var a := TestUtils.check_eq(avatar.theater_pose(), "idle", "a walking NPC does not sit yet")
|
||||
avatar.arrived = true
|
||||
var b := TestUtils.check_eq(avatar.theater_pose(), "sit", "arriving starts the pose")
|
||||
avatar.free()
|
||||
return a and b
|
||||
|
||||
func test_only_sit_crouches() -> bool:
|
||||
var a := TestUtils.check(is_equal_approx(NpcAvatar.crouch_for_pose("sit"), -0.34), "sit lowers the rig")
|
||||
var b := TestUtils.check_eq(NpcAvatar.crouch_for_pose("photo"), 0.0, "photo keeps standing")
|
||||
var c := TestUtils.check_eq(NpcAvatar.crouch_for_pose("perform"), 0.0, "perform keeps standing")
|
||||
var d := TestUtils.check_eq(NpcAvatar.crouch_for_pose("idle"), 0.0, "idle keeps standing")
|
||||
return a and b and c and d
|
||||
|
||||
func test_photo_lift_is_brief() -> bool:
|
||||
var raised := 0.0
|
||||
var flat := 0.0
|
||||
for i in 120:
|
||||
var phase := float(i) / 60.0
|
||||
var lift: float = NpcAvatar.prop_lift_for_pose("photo", phase)
|
||||
raised = maxf(raised, lift)
|
||||
if i == 0:
|
||||
flat = lift
|
||||
var a := TestUtils.check(raised > 0.1, "the camera prop lifts while photographing")
|
||||
var b := TestUtils.check(raised <= 0.16 + 0.001, "the lift stays small")
|
||||
var c := TestUtils.check_eq(flat, 0.0, "the lift starts at rest")
|
||||
var d := TestUtils.check_eq(NpcAvatar.prop_lift_for_pose("sit", 0.5), 0.0, "seated guests do not lift a prop")
|
||||
return a and b and c and d
|
||||
|
||||
func test_performer_sways_only_when_performing() -> bool:
|
||||
var peak := 0.0
|
||||
for i in 120:
|
||||
peak = maxf(peak, absf(NpcAvatar.sway_for_pose("perform", float(i) / 60.0)))
|
||||
var a := TestUtils.check(peak > 0.05, "the performer's sign sways")
|
||||
var b := TestUtils.check_eq(NpcAvatar.sway_for_pose("photo", 1.0), 0.0, "photographers do not sway")
|
||||
var c := TestUtils.check_eq(NpcAvatar.sway_for_pose("sit", 1.0), 0.0, "seated guests do not sway")
|
||||
return a and b and c
|
||||
|
||||
func test_photo_prop_is_wired_for_camera_archetypes() -> bool:
|
||||
var influencer := _avatar("photo", Rules.NpcType.INFLUENCER)
|
||||
var tourist := _avatar("photo", Rules.NpcType.TOURIST)
|
||||
var performer := _avatar("perform", Rules.NpcType.PERFORMER)
|
||||
var a := TestUtils.check(influencer._photo_prop != null, "influencer carries a camera prop")
|
||||
var b := TestUtils.check(tourist._photo_prop != null, "tourist carries a camera prop")
|
||||
var c := TestUtils.check(performer._sign_prop != null, "performer carries a sign prop")
|
||||
influencer.free()
|
||||
tourist.free()
|
||||
performer.free()
|
||||
return a and b and c
|
||||
|
||||
func test_sitting_rig_actually_lowers() -> bool:
|
||||
var avatar := _avatar("sit")
|
||||
avatar.arrived = true
|
||||
for i in 40:
|
||||
avatar._animate(0.016)
|
||||
var a := TestUtils.check(avatar._rig.position.y < -0.2, "the rig settles into the crouch")
|
||||
var legs_bent := TestUtils.check(absf(avatar._legs[0].rotation.x) > deg_to_rad(30.0), "legs bend for the seat")
|
||||
avatar.arrived = false
|
||||
for i in 60:
|
||||
avatar._animate(0.016)
|
||||
var b := TestUtils.check(avatar._rig.position.y > -0.1, "standing back up lifts the rig")
|
||||
avatar.free()
|
||||
return a and legs_bent and b
|
||||
@@ -0,0 +1,621 @@
|
||||
#!/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 friendly pedestrian "
|
||||
"standing on a city sidewalk, a thin pale-gold ring floating around "
|
||||
"their head as a game selection marker, a small name label under the "
|
||||
"ring, minimal HUD target text at the bottom, daytime"),
|
||||
"regions": [
|
||||
(0.35, 0.15, 0.65, 0.60, "NPC in focus"),
|
||||
(0.44, 0.10, 0.56, 0.16, "head ring"),
|
||||
(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, a threat "
|
||||
"assessment and whether anyone is in danger, and a Send button, dark "
|
||||
"translucent panel over a softly blurred city street background"),
|
||||
"regions": [
|
||||
(0.30, 0.08, 0.70, 0.92, "radio panel"),
|
||||
(0.34, 0.16, 0.66, 0.30, "location / description"),
|
||||
(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)
|
||||
failed = []
|
||||
for screen in SCREENS:
|
||||
print(f"generating {screen['name']} ...")
|
||||
try:
|
||||
if args.draft:
|
||||
_draw_wireframe(screen, _design_path(screen["name"]))
|
||||
else:
|
||||
_generate_ai(screen, args.model, args.request_size)
|
||||
except SystemExit as exc:
|
||||
failed.append(screen["name"])
|
||||
print(f" FAILED {screen['name']}: {exc}", file=sys.stderr)
|
||||
print("done")
|
||||
if failed:
|
||||
print(f"failed: {', '.join(failed)}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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())
|
||||
@@ -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()
|
||||