Add body-camera timeline to the shift review
Full build / Import + headless tests (push) Successful in 30s
Full build / Full desktop build + optional release (push) Failing after 3m38s

- New src/game/ui/event_timeline.gd flattens every recorded incident event
  (ADR-0005) into one time-ordered list and shows time, person, and a one-line
  summary. Step with Left/Right or the buttons; Esc returns to the review. No live
  3D replay in this first version.
- Shift review gains a "Body camera timeline" button that hands the review to the
  timeline; the review dim is hidden while it is open (stacked overlays made the
  screen nearly black) and restored on close.
- ShiftCoordinator records started_at_ms so the review can stamp events as mm:ss.
- 8 new string keys for the timeline UI and the review button.

Tests/docs
- Headless suite 113 -> 120 (test_event_timeline.gd), new smoke_timeline.gd that
  opens the timeline from the review, steps through every event, and closes back.
- README, TODO section moved to DONE, MEMORY notes. Version 0.2.6.
This commit is contained in:
zwitschi committed 2026-10-31 21:02:25 +01:00
1 parent 921bab796e
commit 1b976bf351
14 files changed
+614 -71

No files matched your search

+8
View File
@@ -486,3 +486,11 @@ wants evacuation, a closed street, and an investigation when the call was unjust
- [x] Unjustified emergency (low danger, no weapon) writes an investigation note on the shift - [x] Unjustified emergency (low danger, no weapon) writes an investigation note on the shift
review and applies the existing false-report / reputation hit. review and applies the existing false-report / reputation hit.
- [x] Review copy names the responder outcome, not only battery and crowd %. - [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.
+12 -4
View File
@@ -21,11 +21,11 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
Windowed control smoke: `godot --path . --script res://src/test/smoke_focus_radial.gd` → exit 0. Windowed control smoke: `godot --path . --script res://src/test/smoke_focus_radial.gd` → exit 0.
- `main` is in sync with origin. **v0.2.2** tags the session-shell + world-follow-up commit; - `main` is in sync with origin. **v0.2.2** tags the session-shell + world-follow-up commit;
CI builds the portable binaries, NSIS installer, and AppImage on the tag push. CI builds the portable binaries, NSIS installer, and AppImage on the tag push.
- Next work: `TODO.md` body-camera replay timeline, then NPC theater poses (sit/photo/perform) - Next work: `TODO.md` NPC theater poses (sit/photo/perform), then audio and accessibility.
and audio.
- Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg. - Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg.
## Current State ## Current State
- Access artifacts: `src/core/access_loadout.gd` builds from event `override_access` + venue policy. - Access artifacts: `src/core/access_loadout.gd` builds from event `override_access` + venue policy.
Spawn signal is now `(profile, intent, npc_id, event)`. Recognition is not auto-attached to every Spawn signal is now `(profile, intent, npc_id, event)`. Recognition is not auto-attached to every
guest. `AccessVerifier.verify(..., already_screened=false)` so Joke Shop scoring uses guest. `AccessVerifier.verify(..., already_screened=false)` so Joke Shop scoring uses
@@ -94,6 +94,12 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
`_begin_incident()` now starts one per focused NPC (`has_open_incident()` guard), verbs call `_begin_incident()` now starts one per focused NPC (`has_open_incident()` guard), verbs call
`_ensure_incident()`, and `ShiftCoordinator._finish_shift()` closes leftovers as `unresolved` `_ensure_incident()`, and `ShiftCoordinator._finish_shift()` closes leftovers as `unresolved`
before building the review. before building the review.
- **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 - **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 `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 switch/restart (responders never resolved anything). `_connect_radio()` now re-wires it next to
@@ -204,8 +210,10 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
- Screen-design references (`tools/screen_designs.py`, tests in `tools/test_screen_designs.py`): - 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, `generate` turns the screen descriptions in `docs/` (README captions, ADRs 0007–0010,
PLAN, architecture) into reference images under `docs/screens/designs/` — `--draft` PLAN, architecture) into reference images under `docs/screens/designs/` — `--draft`
draws offline Pillow wireframes, `--ai` generates OpenRouter mockups (default model draws offline Pillow wireframes, `--ai` generates realistic 3D scenes / in-game UI via
`openai/gpt-5-image-mini`). `compare` checks `docs/screenshots/*.png` for aspect/size/ 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 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 failures: a mockup never matches a real render, so semantic composition is a follow-up
vision-model judge (tracked in TODO). vision-model judge (tracked in TODO).
+17 -6
View File
@@ -28,8 +28,8 @@ The central design goal is simple:
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases> **All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
> **Latest: v0.2.5** — emergency aftermath (evacuation clears the entrance, unjustified calls open > **Latest: v0.2.6** — body-camera timeline in the shift review, stepping through every recorded
> an investigation, the review names the responder outcome). > incident event.
> Releases are built automatically when a `vX.Y.Z` tag is pushed > Releases are built automatically when a `vX.Y.Z` tag is pushed
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use > (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline > `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
@@ -187,11 +187,17 @@ screen descriptions in `docs/` (README captions, ADRs 0007–0010, `PLAN.md`, an
`architecture.md`) into reference images under `docs/screens/designs/`, then uses `architecture.md`) into reference images under `docs/screens/designs/`, then uses
those images as the expected state to check the game's captures. 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 ```bash
# Deterministic wireframes (offline, no API key) # Deterministic wireframes (offline, no API key)
python3 tools/screen_designs.py generate --draft python3 tools/screen_designs.py generate --draft
# Photorealistic mockups via OpenRouter (requires OPENROUTER_API_KEY) # Realistic 3D scenes and in-game screen designs via OpenRouter
# (requires OPENROUTER_API_KEY)
python3 tools/screen_designs.py generate python3 tools/screen_designs.py generate
# Compare docs/screenshots/*.png against the designs # Compare docs/screenshots/*.png against the designs
@@ -244,6 +250,9 @@ godot --headless --path . --script res://src/test/smoke_crowd.gd
# Emergency smoke: unjustified call, investigation note, evacuation aftermath # Emergency smoke: unjustified call, investigation note, evacuation aftermath
godot --headless --path . --script res://src/test/smoke_emergency.gd godot --headless --path . --script res://src/test/smoke_emergency.gd
# Body-camera timeline smoke: review → timeline → step through → back to review
godot --headless --path . --script res://src/test/smoke_timeline.gd
``` ```
The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor. The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor.
@@ -296,7 +305,7 @@ Implemented so far:
- Godot 4.3 project scaffold, input map, procedural world builder - Godot 4.3 project scaffold, input map, procedural world builder
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem` - Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools - Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
- Headless unit tests: **113 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) - Headless unit tests: **120 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline)
- Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event - Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review - First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
- Control pass: focus cone on NPC heads with a billboarded gold ring and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`) - Control pass: focus cone on NPC heads with a billboarded gold ring and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`)
@@ -309,10 +318,11 @@ Implemented so far:
- 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 - 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 - 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 - Emergency aftermath: the `evacuation` outcome locks the door and clears the entrance, an emergency call that the situation did not justify opens an investigation and applies the false-report hit, and the shift review names the responder outcome and any investigations
- Body-camera timeline: the shift review opens a text timeline of every recorded incident event (time, person, action/outcome) that you step through with the arrow keys or buttons. No live 3D replay yet
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets - Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock) - Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
Not yet implemented: body-camera replay timeline, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`). Not yet implemented: live 3D body-camera replay, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
### Smoke Test Checklist ### Smoke Test Checklist
@@ -345,7 +355,8 @@ Not yet implemented: body-camera replay timeline, editor-based GUT integration,
27. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run). 27. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run).
28. An unjustified emergency call (calm person, no weapon) logs an investigation and costs reputation. 28. An unjustified emergency call (calm person, no weapon) logs an investigation and costs reputation.
29. After an evacuation outcome the door is locked and NPCs are leaving the entrance. 29. After an evacuation outcome the door is locked and NPCs are leaving the entrance.
30. 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. 30. The shift review's "Body camera timeline" button steps through the recorded events (Left/Right or the buttons) and Esc returns to the review.
31. When the shift timer reaches 0, the shift review screen shows grade, incident summary, responder outcomes, investigations, and persisted shift history; "Run another shift" returns to the venue menu.
## Development Workflow ## Development Workflow
+3 -8
View File
@@ -1,16 +1,11 @@
# TODO # TODO
Godot 4.3 prototype. Release **v0.2.5** is tagged. Headless suite **113/113**, plus Godot 4.3 prototype. Release **v0.2.6** is tagged. Headless suite **120/120**, plus
`smoke_session_shell.gd`, `smoke_plan_verbs.gd`, `smoke_crowd.gd`, `smoke_emergency.gd`, and the `smoke_session_shell.gd`, `smoke_plan_verbs.gd`, `smoke_crowd.gd`, `smoke_emergency.gd`,
windowed focus/radial smoke. `smoke_timeline.gd`, and the windowed focus/radial smoke.
Host `REQUIRE_SIGNIN_VIEW` stays deferred. Host `REQUIRE_SIGNIN_VIEW` stays deferred.
## Body-camera replay
- [ ] Shift review: a timeline of recorded incident events (ADR-0005 already stores them).
Scrub or step through; no live 3D replay required in the first version.
## NPC theater ## NPC theater
Intents `sit` / `photo` / `film` / `perform` already pick waypoints. ADR-0010 rig (face/hair) Intents `sit` / `photo` / `film` / `perform` already pick waypoints. ADR-0010 rig (face/hair)
+9 -1
View File
@@ -68,5 +68,13 @@
"wand.recharge": "Wand scanner is recharging…", "wand.recharge": "Wand scanner is recharging…",
"review.responder": "Responder outcomes: %s", "review.responder": "Responder outcomes: %s",
"review.investigations": "Investigations: %s", "review.investigations": "Investigations: %s",
"review.none": "none" "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"
} }
+1 -1
View File
@@ -8,7 +8,7 @@ config_version=5
config/name="Step Line" config/name="Step Line"
config/description="Workplace security simulation about observation, access verification, and proportionate escalation." config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
config/version="0.2.5" config/version="0.2.6"
run/main_scene="res://scenes/main.tscn" run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.3", "GL Compatibility") config/features=PackedStringArray("4.3", "GL Compatibility")
+4
View File
@@ -21,6 +21,8 @@ signal phase_changed(phase: int)
var scenario: VenueScenario = null var scenario: VenueScenario = null
var duration_s: float = 900.0 var duration_s: float = 900.0
var elapsed_s: float = 0.0 var elapsed_s: float = 0.0
## Wall-clock start, so the shift review can show event times as mm:ss.
var started_at_ms: int = 0
var current_phase: int = Rules.Phase.EARLY var current_phase: int = Rules.Phase.EARLY
var running := false var running := false
@@ -65,6 +67,7 @@ func start() -> void:
return return
running = true running = true
_shift_finished = false _shift_finished = false
started_at_ms = Time.get_ticks_msec()
elapsed_s = 0.0 elapsed_s = 0.0
_spawn_accum_s = 0.0 _spawn_accum_s = 0.0
current_phase = _phase_for_time(0.0) current_phase = _phase_for_time(0.0)
@@ -137,6 +140,7 @@ func build_review() -> Dictionary:
"scenario_id": scenario.id if scenario else "", "scenario_id": scenario.id if scenario else "",
"duration_s": duration_s, "duration_s": duration_s,
"elapsed_s": elapsed_s, "elapsed_s": elapsed_s,
"started_at_ms": started_at_ms,
"phase_at_end": current_phase, "phase_at_end": current_phase,
"spawned_count": spawned_count, "spawned_count": spawned_count,
"incidents": recorder.to_dict(), "incidents": recorder.to_dict(),
+31
View File
@@ -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 StartMenu := preload("res://src/game/ui/start_menu.gd")
const PauseOverlay := preload("res://src/game/ui/pause_overlay.gd") const PauseOverlay := preload("res://src/game/ui/pause_overlay.gd")
const SettingsPanel := preload("res://src/game/ui/settings_panel.gd") const SettingsPanel := preload("res://src/game/ui/settings_panel.gd")
const EventTimeline := preload("res://src/game/ui/event_timeline.gd")
var world_builder: WorldBuilder = null var world_builder: WorldBuilder = null
var door := DoorState.new() var door := DoorState.new()
@@ -70,6 +71,8 @@ var shift_review_panel: ShiftReview = null
var start_menu: StartMenu = null var start_menu: StartMenu = null
var pause_overlay: PauseOverlay = null var pause_overlay: PauseOverlay = null
var settings_panel: SettingsPanel = null var settings_panel: SettingsPanel = null
var event_timeline: EventTimeline = null
var _timeline_from_review := false
var _shift_running := false var _shift_running := false
var player_settings: Dictionary = {} var player_settings: Dictionary = {}
var _focused_avatar: Node3D = null var _focused_avatar: Node3D = null
@@ -325,6 +328,7 @@ func _build_ui() -> void:
add_child(shift_review_panel) add_child(shift_review_panel)
shift_review_panel.hide() shift_review_panel.hide()
shift_review_panel.restart_requested.connect(_on_review_restart) shift_review_panel.restart_requested.connect(_on_review_restart)
shift_review_panel.timeline_requested.connect(_open_timeline)
shift_review_panel.visible = false shift_review_panel.visible = false
pause_overlay = PauseOverlay.new() pause_overlay = PauseOverlay.new()
@@ -340,6 +344,11 @@ func _build_ui() -> void:
settings_panel.hide() settings_panel.hide()
settings_panel.applied.connect(_on_settings_applied) settings_panel.applied.connect(_on_settings_applied)
event_timeline = EventTimeline.new()
add_child(event_timeline)
event_timeline.hide()
event_timeline.closed.connect(_on_timeline_closed)
start_menu = StartMenu.new() start_menu = StartMenu.new()
add_child(start_menu) add_child(start_menu)
start_menu.hide() start_menu.hide()
@@ -347,6 +356,23 @@ func _build_ui() -> void:
start_menu.settings_requested.connect(_open_settings) start_menu.settings_requested.connect(_open_settings)
start_menu.quit_requested.connect(func(): get_tree().quit()) start_menu.quit_requested.connect(func(): get_tree().quit())
## Body-camera timeline from the review screen (ADR-0005 events, no 3D replay).
func _open_timeline(review: Dictionary) -> void:
if event_timeline == null:
return
# Hide the review dim so the timeline is not stacked under two overlays.
_timeline_from_review = shift_review_panel != null and shift_review_panel.visible
if shift_review_panel:
shift_review_panel.visible = false
event_timeline.show_timeline(review)
_refresh_freeze()
func _on_timeline_closed() -> void:
if _timeline_from_review and shift_review_panel:
shift_review_panel.visible = true
_timeline_from_review = false
_refresh_freeze()
## "Run another shift" on the review screen returns to the venue menu. ## "Run another shift" on the review screen returns to the venue menu.
func _on_review_restart() -> void: func _on_review_restart() -> void:
_show_start_menu() _show_start_menu()
@@ -778,6 +804,9 @@ func _close_top_modal() -> bool:
hud.set_action_hint("") hud.set_action_hint("")
_refresh_freeze() _refresh_freeze()
return true return true
if event_timeline and event_timeline.visible:
event_timeline.close()
return true
return false return false
func _open_radial() -> void: func _open_radial() -> void:
@@ -857,6 +886,8 @@ func _refresh_freeze() -> void:
freeze = true freeze = true
if settings_panel and settings_panel.visible: if settings_panel and settings_panel.visible:
freeze = true freeze = true
if event_timeline and event_timeline.visible:
freeze = true
if not _shift_running: if not _shift_running:
freeze = true freeze = true
if guard: if guard:
+211
View File
@@ -0,0 +1,211 @@
class_name EventTimeline
extends CanvasLayer
## Body-camera timeline for the shift review (PLAN.md: Evidence and Body Camera).
## First version is a text timeline of the structured incident events ADR-0005
## already stores: step through them with the arrow keys or the buttons. No live
## 3D replay.
signal closed()
const Strings := preload("res://src/core/strings.gd")
var events: Array[Dictionary] = []
var index := 0
var _list_label: Label = null
var _detail_label: Label = null
var _counter_label: Label = null
var _root: Control = null
func _init() -> void:
name = "EventTimeline"
layer = 32
_build()
hide()
func _build() -> void:
_root = Control.new()
_root.name = "TimelineRoot"
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_root)
var dim := ColorRect.new()
dim.color = Color(0.02, 0.03, 0.05, 0.9)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
_root.add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.add_child(center)
var column := VBoxContainer.new()
column.custom_minimum_size = Vector2(760, 0)
column.add_theme_constant_override("separation", 8)
center.add_child(column)
var title := Label.new()
title.text = Strings.lookup("timeline.title")
title.add_theme_font_size_override("font_size", 26)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
column.add_child(title)
_counter_label = Label.new()
_counter_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_counter_label.add_theme_color_override("font_color", Color(0.72, 0.78, 0.9))
column.add_child(_counter_label)
_detail_label = Label.new()
_detail_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_detail_label.custom_minimum_size = Vector2(760, 48)
_detail_label.add_theme_font_size_override("font_size", 18)
column.add_child(_detail_label)
_list_label = Label.new()
_list_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
_list_label.autowrap_mode = TextServer.AUTOWRAP_OFF
_list_label.custom_minimum_size = Vector2(760, 0)
_list_label.add_theme_font_size_override("font_size", 13)
_list_label.add_theme_color_override("font_color", Color(0.8, 0.84, 0.9))
column.add_child(_list_label)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 10)
column.add_child(row)
var prev := Button.new()
prev.name = "PrevButton"
prev.text = Strings.lookup("timeline.prev")
prev.pressed.connect(func(): step(-1))
row.add_child(prev)
var next := Button.new()
next.name = "NextButton"
next.text = Strings.lookup("timeline.next")
next.pressed.connect(func(): step(1))
row.add_child(next)
var close := Button.new()
close.name = "CloseButton"
close.text = Strings.lookup("timeline.close")
close.pressed.connect(_on_close)
row.add_child(close)
var hint := Label.new()
hint.text = Strings.lookup("timeline.hint")
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
hint.add_theme_color_override("font_color", Color(0.55, 0.58, 0.65))
column.add_child(hint)
## Flatten every incident's event stream into one time-ordered list.
func flatten(review: Dictionary) -> Array[Dictionary]:
var out: Array[Dictionary] = []
var incidents: Dictionary = review.get("incidents", {})
var list: Array = incidents.get("incidents", [])
for incident in list:
var incident_id := str(incident.get("id", ""))
var npc_id := str(incident.get("npc_id", ""))
for event in incident.get("events", []):
var row: Dictionary = (event as Dictionary).duplicate()
row["incident_id"] = incident_id
row["npc_id"] = npc_id
out.append(row)
out.sort_custom(func(a, b): return int(a.get("at_ms", 0)) < int(b.get("at_ms", 0)))
return out
func show_timeline(review: Dictionary) -> void:
events = flatten(review)
index = 0
visible = true
_root.visible = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
_refresh()
func step(direction: int) -> void:
if events.is_empty():
return
index = clampi(index + direction, 0, events.size() - 1)
_refresh()
func close() -> void:
visible = false
_root.visible = false
closed.emit()
func _on_close() -> void:
close()
func _refresh() -> void:
if events.is_empty():
_counter_label.text = Strings.lookup("timeline.empty")
_detail_label.text = ""
_list_label.text = ""
return
var event: Dictionary = events[index]
_counter_label.text = Strings.fmt("timeline.position", [index + 1, events.size()])
_detail_label.text = describe(event)
var lines: Array[String] = []
var from := maxi(0, index - 6)
var to := mini(events.size(), from + 13)
for i in range(from, to):
var marker := ">" if i == index else " "
lines.append("%s %s" % [marker, describe(events[i])])
_list_label.text = "\n".join(lines)
## One-line summary of a recorded event. Public so it is headless-testable.
func describe(event: Dictionary) -> String:
var type := str(event.get("type", "event"))
var npc := str(event.get("npc_id", ""))
var stamp := _stamp(int(event.get("at_ms", 0)))
var detail := ""
match type:
"player_action":
detail = "%s (L%d)" % [str(event.get("action", "")), int(event.get("level", 0))]
"access_check":
var evidence: Dictionary = event.get("evidence", {})
detail = "decided %s (verified %s)" % [
"allow" if bool(evidence.get("decided", false)) else "deny",
"yes" if bool(evidence.get("verified_granted", false)) else "no",
]
"recognition_decision":
detail = "recognized"
"reason_requested":
var reply := str(event.get("reply", ""))
detail = "reason: \"%s\"" % (reply if reply != "" else "no answer")
"body_camera":
detail = "body camera %s (%s)" % [
"on" if bool(event.get("on", false)) else "off",
str(event.get("reason", "")),
]
"responder_action":
detail = "%s response: %s" % [str(event.get("call_type", "assist")), str(event.get("outcome", ""))]
"evacuation":
detail = "evacuated"
"investigation_opened":
detail = "investigation: %s" % str(event.get("reason", ""))
_:
detail = type
return "%s %s %s" % [stamp, npc, detail]
func _stamp(at_ms: int) -> String:
if at_ms <= 0:
return "--:--"
var total := at_ms / 1000
return "%02d:%02d" % [total / 60, total % 60]
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventKey and event.pressed and not event.echo:
match event.keycode:
KEY_LEFT, KEY_A:
step(-1)
get_viewport().set_input_as_handled()
KEY_RIGHT, KEY_D:
step(1)
get_viewport().set_input_as_handled()
KEY_ESCAPE:
close()
get_viewport().set_input_as_handled()
+12
View File
@@ -5,6 +5,10 @@ extends CanvasLayer
const Strings := preload("res://src/core/strings.gd") const Strings := preload("res://src/core/strings.gd")
signal restart_requested() signal restart_requested()
signal timeline_requested(review: Dictionary)
## Kept so the timeline button can hand the full review back to the game manager.
var _review: Dictionary = {}
var report_label: Label var report_label: Label
@@ -34,6 +38,13 @@ func _init() -> void:
report_label.custom_minimum_size = Vector2(720, 0) report_label.custom_minimum_size = Vector2(720, 0)
center.add_child(report_label) center.add_child(report_label)
var timeline := Button.new()
timeline.name = "TimelineButton"
timeline.text = Strings.lookup("review.timeline_button")
timeline.pressed.connect(func(): timeline_requested.emit(_review))
timeline.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
center.add_child(timeline)
var restart := Button.new() var restart := Button.new()
restart.text = "Run another shift" restart.text = "Run another shift"
restart.pressed.connect(func(): restart_requested.emit()) restart.pressed.connect(func(): restart_requested.emit())
@@ -47,6 +58,7 @@ func _init() -> void:
center.add_child(quit_btn) center.add_child(quit_btn)
func show_review(review: Dictionary, scenario: Resource, history: Array = []) -> void: func show_review(review: Dictionary, scenario: Resource, history: Array = []) -> void:
_review = review
var reputation: Dictionary = review.get("reputation", {}) var reputation: Dictionary = review.get("reputation", {})
var incidents: Dictionary = review.get("incidents", {}) var incidents: Dictionary = review.get("incidents", {})
var crowd: Dictionary = review.get("crowd", {}) var crowd: Dictionary = review.get("crowd", {})
+83
View File
@@ -0,0 +1,83 @@
extends SceneTree
## Body-camera timeline smoke: records a few real interactions, opens the review's
## timeline, steps through every event, and closes back to the review.
##
## Usage:
## godot --headless --path . --script res://src/test/smoke_timeline.gd
const MAX_FRAMES := 900
var frame := 0
var gm: Node = null
var started := false
var step := 0
var failures: Array[String] = []
func _initialize() -> void:
root.size = Vector2i(1280, 720)
root.add_child(load("res://scenes/main.tscn").instantiate())
func _process(_delta: float) -> bool:
frame += 1
if gm == null:
gm = root.find_child("GameManager", true, false)
return false
if not started:
gm.call("_on_venue_selected", "hollywood_building")
started = true
return false
if frame > MAX_FRAMES:
_fail("timeout")
return true
if frame < 200:
return false
match step:
0:
if gm.npcs.is_empty():
return false
gm.call("_on_guard_target", gm.npcs[0])
gm.call("_apply_interaction", 1, "eye contact")
gm.call("_request_reason")
gm.call("_apply_interaction", 2, "formal warning")
var review: Dictionary = gm.coordinator.build_review()
# Real flow: the review screen shows first, then its timeline button.
gm.shift_review_panel.show_review(review, gm.selected_scenario, [])
var button: Button = gm.shift_review_panel.find_child("TimelineButton", true, false) as Button
_check(button != null, "review offers a body-camera timeline button")
if button:
button.pressed.emit()
var count: int = gm.event_timeline.events.size()
_check(count >= 3, "timeline collected the interaction events (%d)" % count)
_check(gm.event_timeline.visible, "timeline is showing")
_check(not gm.shift_review_panel.visible, "review is hidden behind the timeline")
_check(gm.guard.input_locked, "the guard is frozen while the timeline is open")
for i in count + 2:
gm.event_timeline.step(1)
_check(gm.event_timeline.index == count - 1, "stepping clamps at the last event")
gm.event_timeline.close()
step = 1
1:
_check(not gm.event_timeline.visible, "timeline closes")
_check(gm.shift_review_panel.visible, "closing returns to the review")
_finish()
return true
return false
func _check(condition: bool, message: String) -> void:
if condition:
print("ok: " + message)
else:
_fail(message)
func _fail(message: String) -> void:
failures.append(message)
_finish()
func _finish() -> void:
if failures.is_empty():
print("SMOKE OK: body-camera timeline")
quit(0)
else:
for message in failures:
printerr("SMOKE FAIL: " + message)
quit(1)
+120
View File
@@ -0,0 +1,120 @@
extends RefCounted
## Body-camera timeline: the review screen steps through recorded incident events
## (ADR-0005). No live 3D replay in this version.
const EventTimeline := preload("res://src/game/ui/event_timeline.gd")
const Strings := preload("res://src/core/strings.gd")
const Coordinator := preload("res://src/core/shift_coordinator.gd")
const Factory := preload("res://src/content/scenario_factory.gd")
func _review() -> Dictionary:
return {
"started_at_ms": 1000,
"incidents": {
"open_count": 0,
"incidents": [
{
"id": "inc_a",
"npc_id": "npc_a",
"outcome": "peaceful",
"events": [
{"type": "player_action", "action": "eye contact", "level": 1, "at_ms": 3000},
{"type": "body_camera", "on": true, "reason": "formal warning", "at_ms": 8000},
],
},
{
"id": "inc_b",
"npc_id": "npc_b",
"outcome": "left",
"events": [
{"type": "reason_requested", "reply": "I have a ticket.", "at_ms": 5000},
{"type": "access_check", "evidence": {"decided": true, "verified_granted": true}, "at_ms": 9000},
],
},
],
},
"reputation": {"rating": 70.0, "grade": "C", "modifiers": []},
"crowd": {"mood": 0.1, "panic": false},
"radio": {"battery": 100.0, "emergency_cooldown_s": 0.0},
}
func test_flatten_orders_events_across_incidents() -> bool:
var timeline: Node = EventTimeline.new()
var flat: Array = timeline.flatten(_review())
var a := TestUtils.check_eq(flat.size(), 4, "every recorded event is flattened")
var stamps: Array[int] = []
for row in flat:
stamps.append(int(row.get("at_ms", 0)))
var sorted := stamps.duplicate()
sorted.sort()
var b := TestUtils.check_eq(stamps, sorted, "events are time ordered")
var c := TestUtils.check_eq(flat[0].get("npc_id", ""), "npc_a", "event keeps its NPC id")
var d := TestUtils.check_eq(flat[1].get("npc_id", ""), "npc_b", "second incident interleaves")
var e := TestUtils.check_eq(flat[0].get("incident_id", ""), "inc_a", "event keeps its incident id")
timeline.free()
return a and b and c and d and e
func test_describe_covers_event_types() -> bool:
var timeline: Node = EventTimeline.new()
var a := TestUtils.check(timeline.describe({"type": "player_action", "action": "eye contact", "level": 1, "at_ms": 3000}).contains("eye contact (L1)"), "player action line")
var b := TestUtils.check(timeline.describe({"type": "body_camera", "on": true, "reason": "formal warning"}).contains("body camera on"), "body camera line")
var c := TestUtils.check(timeline.describe({"type": "reason_requested", "reply": "I have a ticket."}).contains("I have a ticket."), "reason reply line")
var d := TestUtils.check(timeline.describe({"type": "access_check", "evidence": {"decided": false, "verified_granted": true}}).contains("deny"), "access decision line")
var e := TestUtils.check(timeline.describe({"type": "responder_action", "call_type": "emergency", "outcome": "evacuation"}).contains("evacuation"), "responder line")
var f := TestUtils.check(timeline.describe({"type": "investigation_opened", "reason": "unjustified"}).contains("investigation"), "investigation line")
timeline.free()
return a and b and c and d and e and f
func test_empty_timeline_is_safe() -> bool:
var timeline: Node = EventTimeline.new()
var flat: Array = timeline.flatten({})
var a := TestUtils.check_eq(flat.size(), 0, "no incidents means no events")
timeline.show_timeline({})
var b := TestUtils.check_eq(timeline.index, 0, "index stays at zero when empty")
timeline.step(1)
var c := TestUtils.check_eq(timeline.index, 0, "stepping an empty timeline does nothing")
timeline.free()
return a and b and c
func test_stepping_clamps() -> bool:
var timeline: Node = EventTimeline.new()
timeline.show_timeline(_review())
timeline.step(-1)
var a := TestUtils.check_eq(timeline.index, 0, "cannot step before the first event")
timeline.step(1)
var b := TestUtils.check_eq(timeline.index, 1, "next advances one event")
timeline.step(99)
var c := TestUtils.check_eq(timeline.index, 3, "cannot step past the last event")
timeline.free()
return a and b and c
func test_review_records_a_shift_start_stamp() -> bool:
var coordinator: Object = Coordinator.new()
coordinator.setup(Factory.hollywood_building(), 30.0, 7)
coordinator.start()
var review: Dictionary = coordinator.build_review()
var a := TestUtils.check(int(review.get("started_at_ms", 0)) > 0, "review carries the shift start stamp")
coordinator = null
return a
func test_timeline_strings_exist() -> bool:
var ok := true
for key in ["timeline.title", "timeline.position", "timeline.empty", "timeline.prev",
"timeline.next", "timeline.close", "timeline.hint", "review.timeline_button"]:
if Strings.lookup(key) == key:
ok = false
return TestUtils.check(ok, "timeline UI strings are localized")
func test_review_button_opens_the_timeline() -> bool:
var Review := load("res://src/game/ui/shift_review.gd")
var panel: Node = Review.new()
var got := {"review": {}}
panel.timeline_requested.connect(func(review): got["review"] = review)
panel.show_review(_review(), Factory.hollywood_building(), [])
var button: Button = panel.find_child("TimelineButton", true, false) as Button
var a := TestUtils.check(button != null, "review screen has a timeline button")
if button:
button.pressed.emit()
var b := TestUtils.check(not (got["review"] as Dictionary).is_empty(), "pressing it hands the review to the timeline")
panel.free()
return a and b
+93 -51
View File
@@ -9,11 +9,11 @@ screenshots against.
Two commands: Two commands:
generate write one reference design per screen to docs/screens/designs/. generate write one reference image per screen to docs/screens/designs/.
Two modes: Two modes:
--ai photorealistic mockups via the OpenRouter image API --ai realistic 3D game scenes and in-game screen designs via
(default; requires OPENROUTER_API_KEY, same as the OpenRouter image API (default; requires
tools/asset_pipeline.py). OPENROUTER_API_KEY, same as tools/asset_pipeline.py).
--draft deterministic wireframe sketches drawn with Pillow --draft deterministic wireframe sketches drawn with Pillow
(no network or API key; good for CI and layout checks). (no network or API key; good for CI and layout checks).
@@ -54,6 +54,23 @@ ASPECT = 16 / 9
# the model asset_pipeline.py already trusts for non-alpha roles). # the model asset_pipeline.py already trusts for non-alpha roles).
DEFAULT_MODEL = "openai/gpt-5-image-mini" 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 # Screen catalogue. Each entry is grounded in a doc; `prompt` feeds --ai and
# `regions` feed --draft (normalised rects + a short label). `ref` names the # `regions` feed --draft (normalised rects + a short label). `ref` names the
@@ -63,6 +80,7 @@ DEFAULT_MODEL = "openai/gpt-5-image-mini"
SCREENS = [ SCREENS = [
{ {
"name": "venue_overview", "name": "venue_overview",
"kind": "scene",
"title": "Venue overview", "title": "Venue overview",
"source": "README screenshot 01", "source": "README screenshot 01",
"ref": "01-venue-overview.png", "ref": "01-venue-overview.png",
@@ -71,11 +89,11 @@ SCREENS = [
"sidewalk, street lamps, planters, crosswalk, and building " "sidewalk, street lamps, planters, crosswalk, and building "
"signage/marquee under early-morning light.", "signage/marquee under early-morning light.",
"prompt": ( "prompt": (
"First-person video game screenshot, security guard standing at the " "Realistic 3D first-person game scene: a security guard's view from "
"top of wide concrete steps outside a Hollywood Boulevard building " "the top of wide concrete steps outside a grand Hollywood Boulevard "
"entrance, daytime early morning light, sidewalk with street lamps, " "building entrance, early-morning golden light, sidewalk with street "
"planters and a crosswalk, building facade with awning and marquee " "lamps, planters, a crosswalk and distant pedestrians, detailed brick "
"signage, clean UI-free gameplay view, 16:9"), "facade with a striped awning and glowing marquee sign"),
"regions": [ "regions": [
(0.00, 0.55, 1.00, 1.00, "steps + sidewalk"), (0.00, 0.55, 1.00, 1.00, "steps + sidewalk"),
(0.20, 0.08, 0.80, 0.60, "building facade / marquee"), (0.20, 0.08, 0.80, 0.60, "building facade / marquee"),
@@ -85,6 +103,7 @@ SCREENS = [
}, },
{ {
"name": "targeting_npc", "name": "targeting_npc",
"kind": "scene",
"title": "Targeting an NPC", "title": "Targeting an NPC",
"source": "README screenshot 02 · ADR-0008 cone focus + head ring", "source": "README screenshot 02 · ADR-0008 cone focus + head ring",
"ref": "02-targeting-npc.png", "ref": "02-targeting-npc.png",
@@ -93,10 +112,10 @@ SCREENS = [
"the ring, and the HUD shows a target line with the " "the ring, and the HUD shows a target line with the "
"interaction hint.", "interaction hint.",
"prompt": ( "prompt": (
"First-person video game screenshot, a pedestrian NPC walking toward " "Realistic 3D first-person game scene: a pedestrian NPC walking "
"a security guard on a city street, a thin pale-gold ring floating " "toward the camera on a city sidewalk, a thin pale-gold ring floating "
"around the NPC's head as a selection marker, small name label under " "around the NPC's head as a selection marker, a small name label "
"the ring, minimal HUD text at the bottom of the screen, daytime, 16:9"), "under the ring, minimal HUD target text at the bottom, daytime"),
"regions": [ "regions": [
(0.35, 0.15, 0.65, 0.60, "NPC in focus"), (0.35, 0.15, 0.65, 0.60, "NPC in focus"),
(0.44, 0.10, 0.56, 0.16, "head ring"), (0.44, 0.10, 0.56, 0.16, "head ring"),
@@ -105,6 +124,7 @@ SCREENS = [
}, },
{ {
"name": "access_inspection", "name": "access_inspection",
"kind": "ui",
"title": "Access inspection", "title": "Access inspection",
"source": "README screenshot 03 · ADR-0003 access verification", "source": "README screenshot 03 · ADR-0003 access verification",
"ref": "03-access-inspection.png", "ref": "03-access-inspection.png",
@@ -113,11 +133,11 @@ SCREENS = [
"pattern, date, and event marking, plus allow and deny " "pattern, date, and event marking, plus allow and deny "
"prompts.", "prompts.",
"prompt": ( "prompt": (
"First-person video game screenshot with a centered access inspection " "Realistic 3D game screenshot with an in-game access inspection panel "
"panel: a card showing a wristband or ID credential with a photo " "overlaid: a centered card showing a wristband or ID credential with a "
"placeholder, colour band, date and event marking, and two buttons " "photo placeholder, colour band, date and event marking, and two "
"labelled allow and deny, dark translucent panel over a city street " "buttons labelled Allow and Deny, dark translucent panel over a "
"scene, 16:9"), "softly blurred city street background"),
"regions": [ "regions": [
(0.25, 0.10, 0.75, 0.90, "inspection panel"), (0.25, 0.10, 0.75, 0.90, "inspection panel"),
(0.30, 0.18, 0.70, 0.60, "artifact card"), (0.30, 0.18, 0.70, 0.60, "artifact card"),
@@ -127,6 +147,7 @@ SCREENS = [
}, },
{ {
"name": "radio_report", "name": "radio_report",
"kind": "ui",
"title": "Radio report", "title": "Radio report",
"source": "README screenshot 04 · PLAN level 4 radio assistance", "source": "README screenshot 04 · PLAN level 4 radio assistance",
"ref": "04-radio-report.png", "ref": "04-radio-report.png",
@@ -134,10 +155,10 @@ SCREENS = [
"description, behaviour, whether weapons were seen, and " "description, behaviour, whether weapons were seen, and "
"whether anyone is in immediate danger, plus a send action.", "whether anyone is in immediate danger, plus a send action.",
"prompt": ( "prompt": (
"First-person video game screenshot with a radio dispatch report " "Realistic 3D game screenshot with an in-game radio dispatch panel "
"panel open: form fields for location, description, behaviour, " "overlaid: form fields for location, description, behaviour, weapons "
"weapons seen, and immediate danger, with a send button, dark " "seen and immediate danger, and a Send button, dark translucent panel "
"translucent panel over a city street scene, 16:9"), "over a softly blurred city street background"),
"regions": [ "regions": [
(0.30, 0.08, 0.70, 0.92, "radio panel"), (0.30, 0.08, 0.70, 0.92, "radio panel"),
(0.34, 0.16, 0.66, 0.30, "location / description"), (0.34, 0.16, 0.66, 0.30, "location / description"),
@@ -147,15 +168,17 @@ SCREENS = [
}, },
{ {
"name": "late_night_avenue", "name": "late_night_avenue",
"kind": "scene",
"title": "Late night avenue", "title": "Late night avenue",
"source": "README screenshot 05", "source": "README screenshot 05",
"ref": "05-late-night-avenue.png", "ref": "05-late-night-avenue.png",
"description": "Night-time view down Hollywood Boulevard: street lamps " "description": "Night-time view down Hollywood Boulevard: street lamps "
"lit, marquee glowing, dark blue street, no flashlight.", "lit, marquee glowing, dark blue street, no flashlight.",
"prompt": ( "prompt": (
"First-person video game screenshot at night, a dark Hollywood " "Realistic 3D first-person game scene at night: a dark Hollywood "
"Boulevard avenue with lit street lamps, a glowing marquee sign, " "Boulevard avenue receding into the distance, lit street lamps, a "
"deep blue night sky, no flashlight beam, 16:9"), "glowing neon marquee sign, deep blue night sky, wet reflective "
"pavement, no flashlight"),
"regions": [ "regions": [
(0.00, 0.55, 1.00, 1.00, "dark avenue"), (0.00, 0.55, 1.00, 1.00, "dark avenue"),
(0.15, 0.25, 0.85, 0.55, "glowing marquee"), (0.15, 0.25, 0.85, 0.55, "glowing marquee"),
@@ -165,15 +188,17 @@ SCREENS = [
}, },
{ {
"name": "night_flashlight", "name": "night_flashlight",
"kind": "scene",
"title": "Night flashlight", "title": "Night flashlight",
"source": "README screenshot 06", "source": "README screenshot 06",
"ref": "06-night-flashlight.png", "ref": "06-night-flashlight.png",
"description": "Night shift: the guard's flashlight beam cuts through the " "description": "Night shift: the guard's flashlight beam cuts through the "
"dark toward the entrance, illuminating the steps and door.", "dark toward the entrance, illuminating the steps and door.",
"prompt": ( "prompt": (
"First-person video game screenshot at night, a flashlight beam " "Realistic 3D first-person game scene at night: a flashlight beam "
"cutting through darkness toward a building entrance and steps, " "cutting through darkness toward a building entrance and steps, a "
"bright spotlight pool on the door, dark surroundings, 16:9"), "bright spotlight pool illuminating the door, dark surroundings and "
"deep shadows"),
"regions": [ "regions": [
(0.35, 0.30, 0.65, 0.80, "flashlight beam pool"), (0.35, 0.30, 0.65, 0.80, "flashlight beam pool"),
(0.40, 0.20, 0.60, 0.35, "lit door"), (0.40, 0.20, 0.60, 0.35, "lit door"),
@@ -182,6 +207,7 @@ SCREENS = [
}, },
{ {
"name": "radial_menu", "name": "radial_menu",
"kind": "ui",
"title": "Radial action menu", "title": "Radial action menu",
"source": "ADR-0009 · content/strings.en.json", "source": "ADR-0009 · content/strings.en.json",
"ref": None, "ref": None,
@@ -190,11 +216,11 @@ SCREENS = [
"Wand, Warn, Block, Reason, Recognize, Radio. The focused " "Wand, Warn, Block, Reason, Recognize, Radio. The focused "
"person's display name sits in the centre.", "person's display name sits in the centre.",
"prompt": ( "prompt": (
"First-person video game screenshot with a radial pie menu centered " "Realistic 3D game screenshot with an in-game radial action menu "
"on screen, twelve labelled slices in a circle reading Stop, Look, " "overlaid: a round doughnut wheel centered on screen with twelve "
"Point, No, Ask, Inspect, Wand, Warn, Block, Reason, Recognize, " "labelled slices reading Stop, Look, Point, No, Ask, Inspect, Wand, "
"Radio, a highlighted slice, a name in the center of the ring, " "Warn, Block, Reason, Recognize, Radio, one slice highlighted, a name "
"over a city street scene, 16:9"), "in the center of the ring, over a softly blurred city street scene"),
"regions": [ "regions": [
(0.38, 0.12, 0.62, 0.88, "radial wheel (12 slices)"), (0.38, 0.12, 0.62, 0.88, "radial wheel (12 slices)"),
(0.46, 0.44, 0.54, 0.56, "name"), (0.46, 0.44, 0.54, 0.56, "name"),
@@ -203,6 +229,7 @@ SCREENS = [
}, },
{ {
"name": "shift_review", "name": "shift_review",
"kind": "ui",
"title": "Shift review", "title": "Shift review",
"source": "PLAN reputation / shift review · architecture.md ShiftReview", "source": "PLAN reputation / shift review · architecture.md ShiftReview",
"ref": None, "ref": None,
@@ -210,10 +237,10 @@ SCREENS = [
"escalation choices, incident list, battery and crowd " "escalation choices, incident list, battery and crowd "
"outcomes, and reputation/score feedback.", "outcomes, and reputation/score feedback.",
"prompt": ( "prompt": (
"Video game end-of-shift review screen: a performance summary panel " "Realistic 3D game end-of-shift review screen: a full-screen "
"listing incident decisions, escalation choices, reputation score, " "performance summary panel listing incident decisions, escalation "
"crowd reaction and battery use, with a continue button, dark themed " "choices, a reputation score, crowd reaction and battery use, with a "
"UI, 16:9"), "Continue button, dark themed game UI"),
"regions": [ "regions": [
(0.30, 0.06, 0.70, 0.94, "review panel"), (0.30, 0.06, 0.70, 0.94, "review panel"),
(0.36, 0.14, 0.64, 0.24, "score / reputation"), (0.36, 0.14, 0.64, 0.24, "score / reputation"),
@@ -223,15 +250,16 @@ SCREENS = [
}, },
{ {
"name": "start_menu", "name": "start_menu",
"kind": "ui",
"title": "Start menu", "title": "Start menu",
"source": "MEMORY session shell (StartMenu, layer 20)", "source": "MEMORY session shell (StartMenu, layer 20)",
"ref": None, "ref": None,
"description": "The boot screen: a venue-selection menu (Hollywood " "description": "The boot screen: a venue-selection menu (Hollywood "
"Boulevard, The Joke Shop, TV Studio) before a shift.", "Boulevard, The Joke Shop, TV Studio) before a shift.",
"prompt": ( "prompt": (
"Video game start menu screen: a title and three venue selection " "Realistic 3D game start menu screen: a game title and three venue "
"buttons labelled Hollywood Boulevard, The Joke Shop, and TV Studio, " "selection buttons labelled Hollywood Boulevard, The Joke Shop and TV "
"dark themed menu over a city street backdrop, 16:9"), "Studio, dark themed menu over a softly blurred city street backdrop"),
"regions": [ "regions": [
(0.25, 0.10, 0.75, 0.26, "title"), (0.25, 0.10, 0.75, 0.26, "title"),
(0.36, 0.36, 0.64, 0.44, "Hollywood Boulevard"), (0.36, 0.36, 0.64, 0.44, "Hollywood Boulevard"),
@@ -241,14 +269,15 @@ SCREENS = [
}, },
{ {
"name": "pause_overlay", "name": "pause_overlay",
"kind": "ui",
"title": "Pause overlay", "title": "Pause overlay",
"source": "MEMORY session shell (PauseOverlay, layer 18)", "source": "MEMORY session shell (PauseOverlay, layer 18)",
"ref": None, "ref": None,
"description": "Pause menu: resume, restart, settings, and quit-to-menu.", "description": "Pause menu: resume, restart, settings, and quit-to-menu.",
"prompt": ( "prompt": (
"Video game pause menu overlay: buttons labelled Resume, Restart, " "Realistic 3D game pause menu overlay: buttons labelled Resume, "
"Settings, and Quit to Menu, dark translucent overlay over a paused " "Restart, Settings and Quit to Menu, dark translucent overlay over a "
"city street scene, 16:9"), "paused, softly blurred city street scene"),
"regions": [ "regions": [
(0.38, 0.26, 0.62, 0.74, "pause menu"), (0.38, 0.26, 0.62, 0.74, "pause menu"),
(0.42, 0.32, 0.58, 0.38, "Resume"), (0.42, 0.32, 0.58, 0.38, "Resume"),
@@ -259,13 +288,14 @@ SCREENS = [
}, },
{ {
"name": "settings_panel", "name": "settings_panel",
"kind": "ui",
"title": "Settings", "title": "Settings",
"source": "MEMORY session shell (SettingsPanel, layer 22)", "source": "MEMORY session shell (SettingsPanel, layer 22)",
"ref": None, "ref": None,
"description": "Settings: look sensitivity and HUD font scale controls.", "description": "Settings: look sensitivity and HUD font scale controls.",
"prompt": ( "prompt": (
"Video game settings screen: sliders labelled look sensitivity and " "Realistic 3D game settings screen: sliders labelled look sensitivity "
"HUD font scale, dark themed settings panel, 16:9"), "and HUD font scale, dark themed settings panel"),
"regions": [ "regions": [
(0.30, 0.16, 0.70, 0.84, "settings panel"), (0.30, 0.16, 0.70, 0.84, "settings panel"),
(0.36, 0.30, 0.64, 0.40, "look sensitivity"), (0.36, 0.30, 0.64, 0.40, "look sensitivity"),
@@ -274,15 +304,17 @@ SCREENS = [
}, },
{ {
"name": "emergency_radio", "name": "emergency_radio",
"kind": "ui",
"title": "Emergency radio", "title": "Emergency radio",
"source": "PLAN level 5 · strings radio.emergency_dispatched", "source": "PLAN level 5 · strings radio.emergency_dispatched",
"ref": None, "ref": None,
"description": "Hold-to-confirm emergency radio panel with a confirmation " "description": "Hold-to-confirm emergency radio panel with a confirmation "
"prompt and a cooldown indicator.", "prompt and a cooldown indicator.",
"prompt": ( "prompt": (
"Video game emergency radio hold-to-confirm panel: a large warning " "Realistic 3D game emergency radio hold-to-confirm panel: a large "
"prompt with a hold progress bar and a cooldown label, red accent, " "warning prompt with a hold progress bar and a cooldown label, red "
"dark translucent overlay over a city street, 16:9"), "accent, dark translucent overlay over a softly blurred city street "
"scene"),
"regions": [ "regions": [
(0.30, 0.28, 0.70, 0.72, "emergency panel"), (0.30, 0.28, 0.70, 0.72, "emergency panel"),
(0.36, 0.36, 0.64, 0.46, "confirm prompt"), (0.36, 0.36, 0.64, 0.46, "confirm prompt"),
@@ -349,10 +381,19 @@ def _draw_wireframe(screen, path):
# AI generation (reuses the OpenRouter backend from generate_textures). # 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): 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( image = generate_textures.generate_image(
screen["prompt"], model, request_size, transparent=False, prompt, model, request_size, transparent=False,
aspect_ratio="1:1") aspect_ratio=SOURCE_ASPECT)
image = ImageOps.fit(image, (WIDTH, HEIGHT), image = ImageOps.fit(image, (WIDTH, HEIGHT),
method=Image.Resampling.LANCZOS) method=Image.Resampling.LANCZOS)
if image.mode in ("RGBA", "LA", "P"): if image.mode in ("RGBA", "LA", "P"):
@@ -360,7 +401,8 @@ def _generate_ai(screen, model, request_size):
path = _design_path(screen["name"]) path = _design_path(screen["name"])
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
image.save(path) image.save(path)
print(f"wrote {path.relative_to(ROOT)} {image.size}") shown = path.relative_to(ROOT) if path.is_relative_to(ROOT) else path
print(f"wrote {shown} {image.size}")
def generate(args): def generate(args):
+10
View File
@@ -25,6 +25,7 @@ class ScreenDesignsTests(unittest.TestCase):
"screenshot refs must be unique") "screenshot refs must be unique")
for screen in screen_designs.SCREENS: for screen in screen_designs.SCREENS:
self.assertTrue(screen["prompt"].strip()) self.assertTrue(screen["prompt"].strip())
self.assertIn(screen["kind"], ("scene", "ui"))
self.assertTrue(screen["regions"]) self.assertTrue(screen["regions"])
for region in screen["regions"]: for region in screen["regions"]:
x0, y0, x1, y1, label = region x0, y0, x1, y1, label = region
@@ -42,6 +43,15 @@ class ScreenDesignsTests(unittest.TestCase):
self.assertEqual(image.size, self.assertEqual(image.size,
(screen_designs.WIDTH, screen_designs.HEIGHT)) (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): def test_mae_is_zero_for_identical_images(self):
path = self.root / "identical.png" path = self.root / "identical.png"
Image.new("RGB", (1280, 720), (40, 80, 120)).save(path) Image.new("RGB", (1280, 720), (40, 80, 120)).save(path)