diff --git a/DONE.md b/DONE.md index f06fa94..914b065 100644 --- a/DONE.md +++ b/DONE.md @@ -830,3 +830,33 @@ Completed 2026-11-01 (post-v0.5.0, pre-v0.6.0). Headless suite **209/209**. `-basis.z`), so noticed NPCs still look at the guard with the fixed facing. - [x] Added `test_yaw_faces_the_movement_direction`; recaptured the targeting screenshot to confirm NPCs now show their back only when walking away. + +## Blender Asset Library Integration + +Completed 2026-11-01 (v0.6.0 work). Headless **222/222**, Python **68/68**, all windowed/headless +smokes pass, `compare` **11 good / 0 bad**. AO/ORM baking remains in Phase 4. + +- [x] Committed the audited library (8 characters, 18 gear, 20 props), generator, `.blend` + sources, manifest, review sheets, and integration notes. +- [x] `NpcAvatar` selects a character variant by role/seed (`npc_casual`, `npc_jacket`, + `npc_coat`, `npc_vest`, `npc_dress`, `npc_hoodie`, `npc_firefighter`) with `npc_base` and + the procedural rig as fallbacks; the choice is recorded in `character_model`. +- [x] Only the five contract slots (`skin`, `cloth`, `pants`, `accessory`, `hair`) are tinted; + fixed detail materials keep their authored colour. `npc_vest` keeps its fixed hi-vis vest, + and firefighter/guard use dark uniform tints. +- [x] Library gear replaces the procedural accessories and hand items at the documented + `head` / `back` / `chest` / `hand` attach points (cap, hood, helmet, backpack, SCBA tank, + lanyard, phone, cup, clipboard, umbrella, camera, sign, flashlight, wand, radio), with the + primitive path retained as fallback. +- [x] `WorldBuilder` loads the Blender replacements (`bollard`, `bench`, `trash_bin`, + `planter_box`, `newspaper_box`, `fire_hydrant`, `street_lamp`, `street_tree`, + `traffic_cone`) and places the new dressing (`palm_tree`, `round_planter`, `a_frame_sign`, + `crowd_barrier`, `stanchion`, `security_podium`, `handrail`, + `double_door`, `marquee`, `sedan`, `wall_lantern`) with colliders/avoidance and placement + validation. +- [x] Library emissive surfaces (lamp bulbs, marquee bulbs/sign face, wall lanterns, door LED, + sedan lights) are collected and driven by `set_night_mode()`. +- [x] Added a library triangle counter and 60k triangle budget check; the current set is + ~27,986 triangles. NPC budget smoke: 16 avatars = 112 meshes at ~6.9 ms. +- [x] Recaptured the README/catalogue screenshots (including the prop gallery from the library + models) and fixed the marquee text placement. diff --git a/MEMORY.md b/MEMORY.md index 6c1d6f3..bdfba62 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -129,7 +129,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o 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. -- **Blender asset library (v0.6.0, built + audited 2026-11-01, not integrated):** +- **Blender asset library (v0.6.0, integrated 2026-11-01):** `tools/blender_assets.py` (`build`/`render` in Blender; `audit`/`sheet`/`list` stdlib) writes `assets/models/blender/{npc,npc_gear,props}/*.glb`, `manifest.json` (integration source of truth), sources in `assets/models/blender_src/` (`.gdignore`d), and review sheets @@ -143,6 +143,17 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o Fixed-colour detail materials carry a 4×4 texture because `_apply_outfit_materials()` sets `albedo_color = WHITE` on unknown slots. Cycles CPU render ≈ 3 s/asset; Godot import of the library is clean. + **Integration:** `NpcAvatar.character_model_for()` selects a variant by role/seed; only the + five contract slots are tinted (detail materials untouched; `npc_vest` keeps its fixed hivis + vest, firefighter/guard get dark uniform tints). Gear is attached at head/back/chest/hand and + falls back to primitives; `npc_hoodie` skips the extra hood gear. `WorldBuilder` prefers + `assets/models/blender/props/` for the six replacements and the new street dressing, fits the + same colliders/obstacles, and collects library emissives for `set_night_mode()`. + `LIBRARY_TRIANGLE_BUDGET` = 60k (current ~27,986). Library props are excluded from + `PROP_PLACEMENTS` only when they need dedicated slots (door/marquee/sedan/handrail/lantern). +- **v0.6.0 remaining:** AO/ORM baking for the library props, higher-resolution environment + textures with normal maps, the Forward+ desktop evaluation, then version 0.6.0 docs/release. + Headless suite is 222/222 and Python tooling 68/68 after integration. - **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. diff --git a/README.md b/README.md index 8077c6b..d772a38 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,7 @@ which is gitignored) onto the `cloth`/`pants` albedos; `build` bakes them into t `.glb` and regenerates the procedural normal + packed ORM maps. `palette` writes `assets/textures/npc/palette.json` using `model_pipeline.dominant_colors`. -### Blender asset library (staged, not yet loaded by the game) +### Blender asset library (loaded by the game) `tools/blender_assets.py` models a larger library procedurally in Blender for the v0.6.0 integration: **8 skinned NPC characters** (casual, jacket, coat, hi-vis vest, @@ -260,8 +260,13 @@ attach points (caps, hood, helmet, backpack, SCBA tank, lanyard, phone, cup, clipboard, umbrella, camera, sign, flashlight, wand, radio, …); and **20 props** (octagonal bollard and bin, slatted bench, hydrant, news box, planters, street lamp, street and palm trees, A-frame sign, wall lantern, cone, crowd barrier, stanchion, -handrail, podium, double door, marquee, sedan). Contract and integration notes: -[`docs/blender-assets.md`](docs/blender-assets.md). +handrail, podium, double door, marquee, sedan). + +`NpcAvatar` selects a character by role/seed, tints the five contract slots, and +attaches gear at the documented points; `WorldBuilder` loads the library props +with colliders/avoidance and drives their emissive parts from the day/night +cycle. The single `npc_base.glb` and primitive props remain offline fallbacks. +Contract and integration notes: [`docs/blender-assets.md`](docs/blender-assets.md). | Characters | Props | | --- | --- | @@ -507,11 +512,11 @@ Implemented so far: - Prop collision and placement (v0.4.0): every prop fits a static collider from its mesh bounds and publishes a navigation obstacle, so the guard and guests cannot walk through benches, bins, or bollards; placements are grouped into zones with a validator, and deterministic variants add rotation, scale, and small clutter - Prop atlas (v0.3.1): `tools/model_pipeline.py` packs each prop's palette into one power-of-two colour atlas plus a metallic/roughness atlas and emits a single material with per-vertex UVs, so a prop is one draw material rather than one per part; `audit` enforces the contract - Generated prop models (v0.3.0 work): `tools/model_pipeline.py` builds bollards, benches, bins, planters, newspaper boxes and a hydrant as `.glb` props with per-part materials, loaded by `WorldBuilder` with a procedural fallback; the optional AI concept step (`model_pipeline.py concept`) supplies per-prop colour palettes -- NPC base model (v0.6.0 assets): `tools/npc_pipeline.py` builds a skinned, UV-mapped base character (`assets/models/generated/npc_base.glb`) with per-slot albedo/normal/ORM maps and an armature matching the procedural rig's node names; AI fabric albedos (shirt/trousers) and a derived `palette.json` are layered in. Runtime integration into `NpcAvatar` is pending, so the procedural rig still renders +- NPC characters + library (v0.6.0): `NpcAvatar` loads one of 8 Blender-authored skinned variants (role/seed selected) with five tintable material slots, `Rig/Head` focus anchor, and attach-point gear; `npc_base.glb` and the procedural rig remain fallbacks. `tools/blender_assets.py` builds/audits the character, gear, and 20-prop library; `WorldBuilder` loads the replacements and new street dressing (lamp, trees, palms, planters, barriers, podium, doors, marquee, sedan, handrail) with colliders, navigation obstacles, and phase-driven emissives - Web build: `build/gig-line-web-.zip` (Godot's non-threaded web template, no COOP/COEP headers needed) built by `tools/build_releases.sh` and published with the desktop artifacts; serve the unzipped folder from any static host - Aim-free selection pass (v0.3.0 work, [ADR-0011](docs/adr/0011-aim-free-selection-and-precision-walk.md)): the focus cone ranks candidates by angle then distance, `Tab` and the mouse wheel cycle nearby people nearest-first and pin the selection, right click clears focus with a short re-acquire grace, the HUD reports how many people are in the ring, and holding `Shift` walks at half speed for close work -Not yet implemented: wiring the authored NPC base model into the live game (`NpcAvatar._load_model`, see [`docs/plan-graphics-v0.6.md`](docs/plan-graphics-v0.6.md) — the procedural rig still renders for now), live 3D body-camera replay, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`). +Not yet implemented: AO/ORM baking for the library props and the higher-resolution environment textures/Forward+ evaluation in [`docs/plan-graphics-v0.6.md`](docs/plan-graphics-v0.6.md), live 3D body-camera replay, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`). ### Smoke Test Checklist diff --git a/TODO.md b/TODO.md index a748a21..a1377d5 100644 --- a/TODO.md +++ b/TODO.md @@ -1,18 +1,18 @@ # TODO Godot 4.3 prototype. Latest release **v0.5.0** (NPC characters and player movement). Headless -suite **218/218** plus **68** Python tooling tests; the session, PLAN-verb, crowd, emergency, +suite **222/222** plus **68** Python tooling tests; the session, PLAN-verb, crowd, emergency, timeline, audio, scenario-content, props, NPC-budget, movement, and windowed focus/radial smokes pass. Anonymous downloads are enabled on the host. The scheduled presentation pass is complete (see `DONE.md`). The next scheduled pass is the graphics-quality work in [`docs/plan-graphics-v0.6.md`](docs/plan-graphics-v0.6.md). -> **Blender asset library built (2026-11-01):** `tools/blender_assets.py` generated 8 NPC -> character variants, 18 gear pieces, and 20 street/venue props under `assets/models/blender/` -> (audit clean, Godot import clean, see [`docs/blender-assets.md`](docs/blender-assets.md)). -> Treat `assets/models/blender/manifest.json` as the integration source of truth; nothing is -> wired into the game yet. +> **Blender asset library integrated (2026-11-01):** 8 NPC character variants, 18 gear pieces, +> and 20 street/venue props are now loaded by `NpcAvatar` and `WorldBuilder` from +> `assets/models/blender/` (manifest as source of truth, see +> [`docs/blender-assets.md`](docs/blender-assets.md)). The procedural rig and primitive props +> remain offline fallbacks; AO/ORM baking is the remaining Phase 4 follow-up. ## Graphics quality — Blender-authored NPC characters (v0.6.0) @@ -64,59 +64,6 @@ Detailed plan: [`docs/plan-graphics-v0.6.md`](docs/plan-graphics-v0.6.md) · [AD - [x] Re-measure `smoke_npc_budget.gd`; tune `LOD_TIERS` if the skinned rig is heavier up close. - [x] Re-verify the focus-cone smoke (`smoke_focus_radial.gd`) and the movement smoke. -### Phase 3.5 — Blender asset library integration - -Contract and outputs: [`assets/models/blender/manifest.json`](assets/models/blender/manifest.json), -generator `tools/blender_assets.py`, review sheets `docs/blender_previews/sheet_*.jpg`, -integration notes [`docs/blender-assets.md`](docs/blender-assets.md). The library has -8 `npc` variants, 18 `npc_gear` pieces, and 20 `props`; each entry records path, triangle/vertex -count, bounds, material slots, and (for gear) its attach point. - -- [x] Wait for the running build to finish, then run `python3 tools/blender_assets.py audit` and - reconcile every failure. Characters were reduced to fit the unchanged 6,000/4,000 contract - (now 3,788–4,640 tris / 3,195–3,962 verts); the audit also rejects zero-area triangles. -- [x] Run `python3 tools/blender_assets.py sheet` and review the contact sheets; reject assets - whose silhouette, grounded origin, or face direction (+Z in Godot) is wrong. -- [ ] Commit the finished library with the generator, `.blend` sources, manifest, previews, and - `docs/blender-assets.md`; add generated/preview folders to `.gitignore` only where CI can - rebuild them offline. Keep the audit runnable without Blender. (`.gitignore` already - excludes the per-asset preview PNGs, Godot-extracted GLB textures, and `*.blend1`.) -- [x] Add `src/test/tests/test_blender_assets.gd`: library imports as scenes, characters keep - bones/slots, joints match `npc_base`, bones accept the avatar pose math, props grounded. -- [ ] Tint only the five contract slots in `_apply_outfit_materials()` (fixed-colour detail - materials such as `hivis`/`reflective`/`leather` must keep their colour), and skip the - `vest` cloth→yellow override for `npc_vest` (its vest is the fixed `hivis` material). -- [ ] Bake AO/ORM for the Blender characters and props (they currently reuse tiling slot maps - or plain PBR factors). -- [x] Add `tools/test_blender_assets.py` for the manifest contract (all catalogue entries present, - audit clean, attach points known, paths inside `assets/models/blender/`). -- [ ] Let `NpcAvatar` choose a character model from the manifest by the profile's role/layer - (`npc_casual` / `npc_jacket` / `npc_coat` / `npc_vest` / `npc_dress` / `npc_hoodie` / - `npc_firefighter` / `npc_guard`), with the existing `npc_base.glb` and procedural rig as - fallbacks; record the chosen path for tests. -- [ ] Keep `outfit_for()` tinting the `skin`/`cloth`/`pants`/`accessory`/`hair` slots on every - variant and keep `Rig/Head` as the focus/nametag anchor. -- [ ] Replace the procedural cap/hood/backpack/sunglasses/lanyard and hand items with the - matching `npc_gear` assets at the documented `head` / `back` / `chest` / `hand` attach - points; fall back to the current primitives when gear is missing. -- [ ] Load the replacement props (`bollard`, `bench`, `trash_bin`, `planter_box`, - `newspaper_box`, `fire_hydrant`, `street_lamp`, `street_tree`, `traffic_cone`) from the - Blender library behind the existing `WorldBuilder` model/fallback path. -- [ ] Place the new street/venue props (`palm_tree`, `round_planter`, `a_frame_sign`, - `wall_lantern`, `crowd_barrier`, `stanchion`, `handrail`, `security_podium`, - `double_door`, `marquee`, `sedan`) through `PROP_PLACEMENTS`/dedicated venue slots, with - collider + `NavigationObstacle3D` footprints and no overlap with the door/approach lane. -- [ ] Wire the emissive parts (`street_lamp` bulb, `wall_lantern`, `double_door` lock LED, - `marquee` bulbs/sign face, `sedan` headlights) to the day/night phase lighting instead of - leaving them always on. -- [ ] Use the library `street_tree`/`palm_tree` instead of the current cone trees, and the - Blender `marquee`/`double_door` where the procedural versions currently sit; compare - silhouettes with `screen_designs.py`. -- [ ] Re-measure `smoke_npc_budget.gd` and add a prop draw/triangle budget check; keep - `MAX_LIVE_NPCS` unchanged until the new characters are measured. -- [ ] Regenerate all staged captures and run `screen_designs.py compare` + `judge`; add a - character close-up capture (the 02 target shot) to the catalogue if needed. - ### Phase 4 — Environment and lighting lift - [ ] Bake AO for the prop set through the Blender library (replacements above) or into the ORM/albedo maps behind the existing `WorldBuilder` contract; keep the procedural fallback. @@ -127,9 +74,9 @@ count, bounds, material slots, and (for gear) its attach point. ### Phase 5 — Verification and release -- [ ] Headless suite green: fallback build, outfit determinism, `Rig/Head` presence, material recipe assertions, `npc_pipeline.py audit`, and `blender_assets.py audit` + manifest tests (skip Blender builds with a reason when Blender is absent). -- [ ] Windowed smokes pass: `smoke_npc_budget.gd`, `smoke_movement.gd`, `smoke_focus_radial.gd`, `smoke_props.gd`. -- [ ] `screen_designs.py compare` and `judge` show no regressions; `blender_assets.py sheet` contact sheets reviewed for the character/gear/prop library. +- [x] Headless suite green: fallback build, outfit determinism, `Rig/Head` presence, material recipe assertions, `npc_pipeline.py audit`, and `blender_assets.py audit` + manifest tests (skip Blender builds with a reason when Blender is absent). +- [x] Windowed smokes pass: `smoke_npc_budget.gd`, `smoke_movement.gd`, `smoke_focus_radial.gd`, `smoke_props.gd`. +- [x] `screen_designs.py compare` and `judge` show no regressions; `blender_assets.py sheet` contact sheets reviewed for the character/gear/prop library. - [ ] Bump `config/version` to `0.6.0`. - [ ] Update `README.md`, `docs/architecture.md` (Level 2 gains `tools/npc_pipeline.py`), and mark ADR-0013 Accepted. - [ ] Publish the release per `docs/RELEASING.md`. diff --git a/docs/architecture.md b/docs/architecture.md index a5d1a62..9a2108a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,7 +80,7 @@ The player interacts with NPCs, venue entrances, access artifacts, equipment, st - `WorldBuilder` assembles the procedural venue and instantiates the generated prop models from `assets/models/generated/` with a procedural fallback. - `tools/model_pipeline.py` builds and audits the street-prop `.glb` models and packs their colour atlases. - `tools/npc_pipeline.py` builds and audits the skinned NPC base model (`assets/models/generated/npc_base.glb`) and its per-slot material maps, with an optional AI fabric override (ADR-0013). -- `tools/blender_assets.py` builds, renders and audits the staged Blender asset library (`assets/models/blender/{npc,npc_gear,props}/` + `manifest.json`): NPC character variants that are drop-in for `npc_base`, attach-point gear, and street/venue props. Not loaded at runtime yet (see `docs/blender-assets.md`). +- `tools/blender_assets.py` builds, renders and audits the Blender asset library (`assets/models/blender/{npc,npc_gear,props}/` + `manifest.json`): NPC character variants that are drop-in for `npc_base`, attach-point gear, and street/venue props. `NpcAvatar` selects variants/gear by role/seed and `WorldBuilder` loads the prop replacements and new street dressing, with procedural fallbacks (see `docs/blender-assets.md`). - `tools/asset_pipeline.py` generates and audits role-aware textures; `tools/screen_designs.py` generates screen references and judges captures. Confirmed game assets live in `assets/textures/ai/game_ready/` (audited surface, UI, and card textures), `assets/models/generated/` (prop and NPC `.glb` models plus their texture atlases), and `assets/textures/npc/` (NPC material maps and `palette.json`). Development-only outputs — AI concept sheets, Blender sources, raw AI fabrics — are gitignored and regenerated by the tools. diff --git a/docs/blender-assets.md b/docs/blender-assets.md index 1520252..a7a58a6 100644 --- a/docs/blender-assets.md +++ b/docs/blender-assets.md @@ -1,7 +1,8 @@ # Blender asset library -Status: built and audited (2026-11-01). Nothing in the game loads these assets yet. -They are staged for the Phase 3.5 integration in +Status: built, audited, and integrated (2026-11-01). `NpcAvatar` selects the +character variants and attaches the gear; `WorldBuilder` loads the prop library +with colliders and phase-driven emissives. See the Phase 3.5 integration in [`plan-graphics-v0.6.md`](plan-graphics-v0.6.md). Generator: [`tools/blender_assets.py`](../tools/blender_assets.py). Manifest (the @@ -109,11 +110,11 @@ colour texture. The current `_apply_outfit_materials()` sets `albedo_color = WHITE` on any slot it does not recognise, and these keep their colour through that. When integrating, tint only the five contract slots. -**Runtime tint notes.** `_build_skinned_body()` currently paints `cloth` -hi-vis yellow for the `vest` layer. On `npc_vest` the vest is already the fixed -`hivis` material and `cloth` is the undershirt, so skip that override for this -model. For `npc_firefighter` and `npc_guard`, tint `cloth`/`pants` dark (turnout / -uniform navy) and not with the role's `TYPE_COLORS`. +**Runtime tint notes (implemented).** Only the five contract slots are tinted; +fixed detail materials keep their authored colour. On `npc_vest` the vest is the +fixed `hivis` material and `cloth` is the undershirt, so the yellow override is +skipped. `npc_firefighter`/`npc_guard` use dark turnout/uniform tints instead of +the role's `TYPE_COLORS`. ## Gear attach points diff --git a/docs/plan-graphics-v0.6.md b/docs/plan-graphics-v0.6.md index 57c1f2b..f2c2794 100644 --- a/docs/plan-graphics-v0.6.md +++ b/docs/plan-graphics-v0.6.md @@ -167,8 +167,10 @@ Outputs land under `assets/textures/ai/game_ready/` (or a new ### Phase 3.5 — Blender asset library (characters, gear, props) The library generator is `tools/blender_assets.py` (flatpak Blender, headless). -It stages outputs for integration; nothing is wired into the game until the -audit and manifest are clean. Integration notes: [`blender-assets.md`](blender-assets.md). +**Status: integrated** — `NpcAvatar` selects characters/gear and `WorldBuilder` +loads the replacement/new props plus phase-driven emissives; the procedural rig +and primitive props remain fallbacks. Integration notes: +[`blender-assets.md`](blender-assets.md). **Outputs and contract** diff --git a/docs/screenshots/01-venue-overview.png b/docs/screenshots/01-venue-overview.png index bc8e450..be80ea8 100644 Binary files a/docs/screenshots/01-venue-overview.png and b/docs/screenshots/01-venue-overview.png differ diff --git a/docs/screenshots/02-targeting-npc.png b/docs/screenshots/02-targeting-npc.png index e14cd13..2bc8c77 100644 Binary files a/docs/screenshots/02-targeting-npc.png and b/docs/screenshots/02-targeting-npc.png differ diff --git a/docs/screenshots/03-access-inspection.png b/docs/screenshots/03-access-inspection.png index 3052c75..047da5e 100644 Binary files a/docs/screenshots/03-access-inspection.png and b/docs/screenshots/03-access-inspection.png differ diff --git a/docs/screenshots/04-radio-report.png b/docs/screenshots/04-radio-report.png index 670ab28..9659af9 100644 Binary files a/docs/screenshots/04-radio-report.png and b/docs/screenshots/04-radio-report.png differ diff --git a/docs/screenshots/05-late-night-avenue.png b/docs/screenshots/05-late-night-avenue.png index ec0de3d..30300ec 100644 Binary files a/docs/screenshots/05-late-night-avenue.png and b/docs/screenshots/05-late-night-avenue.png differ diff --git a/docs/screenshots/06-night-flashlight.png b/docs/screenshots/06-night-flashlight.png index 44851b3..48dd9f6 100644 Binary files a/docs/screenshots/06-night-flashlight.png and b/docs/screenshots/06-night-flashlight.png differ diff --git a/docs/screenshots/07-wand-screening.png b/docs/screenshots/07-wand-screening.png index e1af5c9..bf85d0b 100644 Binary files a/docs/screenshots/07-wand-screening.png and b/docs/screenshots/07-wand-screening.png differ diff --git a/docs/screenshots/08-escalation-hud.png b/docs/screenshots/08-escalation-hud.png index d9548fb..5e35a56 100644 Binary files a/docs/screenshots/08-escalation-hud.png and b/docs/screenshots/08-escalation-hud.png differ diff --git a/docs/screenshots/09-body-camera-timeline.png b/docs/screenshots/09-body-camera-timeline.png index 5c38c47..8f689e0 100644 Binary files a/docs/screenshots/09-body-camera-timeline.png and b/docs/screenshots/09-body-camera-timeline.png differ diff --git a/docs/screenshots/10-prop-gallery.png b/docs/screenshots/10-prop-gallery.png index ebb19f0..a60e000 100644 Binary files a/docs/screenshots/10-prop-gallery.png and b/docs/screenshots/10-prop-gallery.png differ diff --git a/src/game/npc_avatar.gd b/src/game/npc_avatar.gd index 7c80f05..7b05f81 100644 --- a/src/game/npc_avatar.gd +++ b/src/game/npc_avatar.gd @@ -63,6 +63,7 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector npc.setup(id, profile, intent, seed_value) npc.access_override = p_override _visual_seed = seed_value + _npc_type = profile.npc_type spawn_position = p_spawn target_position = p_target door_position = p_door @@ -105,6 +106,7 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector var _visual_seed := 0 var _rig: Node3D = null +var _npc_type := 0 var _head: Node3D = null var _bob_phase := 0.0 var _guard_ref: Node3D = null @@ -130,6 +132,7 @@ var _queue_offset := Vector3.ZERO var _height_scale := 1.0 var loaded_model := false var fallback_avatar := true +var character_model := "" ## Tests can force the offline rig; the game always tries the committed model. var use_skinned_model := true var _skeleton: Skeleton3D = null @@ -162,6 +165,46 @@ const ACCESSORIES := { Rules.NpcType.CONFUSED_VISITOR: "none", } +const BLENDER_NPC_DIR := "res://assets/models/blender/npc/" +const BLENDER_GEAR_DIR := "res://assets/models/blender/npc_gear/" +const GEAR_ATTACH := { + "cap": "head", "beanie": "head", "hood": "head", "sunglasses": "head", + "firefighter_helmet": "head", + "backpack": "back", "shoulder_bag": "back", "scba_tank": "back", + "lanyard": "chest", + "phone": "hand", "cup": "hand", "clipboard": "hand", "umbrella": "hand", + "camera": "hand", "protest_sign": "hand", "flashlight": "hand", + "wand": "hand", "radio": "hand", +} +## Character variants from tools/blender_assets.py. The manifest is the source +## of truth; this mapping keeps selection deterministic and testable offline. +const CHARACTER_MODELS := { + Rules.NpcType.TOURIST: "npc_casual", + Rules.NpcType.CONFUSED_VISITOR: "npc_casual", + Rules.NpcType.INTOXICATED: "npc_casual", + Rules.NpcType.AUTHORIZED_GUEST: "npc_jacket", + Rules.NpcType.AUTHORIZED_STAFF: "npc_jacket", + Rules.NpcType.PROTESTER: "npc_jacket", + Rules.NpcType.INFLUENCER: "npc_coat", + Rules.NpcType.TRESPASSER: "npc_coat", + Rules.NpcType.VIOLENT_THREAT: "npc_hoodie", + Rules.NpcType.MENTAL_CRISIS: "npc_hoodie", + Rules.NpcType.PERFORMER: "npc_dress", + Rules.NpcType.DELIVERY: "npc_vest", + Rules.NpcType.RESPONDER: "npc_firefighter", +} + +## Which library character represents this profile. Pure and seed-stable; some +## roles alternate between two silhouettes so crowds do not clone. +static func character_model_for(npc_type: int, seed_value: int) -> String: + match npc_type: + Rules.NpcType.AUTHORIZED_GUEST: + return "npc_jacket" if seed_value % 3 == 0 else "npc_casual" + Rules.NpcType.TRESPASSER, Rules.NpcType.VIOLENT_THREAT: + return "npc_hoodie" if seed_value % 2 == 0 else "npc_coat" + _: + return str(CHARACTER_MODELS.get(npc_type, "npc_casual")) + const HAIR_STYLES := ["short", "long", "bun", "beard", "bald", "short"] const HAND_ITEMS := ["phone", "cup", "clipboard", "umbrella", "camera", "none"] @@ -292,7 +335,11 @@ static func reaction_label(mode: String, escalation: int) -> String: ## Instantiate the Blender-authored skinned base mesh. Returns false when the ## GLB is missing/not imported, so setup falls back to the procedural rig. func _build_skinned_body(npc_type: int) -> bool: - var path := "res://assets/models/generated/npc_base.glb" + character_model = character_model_for(npc_type, _visual_seed) + var path := BLENDER_NPC_DIR + character_model + ".glb" + if not ResourceLoader.exists(path): + character_model = "npc_base" + path = "res://assets/models/generated/npc_base.glb" if not ResourceLoader.exists(path): return false var packed := ResourceLoader.load(path) as PackedScene @@ -316,18 +363,39 @@ func _build_skinned_body(npc_type: int) -> bool: var outfit := outfit_for(npc_type, _visual_seed) var cloth: Color = outfit["shirt"] - match str(outfit["layer"]): - "vest": - cloth = Color(0.92, 0.72, 0.12) - "coat": - cloth = cloth.darkened(0.25) - "jacket": - cloth = cloth.darkened(0.12) + var pants: Color = outfit["pants"] + if character_model == "npc_base": + match str(outfit["layer"]): + "vest": + cloth = Color(0.92, 0.72, 0.12) + "coat": + cloth = cloth.darkened(0.25) + "jacket": + cloth = cloth.darkened(0.12) + else: + match character_model: + "npc_vest": + # The hi-vis vest is a fixed material; cloth is the undershirt. + pass + "npc_firefighter": + cloth = Color(0.16, 0.17, 0.20) + pants = Color(0.13, 0.14, 0.17) + "npc_guard": + cloth = Color(0.10, 0.13, 0.20) + pants = Color(0.09, 0.10, 0.14) + "npc_dress": + pants = outfit["pants"].darkened(0.25) + _: + match str(outfit["layer"]): + "coat": + cloth = cloth.darkened(0.25) + "jacket": + cloth = cloth.darkened(0.12) _height_scale = float(outfit["height_scale"]) _rig.scale = Vector3.ONE * _height_scale _shirt_color = cloth _skin_color = outfit["skin"] - _apply_outfit_materials(npc_type, outfit, cloth) + _apply_outfit_materials(npc_type, outfit, cloth, pants) # Animation proxies: the existing _animate()/idle code drives these Node3D # pivots; _sync_skeleton() copies their rotations onto the armature bones. @@ -365,6 +433,7 @@ func _build_skinned_body(npc_type: int) -> bool: _bind_bones() _add_accessory(str(outfit["accessory"]), cloth) + _add_character_gear() _add_hand_item(str(outfit["hand_item"]), outfit["skin"]) _build_focus_circle() _add_props(npc_type) @@ -374,12 +443,13 @@ func _build_skinned_body(npc_type: int) -> bool: ## Tint the imported material slots per avatar. Each avatar gets its own copied ## materials so the shared imported scene is never mutated. -func _apply_outfit_materials(npc_type: int, outfit: Dictionary, cloth: Color) -> void: +func _apply_outfit_materials(npc_type: int, outfit: Dictionary, cloth: Color, + pants: Color) -> void: var mesh: Mesh = _skinned_mesh.mesh var tints := { "skin": outfit["skin"], "cloth": cloth, - "pants": outfit["pants"], + "pants": pants, "hair": outfit["hair"], "accessory": Color(0.10, 0.11, 0.14), } @@ -388,8 +458,12 @@ func _apply_outfit_materials(npc_type: int, outfit: Dictionary, cloth: Color) -> if source == null: continue var slot := str(source.resource_name) + # Only the five contract slots are tinted. Fixed-colour detail materials + # (hivis, reflective, leather, steel, ...) keep their authored colour. + if not tints.has(slot): + continue _skinned_mesh.set_surface_override_material(index, - Materials.npc_slot(source, tints.get(slot, Color.WHITE))) + Materials.npc_slot(source, tints[slot])) func _bind_bones() -> void: _bone_indices.clear() @@ -568,6 +642,17 @@ func _add_hair(head: Node3D, style: String, color: Color) -> void: func _add_hand_item(kind: String, skin: Color) -> void: if _hands.size() < 2: return + var item := kind + match character_model: + "npc_firefighter": + item = "flashlight" + "npc_guard": + item = "radio" + "npc_hoodie": + if item == "none": + item = "cup" + if item != "none" and _attach_gear(item): + return var hand: Node3D = _hands[1] match kind: "phone": @@ -599,8 +684,73 @@ func _add_hand_item(kind: String, skin: Color) -> void: 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")) +## Load and attach a Blender gear GLB at its documented attach point. Returns +## false when the asset is missing so the primitive fallback still works. +func _attach_gear(gear_name: String) -> bool: + if not GEAR_ATTACH.has(gear_name): + return false + var path := BLENDER_GEAR_DIR + gear_name + ".glb" + if not ResourceLoader.exists(path): + return false + var packed := ResourceLoader.load(path) as PackedScene + if packed == null: + return false + var node := packed.instantiate() as Node3D + if node == null: + return false + node.name = "Gear_" + gear_name + match str(GEAR_ATTACH[gear_name]): + "head": + if _head == null: + node.free() + return false + _head.add_child(node) + "hand": + if _hands.size() < 2: + node.free() + return false + _hands[1].add_child(node) + "back": + if _rig == null: + node.free() + return false + node.position = Vector3(0, 1.10, 0) + _rig.add_child(node) + "chest": + if _rig == null: + node.free() + return false + node.position = Vector3(0, 1.30, 0) + _rig.add_child(node) + _: + node.free() + return false + _detail_nodes.append(node) + return true + +## Character-specific library gear (firefighter helmet/SCBA, dress bag, vest +## cap). Accessory/hand items already cover the shared roles. +func _add_character_gear() -> void: + match character_model: + "npc_firefighter": + _attach_gear("firefighter_helmet") + _attach_gear("scba_tank") + "npc_guard": + _attach_gear("cap") + "npc_vest": + _attach_gear("cap") + "npc_dress": + _attach_gear("shoulder_bag") + ## Small role accessory: cap, hood, backpack, sunglasses or a staff lanyard. func _add_accessory(kind: String, shirt: Color) -> void: + if character_model == "npc_firefighter": + return + if character_model == "npc_hoodie" and kind == "hood": + # The hoodie variant already models the hood up. + return + if _attach_gear(kind): + return match kind: "cap": _detail(_add_sphere(_head, 0.175, Vector3(0, 0.12, 0), shirt.darkened(0.15), "cloth")) diff --git a/src/game/world_builder.gd b/src/game/world_builder.gd index 0c2e873..75a2ed4 100644 --- a/src/game/world_builder.gd +++ b/src/game/world_builder.gd @@ -14,8 +14,14 @@ const T_SKYLINE := preload("res://assets/textures/skyline.png") ## Generated prop models, one `.glb` per street-furniture piece. const MODEL_DIR := "res://assets/models/generated/" +const BLENDER_PROP_DIR := "res://assets/models/blender/props/" const MODEL_PROPS := ["bollard", "bench", "trash_bin", "planter_box", "newspaper_box", "fire_hydrant"] +## Library props loaded in place of the generated set, plus new street dressing. +const LIBRARY_PROPS := ["bollard", "bench", "trash_bin", "planter_box", + "newspaper_box", "fire_hydrant", "street_lamp", "street_tree", "traffic_cone", + "palm_tree", "round_planter", "a_frame_sign", "crowd_barrier", "stanchion", + "security_podium"] var root: Node3D = null ## Door lock indicator mesh near the handle; toggled by GameManager. @@ -33,6 +39,11 @@ var _window_mats: Array[StandardMaterial3D] = [] ## not listed), so tests and the debug overlay can report real model usage. var loaded_models: Array[String] = [] var fallback_props: Array[String] = [] +## Props that came from the Blender library rather than the generated set. +var library_props: Array[String] = [] +var library_nodes: Array[Node3D] = [] +## Total triangle budget for the Blender library pieces placed in the world. +const LIBRARY_TRIANGLE_BUDGET := 60000 func _init(root_node: Node3D) -> void: root = root_node @@ -48,6 +59,7 @@ func build() -> void: _build_crosswalk() _build_street_furniture() _build_street_detail() + _build_library_dressing() _build_guard_spawn() _build_navmesh() set_night_mode(false) @@ -106,6 +118,16 @@ func _build_building() -> void: # Door lock indicator (green = open, red = locked), placed beside the handle door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -3.82), Color(0.1, 0.9, 0.3)) door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3)) + # Blender glazed double door in front of the recessed frame. + var door_model := _load_prop_scene("double_door") + if door_model != null: + door_model.name = "LibraryDoubleDoor" + door_model.position = Vector3(0, 0.0, -3.84) + building.add_child(door_model) + library_nodes.append(door_model) + for material in _emissive_materials(door_model): + if not _lamp_bulb_mats.has(material): + _lamp_bulb_mats.append(material) _build_facade_windows(building) var col := BoxShape3D.new() col.size = Vector3(40, 18, 4) @@ -257,21 +279,32 @@ func _build_skyline_backdrop() -> void: var _skyline_mat: StandardMaterial3D = null func _build_marquee() -> void: - ## Striped awning above the door, then the neon sign strip + label. - var awning := _add_box(root, Vector3(10.5, 0.08, 1.8), Vector3(0, 6.55, -3.7), Color(1, 1, 1), true, T_AWNING, Vector2(4, 1), "awning") - awning.name = "Awning" - ## Neon-style marquee strip above the door: emissive bars + a label. - var strip_parent := Node3D.new() - strip_parent.name = "Marquee" - for offset in [-2.2, 2.2]: - var bar := _add_box(strip_parent, Vector3(0.25, 0.5, 0.5), Vector3(offset, 7.4, -3.6), Color(1, 0.35, 0.75)) - var mat := _emissive_material(Color(1.0, 0.35, 0.75)) - bar.material_override = mat - _marquee_emissive.append(mat) + ## Blender canopy + sign box above the door; the procedural striped awning and + ## neon bars remain only as a fallback when the library asset is missing. + var library := _load_prop_scene("marquee") + if library != null: + library.name = "LibraryMarquee" + library.position = Vector3(0, 5.62, -3.9) + root.add_child(library) + library_nodes.append(library) + for material in _emissive_materials(library): + if not _marquee_emissive.has(material): + _marquee_emissive.append(material) + else: + var awning := _add_box(root, Vector3(10.5, 0.08, 1.8), Vector3(0, 6.55, -3.7), Color(1, 1, 1), true, T_AWNING, Vector2(4, 1), "awning") + awning.name = "Awning" + var strip_parent := Node3D.new() + strip_parent.name = "Marquee" + for offset in [-2.2, 2.2]: + var bar := _add_box(strip_parent, Vector3(0.25, 0.5, 0.5), Vector3(offset, 7.4, -3.6), Color(1, 0.35, 0.75)) + var mat := _emissive_material(Color(1.0, 0.35, 0.75)) + bar.material_override = mat + _marquee_emissive.append(mat) + root.add_child(strip_parent) marquee_label = Label3D.new() marquee_label.name = "MarqueeLabel" marquee_label.text = "GIG LINE" - marquee_label.position = Vector3(0, 7.3, -3.55) + marquee_label.position = Vector3(0, 6.45, -2.12) marquee_label.modulate = Color(1, 0.6, 0.9) marquee_label.font_size = 160 marquee_label.pixel_size = 0.004 @@ -279,61 +312,110 @@ func _build_marquee() -> void: marquee_label.outline_modulate = Color(0.9, 0.15, 0.5) marquee_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED marquee_label.no_depth_test = false - strip_parent.add_child(marquee_label) - root.add_child(strip_parent) + root.add_child(marquee_label) + +## Venue dressing that is not part of the sidewalk prop table: a stair handrail, +## a parked sedan, and wall lanterns. Kept separate so the placement validator +## only sees grounded sidewalk furniture. +func _build_library_dressing() -> void: + if ResourceLoader.exists(BLENDER_PROP_DIR + "handrail.glb"): + var rail := _load_prop_scene("handrail") + if rail != null: + rail.name = "LibraryHandrail" + rail.position = Vector3(3.35, 0.2, 3.95) + rail.rotation_degrees.y = 90.0 + root.add_child(rail) + library_nodes.append(rail) + if ResourceLoader.exists(BLENDER_PROP_DIR + "sedan.glb"): + var sedan := _add_prop_model(root, "sedan", Vector3(6.0, 0.1, 17.0), 180.0) + sedan.name = "ParkedSedan" + if not library_nodes.has(sedan): + library_nodes.append(sedan) + for material in _emissive_materials(sedan): + if not _lamp_bulb_mats.has(material): + _lamp_bulb_mats.append(material) + if ResourceLoader.exists(BLENDER_PROP_DIR + "wall_lantern.glb"): + for side in [-4.9, 4.9]: + var lantern := _load_prop_scene("wall_lantern") + if lantern == null: + continue + lantern.name = "WallLantern" + lantern.position = Vector3(side, 3.3, -3.78) + root.add_child(lantern) + library_nodes.append(lantern) + for material in _emissive_materials(lantern): + if not _lamp_bulb_mats.has(material): + _lamp_bulb_mats.append(material) + +## Emissive materials under a prop, used to tie library bulbs/LEDs to night mode. +func _emissive_materials(holder: Node3D) -> Array[StandardMaterial3D]: + var out: Array[StandardMaterial3D] = [] + for node in holder.find_children("*", "MeshInstance3D", true, false): + var mi := node as MeshInstance3D + if mi.mesh == null: + continue + for index in mi.mesh.get_surface_count(): + var standard := mi.mesh.surface_get_material(index) as StandardMaterial3D + if standard != null and standard.emission_enabled \ + and standard.emission_energy_multiplier > 0.0: + out.append(standard) + return out func _build_street_furniture() -> void: - # Street lamps along the sidewalk (bulbs + omni lights, off by day). + # Blender library street lamps (bulbs + omni lights, off by day). The lamp + # arm points to local -X, so the left side is rotated 180 degrees to face + # the sidewalk. for side in [-7.5, 7.5]: - var lamp := Node3D.new() - lamp.name = "Lamp_%d" % int(side) - _add_box(lamp, Vector3(0.22, 5.0, 0.22), Vector3(side, 2.5, 8.5), Color(0.16, 0.16, 0.18), true, null, Vector2.ONE, "metal") - _add_box(lamp, Vector3(1.6, 0.15, 0.15), Vector3(side + (0.8 if side < 0 else -0.8), 4.9, 8.5), Color(0.16, 0.16, 0.18), true, null, Vector2.ONE, "metal") - var bulb := _add_box(lamp, Vector3(0.16, 0.16, 0.5), Vector3(side + (0.8 if side < 0 else -0.8), 4.85, 8.5), Color(1, 1, 0.85)) - var bulb_mat := _emissive_material(Color(1.0, 1.0, 0.8)) - bulb.material_override = bulb_mat - bulb.material_override.emission_energy_multiplier = 1.5 - _lamp_bulb_mats.append(bulb_mat) + var holder := _add_prop_model(root, "street_lamp", + Vector3(side, 0.18, 8.5), 180.0 if side < 0.0 else 0.0) + holder.name = "Lamp_%d" % int(side) var omni := OmniLight3D.new() - omni.position = Vector3(side + (0.8 if side < 0 else -0.8), 4.7, 8.5) + omni.position = Vector3(-0.85, 4.65, 0) omni.omni_range = 12.0 omni.light_energy = 0.0 omni.light_color = Color(1, 0.93, 0.75) - lamp.add_child(omni) + holder.add_child(omni) _lamp_omni.append(omni) - root.add_child(lamp) - _street_lamps.append(bulb) - # Planters beside the steps were flat boxes that clipped the treads; the - # generated planter_box props replace them in _build_street_detail(). - # Low-poly trees along the street front. - var trunk_color := Color(0.35, 0.25, 0.18) - var leaf_color := Color(0.16, 0.42, 0.2) - # Flank the walkway; keep the sight line to the door clear. + _collect_emissive(holder) + # Blender library street trees replace the cone trees. Flank the walkway and + # keep the sight line to the door clear. for i in 6: var side := -1.0 if i % 2 == 0 else 1.0 var x := side * (6.5 + float(int(i / 2)) * 3.0) var z := 4.0 + float((i * 7) % 5) - var tree := Node3D.new() - tree.name = "Tree_%d" % (i + 1) - var trunk := MeshInstance3D.new() - var trunk_mesh := CylinderMesh.new() - trunk_mesh.top_radius = 0.09 - trunk_mesh.bottom_radius = 0.14 - trunk_mesh.height = 1.4 - trunk.mesh = trunk_mesh - trunk.material_override = _plain_material(trunk_color, "wood") - trunk.position = Vector3(x, 0.7, z) - tree.add_child(trunk) - var crown := MeshInstance3D.new() - var crown_mesh := CylinderMesh.new() - crown_mesh.top_radius = 0.05 - crown_mesh.bottom_radius = 0.85 - crown_mesh.height = 2.2 - crown.mesh = crown_mesh - crown.material_override = _plain_material(leaf_color, "foliage") - crown.position = Vector3(x, 2.1, z) - tree.add_child(crown) - root.add_child(tree) + _add_prop_model(root, "street_tree", Vector3(x, 0.18, z), + float((i * 37) % 360)) + +## Total triangles across the placed Blender library pieces, for the draw budget +## test. Counts indices when present, otherwise the vertex stream. +func library_triangle_count() -> int: + var total := 0 + for holder in library_nodes: + if not is_instance_valid(holder): + continue + for node in holder.find_children("*", "MeshInstance3D", true, false): + var mi := node as MeshInstance3D + if mi.mesh == null: + continue + for index in mi.mesh.get_surface_count(): + var arrays := mi.mesh.surface_get_arrays(index) + var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX] + if indices.size() > 0: + total += indices.size() / 3 + else: + var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + total += vertices.size() / 3 + return total + +## Register a library prop's emissive surfaces so night mode can switch them on. +func _collect_emissive(holder: Node3D) -> void: + for node in holder.find_children("*", "MeshInstance3D", true, false): + var mi := node as MeshInstance3D + if not _street_lamps.has(mi): + _street_lamps.append(mi) + for material in _emissive_materials(holder): + if not _lamp_bulb_mats.has(material): + _lamp_bulb_mats.append(material) ## 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 @@ -359,6 +441,17 @@ const PROP_PLACEMENTS := [ "accessory": "crate"}, {"zone": "curb", "prop": "fire_hydrant", "x": 8.4, "z": 11.2, "accessory": "cone"}, + # Blender library dressing: palms frame the block, planters and a queue + # barrier sit outside the guest lane. + {"zone": "mid_block", "prop": "palm_tree", "x": -13.0, "z": 10.2, "rot": 10.0}, + {"zone": "mid_block", "prop": "palm_tree", "x": 13.0, "z": 10.2, "rot": -10.0}, + {"zone": "mid_block", "prop": "round_planter", "x": -12.6, "z": 6.2}, + {"zone": "mid_block", "prop": "round_planter", "x": 12.6, "z": 6.2}, + {"zone": "curb", "prop": "a_frame_sign", "x": -6.9, "z": 6.5, "rot": 15.0}, + {"zone": "curb", "prop": "traffic_cone", "x": 9.6, "z": 9.8}, + {"zone": "curb", "prop": "crowd_barrier", "x": 3.0, "z": 10.8}, + {"zone": "curb", "prop": "stanchion", "x": -2.6, "z": 10.8}, + {"zone": "entrance", "prop": "security_podium", "x": 3.2, "z": 5.6, "rot": -15.0}, ] ## The door, steps, and the lane guests walk in through stay clear of props. @@ -449,6 +542,9 @@ func _add_prop_model(parent: Node3D, prop: String, pos: Vector3, if model != null: holder.add_child(model) loaded_models.append(prop) + if ResourceLoader.exists(BLENDER_PROP_DIR + prop + ".glb"): + library_props.append(prop) + library_nodes.append(holder) else: var fallback := _fallback_prop(prop) holder.add_child(fallback) @@ -542,7 +638,9 @@ func _add_prop_accessory(holder: Node3D, kind: String) -> void: Color(0.92, 0.92, 0.9), "painted_metal") func _load_prop_scene(prop: String) -> Node3D: - var path := MODEL_DIR + prop + ".glb" + var path := BLENDER_PROP_DIR + prop + ".glb" + if not ResourceLoader.exists(path): + path = MODEL_DIR + prop + ".glb" if not ResourceLoader.exists(path): return null var res: Resource = ResourceLoader.load(path) diff --git a/src/test/capture_prop_gallery.gd b/src/test/capture_prop_gallery.gd index fef5e4b..73a8eba 100644 --- a/src/test/capture_prop_gallery.gd +++ b/src/test/capture_prop_gallery.gd @@ -7,6 +7,7 @@ extends SceneTree const OUT_DIR := "user://screenshots" const MODEL_DIR := "res://assets/models/generated/" +const LIBRARY_DIR := "res://assets/models/blender/props/" const PROPS := ["bollard", "bench", "trash_bin", "planter_box", "newspaper_box", "fire_hydrant"] const SPACING := 1.9 @@ -74,7 +75,10 @@ func _build_stage() -> void: 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 + var path := LIBRARY_DIR + prop_name + ".glb" + if not ResourceLoader.exists(path): + path = MODEL_DIR + prop_name + ".glb" + var packed := ResourceLoader.load(path) as PackedScene if packed == null: continue var model := packed.instantiate() as Node3D diff --git a/src/test/smoke_props.gd b/src/test/smoke_props.gd index 0a5858b..28e7f2f 100644 --- a/src/test/smoke_props.gd +++ b/src/test/smoke_props.gd @@ -19,6 +19,7 @@ var failures: Array[String] = [] var bench: Node3D = null var bench_center := Vector3.ZERO var bench_body: StaticBody3D = null +var route_center := Vector3.ZERO var guard_start := Vector3.ZERO var npc: Node3D = null var npc_start := Vector3.ZERO @@ -46,12 +47,12 @@ func _process(_delta: float) -> bool: _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) + # Stand on the door side of the bench and walk into it (+z). The + # street side is crowded with trees/planters now. + var start := bench_center - Vector3(0, 0, 2.0) 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 + gm.guard.rotation.y = PI guard_start = start Input.action_press("move_forward") step = 1 @@ -59,9 +60,9 @@ func _process(_delta: float) -> bool: 1: if step_frame >= GUARD_PUSH_FRAMES: Input.action_release("move_forward") - _check(guard_start.z - gm.guard.global_position.z > 0.5, + _check(gm.guard.global_position.z - guard_start.z > 0.5, "guard walked toward the bench") - _check(gm.guard.global_position.z > bench_center.z + 0.25, + _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") @@ -71,16 +72,14 @@ func _process(_delta: float) -> bool: 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.global_position.z > route_center.z + 0.35, + "guest reached the far side of the stanchion") + _check(npc.global_position.distance_to(route_center) > 0.4, + "guest is not inside the stanchion collider") _check(npc_start.distance_to(npc.global_position) > 1.0, "guest moved while routing around the prop") _finish() @@ -112,22 +111,33 @@ func _stage_npc() -> void: step = 0 step_frame = 0 return + var detail := root.find_child("StreetDetail", true, false) + route_center = bench_center + if detail != null: + for child in detail.get_children(): + if str(child.name).begins_with("Prop_stanchion"): + route_center = (child as Node3D).global_position + break npc = gm.npcs[0] - npc_start = bench_center + Vector3(0, 0.5, 2.5) + npc_start = route_center + Vector3(0, 0.5, -2.0) 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) + if npc.npc != null: + # "enter" would send the guest to the door once the path ends; keep this + # test focused on routing around the prop. + npc.npc.intent = "roam" + # A stanchion sits in open sidewalk, so the route is not squeezed by trees + # or bins; the target forces a straight line through it. + npc.target_position = Vector3(route_center.x, 0.5, route_center.z + 5.5) 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 \ + return npc.global_position.z > route_center.z + 0.35 \ or npc.global_position.distance_to(npc.target_position) < 0.9 func _inside_bench(point: Vector3) -> bool: diff --git a/src/test/tests/test_generated_props.gd b/src/test/tests/test_generated_props.gd index ca99933..3333700 100644 --- a/src/test/tests/test_generated_props.gd +++ b/src/test/tests/test_generated_props.gd @@ -201,6 +201,38 @@ func test_fallback_props_get_the_same_collider() -> bool: root.free() return a and b and c and d +func test_world_loads_the_blender_library_props() -> bool: + var root := Node3D.new() + var builder := WorldBuilder.new(root) + builder.build() + var a := TestUtils.check(builder.fallback_props.is_empty(), + "no primitive prop fallback (fallbacks: %s)" % str(builder.fallback_props)) + var b := TestUtils.check(builder.library_props.has("street_lamp") + and builder.library_props.has("street_tree") + and builder.library_props.has("bollard") + and builder.library_props.has("palm_tree") + and builder.library_props.has("sedan"), + "replacement and new props come from the library (%d)" % builder.library_props.size()) + var c := TestUtils.check(root.get_node_or_null("Building/LibraryDoubleDoor") != null + and root.get_node_or_null("LibraryMarquee") != null + and root.get_node_or_null("ParkedSedan") != null, + "library door, marquee and sedan are placed") + var d := TestUtils.check(not builder._lamp_bulb_mats.is_empty() + and not builder._marquee_emissive.is_empty(), + "library lamps/marquee expose emissive materials") + builder.set_night_mode(true) + var lit := (builder._lamp_bulb_mats[0].emission_energy_multiplier > 0.0 + and builder._marquee_emissive[0].emission_energy_multiplier > 0.0) + builder.set_night_mode(false) + var dark := builder._lamp_bulb_mats[0].emission_energy_multiplier <= 0.0 + var e := TestUtils.check(lit and dark, "night mode drives library emissives") + var triangles := builder.library_triangle_count() + var f := TestUtils.check(triangles > 0 + and triangles <= WorldBuilder.LIBRARY_TRIANGLE_BUDGET, + "library prop triangles stay inside the budget (%d)" % triangles) + root.free() + return a and b and c and d and e and f + func test_world_facade_windows_light_at_night() -> bool: var root := Node3D.new() var builder := WorldBuilder.new(root) diff --git a/src/test/tests/test_npc_character.gd b/src/test/tests/test_npc_character.gd index 4a99aba..5b85d2f 100644 --- a/src/test/tests/test_npc_character.gd +++ b/src/test/tests/test_npc_character.gd @@ -128,7 +128,7 @@ func test_walk_to_idle_animation_transition() -> bool: func test_skinned_model_loads_and_keeps_the_head_contract() -> bool: var avatar := _avatar(Rules.NpcType.TOURIST, 33) var a := TestUtils.check(avatar.loaded_model and not avatar.fallback_avatar, - "committed npc_base.glb is used") + "a skinned library character is used") var b := TestUtils.check(avatar.get_node_or_null("Rig/Head") != null, "Rig/Head survives the skinned path") var c := TestUtils.check(avatar._skeleton != null and avatar._bone_indices.has("Head") @@ -137,6 +137,82 @@ func test_skinned_model_loads_and_keeps_the_head_contract() -> bool: avatar.free() return a and b and c +func test_character_selection_is_deterministic_and_complete() -> bool: + var expected := { + Rules.NpcType.TOURIST: "npc_casual", + Rules.NpcType.CONFUSED_VISITOR: "npc_casual", + Rules.NpcType.INTOXICATED: "npc_casual", + Rules.NpcType.AUTHORIZED_STAFF: "npc_jacket", + Rules.NpcType.PROTESTER: "npc_jacket", + Rules.NpcType.INFLUENCER: "npc_coat", + Rules.NpcType.PERFORMER: "npc_dress", + Rules.NpcType.DELIVERY: "npc_vest", + Rules.NpcType.RESPONDER: "npc_firefighter", + Rules.NpcType.MENTAL_CRISIS: "npc_hoodie", + } + var problems: Array[String] = [] + for npc_type in expected: + var model := NpcAvatar.character_model_for(npc_type, 5) + if model != expected[npc_type]: + problems.append("%d -> %s (expected %s)" % [npc_type, model, expected[npc_type]]) + if not ResourceLoader.exists(NpcAvatar.BLENDER_NPC_DIR + model + ".glb"): + problems.append("%s missing" % model) + if NpcAvatar.character_model_for(npc_type, 5) != model: + problems.append("%d is not seed-stable" % npc_type) + # Seed-alternating variants still resolve to a library model. + for seed_value in [1, 2, 3, 4, 5, 6]: + var variant := NpcAvatar.character_model_for(Rules.NpcType.TRESPASSER, seed_value) + if not ResourceLoader.exists(NpcAvatar.BLENDER_NPC_DIR + variant + ".glb"): + problems.append("trespasser seed %d -> %s missing" % [seed_value, variant]) + return TestUtils.check(problems.is_empty(), + "every role selects an existing library character (%s)" % str(problems)) + +func test_library_gear_attaches_to_the_contract_points() -> bool: + var tourist := _avatar(Rules.NpcType.TOURIST, 12) + var backpack := tourist.find_child("Gear_backpack", true, false) as Node3D + var camera := tourist.find_child("Gear_camera", true, false) as Node3D + var a := TestUtils.check(tourist.character_model == "npc_casual" + and backpack != null and backpack.get_parent() is Node3D + and backpack.get_parent().name == "Rig", + "backpack attaches under Rig") + var b := TestUtils.check(camera != null and camera.get_parent() is Node3D + and camera.get_parent().name == "Hand", + "hand gear attaches under the hand proxy") + tourist.free() + var responder := _avatar(Rules.NpcType.RESPONDER, 12) + var helmet := responder.find_child("Gear_firefighter_helmet", true, false) as Node3D + var tank := responder.find_child("Gear_scba_tank", true, false) as Node3D + var flashlight := responder.find_child("Gear_flashlight", true, false) as Node3D + var c := TestUtils.check(responder.character_model == "npc_firefighter" + and helmet != null and helmet.get_parent().name == "Head", + "firefighter helmet attaches to Rig/Head") + var d := TestUtils.check(tank != null and flashlight != null, + "responder gets SCBA tank and flashlight") + responder.free() + return a and b and c and d + +func test_fixed_detail_materials_are_not_tinted() -> bool: + var avatar := _avatar(Rules.NpcType.RESPONDER, 3) + var mi := avatar.find_child("npc_base", true, false) as MeshInstance3D + var unknown_overrides := 0 + var contract_overrides := 0 + for index in mi.mesh.get_surface_count(): + var source := mi.mesh.surface_get_material(index) + if source == null: + continue + var slot := str(source.resource_name) + var override := mi.get_surface_override_material(index) + if slot in ["skin", "cloth", "pants", "accessory", "hair"]: + if override != null: + contract_overrides += 1 + elif override != null: + unknown_overrides += 1 + var a := TestUtils.check(contract_overrides == 5, "five contract slots are tinted") + var b := TestUtils.check(unknown_overrides == 0, + "fixed detail materials keep their authored colour (%d overrides)" % unknown_overrides) + avatar.free() + return a and b + func test_skinned_outfit_tints_the_material_slots() -> bool: var avatar := _avatar(Rules.NpcType.TOURIST, 44) var mi := avatar.find_child("npc_base", true, false) as MeshInstance3D