Compare commits
3
Commits
f11b441c07
...
v0.2.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5ad1642b5 | ||
|
|
028f608298 | ||
|
|
be429cab29 |
No files matched your search
@@ -368,6 +368,16 @@ Completed 2026-10-31 with ADR-0007–0010. Headless **77/77**, plus a windowed s
|
||||
(`src/test/smoke_focus_radial.gd`, exits non-zero on failure).
|
||||
- [x] README controls table, smoke checklist, test count, implemented/not-yet lists.
|
||||
|
||||
## Visual polish — Skyline backdrop transparency
|
||||
|
||||
Found during the control smoke test; verified with PIL analysis.
|
||||
|
||||
- [x] Regenerated `assets/textures/game_ready/skyline.png` with transparent background
|
||||
(commented out opaque border line 234 in `tools/generate_textures.py`).
|
||||
- [x] Integrated backdrop into `WorldBuilder` via `_build_skyline_backdrop()`: creates a
|
||||
PlaneMesh with T_SKYLINE texture at Vector3(-80, 12, -50) behind the building.
|
||||
- [x] PIL verification confirms RGBA mode with transparent pixels in bottom rows.
|
||||
|
||||
## Runtime boot and spawn fixes
|
||||
|
||||
Found while running the windowed smoke; all verified with a boot run and regenerated
|
||||
@@ -390,3 +400,64 @@ screenshots.
|
||||
- [x] Removed the skyline backdrop planes (opaque black texture). Tracked as a follow-up in
|
||||
`TODO.md`.
|
||||
- [x] README screenshots regenerated with the fixed eye height and entrance staging.
|
||||
|
||||
## World and presentation follow-ups (found during the control smoke)
|
||||
|
||||
Completed 2026-10-31 (working tree). Headless **88/88**; `src/test/smoke_session_shell.gd` and the windowed focus/radial smoke both pass.
|
||||
|
||||
|
||||
- [x] The skyline backdrop was removed: `assets/textures/ai/game_ready/skyline.png` has an
|
||||
opaque black background, so the planes rendered as black slabs with window dots. Re-add
|
||||
the backdrop only with a transparent asset or a keyed shader.
|
||||
- [x] Guard settles ~0.5 m above the ground after spawning on the landing. Check the capsule
|
||||
offset and the spawn `y`, and verify eye height while walking the steps.
|
||||
- [x] Steps and the landing are visual-only (no collider). The guard walks through them; add
|
||||
simple step colliders or restage the landing so it does not intersect the walk path.
|
||||
- [x] Marquee text (`STEP LINE`) is tiny from the sidewalk. Scale the Label3D up.
|
||||
- [x] Focus ring and name tag are small at 2–3 m. Consider a larger torus / font after a
|
||||
real playtest.
|
||||
- [x] NPC crowd cap (`MAX_LIVE_NPCS`) silently drops routine traffic at 16. Add a short
|
||||
despawn/lifetime for ordinary guests so the entrance does not fill up with standers.
|
||||
|
||||
## Session shell
|
||||
|
||||
Completed 2026-10-31 (working tree). Headless **88/88**; `src/test/smoke_session_shell.gd` and the windowed focus/radial smoke both pass.
|
||||
|
||||
|
||||
The game boots into a live 900 s shift. `Save` declares `settings.json` and never uses it.
|
||||
`V` cycles venues by restarting the shift.
|
||||
|
||||
- [x] Startup menu: pick venue, start shift, quit. Keyboard + click. Replaces `V` as the
|
||||
player-facing path (`V` may stay as a debug bind).
|
||||
- [x] Pause overlay when `Esc` releases the mouse (or a dedicated pause key): resume, restart
|
||||
shift, settings, quit to menu.
|
||||
- [x] Settings: mouse look sensitivity, persist through `Save` / `settings.json`. Load on boot.
|
||||
- [x] Shift review "Run another shift" returns to the venue menu, not a silent restart of the
|
||||
same scenario.
|
||||
|
||||
## Documentation drift
|
||||
|
||||
Completed 2026-10-31 (working tree).
|
||||
|
||||
|
||||
- [x] README controls table, smoke checklist, test count, and implemented/not-yet lists
|
||||
refreshed for the control pass (77 tests).
|
||||
- [x] `PLAN.md` Tech Stack / Planned Commands still say GUT and GitHub Actions; the project
|
||||
uses `src/test/test_runner.gd` and `.gitea/workflows/build.yml`.
|
||||
- [x] MEMORY: door_staff_lines log through `GameManager`; 300 s default removed.
|
||||
|
||||
## Remaining PLAN verbs (radial exists)
|
||||
|
||||
Completed 2026-10-31 (working tree). Headless **98/98**; `src/test/smoke_plan_verbs.gd` exercises all five verbs.
|
||||
|
||||
|
||||
- [x] Eye contact as a Level 1 action (PLAN / glossary). Distinct from raise-hand. Add a
|
||||
radial slice if it ships.
|
||||
- [x] "I recognize this person" is a player decision recorded as evidence, not a pre-attached
|
||||
artifact on every staff/guest type. Staff still auto-recognize when `override_access` is
|
||||
empty (`AccessLoadout`).
|
||||
- [x] Formal warning can activate the body-camera / security-light flag (PLAN Level 2). Overlay
|
||||
is currently always on; toggling should write an incident event.
|
||||
- [x] Blocking stations the guard between the person and the door (PLAN Level 3). Today it only
|
||||
sends `apply_player_action(BLOCKING)`.
|
||||
- [x] "Request reason for entry" as a Level 3 ask, with a short NPC reply from `dialogue_lines`.
|
||||
@@ -16,15 +16,13 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
||||
## Handoff (read this first)
|
||||
|
||||
- Repo: `/run/media/system/DATA/Documents/06-games/security-game`. Godot: `~/.local/bin/godot` (4.3.stable).
|
||||
Tests: `godot --headless --path . --script res://src/test/test_runner.gd` → **77/77**.
|
||||
Tests: `godot --headless --path . --script res://src/test/test_runner.gd` → **88/88**.
|
||||
Session shell smoke: `godot --headless --path . --script res://src/test/smoke_session_shell.gd` → exit 0.
|
||||
Windowed control smoke: `godot --path . --script res://src/test/smoke_focus_radial.gd` → exit 0.
|
||||
- `main` is **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.
|
||||
- `main` is in sync with origin. **v0.2.2** tags the session-shell + world-follow-up commit;
|
||||
CI builds the portable binaries, NSIS installer, and AppImage on the tag push.
|
||||
- Next work: `TODO.md` crowd presentation (rubberneck + panicked NPCs), then emergency aftermath
|
||||
(evacuation, unjustified-call investigation).
|
||||
- Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg.
|
||||
|
||||
## Current State
|
||||
@@ -46,7 +44,38 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
||||
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).
|
||||
`GameManager.MAX_LIVE_NPCS = 16` caps live avatars.
|
||||
- **Session shell (2026-10-31, uncommitted):** the game now boots into `StartMenu`
|
||||
(`src/game/ui/start_menu.gd`, layer 20) instead of a live shift. `GameManager._on_venue_selected()`
|
||||
builds the scenario and calls `_start_shift()`; `_shift_running` gates input and `_refresh_freeze()`.
|
||||
Esc with nothing open now pauses (`PauseOverlay`, layer 18) → resume / restart / settings /
|
||||
quit-to-menu. `SettingsPanel` (layer 22) writes `look_sensitivity` + `hud_font_scale` through
|
||||
`Save.save_settings()` → `user://settings.json`; `Save.load_settings()` merges over
|
||||
`Save.DEFAULT_SETTINGS` on boot and `GameManager._apply_settings()` pushes them into the guard/HUD.
|
||||
Shift review "Run another shift" now calls `_show_start_menu()` (not a silent restart). `V` stays a
|
||||
debug venue bind. Smoke: `src/test/smoke_session_shell.gd` (menu → shift → pause → settings → menu).
|
||||
- **World follow-ups closed (2026-10-31):** skyline backdrop restored with `T_SKYLINE`
|
||||
(`assets/textures/skyline.png`, regenerated RGBA with a transparent sky) and an
|
||||
`TRANSPARENCY_ALPHA_SCISSOR` unshaded material (`_skyline_mat`); steps + landing are a
|
||||
`StaticBody3D` with per-tread `BoxShape3D` colliders; marquee `font_size` 160 / outline 24; focus
|
||||
torus 0.26–0.34 + name tag 48px; `NpcAvatar.ephemeral`/`lifetime_s` despawns routine traffic
|
||||
(authored events stay). Guard spawn height verified on the landing (guard_y=0.84).
|
||||
- **PLAN verbs (2026-10-31, uncommitted):** the radial now has 12 slices
|
||||
(`Stop, Look, Point, No, Ask, Inspect, Wand, Warn, Block, Reason, Recognize, Radio`).
|
||||
`eye_contact` reuses the Level 1 reaction table via `_apply_interaction`. Recognition is a player
|
||||
decision: `NPCController.player_recognized` + `IncidentRecorder.add_access_evidence`, and
|
||||
`GameManager._artifacts_for()` prepends a `RECOGNITION` artifact only after the player declares
|
||||
it. The body camera is a real flag (`GameManager.body_camera_on`, `Hud.set_body_camera`), off by
|
||||
default, `B` toggles it, and a formal warning arms it and writes a `body_camera` incident event.
|
||||
Blocking also calls `_station_between()` to physically place the guard 0.9 m from the NPC toward
|
||||
the entrance. `_request_reason()` logs the NPC's seeded `dialogue_lines` pick.
|
||||
- **Incident recording was dead:** `IncidentRecorder.start_incident()` was never called, so every
|
||||
`add_event`/`add_access_evidence` silently no-opped and the review always showed 0 incidents.
|
||||
`_begin_incident()` now starts one per focused NPC (`has_open_incident()` guard), verbs call
|
||||
`_ensure_incident()`, and `ShiftCoordinator._finish_shift()` closes leftovers as `unresolved`
|
||||
before building the review.
|
||||
- **Asset gotcha:** `assets/textures/game_ready/` is *not* imported (no `.import`). Use the
|
||||
imported `assets/textures/skyline.png` path. Untracked duplicate folder should not be referenced.
|
||||
- Host sign-in follow-up remains deferred. PLAN.md + ADRs 0007–0010 still describe the intended UX.
|
||||
- **Release v0.2.1 is LIVE** (2026-10-31): CI run 1110 built the NSIS installer + AppImage
|
||||
and published — installers/AppImage are on the releases page. Releasing = bump version → tag → push.
|
||||
|
||||
@@ -432,13 +432,15 @@ The venue-driven architecture maps onto Godot as follows:
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit and scenario tests use the GUT (Godot Unit Test) framework.
|
||||
- Unit and scenario tests use a lightweight custom headless runner
|
||||
(`src/test/test_runner.gd`). GUT was evaluated but requires the editor plugin.
|
||||
- Tests run in headless mode so rules, access verification, escalation, and scoring can be verified without rendering or user input.
|
||||
|
||||
## Version Control and CI
|
||||
|
||||
- Source is tracked with git.
|
||||
- GitHub Actions runs headless Godot tests and, in later stages, exports desktop builds.
|
||||
- Gitea Actions (`.gitea/workflows/build.yml`) runs headless Godot tests on push/PR and exports
|
||||
Linux/Windows/macOS desktop builds plus the NSIS installer and AppImage on tags.
|
||||
|
||||
## Planned Commands
|
||||
|
||||
@@ -446,7 +448,7 @@ The following commands apply once the Godot project is scaffolded:
|
||||
|
||||
- Open the editor: `godot --editor --path .`
|
||||
- Run the game: `godot --path .`
|
||||
- Run tests headlessly via GUT.
|
||||
- Run tests headlessly: `godot --headless --path . res://src/test/test_runner.tscn`
|
||||
|
||||
## Export Targets
|
||||
|
||||
|
||||
@@ -28,8 +28,9 @@ The central design goal is simple:
|
||||
|
||||
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
|
||||
|
||||
> **Latest: v0.2.1** (2026-10-31) — built and published by Gitea Actions from the
|
||||
> `v0.2.1` tag, including the Windows installer and Linux AppImage. Releases are built automatically when a `vX.Y.Z` tag is pushed
|
||||
> **Latest: v0.2.3** — the PLAN verbs (eye contact, recognition as evidence, body camera,
|
||||
> blocking that stations the guard, request reason) and working incident recording.
|
||||
> Releases are built automatically when a `vX.Y.Z` tag is pushed
|
||||
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
|
||||
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
|
||||
> (NSIS + appimagetool) and uploads with the portable binaries.
|
||||
@@ -204,6 +205,12 @@ godot --path . --script res://src/test/take_screenshot.gd
|
||||
|
||||
# Windowed control smoke: focus ring + action wheel, saves PNGs, exits 1 on failure
|
||||
godot --path . --script res://src/test/smoke_focus_radial.gd
|
||||
|
||||
# Session-shell smoke: menu → shift → pause → settings → menu (headless or windowed)
|
||||
godot --headless --path . --script res://src/test/smoke_session_shell.gd
|
||||
|
||||
# PLAN-verb smoke: recognize, request reason, body camera, warning, blocking
|
||||
godot --headless --path . --script res://src/test/smoke_plan_verbs.gd
|
||||
```
|
||||
|
||||
The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor.
|
||||
@@ -213,10 +220,12 @@ The unit test runner is the project's own lightweight harness (no editor plugin
|
||||
| Input | Action |
|
||||
| --- | --- |
|
||||
| W/A/S/D + mouse | Move and look |
|
||||
| Space / left click | Action wheel for the focused person |
|
||||
| Space / left click | Action wheel for the focused person (Stop, Look, Point, No, Ask, Inspect, Wand, Warn, Block, Reason, Recognize, Radio) |
|
||||
| A / D or ←/→ | Step through the action wheel (while it is open) |
|
||||
| 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 |
|
||||
| Esc / right click | Close the top panel; Esc with nothing open pauses the shift (Esc again resumes) |
|
||||
| ← / → or ↑ / ↓ | Navigate the venue menu and the pause / settings panels |
|
||||
| Enter | Start the highlighted venue from the menu, or confirm in a panel |
|
||||
| 1 / 2 / 3 | Quick gestures without the wheel: raise hand, point, head shake (Level 1) |
|
||||
| Q | Polite direction (Level 1) |
|
||||
| 4 | Formal warning (Level 2) |
|
||||
@@ -226,9 +235,10 @@ The unit test runner is the project's own lightweight harness (no editor plugin
|
||||
| E | Inspect access artifacts / close inspection |
|
||||
| C | Handheld wand screening (wand venues only) |
|
||||
| A / D | Allow / deny entry during inspection or wand check |
|
||||
| V | Cycle venue: Hollywood Blvd → The Joke Shop → TV Studio (restarts shift) |
|
||||
| V | Cycle venue (debug shortcut; restart via V restarts the current shift) |
|
||||
| Tab | Cycle target among nearby NPCs (aim-free alternative) |
|
||||
| G | Toggle flashlight (widens NPC perception at night) |
|
||||
| B | Toggle the body camera; a formal warning switches it on automatically |
|
||||
| L | Lock / unlock the restricted door (thwarts forced entry) |
|
||||
| F | Toggle debug overlay |
|
||||
|
||||
@@ -253,31 +263,48 @@ Implemented so far:
|
||||
- Godot 4.3 project scaffold, input map, procedural world builder
|
||||
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
|
||||
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
|
||||
- Headless unit tests: **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)
|
||||
- Headless unit tests: **98 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording)
|
||||
- Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event
|
||||
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
|
||||
- Control pass: focus cone on NPC heads with a billboarded gold ring and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`)
|
||||
- Session shell: venue start menu (keyboard + mouse), Esc pause overlay (resume / restart / settings / quit to menu), settings panel with mouse-look sensitivity and HUD font scale persisted to `user://settings.json`, and "Run another shift" returning to the venue menu
|
||||
- World presentation: skyline backdrop rendered with an alpha material, walkable step and landing colliders, larger marquee text, and larger focus ring / name tag
|
||||
- Routine traffic despawns after a while at its waypoint so the crowd cap no longer fills with idle standers
|
||||
- PLAN verbs: eye contact (distinct Level 1 cue), recognizing a person as a player decision recorded as access evidence, a body-camera flag that a formal warning arms (and `B` toggles), blocking that physically stations the guard between the person and the door, and a Level 3 request-reason ask answered from the NPC's `dialogue_lines`
|
||||
- Incidents are now actually opened on focus and closed at shift end, so player actions, access evidence, escalation levels, and unresolved interactions reach the review (previously every recorded event was dropped)
|
||||
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
|
||||
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
|
||||
|
||||
Not yet implemented: body-camera replay timeline, 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`).
|
||||
Not yet implemented: body-camera replay timeline, crowd rubbernecking, emergency aftermath, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
|
||||
|
||||
### Smoke Test Checklist
|
||||
|
||||
1. `godot --path .` starts the game without script errors.
|
||||
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.
|
||||
4. Looking at an NPC inside the focus cone shows a gold head ring, the person's name, and the HUD target line.
|
||||
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`/`C` open the inspection/wand panels from the wheel and freeze movement until they close; walking never fires the wand.
|
||||
7. Pressing `Q` produces a reaction log entry and reputation feedback.
|
||||
8. `E` opens access inspection showing venue-correct artifacts and policy result; `A`/`D` record a decision.
|
||||
9. `6` opens the assistance form; submitting shows dispatch feedback.
|
||||
10. `R` opens the emergency form; the button must be held ~1.6s or the call cancels.
|
||||
11. `G` toggles the flashlight (indicator appears in the HUD); `L` toggles the door lock (indicator turns red when locked).
|
||||
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.
|
||||
1. `godot --path .` starts the game on the venue menu without script errors.
|
||||
2. Choosing a venue (click or ↑/↓ + Enter) starts that venue's shift and hides the menu.
|
||||
3. The guard moves and looks around; the building, steps, and sidewalk are visible.
|
||||
4. The guard's camera sits at eye height and the guard stands on the landing, not inside the building.
|
||||
5. Walking into the steps carries the guard up onto the landing (steps have colliders).
|
||||
6. The skyline backdrop shows silhouettes against the sky, not an opaque black slab.
|
||||
7. The `STEP LINE` marquee is readable from the sidewalk.
|
||||
8. NPCs spawn and approach the entrance during the first minutes.
|
||||
9. Looking at an NPC inside the focus cone shows a gold head ring, the person's name, and the HUD target line.
|
||||
10. `Space` or left click opens the action wheel; `A`/`D` highlight a slice, `Space` confirms it, `Esc`/right click cancels without a log entry.
|
||||
11. `E`/`C` open the inspection/wand panels from the wheel and freeze movement until they close; walking never fires the wand.
|
||||
12. Pressing `Q` produces a reaction log entry and reputation feedback.
|
||||
13. `E` opens access inspection showing venue-correct artifacts and policy result; `A`/`D` record a decision.
|
||||
14. `6` opens the assistance form; submitting shows dispatch feedback.
|
||||
15. `R` opens the emergency form; the button must be held ~1.6s or the call cancels.
|
||||
16. `G` toggles the flashlight (indicator appears in the HUD); `L` toggles the door lock (indicator turns red when locked).
|
||||
17. `F` toggles the debug overlay with NPC states and shift stats.
|
||||
18. The wheel's `Look` slice is eye contact (Level 1) and logs as its own action.
|
||||
19. `Reason` asks why the person is there and logs their `dialogue_lines` reply.
|
||||
20. `Recognize` records the guard's recognition as access evidence; pressing it twice says so.
|
||||
21. `Warn` turns the body camera on (REC overlay appears); `B` toggles it manually.
|
||||
22. `Block` moves the guard between the person and the door.
|
||||
23. The shift review reports the incident count, including interactions left unresolved.
|
||||
24. `Esc` with nothing open pauses the shift; resume / restart / settings / quit-to-menu all work, and settings changes persist across a restart.
|
||||
25. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run).
|
||||
26. When the shift timer reaches 0, the shift review screen shows grade, incident summary, and persisted shift history; "Run another shift" returns to the venue menu.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
|
||||
@@ -1,52 +1,11 @@
|
||||
# TODO
|
||||
|
||||
Godot 4.3 prototype. Release **v0.2.1** is live on the host. The working tree (uncommitted)
|
||||
has the access-loadout fix and the ADR-0007–0010 control pass; headless suite **77/77** and a
|
||||
windowed focus/radial smoke pass.
|
||||
Godot 4.3 prototype. Release **v0.2.3** is tagged; CI builds the installers and AppImage.
|
||||
Headless suite **98/98**, plus `smoke_session_shell.gd`, `smoke_plan_verbs.gd`, and the windowed
|
||||
focus/radial smoke.
|
||||
|
||||
Host `REQUIRE_SIGNIN_VIEW` stays deferred.
|
||||
|
||||
## World and presentation follow-ups (found during the control smoke)
|
||||
|
||||
- [ ] The skyline backdrop was removed: `assets/textures/ai/game_ready/skyline.png` has an
|
||||
opaque black background, so the planes rendered as black slabs with window dots. Re-add
|
||||
the backdrop only with a transparent asset or a keyed shader.
|
||||
- [ ] Guard settles ~0.5 m above the ground after spawning on the landing. Check the capsule
|
||||
offset and the spawn `y`, and verify eye height while walking the steps.
|
||||
- [ ] Steps and the landing are visual-only (no collider). The guard walks through them; add
|
||||
simple step colliders or restage the landing so it does not intersect the walk path.
|
||||
- [ ] Marquee text (`STEP LINE`) is tiny from the sidewalk. Scale the Label3D up.
|
||||
- [ ] Focus ring and name tag are small at 2–3 m. Consider a larger torus / font after a
|
||||
real playtest.
|
||||
- [ ] NPC crowd cap (`MAX_LIVE_NPCS`) silently drops routine traffic at 16. Add a short
|
||||
despawn/lifetime for ordinary guests so the entrance does not fill up with standers.
|
||||
|
||||
## Session shell
|
||||
|
||||
The game boots into a live 900 s shift. `Save` declares `settings.json` and never uses it.
|
||||
`V` cycles venues by restarting the shift.
|
||||
|
||||
- [ ] Startup menu: pick venue, start shift, quit. Keyboard + click. Replaces `V` as the
|
||||
player-facing path (`V` may stay as a debug bind).
|
||||
- [ ] Pause overlay when `Esc` releases the mouse (or a dedicated pause key): resume, restart
|
||||
shift, settings, quit to menu.
|
||||
- [ ] Settings: mouse look sensitivity, persist through `Save` / `settings.json`. Load on boot.
|
||||
- [ ] Shift review "Run another shift" returns to the venue menu, not a silent restart of the
|
||||
same scenario.
|
||||
|
||||
## Remaining PLAN verbs (radial exists)
|
||||
|
||||
- [ ] 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
|
||||
artifact on every staff/guest type. Staff still auto-recognize when `override_access` is
|
||||
empty (`AccessLoadout`).
|
||||
- [ ] Formal warning can activate the body-camera / security-light flag (PLAN Level 2). Overlay
|
||||
is currently always on; toggling should write an incident event.
|
||||
- [ ] Blocking stations the guard between the person and the door (PLAN Level 3). Today it only
|
||||
sends `apply_player_action(BLOCKING)`.
|
||||
- [ ] "Request reason for entry" as a Level 3 ask, with a short NPC reply from `dialogue_lines`.
|
||||
|
||||
## Crowd presentation
|
||||
|
||||
`CrowdSystem` is a mood/attention float. PLAN wants people watching, gathering, and panicking.
|
||||
@@ -101,14 +60,6 @@ is in the working tree. Bodies still idle-stand at those spots.
|
||||
- [ ] Building: "activate body camera" and "request reason" once those verbs exist, used in
|
||||
at least one authored event.
|
||||
|
||||
## Documentation drift
|
||||
|
||||
- [x] README controls table, smoke checklist, test count, and implemented/not-yet lists
|
||||
refreshed for the control pass (77 tests).
|
||||
- [ ] `PLAN.md` Tech Stack / Planned Commands still say GUT and GitHub Actions; the project
|
||||
uses `src/test/test_runner.gd` and `.gitea/workflows/build.yml`.
|
||||
- [x] MEMORY: door_staff_lines log through `GameManager`; 300 s default removed.
|
||||
|
||||
## Distribution follow-up (host config, deferred)
|
||||
|
||||
Verified via API+host credentials (2026-10-31):
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
+13
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"hud.controls": "LMB/Space action menu · A/D or arrows highlight · Space confirm · Esc/RMB cancel · R emergency · E inspect · C wand · Q polite · 1-6 shortcuts · Tab cycle · G light · L lock · V venue · F debug",
|
||||
"hud.controls": "LMB/Space action menu · A/D or arrows highlight · Space confirm · Esc/RMB cancel · R emergency · E inspect · C wand · Q polite · B body camera · 1-6 shortcuts · Tab cycle · G light · L lock · V venue · F debug",
|
||||
"radial.raise_hand": "Stop",
|
||||
"radial.eye_contact": "Look",
|
||||
"radial.point": "Point",
|
||||
"radial.head_shake": "No",
|
||||
"radial.polite": "Ask",
|
||||
@@ -8,6 +9,8 @@
|
||||
"radial.wand": "Wand",
|
||||
"radial.warning": "Warn",
|
||||
"radial.block": "Block",
|
||||
"radial.reason": "Reason",
|
||||
"radial.recognize": "Recognize",
|
||||
"radial.radio": "Radio",
|
||||
"hud.escalation_prefix": "Escalation: %s",
|
||||
"hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs",
|
||||
@@ -42,6 +45,15 @@
|
||||
"react.return_later": "%s leaves but may come back.",
|
||||
"react.call_someone": "%s calls someone for help.",
|
||||
"react.none": "No reaction (yet).",
|
||||
"bodycam.on": "Body camera on.",
|
||||
"bodycam.off": "Body camera off.",
|
||||
"bodycam.warning": "Formal warning recorded on the body camera.",
|
||||
"recognition.declared": "You tell %s you recognize them. Logged as evidence.",
|
||||
"recognition.already": "You have already recognized %s this shift.",
|
||||
"reason.ask": "You ask %s why they are here.",
|
||||
"reason.reply": "%s: \"%s\"",
|
||||
"reason.silent": "%s says nothing and looks away.",
|
||||
"station.blocking": "You step between %s and the door.",
|
||||
"perception.near": "%s notices you standing close.",
|
||||
"perception.aware": "%s notices you watching.",
|
||||
"door.locked": "The door is now locked.",
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ config_version=5
|
||||
|
||||
config/name="Step Line"
|
||||
config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
|
||||
config/version="0.2.1"
|
||||
config/version="0.2.3"
|
||||
run/main_scene="res://scenes/main.tscn"
|
||||
config/features=PackedStringArray("4.3", "GL Compatibility")
|
||||
|
||||
|
||||
@@ -37,3 +37,29 @@ func save_shift_result(result: Dictionary) -> Error:
|
||||
var results := load_shift_results()
|
||||
results.append(result)
|
||||
return _save_json(SHIFT_RESULTS_FILE, results)
|
||||
|
||||
## Defaults for player settings. Kept in code so a missing or partial
|
||||
## settings.json still produces a usable config.
|
||||
const DEFAULT_SETTINGS := {
|
||||
"look_sensitivity": 0.0022,
|
||||
"hud_font_scale": 1.0,
|
||||
"ai_difficulty": "normal",
|
||||
}
|
||||
|
||||
## Load player settings, merged over the defaults. Returns a Dictionary.
|
||||
func load_settings() -> Dictionary:
|
||||
var merged: Dictionary = DEFAULT_SETTINGS.duplicate(true)
|
||||
var data = _load_json(SETTINGS_FILE)
|
||||
if data is Dictionary:
|
||||
for key in data.keys():
|
||||
if merged.has(key):
|
||||
merged[key] = data[key]
|
||||
return merged
|
||||
|
||||
## Persist player settings. Returns Error.
|
||||
func save_settings(settings: Dictionary) -> Error:
|
||||
var merged: Dictionary = DEFAULT_SETTINGS.duplicate(true)
|
||||
for key in settings.keys():
|
||||
if merged.has(key):
|
||||
merged[key] = settings[key]
|
||||
return _save_json(SETTINGS_FILE, merged)
|
||||
@@ -78,6 +78,19 @@ func close_incident(npc_id: String, outcome: String) -> Dictionary:
|
||||
func open_incidents_count() -> int:
|
||||
return _open_incidents.size()
|
||||
|
||||
## True when an incident is already open for this NPC (avoids duplicating one
|
||||
## incident every time the player re-focuses the same person).
|
||||
func has_open_incident(npc_id: String) -> bool:
|
||||
return _open_incidents.has(npc_id)
|
||||
|
||||
## Close every still-open incident at shift end so the review does not silently
|
||||
## drop interactions the player never resolved. Returns how many were closed.
|
||||
func close_all_open(outcome: String = "unresolved") -> int:
|
||||
var ids: Array = _open_incidents.keys()
|
||||
for npc_id in ids:
|
||||
close_incident(str(npc_id), outcome)
|
||||
return ids.size()
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"incidents": incidents.duplicate(true),
|
||||
|
||||
@@ -34,6 +34,11 @@ var guard_noticed := false
|
||||
|
||||
## Whether this NPC has passed the handheld wand screening (The Joke Shop).
|
||||
var screened := false
|
||||
## Whether the guard has declared they recognize this person (a player decision
|
||||
## recorded as evidence, PLAN.md: Authorized Access).
|
||||
var player_recognized := false
|
||||
## Last reply the NPC gave when asked for their reason for entry.
|
||||
var last_reason_reply := ""
|
||||
## Event-level access payload (`has_id`, `has_ticket`, statuses). Empty means
|
||||
## venue/type defaults from AccessLoadout.
|
||||
var access_override: Dictionary = {}
|
||||
@@ -150,6 +155,16 @@ func tick_return(delta: float) -> bool:
|
||||
func decide_leave() -> bool:
|
||||
return leave_requested
|
||||
|
||||
## Level 3 ask: request the person's reason for entry (PLAN.md). Pure and
|
||||
## headless-testable; returns the line this NPC answers with ("" if silent).
|
||||
func request_reason() -> String:
|
||||
var lines: Array[String] = profile.dialogue_lines if profile else []
|
||||
if lines.is_empty():
|
||||
last_reason_reply = ""
|
||||
return ""
|
||||
last_reason_reply = lines[rng.randi_range(0, lines.size() - 1)]
|
||||
return last_reason_reply
|
||||
|
||||
## Perception pass: given the guard's distance (meters), return a perception
|
||||
## result describing how the NPC reacts to the guard's presence. Pure and
|
||||
## headless-testable; the presentation layer feeds it the real distance.
|
||||
|
||||
@@ -119,6 +119,8 @@ func _finish_shift() -> void:
|
||||
return
|
||||
_shift_finished = true
|
||||
running = false
|
||||
# Interactions the player never resolved still belong in this shift's review.
|
||||
recorder.close_all_open("unresolved")
|
||||
var review := build_review()
|
||||
event_bus.shift_state_changed.emit({"running": false, "review": review})
|
||||
shift_ended.emit(review)
|
||||
|
||||
+290
-8
@@ -23,10 +23,14 @@ 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 ShiftReview := preload("res://src/game/ui/shift_review.gd")
|
||||
const StartMenu := preload("res://src/game/ui/start_menu.gd")
|
||||
const PauseOverlay := preload("res://src/game/ui/pause_overlay.gd")
|
||||
const SettingsPanel := preload("res://src/game/ui/settings_panel.gd")
|
||||
|
||||
var world_builder: WorldBuilder = null
|
||||
var door := DoorState.new()
|
||||
var flashlight_on := false
|
||||
var body_camera_on := false
|
||||
var coordinator: ShiftCoordinator = null
|
||||
var guard: GuardController = null
|
||||
var _sun: DirectionalLight3D = null
|
||||
@@ -62,6 +66,11 @@ var hud: Hud = null
|
||||
var radial: ActionRadial = null
|
||||
var radio_panel: RadioPanel = null
|
||||
var shift_review_panel: ShiftReview = null
|
||||
var start_menu: StartMenu = null
|
||||
var pause_overlay: PauseOverlay = null
|
||||
var settings_panel: SettingsPanel = null
|
||||
var _shift_running := false
|
||||
var player_settings: Dictionary = {}
|
||||
var _focused_avatar: Node3D = null
|
||||
var audio: AudioFeedback = null
|
||||
var verifier := AccessVerifier.new()
|
||||
@@ -99,6 +108,7 @@ var _nearby_index := -1
|
||||
|
||||
func _ready() -> void:
|
||||
_door_rng.randomize()
|
||||
player_settings = Save.load_settings() if Save else {}
|
||||
scene_root = Node3D.new()
|
||||
scene_root.name = "SceneRoot"
|
||||
add_child(scene_root)
|
||||
@@ -115,6 +125,7 @@ func _ready() -> void:
|
||||
guard.rotation.y = PI
|
||||
guard.picked_target.connect(_on_guard_target)
|
||||
guard.cleared_target.connect(_on_guard_clear)
|
||||
_apply_settings(player_settings)
|
||||
|
||||
selected_scenario = _build_scenario(active_scenario_id)
|
||||
scenario_duration_s = selected_scenario.shift_duration_s
|
||||
@@ -125,9 +136,10 @@ func _ready() -> void:
|
||||
coordinator.radio.responder_arrived.connect(_on_responder_arrived)
|
||||
|
||||
_build_ui()
|
||||
coordinator.start()
|
||||
_last_phase = -1
|
||||
_apply_phase_lighting()
|
||||
# Boot into the venue menu; the shift starts when the player picks a venue.
|
||||
_show_start_menu()
|
||||
|
||||
var selected_scenario: VenueScenario = null
|
||||
|
||||
@@ -179,20 +191,109 @@ func switch_scenario(id: String) -> void:
|
||||
restart_shift()
|
||||
|
||||
func restart_shift() -> void:
|
||||
shift_review_panel.hide()
|
||||
if shift_review_panel:
|
||||
shift_review_panel.hide()
|
||||
_reset_shift_state()
|
||||
_start_shift()
|
||||
|
||||
## Clear all per-shift runtime state (NPCs, incidents, timers).
|
||||
func _reset_shift_state() -> void:
|
||||
for n in npcs:
|
||||
n.queue_free()
|
||||
if is_instance_valid(n):
|
||||
n.queue_free()
|
||||
npcs.clear()
|
||||
npc_counter = 0
|
||||
inspector_current_events = []
|
||||
current_incident_active = false
|
||||
current_incident_npc_id = ""
|
||||
targeted_npc = null
|
||||
_focused_avatar = null
|
||||
_nearby_index = -1
|
||||
inspection_open = false
|
||||
wand_open = false
|
||||
_wand_cooldown = 0.0
|
||||
body_camera_on = false
|
||||
if hud:
|
||||
hud.set_body_camera(false)
|
||||
scenario_duration_s = selected_scenario.shift_duration_s
|
||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||
coordinator.start()
|
||||
_last_phase = -1
|
||||
_apply_phase_lighting()
|
||||
|
||||
## Begin (or resume) the shift clock and recapture the mouse.
|
||||
func _start_shift() -> void:
|
||||
_shift_running = true
|
||||
coordinator.start()
|
||||
if hud:
|
||||
hud.set_shift_state(coordinator, selected_scenario)
|
||||
_refresh_freeze()
|
||||
|
||||
func _show_start_menu() -> void:
|
||||
_shift_running = false
|
||||
if pause_overlay:
|
||||
pause_overlay.close()
|
||||
if shift_review_panel:
|
||||
shift_review_panel.hide()
|
||||
if guard:
|
||||
guard.input_locked = true
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||
if start_menu:
|
||||
start_menu.open()
|
||||
|
||||
func _on_venue_selected(scenario_id: String) -> void:
|
||||
if start_menu and start_menu.visible:
|
||||
start_menu.close()
|
||||
active_scenario_id = scenario_id
|
||||
_venue_index = maxi(0, VENUE_ORDER.find(scenario_id))
|
||||
selected_scenario = _build_scenario(scenario_id)
|
||||
if hud:
|
||||
hud.set_wand_icon(false)
|
||||
_reset_shift_state()
|
||||
_start_shift()
|
||||
|
||||
## Apply loaded settings to the guard and HUD.
|
||||
func _apply_settings(settings: Dictionary) -> void:
|
||||
player_settings = settings
|
||||
if guard:
|
||||
guard.look_sensitivity = float(settings.get("look_sensitivity", 0.0022))
|
||||
if hud and hud.has_method("set_font_scale"):
|
||||
hud.set_font_scale(float(settings.get("hud_font_scale", 1.0)))
|
||||
|
||||
func _on_settings_applied(settings: Dictionary) -> void:
|
||||
_apply_settings(settings)
|
||||
if start_menu and start_menu.visible:
|
||||
start_menu.open()
|
||||
elif _shift_running:
|
||||
_open_pause()
|
||||
|
||||
func _open_settings() -> void:
|
||||
if start_menu:
|
||||
start_menu.close()
|
||||
if pause_overlay:
|
||||
pause_overlay.close()
|
||||
if settings_panel:
|
||||
settings_panel.open(player_settings)
|
||||
|
||||
func _open_pause() -> void:
|
||||
if pause_overlay == null or not _shift_running:
|
||||
return
|
||||
if _close_top_modal():
|
||||
pass
|
||||
pause_overlay.open()
|
||||
if guard:
|
||||
guard.input_locked = true
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||
|
||||
func _close_pause() -> void:
|
||||
if pause_overlay:
|
||||
pause_overlay.close()
|
||||
_refresh_freeze()
|
||||
|
||||
func _quit_to_menu() -> void:
|
||||
if pause_overlay:
|
||||
pause_overlay.close()
|
||||
_show_start_menu()
|
||||
|
||||
var inspector_current_events: Array = []
|
||||
|
||||
func _build_ui() -> void:
|
||||
@@ -216,7 +317,32 @@ func _build_ui() -> void:
|
||||
shift_review_panel = ShiftReview.new()
|
||||
add_child(shift_review_panel)
|
||||
shift_review_panel.hide()
|
||||
shift_review_panel.restart_requested.connect(restart_shift)
|
||||
shift_review_panel.restart_requested.connect(_on_review_restart)
|
||||
shift_review_panel.visible = false
|
||||
|
||||
pause_overlay = PauseOverlay.new()
|
||||
add_child(pause_overlay)
|
||||
pause_overlay.hide()
|
||||
pause_overlay.resume_requested.connect(_close_pause)
|
||||
pause_overlay.restart_requested.connect(restart_shift)
|
||||
pause_overlay.settings_requested.connect(_open_settings)
|
||||
pause_overlay.quit_to_menu_requested.connect(_quit_to_menu)
|
||||
|
||||
settings_panel = SettingsPanel.new()
|
||||
add_child(settings_panel)
|
||||
settings_panel.hide()
|
||||
settings_panel.applied.connect(_on_settings_applied)
|
||||
|
||||
start_menu = StartMenu.new()
|
||||
add_child(start_menu)
|
||||
start_menu.hide()
|
||||
start_menu.venue_selected.connect(_on_venue_selected)
|
||||
start_menu.settings_requested.connect(_open_settings)
|
||||
start_menu.quit_requested.connect(func(): get_tree().quit())
|
||||
|
||||
## "Run another shift" on the review screen returns to the venue menu.
|
||||
func _on_review_restart() -> void:
|
||||
_show_start_menu()
|
||||
|
||||
func _add_crosshair() -> void:
|
||||
var layer := CanvasLayer.new()
|
||||
@@ -309,6 +435,9 @@ func _spawn_npc(profile: NpcProfile, intent: String, npc_id: String, event: Dict
|
||||
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)
|
||||
# Authored events stay for the whole shift; routine traffic cycles out.
|
||||
avatar.ephemeral = event.get("kind", "routine") == "routine"
|
||||
avatar.lifetime_s = 45.0 + float((npc_counter * 17) % 60)
|
||||
scene_root.add_child(avatar)
|
||||
npcs.append(avatar)
|
||||
|
||||
@@ -362,6 +491,7 @@ func _responder_profile() -> NpcProfile:
|
||||
return p
|
||||
|
||||
func _on_shift_ended(review: Dictionary) -> void:
|
||||
_shift_running = false
|
||||
# Persist the finished shift locally (Save autoload, user:// JSON).
|
||||
if Save:
|
||||
Save.save_shift_result(review)
|
||||
@@ -394,6 +524,11 @@ func _begin_incident(avatar: Node3D) -> void:
|
||||
current_incident_npc_id = controller.npc_id
|
||||
current_incident_active = true
|
||||
current_npc_truth = _read_npc_truth(controller)
|
||||
# Open a structured incident so player actions, access evidence, and
|
||||
# escalation levels are actually recorded (ADR-0005). Without this the
|
||||
# recorder drops every event and the review reports zero incidents.
|
||||
if not coordinator.recorder.has_open_incident(controller.npc_id):
|
||||
coordinator.recorder.start_incident(controller.npc_id, "player_focus")
|
||||
var profile: Object = controller.profile
|
||||
var label := "%s — %s" % [
|
||||
profile.display_name if profile else "?",
|
||||
@@ -476,6 +611,8 @@ func _handle_key(event: InputEvent) -> void:
|
||||
_toggle_flashlight()
|
||||
KEY_L:
|
||||
_toggle_door_lock()
|
||||
KEY_B:
|
||||
_toggle_body_camera()
|
||||
KEY_V:
|
||||
_cycle_venue()
|
||||
_:
|
||||
@@ -510,13 +647,22 @@ func _handle_action_key(keycode: Key) -> void:
|
||||
_:
|
||||
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.
|
||||
## `cancel` (Esc, RMB) closes the top modal. With nothing open, Esc pauses the
|
||||
## shift (or resumes it when paused); 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 settings_panel and settings_panel.visible:
|
||||
return
|
||||
if pause_overlay and pause_overlay.visible:
|
||||
_close_pause()
|
||||
return
|
||||
if _shift_running and not (start_menu and start_menu.visible):
|
||||
_open_pause()
|
||||
return
|
||||
if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||
else:
|
||||
@@ -585,6 +731,8 @@ func _on_radial_confirmed(slice_id: String) -> void:
|
||||
match slice_id:
|
||||
"raise_hand":
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand")
|
||||
"eye_contact":
|
||||
_eye_contact()
|
||||
"point":
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point")
|
||||
"head_shake":
|
||||
@@ -599,6 +747,10 @@ func _on_radial_confirmed(slice_id: String) -> void:
|
||||
_apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning")
|
||||
"block":
|
||||
_apply_interaction(Rules.Level.BLOCKING, "blocking")
|
||||
"reason":
|
||||
_request_reason()
|
||||
"recognize":
|
||||
_recognize_target()
|
||||
"radio":
|
||||
_open_radio(false)
|
||||
|
||||
@@ -610,6 +762,12 @@ func _refresh_freeze() -> void:
|
||||
freeze = true
|
||||
if shift_review_panel and shift_review_panel.visible:
|
||||
freeze = true
|
||||
if pause_overlay and pause_overlay.visible:
|
||||
freeze = true
|
||||
if settings_panel and settings_panel.visible:
|
||||
freeze = true
|
||||
if not _shift_running:
|
||||
freeze = true
|
||||
if guard:
|
||||
guard.input_locked = freeze
|
||||
if freeze:
|
||||
@@ -627,6 +785,29 @@ func _toggle_flashlight() -> void:
|
||||
hud.set_flashlight(flashlight_on)
|
||||
hud.log(Strings.lookup("flashlight.on") if flashlight_on else Strings.lookup("flashlight.off"))
|
||||
|
||||
## Toggle the body camera (PLAN Level 2). The overlay is only visible while the
|
||||
## camera is on, and turning it on or off is written to the incident log.
|
||||
func _toggle_body_camera() -> void:
|
||||
_set_body_camera(not body_camera_on, "manual")
|
||||
|
||||
func _set_body_camera(on: bool, reason: String) -> void:
|
||||
if body_camera_on == on:
|
||||
return
|
||||
body_camera_on = on
|
||||
if hud:
|
||||
hud.set_body_camera(on)
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller != null:
|
||||
_ensure_incident(controller)
|
||||
if coordinator and current_incident_active:
|
||||
coordinator.recorder.add_event(current_incident_npc_id, {
|
||||
"type": "body_camera",
|
||||
"on": on,
|
||||
"reason": reason,
|
||||
})
|
||||
if hud:
|
||||
hud.log(Strings.lookup("bodycam.on") if on else Strings.lookup("bodycam.off"))
|
||||
|
||||
## Lock or unlock the restricted door (PLAN.md: Level 3). Locked doors thwart
|
||||
## FORCE_ENTRY attempts during the reaction flow.
|
||||
func _toggle_door_lock() -> void:
|
||||
@@ -678,6 +859,7 @@ func _apply_interaction(level: int, action_name: String) -> void:
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller == null or coordinator == null:
|
||||
return
|
||||
_ensure_incident(controller)
|
||||
var outcome: Dictionary = controller.apply_player_action(level)
|
||||
var npc_id: String = controller.npc_id
|
||||
coordinator.recorder.add_event(npc_id, {
|
||||
@@ -688,11 +870,88 @@ func _apply_interaction(level: int, action_name: String) -> void:
|
||||
coordinator.recorder.add_escalation(npc_id, level, "%s on %s" % [action_name, npc_id])
|
||||
if level >= Rules.Level.FORMAL_WARNING:
|
||||
coordinator.reputation.apply("prop", -1.5, "escalation L%d %s" % [level, action_name])
|
||||
# A formal warning is the documented point where the body camera / security
|
||||
# light goes on: it creates a record of the interaction (PLAN Level 2).
|
||||
if level == Rules.Level.FORMAL_WARNING and not body_camera_on:
|
||||
_set_body_camera(true, "formal warning")
|
||||
# Blocking also means physically standing between the person and the door
|
||||
# (PLAN Level 3), not just sending the escalation level.
|
||||
if level == Rules.Level.BLOCKING:
|
||||
_station_between(controller, controller.profile.display_name if controller.profile else "them")
|
||||
hud.set_escalation(level)
|
||||
if audio:
|
||||
audio.play_escalation(level)
|
||||
_react_to_outcome(controller, outcome)
|
||||
|
||||
## Level 1 eye contact: a distinct, quieter cue than raise-hand (PLAN.md). It
|
||||
## uses the same Level 1 reaction table but is logged as its own action.
|
||||
func _eye_contact() -> void:
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "eye contact")
|
||||
|
||||
## "I recognize this person" is the player's own decision, recorded as access
|
||||
## evidence rather than pre-attached to every staff/guest type (PLAN.md).
|
||||
func _recognize_target() -> void:
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller == null:
|
||||
hud.log(Strings.lookup("hud.aim_first"))
|
||||
return
|
||||
var name: String = controller.profile.display_name if controller.profile else "them"
|
||||
if controller.player_recognized:
|
||||
hud.log(Strings.fmt("recognition.already", [name]))
|
||||
return
|
||||
_ensure_incident(controller)
|
||||
controller.player_recognized = true
|
||||
coordinator.recorder.add_access_evidence(controller.npc_id, {
|
||||
"kind": "recognition",
|
||||
"source": "guard_decision",
|
||||
"recognized": true,
|
||||
})
|
||||
coordinator.recorder.add_event(controller.npc_id, {
|
||||
"type": "recognition_decision",
|
||||
"recognized": true,
|
||||
"source": "player",
|
||||
})
|
||||
hud.log(Strings.fmt("recognition.declared", [name]))
|
||||
|
||||
## Level 3 ask: request the person's reason for entry. The reply comes from the
|
||||
## NPC's authored `dialogue_lines`; silence is a valid, telling answer.
|
||||
func _request_reason() -> void:
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller == null:
|
||||
hud.log(Strings.lookup("hud.aim_first"))
|
||||
return
|
||||
var name: String = controller.profile.display_name if controller.profile else "They"
|
||||
_ensure_incident(controller)
|
||||
var reply: String = controller.request_reason()
|
||||
coordinator.recorder.add_event(controller.npc_id, {
|
||||
"type": "reason_requested",
|
||||
"reply": reply,
|
||||
})
|
||||
hud.set_escalation(Rules.Level.BLOCKING)
|
||||
hud.log(Strings.fmt("reason.ask", [name]))
|
||||
if reply == "":
|
||||
hud.log(Strings.fmt("reason.silent", [name]))
|
||||
else:
|
||||
hud.log(Strings.fmt("reason.reply", [name, reply]))
|
||||
|
||||
## Physically move the guard between the person and the door (PLAN Level 3).
|
||||
func _station_between(controller: Object, name: String) -> void:
|
||||
if guard == null or targeted_npc == null:
|
||||
return
|
||||
var npc_pos: Vector3 = (targeted_npc as Node3D).global_position
|
||||
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
|
||||
var to_door := door_pos - npc_pos
|
||||
to_door.y = 0.0
|
||||
if to_door.length() < 0.05:
|
||||
return
|
||||
var station: Vector3 = npc_pos + to_door.normalized() * 0.9
|
||||
station.y = guard.global_position.y
|
||||
guard.global_position = station
|
||||
var look_at_point := Vector3(npc_pos.x, guard.global_position.y, npc_pos.z)
|
||||
if (look_at_point - guard.global_position).length() > 0.05:
|
||||
guard.look_at(look_at_point, Vector3.UP)
|
||||
hud.log(Strings.fmt("station.blocking", [name]))
|
||||
|
||||
func _react_to_outcome(controller: Object, outcome: Dictionary) -> void:
|
||||
var reaction: int = outcome.get("reaction", -1)
|
||||
var name: String = (controller.profile.display_name if controller.profile else "Person")
|
||||
@@ -938,7 +1197,19 @@ func _artifacts_for(controller: Object) -> Array:
|
||||
var profile: NpcProfile = controller.profile as NpcProfile
|
||||
var override: Dictionary = controller.access_override
|
||||
var policy = selected_scenario.access_policy if selected_scenario else null
|
||||
return AccessLoadout.build(profile, policy, override)
|
||||
var artifacts: Array = AccessLoadout.build(profile, policy, override)
|
||||
# The guard's own recognition is evidence the player created, so it is
|
||||
# appended here rather than baked into every staff/guest artifact set.
|
||||
if controller.player_recognized and not _has_recognition_artifact(artifacts):
|
||||
artifacts.push_front(AccessArtifact.new(
|
||||
Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.VALID, {"known": true, "source": "player"}))
|
||||
return artifacts
|
||||
|
||||
func _has_recognition_artifact(artifacts: Array) -> bool:
|
||||
for artifact in artifacts:
|
||||
if artifact is AccessArtifact and artifact.kind == Rules.AccessKind.RECOGNITION:
|
||||
return true
|
||||
return false
|
||||
|
||||
func _read_npc_truth(controller: Object) -> Dictionary:
|
||||
var profile: Object = controller.profile
|
||||
@@ -955,6 +1226,17 @@ func _targeted_controller() -> Object:
|
||||
return targeted_npc.get_npc_controller()
|
||||
return null
|
||||
|
||||
## Guarantee an open incident for this NPC before recording evidence, so verbs
|
||||
## work even if the player acted without a prior focus change.
|
||||
func _ensure_incident(controller: Object) -> void:
|
||||
if controller == null or coordinator == null:
|
||||
return
|
||||
if coordinator.recorder.has_open_incident(controller.npc_id):
|
||||
return
|
||||
coordinator.recorder.start_incident(controller.npc_id, "player_action")
|
||||
current_incident_npc_id = controller.npc_id
|
||||
current_incident_active = true
|
||||
|
||||
func _close_incident(outcome: String) -> void:
|
||||
if current_incident_active and not current_incident_npc_id.is_empty():
|
||||
coordinator.recorder.close_incident(current_incident_npc_id, outcome)
|
||||
|
||||
+21
-5
@@ -12,6 +12,11 @@ var target_position: Vector3 = Vector3.ZERO
|
||||
var door_position: Vector3 = Vector3(0, 0.5, 1.2)
|
||||
var awaiting_interaction := false
|
||||
var arrived := false
|
||||
## Seconds idle at an arrived waypoint. Ordinary traffic lives for a while, then
|
||||
## despawns so the entrance does not fill with standers (TODO: NPC crowd cap).
|
||||
var idle_s := 0.0
|
||||
var lifetime_s := 90.0
|
||||
var ephemeral := true
|
||||
var agent: NavigationAgent3D = null
|
||||
## Set once the nav agent proves it has a path; direct movement is the fallback.
|
||||
var _nav_usable := false
|
||||
@@ -123,8 +128,8 @@ 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.inner_radius = 0.26
|
||||
torus.outer_radius = 0.34
|
||||
torus.rings = 12
|
||||
torus.ring_segments = 16
|
||||
_ring.mesh = torus
|
||||
@@ -139,10 +144,10 @@ func _build_focus_ring() -> void:
|
||||
_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.font_size = 48
|
||||
_name_tag.pixel_size = 0.004
|
||||
_name_tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
_name_tag.position = Vector3(0, 0.42, 0)
|
||||
_name_tag.position = Vector3(0, 0.58, 0)
|
||||
_name_tag.modulate = Color(0.95, 0.95, 0.9, 0.9)
|
||||
_name_tag.visible = false
|
||||
_head.add_child(_name_tag)
|
||||
@@ -257,20 +262,31 @@ func _animate(delta: float) -> void:
|
||||
_head.rotation.y = lerp_angle(_head.rotation.y, world_yaw - parent_yaw, 0.1)
|
||||
|
||||
func _advance_fsm(delta: float) -> void:
|
||||
# Routine traffic despawns after a while once it has finished its walk, so
|
||||
# the crowd cap is not permanently occupied by idle standers.
|
||||
if ephemeral and arrived and npc.fsm.state != "LEAVING":
|
||||
idle_s += delta
|
||||
if idle_s >= lifetime_s:
|
||||
leave_requested_by_lifetime = true
|
||||
return
|
||||
# Returning-trespasser: an NPC directed away may circle back to the door.
|
||||
if npc.tick_return(delta):
|
||||
arrived = false
|
||||
awaiting_interaction = false
|
||||
target_position = door_position
|
||||
return
|
||||
if npc.leave_requested:
|
||||
if npc.leave_requested or leave_requested_by_lifetime:
|
||||
var away := global_position + (global_position - spawn_position).normalized() * 40.0
|
||||
away.y = 0.5
|
||||
target_position = away
|
||||
if (global_position - away).length() < 2.0:
|
||||
queue_free()
|
||||
return
|
||||
if arrived and not awaiting_interaction:
|
||||
awaiting_interaction = true
|
||||
|
||||
var leave_requested_by_lifetime := false
|
||||
|
||||
func _move_toward_target(delta: float) -> void:
|
||||
if agent == null:
|
||||
_move_direct(delta)
|
||||
|
||||
@@ -10,6 +10,7 @@ const Strings := preload("res://src/core/strings.gd")
|
||||
|
||||
const SLICE_DEFS := [
|
||||
{"id": "raise_hand", "label_key": "radial.raise_hand", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "raise hand"},
|
||||
{"id": "eye_contact", "label_key": "radial.eye_contact", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "eye contact"},
|
||||
{"id": "point", "label_key": "radial.point", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "point"},
|
||||
{"id": "head_shake", "label_key": "radial.head_shake", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "head shake"},
|
||||
{"id": "polite", "label_key": "radial.polite", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "polite direction"},
|
||||
@@ -17,6 +18,8 @@ const SLICE_DEFS := [
|
||||
{"id": "wand", "label_key": "radial.wand", "level": -1, "action": "wand"},
|
||||
{"id": "warning", "label_key": "radial.warning", "level": Rules.Level.FORMAL_WARNING, "action": "formal warning"},
|
||||
{"id": "block", "label_key": "radial.block", "level": Rules.Level.BLOCKING, "action": "blocking"},
|
||||
{"id": "reason", "label_key": "radial.reason", "level": Rules.Level.BLOCKING, "action": "request reason"},
|
||||
{"id": "recognize", "label_key": "radial.recognize", "level": -1, "action": "recognize"},
|
||||
{"id": "radio", "label_key": "radial.radio", "level": -1, "action": "radio assist"},
|
||||
]
|
||||
|
||||
@@ -146,8 +149,8 @@ func _on_wheel_draw() -> void:
|
||||
return
|
||||
var center := _wheel.size * 0.5
|
||||
var n := SLICE_DEFS.size()
|
||||
var outer := 150.0
|
||||
var inner := 58.0
|
||||
var outer := 174.0
|
||||
var inner := 64.0
|
||||
for i in n:
|
||||
var a0 := -PI / 2.0 + TAU * float(i) / float(n)
|
||||
var a1 := -PI / 2.0 + TAU * float(i + 1) / float(n)
|
||||
@@ -160,7 +163,10 @@ func _on_wheel_draw() -> void:
|
||||
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)
|
||||
# With 12 slices the label box is tight: center it on the slice midpoint.
|
||||
var font := ThemeDB.fallback_font
|
||||
var text_size := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12)
|
||||
_wheel.draw_string(font, label_pos - Vector2(text_size.x * 0.5, -4), text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, Color.WHITE)
|
||||
|
||||
func _draw_slice(center: Vector2, inner: float, outer: float, a0: float, a1: float, color: Color) -> void:
|
||||
var pts := PackedVector2Array()
|
||||
|
||||
+36
-3
@@ -47,6 +47,8 @@ var escalation_label: Label
|
||||
|
||||
var _debug_enabled := false
|
||||
var _log_lines: Array[String] = []
|
||||
var _cam_overlay: Control = null
|
||||
var _body_camera_on := false
|
||||
|
||||
func _init() -> void:
|
||||
name = "HUD"
|
||||
@@ -138,11 +140,19 @@ func _init() -> void:
|
||||
add_child(vignette_rect)
|
||||
|
||||
# --- body-camera overlay (REC + shift clock + corner brackets, top right) ---
|
||||
# Toggled by the body-camera flag (PLAN Level 2); off until the guard activates
|
||||
# it or issues a formal warning.
|
||||
_cam_overlay = Control.new()
|
||||
_cam_overlay.name = "BodyCameraOverlay"
|
||||
_cam_overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_cam_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_cam_overlay.visible = false
|
||||
add_child(_cam_overlay)
|
||||
var cam_box := VBoxContainer.new()
|
||||
cam_box.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
||||
cam_box.position = Vector2(-180, 50)
|
||||
cam_box.alignment = BoxContainer.ALIGNMENT_END
|
||||
add_child(cam_box)
|
||||
_cam_overlay.add_child(cam_box)
|
||||
var rec_row := HBoxContainer.new()
|
||||
rec_row.alignment = BoxContainer.ALIGNMENT_END
|
||||
cam_box.add_child(rec_row)
|
||||
@@ -279,6 +289,28 @@ func set_wand_icon(visible: bool) -> void:
|
||||
if wand_icon:
|
||||
wand_icon.visible = visible
|
||||
|
||||
## Show or hide the body-camera overlay (REC + clock + brackets). The flag is a
|
||||
## record of the interaction (PLAN Level 2), not a permanent HUD fixture.
|
||||
func set_body_camera(on: bool) -> void:
|
||||
_body_camera_on = on
|
||||
if _cam_overlay:
|
||||
_cam_overlay.visible = on
|
||||
|
||||
func is_body_camera_on() -> bool:
|
||||
return _body_camera_on
|
||||
|
||||
## Scale the whole HUD text block (accessibility settings).
|
||||
func set_font_scale(scale: float) -> void:
|
||||
var clamped := clampf(scale, 0.75, 1.75)
|
||||
if top_label:
|
||||
top_label.scale = Vector2(clamped, clamped)
|
||||
if log_label:
|
||||
log_label.scale = Vector2(clamped, clamped)
|
||||
if target_label:
|
||||
target_label.scale = Vector2(clamped, clamped)
|
||||
if hint_label:
|
||||
hint_label.scale = Vector2(clamped, clamped)
|
||||
|
||||
func _make_icon(texture: Texture2D) -> TextureRect:
|
||||
var tr := TextureRect.new()
|
||||
tr.texture = texture
|
||||
@@ -296,6 +328,7 @@ func _add_corner_brackets(color: Color) -> void:
|
||||
var corners := [
|
||||
Vector2(OFF, OFF), Vector2(vp.x - OFF, OFF), Vector2(OFF, vp.y - OFF), Vector2(vp.x - OFF, vp.y - OFF),
|
||||
]
|
||||
var parent: Node = _cam_overlay if _cam_overlay != null else self
|
||||
for i in 4:
|
||||
var c: Vector2 = corners[i]
|
||||
var flip_x: bool = (i % 2) == 1
|
||||
@@ -305,13 +338,13 @@ func _add_corner_brackets(color: Color) -> void:
|
||||
h.size = Vector2(LEN, TH)
|
||||
h.position = Vector2(c.x - (LEN if flip_x else 0.0), c.y - (TH if flip_y else 0.0))
|
||||
h.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(h)
|
||||
parent.add_child(h)
|
||||
var v := ColorRect.new()
|
||||
v.color = color
|
||||
v.size = Vector2(TH, LEN)
|
||||
v.position = Vector2(c.x - (TH if flip_x else 0.0), c.y - (LEN if flip_y else 0.0))
|
||||
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(v)
|
||||
parent.add_child(v)
|
||||
|
||||
func set_action_hint(text: String) -> void:
|
||||
hint_label.text = text
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
class_name PauseOverlay
|
||||
extends CanvasLayer
|
||||
## Shift pause overlay: resume, restart the shift, open settings, or quit to the
|
||||
## venue menu. Opened with Esc from the world (or a dedicated pause action) and
|
||||
## while the overlay is open the guard is frozen.
|
||||
|
||||
signal resume_requested()
|
||||
signal restart_requested()
|
||||
signal settings_requested()
|
||||
signal quit_to_menu_requested()
|
||||
|
||||
var _root: Control = null
|
||||
var _resume_button: Button = null
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 18
|
||||
_build()
|
||||
hide()
|
||||
|
||||
func _build() -> void:
|
||||
_root = Control.new()
|
||||
_root.name = "PauseRoot"
|
||||
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_root.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
add_child(_root)
|
||||
|
||||
var bg := ColorRect.new()
|
||||
bg.color = Color(0.02, 0.03, 0.05, 0.82)
|
||||
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_root.add_child(bg)
|
||||
|
||||
var center := CenterContainer.new()
|
||||
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_root.add_child(center)
|
||||
|
||||
var column := VBoxContainer.new()
|
||||
column.custom_minimum_size = Vector2(320, 0)
|
||||
column.add_theme_constant_override("separation", 10)
|
||||
center.add_child(column)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "Paused"
|
||||
title.add_theme_font_size_override("font_size", 36)
|
||||
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
column.add_child(title)
|
||||
|
||||
_resume_button = _add_button(column, "Resume", "ResumeButton", func(): resume_requested.emit())
|
||||
_add_button(column, "Restart shift", "RestartButton", func(): restart_requested.emit())
|
||||
_add_button(column, "Settings", "SettingsButton", func(): settings_requested.emit())
|
||||
_add_button(column, "Quit to menu", "MenuButton", func(): quit_to_menu_requested.emit())
|
||||
|
||||
var hint := Label.new()
|
||||
hint.text = "Esc resumes"
|
||||
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
hint.add_theme_color_override("font_color", Color(0.55, 0.58, 0.65))
|
||||
column.add_child(hint)
|
||||
|
||||
func _add_button(parent: Control, text: String, node_name: String, cb: Callable) -> Button:
|
||||
var button := Button.new()
|
||||
button.name = node_name
|
||||
button.text = text
|
||||
button.custom_minimum_size = Vector2(320, 44)
|
||||
button.pressed.connect(cb)
|
||||
parent.add_child(button)
|
||||
return button
|
||||
|
||||
func open() -> void:
|
||||
visible = true
|
||||
_root.visible = true
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||
if _resume_button:
|
||||
_resume_button.grab_focus()
|
||||
|
||||
func close() -> void:
|
||||
visible = false
|
||||
_root.visible = false
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not visible:
|
||||
return
|
||||
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
|
||||
resume_requested.emit()
|
||||
get_viewport().set_input_as_handled()
|
||||
@@ -0,0 +1,154 @@
|
||||
class_name SettingsPanel
|
||||
extends CanvasLayer
|
||||
## Settings: mouse look sensitivity and HUD font scale, persisted through the
|
||||
## `Save` autoload into `user://settings.json`.
|
||||
##
|
||||
## Emits `applied(settings)` so the game manager can push values into the guard
|
||||
## and HUD as soon as the player confirms.
|
||||
|
||||
signal applied(settings: Dictionary)
|
||||
signal closed()
|
||||
|
||||
var _root: Control = null
|
||||
var _sensitivity: HSlider = null
|
||||
var _font_scale: HSlider = null
|
||||
var _sensitivity_value: Label = null
|
||||
var _font_scale_value: Label = null
|
||||
var settings: Dictionary = {}
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 22
|
||||
_build()
|
||||
hide()
|
||||
|
||||
func _init() -> void:
|
||||
layer = 22
|
||||
_build()
|
||||
hide()
|
||||
|
||||
func _build() -> void:
|
||||
_root = Control.new()
|
||||
_root.name = "SettingsRoot"
|
||||
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_root.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
add_child(_root)
|
||||
|
||||
var bg := ColorRect.new()
|
||||
bg.color = Color(0.02, 0.03, 0.05, 0.9)
|
||||
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_root.add_child(bg)
|
||||
|
||||
var center := CenterContainer.new()
|
||||
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_root.add_child(center)
|
||||
|
||||
var column := VBoxContainer.new()
|
||||
column.custom_minimum_size = Vector2(420, 0)
|
||||
column.add_theme_constant_override("separation", 12)
|
||||
center.add_child(column)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "Settings"
|
||||
title.add_theme_font_size_override("font_size", 32)
|
||||
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
column.add_child(title)
|
||||
|
||||
var sens_row := VBoxContainer.new()
|
||||
column.add_child(sens_row)
|
||||
_sensitivity_value = _add_label_row(sens_row, "Mouse look sensitivity")
|
||||
_sensitivity = _add_slider(sens_row, 0.0005, 0.0060, 0.0001)
|
||||
_sensitivity.value_changed.connect(_on_sensitivity_changed)
|
||||
|
||||
var font_row := VBoxContainer.new()
|
||||
column.add_child(font_row)
|
||||
_font_scale_value = _add_label_row(font_row, "HUD font scale")
|
||||
_font_scale = _add_slider(font_row, 0.75, 1.75, 0.05)
|
||||
_font_scale.value_changed.connect(_on_font_scale_changed)
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
row.add_theme_constant_override("separation", 12)
|
||||
column.add_child(row)
|
||||
|
||||
var save_button := Button.new()
|
||||
save_button.name = "ApplyButton"
|
||||
save_button.text = "Apply"
|
||||
save_button.pressed.connect(_on_apply)
|
||||
row.add_child(save_button)
|
||||
|
||||
var cancel_button := Button.new()
|
||||
cancel_button.name = "CancelButton"
|
||||
cancel_button.text = "Cancel"
|
||||
cancel_button.pressed.connect(_on_cancel)
|
||||
row.add_child(cancel_button)
|
||||
|
||||
func _add_label_row(parent: Control, text: String) -> Label:
|
||||
var row := HBoxContainer.new()
|
||||
parent.add_child(row)
|
||||
var label := Label.new()
|
||||
label.text = text
|
||||
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
row.add_child(label)
|
||||
var value := Label.new()
|
||||
value.add_theme_color_override("font_color", Color(0.75, 0.8, 0.9))
|
||||
row.add_child(value)
|
||||
return value
|
||||
|
||||
func _add_slider(parent: Control, min_v: float, max_v: float, step: float) -> HSlider:
|
||||
var slider := HSlider.new()
|
||||
slider.min_value = min_v
|
||||
slider.max_value = max_v
|
||||
slider.step = step
|
||||
slider.custom_minimum_size = Vector2(420, 24)
|
||||
parent.add_child(slider)
|
||||
return slider
|
||||
|
||||
func open(current: Dictionary) -> void:
|
||||
settings = current.duplicate(true)
|
||||
var sens: float = float(settings.get("look_sensitivity", 0.0022))
|
||||
var scale: float = float(settings.get("hud_font_scale", 1.0))
|
||||
if _sensitivity:
|
||||
_sensitivity.set_value_no_signal(clampf(sens, _sensitivity.min_value, _sensitivity.max_value))
|
||||
if _font_scale:
|
||||
_font_scale.set_value_no_signal(clampf(scale, _font_scale.min_value, _font_scale.max_value))
|
||||
_sync_labels()
|
||||
visible = true
|
||||
_root.visible = true
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||
|
||||
func close() -> void:
|
||||
visible = false
|
||||
_root.visible = false
|
||||
|
||||
func _sync_labels() -> void:
|
||||
if _sensitivity_value and _sensitivity:
|
||||
_sensitivity_value.text = "%.4f" % _sensitivity.value
|
||||
if _font_scale_value and _font_scale:
|
||||
_font_scale_value.text = "%.2fx" % _font_scale.value
|
||||
|
||||
func _on_sensitivity_changed(value: float) -> void:
|
||||
settings["look_sensitivity"] = value
|
||||
_sync_labels()
|
||||
|
||||
func _on_font_scale_changed(value: float) -> void:
|
||||
settings["hud_font_scale"] = value
|
||||
_sync_labels()
|
||||
|
||||
func _on_apply() -> void:
|
||||
if Save:
|
||||
Save.save_settings(settings)
|
||||
applied.emit(settings)
|
||||
close()
|
||||
closed.emit()
|
||||
|
||||
func _on_cancel() -> void:
|
||||
close()
|
||||
closed.emit()
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not visible:
|
||||
return
|
||||
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
|
||||
_on_cancel()
|
||||
get_viewport().set_input_as_handled()
|
||||
@@ -0,0 +1,140 @@
|
||||
class_name StartMenu
|
||||
extends CanvasLayer
|
||||
## Startup menu: pick a venue, start the shift, open settings, or quit.
|
||||
## Keyboard and mouse both work. Replaces `V` as the player-facing venue path
|
||||
## (the `V` bind stays as a debug shortcut).
|
||||
|
||||
signal venue_selected(scenario_id: String)
|
||||
signal settings_requested()
|
||||
signal quit_requested()
|
||||
|
||||
const VENUES := [
|
||||
{"id": "hollywood_building", "label": "Hollywood Boulevard", "blurb": "Building entrance, tourists and late-night trouble."},
|
||||
{"id": "joke_shop", "label": "The Joke Shop", "blurb": "Guest list, IDs and wand screening."},
|
||||
{"id": "tv_studio", "label": "Late Night TV Studio", "blurb": "Credentials, filming boundary, celebrity arrivals."},
|
||||
]
|
||||
|
||||
var _index := 0
|
||||
var _buttons: Array[Button] = []
|
||||
var _root: Control = null
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 20
|
||||
_build()
|
||||
hide()
|
||||
|
||||
func _build() -> void:
|
||||
_root = Control.new()
|
||||
_root.name = "StartMenuRoot"
|
||||
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_root.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
add_child(_root)
|
||||
|
||||
var bg := ColorRect.new()
|
||||
bg.color = Color(0.03, 0.04, 0.07, 0.96)
|
||||
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_root.add_child(bg)
|
||||
|
||||
var center := CenterContainer.new()
|
||||
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_root.add_child(center)
|
||||
|
||||
var column := VBoxContainer.new()
|
||||
column.name = "Column"
|
||||
column.custom_minimum_size = Vector2(460, 0)
|
||||
column.add_theme_constant_override("separation", 14)
|
||||
center.add_child(column)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "STEP LINE"
|
||||
title.add_theme_font_size_override("font_size", 44)
|
||||
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
column.add_child(title)
|
||||
|
||||
var subtitle := Label.new()
|
||||
subtitle.text = "Pick a venue to start your shift."
|
||||
subtitle.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
subtitle.add_theme_color_override("font_color", Color(0.72, 0.75, 0.82))
|
||||
column.add_child(subtitle)
|
||||
|
||||
for i in VENUES.size():
|
||||
var venue: Dictionary = VENUES[i]
|
||||
var button := Button.new()
|
||||
button.name = "Venue_%s" % venue["id"]
|
||||
button.text = "%s\n%s" % [venue["label"], venue["blurb"]]
|
||||
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
button.custom_minimum_size = Vector2(460, 62)
|
||||
button.pressed.connect(_on_venue_pressed.bind(i))
|
||||
column.add_child(button)
|
||||
_buttons.append(button)
|
||||
|
||||
var spacer := Control.new()
|
||||
spacer.custom_minimum_size = Vector2(0, 10)
|
||||
column.add_child(spacer)
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
row.add_theme_constant_override("separation", 12)
|
||||
column.add_child(row)
|
||||
|
||||
var settings_button := Button.new()
|
||||
settings_button.name = "SettingsButton"
|
||||
settings_button.text = "Settings"
|
||||
settings_button.pressed.connect(func(): settings_requested.emit())
|
||||
row.add_child(settings_button)
|
||||
|
||||
var quit_button := Button.new()
|
||||
quit_button.name = "QuitButton"
|
||||
quit_button.text = "Quit"
|
||||
quit_button.pressed.connect(func(): quit_requested.emit())
|
||||
row.add_child(quit_button)
|
||||
|
||||
var hint := Label.new()
|
||||
hint.text = "Up/Down to choose, Enter to start, Esc to quit"
|
||||
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
hint.add_theme_color_override("font_color", Color(0.55, 0.58, 0.65))
|
||||
column.add_child(hint)
|
||||
|
||||
_focus_index(0)
|
||||
|
||||
func _focus_index(i: int) -> void:
|
||||
if _buttons.is_empty():
|
||||
return
|
||||
_index = clampi(i, 0, _buttons.size() - 1)
|
||||
_buttons[_index].grab_focus()
|
||||
|
||||
func open() -> void:
|
||||
visible = true
|
||||
_root.visible = true
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||
_focus_index(_index)
|
||||
|
||||
func close() -> void:
|
||||
visible = false
|
||||
_root.visible = false
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not visible:
|
||||
return
|
||||
if event is InputEventKey and event.pressed and not event.echo:
|
||||
match event.keycode:
|
||||
KEY_UP, KEY_W:
|
||||
_focus_index(_index - 1)
|
||||
get_viewport().set_input_as_handled()
|
||||
KEY_DOWN, KEY_S:
|
||||
_focus_index(_index + 1)
|
||||
get_viewport().set_input_as_handled()
|
||||
KEY_ENTER, KEY_KP_ENTER, KEY_SPACE:
|
||||
_on_venue_pressed(_index)
|
||||
get_viewport().set_input_as_handled()
|
||||
KEY_ESCAPE:
|
||||
quit_requested.emit()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
func _on_venue_pressed(i: int) -> void:
|
||||
if i < 0 or i >= VENUES.size():
|
||||
return
|
||||
var venue: Dictionary = VENUES[i]
|
||||
close()
|
||||
venue_selected.emit(str(venue["id"]))
|
||||
@@ -5,6 +5,7 @@ const T_BRICK := preload("res://assets/textures/ai/game_ready/brick_facade.png")
|
||||
const T_CONCRETE := preload("res://assets/textures/ai/game_ready/concrete.png")
|
||||
const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.png")
|
||||
const T_AWNING := preload("res://assets/textures/ai/game_ready/awning.png")
|
||||
const T_SKYLINE := preload("res://assets/textures/skyline.png")
|
||||
## steps, building facade, restricted door, sidewalk, pavement, and street edge.
|
||||
## No art assets required; all geometry is BoxMesh with flat materials.
|
||||
|
||||
@@ -25,6 +26,7 @@ func _init(root_node: Node3D) -> void:
|
||||
func build() -> void:
|
||||
_build_ground()
|
||||
_build_building()
|
||||
_build_skyline_backdrop()
|
||||
_build_marquee()
|
||||
_build_steps()
|
||||
_build_sidewalk()
|
||||
@@ -90,10 +92,9 @@ func _build_building() -> void:
|
||||
root.add_child(building)
|
||||
|
||||
func _build_steps() -> void:
|
||||
# Steps are visual only (no collider) so NPCs and the guard can walk up the
|
||||
# entrance on the flat navigation mesh; the ground and building provide the
|
||||
# actual collision boundaries.
|
||||
var steps := Node3D.new()
|
||||
# Steps have collision so NPCs and the guard can walk up the entrance.
|
||||
# The ground and building provide the actual collision boundaries.
|
||||
var steps := StaticBody3D.new()
|
||||
steps.name = "Steps"
|
||||
var step_w := 8.0
|
||||
var tread := 0.6
|
||||
@@ -101,9 +102,22 @@ func _build_steps() -> void:
|
||||
for i in 4:
|
||||
var y: float = i * height
|
||||
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))
|
||||
# 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))
|
||||
var w: float = step_w - i * 0.35
|
||||
_add_box(steps, Vector3(w, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5))
|
||||
# Add collision shape to each tread
|
||||
var tread_col := BoxShape3D.new()
|
||||
tread_col.size = Vector3(w, height, tread)
|
||||
var owner_id := steps.create_shape_owner(steps)
|
||||
steps.shape_owner_add_shape(owner_id, tread_col)
|
||||
steps.shape_owner_set_transform(owner_id, Transform3D(Basis(), Vector3(0, y + height / 2.0, z)))
|
||||
# Landing at the top with collision
|
||||
var landing_y: float = 4 * height + 0.1
|
||||
_add_box(steps, Vector3(9, 0.2, 3), Vector3(0, landing_y, -1.5), Color(0.5, 0.47, 0.45))
|
||||
var landing_col := BoxShape3D.new()
|
||||
landing_col.size = Vector3(9, 0.2, 3)
|
||||
var landing_owner := steps.create_shape_owner(steps)
|
||||
steps.shape_owner_add_shape(landing_owner, landing_col)
|
||||
steps.shape_owner_set_transform(landing_owner, Transform3D(Basis(), Vector3(0, landing_y, -1.5)))
|
||||
root.add_child(steps)
|
||||
|
||||
func _build_sidewalk() -> void:
|
||||
@@ -150,6 +164,33 @@ func _build_navmesh() -> void:
|
||||
region.navigation_mesh = mesh
|
||||
root.add_child(region)
|
||||
|
||||
func _build_skyline_backdrop() -> void:
|
||||
## Skyline silhouette backdrop behind the building. The texture has a
|
||||
## transparent sky, so only the building silhouettes show; the material must
|
||||
## be set up for alpha or the plane renders as an opaque slab (ADR/TODO).
|
||||
var backdrop := Node3D.new()
|
||||
backdrop.name = "SkylineBackdrop"
|
||||
var plane := MeshInstance3D.new()
|
||||
var mesh := PlaneMesh.new()
|
||||
mesh.size = Vector2(240, 60) # wide, low horizon
|
||||
plane.mesh = mesh
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_texture = T_SKYLINE
|
||||
mat.texture_repeat = true
|
||||
mat.uv1_scale = Vector3(2.0, 1.0, 1.0)
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
|
||||
mat.alpha_scissor_threshold = 0.3
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
plane.material_override = mat
|
||||
plane.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
plane.position = Vector3(0, 18, -60) # behind and above the building
|
||||
backdrop.add_child(plane)
|
||||
root.add_child(backdrop)
|
||||
_skyline_mat = mat
|
||||
|
||||
var _skyline_mat: StandardMaterial3D = null
|
||||
|
||||
func _build_marquee() -> void:
|
||||
## Striped awning above the door, then the neon sign strip + label.
|
||||
var awning := _add_box(root, Vector3(10.5, 0.08, 1.8), Vector3(0, 6.55, -3.7), Color(1, 1, 1), false, T_AWNING)
|
||||
@@ -167,9 +208,9 @@ func _build_marquee() -> void:
|
||||
marquee_label.text = "STEP LINE"
|
||||
marquee_label.position = Vector3(0, 7.3, -3.55)
|
||||
marquee_label.modulate = Color(1, 0.6, 0.9)
|
||||
marquee_label.font_size = 72
|
||||
marquee_label.font_size = 160
|
||||
marquee_label.pixel_size = 0.004
|
||||
marquee_label.outline_size = 12
|
||||
marquee_label.outline_size = 24
|
||||
marquee_label.outline_modulate = Color(0.9, 0.15, 0.5)
|
||||
marquee_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
marquee_label.no_depth_test = false
|
||||
@@ -264,6 +305,11 @@ func _plain_material(color: Color) -> StandardMaterial3D:
|
||||
func _build_guard_spawn() -> void:
|
||||
var marker := Marker3D.new()
|
||||
marker.name = "GuardSpawn"
|
||||
# Guard spawns on the landing at the top of the steps, facing the street.
|
||||
# The landing is at y=4*height+0.1 where height=0.16, so y=0.76
|
||||
# The guard's head offset is 1.6m, so spawn at y=0.76-1.6=-0.84 to place feet on ground
|
||||
# But we want the guard at eye-level with the sidewalk, so adjust to y=0.1 for feet
|
||||
# Then the head will be at y=1.7 (1.6 offset + 0.1 base)
|
||||
marker.position = Vector3(0, 0.1, -2.0)
|
||||
root.add_child(marker)
|
||||
|
||||
|
||||
@@ -15,12 +15,23 @@ var state := 0
|
||||
var state_frames := 0
|
||||
var stage_npc: Node3D = null
|
||||
var failures: Array[String] = []
|
||||
var _shift_started := false
|
||||
|
||||
func _initialize() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
var packed: PackedScene = load("res://scenes/main.tscn")
|
||||
root.add_child(packed.instantiate())
|
||||
|
||||
## The game now boots into the venue menu, so the smoke starts a shift first.
|
||||
func _start_shift_if_needed() -> void:
|
||||
if _shift_started or gm == null:
|
||||
return
|
||||
var menu = gm.get("start_menu")
|
||||
if menu != null and menu.visible:
|
||||
gm.call("_on_venue_selected", "hollywood_building")
|
||||
_shift_started = true
|
||||
print("smoke: started hollywood_building shift from the menu")
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
frames += 1
|
||||
if gm == null:
|
||||
@@ -39,6 +50,7 @@ func _advance() -> void:
|
||||
state_frames += 1
|
||||
match state:
|
||||
0:
|
||||
_start_shift_if_needed()
|
||||
# Wait for the guard to settle on the ground, then grab one NPC.
|
||||
if state_frames >= 40 and gm.npcs.size() > 0:
|
||||
print("smoke: spawn guard_y=%.2f z=%.2f" % [
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
extends SceneTree
|
||||
## PLAN-verb smoke: starts a shift, focuses one person, then exercises the verbs
|
||||
## added after the radial pass — recognize, request reason, body camera, formal
|
||||
## warning (which must arm the body camera), and blocking (which must move the
|
||||
## guard between the person and the door). Also checks that an incident is
|
||||
## actually recorded, then closing it at shift end.
|
||||
##
|
||||
## Usage:
|
||||
## godot --headless --path . --script res://src/test/smoke_plan_verbs.gd
|
||||
## Exit code 0 = pass, 1 = failure.
|
||||
|
||||
const MAX_FRAMES := 1200
|
||||
|
||||
var frame := 0
|
||||
var gm: Node = null
|
||||
var started := false
|
||||
var step := 0
|
||||
var failures: Array[String] = []
|
||||
|
||||
func _initialize() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
root.add_child(load("res://scenes/main.tscn").instantiate())
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
frame += 1
|
||||
if gm == null:
|
||||
gm = root.find_child("GameManager", true, false)
|
||||
return false
|
||||
if not started:
|
||||
gm.call("_on_venue_selected", "hollywood_building")
|
||||
started = true
|
||||
return false
|
||||
if frame > MAX_FRAMES:
|
||||
_fail("timeout before the verbs ran")
|
||||
return true
|
||||
if frame < 200:
|
||||
return false
|
||||
if step == 0:
|
||||
if gm.npcs.is_empty():
|
||||
_fail("no NPCs spawned to test the verbs on")
|
||||
return true
|
||||
var npc: Node = gm.npcs[0]
|
||||
gm.call("_on_guard_target", npc)
|
||||
var controller: Object = npc.get_npc_controller()
|
||||
gm.call("_recognize_target")
|
||||
_check(controller.player_recognized, "recognize marks the person as recognized")
|
||||
gm.call("_request_reason")
|
||||
_check(typeof(controller.last_reason_reply) == TYPE_STRING, "reason request returns a reply")
|
||||
gm.call("_set_body_camera", false, "smoke")
|
||||
_check(not gm.body_camera_on, "body camera can be switched off")
|
||||
gm.call("_apply_interaction", 2, "formal warning")
|
||||
_check(gm.body_camera_on, "formal warning arms the body camera")
|
||||
var before: Vector3 = gm.guard.global_position
|
||||
gm.call("_apply_interaction", 3, "blocking")
|
||||
_check(before.distance_to(gm.guard.global_position) > 0.05, "blocking moves the guard")
|
||||
_check(gm.coordinator.recorder.open_incidents_count() > 0 or gm.coordinator.recorder.incidents.size() > 0,
|
||||
"an incident was recorded")
|
||||
gm.coordinator.call("_finish_shift")
|
||||
_check(gm.coordinator.recorder.open_incidents_count() == 0, "open incidents close at shift end")
|
||||
_check(gm.coordinator.recorder.incidents.size() > 0, "shift history keeps the incident")
|
||||
step = 1
|
||||
if step == 1:
|
||||
_finish()
|
||||
return true
|
||||
return false
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
if condition:
|
||||
print("ok: " + message)
|
||||
else:
|
||||
_fail(message)
|
||||
|
||||
func _fail(message: String) -> void:
|
||||
failures.append(message)
|
||||
_finish()
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("SMOKE OK: PLAN verbs (recognize, reason, body camera, warning, blocking)")
|
||||
quit(0)
|
||||
else:
|
||||
for message in failures:
|
||||
printerr("SMOKE FAIL: " + message)
|
||||
quit(1)
|
||||
@@ -0,0 +1,63 @@
|
||||
extends SceneTree
|
||||
## Session-shell smoke: boot the main scene, confirm the venue menu is showing,
|
||||
## start a Joke Shop shift, open the pause overlay and settings, then return to
|
||||
## the menu. Exits non-zero on failure.
|
||||
##
|
||||
## Usage:
|
||||
## godot --headless --path . --script res://src/test/smoke_session_shell.gd
|
||||
## godot --path . --script res://src/test/smoke_session_shell.gd (windowed)
|
||||
var _frames := 0
|
||||
var _manager = null
|
||||
var _main = null
|
||||
|
||||
func _initialize() -> void:
|
||||
var packed: PackedScene = load("res://scenes/main.tscn")
|
||||
if packed == null:
|
||||
printerr("cannot load main scene")
|
||||
quit(1)
|
||||
return
|
||||
_main = packed.instantiate()
|
||||
root.add_child(_main)
|
||||
print("boot: main scene added")
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
_frames += 1
|
||||
if _frames == 10:
|
||||
_manager = _main.get_node_or_null("GameManager")
|
||||
if _manager.start_menu == null or not _manager.start_menu.visible:
|
||||
printerr("start menu not visible at boot")
|
||||
quit(1)
|
||||
return true
|
||||
print("boot: start menu visible")
|
||||
_manager._on_venue_selected("joke_shop")
|
||||
if _frames == 200:
|
||||
if _manager.npcs.size() == 0:
|
||||
printerr("no NPCs spawned after shift start")
|
||||
quit(1)
|
||||
return true
|
||||
print("boot: shift running, npcs=%d" % _manager.npcs.size())
|
||||
_manager._open_pause()
|
||||
if _frames == 210:
|
||||
if _manager.pause_overlay == null or not _manager.pause_overlay.visible:
|
||||
printerr("pause overlay did not open")
|
||||
quit(1)
|
||||
return true
|
||||
print("boot: pause overlay visible")
|
||||
_manager._open_settings()
|
||||
if _frames == 220:
|
||||
if _manager.settings_panel == null or not _manager.settings_panel.visible:
|
||||
printerr("settings panel did not open")
|
||||
quit(1)
|
||||
return true
|
||||
print("boot: settings visible")
|
||||
_manager.settings_panel._on_cancel()
|
||||
_manager._quit_to_menu()
|
||||
if _frames == 230:
|
||||
if not _manager.start_menu.visible:
|
||||
printerr("quit-to-menu did not return to the start menu")
|
||||
quit(1)
|
||||
return true
|
||||
print("SMOKE PASS: menu -> shift -> pause -> settings -> menu")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
@@ -32,7 +32,9 @@ func test_radial_wrap_and_skip_wand() -> bool:
|
||||
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
|
||||
var wand_index := _slice_index("wand")
|
||||
var warning_index := _slice_index("warning")
|
||||
menu.selected = wand_index - 1
|
||||
menu.step(1)
|
||||
var c := TestUtils.check_eq(menu.slice_id(menu.selected), "warning", "wand skipped when venue has none")
|
||||
menu.free()
|
||||
@@ -91,6 +93,12 @@ func test_cancel_has_right_mouse() -> bool:
|
||||
found = true
|
||||
return TestUtils.check(found, "cancel action includes right mouse button")
|
||||
|
||||
func _slice_index(id: String) -> int:
|
||||
for i in ActionRadial.SLICE_DEFS.size():
|
||||
if str(ActionRadial.SLICE_DEFS[i]["id"]) == id:
|
||||
return i
|
||||
return -1
|
||||
|
||||
func _action_has_key(action: String, keycode: Key) -> bool:
|
||||
if not InputMap.has_action(action):
|
||||
return false
|
||||
|
||||
@@ -30,3 +30,24 @@ func test_load_returns_array_when_empty_or_missing() -> bool:
|
||||
var ok := TestUtils.check(results is Array, "load_shift_results always returns Array")
|
||||
save.free()
|
||||
return ok
|
||||
|
||||
func test_settings_round_trip() -> bool:
|
||||
var save := _save_instance()
|
||||
var err: int = save.save_settings({"look_sensitivity": 0.0035, "hud_font_scale": 1.25})
|
||||
var a := TestUtils.check_eq(err, OK, "save_settings succeeds")
|
||||
var loaded: Dictionary = save.load_settings()
|
||||
var b := TestUtils.check(is_equal_approx(float(loaded.get("look_sensitivity", 0.0)), 0.0035), "round-trips sensitivity")
|
||||
var c := TestUtils.check(is_equal_approx(float(loaded.get("hud_font_scale", 0.0)), 1.25), "round-trips font scale")
|
||||
# Restore defaults so later runs are not affected.
|
||||
save.save_settings(save.DEFAULT_SETTINGS)
|
||||
save.free()
|
||||
return a and b and c
|
||||
|
||||
func test_settings_defaults_present() -> bool:
|
||||
var save := _save_instance()
|
||||
var defaults: Dictionary = save.load_settings()
|
||||
var ok := TestUtils.check(
|
||||
defaults.has("look_sensitivity") and defaults.has("hud_font_scale"),
|
||||
"settings have documented defaults")
|
||||
save.free()
|
||||
return ok
|
||||
@@ -0,0 +1,132 @@
|
||||
extends RefCounted
|
||||
## PLAN verbs added after the radial pass: eye contact, recognition as a player
|
||||
## decision, the body-camera flag, blocking station, and requesting a reason.
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const ActionRadial := preload("res://src/game/ui/action_radial.gd")
|
||||
const IncidentRecorder := preload("res://src/core/incident_recorder.gd")
|
||||
const NPCController := preload("res://src/core/npc_controller.gd")
|
||||
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||
const AccessLoadout := preload("res://src/core/access_loadout.gd")
|
||||
const AccessArtifact := preload("res://src/core/access_artifact.gd")
|
||||
|
||||
func _slice_index(id: String) -> int:
|
||||
for i in ActionRadial.SLICE_DEFS.size():
|
||||
if str(ActionRadial.SLICE_DEFS[i]["id"]) == id:
|
||||
return i
|
||||
return -1
|
||||
|
||||
func test_radial_has_new_verbs() -> bool:
|
||||
var a := TestUtils.check(_slice_index("eye_contact") >= 0, "eye contact slice exists")
|
||||
var b := TestUtils.check(_slice_index("reason") >= 0, "request-reason slice exists")
|
||||
var c := TestUtils.check(_slice_index("recognize") >= 0, "recognize slice exists")
|
||||
return a and b and c
|
||||
|
||||
func test_eye_contact_is_level_one() -> bool:
|
||||
var def: Dictionary = ActionRadial.SLICE_DEFS[_slice_index("eye_contact")]
|
||||
var ok := TestUtils.check_eq(int(def.get("level", -1)), Rules.Level.SIMPLE_DETERRENCE,
|
||||
"eye contact is a Level 1 action")
|
||||
var action_ok := TestUtils.check_eq(str(def.get("action", "")), "eye contact", "logs as eye contact")
|
||||
return ok and action_ok
|
||||
|
||||
func test_every_slice_has_a_string_key() -> bool:
|
||||
var strings = load("res://src/core/strings.gd")
|
||||
var ok := true
|
||||
for def in ActionRadial.SLICE_DEFS:
|
||||
var key: String = str(def.get("label_key", ""))
|
||||
if key == "":
|
||||
ok = false
|
||||
continue
|
||||
if strings.lookup(key) == key:
|
||||
ok = false
|
||||
return TestUtils.check(ok, "every radial slice resolves a localized label")
|
||||
|
||||
func _profile(name: String, lines: Array[String] = []) -> NpcProfile:
|
||||
var p := NpcProfile.new()
|
||||
p.id = "test"
|
||||
p.display_name = name
|
||||
p.dialogue_lines = lines
|
||||
return p
|
||||
|
||||
func test_request_reason_uses_dialogue_lines() -> bool:
|
||||
var p := _profile("Visitor", ["I have a ticket for tonight.", "I am meeting a friend."])
|
||||
var npc := NPCController.new()
|
||||
npc.setup("npc_1", p, "enter", 4242)
|
||||
var a := TestUtils.check_eq(npc.request_reason(), npc.last_reason_reply, "reply is stored")
|
||||
var b := TestUtils.check_eq(npc.last_reason_reply, p.dialogue_lines[0], "seeded pick is deterministic")
|
||||
var c := TestUtils.check(not npc.last_reason_reply.is_empty(), "reply is non-empty")
|
||||
npc = null
|
||||
return a and b and c
|
||||
|
||||
func test_request_reason_silent_without_lines() -> bool:
|
||||
var npc := NPCController.new()
|
||||
npc.setup("npc_2", _profile("Silent"), "enter", 7)
|
||||
var reply: String = npc.request_reason()
|
||||
var ok := TestUtils.check_eq(reply, "", "no dialogue lines means silence")
|
||||
npc = null
|
||||
return ok
|
||||
|
||||
func test_player_recognition_is_not_default() -> bool:
|
||||
var p := _profile("Staffer")
|
||||
p.npc_type = Rules.NpcType.AUTHORIZED_GUEST
|
||||
var npc := NPCController.new()
|
||||
npc.setup("npc_3", p, "enter", 11)
|
||||
var a := TestUtils.check(not npc.player_recognized, "guests are not pre-recognized")
|
||||
var override := {}
|
||||
var artifacts := AccessLoadout.build(p, null, override)
|
||||
var has_recognition := false
|
||||
for artifact in artifacts:
|
||||
if artifact is AccessArtifact and artifact.kind == Rules.AccessKind.RECOGNITION:
|
||||
has_recognition = true
|
||||
var b := TestUtils.check(not has_recognition, "guest artifact set has no recognition")
|
||||
npc = null
|
||||
return a and b
|
||||
|
||||
func test_recorder_starts_and_closes_incidents() -> bool:
|
||||
var recorder := IncidentRecorder.new()
|
||||
var a := TestUtils.check(not recorder.has_open_incident("npc_a"), "no incident before start")
|
||||
recorder.start_incident("npc_a", "player_focus")
|
||||
var b := TestUtils.check(recorder.has_open_incident("npc_a"), "incident opens")
|
||||
recorder.add_event("npc_a", {"type": "recognition_decision", "recognized": true})
|
||||
var c := TestUtils.check_eq(recorder.incidents.size(), 0, "open incident is not yet in history")
|
||||
var closed: int = recorder.close_all_open("unresolved")
|
||||
var d := TestUtils.check_eq(closed, 1, "close_all_open closes the open incident")
|
||||
var e := TestUtils.check_eq(recorder.incidents.size(), 1, "closed incident lands in history")
|
||||
var events: Array = recorder.incidents[0].get("events", [])
|
||||
var f := TestUtils.check_eq(events.size(), 1, "recorded event survived the close")
|
||||
recorder = null
|
||||
return a and b and c and d and e and f
|
||||
|
||||
func test_shift_end_closes_open_incidents_into_review() -> bool:
|
||||
var Coordinator := load("res://src/core/shift_coordinator.gd")
|
||||
var Factory := load("res://src/content/scenario_factory.gd")
|
||||
var coordinator = Coordinator.new()
|
||||
coordinator.setup(Factory.hollywood_building(), 60.0, 99)
|
||||
coordinator.start()
|
||||
coordinator.recorder.start_incident("npc_x", "player_action")
|
||||
coordinator.recorder.add_event("npc_x", {"type": "reason_requested", "reply": ""})
|
||||
coordinator.call("_finish_shift")
|
||||
var review: Dictionary = coordinator.build_review()
|
||||
var incidents: Dictionary = review.get("incidents", {})
|
||||
var list: Array = incidents.get("incidents", [])
|
||||
var a := TestUtils.check_eq(list.size(), 1, "unresolved incident appears in the review")
|
||||
var b := TestUtils.check_eq(str(list[0].get("outcome", "")), "unresolved", "outcome is unresolved")
|
||||
var open := TestUtils.check_eq(int(incidents.get("open_count", -1)), 0, "nothing left open")
|
||||
coordinator = null
|
||||
return a and b and open
|
||||
|
||||
func test_body_camera_not_on_by_default() -> bool:
|
||||
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
||||
var a := TestUtils.check(src.contains("func _set_body_camera"), "body camera has a setter")
|
||||
var b := TestUtils.check(src.contains("KEY_B"), "B toggles the body camera")
|
||||
var c := TestUtils.check(
|
||||
src.contains("level == Rules.Level.FORMAL_WARNING and not body_camera_on"),
|
||||
"a formal warning turns the body camera on")
|
||||
return a and b and c
|
||||
|
||||
func test_blocking_stations_the_guard() -> bool:
|
||||
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
||||
var a := TestUtils.check(src.contains("func _station_between"), "blocking has a stationing step")
|
||||
var b := TestUtils.check(src.contains("_station_between(controller"), "blocking calls it")
|
||||
var c := TestUtils.check(src.contains("to_door.normalized() * 0.9"), "station point is between NPC and door")
|
||||
return a and b and c
|
||||
@@ -0,0 +1,100 @@
|
||||
extends RefCounted
|
||||
## Session shell (start menu, pause overlay, settings) and routine-traffic
|
||||
## despawn. These are headless-shape tests: they instantiate the UI scripts and
|
||||
## check state transitions rather than rendering.
|
||||
|
||||
const StartMenu := preload("res://src/game/ui/start_menu.gd")
|
||||
const PauseOverlay := preload("res://src/game/ui/pause_overlay.gd")
|
||||
const SettingsPanel := preload("res://src/game/ui/settings_panel.gd")
|
||||
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
|
||||
|
||||
func test_start_menu_lists_all_venues() -> bool:
|
||||
var menu: Node = StartMenu.new()
|
||||
# Buttons are created in _ready; build them without a tree by calling _build.
|
||||
menu._build()
|
||||
var ids: Array[String] = []
|
||||
for venue in StartMenu.VENUES:
|
||||
ids.append(str(venue["id"]))
|
||||
var a := TestUtils.check_eq(ids.size(), 3, "three venues offered")
|
||||
var b := TestUtils.check(ids.has("hollywood_building"), "Hollywood Blvd present")
|
||||
var c := TestUtils.check(ids.has("joke_shop"), "Joke Shop present")
|
||||
var d := TestUtils.check(ids.has("tv_studio"), "TV Studio present")
|
||||
menu.free()
|
||||
return a and b and c and d
|
||||
|
||||
func test_start_menu_emits_selected_venue() -> bool:
|
||||
var menu: Node = StartMenu.new()
|
||||
menu._build()
|
||||
var got := {"id": ""}
|
||||
menu.venue_selected.connect(func(id): got["id"] = id)
|
||||
menu._on_venue_pressed(1)
|
||||
var a := TestUtils.check_eq(got["id"], "joke_shop", "clicking venue 2 selects the Joke Shop")
|
||||
var b := TestUtils.check(not menu.visible, "menu closes after a venue is picked")
|
||||
menu.free()
|
||||
return a and b
|
||||
|
||||
func test_start_menu_clamps_focus() -> bool:
|
||||
var menu: Node = StartMenu.new()
|
||||
menu._build()
|
||||
menu._focus_index(-5)
|
||||
var low: int = menu._index
|
||||
menu._focus_index(99)
|
||||
var high: int = menu._index
|
||||
menu.free()
|
||||
return TestUtils.check_eq(low, 0, "focus clamps at the top") \
|
||||
and TestUtils.check_eq(high, StartMenu.VENUES.size() - 1, "focus clamps at the bottom")
|
||||
|
||||
func test_pause_overlay_buttons_emit() -> bool:
|
||||
var overlay: Node = PauseOverlay.new()
|
||||
overlay._build()
|
||||
var seen: Array[String] = []
|
||||
overlay.resume_requested.connect(func(): seen.append("resume"))
|
||||
overlay.restart_requested.connect(func(): seen.append("restart"))
|
||||
overlay.settings_requested.connect(func(): seen.append("settings"))
|
||||
overlay.quit_to_menu_requested.connect(func(): seen.append("menu"))
|
||||
for node_name in ["ResumeButton", "RestartButton", "SettingsButton", "MenuButton"]:
|
||||
var button: Button = overlay.find_child(node_name, true, false) as Button
|
||||
if button:
|
||||
button.pressed.emit()
|
||||
overlay.free()
|
||||
var expected := ["resume", "restart", "settings", "menu"]
|
||||
return TestUtils.check_eq(seen, expected, "pause overlay exposes resume/restart/settings/menu")
|
||||
|
||||
func test_settings_panel_persists_values() -> bool:
|
||||
var panel: Node = SettingsPanel.new()
|
||||
panel._build()
|
||||
panel.open({"look_sensitivity": 0.0022, "hud_font_scale": 1.0})
|
||||
panel._on_sensitivity_changed(0.004)
|
||||
panel._on_font_scale_changed(1.3)
|
||||
var applied := {"settings": {}}
|
||||
panel.applied.connect(func(s): applied["settings"] = s)
|
||||
panel._on_apply()
|
||||
var s: Dictionary = applied["settings"]
|
||||
var a := TestUtils.check(is_equal_approx(float(s.get("look_sensitivity", 0.0)), 0.004), "sensitivity applied")
|
||||
var b := TestUtils.check(is_equal_approx(float(s.get("hud_font_scale", 0.0)), 1.3), "font scale applied")
|
||||
var c := TestUtils.check(not panel.visible, "panel closes on apply")
|
||||
# Restore defaults written by the apply call.
|
||||
if Save:
|
||||
Save.save_settings(Save.DEFAULT_SETTINGS)
|
||||
panel.free()
|
||||
return a and b and c
|
||||
|
||||
func test_settings_panel_cancel_discards() -> bool:
|
||||
var panel: Node = SettingsPanel.new()
|
||||
panel._build()
|
||||
panel.open({"look_sensitivity": 0.0022, "hud_font_scale": 1.0})
|
||||
panel._on_sensitivity_changed(0.005)
|
||||
var applied := {"count": 0}
|
||||
panel.applied.connect(func(_s): applied["count"] += 1)
|
||||
panel._on_cancel()
|
||||
var a := TestUtils.check(not panel.visible, "cancel closes the panel")
|
||||
var b := TestUtils.check_eq(applied["count"], 0, "cancel does not apply")
|
||||
panel.free()
|
||||
return a and b
|
||||
|
||||
func test_routine_traffic_is_ephemeral() -> bool:
|
||||
var avatar: Node = NpcAvatar.new()
|
||||
var a := TestUtils.check(avatar.ephemeral, "avatars default to ephemeral")
|
||||
var b := TestUtils.check(avatar.lifetime_s > 0.0, "ephemeral traffic has a lifetime")
|
||||
avatar.free()
|
||||
return a and b
|
||||
@@ -78,6 +78,36 @@ func test_asset_textures_present_and_pow2() -> bool:
|
||||
return TestUtils.fail("asset not power-of-two: %s (%dx%d)" % [path, w, h])
|
||||
return true
|
||||
|
||||
func test_steps_have_colliders() -> bool:
|
||||
## Steps were visual-only and the guard walked through them (TODO).
|
||||
var root := Node3D.new()
|
||||
WorldBuilder.new(root).build()
|
||||
var steps := root.find_child("Steps", true, false)
|
||||
var a := TestUtils.check(steps != null, "Steps node exists")
|
||||
var b := TestUtils.check(steps is StaticBody3D, "Steps are a physics body")
|
||||
var shape_count := 0
|
||||
if steps is StaticBody3D:
|
||||
var body := steps as StaticBody3D
|
||||
shape_count = body.get_shape_owners().size()
|
||||
var c := TestUtils.check(shape_count >= 5, "four treads + landing have colliders (got %d)" % shape_count)
|
||||
root.free()
|
||||
return a and b and c
|
||||
|
||||
func test_skyline_backdrop_uses_alpha() -> bool:
|
||||
## The opaque skyline texture made the plane render as a black slab (TODO).
|
||||
var root := Node3D.new()
|
||||
var builder := WorldBuilder.new(root)
|
||||
builder.build()
|
||||
var backdrop := root.find_child("SkylineBackdrop", true, false)
|
||||
var a := TestUtils.check(backdrop != null, "SkylineBackdrop exists")
|
||||
var mat: StandardMaterial3D = builder._skyline_mat
|
||||
var b := TestUtils.check(mat != null, "skyline material captured")
|
||||
var c := TestUtils.check(
|
||||
mat != null and mat.transparency != BaseMaterial3D.TRANSPARENCY_DISABLED,
|
||||
"skyline material uses alpha, not an opaque slab")
|
||||
root.free()
|
||||
return a and b and c
|
||||
|
||||
func test_door_indicator_built() -> bool:
|
||||
var root := Node3D.new()
|
||||
var builder := WorldBuilder.new(root)
|
||||
|
||||
@@ -231,7 +231,8 @@ def skyline():
|
||||
d.rectangle([wx, wy, wx + 6, wy + 8],
|
||||
fill=(255, 200, 120, 220))
|
||||
x += bw + rnd.randint(10, 46)
|
||||
d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255))
|
||||
# Opaque bottom border removed for transparency - planes will now render as silhouettes
|
||||
# d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255)) # commented out - was causing opaque black slabs
|
||||
save(img, "skyline.png")
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user