Integrate the Blender character, gear and prop library

NpcAvatar
- select one of 8 library character variants by role/seed (with npc_base and the
  procedural rig as fallbacks), keep Rig/Head and the existing bone animation
- tint only skin/cloth/pants/accessory/hair; fixed detail materials keep their
  colour, npc_vest keeps its hi-vis vest, firefighter/guard use dark uniform tints
- attach caps, hoods, helmets, backpacks, SCBA tanks, lanyards and hand items at
  the documented head/back/chest/hand points (primitive fallbacks retained)

WorldBuilder
- prefer assets/models/blender/props/ for the six replacements and the new
  street dressing (street lamp/tree, palms, planters, A-frame, cone, barrier,
  stanchion, podium, handrail, wall lantern, double door, marquee, sedan)
- fit the same colliders/navigation obstacles and keep the placement validator;
  collect library emissives (bulbs, marquee face, wall lanterns, door LED, sedan
  lights) for set_night_mode()
- add a library triangle counter + 60k budget check (current ~27,986); toggle
  prop gallery to the library models; fix the marquee label placement

Tests/smoke
- character selection/gear attach/fixed-material tint tests; library prop load,
  emissive and triangle-budget tests; smoke_props routes a guest around a
  stanchion in open sidewalk (the old bench route is now crowded by trees)
- headless 222/222, Python 68/68, all smokes pass, compare 11 good / 0 bad

Docs: TODO Phase 3.5 moved to DONE, README/architecture/blender-assets/plan
describe the integrated runtime, MEMORY records the integration rules.
This commit is contained in:
zwitschi committed 2026-11-01 14:57:02 +01:00
1 parent d513e0b82c
commit d8cfddbf89
23 files changed
+515 -149

No files matched your search

+30
View File
@@ -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. `-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 - [x] Added `test_yaw_faces_the_movement_direction`; recaptured the targeting screenshot to
confirm NPCs now show their back only when walking away. 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.
+12 -1
View File
@@ -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 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)`. `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. `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 `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 `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 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 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 `albedo_color = WHITE` on unknown slots. Cycles CPU render ≈ 3 s/asset; Godot import of the
library is clean. 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 - **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 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. line-up, back row on a platform) and the `prop_gallery` catalogue entry compares it.
+10 -5
View File
@@ -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 `.glb` and regenerates the procedural normal + packed ORM maps. `palette` writes
`assets/textures/npc/palette.json` using `model_pipeline.dominant_colors`. `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 `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, 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** clipboard, umbrella, camera, sign, flashlight, wand, radio, …); and **20 props**
(octagonal bollard and bin, slatted bench, hydrant, news box, planters, street lamp, (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, street and palm trees, A-frame sign, wall lantern, cone, crowd barrier, stanchion,
handrail, podium, double door, marquee, sedan). Contract and integration notes: handrail, podium, double door, marquee, sedan).
[`docs/blender-assets.md`](docs/blender-assets.md).
`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 | | 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 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
- 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-<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/gig-line-web-<version>.zip` (Godot's non-threaded web template, no COOP/COEP headers needed) built by `tools/build_releases.sh` and published with the desktop artifacts; serve the unzipped folder from any static host
- Aim-free selection pass (v0.3.0 work, [ADR-0011](docs/adr/0011-aim-free-selection-and-precision-walk.md)): the focus cone ranks candidates by angle then distance, `Tab` and the mouse wheel cycle nearby people nearest-first and pin the selection, right click clears focus with a short re-acquire grace, the HUD reports how many people are in the ring, and holding `Shift` walks at half speed for close work - Aim-free selection pass (v0.3.0 work, [ADR-0011](docs/adr/0011-aim-free-selection-and-precision-walk.md)): the focus cone ranks candidates by angle then distance, `Tab` and the mouse wheel cycle nearby people nearest-first and pin the selection, right click clears focus with a short re-acquire grace, the HUD reports how many people are in the ring, and holding `Shift` walks at half speed for close work
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 ### Smoke Test Checklist
+9 -62
View File
@@ -1,18 +1,18 @@
# TODO # TODO
Godot 4.3 prototype. Latest release **v0.5.0** (NPC characters and player movement). Headless 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 timeline, audio, scenario-content, props, NPC-budget, movement, and windowed focus/radial smokes
pass. Anonymous downloads are enabled on the host. pass. Anonymous downloads are enabled on the host.
The scheduled presentation pass is complete (see `DONE.md`). The next scheduled pass is the 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). 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 > **Blender asset library integrated (2026-11-01):** 8 NPC character variants, 18 gear pieces,
> character variants, 18 gear pieces, and 20 street/venue props under `assets/models/blender/` > and 20 street/venue props are now loaded by `NpcAvatar` and `WorldBuilder` from
> (audit clean, Godot import clean, see [`docs/blender-assets.md`](docs/blender-assets.md)). > `assets/models/blender/` (manifest as source of truth, see
> Treat `assets/models/blender/manifest.json` as the integration source of truth; nothing is > [`docs/blender-assets.md`](docs/blender-assets.md)). The procedural rig and primitive props
> wired into the game yet. > remain offline fallbacks; AO/ORM baking is the remaining Phase 4 follow-up.
## Graphics quality — Blender-authored NPC characters (v0.6.0) ## 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-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. - [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 ### 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. - [ ] 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 ### 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). - [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).
- [ ] Windowed smokes pass: `smoke_npc_budget.gd`, `smoke_movement.gd`, `smoke_focus_radial.gd`, `smoke_props.gd`. - [x] 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] `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`. - [ ] 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. - [ ] 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`. - [ ] Publish the release per `docs/RELEASING.md`.
+1 -1
View File
@@ -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. - `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/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/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. - `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. 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.
+8 -7
View File
@@ -1,7 +1,8 @@
# Blender asset library # Blender asset library
Status: built and audited (2026-11-01). Nothing in the game loads these assets yet. Status: built, audited, and integrated (2026-11-01). `NpcAvatar` selects the
They are staged for the Phase 3.5 integration in 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). [`plan-graphics-v0.6.md`](plan-graphics-v0.6.md).
Generator: [`tools/blender_assets.py`](../tools/blender_assets.py). Manifest (the 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 `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. colour through that. When integrating, tint only the five contract slots.
**Runtime tint notes.** `_build_skinned_body()` currently paints `cloth` **Runtime tint notes (implemented).** Only the five contract slots are tinted;
hi-vis yellow for the `vest` layer. On `npc_vest` the vest is already the fixed fixed detail materials keep their authored colour. On `npc_vest` the vest is the
`hivis` material and `cloth` is the undershirt, so skip that override for this fixed `hivis` material and `cloth` is the undershirt, so the yellow override is
model. For `npc_firefighter` and `npc_guard`, tint `cloth`/`pants` dark (turnout / skipped. `npc_firefighter`/`npc_guard` use dark turnout/uniform tints instead of
uniform navy) and not with the role's `TYPE_COLORS`. the role's `TYPE_COLORS`.
## Gear attach points ## Gear attach points
+4 -2
View File
@@ -167,8 +167,10 @@ Outputs land under `assets/textures/ai/game_ready/` (or a new
### Phase 3.5 — Blender asset library (characters, gear, props) ### Phase 3.5 — Blender asset library (characters, gear, props)
The library generator is `tools/blender_assets.py` (flatpak Blender, headless). The library generator is `tools/blender_assets.py` (flatpak Blender, headless).
It stages outputs for integration; nothing is wired into the game until the **Status: integrated** — `NpcAvatar` selects characters/gear and `WorldBuilder`
audit and manifest are clean. Integration notes: [`blender-assets.md`](blender-assets.md). 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** **Outputs and contract**
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: 845 KiB

After

Width:  |  Height:  |  Size: 864 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: 534 KiB

After

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 56 KiB

+155 -5
View File
@@ -63,6 +63,7 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
npc.setup(id, profile, intent, seed_value) npc.setup(id, profile, intent, seed_value)
npc.access_override = p_override npc.access_override = p_override
_visual_seed = seed_value _visual_seed = seed_value
_npc_type = profile.npc_type
spawn_position = p_spawn spawn_position = p_spawn
target_position = p_target target_position = p_target
door_position = p_door 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 _visual_seed := 0
var _rig: Node3D = null var _rig: Node3D = null
var _npc_type := 0
var _head: Node3D = null var _head: Node3D = null
var _bob_phase := 0.0 var _bob_phase := 0.0
var _guard_ref: Node3D = null var _guard_ref: Node3D = null
@@ -130,6 +132,7 @@ var _queue_offset := Vector3.ZERO
var _height_scale := 1.0 var _height_scale := 1.0
var loaded_model := false var loaded_model := false
var fallback_avatar := true var fallback_avatar := true
var character_model := ""
## Tests can force the offline rig; the game always tries the committed model. ## Tests can force the offline rig; the game always tries the committed model.
var use_skinned_model := true var use_skinned_model := true
var _skeleton: Skeleton3D = null var _skeleton: Skeleton3D = null
@@ -162,6 +165,46 @@ const ACCESSORIES := {
Rules.NpcType.CONFUSED_VISITOR: "none", 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 HAIR_STYLES := ["short", "long", "bun", "beard", "bald", "short"]
const HAND_ITEMS := ["phone", "cup", "clipboard", "umbrella", "camera", "none"] 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 ## Instantiate the Blender-authored skinned base mesh. Returns false when the
## GLB is missing/not imported, so setup falls back to the procedural rig. ## GLB is missing/not imported, so setup falls back to the procedural rig.
func _build_skinned_body(npc_type: int) -> bool: 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): if not ResourceLoader.exists(path):
return false return false
var packed := ResourceLoader.load(path) as PackedScene var packed := ResourceLoader.load(path) as PackedScene
@@ -316,6 +363,8 @@ func _build_skinned_body(npc_type: int) -> bool:
var outfit := outfit_for(npc_type, _visual_seed) var outfit := outfit_for(npc_type, _visual_seed)
var cloth: Color = outfit["shirt"] var cloth: Color = outfit["shirt"]
var pants: Color = outfit["pants"]
if character_model == "npc_base":
match str(outfit["layer"]): match str(outfit["layer"]):
"vest": "vest":
cloth = Color(0.92, 0.72, 0.12) cloth = Color(0.92, 0.72, 0.12)
@@ -323,11 +372,30 @@ func _build_skinned_body(npc_type: int) -> bool:
cloth = cloth.darkened(0.25) cloth = cloth.darkened(0.25)
"jacket": "jacket":
cloth = cloth.darkened(0.12) 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"]) _height_scale = float(outfit["height_scale"])
_rig.scale = Vector3.ONE * _height_scale _rig.scale = Vector3.ONE * _height_scale
_shirt_color = cloth _shirt_color = cloth
_skin_color = outfit["skin"] _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 # Animation proxies: the existing _animate()/idle code drives these Node3D
# pivots; _sync_skeleton() copies their rotations onto the armature bones. # pivots; _sync_skeleton() copies their rotations onto the armature bones.
@@ -365,6 +433,7 @@ func _build_skinned_body(npc_type: int) -> bool:
_bind_bones() _bind_bones()
_add_accessory(str(outfit["accessory"]), cloth) _add_accessory(str(outfit["accessory"]), cloth)
_add_character_gear()
_add_hand_item(str(outfit["hand_item"]), outfit["skin"]) _add_hand_item(str(outfit["hand_item"]), outfit["skin"])
_build_focus_circle() _build_focus_circle()
_add_props(npc_type) _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 ## Tint the imported material slots per avatar. Each avatar gets its own copied
## materials so the shared imported scene is never mutated. ## 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 mesh: Mesh = _skinned_mesh.mesh
var tints := { var tints := {
"skin": outfit["skin"], "skin": outfit["skin"],
"cloth": cloth, "cloth": cloth,
"pants": outfit["pants"], "pants": pants,
"hair": outfit["hair"], "hair": outfit["hair"],
"accessory": Color(0.10, 0.11, 0.14), "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: if source == null:
continue continue
var slot := str(source.resource_name) 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, _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: func _bind_bones() -> void:
_bone_indices.clear() _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: func _add_hand_item(kind: String, skin: Color) -> void:
if _hands.size() < 2: if _hands.size() < 2:
return 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] var hand: Node3D = _hands[1]
match kind: match kind:
"phone": "phone":
@@ -599,8 +684,73 @@ func _add_hand_item(kind: String, skin: Color) -> void:
Color(0.10, 0.10, 0.12), "metal")) 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")) _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. ## 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:
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: match kind:
"cap": "cap":
_detail(_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"))
+145 -47
View File
@@ -14,8 +14,14 @@ const T_SKYLINE := preload("res://assets/textures/skyline.png")
## Generated prop models, one `.glb` per street-furniture piece. ## Generated prop models, one `.glb` per street-furniture piece.
const MODEL_DIR := "res://assets/models/generated/" 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", const MODEL_PROPS := ["bollard", "bench", "trash_bin", "planter_box",
"newspaper_box", "fire_hydrant"] "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 var root: Node3D = null
## Door lock indicator mesh near the handle; toggled by GameManager. ## 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. ## not listed), so tests and the debug overlay can report real model usage.
var loaded_models: Array[String] = [] var loaded_models: Array[String] = []
var fallback_props: 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: func _init(root_node: Node3D) -> void:
root = root_node root = root_node
@@ -48,6 +59,7 @@ func build() -> void:
_build_crosswalk() _build_crosswalk()
_build_street_furniture() _build_street_furniture()
_build_street_detail() _build_street_detail()
_build_library_dressing()
_build_guard_spawn() _build_guard_spawn()
_build_navmesh() _build_navmesh()
set_night_mode(false) set_night_mode(false)
@@ -106,6 +118,16 @@ func _build_building() -> void:
# Door lock indicator (green = open, red = locked), placed beside the handle # Door lock indicator (green = open, red = locked), placed beside the handle
door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -3.82), Color(0.1, 0.9, 0.3)) door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -3.82), Color(0.1, 0.9, 0.3))
door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3)) door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3))
# 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) _build_facade_windows(building)
var col := BoxShape3D.new() var col := BoxShape3D.new()
col.size = Vector3(40, 18, 4) col.size = Vector3(40, 18, 4)
@@ -257,10 +279,20 @@ func _build_skyline_backdrop() -> void:
var _skyline_mat: StandardMaterial3D = null var _skyline_mat: StandardMaterial3D = null
func _build_marquee() -> void: func _build_marquee() -> void:
## Striped awning above the door, then the neon sign strip + label. ## 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") var awning := _add_box(root, Vector3(10.5, 0.08, 1.8), Vector3(0, 6.55, -3.7), Color(1, 1, 1), true, T_AWNING, Vector2(4, 1), "awning")
awning.name = "Awning" awning.name = "Awning"
## Neon-style marquee strip above the door: emissive bars + a label.
var strip_parent := Node3D.new() var strip_parent := Node3D.new()
strip_parent.name = "Marquee" strip_parent.name = "Marquee"
for offset in [-2.2, 2.2]: for offset in [-2.2, 2.2]:
@@ -268,10 +300,11 @@ func _build_marquee() -> void:
var mat := _emissive_material(Color(1.0, 0.35, 0.75)) var mat := _emissive_material(Color(1.0, 0.35, 0.75))
bar.material_override = mat bar.material_override = mat
_marquee_emissive.append(mat) _marquee_emissive.append(mat)
root.add_child(strip_parent)
marquee_label = Label3D.new() marquee_label = Label3D.new()
marquee_label.name = "MarqueeLabel" marquee_label.name = "MarqueeLabel"
marquee_label.text = "GIG LINE" 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.modulate = Color(1, 0.6, 0.9)
marquee_label.font_size = 160 marquee_label.font_size = 160
marquee_label.pixel_size = 0.004 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.outline_modulate = Color(0.9, 0.15, 0.5)
marquee_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED marquee_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
marquee_label.no_depth_test = false marquee_label.no_depth_test = false
strip_parent.add_child(marquee_label) root.add_child(marquee_label)
root.add_child(strip_parent)
## 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: 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]: for side in [-7.5, 7.5]:
var lamp := Node3D.new() var holder := _add_prop_model(root, "street_lamp",
lamp.name = "Lamp_%d" % int(side) Vector3(side, 0.18, 8.5), 180.0 if side < 0.0 else 0.0)
_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") holder.name = "Lamp_%d" % int(side)
_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 omni := OmniLight3D.new() var omni := OmniLight3D.new()
omni.position = Vector3(side + (0.8 if side < 0 else -0.8), 4.7, 8.5) omni.position = Vector3(-0.85, 4.65, 0)
omni.omni_range = 12.0 omni.omni_range = 12.0
omni.light_energy = 0.0 omni.light_energy = 0.0
omni.light_color = Color(1, 0.93, 0.75) omni.light_color = Color(1, 0.93, 0.75)
lamp.add_child(omni) holder.add_child(omni)
_lamp_omni.append(omni) _lamp_omni.append(omni)
root.add_child(lamp) _collect_emissive(holder)
_street_lamps.append(bulb) # Blender library street trees replace the cone trees. Flank the walkway and
# Planters beside the steps were flat boxes that clipped the treads; the # keep the sight line to the door clear.
# 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.
for i in 6: for i in 6:
var side := -1.0 if i % 2 == 0 else 1.0 var side := -1.0 if i % 2 == 0 else 1.0
var x := side * (6.5 + float(int(i / 2)) * 3.0) var x := side * (6.5 + float(int(i / 2)) * 3.0)
var z := 4.0 + float((i * 7) % 5) var z := 4.0 + float((i * 7) % 5)
var tree := Node3D.new() _add_prop_model(root, "street_tree", Vector3(x, 0.18, z),
tree.name = "Tree_%d" % (i + 1) float((i * 37) % 360))
var trunk := MeshInstance3D.new()
var trunk_mesh := CylinderMesh.new() ## Total triangles across the placed Blender library pieces, for the draw budget
trunk_mesh.top_radius = 0.09 ## test. Counts indices when present, otherwise the vertex stream.
trunk_mesh.bottom_radius = 0.14 func library_triangle_count() -> int:
trunk_mesh.height = 1.4 var total := 0
trunk.mesh = trunk_mesh for holder in library_nodes:
trunk.material_override = _plain_material(trunk_color, "wood") if not is_instance_valid(holder):
trunk.position = Vector3(x, 0.7, z) continue
tree.add_child(trunk) for node in holder.find_children("*", "MeshInstance3D", true, false):
var crown := MeshInstance3D.new() var mi := node as MeshInstance3D
var crown_mesh := CylinderMesh.new() if mi.mesh == null:
crown_mesh.top_radius = 0.05 continue
crown_mesh.bottom_radius = 0.85 for index in mi.mesh.get_surface_count():
crown_mesh.height = 2.2 var arrays := mi.mesh.surface_get_arrays(index)
crown.mesh = crown_mesh var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
crown.material_override = _plain_material(leaf_color, "foliage") if indices.size() > 0:
crown.position = Vector3(x, 2.1, z) total += indices.size() / 3
tree.add_child(crown) else:
root.add_child(tree) 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 ## 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 ## sidewalk top (y = 0.18) and grouped by zone, so a cluster reads as street
@@ -359,6 +441,17 @@ const PROP_PLACEMENTS := [
"accessory": "crate"}, "accessory": "crate"},
{"zone": "curb", "prop": "fire_hydrant", "x": 8.4, "z": 11.2, {"zone": "curb", "prop": "fire_hydrant", "x": 8.4, "z": 11.2,
"accessory": "cone"}, "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. ## 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: if model != null:
holder.add_child(model) holder.add_child(model)
loaded_models.append(prop) loaded_models.append(prop)
if ResourceLoader.exists(BLENDER_PROP_DIR + prop + ".glb"):
library_props.append(prop)
library_nodes.append(holder)
else: else:
var fallback := _fallback_prop(prop) var fallback := _fallback_prop(prop)
holder.add_child(fallback) 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") 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 := BLENDER_PROP_DIR + prop + ".glb"
if not ResourceLoader.exists(path):
path = MODEL_DIR + prop + ".glb"
if not ResourceLoader.exists(path): if not ResourceLoader.exists(path):
return null return null
var res: Resource = ResourceLoader.load(path) var res: Resource = ResourceLoader.load(path)
+5 -1
View File
@@ -7,6 +7,7 @@ extends SceneTree
const OUT_DIR := "user://screenshots" const OUT_DIR := "user://screenshots"
const MODEL_DIR := "res://assets/models/generated/" const MODEL_DIR := "res://assets/models/generated/"
const LIBRARY_DIR := "res://assets/models/blender/props/"
const PROPS := ["bollard", "bench", "trash_bin", "planter_box", const PROPS := ["bollard", "bench", "trash_bin", "planter_box",
"newspaper_box", "fire_hydrant"] "newspaper_box", "fire_hydrant"]
const SPACING := 1.9 const SPACING := 1.9
@@ -74,7 +75,10 @@ func _build_stage() -> void:
var columns := [-1.95, 0.0, 1.95] var columns := [-1.95, 0.0, 1.95]
for index in PROPS.size(): for index in PROPS.size():
var prop_name: String = PROPS[index] 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: if packed == null:
continue continue
var model := packed.instantiate() as Node3D var model := packed.instantiate() as Node3D
+27 -17
View File
@@ -19,6 +19,7 @@ var failures: Array[String] = []
var bench: Node3D = null var bench: Node3D = null
var bench_center := Vector3.ZERO var bench_center := Vector3.ZERO
var bench_body: StaticBody3D = null var bench_body: StaticBody3D = null
var route_center := Vector3.ZERO
var guard_start := Vector3.ZERO var guard_start := Vector3.ZERO
var npc: Node3D = null var npc: Node3D = null
var npc_start := Vector3.ZERO var npc_start := Vector3.ZERO
@@ -46,12 +47,12 @@ func _process(_delta: float) -> bool:
_find_bench() _find_bench()
if bench == null: if bench == null:
return false return false
# Stand a couple of metres in front of the bench and walk into it. # Stand on the door side of the bench and walk into it (+z). The
var start := bench_center + Vector3(0, 0, 2.2) # street side is crowded with trees/planters now.
var start := bench_center - Vector3(0, 0, 2.0)
start.y = 0.1 start.y = 0.1
gm.guard.global_position = start gm.guard.global_position = start
# Face the bench (-z): yaw 0 looks down -z, so W walks toward it. gm.guard.rotation.y = PI
gm.guard.rotation.y = 0.0
guard_start = start guard_start = start
Input.action_press("move_forward") Input.action_press("move_forward")
step = 1 step = 1
@@ -59,9 +60,9 @@ func _process(_delta: float) -> bool:
1: 1:
if step_frame >= GUARD_PUSH_FRAMES: if step_frame >= GUARD_PUSH_FRAMES:
Input.action_release("move_forward") 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") "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") "guard stopped at the bench instead of passing through it")
_check(not _inside_bench(gm.guard.global_position), _check(not _inside_bench(gm.guard.global_position),
"guard is not inside the bench collider") "guard is not inside the bench collider")
@@ -71,16 +72,14 @@ func _process(_delta: float) -> bool:
step = 2 step = 2
step_frame = 0 step_frame = 0
2: 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): if npc == null or not is_instance_valid(npc):
_stage_npc() _stage_npc()
return false return false
if _npc_finished(): if _npc_finished():
_check(npc.global_position.z < bench_center.z - 0.4, _check(npc.global_position.z > route_center.z + 0.35,
"guest reached the far side of the bench") "guest reached the far side of the stanchion")
_check(not _inside_bench(npc.global_position), _check(npc.global_position.distance_to(route_center) > 0.4,
"guest is not inside the bench collider") "guest is not inside the stanchion collider")
_check(npc_start.distance_to(npc.global_position) > 1.0, _check(npc_start.distance_to(npc.global_position) > 1.0,
"guest moved while routing around the prop") "guest moved while routing around the prop")
_finish() _finish()
@@ -112,22 +111,33 @@ func _stage_npc() -> void:
step = 0 step = 0
step_frame = 0 step_frame = 0
return 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 = 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.global_position = npc_start
npc.velocity = Vector3.ZERO npc.velocity = Vector3.ZERO
npc.arrived = false npc.arrived = false
npc.awaiting_interaction = false npc.awaiting_interaction = false
npc.lifetime_s = 999.0 npc.lifetime_s = 999.0
npc.reaction_mode = "" npc.reaction_mode = ""
# Target well past the bench forces a straight line through it, and stays if npc.npc != null:
# beyond the controller's 3 m "arrived" shortcut so the guest must route. # "enter" would send the guest to the door once the path ends; keep this
npc.target_position = Vector3(bench_center.x, 0.5, bench_center.z - 5.0) # 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: func _npc_finished() -> bool:
if npc == null or not is_instance_valid(npc): if npc == null or not is_instance_valid(npc):
return false 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 or npc.global_position.distance_to(npc.target_position) < 0.9
func _inside_bench(point: Vector3) -> bool: func _inside_bench(point: Vector3) -> bool:
+32
View File
@@ -201,6 +201,38 @@ func test_fallback_props_get_the_same_collider() -> bool:
root.free() root.free()
return a and b and c and d 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: 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)
+77 -1
View File
@@ -128,7 +128,7 @@ func test_walk_to_idle_animation_transition() -> bool:
func test_skinned_model_loads_and_keeps_the_head_contract() -> bool: func test_skinned_model_loads_and_keeps_the_head_contract() -> bool:
var avatar := _avatar(Rules.NpcType.TOURIST, 33) var avatar := _avatar(Rules.NpcType.TOURIST, 33)
var a := TestUtils.check(avatar.loaded_model and not avatar.fallback_avatar, 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, var b := TestUtils.check(avatar.get_node_or_null("Rig/Head") != null,
"Rig/Head survives the skinned path") "Rig/Head survives the skinned path")
var c := TestUtils.check(avatar._skeleton != null and avatar._bone_indices.has("Head") 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() avatar.free()
return a and b and c 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: func test_skinned_outfit_tints_the_material_slots() -> bool:
var avatar := _avatar(Rules.NpcType.TOURIST, 44) var avatar := _avatar(Rules.NpcType.TOURIST, 44)
var mi := avatar.find_child("npc_base", true, false) as MeshInstance3D var mi := avatar.find_child("npc_base", true, false) as MeshInstance3D