Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2a0ded527 | ||
|
|
922a990c7a | ||
|
|
f0cec192a7 | ||
|
|
2dba562c52 | ||
|
|
afd086ba11 | ||
|
|
b4d189c325 | ||
|
|
6684d36739 | ||
|
|
3f4ac7e9aa | ||
|
|
294ac17333 | ||
|
|
e2c2a0bf4c | ||
|
|
1b976bf351 |
No files matched your search
@@ -113,6 +113,9 @@ jobs:
|
||||
- name: Publish Gitea release (tags only)
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
# An explicit repo secret wins. Without one, publish_release.sh reuses
|
||||
# the local git extraheader that actions/checkout configures for the job
|
||||
# (the runner's own credentials), so tag builds still publish.
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
VERSION: ${{ github.ref_name }}
|
||||
run: |
|
||||
|
||||
@@ -319,7 +319,7 @@ Completed in the working tree (uncommitted with the control pass). Headless **74
|
||||
to the entrance.
|
||||
- [x] Wand shortcut is `C`, not `W`. Tests: `src/test/tests/test_access_loadout.gd`.
|
||||
|
||||
## Player control, focus ring, and radial action menu
|
||||
## Player control, focus circle, and radial action menu
|
||||
|
||||
Completed 2026-10-31 with ADR-0007–0010. Headless **77/77**, plus a windowed smoke
|
||||
(`src/test/smoke_focus_radial.gd`) that opens the wheel and saves screenshots.
|
||||
@@ -334,13 +334,13 @@ Completed 2026-10-31 with ADR-0007–0010. Headless **77/77**, plus a windowed s
|
||||
- [x] `cancel` (Esc / RMB) closes the top modal first; with nothing open, Esc toggles mouse
|
||||
capture and RMB does nothing.
|
||||
|
||||
### Focus and head ring
|
||||
### Focus and focus circle
|
||||
|
||||
- [x] Focus cone in `src/game/npc_focus.gd` (~10 m, ~10°). Closest-to-forward wins; occlusion
|
||||
ray skips building hits. `Tab` still cycles 12 m.
|
||||
- [x] 8-frame miss grace so walk-bob does not drop focus.
|
||||
- [x] Billboarded torus on `Rig/Head`; gold idle, brighter while the radial is open; `Label3D`
|
||||
`display_name`. HUD target line uses the display name, not the node name.
|
||||
- [x] Billboarded blue circle in front of the face on `Rig/Head`; brighter while the radial is
|
||||
open; `Label3D` `display_name`. HUD target line uses the display name, not the node name.
|
||||
- [x] Screen-center crosshair.
|
||||
|
||||
### Radial HUD
|
||||
@@ -364,7 +364,7 @@ Completed 2026-10-31 with ADR-0007–0010. Headless **77/77**, plus a windowed s
|
||||
|
||||
- [x] Named input map: `action_menu` (Space/LMB), `confirm` (Space/Enter), `cancel`
|
||||
(Esc/RMB), `inspect` (E), `wand` (C). `GameManager` uses `is_action_pressed`.
|
||||
- [x] Windowed smoke: focus ring + name, wheel open/step/confirm/cancel
|
||||
- [x] Windowed smoke: focus circle + name, wheel open/step/confirm/cancel
|
||||
(`src/test/smoke_focus_radial.gd`, exits non-zero on failure).
|
||||
- [x] README controls table, smoke checklist, test count, implemented/not-yet lists.
|
||||
|
||||
@@ -405,7 +405,6 @@ screenshots.
|
||||
|
||||
Completed 2026-10-31 (working tree). Headless **88/88**; `src/test/smoke_session_shell.gd` and the windowed focus/radial smoke both pass.
|
||||
|
||||
|
||||
- [x] The skyline backdrop was removed: `assets/textures/ai/game_ready/skyline.png` has an
|
||||
opaque black background, so the planes rendered as black slabs with window dots. Re-add
|
||||
the backdrop only with a transparent asset or a keyed shader.
|
||||
@@ -414,7 +413,7 @@ Completed 2026-10-31 (working tree). Headless **88/88**; `src/test/smoke_session
|
||||
- [x] Steps and the landing are visual-only (no collider). The guard walks through them; add
|
||||
simple step colliders or restage the landing so it does not intersect the walk path.
|
||||
- [x] Marquee text (`STEP LINE`) is tiny from the sidewalk. Scale the Label3D up.
|
||||
- [x] Focus ring and name tag are small at 2–3 m. Consider a larger torus / font after a
|
||||
- [x] Focus circle and name tag are small at 2–3 m. Consider a larger circle / font after a
|
||||
real playtest.
|
||||
- [x] NPC crowd cap (`MAX_LIVE_NPCS`) silently drops routine traffic at 16. Add a short
|
||||
despawn/lifetime for ordinary guests so the entrance does not fill up with standers.
|
||||
@@ -423,7 +422,6 @@ Completed 2026-10-31 (working tree). Headless **88/88**; `src/test/smoke_session
|
||||
|
||||
Completed 2026-10-31 (working tree). Headless **88/88**; `src/test/smoke_session_shell.gd` and the windowed focus/radial smoke both pass.
|
||||
|
||||
|
||||
The game boots into a live 900 s shift. `Save` declares `settings.json` and never uses it.
|
||||
`V` cycles venues by restarting the shift.
|
||||
|
||||
@@ -439,7 +437,6 @@ The game boots into a live 900 s shift. `Save` declares `settings.json` and neve
|
||||
|
||||
Completed 2026-10-31 (working tree).
|
||||
|
||||
|
||||
- [x] README controls table, smoke checklist, test count, and implemented/not-yet lists
|
||||
refreshed for the control pass (77 tests).
|
||||
- [x] `PLAN.md` Tech Stack / Planned Commands still say GUT and GitHub Actions; the project
|
||||
@@ -450,7 +447,6 @@ Completed 2026-10-31 (working tree).
|
||||
|
||||
Completed 2026-10-31 (working tree). Headless **98/98**; `src/test/smoke_plan_verbs.gd` exercises all five verbs.
|
||||
|
||||
|
||||
- [x] Eye contact as a Level 1 action (PLAN / glossary). Distinct from raise-hand. Add a
|
||||
radial slice if it ships.
|
||||
- [x] "I recognize this person" is a player decision recorded as evidence, not a pre-attached
|
||||
@@ -466,7 +462,6 @@ Completed 2026-10-31 (working tree). Headless **98/98**; `src/test/smoke_plan_ve
|
||||
|
||||
Completed 2026-10-31 (working tree). Headless **105/105**; `src/test/smoke_crowd.gd` verifies rubbernecking and panic clearing. A 16-NPC entrance measured ~1.2 ms/frame (824 fps, vsync off), so no LOD/culling was added at the current cap.
|
||||
|
||||
|
||||
`CrowdSystem` is a mood/attention float. PLAN wants people watching, gathering, and panicking.
|
||||
|
||||
- [x] On `aggressive_behavior` / `emergency_responder`, nearby idle NPCs stop and face the
|
||||
@@ -478,7 +473,6 @@ Completed 2026-10-31 (working tree). Headless **105/105**; `src/test/smoke_crowd
|
||||
|
||||
Completed 2026-10-31 (working tree). Headless **113/113**; `src/test/smoke_emergency.gd` covers the unjustified-call investigation, the evacuation aftermath, and the review copy.
|
||||
|
||||
|
||||
Responder outcomes (`command` / `restraint` / `evacuation` / `arrest`) are log lines. PLAN also
|
||||
wants evacuation, a closed street, and an investigation when the call was unjustified.
|
||||
|
||||
@@ -486,3 +480,49 @@ 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.
|
||||
|
||||
## NPC theater
|
||||
|
||||
Completed 2026-10-31 (working tree). Headless **127/127**; verified visually with a staged sit/photo/perform lineup.
|
||||
|
||||
Intents `sit` / `photo` / `film` / `perform` already pick waypoints. ADR-0010 rig (face/hair)
|
||||
is in the working tree. Bodies still idle-stand at those spots.
|
||||
|
||||
- [x] Sit pose at `steps_end`. Photo: brief raise of the influencer/tourist prop. Performer
|
||||
stays put and plays the hat/sign.
|
||||
|
||||
## Audio
|
||||
|
||||
Completed 2026-10-31 (working tree). Headless **134/134**; `src/test/smoke_audio.gd` checks the bed follows mood and the radio crackles, headless and windowed (real audio server).
|
||||
|
||||
`AudioFeedback` is sine beeps. DONE called that "crowd ambience"; it is not.
|
||||
|
||||
- [x] Loop a low crowd bed that rises with `CrowdSystem.mood`.
|
||||
- [x] Radio open/close crackle on the existing radio panel, still headless-safe (no-op without
|
||||
an audio server).
|
||||
|
||||
## Accessibility
|
||||
|
||||
Completed 2026-10-31 (working tree). Headless **139/139**; verified visually at 1.4x font scale with the shape meter showing.
|
||||
|
||||
- [x] Escalation colors that still read in grayscale (shape or label, not hue alone).
|
||||
- [x] HUD font scale in settings.
|
||||
|
||||
## Scenario content still missing vs PLAN lists
|
||||
|
||||
Completed 2026-10-31 (working tree). Headless **149/149**; `src/test/smoke_scenario_content.gd` spawns the refusal event and the celebrity arrival through the real signal path.
|
||||
|
||||
|
||||
- [x] Joke Shop: guest on the reservation list without ID; borrowed/mismatched ID; guest
|
||||
refuses wand screening; late arrival during a performance.
|
||||
- [x] Studio: filming-boundary repeat (influencer ignores a posted line); celebrity-arrival
|
||||
crowd spike (more rubberneckers, not only one `arrive` spawn).
|
||||
- [x] Building: "activate body camera" and "request reason" once those verbs exist, used in
|
||||
at least one authored event.
|
||||
@@ -21,11 +21,12 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
||||
Windowed control smoke: `godot --path . --script res://src/test/smoke_focus_radial.gd` → exit 0.
|
||||
- `main` is in sync with origin. **v0.2.2** tags the session-shell + world-follow-up commit;
|
||||
CI builds the portable binaries, NSIS installer, and AppImage on the tag push.
|
||||
- Next work: `TODO.md` body-camera replay timeline, then NPC theater poses (sit/photo/perform)
|
||||
and audio.
|
||||
- Next work: the remaining TODO sections — distribution host setting (`REQUIRE_SIGNIN_VIEW`),
|
||||
open polish (`Strings.fmt` placeholders), the vision-model screenshot judge, and editor tooling.
|
||||
- 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
|
||||
@@ -35,7 +36,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
||||
`hud.controls` / `hud.screen_first` updated.
|
||||
- Control pass (in tree, uncommitted, smoke-tested): cone focus via `src/game/npc_focus.gd`,
|
||||
accel/decel, gravity only airborne, `input_locked` freeze, Esc no longer on the guard.
|
||||
`NpcAvatar` in group `npc`; neck/hair/eyes/shoes; torus head ring + `Label3D` name; `set_focus_visual`.
|
||||
`NpcAvatar` in group `npc`; neck/hair/eyes/shoes; blue focus circle + `Label3D` name; `set_focus_visual`.
|
||||
`src/game/ui/action_radial.gd` doughnut (LMB/Space open, A/D/arrows, Space/click confirm, Esc/RMB
|
||||
cancel). Crosshair CanvasLayer. Emergency still `R` only. Number keys 1–6 when the wheel is closed.
|
||||
- Named input actions now exist and are used: `action_menu`, `confirm`, `cancel` (Esc + RMB),
|
||||
@@ -58,7 +59,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
||||
(`assets/textures/skyline.png`, regenerated RGBA with a transparent sky) and an
|
||||
`TRANSPARENCY_ALPHA_SCISSOR` unshaded material (`_skyline_mat`); steps + landing are a
|
||||
`StaticBody3D` with per-tread `BoxShape3D` colliders; marquee `font_size` 160 / outline 24; focus
|
||||
torus 0.26–0.34 + name tag 48px; `NpcAvatar.ephemeral`/`lifetime_s` despawns routine traffic
|
||||
circle (blue disc in front of the face) + name tag 48px; `NpcAvatar.ephemeral`/`lifetime_s` despawns routine traffic
|
||||
(authored events stay). Guard spawn height verified on the landing (guard_y=0.84).
|
||||
- **PLAN verbs (2026-10-31, uncommitted):** the radial now has 12 slices
|
||||
(`Stop, Look, Point, No, Ask, Inspect, Wand, Warn, Block, Reason, Recognize, Radio`).
|
||||
@@ -94,6 +95,34 @@ 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.
|
||||
- **Authored scenario content (2026-10-31):** events can now carry `hint` (a string key logged
|
||||
when the avatar spawns), `extra_spawns` (celebrity arrivals pull in tourists/influencers via
|
||||
`_spawn_npc` with `kind: routine`), and `refuses_screening` (copied to
|
||||
`NPCController.refuses_screening`; `_toggle_wand_screening` logs `wand.refused`, records a
|
||||
`wand_refused` event, applies a small prop hit, and does not open the panel). `film_boundary`
|
||||
is a new intent that maps to `steps_end` so the influencer stands past the posted line.
|
||||
- **Accessibility (2026-10-31):** escalation text is `"<name> [###--] L3/5"` via
|
||||
`Hud.escalation_meter(level)` (pure, tested) so the cue survives grayscale. HUD font scale uses
|
||||
`add_theme_font_size_override` on the core labels (`set_font_scale`), **not** `Control.scale`,
|
||||
which pushed the right-anchored clock label off-screen. Settings load before `_build_ui()`, so
|
||||
`_ready()` re-applies player settings after the HUD exists.
|
||||
- **Audio (2026-10-31):** `AudioFeedback.start_crowd_bed()` builds a 4 s looping `AudioStreamWAV`
|
||||
(one-pole-filtered noise + 68/103 Hz drone, seeded) and `set_crowd_level(mood)` maps 0..1 to
|
||||
-34..-12 dB. `play_radio_open/close` push short filtered-noise bursts. All procedural; with a
|
||||
dummy (headless) audio server the calls are no-ops. `_free_player()` returns null when `_ready`
|
||||
has not run (tests), and callers handle null.
|
||||
- **NPC theater (2026-10-31):** `NpcAvatar.theater_pose()` maps `intent` + `arrived` to
|
||||
idle/sit/photo/film/perform. Pure helpers `crouch_for_pose`, `prop_lift_for_pose`,
|
||||
`sway_for_pose` keep the math testable. Sit lowers the rig by 0.34 and swings the legs forward
|
||||
(negative X pitch; positive looked like kneeling). `_photo_prop` is set for influencer/tourist
|
||||
cameras, `_sign_prop` for protester/performer boards; their base Y values are captured at build
|
||||
time so the animation offsets from rest.
|
||||
- **Body-camera timeline (2026-10-31):** `src/game/ui/event_timeline.gd` flattens
|
||||
`review.incidents.incidents[].events` into one time-ordered list (`flatten`), formats each row
|
||||
with `describe`, and steps with Left/Right or the buttons. `ShiftCoordinator.started_at_ms`
|
||||
gives the review an absolute start for mm:ss stamps. `GameManager._open_timeline` hides the
|
||||
review dim (two stacked overlays made the screen nearly black) and `_timeline_from_review`
|
||||
restores it on close. Smoke: `src/test/smoke_timeline.gd`.
|
||||
- **Radio dispatch was dead after the first shift:** `coordinator.setup()` builds a new
|
||||
`RadioSystem`, so the `responder_arrived` connection made in `_ready` was lost on every venue
|
||||
switch/restart (responders never resolved anything). `_connect_radio()` now re-wires it next to
|
||||
@@ -112,7 +141,42 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
||||
needs `mipmaps/generate=true`.
|
||||
- **Asset gotcha:** `assets/textures/game_ready/` is *not* imported (no `.import`). Use the
|
||||
imported `assets/textures/skyline.png` path. Untracked duplicate folder should not be referenced.
|
||||
- Host sign-in follow-up remains deferred. PLAN.md + ADRs 0007–0010 still describe the intended UX.
|
||||
- Host sign-in follow-up remains deferred.
|
||||
- **Release publishing was broken since v0.2.2 (fixed 2026-10-31):** `publish_release.sh`
|
||||
only built `AUTH` in the `GITEA_TOKEN` branch; the `~/.git-credentials` branch set `CRED`
|
||||
and then used unbound `${AUTH[@]}` under `set -u`, so every API call failed and the tag
|
||||
job ended "could not create or find release". The repo also has **no** `GITEA_TOKEN`
|
||||
secret (`/actions/secrets` is empty) and the runner has no git credentials. Fixes: the
|
||||
script sets `AUTH=(-u "$CRED")`; the workflow falls back to `github.token`
|
||||
(`secrets.GITEA_TOKEN || github.token`) — but `github.token` is **not** provided by this
|
||||
Gitea/act runner, so the env dump showed no token and the v0.2.10 tag build failed again.
|
||||
Final fix: `publish_release.sh` falls back to the **local git extraheader** that
|
||||
`actions/checkout` writes (`git config --local --get-regexp '^http\..*\.extraheader$'`),
|
||||
which carries the runner's basic-auth credentials for the same server. Verified locally by
|
||||
adding that extraheader and publishing v0.2.10 (all five artifacts). **v0.2.9 and v0.2.10
|
||||
are published**; v0.2.4–v0.2.8 tags have no artifacts. Verify the CI path on the next tag.
|
||||
- **CI publishing verified (2026-10-31):** tagging v0.2.12 produced a release with all five
|
||||
artifacts automatically (no repo secret), confirming the checkout-extraheader fallback.
|
||||
- **Host access (2026-10-31):** `root@192.168.88.30` now works from this workstation (key auth),
|
||||
host name `gitea`, Gitea 28.2.0. Maintenance applied: `apt-get clean`, journald capped to
|
||||
`SystemMaxUse=300M` (`/etc/systemd/journald.conf.d/99-size.conf`, journal ~133M), disk at
|
||||
~7.6G free of 16G.
|
||||
- **Host disk resolved (2026-10-31):** the Gitea VM root filesystem was expanded from 7.8G to
|
||||
16G (`/dev/mapper/pve-vm--500--disk--0`, now ~7.8G free). The pruned **v0.2.0** and **v0.2.9**
|
||||
releases were restored from the local `build/` artifacts and verified (4 and 5 assets).
|
||||
`git.allucanget.biz` runs on this host as user `zwitschi`; root@192.168.88.30 is **not**
|
||||
reachable from this workstation (publickey denied; password auth closes the banner), so host
|
||||
maintenance must go through a `zwitschi` login with sudo.
|
||||
- **Host disk full (2026-10-31, historical):** the v0.2.11 tag build created release id 27 and logged the
|
||||
uploads, but they failed with `HTTP 500 Attachments.Save: write … no space left on device`
|
||||
(`/dev/mapper/pve-vm--500--disk--0`, 7.8G, 100%, 0 free). `git push` then failed too
|
||||
("unpacker error"). Unblocked by deleting the superseded **v0.2.0** and **v0.2.9** releases
|
||||
via the API (~565 MB of assets), then publishing v0.2.11 locally with the fixed script
|
||||
(verified 5 assets). Remaining ~150 MB is tight — the next release needs host cleanup on
|
||||
192.168.88.30: `sudo journalctl --vacuum-size=100M` (journal 837 MB), `sudo apt-get clean`
|
||||
(91 MB), clear `/var/lib/gitea/data/attachments/tmp`. SSH works as `zwitschi`; `sudo -n`
|
||||
needs a password. **Releases now:** v0.2.11, v0.2.10, v0.2.1 (5 assets each); v0.2.0 and
|
||||
v0.2.9 entries were pruned. PLAN.md + ADRs 0007–0010 still describe the intended UX.
|
||||
- **Release v0.2.1 is LIVE** (2026-10-31): CI run 1110 built the NSIS installer + AppImage
|
||||
and published — installers/AppImage are on the releases page. Releasing = bump version → tag → push.
|
||||
- NSIS gotchas (validated in a local ubuntu:24.04 docker repro): makensis uses `-D` defines
|
||||
@@ -204,8 +268,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).
|
||||
|
||||
@@ -458,7 +458,7 @@ Desktop is the first target: Windows, Linux, and macOS. Web export is a later op
|
||||
|
||||
The prototype already walks and looks in first person, but the control scheme fights itself and targeting is a hairline. This pass replaces that with three things that belong together: solid locomotion, a visible focus on a person, and a round action menu for what to do with them.
|
||||
|
||||
Decisions: [ADR-0007](docs/adr/0007-locomotion-and-input-layers.md) locomotion and input layers, [ADR-0008](docs/adr/0008-cone-focus-and-head-ring.md) cone focus and head ring, [ADR-0009](docs/adr/0009-radial-action-menu.md) radial action menu, [ADR-0010](docs/adr/0010-procedural-npc-presentation.md) procedural NPC presentation.
|
||||
Decisions: [ADR-0007](docs/adr/0007-locomotion-and-input-layers.md) locomotion and input layers, [ADR-0008](docs/adr/0008-cone-focus-and-head-ring.md) cone focus and focus circle, [ADR-0009](docs/adr/0009-radial-action-menu.md) radial action menu, [ADR-0010](docs/adr/0010-procedural-npc-presentation.md) procedural NPC presentation.
|
||||
|
||||
### What is wrong today
|
||||
|
||||
@@ -496,17 +496,17 @@ Replace the hairline ray as the primary picker.
|
||||
- `Tab` still cycles people inside 12 m as the aim-free path. Cycling sets the same focused person the cone would.
|
||||
- Clearing: look away until the head leaves the circle for a few frames (small grace so a bob does not drop them), or they walk out of range.
|
||||
|
||||
### Head ring
|
||||
### Focus circle
|
||||
|
||||
When a person is in focus, draw a thin ring around their head.
|
||||
When a person is in focus, draw a blue, semi-transparent circle in front of their face.
|
||||
|
||||
- Attach to the avatar `Head` node, sitting just above the skull, billboarded to the camera so it stays a circle on screen.
|
||||
- Attach to the avatar `Head` node, just in front of the face, billboarded to the camera so it stays a circle on screen.
|
||||
- Slow spin is fine; no ping-pong scale spam.
|
||||
- Idle focus: pale gold, modest size. Radial open on that person: brighter, slightly larger.
|
||||
- Optional small name under the ring (`display_name` only, not the archetype cheat `Violent Threat` in the world). The existing HUD target line can keep the archetype for the prototype.
|
||||
- One ring at a time. Everyone else has none.
|
||||
- Idle focus: blue, semi-transparent, modest size. Radial open on that person: brighter, slightly larger.
|
||||
- Optional small name under the circle (`display_name` only, not the archetype cheat `Violent Threat` in the world). The existing HUD target line can keep the archetype for the prototype.
|
||||
- One circle at a time. Everyone else has none.
|
||||
|
||||
Implementation sketch: `MeshInstance3D` torus or a thin unshaded ring mesh on the avatar, toggled from `GuardController` / `GameManager` when focus changes. Screen-space fallback (unproject head, draw a `Control` circle) is acceptable if the 3D torus z-fights on hats.
|
||||
Implementation sketch: a thin unshaded `MeshInstance3D` circle (flat disc) in front of the face on the avatar, toggled from `GuardController` / `GameManager` when focus changes. Screen-space fallback (unproject head, draw a `Control` circle) is acceptable if the 3D circle z-fights on hats.
|
||||
|
||||
### Round action HUD
|
||||
|
||||
@@ -545,7 +545,7 @@ A doughnut / pie menu centered on the screen, opened on the focused person.
|
||||
**Cancel**
|
||||
|
||||
- `Esc` or right mouse button: hide the menu, no action, no recorder event, recapture mouse.
|
||||
- Focus and the head ring stay as they were.
|
||||
- Focus and the focus circle stay as they were.
|
||||
|
||||
Number keys `1`–`6` may remain as shortcuts that skip the wheel so a smoke-test script still works. They must not fire while the wheel is open (the wheel owns `A` / `D` / `Space`). Document both in the README.
|
||||
|
||||
|
||||
@@ -28,8 +28,9 @@ The central design goal is simple:
|
||||
|
||||
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/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.13** — authored scenario content: Joke Shop access edge cases, Studio filming
|
||||
> boundaries and celebrity crowd spikes, and Building events that use the body-camera and
|
||||
> request-reason verbs.
|
||||
> 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 +188,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
|
||||
@@ -230,7 +237,7 @@ godot --headless --path . --script res://src/test/test_runner.gd
|
||||
# Save a screenshot to user:// (windowed)
|
||||
godot --path . --script res://src/test/take_screenshot.gd
|
||||
|
||||
# Windowed control smoke: focus ring + action wheel, saves PNGs, exits 1 on failure
|
||||
# Windowed control smoke: focus circle + action wheel, saves PNGs, exits 1 on failure
|
||||
godot --path . --script res://src/test/smoke_focus_radial.gd
|
||||
|
||||
# Session-shell smoke: menu → shift → pause → settings → menu (headless or windowed)
|
||||
@@ -244,6 +251,15 @@ godot --headless --path . --script res://src/test/smoke_crowd.gd
|
||||
|
||||
# Emergency smoke: unjustified call, investigation note, evacuation aftermath
|
||||
godot --headless --path . --script res://src/test/smoke_emergency.gd
|
||||
|
||||
# Body-camera timeline smoke: review → timeline → step through → back to review
|
||||
godot --headless --path . --script res://src/test/smoke_timeline.gd
|
||||
|
||||
# Audio smoke: crowd bed follows mood, radio open/close crackle
|
||||
godot --headless --path . --script res://src/test/smoke_audio.gd
|
||||
|
||||
# Scenario-content smoke: wand refusal, celebrity crowd spike
|
||||
godot --headless --path . --script res://src/test/smoke_scenario_content.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,12 +312,12 @@ 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: **149 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content)
|
||||
- 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`)
|
||||
- Control pass: focus cone on NPC heads with a billboarded blue circle in front of the face 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`)
|
||||
- Session shell: venue start menu (keyboard + mouse), Esc pause overlay (resume / restart / settings / quit to menu), settings panel with mouse-look sensitivity and HUD font scale persisted to `user://settings.json`, and "Run another shift" returning to the venue menu
|
||||
- World presentation: skyline backdrop rendered with an alpha material, walkable step and landing colliders, larger marquee text, and larger focus ring / name tag
|
||||
- World presentation: skyline backdrop rendered with an alpha material, walkable step and landing colliders, larger marquee text, and larger focus circle / name tag
|
||||
- Routine traffic despawns after a while at its waypoint so the crowd cap no longer fills with idle standers
|
||||
- PLAN verbs: eye contact (distinct Level 1 cue), recognizing a person as a player decision recorded as access evidence, a body-camera flag that a formal warning arms (and `B` toggles), blocking that physically stations the guard between the person and the door, and a Level 3 request-reason ask answered from the NPC's `dialogue_lines`
|
||||
- Incidents are now actually opened on focus and closed at shift end, so player actions, access evidence, escalation levels, and unresolved interactions reach the review (previously every recorded event was dropped)
|
||||
@@ -309,10 +325,15 @@ 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
|
||||
- NPC theater: seated guests sit on the steps with bent legs, influencers and tourists raise a camera prop while photographing, performers sway their sign, and filming visitors pan slowly — all once they reach their intent's waypoint
|
||||
- Audio: a looping procedural crowd bed (filtered murmur) whose volume follows `CrowdSystem.mood`, plus radio open/close crackle. All generated at runtime, no audio assets, and safe with a dummy audio server
|
||||
- Accessibility: the escalation cue shows a shape meter plus the numeric level (`Escalation: Blocking [###--] L3/5`) so it reads in grayscale, and the HUD font scale from settings is applied at boot by resizing fonts (not Control.scale, which broke the right-aligned layout)
|
||||
- Authored scenario content: Joke Shop reservation-without-ID, borrowed/mismatched ID, a guest who refuses the wand screen, and a late arrival during a performance; Studio filming-boundary repeats and a celebrity arrival that draws a small crowd; Building events that point the player at the body camera and request-reason verbs
|
||||
- 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
|
||||
|
||||
@@ -324,7 +345,7 @@ Not yet implemented: body-camera replay timeline, editor-based GUT integration,
|
||||
6. The skyline backdrop shows silhouettes against the sky, not an opaque black slab.
|
||||
7. The `STEP LINE` marquee is readable from the sidewalk.
|
||||
8. NPCs spawn and approach the entrance during the first minutes.
|
||||
9. Looking at an NPC inside the focus cone shows a gold head ring, the person's name, and the HUD target line.
|
||||
9. Looking at an NPC inside the focus cone shows a blue focus circle in front of the face, the person's name, and the HUD target line.
|
||||
10. `Space` or left click opens the action wheel; `A`/`D` highlight a slice, `Space` confirms it, `Esc`/right click cancels without a log entry.
|
||||
11. `E`/`C` open the inspection/wand panels from the wheel and freeze movement until they close; walking never fires the wand.
|
||||
12. Pressing `Q` produces a reaction log entry and reputation feedback.
|
||||
@@ -345,7 +366,10 @@ 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. The escalation label shows a `[###--] L3/5` shape meter as well as the level name, and stays readable in grayscale.
|
||||
32. Changing "HUD font scale" in Settings resizes the HUD text and persists across a restart.
|
||||
33. When the shift timer reaches 0, the shift review screen shows grade, incident summary, responder outcomes, investigations, and persisted shift history; "Run another shift" returns to the venue menu.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
|
||||
@@ -1,46 +1,10 @@
|
||||
# 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.13** is tagged. Headless suite **149/149**, plus the session,
|
||||
PLAN-verb, crowd, emergency, timeline, audio, scenario-content, and windowed focus/radial smokes.
|
||||
|
||||
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)
|
||||
is in the working tree. Bodies still idle-stand at those spots.
|
||||
|
||||
- [ ] Sit pose at `steps_end`. Photo: brief raise of the influencer/tourist prop. Performer
|
||||
stays put and plays the hat/sign.
|
||||
|
||||
## Audio
|
||||
|
||||
`AudioFeedback` is sine beeps. DONE called that "crowd ambience"; it is not.
|
||||
|
||||
- [ ] Loop a low crowd bed that rises with `CrowdSystem.mood`.
|
||||
- [ ] Radio open/close crackle on the existing radio panel, still headless-safe (no-op without
|
||||
an audio server).
|
||||
|
||||
## Accessibility
|
||||
|
||||
- [ ] Escalation colors that still read in grayscale (shape or label, not hue alone).
|
||||
- [ ] HUD font scale in settings.
|
||||
|
||||
## Scenario content still missing vs PLAN lists
|
||||
|
||||
- [ ] Joke Shop: guest on the reservation list without ID; borrowed/mismatched ID; guest
|
||||
refuses wand screening; late arrival during a performance.
|
||||
- [ ] Studio: filming-boundary repeat (influencer ignores a posted line); celebrity-arrival
|
||||
crowd spike (more rubberneckers, not only one `arrive` spawn).
|
||||
- [ ] Building: "activate body camera" and "request reason" once those verbs exist, used in
|
||||
at least one authored event.
|
||||
|
||||
## Distribution follow-up (host config, deferred)
|
||||
|
||||
Verified via API+host credentials (2026-10-31):
|
||||
@@ -52,6 +16,24 @@ Verified via API+host credentials (2026-10-31):
|
||||
anonymous browsing/downloads redirect to login. Host admin: `REQUIRE_SIGNIN_VIEW = false`
|
||||
under `[service]` in `app.ini`, restart Gitea. Alternative: mirror downloads.
|
||||
|
||||
## Release pipeline follow-up
|
||||
|
||||
- [x] Fix `tools/publish_release.sh`: the `~/.git-credentials` branch never set `AUTH`,
|
||||
so every tag build since v0.2.2 failed to publish.
|
||||
- [x] Add a `github.token` fallback in `.gitea/workflows/build.yml` (the repo has no
|
||||
`GITEA_TOKEN` secret).
|
||||
- [x] Fall back to the checkout's git extraheader when no token/credentials exist (the
|
||||
Gitea runner does not expose `github.token`).
|
||||
- [x] Uploads now fail loudly and the release is verified to carry every artifact
|
||||
(a silent HTTP 4xx used to leave an empty release with a green job).
|
||||
- [x] Host disk expanded to 16G; v0.2.0 and v0.2.9 releases restored from the local artifacts.
|
||||
- [x] Host maintenance via `root@192.168.88.30`: apt cache cleaned and journald capped
|
||||
(`SystemMaxUse=300M` in `/etc/systemd/journald.conf.d/99-size.conf`); ~7.6G free.
|
||||
- [x] Verified end to end: tagging v0.2.12 produced a release with all five artifacts
|
||||
(AppImage, Linux, macOS, NSIS installer, Windows) from CI, with no repo secret.
|
||||
- [ ] Optionally backfill releases for v0.2.4–v0.2.8 (their CI artifacts were not
|
||||
retained; would need local rebuilds) or leave them superseded by the latest.
|
||||
|
||||
## Open Polish
|
||||
|
||||
- [ ] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
|
||||
|
||||
@@ -56,6 +56,15 @@
|
||||
"reason.ask": "You ask %s why they are here.",
|
||||
"reason.reply": "%s: \"%s\"",
|
||||
"reason.silent": "%s says nothing and looks away.",
|
||||
"hint.reservation_no_id": "On the reservation list but no ID — ask for ID, then check the list.",
|
||||
"hint.borrowed_id": "The ID photo does not match the person. Verify before admitting.",
|
||||
"hint.refuses_wand": "This guest refuses the wand screen. No screen, no entry.",
|
||||
"hint.late_arrival": "Late arrival during the performance — check ID and ticket before opening the door.",
|
||||
"hint.film_boundary": "Filming past the posted line. Ask their reason and point them back to the sidewalk.",
|
||||
"hint.film_boundary_repeat": "Same crew again, still past the line. A formal warning is now reasonable.",
|
||||
"hint.celebrity_arrival": "Celebrity arrival: the crowd is swelling. Keep the entrance clear.",
|
||||
"hint.body_camera_reason": "They are filming the entrance. Ask their reason (Reason) and consider the body camera (B).",
|
||||
"hint.ask_reason": "Unclear intent. Ask why they are here (Reason) before deciding anything else.",
|
||||
"station.blocking": "You step between %s and the door.",
|
||||
"perception.near": "%s notices you standing close.",
|
||||
"perception.aware": "%s notices you watching.",
|
||||
@@ -68,5 +77,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"
|
||||
}
|
||||
@@ -37,8 +37,30 @@ The repository ships a Gitea Actions workflow (`.gitea/workflows/build.yml`):
|
||||
Linux/Windows/macOS, artifact upload, and — on tags — release publication via
|
||||
`tools/publish_release.sh`.
|
||||
|
||||
For tag builds, set a repo secret named **`GITEA_TOKEN`** (write:repository scope).
|
||||
Without it the workflow still builds and tests, but the release publish step fails.
|
||||
`tools/publish_release.sh` picks the first available credential, in order:
|
||||
|
||||
1. `GITEA_TOKEN` (repo secret, write:repository scope),
|
||||
2. `GITEA_AUTH_HEADER` (a ready-made `Authorization: ...` header),
|
||||
3. `~/.git-credentials` for the host,
|
||||
4. the **local git extraheader** that `actions/checkout` configures on the CI job.
|
||||
|
||||
Option 4 is what makes tag builds publish without any repo secret: the runner
|
||||
already injects basic-auth credentials for the checkout, and the script reuses that
|
||||
header for the API (same server). `github.token` is *not* available in this Gitea
|
||||
setup, so do not rely on it.
|
||||
|
||||
> **Disk space:** Gitea stores release assets under `/var/lib/gitea/data/attachments` on
|
||||
> the host root filesystem (7.8 GB here). When it is full, asset uploads fail with
|
||||
> `HTTP 500 … no space left on device` and even `git push` can fail with "unpacker error".
|
||||
> `publish_release.sh` now verifies the asset count and exits non-zero, so this is visible.
|
||||
>
|
||||
> **Incident (2026-10-31):** `tools/publish_release.sh` never set `AUTH` in its
|
||||
> `~/.git-credentials` branch, so every API call failed silently (`AUTH[@]` was
|
||||
> unbound under `set -u`). Combined with no `GITEA_TOKEN` secret on the repo, tag
|
||||
> builds from v0.2.2 to v0.2.8 produced installers but published nothing. The script
|
||||
> is fixed (`AUTH=(-u "$CRED")`) and v0.2.9 was published locally with all five
|
||||
> artifacts. If CI publishing fails again, run
|
||||
> `./tools/publish_release.sh <version>` from a checkout with `~/.git-credentials`.
|
||||
|
||||
> **No `actions/cache` for now:** the runner has no local actions-cache service, so
|
||||
> `actions/cache` times out against GitHub's cache API. We dropped it (Godot import
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ADR-0008: Cone focus and head ring
|
||||
# ADR-0008: Cone focus and focus circle
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** TBD
|
||||
@@ -19,7 +19,7 @@ Focus is a **cone**, not a ray.
|
||||
- A few frames of grace keep walk-bob from dropping focus.
|
||||
- `Tab` still cycles people inside 12 m and sets the same focused person.
|
||||
|
||||
When someone is in focus, draw a **thin ring** around their head, attached to the avatar `Head` node, billboarded so it stays a circle on screen. Pale gold while idle; brighter and slightly larger while the action menu is open on that person. Optional `display_name` under the ring. One ring at a time.
|
||||
When someone is in focus, draw a **blue, semi-transparent circle in front of the face**, attached to the avatar `Head` node, billboarded so it stays a circle on screen. Blue while idle; brighter and slightly larger while the action menu is open on that person. Optional `display_name` under the circle. One circle at a time.
|
||||
|
||||
The world must not show the internal type name (`Trespasser`, `Violent Threat`). The debug overlay (`F`) can still print it.
|
||||
|
||||
@@ -28,19 +28,19 @@ The world must not show the internal type name (`Trespasser`, `Violent Threat`).
|
||||
### Positive
|
||||
|
||||
- Looking near a person is enough; the player does not have to pin a capsule.
|
||||
- The ring is visible in the scene, so the HUD is not the only focus cue.
|
||||
- The circle is visible in the scene, so the HUD is not the only focus cue.
|
||||
- `Tab` remains the accessibility path and shares the same focused person.
|
||||
- The action menu (ADR-0009) has a clear subject.
|
||||
|
||||
### Negative
|
||||
|
||||
- Two heads in the same circle can steal focus; center-distance is a heuristic, not a lock-on.
|
||||
- Hats and protester signs can z-fight a 3D torus. A screen-space circle unprojected from the head is an allowed fallback.
|
||||
- Hats and protester signs can z-fight a 3D circle. A screen-space circle unprojected from the head is an allowed fallback.
|
||||
- Occlusion rays can fail on unusual geometry; grace frames hide some of that.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep the hairline ray:** Rejected because it drops people too easily and has no world mark.
|
||||
- **Sticky lock-on until cancelled:** Rejected because the job is to watch a crowd, not duel one person.
|
||||
- **Screen reticle only, no world ring:** Rejected; in a crowd the player needs a mark on the head, not only a HUD line.
|
||||
- **Screen reticle only, no world circle:** Rejected; in a crowd the player needs a mark on the face, not only a HUD line.
|
||||
- **Click a person to select, then act:** Rejected for default play. The mouse is captured for look (ADR-0007). Click opens the action menu on whoever is already in the cone.
|
||||
@@ -31,7 +31,7 @@ While the menu is open, walk and look freeze (ADR-0007) and the cursor is visibl
|
||||
|
||||
**Confirm:** click a slice, or `Space` on the highlighted slice. The menu closes, the mouse is recaptured, and that action runs. Inspect, wand, and radio assist close the wheel and then open the existing panels. Walk and look stay frozen until that panel closes.
|
||||
|
||||
**Cancel:** `Esc` or right mouse button. The menu hides. No action runs. No recorder event. Focus and the head ring stay. The mouse is recaptured.
|
||||
**Cancel:** `Esc` or right mouse button. The menu hides. No action runs. No recorder event. Focus and the focus circle stay. The mouse is recaptured.
|
||||
|
||||
Number keys `1`–`6` may remain as shortcuts when the wheel is closed, so existing smoke tests still work. They must not fire while the wheel is open.
|
||||
|
||||
@@ -54,7 +54,7 @@ Number keys `1`–`6` may remain as shortcuts when the wheel is closed, so exist
|
||||
## Alternatives considered
|
||||
|
||||
- **Hold-to-open, release-to-confirm (GTA / Destiny):** Rejected. The requested input is click or Space to open, then click or Space to confirm, with Esc / RMB to abort. That is a toggle, not a hold.
|
||||
- **Vertical list / command menu:** Rejected because a round layout around a focused person matches the head ring and reads faster in a crowd.
|
||||
- **Vertical list / command menu:** Rejected because a round layout around a focused person matches the focus circle and reads faster in a crowd.
|
||||
- **One context-sensitive Use key:** Rejected; the game is about choosing among levels, not a single default verb.
|
||||
- **Emergency on the wheel:** Rejected because a misclick would dispatch armed response. ADR-0002 already keeps that path rare and confirmed.
|
||||
- **Drop number-key shortcuts immediately:** Deferred. They remain as a closed-menu fallback until the wheel is proven in play.
|
||||
@@ -21,7 +21,7 @@ Make the body read as a person at conversation distance:
|
||||
- Shoes.
|
||||
- Hair cap whose color comes from the visual seed.
|
||||
- Two dark eye spheres so the head has a front.
|
||||
- Named `Head` node (`Rig/Head`) as the attach point for the focus ring (ADR-0008) and the focus cone.
|
||||
- Named `Head` node (`Rig/Head`) as the attach point for the focus circle (ADR-0008) and the focus cone.
|
||||
- Collision capsule scaled to the visible height so the cone hits the body the player sees.
|
||||
|
||||
Keep archetype props, height / brightness variation, walk bob, and the notice head-turn.
|
||||
@@ -49,5 +49,5 @@ AI clothing textures stay out unless a map is already the right size and is wire
|
||||
|
||||
- **Skinned Mixamo / GLB characters now:** Rejected. Cost and pipeline exceed this pass; the sim only needs a readable body and a head node.
|
||||
- **Billboard sprites:** Rejected; first-person movement walks around people, and sprites fail at profile views.
|
||||
- **Keep colored capsules as-is:** Rejected; the head ring and cone need a real head, and capsules do not read as people at two metres.
|
||||
- **Keep colored capsules as-is:** Rejected; the focus circle and cone need a real head, and capsules do not read as people at two metres.
|
||||
- **World labels with archetype names:** Rejected because they leak sim truth and skip observation.
|
||||
@@ -153,7 +153,7 @@ Architecture decisions are recorded as ADRs:
|
||||
- [ADR-0005: Event-based incident recording](adr/0005-event-based-incident-recording.md)
|
||||
- [ADR-0006: Godot 4 with GDScript](adr/0006-godot-4-gdscript.md)
|
||||
- [ADR-0007: Locomotion and input layers](adr/0007-locomotion-and-input-layers.md)
|
||||
- [ADR-0008: Cone focus and head ring](adr/0008-cone-focus-and-head-ring.md)
|
||||
- [ADR-0008: Cone focus and focus circle](adr/0008-cone-focus-and-head-ring.md)
|
||||
- [ADR-0009: Radial action menu](adr/0009-radial-action-menu.md)
|
||||
- [ADR-0010: Procedural NPC presentation](adr/0010-procedural-npc-presentation.md)
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 1004 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 1000 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 954 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 910 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 971 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 1.3 MiB |
@@ -8,7 +8,7 @@ config_version=5
|
||||
|
||||
config/name="Step Line"
|
||||
config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
|
||||
config/version="0.2.5"
|
||||
config/version="0.2.13"
|
||||
run/main_scene="res://scenes/main.tscn"
|
||||
config/features=PackedStringArray("4.3", "GL Compatibility")
|
||||
|
||||
|
||||
@@ -343,6 +343,28 @@ static func hollywood_building() -> VenueScenario:
|
||||
_building_routine("staff_entry", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 600), 0.35),
|
||||
_building_routine("guest_entry", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.25),
|
||||
_building_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.24),
|
||||
{
|
||||
"id": "building_filming_boundary",
|
||||
"phase": Rules.Phase.MIDDAY,
|
||||
"kind": "scripted",
|
||||
"npc_type": Rules.NpcType.INFLUENCER,
|
||||
"intent": "film",
|
||||
"time_range_s": Vector2(300, 600),
|
||||
"probability": 0.4,
|
||||
"base_weight": 1.0,
|
||||
"hint": "hint.body_camera_reason",
|
||||
},
|
||||
{
|
||||
"id": "building_unclear_intent",
|
||||
"phase": Rules.Phase.EVENING,
|
||||
"kind": "scripted",
|
||||
"npc_type": Rules.NpcType.CONFUSED_VISITOR,
|
||||
"intent": "enter",
|
||||
"time_range_s": Vector2(600, 780),
|
||||
"probability": 0.4,
|
||||
"base_weight": 1.0,
|
||||
"hint": "hint.ask_reason",
|
||||
},
|
||||
_building_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.26),
|
||||
_building_routine("intoxicated_wander", Rules.NpcType.INTOXICATED, "wander", Vector2(600, 900), 0.15),
|
||||
_building_routine("trespass_test", Rules.NpcType.TRESPASSER, "trespass", Vector2(300, 900), 0.16),
|
||||
@@ -437,6 +459,67 @@ static func joke_shop() -> VenueScenario:
|
||||
_joke_guest("guest_no_ticket", false, true, Vector2(0, 900), 0.18),
|
||||
_joke_guest("guest_expired_id", true, true, Vector2(0, 900), 0.12, {"id_status": Rules.ArtifactStatus.EXPIRED}),
|
||||
_joke_guest("guest_no_id", true, false, Vector2(0, 900), 0.12),
|
||||
{
|
||||
"id": "joke_guest_reservation_no_id",
|
||||
"phase": Rules.Phase.EVENING,
|
||||
"kind": "scripted",
|
||||
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
|
||||
"intent": "enter",
|
||||
"time_range_s": Vector2(620, 860),
|
||||
"probability": 0.45,
|
||||
"base_weight": 1.0,
|
||||
"override_access": {
|
||||
"has_guest_list": true, "guest_list_status": Rules.ArtifactStatus.VALID,
|
||||
"has_id": false, "has_ticket": false,
|
||||
},
|
||||
"hint": "hint.reservation_no_id",
|
||||
},
|
||||
{
|
||||
"id": "joke_borrowed_id",
|
||||
"phase": Rules.Phase.MIDDAY,
|
||||
"kind": "scripted",
|
||||
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
|
||||
"intent": "enter",
|
||||
"time_range_s": Vector2(320, 700),
|
||||
"probability": 0.4,
|
||||
"base_weight": 1.0,
|
||||
"override_access": {
|
||||
"has_id": true, "id_status": Rules.ArtifactStatus.MISMATCHED,
|
||||
"has_ticket": true, "ticket_status": Rules.ArtifactStatus.VALID,
|
||||
},
|
||||
"hint": "hint.borrowed_id",
|
||||
},
|
||||
{
|
||||
"id": "joke_refuses_wand",
|
||||
"phase": Rules.Phase.EVENING,
|
||||
"kind": "scripted",
|
||||
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
|
||||
"intent": "enter",
|
||||
"time_range_s": Vector2(650, 880),
|
||||
"probability": 0.4,
|
||||
"base_weight": 1.0,
|
||||
"refuses_screening": true,
|
||||
"override_access": {
|
||||
"has_id": true, "id_status": Rules.ArtifactStatus.VALID,
|
||||
"has_ticket": true, "ticket_status": Rules.ArtifactStatus.VALID,
|
||||
},
|
||||
"hint": "hint.refuses_wand",
|
||||
},
|
||||
{
|
||||
"id": "joke_late_arrival",
|
||||
"phase": Rules.Phase.EVENING,
|
||||
"kind": "scripted",
|
||||
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
|
||||
"intent": "arrive",
|
||||
"time_range_s": Vector2(700, 860),
|
||||
"probability": 0.35,
|
||||
"base_weight": 1.0,
|
||||
"override_access": {
|
||||
"has_id": true, "id_status": Rules.ArtifactStatus.VALID,
|
||||
"has_ticket": true, "ticket_status": Rules.ArtifactStatus.VALID,
|
||||
},
|
||||
"hint": "hint.late_arrival",
|
||||
},
|
||||
_joke_staff_entry(Vector2(0, 900), 0.25),
|
||||
_joke_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.15),
|
||||
_joke_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.15),
|
||||
@@ -515,6 +598,45 @@ static func tv_studio() -> VenueScenario:
|
||||
"base_weight": 1.0,
|
||||
"override_access": {"wristband_status": Rules.ArtifactStatus.COUNTERFEIT, "credential_status": Rules.ArtifactStatus.COUNTERFEIT},
|
||||
},
|
||||
{
|
||||
"id": "studio_film_boundary",
|
||||
"phase": Rules.Phase.MIDDAY,
|
||||
"kind": "scripted",
|
||||
"npc_type": Rules.NpcType.INFLUENCER,
|
||||
"intent": "film_boundary",
|
||||
"time_range_s": Vector2(330, 560),
|
||||
"probability": 0.5,
|
||||
"base_weight": 1.0,
|
||||
"hint": "hint.film_boundary",
|
||||
},
|
||||
{
|
||||
"id": "studio_film_boundary_repeat",
|
||||
"phase": Rules.Phase.EVENING,
|
||||
"kind": "scripted",
|
||||
"npc_type": Rules.NpcType.INFLUENCER,
|
||||
"intent": "film_boundary",
|
||||
"time_range_s": Vector2(640, 830),
|
||||
"probability": 0.45,
|
||||
"base_weight": 1.0,
|
||||
"hint": "hint.film_boundary_repeat",
|
||||
},
|
||||
{
|
||||
"id": "studio_celebrity_arrival",
|
||||
"phase": Rules.Phase.EVENING,
|
||||
"kind": "scripted",
|
||||
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
|
||||
"intent": "arrive",
|
||||
"time_range_s": Vector2(620, 760),
|
||||
"probability": 0.5,
|
||||
"base_weight": 1.0,
|
||||
"override_access": {"has_guest_list": true, "guest_list_status": Rules.ArtifactStatus.VALID},
|
||||
"extra_spawns": [
|
||||
{"npc_type": Rules.NpcType.TOURIST, "intent": "photo"},
|
||||
{"npc_type": Rules.NpcType.TOURIST, "intent": "photo"},
|
||||
{"npc_type": Rules.NpcType.INFLUENCER, "intent": "film"},
|
||||
],
|
||||
"hint": "hint.celebrity_arrival",
|
||||
},
|
||||
{
|
||||
"id": "studio_follow_through",
|
||||
"phase": Rules.Phase.EVENING,
|
||||
@@ -587,6 +709,10 @@ static func _joke_routine(id: String, npc_type: int, intent: String, time_range:
|
||||
static func _studio_routine(id: String, npc_type: int, intent: String, time_range: Vector2, prob: float) -> Dictionary:
|
||||
return _building_routine(id, npc_type, intent, time_range, prob)
|
||||
|
||||
## Scripted film-boundary intent: the influencer stands past the posted line.
|
||||
static func _film_boundary_spot() -> String:
|
||||
return "steps_end"
|
||||
|
||||
static func _phase_for_range(time_range: Vector2, duration: float) -> int:
|
||||
var start := time_range.x / duration
|
||||
if start < 0.34:
|
||||
|
||||
@@ -34,6 +34,8 @@ var guard_noticed := false
|
||||
|
||||
## Whether this NPC has passed the handheld wand screening (The Joke Shop).
|
||||
var screened := false
|
||||
## Authored event flag: this person refuses the wand screen (Joke Shop).
|
||||
var refuses_screening := false
|
||||
## Whether the guard has declared they recognize this person (a player decision
|
||||
## recorded as evidence, PLAN.md: Authorized Access).
|
||||
var player_recognized := false
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -63,7 +63,9 @@ func _spot_for_intent(intent: String) -> String:
|
||||
return "entrance"
|
||||
"photo", "film", "perform", "wander", "roam":
|
||||
return "sidewalk"
|
||||
"sit":
|
||||
"sit", "film_boundary":
|
||||
# film_boundary: an influencer ignoring the posted filming line, so they
|
||||
# stand at the steps instead of the permitted sidewalk spot.
|
||||
return "steps_end"
|
||||
_:
|
||||
return "sidewalk"
|
||||
|
||||
@@ -8,6 +8,11 @@ extends Node
|
||||
const SAMPLE_RATE := 22050.0
|
||||
|
||||
var _players: Array[AudioStreamPlayer] = []
|
||||
var _bed_player: AudioStreamPlayer = null
|
||||
## Observable state so headless tests and the HUD can see what was requested.
|
||||
var crowd_level: float = 0.0
|
||||
var bed_playing := false
|
||||
var radio_crackles := 0
|
||||
|
||||
func _ready() -> void:
|
||||
# Pre-create a few players so overlapping cues don't cut each other off.
|
||||
@@ -30,6 +35,43 @@ func play_escalation(level: int) -> void:
|
||||
var freq := 440.0 - float(level) * 40.0
|
||||
_play_tone(maxf(freq, 180.0), 0.18, 0.6)
|
||||
|
||||
## Start the looping crowd bed (low murmur). Called once per shift; safe to call
|
||||
## again. Volume then follows `set_crowd_level`.
|
||||
func start_crowd_bed() -> void:
|
||||
if _bed_player == null:
|
||||
_bed_player = AudioStreamPlayer.new()
|
||||
_bed_player.name = "CrowdBed"
|
||||
add_child(_bed_player)
|
||||
_bed_player.stream = _build_crowd_stream()
|
||||
_bed_player.volume_db = _level_to_db(crowd_level)
|
||||
_bed_player.play()
|
||||
bed_playing = true
|
||||
|
||||
func stop_crowd_bed() -> void:
|
||||
if _bed_player:
|
||||
_bed_player.stop()
|
||||
bed_playing = false
|
||||
|
||||
## Crowd mood (0..1) drives the bed volume: quiet when calm, louder when the
|
||||
## street is agitated or panicking.
|
||||
func set_crowd_level(mood: float) -> void:
|
||||
crowd_level = clampf(mood, 0.0, 1.0)
|
||||
if _bed_player:
|
||||
_bed_player.volume_db = _level_to_db(crowd_level)
|
||||
|
||||
static func _level_to_db(mood: float) -> float:
|
||||
return lerpf(-34.0, -12.0, clampf(mood, 0.0, 1.0))
|
||||
|
||||
## Radio open cue: a short burst of filtered crackle.
|
||||
func play_radio_open() -> void:
|
||||
radio_crackles += 1
|
||||
_play_noise(0.09, 0.35)
|
||||
|
||||
## Radio close cue: a lower, shorter click.
|
||||
func play_radio_close() -> void:
|
||||
radio_crackles += 1
|
||||
_play_noise(0.06, 0.25, 1400.0)
|
||||
|
||||
## Play a denial/error cue (low buzz).
|
||||
func play_deny() -> void:
|
||||
_play_tone(220.0, 0.20, 0.5)
|
||||
@@ -53,7 +95,64 @@ func _play_tone(freq: float, duration: float, volume: float, delay: float = 0.0)
|
||||
var sample := sin(TAU * freq * t) * 0.6
|
||||
playback.push_frame(Vector2(sample, sample))
|
||||
|
||||
## Deterministic low murmur for the crowd bed: filtered noise plus a low drone.
|
||||
func _build_crowd_stream() -> AudioStreamWAV:
|
||||
const SECONDS := 4.0
|
||||
var frames := int(SAMPLE_RATE * SECONDS)
|
||||
var data := PackedByteArray()
|
||||
data.resize(frames * 2)
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = 20261031
|
||||
var smooth := 0.0
|
||||
for i in frames:
|
||||
var t := float(i) / SAMPLE_RATE
|
||||
# Low-passed noise (a simple one-pole filter) sounds like distant talking.
|
||||
smooth = lerpf(smooth, rng.randf_range(-1.0, 1.0), 0.02)
|
||||
var drone := sin(TAU * 68.0 * t) * 0.25 + sin(TAU * 103.0 * t) * 0.12
|
||||
var sample := (smooth * 2.2 + drone) * 0.35
|
||||
var v := int(clampf(sample, -1.0, 1.0) * 32767.0)
|
||||
data[i * 2] = v & 0xFF
|
||||
data[i * 2 + 1] = (v >> 8) & 0xFF
|
||||
var stream := AudioStreamWAV.new()
|
||||
stream.format = AudioStreamWAV.FORMAT_16_BITS
|
||||
stream.mix_rate = int(SAMPLE_RATE)
|
||||
stream.stereo = false
|
||||
stream.data = data
|
||||
stream.loop_mode = AudioStreamWAV.LOOP_FORWARD
|
||||
stream.loop_begin = 0
|
||||
stream.loop_end = frames
|
||||
return stream
|
||||
|
||||
## Short filtered noise burst used for the radio crackle.
|
||||
func _play_noise(duration: float, volume: float, cutoff_hz: float = 3200.0) -> void:
|
||||
var player := _free_player()
|
||||
if player == null:
|
||||
return
|
||||
var frames := int(SAMPLE_RATE * duration)
|
||||
var data := PackedByteArray()
|
||||
data.resize(frames * 2)
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = 7
|
||||
var smooth := 0.0
|
||||
var alpha := clampf(cutoff_hz / SAMPLE_RATE, 0.01, 0.9)
|
||||
for i in frames:
|
||||
smooth = lerpf(smooth, rng.randf_range(-1.0, 1.0), alpha)
|
||||
var env := 1.0 - float(i) / float(maxi(frames, 1))
|
||||
var v := int(clampf(smooth * env, -1.0, 1.0) * 32767.0)
|
||||
data[i * 2] = v & 0xFF
|
||||
data[i * 2 + 1] = (v >> 8) & 0xFF
|
||||
var stream := AudioStreamWAV.new()
|
||||
stream.format = AudioStreamWAV.FORMAT_16_BITS
|
||||
stream.mix_rate = int(SAMPLE_RATE)
|
||||
stream.stereo = false
|
||||
stream.data = data
|
||||
player.stream = stream
|
||||
player.volume_db = linear_to_db(volume)
|
||||
player.play()
|
||||
|
||||
func _free_player() -> AudioStreamPlayer:
|
||||
if _players.is_empty():
|
||||
return null
|
||||
for player in _players:
|
||||
if not player.playing:
|
||||
return player
|
||||
|
||||
@@ -27,6 +27,7 @@ const ShiftReview := preload("res://src/game/ui/shift_review.gd")
|
||||
const StartMenu := preload("res://src/game/ui/start_menu.gd")
|
||||
const PauseOverlay := preload("res://src/game/ui/pause_overlay.gd")
|
||||
const SettingsPanel := preload("res://src/game/ui/settings_panel.gd")
|
||||
const EventTimeline := preload("res://src/game/ui/event_timeline.gd")
|
||||
|
||||
var world_builder: WorldBuilder = null
|
||||
var door := DoorState.new()
|
||||
@@ -70,6 +71,8 @@ var shift_review_panel: ShiftReview = null
|
||||
var start_menu: StartMenu = null
|
||||
var pause_overlay: PauseOverlay = null
|
||||
var settings_panel: SettingsPanel = null
|
||||
var event_timeline: EventTimeline = null
|
||||
var _timeline_from_review := false
|
||||
var _shift_running := false
|
||||
var player_settings: Dictionary = {}
|
||||
var _focused_avatar: Node3D = null
|
||||
@@ -138,6 +141,9 @@ func _ready() -> void:
|
||||
_connect_crowd()
|
||||
|
||||
_build_ui()
|
||||
# The HUD does not exist yet when settings are loaded, so push them again now
|
||||
# that it does (otherwise the saved font scale is ignored until the next apply).
|
||||
_apply_settings(player_settings)
|
||||
_last_phase = -1
|
||||
_apply_phase_lighting()
|
||||
# Boot into the venue menu; the shift starts when the player picks a venue.
|
||||
@@ -230,6 +236,9 @@ func _reset_shift_state() -> void:
|
||||
## Begin (or resume) the shift clock and recapture the mouse.
|
||||
func _start_shift() -> void:
|
||||
_shift_running = true
|
||||
if audio:
|
||||
audio.start_crowd_bed()
|
||||
audio.set_crowd_level(coordinator.crowd.mood)
|
||||
coordinator.start()
|
||||
if hud:
|
||||
hud.set_shift_state(coordinator, selected_scenario)
|
||||
@@ -319,12 +328,15 @@ func _build_ui() -> void:
|
||||
radio_panel.submitted.connect(_on_radio_submitted)
|
||||
radio_panel.cancelled.connect(func():
|
||||
radio_panel.hide()
|
||||
if audio:
|
||||
audio.play_radio_close()
|
||||
hud.set_action_hint("")
|
||||
_refresh_freeze())
|
||||
shift_review_panel = ShiftReview.new()
|
||||
add_child(shift_review_panel)
|
||||
shift_review_panel.hide()
|
||||
shift_review_panel.restart_requested.connect(_on_review_restart)
|
||||
shift_review_panel.timeline_requested.connect(_open_timeline)
|
||||
shift_review_panel.visible = false
|
||||
|
||||
pause_overlay = PauseOverlay.new()
|
||||
@@ -340,6 +352,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 +364,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()
|
||||
@@ -445,8 +479,19 @@ func _spawn_npc(profile: NpcProfile, intent: String, npc_id: String, event: Dict
|
||||
# Authored events stay for the whole shift; routine traffic cycles out.
|
||||
avatar.ephemeral = event.get("kind", "routine") == "routine"
|
||||
avatar.lifetime_s = 45.0 + float((npc_counter * 17) % 60)
|
||||
avatar.get_npc_controller().refuses_screening = bool(event.get("refuses_screening", false))
|
||||
scene_root.add_child(avatar)
|
||||
npcs.append(avatar)
|
||||
# Authored events can point the player at the right verb (PLAN.md verbs).
|
||||
if event.has("hint") and hud:
|
||||
hud.log(Strings.lookup(str(event["hint"])))
|
||||
# Celebrity arrivals draw extra bystanders, not just one avatar.
|
||||
for extra in event.get("extra_spawns", []):
|
||||
var extra_profile := selected_scenario.profile_for(extra.get("npc_type", Rules.NpcType.TOURIST))
|
||||
if extra_profile == null or npcs.size() >= MAX_LIVE_NPCS:
|
||||
continue
|
||||
_spawn_npc(extra_profile, str(extra.get("intent", "photo")),
|
||||
"%s_w%d" % [npc_id, npcs.size()], {"kind": "routine"})
|
||||
|
||||
## Drop references to freed avatars so the cap and Tab cycling stay accurate.
|
||||
func _purge_npcs() -> void:
|
||||
@@ -540,6 +585,8 @@ func _connect_crowd() -> void:
|
||||
func _on_crowd_changed(payload: Dictionary) -> void:
|
||||
var event_type := str(payload.get("event", ""))
|
||||
var panic := bool(payload.get("panic", false))
|
||||
if audio:
|
||||
audio.set_crowd_level(float(payload.get("mood", 0.0)))
|
||||
# Panic wins: people clear away from the entrance.
|
||||
if panic or event_type == "crowd_panic":
|
||||
_flee_crowd()
|
||||
@@ -583,6 +630,8 @@ func _responder_profile() -> NpcProfile:
|
||||
|
||||
func _on_shift_ended(review: Dictionary) -> void:
|
||||
_shift_running = false
|
||||
if audio:
|
||||
audio.stop_crowd_bed()
|
||||
# Persist the finished shift locally (Save autoload, user:// JSON).
|
||||
if Save:
|
||||
Save.save_shift_result(review)
|
||||
@@ -775,9 +824,14 @@ func _close_top_modal() -> bool:
|
||||
return true
|
||||
if radio_panel and radio_panel.visible:
|
||||
radio_panel.hide()
|
||||
if audio:
|
||||
audio.play_radio_close()
|
||||
hud.set_action_hint("")
|
||||
_refresh_freeze()
|
||||
return true
|
||||
if event_timeline and event_timeline.visible:
|
||||
event_timeline.close()
|
||||
return true
|
||||
return false
|
||||
|
||||
func _open_radial() -> void:
|
||||
@@ -857,6 +911,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:
|
||||
@@ -1095,9 +1151,13 @@ func _open_radio(emergency: bool) -> void:
|
||||
return
|
||||
radio_panel.open(controller, emergency, current_npc_truth)
|
||||
hud.set_action_hint("Fill in the radio report on screen.")
|
||||
if audio:
|
||||
audio.play_radio_open()
|
||||
_refresh_freeze()
|
||||
|
||||
func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
|
||||
if audio:
|
||||
audio.play_radio_close()
|
||||
hud.set_action_hint("")
|
||||
_refresh_freeze()
|
||||
var result: Dictionary = coordinator.radio.request_emergency(report) if emergency else coordinator.radio.request_assistance(report)
|
||||
@@ -1189,6 +1249,16 @@ func _toggle_wand_screening() -> void:
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller == null:
|
||||
return
|
||||
if controller.refuses_screening:
|
||||
# Authored event: this guest refuses the screen (Joke Shop content).
|
||||
hud.set_wand_icon(false)
|
||||
hud.log(Strings.fmt("wand.refused", [controller.profile.display_name if controller.profile else "The guest"]))
|
||||
coordinator.recorder.add_event(controller.npc_id, {
|
||||
"type": "wand_refused",
|
||||
"outcome": "refused",
|
||||
})
|
||||
coordinator.reputation.apply("prop", -1.0, "guest refused screening")
|
||||
return
|
||||
var profile: Object = controller.profile
|
||||
var items: Array = profile.carried_items if profile else []
|
||||
var result: Dictionary = wand_screener.screen(items)
|
||||
|
||||
@@ -89,8 +89,15 @@ var _rig: Node3D = null
|
||||
var _head: Node3D = null
|
||||
var _bob_phase := 0.0
|
||||
var _guard_ref: Node3D = null
|
||||
var _ring: MeshInstance3D = null
|
||||
var _focus_circle: MeshInstance3D = null
|
||||
var _name_tag: Label3D = null
|
||||
## Theater props and limbs for the idle poses (ADR-0010 rig).
|
||||
var _photo_prop: MeshInstance3D = null
|
||||
var _sign_prop: MeshInstance3D = null
|
||||
var _legs: Array[MeshInstance3D] = []
|
||||
var _theater_t := 0.0
|
||||
var _photo_prop_base_y := 0.0
|
||||
var _sign_prop_base_y := 0.0
|
||||
const SKIN_TONE := Color(0.88, 0.72, 0.60)
|
||||
const PANTS_COLOR := Color(0.22, 0.22, 0.26)
|
||||
const HAIR_COLORS := [Color(0.12, 0.1, 0.08), Color(0.35, 0.22, 0.12), Color(0.55, 0.42, 0.28), Color(0.15, 0.15, 0.18)]
|
||||
@@ -111,8 +118,8 @@ func _build_body(npc_type: int) -> void:
|
||||
# Torso
|
||||
_add_capsule(_rig, 0.30, 0.95, Vector3(0, 1.05, 0), base_color)
|
||||
# Legs
|
||||
_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), PANTS_COLOR)
|
||||
_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), PANTS_COLOR)
|
||||
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), PANTS_COLOR))
|
||||
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), PANTS_COLOR))
|
||||
# Arms
|
||||
_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0)
|
||||
_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0)
|
||||
@@ -129,26 +136,29 @@ func _build_body(npc_type: int) -> void:
|
||||
_rig.add_child(_head)
|
||||
_add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(-0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14))
|
||||
_add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14))
|
||||
_build_focus_ring()
|
||||
_build_focus_circle()
|
||||
_add_props(npc_type)
|
||||
|
||||
func _build_focus_ring() -> void:
|
||||
_ring = MeshInstance3D.new()
|
||||
_ring.name = "FocusRing"
|
||||
var torus := TorusMesh.new()
|
||||
torus.inner_radius = 0.26
|
||||
torus.outer_radius = 0.34
|
||||
torus.rings = 12
|
||||
torus.ring_segments = 16
|
||||
_ring.mesh = torus
|
||||
func _build_focus_circle() -> void:
|
||||
_focus_circle = MeshInstance3D.new()
|
||||
_focus_circle.name = "FocusCircle"
|
||||
# Blue, semi-transparent disc floating in front of the face (ADR-0008).
|
||||
# Billboarded so it always reads as a circle on screen.
|
||||
var disc := CylinderMesh.new()
|
||||
disc.top_radius = 0.15
|
||||
disc.bottom_radius = 0.15
|
||||
disc.height = 0.001
|
||||
disc.radial_segments = 24
|
||||
_focus_circle.mesh = disc
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.albedo_color = Color(0.95, 0.82, 0.28, 0.9)
|
||||
mat.albedo_color = Color(0.25, 0.55, 1.0, 0.45)
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
_ring.material_override = mat
|
||||
_ring.position = Vector3(0, 0.28, 0)
|
||||
_ring.visible = false
|
||||
_head.add_child(_ring)
|
||||
mat.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
_focus_circle.material_override = mat
|
||||
_focus_circle.position = Vector3(0, 0, 0.30)
|
||||
_focus_circle.visible = false
|
||||
_head.add_child(_focus_circle)
|
||||
_name_tag = Label3D.new()
|
||||
_name_tag.name = "NameTag"
|
||||
_name_tag.text = npc.profile.display_name if npc and npc.profile else ""
|
||||
@@ -161,16 +171,16 @@ func _build_focus_ring() -> void:
|
||||
_head.add_child(_name_tag)
|
||||
|
||||
func set_focus_visual(focused: bool, menu_open: bool = false) -> void:
|
||||
if _ring:
|
||||
_ring.visible = focused
|
||||
var mat := _ring.material_override as StandardMaterial3D
|
||||
if _focus_circle:
|
||||
_focus_circle.visible = focused
|
||||
var mat := _focus_circle.material_override as StandardMaterial3D
|
||||
if mat:
|
||||
mat.albedo_color = Color(1.0, 0.92, 0.35, 1.0) if menu_open else Color(0.95, 0.82, 0.28, 0.85)
|
||||
_ring.scale = Vector3.ONE * (1.15 if menu_open else 1.0)
|
||||
mat.albedo_color = Color(0.35, 0.65, 1.0, 0.7) if menu_open else Color(0.25, 0.55, 1.0, 0.45)
|
||||
_focus_circle.scale = Vector3.ONE * (1.15 if menu_open else 1.0)
|
||||
if _name_tag:
|
||||
_name_tag.visible = focused
|
||||
|
||||
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> void:
|
||||
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> MeshInstance3D:
|
||||
var mi := MeshInstance3D.new()
|
||||
var mesh := CapsuleMesh.new()
|
||||
mesh.radius = radius
|
||||
@@ -180,6 +190,7 @@ func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, co
|
||||
mi.rotation_degrees.z = z_rot
|
||||
mi.material_override = _material(color)
|
||||
parent.add_child(mi)
|
||||
return mi
|
||||
|
||||
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color) -> void:
|
||||
var mi := MeshInstance3D.new()
|
||||
@@ -201,7 +212,8 @@ func _add_props(npc_type: int) -> void:
|
||||
var chest_y := 1.25
|
||||
match npc_type:
|
||||
Rules.NpcType.INFLUENCER:
|
||||
_add_box_prop(_rig, Vector3(0.16, 0.1, 0.09), Vector3(0.3, chest_y, 0.22), Color(0.1, 0.1, 0.1))
|
||||
_photo_prop = _add_box_prop(_rig, Vector3(0.16, 0.1, 0.09), Vector3(0.3, chest_y, 0.22), Color(0.1, 0.1, 0.1))
|
||||
_photo_prop_base_y = _photo_prop.position.y
|
||||
_add_box_prop(_rig, Vector3(0.05, 0.05, 0.06), Vector3(0.34, chest_y + 0.06, 0.30), Color(0.4, 0.7, 1.0))
|
||||
Rules.NpcType.DELIVERY:
|
||||
_add_box_prop(_rig, Vector3(0.34, 0.26, 0.28), Vector3(0.0, 1.02, 0.3), Color(0.55, 0.4, 0.25))
|
||||
@@ -216,15 +228,22 @@ func _add_props(npc_type: int) -> void:
|
||||
cone.material_override = hat
|
||||
cone.position = Vector3(0, 0.26, 0)
|
||||
_head.add_child(cone)
|
||||
# Hat in one hand and a small board in the other: the performer plays
|
||||
# to the sidewalk once at the waypoint.
|
||||
_add_box_prop(_rig, Vector3(0.06, 0.5, 0.06), Vector3(-0.42, 1.3, 0.14), Color(0.45, 0.35, 0.25))
|
||||
_sign_prop = _add_box_prop(_rig, Vector3(0.42, 0.32, 0.03), Vector3(-0.46, 1.6, 0.14), Color(0.95, 0.9, 0.75))
|
||||
_sign_prop_base_y = _sign_prop.position.y
|
||||
Rules.NpcType.PROTESTER:
|
||||
_add_box_prop(_rig, Vector3(0.05, 1.2, 0.05), Vector3(0.28, 1.45, 0.18), Color(0.4, 0.36, 0.32))
|
||||
_add_box_prop(_rig, Vector3(0.5, 0.42, 0.04), Vector3(0.48, 1.55, 0.18), Color(0.93, 0.93, 0.95))
|
||||
_sign_prop = _add_box_prop(_rig, Vector3(0.5, 0.42, 0.04), Vector3(0.48, 1.55, 0.18), Color(0.93, 0.93, 0.95))
|
||||
_sign_prop_base_y = _sign_prop.position.y
|
||||
Rules.NpcType.TOURIST:
|
||||
_add_box_prop(_rig, Vector3(0.3, 0.36, 0.14), Vector3(-0.34, 1.15, -0.18), Color(0.25, 0.3, 0.45))
|
||||
_photo_prop = _add_box_prop(_rig, Vector3(0.3, 0.36, 0.14), Vector3(-0.34, 1.15, -0.18), Color(0.25, 0.3, 0.45))
|
||||
_photo_prop_base_y = _photo_prop.position.y
|
||||
Rules.NpcType.CONFUSED_VISITOR:
|
||||
_add_box_prop(_rig, Vector3(0.3, 0.05, 0.34), Vector3(0, 0.92, 0.06), Color(0.9, 0.88, 0.8))
|
||||
|
||||
func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) -> void:
|
||||
func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) -> MeshInstance3D:
|
||||
var mi := MeshInstance3D.new()
|
||||
var mesh := BoxMesh.new()
|
||||
mesh.size = size
|
||||
@@ -232,6 +251,7 @@ func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) ->
|
||||
mi.position = pos
|
||||
mi.material_override = _material(color)
|
||||
parent.add_child(mi)
|
||||
return mi
|
||||
|
||||
## Rubberneck: idle people stop and turn toward the incident. Returns false when
|
||||
## this NPC is too engaged (walking, being talked to, already leaving) to watch.
|
||||
@@ -309,22 +329,75 @@ func _physics_process(delta: float) -> void:
|
||||
_animate(delta)
|
||||
|
||||
## Procedural walk bob + head-toward-guard cue when the NPC is aware.
|
||||
## Theater pose for the current intent once the NPC has stopped walking.
|
||||
## "sit" at the steps, "photo" for camera props, "perform" for the hat/sign,
|
||||
## "film" for a slow pan, otherwise "idle".
|
||||
func theater_pose() -> String:
|
||||
if not arrived:
|
||||
return "idle"
|
||||
match npc.intent:
|
||||
"sit":
|
||||
return "sit"
|
||||
"photo":
|
||||
return "photo"
|
||||
"film":
|
||||
return "film"
|
||||
"perform":
|
||||
return "perform"
|
||||
_:
|
||||
return "idle"
|
||||
|
||||
## How far the rig drops for a seated pose. Pure so it is headless-testable.
|
||||
static func crouch_for_pose(pose: String) -> float:
|
||||
return -0.34 if pose == "sit" else 0.0
|
||||
|
||||
## Vertical lift of a held camera prop. `phase` is seconds; the raise is brief so
|
||||
## it reads as taking a photo rather than a permanently raised arm.
|
||||
static func prop_lift_for_pose(pose: String, phase: float) -> float:
|
||||
if pose != "photo" and pose != "film":
|
||||
return 0.0
|
||||
return maxf(0.0, sin(phase * 1.7)) * 0.16
|
||||
|
||||
## Sway amount for a performer playing to the sidewalk.
|
||||
static func sway_for_pose(pose: String, phase: float) -> float:
|
||||
if pose != "perform":
|
||||
return 0.0
|
||||
return sin(phase * 2.2) * 0.09
|
||||
|
||||
func _animate(delta: float) -> void:
|
||||
if _rig == null:
|
||||
return
|
||||
var speed := Vector2(velocity.x, velocity.z).length()
|
||||
var pose := theater_pose()
|
||||
var crouch := crouch_for_pose(pose)
|
||||
if speed > 0.3:
|
||||
_bob_phase += delta * (6.0 + speed)
|
||||
_rig.position.y = sin(_bob_phase) * 0.035
|
||||
_rig.rotation.x = sin(_bob_phase * 0.5) * 0.04
|
||||
elif pose != "idle" or not is_zero_approx(_rig.position.y):
|
||||
# Idle theater: seated, photographing, filming, or performing.
|
||||
_theater_t += delta
|
||||
_bob_phase = 0.0
|
||||
_rig.position.y = lerpf(_rig.position.y, crouch, 0.12)
|
||||
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
|
||||
var lift := prop_lift_for_pose(pose, _theater_t)
|
||||
if _photo_prop:
|
||||
_photo_prop.position.y = _photo_prop_base_y + lift
|
||||
_photo_prop.rotation.x = -lift * 1.4
|
||||
var sway := sway_for_pose(pose, _theater_t)
|
||||
if _sign_prop:
|
||||
_sign_prop.rotation.z = sway
|
||||
_sign_prop.position.y = _sign_prop_base_y + absf(sway) * 0.25
|
||||
if _legs.size() == 2:
|
||||
# Negative pitch swings the legs forward (toward the way the body faces).
|
||||
var leg_angle := deg_to_rad(-78.0) if pose == "sit" else 0.0
|
||||
_legs[0].rotation.x = lerpf(_legs[0].rotation.x, leg_angle, 0.12)
|
||||
_legs[1].rotation.x = lerpf(_legs[1].rotation.x, leg_angle, 0.12)
|
||||
else:
|
||||
_bob_phase = 0.0
|
||||
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)
|
||||
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
|
||||
if _ring and _ring.visible:
|
||||
var cam := get_viewport().get_camera_3d() if get_viewport() else null
|
||||
if cam:
|
||||
_ring.look_at(cam.global_position, Vector3.UP)
|
||||
# The focus circle billboards via its material; no per-frame look_at needed.
|
||||
# Attention cue: head turns toward the guard when awareness was raised.
|
||||
if _head != null and npc.guard_noticed:
|
||||
if _guard_ref == null or not is_instance_valid(_guard_ref):
|
||||
|
||||
@@ -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()
|
||||
@@ -49,6 +49,8 @@ var _debug_enabled := false
|
||||
var _log_lines: Array[String] = []
|
||||
var _cam_overlay: Control = null
|
||||
var _body_camera_on := false
|
||||
var _font_scale := 1.0
|
||||
var _base_font_sizes: Dictionary = {}
|
||||
|
||||
func _init() -> void:
|
||||
name = "HUD"
|
||||
@@ -300,16 +302,20 @@ func is_body_camera_on() -> bool:
|
||||
return _body_camera_on
|
||||
|
||||
## Scale the whole HUD text block (accessibility settings).
|
||||
## Resize HUD text by changing font sizes (not Control.scale, which overflows
|
||||
## anchored labels and breaks the right-aligned layout).
|
||||
func set_font_scale(scale: float) -> void:
|
||||
var clamped := clampf(scale, 0.75, 1.75)
|
||||
if top_label:
|
||||
top_label.scale = Vector2(clamped, clamped)
|
||||
if log_label:
|
||||
log_label.scale = Vector2(clamped, clamped)
|
||||
if target_label:
|
||||
target_label.scale = Vector2(clamped, clamped)
|
||||
if hint_label:
|
||||
hint_label.scale = Vector2(clamped, clamped)
|
||||
_font_scale = clamped
|
||||
var scaled: Array[Label] = [top_label, log_label, target_label, hint_label,
|
||||
time_label, mode_label, escalation_label, radio_cooldown_label, debug_label]
|
||||
for label in scaled:
|
||||
if label == null:
|
||||
continue
|
||||
if not _base_font_sizes.has(label):
|
||||
_base_font_sizes[label] = label.get_theme_font_size("font_size")
|
||||
var base: int = int(_base_font_sizes[label])
|
||||
label.add_theme_font_size_override("font_size", maxi(8, int(round(float(base) * clamped))))
|
||||
|
||||
func _make_icon(texture: Texture2D) -> TextureRect:
|
||||
var tr := TextureRect.new()
|
||||
@@ -353,9 +359,18 @@ func set_action_hint(text: String) -> void:
|
||||
|
||||
## Color-coded escalation feedback (PLAN.md: Polish and Accessibility).
|
||||
## Green → yellow → orange → red as the escalation level rises.
|
||||
## Grayscale-safe escalation meter: filled/empty blocks plus the level number, so
|
||||
## the cue does not rely on hue alone (accessibility).
|
||||
static func escalation_meter(level: int) -> String:
|
||||
var clamped := clampi(level, 0, 5)
|
||||
var filled := ""
|
||||
for i in 5:
|
||||
filled += "#" if i < clamped else "-"
|
||||
return "[%s] L%d/5" % [filled, clamped]
|
||||
|
||||
func set_escalation(level: int) -> void:
|
||||
var name: String = Rules.LEVEL_NAMES.get(level, "Observation")
|
||||
escalation_label.text = Strings.fmt("hud.escalation_prefix", [name])
|
||||
escalation_label.text = "%s %s" % [Strings.fmt("hud.escalation_prefix", [name]), escalation_meter(level)]
|
||||
var tint := Color(0.6, 0.9, 0.6)
|
||||
match level:
|
||||
Rules.Level.OBSERVATION, Rules.Level.SIMPLE_DETERRENCE:
|
||||
|
||||
@@ -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", {})
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
extends SceneTree
|
||||
## Audio smoke: starts a shift, verifies the crowd bed is playing and follows mood
|
||||
## events, and that opening/closing the radio triggers crackle cues.
|
||||
##
|
||||
## Usage:
|
||||
## godot --headless --path . --script res://src/test/smoke_audio.gd
|
||||
## (Headless uses a dummy audio server; the calls must still be safe.)
|
||||
|
||||
const MAX_FRAMES := 900
|
||||
|
||||
var frame := 0
|
||||
var gm: Node = null
|
||||
var started := false
|
||||
var step := 0
|
||||
var failures: Array[String] = []
|
||||
|
||||
func _initialize() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
root.add_child(load("res://scenes/main.tscn").instantiate())
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
frame += 1
|
||||
if gm == null:
|
||||
gm = root.find_child("GameManager", true, false)
|
||||
return false
|
||||
if not started:
|
||||
gm.call("_on_venue_selected", "hollywood_building")
|
||||
started = true
|
||||
return false
|
||||
if frame > MAX_FRAMES:
|
||||
_fail("timeout")
|
||||
return true
|
||||
if frame < 120:
|
||||
return false
|
||||
match step:
|
||||
0:
|
||||
_check(gm.audio.bed_playing, "the crowd bed is playing during the shift")
|
||||
var before: float = gm.audio.crowd_level
|
||||
gm.coordinator.crowd.apply_event("aggressive_behavior")
|
||||
_check(gm.audio.crowd_level > before, "an aggressive event raises the bed level")
|
||||
gm.coordinator.crowd.apply_event("emergency_responder")
|
||||
_check(gm.audio.crowd_level > before, "the responder keeps it up")
|
||||
step = 1
|
||||
1:
|
||||
if gm.npcs.is_empty():
|
||||
return false
|
||||
gm.call("_on_guard_target", gm.npcs[0])
|
||||
var crackles: int = gm.audio.radio_crackles
|
||||
gm.call("_open_radio", false)
|
||||
_check(gm.audio.radio_crackles == crackles + 1, "opening the radio crackles once")
|
||||
gm.call("_on_radio_submitted", {
|
||||
"npc_id": gm.current_incident_npc_id,
|
||||
"location": "entrance",
|
||||
"description": "tourist",
|
||||
"behavior": "standing",
|
||||
"weapons_seen": false,
|
||||
"danger_level": 1,
|
||||
}, false)
|
||||
_check(gm.audio.radio_crackles == crackles + 2, "closing the radio crackles once")
|
||||
gm.coordinator.call("_finish_shift")
|
||||
_check(not gm.audio.bed_playing, "the bed stops when the shift ends")
|
||||
step = 2
|
||||
2:
|
||||
_finish()
|
||||
return true
|
||||
return false
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
if condition:
|
||||
print("ok: " + message)
|
||||
else:
|
||||
_fail(message)
|
||||
|
||||
func _fail(message: String) -> void:
|
||||
failures.append(message)
|
||||
_finish()
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("SMOKE OK: crowd bed and radio crackle")
|
||||
quit(0)
|
||||
else:
|
||||
for message in failures:
|
||||
printerr("SMOKE FAIL: " + message)
|
||||
quit(1)
|
||||
@@ -1,5 +1,5 @@
|
||||
extends SceneTree
|
||||
## Windowed smoke helper for the ADR-0007–0010 control pass (focus ring + action
|
||||
## Windowed smoke helper for the ADR-0007–0010 control pass (focus circle + action
|
||||
## wheel). Boots the real main scene, stages one NPC in front of the guard, opens
|
||||
## the wheel, and saves two PNGs to user://screenshots/. Exits 1 if the focus or
|
||||
## radial state did not come up, so it can be used as a smoke check.
|
||||
@@ -76,7 +76,7 @@ func _advance() -> void:
|
||||
(gm.guard.global_position as Vector3).y,
|
||||
(gm.targeted_npc.global_position as Vector3).y,
|
||||
])
|
||||
_shoot("smoke-01-focus-ring.png")
|
||||
_shoot("smoke-01-focus-circle.png")
|
||||
gm.call("_open_radial")
|
||||
state = 2
|
||||
state_frames = 0
|
||||
@@ -127,7 +127,7 @@ func _fail(message: String) -> void:
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("SMOKE OK: focus ring + action wheel opened and cancelled")
|
||||
print("SMOKE OK: focus circle + action wheel opened and cancelled")
|
||||
quit(0)
|
||||
else:
|
||||
for message in failures:
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
extends SceneTree
|
||||
## Scenario-content smoke: forces the authored Joke Shop refusal event and the
|
||||
## Studio celebrity arrival through the real spawn path, then checks the extra
|
||||
## bystanders, the hint log, and that a screening refusal is handled.
|
||||
##
|
||||
## Usage:
|
||||
## godot --headless --path . --script res://src/test/smoke_scenario_content.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", "joke_shop")
|
||||
started = true
|
||||
return false
|
||||
if frame > MAX_FRAMES:
|
||||
_fail("timeout")
|
||||
return true
|
||||
if frame < 120:
|
||||
return false
|
||||
match step:
|
||||
0:
|
||||
# Force the refusal event through the normal spawn signal.
|
||||
var event := _find_event(gm.selected_scenario.event_pool, "joke_refuses_wand")
|
||||
_check(not event.is_empty(), "the refusal event exists in the Joke Shop pool")
|
||||
var guest: Object = gm.selected_scenario.profile_for(int(event.get("npc_type", 0)))
|
||||
gm.coordinator.npc_spawn_requested.emit(guest, "enter", "smoke_refuser", event)
|
||||
_check(gm.npcs.size() > 0, "the refusing guest spawned")
|
||||
gm.call("_on_guard_target", gm.npcs[gm.npcs.size() - 1])
|
||||
var controller: Object = gm.npcs[gm.npcs.size() - 1].get_npc_controller()
|
||||
_check(controller.refuses_screening, "the refusal flag reached the controller")
|
||||
gm.call("_toggle_wand_screening")
|
||||
_check(not gm.wand_open, "screening does not open for someone who refuses")
|
||||
step = 1
|
||||
1:
|
||||
# Studio: celebrity arrival should bring extra bystanders.
|
||||
gm.call("_on_venue_selected", "tv_studio")
|
||||
step = 2
|
||||
2:
|
||||
if frame % 20 != 0:
|
||||
return false
|
||||
var event := _find_event(gm.selected_scenario.event_pool, "studio_celebrity_arrival")
|
||||
var before: int = gm.npcs.size()
|
||||
var guest: Object = gm.selected_scenario.profile_for(int(event.get("npc_type", 0)))
|
||||
gm.coordinator.npc_spawn_requested.emit(guest, "arrive", "smoke_celeb", event)
|
||||
var added: int = gm.npcs.size() - before
|
||||
_check(added >= 4, "celebrity arrival spawns the guest plus bystanders (%d)" % added)
|
||||
step = 3
|
||||
3:
|
||||
_finish()
|
||||
return true
|
||||
return false
|
||||
|
||||
func _find_event(pool: Array, id: String) -> Dictionary:
|
||||
for event in pool:
|
||||
if str((event as Dictionary).get("id", "")) == id:
|
||||
return event
|
||||
return {}
|
||||
|
||||
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: authored scenario content")
|
||||
quit(0)
|
||||
else:
|
||||
for message in failures:
|
||||
printerr("SMOKE FAIL: " + message)
|
||||
quit(1)
|
||||
@@ -0,0 +1,83 @@
|
||||
extends SceneTree
|
||||
## Body-camera timeline smoke: records a few real interactions, opens the review's
|
||||
## timeline, steps through every event, and closes back to the review.
|
||||
##
|
||||
## Usage:
|
||||
## godot --headless --path . --script res://src/test/smoke_timeline.gd
|
||||
|
||||
const MAX_FRAMES := 900
|
||||
|
||||
var frame := 0
|
||||
var gm: Node = null
|
||||
var started := false
|
||||
var step := 0
|
||||
var failures: Array[String] = []
|
||||
|
||||
func _initialize() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
root.add_child(load("res://scenes/main.tscn").instantiate())
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
frame += 1
|
||||
if gm == null:
|
||||
gm = root.find_child("GameManager", true, false)
|
||||
return false
|
||||
if not started:
|
||||
gm.call("_on_venue_selected", "hollywood_building")
|
||||
started = true
|
||||
return false
|
||||
if frame > MAX_FRAMES:
|
||||
_fail("timeout")
|
||||
return true
|
||||
if frame < 200:
|
||||
return false
|
||||
match step:
|
||||
0:
|
||||
if gm.npcs.is_empty():
|
||||
return false
|
||||
gm.call("_on_guard_target", gm.npcs[0])
|
||||
gm.call("_apply_interaction", 1, "eye contact")
|
||||
gm.call("_request_reason")
|
||||
gm.call("_apply_interaction", 2, "formal warning")
|
||||
var review: Dictionary = gm.coordinator.build_review()
|
||||
# Real flow: the review screen shows first, then its timeline button.
|
||||
gm.shift_review_panel.show_review(review, gm.selected_scenario, [])
|
||||
var button: Button = gm.shift_review_panel.find_child("TimelineButton", true, false) as Button
|
||||
_check(button != null, "review offers a body-camera timeline button")
|
||||
if button:
|
||||
button.pressed.emit()
|
||||
var count: int = gm.event_timeline.events.size()
|
||||
_check(count >= 3, "timeline collected the interaction events (%d)" % count)
|
||||
_check(gm.event_timeline.visible, "timeline is showing")
|
||||
_check(not gm.shift_review_panel.visible, "review is hidden behind the timeline")
|
||||
_check(gm.guard.input_locked, "the guard is frozen while the timeline is open")
|
||||
for i in count + 2:
|
||||
gm.event_timeline.step(1)
|
||||
_check(gm.event_timeline.index == count - 1, "stepping clamps at the last event")
|
||||
gm.event_timeline.close()
|
||||
step = 1
|
||||
1:
|
||||
_check(not gm.event_timeline.visible, "timeline closes")
|
||||
_check(gm.shift_review_panel.visible, "closing returns to the review")
|
||||
_finish()
|
||||
return true
|
||||
return false
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
if condition:
|
||||
print("ok: " + message)
|
||||
else:
|
||||
_fail(message)
|
||||
|
||||
func _fail(message: String) -> void:
|
||||
failures.append(message)
|
||||
_finish()
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("SMOKE OK: body-camera timeline")
|
||||
quit(0)
|
||||
else:
|
||||
for message in failures:
|
||||
printerr("SMOKE FAIL: " + message)
|
||||
quit(1)
|
||||
@@ -0,0 +1,64 @@
|
||||
extends RefCounted
|
||||
## Accessibility: escalation cues that survive grayscale (shape + level, not hue
|
||||
## alone) and the HUD font scale from settings.
|
||||
|
||||
const Hud := preload("res://src/game/ui/hud.gd")
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
|
||||
func test_meter_shape_and_level() -> bool:
|
||||
var a := TestUtils.check_eq(Hud.escalation_meter(0), "[-----] L0/5", "calm shows an empty meter")
|
||||
var b := TestUtils.check_eq(Hud.escalation_meter(3), "[###--] L3/5", "three filled blocks at blocking")
|
||||
var c := TestUtils.check_eq(Hud.escalation_meter(5), "[#####] L5/5", "emergency fills the meter")
|
||||
var d := TestUtils.check_eq(Hud.escalation_meter(99), "[#####] L5/5", "levels clamp at 5")
|
||||
var e := TestUtils.check_eq(Hud.escalation_meter(-2), "[-----] L0/5", "levels clamp at 0")
|
||||
return a and b and c and d and e
|
||||
|
||||
func test_meter_is_not_hue_only() -> bool:
|
||||
## Every level must differ in shape/text, independent of colour.
|
||||
var seen: Array[String] = []
|
||||
for level in 6:
|
||||
var meter: String = Hud.escalation_meter(level)
|
||||
if seen.has(meter):
|
||||
return TestUtils.fail("levels %d and %d share a meter" % [level, seen.find(meter)])
|
||||
seen.append(meter)
|
||||
return TestUtils.check(seen.size() == 6, "all six levels have distinct meters")
|
||||
|
||||
func test_escalation_label_includes_meter_and_name() -> bool:
|
||||
var hud: Node = Hud.new()
|
||||
hud.set_escalation(Rules.Level.FORMAL_WARNING)
|
||||
var text: String = hud.escalation_label.text
|
||||
var a := TestUtils.check(text.contains("Formal Warning"), "label names the level")
|
||||
var b := TestUtils.check(text.contains("[##---]"), "label shows the shape meter")
|
||||
var c := TestUtils.check(text.contains("L2/5"), "label shows the numeric level")
|
||||
hud.free()
|
||||
return a and b and c
|
||||
|
||||
func test_font_scale_applies_to_core_labels() -> bool:
|
||||
var hud: Node = Hud.new()
|
||||
var base_top: int = hud.top_label.get_theme_font_size("font_size")
|
||||
var base_log: int = hud.log_label.get_theme_font_size("font_size")
|
||||
var base_esc: int = hud.escalation_label.get_theme_font_size("font_size")
|
||||
var base_mode: int = hud.mode_label.get_theme_font_size("font_size")
|
||||
hud.set_font_scale(1.4)
|
||||
var a := TestUtils.check(hud.top_label.get_theme_font_size("font_size") > base_top, "top label grows")
|
||||
var b := TestUtils.check(hud.log_label.get_theme_font_size("font_size") > base_log, "log grows")
|
||||
var c := TestUtils.check(hud.escalation_label.get_theme_font_size("font_size") > base_esc, "escalation label grows")
|
||||
var d := TestUtils.check(hud.mode_label.get_theme_font_size("font_size") > base_mode, "mode label grows")
|
||||
# Scaling is by font size, not Control.scale, so anchored labels keep layout.
|
||||
var e := TestUtils.check(is_equal_approx(hud.top_label.scale.x, 1.0), "labels are not Control-scaled")
|
||||
hud.set_font_scale(9.0)
|
||||
var capped: int = hud.top_label.get_theme_font_size("font_size")
|
||||
var f := TestUtils.check(capped <= int(round(float(base_top) * 1.75)) + 1, "scale clamps at 1.75")
|
||||
hud.free()
|
||||
return a and b and c and d and e and f
|
||||
|
||||
func test_settings_font_scale_is_applied_after_ui_exists() -> bool:
|
||||
## The HUD is created after settings load, so the manager must re-apply.
|
||||
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
||||
var a := TestUtils.check(src.contains("_apply_settings(player_settings)"), "settings are applied on boot")
|
||||
var build_idx := src.find("_build_ui()")
|
||||
var apply_idx := src.find("_apply_settings(player_settings)", build_idx)
|
||||
var b := TestUtils.check(apply_idx > build_idx, "the second apply happens after the HUD is built")
|
||||
var c := TestUtils.check(src.contains("hud.set_font_scale(float(settings.get("),
|
||||
"settings drive the HUD font scale")
|
||||
return a and b and c
|
||||
@@ -0,0 +1,78 @@
|
||||
extends RefCounted
|
||||
## Audio feedback: a looping crowd bed that follows the crowd mood, and radio
|
||||
## crackle on open/close. All procedural, no audio assets, headless-safe.
|
||||
|
||||
const AudioFeedback := preload("res://src/game/audio_feedback.gd")
|
||||
const CrowdSystem := preload("res://src/core/crowd_system.gd")
|
||||
|
||||
func test_bed_volume_follows_mood() -> bool:
|
||||
var quiet: float = AudioFeedback._level_to_db(0.0)
|
||||
var loud: float = AudioFeedback._level_to_db(1.0)
|
||||
var mid: float = AudioFeedback._level_to_db(0.5)
|
||||
var a := TestUtils.check(quiet < -30.0, "a calm street keeps the bed quiet")
|
||||
var b := TestUtils.check(loud > quiet, "panic raises the bed")
|
||||
var c := TestUtils.check(mid > quiet and mid < loud, "the rise is gradual")
|
||||
return a and b and c
|
||||
|
||||
func test_crowd_level_is_clamped() -> bool:
|
||||
var audio: Node = AudioFeedback.new()
|
||||
audio.set_crowd_level(-1.0)
|
||||
var a := TestUtils.check_eq(audio.crowd_level, 0.0, "negative mood clamps to 0")
|
||||
audio.set_crowd_level(3.0)
|
||||
var b := TestUtils.check_eq(audio.crowd_level, 1.0, "over-unity mood clamps to 1")
|
||||
audio.free()
|
||||
return a and b
|
||||
|
||||
func test_crowd_stream_loops() -> bool:
|
||||
var audio: Node = AudioFeedback.new()
|
||||
var stream: AudioStreamWAV = audio._build_crowd_stream()
|
||||
var a := TestUtils.check(stream != null, "a crowd stream is generated")
|
||||
var b := TestUtils.check_eq(stream.loop_mode, AudioStreamWAV.LOOP_FORWARD, "the bed loops")
|
||||
var c := TestUtils.check(stream.data.size() > 1000, "the bed has audio data")
|
||||
var d := TestUtils.check(stream.data.size() == stream.loop_end * 2, "loop covers the whole buffer")
|
||||
audio.free()
|
||||
return a and b and c and d
|
||||
|
||||
func test_crowd_bed_is_headless_safe() -> bool:
|
||||
# No tree, no audio server: none of these may crash.
|
||||
var audio: Node = AudioFeedback.new()
|
||||
audio.start_crowd_bed()
|
||||
var a := TestUtils.check(audio.bed_playing, "start marks the bed as playing")
|
||||
audio.set_crowd_level(0.78)
|
||||
var b := TestUtils.check(is_equal_approx(audio.crowd_level, 0.78), "level is stored")
|
||||
audio.stop_crowd_bed()
|
||||
var c := TestUtils.check(not audio.bed_playing, "stop clears the playing flag")
|
||||
audio.free()
|
||||
return a and b and c
|
||||
|
||||
func test_radio_crackle_methods() -> bool:
|
||||
var audio: Node = AudioFeedback.new()
|
||||
audio.play_radio_open()
|
||||
audio.play_radio_close()
|
||||
var a := TestUtils.check_eq(audio.radio_crackles, 2, "open and close each count a crackle")
|
||||
var b := TestUtils.check(audio._free_player() == null, "no players exist before _ready, and that is safe")
|
||||
audio.free()
|
||||
return a and b
|
||||
|
||||
func test_game_manager_wires_audio() -> bool:
|
||||
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
||||
var a := TestUtils.check(src.contains("audio.start_crowd_bed()"), "the bed starts with the shift")
|
||||
var b := TestUtils.check(src.contains("audio.set_crowd_level(float(payload.get("),
|
||||
"crowd events drive the bed")
|
||||
var c := TestUtils.check(src.contains("audio.stop_crowd_bed()"), "the bed stops at shift end")
|
||||
var d := TestUtils.check(src.contains("audio.play_radio_open()"), "opening the radio crackles")
|
||||
var e := TestUtils.check(src.contains("audio.play_radio_close()"), "closing the radio crackles")
|
||||
return a and b and c and d and e
|
||||
|
||||
func test_mood_events_reach_the_bed() -> bool:
|
||||
## The bed level is fed from the same payload the crowd reactions use.
|
||||
var crowd := CrowdSystem.new()
|
||||
var seen: Array = []
|
||||
crowd.changed.connect(func(_p): pass)
|
||||
crowd.apply_event("aggressive_behavior")
|
||||
seen.append(crowd.mood)
|
||||
crowd.apply_event("emergency_responder")
|
||||
seen.append(crowd.mood)
|
||||
var a := TestUtils.check(seen[1] > seen[0], "mood rises with a responder arrival")
|
||||
crowd = null
|
||||
return a
|
||||
@@ -0,0 +1,120 @@
|
||||
extends RefCounted
|
||||
## Body-camera timeline: the review screen steps through recorded incident events
|
||||
## (ADR-0005). No live 3D replay in this version.
|
||||
|
||||
const EventTimeline := preload("res://src/game/ui/event_timeline.gd")
|
||||
const Strings := preload("res://src/core/strings.gd")
|
||||
const Coordinator := preload("res://src/core/shift_coordinator.gd")
|
||||
const Factory := preload("res://src/content/scenario_factory.gd")
|
||||
|
||||
func _review() -> Dictionary:
|
||||
return {
|
||||
"started_at_ms": 1000,
|
||||
"incidents": {
|
||||
"open_count": 0,
|
||||
"incidents": [
|
||||
{
|
||||
"id": "inc_a",
|
||||
"npc_id": "npc_a",
|
||||
"outcome": "peaceful",
|
||||
"events": [
|
||||
{"type": "player_action", "action": "eye contact", "level": 1, "at_ms": 3000},
|
||||
{"type": "body_camera", "on": true, "reason": "formal warning", "at_ms": 8000},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "inc_b",
|
||||
"npc_id": "npc_b",
|
||||
"outcome": "left",
|
||||
"events": [
|
||||
{"type": "reason_requested", "reply": "I have a ticket.", "at_ms": 5000},
|
||||
{"type": "access_check", "evidence": {"decided": true, "verified_granted": true}, "at_ms": 9000},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"reputation": {"rating": 70.0, "grade": "C", "modifiers": []},
|
||||
"crowd": {"mood": 0.1, "panic": false},
|
||||
"radio": {"battery": 100.0, "emergency_cooldown_s": 0.0},
|
||||
}
|
||||
|
||||
func test_flatten_orders_events_across_incidents() -> bool:
|
||||
var timeline: Node = EventTimeline.new()
|
||||
var flat: Array = timeline.flatten(_review())
|
||||
var a := TestUtils.check_eq(flat.size(), 4, "every recorded event is flattened")
|
||||
var stamps: Array[int] = []
|
||||
for row in flat:
|
||||
stamps.append(int(row.get("at_ms", 0)))
|
||||
var sorted := stamps.duplicate()
|
||||
sorted.sort()
|
||||
var b := TestUtils.check_eq(stamps, sorted, "events are time ordered")
|
||||
var c := TestUtils.check_eq(flat[0].get("npc_id", ""), "npc_a", "event keeps its NPC id")
|
||||
var d := TestUtils.check_eq(flat[1].get("npc_id", ""), "npc_b", "second incident interleaves")
|
||||
var e := TestUtils.check_eq(flat[0].get("incident_id", ""), "inc_a", "event keeps its incident id")
|
||||
timeline.free()
|
||||
return a and b and c and d and e
|
||||
|
||||
func test_describe_covers_event_types() -> bool:
|
||||
var timeline: Node = EventTimeline.new()
|
||||
var a := TestUtils.check(timeline.describe({"type": "player_action", "action": "eye contact", "level": 1, "at_ms": 3000}).contains("eye contact (L1)"), "player action line")
|
||||
var b := TestUtils.check(timeline.describe({"type": "body_camera", "on": true, "reason": "formal warning"}).contains("body camera on"), "body camera line")
|
||||
var c := TestUtils.check(timeline.describe({"type": "reason_requested", "reply": "I have a ticket."}).contains("I have a ticket."), "reason reply line")
|
||||
var d := TestUtils.check(timeline.describe({"type": "access_check", "evidence": {"decided": false, "verified_granted": true}}).contains("deny"), "access decision line")
|
||||
var e := TestUtils.check(timeline.describe({"type": "responder_action", "call_type": "emergency", "outcome": "evacuation"}).contains("evacuation"), "responder line")
|
||||
var f := TestUtils.check(timeline.describe({"type": "investigation_opened", "reason": "unjustified"}).contains("investigation"), "investigation line")
|
||||
timeline.free()
|
||||
return a and b and c and d and e and f
|
||||
|
||||
func test_empty_timeline_is_safe() -> bool:
|
||||
var timeline: Node = EventTimeline.new()
|
||||
var flat: Array = timeline.flatten({})
|
||||
var a := TestUtils.check_eq(flat.size(), 0, "no incidents means no events")
|
||||
timeline.show_timeline({})
|
||||
var b := TestUtils.check_eq(timeline.index, 0, "index stays at zero when empty")
|
||||
timeline.step(1)
|
||||
var c := TestUtils.check_eq(timeline.index, 0, "stepping an empty timeline does nothing")
|
||||
timeline.free()
|
||||
return a and b and c
|
||||
|
||||
func test_stepping_clamps() -> bool:
|
||||
var timeline: Node = EventTimeline.new()
|
||||
timeline.show_timeline(_review())
|
||||
timeline.step(-1)
|
||||
var a := TestUtils.check_eq(timeline.index, 0, "cannot step before the first event")
|
||||
timeline.step(1)
|
||||
var b := TestUtils.check_eq(timeline.index, 1, "next advances one event")
|
||||
timeline.step(99)
|
||||
var c := TestUtils.check_eq(timeline.index, 3, "cannot step past the last event")
|
||||
timeline.free()
|
||||
return a and b and c
|
||||
|
||||
func test_review_records_a_shift_start_stamp() -> bool:
|
||||
var coordinator: Object = Coordinator.new()
|
||||
coordinator.setup(Factory.hollywood_building(), 30.0, 7)
|
||||
coordinator.start()
|
||||
var review: Dictionary = coordinator.build_review()
|
||||
var a := TestUtils.check(int(review.get("started_at_ms", 0)) > 0, "review carries the shift start stamp")
|
||||
coordinator = null
|
||||
return a
|
||||
|
||||
func test_timeline_strings_exist() -> bool:
|
||||
var ok := true
|
||||
for key in ["timeline.title", "timeline.position", "timeline.empty", "timeline.prev",
|
||||
"timeline.next", "timeline.close", "timeline.hint", "review.timeline_button"]:
|
||||
if Strings.lookup(key) == key:
|
||||
ok = false
|
||||
return TestUtils.check(ok, "timeline UI strings are localized")
|
||||
|
||||
func test_review_button_opens_the_timeline() -> bool:
|
||||
var Review := load("res://src/game/ui/shift_review.gd")
|
||||
var panel: Node = Review.new()
|
||||
var got := {"review": {}}
|
||||
panel.timeline_requested.connect(func(review): got["review"] = review)
|
||||
panel.show_review(_review(), Factory.hollywood_building(), [])
|
||||
var button: Button = panel.find_child("TimelineButton", true, false) as Button
|
||||
var a := TestUtils.check(button != null, "review screen has a timeline button")
|
||||
if button:
|
||||
button.pressed.emit()
|
||||
var b := TestUtils.check(not (got["review"] as Dictionary).is_empty(), "pressing it hands the review to the timeline")
|
||||
panel.free()
|
||||
return a and b
|
||||
@@ -0,0 +1,98 @@
|
||||
extends RefCounted
|
||||
## NPC theater: seated guests, camera raises, and performers playing to the
|
||||
## sidewalk once their intent's waypoint is reached (PLAN.md: NPC Behavior Types).
|
||||
|
||||
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
|
||||
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
|
||||
func _avatar(intent: String, npc_type: int = Rules.NpcType.TOURIST) -> Node:
|
||||
var p := NpcProfile.new()
|
||||
p.id = "theater"
|
||||
p.display_name = "Theater"
|
||||
p.npc_type = npc_type
|
||||
var avatar: Node = NpcAvatar.new()
|
||||
avatar.setup(p, intent, 1234, Vector3(2, 0.5, 8), Vector3(0, 0.5, 6))
|
||||
return avatar
|
||||
|
||||
func test_pose_per_intent() -> bool:
|
||||
var cases := {
|
||||
"sit": "sit",
|
||||
"photo": "photo",
|
||||
"film": "film",
|
||||
"perform": "perform",
|
||||
"enter": "idle",
|
||||
}
|
||||
var ok := true
|
||||
for intent in cases.keys():
|
||||
var avatar := _avatar(str(intent))
|
||||
avatar.arrived = true
|
||||
if avatar.theater_pose() != cases[intent]:
|
||||
ok = false
|
||||
avatar.free()
|
||||
return TestUtils.check(ok, "each intent maps to its theater pose once arrived")
|
||||
|
||||
func test_walking_npc_has_no_pose() -> bool:
|
||||
var avatar := _avatar("sit")
|
||||
var a := TestUtils.check_eq(avatar.theater_pose(), "idle", "a walking NPC does not sit yet")
|
||||
avatar.arrived = true
|
||||
var b := TestUtils.check_eq(avatar.theater_pose(), "sit", "arriving starts the pose")
|
||||
avatar.free()
|
||||
return a and b
|
||||
|
||||
func test_only_sit_crouches() -> bool:
|
||||
var a := TestUtils.check(is_equal_approx(NpcAvatar.crouch_for_pose("sit"), -0.34), "sit lowers the rig")
|
||||
var b := TestUtils.check_eq(NpcAvatar.crouch_for_pose("photo"), 0.0, "photo keeps standing")
|
||||
var c := TestUtils.check_eq(NpcAvatar.crouch_for_pose("perform"), 0.0, "perform keeps standing")
|
||||
var d := TestUtils.check_eq(NpcAvatar.crouch_for_pose("idle"), 0.0, "idle keeps standing")
|
||||
return a and b and c and d
|
||||
|
||||
func test_photo_lift_is_brief() -> bool:
|
||||
var raised := 0.0
|
||||
var flat := 0.0
|
||||
for i in 120:
|
||||
var phase := float(i) / 60.0
|
||||
var lift: float = NpcAvatar.prop_lift_for_pose("photo", phase)
|
||||
raised = maxf(raised, lift)
|
||||
if i == 0:
|
||||
flat = lift
|
||||
var a := TestUtils.check(raised > 0.1, "the camera prop lifts while photographing")
|
||||
var b := TestUtils.check(raised <= 0.16 + 0.001, "the lift stays small")
|
||||
var c := TestUtils.check_eq(flat, 0.0, "the lift starts at rest")
|
||||
var d := TestUtils.check_eq(NpcAvatar.prop_lift_for_pose("sit", 0.5), 0.0, "seated guests do not lift a prop")
|
||||
return a and b and c and d
|
||||
|
||||
func test_performer_sways_only_when_performing() -> bool:
|
||||
var peak := 0.0
|
||||
for i in 120:
|
||||
peak = maxf(peak, absf(NpcAvatar.sway_for_pose("perform", float(i) / 60.0)))
|
||||
var a := TestUtils.check(peak > 0.05, "the performer's sign sways")
|
||||
var b := TestUtils.check_eq(NpcAvatar.sway_for_pose("photo", 1.0), 0.0, "photographers do not sway")
|
||||
var c := TestUtils.check_eq(NpcAvatar.sway_for_pose("sit", 1.0), 0.0, "seated guests do not sway")
|
||||
return a and b and c
|
||||
|
||||
func test_photo_prop_is_wired_for_camera_archetypes() -> bool:
|
||||
var influencer := _avatar("photo", Rules.NpcType.INFLUENCER)
|
||||
var tourist := _avatar("photo", Rules.NpcType.TOURIST)
|
||||
var performer := _avatar("perform", Rules.NpcType.PERFORMER)
|
||||
var a := TestUtils.check(influencer._photo_prop != null, "influencer carries a camera prop")
|
||||
var b := TestUtils.check(tourist._photo_prop != null, "tourist carries a camera prop")
|
||||
var c := TestUtils.check(performer._sign_prop != null, "performer carries a sign prop")
|
||||
influencer.free()
|
||||
tourist.free()
|
||||
performer.free()
|
||||
return a and b and c
|
||||
|
||||
func test_sitting_rig_actually_lowers() -> bool:
|
||||
var avatar := _avatar("sit")
|
||||
avatar.arrived = true
|
||||
for i in 40:
|
||||
avatar._animate(0.016)
|
||||
var a := TestUtils.check(avatar._rig.position.y < -0.2, "the rig settles into the crouch")
|
||||
var legs_bent := TestUtils.check(absf(avatar._legs[0].rotation.x) > deg_to_rad(30.0), "legs bend for the seat")
|
||||
avatar.arrived = false
|
||||
for i in 60:
|
||||
avatar._animate(0.016)
|
||||
var b := TestUtils.check(avatar._rig.position.y > -0.1, "standing back up lifts the rig")
|
||||
avatar.free()
|
||||
return a and legs_bent and b
|
||||
@@ -0,0 +1,143 @@
|
||||
extends RefCounted
|
||||
## Authored scenario content added to match the PLAN lists: Joke Shop access
|
||||
## edge cases, Studio filming boundaries and celebrity crowd spikes, and Building
|
||||
## events that point at the body-camera / request-reason verbs.
|
||||
|
||||
const Factory := preload("res://src/content/scenario_factory.gd")
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const Strings := preload("res://src/core/strings.gd")
|
||||
const AccessLoadout := preload("res://src/core/access_loadout.gd")
|
||||
const AccessVerifier := preload("res://src/core/access_verifier.gd")
|
||||
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||
|
||||
func _events(scenario_id: String) -> Array:
|
||||
var scenario: Resource
|
||||
match scenario_id:
|
||||
"joke_shop":
|
||||
scenario = Factory.joke_shop()
|
||||
"tv_studio":
|
||||
scenario = Factory.tv_studio()
|
||||
_:
|
||||
scenario = Factory.hollywood_building()
|
||||
return scenario.event_pool
|
||||
|
||||
func _event(scenario_id: String, id: String) -> Dictionary:
|
||||
for event in _events(scenario_id):
|
||||
if str(event.get("id", "")) == id:
|
||||
return event
|
||||
return {}
|
||||
|
||||
func test_joke_shop_has_the_missing_edge_cases() -> bool:
|
||||
var a := TestUtils.check(not _event("joke_shop", "joke_guest_reservation_no_id").is_empty(),
|
||||
"reservation list without ID exists")
|
||||
var b := TestUtils.check(not _event("joke_shop", "joke_borrowed_id").is_empty(),
|
||||
"borrowed/mismatched ID exists")
|
||||
var c := TestUtils.check(not _event("joke_shop", "joke_refuses_wand").is_empty(),
|
||||
"guest who refuses the wand exists")
|
||||
var d := TestUtils.check(not _event("joke_shop", "joke_late_arrival").is_empty(),
|
||||
"late arrival during the performance exists")
|
||||
return a and b and c and d
|
||||
|
||||
func test_reservation_without_id_is_denied() -> bool:
|
||||
var event := _event("joke_shop", "joke_guest_reservation_no_id")
|
||||
var profile := _guest_profile()
|
||||
var artifacts := AccessLoadout.build(profile, Factory.joke_shop().access_policy, event.get("override_access", {}))
|
||||
var verifier := AccessVerifier.new()
|
||||
var result: Dictionary = verifier.verify(artifacts, Factory.joke_shop().access_policy, false)
|
||||
var a := TestUtils.check(not result.get("granted", true), "a reservation without ID is not admitted")
|
||||
var b := TestUtils.check(not (result.get("issues", []) as Array).is_empty(), "the denial names the missing artifact")
|
||||
return a and b
|
||||
|
||||
func test_borrowed_id_is_mismatched() -> bool:
|
||||
var event := _event("joke_shop", "joke_borrowed_id")
|
||||
var profile := _guest_profile()
|
||||
var artifacts := AccessLoadout.build(profile, Factory.joke_shop().access_policy, event.get("override_access", {}))
|
||||
var found_mismatch := false
|
||||
for artifact in artifacts:
|
||||
if artifact.kind == Rules.AccessKind.ID and artifact.status == Rules.ArtifactStatus.MISMATCHED:
|
||||
found_mismatch = true
|
||||
var verifier := AccessVerifier.new()
|
||||
var result: Dictionary = verifier.verify(artifacts, Factory.joke_shop().access_policy, false)
|
||||
var a := TestUtils.check(found_mismatch, "the ID is flagged mismatched")
|
||||
var b := TestUtils.check(not result.get("granted", true), "a mismatched ID is not admitted")
|
||||
return a and b
|
||||
|
||||
func test_refuses_wand_event_sets_the_flag() -> bool:
|
||||
var event := _event("joke_shop", "joke_refuses_wand")
|
||||
var ok := TestUtils.check(bool(event.get("refuses_screening", false)), "the event marks the refusal")
|
||||
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
||||
var wired := TestUtils.check(src.contains("refuses_screening = bool(event.get("),
|
||||
"the spawn copies the flag onto the controller")
|
||||
var handled := TestUtils.check(src.contains("if controller.refuses_screening:"),
|
||||
"the wand screen handles a refusal")
|
||||
return ok and wired and handled
|
||||
|
||||
func test_studio_filming_boundary_repeats() -> bool:
|
||||
var first := _event("tv_studio", "studio_film_boundary")
|
||||
var repeat := _event("tv_studio", "studio_film_boundary_repeat")
|
||||
var a := TestUtils.check(not first.is_empty(), "first boundary event exists")
|
||||
var b := TestUtils.check(not repeat.is_empty(), "repeat boundary event exists")
|
||||
var c := TestUtils.check_eq(str(first.get("intent", "")), "film_boundary", "first uses the boundary intent")
|
||||
var d := TestUtils.check_eq(str(repeat.get("intent", "")), "film_boundary", "repeat uses the boundary intent")
|
||||
var e := TestUtils.check(int(repeat.get("time_range_s", Vector2.ZERO).x) > int(first.get("time_range_s", Vector2.ZERO).x),
|
||||
"the repeat happens later in the shift")
|
||||
return a and b and c and d and e
|
||||
|
||||
func test_film_boundary_stands_at_the_steps() -> bool:
|
||||
var scenario := Factory.tv_studio()
|
||||
var spot: Vector3 = scenario.waypoint_for_intent("film_boundary")
|
||||
var sidewalk: Vector3 = scenario.waypoint_for_intent("film")
|
||||
var a := TestUtils.check(spot != sidewalk, "the boundary spot differs from the permitted sidewalk spot")
|
||||
var b := TestUtils.check_eq(spot, scenario._location_or_default("steps_end", Vector3.ZERO),
|
||||
"the influencer stands at the steps, past the line")
|
||||
return a and b
|
||||
|
||||
func test_celebrity_arrival_spawns_a_crowd() -> bool:
|
||||
var event := _event("tv_studio", "studio_celebrity_arrival")
|
||||
var extras: Array = event.get("extra_spawns", [])
|
||||
var a := TestUtils.check(extras.size() >= 3, "celebrity arrival adds several bystanders")
|
||||
var types: Array = []
|
||||
for extra in extras:
|
||||
types.append(int((extra as Dictionary).get("npc_type", -1)))
|
||||
var b := TestUtils.check(types.has(Rules.NpcType.TOURIST), "tourists gather")
|
||||
var c := TestUtils.check(types.has(Rules.NpcType.INFLUENCER), "an influencer films it")
|
||||
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
||||
var d := TestUtils.check(src.contains('event.get("extra_spawns", [])'), "the spawner honours extra_spawns")
|
||||
return a and b and c and d
|
||||
|
||||
func test_building_events_point_at_the_new_verbs() -> bool:
|
||||
var camera_event := _event("hollywood_building", "building_filming_boundary")
|
||||
var reason_event := _event("hollywood_building", "building_unclear_intent")
|
||||
var a := TestUtils.check(str(camera_event.get("hint", "")).contains("body"), "an event hints at the body camera")
|
||||
var b := TestUtils.check(str(reason_event.get("hint", "")).contains("reason"), "an event hints at requesting a reason")
|
||||
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
||||
var c := TestUtils.check(src.contains('if event.has("hint")'), "spawning logs the authored hint")
|
||||
return a and b and c
|
||||
|
||||
func test_every_authored_hint_resolves() -> bool:
|
||||
var missing: Array[String] = []
|
||||
for scenario in [Factory.hollywood_building(), Factory.joke_shop(), Factory.tv_studio()]:
|
||||
for event in scenario.event_pool:
|
||||
var key := str((event as Dictionary).get("hint", ""))
|
||||
if key != "" and Strings.lookup(key) == key:
|
||||
missing.append(key)
|
||||
return TestUtils.check(missing.is_empty(), "all authored hints have strings (missing: %s)" % str(missing))
|
||||
|
||||
func test_new_events_have_unique_ids() -> bool:
|
||||
# Event ids only need to be unique within a venue.
|
||||
var problems: Array[String] = []
|
||||
for scenario in [Factory.hollywood_building(), Factory.joke_shop(), Factory.tv_studio()]:
|
||||
var seen := {}
|
||||
for event in scenario.event_pool:
|
||||
var id := str((event as Dictionary).get("id", ""))
|
||||
if seen.has(id):
|
||||
problems.append("%s:%s" % [scenario.id, id])
|
||||
seen[id] = true
|
||||
return TestUtils.check(problems.is_empty(), "event ids are unique per venue (dupes: %s)" % str(problems))
|
||||
|
||||
func _guest_profile() -> NpcProfile:
|
||||
var p := NpcProfile.new()
|
||||
p.id = "guest"
|
||||
p.display_name = "Guest"
|
||||
p.npc_type = Rules.NpcType.AUTHORIZED_GUEST
|
||||
return p
|
||||
@@ -16,8 +16,17 @@ HOST="${HOST:-https://git.allucanget.biz}"
|
||||
VERSION="${1:-$(grep -oP 'config/version="\K[^"]+' project.godot)}"
|
||||
TAG="v$VERSION"
|
||||
|
||||
# AUTH is a curl argument list. Populated by the first available method:
|
||||
# 1. GITEA_TOKEN explicit token (repo secret in CI)
|
||||
# 2. GITEA_AUTH_HEADER a ready-made "Authorization: ..." header
|
||||
# 3. ~/.git-credentials basic auth for HOST
|
||||
# 4. the checkout's git extraheader (Gitea Actions runners inject one for the
|
||||
# job; this is what makes tag builds publish without any repo secret)
|
||||
AUTH=()
|
||||
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
||||
AUTH=("Authorization: token $GITEA_TOKEN")
|
||||
AUTH=(-H "Authorization: token $GITEA_TOKEN")
|
||||
elif [[ -n "${GITEA_AUTH_HEADER:-}" ]]; then
|
||||
AUTH=(-H "$GITEA_AUTH_HEADER")
|
||||
elif [[ -f "$HOME/.git-credentials" ]]; then
|
||||
CRED="$(python3 -c "
|
||||
import os
|
||||
@@ -28,8 +37,23 @@ for l in open(os.path.expanduser('~/.git-credentials')):
|
||||
echo "error: no credentials for $HOST in ~/.git-credentials" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "error: set GITEA_TOKEN or provide ~/.git-credentials" >&2
|
||||
# curl basic auth. (Previously only CRED was set, so ${AUTH[@]} was unbound
|
||||
# under `set -u` and every API call failed silently.)
|
||||
AUTH=(-u "$CRED")
|
||||
fi
|
||||
|
||||
if [[ ${#AUTH[@]} -eq 0 ]]; then
|
||||
# Gitea Actions: actions/checkout configures a local extraheader with the
|
||||
# runner's credentials. Reuse it for the API (the same server, so the header is
|
||||
# valid for $HOST too).
|
||||
GIT_HEADER="$(git config --local --get-regexp '^http\..*\.extraheader$' 2>/dev/null | head -n1 | cut -d' ' -f2- || true)"
|
||||
if [[ -n "${GIT_HEADER:-}" ]]; then
|
||||
AUTH=(-H "$GIT_HEADER")
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ${#AUTH[@]} -eq 0 ]]; then
|
||||
echo "error: no credentials. Set GITEA_TOKEN, GITEA_AUTH_HEADER, or provide ~/.git-credentials." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -75,8 +99,31 @@ for f in "${all_artifacts[@]}"; do
|
||||
exit 1
|
||||
fi
|
||||
echo "==> Uploading $(basename "$f")"
|
||||
curl -s -X POST "$API/$RELEASE_ID/assets" "${AUTH[@]}" -F "attachment=@$f" >/dev/null
|
||||
# Fail loudly: a silent 4xx here used to leave a release with no artifacts while
|
||||
# the job still reported success.
|
||||
code="$(curl -sS -o /tmp/release-upload.json -w '%{http_code}' --retry 2 \
|
||||
-X POST "$API/$RELEASE_ID/assets" "${AUTH[@]}" -F "attachment=@$f" || true)"
|
||||
if [[ "$code" != "201" && "$code" != "200" ]]; then
|
||||
echo "error: upload of $(basename "$f") failed with HTTP $code" >&2
|
||||
cat /tmp/release-upload.json >&2 || true
|
||||
echo "" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Verify the release actually carries the artifacts we just sent.
|
||||
have="$(curl -sS "${AUTH[@]}" "$API/$RELEASE_ID" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
print(0); raise SystemExit
|
||||
print(len(d.get('assets', [])))" 2>/dev/null || echo 0)"
|
||||
if [[ "${have:-0}" -lt "${#all_artifacts[@]}" ]]; then
|
||||
echo "error: release has $have assets, expected ${#all_artifacts[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Verified $have artifacts on the release"
|
||||
|
||||
echo ""
|
||||
echo "Release live: $HOST/$REPO/releases/tag/$TAG"
|
||||
@@ -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,26 +103,28 @@ SCREENS = [
|
||||
},
|
||||
{
|
||||
"name": "targeting_npc",
|
||||
"kind": "scene",
|
||||
"title": "Targeting an NPC",
|
||||
"source": "README screenshot 02 · ADR-0008 cone focus + head ring",
|
||||
"source": "README screenshot 02 · ADR-0008 cone focus + focus circle",
|
||||
"ref": "02-targeting-npc.png",
|
||||
"description": "An approaching NPC is in the focus cone: a thin pale-gold "
|
||||
"ring billboards around their head, a display name under "
|
||||
"the ring, and the HUD shows a target line with the "
|
||||
"interaction hint.",
|
||||
"description": "An approaching NPC is in the focus cone: a blue "
|
||||
"semi-transparent circle in front of their face, a display "
|
||||
"name under the circle, 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 friendly pedestrian "
|
||||
"standing on a city sidewalk, a blue semi-transparent circle in front "
|
||||
"of their face as a game selection marker, a small name label under "
|
||||
"the circle, minimal HUD target text at the bottom, daytime"),
|
||||
"regions": [
|
||||
(0.35, 0.15, 0.65, 0.60, "NPC in focus"),
|
||||
(0.44, 0.10, 0.56, 0.16, "head ring"),
|
||||
(0.44, 0.10, 0.56, 0.16, "face circle"),
|
||||
(0.05, 0.85, 0.95, 0.95, "HUD: name + hint"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "access_inspection",
|
||||
"kind": "ui",
|
||||
"title": "Access inspection",
|
||||
"source": "README screenshot 03 · ADR-0003 access verification",
|
||||
"ref": "03-access-inspection.png",
|
||||
@@ -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, a threat "
|
||||
"assessment and whether anyone is in danger, and a Send button, dark "
|
||||
"translucent panel over a softly blurred city street background"),
|
||||
"regions": [
|
||||
(0.30, 0.08, 0.70, 0.92, "radio panel"),
|
||||
(0.34, 0.16, 0.66, 0.30, "location / description"),
|
||||
@@ -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,18 +401,28 @@ 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):
|
||||
DESIGNS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
failed = []
|
||||
for screen in SCREENS:
|
||||
print(f"generating {screen['name']} ...")
|
||||
if args.draft:
|
||||
_draw_wireframe(screen, _design_path(screen["name"]))
|
||||
else:
|
||||
_generate_ai(screen, args.model, args.request_size)
|
||||
try:
|
||||
if args.draft:
|
||||
_draw_wireframe(screen, _design_path(screen["name"]))
|
||||
else:
|
||||
_generate_ai(screen, args.model, args.request_size)
|
||||
except SystemExit as exc:
|
||||
failed.append(screen["name"])
|
||||
print(f" FAILED {screen['name']}: {exc}", file=sys.stderr)
|
||||
print("done")
|
||||
if failed:
|
||||
print(f"failed: {', '.join(failed)}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||