From 1b976bf35198bbf86e8c5e5c474672b328c524c3 Mon Sep 17 00:00:00 2001 From: Zwitschi Date: Sat, 31 Oct 2026 21:02:25 +0100 Subject: [PATCH] Add body-camera timeline to the shift review - 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. --- DONE.md | 8 + MEMORY.md | 16 +- README.md | 23 ++- TODO.md | 11 +- content/strings.en.json | 10 +- project.godot | 2 +- src/core/shift_coordinator.gd | 4 + src/game/game_manager.gd | 31 ++++ src/game/ui/event_timeline.gd | 211 ++++++++++++++++++++++++++ src/game/ui/shift_review.gd | 12 ++ src/test/smoke_timeline.gd | 83 ++++++++++ src/test/tests/test_event_timeline.gd | 120 +++++++++++++++ tools/screen_designs.py | 144 +++++++++++------- tools/test_screen_designs.py | 10 ++ 14 files changed, 614 insertions(+), 71 deletions(-) create mode 100644 src/game/ui/event_timeline.gd create mode 100644 src/test/smoke_timeline.gd create mode 100644 src/test/tests/test_event_timeline.gd diff --git a/DONE.md b/DONE.md index b741a69..b4ff861 100644 --- a/DONE.md +++ b/DONE.md @@ -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 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. diff --git a/MEMORY.md b/MEMORY.md index f0e5c16..df022a8 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -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. - `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` body-camera replay timeline, then NPC theater poses (sit/photo/perform) - and audio. +- Next work: `TODO.md` NPC theater poses (sit/photo/perform), then audio and accessibility. - 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 +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 `_ensure_incident()`, and `ShiftCoordinator._finish_shift()` closes leftovers as `unresolved` before building the review. +- **Body-camera timeline (2026-10-31):** `src/game/ui/event_timeline.gd` flattens + `review.incidents.incidents[].events` into one time-ordered list (`flatten`), formats each row + with `describe`, and steps with Left/Right or the buttons. `ShiftCoordinator.started_at_ms` + gives the review an absolute start for mm:ss stamps. `GameManager._open_timeline` hides the + review dim (two stacked overlays made the screen nearly black) and `_timeline_from_review` + restores it on close. Smoke: `src/test/smoke_timeline.gd`. - **Radio dispatch was dead after the first shift:** `coordinator.setup()` builds a new `RadioSystem`, so the `responder_arrived` connection made in `_ready` was lost on every venue switch/restart (responders never resolved anything). `_connect_radio()` now re-wires it next to @@ -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`): `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 OpenRouter mockups (default model - `openai/gpt-5-image-mini`). `compare` checks `docs/screenshots/*.png` for aspect/size/ + 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). diff --git a/README.md b/README.md index d336299..9fd6278 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ The central design goal is simple: **All releases:** -> **Latest: v0.2.5** — emergency aftermath (evacuation clears the entrance, unjustified calls open -> an investigation, the review names the responder outcome). +> **Latest: v0.2.6** — body-camera timeline in the shift review, stepping through every recorded +> incident event. > 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 @@ -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 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 -# 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 # 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 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. @@ -296,7 +305,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: **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 - 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`) @@ -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 - Flashlight aims straight ahead and lights the person/door being checked instead of the ground in front of the guard - Emergency aftermath: the `evacuation` outcome locks the door and clears the entrance, an emergency call that the situation did not justify opens an investigation and applies the false-report hit, and the shift review names the responder outcome and any investigations +- Body-camera timeline: the shift review opens a text timeline of every recorded incident event (time, person, action/outcome) that you step through with the arrow keys or buttons. No live 3D replay yet - Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets - 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 @@ -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). 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. 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 diff --git a/TODO.md b/TODO.md index 27e0116..44dd46d 100644 --- a/TODO.md +++ b/TODO.md @@ -1,16 +1,11 @@ # TODO -Godot 4.3 prototype. Release **v0.2.5** is tagged. Headless suite **113/113**, plus -`smoke_session_shell.gd`, `smoke_plan_verbs.gd`, `smoke_crowd.gd`, `smoke_emergency.gd`, and the -windowed focus/radial smoke. +Godot 4.3 prototype. Release **v0.2.6** is tagged. Headless suite **120/120**, plus +`smoke_session_shell.gd`, `smoke_plan_verbs.gd`, `smoke_crowd.gd`, `smoke_emergency.gd`, +`smoke_timeline.gd`, and the windowed focus/radial smoke. 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 Intents `sit` / `photo` / `film` / `perform` already pick waypoints. ADR-0010 rig (face/hair) diff --git a/content/strings.en.json b/content/strings.en.json index e71bc4a..56e5720 100644 --- a/content/strings.en.json +++ b/content/strings.en.json @@ -68,5 +68,13 @@ "wand.recharge": "Wand scanner is recharging…", "review.responder": "Responder outcomes: %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" } diff --git a/project.godot b/project.godot index 757104d..15ad91c 100644 --- a/project.godot +++ b/project.godot @@ -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.5" +config/version="0.2.6" run/main_scene="res://scenes/main.tscn" config/features=PackedStringArray("4.3", "GL Compatibility") diff --git a/src/core/shift_coordinator.gd b/src/core/shift_coordinator.gd index 27245b3..3ea528e 100644 --- a/src/core/shift_coordinator.gd +++ b/src/core/shift_coordinator.gd @@ -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 @@ -65,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) @@ -137,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(), diff --git a/src/game/game_manager.gd b/src/game/game_manager.gd index 0db8cd9..c9532ff 100644 --- a/src/game/game_manager.gd +++ b/src/game/game_manager.gd @@ -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 @@ -325,6 +328,7 @@ func _build_ui() -> void: 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() @@ -340,6 +344,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() @@ -347,6 +356,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() @@ -778,6 +804,9 @@ func _close_top_modal() -> bool: 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: @@ -857,6 +886,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: diff --git a/src/game/ui/event_timeline.gd b/src/game/ui/event_timeline.gd new file mode 100644 index 0000000..cd99493 --- /dev/null +++ b/src/game/ui/event_timeline.gd @@ -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() diff --git a/src/game/ui/shift_review.gd b/src/game/ui/shift_review.gd index 721a565..62fd5c8 100644 --- a/src/game/ui/shift_review.gd +++ b/src/game/ui/shift_review.gd @@ -5,6 +5,10 @@ extends CanvasLayer 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 @@ -34,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()) @@ -47,6 +58,7 @@ 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", {}) diff --git a/src/test/smoke_timeline.gd b/src/test/smoke_timeline.gd new file mode 100644 index 0000000..2249804 --- /dev/null +++ b/src/test/smoke_timeline.gd @@ -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) diff --git a/src/test/tests/test_event_timeline.gd b/src/test/tests/test_event_timeline.gd new file mode 100644 index 0000000..624f6ff --- /dev/null +++ b/src/test/tests/test_event_timeline.gd @@ -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 diff --git a/tools/screen_designs.py b/tools/screen_designs.py index 96b7078..2a348c7 100644 --- a/tools/screen_designs.py +++ b/tools/screen_designs.py @@ -9,11 +9,11 @@ screenshots against. 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: - --ai photorealistic mockups via the OpenRouter image API - (default; requires OPENROUTER_API_KEY, same as - tools/asset_pipeline.py). + --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). @@ -54,6 +54,23 @@ ASPECT = 16 / 9 # 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 @@ -63,6 +80,7 @@ DEFAULT_MODEL = "openai/gpt-5-image-mini" SCREENS = [ { "name": "venue_overview", + "kind": "scene", "title": "Venue overview", "source": "README screenshot 01", "ref": "01-venue-overview.png", @@ -71,11 +89,11 @@ SCREENS = [ "sidewalk, street lamps, planters, crosswalk, and building " "signage/marquee under early-morning light.", "prompt": ( - "First-person video game screenshot, security guard standing at the " - "top of wide concrete steps outside a Hollywood Boulevard building " - "entrance, daytime early morning light, sidewalk with street lamps, " - "planters and a crosswalk, building facade with awning and marquee " - "signage, clean UI-free gameplay view, 16:9"), + "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"), @@ -85,6 +103,7 @@ SCREENS = [ }, { "name": "targeting_npc", + "kind": "scene", "title": "Targeting an NPC", "source": "README screenshot 02 · ADR-0008 cone focus + head ring", "ref": "02-targeting-npc.png", @@ -93,10 +112,10 @@ SCREENS = [ "the ring, and the HUD shows a target line with the " "interaction hint.", "prompt": ( - "First-person video game screenshot, a pedestrian NPC walking toward " - "a security guard on a city street, a thin pale-gold ring floating " - "around the NPC's head as a selection marker, small name label under " - "the ring, minimal HUD text at the bottom of the screen, daytime, 16:9"), + "Realistic 3D first-person game scene: a pedestrian NPC walking " + "toward the camera on a city sidewalk, a thin pale-gold ring floating " + "around the NPC's head as a selection marker, a small name label " + "under the ring, minimal HUD target text at the bottom, daytime"), "regions": [ (0.35, 0.15, 0.65, 0.60, "NPC in focus"), (0.44, 0.10, 0.56, 0.16, "head ring"), @@ -105,6 +124,7 @@ SCREENS = [ }, { "name": "access_inspection", + "kind": "ui", "title": "Access inspection", "source": "README screenshot 03 · ADR-0003 access verification", "ref": "03-access-inspection.png", @@ -113,11 +133,11 @@ SCREENS = [ "pattern, date, and event marking, plus allow and deny " "prompts.", "prompt": ( - "First-person video game screenshot with a centered access inspection " - "panel: a 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 city street " - "scene, 16:9"), + "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"), @@ -127,6 +147,7 @@ SCREENS = [ }, { "name": "radio_report", + "kind": "ui", "title": "Radio report", "source": "README screenshot 04 · PLAN level 4 radio assistance", "ref": "04-radio-report.png", @@ -134,10 +155,10 @@ SCREENS = [ "description, behaviour, whether weapons were seen, and " "whether anyone is in immediate danger, plus a send action.", "prompt": ( - "First-person video game screenshot with a radio dispatch report " - "panel open: form fields for location, description, behaviour, " - "weapons seen, and immediate danger, with a send button, dark " - "translucent panel over a city street scene, 16:9"), + "Realistic 3D game screenshot with an in-game radio dispatch panel " + "overlaid: form fields for location, description, behaviour, weapons " + "seen and immediate danger, and a Send button, dark translucent panel " + "over a softly blurred city street background"), "regions": [ (0.30, 0.08, 0.70, 0.92, "radio panel"), (0.34, 0.16, 0.66, 0.30, "location / description"), @@ -147,15 +168,17 @@ SCREENS = [ }, { "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": ( - "First-person video game screenshot at night, a dark Hollywood " - "Boulevard avenue with lit street lamps, a glowing marquee sign, " - "deep blue night sky, no flashlight beam, 16:9"), + "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"), @@ -165,15 +188,17 @@ SCREENS = [ }, { "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": ( - "First-person video game screenshot at night, a flashlight beam " - "cutting through darkness toward a building entrance and steps, " - "bright spotlight pool on the door, dark surroundings, 16:9"), + "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"), @@ -182,6 +207,7 @@ SCREENS = [ }, { "name": "radial_menu", + "kind": "ui", "title": "Radial action menu", "source": "ADR-0009 · content/strings.en.json", "ref": None, @@ -190,11 +216,11 @@ SCREENS = [ "Wand, Warn, Block, Reason, Recognize, Radio. The focused " "person's display name sits in the centre.", "prompt": ( - "First-person video game screenshot with a radial pie menu centered " - "on screen, twelve labelled slices in a circle reading Stop, Look, " - "Point, No, Ask, Inspect, Wand, Warn, Block, Reason, Recognize, " - "Radio, a highlighted slice, a name in the center of the ring, " - "over a city street scene, 16:9"), + "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"), @@ -203,6 +229,7 @@ SCREENS = [ }, { "name": "shift_review", + "kind": "ui", "title": "Shift review", "source": "PLAN reputation / shift review · architecture.md ShiftReview", "ref": None, @@ -210,10 +237,10 @@ SCREENS = [ "escalation choices, incident list, battery and crowd " "outcomes, and reputation/score feedback.", "prompt": ( - "Video game end-of-shift review screen: a performance summary panel " - "listing incident decisions, escalation choices, reputation score, " - "crowd reaction and battery use, with a continue button, dark themed " - "UI, 16:9"), + "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"), @@ -223,15 +250,16 @@ SCREENS = [ }, { "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": ( - "Video game start menu screen: a title and three venue selection " - "buttons labelled Hollywood Boulevard, The Joke Shop, and TV Studio, " - "dark themed menu over a city street backdrop, 16:9"), + "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"), @@ -241,14 +269,15 @@ SCREENS = [ }, { "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": ( - "Video game pause menu overlay: buttons labelled Resume, Restart, " - "Settings, and Quit to Menu, dark translucent overlay over a paused " - "city street scene, 16:9"), + "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"), @@ -259,13 +288,14 @@ SCREENS = [ }, { "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": ( - "Video game settings screen: sliders labelled look sensitivity and " - "HUD font scale, dark themed settings panel, 16:9"), + "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"), @@ -274,15 +304,17 @@ SCREENS = [ }, { "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": ( - "Video 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 city street, 16:9"), + "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"), @@ -349,10 +381,19 @@ def _draw_wireframe(screen, path): # 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( - screen["prompt"], model, request_size, transparent=False, - aspect_ratio="1:1") + 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"): @@ -360,7 +401,8 @@ def _generate_ai(screen, model, request_size): path = _design_path(screen["name"]) path.parent.mkdir(parents=True, exist_ok=True) 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): diff --git a/tools/test_screen_designs.py b/tools/test_screen_designs.py index 18873a3..a2bbff3 100644 --- a/tools/test_screen_designs.py +++ b/tools/test_screen_designs.py @@ -25,6 +25,7 @@ class ScreenDesignsTests(unittest.TestCase): "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 @@ -42,6 +43,15 @@ class ScreenDesignsTests(unittest.TestCase): 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)