Compare commits

...
4 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
43 changed files with 2420 additions and 145 deletions

No files matched your search

+105
View File
@@ -709,3 +709,108 @@ Python tooling **53/53**, headless **182/182**.
the reference matches the game camera. the reference matches the game camera.
- [x] Added `Hud.clear_log()` so staged captures drop target-cycling noise; the escalation shot - [x] Added `Hud.clear_log()` so staged captures drop target-cycling noise; the escalation shot
keeps the real reaction lines. 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.
+24
View File
@@ -46,6 +46,30 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
until it alerts, then jump to 760 s after staging for night lighting. The timeline needs 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 `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). 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` - **Backlog complete:** every planned TODO section is done and moved to `DONE.md`; `TODO.md`
now only lists optional future ideas (live 3D replay, NPC LOD, more venues, real audio, prop now only lists optional future ideas (live 3D replay, NPC LOD, more venues, real audio, prop
atlas, AI mockups for the new catalogue entries, image-to-mesh props). GUT was consciously not atlas, AI mockups for the new catalogue entries, image-to-mesh props). GUT was consciously not
+27 -6
View File
@@ -14,9 +14,10 @@ The central design goal is simple:
| ![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) | | ![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) | | | ![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, late-night, wand screening, escalation, and shift-review timeline.* *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
@@ -34,7 +35,7 @@ The central design goal is simple:
> Downloads are public (no sign-in required) since the host's instance-wide > Downloads are public (no sign-in required) since the host's instance-wide
> `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31. > `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31.
> >
> **Latest: v0.3.3** — staged captures for the wand panel, escalation HUD, and body-camera timeline now feed the visual checks. > **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
@@ -248,6 +249,9 @@ python3 tools/screen_designs.py generate --draft --only wand --force
# (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
@@ -260,11 +264,11 @@ python3 tools/screen_designs.py judge --only 03- # one capture
python3 tools/screen_designs.py judge --dry-run # build requests, no network python3 tools/screen_designs.py judge --dry-run # build requests, no network
``` ```
The catalogue covers 19 screens: the nine README captures plus the start menu, The catalogue covers 21 screens: the eleven README captures plus the start menu,
pause overlay, settings, shift review, radial menu, emergency radio, wand pause overlay, settings, shift review, radial menu, emergency radio, wand
screening, Joke Shop queue, TV Studio arrival, crowd panic, responder arrival, screening, Joke Shop queue, TV Studio arrival, crowd panic, responder arrival,
body-camera timeline, and the escalation HUD. All 19 have an AI reference in body-camera timeline, and the escalation HUD. All 19 have an AI reference in
`docs/screens/designs/`; the nine screens with staged captures are also compared `docs/screens/designs/`; the eleven screens with staged captures are also compared
or judged against the running game. 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
@@ -313,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
@@ -333,6 +346,11 @@ The unit test runner is the project's own lightweight harness (no editor plugin
### 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 |
@@ -381,7 +399,7 @@ Implemented so far:
- Godot 4.3 project scaffold, input map, procedural world builder - Godot 4.3 project scaffold, input map, procedural world builder
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem` - Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools - Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
- Headless unit tests: **182 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material) - 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 blue circle in front of the face and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`) - Control pass: focus cone on NPC heads with a billboarded blue circle in front of the face and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`)
@@ -404,6 +422,9 @@ Implemented so far:
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets - Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock) - Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
- Rendering pass (v0.3.0 work, [ADR-0012](docs/adr/0012-surface-materials-and-generated-props.md)): one material module with roughness/metallic profiles per surface, ACES tonemapping, glow, aerial fog and colour grading driven by the shift phase, correctly scaled brick, framed facade windows that light up at night, door trim and handles, a curb and lane dashes, and the glowing marquee/lamp bulbs. NPCs gained per-role clothing palettes, caps/hoods/backpacks/sunglasses/lanyards, hands and shoes, and an arm/leg walk swing - Rendering pass (v0.3.0 work, [ADR-0012](docs/adr/0012-surface-materials-and-generated-props.md)): one material module with roughness/metallic profiles per surface, ACES tonemapping, glow, aerial fog and colour grading driven by the shift phase, correctly scaled brick, framed facade windows that light up at night, door trim and handles, a curb and lane dashes, and the glowing marquee/lamp bulbs. NPCs gained per-role clothing palettes, caps/hoods/backpacks/sunglasses/lanyards, hands and shoes, and an arm/leg walk swing
- 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 - Prop atlas (v0.3.1): `tools/model_pipeline.py` packs each prop's palette into one power-of-two colour atlas plus a metallic/roughness atlas and emits a single material with per-vertex UVs, so a prop is one draw material rather than one per part; `audit` enforces the contract
- Generated prop models (v0.3.0 work): `tools/model_pipeline.py` builds bollards, benches, bins, planters, newspaper boxes and a hydrant as `.glb` props with per-part materials, loaded by `WorldBuilder` with a procedural fallback; the optional AI concept step (`model_pipeline.py concept`) supplies per-prop colour palettes - Generated prop models (v0.3.0 work): `tools/model_pipeline.py` builds bollards, benches, bins, planters, newspaper boxes and a hydrant as `.glb` props with per-part materials, loaded by `WorldBuilder` with a procedural fallback; the optional AI concept step (`model_pipeline.py concept`) supplies per-prop colour palettes
- Web build: `build/step-line-web-<version>.zip` (Godot's non-threaded web template, no COOP/COEP headers needed) built by `tools/build_releases.sh` and published with the desktop artifacts; serve the unzipped folder from any static host - Web build: `build/step-line-web-<version>.zip` (Godot's non-threaded web template, no COOP/COEP headers needed) built by `tools/build_releases.sh` and published with the desktop artifacts; serve the unzipped folder from any static host
+5 -5
View File
@@ -1,15 +1,15 @@
# TODO # TODO
Godot 4.3 prototype. Latest release **v0.3.2** (mockup set). Headless suite **182/182** plus Godot 4.3 prototype. Latest release **v0.5.0** (NPC characters and player movement). Headless
**53** Python tooling tests; the session, PLAN-verb, crowd, emergency, timeline, audio, scenario-content, and suite **208/208** plus **56** Python tooling tests; the session, PLAN-verb, crowd, emergency,
windowed focus/radial smokes pass. Anonymous downloads are enabled on the host. timeline, audio, scenario-content, props, NPC-budget, movement, and windowed focus/radial smokes
pass. Anonymous downloads are enabled on the host.
All planned backlog sections are complete (see `DONE.md`). Optional future ideas live below. The scheduled presentation pass is complete (see `DONE.md`). Only optional future ideas remain.
## Future ideas (optional, not scheduled) ## Future ideas (optional, not scheduled)
- [ ] Live 3D body-camera replay (the timeline is text-only today). - [ ] Live 3D body-camera replay (the timeline is text-only today).
- [ ] LOD/culling for NPCs if `MAX_LIVE_NPCS` grows past the measured 16-avatar budget.
- [ ] Additional venues or seasonal events authored from the new `.tres` files. - [ ] Additional venues or seasonal events authored from the new `.tres` files.
- [ ] Real voice/dialogue audio (the crowd bed and radio crackle are procedural). - [ ] Real voice/dialogue audio (the crowd bed and radio crackle are procedural).
- [ ] Swap the primitive prop builder for an image-to-mesh model API behind the same - [ ] Swap the primitive prop builder for an image-to-mesh model API behind the same
Binary file not shown.
+3 -1
View File
@@ -86,5 +86,7 @@
"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.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"
} }
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.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 836 KiB

After

Width:  |  Height:  |  Size: 836 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 528 KiB

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

+1 -1
View File
@@ -8,7 +8,7 @@ config_version=5
config/name="Step Line" config/name="Step Line"
config/description="Workplace security simulation about observation, access verification, and proportionate escalation." config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
config/version="0.3.3" 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")
+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",
} }
+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
+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)
+150 -46
View File
@@ -17,6 +17,7 @@ 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 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")
@@ -84,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
@@ -122,6 +125,7 @@ var _door_rng := RandomNumberGenerator.new()
const MAX_LIVE_NPCS := 16 const MAX_LIVE_NPCS := 16
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()
@@ -195,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":
@@ -282,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)))
@@ -651,6 +675,33 @@ 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)
@@ -699,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"):
@@ -713,6 +798,20 @@ 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"): if event.is_action_pressed("cycle_target_next"):
_cycle_nearby_target(1) _cycle_nearby_target(1)
@@ -760,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
+162 -7
View File
@@ -4,12 +4,21 @@ 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
@@ -28,21 +37,91 @@ var focus_suppress_s := 0.0
const FOCUS_SUPPRESS_S := 0.75 const FOCUS_SUPPRESS_S := 0.75
## Radius used by aim-free cycling. ## Radius used by aim-free cycling.
const NEARBY_RADIUS := 12.0 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:
@@ -64,28 +143,104 @@ 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()
var precision := Input.is_action_pressed("walk_slow")
if precision != _precision:
_precision = precision
precision_changed.emit(_precision)
var speed := move_speed var speed := move_speed
if Input.is_action_pressed("walk_slow"): if precision:
speed *= SLOW_WALK_FACTOR speed *= SLOW_WALK_FACTOR
desired *= speed desired *= speed
velocity.x = move_toward(velocity.x, desired.x, accel * _delta * speed) var braking := desired.length() < 0.01
velocity.z = move_toward(velocity.z, desired.z, accel * _delta * speed) 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
+517 -52
View File
@@ -26,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
@@ -64,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()
@@ -73,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.
@@ -95,9 +111,24 @@ 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[MeshInstance3D] = [] 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)
@@ -122,6 +153,50 @@ const ACCESSORIES := {
Rules.NpcType.CONFUSED_VISITOR: "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 ## Clothing palette for one avatar. Pure and seed-driven so crowds read as
## individuals and the result is deterministic (tests rely on that). ## individuals and the result is deterministic (tests rely on that).
static func outfit_for(npc_type: int, seed_value: int) -> Dictionary: static func outfit_for(npc_type: int, seed_value: int) -> Dictionary:
@@ -132,17 +207,70 @@ static func outfit_for(npc_type: int, seed_value: int) -> Dictionary:
# Jeans through khaki/chino, so not every avatar wears the same trousers. # 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 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 hair_index := (seed_value * 7) % HAIR_COLORS.size()
var style_index := (seed_value * 13 + npc_type * 3) % HAIR_STYLES.size()
return { return {
"shirt": shirt, "shirt": shirt,
"sleeves": shirt.darkened(0.16), "sleeves": shirt.darkened(0.16),
"pants": pants, "pants": pants,
"hair": HAIR_COLORS[hair_index], "hair": HAIR_COLORS[hair_index],
"hair_style": HAIR_STYLES[style_index],
"skin": SKIN_TONE.darkened(frac * 0.18), "skin": SKIN_TONE.darkened(frac * 0.18),
"accessory": ACCESSORIES.get(npc_type, "none"), "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, "height_scale": 0.92 + 0.16 * frac,
"blend": 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"
@@ -153,53 +281,184 @@ func _build_body(npc_type: int) -> void:
var pants: Color = outfit["pants"] var pants: Color = outfit["pants"]
var skin: Color = outfit["skin"] var skin: Color = outfit["skin"]
var hair: Color = outfit["hair"] var hair: Color = outfit["hair"]
_rig.scale.y = float(outfit["height_scale"]) _height_scale = float(outfit["height_scale"])
_rig.scale.y = _height_scale
_shirt_color = shirt
_skin_color = skin
var limbs_color := sleeves var limbs_color := sleeves
# Torso, with a shoulder block and hips so the silhouette is not a tube. # Hips and torso, with a shoulder block so the silhouette is not a tube.
_add_capsule(_rig, 0.30, 0.90, Vector3(0, 1.02, 0), shirt, 0.0, "cloth") _add_box_mesh(_rig, Vector3(0.42, 0.64, 0.27), Vector3(0, 1.05, 0), shirt, "cloth")
_add_box_mesh(_rig, Vector3(0.58, 0.14, 0.28), Vector3(0, 1.36, 0), shirt, "cloth") _add_box_mesh(_rig, Vector3(0.50, 0.13, 0.26), Vector3(0, 1.38, 0), shirt, "cloth")
_add_box_mesh(_rig, Vector3(0.44, 0.18, 0.26), Vector3(0, 0.62, 0), pants, "cloth") _add_box_mesh(_rig, Vector3(0.38, 0.20, 0.25), Vector3(0, 0.70, 0), pants, "cloth")
# Legs and arms are tracked so the walk cycle can swing them. _add_layer(str(outfit["layer"]), shirt)
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), pants, 0.0, "cloth")) # Articulated legs: hip pivot -> thigh -> knee -> shin -> shoe.
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), pants, 0.0, "cloth")) for side in [-1.0, 1.0]:
_arms.append(_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0, "cloth")) var hip := Node3D.new()
_arms.append(_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0, "cloth")) hip.name = "Hip%s" % ("L" if side < 0.0 else "R")
# Hands and shoes give the limbs an end point at conversation distance. hip.position = Vector3(0.13 * side, 0.64, 0)
_add_sphere(_rig, 0.065, Vector3(-0.47, 0.94, 0.03), skin, "skin") _rig.add_child(hip)
_add_sphere(_rig, 0.065, Vector3(0.47, 0.94, 0.03), skin, "skin") _add_capsule(hip, 0.105, 0.36, Vector3(0, -0.17, 0), pants, 0.0, "cloth")
# Neck, head, face, hair, shoes so the body reads as a person (ADR-0010). 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") _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, "skin") _add_sphere(_head, 0.17, Vector3.ZERO, skin, "skin")
_add_sphere(_head, 0.18, Vector3(0, 0.08, -0.02), hair, "cloth") _add_ears(_head, skin)
_add_sphere(_head, 0.035, Vector3(-0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1), "glass") _add_face(_head)
_add_sphere(_head, 0.035, Vector3(0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1), "glass") _add_hair(_head, str(outfit["hair_style"]), hair)
_rig.add_child(_head) _rig.add_child(_head)
_add_box_mesh(_rig, Vector3(0.16, 0.08, 0.28), Vector3(-0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14), "rubber")
_add_box_mesh(_rig, Vector3(0.16, 0.08, 0.28), Vector3(0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14), "rubber")
_add_accessory(str(outfit["accessory"]), shirt) _add_accessory(str(outfit["accessory"]), shirt)
_add_hand_item(str(outfit["hand_item"]), skin)
_build_focus_circle() _build_focus_circle()
_add_props(npc_type) _add_props(npc_type)
## Outer layer: jacket, hi-vis vest, coat, or dress over the base shirt.
func _add_layer(kind: String, shirt: Color) -> void:
match kind:
"jacket":
_add_box_mesh(_rig, Vector3(0.46, 0.54, 0.31), Vector3(0, 1.08, 0),
shirt.darkened(0.28), "cloth")
_add_box_mesh(_rig, Vector3(0.05, 0.48, 0.02), Vector3(0, 1.12, 0.165),
shirt.darkened(0.42), "cloth")
"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. ## Small role accessory: cap, hood, backpack, sunglasses or a staff lanyard.
func _add_accessory(kind: String, shirt: Color) -> void: func _add_accessory(kind: String, shirt: Color) -> void:
match kind: match kind:
"cap": "cap":
_add_sphere(_head, 0.175, Vector3(0, 0.12, 0), shirt.darkened(0.15), "cloth") _detail(_add_sphere(_head, 0.175, Vector3(0, 0.12, 0), shirt.darkened(0.15), "cloth"))
_add_box_mesh(_head, Vector3(0.30, 0.03, 0.16), Vector3(0, 0.10, 0.16), shirt.darkened(0.22), "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": "hood":
_add_sphere(_head, 0.205, Vector3(0, 0.03, -0.03), Color(0.16, 0.16, 0.18), "cloth") _detail(_add_sphere(_head, 0.205, Vector3(0, 0.03, -0.03), Color(0.16, 0.16, 0.18), "cloth"))
"backpack": "backpack":
_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.34, 0.42, 0.16), Vector3(0, 1.06, -0.24), Color(0.24, 0.28, 0.36), "cloth"))
_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") _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": "sunglasses":
_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") _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": "lanyard":
_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.05, 0.06, 0.02), Vector3(0, 1.20, 0.16), Color(0.85, 0.78, 0.30), "painted_metal"))
_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"))
_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 pass
@@ -233,6 +492,14 @@ func _build_focus_circle() -> 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 _focus_circle: if _focus_circle:
@@ -258,7 +525,7 @@ func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3,
return mi return mi
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color, func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color,
role: String = "skin") -> void: 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
@@ -267,6 +534,12 @@ func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color,
mi.position = pos mi.position = pos
mi.material_override = _material(color, role) mi.material_override = _material(color, role)
parent.add_child(mi) parent.add_child(mi)
return mi
## Register a small dress detail so the mid-distance LOD can hide it.
func _detail(node: Node3D) -> Node3D:
_detail_nodes.append(node)
return node
func _add_box_mesh(parent: Node3D, size: Vector3, pos: Vector3, color: Color, func _add_box_mesh(parent: Node3D, size: Vector3, pos: Vector3, color: Color,
role: String = "cloth") -> MeshInstance3D: role: String = "cloth") -> MeshInstance3D:
@@ -389,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":
@@ -449,20 +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(): for i in _legs.size():
_legs[i].rotation.x = limb_swing(_bob_phase, i, 0.5) var swing := limb_swing(_bob_phase, i, stride)
for i in _arms.size(): _legs[i].rotation.x = swing
_arms[i].rotation.x = limb_swing(_bob_phase, i, -0.38) # 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:
@@ -477,28 +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: for arm in _arms:
arm.rotation.x = lerpf(arm.rotation.x, 0.0, 0.14) 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)
for i in _legs.size(): for i in _legs.size():
_legs[i].rotation.x = lerpf(_legs[i].rotation.x, 0.0, 0.2) _legs[i].rotation.x = lerpf(_legs[i].rotation.x, 0.0, 0.2)
for arm in _arms: _knees[i].rotation.x = lerpf(_knees[i].rotation.x, 0.0, 0.2)
arm.rotation.x = lerpf(arm.rotation.x, 0.0, 0.2) 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. # 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
@@ -527,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
@@ -575,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()
+24
View File
@@ -45,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] = []
@@ -119,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()
@@ -133,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) ---
@@ -371,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).
+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()
+183 -20
View File
@@ -335,35 +335,112 @@ func _build_street_furniture() -> void:
tree.add_child(crown) tree.add_child(crown)
root.add_child(tree) root.add_child(tree)
## Street-furniture placement table. Positions are in world space on the
## sidewalk top (y = 0.18) and grouped by zone, so a cluster reads as street
## furniture instead of a scattered list. `accessory` names a small procedural
## addition (bag, crate, cone) for one-off variation.
const PROP_PLACEMENTS := [
# Entrance: bollards flank the foot of the steps, bins catch people who
# stop at the door.
{"zone": "entrance", "prop": "bollard", "x": -5.6, "z": 4.7},
{"zone": "entrance", "prop": "bollard", "x": -4.2, "z": 4.7},
{"zone": "entrance", "prop": "bollard", "x": 4.2, "z": 4.7},
{"zone": "entrance", "prop": "bollard", "x": 5.6, "z": 4.7},
{"zone": "entrance", "prop": "trash_bin", "x": -6.6, "z": 4.9, "accessory": "bag"},
{"zone": "entrance", "prop": "trash_bin", "x": 6.6, "z": 4.9},
# Mid-block: benches face the boulevard, planters break up the long run.
{"zone": "mid_block", "prop": "bench", "x": -8.8, "z": 6.4, "rot": 180.0},
{"zone": "mid_block", "prop": "bench", "x": 8.8, "z": 6.4, "rot": 180.0},
{"zone": "mid_block", "prop": "planter_box", "x": -11.8, "z": 8.8},
{"zone": "mid_block", "prop": "planter_box", "x": 11.8, "z": 8.8},
# Curb: newspaper boxes and a hydrant along the roadway edge.
{"zone": "curb", "prop": "newspaper_box", "x": -9.6, "z": 11.0, "rot": -12.0},
{"zone": "curb", "prop": "newspaper_box", "x": -8.9, "z": 11.0, "rot": -12.0,
"accessory": "crate"},
{"zone": "curb", "prop": "fire_hydrant", "x": 8.4, "z": 11.2,
"accessory": "cone"},
]
## The door, steps, and the lane guests walk in through stay clear of props.
const PROP_EXCLUSIONS := [
{"name": "steps and door", "x0": -4.6, "x1": 4.6, "z0": -4.6, "z1": 4.55},
{"name": "guest approach lane", "x0": -1.4, "x1": 1.4, "z0": 4.55, "z1": 14.0},
]
const PROP_BOUNDS := {"x0": -14.0, "x1": 14.0, "z0": 4.55, "z1": 11.8}
## Two prop centres closer than this read as one blob (newspaper boxes are
## deliberately lined up, so this stays below their 0.7 m spacing).
const PROP_MIN_SPACING := 0.6
static func _variant_seed(index: int) -> int:
return index * 7919 + 13
## Deterministic per-placement variation. The same index always gives the same
## rotation and scale, so the street is reproducible while repeats differ.
static func variant_for(index: int, base_rotation: float = 0.0) -> Dictionary:
var rng := RandomNumberGenerator.new()
rng.seed = _variant_seed(index)
return {
"rotation": base_rotation + rng.randf_range(-2.0, 2.0),
"scale": rng.randf_range(0.96, 1.04),
"roll": rng.randf(),
}
## Static validation used by tests: reject duplicate, out-of-bounds, or
## overlapping placements, and any prop inside the door/steps/approach lane.
static func placement_problems(placements: Array = PROP_PLACEMENTS) -> Array[String]:
var problems: Array[String] = []
var seen := {}
for i in placements.size():
var entry: Dictionary = placements[i]
var prop := str(entry.get("prop", ""))
var x := float(entry.get("x", 0.0))
var z := float(entry.get("z", 0.0))
var key := "%s@%.2f,%.2f" % [prop, x, z]
if seen.has(key):
problems.append("duplicate placement %s" % key)
seen[key] = true
if x < float(PROP_BOUNDS["x0"]) or x > float(PROP_BOUNDS["x1"]) \
or z < float(PROP_BOUNDS["z0"]) or z > float(PROP_BOUNDS["z1"]):
problems.append("%s at (%.1f, %.1f) is outside the sidewalk" % [prop, x, z])
for zone in PROP_EXCLUSIONS:
if x > float(zone["x0"]) and x < float(zone["x1"]) \
and z > float(zone["z0"]) and z < float(zone["z1"]):
problems.append("%s at (%.1f, %.1f) blocks %s" % [prop, x, z, zone["name"]])
for j in range(i):
var other: Dictionary = placements[j]
var dx := x - float(other.get("x", 0.0))
var dz := z - float(other.get("z", 0.0))
if Vector2(dx, dz).length() < PROP_MIN_SPACING:
problems.append("%s and %s at (%.1f, %.1f) overlap" % [
prop, str(other.get("prop", "")), x, z])
return problems
## Street furniture built from the generated GLB props (tools/model_pipeline.py). ## Street furniture built from the generated GLB props (tools/model_pipeline.py).
## The meshes are real 3D models (bollards, benches, bins, planters, newspaper ## The meshes are real 3D models (bollards, benches, bins, planters, newspaper
## boxes, a hydrant) rather than scaled boxes, and every placement is fixed so ## boxes, a hydrant) rather than scaled boxes. Placements and their deterministic
## the street looks the same on every run. ## variants are fixed, so the street looks the same on every run.
func _build_street_detail() -> void: func _build_street_detail() -> void:
var detail := Node3D.new() var detail := Node3D.new()
detail.name = "StreetDetail" detail.name = "StreetDetail"
root.add_child(detail) root.add_child(detail)
# Bollards flank the foot of the steps. for index in PROP_PLACEMENTS.size():
for x in [-5.6, -4.2, 4.2, 5.6]: var entry: Dictionary = PROP_PLACEMENTS[index]
_add_prop_model(detail, "bollard", Vector3(x, 0.18, 4.7)) var base_rot := float(entry.get("rot", 0.0))
# Benches face the boulevard. var variant := variant_for(index, base_rot)
_add_prop_model(detail, "bench", Vector3(-8.8, 0.18, 6.4), 180.0) var holder := _add_prop_model(detail, str(entry["prop"]),
_add_prop_model(detail, "bench", Vector3(8.8, 0.18, 6.4), 180.0) Vector3(float(entry["x"]), 0.18, float(entry["z"])),
# Bins where people leave the steps. float(variant["rotation"]), index)
_add_prop_model(detail, "trash_bin", Vector3(-6.6, 0.18, 4.9)) holder.scale = Vector3.ONE * float(variant["scale"])
_add_prop_model(detail, "trash_bin", Vector3(6.6, 0.18, 4.9)) var accessory := str(entry.get("accessory", ""))
# Planters break up the long sidewalk run. if accessory != "":
_add_prop_model(detail, "planter_box", Vector3(-11.8, 0.18, 8.8)) _add_prop_accessory(holder, accessory)
_add_prop_model(detail, "planter_box", Vector3(11.8, 0.18, 8.8))
# Newspaper boxes and a hydrant along the curb.
_add_prop_model(detail, "newspaper_box", Vector3(-9.6, 0.18, 11.0), -12.0)
_add_prop_model(detail, "newspaper_box", Vector3(-8.9, 0.18, 11.0), -12.0)
_add_prop_model(detail, "fire_hydrant", Vector3(8.4, 0.18, 11.2))
## Place one generated prop. Falls back to simple primitives when the `.glb` is ## Place one generated prop. Falls back to simple primitives when the `.glb` is
## missing (fresh checkout before `godot --import`) so the world still builds. ## missing (fresh checkout before `godot --import`) so the world still builds.
## A static collider is fitted from the model bounds (cylinder for round props,
## box for everything else), so props block the guard and NPCs.
func _add_prop_model(parent: Node3D, prop: String, pos: Vector3, func _add_prop_model(parent: Node3D, prop: String, pos: Vector3,
rot_y_deg: float = 0.0) -> Node3D: rot_y_deg: float = 0.0, variant_index: int = -1) -> Node3D:
var holder := Node3D.new() var holder := Node3D.new()
holder.name = "Prop_%s_%d" % [prop, parent.get_child_count()] holder.name = "Prop_%s_%d" % [prop, parent.get_child_count()]
holder.position = pos holder.position = pos
@@ -373,11 +450,97 @@ func _add_prop_model(parent: Node3D, prop: String, pos: Vector3,
holder.add_child(model) holder.add_child(model)
loaded_models.append(prop) loaded_models.append(prop)
else: else:
holder.add_child(_fallback_prop(prop)) var fallback := _fallback_prop(prop)
holder.add_child(fallback)
fallback_props.append(prop) fallback_props.append(prop)
model = fallback
parent.add_child(holder) parent.add_child(holder)
_add_prop_collision(holder, prop, model)
return holder return holder
## Fit a simple collider to the prop's meshes (in holder-local space, so the
## placement scale and rotation carry over).
func _add_prop_collision(holder: Node3D, prop: String, model: Node3D) -> void:
var bounds := _model_bounds(model, holder)
if bounds.size == Vector3.ZERO:
return
var body := StaticBody3D.new()
body.name = "Collision"
var collider := CollisionShape3D.new()
collider.name = "Shape"
var round_props := ["bollard", "trash_bin", "fire_hydrant"]
if prop in round_props:
var cylinder := CylinderShape3D.new()
cylinder.radius = maxf(bounds.size.x, bounds.size.z) * 0.5
cylinder.height = bounds.size.y
collider.shape = cylinder
else:
var box := BoxShape3D.new()
box.size = bounds.size
collider.shape = box
collider.position = bounds.get_center()
body.add_child(collider)
holder.add_child(body)
# Publish the footprint to NavigationServer so guest avoidance steers around
# the prop even though the navmesh itself is a flat walkable plane.
var obstacle := NavigationObstacle3D.new()
obstacle.name = "Avoidance"
obstacle.radius = maxf(bounds.size.x, bounds.size.z) * 0.5 + 0.25
obstacle.height = maxf(bounds.size.y, 0.5)
obstacle.position = Vector3(bounds.get_center().x, 0.0, bounds.get_center().z)
holder.add_child(obstacle)
## AABB of every mesh under `model`, expressed relative to `ancestor`.
func _model_bounds(model: Node3D, ancestor: Node3D) -> AABB:
var bounds := AABB()
var found := false
for node in model.find_children("*", "MeshInstance3D", true, false):
var mi := node as MeshInstance3D
if mi.mesh == null:
continue
var xform := _transform_to_ancestor(mi, ancestor)
var box: AABB = xform * mi.mesh.get_aabb()
bounds = box if not found else bounds.merge(box)
found = true
return bounds
func _transform_to_ancestor(node: Node, ancestor: Node) -> Transform3D:
var xform := Transform3D.IDENTITY
var current := node
while current != null and current != ancestor:
if current is Node3D:
xform = (current as Node3D).transform * xform
current = current.get_parent()
return xform
## Small deterministic clutter next to a prop: a bag by the bin, a crate by the
## newspaper boxes, a cone by the hydrant.
func _add_prop_accessory(holder: Node3D, kind: String) -> void:
match kind:
"bag":
var bag := MeshInstance3D.new()
var sphere := SphereMesh.new()
sphere.radius = 0.22
sphere.height = 0.5
bag.mesh = sphere
bag.material_override = Materials.flat(Color(0.10, 0.11, 0.13), "rubber")
bag.position = Vector3(0.38, 0.22, 0.06)
holder.add_child(bag)
_add_box(holder, Vector3(0.12, 0.10, 0.12), Vector3(0.38, 0.48, 0.06),
Color(0.16, 0.17, 0.20), false, null, Vector2.ONE, "rubber")
"crate":
_add_box(holder, Vector3(0.46, 0.40, 0.40), Vector3(0.48, 0.20, 0.08),
Color(0.45, 0.31, 0.18), true, null, Vector2.ONE, "wood")
_add_box(holder, Vector3(0.48, 0.03, 0.42), Vector3(0.48, 0.41, 0.08),
Color(0.35, 0.23, 0.13), false, null, Vector2.ONE, "wood")
"cone":
_add_cylinder_mesh(holder, 0.16, 0.55, Vector3(-0.42, 0.28, 0.10),
Color(0.95, 0.36, 0.10), "painted_metal")
_add_box(holder, Vector3(0.38, 0.05, 0.38), Vector3(-0.42, 0.03, 0.10),
Color(0.12, 0.12, 0.14), false, null, Vector2.ONE, "rubber")
_add_cylinder_mesh(holder, 0.11, 0.08, Vector3(-0.42, 0.34, 0.10),
Color(0.92, 0.92, 0.9), "painted_metal")
func _load_prop_scene(prop: String) -> Node3D: func _load_prop_scene(prop: String) -> Node3D:
var path := MODEL_DIR + prop + ".glb" var path := MODEL_DIR + prop + ".glb"
if not ResourceLoader.exists(path): if not ResourceLoader.exists(path):
+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
+13
View File
@@ -188,6 +188,19 @@ func _advance() -> void:
10: 10:
if state_frames >= 30: if state_frames >= 30:
_shoot("09-body-camera-timeline.png") _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 ## Put the guard a couple of steps in front of the target so a staged UI capture
+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)
+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)
+118
View File
@@ -68,6 +68,34 @@ func test_prop_scenes_share_one_atlas_material() -> bool:
instance.free() instance.free()
return TestUtils.check(problems.is_empty(), "one atlas material per prop (%s)" % str(problems)) 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: func test_world_places_generated_props() -> bool:
var root := Node3D.new() var root := Node3D.new()
var builder := WorldBuilder.new(root) var builder := WorldBuilder.new(root)
@@ -83,6 +111,96 @@ func test_world_places_generated_props() -> bool:
root.free() root.free()
return a and b and c and d return a and b and c and d
func test_prop_placements_are_valid() -> bool:
var problems := WorldBuilder.placement_problems()
return TestUtils.check(problems.is_empty(),
"prop placements keep the door, steps and approach lane clear (%s)" % str(problems))
func test_placement_validator_catches_problems() -> bool:
var bad: Array = WorldBuilder.PROP_PLACEMENTS.duplicate(true)
bad.append({"zone": "test", "prop": "bench", "x": 0.0, "z": 2.0})
bad.append({"zone": "test", "prop": "bench", "x": 0.0, "z": 2.0})
var problems := WorldBuilder.placement_problems(bad)
var a := TestUtils.check(not problems.is_empty(), "validator rejects bad placements")
var joined := " ".join(problems)
var b := TestUtils.check(joined.contains("steps and door"),
"validator reports the door/steps exclusion")
var c := TestUtils.check(joined.contains("duplicate"), "validator reports a duplicate")
return a and b and c
func test_prop_variants_are_deterministic() -> bool:
var first := WorldBuilder.variant_for(3, 180.0)
var second := WorldBuilder.variant_for(3, 180.0)
var other := WorldBuilder.variant_for(4, 180.0)
var a := TestUtils.check(is_equal_approx(first["rotation"], second["rotation"])
and is_equal_approx(first["scale"], second["scale"]),
"the same placement always gets the same variant")
var b := TestUtils.check(not is_equal_approx(first["rotation"], other["rotation"]),
"different placements get different rotations")
var c := TestUtils.check(first["scale"] >= 0.95 and first["scale"] <= 1.05,
"variant scale stays subtle")
var d := TestUtils.check(absf(first["rotation"] - 180.0) <= 2.01,
"variant rotation stays near the authored angle")
return a and b and c and d
func test_every_prop_has_a_collider() -> bool:
var root := Node3D.new()
var builder := WorldBuilder.new(root)
builder.build()
var detail := root.get_node_or_null("StreetDetail")
var problems: Array[String] = []
if detail == null:
problems.append("no StreetDetail")
else:
for holder in detail.get_children():
var body := holder.get_node_or_null("Collision")
if body == null or not (body is StaticBody3D):
problems.append("%s has no StaticBody3D" % holder.name)
continue
var collider := body.get_node_or_null("Shape") as CollisionShape3D
if collider == null or collider.shape == null:
problems.append("%s has no collision shape" % holder.name)
continue
var size: Vector3
if collider.shape is BoxShape3D:
size = (collider.shape as BoxShape3D).size
elif collider.shape is CylinderShape3D:
var cylinder := collider.shape as CylinderShape3D
size = Vector3(cylinder.radius * 2.0, cylinder.height, cylinder.radius * 2.0)
else:
problems.append("%s uses an unexpected shape" % holder.name)
continue
if size.x <= 0.05 or size.y <= 0.05 or size.z <= 0.05:
problems.append("%s has a degenerate collider" % holder.name)
if collider.position.y + size.y * 0.5 <= 0.05:
problems.append("%s collider sits below the ground" % holder.name)
if holder.get_node_or_null("Avoidance") == null:
problems.append("%s has no navigation obstacle" % holder.name)
root.free()
return TestUtils.check(problems.is_empty(), "every prop has a static collider (%s)" % str(problems))
func test_fallback_props_get_the_same_collider() -> bool:
## A missing `.glb` must not silently remove collision: fallback primitives go
## through the same bounds fit as the imported models.
var root := Node3D.new()
var builder := WorldBuilder.new(root)
var holder := Node3D.new()
var fallback := builder._fallback_prop("bench")
holder.add_child(fallback)
builder._add_prop_collision(holder, "bench", fallback)
var body := holder.get_node_or_null("Collision") as StaticBody3D
var a := TestUtils.check(body != null, "fallback bench gets a static body")
var collider := body.get_node_or_null("Shape") as CollisionShape3D if body else null
var b := TestUtils.check(collider != null and collider.shape is BoxShape3D,
"fallback bench gets a box shape")
var width: float = (collider.shape as BoxShape3D).size.x if b else 0.0
var c := TestUtils.check(width > 1.0, "fallback collider follows the bench width (%.2f)" % width)
var d := TestUtils.check(holder.get_node_or_null("Avoidance") is NavigationObstacle3D,
"fallback bench publishes a navigation obstacle")
holder.free()
root.free()
return a and b and c and d
func test_world_facade_windows_light_at_night() -> bool: func test_world_facade_windows_light_at_night() -> bool:
var root := Node3D.new() var root := Node3D.new()
var builder := WorldBuilder.new(root) var builder := WorldBuilder.new(root)
+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
+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))
+93 -6
View File
@@ -11,7 +11,9 @@ keeps that art direction in one place:
`generate_textures.py`) supplies the colour palette, so a model can look `generate_textures.py`) supplies the colour palette, so a model can look
like the concept without a 3D-model API; like the concept without a 3D-model API;
* `build` writes standard glTF binary (`.glb`) files that Godot 4 imports as * `build` writes standard glTF binary (`.glb`) files that Godot 4 imports as
`PackedScene`s, and `audit` checks the files are structurally valid. `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): Commands (run from the repo root):
@@ -118,13 +120,13 @@ PROPS = [
"short stand, plain light grey studio background"), "short stand, plain light grey studio background"),
"parts": [ "parts": [
{"shape": "box", "size": [0.44, 0.10, 0.38], {"shape": "box", "size": [0.44, 0.10, 0.38],
"at": [0, 0.08, 0], "role": "metal"}, "at": [0, 0.05, 0], "role": "metal"},
{"shape": "box", "size": [0.42, 0.72, 0.34], {"shape": "box", "size": [0.42, 0.72, 0.34],
"at": [0, 0.49, 0], "role": "body"}, "at": [0, 0.46, 0], "role": "body"},
{"shape": "box", "size": [0.34, 0.30, 0.03], {"shape": "box", "size": [0.34, 0.30, 0.03],
"at": [0, 0.60, 0.18], "role": "glass"}, "at": [0, 0.57, 0.18], "role": "glass"},
{"shape": "box", "size": [0.44, 0.10, 0.36], {"shape": "box", "size": [0.44, 0.10, 0.36],
"at": [0, 0.90, 0], "role": "accent"}, "at": [0, 0.87, 0], "role": "accent"},
], ],
}, },
{ {
@@ -705,8 +707,58 @@ def build(only="", quiet=False) -> list:
# Audit. # 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: def audit_glb(path: Path) -> dict:
"""Check a .glb file's container, JSON chunk, and triangle count.""" """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 = [] issues = []
if not path.is_file(): if not path.is_file():
return {"path": str(path), "ok": False, return {"path": str(path), "ok": False,
@@ -765,6 +817,41 @@ def audit_glb(path: Path) -> dict:
for buffer in gltf.get("buffers", []): for buffer in gltf.get("buffers", []):
if int(buffer.get("byteLength", 0)) > len(data): if int(buffer.get("byteLength", 0)) > len(data):
issues.append("buffer larger than the file") issues.append("buffer larger than the file")
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, return {"path": str(path), "ok": not issues, "issues": issues,
"triangles": triangles} "triangles": triangles}
+42
View File
@@ -495,6 +495,48 @@ SCREENS = [
(0.02, 0.20, 0.36, 0.65, "reaction log"), (0.02, 0.20, 0.36, 0.65, "reaction log"),
(0.02, 0.94, 0.98, 0.99, "control hints"), (0.02, 0.94, 0.98, 0.99, "control hints"),
], ],
}, {
"name": "movement_precision",
"kind": "scene",
"title": "Precision walk",
"source": "src/game/guard_controller.gd · README controls",
"ref": "11-precision-walk.png",
"description": "First-person view at the venue steps while the guard uses the "
"precision-walk modifier: a small Precision walk chip on the HUD, "
"the doorway ahead, and a narrower field of view.",
"prompt": (
"First-person 3D game screenshot at night from a security guard standing at "
"the bottom of venue steps, looking at a lit entrance door with a striped "
"awning and STEP LINE sign. Subtle head-height view, brick facade, dark blue "
"night sky, minimal HUD: a small cyan 'Precision walk (Shift)' chip at the "
"bottom left, shift clock top right, thin control hint line at the bottom. "
"Realistic lighting, muted colours, no people"),
"regions": [
(0.00, 0.80, 0.20, 0.88, "precision walk chip"),
(0.30, 0.05, 0.70, 0.60, "entrance door and steps"),
(0.78, 0.02, 0.98, 0.16, "shift clock"),
],
},
{
"name": "prop_gallery",
"kind": "scene",
"title": "Prop gallery",
"source": "tools/model_pipeline.py · docs screenshot set",
"ref": "10-prop-gallery.png",
"description": "Line-up of the six generated street-furniture props — a bollard, "
"bench, trash bin, planter box, newspaper box and fire hydrant — on "
"a plain studio backdrop, used as a close-range art check.",
"prompt": (
"Product line-up photograph of six low-poly 3D street-furniture props on a "
"plain light grey studio backdrop with a low display platform: a black steel "
"bollard, a wooden bench with black legs, a dark green trash bin, a white "
"planter box with a green hedge, a blue newspaper vending box and a red fire "
"hydrant. Even soft studio lighting, straight-on wide shot, muted realistic "
"colours, no people, no street context"),
"regions": [
(0.22, 0.14, 0.80, 0.47, "back row: planter, newspaper box, hydrant"),
(0.22, 0.38, 0.80, 0.68, "front row: bollard, bench, bin"),
],
}, },
] ]
+37
View File
@@ -195,6 +195,43 @@ class AuditTests(unittest.TestCase):
self.assertFalse(result["ok"]) self.assertFalse(result["ok"])
self.assertTrue(result["issues"]) self.assertTrue(result["issues"])
def test_audit_enforces_the_triangle_budget(self):
old = model_pipeline.MAX_TRIANGLES
model_pipeline.MAX_TRIANGLES = 10
try:
path = self.root / "bench.glb"
path.write_bytes(model_pipeline.build_glb(prop("bench")))
result = model_pipeline.audit_glb(path)
finally:
model_pipeline.MAX_TRIANGLES = old
self.assertFalse(result["ok"])
self.assertTrue(any("triangle budget" in issue for issue in result["issues"]),
result["issues"])
def test_degenerate_triangle_helper(self):
self.assertEqual(
model_pipeline._triangle_area2((0, 0, 0), (1, 0, 0), (2, 0, 0)), 0.0)
self.assertGreater(
model_pipeline._triangle_area2((0, 0, 0), (1, 0, 0), (0, 1, 0)), 0.0)
def test_audit_reads_positions_and_uvs_from_the_binary(self):
parsed = parse_glb(model_pipeline.build_glb(prop("bollard")))
gltf, binary = parsed["gltf"], parsed["binary"]
primitive = gltf["meshes"][0]["primitives"][0]
positions = model_pipeline._read_float_accessor(
gltf, binary, primitive["attributes"]["POSITION"])
uvs = model_pipeline._read_float_accessor(
gltf, binary, primitive["attributes"]["TEXCOORD_0"])
indices = model_pipeline._read_index_accessor(
gltf, binary, primitive["indices"])
self.assertEqual(len(positions), gltf["accessors"][
primitive["attributes"]["POSITION"]]["count"])
self.assertEqual(len(uvs), len(positions))
self.assertEqual(len(indices) % 3, 0)
self.assertLess(min(p[1] for p in positions), 0.01)
for u, v in uvs:
self.assertTrue(0.0 <= u <= 1.0 and 0.0 <= v <= 1.0)
def test_build_writes_into_the_models_directory(self): def test_build_writes_into_the_models_directory(self):
old = model_pipeline.MODELS_DIR old = model_pipeline.MODELS_DIR
model_pipeline.MODELS_DIR = self.root model_pipeline.MODELS_DIR = self.root