Compare commits

...
6 Commits
Author SHA1 Message Date
zwitschi bb40a98537 Give street props colliders, zoned placements and variants
Full build / Full desktop build + optional release (push) Successful in 4m2s
Full build / Import + headless tests (push) Successful in 31s
Props were decoration: the guard and guests walked straight through benches,
bins, bollards and planters. They now block movement and steer guests around.

Collision
- _add_prop_model() fits a StaticBody3D from the model's mesh bounds in
  holder-local space: a cylinder for bollard/bin/hydrant, a box otherwise, so
  placement rotation and variant scale carry over.
- Fallback primitives go through the same fit, so a missing .glb cannot silently
  remove collision.
- Each prop also publishes a NavigationObstacle3D; NpcAvatar enables agent
  avoidance and moves with the safe velocity from velocity_computed. The navmesh
  stays a flat walkable plane and avoidance does the steering.

Placement
- PROP_PLACEMENTS groups furniture by entrance, mid-block and curb zones, with a
  static placement_problems() validator for bounds, overlap/min-spacing,
  duplicates, and the door/steps/guest-lane exclusions.
- variant_for(index) gives deterministic yaw jitter, subtle scale, and optional
  clutter (bag by the bin, crate by the newspaper boxes, cone by the hydrant).

Verification
- New windowed smoke_props.gd: guard walks into a bench and stops; a guest
  headed beyond the bench routes around it and never enters the collider.
- Headless 182 -> 187: placement validation and its failure modes, variant
  determinism/range, collider + obstacle on every placed prop, fallback collider.
- Other smokes (PLAN verbs, crowd, emergency, timeline) still pass; Python
  tooling 53/53; screenshots recaptured; compare 9 good / 0 bad.
- Version 0.4.0. TODO: collision/placement section moved to DONE; prop pipeline
  checks remain.
2026-11-01 10:16:53 +01:00
zwitschi a3e90accdd Plan the presentation pass: props, NPCs, movement
Full build / Full desktop build + optional release (push) Skipped
Full build / Import + headless tests (push) Successful in 2m12s
Replace the flat "future ideas" list with three scheduled sections and concrete,
reviewable checkboxes (41 tasks):

- Better Props: colliders for the generated models (they have none today), a
  placement table with variants, sign/bus-stop/shelter/street-lamp props, a
  close-range detail layer, pipeline audits, and a prop-gallery capture.
- Better NPC Characters: articulated rig, layered outfits and accessories, face
  and hair variety, idle/conversation animation, door-queue staging, reaction
  readability, LOD tiers, and a re-measured NPC budget.
- Better Player Movement: accel/decel split, step-up assist, head bob/FOV and a
  motion-sickness toggle, precision-walk feedback, surface footsteps, gamepad
  support, input remapping, and movement tests.

Kept live 3D replay, extra venues, voice audio, and the image-to-mesh swap in
Future ideas. Memory notes the grounding facts (props have no colliders, rigid
NPC rig, single accel value). Docs-only: no version bump or release.
2026-11-01 10:05:04 +01:00
zwitschi 3025ba1cef Capture the wand panel, escalation HUD and body-camera timeline
Full build / Import + headless tests (push) Successful in 35s
Full build / Full desktop build + optional release (push) Successful in 3m56s
The three newest catalogue screens had AI mockups but no staged capture, so
compare and judge skipped them. capture_screenshots.gd now continues past the
night set:

- switches to The Joke Shop at 620 s (EVENING still has routine spawns), frames a
  guest, and retries the wand until it alerts; if nobody carries an alerting item
  it stages the documented keys false-alarm case.
- applies a formal warning (arms the body camera) then blocking, jumps to 760 s
  for night lighting, and captures the HUD at L3.
- closes the open incident and opens the body-camera timeline, which now shows
  the warning / body-camera / blocking rows.

Wired the refs for wand_screening, escalation_hud and body_camera_timeline, and
regenerated the escalation mockup as a third-person over-the-shoulder night shot
so the reference matches the game camera. Added Hud.clear_log() to drop
target-cycling noise from staged shots. README gallery lists the three captures.

Verification: headless 182/182, Python tooling 53/53, compare 9 good / 0 bad,
judge: wand match 0.90, timeline match 1.00, escalation review, 0 diff.
Version 0.3.3.
2026-11-01 00:29:58 +01:00
zwitschi a5e7b3c013 Add AI mockups for the seven remaining catalogue screens
Full build / Import + headless tests (push) Successful in 34s
Full build / Full desktop build + optional release (push) Successful in 4m4s
The design catalogue grew to 19 screens, but only the original twelve had an AI
reference; the rest still showed "design -" in `list`. Generated the missing set
so every screen can be reviewed against an intended look:

- wand screening (metal-detected panel with carried items and allow/deny)
- Joke Shop queue, TV Studio arrival, crowd panic, responder arrival
- body-camera timeline, escalation HUD

`generate` now loads `.env` the same way `judge` does, so `--ai` no longer needs
the API key exported by hand. Each reference is 1280x720; the twelve existing
mockups were kept (no `--force`).

Verification: `list` reports 19 designs, Python tooling 53/53, headless suite
182/182, `compare` still 6 good / 0 bad on the captured screens. Version 0.3.2.
2026-11-01 00:01:02 +01:00
zwitschi f2863abeac Draw prop models with a single atlas material
Full build / Import + headless tests (push) Successful in 33s
Full build / Full desktop build + optional release (push) Successful in 3m39s
Each generated prop used one material per part (body, accent, metal...). The
model pipeline now packs a prop's palette into one power-of-two colour atlas plus a
matching metallic/roughness atlas and emits a single material:

- atlas_for(): 8x8 cells per palette role, colours in sRGB, roughness in the G and
  metallic in the B channel of the second atlas; NEAREST sampling keeps cells flat.
- build_glb(): every part merges into one primitive with constant per-vertex atlas
  UVs; the material uses baseColorTexture + metallicRoughnessTexture with factors 1.0.
- audit_glb(): fails unless a prop has exactly one material, both atlas textures,
  two images, and TEXCOORD_0 on every primitive.
- Removed the now-unused sRGB->linear factor helpers; colour travels in the texture.

Verification: headless 181 -> 182 (new "one material per prop scene" test), Python
tooling 53/53 (atlas determinism/size/UV/palette-override tests replace the old
factor tests), model audit OK for all six props, screenshots regenerated and
screen_designs.py compare still 6 good / 0 bad. Version 0.3.1.
2026-10-31 23:47:15 +01:00
zwitschi 7f92b8861d List the Web build in the README downloads table
Full build / Full desktop build + optional release (push) Skipped
Full build / Import + headless tests (push) Successful in 25s
The v0.3.0 release now carries step-line-web-0.3.0.zip (37.5 MB, 63 MB unzipped);
downloads anonymously and serves from any static host. Docs only.
2026-10-31 23:13:43 +01:00
48 changed files with 1029 additions and 141 deletions

No files matched your search

+73
View File
@@ -661,3 +661,76 @@ produces a 63 MB static bundle and it serves locally with HTTP 200s for html/was
- [x] Cover the preset, the non-threaded choice, and the release wiring in - [x] Cover the preset, the non-threaded choice, and the release wiring in
`src/test/tests/test_web_export.gd`. `src/test/tests/test_web_export.gd`.
- [x] Document the artifact in `docs/RELEASING.md`, the README gallery/status, and `PLAN.md`. - [x] Document the artifact in `docs/RELEASING.md`, the README gallery/status, and `PLAN.md`.
## Prop Texture Atlas
Completed 2026-10-31 (v0.3.1). Headless **182/182**, Python tooling **53/53**; screenshots
regenerated and `screen_designs.py compare` still reports 6 good / 0 bad.
- [x] `model_pipeline.atlas_for()` packs each prop's palette roles into one small power-of-two
PNG and a matching metallic/roughness atlas (roughness in G, metallic in B), with nearest
sampling so a palette cell stays a flat colour.
- [x] `build_glb()` merges every part into a single primitive with per-vertex atlas UVs and one
material (`baseColorTexture` + `metallicRoughnessTexture`), so a prop is one draw material
instead of one per part.
- [x] `audit_glb()` checks the single-material/atlas contract (one material, both textures,
TEXCOORD_0 on every primitive, two images).
- [x] Tests: atlas determinism and power-of-two sizing, UV range, palette override reaching the
atlas, and a Godot test that each imported prop scene uses exactly one material.
- [x] Removed the now-unused sRGB→linear factor helpers (colours travel in the sRGB texture).
## Complete AI Mockup Set
Completed 2026-10-31 (v0.3.2). All 19 catalogue screens now have an AI reference; Python
tooling **53/53**.
- [x] Generated mockups for the seven entries that only had wireframes: wand screening,
Joke Shop queue, TV Studio arrival, crowd panic, responder arrival, body-camera
timeline, escalation HUD.
- [x] `generate` loads `.env` like `judge` already did, so `--ai` works without exporting
`OPENROUTER_API_KEY` by hand.
- [x] Verified every reference is 1280x720, reviewed the new images visually, and confirmed
`list` reports 19 designs (12 kept, 7 new).
## Staged Captures For The New Screens
Completed 2026-11-01 (v0.3.3). `screen_designs.py compare` reports **9 good / 0 bad**;
vision judge: wand **match** (0.90), timeline **match** (1.00), escalation **review**, 0 diff.
Python tooling **53/53**, headless **182/182**.
- [x] `capture_screenshots.gd` now continues past the night set: switches to The Joke Shop,
stages a guest, retries the wand until it alerts (falls back to the documented keys
false alarm), applies formal warning + blocking, and opens the body-camera timeline.
- [x] New captures `07-wand-screening.png`, `08-escalation-hud.png`, `09-body-camera-timeline.png`
added to `docs/screenshots/` and to the README gallery.
- [x] Wired `ref` for `wand_screening`, `escalation_hud` and `body_camera_timeline` so
`compare` and `judge` include them (9 of 19 screens now have a staged capture).
- [x] Regenerated the escalation HUD mockup as a third-person over-the-shoulder night shot so
the reference matches the game camera.
- [x] Added `Hud.clear_log()` so staged captures drop target-cycling noise; the escalation shot
keeps the real reaction lines.
## Prop Collision And Placement
Completed 2026-11-01 (v0.4.0). Headless **187/187**, Python tooling **53/53**, the new
`smoke_props.gd` passes windowed, and `screen_designs.py compare` still reports 9 good / 0 bad.
- [x] Every generated prop now gets a fitted static collider: a cylinder for bollard, bin, and
hydrant, a box for everything else. The collider is computed from the model's mesh bounds in
holder-local space, so placement rotation and variant scale carry over.
- [x] Added `smoke_props.gd` (windowed): the guard walks into a bench and stops instead of passing
through it; a guest heading for a point beyond the same bench routes around it with
navigation avoidance and never ends up inside the collider.
- [x] Props publish a `NavigationObstacle3D` footprint, and `NpcAvatar` enables agent avoidance with
a `velocity_computed` callback. The navmesh stays a flat walkable plane; avoidance does the
steering around street furniture.
- [x] Replaced the hard-coded prop calls with `WorldBuilder.PROP_PLACEMENTS`, grouped by entrance,
mid-block, and curb zones, with a static `placement_problems()` validator for bounds,
overlap/min-spacing, duplicates, and the door/steps/guest-lane exclusions.
- [x] Deterministic variants per placement (`variant_for`): yaw jitter, subtle scale, and optional
procedural clutter (bag by the bin, crate by the newspaper boxes, cone by the hydrant), so
repeated props stop looking cloned.
- [x] Fallback primitive props go through the same bounds fit, so a missing `.glb` cannot silently
remove collision; a unit test covers the fallback body, shape, width, and navigation obstacle.
- [x] Tests: placement validation and its failure modes, variant determinism/range, colliders on
every placed prop (including obstacle presence), and the fallback collider path.
+24
View File
@@ -31,6 +31,30 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
(static hosting, no COOP/COEP); `tools/build_releases.sh` exports `build/web/` and zips it to (static hosting, no COOP/COEP); `tools/build_releases.sh` exports `build/web/` and zips it to
`build/step-line-web-<ver>.zip`, and `publish_release.sh` uploads it. Covered by `build/step-line-web-<ver>.zip`, and `publish_release.sh` uploads it. Covered by
`src/test/tests/test_web_export.gd`. `src/test/tests/test_web_export.gd`.
- **Prop atlas (v0.3.1):** `model_pipeline.atlas_for()` builds a per-prop power-of-two PNG with
one 8×8 cell per palette role, plus a metallic/roughness atlas (G=roughness, B=metallic). The
GLB merges all parts into one primitive with constant per-vertex UVs (cell centre) and a single
material (`baseColorTexture` + `metallicRoughnessTexture`, NEAREST sampling). Colours are sRGB in
the texture, so the old `srgb_to_linear`/`_linear_rgba` helpers were removed. `audit_glb` fails
if a prop is not exactly one material or misses TEXCOORD_0/images.
- **Mockups complete (v0.3.2):** `docs/screens/designs/` holds AI references for all 19
catalogue screens. Only the six staged captures (01–06) are compared/judged; the other 13
have no `capture`, so adding staged captures for the wand panel, timeline and escalation HUD
would extend automated visual checks. `generate` now calls `_load_env_file()`.
- **Staged captures (v0.3.3):** `capture_screenshots.gd` now emits nine shots. States 7–10 switch
to The Joke Shop at elapsed 620 s (EVENING has routine spawns; LATE has none), retry the wand
until it alerts, then jump to 760 s after staging for night lighting. The timeline needs
`recorder.close_all_open()`, because `to_dict()` only lists closed incidents. The escalation
HUD mockup is third-person now; `judge` still classes it `review` (no diff).
- **Prop collision (v0.4.0):** `_add_prop_model()` fits a `StaticBody3D` from the model bounds
(cylinder for bollard/bin/hydrant, box otherwise) and adds a `NavigationObstacle3D`; NPC agents
enable avoidance and move with the safe velocity from `velocity_computed`. The navmesh stays a
flat plane, so avoidance (not a baked mesh) routes guests around props. Placements live in
`PROP_PLACEMENTS` with `placement_problems()` validation; variants come from `variant_for(index)`.
`smoke_props.gd` must be windowed because the guard reads captured-mouse input.
- **Presentation pass remaining (TODO):** NPC characters and player movement. Grounding facts: the
NPC rig is rigid capsules with a whole-limb swing, and `guard_controller.gd` uses one `accel`
value. Each task should get its own windowed smoke or capture where it changes feel/looks.
- **Backlog complete:** every planned TODO section is done and moved to `DONE.md`; `TODO.md` - **Backlog complete:** every planned TODO section is done and moved to `DONE.md`; `TODO.md`
now only lists optional future ideas (live 3D replay, NPC LOD, more venues, real audio, prop now only lists optional future ideas (live 3D replay, NPC LOD, more venues, real audio, prop
atlas, AI mockups for the new catalogue entries, image-to-mesh props). GUT was consciously not atlas, AI mockups for the new catalogue entries, image-to-mesh props). GUT was consciously not
+15 -6
View File
@@ -13,8 +13,10 @@ The central design goal is simple:
| ![Venue overview: the Hollywood Boulevard entrance with steps, sidewalks, street lamps, and signage](docs/screenshots/01-venue-overview.png) | ![Targeting an approaching NPC with the interaction HUD](docs/screenshots/02-targeting-npc.png) | | ![Venue overview: the Hollywood Boulevard entrance with steps, sidewalks, street lamps, and signage](docs/screenshots/01-venue-overview.png) | ![Targeting an approaching NPC with the interaction HUD](docs/screenshots/02-targeting-npc.png) |
| ![Access inspection: wristband/credential check with allow/deny prompt](docs/screenshots/03-access-inspection.png) | ![Radio report form: emergency dispatch panel](docs/screenshots/04-radio-report.png) | | ![Access inspection: wristband/credential check with allow/deny prompt](docs/screenshots/03-access-inspection.png) | ![Radio report form: emergency dispatch panel](docs/screenshots/04-radio-report.png) |
| ![Late night: street lamps lit, marquee glowing, dark Hollywood Boulevard](docs/screenshots/05-late-night-avenue.png) | ![Night shift: guard flashlight cutting through the dark](docs/screenshots/06-night-flashlight.png) | | ![Late night: street lamps lit, marquee glowing, dark Hollywood Boulevard](docs/screenshots/05-late-night-avenue.png) | ![Night shift: guard flashlight cutting through the dark](docs/screenshots/06-night-flashlight.png) |
| ![Wand screening at The Joke Shop: false alarm on keys](docs/screenshots/07-wand-screening.png) | ![Escalation HUD at L3 with body camera armed and reaction log](docs/screenshots/08-escalation-hud.png) |
| ![Body-camera timeline: formal warning, body camera on, blocking](docs/screenshots/09-body-camera-timeline.png) | |
*Screenshots are generated by [`src/test/capture_screenshots.gd`](src/test/capture_screenshots.gd) from the current build — day and late-night shifts.* *Screenshots are generated by [`src/test/capture_screenshots.gd`](src/test/capture_screenshots.gd) from the current build — day, late-night, wand screening, escalation, and shift-review timeline.*
## ⬇️ Downloads ## ⬇️ Downloads
@@ -25,14 +27,14 @@ The central design goal is simple:
| **Linux — AppImage** | [`step-line-<ver>-x86_64.AppImage`](https://git.allucanget.biz/zwitschi/security-game/releases) · single file, `chmod +x` and run | | **Linux — AppImage** | [`step-line-<ver>-x86_64.AppImage`](https://git.allucanget.biz/zwitschi/security-game/releases) · single file, `chmod +x` and run |
| **Linux — portable** | [`step-line-linux-<ver>.x86_64`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, `chmod +x` and run | | **Linux — portable** | [`step-line-linux-<ver>.x86_64`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, `chmod +x` and run |
| **macOS** | [`step-line-macos-<ver>.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unsigned build, quarantine note below | | **macOS** | [`step-line-macos-<ver>.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unsigned build, quarantine note below |
| **Web (browser)** | [`step-line-web-<ver>.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unzip and serve the folder from any static host (no special headers) |
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases> **All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
> Downloads are public (no sign-in required) since the host's instance-wide > Downloads are public (no sign-in required) since the host's instance-wide
> `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31. > `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31.
> >
> **Latest: v0.3.0** — rendering and NPC presentation pass, generated prop models, aim-free > **Latest: v0.4.0** — props have colliders and navigation obstacles, with zoned placements and deterministic variants.
> selection with precision walk, and a Web (browser) build.
> Releases are built automatically when a `vX.Y.Z` tag is pushed > Releases are built automatically when a `vX.Y.Z` tag is pushed
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use > (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline > `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
@@ -258,10 +260,12 @@ python3 tools/screen_designs.py judge --only 03- # one capture
python3 tools/screen_designs.py judge --dry-run # build requests, no network python3 tools/screen_designs.py judge --dry-run # build requests, no network
``` ```
The catalogue covers 19 screens: the six README captures plus the start menu, The catalogue covers 19 screens: the nine README captures plus the start menu,
pause overlay, settings, shift review, radial menu, emergency radio, wand pause overlay, settings, shift review, radial menu, emergency radio, wand
screening, Joke Shop queue, TV Studio arrival, crowd panic, responder arrival, screening, Joke Shop queue, TV Studio arrival, crowd panic, responder arrival,
body-camera timeline, and the escalation HUD. body-camera timeline, and the escalation HUD. All 19 have an AI reference in
`docs/screens/designs/`; the nine screens with staged captures are also compared
or judged against the running game.
`compare` flags gross drift: wrong aspect ratio, undersized frame, near-blank `compare` flags gross drift: wrong aspect ratio, undersized frame, near-blank
frame, and large layout/palette/detail distance. It is a regression gate, not a frame, and large layout/palette/detail distance. It is a regression gate, not a
@@ -309,6 +313,9 @@ godot --headless --path . --script res://src/test/smoke_session_shell.gd
# PLAN-verb smoke: recognize, request reason, body camera, warning, blocking # PLAN-verb smoke: recognize, request reason, body camera, warning, blocking
godot --headless --path . --script res://src/test/smoke_plan_verbs.gd godot --headless --path . --script res://src/test/smoke_plan_verbs.gd
# Prop-collision smoke (windowed): guard stops at a bench, guest routes around it
godot --path . --script res://src/test/smoke_props.gd
# Crowd smoke: rubbernecking on aggression, panic clearing the entrance # Crowd smoke: rubbernecking on aggression, panic clearing the entrance
godot --headless --path . --script res://src/test/smoke_crowd.gd godot --headless --path . --script res://src/test/smoke_crowd.gd
@@ -377,7 +384,7 @@ 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: **181 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset) - Headless unit tests: **187 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material)
- 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 blue circle in front of the face 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 blue circle in front of the face 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`)
@@ -400,6 +407,8 @@ Implemented so far:
- 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)
- Rendering pass (v0.3.0 work, [ADR-0012](docs/adr/0012-surface-materials-and-generated-props.md)): one material module with roughness/metallic profiles per surface, ACES tonemapping, glow, aerial fog and colour grading driven by the shift phase, correctly scaled brick, framed facade windows that light up at night, door trim and handles, a curb and lane dashes, and the glowing marquee/lamp bulbs. NPCs gained per-role clothing palettes, caps/hoods/backpacks/sunglasses/lanyards, hands and shoes, and an arm/leg walk swing - Rendering pass (v0.3.0 work, [ADR-0012](docs/adr/0012-surface-materials-and-generated-props.md)): one material module with roughness/metallic profiles per surface, ACES tonemapping, glow, aerial fog and colour grading driven by the shift phase, correctly scaled brick, framed facade windows that light up at night, door trim and handles, a curb and lane dashes, and the glowing marquee/lamp bulbs. NPCs gained per-role clothing palettes, caps/hoods/backpacks/sunglasses/lanyards, hands and shoes, and an arm/leg walk swing
- Prop collision and placement (v0.4.0): every prop fits a static collider from its mesh bounds and publishes a navigation obstacle, so the guard and guests cannot walk through benches, bins, or bollards; placements are grouped into zones with a validator, and deterministic variants add rotation, scale, and small clutter
- Prop atlas (v0.3.1): `tools/model_pipeline.py` packs each prop's palette into one power-of-two colour atlas plus a metallic/roughness atlas and emits a single material with per-vertex UVs, so a prop is one draw material rather than one per part; `audit` enforces the contract
- Generated prop models (v0.3.0 work): `tools/model_pipeline.py` builds bollards, benches, bins, planters, newspaper boxes and a hydrant as `.glb` props with per-part materials, loaded by `WorldBuilder` with a procedural fallback; the optional AI concept step (`model_pipeline.py concept`) supplies per-prop colour palettes - Generated prop models (v0.3.0 work): `tools/model_pipeline.py` builds bollards, benches, bins, planters, newspaper boxes and a hydrant as `.glb` props with per-part materials, loaded by `WorldBuilder` with a procedural fallback; the optional AI concept step (`model_pipeline.py concept`) supplies per-prop colour palettes
- Web build: `build/step-line-web-<version>.zip` (Godot's non-threaded web template, no COOP/COEP headers needed) built by `tools/build_releases.sh` and published with the desktop artifacts; serve the unzipped folder from any static host - Web build: `build/step-line-web-<version>.zip` (Godot's non-threaded web template, no COOP/COEP headers needed) built by `tools/build_releases.sh` and published with the desktop artifacts; serve the unzipped folder from any static host
- Aim-free selection pass (v0.3.0 work, [ADR-0011](docs/adr/0011-aim-free-selection-and-precision-walk.md)): the focus cone ranks candidates by angle then distance, `Tab` and the mouse wheel cycle nearby people nearest-first and pin the selection, right click clears focus with a short re-acquire grace, the HUD reports how many people are in the ring, and holding `Shift` walks at half speed for close work - Aim-free selection pass (v0.3.0 work, [ADR-0011](docs/adr/0011-aim-free-selection-and-precision-walk.md)): the focus cone ranks candidates by angle then distance, `Tab` and the mouse wheel cycle nearby people nearest-first and pin the selection, right click clears focus with a short re-acquire grace, the HUD reports how many people are in the ring, and holding `Shift` walks at half speed for close work
+83 -8
View File
@@ -1,19 +1,94 @@
# TODO # TODO
Godot 4.3 prototype. Latest release **v0.3.0** (rendering, model pipeline, selection pass and a Godot 4.3 prototype. Latest release **v0.4.0** (prop collision, placement, variants). Headless
Web build). Headless suite **181/181** plus **53** Python tooling tests; the session, PLAN-verb, crowd, emergency, timeline, audio, scenario-content, and suite **187/187** plus **53** Python tooling tests; the session, PLAN-verb, crowd, emergency,
windowed focus/radial smokes pass. Anonymous downloads are enabled on the host. timeline, audio, scenario-content, props, and windowed focus/radial smokes pass. Anonymous
downloads are enabled on the host.
All planned backlog sections are complete (see `DONE.md`). Optional future ideas live below. The sections below are the rest of the presentation pass: prop pipeline checks, NPC characters,
and player movement. Each task is meant to be a small, reviewable slice with its own verification.
## Better Props
### Pipeline and checks
- [ ] Extend `audit_glb()` with a triangle budget, degenerate-triangle test, `TEXCOORD_0` inside
the atlas, minimum-y ≈ 0, and a material-count check per prop.
- [ ] Add a `prop_gallery` capture to `capture_screenshots.gd` (each prop close-up on a plain
backdrop) and wire it to `screen_designs.py` so prop regressions show up in `compare`.
- [ ] Verify all prop instances share their imported material (no per-instance duplication) and
keep a documented triangle/vertex budget in the pipeline docstring.
- [ ] Update the README prop list and the `model_pipeline.py` catalogue docs when new props land.
## Better NPC Characters
### Appearance and rig
- [ ] Replace the rigid capsule limbs with an articulated rig: shoulder, elbow, hip, and knee
joints, so a walk cycle can bend knees and swing arms instead of rotating whole capsules.
- [ ] Add layered clothing: jacket over shirt, hi-vis vest, security uniform, dress/skirt, and
coat, with deterministic per-profile colourways and material roles.
- [ ] Extend the accessory catalogue: bag, umbrella, phone, camera, clipboard, headphones, and a
coffee cup; attach them to the correct hand/back socket.
- [ ] Add face and hair variety: several hair silhouettes, beards, hats, eye/brow shapes, age and
height ranges, and a small set of face textures or decal planes so heads are not identical.
- [ ] Keep appearance deterministic per `(profile, seed)` and add a test that the same seed
produces the same outfit while different types stay recognisable.
### Animation and staging
- [ ] Add an idle layer: breathing, weight shift, occasional look-around, phone check, and
arms-crossed, blended with the current walk and reaction states.
- [ ] Add conversation gestures: facing the guard when addressed, talking hand movement, nodding,
backing away, and pointing, driven by the existing reaction/escalation events.
- [ ] Improve the walk cycle: stride length tied to speed, foot planting without sliding, and a
distinct hurry/flee gait that matches `FLEE_SPEED`.
- [ ] Stage a door queue: waiting offsets, one-at-a-time approach, no overlapping capsules, and a
step-aside after the admission decision.
- [ ] Make reactions readable at a distance with body language plus a short icon/label
(rubberneck, panic, calm, angry) so crowd state is legible without the debug panel.
### Performance and budgets
- [ ] Add three LOD tiers (full rig, simplified mesh, billboard) with distance hysteresis, and
disable shadows for far tiers; keep the switch deterministic and testable.
- [ ] Re-measure the live-NPC budget with the full rig at `MAX_LIVE_NPCS`, and add a perf smoke
that fails when frame time crosses the documented budget.
- [ ] Add tests for animation state transitions (idle → walk → react) and for queue spacing at
the door.
## Better Player Movement
### Feel and handling
- [ ] Split acceleration and deceleration in `guard_controller.gd` (faster stop than start, no
ice-skating), then tune `move_speed` and `accel` against a windowed feel pass.
- [ ] Add step-up assist for kerbs and the entrance steps (about 0.3 m) and keep wall sliding
smooth so the guard does not stick on doorframes, railings, or prop colliders.
- [ ] Add subtle head bob tied to stride and speed, a small FOV change in precision walk, and a
landing dip; expose a motion-sickness toggle in settings with persistence.
- [ ] Give precision walk (Shift) visible feedback: a HUD state chip and an audio tick when it
engages, in addition to the slower speed.
- [ ] Add surface-aware footsteps (concrete, asphalt, stone steps) with stride-synced timing and
a distinct landing sound, reusing the procedural audio helpers.
### Input and accessibility
- [ ] Add gamepad support: move/look actions, radial and action bindings, deadzone and look
sensitivity, with prompts that follow the active device.
- [ ] Move the remaining hard-coded keys into `InputMap` actions and persist any remaps through
the settings panel (keyboard and gamepad).
- [ ] Add a mouse-sensitivity and invert-Y setting if missing, and test the values survive a
settings save/load round trip.
- [ ] Add movement tests: acceleration ramp, step-up height limit, precision-walk speed, wall
slide, and gamepad deadzone.
- [ ] Run one windowed movement smoke per change above (walk the full block, climb the steps,
strafe along the wall) and capture a short screenshot set for the compare tool.
## Future ideas (optional, not scheduled) ## Future ideas (optional, not scheduled)
- [ ] Live 3D body-camera replay (the timeline is text-only today). - [ ] Live 3D body-camera replay (the timeline is text-only today).
- [ ] LOD/culling for NPCs if `MAX_LIVE_NPCS` grows past the measured 16-avatar budget.
- [ ] Additional venues or seasonal events authored from the new `.tres` files. - [ ] Additional venues or seasonal events authored from the new `.tres` files.
- [ ] Real voice/dialogue audio (the crowd bed and radio crackle are procedural). - [ ] Real voice/dialogue audio (the crowd bed and radio crackle are procedural).
- [ ] Swap the primitive prop builder for an image-to-mesh model API behind the same - [ ] Swap the primitive prop builder for an image-to-mesh model API behind the same
`assets/models/generated/<prop>.glb` contract. `assets/models/generated/<prop>.glb` contract.
- [ ] Texture atlas for the props so they share one material instead of one per part.
- [ ] Regenerate the AI screen mockups for the seven new catalogue entries (only the
deterministic wireframes exist so far).
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1007 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1021 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 838 KiB

After

Width:  |  Height:  |  Size: 979 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

+1 -1
View File
@@ -8,7 +8,7 @@ config_version=5
config/name="Step Line" config/name="Step Line"
config/description="Workplace security simulation about observation, access verification, and proportionate escalation." config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
config/version="0.3.0" config/version="0.4.0"
run/main_scene="res://scenes/main.tscn" run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.3", "GL Compatibility") config/features=PackedStringArray("4.3", "GL Compatibility")
+33 -3
View File
@@ -73,6 +73,15 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
agent.target_desired_distance = 4.0 agent.target_desired_distance = 4.0
agent.radius = 0.35 agent.radius = 0.35
agent.height = 1.2 agent.height = 1.2
# Steer around street furniture instead of walking into it (props publish a
# NavigationObstacle3D; the navmesh itself stays a flat walkable plane).
agent.avoidance_enabled = true
agent.neighbor_distance = 4.0
agent.max_neighbors = 8
agent.time_horizon_agents = 0.6
agent.time_horizon_obstacles = 1.2
agent.max_speed = FLEE_SPEED
agent.velocity_computed.connect(_on_safe_velocity)
add_child(agent) add_child(agent)
# Normalize intent targets: approach the entrance, stop near the door. # Normalize intent targets: approach the entrance, stop near the door.
@@ -98,6 +107,7 @@ var _sign_prop: MeshInstance3D = null
var _legs: Array[MeshInstance3D] = [] var _legs: Array[MeshInstance3D] = []
var _arms: Array[MeshInstance3D] = [] var _arms: Array[MeshInstance3D] = []
var _theater_t := 0.0 var _theater_t := 0.0
var _avoidance_active := false
var _photo_prop_base_y := 0.0 var _photo_prop_base_y := 0.0
var _sign_prop_base_y := 0.0 var _sign_prop_base_y := 0.0
const SKIN_TONE := Color(0.88, 0.72, 0.60) const SKIN_TONE := Color(0.88, 0.72, 0.60)
@@ -527,6 +537,7 @@ func _advance_fsm(delta: float) -> void:
var leave_requested_by_lifetime := false var leave_requested_by_lifetime := false
func _move_toward_target(delta: float) -> void: func _move_toward_target(delta: float) -> void:
_avoidance_active = false
if agent == null: if agent == null:
_move_direct(delta) _move_direct(delta)
return return
@@ -575,14 +586,33 @@ func _move_toward_target(delta: float) -> void:
return return
dir = dir.normalized() dir = dir.normalized()
var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3 var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3
velocity.x = dir.x * speed
velocity.z = dir.z * speed
velocity.y -= 9.8 * delta
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP) look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
if agent.avoidance_enabled:
# The navigation server returns the obstacle-aware velocity through
# velocity_computed; never dash straight at the point in the meantime.
_avoidance_active = true
agent.velocity = Vector3(dir.x * speed, 0.0, dir.z * speed)
else:
velocity.x = dir.x * speed
velocity.z = dir.z * speed
velocity.y -= 9.8 * delta
move_and_slide()
## Safe velocity from NavigationServer avoidance: move with the steered vector.
func _on_safe_velocity(safe: Vector3) -> void:
if not _avoidance_active:
return
velocity.x = safe.x
velocity.z = safe.z
if is_on_floor():
velocity.y = 0.0
else:
velocity.y -= 9.8 * get_physics_process_delta_time()
move_and_slide() move_and_slide()
## Fallback used when no navigation mesh is available (e.g. minimal scenes). ## Fallback used when no navigation mesh is available (e.g. minimal scenes).
func _move_direct(delta: float) -> void: func _move_direct(delta: float) -> void:
_avoidance_active = false
var to_target := target_position - global_position var to_target := target_position - global_position
to_target.y = 0.0 to_target.y = 0.0
var dist := to_target.length() var dist := to_target.length()
+6
View File
@@ -402,6 +402,12 @@ func set_escalation(level: int) -> void:
if vignette_rect: if vignette_rect:
vignette_rect.modulate = Color(tint.r, tint.g, tint.b, 0.05 + 0.2 * float(level) / 5.0) vignette_rect.modulate = Color(tint.r, tint.g, tint.b, 0.05 + 0.2 * float(level) / 5.0)
## Dev helper: drop queued log lines so a staged screenshot shows only the
## lines that belong to the moment.
func clear_log() -> void:
_log_lines.clear()
log_label.text = ""
func log(text: String) -> void: func log(text: String) -> void:
_log_lines.append(text) _log_lines.append(text)
if _log_lines.size() > 8: if _log_lines.size() > 8:
+183 -20
View File
@@ -335,35 +335,112 @@ func _build_street_furniture() -> void:
tree.add_child(crown) tree.add_child(crown)
root.add_child(tree) root.add_child(tree)
## Street-furniture placement table. Positions are in world space on the
## sidewalk top (y = 0.18) and grouped by zone, so a cluster reads as street
## furniture instead of a scattered list. `accessory` names a small procedural
## addition (bag, crate, cone) for one-off variation.
const PROP_PLACEMENTS := [
# Entrance: bollards flank the foot of the steps, bins catch people who
# stop at the door.
{"zone": "entrance", "prop": "bollard", "x": -5.6, "z": 4.7},
{"zone": "entrance", "prop": "bollard", "x": -4.2, "z": 4.7},
{"zone": "entrance", "prop": "bollard", "x": 4.2, "z": 4.7},
{"zone": "entrance", "prop": "bollard", "x": 5.6, "z": 4.7},
{"zone": "entrance", "prop": "trash_bin", "x": -6.6, "z": 4.9, "accessory": "bag"},
{"zone": "entrance", "prop": "trash_bin", "x": 6.6, "z": 4.9},
# Mid-block: benches face the boulevard, planters break up the long run.
{"zone": "mid_block", "prop": "bench", "x": -8.8, "z": 6.4, "rot": 180.0},
{"zone": "mid_block", "prop": "bench", "x": 8.8, "z": 6.4, "rot": 180.0},
{"zone": "mid_block", "prop": "planter_box", "x": -11.8, "z": 8.8},
{"zone": "mid_block", "prop": "planter_box", "x": 11.8, "z": 8.8},
# Curb: newspaper boxes and a hydrant along the roadway edge.
{"zone": "curb", "prop": "newspaper_box", "x": -9.6, "z": 11.0, "rot": -12.0},
{"zone": "curb", "prop": "newspaper_box", "x": -8.9, "z": 11.0, "rot": -12.0,
"accessory": "crate"},
{"zone": "curb", "prop": "fire_hydrant", "x": 8.4, "z": 11.2,
"accessory": "cone"},
]
## The door, steps, and the lane guests walk in through stay clear of props.
const PROP_EXCLUSIONS := [
{"name": "steps and door", "x0": -4.6, "x1": 4.6, "z0": -4.6, "z1": 4.55},
{"name": "guest approach lane", "x0": -1.4, "x1": 1.4, "z0": 4.55, "z1": 14.0},
]
const PROP_BOUNDS := {"x0": -14.0, "x1": 14.0, "z0": 4.55, "z1": 11.8}
## Two prop centres closer than this read as one blob (newspaper boxes are
## deliberately lined up, so this stays below their 0.7 m spacing).
const PROP_MIN_SPACING := 0.6
static func _variant_seed(index: int) -> int:
return index * 7919 + 13
## Deterministic per-placement variation. The same index always gives the same
## rotation and scale, so the street is reproducible while repeats differ.
static func variant_for(index: int, base_rotation: float = 0.0) -> Dictionary:
var rng := RandomNumberGenerator.new()
rng.seed = _variant_seed(index)
return {
"rotation": base_rotation + rng.randf_range(-2.0, 2.0),
"scale": rng.randf_range(0.96, 1.04),
"roll": rng.randf(),
}
## Static validation used by tests: reject duplicate, out-of-bounds, or
## overlapping placements, and any prop inside the door/steps/approach lane.
static func placement_problems(placements: Array = PROP_PLACEMENTS) -> Array[String]:
var problems: Array[String] = []
var seen := {}
for i in placements.size():
var entry: Dictionary = placements[i]
var prop := str(entry.get("prop", ""))
var x := float(entry.get("x", 0.0))
var z := float(entry.get("z", 0.0))
var key := "%s@%.2f,%.2f" % [prop, x, z]
if seen.has(key):
problems.append("duplicate placement %s" % key)
seen[key] = true
if x < float(PROP_BOUNDS["x0"]) or x > float(PROP_BOUNDS["x1"]) \
or z < float(PROP_BOUNDS["z0"]) or z > float(PROP_BOUNDS["z1"]):
problems.append("%s at (%.1f, %.1f) is outside the sidewalk" % [prop, x, z])
for zone in PROP_EXCLUSIONS:
if x > float(zone["x0"]) and x < float(zone["x1"]) \
and z > float(zone["z0"]) and z < float(zone["z1"]):
problems.append("%s at (%.1f, %.1f) blocks %s" % [prop, x, z, zone["name"]])
for j in range(i):
var other: Dictionary = placements[j]
var dx := x - float(other.get("x", 0.0))
var dz := z - float(other.get("z", 0.0))
if Vector2(dx, dz).length() < PROP_MIN_SPACING:
problems.append("%s and %s at (%.1f, %.1f) overlap" % [
prop, str(other.get("prop", "")), x, z])
return problems
## Street furniture built from the generated GLB props (tools/model_pipeline.py). ## Street furniture built from the generated GLB props (tools/model_pipeline.py).
## The meshes are real 3D models (bollards, benches, bins, planters, newspaper ## The meshes are real 3D models (bollards, benches, bins, planters, newspaper
## boxes, a hydrant) rather than scaled boxes, and every placement is fixed so ## boxes, a hydrant) rather than scaled boxes. Placements and their deterministic
## the street looks the same on every run. ## variants are fixed, so the street looks the same on every run.
func _build_street_detail() -> void: func _build_street_detail() -> void:
var detail := Node3D.new() var detail := Node3D.new()
detail.name = "StreetDetail" detail.name = "StreetDetail"
root.add_child(detail) root.add_child(detail)
# Bollards flank the foot of the steps. for index in PROP_PLACEMENTS.size():
for x in [-5.6, -4.2, 4.2, 5.6]: var entry: Dictionary = PROP_PLACEMENTS[index]
_add_prop_model(detail, "bollard", Vector3(x, 0.18, 4.7)) var base_rot := float(entry.get("rot", 0.0))
# Benches face the boulevard. var variant := variant_for(index, base_rot)
_add_prop_model(detail, "bench", Vector3(-8.8, 0.18, 6.4), 180.0) var holder := _add_prop_model(detail, str(entry["prop"]),
_add_prop_model(detail, "bench", Vector3(8.8, 0.18, 6.4), 180.0) Vector3(float(entry["x"]), 0.18, float(entry["z"])),
# Bins where people leave the steps. float(variant["rotation"]), index)
_add_prop_model(detail, "trash_bin", Vector3(-6.6, 0.18, 4.9)) holder.scale = Vector3.ONE * float(variant["scale"])
_add_prop_model(detail, "trash_bin", Vector3(6.6, 0.18, 4.9)) var accessory := str(entry.get("accessory", ""))
# Planters break up the long sidewalk run. if accessory != "":
_add_prop_model(detail, "planter_box", Vector3(-11.8, 0.18, 8.8)) _add_prop_accessory(holder, accessory)
_add_prop_model(detail, "planter_box", Vector3(11.8, 0.18, 8.8))
# Newspaper boxes and a hydrant along the curb.
_add_prop_model(detail, "newspaper_box", Vector3(-9.6, 0.18, 11.0), -12.0)
_add_prop_model(detail, "newspaper_box", Vector3(-8.9, 0.18, 11.0), -12.0)
_add_prop_model(detail, "fire_hydrant", Vector3(8.4, 0.18, 11.2))
## Place one generated prop. Falls back to simple primitives when the `.glb` is ## Place one generated prop. Falls back to simple primitives when the `.glb` is
## missing (fresh checkout before `godot --import`) so the world still builds. ## missing (fresh checkout before `godot --import`) so the world still builds.
## A static collider is fitted from the model bounds (cylinder for round props,
## box for everything else), so props block the guard and NPCs.
func _add_prop_model(parent: Node3D, prop: String, pos: Vector3, func _add_prop_model(parent: Node3D, prop: String, pos: Vector3,
rot_y_deg: float = 0.0) -> Node3D: rot_y_deg: float = 0.0, variant_index: int = -1) -> Node3D:
var holder := Node3D.new() var holder := Node3D.new()
holder.name = "Prop_%s_%d" % [prop, parent.get_child_count()] holder.name = "Prop_%s_%d" % [prop, parent.get_child_count()]
holder.position = pos holder.position = pos
@@ -373,11 +450,97 @@ func _add_prop_model(parent: Node3D, prop: String, pos: Vector3,
holder.add_child(model) holder.add_child(model)
loaded_models.append(prop) loaded_models.append(prop)
else: else:
holder.add_child(_fallback_prop(prop)) var fallback := _fallback_prop(prop)
holder.add_child(fallback)
fallback_props.append(prop) fallback_props.append(prop)
model = fallback
parent.add_child(holder) parent.add_child(holder)
_add_prop_collision(holder, prop, model)
return holder return holder
## Fit a simple collider to the prop's meshes (in holder-local space, so the
## placement scale and rotation carry over).
func _add_prop_collision(holder: Node3D, prop: String, model: Node3D) -> void:
var bounds := _model_bounds(model, holder)
if bounds.size == Vector3.ZERO:
return
var body := StaticBody3D.new()
body.name = "Collision"
var collider := CollisionShape3D.new()
collider.name = "Shape"
var round_props := ["bollard", "trash_bin", "fire_hydrant"]
if prop in round_props:
var cylinder := CylinderShape3D.new()
cylinder.radius = maxf(bounds.size.x, bounds.size.z) * 0.5
cylinder.height = bounds.size.y
collider.shape = cylinder
else:
var box := BoxShape3D.new()
box.size = bounds.size
collider.shape = box
collider.position = bounds.get_center()
body.add_child(collider)
holder.add_child(body)
# Publish the footprint to NavigationServer so guest avoidance steers around
# the prop even though the navmesh itself is a flat walkable plane.
var obstacle := NavigationObstacle3D.new()
obstacle.name = "Avoidance"
obstacle.radius = maxf(bounds.size.x, bounds.size.z) * 0.5 + 0.25
obstacle.height = maxf(bounds.size.y, 0.5)
obstacle.position = Vector3(bounds.get_center().x, 0.0, bounds.get_center().z)
holder.add_child(obstacle)
## AABB of every mesh under `model`, expressed relative to `ancestor`.
func _model_bounds(model: Node3D, ancestor: Node3D) -> AABB:
var bounds := AABB()
var found := false
for node in model.find_children("*", "MeshInstance3D", true, false):
var mi := node as MeshInstance3D
if mi.mesh == null:
continue
var xform := _transform_to_ancestor(mi, ancestor)
var box: AABB = xform * mi.mesh.get_aabb()
bounds = box if not found else bounds.merge(box)
found = true
return bounds
func _transform_to_ancestor(node: Node, ancestor: Node) -> Transform3D:
var xform := Transform3D.IDENTITY
var current := node
while current != null and current != ancestor:
if current is Node3D:
xform = (current as Node3D).transform * xform
current = current.get_parent()
return xform
## Small deterministic clutter next to a prop: a bag by the bin, a crate by the
## newspaper boxes, a cone by the hydrant.
func _add_prop_accessory(holder: Node3D, kind: String) -> void:
match kind:
"bag":
var bag := MeshInstance3D.new()
var sphere := SphereMesh.new()
sphere.radius = 0.22
sphere.height = 0.5
bag.mesh = sphere
bag.material_override = Materials.flat(Color(0.10, 0.11, 0.13), "rubber")
bag.position = Vector3(0.38, 0.22, 0.06)
holder.add_child(bag)
_add_box(holder, Vector3(0.12, 0.10, 0.12), Vector3(0.38, 0.48, 0.06),
Color(0.16, 0.17, 0.20), false, null, Vector2.ONE, "rubber")
"crate":
_add_box(holder, Vector3(0.46, 0.40, 0.40), Vector3(0.48, 0.20, 0.08),
Color(0.45, 0.31, 0.18), true, null, Vector2.ONE, "wood")
_add_box(holder, Vector3(0.48, 0.03, 0.42), Vector3(0.48, 0.41, 0.08),
Color(0.35, 0.23, 0.13), false, null, Vector2.ONE, "wood")
"cone":
_add_cylinder_mesh(holder, 0.16, 0.55, Vector3(-0.42, 0.28, 0.10),
Color(0.95, 0.36, 0.10), "painted_metal")
_add_box(holder, Vector3(0.38, 0.05, 0.38), Vector3(-0.42, 0.03, 0.10),
Color(0.12, 0.12, 0.14), false, null, Vector2.ONE, "rubber")
_add_cylinder_mesh(holder, 0.11, 0.08, Vector3(-0.42, 0.34, 0.10),
Color(0.92, 0.92, 0.9), "painted_metal")
func _load_prop_scene(prop: String) -> Node3D: func _load_prop_scene(prop: String) -> Node3D:
var path := MODEL_DIR + prop + ".glb" var path := MODEL_DIR + prop + ".glb"
if not ResourceLoader.exists(path): if not ResourceLoader.exists(path):
+94 -1
View File
@@ -13,6 +13,7 @@ var state := 0
var state_frames := 0 var state_frames := 0
var targeted := false var targeted := false
var camera_staged := false var camera_staged := false
var wand_attempts := 0
func _initialize() -> void: func _initialize() -> void:
root.size = Vector2i(1280, 720) root.size = Vector2i(1280, 720)
@@ -89,7 +90,7 @@ func _advance() -> void:
if gm != null: if gm != null:
gm.radio_panel.hide() gm.radio_panel.hide()
gm.hud.set_action_hint("") gm.hud.set_action_hint("")
gm.coordinator.elapsed_s = 750.0 gm.coordinator.elapsed_s = 620.0
state = 5 state = 5
state_frames = 0 state_frames = 0
5: 5:
@@ -110,9 +111,101 @@ func _advance() -> void:
gm.call("_toggle_flashlight") gm.call("_toggle_flashlight")
if state_frames >= 30: if state_frames >= 30:
_shoot("06-night-flashlight.png") _shoot("06-night-flashlight.png")
# Move to The Joke Shop for the wand/shift-review set, and keep
# the late-night phase so the UI sits on the night scene.
if gm != null:
gm.call("_toggle_flashlight")
gm.switch_scenario("joke_shop")
gm.coordinator.elapsed_s = 620.0
state = 7 state = 7
state_frames = 0
7:
# Wand screening: wait for guests and keep re-targeting until one
# accepts the screen (some authored Joke Shop guests refuse it).
if state_frames > 40 and state_frames % 20 == 0 and gm != null and gm.npcs.size() > 0:
# Prefer a guest whose items make the wand alert, so the panel
# shows the interesting case instead of "Alert: none".
if gm.wand_open:
if _wand_alerts():
state = 8
state_frames = 0
elif wand_attempts >= 6:
# Nobody nearby carried an alerting item: stage the documented
# false-alarm case (keys) on this guest and re-open the panel.
var guest: Object = gm.call("_targeted_controller")
if guest != null and guest.profile != null:
var false_alarm: Array[String] = ["keys"]
guest.profile.carried_items = false_alarm
gm.call("_toggle_wand_screening")
gm.call("_toggle_wand_screening")
state = 8
state_frames = 0
else:
gm.call("_toggle_wand_screening")
wand_attempts += 1
else:
gm.call("_cycle_nearby_target")
_face_target()
gm.call("_toggle_wand_screening")
wand_attempts += 1
elif state_frames > 600 and gm != null:
# Fallback: real controller, panel shown directly.
var controller: Object = gm.call("_targeted_controller")
if controller != null and controller.profile != null:
gm.hud.show_wand(controller, gm.wand_screener.screen(controller.profile.carried_items))
state = 8
state_frames = 0
8:
if state_frames == 1 and gm != null:
gm.hud.clear_log()
_stage_closeup()
if state_frames >= 45:
_shoot("07-wand-screening.png")
if gm != null:
if gm.wand_open:
gm.call("_toggle_wand_screening")
# Real escalation path: formal warning arms the body camera
# (a timeline row), then blocking raises the meter to L3.
gm.call("_apply_interaction", 2, "formal warning")
gm.call("_apply_interaction", 3, "blocking")
state = 9
state_frames = 0
9:
if state_frames == 1 and gm != null:
# Jump into the late phase now that guests exist: LATE has no
# routine spawn pool, so this happens after staging the target.
gm.coordinator.elapsed_s = 760.0
_stage_closeup()
if state_frames >= 45:
_shoot("08-escalation-hud.png")
if gm != null and gm.coordinator != null:
# Close the open incident so the recorder's review includes it
# (to_dict only lists closed incidents).
gm.coordinator.recorder.close_all_open("unresolved")
gm.call("_open_timeline", {"incidents": gm.coordinator.recorder.to_dict()})
state = 10
state_frames = 0
10:
if state_frames >= 30:
_shoot("09-body-camera-timeline.png")
quit(0) quit(0)
## Put the guard a couple of steps in front of the target so a staged UI capture
## frames the person the panel is about.
func _wand_alerts() -> bool:
if gm == null or gm.hud == null or gm.hud.wand_text == null:
return false
return gm.hud.wand_text.text.contains("Alert: YES")
func _stage_closeup() -> void:
var target: Node3D = gm.targeted_npc
if target == null or not is_instance_valid(target):
return
gm.guard.global_position = target.global_position + Vector3(0, -0.4, 2.2)
gm.guard.global_position.y = 0.1
_face_target()
gm.guard.head.rotation.x = deg_to_rad(1.0)
func _face_target() -> void: func _face_target() -> void:
var target: Node3D = gm.targeted_npc var target: Node3D = gm.targeted_npc
if target == null: if target == null:
+165
View File
@@ -0,0 +1,165 @@
extends SceneTree
## Prop-collision smoke: proves the guard and an NPC cannot walk through street
## furniture, and that a guest slides around a bench instead of passing through
## it. Windowed (the guard reads captured-mouse input):
##
## godot --path . --script res://src/test/smoke_props.gd
##
## Exit code 0 = pass, 1 = failure.
const MAX_FRAMES := 1800
const GUARD_PUSH_FRAMES := 100
var frame := 0
var gm: Node = null
var started := false
var step := 0
var step_frame := 0
var failures: Array[String] = []
var bench: Node3D = null
var bench_center := Vector3.ZERO
var bench_body: StaticBody3D = null
var guard_start := Vector3.ZERO
var npc: Node3D = null
var npc_start := Vector3.ZERO
func _initialize() -> void:
root.size = Vector2i(1280, 720)
root.add_child(load("res://scenes/main.tscn").instantiate())
func _process(_delta: float) -> bool:
frame += 1
if gm == null:
gm = root.find_child("GameManager", true, false)
return false
if not started:
gm.call("_on_venue_selected", "hollywood_building")
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
started = true
return false
if frame > MAX_FRAMES:
_fail("timeout before the collision checks finished")
return true
step_frame += 1
match step:
0:
_find_bench()
if bench == null:
return false
# Stand a couple of metres in front of the bench and walk into it.
var start := bench_center + Vector3(0, 0, 2.2)
start.y = 0.1
gm.guard.global_position = start
# Face the bench (-z): with the guard's basis, forward input at yaw PI
# walks toward lower z.
gm.guard.rotation.y = PI
guard_start = start
Input.action_press("move_forward")
step = 1
step_frame = 0
1:
if step_frame >= GUARD_PUSH_FRAMES:
Input.action_release("move_forward")
_check(guard_start.z - gm.guard.global_position.z > 0.5,
"guard walked toward the bench")
_check(gm.guard.global_position.z > bench_center.z + 0.25,
"guard stopped at the bench instead of passing through it")
_check(not _inside_bench(gm.guard.global_position),
"guard is not inside the bench collider")
# Step the guard aside so the guest test hits the bench, not the player.
gm.guard.global_position = bench_center + Vector3(4.0, -0.08, 2.2)
_stage_npc()
step = 2
step_frame = 0
2:
if step_frame % 120 == 0 and npc != null and is_instance_valid(npc):
print("npc dbg pos=%s vel=%s arrived=%s wait=%s" % [str(npc.global_position), str(npc.velocity), str(npc.arrived), str(npc.awaiting_interaction)])
if npc == null or not is_instance_valid(npc):
_stage_npc()
return false
if _npc_finished():
_check(npc.global_position.z < bench_center.z - 0.4,
"guest reached the far side of the bench")
_check(not _inside_bench(npc.global_position),
"guest is not inside the bench collider")
_check(npc_start.distance_to(npc.global_position) > 1.0,
"guest moved while routing around the prop")
_finish()
return true
return false
func _find_bench() -> void:
var detail := gm.get_node_or_null("World/StreetDetail")
if detail == null:
# The world root name differs between scene builds; search instead.
detail = root.find_child("StreetDetail", true, false)
if detail == null:
_fail("StreetDetail not found")
return
for child in detail.get_children():
if str(child.name).begins_with("Prop_bench"):
bench = child as Node3D
break
if bench == null:
_fail("no bench prop placed")
return
bench_center = bench.global_position
bench_body = bench.get_node_or_null("Collision") as StaticBody3D
if bench_body == null:
_fail("bench has no static collider")
func _stage_npc() -> void:
if gm.npcs.is_empty():
step = 0
step_frame = 0
return
npc = gm.npcs[0]
npc_start = bench_center + Vector3(0, 0.5, 2.5)
npc.global_position = npc_start
npc.velocity = Vector3.ZERO
npc.arrived = false
npc.awaiting_interaction = false
npc.lifetime_s = 999.0
npc.reaction_mode = ""
# Target well past the bench forces a straight line through it, and stays
# beyond the controller's 3 m "arrived" shortcut so the guest must route.
npc.target_position = Vector3(bench_center.x, 0.5, bench_center.z - 5.0)
func _npc_finished() -> bool:
if npc == null or not is_instance_valid(npc):
return false
return npc.global_position.z < bench_center.z - 0.4 \
or npc.global_position.distance_to(npc.target_position) < 0.9
func _inside_bench(point: Vector3) -> bool:
if bench_body == null:
return false
var collider := bench_body.get_node_or_null("Shape") as CollisionShape3D
if collider == null or not (collider.shape is BoxShape3D):
return false
var box := collider.shape as BoxShape3D
var half := box.size * 0.5
var center := collider.global_position
half.x += 0.1
half.z += 0.1
return absf(point.x - center.x) < half.x and absf(point.z - center.z) < half.z
func _check(condition: bool, message: String) -> void:
if condition:
print("ok: " + message)
else:
_fail(message)
func _fail(message: String) -> void:
failures.append(message)
_finish()
func _finish() -> void:
Input.action_release("move_forward")
if failures.is_empty():
print("SMOKE OK: props block the guard and guests route around them")
quit(0)
else:
for message in failures:
printerr("SMOKE FAIL: " + message)
quit(1)
+119
View File
@@ -39,6 +39,35 @@ func test_prop_scenes_instantiate_with_meshes() -> bool:
instance.free() instance.free()
return TestUtils.check(problems.is_empty(), "every prop scene has geometry (%s)" % str(problems)) return TestUtils.check(problems.is_empty(), "every prop scene has geometry (%s)" % str(problems))
func test_prop_scenes_share_one_atlas_material() -> bool:
## All parts of a prop share one material backed by the colour atlas, so a
## prop is a single draw material instead of one per part.
var problems: Array[String] = []
for prop in WorldBuilder.MODEL_PROPS:
var packed := ResourceLoader.load(MODEL_DIR + str(prop) + ".glb") as PackedScene
if packed == null:
problems.append("%s: no scene" % prop)
continue
var instance := packed.instantiate()
var meshes := instance.find_children("*", "MeshInstance3D", true, false)
var materials := {}
var surfaces := 0
for node in meshes:
var mesh: Mesh = (node as MeshInstance3D).mesh
if mesh == null:
continue
for index in mesh.get_surface_count():
surfaces += 1
var material := mesh.surface_get_material(index)
if material != null:
materials[material.get_instance_id()] = true
if surfaces == 0:
problems.append("%s: no surfaces" % prop)
elif materials.size() != 1:
problems.append("%s: %d materials (expected 1)" % [prop, materials.size()])
instance.free()
return TestUtils.check(problems.is_empty(), "one atlas material per prop (%s)" % str(problems))
func test_world_places_generated_props() -> bool: func test_world_places_generated_props() -> bool:
var root := Node3D.new() var root := Node3D.new()
var builder := WorldBuilder.new(root) var builder := WorldBuilder.new(root)
@@ -54,6 +83,96 @@ func test_world_places_generated_props() -> bool:
root.free() root.free()
return a and b and c and d return a and b and c and d
func test_prop_placements_are_valid() -> bool:
var problems := WorldBuilder.placement_problems()
return TestUtils.check(problems.is_empty(),
"prop placements keep the door, steps and approach lane clear (%s)" % str(problems))
func test_placement_validator_catches_problems() -> bool:
var bad: Array = WorldBuilder.PROP_PLACEMENTS.duplicate(true)
bad.append({"zone": "test", "prop": "bench", "x": 0.0, "z": 2.0})
bad.append({"zone": "test", "prop": "bench", "x": 0.0, "z": 2.0})
var problems := WorldBuilder.placement_problems(bad)
var a := TestUtils.check(not problems.is_empty(), "validator rejects bad placements")
var joined := " ".join(problems)
var b := TestUtils.check(joined.contains("steps and door"),
"validator reports the door/steps exclusion")
var c := TestUtils.check(joined.contains("duplicate"), "validator reports a duplicate")
return a and b and c
func test_prop_variants_are_deterministic() -> bool:
var first := WorldBuilder.variant_for(3, 180.0)
var second := WorldBuilder.variant_for(3, 180.0)
var other := WorldBuilder.variant_for(4, 180.0)
var a := TestUtils.check(is_equal_approx(first["rotation"], second["rotation"])
and is_equal_approx(first["scale"], second["scale"]),
"the same placement always gets the same variant")
var b := TestUtils.check(not is_equal_approx(first["rotation"], other["rotation"]),
"different placements get different rotations")
var c := TestUtils.check(first["scale"] >= 0.95 and first["scale"] <= 1.05,
"variant scale stays subtle")
var d := TestUtils.check(absf(first["rotation"] - 180.0) <= 2.01,
"variant rotation stays near the authored angle")
return a and b and c and d
func test_every_prop_has_a_collider() -> bool:
var root := Node3D.new()
var builder := WorldBuilder.new(root)
builder.build()
var detail := root.get_node_or_null("StreetDetail")
var problems: Array[String] = []
if detail == null:
problems.append("no StreetDetail")
else:
for holder in detail.get_children():
var body := holder.get_node_or_null("Collision")
if body == null or not (body is StaticBody3D):
problems.append("%s has no StaticBody3D" % holder.name)
continue
var collider := body.get_node_or_null("Shape") as CollisionShape3D
if collider == null or collider.shape == null:
problems.append("%s has no collision shape" % holder.name)
continue
var size: Vector3
if collider.shape is BoxShape3D:
size = (collider.shape as BoxShape3D).size
elif collider.shape is CylinderShape3D:
var cylinder := collider.shape as CylinderShape3D
size = Vector3(cylinder.radius * 2.0, cylinder.height, cylinder.radius * 2.0)
else:
problems.append("%s uses an unexpected shape" % holder.name)
continue
if size.x <= 0.05 or size.y <= 0.05 or size.z <= 0.05:
problems.append("%s has a degenerate collider" % holder.name)
if collider.position.y + size.y * 0.5 <= 0.05:
problems.append("%s collider sits below the ground" % holder.name)
if holder.get_node_or_null("Avoidance") == null:
problems.append("%s has no navigation obstacle" % holder.name)
root.free()
return TestUtils.check(problems.is_empty(), "every prop has a static collider (%s)" % str(problems))
func test_fallback_props_get_the_same_collider() -> bool:
## A missing `.glb` must not silently remove collision: fallback primitives go
## through the same bounds fit as the imported models.
var root := Node3D.new()
var builder := WorldBuilder.new(root)
var holder := Node3D.new()
var fallback := builder._fallback_prop("bench")
holder.add_child(fallback)
builder._add_prop_collision(holder, "bench", fallback)
var body := holder.get_node_or_null("Collision") as StaticBody3D
var a := TestUtils.check(body != null, "fallback bench gets a static body")
var collider := body.get_node_or_null("Shape") as CollisionShape3D if body else null
var b := TestUtils.check(collider != null and collider.shape is BoxShape3D,
"fallback bench gets a box shape")
var width: float = (collider.shape as BoxShape3D).size.x if b else 0.0
var c := TestUtils.check(width > 1.0, "fallback collider follows the bench width (%.2f)" % width)
var d := TestUtils.check(holder.get_node_or_null("Avoidance") is NavigationObstacle3D,
"fallback bench publishes a navigation obstacle")
holder.free()
root.free()
return a and b and c and d
func test_world_facade_windows_light_at_night() -> bool: func test_world_facade_windows_light_at_night() -> bool:
var root := Node3D.new() var root := Node3D.new()
var builder := WorldBuilder.new(root) var builder := WorldBuilder.new(root)
+181 -67
View File
@@ -475,99 +475,196 @@ def generate_concepts(only="", model=DEFAULT_CONCEPT_MODEL, size=1024,
# glTF binary writer. # glTF binary writer.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
ATLAS_CELL = 8
def _next_pow2(value: int) -> int:
size = 1
while size < value:
size *= 2
return size
def _png_bytes(width: int, height: int, rgba: bytearray) -> bytes:
"""Encode raw RGBA rows as a PNG without requiring Pillow."""
import zlib
rows = bytearray()
stride = width * 4
for y in range(height):
rows.append(0) # filter type 0
rows.extend(rgba[y * stride:(y + 1) * stride])
def chunk(tag: bytes, payload: bytes) -> bytes:
return (struct.pack(">I", len(payload)) + tag + payload +
struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF))
header = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) +
chunk(b"IDAT", zlib.compress(bytes(rows), 9)) + chunk(b"IEND", b""))
def atlas_for(prop: dict, palette: dict = None) -> dict:
"""Pack every colour this prop uses into one tiny texture atlas.
Each part gets a constant UV pointing at its palette cell, so all parts can
share a single material instead of one material per part. The same layout
is written into a metallic/roughness atlas (glTF reads roughness from G and
metallic from B), keeping the per-role surface response.
"""
palette = palette or PALETTE
slots = {}
for part in prop["parts"]:
role = part.get("role", "body")
colour = tuple(round(float(c), 5)
for c in palette.get(role, PALETTE[role])[:3])
metallic, roughness = PBR.get(role, PBR["body"])
slots[role] = (colour, metallic, roughness)
roles = sorted(slots.keys())
grid = max(1, int(math.ceil(math.sqrt(len(roles)))))
size = _next_pow2(grid * ATLAS_CELL)
base = bytearray(size * size * 4)
metal_rough = bytearray(size * size * 4)
role_uv = {}
for index, role in enumerate(roles):
colour, metallic, roughness = slots[role]
col = index % grid
row = index // grid
x0 = col * ATLAS_CELL
y0 = row * ATLAS_CELL
rgb = bytes((int(round(colour[0] * 255)), int(round(colour[1] * 255)),
int(round(colour[2] * 255)), 255))
mr = bytes((0, int(round(roughness * 255)), int(round(metallic * 255)), 255))
for y in range(ATLAS_CELL):
for x in range(ATLAS_CELL):
offset = ((y0 + y) * size + (x0 + x)) * 4
base[offset:offset + 4] = rgb
metal_rough[offset:offset + 4] = mr
role_uv[role] = ((x0 + ATLAS_CELL / 2.0) / size,
(y0 + ATLAS_CELL / 2.0) / size)
return {
"size": size,
"roles": roles,
"role_uv": role_uv,
"colours": {role: slots[role][0] for role in roles},
"base_png": _png_bytes(size, size, base),
"metal_rough_png": _png_bytes(size, size, metal_rough),
}
def _pad(data: bytes, fill: bytes) -> bytes: def _pad(data: bytes, fill: bytes) -> bytes:
remainder = len(data) % 4 remainder = len(data) % 4
return data if remainder == 0 else data + fill * (4 - remainder) return data if remainder == 0 else data + fill * (4 - remainder)
def srgb_to_linear(channel: float) -> float: def build_glb(prop: dict, palette: dict = None, atlas: dict = None) -> bytes:
"""Convert one 0..1 sRGB channel to linear. """Serialise one prop as a self-contained .glb (glTF 2.0) file.
glTF stores `baseColorFactor` in linear space, while the catalogue palettes All parts share one material backed by a colour atlas and a
(and the colours sampled from a concept PNG) are sRGB. Writing sRGB numbers metallic/roughness atlas, so the prop draws with a single material.
straight into the factor makes every prop render several shades too light,
which is why the first build's bins and newspaper boxes looked pastel.
""" """
if channel <= 0.04045:
return channel / 12.92
return ((channel + 0.055) / 1.055) ** 2.4
def _linear_rgba(color) -> list:
return [round(srgb_to_linear(float(c)), 5) for c in color[:3]] + [1.0]
def build_glb(prop: dict, palette: dict = None) -> bytes:
"""Serialise one prop as a self-contained .glb (glTF 2.0) file."""
palette = palette or PALETTE palette = palette or PALETTE
atlas = atlas or atlas_for(prop, palette)
positions = []
normals = []
uvs = []
indices = []
for part in prop["parts"]:
mesh = build_part_mesh(part)
base = len(positions)
uv = atlas["role_uv"][part.get("role", "body")]
for position, normal in zip(mesh.positions, mesh.normals):
positions.append(position)
normals.append(normal)
uvs.append(uv)
indices.extend(index + base for index in mesh.indices)
binary = bytearray() binary = bytearray()
buffer_views = [] buffer_views = []
accessors = [] accessors = []
materials = []
primitives = []
def add_view(payload: bytes, target: int) -> int: def add_view(payload: bytes, target=None) -> int:
buffer_views.append({ view = {
"buffer": 0, "buffer": 0,
"byteOffset": len(binary), "byteOffset": len(binary),
"byteLength": len(payload), "byteLength": len(payload),
"target": target, }
}) if target is not None:
view["target"] = target
buffer_views.append(view)
binary.extend(_pad(payload, b"\x00")) binary.extend(_pad(payload, b"\x00"))
return len(buffer_views) - 1 return len(buffer_views) - 1
# One primitive per part: the parts share a mesh but need separate pos_payload = b"".join(struct.pack("<3f", *p) for p in positions)
# materials, and a glTF primitive is the unit of material assignment. nrm_payload = b"".join(struct.pack("<3f", *n) for n in normals)
for part in prop["parts"]: uv_payload = b"".join(struct.pack("<2f", *uv) for uv in uvs)
mesh = build_part_mesh(part) idx_payload = b"".join(struct.pack("<H", i) for i in indices)
positions = b"".join(struct.pack("<3f", *p) for p in mesh.positions)
normals = b"".join(struct.pack("<3f", *n) for n in mesh.normals)
indices = b"".join(struct.pack("<H", i) for i in mesh.indices)
pos_view = add_view(positions, 34962)
nrm_view = add_view(normals, 34962)
idx_view = add_view(indices, 34963)
xs = [p[0] for p in mesh.positions] or [0.0] pos_view = add_view(pos_payload, 34962)
ys = [p[1] for p in mesh.positions] or [0.0] nrm_view = add_view(nrm_payload, 34962)
zs = [p[2] for p in mesh.positions] or [0.0] uv_view = add_view(uv_payload, 34962)
pos_accessor = len(accessors) idx_view = add_view(idx_payload, 34963)
accessors.append({"bufferView": pos_view, "componentType": 5126,
"count": len(mesh.positions), "type": "VEC3",
"min": [min(xs), min(ys), min(zs)],
"max": [max(xs), max(ys), max(zs)]})
nrm_accessor = len(accessors)
accessors.append({"bufferView": nrm_view, "componentType": 5126,
"count": len(mesh.normals), "type": "VEC3"})
idx_accessor = len(accessors)
accessors.append({"bufferView": idx_view, "componentType": 5123,
"count": len(mesh.indices), "type": "SCALAR"})
role = part.get("role", "body") xs = [p[0] for p in positions] or [0.0]
color = palette.get(role, PALETTE[role]) ys = [p[1] for p in positions] or [0.0]
metallic, roughness = PBR.get(role, PBR["body"]) zs = [p[2] for p in positions] or [0.0]
materials.append({ pos_accessor = len(accessors)
"name": role, accessors.append({"bufferView": pos_view, "componentType": 5126,
"doubleSided": False, "count": len(positions), "type": "VEC3",
"pbrMetallicRoughness": { "min": [min(xs), min(ys), min(zs)],
"baseColorFactor": _linear_rgba(color), "max": [max(xs), max(ys), max(zs)]})
"metallicFactor": metallic, nrm_accessor = len(accessors)
"roughnessFactor": roughness, accessors.append({"bufferView": nrm_view, "componentType": 5126,
}, "count": len(normals), "type": "VEC3"})
}) uv_accessor = len(accessors)
primitives.append({ accessors.append({"bufferView": uv_view, "componentType": 5126,
"attributes": {"POSITION": pos_accessor, "NORMAL": nrm_accessor}, "count": len(uvs), "type": "VEC2"})
"indices": idx_accessor, idx_accessor = len(accessors)
"material": len(materials) - 1, accessors.append({"bufferView": idx_view, "componentType": 5123,
}) "count": len(indices), "type": "SCALAR"})
base_view = add_view(atlas["base_png"])
mr_view = add_view(atlas["metal_rough_png"])
gltf = { gltf = {
"asset": {"version": "2.0", "generator": "step-line model_pipeline"}, "asset": {"version": "2.0", "generator": "step-line model_pipeline"},
"scene": 0, "scene": 0,
"scenes": [{"name": prop["name"], "nodes": [0]}], "scenes": [{"name": prop["name"], "nodes": [0]}],
"nodes": [{"name": prop["name"], "mesh": 0}], "nodes": [{"name": prop["name"], "mesh": 0}],
"meshes": [{"name": prop["name"], "primitives": primitives}], "meshes": [{
"materials": materials, "name": prop["name"],
"primitives": [{
"attributes": {"POSITION": pos_accessor,
"NORMAL": nrm_accessor,
"TEXCOORD_0": uv_accessor},
"indices": idx_accessor,
"material": 0,
}],
}],
"materials": [{
"name": "prop_palette",
"doubleSided": False,
"pbrMetallicRoughness": {
"baseColorTexture": {"index": 0},
"metallicRoughnessTexture": {"index": 1},
"metallicFactor": 1.0,
"roughnessFactor": 1.0,
},
}],
"textures": [
{"sampler": 0, "source": 0},
{"sampler": 0, "source": 1},
],
"samplers": [{
"magFilter": 9728, # NEAREST, so a palette cell stays a flat colour
"minFilter": 9728,
"wrapS": 33071, # CLAMP_TO_EDGE
"wrapT": 33071,
}],
"images": [
{"bufferView": base_view, "mimeType": "image/png"},
{"bufferView": mr_view, "mimeType": "image/png"},
],
"accessors": accessors, "accessors": accessors,
"bufferViews": buffer_views, "bufferViews": buffer_views,
"buffers": [{"byteLength": len(binary)}], "buffers": [{"byteLength": len(binary)}],
@@ -646,8 +743,25 @@ def audit_glb(path: Path) -> dict:
issues.append("primitive references a missing accessor") issues.append("primitive references a missing accessor")
if triangles <= 0: if triangles <= 0:
issues.append("no triangles") issues.append("no triangles")
if not gltf.get("materials"): materials = gltf.get("materials", [])
if not materials:
issues.append("no materials") issues.append("no materials")
elif len(materials) != 1:
issues.append(f"expected 1 shared material, found {len(materials)}")
else:
pbr = materials[0].get("pbrMetallicRoughness", {})
if "baseColorTexture" not in pbr:
issues.append("shared material has no baseColorTexture")
if "metallicRoughnessTexture" not in pbr:
issues.append("shared material has no metallicRoughnessTexture")
if len(gltf.get("textures", [])) < 2:
issues.append("expected colour and metallic/roughness textures")
if len(gltf.get("images", [])) < 2:
issues.append("expected colour and metallic/roughness atlas images")
for mesh in gltf.get("meshes", []):
for primitive in mesh.get("primitives", []):
if "TEXCOORD_0" not in primitive.get("attributes", {}):
issues.append("primitive has no atlas UVs (TEXCOORD_0)")
for buffer in gltf.get("buffers", []): for buffer in gltf.get("buffers", []):
if int(buffer.get("byteLength", 0)) > len(data): if int(buffer.get("byteLength", 0)) > len(data):
issues.append("buffer larger than the file") issues.append("buffer larger than the file")
+16 -12
View File
@@ -342,7 +342,7 @@ SCREENS = [
"kind": "ui", "kind": "ui",
"title": "Wand screening", "title": "Wand screening",
"source": "README screenshot gallery · PLAN The Joke Shop · src/core/wand_screener.gd", "source": "README screenshot gallery · PLAN The Joke Shop · src/core/wand_screener.gd",
"ref": None, "ref": "07-wand-screening.png",
"description": "Handheld wand screening panel for The Joke Shop: the alert " "description": "Handheld wand screening panel for The Joke Shop: the alert "
"result, the items the guest is carrying, and allow/deny " "result, the items the guest is carrying, and allow/deny "
"buttons. A phone, keys or a belt buckle can raise a false " "buttons. A phone, keys or a belt buckle can raise a false "
@@ -452,7 +452,7 @@ SCREENS = [
"kind": "ui", "kind": "ui",
"title": "Body-camera timeline", "title": "Body-camera timeline",
"source": "README screenshot gallery · ADR-0005 · src/game/ui/event_timeline.gd", "source": "README screenshot gallery · ADR-0005 · src/game/ui/event_timeline.gd",
"ref": None, "ref": "09-body-camera-timeline.png",
"description": "The shift review's body-camera timeline: a text list of every " "description": "The shift review's body-camera timeline: a text list of every "
"recorded incident event stamped mm:ss with the person and the " "recorded incident event stamped mm:ss with the person and the "
"action or outcome, one row highlighted, and previous/next " "action or outcome, one row highlighted, and previous/next "
@@ -475,22 +475,25 @@ SCREENS = [
"kind": "ui", "kind": "ui",
"title": "Escalation HUD", "title": "Escalation HUD",
"source": "README accessibility note · Hud.escalation_meter (shape meter · level)", "source": "README accessibility note · Hud.escalation_meter (shape meter · level)",
"ref": None, "ref": "08-escalation-hud.png",
"description": "The in-shift HUD: a shape meter plus numeric escalation level " "description": "The in-shift HUD: a shape meter plus numeric escalation level "
"(`Escalation: Blocking [###--] L3/5`), the targeted person's " "(`Escalation: Blocking [###--] L3/5`), the targeted person's "
"name, flashlight / door / body-camera indicators, shift clock, " "name, flashlight / door / body-camera indicators, shift clock, "
"and the reaction log.", "and the reaction log.",
"prompt": ( "prompt": (
"Realistic 3D game screenshot with a minimal in-game HUD overlaid: an " "Third-person over-the-shoulder 3D game screenshot at night: a security "
"escalation meter drawn as a shape bar with the text Blocking and L3/5, " "guard in the foreground facing a guest in front of a venue entrance, "
"a target name at the bottom centre, small indicator icons for flashlight, " "with a minimal in-game HUD overlaid. Bottom left: an escalation meter "
"door lock and body camera, a shift clock at the top right and a short " "drawn as a shape bar reading Escalation: Blocking [###--] L3/5. Centre: "
"reaction log at the left, over a softly blurred daytime street"), "a small target name label above the guest. Top right: a shift clock, "
"reputation and crowd percentage. Left: a short stacked reaction log. "
"Bottom centre: a control hint bar. Realistic lighting, muted colours"),
"regions": [ "regions": [
(0.30, 0.02, 0.70, 0.10, "escalation shape meter"), (0.02, 0.80, 0.34, 0.95, "escalation shape meter"),
(0.30, 0.86, 0.70, 0.96, "target name + hint"), (0.35, 0.28, 0.75, 0.40, "target name label"),
(0.78, 0.02, 0.98, 0.14, "shift clock"), (0.78, 0.02, 0.98, 0.16, "shift clock + crowd"),
(0.02, 0.60, 0.26, 0.95, "reaction log"), (0.02, 0.20, 0.36, 0.65, "reaction log"),
(0.02, 0.94, 0.98, 0.99, "control hints"),
], ],
}, },
] ]
@@ -592,6 +595,7 @@ def _generate_ai(screen, model, request_size):
def generate(args): def generate(args):
_load_env_file()
DESIGNS_DIR.mkdir(parents=True, exist_ok=True) DESIGNS_DIR.mkdir(parents=True, exist_ok=True)
failed = [] failed = []
selected = [screen for screen in SCREENS selected = [screen for screen in SCREENS
+36 -23
View File
@@ -94,14 +94,17 @@ class GlbTests(unittest.TestCase):
self.assertEqual(gltf["asset"]["version"], "2.0") self.assertEqual(gltf["asset"]["version"], "2.0")
self.assertEqual(len(gltf["scenes"]), 1) self.assertEqual(len(gltf["scenes"]), 1)
primitives = gltf["meshes"][0]["primitives"] primitives = gltf["meshes"][0]["primitives"]
self.assertEqual(len(primitives), len(prop("bench")["parts"]), self.assertEqual(len(primitives), 1,
"one primitive per part") "parts share one primitive (one material)")
self.assertEqual(len(gltf["materials"]), len(primitives)) self.assertEqual(len(gltf["materials"]), 1,
"the prop draws with a single material")
for primitive in primitives: for primitive in primitives:
self.assertIn("POSITION", primitive["attributes"]) self.assertIn("POSITION", primitive["attributes"])
self.assertIn("NORMAL", primitive["attributes"]) self.assertIn("NORMAL", primitive["attributes"])
self.assertIn("TEXCOORD_0", primitive["attributes"],
"atlas UVs are required")
self.assertIsNotNone(primitive["indices"]) self.assertIsNotNone(primitive["indices"])
self.assertLess(primitive["material"], len(gltf["materials"])) self.assertEqual(primitive["material"], 0)
for accessor in gltf["accessors"]: for accessor in gltf["accessors"]:
self.assertIn(accessor["bufferView"], self.assertIn(accessor["bufferView"],
range(len(gltf["bufferViews"]))) range(len(gltf["bufferViews"])))
@@ -114,31 +117,41 @@ class GlbTests(unittest.TestCase):
second = model_pipeline.build_glb(prop("bollard")) second = model_pipeline.build_glb(prop("bollard"))
self.assertEqual(first, second) self.assertEqual(first, second)
def test_palette_override_changes_material_colour(self): def test_palette_override_changes_the_atlas_colour(self):
palette = {role: list(rgb) palette = {role: list(rgb)
for role, rgb in model_pipeline.PALETTE.items()} for role, rgb in model_pipeline.PALETTE.items()}
palette["metal"] = [0.9, 0.1, 0.1] palette["metal"] = [0.9, 0.1, 0.1]
gltf = parse_glb(model_pipeline.build_glb( atlas = model_pipeline.atlas_for(prop("bollard"), palette)
prop("bollard"), palette))["gltf"] self.assertIn((0.9, 0.1, 0.1), atlas["colours"].values())
factors = [m["pbrMetallicRoughness"]["baseColorFactor"] default = model_pipeline.build_glb(prop("bollard"))
for m in gltf["materials"]] overridden = model_pipeline.build_glb(prop("bollard"), palette)
self.assertIn(model_pipeline._linear_rgba([0.9, 0.1, 0.1]), factors) self.assertNotEqual(default, overridden,
"the atlas texture carries the palette")
def test_glb_colours_are_written_in_linear_space(self): def test_material_uses_both_atlas_textures(self):
# glTF baseColorFactor is linear; writing sRGB made props render pastel.
gltf = parse_glb(model_pipeline.build_glb(prop("bollard")))["gltf"] gltf = parse_glb(model_pipeline.build_glb(prop("bollard")))["gltf"]
factor = gltf["materials"][0]["pbrMetallicRoughness"]["baseColorFactor"] pbr = gltf["materials"][0]["pbrMetallicRoughness"]
srgb_metal = model_pipeline.PALETTE["metal"][0] self.assertEqual(pbr["metallicFactor"], 1.0)
self.assertLess(factor[0], srgb_metal) self.assertEqual(pbr["roughnessFactor"], 1.0)
self.assertAlmostEqual(factor[0], self.assertIn("baseColorTexture", pbr)
model_pipeline.srgb_to_linear(srgb_metal), self.assertIn("metallicRoughnessTexture", pbr)
places=4) self.assertEqual(len(gltf["images"]), 2)
self.assertEqual(len(gltf["textures"]), 2)
for image in gltf["images"]:
self.assertEqual(image["mimeType"], "image/png")
self.assertEqual(gltf["samplers"][0]["magFilter"], 9728,
"nearest sampling keeps palette cells flat")
def test_srgb_to_linear_reference_values(self): def test_atlas_is_deterministic_and_power_of_two(self):
self.assertAlmostEqual(model_pipeline.srgb_to_linear(0.0), 0.0) first = model_pipeline.atlas_for(prop("planter_box"))
self.assertAlmostEqual(model_pipeline.srgb_to_linear(1.0), 1.0) second = model_pipeline.atlas_for(prop("planter_box"))
self.assertAlmostEqual(model_pipeline.srgb_to_linear(0.5), 0.2140, self.assertEqual(first["base_png"], second["base_png"])
places=4) size = first["size"]
self.assertEqual(size & (size - 1), 0, "atlas size is a power of two")
for u, v in first["role_uv"].values():
self.assertTrue(0.0 < u < 1.0 and 0.0 < v < 1.0,
"UVs stay on the texture")
self.assertEqual(len(first["roles"]), len(set(first["roles"])))
def test_indices_stay_inside_their_primitive(self): def test_indices_stay_inside_their_primitive(self):
payload = model_pipeline.build_glb(prop("planter_box")) payload = model_pipeline.build_glb(prop("planter_box"))