Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f4ac7e9aa | ||
|
|
294ac17333 | ||
|
|
e2c2a0bf4c | ||
|
|
1b976bf351 | ||
|
|
921bab796e | ||
|
|
71b0b53705 | ||
|
|
f5ad1642b5 | ||
|
|
028f608298 | ||
|
|
be429cab29 | ||
|
|
f11b441c07 | ||
|
|
69484866db | ||
|
|
53e98cf3fe | ||
|
|
8cf7745c80 | ||
|
|
2b631ae6d9 | ||
|
|
5662baa14d | ||
|
|
b704a8ed61 | ||
|
|
f2d218cc27 | ||
|
|
3377545a3e | ||
|
|
7ff0c28c03 | ||
|
|
2ca0e18018 |
No files matched your search
@@ -1,3 +1,27 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
*.pycache
|
||||||
|
*.pytest_cache
|
||||||
|
*.mypy_cache
|
||||||
|
*.coverage
|
||||||
|
coverage.xml
|
||||||
|
htmlcov/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
eggs/
|
||||||
|
*.egg-info/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
*.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
# Godot 4
|
# Godot 4
|
||||||
.godot/
|
.godot/
|
||||||
*.import
|
*.import
|
||||||
|
|||||||
@@ -173,6 +173,10 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every
|
|||||||
- [x] `config/version` → `0.2.0`; `build/` artifacts rebuilt and Linux binary smoke-tested.
|
- [x] `config/version` → `0.2.0`; `build/` artifacts rebuilt and Linux binary smoke-tested.
|
||||||
- [x] New world/lighting headless tests (56 total).
|
- [x] New world/lighting headless tests (56 total).
|
||||||
|
|
||||||
|
## Screenshot Refresh
|
||||||
|
|
||||||
|
- [x] Regenerate and verify the six README screenshots from the current build.
|
||||||
|
|
||||||
## 🔴 PRIORITY — Ship downloadable builds (complete 2026-10-31)
|
## 🔴 PRIORITY — Ship downloadable builds (complete 2026-10-31)
|
||||||
|
|
||||||
- [x] Install Godot 4.3 export templates locally.
|
- [x] Install Godot 4.3 export templates locally.
|
||||||
@@ -216,7 +220,13 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every
|
|||||||
ran it headless (0 script errors).
|
ran it headless (0 script errors).
|
||||||
- [x] CI: NSIS + AppImage steps added to `.gitea/workflows/build.yml` build job; publish glob
|
- [x] CI: NSIS + AppImage steps added to `.gitea/workflows/build.yml` build job; publish glob
|
||||||
already uploads `build/step-line-*<ver>*` (covers setup/AppImage names).
|
already uploads `build/step-line-*<ver>*` (covers setup/AppImage names).
|
||||||
- [ ] (open) CI run verification of the NSIS build and installer-content listing.
|
- [x] CI run 1110 verified end-to-end (v0.2.1): NSIS installer `step-line-setup-0.2.1.exe`
|
||||||
|
built, `7z l` shows `step-line.exe` + `uninstall.exe`, AppImage built (33.9 MB),
|
||||||
|
release `v0.2.1` published, `Job succeeded`.
|
||||||
|
- Gotchas fixed on the way: makensis needs `-D` defines (not `/D`) and absolute paths
|
||||||
|
(it resolves relatives against the script dir); installed exe must be space-free
|
||||||
|
(`step-line.exe`); icon.ico must use classic BMP entries (ImageMagick; PIL emits
|
||||||
|
PNG-compressed ICOs NSIS rejects).
|
||||||
|
|
||||||
## 🎮 Assets & detailed graphics pass (textures, rigs, UI feedback)
|
## 🎮 Assets & detailed graphics pass (textures, rigs, UI feedback)
|
||||||
|
|
||||||
@@ -278,3 +288,239 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every
|
|||||||
- [x] Add visual and audio feedback per escalation level (colors, tones, shake).
|
- [x] Add visual and audio feedback per escalation level (colors, tones, shake).
|
||||||
- [x] Add audio for crowd ambience, radio responses, and confirmation prompts.
|
- [x] Add audio for crowd ambience, radio responses, and confirmation prompts.
|
||||||
- [x] Externalize dialogue and UI text for localization.
|
- [x] Externalize dialogue and UI text for localization.
|
||||||
|
|
||||||
|
## Generalized Game Asset Tooling
|
||||||
|
|
||||||
|
- [x] Reuse the OpenRouter image backend for role-aware arbitrary asset generation.
|
||||||
|
- [x] Add exact-dimension image crop and aspect-preserving resize commands.
|
||||||
|
- [x] Audit existing assets for role-specific dimensions, aspect, alpha, and tile seams;
|
||||||
|
report projection/perspective checks for visual review.
|
||||||
|
- [x] Add automated tests and document asset-pipeline usage.
|
||||||
|
|
||||||
|
## Generated Asset Integration
|
||||||
|
|
||||||
|
- [x] Regenerate the AI asset set at role-specific aspect ratios and dimensions.
|
||||||
|
- [x] Audit, process, and wire every generated asset into the game runtime.
|
||||||
|
- [x] Add screenshot auditing and refresh the README screenshot gallery.
|
||||||
|
|
||||||
|
## Access artifacts actually match the venue
|
||||||
|
|
||||||
|
Completed in the working tree (uncommitted with the control pass). Headless **74/74**.
|
||||||
|
|
||||||
|
- [x] Build artifacts from the event (`override_access`, carried items) and the venue policy,
|
||||||
|
not from `NpcType` alone. (`src/core/access_loadout.gd`; spawn signal is
|
||||||
|
`(profile, intent, npc_id, event)`.)
|
||||||
|
- [x] Joke Shop: valid ID+ticket, ID without ticket, expired/missing ID. Recognition does not
|
||||||
|
auto-grant on every `AUTHORIZED_GUEST`.
|
||||||
|
- [x] Wand venues roll `carried_items` per spawn (seeded); override can force a pocket.
|
||||||
|
- [x] Studio counterfeit / follow-through events carry `override_access`.
|
||||||
|
- [x] `AccessVerifier.verify(..., already_screened)` so a screened guest is not still denied.
|
||||||
|
- [x] Joke Shop / Studio `sidewalk` and `steps_end` waypoints so film/photo intents do not walk
|
||||||
|
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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Locomotion
|
||||||
|
|
||||||
|
- [x] Wand off `W` (now `C` + wheel). `A`/`D` allow/deny only while inspect/wand is open
|
||||||
|
(they step the wheel while it is open). `Q` remains a closed-menu polite shortcut.
|
||||||
|
- [x] Accel/decel on walk; gravity only when airborne; `floor_snap_length` 0.3.
|
||||||
|
- [x] Mouse look only while captured and `not input_locked`. Freeze walk+look while radial /
|
||||||
|
inspect / wand / radio / review is up. Recapture on close.
|
||||||
|
- [x] `cancel` (Esc / RMB) closes the top modal first; with nothing open, Esc toggles mouse
|
||||||
|
capture and RMB does nothing.
|
||||||
|
|
||||||
|
### Focus and head ring
|
||||||
|
|
||||||
|
- [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] Screen-center crosshair.
|
||||||
|
|
||||||
|
### Radial HUD
|
||||||
|
|
||||||
|
- [x] `src/game/ui/action_radial.gd`: LMB or `Space` opens when someone is focused. Opening
|
||||||
|
click ignored for 120 ms. Slices: raise hand, point, head shake, polite, inspect, wand
|
||||||
|
(grey if no wand), warning, block, radio assist. Emergency stays on `R`.
|
||||||
|
- [x] Highlight by mouse angle, or `A`/`D` / arrows. Click slice or `Space` confirms. `Esc` /
|
||||||
|
RMB closes with no action (no recorder event).
|
||||||
|
- [x] Inspect / wand / radio slices close the wheel then open the existing panels. Freeze until
|
||||||
|
that panel closes.
|
||||||
|
- [x] Number keys `1`–`6` still work when the wheel is closed.
|
||||||
|
|
||||||
|
### NPC display
|
||||||
|
|
||||||
|
- [x] Procedural rig: neck, shoes, hair cap from seed, two eye spheres. Props, height
|
||||||
|
variation, walk bob, notice-head-turn kept. Collision capsule scaled to rig height.
|
||||||
|
Named `Head` is the ring attach point. Group `npc`.
|
||||||
|
|
||||||
|
### Input, copy, tests
|
||||||
|
|
||||||
|
- [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
|
||||||
|
(`src/test/smoke_focus_radial.gd`, exits non-zero on failure).
|
||||||
|
- [x] README controls table, smoke checklist, test count, implemented/not-yet lists.
|
||||||
|
|
||||||
|
## Visual polish — Skyline backdrop transparency
|
||||||
|
|
||||||
|
Found during the control smoke test; verified with PIL analysis.
|
||||||
|
|
||||||
|
- [x] Regenerated `assets/textures/game_ready/skyline.png` with transparent background
|
||||||
|
(commented out opaque border line 234 in `tools/generate_textures.py`).
|
||||||
|
- [x] Integrated backdrop into `WorldBuilder` via `_build_skyline_backdrop()`: creates a
|
||||||
|
PlaneMesh with T_SKYLINE texture at Vector3(-80, 12, -50) behind the building.
|
||||||
|
- [x] PIL verification confirms RGBA mode with transparent pixels in bottom rows.
|
||||||
|
|
||||||
|
## Runtime boot and spawn fixes
|
||||||
|
|
||||||
|
Found while running the windowed smoke; all verified with a boot run and regenerated
|
||||||
|
screenshots.
|
||||||
|
|
||||||
|
- [x] `GameManager` did not compile: `radial` was typed `CanvasLayer`, so
|
||||||
|
`radial.is_open` / `confirm_selected()` made `:=` inference fail. Typed as
|
||||||
|
`ActionRadial` (the parse error would have broken the whole main scene).
|
||||||
|
- [x] Camera sat at the guard's feet: `Head` had no height offset, and the new gravity pulled
|
||||||
|
the guard to the floor. `Head` is now at y=1.6, and the guard spawns on the landing.
|
||||||
|
- [x] Guard spawned inside the building collider and fell through the world. Building volume
|
||||||
|
moved back to z ∈ [-8, -4] so the door / awning / marquee sit on its front face; the
|
||||||
|
guard spawns in front of the door facing the street.
|
||||||
|
- [x] Steps rose away from the door; treads now rise toward the entrance.
|
||||||
|
- [x] Event scheduler was polled every physics frame (~60 Hz), spawning >1200 NPCs in
|
||||||
|
seconds. `ShiftCoordinator` now accumulates time and pulls once per simulated second;
|
||||||
|
regression test in `test_scenarios.gd`.
|
||||||
|
- [x] Live NPCs capped at `MAX_LIVE_NPCS` (16); routine traffic is dropped at the cap and a
|
||||||
|
scripted event evicts the oldest unfocused NPC.
|
||||||
|
- [x] Removed the skyline backdrop planes (opaque black texture). Tracked as a follow-up in
|
||||||
|
`TODO.md`.
|
||||||
|
- [x] README screenshots regenerated with the fixed eye height and entrance staging.
|
||||||
|
|
||||||
|
## World and presentation follow-ups (found during the control smoke)
|
||||||
|
|
||||||
|
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.
|
||||||
|
- [x] Guard settles ~0.5 m above the ground after spawning on the landing. Check the capsule
|
||||||
|
offset and the spawn `y`, and verify eye height while walking the steps.
|
||||||
|
- [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
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Session shell
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
- [x] Startup menu: pick venue, start shift, quit. Keyboard + click. Replaces `V` as the
|
||||||
|
player-facing path (`V` may stay as a debug bind).
|
||||||
|
- [x] Pause overlay when `Esc` releases the mouse (or a dedicated pause key): resume, restart
|
||||||
|
shift, settings, quit to menu.
|
||||||
|
- [x] Settings: mouse look sensitivity, persist through `Save` / `settings.json`. Load on boot.
|
||||||
|
- [x] Shift review "Run another shift" returns to the venue menu, not a silent restart of the
|
||||||
|
same scenario.
|
||||||
|
|
||||||
|
## Documentation drift
|
||||||
|
|
||||||
|
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
|
||||||
|
uses `src/test/test_runner.gd` and `.gitea/workflows/build.yml`.
|
||||||
|
- [x] MEMORY: door_staff_lines log through `GameManager`; 300 s default removed.
|
||||||
|
|
||||||
|
## Remaining PLAN verbs (radial exists)
|
||||||
|
|
||||||
|
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
|
||||||
|
artifact on every staff/guest type. Staff still auto-recognize when `override_access` is
|
||||||
|
empty (`AccessLoadout`).
|
||||||
|
- [x] Formal warning can activate the body-camera / security-light flag (PLAN Level 2). Overlay
|
||||||
|
is currently always on; toggling should write an incident event.
|
||||||
|
- [x] Blocking stations the guard between the person and the door (PLAN Level 3). Today it only
|
||||||
|
sends `apply_player_action(BLOCKING)`.
|
||||||
|
- [x] "Request reason for entry" as a Level 3 ask, with a short NPC reply from `dialogue_lines`.
|
||||||
|
|
||||||
|
## Crowd presentation
|
||||||
|
|
||||||
|
Completed 2026-10-31 (working tree). Headless **105/105**; `src/test/smoke_crowd.gd` verifies rubbernecking and panic clearing. A 16-NPC entrance measured ~1.2 ms/frame (824 fps, vsync off), so no LOD/culling was added at the current cap.
|
||||||
|
|
||||||
|
|
||||||
|
`CrowdSystem` is a mood/attention float. PLAN wants people watching, gathering, and panicking.
|
||||||
|
|
||||||
|
- [x] On `aggressive_behavior` / `emergency_responder`, nearby idle NPCs stop and face the
|
||||||
|
incident (rubberneck). On `crowd_panic`, they move away from the door.
|
||||||
|
- [x] Profile a busy entrance with textured materials and props; add LOD or culling for distant
|
||||||
|
NPCs if frame time drops.
|
||||||
|
|
||||||
|
## Emergency aftermath
|
||||||
|
|
||||||
|
Completed 2026-10-31 (working tree). Headless **113/113**; `src/test/smoke_emergency.gd` covers the unjustified-call investigation, the evacuation aftermath, and the review copy.
|
||||||
|
|
||||||
|
|
||||||
|
Responder outcomes (`command` / `restraint` / `evacuation` / `arrest`) are log lines. PLAN also
|
||||||
|
wants evacuation, a closed street, and an investigation when the call was unjustified.
|
||||||
|
|
||||||
|
- [x] `evacuation` outcome actually clears the entrance (NPCs leave, door stays locked).
|
||||||
|
- [x] Unjustified emergency (low danger, no weapon) writes an investigation note on the shift
|
||||||
|
review and applies the existing false-report / reputation hit.
|
||||||
|
- [x] Review copy names the responder outcome, not only battery and crowd %.
|
||||||
|
|
||||||
|
## Body-camera replay
|
||||||
|
|
||||||
|
Completed 2026-10-31 (working tree). Headless **120/120**; `src/test/smoke_timeline.gd` opens the timeline from the review, steps through every event, and returns to the review.
|
||||||
|
|
||||||
|
|
||||||
|
- [x] Shift review: a timeline of recorded incident events (ADR-0005 already stores them).
|
||||||
|
Scrub or step through; no live 3D replay required in the first version.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -10,22 +10,159 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
- Eventing: no autoload signal bus. `ShiftCoordinator` owns an `EventBus` instance (`src/core/event_bus.gd`) passed to subsystems; subsystems emit only when a bus is assigned. Autoloads at parse time are unreliable under `--script`.
|
- Eventing: no autoload signal bus. `ShiftCoordinator` owns an `EventBus` instance (`src/core/event_bus.gd`) passed to subsystems; subsystems emit only when a bus is assigned. Autoloads at parse time are unreliable under `--script`.
|
||||||
- Static signals are not available in Godot 4.3 GDScript ("Expected func or var after static") — do not use them.
|
- Static signals are not available in Godot 4.3 GDScript ("Expected func or var after static") — do not use them.
|
||||||
- GDScript lambdas capture local variables **by value**; mutating a captured local inside a lambda does not affect the outer variable. Use member functions for accumulators in tests.
|
- GDScript lambdas capture local variables **by value**; mutating a captured local inside a lambda does not affect the outer variable. Use member functions for accumulators in tests.
|
||||||
- Prototype shift duration defaults to 300 s for testing; production targets 10-15 minutes per PLAN.md.
|
- Shift length is the scenario's `shift_duration_s` (900 s in all three venues). Tests and the screenshot tool override elapsed time; they do not change the default.
|
||||||
- RNG: NPC reactions and event scheduling are seedable for deterministic tests.
|
- RNG: NPC reactions and event scheduling are seedable for deterministic tests.
|
||||||
|
|
||||||
|
## Handoff (read this first)
|
||||||
|
|
||||||
|
- Repo: `/run/media/system/DATA/Documents/06-games/security-game`. Godot: `~/.local/bin/godot` (4.3.stable).
|
||||||
|
Tests: `godot --headless --path . --script res://src/test/test_runner.gd` → **88/88**.
|
||||||
|
Session shell smoke: `godot --headless --path . --script res://src/test/smoke_session_shell.gd` → exit 0.
|
||||||
|
Windowed control smoke: `godot --path . --script res://src/test/smoke_focus_radial.gd` → exit 0.
|
||||||
|
- `main` is in sync with origin. **v0.2.2** tags the session-shell + world-follow-up commit;
|
||||||
|
CI builds the portable binaries, NSIS installer, and AppImage on the tag push.
|
||||||
|
- Next work: `TODO.md` scenario content gaps (Joke Shop / Studio / Building events), then the
|
||||||
|
remaining polish and tooling items.
|
||||||
|
- Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg.
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
- **Release v0.2.0 is LIVE**: pushing the `v0.2.0` tag made Gitea Actions run the build job
|
- 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
|
||||||
|
`controller.screened`. Joke Shop/Studio gained `sidewalk`/`steps_end` waypoints. Wand venues roll
|
||||||
|
`carried_items` per spawn (seeded). Tests in `src/test/tests/test_access_loadout.gd`.
|
||||||
|
- Wand shortcut is **`C`**, not `W` (`W` is walk). `radio_emergency` input map no longer includes W.
|
||||||
|
`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`.
|
||||||
|
`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),
|
||||||
|
`inspect` (E), `wand` (C). The old unused `interact` action was removed.
|
||||||
|
- Runtime fixes from the smoke: `Head` is +1.6 above the guard root (root sits on the floor); guard
|
||||||
|
spawns in front of the door facing the street; building volume moved to z ∈ [-8,-4] so the door,
|
||||||
|
awning, and marquee are visible; step treads rise toward the door; `ShiftCoordinator` pulls the
|
||||||
|
event scheduler once per simulated second (was every physics frame → >1200 NPCs in seconds);
|
||||||
|
`GameManager.MAX_LIVE_NPCS = 16` caps live avatars.
|
||||||
|
- **Session shell (2026-10-31, uncommitted):** the game now boots into `StartMenu`
|
||||||
|
(`src/game/ui/start_menu.gd`, layer 20) instead of a live shift. `GameManager._on_venue_selected()`
|
||||||
|
builds the scenario and calls `_start_shift()`; `_shift_running` gates input and `_refresh_freeze()`.
|
||||||
|
Esc with nothing open now pauses (`PauseOverlay`, layer 18) → resume / restart / settings /
|
||||||
|
quit-to-menu. `SettingsPanel` (layer 22) writes `look_sensitivity` + `hud_font_scale` through
|
||||||
|
`Save.save_settings()` → `user://settings.json`; `Save.load_settings()` merges over
|
||||||
|
`Save.DEFAULT_SETTINGS` on boot and `GameManager._apply_settings()` pushes them into the guard/HUD.
|
||||||
|
Shift review "Run another shift" now calls `_show_start_menu()` (not a silent restart). `V` stays a
|
||||||
|
debug venue bind. Smoke: `src/test/smoke_session_shell.gd` (menu → shift → pause → settings → menu).
|
||||||
|
- **World follow-ups closed (2026-10-31):** skyline backdrop restored with `T_SKYLINE`
|
||||||
|
(`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
|
||||||
|
(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`).
|
||||||
|
`eye_contact` reuses the Level 1 reaction table via `_apply_interaction`. Recognition is a player
|
||||||
|
decision: `NPCController.player_recognized` + `IncidentRecorder.add_access_evidence`, and
|
||||||
|
`GameManager._artifacts_for()` prepends a `RECOGNITION` artifact only after the player declares
|
||||||
|
it. The body camera is a real flag (`GameManager.body_camera_on`, `Hud.set_body_camera`), off by
|
||||||
|
default, `B` toggles it, and a formal warning arms it and writes a `body_camera` incident event.
|
||||||
|
Blocking also calls `_station_between()` to physically place the guard 0.9 m from the NPC toward
|
||||||
|
the entrance. `_request_reason()` logs the NPC's seeded `dialogue_lines` pick.
|
||||||
|
- **NPC navigation was dead (fixed 2026-10-31):** avatars never moved in the live game, for three
|
||||||
|
stacked reasons. (1) `agent.target_position` was re-assigned every physics frame, which
|
||||||
|
invalidates the path so `get_next_path_position()` keeps returning the current point — only set
|
||||||
|
it when the target changes. (2) Nav path points sit on the navmesh plane (y=0) while bodies stand
|
||||||
|
at y≈0.5, so a 3D "are we there" test always looked 0.5 m away and the horizontal direction was
|
||||||
|
zero — compare horizontally. (3) `path[0]` is the old start point, so picking any point "far
|
||||||
|
enough away" can pick the point behind you and oscillate — pick the first point that is
|
||||||
|
**closer to the goal** than the current position. `_nav_try_frames`/`_move_direct` fallback is
|
||||||
|
still there.
|
||||||
|
- **Crowd reactions listen on the EventBus:** `CrowdSystem.apply_event()` emits
|
||||||
|
`event_bus.crowd_changed`; its own `changed` signal is never emitted. `GameManager._connect_crowd()`
|
||||||
|
therefore connects to `coordinator.event_bus.crowd_changed` (reconnect after every
|
||||||
|
`coordinator.setup()`, which recreates both crowd and event bus).
|
||||||
|
- **Crowd presentation:** `NpcAvatar.start_rubberneck(at)` (idle bystanders away from the door stop
|
||||||
|
and face the incident for 12 s) and `start_flee(from)` (panic: walk away at 2.4 m/s, then free
|
||||||
|
themselves). Someone `awaiting_interaction` within 3 m of the door is "engaged" and does not
|
||||||
|
rubberneck. Use `position` (not `global_position`) for that distance check so it also works in
|
||||||
|
headless tests outside the tree.
|
||||||
|
- **Perf:** a busy 16-NPC entrance measured ~1.2 ms/frame (824 fps with vsync off, 800 samples),
|
||||||
|
so LOD/culling is not warranted at `MAX_LIVE_NPCS = 16`. Re-measure before raising the cap.
|
||||||
|
- **Incident recording was dead:** `IncidentRecorder.start_incident()` was never called, so every
|
||||||
|
`add_event`/`add_access_evidence` silently no-opped and the review always showed 0 incidents.
|
||||||
|
`_begin_incident()` now starts one per focused NPC (`has_open_incident()` guard), verbs call
|
||||||
|
`_ensure_incident()`, and `ShiftCoordinator._finish_shift()` closes leftovers as `unresolved`
|
||||||
|
before building the review.
|
||||||
|
- **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
|
||||||
|
`_connect_crowd()` after every setup. Same class of bug as the crowd signal.
|
||||||
|
- **Emergency aftermath:** `ShiftCoordinator.responder_outcomes` / `investigations` feed the review.
|
||||||
|
`evacuation` (emergency call, low danger, no weapon) locks the door, makes every NPC flee, marks
|
||||||
|
them `leave_requested`, and applies a `security_failure` hit. An emergency placed while
|
||||||
|
`current_npc_truth` has no weapon and `danger_level <= 1` appends an investigation note, records
|
||||||
|
`investigation_opened`, and applies `false_report` -6.
|
||||||
|
- **Skyline backdrop (2026-10-31):** `PlaneMesh` defaults to a **horizontal** XZ plane — set
|
||||||
|
`mesh.orientation = PlaneMesh.FACE_Z` or the "backdrop" renders as a band overhead. The skyline
|
||||||
|
is now a ring of four 400x60 planes at 200 m (center y=30, so building bases meet the ground),
|
||||||
|
`TEXTURE_FILTER` default, `TRANSPARENCY_ALPHA_SCISSOR` with threshold **0.5** (0.3 left faint
|
||||||
|
mip dashes across the sky), unshaded with a lifted albedo (`Color(2.6,2.7,3.0)`) so it is not
|
||||||
|
pure black. Ground is 500x500 so no sky gap shows under the buildings. The `skyline.png` import
|
||||||
|
needs `mipmaps/generate=true`.
|
||||||
|
- **Asset gotcha:** `assets/textures/game_ready/` is *not* imported (no `.import`). Use the
|
||||||
|
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.
|
||||||
|
- **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
|
||||||
|
(never `/D`), resolves relative `-DICON`/`-DARTIFACT` against the **script directory** so CI
|
||||||
|
passes `$PWD`-absolute paths; installed exe must be space-free (`step-line.exe`); ICO must be
|
||||||
|
classic BMP entries (ImageMagick `icon:auto-resize=48,32,16`; PIL emits PNG-compressed ICOs NSIS
|
||||||
|
cannot load).
|
||||||
|
- appimagetool runs system `appstreamcli` if present (strict, exit 3 on warnings) — project
|
||||||
|
metadata now passes `--pedantic`.
|
||||||
|
- Distribution facts (verified via Gitea API + host creds): repo is **public**, account is
|
||||||
|
admin; `v0.2.1` has all 5 assets; the installers were initially missing from the release
|
||||||
|
because `publish_release.sh` uploaded only portables — now fixed (uploads installer +
|
||||||
|
AppImage; falls back to `~/.git-credentials` when no `GITEA_TOKEN`).
|
||||||
|
- Host sign-in: instance `[service] REQUIRE_SIGNIN_VIEW=true` blocks anonymous browse/download
|
||||||
|
(not a repo/API setting). `~/.git-credentials` holds the Gitea creds (basic-auth usable);
|
||||||
|
`.env` only holds `OPENROUTER_API_KEY`.
|
||||||
|
- **Release v0.2.0 was LIVE**: pushing the `v0.2.0` tag made Gitea Actions run the build job
|
||||||
and publish automatically (host has the `GITEA_TOKEN` secret). Tag page and `/releases` are
|
and publish automatically (host has the `GITEA_TOKEN` secret). Tag page and `/releases` are
|
||||||
public (HTTP 200). Caveat: anonymous **asset downloads** redirect to login on this host
|
public (HTTP 200). Caveat: anonymous **asset downloads** redirect to login on this host
|
||||||
(host setting — tracked in TODO). Future releases = bump `config/version`, `git tag vX.Y.Z`,
|
(host setting — tracked in TODO). Future releases = bump `config/version`, `git tag vX.Y.Z`,
|
||||||
push. No local token needed.
|
push. No local token needed.
|
||||||
- Core simulation, three scenarios, headless tests (**57 passing**), and a first-person prototype with interaction HUD, access inspection, radio (assist + emergency hold-to-confirm), debug overlay, and shift review are implemented and green.
|
- Core simulation, three scenarios, v0.2.1 installers/AppImage, first-person prototype (inspect/wand/radio, shift review, Tab targeting, V venue cycle) are on local `main`. Tagged v0.2.1 was 57 tests; this working tree is **74/74**.
|
||||||
- NPC avatars now walk to per-intent waypoints (`VenueScenario.waypoint_for_intent`): enter/trespass/deliver/arrive → entrance, photo/film/perform/wander/roam → sidewalk, sit → steps_end. `NpcAvatar.setup` takes an explicit `door_position`.
|
- NPC avatars now walk to per-intent waypoints (`VenueScenario.waypoint_for_intent`): enter/trespass/deliver/arrive → entrance, photo/film/perform/wander/roam → sidewalk, sit → steps_end. `NpcAvatar.setup` takes an explicit `door_position`.
|
||||||
- Returning-trespasser behavior: `NpcProfile.return_probability` (trespasser 0.6, mental-crisis 0.4, others 0). `NPCController._maybe_schedule_return()` moves a directed-away NPC to `LOITERING_DISTANT`; `tick_return(delta)` fires after 20-60s and transitions back to `APPROACH`. `NpcAvatar._advance_fsm` re-targets the door on return.
|
- Returning-trespasser behavior: `NpcProfile.return_probability` (trespasser 0.6, mental-crisis 0.4, others 0). `NPCController._maybe_schedule_return()` moves a directed-away NPC to `LOITERING_DISTANT`; `tick_return(delta)` fires after 20-60s and transitions back to `APPROACH`. `NpcAvatar._advance_fsm` re-targets the door on return.
|
||||||
- Perception pass: `NPCController.perceive_guard(distance)` returns `{noticed, band, reaction}` with bands `unaware` (>6m), `aware` (2-6m), `near` (<2m). Near + temperament ≥0.6 → ARGUE, else COMPLY. `GameManager._perception_pass()` feeds real guard distance each physics tick and logs first-time notice.
|
- Perception pass: `NPCController.perceive_guard(distance)` returns `{noticed, band, reaction}` with bands `unaware` (>6m), `aware` (2-6m), `near` (<2m). Near + temperament ≥0.6 → ARGUE, else COMPLY. `GameManager._perception_pass()` feeds real guard distance each physics tick and logs first-time notice.
|
||||||
- Responder dispatch: `RadioSystem` tracks `pending_calls` and decrements `arrival_delay_s` in `tick`; emits `responder_arrived` when a call's delay elapses. `RadioSystem.responder_outcome(call)` maps danger/weapons → command/restraint/evacuation/arrest. `GameManager._on_responder_arrived` spawns a `RESPONDER` avatar at the entrance and logs the outcome. HUD shows an emergency-radio cooldown label via `_update_radio_cooldown`.
|
- Responder dispatch: `RadioSystem` tracks `pending_calls` and decrements `arrival_delay_s` in `tick`; emits `responder_arrived` when a call's delay elapses. `RadioSystem.responder_outcome(call)` maps danger/weapons → command/restraint/evacuation/arrest. `GameManager._on_responder_arrived` spawns a `RESPONDER` avatar at the entrance and logs the outcome. HUD shows an emergency-radio cooldown label via `_update_radio_cooldown`.
|
||||||
- Wand screening (Joke Shop): `WandScreener` (new `src/core/wand_screener.gd`) classifies carried items as prohibited (`weapon/knife/gun/blade`) vs false-alarm (`keys/belt/phone/coins/watch/buckle`) vs clear. `NpcProfile.carried_items` holds guest items. `GameManager` binds `W` to `_toggle_wand_screening` and `A`/`D` to `decide_wand`; `decide_access` blocks admission at wand venues until `controller.screened` is true. `VenueScenario.door_staff_lines` holds venue-specific door-staff dialogue (Joke Shop has id/ticket/wand/refusal/prohibited lines).
|
- Wand screening (Joke Shop): `WandScreener` classifies carried items as prohibited vs false-alarm vs clear. `NpcProfile.carried_items` is filled at spawn on wand venues. `GameManager` binds **`C`** to `_toggle_wand_screening` and `A`/`D` to `decide_wand` while inspect/wand is open. `decide_access` still blocks admission until `controller.screened` is true, and `verify` now takes that flag. Door-staff lines log through GameManager.
|
||||||
- Design change (2026-10-31): the selectable dialogue tone-of-voice system was **removed**.
|
- Design change (2026-10-31): the selectable dialogue tone-of-voice system was **removed**.
|
||||||
`Rules.Tone`/`TONE_NAMES`, `NpcProfile.tone_modifiers`, and the tone param on
|
`Rules.Tone`/`TONE_NAMES`, `NpcProfile.tone_modifiers`, and the tone param on
|
||||||
`NPCController.roll_reaction/apply_player_action` no longer exist; `NpcProfile.dialogue_lines`
|
`NPCController.roll_reaction/apply_player_action` no longer exist; `NpcProfile.dialogue_lines`
|
||||||
@@ -59,6 +196,11 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
- Assets pipeline: `python3 tools/generate_textures.py` produces all authored PNGs under
|
- Assets pipeline: `python3 tools/generate_textures.py` produces all authored PNGs under
|
||||||
`assets/textures/` (≤1024px, pow2); regenerate after changing the script, then run
|
`assets/textures/` (≤1024px, pow2); regenerate after changing the script, then run
|
||||||
`godot --headless --import` before tests. 57 headless tests guard the pipeline.
|
`godot --headless --import` before tests. 57 headless tests guard the pipeline.
|
||||||
|
- General image pipeline: `python3 tools/asset_pipeline.py` defaults to
|
||||||
|
`openai/gpt-5-image-mini`, requests supported 1:1/3:2 sources, then fits each role's target.
|
||||||
|
It provides crop, resize, tile edge blending, alpha-glyph recoloring, and role-aware audits.
|
||||||
|
Screenshot auditing checks 1280x720 minimum, 16:9 aspect, and contrast; visual composition
|
||||||
|
remains a manual check. Python tools require Pillow from `requirements-tools.txt`.
|
||||||
- AI texture mode: `python3 tools/generate_textures.py --ai` generates textures via the
|
- AI texture mode: `python3 tools/generate_textures.py --ai` generates textures via the
|
||||||
OpenRouter image API (`POST https://openrouter.ai/api/v1/images`, model
|
OpenRouter image API (`POST https://openrouter.ai/api/v1/images`, model
|
||||||
`bytedance-seed/seedream-4.5` by default, base64 PNG in `data[].b64_json`). Requires
|
`bytedance-seed/seedream-4.5` by default, base64 PNG in `data[].b64_json`). Requires
|
||||||
@@ -67,12 +209,14 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
default batch. Output is downscaled to ≤1024px. NOTE: seedream rejects sizes below
|
default batch. Output is downscaled to ≤1024px. NOTE: seedream rejects sizes below
|
||||||
3,686,400 px (1920×1920), so the script requests `GEN_SIZE=2048` and downscales — do not
|
3,686,400 px (1920×1920), so the script requests `GEN_SIZE=2048` and downscales — do not
|
||||||
lower `GEN_SIZE` below 1920. Verified end-to-end 2026-10-31 (3 textures generated).
|
lower `GEN_SIZE` below 1920. Verified end-to-end 2026-10-31 (3 textures generated).
|
||||||
`AI_TEXTURES` now covers all 15 game textures (world surfaces, awning, skyline, vignette,
|
Legacy `--ai` remains on Seedream 4.5 and still ignores `background: transparent`; do not
|
||||||
4 HUD icons, 5 access cards) with a `transparent` flag per entry. NOTE: seedream-4.5
|
use it for alpha-dependent assets. Gemini 3.1 Flash Image advertised broad ratios but the
|
||||||
ignores `background: transparent` and returns fully opaque images (alpha=255), so the
|
current provider rejected authentication. The validated role-aware model is GPT-5 Image Mini.
|
||||||
`ai/` icons/vignette/skyline are opaque — they are NOT drop-in replacements for the
|
- Runtime assets: all 15 loaded world/HUD/card textures now live in
|
||||||
authored transparent PNGs. Use a transparency-capable model (or post-process) if alpha
|
`assets/textures/ai/game_ready/`; each passes role audit. Generated tile edges are blended,
|
||||||
is needed.
|
icon alpha masks are recolored white where needed, and the vignette uses the existing
|
||||||
|
deterministic generator because the API returned an empty transparent image. The six
|
||||||
|
`docs/screenshots/` captures were regenerated after wiring and pass screenshot audit.
|
||||||
- Detailed graphics: textured facade/awning/skyline, humanoid NPC rigs + props + walk bob +
|
- Detailed graphics: textured facade/awning/skyline, humanoid NPC rigs + props + walk bob +
|
||||||
attention head-turn, inspection artifact cards, mode icons, escalation vignette, body-cam
|
attention head-turn, inspection artifact cards, mode icons, escalation vignette, body-cam
|
||||||
overlay (REC + shift clock). Screenshot set regenerated (`docs/screenshots/`, day + night).
|
overlay (REC + shift clock). Screenshot set regenerated (`docs/screenshots/`, day + night).
|
||||||
@@ -80,6 +224,17 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
- NOTE: collaborators occasionally add uncommitted work to this shared tree (e.g. the radio
|
- NOTE: collaborators occasionally add uncommitted work to this shared tree (e.g. the radio
|
||||||
easter egg); check `git status` before committing and leave foreign changes untouched unless asked.
|
easter egg); check `git status` before committing and leave foreign changes untouched unless asked.
|
||||||
|
|
||||||
|
- Screen-design references (`tools/screen_designs.py`, tests in `tools/test_screen_designs.py`):
|
||||||
|
`generate` turns the screen descriptions in `docs/` (README captions, ADRs 0007–0010,
|
||||||
|
PLAN, architecture) into reference images under `docs/screens/designs/` — `--draft`
|
||||||
|
draws offline Pillow wireframes, `--ai` generates realistic 3D scenes / in-game UI via
|
||||||
|
OpenRouter (default model `openai/gpt-5-image-mini`). Screens are tagged `scene` or
|
||||||
|
`ui`; prompts get a shared art-direction suffix, and requests use a 3:2 landscape
|
||||||
|
source cropped to 16:9 (not a square centre-crop). `compare` checks `docs/screenshots/*.png` for aspect/size/
|
||||||
|
blank-frame and coarse layout/palette/detail drift. Drift distances are warnings, not
|
||||||
|
failures: a mockup never matches a real render, so semantic composition is a follow-up
|
||||||
|
vision-model judge (tracked in TODO).
|
||||||
|
|
||||||
## Known Issues / Notes
|
## Known Issues / Notes
|
||||||
|
|
||||||
- Reference cycles are fixed: `FSM` holds its owner via `WeakRef`, `ShiftCoordinator` is
|
- Reference cycles are fixed: `FSM` holds its owner via `WeakRef`, `ShiftCoordinator` is
|
||||||
@@ -92,16 +247,41 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
- Steps are visual-only (no collider) so NPCs can path to the entrance; the guard spawns on the landing so it never needs to climb.
|
- Steps are visual-only (no collider) so NPCs can path to the entrance; the guard spawns on the landing so it never needs to climb.
|
||||||
- Persistence tests write into the real `user://shift_results.json` (harmless local pollution of the demo history).
|
- Persistence tests write into the real `user://shift_results.json` (harmless local pollution of the demo history).
|
||||||
- macOS export required `rendering/textures/vram_compression/import_etc2_astc=true` in `project.godot` (universal build demands ETC2 ASTC enabled).
|
- macOS export required `rendering/textures/vram_compression/import_etc2_astc=true` in `project.godot` (universal build demands ETC2 ASTC enabled).
|
||||||
- Joke Shop `door_staff_lines` authored but not yet surfaced in the prototype UI (tracked in TODO).
|
- Joke Shop `door_staff_lines` are logged through `GameManager` (inspect / wand / refuse). They are not a separate dialogue UI.
|
||||||
|
- Access loadout is implemented in the working tree (see Current State); do not re-fix `override_access` as if it were still unread.
|
||||||
|
- `Save.SETTINGS_FILE` is declared and unused; no mouse-sensitivity or pause menu yet.
|
||||||
|
- Headless suite is **77/77**; the windowed focus/radial smoke passes. Remaining visual follow-ups are at the top of `TODO.md`.
|
||||||
|
- Typed spawn signal extra args: handlers must accept the 4th `event: Dictionary` or Godot will not call them (`test_shift_coordinator_runs_a_shift` failed until `_on_spawn` took four args).
|
||||||
|
- GDScript inference trap that broke the main scene: a `var` typed as a base class
|
||||||
|
(`radial: CanvasLayer`) exposes script members as `Variant`, so `var x := radial.is_open` is a
|
||||||
|
parse error. Type it as the script type (`ActionRadial`) or annotate the local explicitly.
|
||||||
|
`--check-only --script` cannot see the `Save` autoload; use a real boot (`--quit-after 180`) to
|
||||||
|
verify a full-scene compile.
|
||||||
|
- Do not snapshot right after changing a transform in the same frame: `capture_screenshots.gd`
|
||||||
|
needs a state/frame gap or the capture uses the old camera orientation.
|
||||||
|
- `EventScheduler` probabilities are per simulated second, not per call. Never poll it per frame.
|
||||||
|
- Screenshot staging lives in `src/test/capture_screenshots.gd` (guard at z≈10.5 facing the door).
|
||||||
|
`docs/screenshots/` copies are manual: copy from `user://screenshots/`.
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
|
- Add a vision-model (LLM) judge on top of the screen-design comparison: pixel and
|
||||||
|
perceptual metrics cannot decide whether a screenshot's composition is correct.
|
||||||
- Whether the responder avatar should be playable/controllable or purely scripted.
|
- Whether the responder avatar should be playable/controllable or purely scripted.
|
||||||
- Localization target languages beyond English.
|
- Localization target languages beyond English.
|
||||||
- macOS notarization approach for broad distribution.
|
- macOS notarization approach for broad distribution.
|
||||||
|
|
||||||
## Plan vs TODO Gaps (evaluated 2026-10-31, all resolved)
|
## Plan vs code (re-evaluated)
|
||||||
|
|
||||||
All previously identified gaps are now implemented and covered by tests/smoke items: PROTESTER archetype, door lock/secure, flashlight, CALL_SOMEONE reaction, LATE phase fix, shift-result persistence, NavigationAgent3D movement, and JSON-backed localization. Remaining backlog lives in `TODO.md`.
|
On local `main` (6 commits not on origin): v0.2.1 packages, ADRs 0007–0010, asset pipeline wired to
|
||||||
|
`assets/textures/ai/game_ready/`, screenshots refreshed.
|
||||||
|
|
||||||
|
Uncommitted: `AccessLoadout`, control pass (cone, ring, radial, NPC face, named input actions),
|
||||||
|
and boot/spawn fixes. Headless 77/77; windowed smoke passes. Access-artifact and control-pass
|
||||||
|
sections moved to `DONE.md`.
|
||||||
|
|
||||||
|
Still in `TODO.md`: world follow-ups (guard float, step colliders, skyline asset, marquee size);
|
||||||
|
session shell; leftover PLAN verbs; crowd rubberneck; emergency aftermath; body-cam replay;
|
||||||
|
sit/photo/film poses; real crowd audio; Joke Shop/Studio extra events; PLAN.md says GUT/GitHub.
|
||||||
|
|
||||||
<!-- b64: RWFzdGVyIGVnZzogYSByYXJlIHJhZGlvIHRyYW5zbWlzc2lvbiAiwr9Ew7NuZGUgZXN0w6EgbGEgbW90YT8iIGZpcmVzIHZpYSBHYW1lTWFuYWdlci5fbWF5YmVfcmFkaW9fZWFzdGVyX2VnZyAoMi41JSBjaGFuY2UgYWZ0ZXIgYSBzdWNjZXNzZnVsIHJhZGlvIGNhbGwpLiBTdHJpbmcga2V5IHJhZGlvLmVhc3Rlcl9lZ2cgaW4gY29udGVudC9zdHJpbmdzLmVuLmpzb24uIERvIG5vdCByZW1vdmUu -->
|
<!-- b64: RWFzdGVyIGVnZzogYSByYXJlIHJhZGlvIHRyYW5zbWlzc2lvbiAiwr9Ew7NuZGUgZXN0w6EgbGEgbW90YT8iIGZpcmVzIHZpYSBHYW1lTWFuYWdlci5fbWF5YmVfcmFkaW9fZWFzdGVyX2VnZyAoMi41JSBjaGFuY2UgYWZ0ZXIgYSBzdWNjZXNzZnVsIHJhZGlvIGNhbGwpLiBTdHJpbmcga2V5IHJhZGlvLmVhc3Rlcl9lZ2cgaW4gY29udGVudC9zdHJpbmdzLmVuLmpzb24uIERvIG5vdCByZW1vdmUu -->
|
||||||
@@ -432,13 +432,15 @@ The venue-driven architecture maps onto Godot as follows:
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- Unit and scenario tests use the GUT (Godot Unit Test) framework.
|
- Unit and scenario tests use a lightweight custom headless runner
|
||||||
|
(`src/test/test_runner.gd`). GUT was evaluated but requires the editor plugin.
|
||||||
- Tests run in headless mode so rules, access verification, escalation, and scoring can be verified without rendering or user input.
|
- Tests run in headless mode so rules, access verification, escalation, and scoring can be verified without rendering or user input.
|
||||||
|
|
||||||
## Version Control and CI
|
## Version Control and CI
|
||||||
|
|
||||||
- Source is tracked with git.
|
- Source is tracked with git.
|
||||||
- GitHub Actions runs headless Godot tests and, in later stages, exports desktop builds.
|
- Gitea Actions (`.gitea/workflows/build.yml`) runs headless Godot tests on push/PR and exports
|
||||||
|
Linux/Windows/macOS desktop builds plus the NSIS installer and AppImage on tags.
|
||||||
|
|
||||||
## Planned Commands
|
## Planned Commands
|
||||||
|
|
||||||
@@ -446,12 +448,148 @@ The following commands apply once the Godot project is scaffolded:
|
|||||||
|
|
||||||
- Open the editor: `godot --editor --path .`
|
- Open the editor: `godot --editor --path .`
|
||||||
- Run the game: `godot --path .`
|
- Run the game: `godot --path .`
|
||||||
- Run tests headlessly via GUT.
|
- Run tests headlessly: `godot --headless --path . res://src/test/test_runner.tscn`
|
||||||
|
|
||||||
## Export Targets
|
## Export Targets
|
||||||
|
|
||||||
Desktop is the first target: Windows, Linux, and macOS. Web export is a later option.
|
Desktop is the first target: Windows, Linux, and macOS. Web export is a later option.
|
||||||
|
|
||||||
|
## Player Control, Focus, and Action Menu (next pass)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### What is wrong today
|
||||||
|
|
||||||
|
- `W` is walk-forward in the input map and wand-screening in `GameManager._handle_key`. Walking at someone with a wand venue fires the wand.
|
||||||
|
- `A` / `D` are strafe and also allow/deny during inspection.
|
||||||
|
- `Q` is polite direction and is also bound as `cancel`.
|
||||||
|
- Focus is an 8 m physics ray from the camera. Missing a capsule by a few pixels clears the target. There is no mark in the world that you have someone.
|
||||||
|
- NPC bodies are colored capsules with props. They read as markers, not people.
|
||||||
|
- Actions live on number keys `1`–`6` plus `Q` / `E` / `W` / `R`. That is a cheat-sheet, not a verb you pick while looking at a person.
|
||||||
|
- `Esc` only toggles mouse capture. There is no "I changed my mind" for an action in progress.
|
||||||
|
|
||||||
|
### Movement and look
|
||||||
|
|
||||||
|
Keep first-person WASD + mouse look. Fix the feel and stop stealing those keys.
|
||||||
|
|
||||||
|
- Body yaw from mouse X, head pitch from mouse Y, pitch clamped (already `-80` to `80`).
|
||||||
|
- Move relative to body yaw only (already). Add short acceleration / deceleration so starts and stops are not instant.
|
||||||
|
- Gravity only when `not is_on_floor()`; `floor_snap_length` so the steps work.
|
||||||
|
- Mouse look runs only while `MOUSE_MODE_CAPTURED` and no modal UI is open (radial, inspection, wand, radio, shift review).
|
||||||
|
- Walk speed stays around 3.2 m/s. No sprint, no crouch in this pass.
|
||||||
|
- While the radial is open: freeze look and WASD so `A` / `D` can cycle the menu. Recapture the mouse when the menu closes.
|
||||||
|
- `Esc` with a modal open closes that modal and does nothing else. `Esc` with nothing open keeps the current capture/release toggle.
|
||||||
|
|
||||||
|
Equipment stays on its own keys because it is not about a person: `G` flashlight, `L` door lock, `Tab` cycle nearby people, `V` venue (debug), `F` debug overlay. `R` stays the dedicated emergency radio (hold-to-confirm on the existing panel). Those do not go on the wheel.
|
||||||
|
|
||||||
|
Wand leaves `W`. It is a slice on the wheel (greyed when the venue has no wand) and an optional extra key that is not WASD, suggested `C`.
|
||||||
|
|
||||||
|
### Focus area
|
||||||
|
|
||||||
|
Replace the hairline ray as the primary picker.
|
||||||
|
|
||||||
|
- A **focus cone** in front of the camera: people within about 10 m whose head projects inside a screen-space circle around the crosshair (about 90 px / 10 degrees).
|
||||||
|
- If several heads sit in the circle, pick the one closest to the center, then the nearer one if tied.
|
||||||
|
- The person must be in front (dot with camera forward > 0) and not fully occluded by the building. A short ray to the head is enough for occlusion.
|
||||||
|
- `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
|
||||||
|
|
||||||
|
When a person is in focus, draw a thin ring around their head.
|
||||||
|
|
||||||
|
- Attach to the avatar `Head` node, sitting just above the skull, 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Round action HUD
|
||||||
|
|
||||||
|
A doughnut / pie menu centered on the screen, opened on the focused person.
|
||||||
|
|
||||||
|
**Open**
|
||||||
|
|
||||||
|
- Left mouse button, or `Space`, while someone is in focus and no other modal is open.
|
||||||
|
- If nobody is in focus, log the existing "aim first" line and do not open.
|
||||||
|
- The click that opens the menu must not also pick a slice.
|
||||||
|
|
||||||
|
**While open**
|
||||||
|
|
||||||
|
- Mouse becomes visible. Look and walk freeze.
|
||||||
|
- Center of the doughnut shows the focused person's `display_name`.
|
||||||
|
- Slices, clockwise from the top, de-escalation first:
|
||||||
|
1. Raise hand (stop / wait)
|
||||||
|
2. Point away
|
||||||
|
3. Head shake
|
||||||
|
4. Polite direction (old `Q`)
|
||||||
|
5. Inspect access (old `E`)
|
||||||
|
6. Wand (grey if the venue has no wand)
|
||||||
|
7. Formal warning
|
||||||
|
8. Block
|
||||||
|
9. Radio assist
|
||||||
|
- Emergency is **not** on the wheel (`R` only).
|
||||||
|
- Highlight follows the mouse angle when the cursor is over the ring. `Left` / `Right` or `A` / `D` step the highlight. Wrap around.
|
||||||
|
|
||||||
|
**Confirm**
|
||||||
|
|
||||||
|
- Click a slice: that action runs, menu closes, mouse recaptured.
|
||||||
|
- `Space` while a slice is highlighted: same.
|
||||||
|
- Inspect or wand: close the wheel, then open the existing inspect / wand panel. Allow / deny on that panel stay `A` / `D` (walk is frozen only while the wheel is up; once the inspect panel is open, WASD can move again, so allow/deny on `A` / `D` remains a press handler and must ignore repeats / echo, which it already does). Safer: keep walk frozen for inspect/wand too, until the panel closes. Prefer freeze until the whole interaction ends.
|
||||||
|
- Radio assist: close the wheel, open the existing radio panel.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### NPC display
|
||||||
|
|
||||||
|
Stay on the procedural capsule rig (no Mixamo / GLB dependency this pass). Make them read as people at conversation distance.
|
||||||
|
|
||||||
|
- Neck between torso and head; shoes; simple hair cap (color from seed); two dark eye spheres on the face so a head has a front.
|
||||||
|
- Keep archetype props (camera, box, hat, sign, bag). Keep height / brightness variation from the visual seed.
|
||||||
|
- Collision capsule matches the scaled height so the focus ray / cone hits the body you see.
|
||||||
|
- `Head` stays a named node other systems can find (`get_node("Rig/Head")`) for the ring and the cone.
|
||||||
|
- Walk bob and the "look at the guard when noticed" cue stay.
|
||||||
|
- Do not put the internal type name (`Trespasser`, `Violent Threat`) on the body. The player should judge from clothes, props, and behavior. The debug overlay (`F`) can still print the type.
|
||||||
|
|
||||||
|
AI textures in `assets/textures/ai/` stay out of this pass unless a specific map (shirt, hair) is already the right size.
|
||||||
|
|
||||||
|
### Input map (target)
|
||||||
|
|
||||||
|
| Action | Bind |
|
||||||
|
| --- | --- |
|
||||||
|
| Walk | WASD / arrows (arrows **do not** walk while the wheel is open; they step the highlight) |
|
||||||
|
| Look | Mouse, captured |
|
||||||
|
| Open / confirm action menu | LMB, `Space` |
|
||||||
|
| Cancel menu | RMB, `Esc` |
|
||||||
|
| Step highlight | `A` / `D`, left / right arrows |
|
||||||
|
| Emergency radio | `R` |
|
||||||
|
| Flashlight | `G` |
|
||||||
|
| Door lock | `L` |
|
||||||
|
| Cycle nearby people | `Tab` |
|
||||||
|
| Wand shortcut | `C` (not `W`) |
|
||||||
|
| Inspect shortcut | `E` still allowed when the wheel is closed |
|
||||||
|
|
||||||
|
### Files this pass is expected to touch
|
||||||
|
|
||||||
|
- `src/game/guard_controller.gd` — cone focus, no look while modal, accel, floor snap.
|
||||||
|
- `src/game/npc_avatar.gd` — face / hair / neck, head marker, ring toggle.
|
||||||
|
- `src/game/ui/action_radial.gd` — new Control, `_draw` doughnut, slice pick.
|
||||||
|
- `src/game/ui/hud.gd` — host the radial; stop teaching number-key verbs as the only path.
|
||||||
|
- `src/game/game_manager.gd` — input routing, freeze flags, open/confirm/cancel.
|
||||||
|
- `project.godot` — input map.
|
||||||
|
- `src/core/strings.gd` — controls line, aim-first, no new tone-of-voice.
|
||||||
|
- `src/test/tests/test_focus_and_radial.gd` — cone pick, wrap, cancel emits nothing, `W` is not wand.
|
||||||
|
- README controls table and smoke checklist.
|
||||||
|
|
||||||
## Design Direction
|
## Design Direction
|
||||||
|
|
||||||
The game should feel like a tense workplace simulation with occasional crisis moments. The most satisfying outcome is not overpowering an NPC; it is recognizing the situation early and resolving it calmly.
|
The game should feel like a tense workplace simulation with occasional crisis moments. The most satisfying outcome is not overpowering an NPC; it is recognizing the situation early and resolving it calmly.
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ The central design goal is simple:
|
|||||||
|
|
||||||
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
|
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
|
||||||
|
|
||||||
> **Latest: v0.2.0** (2026-10-31) — built and published by Gitea Actions from the
|
> **Latest: v0.2.9** — accessibility pass: grayscale-safe escalation meter and a working HUD font
|
||||||
> `v0.2.0` tag. Releases are built automatically when a `vX.Y.Z` tag is pushed
|
> scale.
|
||||||
|
> Releases are built automatically when a `vX.Y.Z` tag is pushed
|
||||||
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
|
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
|
||||||
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
|
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
|
||||||
> (NSIS + appimagetool) and uploads with the portable binaries.
|
> (NSIS + appimagetool) and uploads with the portable binaries.
|
||||||
@@ -136,11 +137,82 @@ The architecture maps onto Godot 4 nodes, scenes, and custom resources as descri
|
|||||||
- **Navigation:** `NavigationRegion3D` and `NavigationAgent3D`
|
- **Navigation:** `NavigationRegion3D` and `NavigationAgent3D`
|
||||||
- **NPC behavior:** Custom lightweight finite state machines driven by `NpcProfile` resources
|
- **NPC behavior:** Custom lightweight finite state machines driven by `NpcProfile` resources
|
||||||
- **UI and audio:** Godot `Control` nodes, themes, audio buses, and `AudioStreamPlayer` nodes
|
- **UI and audio:** Godot `Control` nodes, themes, audio buses, and `AudioStreamPlayer` nodes
|
||||||
- **Art assets:** authored PNGs in `assets/textures/` (≤1024px, power-of-two) produced by `tools/generate_textures.py` and imported by Godot; structural geometry stays procedural (`WorldBuilder`). The same script can generate textures through the OpenRouter image API (`--ai`, requires `OPENROUTER_API_KEY`).
|
- **Art assets:** authored PNGs and the in-game generated set in `assets/textures/ai/game_ready/`; structural geometry stays procedural (`WorldBuilder`). The legacy `tools/generate_textures.py --ai` batch remains available.
|
||||||
|
- **Asset tooling:** `tools/asset_pipeline.py` generates role-aware assets through OpenRouter and provides crop, resize, seamless-tile, and image-audit commands, including gameplay screenshot checks.
|
||||||
- **Testing:** GUT (Godot Unit Test) in headless mode
|
- **Testing:** GUT (Godot Unit Test) in headless mode
|
||||||
- **Source control:** git on a Gitea host with Gitea Actions (`.gitea/workflows/build.yml`) for headless tests and desktop release builds
|
- **Source control:** git on a Gitea host with Gitea Actions (`.gitea/workflows/build.yml`) for headless tests and desktop release builds
|
||||||
- **Targets:** Windows, Linux, and macOS desktop first; web later
|
- **Targets:** Windows, Linux, and macOS desktop first; web later
|
||||||
|
|
||||||
|
### Image Asset Tools
|
||||||
|
|
||||||
|
Install the Pillow dependency used by the existing texture generator and the asset tools:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m pip install -r requirements-tools.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate a role-sized image (generation requires `OPENROUTER_API_KEY`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/asset_pipeline.py generate --prompt "weathered red brick" --role tile --output assets/textures/brick_extra.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Generated images are resized to the selected role profile. Non-square requests use
|
||||||
|
the validated `openai/gpt-5-image-mini` source ratio and crop to the final role dimensions.
|
||||||
|
Transparent glyphs can be recolored while preserving their alpha mask with
|
||||||
|
`--foreground '#ffffff'`.
|
||||||
|
|
||||||
|
Crop uses the image center by default; `--x` and `--y` select an explicit crop origin. Resize defaults to aspect-preserving `contain`; use `--fit cover` to crop or `--fit stretch` to distort to the exact dimensions.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/asset_pipeline.py crop assets/textures/source.png assets/textures/crop.png --width 256 --height 128
|
||||||
|
python3 tools/asset_pipeline.py resize assets/textures/source.png assets/images/backgrounds/scene.png --width 1920 --height 1080 --fit cover
|
||||||
|
python3 tools/asset_pipeline.py seamless assets/textures/rough_tile.png assets/textures/tile.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Audit accepts image files or directories. `--role auto` infers common roles from paths and filenames; explicit roles include `tile`, `texture`, `icon`, `card`, `banner`, `backdrop`, `background`, `overlay`, `app_icon`, and `screenshot`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/asset_pipeline.py audit assets/textures
|
||||||
|
python3 tools/asset_pipeline.py audit assets/images/backgrounds --role background --json
|
||||||
|
python3 -m unittest discover -s tools -p 'test_*.py'
|
||||||
|
```
|
||||||
|
|
||||||
|
The audit classifies measurable fit issues as `good` or `bad` based on role-specific dimensions, aspect ratio, alpha requirements, and tile-edge continuity. It lists perspective/projection as a visual review item because image semantics cannot be judged reliably by these deterministic checks.
|
||||||
|
|
||||||
|
### Screen design references
|
||||||
|
|
||||||
|
Screen mockups and screenshot comparison use `tools/screen_designs.py`. It turns the
|
||||||
|
screen descriptions in `docs/` (README captions, ADRs 0007–0010, `PLAN.md`, and
|
||||||
|
`architecture.md`) into reference images under `docs/screens/designs/`, then uses
|
||||||
|
those images as the expected state to check the game's captures.
|
||||||
|
|
||||||
|
Each screen is tagged `scene` (a realistic first-person 3D world view) or `ui`
|
||||||
|
(an in-game panel overlaid on a softened scene), and the generator appends a
|
||||||
|
shared art-direction suffix so the frames read as one coherent game. Requests use
|
||||||
|
a 3:2 landscape source and crop to the 16:9 capture dimensions.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Deterministic wireframes (offline, no API key)
|
||||||
|
python3 tools/screen_designs.py generate --draft
|
||||||
|
|
||||||
|
# Realistic 3D scenes and in-game screen designs via OpenRouter
|
||||||
|
# (requires OPENROUTER_API_KEY)
|
||||||
|
python3 tools/screen_designs.py generate
|
||||||
|
|
||||||
|
# Compare docs/screenshots/*.png against the designs
|
||||||
|
python3 tools/screen_designs.py compare
|
||||||
|
python3 tools/screen_designs.py compare --json
|
||||||
|
```
|
||||||
|
|
||||||
|
`compare` flags gross drift: wrong aspect ratio, undersized frame, near-blank
|
||||||
|
frame, and large layout/palette/detail distance. It is a regression gate, not a
|
||||||
|
semantic judge — pixel and perceptual metrics cannot decide whether a frame's
|
||||||
|
composition is *right*, so a vision-model pass is the documented follow-up (see
|
||||||
|
`TODO.md`). Regenerate the designs after changing screen layout, then re-run
|
||||||
|
`compare` after regenerating the game screenshots with
|
||||||
|
`src/test/capture_screenshots.gd`.
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Download the Godot 4.3 editor (official build) and put `godot` on your PATH.
|
1. Download the Godot 4.3 editor (official build) and put `godot` on your PATH.
|
||||||
@@ -163,6 +235,27 @@ godot --headless --path . --script res://src/test/test_runner.gd
|
|||||||
|
|
||||||
# Save a screenshot to user:// (windowed)
|
# Save a screenshot to user:// (windowed)
|
||||||
godot --path . --script res://src/test/take_screenshot.gd
|
godot --path . --script res://src/test/take_screenshot.gd
|
||||||
|
|
||||||
|
# Windowed control smoke: focus ring + 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)
|
||||||
|
godot --headless --path . --script res://src/test/smoke_session_shell.gd
|
||||||
|
|
||||||
|
# PLAN-verb smoke: recognize, request reason, body camera, warning, blocking
|
||||||
|
godot --headless --path . --script res://src/test/smoke_plan_verbs.gd
|
||||||
|
|
||||||
|
# Crowd smoke: rubbernecking on aggression, panic clearing the entrance
|
||||||
|
godot --headless --path . --script res://src/test/smoke_crowd.gd
|
||||||
|
|
||||||
|
# Emergency smoke: unjustified call, investigation note, evacuation aftermath
|
||||||
|
godot --headless --path . --script res://src/test/smoke_emergency.gd
|
||||||
|
|
||||||
|
# Body-camera timeline smoke: review → timeline → step through → back to review
|
||||||
|
godot --headless --path . --script res://src/test/smoke_timeline.gd
|
||||||
|
|
||||||
|
# Audio smoke: crowd bed follows mood, radio open/close crackle
|
||||||
|
godot --headless --path . --script res://src/test/smoke_audio.gd
|
||||||
```
|
```
|
||||||
|
|
||||||
The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor.
|
The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor.
|
||||||
@@ -172,19 +265,25 @@ The unit test runner is the project's own lightweight harness (no editor plugin
|
|||||||
| Input | Action |
|
| Input | Action |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| W/A/S/D + mouse | Move and look |
|
| W/A/S/D + mouse | Move and look |
|
||||||
| Esc | Release / capture mouse |
|
| Space / left click | Action wheel for the focused person (Stop, Look, Point, No, Ask, Inspect, Wand, Warn, Block, Reason, Recognize, Radio) |
|
||||||
|
| A / D or ←/→ | Step through the action wheel (while it is open) |
|
||||||
|
| Space / left click | Confirm the highlighted action (while the wheel is open) |
|
||||||
|
| Esc / right click | Close the top panel; Esc with nothing open pauses the shift (Esc again resumes) |
|
||||||
|
| ← / → or ↑ / ↓ | Navigate the venue menu and the pause / settings panels |
|
||||||
|
| Enter | Start the highlighted venue from the menu, or confirm in a panel |
|
||||||
|
| 1 / 2 / 3 | Quick gestures without the wheel: raise hand, point, head shake (Level 1) |
|
||||||
| Q | Polite direction (Level 1) |
|
| Q | Polite direction (Level 1) |
|
||||||
| 1 / 2 / 3 | Simple gestures: raise hand, point, head shake (Level 1) |
|
|
||||||
| 4 | Formal warning (Level 2) |
|
| 4 | Formal warning (Level 2) |
|
||||||
| 5 | Blocking and positioning (Level 3) |
|
| 5 | Blocking and positioning (Level 3) |
|
||||||
| 6 | Security assistance radio (Level 4) |
|
| 6 | Security assistance radio (Level 4) |
|
||||||
| R | Emergency radio, hold on-screen button ~1.6s (Level 5) |
|
| R | Emergency radio, hold on-screen button ~1.6s (Level 5) |
|
||||||
| E | Inspect access artifacts / close inspection |
|
| E | Inspect access artifacts / close inspection |
|
||||||
| W | Handheld wand screening (wand venues only) |
|
| C | Handheld wand screening (wand venues only) |
|
||||||
| A / D | Allow / deny entry during inspection or wand check |
|
| A / D | Allow / deny entry during inspection or wand check |
|
||||||
| V | Cycle venue: Hollywood Blvd → The Joke Shop → TV Studio (restarts shift) |
|
| V | Cycle venue (debug shortcut; restart via V restarts the current shift) |
|
||||||
| Tab | Cycle target among nearby NPCs (aim-free alternative) |
|
| Tab | Cycle target among nearby NPCs (aim-free alternative) |
|
||||||
| G | Toggle flashlight (widens NPC perception at night) |
|
| G | Toggle flashlight (widens NPC perception at night) |
|
||||||
|
| B | Toggle the body camera; a formal warning switches it on automatically |
|
||||||
| L | Lock / unlock the restricted door (thwarts forced entry) |
|
| L | Lock / unlock the restricted door (thwarts forced entry) |
|
||||||
| F | Toggle debug overlay |
|
| F | Toggle debug overlay |
|
||||||
|
|
||||||
@@ -209,28 +308,63 @@ Implemented so far:
|
|||||||
- Godot 4.3 project scaffold, input map, procedural world builder
|
- Godot 4.3 project scaffold, input map, procedural world builder
|
||||||
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
|
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
|
||||||
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
|
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
|
||||||
- Headless unit tests: **52 passing** (access rules, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, navmesh, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings)
|
- Headless unit tests: **139 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)
|
||||||
- Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event
|
- Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event
|
||||||
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
|
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
|
||||||
|
- Control pass: focus cone on NPC heads with a billboarded gold ring and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`)
|
||||||
|
- 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
|
||||||
|
- 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)
|
||||||
|
- Crowd presentation: aggressive behavior and responder arrivals make idle bystanders stop and face the incident; panic sends people away from the door (and they leave the scene). A busy 16-NPC entrance measured ~1.2 ms/frame, so no LOD/culling was needed at the current cap
|
||||||
|
- Distant skyline ring: four alpha-scissored planes at 200 m read as a hazy city on the horizon (the baked backdrop used to show as a black slab), with a larger ground plane so the buildings meet the street
|
||||||
|
- Flashlight aims straight ahead and lights the person/door being checked instead of the ground in front of the guard
|
||||||
|
- Emergency aftermath: the `evacuation` outcome locks the door and clears the entrance, an emergency call that the situation did not justify opens an investigation and applies the false-report hit, and the shift review names the responder outcome and any investigations
|
||||||
|
- Body-camera timeline: the shift review opens a text timeline of every recorded incident event (time, person, action/outcome) that you step through with the arrow keys or buttons. No live 3D replay yet
|
||||||
|
- NPC theater: seated guests sit on the steps with bent legs, influencers and tourists raise a camera prop while photographing, performers sway their sign, and filming visitors pan slowly — all once they reach their intent's waypoint
|
||||||
|
- Audio: a looping procedural crowd bed (filtered murmur) whose volume follows `CrowdSystem.mood`, plus radio open/close crackle. All generated at runtime, no audio assets, and safe with a dummy audio server
|
||||||
|
- 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)
|
||||||
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
|
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
|
||||||
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
|
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
|
||||||
|
|
||||||
Not yet implemented: body-camera replay timeline, venue selection startup menu, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
|
Not yet implemented: live 3D body-camera replay, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
|
||||||
|
|
||||||
### Smoke Test Checklist
|
### Smoke Test Checklist
|
||||||
|
|
||||||
1. `godot --path .` starts the game without script errors.
|
1. `godot --path .` starts the game on the venue menu without script errors.
|
||||||
2. The guard moves and looks around; the building, steps, and sidewalk are visible.
|
2. Choosing a venue (click or ↑/↓ + Enter) starts that venue's shift and hides the menu.
|
||||||
3. NPCs spawn and approach the entrance during the first minutes.
|
3. The guard moves and looks around; the building, steps, and sidewalk are visible.
|
||||||
4. Aiming at an NPC shows its archetype and intent in the HUD.
|
4. The guard's camera sits at eye height and the guard stands on the landing, not inside the building.
|
||||||
5. Pressing `Q` produces a reaction log entry and reputation feedback.
|
5. Walking into the steps carries the guard up onto the landing (steps have colliders).
|
||||||
6. `E` opens access inspection showing artifacts and policy result; `A`/`D` record a decision.
|
6. The skyline backdrop shows silhouettes against the sky, not an opaque black slab.
|
||||||
7. `6` opens the assistance form; submitting shows dispatch feedback.
|
7. The `STEP LINE` marquee is readable from the sidewalk.
|
||||||
8. `R` opens the emergency form; the button must be held ~1.6s or the call cancels.
|
8. NPCs spawn and approach the entrance during the first minutes.
|
||||||
9. `G` toggles the flashlight (indicator appears in the HUD); `L` toggles the door lock (indicator turns red when locked).
|
9. Looking at an NPC inside the focus cone shows a gold head ring, the person's name, and the HUD target line.
|
||||||
10. `F` toggles the debug overlay with NPC states and shift stats.
|
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. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run).
|
11. `E`/`C` open the inspection/wand panels from the wheel and freeze movement until they close; walking never fires the wand.
|
||||||
12. When the shift timer reaches 0, the shift review screen shows grade, incident summary, and persisted shift history.
|
12. Pressing `Q` produces a reaction log entry and reputation feedback.
|
||||||
|
13. `E` opens access inspection showing venue-correct artifacts and policy result; `A`/`D` record a decision.
|
||||||
|
14. `6` opens the assistance form; submitting shows dispatch feedback.
|
||||||
|
15. `R` opens the emergency form; the button must be held ~1.6s or the call cancels.
|
||||||
|
16. `G` toggles the flashlight (indicator appears in the HUD); `L` toggles the door lock (indicator turns red when locked).
|
||||||
|
17. `F` toggles the debug overlay with NPC states and shift stats.
|
||||||
|
18. The wheel's `Look` slice is eye contact (Level 1) and logs as its own action.
|
||||||
|
19. `Reason` asks why the person is there and logs their `dialogue_lines` reply.
|
||||||
|
20. `Recognize` records the guard's recognition as access evidence; pressing it twice says so.
|
||||||
|
21. `Warn` turns the body camera on (REC overlay appears); `B` toggles it manually.
|
||||||
|
22. `Block` moves the guard between the person and the door.
|
||||||
|
23. The shift review reports the incident count, including interactions left unresolved.
|
||||||
|
24. Escalating on someone (or a responder arriving) makes idle bystanders stop and face the incident.
|
||||||
|
25. Repeated alarms panic the crowd and nearby people clear away from the door instead of standing around.
|
||||||
|
26. `Esc` with nothing open pauses the shift; resume / restart / settings / quit-to-menu all work, and settings changes persist across a restart.
|
||||||
|
27. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run).
|
||||||
|
28. An unjustified emergency call (calm person, no weapon) logs an investigation and costs reputation.
|
||||||
|
29. After an evacuation outcome the door is locked and NPCs are leaving the entrance.
|
||||||
|
30. The shift review's "Body camera timeline" button steps through the recorded events (Left/Right or the buttons) and Esc returns to the review.
|
||||||
|
31. 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
|
## Development Workflow
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +1,47 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
## 🎨 AI texture generation (OpenRouter)
|
Godot 4.3 prototype. Release **v0.2.9** is tagged. Headless suite **139/139**, plus the session,
|
||||||
|
PLAN-verb, crowd, emergency, timeline, audio, and windowed focus/radial smokes.
|
||||||
|
|
||||||
- [x] Extend `tools/generate_textures.py` with an OpenRouter image-API mode
|
Host `REQUIRE_SIGNIN_VIEW` stays deferred.
|
||||||
(`--ai`, `--prompt`, `--name`, `--model`, `--size`), decoding base64 PNG
|
|
||||||
responses and clamping output to ≤1024px.
|
|
||||||
- [x] Add prompts for all 15 game textures (world surfaces, awning, skyline,
|
|
||||||
vignette, 4 HUD icons, 5 access cards) and generate them under `assets/textures/ai/`.
|
|
||||||
- [ ] Make the `ai/` icons/vignette/skyline truly transparent: seedream-4.5 ignores
|
|
||||||
`background: transparent` (returns opaque images). Either switch to a
|
|
||||||
transparency-capable model or post-process (e.g. chroma-key the flat background).
|
|
||||||
|
|
||||||
## 🪟 Windows installer (self-contained, all game data)
|
## Scenario content still missing vs PLAN lists
|
||||||
|
|
||||||
Goal: a real Windows installer that includes the entire game (executable + data),
|
- [ ] Joke Shop: guest on the reservation list without ID; borrowed/mismatched ID; guest
|
||||||
Start-Menu/desktop shortcuts, and an uninstaller — instead of a bare .exe + .pck.
|
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.
|
||||||
|
|
||||||
- [x] Switch the Windows/Linux/macOS export presets to `binary_format/embed_pck=true` so a
|
## Distribution follow-up (host config, deferred)
|
||||||
single self-contained binary replaces the exe+pck pair (all game data in one file).
|
|
||||||
- [x] Add a game icon: `assets/icon.png` (AppImage/desktop) + `assets/icon.ico` and
|
|
||||||
`packaging/windows/step-line.ico` generated by `tools/generate_textures.py`.
|
|
||||||
- [x] Author `packaging/windows/step-line.nsi` (NSIS): installer that installs the exe, writes
|
|
||||||
the uninstaller, shortcuts, and registry uninstall entry.
|
|
||||||
- [x] Add CI packaging steps to `.gitea/workflows/build.yml` (NSIS via apt; AppImage via
|
|
||||||
appimagetool) so tags/dispatch produce `step-line-setup-<ver>.exe` and
|
|
||||||
`step-line-<ver>-x86_64.AppImage` next to the portable binaries.
|
|
||||||
- [x] Extend `tools/publish_release.sh` artifact glob to upload the installer/AppImage too.
|
|
||||||
- [ ] Verify on CI: NSIS installer builds (cannot be run locally — no `makensis` on this box).
|
|
||||||
- [ ] Verify the Windows installer contents in CI (e.g. `7z l` shows the embedded data file).
|
|
||||||
|
|
||||||
## 🐧 Linux AppImage (self-contained, all game data)
|
Verified via API+host credentials (2026-10-31):
|
||||||
|
|
||||||
Goal: a single executable `.AppImage` that runs the full game on any recent distro.
|
- [x] Repo is **public** (`private:false`) and account has admin rights.
|
||||||
|
- [x] Release `v0.2.1` now carries all five artifacts (portable binaries + NSIS installer +
|
||||||
- [x] Author AppDir files: `packaging/linux/step-line.desktop`, `packaging/linux/AppRun`,
|
AppImage).
|
||||||
icon wiring.
|
- [ ] **Remaining host setting**: instance-wide `[service] REQUIRE_SIGNIN_VIEW=true` makes
|
||||||
- [x] Add `packaging/linux/build_appimage.sh` (appimagetool; `APPIMAGE_EXTRACT_AND_RUN=1` to
|
anonymous browsing/downloads redirect to login. Host admin: `REQUIRE_SIGNIN_VIEW = false`
|
||||||
avoid FUSE dependency) producing `step-line-<ver>-x86_64.AppImage`.
|
under `[service]` in `app.ini`, restart Gitea. Alternative: mirror downloads.
|
||||||
- [x] Local verification: built the AppImage on this machine and ran it headless
|
|
||||||
(`--appimage-extract-and-run --headless --quit-after 90` → 0 script errors,
|
|
||||||
`[Step Line] game started`).
|
|
||||||
- [x] AppStream metainfo added (`packaging/linux/step-line.appdata.xml`); tool validates clean.
|
|
||||||
- [ ] CI verification: AppImage step runs on tags/dispatch and the artifact uploads.
|
|
||||||
- [x] README Downloads table documents installer + AppImage rows and naming.
|
|
||||||
|
|
||||||
## Distribution follow-up (host config, no code work)
|
|
||||||
|
|
||||||
- [ ] Asset downloads currently require sign-in on the host (`/releases/download/…` redirects to
|
|
||||||
login). Decide with the host admin: open repo visibility / `REQUIRE_SIGNIN_VIEW=false` or
|
|
||||||
accept sign-in-required downloads. Pages and the tag page are already public (HTTP 200).
|
|
||||||
|
|
||||||
## Future Systems (design-level backlog)
|
|
||||||
|
|
||||||
- [ ] Body-camera replay timeline in the shift review.
|
|
||||||
- [ ] Venue selection startup menu (keyboard cycling with `V` is implemented).
|
|
||||||
- [ ] IDE/editor-based GUT integration when a Godot editor workflow is introduced.
|
|
||||||
|
|
||||||
## Open Polish
|
## Open Polish
|
||||||
|
|
||||||
- [ ] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
|
- [ ] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
|
||||||
|
|
||||||
## Detailed Graphics — remaining performance items
|
## Screen design references (QA tooling)
|
||||||
|
|
||||||
The visual pass is done (textures, rigs, HUD feedback — see `DONE.md`). Remaining work is
|
- [x] `tools/screen_designs.py generate` writes one reference design per screen
|
||||||
performance hardening only.
|
(from the `docs/` descriptions) to `docs/screens/designs/`, with an offline
|
||||||
|
`--draft` wireframe mode and an OpenRouter `--ai` mockup mode.
|
||||||
|
- [x] `tools/screen_designs.py compare` checks `docs/screenshots/*.png` against the
|
||||||
|
designs (aspect ratio, size, blank-frame, and layout/palette/detail drift).
|
||||||
|
- [ ] Vision-model judge for semantic screenshot comparison. Pixel and perceptual
|
||||||
|
metrics cannot decide whether a frame's composition is correct; a follow-up
|
||||||
|
should send the capture + reference design to a vision model and ask whether
|
||||||
|
they depict the same screen.
|
||||||
|
|
||||||
- [ ] Profile the crowd: ensure textured materials and props hold target frame rate around a
|
## Tooling (when an editor workflow exists)
|
||||||
busy entrance.
|
|
||||||
- [ ] Add LOD or culling for distant NPCs/props if the crowd grows.
|
|
||||||
|
|
||||||
## Screenshot Refresh
|
- [ ] IDE/editor-based GUT integration. Until then the custom headless runner stays.
|
||||||
|
- [ ] Optional later: author venues as `.tres` resources instead of `ScenarioFactory` functions.
|
||||||
- [ ] Regenerate and verify the six README screenshots from the current build.
|
|
||||||
|
After Width: | Height: | Size: 597 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 503 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 521 KiB |
|
After Width: | Height: | Size: 927 B |
|
After Width: | Height: | Size: 921 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -1,12 +1,24 @@
|
|||||||
{
|
{
|
||||||
"hud.controls": "Q polite direction · 1-3 simple gestures · 4 warning · 5 block · 6 assist · R emergency · E inspect / close · W wand · A allow · D deny · V venue · G flashlight · L lock door · F debug",
|
"hud.controls": "LMB/Space wheel · A/D step · Space confirm · Esc cancel · R emergency · E inspect · C wand · Q polite · B camera · Tab cycle · G light · L lock · F debug",
|
||||||
|
"radial.raise_hand": "Stop",
|
||||||
|
"radial.eye_contact": "Look",
|
||||||
|
"radial.point": "Point",
|
||||||
|
"radial.head_shake": "No",
|
||||||
|
"radial.polite": "Ask",
|
||||||
|
"radial.inspect": "Inspect",
|
||||||
|
"radial.wand": "Wand",
|
||||||
|
"radial.warning": "Warn",
|
||||||
|
"radial.block": "Block",
|
||||||
|
"radial.reason": "Reason",
|
||||||
|
"radial.recognize": "Recognize",
|
||||||
|
"radial.radio": "Radio",
|
||||||
"hud.escalation_prefix": "Escalation: %s",
|
"hud.escalation_prefix": "Escalation: %s",
|
||||||
"hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs",
|
"hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs",
|
||||||
"hud.aim_first": "Aim at someone first.",
|
"hud.aim_first": "Aim at someone first.",
|
||||||
"hud.no_one_nearby": "No one nearby.",
|
"hud.no_one_nearby": "No one nearby.",
|
||||||
"hud.targeting": "Targeting %s (Tab to cycle).",
|
"hud.targeting": "Targeting %s (Tab to cycle).",
|
||||||
"hud.no_wand": "No wand at this venue.",
|
"hud.no_wand": "No wand at this venue.",
|
||||||
"hud.screen_first": "Screen this guest with the wand (W) before admitting them.",
|
"hud.screen_first": "Screen this guest with the wand (C) before admitting them.",
|
||||||
"access.granted": "Entry granted: %s",
|
"access.granted": "Entry granted: %s",
|
||||||
"access.denied": "Entry denied: %s",
|
"access.denied": "Entry denied: %s",
|
||||||
"access.policy_satisfied": "policy satisfied",
|
"access.policy_satisfied": "policy satisfied",
|
||||||
@@ -23,6 +35,9 @@
|
|||||||
"radio.emergency_dispatched": "Emergency responder dispatched.",
|
"radio.emergency_dispatched": "Emergency responder dispatched.",
|
||||||
"radio.easter_egg": "¿Dónde está la mota?",
|
"radio.easter_egg": "¿Dónde está la mota?",
|
||||||
"responder.arrived": "Responder arrives and %ss the situation.",
|
"responder.arrived": "Responder arrives and %ss the situation.",
|
||||||
|
"responder.evacuated": "The entrance is cleared and the door is locked while the block is secured.",
|
||||||
|
"investigation.unjustified": "Emergency call against %s reviewed: no weapon, low danger.",
|
||||||
|
"investigation.started": "Your emergency call is flagged for investigation.",
|
||||||
"react.comply": "%s complies and steps back.",
|
"react.comply": "%s complies and steps back.",
|
||||||
"react.leave": "%s turns and leaves.",
|
"react.leave": "%s turns and leaves.",
|
||||||
"react.argue": "%s argues with you.",
|
"react.argue": "%s argues with you.",
|
||||||
@@ -33,6 +48,15 @@
|
|||||||
"react.return_later": "%s leaves but may come back.",
|
"react.return_later": "%s leaves but may come back.",
|
||||||
"react.call_someone": "%s calls someone for help.",
|
"react.call_someone": "%s calls someone for help.",
|
||||||
"react.none": "No reaction (yet).",
|
"react.none": "No reaction (yet).",
|
||||||
|
"bodycam.on": "Body camera on.",
|
||||||
|
"bodycam.off": "Body camera off.",
|
||||||
|
"bodycam.warning": "Formal warning recorded on the body camera.",
|
||||||
|
"recognition.declared": "You tell %s you recognize them. Logged as evidence.",
|
||||||
|
"recognition.already": "You have already recognized %s this shift.",
|
||||||
|
"reason.ask": "You ask %s why they are here.",
|
||||||
|
"reason.reply": "%s: \"%s\"",
|
||||||
|
"reason.silent": "%s says nothing and looks away.",
|
||||||
|
"station.blocking": "You step between %s and the door.",
|
||||||
"perception.near": "%s notices you standing close.",
|
"perception.near": "%s notices you standing close.",
|
||||||
"perception.aware": "%s notices you watching.",
|
"perception.aware": "%s notices you watching.",
|
||||||
"door.locked": "The door is now locked.",
|
"door.locked": "The door is now locked.",
|
||||||
@@ -41,5 +65,16 @@
|
|||||||
"flashlight.on": "Flashlight on.",
|
"flashlight.on": "Flashlight on.",
|
||||||
"flashlight.off": "Flashlight off.",
|
"flashlight.off": "Flashlight off.",
|
||||||
"hud.venue_switched": "Venue: %s",
|
"hud.venue_switched": "Venue: %s",
|
||||||
"wand.recharge": "Wand scanner is recharging…"
|
"wand.recharge": "Wand scanner is recharging…",
|
||||||
|
"review.responder": "Responder outcomes: %s",
|
||||||
|
"review.investigations": "Investigations: %s",
|
||||||
|
"review.none": "none",
|
||||||
|
"timeline.title": "Body Camera Timeline",
|
||||||
|
"timeline.position": "Event %d of %d",
|
||||||
|
"timeline.empty": "No recorded events this shift.",
|
||||||
|
"timeline.prev": "◀ Previous",
|
||||||
|
"timeline.next": "Next ▶",
|
||||||
|
"timeline.close": "Close",
|
||||||
|
"timeline.hint": "Left/Right step through events · Esc closes · no live replay yet",
|
||||||
|
"review.timeline_button": "Body camera timeline"
|
||||||
}
|
}
|
||||||
@@ -80,15 +80,22 @@ the NSIS installer requires `makensis` (covered by CI).
|
|||||||
|
|
||||||
The repository lives at `https://git.allucanget.biz/zwitschi/security-game`.
|
The repository lives at `https://git.allucanget.biz/zwitschi/security-game`.
|
||||||
|
|
||||||
Preferred path — use the helper script with a token:
|
Preferred path — use the helper script. It needs either a token or the same
|
||||||
|
credentials used by `git push` (it falls back to `~/.git-credentials`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
GITEA_TOKEN=your_api_token ./tools/publish_release.sh 0.1.0
|
GITEA_TOKEN=your_api_token ./tools/publish_release.sh 0.2.1
|
||||||
|
# or simply: ./tools/publish_release.sh 0.2.1 (uses ~/.git-credentials)
|
||||||
```
|
```
|
||||||
|
|
||||||
The script creates the release from `project.godot`'s `config/version`, uploads
|
The script creates (or reuses) the release from `project.godot`'s
|
||||||
all three `build/` artifacts, and prints the release URL. The token needs only
|
`config/version`, uploads the three portable artifacts **plus** the NSIS
|
||||||
`write:repository` scope on the Gitea host.
|
installer and AppImage when present, and prints the release URL. If CI already
|
||||||
|
published the release, re-running the script only adds missing assets.
|
||||||
|
|
||||||
|
> **Host note**: the release is fully built by CI, but anonymous users can only
|
||||||
|
> browse and download when the instance sets `[service] REQUIRE_SIGNIN_VIEW=false`
|
||||||
|
> in `app.ini` (the repo itself is public). See the TODO for the exact steps.
|
||||||
|
|
||||||
Manual alternative:
|
Manual alternative:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# ADR-0007: Locomotion and input layers
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** TBD
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The prototype is first-person: WASD walks, mouse looks, `Esc` toggles capture. Action keys were then bound on the same letters. `W` is walk-forward in the input map and wand screening in `GameManager._handle_key`. `A` / `D` strafe and also allow or deny during inspection. `Q` is polite direction and is also bound as `cancel`.
|
||||||
|
|
||||||
|
A player who walks toward a guest on a wand venue therefore fires the wand. A player who strafes during an inspection can admit or refuse by accident. The mouse cannot click UI while it is captured, and `Esc` cannot mean both "release the mouse" and "I changed my mind."
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Treat input as layers, only one of which owns a key at a time.
|
||||||
|
|
||||||
|
1. **Locomotion** (default): WASD walk relative to body yaw, mouse look on the captured cursor, pitch clamped. Short acceleration and deceleration. Gravity only when airborne; snap to the floor so the steps work. Walk speed stays around 3.2 m/s. No sprint or crouch in this pass.
|
||||||
|
2. **Modal UI** (radial, inspect, wand, radio, shift review): freeze walk and look. Recapture the mouse when the modal closes. `A` / `D` and arrows belong to the modal while it is open.
|
||||||
|
3. **Equipment and global keys** stay off the person-verb set: `G` flashlight, `L` door lock, `Tab` cycle nearby people, `R` emergency radio, `F` debug, `V` venue (debug). Wand leaves `W`; the optional shortcut is `C`.
|
||||||
|
|
||||||
|
`Esc` closes the top modal first and does nothing else. With no modal open it keeps the capture/release toggle.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- Walking cannot fire wand, inspect, or admission.
|
||||||
|
- The mouse can click the action menu because capture is released while it is open.
|
||||||
|
- Cancel has one meaning when a menu is up.
|
||||||
|
- Locomotion stays first-person, which matches the body-camera framing.
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
- Freeze-while-modal means the player cannot sidestep during an inspect. That is accepted for this pass.
|
||||||
|
- Power users lose `W` as wand. The wheel and `C` replace it.
|
||||||
|
- The input map and `_unhandled_input` must agree, or the conflict returns.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Keep letter-key verbs on WASD:** Rejected because walk and wand already collide in the prototype.
|
||||||
|
- **Third-person or click-to-move:** Rejected; the body-camera overlay and the "you are the guard" framing depend on first person.
|
||||||
|
- **Hold-right-mouse-to-look, free cursor otherwise:** Rejected for this pass. Constant look is the default security-sim stance; the menu is the exception.
|
||||||
|
- **Never freeze walk:** Rejected because `A` / `D` cannot both strafe and step a menu highlight.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# ADR-0008: Cone focus and head ring
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** TBD
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The prototype picks a person with an 8 m physics ray from the camera. The collider is a capsule. A few pixels off the body, or a bob during a walk, clears the target. The HUD then prints a name and hint, but nothing in the world shows who is selected. `Tab` already cycles people within 12 m as an aim-free path.
|
||||||
|
|
||||||
|
The next pass needs the player to look at a person, know they have them, and only then open an action menu. A hairline ray is too brittle for that, and a HUD line is easy to miss in a crowd.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Focus is a **cone**, not a ray.
|
||||||
|
|
||||||
|
- People within about 10 m whose **head** projects inside a screen-space circle around the crosshair (about 90 px / 10 degrees) are candidates.
|
||||||
|
- If several heads sit in the circle, pick the one closest to the center; if tied, the nearer one.
|
||||||
|
- The person must be in front of the camera. A short ray to the head skips people fully hidden by the building.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
The world must not show the internal type name (`Trespasser`, `Violent Threat`). The debug overlay (`F`) can still print it.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
- `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.
|
||||||
|
- 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.
|
||||||
|
- **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.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# ADR-0009: Radial action menu
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** TBD
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
NPC verbs in the prototype live on `Q`, `E`, `W`, `1`–`6`, and `R`. That is a cheat sheet. It also collides with walk keys (ADR-0007). The design wants the player to look at a person (ADR-0008) and then choose the lowest useful action, not memorize a number row.
|
||||||
|
|
||||||
|
Inspection, wand screening, and radio already have their own panels. Emergency radio already has a hold-to-confirm on `R` because a misclick is costly. Those panels should stay. What is missing is a single, visible way to pick the verb.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
When someone is in focus, **left mouse button** or **Space** opens a round doughnut menu in the center of the screen. The click that opens it does not pick a slice. If nobody is in focus, do not open; keep the existing "aim first" log line.
|
||||||
|
|
||||||
|
Slices, clockwise from the top, lowest escalation first:
|
||||||
|
|
||||||
|
1. Raise hand (stop / wait)
|
||||||
|
2. Point away
|
||||||
|
3. Head shake
|
||||||
|
4. Polite direction
|
||||||
|
5. Inspect access
|
||||||
|
6. Wand (grey if the venue has no wand)
|
||||||
|
7. Formal warning
|
||||||
|
8. Block
|
||||||
|
9. Radio assist
|
||||||
|
|
||||||
|
Emergency is **not** on the wheel. It stays on `R` with the existing hold-to-confirm panel.
|
||||||
|
|
||||||
|
While the menu is open, walk and look freeze (ADR-0007) and the cursor is visible. Highlight follows the mouse angle over the ring, or steps with `A` / `D` or the left / right arrows, wrapping around. The center shows the focused person's `display_name`.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- The player sees the verbs in de-escalation order instead of recalling a number row.
|
||||||
|
- Mouse and keyboard both work (click, or arrows / `A`/`D` then Space).
|
||||||
|
- Cancel is explicit and leaves no incident noise.
|
||||||
|
- Emergency cannot be picked by a bad slice click.
|
||||||
|
- Inspect, wand, and radio keep the panels already tested.
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
- Nine slices is dense on a 1280×720 view. Labels must stay short.
|
||||||
|
- Freeze-until-panel-closes stops the player sidestepping during an ID check.
|
||||||
|
- Keeping `1`–`6` as hidden shortcuts can confuse if the README leads with them. Document the wheel first.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- **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.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# ADR-0010: Procedural NPC presentation
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** TBD
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Avatars are `CharacterBody3D` capsules color-coded by `NpcType`, with a few props (camera, box, hat, sign, bag), height and brightness variation from a seed, a walk bob, and a head turn toward the guard when noticed. They work as markers. At conversation distance they do not read as people: no neck, no face direction, no shoes, no hair.
|
||||||
|
|
||||||
|
The player is supposed to judge from clothes, props, and behavior, not from a label. The HUD currently prints the internal type (`Violent Threat`, `Trespasser`) next to the name. Putting that name on the body would make the judgment for them.
|
||||||
|
|
||||||
|
A full Mixamo / GLB pipeline would also add animation imports, licenses, and a skeleton the rest of the sim does not need yet. AI textures in `assets/textures/ai/` are not sized or wired for clothing.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Keep the procedural capsule rig for this pass. Do not add skinned meshes or Mixamo.
|
||||||
|
|
||||||
|
Make the body read as a person at conversation distance:
|
||||||
|
|
||||||
|
- Neck between torso and head.
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
Do **not** put the internal type name on the body or as a world nametag. A focused `display_name` under the ring is allowed. The debug overlay (`F`) may still print type, intent, and FSM state. The HUD target line can keep the type during the prototype; removing it from the HUD is a later readability pass, not this one.
|
||||||
|
|
||||||
|
AI clothing textures stay out unless a map is already the right size and is wired on purpose.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- People have a face direction the ring and the "noticed the guard" cue can use.
|
||||||
|
- No new asset toolchain, licenses, or animation retargeting.
|
||||||
|
- Head is a stable node other systems can find.
|
||||||
|
- The player is not told `Violent Threat` by a floating label.
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
- Capsule people still look like a prototype at cinematic camera distance.
|
||||||
|
- Hair and eyes are spheres; they will not survive a close-up trailer.
|
||||||
|
- A later skinned-mesh pass will replace this rig and must keep the `Head` contract.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **World labels with archetype names:** Rejected because they leak sim truth and skip observation.
|
||||||
@@ -152,6 +152,10 @@ Architecture decisions are recorded as ADRs:
|
|||||||
- [ADR-0004: Local single-player first](adr/0004-local-single-player-first.md)
|
- [ADR-0004: Local single-player first](adr/0004-local-single-player-first.md)
|
||||||
- [ADR-0005: Event-based incident recording](adr/0005-event-based-incident-recording.md)
|
- [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-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-0009: Radial action menu](adr/0009-radial-action-menu.md)
|
||||||
|
- [ADR-0010: Procedural NPC presentation](adr/0010-procedural-npc-presentation.md)
|
||||||
|
|
||||||
## 10. Quality Requirements
|
## 10. Quality Requirements
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1004 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1000 KiB |
|
After Width: | Height: | Size: 954 KiB |
|
After Width: | Height: | Size: 910 KiB |
|
After Width: | Height: | Size: 971 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 986 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 1.2 MiB |
@@ -8,7 +8,7 @@ config_version=5
|
|||||||
|
|
||||||
config/name="Step Line"
|
config/name="Step Line"
|
||||||
config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
|
config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
|
||||||
config/version="0.2.1"
|
config/version="0.2.9"
|
||||||
run/main_scene="res://scenes/main.tscn"
|
run/main_scene="res://scenes/main.tscn"
|
||||||
config/features=PackedStringArray("4.3", "GL Compatibility")
|
config/features=PackedStringArray("4.3", "GL Compatibility")
|
||||||
|
|
||||||
@@ -47,18 +47,6 @@ move_right={
|
|||||||
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
interact={
|
|
||||||
"deadzone": 0.5,
|
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
|
|
||||||
, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
cancel={
|
|
||||||
"deadzone": 0.5,
|
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":81,"key_label":0,"unicode":113,"location":0,"echo":false,"script":null)
|
|
||||||
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
gesture_raise_hand={
|
gesture_raise_hand={
|
||||||
"deadzone": 0.5,
|
"deadzone": 0.5,
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":49,"key_label":0,"unicode":49,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":49,"key_label":0,"unicode":49,"location":0,"echo":false,"script":null)
|
||||||
@@ -91,8 +79,35 @@ radio_assist={
|
|||||||
}
|
}
|
||||||
radio_emergency={
|
radio_emergency={
|
||||||
"deadzone": 0.5,
|
"deadzone": 0.5,
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":87,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":114,"location":0,"echo":false,"script":null)
|
||||||
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":114,"location":0,"echo":false,"script":null)
|
]
|
||||||
|
}
|
||||||
|
action_menu={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
|
||||||
|
, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
confirm={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
|
||||||
|
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194309,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
cancel={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":2,"canceled":false,"pressed":false,"double_click":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
inspect={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
wand={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":67,"key_label":0,"unicode":99,"location":0,"echo":false,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
print_debug={
|
print_debug={
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Pillow>=9.2
|
||||||
@@ -37,3 +37,29 @@ func save_shift_result(result: Dictionary) -> Error:
|
|||||||
var results := load_shift_results()
|
var results := load_shift_results()
|
||||||
results.append(result)
|
results.append(result)
|
||||||
return _save_json(SHIFT_RESULTS_FILE, results)
|
return _save_json(SHIFT_RESULTS_FILE, results)
|
||||||
|
|
||||||
|
## Defaults for player settings. Kept in code so a missing or partial
|
||||||
|
## settings.json still produces a usable config.
|
||||||
|
const DEFAULT_SETTINGS := {
|
||||||
|
"look_sensitivity": 0.0022,
|
||||||
|
"hud_font_scale": 1.0,
|
||||||
|
"ai_difficulty": "normal",
|
||||||
|
}
|
||||||
|
|
||||||
|
## Load player settings, merged over the defaults. Returns a Dictionary.
|
||||||
|
func load_settings() -> Dictionary:
|
||||||
|
var merged: Dictionary = DEFAULT_SETTINGS.duplicate(true)
|
||||||
|
var data = _load_json(SETTINGS_FILE)
|
||||||
|
if data is Dictionary:
|
||||||
|
for key in data.keys():
|
||||||
|
if merged.has(key):
|
||||||
|
merged[key] = data[key]
|
||||||
|
return merged
|
||||||
|
|
||||||
|
## Persist player settings. Returns Error.
|
||||||
|
func save_settings(settings: Dictionary) -> Error:
|
||||||
|
var merged: Dictionary = DEFAULT_SETTINGS.duplicate(true)
|
||||||
|
for key in settings.keys():
|
||||||
|
if merged.has(key):
|
||||||
|
merged[key] = settings[key]
|
||||||
|
return _save_json(SETTINGS_FILE, merged)
|
||||||
@@ -393,6 +393,8 @@ static func joke_shop() -> VenueScenario:
|
|||||||
"guard_post": Vector3(0, 0, -3),
|
"guard_post": Vector3(0, 0, -3),
|
||||||
"entrance": Vector3(0, 0, 1.0),
|
"entrance": Vector3(0, 0, 1.0),
|
||||||
"door_area": Vector3(0, 0, 3),
|
"door_area": Vector3(0, 0, 3),
|
||||||
|
"steps_end": Vector3(0, 0, 3),
|
||||||
|
"sidewalk": Vector3(0, 0, 9),
|
||||||
"queue_area": Vector3(0, 0, 9),
|
"queue_area": Vector3(0, 0, 9),
|
||||||
}
|
}
|
||||||
scenario.door_staff_lines = {
|
scenario.door_staff_lines = {
|
||||||
@@ -433,7 +435,7 @@ static func joke_shop() -> VenueScenario:
|
|||||||
scenario.event_pool = [
|
scenario.event_pool = [
|
||||||
_joke_guest("guest_valid", true, true, Vector2(0, 900), 0.30),
|
_joke_guest("guest_valid", true, true, Vector2(0, 900), 0.30),
|
||||||
_joke_guest("guest_no_ticket", false, true, Vector2(0, 900), 0.18),
|
_joke_guest("guest_no_ticket", false, true, Vector2(0, 900), 0.18),
|
||||||
_joke_guest("guest_expired_id", true, true, Vector2(0, 900), 0.12),
|
_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),
|
_joke_guest("guest_no_id", true, false, Vector2(0, 900), 0.12),
|
||||||
_joke_staff_entry(Vector2(0, 900), 0.25),
|
_joke_staff_entry(Vector2(0, 900), 0.25),
|
||||||
_joke_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.15),
|
_joke_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.15),
|
||||||
@@ -475,6 +477,8 @@ static func tv_studio() -> VenueScenario:
|
|||||||
"guard_post": Vector3(0, 0, -3),
|
"guard_post": Vector3(0, 0, -3),
|
||||||
"entrance": Vector3(0, 0, 1.0),
|
"entrance": Vector3(0, 0, 1.0),
|
||||||
"stage_door": Vector3(6, 0, -2),
|
"stage_door": Vector3(6, 0, -2),
|
||||||
|
"steps_end": Vector3(0, 0, 3),
|
||||||
|
"sidewalk": Vector3(0, 0, 9),
|
||||||
"queue_area": Vector3(0, 0, 9),
|
"queue_area": Vector3(0, 0, 9),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,9 +496,9 @@ static func tv_studio() -> VenueScenario:
|
|||||||
])
|
])
|
||||||
|
|
||||||
scenario.event_pool = [
|
scenario.event_pool = [
|
||||||
_studio_routine("audience_ticket", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.35),
|
_studio_guest("audience_ticket", {"has_ticket": true}, Vector2(0, 900), 0.35),
|
||||||
_studio_routine("protest_outside", Rules.NpcType.PROTESTER, "protest", Vector2(300, 900), 0.18),
|
_studio_routine("protest_outside", Rules.NpcType.PROTESTER, "protest", Vector2(300, 900), 0.18),
|
||||||
_studio_routine("celebrity_arrival", Rules.NpcType.AUTHORIZED_GUEST, "arrive", Vector2(300, 700), 0.20),
|
_studio_guest("celebrity_arrival", {"has_guest_list": true}, Vector2(300, 700), 0.20, "arrive"),
|
||||||
_studio_routine("crew_shift", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 700), 0.30),
|
_studio_routine("crew_shift", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 700), 0.30),
|
||||||
_studio_routine("tourist_photo", Rules.NpcType.TOURIST, "photo", Vector2(0, 900), 0.35),
|
_studio_routine("tourist_photo", Rules.NpcType.TOURIST, "photo", Vector2(0, 900), 0.35),
|
||||||
_studio_routine("confused_enter", Rules.NpcType.CONFUSED_VISITOR, "enter", Vector2(0, 900), 0.25),
|
_studio_routine("confused_enter", Rules.NpcType.CONFUSED_VISITOR, "enter", Vector2(0, 900), 0.25),
|
||||||
@@ -509,6 +513,7 @@ static func tv_studio() -> VenueScenario:
|
|||||||
"time_range_s": Vector2(620, 780),
|
"time_range_s": Vector2(620, 780),
|
||||||
"probability": 0.55,
|
"probability": 0.55,
|
||||||
"base_weight": 1.0,
|
"base_weight": 1.0,
|
||||||
|
"override_access": {"wristband_status": Rules.ArtifactStatus.COUNTERFEIT, "credential_status": Rules.ArtifactStatus.COUNTERFEIT},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "studio_follow_through",
|
"id": "studio_follow_through",
|
||||||
@@ -516,6 +521,7 @@ static func tv_studio() -> VenueScenario:
|
|||||||
"kind": "scripted",
|
"kind": "scripted",
|
||||||
"npc_type": Rules.NpcType.TRESPASSER,
|
"npc_type": Rules.NpcType.TRESPASSER,
|
||||||
"intent": "follow_authorized",
|
"intent": "follow_authorized",
|
||||||
|
"override_access": {"has_ticket": false, "has_wristband": false, "has_credential": false, "has_guest_list": false},
|
||||||
"time_range_s": Vector2(700, 860),
|
"time_range_s": Vector2(700, 860),
|
||||||
"probability": 0.45,
|
"probability": 0.45,
|
||||||
"base_weight": 1.0,
|
"base_weight": 1.0,
|
||||||
@@ -549,12 +555,23 @@ static func _building_routine(id: String, npc_type: int, intent: String, time_ra
|
|||||||
"time_range_s": time_range, "probability": prob, "base_weight": 1.0,
|
"time_range_s": time_range, "probability": prob, "base_weight": 1.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
static func _joke_guest(id: String, has_ticket: bool, has_id: bool, time_range: Vector2, prob: float) -> Dictionary:
|
static func _joke_guest(id: String, has_ticket: bool, has_id: bool, time_range: Vector2, prob: float, extra: Dictionary = {}) -> Dictionary:
|
||||||
|
var override := {"has_ticket": has_ticket, "has_id": has_id}
|
||||||
|
for key in extra.keys():
|
||||||
|
override[key] = extra[key]
|
||||||
return {
|
return {
|
||||||
"id": id, "phase": _phase_for_range(time_range, 900.0),
|
"id": id, "phase": _phase_for_range(time_range, 900.0),
|
||||||
"kind": "routine", "npc_type": Rules.NpcType.AUTHORIZED_GUEST, "intent": "enter",
|
"kind": "routine", "npc_type": Rules.NpcType.AUTHORIZED_GUEST, "intent": "enter",
|
||||||
"time_range_s": time_range, "probability": prob, "base_weight": 1.0,
|
"time_range_s": time_range, "probability": prob, "base_weight": 1.0,
|
||||||
"override_access": {"has_ticket": has_ticket, "has_id": has_id},
|
"override_access": override,
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _studio_guest(id: String, override_access: Dictionary, time_range: Vector2, prob: float, intent: String = "enter") -> Dictionary:
|
||||||
|
return {
|
||||||
|
"id": id, "phase": _phase_for_range(time_range, 900.0),
|
||||||
|
"kind": "routine", "npc_type": Rules.NpcType.AUTHORIZED_GUEST, "intent": intent,
|
||||||
|
"time_range_s": time_range, "probability": prob, "base_weight": 1.0,
|
||||||
|
"override_access": override_access,
|
||||||
}
|
}
|
||||||
|
|
||||||
static func _joke_staff_entry(time_range: Vector2, prob: float) -> Dictionary:
|
static func _joke_staff_entry(time_range: Vector2, prob: float) -> Dictionary:
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Builds the access artifacts a person actually presents.
|
||||||
|
## Event `override_access` wins; otherwise a venue-appropriate default from
|
||||||
|
## type + policy. Recognition is never attached to every guest.
|
||||||
|
|
||||||
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
|
const AccessArtifact := preload("res://src/core/access_artifact.gd")
|
||||||
|
const AccessPolicy := preload("res://src/core/access_policy.gd")
|
||||||
|
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||||
|
|
||||||
|
static func build(profile: NpcProfile, policy: AccessPolicy, override: Dictionary = {}) -> Array:
|
||||||
|
var npc_type: int = profile.npc_type if profile else Rules.NpcType.TOURIST
|
||||||
|
var out: Array = []
|
||||||
|
if override.get("recognized", false) or (override.is_empty() and npc_type == Rules.NpcType.AUTHORIZED_STAFF):
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.VALID, {"known": true}))
|
||||||
|
if not override.is_empty():
|
||||||
|
_append_override(out, profile, override)
|
||||||
|
return out
|
||||||
|
if policy != null and (policy.require_id or policy.requires(Rules.AccessKind.ID)):
|
||||||
|
_append_id_ticket_defaults(out, profile, npc_type, policy)
|
||||||
|
return out
|
||||||
|
if policy != null and policy.grant_if_any_required:
|
||||||
|
_append_or_defaults(out, npc_type)
|
||||||
|
return out
|
||||||
|
_append_wristband_defaults(out, npc_type)
|
||||||
|
return out
|
||||||
|
|
||||||
|
static func roll_carried_items(seed_value: int, override: Dictionary = {}) -> Array:
|
||||||
|
if override.has("carried_items"):
|
||||||
|
return override["carried_items"]
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = seed_value if seed_value != 0 else 1
|
||||||
|
var roll := rng.randf()
|
||||||
|
if roll < 0.08:
|
||||||
|
return ["keys", "knife"]
|
||||||
|
if roll < 0.40:
|
||||||
|
return ["keys"]
|
||||||
|
if roll < 0.62:
|
||||||
|
return ["phone"]
|
||||||
|
if roll < 0.80:
|
||||||
|
return ["belt"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
static func _append_override(out: Array, profile: NpcProfile, override: Dictionary) -> void:
|
||||||
|
if override.has("has_id") or override.has("id_status"):
|
||||||
|
var has_id := bool(override.get("has_id", true))
|
||||||
|
var status: int = int(override.get("id_status", Rules.ArtifactStatus.VALID if has_id else Rules.ArtifactStatus.MISSING))
|
||||||
|
if not has_id:
|
||||||
|
status = Rules.ArtifactStatus.MISSING
|
||||||
|
var attrs := {}
|
||||||
|
if status != Rules.ArtifactStatus.MISSING:
|
||||||
|
attrs["age"] = int(override.get("age", 24))
|
||||||
|
attrs["name"] = profile.display_name if profile else "Guest"
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.ID, status, attrs))
|
||||||
|
if override.has("has_ticket") or override.has("ticket_status"):
|
||||||
|
var has_ticket := bool(override.get("has_ticket", true))
|
||||||
|
var status: int = int(override.get("ticket_status", Rules.ArtifactStatus.VALID if has_ticket else Rules.ArtifactStatus.MISSING))
|
||||||
|
if not has_ticket:
|
||||||
|
status = Rules.ArtifactStatus.MISSING
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.TICKET, status, {}))
|
||||||
|
if override.has("has_wristband") or override.has("wristband_status"):
|
||||||
|
var status := _present_status(override, "has_wristband", "wristband_status")
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, status, _band_attrs(status)))
|
||||||
|
if override.has("has_credential") or override.has("credential_status"):
|
||||||
|
var status := _present_status(override, "has_credential", "credential_status")
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, status, {"role": "staff"}))
|
||||||
|
if override.has("has_guest_list") or override.has("guest_list_status"):
|
||||||
|
var status := _present_status(override, "has_guest_list", "guest_list_status")
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.GUEST_LIST, status, {}))
|
||||||
|
|
||||||
|
static func _present_status(override: Dictionary, has_key: String, status_key: String) -> int:
|
||||||
|
if override.has(status_key):
|
||||||
|
return int(override[status_key])
|
||||||
|
return Rules.ArtifactStatus.VALID if bool(override.get(has_key, false)) else Rules.ArtifactStatus.MISSING
|
||||||
|
|
||||||
|
static func _append_id_ticket_defaults(out: Array, profile: NpcProfile, npc_type: int, policy: AccessPolicy) -> void:
|
||||||
|
if npc_type == Rules.NpcType.AUTHORIZED_STAFF:
|
||||||
|
return
|
||||||
|
if npc_type == Rules.NpcType.AUTHORIZED_GUEST:
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.VALID, {
|
||||||
|
"age": 24, "name": profile.display_name if profile else "Guest",
|
||||||
|
}))
|
||||||
|
if policy.requires(Rules.AccessKind.TICKET):
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.VALID, {}))
|
||||||
|
return
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.MISSING, {}))
|
||||||
|
if policy.requires(Rules.AccessKind.TICKET):
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.MISSING, {}))
|
||||||
|
|
||||||
|
static func _append_or_defaults(out: Array, npc_type: int) -> void:
|
||||||
|
match npc_type:
|
||||||
|
Rules.NpcType.AUTHORIZED_STAFF, Rules.NpcType.DELIVERY:
|
||||||
|
if not _has_kind(out, Rules.AccessKind.RECOGNITION):
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, Rules.ArtifactStatus.VALID, {"role": "staff"}))
|
||||||
|
Rules.NpcType.AUTHORIZED_GUEST:
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.VALID, {}))
|
||||||
|
Rules.NpcType.TRESPASSER:
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, _band_attrs(Rules.ArtifactStatus.COUNTERFEIT)))
|
||||||
|
Rules.NpcType.CONFUSED_VISITOR:
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.AMBIGUOUS, _band_attrs(Rules.ArtifactStatus.AMBIGUOUS)))
|
||||||
|
_:
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {}))
|
||||||
|
|
||||||
|
static func _append_wristband_defaults(out: Array, npc_type: int) -> void:
|
||||||
|
match npc_type:
|
||||||
|
Rules.NpcType.AUTHORIZED_STAFF:
|
||||||
|
pass
|
||||||
|
Rules.NpcType.AUTHORIZED_GUEST, Rules.NpcType.DELIVERY:
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.VALID, _band_attrs(Rules.ArtifactStatus.VALID)))
|
||||||
|
if npc_type == Rules.NpcType.DELIVERY:
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, Rules.ArtifactStatus.VALID, {"role": "delivery"}))
|
||||||
|
Rules.NpcType.TRESPASSER:
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, _band_attrs(Rules.ArtifactStatus.COUNTERFEIT)))
|
||||||
|
Rules.NpcType.CONFUSED_VISITOR:
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.AMBIGUOUS, _band_attrs(Rules.ArtifactStatus.AMBIGUOUS)))
|
||||||
|
_:
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {}))
|
||||||
|
|
||||||
|
static func _band_attrs(status: int) -> Dictionary:
|
||||||
|
match status:
|
||||||
|
Rules.ArtifactStatus.VALID:
|
||||||
|
return {"color": "red", "date": "2026-02-13", "event": "gala"}
|
||||||
|
Rules.ArtifactStatus.COUNTERFEIT:
|
||||||
|
return {"color": "red", "date": "2024-01-01"}
|
||||||
|
Rules.ArtifactStatus.AMBIGUOUS:
|
||||||
|
return {"color": "gold"}
|
||||||
|
_:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
static func _has_kind(artifacts: Array, kind: int) -> bool:
|
||||||
|
for artifact in artifacts:
|
||||||
|
if artifact is AccessArtifact and artifact.kind == kind:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
@@ -15,7 +15,7 @@ func _init() -> void:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
## Verify a single person's set of artifacts (person_artifacts: Array[AccessArtifact]).
|
## Verify a single person's set of artifacts (person_artifacts: Array[AccessArtifact]).
|
||||||
func verify(person_artifacts: Array, policy: AccessPolicy) -> Dictionary:
|
func verify(person_artifacts: Array, policy: AccessPolicy, already_screened: bool = false) -> Dictionary:
|
||||||
var reasons: Array[String] = []
|
var reasons: Array[String] = []
|
||||||
var issues: Array[String] = []
|
var issues: Array[String] = []
|
||||||
var checked: Array[String] = []
|
var checked: Array[String] = []
|
||||||
@@ -48,7 +48,7 @@ func verify(person_artifacts: Array, policy: AccessPolicy) -> Dictionary:
|
|||||||
|
|
||||||
# Wand screening is processed by the interaction layer (false alarms), but a
|
# Wand screening is processed by the interaction layer (false alarms), but a
|
||||||
# policy can hard-require it; a refused screen blocks entry.
|
# policy can hard-require it; a refused screen blocks entry.
|
||||||
if policy.wand_screening_required and not person_screened(person_artifacts):
|
if policy.wand_screening_required and not already_screened and not person_screened(person_artifacts):
|
||||||
issues.append("Screening required")
|
issues.append("Screening required")
|
||||||
return _result(false, reasons, issues, checked)
|
return _result(false, reasons, issues, checked)
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,20 @@ func apply_event(event_type: String) -> void:
|
|||||||
"event": event_type,
|
"event": event_type,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
## Presentation cue for the avatars after a crowd event (PLAN: Public Crowd
|
||||||
|
## Reaction). "watch" = rubberneck at the incident, "flee" = move away from the
|
||||||
|
## door, "" = no reaction. Pure so it is headless-testable.
|
||||||
|
static func reaction_for_event(event_type: String) -> String:
|
||||||
|
match event_type:
|
||||||
|
"aggressive_behavior", "visible_weapon", "emergency_responder":
|
||||||
|
return "watch"
|
||||||
|
_:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
## True once the crowd is panicking hard enough that people should clear out.
|
||||||
|
func should_flee() -> bool:
|
||||||
|
return crowd_panic
|
||||||
|
|
||||||
func summary() -> Dictionary:
|
func summary() -> Dictionary:
|
||||||
return {
|
return {
|
||||||
"mood": mood,
|
"mood": mood,
|
||||||
|
|||||||
@@ -78,6 +78,19 @@ func close_incident(npc_id: String, outcome: String) -> Dictionary:
|
|||||||
func open_incidents_count() -> int:
|
func open_incidents_count() -> int:
|
||||||
return _open_incidents.size()
|
return _open_incidents.size()
|
||||||
|
|
||||||
|
## True when an incident is already open for this NPC (avoids duplicating one
|
||||||
|
## incident every time the player re-focuses the same person).
|
||||||
|
func has_open_incident(npc_id: String) -> bool:
|
||||||
|
return _open_incidents.has(npc_id)
|
||||||
|
|
||||||
|
## Close every still-open incident at shift end so the review does not silently
|
||||||
|
## drop interactions the player never resolved. Returns how many were closed.
|
||||||
|
func close_all_open(outcome: String = "unresolved") -> int:
|
||||||
|
var ids: Array = _open_incidents.keys()
|
||||||
|
for npc_id in ids:
|
||||||
|
close_incident(str(npc_id), outcome)
|
||||||
|
return ids.size()
|
||||||
|
|
||||||
func to_dict() -> Dictionary:
|
func to_dict() -> Dictionary:
|
||||||
return {
|
return {
|
||||||
"incidents": incidents.duplicate(true),
|
"incidents": incidents.duplicate(true),
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ var guard_noticed := false
|
|||||||
|
|
||||||
## Whether this NPC has passed the handheld wand screening (The Joke Shop).
|
## Whether this NPC has passed the handheld wand screening (The Joke Shop).
|
||||||
var screened := false
|
var screened := false
|
||||||
|
## Whether the guard has declared they recognize this person (a player decision
|
||||||
|
## recorded as evidence, PLAN.md: Authorized Access).
|
||||||
|
var player_recognized := false
|
||||||
|
## Last reply the NPC gave when asked for their reason for entry.
|
||||||
|
var last_reason_reply := ""
|
||||||
|
## Event-level access payload (`has_id`, `has_ticket`, statuses). Empty means
|
||||||
|
## venue/type defaults from AccessLoadout.
|
||||||
|
var access_override: Dictionary = {}
|
||||||
|
|
||||||
func _init() -> void:
|
func _init() -> void:
|
||||||
fsm = FSM.new(self)
|
fsm = FSM.new(self)
|
||||||
@@ -147,6 +155,16 @@ func tick_return(delta: float) -> bool:
|
|||||||
func decide_leave() -> bool:
|
func decide_leave() -> bool:
|
||||||
return leave_requested
|
return leave_requested
|
||||||
|
|
||||||
|
## Level 3 ask: request the person's reason for entry (PLAN.md). Pure and
|
||||||
|
## headless-testable; returns the line this NPC answers with ("" if silent).
|
||||||
|
func request_reason() -> String:
|
||||||
|
var lines: Array[String] = profile.dialogue_lines if profile else []
|
||||||
|
if lines.is_empty():
|
||||||
|
last_reason_reply = ""
|
||||||
|
return ""
|
||||||
|
last_reason_reply = lines[rng.randi_range(0, lines.size() - 1)]
|
||||||
|
return last_reason_reply
|
||||||
|
|
||||||
## Perception pass: given the guard's distance (meters), return a perception
|
## Perception pass: given the guard's distance (meters), return a perception
|
||||||
## result describing how the NPC reacts to the guard's presence. Pure and
|
## result describing how the NPC reacts to the guard's presence. Pure and
|
||||||
## headless-testable; the presentation layer feeds it the real distance.
|
## headless-testable; the presentation layer feeds it the real distance.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const EventScheduler := preload("res://src/core/event_scheduler.gd")
|
|||||||
## Drives one shift: advances time, schedules events, owns the shared systems
|
## Drives one shift: advances time, schedules events, owns the shared systems
|
||||||
## (recorder, reputation, radio, crowd), and produces the shift review.
|
## (recorder, reputation, radio, crowd), and produces the shift review.
|
||||||
|
|
||||||
signal npc_spawn_requested(profile: NpcProfile, intent: String, npc_id: String)
|
signal npc_spawn_requested(profile: NpcProfile, intent: String, npc_id: String, event: Dictionary)
|
||||||
signal shift_started(payload: Dictionary)
|
signal shift_started(payload: Dictionary)
|
||||||
signal shift_ended(review: Dictionary)
|
signal shift_ended(review: Dictionary)
|
||||||
signal phase_changed(phase: int)
|
signal phase_changed(phase: int)
|
||||||
@@ -21,6 +21,8 @@ signal phase_changed(phase: int)
|
|||||||
var scenario: VenueScenario = null
|
var scenario: VenueScenario = null
|
||||||
var duration_s: float = 900.0
|
var duration_s: float = 900.0
|
||||||
var elapsed_s: float = 0.0
|
var elapsed_s: float = 0.0
|
||||||
|
## Wall-clock start, so the shift review can show event times as mm:ss.
|
||||||
|
var started_at_ms: int = 0
|
||||||
var current_phase: int = Rules.Phase.EARLY
|
var current_phase: int = Rules.Phase.EARLY
|
||||||
var running := false
|
var running := false
|
||||||
|
|
||||||
@@ -32,8 +34,16 @@ var crowd := CrowdSystem.new()
|
|||||||
var scheduler := EventScheduler.new()
|
var scheduler := EventScheduler.new()
|
||||||
|
|
||||||
var spawned_count := 0
|
var spawned_count := 0
|
||||||
|
## Emergency aftermath (PLAN.md: Level 5). Responder resolutions and any
|
||||||
|
## investigation notes for calls that were not justified.
|
||||||
|
var responder_outcomes: Array[String] = []
|
||||||
|
var investigations: Array[String] = []
|
||||||
var spawn_seed := 0
|
var spawn_seed := 0
|
||||||
var _shift_finished := false
|
var _shift_finished := false
|
||||||
|
## The scheduler rolls once per simulated second. `tick()` runs at physics rate,
|
||||||
|
## so accumulate time and only pull due events on whole-second boundaries.
|
||||||
|
const SPAWN_INTERVAL_S := 1.0
|
||||||
|
var _spawn_accum_s := 0.0
|
||||||
|
|
||||||
func setup(p_scenario: VenueScenario, p_duration_s: float = 900.0, p_seed: int = 0) -> void:
|
func setup(p_scenario: VenueScenario, p_duration_s: float = 900.0, p_seed: int = 0) -> void:
|
||||||
scenario = p_scenario
|
scenario = p_scenario
|
||||||
@@ -41,6 +51,8 @@ func setup(p_scenario: VenueScenario, p_duration_s: float = 900.0, p_seed: int =
|
|||||||
spawn_seed = p_seed
|
spawn_seed = p_seed
|
||||||
scheduler.setup(scenario, p_seed)
|
scheduler.setup(scenario, p_seed)
|
||||||
event_bus = EventBus.new()
|
event_bus = EventBus.new()
|
||||||
|
responder_outcomes.clear()
|
||||||
|
investigations.clear()
|
||||||
radio = RadioSystem.new()
|
radio = RadioSystem.new()
|
||||||
reputation = ReputationEvaluator.new()
|
reputation = ReputationEvaluator.new()
|
||||||
crowd = CrowdSystem.new()
|
crowd = CrowdSystem.new()
|
||||||
@@ -55,7 +67,9 @@ func start() -> void:
|
|||||||
return
|
return
|
||||||
running = true
|
running = true
|
||||||
_shift_finished = false
|
_shift_finished = false
|
||||||
|
started_at_ms = Time.get_ticks_msec()
|
||||||
elapsed_s = 0.0
|
elapsed_s = 0.0
|
||||||
|
_spawn_accum_s = 0.0
|
||||||
current_phase = _phase_for_time(0.0)
|
current_phase = _phase_for_time(0.0)
|
||||||
event_bus.shift_state_changed.emit({"running": true, "elapsed_s": elapsed_s, "phase": current_phase})
|
event_bus.shift_state_changed.emit({"running": true, "elapsed_s": elapsed_s, "phase": current_phase})
|
||||||
shift_started.emit({"scenario_id": scenario.id, "duration_s": duration_s})
|
shift_started.emit({"scenario_id": scenario.id, "duration_s": duration_s})
|
||||||
@@ -77,7 +91,12 @@ func tick(delta: float) -> void:
|
|||||||
if elapsed_s >= duration_s:
|
if elapsed_s >= duration_s:
|
||||||
_finish_shift()
|
_finish_shift()
|
||||||
return
|
return
|
||||||
_spawn_due(delta)
|
_spawn_accum_s += delta
|
||||||
|
if _spawn_accum_s >= SPAWN_INTERVAL_S:
|
||||||
|
_spawn_accum_s -= SPAWN_INTERVAL_S
|
||||||
|
if _spawn_accum_s > SPAWN_INTERVAL_S:
|
||||||
|
_spawn_accum_s = 0.0
|
||||||
|
_spawn_due(SPAWN_INTERVAL_S)
|
||||||
|
|
||||||
func _phase_for_time(t: float) -> int:
|
func _phase_for_time(t: float) -> int:
|
||||||
if scenario == null or scenario.shift_phases.is_empty():
|
if scenario == null or scenario.shift_phases.is_empty():
|
||||||
@@ -102,13 +121,15 @@ func _spawn_due(delta: float) -> void:
|
|||||||
var profile := scenario.profile_for(event.get("npc_type", Rules.NpcType.TOURIST))
|
var profile := scenario.profile_for(event.get("npc_type", Rules.NpcType.TOURIST))
|
||||||
if profile == null:
|
if profile == null:
|
||||||
continue
|
continue
|
||||||
npc_spawn_requested.emit(profile, event.get("intent", "roam"), npc_id)
|
npc_spawn_requested.emit(profile, event.get("intent", "roam"), npc_id, event)
|
||||||
|
|
||||||
func _finish_shift() -> void:
|
func _finish_shift() -> void:
|
||||||
if _shift_finished:
|
if _shift_finished:
|
||||||
return
|
return
|
||||||
_shift_finished = true
|
_shift_finished = true
|
||||||
running = false
|
running = false
|
||||||
|
# Interactions the player never resolved still belong in this shift's review.
|
||||||
|
recorder.close_all_open("unresolved")
|
||||||
var review := build_review()
|
var review := build_review()
|
||||||
event_bus.shift_state_changed.emit({"running": false, "review": review})
|
event_bus.shift_state_changed.emit({"running": false, "review": review})
|
||||||
shift_ended.emit(review)
|
shift_ended.emit(review)
|
||||||
@@ -119,6 +140,7 @@ func build_review() -> Dictionary:
|
|||||||
"scenario_id": scenario.id if scenario else "",
|
"scenario_id": scenario.id if scenario else "",
|
||||||
"duration_s": duration_s,
|
"duration_s": duration_s,
|
||||||
"elapsed_s": elapsed_s,
|
"elapsed_s": elapsed_s,
|
||||||
|
"started_at_ms": started_at_ms,
|
||||||
"phase_at_end": current_phase,
|
"phase_at_end": current_phase,
|
||||||
"spawned_count": spawned_count,
|
"spawned_count": spawned_count,
|
||||||
"incidents": recorder.to_dict(),
|
"incidents": recorder.to_dict(),
|
||||||
@@ -128,6 +150,8 @@ func build_review() -> Dictionary:
|
|||||||
"battery": radio.battery,
|
"battery": radio.battery,
|
||||||
"emergency_cooldown_s": radio.emergency_cooldown_remaining,
|
"emergency_cooldown_s": radio.emergency_cooldown_remaining,
|
||||||
},
|
},
|
||||||
|
"responder_outcomes": responder_outcomes.duplicate(),
|
||||||
|
"investigations": investigations.duplicate(),
|
||||||
"completed_at_ms": Time.get_ticks_msec(),
|
"completed_at_ms": Time.get_ticks_msec(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ extends Node
|
|||||||
const SAMPLE_RATE := 22050.0
|
const SAMPLE_RATE := 22050.0
|
||||||
|
|
||||||
var _players: Array[AudioStreamPlayer] = []
|
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:
|
func _ready() -> void:
|
||||||
# Pre-create a few players so overlapping cues don't cut each other off.
|
# 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
|
var freq := 440.0 - float(level) * 40.0
|
||||||
_play_tone(maxf(freq, 180.0), 0.18, 0.6)
|
_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).
|
## Play a denial/error cue (low buzz).
|
||||||
func play_deny() -> void:
|
func play_deny() -> void:
|
||||||
_play_tone(220.0, 0.20, 0.5)
|
_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
|
var sample := sin(TAU * freq * t) * 0.6
|
||||||
playback.push_frame(Vector2(sample, sample))
|
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:
|
func _free_player() -> AudioStreamPlayer:
|
||||||
|
if _players.is_empty():
|
||||||
|
return null
|
||||||
for player in _players:
|
for player in _players:
|
||||||
if not player.playing:
|
if not player.playing:
|
||||||
return player
|
return player
|
||||||
|
|||||||
@@ -1,35 +1,47 @@
|
|||||||
class_name GuardController
|
class_name GuardController
|
||||||
extends CharacterBody3D
|
extends CharacterBody3D
|
||||||
## First-person guard controller: WASD movement, mouse look, interact raycast.
|
## First-person guard: WASD + mouse look. Focus is a cone on NPC heads (ADR-0007, ADR-0008).
|
||||||
|
|
||||||
signal picked_target(target: Node)
|
signal picked_target(target: Node)
|
||||||
signal cleared_target()
|
signal cleared_target()
|
||||||
|
|
||||||
|
const NpcFocus := preload("res://src/game/npc_focus.gd")
|
||||||
|
|
||||||
var move_speed: float = 3.2
|
var move_speed: float = 3.2
|
||||||
var look_sensitivity: float = 0.0022
|
var look_sensitivity: float = 0.0022
|
||||||
|
var accel: float = 12.0
|
||||||
var is_ready := false
|
var is_ready := false
|
||||||
var targeted_npc: Node3D = null
|
var targeted_npc: Node3D = null
|
||||||
|
var input_locked := false
|
||||||
|
var _miss_frames := 0
|
||||||
|
const MISS_GRACE := 8
|
||||||
|
|
||||||
@onready var camera: Camera3D = $Head/Camera3D
|
@onready var camera: Camera3D = $Head/Camera3D
|
||||||
@onready var head: Node3D = $Head
|
@onready var head: Node3D = $Head
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
floor_snap_length = 0.3
|
||||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||||
is_ready = true
|
is_ready = true
|
||||||
|
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if input_locked:
|
||||||
|
return
|
||||||
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
|
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
|
||||||
rotate_y(-event.relative.x * look_sensitivity)
|
rotate_y(-event.relative.x * look_sensitivity)
|
||||||
head.rotate_x(-event.relative.y * look_sensitivity)
|
head.rotate_x(-event.relative.y * look_sensitivity)
|
||||||
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0))
|
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0))
|
||||||
elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
|
|
||||||
if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
|
|
||||||
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
|
||||||
else:
|
|
||||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
|
||||||
|
|
||||||
func _physics_process(_delta: float) -> void:
|
func _physics_process(_delta: float) -> void:
|
||||||
# Movement relative to the head's yaw only.
|
if input_locked:
|
||||||
|
velocity.x = 0.0
|
||||||
|
velocity.z = 0.0
|
||||||
|
if not is_on_floor():
|
||||||
|
velocity.y -= 9.8 * _delta
|
||||||
|
else:
|
||||||
|
velocity.y = 0.0
|
||||||
|
move_and_slide()
|
||||||
|
return
|
||||||
var forward := -global_transform.basis.z
|
var forward := -global_transform.basis.z
|
||||||
forward.y = 0.0
|
forward.y = 0.0
|
||||||
forward = forward.normalized()
|
forward = forward.normalized()
|
||||||
@@ -41,28 +53,49 @@ func _physics_process(_delta: float) -> void:
|
|||||||
Input.get_axis("move_left", "move_right"),
|
Input.get_axis("move_left", "move_right"),
|
||||||
Input.get_axis("move_forward", "move_back")
|
Input.get_axis("move_forward", "move_back")
|
||||||
)
|
)
|
||||||
var direction := (forward * input_dir.y + right * input_dir.x).normalized()
|
if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
|
||||||
velocity.x = direction.x * move_speed
|
input_dir = Vector2.ZERO
|
||||||
velocity.z = direction.z * move_speed
|
var desired := (forward * input_dir.y + right * input_dir.x)
|
||||||
velocity.y -= 9.8 * _delta
|
if desired.length() > 1.0:
|
||||||
|
desired = desired.normalized()
|
||||||
|
desired *= move_speed
|
||||||
|
velocity.x = move_toward(velocity.x, desired.x, accel * _delta * move_speed)
|
||||||
|
velocity.z = move_toward(velocity.z, desired.z, accel * _delta * move_speed)
|
||||||
|
if is_on_floor():
|
||||||
|
velocity.y = 0.0
|
||||||
|
else:
|
||||||
|
velocity.y -= 9.8 * _delta
|
||||||
move_and_slide()
|
move_and_slide()
|
||||||
_update_target()
|
_update_target()
|
||||||
|
|
||||||
func _update_target() -> void:
|
func _update_target() -> void:
|
||||||
var space := get_world_3d().direct_space_state
|
if camera == null:
|
||||||
var from := camera.global_position
|
return
|
||||||
var to := from + -camera.global_transform.basis.z * 8.0
|
var npcs := get_tree().get_nodes_in_group("npc") if get_tree() else []
|
||||||
var query := PhysicsRayQueryParameters3D.create(from, to)
|
var heads: Array = []
|
||||||
query.collide_with_areas = true
|
var avatars: Array = []
|
||||||
query.collide_with_bodies = true
|
for node in npcs:
|
||||||
query.exclude = [get_rid()]
|
if not (node is Node3D) or not is_instance_valid(node):
|
||||||
var hit := space.intersect_ray(query)
|
continue
|
||||||
|
var avatar := node as Node3D
|
||||||
|
var head_node := avatar.get_node_or_null("Rig/Head") as Node3D
|
||||||
|
var pos: Vector3 = head_node.global_position if head_node else avatar.global_position + Vector3(0, 1.6, 0)
|
||||||
|
heads.append(pos)
|
||||||
|
avatars.append(avatar)
|
||||||
|
var cam_origin := camera.global_position
|
||||||
|
var cam_forward := -camera.global_transform.basis.z
|
||||||
|
var idx := NpcFocus.pick_index(cam_origin, cam_forward, heads)
|
||||||
var new_target: Node3D = null
|
var new_target: Node3D = null
|
||||||
if hit:
|
if idx >= 0:
|
||||||
var obj: Object = hit.get("collider")
|
var candidate: Node3D = avatars[idx]
|
||||||
var parent_avatar: Node3D = _find_avatar(obj)
|
if _head_visible(cam_origin, heads[idx]):
|
||||||
if parent_avatar != null:
|
new_target = candidate
|
||||||
new_target = parent_avatar
|
if new_target == null and targeted_npc != null:
|
||||||
|
_miss_frames += 1
|
||||||
|
if _miss_frames < MISS_GRACE:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
_miss_frames = 0
|
||||||
if new_target != targeted_npc:
|
if new_target != targeted_npc:
|
||||||
targeted_npc = new_target
|
targeted_npc = new_target
|
||||||
if new_target != null:
|
if new_target != null:
|
||||||
@@ -70,14 +103,21 @@ func _update_target() -> void:
|
|||||||
else:
|
else:
|
||||||
cleared_target.emit()
|
cleared_target.emit()
|
||||||
|
|
||||||
func _find_avatar(obj: Object) -> Node3D:
|
func _head_visible(from: Vector3, to: Vector3) -> bool:
|
||||||
# Avatars are CharacterBody3D with a collision shape; guard against self and scene junk.
|
var space := get_world_3d().direct_space_state
|
||||||
|
if space == null:
|
||||||
|
return true
|
||||||
|
var query := PhysicsRayQueryParameters3D.create(from, to)
|
||||||
|
query.collide_with_areas = false
|
||||||
|
query.collide_with_bodies = true
|
||||||
|
query.exclude = [get_rid()]
|
||||||
|
var hit := space.intersect_ray(query)
|
||||||
|
if hit.is_empty():
|
||||||
|
return true
|
||||||
|
var obj: Object = hit.get("collider")
|
||||||
var n := obj as Node
|
var n := obj as Node
|
||||||
if n == null:
|
while n != null:
|
||||||
return null
|
if n.is_in_group("npc"):
|
||||||
var current: Node = n
|
return true
|
||||||
while current != null:
|
n = n.get_parent()
|
||||||
if current.has_method("get_npc_controller"):
|
return false
|
||||||
return current as Node3D
|
|
||||||
current = current.get_parent()
|
|
||||||
return null
|
|
||||||
@@ -12,6 +12,19 @@ var target_position: Vector3 = Vector3.ZERO
|
|||||||
var door_position: Vector3 = Vector3(0, 0.5, 1.2)
|
var door_position: Vector3 = Vector3(0, 0.5, 1.2)
|
||||||
var awaiting_interaction := false
|
var awaiting_interaction := false
|
||||||
var arrived := false
|
var arrived := false
|
||||||
|
## Seconds idle at an arrived waypoint. Ordinary traffic lives for a while, then
|
||||||
|
## despawns so the entrance does not fill with standers (TODO: NPC crowd cap).
|
||||||
|
var idle_s := 0.0
|
||||||
|
var lifetime_s := 90.0
|
||||||
|
var ephemeral := true
|
||||||
|
## Crowd presentation (PLAN: Public Crowd Reaction). "watch" rubbernecks at the
|
||||||
|
## incident, "flee" clears away from it, "" is normal navigation.
|
||||||
|
var reaction_mode := ""
|
||||||
|
var reaction_point := Vector3.ZERO
|
||||||
|
var _reaction_timer := 0.0
|
||||||
|
const RUBBERNECK_S := 12.0
|
||||||
|
const FLEE_S := 20.0
|
||||||
|
const FLEE_SPEED := 2.4
|
||||||
var agent: NavigationAgent3D = null
|
var agent: NavigationAgent3D = null
|
||||||
## Set once the nav agent proves it has a path; direct movement is the fallback.
|
## Set once the nav agent proves it has a path; direct movement is the fallback.
|
||||||
var _nav_usable := false
|
var _nav_usable := false
|
||||||
@@ -39,14 +52,17 @@ func _init() -> void:
|
|||||||
func get_npc_controller() -> NPCController:
|
func get_npc_controller() -> NPCController:
|
||||||
return npc
|
return npc
|
||||||
|
|
||||||
func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3, p_door: Vector3 = Vector3(0, 0.5, 1.2)) -> void:
|
func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3, p_door: Vector3 = Vector3(0, 0.5, 1.2), p_npc_id: String = "", p_override: Dictionary = {}) -> void:
|
||||||
npc.setup("npc_%d" % seed_value, profile, intent, seed_value)
|
var id := p_npc_id if p_npc_id != "" else "npc_%d" % seed_value
|
||||||
|
npc.setup(id, profile, intent, seed_value)
|
||||||
|
npc.access_override = p_override
|
||||||
_visual_seed = seed_value
|
_visual_seed = seed_value
|
||||||
spawn_position = p_spawn
|
spawn_position = p_spawn
|
||||||
target_position = p_target
|
target_position = p_target
|
||||||
door_position = p_door
|
door_position = p_door
|
||||||
position = p_spawn
|
position = p_spawn
|
||||||
npc.fsm.state_changed.connect(_on_state_changed)
|
npc.fsm.state_changed.connect(_on_state_changed)
|
||||||
|
add_to_group("npc")
|
||||||
_build_body(profile.npc_type)
|
_build_body(profile.npc_type)
|
||||||
_nav_usable = false
|
_nav_usable = false
|
||||||
_nav_try_frames = 0
|
_nav_try_frames = 0
|
||||||
@@ -62,9 +78,10 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
|
|||||||
var collider := CollisionShape3D.new()
|
var collider := CollisionShape3D.new()
|
||||||
var shape := CapsuleShape3D.new()
|
var shape := CapsuleShape3D.new()
|
||||||
shape.radius = 0.35
|
shape.radius = 0.35
|
||||||
shape.height = 1.6
|
var height_scale: float = _rig.scale.y if _rig else 1.0
|
||||||
|
shape.height = 1.6 * height_scale
|
||||||
collider.shape = shape
|
collider.shape = shape
|
||||||
collider.position = Vector3(0, 0.8, 0)
|
collider.position = Vector3(0, 0.8 * height_scale, 0)
|
||||||
add_child(collider)
|
add_child(collider)
|
||||||
|
|
||||||
var _visual_seed := 0
|
var _visual_seed := 0
|
||||||
@@ -72,8 +89,18 @@ var _rig: Node3D = null
|
|||||||
var _head: Node3D = null
|
var _head: Node3D = null
|
||||||
var _bob_phase := 0.0
|
var _bob_phase := 0.0
|
||||||
var _guard_ref: Node3D = null
|
var _guard_ref: Node3D = null
|
||||||
|
var _ring: MeshInstance3D = null
|
||||||
|
var _name_tag: Label3D = null
|
||||||
|
## Theater props and limbs for the idle poses (ADR-0010 rig).
|
||||||
|
var _photo_prop: MeshInstance3D = null
|
||||||
|
var _sign_prop: MeshInstance3D = null
|
||||||
|
var _legs: Array[MeshInstance3D] = []
|
||||||
|
var _theater_t := 0.0
|
||||||
|
var _photo_prop_base_y := 0.0
|
||||||
|
var _sign_prop_base_y := 0.0
|
||||||
const SKIN_TONE := Color(0.88, 0.72, 0.60)
|
const SKIN_TONE := Color(0.88, 0.72, 0.60)
|
||||||
const PANTS_COLOR := Color(0.22, 0.22, 0.26)
|
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)]
|
||||||
|
|
||||||
func _build_body(npc_type: int) -> void:
|
func _build_body(npc_type: int) -> void:
|
||||||
_rig = Node3D.new()
|
_rig = Node3D.new()
|
||||||
@@ -91,20 +118,66 @@ func _build_body(npc_type: int) -> void:
|
|||||||
# Torso
|
# Torso
|
||||||
_add_capsule(_rig, 0.30, 0.95, Vector3(0, 1.05, 0), base_color)
|
_add_capsule(_rig, 0.30, 0.95, Vector3(0, 1.05, 0), base_color)
|
||||||
# Legs
|
# Legs
|
||||||
_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))
|
||||||
_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
|
# 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)
|
||||||
_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)
|
||||||
# Head
|
# Neck, head, face, hair, shoes so the body reads as a person (ADR-0010).
|
||||||
|
_add_capsule(_rig, 0.08, 0.16, Vector3(0, 1.48, 0), SKIN_TONE)
|
||||||
_head = Node3D.new()
|
_head = Node3D.new()
|
||||||
_head.name = "Head"
|
_head.name = "Head"
|
||||||
_head.position = Vector3(0, 1.62, 0)
|
_head.position = Vector3(0, 1.62, 0)
|
||||||
_add_sphere(_head, 0.17, Vector3.ZERO, SKIN_TONE)
|
_add_sphere(_head, 0.17, Vector3.ZERO, SKIN_TONE)
|
||||||
|
var hair_i := (_visual_seed * 7) % HAIR_COLORS.size()
|
||||||
|
_add_sphere(_head, 0.18, Vector3(0, 0.08, -0.02), HAIR_COLORS[hair_i])
|
||||||
|
_add_sphere(_head, 0.035, Vector3(-0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1))
|
||||||
|
_add_sphere(_head, 0.035, Vector3(0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1))
|
||||||
_rig.add_child(_head)
|
_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()
|
||||||
_add_props(npc_type)
|
_add_props(npc_type)
|
||||||
|
|
||||||
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> void:
|
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
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
|
mat.albedo_color = Color(0.95, 0.82, 0.28, 0.9)
|
||||||
|
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||||
|
_ring.material_override = mat
|
||||||
|
_ring.position = Vector3(0, 0.28, 0)
|
||||||
|
_ring.visible = false
|
||||||
|
_head.add_child(_ring)
|
||||||
|
_name_tag = Label3D.new()
|
||||||
|
_name_tag.name = "NameTag"
|
||||||
|
_name_tag.text = npc.profile.display_name if npc and npc.profile else ""
|
||||||
|
_name_tag.font_size = 48
|
||||||
|
_name_tag.pixel_size = 0.004
|
||||||
|
_name_tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||||
|
_name_tag.position = Vector3(0, 0.58, 0)
|
||||||
|
_name_tag.modulate = Color(0.95, 0.95, 0.9, 0.9)
|
||||||
|
_name_tag.visible = false
|
||||||
|
_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 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)
|
||||||
|
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) -> MeshInstance3D:
|
||||||
var mi := MeshInstance3D.new()
|
var mi := MeshInstance3D.new()
|
||||||
var mesh := CapsuleMesh.new()
|
var mesh := CapsuleMesh.new()
|
||||||
mesh.radius = radius
|
mesh.radius = radius
|
||||||
@@ -114,6 +187,7 @@ func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, co
|
|||||||
mi.rotation_degrees.z = z_rot
|
mi.rotation_degrees.z = z_rot
|
||||||
mi.material_override = _material(color)
|
mi.material_override = _material(color)
|
||||||
parent.add_child(mi)
|
parent.add_child(mi)
|
||||||
|
return mi
|
||||||
|
|
||||||
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color) -> void:
|
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color) -> void:
|
||||||
var mi := MeshInstance3D.new()
|
var mi := MeshInstance3D.new()
|
||||||
@@ -135,7 +209,8 @@ func _add_props(npc_type: int) -> void:
|
|||||||
var chest_y := 1.25
|
var chest_y := 1.25
|
||||||
match npc_type:
|
match npc_type:
|
||||||
Rules.NpcType.INFLUENCER:
|
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))
|
_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:
|
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))
|
_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))
|
||||||
@@ -150,15 +225,22 @@ func _add_props(npc_type: int) -> void:
|
|||||||
cone.material_override = hat
|
cone.material_override = hat
|
||||||
cone.position = Vector3(0, 0.26, 0)
|
cone.position = Vector3(0, 0.26, 0)
|
||||||
_head.add_child(cone)
|
_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:
|
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.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:
|
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:
|
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))
|
_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 mi := MeshInstance3D.new()
|
||||||
var mesh := BoxMesh.new()
|
var mesh := BoxMesh.new()
|
||||||
mesh.size = size
|
mesh.size = size
|
||||||
@@ -166,27 +248,156 @@ func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) ->
|
|||||||
mi.position = pos
|
mi.position = pos
|
||||||
mi.material_override = _material(color)
|
mi.material_override = _material(color)
|
||||||
parent.add_child(mi)
|
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.
|
||||||
|
func start_rubberneck(at: Vector3) -> bool:
|
||||||
|
if not arrived or npc.leave_requested or leave_requested_by_lifetime:
|
||||||
|
return false
|
||||||
|
# Someone actually waiting at the entrance is being dealt with, not a bystander.
|
||||||
|
# Avatars live under the identity SceneRoot, so `position` matches world space
|
||||||
|
# and also works before the node enters the tree (tests).
|
||||||
|
if awaiting_interaction and (position - door_position).length() < 3.0:
|
||||||
|
return false
|
||||||
|
if reaction_mode == "flee":
|
||||||
|
return false
|
||||||
|
reaction_mode = "watch"
|
||||||
|
reaction_point = at
|
||||||
|
_reaction_timer = RUBBERNECK_S
|
||||||
|
velocity = Vector3.ZERO
|
||||||
|
return true
|
||||||
|
|
||||||
|
## Panic: clear away from the incident (usually the door) in a straight line.
|
||||||
|
func start_flee(from: Vector3) -> void:
|
||||||
|
if npc.leave_requested or leave_requested_by_lifetime:
|
||||||
|
return
|
||||||
|
reaction_mode = "flee"
|
||||||
|
reaction_point = from
|
||||||
|
_reaction_timer = FLEE_S
|
||||||
|
var away := global_position - from
|
||||||
|
away.y = 0.0
|
||||||
|
if away.length() < 0.5:
|
||||||
|
away = Vector3(0.0, 0.0, 1.0)
|
||||||
|
target_position = global_position + away.normalized() * 25.0
|
||||||
|
target_position.y = 0.5
|
||||||
|
arrived = false
|
||||||
|
awaiting_interaction = false
|
||||||
|
|
||||||
|
func reaction_is_active() -> bool:
|
||||||
|
return reaction_mode != ""
|
||||||
|
|
||||||
|
func _tick_reaction(delta: float) -> void:
|
||||||
|
if reaction_mode == "":
|
||||||
|
return
|
||||||
|
_reaction_timer -= delta
|
||||||
|
if reaction_mode == "flee":
|
||||||
|
if _reaction_timer <= 0.0 or (global_position - target_position).length() < 1.5:
|
||||||
|
reaction_mode = ""
|
||||||
|
queue_free()
|
||||||
|
return
|
||||||
|
if _reaction_timer <= 0.0:
|
||||||
|
reaction_mode = ""
|
||||||
|
|
||||||
|
## Turn the body toward the reaction point without moving.
|
||||||
|
func _face_reaction_point(delta: float) -> void:
|
||||||
|
var to_point := reaction_point - global_position
|
||||||
|
to_point.y = 0.0
|
||||||
|
if to_point.length() < 0.1:
|
||||||
|
return
|
||||||
|
var target_yaw := atan2(-to_point.x, -to_point.z)
|
||||||
|
rotation.y = lerp_angle(rotation.y, target_yaw, clampf(delta * 4.0, 0.0, 1.0))
|
||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
if npc == null or npc.fsm == null:
|
if npc == null or npc.fsm == null:
|
||||||
return
|
return
|
||||||
_advance_fsm(delta)
|
_advance_fsm(delta)
|
||||||
|
_tick_reaction(delta)
|
||||||
|
if reaction_mode == "watch":
|
||||||
|
velocity = Vector3.ZERO
|
||||||
|
_face_reaction_point(delta)
|
||||||
|
_animate(delta)
|
||||||
|
return
|
||||||
|
if reaction_mode == "flee":
|
||||||
|
_move_toward_target(delta)
|
||||||
|
_animate(delta)
|
||||||
|
return
|
||||||
_move_toward_target(delta)
|
_move_toward_target(delta)
|
||||||
_animate(delta)
|
_animate(delta)
|
||||||
|
|
||||||
## Procedural walk bob + head-toward-guard cue when the NPC is aware.
|
## 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:
|
func _animate(delta: float) -> void:
|
||||||
if _rig == null:
|
if _rig == null:
|
||||||
return
|
return
|
||||||
var speed := Vector2(velocity.x, velocity.z).length()
|
var speed := Vector2(velocity.x, velocity.z).length()
|
||||||
|
var pose := theater_pose()
|
||||||
|
var crouch := crouch_for_pose(pose)
|
||||||
if speed > 0.3:
|
if speed > 0.3:
|
||||||
_bob_phase += delta * (6.0 + speed)
|
_bob_phase += delta * (6.0 + speed)
|
||||||
_rig.position.y = sin(_bob_phase) * 0.035
|
_rig.position.y = sin(_bob_phase) * 0.035
|
||||||
_rig.rotation.x = sin(_bob_phase * 0.5) * 0.04
|
_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:
|
else:
|
||||||
_bob_phase = 0.0
|
_bob_phase = 0.0
|
||||||
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)
|
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)
|
||||||
_rig.rotation.x = lerpf(_rig.rotation.x, 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)
|
||||||
# Attention cue: head turns toward the guard when awareness was raised.
|
# Attention cue: head turns toward the guard when awareness was raised.
|
||||||
if _head != null and npc.guard_noticed:
|
if _head != null and npc.guard_noticed:
|
||||||
if _guard_ref == null or not is_instance_valid(_guard_ref):
|
if _guard_ref == null or not is_instance_valid(_guard_ref):
|
||||||
@@ -200,25 +411,40 @@ func _animate(delta: float) -> void:
|
|||||||
_head.rotation.y = lerp_angle(_head.rotation.y, world_yaw - parent_yaw, 0.1)
|
_head.rotation.y = lerp_angle(_head.rotation.y, world_yaw - parent_yaw, 0.1)
|
||||||
|
|
||||||
func _advance_fsm(delta: float) -> void:
|
func _advance_fsm(delta: float) -> void:
|
||||||
|
# Routine traffic despawns after a while once it has finished its walk, so
|
||||||
|
# the crowd cap is not permanently occupied by idle standers.
|
||||||
|
if ephemeral and arrived and npc.fsm.current_state != "LEAVING":
|
||||||
|
idle_s += delta
|
||||||
|
if idle_s >= lifetime_s:
|
||||||
|
leave_requested_by_lifetime = true
|
||||||
|
return
|
||||||
# Returning-trespasser: an NPC directed away may circle back to the door.
|
# Returning-trespasser: an NPC directed away may circle back to the door.
|
||||||
if npc.tick_return(delta):
|
if npc.tick_return(delta):
|
||||||
arrived = false
|
arrived = false
|
||||||
awaiting_interaction = false
|
awaiting_interaction = false
|
||||||
target_position = door_position
|
target_position = door_position
|
||||||
return
|
return
|
||||||
if npc.leave_requested:
|
if npc.leave_requested or leave_requested_by_lifetime:
|
||||||
var away := global_position + (global_position - spawn_position).normalized() * 40.0
|
var away := global_position + (global_position - spawn_position).normalized() * 40.0
|
||||||
away.y = 0.5
|
away.y = 0.5
|
||||||
target_position = away
|
target_position = away
|
||||||
|
if (global_position - away).length() < 2.0:
|
||||||
|
queue_free()
|
||||||
return
|
return
|
||||||
if arrived and not awaiting_interaction:
|
if arrived and not awaiting_interaction:
|
||||||
awaiting_interaction = true
|
awaiting_interaction = true
|
||||||
|
|
||||||
|
var leave_requested_by_lifetime := false
|
||||||
|
|
||||||
func _move_toward_target(delta: float) -> void:
|
func _move_toward_target(delta: float) -> void:
|
||||||
if agent == null:
|
if agent == null:
|
||||||
_move_direct(delta)
|
_move_direct(delta)
|
||||||
return
|
return
|
||||||
agent.target_position = target_position
|
# Re-assigning the target every physics frame invalidates the path, so
|
||||||
|
# get_next_path_position() keeps returning the current position and the
|
||||||
|
# avatar never moves. Only update the agent when the target changes.
|
||||||
|
if agent.target_position != target_position:
|
||||||
|
agent.target_position = target_position
|
||||||
if agent.is_navigation_finished():
|
if agent.is_navigation_finished():
|
||||||
if (global_position - target_position).length() < 3.0:
|
if (global_position - target_position).length() < 3.0:
|
||||||
_arrive()
|
_arrive()
|
||||||
@@ -235,14 +461,32 @@ func _move_toward_target(delta: float) -> void:
|
|||||||
_move_direct(delta)
|
_move_direct(delta)
|
||||||
return
|
return
|
||||||
var next := agent.get_next_path_position()
|
var next := agent.get_next_path_position()
|
||||||
|
# Godot reports the current position as the first path point (on the navmesh
|
||||||
|
# plane, so below the body) and only advances past it once the agent is within
|
||||||
|
# path_desired_distance. Steering at the point we are standing on gives a
|
||||||
|
# zero-length direction, and steering at any point merely "far enough" away can
|
||||||
|
# pick the old start behind us. Follow the first path point that is genuinely
|
||||||
|
# closer to the destination than we are.
|
||||||
|
var path := agent.get_current_navigation_path()
|
||||||
|
if path.size() >= 2:
|
||||||
|
var goal: Vector3 = path[path.size() - 1]
|
||||||
|
var here_to_goal := Vector2(goal.x - global_position.x, goal.z - global_position.z).length()
|
||||||
|
for i in range(1, path.size()):
|
||||||
|
var candidate: Vector3 = path[i]
|
||||||
|
candidate.y = global_position.y
|
||||||
|
var candidate_to_goal := Vector2(goal.x - candidate.x, goal.z - candidate.z).length()
|
||||||
|
if candidate_to_goal < here_to_goal - 0.05:
|
||||||
|
next = candidate
|
||||||
|
break
|
||||||
var dir := (next - global_position)
|
var dir := (next - global_position)
|
||||||
dir.y = 0.0
|
dir.y = 0.0
|
||||||
if dir.length() < 0.2:
|
if dir.length() < 0.2:
|
||||||
velocity = Vector3.ZERO
|
velocity = Vector3.ZERO
|
||||||
return
|
return
|
||||||
dir = dir.normalized()
|
dir = dir.normalized()
|
||||||
velocity.x = dir.x * 1.3
|
var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3
|
||||||
velocity.z = dir.z * 1.3
|
velocity.x = dir.x * speed
|
||||||
|
velocity.z = dir.z * speed
|
||||||
velocity.y -= 9.8 * delta
|
velocity.y -= 9.8 * delta
|
||||||
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
|
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
|
||||||
move_and_slide()
|
move_and_slide()
|
||||||
@@ -256,8 +500,9 @@ func _move_direct(delta: float) -> void:
|
|||||||
_arrive()
|
_arrive()
|
||||||
return
|
return
|
||||||
var dir := to_target.normalized()
|
var dir := to_target.normalized()
|
||||||
velocity.x = dir.x * 1.3
|
var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3
|
||||||
velocity.z = dir.z * 1.3
|
velocity.x = dir.x * speed
|
||||||
|
velocity.z = dir.z * speed
|
||||||
velocity.y -= 9.8 * delta
|
velocity.y -= 9.8 * delta
|
||||||
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
|
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
|
||||||
move_and_slide()
|
move_and_slide()
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## World-space focus cone (ADR-0008). Picks the head closest to the camera
|
||||||
|
## forward vector inside range and angle. No viewport needed, so tests run headless.
|
||||||
|
|
||||||
|
const MAX_RANGE := 10.0
|
||||||
|
const MAX_ANGLE_RAD := 0.175 # ~10 degrees
|
||||||
|
|
||||||
|
static func score_head(cam_origin: Vector3, cam_forward: Vector3, head_pos: Vector3, max_range: float = MAX_RANGE, max_angle: float = MAX_ANGLE_RAD) -> float:
|
||||||
|
var to := head_pos - cam_origin
|
||||||
|
var dist := to.length()
|
||||||
|
if dist < 0.05 or dist > max_range:
|
||||||
|
return -1.0
|
||||||
|
var dir := to / dist
|
||||||
|
var dot := cam_forward.dot(dir)
|
||||||
|
if dot <= 0.0:
|
||||||
|
return -1.0
|
||||||
|
var ang := acos(clampf(dot, -1.0, 1.0))
|
||||||
|
if ang > max_angle:
|
||||||
|
return -1.0
|
||||||
|
return ang
|
||||||
|
|
||||||
|
static func pick_index(cam_origin: Vector3, cam_forward: Vector3, head_positions: Array, max_range: float = MAX_RANGE, max_angle: float = MAX_ANGLE_RAD) -> int:
|
||||||
|
var best_i := -1
|
||||||
|
var best_score := 999.0
|
||||||
|
for i in head_positions.size():
|
||||||
|
var pos: Vector3 = head_positions[i]
|
||||||
|
var score := score_head(cam_origin, cam_forward, pos, max_range, max_angle)
|
||||||
|
if score < 0.0:
|
||||||
|
continue
|
||||||
|
if score < best_score:
|
||||||
|
best_score = score
|
||||||
|
best_i = i
|
||||||
|
return best_i
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
extends CanvasLayer
|
||||||
|
## Doughnut action menu (ADR-0009). Open on LMB/Space, confirm click/Space,
|
||||||
|
## cancel Esc/RMB. Emergency is not a slice.
|
||||||
|
|
||||||
|
signal confirmed(slice_id: String)
|
||||||
|
signal cancelled()
|
||||||
|
|
||||||
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
|
const Strings := preload("res://src/core/strings.gd")
|
||||||
|
|
||||||
|
const SLICE_DEFS := [
|
||||||
|
{"id": "raise_hand", "label_key": "radial.raise_hand", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "raise hand"},
|
||||||
|
{"id": "eye_contact", "label_key": "radial.eye_contact", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "eye contact"},
|
||||||
|
{"id": "point", "label_key": "radial.point", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "point"},
|
||||||
|
{"id": "head_shake", "label_key": "radial.head_shake", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "head shake"},
|
||||||
|
{"id": "polite", "label_key": "radial.polite", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "polite direction"},
|
||||||
|
{"id": "inspect", "label_key": "radial.inspect", "level": -1, "action": "inspect"},
|
||||||
|
{"id": "wand", "label_key": "radial.wand", "level": -1, "action": "wand"},
|
||||||
|
{"id": "warning", "label_key": "radial.warning", "level": Rules.Level.FORMAL_WARNING, "action": "formal warning"},
|
||||||
|
{"id": "block", "label_key": "radial.block", "level": Rules.Level.BLOCKING, "action": "blocking"},
|
||||||
|
{"id": "reason", "label_key": "radial.reason", "level": Rules.Level.BLOCKING, "action": "request reason"},
|
||||||
|
{"id": "recognize", "label_key": "radial.recognize", "level": -1, "action": "recognize"},
|
||||||
|
{"id": "radio", "label_key": "radial.radio", "level": -1, "action": "radio assist"},
|
||||||
|
]
|
||||||
|
|
||||||
|
var selected := 0
|
||||||
|
var wand_available := false
|
||||||
|
var person_name := ""
|
||||||
|
var is_open := false
|
||||||
|
var _opened_ms := 0
|
||||||
|
var _wheel: Control = null
|
||||||
|
var _center_label: Label = null
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
name = "ActionRadial"
|
||||||
|
layer = 25
|
||||||
|
var wheel := Control.new()
|
||||||
|
wheel.name = "Wheel"
|
||||||
|
wheel.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
wheel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
|
wheel.draw.connect(_on_wheel_draw)
|
||||||
|
wheel.gui_input.connect(_on_wheel_input)
|
||||||
|
add_child(wheel)
|
||||||
|
_wheel = wheel
|
||||||
|
_center_label = Label.new()
|
||||||
|
_center_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
_center_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||||
|
_center_label.set_anchors_preset(Control.PRESET_CENTER)
|
||||||
|
_center_label.offset_left = -120
|
||||||
|
_center_label.offset_right = 120
|
||||||
|
_center_label.offset_top = -24
|
||||||
|
_center_label.offset_bottom = 24
|
||||||
|
_center_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
add_child(_center_label)
|
||||||
|
hide()
|
||||||
|
|
||||||
|
func slice_count() -> int:
|
||||||
|
return SLICE_DEFS.size()
|
||||||
|
|
||||||
|
func slice_id(index: int) -> String:
|
||||||
|
if index < 0 or index >= SLICE_DEFS.size():
|
||||||
|
return ""
|
||||||
|
return str(SLICE_DEFS[index]["id"])
|
||||||
|
|
||||||
|
func slice_def(index: int) -> Dictionary:
|
||||||
|
if index < 0 or index >= SLICE_DEFS.size():
|
||||||
|
return {}
|
||||||
|
return SLICE_DEFS[index]
|
||||||
|
|
||||||
|
func is_slice_enabled(index: int) -> bool:
|
||||||
|
if slice_id(index) == "wand":
|
||||||
|
return wand_available
|
||||||
|
return true
|
||||||
|
|
||||||
|
func open_menu(p_name: String, p_wand: bool) -> void:
|
||||||
|
person_name = p_name
|
||||||
|
wand_available = p_wand
|
||||||
|
selected = 0
|
||||||
|
is_open = true
|
||||||
|
_opened_ms = Time.get_ticks_msec()
|
||||||
|
_center_label.text = person_name
|
||||||
|
show()
|
||||||
|
_wheel.queue_redraw()
|
||||||
|
|
||||||
|
func close_menu() -> void:
|
||||||
|
is_open = false
|
||||||
|
hide()
|
||||||
|
|
||||||
|
func step(direction: int) -> void:
|
||||||
|
if not is_open or SLICE_DEFS.is_empty():
|
||||||
|
return
|
||||||
|
var n := SLICE_DEFS.size()
|
||||||
|
var next := selected
|
||||||
|
for i in n:
|
||||||
|
next = (next + direction) % n
|
||||||
|
if next < 0:
|
||||||
|
next += n
|
||||||
|
if is_slice_enabled(next):
|
||||||
|
selected = next
|
||||||
|
_wheel.queue_redraw()
|
||||||
|
return
|
||||||
|
|
||||||
|
func confirm_selected() -> String:
|
||||||
|
if not is_open:
|
||||||
|
return ""
|
||||||
|
if not is_slice_enabled(selected):
|
||||||
|
return ""
|
||||||
|
return slice_id(selected)
|
||||||
|
|
||||||
|
func angle_to_index(angle: float) -> int:
|
||||||
|
# angle from atan2, 0 = +x, we want 0 = top (-PI/2).
|
||||||
|
var n := float(SLICE_DEFS.size())
|
||||||
|
var shifted := angle + PI / 2.0
|
||||||
|
while shifted < 0.0:
|
||||||
|
shifted += TAU
|
||||||
|
while shifted >= TAU:
|
||||||
|
shifted -= TAU
|
||||||
|
var idx := int(floor(shifted / TAU * n)) % SLICE_DEFS.size()
|
||||||
|
return idx
|
||||||
|
|
||||||
|
func _on_wheel_input(event: InputEvent) -> void:
|
||||||
|
if not is_open:
|
||||||
|
return
|
||||||
|
if event is InputEventMouseMotion:
|
||||||
|
var idx := _index_at_mouse(event.position)
|
||||||
|
if idx >= 0 and is_slice_enabled(idx):
|
||||||
|
selected = idx
|
||||||
|
_wheel.queue_redraw()
|
||||||
|
elif event is InputEventMouseButton and event.pressed:
|
||||||
|
if event.button_index == MOUSE_BUTTON_LEFT:
|
||||||
|
if Time.get_ticks_msec() - _opened_ms < 120:
|
||||||
|
return
|
||||||
|
var idx := _index_at_mouse(event.position)
|
||||||
|
if idx >= 0 and is_slice_enabled(idx):
|
||||||
|
selected = idx
|
||||||
|
confirmed.emit(slice_id(selected))
|
||||||
|
elif event.button_index == MOUSE_BUTTON_RIGHT:
|
||||||
|
cancelled.emit()
|
||||||
|
|
||||||
|
func _index_at_mouse(local_pos: Vector2) -> int:
|
||||||
|
var center := _wheel.size * 0.5
|
||||||
|
var delta := local_pos - center
|
||||||
|
if delta.length() < 36.0:
|
||||||
|
return -1
|
||||||
|
return angle_to_index(atan2(delta.y, delta.x))
|
||||||
|
|
||||||
|
func _on_wheel_draw() -> void:
|
||||||
|
if not is_open:
|
||||||
|
return
|
||||||
|
var center := _wheel.size * 0.5
|
||||||
|
var n := SLICE_DEFS.size()
|
||||||
|
var outer := 174.0
|
||||||
|
var inner := 64.0
|
||||||
|
for i in n:
|
||||||
|
var a0 := -PI / 2.0 + TAU * float(i) / float(n)
|
||||||
|
var a1 := -PI / 2.0 + TAU * float(i + 1) / float(n)
|
||||||
|
var col := Color(0.12, 0.14, 0.18, 0.82)
|
||||||
|
if i == selected:
|
||||||
|
col = Color(0.92, 0.78, 0.28, 0.9)
|
||||||
|
if not is_slice_enabled(i):
|
||||||
|
col = Color(0.2, 0.2, 0.22, 0.45)
|
||||||
|
_draw_slice(center, inner, outer, a0, a1, col)
|
||||||
|
var mid := (a0 + a1) * 0.5
|
||||||
|
var label_pos := center + Vector2(cos(mid), sin(mid)) * ((inner + outer) * 0.5)
|
||||||
|
var text := Strings.lookup(str(SLICE_DEFS[i]["label_key"]))
|
||||||
|
# With 12 slices the label box is tight: center it on the slice midpoint.
|
||||||
|
var font := ThemeDB.fallback_font
|
||||||
|
var text_size := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12)
|
||||||
|
_wheel.draw_string(font, label_pos - Vector2(text_size.x * 0.5, -4), text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, Color.WHITE)
|
||||||
|
|
||||||
|
func _draw_slice(center: Vector2, inner: float, outer: float, a0: float, a1: float, color: Color) -> void:
|
||||||
|
var pts := PackedVector2Array()
|
||||||
|
var steps := 8
|
||||||
|
for s in steps + 1:
|
||||||
|
var t := float(s) / float(steps)
|
||||||
|
var ang := lerpf(a0, a1, t)
|
||||||
|
pts.append(center + Vector2(cos(ang), sin(ang)) * outer)
|
||||||
|
for s in steps + 1:
|
||||||
|
var t := float(steps - s) / float(steps)
|
||||||
|
var ang := lerpf(a0, a1, t)
|
||||||
|
pts.append(center + Vector2(cos(ang), sin(ang)) * inner)
|
||||||
|
_wheel.draw_colored_polygon(pts, color)
|
||||||
@@ -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()
|
||||||
@@ -5,16 +5,16 @@ extends CanvasLayer
|
|||||||
|
|
||||||
const Rules := preload("res://src/core/rules.gd")
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
const Strings := preload("res://src/core/strings.gd")
|
const Strings := preload("res://src/core/strings.gd")
|
||||||
const T_VIGNETTE := preload("res://assets/textures/vignette.png")
|
const T_VIGNETTE := preload("res://assets/textures/ai/game_ready/vignette.png")
|
||||||
const T_ICON_FLASH := preload("res://assets/textures/icons/flashlight_on.png")
|
const T_ICON_FLASH := preload("res://assets/textures/ai/game_ready/icons/flashlight_on.png")
|
||||||
const T_ICON_DOOR := preload("res://assets/textures/icons/door_locked.png")
|
const T_ICON_DOOR := preload("res://assets/textures/ai/game_ready/icons/door_locked.png")
|
||||||
const T_ICON_WAND := preload("res://assets/textures/icons/wand.png")
|
const T_ICON_WAND := preload("res://assets/textures/ai/game_ready/icons/wand.png")
|
||||||
const T_ICON_REC := preload("res://assets/textures/icons/rec_dot.png")
|
const T_ICON_REC := preload("res://assets/textures/ai/game_ready/icons/rec_dot.png")
|
||||||
const T_CARD_ID := preload("res://assets/textures/cards/id_card.png")
|
const T_CARD_ID := preload("res://assets/textures/ai/game_ready/cards/id_card.png")
|
||||||
const T_CARD_WRIST := preload("res://assets/textures/cards/wristband.png")
|
const T_CARD_WRIST := preload("res://assets/textures/ai/game_ready/cards/wristband.png")
|
||||||
const T_CARD_TICKET := preload("res://assets/textures/cards/ticket.png")
|
const T_CARD_TICKET := preload("res://assets/textures/ai/game_ready/cards/ticket.png")
|
||||||
const T_CARD_CRED := preload("res://assets/textures/cards/credential.png")
|
const T_CARD_CRED := preload("res://assets/textures/ai/game_ready/cards/credential.png")
|
||||||
const T_CARD_GUEST := preload("res://assets/textures/cards/guest_list.png")
|
const T_CARD_GUEST := preload("res://assets/textures/ai/game_ready/cards/guest_list.png")
|
||||||
const CARD_TEXTURES := {
|
const CARD_TEXTURES := {
|
||||||
Rules.AccessKind.ID: T_CARD_ID,
|
Rules.AccessKind.ID: T_CARD_ID,
|
||||||
Rules.AccessKind.WRISTBAND: T_CARD_WRIST,
|
Rules.AccessKind.WRISTBAND: T_CARD_WRIST,
|
||||||
@@ -47,6 +47,10 @@ var escalation_label: Label
|
|||||||
|
|
||||||
var _debug_enabled := false
|
var _debug_enabled := false
|
||||||
var _log_lines: Array[String] = []
|
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:
|
func _init() -> void:
|
||||||
name = "HUD"
|
name = "HUD"
|
||||||
@@ -138,11 +142,19 @@ func _init() -> void:
|
|||||||
add_child(vignette_rect)
|
add_child(vignette_rect)
|
||||||
|
|
||||||
# --- body-camera overlay (REC + shift clock + corner brackets, top right) ---
|
# --- body-camera overlay (REC + shift clock + corner brackets, top right) ---
|
||||||
|
# Toggled by the body-camera flag (PLAN Level 2); off until the guard activates
|
||||||
|
# it or issues a formal warning.
|
||||||
|
_cam_overlay = Control.new()
|
||||||
|
_cam_overlay.name = "BodyCameraOverlay"
|
||||||
|
_cam_overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
_cam_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_cam_overlay.visible = false
|
||||||
|
add_child(_cam_overlay)
|
||||||
var cam_box := VBoxContainer.new()
|
var cam_box := VBoxContainer.new()
|
||||||
cam_box.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
cam_box.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
||||||
cam_box.position = Vector2(-180, 50)
|
cam_box.position = Vector2(-180, 50)
|
||||||
cam_box.alignment = BoxContainer.ALIGNMENT_END
|
cam_box.alignment = BoxContainer.ALIGNMENT_END
|
||||||
add_child(cam_box)
|
_cam_overlay.add_child(cam_box)
|
||||||
var rec_row := HBoxContainer.new()
|
var rec_row := HBoxContainer.new()
|
||||||
rec_row.alignment = BoxContainer.ALIGNMENT_END
|
rec_row.alignment = BoxContainer.ALIGNMENT_END
|
||||||
cam_box.add_child(rec_row)
|
cam_box.add_child(rec_row)
|
||||||
@@ -279,6 +291,32 @@ func set_wand_icon(visible: bool) -> void:
|
|||||||
if wand_icon:
|
if wand_icon:
|
||||||
wand_icon.visible = visible
|
wand_icon.visible = visible
|
||||||
|
|
||||||
|
## Show or hide the body-camera overlay (REC + clock + brackets). The flag is a
|
||||||
|
## record of the interaction (PLAN Level 2), not a permanent HUD fixture.
|
||||||
|
func set_body_camera(on: bool) -> void:
|
||||||
|
_body_camera_on = on
|
||||||
|
if _cam_overlay:
|
||||||
|
_cam_overlay.visible = on
|
||||||
|
|
||||||
|
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)
|
||||||
|
_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:
|
func _make_icon(texture: Texture2D) -> TextureRect:
|
||||||
var tr := TextureRect.new()
|
var tr := TextureRect.new()
|
||||||
tr.texture = texture
|
tr.texture = texture
|
||||||
@@ -296,6 +334,7 @@ func _add_corner_brackets(color: Color) -> void:
|
|||||||
var corners := [
|
var corners := [
|
||||||
Vector2(OFF, OFF), Vector2(vp.x - OFF, OFF), Vector2(OFF, vp.y - OFF), Vector2(vp.x - OFF, vp.y - OFF),
|
Vector2(OFF, OFF), Vector2(vp.x - OFF, OFF), Vector2(OFF, vp.y - OFF), Vector2(vp.x - OFF, vp.y - OFF),
|
||||||
]
|
]
|
||||||
|
var parent: Node = _cam_overlay if _cam_overlay != null else self
|
||||||
for i in 4:
|
for i in 4:
|
||||||
var c: Vector2 = corners[i]
|
var c: Vector2 = corners[i]
|
||||||
var flip_x: bool = (i % 2) == 1
|
var flip_x: bool = (i % 2) == 1
|
||||||
@@ -305,13 +344,13 @@ func _add_corner_brackets(color: Color) -> void:
|
|||||||
h.size = Vector2(LEN, TH)
|
h.size = Vector2(LEN, TH)
|
||||||
h.position = Vector2(c.x - (LEN if flip_x else 0.0), c.y - (TH if flip_y else 0.0))
|
h.position = Vector2(c.x - (LEN if flip_x else 0.0), c.y - (TH if flip_y else 0.0))
|
||||||
h.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
h.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
add_child(h)
|
parent.add_child(h)
|
||||||
var v := ColorRect.new()
|
var v := ColorRect.new()
|
||||||
v.color = color
|
v.color = color
|
||||||
v.size = Vector2(TH, LEN)
|
v.size = Vector2(TH, LEN)
|
||||||
v.position = Vector2(c.x - (TH if flip_x else 0.0), c.y - (LEN if flip_y else 0.0))
|
v.position = Vector2(c.x - (TH if flip_x else 0.0), c.y - (LEN if flip_y else 0.0))
|
||||||
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
add_child(v)
|
parent.add_child(v)
|
||||||
|
|
||||||
func set_action_hint(text: String) -> void:
|
func set_action_hint(text: String) -> void:
|
||||||
hint_label.text = text
|
hint_label.text = text
|
||||||
@@ -320,9 +359,18 @@ func set_action_hint(text: String) -> void:
|
|||||||
|
|
||||||
## Color-coded escalation feedback (PLAN.md: Polish and Accessibility).
|
## Color-coded escalation feedback (PLAN.md: Polish and Accessibility).
|
||||||
## Green → yellow → orange → red as the escalation level rises.
|
## 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:
|
func set_escalation(level: int) -> void:
|
||||||
var name: String = Rules.LEVEL_NAMES.get(level, "Observation")
|
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)
|
var tint := Color(0.6, 0.9, 0.6)
|
||||||
match level:
|
match level:
|
||||||
Rules.Level.OBSERVATION, Rules.Level.SIMPLE_DETERRENCE:
|
Rules.Level.OBSERVATION, Rules.Level.SIMPLE_DETERRENCE:
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
class_name PauseOverlay
|
||||||
|
extends CanvasLayer
|
||||||
|
## Shift pause overlay: resume, restart the shift, open settings, or quit to the
|
||||||
|
## venue menu. Opened with Esc from the world (or a dedicated pause action) and
|
||||||
|
## while the overlay is open the guard is frozen.
|
||||||
|
|
||||||
|
signal resume_requested()
|
||||||
|
signal restart_requested()
|
||||||
|
signal settings_requested()
|
||||||
|
signal quit_to_menu_requested()
|
||||||
|
|
||||||
|
var _root: Control = null
|
||||||
|
var _resume_button: Button = null
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
layer = 18
|
||||||
|
_build()
|
||||||
|
hide()
|
||||||
|
|
||||||
|
func _build() -> void:
|
||||||
|
_root = Control.new()
|
||||||
|
_root.name = "PauseRoot"
|
||||||
|
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
_root.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
|
add_child(_root)
|
||||||
|
|
||||||
|
var bg := ColorRect.new()
|
||||||
|
bg.color = Color(0.02, 0.03, 0.05, 0.82)
|
||||||
|
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_root.add_child(bg)
|
||||||
|
|
||||||
|
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(320, 0)
|
||||||
|
column.add_theme_constant_override("separation", 10)
|
||||||
|
center.add_child(column)
|
||||||
|
|
||||||
|
var title := Label.new()
|
||||||
|
title.text = "Paused"
|
||||||
|
title.add_theme_font_size_override("font_size", 36)
|
||||||
|
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
column.add_child(title)
|
||||||
|
|
||||||
|
_resume_button = _add_button(column, "Resume", "ResumeButton", func(): resume_requested.emit())
|
||||||
|
_add_button(column, "Restart shift", "RestartButton", func(): restart_requested.emit())
|
||||||
|
_add_button(column, "Settings", "SettingsButton", func(): settings_requested.emit())
|
||||||
|
_add_button(column, "Quit to menu", "MenuButton", func(): quit_to_menu_requested.emit())
|
||||||
|
|
||||||
|
var hint := Label.new()
|
||||||
|
hint.text = "Esc resumes"
|
||||||
|
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
hint.add_theme_color_override("font_color", Color(0.55, 0.58, 0.65))
|
||||||
|
column.add_child(hint)
|
||||||
|
|
||||||
|
func _add_button(parent: Control, text: String, node_name: String, cb: Callable) -> Button:
|
||||||
|
var button := Button.new()
|
||||||
|
button.name = node_name
|
||||||
|
button.text = text
|
||||||
|
button.custom_minimum_size = Vector2(320, 44)
|
||||||
|
button.pressed.connect(cb)
|
||||||
|
parent.add_child(button)
|
||||||
|
return button
|
||||||
|
|
||||||
|
func open() -> void:
|
||||||
|
visible = true
|
||||||
|
_root.visible = true
|
||||||
|
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||||
|
if _resume_button:
|
||||||
|
_resume_button.grab_focus()
|
||||||
|
|
||||||
|
func close() -> void:
|
||||||
|
visible = false
|
||||||
|
_root.visible = false
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if not visible:
|
||||||
|
return
|
||||||
|
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
|
||||||
|
resume_requested.emit()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
class_name SettingsPanel
|
||||||
|
extends CanvasLayer
|
||||||
|
## Settings: mouse look sensitivity and HUD font scale, persisted through the
|
||||||
|
## `Save` autoload into `user://settings.json`.
|
||||||
|
##
|
||||||
|
## Emits `applied(settings)` so the game manager can push values into the guard
|
||||||
|
## and HUD as soon as the player confirms.
|
||||||
|
|
||||||
|
signal applied(settings: Dictionary)
|
||||||
|
signal closed()
|
||||||
|
|
||||||
|
var _root: Control = null
|
||||||
|
var _sensitivity: HSlider = null
|
||||||
|
var _font_scale: HSlider = null
|
||||||
|
var _sensitivity_value: Label = null
|
||||||
|
var _font_scale_value: Label = null
|
||||||
|
var settings: Dictionary = {}
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
layer = 22
|
||||||
|
_build()
|
||||||
|
hide()
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
layer = 22
|
||||||
|
_build()
|
||||||
|
hide()
|
||||||
|
|
||||||
|
func _build() -> void:
|
||||||
|
_root = Control.new()
|
||||||
|
_root.name = "SettingsRoot"
|
||||||
|
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
_root.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
|
add_child(_root)
|
||||||
|
|
||||||
|
var bg := ColorRect.new()
|
||||||
|
bg.color = Color(0.02, 0.03, 0.05, 0.9)
|
||||||
|
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_root.add_child(bg)
|
||||||
|
|
||||||
|
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(420, 0)
|
||||||
|
column.add_theme_constant_override("separation", 12)
|
||||||
|
center.add_child(column)
|
||||||
|
|
||||||
|
var title := Label.new()
|
||||||
|
title.text = "Settings"
|
||||||
|
title.add_theme_font_size_override("font_size", 32)
|
||||||
|
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
column.add_child(title)
|
||||||
|
|
||||||
|
var sens_row := VBoxContainer.new()
|
||||||
|
column.add_child(sens_row)
|
||||||
|
_sensitivity_value = _add_label_row(sens_row, "Mouse look sensitivity")
|
||||||
|
_sensitivity = _add_slider(sens_row, 0.0005, 0.0060, 0.0001)
|
||||||
|
_sensitivity.value_changed.connect(_on_sensitivity_changed)
|
||||||
|
|
||||||
|
var font_row := VBoxContainer.new()
|
||||||
|
column.add_child(font_row)
|
||||||
|
_font_scale_value = _add_label_row(font_row, "HUD font scale")
|
||||||
|
_font_scale = _add_slider(font_row, 0.75, 1.75, 0.05)
|
||||||
|
_font_scale.value_changed.connect(_on_font_scale_changed)
|
||||||
|
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||||
|
row.add_theme_constant_override("separation", 12)
|
||||||
|
column.add_child(row)
|
||||||
|
|
||||||
|
var save_button := Button.new()
|
||||||
|
save_button.name = "ApplyButton"
|
||||||
|
save_button.text = "Apply"
|
||||||
|
save_button.pressed.connect(_on_apply)
|
||||||
|
row.add_child(save_button)
|
||||||
|
|
||||||
|
var cancel_button := Button.new()
|
||||||
|
cancel_button.name = "CancelButton"
|
||||||
|
cancel_button.text = "Cancel"
|
||||||
|
cancel_button.pressed.connect(_on_cancel)
|
||||||
|
row.add_child(cancel_button)
|
||||||
|
|
||||||
|
func _add_label_row(parent: Control, text: String) -> Label:
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
parent.add_child(row)
|
||||||
|
var label := Label.new()
|
||||||
|
label.text = text
|
||||||
|
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
row.add_child(label)
|
||||||
|
var value := Label.new()
|
||||||
|
value.add_theme_color_override("font_color", Color(0.75, 0.8, 0.9))
|
||||||
|
row.add_child(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
func _add_slider(parent: Control, min_v: float, max_v: float, step: float) -> HSlider:
|
||||||
|
var slider := HSlider.new()
|
||||||
|
slider.min_value = min_v
|
||||||
|
slider.max_value = max_v
|
||||||
|
slider.step = step
|
||||||
|
slider.custom_minimum_size = Vector2(420, 24)
|
||||||
|
parent.add_child(slider)
|
||||||
|
return slider
|
||||||
|
|
||||||
|
func open(current: Dictionary) -> void:
|
||||||
|
settings = current.duplicate(true)
|
||||||
|
var sens: float = float(settings.get("look_sensitivity", 0.0022))
|
||||||
|
var scale: float = float(settings.get("hud_font_scale", 1.0))
|
||||||
|
if _sensitivity:
|
||||||
|
_sensitivity.set_value_no_signal(clampf(sens, _sensitivity.min_value, _sensitivity.max_value))
|
||||||
|
if _font_scale:
|
||||||
|
_font_scale.set_value_no_signal(clampf(scale, _font_scale.min_value, _font_scale.max_value))
|
||||||
|
_sync_labels()
|
||||||
|
visible = true
|
||||||
|
_root.visible = true
|
||||||
|
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||||
|
|
||||||
|
func close() -> void:
|
||||||
|
visible = false
|
||||||
|
_root.visible = false
|
||||||
|
|
||||||
|
func _sync_labels() -> void:
|
||||||
|
if _sensitivity_value and _sensitivity:
|
||||||
|
_sensitivity_value.text = "%.4f" % _sensitivity.value
|
||||||
|
if _font_scale_value and _font_scale:
|
||||||
|
_font_scale_value.text = "%.2fx" % _font_scale.value
|
||||||
|
|
||||||
|
func _on_sensitivity_changed(value: float) -> void:
|
||||||
|
settings["look_sensitivity"] = value
|
||||||
|
_sync_labels()
|
||||||
|
|
||||||
|
func _on_font_scale_changed(value: float) -> void:
|
||||||
|
settings["hud_font_scale"] = value
|
||||||
|
_sync_labels()
|
||||||
|
|
||||||
|
func _on_apply() -> void:
|
||||||
|
if Save:
|
||||||
|
Save.save_settings(settings)
|
||||||
|
applied.emit(settings)
|
||||||
|
close()
|
||||||
|
closed.emit()
|
||||||
|
|
||||||
|
func _on_cancel() -> void:
|
||||||
|
close()
|
||||||
|
closed.emit()
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if not visible:
|
||||||
|
return
|
||||||
|
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
|
||||||
|
_on_cancel()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
@@ -2,7 +2,13 @@ class_name ShiftReview
|
|||||||
extends CanvasLayer
|
extends CanvasLayer
|
||||||
## End-of-shift review screen (PLAN.md: Evidence and Body Camera, Shift Review).
|
## End-of-shift review screen (PLAN.md: Evidence and Body Camera, Shift Review).
|
||||||
|
|
||||||
|
const Strings := preload("res://src/core/strings.gd")
|
||||||
|
|
||||||
signal restart_requested()
|
signal restart_requested()
|
||||||
|
signal timeline_requested(review: Dictionary)
|
||||||
|
|
||||||
|
## Kept so the timeline button can hand the full review back to the game manager.
|
||||||
|
var _review: Dictionary = {}
|
||||||
|
|
||||||
var report_label: Label
|
var report_label: Label
|
||||||
|
|
||||||
@@ -32,6 +38,13 @@ func _init() -> void:
|
|||||||
report_label.custom_minimum_size = Vector2(720, 0)
|
report_label.custom_minimum_size = Vector2(720, 0)
|
||||||
center.add_child(report_label)
|
center.add_child(report_label)
|
||||||
|
|
||||||
|
var timeline := Button.new()
|
||||||
|
timeline.name = "TimelineButton"
|
||||||
|
timeline.text = Strings.lookup("review.timeline_button")
|
||||||
|
timeline.pressed.connect(func(): timeline_requested.emit(_review))
|
||||||
|
timeline.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||||
|
center.add_child(timeline)
|
||||||
|
|
||||||
var restart := Button.new()
|
var restart := Button.new()
|
||||||
restart.text = "Run another shift"
|
restart.text = "Run another shift"
|
||||||
restart.pressed.connect(func(): restart_requested.emit())
|
restart.pressed.connect(func(): restart_requested.emit())
|
||||||
@@ -45,10 +58,13 @@ func _init() -> void:
|
|||||||
center.add_child(quit_btn)
|
center.add_child(quit_btn)
|
||||||
|
|
||||||
func show_review(review: Dictionary, scenario: Resource, history: Array = []) -> void:
|
func show_review(review: Dictionary, scenario: Resource, history: Array = []) -> void:
|
||||||
|
_review = review
|
||||||
var reputation: Dictionary = review.get("reputation", {})
|
var reputation: Dictionary = review.get("reputation", {})
|
||||||
var incidents: Dictionary = review.get("incidents", {})
|
var incidents: Dictionary = review.get("incidents", {})
|
||||||
var crowd: Dictionary = review.get("crowd", {})
|
var crowd: Dictionary = review.get("crowd", {})
|
||||||
var radio: Dictionary = review.get("radio", {})
|
var radio: Dictionary = review.get("radio", {})
|
||||||
|
var outcomes: Array = review.get("responder_outcomes", [])
|
||||||
|
var investigations: Array = review.get("investigations", [])
|
||||||
var incident_list: Array = incidents.get("incidents", [])
|
var incident_list: Array = incidents.get("incidents", [])
|
||||||
var peaceful := 0
|
var peaceful := 0
|
||||||
for inc in incident_list:
|
for inc in incident_list:
|
||||||
@@ -78,6 +94,8 @@ func show_review(review: Dictionary, scenario: Resource, history: Array = []) ->
|
|||||||
"NPCs encountered: %d" % review.get("spawned_count", 0),
|
"NPCs encountered: %d" % review.get("spawned_count", 0),
|
||||||
"Radio battery: %.0f%% emergency cooldown: %.0fs" % [radio.get("battery", 0.0), radio.get("emergency_cooldown_s", 0.0)],
|
"Radio battery: %.0f%% emergency cooldown: %.0fs" % [radio.get("battery", 0.0), radio.get("emergency_cooldown_s", 0.0)],
|
||||||
"Crowd mood: %.0f%% (panic: %s)" % [crowd.get("mood", 0.0) * 100.0, str(crowd.get("panic", false))],
|
"Crowd mood: %.0f%% (panic: %s)" % [crowd.get("mood", 0.0) * 100.0, str(crowd.get("panic", false))],
|
||||||
|
Strings.fmt("review.responder", [", ".join(outcomes) if outcomes.size() > 0 else Strings.lookup("review.none")]),
|
||||||
|
Strings.fmt("review.investigations", [" | ".join(investigations) if investigations.size() > 0 else Strings.lookup("review.none")]),
|
||||||
"",
|
"",
|
||||||
"Highlights:",
|
"Highlights:",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
class_name StartMenu
|
||||||
|
extends CanvasLayer
|
||||||
|
## Startup menu: pick a venue, start the shift, open settings, or quit.
|
||||||
|
## Keyboard and mouse both work. Replaces `V` as the player-facing venue path
|
||||||
|
## (the `V` bind stays as a debug shortcut).
|
||||||
|
|
||||||
|
signal venue_selected(scenario_id: String)
|
||||||
|
signal settings_requested()
|
||||||
|
signal quit_requested()
|
||||||
|
|
||||||
|
const VENUES := [
|
||||||
|
{"id": "hollywood_building", "label": "Hollywood Boulevard", "blurb": "Building entrance, tourists and late-night trouble."},
|
||||||
|
{"id": "joke_shop", "label": "The Joke Shop", "blurb": "Guest list, IDs and wand screening."},
|
||||||
|
{"id": "tv_studio", "label": "Late Night TV Studio", "blurb": "Credentials, filming boundary, celebrity arrivals."},
|
||||||
|
]
|
||||||
|
|
||||||
|
var _index := 0
|
||||||
|
var _buttons: Array[Button] = []
|
||||||
|
var _root: Control = null
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
layer = 20
|
||||||
|
_build()
|
||||||
|
hide()
|
||||||
|
|
||||||
|
func _build() -> void:
|
||||||
|
_root = Control.new()
|
||||||
|
_root.name = "StartMenuRoot"
|
||||||
|
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
_root.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
|
add_child(_root)
|
||||||
|
|
||||||
|
var bg := ColorRect.new()
|
||||||
|
bg.color = Color(0.03, 0.04, 0.07, 0.96)
|
||||||
|
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_root.add_child(bg)
|
||||||
|
|
||||||
|
var center := CenterContainer.new()
|
||||||
|
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
_root.add_child(center)
|
||||||
|
|
||||||
|
var column := VBoxContainer.new()
|
||||||
|
column.name = "Column"
|
||||||
|
column.custom_minimum_size = Vector2(460, 0)
|
||||||
|
column.add_theme_constant_override("separation", 14)
|
||||||
|
center.add_child(column)
|
||||||
|
|
||||||
|
var title := Label.new()
|
||||||
|
title.text = "STEP LINE"
|
||||||
|
title.add_theme_font_size_override("font_size", 44)
|
||||||
|
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
column.add_child(title)
|
||||||
|
|
||||||
|
var subtitle := Label.new()
|
||||||
|
subtitle.text = "Pick a venue to start your shift."
|
||||||
|
subtitle.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
subtitle.add_theme_color_override("font_color", Color(0.72, 0.75, 0.82))
|
||||||
|
column.add_child(subtitle)
|
||||||
|
|
||||||
|
for i in VENUES.size():
|
||||||
|
var venue: Dictionary = VENUES[i]
|
||||||
|
var button := Button.new()
|
||||||
|
button.name = "Venue_%s" % venue["id"]
|
||||||
|
button.text = "%s\n%s" % [venue["label"], venue["blurb"]]
|
||||||
|
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||||
|
button.custom_minimum_size = Vector2(460, 62)
|
||||||
|
button.pressed.connect(_on_venue_pressed.bind(i))
|
||||||
|
column.add_child(button)
|
||||||
|
_buttons.append(button)
|
||||||
|
|
||||||
|
var spacer := Control.new()
|
||||||
|
spacer.custom_minimum_size = Vector2(0, 10)
|
||||||
|
column.add_child(spacer)
|
||||||
|
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||||
|
row.add_theme_constant_override("separation", 12)
|
||||||
|
column.add_child(row)
|
||||||
|
|
||||||
|
var settings_button := Button.new()
|
||||||
|
settings_button.name = "SettingsButton"
|
||||||
|
settings_button.text = "Settings"
|
||||||
|
settings_button.pressed.connect(func(): settings_requested.emit())
|
||||||
|
row.add_child(settings_button)
|
||||||
|
|
||||||
|
var quit_button := Button.new()
|
||||||
|
quit_button.name = "QuitButton"
|
||||||
|
quit_button.text = "Quit"
|
||||||
|
quit_button.pressed.connect(func(): quit_requested.emit())
|
||||||
|
row.add_child(quit_button)
|
||||||
|
|
||||||
|
var hint := Label.new()
|
||||||
|
hint.text = "Up/Down to choose, Enter to start, Esc to quit"
|
||||||
|
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
hint.add_theme_color_override("font_color", Color(0.55, 0.58, 0.65))
|
||||||
|
column.add_child(hint)
|
||||||
|
|
||||||
|
_focus_index(0)
|
||||||
|
|
||||||
|
func _focus_index(i: int) -> void:
|
||||||
|
if _buttons.is_empty():
|
||||||
|
return
|
||||||
|
_index = clampi(i, 0, _buttons.size() - 1)
|
||||||
|
_buttons[_index].grab_focus()
|
||||||
|
|
||||||
|
func open() -> void:
|
||||||
|
visible = true
|
||||||
|
_root.visible = true
|
||||||
|
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||||
|
_focus_index(_index)
|
||||||
|
|
||||||
|
func close() -> void:
|
||||||
|
visible = false
|
||||||
|
_root.visible = false
|
||||||
|
|
||||||
|
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_UP, KEY_W:
|
||||||
|
_focus_index(_index - 1)
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
KEY_DOWN, KEY_S:
|
||||||
|
_focus_index(_index + 1)
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
KEY_ENTER, KEY_KP_ENTER, KEY_SPACE:
|
||||||
|
_on_venue_pressed(_index)
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
KEY_ESCAPE:
|
||||||
|
quit_requested.emit()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
func _on_venue_pressed(i: int) -> void:
|
||||||
|
if i < 0 or i >= VENUES.size():
|
||||||
|
return
|
||||||
|
var venue: Dictionary = VENUES[i]
|
||||||
|
close()
|
||||||
|
venue_selected.emit(str(venue["id"]))
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
class_name WorldBuilder
|
class_name WorldBuilder
|
||||||
## Procedurally assembles a minimal Hollywood Boulevard entrance prototype:
|
## Procedurally assembles a minimal Hollywood Boulevard entrance prototype:
|
||||||
|
|
||||||
const T_BRICK := preload("res://assets/textures/brick_facade.png")
|
const T_BRICK := preload("res://assets/textures/ai/game_ready/brick_facade.png")
|
||||||
const T_CONCRETE := preload("res://assets/textures/concrete.png")
|
const T_CONCRETE := preload("res://assets/textures/ai/game_ready/concrete.png")
|
||||||
const T_ASPHALT := preload("res://assets/textures/asphalt.png")
|
const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.png")
|
||||||
const T_AWNING := preload("res://assets/textures/awning.png")
|
const T_AWNING := preload("res://assets/textures/ai/game_ready/awning.png")
|
||||||
const T_SKYLINE := preload("res://assets/textures/skyline.png")
|
const T_SKYLINE := preload("res://assets/textures/skyline.png")
|
||||||
## steps, building facade, restricted door, sidewalk, pavement, and street edge.
|
## steps, building facade, restricted door, sidewalk, pavement, and street edge.
|
||||||
## No art assets required; all geometry is BoxMesh with flat materials.
|
## No art assets required; all geometry is BoxMesh with flat materials.
|
||||||
@@ -26,6 +26,7 @@ func _init(root_node: Node3D) -> void:
|
|||||||
func build() -> void:
|
func build() -> void:
|
||||||
_build_ground()
|
_build_ground()
|
||||||
_build_building()
|
_build_building()
|
||||||
|
_build_skyline_backdrop()
|
||||||
_build_marquee()
|
_build_marquee()
|
||||||
_build_steps()
|
_build_steps()
|
||||||
_build_sidewalk()
|
_build_sidewalk()
|
||||||
@@ -33,7 +34,6 @@ func build() -> void:
|
|||||||
_build_crosswalk()
|
_build_crosswalk()
|
||||||
_build_street_furniture()
|
_build_street_furniture()
|
||||||
_build_guard_spawn()
|
_build_guard_spawn()
|
||||||
_build_skyline_backdrop()
|
|
||||||
_build_navmesh()
|
_build_navmesh()
|
||||||
set_night_mode(false)
|
set_night_mode(false)
|
||||||
|
|
||||||
@@ -57,9 +57,11 @@ func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_sh
|
|||||||
func _build_ground() -> void:
|
func _build_ground() -> void:
|
||||||
var ground := StaticBody3D.new()
|
var ground := StaticBody3D.new()
|
||||||
ground.name = "Ground"
|
ground.name = "Ground"
|
||||||
_add_box(root, Vector3(160, 0.5, 160), Vector3(0, -0.25, 20), Color(0.22, 0.24, 0.26))
|
# Large enough that the ground meets the distant skyline ring (no gap of sky
|
||||||
|
# under the buildings when looking down the boulevard).
|
||||||
|
_add_box(root, Vector3(500, 0.5, 500), Vector3(0, -0.25, 20), Color(0.22, 0.24, 0.26))
|
||||||
var col := BoxShape3D.new()
|
var col := BoxShape3D.new()
|
||||||
col.size = Vector3(160, 1, 160)
|
col.size = Vector3(500, 1, 500)
|
||||||
var shape_owner := ground.create_shape_owner(ground)
|
var shape_owner := ground.create_shape_owner(ground)
|
||||||
ground.shape_owner_add_shape(shape_owner, col)
|
ground.shape_owner_add_shape(shape_owner, col)
|
||||||
root.add_child(ground)
|
root.add_child(ground)
|
||||||
@@ -68,13 +70,13 @@ func _build_building() -> void:
|
|||||||
var building := StaticBody3D.new()
|
var building := StaticBody3D.new()
|
||||||
building.name = "Building"
|
building.name = "Building"
|
||||||
# Main facade (textured brick)
|
# Main facade (textured brick)
|
||||||
_add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -2), Color(0.85, 0.8, 0.78), false, T_BRICK, Vector2(10, 5))
|
_add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -6), Color(0.85, 0.8, 0.78), false, T_BRICK, Vector2(10, 5))
|
||||||
# Entrance recess + door recess frame
|
# Entrance recess + door recess frame
|
||||||
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -4.0), Color(0.08, 0.08, 0.09))
|
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -3.9), Color(0.08, 0.08, 0.09))
|
||||||
# Door frame and dark door
|
# Door frame and dark door
|
||||||
_add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -4.15), Color(0.13, 0.12, 0.11))
|
_add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -3.98), Color(0.13, 0.12, 0.11))
|
||||||
# Door glass (semi-transparent)
|
# Door glass (semi-transparent)
|
||||||
var glass := _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -4.0), Color(0.6, 0.75, 0.9, 0.55))
|
var glass := _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -3.9), Color(0.6, 0.75, 0.9, 0.55))
|
||||||
var glass_mat := StandardMaterial3D.new()
|
var glass_mat := StandardMaterial3D.new()
|
||||||
glass_mat.albedo_color = Color(0.6, 0.75, 0.9, 0.45)
|
glass_mat.albedo_color = Color(0.6, 0.75, 0.9, 0.45)
|
||||||
glass_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
glass_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||||
@@ -82,30 +84,42 @@ func _build_building() -> void:
|
|||||||
glass_mat.metallic = 0.1
|
glass_mat.metallic = 0.1
|
||||||
glass.material_override = glass_mat
|
glass.material_override = glass_mat
|
||||||
# Door lock indicator (green = open, red = locked), placed beside the handle
|
# Door lock indicator (green = open, red = locked), placed beside the handle
|
||||||
door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -4.35), Color(0.1, 0.9, 0.3))
|
door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -3.82), Color(0.1, 0.9, 0.3))
|
||||||
door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3))
|
door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3))
|
||||||
var col := BoxShape3D.new()
|
var col := BoxShape3D.new()
|
||||||
col.size = Vector3(40, 18, 4)
|
col.size = Vector3(40, 18, 4)
|
||||||
var so := building.create_shape_owner(building)
|
var so := building.create_shape_owner(building)
|
||||||
building.shape_owner_add_shape(so, col)
|
building.shape_owner_add_shape(so, col)
|
||||||
building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -2)))
|
building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -6)))
|
||||||
root.add_child(building)
|
root.add_child(building)
|
||||||
|
|
||||||
func _build_steps() -> void:
|
func _build_steps() -> void:
|
||||||
# Steps are visual only (no collider) so NPCs and the guard can walk up the
|
# Steps have collision so NPCs and the guard can walk up the entrance.
|
||||||
# entrance on the flat navigation mesh; the ground and building provide the
|
# The ground and building provide the actual collision boundaries.
|
||||||
# actual collision boundaries.
|
var steps := StaticBody3D.new()
|
||||||
var steps := Node3D.new()
|
|
||||||
steps.name = "Steps"
|
steps.name = "Steps"
|
||||||
var step_w := 8.0
|
var step_w := 8.0
|
||||||
var tread := 0.6
|
var tread := 0.6
|
||||||
var height := 0.16
|
var height := 0.16
|
||||||
for i in 4:
|
for i in 4:
|
||||||
var y: float = i * height
|
var y: float = i * height
|
||||||
var z: float = i * tread
|
var z: float = 4.0 * tread - i * tread
|
||||||
_add_box(steps, Vector3(step_w - i * 0.35, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5))
|
var w: float = step_w - i * 0.35
|
||||||
# Landing at the top
|
_add_box(steps, Vector3(w, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5))
|
||||||
_add_box(steps, Vector3(9, 0.2, 3), Vector3(0, 4 * height + 0.1, -1.5), Color(0.5, 0.47, 0.45))
|
# Add collision shape to each tread
|
||||||
|
var tread_col := BoxShape3D.new()
|
||||||
|
tread_col.size = Vector3(w, height, tread)
|
||||||
|
var owner_id := steps.create_shape_owner(steps)
|
||||||
|
steps.shape_owner_add_shape(owner_id, tread_col)
|
||||||
|
steps.shape_owner_set_transform(owner_id, Transform3D(Basis(), Vector3(0, y + height / 2.0, z)))
|
||||||
|
# Landing at the top with collision
|
||||||
|
var landing_y: float = 4 * height + 0.1
|
||||||
|
_add_box(steps, Vector3(9, 0.2, 3), Vector3(0, landing_y, -1.5), Color(0.5, 0.47, 0.45))
|
||||||
|
var landing_col := BoxShape3D.new()
|
||||||
|
landing_col.size = Vector3(9, 0.2, 3)
|
||||||
|
var landing_owner := steps.create_shape_owner(steps)
|
||||||
|
steps.shape_owner_add_shape(landing_owner, landing_col)
|
||||||
|
steps.shape_owner_set_transform(landing_owner, Transform3D(Basis(), Vector3(0, landing_y, -1.5)))
|
||||||
root.add_child(steps)
|
root.add_child(steps)
|
||||||
|
|
||||||
func _build_sidewalk() -> void:
|
func _build_sidewalk() -> void:
|
||||||
@@ -152,6 +166,53 @@ func _build_navmesh() -> void:
|
|||||||
region.navigation_mesh = mesh
|
region.navigation_mesh = mesh
|
||||||
root.add_child(region)
|
root.add_child(region)
|
||||||
|
|
||||||
|
func _build_skyline_backdrop() -> void:
|
||||||
|
## Distant skyline surrounding the block. The texture's sky is transparent
|
||||||
|
## (alpha scissor), so only building silhouettes show. Four planes form a
|
||||||
|
## ring: a single plane placed behind the building reads as a floating black
|
||||||
|
## band from the sidewalk.
|
||||||
|
var backdrop := Node3D.new()
|
||||||
|
backdrop.name = "SkylineBackdrop"
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.albedo_texture = T_SKYLINE
|
||||||
|
mat.texture_repeat = true
|
||||||
|
mat.uv1_scale = Vector3(3.0, 1.0, 1.0)
|
||||||
|
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
|
||||||
|
mat.alpha_scissor_threshold = 0.5
|
||||||
|
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
|
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||||
|
# Unshaded silhouettes read as pure black at 255-scale; lift the albedo so the
|
||||||
|
# distant skyline sits closer to a hazy daytime city.
|
||||||
|
mat.albedo_color = Color(2.6, 2.7, 3.0, 1.0)
|
||||||
|
_skyline_mat = mat
|
||||||
|
var mesh := PlaneMesh.new()
|
||||||
|
# PlaneMesh defaults to a horizontal (XZ) plane; the skyline must stand up.
|
||||||
|
mesh.orientation = PlaneMesh.FACE_Z
|
||||||
|
mesh.size = Vector2(400, 60)
|
||||||
|
# Far enough that the skyline reads as background and its top edge stays out
|
||||||
|
# of the middle of the sky.
|
||||||
|
const DIST := 200.0
|
||||||
|
# The texture keeps its buildings in the lower part of the image, so the plane
|
||||||
|
# center sits at half its height: the building bases meet the ground.
|
||||||
|
const CENTER_Y := 30.0
|
||||||
|
var placements := [
|
||||||
|
[Vector3(0, CENTER_Y, -DIST), 0.0],
|
||||||
|
[Vector3(0, CENTER_Y, DIST), 0.0],
|
||||||
|
[Vector3(-DIST, CENTER_Y, 0), PI / 2.0],
|
||||||
|
[Vector3(DIST, CENTER_Y, 0), PI / 2.0],
|
||||||
|
]
|
||||||
|
for placement in placements:
|
||||||
|
var plane := MeshInstance3D.new()
|
||||||
|
plane.mesh = mesh
|
||||||
|
plane.material_override = mat
|
||||||
|
plane.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||||
|
plane.position = placement[0]
|
||||||
|
plane.rotation.y = placement[1]
|
||||||
|
backdrop.add_child(plane)
|
||||||
|
root.add_child(backdrop)
|
||||||
|
|
||||||
|
var _skyline_mat: StandardMaterial3D = null
|
||||||
|
|
||||||
func _build_marquee() -> void:
|
func _build_marquee() -> void:
|
||||||
## Striped awning above the door, then the neon sign strip + label.
|
## Striped awning above the door, then the neon sign strip + label.
|
||||||
var awning := _add_box(root, Vector3(10.5, 0.08, 1.8), Vector3(0, 6.55, -3.7), Color(1, 1, 1), false, T_AWNING)
|
var awning := _add_box(root, Vector3(10.5, 0.08, 1.8), Vector3(0, 6.55, -3.7), Color(1, 1, 1), false, T_AWNING)
|
||||||
@@ -169,9 +230,9 @@ func _build_marquee() -> void:
|
|||||||
marquee_label.text = "STEP LINE"
|
marquee_label.text = "STEP LINE"
|
||||||
marquee_label.position = Vector3(0, 7.3, -3.55)
|
marquee_label.position = Vector3(0, 7.3, -3.55)
|
||||||
marquee_label.modulate = Color(1, 0.6, 0.9)
|
marquee_label.modulate = Color(1, 0.6, 0.9)
|
||||||
marquee_label.font_size = 72
|
marquee_label.font_size = 160
|
||||||
marquee_label.pixel_size = 0.004
|
marquee_label.pixel_size = 0.004
|
||||||
marquee_label.outline_size = 12
|
marquee_label.outline_size = 24
|
||||||
marquee_label.outline_modulate = Color(0.9, 0.15, 0.5)
|
marquee_label.outline_modulate = Color(0.9, 0.15, 0.5)
|
||||||
marquee_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
marquee_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||||
marquee_label.no_depth_test = false
|
marquee_label.no_depth_test = false
|
||||||
@@ -206,8 +267,10 @@ func _build_street_furniture() -> void:
|
|||||||
# Low-poly trees along the street front.
|
# Low-poly trees along the street front.
|
||||||
var trunk_color := Color(0.35, 0.25, 0.18)
|
var trunk_color := Color(0.35, 0.25, 0.18)
|
||||||
var leaf_color := Color(0.16, 0.42, 0.2)
|
var leaf_color := Color(0.16, 0.42, 0.2)
|
||||||
|
# Flank the walkway; keep the sight line to the door clear.
|
||||||
for i in 6:
|
for i in 6:
|
||||||
var x := -11.0 + i * 4.4
|
var side := -1.0 if i % 2 == 0 else 1.0
|
||||||
|
var x := side * (6.5 + float(int(i / 2)) * 3.0)
|
||||||
var z := 4.0 + float((i * 7) % 5)
|
var z := 4.0 + float((i * 7) % 5)
|
||||||
var tree := Node3D.new()
|
var tree := Node3D.new()
|
||||||
tree.name = "Tree_%d" % (i + 1)
|
tree.name = "Tree_%d" % (i + 1)
|
||||||
@@ -261,32 +324,15 @@ func _plain_material(color: Color) -> StandardMaterial3D:
|
|||||||
mat.albedo_color = color
|
mat.albedo_color = color
|
||||||
return mat
|
return mat
|
||||||
|
|
||||||
func _build_skyline_backdrop() -> void:
|
|
||||||
## Distant procedural skyline on four unshaded planes around the block.
|
|
||||||
var mat := StandardMaterial3D.new()
|
|
||||||
mat.albedo_texture = T_SKYLINE
|
|
||||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
||||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
||||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
|
||||||
const DIST := 95.0
|
|
||||||
const HEIGHT := 90.0
|
|
||||||
for i in 4:
|
|
||||||
var plane := MeshInstance3D.new()
|
|
||||||
plane.name = "Skyline_%d" % i
|
|
||||||
var pm := PlaneMesh.new()
|
|
||||||
pm.size = Vector2(300, HEIGHT)
|
|
||||||
plane.mesh = pm
|
|
||||||
plane.material_override = mat
|
|
||||||
var angle := i * 90.0
|
|
||||||
plane.position = Vector3(sin(deg_to_rad(angle)) * DIST, HEIGHT / 2.0, cos(deg_to_rad(angle)) * DIST)
|
|
||||||
plane.rotation_degrees = Vector3(0, -angle + 90.0, 0)
|
|
||||||
plane.cast_shadow = (0 as GeometryInstance3D.ShadowCastingSetting)
|
|
||||||
root.add_child(plane)
|
|
||||||
|
|
||||||
func _build_guard_spawn() -> void:
|
func _build_guard_spawn() -> void:
|
||||||
var marker := Marker3D.new()
|
var marker := Marker3D.new()
|
||||||
marker.name = "GuardSpawn"
|
marker.name = "GuardSpawn"
|
||||||
marker.position = Vector3(0, 1.6, -2.5)
|
# Guard spawns on the landing at the top of the steps, facing the street.
|
||||||
|
# The landing is at y=4*height+0.1 where height=0.16, so y=0.76
|
||||||
|
# The guard's head offset is 1.6m, so spawn at y=0.76-1.6=-0.84 to place feet on ground
|
||||||
|
# But we want the guard at eye-level with the sidewalk, so adjust to y=0.1 for feet
|
||||||
|
# Then the head will be at y=1.7 (1.6 offset + 0.1 base)
|
||||||
|
marker.position = Vector3(0, 0.1, -2.0)
|
||||||
root.add_child(marker)
|
root.add_child(marker)
|
||||||
|
|
||||||
func _lock_indicator_material(color: Color) -> StandardMaterial3D:
|
func _lock_indicator_material(color: Color) -> StandardMaterial3D:
|
||||||
|
|||||||
@@ -31,8 +31,13 @@ func _process(_delta: float) -> bool:
|
|||||||
quit(1)
|
quit(1)
|
||||||
return true
|
return true
|
||||||
if gm != null and not camera_staged:
|
if gm != null and not camera_staged:
|
||||||
gm.guard.global_position = Vector3(0, 1.6, 5.0)
|
# The game boots into the venue menu now; start the Hollywood shift first.
|
||||||
gm.guard.head.rotation.x = deg_to_rad(-8.0)
|
var menu = gm.get("start_menu")
|
||||||
|
if menu != null and menu.visible:
|
||||||
|
gm.call("_on_venue_selected", "hollywood_building")
|
||||||
|
gm.guard.global_position = Vector3(0, 0.1, 10.5)
|
||||||
|
gm.guard.rotation.y = 0.0
|
||||||
|
gm.guard.head.rotation.x = deg_to_rad(2.0)
|
||||||
camera_staged = true
|
camera_staged = true
|
||||||
if frames > MAX_FRAMES:
|
if frames > MAX_FRAMES:
|
||||||
print("timeout reached")
|
print("timeout reached")
|
||||||
@@ -51,9 +56,11 @@ func _advance() -> void:
|
|||||||
state = 1
|
state = 1
|
||||||
state_frames = 0
|
state_frames = 0
|
||||||
1:
|
1:
|
||||||
# Wait for a nearby NPC so the interaction HUD has a target.
|
# Wait for a nearby NPC so the interaction HUD has a target. NPCs now
|
||||||
|
# walk in from the street, so this can take a few simulated seconds.
|
||||||
if gm != null and gm.npcs.size() > 0:
|
if gm != null and gm.npcs.size() > 0:
|
||||||
gm.call("_cycle_nearby_target")
|
gm.call("_cycle_nearby_target")
|
||||||
|
_face_target()
|
||||||
targeted = true
|
targeted = true
|
||||||
state = 2
|
state = 2
|
||||||
state_frames = 0
|
state_frames = 0
|
||||||
@@ -61,6 +68,7 @@ func _advance() -> void:
|
|||||||
if state_frames >= 30:
|
if state_frames >= 30:
|
||||||
_shoot("02-targeting-npc.png")
|
_shoot("02-targeting-npc.png")
|
||||||
if targeted and gm != null:
|
if targeted and gm != null:
|
||||||
|
_face_target()
|
||||||
gm.call("_toggle_inspection")
|
gm.call("_toggle_inspection")
|
||||||
state = 3
|
state = 3
|
||||||
state_frames = 0
|
state_frames = 0
|
||||||
@@ -80,24 +88,42 @@ func _advance() -> void:
|
|||||||
# Close the radio form, then jump the shift to the LATE phase.
|
# Close the radio form, then jump the shift to the LATE phase.
|
||||||
if gm != null:
|
if gm != null:
|
||||||
gm.radio_panel.hide()
|
gm.radio_panel.hide()
|
||||||
|
gm.hud.set_action_hint("")
|
||||||
gm.coordinator.elapsed_s = 750.0
|
gm.coordinator.elapsed_s = 750.0
|
||||||
state = 5
|
state = 5
|
||||||
state_frames = 0
|
state_frames = 0
|
||||||
5:
|
5:
|
||||||
# Let the phase transition (and street lamps / marquee) settle, then
|
# Stage the avenue view down the boulevard (not into the wall) and let
|
||||||
# take the night avenue shot.
|
# the night lighting and street lamps settle.
|
||||||
|
if state_frames == 1 and gm != null:
|
||||||
|
gm.guard.rotation.y = deg_to_rad(-70)
|
||||||
|
gm.guard.head.rotation.x = deg_to_rad(3.0)
|
||||||
if state_frames >= 90:
|
if state_frames >= 90:
|
||||||
_shoot("05-late-night-avenue.png")
|
_shoot("05-late-night-avenue.png")
|
||||||
if gm != null:
|
|
||||||
gm.call("_toggle_flashlight")
|
|
||||||
state = 6
|
state = 6
|
||||||
state_frames = 0
|
state_frames = 0
|
||||||
6:
|
6:
|
||||||
if state_frames >= 40:
|
# Face the entrance again and switch the flashlight on for the night set.
|
||||||
|
if state_frames == 1 and gm != null:
|
||||||
|
gm.guard.rotation.y = 0.0
|
||||||
|
gm.guard.head.rotation.x = deg_to_rad(4.0)
|
||||||
|
gm.call("_toggle_flashlight")
|
||||||
|
if state_frames >= 30:
|
||||||
_shoot("06-night-flashlight.png")
|
_shoot("06-night-flashlight.png")
|
||||||
state = 7
|
state = 7
|
||||||
quit(0)
|
quit(0)
|
||||||
|
|
||||||
|
func _face_target() -> void:
|
||||||
|
var target: Node3D = gm.targeted_npc
|
||||||
|
if target == null:
|
||||||
|
return
|
||||||
|
var to: Vector3 = target.global_position - gm.guard.global_position
|
||||||
|
to.y = 0.0
|
||||||
|
if to.length() < 0.01:
|
||||||
|
return
|
||||||
|
gm.guard.rotation.y = atan2(-to.x, -to.z)
|
||||||
|
gm.guard.head.rotation.x = deg_to_rad(-4.0)
|
||||||
|
|
||||||
func _shoot(file_name: String) -> void:
|
func _shoot(file_name: String) -> void:
|
||||||
var dir := DirAccess.open("user://")
|
var dir := DirAccess.open("user://")
|
||||||
if dir == null or not dir.dir_exists("screenshots"):
|
if dir == null or not dir.dir_exists("screenshots"):
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Audio smoke: starts a shift, verifies the crowd bed is playing and follows mood
|
||||||
|
## events, and that opening/closing the radio triggers crackle cues.
|
||||||
|
##
|
||||||
|
## Usage:
|
||||||
|
## godot --headless --path . --script res://src/test/smoke_audio.gd
|
||||||
|
## (Headless uses a dummy audio server; the calls must still be safe.)
|
||||||
|
|
||||||
|
const MAX_FRAMES := 900
|
||||||
|
|
||||||
|
var frame := 0
|
||||||
|
var gm: Node = null
|
||||||
|
var started := false
|
||||||
|
var step := 0
|
||||||
|
var failures: Array[String] = []
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
root.size = Vector2i(1280, 720)
|
||||||
|
root.add_child(load("res://scenes/main.tscn").instantiate())
|
||||||
|
|
||||||
|
func _process(_delta: float) -> bool:
|
||||||
|
frame += 1
|
||||||
|
if gm == null:
|
||||||
|
gm = root.find_child("GameManager", true, false)
|
||||||
|
return false
|
||||||
|
if not started:
|
||||||
|
gm.call("_on_venue_selected", "hollywood_building")
|
||||||
|
started = true
|
||||||
|
return false
|
||||||
|
if frame > MAX_FRAMES:
|
||||||
|
_fail("timeout")
|
||||||
|
return true
|
||||||
|
if frame < 120:
|
||||||
|
return false
|
||||||
|
match step:
|
||||||
|
0:
|
||||||
|
_check(gm.audio.bed_playing, "the crowd bed is playing during the shift")
|
||||||
|
var before: float = gm.audio.crowd_level
|
||||||
|
gm.coordinator.crowd.apply_event("aggressive_behavior")
|
||||||
|
_check(gm.audio.crowd_level > before, "an aggressive event raises the bed level")
|
||||||
|
gm.coordinator.crowd.apply_event("emergency_responder")
|
||||||
|
_check(gm.audio.crowd_level > before, "the responder keeps it up")
|
||||||
|
step = 1
|
||||||
|
1:
|
||||||
|
if gm.npcs.is_empty():
|
||||||
|
return false
|
||||||
|
gm.call("_on_guard_target", gm.npcs[0])
|
||||||
|
var crackles: int = gm.audio.radio_crackles
|
||||||
|
gm.call("_open_radio", false)
|
||||||
|
_check(gm.audio.radio_crackles == crackles + 1, "opening the radio crackles once")
|
||||||
|
gm.call("_on_radio_submitted", {
|
||||||
|
"npc_id": gm.current_incident_npc_id,
|
||||||
|
"location": "entrance",
|
||||||
|
"description": "tourist",
|
||||||
|
"behavior": "standing",
|
||||||
|
"weapons_seen": false,
|
||||||
|
"danger_level": 1,
|
||||||
|
}, false)
|
||||||
|
_check(gm.audio.radio_crackles == crackles + 2, "closing the radio crackles once")
|
||||||
|
gm.coordinator.call("_finish_shift")
|
||||||
|
_check(not gm.audio.bed_playing, "the bed stops when the shift ends")
|
||||||
|
step = 2
|
||||||
|
2:
|
||||||
|
_finish()
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
func _check(condition: bool, message: String) -> void:
|
||||||
|
if condition:
|
||||||
|
print("ok: " + message)
|
||||||
|
else:
|
||||||
|
_fail(message)
|
||||||
|
|
||||||
|
func _fail(message: String) -> void:
|
||||||
|
failures.append(message)
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
func _finish() -> void:
|
||||||
|
if failures.is_empty():
|
||||||
|
print("SMOKE OK: crowd bed and radio crackle")
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
for message in failures:
|
||||||
|
printerr("SMOKE FAIL: " + message)
|
||||||
|
quit(1)
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Crowd-presentation smoke: starts a shift, waits for bystanders to settle, then
|
||||||
|
## checks that an aggressive crowd event makes idle NPCs stop and watch, and that
|
||||||
|
## panic sends them away from the door.
|
||||||
|
##
|
||||||
|
## Usage:
|
||||||
|
## godot --headless --path . --script res://src/test/smoke_crowd.gd
|
||||||
|
## Exit code 0 = pass, 1 = failure.
|
||||||
|
|
||||||
|
const MAX_FRAMES := 2400
|
||||||
|
|
||||||
|
var frame := 0
|
||||||
|
var gm: Node = null
|
||||||
|
var started := false
|
||||||
|
var phase := 0
|
||||||
|
var failures: Array[String] = []
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
root.size = Vector2i(1280, 720)
|
||||||
|
root.add_child(load("res://scenes/main.tscn").instantiate())
|
||||||
|
|
||||||
|
func _process(_delta: float) -> bool:
|
||||||
|
frame += 1
|
||||||
|
if gm == null:
|
||||||
|
gm = root.find_child("GameManager", true, false)
|
||||||
|
return false
|
||||||
|
if not started:
|
||||||
|
gm.call("_on_venue_selected", "hollywood_building")
|
||||||
|
started = true
|
||||||
|
return false
|
||||||
|
if frame > MAX_FRAMES:
|
||||||
|
_fail("timeout before crowd reactions were observed")
|
||||||
|
return true
|
||||||
|
match phase:
|
||||||
|
0:
|
||||||
|
if frame < 300:
|
||||||
|
return false
|
||||||
|
# Wait until at least one settled bystander is away from the door (someone
|
||||||
|
# waiting at the entrance is being dealt with and must not rubberneck).
|
||||||
|
if _bystander_count() == 0:
|
||||||
|
return false
|
||||||
|
gm.coordinator.crowd.apply_event("aggressive_behavior")
|
||||||
|
var watching := _count_mode("watch")
|
||||||
|
_check(watching > 0, "aggressive behavior makes idle NPCs watch (%d watching)" % watching)
|
||||||
|
phase = 1
|
||||||
|
1:
|
||||||
|
if frame < 320:
|
||||||
|
return false
|
||||||
|
var door: Vector3 = gm.selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
|
||||||
|
for npc in gm.npcs:
|
||||||
|
if is_instance_valid(npc) and npc.has_method("reaction_is_active"):
|
||||||
|
pass
|
||||||
|
gm.coordinator.crowd.apply_event("visible_weapon")
|
||||||
|
gm.coordinator.crowd.apply_event("aggressive_behavior")
|
||||||
|
gm.coordinator.crowd.apply_event("aggressive_behavior")
|
||||||
|
gm.coordinator.crowd.apply_event("emergency_responder")
|
||||||
|
_check(gm.coordinator.crowd.should_flee(), "stacked alarms panic the crowd")
|
||||||
|
var fleeing := _count_mode("flee")
|
||||||
|
_check(fleeing > 0, "panic sends NPCs away from the door (%d fleeing)" % fleeing)
|
||||||
|
var away_ok := true
|
||||||
|
for npc in gm.npcs:
|
||||||
|
if not is_instance_valid(npc) or npc.reaction_mode != "flee":
|
||||||
|
continue
|
||||||
|
if (npc.target_position - door).length() <= (npc.global_position - door).length():
|
||||||
|
away_ok = false
|
||||||
|
_check(away_ok, "every fleeing NPC targets a point farther from the door")
|
||||||
|
phase = 2
|
||||||
|
_:
|
||||||
|
_finish()
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
func _bystander_count() -> int:
|
||||||
|
var n := 0
|
||||||
|
for npc in gm.npcs:
|
||||||
|
if not is_instance_valid(npc) or not npc.arrived:
|
||||||
|
continue
|
||||||
|
if npc.awaiting_interaction and npc.global_position.distance_to(npc.door_position) < 3.0:
|
||||||
|
continue
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
func _settled_count() -> int:
|
||||||
|
var n := 0
|
||||||
|
for npc in gm.npcs:
|
||||||
|
if is_instance_valid(npc) and npc.arrived:
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
func _count_mode(mode: String) -> int:
|
||||||
|
var n := 0
|
||||||
|
for npc in gm.npcs:
|
||||||
|
if is_instance_valid(npc) and npc.reaction_mode == mode:
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
func _check(condition: bool, message: String) -> void:
|
||||||
|
if condition:
|
||||||
|
print("ok: " + message)
|
||||||
|
else:
|
||||||
|
_fail(message)
|
||||||
|
|
||||||
|
func _fail(message: String) -> void:
|
||||||
|
failures.append(message)
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
func _finish() -> void:
|
||||||
|
if failures.is_empty():
|
||||||
|
print("SMOKE OK: crowd rubberneck and panic flee")
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
for message in failures:
|
||||||
|
printerr("SMOKE FAIL: " + message)
|
||||||
|
quit(1)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Emergency-aftermath smoke: places an unjustified emergency call, checks the
|
||||||
|
## investigation note and reputation hit, forces the responder to arrive, and
|
||||||
|
## verifies the evacuation outcome locks the door and clears the entrance.
|
||||||
|
##
|
||||||
|
## Usage:
|
||||||
|
## godot --headless --path . --script res://src/test/smoke_emergency.gd
|
||||||
|
## Exit code 0 = pass, 1 = failure.
|
||||||
|
|
||||||
|
const MAX_FRAMES := 900
|
||||||
|
|
||||||
|
var frame := 0
|
||||||
|
var gm: Node = null
|
||||||
|
var started := false
|
||||||
|
var step := 0
|
||||||
|
var failures: Array[String] = []
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
root.size = Vector2i(1280, 720)
|
||||||
|
root.add_child(load("res://scenes/main.tscn").instantiate())
|
||||||
|
|
||||||
|
func _process(_delta: float) -> bool:
|
||||||
|
frame += 1
|
||||||
|
if gm == null:
|
||||||
|
gm = root.find_child("GameManager", true, false)
|
||||||
|
return false
|
||||||
|
if not started:
|
||||||
|
gm.call("_on_venue_selected", "hollywood_building")
|
||||||
|
started = true
|
||||||
|
return false
|
||||||
|
if frame > MAX_FRAMES:
|
||||||
|
_fail("timeout before the emergency aftermath ran")
|
||||||
|
return true
|
||||||
|
if frame < 200:
|
||||||
|
return false
|
||||||
|
if step == 0:
|
||||||
|
if gm.npcs.is_empty():
|
||||||
|
return false
|
||||||
|
gm.call("_on_guard_target", gm.npcs[0])
|
||||||
|
# A tourist: no weapon, low danger.
|
||||||
|
gm.current_npc_truth = {"weapons_seen": false, "danger_level": 1}
|
||||||
|
var rating_before: float = gm.coordinator.reputation.rating
|
||||||
|
gm.call("_on_radio_submitted", {
|
||||||
|
"npc_id": gm.current_incident_npc_id,
|
||||||
|
"location": "entrance",
|
||||||
|
"description": "tourist",
|
||||||
|
"behavior": "standing",
|
||||||
|
"weapons_seen": false,
|
||||||
|
"danger_level": 1,
|
||||||
|
}, true)
|
||||||
|
_check(gm.coordinator.investigations.size() == 1, "unjustified emergency opens one investigation")
|
||||||
|
_check(gm.coordinator.reputation.rating < rating_before, "unjustified emergency loses reputation")
|
||||||
|
_check(gm.coordinator.radio.pending_calls.size() == 1, "the emergency call is pending")
|
||||||
|
_check(not gm.door.locked, "the door is open before the responder arrives")
|
||||||
|
step = 1
|
||||||
|
if step == 1:
|
||||||
|
# Fast-forward the arrival delay.
|
||||||
|
gm.coordinator.radio.tick(30.0)
|
||||||
|
_check(gm.coordinator.radio.pending_calls.is_empty(), "the responder arrived")
|
||||||
|
_check(gm.coordinator.responder_outcomes.has("evacuation"), "low-danger emergency resolves as evacuation")
|
||||||
|
_check(gm.door.locked, "evacuation locks the door")
|
||||||
|
var clearing := 0
|
||||||
|
for npc in gm.npcs:
|
||||||
|
if is_instance_valid(npc) and (npc.reaction_mode == "flee" or npc.npc.leave_requested):
|
||||||
|
clearing += 1
|
||||||
|
_check(clearing > 0, "evacuation sends people away from the entrance (%d clearing)" % clearing)
|
||||||
|
var review: Dictionary = gm.coordinator.build_review()
|
||||||
|
_check((review.get("review", review).get("responder_outcomes", []) as Array).has("evacuation"),
|
||||||
|
"the review carries the responder outcome")
|
||||||
|
_check((review.get("investigations", []) as Array).size() == 1, "the review carries the investigation note")
|
||||||
|
step = 2
|
||||||
|
if step == 2:
|
||||||
|
_finish()
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
func _check(condition: bool, message: String) -> void:
|
||||||
|
if condition:
|
||||||
|
print("ok: " + message)
|
||||||
|
else:
|
||||||
|
_fail(message)
|
||||||
|
|
||||||
|
func _fail(message: String) -> void:
|
||||||
|
failures.append(message)
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
func _finish() -> void:
|
||||||
|
if failures.is_empty():
|
||||||
|
print("SMOKE OK: emergency aftermath (investigation, evacuation, review)")
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
for message in failures:
|
||||||
|
printerr("SMOKE FAIL: " + message)
|
||||||
|
quit(1)
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Windowed smoke helper for the ADR-0007–0010 control pass (focus ring + 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.
|
||||||
|
## Usage: godot --path . --script res://src/test/smoke_focus_radial.gd
|
||||||
|
|
||||||
|
const OUT_DIR := "user://screenshots"
|
||||||
|
const MAX_FRAMES := 1800
|
||||||
|
const STAGE_DISTANCE := 2.4
|
||||||
|
|
||||||
|
var frames := 0
|
||||||
|
var gm: Node = null
|
||||||
|
var state := 0
|
||||||
|
var state_frames := 0
|
||||||
|
var stage_npc: Node3D = null
|
||||||
|
var failures: Array[String] = []
|
||||||
|
var _shift_started := false
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
root.size = Vector2i(1280, 720)
|
||||||
|
var packed: PackedScene = load("res://scenes/main.tscn")
|
||||||
|
root.add_child(packed.instantiate())
|
||||||
|
|
||||||
|
## The game now boots into the venue menu, so the smoke starts a shift first.
|
||||||
|
func _start_shift_if_needed() -> void:
|
||||||
|
if _shift_started or gm == null:
|
||||||
|
return
|
||||||
|
var menu = gm.get("start_menu")
|
||||||
|
if menu != null and menu.visible:
|
||||||
|
gm.call("_on_venue_selected", "hollywood_building")
|
||||||
|
_shift_started = true
|
||||||
|
print("smoke: started hollywood_building shift from the menu")
|
||||||
|
|
||||||
|
func _process(_delta: float) -> bool:
|
||||||
|
frames += 1
|
||||||
|
if gm == null:
|
||||||
|
gm = root.find_child("GameManager", true, false)
|
||||||
|
if gm == null:
|
||||||
|
if frames > 600:
|
||||||
|
_fail("GameManager not found")
|
||||||
|
return false
|
||||||
|
if frames > MAX_FRAMES:
|
||||||
|
_fail("timeout before the radial state")
|
||||||
|
return true
|
||||||
|
_advance()
|
||||||
|
return false
|
||||||
|
|
||||||
|
func _advance() -> void:
|
||||||
|
state_frames += 1
|
||||||
|
match state:
|
||||||
|
0:
|
||||||
|
_start_shift_if_needed()
|
||||||
|
# Wait for the guard to settle on the ground, then grab one NPC.
|
||||||
|
if state_frames >= 40 and gm.npcs.size() > 0:
|
||||||
|
print("smoke: spawn guard_y=%.2f z=%.2f" % [
|
||||||
|
(gm.guard.global_position as Vector3).y,
|
||||||
|
(gm.guard.global_position as Vector3).z,
|
||||||
|
])
|
||||||
|
_shoot("smoke-00-spawn.png")
|
||||||
|
stage_npc = gm.npcs[0]
|
||||||
|
_hide_other_npcs(stage_npc)
|
||||||
|
state = 1
|
||||||
|
state_frames = 0
|
||||||
|
1:
|
||||||
|
# Keep the staged NPC in front of the guard so the ring is visible.
|
||||||
|
_stage_target()
|
||||||
|
if state_frames >= 20:
|
||||||
|
gm.call("_cycle_nearby_target")
|
||||||
|
if gm.targeted_npc == null:
|
||||||
|
_fail("no NPC focused")
|
||||||
|
elif gm.radial == null:
|
||||||
|
_fail("radial layer missing")
|
||||||
|
else:
|
||||||
|
print("smoke: guard_y=%.2f npc_y=%.2f" % [
|
||||||
|
(gm.guard.global_position as Vector3).y,
|
||||||
|
(gm.targeted_npc.global_position as Vector3).y,
|
||||||
|
])
|
||||||
|
_shoot("smoke-01-focus-ring.png")
|
||||||
|
gm.call("_open_radial")
|
||||||
|
state = 2
|
||||||
|
state_frames = 0
|
||||||
|
2:
|
||||||
|
if state_frames >= 20:
|
||||||
|
if not gm.radial.is_open:
|
||||||
|
_fail("action wheel did not open")
|
||||||
|
else:
|
||||||
|
_shoot("smoke-02-action-wheel.png")
|
||||||
|
gm.call("_cancel_radial")
|
||||||
|
state = 3
|
||||||
|
state_frames = 0
|
||||||
|
3:
|
||||||
|
if state_frames >= 5:
|
||||||
|
if gm.radial.is_open:
|
||||||
|
_fail("action wheel did not close on cancel")
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
func _stage_target() -> void:
|
||||||
|
if stage_npc == null or not is_instance_valid(stage_npc):
|
||||||
|
stage_npc = gm.npcs[0] if gm.npcs.size() > 0 else null
|
||||||
|
if stage_npc == null:
|
||||||
|
return
|
||||||
|
_hide_other_npcs(stage_npc)
|
||||||
|
var guard_pos: Vector3 = gm.guard.global_position
|
||||||
|
var forward: Vector3 = -gm.guard.global_transform.basis.z
|
||||||
|
forward.y = 0.0
|
||||||
|
forward = forward.normalized()
|
||||||
|
stage_npc.global_position = guard_pos + forward * STAGE_DISTANCE + Vector3(0, 0.4, 0)
|
||||||
|
stage_npc.rotation.y = gm.guard.rotation.y + PI
|
||||||
|
|
||||||
|
func _hide_other_npcs(keep: Node3D) -> void:
|
||||||
|
for npc in gm.npcs:
|
||||||
|
if is_instance_valid(npc) and npc is Node3D and npc != keep:
|
||||||
|
(npc as Node3D).visible = false
|
||||||
|
|
||||||
|
func _shoot(file_name: String) -> void:
|
||||||
|
var dir := DirAccess.open("user://")
|
||||||
|
if dir == null or not dir.dir_exists("screenshots"):
|
||||||
|
dir.make_dir_recursive("screenshots")
|
||||||
|
var img: Image = root.get_texture().get_image()
|
||||||
|
var err: int = img.save_png(OUT_DIR.path_join(file_name))
|
||||||
|
print("%s -> err=%d (%dx%d)" % [file_name, err, img.get_width(), img.get_height()])
|
||||||
|
|
||||||
|
func _fail(message: String) -> void:
|
||||||
|
failures.append(message)
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
func _finish() -> void:
|
||||||
|
if failures.is_empty():
|
||||||
|
print("SMOKE OK: focus ring + action wheel opened and cancelled")
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
for message in failures:
|
||||||
|
printerr("SMOKE FAIL: " + message)
|
||||||
|
quit(1)
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## PLAN-verb smoke: starts a shift, focuses one person, then exercises the verbs
|
||||||
|
## added after the radial pass — recognize, request reason, body camera, formal
|
||||||
|
## warning (which must arm the body camera), and blocking (which must move the
|
||||||
|
## guard between the person and the door). Also checks that an incident is
|
||||||
|
## actually recorded, then closing it at shift end.
|
||||||
|
##
|
||||||
|
## Usage:
|
||||||
|
## godot --headless --path . --script res://src/test/smoke_plan_verbs.gd
|
||||||
|
## Exit code 0 = pass, 1 = failure.
|
||||||
|
|
||||||
|
const MAX_FRAMES := 1200
|
||||||
|
|
||||||
|
var frame := 0
|
||||||
|
var gm: Node = null
|
||||||
|
var started := false
|
||||||
|
var step := 0
|
||||||
|
var failures: Array[String] = []
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
root.size = Vector2i(1280, 720)
|
||||||
|
root.add_child(load("res://scenes/main.tscn").instantiate())
|
||||||
|
|
||||||
|
func _process(_delta: float) -> bool:
|
||||||
|
frame += 1
|
||||||
|
if gm == null:
|
||||||
|
gm = root.find_child("GameManager", true, false)
|
||||||
|
return false
|
||||||
|
if not started:
|
||||||
|
gm.call("_on_venue_selected", "hollywood_building")
|
||||||
|
started = true
|
||||||
|
return false
|
||||||
|
if frame > MAX_FRAMES:
|
||||||
|
_fail("timeout before the verbs ran")
|
||||||
|
return true
|
||||||
|
if frame < 200:
|
||||||
|
return false
|
||||||
|
if step == 0:
|
||||||
|
if gm.npcs.is_empty():
|
||||||
|
_fail("no NPCs spawned to test the verbs on")
|
||||||
|
return true
|
||||||
|
var npc: Node = gm.npcs[0]
|
||||||
|
gm.call("_on_guard_target", npc)
|
||||||
|
var controller: Object = npc.get_npc_controller()
|
||||||
|
gm.call("_recognize_target")
|
||||||
|
_check(controller.player_recognized, "recognize marks the person as recognized")
|
||||||
|
gm.call("_request_reason")
|
||||||
|
_check(typeof(controller.last_reason_reply) == TYPE_STRING, "reason request returns a reply")
|
||||||
|
gm.call("_set_body_camera", false, "smoke")
|
||||||
|
_check(not gm.body_camera_on, "body camera can be switched off")
|
||||||
|
gm.call("_apply_interaction", 2, "formal warning")
|
||||||
|
_check(gm.body_camera_on, "formal warning arms the body camera")
|
||||||
|
var before: Vector3 = gm.guard.global_position
|
||||||
|
gm.call("_apply_interaction", 3, "blocking")
|
||||||
|
_check(before.distance_to(gm.guard.global_position) > 0.05, "blocking moves the guard")
|
||||||
|
_check(gm.coordinator.recorder.open_incidents_count() > 0 or gm.coordinator.recorder.incidents.size() > 0,
|
||||||
|
"an incident was recorded")
|
||||||
|
gm.coordinator.call("_finish_shift")
|
||||||
|
_check(gm.coordinator.recorder.open_incidents_count() == 0, "open incidents close at shift end")
|
||||||
|
_check(gm.coordinator.recorder.incidents.size() > 0, "shift history keeps the incident")
|
||||||
|
step = 1
|
||||||
|
if step == 1:
|
||||||
|
_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: PLAN verbs (recognize, reason, body camera, warning, blocking)")
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
for message in failures:
|
||||||
|
printerr("SMOKE FAIL: " + message)
|
||||||
|
quit(1)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Session-shell smoke: boot the main scene, confirm the venue menu is showing,
|
||||||
|
## start a Joke Shop shift, open the pause overlay and settings, then return to
|
||||||
|
## the menu. Exits non-zero on failure.
|
||||||
|
##
|
||||||
|
## Usage:
|
||||||
|
## godot --headless --path . --script res://src/test/smoke_session_shell.gd
|
||||||
|
## godot --path . --script res://src/test/smoke_session_shell.gd (windowed)
|
||||||
|
var _frames := 0
|
||||||
|
var _manager = null
|
||||||
|
var _main = null
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
var packed: PackedScene = load("res://scenes/main.tscn")
|
||||||
|
if packed == null:
|
||||||
|
printerr("cannot load main scene")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
_main = packed.instantiate()
|
||||||
|
root.add_child(_main)
|
||||||
|
print("boot: main scene added")
|
||||||
|
|
||||||
|
func _process(_delta: float) -> bool:
|
||||||
|
_frames += 1
|
||||||
|
if _frames == 10:
|
||||||
|
_manager = _main.get_node_or_null("GameManager")
|
||||||
|
if _manager.start_menu == null or not _manager.start_menu.visible:
|
||||||
|
printerr("start menu not visible at boot")
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
|
print("boot: start menu visible")
|
||||||
|
_manager._on_venue_selected("joke_shop")
|
||||||
|
if _frames == 200:
|
||||||
|
if _manager.npcs.size() == 0:
|
||||||
|
printerr("no NPCs spawned after shift start")
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
|
print("boot: shift running, npcs=%d" % _manager.npcs.size())
|
||||||
|
_manager._open_pause()
|
||||||
|
if _frames == 210:
|
||||||
|
if _manager.pause_overlay == null or not _manager.pause_overlay.visible:
|
||||||
|
printerr("pause overlay did not open")
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
|
print("boot: pause overlay visible")
|
||||||
|
_manager._open_settings()
|
||||||
|
if _frames == 220:
|
||||||
|
if _manager.settings_panel == null or not _manager.settings_panel.visible:
|
||||||
|
printerr("settings panel did not open")
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
|
print("boot: settings visible")
|
||||||
|
_manager.settings_panel._on_cancel()
|
||||||
|
_manager._quit_to_menu()
|
||||||
|
if _frames == 230:
|
||||||
|
if not _manager.start_menu.visible:
|
||||||
|
printerr("quit-to-menu did not return to the start menu")
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
|
print("SMOKE PASS: menu -> shift -> pause -> settings -> menu")
|
||||||
|
quit(0)
|
||||||
|
return true
|
||||||
|
return false
|
||||||
@@ -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,134 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Venue-correct access artifacts (override_access, no free recognition on guests).
|
||||||
|
|
||||||
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
|
const AccessLoadout := preload("res://src/core/access_loadout.gd")
|
||||||
|
const AccessVerifier := preload("res://src/core/access_verifier.gd")
|
||||||
|
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
|
||||||
|
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||||
|
|
||||||
|
var verifier := AccessVerifier.new()
|
||||||
|
|
||||||
|
func _guest() -> NpcProfile:
|
||||||
|
var p := NpcProfile.new()
|
||||||
|
p.id = "authorized_guest"
|
||||||
|
p.display_name = "Authorized Guest"
|
||||||
|
p.npc_type = Rules.NpcType.AUTHORIZED_GUEST
|
||||||
|
return p
|
||||||
|
|
||||||
|
func _staff() -> NpcProfile:
|
||||||
|
var p := NpcProfile.new()
|
||||||
|
p.id = "authorized_staff"
|
||||||
|
p.display_name = "Staff Member"
|
||||||
|
p.npc_type = Rules.NpcType.AUTHORIZED_STAFF
|
||||||
|
return p
|
||||||
|
|
||||||
|
func _kinds(artifacts: Array) -> Array:
|
||||||
|
var out: Array = []
|
||||||
|
for a in artifacts:
|
||||||
|
out.append(a.kind)
|
||||||
|
return out
|
||||||
|
|
||||||
|
func test_joke_guest_valid_needs_screen_then_grants() -> bool:
|
||||||
|
var policy = ScenarioFactory.joke_shop().access_policy
|
||||||
|
var arts: Array = AccessLoadout.build(_guest(), policy, {"has_id": true, "has_ticket": true})
|
||||||
|
var has_rec := false
|
||||||
|
for art in arts:
|
||||||
|
if art.kind == Rules.AccessKind.RECOGNITION:
|
||||||
|
has_rec = true
|
||||||
|
var a := TestUtils.check(not has_rec, "no free recognition")
|
||||||
|
var before := verifier.verify(arts, policy, false)
|
||||||
|
var after := verifier.verify(arts, policy, true)
|
||||||
|
var b := TestUtils.check_eq(before["granted"], false, "unscreened valid guest denied")
|
||||||
|
var c := TestUtils.check_eq(after["granted"], true, "screened valid guest granted")
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_joke_guest_no_id_denied_even_when_screened() -> bool:
|
||||||
|
var policy = ScenarioFactory.joke_shop().access_policy
|
||||||
|
var arts: Array = AccessLoadout.build(_guest(), policy, {"has_id": false, "has_ticket": true})
|
||||||
|
var result := verifier.verify(arts, policy, true)
|
||||||
|
return TestUtils.check_eq(result["granted"], false, "no-id guest denied")
|
||||||
|
|
||||||
|
func test_joke_guest_expired_id_denied() -> bool:
|
||||||
|
var policy = ScenarioFactory.joke_shop().access_policy
|
||||||
|
var arts: Array = AccessLoadout.build(_guest(), policy, {
|
||||||
|
"has_id": true, "has_ticket": true, "id_status": Rules.ArtifactStatus.EXPIRED,
|
||||||
|
})
|
||||||
|
var result := verifier.verify(arts, policy, true)
|
||||||
|
return TestUtils.check_eq(result["granted"], false, "expired id denied")
|
||||||
|
|
||||||
|
func test_joke_event_payload_matches_factory() -> bool:
|
||||||
|
var shop = ScenarioFactory.joke_shop()
|
||||||
|
var found_expired := false
|
||||||
|
var found_no_id := false
|
||||||
|
for event in shop.event_pool:
|
||||||
|
var ov: Dictionary = event.get("override_access", {})
|
||||||
|
if event.get("id", "") == "guest_expired_id":
|
||||||
|
found_expired = int(ov.get("id_status", -1)) == Rules.ArtifactStatus.EXPIRED
|
||||||
|
if event.get("id", "") == "guest_no_id":
|
||||||
|
found_no_id = ov.get("has_id", true) == false
|
||||||
|
var a := TestUtils.check(found_expired, "guest_expired_id carries expired status")
|
||||||
|
var b := TestUtils.check(found_no_id, "guest_no_id has has_id false")
|
||||||
|
return a and b
|
||||||
|
|
||||||
|
func test_joke_staff_recognized_without_override() -> bool:
|
||||||
|
var policy = ScenarioFactory.joke_shop().access_policy
|
||||||
|
var arts: Array = AccessLoadout.build(_staff(), policy, {})
|
||||||
|
var has_rec := false
|
||||||
|
for a in arts:
|
||||||
|
if a.kind == Rules.AccessKind.RECOGNITION and a.status == Rules.ArtifactStatus.VALID:
|
||||||
|
has_rec = true
|
||||||
|
var screened := verifier.verify(arts, policy, true)
|
||||||
|
return TestUtils.check(has_rec, "staff are recognized") and TestUtils.check_eq(screened["granted"], true, "recognized staff enter after screen")
|
||||||
|
|
||||||
|
func test_building_guest_uses_wristband_not_recognition() -> bool:
|
||||||
|
var policy = ScenarioFactory.hollywood_building().access_policy
|
||||||
|
var arts: Array = AccessLoadout.build(_guest(), policy, {})
|
||||||
|
var has_rec := false
|
||||||
|
var has_band := false
|
||||||
|
for a in arts:
|
||||||
|
if a.kind == Rules.AccessKind.RECOGNITION:
|
||||||
|
has_rec = true
|
||||||
|
if a.kind == Rules.AccessKind.WRISTBAND and a.status == Rules.ArtifactStatus.VALID:
|
||||||
|
has_band = true
|
||||||
|
var result := verifier.verify(arts, policy)
|
||||||
|
var a := TestUtils.check(not has_rec, "building guest is not auto-recognized")
|
||||||
|
var b := TestUtils.check(has_band, "building guest presents a wristband")
|
||||||
|
var c := TestUtils.check_eq(result["granted"], true, "valid wristband grants")
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_studio_ticket_grants_without_recognition() -> bool:
|
||||||
|
var policy = ScenarioFactory.tv_studio().access_policy
|
||||||
|
var arts: Array = AccessLoadout.build(_guest(), policy, {"has_ticket": true})
|
||||||
|
var has_rec := false
|
||||||
|
for a in arts:
|
||||||
|
if a.kind == Rules.AccessKind.RECOGNITION:
|
||||||
|
has_rec = true
|
||||||
|
var result := verifier.verify(arts, policy)
|
||||||
|
return TestUtils.check(not has_rec, "audience ticket is not recognition") and TestUtils.check_eq(result["granted"], true, "ticket grants at studio")
|
||||||
|
|
||||||
|
func test_studio_counterfeit_denied() -> bool:
|
||||||
|
var policy = ScenarioFactory.tv_studio().access_policy
|
||||||
|
var arts: Array = AccessLoadout.build(_guest(), policy, {
|
||||||
|
"wristband_status": Rules.ArtifactStatus.COUNTERFEIT,
|
||||||
|
"credential_status": Rules.ArtifactStatus.COUNTERFEIT,
|
||||||
|
})
|
||||||
|
var result := verifier.verify(arts, policy)
|
||||||
|
return TestUtils.check_eq(result["granted"], false, "counterfeit studio artifacts deny")
|
||||||
|
|
||||||
|
func test_carried_items_override_and_roll() -> bool:
|
||||||
|
var forced: Array = AccessLoadout.roll_carried_items(1, {"carried_items": ["weapon"]})
|
||||||
|
var a := TestUtils.check_eq(forced, ["weapon"], "override items win")
|
||||||
|
var rolled: Array = AccessLoadout.roll_carried_items(42, {})
|
||||||
|
var b := TestUtils.check(rolled is Array, "roll returns an array")
|
||||||
|
var again: Array = AccessLoadout.roll_carried_items(42, {})
|
||||||
|
var c := TestUtils.check_eq(rolled, again, "same seed same pocket")
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_joke_shop_sidewalk_exists() -> bool:
|
||||||
|
var shop = ScenarioFactory.joke_shop()
|
||||||
|
var studio = ScenarioFactory.tv_studio()
|
||||||
|
var a := TestUtils.check(shop.locations.has("sidewalk"), "joke shop sidewalk")
|
||||||
|
var b := TestUtils.check(studio.locations.has("sidewalk"), "studio sidewalk")
|
||||||
|
var c := TestUtils.check_eq(shop.waypoint_for_intent("film").z, shop.locations["sidewalk"].z, "film walks to sidewalk")
|
||||||
|
return a and b and c
|
||||||
@@ -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,106 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Crowd presentation: rubbernecking on aggressive/emergency events and clearing
|
||||||
|
## out when the crowd panics (PLAN: Public Crowd Reaction).
|
||||||
|
|
||||||
|
const CrowdSystem := preload("res://src/core/crowd_system.gd")
|
||||||
|
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
|
||||||
|
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||||
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
|
|
||||||
|
func _profile() -> NpcProfile:
|
||||||
|
var p := NpcProfile.new()
|
||||||
|
p.id = "bystander"
|
||||||
|
p.display_name = "Bystander"
|
||||||
|
p.npc_type = Rules.NpcType.TOURIST
|
||||||
|
return p
|
||||||
|
|
||||||
|
func _avatar() -> Node:
|
||||||
|
var avatar: Node = NpcAvatar.new()
|
||||||
|
avatar.setup(_profile(), "photo", 99, Vector3(4, 0.5, 8), Vector3(2, 0.5, 12))
|
||||||
|
return avatar
|
||||||
|
|
||||||
|
func test_reaction_for_event() -> bool:
|
||||||
|
var a := TestUtils.check_eq(CrowdSystem.reaction_for_event("aggressive_behavior"), "watch", "aggression draws watchers")
|
||||||
|
var b := TestUtils.check_eq(CrowdSystem.reaction_for_event("emergency_responder"), "watch", "responder draws watchers")
|
||||||
|
var c := TestUtils.check_eq(CrowdSystem.reaction_for_event("visible_weapon"), "watch", "a weapon draws watchers")
|
||||||
|
var d := TestUtils.check_eq(CrowdSystem.reaction_for_event("de_escalation"), "", "de-escalation is not a watch cue")
|
||||||
|
var e := TestUtils.check_eq(CrowdSystem.reaction_for_event("calm_professionalism"), "", "calm is not a watch cue")
|
||||||
|
return a and b and c and d and e
|
||||||
|
|
||||||
|
func test_panic_flag_and_flee_cue() -> bool:
|
||||||
|
var crowd := CrowdSystem.new()
|
||||||
|
var a := TestUtils.check(not crowd.should_flee(), "crowd starts calm")
|
||||||
|
crowd.apply_event("visible_weapon")
|
||||||
|
crowd.apply_event("aggressive_behavior")
|
||||||
|
crowd.apply_event("aggressive_behavior")
|
||||||
|
crowd.apply_event("emergency_responder")
|
||||||
|
var b := TestUtils.check(crowd.should_flee(), "stacked alarms reach panic")
|
||||||
|
var c := TestUtils.check_eq(crowd.summary().get("panic"), true, "summary reports panic")
|
||||||
|
var calm := CrowdSystem.new()
|
||||||
|
calm.apply_event("de_escalation")
|
||||||
|
calm.apply_event("calm_professionalism")
|
||||||
|
var d := TestUtils.check(not calm.should_flee(), "a calm crowd does not flee")
|
||||||
|
crowd = null
|
||||||
|
calm = null
|
||||||
|
return a and b and c and d
|
||||||
|
|
||||||
|
func test_idle_bystander_rubbernecks() -> bool:
|
||||||
|
var avatar := _avatar()
|
||||||
|
# Still walking: should refuse so traffic is not frozen mid-approach.
|
||||||
|
var walking := TestUtils.check(not avatar.start_rubberneck(Vector3(0, 1, 1)), "walkers keep walking")
|
||||||
|
avatar.arrived = true
|
||||||
|
var accepted := TestUtils.check(avatar.start_rubberneck(Vector3(0, 1, 1)), "idle bystander watches")
|
||||||
|
var mode := TestUtils.check_eq(avatar.reaction_mode, "watch", "watch mode is set")
|
||||||
|
var active := TestUtils.check(avatar.reaction_is_active(), "reaction is active")
|
||||||
|
avatar.free()
|
||||||
|
return walking and accepted and mode and active
|
||||||
|
|
||||||
|
func test_engaged_person_does_not_rubberneck() -> bool:
|
||||||
|
var avatar := _avatar()
|
||||||
|
avatar.arrived = true
|
||||||
|
avatar.awaiting_interaction = true
|
||||||
|
# Not in a tree, so use local position (global_position is a no-op here).
|
||||||
|
avatar.position = avatar.door_position
|
||||||
|
var ok := TestUtils.check(not avatar.start_rubberneck(Vector3.ZERO), "someone waiting at the door does not rubberneck")
|
||||||
|
# The same person further away is an ordinary bystander and does watch.
|
||||||
|
avatar.position = avatar.door_position + Vector3(8, 0, 0)
|
||||||
|
var far_ok := TestUtils.check(avatar.start_rubberneck(Vector3.ZERO), "a bystander away from the door still watches")
|
||||||
|
avatar.free()
|
||||||
|
return ok and far_ok
|
||||||
|
|
||||||
|
func test_panic_moves_people_away_from_the_door() -> bool:
|
||||||
|
var avatar := _avatar()
|
||||||
|
avatar.position = Vector3(1, 0.5, 2)
|
||||||
|
var door := Vector3(0, 0.5, 1.2)
|
||||||
|
avatar.start_flee(door)
|
||||||
|
var a := TestUtils.check_eq(avatar.reaction_mode, "flee", "flee mode is set")
|
||||||
|
var b := TestUtils.check((avatar.target_position - door).length() > (avatar.position - door).length(),
|
||||||
|
"flee target is farther from the door than the start")
|
||||||
|
var c := TestUtils.check(not avatar.awaiting_interaction, "fleeing cancels waiting at the entrance")
|
||||||
|
avatar.free()
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_crowd_updates_arrive_through_the_event_bus() -> bool:
|
||||||
|
## CrowdSystem.apply_event() emits EventBus.crowd_changed, not its own
|
||||||
|
## `changed` signal; wiring the wrong one silently disables crowd reactions.
|
||||||
|
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
||||||
|
var ok := TestUtils.check(src.contains("event_bus.crowd_changed.connect(_on_crowd_changed)"),
|
||||||
|
"GameManager listens on EventBus.crowd_changed")
|
||||||
|
var bus: Object = load("res://src/core/event_bus.gd").new()
|
||||||
|
var seen: Array = []
|
||||||
|
bus.crowd_changed.connect(func(payload): seen.append(payload.get("event", "")))
|
||||||
|
var crowd := CrowdSystem.new()
|
||||||
|
crowd.set_event_bus(bus)
|
||||||
|
crowd.apply_event("aggressive_behavior")
|
||||||
|
var delivers := TestUtils.check_eq(seen, ["aggressive_behavior"], "apply_event reaches the bus listener")
|
||||||
|
bus = null
|
||||||
|
crowd = null
|
||||||
|
return ok and delivers
|
||||||
|
|
||||||
|
func test_leaving_npc_does_not_flee() -> bool:
|
||||||
|
var avatar := _avatar()
|
||||||
|
avatar.npc.leave_requested = true
|
||||||
|
avatar.start_flee(Vector3.ZERO)
|
||||||
|
var ok := TestUtils.check_eq(avatar.reaction_mode, "", "someone already leaving is left alone")
|
||||||
|
avatar.free()
|
||||||
|
return ok
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Emergency aftermath: responder outcomes reach the review, evacuation clears the
|
||||||
|
## entrance and locks the door, and an unjustified call opens an investigation.
|
||||||
|
|
||||||
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
|
const RadioSystem := preload("res://src/core/radio_system.gd")
|
||||||
|
const Coordinator := preload("res://src/core/shift_coordinator.gd")
|
||||||
|
const Factory := preload("res://src/content/scenario_factory.gd")
|
||||||
|
const Strings := preload("res://src/core/strings.gd")
|
||||||
|
|
||||||
|
func _coordinator() -> Object:
|
||||||
|
var c := Coordinator.new()
|
||||||
|
c.setup(Factory.hollywood_building(), 60.0, 4242)
|
||||||
|
return c
|
||||||
|
|
||||||
|
func test_responder_outcomes_reach_the_review() -> bool:
|
||||||
|
var c := _coordinator()
|
||||||
|
c.responder_outcomes.append("arrest")
|
||||||
|
c.responder_outcomes.append("command")
|
||||||
|
var review: Dictionary = c.build_review()
|
||||||
|
var outcomes: Array = review.get("responder_outcomes", [])
|
||||||
|
var a := TestUtils.check_eq(outcomes, ["arrest", "command"], "responder outcomes are in the review")
|
||||||
|
var b := TestUtils.check(review.has("investigations"), "review carries an investigations list")
|
||||||
|
c = null
|
||||||
|
return a and b
|
||||||
|
|
||||||
|
func test_outcomes_reset_with_a_new_shift() -> bool:
|
||||||
|
var c := _coordinator()
|
||||||
|
c.responder_outcomes.append("evacuation")
|
||||||
|
c.investigations.append("note")
|
||||||
|
c.setup(Factory.hollywood_building(), 60.0, 1)
|
||||||
|
var a := TestUtils.check_eq(c.responder_outcomes.size(), 0, "outcomes reset on setup")
|
||||||
|
var b := TestUtils.check_eq(c.investigations.size(), 0, "investigations reset on setup")
|
||||||
|
c = null
|
||||||
|
return a and b
|
||||||
|
|
||||||
|
func test_evacuation_is_the_emergency_default() -> bool:
|
||||||
|
var call := {"type": "emergency", "report": {"danger_level": 2, "weapons_seen": false}}
|
||||||
|
var a := TestUtils.check_eq(RadioSystem.responder_outcome(call), "restraint", "danger 2 restrains")
|
||||||
|
var call2 := {"type": "emergency", "report": {"danger_level": 1, "weapons_seen": false}}
|
||||||
|
var b := TestUtils.check_eq(RadioSystem.responder_outcome(call2), "evacuation", "low-danger emergency evacuates")
|
||||||
|
var call3 := {"type": "assist", "report": {"danger_level": 1, "weapons_seen": false}}
|
||||||
|
var c := TestUtils.check_eq(RadioSystem.responder_outcome(call3), "command", "assistance just commands")
|
||||||
|
var call4 := {"type": "emergency", "report": {"danger_level": 3, "weapons_seen": true}}
|
||||||
|
var d := TestUtils.check_eq(RadioSystem.responder_outcome(call4), "arrest", "weapons arrest")
|
||||||
|
return a and b and c and d
|
||||||
|
|
||||||
|
func test_evacuation_wiring_locks_the_door_and_clears_npcs() -> bool:
|
||||||
|
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
||||||
|
var a := TestUtils.check(src.contains("func _evacuate_entrance"), "evacuation has a handler")
|
||||||
|
var b := TestUtils.check(src.contains('if outcome == "evacuation":'), "arrival routes evacuation")
|
||||||
|
var c := TestUtils.check(src.contains("door.set_locked(true)"), "evacuation locks the door")
|
||||||
|
var d := TestUtils.check(src.contains("npc.start_flee(door_pos)"), "evacuation sends NPCs away")
|
||||||
|
var e := TestUtils.check(src.contains("controller.leave_requested = true"), "evacuated NPCs are marked as leaving")
|
||||||
|
return a and b and c and d and e
|
||||||
|
|
||||||
|
func test_unjustified_emergency_is_detected() -> bool:
|
||||||
|
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
||||||
|
var a := TestUtils.check(src.contains("func _is_unjustified_emergency"), "unjustified check exists")
|
||||||
|
var b := TestUtils.check(src.contains("return not weapons and danger <= 1"), "no weapon and low danger is unjustified")
|
||||||
|
var c := TestUtils.check(src.contains("coordinator.investigations.append"), "an investigation note is recorded")
|
||||||
|
var d := TestUtils.check(src.contains('"type": "investigation_opened"'), "the incident log records the investigation")
|
||||||
|
var e := TestUtils.check(src.contains('coordinator.reputation.apply("false_report"'), "the false-report hit applies")
|
||||||
|
return a and b and c and d and e
|
||||||
|
|
||||||
|
func test_radio_arrival_reconnects_after_setup() -> bool:
|
||||||
|
## coordinator.setup() builds a new RadioSystem, so the arrival handler must be
|
||||||
|
## re-connected each shift or dispatched responders never resolve anything.
|
||||||
|
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
||||||
|
var a := TestUtils.check(src.contains("func _connect_radio"), "radio wiring has a helper")
|
||||||
|
var b := TestUtils.check(src.contains("coordinator.radio.responder_arrived.connect(_on_responder_arrived)"),
|
||||||
|
"the helper connects the arrival handler")
|
||||||
|
var c := TestUtils.check(src.contains("_connect_radio()"), "shifts call the helper")
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_review_strings_exist() -> bool:
|
||||||
|
var a := TestUtils.check(Strings.lookup("review.responder") != "review.responder", "responder line exists")
|
||||||
|
var b := TestUtils.check(Strings.lookup("review.investigations") != "review.investigations", "investigation line exists")
|
||||||
|
var c := TestUtils.check(Strings.lookup("review.none") != "review.none", "empty placeholder exists")
|
||||||
|
var d := TestUtils.check(Strings.lookup("responder.evacuated") != "responder.evacuated", "evacuation log line exists")
|
||||||
|
var e := TestUtils.check(Strings.lookup("investigation.started") != "investigation.started", "investigation log line exists")
|
||||||
|
return a and b and c and d and e
|
||||||
|
|
||||||
|
func test_review_renders_outcomes_and_investigations() -> bool:
|
||||||
|
var Review := load("res://src/game/ui/shift_review.gd")
|
||||||
|
var panel: Node = Review.new()
|
||||||
|
var review := {
|
||||||
|
"scenario_id": "hollywood_building",
|
||||||
|
"reputation": {"rating": 61.0, "grade": "C", "modifiers": []},
|
||||||
|
"incidents": {"incidents": []},
|
||||||
|
"crowd": {"mood": 0.4, "panic": false},
|
||||||
|
"radio": {"battery": 70.0, "emergency_cooldown_s": 0.0},
|
||||||
|
"spawned_count": 3,
|
||||||
|
"responder_outcomes": ["evacuation"],
|
||||||
|
"investigations": ["Emergency call reviewed"],
|
||||||
|
}
|
||||||
|
panel.show_review(review, Factory.hollywood_building(), [])
|
||||||
|
var text: String = panel.report_label.text
|
||||||
|
var a := TestUtils.check(text.contains("evacuation"), "review names the responder outcome")
|
||||||
|
var b := TestUtils.check(text.contains("Emergency call reviewed"), "review shows the investigation note")
|
||||||
|
var c := TestUtils.check(text.contains("Investigations:"), "investigations line is labelled")
|
||||||
|
panel.free()
|
||||||
|
return a and b and c
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Body-camera timeline: the review screen steps through recorded incident events
|
||||||
|
## (ADR-0005). No live 3D replay in this version.
|
||||||
|
|
||||||
|
const EventTimeline := preload("res://src/game/ui/event_timeline.gd")
|
||||||
|
const Strings := preload("res://src/core/strings.gd")
|
||||||
|
const Coordinator := preload("res://src/core/shift_coordinator.gd")
|
||||||
|
const Factory := preload("res://src/content/scenario_factory.gd")
|
||||||
|
|
||||||
|
func _review() -> Dictionary:
|
||||||
|
return {
|
||||||
|
"started_at_ms": 1000,
|
||||||
|
"incidents": {
|
||||||
|
"open_count": 0,
|
||||||
|
"incidents": [
|
||||||
|
{
|
||||||
|
"id": "inc_a",
|
||||||
|
"npc_id": "npc_a",
|
||||||
|
"outcome": "peaceful",
|
||||||
|
"events": [
|
||||||
|
{"type": "player_action", "action": "eye contact", "level": 1, "at_ms": 3000},
|
||||||
|
{"type": "body_camera", "on": true, "reason": "formal warning", "at_ms": 8000},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "inc_b",
|
||||||
|
"npc_id": "npc_b",
|
||||||
|
"outcome": "left",
|
||||||
|
"events": [
|
||||||
|
{"type": "reason_requested", "reply": "I have a ticket.", "at_ms": 5000},
|
||||||
|
{"type": "access_check", "evidence": {"decided": true, "verified_granted": true}, "at_ms": 9000},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"reputation": {"rating": 70.0, "grade": "C", "modifiers": []},
|
||||||
|
"crowd": {"mood": 0.1, "panic": false},
|
||||||
|
"radio": {"battery": 100.0, "emergency_cooldown_s": 0.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_flatten_orders_events_across_incidents() -> bool:
|
||||||
|
var timeline: Node = EventTimeline.new()
|
||||||
|
var flat: Array = timeline.flatten(_review())
|
||||||
|
var a := TestUtils.check_eq(flat.size(), 4, "every recorded event is flattened")
|
||||||
|
var stamps: Array[int] = []
|
||||||
|
for row in flat:
|
||||||
|
stamps.append(int(row.get("at_ms", 0)))
|
||||||
|
var sorted := stamps.duplicate()
|
||||||
|
sorted.sort()
|
||||||
|
var b := TestUtils.check_eq(stamps, sorted, "events are time ordered")
|
||||||
|
var c := TestUtils.check_eq(flat[0].get("npc_id", ""), "npc_a", "event keeps its NPC id")
|
||||||
|
var d := TestUtils.check_eq(flat[1].get("npc_id", ""), "npc_b", "second incident interleaves")
|
||||||
|
var e := TestUtils.check_eq(flat[0].get("incident_id", ""), "inc_a", "event keeps its incident id")
|
||||||
|
timeline.free()
|
||||||
|
return a and b and c and d and e
|
||||||
|
|
||||||
|
func test_describe_covers_event_types() -> bool:
|
||||||
|
var timeline: Node = EventTimeline.new()
|
||||||
|
var a := TestUtils.check(timeline.describe({"type": "player_action", "action": "eye contact", "level": 1, "at_ms": 3000}).contains("eye contact (L1)"), "player action line")
|
||||||
|
var b := TestUtils.check(timeline.describe({"type": "body_camera", "on": true, "reason": "formal warning"}).contains("body camera on"), "body camera line")
|
||||||
|
var c := TestUtils.check(timeline.describe({"type": "reason_requested", "reply": "I have a ticket."}).contains("I have a ticket."), "reason reply line")
|
||||||
|
var d := TestUtils.check(timeline.describe({"type": "access_check", "evidence": {"decided": false, "verified_granted": true}}).contains("deny"), "access decision line")
|
||||||
|
var e := TestUtils.check(timeline.describe({"type": "responder_action", "call_type": "emergency", "outcome": "evacuation"}).contains("evacuation"), "responder line")
|
||||||
|
var f := TestUtils.check(timeline.describe({"type": "investigation_opened", "reason": "unjustified"}).contains("investigation"), "investigation line")
|
||||||
|
timeline.free()
|
||||||
|
return a and b and c and d and e and f
|
||||||
|
|
||||||
|
func test_empty_timeline_is_safe() -> bool:
|
||||||
|
var timeline: Node = EventTimeline.new()
|
||||||
|
var flat: Array = timeline.flatten({})
|
||||||
|
var a := TestUtils.check_eq(flat.size(), 0, "no incidents means no events")
|
||||||
|
timeline.show_timeline({})
|
||||||
|
var b := TestUtils.check_eq(timeline.index, 0, "index stays at zero when empty")
|
||||||
|
timeline.step(1)
|
||||||
|
var c := TestUtils.check_eq(timeline.index, 0, "stepping an empty timeline does nothing")
|
||||||
|
timeline.free()
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_stepping_clamps() -> bool:
|
||||||
|
var timeline: Node = EventTimeline.new()
|
||||||
|
timeline.show_timeline(_review())
|
||||||
|
timeline.step(-1)
|
||||||
|
var a := TestUtils.check_eq(timeline.index, 0, "cannot step before the first event")
|
||||||
|
timeline.step(1)
|
||||||
|
var b := TestUtils.check_eq(timeline.index, 1, "next advances one event")
|
||||||
|
timeline.step(99)
|
||||||
|
var c := TestUtils.check_eq(timeline.index, 3, "cannot step past the last event")
|
||||||
|
timeline.free()
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_review_records_a_shift_start_stamp() -> bool:
|
||||||
|
var coordinator: Object = Coordinator.new()
|
||||||
|
coordinator.setup(Factory.hollywood_building(), 30.0, 7)
|
||||||
|
coordinator.start()
|
||||||
|
var review: Dictionary = coordinator.build_review()
|
||||||
|
var a := TestUtils.check(int(review.get("started_at_ms", 0)) > 0, "review carries the shift start stamp")
|
||||||
|
coordinator = null
|
||||||
|
return a
|
||||||
|
|
||||||
|
func test_timeline_strings_exist() -> bool:
|
||||||
|
var ok := true
|
||||||
|
for key in ["timeline.title", "timeline.position", "timeline.empty", "timeline.prev",
|
||||||
|
"timeline.next", "timeline.close", "timeline.hint", "review.timeline_button"]:
|
||||||
|
if Strings.lookup(key) == key:
|
||||||
|
ok = false
|
||||||
|
return TestUtils.check(ok, "timeline UI strings are localized")
|
||||||
|
|
||||||
|
func test_review_button_opens_the_timeline() -> bool:
|
||||||
|
var Review := load("res://src/game/ui/shift_review.gd")
|
||||||
|
var panel: Node = Review.new()
|
||||||
|
var got := {"review": {}}
|
||||||
|
panel.timeline_requested.connect(func(review): got["review"] = review)
|
||||||
|
panel.show_review(_review(), Factory.hollywood_building(), [])
|
||||||
|
var button: Button = panel.find_child("TimelineButton", true, false) as Button
|
||||||
|
var a := TestUtils.check(button != null, "review screen has a timeline button")
|
||||||
|
if button:
|
||||||
|
button.pressed.emit()
|
||||||
|
var b := TestUtils.check(not (got["review"] as Dictionary).is_empty(), "pressing it hands the review to the timeline")
|
||||||
|
panel.free()
|
||||||
|
return a and b
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Focus cone math and radial menu wrap/cancel (ADR-0008, ADR-0009).
|
||||||
|
|
||||||
|
const NpcFocus := preload("res://src/game/npc_focus.gd")
|
||||||
|
const ActionRadial := preload("res://src/game/ui/action_radial.gd")
|
||||||
|
|
||||||
|
func test_cone_picks_centered_head() -> bool:
|
||||||
|
var origin := Vector3(0, 1.6, 0)
|
||||||
|
var forward := Vector3(0, 0, -1)
|
||||||
|
var heads: Array = [
|
||||||
|
Vector3(3, 1.6, -4),
|
||||||
|
Vector3(0.05, 1.6, -4),
|
||||||
|
Vector3(-2, 1.6, -4),
|
||||||
|
]
|
||||||
|
var idx := NpcFocus.pick_index(origin, forward, heads)
|
||||||
|
return TestUtils.check_eq(idx, 1, "head nearest the look axis wins")
|
||||||
|
|
||||||
|
func test_cone_rejects_behind_and_far() -> bool:
|
||||||
|
var origin := Vector3(0, 1.6, 0)
|
||||||
|
var forward := Vector3(0, 0, -1)
|
||||||
|
var behind := NpcFocus.score_head(origin, forward, Vector3(0, 1.6, 4))
|
||||||
|
var far := NpcFocus.score_head(origin, forward, Vector3(0, 1.6, -40))
|
||||||
|
var a := TestUtils.check_eq(behind, -1.0, "behind the camera is ignored")
|
||||||
|
var b := TestUtils.check_eq(far, -1.0, "out of range is ignored")
|
||||||
|
return a and b
|
||||||
|
|
||||||
|
func test_radial_wrap_and_skip_wand() -> bool:
|
||||||
|
var menu: Node = ActionRadial.new()
|
||||||
|
menu.open_menu("Guest", false)
|
||||||
|
var start: int = menu.selected
|
||||||
|
menu.step(-1)
|
||||||
|
var a := TestUtils.check(menu.selected != start, "step wrap moves selection")
|
||||||
|
var b := TestUtils.check(menu.is_slice_enabled(menu.selected), "landed on an enabled slice")
|
||||||
|
# Walk forward until we would hit wand; it must skip.
|
||||||
|
var wand_index := _slice_index("wand")
|
||||||
|
var warning_index := _slice_index("warning")
|
||||||
|
menu.selected = wand_index - 1
|
||||||
|
menu.step(1)
|
||||||
|
var c := TestUtils.check_eq(menu.slice_id(menu.selected), "warning", "wand skipped when venue has none")
|
||||||
|
menu.free()
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_radial_cancel_does_not_confirm() -> bool:
|
||||||
|
var menu: Node = ActionRadial.new()
|
||||||
|
menu.open_menu("Guest", true)
|
||||||
|
var got := {"id": ""}
|
||||||
|
menu.confirmed.connect(func(id): got["id"] = id)
|
||||||
|
menu.cancelled.connect(func(): got["id"] = "cancelled")
|
||||||
|
menu.close_menu()
|
||||||
|
var a := TestUtils.check_eq(got["id"], "", "closing without confirm emits nothing")
|
||||||
|
var slice: String = menu.confirm_selected()
|
||||||
|
var b := TestUtils.check_eq(slice, "", "confirm while closed is empty")
|
||||||
|
menu.free()
|
||||||
|
return a and b
|
||||||
|
|
||||||
|
func test_radial_confirm_returns_id() -> bool:
|
||||||
|
var menu: Node = ActionRadial.new()
|
||||||
|
menu.open_menu("Guest", true)
|
||||||
|
menu.selected = 0
|
||||||
|
var slice: String = menu.confirm_selected()
|
||||||
|
menu.free()
|
||||||
|
return TestUtils.check_eq(slice, "raise_hand", "space-confirm uses highlighted slice")
|
||||||
|
|
||||||
|
func test_angle_top_is_first_slice() -> bool:
|
||||||
|
var menu: Node = ActionRadial.new()
|
||||||
|
var idx: int = menu.angle_to_index(-PI / 2.0)
|
||||||
|
menu.free()
|
||||||
|
return TestUtils.check_eq(idx, 0, "top of the doughnut is slice 0")
|
||||||
|
|
||||||
|
func test_wand_not_on_w() -> bool:
|
||||||
|
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
||||||
|
var a := TestUtils.check(not src.contains("KEY_W:"), "W is not a game-manager action key")
|
||||||
|
var b := TestUtils.check(src.contains("is_action_pressed(\"wand\")"), "wand goes through the named action")
|
||||||
|
var c := TestUtils.check(not _action_has_key("wand", KEY_W), "wand action is not bound to W")
|
||||||
|
var d := TestUtils.check(_action_has_key("wand", KEY_C), "wand action is bound to C")
|
||||||
|
return a and b and c and d
|
||||||
|
|
||||||
|
func test_named_input_actions_exist() -> bool:
|
||||||
|
var menu_ok := _action_has_key("action_menu", KEY_SPACE)
|
||||||
|
var confirm_ok := _action_has_key("confirm", KEY_SPACE) and _action_has_key("confirm", KEY_ENTER)
|
||||||
|
var cancel_ok := _action_has_key("cancel", KEY_ESCAPE)
|
||||||
|
var inspect_ok := _action_has_key("inspect", KEY_E)
|
||||||
|
return TestUtils.check(menu_ok, "action_menu has Space") \
|
||||||
|
and TestUtils.check(confirm_ok, "confirm has Space + Enter") \
|
||||||
|
and TestUtils.check(cancel_ok, "cancel has Esc") \
|
||||||
|
and TestUtils.check(inspect_ok, "inspect has E")
|
||||||
|
|
||||||
|
func test_cancel_has_right_mouse() -> bool:
|
||||||
|
var events: Array = InputMap.action_get_events("cancel")
|
||||||
|
var found := false
|
||||||
|
for event in events:
|
||||||
|
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT:
|
||||||
|
found = true
|
||||||
|
return TestUtils.check(found, "cancel action includes right mouse button")
|
||||||
|
|
||||||
|
func _slice_index(id: String) -> int:
|
||||||
|
for i in ActionRadial.SLICE_DEFS.size():
|
||||||
|
if str(ActionRadial.SLICE_DEFS[i]["id"]) == id:
|
||||||
|
return i
|
||||||
|
return -1
|
||||||
|
|
||||||
|
func _action_has_key(action: String, keycode: Key) -> bool:
|
||||||
|
if not InputMap.has_action(action):
|
||||||
|
return false
|
||||||
|
for event in InputMap.action_get_events(action):
|
||||||
|
if event is InputEventKey and event.physical_keycode == keycode:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
@@ -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
|
||||||
@@ -30,3 +30,24 @@ func test_load_returns_array_when_empty_or_missing() -> bool:
|
|||||||
var ok := TestUtils.check(results is Array, "load_shift_results always returns Array")
|
var ok := TestUtils.check(results is Array, "load_shift_results always returns Array")
|
||||||
save.free()
|
save.free()
|
||||||
return ok
|
return ok
|
||||||
|
|
||||||
|
func test_settings_round_trip() -> bool:
|
||||||
|
var save := _save_instance()
|
||||||
|
var err: int = save.save_settings({"look_sensitivity": 0.0035, "hud_font_scale": 1.25})
|
||||||
|
var a := TestUtils.check_eq(err, OK, "save_settings succeeds")
|
||||||
|
var loaded: Dictionary = save.load_settings()
|
||||||
|
var b := TestUtils.check(is_equal_approx(float(loaded.get("look_sensitivity", 0.0)), 0.0035), "round-trips sensitivity")
|
||||||
|
var c := TestUtils.check(is_equal_approx(float(loaded.get("hud_font_scale", 0.0)), 1.25), "round-trips font scale")
|
||||||
|
# Restore defaults so later runs are not affected.
|
||||||
|
save.save_settings(save.DEFAULT_SETTINGS)
|
||||||
|
save.free()
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_settings_defaults_present() -> bool:
|
||||||
|
var save := _save_instance()
|
||||||
|
var defaults: Dictionary = save.load_settings()
|
||||||
|
var ok := TestUtils.check(
|
||||||
|
defaults.has("look_sensitivity") and defaults.has("hud_font_scale"),
|
||||||
|
"settings have documented defaults")
|
||||||
|
save.free()
|
||||||
|
return ok
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## PLAN verbs added after the radial pass: eye contact, recognition as a player
|
||||||
|
## decision, the body-camera flag, blocking station, and requesting a reason.
|
||||||
|
|
||||||
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
|
const ActionRadial := preload("res://src/game/ui/action_radial.gd")
|
||||||
|
const IncidentRecorder := preload("res://src/core/incident_recorder.gd")
|
||||||
|
const NPCController := preload("res://src/core/npc_controller.gd")
|
||||||
|
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||||
|
const AccessLoadout := preload("res://src/core/access_loadout.gd")
|
||||||
|
const AccessArtifact := preload("res://src/core/access_artifact.gd")
|
||||||
|
|
||||||
|
func _slice_index(id: String) -> int:
|
||||||
|
for i in ActionRadial.SLICE_DEFS.size():
|
||||||
|
if str(ActionRadial.SLICE_DEFS[i]["id"]) == id:
|
||||||
|
return i
|
||||||
|
return -1
|
||||||
|
|
||||||
|
func test_radial_has_new_verbs() -> bool:
|
||||||
|
var a := TestUtils.check(_slice_index("eye_contact") >= 0, "eye contact slice exists")
|
||||||
|
var b := TestUtils.check(_slice_index("reason") >= 0, "request-reason slice exists")
|
||||||
|
var c := TestUtils.check(_slice_index("recognize") >= 0, "recognize slice exists")
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_eye_contact_is_level_one() -> bool:
|
||||||
|
var def: Dictionary = ActionRadial.SLICE_DEFS[_slice_index("eye_contact")]
|
||||||
|
var ok := TestUtils.check_eq(int(def.get("level", -1)), Rules.Level.SIMPLE_DETERRENCE,
|
||||||
|
"eye contact is a Level 1 action")
|
||||||
|
var action_ok := TestUtils.check_eq(str(def.get("action", "")), "eye contact", "logs as eye contact")
|
||||||
|
return ok and action_ok
|
||||||
|
|
||||||
|
func test_every_slice_has_a_string_key() -> bool:
|
||||||
|
var strings = load("res://src/core/strings.gd")
|
||||||
|
var ok := true
|
||||||
|
for def in ActionRadial.SLICE_DEFS:
|
||||||
|
var key: String = str(def.get("label_key", ""))
|
||||||
|
if key == "":
|
||||||
|
ok = false
|
||||||
|
continue
|
||||||
|
if strings.lookup(key) == key:
|
||||||
|
ok = false
|
||||||
|
return TestUtils.check(ok, "every radial slice resolves a localized label")
|
||||||
|
|
||||||
|
func _profile(name: String, lines: Array[String] = []) -> NpcProfile:
|
||||||
|
var p := NpcProfile.new()
|
||||||
|
p.id = "test"
|
||||||
|
p.display_name = name
|
||||||
|
p.dialogue_lines = lines
|
||||||
|
return p
|
||||||
|
|
||||||
|
func test_request_reason_uses_dialogue_lines() -> bool:
|
||||||
|
var p := _profile("Visitor", ["I have a ticket for tonight.", "I am meeting a friend."])
|
||||||
|
var npc := NPCController.new()
|
||||||
|
npc.setup("npc_1", p, "enter", 4242)
|
||||||
|
var a := TestUtils.check_eq(npc.request_reason(), npc.last_reason_reply, "reply is stored")
|
||||||
|
var b := TestUtils.check_eq(npc.last_reason_reply, p.dialogue_lines[0], "seeded pick is deterministic")
|
||||||
|
var c := TestUtils.check(not npc.last_reason_reply.is_empty(), "reply is non-empty")
|
||||||
|
npc = null
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_request_reason_silent_without_lines() -> bool:
|
||||||
|
var npc := NPCController.new()
|
||||||
|
npc.setup("npc_2", _profile("Silent"), "enter", 7)
|
||||||
|
var reply: String = npc.request_reason()
|
||||||
|
var ok := TestUtils.check_eq(reply, "", "no dialogue lines means silence")
|
||||||
|
npc = null
|
||||||
|
return ok
|
||||||
|
|
||||||
|
func test_player_recognition_is_not_default() -> bool:
|
||||||
|
var p := _profile("Staffer")
|
||||||
|
p.npc_type = Rules.NpcType.AUTHORIZED_GUEST
|
||||||
|
var npc := NPCController.new()
|
||||||
|
npc.setup("npc_3", p, "enter", 11)
|
||||||
|
var a := TestUtils.check(not npc.player_recognized, "guests are not pre-recognized")
|
||||||
|
var override := {}
|
||||||
|
var artifacts := AccessLoadout.build(p, null, override)
|
||||||
|
var has_recognition := false
|
||||||
|
for artifact in artifacts:
|
||||||
|
if artifact is AccessArtifact and artifact.kind == Rules.AccessKind.RECOGNITION:
|
||||||
|
has_recognition = true
|
||||||
|
var b := TestUtils.check(not has_recognition, "guest artifact set has no recognition")
|
||||||
|
npc = null
|
||||||
|
return a and b
|
||||||
|
|
||||||
|
func test_recorder_starts_and_closes_incidents() -> bool:
|
||||||
|
var recorder := IncidentRecorder.new()
|
||||||
|
var a := TestUtils.check(not recorder.has_open_incident("npc_a"), "no incident before start")
|
||||||
|
recorder.start_incident("npc_a", "player_focus")
|
||||||
|
var b := TestUtils.check(recorder.has_open_incident("npc_a"), "incident opens")
|
||||||
|
recorder.add_event("npc_a", {"type": "recognition_decision", "recognized": true})
|
||||||
|
var c := TestUtils.check_eq(recorder.incidents.size(), 0, "open incident is not yet in history")
|
||||||
|
var closed: int = recorder.close_all_open("unresolved")
|
||||||
|
var d := TestUtils.check_eq(closed, 1, "close_all_open closes the open incident")
|
||||||
|
var e := TestUtils.check_eq(recorder.incidents.size(), 1, "closed incident lands in history")
|
||||||
|
var events: Array = recorder.incidents[0].get("events", [])
|
||||||
|
var f := TestUtils.check_eq(events.size(), 1, "recorded event survived the close")
|
||||||
|
recorder = null
|
||||||
|
return a and b and c and d and e and f
|
||||||
|
|
||||||
|
func test_shift_end_closes_open_incidents_into_review() -> bool:
|
||||||
|
var Coordinator := load("res://src/core/shift_coordinator.gd")
|
||||||
|
var Factory := load("res://src/content/scenario_factory.gd")
|
||||||
|
var coordinator = Coordinator.new()
|
||||||
|
coordinator.setup(Factory.hollywood_building(), 60.0, 99)
|
||||||
|
coordinator.start()
|
||||||
|
coordinator.recorder.start_incident("npc_x", "player_action")
|
||||||
|
coordinator.recorder.add_event("npc_x", {"type": "reason_requested", "reply": ""})
|
||||||
|
coordinator.call("_finish_shift")
|
||||||
|
var review: Dictionary = coordinator.build_review()
|
||||||
|
var incidents: Dictionary = review.get("incidents", {})
|
||||||
|
var list: Array = incidents.get("incidents", [])
|
||||||
|
var a := TestUtils.check_eq(list.size(), 1, "unresolved incident appears in the review")
|
||||||
|
var b := TestUtils.check_eq(str(list[0].get("outcome", "")), "unresolved", "outcome is unresolved")
|
||||||
|
var open := TestUtils.check_eq(int(incidents.get("open_count", -1)), 0, "nothing left open")
|
||||||
|
coordinator = null
|
||||||
|
return a and b and open
|
||||||
|
|
||||||
|
func test_body_camera_not_on_by_default() -> bool:
|
||||||
|
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
||||||
|
var a := TestUtils.check(src.contains("func _set_body_camera"), "body camera has a setter")
|
||||||
|
var b := TestUtils.check(src.contains("KEY_B"), "B toggles the body camera")
|
||||||
|
var c := TestUtils.check(
|
||||||
|
src.contains("level == Rules.Level.FORMAL_WARNING and not body_camera_on"),
|
||||||
|
"a formal warning turns the body camera on")
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_blocking_stations_the_guard() -> bool:
|
||||||
|
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
||||||
|
var a := TestUtils.check(src.contains("func _station_between"), "blocking has a stationing step")
|
||||||
|
var b := TestUtils.check(src.contains("_station_between(controller"), "blocking calls it")
|
||||||
|
var c := TestUtils.check(src.contains("to_door.normalized() * 0.9"), "station point is between NPC and door")
|
||||||
|
return a and b and c
|
||||||
@@ -38,7 +38,7 @@ func test_tv_studio_has_all_npc_types() -> bool:
|
|||||||
|
|
||||||
var _spawn_count := 0
|
var _spawn_count := 0
|
||||||
|
|
||||||
func _on_spawn(_profile: NpcProfile, _intent: String, _id: String) -> void:
|
func _on_spawn(_profile: NpcProfile, _intent: String, _id: String, _event: Dictionary = {}) -> void:
|
||||||
_spawn_count += 1
|
_spawn_count += 1
|
||||||
|
|
||||||
func test_late_phase_reaches_violent_event() -> bool:
|
func test_late_phase_reaches_violent_event() -> bool:
|
||||||
@@ -137,3 +137,17 @@ func test_waypoints_map_intent_to_location() -> bool:
|
|||||||
var d := TestUtils.check_eq(scenario.waypoint_for_intent("sit"), steps_end, "sit -> steps_end")
|
var d := TestUtils.check_eq(scenario.waypoint_for_intent("sit"), steps_end, "sit -> steps_end")
|
||||||
var e := TestUtils.check_eq(scenario.waypoint_for_intent("unknown_intent"), sidewalk, "unknown -> sidewalk fallback")
|
var e := TestUtils.check_eq(scenario.waypoint_for_intent("unknown_intent"), sidewalk, "unknown -> sidewalk fallback")
|
||||||
return a and b and c and d and e
|
return a and b and c and d and e
|
||||||
|
|
||||||
|
func test_spawn_rate_is_time_based() -> bool:
|
||||||
|
# Regression: tick() runs at physics rate. Rolling the event scheduler every
|
||||||
|
# frame spawned >1000 NPCs in seconds. Ten simulated seconds at 60 Hz must
|
||||||
|
# stay in the normal single-digit-per-second range.
|
||||||
|
var scenario: VenueScenario = ScenarioFactory.hollywood_building()
|
||||||
|
var coordinator := ShiftCoordinator.new()
|
||||||
|
coordinator.setup(scenario, 60.0, 99)
|
||||||
|
_spawn_count = 0
|
||||||
|
coordinator.npc_spawn_requested.connect(_on_spawn)
|
||||||
|
coordinator.start()
|
||||||
|
for i in 600:
|
||||||
|
coordinator.tick(1.0 / 60.0)
|
||||||
|
return TestUtils.check(_spawn_count <= 60, "spawns stay time-based at physics rate (was %d)" % _spawn_count)
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Session shell (start menu, pause overlay, settings) and routine-traffic
|
||||||
|
## despawn. These are headless-shape tests: they instantiate the UI scripts and
|
||||||
|
## check state transitions rather than rendering.
|
||||||
|
|
||||||
|
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 NpcAvatar := preload("res://src/game/npc_avatar.gd")
|
||||||
|
|
||||||
|
func test_start_menu_lists_all_venues() -> bool:
|
||||||
|
var menu: Node = StartMenu.new()
|
||||||
|
# Buttons are created in _ready; build them without a tree by calling _build.
|
||||||
|
menu._build()
|
||||||
|
var ids: Array[String] = []
|
||||||
|
for venue in StartMenu.VENUES:
|
||||||
|
ids.append(str(venue["id"]))
|
||||||
|
var a := TestUtils.check_eq(ids.size(), 3, "three venues offered")
|
||||||
|
var b := TestUtils.check(ids.has("hollywood_building"), "Hollywood Blvd present")
|
||||||
|
var c := TestUtils.check(ids.has("joke_shop"), "Joke Shop present")
|
||||||
|
var d := TestUtils.check(ids.has("tv_studio"), "TV Studio present")
|
||||||
|
menu.free()
|
||||||
|
return a and b and c and d
|
||||||
|
|
||||||
|
func test_start_menu_emits_selected_venue() -> bool:
|
||||||
|
var menu: Node = StartMenu.new()
|
||||||
|
menu._build()
|
||||||
|
var got := {"id": ""}
|
||||||
|
menu.venue_selected.connect(func(id): got["id"] = id)
|
||||||
|
menu._on_venue_pressed(1)
|
||||||
|
var a := TestUtils.check_eq(got["id"], "joke_shop", "clicking venue 2 selects the Joke Shop")
|
||||||
|
var b := TestUtils.check(not menu.visible, "menu closes after a venue is picked")
|
||||||
|
menu.free()
|
||||||
|
return a and b
|
||||||
|
|
||||||
|
func test_start_menu_clamps_focus() -> bool:
|
||||||
|
var menu: Node = StartMenu.new()
|
||||||
|
menu._build()
|
||||||
|
menu._focus_index(-5)
|
||||||
|
var low: int = menu._index
|
||||||
|
menu._focus_index(99)
|
||||||
|
var high: int = menu._index
|
||||||
|
menu.free()
|
||||||
|
return TestUtils.check_eq(low, 0, "focus clamps at the top") \
|
||||||
|
and TestUtils.check_eq(high, StartMenu.VENUES.size() - 1, "focus clamps at the bottom")
|
||||||
|
|
||||||
|
func test_pause_overlay_buttons_emit() -> bool:
|
||||||
|
var overlay: Node = PauseOverlay.new()
|
||||||
|
overlay._build()
|
||||||
|
var seen: Array[String] = []
|
||||||
|
overlay.resume_requested.connect(func(): seen.append("resume"))
|
||||||
|
overlay.restart_requested.connect(func(): seen.append("restart"))
|
||||||
|
overlay.settings_requested.connect(func(): seen.append("settings"))
|
||||||
|
overlay.quit_to_menu_requested.connect(func(): seen.append("menu"))
|
||||||
|
for node_name in ["ResumeButton", "RestartButton", "SettingsButton", "MenuButton"]:
|
||||||
|
var button: Button = overlay.find_child(node_name, true, false) as Button
|
||||||
|
if button:
|
||||||
|
button.pressed.emit()
|
||||||
|
overlay.free()
|
||||||
|
var expected := ["resume", "restart", "settings", "menu"]
|
||||||
|
return TestUtils.check_eq(seen, expected, "pause overlay exposes resume/restart/settings/menu")
|
||||||
|
|
||||||
|
func test_settings_panel_persists_values() -> bool:
|
||||||
|
var panel: Node = SettingsPanel.new()
|
||||||
|
panel._build()
|
||||||
|
panel.open({"look_sensitivity": 0.0022, "hud_font_scale": 1.0})
|
||||||
|
panel._on_sensitivity_changed(0.004)
|
||||||
|
panel._on_font_scale_changed(1.3)
|
||||||
|
var applied := {"settings": {}}
|
||||||
|
panel.applied.connect(func(s): applied["settings"] = s)
|
||||||
|
panel._on_apply()
|
||||||
|
var s: Dictionary = applied["settings"]
|
||||||
|
var a := TestUtils.check(is_equal_approx(float(s.get("look_sensitivity", 0.0)), 0.004), "sensitivity applied")
|
||||||
|
var b := TestUtils.check(is_equal_approx(float(s.get("hud_font_scale", 0.0)), 1.3), "font scale applied")
|
||||||
|
var c := TestUtils.check(not panel.visible, "panel closes on apply")
|
||||||
|
# Restore defaults written by the apply call.
|
||||||
|
if Save:
|
||||||
|
Save.save_settings(Save.DEFAULT_SETTINGS)
|
||||||
|
panel.free()
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_settings_panel_cancel_discards() -> bool:
|
||||||
|
var panel: Node = SettingsPanel.new()
|
||||||
|
panel._build()
|
||||||
|
panel.open({"look_sensitivity": 0.0022, "hud_font_scale": 1.0})
|
||||||
|
panel._on_sensitivity_changed(0.005)
|
||||||
|
var applied := {"count": 0}
|
||||||
|
panel.applied.connect(func(_s): applied["count"] += 1)
|
||||||
|
panel._on_cancel()
|
||||||
|
var a := TestUtils.check(not panel.visible, "cancel closes the panel")
|
||||||
|
var b := TestUtils.check_eq(applied["count"], 0, "cancel does not apply")
|
||||||
|
panel.free()
|
||||||
|
return a and b
|
||||||
|
|
||||||
|
func test_routine_traffic_is_ephemeral() -> bool:
|
||||||
|
var avatar: Node = NpcAvatar.new()
|
||||||
|
var a := TestUtils.check(avatar.ephemeral, "avatars default to ephemeral")
|
||||||
|
var b := TestUtils.check(avatar.lifetime_s > 0.0, "ephemeral traffic has a lifetime")
|
||||||
|
avatar.free()
|
||||||
|
return a and b
|
||||||
@@ -78,6 +78,36 @@ func test_asset_textures_present_and_pow2() -> bool:
|
|||||||
return TestUtils.fail("asset not power-of-two: %s (%dx%d)" % [path, w, h])
|
return TestUtils.fail("asset not power-of-two: %s (%dx%d)" % [path, w, h])
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
func test_steps_have_colliders() -> bool:
|
||||||
|
## Steps were visual-only and the guard walked through them (TODO).
|
||||||
|
var root := Node3D.new()
|
||||||
|
WorldBuilder.new(root).build()
|
||||||
|
var steps := root.find_child("Steps", true, false)
|
||||||
|
var a := TestUtils.check(steps != null, "Steps node exists")
|
||||||
|
var b := TestUtils.check(steps is StaticBody3D, "Steps are a physics body")
|
||||||
|
var shape_count := 0
|
||||||
|
if steps is StaticBody3D:
|
||||||
|
var body := steps as StaticBody3D
|
||||||
|
shape_count = body.get_shape_owners().size()
|
||||||
|
var c := TestUtils.check(shape_count >= 5, "four treads + landing have colliders (got %d)" % shape_count)
|
||||||
|
root.free()
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_skyline_backdrop_uses_alpha() -> bool:
|
||||||
|
## The opaque skyline texture made the plane render as a black slab (TODO).
|
||||||
|
var root := Node3D.new()
|
||||||
|
var builder := WorldBuilder.new(root)
|
||||||
|
builder.build()
|
||||||
|
var backdrop := root.find_child("SkylineBackdrop", true, false)
|
||||||
|
var a := TestUtils.check(backdrop != null, "SkylineBackdrop exists")
|
||||||
|
var mat: StandardMaterial3D = builder._skyline_mat
|
||||||
|
var b := TestUtils.check(mat != null, "skyline material captured")
|
||||||
|
var c := TestUtils.check(
|
||||||
|
mat != null and mat.transparency != BaseMaterial3D.TRANSPARENCY_DISABLED,
|
||||||
|
"skyline material uses alpha, not an opaque slab")
|
||||||
|
root.free()
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
func test_door_indicator_built() -> bool:
|
func test_door_indicator_built() -> bool:
|
||||||
var root := Node3D.new()
|
var root := Node3D.new()
|
||||||
var builder := WorldBuilder.new(root)
|
var builder := WorldBuilder.new(root)
|
||||||
|
|||||||
@@ -0,0 +1,451 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate, crop, resize, and quality-check game image assets."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, ImageChops, ImageOps, ImageStat
|
||||||
|
|
||||||
|
import generate_textures
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"}
|
||||||
|
DEFAULT_ROLE_IMAGE_MODEL = "openai/gpt-5-image-mini"
|
||||||
|
API_ASPECTS = {
|
||||||
|
"texture": "1:1", "tile": "1:1", "icon": "1:1", "app_icon": "1:1",
|
||||||
|
"overlay": "1:1", "card": "3:2", "banner": "3:2", "backdrop": "3:2",
|
||||||
|
"background": "3:2",
|
||||||
|
}
|
||||||
|
|
||||||
|
PROFILES = {
|
||||||
|
"texture": {"target": (512, 512), "min": (256, 256), "max": (1024, 1024),
|
||||||
|
"aspect": 1.0, "alpha": None, "view": "flat/orthographic"},
|
||||||
|
"tile": {"target": (512, 512), "min": (256, 256), "max": (1024, 1024),
|
||||||
|
"aspect": 1.0, "alpha": None, "view": "flat/orthographic",
|
||||||
|
"power_of_two": True, "seamless": True},
|
||||||
|
"icon": {"target": (64, 64), "min": (32, 32), "max": (512, 512),
|
||||||
|
"aspect": 1.0, "alpha": True, "view": "flat, centered glyph"},
|
||||||
|
"app_icon": {"target": (1024, 1024), "min": (512, 512), "max": (1024, 1024),
|
||||||
|
"aspect": 1.0, "alpha": None, "view": "square, centered composition"},
|
||||||
|
"overlay": {"target": (512, 512), "min": (256, 256), "max": (1024, 1024),
|
||||||
|
"aspect": 1.0, "alpha": None, "view": "screen-space overlay",
|
||||||
|
"min_contrast": 5.0},
|
||||||
|
"card": {"target": (256, 128), "min": (128, 64), "max": (1024, 512),
|
||||||
|
"aspect": 2.0, "alpha": None, "view": "flat, front-facing card"},
|
||||||
|
"banner": {"target": (1024, 256), "min": (512, 128), "max": (2048, 512),
|
||||||
|
"aspect": 4.0, "alpha": None, "view": "flat, front-facing banner"},
|
||||||
|
"backdrop": {"target": (1024, 256), "min": (1024, 256), "max": (8192, 2048),
|
||||||
|
"aspect": 4.0, "alpha": True,
|
||||||
|
"view": "straight-on wide backdrop on transparency"},
|
||||||
|
"background": {"target": (1920, 1080), "min": (1280, 720), "max": (8192, 4320),
|
||||||
|
"aspect": 16 / 9, "alpha": None, "view": "intended game camera and horizon"},
|
||||||
|
"screenshot": {"target": (1280, 720), "min": (1280, 720), "max": (3840, 2160),
|
||||||
|
"aspect": 16 / 9, "alpha": None, "view": "gameplay frame, venue, and UI state",
|
||||||
|
"min_contrast": 5.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
ROLE_GUIDANCE = {
|
||||||
|
"texture": "flat-lit game surface texture, straight-on orthographic view, no perspective",
|
||||||
|
"tile": "seamless tileable surface texture, flat even lighting, orthographic, no perspective or shadows",
|
||||||
|
"icon": "single centered flat UI glyph, clean silhouette, transparent background, no mockup or perspective",
|
||||||
|
"app_icon": "square game application icon, centered composition, no perspective",
|
||||||
|
"overlay": "screen-space overlay texture with transparent edges, no perspective",
|
||||||
|
"card": "single wide landscape game UI card filling the frame, front-facing and flat, no perspective, no surrounding mockup or whitespace",
|
||||||
|
"banner": "wide front-facing game banner texture, keep important details in a narrow horizontal band through the image center for final cropping, no perspective, no surrounding scene",
|
||||||
|
"backdrop": "wide isolated skyline silhouette across a low horizon in a narrow horizontal band through the image center, transparent background, no foreground objects",
|
||||||
|
"background": "wide 16:9 game background with a coherent horizon and straight camera view, keep important details in the central horizontal band for final cropping",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_path(value):
|
||||||
|
path = Path(value).expanduser()
|
||||||
|
return path if path.is_absolute() else ROOT / path
|
||||||
|
|
||||||
|
|
||||||
|
def has_alpha(image):
|
||||||
|
return image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info)
|
||||||
|
|
||||||
|
|
||||||
|
def has_transparency(image):
|
||||||
|
if not has_alpha(image):
|
||||||
|
return False
|
||||||
|
return image.convert("RGBA").getchannel("A").getextrema()[0] < 255
|
||||||
|
|
||||||
|
|
||||||
|
def parse_color(value):
|
||||||
|
value = value.strip().lstrip("#")
|
||||||
|
if len(value) not in (6, 8):
|
||||||
|
raise argparse.ArgumentTypeError("color must be #RRGGBB or #RRGGBBAA")
|
||||||
|
try:
|
||||||
|
channels = tuple(int(value[index:index + 2], 16)
|
||||||
|
for index in range(0, len(value), 2))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
"color must use hexadecimal digits") from exc
|
||||||
|
return channels + ((255,) if len(channels) == 3 else ())
|
||||||
|
|
||||||
|
|
||||||
|
def save_image(image, path):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
if suffix in (".jpg", ".jpeg") and image.mode in ("RGBA", "LA"):
|
||||||
|
background = Image.new("RGB", image.size, (0, 0, 0))
|
||||||
|
background.paste(image.convert("RGBA"),
|
||||||
|
mask=image.convert("RGBA").getchannel("A"))
|
||||||
|
image = background
|
||||||
|
image.save(path)
|
||||||
|
print(
|
||||||
|
f"wrote {path.relative_to(ROOT) if path.is_relative_to(ROOT) else path} {image.size}")
|
||||||
|
|
||||||
|
|
||||||
|
def crop_image(source, output, width, height, x=None, y=None):
|
||||||
|
with Image.open(source) as image:
|
||||||
|
image.load()
|
||||||
|
source_width, source_height = image.size
|
||||||
|
if width <= 0 or height <= 0 or width > source_width or height > source_height:
|
||||||
|
raise ValueError(
|
||||||
|
f"crop {width}x{height} must fit inside source {source_width}x{source_height}")
|
||||||
|
if (x is None) != (y is None):
|
||||||
|
raise ValueError("--x and --y must be supplied together")
|
||||||
|
left = (source_width - width) // 2 if x is None else x
|
||||||
|
top = (source_height - height) // 2 if y is None else y
|
||||||
|
if left < 0 or top < 0 or left + width > source_width or top + height > source_height:
|
||||||
|
raise ValueError(
|
||||||
|
"crop origin and dimensions extend beyond the source image")
|
||||||
|
cropped = image.crop((left, top, left + width, top + height))
|
||||||
|
save_image(cropped, output)
|
||||||
|
|
||||||
|
|
||||||
|
def resize_image(source, output, width, height, fit="contain", background=(0, 0, 0, 0)):
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
raise ValueError("resize dimensions must be positive")
|
||||||
|
with Image.open(source) as image:
|
||||||
|
image.load()
|
||||||
|
if fit == "stretch":
|
||||||
|
result = image.resize((width, height), Image.Resampling.LANCZOS)
|
||||||
|
elif fit == "cover":
|
||||||
|
result = ImageOps.fit(image, (width, height),
|
||||||
|
method=Image.Resampling.LANCZOS)
|
||||||
|
else:
|
||||||
|
contained = ImageOps.contain(
|
||||||
|
image, (width, height), method=Image.Resampling.LANCZOS)
|
||||||
|
use_alpha = has_alpha(image) or background[3] < 255
|
||||||
|
mode = "RGBA" if use_alpha else "RGB"
|
||||||
|
fill = background if use_alpha else background[:3]
|
||||||
|
result = Image.new(mode, (width, height), fill)
|
||||||
|
contained = contained.convert(mode)
|
||||||
|
offset = ((width - contained.width) // 2,
|
||||||
|
(height - contained.height) // 2)
|
||||||
|
if use_alpha:
|
||||||
|
result.alpha_composite(contained, offset)
|
||||||
|
else:
|
||||||
|
result.paste(contained, offset)
|
||||||
|
save_image(result, output)
|
||||||
|
|
||||||
|
|
||||||
|
def infer_role(path):
|
||||||
|
parts = [part.lower() for part in path.parts]
|
||||||
|
stem = path.stem.lower()
|
||||||
|
if "screenshots" in parts or "screenshot" in stem:
|
||||||
|
return "screenshot"
|
||||||
|
if "icons" in parts:
|
||||||
|
return "icon"
|
||||||
|
if "cards" in parts:
|
||||||
|
return "card"
|
||||||
|
if stem == "icon":
|
||||||
|
return "app_icon"
|
||||||
|
if "vignette" in stem or "overlay" in stem:
|
||||||
|
return "overlay"
|
||||||
|
if "skyline" in stem or "backdrop" in parts:
|
||||||
|
return "backdrop"
|
||||||
|
if "awning" in stem or "banner" in parts:
|
||||||
|
return "banner"
|
||||||
|
if stem in {"brick_facade", "concrete", "asphalt"} or "tiles" in parts:
|
||||||
|
return "tile"
|
||||||
|
if "backgrounds" in parts or "background" in parts:
|
||||||
|
return "background"
|
||||||
|
return "texture"
|
||||||
|
|
||||||
|
|
||||||
|
def _mean_edge_difference(image):
|
||||||
|
rgb = image.convert("RGB")
|
||||||
|
width, height = rgb.size
|
||||||
|
horizontal = ImageStat.Stat(ImageChops.difference(
|
||||||
|
rgb.crop((0, 0, 1, height)), rgb.crop((width - 1, 0, width, height))))
|
||||||
|
vertical = ImageStat.Stat(ImageChops.difference(
|
||||||
|
rgb.crop((0, 0, width, 1)), rgb.crop((0, height - 1, width, height))))
|
||||||
|
return max(sum(horizontal.mean) / len(horizontal.mean),
|
||||||
|
sum(vertical.mean) / len(vertical.mean))
|
||||||
|
|
||||||
|
|
||||||
|
def audit_image(path, role="auto", seam_threshold=18.0):
|
||||||
|
path = Path(path)
|
||||||
|
chosen_role = infer_role(path) if role == "auto" else role
|
||||||
|
profile = PROFILES[chosen_role]
|
||||||
|
errors = []
|
||||||
|
warnings = []
|
||||||
|
try:
|
||||||
|
with Image.open(path) as image:
|
||||||
|
image.load()
|
||||||
|
width, height = image.size
|
||||||
|
alpha = has_transparency(image)
|
||||||
|
minimum = profile["min"]
|
||||||
|
maximum = profile["max"]
|
||||||
|
if width < minimum[0] or height < minimum[1]:
|
||||||
|
errors.append(
|
||||||
|
f"too small: {width}x{height}; minimum is {minimum[0]}x{minimum[1]}")
|
||||||
|
if width > maximum[0] or height > maximum[1]:
|
||||||
|
errors.append(
|
||||||
|
f"too large: {width}x{height}; maximum is {maximum[0]}x{maximum[1]}")
|
||||||
|
aspect = profile.get("aspect")
|
||||||
|
actual_aspect = width / height
|
||||||
|
if aspect and abs(actual_aspect - aspect) / aspect > 0.12:
|
||||||
|
errors.append(
|
||||||
|
f"wrong aspect ratio: {actual_aspect:.3f}; expected about {aspect:.3f}")
|
||||||
|
if profile.get("alpha") is True and not alpha:
|
||||||
|
errors.append("transparency required but image is opaque")
|
||||||
|
if alpha and image.convert("RGBA").getchannel("A").getbbox() is None:
|
||||||
|
errors.append("image is fully transparent")
|
||||||
|
elif profile.get("alpha") is False and alpha:
|
||||||
|
warnings.append(
|
||||||
|
"image has an alpha channel although this role is normally opaque")
|
||||||
|
if profile.get("power_of_two") and (width & (width - 1) or height & (height - 1)):
|
||||||
|
warnings.append("dimensions are not powers of two")
|
||||||
|
edge_difference = None
|
||||||
|
if profile.get("seamless"):
|
||||||
|
edge_difference = _mean_edge_difference(image)
|
||||||
|
if edge_difference > seam_threshold:
|
||||||
|
errors.append(
|
||||||
|
f"tile edges differ by {edge_difference:.1f} (limit {seam_threshold:.1f})")
|
||||||
|
contrast = None
|
||||||
|
if profile.get("min_contrast") is not None:
|
||||||
|
contrast = ImageStat.Stat(image.convert("L")).stddev[0]
|
||||||
|
if contrast < profile["min_contrast"]:
|
||||||
|
errors.append(
|
||||||
|
f"near-blank frame: contrast {contrast:.1f} is below {profile['min_contrast']:.1f}")
|
||||||
|
manual_review = [
|
||||||
|
f"Confirm {profile.get('view', 'intended')} perspective/projection visually; "
|
||||||
|
"automated checks cannot infer it reliably."
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"path": str(path), "role": chosen_role, "size": [width, height],
|
||||||
|
"alpha": alpha, "edge_difference": edge_difference, "contrast": contrast,
|
||||||
|
"quality": "bad" if errors else "good",
|
||||||
|
"errors": errors, "warnings": warnings, "manual_review": manual_review,
|
||||||
|
}
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
return {
|
||||||
|
"path": str(path), "role": chosen_role, "size": None, "alpha": None,
|
||||||
|
"edge_difference": None, "contrast": None, "quality": "bad",
|
||||||
|
"errors": [f"cannot read image: {exc}"],
|
||||||
|
"warnings": [], "manual_review": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def image_paths(values):
|
||||||
|
found = []
|
||||||
|
for value in values:
|
||||||
|
path = resolve_path(value)
|
||||||
|
if path.is_dir():
|
||||||
|
found.extend(item for item in path.rglob(
|
||||||
|
"*") if item.is_file() and item.suffix.lower() in IMAGE_EXTENSIONS)
|
||||||
|
elif path.suffix.lower() in IMAGE_EXTENSIONS:
|
||||||
|
found.append(path)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"not an image file or directory: {path}")
|
||||||
|
return sorted(set(found))
|
||||||
|
|
||||||
|
|
||||||
|
def _generate(args):
|
||||||
|
profile = PROFILES[args.role]
|
||||||
|
if (args.width is None) != (args.height is None):
|
||||||
|
raise ValueError("--width and --height must be supplied together")
|
||||||
|
width, height = (
|
||||||
|
args.width, args.height) if args.width else profile["target"]
|
||||||
|
transparent = args.transparent if args.transparent is not None else profile.get(
|
||||||
|
"alpha") is True
|
||||||
|
prompt = f"{args.prompt.strip()}. {ROLE_GUIDANCE[args.role]}."
|
||||||
|
image = generate_textures.generate_image(
|
||||||
|
prompt, args.model, args.request_size, transparent,
|
||||||
|
aspect_ratio=API_ASPECTS[args.role])
|
||||||
|
foreground = getattr(args, "foreground", None)
|
||||||
|
if foreground:
|
||||||
|
image = colorize_alpha(image, foreground)
|
||||||
|
if args.role == "icon" and has_alpha(image):
|
||||||
|
image = resize_image_from_image(
|
||||||
|
image, width, height, "contain", (0, 0, 0, 0))
|
||||||
|
else:
|
||||||
|
image = ImageOps.fit(image, (width, height),
|
||||||
|
method=Image.Resampling.LANCZOS)
|
||||||
|
if args.role == "tile":
|
||||||
|
image = make_seamless(image)
|
||||||
|
save_image(image, resolve_path(args.output))
|
||||||
|
|
||||||
|
|
||||||
|
def resize_image_from_image(image, width, height, fit, background):
|
||||||
|
if fit == "cover":
|
||||||
|
return ImageOps.fit(image, (width, height), method=Image.Resampling.LANCZOS)
|
||||||
|
contained = ImageOps.contain(
|
||||||
|
image, (width, height), method=Image.Resampling.LANCZOS)
|
||||||
|
mode = "RGBA" if has_alpha(image) or background[3] < 255 else "RGB"
|
||||||
|
result = Image.new(mode, (width, height),
|
||||||
|
background if mode == "RGBA" else background[:3])
|
||||||
|
contained = contained.convert(mode)
|
||||||
|
offset = ((width - contained.width) // 2, (height - contained.height) // 2)
|
||||||
|
if mode == "RGBA":
|
||||||
|
result.alpha_composite(contained, offset)
|
||||||
|
else:
|
||||||
|
result.paste(contained, offset)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def colorize_alpha(image, color):
|
||||||
|
if not has_alpha(image):
|
||||||
|
raise ValueError(
|
||||||
|
"foreground color requires an image with transparency")
|
||||||
|
result = Image.new("RGBA", image.size, color[:3])
|
||||||
|
result.putalpha(image.convert("RGBA").getchannel("A"))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def make_seamless(image, band=32):
|
||||||
|
original_mode = image.mode
|
||||||
|
result = image.convert("RGBA")
|
||||||
|
width, height = result.size
|
||||||
|
pixels = result.load()
|
||||||
|
band = min(band, width // 4, height // 4)
|
||||||
|
|
||||||
|
def blend_edges(first_index, last_index, length, across_length, horizontal):
|
||||||
|
for offset in range(band):
|
||||||
|
weight = 1.0 - offset / band
|
||||||
|
first = first_index(offset)
|
||||||
|
last = last_index(offset)
|
||||||
|
for across in range(across_length):
|
||||||
|
first_pos = (first, across) if horizontal else (across, first)
|
||||||
|
last_pos = (last, across) if horizontal else (across, last)
|
||||||
|
first_color = pixels[first_pos]
|
||||||
|
last_color = pixels[last_pos]
|
||||||
|
average = tuple((a + b) / 2 for a,
|
||||||
|
b in zip(first_color, last_color))
|
||||||
|
pixels[first_pos] = tuple(round(a * (1 - weight) + b * weight)
|
||||||
|
for a, b in zip(first_color, average))
|
||||||
|
pixels[last_pos] = tuple(round(a * (1 - weight) + b * weight)
|
||||||
|
for a, b in zip(last_color, average))
|
||||||
|
|
||||||
|
blend_edges(lambda offset: offset, lambda offset: width - 1 - offset,
|
||||||
|
width, height, True)
|
||||||
|
blend_edges(lambda offset: offset, lambda offset: height - 1 - offset,
|
||||||
|
height, width, False)
|
||||||
|
return result if has_alpha(image) else result.convert(original_mode)
|
||||||
|
|
||||||
|
|
||||||
|
def seamless_image(source, output):
|
||||||
|
with Image.open(source) as image:
|
||||||
|
image.load()
|
||||||
|
result = make_seamless(image)
|
||||||
|
save_image(result, output)
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Create and validate game image assets.")
|
||||||
|
commands = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
generate = commands.add_parser(
|
||||||
|
"generate", help="generate an image using the existing OpenRouter backend")
|
||||||
|
generate.add_argument("--prompt", required=True)
|
||||||
|
generate.add_argument("--output", required=True,
|
||||||
|
help="output path, relative to the project root or absolute")
|
||||||
|
generate.add_argument("--role", choices=API_ASPECTS, required=True)
|
||||||
|
generate.add_argument(
|
||||||
|
"--model", default=DEFAULT_ROLE_IMAGE_MODEL)
|
||||||
|
generate.add_argument("--request-size", type=int,
|
||||||
|
default=generate_textures.GEN_SIZE)
|
||||||
|
generate.add_argument("--width", type=int)
|
||||||
|
generate.add_argument("--height", type=int)
|
||||||
|
generate.add_argument(
|
||||||
|
"--foreground", type=parse_color,
|
||||||
|
help="replace visible RGB with this color while preserving alpha")
|
||||||
|
alpha = generate.add_mutually_exclusive_group()
|
||||||
|
alpha.add_argument("--transparent", dest="transparent",
|
||||||
|
action="store_true")
|
||||||
|
alpha.add_argument("--opaque", dest="transparent", action="store_false")
|
||||||
|
generate.set_defaults(transparent=None)
|
||||||
|
generate.set_defaults(handler=_generate)
|
||||||
|
|
||||||
|
crop = commands.add_parser(
|
||||||
|
"crop", help="crop an exact-size region; defaults to the image center")
|
||||||
|
crop.add_argument("input")
|
||||||
|
crop.add_argument("output")
|
||||||
|
crop.add_argument("--width", type=int, required=True)
|
||||||
|
crop.add_argument("--height", type=int, required=True)
|
||||||
|
crop.add_argument("--x", type=int)
|
||||||
|
crop.add_argument("--y", type=int)
|
||||||
|
crop.set_defaults(handler=lambda args: crop_image(resolve_path(args.input), resolve_path(args.output),
|
||||||
|
args.width, args.height, args.x, args.y))
|
||||||
|
|
||||||
|
resize = commands.add_parser(
|
||||||
|
"resize", help="resize to exact dimensions while preserving aspect by default")
|
||||||
|
resize.add_argument("input")
|
||||||
|
resize.add_argument("output")
|
||||||
|
resize.add_argument("--width", type=int, required=True)
|
||||||
|
resize.add_argument("--height", type=int, required=True)
|
||||||
|
resize.add_argument("--fit", choices=("contain",
|
||||||
|
"cover", "stretch"), default="contain")
|
||||||
|
resize.add_argument("--background", type=parse_color, default=(0, 0, 0, 0))
|
||||||
|
resize.set_defaults(handler=lambda args: resize_image(resolve_path(args.input), resolve_path(args.output),
|
||||||
|
args.width, args.height, args.fit, args.background))
|
||||||
|
|
||||||
|
seamless = commands.add_parser(
|
||||||
|
"seamless", help="blend opposite edges of a texture for tiling")
|
||||||
|
seamless.add_argument("input")
|
||||||
|
seamless.add_argument("output")
|
||||||
|
seamless.set_defaults(handler=lambda args: seamless_image(
|
||||||
|
resolve_path(args.input), resolve_path(args.output)))
|
||||||
|
|
||||||
|
audit = commands.add_parser(
|
||||||
|
"audit", help="classify images as good/bad using their game-asset role")
|
||||||
|
audit.add_argument("paths", nargs="+", help="image files or directories")
|
||||||
|
audit.add_argument("--role", choices=("auto", *PROFILES), default="auto")
|
||||||
|
audit.add_argument("--seam-threshold", type=float, default=18.0)
|
||||||
|
audit.add_argument("--json", action="store_true",
|
||||||
|
help="emit machine-readable JSON")
|
||||||
|
audit.set_defaults(handler=_audit)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def _audit(args):
|
||||||
|
results = [audit_image(path, args.role, args.seam_threshold)
|
||||||
|
for path in image_paths(args.paths)]
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(results, indent=2))
|
||||||
|
else:
|
||||||
|
good_count = sum(result["quality"] == "good" for result in results)
|
||||||
|
for result in results:
|
||||||
|
size = "unreadable" if result["size"] is None else "x".join(
|
||||||
|
map(str, result["size"]))
|
||||||
|
print(
|
||||||
|
f"{result['quality'].upper():4} {result['role']:10} {size:12} {result['path']}")
|
||||||
|
for message in result["errors"] + result["warnings"]:
|
||||||
|
print(f" - {message}")
|
||||||
|
for message in result["manual_review"]:
|
||||||
|
print(f" - review: {message}")
|
||||||
|
print(f"Summary: {good_count} good, {len(results) - good_count} bad")
|
||||||
|
return 1 if any(result["quality"] == "bad" for result in results) else 0
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
try:
|
||||||
|
result = args.handler(args)
|
||||||
|
return result if isinstance(result, int) else 0
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -221,7 +221,7 @@ def skyline():
|
|||||||
x = -40
|
x = -40
|
||||||
while x < w + 40:
|
while x < w + 40:
|
||||||
bw = rnd.randint(64, 170)
|
bw = rnd.randint(64, 170)
|
||||||
bh = rnd.randint(150, 400)
|
bh = rnd.randint(90, 165)
|
||||||
d.rectangle([x, h - bh, x + bw, h], fill=build_color)
|
d.rectangle([x, h - bh, x + bw, h], fill=build_color)
|
||||||
# few lit windows
|
# few lit windows
|
||||||
for _ in range(bw * bh // 900):
|
for _ in range(bw * bh // 900):
|
||||||
@@ -231,7 +231,8 @@ def skyline():
|
|||||||
d.rectangle([wx, wy, wx + 6, wy + 8],
|
d.rectangle([wx, wy, wx + 6, wy + 8],
|
||||||
fill=(255, 200, 120, 220))
|
fill=(255, 200, 120, 220))
|
||||||
x += bw + rnd.randint(10, 46)
|
x += bw + rnd.randint(10, 46)
|
||||||
d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255))
|
# Opaque bottom border removed for transparency - planes will now render as silhouettes
|
||||||
|
# d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255)) # commented out - was causing opaque black slabs
|
||||||
save(img, "skyline.png")
|
save(img, "skyline.png")
|
||||||
|
|
||||||
|
|
||||||
@@ -316,8 +317,10 @@ def app_icon():
|
|||||||
src_png = os.path.join(ASSETS, "icon.png")
|
src_png = os.path.join(ASSETS, "icon.png")
|
||||||
ran = False
|
ran = False
|
||||||
for cmd in (
|
for cmd in (
|
||||||
["magick", src_png, "-background", "none", "-define", "icon:auto-resize=48,32,16", ico_path],
|
["magick", src_png, "-background", "none", "-define",
|
||||||
["convert", src_png, "-background", "none", "-define", "icon:auto-resize=48,32,16", ico_path],
|
"icon:auto-resize=48,32,16", ico_path],
|
||||||
|
["convert", src_png, "-background", "none",
|
||||||
|
"-define", "icon:auto-resize=48,32,16", ico_path],
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
subprocess.run(cmd, check=True, capture_output=True)
|
subprocess.run(cmd, check=True, capture_output=True)
|
||||||
@@ -329,7 +332,8 @@ def app_icon():
|
|||||||
print("wrote assets/textures/icon.ico (BMP 16/32/48 via ImageMagick)")
|
print("wrote assets/textures/icon.ico (BMP 16/32/48 via ImageMagick)")
|
||||||
else:
|
else:
|
||||||
print("WARNING: ImageMagick not found; writing PIL ICO (may be PNG-compressed)")
|
print("WARNING: ImageMagick not found; writing PIL ICO (may be PNG-compressed)")
|
||||||
Image.open(src_png).convert("RGBA").save(ico_path, format="ICO", sizes=[(48, 48), (32, 32), (16, 16)])
|
Image.open(src_png).convert("RGBA").save(
|
||||||
|
ico_path, format="ICO", sizes=[(48, 48), (32, 32), (16, 16)])
|
||||||
|
|
||||||
|
|
||||||
def cards():
|
def cards():
|
||||||
@@ -369,24 +373,33 @@ def _api_key() -> str:
|
|||||||
return key
|
return key
|
||||||
|
|
||||||
|
|
||||||
def _generate_image(prompt: str, model: str, size: int,
|
def generate_image(prompt: str, model: str, size: int,
|
||||||
transparent: bool = False) -> Image.Image:
|
transparent: bool = False,
|
||||||
|
aspect_ratio: str = "1:1") -> Image.Image:
|
||||||
"""Call the OpenRouter image API and return the decoded PIL image.
|
"""Call the OpenRouter image API and return the decoded PIL image.
|
||||||
|
|
||||||
Requests a square image at `size` px and decodes the base64 PNG/JPEG/WebP
|
Requests a square image at `size` px or a role-shaped image at the matching
|
||||||
bytes returned in `data[].b64_json`. The requested size is clamped to at
|
resolution tier and decodes `data[].b64_json`. Seedream's square requests
|
||||||
least GEN_SIZE because some models (e.g. seedream) reject small outputs;
|
retain the existing GEN_SIZE floor. When `transparent` is true, the API is
|
||||||
callers downscale the result to MAX_DIM afterwards. When `transparent` is
|
asked for a transparent PNG background.
|
||||||
true, the API is asked for a transparent background (PNG alpha).
|
|
||||||
"""
|
"""
|
||||||
size = max(GEN_SIZE, int(size))
|
size = max(1, int(size))
|
||||||
|
if model == DEFAULT_IMAGE_MODEL:
|
||||||
|
size = max(GEN_SIZE, size)
|
||||||
body = {
|
body = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"size": f"{size}x{size}",
|
|
||||||
"output_format": "png",
|
"output_format": "png",
|
||||||
"n": 1,
|
"n": 1,
|
||||||
}
|
}
|
||||||
|
if aspect_ratio == "1:1":
|
||||||
|
body["size"] = f"{size}x{size}"
|
||||||
|
else:
|
||||||
|
body["aspect_ratio"] = aspect_ratio
|
||||||
|
body["resolution"] = next(
|
||||||
|
(tier for limit, tier in ((512, "512"), (768, "768"), (1024, "1K"),
|
||||||
|
(1536, "1.5K"), (2048, "2K"), (4096, "4K"))
|
||||||
|
if size <= limit), "4K")
|
||||||
if transparent:
|
if transparent:
|
||||||
body["background"] = "transparent"
|
body["background"] = "transparent"
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
@@ -435,7 +448,7 @@ def _fit(img: Image.Image, max_dim: int = MAX_DIM) -> Image.Image:
|
|||||||
def generate_ai_textures(prompt: str, name: str, model: str, size: int,
|
def generate_ai_textures(prompt: str, name: str, model: str, size: int,
|
||||||
transparent: bool = False) -> None:
|
transparent: bool = False) -> None:
|
||||||
"""Generate one texture via OpenRouter and save it under assets/textures/."""
|
"""Generate one texture via OpenRouter and save it under assets/textures/."""
|
||||||
img = _generate_image(prompt, model, size, transparent)
|
img = generate_image(prompt, model, size, transparent)
|
||||||
img = _fit(img)
|
img = _fit(img)
|
||||||
if transparent:
|
if transparent:
|
||||||
img = img.convert("RGBA")
|
img = img.convert("RGBA")
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
# Publish a release to the Gitea/Forgejo host, uploading the built artifacts.
|
# Publish a release to the Gitea/Forgejo host, uploading the built artifacts.
|
||||||
#
|
#
|
||||||
# Requires:
|
# Requires:
|
||||||
# - GITEA_TOKEN (a token with write access to the repo)
|
# - GITEA_TOKEN (token with write access) OR credentials usable for basic auth
|
||||||
# - build artifacts from tools/build_releases.sh (or already exported)
|
# (e.g. the same ones stored in ~/.git-credentials by `git push`)
|
||||||
|
# - build artifacts from tools/build_releases.sh (+ optional installer/AppImage)
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# GITEA_TOKEN=xxx ./tools/publish_release.sh [version]
|
# GITEA_TOKEN=xxx ./tools/publish_release.sh [version]
|
||||||
@@ -12,45 +13,69 @@ set -euo pipefail
|
|||||||
|
|
||||||
REPO="${REPO:-zwitschi/security-game}"
|
REPO="${REPO:-zwitschi/security-game}"
|
||||||
HOST="${HOST:-https://git.allucanget.biz}"
|
HOST="${HOST:-https://git.allucanget.biz}"
|
||||||
API="$HOST/api/v1/repos/$REPO/releases"
|
|
||||||
VERSION="${1:-$(grep -oP 'config/version="\K[^"]+' project.godot)}"
|
VERSION="${1:-$(grep -oP 'config/version="\K[^"]+' project.godot)}"
|
||||||
TAG="v$VERSION"
|
TAG="v$VERSION"
|
||||||
|
|
||||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
||||||
echo "error: GITEA_TOKEN is not set (see docs/RELEASING.md)" >&2
|
AUTH=("Authorization: token $GITEA_TOKEN")
|
||||||
|
elif [[ -f "$HOME/.git-credentials" ]]; then
|
||||||
|
CRED="$(python3 -c "
|
||||||
|
import os
|
||||||
|
for l in open(os.path.expanduser('~/.git-credentials')):
|
||||||
|
if '$HOST'.split('//')[1] in l:
|
||||||
|
print(l.split('://',1)[1].split('@')[0]); break")"
|
||||||
|
if [[ -z "$CRED" ]]; then
|
||||||
|
echo "error: no credentials for $HOST in ~/.git-credentials" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "error: set GITEA_TOKEN or provide ~/.git-credentials" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Artifacts must already exist (run tools/build_releases.sh first).
|
# Artifacts that must exist (run tools/build_releases.sh first).
|
||||||
printf -v BS 'build/step-line-linux-%s.x86_64' "$VERSION"
|
portables=(
|
||||||
printf -v BS_WIN 'build/step-line-windows-%s.exe' "$VERSION"
|
"build/step-line-linux-$VERSION.x86_64"
|
||||||
printf -v BS_MAC 'build/step-line-macos-%s.zip' "$VERSION"
|
"build/step-line-windows-$VERSION.exe"
|
||||||
for f in "$BS" "$BS_WIN" "$BS_MAC"; do
|
"build/step-line-macos-$VERSION.zip"
|
||||||
if [[ ! -f "$f" ]]; then
|
)
|
||||||
echo "error: missing artifact $f — run ./tools/build_releases.sh first" >&2
|
extras=(
|
||||||
exit 1
|
"build/step-line-setup-$VERSION.exe"
|
||||||
|
"build/step-line-$VERSION-x86_64.AppImage"
|
||||||
|
)
|
||||||
|
all_artifacts=("${portables[@]}")
|
||||||
|
for f in "${extras[@]}"; do
|
||||||
|
if [[ -f "$f" ]]; then
|
||||||
|
all_artifacts+=("$f")
|
||||||
|
else
|
||||||
|
echo "note: optional artifact missing (skipping): $f"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
API="$HOST/api/v1/repos/$REPO/releases"
|
||||||
echo "==> Creating release $TAG on $HOST/$REPO"
|
echo "==> Creating release $TAG on $HOST/$REPO"
|
||||||
RELEASE_JSON="$(curl -s -X POST "$API" \
|
RELEASE_ID="$(curl -s -X POST "$API" "${AUTH[@]}" \
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Step Line $VERSION — playable prototype. See README for controls.\",\"draft\":false,\"prerelease\":false}")"
|
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Step Line $VERSION — playable prototype. See README for controls.\",\"draft\":false,\"prerelease\":false}" \
|
||||||
|
| python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)"
|
||||||
|
|
||||||
RELEASE_ID="$(echo "$RELEASE_JSON" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)"
|
# If the release already exists (tag already pushed), reuse it.
|
||||||
if [[ -z "$RELEASE_ID" ]]; then
|
if [[ -z "$RELEASE_ID" ]]; then
|
||||||
echo "error: could not create release; response:" >&2
|
RELEASE_ID="$(curl -s "${AUTH[@]}" "$API/tags/$TAG" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)"
|
||||||
echo "$RELEASE_JSON" >&2
|
fi
|
||||||
|
if [[ -z "$RELEASE_ID" ]]; then
|
||||||
|
echo "error: could not create or find release $TAG" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Release id: $RELEASE_ID"
|
echo "Release id: $RELEASE_ID"
|
||||||
|
|
||||||
for f in "$BS" "$BS_WIN" "$BS_MAC"; do
|
for f in "${all_artifacts[@]}"; do
|
||||||
|
if [[ ! -f "$f" ]]; then
|
||||||
|
echo "error: missing artifact $f — run ./tools/build_releases.sh first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo "==> Uploading $(basename "$f")"
|
echo "==> Uploading $(basename "$f")"
|
||||||
curl -s -X POST "$API/$RELEASE_ID/assets" \
|
curl -s -X POST "$API/$RELEASE_ID/assets" "${AUTH[@]}" -F "attachment=@$f" >/dev/null
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
|
||||||
-F "attachment=@$f" >/dev/null
|
|
||||||
done
|
done
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -0,0 +1,621 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate screen-design reference images for Step Line and compare captures.
|
||||||
|
|
||||||
|
This tool turns the screen descriptions that already live in `docs/`
|
||||||
|
(README screenshot captions, ADRs 0007-0010, PLAN.md, architecture.md, and the
|
||||||
|
HUD strings in `content/strings.en.json`) into reference "design" images, then
|
||||||
|
uses those images as the expected state to check the game's captured
|
||||||
|
screenshots against.
|
||||||
|
|
||||||
|
Two commands:
|
||||||
|
|
||||||
|
generate write one reference image per screen to docs/screens/designs/.
|
||||||
|
Two modes:
|
||||||
|
--ai realistic 3D game scenes and in-game screen designs via
|
||||||
|
the OpenRouter image API (default; requires
|
||||||
|
OPENROUTER_API_KEY, same as tools/asset_pipeline.py).
|
||||||
|
--draft deterministic wireframe sketches drawn with Pillow
|
||||||
|
(no network or API key; good for CI and layout checks).
|
||||||
|
|
||||||
|
compare compare docs/screenshots/*.png against the reference designs and
|
||||||
|
flag gross drift. Semantic composition is judged with coarse,
|
||||||
|
Pillow-only signals (aspect ratio, frame size, blank-frame
|
||||||
|
contrast, grayscale MAE, colour-histogram distance, edge density).
|
||||||
|
These catch "wrong screen / blank frame / wrong time of day"
|
||||||
|
regressions; they are NOT a pixel-exact or semantic match. A
|
||||||
|
vision-model pass is the documented next step (see PLAN.md).
|
||||||
|
|
||||||
|
Run from the repo root:
|
||||||
|
|
||||||
|
python3 tools/screen_designs.py generate
|
||||||
|
python3 tools/screen_designs.py generate --draft
|
||||||
|
python3 tools/screen_designs.py compare
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageOps, ImageStat
|
||||||
|
|
||||||
|
import generate_textures
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DESIGNS_DIR = ROOT / "docs" / "screens" / "designs"
|
||||||
|
SCREENSHOTS_DIR = ROOT / "docs" / "screenshots"
|
||||||
|
|
||||||
|
# Reference "design" dimensions — match the capture tool and the README gallery.
|
||||||
|
WIDTH, HEIGHT = 1280, 720
|
||||||
|
ASPECT = 16 / 9
|
||||||
|
|
||||||
|
# Default image model for screen mockups (validated role-aware model, per
|
||||||
|
# MEMORY.md; seedream rejects small sizes and ignores transparency, so we use
|
||||||
|
# the model asset_pipeline.py already trusts for non-alpha roles).
|
||||||
|
DEFAULT_MODEL = "openai/gpt-5-image-mini"
|
||||||
|
|
||||||
|
# Shared art direction, appended to every prompt so the generated frames read
|
||||||
|
# as one coherent game. "scene" entries are first-person 3D world views;
|
||||||
|
# "ui" entries are in-game panels overlaid on a softened scene.
|
||||||
|
SCENE_STYLE = (
|
||||||
|
"rendered in a modern 3D game engine, first-person perspective, cinematic "
|
||||||
|
"lighting, physically based materials, soft shadows, ambient occlusion, "
|
||||||
|
"subtle depth of field, photorealistic detail, no watermark or UI clutter"
|
||||||
|
)
|
||||||
|
UI_STYLE = (
|
||||||
|
"clean modern in-game user interface, dark translucent panels, readable "
|
||||||
|
"sans-serif labels, consistent game UI colour theme, crisp vector text"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Landscape source ratio requested from the API before cropping to 16:9.
|
||||||
|
# 3:2 is the validated non-square ratio for the default model (see MEMORY.md).
|
||||||
|
SOURCE_ASPECT = "3:2"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Screen catalogue. Each entry is grounded in a doc; `prompt` feeds --ai and
|
||||||
|
# `regions` feed --draft (normalised rects + a short label). `ref` names the
|
||||||
|
# captured screenshot, if one exists, so `compare` can pair them up.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SCREENS = [
|
||||||
|
{
|
||||||
|
"name": "venue_overview",
|
||||||
|
"kind": "scene",
|
||||||
|
"title": "Venue overview",
|
||||||
|
"source": "README screenshot 01",
|
||||||
|
"ref": "01-venue-overview.png",
|
||||||
|
"description": "First-person view from the guard post at the top of the "
|
||||||
|
"steps: Hollywood Boulevard entrance, wide concrete steps, "
|
||||||
|
"sidewalk, street lamps, planters, crosswalk, and building "
|
||||||
|
"signage/marquee under early-morning light.",
|
||||||
|
"prompt": (
|
||||||
|
"Realistic 3D first-person game scene: a security guard's view from "
|
||||||
|
"the top of wide concrete steps outside a grand Hollywood Boulevard "
|
||||||
|
"building entrance, early-morning golden light, sidewalk with street "
|
||||||
|
"lamps, planters, a crosswalk and distant pedestrians, detailed brick "
|
||||||
|
"facade with a striped awning and glowing marquee sign"),
|
||||||
|
"regions": [
|
||||||
|
(0.00, 0.55, 1.00, 1.00, "steps + sidewalk"),
|
||||||
|
(0.20, 0.08, 0.80, 0.60, "building facade / marquee"),
|
||||||
|
(0.05, 0.60, 0.20, 0.75, "street lamp"),
|
||||||
|
(0.78, 0.60, 0.95, 0.75, "street lamp"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "targeting_npc",
|
||||||
|
"kind": "scene",
|
||||||
|
"title": "Targeting an NPC",
|
||||||
|
"source": "README screenshot 02 · ADR-0008 cone focus + head ring",
|
||||||
|
"ref": "02-targeting-npc.png",
|
||||||
|
"description": "An approaching NPC is in the focus cone: a thin pale-gold "
|
||||||
|
"ring billboards around their head, a display name under "
|
||||||
|
"the ring, and the HUD shows a target line with the "
|
||||||
|
"interaction hint.",
|
||||||
|
"prompt": (
|
||||||
|
"Realistic 3D first-person game scene: a friendly pedestrian "
|
||||||
|
"standing on a city sidewalk, a thin pale-gold ring floating around "
|
||||||
|
"their head as a game selection marker, a small name label under the "
|
||||||
|
"ring, minimal HUD target text at the bottom, daytime"),
|
||||||
|
"regions": [
|
||||||
|
(0.35, 0.15, 0.65, 0.60, "NPC in focus"),
|
||||||
|
(0.44, 0.10, 0.56, 0.16, "head ring"),
|
||||||
|
(0.05, 0.85, 0.95, 0.95, "HUD: name + hint"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "access_inspection",
|
||||||
|
"kind": "ui",
|
||||||
|
"title": "Access inspection",
|
||||||
|
"source": "README screenshot 03 · ADR-0003 access verification",
|
||||||
|
"ref": "03-access-inspection.png",
|
||||||
|
"description": "An inspection panel shows the access artifact (wristband / "
|
||||||
|
"ID / ticket / credential / guest list) with its colour, "
|
||||||
|
"pattern, date, and event marking, plus allow and deny "
|
||||||
|
"prompts.",
|
||||||
|
"prompt": (
|
||||||
|
"Realistic 3D game screenshot with an in-game access inspection panel "
|
||||||
|
"overlaid: a centered card showing a wristband or ID credential with a "
|
||||||
|
"photo placeholder, colour band, date and event marking, and two "
|
||||||
|
"buttons labelled Allow and Deny, dark translucent panel over a "
|
||||||
|
"softly blurred city street background"),
|
||||||
|
"regions": [
|
||||||
|
(0.25, 0.10, 0.75, 0.90, "inspection panel"),
|
||||||
|
(0.30, 0.18, 0.70, 0.60, "artifact card"),
|
||||||
|
(0.30, 0.65, 0.48, 0.72, "allow"),
|
||||||
|
(0.52, 0.65, 0.70, 0.72, "deny"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "radio_report",
|
||||||
|
"kind": "ui",
|
||||||
|
"title": "Radio report",
|
||||||
|
"source": "README screenshot 04 · PLAN level 4 radio assistance",
|
||||||
|
"ref": "04-radio-report.png",
|
||||||
|
"description": "A radio dispatch panel with fields for location, "
|
||||||
|
"description, behaviour, whether weapons were seen, and "
|
||||||
|
"whether anyone is in immediate danger, plus a send action.",
|
||||||
|
"prompt": (
|
||||||
|
"Realistic 3D game screenshot with an in-game radio dispatch panel "
|
||||||
|
"overlaid: form fields for location, description, behaviour, a threat "
|
||||||
|
"assessment and whether anyone is in danger, and a Send button, dark "
|
||||||
|
"translucent panel over a softly blurred city street background"),
|
||||||
|
"regions": [
|
||||||
|
(0.30, 0.08, 0.70, 0.92, "radio panel"),
|
||||||
|
(0.34, 0.16, 0.66, 0.30, "location / description"),
|
||||||
|
(0.34, 0.34, 0.66, 0.56, "behaviour / weapons / danger"),
|
||||||
|
(0.42, 0.60, 0.58, 0.66, "send"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "late_night_avenue",
|
||||||
|
"kind": "scene",
|
||||||
|
"title": "Late night avenue",
|
||||||
|
"source": "README screenshot 05",
|
||||||
|
"ref": "05-late-night-avenue.png",
|
||||||
|
"description": "Night-time view down Hollywood Boulevard: street lamps "
|
||||||
|
"lit, marquee glowing, dark blue street, no flashlight.",
|
||||||
|
"prompt": (
|
||||||
|
"Realistic 3D first-person game scene at night: a dark Hollywood "
|
||||||
|
"Boulevard avenue receding into the distance, lit street lamps, a "
|
||||||
|
"glowing neon marquee sign, deep blue night sky, wet reflective "
|
||||||
|
"pavement, no flashlight"),
|
||||||
|
"regions": [
|
||||||
|
(0.00, 0.55, 1.00, 1.00, "dark avenue"),
|
||||||
|
(0.15, 0.25, 0.85, 0.55, "glowing marquee"),
|
||||||
|
(0.10, 0.45, 0.30, 0.70, "lit street lamp"),
|
||||||
|
(0.70, 0.45, 0.90, 0.70, "lit street lamp"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "night_flashlight",
|
||||||
|
"kind": "scene",
|
||||||
|
"title": "Night flashlight",
|
||||||
|
"source": "README screenshot 06",
|
||||||
|
"ref": "06-night-flashlight.png",
|
||||||
|
"description": "Night shift: the guard's flashlight beam cuts through the "
|
||||||
|
"dark toward the entrance, illuminating the steps and door.",
|
||||||
|
"prompt": (
|
||||||
|
"Realistic 3D first-person game scene at night: a flashlight beam "
|
||||||
|
"cutting through darkness toward a building entrance and steps, a "
|
||||||
|
"bright spotlight pool illuminating the door, dark surroundings and "
|
||||||
|
"deep shadows"),
|
||||||
|
"regions": [
|
||||||
|
(0.35, 0.30, 0.65, 0.80, "flashlight beam pool"),
|
||||||
|
(0.40, 0.20, 0.60, 0.35, "lit door"),
|
||||||
|
(0.00, 0.00, 1.00, 0.25, "dark sky"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "radial_menu",
|
||||||
|
"kind": "ui",
|
||||||
|
"title": "Radial action menu",
|
||||||
|
"source": "ADR-0009 · content/strings.en.json",
|
||||||
|
"ref": None,
|
||||||
|
"description": "A round doughnut menu centred on screen, 12 slices in "
|
||||||
|
"de-escalation order: Stop, Look, Point, No, Ask, Inspect, "
|
||||||
|
"Wand, Warn, Block, Reason, Recognize, Radio. The focused "
|
||||||
|
"person's display name sits in the centre.",
|
||||||
|
"prompt": (
|
||||||
|
"Realistic 3D game screenshot with an in-game radial action menu "
|
||||||
|
"overlaid: a round doughnut wheel centered on screen with twelve "
|
||||||
|
"labelled slices reading Stop, Look, Point, No, Ask, Inspect, Wand, "
|
||||||
|
"Warn, Block, Reason, Recognize, Radio, one slice highlighted, a name "
|
||||||
|
"in the center of the ring, over a softly blurred city street scene"),
|
||||||
|
"regions": [
|
||||||
|
(0.38, 0.12, 0.62, 0.88, "radial wheel (12 slices)"),
|
||||||
|
(0.46, 0.44, 0.54, 0.56, "name"),
|
||||||
|
(0.62, 0.12, 0.70, 0.20, "highlight"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "shift_review",
|
||||||
|
"kind": "ui",
|
||||||
|
"title": "Shift review",
|
||||||
|
"source": "PLAN reputation / shift review · architecture.md ShiftReview",
|
||||||
|
"ref": None,
|
||||||
|
"description": "End-of-shift performance review: access decisions, "
|
||||||
|
"escalation choices, incident list, battery and crowd "
|
||||||
|
"outcomes, and reputation/score feedback.",
|
||||||
|
"prompt": (
|
||||||
|
"Realistic 3D game end-of-shift review screen: a full-screen "
|
||||||
|
"performance summary panel listing incident decisions, escalation "
|
||||||
|
"choices, a reputation score, crowd reaction and battery use, with a "
|
||||||
|
"Continue button, dark themed game UI"),
|
||||||
|
"regions": [
|
||||||
|
(0.30, 0.06, 0.70, 0.94, "review panel"),
|
||||||
|
(0.36, 0.14, 0.64, 0.24, "score / reputation"),
|
||||||
|
(0.36, 0.28, 0.64, 0.70, "incident list"),
|
||||||
|
(0.42, 0.76, 0.58, 0.84, "continue"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "start_menu",
|
||||||
|
"kind": "ui",
|
||||||
|
"title": "Start menu",
|
||||||
|
"source": "MEMORY session shell (StartMenu, layer 20)",
|
||||||
|
"ref": None,
|
||||||
|
"description": "The boot screen: a venue-selection menu (Hollywood "
|
||||||
|
"Boulevard, The Joke Shop, TV Studio) before a shift.",
|
||||||
|
"prompt": (
|
||||||
|
"Realistic 3D game start menu screen: a game title and three venue "
|
||||||
|
"selection buttons labelled Hollywood Boulevard, The Joke Shop and TV "
|
||||||
|
"Studio, dark themed menu over a softly blurred city street backdrop"),
|
||||||
|
"regions": [
|
||||||
|
(0.25, 0.10, 0.75, 0.26, "title"),
|
||||||
|
(0.36, 0.36, 0.64, 0.44, "Hollywood Boulevard"),
|
||||||
|
(0.36, 0.48, 0.64, 0.56, "The Joke Shop"),
|
||||||
|
(0.36, 0.60, 0.64, 0.68, "TV Studio"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pause_overlay",
|
||||||
|
"kind": "ui",
|
||||||
|
"title": "Pause overlay",
|
||||||
|
"source": "MEMORY session shell (PauseOverlay, layer 18)",
|
||||||
|
"ref": None,
|
||||||
|
"description": "Pause menu: resume, restart, settings, and quit-to-menu.",
|
||||||
|
"prompt": (
|
||||||
|
"Realistic 3D game pause menu overlay: buttons labelled Resume, "
|
||||||
|
"Restart, Settings and Quit to Menu, dark translucent overlay over a "
|
||||||
|
"paused, softly blurred city street scene"),
|
||||||
|
"regions": [
|
||||||
|
(0.38, 0.26, 0.62, 0.74, "pause menu"),
|
||||||
|
(0.42, 0.32, 0.58, 0.38, "Resume"),
|
||||||
|
(0.42, 0.42, 0.58, 0.48, "Restart"),
|
||||||
|
(0.42, 0.52, 0.58, 0.58, "Settings"),
|
||||||
|
(0.42, 0.62, 0.58, 0.68, "Quit to Menu"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "settings_panel",
|
||||||
|
"kind": "ui",
|
||||||
|
"title": "Settings",
|
||||||
|
"source": "MEMORY session shell (SettingsPanel, layer 22)",
|
||||||
|
"ref": None,
|
||||||
|
"description": "Settings: look sensitivity and HUD font scale controls.",
|
||||||
|
"prompt": (
|
||||||
|
"Realistic 3D game settings screen: sliders labelled look sensitivity "
|
||||||
|
"and HUD font scale, dark themed settings panel"),
|
||||||
|
"regions": [
|
||||||
|
(0.30, 0.16, 0.70, 0.84, "settings panel"),
|
||||||
|
(0.36, 0.30, 0.64, 0.40, "look sensitivity"),
|
||||||
|
(0.36, 0.46, 0.64, 0.56, "HUD font scale"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "emergency_radio",
|
||||||
|
"kind": "ui",
|
||||||
|
"title": "Emergency radio",
|
||||||
|
"source": "PLAN level 5 · strings radio.emergency_dispatched",
|
||||||
|
"ref": None,
|
||||||
|
"description": "Hold-to-confirm emergency radio panel with a confirmation "
|
||||||
|
"prompt and a cooldown indicator.",
|
||||||
|
"prompt": (
|
||||||
|
"Realistic 3D game emergency radio hold-to-confirm panel: a large "
|
||||||
|
"warning prompt with a hold progress bar and a cooldown label, red "
|
||||||
|
"accent, dark translucent overlay over a softly blurred city street "
|
||||||
|
"scene"),
|
||||||
|
"regions": [
|
||||||
|
(0.30, 0.28, 0.70, 0.72, "emergency panel"),
|
||||||
|
(0.36, 0.36, 0.64, 0.46, "confirm prompt"),
|
||||||
|
(0.38, 0.52, 0.62, 0.60, "hold progress"),
|
||||||
|
(0.36, 0.64, 0.64, 0.68, "cooldown"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_path(value):
|
||||||
|
path = Path(value).expanduser()
|
||||||
|
return path if path.is_absolute() else ROOT / path
|
||||||
|
|
||||||
|
|
||||||
|
def _design_path(name):
|
||||||
|
return DESIGNS_DIR / f"{name}.png"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Draft (wireframe) rendering — deterministic, offline.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _draw_wireframe(screen, path):
|
||||||
|
image = Image.new("RGB", (WIDTH, HEIGHT), (24, 26, 38))
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
title_font = generate_textures.font(52)
|
||||||
|
body_font = generate_textures.font(24)
|
||||||
|
label_font = generate_textures.font(26)
|
||||||
|
|
||||||
|
draw.text((40, 28), screen["title"], font=title_font, fill=(240, 240, 245))
|
||||||
|
draw.text((40, 98), screen["source"], font=body_font, fill=(150, 155, 175))
|
||||||
|
# wrap the description into a small caption block
|
||||||
|
words = screen["description"].split()
|
||||||
|
lines, line = [], ""
|
||||||
|
for word in words:
|
||||||
|
if draw.textlength(line + " " + word, font=body_font) < WIDTH - 80:
|
||||||
|
line = (line + " " + word).strip()
|
||||||
|
else:
|
||||||
|
lines.append(line)
|
||||||
|
line = word
|
||||||
|
lines.append(line)
|
||||||
|
for index, text in enumerate(lines[:4]):
|
||||||
|
draw.text((40, 132 + index * 30), text,
|
||||||
|
font=body_font, fill=(120, 125, 145))
|
||||||
|
|
||||||
|
for region in screen["regions"]:
|
||||||
|
x0, y0, x1, y1, label = region
|
||||||
|
box = (int(x0 * WIDTH), int(y0 * HEIGHT),
|
||||||
|
int(x1 * WIDTH), int(y1 * HEIGHT))
|
||||||
|
draw.rounded_rectangle(box, radius=10,
|
||||||
|
outline=(120, 130, 170), width=2)
|
||||||
|
cx = (box[0] + box[2]) / 2
|
||||||
|
cy = (box[1] + box[3]) / 2
|
||||||
|
draw.text((cx - draw.textlength(label, font=label_font) / 2,
|
||||||
|
cy - 14), label, font=label_font, fill=(170, 180, 210))
|
||||||
|
|
||||||
|
image.save(path)
|
||||||
|
shown = path.relative_to(ROOT) if path.is_relative_to(ROOT) else path
|
||||||
|
print(f"wrote {shown} {image.size}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AI generation (reuses the OpenRouter backend from generate_textures).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _compose_prompt(screen):
|
||||||
|
"""Append the shared art direction to a screen's content prompt."""
|
||||||
|
style = UI_STYLE if screen.get("kind") == "ui" else SCENE_STYLE
|
||||||
|
return f"{screen['prompt']}. {style}. 16:9 widescreen composition."
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_ai(screen, model, request_size):
|
||||||
|
prompt = _compose_prompt(screen)
|
||||||
|
# Request a landscape source and crop to the 16:9 capture dimensions rather
|
||||||
|
# than a square that loses framing when centre-cropped.
|
||||||
|
image = generate_textures.generate_image(
|
||||||
|
prompt, model, request_size, transparent=False,
|
||||||
|
aspect_ratio=SOURCE_ASPECT)
|
||||||
|
image = ImageOps.fit(image, (WIDTH, HEIGHT),
|
||||||
|
method=Image.Resampling.LANCZOS)
|
||||||
|
if image.mode in ("RGBA", "LA", "P"):
|
||||||
|
image = image.convert("RGB")
|
||||||
|
path = _design_path(screen["name"])
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
image.save(path)
|
||||||
|
shown = path.relative_to(ROOT) if path.is_relative_to(ROOT) else path
|
||||||
|
print(f"wrote {shown} {image.size}")
|
||||||
|
|
||||||
|
|
||||||
|
def generate(args):
|
||||||
|
DESIGNS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
failed = []
|
||||||
|
for screen in SCREENS:
|
||||||
|
print(f"generating {screen['name']} ...")
|
||||||
|
try:
|
||||||
|
if args.draft:
|
||||||
|
_draw_wireframe(screen, _design_path(screen["name"]))
|
||||||
|
else:
|
||||||
|
_generate_ai(screen, args.model, args.request_size)
|
||||||
|
except SystemExit as exc:
|
||||||
|
failed.append(screen["name"])
|
||||||
|
print(f" FAILED {screen['name']}: {exc}", file=sys.stderr)
|
||||||
|
print("done")
|
||||||
|
if failed:
|
||||||
|
print(f"failed: {', '.join(failed)}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Comparison metrics (Pillow only; no numpy/scikit-image dependency).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _load_rgb(path):
|
||||||
|
with Image.open(path) as image:
|
||||||
|
image.load()
|
||||||
|
return image.convert("RGB")
|
||||||
|
|
||||||
|
|
||||||
|
def _grayscale(image):
|
||||||
|
return ImageOps.grayscale(image)
|
||||||
|
|
||||||
|
|
||||||
|
def _mae(left, right):
|
||||||
|
"""Mean absolute error between two same-size grayscale images, 0..255."""
|
||||||
|
left_gray = _grayscale(left).resize((256, 144))
|
||||||
|
right_gray = _grayscale(right).resize((256, 144))
|
||||||
|
diff = ImageChops.difference(left_gray, right_gray)
|
||||||
|
return ImageStat.Stat(diff).mean[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _histogram_distance(left, right):
|
||||||
|
"""Normalised L1 distance between 3x256-bin colour histograms, 0..1."""
|
||||||
|
left_hist = left.resize((256, 144)).histogram()
|
||||||
|
right_hist = right.resize((256, 144)).histogram()
|
||||||
|
total = sum(left_hist)
|
||||||
|
if total == 0:
|
||||||
|
return 1.0
|
||||||
|
distance = sum(abs(a - b) for a, b in zip(left_hist, right_hist))
|
||||||
|
return distance / (2 * total)
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_density(image):
|
||||||
|
"""Mean brightness of an edge-detected frame; higher = more detail."""
|
||||||
|
edges = _grayscale(image).filter(ImageFilter.FIND_EDGES)
|
||||||
|
return ImageStat.Stat(edges).mean[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _contrast(image):
|
||||||
|
"""Standard deviation of grayscale brightness; low = near-blank frame."""
|
||||||
|
return ImageStat.Stat(_grayscale(image)).stddev[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _aspect_ok(image, tolerance=0.12):
|
||||||
|
width, height = image.size
|
||||||
|
actual = width / height
|
||||||
|
return abs(actual - ASPECT) / ASPECT <= tolerance
|
||||||
|
|
||||||
|
|
||||||
|
def compare_screenshot(reference, capture):
|
||||||
|
"""Compare one capture against its reference design; return a dict report."""
|
||||||
|
ref = _load_rgb(reference)
|
||||||
|
cap = _load_rgb(capture)
|
||||||
|
width, height = cap.size
|
||||||
|
report = {
|
||||||
|
"capture": str(capture),
|
||||||
|
"reference": str(reference),
|
||||||
|
"size": [width, height],
|
||||||
|
}
|
||||||
|
|
||||||
|
errors, warnings = [], []
|
||||||
|
|
||||||
|
if width < WIDTH or height < HEIGHT:
|
||||||
|
errors.append(f"too small: {width}x{height}; minimum {WIDTH}x{HEIGHT}")
|
||||||
|
if not _aspect_ok(cap):
|
||||||
|
errors.append(f"wrong aspect ratio: {width / height:.3f}; "
|
||||||
|
f"expected about {ASPECT:.3f}")
|
||||||
|
|
||||||
|
contrast = _contrast(cap)
|
||||||
|
report["contrast"] = round(contrast, 1)
|
||||||
|
if contrast < 5.0:
|
||||||
|
errors.append(f"near-blank frame: contrast {contrast:.1f} below 5.0")
|
||||||
|
|
||||||
|
mae = _mae(ref, cap)
|
||||||
|
hist = _histogram_distance(ref, cap)
|
||||||
|
edge_ref = _edge_density(ref)
|
||||||
|
edge_cap = _edge_density(cap)
|
||||||
|
report["mae"] = round(mae, 1)
|
||||||
|
report["hist_distance"] = round(hist, 3)
|
||||||
|
report["edge_density_ref"] = round(edge_ref, 1)
|
||||||
|
report["edge_density_cap"] = round(edge_cap, 1)
|
||||||
|
|
||||||
|
# Coarse drift warnings (not hard failures: an AI mockup will never match a
|
||||||
|
# real render pixel-for-pixel, but a wildly different frame will still
|
||||||
|
# stand out on these signals).
|
||||||
|
if mae > 90:
|
||||||
|
warnings.append(
|
||||||
|
f"grayscale MAE {mae:.1f} is high (very different layout)")
|
||||||
|
if hist > 0.75:
|
||||||
|
warnings.append(f"colour histogram distance {hist:.3f} is high "
|
||||||
|
"(very different palette / time of day)")
|
||||||
|
edge_delta = abs(edge_cap - edge_ref)
|
||||||
|
report["edge_delta"] = round(edge_delta, 1)
|
||||||
|
if edge_delta > 18:
|
||||||
|
warnings.append(f"edge density differs by {edge_delta:.1f} "
|
||||||
|
"(very different amount of detail)")
|
||||||
|
|
||||||
|
report["quality"] = "bad" if errors else "good"
|
||||||
|
report["errors"] = errors
|
||||||
|
report["warnings"] = warnings
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def compare(args):
|
||||||
|
if not SCREENSHOTS_DIR.is_dir():
|
||||||
|
sys.exit(f"error: no screenshots directory at {SCREENSHOTS_DIR}; "
|
||||||
|
"run src/test/capture_screenshots.gd first")
|
||||||
|
captures = sorted(SCREENSHOTS_DIR.glob("*.png"))
|
||||||
|
if not captures:
|
||||||
|
sys.exit(f"error: no .png screenshots found in {SCREENSHOTS_DIR}")
|
||||||
|
|
||||||
|
refs = {screen["name"]: screen["ref"]
|
||||||
|
for screen in SCREENS if screen["ref"]}
|
||||||
|
results = []
|
||||||
|
for capture in captures:
|
||||||
|
name = capture.name
|
||||||
|
# find the screen whose ref matches this capture filename
|
||||||
|
design_name = next((n for n, ref in refs.items() if ref == name), None)
|
||||||
|
if design_name is None:
|
||||||
|
results.append({"capture": str(capture), "reference": None,
|
||||||
|
"errors": [f"no design reference for {name}"],
|
||||||
|
"warnings": [], "quality": "bad"})
|
||||||
|
continue
|
||||||
|
reference = _design_path(design_name)
|
||||||
|
if not reference.is_file():
|
||||||
|
results.append({"capture": str(capture),
|
||||||
|
"reference": str(reference),
|
||||||
|
"errors": [f"missing reference design "
|
||||||
|
f"({reference.name}); run generate first"],
|
||||||
|
"warnings": [], "quality": "bad"})
|
||||||
|
continue
|
||||||
|
results.append(compare_screenshot(reference, capture))
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
import json
|
||||||
|
print(json.dumps(results, indent=2))
|
||||||
|
else:
|
||||||
|
good_count = sum(r["quality"] == "good" for r in results)
|
||||||
|
for result in results:
|
||||||
|
ref = (Path(result["reference"]).name
|
||||||
|
if result.get("reference") else "-")
|
||||||
|
size = ("x".join(map(str, result["size"]))
|
||||||
|
if result.get("size") else "unreadable")
|
||||||
|
print(f"{result['quality'].upper():4} {result['capture']}")
|
||||||
|
print(f" ref={ref} size={size} "
|
||||||
|
f"mae={result.get('mae', '-')} "
|
||||||
|
f"hist={result.get('hist_distance', '-')}")
|
||||||
|
for message in result["errors"] + result["warnings"]:
|
||||||
|
print(f" - {message}")
|
||||||
|
print(f"Summary: {good_count} good, {len(results) - good_count} bad")
|
||||||
|
|
||||||
|
return 1 if any(r["quality"] == "bad" for r in results) else 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_parser():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Generate and compare Step Line screen-design references.")
|
||||||
|
commands = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
gen = commands.add_parser(
|
||||||
|
"generate", help="write reference design images to docs/screens/designs/")
|
||||||
|
mode = gen.add_mutually_exclusive_group()
|
||||||
|
mode.add_argument("--draft", action="store_true",
|
||||||
|
help="draw deterministic wireframes (no API/network)")
|
||||||
|
mode.add_argument("--ai", action="store_true",
|
||||||
|
help="generate mockups via OpenRouter (default)")
|
||||||
|
gen.add_argument("--model", default=DEFAULT_MODEL)
|
||||||
|
gen.add_argument("--request-size", type=int,
|
||||||
|
default=generate_textures.GEN_SIZE)
|
||||||
|
gen.set_defaults(handler=generate, draft=False)
|
||||||
|
|
||||||
|
cmp = commands.add_parser(
|
||||||
|
"compare", help="compare docs/screenshots/*.png against the designs")
|
||||||
|
cmp.add_argument("--json", action="store_true")
|
||||||
|
cmp.set_defaults(handler=compare)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
try:
|
||||||
|
result = args.handler(args)
|
||||||
|
return result if isinstance(result, int) else 0
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
import asset_pipeline
|
||||||
|
|
||||||
|
|
||||||
|
class AssetPipelineTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp_dir.name)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_crop_uses_center_and_exact_dimensions(self):
|
||||||
|
source = self.root / "source.png"
|
||||||
|
output = self.root / "crop.png"
|
||||||
|
Image.new("RGB", (80, 60), (20, 40, 60)).save(source)
|
||||||
|
|
||||||
|
asset_pipeline.crop_image(source, output, 32, 24)
|
||||||
|
|
||||||
|
with Image.open(output) as image:
|
||||||
|
self.assertEqual(image.size, (32, 24))
|
||||||
|
|
||||||
|
def test_resize_contain_preserves_aspect_and_exact_canvas(self):
|
||||||
|
source = self.root / "source.png"
|
||||||
|
output = self.root / "resized.png"
|
||||||
|
Image.new("RGB", (80, 40), (20, 40, 60)).save(source)
|
||||||
|
|
||||||
|
asset_pipeline.resize_image(source, output, 64, 64, "contain")
|
||||||
|
|
||||||
|
with Image.open(output) as image:
|
||||||
|
self.assertEqual(image.size, (64, 64))
|
||||||
|
self.assertEqual(image.getpixel((32, 0)), (0, 0, 0, 0))
|
||||||
|
|
||||||
|
def test_texture_audit_accepts_valid_tile_and_rejects_bad_seams(self):
|
||||||
|
good = self.root / "good.png"
|
||||||
|
bad = self.root / "bad.png"
|
||||||
|
Image.new("RGB", (512, 512), (80, 80, 80)).save(good)
|
||||||
|
mismatch = Image.new("RGB", (512, 512), (80, 80, 80))
|
||||||
|
for y in range(512):
|
||||||
|
mismatch.putpixel((0, y), (255, 0, 0))
|
||||||
|
mismatch.save(bad)
|
||||||
|
|
||||||
|
self.assertEqual(asset_pipeline.audit_image(
|
||||||
|
good, "tile")["quality"], "good")
|
||||||
|
self.assertEqual(asset_pipeline.audit_image(
|
||||||
|
bad, "tile")["quality"], "bad")
|
||||||
|
|
||||||
|
def test_seamless_transform_makes_tile_edges_continuous(self):
|
||||||
|
image_path = self.root / "tile.png"
|
||||||
|
output = self.root / "seamless.png"
|
||||||
|
image = Image.new("RGB", (512, 512))
|
||||||
|
for x in range(512):
|
||||||
|
for y in range(512):
|
||||||
|
image.putpixel((x, y), (x // 2, y // 2, (x + y) // 4))
|
||||||
|
image.save(image_path)
|
||||||
|
|
||||||
|
asset_pipeline.seamless_image(image_path, output)
|
||||||
|
|
||||||
|
self.assertEqual(asset_pipeline.audit_image(
|
||||||
|
output, "tile")["quality"], "good")
|
||||||
|
|
||||||
|
def test_tile_generation_applies_seamless_transform(self):
|
||||||
|
output = self.root / "generated_tile.png"
|
||||||
|
args = type("Args", (), {
|
||||||
|
"prompt": "brick", "role": "tile", "output": str(output),
|
||||||
|
"width": None, "height": None, "transparent": False,
|
||||||
|
"model": "test-model", "request_size": 1024,
|
||||||
|
})()
|
||||||
|
generated = Image.new("RGB", (512, 512), (80, 80, 80))
|
||||||
|
for y in range(512):
|
||||||
|
generated.putpixel((0, y), (255, 0, 0))
|
||||||
|
|
||||||
|
with patch.object(asset_pipeline.generate_textures, "generate_image",
|
||||||
|
return_value=generated):
|
||||||
|
asset_pipeline._generate(args)
|
||||||
|
|
||||||
|
self.assertEqual(asset_pipeline.audit_image(
|
||||||
|
output, "tile")["quality"], "good")
|
||||||
|
|
||||||
|
def test_background_audit_rejects_small_image(self):
|
||||||
|
image_path = self.root / "small.png"
|
||||||
|
Image.new("RGB", (640, 360)).save(image_path)
|
||||||
|
|
||||||
|
result = asset_pipeline.audit_image(image_path, "background")
|
||||||
|
|
||||||
|
self.assertEqual(result["quality"], "bad")
|
||||||
|
self.assertTrue(
|
||||||
|
any("too small" in error for error in result["errors"]))
|
||||||
|
|
||||||
|
def test_grayscale_vignette_is_valid_without_alpha(self):
|
||||||
|
image_path = self.root / "vignette.png"
|
||||||
|
image = Image.new("L", (512, 512), 0)
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
for radius in range(255, -1, -1):
|
||||||
|
value = int(255 * (1 - radius / 255))
|
||||||
|
draw.ellipse((256 - radius, 256 - radius,
|
||||||
|
256 + radius, 256 + radius), fill=value)
|
||||||
|
image.save(image_path)
|
||||||
|
|
||||||
|
self.assertEqual(asset_pipeline.audit_image(
|
||||||
|
image_path, "overlay")["quality"], "good")
|
||||||
|
|
||||||
|
def test_overlay_audit_rejects_blank_transparent_image(self):
|
||||||
|
image_path = self.root / "blank_overlay.png"
|
||||||
|
Image.new("RGBA", (512, 512), (0, 0, 0, 0)).save(image_path)
|
||||||
|
|
||||||
|
result = asset_pipeline.audit_image(image_path, "overlay")
|
||||||
|
|
||||||
|
self.assertEqual(result["quality"], "bad")
|
||||||
|
self.assertTrue(any("near-blank frame" in error
|
||||||
|
for error in result["errors"]))
|
||||||
|
|
||||||
|
def test_icon_audit_rejects_opaque_rgba_images(self):
|
||||||
|
image_path = self.root / "opaque_icon.png"
|
||||||
|
Image.new("RGBA", (64, 64), (255, 255, 255, 255)).save(image_path)
|
||||||
|
|
||||||
|
result = asset_pipeline.audit_image(image_path, "icon")
|
||||||
|
|
||||||
|
self.assertEqual(result["quality"], "bad")
|
||||||
|
self.assertTrue(any("transparency required" in error
|
||||||
|
for error in result["errors"]))
|
||||||
|
|
||||||
|
def test_backdrop_audit_requires_transparency(self):
|
||||||
|
image_path = self.root / "skyline.png"
|
||||||
|
Image.new("RGB", (1024, 256), (20, 30, 40)).save(image_path)
|
||||||
|
|
||||||
|
result = asset_pipeline.audit_image(image_path, "backdrop")
|
||||||
|
|
||||||
|
self.assertEqual(result["quality"], "bad")
|
||||||
|
self.assertTrue(any("transparency required" in error
|
||||||
|
for error in result["errors"]))
|
||||||
|
|
||||||
|
def test_generation_reuses_existing_backend_and_targets_role_dimensions(self):
|
||||||
|
output = self.root / "background.png"
|
||||||
|
args = type("Args", (), {
|
||||||
|
"prompt": "Hollywood at night", "role": "background", "output": str(output),
|
||||||
|
"width": 1920, "height": 1080, "transparent": None,
|
||||||
|
"model": "test-model", "request_size": 2048,
|
||||||
|
})()
|
||||||
|
generated = Image.new("RGB", (256, 256), (20, 40, 60))
|
||||||
|
|
||||||
|
with patch.object(asset_pipeline.generate_textures, "generate_image",
|
||||||
|
return_value=generated) as backend:
|
||||||
|
asset_pipeline._generate(args)
|
||||||
|
|
||||||
|
self.assertIn("Hollywood at night", backend.call_args.args[0])
|
||||||
|
self.assertIn("wide 16:9", backend.call_args.args[0])
|
||||||
|
self.assertEqual(backend.call_args.kwargs["aspect_ratio"], "3:2")
|
||||||
|
with Image.open(output) as image:
|
||||||
|
self.assertEqual(image.size, (1920, 1080))
|
||||||
|
|
||||||
|
def test_generation_can_recolor_transparent_glyph(self):
|
||||||
|
output = self.root / "icon.png"
|
||||||
|
args = type("Args", (), {
|
||||||
|
"prompt": "flashlight", "role": "icon", "output": str(output),
|
||||||
|
"width": None, "height": None, "transparent": True,
|
||||||
|
"model": "test-model", "request_size": 1024,
|
||||||
|
"foreground": (255, 255, 255, 255),
|
||||||
|
})()
|
||||||
|
generated = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
|
||||||
|
generated.putpixel((32, 32), (20, 20, 20, 255))
|
||||||
|
|
||||||
|
with patch.object(asset_pipeline.generate_textures, "generate_image",
|
||||||
|
return_value=generated):
|
||||||
|
asset_pipeline._generate(args)
|
||||||
|
|
||||||
|
with Image.open(output) as image:
|
||||||
|
self.assertEqual(image.getpixel((32, 32)), (255, 255, 255, 255))
|
||||||
|
self.assertEqual(image.getpixel((0, 0))[3], 0)
|
||||||
|
|
||||||
|
def test_screenshot_audit_accepts_game_frame_and_rejects_blank_capture(self):
|
||||||
|
valid = self.root / "frame.png"
|
||||||
|
blank = self.root / "blank.png"
|
||||||
|
frame = Image.new("RGB", (1280, 720), (0, 0, 0))
|
||||||
|
ImageDraw.Draw(frame).rectangle(
|
||||||
|
(640, 0, 1279, 719), fill=(255, 255, 255))
|
||||||
|
frame.save(valid)
|
||||||
|
Image.new("RGB", (1280, 720), (0, 0, 0)).save(blank)
|
||||||
|
|
||||||
|
self.assertEqual(asset_pipeline.audit_image(
|
||||||
|
valid, "screenshot")["quality"], "good")
|
||||||
|
self.assertEqual(asset_pipeline.audit_image(
|
||||||
|
blank, "screenshot")["quality"], "bad")
|
||||||
|
|
||||||
|
def test_role_inference_uses_game_asset_paths(self):
|
||||||
|
self.assertEqual(asset_pipeline.infer_role(
|
||||||
|
Path("assets/textures/icons/wand.png")), "icon")
|
||||||
|
self.assertEqual(asset_pipeline.infer_role(
|
||||||
|
Path("assets/textures/cards/ticket.png")), "card")
|
||||||
|
self.assertEqual(asset_pipeline.infer_role(
|
||||||
|
Path("assets/textures/skyline.png")), "backdrop")
|
||||||
|
|
||||||
|
def test_role_generator_defaults_to_validated_image_model(self):
|
||||||
|
args = asset_pipeline.build_parser().parse_args([
|
||||||
|
"generate", "--prompt", "test", "--role", "tile", "--output", "tile.png"])
|
||||||
|
|
||||||
|
self.assertEqual(args.model, asset_pipeline.DEFAULT_ROLE_IMAGE_MODEL)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
import screen_designs
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenDesignsTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp_dir.name)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_catalogue_is_well_formed(self):
|
||||||
|
names = [screen["name"] for screen in screen_designs.SCREENS]
|
||||||
|
self.assertEqual(len(names), len(set(names)),
|
||||||
|
"screen names must be unique")
|
||||||
|
refs = [screen["ref"] for screen in screen_designs.SCREENS
|
||||||
|
if screen["ref"]]
|
||||||
|
self.assertEqual(len(refs), len(set(refs)),
|
||||||
|
"screenshot refs must be unique")
|
||||||
|
for screen in screen_designs.SCREENS:
|
||||||
|
self.assertTrue(screen["prompt"].strip())
|
||||||
|
self.assertIn(screen["kind"], ("scene", "ui"))
|
||||||
|
self.assertTrue(screen["regions"])
|
||||||
|
for region in screen["regions"]:
|
||||||
|
x0, y0, x1, y1, label = region
|
||||||
|
self.assertLess(x0, x1)
|
||||||
|
self.assertLess(y0, y1)
|
||||||
|
self.assertTrue(0 <= x0 <= 1 and 0 <= x1 <= 1)
|
||||||
|
self.assertTrue(0 <= y0 <= 1 and 0 <= y1 <= 1)
|
||||||
|
self.assertTrue(label.strip())
|
||||||
|
|
||||||
|
def test_draft_wireframe_matches_reference_dimensions(self):
|
||||||
|
output = self.root / "draft.png"
|
||||||
|
screen = screen_designs.SCREENS[0]
|
||||||
|
screen_designs._draw_wireframe(screen, output)
|
||||||
|
with Image.open(output) as image:
|
||||||
|
self.assertEqual(image.size,
|
||||||
|
(screen_designs.WIDTH, screen_designs.HEIGHT))
|
||||||
|
|
||||||
|
def test_compose_prompt_appends_kind_specific_style(self):
|
||||||
|
scene = {"kind": "scene", "prompt": "a street"}
|
||||||
|
ui = {"kind": "ui", "prompt": "a panel"}
|
||||||
|
self.assertIn(screen_designs.SCENE_STYLE,
|
||||||
|
screen_designs._compose_prompt(scene))
|
||||||
|
self.assertIn(screen_designs.UI_STYLE,
|
||||||
|
screen_designs._compose_prompt(ui))
|
||||||
|
self.assertIn("16:9", screen_designs._compose_prompt(scene))
|
||||||
|
|
||||||
|
def test_mae_is_zero_for_identical_images(self):
|
||||||
|
path = self.root / "identical.png"
|
||||||
|
Image.new("RGB", (1280, 720), (40, 80, 120)).save(path)
|
||||||
|
left = screen_designs._load_rgb(path)
|
||||||
|
right = screen_designs._load_rgb(path)
|
||||||
|
self.assertAlmostEqual(screen_designs._mae(left, right), 0.0, places=1)
|
||||||
|
|
||||||
|
def test_histogram_distance_is_zero_for_identical_images(self):
|
||||||
|
path = self.root / "identical.png"
|
||||||
|
Image.new("RGB", (1280, 720), (40, 80, 120)).save(path)
|
||||||
|
left = screen_designs._load_rgb(path)
|
||||||
|
right = screen_designs._load_rgb(path)
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
screen_designs._histogram_distance(left, right), 0.0, places=3)
|
||||||
|
|
||||||
|
def test_blank_frame_is_flagged_as_bad(self):
|
||||||
|
reference = self.root / "ref.png"
|
||||||
|
capture = self.root / "cap.png"
|
||||||
|
Image.new("RGB", (1280, 720), (200, 200, 200)).save(reference)
|
||||||
|
Image.new("RGB", (1280, 720), (0, 0, 0)).save(capture)
|
||||||
|
report = screen_designs.compare_screenshot(reference, capture)
|
||||||
|
self.assertEqual(report["quality"], "bad")
|
||||||
|
self.assertTrue(any("near-blank" in e for e in report["errors"]))
|
||||||
|
|
||||||
|
def test_wrong_aspect_is_flagged_as_bad(self):
|
||||||
|
reference = self.root / "ref.png"
|
||||||
|
capture = self.root / "cap.png"
|
||||||
|
Image.new("RGB", (1280, 720), (100, 120, 140)).save(reference)
|
||||||
|
Image.new("RGB", (640, 640), (100, 120, 140)).save(capture)
|
||||||
|
report = screen_designs.compare_screenshot(reference, capture)
|
||||||
|
self.assertEqual(report["quality"], "bad")
|
||||||
|
self.assertTrue(any("aspect" in e for e in report["errors"]))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||