Add session shell and close world presentation follow-ups
Full build / Import + headless tests (push) Successful in 24s
Full build / Full desktop build + optional release (push) Skipped

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:
zwitschi committed 2026-10-31 20:19:22 +01:00
1 parent f11b441c07
commit be429cab29
20 files changed
+989 -90

No files matched your search

+55
View File
@@ -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.
+26 -9
View File
@@ -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.
+5 -3
View File
@@ -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
+32 -18
View File
@@ -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
+3 -38
View File
@@ -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

+26
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+12
View File
@@ -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
+84
View File
@@ -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()
+154
View File
@@ -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()
+140
View File
@@ -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"]))
+55 -9
View File
@@ -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)
+12
View File
@@ -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" % [
+63
View File
@@ -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
+21
View File
@@ -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
+100
View File
@@ -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
+30
View File
@@ -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)
+2 -1
View File
@@ -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")