Implement control pass: focus cone, action wheel, named inputs, boot fixes
- Venue-correct access artifacts (AccessLoadout) and screened-aware verify - Cone focus on NPC heads with billboarded ring + display name (ADR-0008) - Action wheel with de-escalation-first slices (ADR-0009); Esc/RMB cancel - Procedural NPC face/hair/neck/shoes (ADR-0010); named input actions - Fix GameManager parse error (radial typed as ActionRadial) - Fix first-person camera height and guard spawning inside the building - Move building volume back so the door/awning/marquee are visible - Pull the event scheduler once per simulated second; cap live NPCs - Regenerate README screenshots; update TODO/DONE/MEMORY/README; 77 tests Adds src/test/smoke_focus_radial.gd for the windowed focus/radial smoke.
No files matched your search
@@ -302,3 +302,91 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every
|
|||||||
- [x] Regenerate the AI asset set at role-specific aspect ratios and dimensions.
|
- [x] Regenerate the AI asset set at role-specific aspect ratios and dimensions.
|
||||||
- [x] Audit, process, and wire every generated asset into the game runtime.
|
- [x] Audit, process, and wire every generated asset into the game runtime.
|
||||||
- [x] Add screenshot auditing and refresh the README screenshot gallery.
|
- [x] Add screenshot auditing and refresh the README screenshot gallery.
|
||||||
|
|
||||||
|
## Access artifacts actually match the venue
|
||||||
|
|
||||||
|
Completed in the working tree (uncommitted with the control pass). Headless **74/74**.
|
||||||
|
|
||||||
|
- [x] Build artifacts from the event (`override_access`, carried items) and the venue policy,
|
||||||
|
not from `NpcType` alone. (`src/core/access_loadout.gd`; spawn signal is
|
||||||
|
`(profile, intent, npc_id, event)`.)
|
||||||
|
- [x] Joke Shop: valid ID+ticket, ID without ticket, expired/missing ID. Recognition does not
|
||||||
|
auto-grant on every `AUTHORIZED_GUEST`.
|
||||||
|
- [x] Wand venues roll `carried_items` per spawn (seeded); override can force a pocket.
|
||||||
|
- [x] Studio counterfeit / follow-through events carry `override_access`.
|
||||||
|
- [x] `AccessVerifier.verify(..., already_screened)` so a screened guest is not still denied.
|
||||||
|
- [x] Joke Shop / Studio `sidewalk` and `steps_end` waypoints so film/photo intents do not walk
|
||||||
|
to the entrance.
|
||||||
|
- [x] Wand shortcut is `C`, not `W`. Tests: `src/test/tests/test_access_loadout.gd`.
|
||||||
|
|
||||||
|
## Player control, focus ring, and radial action menu
|
||||||
|
|
||||||
|
Completed 2026-10-31 with ADR-0007–0010. Headless **77/77**, plus a windowed smoke
|
||||||
|
(`src/test/smoke_focus_radial.gd`) that opens the wheel and saves screenshots.
|
||||||
|
|
||||||
|
### Locomotion
|
||||||
|
|
||||||
|
- [x] Wand off `W` (now `C` + wheel). `A`/`D` allow/deny only while inspect/wand is open
|
||||||
|
(they step the wheel while it is open). `Q` remains a closed-menu polite shortcut.
|
||||||
|
- [x] Accel/decel on walk; gravity only when airborne; `floor_snap_length` 0.3.
|
||||||
|
- [x] Mouse look only while captured and `not input_locked`. Freeze walk+look while radial /
|
||||||
|
inspect / wand / radio / review is up. Recapture on close.
|
||||||
|
- [x] `cancel` (Esc / RMB) closes the top modal first; with nothing open, Esc toggles mouse
|
||||||
|
capture and RMB does nothing.
|
||||||
|
|
||||||
|
### Focus and head ring
|
||||||
|
|
||||||
|
- [x] Focus cone in `src/game/npc_focus.gd` (~10 m, ~10°). Closest-to-forward wins; occlusion
|
||||||
|
ray skips building hits. `Tab` still cycles 12 m.
|
||||||
|
- [x] 8-frame miss grace so walk-bob does not drop focus.
|
||||||
|
- [x] Billboarded torus on `Rig/Head`; gold idle, brighter while the radial is open; `Label3D`
|
||||||
|
`display_name`. HUD target line uses the display name, not the node name.
|
||||||
|
- [x] Screen-center crosshair.
|
||||||
|
|
||||||
|
### Radial HUD
|
||||||
|
|
||||||
|
- [x] `src/game/ui/action_radial.gd`: LMB or `Space` opens when someone is focused. Opening
|
||||||
|
click ignored for 120 ms. Slices: raise hand, point, head shake, polite, inspect, wand
|
||||||
|
(grey if no wand), warning, block, radio assist. Emergency stays on `R`.
|
||||||
|
- [x] Highlight by mouse angle, or `A`/`D` / arrows. Click slice or `Space` confirms. `Esc` /
|
||||||
|
RMB closes with no action (no recorder event).
|
||||||
|
- [x] Inspect / wand / radio slices close the wheel then open the existing panels. Freeze until
|
||||||
|
that panel closes.
|
||||||
|
- [x] Number keys `1`–`6` still work when the wheel is closed.
|
||||||
|
|
||||||
|
### NPC display
|
||||||
|
|
||||||
|
- [x] Procedural rig: neck, shoes, hair cap from seed, two eye spheres. Props, height
|
||||||
|
variation, walk bob, notice-head-turn kept. Collision capsule scaled to rig height.
|
||||||
|
Named `Head` is the ring attach point. Group `npc`.
|
||||||
|
|
||||||
|
### Input, copy, tests
|
||||||
|
|
||||||
|
- [x] Named input map: `action_menu` (Space/LMB), `confirm` (Space/Enter), `cancel`
|
||||||
|
(Esc/RMB), `inspect` (E), `wand` (C). `GameManager` uses `is_action_pressed`.
|
||||||
|
- [x] Windowed smoke: focus ring + name, wheel open/step/confirm/cancel
|
||||||
|
(`src/test/smoke_focus_radial.gd`, exits non-zero on failure).
|
||||||
|
- [x] README controls table, smoke checklist, test count, implemented/not-yet lists.
|
||||||
|
|
||||||
|
## Runtime boot and spawn fixes
|
||||||
|
|
||||||
|
Found while running the windowed smoke; all verified with a boot run and regenerated
|
||||||
|
screenshots.
|
||||||
|
|
||||||
|
- [x] `GameManager` did not compile: `radial` was typed `CanvasLayer`, so
|
||||||
|
`radial.is_open` / `confirm_selected()` made `:=` inference fail. Typed as
|
||||||
|
`ActionRadial` (the parse error would have broken the whole main scene).
|
||||||
|
- [x] Camera sat at the guard's feet: `Head` had no height offset, and the new gravity pulled
|
||||||
|
the guard to the floor. `Head` is now at y=1.6, and the guard spawns on the landing.
|
||||||
|
- [x] Guard spawned inside the building collider and fell through the world. Building volume
|
||||||
|
moved back to z ∈ [-8, -4] so the door / awning / marquee sit on its front face; the
|
||||||
|
guard spawns in front of the door facing the street.
|
||||||
|
- [x] Steps rose away from the door; treads now rise toward the entrance.
|
||||||
|
- [x] Event scheduler was polled every physics frame (~60 Hz), spawning >1200 NPCs in
|
||||||
|
seconds. `ShiftCoordinator` now accumulates time and pulls once per simulated second;
|
||||||
|
regression test in `test_scenarios.gd`.
|
||||||
|
- [x] Live NPCs capped at `MAX_LIVE_NPCS` (16); routine traffic is dropped at the cap and a
|
||||||
|
scripted event evicts the oldest unfocused NPC.
|
||||||
|
- [x] Removed the skyline backdrop planes (opaque black texture). Tracked as a follow-up in
|
||||||
|
`TODO.md`.
|
||||||
|
- [x] README screenshots regenerated with the fixed eye height and entrance staging.
|
||||||
@@ -13,11 +13,41 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
- Shift length is the scenario's `shift_duration_s` (900 s in all three venues). Tests and the screenshot tool override elapsed time; they do not change the default.
|
- Shift length is the scenario's `shift_duration_s` (900 s in all three venues). Tests and the screenshot tool override elapsed time; they do not change the default.
|
||||||
- RNG: NPC reactions and event scheduling are seedable for deterministic tests.
|
- RNG: NPC reactions and event scheduling are seedable for deterministic tests.
|
||||||
|
|
||||||
## Current State
|
## Handoff (read this first)
|
||||||
|
|
||||||
- Next playable pass (planned, not coded): ADR-0007 locomotion/input layers, ADR-0008 cone focus +
|
- Repo: `/run/media/system/DATA/Documents/06-games/security-game`. Godot: `~/.local/bin/godot` (4.3.stable).
|
||||||
head ring, ADR-0009 radial action menu, ADR-0010 procedural NPC presentation. Emergency stays on
|
Tests: `godot --headless --path . --script res://src/test/test_runner.gd` → **77/77**.
|
||||||
`R`. Wand leaves `W`. Host sign-in follow-up is deferred. PLAN.md has the full pass notes.
|
Windowed control smoke: `godot --path . --script res://src/test/smoke_focus_radial.gd` → exit 0.
|
||||||
|
- `main` is **7 commits ahead of origin**, all committed locally, **not pushed**. The newest commit
|
||||||
|
is the control pass + boot/spawn fixes (access loadout, focus cone, action wheel, named input
|
||||||
|
actions, NPC rig, camera/building fixes, scheduler rate, NPC cap, screenshots, docs).
|
||||||
|
- Nothing is left uncommitted. Ask before pushing if you did not author the earlier commits.
|
||||||
|
- Next work, in order: (1) session shell in `TODO.md`; (2) world follow-ups listed at the top of
|
||||||
|
`TODO.md` (guard floats ~0.5 m, step colliders, skyline asset, marquee size); (3) push / release
|
||||||
|
when the user asks.
|
||||||
|
- Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
- Access artifacts: `src/core/access_loadout.gd` builds from event `override_access` + venue policy.
|
||||||
|
Spawn signal is now `(profile, intent, npc_id, event)`. Recognition is not auto-attached to every
|
||||||
|
guest. `AccessVerifier.verify(..., already_screened=false)` so Joke Shop scoring uses
|
||||||
|
`controller.screened`. Joke Shop/Studio gained `sidewalk`/`steps_end` waypoints. Wand venues roll
|
||||||
|
`carried_items` per spawn (seeded). Tests in `src/test/tests/test_access_loadout.gd`.
|
||||||
|
- Wand shortcut is **`C`**, not `W` (`W` is walk). `radio_emergency` input map no longer includes W.
|
||||||
|
`hud.controls` / `hud.screen_first` updated.
|
||||||
|
- Control pass (in tree, uncommitted, smoke-tested): cone focus via `src/game/npc_focus.gd`,
|
||||||
|
accel/decel, gravity only airborne, `input_locked` freeze, Esc no longer on the guard.
|
||||||
|
`NpcAvatar` in group `npc`; neck/hair/eyes/shoes; torus head ring + `Label3D` name; `set_focus_visual`.
|
||||||
|
`src/game/ui/action_radial.gd` doughnut (LMB/Space open, A/D/arrows, Space/click confirm, Esc/RMB
|
||||||
|
cancel). Crosshair CanvasLayer. Emergency still `R` only. Number keys 1–6 when the wheel is closed.
|
||||||
|
- Named input actions now exist and are used: `action_menu`, `confirm`, `cancel` (Esc + RMB),
|
||||||
|
`inspect` (E), `wand` (C). The old unused `interact` action was removed.
|
||||||
|
- Runtime fixes from the smoke: `Head` is +1.6 above the guard root (root sits on the floor); guard
|
||||||
|
spawns in front of the door facing the street; building volume moved to z ∈ [-8,-4] so the door,
|
||||||
|
awning, and marquee are visible; step treads rise toward the door; `ShiftCoordinator` pulls the
|
||||||
|
event scheduler once per simulated second (was every physics frame → >1200 NPCs in seconds);
|
||||||
|
`GameManager.MAX_LIVE_NPCS = 16` caps live avatars; skyline backdrop removed (opaque black asset).
|
||||||
|
- Host sign-in follow-up remains deferred. PLAN.md + ADRs 0007–0010 still describe the intended UX.
|
||||||
- **Release v0.2.1 is LIVE** (2026-10-31): CI run 1110 built the NSIS installer + AppImage
|
- **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
|
||||||
@@ -39,12 +69,12 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
public (HTTP 200). Caveat: anonymous **asset downloads** redirect to login on this host
|
public (HTTP 200). Caveat: anonymous **asset downloads** redirect to login on this host
|
||||||
(host setting — tracked in TODO). Future releases = bump `config/version`, `git tag vX.Y.Z`,
|
(host setting — tracked in TODO). Future releases = bump `config/version`, `git tag vX.Y.Z`,
|
||||||
push. No local token needed.
|
push. No local token needed.
|
||||||
- Core simulation, three scenarios, headless tests (**57 passing**), and a first-person prototype with interaction HUD, access inspection, radio (assist + emergency hold-to-confirm), debug overlay, and shift review are implemented and green.
|
- Core simulation, three scenarios, v0.2.1 installers/AppImage, first-person prototype (inspect/wand/radio, shift review, Tab targeting, V venue cycle) are on local `main`. Tagged v0.2.1 was 57 tests; this working tree is **74/74**.
|
||||||
- NPC avatars now walk to per-intent waypoints (`VenueScenario.waypoint_for_intent`): enter/trespass/deliver/arrive → entrance, photo/film/perform/wander/roam → sidewalk, sit → steps_end. `NpcAvatar.setup` takes an explicit `door_position`.
|
- NPC avatars now walk to per-intent waypoints (`VenueScenario.waypoint_for_intent`): enter/trespass/deliver/arrive → entrance, photo/film/perform/wander/roam → sidewalk, sit → steps_end. `NpcAvatar.setup` takes an explicit `door_position`.
|
||||||
- Returning-trespasser behavior: `NpcProfile.return_probability` (trespasser 0.6, mental-crisis 0.4, others 0). `NPCController._maybe_schedule_return()` moves a directed-away NPC to `LOITERING_DISTANT`; `tick_return(delta)` fires after 20-60s and transitions back to `APPROACH`. `NpcAvatar._advance_fsm` re-targets the door on return.
|
- Returning-trespasser behavior: `NpcProfile.return_probability` (trespasser 0.6, mental-crisis 0.4, others 0). `NPCController._maybe_schedule_return()` moves a directed-away NPC to `LOITERING_DISTANT`; `tick_return(delta)` fires after 20-60s and transitions back to `APPROACH`. `NpcAvatar._advance_fsm` re-targets the door on return.
|
||||||
- Perception pass: `NPCController.perceive_guard(distance)` returns `{noticed, band, reaction}` with bands `unaware` (>6m), `aware` (2-6m), `near` (<2m). Near + temperament ≥0.6 → ARGUE, else COMPLY. `GameManager._perception_pass()` feeds real guard distance each physics tick and logs first-time notice.
|
- Perception pass: `NPCController.perceive_guard(distance)` returns `{noticed, band, reaction}` with bands `unaware` (>6m), `aware` (2-6m), `near` (<2m). Near + temperament ≥0.6 → ARGUE, else COMPLY. `GameManager._perception_pass()` feeds real guard distance each physics tick and logs first-time notice.
|
||||||
- Responder dispatch: `RadioSystem` tracks `pending_calls` and decrements `arrival_delay_s` in `tick`; emits `responder_arrived` when a call's delay elapses. `RadioSystem.responder_outcome(call)` maps danger/weapons → command/restraint/evacuation/arrest. `GameManager._on_responder_arrived` spawns a `RESPONDER` avatar at the entrance and logs the outcome. HUD shows an emergency-radio cooldown label via `_update_radio_cooldown`.
|
- Responder dispatch: `RadioSystem` tracks `pending_calls` and decrements `arrival_delay_s` in `tick`; emits `responder_arrived` when a call's delay elapses. `RadioSystem.responder_outcome(call)` maps danger/weapons → command/restraint/evacuation/arrest. `GameManager._on_responder_arrived` spawns a `RESPONDER` avatar at the entrance and logs the outcome. HUD shows an emergency-radio cooldown label via `_update_radio_cooldown`.
|
||||||
- Wand screening (Joke Shop): `WandScreener` (new `src/core/wand_screener.gd`) classifies carried items as prohibited (`weapon/knife/gun/blade`) vs false-alarm (`keys/belt/phone/coins/watch/buckle`) vs clear. `NpcProfile.carried_items` holds guest items. `GameManager` binds `W` to `_toggle_wand_screening` and `A`/`D` to `decide_wand`; `decide_access` blocks admission at wand venues until `controller.screened` is true. `VenueScenario.door_staff_lines` holds venue-specific door-staff dialogue (Joke Shop has id/ticket/wand/refusal/prohibited lines).
|
- Wand screening (Joke Shop): `WandScreener` classifies carried items as prohibited vs false-alarm vs clear. `NpcProfile.carried_items` is filled at spawn on wand venues. `GameManager` binds **`C`** to `_toggle_wand_screening` and `A`/`D` to `decide_wand` while inspect/wand is open. `decide_access` still blocks admission until `controller.screened` is true, and `verify` now takes that flag. Door-staff lines log through GameManager.
|
||||||
- Design change (2026-10-31): the selectable dialogue tone-of-voice system was **removed**.
|
- Design change (2026-10-31): the selectable dialogue tone-of-voice system was **removed**.
|
||||||
`Rules.Tone`/`TONE_NAMES`, `NpcProfile.tone_modifiers`, and the tone param on
|
`Rules.Tone`/`TONE_NAMES`, `NpcProfile.tone_modifiers`, and the tone param on
|
||||||
`NPCController.roll_reaction/apply_player_action` no longer exist; `NpcProfile.dialogue_lines`
|
`NPCController.roll_reaction/apply_player_action` no longer exist; `NpcProfile.dialogue_lines`
|
||||||
@@ -119,8 +149,20 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
- Persistence tests write into the real `user://shift_results.json` (harmless local pollution of the demo history).
|
- Persistence tests write into the real `user://shift_results.json` (harmless local pollution of the demo history).
|
||||||
- macOS export required `rendering/textures/vram_compression/import_etc2_astc=true` in `project.godot` (universal build demands ETC2 ASTC enabled).
|
- macOS export required `rendering/textures/vram_compression/import_etc2_astc=true` in `project.godot` (universal build demands ETC2 ASTC enabled).
|
||||||
- Joke Shop `door_staff_lines` are logged through `GameManager` (inspect / wand / refuse). They are not a separate dialogue UI.
|
- Joke Shop `door_staff_lines` are logged through `GameManager` (inspect / wand / refuse). They are not a separate dialogue UI.
|
||||||
- `GameManager._artifacts_for` ignores event `override_access` and maps artifacts by `NpcType`. Joke Shop guests currently inspect as recognized staff. Tracked in TODO "Access artifacts actually match the venue".
|
- Access loadout is implemented in the working tree (see Current State); do not re-fix `override_access` as if it were still unread.
|
||||||
- `Save.SETTINGS_FILE` is declared and unused; no mouse-sensitivity or pause menu yet.
|
- `Save.SETTINGS_FILE` is declared and unused; no mouse-sensitivity or pause menu yet.
|
||||||
|
- Headless suite is **77/77**; the windowed focus/radial smoke passes. Remaining visual follow-ups are at the top of `TODO.md`.
|
||||||
|
- Typed spawn signal extra args: handlers must accept the 4th `event: Dictionary` or Godot will not call them (`test_shift_coordinator_runs_a_shift` failed until `_on_spawn` took four args).
|
||||||
|
- GDScript inference trap that broke the main scene: a `var` typed as a base class
|
||||||
|
(`radial: CanvasLayer`) exposes script members as `Variant`, so `var x := radial.is_open` is a
|
||||||
|
parse error. Type it as the script type (`ActionRadial`) or annotate the local explicitly.
|
||||||
|
`--check-only --script` cannot see the `Save` autoload; use a real boot (`--quit-after 180`) to
|
||||||
|
verify a full-scene compile.
|
||||||
|
- Do not snapshot right after changing a transform in the same frame: `capture_screenshots.gd`
|
||||||
|
needs a state/frame gap or the capture uses the old camera orientation.
|
||||||
|
- `EventScheduler` probabilities are per simulated second, not per call. Never poll it per frame.
|
||||||
|
- Screenshot staging lives in `src/test/capture_screenshots.gd` (guard at z≈10.5 facing the door).
|
||||||
|
`docs/screenshots/` copies are manual: copy from `user://screenshots/`.
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
@@ -130,13 +172,15 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
|
|
||||||
## Plan vs code (re-evaluated)
|
## Plan vs code (re-evaluated)
|
||||||
|
|
||||||
Shipped and tested: simulation core, three venues, 57 headless tests, v0.2.1 installers/AppImage,
|
On local `main` (6 commits not on origin): v0.2.1 packages, ADRs 0007–0010, asset pipeline wired to
|
||||||
procedural world, HUD inspect/wand/radio, shift review (summary only), Tab targeting, V venue cycle.
|
`assets/textures/ai/game_ready/`, screenshots refreshed.
|
||||||
|
|
||||||
Still missing (now in `TODO.md`, control pass first): ADR-0007–0010 input/focus/radial/NPC face;
|
Uncommitted: `AccessLoadout`, control pass (cone, ring, radial, NPC face, named input actions),
|
||||||
venue-correct access artifacts (`override_access` unread); startup/pause/settings; eye contact,
|
and boot/spawn fixes. Headless 77/77; windowed smoke passes. Access-artifact and control-pass
|
||||||
recognize-as-verb, body-cam toggle, real blocking, request-reason; crowd rubberneck; emergency
|
sections moved to `DONE.md`.
|
||||||
aftermath; body-cam replay timeline; sit/photo/film poses; real crowd audio; a few Joke Shop /
|
|
||||||
Studio scripted cases; README test-count drift (says 52).
|
Still in `TODO.md`: world follow-ups (guard float, step colliders, skyline asset, marquee size);
|
||||||
|
session shell; leftover PLAN verbs; crowd rubberneck; emergency aftermath; body-cam replay;
|
||||||
|
sit/photo/film poses; real crowd audio; Joke Shop/Studio extra events; PLAN.md says GUT/GitHub.
|
||||||
|
|
||||||
<!-- b64: RWFzdGVyIGVnZzogYSByYXJlIHJhZGlvIHRyYW5zbWlzc2lvbiAiwr9Ew7NuZGUgZXN0w6EgbGEgbW90YT8iIGZpcmVzIHZpYSBHYW1lTWFuYWdlci5fbWF5YmVfcmFkaW9fZWFzdGVyX2VnZyAoMi41JSBjaGFuY2UgYWZ0ZXIgYSBzdWNjZXNzZnVsIHJhZGlvIGNhbGwpLiBTdHJpbmcga2V5IHJhZGlvLmVhc3Rlcl9lZ2cgaW4gY29udGVudC9zdHJpbmdzLmVuLmpzb24uIERvIG5vdCByZW1vdmUu -->
|
<!-- b64: RWFzdGVyIGVnZzogYSByYXJlIHJhZGlvIHRyYW5zbWlzc2lvbiAiwr9Ew7NuZGUgZXN0w6EgbGEgbW90YT8iIGZpcmVzIHZpYSBHYW1lTWFuYWdlci5fbWF5YmVfcmFkaW9fZWFzdGVyX2VnZyAoMi41JSBjaGFuY2UgYWZ0ZXIgYSBzdWNjZXNzZnVsIHJhZGlvIGNhbGwpLiBTdHJpbmcga2V5IHJhZGlvLmVhc3Rlcl9lZ2cgaW4gY29udGVudC9zdHJpbmdzLmVuLmpzb24uIERvIG5vdCByZW1vdmUu -->
|
||||||
@@ -201,6 +201,9 @@ godot --headless --path . --script res://src/test/test_runner.gd
|
|||||||
|
|
||||||
# Save a screenshot to user:// (windowed)
|
# Save a screenshot to user:// (windowed)
|
||||||
godot --path . --script res://src/test/take_screenshot.gd
|
godot --path . --script res://src/test/take_screenshot.gd
|
||||||
|
|
||||||
|
# Windowed control smoke: focus ring + action wheel, saves PNGs, exits 1 on failure
|
||||||
|
godot --path . --script res://src/test/smoke_focus_radial.gd
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
||||||
@@ -210,15 +213,18 @@ The unit test runner is the project's own lightweight harness (no editor plugin
|
|||||||
| Input | Action |
|
| Input | Action |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| W/A/S/D + mouse | Move and look |
|
| W/A/S/D + mouse | Move and look |
|
||||||
| Esc | Release / capture mouse |
|
| Space / left click | Action wheel for the focused person |
|
||||||
|
| A / D or ←/→ | Step through the action wheel (while it is open) |
|
||||||
|
| Space / left click | Confirm the highlighted action (while the wheel is open) |
|
||||||
|
| Esc / right click | Close the top panel; Esc with nothing open releases / captures the mouse |
|
||||||
|
| 1 / 2 / 3 | Quick gestures without the wheel: raise hand, point, head shake (Level 1) |
|
||||||
| Q | Polite direction (Level 1) |
|
| Q | Polite direction (Level 1) |
|
||||||
| 1 / 2 / 3 | Simple gestures: raise hand, point, head shake (Level 1) |
|
|
||||||
| 4 | Formal warning (Level 2) |
|
| 4 | Formal warning (Level 2) |
|
||||||
| 5 | Blocking and positioning (Level 3) |
|
| 5 | Blocking and positioning (Level 3) |
|
||||||
| 6 | Security assistance radio (Level 4) |
|
| 6 | Security assistance radio (Level 4) |
|
||||||
| R | Emergency radio, hold on-screen button ~1.6s (Level 5) |
|
| R | Emergency radio, hold on-screen button ~1.6s (Level 5) |
|
||||||
| E | Inspect access artifacts / close inspection |
|
| E | Inspect access artifacts / close inspection |
|
||||||
| W | Handheld wand screening (wand venues only) |
|
| C | Handheld wand screening (wand venues only) |
|
||||||
| A / D | Allow / deny entry during inspection or wand check |
|
| A / D | Allow / deny entry during inspection or wand check |
|
||||||
| V | Cycle venue: Hollywood Blvd → The Joke Shop → TV Studio (restarts shift) |
|
| V | Cycle venue: Hollywood Blvd → The Joke Shop → TV Studio (restarts shift) |
|
||||||
| Tab | Cycle target among nearby NPCs (aim-free alternative) |
|
| Tab | Cycle target among nearby NPCs (aim-free alternative) |
|
||||||
@@ -247,28 +253,31 @@ Implemented so far:
|
|||||||
- Godot 4.3 project scaffold, input map, procedural world builder
|
- Godot 4.3 project scaffold, input map, procedural world builder
|
||||||
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
|
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
|
||||||
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
|
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
|
||||||
- Headless unit tests: **52 passing** (access rules, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, navmesh, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings)
|
- Headless unit tests: **77 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, navmesh, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone and action wheel)
|
||||||
- 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`)
|
||||||
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
|
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
|
||||||
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
|
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
|
||||||
|
|
||||||
Not yet implemented: body-camera replay timeline, venue selection startup menu, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
|
Not yet implemented: body-camera replay timeline, venue selection startup menu, pause/settings shell, eye-contact and request-reason verbs, crowd rubbernecking, emergency aftermath, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
|
||||||
|
|
||||||
### Smoke Test Checklist
|
### Smoke Test Checklist
|
||||||
|
|
||||||
1. `godot --path .` starts the game without script errors.
|
1. `godot --path .` starts the game without script errors.
|
||||||
2. The guard moves and looks around; the building, steps, and sidewalk are visible.
|
2. The guard moves and looks around; the building, steps, and sidewalk are visible.
|
||||||
3. NPCs spawn and approach the entrance during the first minutes.
|
3. NPCs spawn and approach the entrance during the first minutes.
|
||||||
4. Aiming at an NPC shows its archetype and intent in the HUD.
|
4. Looking at an NPC inside the focus cone shows a gold head ring, the person's name, and the HUD target line.
|
||||||
5. Pressing `Q` produces a reaction log entry and reputation feedback.
|
5. `Space` or left click opens the action wheel; `A`/`D` highlight a slice, `Space` confirms it, `Esc`/right click cancels without a log entry.
|
||||||
6. `E` opens access inspection showing artifacts and policy result; `A`/`D` record a decision.
|
6. `E`/`C` open the inspection/wand panels from the wheel and freeze movement until they close; walking never fires the wand.
|
||||||
7. `6` opens the assistance form; submitting shows dispatch feedback.
|
7. Pressing `Q` produces a reaction log entry and reputation feedback.
|
||||||
8. `R` opens the emergency form; the button must be held ~1.6s or the call cancels.
|
8. `E` opens access inspection showing venue-correct artifacts and policy result; `A`/`D` record a decision.
|
||||||
9. `G` toggles the flashlight (indicator appears in the HUD); `L` toggles the door lock (indicator turns red when locked).
|
9. `6` opens the assistance form; submitting shows dispatch feedback.
|
||||||
10. `F` toggles the debug overlay with NPC states and shift stats.
|
10. `R` opens the emergency form; the button must be held ~1.6s or the call cancels.
|
||||||
11. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run).
|
11. `G` toggles the flashlight (indicator appears in the HUD); `L` toggles the door lock (indicator turns red when locked).
|
||||||
12. When the shift timer reaches 0, the shift review screen shows grade, incident summary, and persisted shift history.
|
12. `F` toggles the debug overlay with NPC states and shift stats.
|
||||||
|
13. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run).
|
||||||
|
14. When the shift timer reaches 0, the shift review screen shows grade, incident summary, and persisted shift history.
|
||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
|
|
||||||
|
|||||||
@@ -1,76 +1,25 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
Checked against `PLAN.md`, ADRs 0001–0010, `README.md`, `DONE.md`, and `src/` (Godot 4.3
|
Godot 4.3 prototype. Release **v0.2.1** is live on the host. The working tree (uncommitted)
|
||||||
prototype, release **v0.2.1**, 57 headless tests). Do the control pass first; it unblocks
|
has the access-loadout fix and the ADR-0007–0010 control pass; headless suite **77/77** and a
|
||||||
everything that uses focus or verbs.
|
windowed focus/radial smoke pass.
|
||||||
|
|
||||||
Host `REQUIRE_SIGNIN_VIEW` stays deferred.
|
Host `REQUIRE_SIGNIN_VIEW` stays deferred.
|
||||||
|
|
||||||
## Player control, focus ring, and radial action menu
|
## World and presentation follow-ups (found during the control smoke)
|
||||||
|
|
||||||
Design: `PLAN.md` → "Player Control, Focus, and Action Menu" and ADRs 0007–0010.
|
- [ ] 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
|
||||||
### Locomotion
|
the backdrop only with a transparent asset or a keyed shader.
|
||||||
|
- [ ] Guard settles ~0.5 m above the ground after spawning on the landing. Check the capsule
|
||||||
- [ ] Stop stealing WASD: wand off `W` (wheel + optional `C`); allow/deny `A`/`D` only while
|
offset and the spawn `y`, and verify eye height while walking the steps.
|
||||||
inspect/wand is open; polite direction off `Q` as a required bind.
|
- [ ] Steps and the landing are visual-only (no collider). The guard walks through them; add
|
||||||
- [ ] Accel/decel on walk; gravity only when airborne; `floor_snap_length` for the steps.
|
simple step colliders or restage the landing so it does not intersect the walk path.
|
||||||
- [ ] Mouse look only while captured and no modal UI is open. Freeze walk+look while the radial
|
- [ ] Marquee text (`STEP LINE`) is tiny from the sidewalk. Scale the Label3D up.
|
||||||
(and inspect/wand opened from it) is up. Recapture on close.
|
- [ ] Focus ring and name tag are small at 2–3 m. Consider a larger torus / font after a
|
||||||
- [ ] `Esc` closes the top modal first; with nothing open it still toggles mouse capture.
|
real playtest.
|
||||||
|
- [ ] NPC crowd cap (`MAX_LIVE_NPCS`) silently drops routine traffic at 16. Add a short
|
||||||
### Focus and head ring
|
despawn/lifetime for ordinary guests so the entrance does not fill up with standers.
|
||||||
|
|
||||||
- [ ] Replace the 8 m hairline ray as the primary picker with a focus cone (range ~10 m, head
|
|
||||||
inside a ~90 px / 10° circle around the crosshair; closest to center wins; occluded heads
|
|
||||||
skipped). Keep `Tab` as the 12 m cycle.
|
|
||||||
- [ ] Small grace so walk-bob does not drop focus.
|
|
||||||
- [ ] Billboarded ring on the focused avatar `Head`; gold idle, brighter while the radial is open;
|
|
||||||
one at a time. Optional `display_name` under the ring. No internal type name in the world.
|
|
||||||
- [ ] Add a small screen-center crosshair so the focus circle has a visible origin.
|
|
||||||
|
|
||||||
### Radial HUD
|
|
||||||
|
|
||||||
- [ ] New `src/game/ui/action_radial.gd` doughnut: open on LMB or `Space` when someone is focused.
|
|
||||||
Opening click does not pick a slice. Slices (top, clockwise, de-escalation first): raise
|
|
||||||
hand, point, head shake, polite direction, inspect, wand (grey if no wand), warning, block,
|
|
||||||
radio assist. Emergency stays on `R` only.
|
|
||||||
- [ ] Highlight by mouse angle, or `A`/`D` / arrows. Click slice or `Space` confirms. `Esc` / RMB
|
|
||||||
closes with no action and no recorder event.
|
|
||||||
- [ ] Inspect / wand / radio slices close the wheel then open the existing panels. Freeze until
|
|
||||||
that panel closes.
|
|
||||||
- [ ] Number keys `1`–`6` may remain as closed-menu shortcuts; they must not fire while the wheel
|
|
||||||
is open.
|
|
||||||
|
|
||||||
### NPC display
|
|
||||||
|
|
||||||
- [ ] Procedural rig: neck, shoes, hair cap from seed, two eye spheres so the head has a front.
|
|
||||||
Keep props, height variation, walk bob, notice-head-turn. Collision capsule matches height.
|
|
||||||
Named `Head` node stays the attach point.
|
|
||||||
|
|
||||||
### Input, copy, tests
|
|
||||||
|
|
||||||
- [ ] Update `project.godot` input map (`action_menu`, `confirm`, `cancel` includes RMB, wand on
|
|
||||||
`C`). Update `hud.controls` and the README table + smoke checklist.
|
|
||||||
- [ ] Headless tests `src/test/tests/test_focus_and_radial.gd`: cone picks the centered head;
|
|
||||||
highlight wrap; cancel writes no recorder event; `W` does not open wand.
|
|
||||||
|
|
||||||
## Access artifacts actually match the venue
|
|
||||||
|
|
||||||
`GameManager._artifacts_for` maps `NpcType` → a wristband or free `RECOGNITION`. Joke Shop
|
|
||||||
events set `override_access` `{has_ticket, has_id}` and nobody reads it, so club guests inspect
|
|
||||||
as "recognized staff" instead of ID/ticket. Studio counterfeit/follow events have the same hole.
|
|
||||||
|
|
||||||
- [ ] Build artifacts from the event (`override_access`, carried items) and the venue policy,
|
|
||||||
not from `NpcType` alone.
|
|
||||||
- [ ] Joke Shop: valid ID+ticket, ID without ticket, expired/mismatched/missing ID, no ID when
|
|
||||||
required. Recognition must not auto-grant on every `AUTHORIZED_GUEST`.
|
|
||||||
- [ ] Give some guests `carried_items` (keys / belt / phone / prohibited) so wand alerts are not
|
|
||||||
always clear.
|
|
||||||
- [ ] Studio: counterfeit wristband/credential and "follow authorized guest" use the scripted
|
|
||||||
event data, not the generic type map.
|
|
||||||
- [ ] Headless tests: Joke Shop `guest_no_id` is not granted by recognition; `guest_valid`
|
|
||||||
grants only with ID+ticket; wand venues still require `controller.screened`.
|
|
||||||
|
|
||||||
## Session shell
|
## Session shell
|
||||||
|
|
||||||
@@ -85,11 +34,13 @@ The game boots into a live 900 s shift. `Save` declares `settings.json` and neve
|
|||||||
- [ ] Shift review "Run another shift" returns to the venue menu, not a silent restart of the
|
- [ ] Shift review "Run another shift" returns to the venue menu, not a silent restart of the
|
||||||
same scenario.
|
same scenario.
|
||||||
|
|
||||||
## Remaining PLAN verbs (after the radial exists)
|
## Remaining PLAN verbs (radial exists)
|
||||||
|
|
||||||
- [ ] Eye contact as a Level 1 action (PLAN / glossary). Distinct from raise-hand.
|
- [ ] Eye contact as a Level 1 action (PLAN / glossary). Distinct from raise-hand. Add a
|
||||||
|
radial slice if it ships.
|
||||||
- [ ] "I recognize this person" is a player decision recorded as evidence, not a pre-attached
|
- [ ] "I recognize this person" is a player decision recorded as evidence, not a pre-attached
|
||||||
artifact on every staff/guest type.
|
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
|
- [ ] Formal warning can activate the body-camera / security-light flag (PLAN Level 2). Overlay
|
||||||
is currently always on; toggling should write an incident event.
|
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
|
- [ ] Blocking stations the guard between the person and the door (PLAN Level 3). Today it only
|
||||||
@@ -122,10 +73,11 @@ wants evacuation, a closed street, and an investigation when the call was unjust
|
|||||||
|
|
||||||
## NPC theater
|
## NPC theater
|
||||||
|
|
||||||
Intents `sit` / `photo` / `film` / `perform` already pick waypoints. Bodies still idle-stand.
|
Intents `sit` / `photo` / `film` / `perform` already pick waypoints. ADR-0010 rig (face/hair)
|
||||||
|
is in the working tree. Bodies still idle-stand at those spots.
|
||||||
|
|
||||||
- [ ] Sit pose at `steps_end`. Photo: brief raise of the influencer/tourist prop. Performer
|
- [ ] Sit pose at `steps_end`. Photo: brief raise of the influencer/tourist prop. Performer
|
||||||
stays put and plays the hat/sign. After the ADR-0010 rig pass, not before.
|
stays put and plays the hat/sign.
|
||||||
|
|
||||||
## Audio
|
## Audio
|
||||||
|
|
||||||
@@ -151,12 +103,11 @@ Intents `sit` / `photo` / `film` / `perform` already pick waypoints. Bodies stil
|
|||||||
|
|
||||||
## Documentation drift
|
## Documentation drift
|
||||||
|
|
||||||
- [ ] README Development Status: test count is **57**, not 52. "Not yet implemented" should
|
- [x] README controls table, smoke checklist, test count, and implemented/not-yet lists
|
||||||
match this file (control pass, artifact wiring, session shell, replay, crowd presentation).
|
refreshed for the control pass (77 tests).
|
||||||
- [ ] `PLAN.md` Tech Stack / Planned Commands still say GUT and GitHub Actions; the project
|
- [ ] `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`.
|
||||||
- [ ] MEMORY: drop "door_staff_lines not surfaced" (they log through `GameManager`) and the
|
- [x] MEMORY: door_staff_lines log through `GameManager`; 300 s default removed.
|
||||||
obsolete "prototype defaults to 300 s" decision.
|
|
||||||
|
|
||||||
## Distribution follow-up (host config, deferred)
|
## Distribution follow-up (host config, deferred)
|
||||||
|
|
||||||
@@ -164,15 +115,10 @@ Verified via API+host credentials (2026-10-31):
|
|||||||
|
|
||||||
- [x] Repo is **public** (`private:false`) and account has admin rights.
|
- [x] Repo is **public** (`private:false`) and account has admin rights.
|
||||||
- [x] Release `v0.2.1` now carries all five artifacts (portable binaries + NSIS installer +
|
- [x] Release `v0.2.1` now carries all five artifacts (portable binaries + NSIS installer +
|
||||||
AppImage) — the two installers were missing after CI publish (script uploaded only the
|
AppImage).
|
||||||
portables); `tools/publish_release.sh` fixed to include them and re-uploaded via API.
|
|
||||||
- [ ] **Remaining host setting**: instance-wide `[service] REQUIRE_SIGNIN_VIEW=true` makes
|
- [ ] **Remaining host setting**: instance-wide `[service] REQUIRE_SIGNIN_VIEW=true` makes
|
||||||
anonymous browsing/downloads redirect to login (API-confirmed repo is public; this is a
|
anonymous browsing/downloads redirect to login. Host admin: `REQUIRE_SIGNIN_VIEW = false`
|
||||||
server `app.ini` setting, not an API or repo option).
|
under `[service]` in `app.ini`, restart Gitea. Alternative: mirror downloads.
|
||||||
Advised path for the host admin: set `REQUIRE_SIGNIN_VIEW = false` in `app.ini` under
|
|
||||||
`[service]`, restart Gitea, then verify `curl -sI https://git.allucanget.biz/zwitschi/security-game/releases/tag/v0.2.1`
|
|
||||||
returns 200 (not a login redirect) and an anonymous asset download succeeds. Alternative:
|
|
||||||
mirror downloads (Itch.io / GitHub Releases) for anonymous users.
|
|
||||||
|
|
||||||
## Open Polish
|
## Open Polish
|
||||||
|
|
||||||
@@ -182,4 +128,3 @@ Verified via API+host credentials (2026-10-31):
|
|||||||
|
|
||||||
- [ ] IDE/editor-based GUT integration. Until then the custom headless runner stays.
|
- [ ] IDE/editor-based GUT integration. Until then the custom headless runner stays.
|
||||||
- [ ] Optional later: author venues as `.tres` resources instead of `ScenarioFactory` functions.
|
- [ ] Optional later: author venues as `.tres` resources instead of `ScenarioFactory` functions.
|
||||||
Not required for the control pass.
|
|
||||||
@@ -1,12 +1,21 @@
|
|||||||
{
|
{
|
||||||
"hud.controls": "Q polite direction · 1-3 simple gestures · 4 warning · 5 block · 6 assist · R emergency · E inspect / close · W wand · A allow · D deny · V venue · G flashlight · L lock door · F debug",
|
"hud.controls": "LMB/Space 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",
|
||||||
|
"radial.raise_hand": "Stop",
|
||||||
|
"radial.point": "Point",
|
||||||
|
"radial.head_shake": "No",
|
||||||
|
"radial.polite": "Ask",
|
||||||
|
"radial.inspect": "Inspect",
|
||||||
|
"radial.wand": "Wand",
|
||||||
|
"radial.warning": "Warn",
|
||||||
|
"radial.block": "Block",
|
||||||
|
"radial.radio": "Radio",
|
||||||
"hud.escalation_prefix": "Escalation: %s",
|
"hud.escalation_prefix": "Escalation: %s",
|
||||||
"hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs",
|
"hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs",
|
||||||
"hud.aim_first": "Aim at someone first.",
|
"hud.aim_first": "Aim at someone first.",
|
||||||
"hud.no_one_nearby": "No one nearby.",
|
"hud.no_one_nearby": "No one nearby.",
|
||||||
"hud.targeting": "Targeting %s (Tab to cycle).",
|
"hud.targeting": "Targeting %s (Tab to cycle).",
|
||||||
"hud.no_wand": "No wand at this venue.",
|
"hud.no_wand": "No wand at this venue.",
|
||||||
"hud.screen_first": "Screen this guest with the wand (W) before admitting them.",
|
"hud.screen_first": "Screen this guest with the wand (C) before admitting them.",
|
||||||
"access.granted": "Entry granted: %s",
|
"access.granted": "Entry granted: %s",
|
||||||
"access.denied": "Entry denied: %s",
|
"access.denied": "Entry denied: %s",
|
||||||
"access.policy_satisfied": "policy satisfied",
|
"access.policy_satisfied": "policy satisfied",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 744 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 749 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 771 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 594 KiB After Width: | Height: | Size: 953 KiB |
|
Before Width: | Height: | Size: 691 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 722 KiB After Width: | Height: | Size: 1.2 MiB |
@@ -47,18 +47,6 @@ move_right={
|
|||||||
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
interact={
|
|
||||||
"deadzone": 0.5,
|
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
|
|
||||||
, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
cancel={
|
|
||||||
"deadzone": 0.5,
|
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":81,"key_label":0,"unicode":113,"location":0,"echo":false,"script":null)
|
|
||||||
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
gesture_raise_hand={
|
gesture_raise_hand={
|
||||||
"deadzone": 0.5,
|
"deadzone": 0.5,
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":49,"key_label":0,"unicode":49,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":49,"key_label":0,"unicode":49,"location":0,"echo":false,"script":null)
|
||||||
@@ -91,8 +79,35 @@ radio_assist={
|
|||||||
}
|
}
|
||||||
radio_emergency={
|
radio_emergency={
|
||||||
"deadzone": 0.5,
|
"deadzone": 0.5,
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":87,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":114,"location":0,"echo":false,"script":null)
|
||||||
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":114,"location":0,"echo":false,"script":null)
|
]
|
||||||
|
}
|
||||||
|
action_menu={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
|
||||||
|
, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
confirm={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
|
||||||
|
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194309,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
cancel={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":2,"canceled":false,"pressed":false,"double_click":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
inspect={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
wand={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":67,"key_label":0,"unicode":99,"location":0,"echo":false,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
print_debug={
|
print_debug={
|
||||||
|
|||||||
@@ -393,6 +393,8 @@ static func joke_shop() -> VenueScenario:
|
|||||||
"guard_post": Vector3(0, 0, -3),
|
"guard_post": Vector3(0, 0, -3),
|
||||||
"entrance": Vector3(0, 0, 1.0),
|
"entrance": Vector3(0, 0, 1.0),
|
||||||
"door_area": Vector3(0, 0, 3),
|
"door_area": Vector3(0, 0, 3),
|
||||||
|
"steps_end": Vector3(0, 0, 3),
|
||||||
|
"sidewalk": Vector3(0, 0, 9),
|
||||||
"queue_area": Vector3(0, 0, 9),
|
"queue_area": Vector3(0, 0, 9),
|
||||||
}
|
}
|
||||||
scenario.door_staff_lines = {
|
scenario.door_staff_lines = {
|
||||||
@@ -433,7 +435,7 @@ static func joke_shop() -> VenueScenario:
|
|||||||
scenario.event_pool = [
|
scenario.event_pool = [
|
||||||
_joke_guest("guest_valid", true, true, Vector2(0, 900), 0.30),
|
_joke_guest("guest_valid", true, true, Vector2(0, 900), 0.30),
|
||||||
_joke_guest("guest_no_ticket", false, true, Vector2(0, 900), 0.18),
|
_joke_guest("guest_no_ticket", false, true, Vector2(0, 900), 0.18),
|
||||||
_joke_guest("guest_expired_id", true, true, Vector2(0, 900), 0.12),
|
_joke_guest("guest_expired_id", true, true, Vector2(0, 900), 0.12, {"id_status": Rules.ArtifactStatus.EXPIRED}),
|
||||||
_joke_guest("guest_no_id", true, false, Vector2(0, 900), 0.12),
|
_joke_guest("guest_no_id", true, false, Vector2(0, 900), 0.12),
|
||||||
_joke_staff_entry(Vector2(0, 900), 0.25),
|
_joke_staff_entry(Vector2(0, 900), 0.25),
|
||||||
_joke_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.15),
|
_joke_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.15),
|
||||||
@@ -475,6 +477,8 @@ static func tv_studio() -> VenueScenario:
|
|||||||
"guard_post": Vector3(0, 0, -3),
|
"guard_post": Vector3(0, 0, -3),
|
||||||
"entrance": Vector3(0, 0, 1.0),
|
"entrance": Vector3(0, 0, 1.0),
|
||||||
"stage_door": Vector3(6, 0, -2),
|
"stage_door": Vector3(6, 0, -2),
|
||||||
|
"steps_end": Vector3(0, 0, 3),
|
||||||
|
"sidewalk": Vector3(0, 0, 9),
|
||||||
"queue_area": Vector3(0, 0, 9),
|
"queue_area": Vector3(0, 0, 9),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,9 +496,9 @@ static func tv_studio() -> VenueScenario:
|
|||||||
])
|
])
|
||||||
|
|
||||||
scenario.event_pool = [
|
scenario.event_pool = [
|
||||||
_studio_routine("audience_ticket", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.35),
|
_studio_guest("audience_ticket", {"has_ticket": true}, Vector2(0, 900), 0.35),
|
||||||
_studio_routine("protest_outside", Rules.NpcType.PROTESTER, "protest", Vector2(300, 900), 0.18),
|
_studio_routine("protest_outside", Rules.NpcType.PROTESTER, "protest", Vector2(300, 900), 0.18),
|
||||||
_studio_routine("celebrity_arrival", Rules.NpcType.AUTHORIZED_GUEST, "arrive", Vector2(300, 700), 0.20),
|
_studio_guest("celebrity_arrival", {"has_guest_list": true}, Vector2(300, 700), 0.20, "arrive"),
|
||||||
_studio_routine("crew_shift", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 700), 0.30),
|
_studio_routine("crew_shift", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 700), 0.30),
|
||||||
_studio_routine("tourist_photo", Rules.NpcType.TOURIST, "photo", Vector2(0, 900), 0.35),
|
_studio_routine("tourist_photo", Rules.NpcType.TOURIST, "photo", Vector2(0, 900), 0.35),
|
||||||
_studio_routine("confused_enter", Rules.NpcType.CONFUSED_VISITOR, "enter", Vector2(0, 900), 0.25),
|
_studio_routine("confused_enter", Rules.NpcType.CONFUSED_VISITOR, "enter", Vector2(0, 900), 0.25),
|
||||||
@@ -509,6 +513,7 @@ static func tv_studio() -> VenueScenario:
|
|||||||
"time_range_s": Vector2(620, 780),
|
"time_range_s": Vector2(620, 780),
|
||||||
"probability": 0.55,
|
"probability": 0.55,
|
||||||
"base_weight": 1.0,
|
"base_weight": 1.0,
|
||||||
|
"override_access": {"wristband_status": Rules.ArtifactStatus.COUNTERFEIT, "credential_status": Rules.ArtifactStatus.COUNTERFEIT},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "studio_follow_through",
|
"id": "studio_follow_through",
|
||||||
@@ -516,6 +521,7 @@ static func tv_studio() -> VenueScenario:
|
|||||||
"kind": "scripted",
|
"kind": "scripted",
|
||||||
"npc_type": Rules.NpcType.TRESPASSER,
|
"npc_type": Rules.NpcType.TRESPASSER,
|
||||||
"intent": "follow_authorized",
|
"intent": "follow_authorized",
|
||||||
|
"override_access": {"has_ticket": false, "has_wristband": false, "has_credential": false, "has_guest_list": false},
|
||||||
"time_range_s": Vector2(700, 860),
|
"time_range_s": Vector2(700, 860),
|
||||||
"probability": 0.45,
|
"probability": 0.45,
|
||||||
"base_weight": 1.0,
|
"base_weight": 1.0,
|
||||||
@@ -549,12 +555,23 @@ static func _building_routine(id: String, npc_type: int, intent: String, time_ra
|
|||||||
"time_range_s": time_range, "probability": prob, "base_weight": 1.0,
|
"time_range_s": time_range, "probability": prob, "base_weight": 1.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
static func _joke_guest(id: String, has_ticket: bool, has_id: bool, time_range: Vector2, prob: float) -> Dictionary:
|
static func _joke_guest(id: String, has_ticket: bool, has_id: bool, time_range: Vector2, prob: float, extra: Dictionary = {}) -> Dictionary:
|
||||||
|
var override := {"has_ticket": has_ticket, "has_id": has_id}
|
||||||
|
for key in extra.keys():
|
||||||
|
override[key] = extra[key]
|
||||||
return {
|
return {
|
||||||
"id": id, "phase": _phase_for_range(time_range, 900.0),
|
"id": id, "phase": _phase_for_range(time_range, 900.0),
|
||||||
"kind": "routine", "npc_type": Rules.NpcType.AUTHORIZED_GUEST, "intent": "enter",
|
"kind": "routine", "npc_type": Rules.NpcType.AUTHORIZED_GUEST, "intent": "enter",
|
||||||
"time_range_s": time_range, "probability": prob, "base_weight": 1.0,
|
"time_range_s": time_range, "probability": prob, "base_weight": 1.0,
|
||||||
"override_access": {"has_ticket": has_ticket, "has_id": has_id},
|
"override_access": override,
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _studio_guest(id: String, override_access: Dictionary, time_range: Vector2, prob: float, intent: String = "enter") -> Dictionary:
|
||||||
|
return {
|
||||||
|
"id": id, "phase": _phase_for_range(time_range, 900.0),
|
||||||
|
"kind": "routine", "npc_type": Rules.NpcType.AUTHORIZED_GUEST, "intent": intent,
|
||||||
|
"time_range_s": time_range, "probability": prob, "base_weight": 1.0,
|
||||||
|
"override_access": override_access,
|
||||||
}
|
}
|
||||||
|
|
||||||
static func _joke_staff_entry(time_range: Vector2, prob: float) -> Dictionary:
|
static func _joke_staff_entry(time_range: Vector2, prob: float) -> Dictionary:
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Builds the access artifacts a person actually presents.
|
||||||
|
## Event `override_access` wins; otherwise a venue-appropriate default from
|
||||||
|
## type + policy. Recognition is never attached to every guest.
|
||||||
|
|
||||||
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
|
const AccessArtifact := preload("res://src/core/access_artifact.gd")
|
||||||
|
const AccessPolicy := preload("res://src/core/access_policy.gd")
|
||||||
|
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||||
|
|
||||||
|
static func build(profile: NpcProfile, policy: AccessPolicy, override: Dictionary = {}) -> Array:
|
||||||
|
var npc_type: int = profile.npc_type if profile else Rules.NpcType.TOURIST
|
||||||
|
var out: Array = []
|
||||||
|
if override.get("recognized", false) or (override.is_empty() and npc_type == Rules.NpcType.AUTHORIZED_STAFF):
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.VALID, {"known": true}))
|
||||||
|
if not override.is_empty():
|
||||||
|
_append_override(out, profile, override)
|
||||||
|
return out
|
||||||
|
if policy != null and (policy.require_id or policy.requires(Rules.AccessKind.ID)):
|
||||||
|
_append_id_ticket_defaults(out, profile, npc_type, policy)
|
||||||
|
return out
|
||||||
|
if policy != null and policy.grant_if_any_required:
|
||||||
|
_append_or_defaults(out, npc_type)
|
||||||
|
return out
|
||||||
|
_append_wristband_defaults(out, npc_type)
|
||||||
|
return out
|
||||||
|
|
||||||
|
static func roll_carried_items(seed_value: int, override: Dictionary = {}) -> Array:
|
||||||
|
if override.has("carried_items"):
|
||||||
|
return override["carried_items"]
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = seed_value if seed_value != 0 else 1
|
||||||
|
var roll := rng.randf()
|
||||||
|
if roll < 0.08:
|
||||||
|
return ["keys", "knife"]
|
||||||
|
if roll < 0.40:
|
||||||
|
return ["keys"]
|
||||||
|
if roll < 0.62:
|
||||||
|
return ["phone"]
|
||||||
|
if roll < 0.80:
|
||||||
|
return ["belt"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
static func _append_override(out: Array, profile: NpcProfile, override: Dictionary) -> void:
|
||||||
|
if override.has("has_id") or override.has("id_status"):
|
||||||
|
var has_id := bool(override.get("has_id", true))
|
||||||
|
var status: int = int(override.get("id_status", Rules.ArtifactStatus.VALID if has_id else Rules.ArtifactStatus.MISSING))
|
||||||
|
if not has_id:
|
||||||
|
status = Rules.ArtifactStatus.MISSING
|
||||||
|
var attrs := {}
|
||||||
|
if status != Rules.ArtifactStatus.MISSING:
|
||||||
|
attrs["age"] = int(override.get("age", 24))
|
||||||
|
attrs["name"] = profile.display_name if profile else "Guest"
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.ID, status, attrs))
|
||||||
|
if override.has("has_ticket") or override.has("ticket_status"):
|
||||||
|
var has_ticket := bool(override.get("has_ticket", true))
|
||||||
|
var status: int = int(override.get("ticket_status", Rules.ArtifactStatus.VALID if has_ticket else Rules.ArtifactStatus.MISSING))
|
||||||
|
if not has_ticket:
|
||||||
|
status = Rules.ArtifactStatus.MISSING
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.TICKET, status, {}))
|
||||||
|
if override.has("has_wristband") or override.has("wristband_status"):
|
||||||
|
var status := _present_status(override, "has_wristband", "wristband_status")
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, status, _band_attrs(status)))
|
||||||
|
if override.has("has_credential") or override.has("credential_status"):
|
||||||
|
var status := _present_status(override, "has_credential", "credential_status")
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, status, {"role": "staff"}))
|
||||||
|
if override.has("has_guest_list") or override.has("guest_list_status"):
|
||||||
|
var status := _present_status(override, "has_guest_list", "guest_list_status")
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.GUEST_LIST, status, {}))
|
||||||
|
|
||||||
|
static func _present_status(override: Dictionary, has_key: String, status_key: String) -> int:
|
||||||
|
if override.has(status_key):
|
||||||
|
return int(override[status_key])
|
||||||
|
return Rules.ArtifactStatus.VALID if bool(override.get(has_key, false)) else Rules.ArtifactStatus.MISSING
|
||||||
|
|
||||||
|
static func _append_id_ticket_defaults(out: Array, profile: NpcProfile, npc_type: int, policy: AccessPolicy) -> void:
|
||||||
|
if npc_type == Rules.NpcType.AUTHORIZED_STAFF:
|
||||||
|
return
|
||||||
|
if npc_type == Rules.NpcType.AUTHORIZED_GUEST:
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.VALID, {
|
||||||
|
"age": 24, "name": profile.display_name if profile else "Guest",
|
||||||
|
}))
|
||||||
|
if policy.requires(Rules.AccessKind.TICKET):
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.VALID, {}))
|
||||||
|
return
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.MISSING, {}))
|
||||||
|
if policy.requires(Rules.AccessKind.TICKET):
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.MISSING, {}))
|
||||||
|
|
||||||
|
static func _append_or_defaults(out: Array, npc_type: int) -> void:
|
||||||
|
match npc_type:
|
||||||
|
Rules.NpcType.AUTHORIZED_STAFF, Rules.NpcType.DELIVERY:
|
||||||
|
if not _has_kind(out, Rules.AccessKind.RECOGNITION):
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, Rules.ArtifactStatus.VALID, {"role": "staff"}))
|
||||||
|
Rules.NpcType.AUTHORIZED_GUEST:
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.VALID, {}))
|
||||||
|
Rules.NpcType.TRESPASSER:
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, _band_attrs(Rules.ArtifactStatus.COUNTERFEIT)))
|
||||||
|
Rules.NpcType.CONFUSED_VISITOR:
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.AMBIGUOUS, _band_attrs(Rules.ArtifactStatus.AMBIGUOUS)))
|
||||||
|
_:
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {}))
|
||||||
|
|
||||||
|
static func _append_wristband_defaults(out: Array, npc_type: int) -> void:
|
||||||
|
match npc_type:
|
||||||
|
Rules.NpcType.AUTHORIZED_STAFF:
|
||||||
|
pass
|
||||||
|
Rules.NpcType.AUTHORIZED_GUEST, Rules.NpcType.DELIVERY:
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.VALID, _band_attrs(Rules.ArtifactStatus.VALID)))
|
||||||
|
if npc_type == Rules.NpcType.DELIVERY:
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, Rules.ArtifactStatus.VALID, {"role": "delivery"}))
|
||||||
|
Rules.NpcType.TRESPASSER:
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, _band_attrs(Rules.ArtifactStatus.COUNTERFEIT)))
|
||||||
|
Rules.NpcType.CONFUSED_VISITOR:
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.AMBIGUOUS, _band_attrs(Rules.ArtifactStatus.AMBIGUOUS)))
|
||||||
|
_:
|
||||||
|
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {}))
|
||||||
|
|
||||||
|
static func _band_attrs(status: int) -> Dictionary:
|
||||||
|
match status:
|
||||||
|
Rules.ArtifactStatus.VALID:
|
||||||
|
return {"color": "red", "date": "2026-02-13", "event": "gala"}
|
||||||
|
Rules.ArtifactStatus.COUNTERFEIT:
|
||||||
|
return {"color": "red", "date": "2024-01-01"}
|
||||||
|
Rules.ArtifactStatus.AMBIGUOUS:
|
||||||
|
return {"color": "gold"}
|
||||||
|
_:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
static func _has_kind(artifacts: Array, kind: int) -> bool:
|
||||||
|
for artifact in artifacts:
|
||||||
|
if artifact is AccessArtifact and artifact.kind == kind:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
@@ -15,7 +15,7 @@ func _init() -> void:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
## Verify a single person's set of artifacts (person_artifacts: Array[AccessArtifact]).
|
## Verify a single person's set of artifacts (person_artifacts: Array[AccessArtifact]).
|
||||||
func verify(person_artifacts: Array, policy: AccessPolicy) -> Dictionary:
|
func verify(person_artifacts: Array, policy: AccessPolicy, already_screened: bool = false) -> Dictionary:
|
||||||
var reasons: Array[String] = []
|
var reasons: Array[String] = []
|
||||||
var issues: Array[String] = []
|
var issues: Array[String] = []
|
||||||
var checked: Array[String] = []
|
var checked: Array[String] = []
|
||||||
@@ -48,7 +48,7 @@ func verify(person_artifacts: Array, policy: AccessPolicy) -> Dictionary:
|
|||||||
|
|
||||||
# Wand screening is processed by the interaction layer (false alarms), but a
|
# Wand screening is processed by the interaction layer (false alarms), but a
|
||||||
# policy can hard-require it; a refused screen blocks entry.
|
# policy can hard-require it; a refused screen blocks entry.
|
||||||
if policy.wand_screening_required and not person_screened(person_artifacts):
|
if policy.wand_screening_required and not already_screened and not person_screened(person_artifacts):
|
||||||
issues.append("Screening required")
|
issues.append("Screening required")
|
||||||
return _result(false, reasons, issues, checked)
|
return _result(false, reasons, issues, checked)
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ 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
|
||||||
|
## Event-level access payload (`has_id`, `has_ticket`, statuses). Empty means
|
||||||
|
## venue/type defaults from AccessLoadout.
|
||||||
|
var access_override: Dictionary = {}
|
||||||
|
|
||||||
func _init() -> void:
|
func _init() -> void:
|
||||||
fsm = FSM.new(self)
|
fsm = FSM.new(self)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const EventScheduler := preload("res://src/core/event_scheduler.gd")
|
|||||||
## Drives one shift: advances time, schedules events, owns the shared systems
|
## Drives one shift: advances time, schedules events, owns the shared systems
|
||||||
## (recorder, reputation, radio, crowd), and produces the shift review.
|
## (recorder, reputation, radio, crowd), and produces the shift review.
|
||||||
|
|
||||||
signal npc_spawn_requested(profile: NpcProfile, intent: String, npc_id: String)
|
signal npc_spawn_requested(profile: NpcProfile, intent: String, npc_id: String, event: Dictionary)
|
||||||
signal shift_started(payload: Dictionary)
|
signal shift_started(payload: Dictionary)
|
||||||
signal shift_ended(review: Dictionary)
|
signal shift_ended(review: Dictionary)
|
||||||
signal phase_changed(phase: int)
|
signal phase_changed(phase: int)
|
||||||
@@ -34,6 +34,10 @@ var scheduler := EventScheduler.new()
|
|||||||
var spawned_count := 0
|
var spawned_count := 0
|
||||||
var spawn_seed := 0
|
var spawn_seed := 0
|
||||||
var _shift_finished := false
|
var _shift_finished := false
|
||||||
|
## The scheduler rolls once per simulated second. `tick()` runs at physics rate,
|
||||||
|
## so accumulate time and only pull due events on whole-second boundaries.
|
||||||
|
const SPAWN_INTERVAL_S := 1.0
|
||||||
|
var _spawn_accum_s := 0.0
|
||||||
|
|
||||||
func setup(p_scenario: VenueScenario, p_duration_s: float = 900.0, p_seed: int = 0) -> void:
|
func setup(p_scenario: VenueScenario, p_duration_s: float = 900.0, p_seed: int = 0) -> void:
|
||||||
scenario = p_scenario
|
scenario = p_scenario
|
||||||
@@ -56,6 +60,7 @@ func start() -> void:
|
|||||||
running = true
|
running = true
|
||||||
_shift_finished = false
|
_shift_finished = false
|
||||||
elapsed_s = 0.0
|
elapsed_s = 0.0
|
||||||
|
_spawn_accum_s = 0.0
|
||||||
current_phase = _phase_for_time(0.0)
|
current_phase = _phase_for_time(0.0)
|
||||||
event_bus.shift_state_changed.emit({"running": true, "elapsed_s": elapsed_s, "phase": current_phase})
|
event_bus.shift_state_changed.emit({"running": true, "elapsed_s": elapsed_s, "phase": current_phase})
|
||||||
shift_started.emit({"scenario_id": scenario.id, "duration_s": duration_s})
|
shift_started.emit({"scenario_id": scenario.id, "duration_s": duration_s})
|
||||||
@@ -77,7 +82,12 @@ func tick(delta: float) -> void:
|
|||||||
if elapsed_s >= duration_s:
|
if elapsed_s >= duration_s:
|
||||||
_finish_shift()
|
_finish_shift()
|
||||||
return
|
return
|
||||||
_spawn_due(delta)
|
_spawn_accum_s += delta
|
||||||
|
if _spawn_accum_s >= SPAWN_INTERVAL_S:
|
||||||
|
_spawn_accum_s -= SPAWN_INTERVAL_S
|
||||||
|
if _spawn_accum_s > SPAWN_INTERVAL_S:
|
||||||
|
_spawn_accum_s = 0.0
|
||||||
|
_spawn_due(SPAWN_INTERVAL_S)
|
||||||
|
|
||||||
func _phase_for_time(t: float) -> int:
|
func _phase_for_time(t: float) -> int:
|
||||||
if scenario == null or scenario.shift_phases.is_empty():
|
if scenario == null or scenario.shift_phases.is_empty():
|
||||||
@@ -102,7 +112,7 @@ func _spawn_due(delta: float) -> void:
|
|||||||
var profile := scenario.profile_for(event.get("npc_type", Rules.NpcType.TOURIST))
|
var profile := scenario.profile_for(event.get("npc_type", Rules.NpcType.TOURIST))
|
||||||
if profile == null:
|
if profile == null:
|
||||||
continue
|
continue
|
||||||
npc_spawn_requested.emit(profile, event.get("intent", "roam"), npc_id)
|
npc_spawn_requested.emit(profile, event.get("intent", "roam"), npc_id, event)
|
||||||
|
|
||||||
func _finish_shift() -> void:
|
func _finish_shift() -> void:
|
||||||
if _shift_finished:
|
if _shift_finished:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ extends Node
|
|||||||
const Rules := preload("res://src/core/rules.gd")
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
const Strings := preload("res://src/core/strings.gd")
|
const Strings := preload("res://src/core/strings.gd")
|
||||||
const AccessArtifact := preload("res://src/core/access_artifact.gd")
|
const AccessArtifact := preload("res://src/core/access_artifact.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 WandScreener := preload("res://src/core/wand_screener.gd")
|
const WandScreener := preload("res://src/core/wand_screener.gd")
|
||||||
@@ -19,6 +20,7 @@ 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")
|
||||||
const AudioFeedback := preload("res://src/game/audio_feedback.gd")
|
const AudioFeedback := preload("res://src/game/audio_feedback.gd")
|
||||||
const Hud := preload("res://src/game/ui/hud.gd")
|
const Hud := preload("res://src/game/ui/hud.gd")
|
||||||
|
const ActionRadial := preload("res://src/game/ui/action_radial.gd")
|
||||||
const RadioPanel := preload("res://src/game/ui/radio_panel.gd")
|
const RadioPanel := preload("res://src/game/ui/radio_panel.gd")
|
||||||
const ShiftReview := preload("res://src/game/ui/shift_review.gd")
|
const ShiftReview := preload("res://src/game/ui/shift_review.gd")
|
||||||
|
|
||||||
@@ -57,8 +59,10 @@ const PHASE_LIGHTING := {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
var hud: Hud = null
|
var hud: Hud = null
|
||||||
|
var radial: ActionRadial = null
|
||||||
var radio_panel: RadioPanel = null
|
var radio_panel: RadioPanel = null
|
||||||
var shift_review_panel: ShiftReview = null
|
var shift_review_panel: ShiftReview = null
|
||||||
|
var _focused_avatar: Node3D = null
|
||||||
var audio: AudioFeedback = null
|
var audio: AudioFeedback = null
|
||||||
var verifier := AccessVerifier.new()
|
var verifier := AccessVerifier.new()
|
||||||
var wand_screener := WandScreener.new()
|
var wand_screener := WandScreener.new()
|
||||||
@@ -88,6 +92,9 @@ var _door_rng := RandomNumberGenerator.new()
|
|||||||
|
|
||||||
## Aim-free targeting: cycle through nearby NPCs with Tab (accessibility).
|
## Aim-free targeting: cycle through nearby NPCs with Tab (accessibility).
|
||||||
const NEARBY_RADIUS := 12.0
|
const NEARBY_RADIUS := 12.0
|
||||||
|
## Upper bound on live avatars. Routine traffic is dropped at the cap; scripted
|
||||||
|
## events evict the oldest unfocused NPC so authored moments still spawn.
|
||||||
|
const MAX_LIVE_NPCS := 16
|
||||||
var _nearby_index := -1
|
var _nearby_index := -1
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -102,7 +109,10 @@ func _ready() -> void:
|
|||||||
|
|
||||||
guard = _build_guard()
|
guard = _build_guard()
|
||||||
scene_root.add_child(guard)
|
scene_root.add_child(guard)
|
||||||
guard.global_position = Vector3(0, 1.6, -2.5)
|
# Stand on the landing in front of the door, facing the street where guests
|
||||||
|
# arrive. The building volume now starts behind the door (world_builder).
|
||||||
|
guard.global_position = Vector3(0, 0.1, -2.0)
|
||||||
|
guard.rotation.y = PI
|
||||||
guard.picked_target.connect(_on_guard_target)
|
guard.picked_target.connect(_on_guard_target)
|
||||||
guard.cleared_target.connect(_on_guard_clear)
|
guard.cleared_target.connect(_on_guard_clear)
|
||||||
|
|
||||||
@@ -126,6 +136,9 @@ func _build_guard() -> GuardController:
|
|||||||
g.name = "Guard"
|
g.name = "Guard"
|
||||||
var head := Node3D.new()
|
var head := Node3D.new()
|
||||||
head.name = "Head"
|
head.name = "Head"
|
||||||
|
# Eye height. The guard root sits on the floor, so this keeps the camera at
|
||||||
|
# ~1.7 m while gravity/floor snapping handles the steps (ADR-0007).
|
||||||
|
head.position = Vector3(0, 1.6, 0)
|
||||||
g.add_child(head)
|
g.add_child(head)
|
||||||
var camera := Camera3D.new()
|
var camera := Camera3D.new()
|
||||||
camera.name = "Camera3D"
|
camera.name = "Camera3D"
|
||||||
@@ -185,20 +198,52 @@ var inspector_current_events: Array = []
|
|||||||
func _build_ui() -> void:
|
func _build_ui() -> void:
|
||||||
hud = Hud.new()
|
hud = Hud.new()
|
||||||
add_child(hud)
|
add_child(hud)
|
||||||
|
_add_crosshair()
|
||||||
audio = AudioFeedback.new()
|
audio = AudioFeedback.new()
|
||||||
add_child(audio)
|
add_child(audio)
|
||||||
|
radial = ActionRadial.new()
|
||||||
|
add_child(radial)
|
||||||
|
radial.confirmed.connect(_on_radial_confirmed)
|
||||||
|
radial.cancelled.connect(_cancel_radial)
|
||||||
radio_panel = RadioPanel.new()
|
radio_panel = RadioPanel.new()
|
||||||
add_child(radio_panel)
|
add_child(radio_panel)
|
||||||
radio_panel.hide()
|
radio_panel.hide()
|
||||||
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()
|
||||||
hud.set_action_hint(""))
|
hud.set_action_hint("")
|
||||||
|
_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(restart_shift)
|
shift_review_panel.restart_requested.connect(restart_shift)
|
||||||
|
|
||||||
|
func _add_crosshair() -> void:
|
||||||
|
var layer := CanvasLayer.new()
|
||||||
|
layer.layer = 9
|
||||||
|
layer.name = "Crosshair"
|
||||||
|
var center := CenterContainer.new()
|
||||||
|
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
layer.add_child(center)
|
||||||
|
var box := Control.new()
|
||||||
|
box.custom_minimum_size = Vector2(16, 16)
|
||||||
|
box.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
center.add_child(box)
|
||||||
|
var h := ColorRect.new()
|
||||||
|
h.color = Color(1, 1, 1, 0.75)
|
||||||
|
h.size = Vector2(12, 2)
|
||||||
|
h.position = Vector2(2, 7)
|
||||||
|
h.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
box.add_child(h)
|
||||||
|
var v := ColorRect.new()
|
||||||
|
v.color = Color(1, 1, 1, 0.75)
|
||||||
|
v.size = Vector2(2, 12)
|
||||||
|
v.position = Vector2(7, 2)
|
||||||
|
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
box.add_child(v)
|
||||||
|
add_child(layer)
|
||||||
|
|
||||||
func _ensure_lighting() -> void:
|
func _ensure_lighting() -> void:
|
||||||
_sun = DirectionalLight3D.new()
|
_sun = DirectionalLight3D.new()
|
||||||
_sun.name = "Sun"
|
_sun.name = "Sun"
|
||||||
@@ -239,7 +284,12 @@ func _apply_phase_lighting() -> void:
|
|||||||
if world_builder:
|
if world_builder:
|
||||||
world_builder.set_night_mode(cfg.get("night", false))
|
world_builder.set_night_mode(cfg.get("night", false))
|
||||||
|
|
||||||
func _spawn_npc(profile: NpcProfile, intent: String, _npc_id: String) -> void:
|
func _spawn_npc(profile: NpcProfile, intent: String, npc_id: String, event: Dictionary = {}) -> void:
|
||||||
|
_purge_npcs()
|
||||||
|
if npcs.size() >= MAX_LIVE_NPCS and event.get("kind", "routine") == "routine":
|
||||||
|
return
|
||||||
|
if npcs.size() >= MAX_LIVE_NPCS:
|
||||||
|
_evict_oldest_npc()
|
||||||
npc_counter += 1
|
npc_counter += 1
|
||||||
var avatar := NpcAvatar.new()
|
var avatar := NpcAvatar.new()
|
||||||
avatar.name = "Npc_%d" % npc_counter
|
avatar.name = "Npc_%d" % npc_counter
|
||||||
@@ -250,10 +300,35 @@ func _spawn_npc(profile: NpcProfile, intent: String, _npc_id: String) -> void:
|
|||||||
# of every NPC walking to the entrance (TODO: Core Simulation — NPC Perception).
|
# of every NPC walking to the entrance (TODO: Core Simulation — NPC Perception).
|
||||||
var target_pos: Vector3 = selected_scenario.waypoint_for_intent(intent)
|
var target_pos: Vector3 = selected_scenario.waypoint_for_intent(intent)
|
||||||
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
|
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
|
||||||
avatar.setup(profile, intent, npc_counter * 991 + 17, spawn_pos, target_pos, door_pos)
|
var spawn_profile: NpcProfile = profile.duplicate(true) if profile else profile
|
||||||
|
var override: Dictionary = event.get("override_access", {})
|
||||||
|
var seed_value := npc_counter * 991 + 17
|
||||||
|
if spawn_profile and selected_scenario.access_policy.wand_screening_required:
|
||||||
|
var items: Array[String] = []
|
||||||
|
for item in AccessLoadout.roll_carried_items(seed_value, override):
|
||||||
|
items.append(str(item))
|
||||||
|
spawn_profile.carried_items = items
|
||||||
|
avatar.setup(spawn_profile, intent, seed_value, spawn_pos, target_pos, door_pos, npc_id, override)
|
||||||
scene_root.add_child(avatar)
|
scene_root.add_child(avatar)
|
||||||
npcs.append(avatar)
|
npcs.append(avatar)
|
||||||
|
|
||||||
|
## Drop references to freed avatars so the cap and Tab cycling stay accurate.
|
||||||
|
func _purge_npcs() -> void:
|
||||||
|
for i in range(npcs.size() - 1, -1, -1):
|
||||||
|
if not is_instance_valid(npcs[i]):
|
||||||
|
npcs.remove_at(i)
|
||||||
|
|
||||||
|
## Make room for a scripted event by removing the oldest NPC the player is not
|
||||||
|
## currently focused on.
|
||||||
|
func _evict_oldest_npc() -> void:
|
||||||
|
for i in npcs.size():
|
||||||
|
var npc: Node = npcs[i]
|
||||||
|
if npc == targeted_npc or npc == _focused_avatar:
|
||||||
|
continue
|
||||||
|
npcs.remove_at(i)
|
||||||
|
npc.queue_free()
|
||||||
|
return
|
||||||
|
|
||||||
## Spawn a dispatched responder avatar when a radio call's arrival delay elapses
|
## Spawn a dispatched responder avatar when a radio call's arrival delay elapses
|
||||||
## (TODO: Escalation and Radio — Responder Dispatch). The responder walks to the
|
## (TODO: Escalation and Radio — Responder Dispatch). The responder walks to the
|
||||||
## entrance and resolves the incident with command/restraint/evacuation/arrest.
|
## entrance and resolves the incident with command/restraint/evacuation/arrest.
|
||||||
@@ -291,16 +366,27 @@ func _on_shift_ended(review: Dictionary) -> void:
|
|||||||
if Save:
|
if Save:
|
||||||
Save.save_shift_result(review)
|
Save.save_shift_result(review)
|
||||||
shift_review_panel.show_review(review, selected_scenario, Save.load_shift_results() if Save else [])
|
shift_review_panel.show_review(review, selected_scenario, Save.load_shift_results() if Save else [])
|
||||||
|
_refresh_freeze()
|
||||||
|
|
||||||
func _on_guard_target(target: Node3D) -> void:
|
func _on_guard_target(target: Node3D) -> void:
|
||||||
targeted_npc = target
|
targeted_npc = target
|
||||||
|
_set_focused_avatar(target)
|
||||||
_begin_incident(target)
|
_begin_incident(target)
|
||||||
|
|
||||||
func _on_guard_clear() -> void:
|
func _on_guard_clear() -> void:
|
||||||
targeted_npc = null
|
targeted_npc = null
|
||||||
|
_set_focused_avatar(null)
|
||||||
hud.set_target_label("")
|
hud.set_target_label("")
|
||||||
hud.set_action_hint("")
|
hud.set_action_hint("")
|
||||||
|
|
||||||
|
func _set_focused_avatar(avatar: Node3D) -> void:
|
||||||
|
if _focused_avatar and is_instance_valid(_focused_avatar) and _focused_avatar.has_method("set_focus_visual"):
|
||||||
|
_focused_avatar.set_focus_visual(false, false)
|
||||||
|
_focused_avatar = avatar
|
||||||
|
var menu_open := radial != null and radial.is_open
|
||||||
|
if avatar and avatar.has_method("set_focus_visual"):
|
||||||
|
avatar.set_focus_visual(true, menu_open)
|
||||||
|
|
||||||
func _begin_incident(avatar: Node3D) -> void:
|
func _begin_incident(avatar: Node3D) -> void:
|
||||||
var controller: Object = avatar.get_npc_controller() if avatar.has_method("get_npc_controller") else null
|
var controller: Object = avatar.get_npc_controller() if avatar.has_method("get_npc_controller") else null
|
||||||
if controller == null:
|
if controller == null:
|
||||||
@@ -309,8 +395,7 @@ func _begin_incident(avatar: Node3D) -> void:
|
|||||||
current_incident_active = true
|
current_incident_active = true
|
||||||
current_npc_truth = _read_npc_truth(controller)
|
current_npc_truth = _read_npc_truth(controller)
|
||||||
var profile: Object = controller.profile
|
var profile: Object = controller.profile
|
||||||
var label := "[%s] %s — %s" % [
|
var label := "%s — %s" % [
|
||||||
Rules.NPC_TYPE_NAMES.get((profile.npc_type if profile else -1), "?"),
|
|
||||||
profile.display_name if profile else "?",
|
profile.display_name if profile else "?",
|
||||||
controller.intent,
|
controller.intent,
|
||||||
]
|
]
|
||||||
@@ -327,20 +412,62 @@ func _begin_incident(avatar: Node3D) -> void:
|
|||||||
# ------------------------------------------------------------------ input ---
|
# ------------------------------------------------------------------ input ---
|
||||||
|
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
if event is InputEventKey and event.pressed and not event.echo:
|
if event is InputEventKey and event.echo:
|
||||||
_handle_key(event.keycode)
|
return
|
||||||
|
if event.is_action_pressed("cancel"):
|
||||||
|
_handle_cancel(event is InputEventMouseButton)
|
||||||
|
return
|
||||||
|
if event is InputEventMouseButton and event.pressed:
|
||||||
|
_handle_mouse_button(event)
|
||||||
|
return
|
||||||
|
if event is InputEventKey and event.pressed:
|
||||||
|
_handle_key(event)
|
||||||
|
|
||||||
func _handle_key(keycode: Key) -> void:
|
func _handle_mouse_button(event: InputEventMouseButton) -> void:
|
||||||
|
if event.button_index != MOUSE_BUTTON_LEFT:
|
||||||
|
return
|
||||||
|
if radial and radial.is_open:
|
||||||
|
return
|
||||||
|
if not _modal_blocks_radial() and targeted_npc != null:
|
||||||
|
_open_radial()
|
||||||
|
|
||||||
|
func _modal_blocks_radial() -> bool:
|
||||||
|
if inspection_open or wand_open:
|
||||||
|
return true
|
||||||
|
if radio_panel and radio_panel.visible:
|
||||||
|
return true
|
||||||
|
if shift_review_panel and shift_review_panel.visible:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
func _handle_key(event: InputEvent) -> void:
|
||||||
|
var keycode: Key = event.keycode
|
||||||
|
if radial and radial.is_open:
|
||||||
|
if event.is_action_pressed("confirm"):
|
||||||
|
_confirm_radial()
|
||||||
|
return
|
||||||
|
match keycode:
|
||||||
|
KEY_A, KEY_LEFT:
|
||||||
|
radial.step(-1)
|
||||||
|
KEY_D, KEY_RIGHT:
|
||||||
|
radial.step(1)
|
||||||
|
return
|
||||||
|
if event.is_action_pressed("action_menu"):
|
||||||
|
if targeted_npc != null and not _modal_blocks_radial():
|
||||||
|
_open_radial()
|
||||||
|
return
|
||||||
|
if event.is_action_pressed("inspect"):
|
||||||
|
if targeted_npc != null and not inspection_open and not wand_open:
|
||||||
|
_toggle_inspection()
|
||||||
|
return
|
||||||
|
if event.is_action_pressed("wand"):
|
||||||
|
if targeted_npc != null and not inspection_open and not wand_open:
|
||||||
|
_toggle_wand_screening()
|
||||||
|
return
|
||||||
match keycode:
|
match keycode:
|
||||||
KEY_E:
|
|
||||||
if targeted_npc != null and not inspection_open:
|
|
||||||
_toggle_inspection()
|
|
||||||
KEY_Q:
|
KEY_Q:
|
||||||
if targeted_npc != null and not inspection_open:
|
if targeted_npc != null and not inspection_open:
|
||||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "polite direction")
|
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "polite direction")
|
||||||
KEY_W:
|
|
||||||
if targeted_npc != null and not inspection_open:
|
|
||||||
_toggle_wand_screening()
|
|
||||||
KEY_TAB:
|
KEY_TAB:
|
||||||
_cycle_nearby_target()
|
_cycle_nearby_target()
|
||||||
KEY_F:
|
KEY_F:
|
||||||
@@ -383,6 +510,113 @@ func _handle_action_key(keycode: Key) -> void:
|
|||||||
_:
|
_:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
## `cancel` (Esc, RMB) closes the top modal. With nothing open, Esc toggles mouse
|
||||||
|
## capture; RMB does nothing so it never grabs the mouse by accident.
|
||||||
|
func _handle_cancel(from_mouse: bool) -> void:
|
||||||
|
if _close_top_modal():
|
||||||
|
return
|
||||||
|
if from_mouse:
|
||||||
|
return
|
||||||
|
if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
|
||||||
|
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||||
|
else:
|
||||||
|
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||||
|
|
||||||
|
func _close_top_modal() -> bool:
|
||||||
|
if radial and radial.is_open:
|
||||||
|
_cancel_radial()
|
||||||
|
return true
|
||||||
|
if inspection_open:
|
||||||
|
hud.hide_inspection()
|
||||||
|
inspection_open = false
|
||||||
|
_refresh_freeze()
|
||||||
|
return true
|
||||||
|
if wand_open:
|
||||||
|
hud.hide_wand()
|
||||||
|
wand_open = false
|
||||||
|
_refresh_freeze()
|
||||||
|
return true
|
||||||
|
if radio_panel and radio_panel.visible:
|
||||||
|
radio_panel.hide()
|
||||||
|
hud.set_action_hint("")
|
||||||
|
_refresh_freeze()
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
func _open_radial() -> void:
|
||||||
|
if targeted_npc == null:
|
||||||
|
hud.log(Strings.lookup("hud.aim_first"))
|
||||||
|
return
|
||||||
|
var controller: Object = _targeted_controller()
|
||||||
|
var p_name := "Person"
|
||||||
|
if controller and controller.profile:
|
||||||
|
p_name = str(controller.profile.display_name)
|
||||||
|
var wand_ok := selected_scenario != null and selected_scenario.equipment.has("wand")
|
||||||
|
radial.open_menu(p_name, wand_ok)
|
||||||
|
if targeted_npc and targeted_npc.has_method("set_focus_visual"):
|
||||||
|
targeted_npc.set_focus_visual(true, true)
|
||||||
|
_refresh_freeze()
|
||||||
|
|
||||||
|
func _confirm_radial() -> void:
|
||||||
|
if radial == null or not radial.is_open:
|
||||||
|
return
|
||||||
|
var slice := radial.confirm_selected()
|
||||||
|
radial.close_menu()
|
||||||
|
if targeted_npc and targeted_npc.has_method("set_focus_visual"):
|
||||||
|
targeted_npc.set_focus_visual(true, false)
|
||||||
|
_refresh_freeze()
|
||||||
|
if slice != "":
|
||||||
|
_on_radial_confirmed(slice)
|
||||||
|
|
||||||
|
func _cancel_radial() -> void:
|
||||||
|
if radial == null or not radial.is_open:
|
||||||
|
return
|
||||||
|
radial.close_menu()
|
||||||
|
if targeted_npc and targeted_npc.has_method("set_focus_visual"):
|
||||||
|
targeted_npc.set_focus_visual(true, false)
|
||||||
|
_refresh_freeze()
|
||||||
|
|
||||||
|
func _on_radial_confirmed(slice_id: String) -> void:
|
||||||
|
if radial and radial.is_open:
|
||||||
|
radial.close_menu()
|
||||||
|
if targeted_npc and targeted_npc.has_method("set_focus_visual"):
|
||||||
|
targeted_npc.set_focus_visual(true, false)
|
||||||
|
_refresh_freeze()
|
||||||
|
match slice_id:
|
||||||
|
"raise_hand":
|
||||||
|
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand")
|
||||||
|
"point":
|
||||||
|
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point")
|
||||||
|
"head_shake":
|
||||||
|
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "head shake")
|
||||||
|
"polite":
|
||||||
|
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "polite direction")
|
||||||
|
"inspect":
|
||||||
|
_toggle_inspection()
|
||||||
|
"wand":
|
||||||
|
_toggle_wand_screening()
|
||||||
|
"warning":
|
||||||
|
_apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning")
|
||||||
|
"block":
|
||||||
|
_apply_interaction(Rules.Level.BLOCKING, "blocking")
|
||||||
|
"radio":
|
||||||
|
_open_radio(false)
|
||||||
|
|
||||||
|
func _refresh_freeze() -> void:
|
||||||
|
var freeze := inspection_open or wand_open
|
||||||
|
if radial and radial.is_open:
|
||||||
|
freeze = true
|
||||||
|
if radio_panel and radio_panel.visible:
|
||||||
|
freeze = true
|
||||||
|
if shift_review_panel and shift_review_panel.visible:
|
||||||
|
freeze = true
|
||||||
|
if guard:
|
||||||
|
guard.input_locked = freeze
|
||||||
|
if freeze:
|
||||||
|
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||||
|
else:
|
||||||
|
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||||
|
|
||||||
## Toggle the guard's flashlight. In evening/late phases it widens NPC
|
## Toggle the guard's flashlight. In evening/late phases it widens NPC
|
||||||
## perception radius (PLAN.md: Level 3 — flashlight at night).
|
## perception radius (PLAN.md: Level 3 — flashlight at night).
|
||||||
func _toggle_flashlight() -> void:
|
func _toggle_flashlight() -> void:
|
||||||
@@ -429,8 +663,13 @@ func _cycle_nearby_target() -> void:
|
|||||||
_nearby_index = (_nearby_index + 1) % nearby.size()
|
_nearby_index = (_nearby_index + 1) % nearby.size()
|
||||||
var next_target: Node3D = nearby[_nearby_index]
|
var next_target: Node3D = nearby[_nearby_index]
|
||||||
targeted_npc = next_target
|
targeted_npc = next_target
|
||||||
|
_set_focused_avatar(next_target)
|
||||||
_begin_incident(next_target)
|
_begin_incident(next_target)
|
||||||
hud.log(Strings.fmt("hud.targeting", [next_target.name]))
|
var controller: Object = _targeted_controller()
|
||||||
|
var who: String = next_target.name
|
||||||
|
if controller and controller.profile and controller.profile.display_name != "":
|
||||||
|
who = str(controller.profile.display_name)
|
||||||
|
hud.log(Strings.fmt("hud.targeting", [who]))
|
||||||
|
|
||||||
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:
|
||||||
@@ -506,9 +745,11 @@ 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.")
|
||||||
|
_refresh_freeze()
|
||||||
|
|
||||||
func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
|
func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
|
||||||
hud.set_action_hint("")
|
hud.set_action_hint("")
|
||||||
|
_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)
|
||||||
if not result.get("ok", false):
|
if not result.get("ok", false):
|
||||||
hud.log(Strings.fmt("radio.blocked", [result.get("reason", "unknown")]))
|
hud.log(Strings.fmt("radio.blocked", [result.get("reason", "unknown")]))
|
||||||
@@ -540,14 +781,16 @@ func _toggle_inspection() -> void:
|
|||||||
if inspection_open:
|
if inspection_open:
|
||||||
hud.hide_inspection()
|
hud.hide_inspection()
|
||||||
inspection_open = false
|
inspection_open = false
|
||||||
|
_refresh_freeze()
|
||||||
return
|
return
|
||||||
var controller: Object = _targeted_controller()
|
var controller: Object = _targeted_controller()
|
||||||
if controller == null:
|
if controller == null:
|
||||||
return
|
return
|
||||||
inspection_open = true
|
inspection_open = true
|
||||||
var artifacts := _artifacts_for(controller)
|
var artifacts := _artifacts_for(controller)
|
||||||
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy)
|
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy, controller.screened)
|
||||||
hud.show_inspection(controller, artifacts, result)
|
hud.show_inspection(controller, artifacts, result)
|
||||||
|
_refresh_freeze()
|
||||||
# Venue door-staff flavor: an applicable check line (PLAN.md: door-staff dialogue).
|
# Venue door-staff flavor: an applicable check line (PLAN.md: door-staff dialogue).
|
||||||
var line := _door_context_line()
|
var line := _door_context_line()
|
||||||
if line != "":
|
if line != "":
|
||||||
@@ -565,6 +808,7 @@ func _toggle_wand_screening() -> void:
|
|||||||
hud.hide_wand()
|
hud.hide_wand()
|
||||||
hud.set_wand_icon(false)
|
hud.set_wand_icon(false)
|
||||||
wand_open = false
|
wand_open = false
|
||||||
|
_refresh_freeze()
|
||||||
return
|
return
|
||||||
if not selected_scenario.equipment.has("wand"):
|
if not selected_scenario.equipment.has("wand"):
|
||||||
hud.log(Strings.lookup("hud.no_wand"))
|
hud.log(Strings.lookup("hud.no_wand"))
|
||||||
@@ -579,6 +823,7 @@ func _toggle_wand_screening() -> void:
|
|||||||
var result: Dictionary = wand_screener.screen(items)
|
var result: Dictionary = wand_screener.screen(items)
|
||||||
wand_open = true
|
wand_open = true
|
||||||
hud.show_wand(controller, result)
|
hud.show_wand(controller, result)
|
||||||
|
_refresh_freeze()
|
||||||
var wand_line := selected_scenario.door_staff_line("wand_check", _door_rng)
|
var wand_line := selected_scenario.door_staff_line("wand_check", _door_rng)
|
||||||
if wand_line != "":
|
if wand_line != "":
|
||||||
hud.log(wand_line)
|
hud.log(wand_line)
|
||||||
@@ -589,6 +834,7 @@ func decide_wand(allowed: bool) -> void:
|
|||||||
if controller == null:
|
if controller == null:
|
||||||
hud.hide_wand()
|
hud.hide_wand()
|
||||||
wand_open = false
|
wand_open = false
|
||||||
|
_refresh_freeze()
|
||||||
return
|
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 []
|
||||||
@@ -624,6 +870,7 @@ func decide_wand(allowed: bool) -> void:
|
|||||||
controller.screened = true
|
controller.screened = true
|
||||||
hud.hide_wand()
|
hud.hide_wand()
|
||||||
wand_open = false
|
wand_open = false
|
||||||
|
_refresh_freeze()
|
||||||
# Short scan cooldown: the wand must “recharge” between guests (PLAN.md: feel).
|
# Short scan cooldown: the wand must “recharge” between guests (PLAN.md: feel).
|
||||||
_wand_cooldown = WAND_COOLDOWN_S
|
_wand_cooldown = WAND_COOLDOWN_S
|
||||||
|
|
||||||
@@ -632,15 +879,17 @@ func decide_access(allowed: bool) -> void:
|
|||||||
if controller == null:
|
if controller == null:
|
||||||
hud.hide_inspection()
|
hud.hide_inspection()
|
||||||
inspection_open = false
|
inspection_open = false
|
||||||
|
_refresh_freeze()
|
||||||
return
|
return
|
||||||
# Screening-before-entry: at wand venues, a guest must be screened first.
|
# Screening-before-entry: at wand venues, a guest must be screened first.
|
||||||
if allowed and selected_scenario.access_policy.wand_screening_required and not controller.screened:
|
if allowed and selected_scenario.access_policy.wand_screening_required and not controller.screened:
|
||||||
hud.log(Strings.lookup("hud.screen_first"))
|
hud.log(Strings.lookup("hud.screen_first"))
|
||||||
hud.hide_inspection()
|
hud.hide_inspection()
|
||||||
inspection_open = false
|
inspection_open = false
|
||||||
|
_refresh_freeze()
|
||||||
return
|
return
|
||||||
var artifacts := _artifacts_for(controller)
|
var artifacts := _artifacts_for(controller)
|
||||||
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy)
|
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy, controller.screened)
|
||||||
var granted: bool = result.get("granted", false)
|
var granted: bool = result.get("granted", false)
|
||||||
coordinator.recorder.add_access_evidence(controller.npc_id, {
|
coordinator.recorder.add_access_evidence(controller.npc_id, {
|
||||||
"decided": allowed,
|
"decided": allowed,
|
||||||
@@ -672,6 +921,7 @@ func decide_access(allowed: bool) -> void:
|
|||||||
audio.play_deny()
|
audio.play_deny()
|
||||||
hud.hide_inspection()
|
hud.hide_inspection()
|
||||||
inspection_open = false
|
inspection_open = false
|
||||||
|
_refresh_freeze()
|
||||||
|
|
||||||
## Venue-specific opening line for the access inspection (e.g. The Joke Shop's
|
## Venue-specific opening line for the access inspection (e.g. The Joke Shop's
|
||||||
## ID / ticket check lines). Returns "" when the venue has no matching lines.
|
## ID / ticket check lines). Returns "" when the venue has no matching lines.
|
||||||
@@ -685,23 +935,10 @@ func _door_context_line() -> String:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
func _artifacts_for(controller: Object) -> Array:
|
func _artifacts_for(controller: Object) -> Array:
|
||||||
var profile: Object = controller.profile
|
var profile: NpcProfile = controller.profile as NpcProfile
|
||||||
var npc_type: int = profile.npc_type if profile else -1
|
var override: Dictionary = controller.access_override
|
||||||
var out: Array = []
|
var policy = selected_scenario.access_policy if selected_scenario else null
|
||||||
match npc_type:
|
return AccessLoadout.build(profile, policy, override)
|
||||||
Rules.NpcType.AUTHORIZED_GUEST, Rules.NpcType.AUTHORIZED_STAFF:
|
|
||||||
out.append(AccessArtifact.new(Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.VALID, {"known": true}))
|
|
||||||
Rules.NpcType.DELIVERY:
|
|
||||||
out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, Rules.ArtifactStatus.VALID, {"role": "delivery"}))
|
|
||||||
Rules.NpcType.TRESPASSER:
|
|
||||||
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, {"color": "red", "date": "2024-01-01"}))
|
|
||||||
Rules.NpcType.TOURIST:
|
|
||||||
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {}))
|
|
||||||
Rules.NpcType.CONFUSED_VISITOR:
|
|
||||||
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.AMBIGUOUS, {"color": "gold"}))
|
|
||||||
_:
|
|
||||||
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {}))
|
|
||||||
return out
|
|
||||||
|
|
||||||
func _read_npc_truth(controller: Object) -> Dictionary:
|
func _read_npc_truth(controller: Object) -> Dictionary:
|
||||||
var profile: Object = controller.profile
|
var profile: Object = controller.profile
|
||||||
|
|||||||
@@ -1,35 +1,47 @@
|
|||||||
class_name GuardController
|
class_name GuardController
|
||||||
extends CharacterBody3D
|
extends CharacterBody3D
|
||||||
## First-person guard controller: WASD movement, mouse look, interact raycast.
|
## First-person guard: WASD + mouse look. Focus is a cone on NPC heads (ADR-0007, ADR-0008).
|
||||||
|
|
||||||
signal picked_target(target: Node)
|
signal picked_target(target: Node)
|
||||||
signal cleared_target()
|
signal cleared_target()
|
||||||
|
|
||||||
|
const NpcFocus := preload("res://src/game/npc_focus.gd")
|
||||||
|
|
||||||
var move_speed: float = 3.2
|
var move_speed: float = 3.2
|
||||||
var look_sensitivity: float = 0.0022
|
var look_sensitivity: float = 0.0022
|
||||||
|
var accel: float = 12.0
|
||||||
var is_ready := false
|
var is_ready := false
|
||||||
var targeted_npc: Node3D = null
|
var targeted_npc: Node3D = null
|
||||||
|
var input_locked := false
|
||||||
|
var _miss_frames := 0
|
||||||
|
const MISS_GRACE := 8
|
||||||
|
|
||||||
@onready var camera: Camera3D = $Head/Camera3D
|
@onready var camera: Camera3D = $Head/Camera3D
|
||||||
@onready var head: Node3D = $Head
|
@onready var head: Node3D = $Head
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
floor_snap_length = 0.3
|
||||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||||
is_ready = true
|
is_ready = true
|
||||||
|
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if input_locked:
|
||||||
|
return
|
||||||
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
|
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
|
||||||
rotate_y(-event.relative.x * look_sensitivity)
|
rotate_y(-event.relative.x * look_sensitivity)
|
||||||
head.rotate_x(-event.relative.y * look_sensitivity)
|
head.rotate_x(-event.relative.y * look_sensitivity)
|
||||||
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0))
|
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0))
|
||||||
elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
|
|
||||||
if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
|
|
||||||
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
|
||||||
else:
|
|
||||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
|
||||||
|
|
||||||
func _physics_process(_delta: float) -> void:
|
func _physics_process(_delta: float) -> void:
|
||||||
# Movement relative to the head's yaw only.
|
if input_locked:
|
||||||
|
velocity.x = 0.0
|
||||||
|
velocity.z = 0.0
|
||||||
|
if not is_on_floor():
|
||||||
|
velocity.y -= 9.8 * _delta
|
||||||
|
else:
|
||||||
|
velocity.y = 0.0
|
||||||
|
move_and_slide()
|
||||||
|
return
|
||||||
var forward := -global_transform.basis.z
|
var forward := -global_transform.basis.z
|
||||||
forward.y = 0.0
|
forward.y = 0.0
|
||||||
forward = forward.normalized()
|
forward = forward.normalized()
|
||||||
@@ -41,28 +53,49 @@ func _physics_process(_delta: float) -> void:
|
|||||||
Input.get_axis("move_left", "move_right"),
|
Input.get_axis("move_left", "move_right"),
|
||||||
Input.get_axis("move_forward", "move_back")
|
Input.get_axis("move_forward", "move_back")
|
||||||
)
|
)
|
||||||
var direction := (forward * input_dir.y + right * input_dir.x).normalized()
|
if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
|
||||||
velocity.x = direction.x * move_speed
|
input_dir = Vector2.ZERO
|
||||||
velocity.z = direction.z * move_speed
|
var desired := (forward * input_dir.y + right * input_dir.x)
|
||||||
velocity.y -= 9.8 * _delta
|
if desired.length() > 1.0:
|
||||||
|
desired = desired.normalized()
|
||||||
|
desired *= move_speed
|
||||||
|
velocity.x = move_toward(velocity.x, desired.x, accel * _delta * move_speed)
|
||||||
|
velocity.z = move_toward(velocity.z, desired.z, accel * _delta * move_speed)
|
||||||
|
if is_on_floor():
|
||||||
|
velocity.y = 0.0
|
||||||
|
else:
|
||||||
|
velocity.y -= 9.8 * _delta
|
||||||
move_and_slide()
|
move_and_slide()
|
||||||
_update_target()
|
_update_target()
|
||||||
|
|
||||||
func _update_target() -> void:
|
func _update_target() -> void:
|
||||||
var space := get_world_3d().direct_space_state
|
if camera == null:
|
||||||
var from := camera.global_position
|
return
|
||||||
var to := from + -camera.global_transform.basis.z * 8.0
|
var npcs := get_tree().get_nodes_in_group("npc") if get_tree() else []
|
||||||
var query := PhysicsRayQueryParameters3D.create(from, to)
|
var heads: Array = []
|
||||||
query.collide_with_areas = true
|
var avatars: Array = []
|
||||||
query.collide_with_bodies = true
|
for node in npcs:
|
||||||
query.exclude = [get_rid()]
|
if not (node is Node3D) or not is_instance_valid(node):
|
||||||
var hit := space.intersect_ray(query)
|
continue
|
||||||
|
var avatar := node as Node3D
|
||||||
|
var head_node := avatar.get_node_or_null("Rig/Head") as Node3D
|
||||||
|
var pos: Vector3 = head_node.global_position if head_node else avatar.global_position + Vector3(0, 1.6, 0)
|
||||||
|
heads.append(pos)
|
||||||
|
avatars.append(avatar)
|
||||||
|
var cam_origin := camera.global_position
|
||||||
|
var cam_forward := -camera.global_transform.basis.z
|
||||||
|
var idx := NpcFocus.pick_index(cam_origin, cam_forward, heads)
|
||||||
var new_target: Node3D = null
|
var new_target: Node3D = null
|
||||||
if hit:
|
if idx >= 0:
|
||||||
var obj: Object = hit.get("collider")
|
var candidate: Node3D = avatars[idx]
|
||||||
var parent_avatar: Node3D = _find_avatar(obj)
|
if _head_visible(cam_origin, heads[idx]):
|
||||||
if parent_avatar != null:
|
new_target = candidate
|
||||||
new_target = parent_avatar
|
if new_target == null and targeted_npc != null:
|
||||||
|
_miss_frames += 1
|
||||||
|
if _miss_frames < MISS_GRACE:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
_miss_frames = 0
|
||||||
if new_target != targeted_npc:
|
if new_target != targeted_npc:
|
||||||
targeted_npc = new_target
|
targeted_npc = new_target
|
||||||
if new_target != null:
|
if new_target != null:
|
||||||
@@ -70,14 +103,21 @@ func _update_target() -> void:
|
|||||||
else:
|
else:
|
||||||
cleared_target.emit()
|
cleared_target.emit()
|
||||||
|
|
||||||
func _find_avatar(obj: Object) -> Node3D:
|
func _head_visible(from: Vector3, to: Vector3) -> bool:
|
||||||
# Avatars are CharacterBody3D with a collision shape; guard against self and scene junk.
|
var space := get_world_3d().direct_space_state
|
||||||
|
if space == null:
|
||||||
|
return true
|
||||||
|
var query := PhysicsRayQueryParameters3D.create(from, to)
|
||||||
|
query.collide_with_areas = false
|
||||||
|
query.collide_with_bodies = true
|
||||||
|
query.exclude = [get_rid()]
|
||||||
|
var hit := space.intersect_ray(query)
|
||||||
|
if hit.is_empty():
|
||||||
|
return true
|
||||||
|
var obj: Object = hit.get("collider")
|
||||||
var n := obj as Node
|
var n := obj as Node
|
||||||
if n == null:
|
while n != null:
|
||||||
return null
|
if n.is_in_group("npc"):
|
||||||
var current: Node = n
|
return true
|
||||||
while current != null:
|
n = n.get_parent()
|
||||||
if current.has_method("get_npc_controller"):
|
return false
|
||||||
return current as Node3D
|
|
||||||
current = current.get_parent()
|
|
||||||
return null
|
|
||||||
@@ -39,14 +39,17 @@ func _init() -> void:
|
|||||||
func get_npc_controller() -> NPCController:
|
func get_npc_controller() -> NPCController:
|
||||||
return npc
|
return npc
|
||||||
|
|
||||||
func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3, p_door: Vector3 = Vector3(0, 0.5, 1.2)) -> void:
|
func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3, p_door: Vector3 = Vector3(0, 0.5, 1.2), p_npc_id: String = "", p_override: Dictionary = {}) -> void:
|
||||||
npc.setup("npc_%d" % seed_value, profile, intent, seed_value)
|
var id := p_npc_id if p_npc_id != "" else "npc_%d" % seed_value
|
||||||
|
npc.setup(id, profile, intent, seed_value)
|
||||||
|
npc.access_override = p_override
|
||||||
_visual_seed = seed_value
|
_visual_seed = seed_value
|
||||||
spawn_position = p_spawn
|
spawn_position = p_spawn
|
||||||
target_position = p_target
|
target_position = p_target
|
||||||
door_position = p_door
|
door_position = p_door
|
||||||
position = p_spawn
|
position = p_spawn
|
||||||
npc.fsm.state_changed.connect(_on_state_changed)
|
npc.fsm.state_changed.connect(_on_state_changed)
|
||||||
|
add_to_group("npc")
|
||||||
_build_body(profile.npc_type)
|
_build_body(profile.npc_type)
|
||||||
_nav_usable = false
|
_nav_usable = false
|
||||||
_nav_try_frames = 0
|
_nav_try_frames = 0
|
||||||
@@ -62,9 +65,10 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
|
|||||||
var collider := CollisionShape3D.new()
|
var collider := CollisionShape3D.new()
|
||||||
var shape := CapsuleShape3D.new()
|
var shape := CapsuleShape3D.new()
|
||||||
shape.radius = 0.35
|
shape.radius = 0.35
|
||||||
shape.height = 1.6
|
var height_scale: float = _rig.scale.y if _rig else 1.0
|
||||||
|
shape.height = 1.6 * height_scale
|
||||||
collider.shape = shape
|
collider.shape = shape
|
||||||
collider.position = Vector3(0, 0.8, 0)
|
collider.position = Vector3(0, 0.8 * height_scale, 0)
|
||||||
add_child(collider)
|
add_child(collider)
|
||||||
|
|
||||||
var _visual_seed := 0
|
var _visual_seed := 0
|
||||||
@@ -72,8 +76,11 @@ var _rig: Node3D = null
|
|||||||
var _head: Node3D = null
|
var _head: Node3D = null
|
||||||
var _bob_phase := 0.0
|
var _bob_phase := 0.0
|
||||||
var _guard_ref: Node3D = null
|
var _guard_ref: Node3D = null
|
||||||
|
var _ring: MeshInstance3D = null
|
||||||
|
var _name_tag: Label3D = null
|
||||||
const SKIN_TONE := Color(0.88, 0.72, 0.60)
|
const SKIN_TONE := Color(0.88, 0.72, 0.60)
|
||||||
const PANTS_COLOR := Color(0.22, 0.22, 0.26)
|
const PANTS_COLOR := Color(0.22, 0.22, 0.26)
|
||||||
|
const HAIR_COLORS := [Color(0.12, 0.1, 0.08), Color(0.35, 0.22, 0.12), Color(0.55, 0.42, 0.28), Color(0.15, 0.15, 0.18)]
|
||||||
|
|
||||||
func _build_body(npc_type: int) -> void:
|
func _build_body(npc_type: int) -> void:
|
||||||
_rig = Node3D.new()
|
_rig = Node3D.new()
|
||||||
@@ -96,14 +103,60 @@ func _build_body(npc_type: int) -> void:
|
|||||||
# Arms
|
# Arms
|
||||||
_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0)
|
_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0)
|
||||||
_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0)
|
_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0)
|
||||||
# Head
|
# Neck, head, face, hair, shoes so the body reads as a person (ADR-0010).
|
||||||
|
_add_capsule(_rig, 0.08, 0.16, Vector3(0, 1.48, 0), SKIN_TONE)
|
||||||
_head = Node3D.new()
|
_head = Node3D.new()
|
||||||
_head.name = "Head"
|
_head.name = "Head"
|
||||||
_head.position = Vector3(0, 1.62, 0)
|
_head.position = Vector3(0, 1.62, 0)
|
||||||
_add_sphere(_head, 0.17, Vector3.ZERO, SKIN_TONE)
|
_add_sphere(_head, 0.17, Vector3.ZERO, SKIN_TONE)
|
||||||
|
var hair_i := (_visual_seed * 7) % HAIR_COLORS.size()
|
||||||
|
_add_sphere(_head, 0.18, Vector3(0, 0.08, -0.02), HAIR_COLORS[hair_i])
|
||||||
|
_add_sphere(_head, 0.035, Vector3(-0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1))
|
||||||
|
_add_sphere(_head, 0.035, Vector3(0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1))
|
||||||
_rig.add_child(_head)
|
_rig.add_child(_head)
|
||||||
|
_add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(-0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14))
|
||||||
|
_add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14))
|
||||||
|
_build_focus_ring()
|
||||||
_add_props(npc_type)
|
_add_props(npc_type)
|
||||||
|
|
||||||
|
func _build_focus_ring() -> void:
|
||||||
|
_ring = MeshInstance3D.new()
|
||||||
|
_ring.name = "FocusRing"
|
||||||
|
var torus := TorusMesh.new()
|
||||||
|
torus.inner_radius = 0.16
|
||||||
|
torus.outer_radius = 0.20
|
||||||
|
torus.rings = 12
|
||||||
|
torus.ring_segments = 16
|
||||||
|
_ring.mesh = torus
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
|
mat.albedo_color = Color(0.95, 0.82, 0.28, 0.9)
|
||||||
|
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||||
|
_ring.material_override = mat
|
||||||
|
_ring.position = Vector3(0, 0.28, 0)
|
||||||
|
_ring.visible = false
|
||||||
|
_head.add_child(_ring)
|
||||||
|
_name_tag = Label3D.new()
|
||||||
|
_name_tag.name = "NameTag"
|
||||||
|
_name_tag.text = npc.profile.display_name if npc and npc.profile else ""
|
||||||
|
_name_tag.font_size = 28
|
||||||
|
_name_tag.pixel_size = 0.004
|
||||||
|
_name_tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||||
|
_name_tag.position = Vector3(0, 0.42, 0)
|
||||||
|
_name_tag.modulate = Color(0.95, 0.95, 0.9, 0.9)
|
||||||
|
_name_tag.visible = false
|
||||||
|
_head.add_child(_name_tag)
|
||||||
|
|
||||||
|
func set_focus_visual(focused: bool, menu_open: bool = false) -> void:
|
||||||
|
if _ring:
|
||||||
|
_ring.visible = focused
|
||||||
|
var mat := _ring.material_override as StandardMaterial3D
|
||||||
|
if mat:
|
||||||
|
mat.albedo_color = Color(1.0, 0.92, 0.35, 1.0) if menu_open else Color(0.95, 0.82, 0.28, 0.85)
|
||||||
|
_ring.scale = Vector3.ONE * (1.15 if menu_open else 1.0)
|
||||||
|
if _name_tag:
|
||||||
|
_name_tag.visible = focused
|
||||||
|
|
||||||
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> void:
|
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> void:
|
||||||
var mi := MeshInstance3D.new()
|
var mi := MeshInstance3D.new()
|
||||||
var mesh := CapsuleMesh.new()
|
var mesh := CapsuleMesh.new()
|
||||||
@@ -187,6 +240,10 @@ func _animate(delta: float) -> void:
|
|||||||
_bob_phase = 0.0
|
_bob_phase = 0.0
|
||||||
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)
|
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)
|
||||||
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
|
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
|
||||||
|
if _ring and _ring.visible:
|
||||||
|
var cam := get_viewport().get_camera_3d() if get_viewport() else null
|
||||||
|
if cam:
|
||||||
|
_ring.look_at(cam.global_position, Vector3.UP)
|
||||||
# Attention cue: head turns toward the guard when awareness was raised.
|
# Attention cue: head turns toward the guard when awareness was raised.
|
||||||
if _head != null and npc.guard_noticed:
|
if _head != null and npc.guard_noticed:
|
||||||
if _guard_ref == null or not is_instance_valid(_guard_ref):
|
if _guard_ref == null or not is_instance_valid(_guard_ref):
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## World-space focus cone (ADR-0008). Picks the head closest to the camera
|
||||||
|
## forward vector inside range and angle. No viewport needed, so tests run headless.
|
||||||
|
|
||||||
|
const MAX_RANGE := 10.0
|
||||||
|
const MAX_ANGLE_RAD := 0.175 # ~10 degrees
|
||||||
|
|
||||||
|
static func score_head(cam_origin: Vector3, cam_forward: Vector3, head_pos: Vector3, max_range: float = MAX_RANGE, max_angle: float = MAX_ANGLE_RAD) -> float:
|
||||||
|
var to := head_pos - cam_origin
|
||||||
|
var dist := to.length()
|
||||||
|
if dist < 0.05 or dist > max_range:
|
||||||
|
return -1.0
|
||||||
|
var dir := to / dist
|
||||||
|
var dot := cam_forward.dot(dir)
|
||||||
|
if dot <= 0.0:
|
||||||
|
return -1.0
|
||||||
|
var ang := acos(clampf(dot, -1.0, 1.0))
|
||||||
|
if ang > max_angle:
|
||||||
|
return -1.0
|
||||||
|
return ang
|
||||||
|
|
||||||
|
static func pick_index(cam_origin: Vector3, cam_forward: Vector3, head_positions: Array, max_range: float = MAX_RANGE, max_angle: float = MAX_ANGLE_RAD) -> int:
|
||||||
|
var best_i := -1
|
||||||
|
var best_score := 999.0
|
||||||
|
for i in head_positions.size():
|
||||||
|
var pos: Vector3 = head_positions[i]
|
||||||
|
var score := score_head(cam_origin, cam_forward, pos, max_range, max_angle)
|
||||||
|
if score < 0.0:
|
||||||
|
continue
|
||||||
|
if score < best_score:
|
||||||
|
best_score = score
|
||||||
|
best_i = i
|
||||||
|
return best_i
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
extends CanvasLayer
|
||||||
|
## Doughnut action menu (ADR-0009). Open on LMB/Space, confirm click/Space,
|
||||||
|
## cancel Esc/RMB. Emergency is not a slice.
|
||||||
|
|
||||||
|
signal confirmed(slice_id: String)
|
||||||
|
signal cancelled()
|
||||||
|
|
||||||
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
|
const Strings := preload("res://src/core/strings.gd")
|
||||||
|
|
||||||
|
const SLICE_DEFS := [
|
||||||
|
{"id": "raise_hand", "label_key": "radial.raise_hand", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "raise hand"},
|
||||||
|
{"id": "point", "label_key": "radial.point", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "point"},
|
||||||
|
{"id": "head_shake", "label_key": "radial.head_shake", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "head shake"},
|
||||||
|
{"id": "polite", "label_key": "radial.polite", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "polite direction"},
|
||||||
|
{"id": "inspect", "label_key": "radial.inspect", "level": -1, "action": "inspect"},
|
||||||
|
{"id": "wand", "label_key": "radial.wand", "level": -1, "action": "wand"},
|
||||||
|
{"id": "warning", "label_key": "radial.warning", "level": Rules.Level.FORMAL_WARNING, "action": "formal warning"},
|
||||||
|
{"id": "block", "label_key": "radial.block", "level": Rules.Level.BLOCKING, "action": "blocking"},
|
||||||
|
{"id": "radio", "label_key": "radial.radio", "level": -1, "action": "radio assist"},
|
||||||
|
]
|
||||||
|
|
||||||
|
var selected := 0
|
||||||
|
var wand_available := false
|
||||||
|
var person_name := ""
|
||||||
|
var is_open := false
|
||||||
|
var _opened_ms := 0
|
||||||
|
var _wheel: Control = null
|
||||||
|
var _center_label: Label = null
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
name = "ActionRadial"
|
||||||
|
layer = 25
|
||||||
|
var wheel := Control.new()
|
||||||
|
wheel.name = "Wheel"
|
||||||
|
wheel.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
wheel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
|
wheel.draw.connect(_on_wheel_draw)
|
||||||
|
wheel.gui_input.connect(_on_wheel_input)
|
||||||
|
add_child(wheel)
|
||||||
|
_wheel = wheel
|
||||||
|
_center_label = Label.new()
|
||||||
|
_center_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
_center_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||||
|
_center_label.set_anchors_preset(Control.PRESET_CENTER)
|
||||||
|
_center_label.offset_left = -120
|
||||||
|
_center_label.offset_right = 120
|
||||||
|
_center_label.offset_top = -24
|
||||||
|
_center_label.offset_bottom = 24
|
||||||
|
_center_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
add_child(_center_label)
|
||||||
|
hide()
|
||||||
|
|
||||||
|
func slice_count() -> int:
|
||||||
|
return SLICE_DEFS.size()
|
||||||
|
|
||||||
|
func slice_id(index: int) -> String:
|
||||||
|
if index < 0 or index >= SLICE_DEFS.size():
|
||||||
|
return ""
|
||||||
|
return str(SLICE_DEFS[index]["id"])
|
||||||
|
|
||||||
|
func slice_def(index: int) -> Dictionary:
|
||||||
|
if index < 0 or index >= SLICE_DEFS.size():
|
||||||
|
return {}
|
||||||
|
return SLICE_DEFS[index]
|
||||||
|
|
||||||
|
func is_slice_enabled(index: int) -> bool:
|
||||||
|
if slice_id(index) == "wand":
|
||||||
|
return wand_available
|
||||||
|
return true
|
||||||
|
|
||||||
|
func open_menu(p_name: String, p_wand: bool) -> void:
|
||||||
|
person_name = p_name
|
||||||
|
wand_available = p_wand
|
||||||
|
selected = 0
|
||||||
|
is_open = true
|
||||||
|
_opened_ms = Time.get_ticks_msec()
|
||||||
|
_center_label.text = person_name
|
||||||
|
show()
|
||||||
|
_wheel.queue_redraw()
|
||||||
|
|
||||||
|
func close_menu() -> void:
|
||||||
|
is_open = false
|
||||||
|
hide()
|
||||||
|
|
||||||
|
func step(direction: int) -> void:
|
||||||
|
if not is_open or SLICE_DEFS.is_empty():
|
||||||
|
return
|
||||||
|
var n := SLICE_DEFS.size()
|
||||||
|
var next := selected
|
||||||
|
for i in n:
|
||||||
|
next = (next + direction) % n
|
||||||
|
if next < 0:
|
||||||
|
next += n
|
||||||
|
if is_slice_enabled(next):
|
||||||
|
selected = next
|
||||||
|
_wheel.queue_redraw()
|
||||||
|
return
|
||||||
|
|
||||||
|
func confirm_selected() -> String:
|
||||||
|
if not is_open:
|
||||||
|
return ""
|
||||||
|
if not is_slice_enabled(selected):
|
||||||
|
return ""
|
||||||
|
return slice_id(selected)
|
||||||
|
|
||||||
|
func angle_to_index(angle: float) -> int:
|
||||||
|
# angle from atan2, 0 = +x, we want 0 = top (-PI/2).
|
||||||
|
var n := float(SLICE_DEFS.size())
|
||||||
|
var shifted := angle + PI / 2.0
|
||||||
|
while shifted < 0.0:
|
||||||
|
shifted += TAU
|
||||||
|
while shifted >= TAU:
|
||||||
|
shifted -= TAU
|
||||||
|
var idx := int(floor(shifted / TAU * n)) % SLICE_DEFS.size()
|
||||||
|
return idx
|
||||||
|
|
||||||
|
func _on_wheel_input(event: InputEvent) -> void:
|
||||||
|
if not is_open:
|
||||||
|
return
|
||||||
|
if event is InputEventMouseMotion:
|
||||||
|
var idx := _index_at_mouse(event.position)
|
||||||
|
if idx >= 0 and is_slice_enabled(idx):
|
||||||
|
selected = idx
|
||||||
|
_wheel.queue_redraw()
|
||||||
|
elif event is InputEventMouseButton and event.pressed:
|
||||||
|
if event.button_index == MOUSE_BUTTON_LEFT:
|
||||||
|
if Time.get_ticks_msec() - _opened_ms < 120:
|
||||||
|
return
|
||||||
|
var idx := _index_at_mouse(event.position)
|
||||||
|
if idx >= 0 and is_slice_enabled(idx):
|
||||||
|
selected = idx
|
||||||
|
confirmed.emit(slice_id(selected))
|
||||||
|
elif event.button_index == MOUSE_BUTTON_RIGHT:
|
||||||
|
cancelled.emit()
|
||||||
|
|
||||||
|
func _index_at_mouse(local_pos: Vector2) -> int:
|
||||||
|
var center := _wheel.size * 0.5
|
||||||
|
var delta := local_pos - center
|
||||||
|
if delta.length() < 36.0:
|
||||||
|
return -1
|
||||||
|
return angle_to_index(atan2(delta.y, delta.x))
|
||||||
|
|
||||||
|
func _on_wheel_draw() -> void:
|
||||||
|
if not is_open:
|
||||||
|
return
|
||||||
|
var center := _wheel.size * 0.5
|
||||||
|
var n := SLICE_DEFS.size()
|
||||||
|
var outer := 150.0
|
||||||
|
var inner := 58.0
|
||||||
|
for i in n:
|
||||||
|
var a0 := -PI / 2.0 + TAU * float(i) / float(n)
|
||||||
|
var a1 := -PI / 2.0 + TAU * float(i + 1) / float(n)
|
||||||
|
var col := Color(0.12, 0.14, 0.18, 0.82)
|
||||||
|
if i == selected:
|
||||||
|
col = Color(0.92, 0.78, 0.28, 0.9)
|
||||||
|
if not is_slice_enabled(i):
|
||||||
|
col = Color(0.2, 0.2, 0.22, 0.45)
|
||||||
|
_draw_slice(center, inner, outer, a0, a1, col)
|
||||||
|
var mid := (a0 + a1) * 0.5
|
||||||
|
var label_pos := center + Vector2(cos(mid), sin(mid)) * ((inner + outer) * 0.5)
|
||||||
|
var text := Strings.lookup(str(SLICE_DEFS[i]["label_key"]))
|
||||||
|
_wheel.draw_string(ThemeDB.fallback_font, label_pos + Vector2(-28, 4), text, HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color.WHITE)
|
||||||
|
|
||||||
|
func _draw_slice(center: Vector2, inner: float, outer: float, a0: float, a1: float, color: Color) -> void:
|
||||||
|
var pts := PackedVector2Array()
|
||||||
|
var steps := 8
|
||||||
|
for s in steps + 1:
|
||||||
|
var t := float(s) / float(steps)
|
||||||
|
var ang := lerpf(a0, a1, t)
|
||||||
|
pts.append(center + Vector2(cos(ang), sin(ang)) * outer)
|
||||||
|
for s in steps + 1:
|
||||||
|
var t := float(steps - s) / float(steps)
|
||||||
|
var ang := lerpf(a0, a1, t)
|
||||||
|
pts.append(center + Vector2(cos(ang), sin(ang)) * inner)
|
||||||
|
_wheel.draw_colored_polygon(pts, color)
|
||||||
@@ -5,7 +5,6 @@ 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/ai/game_ready/skyline.png")
|
|
||||||
## steps, building facade, restricted door, sidewalk, pavement, and street edge.
|
## steps, building facade, restricted door, sidewalk, pavement, and street edge.
|
||||||
## No art assets required; all geometry is BoxMesh with flat materials.
|
## No art assets required; all geometry is BoxMesh with flat materials.
|
||||||
|
|
||||||
@@ -33,7 +32,6 @@ func build() -> void:
|
|||||||
_build_crosswalk()
|
_build_crosswalk()
|
||||||
_build_street_furniture()
|
_build_street_furniture()
|
||||||
_build_guard_spawn()
|
_build_guard_spawn()
|
||||||
_build_skyline_backdrop()
|
|
||||||
_build_navmesh()
|
_build_navmesh()
|
||||||
set_night_mode(false)
|
set_night_mode(false)
|
||||||
|
|
||||||
@@ -68,13 +66,13 @@ func _build_building() -> void:
|
|||||||
var building := StaticBody3D.new()
|
var building := StaticBody3D.new()
|
||||||
building.name = "Building"
|
building.name = "Building"
|
||||||
# Main facade (textured brick)
|
# Main facade (textured brick)
|
||||||
_add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -2), Color(0.85, 0.8, 0.78), false, T_BRICK, Vector2(10, 5))
|
_add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -6), Color(0.85, 0.8, 0.78), false, T_BRICK, Vector2(10, 5))
|
||||||
# Entrance recess + door recess frame
|
# Entrance recess + door recess frame
|
||||||
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -4.0), Color(0.08, 0.08, 0.09))
|
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -3.9), Color(0.08, 0.08, 0.09))
|
||||||
# Door frame and dark door
|
# Door frame and dark door
|
||||||
_add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -4.15), Color(0.13, 0.12, 0.11))
|
_add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -3.98), Color(0.13, 0.12, 0.11))
|
||||||
# Door glass (semi-transparent)
|
# Door glass (semi-transparent)
|
||||||
var glass := _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -4.0), Color(0.6, 0.75, 0.9, 0.55))
|
var glass := _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -3.9), Color(0.6, 0.75, 0.9, 0.55))
|
||||||
var glass_mat := StandardMaterial3D.new()
|
var glass_mat := StandardMaterial3D.new()
|
||||||
glass_mat.albedo_color = Color(0.6, 0.75, 0.9, 0.45)
|
glass_mat.albedo_color = Color(0.6, 0.75, 0.9, 0.45)
|
||||||
glass_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
glass_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||||
@@ -82,13 +80,13 @@ func _build_building() -> void:
|
|||||||
glass_mat.metallic = 0.1
|
glass_mat.metallic = 0.1
|
||||||
glass.material_override = glass_mat
|
glass.material_override = glass_mat
|
||||||
# Door lock indicator (green = open, red = locked), placed beside the handle
|
# Door lock indicator (green = open, red = locked), placed beside the handle
|
||||||
door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -4.35), Color(0.1, 0.9, 0.3))
|
door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -3.82), Color(0.1, 0.9, 0.3))
|
||||||
door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3))
|
door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3))
|
||||||
var col := BoxShape3D.new()
|
var col := BoxShape3D.new()
|
||||||
col.size = Vector3(40, 18, 4)
|
col.size = Vector3(40, 18, 4)
|
||||||
var so := building.create_shape_owner(building)
|
var so := building.create_shape_owner(building)
|
||||||
building.shape_owner_add_shape(so, col)
|
building.shape_owner_add_shape(so, col)
|
||||||
building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -2)))
|
building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -6)))
|
||||||
root.add_child(building)
|
root.add_child(building)
|
||||||
|
|
||||||
func _build_steps() -> void:
|
func _build_steps() -> void:
|
||||||
@@ -102,7 +100,7 @@ func _build_steps() -> void:
|
|||||||
var height := 0.16
|
var height := 0.16
|
||||||
for i in 4:
|
for i in 4:
|
||||||
var y: float = i * height
|
var y: float = i * height
|
||||||
var z: float = i * tread
|
var z: float = 4.0 * tread - i * tread
|
||||||
_add_box(steps, Vector3(step_w - i * 0.35, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5))
|
_add_box(steps, Vector3(step_w - i * 0.35, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5))
|
||||||
# Landing at the top
|
# Landing at the top
|
||||||
_add_box(steps, Vector3(9, 0.2, 3), Vector3(0, 4 * height + 0.1, -1.5), Color(0.5, 0.47, 0.45))
|
_add_box(steps, Vector3(9, 0.2, 3), Vector3(0, 4 * height + 0.1, -1.5), Color(0.5, 0.47, 0.45))
|
||||||
@@ -206,8 +204,10 @@ func _build_street_furniture() -> void:
|
|||||||
# Low-poly trees along the street front.
|
# Low-poly trees along the street front.
|
||||||
var trunk_color := Color(0.35, 0.25, 0.18)
|
var trunk_color := Color(0.35, 0.25, 0.18)
|
||||||
var leaf_color := Color(0.16, 0.42, 0.2)
|
var leaf_color := Color(0.16, 0.42, 0.2)
|
||||||
|
# Flank the walkway; keep the sight line to the door clear.
|
||||||
for i in 6:
|
for i in 6:
|
||||||
var x := -11.0 + i * 4.4
|
var side := -1.0 if i % 2 == 0 else 1.0
|
||||||
|
var x := side * (6.5 + float(int(i / 2)) * 3.0)
|
||||||
var z := 4.0 + float((i * 7) % 5)
|
var z := 4.0 + float((i * 7) % 5)
|
||||||
var tree := Node3D.new()
|
var tree := Node3D.new()
|
||||||
tree.name = "Tree_%d" % (i + 1)
|
tree.name = "Tree_%d" % (i + 1)
|
||||||
@@ -261,32 +261,10 @@ func _plain_material(color: Color) -> StandardMaterial3D:
|
|||||||
mat.albedo_color = color
|
mat.albedo_color = color
|
||||||
return mat
|
return mat
|
||||||
|
|
||||||
func _build_skyline_backdrop() -> void:
|
|
||||||
## Distant procedural skyline on four unshaded planes around the block.
|
|
||||||
var mat := StandardMaterial3D.new()
|
|
||||||
mat.albedo_texture = T_SKYLINE
|
|
||||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
||||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
||||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
|
||||||
const DIST := 95.0
|
|
||||||
const HEIGHT := 90.0
|
|
||||||
for i in 4:
|
|
||||||
var plane := MeshInstance3D.new()
|
|
||||||
plane.name = "Skyline_%d" % i
|
|
||||||
var pm := PlaneMesh.new()
|
|
||||||
pm.size = Vector2(300, HEIGHT)
|
|
||||||
plane.mesh = pm
|
|
||||||
plane.material_override = mat
|
|
||||||
var angle := i * 90.0
|
|
||||||
plane.position = Vector3(sin(deg_to_rad(angle)) * DIST, HEIGHT / 2.0, cos(deg_to_rad(angle)) * DIST)
|
|
||||||
plane.rotation_degrees = Vector3(0, -angle + 90.0, 0)
|
|
||||||
plane.cast_shadow = (0 as GeometryInstance3D.ShadowCastingSetting)
|
|
||||||
root.add_child(plane)
|
|
||||||
|
|
||||||
func _build_guard_spawn() -> void:
|
func _build_guard_spawn() -> void:
|
||||||
var marker := Marker3D.new()
|
var marker := Marker3D.new()
|
||||||
marker.name = "GuardSpawn"
|
marker.name = "GuardSpawn"
|
||||||
marker.position = Vector3(0, 1.6, -2.5)
|
marker.position = Vector3(0, 0.1, -2.0)
|
||||||
root.add_child(marker)
|
root.add_child(marker)
|
||||||
|
|
||||||
func _lock_indicator_material(color: Color) -> StandardMaterial3D:
|
func _lock_indicator_material(color: Color) -> StandardMaterial3D:
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ func _process(_delta: float) -> bool:
|
|||||||
quit(1)
|
quit(1)
|
||||||
return true
|
return true
|
||||||
if gm != null and not camera_staged:
|
if gm != null and not camera_staged:
|
||||||
gm.guard.global_position = Vector3(0, 1.6, 5.0)
|
gm.guard.global_position = Vector3(0, 0.1, 10.5)
|
||||||
gm.guard.head.rotation.x = deg_to_rad(-8.0)
|
gm.guard.rotation.y = 0.0
|
||||||
|
gm.guard.head.rotation.x = deg_to_rad(2.0)
|
||||||
camera_staged = true
|
camera_staged = true
|
||||||
if frames > MAX_FRAMES:
|
if frames > MAX_FRAMES:
|
||||||
print("timeout reached")
|
print("timeout reached")
|
||||||
@@ -54,6 +55,7 @@ func _advance() -> void:
|
|||||||
# Wait for a nearby NPC so the interaction HUD has a target.
|
# Wait for a nearby NPC so the interaction HUD has a target.
|
||||||
if gm != null and gm.npcs.size() > 0:
|
if gm != null and gm.npcs.size() > 0:
|
||||||
gm.call("_cycle_nearby_target")
|
gm.call("_cycle_nearby_target")
|
||||||
|
_face_target()
|
||||||
targeted = true
|
targeted = true
|
||||||
state = 2
|
state = 2
|
||||||
state_frames = 0
|
state_frames = 0
|
||||||
@@ -61,6 +63,7 @@ func _advance() -> void:
|
|||||||
if state_frames >= 30:
|
if state_frames >= 30:
|
||||||
_shoot("02-targeting-npc.png")
|
_shoot("02-targeting-npc.png")
|
||||||
if targeted and gm != null:
|
if targeted and gm != null:
|
||||||
|
_face_target()
|
||||||
gm.call("_toggle_inspection")
|
gm.call("_toggle_inspection")
|
||||||
state = 3
|
state = 3
|
||||||
state_frames = 0
|
state_frames = 0
|
||||||
@@ -80,6 +83,8 @@ 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.guard.head.rotation.x = deg_to_rad(2.0)
|
||||||
gm.coordinator.elapsed_s = 750.0
|
gm.coordinator.elapsed_s = 750.0
|
||||||
state = 5
|
state = 5
|
||||||
state_frames = 0
|
state_frames = 0
|
||||||
@@ -87,6 +92,8 @@ func _advance() -> void:
|
|||||||
# Let the phase transition (and street lamps / marquee) settle, then
|
# Let the phase transition (and street lamps / marquee) settle, then
|
||||||
# take the night avenue shot.
|
# take the night avenue shot.
|
||||||
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:
|
if gm != null:
|
||||||
gm.call("_toggle_flashlight")
|
gm.call("_toggle_flashlight")
|
||||||
@@ -98,6 +105,17 @@ func _advance() -> void:
|
|||||||
state = 7
|
state = 7
|
||||||
quit(0)
|
quit(0)
|
||||||
|
|
||||||
|
func _face_target() -> void:
|
||||||
|
var target: Node3D = gm.targeted_npc
|
||||||
|
if target == null:
|
||||||
|
return
|
||||||
|
var to: Vector3 = target.global_position - gm.guard.global_position
|
||||||
|
to.y = 0.0
|
||||||
|
if to.length() < 0.01:
|
||||||
|
return
|
||||||
|
gm.guard.rotation.y = atan2(-to.x, -to.z)
|
||||||
|
gm.guard.head.rotation.x = deg_to_rad(-4.0)
|
||||||
|
|
||||||
func _shoot(file_name: String) -> void:
|
func _shoot(file_name: String) -> void:
|
||||||
var dir := DirAccess.open("user://")
|
var dir := DirAccess.open("user://")
|
||||||
if dir == null or not dir.dir_exists("screenshots"):
|
if dir == null or not dir.dir_exists("screenshots"):
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Windowed smoke helper for the ADR-0007–0010 control pass (focus ring + action
|
||||||
|
## wheel). Boots the real main scene, stages one NPC in front of the guard, opens
|
||||||
|
## the wheel, and saves two PNGs to user://screenshots/. Exits 1 if the focus or
|
||||||
|
## radial state did not come up, so it can be used as a smoke check.
|
||||||
|
## Usage: godot --path . --script res://src/test/smoke_focus_radial.gd
|
||||||
|
|
||||||
|
const OUT_DIR := "user://screenshots"
|
||||||
|
const MAX_FRAMES := 1800
|
||||||
|
const STAGE_DISTANCE := 2.4
|
||||||
|
|
||||||
|
var frames := 0
|
||||||
|
var gm: Node = null
|
||||||
|
var state := 0
|
||||||
|
var state_frames := 0
|
||||||
|
var stage_npc: Node3D = null
|
||||||
|
var failures: Array[String] = []
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
root.size = Vector2i(1280, 720)
|
||||||
|
var packed: PackedScene = load("res://scenes/main.tscn")
|
||||||
|
root.add_child(packed.instantiate())
|
||||||
|
|
||||||
|
func _process(_delta: float) -> bool:
|
||||||
|
frames += 1
|
||||||
|
if gm == null:
|
||||||
|
gm = root.find_child("GameManager", true, false)
|
||||||
|
if gm == null:
|
||||||
|
if frames > 600:
|
||||||
|
_fail("GameManager not found")
|
||||||
|
return false
|
||||||
|
if frames > MAX_FRAMES:
|
||||||
|
_fail("timeout before the radial state")
|
||||||
|
return true
|
||||||
|
_advance()
|
||||||
|
return false
|
||||||
|
|
||||||
|
func _advance() -> void:
|
||||||
|
state_frames += 1
|
||||||
|
match state:
|
||||||
|
0:
|
||||||
|
# Wait for the guard to settle on the ground, then grab one NPC.
|
||||||
|
if state_frames >= 40 and gm.npcs.size() > 0:
|
||||||
|
print("smoke: spawn guard_y=%.2f z=%.2f" % [
|
||||||
|
(gm.guard.global_position as Vector3).y,
|
||||||
|
(gm.guard.global_position as Vector3).z,
|
||||||
|
])
|
||||||
|
_shoot("smoke-00-spawn.png")
|
||||||
|
stage_npc = gm.npcs[0]
|
||||||
|
_hide_other_npcs(stage_npc)
|
||||||
|
state = 1
|
||||||
|
state_frames = 0
|
||||||
|
1:
|
||||||
|
# Keep the staged NPC in front of the guard so the ring is visible.
|
||||||
|
_stage_target()
|
||||||
|
if state_frames >= 20:
|
||||||
|
gm.call("_cycle_nearby_target")
|
||||||
|
if gm.targeted_npc == null:
|
||||||
|
_fail("no NPC focused")
|
||||||
|
elif gm.radial == null:
|
||||||
|
_fail("radial layer missing")
|
||||||
|
else:
|
||||||
|
print("smoke: guard_y=%.2f npc_y=%.2f" % [
|
||||||
|
(gm.guard.global_position as Vector3).y,
|
||||||
|
(gm.targeted_npc.global_position as Vector3).y,
|
||||||
|
])
|
||||||
|
_shoot("smoke-01-focus-ring.png")
|
||||||
|
gm.call("_open_radial")
|
||||||
|
state = 2
|
||||||
|
state_frames = 0
|
||||||
|
2:
|
||||||
|
if state_frames >= 20:
|
||||||
|
if not gm.radial.is_open:
|
||||||
|
_fail("action wheel did not open")
|
||||||
|
else:
|
||||||
|
_shoot("smoke-02-action-wheel.png")
|
||||||
|
gm.call("_cancel_radial")
|
||||||
|
state = 3
|
||||||
|
state_frames = 0
|
||||||
|
3:
|
||||||
|
if state_frames >= 5:
|
||||||
|
if gm.radial.is_open:
|
||||||
|
_fail("action wheel did not close on cancel")
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
func _stage_target() -> void:
|
||||||
|
if stage_npc == null or not is_instance_valid(stage_npc):
|
||||||
|
stage_npc = gm.npcs[0] if gm.npcs.size() > 0 else null
|
||||||
|
if stage_npc == null:
|
||||||
|
return
|
||||||
|
_hide_other_npcs(stage_npc)
|
||||||
|
var guard_pos: Vector3 = gm.guard.global_position
|
||||||
|
var forward: Vector3 = -gm.guard.global_transform.basis.z
|
||||||
|
forward.y = 0.0
|
||||||
|
forward = forward.normalized()
|
||||||
|
stage_npc.global_position = guard_pos + forward * STAGE_DISTANCE + Vector3(0, 0.4, 0)
|
||||||
|
stage_npc.rotation.y = gm.guard.rotation.y + PI
|
||||||
|
|
||||||
|
func _hide_other_npcs(keep: Node3D) -> void:
|
||||||
|
for npc in gm.npcs:
|
||||||
|
if is_instance_valid(npc) and npc is Node3D and npc != keep:
|
||||||
|
(npc as Node3D).visible = false
|
||||||
|
|
||||||
|
func _shoot(file_name: String) -> void:
|
||||||
|
var dir := DirAccess.open("user://")
|
||||||
|
if dir == null or not dir.dir_exists("screenshots"):
|
||||||
|
dir.make_dir_recursive("screenshots")
|
||||||
|
var img: Image = root.get_texture().get_image()
|
||||||
|
var err: int = img.save_png(OUT_DIR.path_join(file_name))
|
||||||
|
print("%s -> err=%d (%dx%d)" % [file_name, err, img.get_width(), img.get_height()])
|
||||||
|
|
||||||
|
func _fail(message: String) -> void:
|
||||||
|
failures.append(message)
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
func _finish() -> void:
|
||||||
|
if failures.is_empty():
|
||||||
|
print("SMOKE OK: focus ring + action wheel opened and cancelled")
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
for message in failures:
|
||||||
|
printerr("SMOKE FAIL: " + message)
|
||||||
|
quit(1)
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Venue-correct access artifacts (override_access, no free recognition on guests).
|
||||||
|
|
||||||
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
|
const AccessLoadout := preload("res://src/core/access_loadout.gd")
|
||||||
|
const AccessVerifier := preload("res://src/core/access_verifier.gd")
|
||||||
|
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
|
||||||
|
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||||
|
|
||||||
|
var verifier := AccessVerifier.new()
|
||||||
|
|
||||||
|
func _guest() -> NpcProfile:
|
||||||
|
var p := NpcProfile.new()
|
||||||
|
p.id = "authorized_guest"
|
||||||
|
p.display_name = "Authorized Guest"
|
||||||
|
p.npc_type = Rules.NpcType.AUTHORIZED_GUEST
|
||||||
|
return p
|
||||||
|
|
||||||
|
func _staff() -> NpcProfile:
|
||||||
|
var p := NpcProfile.new()
|
||||||
|
p.id = "authorized_staff"
|
||||||
|
p.display_name = "Staff Member"
|
||||||
|
p.npc_type = Rules.NpcType.AUTHORIZED_STAFF
|
||||||
|
return p
|
||||||
|
|
||||||
|
func _kinds(artifacts: Array) -> Array:
|
||||||
|
var out: Array = []
|
||||||
|
for a in artifacts:
|
||||||
|
out.append(a.kind)
|
||||||
|
return out
|
||||||
|
|
||||||
|
func test_joke_guest_valid_needs_screen_then_grants() -> bool:
|
||||||
|
var policy = ScenarioFactory.joke_shop().access_policy
|
||||||
|
var arts: Array = AccessLoadout.build(_guest(), policy, {"has_id": true, "has_ticket": true})
|
||||||
|
var has_rec := false
|
||||||
|
for art in arts:
|
||||||
|
if art.kind == Rules.AccessKind.RECOGNITION:
|
||||||
|
has_rec = true
|
||||||
|
var a := TestUtils.check(not has_rec, "no free recognition")
|
||||||
|
var before := verifier.verify(arts, policy, false)
|
||||||
|
var after := verifier.verify(arts, policy, true)
|
||||||
|
var b := TestUtils.check_eq(before["granted"], false, "unscreened valid guest denied")
|
||||||
|
var c := TestUtils.check_eq(after["granted"], true, "screened valid guest granted")
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_joke_guest_no_id_denied_even_when_screened() -> bool:
|
||||||
|
var policy = ScenarioFactory.joke_shop().access_policy
|
||||||
|
var arts: Array = AccessLoadout.build(_guest(), policy, {"has_id": false, "has_ticket": true})
|
||||||
|
var result := verifier.verify(arts, policy, true)
|
||||||
|
return TestUtils.check_eq(result["granted"], false, "no-id guest denied")
|
||||||
|
|
||||||
|
func test_joke_guest_expired_id_denied() -> bool:
|
||||||
|
var policy = ScenarioFactory.joke_shop().access_policy
|
||||||
|
var arts: Array = AccessLoadout.build(_guest(), policy, {
|
||||||
|
"has_id": true, "has_ticket": true, "id_status": Rules.ArtifactStatus.EXPIRED,
|
||||||
|
})
|
||||||
|
var result := verifier.verify(arts, policy, true)
|
||||||
|
return TestUtils.check_eq(result["granted"], false, "expired id denied")
|
||||||
|
|
||||||
|
func test_joke_event_payload_matches_factory() -> bool:
|
||||||
|
var shop = ScenarioFactory.joke_shop()
|
||||||
|
var found_expired := false
|
||||||
|
var found_no_id := false
|
||||||
|
for event in shop.event_pool:
|
||||||
|
var ov: Dictionary = event.get("override_access", {})
|
||||||
|
if event.get("id", "") == "guest_expired_id":
|
||||||
|
found_expired = int(ov.get("id_status", -1)) == Rules.ArtifactStatus.EXPIRED
|
||||||
|
if event.get("id", "") == "guest_no_id":
|
||||||
|
found_no_id = ov.get("has_id", true) == false
|
||||||
|
var a := TestUtils.check(found_expired, "guest_expired_id carries expired status")
|
||||||
|
var b := TestUtils.check(found_no_id, "guest_no_id has has_id false")
|
||||||
|
return a and b
|
||||||
|
|
||||||
|
func test_joke_staff_recognized_without_override() -> bool:
|
||||||
|
var policy = ScenarioFactory.joke_shop().access_policy
|
||||||
|
var arts: Array = AccessLoadout.build(_staff(), policy, {})
|
||||||
|
var has_rec := false
|
||||||
|
for a in arts:
|
||||||
|
if a.kind == Rules.AccessKind.RECOGNITION and a.status == Rules.ArtifactStatus.VALID:
|
||||||
|
has_rec = true
|
||||||
|
var screened := verifier.verify(arts, policy, true)
|
||||||
|
return TestUtils.check(has_rec, "staff are recognized") and TestUtils.check_eq(screened["granted"], true, "recognized staff enter after screen")
|
||||||
|
|
||||||
|
func test_building_guest_uses_wristband_not_recognition() -> bool:
|
||||||
|
var policy = ScenarioFactory.hollywood_building().access_policy
|
||||||
|
var arts: Array = AccessLoadout.build(_guest(), policy, {})
|
||||||
|
var has_rec := false
|
||||||
|
var has_band := false
|
||||||
|
for a in arts:
|
||||||
|
if a.kind == Rules.AccessKind.RECOGNITION:
|
||||||
|
has_rec = true
|
||||||
|
if a.kind == Rules.AccessKind.WRISTBAND and a.status == Rules.ArtifactStatus.VALID:
|
||||||
|
has_band = true
|
||||||
|
var result := verifier.verify(arts, policy)
|
||||||
|
var a := TestUtils.check(not has_rec, "building guest is not auto-recognized")
|
||||||
|
var b := TestUtils.check(has_band, "building guest presents a wristband")
|
||||||
|
var c := TestUtils.check_eq(result["granted"], true, "valid wristband grants")
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_studio_ticket_grants_without_recognition() -> bool:
|
||||||
|
var policy = ScenarioFactory.tv_studio().access_policy
|
||||||
|
var arts: Array = AccessLoadout.build(_guest(), policy, {"has_ticket": true})
|
||||||
|
var has_rec := false
|
||||||
|
for a in arts:
|
||||||
|
if a.kind == Rules.AccessKind.RECOGNITION:
|
||||||
|
has_rec = true
|
||||||
|
var result := verifier.verify(arts, policy)
|
||||||
|
return TestUtils.check(not has_rec, "audience ticket is not recognition") and TestUtils.check_eq(result["granted"], true, "ticket grants at studio")
|
||||||
|
|
||||||
|
func test_studio_counterfeit_denied() -> bool:
|
||||||
|
var policy = ScenarioFactory.tv_studio().access_policy
|
||||||
|
var arts: Array = AccessLoadout.build(_guest(), policy, {
|
||||||
|
"wristband_status": Rules.ArtifactStatus.COUNTERFEIT,
|
||||||
|
"credential_status": Rules.ArtifactStatus.COUNTERFEIT,
|
||||||
|
})
|
||||||
|
var result := verifier.verify(arts, policy)
|
||||||
|
return TestUtils.check_eq(result["granted"], false, "counterfeit studio artifacts deny")
|
||||||
|
|
||||||
|
func test_carried_items_override_and_roll() -> bool:
|
||||||
|
var forced: Array = AccessLoadout.roll_carried_items(1, {"carried_items": ["weapon"]})
|
||||||
|
var a := TestUtils.check_eq(forced, ["weapon"], "override items win")
|
||||||
|
var rolled: Array = AccessLoadout.roll_carried_items(42, {})
|
||||||
|
var b := TestUtils.check(rolled is Array, "roll returns an array")
|
||||||
|
var again: Array = AccessLoadout.roll_carried_items(42, {})
|
||||||
|
var c := TestUtils.check_eq(rolled, again, "same seed same pocket")
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_joke_shop_sidewalk_exists() -> bool:
|
||||||
|
var shop = ScenarioFactory.joke_shop()
|
||||||
|
var studio = ScenarioFactory.tv_studio()
|
||||||
|
var a := TestUtils.check(shop.locations.has("sidewalk"), "joke shop sidewalk")
|
||||||
|
var b := TestUtils.check(studio.locations.has("sidewalk"), "studio sidewalk")
|
||||||
|
var c := TestUtils.check_eq(shop.waypoint_for_intent("film").z, shop.locations["sidewalk"].z, "film walks to sidewalk")
|
||||||
|
return a and b and c
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Focus cone math and radial menu wrap/cancel (ADR-0008, ADR-0009).
|
||||||
|
|
||||||
|
const NpcFocus := preload("res://src/game/npc_focus.gd")
|
||||||
|
const ActionRadial := preload("res://src/game/ui/action_radial.gd")
|
||||||
|
|
||||||
|
func test_cone_picks_centered_head() -> bool:
|
||||||
|
var origin := Vector3(0, 1.6, 0)
|
||||||
|
var forward := Vector3(0, 0, -1)
|
||||||
|
var heads: Array = [
|
||||||
|
Vector3(3, 1.6, -4),
|
||||||
|
Vector3(0.05, 1.6, -4),
|
||||||
|
Vector3(-2, 1.6, -4),
|
||||||
|
]
|
||||||
|
var idx := NpcFocus.pick_index(origin, forward, heads)
|
||||||
|
return TestUtils.check_eq(idx, 1, "head nearest the look axis wins")
|
||||||
|
|
||||||
|
func test_cone_rejects_behind_and_far() -> bool:
|
||||||
|
var origin := Vector3(0, 1.6, 0)
|
||||||
|
var forward := Vector3(0, 0, -1)
|
||||||
|
var behind := NpcFocus.score_head(origin, forward, Vector3(0, 1.6, 4))
|
||||||
|
var far := NpcFocus.score_head(origin, forward, Vector3(0, 1.6, -40))
|
||||||
|
var a := TestUtils.check_eq(behind, -1.0, "behind the camera is ignored")
|
||||||
|
var b := TestUtils.check_eq(far, -1.0, "out of range is ignored")
|
||||||
|
return a and b
|
||||||
|
|
||||||
|
func test_radial_wrap_and_skip_wand() -> bool:
|
||||||
|
var menu: Node = ActionRadial.new()
|
||||||
|
menu.open_menu("Guest", false)
|
||||||
|
var start: int = menu.selected
|
||||||
|
menu.step(-1)
|
||||||
|
var a := TestUtils.check(menu.selected != start, "step wrap moves selection")
|
||||||
|
var b := TestUtils.check(menu.is_slice_enabled(menu.selected), "landed on an enabled slice")
|
||||||
|
# Walk forward until we would hit wand; it must skip.
|
||||||
|
menu.selected = 4
|
||||||
|
menu.step(1)
|
||||||
|
var c := TestUtils.check_eq(menu.slice_id(menu.selected), "warning", "wand skipped when venue has none")
|
||||||
|
menu.free()
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_radial_cancel_does_not_confirm() -> bool:
|
||||||
|
var menu: Node = ActionRadial.new()
|
||||||
|
menu.open_menu("Guest", true)
|
||||||
|
var got := {"id": ""}
|
||||||
|
menu.confirmed.connect(func(id): got["id"] = id)
|
||||||
|
menu.cancelled.connect(func(): got["id"] = "cancelled")
|
||||||
|
menu.close_menu()
|
||||||
|
var a := TestUtils.check_eq(got["id"], "", "closing without confirm emits nothing")
|
||||||
|
var slice: String = menu.confirm_selected()
|
||||||
|
var b := TestUtils.check_eq(slice, "", "confirm while closed is empty")
|
||||||
|
menu.free()
|
||||||
|
return a and b
|
||||||
|
|
||||||
|
func test_radial_confirm_returns_id() -> bool:
|
||||||
|
var menu: Node = ActionRadial.new()
|
||||||
|
menu.open_menu("Guest", true)
|
||||||
|
menu.selected = 0
|
||||||
|
var slice: String = menu.confirm_selected()
|
||||||
|
menu.free()
|
||||||
|
return TestUtils.check_eq(slice, "raise_hand", "space-confirm uses highlighted slice")
|
||||||
|
|
||||||
|
func test_angle_top_is_first_slice() -> bool:
|
||||||
|
var menu: Node = ActionRadial.new()
|
||||||
|
var idx: int = menu.angle_to_index(-PI / 2.0)
|
||||||
|
menu.free()
|
||||||
|
return TestUtils.check_eq(idx, 0, "top of the doughnut is slice 0")
|
||||||
|
|
||||||
|
func test_wand_not_on_w() -> bool:
|
||||||
|
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
||||||
|
var a := TestUtils.check(not src.contains("KEY_W:"), "W is not a game-manager action key")
|
||||||
|
var b := TestUtils.check(src.contains("is_action_pressed(\"wand\")"), "wand goes through the named action")
|
||||||
|
var c := TestUtils.check(not _action_has_key("wand", KEY_W), "wand action is not bound to W")
|
||||||
|
var d := TestUtils.check(_action_has_key("wand", KEY_C), "wand action is bound to C")
|
||||||
|
return a and b and c and d
|
||||||
|
|
||||||
|
func test_named_input_actions_exist() -> bool:
|
||||||
|
var menu_ok := _action_has_key("action_menu", KEY_SPACE)
|
||||||
|
var confirm_ok := _action_has_key("confirm", KEY_SPACE) and _action_has_key("confirm", KEY_ENTER)
|
||||||
|
var cancel_ok := _action_has_key("cancel", KEY_ESCAPE)
|
||||||
|
var inspect_ok := _action_has_key("inspect", KEY_E)
|
||||||
|
return TestUtils.check(menu_ok, "action_menu has Space") \
|
||||||
|
and TestUtils.check(confirm_ok, "confirm has Space + Enter") \
|
||||||
|
and TestUtils.check(cancel_ok, "cancel has Esc") \
|
||||||
|
and TestUtils.check(inspect_ok, "inspect has E")
|
||||||
|
|
||||||
|
func test_cancel_has_right_mouse() -> bool:
|
||||||
|
var events: Array = InputMap.action_get_events("cancel")
|
||||||
|
var found := false
|
||||||
|
for event in events:
|
||||||
|
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT:
|
||||||
|
found = true
|
||||||
|
return TestUtils.check(found, "cancel action includes right mouse button")
|
||||||
|
|
||||||
|
func _action_has_key(action: String, keycode: Key) -> bool:
|
||||||
|
if not InputMap.has_action(action):
|
||||||
|
return false
|
||||||
|
for event in InputMap.action_get_events(action):
|
||||||
|
if event is InputEventKey and event.physical_keycode == keycode:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
@@ -38,7 +38,7 @@ func test_tv_studio_has_all_npc_types() -> bool:
|
|||||||
|
|
||||||
var _spawn_count := 0
|
var _spawn_count := 0
|
||||||
|
|
||||||
func _on_spawn(_profile: NpcProfile, _intent: String, _id: String) -> void:
|
func _on_spawn(_profile: NpcProfile, _intent: String, _id: String, _event: Dictionary = {}) -> void:
|
||||||
_spawn_count += 1
|
_spawn_count += 1
|
||||||
|
|
||||||
func test_late_phase_reaches_violent_event() -> bool:
|
func test_late_phase_reaches_violent_event() -> bool:
|
||||||
@@ -137,3 +137,17 @@ func test_waypoints_map_intent_to_location() -> bool:
|
|||||||
var d := TestUtils.check_eq(scenario.waypoint_for_intent("sit"), steps_end, "sit -> steps_end")
|
var d := TestUtils.check_eq(scenario.waypoint_for_intent("sit"), steps_end, "sit -> steps_end")
|
||||||
var e := TestUtils.check_eq(scenario.waypoint_for_intent("unknown_intent"), sidewalk, "unknown -> sidewalk fallback")
|
var e := TestUtils.check_eq(scenario.waypoint_for_intent("unknown_intent"), sidewalk, "unknown -> sidewalk fallback")
|
||||||
return a and b and c and d and e
|
return a and b and c and d and e
|
||||||
|
|
||||||
|
func test_spawn_rate_is_time_based() -> bool:
|
||||||
|
# Regression: tick() runs at physics rate. Rolling the event scheduler every
|
||||||
|
# frame spawned >1000 NPCs in seconds. Ten simulated seconds at 60 Hz must
|
||||||
|
# stay in the normal single-digit-per-second range.
|
||||||
|
var scenario: VenueScenario = ScenarioFactory.hollywood_building()
|
||||||
|
var coordinator := ShiftCoordinator.new()
|
||||||
|
coordinator.setup(scenario, 60.0, 99)
|
||||||
|
_spawn_count = 0
|
||||||
|
coordinator.npc_spawn_requested.connect(_on_spawn)
|
||||||
|
coordinator.start()
|
||||||
|
for i in 600:
|
||||||
|
coordinator.tick(1.0 / 60.0)
|
||||||
|
return TestUtils.check(_spawn_count <= 60, "spawns stay time-based at physics rate (was %d)" % _spawn_count)
|
||||||