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).
This commit is contained in:
1 parent
f11b441c07
commit
be429cab29
20 files changed
+989
-90
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).
|
(`src/test/smoke_focus_radial.gd`, exits non-zero on failure).
|
||||||
- [x] README controls table, smoke checklist, test count, implemented/not-yet lists.
|
- [x] README controls table, smoke checklist, test count, implemented/not-yet lists.
|
||||||
|
|
||||||
|
## 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
|
## Runtime boot and spawn fixes
|
||||||
|
|
||||||
Found while running the windowed smoke; all verified with a boot run and regenerated
|
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
|
- [x] Removed the skyline backdrop planes (opaque black texture). Tracked as a follow-up in
|
||||||
`TODO.md`.
|
`TODO.md`.
|
||||||
- [x] README screenshots regenerated with the fixed eye height and entrance staging.
|
- [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.
|
||||||
@@ -16,15 +16,15 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
## Handoff (read this first)
|
## Handoff (read this first)
|
||||||
|
|
||||||
- Repo: `/run/media/system/DATA/Documents/06-games/security-game`. Godot: `~/.local/bin/godot` (4.3.stable).
|
- Repo: `/run/media/system/DATA/Documents/06-games/security-game`. 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.
|
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
|
- `main` is ahead of origin, all committed locally, **not pushed**. The working tree additionally
|
||||||
is the control pass + boot/spawn fixes (access loadout, focus cone, action wheel, named input
|
holds the session-shell + world-follow-up batch (start menu, pause overlay, settings persistence,
|
||||||
actions, NPC rig, camera/building fixes, scheduler rate, NPC cap, screenshots, docs).
|
step colliders, skyline alpha, marquee/ring scale, traffic despawn, README/PLAN/TODO refresh).
|
||||||
- Nothing is left uncommitted. Ask before pushing if you did not author the earlier commits.
|
- Next work, in order: (1) remaining PLAN verbs in `TODO.md` (eye contact, recognize decision,
|
||||||
- Next work, in order: (1) session shell in `TODO.md`; (2) world follow-ups listed at the top of
|
warning body-cam flag, blocking station, request reason); (2) crowd presentation (rubberneck +
|
||||||
`TODO.md` (guard floats ~0.5 m, step colliders, skyline asset, marquee size); (3) push / release
|
evacuation); (3) `git add -A` + commit, push / release when the user asks.
|
||||||
when the user asks.
|
|
||||||
- Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg.
|
- Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg.
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
@@ -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,
|
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
|
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);
|
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.
|
- Host sign-in follow-up remains deferred. PLAN.md + ADRs 0007–0010 still describe the intended UX.
|
||||||
- **Release v0.2.1 is LIVE** (2026-10-31): CI run 1110 built the NSIS installer + AppImage
|
- **Release v0.2.1 is LIVE** (2026-10-31): CI run 1110 built the NSIS installer + AppImage
|
||||||
and published — installers/AppImage are on the releases page. Releasing = bump version → tag → push.
|
and published — installers/AppImage are on the releases page. Releasing = bump version → tag → push.
|
||||||
|
|||||||
@@ -432,13 +432,15 @@ The venue-driven architecture maps onto Godot as follows:
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- Unit and scenario tests use the GUT (Godot Unit Test) framework.
|
- Unit and scenario tests use a lightweight custom headless runner
|
||||||
|
(`src/test/test_runner.gd`). GUT was evaluated but requires the editor plugin.
|
||||||
- Tests run in headless mode so rules, access verification, escalation, and scoring can be verified without rendering or user input.
|
- Tests run in headless mode so rules, access verification, escalation, and scoring can be verified without rendering or user input.
|
||||||
|
|
||||||
## Version Control and CI
|
## Version Control and CI
|
||||||
|
|
||||||
- Source is tracked with git.
|
- Source is tracked with git.
|
||||||
- GitHub Actions runs headless Godot tests and, in later stages, exports desktop builds.
|
- Gitea Actions (`.gitea/workflows/build.yml`) runs headless Godot tests on push/PR and exports
|
||||||
|
Linux/Windows/macOS desktop builds plus the NSIS installer and AppImage on tags.
|
||||||
|
|
||||||
## Planned Commands
|
## Planned Commands
|
||||||
|
|
||||||
@@ -446,7 +448,7 @@ The following commands apply once the Godot project is scaffolded:
|
|||||||
|
|
||||||
- Open the editor: `godot --editor --path .`
|
- Open the editor: `godot --editor --path .`
|
||||||
- Run the game: `godot --path .`
|
- Run the game: `godot --path .`
|
||||||
- Run tests headlessly via GUT.
|
- Run tests headlessly: `godot --headless --path . res://src/test/test_runner.tscn`
|
||||||
|
|
||||||
## Export Targets
|
## Export Targets
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
# Windowed control smoke: focus ring + action wheel, saves PNGs, exits 1 on failure
|
||||||
godot --path . --script res://src/test/smoke_focus_radial.gd
|
godot --path . --script res://src/test/smoke_focus_radial.gd
|
||||||
|
|
||||||
|
# Session-shell smoke: menu → shift → pause → settings → menu (headless or windowed)
|
||||||
|
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.
|
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 |
|
| Space / left click | Action wheel for the focused person |
|
||||||
| A / D or ←/→ | Step through the action wheel (while it is open) |
|
| A / D or ←/→ | Step through the action wheel (while it is open) |
|
||||||
| Space / left click | Confirm the highlighted action (while the wheel is open) |
|
| Space / left click | Confirm the highlighted action (while the wheel is open) |
|
||||||
| Esc / right click | Close the top panel; Esc with nothing open 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) |
|
| 1 / 2 / 3 | Quick gestures without the wheel: raise hand, point, head shake (Level 1) |
|
||||||
| Q | Polite direction (Level 1) |
|
| Q | Polite direction (Level 1) |
|
||||||
| 4 | Formal warning (Level 2) |
|
| 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 |
|
| E | Inspect access artifacts / close inspection |
|
||||||
| C | Handheld wand screening (wand venues only) |
|
| C | Handheld wand screening (wand venues only) |
|
||||||
| A / D | Allow / deny entry during inspection or wand check |
|
| A / D | Allow / deny entry during inspection or wand check |
|
||||||
| V | Cycle venue: Hollywood Blvd → The Joke Shop → TV Studio (restarts shift) |
|
| V | Cycle venue (debug shortcut; restart via V restarts the current shift) |
|
||||||
| Tab | Cycle target among nearby NPCs (aim-free alternative) |
|
| Tab | Cycle target among nearby NPCs (aim-free alternative) |
|
||||||
| G | Toggle flashlight (widens NPC perception at night) |
|
| G | Toggle flashlight (widens NPC perception at night) |
|
||||||
| L | Lock / unlock the restricted door (thwarts forced entry) |
|
| 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
|
- Godot 4.3 project scaffold, input map, procedural world builder
|
||||||
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
|
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
|
||||||
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
|
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
|
||||||
- Headless unit tests: **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
|
- Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event
|
||||||
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
|
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
|
||||||
- Control pass: focus cone on NPC heads with a billboarded gold ring and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`)
|
- Control pass: focus cone on NPC heads with a billboarded 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
|
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
|
||||||
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
|
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
|
||||||
|
|
||||||
Not yet implemented: body-camera replay timeline, venue selection startup menu, 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
|
### Smoke Test Checklist
|
||||||
|
|
||||||
1. `godot --path .` starts the game without script errors.
|
1. `godot --path .` starts the game on the venue menu without script errors.
|
||||||
2. The guard moves and looks around; the building, steps, and sidewalk are visible.
|
2. Choosing a venue (click or ↑/↓ + Enter) starts that venue's shift and hides the menu.
|
||||||
3. NPCs spawn and approach the entrance during the first minutes.
|
3. The guard moves and looks around; the building, steps, and sidewalk are visible.
|
||||||
4. Looking at an NPC inside the focus cone shows a gold head ring, the person's name, and the HUD target line.
|
4. The guard's camera sits at eye height and the guard stands on the landing, not inside the building.
|
||||||
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.
|
5. Walking into the steps carries the guard up onto the landing (steps have colliders).
|
||||||
6. `E`/`C` open the inspection/wand panels from the wheel and freeze movement until they close; walking never fires the wand.
|
6. The skyline backdrop shows silhouettes against the sky, not an opaque black slab.
|
||||||
7. Pressing `Q` produces a reaction log entry and reputation feedback.
|
7. The `STEP LINE` marquee is readable from the sidewalk.
|
||||||
8. `E` opens access inspection showing venue-correct artifacts and policy result; `A`/`D` record a decision.
|
8. NPCs spawn and approach the entrance during the first minutes.
|
||||||
9. `6` opens the assistance form; submitting shows dispatch feedback.
|
9. Looking at an NPC inside the focus cone shows a gold head ring, the person's name, and the HUD target line.
|
||||||
10. `R` opens the emergency form; the button must be held ~1.6s or the call cancels.
|
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. `G` toggles the flashlight (indicator appears in the HUD); `L` toggles the door lock (indicator turns red when locked).
|
11. `E`/`C` open the inspection/wand panels from the wheel and freeze movement until they close; walking never fires the wand.
|
||||||
12. `F` toggles the debug overlay with NPC states and shift stats.
|
12. Pressing `Q` produces a reaction log entry and reputation feedback.
|
||||||
13. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run).
|
13. `E` opens access inspection showing venue-correct artifacts and policy result; `A`/`D` record a decision.
|
||||||
14. When the shift timer reaches 0, the shift review screen shows grade, incident summary, and persisted shift history.
|
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
|
## Development Workflow
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +1,12 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
Godot 4.3 prototype. Release **v0.2.1** is live on the host. The working tree (uncommitted)
|
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
|
has the access-loadout fix, the ADR-0007–0010 control pass, and the session-shell / world
|
||||||
windowed focus/radial smoke pass.
|
follow-up batch; headless suite **88/88**, plus `smoke_session_shell.gd` and the windowed
|
||||||
|
focus/radial smoke.
|
||||||
|
|
||||||
Host `REQUIRE_SIGNIN_VIEW` stays deferred.
|
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)
|
## Remaining PLAN verbs (radial exists)
|
||||||
|
|
||||||
- [ ] Eye contact as a Level 1 action (PLAN / glossary). Distinct from raise-hand. Add a
|
- [ ] 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
|
- [ ] Building: "activate body camera" and "request reason" once those verbs exist, used in
|
||||||
at least one authored event.
|
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)
|
## Distribution follow-up (host config, deferred)
|
||||||
|
|
||||||
Verified via API+host credentials (2026-10-31):
|
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 |
@@ -37,3 +37,29 @@ func save_shift_result(result: Dictionary) -> Error:
|
|||||||
var results := load_shift_results()
|
var results := load_shift_results()
|
||||||
results.append(result)
|
results.append(result)
|
||||||
return _save_json(SHIFT_RESULTS_FILE, results)
|
return _save_json(SHIFT_RESULTS_FILE, results)
|
||||||
|
|
||||||
|
## Defaults for player settings. Kept in code so a missing or partial
|
||||||
|
## settings.json still produces a usable config.
|
||||||
|
const DEFAULT_SETTINGS := {
|
||||||
|
"look_sensitivity": 0.0022,
|
||||||
|
"hud_font_scale": 1.0,
|
||||||
|
"ai_difficulty": "normal",
|
||||||
|
}
|
||||||
|
|
||||||
|
## Load player settings, merged over the defaults. Returns a Dictionary.
|
||||||
|
func load_settings() -> Dictionary:
|
||||||
|
var merged: Dictionary = DEFAULT_SETTINGS.duplicate(true)
|
||||||
|
var data = _load_json(SETTINGS_FILE)
|
||||||
|
if data is Dictionary:
|
||||||
|
for key in data.keys():
|
||||||
|
if merged.has(key):
|
||||||
|
merged[key] = data[key]
|
||||||
|
return merged
|
||||||
|
|
||||||
|
## Persist player settings. Returns Error.
|
||||||
|
func save_settings(settings: Dictionary) -> Error:
|
||||||
|
var merged: Dictionary = DEFAULT_SETTINGS.duplicate(true)
|
||||||
|
for key in settings.keys():
|
||||||
|
if merged.has(key):
|
||||||
|
merged[key] = settings[key]
|
||||||
|
return _save_json(SETTINGS_FILE, merged)
|
||||||
+148
-7
@@ -23,6 +23,9 @@ const Hud := preload("res://src/game/ui/hud.gd")
|
|||||||
const ActionRadial := preload("res://src/game/ui/action_radial.gd")
|
const ActionRadial := preload("res://src/game/ui/action_radial.gd")
|
||||||
const RadioPanel := preload("res://src/game/ui/radio_panel.gd")
|
const RadioPanel := preload("res://src/game/ui/radio_panel.gd")
|
||||||
const ShiftReview := preload("res://src/game/ui/shift_review.gd")
|
const ShiftReview := preload("res://src/game/ui/shift_review.gd")
|
||||||
|
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 world_builder: WorldBuilder = null
|
||||||
var door := DoorState.new()
|
var door := DoorState.new()
|
||||||
@@ -62,6 +65,11 @@ var hud: Hud = null
|
|||||||
var radial: ActionRadial = null
|
var radial: ActionRadial = null
|
||||||
var radio_panel: RadioPanel = null
|
var radio_panel: RadioPanel = null
|
||||||
var shift_review_panel: ShiftReview = null
|
var shift_review_panel: ShiftReview = null
|
||||||
|
var 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 _focused_avatar: Node3D = null
|
||||||
var audio: AudioFeedback = null
|
var audio: AudioFeedback = null
|
||||||
var verifier := AccessVerifier.new()
|
var verifier := AccessVerifier.new()
|
||||||
@@ -99,6 +107,7 @@ var _nearby_index := -1
|
|||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_door_rng.randomize()
|
_door_rng.randomize()
|
||||||
|
player_settings = Save.load_settings() if Save else {}
|
||||||
scene_root = Node3D.new()
|
scene_root = Node3D.new()
|
||||||
scene_root.name = "SceneRoot"
|
scene_root.name = "SceneRoot"
|
||||||
add_child(scene_root)
|
add_child(scene_root)
|
||||||
@@ -115,6 +124,7 @@ func _ready() -> void:
|
|||||||
guard.rotation.y = PI
|
guard.rotation.y = PI
|
||||||
guard.picked_target.connect(_on_guard_target)
|
guard.picked_target.connect(_on_guard_target)
|
||||||
guard.cleared_target.connect(_on_guard_clear)
|
guard.cleared_target.connect(_on_guard_clear)
|
||||||
|
_apply_settings(player_settings)
|
||||||
|
|
||||||
selected_scenario = _build_scenario(active_scenario_id)
|
selected_scenario = _build_scenario(active_scenario_id)
|
||||||
scenario_duration_s = selected_scenario.shift_duration_s
|
scenario_duration_s = selected_scenario.shift_duration_s
|
||||||
@@ -125,9 +135,10 @@ func _ready() -> void:
|
|||||||
coordinator.radio.responder_arrived.connect(_on_responder_arrived)
|
coordinator.radio.responder_arrived.connect(_on_responder_arrived)
|
||||||
|
|
||||||
_build_ui()
|
_build_ui()
|
||||||
coordinator.start()
|
|
||||||
_last_phase = -1
|
_last_phase = -1
|
||||||
_apply_phase_lighting()
|
_apply_phase_lighting()
|
||||||
|
# Boot into the venue menu; the shift starts when the player picks a venue.
|
||||||
|
_show_start_menu()
|
||||||
|
|
||||||
var selected_scenario: VenueScenario = null
|
var selected_scenario: VenueScenario = null
|
||||||
|
|
||||||
@@ -179,20 +190,106 @@ func switch_scenario(id: String) -> void:
|
|||||||
restart_shift()
|
restart_shift()
|
||||||
|
|
||||||
func restart_shift() -> void:
|
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:
|
for n in npcs:
|
||||||
n.queue_free()
|
if is_instance_valid(n):
|
||||||
|
n.queue_free()
|
||||||
npcs.clear()
|
npcs.clear()
|
||||||
npc_counter = 0
|
npc_counter = 0
|
||||||
inspector_current_events = []
|
inspector_current_events = []
|
||||||
current_incident_active = false
|
current_incident_active = false
|
||||||
current_incident_npc_id = ""
|
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
|
scenario_duration_s = selected_scenario.shift_duration_s
|
||||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||||
coordinator.start()
|
|
||||||
_last_phase = -1
|
_last_phase = -1
|
||||||
_apply_phase_lighting()
|
_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 = []
|
var inspector_current_events: Array = []
|
||||||
|
|
||||||
func _build_ui() -> void:
|
func _build_ui() -> void:
|
||||||
@@ -216,7 +313,32 @@ func _build_ui() -> void:
|
|||||||
shift_review_panel = ShiftReview.new()
|
shift_review_panel = ShiftReview.new()
|
||||||
add_child(shift_review_panel)
|
add_child(shift_review_panel)
|
||||||
shift_review_panel.hide()
|
shift_review_panel.hide()
|
||||||
shift_review_panel.restart_requested.connect(restart_shift)
|
shift_review_panel.restart_requested.connect(_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:
|
func _add_crosshair() -> void:
|
||||||
var layer := CanvasLayer.new()
|
var layer := CanvasLayer.new()
|
||||||
@@ -309,6 +431,9 @@ func _spawn_npc(profile: NpcProfile, intent: String, npc_id: String, event: Dict
|
|||||||
items.append(str(item))
|
items.append(str(item))
|
||||||
spawn_profile.carried_items = items
|
spawn_profile.carried_items = items
|
||||||
avatar.setup(spawn_profile, intent, seed_value, spawn_pos, target_pos, door_pos, npc_id, override)
|
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)
|
scene_root.add_child(avatar)
|
||||||
npcs.append(avatar)
|
npcs.append(avatar)
|
||||||
|
|
||||||
@@ -362,6 +487,7 @@ func _responder_profile() -> NpcProfile:
|
|||||||
return p
|
return p
|
||||||
|
|
||||||
func _on_shift_ended(review: Dictionary) -> void:
|
func _on_shift_ended(review: Dictionary) -> void:
|
||||||
|
_shift_running = false
|
||||||
# Persist the finished shift locally (Save autoload, user:// JSON).
|
# Persist the finished shift locally (Save autoload, user:// JSON).
|
||||||
if Save:
|
if Save:
|
||||||
Save.save_shift_result(review)
|
Save.save_shift_result(review)
|
||||||
@@ -510,13 +636,22 @@ func _handle_action_key(keycode: Key) -> void:
|
|||||||
_:
|
_:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
## `cancel` (Esc, RMB) closes the top modal. With nothing open, Esc toggles mouse
|
## `cancel` (Esc, RMB) closes the top modal. With nothing open, Esc pauses the
|
||||||
## capture; RMB does nothing so it never grabs the mouse by accident.
|
## shift (or resumes it when paused); RMB does nothing so it never grabs the
|
||||||
|
## mouse by accident.
|
||||||
func _handle_cancel(from_mouse: bool) -> void:
|
func _handle_cancel(from_mouse: bool) -> void:
|
||||||
if _close_top_modal():
|
if _close_top_modal():
|
||||||
return
|
return
|
||||||
if from_mouse:
|
if from_mouse:
|
||||||
return
|
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:
|
if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
|
||||||
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||||
else:
|
else:
|
||||||
@@ -610,6 +745,12 @@ func _refresh_freeze() -> void:
|
|||||||
freeze = true
|
freeze = true
|
||||||
if shift_review_panel and shift_review_panel.visible:
|
if shift_review_panel and shift_review_panel.visible:
|
||||||
freeze = true
|
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:
|
if guard:
|
||||||
guard.input_locked = freeze
|
guard.input_locked = freeze
|
||||||
if freeze:
|
if freeze:
|
||||||
|
|||||||
+21
-5
@@ -12,6 +12,11 @@ var target_position: Vector3 = Vector3.ZERO
|
|||||||
var door_position: Vector3 = Vector3(0, 0.5, 1.2)
|
var door_position: Vector3 = Vector3(0, 0.5, 1.2)
|
||||||
var awaiting_interaction := false
|
var awaiting_interaction := false
|
||||||
var arrived := false
|
var arrived := false
|
||||||
|
## Seconds idle at an arrived waypoint. Ordinary traffic lives for a while, then
|
||||||
|
## despawns so the entrance does not fill with standers (TODO: NPC crowd cap).
|
||||||
|
var idle_s := 0.0
|
||||||
|
var lifetime_s := 90.0
|
||||||
|
var ephemeral := true
|
||||||
var agent: NavigationAgent3D = null
|
var agent: NavigationAgent3D = null
|
||||||
## Set once the nav agent proves it has a path; direct movement is the fallback.
|
## Set once the nav agent proves it has a path; direct movement is the fallback.
|
||||||
var _nav_usable := false
|
var _nav_usable := false
|
||||||
@@ -123,8 +128,8 @@ func _build_focus_ring() -> void:
|
|||||||
_ring = MeshInstance3D.new()
|
_ring = MeshInstance3D.new()
|
||||||
_ring.name = "FocusRing"
|
_ring.name = "FocusRing"
|
||||||
var torus := TorusMesh.new()
|
var torus := TorusMesh.new()
|
||||||
torus.inner_radius = 0.16
|
torus.inner_radius = 0.26
|
||||||
torus.outer_radius = 0.20
|
torus.outer_radius = 0.34
|
||||||
torus.rings = 12
|
torus.rings = 12
|
||||||
torus.ring_segments = 16
|
torus.ring_segments = 16
|
||||||
_ring.mesh = torus
|
_ring.mesh = torus
|
||||||
@@ -139,10 +144,10 @@ func _build_focus_ring() -> void:
|
|||||||
_name_tag = Label3D.new()
|
_name_tag = Label3D.new()
|
||||||
_name_tag.name = "NameTag"
|
_name_tag.name = "NameTag"
|
||||||
_name_tag.text = npc.profile.display_name if npc and npc.profile else ""
|
_name_tag.text = npc.profile.display_name if npc and npc.profile else ""
|
||||||
_name_tag.font_size = 28
|
_name_tag.font_size = 48
|
||||||
_name_tag.pixel_size = 0.004
|
_name_tag.pixel_size = 0.004
|
||||||
_name_tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
_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.modulate = Color(0.95, 0.95, 0.9, 0.9)
|
||||||
_name_tag.visible = false
|
_name_tag.visible = false
|
||||||
_head.add_child(_name_tag)
|
_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)
|
_head.rotation.y = lerp_angle(_head.rotation.y, world_yaw - parent_yaw, 0.1)
|
||||||
|
|
||||||
func _advance_fsm(delta: float) -> void:
|
func _advance_fsm(delta: float) -> void:
|
||||||
|
# Routine traffic despawns after a while once it has finished its walk, so
|
||||||
|
# the crowd cap is not permanently occupied by idle standers.
|
||||||
|
if ephemeral and arrived and npc.fsm.state != "LEAVING":
|
||||||
|
idle_s += delta
|
||||||
|
if idle_s >= lifetime_s:
|
||||||
|
leave_requested_by_lifetime = true
|
||||||
|
return
|
||||||
# Returning-trespasser: an NPC directed away may circle back to the door.
|
# Returning-trespasser: an NPC directed away may circle back to the door.
|
||||||
if npc.tick_return(delta):
|
if npc.tick_return(delta):
|
||||||
arrived = false
|
arrived = false
|
||||||
awaiting_interaction = false
|
awaiting_interaction = false
|
||||||
target_position = door_position
|
target_position = door_position
|
||||||
return
|
return
|
||||||
if npc.leave_requested:
|
if npc.leave_requested or leave_requested_by_lifetime:
|
||||||
var away := global_position + (global_position - spawn_position).normalized() * 40.0
|
var away := global_position + (global_position - spawn_position).normalized() * 40.0
|
||||||
away.y = 0.5
|
away.y = 0.5
|
||||||
target_position = away
|
target_position = away
|
||||||
|
if (global_position - away).length() < 2.0:
|
||||||
|
queue_free()
|
||||||
return
|
return
|
||||||
if arrived and not awaiting_interaction:
|
if arrived and not awaiting_interaction:
|
||||||
awaiting_interaction = true
|
awaiting_interaction = true
|
||||||
|
|
||||||
|
var leave_requested_by_lifetime := false
|
||||||
|
|
||||||
func _move_toward_target(delta: float) -> void:
|
func _move_toward_target(delta: float) -> void:
|
||||||
if agent == null:
|
if agent == null:
|
||||||
_move_direct(delta)
|
_move_direct(delta)
|
||||||
|
|||||||
@@ -279,6 +279,18 @@ func set_wand_icon(visible: bool) -> void:
|
|||||||
if wand_icon:
|
if wand_icon:
|
||||||
wand_icon.visible = visible
|
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:
|
func _make_icon(texture: Texture2D) -> TextureRect:
|
||||||
var tr := TextureRect.new()
|
var tr := TextureRect.new()
|
||||||
tr.texture = texture
|
tr.texture = texture
|
||||||
|
|||||||
@@ -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_CONCRETE := preload("res://assets/textures/ai/game_ready/concrete.png")
|
||||||
const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.png")
|
const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.png")
|
||||||
const T_AWNING := preload("res://assets/textures/ai/game_ready/awning.png")
|
const T_AWNING := preload("res://assets/textures/ai/game_ready/awning.png")
|
||||||
|
const T_SKYLINE := preload("res://assets/textures/skyline.png")
|
||||||
## steps, building facade, restricted door, sidewalk, pavement, and street edge.
|
## steps, building facade, restricted door, sidewalk, pavement, and street edge.
|
||||||
## No art assets required; all geometry is BoxMesh with flat materials.
|
## No art assets required; all geometry is BoxMesh with flat materials.
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ func _init(root_node: Node3D) -> void:
|
|||||||
func build() -> void:
|
func build() -> void:
|
||||||
_build_ground()
|
_build_ground()
|
||||||
_build_building()
|
_build_building()
|
||||||
|
_build_skyline_backdrop()
|
||||||
_build_marquee()
|
_build_marquee()
|
||||||
_build_steps()
|
_build_steps()
|
||||||
_build_sidewalk()
|
_build_sidewalk()
|
||||||
@@ -90,10 +92,9 @@ func _build_building() -> void:
|
|||||||
root.add_child(building)
|
root.add_child(building)
|
||||||
|
|
||||||
func _build_steps() -> void:
|
func _build_steps() -> void:
|
||||||
# Steps are visual only (no collider) so NPCs and the guard can walk up the
|
# Steps have collision so NPCs and the guard can walk up the entrance.
|
||||||
# entrance on the flat navigation mesh; the ground and building provide the
|
# The ground and building provide the actual collision boundaries.
|
||||||
# actual collision boundaries.
|
var steps := StaticBody3D.new()
|
||||||
var steps := Node3D.new()
|
|
||||||
steps.name = "Steps"
|
steps.name = "Steps"
|
||||||
var step_w := 8.0
|
var step_w := 8.0
|
||||||
var tread := 0.6
|
var tread := 0.6
|
||||||
@@ -101,9 +102,22 @@ func _build_steps() -> void:
|
|||||||
for i in 4:
|
for i in 4:
|
||||||
var y: float = i * height
|
var y: float = i * height
|
||||||
var z: float = 4.0 * tread - i * tread
|
var z: float = 4.0 * tread - i * tread
|
||||||
_add_box(steps, Vector3(step_w - i * 0.35, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5))
|
var w: float = step_w - i * 0.35
|
||||||
# Landing at the top
|
_add_box(steps, Vector3(w, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5))
|
||||||
_add_box(steps, Vector3(9, 0.2, 3), Vector3(0, 4 * height + 0.1, -1.5), Color(0.5, 0.47, 0.45))
|
# Add collision shape to each tread
|
||||||
|
var tread_col := BoxShape3D.new()
|
||||||
|
tread_col.size = Vector3(w, height, tread)
|
||||||
|
var owner_id := steps.create_shape_owner(steps)
|
||||||
|
steps.shape_owner_add_shape(owner_id, tread_col)
|
||||||
|
steps.shape_owner_set_transform(owner_id, Transform3D(Basis(), Vector3(0, y + height / 2.0, z)))
|
||||||
|
# Landing at the top with collision
|
||||||
|
var landing_y: float = 4 * height + 0.1
|
||||||
|
_add_box(steps, Vector3(9, 0.2, 3), Vector3(0, landing_y, -1.5), Color(0.5, 0.47, 0.45))
|
||||||
|
var landing_col := BoxShape3D.new()
|
||||||
|
landing_col.size = Vector3(9, 0.2, 3)
|
||||||
|
var landing_owner := steps.create_shape_owner(steps)
|
||||||
|
steps.shape_owner_add_shape(landing_owner, landing_col)
|
||||||
|
steps.shape_owner_set_transform(landing_owner, Transform3D(Basis(), Vector3(0, landing_y, -1.5)))
|
||||||
root.add_child(steps)
|
root.add_child(steps)
|
||||||
|
|
||||||
func _build_sidewalk() -> void:
|
func _build_sidewalk() -> void:
|
||||||
@@ -150,6 +164,33 @@ func _build_navmesh() -> void:
|
|||||||
region.navigation_mesh = mesh
|
region.navigation_mesh = mesh
|
||||||
root.add_child(region)
|
root.add_child(region)
|
||||||
|
|
||||||
|
func _build_skyline_backdrop() -> void:
|
||||||
|
## 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:
|
func _build_marquee() -> void:
|
||||||
## Striped awning above the door, then the neon sign strip + label.
|
## Striped awning above the door, then the neon sign strip + label.
|
||||||
var awning := _add_box(root, Vector3(10.5, 0.08, 1.8), Vector3(0, 6.55, -3.7), Color(1, 1, 1), false, T_AWNING)
|
var awning := _add_box(root, Vector3(10.5, 0.08, 1.8), Vector3(0, 6.55, -3.7), Color(1, 1, 1), false, T_AWNING)
|
||||||
@@ -167,9 +208,9 @@ func _build_marquee() -> void:
|
|||||||
marquee_label.text = "STEP LINE"
|
marquee_label.text = "STEP LINE"
|
||||||
marquee_label.position = Vector3(0, 7.3, -3.55)
|
marquee_label.position = Vector3(0, 7.3, -3.55)
|
||||||
marquee_label.modulate = Color(1, 0.6, 0.9)
|
marquee_label.modulate = Color(1, 0.6, 0.9)
|
||||||
marquee_label.font_size = 72
|
marquee_label.font_size = 160
|
||||||
marquee_label.pixel_size = 0.004
|
marquee_label.pixel_size = 0.004
|
||||||
marquee_label.outline_size = 12
|
marquee_label.outline_size = 24
|
||||||
marquee_label.outline_modulate = Color(0.9, 0.15, 0.5)
|
marquee_label.outline_modulate = Color(0.9, 0.15, 0.5)
|
||||||
marquee_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
marquee_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||||
marquee_label.no_depth_test = false
|
marquee_label.no_depth_test = false
|
||||||
@@ -264,6 +305,11 @@ func _plain_material(color: Color) -> StandardMaterial3D:
|
|||||||
func _build_guard_spawn() -> void:
|
func _build_guard_spawn() -> void:
|
||||||
var marker := Marker3D.new()
|
var marker := Marker3D.new()
|
||||||
marker.name = "GuardSpawn"
|
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)
|
marker.position = Vector3(0, 0.1, -2.0)
|
||||||
root.add_child(marker)
|
root.add_child(marker)
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,23 @@ var state := 0
|
|||||||
var state_frames := 0
|
var state_frames := 0
|
||||||
var stage_npc: Node3D = null
|
var stage_npc: Node3D = null
|
||||||
var failures: Array[String] = []
|
var failures: Array[String] = []
|
||||||
|
var _shift_started := false
|
||||||
|
|
||||||
func _initialize() -> void:
|
func _initialize() -> void:
|
||||||
root.size = Vector2i(1280, 720)
|
root.size = Vector2i(1280, 720)
|
||||||
var packed: PackedScene = load("res://scenes/main.tscn")
|
var packed: PackedScene = load("res://scenes/main.tscn")
|
||||||
root.add_child(packed.instantiate())
|
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:
|
func _process(_delta: float) -> bool:
|
||||||
frames += 1
|
frames += 1
|
||||||
if gm == null:
|
if gm == null:
|
||||||
@@ -39,6 +50,7 @@ func _advance() -> void:
|
|||||||
state_frames += 1
|
state_frames += 1
|
||||||
match state:
|
match state:
|
||||||
0:
|
0:
|
||||||
|
_start_shift_if_needed()
|
||||||
# Wait for the guard to settle on the ground, then grab one NPC.
|
# Wait for the guard to settle on the ground, then grab one NPC.
|
||||||
if state_frames >= 40 and gm.npcs.size() > 0:
|
if state_frames >= 40 and gm.npcs.size() > 0:
|
||||||
print("smoke: spawn guard_y=%.2f z=%.2f" % [
|
print("smoke: spawn guard_y=%.2f z=%.2f" % [
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -30,3 +30,24 @@ func test_load_returns_array_when_empty_or_missing() -> bool:
|
|||||||
var ok := TestUtils.check(results is Array, "load_shift_results always returns Array")
|
var ok := TestUtils.check(results is Array, "load_shift_results always returns Array")
|
||||||
save.free()
|
save.free()
|
||||||
return ok
|
return ok
|
||||||
|
|
||||||
|
func test_settings_round_trip() -> bool:
|
||||||
|
var save := _save_instance()
|
||||||
|
var err: int = save.save_settings({"look_sensitivity": 0.0035, "hud_font_scale": 1.25})
|
||||||
|
var a := TestUtils.check_eq(err, OK, "save_settings succeeds")
|
||||||
|
var loaded: Dictionary = save.load_settings()
|
||||||
|
var b := TestUtils.check(is_equal_approx(float(loaded.get("look_sensitivity", 0.0)), 0.0035), "round-trips sensitivity")
|
||||||
|
var c := TestUtils.check(is_equal_approx(float(loaded.get("hud_font_scale", 0.0)), 1.25), "round-trips font scale")
|
||||||
|
# Restore defaults so later runs are not affected.
|
||||||
|
save.save_settings(save.DEFAULT_SETTINGS)
|
||||||
|
save.free()
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_settings_defaults_present() -> bool:
|
||||||
|
var save := _save_instance()
|
||||||
|
var defaults: Dictionary = save.load_settings()
|
||||||
|
var ok := TestUtils.check(
|
||||||
|
defaults.has("look_sensitivity") and defaults.has("hud_font_scale"),
|
||||||
|
"settings have documented defaults")
|
||||||
|
save.free()
|
||||||
|
return ok
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Session shell (start menu, pause overlay, settings) and routine-traffic
|
||||||
|
## despawn. These are headless-shape tests: they instantiate the UI scripts and
|
||||||
|
## check state transitions rather than rendering.
|
||||||
|
|
||||||
|
const StartMenu := preload("res://src/game/ui/start_menu.gd")
|
||||||
|
const PauseOverlay := preload("res://src/game/ui/pause_overlay.gd")
|
||||||
|
const SettingsPanel := preload("res://src/game/ui/settings_panel.gd")
|
||||||
|
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
|
||||||
|
|
||||||
|
func test_start_menu_lists_all_venues() -> bool:
|
||||||
|
var menu: Node = StartMenu.new()
|
||||||
|
# Buttons are created in _ready; build them without a tree by calling _build.
|
||||||
|
menu._build()
|
||||||
|
var ids: Array[String] = []
|
||||||
|
for venue in StartMenu.VENUES:
|
||||||
|
ids.append(str(venue["id"]))
|
||||||
|
var a := TestUtils.check_eq(ids.size(), 3, "three venues offered")
|
||||||
|
var b := TestUtils.check(ids.has("hollywood_building"), "Hollywood Blvd present")
|
||||||
|
var c := TestUtils.check(ids.has("joke_shop"), "Joke Shop present")
|
||||||
|
var d := TestUtils.check(ids.has("tv_studio"), "TV Studio present")
|
||||||
|
menu.free()
|
||||||
|
return a and b and c and d
|
||||||
|
|
||||||
|
func test_start_menu_emits_selected_venue() -> bool:
|
||||||
|
var menu: Node = StartMenu.new()
|
||||||
|
menu._build()
|
||||||
|
var got := {"id": ""}
|
||||||
|
menu.venue_selected.connect(func(id): got["id"] = id)
|
||||||
|
menu._on_venue_pressed(1)
|
||||||
|
var a := TestUtils.check_eq(got["id"], "joke_shop", "clicking venue 2 selects the Joke Shop")
|
||||||
|
var b := TestUtils.check(not menu.visible, "menu closes after a venue is picked")
|
||||||
|
menu.free()
|
||||||
|
return a and b
|
||||||
|
|
||||||
|
func test_start_menu_clamps_focus() -> bool:
|
||||||
|
var menu: Node = StartMenu.new()
|
||||||
|
menu._build()
|
||||||
|
menu._focus_index(-5)
|
||||||
|
var low: int = menu._index
|
||||||
|
menu._focus_index(99)
|
||||||
|
var high: int = menu._index
|
||||||
|
menu.free()
|
||||||
|
return TestUtils.check_eq(low, 0, "focus clamps at the top") \
|
||||||
|
and TestUtils.check_eq(high, StartMenu.VENUES.size() - 1, "focus clamps at the bottom")
|
||||||
|
|
||||||
|
func test_pause_overlay_buttons_emit() -> bool:
|
||||||
|
var overlay: Node = PauseOverlay.new()
|
||||||
|
overlay._build()
|
||||||
|
var seen: Array[String] = []
|
||||||
|
overlay.resume_requested.connect(func(): seen.append("resume"))
|
||||||
|
overlay.restart_requested.connect(func(): seen.append("restart"))
|
||||||
|
overlay.settings_requested.connect(func(): seen.append("settings"))
|
||||||
|
overlay.quit_to_menu_requested.connect(func(): seen.append("menu"))
|
||||||
|
for node_name in ["ResumeButton", "RestartButton", "SettingsButton", "MenuButton"]:
|
||||||
|
var button: Button = overlay.find_child(node_name, true, false) as Button
|
||||||
|
if button:
|
||||||
|
button.pressed.emit()
|
||||||
|
overlay.free()
|
||||||
|
var expected := ["resume", "restart", "settings", "menu"]
|
||||||
|
return TestUtils.check_eq(seen, expected, "pause overlay exposes resume/restart/settings/menu")
|
||||||
|
|
||||||
|
func test_settings_panel_persists_values() -> bool:
|
||||||
|
var panel: Node = SettingsPanel.new()
|
||||||
|
panel._build()
|
||||||
|
panel.open({"look_sensitivity": 0.0022, "hud_font_scale": 1.0})
|
||||||
|
panel._on_sensitivity_changed(0.004)
|
||||||
|
panel._on_font_scale_changed(1.3)
|
||||||
|
var applied := {"settings": {}}
|
||||||
|
panel.applied.connect(func(s): applied["settings"] = s)
|
||||||
|
panel._on_apply()
|
||||||
|
var s: Dictionary = applied["settings"]
|
||||||
|
var a := TestUtils.check(is_equal_approx(float(s.get("look_sensitivity", 0.0)), 0.004), "sensitivity applied")
|
||||||
|
var b := TestUtils.check(is_equal_approx(float(s.get("hud_font_scale", 0.0)), 1.3), "font scale applied")
|
||||||
|
var c := TestUtils.check(not panel.visible, "panel closes on apply")
|
||||||
|
# Restore defaults written by the apply call.
|
||||||
|
if Save:
|
||||||
|
Save.save_settings(Save.DEFAULT_SETTINGS)
|
||||||
|
panel.free()
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_settings_panel_cancel_discards() -> bool:
|
||||||
|
var panel: Node = SettingsPanel.new()
|
||||||
|
panel._build()
|
||||||
|
panel.open({"look_sensitivity": 0.0022, "hud_font_scale": 1.0})
|
||||||
|
panel._on_sensitivity_changed(0.005)
|
||||||
|
var applied := {"count": 0}
|
||||||
|
panel.applied.connect(func(_s): applied["count"] += 1)
|
||||||
|
panel._on_cancel()
|
||||||
|
var a := TestUtils.check(not panel.visible, "cancel closes the panel")
|
||||||
|
var b := TestUtils.check_eq(applied["count"], 0, "cancel does not apply")
|
||||||
|
panel.free()
|
||||||
|
return a and b
|
||||||
|
|
||||||
|
func test_routine_traffic_is_ephemeral() -> bool:
|
||||||
|
var avatar: Node = NpcAvatar.new()
|
||||||
|
var a := TestUtils.check(avatar.ephemeral, "avatars default to ephemeral")
|
||||||
|
var b := TestUtils.check(avatar.lifetime_s > 0.0, "ephemeral traffic has a lifetime")
|
||||||
|
avatar.free()
|
||||||
|
return a and b
|
||||||
@@ -78,6 +78,36 @@ func test_asset_textures_present_and_pow2() -> bool:
|
|||||||
return TestUtils.fail("asset not power-of-two: %s (%dx%d)" % [path, w, h])
|
return TestUtils.fail("asset not power-of-two: %s (%dx%d)" % [path, w, h])
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
func test_steps_have_colliders() -> bool:
|
||||||
|
## Steps were visual-only and the guard walked through them (TODO).
|
||||||
|
var root := Node3D.new()
|
||||||
|
WorldBuilder.new(root).build()
|
||||||
|
var steps := root.find_child("Steps", true, false)
|
||||||
|
var a := TestUtils.check(steps != null, "Steps node exists")
|
||||||
|
var b := TestUtils.check(steps is StaticBody3D, "Steps are a physics body")
|
||||||
|
var shape_count := 0
|
||||||
|
if steps is StaticBody3D:
|
||||||
|
var body := steps as StaticBody3D
|
||||||
|
shape_count = body.get_shape_owners().size()
|
||||||
|
var c := TestUtils.check(shape_count >= 5, "four treads + landing have colliders (got %d)" % shape_count)
|
||||||
|
root.free()
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_skyline_backdrop_uses_alpha() -> bool:
|
||||||
|
## The opaque skyline texture made the plane render as a black slab (TODO).
|
||||||
|
var root := Node3D.new()
|
||||||
|
var builder := WorldBuilder.new(root)
|
||||||
|
builder.build()
|
||||||
|
var backdrop := root.find_child("SkylineBackdrop", true, false)
|
||||||
|
var a := TestUtils.check(backdrop != null, "SkylineBackdrop exists")
|
||||||
|
var mat: StandardMaterial3D = builder._skyline_mat
|
||||||
|
var b := TestUtils.check(mat != null, "skyline material captured")
|
||||||
|
var c := TestUtils.check(
|
||||||
|
mat != null and mat.transparency != BaseMaterial3D.TRANSPARENCY_DISABLED,
|
||||||
|
"skyline material uses alpha, not an opaque slab")
|
||||||
|
root.free()
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
func test_door_indicator_built() -> bool:
|
func test_door_indicator_built() -> bool:
|
||||||
var root := Node3D.new()
|
var root := Node3D.new()
|
||||||
var builder := WorldBuilder.new(root)
|
var builder := WorldBuilder.new(root)
|
||||||
|
|||||||
@@ -231,7 +231,8 @@ def skyline():
|
|||||||
d.rectangle([wx, wy, wx + 6, wy + 8],
|
d.rectangle([wx, wy, wx + 6, wy + 8],
|
||||||
fill=(255, 200, 120, 220))
|
fill=(255, 200, 120, 220))
|
||||||
x += bw + rnd.randint(10, 46)
|
x += bw + rnd.randint(10, 46)
|
||||||
d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255))
|
# Opaque bottom border removed for transparency - planes will now render as silhouettes
|
||||||
|
# d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255)) # commented out - was causing opaque black slabs
|
||||||
save(img, "skyline.png")
|
save(img, "skyline.png")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user