Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb40a98537 | ||
|
|
a3e90accdd | ||
|
|
3025ba1cef | ||
|
|
a5e7b3c013 | ||
|
|
f2863abeac | ||
|
|
7f92b8861d | ||
|
|
afa1d6beec | ||
|
|
f763cff744 | ||
|
|
72b9378e69 | ||
|
|
74f14014b2 | ||
|
|
a2a0ded527 | ||
|
|
922a990c7a | ||
|
|
f0cec192a7 | ||
|
|
2dba562c52 | ||
|
|
afd086ba11 | ||
|
|
b4d189c325 | ||
|
|
6684d36739 | ||
|
|
3f4ac7e9aa | ||
|
|
294ac17333 | ||
|
|
e2c2a0bf4c | ||
|
|
1b976bf351 | ||
|
|
921bab796e | ||
|
|
71b0b53705 | ||
|
|
f5ad1642b5 |
No files matched your search
@@ -113,6 +113,9 @@ jobs:
|
|||||||
- name: Publish Gitea release (tags only)
|
- name: Publish Gitea release (tags only)
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
env:
|
env:
|
||||||
|
# An explicit repo secret wins. Without one, publish_release.sh reuses
|
||||||
|
# the local git extraheader that actions/checkout configures for the job
|
||||||
|
# (the runner's own credentials), so tag builds still publish.
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
VERSION: ${{ github.ref_name }}
|
VERSION: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ Completed in the working tree (uncommitted with the control pass). Headless **74
|
|||||||
to the entrance.
|
to the entrance.
|
||||||
- [x] Wand shortcut is `C`, not `W`. Tests: `src/test/tests/test_access_loadout.gd`.
|
- [x] Wand shortcut is `C`, not `W`. Tests: `src/test/tests/test_access_loadout.gd`.
|
||||||
|
|
||||||
## Player control, focus ring, and radial action menu
|
## Player control, focus circle, and radial action menu
|
||||||
|
|
||||||
Completed 2026-10-31 with ADR-0007–0010. Headless **77/77**, plus a windowed smoke
|
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.
|
(`src/test/smoke_focus_radial.gd`) that opens the wheel and saves screenshots.
|
||||||
@@ -334,13 +334,13 @@ Completed 2026-10-31 with ADR-0007–0010. Headless **77/77**, plus a windowed s
|
|||||||
- [x] `cancel` (Esc / RMB) closes the top modal first; with nothing open, Esc toggles mouse
|
- [x] `cancel` (Esc / RMB) closes the top modal first; with nothing open, Esc toggles mouse
|
||||||
capture and RMB does nothing.
|
capture and RMB does nothing.
|
||||||
|
|
||||||
### Focus and head ring
|
### Focus and focus circle
|
||||||
|
|
||||||
- [x] Focus cone in `src/game/npc_focus.gd` (~10 m, ~10°). Closest-to-forward wins; occlusion
|
- [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.
|
ray skips building hits. `Tab` still cycles 12 m.
|
||||||
- [x] 8-frame miss grace so walk-bob does not drop focus.
|
- [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`
|
- [x] Billboarded blue circle in front of the face on `Rig/Head`; brighter while the radial is
|
||||||
`display_name`. HUD target line uses the display name, not the node name.
|
open; `Label3D` `display_name`. HUD target line uses the display name, not the node name.
|
||||||
- [x] Screen-center crosshair.
|
- [x] Screen-center crosshair.
|
||||||
|
|
||||||
### Radial HUD
|
### Radial HUD
|
||||||
@@ -364,7 +364,7 @@ Completed 2026-10-31 with ADR-0007–0010. Headless **77/77**, plus a windowed s
|
|||||||
|
|
||||||
- [x] Named input map: `action_menu` (Space/LMB), `confirm` (Space/Enter), `cancel`
|
- [x] Named input map: `action_menu` (Space/LMB), `confirm` (Space/Enter), `cancel`
|
||||||
(Esc/RMB), `inspect` (E), `wand` (C). `GameManager` uses `is_action_pressed`.
|
(Esc/RMB), `inspect` (E), `wand` (C). `GameManager` uses `is_action_pressed`.
|
||||||
- [x] Windowed smoke: focus ring + name, wheel open/step/confirm/cancel
|
- [x] Windowed smoke: focus circle + name, wheel open/step/confirm/cancel
|
||||||
(`src/test/smoke_focus_radial.gd`, exits non-zero on failure).
|
(`src/test/smoke_focus_radial.gd`, exits non-zero on failure).
|
||||||
- [x] README controls table, smoke checklist, test count, implemented/not-yet lists.
|
- [x] README controls table, smoke checklist, test count, implemented/not-yet lists.
|
||||||
|
|
||||||
@@ -405,7 +405,6 @@ screenshots.
|
|||||||
|
|
||||||
Completed 2026-10-31 (working tree). Headless **88/88**; `src/test/smoke_session_shell.gd` and the windowed focus/radial smoke both pass.
|
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
|
- [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
|
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.
|
the backdrop only with a transparent asset or a keyed shader.
|
||||||
@@ -414,7 +413,7 @@ Completed 2026-10-31 (working tree). Headless **88/88**; `src/test/smoke_session
|
|||||||
- [x] Steps and the landing are visual-only (no collider). The guard walks through them; add
|
- [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.
|
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] Marquee text (`STEP LINE`) is tiny from the sidewalk. Scale the Label3D up.
|
||||||
- [x] Focus ring and name tag are small at 2–3 m. Consider a larger torus / font after a
|
- [x] Focus circle and name tag are small at 2–3 m. Consider a larger circle / font after a
|
||||||
real playtest.
|
real playtest.
|
||||||
- [x] NPC crowd cap (`MAX_LIVE_NPCS`) silently drops routine traffic at 16. Add a short
|
- [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.
|
despawn/lifetime for ordinary guests so the entrance does not fill up with standers.
|
||||||
@@ -423,7 +422,6 @@ Completed 2026-10-31 (working tree). Headless **88/88**; `src/test/smoke_session
|
|||||||
|
|
||||||
Completed 2026-10-31 (working tree). Headless **88/88**; `src/test/smoke_session_shell.gd` and the windowed focus/radial smoke both pass.
|
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.
|
The game boots into a live 900 s shift. `Save` declares `settings.json` and never uses it.
|
||||||
`V` cycles venues by restarting the shift.
|
`V` cycles venues by restarting the shift.
|
||||||
|
|
||||||
@@ -439,9 +437,300 @@ The game boots into a live 900 s shift. `Save` declares `settings.json` and neve
|
|||||||
|
|
||||||
Completed 2026-10-31 (working tree).
|
Completed 2026-10-31 (working tree).
|
||||||
|
|
||||||
|
|
||||||
- [x] README controls table, smoke checklist, test count, and implemented/not-yet lists
|
- [x] README controls table, smoke checklist, test count, and implemented/not-yet lists
|
||||||
refreshed for the control pass (77 tests).
|
refreshed for the control pass (77 tests).
|
||||||
- [x] `PLAN.md` Tech Stack / Planned Commands still say GUT and GitHub Actions; the project
|
- [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`.
|
uses `src/test/test_runner.gd` and `.gitea/workflows/build.yml`.
|
||||||
- [x] MEMORY: door_staff_lines log through `GameManager`; 300 s default removed.
|
- [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.
|
||||||
|
|
||||||
|
## Scenario content still missing vs PLAN lists
|
||||||
|
|
||||||
|
Completed 2026-10-31 (working tree). Headless **149/149**; `src/test/smoke_scenario_content.gd` spawns the refusal event and the celebrity arrival through the real signal path.
|
||||||
|
|
||||||
|
- [x] Joke Shop: guest on the reservation list without ID; borrowed/mismatched ID; guest
|
||||||
|
refuses wand screening; late arrival during a performance.
|
||||||
|
- [x] Studio: filming-boundary repeat (influencer ignores a posted line); celebrity-arrival
|
||||||
|
crowd spike (more rubberneckers, not only one `arrive` spawn).
|
||||||
|
- [x] Building: "activate body camera" and "request reason" once those verbs exist, used in
|
||||||
|
at least one authored event.
|
||||||
|
|
||||||
|
## Distribution follow-up (host config)
|
||||||
|
|
||||||
|
Completed 2026-10-31 (working tree). Headless **153/153**.
|
||||||
|
|
||||||
|
Verified via API+host credentials (2026-10-31):
|
||||||
|
|
||||||
|
- [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 +
|
||||||
|
AppImage).
|
||||||
|
- [x] **Host setting applied**: `[service] REQUIRE_SIGNIN_VIEW = false` in `/etc/gitea/app.ini`
|
||||||
|
(backup written alongside), Gitea restarted. Verified anonymously: repo page 200, API 200,
|
||||||
|
and a release asset downloads with the full 50.8 MB AppImage.
|
||||||
|
|
||||||
|
## Release pipeline follow-up
|
||||||
|
|
||||||
|
Completed 2026-10-31 (working tree). Headless **153/153**.
|
||||||
|
|
||||||
|
- [x] Fix `tools/publish_release.sh`: the `~/.git-credentials` branch never set `AUTH`,
|
||||||
|
so every tag build since v0.2.2 failed to publish.
|
||||||
|
- [x] Add a `github.token` fallback in `.gitea/workflows/build.yml` (the repo has no
|
||||||
|
`GITEA_TOKEN` secret).
|
||||||
|
- [x] Fall back to the checkout's git extraheader when no token/credentials exist (the
|
||||||
|
Gitea runner does not expose `github.token`).
|
||||||
|
- [x] Uploads now fail loudly and the release is verified to carry every artifact
|
||||||
|
(a silent HTTP 4xx used to leave an empty release with a green job).
|
||||||
|
- [x] Host disk expanded to 16G; v0.2.0 and v0.2.9 releases restored from the local artifacts.
|
||||||
|
- [x] Host maintenance via `root@192.168.88.30`: apt cache cleaned and journald capped
|
||||||
|
(`SystemMaxUse=300M` in `/etc/systemd/journald.conf.d/99-size.conf`); ~7.6G free.
|
||||||
|
- [x] Verified end to end: tagging v0.2.12 produced a release with all five artifacts
|
||||||
|
(AppImage, Linux, macOS, NSIS installer, Windows) from CI, with no repo secret.
|
||||||
|
- [x] Backfill decision: v0.2.4–v0.2.8 stay superseded; their tags and source remain, and the
|
||||||
|
release page points at the current build. No rebuilds planned.
|
||||||
|
|
||||||
|
## Open Polish
|
||||||
|
|
||||||
|
Completed 2026-10-31 (working tree). Headless **153/153**.
|
||||||
|
|
||||||
|
- [x] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
|
||||||
|
|
||||||
|
## Screen design references (QA tooling)
|
||||||
|
|
||||||
|
Completed 2026-10-31 (working tree). 30 Python tooling tests pass; the live judge run classed 3 captures as match and 3 as review (concept mockups are more stylised), with no confident mismatch.
|
||||||
|
|
||||||
|
- [x] `tools/screen_designs.py generate` writes one reference design per screen
|
||||||
|
(from the `docs/` descriptions) to `docs/screens/designs/`, with an offline
|
||||||
|
`--draft` wireframe mode and an OpenRouter `--ai` mockup mode.
|
||||||
|
- [x] `tools/screen_designs.py compare` checks `docs/screenshots/*.png` against the
|
||||||
|
designs (aspect ratio, size, blank-frame, and layout/palette/detail drift).
|
||||||
|
- [x] Vision-model judge for semantic screenshot comparison: `judge` sends the
|
||||||
|
capture + reference design to a vision model, classes verdicts as
|
||||||
|
match/review/diff with a confidence threshold, supports `--dry-run`, `--json`,
|
||||||
|
`--only`, and `--model`, and reads the repo `.env` for the API key.
|
||||||
|
|
||||||
|
## Tooling (when an editor workflow exists)
|
||||||
|
|
||||||
|
Completed 2026-10-31 (working tree). Headless **157/157**; the exported `.tres` venues round-trip through the resource loader.
|
||||||
|
|
||||||
|
- [x] Author venues as `.tres` resources: `tools/export_venues.gd` writes
|
||||||
|
`content/venues/<id>.tres` from the factory, and a test loads them back to check
|
||||||
|
nothing is lost (events, profiles, policy, phases, door lines, locations).
|
||||||
|
- [x] GUT decision: **not adopted.** The custom headless runner (`src/test/test_runner.gd`)
|
||||||
|
already covers 157 assertions without an editor plugin, runs in CI, and keeps the
|
||||||
|
project dependency-free; adding GUT would duplicate coverage and require the editor.
|
||||||
|
|
||||||
|
## In-Game Scene Examples
|
||||||
|
|
||||||
|
Completed 2026-10-31 (v0.3.0 working tree). `python3 tools/screen_designs.py list` reports
|
||||||
|
19 screens (8 scene, 11 UI); the Python tooling tests pass.
|
||||||
|
|
||||||
|
- [x] Extend the `tools/screen_designs.py` catalogue with the screens that exist in code but
|
||||||
|
had no reference: wand screening, Joke Shop door queue, TV Studio celebrity arrival,
|
||||||
|
crowd panic, responder arrival, body-camera timeline, escalation HUD.
|
||||||
|
- [x] Add a `list` subcommand so the catalogue is inspectable without generating images.
|
||||||
|
- [x] Cover the new entries with `tools/test_screen_designs.py` assertions.
|
||||||
|
- [x] Regenerate the deterministic draft references under `docs/screens/designs/`.
|
||||||
|
(`generate` now keeps existing references unless `--force` is passed, so a draft run
|
||||||
|
cannot replace an AI mockup; `--only` selects a subset.)
|
||||||
|
|
||||||
|
## Rendering Quality
|
||||||
|
|
||||||
|
Completed 2026-10-31 (v0.3.0 working tree). Screenshots regenerated; `screen_designs.py
|
||||||
|
compare` reports 6 good / 0 bad.
|
||||||
|
|
||||||
|
- [x] Add a shared surface-material module (`src/game/materials.gd`) with
|
||||||
|
roughness/metallic/uv-scale profiles per surface and a single environment tune-up
|
||||||
|
(tonemap, glow, fog, shadows).
|
||||||
|
- [x] Rebuild `WorldBuilder` surfaces with the material profiles and add street detail:
|
||||||
|
window bands, door trim, bollards, bench, bin, curb, planter foliage.
|
||||||
|
- [x] Per-phase environment values (fog, sky energy, glow) so night reads darker and the
|
||||||
|
marquee glows.
|
||||||
|
- [x] Improve NPC silhouette and materials: per-role clothing palette, caps/hoods/backpacks,
|
||||||
|
sleeves and hands, walking leg swing.
|
||||||
|
- [x] Cover materials/environment/avatar helpers with headless tests
|
||||||
|
(`test_materials.gd`, `test_target_cycle.gd`).
|
||||||
|
|
||||||
|
## AI 3D Model Tooling
|
||||||
|
|
||||||
|
Completed 2026-10-31 (v0.3.0 working tree). `model_pipeline.py build` + `audit` pass for all
|
||||||
|
six props; `test_generated_props.gd` proves they import as scenes and that the world places
|
||||||
|
them.
|
||||||
|
|
||||||
|
- [x] Add `tools/model_pipeline.py`: prop catalogue, deterministic low-poly GLB builder,
|
||||||
|
OpenRouter concept-image generation, palette sampling and GLB audit.
|
||||||
|
- [x] Add `tools/test_model_pipeline.py` (valid GLB structure, deterministic output, audit
|
||||||
|
failures, sRGB→linear conversion, role assignment).
|
||||||
|
- [x] Generate the prop models into `assets/models/generated/` and import them.
|
||||||
|
- [x] Load the generated props in `WorldBuilder` with a procedural fallback when the GLB is
|
||||||
|
missing.
|
||||||
|
|
||||||
|
## Controls, Movement and NPC Selection
|
||||||
|
|
||||||
|
Completed 2026-10-31 (v0.3.0 working tree). The windowed smoke now covers wheel cycling and
|
||||||
|
right-click clearing; the headless suite passes 177/177.
|
||||||
|
|
||||||
|
- [x] Record the re-evaluated control model as ADR-0011.
|
||||||
|
- [x] Rank focus candidates by view angle then distance (`NpcFocus.rank_indices`) instead of
|
||||||
|
insertion order.
|
||||||
|
- [x] Add mouse-wheel target cycling next to `Tab`, right-click clearing focus, and a `Shift`
|
||||||
|
precision walk speed.
|
||||||
|
- [x] Show the nearby-candidate count and the cycle hint in the HUD.
|
||||||
|
- [x] Cover ranking, cycling and walk speed with tests, then re-run the windowed smoke.
|
||||||
|
|
||||||
|
## Web Export Build Target
|
||||||
|
|
||||||
|
Completed 2026-10-31 (v0.3.0). Headless **181/181**; `godot --headless --export-release "Web"`
|
||||||
|
produces a 63 MB static bundle and it serves locally with HTTP 200s for html/wasm/pck.
|
||||||
|
|
||||||
|
- [x] Add a `Web` preset to `export_presets.cfg` using `variant/thread_support=false` so the
|
||||||
|
build runs on plain static hosting without COOP/COEP headers.
|
||||||
|
- [x] Export to `build/web/` and package `build/step-line-web-<version>.zip` from
|
||||||
|
`tools/build_releases.sh` (zip or Python fallback).
|
||||||
|
- [x] Publish the web zip with the desktop artifacts (`tools/publish_release.sh`).
|
||||||
|
- [x] Cover the preset, the non-threaded choice, and the release wiring in
|
||||||
|
`src/test/tests/test_web_export.gd`.
|
||||||
|
- [x] Document the artifact in `docs/RELEASING.md`, the README gallery/status, and `PLAN.md`.
|
||||||
|
|
||||||
|
## Prop Texture Atlas
|
||||||
|
|
||||||
|
Completed 2026-10-31 (v0.3.1). Headless **182/182**, Python tooling **53/53**; screenshots
|
||||||
|
regenerated and `screen_designs.py compare` still reports 6 good / 0 bad.
|
||||||
|
|
||||||
|
- [x] `model_pipeline.atlas_for()` packs each prop's palette roles into one small power-of-two
|
||||||
|
PNG and a matching metallic/roughness atlas (roughness in G, metallic in B), with nearest
|
||||||
|
sampling so a palette cell stays a flat colour.
|
||||||
|
- [x] `build_glb()` merges every part into a single primitive with per-vertex atlas UVs and one
|
||||||
|
material (`baseColorTexture` + `metallicRoughnessTexture`), so a prop is one draw material
|
||||||
|
instead of one per part.
|
||||||
|
- [x] `audit_glb()` checks the single-material/atlas contract (one material, both textures,
|
||||||
|
TEXCOORD_0 on every primitive, two images).
|
||||||
|
- [x] Tests: atlas determinism and power-of-two sizing, UV range, palette override reaching the
|
||||||
|
atlas, and a Godot test that each imported prop scene uses exactly one material.
|
||||||
|
- [x] Removed the now-unused sRGB→linear factor helpers (colours travel in the sRGB texture).
|
||||||
|
|
||||||
|
## Complete AI Mockup Set
|
||||||
|
|
||||||
|
Completed 2026-10-31 (v0.3.2). All 19 catalogue screens now have an AI reference; Python
|
||||||
|
tooling **53/53**.
|
||||||
|
|
||||||
|
- [x] Generated mockups for the seven entries that only had wireframes: wand screening,
|
||||||
|
Joke Shop queue, TV Studio arrival, crowd panic, responder arrival, body-camera
|
||||||
|
timeline, escalation HUD.
|
||||||
|
- [x] `generate` loads `.env` like `judge` already did, so `--ai` works without exporting
|
||||||
|
`OPENROUTER_API_KEY` by hand.
|
||||||
|
- [x] Verified every reference is 1280x720, reviewed the new images visually, and confirmed
|
||||||
|
`list` reports 19 designs (12 kept, 7 new).
|
||||||
|
|
||||||
|
## Staged Captures For The New Screens
|
||||||
|
|
||||||
|
Completed 2026-11-01 (v0.3.3). `screen_designs.py compare` reports **9 good / 0 bad**;
|
||||||
|
vision judge: wand **match** (0.90), timeline **match** (1.00), escalation **review**, 0 diff.
|
||||||
|
Python tooling **53/53**, headless **182/182**.
|
||||||
|
|
||||||
|
- [x] `capture_screenshots.gd` now continues past the night set: switches to The Joke Shop,
|
||||||
|
stages a guest, retries the wand until it alerts (falls back to the documented keys
|
||||||
|
false alarm), applies formal warning + blocking, and opens the body-camera timeline.
|
||||||
|
- [x] New captures `07-wand-screening.png`, `08-escalation-hud.png`, `09-body-camera-timeline.png`
|
||||||
|
added to `docs/screenshots/` and to the README gallery.
|
||||||
|
- [x] Wired `ref` for `wand_screening`, `escalation_hud` and `body_camera_timeline` so
|
||||||
|
`compare` and `judge` include them (9 of 19 screens now have a staged capture).
|
||||||
|
- [x] Regenerated the escalation HUD mockup as a third-person over-the-shoulder night shot so
|
||||||
|
the reference matches the game camera.
|
||||||
|
- [x] Added `Hud.clear_log()` so staged captures drop target-cycling noise; the escalation shot
|
||||||
|
keeps the real reaction lines.
|
||||||
|
|
||||||
|
## Prop Collision And Placement
|
||||||
|
|
||||||
|
Completed 2026-11-01 (v0.4.0). Headless **187/187**, Python tooling **53/53**, the new
|
||||||
|
`smoke_props.gd` passes windowed, and `screen_designs.py compare` still reports 9 good / 0 bad.
|
||||||
|
|
||||||
|
- [x] Every generated prop now gets a fitted static collider: a cylinder for bollard, bin, and
|
||||||
|
hydrant, a box for everything else. The collider is computed from the model's mesh bounds in
|
||||||
|
holder-local space, so placement rotation and variant scale carry over.
|
||||||
|
- [x] Added `smoke_props.gd` (windowed): the guard walks into a bench and stops instead of passing
|
||||||
|
through it; a guest heading for a point beyond the same bench routes around it with
|
||||||
|
navigation avoidance and never ends up inside the collider.
|
||||||
|
- [x] Props publish a `NavigationObstacle3D` footprint, and `NpcAvatar` enables agent avoidance with
|
||||||
|
a `velocity_computed` callback. The navmesh stays a flat walkable plane; avoidance does the
|
||||||
|
steering around street furniture.
|
||||||
|
- [x] Replaced the hard-coded prop calls with `WorldBuilder.PROP_PLACEMENTS`, grouped by entrance,
|
||||||
|
mid-block, and curb zones, with a static `placement_problems()` validator for bounds,
|
||||||
|
overlap/min-spacing, duplicates, and the door/steps/guest-lane exclusions.
|
||||||
|
- [x] Deterministic variants per placement (`variant_for`): yaw jitter, subtle scale, and optional
|
||||||
|
procedural clutter (bag by the bin, crate by the newspaper boxes, cone by the hydrant), so
|
||||||
|
repeated props stop looking cloned.
|
||||||
|
- [x] Fallback primitive props go through the same bounds fit, so a missing `.glb` cannot silently
|
||||||
|
remove collision; a unit test covers the fallback body, shape, width, and navigation obstacle.
|
||||||
|
- [x] Tests: placement validation and its failure modes, variant determinism/range, colliders on
|
||||||
|
every placed prop (including obstacle presence), and the fallback collider path.
|
||||||
@@ -15,17 +15,97 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
|
|
||||||
## Handoff (read this first)
|
## Handoff (read this first)
|
||||||
|
|
||||||
- Repo: `/run/media/system/DATA/Documents/06-games/security-game`. Godot: `~/.local/bin/godot` (4.3.stable).
|
- Repo: `/run/media/system/DATA/Documents/06-games/security-game` (also reachable at
|
||||||
Tests: `godot --headless --path . --script res://src/test/test_runner.gd` → **88/88**.
|
`/var/home/zwitschi/Documents/06-games/security-game`). Godot: `~/.local/bin/godot` (4.3.stable).
|
||||||
|
Tests: `godot --headless --path . --script res://src/test/test_runner.gd` → **177/177**.
|
||||||
|
Python tooling tests: `cd tools && python3 -m unittest test_asset_pipeline test_screen_designs test_model_pipeline` → **53**.
|
||||||
Session shell smoke: `godot --headless --path . --script res://src/test/smoke_session_shell.gd` → exit 0.
|
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.
|
Windowed control smoke: `godot --path . --script res://src/test/smoke_focus_radial.gd` → exit 0
|
||||||
|
(now also covers wheel cycling and right-click clearing).
|
||||||
- `main` is in sync with origin. **v0.2.2** tags the session-shell + world-follow-up commit;
|
- `main` is in sync with origin. **v0.2.2** tags the session-shell + world-follow-up commit;
|
||||||
CI builds the portable binaries, NSIS installer, and AppImage on the tag push.
|
CI builds the portable binaries, NSIS installer, and AppImage on the tag push.
|
||||||
- Next work: remaining PLAN verbs in `TODO.md` (eye contact, recognize decision, warning body-cam
|
- **v0.3.0 (2026-10-31, working tree):** rendering pass (`src/game/materials.gd`, phase-driven
|
||||||
flag, blocking station, request reason), then crowd presentation (rubberneck + evacuation).
|
environment), generated `.glb` props (`tools/model_pipeline.py`, `assets/models/generated/`),
|
||||||
|
aim-free selection with wheel cycling and Shift precision walk (ADR-0011/0012), and a **Web**
|
||||||
|
export target. `export_presets.cfg` gains a `Web` preset with `variant/thread_support=false`
|
||||||
|
(static hosting, no COOP/COEP); `tools/build_releases.sh` exports `build/web/` and zips it to
|
||||||
|
`build/step-line-web-<ver>.zip`, and `publish_release.sh` uploads it. Covered by
|
||||||
|
`src/test/tests/test_web_export.gd`.
|
||||||
|
- **Prop atlas (v0.3.1):** `model_pipeline.atlas_for()` builds a per-prop power-of-two PNG with
|
||||||
|
one 8×8 cell per palette role, plus a metallic/roughness atlas (G=roughness, B=metallic). The
|
||||||
|
GLB merges all parts into one primitive with constant per-vertex UVs (cell centre) and a single
|
||||||
|
material (`baseColorTexture` + `metallicRoughnessTexture`, NEAREST sampling). Colours are sRGB in
|
||||||
|
the texture, so the old `srgb_to_linear`/`_linear_rgba` helpers were removed. `audit_glb` fails
|
||||||
|
if a prop is not exactly one material or misses TEXCOORD_0/images.
|
||||||
|
- **Mockups complete (v0.3.2):** `docs/screens/designs/` holds AI references for all 19
|
||||||
|
catalogue screens. Only the six staged captures (01–06) are compared/judged; the other 13
|
||||||
|
have no `capture`, so adding staged captures for the wand panel, timeline and escalation HUD
|
||||||
|
would extend automated visual checks. `generate` now calls `_load_env_file()`.
|
||||||
|
- **Staged captures (v0.3.3):** `capture_screenshots.gd` now emits nine shots. States 7–10 switch
|
||||||
|
to The Joke Shop at elapsed 620 s (EVENING has routine spawns; LATE has none), retry the wand
|
||||||
|
until it alerts, then jump to 760 s after staging for night lighting. The timeline needs
|
||||||
|
`recorder.close_all_open()`, because `to_dict()` only lists closed incidents. The escalation
|
||||||
|
HUD mockup is third-person now; `judge` still classes it `review` (no diff).
|
||||||
|
- **Prop collision (v0.4.0):** `_add_prop_model()` fits a `StaticBody3D` from the model bounds
|
||||||
|
(cylinder for bollard/bin/hydrant, box otherwise) and adds a `NavigationObstacle3D`; NPC agents
|
||||||
|
enable avoidance and move with the safe velocity from `velocity_computed`. The navmesh stays a
|
||||||
|
flat plane, so avoidance (not a baked mesh) routes guests around props. Placements live in
|
||||||
|
`PROP_PLACEMENTS` with `placement_problems()` validation; variants come from `variant_for(index)`.
|
||||||
|
`smoke_props.gd` must be windowed because the guard reads captured-mouse input.
|
||||||
|
- **Presentation pass remaining (TODO):** NPC characters and player movement. Grounding facts: the
|
||||||
|
NPC rig is rigid capsules with a whole-limb swing, and `guard_controller.gd` uses one `accel`
|
||||||
|
value. Each task should get its own windowed smoke or capture where it changes feel/looks.
|
||||||
|
- **Backlog complete:** every planned TODO section is done and moved to `DONE.md`; `TODO.md`
|
||||||
|
now only lists optional future ideas (live 3D replay, NPC LOD, more venues, real audio, prop
|
||||||
|
atlas, AI mockups for the new catalogue entries, image-to-mesh props). GUT was consciously not
|
||||||
|
adopted (custom runner covers it without an editor plugin).
|
||||||
- Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg.
|
- Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg.
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
|
- **v0.3.0 pass (2026-10-31, uncommitted):** rendering, generated prop models, screen
|
||||||
|
catalogue, aim-free selection. See ADR-0011 (selection) and ADR-0012 (materials/props).
|
||||||
|
- `src/game/materials.gd` (`SurfaceMaterials`) is now the single source of surface
|
||||||
|
recipes + `configure_environment()`. `GameManager.PHASE_LIGHTING` gained
|
||||||
|
`exposure/glow/fog_color/fog_density/fog_energy/contrast/saturation/ambient_energy`.
|
||||||
|
Renderer is **GL Compatibility**, so SSAO/SSIL are unavailable; glow+fog+ACES are used
|
||||||
|
instead and unsupported settings are ignored rather than gated in code.
|
||||||
|
- World: scaled brick (uv 22,10), framed windows with per-window night emission
|
||||||
|
(`i % 3 != 2` lit), stone base course, door trim/handles, curb, lane dashes (the old
|
||||||
|
loop had `Vector3(i * 0.0, …)` and stacked every dash at x=0), street lamps use the
|
||||||
|
metal profile. The two flat planters that clipped the steps were removed.
|
||||||
|
- `tools/model_pipeline.py` builds `assets/models/generated/*.glb` (bollard, bench,
|
||||||
|
trash_bin, planter_box, newspaper_box, fire_hydrant) from box/cylinder specs;
|
||||||
|
`WorldBuilder._add_prop_model()` loads them with a procedural fallback and records
|
||||||
|
`loaded_models` / `fallback_props`. **Gotcha:** glTF `baseColorFactor` is LINEAR —
|
||||||
|
writing sRGB made every prop pastel; `srgb_to_linear()` is applied in `build_glb`.
|
||||||
|
**Gotcha:** a new/changed `.glb` needs `godot --headless --path . --import` before
|
||||||
|
`ResourceLoader.exists()` / `load()` sees it (test_generated_props fails otherwise).
|
||||||
|
- `model_pipeline.py concept` (OpenRouter, reads repo `.env`) writes per-prop concept
|
||||||
|
sheets to `assets/models/concepts/`; `dominant_colors()` centre-crops and weights by
|
||||||
|
saturation (a grey studio backdrop otherwise wins), `assign_roles()` maps colours by
|
||||||
|
hue/value. Delete a concept file to fall back to the catalogue palette.
|
||||||
|
- NPC avatars: `outfit_for(type, seed)` (pure) gives shirt/trousers/hair/skin +
|
||||||
|
accessory (cap, hood, backpack, sunglasses, lanyard) and height scale; `_arms` and
|
||||||
|
`_legs` now swing (`limb_swing`) while walking. Keep `Rig/Head` named for focus.
|
||||||
|
- Selection: `NpcFocus.rank_indices` (angle then distance) — `pick_index` = first.
|
||||||
|
`GuardController.cycle_target(direction)` walks nearest-first `nearby_candidates()` and
|
||||||
|
sets `target_pinned`, which makes `_update_target()` skip the cone. `clear_target()`
|
||||||
|
sets `focus_suppress_s = 0.75` so the cone does not instantly re-acquire the person.
|
||||||
|
New input actions `walk_slow` (Shift), `cycle_target_next` / `cycle_target_prev`
|
||||||
|
(wheel down / up). Right click with nothing open clears focus (Esc still pauses).
|
||||||
|
- HUD: `nearby_label` + `set_nearby_hint(count)` show `hud.nearby`; `hud.controls` and
|
||||||
|
`hud.targeting` mention the wheel and Shift.
|
||||||
|
- `tools/screen_designs.py`: catalogue now 19 screens (added wand_screening,
|
||||||
|
joke_shop_queue, studio_arrival, crowd_panic, responder_arrival,
|
||||||
|
body_camera_timeline, escalation_hud), new `list` command, `generate --only/--force`
|
||||||
|
(existing designs are kept by default so a draft run cannot clobber AI mockups),
|
||||||
|
and `judge --dry-run` no longer raises `KeyError: 'class'`.
|
||||||
|
- Screenshot tool leak: `capture_screenshots.gd` intermittently prints
|
||||||
|
`ObjectDB instances leaked at exit`. Verified against a `git worktree` of HEAD~ — the
|
||||||
|
baseline leaks more often (3/3 vs 1/3), so it is a pre-existing quit-timing artifact,
|
||||||
|
not a regression. Normal boots are clean.
|
||||||
|
|
||||||
- Access artifacts: `src/core/access_loadout.gd` builds from event `override_access` + venue policy.
|
- Access artifacts: `src/core/access_loadout.gd` builds from event `override_access` + venue policy.
|
||||||
Spawn signal is now `(profile, intent, npc_id, event)`. Recognition is not auto-attached to every
|
Spawn signal is now `(profile, intent, npc_id, event)`. Recognition is not auto-attached to every
|
||||||
guest. `AccessVerifier.verify(..., already_screened=false)` so Joke Shop scoring uses
|
guest. `AccessVerifier.verify(..., already_screened=false)` so Joke Shop scoring uses
|
||||||
@@ -35,7 +115,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
`hud.controls` / `hud.screen_first` updated.
|
`hud.controls` / `hud.screen_first` updated.
|
||||||
- Control pass (in tree, uncommitted, smoke-tested): cone focus via `src/game/npc_focus.gd`,
|
- 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.
|
accel/decel, gravity only airborne, `input_locked` freeze, Esc no longer on the guard.
|
||||||
`NpcAvatar` in group `npc`; neck/hair/eyes/shoes; torus head ring + `Label3D` name; `set_focus_visual`.
|
`NpcAvatar` in group `npc`; neck/hair/eyes/shoes; blue focus circle + `Label3D` name; `set_focus_visual`.
|
||||||
`src/game/ui/action_radial.gd` doughnut (LMB/Space open, A/D/arrows, Space/click confirm, Esc/RMB
|
`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.
|
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),
|
- Named input actions now exist and are used: `action_menu`, `confirm`, `cancel` (Esc + RMB),
|
||||||
@@ -58,11 +138,144 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
(`assets/textures/skyline.png`, regenerated RGBA with a transparent sky) and an
|
(`assets/textures/skyline.png`, regenerated RGBA with a transparent sky) and an
|
||||||
`TRANSPARENCY_ALPHA_SCISSOR` unshaded material (`_skyline_mat`); steps + landing are a
|
`TRANSPARENCY_ALPHA_SCISSOR` unshaded material (`_skyline_mat`); steps + landing are a
|
||||||
`StaticBody3D` with per-tread `BoxShape3D` colliders; marquee `font_size` 160 / outline 24; focus
|
`StaticBody3D` with per-tread `BoxShape3D` colliders; marquee `font_size` 160 / outline 24; focus
|
||||||
torus 0.26–0.34 + name tag 48px; `NpcAvatar.ephemeral`/`lifetime_s` despawns routine traffic
|
circle (blue disc in front of the face) + name tag 48px; `NpcAvatar.ephemeral`/`lifetime_s` despawns routine traffic
|
||||||
(authored events stay). Guard spawn height verified on the landing (guard_y=0.84).
|
(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.
|
||||||
|
- **Editor-ready venues (2026-10-31):** `tools/export_venues.gd` (a SceneTree script) saves the
|
||||||
|
three factory scenarios to `content/venues/<id>.tres` with `ResourceSaver`; the `.tres` files
|
||||||
|
round-trip through `ResourceLoader` (events, profiles, policy, shift phases, door lines,
|
||||||
|
locations). `test_venue_resources.gd` also fails if the exported files are missing or stale
|
||||||
|
relative to the factory, so re-run the export after changing `ScenarioFactory`.
|
||||||
|
- **Vision judge (2026-10-31):** `python3 tools/screen_designs.py judge` sends the capture plus
|
||||||
|
its concept mockup to a vision model (`DEFAULT_JUDGE_MODEL = google/gemini-2.5-flash-lite`,
|
||||||
|
override with `--model`; `openai/gpt-4o-mini` also works). Reads the repo `.env` via
|
||||||
|
`_load_env_file()` (never overrides real env). Verdicts are classed `match` / `review`
|
||||||
|
(mismatch below `--fail-confidence`, default 0.6) / `diff` (confident mismatch → exit 1) /
|
||||||
|
`unknown`. `--dry-run`, `--json`, `--only` supported. The prompt explicitly ignores art style,
|
||||||
|
because the references are stylised mockups; the live run gave 3 match, 3 review, 0 diff.
|
||||||
|
Python tests: `cd tools && python3 -m unittest test_asset_pipeline test_screen_designs`.
|
||||||
|
- **String formatting safety (2026-10-31):** `Strings.placeholder_count()` counts `%` specs
|
||||||
|
(ignoring `%%`), `fmt()` warns and returns the template when the arg count does not match
|
||||||
|
(instead of raising an engine error), and `validate()` lints the table for trailing `%`,
|
||||||
|
unsupported conversions, and mixed positional/sequential placeholders. Tests cover all three
|
||||||
|
plus a formatting pass over every placeholder key with type-correct dummy args.
|
||||||
|
- **Authored scenario content (2026-10-31):** events can now carry `hint` (a string key logged
|
||||||
|
when the avatar spawns), `extra_spawns` (celebrity arrivals pull in tourists/influencers via
|
||||||
|
`_spawn_npc` with `kind: routine`), and `refuses_screening` (copied to
|
||||||
|
`NPCController.refuses_screening`; `_toggle_wand_screening` logs `wand.refused`, records a
|
||||||
|
`wand_refused` event, applies a small prop hit, and does not open the panel). `film_boundary`
|
||||||
|
is a new intent that maps to `steps_end` so the influencer stands past the posted line.
|
||||||
|
- **Accessibility (2026-10-31):** escalation text is `"<name> [###--] L3/5"` via
|
||||||
|
`Hud.escalation_meter(level)` (pure, tested) so the cue survives grayscale. HUD font scale uses
|
||||||
|
`add_theme_font_size_override` on the core labels (`set_font_scale`), **not** `Control.scale`,
|
||||||
|
which pushed the right-anchored clock label off-screen. Settings load before `_build_ui()`, so
|
||||||
|
`_ready()` re-applies player settings after the HUD exists.
|
||||||
|
- **Audio (2026-10-31):** `AudioFeedback.start_crowd_bed()` builds a 4 s looping `AudioStreamWAV`
|
||||||
|
(one-pole-filtered noise + 68/103 Hz drone, seeded) and `set_crowd_level(mood)` maps 0..1 to
|
||||||
|
-34..-12 dB. `play_radio_open/close` push short filtered-noise bursts. All procedural; with a
|
||||||
|
dummy (headless) audio server the calls are no-ops. `_free_player()` returns null when `_ready`
|
||||||
|
has not run (tests), and callers handle null.
|
||||||
|
- **NPC theater (2026-10-31):** `NpcAvatar.theater_pose()` maps `intent` + `arrived` to
|
||||||
|
idle/sit/photo/film/perform. Pure helpers `crouch_for_pose`, `prop_lift_for_pose`,
|
||||||
|
`sway_for_pose` keep the math testable. Sit lowers the rig by 0.34 and swings the legs forward
|
||||||
|
(negative X pitch; positive looked like kneeling). `_photo_prop` is set for influencer/tourist
|
||||||
|
cameras, `_sign_prop` for protester/performer boards; their base Y values are captured at build
|
||||||
|
time so the animation offsets from rest.
|
||||||
|
- **Body-camera timeline (2026-10-31):** `src/game/ui/event_timeline.gd` flattens
|
||||||
|
`review.incidents.incidents[].events` into one time-ordered list (`flatten`), formats each row
|
||||||
|
with `describe`, and steps with Left/Right or the buttons. `ShiftCoordinator.started_at_ms`
|
||||||
|
gives the review an absolute start for mm:ss stamps. `GameManager._open_timeline` hides the
|
||||||
|
review dim (two stacked overlays made the screen nearly black) and `_timeline_from_review`
|
||||||
|
restores it on close. Smoke: `src/test/smoke_timeline.gd`.
|
||||||
|
- **Radio dispatch was dead after the first shift:** `coordinator.setup()` builds a new
|
||||||
|
`RadioSystem`, so the `responder_arrived` connection made in `_ready` was lost on every venue
|
||||||
|
switch/restart (responders never resolved anything). `_connect_radio()` now re-wires it next to
|
||||||
|
`_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
|
- **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.
|
imported `assets/textures/skyline.png` path. Untracked duplicate folder should not be referenced.
|
||||||
- Host sign-in follow-up remains deferred. PLAN.md + ADRs 0007–0010 still describe the intended UX.
|
- Host sign-in follow-up is **done**: `REQUIRE_SIGNIN_VIEW = false` in `/etc/gitea/app.ini`
|
||||||
|
(backup `app.ini.bak-*` alongside), Gitea restarted; anonymous repo page/API return 200 and
|
||||||
|
release assets download in full. Reverse by setting it back to `true` and restarting.
|
||||||
|
- **Release publishing was broken since v0.2.2 (fixed 2026-10-31):** `publish_release.sh`
|
||||||
|
only built `AUTH` in the `GITEA_TOKEN` branch; the `~/.git-credentials` branch set `CRED`
|
||||||
|
and then used unbound `${AUTH[@]}` under `set -u`, so every API call failed and the tag
|
||||||
|
job ended "could not create or find release". The repo also has **no** `GITEA_TOKEN`
|
||||||
|
secret (`/actions/secrets` is empty) and the runner has no git credentials. Fixes: the
|
||||||
|
script sets `AUTH=(-u "$CRED")`; the workflow falls back to `github.token`
|
||||||
|
(`secrets.GITEA_TOKEN || github.token`) — but `github.token` is **not** provided by this
|
||||||
|
Gitea/act runner, so the env dump showed no token and the v0.2.10 tag build failed again.
|
||||||
|
Final fix: `publish_release.sh` falls back to the **local git extraheader** that
|
||||||
|
`actions/checkout` writes (`git config --local --get-regexp '^http\..*\.extraheader$'`),
|
||||||
|
which carries the runner's basic-auth credentials for the same server. Verified locally by
|
||||||
|
adding that extraheader and publishing v0.2.10 (all five artifacts). **v0.2.9 and v0.2.10
|
||||||
|
are published**; v0.2.4–v0.2.8 tags have no artifacts. Verify the CI path on the next tag.
|
||||||
|
- **CI publishing verified (2026-10-31):** tagging v0.2.12 produced a release with all five
|
||||||
|
artifacts automatically (no repo secret), confirming the checkout-extraheader fallback.
|
||||||
|
- **Host access (2026-10-31):** `root@192.168.88.30` now works from this workstation (key auth),
|
||||||
|
host name `gitea`, Gitea 28.2.0. Maintenance applied: `apt-get clean`, journald capped to
|
||||||
|
`SystemMaxUse=300M` (`/etc/systemd/journald.conf.d/99-size.conf`, journal ~133M), disk at
|
||||||
|
~7.6G free of 16G.
|
||||||
|
- **Host disk resolved (2026-10-31):** the Gitea VM root filesystem was expanded from 7.8G to
|
||||||
|
16G (`/dev/mapper/pve-vm--500--disk--0`, now ~7.8G free). The pruned **v0.2.0** and **v0.2.9**
|
||||||
|
releases were restored from the local `build/` artifacts and verified (4 and 5 assets).
|
||||||
|
`git.allucanget.biz` runs on this host as user `zwitschi`; root@192.168.88.30 is **not**
|
||||||
|
reachable from this workstation (publickey denied; password auth closes the banner), so host
|
||||||
|
maintenance must go through a `zwitschi` login with sudo.
|
||||||
|
- **Host disk full (2026-10-31, historical):** the v0.2.11 tag build created release id 27 and logged the
|
||||||
|
uploads, but they failed with `HTTP 500 Attachments.Save: write … no space left on device`
|
||||||
|
(`/dev/mapper/pve-vm--500--disk--0`, 7.8G, 100%, 0 free). `git push` then failed too
|
||||||
|
("unpacker error"). Unblocked by deleting the superseded **v0.2.0** and **v0.2.9** releases
|
||||||
|
via the API (~565 MB of assets), then publishing v0.2.11 locally with the fixed script
|
||||||
|
(verified 5 assets). Remaining ~150 MB is tight — the next release needs host cleanup on
|
||||||
|
192.168.88.30: `sudo journalctl --vacuum-size=100M` (journal 837 MB), `sudo apt-get clean`
|
||||||
|
(91 MB), clear `/var/lib/gitea/data/attachments/tmp`. SSH works as `zwitschi`; `sudo -n`
|
||||||
|
needs a password. **Releases now:** v0.2.11, v0.2.10, v0.2.1 (5 assets each); v0.2.0 and
|
||||||
|
v0.2.9 entries were pruned. PLAN.md + ADRs 0007–0010 still describe the intended UX.
|
||||||
- **Release v0.2.1 is LIVE** (2026-10-31): CI run 1110 built the NSIS installer + AppImage
|
- **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.
|
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
|
- NSIS gotchas (validated in a local ubuntu:24.04 docker repro): makensis uses `-D` defines
|
||||||
@@ -151,6 +364,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
|
||||||
@@ -181,6 +405,8 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
|
|
||||||
## 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.
|
||||||
|
|||||||
@@ -452,13 +452,14 @@ The following commands apply once the Godot project is scaffolded:
|
|||||||
|
|
||||||
## 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. A Web export now ships as well
|
||||||
|
(non-threaded, static hosting); it is published with each release as a zip.
|
||||||
|
|
||||||
## Player Control, Focus, and Action Menu (next pass)
|
## 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.
|
The prototype already walks and looks in first person, but the control scheme fights itself and targeting is a hairline. This pass replaces that with three things that belong together: solid locomotion, a visible focus on a person, and a round action menu for what to do with them.
|
||||||
|
|
||||||
Decisions: [ADR-0007](docs/adr/0007-locomotion-and-input-layers.md) locomotion and input layers, [ADR-0008](docs/adr/0008-cone-focus-and-head-ring.md) cone focus and head ring, [ADR-0009](docs/adr/0009-radial-action-menu.md) radial action menu, [ADR-0010](docs/adr/0010-procedural-npc-presentation.md) procedural NPC presentation.
|
Decisions: [ADR-0007](docs/adr/0007-locomotion-and-input-layers.md) locomotion and input layers, [ADR-0008](docs/adr/0008-cone-focus-and-head-ring.md) cone focus and focus circle, [ADR-0009](docs/adr/0009-radial-action-menu.md) radial action menu, [ADR-0010](docs/adr/0010-procedural-npc-presentation.md) procedural NPC presentation.
|
||||||
|
|
||||||
### What is wrong today
|
### What is wrong today
|
||||||
|
|
||||||
@@ -496,17 +497,17 @@ Replace the hairline ray as the primary picker.
|
|||||||
- `Tab` still cycles people inside 12 m as the aim-free path. Cycling sets the same focused person the cone would.
|
- `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.
|
- Clearing: look away until the head leaves the circle for a few frames (small grace so a bob does not drop them), or they walk out of range.
|
||||||
|
|
||||||
### Head ring
|
### Focus circle
|
||||||
|
|
||||||
When a person is in focus, draw a thin ring around their head.
|
When a person is in focus, draw a blue, semi-transparent circle in front of their face.
|
||||||
|
|
||||||
- Attach to the avatar `Head` node, sitting just above the skull, billboarded to the camera so it stays a circle on screen.
|
- Attach to the avatar `Head` node, just in front of the face, billboarded to the camera so it stays a circle on screen.
|
||||||
- Slow spin is fine; no ping-pong scale spam.
|
- Slow spin is fine; no ping-pong scale spam.
|
||||||
- Idle focus: pale gold, modest size. Radial open on that person: brighter, slightly larger.
|
- Idle focus: blue, semi-transparent, 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.
|
- Optional small name under the circle (`display_name` only, not the archetype cheat `Violent Threat` in the world). The existing HUD target line can keep the archetype for the prototype.
|
||||||
- One ring at a time. Everyone else has none.
|
- One circle at a time. Everyone else has none.
|
||||||
|
|
||||||
Implementation sketch: `MeshInstance3D` torus or a thin unshaded ring mesh on the avatar, toggled from `GuardController` / `GameManager` when focus changes. Screen-space fallback (unproject head, draw a `Control` circle) is acceptable if the 3D torus z-fights on hats.
|
Implementation sketch: a thin unshaded `MeshInstance3D` circle (flat disc) in front of the face on the avatar, toggled from `GuardController` / `GameManager` when focus changes. Screen-space fallback (unproject head, draw a `Control` circle) is acceptable if the 3D circle z-fights on hats.
|
||||||
|
|
||||||
### Round action HUD
|
### Round action HUD
|
||||||
|
|
||||||
@@ -545,7 +546,7 @@ A doughnut / pie menu centered on the screen, opened on the focused person.
|
|||||||
**Cancel**
|
**Cancel**
|
||||||
|
|
||||||
- `Esc` or right mouse button: hide the menu, no action, no recorder event, recapture mouse.
|
- `Esc` or right mouse button: hide the menu, no action, no recorder event, recapture mouse.
|
||||||
- Focus and the head ring stay as they were.
|
- Focus and the focus circle stay as they were.
|
||||||
|
|
||||||
Number keys `1`–`6` may remain as shortcuts that skip the wheel so a smoke-test script still works. They must not fire while the wheel is open (the wheel owns `A` / `D` / `Space`). Document both in the README.
|
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.
|
||||||
|
|
||||||
@@ -590,6 +591,18 @@ AI textures in `assets/textures/ai/` stay out of this pass unless a specific map
|
|||||||
- `src/test/tests/test_focus_and_radial.gd` — cone pick, wrap, cancel emits nothing, `W` is not wand.
|
- `src/test/tests/test_focus_and_radial.gd` — cone pick, wrap, cancel emits nothing, `W` is not wand.
|
||||||
- README controls table and smoke checklist.
|
- README controls table and smoke checklist.
|
||||||
|
|
||||||
|
## Rendering, Models, and Selection (v0.3.0 pass)
|
||||||
|
|
||||||
|
Decisions: [ADR-0011](docs/adr/0011-aim-free-selection-and-precision-walk.md) aim-free selection and precision walk, [ADR-0012](docs/adr/0012-surface-materials-and-generated-props.md) surface materials and generated props.
|
||||||
|
|
||||||
|
The blockout look and the aim-first selection model were the two biggest gaps left by the control pass. This pass closes them without changing the game's rules.
|
||||||
|
|
||||||
|
**Look.** Surfaces get real profiles instead of stock materials: brick is rough and finely tiled, concrete is rougher still, asphalt is smoother and damp-looking, glass is smooth and slightly metallic. The environment gains ACES tonemapping, soft glow, aerial fog and a mild grade, all driven by the same per-phase configuration that already moves the sun and sky. The facade gains framed windows that light up after dark, a stone base course, door trim and handles. Street furniture becomes real geometry: bollards, benches, bins, planters, newspaper boxes and a hydrant, built as `.glb` props by `tools/model_pipeline.py` with a procedural fallback. NPCs get per-role clothing palettes, small role accessories (cap, hood, backpack, sunglasses, lanyard), hands, shoes, and an arm/leg walk swing. The rule stays: the player judges people from clothes, props and behaviour, never from an archetype label.
|
||||||
|
|
||||||
|
**Models.** Prop meshes are deterministic primitives, so the build works offline. An image model can supply the per-prop colour palette from a concept sheet; that is the only AI influence on geometry, and it is optional. Colours are converted from sRGB to linear before they reach glTF, because glTF factors are linear.
|
||||||
|
|
||||||
|
**Selection.** The focus cone ranks heads by angle from the crosshair then distance. `Tab` and the mouse wheel cycle nearby people nearest-first and pin the choice, so aim-free play works. Right click clears focus and briefly suppresses re-acquisition. Holding `Shift` walks at half speed for close work at the door. Escape keeps its single meaning (close the top panel, otherwise pause).
|
||||||
|
|
||||||
## 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.
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ The central design goal is simple:
|
|||||||
|  |  |
|
|  |  |
|
||||||
|  |  |
|
|  |  |
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|  |  |
|
||||||
|
|  | |
|
||||||
|
|
||||||
*Screenshots are generated by [`src/test/capture_screenshots.gd`](src/test/capture_screenshots.gd) from the current build — day and late-night shifts.*
|
*Screenshots are generated by [`src/test/capture_screenshots.gd`](src/test/capture_screenshots.gd) from the current build — day, late-night, wand screening, escalation, and shift-review timeline.*
|
||||||
|
|
||||||
## ⬇️ Downloads
|
## ⬇️ Downloads
|
||||||
|
|
||||||
@@ -25,12 +27,15 @@ The central design goal is simple:
|
|||||||
| **Linux — AppImage** | [`step-line-<ver>-x86_64.AppImage`](https://git.allucanget.biz/zwitschi/security-game/releases) · single file, `chmod +x` and run |
|
| **Linux — AppImage** | [`step-line-<ver>-x86_64.AppImage`](https://git.allucanget.biz/zwitschi/security-game/releases) · single file, `chmod +x` and run |
|
||||||
| **Linux — portable** | [`step-line-linux-<ver>.x86_64`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, `chmod +x` and run |
|
| **Linux — portable** | [`step-line-linux-<ver>.x86_64`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, `chmod +x` and run |
|
||||||
| **macOS** | [`step-line-macos-<ver>.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unsigned build, quarantine note below |
|
| **macOS** | [`step-line-macos-<ver>.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unsigned build, quarantine note below |
|
||||||
|
| **Web (browser)** | [`step-line-web-<ver>.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unzip and serve the folder from any static host (no special headers) |
|
||||||
|
|
||||||
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
|
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
|
||||||
|
|
||||||
> **Latest: v0.2.2** — session shell (venue menu, pause overlay, persisted settings) and the
|
> Downloads are public (no sign-in required) since the host's instance-wide
|
||||||
> world presentation follow-ups (walkable steps, transparent skyline, larger marquee and focus
|
> `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31.
|
||||||
> ring). Releases are built automatically when a `vX.Y.Z` tag is pushed
|
>
|
||||||
|
> **Latest: v0.4.0** — props have colliders and navigation obstacles, with zoned placements and deterministic variants.
|
||||||
|
> 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.
|
||||||
@@ -137,8 +142,8 @@ 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 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.
|
- **Art assets:** authored PNGs and the in-game generated set in `assets/textures/ai/game_ready/`; street-furniture models in `assets/models/generated/` build from `tools/model_pipeline.py`; 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.
|
- **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. `tools/model_pipeline.py` builds and audits the prop `.glb` models.
|
||||||
- **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
|
||||||
@@ -180,6 +185,102 @@ 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.
|
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.
|
||||||
|
|
||||||
|
### Prop models
|
||||||
|
|
||||||
|
Street furniture (bollards, benches, bins, planters, newspaper boxes, a hydrant)
|
||||||
|
is real 3D geometry generated by `tools/model_pipeline.py` and written to
|
||||||
|
`assets/models/generated/*.glb`. Godot imports each file as a `PackedScene`, and
|
||||||
|
`WorldBuilder` instantiates it with a procedural fallback when the model or its
|
||||||
|
import is missing, so the world still builds on a fresh checkout.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Print the catalogue (parts, triangle count, whether a model/concept exists)
|
||||||
|
python3 tools/model_pipeline.py list
|
||||||
|
|
||||||
|
# Rebuild the models (deterministic, offline) and validate them
|
||||||
|
python3 tools/model_pipeline.py build
|
||||||
|
python3 tools/model_pipeline.py audit
|
||||||
|
|
||||||
|
# Optional: an image model supplies the colour palette per prop
|
||||||
|
# (requires OPENROUTER_API_KEY; the repo .env is read automatically)
|
||||||
|
python3 tools/model_pipeline.py concept
|
||||||
|
python3 tools/model_pipeline.py concept --only bench --force
|
||||||
|
```
|
||||||
|
|
||||||
|
Each prop is a small list of boxes and cylinders, so `build` needs no network and
|
||||||
|
the output is byte-identical between runs. `concept` asks an image model for a
|
||||||
|
studio-style reference; `dominant_colors` and `assign_roles` then map that image's
|
||||||
|
colours onto material roles (darkest → metal, warm saturated → wood, light grey →
|
||||||
|
stone, green → foliage). glTF stores colours in linear space, so the tool converts
|
||||||
|
the sRGB palettes before writing them. Re-run `build` and `godot --headless
|
||||||
|
--path . --import` after changing a prop. See
|
||||||
|
[ADR-0012](docs/adr/0012-surface-materials-and-generated-props.md).
|
||||||
|
|
||||||
|
Surface materials share one definition in `src/game/materials.gd`: named
|
||||||
|
roughness/metallic profiles per surface, plus the environment tune-up (ACES
|
||||||
|
tonemapping, glow, fog, colour grading) that the phase lighting applies.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
# Export the built-in scenarios to editor-authorable .tres resources
|
||||||
|
godot --headless --path . --script res://tools/export_venues.gd
|
||||||
|
|
||||||
|
# List the catalogue (name, kind, whether a reference design exists)
|
||||||
|
python3 tools/screen_designs.py list
|
||||||
|
|
||||||
|
# Deterministic wireframes (offline, no API key); existing designs are kept
|
||||||
|
# unless --force is passed, so an offline run never replaces an AI mockup
|
||||||
|
python3 tools/screen_designs.py generate --draft
|
||||||
|
python3 tools/screen_designs.py generate --draft --only wand --force
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Ask a vision model whether each capture realises its design's screen and layout
|
||||||
|
# (requires OPENROUTER_API_KEY; the repo .env is read automatically)
|
||||||
|
python3 tools/screen_designs.py judge
|
||||||
|
python3 tools/screen_designs.py judge --model openai/gpt-4o-mini --json
|
||||||
|
python3 tools/screen_designs.py judge --only 03- # one capture
|
||||||
|
python3 tools/screen_designs.py judge --dry-run # build requests, no network
|
||||||
|
```
|
||||||
|
|
||||||
|
The catalogue covers 19 screens: the nine README captures plus the start menu,
|
||||||
|
pause overlay, settings, shift review, radial menu, emergency radio, wand
|
||||||
|
screening, Joke Shop queue, TV Studio arrival, crowd panic, responder arrival,
|
||||||
|
body-camera timeline, and the escalation HUD. All 19 have an AI reference in
|
||||||
|
`docs/screens/designs/`; the nine screens with staged captures are also compared
|
||||||
|
or judged against the running game.
|
||||||
|
|
||||||
|
`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*.
|
||||||
|
|
||||||
|
`judge` covers that gap: it sends each capture together with its concept mockup to
|
||||||
|
a vision model and asks whether the capture realises the same screen and layout
|
||||||
|
intent. Art style, fidelity, fonts, and placeholder text are explicitly ignored.
|
||||||
|
Verdicts are classed as `match`, `review` (a mismatch below `--fail-confidence`,
|
||||||
|
default 0.6 — expected when a concept mockup is more stylised than the game), or
|
||||||
|
`diff` (a confident mismatch, which exits non-zero). 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.
|
||||||
@@ -203,11 +304,32 @@ 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
|
# Windowed control smoke: focus circle, action wheel, wheel cycling + right-click clear; saves PNGs, exits 1 on failure
|
||||||
godot --path . --script res://src/test/smoke_focus_radial.gd
|
godot --path . --script res://src/test/smoke_focus_radial.gd
|
||||||
|
|
||||||
# Session-shell smoke: menu → shift → pause → settings → menu (headless or windowed)
|
# Session-shell smoke: menu → shift → pause → settings → menu (headless or windowed)
|
||||||
godot --headless --path . --script res://src/test/smoke_session_shell.gd
|
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
|
||||||
|
|
||||||
|
# Prop-collision smoke (windowed): guard stops at a bench, guest routes around it
|
||||||
|
godot --path . --script res://src/test/smoke_props.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
|
||||||
|
|
||||||
|
# Scenario-content smoke: wand refusal, celebrity crowd spike
|
||||||
|
godot --headless --path . --script res://src/test/smoke_scenario_content.gd
|
||||||
```
|
```
|
||||||
|
|
||||||
The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor.
|
The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor.
|
||||||
@@ -217,7 +339,7 @@ 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 |
|
||||||
| Space / left click | Action wheel for the focused person |
|
| 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) |
|
| A / D or ←/→ | Step through the action wheel (while it is open) |
|
||||||
| Space / left click | Confirm the highlighted action (while the wheel 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) |
|
| Esc / right click | Close the top panel; Esc with nothing open pauses the shift (Esc again resumes) |
|
||||||
@@ -233,8 +355,11 @@ The unit test runner is the project's own lightweight harness (no editor plugin
|
|||||||
| C | 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 (debug shortcut; restart via V restarts the current shift) |
|
| V | Cycle venue (debug shortcut; restart via V restarts the current shift) |
|
||||||
| Tab | Cycle target among nearby NPCs (aim-free alternative) |
|
| Tab / mouse wheel ↓ / ↑ | Cycle the target among nearby NPCs, nearest first (aim-free; the target stays selected) |
|
||||||
|
| Right click (nothing open) | Clear the focused person and return to aim-based focus |
|
||||||
|
| Shift (hold) | Precision walk at roughly half speed for close work at the door |
|
||||||
| 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 |
|
||||||
|
|
||||||
@@ -259,17 +384,36 @@ 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: **88 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)
|
- Headless unit tests: **187 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material)
|
||||||
- Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event
|
- Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event
|
||||||
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
|
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
|
||||||
- Control pass: focus cone on NPC heads with a billboarded gold ring and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`)
|
- Control pass: focus cone on NPC heads with a billboarded blue circle in front of the face and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`)
|
||||||
- Session shell: venue start menu (keyboard + mouse), Esc pause overlay (resume / restart / settings / quit to menu), settings panel with mouse-look sensitivity and HUD font scale persisted to `user://settings.json`, and "Run another shift" returning to the venue menu
|
- Session shell: venue start menu (keyboard + mouse), Esc pause overlay (resume / restart / settings / quit to menu), settings panel with mouse-look sensitivity and HUD font scale persisted to `user://settings.json`, and "Run another shift" returning to the venue menu
|
||||||
- World presentation: skyline backdrop rendered with an alpha material, walkable step and landing colliders, larger marquee text, and larger focus ring / name tag
|
- World presentation: skyline backdrop rendered with an alpha material, walkable step and landing colliders, larger marquee text, and larger focus circle / name tag
|
||||||
- Routine traffic despawns after a while at its waypoint so the crowd cap no longer fills with idle standers
|
- 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)
|
||||||
|
- Editor-ready venues: `tools/export_venues.gd` writes `content/venues/<id>.tres` from `ScenarioFactory`, and the `.tres` files load back with events, profiles, policy, phases, door lines, and locations intact (the factory stays the source of truth)
|
||||||
|
- Localization safety: `Strings.fmt` validates the placeholder count before formatting and fails safe (warns, returns the template) instead of raising an engine error, `Strings.placeholder_count` ignores `%%`, and `Strings.validate()` lints the table for trailing `%`, unsupported conversions, and mixed positional/sequential placeholders
|
||||||
|
- Authored scenario content: Joke Shop reservation-without-ID, borrowed/mismatched ID, a guest who refuses the wand screen, and a late arrival during a performance; Studio filming-boundary repeats and a celebrity arrival that draws a small crowd; Building events that point the player at the body camera and request-reason verbs
|
||||||
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
|
- 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)
|
||||||
|
- Rendering pass (v0.3.0 work, [ADR-0012](docs/adr/0012-surface-materials-and-generated-props.md)): one material module with roughness/metallic profiles per surface, ACES tonemapping, glow, aerial fog and colour grading driven by the shift phase, correctly scaled brick, framed facade windows that light up at night, door trim and handles, a curb and lane dashes, and the glowing marquee/lamp bulbs. NPCs gained per-role clothing palettes, caps/hoods/backpacks/sunglasses/lanyards, hands and shoes, and an arm/leg walk swing
|
||||||
|
- Prop collision and placement (v0.4.0): every prop fits a static collider from its mesh bounds and publishes a navigation obstacle, so the guard and guests cannot walk through benches, bins, or bollards; placements are grouped into zones with a validator, and deterministic variants add rotation, scale, and small clutter
|
||||||
|
- Prop atlas (v0.3.1): `tools/model_pipeline.py` packs each prop's palette into one power-of-two colour atlas plus a metallic/roughness atlas and emits a single material with per-vertex UVs, so a prop is one draw material rather than one per part; `audit` enforces the contract
|
||||||
|
- Generated prop models (v0.3.0 work): `tools/model_pipeline.py` builds bollards, benches, bins, planters, newspaper boxes and a hydrant as `.glb` props with per-part materials, loaded by `WorldBuilder` with a procedural fallback; the optional AI concept step (`model_pipeline.py concept`) supplies per-prop colour palettes
|
||||||
|
- Web build: `build/step-line-web-<version>.zip` (Godot's non-threaded web template, no COOP/COEP headers needed) built by `tools/build_releases.sh` and published with the desktop artifacts; serve the unzipped folder from any static host
|
||||||
|
- Aim-free selection pass (v0.3.0 work, [ADR-0011](docs/adr/0011-aim-free-selection-and-precision-walk.md)): the focus cone ranks candidates by angle then distance, `Tab` and the mouse wheel cycle nearby people nearest-first and pin the selection, right click clears focus with a short re-acquire grace, the HUD reports how many people are in the ring, and holding `Shift` walks at half speed for close work
|
||||||
|
|
||||||
Not yet implemented: body-camera replay timeline, eye-contact and request-reason verbs, crowd rubbernecking, emergency aftermath, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
|
Not yet implemented: live 3D body-camera replay, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
|
||||||
|
|
||||||
### Smoke Test Checklist
|
### Smoke Test Checklist
|
||||||
|
|
||||||
@@ -281,7 +425,7 @@ Not yet implemented: body-camera replay timeline, eye-contact and request-reason
|
|||||||
6. The skyline backdrop shows silhouettes against the sky, not an opaque black slab.
|
6. The skyline backdrop shows silhouettes against the sky, not an opaque black slab.
|
||||||
7. The `STEP LINE` marquee is readable from the sidewalk.
|
7. The `STEP LINE` marquee is readable from the sidewalk.
|
||||||
8. NPCs spawn and approach the entrance during the first minutes.
|
8. NPCs spawn and approach the entrance during the first minutes.
|
||||||
9. Looking at an NPC inside the focus cone shows a gold head ring, the person's name, and the HUD target line.
|
9. Looking at an NPC inside the focus cone shows a blue focus circle in front of the face, the person's name, and the HUD target line.
|
||||||
10. `Space` or left click opens the action wheel; `A`/`D` highlight a slice, `Space` confirms it, `Esc`/right click cancels without a log entry.
|
10. `Space` or left click opens the action wheel; `A`/`D` highlight a slice, `Space` confirms it, `Esc`/right click cancels without a log entry.
|
||||||
11. `E`/`C` open the inspection/wand panels from the wheel and freeze movement until they close; walking never fires the wand.
|
11. `E`/`C` open the inspection/wand panels from the wheel and freeze movement until they close; walking never fires the wand.
|
||||||
12. Pressing `Q` produces a reaction log entry and reputation feedback.
|
12. Pressing `Q` produces a reaction log entry and reputation feedback.
|
||||||
@@ -290,9 +434,22 @@ Not yet implemented: body-camera replay timeline, eye-contact and request-reason
|
|||||||
15. `R` opens the emergency form; the button must be held ~1.6s or the call cancels.
|
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).
|
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.
|
17. `F` toggles the debug overlay with NPC states and shift stats.
|
||||||
18. `Esc` with nothing open pauses the shift; resume / restart / settings / quit-to-menu all work, and settings changes persist across a restart.
|
18. The wheel's `Look` slice is eye contact (Level 1) and logs as its own action.
|
||||||
19. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run).
|
19. `Reason` asks why the person is there and logs their `dialogue_lines` reply.
|
||||||
20. When the shift timer reaches 0, the shift review screen shows grade, incident summary, and persisted shift history; "Run another shift" returns to the venue menu.
|
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,94 +1,94 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
Godot 4.3 prototype. Release **v0.2.2** is tagged from the session-shell commit; CI builds the
|
Godot 4.3 prototype. Latest release **v0.4.0** (prop collision, placement, variants). Headless
|
||||||
installers and AppImage. Headless suite **88/88**, plus `smoke_session_shell.gd` and the windowed
|
suite **187/187** plus **53** Python tooling tests; the session, PLAN-verb, crowd, emergency,
|
||||||
focus/radial smoke.
|
timeline, audio, scenario-content, props, and windowed focus/radial smokes pass. Anonymous
|
||||||
|
downloads are enabled on the host.
|
||||||
|
|
||||||
Host `REQUIRE_SIGNIN_VIEW` stays deferred.
|
The sections below are the rest of the presentation pass: prop pipeline checks, NPC characters,
|
||||||
|
and player movement. Each task is meant to be a small, reviewable slice with its own verification.
|
||||||
|
|
||||||
## Remaining PLAN verbs (radial exists)
|
## Better Props
|
||||||
|
|
||||||
- [ ] Eye contact as a Level 1 action (PLAN / glossary). Distinct from raise-hand. Add a
|
### Pipeline and checks
|
||||||
radial slice if it ships.
|
|
||||||
- [ ] "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`).
|
|
||||||
- [ ] Formal warning can activate the body-camera / security-light flag (PLAN Level 2). Overlay
|
|
||||||
is currently always on; toggling should write an incident event.
|
|
||||||
- [ ] Blocking stations the guard between the person and the door (PLAN Level 3). Today it only
|
|
||||||
sends `apply_player_action(BLOCKING)`.
|
|
||||||
- [ ] "Request reason for entry" as a Level 3 ask, with a short NPC reply from `dialogue_lines`.
|
|
||||||
|
|
||||||
## Crowd presentation
|
- [ ] Extend `audit_glb()` with a triangle budget, degenerate-triangle test, `TEXCOORD_0` inside
|
||||||
|
the atlas, minimum-y ≈ 0, and a material-count check per prop.
|
||||||
|
- [ ] Add a `prop_gallery` capture to `capture_screenshots.gd` (each prop close-up on a plain
|
||||||
|
backdrop) and wire it to `screen_designs.py` so prop regressions show up in `compare`.
|
||||||
|
- [ ] Verify all prop instances share their imported material (no per-instance duplication) and
|
||||||
|
keep a documented triangle/vertex budget in the pipeline docstring.
|
||||||
|
- [ ] Update the README prop list and the `model_pipeline.py` catalogue docs when new props land.
|
||||||
|
|
||||||
`CrowdSystem` is a mood/attention float. PLAN wants people watching, gathering, and panicking.
|
## Better NPC Characters
|
||||||
|
|
||||||
- [ ] On `aggressive_behavior` / `emergency_responder`, nearby idle NPCs stop and face the
|
### Appearance and rig
|
||||||
incident (rubberneck). On `crowd_panic`, they move away from the door.
|
|
||||||
- [ ] Profile a busy entrance with textured materials and props; add LOD or culling for distant
|
|
||||||
NPCs if frame time drops.
|
|
||||||
|
|
||||||
## Emergency aftermath
|
- [ ] Replace the rigid capsule limbs with an articulated rig: shoulder, elbow, hip, and knee
|
||||||
|
joints, so a walk cycle can bend knees and swing arms instead of rotating whole capsules.
|
||||||
|
- [ ] Add layered clothing: jacket over shirt, hi-vis vest, security uniform, dress/skirt, and
|
||||||
|
coat, with deterministic per-profile colourways and material roles.
|
||||||
|
- [ ] Extend the accessory catalogue: bag, umbrella, phone, camera, clipboard, headphones, and a
|
||||||
|
coffee cup; attach them to the correct hand/back socket.
|
||||||
|
- [ ] Add face and hair variety: several hair silhouettes, beards, hats, eye/brow shapes, age and
|
||||||
|
height ranges, and a small set of face textures or decal planes so heads are not identical.
|
||||||
|
- [ ] Keep appearance deterministic per `(profile, seed)` and add a test that the same seed
|
||||||
|
produces the same outfit while different types stay recognisable.
|
||||||
|
|
||||||
Responder outcomes (`command` / `restraint` / `evacuation` / `arrest`) are log lines. PLAN also
|
### Animation and staging
|
||||||
wants evacuation, a closed street, and an investigation when the call was unjustified.
|
|
||||||
|
|
||||||
- [ ] `evacuation` outcome actually clears the entrance (NPCs leave, door stays locked).
|
- [ ] Add an idle layer: breathing, weight shift, occasional look-around, phone check, and
|
||||||
- [ ] Unjustified emergency (low danger, no weapon) writes an investigation note on the shift
|
arms-crossed, blended with the current walk and reaction states.
|
||||||
review and applies the existing false-report / reputation hit.
|
- [ ] Add conversation gestures: facing the guard when addressed, talking hand movement, nodding,
|
||||||
- [ ] Review copy names the responder outcome, not only battery and crowd %.
|
backing away, and pointing, driven by the existing reaction/escalation events.
|
||||||
|
- [ ] Improve the walk cycle: stride length tied to speed, foot planting without sliding, and a
|
||||||
|
distinct hurry/flee gait that matches `FLEE_SPEED`.
|
||||||
|
- [ ] Stage a door queue: waiting offsets, one-at-a-time approach, no overlapping capsules, and a
|
||||||
|
step-aside after the admission decision.
|
||||||
|
- [ ] Make reactions readable at a distance with body language plus a short icon/label
|
||||||
|
(rubberneck, panic, calm, angry) so crowd state is legible without the debug panel.
|
||||||
|
|
||||||
## Body-camera replay
|
### Performance and budgets
|
||||||
|
|
||||||
- [ ] Shift review: a timeline of recorded incident events (ADR-0005 already stores them).
|
- [ ] Add three LOD tiers (full rig, simplified mesh, billboard) with distance hysteresis, and
|
||||||
Scrub or step through; no live 3D replay required in the first version.
|
disable shadows for far tiers; keep the switch deterministic and testable.
|
||||||
|
- [ ] Re-measure the live-NPC budget with the full rig at `MAX_LIVE_NPCS`, and add a perf smoke
|
||||||
|
that fails when frame time crosses the documented budget.
|
||||||
|
- [ ] Add tests for animation state transitions (idle → walk → react) and for queue spacing at
|
||||||
|
the door.
|
||||||
|
|
||||||
## NPC theater
|
## Better Player Movement
|
||||||
|
|
||||||
Intents `sit` / `photo` / `film` / `perform` already pick waypoints. ADR-0010 rig (face/hair)
|
### Feel and handling
|
||||||
is in the working tree. Bodies still idle-stand at those spots.
|
|
||||||
|
|
||||||
- [ ] Sit pose at `steps_end`. Photo: brief raise of the influencer/tourist prop. Performer
|
- [ ] Split acceleration and deceleration in `guard_controller.gd` (faster stop than start, no
|
||||||
stays put and plays the hat/sign.
|
ice-skating), then tune `move_speed` and `accel` against a windowed feel pass.
|
||||||
|
- [ ] Add step-up assist for kerbs and the entrance steps (about 0.3 m) and keep wall sliding
|
||||||
|
smooth so the guard does not stick on doorframes, railings, or prop colliders.
|
||||||
|
- [ ] Add subtle head bob tied to stride and speed, a small FOV change in precision walk, and a
|
||||||
|
landing dip; expose a motion-sickness toggle in settings with persistence.
|
||||||
|
- [ ] Give precision walk (Shift) visible feedback: a HUD state chip and an audio tick when it
|
||||||
|
engages, in addition to the slower speed.
|
||||||
|
- [ ] Add surface-aware footsteps (concrete, asphalt, stone steps) with stride-synced timing and
|
||||||
|
a distinct landing sound, reusing the procedural audio helpers.
|
||||||
|
|
||||||
## Audio
|
### Input and accessibility
|
||||||
|
|
||||||
`AudioFeedback` is sine beeps. DONE called that "crowd ambience"; it is not.
|
- [ ] Add gamepad support: move/look actions, radial and action bindings, deadzone and look
|
||||||
|
sensitivity, with prompts that follow the active device.
|
||||||
|
- [ ] Move the remaining hard-coded keys into `InputMap` actions and persist any remaps through
|
||||||
|
the settings panel (keyboard and gamepad).
|
||||||
|
- [ ] Add a mouse-sensitivity and invert-Y setting if missing, and test the values survive a
|
||||||
|
settings save/load round trip.
|
||||||
|
- [ ] Add movement tests: acceleration ramp, step-up height limit, precision-walk speed, wall
|
||||||
|
slide, and gamepad deadzone.
|
||||||
|
- [ ] Run one windowed movement smoke per change above (walk the full block, climb the steps,
|
||||||
|
strafe along the wall) and capture a short screenshot set for the compare tool.
|
||||||
|
|
||||||
- [ ] Loop a low crowd bed that rises with `CrowdSystem.mood`.
|
## Future ideas (optional, not scheduled)
|
||||||
- [ ] Radio open/close crackle on the existing radio panel, still headless-safe (no-op without
|
|
||||||
an audio server).
|
|
||||||
|
|
||||||
## Accessibility
|
- [ ] Live 3D body-camera replay (the timeline is text-only today).
|
||||||
|
- [ ] Additional venues or seasonal events authored from the new `.tres` files.
|
||||||
- [ ] Escalation colors that still read in grayscale (shape or label, not hue alone).
|
- [ ] Real voice/dialogue audio (the crowd bed and radio crackle are procedural).
|
||||||
- [ ] HUD font scale in settings.
|
- [ ] Swap the primitive prop builder for an image-to-mesh model API behind the same
|
||||||
|
`assets/models/generated/<prop>.glb` contract.
|
||||||
## Scenario content still missing vs PLAN lists
|
|
||||||
|
|
||||||
- [ ] Joke Shop: guest on the reservation list without ID; borrowed/mismatched ID; guest
|
|
||||||
refuses wand screening; late arrival during a performance.
|
|
||||||
- [ ] Studio: filming-boundary repeat (influencer ignores a posted line); celebrity-arrival
|
|
||||||
crowd spike (more rubberneckers, not only one `arrive` spawn).
|
|
||||||
- [ ] Building: "activate body camera" and "request reason" once those verbs exist, used in
|
|
||||||
at least one authored event.
|
|
||||||
|
|
||||||
## Distribution follow-up (host config, deferred)
|
|
||||||
|
|
||||||
Verified via API+host credentials (2026-10-31):
|
|
||||||
|
|
||||||
- [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 +
|
|
||||||
AppImage).
|
|
||||||
- [ ] **Remaining host setting**: instance-wide `[service] REQUIRE_SIGNIN_VIEW=true` makes
|
|
||||||
anonymous browsing/downloads redirect to login. Host admin: `REQUIRE_SIGNIN_VIEW = false`
|
|
||||||
under `[service]` in `app.ini`, restart Gitea. Alternative: mirror downloads.
|
|
||||||
|
|
||||||
## Open Polish
|
|
||||||
|
|
||||||
- [ ] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
|
|
||||||
|
|
||||||
## Tooling (when an editor workflow exists)
|
|
||||||
|
|
||||||
- [ ] IDE/editor-based GUT integration. Until then the custom headless runner stays.
|
|
||||||
- [ ] Optional later: author venues as `.tres` resources instead of `ScenarioFactory` functions.
|
|
||||||
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 311 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 89 B |
|
After Width: | Height: | Size: 86 B |
|
After Width: | Height: | Size: 89 B |
|
After Width: | Height: | Size: 86 B |
|
After Width: | Height: | Size: 96 B |
|
After Width: | Height: | Size: 92 B |
|
After Width: | Height: | Size: 99 B |
|
After Width: | Height: | Size: 94 B |
|
After Width: | Height: | Size: 92 B |
|
After Width: | Height: | Size: 92 B |
|
After Width: | Height: | Size: 96 B |
|
After Width: | Height: | Size: 92 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"hud.controls": "LMB/Space action menu · A/D or arrows highlight · Space confirm · Esc/RMB cancel · R emergency · E inspect · C wand · Q polite · 1-6 shortcuts · Tab cycle · G light · L lock · V venue · 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/wheel cycle · Shift slow · G light · L lock · F debug",
|
||||||
"radial.raise_hand": "Stop",
|
"radial.raise_hand": "Stop",
|
||||||
|
"radial.eye_contact": "Look",
|
||||||
"radial.point": "Point",
|
"radial.point": "Point",
|
||||||
"radial.head_shake": "No",
|
"radial.head_shake": "No",
|
||||||
"radial.polite": "Ask",
|
"radial.polite": "Ask",
|
||||||
@@ -8,12 +9,14 @@
|
|||||||
"radial.wand": "Wand",
|
"radial.wand": "Wand",
|
||||||
"radial.warning": "Warn",
|
"radial.warning": "Warn",
|
||||||
"radial.block": "Block",
|
"radial.block": "Block",
|
||||||
|
"radial.reason": "Reason",
|
||||||
|
"radial.recognize": "Recognize",
|
||||||
"radial.radio": "Radio",
|
"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 or mouse wheel cycles).",
|
||||||
"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 (C) 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",
|
||||||
@@ -32,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.",
|
||||||
@@ -42,6 +48,24 @@
|
|||||||
"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.",
|
||||||
|
"hint.reservation_no_id": "On the reservation list but no ID — ask for ID, then check the list.",
|
||||||
|
"hint.borrowed_id": "The ID photo does not match the person. Verify before admitting.",
|
||||||
|
"hint.refuses_wand": "This guest refuses the wand screen. No screen, no entry.",
|
||||||
|
"hint.late_arrival": "Late arrival during the performance — check ID and ticket before opening the door.",
|
||||||
|
"hint.film_boundary": "Filming past the posted line. Ask their reason and point them back to the sidewalk.",
|
||||||
|
"hint.film_boundary_repeat": "Same crew again, still past the line. A formal warning is now reasonable.",
|
||||||
|
"hint.celebrity_arrival": "Celebrity arrival: the crowd is swelling. Keep the entrance clear.",
|
||||||
|
"hint.body_camera_reason": "They are filming the entrance. Ask their reason (Reason) and consider the body camera (B).",
|
||||||
|
"hint.ask_reason": "Unclear intent. Ask why they are here (Reason) before deciding anything else.",
|
||||||
|
"station.blocking": "You step between %s and the door.",
|
||||||
"perception.near": "%s notices you standing close.",
|
"perception.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.",
|
||||||
@@ -50,5 +74,17 @@
|
|||||||
"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",
|
||||||
|
"hud.nearby": "%d nearby · Tab or mouse wheel cycles targets"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,794 @@
|
|||||||
|
[gd_resource type="Resource" script_class="VenueScenario" load_steps=17 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_2u28i"]
|
||||||
|
[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_acokf"]
|
||||||
|
[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_mqiqi"]
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_i8fga"]
|
||||||
|
script = ExtResource("1_2u28i")
|
||||||
|
required_artifacts = {
|
||||||
|
1: true
|
||||||
|
}
|
||||||
|
grant_if_any_required = false
|
||||||
|
minimum_age = 0
|
||||||
|
require_id = false
|
||||||
|
wand_screening_required = false
|
||||||
|
policy_summary = "Private property. Entry requires recognition or a valid wristband."
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_c7lhe"]
|
||||||
|
script = ExtResource("2_acokf")
|
||||||
|
id = "tourist"
|
||||||
|
display_name = "Tourist"
|
||||||
|
npc_type = 0
|
||||||
|
temperament = 0.1
|
||||||
|
base_compliance = 0.9
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_0t6ar"]
|
||||||
|
script = ExtResource("2_acokf")
|
||||||
|
id = "confused_visitor"
|
||||||
|
display_name = "Confused Visitor"
|
||||||
|
npc_type = 3
|
||||||
|
temperament = 0.2
|
||||||
|
base_compliance = 0.85
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_wyv8p"]
|
||||||
|
script = ExtResource("2_acokf")
|
||||||
|
id = "delivery"
|
||||||
|
display_name = "Delivery Worker"
|
||||||
|
npc_type = 10
|
||||||
|
temperament = 0.25
|
||||||
|
base_compliance = 0.8
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["Where should I leave this?"])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_v2hc4"]
|
||||||
|
script = ExtResource("2_acokf")
|
||||||
|
id = "street_performer"
|
||||||
|
display_name = "Street Performer"
|
||||||
|
npc_type = 1
|
||||||
|
temperament = 0.45
|
||||||
|
base_compliance = 0.55
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 55,
|
||||||
|
2: 15,
|
||||||
|
3: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_lg4td"]
|
||||||
|
script = ExtResource("2_acokf")
|
||||||
|
id = "influencer"
|
||||||
|
display_name = "Influencer"
|
||||||
|
npc_type = 2
|
||||||
|
temperament = 0.5
|
||||||
|
base_compliance = 0.5
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 40,
|
||||||
|
2: 10,
|
||||||
|
3: 30,
|
||||||
|
4: 20
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 30,
|
||||||
|
2: 20,
|
||||||
|
4: 35,
|
||||||
|
8: 15
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 10,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 30,
|
||||||
|
8: 25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["This is public sidewalk, you can't stop me from filming."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_ijl2s"]
|
||||||
|
script = ExtResource("2_acokf")
|
||||||
|
id = "intoxicated"
|
||||||
|
display_name = "Intoxicated Pedestrian"
|
||||||
|
npc_type = 4
|
||||||
|
temperament = 0.6
|
||||||
|
base_compliance = 0.4
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 30,
|
||||||
|
2: 20,
|
||||||
|
3: 50
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 40,
|
||||||
|
2: 35,
|
||||||
|
3: 15,
|
||||||
|
4: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 1.8
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_eycmk"]
|
||||||
|
script = ExtResource("2_acokf")
|
||||||
|
id = "trespasser"
|
||||||
|
display_name = "Trespasser"
|
||||||
|
npc_type = 5
|
||||||
|
temperament = 0.55
|
||||||
|
base_compliance = 0.35
|
||||||
|
return_probability = 0.6
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 30,
|
||||||
|
2: 25,
|
||||||
|
3: 45
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 40,
|
||||||
|
2: 35,
|
||||||
|
6: 25
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
1: 35,
|
||||||
|
2: 25,
|
||||||
|
6: 15,
|
||||||
|
7: 10,
|
||||||
|
8: 15
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 70,
|
||||||
|
7: 30
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 50,
|
||||||
|
7: 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["I'm just here to see a friend, come on.", "I'll be inside for ten seconds."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_ggtpw"]
|
||||||
|
script = ExtResource("2_acokf")
|
||||||
|
id = "mental_crisis"
|
||||||
|
display_name = "Person in Crisis"
|
||||||
|
npc_type = 7
|
||||||
|
temperament = 0.7
|
||||||
|
base_compliance = 0.3
|
||||||
|
return_probability = 0.4
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 25,
|
||||||
|
2: 20,
|
||||||
|
3: 55
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 25,
|
||||||
|
2: 35,
|
||||||
|
3: 30,
|
||||||
|
5: 10
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 25,
|
||||||
|
1: 15,
|
||||||
|
2: 30,
|
||||||
|
5: 30
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 40,
|
||||||
|
5: 35,
|
||||||
|
7: 25
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 25,
|
||||||
|
5: 60,
|
||||||
|
7: 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 2.4
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_oa64f"]
|
||||||
|
script = ExtResource("2_acokf")
|
||||||
|
id = "violent_threat"
|
||||||
|
display_name = "Violent Threat"
|
||||||
|
npc_type = 6
|
||||||
|
temperament = 0.95
|
||||||
|
base_compliance = 0.05
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
2: 25,
|
||||||
|
3: 45,
|
||||||
|
5: 30
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
2: 30,
|
||||||
|
5: 45,
|
||||||
|
7: 25
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
1: 20,
|
||||||
|
5: 45,
|
||||||
|
7: 35
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 15,
|
||||||
|
5: 30,
|
||||||
|
7: 55
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_7t7dh"]
|
||||||
|
script = ExtResource("2_acokf")
|
||||||
|
id = "protester"
|
||||||
|
display_name = "Protester"
|
||||||
|
npc_type = 12
|
||||||
|
temperament = 0.5
|
||||||
|
base_compliance = 0.4
|
||||||
|
return_probability = 0.25
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 25,
|
||||||
|
2: 35,
|
||||||
|
3: 25,
|
||||||
|
4: 15
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 15,
|
||||||
|
2: 40,
|
||||||
|
3: 15,
|
||||||
|
4: 25,
|
||||||
|
8: 5
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 10,
|
||||||
|
1: 25,
|
||||||
|
2: 25,
|
||||||
|
4: 25,
|
||||||
|
8: 15
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 60,
|
||||||
|
2: 20,
|
||||||
|
7: 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["I have every right to be on public sidewalk.", "You can't touch me, this is a lawful protest."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_8tj1k"]
|
||||||
|
script = ExtResource("2_acokf")
|
||||||
|
id = "authorized_staff"
|
||||||
|
display_name = "Staff Member"
|
||||||
|
npc_type = 8
|
||||||
|
temperament = 0.15
|
||||||
|
base_compliance = 1.0
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_tnpxm"]
|
||||||
|
script = ExtResource("2_acokf")
|
||||||
|
id = "authorized_guest"
|
||||||
|
display_name = "Authorized Guest"
|
||||||
|
npc_type = 9
|
||||||
|
temperament = 0.2
|
||||||
|
base_compliance = 0.95
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 92,
|
||||||
|
2: 8
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 88,
|
||||||
|
2: 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("3_mqiqi")
|
||||||
|
id = "hollywood_building"
|
||||||
|
display_name = "Hollywood Boulevard Building"
|
||||||
|
description = "Steps and entrance of a private building off Hollywood Boulevard."
|
||||||
|
access_policy = SubResource("Resource_i8fga")
|
||||||
|
equipment = Array[String](["wristband_policy", "flashlight", "radio"])
|
||||||
|
locations = {
|
||||||
|
"entrance": Vector3(0, 0, 1),
|
||||||
|
"guard_post": Vector3(0, 0, -3),
|
||||||
|
"queue_area": Vector3(0, 0, 6),
|
||||||
|
"sidewalk": Vector3(0, 0, 9),
|
||||||
|
"steps_end": Vector3(0, 0, 3)
|
||||||
|
}
|
||||||
|
npc_profiles = Array[ExtResource("2_acokf")]([SubResource("Resource_c7lhe"), SubResource("Resource_0t6ar"), SubResource("Resource_wyv8p"), SubResource("Resource_v2hc4"), SubResource("Resource_lg4td"), SubResource("Resource_ijl2s"), SubResource("Resource_eycmk"), SubResource("Resource_ggtpw"), SubResource("Resource_oa64f"), SubResource("Resource_7t7dh"), SubResource("Resource_8tj1k"), SubResource("Resource_tnpxm")])
|
||||||
|
event_pool = Array[Dictionary]([{
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "tourist_photo",
|
||||||
|
"intent": "photo",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 0,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.42,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "protest_gather",
|
||||||
|
"intent": "protest",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 12,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.18,
|
||||||
|
"time_range_s": Vector2(300, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "tourist_sit",
|
||||||
|
"intent": "sit",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 0,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.26,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "confused_enter",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 3,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.25,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "delivery_drop",
|
||||||
|
"intent": "deliver",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 10,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.3,
|
||||||
|
"time_range_s": Vector2(0, 600)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "staff_entry",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 8,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.35,
|
||||||
|
"time_range_s": Vector2(0, 600)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "guest_entry",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 9,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.25,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "performer_show",
|
||||||
|
"intent": "perform",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 1,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.24,
|
||||||
|
"time_range_s": Vector2(300, 800)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"hint": "hint.body_camera_reason",
|
||||||
|
"id": "building_filming_boundary",
|
||||||
|
"intent": "film",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 2,
|
||||||
|
"phase": 1,
|
||||||
|
"probability": 0.4,
|
||||||
|
"time_range_s": Vector2(300, 600)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"hint": "hint.ask_reason",
|
||||||
|
"id": "building_unclear_intent",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 3,
|
||||||
|
"phase": 2,
|
||||||
|
"probability": 0.4,
|
||||||
|
"time_range_s": Vector2(600, 780)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "influencer_film",
|
||||||
|
"intent": "film",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 2,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.26,
|
||||||
|
"time_range_s": Vector2(300, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "intoxicated_wander",
|
||||||
|
"intent": "wander",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 4,
|
||||||
|
"phase": 1,
|
||||||
|
"probability": 0.15,
|
||||||
|
"time_range_s": Vector2(600, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "trespass_test",
|
||||||
|
"intent": "trespass",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 5,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.16,
|
||||||
|
"time_range_s": Vector2(300, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "crisis_event",
|
||||||
|
"intent": "crisis",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 7,
|
||||||
|
"phase": 2,
|
||||||
|
"probability": 0.5,
|
||||||
|
"time_range_s": Vector2(640, 740)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "violent_emergency_event",
|
||||||
|
"intent": "threat",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 6,
|
||||||
|
"phase": 3,
|
||||||
|
"probability": 0.28,
|
||||||
|
"requires_emergency": true,
|
||||||
|
"time_range_s": Vector2(760, 880)
|
||||||
|
}])
|
||||||
|
shift_phases = {
|
||||||
|
0: {
|
||||||
|
"start_s": 0.0
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
"start_s": 300.0
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
"start_s": 600.0
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
"start_s": 700.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shift_duration_s = 900.0
|
||||||
|
policy_summary = ""
|
||||||
|
door_staff_lines = {}
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
[gd_resource type="Resource" script_class="VenueScenario" load_steps=13 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_lxsgk"]
|
||||||
|
[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_wfc6m"]
|
||||||
|
[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_8xm7a"]
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_xha77"]
|
||||||
|
script = ExtResource("1_lxsgk")
|
||||||
|
required_artifacts = {
|
||||||
|
2: true,
|
||||||
|
3: true
|
||||||
|
}
|
||||||
|
grant_if_any_required = false
|
||||||
|
minimum_age = 21
|
||||||
|
require_id = true
|
||||||
|
wand_screening_required = true
|
||||||
|
policy_summary = "Government ID and a valid ticket or reservation required. Carry items are screened."
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_8ksyk"]
|
||||||
|
script = ExtResource("2_wfc6m")
|
||||||
|
id = "tourist"
|
||||||
|
display_name = "Tourist"
|
||||||
|
npc_type = 0
|
||||||
|
temperament = 0.1
|
||||||
|
base_compliance = 0.9
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_wt1op"]
|
||||||
|
script = ExtResource("2_wfc6m")
|
||||||
|
id = "confused_visitor"
|
||||||
|
display_name = "Confused Visitor"
|
||||||
|
npc_type = 3
|
||||||
|
temperament = 0.2
|
||||||
|
base_compliance = 0.85
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_g1jr2"]
|
||||||
|
script = ExtResource("2_wfc6m")
|
||||||
|
id = "influencer"
|
||||||
|
display_name = "Influencer"
|
||||||
|
npc_type = 2
|
||||||
|
temperament = 0.5
|
||||||
|
base_compliance = 0.5
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 40,
|
||||||
|
2: 10,
|
||||||
|
3: 30,
|
||||||
|
4: 20
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 30,
|
||||||
|
2: 20,
|
||||||
|
4: 35,
|
||||||
|
8: 15
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 10,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 30,
|
||||||
|
8: 25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["This is public sidewalk, you can't stop me from filming."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_ys0b0"]
|
||||||
|
script = ExtResource("2_wfc6m")
|
||||||
|
id = "intoxicated"
|
||||||
|
display_name = "Intoxicated Pedestrian"
|
||||||
|
npc_type = 4
|
||||||
|
temperament = 0.6
|
||||||
|
base_compliance = 0.4
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 30,
|
||||||
|
2: 20,
|
||||||
|
3: 50
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 40,
|
||||||
|
2: 35,
|
||||||
|
3: 15,
|
||||||
|
4: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 1.8
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_bv0jw"]
|
||||||
|
script = ExtResource("2_wfc6m")
|
||||||
|
id = "trespasser"
|
||||||
|
display_name = "Trespasser"
|
||||||
|
npc_type = 5
|
||||||
|
temperament = 0.55
|
||||||
|
base_compliance = 0.35
|
||||||
|
return_probability = 0.6
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 30,
|
||||||
|
2: 25,
|
||||||
|
3: 45
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 40,
|
||||||
|
2: 35,
|
||||||
|
6: 25
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
1: 35,
|
||||||
|
2: 25,
|
||||||
|
6: 15,
|
||||||
|
7: 10,
|
||||||
|
8: 15
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 70,
|
||||||
|
7: 30
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 50,
|
||||||
|
7: 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["I'm just here to see a friend, come on.", "I'll be inside for ten seconds."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_7vgx3"]
|
||||||
|
script = ExtResource("2_wfc6m")
|
||||||
|
id = "violent_threat"
|
||||||
|
display_name = "Violent Threat"
|
||||||
|
npc_type = 6
|
||||||
|
temperament = 0.95
|
||||||
|
base_compliance = 0.05
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
2: 25,
|
||||||
|
3: 45,
|
||||||
|
5: 30
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
2: 30,
|
||||||
|
5: 45,
|
||||||
|
7: 25
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
1: 20,
|
||||||
|
5: 45,
|
||||||
|
7: 35
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 15,
|
||||||
|
5: 30,
|
||||||
|
7: 55
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_x43ed"]
|
||||||
|
script = ExtResource("2_wfc6m")
|
||||||
|
id = "authorized_staff"
|
||||||
|
display_name = "Staff Member"
|
||||||
|
npc_type = 8
|
||||||
|
temperament = 0.15
|
||||||
|
base_compliance = 1.0
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_07lsg"]
|
||||||
|
script = ExtResource("2_wfc6m")
|
||||||
|
id = "authorized_guest"
|
||||||
|
display_name = "Authorized Guest"
|
||||||
|
npc_type = 9
|
||||||
|
temperament = 0.2
|
||||||
|
base_compliance = 0.95
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 92,
|
||||||
|
2: 8
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 88,
|
||||||
|
2: 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("3_8xm7a")
|
||||||
|
id = "joke_shop"
|
||||||
|
display_name = "The Joke Shop"
|
||||||
|
description = "Comedy club door: IDs, reservations, and wand screening."
|
||||||
|
access_policy = SubResource("Resource_xha77")
|
||||||
|
equipment = Array[String](["id_check", "guest_list", "wand", "radio"])
|
||||||
|
locations = {
|
||||||
|
"door_area": Vector3(0, 0, 3),
|
||||||
|
"entrance": Vector3(0, 0, 1),
|
||||||
|
"guard_post": Vector3(0, 0, -3),
|
||||||
|
"queue_area": Vector3(0, 0, 9),
|
||||||
|
"sidewalk": Vector3(0, 0, 9),
|
||||||
|
"steps_end": Vector3(0, 0, 3)
|
||||||
|
}
|
||||||
|
npc_profiles = Array[ExtResource("2_wfc6m")]([SubResource("Resource_8ksyk"), SubResource("Resource_wt1op"), SubResource("Resource_g1jr2"), SubResource("Resource_ys0b0"), SubResource("Resource_bv0jw"), SubResource("Resource_7vgx3"), SubResource("Resource_x43ed"), SubResource("Resource_07lsg")])
|
||||||
|
event_pool = Array[Dictionary]([{
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "guest_valid",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 9,
|
||||||
|
"override_access": {
|
||||||
|
"has_id": true,
|
||||||
|
"has_ticket": true
|
||||||
|
},
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.3,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "guest_no_ticket",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 9,
|
||||||
|
"override_access": {
|
||||||
|
"has_id": true,
|
||||||
|
"has_ticket": false
|
||||||
|
},
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.18,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "guest_expired_id",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 9,
|
||||||
|
"override_access": {
|
||||||
|
"has_id": true,
|
||||||
|
"has_ticket": true,
|
||||||
|
"id_status": 1
|
||||||
|
},
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.12,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "guest_no_id",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 9,
|
||||||
|
"override_access": {
|
||||||
|
"has_id": false,
|
||||||
|
"has_ticket": true
|
||||||
|
},
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.12,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"hint": "hint.reservation_no_id",
|
||||||
|
"id": "joke_guest_reservation_no_id",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 9,
|
||||||
|
"override_access": {
|
||||||
|
"guest_list_status": 0,
|
||||||
|
"has_guest_list": true,
|
||||||
|
"has_id": false,
|
||||||
|
"has_ticket": false
|
||||||
|
},
|
||||||
|
"phase": 2,
|
||||||
|
"probability": 0.45,
|
||||||
|
"time_range_s": Vector2(620, 860)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"hint": "hint.borrowed_id",
|
||||||
|
"id": "joke_borrowed_id",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 9,
|
||||||
|
"override_access": {
|
||||||
|
"has_id": true,
|
||||||
|
"has_ticket": true,
|
||||||
|
"id_status": 3,
|
||||||
|
"ticket_status": 0
|
||||||
|
},
|
||||||
|
"phase": 1,
|
||||||
|
"probability": 0.4,
|
||||||
|
"time_range_s": Vector2(320, 700)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"hint": "hint.refuses_wand",
|
||||||
|
"id": "joke_refuses_wand",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 9,
|
||||||
|
"override_access": {
|
||||||
|
"has_id": true,
|
||||||
|
"has_ticket": true,
|
||||||
|
"id_status": 0,
|
||||||
|
"ticket_status": 0
|
||||||
|
},
|
||||||
|
"phase": 2,
|
||||||
|
"probability": 0.4,
|
||||||
|
"refuses_screening": true,
|
||||||
|
"time_range_s": Vector2(650, 880)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"hint": "hint.late_arrival",
|
||||||
|
"id": "joke_late_arrival",
|
||||||
|
"intent": "arrive",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 9,
|
||||||
|
"override_access": {
|
||||||
|
"has_id": true,
|
||||||
|
"has_ticket": true,
|
||||||
|
"id_status": 0,
|
||||||
|
"ticket_status": 0
|
||||||
|
},
|
||||||
|
"phase": 2,
|
||||||
|
"probability": 0.35,
|
||||||
|
"time_range_s": Vector2(700, 860)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "joke_staff",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 8,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.25,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "performer_show",
|
||||||
|
"intent": "perform",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 1,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.15,
|
||||||
|
"time_range_s": Vector2(300, 800)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "influencer_film",
|
||||||
|
"intent": "film",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 2,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.15,
|
||||||
|
"time_range_s": Vector2(300, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "joke_disruptive",
|
||||||
|
"intent": "force_entry",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 4,
|
||||||
|
"phase": 2,
|
||||||
|
"probability": 0.5,
|
||||||
|
"time_range_s": Vector2(650, 830)
|
||||||
|
}])
|
||||||
|
shift_phases = {
|
||||||
|
0: {
|
||||||
|
"start_s": 0.0
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
"start_s": 300.0
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
"start_s": 600.0
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
"start_s": 700.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shift_duration_s = 900.0
|
||||||
|
policy_summary = ""
|
||||||
|
door_staff_lines = {
|
||||||
|
"id_check": ["May I see your ID, please?", "Could you show me some identification?"],
|
||||||
|
"prohibited_item": ["I can't allow that item inside. Please leave it outside.", "That item isn't permitted in the venue."],
|
||||||
|
"refusal": ["I'm sorry, I can't let you in without that.", "Without a valid ID, I can't admit you tonight."],
|
||||||
|
"ticket_check": ["Your ticket or reservation, please.", "Do you have a ticket for tonight's show?"],
|
||||||
|
"wand_check": ["Please step forward for the wand check.", "Arms out, please — just a quick screening."]
|
||||||
|
}
|
||||||
@@ -0,0 +1,810 @@
|
|||||||
|
[gd_resource type="Resource" script_class="VenueScenario" load_steps=17 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_xxhx3"]
|
||||||
|
[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_fiebn"]
|
||||||
|
[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_2dvuq"]
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_blmt0"]
|
||||||
|
script = ExtResource("1_xxhx3")
|
||||||
|
required_artifacts = {
|
||||||
|
1: true,
|
||||||
|
3: true,
|
||||||
|
4: true,
|
||||||
|
5: true
|
||||||
|
}
|
||||||
|
grant_if_any_required = true
|
||||||
|
minimum_age = 0
|
||||||
|
require_id = false
|
||||||
|
wand_screening_required = false
|
||||||
|
policy_summary = "Entry requires a wristband, credential, ticket, or guest-list confirmation."
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_oddac"]
|
||||||
|
script = ExtResource("2_fiebn")
|
||||||
|
id = "tourist"
|
||||||
|
display_name = "Tourist"
|
||||||
|
npc_type = 0
|
||||||
|
temperament = 0.1
|
||||||
|
base_compliance = 0.9
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_byir2"]
|
||||||
|
script = ExtResource("2_fiebn")
|
||||||
|
id = "confused_visitor"
|
||||||
|
display_name = "Confused Visitor"
|
||||||
|
npc_type = 3
|
||||||
|
temperament = 0.2
|
||||||
|
base_compliance = 0.85
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_3tt0b"]
|
||||||
|
script = ExtResource("2_fiebn")
|
||||||
|
id = "delivery"
|
||||||
|
display_name = "Delivery Worker"
|
||||||
|
npc_type = 10
|
||||||
|
temperament = 0.25
|
||||||
|
base_compliance = 0.8
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["Where should I leave this?"])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_ihxib"]
|
||||||
|
script = ExtResource("2_fiebn")
|
||||||
|
id = "street_performer"
|
||||||
|
display_name = "Street Performer"
|
||||||
|
npc_type = 1
|
||||||
|
temperament = 0.45
|
||||||
|
base_compliance = 0.55
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 55,
|
||||||
|
2: 15,
|
||||||
|
3: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_c57iy"]
|
||||||
|
script = ExtResource("2_fiebn")
|
||||||
|
id = "influencer"
|
||||||
|
display_name = "Influencer"
|
||||||
|
npc_type = 2
|
||||||
|
temperament = 0.5
|
||||||
|
base_compliance = 0.5
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 40,
|
||||||
|
2: 10,
|
||||||
|
3: 30,
|
||||||
|
4: 20
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 30,
|
||||||
|
2: 20,
|
||||||
|
4: 35,
|
||||||
|
8: 15
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 10,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 30,
|
||||||
|
8: 25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["This is public sidewalk, you can't stop me from filming."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_bk410"]
|
||||||
|
script = ExtResource("2_fiebn")
|
||||||
|
id = "intoxicated"
|
||||||
|
display_name = "Intoxicated Pedestrian"
|
||||||
|
npc_type = 4
|
||||||
|
temperament = 0.6
|
||||||
|
base_compliance = 0.4
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 30,
|
||||||
|
2: 20,
|
||||||
|
3: 50
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 40,
|
||||||
|
2: 35,
|
||||||
|
3: 15,
|
||||||
|
4: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 1.8
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_jp52w"]
|
||||||
|
script = ExtResource("2_fiebn")
|
||||||
|
id = "trespasser"
|
||||||
|
display_name = "Trespasser"
|
||||||
|
npc_type = 5
|
||||||
|
temperament = 0.55
|
||||||
|
base_compliance = 0.35
|
||||||
|
return_probability = 0.6
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 30,
|
||||||
|
2: 25,
|
||||||
|
3: 45
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 40,
|
||||||
|
2: 35,
|
||||||
|
6: 25
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
1: 35,
|
||||||
|
2: 25,
|
||||||
|
6: 15,
|
||||||
|
7: 10,
|
||||||
|
8: 15
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 70,
|
||||||
|
7: 30
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 50,
|
||||||
|
7: 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["I'm just here to see a friend, come on.", "I'll be inside for ten seconds."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_5kplf"]
|
||||||
|
script = ExtResource("2_fiebn")
|
||||||
|
id = "mental_crisis"
|
||||||
|
display_name = "Person in Crisis"
|
||||||
|
npc_type = 7
|
||||||
|
temperament = 0.7
|
||||||
|
base_compliance = 0.3
|
||||||
|
return_probability = 0.4
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 25,
|
||||||
|
2: 20,
|
||||||
|
3: 55
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 25,
|
||||||
|
2: 35,
|
||||||
|
3: 30,
|
||||||
|
5: 10
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 25,
|
||||||
|
1: 15,
|
||||||
|
2: 30,
|
||||||
|
5: 30
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 40,
|
||||||
|
5: 35,
|
||||||
|
7: 25
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 25,
|
||||||
|
5: 60,
|
||||||
|
7: 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 2.4
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_6bscs"]
|
||||||
|
script = ExtResource("2_fiebn")
|
||||||
|
id = "violent_threat"
|
||||||
|
display_name = "Violent Threat"
|
||||||
|
npc_type = 6
|
||||||
|
temperament = 0.95
|
||||||
|
base_compliance = 0.05
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
2: 25,
|
||||||
|
3: 45,
|
||||||
|
5: 30
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
2: 30,
|
||||||
|
5: 45,
|
||||||
|
7: 25
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
1: 20,
|
||||||
|
5: 45,
|
||||||
|
7: 35
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 15,
|
||||||
|
5: 30,
|
||||||
|
7: 55
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_n4lxh"]
|
||||||
|
script = ExtResource("2_fiebn")
|
||||||
|
id = "protester"
|
||||||
|
display_name = "Protester"
|
||||||
|
npc_type = 12
|
||||||
|
temperament = 0.5
|
||||||
|
base_compliance = 0.4
|
||||||
|
return_probability = 0.25
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 25,
|
||||||
|
2: 35,
|
||||||
|
3: 25,
|
||||||
|
4: 15
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 15,
|
||||||
|
2: 40,
|
||||||
|
3: 15,
|
||||||
|
4: 25,
|
||||||
|
8: 5
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 10,
|
||||||
|
1: 25,
|
||||||
|
2: 25,
|
||||||
|
4: 25,
|
||||||
|
8: 15
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 60,
|
||||||
|
2: 20,
|
||||||
|
7: 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["I have every right to be on public sidewalk.", "You can't touch me, this is a lawful protest."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_6st6l"]
|
||||||
|
script = ExtResource("2_fiebn")
|
||||||
|
id = "authorized_staff"
|
||||||
|
display_name = "Staff Member"
|
||||||
|
npc_type = 8
|
||||||
|
temperament = 0.15
|
||||||
|
base_compliance = 1.0
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_nf2oq"]
|
||||||
|
script = ExtResource("2_fiebn")
|
||||||
|
id = "authorized_guest"
|
||||||
|
display_name = "Authorized Guest"
|
||||||
|
npc_type = 9
|
||||||
|
temperament = 0.2
|
||||||
|
base_compliance = 0.95
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 92,
|
||||||
|
2: 8
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 88,
|
||||||
|
2: 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("3_2dvuq")
|
||||||
|
id = "tv_studio"
|
||||||
|
display_name = "Hollywood Blvd Late Night TV Studio"
|
||||||
|
description = "Crowd, celebrities, and staff around a late-night studio."
|
||||||
|
access_policy = SubResource("Resource_blmt0")
|
||||||
|
equipment = Array[String](["wristband_policy", "guest_list", "radio", "flashlight"])
|
||||||
|
locations = {
|
||||||
|
"entrance": Vector3(0, 0, 1),
|
||||||
|
"guard_post": Vector3(0, 0, -3),
|
||||||
|
"queue_area": Vector3(0, 0, 9),
|
||||||
|
"sidewalk": Vector3(0, 0, 9),
|
||||||
|
"stage_door": Vector3(6, 0, -2),
|
||||||
|
"steps_end": Vector3(0, 0, 3)
|
||||||
|
}
|
||||||
|
npc_profiles = Array[ExtResource("2_fiebn")]([SubResource("Resource_oddac"), SubResource("Resource_byir2"), SubResource("Resource_3tt0b"), SubResource("Resource_ihxib"), SubResource("Resource_c57iy"), SubResource("Resource_bk410"), SubResource("Resource_jp52w"), SubResource("Resource_5kplf"), SubResource("Resource_6bscs"), SubResource("Resource_n4lxh"), SubResource("Resource_6st6l"), SubResource("Resource_nf2oq")])
|
||||||
|
event_pool = Array[Dictionary]([{
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "audience_ticket",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 9,
|
||||||
|
"override_access": {
|
||||||
|
"has_ticket": true
|
||||||
|
},
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.35,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "protest_outside",
|
||||||
|
"intent": "protest",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 12,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.18,
|
||||||
|
"time_range_s": Vector2(300, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "celebrity_arrival",
|
||||||
|
"intent": "arrive",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 9,
|
||||||
|
"override_access": {
|
||||||
|
"has_guest_list": true
|
||||||
|
},
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.2,
|
||||||
|
"time_range_s": Vector2(300, 700)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "crew_shift",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 8,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.3,
|
||||||
|
"time_range_s": Vector2(0, 700)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "tourist_photo",
|
||||||
|
"intent": "photo",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 0,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.35,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "confused_enter",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 3,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.25,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "influencer_film",
|
||||||
|
"intent": "film",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 2,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.28,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "trespass_blend",
|
||||||
|
"intent": "blend_in",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 5,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.15,
|
||||||
|
"time_range_s": Vector2(300, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "studio_counterfeit",
|
||||||
|
"intent": "counterfeit_credential",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 5,
|
||||||
|
"override_access": {
|
||||||
|
"credential_status": 2,
|
||||||
|
"wristband_status": 2
|
||||||
|
},
|
||||||
|
"phase": 2,
|
||||||
|
"probability": 0.55,
|
||||||
|
"time_range_s": Vector2(620, 780)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"hint": "hint.film_boundary",
|
||||||
|
"id": "studio_film_boundary",
|
||||||
|
"intent": "film_boundary",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 2,
|
||||||
|
"phase": 1,
|
||||||
|
"probability": 0.5,
|
||||||
|
"time_range_s": Vector2(330, 560)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"hint": "hint.film_boundary_repeat",
|
||||||
|
"id": "studio_film_boundary_repeat",
|
||||||
|
"intent": "film_boundary",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 2,
|
||||||
|
"phase": 2,
|
||||||
|
"probability": 0.45,
|
||||||
|
"time_range_s": Vector2(640, 830)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"extra_spawns": [{
|
||||||
|
"intent": "photo",
|
||||||
|
"npc_type": 0
|
||||||
|
}, {
|
||||||
|
"intent": "photo",
|
||||||
|
"npc_type": 0
|
||||||
|
}, {
|
||||||
|
"intent": "film",
|
||||||
|
"npc_type": 2
|
||||||
|
}],
|
||||||
|
"hint": "hint.celebrity_arrival",
|
||||||
|
"id": "studio_celebrity_arrival",
|
||||||
|
"intent": "arrive",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 9,
|
||||||
|
"override_access": {
|
||||||
|
"guest_list_status": 0,
|
||||||
|
"has_guest_list": true
|
||||||
|
},
|
||||||
|
"phase": 2,
|
||||||
|
"probability": 0.5,
|
||||||
|
"time_range_s": Vector2(620, 760)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "studio_follow_through",
|
||||||
|
"intent": "follow_authorized",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 5,
|
||||||
|
"override_access": {
|
||||||
|
"has_credential": false,
|
||||||
|
"has_guest_list": false,
|
||||||
|
"has_ticket": false,
|
||||||
|
"has_wristband": false
|
||||||
|
},
|
||||||
|
"phase": 2,
|
||||||
|
"probability": 0.45,
|
||||||
|
"time_range_s": Vector2(700, 860)
|
||||||
|
}])
|
||||||
|
shift_phases = {
|
||||||
|
0: {
|
||||||
|
"start_s": 0.0
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
"start_s": 300.0
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
"start_s": 600.0
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
"start_s": 700.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shift_duration_s = 900.0
|
||||||
|
policy_summary = ""
|
||||||
|
door_staff_lines = {}
|
||||||
@@ -37,8 +37,30 @@ The repository ships a Gitea Actions workflow (`.gitea/workflows/build.yml`):
|
|||||||
Linux/Windows/macOS, artifact upload, and — on tags — release publication via
|
Linux/Windows/macOS, artifact upload, and — on tags — release publication via
|
||||||
`tools/publish_release.sh`.
|
`tools/publish_release.sh`.
|
||||||
|
|
||||||
For tag builds, set a repo secret named **`GITEA_TOKEN`** (write:repository scope).
|
`tools/publish_release.sh` picks the first available credential, in order:
|
||||||
Without it the workflow still builds and tests, but the release publish step fails.
|
|
||||||
|
1. `GITEA_TOKEN` (repo secret, write:repository scope),
|
||||||
|
2. `GITEA_AUTH_HEADER` (a ready-made `Authorization: ...` header),
|
||||||
|
3. `~/.git-credentials` for the host,
|
||||||
|
4. the **local git extraheader** that `actions/checkout` configures on the CI job.
|
||||||
|
|
||||||
|
Option 4 is what makes tag builds publish without any repo secret: the runner
|
||||||
|
already injects basic-auth credentials for the checkout, and the script reuses that
|
||||||
|
header for the API (same server). `github.token` is *not* available in this Gitea
|
||||||
|
setup, so do not rely on it.
|
||||||
|
|
||||||
|
> **Disk space:** Gitea stores release assets under `/var/lib/gitea/data/attachments` on
|
||||||
|
> the host root filesystem (7.8 GB here). When it is full, asset uploads fail with
|
||||||
|
> `HTTP 500 … no space left on device` and even `git push` can fail with "unpacker error".
|
||||||
|
> `publish_release.sh` now verifies the asset count and exits non-zero, so this is visible.
|
||||||
|
>
|
||||||
|
> **Incident (2026-10-31):** `tools/publish_release.sh` never set `AUTH` in its
|
||||||
|
> `~/.git-credentials` branch, so every API call failed silently (`AUTH[@]` was
|
||||||
|
> unbound under `set -u`). Combined with no `GITEA_TOKEN` secret on the repo, tag
|
||||||
|
> builds from v0.2.2 to v0.2.8 produced installers but published nothing. The script
|
||||||
|
> is fixed (`AUTH=(-u "$CRED")`) and v0.2.9 was published locally with all five
|
||||||
|
> artifacts. If CI publishing fails again, run
|
||||||
|
> `./tools/publish_release.sh <version>` from a checkout with `~/.git-credentials`.
|
||||||
|
|
||||||
> **No `actions/cache` for now:** the runner has no local actions-cache service, so
|
> **No `actions/cache` for now:** the runner has no local actions-cache service, so
|
||||||
> `actions/cache` times out against GitHub's cache API. We dropped it (Godot import
|
> `actions/cache` times out against GitHub's cache API. We dropped it (Godot import
|
||||||
@@ -62,6 +84,8 @@ Artifacts are written to `build/` (all exports embed the PCK — single files):
|
|||||||
- `build/step-line-linux-<version>.x86_64`
|
- `build/step-line-linux-<version>.x86_64`
|
||||||
- `build/step-line-windows-<version>.exe`
|
- `build/step-line-windows-<version>.exe`
|
||||||
- `build/step-line-macos-<version>.zip`
|
- `build/step-line-macos-<version>.zip`
|
||||||
|
- `build/step-line-web-<version>.zip` — browser build (non-threaded Godot web
|
||||||
|
template; unzip and serve the folder, no COOP/COEP headers required)
|
||||||
|
|
||||||
When run in CI (tags/dispatch), the build job also packages:
|
When run in CI (tags/dispatch), the build job also packages:
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# ADR-0008: Cone focus and head ring
|
# ADR-0008: Cone focus and focus circle
|
||||||
|
|
||||||
- **Status:** Accepted
|
- **Status:** Accepted
|
||||||
- **Date:** TBD
|
- **Date:** TBD
|
||||||
@@ -19,7 +19,7 @@ Focus is a **cone**, not a ray.
|
|||||||
- A few frames of grace keep walk-bob from dropping focus.
|
- A few frames of grace keep walk-bob from dropping focus.
|
||||||
- `Tab` still cycles people inside 12 m and sets the same focused person.
|
- `Tab` still cycles people inside 12 m and sets the same focused person.
|
||||||
|
|
||||||
When someone is in focus, draw a **thin ring** around their head, attached to the avatar `Head` node, billboarded so it stays a circle on screen. Pale gold while idle; brighter and slightly larger while the action menu is open on that person. Optional `display_name` under the ring. One ring at a time.
|
When someone is in focus, draw a **blue, semi-transparent circle in front of the face**, attached to the avatar `Head` node, billboarded so it stays a circle on screen. Blue while idle; brighter and slightly larger while the action menu is open on that person. Optional `display_name` under the circle. One circle at a time.
|
||||||
|
|
||||||
The world must not show the internal type name (`Trespasser`, `Violent Threat`). The debug overlay (`F`) can still print it.
|
The world must not show the internal type name (`Trespasser`, `Violent Threat`). The debug overlay (`F`) can still print it.
|
||||||
|
|
||||||
@@ -28,19 +28,19 @@ The world must not show the internal type name (`Trespasser`, `Violent Threat`).
|
|||||||
### Positive
|
### Positive
|
||||||
|
|
||||||
- Looking near a person is enough; the player does not have to pin a capsule.
|
- Looking near a person is enough; the player does not have to pin a capsule.
|
||||||
- The ring is visible in the scene, so the HUD is not the only focus cue.
|
- The circle is visible in the scene, so the HUD is not the only focus cue.
|
||||||
- `Tab` remains the accessibility path and shares the same focused person.
|
- `Tab` remains the accessibility path and shares the same focused person.
|
||||||
- The action menu (ADR-0009) has a clear subject.
|
- The action menu (ADR-0009) has a clear subject.
|
||||||
|
|
||||||
### Negative
|
### Negative
|
||||||
|
|
||||||
- Two heads in the same circle can steal focus; center-distance is a heuristic, not a lock-on.
|
- Two heads in the same circle can steal focus; center-distance is a heuristic, not a lock-on.
|
||||||
- Hats and protester signs can z-fight a 3D torus. A screen-space circle unprojected from the head is an allowed fallback.
|
- Hats and protester signs can z-fight a 3D circle. A screen-space circle unprojected from the head is an allowed fallback.
|
||||||
- Occlusion rays can fail on unusual geometry; grace frames hide some of that.
|
- Occlusion rays can fail on unusual geometry; grace frames hide some of that.
|
||||||
|
|
||||||
## Alternatives considered
|
## Alternatives considered
|
||||||
|
|
||||||
- **Keep the hairline ray:** Rejected because it drops people too easily and has no world mark.
|
- **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.
|
- **Sticky lock-on until cancelled:** Rejected because the job is to watch a crowd, not duel one person.
|
||||||
- **Screen reticle only, no world ring:** Rejected; in a crowd the player needs a mark on the head, not only a HUD line.
|
- **Screen reticle only, no world circle:** Rejected; in a crowd the player needs a mark on the face, not only a HUD line.
|
||||||
- **Click a person to select, then act:** Rejected for default play. The mouse is captured for look (ADR-0007). Click opens the action menu on whoever is already in the cone.
|
- **Click a person to select, then act:** Rejected for default play. The mouse is captured for look (ADR-0007). Click opens the action menu on whoever is already in the cone.
|
||||||
@@ -31,7 +31,7 @@ While the menu is open, walk and look freeze (ADR-0007) and the cursor is visibl
|
|||||||
|
|
||||||
**Confirm:** click a slice, or `Space` on the highlighted slice. The menu closes, the mouse is recaptured, and that action runs. Inspect, wand, and radio assist close the wheel and then open the existing panels. Walk and look stay frozen until that panel closes.
|
**Confirm:** click a slice, or `Space` on the highlighted slice. The menu closes, the mouse is recaptured, and that action runs. Inspect, wand, and radio assist close the wheel and then open the existing panels. Walk and look stay frozen until that panel closes.
|
||||||
|
|
||||||
**Cancel:** `Esc` or right mouse button. The menu hides. No action runs. No recorder event. Focus and the head ring stay. The mouse is recaptured.
|
**Cancel:** `Esc` or right mouse button. The menu hides. No action runs. No recorder event. Focus and the focus circle stay. The mouse is recaptured.
|
||||||
|
|
||||||
Number keys `1`–`6` may remain as shortcuts when the wheel is closed, so existing smoke tests still work. They must not fire while the wheel is open.
|
Number keys `1`–`6` may remain as shortcuts when the wheel is closed, so existing smoke tests still work. They must not fire while the wheel is open.
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ Number keys `1`–`6` may remain as shortcuts when the wheel is closed, so exist
|
|||||||
## Alternatives considered
|
## 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.
|
- **Hold-to-open, release-to-confirm (GTA / Destiny):** Rejected. The requested input is click or Space to open, then click or Space to confirm, with Esc / RMB to abort. That is a toggle, not a hold.
|
||||||
- **Vertical list / command menu:** Rejected because a round layout around a focused person matches the head ring and reads faster in a crowd.
|
- **Vertical list / command menu:** Rejected because a round layout around a focused person matches the focus circle and reads faster in a crowd.
|
||||||
- **One context-sensitive Use key:** Rejected; the game is about choosing among levels, not a single default verb.
|
- **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.
|
- **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.
|
- **Drop number-key shortcuts immediately:** Deferred. They remain as a closed-menu fallback until the wheel is proven in play.
|
||||||
@@ -21,7 +21,7 @@ Make the body read as a person at conversation distance:
|
|||||||
- Shoes.
|
- Shoes.
|
||||||
- Hair cap whose color comes from the visual seed.
|
- Hair cap whose color comes from the visual seed.
|
||||||
- Two dark eye spheres so the head has a front.
|
- Two dark eye spheres so the head has a front.
|
||||||
- Named `Head` node (`Rig/Head`) as the attach point for the focus ring (ADR-0008) and the focus cone.
|
- Named `Head` node (`Rig/Head`) as the attach point for the focus circle (ADR-0008) and the focus cone.
|
||||||
- Collision capsule scaled to the visible height so the cone hits the body the player sees.
|
- 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.
|
Keep archetype props, height / brightness variation, walk bob, and the notice head-turn.
|
||||||
@@ -49,5 +49,5 @@ AI clothing textures stay out unless a map is already the right size and is wire
|
|||||||
|
|
||||||
- **Skinned Mixamo / GLB characters now:** Rejected. Cost and pipeline exceed this pass; the sim only needs a readable body and a head node.
|
- **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.
|
- **Billboard sprites:** Rejected; first-person movement walks around people, and sprites fail at profile views.
|
||||||
- **Keep colored capsules as-is:** Rejected; the head ring and cone need a real head, and capsules do not read as people at two metres.
|
- **Keep colored capsules as-is:** Rejected; the focus circle and cone need a real head, and capsules do not read as people at two metres.
|
||||||
- **World labels with archetype names:** Rejected because they leak sim truth and skip observation.
|
- **World labels with archetype names:** Rejected because they leak sim truth and skip observation.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# ADR-0011: Aim-free target selection, precision walk, and focus clearing
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-10-31
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-0007 and ADR-0008 split input into locomotion, look, and a focus cone. A pass over the resulting controls found four problems in play:
|
||||||
|
|
||||||
|
1. **The cone ignored what the player sees.** `NpcFocus.pick_index` returned the first head inside the cone in group order, not the one nearest the crosshair. Two people in the cone gave an arbitrary pick.
|
||||||
|
2. **`Tab` cycled in spawn order.** `GameManager._cycle_nearby_target` walked the `npcs` array, so "next" meant "next spawned", not "next nearest".
|
||||||
|
3. **A cycled target was dropped within a few frames.** The cone re-evaluated every physics frame and cleared a target that was not under the crosshair after the 8-frame miss grace. Aim-free selection therefore only worked while the player also aimed.
|
||||||
|
4. **There was no way to dismiss someone.** `Esc` pauses the shift and right click only cancelled modals.
|
||||||
|
|
||||||
|
The movement pass also left one gap at the door: the only walking speed is 3.2 m/s, which overshoots when the player is trying to stand at a register or read an artifact at conversation distance.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Rank the cone by angle, then distance.** `NpcFocus.rank_indices` scores every head by its angle from the camera forward vector and breaks ties by distance; `pick_index` is the first ranked entry. The cone therefore picks the person closest to the crosshair, which is what the player is looking at.
|
||||||
|
|
||||||
|
**Make aim-free selection authoritative.** `GuardController.cycle_target(direction)` walks `nearby_candidates()` — everyone within 12 m, nearest first — starting from the current target and wrapping in both directions. The chosen avatar is *pinned*: while a pinned target is alive the cone does not re-evaluate, so `Tab` and the mouse wheel produce a stable selection the player can then act on.
|
||||||
|
|
||||||
|
**Bind the mouse wheel to the same ring.** `cycle_target_next` (wheel down) and `cycle_target_prev` (wheel up) are new input actions handled in `GameManager._handle_mouse_button`. `Tab` remains the keyboard path. The HUD prints how many people are in the ring (`hud.nearby`), so the player knows the control exists before they need it.
|
||||||
|
|
||||||
|
**Right click clears focus, with a short grace.** With no modal open, right click clears the target and suppresses cone acquisition for `FOCUS_SUPPRESS_S = 0.75 s`. Without the grace the cone re-acquired the same person on the next frame, which made the control feel broken. Any explicit pick (cone or cycle) cancels the suppression.
|
||||||
|
|
||||||
|
**Add a precision walk.** Holding `Shift` (`walk_slow`) multiplies the walk speed by `SLOW_WALK_FACTOR = 0.45`. This is a fine-positioning aid, not a sprint: PLAN.md deliberately excludes sprint and crouch, and this does not add a faster move.
|
||||||
|
|
||||||
|
Escape keeps its current meaning (close the top modal, otherwise pause). Selection is not overloaded onto the same key.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- The focused person is predictable: the one nearest the crosshair, or the one the player explicitly cycled to.
|
||||||
|
- Aim-free play works: cycle, act, cycle again, without fighting the cone.
|
||||||
|
- Right click has an unambiguous "never mind" meaning while remaining a modal cancel.
|
||||||
|
- Standing on a mark while reading an artifact is possible.
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
- Two more input actions and one more piece of guard state (`target_pinned`, `focus_suppress_s`).
|
||||||
|
- A cycled target stays selected until the player clears it, acts, or the avatar is freed; it does not expire on its own.
|
||||||
|
- The 0.75 s suppression is a feel constant that may need tuning against play testing.
|
||||||
|
|
||||||
|
## Follow-up
|
||||||
|
|
||||||
|
- The action wheel (ADR-0009) and inspection panels still take `A`/`D`; the wheel owns those keys while it is open, and the guard is frozen then, so no conflict is observable.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# ADR-0012: Surface material profiles and generated prop models
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-10-31
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The procedural street was built from `BoxMesh` primitives with `StandardMaterial3D` defaults: albedo colour (or a texture) and everything else stock. Every surface therefore shared one roughness, street furniture was a set of scaled boxes, and the sky was a flat `ProceduralSkyMaterial` with no tonemapping, glow, or fog. Captures looked like a blockout: brick tiles were metres wide, the sidewalk blew out to white, the facade had no windows, and night mode only switched the lamp bulbs and the marquee.
|
||||||
|
|
||||||
|
Two constraints shaped the fix:
|
||||||
|
|
||||||
|
- The project renders with the **GL Compatibility** backend so it stays small and portable, which rules out features that only exist in Forward+ (`SSAO`, `SSIL`).
|
||||||
|
- The repo must build offline in CI, so any asset step has to work without a network or an API key.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**One material module.** `src/game/materials.gd` (`SurfaceMaterials`) owns a named profile table (`brick`, `concrete`, `asphalt`, `awning`, `stone`, `metal`, `painted_metal`, `wood`, `cloth`, `skin`, `glass`, `rubber`) with roughness/metallic pairs, plus the recipes the world and avatars use (`brick()`, `concrete()`, `asphalt()`, `awning()`, `flat()`, `emissive()`, `glass()`, `window_pane()`). The world builder, the NPC avatars, and the prop fallbacks all call it, so a surface has one definition.
|
||||||
|
|
||||||
|
**One environment tune-up.** `SurfaceMaterials.configure_environment()` applies ACES tonemapping, soft glow (for the marquee, lamp bulbs, and lit windows), aerial fog, and a mild contrast/saturation lift. `GameManager.PHASE_LIGHTING` now carries the per-phase values (`exposure`, `glow`, `fog_color`, `fog_density`, `fog_energy`, `contrast`, `saturation`, `ambient_energy`) next to the existing sun/sky entries, so a phase change still happens in one place. Features unavailable in Compatibility are ignored by the driver rather than gated in code.
|
||||||
|
|
||||||
|
**Generated prop models.** `tools/model_pipeline.py` describes street furniture (bollard, bench, bin, planter, newspaper box, hydrant) as boxes and cylinders and writes binary glTF (`.glb`) into `assets/models/generated/`. Godot imports each file as a `PackedScene`, and `WorldBuilder._add_prop_model()` instantiates it with a procedural fallback when the file or its import is missing. The build is deterministic and needs no network.
|
||||||
|
|
||||||
|
**AI only supplies colour.** `model_pipeline.py concept` asks an image model (OpenRouter, same backend as the texture tools) for one studio-style concept per prop. `dominant_colors()` reads the centre of that image and ranks colours by frequency weighted by saturation (a grey backdrop otherwise wins), and `assign_roles()` maps the result onto material roles by hue and value: darkest tone → metal, warm saturated tone → wood, light desaturated tone → stone, green tone → foliage. Colours are converted from sRGB to **linear** before they are written into glTF `baseColorFactor`, because the factor is linear space and writing sRGB made every prop render pastel.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- Surfaces read as materials: brick has fine, correctly scaled tiling, asphalt is smoother than brick, glass is smooth and slightly metallic, and the night sky carries warm lit windows.
|
||||||
|
- Street furniture is real geometry with per-part materials instead of scaled boxes, and it costs ~700 triangles for the whole set.
|
||||||
|
- The prop pipeline works with no key and no network; the concept step is optional and only changes colours.
|
||||||
|
- Tests can assert material profiles, environment values, and GLB structure without rendering.
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
- The generated models are low-poly primitives, not sculpted assets; they are an improvement in silhouette, not photoreal props.
|
||||||
|
- Two colour spaces now exist in the tooling. `model_pipeline.srgb_to_linear` must be used for every colour written into glTF, which is easy to forget.
|
||||||
|
- Concept images are per-prop inputs that have to be regenerated (and reviewed) when the art direction changes; a poor concept now produces a poor palette.
|
||||||
|
- Compatibility rendering means no ambient occlusion, so contact shadows rely on the directional light only.
|
||||||
|
|
||||||
|
## Follow-up
|
||||||
|
|
||||||
|
- A 3D-model API (image-to-mesh) could replace the primitive builder behind the same `assets/models/generated/<prop>.glb` contract without touching `WorldBuilder`.
|
||||||
|
- Texture atlases for the props would let them share one material instead of one per part.
|
||||||
@@ -76,6 +76,9 @@ The player interacts with NPCs, venue entrances, access artifacts, equipment, st
|
|||||||
- `ReputationEvaluator` converts incidents and shift outcomes into performance feedback.
|
- `ReputationEvaluator` converts incidents and shift outcomes into performance feedback.
|
||||||
- `CrowdSystem` models queues, attention, congestion, and public reaction.
|
- `CrowdSystem` models queues, attention, congestion, and public reaction.
|
||||||
- `ShiftReview` presents decisions, consequences, and improvement opportunities.
|
- `ShiftReview` presents decisions, consequences, and improvement opportunities.
|
||||||
|
- `SurfaceMaterials` (`src/game/materials.gd`) holds the surface profiles, glass/emissive recipes, and the environment tune-up (tonemapping, glow, fog, grading) that the phase lighting applies.
|
||||||
|
- `WorldBuilder` assembles the procedural venue and instantiates the generated prop models from `assets/models/generated/` with a procedural fallback.
|
||||||
|
- `tools/model_pipeline.py` builds and audits those prop models; `tools/screen_designs.py` generates screen references and judges captures.
|
||||||
|
|
||||||
## 6. Runtime View
|
## 6. Runtime View
|
||||||
|
|
||||||
@@ -153,9 +156,11 @@ Architecture decisions are recorded as ADRs:
|
|||||||
- [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-0007: Locomotion and input layers](adr/0007-locomotion-and-input-layers.md)
|
||||||
- [ADR-0008: Cone focus and head ring](adr/0008-cone-focus-and-head-ring.md)
|
- [ADR-0008: Cone focus and focus circle](adr/0008-cone-focus-and-head-ring.md)
|
||||||
- [ADR-0009: Radial action menu](adr/0009-radial-action-menu.md)
|
- [ADR-0009: Radial action menu](adr/0009-radial-action-menu.md)
|
||||||
- [ADR-0010: Procedural NPC presentation](adr/0010-procedural-npc-presentation.md)
|
- [ADR-0010: Procedural NPC presentation](adr/0010-procedural-npc-presentation.md)
|
||||||
|
- [ADR-0011: Aim-free selection and precision walk](adr/0011-aim-free-selection-and-precision-walk.md)
|
||||||
|
- [ADR-0012: Surface materials and generated props](adr/0012-surface-materials-and-generated-props.md)
|
||||||
|
|
||||||
## 10. Quality Requirements
|
## 10. Quality Requirements
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1007 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 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: 1.1 MiB |
|
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.2 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1021 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 953 KiB After Width: | Height: | Size: 979 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 526 KiB |
@@ -103,3 +103,39 @@ application/export_angle=0
|
|||||||
application/target_os_version=""
|
application/target_os_version=""
|
||||||
application/signature_flags=0
|
application/signature_flags=0
|
||||||
application/show_bundle_name=1
|
application/show_bundle_name=1
|
||||||
|
|
||||||
|
[preset.3]
|
||||||
|
|
||||||
|
name="Web"
|
||||||
|
platform="Web"
|
||||||
|
runnable=true
|
||||||
|
advanced_options=false
|
||||||
|
dedicated_server=false
|
||||||
|
custom_features=""
|
||||||
|
export_filter="all_resources"
|
||||||
|
include_filter=""
|
||||||
|
exclude_filter=""
|
||||||
|
export_path="build/web/index.html"
|
||||||
|
patches=PackedStringArray()
|
||||||
|
encryption_include_filters=""
|
||||||
|
encryption_exclude_filters=""
|
||||||
|
seed=0
|
||||||
|
encrypt_pck=false
|
||||||
|
encrypt_directory=false
|
||||||
|
script_export_mode=2
|
||||||
|
|
||||||
|
[preset.3.options]
|
||||||
|
|
||||||
|
custom_template/debug=""
|
||||||
|
custom_template/release=""
|
||||||
|
variant/extensions_support=false
|
||||||
|
variant/thread_support=false
|
||||||
|
vram_texture_compression/for_desktop=true
|
||||||
|
vram_texture_compression/for_mobile=false
|
||||||
|
html/export_icon=true
|
||||||
|
html/custom_html_shell=""
|
||||||
|
html/head_include=""
|
||||||
|
html/canvas_resize_policy=2
|
||||||
|
html/focus_canvas_on_start=true
|
||||||
|
html/experimental_virtual_keyboard=false
|
||||||
|
progressive_web_app/enabled=false
|
||||||
@@ -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.2"
|
config/version="0.4.0"
|
||||||
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")
|
||||||
|
|
||||||
@@ -110,6 +110,21 @@ wand={
|
|||||||
"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)
|
"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)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
walk_slow={
|
||||||
|
"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":4194325,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
cycle_target_next={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [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":5,"canceled":false,"pressed":false,"double_click":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
cycle_target_prev={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [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":4,"canceled":false,"pressed":false,"double_click":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
print_debug={
|
print_debug={
|
||||||
"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":70,"key_label":0,"unicode":102,"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":70,"key_label":0,"unicode":102,"location":0,"echo":false,"script":null)
|
||||||
|
|||||||
@@ -343,6 +343,28 @@ static func hollywood_building() -> VenueScenario:
|
|||||||
_building_routine("staff_entry", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 600), 0.35),
|
_building_routine("staff_entry", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 600), 0.35),
|
||||||
_building_routine("guest_entry", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.25),
|
_building_routine("guest_entry", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.25),
|
||||||
_building_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.24),
|
_building_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.24),
|
||||||
|
{
|
||||||
|
"id": "building_filming_boundary",
|
||||||
|
"phase": Rules.Phase.MIDDAY,
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": Rules.NpcType.INFLUENCER,
|
||||||
|
"intent": "film",
|
||||||
|
"time_range_s": Vector2(300, 600),
|
||||||
|
"probability": 0.4,
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"hint": "hint.body_camera_reason",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "building_unclear_intent",
|
||||||
|
"phase": Rules.Phase.EVENING,
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": Rules.NpcType.CONFUSED_VISITOR,
|
||||||
|
"intent": "enter",
|
||||||
|
"time_range_s": Vector2(600, 780),
|
||||||
|
"probability": 0.4,
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"hint": "hint.ask_reason",
|
||||||
|
},
|
||||||
_building_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.26),
|
_building_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.26),
|
||||||
_building_routine("intoxicated_wander", Rules.NpcType.INTOXICATED, "wander", Vector2(600, 900), 0.15),
|
_building_routine("intoxicated_wander", Rules.NpcType.INTOXICATED, "wander", Vector2(600, 900), 0.15),
|
||||||
_building_routine("trespass_test", Rules.NpcType.TRESPASSER, "trespass", Vector2(300, 900), 0.16),
|
_building_routine("trespass_test", Rules.NpcType.TRESPASSER, "trespass", Vector2(300, 900), 0.16),
|
||||||
@@ -437,6 +459,67 @@ static func joke_shop() -> VenueScenario:
|
|||||||
_joke_guest("guest_no_ticket", false, true, Vector2(0, 900), 0.18),
|
_joke_guest("guest_no_ticket", false, true, Vector2(0, 900), 0.18),
|
||||||
_joke_guest("guest_expired_id", true, true, Vector2(0, 900), 0.12, {"id_status": Rules.ArtifactStatus.EXPIRED}),
|
_joke_guest("guest_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),
|
||||||
|
{
|
||||||
|
"id": "joke_guest_reservation_no_id",
|
||||||
|
"phase": Rules.Phase.EVENING,
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
|
||||||
|
"intent": "enter",
|
||||||
|
"time_range_s": Vector2(620, 860),
|
||||||
|
"probability": 0.45,
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"override_access": {
|
||||||
|
"has_guest_list": true, "guest_list_status": Rules.ArtifactStatus.VALID,
|
||||||
|
"has_id": false, "has_ticket": false,
|
||||||
|
},
|
||||||
|
"hint": "hint.reservation_no_id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "joke_borrowed_id",
|
||||||
|
"phase": Rules.Phase.MIDDAY,
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
|
||||||
|
"intent": "enter",
|
||||||
|
"time_range_s": Vector2(320, 700),
|
||||||
|
"probability": 0.4,
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"override_access": {
|
||||||
|
"has_id": true, "id_status": Rules.ArtifactStatus.MISMATCHED,
|
||||||
|
"has_ticket": true, "ticket_status": Rules.ArtifactStatus.VALID,
|
||||||
|
},
|
||||||
|
"hint": "hint.borrowed_id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "joke_refuses_wand",
|
||||||
|
"phase": Rules.Phase.EVENING,
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
|
||||||
|
"intent": "enter",
|
||||||
|
"time_range_s": Vector2(650, 880),
|
||||||
|
"probability": 0.4,
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"refuses_screening": true,
|
||||||
|
"override_access": {
|
||||||
|
"has_id": true, "id_status": Rules.ArtifactStatus.VALID,
|
||||||
|
"has_ticket": true, "ticket_status": Rules.ArtifactStatus.VALID,
|
||||||
|
},
|
||||||
|
"hint": "hint.refuses_wand",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "joke_late_arrival",
|
||||||
|
"phase": Rules.Phase.EVENING,
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
|
||||||
|
"intent": "arrive",
|
||||||
|
"time_range_s": Vector2(700, 860),
|
||||||
|
"probability": 0.35,
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"override_access": {
|
||||||
|
"has_id": true, "id_status": Rules.ArtifactStatus.VALID,
|
||||||
|
"has_ticket": true, "ticket_status": Rules.ArtifactStatus.VALID,
|
||||||
|
},
|
||||||
|
"hint": "hint.late_arrival",
|
||||||
|
},
|
||||||
_joke_staff_entry(Vector2(0, 900), 0.25),
|
_joke_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),
|
||||||
_joke_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.15),
|
_joke_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.15),
|
||||||
@@ -515,6 +598,45 @@ static func tv_studio() -> VenueScenario:
|
|||||||
"base_weight": 1.0,
|
"base_weight": 1.0,
|
||||||
"override_access": {"wristband_status": Rules.ArtifactStatus.COUNTERFEIT, "credential_status": Rules.ArtifactStatus.COUNTERFEIT},
|
"override_access": {"wristband_status": Rules.ArtifactStatus.COUNTERFEIT, "credential_status": Rules.ArtifactStatus.COUNTERFEIT},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "studio_film_boundary",
|
||||||
|
"phase": Rules.Phase.MIDDAY,
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": Rules.NpcType.INFLUENCER,
|
||||||
|
"intent": "film_boundary",
|
||||||
|
"time_range_s": Vector2(330, 560),
|
||||||
|
"probability": 0.5,
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"hint": "hint.film_boundary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "studio_film_boundary_repeat",
|
||||||
|
"phase": Rules.Phase.EVENING,
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": Rules.NpcType.INFLUENCER,
|
||||||
|
"intent": "film_boundary",
|
||||||
|
"time_range_s": Vector2(640, 830),
|
||||||
|
"probability": 0.45,
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"hint": "hint.film_boundary_repeat",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "studio_celebrity_arrival",
|
||||||
|
"phase": Rules.Phase.EVENING,
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
|
||||||
|
"intent": "arrive",
|
||||||
|
"time_range_s": Vector2(620, 760),
|
||||||
|
"probability": 0.5,
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"override_access": {"has_guest_list": true, "guest_list_status": Rules.ArtifactStatus.VALID},
|
||||||
|
"extra_spawns": [
|
||||||
|
{"npc_type": Rules.NpcType.TOURIST, "intent": "photo"},
|
||||||
|
{"npc_type": Rules.NpcType.TOURIST, "intent": "photo"},
|
||||||
|
{"npc_type": Rules.NpcType.INFLUENCER, "intent": "film"},
|
||||||
|
],
|
||||||
|
"hint": "hint.celebrity_arrival",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "studio_follow_through",
|
"id": "studio_follow_through",
|
||||||
"phase": Rules.Phase.EVENING,
|
"phase": Rules.Phase.EVENING,
|
||||||
@@ -587,6 +709,10 @@ static func _joke_routine(id: String, npc_type: int, intent: String, time_range:
|
|||||||
static func _studio_routine(id: String, npc_type: int, intent: String, time_range: Vector2, prob: float) -> Dictionary:
|
static func _studio_routine(id: String, npc_type: int, intent: String, time_range: Vector2, prob: float) -> Dictionary:
|
||||||
return _building_routine(id, npc_type, intent, time_range, prob)
|
return _building_routine(id, npc_type, intent, time_range, prob)
|
||||||
|
|
||||||
|
## Scripted film-boundary intent: the influencer stands past the posted line.
|
||||||
|
static func _film_boundary_spot() -> String:
|
||||||
|
return "steps_end"
|
||||||
|
|
||||||
static func _phase_for_range(time_range: Vector2, duration: float) -> int:
|
static func _phase_for_range(time_range: Vector2, duration: float) -> int:
|
||||||
var start := time_range.x / duration
|
var start := time_range.x / duration
|
||||||
if start < 0.34:
|
if start < 0.34:
|
||||||
|
|||||||
@@ -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,13 @@ 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
|
||||||
|
## Authored event flag: this person refuses the wand screen (Joke Shop).
|
||||||
|
var refuses_screening := false
|
||||||
|
## Whether the guard has declared they recognize this person (a player decision
|
||||||
|
## recorded as evidence, PLAN.md: Authorized Access).
|
||||||
|
var player_recognized := false
|
||||||
|
## 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
|
## Event-level access payload (`has_id`, `has_ticket`, statuses). Empty means
|
||||||
## venue/type defaults from AccessLoadout.
|
## venue/type defaults from AccessLoadout.
|
||||||
var access_override: Dictionary = {}
|
var access_override: Dictionary = {}
|
||||||
@@ -150,6 +157,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.
|
||||||
|
|||||||
@@ -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,6 +34,10 @@ 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,
|
## The scheduler rolls once per simulated second. `tick()` runs at physics rate,
|
||||||
@@ -45,6 +51,8 @@ func setup(p_scenario: VenueScenario, p_duration_s: float = 900.0, p_seed: int =
|
|||||||
spawn_seed = p_seed
|
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()
|
||||||
@@ -59,6 +67,7 @@ func start() -> void:
|
|||||||
return
|
return
|
||||||
running = true
|
running = true
|
||||||
_shift_finished = false
|
_shift_finished = false
|
||||||
|
started_at_ms = Time.get_ticks_msec()
|
||||||
elapsed_s = 0.0
|
elapsed_s = 0.0
|
||||||
_spawn_accum_s = 0.0
|
_spawn_accum_s = 0.0
|
||||||
current_phase = _phase_for_time(0.0)
|
current_phase = _phase_for_time(0.0)
|
||||||
@@ -119,6 +128,8 @@ func _finish_shift() -> void:
|
|||||||
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)
|
||||||
@@ -129,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(),
|
||||||
@@ -138,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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,12 +34,69 @@ static func lookup(key: String) -> String:
|
|||||||
_ensure_loaded()
|
_ensure_loaded()
|
||||||
return TABLE.get(key, key)
|
return TABLE.get(key, key)
|
||||||
|
|
||||||
|
## Number of `%` conversion placeholders in a template, ignoring `%%` escapes.
|
||||||
|
static func placeholder_count(template: String) -> int:
|
||||||
|
var count := 0
|
||||||
|
var i := 0
|
||||||
|
while i < template.length():
|
||||||
|
if template[i] == "%":
|
||||||
|
if i + 1 < template.length() and template[i + 1] == "%":
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
count += 1
|
||||||
|
i += 1
|
||||||
|
return count
|
||||||
|
|
||||||
## Format a string by key with positional arguments.
|
## Format a string by key with positional arguments.
|
||||||
|
##
|
||||||
|
## GDScript's `%` binds arguments by order, so a key whose placeholder count drifts
|
||||||
|
## from its call site would push an engine error. Validate first and fail safe: log
|
||||||
|
## a warning and return the raw template instead of crashing.
|
||||||
static func fmt(key: String, args: Array) -> String:
|
static func fmt(key: String, args: Array) -> String:
|
||||||
var template := lookup(key)
|
var template := lookup(key)
|
||||||
if template == key:
|
if template == key:
|
||||||
return template
|
return template
|
||||||
|
var needed := placeholder_count(template)
|
||||||
|
if needed != args.size():
|
||||||
|
push_warning("Strings.fmt(%s): template needs %d args, got %d" % [key, needed, args.size()])
|
||||||
|
return template
|
||||||
return template % args
|
return template % args
|
||||||
|
|
||||||
|
## Lint the loaded table for formatting hazards. Returns an array of problems.
|
||||||
|
## Checks: trailing `%`, invalid conversions, and mixed positional (`%1$s`) and
|
||||||
|
## sequential placeholders in one template.
|
||||||
|
static func validate() -> Array[String]:
|
||||||
|
_ensure_loaded()
|
||||||
|
var problems: Array[String] = []
|
||||||
|
for key in TABLE.keys():
|
||||||
|
var template := str(TABLE[key])
|
||||||
|
var positional := template.contains("$")
|
||||||
|
var sequential := 0
|
||||||
|
var i := 0
|
||||||
|
while i < template.length():
|
||||||
|
if template[i] != "%":
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if i + 1 >= template.length():
|
||||||
|
problems.append("%s: trailing %% escape" % key)
|
||||||
|
break
|
||||||
|
var next := template[i + 1]
|
||||||
|
if next == "%":
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
var j := i + 1
|
||||||
|
while j < template.length() and template[j] in "0123456789.$":
|
||||||
|
if template[j] == "$":
|
||||||
|
positional = true
|
||||||
|
j += 1
|
||||||
|
if j >= template.length() or not (template[j] in "sdfxXv"):
|
||||||
|
problems.append("%s: unsupported conversion near '%%%s'" % [key, template.substr(i + 1, 3)])
|
||||||
|
elif not positional:
|
||||||
|
sequential += 1
|
||||||
|
i = j + 1
|
||||||
|
if positional and sequential > 0:
|
||||||
|
problems.append("%s: mixes positional (%%1$s) and sequential (%%s) placeholders" % key)
|
||||||
|
return problems
|
||||||
|
|
||||||
func _init() -> void:
|
func _init() -> void:
|
||||||
push_error("Strings is a static utility class; use Strings.lookup / Strings.fmt.")
|
push_error("Strings is a static utility class; use Strings.lookup / Strings.fmt.")
|
||||||
@@ -63,7 +63,9 @@ func _spot_for_intent(intent: String) -> String:
|
|||||||
return "entrance"
|
return "entrance"
|
||||||
"photo", "film", "perform", "wander", "roam":
|
"photo", "film", "perform", "wander", "roam":
|
||||||
return "sidewalk"
|
return "sidewalk"
|
||||||
"sit":
|
"sit", "film_boundary":
|
||||||
|
# film_boundary: an influencer ignoring the posted filming line, so they
|
||||||
|
# stand at the steps instead of the permitted sidewalk spot.
|
||||||
return "steps_end"
|
return "steps_end"
|
||||||
_:
|
_:
|
||||||
return "sidewalk"
|
return "sidewalk"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ const AccessArtifact := preload("res://src/core/access_artifact.gd")
|
|||||||
const AccessLoadout := preload("res://src/core/access_loadout.gd")
|
const AccessLoadout := preload("res://src/core/access_loadout.gd")
|
||||||
const AccessVerifier := preload("res://src/core/access_verifier.gd")
|
const AccessVerifier := preload("res://src/core/access_verifier.gd")
|
||||||
const DoorState := preload("res://src/core/door_state.gd")
|
const DoorState := preload("res://src/core/door_state.gd")
|
||||||
|
const CrowdSystem := preload("res://src/core/crowd_system.gd")
|
||||||
const WandScreener := preload("res://src/core/wand_screener.gd")
|
const WandScreener := preload("res://src/core/wand_screener.gd")
|
||||||
const RadioSystem := preload("res://src/core/radio_system.gd")
|
const RadioSystem := preload("res://src/core/radio_system.gd")
|
||||||
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
|
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
|
||||||
const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd")
|
const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd")
|
||||||
const VenueScenario := preload("res://src/core/venue_scenario.gd")
|
const VenueScenario := preload("res://src/core/venue_scenario.gd")
|
||||||
const WorldBuilder := preload("res://src/game/world_builder.gd")
|
const WorldBuilder := preload("res://src/game/world_builder.gd")
|
||||||
|
const Materials := preload("res://src/game/materials.gd")
|
||||||
const GuardController := preload("res://src/game/guard_controller.gd")
|
const GuardController := preload("res://src/game/guard_controller.gd")
|
||||||
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
|
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
|
||||||
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||||
@@ -26,10 +28,12 @@ const ShiftReview := preload("res://src/game/ui/shift_review.gd")
|
|||||||
const StartMenu := preload("res://src/game/ui/start_menu.gd")
|
const StartMenu := preload("res://src/game/ui/start_menu.gd")
|
||||||
const PauseOverlay := preload("res://src/game/ui/pause_overlay.gd")
|
const PauseOverlay := preload("res://src/game/ui/pause_overlay.gd")
|
||||||
const SettingsPanel := preload("res://src/game/ui/settings_panel.gd")
|
const SettingsPanel := preload("res://src/game/ui/settings_panel.gd")
|
||||||
|
const EventTimeline := preload("res://src/game/ui/event_timeline.gd")
|
||||||
|
|
||||||
var world_builder: WorldBuilder = null
|
var world_builder: WorldBuilder = null
|
||||||
var door := DoorState.new()
|
var door := DoorState.new()
|
||||||
var flashlight_on := false
|
var flashlight_on := false
|
||||||
|
var body_camera_on := false
|
||||||
var coordinator: ShiftCoordinator = null
|
var coordinator: ShiftCoordinator = null
|
||||||
var guard: GuardController = null
|
var guard: GuardController = null
|
||||||
var _sun: DirectionalLight3D = null
|
var _sun: DirectionalLight3D = null
|
||||||
@@ -41,24 +45,34 @@ var _last_phase: int = -1
|
|||||||
## structure — phase-based lighting). Night phases switch street lamps on.
|
## structure — phase-based lighting). Night phases switch street lamps on.
|
||||||
const PHASE_LIGHTING := {
|
const PHASE_LIGHTING := {
|
||||||
Rules.Phase.EARLY: {
|
Rules.Phase.EARLY: {
|
||||||
"sun_energy": 1.2, "sun_angle": -50.0, "sun_color": Color(1.0, 0.98, 0.92),
|
"sun_energy": 1.05, "sun_angle": -50.0, "sun_color": Color(1.0, 0.97, 0.90),
|
||||||
"sky_top": Color(0.32, 0.55, 0.95), "sky_horizon": Color(0.82, 0.88, 0.95),
|
"sky_top": Color(0.32, 0.55, 0.95), "sky_horizon": Color(0.82, 0.88, 0.95),
|
||||||
"sky_ground": Color(0.45, 0.5, 0.55), "ambient": Color(0.75, 0.78, 0.85), "night": false,
|
"sky_ground": Color(0.45, 0.5, 0.55), "ambient": Color(0.75, 0.78, 0.85), "night": false,
|
||||||
|
"exposure": 0.88, "glow": 0.35, "fog_color": Color(0.80, 0.85, 0.92),
|
||||||
|
"fog_density": 0.0028, "fog_energy": 0.75, "contrast": 1.04, "saturation": 1.06,
|
||||||
|
"ambient_energy": 0.45,
|
||||||
},
|
},
|
||||||
Rules.Phase.MIDDAY: {
|
Rules.Phase.MIDDAY: {
|
||||||
"sun_energy": 1.5, "sun_angle": -62.0, "sun_color": Color(1.0, 1.0, 0.98),
|
"sun_energy": 1.3, "sun_angle": -62.0, "sun_color": Color(1.0, 0.99, 0.96),
|
||||||
"sky_top": Color(0.3, 0.52, 0.95), "sky_horizon": Color(0.85, 0.9, 0.96),
|
"sky_top": Color(0.3, 0.52, 0.95), "sky_horizon": Color(0.85, 0.9, 0.96),
|
||||||
"sky_ground": Color(0.5, 0.54, 0.58), "ambient": Color(0.85, 0.88, 0.95), "night": false,
|
"sky_ground": Color(0.5, 0.54, 0.58), "ambient": Color(0.85, 0.88, 0.95), "night": false,
|
||||||
|
"exposure": 0.85, "glow": 0.3, "fog_color": Color(0.84, 0.88, 0.94),
|
||||||
|
"fog_density": 0.0022, "fog_energy": 0.8, "contrast": 1.05, "saturation": 1.05,
|
||||||
|
"ambient_energy": 0.45,
|
||||||
},
|
},
|
||||||
Rules.Phase.EVENING: {
|
Rules.Phase.EVENING: {
|
||||||
"sun_energy": 0.7, "sun_angle": -16.0, "sun_color": Color(1.0, 0.72, 0.5),
|
"sun_energy": 0.7, "sun_angle": -16.0, "sun_color": Color(1.0, 0.72, 0.5),
|
||||||
"sky_top": Color(0.62, 0.32, 0.35), "sky_horizon": Color(1.0, 0.68, 0.42),
|
"sky_top": Color(0.62, 0.32, 0.35), "sky_horizon": Color(1.0, 0.68, 0.42),
|
||||||
"sky_ground": Color(0.3, 0.24, 0.3), "ambient": Color(0.55, 0.45, 0.5), "night": true,
|
"sky_ground": Color(0.3, 0.24, 0.3), "ambient": Color(0.55, 0.45, 0.5), "night": true,
|
||||||
|
"exposure": 1.05, "glow": 0.7, "fog_color": Color(0.95, 0.68, 0.48),
|
||||||
|
"fog_density": 0.0045, "fog_energy": 0.6, "contrast": 1.08, "saturation": 1.12,
|
||||||
},
|
},
|
||||||
Rules.Phase.LATE: {
|
Rules.Phase.LATE: {
|
||||||
"sun_energy": 0.05, "sun_angle": -8.0, "sun_color": Color(0.3, 0.35, 0.5),
|
"sun_energy": 0.05, "sun_angle": -8.0, "sun_color": Color(0.3, 0.35, 0.5),
|
||||||
"sky_top": Color(0.02, 0.03, 0.08), "sky_horizon": Color(0.1, 0.12, 0.2),
|
"sky_top": Color(0.02, 0.03, 0.08), "sky_horizon": Color(0.1, 0.12, 0.2),
|
||||||
"sky_ground": Color(0.05, 0.05, 0.08), "ambient": Color(0.08, 0.09, 0.16), "night": true,
|
"sky_ground": Color(0.05, 0.05, 0.08), "ambient": Color(0.08, 0.09, 0.16), "night": true,
|
||||||
|
"exposure": 1.25, "glow": 0.95, "fog_color": Color(0.10, 0.13, 0.24),
|
||||||
|
"fog_density": 0.0055, "fog_energy": 0.5, "contrast": 1.1, "saturation": 1.0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
var hud: Hud = null
|
var hud: Hud = null
|
||||||
@@ -68,6 +82,8 @@ var shift_review_panel: ShiftReview = null
|
|||||||
var start_menu: StartMenu = null
|
var start_menu: StartMenu = null
|
||||||
var pause_overlay: PauseOverlay = null
|
var pause_overlay: PauseOverlay = null
|
||||||
var settings_panel: SettingsPanel = null
|
var settings_panel: SettingsPanel = null
|
||||||
|
var event_timeline: EventTimeline = null
|
||||||
|
var _timeline_from_review := false
|
||||||
var _shift_running := false
|
var _shift_running := false
|
||||||
var player_settings: Dictionary = {}
|
var player_settings: Dictionary = {}
|
||||||
var _focused_avatar: Node3D = null
|
var _focused_avatar: Node3D = null
|
||||||
@@ -98,12 +114,12 @@ var _venue_index := 0
|
|||||||
## RNG for venue door-staff lines so repeated interactions vary.
|
## RNG for venue door-staff lines so repeated interactions vary.
|
||||||
var _door_rng := RandomNumberGenerator.new()
|
var _door_rng := RandomNumberGenerator.new()
|
||||||
|
|
||||||
## Aim-free targeting: cycle through nearby NPCs with Tab (accessibility).
|
## Aim-free targeting lives on the guard (`GuardController.nearby_candidates`);
|
||||||
const NEARBY_RADIUS := 12.0
|
## this file only reacts to the signals it emits and logs the selection.
|
||||||
|
|
||||||
## Upper bound on live avatars. Routine traffic is dropped at the cap; scripted
|
## Upper bound on live avatars. Routine traffic is dropped at the cap; scripted
|
||||||
## events evict the oldest unfocused NPC so authored moments still spawn.
|
## events evict the oldest unfocused NPC so authored moments still spawn.
|
||||||
const MAX_LIVE_NPCS := 16
|
const MAX_LIVE_NPCS := 16
|
||||||
var _nearby_index := -1
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_door_rng.randomize()
|
_door_rng.randomize()
|
||||||
@@ -132,9 +148,13 @@ func _ready() -> void:
|
|||||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||||
coordinator.npc_spawn_requested.connect(_spawn_npc)
|
coordinator.npc_spawn_requested.connect(_spawn_npc)
|
||||||
coordinator.shift_ended.connect(_on_shift_ended)
|
coordinator.shift_ended.connect(_on_shift_ended)
|
||||||
coordinator.radio.responder_arrived.connect(_on_responder_arrived)
|
_connect_radio()
|
||||||
|
_connect_crowd()
|
||||||
|
|
||||||
_build_ui()
|
_build_ui()
|
||||||
|
# The HUD does not exist yet when settings are loaded, so push them again now
|
||||||
|
# that it does (otherwise the saved font scale is ignored until the next apply).
|
||||||
|
_apply_settings(player_settings)
|
||||||
_last_phase = -1
|
_last_phase = -1
|
||||||
_apply_phase_lighting()
|
_apply_phase_lighting()
|
||||||
# Boot into the venue menu; the shift starts when the player picks a venue.
|
# Boot into the venue menu; the shift starts when the player picks a venue.
|
||||||
@@ -159,10 +179,13 @@ func _build_guard() -> GuardController:
|
|||||||
flashlight.name = "Flashlight"
|
flashlight.name = "Flashlight"
|
||||||
flashlight.visible = false
|
flashlight.visible = false
|
||||||
flashlight.position = Vector3(0, -0.15, -0.4)
|
flashlight.position = Vector3(0, -0.15, -0.4)
|
||||||
flashlight.rotation_degrees = Vector3(-10, 0, 0)
|
# Aim straight ahead: tilting it down made the cone hit the ground right in
|
||||||
flashlight.spot_range = 18.0
|
# front of the guard instead of the person or door being checked.
|
||||||
flashlight.spot_angle = 28.0
|
flashlight.rotation_degrees = Vector3.ZERO
|
||||||
flashlight.light_energy = 2.5
|
flashlight.spot_range = 22.0
|
||||||
|
flashlight.spot_angle = 30.0
|
||||||
|
flashlight.light_energy = 4.0
|
||||||
|
flashlight.spot_attenuation = 0.6
|
||||||
head.add_child(flashlight)
|
head.add_child(flashlight)
|
||||||
var collider := CollisionShape3D.new()
|
var collider := CollisionShape3D.new()
|
||||||
var shape := CapsuleShape3D.new()
|
var shape := CapsuleShape3D.new()
|
||||||
@@ -207,18 +230,25 @@ func _reset_shift_state() -> void:
|
|||||||
current_incident_npc_id = ""
|
current_incident_npc_id = ""
|
||||||
targeted_npc = null
|
targeted_npc = null
|
||||||
_focused_avatar = null
|
_focused_avatar = null
|
||||||
_nearby_index = -1
|
|
||||||
inspection_open = false
|
inspection_open = false
|
||||||
wand_open = false
|
wand_open = false
|
||||||
_wand_cooldown = 0.0
|
_wand_cooldown = 0.0
|
||||||
|
body_camera_on = false
|
||||||
|
if hud:
|
||||||
|
hud.set_body_camera(false)
|
||||||
scenario_duration_s = selected_scenario.shift_duration_s
|
scenario_duration_s = selected_scenario.shift_duration_s
|
||||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||||
|
_connect_radio()
|
||||||
|
_connect_crowd()
|
||||||
_last_phase = -1
|
_last_phase = -1
|
||||||
_apply_phase_lighting()
|
_apply_phase_lighting()
|
||||||
|
|
||||||
## Begin (or resume) the shift clock and recapture the mouse.
|
## Begin (or resume) the shift clock and recapture the mouse.
|
||||||
func _start_shift() -> void:
|
func _start_shift() -> void:
|
||||||
_shift_running = true
|
_shift_running = true
|
||||||
|
if audio:
|
||||||
|
audio.start_crowd_bed()
|
||||||
|
audio.set_crowd_level(coordinator.crowd.mood)
|
||||||
coordinator.start()
|
coordinator.start()
|
||||||
if hud:
|
if hud:
|
||||||
hud.set_shift_state(coordinator, selected_scenario)
|
hud.set_shift_state(coordinator, selected_scenario)
|
||||||
@@ -308,12 +338,15 @@ func _build_ui() -> void:
|
|||||||
radio_panel.submitted.connect(_on_radio_submitted)
|
radio_panel.submitted.connect(_on_radio_submitted)
|
||||||
radio_panel.cancelled.connect(func():
|
radio_panel.cancelled.connect(func():
|
||||||
radio_panel.hide()
|
radio_panel.hide()
|
||||||
|
if audio:
|
||||||
|
audio.play_radio_close()
|
||||||
hud.set_action_hint("")
|
hud.set_action_hint("")
|
||||||
_refresh_freeze())
|
_refresh_freeze())
|
||||||
shift_review_panel = ShiftReview.new()
|
shift_review_panel = ShiftReview.new()
|
||||||
add_child(shift_review_panel)
|
add_child(shift_review_panel)
|
||||||
shift_review_panel.hide()
|
shift_review_panel.hide()
|
||||||
shift_review_panel.restart_requested.connect(_on_review_restart)
|
shift_review_panel.restart_requested.connect(_on_review_restart)
|
||||||
|
shift_review_panel.timeline_requested.connect(_open_timeline)
|
||||||
shift_review_panel.visible = false
|
shift_review_panel.visible = false
|
||||||
|
|
||||||
pause_overlay = PauseOverlay.new()
|
pause_overlay = PauseOverlay.new()
|
||||||
@@ -329,6 +362,11 @@ func _build_ui() -> void:
|
|||||||
settings_panel.hide()
|
settings_panel.hide()
|
||||||
settings_panel.applied.connect(_on_settings_applied)
|
settings_panel.applied.connect(_on_settings_applied)
|
||||||
|
|
||||||
|
event_timeline = EventTimeline.new()
|
||||||
|
add_child(event_timeline)
|
||||||
|
event_timeline.hide()
|
||||||
|
event_timeline.closed.connect(_on_timeline_closed)
|
||||||
|
|
||||||
start_menu = StartMenu.new()
|
start_menu = StartMenu.new()
|
||||||
add_child(start_menu)
|
add_child(start_menu)
|
||||||
start_menu.hide()
|
start_menu.hide()
|
||||||
@@ -336,6 +374,23 @@ func _build_ui() -> void:
|
|||||||
start_menu.settings_requested.connect(_open_settings)
|
start_menu.settings_requested.connect(_open_settings)
|
||||||
start_menu.quit_requested.connect(func(): get_tree().quit())
|
start_menu.quit_requested.connect(func(): get_tree().quit())
|
||||||
|
|
||||||
|
## Body-camera timeline from the review screen (ADR-0005 events, no 3D replay).
|
||||||
|
func _open_timeline(review: Dictionary) -> void:
|
||||||
|
if event_timeline == null:
|
||||||
|
return
|
||||||
|
# Hide the review dim so the timeline is not stacked under two overlays.
|
||||||
|
_timeline_from_review = shift_review_panel != null and shift_review_panel.visible
|
||||||
|
if shift_review_panel:
|
||||||
|
shift_review_panel.visible = false
|
||||||
|
event_timeline.show_timeline(review)
|
||||||
|
_refresh_freeze()
|
||||||
|
|
||||||
|
func _on_timeline_closed() -> void:
|
||||||
|
if _timeline_from_review and shift_review_panel:
|
||||||
|
shift_review_panel.visible = true
|
||||||
|
_timeline_from_review = false
|
||||||
|
_refresh_freeze()
|
||||||
|
|
||||||
## "Run another shift" on the review screen returns to the venue menu.
|
## "Run another shift" on the review screen returns to the venue menu.
|
||||||
func _on_review_restart() -> void:
|
func _on_review_restart() -> void:
|
||||||
_show_start_menu()
|
_show_start_menu()
|
||||||
@@ -384,8 +439,9 @@ func _ensure_lighting() -> void:
|
|||||||
sky.sky_material = _sky
|
sky.sky_material = _sky
|
||||||
env.sky = sky
|
env.sky = sky
|
||||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
|
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
|
||||||
env.ambient_light_energy = 0.5
|
# Tonemap, glow, fog and colour grading live in SurfaceMaterials so the
|
||||||
env.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
|
# world builder and the environment tune-up stay in one place.
|
||||||
|
Materials.configure_environment(env, PHASE_LIGHTING[Rules.Phase.EARLY])
|
||||||
_env_node.environment = env
|
_env_node.environment = env
|
||||||
scene_root.add_child(_env_node)
|
scene_root.add_child(_env_node)
|
||||||
|
|
||||||
@@ -403,6 +459,8 @@ func _apply_phase_lighting() -> void:
|
|||||||
_sky.sky_horizon_color = cfg.get("sky_horizon", Color.WHITE)
|
_sky.sky_horizon_color = cfg.get("sky_horizon", Color.WHITE)
|
||||||
_sky.ground_bottom_color = cfg.get("sky_ground", Color(0.5, 0.5, 0.5))
|
_sky.ground_bottom_color = cfg.get("sky_ground", Color(0.5, 0.5, 0.5))
|
||||||
_sky.ground_horizon_color = cfg.get("sky_horizon", Color.WHITE)
|
_sky.ground_horizon_color = cfg.get("sky_horizon", Color.WHITE)
|
||||||
|
if _env_node and _env_node.environment:
|
||||||
|
Materials.configure_environment(_env_node.environment, cfg)
|
||||||
if world_builder:
|
if world_builder:
|
||||||
world_builder.set_night_mode(cfg.get("night", false))
|
world_builder.set_night_mode(cfg.get("night", false))
|
||||||
|
|
||||||
@@ -434,8 +492,19 @@ func _spawn_npc(profile: NpcProfile, intent: String, npc_id: String, event: Dict
|
|||||||
# Authored events stay for the whole shift; routine traffic cycles out.
|
# Authored events stay for the whole shift; routine traffic cycles out.
|
||||||
avatar.ephemeral = event.get("kind", "routine") == "routine"
|
avatar.ephemeral = event.get("kind", "routine") == "routine"
|
||||||
avatar.lifetime_s = 45.0 + float((npc_counter * 17) % 60)
|
avatar.lifetime_s = 45.0 + float((npc_counter * 17) % 60)
|
||||||
|
avatar.get_npc_controller().refuses_screening = bool(event.get("refuses_screening", false))
|
||||||
scene_root.add_child(avatar)
|
scene_root.add_child(avatar)
|
||||||
npcs.append(avatar)
|
npcs.append(avatar)
|
||||||
|
# Authored events can point the player at the right verb (PLAN.md verbs).
|
||||||
|
if event.has("hint") and hud:
|
||||||
|
hud.log(Strings.lookup(str(event["hint"])))
|
||||||
|
# Celebrity arrivals draw extra bystanders, not just one avatar.
|
||||||
|
for extra in event.get("extra_spawns", []):
|
||||||
|
var extra_profile := selected_scenario.profile_for(extra.get("npc_type", Rules.NpcType.TOURIST))
|
||||||
|
if extra_profile == null or npcs.size() >= MAX_LIVE_NPCS:
|
||||||
|
continue
|
||||||
|
_spawn_npc(extra_profile, str(extra.get("intent", "photo")),
|
||||||
|
"%s_w%d" % [npc_id, npcs.size()], {"kind": "routine"})
|
||||||
|
|
||||||
## Drop references to freed avatars so the cap and Tab cycling stay accurate.
|
## Drop references to freed avatars so the cap and Tab cycling stay accurate.
|
||||||
func _purge_npcs() -> void:
|
func _purge_npcs() -> void:
|
||||||
@@ -470,12 +539,98 @@ func _on_responder_arrived(call: Dictionary) -> void:
|
|||||||
scene_root.add_child(avatar)
|
scene_root.add_child(avatar)
|
||||||
npcs.append(avatar)
|
npcs.append(avatar)
|
||||||
hud.log(Strings.fmt("responder.arrived", [outcome]))
|
hud.log(Strings.fmt("responder.arrived", [outcome]))
|
||||||
|
coordinator.responder_outcomes.append(outcome)
|
||||||
coordinator.recorder.add_event(call.get("report", {}).get("npc_id", ""), {
|
coordinator.recorder.add_event(call.get("report", {}).get("npc_id", ""), {
|
||||||
"type": "responder_action",
|
"type": "responder_action",
|
||||||
"outcome": outcome,
|
"outcome": outcome,
|
||||||
"call_type": call.get("type", "assist"),
|
"call_type": call.get("type", "assist"),
|
||||||
})
|
})
|
||||||
coordinator.crowd.apply_event("emergency_responder" if call.get("type", "") == "emergency" else "calm_professionalism")
|
coordinator.crowd.apply_event("emergency_responder" if call.get("type", "") == "emergency" else "calm_professionalism")
|
||||||
|
if outcome == "evacuation":
|
||||||
|
_evacuate_entrance()
|
||||||
|
|
||||||
|
## Responder outcome "evacuation": the entrance is cleared and the door stays
|
||||||
|
## locked while the block is secured (PLAN.md: Level 5 Emergency Response).
|
||||||
|
func _evacuate_entrance() -> void:
|
||||||
|
if door and not door.locked:
|
||||||
|
door.set_locked(true)
|
||||||
|
if world_builder:
|
||||||
|
world_builder.set_door_locked(true)
|
||||||
|
if hud:
|
||||||
|
hud.set_door(true)
|
||||||
|
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
|
||||||
|
for npc in npcs:
|
||||||
|
if not is_instance_valid(npc):
|
||||||
|
continue
|
||||||
|
if npc.has_method("start_flee"):
|
||||||
|
npc.start_flee(door_pos)
|
||||||
|
var controller: Object = npc.get_npc_controller() if npc.has_method("get_npc_controller") else null
|
||||||
|
if controller != null:
|
||||||
|
coordinator.recorder.add_event(controller.npc_id, {
|
||||||
|
"type": "evacuation",
|
||||||
|
"outcome": "evacuated",
|
||||||
|
})
|
||||||
|
if controller.leave_requested == false:
|
||||||
|
controller.leave_requested = true
|
||||||
|
coordinator.reputation.apply("security_failure", -3.0, "entrance evacuated by responder")
|
||||||
|
if hud:
|
||||||
|
hud.log(Strings.lookup("responder.evacuated"))
|
||||||
|
|
||||||
|
## Responder dispatch. `coordinator.setup()` builds a new RadioSystem for each
|
||||||
|
## shift, so the arrival signal must be re-connected after every setup or radio
|
||||||
|
## calls are silently resolved by nobody.
|
||||||
|
func _connect_radio() -> void:
|
||||||
|
if coordinator == null or coordinator.radio == null:
|
||||||
|
return
|
||||||
|
if not coordinator.radio.responder_arrived.is_connected(_on_responder_arrived):
|
||||||
|
coordinator.radio.responder_arrived.connect(_on_responder_arrived)
|
||||||
|
|
||||||
|
## Crowd presentation (PLAN: Public Crowd Reaction). The crowd system is a
|
||||||
|
## mood/attention float; this turns its events into avatar behavior.
|
||||||
|
func _connect_crowd() -> void:
|
||||||
|
# CrowdSystem routes its updates through the EventBus (`event_bus.crowd_changed`);
|
||||||
|
# its own `changed` signal is not emitted by apply_event().
|
||||||
|
if coordinator == null or coordinator.event_bus == null:
|
||||||
|
return
|
||||||
|
if not coordinator.event_bus.crowd_changed.is_connected(_on_crowd_changed):
|
||||||
|
coordinator.event_bus.crowd_changed.connect(_on_crowd_changed)
|
||||||
|
|
||||||
|
func _on_crowd_changed(payload: Dictionary) -> void:
|
||||||
|
var event_type := str(payload.get("event", ""))
|
||||||
|
var panic := bool(payload.get("panic", false))
|
||||||
|
if audio:
|
||||||
|
audio.set_crowd_level(float(payload.get("mood", 0.0)))
|
||||||
|
# Panic wins: people clear away from the entrance.
|
||||||
|
if panic or event_type == "crowd_panic":
|
||||||
|
_flee_crowd()
|
||||||
|
return
|
||||||
|
if CrowdSystem.reaction_for_event(event_type) == "watch":
|
||||||
|
_rubberneck_crowd()
|
||||||
|
|
||||||
|
func _incident_point() -> Vector3:
|
||||||
|
if targeted_npc is Node3D and is_instance_valid(targeted_npc):
|
||||||
|
return (targeted_npc as Node3D).global_position
|
||||||
|
if guard:
|
||||||
|
return guard.global_position
|
||||||
|
return Vector3(0, 0, 1.2)
|
||||||
|
|
||||||
|
## Idle bystanders stop and face the incident instead of walking on.
|
||||||
|
func _rubberneck_crowd() -> void:
|
||||||
|
var point := _incident_point()
|
||||||
|
for npc in npcs:
|
||||||
|
if not is_instance_valid(npc) or not npc.has_method("start_rubberneck"):
|
||||||
|
continue
|
||||||
|
if npc == targeted_npc:
|
||||||
|
continue
|
||||||
|
npc.start_rubberneck(point)
|
||||||
|
|
||||||
|
## Panic: everyone nearby moves away from the door.
|
||||||
|
func _flee_crowd() -> void:
|
||||||
|
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
|
||||||
|
for npc in npcs:
|
||||||
|
if not is_instance_valid(npc) or not npc.has_method("start_flee"):
|
||||||
|
continue
|
||||||
|
npc.start_flee(door_pos)
|
||||||
|
|
||||||
func _responder_profile() -> NpcProfile:
|
func _responder_profile() -> NpcProfile:
|
||||||
var p := NpcProfile.new()
|
var p := NpcProfile.new()
|
||||||
@@ -488,6 +643,8 @@ func _responder_profile() -> NpcProfile:
|
|||||||
|
|
||||||
func _on_shift_ended(review: Dictionary) -> void:
|
func _on_shift_ended(review: Dictionary) -> void:
|
||||||
_shift_running = false
|
_shift_running = false
|
||||||
|
if audio:
|
||||||
|
audio.stop_crowd_bed()
|
||||||
# Persist the finished shift locally (Save autoload, user:// JSON).
|
# Persist the finished shift locally (Save autoload, user:// JSON).
|
||||||
if Save:
|
if Save:
|
||||||
Save.save_shift_result(review)
|
Save.save_shift_result(review)
|
||||||
@@ -498,12 +655,14 @@ func _on_guard_target(target: Node3D) -> void:
|
|||||||
targeted_npc = target
|
targeted_npc = target
|
||||||
_set_focused_avatar(target)
|
_set_focused_avatar(target)
|
||||||
_begin_incident(target)
|
_begin_incident(target)
|
||||||
|
_refresh_nearby_hint()
|
||||||
|
|
||||||
func _on_guard_clear() -> void:
|
func _on_guard_clear() -> void:
|
||||||
targeted_npc = null
|
targeted_npc = null
|
||||||
_set_focused_avatar(null)
|
_set_focused_avatar(null)
|
||||||
hud.set_target_label("")
|
hud.set_target_label("")
|
||||||
hud.set_action_hint("")
|
hud.set_action_hint("")
|
||||||
|
_refresh_nearby_hint()
|
||||||
|
|
||||||
func _set_focused_avatar(avatar: Node3D) -> void:
|
func _set_focused_avatar(avatar: Node3D) -> void:
|
||||||
if _focused_avatar and is_instance_valid(_focused_avatar) and _focused_avatar.has_method("set_focus_visual"):
|
if _focused_avatar and is_instance_valid(_focused_avatar) and _focused_avatar.has_method("set_focus_visual"):
|
||||||
@@ -520,6 +679,11 @@ func _begin_incident(avatar: Node3D) -> void:
|
|||||||
current_incident_npc_id = controller.npc_id
|
current_incident_npc_id = controller.npc_id
|
||||||
current_incident_active = true
|
current_incident_active = true
|
||||||
current_npc_truth = _read_npc_truth(controller)
|
current_npc_truth = _read_npc_truth(controller)
|
||||||
|
# Open a structured incident so player actions, access evidence, and
|
||||||
|
# escalation levels are actually recorded (ADR-0005). Without this the
|
||||||
|
# recorder drops every event and the review reports zero incidents.
|
||||||
|
if not coordinator.recorder.has_open_incident(controller.npc_id):
|
||||||
|
coordinator.recorder.start_incident(controller.npc_id, "player_focus")
|
||||||
var profile: Object = controller.profile
|
var profile: Object = controller.profile
|
||||||
var label := "%s — %s" % [
|
var label := "%s — %s" % [
|
||||||
profile.display_name if profile else "?",
|
profile.display_name if profile else "?",
|
||||||
@@ -550,6 +714,12 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||||||
_handle_key(event)
|
_handle_key(event)
|
||||||
|
|
||||||
func _handle_mouse_button(event: InputEventMouseButton) -> void:
|
func _handle_mouse_button(event: InputEventMouseButton) -> void:
|
||||||
|
if event.is_action_pressed("cycle_target_next"):
|
||||||
|
_cycle_nearby_target(1)
|
||||||
|
return
|
||||||
|
if event.is_action_pressed("cycle_target_prev"):
|
||||||
|
_cycle_nearby_target(-1)
|
||||||
|
return
|
||||||
if event.button_index != MOUSE_BUTTON_LEFT:
|
if event.button_index != MOUSE_BUTTON_LEFT:
|
||||||
return
|
return
|
||||||
if radial and radial.is_open:
|
if radial and radial.is_open:
|
||||||
@@ -602,6 +772,8 @@ func _handle_key(event: InputEvent) -> void:
|
|||||||
_toggle_flashlight()
|
_toggle_flashlight()
|
||||||
KEY_L:
|
KEY_L:
|
||||||
_toggle_door_lock()
|
_toggle_door_lock()
|
||||||
|
KEY_B:
|
||||||
|
_toggle_body_camera()
|
||||||
KEY_V:
|
KEY_V:
|
||||||
_cycle_venue()
|
_cycle_venue()
|
||||||
_:
|
_:
|
||||||
@@ -643,6 +815,10 @@ func _handle_cancel(from_mouse: bool) -> void:
|
|||||||
if _close_top_modal():
|
if _close_top_modal():
|
||||||
return
|
return
|
||||||
if from_mouse:
|
if from_mouse:
|
||||||
|
# Right click with nothing open drops the current target, so the player can
|
||||||
|
# break focus without walking or looking away.
|
||||||
|
if targeted_npc != null:
|
||||||
|
_clear_focus()
|
||||||
return
|
return
|
||||||
if settings_panel and settings_panel.visible:
|
if settings_panel and settings_panel.visible:
|
||||||
return
|
return
|
||||||
@@ -673,9 +849,14 @@ func _close_top_modal() -> bool:
|
|||||||
return true
|
return true
|
||||||
if radio_panel and radio_panel.visible:
|
if radio_panel and radio_panel.visible:
|
||||||
radio_panel.hide()
|
radio_panel.hide()
|
||||||
|
if audio:
|
||||||
|
audio.play_radio_close()
|
||||||
hud.set_action_hint("")
|
hud.set_action_hint("")
|
||||||
_refresh_freeze()
|
_refresh_freeze()
|
||||||
return true
|
return true
|
||||||
|
if event_timeline and event_timeline.visible:
|
||||||
|
event_timeline.close()
|
||||||
|
return true
|
||||||
return false
|
return false
|
||||||
|
|
||||||
func _open_radial() -> void:
|
func _open_radial() -> void:
|
||||||
@@ -720,6 +901,8 @@ func _on_radial_confirmed(slice_id: String) -> void:
|
|||||||
match slice_id:
|
match slice_id:
|
||||||
"raise_hand":
|
"raise_hand":
|
||||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand")
|
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand")
|
||||||
|
"eye_contact":
|
||||||
|
_eye_contact()
|
||||||
"point":
|
"point":
|
||||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point")
|
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point")
|
||||||
"head_shake":
|
"head_shake":
|
||||||
@@ -734,6 +917,10 @@ func _on_radial_confirmed(slice_id: String) -> void:
|
|||||||
_apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning")
|
_apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning")
|
||||||
"block":
|
"block":
|
||||||
_apply_interaction(Rules.Level.BLOCKING, "blocking")
|
_apply_interaction(Rules.Level.BLOCKING, "blocking")
|
||||||
|
"reason":
|
||||||
|
_request_reason()
|
||||||
|
"recognize":
|
||||||
|
_recognize_target()
|
||||||
"radio":
|
"radio":
|
||||||
_open_radio(false)
|
_open_radio(false)
|
||||||
|
|
||||||
@@ -749,6 +936,8 @@ func _refresh_freeze() -> void:
|
|||||||
freeze = true
|
freeze = true
|
||||||
if settings_panel and settings_panel.visible:
|
if settings_panel and settings_panel.visible:
|
||||||
freeze = true
|
freeze = true
|
||||||
|
if event_timeline and event_timeline.visible:
|
||||||
|
freeze = true
|
||||||
if not _shift_running:
|
if not _shift_running:
|
||||||
freeze = true
|
freeze = true
|
||||||
if guard:
|
if guard:
|
||||||
@@ -768,6 +957,29 @@ func _toggle_flashlight() -> void:
|
|||||||
hud.set_flashlight(flashlight_on)
|
hud.set_flashlight(flashlight_on)
|
||||||
hud.log(Strings.lookup("flashlight.on") if flashlight_on else Strings.lookup("flashlight.off"))
|
hud.log(Strings.lookup("flashlight.on") if flashlight_on else Strings.lookup("flashlight.off"))
|
||||||
|
|
||||||
|
## Toggle the body camera (PLAN Level 2). The overlay is only visible while the
|
||||||
|
## camera is on, and turning it on or off is written to the incident log.
|
||||||
|
func _toggle_body_camera() -> void:
|
||||||
|
_set_body_camera(not body_camera_on, "manual")
|
||||||
|
|
||||||
|
func _set_body_camera(on: bool, reason: String) -> void:
|
||||||
|
if body_camera_on == on:
|
||||||
|
return
|
||||||
|
body_camera_on = on
|
||||||
|
if hud:
|
||||||
|
hud.set_body_camera(on)
|
||||||
|
var controller: Object = _targeted_controller()
|
||||||
|
if controller != null:
|
||||||
|
_ensure_incident(controller)
|
||||||
|
if coordinator and current_incident_active:
|
||||||
|
coordinator.recorder.add_event(current_incident_npc_id, {
|
||||||
|
"type": "body_camera",
|
||||||
|
"on": on,
|
||||||
|
"reason": reason,
|
||||||
|
})
|
||||||
|
if hud:
|
||||||
|
hud.log(Strings.lookup("bodycam.on") if on else Strings.lookup("bodycam.off"))
|
||||||
|
|
||||||
## Lock or unlock the restricted door (PLAN.md: Level 3). Locked doors thwart
|
## Lock or unlock the restricted door (PLAN.md: Level 3). Locked doors thwart
|
||||||
## FORCE_ENTRY attempts during the reaction flow.
|
## FORCE_ENTRY attempts during the reaction flow.
|
||||||
func _toggle_door_lock() -> void:
|
func _toggle_door_lock() -> void:
|
||||||
@@ -787,30 +999,41 @@ func _cycle_venue() -> void:
|
|||||||
hud.set_wand_icon(false)
|
hud.set_wand_icon(false)
|
||||||
hud.set_shift_state(coordinator, selected_scenario)
|
hud.set_shift_state(coordinator, selected_scenario)
|
||||||
|
|
||||||
## Aim-free targeting: cycle through NPCs near the guard (Tab). Lets players
|
## Aim-free targeting: cycle through NPCs near the guard (Tab, mouse wheel).
|
||||||
## interact without precise aiming (accessibility).
|
## Lets players interact without precise aiming (accessibility) and pins the
|
||||||
func _cycle_nearby_target() -> void:
|
## target so the focus cone does not drop someone the player is not looking at.
|
||||||
|
func _cycle_nearby_target(direction: int = 1) -> void:
|
||||||
if guard == null:
|
if guard == null:
|
||||||
return
|
return
|
||||||
var nearby: Array = []
|
var next_target: Node3D = guard.cycle_target(direction)
|
||||||
for avatar in npcs:
|
if next_target == null:
|
||||||
if not is_instance_valid(avatar):
|
|
||||||
continue
|
|
||||||
if (avatar.global_position - guard.global_position).length() <= NEARBY_RADIUS:
|
|
||||||
nearby.append(avatar)
|
|
||||||
if nearby.is_empty():
|
|
||||||
hud.log(Strings.lookup("hud.no_one_nearby"))
|
hud.log(Strings.lookup("hud.no_one_nearby"))
|
||||||
|
_refresh_nearby_hint()
|
||||||
return
|
return
|
||||||
_nearby_index = (_nearby_index + 1) % nearby.size()
|
guard.pin_target(next_target)
|
||||||
var next_target: Node3D = nearby[_nearby_index]
|
|
||||||
targeted_npc = next_target
|
|
||||||
_set_focused_avatar(next_target)
|
|
||||||
_begin_incident(next_target)
|
|
||||||
var controller: Object = _targeted_controller()
|
var controller: Object = _targeted_controller()
|
||||||
var who: String = next_target.name
|
var who: String = next_target.name
|
||||||
if controller and controller.profile and controller.profile.display_name != "":
|
if controller and controller.profile and controller.profile.display_name != "":
|
||||||
who = str(controller.profile.display_name)
|
who = str(controller.profile.display_name)
|
||||||
hud.log(Strings.fmt("hud.targeting", [who]))
|
hud.log(Strings.fmt("hud.targeting", [who]))
|
||||||
|
_refresh_nearby_hint()
|
||||||
|
|
||||||
|
## Drop the focus and hand selection back to the aim cone (right click).
|
||||||
|
func _clear_focus() -> void:
|
||||||
|
if guard:
|
||||||
|
guard.clear_target()
|
||||||
|
targeted_npc = null
|
||||||
|
_set_focused_avatar(null)
|
||||||
|
if hud:
|
||||||
|
hud.set_target_label("")
|
||||||
|
hud.set_action_hint("")
|
||||||
|
_refresh_nearby_hint()
|
||||||
|
|
||||||
|
## How many people the aim-free ring can cycle through right now.
|
||||||
|
func _refresh_nearby_hint() -> void:
|
||||||
|
if hud == null or guard == null:
|
||||||
|
return
|
||||||
|
hud.set_nearby_hint(guard.nearby_candidates().size())
|
||||||
|
|
||||||
func _apply_interaction(level: int, action_name: String) -> void:
|
func _apply_interaction(level: int, action_name: String) -> void:
|
||||||
if targeted_npc == null:
|
if targeted_npc == null:
|
||||||
@@ -819,6 +1042,7 @@ func _apply_interaction(level: int, action_name: String) -> void:
|
|||||||
var controller: Object = _targeted_controller()
|
var controller: Object = _targeted_controller()
|
||||||
if controller == null or coordinator == null:
|
if controller == null or coordinator == null:
|
||||||
return
|
return
|
||||||
|
_ensure_incident(controller)
|
||||||
var outcome: Dictionary = controller.apply_player_action(level)
|
var outcome: Dictionary = controller.apply_player_action(level)
|
||||||
var npc_id: String = controller.npc_id
|
var npc_id: String = controller.npc_id
|
||||||
coordinator.recorder.add_event(npc_id, {
|
coordinator.recorder.add_event(npc_id, {
|
||||||
@@ -829,11 +1053,88 @@ func _apply_interaction(level: int, action_name: String) -> void:
|
|||||||
coordinator.recorder.add_escalation(npc_id, level, "%s on %s" % [action_name, npc_id])
|
coordinator.recorder.add_escalation(npc_id, level, "%s on %s" % [action_name, npc_id])
|
||||||
if level >= Rules.Level.FORMAL_WARNING:
|
if level >= Rules.Level.FORMAL_WARNING:
|
||||||
coordinator.reputation.apply("prop", -1.5, "escalation L%d %s" % [level, action_name])
|
coordinator.reputation.apply("prop", -1.5, "escalation L%d %s" % [level, action_name])
|
||||||
|
# A formal warning is the documented point where the body camera / security
|
||||||
|
# light goes on: it creates a record of the interaction (PLAN Level 2).
|
||||||
|
if level == Rules.Level.FORMAL_WARNING and not body_camera_on:
|
||||||
|
_set_body_camera(true, "formal warning")
|
||||||
|
# Blocking also means physically standing between the person and the door
|
||||||
|
# (PLAN Level 3), not just sending the escalation level.
|
||||||
|
if level == Rules.Level.BLOCKING:
|
||||||
|
_station_between(controller, controller.profile.display_name if controller.profile else "them")
|
||||||
hud.set_escalation(level)
|
hud.set_escalation(level)
|
||||||
if audio:
|
if audio:
|
||||||
audio.play_escalation(level)
|
audio.play_escalation(level)
|
||||||
_react_to_outcome(controller, outcome)
|
_react_to_outcome(controller, outcome)
|
||||||
|
|
||||||
|
## Level 1 eye contact: a distinct, quieter cue than raise-hand (PLAN.md). It
|
||||||
|
## uses the same Level 1 reaction table but is logged as its own action.
|
||||||
|
func _eye_contact() -> void:
|
||||||
|
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "eye contact")
|
||||||
|
|
||||||
|
## "I recognize this person" is the player's own decision, recorded as access
|
||||||
|
## evidence rather than pre-attached to every staff/guest type (PLAN.md).
|
||||||
|
func _recognize_target() -> void:
|
||||||
|
var controller: Object = _targeted_controller()
|
||||||
|
if controller == null:
|
||||||
|
hud.log(Strings.lookup("hud.aim_first"))
|
||||||
|
return
|
||||||
|
var name: String = controller.profile.display_name if controller.profile else "them"
|
||||||
|
if controller.player_recognized:
|
||||||
|
hud.log(Strings.fmt("recognition.already", [name]))
|
||||||
|
return
|
||||||
|
_ensure_incident(controller)
|
||||||
|
controller.player_recognized = true
|
||||||
|
coordinator.recorder.add_access_evidence(controller.npc_id, {
|
||||||
|
"kind": "recognition",
|
||||||
|
"source": "guard_decision",
|
||||||
|
"recognized": true,
|
||||||
|
})
|
||||||
|
coordinator.recorder.add_event(controller.npc_id, {
|
||||||
|
"type": "recognition_decision",
|
||||||
|
"recognized": true,
|
||||||
|
"source": "player",
|
||||||
|
})
|
||||||
|
hud.log(Strings.fmt("recognition.declared", [name]))
|
||||||
|
|
||||||
|
## Level 3 ask: request the person's reason for entry. The reply comes from the
|
||||||
|
## NPC's authored `dialogue_lines`; silence is a valid, telling answer.
|
||||||
|
func _request_reason() -> void:
|
||||||
|
var controller: Object = _targeted_controller()
|
||||||
|
if controller == null:
|
||||||
|
hud.log(Strings.lookup("hud.aim_first"))
|
||||||
|
return
|
||||||
|
var name: String = controller.profile.display_name if controller.profile else "They"
|
||||||
|
_ensure_incident(controller)
|
||||||
|
var reply: String = controller.request_reason()
|
||||||
|
coordinator.recorder.add_event(controller.npc_id, {
|
||||||
|
"type": "reason_requested",
|
||||||
|
"reply": reply,
|
||||||
|
})
|
||||||
|
hud.set_escalation(Rules.Level.BLOCKING)
|
||||||
|
hud.log(Strings.fmt("reason.ask", [name]))
|
||||||
|
if reply == "":
|
||||||
|
hud.log(Strings.fmt("reason.silent", [name]))
|
||||||
|
else:
|
||||||
|
hud.log(Strings.fmt("reason.reply", [name, reply]))
|
||||||
|
|
||||||
|
## Physically move the guard between the person and the door (PLAN Level 3).
|
||||||
|
func _station_between(controller: Object, name: String) -> void:
|
||||||
|
if guard == null or targeted_npc == null:
|
||||||
|
return
|
||||||
|
var npc_pos: Vector3 = (targeted_npc as Node3D).global_position
|
||||||
|
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
|
||||||
|
var to_door := door_pos - npc_pos
|
||||||
|
to_door.y = 0.0
|
||||||
|
if to_door.length() < 0.05:
|
||||||
|
return
|
||||||
|
var station: Vector3 = npc_pos + to_door.normalized() * 0.9
|
||||||
|
station.y = guard.global_position.y
|
||||||
|
guard.global_position = station
|
||||||
|
var look_at_point := Vector3(npc_pos.x, guard.global_position.y, npc_pos.z)
|
||||||
|
if (look_at_point - guard.global_position).length() > 0.05:
|
||||||
|
guard.look_at(look_at_point, Vector3.UP)
|
||||||
|
hud.log(Strings.fmt("station.blocking", [name]))
|
||||||
|
|
||||||
func _react_to_outcome(controller: Object, outcome: Dictionary) -> void:
|
func _react_to_outcome(controller: Object, outcome: Dictionary) -> void:
|
||||||
var reaction: int = outcome.get("reaction", -1)
|
var reaction: int = outcome.get("reaction", -1)
|
||||||
var name: String = (controller.profile.display_name if controller.profile else "Person")
|
var name: String = (controller.profile.display_name if controller.profile else "Person")
|
||||||
@@ -886,9 +1187,13 @@ func _open_radio(emergency: bool) -> void:
|
|||||||
return
|
return
|
||||||
radio_panel.open(controller, emergency, current_npc_truth)
|
radio_panel.open(controller, emergency, current_npc_truth)
|
||||||
hud.set_action_hint("Fill in the radio report on screen.")
|
hud.set_action_hint("Fill in the radio report on screen.")
|
||||||
|
if audio:
|
||||||
|
audio.play_radio_open()
|
||||||
_refresh_freeze()
|
_refresh_freeze()
|
||||||
|
|
||||||
func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
|
func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
|
||||||
|
if audio:
|
||||||
|
audio.play_radio_close()
|
||||||
hud.set_action_hint("")
|
hud.set_action_hint("")
|
||||||
_refresh_freeze()
|
_refresh_freeze()
|
||||||
var result: Dictionary = coordinator.radio.request_emergency(report) if emergency else coordinator.radio.request_assistance(report)
|
var result: Dictionary = coordinator.radio.request_emergency(report) if emergency else coordinator.radio.request_assistance(report)
|
||||||
@@ -904,12 +1209,33 @@ func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
|
|||||||
if emergency:
|
if emergency:
|
||||||
coordinator.crowd.apply_event("emergency_responder")
|
coordinator.crowd.apply_event("emergency_responder")
|
||||||
hud.log(Strings.lookup("radio.emergency_dispatched"))
|
hud.log(Strings.lookup("radio.emergency_dispatched"))
|
||||||
|
# A call that the situation did not justify is reviewed afterwards
|
||||||
|
# (PLAN.md: Level 5 consequences).
|
||||||
|
if _is_unjustified_emergency():
|
||||||
|
var who: String = "the person"
|
||||||
|
var controller: Object = _targeted_controller()
|
||||||
|
if controller and controller.profile:
|
||||||
|
who = str(controller.profile.display_name)
|
||||||
|
coordinator.investigations.append(Strings.fmt("investigation.unjustified", [who]))
|
||||||
|
coordinator.recorder.add_event(current_incident_npc_id, {
|
||||||
|
"type": "investigation_opened",
|
||||||
|
"reason": "unjustified emergency call",
|
||||||
|
})
|
||||||
|
coordinator.reputation.apply("false_report", -6.0, "unjustified emergency call")
|
||||||
|
hud.log(Strings.lookup("investigation.started"))
|
||||||
else:
|
else:
|
||||||
coordinator.crowd.apply_event("calm_professionalism")
|
coordinator.crowd.apply_event("calm_professionalism")
|
||||||
if audio:
|
if audio:
|
||||||
audio.play_radio_response()
|
audio.play_radio_response()
|
||||||
_maybe_radio_easter_egg()
|
_maybe_radio_easter_egg()
|
||||||
|
|
||||||
|
## True when an emergency call is not supported by the situation: no weapon
|
||||||
|
## seen and low danger (PLAN.md: unjustified call consequences).
|
||||||
|
func _is_unjustified_emergency() -> bool:
|
||||||
|
var weapons := bool(current_npc_truth.get("weapons_seen", false))
|
||||||
|
var danger := int(current_npc_truth.get("danger_level", 0))
|
||||||
|
return not weapons and danger <= 1
|
||||||
|
|
||||||
## Rare hidden radio transmission: a stray voice asks "¿Dónde está la mota?"
|
## Rare hidden radio transmission: a stray voice asks "¿Dónde está la mota?"
|
||||||
## over the radio. Fires on a small random chance after a successful call.
|
## over the radio. Fires on a small random chance after a successful call.
|
||||||
func _maybe_radio_easter_egg() -> void:
|
func _maybe_radio_easter_egg() -> void:
|
||||||
@@ -959,6 +1285,16 @@ func _toggle_wand_screening() -> void:
|
|||||||
var controller: Object = _targeted_controller()
|
var controller: Object = _targeted_controller()
|
||||||
if controller == null:
|
if controller == null:
|
||||||
return
|
return
|
||||||
|
if controller.refuses_screening:
|
||||||
|
# Authored event: this guest refuses the screen (Joke Shop content).
|
||||||
|
hud.set_wand_icon(false)
|
||||||
|
hud.log(Strings.fmt("wand.refused", [controller.profile.display_name if controller.profile else "The guest"]))
|
||||||
|
coordinator.recorder.add_event(controller.npc_id, {
|
||||||
|
"type": "wand_refused",
|
||||||
|
"outcome": "refused",
|
||||||
|
})
|
||||||
|
coordinator.reputation.apply("prop", -1.0, "guest refused screening")
|
||||||
|
return
|
||||||
var profile: Object = controller.profile
|
var profile: Object = controller.profile
|
||||||
var items: Array = profile.carried_items if profile else []
|
var items: Array = profile.carried_items if profile else []
|
||||||
var result: Dictionary = wand_screener.screen(items)
|
var result: Dictionary = wand_screener.screen(items)
|
||||||
@@ -1079,7 +1415,19 @@ func _artifacts_for(controller: Object) -> Array:
|
|||||||
var profile: NpcProfile = controller.profile as NpcProfile
|
var profile: NpcProfile = controller.profile as NpcProfile
|
||||||
var override: Dictionary = controller.access_override
|
var override: Dictionary = controller.access_override
|
||||||
var policy = selected_scenario.access_policy if selected_scenario else null
|
var policy = selected_scenario.access_policy if selected_scenario else null
|
||||||
return AccessLoadout.build(profile, policy, override)
|
var artifacts: Array = AccessLoadout.build(profile, policy, override)
|
||||||
|
# The guard's own recognition is evidence the player created, so it is
|
||||||
|
# appended here rather than baked into every staff/guest artifact set.
|
||||||
|
if controller.player_recognized and not _has_recognition_artifact(artifacts):
|
||||||
|
artifacts.push_front(AccessArtifact.new(
|
||||||
|
Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.VALID, {"known": true, "source": "player"}))
|
||||||
|
return artifacts
|
||||||
|
|
||||||
|
func _has_recognition_artifact(artifacts: Array) -> bool:
|
||||||
|
for artifact in artifacts:
|
||||||
|
if artifact is AccessArtifact and artifact.kind == Rules.AccessKind.RECOGNITION:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
func _read_npc_truth(controller: Object) -> Dictionary:
|
func _read_npc_truth(controller: Object) -> Dictionary:
|
||||||
var profile: Object = controller.profile
|
var profile: Object = controller.profile
|
||||||
@@ -1096,6 +1444,17 @@ func _targeted_controller() -> Object:
|
|||||||
return targeted_npc.get_npc_controller()
|
return targeted_npc.get_npc_controller()
|
||||||
return null
|
return null
|
||||||
|
|
||||||
|
## Guarantee an open incident for this NPC before recording evidence, so verbs
|
||||||
|
## work even if the player acted without a prior focus change.
|
||||||
|
func _ensure_incident(controller: Object) -> void:
|
||||||
|
if controller == null or coordinator == null:
|
||||||
|
return
|
||||||
|
if coordinator.recorder.has_open_incident(controller.npc_id):
|
||||||
|
return
|
||||||
|
coordinator.recorder.start_incident(controller.npc_id, "player_action")
|
||||||
|
current_incident_npc_id = controller.npc_id
|
||||||
|
current_incident_active = true
|
||||||
|
|
||||||
func _close_incident(outcome: String) -> void:
|
func _close_incident(outcome: String) -> void:
|
||||||
if current_incident_active and not current_incident_npc_id.is_empty():
|
if current_incident_active and not current_incident_npc_id.is_empty():
|
||||||
coordinator.recorder.close_incident(current_incident_npc_id, outcome)
|
coordinator.recorder.close_incident(current_incident_npc_id, outcome)
|
||||||
|
|||||||
@@ -15,6 +15,19 @@ var targeted_npc: Node3D = null
|
|||||||
var input_locked := false
|
var input_locked := false
|
||||||
var _miss_frames := 0
|
var _miss_frames := 0
|
||||||
const MISS_GRACE := 8
|
const MISS_GRACE := 8
|
||||||
|
## Loop `Shift` halves the walk speed for close work (inspection, a guest at the
|
||||||
|
## door). It is a precision modifier, not a sprint (PLAN.md keeps movement plain).
|
||||||
|
const SLOW_WALK_FACTOR := 0.45
|
||||||
|
## Aim-free cycling (Tab / mouse wheel) has to survive the focus cone: without a
|
||||||
|
## pin the cone would drop a target the player is not looking at within a few
|
||||||
|
## frames.
|
||||||
|
var target_pinned := false
|
||||||
|
## After the player clears the focus, the cone stays quiet briefly so the person
|
||||||
|
## they were looking at is not re-acquired on the very next frame.
|
||||||
|
var focus_suppress_s := 0.0
|
||||||
|
const FOCUS_SUPPRESS_S := 0.75
|
||||||
|
## Radius used by aim-free cycling.
|
||||||
|
const NEARBY_RADIUS := 12.0
|
||||||
|
|
||||||
@onready var camera: Camera3D = $Head/Camera3D
|
@onready var camera: Camera3D = $Head/Camera3D
|
||||||
@onready var head: Node3D = $Head
|
@onready var head: Node3D = $Head
|
||||||
@@ -33,6 +46,8 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||||||
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))
|
||||||
|
|
||||||
func _physics_process(_delta: float) -> void:
|
func _physics_process(_delta: float) -> void:
|
||||||
|
if focus_suppress_s > 0.0:
|
||||||
|
focus_suppress_s = maxf(0.0, focus_suppress_s - _delta)
|
||||||
if input_locked:
|
if input_locked:
|
||||||
velocity.x = 0.0
|
velocity.x = 0.0
|
||||||
velocity.z = 0.0
|
velocity.z = 0.0
|
||||||
@@ -58,9 +73,12 @@ func _physics_process(_delta: float) -> void:
|
|||||||
var desired := (forward * input_dir.y + right * input_dir.x)
|
var desired := (forward * input_dir.y + right * input_dir.x)
|
||||||
if desired.length() > 1.0:
|
if desired.length() > 1.0:
|
||||||
desired = desired.normalized()
|
desired = desired.normalized()
|
||||||
desired *= move_speed
|
var speed := move_speed
|
||||||
velocity.x = move_toward(velocity.x, desired.x, accel * _delta * move_speed)
|
if Input.is_action_pressed("walk_slow"):
|
||||||
velocity.z = move_toward(velocity.z, desired.z, accel * _delta * move_speed)
|
speed *= SLOW_WALK_FACTOR
|
||||||
|
desired *= speed
|
||||||
|
velocity.x = move_toward(velocity.x, desired.x, accel * _delta * speed)
|
||||||
|
velocity.z = move_toward(velocity.z, desired.z, accel * _delta * speed)
|
||||||
if is_on_floor():
|
if is_on_floor():
|
||||||
velocity.y = 0.0
|
velocity.y = 0.0
|
||||||
else:
|
else:
|
||||||
@@ -71,6 +89,19 @@ func _physics_process(_delta: float) -> void:
|
|||||||
func _update_target() -> void:
|
func _update_target() -> void:
|
||||||
if camera == null:
|
if camera == null:
|
||||||
return
|
return
|
||||||
|
# Right after "clear target" the cone stays quiet for a moment, otherwise it
|
||||||
|
# would re-acquire the person the player just dismissed.
|
||||||
|
if focus_suppress_s > 0.0:
|
||||||
|
if targeted_npc != null:
|
||||||
|
targeted_npc = null
|
||||||
|
cleared_target.emit()
|
||||||
|
return
|
||||||
|
# A pinned target (chosen with Tab or the mouse wheel) is not subject to the
|
||||||
|
# cone until it is freed or the player clears it.
|
||||||
|
if target_pinned:
|
||||||
|
if targeted_npc != null and is_instance_valid(targeted_npc):
|
||||||
|
return
|
||||||
|
target_pinned = false
|
||||||
var npcs := get_tree().get_nodes_in_group("npc") if get_tree() else []
|
var npcs := get_tree().get_nodes_in_group("npc") if get_tree() else []
|
||||||
var heads: Array = []
|
var heads: Array = []
|
||||||
var avatars: Array = []
|
var avatars: Array = []
|
||||||
@@ -121,3 +152,54 @@ func _head_visible(from: Vector3, to: Vector3) -> bool:
|
|||||||
return true
|
return true
|
||||||
n = n.get_parent()
|
n = n.get_parent()
|
||||||
return false
|
return false
|
||||||
|
|
||||||
|
## Avatars within cycling range, nearest first. Aim-free selection (Tab, mouse
|
||||||
|
## wheel) walks this list so the order is predictable: closest person next.
|
||||||
|
func nearby_candidates(max_range: float = NEARBY_RADIUS) -> Array:
|
||||||
|
if get_tree() == null:
|
||||||
|
return []
|
||||||
|
var here := global_position
|
||||||
|
var out: Array = []
|
||||||
|
for node in get_tree().get_nodes_in_group("npc"):
|
||||||
|
if not (node is Node3D) or not is_instance_valid(node):
|
||||||
|
continue
|
||||||
|
var avatar := node as Node3D
|
||||||
|
if (avatar.global_position - here).length() <= max_range:
|
||||||
|
out.append(avatar)
|
||||||
|
out.sort_custom(func(a: Node3D, b: Node3D) -> bool:
|
||||||
|
return (a.global_position - here).length_squared() < (b.global_position - here).length_squared())
|
||||||
|
return out
|
||||||
|
|
||||||
|
## Next target in the aim-free ring. `direction` is +1 forward, -1 backward.
|
||||||
|
## Returns null when nobody is in range (the caller logs "no one nearby").
|
||||||
|
func cycle_target(direction: int = 1) -> Node3D:
|
||||||
|
var list := nearby_candidates()
|
||||||
|
if list.is_empty():
|
||||||
|
target_pinned = false
|
||||||
|
return null
|
||||||
|
var index := list.find(targeted_npc)
|
||||||
|
var next_index := 0
|
||||||
|
if index >= 0:
|
||||||
|
next_index = posmod(index + direction, list.size())
|
||||||
|
elif direction < 0:
|
||||||
|
next_index = list.size() - 1
|
||||||
|
target_pinned = true
|
||||||
|
return list[next_index]
|
||||||
|
|
||||||
|
## Pin a target chosen outside the cone and announce it like a cone pick.
|
||||||
|
func pin_target(avatar: Node3D) -> void:
|
||||||
|
target_pinned = avatar != null
|
||||||
|
# An explicit pick overrides the post-clear suppression window.
|
||||||
|
focus_suppress_s = 0.0
|
||||||
|
if targeted_npc == avatar:
|
||||||
|
return
|
||||||
|
targeted_npc = avatar
|
||||||
|
if avatar != null:
|
||||||
|
picked_target.emit(avatar)
|
||||||
|
else:
|
||||||
|
cleared_target.emit()
|
||||||
|
|
||||||
|
## Drop the target and hand focus back to the focus cone.
|
||||||
|
func clear_target() -> void:
|
||||||
|
target_pinned = false
|
||||||
|
focus_suppress_s = FOCUS_SUPPRESS_S
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
class_name SurfaceMaterials
|
||||||
|
extends RefCounted
|
||||||
|
## Shared surface recipes for the procedural world (ADR-0012).
|
||||||
|
##
|
||||||
|
## Godot's `StandardMaterial3D` defaults are flat: every surface comes out with
|
||||||
|
## the same roughness and a plain albedo, which is what made the street read as
|
||||||
|
## grey boxes. These helpers give each surface a physically plausible profile
|
||||||
|
## (brick is rough, glass is smooth, metal is metallic) and keep the textures,
|
||||||
|
## UV scaling, and texture filtering in one place so the world builder, the NPC
|
||||||
|
## avatars, and the props all look like one scene.
|
||||||
|
|
||||||
|
const T_BRICK := preload("res://assets/textures/ai/game_ready/brick_facade.png")
|
||||||
|
const T_CONCRETE := preload("res://assets/textures/ai/game_ready/concrete.png")
|
||||||
|
const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.png")
|
||||||
|
const T_AWNING := preload("res://assets/textures/ai/game_ready/awning.png")
|
||||||
|
|
||||||
|
## Roughness / metallic pairs per surface role. Exposed so tests and tooling can
|
||||||
|
## assert the profiles instead of hard-coding numbers twice.
|
||||||
|
const PROFILES := {
|
||||||
|
"brick": {"roughness": 0.88, "metallic": 0.0},
|
||||||
|
"concrete": {"roughness": 0.94, "metallic": 0.0},
|
||||||
|
"asphalt": {"roughness": 0.72, "metallic": 0.0},
|
||||||
|
"awning": {"roughness": 0.80, "metallic": 0.0},
|
||||||
|
"stone": {"roughness": 0.88, "metallic": 0.0},
|
||||||
|
"metal": {"roughness": 0.32, "metallic": 0.75},
|
||||||
|
"painted_metal": {"roughness": 0.48, "metallic": 0.35},
|
||||||
|
"wood": {"roughness": 0.70, "metallic": 0.0},
|
||||||
|
"cloth": {"roughness": 0.85, "metallic": 0.0},
|
||||||
|
"skin": {"roughness": 0.62, "metallic": 0.0},
|
||||||
|
"glass": {"roughness": 0.12, "metallic": 0.10},
|
||||||
|
"rubber": {"roughness": 0.95, "metallic": 0.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
static func profile(role: String) -> Dictionary:
|
||||||
|
return PROFILES.get(role, PROFILES["concrete"])
|
||||||
|
|
||||||
|
## Base textured surface with a named profile.
|
||||||
|
static func textured(texture: Texture2D, tint: Color, uv_scale: Vector2,
|
||||||
|
role: String = "concrete") -> StandardMaterial3D:
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.albedo_texture = texture
|
||||||
|
mat.albedo_color = tint
|
||||||
|
mat.uv1_scale = Vector3(uv_scale.x, uv_scale.y, 1.0)
|
||||||
|
mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC
|
||||||
|
mat.roughness = float(profile(role).get("roughness", 0.9))
|
||||||
|
mat.metallic = float(profile(role).get("metallic", 0.0))
|
||||||
|
return mat
|
||||||
|
|
||||||
|
static func brick(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(10, 5)) -> StandardMaterial3D:
|
||||||
|
return textured(T_BRICK, tint, uv_scale, "brick")
|
||||||
|
|
||||||
|
static func concrete(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(9, 2.4)) -> StandardMaterial3D:
|
||||||
|
return textured(T_CONCRETE, tint, uv_scale, "concrete")
|
||||||
|
|
||||||
|
static func asphalt(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(30, 7)) -> StandardMaterial3D:
|
||||||
|
var mat := textured(T_ASPHALT, tint, uv_scale, "asphalt")
|
||||||
|
# Damp asphalt at night picks up the street lamps and the marquee.
|
||||||
|
mat.metallic_specular = 0.6
|
||||||
|
return mat
|
||||||
|
|
||||||
|
static func awning(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(4, 1)) -> StandardMaterial3D:
|
||||||
|
return textured(T_AWNING, tint, uv_scale, "awning")
|
||||||
|
|
||||||
|
## Flat-coloured surface with a profile (props, trim, limbs, clothing).
|
||||||
|
static func flat(color: Color, role: String = "concrete") -> StandardMaterial3D:
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.albedo_color = color
|
||||||
|
mat.roughness = float(profile(role).get("roughness", 0.9))
|
||||||
|
mat.metallic = float(profile(role).get("metallic", 0.0))
|
||||||
|
return mat
|
||||||
|
|
||||||
|
## Self-lit surface (marquee bars, lamp bulbs, lock indicator).
|
||||||
|
static func emissive(color: Color, energy: float = 1.4) -> StandardMaterial3D:
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.albedo_color = color
|
||||||
|
mat.emission_enabled = true
|
||||||
|
mat.emission = color
|
||||||
|
mat.emission_energy_multiplier = energy
|
||||||
|
mat.roughness = 0.4
|
||||||
|
return mat
|
||||||
|
|
||||||
|
## Dark glass for windows and the entrance door. The alpha keeps the interior
|
||||||
|
## visible, and the low roughness gives the facade a highlight instead of a hole.
|
||||||
|
static func glass(color: Color = Color(0.42, 0.56, 0.72, 0.75)) -> StandardMaterial3D:
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.albedo_color = color
|
||||||
|
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||||
|
mat.roughness = float(profile("glass").get("roughness", 0.12))
|
||||||
|
mat.metallic = 0.15
|
||||||
|
mat.metallic_specular = 0.8
|
||||||
|
return mat
|
||||||
|
|
||||||
|
## Window pane that can be lit from the inside at night. Solid dark glass reads
|
||||||
|
## as a window at midday; alpha glass just looked like white paper on the brick.
|
||||||
|
static func window_pane() -> StandardMaterial3D:
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.albedo_color = Color(0.09, 0.12, 0.17)
|
||||||
|
mat.roughness = 0.18
|
||||||
|
mat.metallic = 0.35
|
||||||
|
mat.metallic_specular = 0.6
|
||||||
|
mat.emission_enabled = true
|
||||||
|
mat.emission = Color(1.0, 0.86, 0.62)
|
||||||
|
mat.emission_energy_multiplier = 0.0
|
||||||
|
return mat
|
||||||
|
|
||||||
|
## Environment look shared by every phase: ACES tonemapping, soft glow, aerial
|
||||||
|
## fog for depth, and a mild contrast/saturation lift. Phase values come from
|
||||||
|
## `GameManager.PHASE_LIGHTING`.
|
||||||
|
static func configure_environment(env: Environment, cfg: Dictionary) -> void:
|
||||||
|
if env == null:
|
||||||
|
return
|
||||||
|
env.background_mode = Environment.BG_SKY
|
||||||
|
env.tonemap_mode = Environment.TONE_MAPPER_ACES
|
||||||
|
env.tonemap_exposure = float(cfg.get("exposure", 1.0))
|
||||||
|
env.tonemap_white = 6.0
|
||||||
|
env.ambient_light_energy = float(cfg.get("ambient_energy", 0.5))
|
||||||
|
env.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
|
||||||
|
# Glow makes the marquee, lamp bulbs, and windows read as light sources.
|
||||||
|
env.glow_enabled = true
|
||||||
|
env.glow_normalized = true
|
||||||
|
env.glow_intensity = float(cfg.get("glow", 0.55))
|
||||||
|
env.glow_strength = 1.0
|
||||||
|
env.glow_bloom = 0.08
|
||||||
|
env.glow_hdr_threshold = 1.0
|
||||||
|
env.glow_blend_mode = Environment.GLOW_BLEND_MODE_SOFTLIGHT
|
||||||
|
# Aerial perspective: the skyline ring sits ~200 m out, so light fog gives the
|
||||||
|
# distance its haze without hiding the street.
|
||||||
|
env.fog_enabled = true
|
||||||
|
env.fog_light_color = cfg.get("fog_color", Color(0.72, 0.78, 0.88))
|
||||||
|
env.fog_light_energy = float(cfg.get("fog_energy", 0.7))
|
||||||
|
env.fog_density = float(cfg.get("fog_density", 0.0035))
|
||||||
|
env.fog_aerial_perspective = 0.25
|
||||||
|
env.fog_sky_affect = 0.4
|
||||||
|
env.adjustment_enabled = true
|
||||||
|
env.adjustment_brightness = 1.0
|
||||||
|
env.adjustment_contrast = float(cfg.get("contrast", 1.06))
|
||||||
|
env.adjustment_saturation = float(cfg.get("saturation", 1.08))
|
||||||
@@ -5,6 +5,7 @@ extends CharacterBody3D
|
|||||||
|
|
||||||
const NPCController := preload("res://src/core/npc_controller.gd")
|
const NPCController := preload("res://src/core/npc_controller.gd")
|
||||||
const Rules := preload("res://src/core/rules.gd")
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
|
const Materials := preload("res://src/game/materials.gd")
|
||||||
|
|
||||||
var npc: NPCController = null
|
var npc: NPCController = null
|
||||||
var spawn_position: Vector3 = Vector3.ZERO
|
var spawn_position: Vector3 = Vector3.ZERO
|
||||||
@@ -17,6 +18,14 @@ var arrived := false
|
|||||||
var idle_s := 0.0
|
var idle_s := 0.0
|
||||||
var lifetime_s := 90.0
|
var lifetime_s := 90.0
|
||||||
var ephemeral := true
|
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
|
||||||
@@ -64,6 +73,15 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
|
|||||||
agent.target_desired_distance = 4.0
|
agent.target_desired_distance = 4.0
|
||||||
agent.radius = 0.35
|
agent.radius = 0.35
|
||||||
agent.height = 1.2
|
agent.height = 1.2
|
||||||
|
# Steer around street furniture instead of walking into it (props publish a
|
||||||
|
# NavigationObstacle3D; the navmesh itself stays a flat walkable plane).
|
||||||
|
agent.avoidance_enabled = true
|
||||||
|
agent.neighbor_distance = 4.0
|
||||||
|
agent.max_neighbors = 8
|
||||||
|
agent.time_horizon_agents = 0.6
|
||||||
|
agent.time_horizon_obstacles = 1.2
|
||||||
|
agent.max_speed = FLEE_SPEED
|
||||||
|
agent.velocity_computed.connect(_on_safe_velocity)
|
||||||
add_child(agent)
|
add_child(agent)
|
||||||
|
|
||||||
# Normalize intent targets: approach the entrance, stop near the door.
|
# Normalize intent targets: approach the entrance, stop near the door.
|
||||||
@@ -81,66 +99,140 @@ 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 _focus_circle: MeshInstance3D = null
|
||||||
var _name_tag: Label3D = 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 _arms: Array[MeshInstance3D] = []
|
||||||
|
var _theater_t := 0.0
|
||||||
|
var _avoidance_active := false
|
||||||
|
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)]
|
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)]
|
||||||
|
|
||||||
|
## Which small accessory a role wears. This is look-only: the player must still
|
||||||
|
## read intent from behaviour, never from the archetype name (ADR-0010).
|
||||||
|
const ACCESSORIES := {
|
||||||
|
Rules.NpcType.AUTHORIZED_STAFF: "cap",
|
||||||
|
Rules.NpcType.RESPONDER: "cap",
|
||||||
|
Rules.NpcType.DELIVERY: "cap",
|
||||||
|
Rules.NpcType.TOURIST: "backpack",
|
||||||
|
Rules.NpcType.INFLUENCER: "sunglasses",
|
||||||
|
Rules.NpcType.TRESPASSER: "hood",
|
||||||
|
Rules.NpcType.VIOLENT_THREAT: "hood",
|
||||||
|
Rules.NpcType.MENTAL_CRISIS: "hood",
|
||||||
|
Rules.NpcType.AUTHORIZED_GUEST: "lanyard",
|
||||||
|
Rules.NpcType.PERFORMER: "none",
|
||||||
|
Rules.NpcType.PROTESTER: "none",
|
||||||
|
Rules.NpcType.INTOXICATED: "none",
|
||||||
|
Rules.NpcType.CONFUSED_VISITOR: "none",
|
||||||
|
}
|
||||||
|
|
||||||
|
## Clothing palette for one avatar. Pure and seed-driven so crowds read as
|
||||||
|
## individuals and the result is deterministic (tests rely on that).
|
||||||
|
static func outfit_for(npc_type: int, seed_value: int) -> Dictionary:
|
||||||
|
var base: Color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
|
||||||
|
var frac := float((seed_value * 1103515245 + 12345) % 1000) / 1000.0
|
||||||
|
var shirt := base.lerp(Color(0.92, 0.92, 0.94), frac * 0.22)
|
||||||
|
shirt = shirt.darkened(frac * 0.12)
|
||||||
|
# Jeans through khaki/chino, so not every avatar wears the same trousers.
|
||||||
|
var pants := Color(0.16, 0.18, 0.23).lerp(Color(0.46, 0.38, 0.28), frac)
|
||||||
|
var hair_index := (seed_value * 7) % HAIR_COLORS.size()
|
||||||
|
return {
|
||||||
|
"shirt": shirt,
|
||||||
|
"sleeves": shirt.darkened(0.16),
|
||||||
|
"pants": pants,
|
||||||
|
"hair": HAIR_COLORS[hair_index],
|
||||||
|
"skin": SKIN_TONE.darkened(frac * 0.18),
|
||||||
|
"accessory": ACCESSORIES.get(npc_type, "none"),
|
||||||
|
"height_scale": 0.92 + 0.16 * frac,
|
||||||
|
"blend": frac,
|
||||||
|
}
|
||||||
|
|
||||||
func _build_body(npc_type: int) -> void:
|
func _build_body(npc_type: int) -> void:
|
||||||
_rig = Node3D.new()
|
_rig = Node3D.new()
|
||||||
_rig.name = "Rig"
|
_rig.name = "Rig"
|
||||||
add_child(_rig)
|
add_child(_rig)
|
||||||
var base_color: Color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
|
var outfit := outfit_for(npc_type, _visual_seed)
|
||||||
# Per-seed variation: slight height and brightness differences so crowds
|
var shirt: Color = outfit["shirt"]
|
||||||
# read as individuals (deterministic LCG, no RNG side effects).
|
var sleeves: Color = outfit["sleeves"]
|
||||||
var frac := float((_visual_seed * 1103515245 + 12345) % 1000) / 1000.0
|
var pants: Color = outfit["pants"]
|
||||||
var height_scale := 0.92 + 0.16 * frac
|
var skin: Color = outfit["skin"]
|
||||||
_rig.scale.y = height_scale
|
var hair: Color = outfit["hair"]
|
||||||
var shader_delta := 0.12 * frac
|
_rig.scale.y = float(outfit["height_scale"])
|
||||||
base_color = base_color.lightened(shader_delta) if frac < 0.5 else base_color.darkened(shader_delta)
|
var limbs_color := sleeves
|
||||||
var limbs_color := base_color.darkened(0.25)
|
# Torso, with a shoulder block and hips so the silhouette is not a tube.
|
||||||
# Torso
|
_add_capsule(_rig, 0.30, 0.90, Vector3(0, 1.02, 0), shirt, 0.0, "cloth")
|
||||||
_add_capsule(_rig, 0.30, 0.95, Vector3(0, 1.05, 0), base_color)
|
_add_box_mesh(_rig, Vector3(0.58, 0.14, 0.28), Vector3(0, 1.36, 0), shirt, "cloth")
|
||||||
# Legs
|
_add_box_mesh(_rig, Vector3(0.44, 0.18, 0.26), Vector3(0, 0.62, 0), pants, "cloth")
|
||||||
_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), PANTS_COLOR)
|
# Legs and arms are tracked so the walk cycle can swing them.
|
||||||
_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, 0.0, "cloth"))
|
||||||
# Arms
|
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), pants, 0.0, "cloth"))
|
||||||
_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0)
|
_arms.append(_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0, "cloth"))
|
||||||
_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0)
|
_arms.append(_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0, "cloth"))
|
||||||
|
# Hands and shoes give the limbs an end point at conversation distance.
|
||||||
|
_add_sphere(_rig, 0.065, Vector3(-0.47, 0.94, 0.03), skin, "skin")
|
||||||
|
_add_sphere(_rig, 0.065, Vector3(0.47, 0.94, 0.03), skin, "skin")
|
||||||
# Neck, head, face, hair, shoes so the body reads as a person (ADR-0010).
|
# 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)
|
_add_capsule(_rig, 0.08, 0.16, Vector3(0, 1.48, 0), skin, 0.0, "skin")
|
||||||
_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, "skin")
|
||||||
var hair_i := (_visual_seed * 7) % HAIR_COLORS.size()
|
_add_sphere(_head, 0.18, Vector3(0, 0.08, -0.02), hair, "cloth")
|
||||||
_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), "glass")
|
||||||
_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), "glass")
|
||||||
_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_mesh(_rig, Vector3(0.16, 0.08, 0.28), Vector3(-0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14), "rubber")
|
||||||
_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_mesh(_rig, Vector3(0.16, 0.08, 0.28), Vector3(0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14), "rubber")
|
||||||
_build_focus_ring()
|
_add_accessory(str(outfit["accessory"]), shirt)
|
||||||
|
_build_focus_circle()
|
||||||
_add_props(npc_type)
|
_add_props(npc_type)
|
||||||
|
|
||||||
func _build_focus_ring() -> void:
|
## Small role accessory: cap, hood, backpack, sunglasses or a staff lanyard.
|
||||||
_ring = MeshInstance3D.new()
|
func _add_accessory(kind: String, shirt: Color) -> void:
|
||||||
_ring.name = "FocusRing"
|
match kind:
|
||||||
var torus := TorusMesh.new()
|
"cap":
|
||||||
torus.inner_radius = 0.26
|
_add_sphere(_head, 0.175, Vector3(0, 0.12, 0), shirt.darkened(0.15), "cloth")
|
||||||
torus.outer_radius = 0.34
|
_add_box_mesh(_head, Vector3(0.30, 0.03, 0.16), Vector3(0, 0.10, 0.16), shirt.darkened(0.22), "cloth")
|
||||||
torus.rings = 12
|
"hood":
|
||||||
torus.ring_segments = 16
|
_add_sphere(_head, 0.205, Vector3(0, 0.03, -0.03), Color(0.16, 0.16, 0.18), "cloth")
|
||||||
_ring.mesh = torus
|
"backpack":
|
||||||
|
_add_box_mesh(_rig, Vector3(0.34, 0.42, 0.16), Vector3(0, 1.06, -0.24), Color(0.24, 0.28, 0.36), "cloth")
|
||||||
|
_add_box_mesh(_rig, Vector3(0.30, 0.06, 0.02), Vector3(0, 1.24, -0.33), Color(0.42, 0.44, 0.5), "cloth")
|
||||||
|
"sunglasses":
|
||||||
|
_add_box_mesh(_head, Vector3(0.24, 0.05, 0.03), Vector3(0, 0.04, 0.15), Color(0.06, 0.06, 0.08), "glass")
|
||||||
|
"lanyard":
|
||||||
|
_add_box_mesh(_rig, Vector3(0.05, 0.06, 0.02), Vector3(0, 1.20, 0.16), Color(0.85, 0.78, 0.30), "painted_metal")
|
||||||
|
_add_box_mesh(_rig, Vector3(0.02, 0.20, 0.02), Vector3(-0.08, 1.34, 0.14), Color(0.3, 0.32, 0.36), "cloth")
|
||||||
|
_add_box_mesh(_rig, Vector3(0.02, 0.20, 0.02), Vector3(0.08, 1.34, 0.14), Color(0.3, 0.32, 0.36), "cloth")
|
||||||
|
_:
|
||||||
|
pass
|
||||||
|
|
||||||
|
func _build_focus_circle() -> void:
|
||||||
|
_focus_circle = MeshInstance3D.new()
|
||||||
|
_focus_circle.name = "FocusCircle"
|
||||||
|
# Blue, semi-transparent disc floating in front of the face (ADR-0008).
|
||||||
|
# Billboarded so it always reads as a circle on screen.
|
||||||
|
var disc := CylinderMesh.new()
|
||||||
|
disc.top_radius = 0.15
|
||||||
|
disc.bottom_radius = 0.15
|
||||||
|
disc.height = 0.001
|
||||||
|
disc.radial_segments = 24
|
||||||
|
_focus_circle.mesh = disc
|
||||||
var mat := StandardMaterial3D.new()
|
var mat := StandardMaterial3D.new()
|
||||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
mat.albedo_color = Color(0.95, 0.82, 0.28, 0.9)
|
mat.albedo_color = Color(0.25, 0.55, 1.0, 0.45)
|
||||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||||
_ring.material_override = mat
|
mat.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
|
||||||
_ring.position = Vector3(0, 0.28, 0)
|
_focus_circle.material_override = mat
|
||||||
_ring.visible = false
|
_focus_circle.position = Vector3(0, 0, 0.30)
|
||||||
_head.add_child(_ring)
|
_focus_circle.visible = false
|
||||||
|
_head.add_child(_focus_circle)
|
||||||
_name_tag = Label3D.new()
|
_name_tag = Label3D.new()
|
||||||
_name_tag.name = "NameTag"
|
_name_tag.name = "NameTag"
|
||||||
_name_tag.text = npc.profile.display_name if npc and npc.profile else ""
|
_name_tag.text = npc.profile.display_name if npc and npc.profile else ""
|
||||||
@@ -153,16 +245,17 @@ func _build_focus_ring() -> void:
|
|||||||
_head.add_child(_name_tag)
|
_head.add_child(_name_tag)
|
||||||
|
|
||||||
func set_focus_visual(focused: bool, menu_open: bool = false) -> void:
|
func set_focus_visual(focused: bool, menu_open: bool = false) -> void:
|
||||||
if _ring:
|
if _focus_circle:
|
||||||
_ring.visible = focused
|
_focus_circle.visible = focused
|
||||||
var mat := _ring.material_override as StandardMaterial3D
|
var mat := _focus_circle.material_override as StandardMaterial3D
|
||||||
if mat:
|
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)
|
mat.albedo_color = Color(0.35, 0.65, 1.0, 0.7) if menu_open else Color(0.25, 0.55, 1.0, 0.45)
|
||||||
_ring.scale = Vector3.ONE * (1.15 if menu_open else 1.0)
|
_focus_circle.scale = Vector3.ONE * (1.15 if menu_open else 1.0)
|
||||||
if _name_tag:
|
if _name_tag:
|
||||||
_name_tag.visible = focused
|
_name_tag.visible = focused
|
||||||
|
|
||||||
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> void:
|
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3,
|
||||||
|
color: Color, z_rot: float = 0.0, role: String = "cloth") -> MeshInstance3D:
|
||||||
var mi := MeshInstance3D.new()
|
var mi := MeshInstance3D.new()
|
||||||
var mesh := CapsuleMesh.new()
|
var mesh := CapsuleMesh.new()
|
||||||
mesh.radius = radius
|
mesh.radius = radius
|
||||||
@@ -170,30 +263,42 @@ func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, co
|
|||||||
mi.mesh = mesh
|
mi.mesh = mesh
|
||||||
mi.position = pos
|
mi.position = pos
|
||||||
mi.rotation_degrees.z = z_rot
|
mi.rotation_degrees.z = z_rot
|
||||||
mi.material_override = _material(color)
|
mi.material_override = _material(color, role)
|
||||||
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,
|
||||||
|
role: String = "skin") -> void:
|
||||||
var mi := MeshInstance3D.new()
|
var mi := MeshInstance3D.new()
|
||||||
var mesh := SphereMesh.new()
|
var mesh := SphereMesh.new()
|
||||||
mesh.radius = radius
|
mesh.radius = radius
|
||||||
mesh.height = radius * 2.0
|
mesh.height = radius * 2.0
|
||||||
mi.mesh = mesh
|
mi.mesh = mesh
|
||||||
mi.position = pos
|
mi.position = pos
|
||||||
mi.material_override = _material(color)
|
mi.material_override = _material(color, role)
|
||||||
parent.add_child(mi)
|
parent.add_child(mi)
|
||||||
|
|
||||||
func _material(color: Color) -> StandardMaterial3D:
|
func _add_box_mesh(parent: Node3D, size: Vector3, pos: Vector3, color: Color,
|
||||||
var mat := StandardMaterial3D.new()
|
role: String = "cloth") -> MeshInstance3D:
|
||||||
mat.albedo_color = color
|
var mi := MeshInstance3D.new()
|
||||||
return mat
|
var mesh := BoxMesh.new()
|
||||||
|
mesh.size = size
|
||||||
|
mi.mesh = mesh
|
||||||
|
mi.position = pos
|
||||||
|
mi.material_override = _material(color, role)
|
||||||
|
parent.add_child(mi)
|
||||||
|
return mi
|
||||||
|
|
||||||
|
func _material(color: Color, role: String = "cloth") -> StandardMaterial3D:
|
||||||
|
return Materials.flat(color, role)
|
||||||
|
|
||||||
## Small per-archetype props so silhouettes differ (camera, box, hat, sign…).
|
## Small per-archetype props so silhouettes differ (camera, box, hat, sign…).
|
||||||
func _add_props(npc_type: int) -> void:
|
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))
|
||||||
@@ -208,15 +313,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
|
||||||
@@ -224,31 +336,168 @@ 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
|
||||||
|
|
||||||
|
## Opposite arm/leg swing for the walk cycle. `side` is 0 or 1; pure so the gait
|
||||||
|
## can be checked without a scene tree.
|
||||||
|
static func limb_swing(phase: float, side: int, amplitude: float) -> float:
|
||||||
|
return sin(phase + float(side) * PI) * amplitude
|
||||||
|
|
||||||
|
## 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
|
||||||
|
for i in _legs.size():
|
||||||
|
_legs[i].rotation.x = limb_swing(_bob_phase, i, 0.5)
|
||||||
|
for i in _arms.size():
|
||||||
|
_arms[i].rotation.x = limb_swing(_bob_phase, i, -0.38)
|
||||||
|
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)
|
||||||
|
for arm in _arms:
|
||||||
|
arm.rotation.x = lerpf(arm.rotation.x, 0.0, 0.14)
|
||||||
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:
|
for i in _legs.size():
|
||||||
var cam := get_viewport().get_camera_3d() if get_viewport() else null
|
_legs[i].rotation.x = lerpf(_legs[i].rotation.x, 0.0, 0.2)
|
||||||
if cam:
|
for arm in _arms:
|
||||||
_ring.look_at(cam.global_position, Vector3.UP)
|
arm.rotation.x = lerpf(arm.rotation.x, 0.0, 0.2)
|
||||||
|
# The focus circle billboards via its material; no per-frame look_at needed.
|
||||||
# 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):
|
||||||
@@ -264,7 +513,7 @@ func _animate(delta: float) -> void:
|
|||||||
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
|
# Routine traffic despawns after a while once it has finished its walk, so
|
||||||
# the crowd cap is not permanently occupied by idle standers.
|
# the crowd cap is not permanently occupied by idle standers.
|
||||||
if ephemeral and arrived and npc.fsm.state != "LEAVING":
|
if ephemeral and arrived and npc.fsm.current_state != "LEAVING":
|
||||||
idle_s += delta
|
idle_s += delta
|
||||||
if idle_s >= lifetime_s:
|
if idle_s >= lifetime_s:
|
||||||
leave_requested_by_lifetime = true
|
leave_requested_by_lifetime = true
|
||||||
@@ -288,10 +537,15 @@ func _advance_fsm(delta: float) -> void:
|
|||||||
var leave_requested_by_lifetime := false
|
var leave_requested_by_lifetime := false
|
||||||
|
|
||||||
func _move_toward_target(delta: float) -> void:
|
func _move_toward_target(delta: float) -> void:
|
||||||
|
_avoidance_active = false
|
||||||
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()
|
||||||
@@ -308,20 +562,57 @@ 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.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)
|
||||||
|
if agent.avoidance_enabled:
|
||||||
|
# The navigation server returns the obstacle-aware velocity through
|
||||||
|
# velocity_computed; never dash straight at the point in the meantime.
|
||||||
|
_avoidance_active = true
|
||||||
|
agent.velocity = Vector3(dir.x * speed, 0.0, dir.z * speed)
|
||||||
|
else:
|
||||||
|
velocity.x = dir.x * speed
|
||||||
|
velocity.z = dir.z * speed
|
||||||
|
velocity.y -= 9.8 * delta
|
||||||
|
move_and_slide()
|
||||||
|
|
||||||
|
## Safe velocity from NavigationServer avoidance: move with the steered vector.
|
||||||
|
func _on_safe_velocity(safe: Vector3) -> void:
|
||||||
|
if not _avoidance_active:
|
||||||
|
return
|
||||||
|
velocity.x = safe.x
|
||||||
|
velocity.z = safe.z
|
||||||
|
if is_on_floor():
|
||||||
|
velocity.y = 0.0
|
||||||
|
else:
|
||||||
|
velocity.y -= 9.8 * get_physics_process_delta_time()
|
||||||
move_and_slide()
|
move_and_slide()
|
||||||
|
|
||||||
## Fallback used when no navigation mesh is available (e.g. minimal scenes).
|
## Fallback used when no navigation mesh is available (e.g. minimal scenes).
|
||||||
func _move_direct(delta: float) -> void:
|
func _move_direct(delta: float) -> void:
|
||||||
|
_avoidance_active = false
|
||||||
var to_target := target_position - global_position
|
var to_target := target_position - global_position
|
||||||
to_target.y = 0.0
|
to_target.y = 0.0
|
||||||
var dist := to_target.length()
|
var dist := to_target.length()
|
||||||
@@ -329,8 +620,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()
|
||||||
|
|||||||
@@ -20,14 +20,26 @@ static func score_head(cam_origin: Vector3, cam_forward: Vector3, head_pos: Vect
|
|||||||
return ang
|
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:
|
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 ranked := rank_indices(cam_origin, cam_forward, head_positions, max_range, max_angle)
|
||||||
var best_score := 999.0
|
return ranked[0] if not ranked.is_empty() else -1
|
||||||
|
|
||||||
|
## Every candidate inside the cone, best first: smallest angle to the crosshair,
|
||||||
|
## then nearest. The cone used to return position in the group, which made the
|
||||||
|
## pick order depend on spawn order; ranking by angle matches what the player
|
||||||
|
## sees and gives `Tab` / the mouse wheel a stable order too.
|
||||||
|
static func rank_indices(cam_origin: Vector3, cam_forward: Vector3, head_positions: Array, max_range: float = MAX_RANGE, max_angle: float = MAX_ANGLE_RAD) -> Array:
|
||||||
|
var scored: Array = []
|
||||||
for i in head_positions.size():
|
for i in head_positions.size():
|
||||||
var pos: Vector3 = head_positions[i]
|
var pos: Vector3 = head_positions[i]
|
||||||
var score := score_head(cam_origin, cam_forward, pos, max_range, max_angle)
|
var score := score_head(cam_origin, cam_forward, pos, max_range, max_angle)
|
||||||
if score < 0.0:
|
if score < 0.0:
|
||||||
continue
|
continue
|
||||||
if score < best_score:
|
scored.append([score, (pos - cam_origin).length(), i])
|
||||||
best_score = score
|
scored.sort_custom(func(a: Array, b: Array) -> bool:
|
||||||
best_i = i
|
if not is_equal_approx(float(a[0]), float(b[0])):
|
||||||
return best_i
|
return float(a[0]) < float(b[0])
|
||||||
|
return float(a[1]) < float(b[1]))
|
||||||
|
var ranked: Array = []
|
||||||
|
for entry in scored:
|
||||||
|
ranked.append(int(entry[2]))
|
||||||
|
return ranked
|
||||||
@@ -10,6 +10,7 @@ const Strings := preload("res://src/core/strings.gd")
|
|||||||
|
|
||||||
const SLICE_DEFS := [
|
const SLICE_DEFS := [
|
||||||
{"id": "raise_hand", "label_key": "radial.raise_hand", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "raise hand"},
|
{"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": "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": "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": "polite", "label_key": "radial.polite", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "polite direction"},
|
||||||
@@ -17,6 +18,8 @@ const SLICE_DEFS := [
|
|||||||
{"id": "wand", "label_key": "radial.wand", "level": -1, "action": "wand"},
|
{"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": "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": "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"},
|
{"id": "radio", "label_key": "radial.radio", "level": -1, "action": "radio assist"},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -146,8 +149,8 @@ func _on_wheel_draw() -> void:
|
|||||||
return
|
return
|
||||||
var center := _wheel.size * 0.5
|
var center := _wheel.size * 0.5
|
||||||
var n := SLICE_DEFS.size()
|
var n := SLICE_DEFS.size()
|
||||||
var outer := 150.0
|
var outer := 174.0
|
||||||
var inner := 58.0
|
var inner := 64.0
|
||||||
for i in n:
|
for i in n:
|
||||||
var a0 := -PI / 2.0 + TAU * float(i) / float(n)
|
var a0 := -PI / 2.0 + TAU * float(i) / float(n)
|
||||||
var a1 := -PI / 2.0 + TAU * float(i + 1) / float(n)
|
var a1 := -PI / 2.0 + TAU * float(i + 1) / float(n)
|
||||||
@@ -160,7 +163,10 @@ func _on_wheel_draw() -> void:
|
|||||||
var mid := (a0 + a1) * 0.5
|
var mid := (a0 + a1) * 0.5
|
||||||
var label_pos := center + Vector2(cos(mid), sin(mid)) * ((inner + outer) * 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"]))
|
var text := Strings.lookup(str(SLICE_DEFS[i]["label_key"]))
|
||||||
_wheel.draw_string(ThemeDB.fallback_font, label_pos + Vector2(-28, 4), text, HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color.WHITE)
|
# 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:
|
func _draw_slice(center: Vector2, inner: float, outer: float, a0: float, a1: float, color: Color) -> void:
|
||||||
var pts := PackedVector2Array()
|
var pts := PackedVector2Array()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -26,6 +26,7 @@ const CARD_TEXTURES := {
|
|||||||
var top_label: Label
|
var top_label: Label
|
||||||
var time_label: Label
|
var time_label: Label
|
||||||
var target_label: Label
|
var target_label: Label
|
||||||
|
var nearby_label: Label
|
||||||
var hint_label: Label
|
var hint_label: Label
|
||||||
var log_label: Label
|
var log_label: Label
|
||||||
var debug_label: Label
|
var debug_label: Label
|
||||||
@@ -47,6 +48,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"
|
||||||
@@ -81,6 +86,15 @@ func _init() -> void:
|
|||||||
target_label.add_theme_font_size_override("font_size", 16)
|
target_label.add_theme_font_size_override("font_size", 16)
|
||||||
vbox.add_child(target_label)
|
vbox.add_child(target_label)
|
||||||
|
|
||||||
|
nearby_label = Label.new()
|
||||||
|
nearby_label.add_theme_font_size_override("font_size", 12)
|
||||||
|
nearby_label.modulate = Color(0.92, 0.95, 1.0)
|
||||||
|
# A shadow keeps the hint readable over the bright sidewalk and the sky.
|
||||||
|
nearby_label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.7))
|
||||||
|
nearby_label.add_theme_constant_override("shadow_offset_x", 1)
|
||||||
|
nearby_label.add_theme_constant_override("shadow_offset_y", 1)
|
||||||
|
vbox.add_child(nearby_label)
|
||||||
|
|
||||||
hint_label = Label.new()
|
hint_label = Label.new()
|
||||||
hint_label.modulate = Color(1, 0.9, 0.6)
|
hint_label.modulate = Color(1, 0.9, 0.6)
|
||||||
hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
@@ -138,11 +152,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)
|
||||||
@@ -258,6 +280,12 @@ func _update_radio_cooldown(coordinator: Object) -> void:
|
|||||||
func set_target_label(text: String) -> void:
|
func set_target_label(text: String) -> void:
|
||||||
target_label.text = text
|
target_label.text = text
|
||||||
|
|
||||||
|
## How many people the aim-free ring (Tab / mouse wheel) can cycle through.
|
||||||
|
func set_nearby_hint(count: int) -> void:
|
||||||
|
if nearby_label == null:
|
||||||
|
return
|
||||||
|
nearby_label.text = Strings.fmt("hud.nearby", [count]) if count > 0 else ""
|
||||||
|
|
||||||
var _flashlight := false
|
var _flashlight := false
|
||||||
var _door_locked := false
|
var _door_locked := false
|
||||||
|
|
||||||
@@ -279,17 +307,31 @@ 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).
|
## 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:
|
func set_font_scale(scale: float) -> void:
|
||||||
var clamped := clampf(scale, 0.75, 1.75)
|
var clamped := clampf(scale, 0.75, 1.75)
|
||||||
if top_label:
|
_font_scale = clamped
|
||||||
top_label.scale = Vector2(clamped, clamped)
|
var scaled: Array[Label] = [top_label, log_label, target_label, hint_label,
|
||||||
if log_label:
|
nearby_label, time_label, mode_label, escalation_label, radio_cooldown_label, debug_label]
|
||||||
log_label.scale = Vector2(clamped, clamped)
|
for label in scaled:
|
||||||
if target_label:
|
if label == null:
|
||||||
target_label.scale = Vector2(clamped, clamped)
|
continue
|
||||||
if hint_label:
|
if not _base_font_sizes.has(label):
|
||||||
hint_label.scale = Vector2(clamped, clamped)
|
_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()
|
||||||
@@ -308,6 +350,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
|
||||||
@@ -317,13 +360,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
|
||||||
@@ -332,9 +375,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:
|
||||||
@@ -350,6 +402,12 @@ func set_escalation(level: int) -> void:
|
|||||||
if vignette_rect:
|
if vignette_rect:
|
||||||
vignette_rect.modulate = Color(tint.r, tint.g, tint.b, 0.05 + 0.2 * float(level) / 5.0)
|
vignette_rect.modulate = Color(tint.r, tint.g, tint.b, 0.05 + 0.2 * float(level) / 5.0)
|
||||||
|
|
||||||
|
## Dev helper: drop queued log lines so a staged screenshot shows only the
|
||||||
|
## lines that belong to the moment.
|
||||||
|
func clear_log() -> void:
|
||||||
|
_log_lines.clear()
|
||||||
|
log_label.text = ""
|
||||||
|
|
||||||
func log(text: String) -> void:
|
func log(text: String) -> void:
|
||||||
_log_lines.append(text)
|
_log_lines.append(text)
|
||||||
if _log_lines.size() > 8:
|
if _log_lines.size() > 8:
|
||||||
|
|||||||
@@ -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:",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
class_name WorldBuilder
|
class_name WorldBuilder
|
||||||
## Procedurally assembles a minimal Hollywood Boulevard entrance prototype:
|
## Procedurally assembles a minimal Hollywood Boulevard entrance prototype:
|
||||||
|
|
||||||
|
const Materials := preload("res://src/game/materials.gd")
|
||||||
const T_BRICK := preload("res://assets/textures/ai/game_ready/brick_facade.png")
|
const T_BRICK := preload("res://assets/textures/ai/game_ready/brick_facade.png")
|
||||||
const T_CONCRETE := preload("res://assets/textures/ai/game_ready/concrete.png")
|
const T_CONCRETE := preload("res://assets/textures/ai/game_ready/concrete.png")
|
||||||
const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.png")
|
const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.png")
|
||||||
const T_AWNING := preload("res://assets/textures/ai/game_ready/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.
|
## Surfaces use the shared PBR profiles in `src/game/materials.gd`; street
|
||||||
|
## furniture comes from the generated GLB props (tools/model_pipeline.py) with a
|
||||||
|
## procedural fallback so a headless checkout without imports still builds.
|
||||||
|
|
||||||
|
## Generated prop models, one `.glb` per street-furniture piece.
|
||||||
|
const MODEL_DIR := "res://assets/models/generated/"
|
||||||
|
const MODEL_PROPS := ["bollard", "bench", "trash_bin", "planter_box",
|
||||||
|
"newspaper_box", "fire_hydrant"]
|
||||||
|
|
||||||
var root: Node3D = null
|
var root: Node3D = null
|
||||||
## Door lock indicator mesh near the handle; toggled by GameManager.
|
## Door lock indicator mesh near the handle; toggled by GameManager.
|
||||||
@@ -19,6 +27,12 @@ var _lamp_omni: Array[OmniLight3D] = []
|
|||||||
var _lamp_bulb_mats: Array[StandardMaterial3D] = []
|
var _lamp_bulb_mats: Array[StandardMaterial3D] = []
|
||||||
var marquee_label: Label3D = null
|
var marquee_label: Label3D = null
|
||||||
var _marquee_emissive: Array[StandardMaterial3D] = []
|
var _marquee_emissive: Array[StandardMaterial3D] = []
|
||||||
|
## Facade windows that light up after dark.
|
||||||
|
var _window_mats: Array[StandardMaterial3D] = []
|
||||||
|
## Names of the generated props that actually loaded (procedural fallbacks are
|
||||||
|
## not listed), so tests and the debug overlay can report real model usage.
|
||||||
|
var loaded_models: Array[String] = []
|
||||||
|
var fallback_props: Array[String] = []
|
||||||
|
|
||||||
func _init(root_node: Node3D) -> void:
|
func _init(root_node: Node3D) -> void:
|
||||||
root = root_node
|
root = root_node
|
||||||
@@ -33,19 +47,21 @@ func build() -> void:
|
|||||||
_build_street()
|
_build_street()
|
||||||
_build_crosswalk()
|
_build_crosswalk()
|
||||||
_build_street_furniture()
|
_build_street_furniture()
|
||||||
|
_build_street_detail()
|
||||||
_build_guard_spawn()
|
_build_guard_spawn()
|
||||||
_build_navmesh()
|
_build_navmesh()
|
||||||
set_night_mode(false)
|
set_night_mode(false)
|
||||||
|
|
||||||
func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false,
|
func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false,
|
||||||
texture: Texture2D = null, uv_scale: Vector2 = Vector2(1, 1)) -> MeshInstance3D:
|
texture: Texture2D = null, uv_scale: Vector2 = Vector2(1, 1),
|
||||||
|
role: String = "concrete") -> MeshInstance3D:
|
||||||
var mesh := BoxMesh.new()
|
var mesh := BoxMesh.new()
|
||||||
mesh.size = size
|
mesh.size = size
|
||||||
var mat := StandardMaterial3D.new()
|
var mat: StandardMaterial3D
|
||||||
mat.albedo_color = color
|
|
||||||
if texture:
|
if texture:
|
||||||
mat.albedo_texture = texture
|
mat = Materials.textured(texture, color, uv_scale, role)
|
||||||
mat.uv1_scale = Vector3(uv_scale.x, uv_scale.y, 1.0)
|
else:
|
||||||
|
mat = Materials.flat(color, role)
|
||||||
mesh.material = mat
|
mesh.material = mat
|
||||||
var mi := MeshInstance3D.new()
|
var mi := MeshInstance3D.new()
|
||||||
mi.mesh = mesh
|
mi.mesh = mesh
|
||||||
@@ -57,9 +73,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), false, null, Vector2.ONE, "asphalt")
|
||||||
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,22 +86,27 @@ 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, -6), 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.80, 0.76, 0.74), false, T_BRICK, Vector2(22, 10), "brick")
|
||||||
|
# Stone base course so the facade has a footing instead of meeting the steps dead-on.
|
||||||
|
_add_box(building, Vector3(40, 0.8, 0.35), Vector3(0, 0.4, -3.85), Color(0.58, 0.56, 0.53), false, null, Vector2.ONE, "stone")
|
||||||
# Entrance recess + door recess frame
|
# Entrance recess + door recess frame
|
||||||
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -3.9), 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), false, null, Vector2.ONE, "stone")
|
||||||
|
# Door frame columns and lintel, so the entrance is framed rather than painted on.
|
||||||
|
for side in [-1.55, 1.55]:
|
||||||
|
_add_box(building, Vector3(0.28, 5.2, 0.42), Vector3(side, 2.9, -3.86), Color(0.30, 0.28, 0.26), true, null, Vector2.ONE, "stone")
|
||||||
|
_add_box(building, Vector3(3.4, 0.3, 0.42), Vector3(0, 5.55, -3.86), Color(0.30, 0.28, 0.26), true, null, Vector2.ONE, "stone")
|
||||||
# Door frame and dark door
|
# Door frame and dark door
|
||||||
_add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -3.98), 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), true, null, Vector2.ONE, "painted_metal")
|
||||||
# Door glass (semi-transparent)
|
# Door glass (semi-transparent)
|
||||||
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 := _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()
|
glass.material_override = Materials.glass(Color(0.55, 0.7, 0.88, 0.55))
|
||||||
glass_mat.albedo_color = Color(0.6, 0.75, 0.9, 0.45)
|
# Door handles: two slim metal bars where the guard reaches for the lock.
|
||||||
glass_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
for side in [-0.42, 0.42]:
|
||||||
glass_mat.roughness = 0.15
|
_add_box(building, Vector3(0.06, 0.5, 0.06), Vector3(side, 2.6, -3.79), Color(0.75, 0.74, 0.70), false, null, Vector2.ONE, "metal")
|
||||||
glass_mat.metallic = 0.1
|
|
||||||
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, -3.82), 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))
|
||||||
|
_build_facade_windows(building)
|
||||||
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)
|
||||||
@@ -91,6 +114,24 @@ func _build_building() -> void:
|
|||||||
building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -6)))
|
building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -6)))
|
||||||
root.add_child(building)
|
root.add_child(building)
|
||||||
|
|
||||||
|
## Regular window bands above the entrance. Panes are dark glass by day and
|
||||||
|
## light up after dark, which gives the facade depth at night.
|
||||||
|
func _build_facade_windows(parent: Node3D) -> void:
|
||||||
|
var columns := [-17.0, -13.6, -10.2, -6.8, 6.8, 10.2, 13.6, 17.0]
|
||||||
|
for y in [9.2, 12.4]:
|
||||||
|
for x in columns:
|
||||||
|
# Recessed reveal, then the pane: gives the window a dark frame edge.
|
||||||
|
_add_box(parent, Vector3(1.96, 2.46, 0.1), Vector3(x, y, -3.99),
|
||||||
|
Color(0.22, 0.21, 0.20), false, null, Vector2.ONE, "stone")
|
||||||
|
var pane := _add_box(parent, Vector3(1.7, 2.2, 0.12),
|
||||||
|
Vector3(x, y, -3.94), Color(1, 1, 1))
|
||||||
|
var pane_mat := Materials.window_pane()
|
||||||
|
pane.material_override = pane_mat
|
||||||
|
_window_mats.append(pane_mat)
|
||||||
|
# Sill below the frame.
|
||||||
|
_add_box(parent, Vector3(2.1, 0.12, 0.3), Vector3(x, y - 1.3, -3.86),
|
||||||
|
Color(0.55, 0.53, 0.50), false, null, Vector2.ONE, "stone")
|
||||||
|
|
||||||
func _build_steps() -> void:
|
func _build_steps() -> void:
|
||||||
# Steps have collision so NPCs and the guard can walk up the entrance.
|
# Steps have collision so NPCs and the guard can walk up the entrance.
|
||||||
# The ground and building provide the actual collision boundaries.
|
# The ground and building provide the actual collision boundaries.
|
||||||
@@ -123,7 +164,7 @@ func _build_steps() -> void:
|
|||||||
func _build_sidewalk() -> void:
|
func _build_sidewalk() -> void:
|
||||||
var sidewalk := StaticBody3D.new()
|
var sidewalk := StaticBody3D.new()
|
||||||
sidewalk.name = "Sidewalk"
|
sidewalk.name = "Sidewalk"
|
||||||
_add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(1, 1, 1), false, T_CONCRETE, Vector2(9, 2.4))
|
_add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(0.74, 0.74, 0.72), false, T_CONCRETE, Vector2(9, 2.4), "concrete")
|
||||||
var col := BoxShape3D.new()
|
var col := BoxShape3D.new()
|
||||||
col.size = Vector3(30, 0.36, 8)
|
col.size = Vector3(30, 0.36, 8)
|
||||||
var so := sidewalk.create_shape_owner(sidewalk)
|
var so := sidewalk.create_shape_owner(sidewalk)
|
||||||
@@ -134,10 +175,14 @@ func _build_sidewalk() -> void:
|
|||||||
func _build_street() -> void:
|
func _build_street() -> void:
|
||||||
var street := StaticBody3D.new()
|
var street := StaticBody3D.new()
|
||||||
street.name = "Street"
|
street.name = "Street"
|
||||||
_add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22), Color(1, 1, 1), false, T_ASPHALT, Vector2(30, 7))
|
# The roadway reads darker than the sidewalk; the raw asphalt texture tinted
|
||||||
# Center dashes
|
# white looked like more pavement.
|
||||||
|
_add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22), Color(0.52, 0.53, 0.57), false, T_ASPHALT, Vector2(30, 7), "asphalt")
|
||||||
|
# Center dashes along the street (x varied so they form a lane line, not a stack).
|
||||||
for i in range(-40, 41, 4):
|
for i in range(-40, 41, 4):
|
||||||
_add_box(street, Vector3(0.2, 0.02, 1.2), Vector3(i * 0.0, 0.11, 22), Color(0.85, 0.85, 0.30))
|
_add_box(street, Vector3(0.2, 0.02, 1.2), Vector3(float(i), 0.11, 22), Color(0.85, 0.85, 0.30), false, null, Vector2.ONE, "concrete")
|
||||||
|
# Curb between the sidewalk and the roadway.
|
||||||
|
_add_box(street, Vector3(120, 0.22, 0.5), Vector3(0, 0.11, 12.1), Color(0.62, 0.61, 0.58), false, null, Vector2.ONE, "concrete")
|
||||||
var col := BoxShape3D.new()
|
var col := BoxShape3D.new()
|
||||||
col.size = Vector3(120, 0.2, 26)
|
col.size = Vector3(120, 0.2, 26)
|
||||||
var so := street.create_shape_owner(street)
|
var so := street.create_shape_owner(street)
|
||||||
@@ -165,35 +210,55 @@ func _build_navmesh() -> void:
|
|||||||
root.add_child(region)
|
root.add_child(region)
|
||||||
|
|
||||||
func _build_skyline_backdrop() -> void:
|
func _build_skyline_backdrop() -> void:
|
||||||
## Skyline silhouette backdrop behind the building. The texture has a
|
## Distant skyline surrounding the block. The texture's sky is transparent
|
||||||
## transparent sky, so only the building silhouettes show; the material must
|
## (alpha scissor), so only building silhouettes show. Four planes form a
|
||||||
## be set up for alpha or the plane renders as an opaque slab (ADR/TODO).
|
## ring: a single plane placed behind the building reads as a floating black
|
||||||
|
## band from the sidewalk.
|
||||||
var backdrop := Node3D.new()
|
var backdrop := Node3D.new()
|
||||||
backdrop.name = "SkylineBackdrop"
|
backdrop.name = "SkylineBackdrop"
|
||||||
var plane := MeshInstance3D.new()
|
|
||||||
var mesh := PlaneMesh.new()
|
|
||||||
mesh.size = Vector2(240, 60) # wide, low horizon
|
|
||||||
plane.mesh = mesh
|
|
||||||
var mat := StandardMaterial3D.new()
|
var mat := StandardMaterial3D.new()
|
||||||
mat.albedo_texture = T_SKYLINE
|
mat.albedo_texture = T_SKYLINE
|
||||||
mat.albedo_texture_repeat = true
|
mat.texture_repeat = true
|
||||||
mat.uv1_scale = Vector3(2.0, 1.0, 1.0)
|
mat.uv1_scale = Vector3(3.0, 1.0, 1.0)
|
||||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
|
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
|
||||||
mat.alpha_scissor_threshold = 0.3
|
mat.alpha_scissor_threshold = 0.5
|
||||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||||
plane.material_override = mat
|
# Unshaded silhouettes read as pure black at 255-scale; lift the albedo so the
|
||||||
plane.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
# distant skyline sits closer to a hazy daytime city.
|
||||||
plane.position = Vector3(0, 18, -60) # behind and above the building
|
mat.albedo_color = Color(2.6, 2.7, 3.0, 1.0)
|
||||||
backdrop.add_child(plane)
|
|
||||||
root.add_child(backdrop)
|
|
||||||
_skyline_mat = mat
|
_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
|
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), true, T_AWNING, Vector2(4, 1), "awning")
|
||||||
awning.name = "Awning"
|
awning.name = "Awning"
|
||||||
## Neon-style marquee strip above the door: emissive bars + a label.
|
## Neon-style marquee strip above the door: emissive bars + a label.
|
||||||
var strip_parent := Node3D.new()
|
var strip_parent := Node3D.new()
|
||||||
@@ -222,8 +287,8 @@ func _build_street_furniture() -> void:
|
|||||||
for side in [-7.5, 7.5]:
|
for side in [-7.5, 7.5]:
|
||||||
var lamp := Node3D.new()
|
var lamp := Node3D.new()
|
||||||
lamp.name = "Lamp_%d" % int(side)
|
lamp.name = "Lamp_%d" % int(side)
|
||||||
_add_box(lamp, Vector3(0.22, 5.0, 0.22), Vector3(side, 2.5, 8.5), Color(0.16, 0.16, 0.18))
|
_add_box(lamp, Vector3(0.22, 5.0, 0.22), Vector3(side, 2.5, 8.5), Color(0.16, 0.16, 0.18), true, null, Vector2.ONE, "metal")
|
||||||
_add_box(lamp, Vector3(1.6, 0.15, 0.15), Vector3(side + (0.8 if side < 0 else -0.8), 4.9, 8.5), Color(0.16, 0.16, 0.18))
|
_add_box(lamp, Vector3(1.6, 0.15, 0.15), Vector3(side + (0.8 if side < 0 else -0.8), 4.9, 8.5), Color(0.16, 0.16, 0.18), true, null, Vector2.ONE, "metal")
|
||||||
var bulb := _add_box(lamp, Vector3(0.16, 0.16, 0.5), Vector3(side + (0.8 if side < 0 else -0.8), 4.85, 8.5), Color(1, 1, 0.85))
|
var bulb := _add_box(lamp, Vector3(0.16, 0.16, 0.5), Vector3(side + (0.8 if side < 0 else -0.8), 4.85, 8.5), Color(1, 1, 0.85))
|
||||||
var bulb_mat := _emissive_material(Color(1.0, 1.0, 0.8))
|
var bulb_mat := _emissive_material(Color(1.0, 1.0, 0.8))
|
||||||
bulb.material_override = bulb_mat
|
bulb.material_override = bulb_mat
|
||||||
@@ -231,17 +296,15 @@ func _build_street_furniture() -> void:
|
|||||||
_lamp_bulb_mats.append(bulb_mat)
|
_lamp_bulb_mats.append(bulb_mat)
|
||||||
var omni := OmniLight3D.new()
|
var omni := OmniLight3D.new()
|
||||||
omni.position = Vector3(side + (0.8 if side < 0 else -0.8), 4.7, 8.5)
|
omni.position = Vector3(side + (0.8 if side < 0 else -0.8), 4.7, 8.5)
|
||||||
omni.omni_range = 14.0
|
omni.omni_range = 12.0
|
||||||
omni.light_energy = 0.0
|
omni.light_energy = 0.0
|
||||||
omni.light_color = Color(1, 0.93, 0.75)
|
omni.light_color = Color(1, 0.93, 0.75)
|
||||||
lamp.add_child(omni)
|
lamp.add_child(omni)
|
||||||
_lamp_omni.append(omni)
|
_lamp_omni.append(omni)
|
||||||
root.add_child(lamp)
|
root.add_child(lamp)
|
||||||
_street_lamps.append(bulb)
|
_street_lamps.append(bulb)
|
||||||
# Planters beside the steps.
|
# Planters beside the steps were flat boxes that clipped the treads; the
|
||||||
for side in [-4.6, 4.6]:
|
# generated planter_box props replace them in _build_street_detail().
|
||||||
var planter := _add_box(root, Vector3(1.1, 0.7, 1.1), Vector3(side, 0.35, 1.0), Color(0.42, 0.34, 0.28))
|
|
||||||
planter.name = "Planter_%d" % int(side)
|
|
||||||
# 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)
|
||||||
@@ -258,7 +321,7 @@ func _build_street_furniture() -> void:
|
|||||||
trunk_mesh.bottom_radius = 0.14
|
trunk_mesh.bottom_radius = 0.14
|
||||||
trunk_mesh.height = 1.4
|
trunk_mesh.height = 1.4
|
||||||
trunk.mesh = trunk_mesh
|
trunk.mesh = trunk_mesh
|
||||||
trunk.material_override = _plain_material(trunk_color)
|
trunk.material_override = _plain_material(trunk_color, "wood")
|
||||||
trunk.position = Vector3(x, 0.7, z)
|
trunk.position = Vector3(x, 0.7, z)
|
||||||
tree.add_child(trunk)
|
tree.add_child(trunk)
|
||||||
var crown := MeshInstance3D.new()
|
var crown := MeshInstance3D.new()
|
||||||
@@ -267,11 +330,271 @@ func _build_street_furniture() -> void:
|
|||||||
crown_mesh.bottom_radius = 0.85
|
crown_mesh.bottom_radius = 0.85
|
||||||
crown_mesh.height = 2.2
|
crown_mesh.height = 2.2
|
||||||
crown.mesh = crown_mesh
|
crown.mesh = crown_mesh
|
||||||
crown.material_override = _plain_material(leaf_color)
|
crown.material_override = _plain_material(leaf_color, "foliage")
|
||||||
crown.position = Vector3(x, 2.1, z)
|
crown.position = Vector3(x, 2.1, z)
|
||||||
tree.add_child(crown)
|
tree.add_child(crown)
|
||||||
root.add_child(tree)
|
root.add_child(tree)
|
||||||
|
|
||||||
|
## Street-furniture placement table. Positions are in world space on the
|
||||||
|
## sidewalk top (y = 0.18) and grouped by zone, so a cluster reads as street
|
||||||
|
## furniture instead of a scattered list. `accessory` names a small procedural
|
||||||
|
## addition (bag, crate, cone) for one-off variation.
|
||||||
|
const PROP_PLACEMENTS := [
|
||||||
|
# Entrance: bollards flank the foot of the steps, bins catch people who
|
||||||
|
# stop at the door.
|
||||||
|
{"zone": "entrance", "prop": "bollard", "x": -5.6, "z": 4.7},
|
||||||
|
{"zone": "entrance", "prop": "bollard", "x": -4.2, "z": 4.7},
|
||||||
|
{"zone": "entrance", "prop": "bollard", "x": 4.2, "z": 4.7},
|
||||||
|
{"zone": "entrance", "prop": "bollard", "x": 5.6, "z": 4.7},
|
||||||
|
{"zone": "entrance", "prop": "trash_bin", "x": -6.6, "z": 4.9, "accessory": "bag"},
|
||||||
|
{"zone": "entrance", "prop": "trash_bin", "x": 6.6, "z": 4.9},
|
||||||
|
# Mid-block: benches face the boulevard, planters break up the long run.
|
||||||
|
{"zone": "mid_block", "prop": "bench", "x": -8.8, "z": 6.4, "rot": 180.0},
|
||||||
|
{"zone": "mid_block", "prop": "bench", "x": 8.8, "z": 6.4, "rot": 180.0},
|
||||||
|
{"zone": "mid_block", "prop": "planter_box", "x": -11.8, "z": 8.8},
|
||||||
|
{"zone": "mid_block", "prop": "planter_box", "x": 11.8, "z": 8.8},
|
||||||
|
# Curb: newspaper boxes and a hydrant along the roadway edge.
|
||||||
|
{"zone": "curb", "prop": "newspaper_box", "x": -9.6, "z": 11.0, "rot": -12.0},
|
||||||
|
{"zone": "curb", "prop": "newspaper_box", "x": -8.9, "z": 11.0, "rot": -12.0,
|
||||||
|
"accessory": "crate"},
|
||||||
|
{"zone": "curb", "prop": "fire_hydrant", "x": 8.4, "z": 11.2,
|
||||||
|
"accessory": "cone"},
|
||||||
|
]
|
||||||
|
|
||||||
|
## The door, steps, and the lane guests walk in through stay clear of props.
|
||||||
|
const PROP_EXCLUSIONS := [
|
||||||
|
{"name": "steps and door", "x0": -4.6, "x1": 4.6, "z0": -4.6, "z1": 4.55},
|
||||||
|
{"name": "guest approach lane", "x0": -1.4, "x1": 1.4, "z0": 4.55, "z1": 14.0},
|
||||||
|
]
|
||||||
|
const PROP_BOUNDS := {"x0": -14.0, "x1": 14.0, "z0": 4.55, "z1": 11.8}
|
||||||
|
## Two prop centres closer than this read as one blob (newspaper boxes are
|
||||||
|
## deliberately lined up, so this stays below their 0.7 m spacing).
|
||||||
|
const PROP_MIN_SPACING := 0.6
|
||||||
|
|
||||||
|
static func _variant_seed(index: int) -> int:
|
||||||
|
return index * 7919 + 13
|
||||||
|
|
||||||
|
## Deterministic per-placement variation. The same index always gives the same
|
||||||
|
## rotation and scale, so the street is reproducible while repeats differ.
|
||||||
|
static func variant_for(index: int, base_rotation: float = 0.0) -> Dictionary:
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = _variant_seed(index)
|
||||||
|
return {
|
||||||
|
"rotation": base_rotation + rng.randf_range(-2.0, 2.0),
|
||||||
|
"scale": rng.randf_range(0.96, 1.04),
|
||||||
|
"roll": rng.randf(),
|
||||||
|
}
|
||||||
|
|
||||||
|
## Static validation used by tests: reject duplicate, out-of-bounds, or
|
||||||
|
## overlapping placements, and any prop inside the door/steps/approach lane.
|
||||||
|
static func placement_problems(placements: Array = PROP_PLACEMENTS) -> Array[String]:
|
||||||
|
var problems: Array[String] = []
|
||||||
|
var seen := {}
|
||||||
|
for i in placements.size():
|
||||||
|
var entry: Dictionary = placements[i]
|
||||||
|
var prop := str(entry.get("prop", ""))
|
||||||
|
var x := float(entry.get("x", 0.0))
|
||||||
|
var z := float(entry.get("z", 0.0))
|
||||||
|
var key := "%s@%.2f,%.2f" % [prop, x, z]
|
||||||
|
if seen.has(key):
|
||||||
|
problems.append("duplicate placement %s" % key)
|
||||||
|
seen[key] = true
|
||||||
|
if x < float(PROP_BOUNDS["x0"]) or x > float(PROP_BOUNDS["x1"]) \
|
||||||
|
or z < float(PROP_BOUNDS["z0"]) or z > float(PROP_BOUNDS["z1"]):
|
||||||
|
problems.append("%s at (%.1f, %.1f) is outside the sidewalk" % [prop, x, z])
|
||||||
|
for zone in PROP_EXCLUSIONS:
|
||||||
|
if x > float(zone["x0"]) and x < float(zone["x1"]) \
|
||||||
|
and z > float(zone["z0"]) and z < float(zone["z1"]):
|
||||||
|
problems.append("%s at (%.1f, %.1f) blocks %s" % [prop, x, z, zone["name"]])
|
||||||
|
for j in range(i):
|
||||||
|
var other: Dictionary = placements[j]
|
||||||
|
var dx := x - float(other.get("x", 0.0))
|
||||||
|
var dz := z - float(other.get("z", 0.0))
|
||||||
|
if Vector2(dx, dz).length() < PROP_MIN_SPACING:
|
||||||
|
problems.append("%s and %s at (%.1f, %.1f) overlap" % [
|
||||||
|
prop, str(other.get("prop", "")), x, z])
|
||||||
|
return problems
|
||||||
|
|
||||||
|
## Street furniture built from the generated GLB props (tools/model_pipeline.py).
|
||||||
|
## The meshes are real 3D models (bollards, benches, bins, planters, newspaper
|
||||||
|
## boxes, a hydrant) rather than scaled boxes. Placements and their deterministic
|
||||||
|
## variants are fixed, so the street looks the same on every run.
|
||||||
|
func _build_street_detail() -> void:
|
||||||
|
var detail := Node3D.new()
|
||||||
|
detail.name = "StreetDetail"
|
||||||
|
root.add_child(detail)
|
||||||
|
for index in PROP_PLACEMENTS.size():
|
||||||
|
var entry: Dictionary = PROP_PLACEMENTS[index]
|
||||||
|
var base_rot := float(entry.get("rot", 0.0))
|
||||||
|
var variant := variant_for(index, base_rot)
|
||||||
|
var holder := _add_prop_model(detail, str(entry["prop"]),
|
||||||
|
Vector3(float(entry["x"]), 0.18, float(entry["z"])),
|
||||||
|
float(variant["rotation"]), index)
|
||||||
|
holder.scale = Vector3.ONE * float(variant["scale"])
|
||||||
|
var accessory := str(entry.get("accessory", ""))
|
||||||
|
if accessory != "":
|
||||||
|
_add_prop_accessory(holder, accessory)
|
||||||
|
|
||||||
|
## Place one generated prop. Falls back to simple primitives when the `.glb` is
|
||||||
|
## missing (fresh checkout before `godot --import`) so the world still builds.
|
||||||
|
## A static collider is fitted from the model bounds (cylinder for round props,
|
||||||
|
## box for everything else), so props block the guard and NPCs.
|
||||||
|
func _add_prop_model(parent: Node3D, prop: String, pos: Vector3,
|
||||||
|
rot_y_deg: float = 0.0, variant_index: int = -1) -> Node3D:
|
||||||
|
var holder := Node3D.new()
|
||||||
|
holder.name = "Prop_%s_%d" % [prop, parent.get_child_count()]
|
||||||
|
holder.position = pos
|
||||||
|
holder.rotation_degrees.y = rot_y_deg
|
||||||
|
var model := _load_prop_scene(prop)
|
||||||
|
if model != null:
|
||||||
|
holder.add_child(model)
|
||||||
|
loaded_models.append(prop)
|
||||||
|
else:
|
||||||
|
var fallback := _fallback_prop(prop)
|
||||||
|
holder.add_child(fallback)
|
||||||
|
fallback_props.append(prop)
|
||||||
|
model = fallback
|
||||||
|
parent.add_child(holder)
|
||||||
|
_add_prop_collision(holder, prop, model)
|
||||||
|
return holder
|
||||||
|
|
||||||
|
## Fit a simple collider to the prop's meshes (in holder-local space, so the
|
||||||
|
## placement scale and rotation carry over).
|
||||||
|
func _add_prop_collision(holder: Node3D, prop: String, model: Node3D) -> void:
|
||||||
|
var bounds := _model_bounds(model, holder)
|
||||||
|
if bounds.size == Vector3.ZERO:
|
||||||
|
return
|
||||||
|
var body := StaticBody3D.new()
|
||||||
|
body.name = "Collision"
|
||||||
|
var collider := CollisionShape3D.new()
|
||||||
|
collider.name = "Shape"
|
||||||
|
var round_props := ["bollard", "trash_bin", "fire_hydrant"]
|
||||||
|
if prop in round_props:
|
||||||
|
var cylinder := CylinderShape3D.new()
|
||||||
|
cylinder.radius = maxf(bounds.size.x, bounds.size.z) * 0.5
|
||||||
|
cylinder.height = bounds.size.y
|
||||||
|
collider.shape = cylinder
|
||||||
|
else:
|
||||||
|
var box := BoxShape3D.new()
|
||||||
|
box.size = bounds.size
|
||||||
|
collider.shape = box
|
||||||
|
collider.position = bounds.get_center()
|
||||||
|
body.add_child(collider)
|
||||||
|
holder.add_child(body)
|
||||||
|
# Publish the footprint to NavigationServer so guest avoidance steers around
|
||||||
|
# the prop even though the navmesh itself is a flat walkable plane.
|
||||||
|
var obstacle := NavigationObstacle3D.new()
|
||||||
|
obstacle.name = "Avoidance"
|
||||||
|
obstacle.radius = maxf(bounds.size.x, bounds.size.z) * 0.5 + 0.25
|
||||||
|
obstacle.height = maxf(bounds.size.y, 0.5)
|
||||||
|
obstacle.position = Vector3(bounds.get_center().x, 0.0, bounds.get_center().z)
|
||||||
|
holder.add_child(obstacle)
|
||||||
|
|
||||||
|
## AABB of every mesh under `model`, expressed relative to `ancestor`.
|
||||||
|
func _model_bounds(model: Node3D, ancestor: Node3D) -> AABB:
|
||||||
|
var bounds := AABB()
|
||||||
|
var found := false
|
||||||
|
for node in model.find_children("*", "MeshInstance3D", true, false):
|
||||||
|
var mi := node as MeshInstance3D
|
||||||
|
if mi.mesh == null:
|
||||||
|
continue
|
||||||
|
var xform := _transform_to_ancestor(mi, ancestor)
|
||||||
|
var box: AABB = xform * mi.mesh.get_aabb()
|
||||||
|
bounds = box if not found else bounds.merge(box)
|
||||||
|
found = true
|
||||||
|
return bounds
|
||||||
|
|
||||||
|
func _transform_to_ancestor(node: Node, ancestor: Node) -> Transform3D:
|
||||||
|
var xform := Transform3D.IDENTITY
|
||||||
|
var current := node
|
||||||
|
while current != null and current != ancestor:
|
||||||
|
if current is Node3D:
|
||||||
|
xform = (current as Node3D).transform * xform
|
||||||
|
current = current.get_parent()
|
||||||
|
return xform
|
||||||
|
|
||||||
|
## Small deterministic clutter next to a prop: a bag by the bin, a crate by the
|
||||||
|
## newspaper boxes, a cone by the hydrant.
|
||||||
|
func _add_prop_accessory(holder: Node3D, kind: String) -> void:
|
||||||
|
match kind:
|
||||||
|
"bag":
|
||||||
|
var bag := MeshInstance3D.new()
|
||||||
|
var sphere := SphereMesh.new()
|
||||||
|
sphere.radius = 0.22
|
||||||
|
sphere.height = 0.5
|
||||||
|
bag.mesh = sphere
|
||||||
|
bag.material_override = Materials.flat(Color(0.10, 0.11, 0.13), "rubber")
|
||||||
|
bag.position = Vector3(0.38, 0.22, 0.06)
|
||||||
|
holder.add_child(bag)
|
||||||
|
_add_box(holder, Vector3(0.12, 0.10, 0.12), Vector3(0.38, 0.48, 0.06),
|
||||||
|
Color(0.16, 0.17, 0.20), false, null, Vector2.ONE, "rubber")
|
||||||
|
"crate":
|
||||||
|
_add_box(holder, Vector3(0.46, 0.40, 0.40), Vector3(0.48, 0.20, 0.08),
|
||||||
|
Color(0.45, 0.31, 0.18), true, null, Vector2.ONE, "wood")
|
||||||
|
_add_box(holder, Vector3(0.48, 0.03, 0.42), Vector3(0.48, 0.41, 0.08),
|
||||||
|
Color(0.35, 0.23, 0.13), false, null, Vector2.ONE, "wood")
|
||||||
|
"cone":
|
||||||
|
_add_cylinder_mesh(holder, 0.16, 0.55, Vector3(-0.42, 0.28, 0.10),
|
||||||
|
Color(0.95, 0.36, 0.10), "painted_metal")
|
||||||
|
_add_box(holder, Vector3(0.38, 0.05, 0.38), Vector3(-0.42, 0.03, 0.10),
|
||||||
|
Color(0.12, 0.12, 0.14), false, null, Vector2.ONE, "rubber")
|
||||||
|
_add_cylinder_mesh(holder, 0.11, 0.08, Vector3(-0.42, 0.34, 0.10),
|
||||||
|
Color(0.92, 0.92, 0.9), "painted_metal")
|
||||||
|
|
||||||
|
func _load_prop_scene(prop: String) -> Node3D:
|
||||||
|
var path := MODEL_DIR + prop + ".glb"
|
||||||
|
if not ResourceLoader.exists(path):
|
||||||
|
return null
|
||||||
|
var res: Resource = ResourceLoader.load(path)
|
||||||
|
if res is PackedScene:
|
||||||
|
var node: Node = (res as PackedScene).instantiate()
|
||||||
|
if node is Node3D:
|
||||||
|
return node as Node3D
|
||||||
|
node.queue_free()
|
||||||
|
return null
|
||||||
|
|
||||||
|
## Rough primitive stand-in per prop, used only when the model is unavailable.
|
||||||
|
func _fallback_prop(prop: String) -> Node3D:
|
||||||
|
var node := Node3D.new()
|
||||||
|
match prop:
|
||||||
|
"bollard":
|
||||||
|
_add_cylinder_mesh(node, 0.09, 0.92, Vector3(0, 0.46, 0), Color(0.16, 0.17, 0.19), "metal")
|
||||||
|
"bench":
|
||||||
|
_add_box(node, Vector3(1.8, 0.08, 0.5), Vector3(0, 0.46, 0), Color(0.42, 0.28, 0.17), true, null, Vector2.ONE, "wood")
|
||||||
|
_add_box(node, Vector3(1.8, 0.42, 0.07), Vector3(0, 0.72, -0.22), Color(0.42, 0.28, 0.17), true, null, Vector2.ONE, "wood")
|
||||||
|
for x in [-0.72, 0.72]:
|
||||||
|
_add_box(node, Vector3(0.09, 0.46, 0.46), Vector3(x, 0.23, 0), Color(0.16, 0.17, 0.19), true, null, Vector2.ONE, "metal")
|
||||||
|
"trash_bin":
|
||||||
|
_add_cylinder_mesh(node, 0.27, 0.78, Vector3(0, 0.39, 0), Color(0.22, 0.30, 0.24), "painted_metal")
|
||||||
|
_add_cylinder_mesh(node, 0.29, 0.06, Vector3(0, 0.81, 0), Color(0.16, 0.17, 0.19), "metal")
|
||||||
|
"planter_box":
|
||||||
|
_add_box(node, Vector3(0.96, 0.54, 0.96), Vector3(0, 0.27, 0), Color(0.52, 0.51, 0.48), true, null, Vector2.ONE, "stone")
|
||||||
|
_add_cylinder_mesh(node, 0.30, 0.55, Vector3(0, 0.86, 0), Color(0.18, 0.42, 0.20), "foliage")
|
||||||
|
"newspaper_box":
|
||||||
|
_add_box(node, Vector3(0.42, 0.72, 0.34), Vector3(0, 0.49, 0), Color(0.16, 0.27, 0.45), true, null, Vector2.ONE, "painted_metal")
|
||||||
|
_add_box(node, Vector3(0.44, 0.10, 0.36), Vector3(0, 0.90, 0), Color(0.85, 0.72, 0.28), true, null, Vector2.ONE, "painted_metal")
|
||||||
|
"fire_hydrant":
|
||||||
|
_add_cylinder_mesh(node, 0.15, 0.62, Vector3(0, 0.31, 0), Color(0.6, 0.12, 0.12), "painted_metal")
|
||||||
|
_add_box(node, Vector3(0.52, 0.10, 0.12), Vector3(0, 0.42, 0), Color(0.75, 0.74, 0.70), true, null, Vector2.ONE, "metal")
|
||||||
|
_:
|
||||||
|
_add_box(node, Vector3(0.5, 0.5, 0.5), Vector3(0, 0.25, 0), Color(0.5, 0.5, 0.5))
|
||||||
|
return node
|
||||||
|
|
||||||
|
func _add_cylinder_mesh(parent: Node3D, radius: float, height: float,
|
||||||
|
pos: Vector3, color: Color, role: String = "metal") -> MeshInstance3D:
|
||||||
|
var mi := MeshInstance3D.new()
|
||||||
|
var mesh := CylinderMesh.new()
|
||||||
|
mesh.top_radius = radius
|
||||||
|
mesh.bottom_radius = radius
|
||||||
|
mesh.height = height
|
||||||
|
mesh.radial_segments = 12
|
||||||
|
mi.mesh = mesh
|
||||||
|
mi.position = pos
|
||||||
|
mi.material_override = Materials.flat(color, role)
|
||||||
|
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_ON
|
||||||
|
parent.add_child(mi)
|
||||||
|
return mi
|
||||||
|
|
||||||
func _build_crosswalk() -> void:
|
func _build_crosswalk() -> void:
|
||||||
# White crosswalk stripes in front of the entrance on the street.
|
# White crosswalk stripes in front of the entrance on the street.
|
||||||
for i in 8:
|
for i in 8:
|
||||||
@@ -283,24 +606,24 @@ func set_night_mode(night: bool) -> void:
|
|||||||
for mat in _lamp_bulb_mats:
|
for mat in _lamp_bulb_mats:
|
||||||
mat.emission_energy_multiplier = (2.0 if night else 0.0)
|
mat.emission_energy_multiplier = (2.0 if night else 0.0)
|
||||||
for omni in _lamp_omni:
|
for omni in _lamp_omni:
|
||||||
omni.light_energy = (3.0 if night else 0.0)
|
# Two lamps at full energy washed the whole sidewalk grey; keep visible
|
||||||
|
# pools of light instead of flat night-time brightness.
|
||||||
|
omni.light_energy = (1.8 if night else 0.0)
|
||||||
for mat in _marquee_emissive:
|
for mat in _marquee_emissive:
|
||||||
mat.emission_energy_multiplier = (2.0 if night else 0.2)
|
mat.emission_energy_multiplier = (2.0 if night else 0.2)
|
||||||
|
# Light most, not all, of the windows: a fully lit grid reads as a light box
|
||||||
|
# instead of an occupied building.
|
||||||
|
for i in _window_mats.size():
|
||||||
|
var lit := night and i % 3 != 2
|
||||||
|
_window_mats[i].emission_energy_multiplier = (1.1 if lit else 0.0)
|
||||||
if marquee_label:
|
if marquee_label:
|
||||||
marquee_label.modulate.a = (1.0 if night else 0.9)
|
marquee_label.modulate.a = (1.0 if night else 0.9)
|
||||||
|
|
||||||
func _emissive_material(color: Color) -> StandardMaterial3D:
|
func _emissive_material(color: Color) -> StandardMaterial3D:
|
||||||
var mat := StandardMaterial3D.new()
|
return Materials.emissive(color)
|
||||||
mat.albedo_color = color
|
|
||||||
mat.emission_enabled = true
|
|
||||||
mat.emission = color
|
|
||||||
mat.emission_energy_multiplier = 1.0
|
|
||||||
return mat
|
|
||||||
|
|
||||||
func _plain_material(color: Color) -> StandardMaterial3D:
|
func _plain_material(color: Color, role: String = "concrete") -> StandardMaterial3D:
|
||||||
var mat := StandardMaterial3D.new()
|
return Materials.flat(color, role)
|
||||||
mat.albedo_color = color
|
|
||||||
return mat
|
|
||||||
|
|
||||||
func _build_guard_spawn() -> void:
|
func _build_guard_spawn() -> void:
|
||||||
var marker := Marker3D.new()
|
var marker := Marker3D.new()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ var state := 0
|
|||||||
var state_frames := 0
|
var state_frames := 0
|
||||||
var targeted := false
|
var targeted := false
|
||||||
var camera_staged := false
|
var camera_staged := false
|
||||||
|
var wand_attempts := 0
|
||||||
|
|
||||||
func _initialize() -> void:
|
func _initialize() -> void:
|
||||||
root.size = Vector2i(1280, 720)
|
root.size = Vector2i(1280, 720)
|
||||||
@@ -31,6 +32,10 @@ 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:
|
||||||
|
# The game boots into the venue menu now; start the Hollywood shift first.
|
||||||
|
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.global_position = Vector3(0, 0.1, 10.5)
|
||||||
gm.guard.rotation.y = 0.0
|
gm.guard.rotation.y = 0.0
|
||||||
gm.guard.head.rotation.x = deg_to_rad(2.0)
|
gm.guard.head.rotation.x = deg_to_rad(2.0)
|
||||||
@@ -52,7 +57,8 @@ 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()
|
_face_target()
|
||||||
@@ -83,28 +89,123 @@ 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.guard.rotation.y = 0.0
|
gm.hud.set_action_hint("")
|
||||||
gm.guard.head.rotation.x = deg_to_rad(2.0)
|
gm.coordinator.elapsed_s = 620.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:
|
||||||
gm.guard.rotation.y = 0.0
|
|
||||||
gm.guard.head.rotation.x = deg_to_rad(2.0)
|
|
||||||
_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")
|
||||||
|
# Move to The Joke Shop for the wand/shift-review set, and keep
|
||||||
|
# the late-night phase so the UI sits on the night scene.
|
||||||
|
if gm != null:
|
||||||
|
gm.call("_toggle_flashlight")
|
||||||
|
gm.switch_scenario("joke_shop")
|
||||||
|
gm.coordinator.elapsed_s = 620.0
|
||||||
state = 7
|
state = 7
|
||||||
|
state_frames = 0
|
||||||
|
7:
|
||||||
|
# Wand screening: wait for guests and keep re-targeting until one
|
||||||
|
# accepts the screen (some authored Joke Shop guests refuse it).
|
||||||
|
if state_frames > 40 and state_frames % 20 == 0 and gm != null and gm.npcs.size() > 0:
|
||||||
|
# Prefer a guest whose items make the wand alert, so the panel
|
||||||
|
# shows the interesting case instead of "Alert: none".
|
||||||
|
if gm.wand_open:
|
||||||
|
if _wand_alerts():
|
||||||
|
state = 8
|
||||||
|
state_frames = 0
|
||||||
|
elif wand_attempts >= 6:
|
||||||
|
# Nobody nearby carried an alerting item: stage the documented
|
||||||
|
# false-alarm case (keys) on this guest and re-open the panel.
|
||||||
|
var guest: Object = gm.call("_targeted_controller")
|
||||||
|
if guest != null and guest.profile != null:
|
||||||
|
var false_alarm: Array[String] = ["keys"]
|
||||||
|
guest.profile.carried_items = false_alarm
|
||||||
|
gm.call("_toggle_wand_screening")
|
||||||
|
gm.call("_toggle_wand_screening")
|
||||||
|
state = 8
|
||||||
|
state_frames = 0
|
||||||
|
else:
|
||||||
|
gm.call("_toggle_wand_screening")
|
||||||
|
wand_attempts += 1
|
||||||
|
else:
|
||||||
|
gm.call("_cycle_nearby_target")
|
||||||
|
_face_target()
|
||||||
|
gm.call("_toggle_wand_screening")
|
||||||
|
wand_attempts += 1
|
||||||
|
elif state_frames > 600 and gm != null:
|
||||||
|
# Fallback: real controller, panel shown directly.
|
||||||
|
var controller: Object = gm.call("_targeted_controller")
|
||||||
|
if controller != null and controller.profile != null:
|
||||||
|
gm.hud.show_wand(controller, gm.wand_screener.screen(controller.profile.carried_items))
|
||||||
|
state = 8
|
||||||
|
state_frames = 0
|
||||||
|
8:
|
||||||
|
if state_frames == 1 and gm != null:
|
||||||
|
gm.hud.clear_log()
|
||||||
|
_stage_closeup()
|
||||||
|
if state_frames >= 45:
|
||||||
|
_shoot("07-wand-screening.png")
|
||||||
|
if gm != null:
|
||||||
|
if gm.wand_open:
|
||||||
|
gm.call("_toggle_wand_screening")
|
||||||
|
# Real escalation path: formal warning arms the body camera
|
||||||
|
# (a timeline row), then blocking raises the meter to L3.
|
||||||
|
gm.call("_apply_interaction", 2, "formal warning")
|
||||||
|
gm.call("_apply_interaction", 3, "blocking")
|
||||||
|
state = 9
|
||||||
|
state_frames = 0
|
||||||
|
9:
|
||||||
|
if state_frames == 1 and gm != null:
|
||||||
|
# Jump into the late phase now that guests exist: LATE has no
|
||||||
|
# routine spawn pool, so this happens after staging the target.
|
||||||
|
gm.coordinator.elapsed_s = 760.0
|
||||||
|
_stage_closeup()
|
||||||
|
if state_frames >= 45:
|
||||||
|
_shoot("08-escalation-hud.png")
|
||||||
|
if gm != null and gm.coordinator != null:
|
||||||
|
# Close the open incident so the recorder's review includes it
|
||||||
|
# (to_dict only lists closed incidents).
|
||||||
|
gm.coordinator.recorder.close_all_open("unresolved")
|
||||||
|
gm.call("_open_timeline", {"incidents": gm.coordinator.recorder.to_dict()})
|
||||||
|
state = 10
|
||||||
|
state_frames = 0
|
||||||
|
10:
|
||||||
|
if state_frames >= 30:
|
||||||
|
_shoot("09-body-camera-timeline.png")
|
||||||
quit(0)
|
quit(0)
|
||||||
|
|
||||||
|
## Put the guard a couple of steps in front of the target so a staged UI capture
|
||||||
|
## frames the person the panel is about.
|
||||||
|
func _wand_alerts() -> bool:
|
||||||
|
if gm == null or gm.hud == null or gm.hud.wand_text == null:
|
||||||
|
return false
|
||||||
|
return gm.hud.wand_text.text.contains("Alert: YES")
|
||||||
|
|
||||||
|
func _stage_closeup() -> void:
|
||||||
|
var target: Node3D = gm.targeted_npc
|
||||||
|
if target == null or not is_instance_valid(target):
|
||||||
|
return
|
||||||
|
gm.guard.global_position = target.global_position + Vector3(0, -0.4, 2.2)
|
||||||
|
gm.guard.global_position.y = 0.1
|
||||||
|
_face_target()
|
||||||
|
gm.guard.head.rotation.x = deg_to_rad(1.0)
|
||||||
|
|
||||||
func _face_target() -> void:
|
func _face_target() -> void:
|
||||||
var target: Node3D = gm.targeted_npc
|
var target: Node3D = gm.targeted_npc
|
||||||
if target == null:
|
if target == null:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
extends SceneTree
|
extends SceneTree
|
||||||
## Windowed smoke helper for the ADR-0007–0010 control pass (focus ring + action
|
## Windowed smoke helper for the ADR-0007–0010 control pass (focus circle + action
|
||||||
## wheel). Boots the real main scene, stages one NPC in front of the guard, opens
|
## 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
|
## 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.
|
## radial state did not come up, so it can be used as a smoke check.
|
||||||
@@ -76,7 +76,7 @@ func _advance() -> void:
|
|||||||
(gm.guard.global_position as Vector3).y,
|
(gm.guard.global_position as Vector3).y,
|
||||||
(gm.targeted_npc.global_position as Vector3).y,
|
(gm.targeted_npc.global_position as Vector3).y,
|
||||||
])
|
])
|
||||||
_shoot("smoke-01-focus-ring.png")
|
_shoot("smoke-01-focus-circle.png")
|
||||||
gm.call("_open_radial")
|
gm.call("_open_radial")
|
||||||
state = 2
|
state = 2
|
||||||
state_frames = 0
|
state_frames = 0
|
||||||
@@ -93,8 +93,53 @@ func _advance() -> void:
|
|||||||
if state_frames >= 5:
|
if state_frames >= 5:
|
||||||
if gm.radial.is_open:
|
if gm.radial.is_open:
|
||||||
_fail("action wheel did not close on cancel")
|
_fail("action wheel did not close on cancel")
|
||||||
|
state = 4
|
||||||
|
state_frames = 0
|
||||||
|
4:
|
||||||
|
# Aim-free selection (ADR-0011): the cycled target stays pinned, the
|
||||||
|
# HUD reports how many people can be cycled, and the mouse wheel is
|
||||||
|
# bound to the same ring.
|
||||||
|
if state_frames >= 5:
|
||||||
|
if gm.targeted_npc == null:
|
||||||
|
_fail("target lost after the wheel closed")
|
||||||
|
if not gm.guard.target_pinned:
|
||||||
|
_fail("cycled target was not pinned")
|
||||||
|
if gm.hud.nearby_label.text == "":
|
||||||
|
_fail("nearby hint empty while people are in range")
|
||||||
|
_wheel(true)
|
||||||
|
state = 5
|
||||||
|
state_frames = 0
|
||||||
|
5:
|
||||||
|
if state_frames >= 5:
|
||||||
|
if gm.targeted_npc == null:
|
||||||
|
_fail("mouse wheel dropped the target")
|
||||||
|
elif not gm.guard.target_pinned:
|
||||||
|
_fail("mouse wheel did not pin the cycled target")
|
||||||
|
print("smoke: wheel cycled to %s" % gm.targeted_npc.name)
|
||||||
|
# Right click with nothing open returns to cone focus.
|
||||||
|
gm.call("_handle_cancel", true)
|
||||||
|
state = 6
|
||||||
|
state_frames = 0
|
||||||
|
6:
|
||||||
|
if state_frames >= 5:
|
||||||
|
if gm.targeted_npc != null:
|
||||||
|
_fail("right click did not clear the target")
|
||||||
|
if gm.guard.target_pinned:
|
||||||
|
_fail("clearing the target left it pinned")
|
||||||
_finish()
|
_finish()
|
||||||
|
|
||||||
|
## Synthesised mouse-wheel event, fed through the real handler so the input-map
|
||||||
|
## binding is exercised as well as the cycling logic.
|
||||||
|
func _wheel(down: bool) -> void:
|
||||||
|
var ev := InputEventMouseButton.new()
|
||||||
|
ev.button_index = MOUSE_BUTTON_WHEEL_DOWN if down else MOUSE_BUTTON_WHEEL_UP
|
||||||
|
ev.pressed = true
|
||||||
|
ev.position = Vector2(640, 360)
|
||||||
|
ev.global_position = ev.position
|
||||||
|
if not ev.is_action_pressed("cycle_target_next"):
|
||||||
|
_fail("cycle_target_next is not bound to the mouse wheel")
|
||||||
|
gm.call("_handle_mouse_button", ev)
|
||||||
|
|
||||||
func _stage_target() -> void:
|
func _stage_target() -> void:
|
||||||
if stage_npc == null or not is_instance_valid(stage_npc):
|
if stage_npc == null or not is_instance_valid(stage_npc):
|
||||||
stage_npc = gm.npcs[0] if gm.npcs.size() > 0 else null
|
stage_npc = gm.npcs[0] if gm.npcs.size() > 0 else null
|
||||||
@@ -127,7 +172,7 @@ func _fail(message: String) -> void:
|
|||||||
|
|
||||||
func _finish() -> void:
|
func _finish() -> void:
|
||||||
if failures.is_empty():
|
if failures.is_empty():
|
||||||
print("SMOKE OK: focus ring + action wheel opened and cancelled")
|
print("SMOKE OK: focus circle + action wheel + aim-free cycling")
|
||||||
quit(0)
|
quit(0)
|
||||||
else:
|
else:
|
||||||
for message in failures:
|
for message in failures:
|
||||||
|
|||||||
@@ -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,165 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Prop-collision smoke: proves the guard and an NPC cannot walk through street
|
||||||
|
## furniture, and that a guest slides around a bench instead of passing through
|
||||||
|
## it. Windowed (the guard reads captured-mouse input):
|
||||||
|
##
|
||||||
|
## godot --path . --script res://src/test/smoke_props.gd
|
||||||
|
##
|
||||||
|
## Exit code 0 = pass, 1 = failure.
|
||||||
|
|
||||||
|
const MAX_FRAMES := 1800
|
||||||
|
const GUARD_PUSH_FRAMES := 100
|
||||||
|
|
||||||
|
var frame := 0
|
||||||
|
var gm: Node = null
|
||||||
|
var started := false
|
||||||
|
var step := 0
|
||||||
|
var step_frame := 0
|
||||||
|
var failures: Array[String] = []
|
||||||
|
var bench: Node3D = null
|
||||||
|
var bench_center := Vector3.ZERO
|
||||||
|
var bench_body: StaticBody3D = null
|
||||||
|
var guard_start := Vector3.ZERO
|
||||||
|
var npc: Node3D = null
|
||||||
|
var npc_start := Vector3.ZERO
|
||||||
|
|
||||||
|
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")
|
||||||
|
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||||
|
started = true
|
||||||
|
return false
|
||||||
|
if frame > MAX_FRAMES:
|
||||||
|
_fail("timeout before the collision checks finished")
|
||||||
|
return true
|
||||||
|
step_frame += 1
|
||||||
|
match step:
|
||||||
|
0:
|
||||||
|
_find_bench()
|
||||||
|
if bench == null:
|
||||||
|
return false
|
||||||
|
# Stand a couple of metres in front of the bench and walk into it.
|
||||||
|
var start := bench_center + Vector3(0, 0, 2.2)
|
||||||
|
start.y = 0.1
|
||||||
|
gm.guard.global_position = start
|
||||||
|
# Face the bench (-z): with the guard's basis, forward input at yaw PI
|
||||||
|
# walks toward lower z.
|
||||||
|
gm.guard.rotation.y = PI
|
||||||
|
guard_start = start
|
||||||
|
Input.action_press("move_forward")
|
||||||
|
step = 1
|
||||||
|
step_frame = 0
|
||||||
|
1:
|
||||||
|
if step_frame >= GUARD_PUSH_FRAMES:
|
||||||
|
Input.action_release("move_forward")
|
||||||
|
_check(guard_start.z - gm.guard.global_position.z > 0.5,
|
||||||
|
"guard walked toward the bench")
|
||||||
|
_check(gm.guard.global_position.z > bench_center.z + 0.25,
|
||||||
|
"guard stopped at the bench instead of passing through it")
|
||||||
|
_check(not _inside_bench(gm.guard.global_position),
|
||||||
|
"guard is not inside the bench collider")
|
||||||
|
# Step the guard aside so the guest test hits the bench, not the player.
|
||||||
|
gm.guard.global_position = bench_center + Vector3(4.0, -0.08, 2.2)
|
||||||
|
_stage_npc()
|
||||||
|
step = 2
|
||||||
|
step_frame = 0
|
||||||
|
2:
|
||||||
|
if step_frame % 120 == 0 and npc != null and is_instance_valid(npc):
|
||||||
|
print("npc dbg pos=%s vel=%s arrived=%s wait=%s" % [str(npc.global_position), str(npc.velocity), str(npc.arrived), str(npc.awaiting_interaction)])
|
||||||
|
if npc == null or not is_instance_valid(npc):
|
||||||
|
_stage_npc()
|
||||||
|
return false
|
||||||
|
if _npc_finished():
|
||||||
|
_check(npc.global_position.z < bench_center.z - 0.4,
|
||||||
|
"guest reached the far side of the bench")
|
||||||
|
_check(not _inside_bench(npc.global_position),
|
||||||
|
"guest is not inside the bench collider")
|
||||||
|
_check(npc_start.distance_to(npc.global_position) > 1.0,
|
||||||
|
"guest moved while routing around the prop")
|
||||||
|
_finish()
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
func _find_bench() -> void:
|
||||||
|
var detail := gm.get_node_or_null("World/StreetDetail")
|
||||||
|
if detail == null:
|
||||||
|
# The world root name differs between scene builds; search instead.
|
||||||
|
detail = root.find_child("StreetDetail", true, false)
|
||||||
|
if detail == null:
|
||||||
|
_fail("StreetDetail not found")
|
||||||
|
return
|
||||||
|
for child in detail.get_children():
|
||||||
|
if str(child.name).begins_with("Prop_bench"):
|
||||||
|
bench = child as Node3D
|
||||||
|
break
|
||||||
|
if bench == null:
|
||||||
|
_fail("no bench prop placed")
|
||||||
|
return
|
||||||
|
bench_center = bench.global_position
|
||||||
|
bench_body = bench.get_node_or_null("Collision") as StaticBody3D
|
||||||
|
if bench_body == null:
|
||||||
|
_fail("bench has no static collider")
|
||||||
|
|
||||||
|
func _stage_npc() -> void:
|
||||||
|
if gm.npcs.is_empty():
|
||||||
|
step = 0
|
||||||
|
step_frame = 0
|
||||||
|
return
|
||||||
|
npc = gm.npcs[0]
|
||||||
|
npc_start = bench_center + Vector3(0, 0.5, 2.5)
|
||||||
|
npc.global_position = npc_start
|
||||||
|
npc.velocity = Vector3.ZERO
|
||||||
|
npc.arrived = false
|
||||||
|
npc.awaiting_interaction = false
|
||||||
|
npc.lifetime_s = 999.0
|
||||||
|
npc.reaction_mode = ""
|
||||||
|
# Target well past the bench forces a straight line through it, and stays
|
||||||
|
# beyond the controller's 3 m "arrived" shortcut so the guest must route.
|
||||||
|
npc.target_position = Vector3(bench_center.x, 0.5, bench_center.z - 5.0)
|
||||||
|
|
||||||
|
func _npc_finished() -> bool:
|
||||||
|
if npc == null or not is_instance_valid(npc):
|
||||||
|
return false
|
||||||
|
return npc.global_position.z < bench_center.z - 0.4 \
|
||||||
|
or npc.global_position.distance_to(npc.target_position) < 0.9
|
||||||
|
|
||||||
|
func _inside_bench(point: Vector3) -> bool:
|
||||||
|
if bench_body == null:
|
||||||
|
return false
|
||||||
|
var collider := bench_body.get_node_or_null("Shape") as CollisionShape3D
|
||||||
|
if collider == null or not (collider.shape is BoxShape3D):
|
||||||
|
return false
|
||||||
|
var box := collider.shape as BoxShape3D
|
||||||
|
var half := box.size * 0.5
|
||||||
|
var center := collider.global_position
|
||||||
|
half.x += 0.1
|
||||||
|
half.z += 0.1
|
||||||
|
return absf(point.x - center.x) < half.x and absf(point.z - center.z) < half.z
|
||||||
|
|
||||||
|
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:
|
||||||
|
Input.action_release("move_forward")
|
||||||
|
if failures.is_empty():
|
||||||
|
print("SMOKE OK: props block the guard and guests route around them")
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
for message in failures:
|
||||||
|
printerr("SMOKE FAIL: " + message)
|
||||||
|
quit(1)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Scenario-content smoke: forces the authored Joke Shop refusal event and the
|
||||||
|
## Studio celebrity arrival through the real spawn path, then checks the extra
|
||||||
|
## bystanders, the hint log, and that a screening refusal is handled.
|
||||||
|
##
|
||||||
|
## Usage:
|
||||||
|
## godot --headless --path . --script res://src/test/smoke_scenario_content.gd
|
||||||
|
|
||||||
|
const MAX_FRAMES := 900
|
||||||
|
|
||||||
|
var frame := 0
|
||||||
|
var gm: Node = null
|
||||||
|
var started := false
|
||||||
|
var step := 0
|
||||||
|
var failures: Array[String] = []
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
root.size = Vector2i(1280, 720)
|
||||||
|
root.add_child(load("res://scenes/main.tscn").instantiate())
|
||||||
|
|
||||||
|
func _process(_delta: float) -> bool:
|
||||||
|
frame += 1
|
||||||
|
if gm == null:
|
||||||
|
gm = root.find_child("GameManager", true, false)
|
||||||
|
return false
|
||||||
|
if not started:
|
||||||
|
gm.call("_on_venue_selected", "joke_shop")
|
||||||
|
started = true
|
||||||
|
return false
|
||||||
|
if frame > MAX_FRAMES:
|
||||||
|
_fail("timeout")
|
||||||
|
return true
|
||||||
|
if frame < 120:
|
||||||
|
return false
|
||||||
|
match step:
|
||||||
|
0:
|
||||||
|
# Force the refusal event through the normal spawn signal.
|
||||||
|
var event := _find_event(gm.selected_scenario.event_pool, "joke_refuses_wand")
|
||||||
|
_check(not event.is_empty(), "the refusal event exists in the Joke Shop pool")
|
||||||
|
var guest: Object = gm.selected_scenario.profile_for(int(event.get("npc_type", 0)))
|
||||||
|
gm.coordinator.npc_spawn_requested.emit(guest, "enter", "smoke_refuser", event)
|
||||||
|
_check(gm.npcs.size() > 0, "the refusing guest spawned")
|
||||||
|
gm.call("_on_guard_target", gm.npcs[gm.npcs.size() - 1])
|
||||||
|
var controller: Object = gm.npcs[gm.npcs.size() - 1].get_npc_controller()
|
||||||
|
_check(controller.refuses_screening, "the refusal flag reached the controller")
|
||||||
|
gm.call("_toggle_wand_screening")
|
||||||
|
_check(not gm.wand_open, "screening does not open for someone who refuses")
|
||||||
|
step = 1
|
||||||
|
1:
|
||||||
|
# Studio: celebrity arrival should bring extra bystanders.
|
||||||
|
gm.call("_on_venue_selected", "tv_studio")
|
||||||
|
step = 2
|
||||||
|
2:
|
||||||
|
if frame % 20 != 0:
|
||||||
|
return false
|
||||||
|
var event := _find_event(gm.selected_scenario.event_pool, "studio_celebrity_arrival")
|
||||||
|
var before: int = gm.npcs.size()
|
||||||
|
var guest: Object = gm.selected_scenario.profile_for(int(event.get("npc_type", 0)))
|
||||||
|
gm.coordinator.npc_spawn_requested.emit(guest, "arrive", "smoke_celeb", event)
|
||||||
|
var added: int = gm.npcs.size() - before
|
||||||
|
_check(added >= 4, "celebrity arrival spawns the guest plus bystanders (%d)" % added)
|
||||||
|
step = 3
|
||||||
|
3:
|
||||||
|
_finish()
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
func _find_event(pool: Array, id: String) -> Dictionary:
|
||||||
|
for event in pool:
|
||||||
|
if str((event as Dictionary).get("id", "")) == id:
|
||||||
|
return event
|
||||||
|
return {}
|
||||||
|
|
||||||
|
func _check(condition: bool, message: String) -> void:
|
||||||
|
if condition:
|
||||||
|
print("ok: " + message)
|
||||||
|
else:
|
||||||
|
_fail(message)
|
||||||
|
|
||||||
|
func _fail(message: String) -> void:
|
||||||
|
failures.append(message)
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
func _finish() -> void:
|
||||||
|
if failures.is_empty():
|
||||||
|
print("SMOKE OK: authored scenario content")
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
for message in failures:
|
||||||
|
printerr("SMOKE FAIL: " + message)
|
||||||
|
quit(1)
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Body-camera timeline smoke: records a few real interactions, opens the review's
|
||||||
|
## timeline, steps through every event, and closes back to the review.
|
||||||
|
##
|
||||||
|
## Usage:
|
||||||
|
## godot --headless --path . --script res://src/test/smoke_timeline.gd
|
||||||
|
|
||||||
|
const MAX_FRAMES := 900
|
||||||
|
|
||||||
|
var frame := 0
|
||||||
|
var gm: Node = null
|
||||||
|
var started := false
|
||||||
|
var step := 0
|
||||||
|
var failures: Array[String] = []
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
root.size = Vector2i(1280, 720)
|
||||||
|
root.add_child(load("res://scenes/main.tscn").instantiate())
|
||||||
|
|
||||||
|
func _process(_delta: float) -> bool:
|
||||||
|
frame += 1
|
||||||
|
if gm == null:
|
||||||
|
gm = root.find_child("GameManager", true, false)
|
||||||
|
return false
|
||||||
|
if not started:
|
||||||
|
gm.call("_on_venue_selected", "hollywood_building")
|
||||||
|
started = true
|
||||||
|
return false
|
||||||
|
if frame > MAX_FRAMES:
|
||||||
|
_fail("timeout")
|
||||||
|
return true
|
||||||
|
if frame < 200:
|
||||||
|
return false
|
||||||
|
match step:
|
||||||
|
0:
|
||||||
|
if gm.npcs.is_empty():
|
||||||
|
return false
|
||||||
|
gm.call("_on_guard_target", gm.npcs[0])
|
||||||
|
gm.call("_apply_interaction", 1, "eye contact")
|
||||||
|
gm.call("_request_reason")
|
||||||
|
gm.call("_apply_interaction", 2, "formal warning")
|
||||||
|
var review: Dictionary = gm.coordinator.build_review()
|
||||||
|
# Real flow: the review screen shows first, then its timeline button.
|
||||||
|
gm.shift_review_panel.show_review(review, gm.selected_scenario, [])
|
||||||
|
var button: Button = gm.shift_review_panel.find_child("TimelineButton", true, false) as Button
|
||||||
|
_check(button != null, "review offers a body-camera timeline button")
|
||||||
|
if button:
|
||||||
|
button.pressed.emit()
|
||||||
|
var count: int = gm.event_timeline.events.size()
|
||||||
|
_check(count >= 3, "timeline collected the interaction events (%d)" % count)
|
||||||
|
_check(gm.event_timeline.visible, "timeline is showing")
|
||||||
|
_check(not gm.shift_review_panel.visible, "review is hidden behind the timeline")
|
||||||
|
_check(gm.guard.input_locked, "the guard is frozen while the timeline is open")
|
||||||
|
for i in count + 2:
|
||||||
|
gm.event_timeline.step(1)
|
||||||
|
_check(gm.event_timeline.index == count - 1, "stepping clamps at the last event")
|
||||||
|
gm.event_timeline.close()
|
||||||
|
step = 1
|
||||||
|
1:
|
||||||
|
_check(not gm.event_timeline.visible, "timeline closes")
|
||||||
|
_check(gm.shift_review_panel.visible, "closing returns to the review")
|
||||||
|
_finish()
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
func _check(condition: bool, message: String) -> void:
|
||||||
|
if condition:
|
||||||
|
print("ok: " + message)
|
||||||
|
else:
|
||||||
|
_fail(message)
|
||||||
|
|
||||||
|
func _fail(message: String) -> void:
|
||||||
|
failures.append(message)
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
func _finish() -> void:
|
||||||
|
if failures.is_empty():
|
||||||
|
print("SMOKE OK: body-camera timeline")
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
for message in failures:
|
||||||
|
printerr("SMOKE FAIL: " + message)
|
||||||
|
quit(1)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Accessibility: escalation cues that survive grayscale (shape + level, not hue
|
||||||
|
## alone) and the HUD font scale from settings.
|
||||||
|
|
||||||
|
const Hud := preload("res://src/game/ui/hud.gd")
|
||||||
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
|
|
||||||
|
func test_meter_shape_and_level() -> bool:
|
||||||
|
var a := TestUtils.check_eq(Hud.escalation_meter(0), "[-----] L0/5", "calm shows an empty meter")
|
||||||
|
var b := TestUtils.check_eq(Hud.escalation_meter(3), "[###--] L3/5", "three filled blocks at blocking")
|
||||||
|
var c := TestUtils.check_eq(Hud.escalation_meter(5), "[#####] L5/5", "emergency fills the meter")
|
||||||
|
var d := TestUtils.check_eq(Hud.escalation_meter(99), "[#####] L5/5", "levels clamp at 5")
|
||||||
|
var e := TestUtils.check_eq(Hud.escalation_meter(-2), "[-----] L0/5", "levels clamp at 0")
|
||||||
|
return a and b and c and d and e
|
||||||
|
|
||||||
|
func test_meter_is_not_hue_only() -> bool:
|
||||||
|
## Every level must differ in shape/text, independent of colour.
|
||||||
|
var seen: Array[String] = []
|
||||||
|
for level in 6:
|
||||||
|
var meter: String = Hud.escalation_meter(level)
|
||||||
|
if seen.has(meter):
|
||||||
|
return TestUtils.fail("levels %d and %d share a meter" % [level, seen.find(meter)])
|
||||||
|
seen.append(meter)
|
||||||
|
return TestUtils.check(seen.size() == 6, "all six levels have distinct meters")
|
||||||
|
|
||||||
|
func test_escalation_label_includes_meter_and_name() -> bool:
|
||||||
|
var hud: Node = Hud.new()
|
||||||
|
hud.set_escalation(Rules.Level.FORMAL_WARNING)
|
||||||
|
var text: String = hud.escalation_label.text
|
||||||
|
var a := TestUtils.check(text.contains("Formal Warning"), "label names the level")
|
||||||
|
var b := TestUtils.check(text.contains("[##---]"), "label shows the shape meter")
|
||||||
|
var c := TestUtils.check(text.contains("L2/5"), "label shows the numeric level")
|
||||||
|
hud.free()
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_font_scale_applies_to_core_labels() -> bool:
|
||||||
|
var hud: Node = Hud.new()
|
||||||
|
var base_top: int = hud.top_label.get_theme_font_size("font_size")
|
||||||
|
var base_log: int = hud.log_label.get_theme_font_size("font_size")
|
||||||
|
var base_esc: int = hud.escalation_label.get_theme_font_size("font_size")
|
||||||
|
var base_mode: int = hud.mode_label.get_theme_font_size("font_size")
|
||||||
|
hud.set_font_scale(1.4)
|
||||||
|
var a := TestUtils.check(hud.top_label.get_theme_font_size("font_size") > base_top, "top label grows")
|
||||||
|
var b := TestUtils.check(hud.log_label.get_theme_font_size("font_size") > base_log, "log grows")
|
||||||
|
var c := TestUtils.check(hud.escalation_label.get_theme_font_size("font_size") > base_esc, "escalation label grows")
|
||||||
|
var d := TestUtils.check(hud.mode_label.get_theme_font_size("font_size") > base_mode, "mode label grows")
|
||||||
|
# Scaling is by font size, not Control.scale, so anchored labels keep layout.
|
||||||
|
var e := TestUtils.check(is_equal_approx(hud.top_label.scale.x, 1.0), "labels are not Control-scaled")
|
||||||
|
hud.set_font_scale(9.0)
|
||||||
|
var capped: int = hud.top_label.get_theme_font_size("font_size")
|
||||||
|
var f := TestUtils.check(capped <= int(round(float(base_top) * 1.75)) + 1, "scale clamps at 1.75")
|
||||||
|
hud.free()
|
||||||
|
return a and b and c and d and e and f
|
||||||
|
|
||||||
|
func test_settings_font_scale_is_applied_after_ui_exists() -> bool:
|
||||||
|
## The HUD is created after settings load, so the manager must re-apply.
|
||||||
|
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
||||||
|
var a := TestUtils.check(src.contains("_apply_settings(player_settings)"), "settings are applied on boot")
|
||||||
|
var build_idx := src.find("_build_ui()")
|
||||||
|
var apply_idx := src.find("_apply_settings(player_settings)", build_idx)
|
||||||
|
var b := TestUtils.check(apply_idx > build_idx, "the second apply happens after the HUD is built")
|
||||||
|
var c := TestUtils.check(src.contains("hud.set_font_scale(float(settings.get("),
|
||||||
|
"settings drive the HUD font scale")
|
||||||
|
return a and b and c
|
||||||