Compare commits

...
19 Commits
Author SHA1 Message Date
zwitschi a27e8cb74c Character and movement pass
Full build / Import + headless tests (push) Successful in 33s
Full build / Full desktop build + optional release (push) Successful in 3m37s
NPC characters
- Articulated rig (hips/knees, shoulders/elbows/hands) replaces rigid capsules;
  the walk cycle bends knees and elbows and flees with a longer stride.
- Layered outfits (jacket/vest/coat/dress), extended accessories and hand items
  (bag, umbrella, phone, camera, clipboard, cup), face details and five hair
  silhouettes, all deterministic per profile seed.
- Idle behaviour layers (phone, clipboard, crossed arms, look-around), breathing
  and weight shift; escalation raises hands or points, and noticed NPCs turn
  toward the guard.
- Reaction symbols above the head (!!, ?!, !, ?), door-queue offsets assigned by
  GameManager, three LOD tiers with hysteresis and a billboard impostor.
- smoke_npc_budget.gd: 16 avatars = 534 meshes, ~6.9 ms average (50 ms budget).

Player movement
- Separate accel/decel, step-up assist for kerbs and the entrance steps, and a
  forward-axis fix: W was inverted (get_axis(forward, back) is negative).
- Head bob, landing dip and precision-walk FOV, gated by persisted head-bob and
  invert-Y settings; precision chip and tick; surface-aware footsteps and a
  landing cue forwarded to AudioFeedback.
- Gamepad support (sticks, deadzone, face buttons, bumpers), a control legend
  that follows the active device, and named actions for the remaining keys.
- Key/button remapping in Settings, persisted as input_bindings and applied by
  InputBindings (mouse and gamepad axes are never removed).
- smoke_movement.gd (windowed): climbs the steps, checks precision speed, chip,
  footsteps and landing; new 11-precision-walk capture and catalogue entry.

Verification: headless 208/208, Python tooling 56/56, all 11 smokes pass,
compare 11 good / 0 bad, movement judge match. Version 0.5.0. Only optional
Future ideas remain in TODO.
2026-11-01 10:41:17 +01:00
zwitschi 2a4e0d1998 Audit prop budgets and add a prop-gallery art check
- audit_glb decodes geometry: 400 tris / 600 verts budget, degenerate
  triangles, UV range, ground contact, one material; newspaper box parts moved
  to sit on y=0
- capture_prop_gallery.gd renders the six props on a studio backdrop; new
  prop_gallery catalogue entry + AI mockup (compare 10 good, judge match 0.90)
- headless test that instances share the imported atlas material
- README documents the gallery capture and budget; TODO props pipeline section
  moved to DONE
2026-11-01 10:27:27 +01:00
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
zwitschi afa1d6beec v0.3.0: rendering pass, generated props, selection model and Web build
Full build / Import + headless tests (push) Successful in 36s
Full build / Full desktop build + optional release (push) Successful in 4m17s
Rendering and presentation
- Shared surface-material module (src/game/materials.gd) with per-surface
  roughness/metallic/uv profiles; ACES tonemapping, glow, fog and phase-driven sky
  energy. WorldBuilder rebuilt on those profiles with facade windows that light up,
  door trim/handles, curb, lane dashes, bollards, bench, bin and planter foliage.
- NPC silhouettes: per-role clothing palettes, caps/hoods/backpacks/sunglasses,
  hands and shoes, arm/leg walk swing.
- Six generated low-poly GLB props (bollards, bench, bin, planter, newspaper box,
  hydrant) built by tools/model_pipeline.py, loaded by WorldBuilder with a
  procedural fallback; ADR-0012.

Selection and movement
- Focus ranking by view angle then distance (NpcFocus.rank_indices); Tab and the
  mouse wheel cycle nearby people initially, right click clears focus with a
  re-acquire grace, HUD shows the candidate count, Shift walks at half speed.
  ADR-0011. Windowed smoke extended to cover wheel cycling and clearing.

QA tooling
- screen_designs.py catalogue grown to 19 screens (8 scene, 11 UI) plus a `list`
  subcommand; draft generation keeps existing AI references unless --force.

Web export
- New Web preset using the non-threaded Godot template (static hosting, no
  COOP/COEP). tools/build_releases.sh exports build/web and packages
  build/step-line-web-<ver>.zip; publish_release.sh uploads it with the desktop
  artifacts. test_web_export.gd covers the preset and release wiring.

Verification
- Headless suite 177 -> 181, Python tooling 53 tests, all eight smokes pass
  (including the windowed focus/wheel smoke). Web bundle serves index.html,
  index.wasm (35 MB) and index.pck (30 MB) with HTTP 200 from a static server.
- Version 0.3.0; README, PLAN, RELEASING, TODO/DONE and MEMORY updated.
2026-10-31 23:07:41 +01:00
zwitschi f763cff744 Export venues as editor-authorable .tres resources
Full build / Import + headless tests (push) Successful in 34s
Full build / Full desktop build + optional release (push) Successful in 3m51s
- tools/export_venues.gd (SceneTree script) saves the three factory scenarios to
  content/venues/<id>.tres via ResourceSaver. The factory stays the source of truth;
  the exported files are what an editor workflow would edit and ship.
- New tests load the files back and check that id, name, event pool, profiles,
  policy (ID/wand), equipment, door-staff lines, shift phases, and locations all
  survive. They also fail when the exported files are missing or stale, so the
  export is re-run after content changes.
- GUT is consciously not adopted: the custom headless runner already covers 157
  assertions without an editor plugin and keeps CI dependency-free. Recorded in
  DONE.md.
- TODO: the planned backlog is complete; only optional future ideas remain.
- README/MEMORY updated. Version 0.2.16.
2026-10-31 22:16:55 +01:00
zwitschi 72b9378e69 Add a vision-model screenshot judge to the QA tooling
Full build / Import + headless tests (push) Successful in 32s
Full build / Full desktop build + optional release (push) Successful in 3m30s
`tools/screen_designs.py judge` sends each capture together with its concept
mockup to a vision model and asks whether the capture realises the same screen and
layout intent. This covers what pixel/perceptual metrics cannot judge.

- Prompt explicitly ignores art style, fidelity, fonts, and placeholder text, since
  the references are stylised mockups.
- Verdicts are classed match / review (mismatch below --fail-confidence, default
  0.6) / diff (confident mismatch, exits non-zero) / unknown; --json, --only,
  --dry-run, --model supported.
- Reads the repo .env for OPENROUTER_API_KEY without overriding real env vars.
- Default judge model google/gemini-2.5-flash-lite; openai/gpt-4o-mini verified too.

Tests/docs
- 7 new offline tests in tools/test_screen_designs.py (request payload embeds both
  images, JSON/fenced/parts/non-JSON parsing, threshold classification, .env
  loading); 30 Python tests pass.
- Live run: 3 captures match, 3 review, 0 confident diffs.
- README, TODO section moved to DONE, MEMORY. Version 0.2.15.
2026-10-31 22:11:45 +01:00
zwitschi 74f14014b2 Enable anonymous downloads on the host and harden Strings.fmt
Full build / Import + headless tests (push) Successful in 33s
Full build / Full desktop build + optional release (push) Successful in 3m31s
Host (applied with root@192.168.88.30)
- [service] REQUIRE_SIGNIN_VIEW = false in /etc/gitea/app.ini (backup written
  alongside), Gitea restarted. Verified anonymously: repo page 200, API 200, and a
  release asset downloads at full size (50.8 MB AppImage).

Localization safety (Open Polish)
- Strings.placeholder_count() counts conversions and ignores %% escapes.
- Strings.fmt() validates the count and fails safe (warns, returns the template)
  instead of raising an engine error when a key's placeholders drift.
- Strings.validate() lints the table for a trailing %, unsupported conversions, and
  mixed positional/sequential placeholders.
- Tests cover the counter, the fail-safe path, a clean table, and a formatting pass
  over every placeholder key with type-correct arguments.

Docs
- TODO: distribution, release-pipeline, and open-polish sections are complete and
  moved to DONE; the v0.2.4-v0.2.8 backfill is closed as superseded.
- README/MEMORY updated. Version 0.2.14.
2026-10-31 22:02:24 +01:00
zwitschi a2a0ded527 Author the missing scenario content from the PLAN lists
Full build / Full desktop build + optional release (push) Successful in 3m26s
Full build / Import + headless tests (push) Successful in 35s
Joke Shop
- Reservation list without ID (guest list valid, ID missing) — denied.
- Borrowed/mismatched ID (ID valid but mismatched) — denied.
- Guest who refuses the wand screen: new `refuses_screening` event flag, copied
  onto NPCController, handled in _toggle_wand_screening (logs wand.refused, records
  a wand_refused event, small prop hit, panel stays closed).
- Late arrival during the performance (ID + ticket, "arrive" intent).

Studio
- Filming-boundary event and a later repeat, both using the new `film_boundary`
  intent which maps to `steps_end` so the influencer stands past the posted line.
- Celebrity arrival now pulls in a small crowd via a new `extra_spawns` event field
  (two tourists + an influencer), spawned through the normal path.

Building
- Filming-boundary event whose hint points at the body camera and request-reason
  verbs, plus an unclear-intent event that hints at asking for a reason.

Engine support
- Events may carry `hint` (string key logged on spawn), `extra_spawns`, and
  `refuses_screening`; 9 new localized hint strings.

Tests/docs
- Headless suite 139 -> 149 (test_scenario_content.gd) and a new
  smoke_scenario_content.gd that spawns the refusal and celebrity events through the
  real signal path.
- README, TODO section moved to DONE, MEMORY. Version 0.2.13.
2026-10-31 21:55:24 +01:00
zwitschi 922a990c7a Record verified CI publishing and host maintenance
Full build / Import + headless tests (push) Successful in 24s
Full build / Full desktop build + optional release (push) Skipped
- v0.2.12 was tagged and CI produced the release with all five artifacts by itself,
  confirming the checkout-credential fallback end to end.
- Host maintenance through root@192.168.88.30: apt cache cleaned and journald capped
  at 300M (journal ~133M now), ~7.6G free of 16G.
- TODO release-pipeline section is fully closed; MEMORY records the host state.
2026-10-31 21:51:41 +01:00
zwitschi f0cec192a7 Restore pruned releases and verify CI publishing with v0.2.12
Full build / Full desktop build + optional release (push) Successful in 3m29s
Full build / Import + headless tests (push) Successful in 32s
- Host root filesystem was expanded from 7.8G to 16G (~7.8G free), so the disk
  blocker is gone.
- Re-published v0.2.0 (4 artifacts) and v0.2.9 (5 artifacts) from the local build/
  outputs; both releases verify.
- Root SSH from the workstation is not available (publickey denied, password auth
  closes the banner); the host is administered via the zwitschi login.
- Version 0.2.12 exists to verify the tag pipeline end to end: the workflow should
  create the release via the checkout-credential fallback and upload all five
  artifacts.
2026-10-31 21:46:09 +01:00
zwitschi 2dba562c52 Publish v0.2.11 and document the host disk limit
Full build / Import + headless tests (push) Successful in 26s
Full build / Full desktop build + optional release (push) Skipped
- Pruned the superseded v0.2.0 and v0.2.9 releases through the API (~565 MB of
  assets) after the host root filesystem hit 100%, which blocked release uploads
  (HTTP 500 "no space left on device") and even git push ("unpacker error").
- Published v0.2.11 locally with the fixed script: all five artifacts verified on
  the release (Linux/Windows/macOS ports, NSIS installer, AppImage).
- TODO records the remaining ~150 MB headroom and the exact host cleanup commands;
  MEMORY and RELEASING describe the disk failure mode and the pruning.
2026-10-31 21:39:15 +01:00
zwitschi afd086ba11 Fail loudly when release uploads do not land
Full build / Import + headless tests (push) Successful in 35s
Full build / Full desktop build + optional release (push) Skipped
Follow-up to the publishing fix: CI created the v0.2.11 release and logged each
upload, but the assets never arrived, so the job looked successful while the
release was empty. The host root filesystem is full (7.8G, 100%), and Gitea
answered HTTP 500 "Attachments.Save: no space left on device".

- publish_release.sh now checks the HTTP status of every asset upload and prints the
  response body on failure, then verifies the release reports as many assets as were
  sent. Empty releases can no longer pass silently.
- Deleted the empty v0.2.11 release so the "latest" release (v0.2.10) still has all
  five artifacts; the tag itself remains.
- TODO/MEMORY record the host disk blocker with concrete cleanup commands and note
  that v0.2.11 artifacts are built locally in build/ for upload once space exists.
2026-10-31 21:38:12 +01:00
zwitschi b4d189c325 Publish releases with the runner's checkout credentials
Full build / Import + headless tests (push) Successful in 36s
Full build / Full desktop build + optional release (push) Successful in 3m37s
The v0.2.10 tag build still failed to publish: its env dump showed no token at all,
because this Gitea/act runner does not provide `github.token`, and the repo has no
GITEA_TOKEN secret.

publish_release.sh now resolves credentials in order: GITEA_TOKEN,
GITEA_AUTH_HEADER, ~/.git-credentials, then the local git extraheader that
actions/checkout writes for the job. That header carries the runner's basic-auth
credentials for the same server, so tag builds publish with no repo secret.

Verified by adding the extraheader locally and publishing v0.2.10 (all five
artifacts: Linux/Windows/macOS ports, NSIS installer, AppImage), then removing it.

Docs: RELEASING describes the four credential sources and warns that github.token is
not available here; MEMORY records both failed attempts and the final fix; TODO keeps
the "verify on the next tag" follow-up. Version 0.2.11.
2026-10-31 21:30:24 +01:00
zwitschi 6684d36739 Fix release publishing and add a CI token fallback
Full build / Import + headless tests (push) Successful in 26s
Full build / Full desktop build + optional release (push) Failing after 3m58s
Tag builds since v0.2.2 built all artifacts but never published a release:
- tools/publish_release.sh only assembled AUTH in the GITEA_TOKEN branch; the
  ~/.git-credentials branch set CRED and then used an unbound ${AUTH[@]} under
  `set -u`, so every API call failed and the job ended "could not create or find
  release". It now sets AUTH=(-u "$CRED").
- The repo has no GITEA_TOKEN secret and the runner has no git credentials, so the
  workflow now falls back to the built-in workflow token:
  GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}.

v0.2.9 was then built and published locally with all five artifacts (portable
Linux/Windows/macOS, NSIS installer, AppImage) and the release page verified via
the API. v0.2.4-v0.2.8 remain unpublished; they are superseded by the latest.

Docs
- docs/RELEASING.md records the incident and the local-publish fallback.
- TODO gains a release-pipeline follow-up (verify CI on the next tag).
- MEMORY notes the root cause, the missing secret, and which releases exist.
- Version 0.2.10.
2026-10-31 21:23:09 +01:00
104 changed files with 8681 additions and 331 deletions

No files matched your search

+3
View File
@@ -113,6 +113,9 @@ jobs:
- name: Publish Gitea release (tags only) - name: Publish Gitea release (tags only)
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
env: env:
# An explicit repo secret wins. Without one, publish_release.sh reuses
# the local git extraheader that actions/checkout configures for the job
# (the runner's own credentials), so tag builds still publish.
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
VERSION: ${{ github.ref_name }} VERSION: ${{ github.ref_name }}
run: | run: |
+306 -16
View File
@@ -319,7 +319,7 @@ Completed in the working tree (uncommitted with the control pass). Headless **74
to the entrance. to the entrance.
- [x] Wand shortcut is `C`, not `W`. Tests: `src/test/tests/test_access_loadout.gd`. - [x] Wand shortcut is `C`, not `W`. Tests: `src/test/tests/test_access_loadout.gd`.
## Player control, focus ring, and radial action menu ## Player control, focus circle, and radial action menu
Completed 2026-10-31 with ADR-0007–0010. Headless **77/77**, plus a windowed smoke Completed 2026-10-31 with ADR-0007–0010. Headless **77/77**, plus a windowed smoke
(`src/test/smoke_focus_radial.gd`) that opens the wheel and saves screenshots. (`src/test/smoke_focus_radial.gd`) that opens the wheel and saves screenshots.
@@ -334,13 +334,13 @@ Completed 2026-10-31 with ADR-0007–0010. Headless **77/77**, plus a windowed s
- [x] `cancel` (Esc / RMB) closes the top modal first; with nothing open, Esc toggles mouse - [x] `cancel` (Esc / RMB) closes the top modal first; with nothing open, Esc toggles mouse
capture and RMB does nothing. capture and RMB does nothing.
### Focus and head ring ### Focus and focus circle
- [x] Focus cone in `src/game/npc_focus.gd` (~10 m, ~10°). Closest-to-forward wins; occlusion - [x] Focus cone in `src/game/npc_focus.gd` (~10 m, ~10°). Closest-to-forward wins; occlusion
ray skips building hits. `Tab` still cycles 12 m. ray skips building hits. `Tab` still cycles 12 m.
- [x] 8-frame miss grace so walk-bob does not drop focus. - [x] 8-frame miss grace so walk-bob does not drop focus.
- [x] Billboarded torus on `Rig/Head`; gold idle, brighter while the radial is open; `Label3D` - [x] Billboarded blue circle in front of the face on `Rig/Head`; brighter while the radial is
`display_name`. HUD target line uses the display name, not the node name. open; `Label3D` `display_name`. HUD target line uses the display name, not the node name.
- [x] Screen-center crosshair. - [x] Screen-center crosshair.
### Radial HUD ### Radial HUD
@@ -364,7 +364,7 @@ Completed 2026-10-31 with ADR-0007–0010. Headless **77/77**, plus a windowed s
- [x] Named input map: `action_menu` (Space/LMB), `confirm` (Space/Enter), `cancel` - [x] Named input map: `action_menu` (Space/LMB), `confirm` (Space/Enter), `cancel`
(Esc/RMB), `inspect` (E), `wand` (C). `GameManager` uses `is_action_pressed`. (Esc/RMB), `inspect` (E), `wand` (C). `GameManager` uses `is_action_pressed`.
- [x] Windowed smoke: focus ring + name, wheel open/step/confirm/cancel - [x] Windowed smoke: focus circle + name, wheel open/step/confirm/cancel
(`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.
@@ -405,7 +405,6 @@ screenshots.
Completed 2026-10-31 (working tree). Headless **88/88**; `src/test/smoke_session_shell.gd` and the windowed focus/radial smoke both pass. 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 - [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 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. the backdrop only with a transparent asset or a keyed shader.
@@ -414,7 +413,7 @@ Completed 2026-10-31 (working tree). Headless **88/88**; `src/test/smoke_session
- [x] Steps and the landing are visual-only (no collider). The guard walks through them; add - [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. 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] 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 - [x] Focus circle and name tag are small at 2–3 m. Consider a larger circle / font after a
real playtest. real playtest.
- [x] NPC crowd cap (`MAX_LIVE_NPCS`) silently drops routine traffic at 16. Add a short - [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. despawn/lifetime for ordinary guests so the entrance does not fill up with standers.
@@ -423,7 +422,6 @@ Completed 2026-10-31 (working tree). Headless **88/88**; `src/test/smoke_session
Completed 2026-10-31 (working tree). Headless **88/88**; `src/test/smoke_session_shell.gd` and the windowed focus/radial smoke both pass. 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. The game boots into a live 900 s shift. `Save` declares `settings.json` and never uses it.
`V` cycles venues by restarting the shift. `V` cycles venues by restarting the shift.
@@ -439,7 +437,6 @@ The game boots into a live 900 s shift. `Save` declares `settings.json` and neve
Completed 2026-10-31 (working tree). Completed 2026-10-31 (working tree).
- [x] README controls table, smoke checklist, test count, and implemented/not-yet lists - [x] README controls table, smoke checklist, test count, and implemented/not-yet lists
refreshed for the control pass (77 tests). refreshed for the control pass (77 tests).
- [x] `PLAN.md` Tech Stack / Planned Commands still say GUT and GitHub Actions; the project - [x] `PLAN.md` Tech Stack / Planned Commands still say GUT and GitHub Actions; the project
@@ -450,7 +447,6 @@ Completed 2026-10-31 (working tree).
Completed 2026-10-31 (working tree). Headless **98/98**; `src/test/smoke_plan_verbs.gd` exercises all five verbs. Completed 2026-10-31 (working tree). Headless **98/98**; `src/test/smoke_plan_verbs.gd` exercises all five verbs.
- [x] Eye contact as a Level 1 action (PLAN / glossary). Distinct from raise-hand. Add a - [x] Eye contact as a Level 1 action (PLAN / glossary). Distinct from raise-hand. Add a
radial slice if it ships. radial slice if it ships.
- [x] "I recognize this person" is a player decision recorded as evidence, not a pre-attached - [x] "I recognize this person" is a player decision recorded as evidence, not a pre-attached
@@ -466,7 +462,6 @@ Completed 2026-10-31 (working tree). Headless **98/98**; `src/test/smoke_plan_ve
Completed 2026-10-31 (working tree). Headless **105/105**; `src/test/smoke_crowd.gd` verifies rubbernecking and panic clearing. A 16-NPC entrance measured ~1.2 ms/frame (824 fps, vsync off), so no LOD/culling was added at the current cap. Completed 2026-10-31 (working tree). Headless **105/105**; `src/test/smoke_crowd.gd` verifies rubbernecking and panic clearing. A 16-NPC entrance measured ~1.2 ms/frame (824 fps, vsync off), so no LOD/culling was added at the current cap.
`CrowdSystem` is a mood/attention float. PLAN wants people watching, gathering, and panicking. `CrowdSystem` is a mood/attention float. PLAN wants people watching, gathering, and panicking.
- [x] On `aggressive_behavior` / `emergency_responder`, nearby idle NPCs stop and face the - [x] On `aggressive_behavior` / `emergency_responder`, nearby idle NPCs stop and face the
@@ -478,7 +473,6 @@ Completed 2026-10-31 (working tree). Headless **105/105**; `src/test/smoke_crowd
Completed 2026-10-31 (working tree). Headless **113/113**; `src/test/smoke_emergency.gd` covers the unjustified-call investigation, the evacuation aftermath, and the review copy. Completed 2026-10-31 (working tree). Headless **113/113**; `src/test/smoke_emergency.gd` covers the unjustified-call investigation, the evacuation aftermath, and the review copy.
Responder outcomes (`command` / `restraint` / `evacuation` / `arrest`) are log lines. PLAN also Responder outcomes (`command` / `restraint` / `evacuation` / `arrest`) are log lines. PLAN also
wants evacuation, a closed street, and an investigation when the call was unjustified. wants evacuation, a closed street, and an investigation when the call was unjustified.
@@ -491,7 +485,6 @@ wants evacuation, a closed street, and an investigation when the call was unjust
Completed 2026-10-31 (working tree). Headless **120/120**; `src/test/smoke_timeline.gd` opens the timeline from the review, steps through every event, and returns to the review. Completed 2026-10-31 (working tree). Headless **120/120**; `src/test/smoke_timeline.gd` opens the timeline from the review, steps through every event, and returns to the review.
- [x] Shift review: a timeline of recorded incident events (ADR-0005 already stores them). - [x] Shift review: a timeline of recorded incident events (ADR-0005 already stores them).
Scrub or step through; no live 3D replay required in the first version. Scrub or step through; no live 3D replay required in the first version.
@@ -499,7 +492,6 @@ Completed 2026-10-31 (working tree). Headless **120/120**; `src/test/smoke_timel
Completed 2026-10-31 (working tree). Headless **127/127**; verified visually with a staged sit/photo/perform lineup. Completed 2026-10-31 (working tree). Headless **127/127**; verified visually with a staged sit/photo/perform lineup.
Intents `sit` / `photo` / `film` / `perform` already pick waypoints. ADR-0010 rig (face/hair) Intents `sit` / `photo` / `film` / `perform` already pick waypoints. ADR-0010 rig (face/hair)
is in the working tree. Bodies still idle-stand at those spots. is in the working tree. Bodies still idle-stand at those spots.
@@ -510,7 +502,6 @@ is in the working tree. Bodies still idle-stand at those spots.
Completed 2026-10-31 (working tree). Headless **134/134**; `src/test/smoke_audio.gd` checks the bed follows mood and the radio crackles, headless and windowed (real audio server). Completed 2026-10-31 (working tree). Headless **134/134**; `src/test/smoke_audio.gd` checks the bed follows mood and the radio crackles, headless and windowed (real audio server).
`AudioFeedback` is sine beeps. DONE called that "crowd ambience"; it is not. `AudioFeedback` is sine beeps. DONE called that "crowd ambience"; it is not.
- [x] Loop a low crowd bed that rises with `CrowdSystem.mood`. - [x] Loop a low crowd bed that rises with `CrowdSystem.mood`.
@@ -521,6 +512,305 @@ Completed 2026-10-31 (working tree). Headless **134/134**; `src/test/smoke_audio
Completed 2026-10-31 (working tree). Headless **139/139**; verified visually at 1.4x font scale with the shape meter showing. Completed 2026-10-31 (working tree). Headless **139/139**; verified visually at 1.4x font scale with the shape meter showing.
- [x] Escalation colors that still read in grayscale (shape or label, not hue alone). - [x] Escalation colors that still read in grayscale (shape or label, not hue alone).
- [x] HUD font scale in settings. - [x] HUD font scale in settings.
## Scenario content still missing vs PLAN lists
Completed 2026-10-31 (working tree). Headless **149/149**; `src/test/smoke_scenario_content.gd` spawns the refusal event and the celebrity arrival through the real signal path.
- [x] Joke Shop: guest on the reservation list without ID; borrowed/mismatched ID; guest
refuses wand screening; late arrival during a performance.
- [x] Studio: filming-boundary repeat (influencer ignores a posted line); celebrity-arrival
crowd spike (more rubberneckers, not only one `arrive` spawn).
- [x] Building: "activate body camera" and "request reason" once those verbs exist, used in
at least one authored event.
## Distribution follow-up (host config)
Completed 2026-10-31 (working tree). Headless **153/153**.
Verified via API+host credentials (2026-10-31):
- [x] Repo is **public** (`private:false`) and account has admin rights.
- [x] Release `v0.2.1` now carries all five artifacts (portable binaries + NSIS installer +
AppImage).
- [x] **Host setting applied**: `[service] REQUIRE_SIGNIN_VIEW = false` in `/etc/gitea/app.ini`
(backup written alongside), Gitea restarted. Verified anonymously: repo page 200, API 200,
and a release asset downloads with the full 50.8 MB AppImage.
## Release pipeline follow-up
Completed 2026-10-31 (working tree). Headless **153/153**.
- [x] Fix `tools/publish_release.sh`: the `~/.git-credentials` branch never set `AUTH`,
so every tag build since v0.2.2 failed to publish.
- [x] Add a `github.token` fallback in `.gitea/workflows/build.yml` (the repo has no
`GITEA_TOKEN` secret).
- [x] Fall back to the checkout's git extraheader when no token/credentials exist (the
Gitea runner does not expose `github.token`).
- [x] Uploads now fail loudly and the release is verified to carry every artifact
(a silent HTTP 4xx used to leave an empty release with a green job).
- [x] Host disk expanded to 16G; v0.2.0 and v0.2.9 releases restored from the local artifacts.
- [x] Host maintenance via `root@192.168.88.30`: apt cache cleaned and journald capped
(`SystemMaxUse=300M` in `/etc/systemd/journald.conf.d/99-size.conf`); ~7.6G free.
- [x] Verified end to end: tagging v0.2.12 produced a release with all five artifacts
(AppImage, Linux, macOS, NSIS installer, Windows) from CI, with no repo secret.
- [x] Backfill decision: v0.2.4–v0.2.8 stay superseded; their tags and source remain, and the
release page points at the current build. No rebuilds planned.
## Open Polish
Completed 2026-10-31 (working tree). Headless **153/153**.
- [x] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
## Screen design references (QA tooling)
Completed 2026-10-31 (working tree). 30 Python tooling tests pass; the live judge run classed 3 captures as match and 3 as review (concept mockups are more stylised), with no confident mismatch.
- [x] `tools/screen_designs.py generate` writes one reference design per screen
(from the `docs/` descriptions) to `docs/screens/designs/`, with an offline
`--draft` wireframe mode and an OpenRouter `--ai` mockup mode.
- [x] `tools/screen_designs.py compare` checks `docs/screenshots/*.png` against the
designs (aspect ratio, size, blank-frame, and layout/palette/detail drift).
- [x] Vision-model judge for semantic screenshot comparison: `judge` sends the
capture + reference design to a vision model, classes verdicts as
match/review/diff with a confidence threshold, supports `--dry-run`, `--json`,
`--only`, and `--model`, and reads the repo `.env` for the API key.
## Tooling (when an editor workflow exists)
Completed 2026-10-31 (working tree). Headless **157/157**; the exported `.tres` venues round-trip through the resource loader.
- [x] Author venues as `.tres` resources: `tools/export_venues.gd` writes
`content/venues/<id>.tres` from the factory, and a test loads them back to check
nothing is lost (events, profiles, policy, phases, door lines, locations).
- [x] GUT decision: **not adopted.** The custom headless runner (`src/test/test_runner.gd`)
already covers 157 assertions without an editor plugin, runs in CI, and keeps the
project dependency-free; adding GUT would duplicate coverage and require the editor.
## In-Game Scene Examples
Completed 2026-10-31 (v0.3.0 working tree). `python3 tools/screen_designs.py list` reports
19 screens (8 scene, 11 UI); the Python tooling tests pass.
- [x] Extend the `tools/screen_designs.py` catalogue with the screens that exist in code but
had no reference: wand screening, Joke Shop door queue, TV Studio celebrity arrival,
crowd panic, responder arrival, body-camera timeline, escalation HUD.
- [x] Add a `list` subcommand so the catalogue is inspectable without generating images.
- [x] Cover the new entries with `tools/test_screen_designs.py` assertions.
- [x] Regenerate the deterministic draft references under `docs/screens/designs/`.
(`generate` now keeps existing references unless `--force` is passed, so a draft run
cannot replace an AI mockup; `--only` selects a subset.)
## Rendering Quality
Completed 2026-10-31 (v0.3.0 working tree). Screenshots regenerated; `screen_designs.py
compare` reports 6 good / 0 bad.
- [x] Add a shared surface-material module (`src/game/materials.gd`) with
roughness/metallic/uv-scale profiles per surface and a single environment tune-up
(tonemap, glow, fog, shadows).
- [x] Rebuild `WorldBuilder` surfaces with the material profiles and add street detail:
window bands, door trim, bollards, bench, bin, curb, planter foliage.
- [x] Per-phase environment values (fog, sky energy, glow) so night reads darker and the
marquee glows.
- [x] Improve NPC silhouette and materials: per-role clothing palette, caps/hoods/backpacks,
sleeves and hands, walking leg swing.
- [x] Cover materials/environment/avatar helpers with headless tests
(`test_materials.gd`, `test_target_cycle.gd`).
## AI 3D Model Tooling
Completed 2026-10-31 (v0.3.0 working tree). `model_pipeline.py build` + `audit` pass for all
six props; `test_generated_props.gd` proves they import as scenes and that the world places
them.
- [x] Add `tools/model_pipeline.py`: prop catalogue, deterministic low-poly GLB builder,
OpenRouter concept-image generation, palette sampling and GLB audit.
- [x] Add `tools/test_model_pipeline.py` (valid GLB structure, deterministic output, audit
failures, sRGB→linear conversion, role assignment).
- [x] Generate the prop models into `assets/models/generated/` and import them.
- [x] Load the generated props in `WorldBuilder` with a procedural fallback when the GLB is
missing.
## Controls, Movement and NPC Selection
Completed 2026-10-31 (v0.3.0 working tree). The windowed smoke now covers wheel cycling and
right-click clearing; the headless suite passes 177/177.
- [x] Record the re-evaluated control model as ADR-0011.
- [x] Rank focus candidates by view angle then distance (`NpcFocus.rank_indices`) instead of
insertion order.
- [x] Add mouse-wheel target cycling next to `Tab`, right-click clearing focus, and a `Shift`
precision walk speed.
- [x] Show the nearby-candidate count and the cycle hint in the HUD.
- [x] Cover ranking, cycling and walk speed with tests, then re-run the windowed smoke.
## Web Export Build Target
Completed 2026-10-31 (v0.3.0). Headless **181/181**; `godot --headless --export-release "Web"`
produces a 63 MB static bundle and it serves locally with HTTP 200s for html/wasm/pck.
- [x] Add a `Web` preset to `export_presets.cfg` using `variant/thread_support=false` so the
build runs on plain static hosting without COOP/COEP headers.
- [x] Export to `build/web/` and package `build/step-line-web-<version>.zip` from
`tools/build_releases.sh` (zip or Python fallback).
- [x] Publish the web zip with the desktop artifacts (`tools/publish_release.sh`).
- [x] Cover the preset, the non-threaded choice, and the release wiring in
`src/test/tests/test_web_export.gd`.
- [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.
## Prop Pipeline Checks
Completed 2026-11-01. Python tooling **56/56**, headless **188/188**, prop gallery **match**
(0.90) in the vision judge, `compare` **10 good / 0 bad**.
- [x] `audit_glb()` now decodes positions/UVs/indices and enforces the art budget
(`MAX_TRIANGLES = 400`, `MAX_VERTICES = 600`), rejects degenerate triangles, UVs outside
`[0, 1]`, a prop that floats above or sinks below the ground (`min y ~= 0`), and the
single-material/atlas contract. The newspaper box sat 3 cm high; its catalogue parts moved
down so it rests on y = 0.
- [x] Added `capture_prop_gallery.gd`: renders all six props on a studio backdrop (two rows on a
display platform) to `docs/screenshots/10-prop-gallery.png`, with a `prop_gallery`
catalogue entry and AI mockup, so `compare`/`judge` cover close-range prop art.
- [x] Confirmed instances share the imported atlas material (headless test) and documented the
triangle/vertex budget in the pipeline docstring.
- [x] README lists the gallery capture command and image; the pipeline docstring and audit docs
describe the budget checks.
## Better NPC Characters
Completed 2026-11-01 (v0.5.0). Headless suite **208/208**; NPC budget smoke reports 16 avatars,
534 meshes, ~6.9 ms average frame time; PLAN-verb, crowd, emergency, and timeline smokes pass.
- [x] Replaced the rigid capsules with an articulated rig: hip -> thigh -> knee -> shin -> shoe and
shoulder -> upper arm -> elbow -> forearm -> hand, with knee/elbow pivots driven by the walk
cycle and the seated pose.
- [x] Layered clothing (`layer_for`): jacket, hi-vis vest, coat, and dress over the base outfit,
with role-appropriate defaults and deterministic colourways.
- [x] Extended accessories and hand items: bag, umbrella, phone, camera, clipboard, cup, plus the
existing cap/hood/backpack/sunglasses/lanyard. Hand items attach to the right hand socket.
- [x] Face and hair variety: ears, brows, mouth, five hair silhouettes (long, bun, beard, bald,
short), height range, and deterministic per-seed appearance.
- [x] Added idle behaviour layers (`idle_style_for`): breathing, weight shift, look-around, phone,
clipboard, and crossed arms; escalation gestures raise both hands (L3+) or point (L2), and
noticed NPCs turn their head toward the guard.
- [x] Walk cycle now swings hips/shoulders, bends knees and elbows, and uses a faster, longer
stride when fleeing.
- [x] Door queue staging: GameManager sorts waiting guests and assigns spaced `queue_slot_for`
offsets, so one person is at the door and the rest wait behind without overlapping.
- [x] Reaction readability: a billboarded symbol above the head (`!!` panic, `?!` watching, `!`
blocked, `?` warned) updates from the reaction/escalation state.
- [x] Three LOD tiers with hysteresis: full detail, simplified (props/shadows off), and a
billboard impostor; `apply_lod` is tested for the swap.
- [x] Re-measured the live-NPC budget with the full rig (16 avatars, 534 meshes) and added
`smoke_npc_budget.gd`, which fails above the documented 50 ms frame budget.
- [x] Tests: deterministic layers/hand items/hair, role layers, queue slot spacing, LOD
thresholds/hysteresis, reaction labels, articulated pivots, mesh budget, and the
walk-to-idle animation transition.
## Better Player Movement
Completed 2026-11-01 (v0.5.0). Windowed `smoke_movement.gd` passes; headless tests cover surface
lookup, constants, gamepad bindings, and settings persistence.
- [x] Split acceleration and braking (`accel` 22, `decel` 30 m/s²) so stopping is snappier than
starting, and fixed the inverted forward axis found while staging the movement smoke: W now
walks toward the camera's forward vector instead of moonwalking.
- [x] Step-up assist (0.32 m) climbs kerbs and the entrance steps, with wall sliding left to
`move_and_slide`; the smoke walks the guard from the sidewalk onto the landing.
- [x] Camera feel: speed-scaled head bob, a landing dip, and a precision-walk FOV change, all
gated by the new "Head bob and camera motion" setting; a motion-sickness toggle persists.
- [x] Precision walk shows a HUD chip and plays a short tick when it engages, in addition to the
slower speed.
- [x] Surface-aware footsteps (concrete/asphalt/stone) synced to stride distance, plus a landing
cue; both have observable counters for tests.
- [x] Gamepad support: left-stick movement, right-stick look with deadzone, A/B/X/Y face buttons,
LB slow, RB wand; bindings are added at runtime and the control legend swaps to gamepad
hints when a pad event is seen.
- [x] Moved the remaining hard-coded gameplay keys (Q/F/G/L/B/V, Tab cycle) onto named InputMap
actions, and the panel allow/deny keys onto movement actions.
- [x] Key/button remapping in the settings panel: click a binding, press a key or pad button,
persisted as `input_bindings` through `Save`; `InputBindings.apply` rewrites only key/button
events and leaves mouse and gamepad axes alone.
- [x] Added invert-Y and head-bob settings (with save/load round-trip tests) alongside the
existing look sensitivity.
- [x] Tests: surface lookup, step/stride/precision constants, gamepad binding presence and
deadzone, remap descriptor/apply/labels, and settings persistence for camera and bindings.
- [x] Added `smoke_movement.gd` (windowed) and `11-precision-walk.png` with a movement catalogue
entry, AI mockup, and compare coverage.
+163 -8
View File
@@ -15,18 +15,112 @@ 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` (also reachable at
Tests: `godot --headless --path . --script res://src/test/test_runner.gd` → **88/88**. `/var/home/zwitschi/Documents/06-games/security-game`). Godot: `~/.local/bin/godot` (4.3.stable).
Tests: `godot --headless --path . --script res://src/test/test_runner.gd` → **177/177**.
Python tooling tests: `cd tools && python3 -m unittest test_asset_pipeline test_screen_designs test_model_pipeline` → **53**.
Session shell smoke: `godot --headless --path . --script res://src/test/smoke_session_shell.gd` → exit 0. 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
(now also covers wheel cycling and right-click clearing).
- `main` is in sync with origin. **v0.2.2** tags the session-shell + world-follow-up commit; - `main` is in sync with origin. **v0.2.2** tags the session-shell + world-follow-up commit;
CI builds the portable binaries, NSIS installer, and AppImage on the tag push. CI builds the portable binaries, NSIS installer, and AppImage on the tag push.
- Next work: `TODO.md` scenario content gaps (Joke Shop / Studio / Building events), then the - **v0.3.0 (2026-10-31, working tree):** rendering pass (`src/game/materials.gd`, phase-driven
remaining polish and tooling items. environment), generated `.glb` props (`tools/model_pipeline.py`, `assets/models/generated/`),
aim-free selection with wheel cycling and Shift precision walk (ADR-0011/0012), and a **Web**
export target. `export_presets.cfg` gains a `Web` preset with `variant/thread_support=false`
(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
`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.
- **Prop pipeline (done):** audit enforces 400 tris / 600 verts, ground contact, UV range, no
degenerate tris, one material. `capture_prop_gallery.gd` writes `10-prop-gallery.png` (3x2 studio
line-up, back row on a platform) and the `prop_gallery` catalogue entry compares it.
- **NPC character pass (v0.5.0):** Rig is articulated (`Rig/Hip* -> Knee`, `Shoulder -> Elbow ->
Hand`); `outfit_for` adds layer/hair_style/hand_item; `_idle_layer` drives phone/clipboard/crossed
and escalation gestures; `_reaction_tag` shows `!!`, `?!`, `!`, `?`; `apply_lod` swaps to a
billboard impostor at 26 m (hysteresis 1.5 m); GameManager `_refresh_queue` assigns queue offsets.
Perf: 16 avatars = 534 meshes, ~6.9 ms headless.
- **Player movement (v0.5.0):** **W was inverted** (`get_axis(forward, back)` is negative for W and
the code multiplied it directly); now `forward * -input_dir.y`. Step-up uses test_move + a raised
copy (0.32 m). Guard emits footstep/landed/precision_changed; GameManager forwards them to
AudioFeedback and the HUD. Gamepad actions are added at runtime by
`GuardController._ensure_gamepad_bindings`; remaps persist via `Save` `input_bindings` and are
applied by `InputBindings.apply` (key/button events only). Windowed `smoke_movement.gd` guards it.
- **Scheduled TODO pass complete:** only the optional Future ideas remain (live replay, extra
venues, voice audio, image-to-mesh). 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`
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
adopted (custom runner covers it without an editor plugin).
- 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
- **v0.3.0 pass (2026-10-31, uncommitted):** rendering, generated prop models, screen
catalogue, aim-free selection. See ADR-0011 (selection) and ADR-0012 (materials/props).
- `src/game/materials.gd` (`SurfaceMaterials`) is now the single source of surface
recipes + `configure_environment()`. `GameManager.PHASE_LIGHTING` gained
`exposure/glow/fog_color/fog_density/fog_energy/contrast/saturation/ambient_energy`.
Renderer is **GL Compatibility**, so SSAO/SSIL are unavailable; glow+fog+ACES are used
instead and unsupported settings are ignored rather than gated in code.
- World: scaled brick (uv 22,10), framed windows with per-window night emission
(`i % 3 != 2` lit), stone base course, door trim/handles, curb, lane dashes (the old
loop had `Vector3(i * 0.0, …)` and stacked every dash at x=0), street lamps use the
metal profile. The two flat planters that clipped the steps were removed.
- `tools/model_pipeline.py` builds `assets/models/generated/*.glb` (bollard, bench,
trash_bin, planter_box, newspaper_box, fire_hydrant) from box/cylinder specs;
`WorldBuilder._add_prop_model()` loads them with a procedural fallback and records
`loaded_models` / `fallback_props`. **Gotcha:** glTF `baseColorFactor` is LINEAR —
writing sRGB made every prop pastel; `srgb_to_linear()` is applied in `build_glb`.
**Gotcha:** a new/changed `.glb` needs `godot --headless --path . --import` before
`ResourceLoader.exists()` / `load()` sees it (test_generated_props fails otherwise).
- `model_pipeline.py concept` (OpenRouter, reads repo `.env`) writes per-prop concept
sheets to `assets/models/concepts/`; `dominant_colors()` centre-crops and weights by
saturation (a grey studio backdrop otherwise wins), `assign_roles()` maps colours by
hue/value. Delete a concept file to fall back to the catalogue palette.
- NPC avatars: `outfit_for(type, seed)` (pure) gives shirt/trousers/hair/skin +
accessory (cap, hood, backpack, sunglasses, lanyard) and height scale; `_arms` and
`_legs` now swing (`limb_swing`) while walking. Keep `Rig/Head` named for focus.
- Selection: `NpcFocus.rank_indices` (angle then distance) — `pick_index` = first.
`GuardController.cycle_target(direction)` walks nearest-first `nearby_candidates()` and
sets `target_pinned`, which makes `_update_target()` skip the cone. `clear_target()`
sets `focus_suppress_s = 0.75` so the cone does not instantly re-acquire the person.
New input actions `walk_slow` (Shift), `cycle_target_next` / `cycle_target_prev`
(wheel down / up). Right click with nothing open clears focus (Esc still pauses).
- HUD: `nearby_label` + `set_nearby_hint(count)` show `hud.nearby`; `hud.controls` and
`hud.targeting` mention the wheel and Shift.
- `tools/screen_designs.py`: catalogue now 19 screens (added wand_screening,
joke_shop_queue, studio_arrival, crowd_panic, responder_arrival,
body_camera_timeline, escalation_hud), new `list` command, `generate --only/--force`
(existing designs are kept by default so a draft run cannot clobber AI mockups),
and `judge --dry-run` no longer raises `KeyError: 'class'`.
- Screenshot tool leak: `capture_screenshots.gd` intermittently prints
`ObjectDB instances leaked at exit`. Verified against a `git worktree` of HEAD~ — the
baseline leaks more often (3/3 vs 1/3), so it is a pre-existing quit-timing artifact,
not a regression. Normal boots are clean.
- Access artifacts: `src/core/access_loadout.gd` builds from event `override_access` + venue policy. - Access artifacts: `src/core/access_loadout.gd` builds from event `override_access` + venue policy.
Spawn signal is now `(profile, intent, npc_id, event)`. Recognition is not auto-attached to every Spawn signal is now `(profile, intent, npc_id, event)`. Recognition is not auto-attached to every
guest. `AccessVerifier.verify(..., already_screened=false)` so Joke Shop scoring uses guest. `AccessVerifier.verify(..., already_screened=false)` so Joke Shop scoring uses
@@ -36,7 +130,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
`hud.controls` / `hud.screen_first` updated. `hud.controls` / `hud.screen_first` updated.
- Control pass (in tree, uncommitted, smoke-tested): cone focus via `src/game/npc_focus.gd`, - Control pass (in tree, uncommitted, smoke-tested): cone focus via `src/game/npc_focus.gd`,
accel/decel, gravity only airborne, `input_locked` freeze, Esc no longer on the guard. accel/decel, gravity only airborne, `input_locked` freeze, Esc no longer on the guard.
`NpcAvatar` in group `npc`; neck/hair/eyes/shoes; torus head ring + `Label3D` name; `set_focus_visual`. `NpcAvatar` in group `npc`; neck/hair/eyes/shoes; blue focus circle + `Label3D` name; `set_focus_visual`.
`src/game/ui/action_radial.gd` doughnut (LMB/Space open, A/D/arrows, Space/click confirm, Esc/RMB `src/game/ui/action_radial.gd` doughnut (LMB/Space open, A/D/arrows, Space/click confirm, Esc/RMB
cancel). Crosshair CanvasLayer. Emergency still `R` only. Number keys 1–6 when the wheel is closed. cancel). Crosshair CanvasLayer. Emergency still `R` only. Number keys 1–6 when the wheel is closed.
- Named input actions now exist and are used: `action_menu`, `confirm`, `cancel` (Esc + RMB), - Named input actions now exist and are used: `action_menu`, `confirm`, `cancel` (Esc + RMB),
@@ -59,7 +153,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
(`assets/textures/skyline.png`, regenerated RGBA with a transparent sky) and an (`assets/textures/skyline.png`, regenerated RGBA with a transparent sky) and an
`TRANSPARENCY_ALPHA_SCISSOR` unshaded material (`_skyline_mat`); steps + landing are a `TRANSPARENCY_ALPHA_SCISSOR` unshaded material (`_skyline_mat`); steps + landing are a
`StaticBody3D` with per-tread `BoxShape3D` colliders; marquee `font_size` 160 / outline 24; focus `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 circle (blue disc in front of the face) + name tag 48px; `NpcAvatar.ephemeral`/`lifetime_s` despawns routine traffic
(authored events stay). Guard spawn height verified on the landing (guard_y=0.84). (authored events stay). Guard spawn height verified on the landing (guard_y=0.84).
- **PLAN verbs (2026-10-31, uncommitted):** the radial now has 12 slices - **PLAN verbs (2026-10-31, uncommitted):** the radial now has 12 slices
(`Stop, Look, Point, No, Ask, Inspect, Wand, Warn, Block, Reason, Recognize, Radio`). (`Stop, Look, Point, No, Ask, Inspect, Wand, Warn, Block, Reason, Recognize, Radio`).
@@ -95,6 +189,30 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
`_begin_incident()` now starts one per focused NPC (`has_open_incident()` guard), verbs call `_begin_incident()` now starts one per focused NPC (`has_open_incident()` guard), verbs call
`_ensure_incident()`, and `ShiftCoordinator._finish_shift()` closes leftovers as `unresolved` `_ensure_incident()`, and `ShiftCoordinator._finish_shift()` closes leftovers as `unresolved`
before building the review. before building the review.
- **Editor-ready venues (2026-10-31):** `tools/export_venues.gd` (a SceneTree script) saves the
three factory scenarios to `content/venues/<id>.tres` with `ResourceSaver`; the `.tres` files
round-trip through `ResourceLoader` (events, profiles, policy, shift phases, door lines,
locations). `test_venue_resources.gd` also fails if the exported files are missing or stale
relative to the factory, so re-run the export after changing `ScenarioFactory`.
- **Vision judge (2026-10-31):** `python3 tools/screen_designs.py judge` sends the capture plus
its concept mockup to a vision model (`DEFAULT_JUDGE_MODEL = google/gemini-2.5-flash-lite`,
override with `--model`; `openai/gpt-4o-mini` also works). Reads the repo `.env` via
`_load_env_file()` (never overrides real env). Verdicts are classed `match` / `review`
(mismatch below `--fail-confidence`, default 0.6) / `diff` (confident mismatch → exit 1) /
`unknown`. `--dry-run`, `--json`, `--only` supported. The prompt explicitly ignores art style,
because the references are stylised mockups; the live run gave 3 match, 3 review, 0 diff.
Python tests: `cd tools && python3 -m unittest test_asset_pipeline test_screen_designs`.
- **String formatting safety (2026-10-31):** `Strings.placeholder_count()` counts `%` specs
(ignoring `%%`), `fmt()` warns and returns the template when the arg count does not match
(instead of raising an engine error), and `validate()` lints the table for trailing `%`,
unsupported conversions, and mixed positional/sequential placeholders. Tests cover all three
plus a formatting pass over every placeholder key with type-correct dummy args.
- **Authored scenario content (2026-10-31):** events can now carry `hint` (a string key logged
when the avatar spawns), `extra_spawns` (celebrity arrivals pull in tourists/influencers via
`_spawn_npc` with `kind: routine`), and `refuses_screening` (copied to
`NPCController.refuses_screening`; `_toggle_wand_screening` logs `wand.refused`, records a
`wand_refused` event, applies a small prop hit, and does not open the panel). `film_boundary`
is a new intent that maps to `steps_end` so the influencer stands past the posted line.
- **Accessibility (2026-10-31):** escalation text is `"<name> [###--] L3/5"` via - **Accessibility (2026-10-31):** escalation text is `"<name> [###--] L3/5"` via
`Hud.escalation_meter(level)` (pure, tested) so the cue survives grayscale. HUD font scale uses `Hud.escalation_meter(level)` (pure, tested) so the cue survives grayscale. HUD font scale uses
`add_theme_font_size_override` on the core labels (`set_font_scale`), **not** `Control.scale`, `add_theme_font_size_override` on the core labels (`set_font_scale`), **not** `Control.scale`,
@@ -135,7 +253,44 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
needs `mipmaps/generate=true`. needs `mipmaps/generate=true`.
- **Asset gotcha:** `assets/textures/game_ready/` is *not* imported (no `.import`). Use the - **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. 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 is **done**: `REQUIRE_SIGNIN_VIEW = false` in `/etc/gitea/app.ini`
(backup `app.ini.bak-*` alongside), Gitea restarted; anonymous repo page/API return 200 and
release assets download in full. Reverse by setting it back to `true` and restarting.
- **Release publishing was broken since v0.2.2 (fixed 2026-10-31):** `publish_release.sh`
only built `AUTH` in the `GITEA_TOKEN` branch; the `~/.git-credentials` branch set `CRED`
and then used unbound `${AUTH[@]}` under `set -u`, so every API call failed and the tag
job ended "could not create or find release". The repo also has **no** `GITEA_TOKEN`
secret (`/actions/secrets` is empty) and the runner has no git credentials. Fixes: the
script sets `AUTH=(-u "$CRED")`; the workflow falls back to `github.token`
(`secrets.GITEA_TOKEN || github.token`) — but `github.token` is **not** provided by this
Gitea/act runner, so the env dump showed no token and the v0.2.10 tag build failed again.
Final fix: `publish_release.sh` falls back to the **local git extraheader** that
`actions/checkout` writes (`git config --local --get-regexp '^http\..*\.extraheader$'`),
which carries the runner's basic-auth credentials for the same server. Verified locally by
adding that extraheader and publishing v0.2.10 (all five artifacts). **v0.2.9 and v0.2.10
are published**; v0.2.4–v0.2.8 tags have no artifacts. Verify the CI path on the next tag.
- **CI publishing verified (2026-10-31):** tagging v0.2.12 produced a release with all five
artifacts automatically (no repo secret), confirming the checkout-extraheader fallback.
- **Host access (2026-10-31):** `root@192.168.88.30` now works from this workstation (key auth),
host name `gitea`, Gitea 28.2.0. Maintenance applied: `apt-get clean`, journald capped to
`SystemMaxUse=300M` (`/etc/systemd/journald.conf.d/99-size.conf`, journal ~133M), disk at
~7.6G free of 16G.
- **Host disk resolved (2026-10-31):** the Gitea VM root filesystem was expanded from 7.8G to
16G (`/dev/mapper/pve-vm--500--disk--0`, now ~7.8G free). The pruned **v0.2.0** and **v0.2.9**
releases were restored from the local `build/` artifacts and verified (4 and 5 assets).
`git.allucanget.biz` runs on this host as user `zwitschi`; root@192.168.88.30 is **not**
reachable from this workstation (publickey denied; password auth closes the banner), so host
maintenance must go through a `zwitschi` login with sudo.
- **Host disk full (2026-10-31, historical):** the v0.2.11 tag build created release id 27 and logged the
uploads, but they failed with `HTTP 500 Attachments.Save: write … no space left on device`
(`/dev/mapper/pve-vm--500--disk--0`, 7.8G, 100%, 0 free). `git push` then failed too
("unpacker error"). Unblocked by deleting the superseded **v0.2.0** and **v0.2.9** releases
via the API (~565 MB of assets), then publishing v0.2.11 locally with the fixed script
(verified 5 assets). Remaining ~150 MB is tight — the next release needs host cleanup on
192.168.88.30: `sudo journalctl --vacuum-size=100M` (journal 837 MB), `sudo apt-get clean`
(91 MB), clear `/var/lib/gitea/data/attachments/tmp`. SSH works as `zwitschi`; `sudo -n`
needs a password. **Releases now:** v0.2.11, v0.2.10, v0.2.1 (5 assets each); v0.2.0 and
v0.2.9 entries were pruned. 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.
- NSIS gotchas (validated in a local ubuntu:24.04 docker repro): makensis uses `-D` defines - NSIS gotchas (validated in a local ubuntu:24.04 docker repro): makensis uses `-D` defines
+23 -10
View File
@@ -452,13 +452,14 @@ The following commands apply once the Godot project is scaffolded:
## Export Targets ## Export Targets
Desktop is the first target: Windows, Linux, and macOS. Web export is a later option. Desktop is the first target: Windows, Linux, and macOS. A Web export now ships as well
(non-threaded, static hosting); it is published with each release as a zip.
## Player Control, Focus, and Action Menu (next pass) ## Player Control, Focus, and Action Menu (next pass)
The prototype already walks and looks in first person, but the control scheme fights itself and targeting is a hairline. This pass replaces that with three things that belong together: solid locomotion, a visible focus on a person, and a round action menu for what to do with them. The prototype already walks and looks in first person, but the control scheme fights itself and targeting is a hairline. This pass replaces that with three things that belong together: solid locomotion, a visible focus on a person, and a round action menu for what to do with them.
Decisions: [ADR-0007](docs/adr/0007-locomotion-and-input-layers.md) locomotion and input layers, [ADR-0008](docs/adr/0008-cone-focus-and-head-ring.md) cone focus and head ring, [ADR-0009](docs/adr/0009-radial-action-menu.md) radial action menu, [ADR-0010](docs/adr/0010-procedural-npc-presentation.md) procedural NPC presentation. Decisions: [ADR-0007](docs/adr/0007-locomotion-and-input-layers.md) locomotion and input layers, [ADR-0008](docs/adr/0008-cone-focus-and-head-ring.md) cone focus and focus circle, [ADR-0009](docs/adr/0009-radial-action-menu.md) radial action menu, [ADR-0010](docs/adr/0010-procedural-npc-presentation.md) procedural NPC presentation.
### What is wrong today ### What is wrong today
@@ -496,17 +497,17 @@ Replace the hairline ray as the primary picker.
- `Tab` still cycles people inside 12 m as the aim-free path. Cycling sets the same focused person the cone would. - `Tab` still cycles people inside 12 m as the aim-free path. Cycling sets the same focused person the cone would.
- Clearing: look away until the head leaves the circle for a few frames (small grace so a bob does not drop them), or they walk out of range. - Clearing: look away until the head leaves the circle for a few frames (small grace so a bob does not drop them), or they walk out of range.
### Head ring ### Focus circle
When a person is in focus, draw a thin ring around their head. When a person is in focus, draw a blue, semi-transparent circle in front of their face.
- Attach to the avatar `Head` node, sitting just above the skull, billboarded to the camera so it stays a circle on screen. - Attach to the avatar `Head` node, just in front of the face, billboarded to the camera so it stays a circle on screen.
- Slow spin is fine; no ping-pong scale spam. - Slow spin is fine; no ping-pong scale spam.
- Idle focus: pale gold, modest size. Radial open on that person: brighter, slightly larger. - Idle focus: blue, semi-transparent, modest size. Radial open on that person: brighter, slightly larger.
- Optional small name under the ring (`display_name` only, not the archetype cheat `Violent Threat` in the world). The existing HUD target line can keep the archetype for the prototype. - Optional small name under the circle (`display_name` only, not the archetype cheat `Violent Threat` in the world). The existing HUD target line can keep the archetype for the prototype.
- One ring at a time. Everyone else has none. - One circle at a time. Everyone else has none.
Implementation sketch: `MeshInstance3D` torus or a thin unshaded ring mesh on the avatar, toggled from `GuardController` / `GameManager` when focus changes. Screen-space fallback (unproject head, draw a `Control` circle) is acceptable if the 3D torus z-fights on hats. Implementation sketch: a thin unshaded `MeshInstance3D` circle (flat disc) in front of the face on the avatar, toggled from `GuardController` / `GameManager` when focus changes. Screen-space fallback (unproject head, draw a `Control` circle) is acceptable if the 3D circle z-fights on hats.
### Round action HUD ### Round action HUD
@@ -545,7 +546,7 @@ A doughnut / pie menu centered on the screen, opened on the focused person.
**Cancel** **Cancel**
- `Esc` or right mouse button: hide the menu, no action, no recorder event, recapture mouse. - `Esc` or right mouse button: hide the menu, no action, no recorder event, recapture mouse.
- Focus and the head ring stay as they were. - Focus and the focus circle stay as they were.
Number keys `1`–`6` may remain as shortcuts that skip the wheel so a smoke-test script still works. They must not fire while the wheel is open (the wheel owns `A` / `D` / `Space`). Document both in the README. Number keys `1`–`6` may remain as shortcuts that skip the wheel so a smoke-test script still works. They must not fire while the wheel is open (the wheel owns `A` / `D` / `Space`). Document both in the README.
@@ -590,6 +591,18 @@ AI textures in `assets/textures/ai/` stay out of this pass unless a specific map
- `src/test/tests/test_focus_and_radial.gd` — cone pick, wrap, cancel emits nothing, `W` is not wand. - `src/test/tests/test_focus_and_radial.gd` — cone pick, wrap, cancel emits nothing, `W` is not wand.
- README controls table and smoke checklist. - README controls table and smoke checklist.
## Rendering, Models, and Selection (v0.3.0 pass)
Decisions: [ADR-0011](docs/adr/0011-aim-free-selection-and-precision-walk.md) aim-free selection and precision walk, [ADR-0012](docs/adr/0012-surface-materials-and-generated-props.md) surface materials and generated props.
The blockout look and the aim-first selection model were the two biggest gaps left by the control pass. This pass closes them without changing the game's rules.
**Look.** Surfaces get real profiles instead of stock materials: brick is rough and finely tiled, concrete is rougher still, asphalt is smoother and damp-looking, glass is smooth and slightly metallic. The environment gains ACES tonemapping, soft glow, aerial fog and a mild grade, all driven by the same per-phase configuration that already moves the sun and sky. The facade gains framed windows that light up after dark, a stone base course, door trim and handles. Street furniture becomes real geometry: bollards, benches, bins, planters, newspaper boxes and a hydrant, built as `.glb` props by `tools/model_pipeline.py` with a procedural fallback. NPCs get per-role clothing palettes, small role accessories (cap, hood, backpack, sunglasses, lanyard), hands, shoes, and an arm/leg walk swing. The rule stays: the player judges people from clothes, props and behaviour, never from an archetype label.
**Models.** Prop meshes are deterministic primitives, so the build works offline. An image model can supply the per-prop colour palette from a concept sheet; that is the only AI influence on geometry, and it is optional. Colours are converted from sRGB to linear before they reach glTF, because glTF factors are linear.
**Selection.** The focus cone ranks heads by angle from the crosshair then distance. `Tab` and the mouse wheel cycle nearby people nearest-first and pin the choice, so aim-free play works. Right click clears focus and briefly suppresses re-acquisition. Holding `Shift` walks at half speed for close work at the door. Escape keeps its single meaning (close the top panel, otherwise pause).
## Design Direction ## Design Direction
The game should feel like a tense workplace simulation with occasional crisis moments. The most satisfying outcome is not overpowering an NPC; it is recognizing the situation early and resolving it calmly. The game should feel like a tense workplace simulation with occasional crisis moments. The most satisfying outcome is not overpowering an NPC; it is recognizing the situation early and resolving it calmly.
+116 -14
View File
@@ -13,8 +13,11 @@ 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) | ![Prop gallery: the six generated street-furniture models on a studio backdrop](docs/screenshots/10-prop-gallery.png) |
| ![Precision walk at the venue steps with the HUD chip visible](docs/screenshots/11-precision-walk.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) and [`src/test/capture_prop_gallery.gd`](src/test/capture_prop_gallery.gd) from the current build — day, late-night, wand screening, escalation, shift-review timeline, precision walk, and the prop lineup.*
## ⬇️ Downloads ## ⬇️ Downloads
@@ -25,11 +28,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>
> **Latest: v0.2.9** — accessibility pass: grayscale-safe escalation meter and a working HUD font > Downloads are public (no sign-in required) since the host's instance-wide
> scale. > `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31.
>
> **Latest: v0.5.0** — articulated NPC characters with LOD and idle behaviour, plus movement feel, gamepad support, and key remapping.
> 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
@@ -137,8 +143,8 @@ The architecture maps onto Godot 4 nodes, scenes, and custom resources as descri
- **Navigation:** `NavigationRegion3D` and `NavigationAgent3D` - **Navigation:** `NavigationRegion3D` and `NavigationAgent3D`
- **NPC behavior:** Custom lightweight finite state machines driven by `NpcProfile` resources - **NPC behavior:** Custom lightweight finite state machines driven by `NpcProfile` resources
- **UI and audio:** Godot `Control` nodes, themes, audio buses, and `AudioStreamPlayer` nodes - **UI and audio:** Godot `Control` nodes, themes, audio buses, and `AudioStreamPlayer` nodes
- **Art assets:** authored PNGs and the in-game generated set in `assets/textures/ai/game_ready/`; structural geometry stays procedural (`WorldBuilder`). The legacy `tools/generate_textures.py --ai` batch remains available. - **Art assets:** authored PNGs and the in-game generated set in `assets/textures/ai/game_ready/`; street-furniture models in `assets/models/generated/` build from `tools/model_pipeline.py`; structural geometry stays procedural (`WorldBuilder`). The legacy `tools/generate_textures.py --ai` batch remains available.
- **Asset tooling:** `tools/asset_pipeline.py` generates role-aware assets through OpenRouter and provides crop, resize, seamless-tile, and image-audit commands, including gameplay screenshot checks. - **Asset tooling:** `tools/asset_pipeline.py` generates role-aware assets through OpenRouter and provides crop, resize, seamless-tile, and image-audit commands, including gameplay screenshot checks. `tools/model_pipeline.py` builds and audits the prop `.glb` models.
- **Testing:** GUT (Godot Unit Test) in headless mode - **Testing:** GUT (Godot Unit Test) in headless mode
- **Source control:** git on a Gitea host with Gitea Actions (`.gitea/workflows/build.yml`) for headless tests and desktop release builds - **Source control:** git on a Gitea host with Gitea Actions (`.gitea/workflows/build.yml`) for headless tests and desktop release builds
- **Targets:** Windows, Linux, and macOS desktop first; web later - **Targets:** Windows, Linux, and macOS desktop first; web later
@@ -180,6 +186,41 @@ python3 -m unittest discover -s tools -p 'test_*.py'
The audit classifies measurable fit issues as `good` or `bad` based on role-specific dimensions, aspect ratio, alpha requirements, and tile-edge continuity. It lists perspective/projection as a visual review item because image semantics cannot be judged reliably by these deterministic checks. The audit classifies measurable fit issues as `good` or `bad` based on role-specific dimensions, aspect ratio, alpha requirements, and tile-edge continuity. It lists perspective/projection as a visual review item because image semantics cannot be judged reliably by these deterministic checks.
### Prop models
Street furniture (bollards, benches, bins, planters, newspaper boxes, a hydrant)
is real 3D geometry generated by `tools/model_pipeline.py` and written to
`assets/models/generated/*.glb`. Godot imports each file as a `PackedScene`, and
`WorldBuilder` instantiates it with a procedural fallback when the model or its
import is missing, so the world still builds on a fresh checkout.
```bash
# Print the catalogue (parts, triangle count, whether a model/concept exists)
python3 tools/model_pipeline.py list
# Rebuild the models (deterministic, offline) and validate them
python3 tools/model_pipeline.py build
python3 tools/model_pipeline.py audit
# Optional: an image model supplies the colour palette per prop
# (requires OPENROUTER_API_KEY; the repo .env is read automatically)
python3 tools/model_pipeline.py concept
python3 tools/model_pipeline.py concept --only bench --force
```
Each prop is a small list of boxes and cylinders, so `build` needs no network and
the output is byte-identical between runs. `concept` asks an image model for a
studio-style reference; `dominant_colors` and `assign_roles` then map that image's
colours onto material roles (darkest → metal, warm saturated → wood, light grey →
stone, green → foliage). glTF stores colours in linear space, so the tool converts
the sRGB palettes before writing them. Re-run `build` and `godot --headless
--path . --import` after changing a prop. See
[ADR-0012](docs/adr/0012-surface-materials-and-generated-props.md).
Surface materials share one definition in `src/game/materials.gd`: named
roughness/metallic profiles per surface, plus the environment tune-up (ACES
tonemapping, glow, fog, colour grading) that the phase lighting applies.
### Screen design references ### Screen design references
Screen mockups and screenshot comparison use `tools/screen_designs.py`. It turns the Screen mockups and screenshot comparison use `tools/screen_designs.py`. It turns the
@@ -193,23 +234,54 @@ shared art-direction suffix so the frames read as one coherent game. Requests us
a 3:2 landscape source and crop to the 16:9 capture dimensions. a 3:2 landscape source and crop to the 16:9 capture dimensions.
```bash ```bash
# Deterministic wireframes (offline, no API key) # Export the built-in scenarios to editor-authorable .tres resources
godot --headless --path . --script res://tools/export_venues.gd
# List the catalogue (name, kind, whether a reference design exists)
python3 tools/screen_designs.py list
# Deterministic wireframes (offline, no API key); existing designs are kept
# unless --force is passed, so an offline run never replaces an AI mockup
python3 tools/screen_designs.py generate --draft python3 tools/screen_designs.py generate --draft
python3 tools/screen_designs.py generate --draft --only wand --force
# Realistic 3D scenes and in-game screen designs via OpenRouter # Realistic 3D scenes and in-game screen designs via OpenRouter
# (requires OPENROUTER_API_KEY) # (requires OPENROUTER_API_KEY)
python3 tools/screen_designs.py generate python3 tools/screen_designs.py generate
# Prop gallery (studio line-up) for close-range prop checks
godot --path . --script res://src/test/capture_prop_gallery.gd
# Compare docs/screenshots/*.png against the designs # Compare docs/screenshots/*.png against the designs
python3 tools/screen_designs.py compare python3 tools/screen_designs.py compare
python3 tools/screen_designs.py compare --json python3 tools/screen_designs.py compare --json
# Ask a vision model whether each capture realises its design's screen and layout
# (requires OPENROUTER_API_KEY; the repo .env is read automatically)
python3 tools/screen_designs.py judge
python3 tools/screen_designs.py judge --model openai/gpt-4o-mini --json
python3 tools/screen_designs.py judge --only 03- # one capture
python3 tools/screen_designs.py judge --dry-run # build requests, no network
``` ```
The catalogue covers 21 screens: the eleven README captures plus the start menu,
pause overlay, settings, shift review, radial menu, emergency radio, wand
screening, Joke Shop queue, TV Studio arrival, crowd panic, responder arrival,
body-camera timeline, and the escalation HUD. All 19 have an AI reference in
`docs/screens/designs/`; the eleven 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
semantic judge — pixel and perceptual metrics cannot decide whether a frame's semantic judge — pixel and perceptual metrics cannot decide whether a frame's
composition is *right*, so a vision-model pass is the documented follow-up (see composition is *right*.
`TODO.md`). Regenerate the designs after changing screen layout, then re-run
`judge` covers that gap: it sends each capture together with its concept mockup to
a vision model and asks whether the capture realises the same screen and layout
intent. Art style, fidelity, fonts, and placeholder text are explicitly ignored.
Verdicts are classed as `match`, `review` (a mismatch below `--fail-confidence`,
default 0.6 — expected when a concept mockup is more stylised than the game), or
`diff` (a confident mismatch, which exits non-zero). Regenerate the designs after changing screen layout, then re-run
`compare` after regenerating the game screenshots with `compare` after regenerating the game screenshots with
`src/test/capture_screenshots.gd`. `src/test/capture_screenshots.gd`.
@@ -236,7 +308,7 @@ godot --headless --path . --script res://src/test/test_runner.gd
# Save a screenshot to user:// (windowed) # Save a screenshot to user:// (windowed)
godot --path . --script res://src/test/take_screenshot.gd 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 circle, action wheel, wheel cycling + right-click clear; 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) # Session-shell smoke: menu → shift → pause → settings → menu (headless or windowed)
@@ -245,6 +317,15 @@ 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
# NPC budget smoke: 16 full-rig avatars against the documented frame budget
godot --headless --path . --script res://src/test/smoke_npc_budget.gd
# Movement smoke (windowed): steps, precision walk, footsteps and landing
godot --path . --script res://src/test/smoke_movement.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
@@ -256,12 +337,20 @@ godot --headless --path . --script res://src/test/smoke_timeline.gd
# Audio smoke: crowd bed follows mood, radio open/close crackle # Audio smoke: crowd bed follows mood, radio open/close crackle
godot --headless --path . --script res://src/test/smoke_audio.gd godot --headless --path . --script res://src/test/smoke_audio.gd
# Scenario-content smoke: wand refusal, celebrity crowd spike
godot --headless --path . --script res://src/test/smoke_scenario_content.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.
### Controls ### Controls
Keyboard and mouse are the default; a gamepad works without configuration (left stick moves,
right stick looks, A opens/confirms the wheel, B cancels, X inspects, Y calls the radio, LB walks
slowly, RB uses the wand). Keys and pad buttons can be rebound in Settings, and the remaps persist
in `user://settings.json`. The HUD legend switches to gamepad hints when a pad event arrives.
| Input | Action | | Input | Action |
| --- | --- | | --- | --- |
| W/A/S/D + mouse | Move and look | | W/A/S/D + mouse | Move and look |
@@ -281,7 +370,9 @@ The unit test runner is the project's own lightweight harness (no editor plugin
| 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 (debug shortcut; restart via V restarts the current shift) | | V | Cycle venue (debug shortcut; restart via V restarts the current shift) |
| Tab | Cycle target among nearby NPCs (aim-free alternative) | | Tab / mouse wheel ↓ / ↑ | Cycle the target among nearby NPCs, nearest first (aim-free; the target stays selected) |
| Right click (nothing open) | Clear the focused person and return to aim-based focus |
| Shift (hold) | Precision walk at roughly half speed for close work at the door |
| G | Toggle flashlight (widens NPC perception at night) | | G | Toggle flashlight (widens NPC perception at night) |
| B | Toggle the body camera; a formal warning switches it on automatically | | B | Toggle the body camera; a formal warning switches it on automatically |
| L | Lock / unlock the restricted door (thwarts forced entry) | | L | Lock / unlock the restricted door (thwarts forced entry) |
@@ -308,12 +399,12 @@ 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: **139 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) - Headless unit tests: **208 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, input remaps, 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, player movement, NPC character layers/LOD/queue, input bindings)
- 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 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`)
- 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 - 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 - World presentation: skyline backdrop rendered with an alpha material, walkable step and landing colliders, larger marquee text, and larger focus circle / name tag
- Routine traffic despawns after a while at its waypoint so the crowd cap no longer fills with idle standers - Routine traffic despawns after a while at its waypoint so the crowd cap no longer fills with idle standers
- PLAN verbs: eye contact (distinct Level 1 cue), recognizing a person as a player decision recorded as access evidence, a body-camera flag that a formal warning arms (and `B` toggles), blocking that physically stations the guard between the person and the door, and a Level 3 request-reason ask answered from the NPC's `dialogue_lines` - PLAN verbs: eye contact (distinct Level 1 cue), recognizing a person as a player decision recorded as access evidence, a body-camera flag that a formal warning arms (and `B` toggles), blocking that physically stations the guard between the person and the door, and a Level 3 request-reason ask answered from the NPC's `dialogue_lines`
- Incidents are now actually opened on focus and closed at shift end, so player actions, access evidence, escalation levels, and unresolved interactions reach the review (previously every recorded event was dropped) - Incidents are now actually opened on focus and closed at shift end, so player actions, access evidence, escalation levels, and unresolved interactions reach the review (previously every recorded event was dropped)
@@ -325,8 +416,19 @@ Implemented so far:
- NPC theater: seated guests sit on the steps with bent legs, influencers and tourists raise a camera prop while photographing, performers sway their sign, and filming visitors pan slowly — all once they reach their intent's waypoint - NPC theater: seated guests sit on the steps with bent legs, influencers and tourists raise a camera prop while photographing, performers sway their sign, and filming visitors pan slowly — all once they reach their intent's waypoint
- Audio: a looping procedural crowd bed (filtered murmur) whose volume follows `CrowdSystem.mood`, plus radio open/close crackle. All generated at runtime, no audio assets, and safe with a dummy audio server - Audio: a looping procedural crowd bed (filtered murmur) whose volume follows `CrowdSystem.mood`, plus radio open/close crackle. All generated at runtime, no audio assets, and safe with a dummy audio server
- Accessibility: the escalation cue shows a shape meter plus the numeric level (`Escalation: Blocking [###--] L3/5`) so it reads in grayscale, and the HUD font scale from settings is applied at boot by resizing fonts (not Control.scale, which broke the right-aligned layout) - Accessibility: the escalation cue shows a shape meter plus the numeric level (`Escalation: Blocking [###--] L3/5`) so it reads in grayscale, and the HUD font scale from settings is applied at boot by resizing fonts (not Control.scale, which broke the right-aligned layout)
- Editor-ready venues: `tools/export_venues.gd` writes `content/venues/<id>.tres` from `ScenarioFactory`, and the `.tres` files load back with events, profiles, policy, phases, door lines, and locations intact (the factory stays the source of truth)
- Localization safety: `Strings.fmt` validates the placeholder count before formatting and fails safe (warns, returns the template) instead of raising an engine error, `Strings.placeholder_count` ignores `%%`, and `Strings.validate()` lints the table for trailing `%`, unsupported conversions, and mixed positional/sequential placeholders
- Authored scenario content: Joke Shop reservation-without-ID, borrowed/mismatched ID, a guest who refuses the wand screen, and a late arrival during a performance; Studio filming-boundary repeats and a celebrity arrival that draws a small crowd; Building events that point the player at the body camera and request-reason verbs
- 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
- NPC characters (v0.5.0): articulated limb rigs, layered outfits, hand items, face/hair variety, idle behaviour (phone, clipboard, crossed arms, look-around), escalation gestures, readable reaction symbols, door-queue offsets, and three LOD tiers with a billboard impostor
- Player movement (v0.5.0): separate acceleration/braking, step-up assist, head bob and landing dip (toggleable), precision-walk FOV/chip/tick, surface-aware footsteps, gamepad support, and persistent key/button remapping in settings
- 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
- 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
Not yet implemented: live 3D body-camera replay, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`). Not yet implemented: live 3D body-camera replay, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
@@ -340,7 +442,7 @@ Not yet implemented: live 3D body-camera replay, editor-based GUT integration, a
6. The skyline backdrop shows silhouettes against the sky, not an opaque black slab. 6. The skyline backdrop shows silhouettes against the sky, not an opaque black slab.
7. The `STEP LINE` marquee is readable from the sidewalk. 7. The `STEP LINE` marquee is readable from the sidewalk.
8. NPCs spawn and approach the entrance during the first minutes. 8. NPCs spawn and approach the entrance during the first minutes.
9. Looking at an NPC inside the focus cone shows a gold head ring, the person's name, and the HUD target line. 9. Looking at an NPC inside the focus cone shows a blue focus circle in front of the face, the person's name, and the HUD target line.
10. `Space` or left click opens the action wheel; `A`/`D` highlight a slice, `Space` confirms it, `Esc`/right click cancels without a log entry. 10. `Space` or left click opens the action wheel; `A`/`D` highlight a slice, `Space` confirms it, `Esc`/right click cancels without a log entry.
11. `E`/`C` open the inspection/wand panels from the wheel and freeze movement until they close; walking never fires the wand. 11. `E`/`C` open the inspection/wand panels from the wheel and freeze movement until they close; walking never fires the wand.
12. Pressing `Q` produces a reaction log entry and reputation feedback. 12. Pressing `Q` produces a reaction log entry and reputation feedback.
+11 -42
View File
@@ -1,47 +1,16 @@
# TODO # TODO
Godot 4.3 prototype. Release **v0.2.9** is tagged. Headless suite **139/139**, plus the session, Godot 4.3 prototype. Latest release **v0.5.0** (NPC characters and player movement). Headless
PLAN-verb, crowd, emergency, timeline, audio, and windowed focus/radial smokes. suite **208/208** plus **56** Python tooling tests; the session, PLAN-verb, crowd, emergency,
timeline, audio, scenario-content, props, NPC-budget, movement, and windowed focus/radial smokes
pass. Anonymous downloads are enabled on the host.
Host `REQUIRE_SIGNIN_VIEW` stays deferred. The scheduled presentation pass is complete (see `DONE.md`). Only optional future ideas remain.
## Scenario content still missing vs PLAN lists ## Future ideas (optional, not scheduled)
- [ ] Joke Shop: guest on the reservation list without ID; borrowed/mismatched ID; guest - [ ] Live 3D body-camera replay (the timeline is text-only today).
refuses wand screening; late arrival during a performance. - [ ] Additional venues or seasonal events authored from the new `.tres` files.
- [ ] Studio: filming-boundary repeat (influencer ignores a posted line); celebrity-arrival - [ ] Real voice/dialogue audio (the crowd bed and radio crackle are procedural).
crowd spike (more rubberneckers, not only one `arrive` spawn). - [ ] Swap the primitive prop builder for an image-to-mesh model API behind the same
- [ ] Building: "activate body camera" and "request reason" once those verbs exist, used in `assets/models/generated/<prop>.glb` contract.
at least one authored event.
## Distribution follow-up (host config, deferred)
Verified via API+host credentials (2026-10-31):
- [x] Repo is **public** (`private:false`) and account has admin rights.
- [x] Release `v0.2.1` now carries all five artifacts (portable binaries + NSIS installer +
AppImage).
- [ ] **Remaining host setting**: instance-wide `[service] REQUIRE_SIGNIN_VIEW=true` makes
anonymous browsing/downloads redirect to login. Host admin: `REQUIRE_SIGNIN_VIEW = false`
under `[service]` in `app.ini`, restart Gitea. Alternative: mirror downloads.
## Open Polish
- [ ] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
## Screen design references (QA tooling)
- [x] `tools/screen_designs.py generate` writes one reference design per screen
(from the `docs/` descriptions) to `docs/screens/designs/`, with an offline
`--draft` wireframe mode and an OpenRouter `--ai` mockup mode.
- [x] `tools/screen_designs.py compare` checks `docs/screenshots/*.png` against the
designs (aspect ratio, size, blank-frame, and layout/palette/detail drift).
- [ ] Vision-model judge for semantic screenshot comparison. Pixel and perceptual
metrics cannot decide whether a frame's composition is correct; a follow-up
should send the capture + reference design to a vision model and ask whether
they depict the same screen.
## Tooling (when an editor workflow exists)
- [ ] IDE/editor-based GUT integration. Until then the custom headless runner stays.
- [ ] Optional later: author venues as `.tres` resources instead of `ScenarioFactory` functions.
Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

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

+15 -3
View File
@@ -1,5 +1,5 @@
{ {
"hud.controls": "LMB/Space wheel · A/D step · Space confirm · Esc cancel · R emergency · E inspect · C wand · Q polite · B camera · Tab cycle · G light · L lock · F debug", "hud.controls": "LMB/Space wheel · A/D step · Space confirm · Esc cancel · R emergency · E inspect · C wand · Q polite · B camera · Tab/wheel cycle · Shift slow · G light · L lock · F debug",
"radial.raise_hand": "Stop", "radial.raise_hand": "Stop",
"radial.eye_contact": "Look", "radial.eye_contact": "Look",
"radial.point": "Point", "radial.point": "Point",
@@ -16,7 +16,7 @@
"hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs", "hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs",
"hud.aim_first": "Aim at someone first.", "hud.aim_first": "Aim at someone first.",
"hud.no_one_nearby": "No one nearby.", "hud.no_one_nearby": "No one nearby.",
"hud.targeting": "Targeting %s (Tab to cycle).", "hud.targeting": "Targeting %s (Tab or mouse wheel cycles).",
"hud.no_wand": "No wand at this venue.", "hud.no_wand": "No wand at this venue.",
"hud.screen_first": "Screen this guest with the wand (C) before admitting them.", "hud.screen_first": "Screen this guest with the wand (C) before admitting them.",
"access.granted": "Entry granted: %s", "access.granted": "Entry granted: %s",
@@ -56,6 +56,15 @@
"reason.ask": "You ask %s why they are here.", "reason.ask": "You ask %s why they are here.",
"reason.reply": "%s: \"%s\"", "reason.reply": "%s: \"%s\"",
"reason.silent": "%s says nothing and looks away.", "reason.silent": "%s says nothing and looks away.",
"hint.reservation_no_id": "On the reservation list but no ID — ask for ID, then check the list.",
"hint.borrowed_id": "The ID photo does not match the person. Verify before admitting.",
"hint.refuses_wand": "This guest refuses the wand screen. No screen, no entry.",
"hint.late_arrival": "Late arrival during the performance — check ID and ticket before opening the door.",
"hint.film_boundary": "Filming past the posted line. Ask their reason and point them back to the sidewalk.",
"hint.film_boundary_repeat": "Same crew again, still past the line. A formal warning is now reasonable.",
"hint.celebrity_arrival": "Celebrity arrival: the crowd is swelling. Keep the entrance clear.",
"hint.body_camera_reason": "They are filming the entrance. Ask their reason (Reason) and consider the body camera (B).",
"hint.ask_reason": "Unclear intent. Ask why they are here (Reason) before deciding anything else.",
"station.blocking": "You step between %s and the door.", "station.blocking": "You step between %s and the door.",
"perception.near": "%s notices you standing close.", "perception.near": "%s notices you standing close.",
"perception.aware": "%s notices you watching.", "perception.aware": "%s notices you watching.",
@@ -76,5 +85,8 @@
"timeline.next": "Next ▶", "timeline.next": "Next ▶",
"timeline.close": "Close", "timeline.close": "Close",
"timeline.hint": "Left/Right step through events · Esc closes · no live replay yet", "timeline.hint": "Left/Right step through events · Esc closes · no live replay yet",
"review.timeline_button": "Body camera timeline" "review.timeline_button": "Body camera timeline",
"hud.nearby": "%d nearby · Tab or mouse wheel cycles targets",
"hud.precision": "Precision walk (Shift)",
"hud.controls_gamepad": "Left stick move · Right stick look · A wheel/confirm · B cancel · X inspect · Y radio · LB slow · RB wand"
} }
+794
View File
@@ -0,0 +1,794 @@
[gd_resource type="Resource" script_class="VenueScenario" load_steps=17 format=3]
[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_2u28i"]
[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_acokf"]
[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_mqiqi"]
[sub_resource type="Resource" id="Resource_i8fga"]
script = ExtResource("1_2u28i")
required_artifacts = {
1: true
}
grant_if_any_required = false
minimum_age = 0
require_id = false
wand_screening_required = false
policy_summary = "Private property. Entry requires recognition or a valid wristband."
[sub_resource type="Resource" id="Resource_c7lhe"]
script = ExtResource("2_acokf")
id = "tourist"
display_name = "Tourist"
npc_type = 0
temperament = 0.1
base_compliance = 0.9
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_0t6ar"]
script = ExtResource("2_acokf")
id = "confused_visitor"
display_name = "Confused Visitor"
npc_type = 3
temperament = 0.2
base_compliance = 0.85
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_wyv8p"]
script = ExtResource("2_acokf")
id = "delivery"
display_name = "Delivery Worker"
npc_type = 10
temperament = 0.25
base_compliance = 0.8
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["Where should I leave this?"])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_v2hc4"]
script = ExtResource("2_acokf")
id = "street_performer"
display_name = "Street Performer"
npc_type = 1
temperament = 0.45
base_compliance = 0.55
return_probability = 0.0
reactions_by_level = {
1: {
0: 55,
2: 15,
3: 30
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_lg4td"]
script = ExtResource("2_acokf")
id = "influencer"
display_name = "Influencer"
npc_type = 2
temperament = 0.5
base_compliance = 0.5
return_probability = 0.0
reactions_by_level = {
1: {
0: 40,
2: 10,
3: 30,
4: 20
},
2: {
0: 30,
2: 20,
4: 35,
8: 15
},
3: {
0: 10,
1: 15,
2: 20,
4: 30,
8: 25
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["This is public sidewalk, you can't stop me from filming."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_ijl2s"]
script = ExtResource("2_acokf")
id = "intoxicated"
display_name = "Intoxicated Pedestrian"
npc_type = 4
temperament = 0.6
base_compliance = 0.4
return_probability = 0.0
reactions_by_level = {
1: {
0: 30,
2: 20,
3: 50
},
2: {
0: 40,
2: 35,
3: 15,
4: 10
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 1.8
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_eycmk"]
script = ExtResource("2_acokf")
id = "trespasser"
display_name = "Trespasser"
npc_type = 5
temperament = 0.55
base_compliance = 0.35
return_probability = 0.6
reactions_by_level = {
1: {
0: 30,
2: 25,
3: 45
},
2: {
0: 40,
2: 35,
6: 25
},
3: {
1: 35,
2: 25,
6: 15,
7: 10,
8: 15
},
4: {
1: 70,
7: 30
},
5: {
1: 30,
5: 50,
7: 20
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["I'm just here to see a friend, come on.", "I'll be inside for ten seconds."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_ggtpw"]
script = ExtResource("2_acokf")
id = "mental_crisis"
display_name = "Person in Crisis"
npc_type = 7
temperament = 0.7
base_compliance = 0.3
return_probability = 0.4
reactions_by_level = {
1: {
0: 25,
2: 20,
3: 55
},
2: {
0: 25,
2: 35,
3: 30,
5: 10
},
3: {
0: 25,
1: 15,
2: 30,
5: 30
},
4: {
1: 40,
5: 35,
7: 25
},
5: {
1: 25,
5: 60,
7: 15
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 2.4
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_oa64f"]
script = ExtResource("2_acokf")
id = "violent_threat"
display_name = "Violent Threat"
npc_type = 6
temperament = 0.95
base_compliance = 0.05
return_probability = 0.0
reactions_by_level = {
1: {
2: 25,
3: 45,
5: 30
},
2: {
2: 30,
5: 45,
7: 25
},
3: {
1: 20,
5: 45,
7: 35
},
4: {
1: 15,
5: 30,
7: 55
},
5: {
1: 30,
5: 40,
7: 30
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_7t7dh"]
script = ExtResource("2_acokf")
id = "protester"
display_name = "Protester"
npc_type = 12
temperament = 0.5
base_compliance = 0.4
return_probability = 0.25
reactions_by_level = {
1: {
0: 25,
2: 35,
3: 25,
4: 15
},
2: {
0: 15,
2: 40,
3: 15,
4: 25,
8: 5
},
3: {
0: 10,
1: 25,
2: 25,
4: 25,
8: 15
},
4: {
1: 60,
2: 20,
7: 20
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["I have every right to be on public sidewalk.", "You can't touch me, this is a lawful protest."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_8tj1k"]
script = ExtResource("2_acokf")
id = "authorized_staff"
display_name = "Staff Member"
npc_type = 8
temperament = 0.15
base_compliance = 1.0
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 100
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_tnpxm"]
script = ExtResource("2_acokf")
id = "authorized_guest"
display_name = "Authorized Guest"
npc_type = 9
temperament = 0.2
base_compliance = 0.95
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 92,
2: 8
},
2: {
0: 88,
2: 12
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[resource]
script = ExtResource("3_mqiqi")
id = "hollywood_building"
display_name = "Hollywood Boulevard Building"
description = "Steps and entrance of a private building off Hollywood Boulevard."
access_policy = SubResource("Resource_i8fga")
equipment = Array[String](["wristband_policy", "flashlight", "radio"])
locations = {
"entrance": Vector3(0, 0, 1),
"guard_post": Vector3(0, 0, -3),
"queue_area": Vector3(0, 0, 6),
"sidewalk": Vector3(0, 0, 9),
"steps_end": Vector3(0, 0, 3)
}
npc_profiles = Array[ExtResource("2_acokf")]([SubResource("Resource_c7lhe"), SubResource("Resource_0t6ar"), SubResource("Resource_wyv8p"), SubResource("Resource_v2hc4"), SubResource("Resource_lg4td"), SubResource("Resource_ijl2s"), SubResource("Resource_eycmk"), SubResource("Resource_ggtpw"), SubResource("Resource_oa64f"), SubResource("Resource_7t7dh"), SubResource("Resource_8tj1k"), SubResource("Resource_tnpxm")])
event_pool = Array[Dictionary]([{
"base_weight": 1.0,
"id": "tourist_photo",
"intent": "photo",
"kind": "routine",
"npc_type": 0,
"phase": 0,
"probability": 0.42,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "protest_gather",
"intent": "protest",
"kind": "routine",
"npc_type": 12,
"phase": 0,
"probability": 0.18,
"time_range_s": Vector2(300, 900)
}, {
"base_weight": 1.0,
"id": "tourist_sit",
"intent": "sit",
"kind": "routine",
"npc_type": 0,
"phase": 0,
"probability": 0.26,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "confused_enter",
"intent": "enter",
"kind": "routine",
"npc_type": 3,
"phase": 0,
"probability": 0.25,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "delivery_drop",
"intent": "deliver",
"kind": "routine",
"npc_type": 10,
"phase": 0,
"probability": 0.3,
"time_range_s": Vector2(0, 600)
}, {
"base_weight": 1.0,
"id": "staff_entry",
"intent": "enter",
"kind": "routine",
"npc_type": 8,
"phase": 0,
"probability": 0.35,
"time_range_s": Vector2(0, 600)
}, {
"base_weight": 1.0,
"id": "guest_entry",
"intent": "enter",
"kind": "routine",
"npc_type": 9,
"phase": 0,
"probability": 0.25,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "performer_show",
"intent": "perform",
"kind": "routine",
"npc_type": 1,
"phase": 0,
"probability": 0.24,
"time_range_s": Vector2(300, 800)
}, {
"base_weight": 1.0,
"hint": "hint.body_camera_reason",
"id": "building_filming_boundary",
"intent": "film",
"kind": "scripted",
"npc_type": 2,
"phase": 1,
"probability": 0.4,
"time_range_s": Vector2(300, 600)
}, {
"base_weight": 1.0,
"hint": "hint.ask_reason",
"id": "building_unclear_intent",
"intent": "enter",
"kind": "scripted",
"npc_type": 3,
"phase": 2,
"probability": 0.4,
"time_range_s": Vector2(600, 780)
}, {
"base_weight": 1.0,
"id": "influencer_film",
"intent": "film",
"kind": "routine",
"npc_type": 2,
"phase": 0,
"probability": 0.26,
"time_range_s": Vector2(300, 900)
}, {
"base_weight": 1.0,
"id": "intoxicated_wander",
"intent": "wander",
"kind": "routine",
"npc_type": 4,
"phase": 1,
"probability": 0.15,
"time_range_s": Vector2(600, 900)
}, {
"base_weight": 1.0,
"id": "trespass_test",
"intent": "trespass",
"kind": "routine",
"npc_type": 5,
"phase": 0,
"probability": 0.16,
"time_range_s": Vector2(300, 900)
}, {
"base_weight": 1.0,
"id": "crisis_event",
"intent": "crisis",
"kind": "scripted",
"npc_type": 7,
"phase": 2,
"probability": 0.5,
"time_range_s": Vector2(640, 740)
}, {
"base_weight": 1.0,
"id": "violent_emergency_event",
"intent": "threat",
"kind": "scripted",
"npc_type": 6,
"phase": 3,
"probability": 0.28,
"requires_emergency": true,
"time_range_s": Vector2(760, 880)
}])
shift_phases = {
0: {
"start_s": 0.0
},
1: {
"start_s": 300.0
},
2: {
"start_s": 600.0
},
3: {
"start_s": 700.0
}
}
shift_duration_s = 900.0
policy_summary = ""
door_staff_lines = {}
+590
View File
@@ -0,0 +1,590 @@
[gd_resource type="Resource" script_class="VenueScenario" load_steps=13 format=3]
[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_lxsgk"]
[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_wfc6m"]
[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_8xm7a"]
[sub_resource type="Resource" id="Resource_xha77"]
script = ExtResource("1_lxsgk")
required_artifacts = {
2: true,
3: true
}
grant_if_any_required = false
minimum_age = 21
require_id = true
wand_screening_required = true
policy_summary = "Government ID and a valid ticket or reservation required. Carry items are screened."
[sub_resource type="Resource" id="Resource_8ksyk"]
script = ExtResource("2_wfc6m")
id = "tourist"
display_name = "Tourist"
npc_type = 0
temperament = 0.1
base_compliance = 0.9
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_wt1op"]
script = ExtResource("2_wfc6m")
id = "confused_visitor"
display_name = "Confused Visitor"
npc_type = 3
temperament = 0.2
base_compliance = 0.85
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_g1jr2"]
script = ExtResource("2_wfc6m")
id = "influencer"
display_name = "Influencer"
npc_type = 2
temperament = 0.5
base_compliance = 0.5
return_probability = 0.0
reactions_by_level = {
1: {
0: 40,
2: 10,
3: 30,
4: 20
},
2: {
0: 30,
2: 20,
4: 35,
8: 15
},
3: {
0: 10,
1: 15,
2: 20,
4: 30,
8: 25
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["This is public sidewalk, you can't stop me from filming."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_ys0b0"]
script = ExtResource("2_wfc6m")
id = "intoxicated"
display_name = "Intoxicated Pedestrian"
npc_type = 4
temperament = 0.6
base_compliance = 0.4
return_probability = 0.0
reactions_by_level = {
1: {
0: 30,
2: 20,
3: 50
},
2: {
0: 40,
2: 35,
3: 15,
4: 10
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 1.8
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_bv0jw"]
script = ExtResource("2_wfc6m")
id = "trespasser"
display_name = "Trespasser"
npc_type = 5
temperament = 0.55
base_compliance = 0.35
return_probability = 0.6
reactions_by_level = {
1: {
0: 30,
2: 25,
3: 45
},
2: {
0: 40,
2: 35,
6: 25
},
3: {
1: 35,
2: 25,
6: 15,
7: 10,
8: 15
},
4: {
1: 70,
7: 30
},
5: {
1: 30,
5: 50,
7: 20
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["I'm just here to see a friend, come on.", "I'll be inside for ten seconds."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_7vgx3"]
script = ExtResource("2_wfc6m")
id = "violent_threat"
display_name = "Violent Threat"
npc_type = 6
temperament = 0.95
base_compliance = 0.05
return_probability = 0.0
reactions_by_level = {
1: {
2: 25,
3: 45,
5: 30
},
2: {
2: 30,
5: 45,
7: 25
},
3: {
1: 20,
5: 45,
7: 35
},
4: {
1: 15,
5: 30,
7: 55
},
5: {
1: 30,
5: 40,
7: 30
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_x43ed"]
script = ExtResource("2_wfc6m")
id = "authorized_staff"
display_name = "Staff Member"
npc_type = 8
temperament = 0.15
base_compliance = 1.0
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 100
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_07lsg"]
script = ExtResource("2_wfc6m")
id = "authorized_guest"
display_name = "Authorized Guest"
npc_type = 9
temperament = 0.2
base_compliance = 0.95
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 92,
2: 8
},
2: {
0: 88,
2: 12
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[resource]
script = ExtResource("3_8xm7a")
id = "joke_shop"
display_name = "The Joke Shop"
description = "Comedy club door: IDs, reservations, and wand screening."
access_policy = SubResource("Resource_xha77")
equipment = Array[String](["id_check", "guest_list", "wand", "radio"])
locations = {
"door_area": Vector3(0, 0, 3),
"entrance": Vector3(0, 0, 1),
"guard_post": Vector3(0, 0, -3),
"queue_area": Vector3(0, 0, 9),
"sidewalk": Vector3(0, 0, 9),
"steps_end": Vector3(0, 0, 3)
}
npc_profiles = Array[ExtResource("2_wfc6m")]([SubResource("Resource_8ksyk"), SubResource("Resource_wt1op"), SubResource("Resource_g1jr2"), SubResource("Resource_ys0b0"), SubResource("Resource_bv0jw"), SubResource("Resource_7vgx3"), SubResource("Resource_x43ed"), SubResource("Resource_07lsg")])
event_pool = Array[Dictionary]([{
"base_weight": 1.0,
"id": "guest_valid",
"intent": "enter",
"kind": "routine",
"npc_type": 9,
"override_access": {
"has_id": true,
"has_ticket": true
},
"phase": 0,
"probability": 0.3,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "guest_no_ticket",
"intent": "enter",
"kind": "routine",
"npc_type": 9,
"override_access": {
"has_id": true,
"has_ticket": false
},
"phase": 0,
"probability": 0.18,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "guest_expired_id",
"intent": "enter",
"kind": "routine",
"npc_type": 9,
"override_access": {
"has_id": true,
"has_ticket": true,
"id_status": 1
},
"phase": 0,
"probability": 0.12,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "guest_no_id",
"intent": "enter",
"kind": "routine",
"npc_type": 9,
"override_access": {
"has_id": false,
"has_ticket": true
},
"phase": 0,
"probability": 0.12,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"hint": "hint.reservation_no_id",
"id": "joke_guest_reservation_no_id",
"intent": "enter",
"kind": "scripted",
"npc_type": 9,
"override_access": {
"guest_list_status": 0,
"has_guest_list": true,
"has_id": false,
"has_ticket": false
},
"phase": 2,
"probability": 0.45,
"time_range_s": Vector2(620, 860)
}, {
"base_weight": 1.0,
"hint": "hint.borrowed_id",
"id": "joke_borrowed_id",
"intent": "enter",
"kind": "scripted",
"npc_type": 9,
"override_access": {
"has_id": true,
"has_ticket": true,
"id_status": 3,
"ticket_status": 0
},
"phase": 1,
"probability": 0.4,
"time_range_s": Vector2(320, 700)
}, {
"base_weight": 1.0,
"hint": "hint.refuses_wand",
"id": "joke_refuses_wand",
"intent": "enter",
"kind": "scripted",
"npc_type": 9,
"override_access": {
"has_id": true,
"has_ticket": true,
"id_status": 0,
"ticket_status": 0
},
"phase": 2,
"probability": 0.4,
"refuses_screening": true,
"time_range_s": Vector2(650, 880)
}, {
"base_weight": 1.0,
"hint": "hint.late_arrival",
"id": "joke_late_arrival",
"intent": "arrive",
"kind": "scripted",
"npc_type": 9,
"override_access": {
"has_id": true,
"has_ticket": true,
"id_status": 0,
"ticket_status": 0
},
"phase": 2,
"probability": 0.35,
"time_range_s": Vector2(700, 860)
}, {
"base_weight": 1.0,
"id": "joke_staff",
"intent": "enter",
"kind": "routine",
"npc_type": 8,
"phase": 0,
"probability": 0.25,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "performer_show",
"intent": "perform",
"kind": "routine",
"npc_type": 1,
"phase": 0,
"probability": 0.15,
"time_range_s": Vector2(300, 800)
}, {
"base_weight": 1.0,
"id": "influencer_film",
"intent": "film",
"kind": "routine",
"npc_type": 2,
"phase": 0,
"probability": 0.15,
"time_range_s": Vector2(300, 900)
}, {
"base_weight": 1.0,
"id": "joke_disruptive",
"intent": "force_entry",
"kind": "scripted",
"npc_type": 4,
"phase": 2,
"probability": 0.5,
"time_range_s": Vector2(650, 830)
}])
shift_phases = {
0: {
"start_s": 0.0
},
1: {
"start_s": 300.0
},
2: {
"start_s": 600.0
},
3: {
"start_s": 700.0
}
}
shift_duration_s = 900.0
policy_summary = ""
door_staff_lines = {
"id_check": ["May I see your ID, please?", "Could you show me some identification?"],
"prohibited_item": ["I can't allow that item inside. Please leave it outside.", "That item isn't permitted in the venue."],
"refusal": ["I'm sorry, I can't let you in without that.", "Without a valid ID, I can't admit you tonight."],
"ticket_check": ["Your ticket or reservation, please.", "Do you have a ticket for tonight's show?"],
"wand_check": ["Please step forward for the wand check.", "Arms out, please — just a quick screening."]
}
+810
View File
@@ -0,0 +1,810 @@
[gd_resource type="Resource" script_class="VenueScenario" load_steps=17 format=3]
[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_xxhx3"]
[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_fiebn"]
[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_2dvuq"]
[sub_resource type="Resource" id="Resource_blmt0"]
script = ExtResource("1_xxhx3")
required_artifacts = {
1: true,
3: true,
4: true,
5: true
}
grant_if_any_required = true
minimum_age = 0
require_id = false
wand_screening_required = false
policy_summary = "Entry requires a wristband, credential, ticket, or guest-list confirmation."
[sub_resource type="Resource" id="Resource_oddac"]
script = ExtResource("2_fiebn")
id = "tourist"
display_name = "Tourist"
npc_type = 0
temperament = 0.1
base_compliance = 0.9
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_byir2"]
script = ExtResource("2_fiebn")
id = "confused_visitor"
display_name = "Confused Visitor"
npc_type = 3
temperament = 0.2
base_compliance = 0.85
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_3tt0b"]
script = ExtResource("2_fiebn")
id = "delivery"
display_name = "Delivery Worker"
npc_type = 10
temperament = 0.25
base_compliance = 0.8
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["Where should I leave this?"])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_ihxib"]
script = ExtResource("2_fiebn")
id = "street_performer"
display_name = "Street Performer"
npc_type = 1
temperament = 0.45
base_compliance = 0.55
return_probability = 0.0
reactions_by_level = {
1: {
0: 55,
2: 15,
3: 30
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_c57iy"]
script = ExtResource("2_fiebn")
id = "influencer"
display_name = "Influencer"
npc_type = 2
temperament = 0.5
base_compliance = 0.5
return_probability = 0.0
reactions_by_level = {
1: {
0: 40,
2: 10,
3: 30,
4: 20
},
2: {
0: 30,
2: 20,
4: 35,
8: 15
},
3: {
0: 10,
1: 15,
2: 20,
4: 30,
8: 25
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["This is public sidewalk, you can't stop me from filming."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_bk410"]
script = ExtResource("2_fiebn")
id = "intoxicated"
display_name = "Intoxicated Pedestrian"
npc_type = 4
temperament = 0.6
base_compliance = 0.4
return_probability = 0.0
reactions_by_level = {
1: {
0: 30,
2: 20,
3: 50
},
2: {
0: 40,
2: 35,
3: 15,
4: 10
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 1.8
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_jp52w"]
script = ExtResource("2_fiebn")
id = "trespasser"
display_name = "Trespasser"
npc_type = 5
temperament = 0.55
base_compliance = 0.35
return_probability = 0.6
reactions_by_level = {
1: {
0: 30,
2: 25,
3: 45
},
2: {
0: 40,
2: 35,
6: 25
},
3: {
1: 35,
2: 25,
6: 15,
7: 10,
8: 15
},
4: {
1: 70,
7: 30
},
5: {
1: 30,
5: 50,
7: 20
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["I'm just here to see a friend, come on.", "I'll be inside for ten seconds."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_5kplf"]
script = ExtResource("2_fiebn")
id = "mental_crisis"
display_name = "Person in Crisis"
npc_type = 7
temperament = 0.7
base_compliance = 0.3
return_probability = 0.4
reactions_by_level = {
1: {
0: 25,
2: 20,
3: 55
},
2: {
0: 25,
2: 35,
3: 30,
5: 10
},
3: {
0: 25,
1: 15,
2: 30,
5: 30
},
4: {
1: 40,
5: 35,
7: 25
},
5: {
1: 25,
5: 60,
7: 15
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 2.4
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_6bscs"]
script = ExtResource("2_fiebn")
id = "violent_threat"
display_name = "Violent Threat"
npc_type = 6
temperament = 0.95
base_compliance = 0.05
return_probability = 0.0
reactions_by_level = {
1: {
2: 25,
3: 45,
5: 30
},
2: {
2: 30,
5: 45,
7: 25
},
3: {
1: 20,
5: 45,
7: 35
},
4: {
1: 15,
5: 30,
7: 55
},
5: {
1: 30,
5: 40,
7: 30
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_n4lxh"]
script = ExtResource("2_fiebn")
id = "protester"
display_name = "Protester"
npc_type = 12
temperament = 0.5
base_compliance = 0.4
return_probability = 0.25
reactions_by_level = {
1: {
0: 25,
2: 35,
3: 25,
4: 15
},
2: {
0: 15,
2: 40,
3: 15,
4: 25,
8: 5
},
3: {
0: 10,
1: 25,
2: 25,
4: 25,
8: 15
},
4: {
1: 60,
2: 20,
7: 20
}
}
default_reactions = {
1: {
0: 80,
2: 8,
3: 12
},
2: {
0: 70,
1: 10,
2: 12,
4: 8
},
3: {
0: 55,
1: 15,
2: 20,
4: 10
},
4: {
1: 65,
2: 15,
7: 20
},
5: {
1: 30,
5: 40,
7: 30
}
}
reaction_delay = 0.6
dialogue_lines = Array[String](["I have every right to be on public sidewalk.", "You can't touch me, this is a lawful protest."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_6st6l"]
script = ExtResource("2_fiebn")
id = "authorized_staff"
display_name = "Staff Member"
npc_type = 8
temperament = 0.15
base_compliance = 1.0
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 100
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_nf2oq"]
script = ExtResource("2_fiebn")
id = "authorized_guest"
display_name = "Authorized Guest"
npc_type = 9
temperament = 0.2
base_compliance = 0.95
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 92,
2: 8
},
2: {
0: 88,
2: 12
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[resource]
script = ExtResource("3_2dvuq")
id = "tv_studio"
display_name = "Hollywood Blvd Late Night TV Studio"
description = "Crowd, celebrities, and staff around a late-night studio."
access_policy = SubResource("Resource_blmt0")
equipment = Array[String](["wristband_policy", "guest_list", "radio", "flashlight"])
locations = {
"entrance": Vector3(0, 0, 1),
"guard_post": Vector3(0, 0, -3),
"queue_area": Vector3(0, 0, 9),
"sidewalk": Vector3(0, 0, 9),
"stage_door": Vector3(6, 0, -2),
"steps_end": Vector3(0, 0, 3)
}
npc_profiles = Array[ExtResource("2_fiebn")]([SubResource("Resource_oddac"), SubResource("Resource_byir2"), SubResource("Resource_3tt0b"), SubResource("Resource_ihxib"), SubResource("Resource_c57iy"), SubResource("Resource_bk410"), SubResource("Resource_jp52w"), SubResource("Resource_5kplf"), SubResource("Resource_6bscs"), SubResource("Resource_n4lxh"), SubResource("Resource_6st6l"), SubResource("Resource_nf2oq")])
event_pool = Array[Dictionary]([{
"base_weight": 1.0,
"id": "audience_ticket",
"intent": "enter",
"kind": "routine",
"npc_type": 9,
"override_access": {
"has_ticket": true
},
"phase": 0,
"probability": 0.35,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "protest_outside",
"intent": "protest",
"kind": "routine",
"npc_type": 12,
"phase": 0,
"probability": 0.18,
"time_range_s": Vector2(300, 900)
}, {
"base_weight": 1.0,
"id": "celebrity_arrival",
"intent": "arrive",
"kind": "routine",
"npc_type": 9,
"override_access": {
"has_guest_list": true
},
"phase": 0,
"probability": 0.2,
"time_range_s": Vector2(300, 700)
}, {
"base_weight": 1.0,
"id": "crew_shift",
"intent": "enter",
"kind": "routine",
"npc_type": 8,
"phase": 0,
"probability": 0.3,
"time_range_s": Vector2(0, 700)
}, {
"base_weight": 1.0,
"id": "tourist_photo",
"intent": "photo",
"kind": "routine",
"npc_type": 0,
"phase": 0,
"probability": 0.35,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "confused_enter",
"intent": "enter",
"kind": "routine",
"npc_type": 3,
"phase": 0,
"probability": 0.25,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "influencer_film",
"intent": "film",
"kind": "routine",
"npc_type": 2,
"phase": 0,
"probability": 0.28,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "trespass_blend",
"intent": "blend_in",
"kind": "routine",
"npc_type": 5,
"phase": 0,
"probability": 0.15,
"time_range_s": Vector2(300, 900)
}, {
"base_weight": 1.0,
"id": "studio_counterfeit",
"intent": "counterfeit_credential",
"kind": "scripted",
"npc_type": 5,
"override_access": {
"credential_status": 2,
"wristband_status": 2
},
"phase": 2,
"probability": 0.55,
"time_range_s": Vector2(620, 780)
}, {
"base_weight": 1.0,
"hint": "hint.film_boundary",
"id": "studio_film_boundary",
"intent": "film_boundary",
"kind": "scripted",
"npc_type": 2,
"phase": 1,
"probability": 0.5,
"time_range_s": Vector2(330, 560)
}, {
"base_weight": 1.0,
"hint": "hint.film_boundary_repeat",
"id": "studio_film_boundary_repeat",
"intent": "film_boundary",
"kind": "scripted",
"npc_type": 2,
"phase": 2,
"probability": 0.45,
"time_range_s": Vector2(640, 830)
}, {
"base_weight": 1.0,
"extra_spawns": [{
"intent": "photo",
"npc_type": 0
}, {
"intent": "photo",
"npc_type": 0
}, {
"intent": "film",
"npc_type": 2
}],
"hint": "hint.celebrity_arrival",
"id": "studio_celebrity_arrival",
"intent": "arrive",
"kind": "scripted",
"npc_type": 9,
"override_access": {
"guest_list_status": 0,
"has_guest_list": true
},
"phase": 2,
"probability": 0.5,
"time_range_s": Vector2(620, 760)
}, {
"base_weight": 1.0,
"id": "studio_follow_through",
"intent": "follow_authorized",
"kind": "scripted",
"npc_type": 5,
"override_access": {
"has_credential": false,
"has_guest_list": false,
"has_ticket": false,
"has_wristband": false
},
"phase": 2,
"probability": 0.45,
"time_range_s": Vector2(700, 860)
}])
shift_phases = {
0: {
"start_s": 0.0
},
1: {
"start_s": 300.0
},
2: {
"start_s": 600.0
},
3: {
"start_s": 700.0
}
}
shift_duration_s = 900.0
policy_summary = ""
door_staff_lines = {}
+26 -2
View File
@@ -37,8 +37,30 @@ The repository ships a Gitea Actions workflow (`.gitea/workflows/build.yml`):
Linux/Windows/macOS, artifact upload, and — on tags — release publication via Linux/Windows/macOS, artifact upload, and — on tags — release publication via
`tools/publish_release.sh`. `tools/publish_release.sh`.
For tag builds, set a repo secret named **`GITEA_TOKEN`** (write:repository scope). `tools/publish_release.sh` picks the first available credential, in order:
Without it the workflow still builds and tests, but the release publish step fails.
1. `GITEA_TOKEN` (repo secret, write:repository scope),
2. `GITEA_AUTH_HEADER` (a ready-made `Authorization: ...` header),
3. `~/.git-credentials` for the host,
4. the **local git extraheader** that `actions/checkout` configures on the CI job.
Option 4 is what makes tag builds publish without any repo secret: the runner
already injects basic-auth credentials for the checkout, and the script reuses that
header for the API (same server). `github.token` is *not* available in this Gitea
setup, so do not rely on it.
> **Disk space:** Gitea stores release assets under `/var/lib/gitea/data/attachments` on
> the host root filesystem (7.8 GB here). When it is full, asset uploads fail with
> `HTTP 500 … no space left on device` and even `git push` can fail with "unpacker error".
> `publish_release.sh` now verifies the asset count and exits non-zero, so this is visible.
>
> **Incident (2026-10-31):** `tools/publish_release.sh` never set `AUTH` in its
> `~/.git-credentials` branch, so every API call failed silently (`AUTH[@]` was
> unbound under `set -u`). Combined with no `GITEA_TOKEN` secret on the repo, tag
> builds from v0.2.2 to v0.2.8 produced installers but published nothing. The script
> is fixed (`AUTH=(-u "$CRED")`) and v0.2.9 was published locally with all five
> artifacts. If CI publishing fails again, run
> `./tools/publish_release.sh <version>` from a checkout with `~/.git-credentials`.
> **No `actions/cache` for now:** the runner has no local actions-cache service, so > **No `actions/cache` for now:** the runner has no local actions-cache service, so
> `actions/cache` times out against GitHub's cache API. We dropped it (Godot import > `actions/cache` times out against GitHub's cache API. We dropped it (Godot import
@@ -62,6 +84,8 @@ Artifacts are written to `build/` (all exports embed the PCK — single files):
- `build/step-line-linux-<version>.x86_64` - `build/step-line-linux-<version>.x86_64`
- `build/step-line-windows-<version>.exe` - `build/step-line-windows-<version>.exe`
- `build/step-line-macos-<version>.zip` - `build/step-line-macos-<version>.zip`
- `build/step-line-web-<version>.zip` — browser build (non-threaded Godot web
template; unzip and serve the folder, no COOP/COEP headers required)
When run in CI (tags/dispatch), the build job also packages: When run in CI (tags/dispatch), the build job also packages:
+5 -5
View File
@@ -1,4 +1,4 @@
# ADR-0008: Cone focus and head ring # ADR-0008: Cone focus and focus circle
- **Status:** Accepted - **Status:** Accepted
- **Date:** TBD - **Date:** TBD
@@ -19,7 +19,7 @@ Focus is a **cone**, not a ray.
- A few frames of grace keep walk-bob from dropping focus. - A few frames of grace keep walk-bob from dropping focus.
- `Tab` still cycles people inside 12 m and sets the same focused person. - `Tab` still cycles people inside 12 m and sets the same focused person.
When someone is in focus, draw a **thin ring** around their head, attached to the avatar `Head` node, billboarded so it stays a circle on screen. Pale gold while idle; brighter and slightly larger while the action menu is open on that person. Optional `display_name` under the ring. One ring at a time. When someone is in focus, draw a **blue, semi-transparent circle in front of the face**, attached to the avatar `Head` node, billboarded so it stays a circle on screen. Blue while idle; brighter and slightly larger while the action menu is open on that person. Optional `display_name` under the circle. One circle at a time.
The world must not show the internal type name (`Trespasser`, `Violent Threat`). The debug overlay (`F`) can still print it. The world must not show the internal type name (`Trespasser`, `Violent Threat`). The debug overlay (`F`) can still print it.
@@ -28,19 +28,19 @@ The world must not show the internal type name (`Trespasser`, `Violent Threat`).
### Positive ### Positive
- Looking near a person is enough; the player does not have to pin a capsule. - Looking near a person is enough; the player does not have to pin a capsule.
- The ring is visible in the scene, so the HUD is not the only focus cue. - The circle is visible in the scene, so the HUD is not the only focus cue.
- `Tab` remains the accessibility path and shares the same focused person. - `Tab` remains the accessibility path and shares the same focused person.
- The action menu (ADR-0009) has a clear subject. - The action menu (ADR-0009) has a clear subject.
### Negative ### Negative
- Two heads in the same circle can steal focus; center-distance is a heuristic, not a lock-on. - Two heads in the same circle can steal focus; center-distance is a heuristic, not a lock-on.
- Hats and protester signs can z-fight a 3D torus. A screen-space circle unprojected from the head is an allowed fallback. - Hats and protester signs can z-fight a 3D circle. A screen-space circle unprojected from the head is an allowed fallback.
- Occlusion rays can fail on unusual geometry; grace frames hide some of that. - Occlusion rays can fail on unusual geometry; grace frames hide some of that.
## Alternatives considered ## Alternatives considered
- **Keep the hairline ray:** Rejected because it drops people too easily and has no world mark. - **Keep the hairline ray:** Rejected because it drops people too easily and has no world mark.
- **Sticky lock-on until cancelled:** Rejected because the job is to watch a crowd, not duel one person. - **Sticky lock-on until cancelled:** Rejected because the job is to watch a crowd, not duel one person.
- **Screen reticle only, no world ring:** Rejected; in a crowd the player needs a mark on the head, not only a HUD line. - **Screen reticle only, no world circle:** Rejected; in a crowd the player needs a mark on the face, not only a HUD line.
- **Click a person to select, then act:** Rejected for default play. The mouse is captured for look (ADR-0007). Click opens the action menu on whoever is already in the cone. - **Click a person to select, then act:** Rejected for default play. The mouse is captured for look (ADR-0007). Click opens the action menu on whoever is already in the cone.
+2 -2
View File
@@ -31,7 +31,7 @@ While the menu is open, walk and look freeze (ADR-0007) and the cursor is visibl
**Confirm:** click a slice, or `Space` on the highlighted slice. The menu closes, the mouse is recaptured, and that action runs. Inspect, wand, and radio assist close the wheel and then open the existing panels. Walk and look stay frozen until that panel closes. **Confirm:** click a slice, or `Space` on the highlighted slice. The menu closes, the mouse is recaptured, and that action runs. Inspect, wand, and radio assist close the wheel and then open the existing panels. Walk and look stay frozen until that panel closes.
**Cancel:** `Esc` or right mouse button. The menu hides. No action runs. No recorder event. Focus and the head ring stay. The mouse is recaptured. **Cancel:** `Esc` or right mouse button. The menu hides. No action runs. No recorder event. Focus and the focus circle stay. The mouse is recaptured.
Number keys `1`–`6` may remain as shortcuts when the wheel is closed, so existing smoke tests still work. They must not fire while the wheel is open. Number keys `1`–`6` may remain as shortcuts when the wheel is closed, so existing smoke tests still work. They must not fire while the wheel is open.
@@ -54,7 +54,7 @@ Number keys `1`–`6` may remain as shortcuts when the wheel is closed, so exist
## Alternatives considered ## Alternatives considered
- **Hold-to-open, release-to-confirm (GTA / Destiny):** Rejected. The requested input is click or Space to open, then click or Space to confirm, with Esc / RMB to abort. That is a toggle, not a hold. - **Hold-to-open, release-to-confirm (GTA / Destiny):** Rejected. The requested input is click or Space to open, then click or Space to confirm, with Esc / RMB to abort. That is a toggle, not a hold.
- **Vertical list / command menu:** Rejected because a round layout around a focused person matches the head ring and reads faster in a crowd. - **Vertical list / command menu:** Rejected because a round layout around a focused person matches the focus circle and reads faster in a crowd.
- **One context-sensitive Use key:** Rejected; the game is about choosing among levels, not a single default verb. - **One context-sensitive Use key:** Rejected; the game is about choosing among levels, not a single default verb.
- **Emergency on the wheel:** Rejected because a misclick would dispatch armed response. ADR-0002 already keeps that path rare and confirmed. - **Emergency on the wheel:** Rejected because a misclick would dispatch armed response. ADR-0002 already keeps that path rare and confirmed.
- **Drop number-key shortcuts immediately:** Deferred. They remain as a closed-menu fallback until the wheel is proven in play. - **Drop number-key shortcuts immediately:** Deferred. They remain as a closed-menu fallback until the wheel is proven in play.
+2 -2
View File
@@ -21,7 +21,7 @@ Make the body read as a person at conversation distance:
- Shoes. - Shoes.
- Hair cap whose color comes from the visual seed. - Hair cap whose color comes from the visual seed.
- Two dark eye spheres so the head has a front. - Two dark eye spheres so the head has a front.
- Named `Head` node (`Rig/Head`) as the attach point for the focus ring (ADR-0008) and the focus cone. - Named `Head` node (`Rig/Head`) as the attach point for the focus circle (ADR-0008) and the focus cone.
- Collision capsule scaled to the visible height so the cone hits the body the player sees. - Collision capsule scaled to the visible height so the cone hits the body the player sees.
Keep archetype props, height / brightness variation, walk bob, and the notice head-turn. Keep archetype props, height / brightness variation, walk bob, and the notice head-turn.
@@ -49,5 +49,5 @@ AI clothing textures stay out unless a map is already the right size and is wire
- **Skinned Mixamo / GLB characters now:** Rejected. Cost and pipeline exceed this pass; the sim only needs a readable body and a head node. - **Skinned Mixamo / GLB characters now:** Rejected. Cost and pipeline exceed this pass; the sim only needs a readable body and a head node.
- **Billboard sprites:** Rejected; first-person movement walks around people, and sprites fail at profile views. - **Billboard sprites:** Rejected; first-person movement walks around people, and sprites fail at profile views.
- **Keep colored capsules as-is:** Rejected; the head ring and cone need a real head, and capsules do not read as people at two metres. - **Keep colored capsules as-is:** Rejected; the focus circle and cone need a real head, and capsules do not read as people at two metres.
- **World labels with archetype names:** Rejected because they leak sim truth and skip observation. - **World labels with archetype names:** Rejected because they leak sim truth and skip observation.
@@ -0,0 +1,48 @@
# ADR-0011: Aim-free target selection, precision walk, and focus clearing
- **Status:** Accepted
- **Date:** 2026-10-31
## Context
ADR-0007 and ADR-0008 split input into locomotion, look, and a focus cone. A pass over the resulting controls found four problems in play:
1. **The cone ignored what the player sees.** `NpcFocus.pick_index` returned the first head inside the cone in group order, not the one nearest the crosshair. Two people in the cone gave an arbitrary pick.
2. **`Tab` cycled in spawn order.** `GameManager._cycle_nearby_target` walked the `npcs` array, so "next" meant "next spawned", not "next nearest".
3. **A cycled target was dropped within a few frames.** The cone re-evaluated every physics frame and cleared a target that was not under the crosshair after the 8-frame miss grace. Aim-free selection therefore only worked while the player also aimed.
4. **There was no way to dismiss someone.** `Esc` pauses the shift and right click only cancelled modals.
The movement pass also left one gap at the door: the only walking speed is 3.2 m/s, which overshoots when the player is trying to stand at a register or read an artifact at conversation distance.
## Decision
**Rank the cone by angle, then distance.** `NpcFocus.rank_indices` scores every head by its angle from the camera forward vector and breaks ties by distance; `pick_index` is the first ranked entry. The cone therefore picks the person closest to the crosshair, which is what the player is looking at.
**Make aim-free selection authoritative.** `GuardController.cycle_target(direction)` walks `nearby_candidates()` — everyone within 12 m, nearest first — starting from the current target and wrapping in both directions. The chosen avatar is *pinned*: while a pinned target is alive the cone does not re-evaluate, so `Tab` and the mouse wheel produce a stable selection the player can then act on.
**Bind the mouse wheel to the same ring.** `cycle_target_next` (wheel down) and `cycle_target_prev` (wheel up) are new input actions handled in `GameManager._handle_mouse_button`. `Tab` remains the keyboard path. The HUD prints how many people are in the ring (`hud.nearby`), so the player knows the control exists before they need it.
**Right click clears focus, with a short grace.** With no modal open, right click clears the target and suppresses cone acquisition for `FOCUS_SUPPRESS_S = 0.75 s`. Without the grace the cone re-acquired the same person on the next frame, which made the control feel broken. Any explicit pick (cone or cycle) cancels the suppression.
**Add a precision walk.** Holding `Shift` (`walk_slow`) multiplies the walk speed by `SLOW_WALK_FACTOR = 0.45`. This is a fine-positioning aid, not a sprint: PLAN.md deliberately excludes sprint and crouch, and this does not add a faster move.
Escape keeps its current meaning (close the top modal, otherwise pause). Selection is not overloaded onto the same key.
## Consequences
### Positive
- The focused person is predictable: the one nearest the crosshair, or the one the player explicitly cycled to.
- Aim-free play works: cycle, act, cycle again, without fighting the cone.
- Right click has an unambiguous "never mind" meaning while remaining a modal cancel.
- Standing on a mark while reading an artifact is possible.
### Negative
- Two more input actions and one more piece of guard state (`target_pinned`, `focus_suppress_s`).
- A cycled target stays selected until the player clears it, acts, or the avatar is freed; it does not expire on its own.
- The 0.75 s suppression is a feel constant that may need tuning against play testing.
## Follow-up
- The action wheel (ADR-0009) and inspection panels still take `A`/`D`; the wheel owns those keys while it is open, and the guard is frozen then, so no conflict is observable.
@@ -0,0 +1,44 @@
# ADR-0012: Surface material profiles and generated prop models
- **Status:** Accepted
- **Date:** 2026-10-31
## Context
The procedural street was built from `BoxMesh` primitives with `StandardMaterial3D` defaults: albedo colour (or a texture) and everything else stock. Every surface therefore shared one roughness, street furniture was a set of scaled boxes, and the sky was a flat `ProceduralSkyMaterial` with no tonemapping, glow, or fog. Captures looked like a blockout: brick tiles were metres wide, the sidewalk blew out to white, the facade had no windows, and night mode only switched the lamp bulbs and the marquee.
Two constraints shaped the fix:
- The project renders with the **GL Compatibility** backend so it stays small and portable, which rules out features that only exist in Forward+ (`SSAO`, `SSIL`).
- The repo must build offline in CI, so any asset step has to work without a network or an API key.
## Decision
**One material module.** `src/game/materials.gd` (`SurfaceMaterials`) owns a named profile table (`brick`, `concrete`, `asphalt`, `awning`, `stone`, `metal`, `painted_metal`, `wood`, `cloth`, `skin`, `glass`, `rubber`) with roughness/metallic pairs, plus the recipes the world and avatars use (`brick()`, `concrete()`, `asphalt()`, `awning()`, `flat()`, `emissive()`, `glass()`, `window_pane()`). The world builder, the NPC avatars, and the prop fallbacks all call it, so a surface has one definition.
**One environment tune-up.** `SurfaceMaterials.configure_environment()` applies ACES tonemapping, soft glow (for the marquee, lamp bulbs, and lit windows), aerial fog, and a mild contrast/saturation lift. `GameManager.PHASE_LIGHTING` now carries the per-phase values (`exposure`, `glow`, `fog_color`, `fog_density`, `fog_energy`, `contrast`, `saturation`, `ambient_energy`) next to the existing sun/sky entries, so a phase change still happens in one place. Features unavailable in Compatibility are ignored by the driver rather than gated in code.
**Generated prop models.** `tools/model_pipeline.py` describes street furniture (bollard, bench, bin, planter, newspaper box, hydrant) as boxes and cylinders and writes binary glTF (`.glb`) into `assets/models/generated/`. Godot imports each file as a `PackedScene`, and `WorldBuilder._add_prop_model()` instantiates it with a procedural fallback when the file or its import is missing. The build is deterministic and needs no network.
**AI only supplies colour.** `model_pipeline.py concept` asks an image model (OpenRouter, same backend as the texture tools) for one studio-style concept per prop. `dominant_colors()` reads the centre of that image and ranks colours by frequency weighted by saturation (a grey backdrop otherwise wins), and `assign_roles()` maps the result onto material roles by hue and value: darkest tone → metal, warm saturated tone → wood, light desaturated tone → stone, green tone → foliage. Colours are converted from sRGB to **linear** before they are written into glTF `baseColorFactor`, because the factor is linear space and writing sRGB made every prop render pastel.
## Consequences
### Positive
- Surfaces read as materials: brick has fine, correctly scaled tiling, asphalt is smoother than brick, glass is smooth and slightly metallic, and the night sky carries warm lit windows.
- Street furniture is real geometry with per-part materials instead of scaled boxes, and it costs ~700 triangles for the whole set.
- The prop pipeline works with no key and no network; the concept step is optional and only changes colours.
- Tests can assert material profiles, environment values, and GLB structure without rendering.
### Negative
- The generated models are low-poly primitives, not sculpted assets; they are an improvement in silhouette, not photoreal props.
- Two colour spaces now exist in the tooling. `model_pipeline.srgb_to_linear` must be used for every colour written into glTF, which is easy to forget.
- Concept images are per-prop inputs that have to be regenerated (and reviewed) when the art direction changes; a poor concept now produces a poor palette.
- Compatibility rendering means no ambient occlusion, so contact shadows rely on the directional light only.
## Follow-up
- A 3D-model API (image-to-mesh) could replace the primitive builder behind the same `assets/models/generated/<prop>.glb` contract without touching `WorldBuilder`.
- Texture atlases for the props would let them share one material instead of one per part.
+6 -1
View File
@@ -76,6 +76,9 @@ The player interacts with NPCs, venue entrances, access artifacts, equipment, st
- `ReputationEvaluator` converts incidents and shift outcomes into performance feedback. - `ReputationEvaluator` converts incidents and shift outcomes into performance feedback.
- `CrowdSystem` models queues, attention, congestion, and public reaction. - `CrowdSystem` models queues, attention, congestion, and public reaction.
- `ShiftReview` presents decisions, consequences, and improvement opportunities. - `ShiftReview` presents decisions, consequences, and improvement opportunities.
- `SurfaceMaterials` (`src/game/materials.gd`) holds the surface profiles, glass/emissive recipes, and the environment tune-up (tonemapping, glow, fog, grading) that the phase lighting applies.
- `WorldBuilder` assembles the procedural venue and instantiates the generated prop models from `assets/models/generated/` with a procedural fallback.
- `tools/model_pipeline.py` builds and audits those prop models; `tools/screen_designs.py` generates screen references and judges captures.
## 6. Runtime View ## 6. Runtime View
@@ -153,9 +156,11 @@ Architecture decisions are recorded as ADRs:
- [ADR-0005: Event-based incident recording](adr/0005-event-based-incident-recording.md) - [ADR-0005: Event-based incident recording](adr/0005-event-based-incident-recording.md)
- [ADR-0006: Godot 4 with GDScript](adr/0006-godot-4-gdscript.md) - [ADR-0006: Godot 4 with GDScript](adr/0006-godot-4-gdscript.md)
- [ADR-0007: Locomotion and input layers](adr/0007-locomotion-and-input-layers.md) - [ADR-0007: Locomotion and input layers](adr/0007-locomotion-and-input-layers.md)
- [ADR-0008: Cone focus and head ring](adr/0008-cone-focus-and-head-ring.md) - [ADR-0008: Cone focus and focus circle](adr/0008-cone-focus-and-head-ring.md)
- [ADR-0009: Radial action menu](adr/0009-radial-action-menu.md) - [ADR-0009: Radial action menu](adr/0009-radial-action-menu.md)
- [ADR-0010: Procedural NPC presentation](adr/0010-procedural-npc-presentation.md) - [ADR-0010: Procedural NPC presentation](adr/0010-procedural-npc-presentation.md)
- [ADR-0011: Aim-free selection and precision walk](adr/0011-aim-free-selection-and-precision-walk.md)
- [ADR-0012: Surface materials and generated props](adr/0012-surface-materials-and-generated-props.md)
## 10. Quality Requirements ## 10. Quality Requirements
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.1 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.4 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 986 KiB

After

Width:  |  Height:  |  Size: 836 KiB

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.2 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: 531 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 KiB

+36
View File
@@ -103,3 +103,39 @@ application/export_angle=0
application/target_os_version="" application/target_os_version=""
application/signature_flags=0 application/signature_flags=0
application/show_bundle_name=1 application/show_bundle_name=1
[preset.3]
name="Web"
platform="Web"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="build/web/index.html"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.3.options]
custom_template/debug=""
custom_template/release=""
variant/extensions_support=false
variant/thread_support=false
vram_texture_compression/for_desktop=true
vram_texture_compression/for_mobile=false
html/export_icon=true
html/custom_html_shell=""
html/head_include=""
html/canvas_resize_policy=2
html/focus_canvas_on_start=true
html/experimental_virtual_keyboard=false
progressive_web_app/enabled=false
+16 -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.2.9" config/version="0.5.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")
@@ -110,6 +110,21 @@ wand={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":67,"key_label":0,"unicode":99,"location":0,"echo":false,"script":null) "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":67,"key_label":0,"unicode":99,"location":0,"echo":false,"script":null)
] ]
} }
walk_slow={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194325,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
cycle_target_next={
"deadzone": 0.5,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":5,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
cycle_target_prev={
"deadzone": 0.5,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":4,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
print_debug={ print_debug={
"deadzone": 0.5, "deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":70,"key_label":0,"unicode":102,"location":0,"echo":false,"script":null) "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":70,"key_label":0,"unicode":102,"location":0,"echo":false,"script":null)
+3
View File
@@ -43,6 +43,9 @@ func save_shift_result(result: Dictionary) -> Error:
const DEFAULT_SETTINGS := { const DEFAULT_SETTINGS := {
"look_sensitivity": 0.0022, "look_sensitivity": 0.0022,
"hud_font_scale": 1.0, "hud_font_scale": 1.0,
"invert_y": false,
"head_bob": true,
"input_bindings": {},
"ai_difficulty": "normal", "ai_difficulty": "normal",
} }
+126
View File
@@ -343,6 +343,28 @@ static func hollywood_building() -> VenueScenario:
_building_routine("staff_entry", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 600), 0.35), _building_routine("staff_entry", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 600), 0.35),
_building_routine("guest_entry", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.25), _building_routine("guest_entry", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.25),
_building_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.24), _building_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.24),
{
"id": "building_filming_boundary",
"phase": Rules.Phase.MIDDAY,
"kind": "scripted",
"npc_type": Rules.NpcType.INFLUENCER,
"intent": "film",
"time_range_s": Vector2(300, 600),
"probability": 0.4,
"base_weight": 1.0,
"hint": "hint.body_camera_reason",
},
{
"id": "building_unclear_intent",
"phase": Rules.Phase.EVENING,
"kind": "scripted",
"npc_type": Rules.NpcType.CONFUSED_VISITOR,
"intent": "enter",
"time_range_s": Vector2(600, 780),
"probability": 0.4,
"base_weight": 1.0,
"hint": "hint.ask_reason",
},
_building_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.26), _building_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.26),
_building_routine("intoxicated_wander", Rules.NpcType.INTOXICATED, "wander", Vector2(600, 900), 0.15), _building_routine("intoxicated_wander", Rules.NpcType.INTOXICATED, "wander", Vector2(600, 900), 0.15),
_building_routine("trespass_test", Rules.NpcType.TRESPASSER, "trespass", Vector2(300, 900), 0.16), _building_routine("trespass_test", Rules.NpcType.TRESPASSER, "trespass", Vector2(300, 900), 0.16),
@@ -437,6 +459,67 @@ static func joke_shop() -> VenueScenario:
_joke_guest("guest_no_ticket", false, true, Vector2(0, 900), 0.18), _joke_guest("guest_no_ticket", false, true, Vector2(0, 900), 0.18),
_joke_guest("guest_expired_id", true, true, Vector2(0, 900), 0.12, {"id_status": Rules.ArtifactStatus.EXPIRED}), _joke_guest("guest_expired_id", true, true, Vector2(0, 900), 0.12, {"id_status": Rules.ArtifactStatus.EXPIRED}),
_joke_guest("guest_no_id", true, false, Vector2(0, 900), 0.12), _joke_guest("guest_no_id", true, false, Vector2(0, 900), 0.12),
{
"id": "joke_guest_reservation_no_id",
"phase": Rules.Phase.EVENING,
"kind": "scripted",
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
"intent": "enter",
"time_range_s": Vector2(620, 860),
"probability": 0.45,
"base_weight": 1.0,
"override_access": {
"has_guest_list": true, "guest_list_status": Rules.ArtifactStatus.VALID,
"has_id": false, "has_ticket": false,
},
"hint": "hint.reservation_no_id",
},
{
"id": "joke_borrowed_id",
"phase": Rules.Phase.MIDDAY,
"kind": "scripted",
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
"intent": "enter",
"time_range_s": Vector2(320, 700),
"probability": 0.4,
"base_weight": 1.0,
"override_access": {
"has_id": true, "id_status": Rules.ArtifactStatus.MISMATCHED,
"has_ticket": true, "ticket_status": Rules.ArtifactStatus.VALID,
},
"hint": "hint.borrowed_id",
},
{
"id": "joke_refuses_wand",
"phase": Rules.Phase.EVENING,
"kind": "scripted",
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
"intent": "enter",
"time_range_s": Vector2(650, 880),
"probability": 0.4,
"base_weight": 1.0,
"refuses_screening": true,
"override_access": {
"has_id": true, "id_status": Rules.ArtifactStatus.VALID,
"has_ticket": true, "ticket_status": Rules.ArtifactStatus.VALID,
},
"hint": "hint.refuses_wand",
},
{
"id": "joke_late_arrival",
"phase": Rules.Phase.EVENING,
"kind": "scripted",
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
"intent": "arrive",
"time_range_s": Vector2(700, 860),
"probability": 0.35,
"base_weight": 1.0,
"override_access": {
"has_id": true, "id_status": Rules.ArtifactStatus.VALID,
"has_ticket": true, "ticket_status": Rules.ArtifactStatus.VALID,
},
"hint": "hint.late_arrival",
},
_joke_staff_entry(Vector2(0, 900), 0.25), _joke_staff_entry(Vector2(0, 900), 0.25),
_joke_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.15), _joke_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.15),
_joke_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.15), _joke_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.15),
@@ -515,6 +598,45 @@ static func tv_studio() -> VenueScenario:
"base_weight": 1.0, "base_weight": 1.0,
"override_access": {"wristband_status": Rules.ArtifactStatus.COUNTERFEIT, "credential_status": Rules.ArtifactStatus.COUNTERFEIT}, "override_access": {"wristband_status": Rules.ArtifactStatus.COUNTERFEIT, "credential_status": Rules.ArtifactStatus.COUNTERFEIT},
}, },
{
"id": "studio_film_boundary",
"phase": Rules.Phase.MIDDAY,
"kind": "scripted",
"npc_type": Rules.NpcType.INFLUENCER,
"intent": "film_boundary",
"time_range_s": Vector2(330, 560),
"probability": 0.5,
"base_weight": 1.0,
"hint": "hint.film_boundary",
},
{
"id": "studio_film_boundary_repeat",
"phase": Rules.Phase.EVENING,
"kind": "scripted",
"npc_type": Rules.NpcType.INFLUENCER,
"intent": "film_boundary",
"time_range_s": Vector2(640, 830),
"probability": 0.45,
"base_weight": 1.0,
"hint": "hint.film_boundary_repeat",
},
{
"id": "studio_celebrity_arrival",
"phase": Rules.Phase.EVENING,
"kind": "scripted",
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
"intent": "arrive",
"time_range_s": Vector2(620, 760),
"probability": 0.5,
"base_weight": 1.0,
"override_access": {"has_guest_list": true, "guest_list_status": Rules.ArtifactStatus.VALID},
"extra_spawns": [
{"npc_type": Rules.NpcType.TOURIST, "intent": "photo"},
{"npc_type": Rules.NpcType.TOURIST, "intent": "photo"},
{"npc_type": Rules.NpcType.INFLUENCER, "intent": "film"},
],
"hint": "hint.celebrity_arrival",
},
{ {
"id": "studio_follow_through", "id": "studio_follow_through",
"phase": Rules.Phase.EVENING, "phase": Rules.Phase.EVENING,
@@ -587,6 +709,10 @@ static func _joke_routine(id: String, npc_type: int, intent: String, time_range:
static func _studio_routine(id: String, npc_type: int, intent: String, time_range: Vector2, prob: float) -> Dictionary: static func _studio_routine(id: String, npc_type: int, intent: String, time_range: Vector2, prob: float) -> Dictionary:
return _building_routine(id, npc_type, intent, time_range, prob) return _building_routine(id, npc_type, intent, time_range, prob)
## Scripted film-boundary intent: the influencer stands past the posted line.
static func _film_boundary_spot() -> String:
return "steps_end"
static func _phase_for_range(time_range: Vector2, duration: float) -> int: static func _phase_for_range(time_range: Vector2, duration: float) -> int:
var start := time_range.x / duration var start := time_range.x / duration
if start < 0.34: if start < 0.34:
+67
View File
@@ -0,0 +1,67 @@
class_name InputBindings
extends RefCounted
## Key/button remaps persisted through the settings panel. Games keep working
## with the defaults in `project.godot`; only actions the player rebinds are
## rewritten, and remaps never remove mouse or gamepad axis events.
const REMAPPABLE := [
"move_forward", "move_back", "move_left", "move_right",
"walk_slow", "inspect", "wand", "radio_emergency", "action_menu",
]
## Turn an input event into a storable descriptor, or {} when it is not a key or
## pad button (mouse buttons and axes are deliberately not remappable here).
static func descriptor_from_event(event: InputEvent) -> Dictionary:
if event is InputEventKey:
var key := event as InputEventKey
var code := key.physical_keycode if key.physical_keycode != 0 else key.keycode
if code == 0:
return {}
return {"type": "key", "code": int(code)}
if event is InputEventJoypadButton:
return {"type": "joy", "button": int((event as InputEventJoypadButton).button_index)}
return {}
static func label_for(descriptor: Dictionary) -> String:
match str(descriptor.get("type", "")):
"key":
return OS.get_keycode_string(int(descriptor.get("code", 0)))
"joy":
return "Pad %d" % int(descriptor.get("button", 0))
_:
return ""
## First key/button currently bound to the action, used to label the UI.
static func current_descriptor(action: String) -> Dictionary:
if not InputMap.has_action(action):
return {}
for event in InputMap.action_get_events(action):
var descriptor := descriptor_from_event(event)
if not descriptor.is_empty():
return descriptor
return {}
## Rewrite the actions present in `bindings`. Existing key/button events for
## those actions are replaced; mouse and joypad-axis events are left alone.
static func apply(bindings: Dictionary) -> void:
for action in bindings.keys():
if not InputMap.has_action(action):
continue
var descriptor: Dictionary = bindings[action]
if descriptor.is_empty():
continue
for event in InputMap.action_get_events(action):
if event is InputEventKey or event is InputEventJoypadButton:
InputMap.action_erase_event(action, event)
InputMap.action_add_event(action, event_for(descriptor))
static func event_for(descriptor: Dictionary) -> InputEvent:
match str(descriptor.get("type", "")):
"joy":
var button := InputEventJoypadButton.new()
button.button_index = int(descriptor.get("button", 0))
return button
_:
var key := InputEventKey.new()
key.physical_keycode = int(descriptor.get("code", 0))
return key
+2
View File
@@ -34,6 +34,8 @@ var guard_noticed := false
## Whether this NPC has passed the handheld wand screening (The Joke Shop). ## Whether this NPC has passed the handheld wand screening (The Joke Shop).
var screened := false var screened := false
## Authored event flag: this person refuses the wand screen (Joke Shop).
var refuses_screening := false
## Whether the guard has declared they recognize this person (a player decision ## Whether the guard has declared they recognize this person (a player decision
## recorded as evidence, PLAN.md: Authorized Access). ## recorded as evidence, PLAN.md: Authorized Access).
var player_recognized := false var player_recognized := false
+57
View File
@@ -34,12 +34,69 @@ static func lookup(key: String) -> String:
_ensure_loaded() _ensure_loaded()
return TABLE.get(key, key) return TABLE.get(key, key)
## Number of `%` conversion placeholders in a template, ignoring `%%` escapes.
static func placeholder_count(template: String) -> int:
var count := 0
var i := 0
while i < template.length():
if template[i] == "%":
if i + 1 < template.length() and template[i + 1] == "%":
i += 2
continue
count += 1
i += 1
return count
## Format a string by key with positional arguments. ## Format a string by key with positional arguments.
##
## GDScript's `%` binds arguments by order, so a key whose placeholder count drifts
## from its call site would push an engine error. Validate first and fail safe: log
## a warning and return the raw template instead of crashing.
static func fmt(key: String, args: Array) -> String: static func fmt(key: String, args: Array) -> String:
var template := lookup(key) var template := lookup(key)
if template == key: if template == key:
return template return template
var needed := placeholder_count(template)
if needed != args.size():
push_warning("Strings.fmt(%s): template needs %d args, got %d" % [key, needed, args.size()])
return template
return template % args return template % args
## Lint the loaded table for formatting hazards. Returns an array of problems.
## Checks: trailing `%`, invalid conversions, and mixed positional (`%1$s`) and
## sequential placeholders in one template.
static func validate() -> Array[String]:
_ensure_loaded()
var problems: Array[String] = []
for key in TABLE.keys():
var template := str(TABLE[key])
var positional := template.contains("$")
var sequential := 0
var i := 0
while i < template.length():
if template[i] != "%":
i += 1
continue
if i + 1 >= template.length():
problems.append("%s: trailing %% escape" % key)
break
var next := template[i + 1]
if next == "%":
i += 2
continue
var j := i + 1
while j < template.length() and template[j] in "0123456789.$":
if template[j] == "$":
positional = true
j += 1
if j >= template.length() or not (template[j] in "sdfxXv"):
problems.append("%s: unsupported conversion near '%%%s'" % [key, template.substr(i + 1, 3)])
elif not positional:
sequential += 1
i = j + 1
if positional and sequential > 0:
problems.append("%s: mixes positional (%%1$s) and sequential (%%s) placeholders" % key)
return problems
func _init() -> void: func _init() -> void:
push_error("Strings is a static utility class; use Strings.lookup / Strings.fmt.") push_error("Strings is a static utility class; use Strings.lookup / Strings.fmt.")
+3 -1
View File
@@ -63,7 +63,9 @@ func _spot_for_intent(intent: String) -> String:
return "entrance" return "entrance"
"photo", "film", "perform", "wander", "roam": "photo", "film", "perform", "wander", "roam":
return "sidewalk" return "sidewalk"
"sit": "sit", "film_boundary":
# film_boundary: an influencer ignoring the posted filming line, so they
# stand at the steps instead of the permitted sidewalk spot.
return "steps_end" return "steps_end"
_: _:
return "sidewalk" return "sidewalk"
+27
View File
@@ -13,6 +13,9 @@ var _bed_player: AudioStreamPlayer = null
var crowd_level: float = 0.0 var crowd_level: float = 0.0
var bed_playing := false var bed_playing := false
var radio_crackles := 0 var radio_crackles := 0
var footsteps := 0
var landings := 0
var precision_ticks := 0
func _ready() -> void: func _ready() -> void:
# Pre-create a few players so overlapping cues don't cut each other off. # Pre-create a few players so overlapping cues don't cut each other off.
@@ -72,6 +75,30 @@ func play_radio_close() -> void:
radio_crackles += 1 radio_crackles += 1
_play_noise(0.06, 0.25, 1400.0) _play_noise(0.06, 0.25, 1400.0)
## Surface-aware footstep: a short filtered burst, lower on asphalt, brighter on
## the stone entrance steps.
func play_footstep(surface: String) -> void:
footsteps += 1
var cutoff := 2600.0
match surface:
"asphalt":
cutoff = 1500.0
"stone":
cutoff = 3400.0
_:
cutoff = 2400.0
_play_noise(0.045, 0.22, cutoff)
## Landing thump after a step or drop.
func play_land() -> void:
landings += 1
_play_tone(92.0, 0.09, 0.4)
## Quiet tick when the precision-walk modifier engages.
func play_precision_tick() -> void:
precision_ticks += 1
_play_tone(1180.0, 0.035, 0.22)
## Play a denial/error cue (low buzz). ## Play a denial/error cue (low buzz).
func play_deny() -> void: func play_deny() -> void:
_play_tone(220.0, 0.20, 0.5) _play_tone(220.0, 0.20, 0.5)
+230 -69
View File
@@ -16,6 +16,8 @@ const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd") const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd")
const VenueScenario := preload("res://src/core/venue_scenario.gd") const VenueScenario := preload("res://src/core/venue_scenario.gd")
const WorldBuilder := preload("res://src/game/world_builder.gd") const WorldBuilder := preload("res://src/game/world_builder.gd")
const Materials := preload("res://src/game/materials.gd")
const InputBindings := preload("res://src/core/input_bindings.gd")
const GuardController := preload("res://src/game/guard_controller.gd") const GuardController := preload("res://src/game/guard_controller.gd")
const NpcAvatar := preload("res://src/game/npc_avatar.gd") const NpcAvatar := preload("res://src/game/npc_avatar.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd") const NpcProfile := preload("res://src/core/npc_profile.gd")
@@ -44,24 +46,34 @@ var _last_phase: int = -1
## structure — phase-based lighting). Night phases switch street lamps on. ## structure — phase-based lighting). Night phases switch street lamps on.
const PHASE_LIGHTING := { const PHASE_LIGHTING := {
Rules.Phase.EARLY: { Rules.Phase.EARLY: {
"sun_energy": 1.2, "sun_angle": -50.0, "sun_color": Color(1.0, 0.98, 0.92), "sun_energy": 1.05, "sun_angle": -50.0, "sun_color": Color(1.0, 0.97, 0.90),
"sky_top": Color(0.32, 0.55, 0.95), "sky_horizon": Color(0.82, 0.88, 0.95), "sky_top": Color(0.32, 0.55, 0.95), "sky_horizon": Color(0.82, 0.88, 0.95),
"sky_ground": Color(0.45, 0.5, 0.55), "ambient": Color(0.75, 0.78, 0.85), "night": false, "sky_ground": Color(0.45, 0.5, 0.55), "ambient": Color(0.75, 0.78, 0.85), "night": false,
"exposure": 0.88, "glow": 0.35, "fog_color": Color(0.80, 0.85, 0.92),
"fog_density": 0.0028, "fog_energy": 0.75, "contrast": 1.04, "saturation": 1.06,
"ambient_energy": 0.45,
}, },
Rules.Phase.MIDDAY: { Rules.Phase.MIDDAY: {
"sun_energy": 1.5, "sun_angle": -62.0, "sun_color": Color(1.0, 1.0, 0.98), "sun_energy": 1.3, "sun_angle": -62.0, "sun_color": Color(1.0, 0.99, 0.96),
"sky_top": Color(0.3, 0.52, 0.95), "sky_horizon": Color(0.85, 0.9, 0.96), "sky_top": Color(0.3, 0.52, 0.95), "sky_horizon": Color(0.85, 0.9, 0.96),
"sky_ground": Color(0.5, 0.54, 0.58), "ambient": Color(0.85, 0.88, 0.95), "night": false, "sky_ground": Color(0.5, 0.54, 0.58), "ambient": Color(0.85, 0.88, 0.95), "night": false,
"exposure": 0.85, "glow": 0.3, "fog_color": Color(0.84, 0.88, 0.94),
"fog_density": 0.0022, "fog_energy": 0.8, "contrast": 1.05, "saturation": 1.05,
"ambient_energy": 0.45,
}, },
Rules.Phase.EVENING: { Rules.Phase.EVENING: {
"sun_energy": 0.7, "sun_angle": -16.0, "sun_color": Color(1.0, 0.72, 0.5), "sun_energy": 0.7, "sun_angle": -16.0, "sun_color": Color(1.0, 0.72, 0.5),
"sky_top": Color(0.62, 0.32, 0.35), "sky_horizon": Color(1.0, 0.68, 0.42), "sky_top": Color(0.62, 0.32, 0.35), "sky_horizon": Color(1.0, 0.68, 0.42),
"sky_ground": Color(0.3, 0.24, 0.3), "ambient": Color(0.55, 0.45, 0.5), "night": true, "sky_ground": Color(0.3, 0.24, 0.3), "ambient": Color(0.55, 0.45, 0.5), "night": true,
"exposure": 1.05, "glow": 0.7, "fog_color": Color(0.95, 0.68, 0.48),
"fog_density": 0.0045, "fog_energy": 0.6, "contrast": 1.08, "saturation": 1.12,
}, },
Rules.Phase.LATE: { Rules.Phase.LATE: {
"sun_energy": 0.05, "sun_angle": -8.0, "sun_color": Color(0.3, 0.35, 0.5), "sun_energy": 0.05, "sun_angle": -8.0, "sun_color": Color(0.3, 0.35, 0.5),
"sky_top": Color(0.02, 0.03, 0.08), "sky_horizon": Color(0.1, 0.12, 0.2), "sky_top": Color(0.02, 0.03, 0.08), "sky_horizon": Color(0.1, 0.12, 0.2),
"sky_ground": Color(0.05, 0.05, 0.08), "ambient": Color(0.08, 0.09, 0.16), "night": true, "sky_ground": Color(0.05, 0.05, 0.08), "ambient": Color(0.08, 0.09, 0.16), "night": true,
"exposure": 1.25, "glow": 0.95, "fog_color": Color(0.10, 0.13, 0.24),
"fog_density": 0.0055, "fog_energy": 0.5, "contrast": 1.1, "saturation": 1.0,
}, },
} }
var hud: Hud = null var hud: Hud = null
@@ -73,6 +85,8 @@ var pause_overlay: PauseOverlay = null
var settings_panel: SettingsPanel = null var settings_panel: SettingsPanel = null
var event_timeline: EventTimeline = null var event_timeline: EventTimeline = null
var _timeline_from_review := false var _timeline_from_review := false
var _queue_tick := 0.0
var _input_device := "keyboard"
var _shift_running := false var _shift_running := false
var player_settings: Dictionary = {} var player_settings: Dictionary = {}
var _focused_avatar: Node3D = null var _focused_avatar: Node3D = null
@@ -103,14 +117,15 @@ var _venue_index := 0
## RNG for venue door-staff lines so repeated interactions vary. ## RNG for venue door-staff lines so repeated interactions vary.
var _door_rng := RandomNumberGenerator.new() var _door_rng := RandomNumberGenerator.new()
## Aim-free targeting: cycle through nearby NPCs with Tab (accessibility). ## Aim-free targeting lives on the guard (`GuardController.nearby_candidates`);
const NEARBY_RADIUS := 12.0 ## this file only reacts to the signals it emits and logs the selection.
## Upper bound on live avatars. Routine traffic is dropped at the cap; scripted ## Upper bound on live avatars. Routine traffic is dropped at the cap; scripted
## events evict the oldest unfocused NPC so authored moments still spawn. ## events evict the oldest unfocused NPC so authored moments still spawn.
const MAX_LIVE_NPCS := 16 const MAX_LIVE_NPCS := 16
var _nearby_index := -1
func _ready() -> void: func _ready() -> void:
_ensure_action_bindings()
_door_rng.randomize() _door_rng.randomize()
player_settings = Save.load_settings() if Save else {} player_settings = Save.load_settings() if Save else {}
scene_root = Node3D.new() scene_root = Node3D.new()
@@ -184,8 +199,25 @@ func _build_guard() -> GuardController:
collider.position = Vector3(0, 0.85, 0) collider.position = Vector3(0, 0.85, 0)
g.add_child(collider) g.add_child(collider)
g.add_to_group("guard") g.add_to_group("guard")
g.footstep.connect(_on_guard_footstep)
g.landed.connect(_on_guard_landed)
g.precision_changed.connect(_on_precision_changed)
return g return g
func _on_guard_footstep(surface: String) -> void:
if audio:
audio.play_footstep(surface)
func _on_guard_landed() -> void:
if audio:
audio.play_land()
func _on_precision_changed(active: bool) -> void:
if hud:
hud.set_precision(active)
if audio and active:
audio.play_precision_tick()
func _build_scenario(id: String) -> VenueScenario: func _build_scenario(id: String) -> VenueScenario:
match id: match id:
"joke_shop": "joke_shop":
@@ -219,7 +251,6 @@ func _reset_shift_state() -> void:
current_incident_npc_id = "" current_incident_npc_id = ""
targeted_npc = null targeted_npc = null
_focused_avatar = null _focused_avatar = null
_nearby_index = -1
inspection_open = false inspection_open = false
wand_open = false wand_open = false
_wand_cooldown = 0.0 _wand_cooldown = 0.0
@@ -272,6 +303,9 @@ func _apply_settings(settings: Dictionary) -> void:
player_settings = settings player_settings = settings
if guard: if guard:
guard.look_sensitivity = float(settings.get("look_sensitivity", 0.0022)) guard.look_sensitivity = float(settings.get("look_sensitivity", 0.0022))
guard.invert_y = bool(settings.get("invert_y", false))
guard.head_bob_enabled = bool(settings.get("head_bob", true))
InputBindings.apply(settings.get("input_bindings", {}))
if hud and hud.has_method("set_font_scale"): if hud and hud.has_method("set_font_scale"):
hud.set_font_scale(float(settings.get("hud_font_scale", 1.0))) hud.set_font_scale(float(settings.get("hud_font_scale", 1.0)))
@@ -429,8 +463,9 @@ func _ensure_lighting() -> void:
sky.sky_material = _sky sky.sky_material = _sky
env.sky = sky env.sky = sky
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
env.ambient_light_energy = 0.5 # Tonemap, glow, fog and colour grading live in SurfaceMaterials so the
env.reflected_light_source = Environment.REFLECTION_SOURCE_SKY # world builder and the environment tune-up stay in one place.
Materials.configure_environment(env, PHASE_LIGHTING[Rules.Phase.EARLY])
_env_node.environment = env _env_node.environment = env
scene_root.add_child(_env_node) scene_root.add_child(_env_node)
@@ -448,6 +483,8 @@ func _apply_phase_lighting() -> void:
_sky.sky_horizon_color = cfg.get("sky_horizon", Color.WHITE) _sky.sky_horizon_color = cfg.get("sky_horizon", Color.WHITE)
_sky.ground_bottom_color = cfg.get("sky_ground", Color(0.5, 0.5, 0.5)) _sky.ground_bottom_color = cfg.get("sky_ground", Color(0.5, 0.5, 0.5))
_sky.ground_horizon_color = cfg.get("sky_horizon", Color.WHITE) _sky.ground_horizon_color = cfg.get("sky_horizon", Color.WHITE)
if _env_node and _env_node.environment:
Materials.configure_environment(_env_node.environment, cfg)
if world_builder: if world_builder:
world_builder.set_night_mode(cfg.get("night", false)) world_builder.set_night_mode(cfg.get("night", false))
@@ -479,8 +516,19 @@ func _spawn_npc(profile: NpcProfile, intent: String, npc_id: String, event: Dict
# Authored events stay for the whole shift; routine traffic cycles out. # Authored events stay for the whole shift; routine traffic cycles out.
avatar.ephemeral = event.get("kind", "routine") == "routine" avatar.ephemeral = event.get("kind", "routine") == "routine"
avatar.lifetime_s = 45.0 + float((npc_counter * 17) % 60) avatar.lifetime_s = 45.0 + float((npc_counter * 17) % 60)
avatar.get_npc_controller().refuses_screening = bool(event.get("refuses_screening", false))
scene_root.add_child(avatar) scene_root.add_child(avatar)
npcs.append(avatar) npcs.append(avatar)
# Authored events can point the player at the right verb (PLAN.md verbs).
if event.has("hint") and hud:
hud.log(Strings.lookup(str(event["hint"])))
# Celebrity arrivals draw extra bystanders, not just one avatar.
for extra in event.get("extra_spawns", []):
var extra_profile := selected_scenario.profile_for(extra.get("npc_type", Rules.NpcType.TOURIST))
if extra_profile == null or npcs.size() >= MAX_LIVE_NPCS:
continue
_spawn_npc(extra_profile, str(extra.get("intent", "photo")),
"%s_w%d" % [npc_id, npcs.size()], {"kind": "routine"})
## Drop references to freed avatars so the cap and Tab cycling stay accurate. ## Drop references to freed avatars so the cap and Tab cycling stay accurate.
func _purge_npcs() -> void: func _purge_npcs() -> void:
@@ -627,16 +675,45 @@ func _on_shift_ended(review: Dictionary) -> void:
shift_review_panel.show_review(review, selected_scenario, Save.load_shift_results() if Save else []) shift_review_panel.show_review(review, selected_scenario, Save.load_shift_results() if Save else [])
_refresh_freeze() _refresh_freeze()
## Keep the people waiting at the door in a deterministic queue: nearest first,
## lateral slots behind the door, so they do not stack on one point.
func _process(delta: float) -> void:
if coordinator == null or npcs.is_empty():
return
_queue_tick -= delta
if _queue_tick > 0.0:
return
_queue_tick = 0.3
_refresh_queue()
func _refresh_queue() -> void:
var waiting: Array = []
for node in npcs:
if not is_instance_valid(node) or node.npc == null:
continue
if node.awaiting_interaction and not node.npc.leave_requested:
waiting.append(node)
else:
node.set_queue_offset(Vector3.ZERO)
waiting.sort_custom(func(a, b) -> bool:
var da: float = a.global_position.distance_to(a.door_position)
var db: float = b.global_position.distance_to(b.door_position)
return da < db)
for index in waiting.size():
waiting[index].set_queue_offset(NpcAvatar.queue_slot_for(index))
func _on_guard_target(target: Node3D) -> void: func _on_guard_target(target: Node3D) -> void:
targeted_npc = target targeted_npc = target
_set_focused_avatar(target) _set_focused_avatar(target)
_begin_incident(target) _begin_incident(target)
_refresh_nearby_hint()
func _on_guard_clear() -> void: func _on_guard_clear() -> void:
targeted_npc = null targeted_npc = null
_set_focused_avatar(null) _set_focused_avatar(null)
hud.set_target_label("") hud.set_target_label("")
hud.set_action_hint("") hud.set_action_hint("")
_refresh_nearby_hint()
func _set_focused_avatar(avatar: Node3D) -> void: func _set_focused_avatar(avatar: Node3D) -> void:
if _focused_avatar and is_instance_valid(_focused_avatar) and _focused_avatar.has_method("set_focus_visual"): if _focused_avatar and is_instance_valid(_focused_avatar) and _focused_avatar.has_method("set_focus_visual"):
@@ -673,9 +750,43 @@ func _begin_incident(avatar: Node3D) -> void:
hint = "Likely authorized. Press E to verify access." hint = "Likely authorized. Press E to verify access."
hud.set_action_hint(hint) hud.set_action_hint(hint)
## Bind the remaining gameplay keys as named actions, so remaps can target them
## (and gamepads can grow onto them). Existing actions are left untouched.
static func _ensure_action_bindings() -> void:
var defaults := {
"polite_direction": KEY_Q,
"toggle_debug": KEY_F,
"toggle_flashlight": KEY_G,
"door_lock": KEY_L,
"body_camera": KEY_B,
"cycle_venue": KEY_V,
}
for action in defaults.keys():
if not InputMap.has_action(action):
InputMap.add_action(action)
var has_key := false
for event in InputMap.action_get_events(action):
if event is InputEventKey:
has_key = true
if has_key:
continue
var key := InputEventKey.new()
key.physical_keycode = defaults[action]
InputMap.action_add_event(action, key)
if InputMap.has_action("cycle_target_next"):
var has_tab := false
for event in InputMap.action_get_events("cycle_target_next"):
if event is InputEventKey and event.physical_keycode == KEY_TAB:
has_tab = true
if not has_tab:
var tab := InputEventKey.new()
tab.physical_keycode = KEY_TAB
InputMap.action_add_event("cycle_target_next", tab)
# ------------------------------------------------------------------ input --- # ------------------------------------------------------------------ input ---
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
_track_input_device(event)
if event is InputEventKey and event.echo: if event is InputEventKey and event.echo:
return return
if event.is_action_pressed("cancel"): if event.is_action_pressed("cancel"):
@@ -687,7 +798,27 @@ func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed: if event is InputEventKey and event.pressed:
_handle_key(event) _handle_key(event)
## Switch the control legend when the player picks up a gamepad or goes back to
## mouse/keyboard. Cheap: only the legend text changes.
func _track_input_device(event: InputEvent) -> void:
var device := ""
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
device = "gamepad"
elif event is InputEventKey or event is InputEventMouse:
device = "keyboard"
if device == "" or device == _input_device:
return
_input_device = device
if hud:
hud.set_input_device(device)
func _handle_mouse_button(event: InputEventMouseButton) -> void: func _handle_mouse_button(event: InputEventMouseButton) -> void:
if event.is_action_pressed("cycle_target_next"):
_cycle_nearby_target(1)
return
if event.is_action_pressed("cycle_target_prev"):
_cycle_nearby_target(-1)
return
if event.button_index != MOUSE_BUTTON_LEFT: if event.button_index != MOUSE_BUTTON_LEFT:
return return
if radial and radial.is_open: if radial and radial.is_open:
@@ -728,53 +859,58 @@ func _handle_key(event: InputEvent) -> void:
if targeted_npc != null and not inspection_open and not wand_open: if targeted_npc != null and not inspection_open and not wand_open:
_toggle_wand_screening() _toggle_wand_screening()
return return
match keycode: if event.is_action_pressed("polite_direction"):
KEY_Q: if targeted_npc != null and not inspection_open:
if targeted_npc != null and not inspection_open: _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "polite direction")
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "polite direction") return
KEY_TAB: if event.is_action_pressed("cycle_target_next"):
_cycle_nearby_target() _cycle_nearby_target(1)
KEY_F: return
hud.toggle_debug() if event.is_action_pressed("cycle_target_prev"):
KEY_G: _cycle_nearby_target(-1)
_toggle_flashlight() return
KEY_L: if event.is_action_pressed("toggle_debug"):
_toggle_door_lock() hud.toggle_debug()
KEY_B: return
_toggle_body_camera() if event.is_action_pressed("toggle_flashlight"):
KEY_V: _toggle_flashlight()
_cycle_venue() return
_: if event.is_action_pressed("door_lock"):
_handle_action_key(keycode) _toggle_door_lock()
return
if event.is_action_pressed("body_camera"):
_toggle_body_camera()
return
if event.is_action_pressed("cycle_venue"):
_cycle_venue()
return
_handle_action_key(event)
func _handle_action_key(keycode: Key) -> void: func _handle_action_key(event: InputEvent) -> void:
match keycode: if event.is_action_pressed("gesture_raise_hand"):
KEY_1: _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand")
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand") elif event.is_action_pressed("gesture_point"):
KEY_2: _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point")
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point") elif event.is_action_pressed("gesture_head_shake"):
KEY_3: _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "head shake")
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "head shake") elif event.is_action_pressed("escalate_warning"):
KEY_4: _apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning")
_apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning") elif event.is_action_pressed("escalate_block"):
KEY_5: _apply_interaction(Rules.Level.BLOCKING, "blocking")
_apply_interaction(Rules.Level.BLOCKING, "blocking") elif event.is_action_pressed("radio_assist"):
KEY_6: _open_radio(false)
_open_radio(false) elif event.is_action_pressed("radio_emergency"):
KEY_R: _open_radio(true)
_open_radio(true) elif event.is_action_pressed("move_left"):
KEY_A: if inspection_open:
if inspection_open: decide_access(true)
decide_access(true) elif wand_open:
elif wand_open: decide_wand(true)
decide_wand(true) elif event.is_action_pressed("move_right"):
KEY_D: if inspection_open:
if inspection_open: decide_access(false)
decide_access(false) elif wand_open:
elif wand_open: decide_wand(false)
decide_wand(false)
_:
pass
## `cancel` (Esc, RMB) closes the top modal. With nothing open, Esc pauses the ## `cancel` (Esc, RMB) closes the top modal. With nothing open, Esc pauses the
## shift (or resumes it when paused); RMB does nothing so it never grabs the ## shift (or resumes it when paused); RMB does nothing so it never grabs the
@@ -783,6 +919,10 @@ func _handle_cancel(from_mouse: bool) -> void:
if _close_top_modal(): if _close_top_modal():
return return
if from_mouse: if from_mouse:
# Right click with nothing open drops the current target, so the player can
# break focus without walking or looking away.
if targeted_npc != null:
_clear_focus()
return return
if settings_panel and settings_panel.visible: if settings_panel and settings_panel.visible:
return return
@@ -963,30 +1103,41 @@ func _cycle_venue() -> void:
hud.set_wand_icon(false) hud.set_wand_icon(false)
hud.set_shift_state(coordinator, selected_scenario) hud.set_shift_state(coordinator, selected_scenario)
## Aim-free targeting: cycle through NPCs near the guard (Tab). Lets players ## Aim-free targeting: cycle through NPCs near the guard (Tab, mouse wheel).
## interact without precise aiming (accessibility). ## Lets players interact without precise aiming (accessibility) and pins the
func _cycle_nearby_target() -> void: ## target so the focus cone does not drop someone the player is not looking at.
func _cycle_nearby_target(direction: int = 1) -> void:
if guard == null: if guard == null:
return return
var nearby: Array = [] var next_target: Node3D = guard.cycle_target(direction)
for avatar in npcs: if next_target == null:
if not is_instance_valid(avatar):
continue
if (avatar.global_position - guard.global_position).length() <= NEARBY_RADIUS:
nearby.append(avatar)
if nearby.is_empty():
hud.log(Strings.lookup("hud.no_one_nearby")) hud.log(Strings.lookup("hud.no_one_nearby"))
_refresh_nearby_hint()
return return
_nearby_index = (_nearby_index + 1) % nearby.size() guard.pin_target(next_target)
var next_target: Node3D = nearby[_nearby_index]
targeted_npc = next_target
_set_focused_avatar(next_target)
_begin_incident(next_target)
var controller: Object = _targeted_controller() var controller: Object = _targeted_controller()
var who: String = next_target.name var who: String = next_target.name
if controller and controller.profile and controller.profile.display_name != "": if controller and controller.profile and controller.profile.display_name != "":
who = str(controller.profile.display_name) who = str(controller.profile.display_name)
hud.log(Strings.fmt("hud.targeting", [who])) hud.log(Strings.fmt("hud.targeting", [who]))
_refresh_nearby_hint()
## Drop the focus and hand selection back to the aim cone (right click).
func _clear_focus() -> void:
if guard:
guard.clear_target()
targeted_npc = null
_set_focused_avatar(null)
if hud:
hud.set_target_label("")
hud.set_action_hint("")
_refresh_nearby_hint()
## How many people the aim-free ring can cycle through right now.
func _refresh_nearby_hint() -> void:
if hud == null or guard == null:
return
hud.set_nearby_hint(guard.nearby_candidates().size())
func _apply_interaction(level: int, action_name: String) -> void: func _apply_interaction(level: int, action_name: String) -> void:
if targeted_npc == null: if targeted_npc == null:
@@ -1238,6 +1389,16 @@ func _toggle_wand_screening() -> void:
var controller: Object = _targeted_controller() var controller: Object = _targeted_controller()
if controller == null: if controller == null:
return return
if controller.refuses_screening:
# Authored event: this guest refuses the screen (Joke Shop content).
hud.set_wand_icon(false)
hud.log(Strings.fmt("wand.refused", [controller.profile.display_name if controller.profile else "The guest"]))
coordinator.recorder.add_event(controller.npc_id, {
"type": "wand_refused",
"outcome": "refused",
})
coordinator.reputation.apply("prop", -1.0, "guest refused screening")
return
var profile: Object = controller.profile var profile: Object = controller.profile
var items: Array = profile.carried_items if profile else [] var items: Array = profile.carried_items if profile else []
var result: Dictionary = wand_screener.screen(items) var result: Dictionary = wand_screener.screen(items)
+244 -7
View File
@@ -4,35 +4,129 @@ extends CharacterBody3D
signal picked_target(target: Node) signal picked_target(target: Node)
signal cleared_target() signal cleared_target()
signal footstep(surface: String)
signal landed()
signal precision_changed(active: bool)
const NpcFocus := preload("res://src/game/npc_focus.gd") const NpcFocus := preload("res://src/game/npc_focus.gd")
var move_speed: float = 3.2 var move_speed: float = 3.2
var look_sensitivity: float = 0.0022 var look_sensitivity: float = 0.0022
var accel: float = 12.0 var accel: float = 22.0
## Separate braking rate: stopping is snappier than starting so the guard does
## not ice-skate into a panel or the door.
var decel: float = 30.0
var invert_y := false
var head_bob_enabled := true
var gamepad_look_sensitivity := 2.6
var is_ready := false var is_ready := false
var targeted_npc: Node3D = null var targeted_npc: Node3D = null
var input_locked := false var input_locked := false
var _miss_frames := 0 var _miss_frames := 0
const MISS_GRACE := 8 const MISS_GRACE := 8
## Loop `Shift` halves the walk speed for close work (inspection, a guest at the
## door). It is a precision modifier, not a sprint (PLAN.md keeps movement plain).
const SLOW_WALK_FACTOR := 0.45
## Aim-free cycling (Tab / mouse wheel) has to survive the focus cone: without a
## pin the cone would drop a target the player is not looking at within a few
## frames.
var target_pinned := false
## After the player clears the focus, the cone stays quiet briefly so the person
## they were looking at is not re-acquired on the very next frame.
var focus_suppress_s := 0.0
const FOCUS_SUPPRESS_S := 0.75
## Radius used by aim-free cycling.
const NEARBY_RADIUS := 12.0
## Maximum kerb/step height the guard can walk up without jumping.
const STEP_HEIGHT := 0.32
## Distance walked per footstep cue.
const STRIDE_LENGTH := 0.85
const GAMEPAD_DEADZONE := 0.2
@onready var camera: Camera3D = $Head/Camera3D @onready var camera: Camera3D = $Head/Camera3D
@onready var head: Node3D = $Head @onready var head: Node3D = $Head
var _camera_base_y := 0.0
var _base_fov := 75.0
var _bob_phase := 0.0
var _stride := 0.0
var _land_dip := 0.0
var _was_on_floor := true
var _precision := false
func _ready() -> void: func _ready() -> void:
floor_snap_length = 0.3 floor_snap_length = 0.45
_camera_base_y = camera.position.y
_base_fov = camera.fov
_ensure_gamepad_bindings()
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
is_ready = true is_ready = true
## Add gamepad axes/buttons to the named actions if they are not bound yet. Keeps
## `project.godot` keyboard-first while making a controller plug-and-play.
static func _ensure_gamepad_bindings() -> void:
var axes := {
"move_forward": [JOY_AXIS_LEFT_Y, -1.0],
"move_back": [JOY_AXIS_LEFT_Y, 1.0],
"move_left": [JOY_AXIS_LEFT_X, -1.0],
"move_right": [JOY_AXIS_LEFT_X, 1.0],
"look_left": [JOY_AXIS_RIGHT_X, -1.0],
"look_right": [JOY_AXIS_RIGHT_X, 1.0],
"look_up": [JOY_AXIS_RIGHT_Y, -1.0],
"look_down": [JOY_AXIS_RIGHT_Y, 1.0],
}
for action in axes.keys():
if not InputMap.has_action(action):
InputMap.add_action(action, GAMEPAD_DEADZONE)
elif InputMap.action_get_deadzone(action) > GAMEPAD_DEADZONE:
InputMap.action_set_deadzone(action, GAMEPAD_DEADZONE)
var axis: int = axes[action][0]
var value: float = axes[action][1]
var bound := false
for event in InputMap.action_get_events(action):
if event is InputEventJoypadMotion and event.axis == axis \
and is_equal_approx(event.axis_value, value):
bound = true
if bound:
continue
var motion := InputEventJoypadMotion.new()
motion.axis = axis
motion.axis_value = value
InputMap.action_add_event(action, motion)
var buttons := {
"action_menu": JOY_BUTTON_A,
"cancel": JOY_BUTTON_B,
"inspect": JOY_BUTTON_X,
"radio_assist": JOY_BUTTON_Y,
"walk_slow": JOY_BUTTON_LEFT_SHOULDER,
"wand": JOY_BUTTON_RIGHT_SHOULDER,
}
for action in buttons.keys():
if not InputMap.has_action(action):
InputMap.add_action(action)
var button: int = buttons[action]
var bound := false
for event in InputMap.action_get_events(action):
if event is InputEventJoypadButton and event.button_index == button:
bound = true
if not bound:
var press := InputEventJoypadButton.new()
press.button_index = button
press.pressed = false
InputMap.action_add_event(action, press)
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if input_locked: if input_locked:
return return
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED: if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
rotate_y(-event.relative.x * look_sensitivity) rotate_y(-event.relative.x * look_sensitivity)
head.rotate_x(-event.relative.y * look_sensitivity) var pitch_sign := 1.0 if invert_y else -1.0
head.rotate_x(pitch_sign * event.relative.y * look_sensitivity)
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0)) head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0))
func _physics_process(_delta: float) -> void: func _physics_process(_delta: float) -> void:
if focus_suppress_s > 0.0:
focus_suppress_s = maxf(0.0, focus_suppress_s - _delta)
if input_locked: if input_locked:
velocity.x = 0.0 velocity.x = 0.0
velocity.z = 0.0 velocity.z = 0.0
@@ -49,28 +143,120 @@ func _physics_process(_delta: float) -> void:
right.y = 0.0 right.y = 0.0
right = right.normalized() right = right.normalized()
_update_gamepad_look(_delta)
var input_dir := Vector2( var input_dir := Vector2(
Input.get_axis("move_left", "move_right"), Input.get_axis("move_left", "move_right"),
Input.get_axis("move_forward", "move_back") Input.get_axis("move_forward", "move_back")
) )
if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED: if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
input_dir = Vector2.ZERO input_dir = Vector2.ZERO
var desired := (forward * input_dir.y + right * input_dir.x) # get_axis(forward, back) is negative when W is held, so negate it to walk
# toward the camera's forward vector instead of moonwalking.
var desired := (forward * -input_dir.y + right * input_dir.x)
if desired.length() > 1.0: if desired.length() > 1.0:
desired = desired.normalized() desired = desired.normalized()
desired *= move_speed var precision := Input.is_action_pressed("walk_slow")
velocity.x = move_toward(velocity.x, desired.x, accel * _delta * move_speed) if precision != _precision:
velocity.z = move_toward(velocity.z, desired.z, accel * _delta * move_speed) _precision = precision
precision_changed.emit(_precision)
var speed := move_speed
if precision:
speed *= SLOW_WALK_FACTOR
desired *= speed
var braking := desired.length() < 0.01
var rate := decel if braking else accel
velocity.x = move_toward(velocity.x, desired.x, rate * _delta)
velocity.z = move_toward(velocity.z, desired.z, rate * _delta)
if is_on_floor(): if is_on_floor():
velocity.y = 0.0 velocity.y = 0.0
_try_step_up(Vector3(velocity.x, 0.0, velocity.z) * _delta)
else: else:
velocity.y -= 9.8 * _delta velocity.y -= 9.8 * _delta
move_and_slide() move_and_slide()
_update_feedback(_delta, speed)
_update_target() _update_target()
## Right-stick look, polled so it keeps turning while held.
func _update_gamepad_look(delta: float) -> void:
if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
return
if not InputMap.has_action("look_left"):
return
var look := Vector2(
Input.get_axis("look_left", "look_right"),
Input.get_axis("look_up", "look_down")
)
if look.length() < GAMEPAD_DEADZONE:
return
rotate_y(-look.x * gamepad_look_sensitivity * delta)
head.rotate_x((1.0 if invert_y else -1.0) * look.y * gamepad_look_sensitivity * delta)
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0))
## Walk up kerbs and the entrance steps without jumping. Tests the blocked
## motion first, then a raised copy, and only commits when the step is short.
func _try_step_up(motion: Vector3) -> void:
if motion.length() < 0.001 or not is_on_floor():
return
var start := global_transform
if not test_move(start, motion):
return
var up := Vector3.UP * STEP_HEIGHT
if test_move(start, up):
return
var raised := start.translated(up)
if test_move(raised, motion):
return
global_transform = raised
if not test_move(global_transform, -up):
global_transform = global_transform.translated(-up)
## Landing dip, head bob, precision FOV and stride-synced footsteps.
func _update_feedback(delta: float, speed: float) -> void:
var on_floor := is_on_floor()
if on_floor and not _was_on_floor:
_land_dip = 0.055
landed.emit()
_was_on_floor = on_floor
var horizontal := Vector2(velocity.x, velocity.z).length()
if on_floor and horizontal > 0.4:
_bob_phase += delta * (5.0 + horizontal)
_stride += horizontal * delta
var stride := STRIDE_LENGTH * (1.35 if _precision else 1.0)
if _stride >= stride:
_stride = 0.0
footstep.emit(_surface_at(global_position))
_land_dip = move_toward(_land_dip, 0.0, delta * 0.55)
var bob := 0.0
if head_bob_enabled and on_floor:
bob = sin(_bob_phase * 2.0) * 0.018 * clampf(horizontal / move_speed, 0.0, 1.0)
camera.position.y = _camera_base_y + bob - _land_dip
var target_fov := _base_fov - (6.0 if _precision and horizontal > 0.1 else 0.0)
camera.fov = lerpf(camera.fov, target_fov, minf(1.0, delta * 8.0))
## Rough surface under the guard for the footstep cue.
func _surface_at(pos: Vector3) -> String:
if pos.z < 4.3 and absf(pos.x) < 5.0:
return "stone"
if pos.z < 12.2:
return "concrete"
return "asphalt"
func _update_target() -> void: func _update_target() -> void:
if camera == null: if camera == null:
return return
# Right after "clear target" the cone stays quiet for a moment, otherwise it
# would re-acquire the person the player just dismissed.
if focus_suppress_s > 0.0:
if targeted_npc != null:
targeted_npc = null
cleared_target.emit()
return
# A pinned target (chosen with Tab or the mouse wheel) is not subject to the
# cone until it is freed or the player clears it.
if target_pinned:
if targeted_npc != null and is_instance_valid(targeted_npc):
return
target_pinned = false
var npcs := get_tree().get_nodes_in_group("npc") if get_tree() else [] var npcs := get_tree().get_nodes_in_group("npc") if get_tree() else []
var heads: Array = [] var heads: Array = []
var avatars: Array = [] var avatars: Array = []
@@ -121,3 +307,54 @@ func _head_visible(from: Vector3, to: Vector3) -> bool:
return true return true
n = n.get_parent() n = n.get_parent()
return false return false
## Avatars within cycling range, nearest first. Aim-free selection (Tab, mouse
## wheel) walks this list so the order is predictable: closest person next.
func nearby_candidates(max_range: float = NEARBY_RADIUS) -> Array:
if get_tree() == null:
return []
var here := global_position
var out: Array = []
for node in get_tree().get_nodes_in_group("npc"):
if not (node is Node3D) or not is_instance_valid(node):
continue
var avatar := node as Node3D
if (avatar.global_position - here).length() <= max_range:
out.append(avatar)
out.sort_custom(func(a: Node3D, b: Node3D) -> bool:
return (a.global_position - here).length_squared() < (b.global_position - here).length_squared())
return out
## Next target in the aim-free ring. `direction` is +1 forward, -1 backward.
## Returns null when nobody is in range (the caller logs "no one nearby").
func cycle_target(direction: int = 1) -> Node3D:
var list := nearby_candidates()
if list.is_empty():
target_pinned = false
return null
var index := list.find(targeted_npc)
var next_index := 0
if index >= 0:
next_index = posmod(index + direction, list.size())
elif direction < 0:
next_index = list.size() - 1
target_pinned = true
return list[next_index]
## Pin a target chosen outside the cone and announce it like a cone pick.
func pin_target(avatar: Node3D) -> void:
target_pinned = avatar != null
# An explicit pick overrides the post-clear suppression window.
focus_suppress_s = 0.0
if targeted_npc == avatar:
return
targeted_npc = avatar
if avatar != null:
picked_target.emit(avatar)
else:
cleared_target.emit()
## Drop the target and hand focus back to the focus cone.
func clear_target() -> void:
target_pinned = false
focus_suppress_s = FOCUS_SUPPRESS_S
+137
View File
@@ -0,0 +1,137 @@
class_name SurfaceMaterials
extends RefCounted
## Shared surface recipes for the procedural world (ADR-0012).
##
## Godot's `StandardMaterial3D` defaults are flat: every surface comes out with
## the same roughness and a plain albedo, which is what made the street read as
## grey boxes. These helpers give each surface a physically plausible profile
## (brick is rough, glass is smooth, metal is metallic) and keep the textures,
## UV scaling, and texture filtering in one place so the world builder, the NPC
## avatars, and the props all look like one scene.
const T_BRICK := preload("res://assets/textures/ai/game_ready/brick_facade.png")
const T_CONCRETE := preload("res://assets/textures/ai/game_ready/concrete.png")
const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.png")
const T_AWNING := preload("res://assets/textures/ai/game_ready/awning.png")
## Roughness / metallic pairs per surface role. Exposed so tests and tooling can
## assert the profiles instead of hard-coding numbers twice.
const PROFILES := {
"brick": {"roughness": 0.88, "metallic": 0.0},
"concrete": {"roughness": 0.94, "metallic": 0.0},
"asphalt": {"roughness": 0.72, "metallic": 0.0},
"awning": {"roughness": 0.80, "metallic": 0.0},
"stone": {"roughness": 0.88, "metallic": 0.0},
"metal": {"roughness": 0.32, "metallic": 0.75},
"painted_metal": {"roughness": 0.48, "metallic": 0.35},
"wood": {"roughness": 0.70, "metallic": 0.0},
"cloth": {"roughness": 0.85, "metallic": 0.0},
"skin": {"roughness": 0.62, "metallic": 0.0},
"glass": {"roughness": 0.12, "metallic": 0.10},
"rubber": {"roughness": 0.95, "metallic": 0.0},
}
static func profile(role: String) -> Dictionary:
return PROFILES.get(role, PROFILES["concrete"])
## Base textured surface with a named profile.
static func textured(texture: Texture2D, tint: Color, uv_scale: Vector2,
role: String = "concrete") -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_texture = texture
mat.albedo_color = tint
mat.uv1_scale = Vector3(uv_scale.x, uv_scale.y, 1.0)
mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC
mat.roughness = float(profile(role).get("roughness", 0.9))
mat.metallic = float(profile(role).get("metallic", 0.0))
return mat
static func brick(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(10, 5)) -> StandardMaterial3D:
return textured(T_BRICK, tint, uv_scale, "brick")
static func concrete(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(9, 2.4)) -> StandardMaterial3D:
return textured(T_CONCRETE, tint, uv_scale, "concrete")
static func asphalt(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(30, 7)) -> StandardMaterial3D:
var mat := textured(T_ASPHALT, tint, uv_scale, "asphalt")
# Damp asphalt at night picks up the street lamps and the marquee.
mat.metallic_specular = 0.6
return mat
static func awning(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(4, 1)) -> StandardMaterial3D:
return textured(T_AWNING, tint, uv_scale, "awning")
## Flat-coloured surface with a profile (props, trim, limbs, clothing).
static func flat(color: Color, role: String = "concrete") -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = color
mat.roughness = float(profile(role).get("roughness", 0.9))
mat.metallic = float(profile(role).get("metallic", 0.0))
return mat
## Self-lit surface (marquee bars, lamp bulbs, lock indicator).
static func emissive(color: Color, energy: float = 1.4) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = color
mat.emission_enabled = true
mat.emission = color
mat.emission_energy_multiplier = energy
mat.roughness = 0.4
return mat
## Dark glass for windows and the entrance door. The alpha keeps the interior
## visible, and the low roughness gives the facade a highlight instead of a hole.
static func glass(color: Color = Color(0.42, 0.56, 0.72, 0.75)) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = color
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.roughness = float(profile("glass").get("roughness", 0.12))
mat.metallic = 0.15
mat.metallic_specular = 0.8
return mat
## Window pane that can be lit from the inside at night. Solid dark glass reads
## as a window at midday; alpha glass just looked like white paper on the brick.
static func window_pane() -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(0.09, 0.12, 0.17)
mat.roughness = 0.18
mat.metallic = 0.35
mat.metallic_specular = 0.6
mat.emission_enabled = true
mat.emission = Color(1.0, 0.86, 0.62)
mat.emission_energy_multiplier = 0.0
return mat
## Environment look shared by every phase: ACES tonemapping, soft glow, aerial
## fog for depth, and a mild contrast/saturation lift. Phase values come from
## `GameManager.PHASE_LIGHTING`.
static func configure_environment(env: Environment, cfg: Dictionary) -> void:
if env == null:
return
env.background_mode = Environment.BG_SKY
env.tonemap_mode = Environment.TONE_MAPPER_ACES
env.tonemap_exposure = float(cfg.get("exposure", 1.0))
env.tonemap_white = 6.0
env.ambient_light_energy = float(cfg.get("ambient_energy", 0.5))
env.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
# Glow makes the marquee, lamp bulbs, and windows read as light sources.
env.glow_enabled = true
env.glow_normalized = true
env.glow_intensity = float(cfg.get("glow", 0.55))
env.glow_strength = 1.0
env.glow_bloom = 0.08
env.glow_hdr_threshold = 1.0
env.glow_blend_mode = Environment.GLOW_BLEND_MODE_SOFTLIGHT
# Aerial perspective: the skyline ring sits ~200 m out, so light fog gives the
# distance its haze without hiding the street.
env.fog_enabled = true
env.fog_light_color = cfg.get("fog_color", Color(0.72, 0.78, 0.88))
env.fog_light_energy = float(cfg.get("fog_energy", 0.7))
env.fog_density = float(cfg.get("fog_density", 0.0035))
env.fog_aerial_perspective = 0.25
env.fog_sky_affect = 0.4
env.adjustment_enabled = true
env.adjustment_brightness = 1.0
env.adjustment_contrast = float(cfg.get("contrast", 1.06))
env.adjustment_saturation = float(cfg.get("saturation", 1.08))
+631 -76
View File
@@ -5,6 +5,7 @@ extends CharacterBody3D
const NPCController := preload("res://src/core/npc_controller.gd") const NPCController := preload("res://src/core/npc_controller.gd")
const Rules := preload("res://src/core/rules.gd") const Rules := preload("res://src/core/rules.gd")
const Materials := preload("res://src/game/materials.gd")
var npc: NPCController = null var npc: NPCController = null
var spawn_position: Vector3 = Vector3.ZERO var spawn_position: Vector3 = Vector3.ZERO
@@ -25,6 +26,11 @@ var _reaction_timer := 0.0
const RUBBERNECK_S := 12.0 const RUBBERNECK_S := 12.0
const FLEE_S := 20.0 const FLEE_S := 20.0
const FLEE_SPEED := 2.4 const FLEE_SPEED := 2.4
## LOD distances in metres. Hysteresis keeps the tier from flickering at a
## boundary; the impostor is used far enough away that the silhouette is all
## that reads.
const LOD_TIERS := [14.0, 26.0]
const LOD_HYSTERESIS := 1.5
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
@@ -63,7 +69,9 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
position = p_spawn position = p_spawn
npc.fsm.state_changed.connect(_on_state_changed) npc.fsm.state_changed.connect(_on_state_changed)
add_to_group("npc") add_to_group("npc")
_idle_style = idle_style_for(profile.npc_type, seed_value)
_build_body(profile.npc_type) _build_body(profile.npc_type)
_build_impostor()
_nav_usable = false _nav_usable = false
_nav_try_frames = 0 _nav_try_frames = 0
agent = NavigationAgent3D.new() agent = NavigationAgent3D.new()
@@ -72,6 +80,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.
@@ -89,73 +106,382 @@ var _rig: Node3D = null
var _head: Node3D = null var _head: Node3D = null
var _bob_phase := 0.0 var _bob_phase := 0.0
var _guard_ref: Node3D = null var _guard_ref: Node3D = null
var _ring: MeshInstance3D = null var _focus_circle: MeshInstance3D = null
var _name_tag: Label3D = null var _name_tag: Label3D = null
## Theater props and limbs for the idle poses (ADR-0010 rig). ## Theater props and limbs for the idle poses (ADR-0010 rig).
var _photo_prop: MeshInstance3D = null var _photo_prop: MeshInstance3D = null
var _sign_prop: MeshInstance3D = null var _sign_prop: MeshInstance3D = null
var _legs: Array[MeshInstance3D] = [] var _legs: Array[Node3D] = []
var _arms: Array[Node3D] = []
var _knees: Array[Node3D] = []
var _elbows: Array[Node3D] = []
var _hands: Array[Node3D] = []
var _theater_t := 0.0 var _theater_t := 0.0
var _idle_t := 0.0
var _idle_style := "breathe"
var _reaction_tag: Label3D = null
var _impostor: Node3D = null
var _detail_nodes: Array[Node3D] = []
var _lod_tier := 0
var _lod_check_s := 0.0
var _queue_offset := Vector3.ZERO
var _height_scale := 1.0
var _shirt_color := Color(0.7, 0.7, 0.7)
var _skin_color := Color(0.88, 0.72, 0.60)
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)
const PANTS_COLOR := Color(0.22, 0.22, 0.26) const PANTS_COLOR := Color(0.22, 0.22, 0.26)
const HAIR_COLORS := [Color(0.12, 0.1, 0.08), Color(0.35, 0.22, 0.12), Color(0.55, 0.42, 0.28), Color(0.15, 0.15, 0.18)] const HAIR_COLORS := [Color(0.12, 0.1, 0.08), Color(0.35, 0.22, 0.12), Color(0.55, 0.42, 0.28), Color(0.15, 0.15, 0.18)]
## Which small accessory a role wears. This is look-only: the player must still
## read intent from behaviour, never from the archetype name (ADR-0010).
const ACCESSORIES := {
Rules.NpcType.AUTHORIZED_STAFF: "cap",
Rules.NpcType.RESPONDER: "cap",
Rules.NpcType.DELIVERY: "cap",
Rules.NpcType.TOURIST: "backpack",
Rules.NpcType.INFLUENCER: "sunglasses",
Rules.NpcType.TRESPASSER: "hood",
Rules.NpcType.VIOLENT_THREAT: "hood",
Rules.NpcType.MENTAL_CRISIS: "hood",
Rules.NpcType.AUTHORIZED_GUEST: "lanyard",
Rules.NpcType.PERFORMER: "none",
Rules.NpcType.PROTESTER: "none",
Rules.NpcType.INTOXICATED: "none",
Rules.NpcType.CONFUSED_VISITOR: "none",
}
const HAIR_STYLES := ["short", "long", "bun", "beard", "bald", "short"]
const HAND_ITEMS := ["phone", "cup", "clipboard", "umbrella", "camera", "none"]
## Outer layer per role/seed. Pure so the same person always dresses the same.
static func layer_for(npc_type: int, seed_value: int) -> String:
match npc_type:
Rules.NpcType.RESPONDER:
return "vest"
Rules.NpcType.DELIVERY:
return "vest"
Rules.NpcType.AUTHORIZED_STAFF:
return "jacket"
Rules.NpcType.INFLUENCER:
return "coat"
Rules.NpcType.PERFORMER:
return "dress" if seed_value % 2 == 0 else "coat"
Rules.NpcType.PROTESTER:
return "jacket"
Rules.NpcType.AUTHORIZED_GUEST:
return "jacket" if seed_value % 3 == 0 else "none"
Rules.NpcType.TRESPASSER, Rules.NpcType.VIOLENT_THREAT:
return "coat"
_:
return "none"
## Deterministic hand item so silhouettes differ without changing the role read.
static func hand_item_for(npc_type: int, seed_value: int) -> String:
match npc_type:
Rules.NpcType.AUTHORIZED_STAFF, Rules.NpcType.DELIVERY:
return "clipboard"
Rules.NpcType.TOURIST:
return "camera"
Rules.NpcType.INFLUENCER:
return "phone"
Rules.NpcType.AUTHORIZED_GUEST:
return "phone" if seed_value % 4 == 0 else "none"
Rules.NpcType.CONFUSED_VISITOR:
return "cup"
Rules.NpcType.PERFORMER, Rules.NpcType.PROTESTER:
return "none"
_:
return HAND_ITEMS[(seed_value * 5 + 1) % 3 + 3] \
if seed_value % 5 == 0 else "none"
## Clothing palette for one avatar. Pure and seed-driven so crowds read as
## individuals and the result is deterministic (tests rely on that).
static func outfit_for(npc_type: int, seed_value: int) -> Dictionary:
var base: Color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
var frac := float((seed_value * 1103515245 + 12345) % 1000) / 1000.0
var shirt := base.lerp(Color(0.92, 0.92, 0.94), frac * 0.22)
shirt = shirt.darkened(frac * 0.12)
# Jeans through khaki/chino, so not every avatar wears the same trousers.
var pants := Color(0.16, 0.18, 0.23).lerp(Color(0.46, 0.38, 0.28), frac)
var hair_index := (seed_value * 7) % HAIR_COLORS.size()
var style_index := (seed_value * 13 + npc_type * 3) % HAIR_STYLES.size()
return {
"shirt": shirt,
"sleeves": shirt.darkened(0.16),
"pants": pants,
"hair": HAIR_COLORS[hair_index],
"hair_style": HAIR_STYLES[style_index],
"skin": SKIN_TONE.darkened(frac * 0.18),
"accessory": ACCESSORIES.get(npc_type, "none"),
"layer": layer_for(npc_type, seed_value),
"hand_item": hand_item_for(npc_type, seed_value),
"height_scale": 0.92 + 0.16 * frac,
"blend": frac,
}
## Idle behaviour style: someone chatting on a phone, arms crossed, looking
## around, or just breathing. Pure so it is deterministic and testable.
static func idle_style_for(npc_type: int, seed_value: int) -> String:
match npc_type:
Rules.NpcType.INFLUENCER, Rules.NpcType.TOURIST:
return "phone"
Rules.NpcType.PROTESTER, Rules.NpcType.MENTAL_CRISIS:
return "look"
Rules.NpcType.AUTHORIZED_STAFF, Rules.NpcType.DELIVERY:
return "clipboard"
Rules.NpcType.TRESPASSER, Rules.NpcType.VIOLENT_THREAT:
return "look"
_:
if seed_value % 3 == 0:
return "crossed"
return "breathe"
## Queue slot behind the door for the nth person waiting, so arrivals stack
## instead of sharing one point.
static func queue_slot_for(index: int) -> Vector3:
var slots := [
Vector3(0.0, 0.0, 0.0),
Vector3(0.85, 0.0, 0.45),
Vector3(-0.85, 0.0, 0.45),
Vector3(0.85, 0.0, 1.15),
Vector3(-0.85, 0.0, 1.15),
Vector3(0.0, 0.0, 1.85),
]
return slots[clampi(index, 0, slots.size() - 1)]
static func lod_tier_for_distance(distance: float) -> int:
if distance < LOD_TIERS[0]:
return 0
if distance < LOD_TIERS[1]:
return 1
return 2
## Short reaction symbol shown above the head: readable crowd state at a glance.
static func reaction_label(mode: String, escalation: int) -> String:
if mode == "flee":
return "!!"
if mode == "watch":
return "?!"
if escalation >= 3:
return "!"
if escalation >= 2:
return "?"
return ""
func _build_body(npc_type: int) -> void: func _build_body(npc_type: int) -> void:
_rig = Node3D.new() _rig = Node3D.new()
_rig.name = "Rig" _rig.name = "Rig"
add_child(_rig) add_child(_rig)
var base_color: Color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7)) var outfit := outfit_for(npc_type, _visual_seed)
# Per-seed variation: slight height and brightness differences so crowds var shirt: Color = outfit["shirt"]
# read as individuals (deterministic LCG, no RNG side effects). var sleeves: Color = outfit["sleeves"]
var frac := float((_visual_seed * 1103515245 + 12345) % 1000) / 1000.0 var pants: Color = outfit["pants"]
var height_scale := 0.92 + 0.16 * frac var skin: Color = outfit["skin"]
_rig.scale.y = height_scale var hair: Color = outfit["hair"]
var shader_delta := 0.12 * frac _height_scale = float(outfit["height_scale"])
base_color = base_color.lightened(shader_delta) if frac < 0.5 else base_color.darkened(shader_delta) _rig.scale.y = _height_scale
var limbs_color := base_color.darkened(0.25) _shirt_color = shirt
# Torso _skin_color = skin
_add_capsule(_rig, 0.30, 0.95, Vector3(0, 1.05, 0), base_color) var limbs_color := sleeves
# Legs # Hips and torso, with a shoulder block so the silhouette is not a tube.
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), PANTS_COLOR)) _add_box_mesh(_rig, Vector3(0.42, 0.64, 0.27), Vector3(0, 1.05, 0), shirt, "cloth")
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), PANTS_COLOR)) _add_box_mesh(_rig, Vector3(0.50, 0.13, 0.26), Vector3(0, 1.38, 0), shirt, "cloth")
# Arms _add_box_mesh(_rig, Vector3(0.38, 0.20, 0.25), Vector3(0, 0.70, 0), pants, "cloth")
_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0) _add_layer(str(outfit["layer"]), shirt)
_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0) # Articulated legs: hip pivot -> thigh -> knee -> shin -> shoe.
# Neck, head, face, hair, shoes so the body reads as a person (ADR-0010). for side in [-1.0, 1.0]:
_add_capsule(_rig, 0.08, 0.16, Vector3(0, 1.48, 0), SKIN_TONE) var hip := Node3D.new()
hip.name = "Hip%s" % ("L" if side < 0.0 else "R")
hip.position = Vector3(0.13 * side, 0.64, 0)
_rig.add_child(hip)
_add_capsule(hip, 0.105, 0.36, Vector3(0, -0.17, 0), pants, 0.0, "cloth")
var knee := Node3D.new()
knee.name = "Knee"
knee.position = Vector3(0, -0.34, 0)
hip.add_child(knee)
_add_capsule(knee, 0.09, 0.32, Vector3(0, -0.15, 0), pants, 0.0, "cloth")
_add_box_mesh(knee, Vector3(0.16, 0.10, 0.30),
Vector3(0, -0.33, 0.05), Color(0.12, 0.12, 0.14), "rubber")
_legs.append(hip)
_knees.append(knee)
# Articulated arms: shoulder pivot -> upper arm -> elbow -> forearm -> hand.
for side in [-1.0, 1.0]:
var shoulder := Node3D.new()
shoulder.name = "Shoulder%s" % ("L" if side < 0.0 else "R")
shoulder.position = Vector3(0.33 * side, 1.38, 0)
_rig.add_child(shoulder)
_add_capsule(shoulder, 0.075, 0.32, Vector3(0, -0.15, 0), limbs_color, 0.0, "cloth")
var elbow := Node3D.new()
elbow.name = "Elbow"
elbow.position = Vector3(0, -0.30, 0)
shoulder.add_child(elbow)
_add_capsule(elbow, 0.065, 0.28, Vector3(0, -0.14, 0), limbs_color, 0.0, "cloth")
var hand := Node3D.new()
hand.name = "Hand"
hand.position = Vector3(0, -0.30, 0.02)
elbow.add_child(hand)
_add_sphere(hand, 0.065, Vector3.ZERO, skin, "skin")
_arms.append(shoulder)
_elbows.append(elbow)
_hands.append(hand)
# Neck, head, face, hair, so the body reads as a person (ADR-0010).
_add_capsule(_rig, 0.08, 0.16, Vector3(0, 1.48, 0), skin, 0.0, "skin")
_head = Node3D.new() _head = Node3D.new()
_head.name = "Head" _head.name = "Head"
_head.position = Vector3(0, 1.62, 0) _head.position = Vector3(0, 1.62, 0)
_add_sphere(_head, 0.17, Vector3.ZERO, SKIN_TONE) _add_sphere(_head, 0.17, Vector3.ZERO, skin, "skin")
var hair_i := (_visual_seed * 7) % HAIR_COLORS.size() _add_ears(_head, skin)
_add_sphere(_head, 0.18, Vector3(0, 0.08, -0.02), HAIR_COLORS[hair_i]) _add_face(_head)
_add_sphere(_head, 0.035, Vector3(-0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1)) _add_hair(_head, str(outfit["hair_style"]), hair)
_add_sphere(_head, 0.035, Vector3(0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1))
_rig.add_child(_head) _rig.add_child(_head)
_add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(-0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14)) _add_accessory(str(outfit["accessory"]), shirt)
_add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14)) _add_hand_item(str(outfit["hand_item"]), skin)
_build_focus_ring() _build_focus_circle()
_add_props(npc_type) _add_props(npc_type)
func _build_focus_ring() -> void: ## Outer layer: jacket, hi-vis vest, coat, or dress over the base shirt.
_ring = MeshInstance3D.new() func _add_layer(kind: String, shirt: Color) -> void:
_ring.name = "FocusRing" match kind:
var torus := TorusMesh.new() "jacket":
torus.inner_radius = 0.26 _add_box_mesh(_rig, Vector3(0.46, 0.54, 0.31), Vector3(0, 1.08, 0),
torus.outer_radius = 0.34 shirt.darkened(0.28), "cloth")
torus.rings = 12 _add_box_mesh(_rig, Vector3(0.05, 0.48, 0.02), Vector3(0, 1.12, 0.165),
torus.ring_segments = 16 shirt.darkened(0.42), "cloth")
_ring.mesh = torus "vest":
_add_box_mesh(_rig, Vector3(0.45, 0.44, 0.30), Vector3(0, 1.06, 0),
Color(0.92, 0.72, 0.12), "cloth")
_add_box_mesh(_rig, Vector3(0.48, 0.06, 0.31), Vector3(0, 0.84, 0),
Color(0.82, 0.82, 0.84), "cloth")
"coat":
_add_box_mesh(_rig, Vector3(0.47, 0.80, 0.33), Vector3(0, 1.01, 0),
shirt.darkened(0.35), "cloth")
_add_box_mesh(_rig, Vector3(0.54, 0.08, 0.30), Vector3(0, 1.41, 0),
shirt.darkened(0.42), "cloth")
_add_box_mesh(_rig, Vector3(0.04, 0.60, 0.02), Vector3(0, 1.02, 0.17),
shirt.darkened(0.5), "cloth")
"dress":
var skirt := CylinderMesh.new()
skirt.top_radius = 0.24
skirt.bottom_radius = 0.38
skirt.height = 0.52
var mi := MeshInstance3D.new()
mi.mesh = skirt
mi.position = Vector3(0, 0.82, 0)
mi.material_override = _material(shirt.darkened(0.1), "cloth")
_rig.add_child(mi)
_:
pass
func _add_ears(head: Node3D, skin: Color) -> void:
for side in [-1.0, 1.0]:
_add_sphere(head, 0.045, Vector3(0.165 * side, 0.0, 0.0), skin, "skin")
func _add_face(head: Node3D) -> void:
var eye := Color(0.08, 0.08, 0.1)
for side in [-1.0, 1.0]:
_add_sphere(head, 0.032, Vector3(0.06 * side, 0.02, 0.145), eye, "glass")
_add_box_mesh(head, Vector3(0.07, 0.015, 0.02),
Vector3(0.06 * side, 0.075, 0.152), Color(0.16, 0.12, 0.10), "cloth")
_add_box_mesh(head, Vector3(0.09, 0.015, 0.02), Vector3(0, -0.075, 0.15),
Color(0.42, 0.22, 0.20), "cloth")
func _add_hair(head: Node3D, style: String, color: Color) -> void:
match style:
"long":
_add_sphere(head, 0.185, Vector3(0, 0.07, -0.02), color, "cloth")
_add_box_mesh(head, Vector3(0.30, 0.36, 0.12), Vector3(0, -0.14, -0.11),
color, "cloth")
"bun":
_add_sphere(head, 0.185, Vector3(0, 0.07, -0.02), color, "cloth")
_add_sphere(head, 0.09, Vector3(0, 0.20, -0.10), color, "cloth")
"beard":
_add_sphere(head, 0.185, Vector3(0, 0.07, -0.02), color, "cloth")
_add_box_mesh(head, Vector3(0.22, 0.14, 0.10), Vector3(0, -0.11, 0.09),
color.darkened(0.1), "cloth")
"bald":
pass
_:
_add_sphere(head, 0.185, Vector3(0, 0.07, -0.02), color, "cloth")
## Hand-held item attached to the right hand socket: phone, cup, clipboard,
## umbrella, camera, or nothing.
func _add_hand_item(kind: String, skin: Color) -> void:
if _hands.size() < 2:
return
var hand: Node3D = _hands[1]
match kind:
"phone":
_detail(_add_box_mesh(hand, Vector3(0.055, 0.11, 0.012), Vector3(0, -0.03, 0.02),
Color(0.10, 0.11, 0.14), "glass"))
"cup":
_detail(_add_capsule(hand, 0.035, 0.10, Vector3(0, -0.05, 0.02),
Color(0.90, 0.88, 0.82), 0.0, "cloth"))
"clipboard":
_detail(_add_box_mesh(hand, Vector3(0.22, 0.28, 0.02), Vector3(0, -0.08, 0.05),
Color(0.55, 0.40, 0.22), "wood"))
_detail(_add_box_mesh(hand, Vector3(0.16, 0.02, 0.01), Vector3(0, 0.05, 0.07),
Color(0.85, 0.85, 0.88), "metal"))
"umbrella":
_detail(_add_capsule(hand, 0.014, 0.85, Vector3(0, -0.30, 0.02),
Color(0.15, 0.16, 0.20), 0.0, "metal"))
var canopy := CylinderMesh.new()
canopy.top_radius = 0.02
canopy.bottom_radius = 0.34
canopy.height = 0.22
var mi := MeshInstance3D.new()
mi.mesh = canopy
mi.position = Vector3(0, 0.12, 0.02)
mi.material_override = _material(skin.lerp(Color(0.2, 0.25, 0.4), 0.7), "cloth")
hand.add_child(mi)
_detail(mi)
"camera":
_detail(_add_box_mesh(hand, Vector3(0.16, 0.10, 0.09), Vector3(0, -0.04, 0.02),
Color(0.10, 0.10, 0.12), "metal"))
_detail(_add_sphere(hand, 0.045, Vector3(0, -0.02, 0.08), Color(0.2, 0.35, 0.5), "glass"))
## Small role accessory: cap, hood, backpack, sunglasses or a staff lanyard.
func _add_accessory(kind: String, shirt: Color) -> void:
match kind:
"cap":
_detail(_add_sphere(_head, 0.175, Vector3(0, 0.12, 0), shirt.darkened(0.15), "cloth"))
_detail(_add_box_mesh(_head, Vector3(0.30, 0.03, 0.16), Vector3(0, 0.10, 0.16), shirt.darkened(0.22), "cloth"))
"hood":
_detail(_add_sphere(_head, 0.205, Vector3(0, 0.03, -0.03), Color(0.16, 0.16, 0.18), "cloth"))
"backpack":
_detail(_add_box_mesh(_rig, Vector3(0.34, 0.42, 0.16), Vector3(0, 1.06, -0.24), Color(0.24, 0.28, 0.36), "cloth"))
_detail(_add_box_mesh(_rig, Vector3(0.30, 0.06, 0.02), Vector3(0, 1.24, -0.33), Color(0.42, 0.44, 0.5), "cloth"))
"sunglasses":
_detail(_add_box_mesh(_head, Vector3(0.24, 0.05, 0.03), Vector3(0, 0.04, 0.15), Color(0.06, 0.06, 0.08), "glass"))
"lanyard":
_detail(_add_box_mesh(_rig, Vector3(0.05, 0.06, 0.02), Vector3(0, 1.20, 0.16), Color(0.85, 0.78, 0.30), "painted_metal"))
_detail(_add_box_mesh(_rig, Vector3(0.02, 0.20, 0.02), Vector3(-0.08, 1.34, 0.14), Color(0.3, 0.32, 0.36), "cloth"))
_detail(_add_box_mesh(_rig, Vector3(0.02, 0.20, 0.02), Vector3(0.08, 1.34, 0.14), Color(0.3, 0.32, 0.36), "cloth"))
_:
pass
func _build_focus_circle() -> void:
_focus_circle = MeshInstance3D.new()
_focus_circle.name = "FocusCircle"
# Blue, semi-transparent disc floating in front of the face (ADR-0008).
# Billboarded so it always reads as a circle on screen.
var disc := CylinderMesh.new()
disc.top_radius = 0.15
disc.bottom_radius = 0.15
disc.height = 0.001
disc.radial_segments = 24
_focus_circle.mesh = disc
var mat := StandardMaterial3D.new() var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.albedo_color = Color(0.95, 0.82, 0.28, 0.9) mat.albedo_color = Color(0.25, 0.55, 1.0, 0.45)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
_ring.material_override = mat mat.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
_ring.position = Vector3(0, 0.28, 0) _focus_circle.material_override = mat
_ring.visible = false _focus_circle.position = Vector3(0, 0, 0.30)
_head.add_child(_ring) _focus_circle.visible = false
_head.add_child(_focus_circle)
_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 ""
@@ -166,18 +492,27 @@ func _build_focus_ring() -> void:
_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)
_reaction_tag = Label3D.new()
_reaction_tag.name = "ReactionTag"
_reaction_tag.font_size = 72
_reaction_tag.pixel_size = 0.005
_reaction_tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
_reaction_tag.position = Vector3(0, 2.05, 0)
_reaction_tag.visible = false
_rig.add_child(_reaction_tag)
func set_focus_visual(focused: bool, menu_open: bool = false) -> void: func set_focus_visual(focused: bool, menu_open: bool = false) -> void:
if _ring: if _focus_circle:
_ring.visible = focused _focus_circle.visible = focused
var mat := _ring.material_override as StandardMaterial3D var mat := _focus_circle.material_override as StandardMaterial3D
if mat: if mat:
mat.albedo_color = Color(1.0, 0.92, 0.35, 1.0) if menu_open else Color(0.95, 0.82, 0.28, 0.85) mat.albedo_color = Color(0.35, 0.65, 1.0, 0.7) if menu_open else Color(0.25, 0.55, 1.0, 0.45)
_ring.scale = Vector3.ONE * (1.15 if menu_open else 1.0) _focus_circle.scale = Vector3.ONE * (1.15 if menu_open else 1.0)
if _name_tag: if _name_tag:
_name_tag.visible = focused _name_tag.visible = focused
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> MeshInstance3D: func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3,
color: Color, z_rot: float = 0.0, role: String = "cloth") -> MeshInstance3D:
var mi := MeshInstance3D.new() var mi := MeshInstance3D.new()
var mesh := CapsuleMesh.new() var mesh := CapsuleMesh.new()
mesh.radius = radius mesh.radius = radius
@@ -185,24 +520,40 @@ func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, co
mi.mesh = mesh mi.mesh = mesh
mi.position = pos mi.position = pos
mi.rotation_degrees.z = z_rot mi.rotation_degrees.z = z_rot
mi.material_override = _material(color) mi.material_override = _material(color, role)
parent.add_child(mi) parent.add_child(mi)
return mi return mi
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color) -> void: func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color,
role: String = "skin") -> MeshInstance3D:
var mi := MeshInstance3D.new() var mi := MeshInstance3D.new()
var mesh := SphereMesh.new() var mesh := SphereMesh.new()
mesh.radius = radius mesh.radius = radius
mesh.height = radius * 2.0 mesh.height = radius * 2.0
mi.mesh = mesh mi.mesh = mesh
mi.position = pos mi.position = pos
mi.material_override = _material(color) mi.material_override = _material(color, role)
parent.add_child(mi) parent.add_child(mi)
return mi
func _material(color: Color) -> StandardMaterial3D: ## Register a small dress detail so the mid-distance LOD can hide it.
var mat := StandardMaterial3D.new() func _detail(node: Node3D) -> Node3D:
mat.albedo_color = color _detail_nodes.append(node)
return mat return node
func _add_box_mesh(parent: Node3D, size: Vector3, pos: Vector3, color: Color,
role: String = "cloth") -> MeshInstance3D:
var mi := MeshInstance3D.new()
var mesh := BoxMesh.new()
mesh.size = size
mi.mesh = mesh
mi.position = pos
mi.material_override = _material(color, role)
parent.add_child(mi)
return mi
func _material(color: Color, role: String = "cloth") -> StandardMaterial3D:
return Materials.flat(color, role)
## Small per-archetype props so silhouettes differ (camera, box, hat, sign…). ## Small per-archetype props so silhouettes differ (camera, box, hat, sign…).
func _add_props(npc_type: int) -> void: func _add_props(npc_type: int) -> void:
@@ -311,6 +662,12 @@ func _face_reaction_point(delta: float) -> void:
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
if npc == null or npc.fsm == null: if npc == null or npc.fsm == null:
return return
_tick_lod(delta)
if awaiting_interaction and _queue_offset != Vector3.ZERO:
var queued := door_position + _queue_offset
if target_position.distance_to(queued) > 0.05:
target_position = queued
arrived = false
_advance_fsm(delta) _advance_fsm(delta)
_tick_reaction(delta) _tick_reaction(delta)
if reaction_mode == "watch": if reaction_mode == "watch":
@@ -355,6 +712,11 @@ static func prop_lift_for_pose(pose: String, phase: float) -> float:
return 0.0 return 0.0
return maxf(0.0, sin(phase * 1.7)) * 0.16 return maxf(0.0, sin(phase * 1.7)) * 0.16
## Opposite arm/leg swing for the walk cycle. `side` is 0 or 1; pure so the gait
## can be checked without a scene tree.
static func limb_swing(phase: float, side: int, amplitude: float) -> float:
return sin(phase + float(side) * PI) * amplitude
## Sway amount for a performer playing to the sidewalk. ## Sway amount for a performer playing to the sidewalk.
static func sway_for_pose(pose: String, phase: float) -> float: static func sway_for_pose(pose: String, phase: float) -> float:
if pose != "perform": if pose != "perform":
@@ -366,16 +728,32 @@ func _animate(delta: float) -> void:
return return
var speed := Vector2(velocity.x, velocity.z).length() var speed := Vector2(velocity.x, velocity.z).length()
var pose := theater_pose() var pose := theater_pose()
var escalation: int = npc.highest_escalation_received if npc != null else 0
var crouch := crouch_for_pose(pose) var crouch := crouch_for_pose(pose)
if speed > 0.3: if speed > 0.3:
_bob_phase += delta * (6.0 + speed) _idle_t = 0.0
_rig.position.y = sin(_bob_phase) * 0.035 _bob_phase += delta * (6.0 + speed * 1.7)
_rig.rotation.x = sin(_bob_phase * 0.5) * 0.04 var urgency := 1.35 if reaction_mode == "flee" else 1.0
var stride := clampf(0.36 + speed * 0.10, 0.42, 0.7) * urgency
_rig.position.y = sin(_bob_phase * 2.0) * 0.03
_rig.position.x = 0.0
_rig.rotation.x = sin(_bob_phase) * 0.03
_rig.rotation.z = sin(_bob_phase) * 0.02
for i in _legs.size():
var swing := limb_swing(_bob_phase, i, stride)
_legs[i].rotation.x = swing
# Bend the knee as the leg travels behind the body.
var bend := maxf(0.0, -swing) * (1.7 if reaction_mode == "flee" else 1.15)
_knees[i].rotation.x = lerpf(_knees[i].rotation.x, bend, 0.35)
_arms[i].rotation.x = limb_swing(_bob_phase, i, -0.34 * urgency)
_elbows[i].rotation.x = -0.18 - absf(swing) * 0.55
elif pose != "idle" or not is_zero_approx(_rig.position.y): elif pose != "idle" or not is_zero_approx(_rig.position.y):
# Idle theater: seated, photographing, filming, or performing. # Idle theater: seated, photographing, filming, or performing.
_theater_t += delta _theater_t += delta
_idle_t = 0.0
_bob_phase = 0.0 _bob_phase = 0.0
_rig.position.y = lerpf(_rig.position.y, crouch, 0.12) _rig.position.y = lerpf(_rig.position.y, crouch, 0.12)
_rig.position.x = lerpf(_rig.position.x, 0.0, 0.2)
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2) _rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
var lift := prop_lift_for_pose(pose, _theater_t) var lift := prop_lift_for_pose(pose, _theater_t)
if _photo_prop: if _photo_prop:
@@ -390,25 +768,182 @@ func _animate(delta: float) -> void:
var leg_angle := deg_to_rad(-78.0) if pose == "sit" else 0.0 var leg_angle := deg_to_rad(-78.0) if pose == "sit" else 0.0
_legs[0].rotation.x = lerpf(_legs[0].rotation.x, leg_angle, 0.12) _legs[0].rotation.x = lerpf(_legs[0].rotation.x, leg_angle, 0.12)
_legs[1].rotation.x = lerpf(_legs[1].rotation.x, leg_angle, 0.12) _legs[1].rotation.x = lerpf(_legs[1].rotation.x, leg_angle, 0.12)
var knee_angle := deg_to_rad(80.0) if pose == "sit" else 0.0
_knees[0].rotation.x = lerpf(_knees[0].rotation.x, knee_angle, 0.12)
_knees[1].rotation.x = lerpf(_knees[1].rotation.x, knee_angle, 0.12)
for arm in _arms:
arm.rotation.x = lerpf(arm.rotation.x, 0.0, 0.14)
for elbow in _elbows:
elbow.rotation.x = lerpf(elbow.rotation.x, -0.1, 0.14)
else: else:
_bob_phase = 0.0 _bob_phase = 0.0
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2) _rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2) _rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
if _ring and _ring.visible: for i in _legs.size():
var cam := get_viewport().get_camera_3d() if get_viewport() else null _legs[i].rotation.x = lerpf(_legs[i].rotation.x, 0.0, 0.2)
if cam: _knees[i].rotation.x = lerpf(_knees[i].rotation.x, 0.0, 0.2)
_ring.look_at(cam.global_position, Vector3.UP) for i in _arms.size():
_arms[i].rotation.x = lerpf(_arms[i].rotation.x, 0.0, 0.18)
_elbows[i].rotation.x = lerpf(_elbows[i].rotation.x, -0.08, 0.18)
_idle_t += delta
_idle_layer(pose, escalation)
# The focus circle billboards via its material; no per-frame look_at needed.
# Attention cue: head turns toward the guard when awareness was raised. # Attention cue: head turns toward the guard when awareness was raised.
if _head != null and npc.guard_noticed: if _head != null:
if _guard_ref == null or not is_instance_valid(_guard_ref): if npc != null and npc.guard_noticed:
_guard_ref = get_tree().get_first_node_in_group("guard") as Node3D if _guard_ref == null or not is_instance_valid(_guard_ref):
if _guard_ref: _guard_ref = get_tree().get_first_node_in_group("guard") as Node3D
var to_guard := (_guard_ref.global_position - global_position) if _guard_ref:
to_guard.y = 0.0 var to_guard := (_guard_ref.global_position - global_position)
var world_yaw := atan2(to_guard.x, to_guard.z) to_guard.y = 0.0
var fwd := -_rig.global_transform.basis.z var world_yaw := atan2(to_guard.x, to_guard.z)
var parent_yaw := atan2(fwd.x, fwd.z) var fwd := -_rig.global_transform.basis.z
_head.rotation.y = lerp_angle(_head.rotation.y, world_yaw - parent_yaw, 0.1) var parent_yaw := atan2(fwd.x, fwd.z)
_head.rotation.y = lerp_angle(_head.rotation.y, world_yaw - parent_yaw, 0.1)
elif speed <= 0.3 and pose == "idle":
# Idle look-around, unless the person is already engaged.
_head.rotation.y = lerpf(_head.rotation.y, sin(_idle_t * 0.45) * 0.3, 0.05)
_update_reaction_label(escalation)
## Idle behaviour layered on top of the breathing pose: phone, clipboard,
## crossed arms, or looking around, plus escalation body language.
func _idle_layer(pose: String, escalation: int) -> void:
if _rig == null:
return
# Breathing and a slow weight shift keep idle people from looking frozen.
var breath := 1.0 + sin(_idle_t * 1.8) * 0.006
_rig.scale.y = lerpf(_rig.scale.y, _height_scale * breath, 0.15)
_rig.position.x = lerpf(_rig.position.x, sin(_idle_t * 0.7) * 0.015, 0.1)
_rig.rotation.z = lerpf(_rig.rotation.z, sin(_idle_t * 0.7) * 0.015, 0.1)
if pose != "idle" or _arms.size() < 2:
return
var right := _arms[1]
var right_elbow := _elbows[1]
if escalation >= 3:
# Both hands up: the person is being blocked or warned.
_arms[0].rotation.x = lerpf(_arms[0].rotation.x, -1.75, 0.14)
right.rotation.x = lerpf(right.rotation.x, -1.75, 0.14)
_elbows[0].rotation.x = lerpf(_elbows[0].rotation.x, -0.5, 0.14)
right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -0.5, 0.14)
_rig.rotation.x = lerpf(_rig.rotation.x, -0.06, 0.1)
return
if escalation >= 2:
# One arm out, pointing forward while arguing.
right.rotation.x = lerpf(right.rotation.x, -1.15, 0.12)
right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -0.15, 0.12)
return
match _idle_style:
"phone":
# Phone held up near the head in bursts.
var raise := 0.55 + maxf(0.0, sin(_idle_t * 0.6)) * 0.45
right.rotation.x = lerpf(right.rotation.x, -1.35 * raise, 0.08)
right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -1.75 * raise, 0.1)
"clipboard":
_arms[0].rotation.x = lerpf(_arms[0].rotation.x, -0.55, 0.1)
_elbows[0].rotation.x = lerpf(_elbows[0].rotation.x, -0.9, 0.1)
right.rotation.x = lerpf(right.rotation.x, -0.45, 0.1)
right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -0.8, 0.1)
"crossed":
_arms[0].rotation.x = lerpf(_arms[0].rotation.x, -0.55, 0.1)
right.rotation.x = lerpf(right.rotation.x, -0.55, 0.1)
_elbows[0].rotation.x = lerpf(_elbows[0].rotation.x, -1.25, 0.1)
right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -1.25, 0.1)
"look":
pass
_:
pass
func _update_reaction_label(escalation: int) -> void:
if _reaction_tag == null:
return
var text := reaction_label(reaction_mode, escalation)
_reaction_tag.visible = text != ""
if text == "":
return
_reaction_tag.text = text
if reaction_mode == "flee":
_reaction_tag.modulate = Color(1.0, 0.26, 0.2)
elif reaction_mode == "watch":
_reaction_tag.modulate = Color(1.0, 0.72, 0.2)
elif escalation >= 3:
_reaction_tag.modulate = Color(1.0, 0.35, 0.3)
else:
_reaction_tag.modulate = Color(1.0, 0.85, 0.4)
## Build the far-distance impostor: two billboard quads (body + head) in the
## avatar's clothing colours.
func _build_impostor() -> void:
_impostor = Node3D.new()
_impostor.name = "Impostor"
_impostor.visible = false
add_child(_impostor)
var body := MeshInstance3D.new()
var quad := QuadMesh.new()
quad.size = Vector2(0.56, 1.15)
body.mesh = quad
body.position = Vector3(0, 0.95, 0)
body.material_override = _billboard_material(_shirt_color)
_impostor.add_child(body)
var head := MeshInstance3D.new()
var head_quad := QuadMesh.new()
head_quad.size = Vector2(0.34, 0.34)
head.mesh = head_quad
head.position = Vector3(0, 1.63, 0)
head.material_override = _billboard_material(_skin_color)
_impostor.add_child(head)
func _billboard_material(color: Color) -> StandardMaterial3D:
var material := StandardMaterial3D.new()
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.albedo_color = color
material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
return material
## Apply a LOD tier: 0 full detail, 1 no small props/shadows, 2 impostor.
func apply_lod(tier: int) -> void:
_lod_tier = clampi(tier, 0, 2)
if _rig != null:
_rig.visible = _lod_tier < 2
if _impostor != null:
_impostor.visible = _lod_tier == 2
for node in _detail_nodes:
if is_instance_valid(node):
node.visible = _lod_tier == 0
var shadows := _lod_tier == 0
for node in find_children("*", "MeshInstance3D", true, false):
(node as MeshInstance3D).cast_shadow = \
GeometryInstance3D.SHADOW_CASTING_SETTING_ON if shadows \
else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
func _lod_tier_for(distance: float) -> int:
# Hysteresis: stay in the current tier until the distance clears the edge.
if _lod_tier == 0:
return 1 if distance > LOD_TIERS[0] + LOD_HYSTERESIS else 0
if _lod_tier == 1:
if distance < LOD_TIERS[0] - LOD_HYSTERESIS:
return 0
return 2 if distance > LOD_TIERS[1] + LOD_HYSTERESIS else 1
if distance < LOD_TIERS[1] - LOD_HYSTERESIS:
return 1 if distance >= LOD_TIERS[0] else 0
return 2
func set_queue_offset(offset: Vector3) -> void:
_queue_offset = offset
func _tick_lod(delta: float) -> void:
_lod_check_s -= delta
if _lod_check_s > 0.0:
return
_lod_check_s = 0.5
if not is_inside_tree():
return
var camera := get_viewport().get_camera_3d()
if camera == null:
return
var distance := global_position.distance_to(camera.global_position)
var desired := _lod_tier_for(distance)
if desired != _lod_tier:
apply_lod(desired)
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 # Routine traffic despawns after a while once it has finished its walk, so
@@ -437,6 +972,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
@@ -485,14 +1021,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()
+18 -6
View File
@@ -20,14 +20,26 @@ static func score_head(cam_origin: Vector3, cam_forward: Vector3, head_pos: Vect
return ang return ang
static func pick_index(cam_origin: Vector3, cam_forward: Vector3, head_positions: Array, max_range: float = MAX_RANGE, max_angle: float = MAX_ANGLE_RAD) -> int: static func pick_index(cam_origin: Vector3, cam_forward: Vector3, head_positions: Array, max_range: float = MAX_RANGE, max_angle: float = MAX_ANGLE_RAD) -> int:
var best_i := -1 var ranked := rank_indices(cam_origin, cam_forward, head_positions, max_range, max_angle)
var best_score := 999.0 return ranked[0] if not ranked.is_empty() else -1
## Every candidate inside the cone, best first: smallest angle to the crosshair,
## then nearest. The cone used to return position in the group, which made the
## pick order depend on spawn order; ranking by angle matches what the player
## sees and gives `Tab` / the mouse wheel a stable order too.
static func rank_indices(cam_origin: Vector3, cam_forward: Vector3, head_positions: Array, max_range: float = MAX_RANGE, max_angle: float = MAX_ANGLE_RAD) -> Array:
var scored: Array = []
for i in head_positions.size(): for i in head_positions.size():
var pos: Vector3 = head_positions[i] var pos: Vector3 = head_positions[i]
var score := score_head(cam_origin, cam_forward, pos, max_range, max_angle) var score := score_head(cam_origin, cam_forward, pos, max_range, max_angle)
if score < 0.0: if score < 0.0:
continue continue
if score < best_score: scored.append([score, (pos - cam_origin).length(), i])
best_score = score scored.sort_custom(func(a: Array, b: Array) -> bool:
best_i = i if not is_equal_approx(float(a[0]), float(b[0])):
return best_i return float(a[0]) < float(b[0])
return float(a[1]) < float(b[1]))
var ranked: Array = []
for entry in scored:
ranked.append(int(entry[2]))
return ranked
+47 -1
View File
@@ -26,6 +26,7 @@ const CARD_TEXTURES := {
var top_label: Label var top_label: Label
var time_label: Label var time_label: Label
var target_label: Label var target_label: Label
var nearby_label: Label
var hint_label: Label var hint_label: Label
var log_label: Label var log_label: Label
var debug_label: Label var debug_label: Label
@@ -44,6 +45,8 @@ var wand_panel: PanelContainer
var wand_text: Label var wand_text: Label
var wand_meta: Label var wand_meta: Label
var escalation_label: Label var escalation_label: Label
var precision_label: Label
var _controls_label: Label
var _debug_enabled := false var _debug_enabled := false
var _log_lines: Array[String] = [] var _log_lines: Array[String] = []
@@ -85,6 +88,15 @@ func _init() -> void:
target_label.add_theme_font_size_override("font_size", 16) target_label.add_theme_font_size_override("font_size", 16)
vbox.add_child(target_label) vbox.add_child(target_label)
nearby_label = Label.new()
nearby_label.add_theme_font_size_override("font_size", 12)
nearby_label.modulate = Color(0.92, 0.95, 1.0)
# A shadow keeps the hint readable over the bright sidewalk and the sky.
nearby_label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.7))
nearby_label.add_theme_constant_override("shadow_offset_x", 1)
nearby_label.add_theme_constant_override("shadow_offset_y", 1)
vbox.add_child(nearby_label)
hint_label = Label.new() hint_label = Label.new()
hint_label.modulate = Color(1, 0.9, 0.6) hint_label.modulate = Color(1, 0.9, 0.6)
hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
@@ -109,7 +121,9 @@ func _init() -> void:
controls.text = Strings.lookup("hud.controls") controls.text = Strings.lookup("hud.controls")
controls.add_theme_font_size_override("font_size", 12) controls.add_theme_font_size_override("font_size", 12)
controls.modulate = Color(0.8, 0.8, 0.85) controls.modulate = Color(0.8, 0.8, 0.85)
controls.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
vbox.add_child(controls) vbox.add_child(controls)
_controls_label = controls
# --- emergency radio cooldown indicator --- # --- emergency radio cooldown indicator ---
radio_cooldown_label = Label.new() radio_cooldown_label = Label.new()
@@ -123,6 +137,12 @@ func _init() -> void:
mode_label.text = "Door: open · Light: off" mode_label.text = "Door: open · Light: off"
mode_label.add_theme_font_size_override("font_size", 13) mode_label.add_theme_font_size_override("font_size", 13)
mode_label.modulate = Color(0.85, 0.85, 0.9) mode_label.modulate = Color(0.85, 0.85, 0.9)
precision_label = Label.new()
precision_label.text = Strings.lookup("hud.precision")
precision_label.add_theme_font_size_override("font_size", 12)
precision_label.modulate = Color(0.55, 0.85, 1.0)
precision_label.visible = false
vbox.add_child(precision_label)
vbox.add_child(mode_label) vbox.add_child(mode_label)
# --- escalation level indicator (color-coded) --- # --- escalation level indicator (color-coded) ---
@@ -270,6 +290,12 @@ func _update_radio_cooldown(coordinator: Object) -> void:
func set_target_label(text: String) -> void: func set_target_label(text: String) -> void:
target_label.text = text target_label.text = text
## How many people the aim-free ring (Tab / mouse wheel) can cycle through.
func set_nearby_hint(count: int) -> void:
if nearby_label == null:
return
nearby_label.text = Strings.fmt("hud.nearby", [count]) if count > 0 else ""
var _flashlight := false var _flashlight := false
var _door_locked := false var _door_locked := false
@@ -308,7 +334,7 @@ func set_font_scale(scale: float) -> void:
var clamped := clampf(scale, 0.75, 1.75) var clamped := clampf(scale, 0.75, 1.75)
_font_scale = clamped _font_scale = clamped
var scaled: Array[Label] = [top_label, log_label, target_label, hint_label, var scaled: Array[Label] = [top_label, log_label, target_label, hint_label,
time_label, mode_label, escalation_label, radio_cooldown_label, debug_label] nearby_label, time_label, mode_label, escalation_label, radio_cooldown_label, debug_label]
for label in scaled: for label in scaled:
if label == null: if label == null:
continue continue
@@ -355,6 +381,20 @@ func _add_corner_brackets(color: Color) -> void:
func set_action_hint(text: String) -> void: func set_action_hint(text: String) -> void:
hint_label.text = text hint_label.text = text
## Precision-walk state chip next to the door/light indicators.
func set_precision(active: bool) -> void:
if precision_label:
precision_label.visible = active
## Swap the control legend for the device the player is actually using.
func set_input_device(device: String) -> void:
if _controls_label == null:
return
if device == "gamepad":
_controls_label.text = Strings.lookup("hud.controls_gamepad")
else:
_controls_label.text = Strings.lookup("hud.controls")
## Color-coded escalation feedback (PLAN.md: Polish and Accessibility). ## Color-coded escalation feedback (PLAN.md: Polish and Accessibility).
@@ -386,6 +426,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:
+85
View File
@@ -6,6 +6,8 @@ extends CanvasLayer
## Emits `applied(settings)` so the game manager can push values into the guard ## Emits `applied(settings)` so the game manager can push values into the guard
## and HUD as soon as the player confirms. ## and HUD as soon as the player confirms.
const InputBindings := preload("res://src/core/input_bindings.gd")
signal applied(settings: Dictionary) signal applied(settings: Dictionary)
signal closed() signal closed()
@@ -14,6 +16,10 @@ var _sensitivity: HSlider = null
var _font_scale: HSlider = null var _font_scale: HSlider = null
var _sensitivity_value: Label = null var _sensitivity_value: Label = null
var _font_scale_value: Label = null var _font_scale_value: Label = null
var _invert_y: CheckButton = null
var _head_bob: CheckButton = null
var _bind_buttons := {}
var _listening_action := ""
var settings: Dictionary = {} var settings: Dictionary = {}
func _ready() -> void: func _ready() -> void:
@@ -66,6 +72,37 @@ func _build() -> void:
_font_scale = _add_slider(font_row, 0.75, 1.75, 0.05) _font_scale = _add_slider(font_row, 0.75, 1.75, 0.05)
_font_scale.value_changed.connect(_on_font_scale_changed) _font_scale.value_changed.connect(_on_font_scale_changed)
_invert_y = CheckButton.new()
_invert_y.text = "Invert vertical look"
_invert_y.toggled.connect(_on_invert_y_toggled)
column.add_child(_invert_y)
_head_bob = CheckButton.new()
_head_bob.text = "Head bob and camera motion"
_head_bob.toggled.connect(_on_head_bob_toggled)
column.add_child(_head_bob)
var controls_title := Label.new()
controls_title.text = "Controls (click a binding, then press a key or pad button)"
controls_title.add_theme_font_size_override("font_size", 13)
controls_title.modulate = Color(0.75, 0.8, 0.9)
column.add_child(controls_title)
var grid := GridContainer.new()
grid.columns = 4
grid.add_theme_constant_override("h_separation", 10)
grid.add_theme_constant_override("v_separation", 6)
column.add_child(grid)
for action in InputBindings.REMAPPABLE:
var label := Label.new()
label.text = str(action).replace("_", " ").capitalize()
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
grid.add_child(label)
var button := Button.new()
button.custom_minimum_size = Vector2(130, 0)
button.pressed.connect(_on_rebind.bind(action))
grid.add_child(button)
_bind_buttons[action] = button
var row := HBoxContainer.new() var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 12) row.add_theme_constant_override("separation", 12)
@@ -112,6 +149,12 @@ func open(current: Dictionary) -> void:
_sensitivity.set_value_no_signal(clampf(sens, _sensitivity.min_value, _sensitivity.max_value)) _sensitivity.set_value_no_signal(clampf(sens, _sensitivity.min_value, _sensitivity.max_value))
if _font_scale: if _font_scale:
_font_scale.set_value_no_signal(clampf(scale, _font_scale.min_value, _font_scale.max_value)) _font_scale.set_value_no_signal(clampf(scale, _font_scale.min_value, _font_scale.max_value))
if _invert_y:
_invert_y.set_pressed_no_signal(bool(settings.get("invert_y", false)))
if _head_bob:
_head_bob.set_pressed_no_signal(bool(settings.get("head_bob", true)))
_listening_action = ""
_refresh_bind_buttons()
_sync_labels() _sync_labels()
visible = true visible = true
_root.visible = true _root.visible = true
@@ -135,6 +178,42 @@ func _on_font_scale_changed(value: float) -> void:
settings["hud_font_scale"] = value settings["hud_font_scale"] = value
_sync_labels() _sync_labels()
func _on_invert_y_toggled(pressed: bool) -> void:
settings["invert_y"] = pressed
func _on_head_bob_toggled(pressed: bool) -> void:
settings["head_bob"] = pressed
func _refresh_bind_buttons() -> void:
var remaps: Dictionary = settings.get("input_bindings", {})
for action in _bind_buttons.keys():
var button: Button = _bind_buttons[action]
var descriptor: Dictionary = remaps.get(action, {})
if descriptor.is_empty():
descriptor = InputBindings.current_descriptor(action)
button.text = InputBindings.label_for(descriptor)
func _on_rebind(action: String) -> void:
_listening_action = action
var button: Button = _bind_buttons.get(action)
if button:
button.text = "Press…"
if button.is_inside_tree():
button.grab_focus()
func _apply_remap(event: InputEvent) -> bool:
if _listening_action == "":
return false
var descriptor := InputBindings.descriptor_from_event(event)
if descriptor.is_empty():
return false
var remaps: Dictionary = settings.get("input_bindings", {})
remaps[_listening_action] = descriptor
settings["input_bindings"] = remaps
_listening_action = ""
_refresh_bind_buttons()
return true
func _on_apply() -> void: func _on_apply() -> void:
if Save: if Save:
Save.save_settings(settings) Save.save_settings(settings)
@@ -149,6 +228,12 @@ func _on_cancel() -> void:
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if not visible: if not visible:
return return
if _listening_action != "":
var pressed: bool = (event is InputEventKey and event.pressed and not event.echo) \
or (event is InputEventJoypadButton and event.pressed)
if pressed and _apply_remap(event):
get_viewport().set_input_as_handled()
return
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE: if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
_on_cancel() _on_cancel()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
+342 -41
View File
@@ -1,13 +1,21 @@
class_name WorldBuilder class_name WorldBuilder
## Procedurally assembles a minimal Hollywood Boulevard entrance prototype: ## Procedurally assembles a minimal Hollywood Boulevard entrance prototype:
const Materials := preload("res://src/game/materials.gd")
const T_BRICK := preload("res://assets/textures/ai/game_ready/brick_facade.png") 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") 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. ## Surfaces use the shared PBR profiles in `src/game/materials.gd`; street
## furniture comes from the generated GLB props (tools/model_pipeline.py) with a
## procedural fallback so a headless checkout without imports still builds.
## Generated prop models, one `.glb` per street-furniture piece.
const MODEL_DIR := "res://assets/models/generated/"
const MODEL_PROPS := ["bollard", "bench", "trash_bin", "planter_box",
"newspaper_box", "fire_hydrant"]
var root: Node3D = null var root: Node3D = null
## Door lock indicator mesh near the handle; toggled by GameManager. ## Door lock indicator mesh near the handle; toggled by GameManager.
@@ -19,6 +27,12 @@ var _lamp_omni: Array[OmniLight3D] = []
var _lamp_bulb_mats: Array[StandardMaterial3D] = [] var _lamp_bulb_mats: Array[StandardMaterial3D] = []
var marquee_label: Label3D = null var marquee_label: Label3D = null
var _marquee_emissive: Array[StandardMaterial3D] = [] var _marquee_emissive: Array[StandardMaterial3D] = []
## Facade windows that light up after dark.
var _window_mats: Array[StandardMaterial3D] = []
## Names of the generated props that actually loaded (procedural fallbacks are
## not listed), so tests and the debug overlay can report real model usage.
var loaded_models: Array[String] = []
var fallback_props: Array[String] = []
func _init(root_node: Node3D) -> void: func _init(root_node: Node3D) -> void:
root = root_node root = root_node
@@ -33,19 +47,21 @@ func build() -> void:
_build_street() _build_street()
_build_crosswalk() _build_crosswalk()
_build_street_furniture() _build_street_furniture()
_build_street_detail()
_build_guard_spawn() _build_guard_spawn()
_build_navmesh() _build_navmesh()
set_night_mode(false) set_night_mode(false)
func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false, func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false,
texture: Texture2D = null, uv_scale: Vector2 = Vector2(1, 1)) -> MeshInstance3D: texture: Texture2D = null, uv_scale: Vector2 = Vector2(1, 1),
role: String = "concrete") -> MeshInstance3D:
var mesh := BoxMesh.new() var mesh := BoxMesh.new()
mesh.size = size mesh.size = size
var mat := StandardMaterial3D.new() var mat: StandardMaterial3D
mat.albedo_color = color
if texture: if texture:
mat.albedo_texture = texture mat = Materials.textured(texture, color, uv_scale, role)
mat.uv1_scale = Vector3(uv_scale.x, uv_scale.y, 1.0) else:
mat = Materials.flat(color, role)
mesh.material = mat mesh.material = mat
var mi := MeshInstance3D.new() var mi := MeshInstance3D.new()
mi.mesh = mesh mi.mesh = mesh
@@ -59,7 +75,7 @@ func _build_ground() -> void:
ground.name = "Ground" ground.name = "Ground"
# Large enough that the ground meets the distant skyline ring (no gap of sky # Large enough that the ground meets the distant skyline ring (no gap of sky
# under the buildings when looking down the boulevard). # under the buildings when looking down the boulevard).
_add_box(root, Vector3(500, 0.5, 500), Vector3(0, -0.25, 20), Color(0.22, 0.24, 0.26)) _add_box(root, Vector3(500, 0.5, 500), Vector3(0, -0.25, 20), Color(0.22, 0.24, 0.26), false, null, Vector2.ONE, "asphalt")
var col := BoxShape3D.new() var col := BoxShape3D.new()
col.size = Vector3(500, 1, 500) col.size = Vector3(500, 1, 500)
var shape_owner := ground.create_shape_owner(ground) var shape_owner := ground.create_shape_owner(ground)
@@ -70,22 +86,27 @@ func _build_building() -> void:
var building := StaticBody3D.new() var building := StaticBody3D.new()
building.name = "Building" building.name = "Building"
# Main facade (textured brick) # Main facade (textured brick)
_add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -6), Color(0.85, 0.8, 0.78), false, T_BRICK, Vector2(10, 5)) _add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -6), Color(0.80, 0.76, 0.74), false, T_BRICK, Vector2(22, 10), "brick")
# Stone base course so the facade has a footing instead of meeting the steps dead-on.
_add_box(building, Vector3(40, 0.8, 0.35), Vector3(0, 0.4, -3.85), Color(0.58, 0.56, 0.53), false, null, Vector2.ONE, "stone")
# Entrance recess + door recess frame # Entrance recess + door recess frame
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -3.9), Color(0.08, 0.08, 0.09)) _add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -3.9), Color(0.08, 0.08, 0.09), false, null, Vector2.ONE, "stone")
# Door frame columns and lintel, so the entrance is framed rather than painted on.
for side in [-1.55, 1.55]:
_add_box(building, Vector3(0.28, 5.2, 0.42), Vector3(side, 2.9, -3.86), Color(0.30, 0.28, 0.26), true, null, Vector2.ONE, "stone")
_add_box(building, Vector3(3.4, 0.3, 0.42), Vector3(0, 5.55, -3.86), Color(0.30, 0.28, 0.26), true, null, Vector2.ONE, "stone")
# Door frame and dark door # Door frame and dark door
_add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -3.98), Color(0.13, 0.12, 0.11)) _add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -3.98), Color(0.13, 0.12, 0.11), true, null, Vector2.ONE, "painted_metal")
# Door glass (semi-transparent) # Door glass (semi-transparent)
var glass := _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -3.9), Color(0.6, 0.75, 0.9, 0.55)) var glass := _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -3.9), Color(0.6, 0.75, 0.9, 0.55))
var glass_mat := StandardMaterial3D.new() glass.material_override = Materials.glass(Color(0.55, 0.7, 0.88, 0.55))
glass_mat.albedo_color = Color(0.6, 0.75, 0.9, 0.45) # Door handles: two slim metal bars where the guard reaches for the lock.
glass_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA for side in [-0.42, 0.42]:
glass_mat.roughness = 0.15 _add_box(building, Vector3(0.06, 0.5, 0.06), Vector3(side, 2.6, -3.79), Color(0.75, 0.74, 0.70), false, null, Vector2.ONE, "metal")
glass_mat.metallic = 0.1
glass.material_override = glass_mat
# Door lock indicator (green = open, red = locked), placed beside the handle # Door lock indicator (green = open, red = locked), placed beside the handle
door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -3.82), Color(0.1, 0.9, 0.3)) door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -3.82), Color(0.1, 0.9, 0.3))
door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3)) door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3))
_build_facade_windows(building)
var col := BoxShape3D.new() var col := BoxShape3D.new()
col.size = Vector3(40, 18, 4) col.size = Vector3(40, 18, 4)
var so := building.create_shape_owner(building) var so := building.create_shape_owner(building)
@@ -93,6 +114,24 @@ func _build_building() -> void:
building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -6))) building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -6)))
root.add_child(building) root.add_child(building)
## Regular window bands above the entrance. Panes are dark glass by day and
## light up after dark, which gives the facade depth at night.
func _build_facade_windows(parent: Node3D) -> void:
var columns := [-17.0, -13.6, -10.2, -6.8, 6.8, 10.2, 13.6, 17.0]
for y in [9.2, 12.4]:
for x in columns:
# Recessed reveal, then the pane: gives the window a dark frame edge.
_add_box(parent, Vector3(1.96, 2.46, 0.1), Vector3(x, y, -3.99),
Color(0.22, 0.21, 0.20), false, null, Vector2.ONE, "stone")
var pane := _add_box(parent, Vector3(1.7, 2.2, 0.12),
Vector3(x, y, -3.94), Color(1, 1, 1))
var pane_mat := Materials.window_pane()
pane.material_override = pane_mat
_window_mats.append(pane_mat)
# Sill below the frame.
_add_box(parent, Vector3(2.1, 0.12, 0.3), Vector3(x, y - 1.3, -3.86),
Color(0.55, 0.53, 0.50), false, null, Vector2.ONE, "stone")
func _build_steps() -> void: func _build_steps() -> void:
# Steps have collision so NPCs and the guard can walk up the entrance. # Steps have collision so NPCs and the guard can walk up the entrance.
# The ground and building provide the actual collision boundaries. # The ground and building provide the actual collision boundaries.
@@ -125,7 +164,7 @@ func _build_steps() -> void:
func _build_sidewalk() -> void: func _build_sidewalk() -> void:
var sidewalk := StaticBody3D.new() var sidewalk := StaticBody3D.new()
sidewalk.name = "Sidewalk" sidewalk.name = "Sidewalk"
_add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(1, 1, 1), false, T_CONCRETE, Vector2(9, 2.4)) _add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(0.74, 0.74, 0.72), false, T_CONCRETE, Vector2(9, 2.4), "concrete")
var col := BoxShape3D.new() var col := BoxShape3D.new()
col.size = Vector3(30, 0.36, 8) col.size = Vector3(30, 0.36, 8)
var so := sidewalk.create_shape_owner(sidewalk) var so := sidewalk.create_shape_owner(sidewalk)
@@ -136,10 +175,14 @@ func _build_sidewalk() -> void:
func _build_street() -> void: func _build_street() -> void:
var street := StaticBody3D.new() var street := StaticBody3D.new()
street.name = "Street" street.name = "Street"
_add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22), Color(1, 1, 1), false, T_ASPHALT, Vector2(30, 7)) # The roadway reads darker than the sidewalk; the raw asphalt texture tinted
# Center dashes # white looked like more pavement.
_add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22), Color(0.52, 0.53, 0.57), false, T_ASPHALT, Vector2(30, 7), "asphalt")
# Center dashes along the street (x varied so they form a lane line, not a stack).
for i in range(-40, 41, 4): for i in range(-40, 41, 4):
_add_box(street, Vector3(0.2, 0.02, 1.2), Vector3(i * 0.0, 0.11, 22), Color(0.85, 0.85, 0.30)) _add_box(street, Vector3(0.2, 0.02, 1.2), Vector3(float(i), 0.11, 22), Color(0.85, 0.85, 0.30), false, null, Vector2.ONE, "concrete")
# Curb between the sidewalk and the roadway.
_add_box(street, Vector3(120, 0.22, 0.5), Vector3(0, 0.11, 12.1), Color(0.62, 0.61, 0.58), false, null, Vector2.ONE, "concrete")
var col := BoxShape3D.new() var col := BoxShape3D.new()
col.size = Vector3(120, 0.2, 26) col.size = Vector3(120, 0.2, 26)
var so := street.create_shape_owner(street) var so := street.create_shape_owner(street)
@@ -215,7 +258,7 @@ 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), true, T_AWNING, Vector2(4, 1), "awning")
awning.name = "Awning" awning.name = "Awning"
## Neon-style marquee strip above the door: emissive bars + a label. ## Neon-style marquee strip above the door: emissive bars + a label.
var strip_parent := Node3D.new() var strip_parent := Node3D.new()
@@ -244,8 +287,8 @@ func _build_street_furniture() -> void:
for side in [-7.5, 7.5]: for side in [-7.5, 7.5]:
var lamp := Node3D.new() var lamp := Node3D.new()
lamp.name = "Lamp_%d" % int(side) lamp.name = "Lamp_%d" % int(side)
_add_box(lamp, Vector3(0.22, 5.0, 0.22), Vector3(side, 2.5, 8.5), Color(0.16, 0.16, 0.18)) _add_box(lamp, Vector3(0.22, 5.0, 0.22), Vector3(side, 2.5, 8.5), Color(0.16, 0.16, 0.18), true, null, Vector2.ONE, "metal")
_add_box(lamp, Vector3(1.6, 0.15, 0.15), Vector3(side + (0.8 if side < 0 else -0.8), 4.9, 8.5), Color(0.16, 0.16, 0.18)) _add_box(lamp, Vector3(1.6, 0.15, 0.15), Vector3(side + (0.8 if side < 0 else -0.8), 4.9, 8.5), Color(0.16, 0.16, 0.18), true, null, Vector2.ONE, "metal")
var bulb := _add_box(lamp, Vector3(0.16, 0.16, 0.5), Vector3(side + (0.8 if side < 0 else -0.8), 4.85, 8.5), Color(1, 1, 0.85)) var bulb := _add_box(lamp, Vector3(0.16, 0.16, 0.5), Vector3(side + (0.8 if side < 0 else -0.8), 4.85, 8.5), Color(1, 1, 0.85))
var bulb_mat := _emissive_material(Color(1.0, 1.0, 0.8)) var bulb_mat := _emissive_material(Color(1.0, 1.0, 0.8))
bulb.material_override = bulb_mat bulb.material_override = bulb_mat
@@ -253,17 +296,15 @@ func _build_street_furniture() -> void:
_lamp_bulb_mats.append(bulb_mat) _lamp_bulb_mats.append(bulb_mat)
var omni := OmniLight3D.new() var omni := OmniLight3D.new()
omni.position = Vector3(side + (0.8 if side < 0 else -0.8), 4.7, 8.5) omni.position = Vector3(side + (0.8 if side < 0 else -0.8), 4.7, 8.5)
omni.omni_range = 14.0 omni.omni_range = 12.0
omni.light_energy = 0.0 omni.light_energy = 0.0
omni.light_color = Color(1, 0.93, 0.75) omni.light_color = Color(1, 0.93, 0.75)
lamp.add_child(omni) lamp.add_child(omni)
_lamp_omni.append(omni) _lamp_omni.append(omni)
root.add_child(lamp) root.add_child(lamp)
_street_lamps.append(bulb) _street_lamps.append(bulb)
# Planters beside the steps. # Planters beside the steps were flat boxes that clipped the treads; the
for side in [-4.6, 4.6]: # generated planter_box props replace them in _build_street_detail().
var planter := _add_box(root, Vector3(1.1, 0.7, 1.1), Vector3(side, 0.35, 1.0), Color(0.42, 0.34, 0.28))
planter.name = "Planter_%d" % int(side)
# Low-poly trees along the street front. # Low-poly trees along the street front.
var trunk_color := Color(0.35, 0.25, 0.18) var trunk_color := Color(0.35, 0.25, 0.18)
var leaf_color := Color(0.16, 0.42, 0.2) var leaf_color := Color(0.16, 0.42, 0.2)
@@ -280,7 +321,7 @@ func _build_street_furniture() -> void:
trunk_mesh.bottom_radius = 0.14 trunk_mesh.bottom_radius = 0.14
trunk_mesh.height = 1.4 trunk_mesh.height = 1.4
trunk.mesh = trunk_mesh trunk.mesh = trunk_mesh
trunk.material_override = _plain_material(trunk_color) trunk.material_override = _plain_material(trunk_color, "wood")
trunk.position = Vector3(x, 0.7, z) trunk.position = Vector3(x, 0.7, z)
tree.add_child(trunk) tree.add_child(trunk)
var crown := MeshInstance3D.new() var crown := MeshInstance3D.new()
@@ -289,11 +330,271 @@ func _build_street_furniture() -> void:
crown_mesh.bottom_radius = 0.85 crown_mesh.bottom_radius = 0.85
crown_mesh.height = 2.2 crown_mesh.height = 2.2
crown.mesh = crown_mesh crown.mesh = crown_mesh
crown.material_override = _plain_material(leaf_color) crown.material_override = _plain_material(leaf_color, "foliage")
crown.position = Vector3(x, 2.1, z) crown.position = Vector3(x, 2.1, z)
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).
## The meshes are real 3D models (bollards, benches, bins, planters, newspaper
## boxes, a hydrant) rather than scaled boxes. Placements and their deterministic
## variants are fixed, so the street looks the same on every run.
func _build_street_detail() -> void:
var detail := Node3D.new()
detail.name = "StreetDetail"
root.add_child(detail)
for index in PROP_PLACEMENTS.size():
var entry: Dictionary = PROP_PLACEMENTS[index]
var base_rot := float(entry.get("rot", 0.0))
var variant := variant_for(index, base_rot)
var holder := _add_prop_model(detail, str(entry["prop"]),
Vector3(float(entry["x"]), 0.18, float(entry["z"])),
float(variant["rotation"]), index)
holder.scale = Vector3.ONE * float(variant["scale"])
var accessory := str(entry.get("accessory", ""))
if accessory != "":
_add_prop_accessory(holder, accessory)
## Place one generated prop. Falls back to simple primitives when the `.glb` is
## 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,
rot_y_deg: float = 0.0, variant_index: int = -1) -> Node3D:
var holder := Node3D.new()
holder.name = "Prop_%s_%d" % [prop, parent.get_child_count()]
holder.position = pos
holder.rotation_degrees.y = rot_y_deg
var model := _load_prop_scene(prop)
if model != null:
holder.add_child(model)
loaded_models.append(prop)
else:
var fallback := _fallback_prop(prop)
holder.add_child(fallback)
fallback_props.append(prop)
model = fallback
parent.add_child(holder)
_add_prop_collision(holder, prop, model)
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:
var path := MODEL_DIR + prop + ".glb"
if not ResourceLoader.exists(path):
return null
var res: Resource = ResourceLoader.load(path)
if res is PackedScene:
var node: Node = (res as PackedScene).instantiate()
if node is Node3D:
return node as Node3D
node.queue_free()
return null
## Rough primitive stand-in per prop, used only when the model is unavailable.
func _fallback_prop(prop: String) -> Node3D:
var node := Node3D.new()
match prop:
"bollard":
_add_cylinder_mesh(node, 0.09, 0.92, Vector3(0, 0.46, 0), Color(0.16, 0.17, 0.19), "metal")
"bench":
_add_box(node, Vector3(1.8, 0.08, 0.5), Vector3(0, 0.46, 0), Color(0.42, 0.28, 0.17), true, null, Vector2.ONE, "wood")
_add_box(node, Vector3(1.8, 0.42, 0.07), Vector3(0, 0.72, -0.22), Color(0.42, 0.28, 0.17), true, null, Vector2.ONE, "wood")
for x in [-0.72, 0.72]:
_add_box(node, Vector3(0.09, 0.46, 0.46), Vector3(x, 0.23, 0), Color(0.16, 0.17, 0.19), true, null, Vector2.ONE, "metal")
"trash_bin":
_add_cylinder_mesh(node, 0.27, 0.78, Vector3(0, 0.39, 0), Color(0.22, 0.30, 0.24), "painted_metal")
_add_cylinder_mesh(node, 0.29, 0.06, Vector3(0, 0.81, 0), Color(0.16, 0.17, 0.19), "metal")
"planter_box":
_add_box(node, Vector3(0.96, 0.54, 0.96), Vector3(0, 0.27, 0), Color(0.52, 0.51, 0.48), true, null, Vector2.ONE, "stone")
_add_cylinder_mesh(node, 0.30, 0.55, Vector3(0, 0.86, 0), Color(0.18, 0.42, 0.20), "foliage")
"newspaper_box":
_add_box(node, Vector3(0.42, 0.72, 0.34), Vector3(0, 0.49, 0), Color(0.16, 0.27, 0.45), true, null, Vector2.ONE, "painted_metal")
_add_box(node, Vector3(0.44, 0.10, 0.36), Vector3(0, 0.90, 0), Color(0.85, 0.72, 0.28), true, null, Vector2.ONE, "painted_metal")
"fire_hydrant":
_add_cylinder_mesh(node, 0.15, 0.62, Vector3(0, 0.31, 0), Color(0.6, 0.12, 0.12), "painted_metal")
_add_box(node, Vector3(0.52, 0.10, 0.12), Vector3(0, 0.42, 0), Color(0.75, 0.74, 0.70), true, null, Vector2.ONE, "metal")
_:
_add_box(node, Vector3(0.5, 0.5, 0.5), Vector3(0, 0.25, 0), Color(0.5, 0.5, 0.5))
return node
func _add_cylinder_mesh(parent: Node3D, radius: float, height: float,
pos: Vector3, color: Color, role: String = "metal") -> MeshInstance3D:
var mi := MeshInstance3D.new()
var mesh := CylinderMesh.new()
mesh.top_radius = radius
mesh.bottom_radius = radius
mesh.height = height
mesh.radial_segments = 12
mi.mesh = mesh
mi.position = pos
mi.material_override = Materials.flat(color, role)
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_ON
parent.add_child(mi)
return mi
func _build_crosswalk() -> void: func _build_crosswalk() -> void:
# White crosswalk stripes in front of the entrance on the street. # White crosswalk stripes in front of the entrance on the street.
for i in 8: for i in 8:
@@ -305,24 +606,24 @@ func set_night_mode(night: bool) -> void:
for mat in _lamp_bulb_mats: for mat in _lamp_bulb_mats:
mat.emission_energy_multiplier = (2.0 if night else 0.0) mat.emission_energy_multiplier = (2.0 if night else 0.0)
for omni in _lamp_omni: for omni in _lamp_omni:
omni.light_energy = (3.0 if night else 0.0) # Two lamps at full energy washed the whole sidewalk grey; keep visible
# pools of light instead of flat night-time brightness.
omni.light_energy = (1.8 if night else 0.0)
for mat in _marquee_emissive: for mat in _marquee_emissive:
mat.emission_energy_multiplier = (2.0 if night else 0.2) mat.emission_energy_multiplier = (2.0 if night else 0.2)
# Light most, not all, of the windows: a fully lit grid reads as a light box
# instead of an occupied building.
for i in _window_mats.size():
var lit := night and i % 3 != 2
_window_mats[i].emission_energy_multiplier = (1.1 if lit else 0.0)
if marquee_label: if marquee_label:
marquee_label.modulate.a = (1.0 if night else 0.9) marquee_label.modulate.a = (1.0 if night else 0.9)
func _emissive_material(color: Color) -> StandardMaterial3D: func _emissive_material(color: Color) -> StandardMaterial3D:
var mat := StandardMaterial3D.new() return Materials.emissive(color)
mat.albedo_color = color
mat.emission_enabled = true
mat.emission = color
mat.emission_energy_multiplier = 1.0
return mat
func _plain_material(color: Color) -> StandardMaterial3D: func _plain_material(color: Color, role: String = "concrete") -> StandardMaterial3D:
var mat := StandardMaterial3D.new() return Materials.flat(color, role)
mat.albedo_color = color
return mat
func _build_guard_spawn() -> void: func _build_guard_spawn() -> void:
var marker := Marker3D.new() var marker := Marker3D.new()
+104
View File
@@ -0,0 +1,104 @@
extends SceneTree
## Dev helper: render every generated prop side by side on a plain backdrop and
## save user://screenshots/10-prop-gallery.png. The image feeds
## `tools/screen_designs.py compare` so a prop regression is visible.
##
## Usage: godot --path . --script res://src/test/capture_prop_gallery.gd
const OUT_DIR := "user://screenshots"
const MODEL_DIR := "res://assets/models/generated/"
const PROPS := ["bollard", "bench", "trash_bin", "planter_box",
"newspaper_box", "fire_hydrant"]
const SPACING := 1.9
var frames := 0
func _initialize() -> void:
root.size = Vector2i(1280, 720)
_build_stage()
func _build_stage() -> void:
var stage := Node3D.new()
stage.name = "PropGallery"
root.add_child(stage)
var environment := Environment.new()
environment.background_mode = Environment.BG_COLOR
environment.background_color = Color(0.52, 0.54, 0.58)
environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
environment.ambient_light_color = Color(0.78, 0.80, 0.84)
environment.ambient_light_energy = 0.8
var world_env := WorldEnvironment.new()
world_env.environment = environment
stage.add_child(world_env)
# Floor and a plain backdrop wall: a studio line-up with no street context.
var floor_mesh := PlaneMesh.new()
floor_mesh.size = Vector2(14, 8)
var floor := MeshInstance3D.new()
floor.mesh = floor_mesh
floor.material_override = _flat(Color(0.50, 0.51, 0.54), 0.9)
stage.add_child(floor)
var backdrop_mesh := BoxMesh.new()
backdrop_mesh.size = Vector3(14, 6, 0.1)
var backdrop := MeshInstance3D.new()
backdrop.mesh = backdrop_mesh
backdrop.material_override = _flat(Color(0.55, 0.57, 0.61), 0.95)
backdrop.position = Vector3(0, 3.0, -3.4)
stage.add_child(backdrop)
# Raised back row so both rows read in a single shot.
var platform_mesh := BoxMesh.new()
platform_mesh.size = Vector3(6.8, 0.8, 1.9)
var platform := MeshInstance3D.new()
platform.mesh = platform_mesh
platform.material_override = _flat(Color(0.38, 0.39, 0.42), 0.85)
platform.position = Vector3(0, 0.4, -2.1)
stage.add_child(platform)
var light := DirectionalLight3D.new()
light.rotation_degrees = Vector3(-48, -35, 0)
light.light_energy = 0.85
light.shadow_enabled = true
stage.add_child(light)
var camera := Camera3D.new()
camera.position = Vector3(0, 2.1, 6.2)
camera.fov = 50.0
camera.look_at_from_position(camera.position, Vector3(0, 0.75, -1.0))
camera.current = true
stage.add_child(camera)
# 3 x 2 grid: three props on the front row, three on the back row.
var columns := [-1.95, 0.0, 1.95]
for index in PROPS.size():
var prop_name: String = PROPS[index]
var packed := ResourceLoader.load(MODEL_DIR + prop_name + ".glb") as PackedScene
if packed == null:
continue
var model := packed.instantiate() as Node3D
var col: int = index % 3
var row: int = index / 3
model.position = Vector3(columns[col], 0.8 if row == 1 else 0.0, -2.1 * row)
model.rotation_degrees.y = 14.0
stage.add_child(model)
func _flat(color: Color, roughness: float) -> StandardMaterial3D:
var material := StandardMaterial3D.new()
material.albedo_color = color
material.roughness = roughness
return material
func _process(_delta: float) -> bool:
frames += 1
if frames < 20:
return false
var dir := DirAccess.open("user://")
if dir == null or not dir.dir_exists("screenshots"):
dir.make_dir_recursive("screenshots")
var image := root.get_texture().get_image()
var err := image.save_png(OUT_DIR.path_join("10-prop-gallery.png"))
print("10-prop-gallery.png -> err=%d (%dx%d)" % [err, image.get_width(), image.get_height()])
quit(0)
return true
+107 -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,114 @@ 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")
if gm != null:
gm.event_timeline.close()
gm.guard.global_position = Vector3(0, 0.1, 6.2)
gm.guard.rotation.y = 0.0
Input.action_press("move_forward")
Input.action_press("walk_slow")
state = 11
state_frames = 0
11:
if state_frames >= 50:
_shoot("11-precision-walk.png")
Input.action_release("move_forward")
Input.action_release("walk_slow")
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:
+48 -3
View File
@@ -1,5 +1,5 @@
extends SceneTree extends SceneTree
## Windowed smoke helper for the ADR-0007–0010 control pass (focus ring + action ## Windowed smoke helper for the ADR-0007–0010 control pass (focus circle + action
## wheel). Boots the real main scene, stages one NPC in front of the guard, opens ## wheel). Boots the real main scene, stages one NPC in front of the guard, opens
## the wheel, and saves two PNGs to user://screenshots/. Exits 1 if the focus or ## the wheel, and saves two PNGs to user://screenshots/. Exits 1 if the focus or
## radial state did not come up, so it can be used as a smoke check. ## radial state did not come up, so it can be used as a smoke check.
@@ -76,7 +76,7 @@ func _advance() -> void:
(gm.guard.global_position as Vector3).y, (gm.guard.global_position as Vector3).y,
(gm.targeted_npc.global_position as Vector3).y, (gm.targeted_npc.global_position as Vector3).y,
]) ])
_shoot("smoke-01-focus-ring.png") _shoot("smoke-01-focus-circle.png")
gm.call("_open_radial") gm.call("_open_radial")
state = 2 state = 2
state_frames = 0 state_frames = 0
@@ -93,8 +93,53 @@ func _advance() -> void:
if state_frames >= 5: if state_frames >= 5:
if gm.radial.is_open: if gm.radial.is_open:
_fail("action wheel did not close on cancel") _fail("action wheel did not close on cancel")
state = 4
state_frames = 0
4:
# Aim-free selection (ADR-0011): the cycled target stays pinned, the
# HUD reports how many people can be cycled, and the mouse wheel is
# bound to the same ring.
if state_frames >= 5:
if gm.targeted_npc == null:
_fail("target lost after the wheel closed")
if not gm.guard.target_pinned:
_fail("cycled target was not pinned")
if gm.hud.nearby_label.text == "":
_fail("nearby hint empty while people are in range")
_wheel(true)
state = 5
state_frames = 0
5:
if state_frames >= 5:
if gm.targeted_npc == null:
_fail("mouse wheel dropped the target")
elif not gm.guard.target_pinned:
_fail("mouse wheel did not pin the cycled target")
print("smoke: wheel cycled to %s" % gm.targeted_npc.name)
# Right click with nothing open returns to cone focus.
gm.call("_handle_cancel", true)
state = 6
state_frames = 0
6:
if state_frames >= 5:
if gm.targeted_npc != null:
_fail("right click did not clear the target")
if gm.guard.target_pinned:
_fail("clearing the target left it pinned")
_finish() _finish()
## Synthesised mouse-wheel event, fed through the real handler so the input-map
## binding is exercised as well as the cycling logic.
func _wheel(down: bool) -> void:
var ev := InputEventMouseButton.new()
ev.button_index = MOUSE_BUTTON_WHEEL_DOWN if down else MOUSE_BUTTON_WHEEL_UP
ev.pressed = true
ev.position = Vector2(640, 360)
ev.global_position = ev.position
if not ev.is_action_pressed("cycle_target_next"):
_fail("cycle_target_next is not bound to the mouse wheel")
gm.call("_handle_mouse_button", ev)
func _stage_target() -> void: func _stage_target() -> void:
if stage_npc == null or not is_instance_valid(stage_npc): if stage_npc == null or not is_instance_valid(stage_npc):
stage_npc = gm.npcs[0] if gm.npcs.size() > 0 else null stage_npc = gm.npcs[0] if gm.npcs.size() > 0 else null
@@ -127,7 +172,7 @@ func _fail(message: String) -> void:
func _finish() -> void: func _finish() -> void:
if failures.is_empty(): if failures.is_empty():
print("SMOKE OK: focus ring + action wheel opened and cancelled") print("SMOKE OK: focus circle + action wheel + aim-free cycling")
quit(0) quit(0)
else: else:
for message in failures: for message in failures:
+117
View File
@@ -0,0 +1,117 @@
extends SceneTree
## Movement smoke (windowed): walks the guard to the entrance to check step-up,
## measures precision-walk speed, and checks footstep/landing cues. The guard
## reads captured-mouse input, so run it windowed:
##
## godot --path . --script res://src/test/smoke_movement.gd
##
## Exit code 0 = pass, 1 = failure.
const MAX_FRAMES := 1600
var frame := 0
var gm: Node = null
var started := false
var step := 0
var step_frame := 0
var failures: Array[String] = []
var start_pos := Vector3.ZERO
var normal_distance := 0.0
var footsteps_before := 0
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 movement checks finished")
return true
step_frame += 1
match step:
0:
if step_frame < 20:
return false
_place_guard(8.5)
footsteps_before = gm.audio.footsteps
start_pos = gm.guard.global_position
Input.action_press("move_forward")
step = 1
step_frame = 0
1:
if step_frame >= 280:
Input.action_release("move_forward")
var moved := start_pos.distance_to(gm.guard.global_position)
_check(moved > 2.0, "guard walked toward the entrance (%.1f m)" % moved)
_check(gm.guard.global_position.y > 0.35,
"guard climbed the steps (y=%.2f)" % gm.guard.global_position.y)
_check(gm.guard.global_position.z < 4.2,
"guard reached the steps (z=%.1f)" % gm.guard.global_position.z)
_check(gm.audio.footsteps > footsteps_before,
"footsteps are emitted while walking")
step = 2
step_frame = 0
2:
if step_frame < 45:
return false
_place_guard(8.5)
normal_distance = 0.0
start_pos = gm.guard.global_position
Input.action_press("move_forward")
Input.action_press("walk_slow")
step = 3
step_frame = 0
3:
if step_frame >= 120:
Input.action_release("walk_slow")
Input.action_release("move_forward")
var moved := start_pos.distance_to(gm.guard.global_position)
_check(moved > 1.0 and moved < 4.6,
"precision walk covers less ground (%.1f m in 2 s)" % moved)
var chip = gm.hud.precision_label
_check(chip != null and chip.visible,
"precision walk shows its HUD chip")
step = 4
step_frame = 0
4:
if gm.audio.landings == 0:
gm.guard.global_position += Vector3(0, 1.2, 0)
if step_frame >= 90:
_check(gm.audio.landings > 0, "landing cue fires after a drop")
_finish()
return true
return false
func _place_guard(z: float) -> void:
gm.guard.global_position = Vector3(0, 0.1, z)
gm.guard.rotation.y = 0.0
gm.guard.velocity = Vector3.ZERO
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")
Input.action_release("walk_slow")
if failures.is_empty():
print("SMOKE OK: movement feel (steps, precision walk, footsteps)")
quit(0)
else:
for message in failures:
printerr("SMOKE FAIL: " + message)
quit(1)
+57
View File
@@ -0,0 +1,57 @@
extends SceneTree
## NPC performance budget smoke: put the documented live-NPC count in the scene,
## let the animations run, then report the average frame time. The budget is
## 50 ms (20 FPS) so slow CI hardware does not fail on noise; the report is the
## useful part when tuning the rig.
##
## Usage: godot --headless --path . --script res://src/test/smoke_npc_budget.gd
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
const Rules := preload("res://src/core/rules.gd")
const COUNT := 16
const WARMUP_FRAMES := 60
const SAMPLE_FRAMES := 180
const BUDGET_MS := 50.0
var frame := 0
var avatars: Array = []
var sample_ms := 0.0
func _initialize() -> void:
root.size = Vector2i(1280, 720)
var stage := Node3D.new()
stage.name = "BudgetStage"
root.add_child(stage)
for index in COUNT:
var profile := NpcProfile.new()
profile.id = "budget_%d" % index
profile.npc_type = Rules.NpcType.TOURIST
profile.display_name = "Budget %d" % index
var avatar := NpcAvatar.new()
avatar.setup(profile, "roam", index + 1,
Vector3(-6.0 + (index % 8) * 1.7, 0.5, 4.0 + (index / 8) * 2.0),
Vector3(0, 0.5, 8.0), Vector3(0, 0.5, 1.2), profile.id)
stage.add_child(avatar)
avatars.append(avatar)
func _process(delta: float) -> bool:
frame += 1
if frame > WARMUP_FRAMES:
sample_ms += delta * 1000.0
if frame >= WARMUP_FRAMES + SAMPLE_FRAMES:
var average := sample_ms / SAMPLE_FRAMES
var meshes := 0
for avatar in avatars:
meshes += avatar.find_children("*", "MeshInstance3D", true, false).size()
print("NPC budget: %d avatars, %d meshes, avg frame %.2f ms (budget %.0f ms)" % [
COUNT, meshes, average, BUDGET_MS])
if average > BUDGET_MS:
printerr("SMOKE FAIL: frame time %.1f ms exceeds the %.0f ms budget" % [
average, BUDGET_MS])
quit(1)
return true
print("SMOKE OK: %d avatars stay inside the frame budget" % COUNT)
quit(0)
return true
return false
+164
View File
@@ -0,0 +1,164 @@
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): yaw 0 looks down -z, so W walks toward it.
gm.guard.rotation.y = 0.0
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)
+91
View File
@@ -0,0 +1,91 @@
extends SceneTree
## Scenario-content smoke: forces the authored Joke Shop refusal event and the
## Studio celebrity arrival through the real spawn path, then checks the extra
## bystanders, the hint log, and that a screening refusal is handled.
##
## Usage:
## godot --headless --path . --script res://src/test/smoke_scenario_content.gd
const MAX_FRAMES := 900
var frame := 0
var gm: Node = null
var started := false
var step := 0
var failures: Array[String] = []
func _initialize() -> void:
root.size = Vector2i(1280, 720)
root.add_child(load("res://scenes/main.tscn").instantiate())
func _process(_delta: float) -> bool:
frame += 1
if gm == null:
gm = root.find_child("GameManager", true, false)
return false
if not started:
gm.call("_on_venue_selected", "joke_shop")
started = true
return false
if frame > MAX_FRAMES:
_fail("timeout")
return true
if frame < 120:
return false
match step:
0:
# Force the refusal event through the normal spawn signal.
var event := _find_event(gm.selected_scenario.event_pool, "joke_refuses_wand")
_check(not event.is_empty(), "the refusal event exists in the Joke Shop pool")
var guest: Object = gm.selected_scenario.profile_for(int(event.get("npc_type", 0)))
gm.coordinator.npc_spawn_requested.emit(guest, "enter", "smoke_refuser", event)
_check(gm.npcs.size() > 0, "the refusing guest spawned")
gm.call("_on_guard_target", gm.npcs[gm.npcs.size() - 1])
var controller: Object = gm.npcs[gm.npcs.size() - 1].get_npc_controller()
_check(controller.refuses_screening, "the refusal flag reached the controller")
gm.call("_toggle_wand_screening")
_check(not gm.wand_open, "screening does not open for someone who refuses")
step = 1
1:
# Studio: celebrity arrival should bring extra bystanders.
gm.call("_on_venue_selected", "tv_studio")
step = 2
2:
if frame % 20 != 0:
return false
var event := _find_event(gm.selected_scenario.event_pool, "studio_celebrity_arrival")
var before: int = gm.npcs.size()
var guest: Object = gm.selected_scenario.profile_for(int(event.get("npc_type", 0)))
gm.coordinator.npc_spawn_requested.emit(guest, "arrive", "smoke_celeb", event)
var added: int = gm.npcs.size() - before
_check(added >= 4, "celebrity arrival spawns the guest plus bystanders (%d)" % added)
step = 3
3:
_finish()
return true
return false
func _find_event(pool: Array, id: String) -> Dictionary:
for event in pool:
if str((event as Dictionary).get("id", "")) == id:
return event
return {}
func _check(condition: bool, message: String) -> void:
if condition:
print("ok: " + message)
else:
_fail(message)
func _fail(message: String) -> void:
failures.append(message)
_finish()
func _finish() -> void:
if failures.is_empty():
print("SMOKE OK: authored scenario content")
quit(0)
else:
for message in failures:
printerr("SMOKE FAIL: " + message)
quit(1)
+12
View File
@@ -5,6 +5,18 @@ extends RefCounted
const AudioFeedback := preload("res://src/game/audio_feedback.gd") const AudioFeedback := preload("res://src/game/audio_feedback.gd")
const CrowdSystem := preload("res://src/core/crowd_system.gd") const CrowdSystem := preload("res://src/core/crowd_system.gd")
func test_footstep_and_landing_cues_count() -> bool:
var audio := AudioFeedback.new()
audio.play_footstep("concrete")
audio.play_footstep("asphalt")
audio.play_land()
audio.play_precision_tick()
var a := TestUtils.check_eq(audio.footsteps, 2, "footsteps are counted")
var b := TestUtils.check_eq(audio.landings, 1, "landings are counted")
var c := TestUtils.check_eq(audio.precision_ticks, 1, "precision ticks are counted")
audio.free()
return a and b and c
func test_bed_volume_follows_mood() -> bool: func test_bed_volume_follows_mood() -> bool:
var quiet: float = AudioFeedback._level_to_db(0.0) var quiet: float = AudioFeedback._level_to_db(0.0)
var loud: float = AudioFeedback._level_to_db(1.0) var loud: float = AudioFeedback._level_to_db(1.0)
+224
View File
@@ -0,0 +1,224 @@
extends RefCounted
## Headless tests for the generated prop models (tools/model_pipeline.py) and
## their placement in the procedural world.
const WorldBuilder := preload("res://src/game/world_builder.gd")
const MODEL_DIR := "res://assets/models/generated/"
func test_prop_glb_files_exist() -> bool:
var missing: Array[String] = []
for prop in WorldBuilder.MODEL_PROPS:
if not FileAccess.file_exists(MODEL_DIR + str(prop) + ".glb"):
missing.append(str(prop))
return TestUtils.check(missing.is_empty(), "all prop models built (missing: %s)" % str(missing))
func test_prop_glb_files_are_imported_scenes() -> bool:
## `load()` only returns a PackedScene once Godot has imported the `.glb`;
## a null here means the checkout is missing the import or the model is invalid.
var problems: Array[String] = []
for prop in WorldBuilder.MODEL_PROPS:
var path := MODEL_DIR + str(prop) + ".glb"
var packed := ResourceLoader.load(path) as PackedScene
if packed == null:
problems.append("%s not imported as a scene" % prop)
elif not FileAccess.file_exists(path + ".import"):
problems.append("%s has no .import file" % prop)
return TestUtils.check(problems.is_empty(), "prop models import as scenes (%s)" % str(problems))
func test_prop_scenes_instantiate_with_meshes() -> bool:
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)
if meshes.is_empty():
problems.append("%s: no mesh instances" % prop)
instance.free()
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_prop_instances_share_the_imported_material() -> bool:
## Instancing a prop must not duplicate its atlas material; every copy uses
## the same resource, so many props stay cheap to draw.
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 first := packed.instantiate()
var second := packed.instantiate()
var a := _first_material(first)
var b := _first_material(second)
if a == null or b == null:
problems.append("%s: missing material" % prop)
elif a.get_instance_id() != b.get_instance_id():
problems.append("%s: material is duplicated per instance" % prop)
first.free()
second.free()
return TestUtils.check(problems.is_empty(), "prop instances share materials (%s)" % str(problems))
func _first_material(instance: Node) -> Material:
for node in instance.find_children("*", "MeshInstance3D", true, false):
var mesh: Mesh = (node as MeshInstance3D).mesh
if mesh != null and mesh.get_surface_count() > 0:
return mesh.surface_get_material(0)
return null
func test_world_places_generated_props() -> bool:
var root := Node3D.new()
var builder := WorldBuilder.new(root)
builder.build()
var detail := root.get_node_or_null("StreetDetail")
var a := TestUtils.check(detail != null, "StreetDetail exists")
var placed: int = detail.get_child_count() if detail else 0
var b := TestUtils.check(placed >= 10, "street furniture placed (%d)" % placed)
var c := TestUtils.check(builder.loaded_models.size() >= WorldBuilder.MODEL_PROPS.size(),
"generated models loaded instead of fallbacks")
var d := TestUtils.check(builder.fallback_props.is_empty(),
"no procedural fallback used (fallbacks: %s)" % str(builder.fallback_props))
root.free()
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:
var root := Node3D.new()
var builder := WorldBuilder.new(root)
builder.build()
var a := TestUtils.check(builder._window_mats.size() >= 8, "window panes built")
builder.set_night_mode(true)
var lit: bool = builder._window_mats[0].emission_energy_multiplier > 0.0
builder.set_night_mode(false)
var dark: bool = is_zero_approx(builder._window_mats[0].emission_energy_multiplier)
root.free()
return a and TestUtils.check(lit, "windows light up at night") and TestUtils.check(dark, "windows are dark by day")
func test_fallback_prop_is_usable() -> bool:
var root := Node3D.new()
var builder := WorldBuilder.new(root)
var fallback := builder._fallback_prop("bench")
var meshes := fallback.find_children("*", "MeshInstance3D", true, false)
var ok := TestUtils.check(meshes.size() >= 3, "fallback bench has geometry")
fallback.free()
root.free()
return ok
+53
View File
@@ -0,0 +1,53 @@
extends RefCounted
## Headless tests for the key/button remap helper.
const InputBindings := preload("res://src/core/input_bindings.gd")
func test_descriptor_from_events() -> bool:
var key := InputEventKey.new()
key.physical_keycode = KEY_Z
var a := TestUtils.check_eq(InputBindings.descriptor_from_event(key),
{"type": "key", "code": int(KEY_Z)}, "keys store a code")
var button := InputEventJoypadButton.new()
button.button_index = JOY_BUTTON_Y
var b := TestUtils.check_eq(InputBindings.descriptor_from_event(button),
{"type": "joy", "button": int(JOY_BUTTON_Y)}, "pad buttons store an index")
var c := TestUtils.check(
InputBindings.descriptor_from_event(InputEventMouseButton.new()).is_empty(),
"mouse buttons are not remappable")
return a and b and c
func test_apply_rebinds_and_keeps_mouse_events() -> bool:
var old := InputBindings.current_descriptor("inspect")
var remap := {"inspect": {"type": "key", "code": int(KEY_Z)}}
InputBindings.apply(remap)
var rebound := InputBindings.current_descriptor("inspect")
var a := TestUtils.check_eq(rebound, {"type": "key", "code": int(KEY_Z)},
"inspect now uses the new key")
var action_menu_mouse := 0
for event in InputMap.action_get_events("action_menu"):
if event is InputEventMouseButton:
action_menu_mouse += 1
var b := TestUtils.check(action_menu_mouse > 0,
"remapping leaves mouse events alone")
# Restore the original binding for later tests.
if not old.is_empty():
InputBindings.apply({"inspect": old})
return a and b
func test_apply_accepts_pad_buttons() -> bool:
var remap := {"wand": {"type": "joy", "button": int(JOY_BUTTON_RIGHT_SHOULDER)}}
InputBindings.apply(remap)
var a := TestUtils.check_eq(InputBindings.current_descriptor("wand"),
{"type": "joy", "button": int(JOY_BUTTON_RIGHT_SHOULDER)},
"actions can be rebound to a pad button")
InputBindings.apply({"wand": {"type": "key", "code": int(KEY_C)}})
return a
func test_labels_are_human_readable() -> bool:
var a := TestUtils.check(InputBindings.label_for(
{"type": "key", "code": int(KEY_W)}) == "W", "key label")
var b := TestUtils.check(InputBindings.label_for(
{"type": "joy", "button": 3}) == "Pad 3", "pad label")
var c := TestUtils.check_eq(InputBindings.label_for({}), "", "empty label")
return a and b and c
+77
View File
@@ -0,0 +1,77 @@
extends RefCounted
## Headless tests for the shared surface/environment recipes (ADR-0012).
const Materials := preload("res://src/game/materials.gd")
const GameManager := preload("res://src/game/game_manager.gd")
const Rules := preload("res://src/core/rules.gd")
func test_every_profile_has_physical_values() -> bool:
var ok := true
for role in ["brick", "concrete", "asphalt", "awning", "stone", "metal",
"painted_metal", "wood", "cloth", "skin", "glass", "rubber"]:
var profile: Dictionary = Materials.profile(role)
var roughness: float = float(profile.get("roughness", -1.0))
var metallic: float = float(profile.get("metallic", -1.0))
if roughness < 0.0 or roughness > 1.0:
ok = false
if metallic < 0.0 or metallic > 1.0:
ok = false
return TestUtils.check(ok, "all roles have roughness/metallic in 0..1")
func test_textured_recipes_keep_their_texture_and_profile() -> bool:
var brick := Materials.brick()
var concrete := Materials.concrete()
var asphalt := Materials.asphalt()
var awning := Materials.awning()
var a := TestUtils.check(brick.albedo_texture != null, "brick keeps its texture")
var b := TestUtils.check(concrete.uv1_scale.x > 1.0, "concrete tiles across the sidewalk")
var c := TestUtils.check(asphalt.roughness < brick.roughness, "asphalt is smoother than brick")
var d := TestUtils.check(awning.albedo_texture != null, "awning keeps its texture")
var e := TestUtils.check(Materials.flat(Color.RED, "metal").metallic > 0.5, "metal role is metallic")
return a and b and c and d and e
func test_glass_and_emissive_recipes() -> bool:
var glass := Materials.glass()
var pane := Materials.window_pane()
var bar := Materials.emissive(Color(1.0, 0.35, 0.75), 2.5)
var a := TestUtils.check(glass.transparency == BaseMaterial3D.TRANSPARENCY_ALPHA,
"glass is alpha blended")
var b := TestUtils.check(glass.roughness < 0.2, "glass is smooth")
var c := TestUtils.check(pane.emission_enabled, "window panes can light up")
var d := TestUtils.check(is_equal_approx(pane.emission_energy_multiplier, 0.0),
"window panes start dark")
var e := TestUtils.check(is_equal_approx(bar.emission_energy_multiplier, 2.5),
"emissive energy is applied")
return a and b and c and d and e
func test_environment_tuning_applies_phase_values() -> bool:
var env := Environment.new()
var late: Dictionary = GameManager.PHASE_LIGHTING[Rules.Phase.LATE]
Materials.configure_environment(env, late)
var a := TestUtils.check(env.tonemap_mode == Environment.TONE_MAPPER_ACES,
"ACES tonemapping")
var b := TestUtils.check(env.glow_enabled, "glow enabled for the marquee")
var c := TestUtils.check(env.fog_enabled, "fog enabled for distance haze")
var d := TestUtils.check(env.fog_density > 0.0, "fog has a density")
var e := TestUtils.check(env.adjustment_enabled, "colour grading enabled")
var f := TestUtils.check(
env.fog_light_color.is_equal_approx(late["fog_color"]),
"phase fog colour is used")
return a and b and c and d and e and f
func test_every_phase_has_environment_keys() -> bool:
var ok := true
for phase in [Rules.Phase.EARLY, Rules.Phase.MIDDAY, Rules.Phase.EVENING, Rules.Phase.LATE]:
var cfg: Dictionary = GameManager.PHASE_LIGHTING.get(phase, {})
for key in ["exposure", "glow", "fog_color", "fog_density", "fog_energy",
"contrast", "saturation"]:
if not cfg.has(key):
ok = false
return TestUtils.check(ok, "all phases set exposure/glow/fog/grading")
func test_night_phases_are_night() -> bool:
var evening: Dictionary = GameManager.PHASE_LIGHTING[Rules.Phase.EVENING]
var late: Dictionary = GameManager.PHASE_LIGHTING[Rules.Phase.LATE]
var early: Dictionary = GameManager.PHASE_LIGHTING[Rules.Phase.EARLY]
return TestUtils.check(bool(evening["night"]) and bool(late["night"]) and not bool(early["night"]),
"night flag matches the phase")
+138
View File
@@ -0,0 +1,138 @@
extends RefCounted
## Headless tests for the character pass: layered outfits, hand items, idle
## styles, queue slots, LOD tiers/labels, and the articulated rig structure.
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
const Rules := preload("res://src/core/rules.gd")
func _avatar(npc_type: int, seed_value: int) -> NpcAvatar:
var profile := NpcProfile.new()
profile.id = "char_test"
profile.npc_type = npc_type
profile.display_name = "Test Person"
var avatar := NpcAvatar.new()
avatar.setup(profile, "enter", seed_value,
Vector3(0, 0.5, 6), Vector3(0, 0.5, 1.5), Vector3(0, 0.5, 1.2), "char_test")
return avatar
func test_outfit_layers_and_items_are_deterministic() -> bool:
var a: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 4242)
var b: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 4242)
return TestUtils.check(
a["layer"] == b["layer"] and a["hand_item"] == b["hand_item"]
and a["hair_style"] == b["hair_style"],
"outfit layers, hand item and hair style follow the seed")
func test_layers_follow_roles() -> bool:
var a := TestUtils.check_eq(
NpcAvatar.layer_for(Rules.NpcType.RESPONDER, 1), "vest",
"responders wear a hi-vis vest")
var b := TestUtils.check_eq(
NpcAvatar.layer_for(Rules.NpcType.INFLUENCER, 1), "coat",
"influencers wear a coat")
var c := TestUtils.check_eq(
NpcAvatar.layer_for(Rules.NpcType.AUTHORIZED_STAFF, 1), "jacket",
"staff wear a jacket")
return a and b and c
func test_hand_items_are_known_and_deterministic() -> bool:
var allowed := ["phone", "cup", "clipboard", "umbrella", "camera", "none"]
var ok := true
for seed_value in range(1, 30):
var item := NpcAvatar.hand_item_for(Rules.NpcType.TOURIST, seed_value * 31)
ok = ok and item in allowed
ok = ok and item == NpcAvatar.hand_item_for(
Rules.NpcType.TOURIST, seed_value * 31)
return TestUtils.check(ok, "hand items stay in the catalogue and are deterministic")
func test_idle_styles_are_known() -> bool:
var allowed := ["phone", "crossed", "look", "clipboard", "breathe"]
var ok := true
for seed_value in range(1, 30):
var style := NpcAvatar.idle_style_for(Rules.NpcType.TOURIST, seed_value) \
if seed_value > 1 else NpcAvatar.idle_style_for(Rules.NpcType.INFLUENCER, 1)
ok = ok and style in allowed
return TestUtils.check(ok, "idle styles stay in the catalogue")
func test_queue_slots_are_spaced_and_unique() -> bool:
var seen := {}
var ok := true
var previous := NpcAvatar.queue_slot_for(0)
for index in 6:
var slot := NpcAvatar.queue_slot_for(index)
ok = ok and not seen.has(slot)
seen[slot] = true
if index > 0:
ok = ok and slot.distance_to(previous) > 0.3
previous = slot
return TestUtils.check(ok and NpcAvatar.queue_slot_for(0) == Vector3.ZERO,
"queue slots are unique, spaced, and start at the door")
func test_lod_tiers_use_hysteresis() -> bool:
var avatar := NpcAvatar.new()
avatar._lod_tier = 0
var a := TestUtils.check_eq(avatar._lod_tier_for(5.0), 0, "near stays full detail")
var b := TestUtils.check_eq(avatar._lod_tier_for(16.0), 1,
"past the hysteresis band switches to simplified")
avatar._lod_tier = 1
var c := TestUtils.check_eq(avatar._lod_tier_for(13.0), 1,
"hysteresis keeps the simplified tier just inside the edge")
var d := TestUtils.check_eq(avatar._lod_tier_for(12.0), 0,
"clearing the hysteresis band returns to full detail")
avatar._lod_tier = 1
var e := TestUtils.check_eq(avatar._lod_tier_for(30.0), 2, "far uses the impostor")
avatar.free()
return a and b and c and d and e
func test_reaction_labels_match_state() -> bool:
var a := TestUtils.check_eq(NpcAvatar.reaction_label("flee", 0), "!!", "panic label")
var b := TestUtils.check_eq(NpcAvatar.reaction_label("watch", 0), "?!", "watching label")
var c := TestUtils.check_eq(NpcAvatar.reaction_label("", 3), "!", "blocked label")
var d := TestUtils.check_eq(NpcAvatar.reaction_label("", 0), "", "calm has no label")
return a and b and c and d
func test_walk_to_idle_animation_transition() -> bool:
var avatar := _avatar(Rules.NpcType.TOURIST, 21)
var hip := avatar.find_child("HipL", true, false) as Node3D
var a := TestUtils.check(hip != null, "rig has a hip pivot")
avatar.velocity = Vector3(0, 0, 1.2)
avatar._animate(0.016)
var walking := absf(hip.rotation.x) > 0.01
var b := TestUtils.check(walking, "walking swings the hip")
avatar.velocity = Vector3.ZERO
for i in 90:
avatar._animate(0.016)
var c := TestUtils.check(absf(hip.rotation.x) < 0.05,
"stopping settles the hip back to the idle pose (%.3f)" % hip.rotation.x)
avatar.free()
return a and b and c
func test_rig_is_articulated() -> bool:
var avatar := _avatar(Rules.NpcType.TOURIST, 77)
var a := TestUtils.check(avatar.find_child("HipL", true, false) != null
and avatar.find_child("Knee", true, false) != null,
"legs have hip and knee pivots")
var b := TestUtils.check(avatar.find_child("ShoulderR", true, false) != null
and avatar.find_child("Elbow", true, false) != null
and avatar.find_child("Hand", true, false) != null,
"arms have shoulder, elbow and hand pivots")
var c := TestUtils.check(avatar.get_node_or_null("Rig/Head") != null,
"the focus code can still find Rig/Head")
avatar.free()
return a and b and c
func test_avatar_mesh_budget_and_lod_swap() -> bool:
var avatar := _avatar(Rules.NpcType.TOURIST, 12)
var meshes := avatar.find_children("*", "MeshInstance3D", true, false)
var a := TestUtils.check(meshes.size() <= 40,
"avatar stays inside the mesh budget (%d)" % meshes.size())
avatar.apply_lod(2)
var impostor := avatar.get_node_or_null("Impostor") as Node3D
var rig := avatar.get_node_or_null("Rig") as Node3D
var b := TestUtils.check(impostor != null and impostor.visible, "far LOD shows the impostor")
var c := TestUtils.check(rig != null and not rig.visible, "far LOD hides the full rig")
avatar.apply_lod(0)
var d := TestUtils.check(rig.visible and not impostor.visible, "near LOD restores the rig")
avatar.free()
return a and b and c and d
+35 -1
View File
@@ -43,11 +43,45 @@ func test_settings_round_trip() -> bool:
save.free() save.free()
return a and b and c return a and b and c
func test_camera_settings_round_trip() -> bool:
var save := _save_instance()
var err: int = save.save_settings({
"look_sensitivity": 0.0028, "hud_font_scale": 1.0,
"invert_y": true, "head_bob": false,
})
var a := TestUtils.check_eq(err, OK, "camera settings save")
var loaded: Dictionary = save.load_settings()
var b := TestUtils.check_eq(bool(loaded.get("invert_y", false)), true,
"invert_y survives the round trip")
var c := TestUtils.check_eq(bool(loaded.get("head_bob", true)), false,
"head_bob survives the round trip")
save.save_settings(save.DEFAULT_SETTINGS)
save.free()
return a and b and c
func test_input_bindings_round_trip() -> bool:
var save := _save_instance()
var bindings := {"inspect": {"type": "key", "code": int(KEY_Z)}}
var err: int = save.save_settings({"input_bindings": bindings})
var a := TestUtils.check_eq(err, OK, "bindings save")
var loaded: Dictionary = save.load_settings()
var stored: Dictionary = loaded.get("input_bindings", {})
var inspect: Dictionary = stored.get("inspect", {})
var b := TestUtils.check_eq(int(inspect.get("code", 0)), int(KEY_Z),
"bindings survive the settings round trip")
var c := TestUtils.check_eq(str(inspect.get("type", "")), "key",
"binding type survives the settings round trip")
save.save_settings(save.DEFAULT_SETTINGS)
save.free()
return a and b and c
func test_settings_defaults_present() -> bool: func test_settings_defaults_present() -> bool:
var save := _save_instance() var save := _save_instance()
var defaults: Dictionary = save.load_settings() var defaults: Dictionary = save.load_settings()
var ok := TestUtils.check( var ok := TestUtils.check(
defaults.has("look_sensitivity") and defaults.has("hud_font_scale"), defaults.has("look_sensitivity") and defaults.has("hud_font_scale")
and defaults.has("invert_y") and defaults.has("head_bob")
and defaults.has("input_bindings"),
"settings have documented defaults") "settings have documented defaults")
save.free() save.free()
return ok return ok
+54
View File
@@ -0,0 +1,54 @@
extends RefCounted
## Headless tests for movement constants, surface lookup, gamepad bindings, and
## the settings that drive the camera feel.
const GuardController := preload("res://src/game/guard_controller.gd")
func test_surface_lookup_covers_the_block() -> bool:
var guard := GuardController.new()
var a := TestUtils.check_eq(guard._surface_at(Vector3(0, 0.2, 2.0)), "stone",
"the entrance steps are stone")
var b := TestUtils.check_eq(guard._surface_at(Vector3(3, 0.2, 8.0)), "concrete",
"the sidewalk is concrete")
var c := TestUtils.check_eq(guard._surface_at(Vector3(2, 0.1, 18.0)), "asphalt",
"the roadway is asphalt")
guard.free()
return a and b and c
func test_step_and_stride_stay_modest() -> bool:
var a := TestUtils.check(GuardController.STEP_HEIGHT <= 0.35,
"step-up stays at kerb height")
var b := TestUtils.check(GuardController.STRIDE_LENGTH >= 0.5
and GuardController.STRIDE_LENGTH <= 1.2, "stride stays human-sized")
var c := TestUtils.check(GuardController.SLOW_WALK_FACTOR < 1.0
and GuardController.SLOW_WALK_FACTOR > 0.3, "precision walk is slower but usable")
return a and b and c
func test_gamepad_bindings_are_added_once() -> bool:
GuardController._ensure_gamepad_bindings()
GuardController._ensure_gamepad_bindings()
var expected := ["look_left", "look_right", "look_up", "look_down",
"move_forward", "move_back", "move_left", "move_right"]
var problems: Array[String] = []
for action in expected:
if not InputMap.has_action(action):
problems.append("%s missing" % action)
continue
var motions := 0
for event in InputMap.action_get_events(action):
if event is InputEventJoypadMotion:
motions += 1
if motions == 0:
problems.append("%s has no joypad axis" % action)
if InputMap.action_get_deadzone(action) > 0.25:
problems.append("%s deadzone too high" % action)
var buttons := {"action_menu": JOY_BUTTON_A, "cancel": JOY_BUTTON_B,
"inspect": JOY_BUTTON_X, "walk_slow": JOY_BUTTON_LEFT_SHOULDER}
for action in buttons.keys():
var found := false
for event in InputMap.action_get_events(action):
if event is InputEventJoypadButton and event.button_index == buttons[action]:
found = true
if not found:
problems.append("%s has no gamepad button" % action)
return TestUtils.check(problems.is_empty(), "gamepad actions bound (%s)" % str(problems))
+143
View File
@@ -0,0 +1,143 @@
extends RefCounted
## Authored scenario content added to match the PLAN lists: Joke Shop access
## edge cases, Studio filming boundaries and celebrity crowd spikes, and Building
## events that point at the body-camera / request-reason verbs.
const Factory := preload("res://src/content/scenario_factory.gd")
const Rules := preload("res://src/core/rules.gd")
const Strings := preload("res://src/core/strings.gd")
const AccessLoadout := preload("res://src/core/access_loadout.gd")
const AccessVerifier := preload("res://src/core/access_verifier.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
func _events(scenario_id: String) -> Array:
var scenario: Resource
match scenario_id:
"joke_shop":
scenario = Factory.joke_shop()
"tv_studio":
scenario = Factory.tv_studio()
_:
scenario = Factory.hollywood_building()
return scenario.event_pool
func _event(scenario_id: String, id: String) -> Dictionary:
for event in _events(scenario_id):
if str(event.get("id", "")) == id:
return event
return {}
func test_joke_shop_has_the_missing_edge_cases() -> bool:
var a := TestUtils.check(not _event("joke_shop", "joke_guest_reservation_no_id").is_empty(),
"reservation list without ID exists")
var b := TestUtils.check(not _event("joke_shop", "joke_borrowed_id").is_empty(),
"borrowed/mismatched ID exists")
var c := TestUtils.check(not _event("joke_shop", "joke_refuses_wand").is_empty(),
"guest who refuses the wand exists")
var d := TestUtils.check(not _event("joke_shop", "joke_late_arrival").is_empty(),
"late arrival during the performance exists")
return a and b and c and d
func test_reservation_without_id_is_denied() -> bool:
var event := _event("joke_shop", "joke_guest_reservation_no_id")
var profile := _guest_profile()
var artifacts := AccessLoadout.build(profile, Factory.joke_shop().access_policy, event.get("override_access", {}))
var verifier := AccessVerifier.new()
var result: Dictionary = verifier.verify(artifacts, Factory.joke_shop().access_policy, false)
var a := TestUtils.check(not result.get("granted", true), "a reservation without ID is not admitted")
var b := TestUtils.check(not (result.get("issues", []) as Array).is_empty(), "the denial names the missing artifact")
return a and b
func test_borrowed_id_is_mismatched() -> bool:
var event := _event("joke_shop", "joke_borrowed_id")
var profile := _guest_profile()
var artifacts := AccessLoadout.build(profile, Factory.joke_shop().access_policy, event.get("override_access", {}))
var found_mismatch := false
for artifact in artifacts:
if artifact.kind == Rules.AccessKind.ID and artifact.status == Rules.ArtifactStatus.MISMATCHED:
found_mismatch = true
var verifier := AccessVerifier.new()
var result: Dictionary = verifier.verify(artifacts, Factory.joke_shop().access_policy, false)
var a := TestUtils.check(found_mismatch, "the ID is flagged mismatched")
var b := TestUtils.check(not result.get("granted", true), "a mismatched ID is not admitted")
return a and b
func test_refuses_wand_event_sets_the_flag() -> bool:
var event := _event("joke_shop", "joke_refuses_wand")
var ok := TestUtils.check(bool(event.get("refuses_screening", false)), "the event marks the refusal")
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var wired := TestUtils.check(src.contains("refuses_screening = bool(event.get("),
"the spawn copies the flag onto the controller")
var handled := TestUtils.check(src.contains("if controller.refuses_screening:"),
"the wand screen handles a refusal")
return ok and wired and handled
func test_studio_filming_boundary_repeats() -> bool:
var first := _event("tv_studio", "studio_film_boundary")
var repeat := _event("tv_studio", "studio_film_boundary_repeat")
var a := TestUtils.check(not first.is_empty(), "first boundary event exists")
var b := TestUtils.check(not repeat.is_empty(), "repeat boundary event exists")
var c := TestUtils.check_eq(str(first.get("intent", "")), "film_boundary", "first uses the boundary intent")
var d := TestUtils.check_eq(str(repeat.get("intent", "")), "film_boundary", "repeat uses the boundary intent")
var e := TestUtils.check(int(repeat.get("time_range_s", Vector2.ZERO).x) > int(first.get("time_range_s", Vector2.ZERO).x),
"the repeat happens later in the shift")
return a and b and c and d and e
func test_film_boundary_stands_at_the_steps() -> bool:
var scenario := Factory.tv_studio()
var spot: Vector3 = scenario.waypoint_for_intent("film_boundary")
var sidewalk: Vector3 = scenario.waypoint_for_intent("film")
var a := TestUtils.check(spot != sidewalk, "the boundary spot differs from the permitted sidewalk spot")
var b := TestUtils.check_eq(spot, scenario._location_or_default("steps_end", Vector3.ZERO),
"the influencer stands at the steps, past the line")
return a and b
func test_celebrity_arrival_spawns_a_crowd() -> bool:
var event := _event("tv_studio", "studio_celebrity_arrival")
var extras: Array = event.get("extra_spawns", [])
var a := TestUtils.check(extras.size() >= 3, "celebrity arrival adds several bystanders")
var types: Array = []
for extra in extras:
types.append(int((extra as Dictionary).get("npc_type", -1)))
var b := TestUtils.check(types.has(Rules.NpcType.TOURIST), "tourists gather")
var c := TestUtils.check(types.has(Rules.NpcType.INFLUENCER), "an influencer films it")
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var d := TestUtils.check(src.contains('event.get("extra_spawns", [])'), "the spawner honours extra_spawns")
return a and b and c and d
func test_building_events_point_at_the_new_verbs() -> bool:
var camera_event := _event("hollywood_building", "building_filming_boundary")
var reason_event := _event("hollywood_building", "building_unclear_intent")
var a := TestUtils.check(str(camera_event.get("hint", "")).contains("body"), "an event hints at the body camera")
var b := TestUtils.check(str(reason_event.get("hint", "")).contains("reason"), "an event hints at requesting a reason")
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var c := TestUtils.check(src.contains('if event.has("hint")'), "spawning logs the authored hint")
return a and b and c
func test_every_authored_hint_resolves() -> bool:
var missing: Array[String] = []
for scenario in [Factory.hollywood_building(), Factory.joke_shop(), Factory.tv_studio()]:
for event in scenario.event_pool:
var key := str((event as Dictionary).get("hint", ""))
if key != "" and Strings.lookup(key) == key:
missing.append(key)
return TestUtils.check(missing.is_empty(), "all authored hints have strings (missing: %s)" % str(missing))
func test_new_events_have_unique_ids() -> bool:
# Event ids only need to be unique within a venue.
var problems: Array[String] = []
for scenario in [Factory.hollywood_building(), Factory.joke_shop(), Factory.tv_studio()]:
var seen := {}
for event in scenario.event_pool:
var id := str((event as Dictionary).get("id", ""))
if seen.has(id):
problems.append("%s:%s" % [scenario.id, id])
seen[id] = true
return TestUtils.check(problems.is_empty(), "event ids are unique per venue (dupes: %s)" % str(problems))
func _guest_profile() -> NpcProfile:
var p := NpcProfile.new()
p.id = "guest"
p.display_name = "Guest"
p.npc_type = Rules.NpcType.AUTHORIZED_GUEST
return p
+64
View File
@@ -23,6 +23,70 @@ func test_strings_json_backed_all_resolve() -> bool:
return TestUtils.fail("key %s resolves to itself — table entry missing" % key) return TestUtils.fail("key %s resolves to itself — table entry missing" % key)
return true return true
func test_placeholder_count() -> bool:
var a := TestUtils.check_eq(Strings.placeholder_count("no args"), 0, "plain text has no placeholders")
var b := TestUtils.check_eq(Strings.placeholder_count("%s complies"), 1, "one %s")
var c := TestUtils.check_eq(Strings.placeholder_count("%s: \"%s\""), 2, "two %s")
var d := TestUtils.check_eq(Strings.placeholder_count("100%% sure"), 0, "%% is an escape, not an argument")
var e := TestUtils.check_eq(Strings.placeholder_count("%.0f%% mood"), 1, "float plus percent sign")
return a and b and c and d and e
func test_fmt_fails_safe_on_arg_mismatch() -> bool:
## A drifting placeholder count must not raise an engine error at runtime.
var result: String = Strings.fmt("react.comply", [])
var a := TestUtils.check_eq(result, Strings.lookup("react.comply"), "wrong arg count returns the raw template")
var extra: String = Strings.fmt("react.comply", ["A", "B"])
var b := TestUtils.check_eq(extra, Strings.lookup("react.comply"), "too many args also fails safe")
return a and b
func test_table_validates_cleanly() -> bool:
var problems: Array = Strings.validate()
if not problems.is_empty():
return TestUtils.fail("string table format problems: %s" % str(problems))
return true
func test_every_placeholder_key_formats() -> bool:
## Feed each template one dummy arg per placeholder, with a type that matches
## the conversion (%s text, %d int, %f float). Nothing may be left unformatted.
var problems: Array[String] = []
for key in Strings.TABLE.keys():
var template: String = str(Strings.TABLE[key])
var specs: Array[String] = _specifiers(template)
if specs.is_empty():
continue
var args := []
for spec in specs:
var conv: String = spec.substr(spec.length() - 1)
if conv == "d" or conv == "x" or conv == "X":
args.append(7)
elif conv == "f":
args.append(1.5)
else:
args.append("x")
var out: String = Strings.fmt(key, args)
if out == template:
problems.append("%s did not format (%s)" % [key, str(specs)])
return TestUtils.check(problems.is_empty(), "all placeholder keys format (problems: %s)" % str(problems))
func _specifiers(template: String) -> Array[String]:
var out: Array[String] = []
var i := 0
while i < template.length():
if template[i] != "%":
i += 1
continue
if i + 1 < template.length() and template[i + 1] == "%":
i += 2
continue
var j := i + 1
while j < template.length() and template[j] in "0123456789.$":
j += 1
if j < template.length():
out.append(template.substr(i, j - i + 1))
j += 1
i = j
return out
func test_strings_cover_ui_keys() -> bool: func test_strings_cover_ui_keys() -> bool:
## Every key referenced by the HUD and game manager must exist. ## Every key referenced by the HUD and game manager must exist.
var required := [ var required := [
+88
View File
@@ -0,0 +1,88 @@
extends RefCounted
## Headless tests for focus ranking, aim-free cycling helpers, and the NPC
## outfit/gait helpers added in the control re-evaluation (ADR-0011).
const NpcFocus := preload("res://src/game/npc_focus.gd")
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
const Rules := preload("res://src/core/rules.gd")
func test_rank_orders_by_angle_then_distance() -> bool:
var origin := Vector3.ZERO
var forward := Vector3(0, 0, -1)
# Index 2 is dead ahead and near, index 0 dead ahead but far, index 1 off to
# the side. Ranking must be angle first, then distance.
var heads := [Vector3(0, 0, -9), Vector3(0.9, 0, -6), Vector3(0, 0, -5)]
var ranked: Array = NpcFocus.rank_indices(origin, forward, heads)
var a := TestUtils.check_eq(ranked, [2, 0, 1], "angle first, then distance")
var b := TestUtils.check_eq(NpcFocus.pick_index(origin, forward, heads), 2,
"pick_index returns the best-ranked head")
return a and b
func test_rank_excludes_out_of_cone_and_range() -> bool:
var origin := Vector3.ZERO
var forward := Vector3(0, 0, -1)
var heads := [
Vector3(0, 0, 40), # too far
Vector3(8, 0, -5), # too wide
Vector3(0, 0, 6), # behind the camera
Vector3(0.2, 0, -7), # valid
]
var ranked: Array = NpcFocus.rank_indices(origin, forward, heads)
return TestUtils.check_eq(ranked, [3], "only the in-cone head is ranked")
func test_rank_is_empty_when_nobody_is_in_the_cone() -> bool:
var ranked: Array = NpcFocus.rank_indices(
Vector3.ZERO, Vector3(0, 0, -1), [Vector3(0, 0, 30)])
var picked := NpcFocus.pick_index(Vector3.ZERO, Vector3(0, 0, -1), [Vector3(0, 0, 30)])
return TestUtils.check(ranked.is_empty() and picked == -1, "empty ranking and -1 pick")
func test_limb_swing_is_opposite_per_side() -> bool:
var left := NpcAvatar.limb_swing(PI / 2.0, 0, 1.0)
var right := NpcAvatar.limb_swing(PI / 2.0, 1, 1.0)
var still := NpcAvatar.limb_swing(0.0, 0, 1.0)
var a := TestUtils.check(absf(left - 1.0) < 0.001, "left limb swings to +amplitude")
var b := TestUtils.check(absf(right + 1.0) < 0.001, "right limb swings the other way")
var c := TestUtils.check(absf(still) < 0.001, "phase 0 is the neutral pose")
return a and b and c
func test_outfit_is_deterministic_per_seed() -> bool:
var first: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 991)
var second: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 991)
var a := TestUtils.check(first["shirt"] == second["shirt"], "same seed, same shirt")
var b := TestUtils.check(first["pants"] == second["pants"], "same seed, same trousers")
var c := TestUtils.check(first["accessory"] == second["accessory"], "same seed, same accessory")
return a and b and c
func test_outfit_varies_across_seeds() -> bool:
var shirts := {}
var pants := {}
for seed_value in [1, 2, 3, 4, 5, 6, 7, 8]:
var outfit: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, seed_value * 977)
shirts[outfit["shirt"]] = true
pants[outfit["pants"]] = true
var a := TestUtils.check(shirts.size() > 1, "shirt colour varies between people")
var b := TestUtils.check(pants.size() > 1, "trouser colour varies between people")
return a and b
func test_accessories_follow_the_role() -> bool:
var a := TestUtils.check_eq(
NpcAvatar.outfit_for(Rules.NpcType.RESPONDER, 5)["accessory"], "cap",
"responders wear a cap")
var b := TestUtils.check_eq(
NpcAvatar.outfit_for(Rules.NpcType.TRESPASSER, 5)["accessory"], "hood",
"trespassers wear a hood")
var c := TestUtils.check_eq(
NpcAvatar.outfit_for(Rules.NpcType.AUTHORIZED_GUEST, 5)["accessory"], "lanyard",
"guests wear a lanyard")
var d := TestUtils.check_eq(
NpcAvatar.outfit_for(Rules.NpcType.INTOXICATED, 5)["accessory"], "none",
"roles without an accessory say none")
return a and b and c and d
func test_height_scale_stays_human() -> bool:
var ok := true
for seed_value in range(1, 40):
var scale_y: float = float(NpcAvatar.outfit_for(Rules.NpcType.TOURIST, seed_value * 131)["height_scale"])
if scale_y < 0.9 or scale_y > 1.1:
ok = false
return TestUtils.check(ok, "avatar height scale stays between 0.9 and 1.1")
+62
View File
@@ -0,0 +1,62 @@
extends RefCounted
## Venues can be authored as `.tres` resources: the built-in factories export and
## round-trip without losing their data (optional editor workflow, TODO tooling).
const Factory := preload("res://src/content/scenario_factory.gd")
const Rules := preload("res://src/core/rules.gd")
const VenueScenario := preload("res://src/core/venue_scenario.gd")
func test_venues_round_trip_through_tres() -> bool:
var scenarios := [Factory.hollywood_building(), Factory.joke_shop(), Factory.tv_studio()]
for scenario in scenarios:
var path := "user://%s_roundtrip.tres" % scenario.id
var err: int = ResourceSaver.save(scenario, path)
if err != OK:
return TestUtils.fail("could not save %s (error %d)" % [scenario.id, err])
var loaded = ResourceLoader.load(path)
if not (loaded is VenueScenario):
return TestUtils.fail("%s did not load back as VenueScenario" % scenario.id)
var a := TestUtils.check_eq(loaded.id, scenario.id, "%s id survives" % scenario.id)
var b := TestUtils.check_eq(loaded.display_name, scenario.display_name, "%s name survives" % scenario.id)
var c := TestUtils.check_eq(loaded.event_pool.size(), scenario.event_pool.size(), "%s event pool survives" % scenario.id)
var d := TestUtils.check_eq(loaded.npc_profiles.size(), scenario.npc_profiles.size(), "%s profiles survive" % scenario.id)
var e := TestUtils.check_eq(loaded.waypoint_for_intent("sit"), scenario.waypoint_for_intent("sit"), "%s locations survive" % scenario.id)
if not (a and b and c and d and e):
return false
return true
func test_exported_tres_files_match_the_factory() -> bool:
## content/venues/*.tres is generated by tools/export_venues.gd; make sure it is
## present and not stale relative to the factory.
for scenario in [Factory.hollywood_building(), Factory.joke_shop(), Factory.tv_studio()]:
var path := "res://content/venues/%s.tres" % scenario.id
if not FileAccess.file_exists(path):
return TestUtils.fail("missing exported venue %s — run tools/export_venues.gd" % path)
var loaded = ResourceLoader.load(path)
if not (loaded is VenueScenario):
return TestUtils.fail("%s is not a VenueScenario" % path)
if loaded.event_pool.size() != scenario.event_pool.size():
return TestUtils.fail("%s is stale (events %d vs %d) — re-run tools/export_venues.gd" % [
path, loaded.event_pool.size(), scenario.event_pool.size()])
if not TestUtils.check_eq(loaded.shift_duration_s, scenario.shift_duration_s, "%s duration matches" % scenario.id):
return false
return true
func test_exported_policy_and_events_intact() -> bool:
var loaded = ResourceLoader.load("res://content/venues/joke_shop.tres")
var a := TestUtils.check(loaded != null and loaded.access_policy != null, "access policy survives export")
var b := TestUtils.check_eq(loaded.access_policy.require_id, true, "joke shop still requires ID")
var c := TestUtils.check_eq(loaded.access_policy.wand_screening_required, true, "wand screening still required")
var d := TestUtils.check_eq(loaded.equipment.has("wand"), true, "equipment survives export")
var ids: Array = []
for event in loaded.event_pool:
ids.append(str((event as Dictionary).get("id", "")))
var e := TestUtils.check(ids.has("joke_refuses_wand"), "authored events survive export")
var f := TestUtils.check(loaded.door_staff_lines.has("refusal"), "door-staff lines survive export")
return a and b and c and d and e and f
func test_exported_phases_survive() -> bool:
var loaded = ResourceLoader.load("res://content/venues/hollywood_building.tres")
var a := TestUtils.check(loaded.shift_phases.has(Rules.Phase.LATE), "late phase survives export")
var b := TestUtils.check_eq(int(loaded.shift_phases[Rules.Phase.LATE].get("start_s", -1)), 700, "late start preserved")
return a and b
+65
View File
@@ -0,0 +1,65 @@
extends RefCounted
## Web export target: the preset exists, uses the non-threaded template (so it
## runs on plain static hosting without COOP/COEP headers), and the release
## scripts build and publish it.
func _preset() -> String:
## Return the `[preset.N]` block whose name is Web plus its `.options` block.
var lines := FileAccess.get_file_as_string("res://export_presets.cfg").split("\n")
var sections := {}
var current := ""
var web_id := ""
for line in lines:
if line.begins_with("[") and line.ends_with("]"):
current = line
sections[current] = PackedStringArray()
continue
if sections.has(current):
# PackedStringArray is a value type: mutate a local and write it back.
var block: PackedStringArray = sections[current]
block.append(line)
sections[current] = block
if line == 'name="Web"':
web_id = current.trim_prefix("[").trim_suffix("]")
if web_id == "":
return ""
var out: PackedStringArray = []
out.append("[%s]" % web_id)
out.append_array(sections.get("[%s]" % web_id, PackedStringArray()))
var options := "[%s.options]" % web_id
if sections.has(options):
out.append(options)
out.append_array(sections[options])
return "\n".join(out)
func test_web_preset_exists() -> bool:
var preset := _preset()
var a := TestUtils.check(preset != "", "a Web export preset exists")
var b := TestUtils.check(preset.contains('platform="Web"'), "preset targets the Web platform")
var c := TestUtils.check(preset.contains('export_path="build/web/index.html"'), "preset writes build/web/index.html")
return a and b and c
func test_web_preset_is_static_hosting_friendly() -> bool:
var preset := _preset()
return TestUtils.check(preset.contains("variant/thread_support=false"),
"threads are disabled so no COOP/COEP headers are required")
func test_release_scripts_build_the_web_zip() -> bool:
var build := FileAccess.get_file_as_string("res://tools/build_releases.sh")
var a := TestUtils.check(build.contains('--export-release "Web"'), "build script exports Web")
var b := TestUtils.check(build.contains("step-line-web-${VERSION}.zip"), "build script names the web zip")
var publish := FileAccess.get_file_as_string("res://tools/publish_release.sh")
var c := TestUtils.check(publish.contains("step-line-web-$VERSION.zip"), "publish script uploads the web zip")
return a and b and c
func test_web_build_files_present_when_exported() -> bool:
## Only asserts shape if a web build exists in build/web (created by the
## release script); missing build output is not a test failure by itself.
var index := "res://build/web/index.html"
if not FileAccess.file_exists(index):
return true
var html := FileAccess.get_file_as_string(index)
var a := TestUtils.check(html.contains("index.js"), "index.html loads index.js")
var b := TestUtils.check(FileAccess.file_exists("res://build/web/index.wasm"), "wasm payload exists")
var c := TestUtils.check(FileAccess.file_exists("res://build/web/index.pck"), "pck payload exists")
return a and b and c
+21
View File
@@ -10,6 +10,7 @@
# build/step-line-linux-<version>.x86_64 # build/step-line-linux-<version>.x86_64
# build/step-line-windows-<version>.exe # build/step-line-windows-<version>.exe
# build/step-line-macos-<version>.zip # build/step-line-macos-<version>.zip
# build/step-line-web-<version>.zip (Godot web build: static hosting, no threads)
# #
set -euo pipefail set -euo pipefail
@@ -28,6 +29,26 @@ echo "==> Exporting Windows Desktop"
echo "==> Exporting macOS" echo "==> Exporting macOS"
"$GODOT" --headless --export-release "macOS" "build/step-line-macos-${VERSION}.zip" "$GODOT" --headless --export-release "macOS" "build/step-line-macos-${VERSION}.zip"
echo "==> Exporting Web (no threads, static-hosting friendly)"
rm -rf build/web
mkdir -p build/web
"$GODOT" --headless --export-release "Web" "build/web/index.html"
# Ship the web build as a single zip so it can be downloaded and served as-is.
rm -f "build/step-line-web-${VERSION}.zip"
if command -v zip >/dev/null 2>&1; then
(cd build/web && zip -q -r "../step-line-web-${VERSION}.zip" .)
else
python3 - "$PWD/build/web" "$PWD/build/step-line-web-${VERSION}.zip" <<'PYZIP'
import os, sys, zipfile
src, out = sys.argv[1], sys.argv[2]
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as archive:
for root, _dirs, files in os.walk(src):
for name in files:
path = os.path.join(root, name)
archive.write(path, os.path.relpath(path, src))
PYZIP
fi
echo "" echo ""
echo "Artifacts:" echo "Artifacts:"
ls -la build/*"${VERSION}"* ls -la build/*"${VERSION}"*
+26
View File
@@ -0,0 +1,26 @@
extends SceneTree
## Export the built-in scenarios to `.tres` resources for editor authoring.
##
## Usage:
## godot --headless --path . --script res://tools/export_venues.gd
##
## Writes content/venues/<id>.tres. These mirror ScenarioFactory exactly; the
## factory stays the source of truth for the prototype, and the exported files are
## what an editor-based workflow would edit and ship.
const Factory := preload("res://src/content/scenario_factory.gd")
func _initialize() -> void:
var out_dir := "res://content/venues"
DirAccess.make_dir_recursive_absolute(out_dir)
var scenarios := [Factory.hollywood_building(), Factory.joke_shop(), Factory.tv_studio()]
var failures := 0
for scenario in scenarios:
var path := "%s/%s.tres" % [out_dir, scenario.id]
var err: int = ResourceSaver.save(scenario, path)
if err == OK:
print("wrote %s" % path)
else:
printerr("failed to write %s (error %d)" % [path, err])
failures += 1
quit(0 if failures == 0 else 1)
+933
View File
@@ -0,0 +1,933 @@
#!/usr/bin/env python3
"""Build the low-poly prop models used in the Step Line street scene.
The scene needs street furniture (bollards, benches, bins, planters, newspaper
boxes, a hydrant) that reads as solid geometry instead of flat boxes. This tool
keeps that art direction in one place:
* every prop is described as a small list of boxes and cylinders, so the
build is deterministic and runs offline in CI;
* an optional AI concept image per prop (OpenRouter, the same backend as
`generate_textures.py`) supplies the colour palette, so a model can look
like the concept without a 3D-model API;
* `build` writes standard glTF binary (`.glb`) files that Godot 4 imports as
`PackedScene`s, and `audit` checks the files are structurally valid and stay
inside the art budget (<= 400 triangles, <= 600 vertices per prop, no
degenerate triangles, UVs in the atlas, base at y ~= 0, one material).
Commands (run from the repo root):
python3 tools/model_pipeline.py list
python3 tools/model_pipeline.py build
python3 tools/model_pipeline.py audit
python3 tools/model_pipeline.py concept # needs OPENROUTER_API_KEY
python3 tools/model_pipeline.py concept --only bench
The concept step is optional: `build` never needs the network. If
`assets/models/concepts/<prop>.png` exists, its dominant colours replace the
role colours from the catalogue, which is the only AI influence on the mesh.
"""
import argparse
import json
import math
import os
import struct
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
MODELS_DIR = ROOT / "assets" / "models" / "generated"
CONCEPTS_DIR = ROOT / "assets" / "models" / "concepts"
# Reuse the validated OpenRouter image backend; keep the import lazy so `list`,
# `build` and `audit` work without Pillow installed? Pillow is needed for the
# concept palette step, so it stays a normal import inside those functions.
DEFAULT_CONCEPT_MODEL = "openai/gpt-5-image-mini"
# ---------------------------------------------------------------------------
# Catalogue. `role` names the palette slot a part uses:
# body / accent / metal / wood / foliage / stone / glass
# `concept` prompts ask for a single object on a plain background.
# ---------------------------------------------------------------------------
PROPS = [
{
"name": "bollard",
"concept": ("product photo of a single short black steel street bollard "
"with a reflective band, plain light grey studio background"),
"parts": [
{"shape": "cylinder", "radius": 0.09, "height": 0.92,
"at": [0, 0.46, 0], "segments": 12, "role": "metal"},
{"shape": "cylinder", "radius": 0.095, "height": 0.07,
"at": [0, 0.80, 0], "segments": 12, "role": "accent"},
{"shape": "cylinder", "radius": 0.10, "height": 0.05,
"at": [0, 0.94, 0], "segments": 12, "role": "metal"},
],
},
{
"name": "bench",
"concept": ("product photo of a single park bench with wooden slats and "
"dark metal legs, plain light grey studio background"),
"parts": [
{"shape": "box", "size": [1.80, 0.08, 0.52],
"at": [0, 0.46, 0], "role": "wood"},
{"shape": "box", "size": [1.80, 0.42, 0.07],
"at": [0, 0.72, -0.22], "role": "wood"},
{"shape": "box", "size": [0.09, 0.46, 0.46],
"at": [-0.72, 0.23, 0], "role": "metal"},
{"shape": "box", "size": [0.09, 0.46, 0.46],
"at": [0.72, 0.23, 0], "role": "metal"},
{"shape": "box", "size": [0.07, 0.40, 0.07],
"at": [-0.72, 0.72, -0.20], "role": "metal"},
{"shape": "box", "size": [0.07, 0.40, 0.07],
"at": [0.72, 0.72, -0.20], "role": "metal"},
],
},
{
"name": "trash_bin",
"concept": ("product photo of a single dark green cylindrical street "
"litter bin with a domed lid, plain light grey studio background"),
"parts": [
{"shape": "cylinder", "radius": 0.27, "height": 0.78,
"at": [0, 0.39, 0], "segments": 14, "role": "body"},
{"shape": "cylinder", "radius": 0.29, "height": 0.06,
"at": [0, 0.81, 0], "segments": 14, "role": "metal"},
{"shape": "cylinder", "radius": 0.24, "height": 0.04,
"at": [0, 0.86, 0], "segments": 14, "role": "accent"},
],
},
{
"name": "planter_box",
"concept": ("product photo of a single square concrete planter box with a "
"small green shrub, plain light grey studio background"),
"parts": [
{"shape": "box", "size": [0.96, 0.54, 0.96],
"at": [0, 0.27, 0], "role": "stone"},
{"shape": "box", "size": [0.84, 0.04, 0.84],
"at": [0, 0.55, 0], "role": "body"},
{"shape": "cylinder", "radius": 0.30, "height": 0.55,
"at": [0, 0.86, 0], "segments": 9, "role": "foliage"},
{"shape": "cylinder", "radius": 0.22, "height": 0.42,
"at": [-0.18, 1.10, 0.10], "segments": 8, "role": "foliage"},
{"shape": "cylinder", "radius": 0.18, "height": 0.34,
"at": [0.20, 1.06, -0.12], "segments": 8, "role": "foliage"},
],
},
{
"name": "newspaper_box",
"concept": ("product photo of a single blue newspaper vending box on a "
"short stand, plain light grey studio background"),
"parts": [
{"shape": "box", "size": [0.44, 0.10, 0.38],
"at": [0, 0.05, 0], "role": "metal"},
{"shape": "box", "size": [0.42, 0.72, 0.34],
"at": [0, 0.46, 0], "role": "body"},
{"shape": "box", "size": [0.34, 0.30, 0.03],
"at": [0, 0.57, 0.18], "role": "glass"},
{"shape": "box", "size": [0.44, 0.10, 0.36],
"at": [0, 0.87, 0], "role": "accent"},
],
},
{
"name": "fire_hydrant",
"concept": ("product photo of a single red fire hydrant with silver caps, "
"plain light grey studio background"),
"parts": [
{"shape": "cylinder", "radius": 0.15, "height": 0.62,
"at": [0, 0.31, 0], "segments": 12, "role": "body"},
{"shape": "cylinder", "radius": 0.17, "height": 0.06,
"at": [0, 0.65, 0], "segments": 12, "role": "accent"},
{"shape": "cylinder", "radius": 0.19, "height": 0.09,
"at": [0, 0.05, 0], "segments": 12, "role": "metal"},
{"shape": "box", "size": [0.52, 0.10, 0.12],
"at": [0, 0.42, 0], "role": "metal"},
],
},
]
# Default palette per role (linear-ish sRGB values the GLB stores directly).
PALETTE = {
"body": [0.22, 0.30, 0.24],
"accent": [0.85, 0.72, 0.28],
"metal": [0.16, 0.17, 0.19],
"wood": [0.42, 0.28, 0.17],
"foliage": [0.18, 0.42, 0.20],
"stone": [0.52, 0.51, 0.48],
"glass": [0.35, 0.48, 0.62],
}
PBR = {
"metal": (0.75, 0.35),
"wood": (0.0, 0.75),
"foliage": (0.0, 0.9),
"stone": (0.0, 0.85),
"glass": (0.1, 0.12),
"body": (0.15, 0.65),
"accent": (0.25, 0.5),
}
DIRECTORY_BY_NAME = {prop["name"]: prop for prop in PROPS}
# ---------------------------------------------------------------------------
# Geometry helpers. Every quad/triangle is wound so its geometric normal agrees
# with the intended outward normal — Godot culls back faces, so this matters.
# ---------------------------------------------------------------------------
def _sub(a, b):
return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
def _cross(a, b):
return (a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0])
def _dot(a, b):
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
def _normalize(v):
length = math.sqrt(_dot(v, v))
if length <= 1e-9:
return (0.0, 1.0, 0.0)
return (v[0] / length, v[1] / length, v[2] / length)
class MeshData:
"""Accumulates one flat-shaded, indexed triangle list."""
def __init__(self):
self.positions = []
self.normals = []
self.indices = []
def triangle_count(self) -> int:
return len(self.indices) // 3
def add_quad(self, corners, want_normal):
a, b, c = corners[0], corners[1], corners[2]
normal = _cross(_sub(b, a), _sub(c, a))
if _dot(normal, want_normal) < 0.0:
corners = [corners[0], corners[3], corners[2], corners[1]]
normal = (-normal[0], -normal[1], -normal[2])
normal = _normalize(normal)
base = len(self.positions)
for corner in corners:
self.positions.append(corner)
self.normals.append(normal)
self.indices.extend([base, base + 1, base + 2,
base, base + 2, base + 3])
def add_triangle(self, v0, v1, v2, want_normal):
normal = _cross(_sub(v1, v0), _sub(v2, v0))
if _dot(normal, want_normal) < 0.0:
v1, v2 = v2, v1
normal = (-normal[0], -normal[1], -normal[2])
normal = _normalize(normal)
base = len(self.positions)
for vertex in (v0, v1, v2):
self.positions.append(vertex)
self.normals.append(normal)
self.indices.extend([base, base + 1, base + 2])
def _add_box(mesh: MeshData, size, at):
hx, hy, hz = size[0] / 2.0, size[1] / 2.0, size[2] / 2.0
cx, cy, cz = at
faces = [
((0, 0, 1), [(-hx, -hy, hz), (hx, -hy, hz),
(hx, hy, hz), (-hx, hy, hz)]),
((0, 0, -1), [(hx, -hy, -hz), (-hx, -hy, -hz),
(-hx, hy, -hz), (hx, hy, -hz)]),
((1, 0, 0), [(hx, -hy, hz), (hx, -hy, -hz),
(hx, hy, -hz), (hx, hy, hz)]),
((-1, 0, 0), [(-hx, -hy, -hz), (-hx, -hy, hz),
(-hx, hy, hz), (-hx, hy, -hz)]),
((0, 1, 0), [(-hx, hy, hz), (hx, hy, hz),
(hx, hy, -hz), (-hx, hy, -hz)]),
((0, -1, 0), [(-hx, -hy, -hz), (hx, -hy, -hz),
(hx, -hy, hz), (-hx, -hy, hz)]),
]
for normal, corners in faces:
mesh.add_quad([(cx + x, cy + y, cz + z)
for x, y, z in corners], normal)
def _add_cylinder(mesh: MeshData, radius, height, at, segments=12, top_radius=None):
r0 = float(radius)
r1 = r0 if top_radius is None else float(top_radius)
cx, cy, cz = at
y0 = cy - height / 2.0
y1 = cy + height / 2.0
segments = max(3, int(segments))
for i in range(segments):
t0 = 2.0 * math.pi * i / segments
t1 = 2.0 * math.pi * (i + 1) / segments
b0 = (cx + r0 * math.cos(t0), y0, cz + r0 * math.sin(t0))
b1 = (cx + r0 * math.cos(t1), y0, cz + r0 * math.sin(t1))
u1 = (cx + r1 * math.cos(t1), y1, cz + r1 * math.sin(t1))
u0 = (cx + r1 * math.cos(t0), y1, cz + r1 * math.sin(t0))
want = ((math.cos(t0) + math.cos(t1)) * 0.5, 0.0,
(math.sin(t0) + math.sin(t1)) * 0.5)
mesh.add_quad([b0, b1, u1, u0], want)
bottom_center = (cx, y0, cz)
top_center = (cx, y1, cz)
mesh.add_triangle(bottom_center, b0, b1, (0.0, -1.0, 0.0))
mesh.add_triangle(top_center, u0, u1, (0.0, 1.0, 0.0))
def build_part_mesh(part: dict) -> MeshData:
"""Geometry for a single part. Each part becomes one glTF primitive so it
can carry its own material."""
mesh = MeshData()
if part["shape"] == "box":
_add_box(mesh, part["size"], part["at"])
elif part["shape"] == "cylinder":
_add_cylinder(mesh, part["radius"], part["height"], part["at"],
part.get("segments", 12), part.get("top_radius"))
else:
raise ValueError(f"unknown shape {part['shape']!r}")
return mesh
def build_prop_mesh(prop: dict) -> MeshData:
"""All parts merged into one mesh (stats and tests; the GLB keeps them
separate so each part keeps its material)."""
merged = MeshData()
for part in prop["parts"]:
mesh = build_part_mesh(part)
base = len(merged.positions)
merged.positions.extend(mesh.positions)
merged.normals.extend(mesh.normals)
merged.indices.extend(index + base for index in mesh.indices)
return merged
# ---------------------------------------------------------------------------
# AI concept palette (optional). The image only supplies colours.
# ---------------------------------------------------------------------------
def _load_env_file(path=None):
path = Path(path) if path else ROOT / ".env"
if not path.is_file():
return
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip().strip("\"'"))
def _hsv(rgb):
"""Hue (0..1), saturation (0..1), value (0..1) for a 0..1 RGB triple."""
r, g, b = rgb
mx, mn = max(rgb), min(rgb)
delta = mx - mn
if mx <= 0.0:
return 0.0, 0.0, 0.0
sat = delta / mx
if delta <= 1e-6:
return 0.0, sat, mx
if mx == r:
hue = ((g - b) / delta) % 6.0
elif mx == g:
hue = (b - r) / delta + 2.0
else:
hue = (r - g) / delta + 4.0
return hue / 6.0, sat, mx
def _luminance(rgb):
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
def dominant_colors(image, count=4):
"""Most characteristic colours of the concept subject, 0..1 RGB triples.
A studio backdrop is a large grey gradient, so raw pixel frequency just
returns the background. Two things fix that: read the centre of the frame
(the subject), and rank colours by ``count * (0.25 + saturation)`` so a
saturated wood or paint beats a big flat grey.
"""
source = image.convert("RGB")
width, height = source.size
box = (int(width * 0.22), int(height * 0.22),
int(width * 0.78), int(height * 0.78))
small = source.crop(box).resize((96, 96))
quantized = small.quantize(colors=max(8, count * 4), method=2)
palette = quantized.getpalette() or []
scored = []
for tally, index in (quantized.getcolors() or []):
r, g, b = palette[index * 3:index * 3 + 3]
rgb = [r / 255.0, g / 255.0, b / 255.0]
_hue, sat, value = _hsv(rgb)
if value > 0.96 and sat < 0.05:
continue # white studio backdrop
if value < 0.05:
continue # pure shadow
scored.append((tally * (0.25 + sat), rgb))
scored.sort(key=lambda item: item[0], reverse=True)
return [rgb for _score, rgb in scored[:count]]
def assign_roles(colors, base=None):
"""Map sampled colours onto material roles using their character.
Frequency alone cannot tell a black cast-iron leg from a darker wood slat.
The bench concept has warm teak slats and near-black legs; the planter
concept has grey concrete and a green shrub. Assign by hue and value
instead: the darkest tone is metal, a warm saturated tone is wood, a light
desaturated tone is stone, a green tone is foliage.
"""
palette = {role: list(rgb) for role, rgb in (base or PALETTE).items()}
if not colors:
return palette
def sat(rgb):
return _hsv(rgb)[1]
def hue(rgb):
return _hsv(rgb)[0]
darkest = min(colors, key=_luminance)
if _luminance(darkest) < 0.35:
palette["metal"] = darkest
warm = [c for c in colors
if 0.02 <= hue(c) <= 0.16 and sat(c) >= 0.25
and 0.06 <= _luminance(c) <= 0.8]
if warm:
palette["wood"] = max(warm, key=sat)
greens = [c for c in colors
if 0.20 <= hue(c) <= 0.45 and sat(c) >= 0.2
and _luminance(c) >= 0.10]
if greens:
palette["foliage"] = max(greens, key=sat)
greys = [c for c in colors
if sat(c) < 0.18 and 0.08 <= _luminance(c) <= 0.85]
if greys:
# Clamp concrete: a near-white sample renders as a glowing crate under the
# street lamps, so keep stone a believable mid grey.
stone = max(greys, key=_luminance)
palette["stone"] = [min(0.60, channel) for channel in stone]
candidates = [c for c in colors
if sat(c) >= 0.12 and _luminance(c) >= 0.08]
if candidates:
body = max(candidates, key=lambda c: sat(c) * (0.3 + _luminance(c)))
palette["body"] = body
others = [c for c in candidates
if c is not body and sat(c) > sat(body) + 0.05]
if others:
palette["accent"] = max(others, key=sat)
else:
palette["accent"] = [min(1.0, channel * 1.35 + 0.05)
for channel in body]
return palette
def palette_for_prop(prop_name: str) -> dict:
"""Role palette for a prop: defaults, refined by its AI concept image."""
concept = CONCEPTS_DIR / f"{prop_name}.png"
if not concept.is_file():
return {role: list(rgb) for role, rgb in PALETTE.items()}
try:
from PIL import Image
with Image.open(concept) as image:
image.load()
colors = dominant_colors(image)
except Exception as exc: # noqa: BLE001 - a bad concept must not break build
print(
f"warning: could not read {concept.name}: {exc}", file=sys.stderr)
return {role: list(rgb) for role, rgb in PALETTE.items()}
return assign_roles(colors)
def generate_concepts(only="", model=DEFAULT_CONCEPT_MODEL, size=1024,
force=False) -> list:
"""Ask the image model for one concept sheet per prop (needs API key)."""
_load_env_file()
import generate_textures
CONCEPTS_DIR.mkdir(parents=True, exist_ok=True)
written = []
for prop in PROPS:
if only and only not in prop["name"]:
continue
path = CONCEPTS_DIR / f"{prop['name']}.png"
if path.is_file() and not force:
print(f"skip concept {prop['name']} (exists; --force to replace)")
continue
print(f"concept {prop['name']} ...")
image = generate_textures.generate_image(
f"{prop['concept']}. Three-quarter view, soft studio lighting, "
"no text, no watermark.", model, size, transparent=False)
image.save(path)
written.append(path)
print(f"wrote {path.relative_to(ROOT)} {image.size}")
return written
# ---------------------------------------------------------------------------
# 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:
remainder = len(data) % 4
return data if remainder == 0 else data + fill * (4 - remainder)
def build_glb(prop: dict, palette: dict = None, atlas: dict = None) -> bytes:
"""Serialise one prop as a self-contained .glb (glTF 2.0) file.
All parts share one material backed by a colour atlas and a
metallic/roughness atlas, so the prop draws with a single material.
"""
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()
buffer_views = []
accessors = []
def add_view(payload: bytes, target=None) -> int:
view = {
"buffer": 0,
"byteOffset": len(binary),
"byteLength": len(payload),
}
if target is not None:
view["target"] = target
buffer_views.append(view)
binary.extend(_pad(payload, b"\x00"))
return len(buffer_views) - 1
pos_payload = b"".join(struct.pack("<3f", *p) for p in positions)
nrm_payload = b"".join(struct.pack("<3f", *n) for n in normals)
uv_payload = b"".join(struct.pack("<2f", *uv) for uv in uvs)
idx_payload = b"".join(struct.pack("<H", i) for i in indices)
pos_view = add_view(pos_payload, 34962)
nrm_view = add_view(nrm_payload, 34962)
uv_view = add_view(uv_payload, 34962)
idx_view = add_view(idx_payload, 34963)
xs = [p[0] for p in positions] or [0.0]
ys = [p[1] for p in positions] or [0.0]
zs = [p[2] for p in positions] or [0.0]
pos_accessor = len(accessors)
accessors.append({"bufferView": pos_view, "componentType": 5126,
"count": len(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(normals), "type": "VEC3"})
uv_accessor = len(accessors)
accessors.append({"bufferView": uv_view, "componentType": 5126,
"count": len(uvs), "type": "VEC2"})
idx_accessor = len(accessors)
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 = {
"asset": {"version": "2.0", "generator": "step-line model_pipeline"},
"scene": 0,
"scenes": [{"name": prop["name"], "nodes": [0]}],
"nodes": [{"name": prop["name"], "mesh": 0}],
"meshes": [{
"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,
"bufferViews": buffer_views,
"buffers": [{"byteLength": len(binary)}],
}
json_chunk = _pad(json.dumps(gltf, separators=(",", ":")).encode("utf-8"),
b" ")
bin_chunk = _pad(bytes(binary), b"\x00")
total = 12 + 8 + len(json_chunk) + 8 + len(bin_chunk)
out = bytearray()
out.extend(struct.pack("<III", 0x46546C67, 2, total))
out.extend(struct.pack("<II", len(json_chunk), 0x4E4F534A))
out.extend(json_chunk)
out.extend(struct.pack("<II", len(bin_chunk), 0x004E4942))
out.extend(bin_chunk)
return bytes(out)
def build(only="", quiet=False) -> list:
MODELS_DIR.mkdir(parents=True, exist_ok=True)
written = []
for prop in PROPS:
if only and only not in prop["name"]:
continue
palette = palette_for_prop(prop["name"])
payload = build_glb(prop, palette)
path = MODELS_DIR / f"{prop['name']}.glb"
path.write_bytes(payload)
written.append(path)
if not quiet:
mesh = build_prop_mesh(prop)
print(f"wrote {path.relative_to(ROOT)} "
f"{mesh.triangle_count()} tris {len(payload)} bytes")
return written
# ---------------------------------------------------------------------------
# Audit.
# ---------------------------------------------------------------------------
MAX_TRIANGLES = 400
MAX_VERTICES = 600
_DEGENERATE_AREA2 = 1e-9
def _read_float_accessor(gltf: dict, binary: bytes, index: int) -> list:
"""Decode a float SCALAR/VEC2/VEC3 accessor into a list of tuples/values."""
accessor = gltf["accessors"][index]
count = int(accessor["count"])
kind = accessor["type"]
width = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4}[kind]
view = gltf["bufferViews"][accessor["bufferView"]]
offset = int(view.get("byteOffset", 0)) + int(accessor.get("byteOffset", 0))
stride = int(view.get("byteStride", width * 4))
values = []
for i in range(count):
base = offset + i * stride
row = struct.unpack_from("<" + "f" * width, binary, base)
values.append(row[0] if width == 1 else row)
return values
def _read_index_accessor(gltf: dict, binary: bytes, index: int) -> list:
accessor = gltf["accessors"][index]
width = {5121: 1, 5123: 2, 5125: 4}.get(int(accessor["componentType"]), 2)
fmt = {1: "B", 2: "H", 4: "I"}[width]
view = gltf["bufferViews"][accessor["bufferView"]]
offset = int(view.get("byteOffset", 0)) + int(accessor.get("byteOffset", 0))
stride = int(view.get("byteStride", width))
out = []
for i in range(int(accessor["count"])):
out.append(struct.unpack_from("<" + fmt, binary,
offset + i * stride)[0])
return out
def _triangle_area2(a, b, c) -> float:
ab = (b[0] - a[0], b[1] - a[1], b[2] - a[2])
ac = (c[0] - a[0], c[1] - a[1], c[2] - a[2])
cross = (ab[1] * ac[2] - ab[2] * ac[1],
ab[2] * ac[0] - ab[0] * ac[2],
ab[0] * ac[1] - ab[1] * ac[0])
return cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2]
def audit_glb(path: Path) -> dict:
"""Check a .glb file's container, JSON chunk, and mesh contract.
The mesh must stay in the prop budget, have no degenerate triangles, keep
atlas UVs inside [0, 1], sit on the ground (min y ~= 0), and use exactly one
atlas material.
"""
issues = []
if not path.is_file():
return {"path": str(path), "ok": False,
"issues": ["missing file"], "triangles": 0}
data = path.read_bytes()
if len(data) < 20:
return {"path": str(path), "ok": False,
"issues": ["file too short"], "triangles": 0}
magic, version, length = struct.unpack_from("<III", data, 0)
if magic != 0x46546C67:
issues.append("bad magic (not a glTF binary)")
if version != 2:
issues.append(f"unexpected glTF version {version}")
if length != len(data):
issues.append(f"header length {length} != file size {len(data)}")
json_len, json_type = struct.unpack_from("<II", data, 12)
if json_type != 0x4E4F534A:
issues.append("first chunk is not JSON")
gltf = {}
try:
gltf = json.loads(data[20:20 + json_len].decode("utf-8"))
except (ValueError, UnicodeDecodeError) as exc:
issues.append(f"unreadable JSON chunk: {exc}")
triangles = 0
for mesh in gltf.get("meshes", []):
for primitive in mesh.get("primitives", []):
indices = primitive.get("indices")
if indices is None:
issues.append("primitive without indices")
continue
try:
triangles += gltf["accessors"][indices]["count"] // 3
except (KeyError, IndexError, TypeError):
issues.append("primitive references a missing accessor")
if triangles <= 0:
issues.append("no triangles")
materials = gltf.get("materials", [])
if not 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", []):
if int(buffer.get("byteLength", 0)) > len(data):
issues.append("buffer larger than the file")
if triangles > MAX_TRIANGLES:
issues.append(f"triangle budget exceeded ({triangles} > {MAX_TRIANGLES})")
# Decode geometry for the deeper checks. A malformed buffer view is already
# reported above, so skip quietly when the binary chunk is unusable.
try:
_, bin_len, _ = struct.unpack_from("<III", data, 12 + 8 + json_len)
binary = data[12 + 8 + json_len + 8:12 + 8 + json_len + 8 + bin_len]
vertices = 0
for mesh in gltf.get("meshes", []):
for primitive in mesh.get("primitives", []):
attributes = primitive.get("attributes", {})
positions = _read_float_accessor(
gltf, binary, attributes["POSITION"])
vertices += len(positions)
if positions:
min_y = min(p[1] for p in positions)
if abs(min_y) > 0.02:
issues.append(
f"prop does not sit on the ground (min y {min_y:.3f})")
uvs = _read_float_accessor(
gltf, binary, attributes["TEXCOORD_0"])
if any(not (0.0 <= u <= 1.0 and 0.0 <= v <= 1.0)
for u, v in uvs):
issues.append("atlas UV outside [0, 1]")
indices = _read_index_accessor(
gltf, binary, primitive["indices"])
for tri in range(0, len(indices), 3):
a, b, c = (positions[indices[tri + i]] for i in range(3))
if _triangle_area2(a, b, c) < _DEGENERATE_AREA2:
issues.append("degenerate triangle")
break
if vertices > MAX_VERTICES:
issues.append(f"vertex budget exceeded ({vertices} > {MAX_VERTICES})")
except (KeyError, IndexError, ValueError, struct.error) as exc:
issues.append(f"unreadable geometry: {exc}")
return {"path": str(path), "ok": not issues, "issues": issues,
"triangles": triangles}
def audit(only="", as_json=False) -> int:
results = []
for prop in PROPS:
if only and only not in prop["name"]:
continue
results.append(audit_glb(MODELS_DIR / f"{prop['name']}.glb"))
if as_json:
print(json.dumps(results, indent=2))
else:
for result in results:
name = Path(result["path"]).name
state = "OK " if result["ok"] else "BAD "
print(f"{state} {name:24} tris={result['triangles']}")
for issue in result["issues"]:
print(f" - {issue}")
return 0 if all(result["ok"] for result in results) else 1
def list_props(args) -> int:
for prop in PROPS:
concept = CONCEPTS_DIR / f"{prop['name']}.png"
built = MODELS_DIR / f"{prop['name']}.glb"
mesh = build_prop_mesh(prop)
print(f"{prop['name']:16} parts={len(prop['parts']):2} "
f"tris={mesh.triangle_count():4} "
f"glb={'yes' if built.is_file() else '- '} "
f"concept={'yes' if concept.is_file() else '-'}")
print(f"Total: {len(PROPS)} props")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Build low-poly prop models for Step Line.")
commands = parser.add_subparsers(dest="command", required=True)
listing = commands.add_parser("list", help="print the prop catalogue")
listing.set_defaults(handler=list_props)
building = commands.add_parser(
"build", help="write assets/models/generated/*.glb")
building.add_argument("--only", default="",
help="only build props whose name contains this text")
building.set_defaults(handler=lambda args: 0 if build(args.only) else 1)
auditing = commands.add_parser(
"audit", help="validate the built GLB files")
auditing.add_argument("--only", default="")
auditing.add_argument("--json", action="store_true")
auditing.set_defaults(handler=lambda args: audit(args.only, args.json))
concept = commands.add_parser(
"concept", help="generate concept images via OpenRouter")
concept.add_argument("--only", default="")
concept.add_argument("--model", default=DEFAULT_CONCEPT_MODEL)
concept.add_argument("--size", type=int, default=1024)
concept.add_argument("--force", action="store_true")
concept.set_defaults(
handler=lambda args: 0 if generate_concepts(
args.only, args.model, args.size, args.force) is not None else 1)
return parser
def main(argv=None) -> int:
args = build_parser().parse_args(argv)
try:
return args.handler(args)
except (OSError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more