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.
|
`-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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|
||||||
|
|||||||
|
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.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,18 +363,39 @@ 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"]
|
||||||
match str(outfit["layer"]):
|
var pants: Color = outfit["pants"]
|
||||||
"vest":
|
if character_model == "npc_base":
|
||||||
cloth = Color(0.92, 0.72, 0.12)
|
match str(outfit["layer"]):
|
||||||
"coat":
|
"vest":
|
||||||
cloth = cloth.darkened(0.25)
|
cloth = Color(0.92, 0.72, 0.12)
|
||||||
"jacket":
|
"coat":
|
||||||
cloth = cloth.darkened(0.12)
|
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"])
|
_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"))
|
||||||
|
|||||||
@@ -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,21 +279,32 @@ 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
|
||||||
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")
|
## neon bars remain only as a fallback when the library asset is missing.
|
||||||
awning.name = "Awning"
|
var library := _load_prop_scene("marquee")
|
||||||
## Neon-style marquee strip above the door: emissive bars + a label.
|
if library != null:
|
||||||
var strip_parent := Node3D.new()
|
library.name = "LibraryMarquee"
|
||||||
strip_parent.name = "Marquee"
|
library.position = Vector3(0, 5.62, -3.9)
|
||||||
for offset in [-2.2, 2.2]:
|
root.add_child(library)
|
||||||
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))
|
library_nodes.append(library)
|
||||||
var mat := _emissive_material(Color(1.0, 0.35, 0.75))
|
for material in _emissive_materials(library):
|
||||||
bar.material_override = mat
|
if not _marquee_emissive.has(material):
|
||||||
_marquee_emissive.append(mat)
|
_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 = 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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||