From be429cab296365f9c3e2f8eb63d3556f1d51ff5a Mon Sep 17 00:00:00 2001 From: Zwitschi Date: Sat, 31 Oct 2026 20:19:22 +0100 Subject: [PATCH] Add session shell and close world presentation follow-ups Session shell - Startup venue menu (src/game/ui/start_menu.gd): keyboard + mouse, replaces the live-shift boot and V as the player-facing venue path (V stays a debug bind). - Pause overlay on Esc (src/game/ui/pause_overlay.gd): resume, restart shift, settings, quit to menu. - Settings panel (src/game/ui/settings_panel.gd): mouse look sensitivity and HUD font scale, persisted via Save into user://settings.json and loaded on boot. - Shift review "Run another shift" now returns to the venue menu. - New smoke: src/test/smoke_session_shell.gd (menu -> shift -> pause -> settings -> menu), passes headless and windowed. World and presentation - Skyline backdrop restored with a transparent asset and an alpha-scissor unshaded material; fixes the opaque black slab from the earlier attempt and the unimported texture path. - Steps and landing are a StaticBody3D with per-tread colliders, so the guard and NPCs walk up instead of clipping through. - Marquee Label3D scaled up (72 -> 160) so STEP LINE reads from the sidewalk. - Focus torus and name tag enlarged for 2-3 m readability. - Routine traffic despawns after an idle lifetime (NpcAvatar.ephemeral), so the 16-NPC cap no longer fills with standers; authored events stay for the shift. Docs and tests - PLAN.md test/CI section corrected to the custom headless runner and Gitea Actions. - README controls table, smoke checklist, test count, and status lists refreshed. - TODO/DONE/MEMORY updated; completed sections moved to DONE.md. - Headless suite 77 -> 88 passing (settings round-trip, step colliders, skyline alpha, session-shell UI behavior, traffic lifetime). --- DONE.md | 55 ++++++++++ MEMORY.md | 35 ++++-- PLAN.md | 8 +- README.md | 50 +++++---- TODO.md | 41 +------ assets/textures/skyline.png | Bin 2202 -> 2204 bytes src/autoload/save.gd | 26 +++++ src/game/game_manager.gd | 155 +++++++++++++++++++++++++-- src/game/npc_avatar.gd | 26 ++++- src/game/ui/hud.gd | 12 +++ src/game/ui/pause_overlay.gd | 84 +++++++++++++++ src/game/ui/settings_panel.gd | 154 ++++++++++++++++++++++++++ src/game/ui/start_menu.gd | 140 ++++++++++++++++++++++++ src/game/world_builder.gd | 64 +++++++++-- src/test/smoke_focus_radial.gd | 12 +++ src/test/smoke_session_shell.gd | 63 +++++++++++ src/test/tests/test_persistence.gd | 21 ++++ src/test/tests/test_session_shell.gd | 100 +++++++++++++++++ src/test/tests/test_world.gd | 30 ++++++ tools/generate_textures.py | 3 +- 20 files changed, 989 insertions(+), 90 deletions(-) create mode 100644 src/game/ui/pause_overlay.gd create mode 100644 src/game/ui/settings_panel.gd create mode 100644 src/game/ui/start_menu.gd create mode 100644 src/test/smoke_session_shell.gd create mode 100644 src/test/tests/test_session_shell.gd diff --git a/DONE.md b/DONE.md index be4194a..34a0e71 100644 --- a/DONE.md +++ b/DONE.md @@ -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,48 @@ 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. diff --git a/MEMORY.md b/MEMORY.md index 2404b78..fb7fc64 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -16,15 +16,15 @@ 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 ahead of origin, all committed locally, **not pushed**. The working tree additionally + holds the session-shell + world-follow-up batch (start menu, pause overlay, settings persistence, + step colliders, skyline alpha, marquee/ring scale, traffic despawn, README/PLAN/TODO refresh). +- Next work, in order: (1) remaining PLAN verbs in `TODO.md` (eye contact, recognize decision, + warning body-cam flag, blocking station, request reason); (2) crowd presentation (rubberneck + + evacuation); (3) `git add -A` + commit, push / release when the user asks. - Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg. ## Current State @@ -46,7 +46,24 @@ 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). +- **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. diff --git a/PLAN.md b/PLAN.md index 05f2cf7..353968a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 diff --git a/README.md b/README.md index 8b55f6d..bc8e6b3 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,9 @@ 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 ``` The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor. @@ -216,7 +219,9 @@ The unit test runner is the project's own lightweight harness (no editor plugin | Space / left click | Action wheel for the focused person | | A / D or ←/→ | Step through the action wheel (while it is open) | | Space / left click | Confirm the highlighted action (while the wheel is open) | -| Esc / right click | Close the top panel; Esc with nothing open releases / captures the mouse | +| 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,7 +231,7 @@ 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) | | L | Lock / unlock the restricted door (thwarts forced entry) | @@ -253,31 +258,40 @@ 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: **88 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell) - 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 - 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, eye-contact and request-reason verbs, crowd rubbernecking, emergency aftermath, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`). ### Smoke Test Checklist -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. `Esc` with nothing open pauses the shift; resume / restart / settings / quit-to-menu all work, and settings changes persist across a restart. +19. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run). +20. When the shift timer reaches 0, the shift review screen shows grade, incident summary, and persisted shift history; "Run another shift" returns to the venue menu. ## Development Workflow diff --git a/TODO.md b/TODO.md index 30b9210..c25e182 100644 --- a/TODO.md +++ b/TODO.md @@ -1,39 +1,12 @@ # 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. +has the access-loadout fix, the ADR-0007–0010 control pass, and the session-shell / world +follow-up batch; headless suite **88/88**, plus `smoke_session_shell.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 @@ -101,14 +74,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): diff --git a/assets/textures/skyline.png b/assets/textures/skyline.png index e9be6c30a0f2a7b577635b92d85134727a4fdcf9..7a8a564946672a2ed9fa4ef84a089789cb642aed 100644 GIT binary patch literal 2204 zcmZux4Nz276h80mE-d8o;}1WJ;9~^JB3(7HU|65!&yocMSf)&1nhK@6vzS>JUQ{51 z@To;c%}CSs!#IkXBq_=!rdy_BWD1IGilCzivWl}JOXuFVC63;Kvv=>Fd(L;h^WC#P zIdP`OTk8z~ngqkN6aXb|IZ%7x$J3F@2mtkhglSXLRyKFG{PJ#d_V9vJ8=}3f!>xx~ zYLwo~y_VOnEMvOQJ+jC~S7oIw*%P=+`UFdcs! zQ{X8zuuuu)w`9$OfP!iM3EAyW5P2A8IrWu|O-`D5m7pB%XQSfg1V7EsEofz(d&xHrT3 z2~Vyw_dl_ezjkqq(%=lrI$bQ8$hHeTL9Ck;_s(}gZicXOVe2SGcZg~*v+N=Uz(5U_ zy(bl5clpS5GPVT-{zYh_#_xU*j4sRyQUTPcL-GM^vLDNd7n}6HK>Fh3#v(|B8Le9u z(}@BRvk7Aw&*V#%569p>aQ-9E%dP%>sE7_r)~Sxr!ZL%tZ&}e*JPDhHYmjI}`dwC^ zEEMS^G9e}H`Dae-37K-%B^__PoG?gntikZX<${e`8bI==R-^`yJ&k_B#j~7Udr`Y7 zY0qH46}e1 zFK;<;TJ@(tY`hhK>@s2EO91G(8gCyzP-v_+a2(_6U`SU5&qV;Qg#cTQ#J6p4FX&6!eZN

zx&834q8l)hzu>OY05Eh$6&*NE<5kq*{ zi7M?%ma(IjQ?mC|S*Q@#)|s%uxKJXv*h6HuK#C*)&V34YCd~JcLfL-rQ5Co`L!|h^axch zSKxCgm!=Fue#(GW$$DU^^v3;!A~i@m6Ho)->)^5fvZzbY3#6D3>P`O0s9zWOqLwm) z`3*1IwtC4_OI=G81W!R&I$NGh@%kl0>4E4N^|J#d01!l|#j?=|jx)OcoR{d<{p_e@ zh0}B0PtKsIFy>Y1j2e>*J?P^A*mt+5k+oy6;uxQ-7^$kaZCgC>A}k!oDFBr)6B+jec6(>!_Kor7WdHn zFgX0DWoy9V;{AvEkK~jD4mV62>1*f+jEmG}#uRqUD%;fShiD#7^0Z4cKnS1;LIsGMt(-8Zj_`@Fpg9#)3siuD3V{kp zQ}U4OOs{I&Ue^_SGngCe#wPo#zW-)xYW3guvrUmmQ{$r*^)_FyEtdf}m=P-iOSc1@ zb(M0|98;1{0d#@odvJ^DKtcsS1BDP-fo}?2Qgxu#PYz%fQgs=S=Np{{mH;eZEPf!QX(&zpZN$f zRGF$9vz<#6hmwc#&fQLkc^VubuCGy1eq>4;EgUk+;MOw3!_(np~AT|68+$D z;8(7up$X0}!=vw-(OVuC83pQ$y)s zP1@F;%XL<1!~$iRCl8y;n%~3IiEm)3#fIX_D#(G%>{$BaNGOYxHLT%fh=2&TG-Y08 zkSYVEfQs(=sib2gI9Nh?6-nG!a|pmzGk5C2n)}7TPoceq>`43p9%ia{RP4An{DoNQ z3^0$|Vrx-@;%H!!7^{CsP0mpv9oHqG>#94Qv)2B#*bTIqyNl-1N)B(N4B6_Go#AMY zwy0;~(7tE&!c&Yh23 zBbE5DLd0$n%Abh22r$o?jV^i18l>HUjg3dCpdH{S@E%Pw&vYzC!j_aGM?yS3tc#-s zzeLR%3pbe$)Z>HQ4Ff0ICJx4U8ui4T}I>@I<0jQYid5#&jT6> z64W_SJt`iN;#kuML4bQ--fOrX2U+aKFwD85C0GnYnFk>O@T{8#M!{e_c#S01abuEW zipZ$~1Vy~HCwa)rRy#9Pd_7` z90};g6?~M3yNvM!{*jNb9yN7)Ge}x{* zK4A_|6l!CND2lNaCs2hLjLW?0s#D5Cb?*6$_igpc26QB%ZyX4ORhGVUE$!sm+N;}@ xuc$rSK*g>RS56kPIPSy6AXDN|O$BTjQrEO=t~3^`yg^@$%j1`weK+Nke*h5<=jQ+b diff --git a/src/autoload/save.gd b/src/autoload/save.gd index 6c4f1a2..0f68078 100644 --- a/src/autoload/save.gd +++ b/src/autoload/save.gd @@ -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) diff --git a/src/game/game_manager.gd b/src/game/game_manager.gd index e3ee4d2..52b27ce 100644 --- a/src/game/game_manager.gd +++ b/src/game/game_manager.gd @@ -23,6 +23,9 @@ 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() @@ -62,6 +65,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 +107,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 +124,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 +135,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 +190,106 @@ 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 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 +313,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 +431,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 +487,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) @@ -510,13 +636,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: @@ -610,6 +745,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: diff --git a/src/game/npc_avatar.gd b/src/game/npc_avatar.gd index 4c6ead9..ee67fde 100644 --- a/src/game/npc_avatar.gd +++ b/src/game/npc_avatar.gd @@ -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) diff --git a/src/game/ui/hud.gd b/src/game/ui/hud.gd index 9c07e66..6f8acb0 100644 --- a/src/game/ui/hud.gd +++ b/src/game/ui/hud.gd @@ -279,6 +279,18 @@ func set_wand_icon(visible: bool) -> void: if wand_icon: wand_icon.visible = visible +## 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 diff --git a/src/game/ui/pause_overlay.gd b/src/game/ui/pause_overlay.gd new file mode 100644 index 0000000..ffd1444 --- /dev/null +++ b/src/game/ui/pause_overlay.gd @@ -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() diff --git a/src/game/ui/settings_panel.gd b/src/game/ui/settings_panel.gd new file mode 100644 index 0000000..609e9e1 --- /dev/null +++ b/src/game/ui/settings_panel.gd @@ -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() diff --git a/src/game/ui/start_menu.gd b/src/game/ui/start_menu.gd new file mode 100644 index 0000000..dc95514 --- /dev/null +++ b/src/game/ui/start_menu.gd @@ -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"])) diff --git a/src/game/world_builder.gd b/src/game/world_builder.gd index 5e077c4..a1351a5 100644 --- a/src/game/world_builder.gd +++ b/src/game/world_builder.gd @@ -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.albedo_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) diff --git a/src/test/smoke_focus_radial.gd b/src/test/smoke_focus_radial.gd index c9afd33..bd6ea69 100644 --- a/src/test/smoke_focus_radial.gd +++ b/src/test/smoke_focus_radial.gd @@ -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" % [ diff --git a/src/test/smoke_session_shell.gd b/src/test/smoke_session_shell.gd new file mode 100644 index 0000000..03a776f --- /dev/null +++ b/src/test/smoke_session_shell.gd @@ -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 diff --git a/src/test/tests/test_persistence.gd b/src/test/tests/test_persistence.gd index 2a0ed7a..e2641d9 100644 --- a/src/test/tests/test_persistence.gd +++ b/src/test/tests/test_persistence.gd @@ -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 diff --git a/src/test/tests/test_session_shell.gd b/src/test/tests/test_session_shell.gd new file mode 100644 index 0000000..13057c0 --- /dev/null +++ b/src/test/tests/test_session_shell.gd @@ -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 diff --git a/src/test/tests/test_world.gd b/src/test/tests/test_world.gd index d520d9c..c55a5fd 100644 --- a/src/test/tests/test_world.gd +++ b/src/test/tests/test_world.gd @@ -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) diff --git a/tools/generate_textures.py b/tools/generate_textures.py index 37ed849..5713c23 100644 --- a/tools/generate_textures.py +++ b/tools/generate_textures.py @@ -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")