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.
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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-<version>.zip` (Godot's non-threaded web template, no COOP/COEP headers needed) built by `tools/build_releases.sh` and published with the desktop artifacts; serve the unzipped folder from any static host
|
||||
- Aim-free selection pass (v0.3.0 work, [ADR-0011](docs/adr/0011-aim-free-selection-and-precision-walk.md)): the focus cone ranks candidates by angle then distance, `Tab` and the mouse wheel cycle nearby people nearest-first and pin the selection, right click clears focus with a short re-acquire grace, the HUD reports how many people are in the ring, and holding `Shift` walks at half speed for close work
|
||||
|
||||
Not yet implemented: 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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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**
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 845 KiB After Width: | Height: | Size: 864 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 534 KiB After Width: | Height: | Size: 530 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 56 KiB |
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||