Load and animate the Blender-authored NPC base mesh

Wire the skinned model from ADR-0013 into NpcAvatar:

- _build_skinned_body() instantiates the imported npc_base.glb, binds the 14
  armature bones, and keeps the procedural _build_body() as the offline
  fallback (use_skinned_model can force it in tests)
- outfit_for()/layer_for() tint the five material slots through the new
  SurfaceMaterials.npc_slot(); accessories and hand items attach under Rig/Head
- the existing limb pivots (Hip/Knee/Shoulder/Elbow/Hand + Head marker) keep the
  old animation math; _sync_skeleton() copies their rotations to bones. Bone
  pose rotation is absolute local orientation, so it is composed with the rest
  basis (otherwise the whole body snaps into a broken pose)
- skinned rigs scale uniformly (non-uniform parent scale shears the skin) and
  keep Rig/Head for the focus cone, nametag, and reaction tag
- commit the editable npc_base.blend plus a .gdignore: Godot's editor otherwise
  tries to import the blend and hangs during --import
- npc_pipeline audit tests (contract, textures, clean GLB, committed source)

Verification: headless 212/212, Python tooling 60/60, NPC budget 16 avatars =
144 meshes at ~6.9 ms, movement/focus/props smokes pass, compare 11 good / 0 bad.
TODO Phases 0-3 checked; environment lift and release remain.
This commit is contained in:
zwitschi committed 2026-11-01 13:26:37 +01:00
1 parent 3989d7b2e5
commit a231f7588a
20 files changed
+316 -37

No files matched your search

+8 -2
View File
@@ -47,7 +47,13 @@ build/
# AI / asset-development outputs (regenerated by the tools; not shipped in the game)
assets/models/concepts/
assets/models/npc/
assets/models/npc/*.blend1
assets/textures/npc/ai/
assets/textures/ai/*
!assets/textures/ai/game_ready/
!assets/textures/ai/game_ready/
# Godot extracts embedded glTF textures next to the source on import
assets/models/generated/npc_base_*_albedo.png
assets/models/generated/npc_base_*_normal.png
# Local AppImage extraction
squashfs-root/
+12
View File
@@ -20,6 +20,18 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
- Shift length is the scenario's `shift_duration_s` (900 s in all three venues). Tests and the screenshot tool override elapsed time; they do not change the default.
- RNG: NPC reactions and event scheduling are seedable for deterministic tests.
- **Blender invocation (v0.6.0):** `flatpak run org.blender.Blender --background --python
/run/media/system/DATA/Documents/06-games/security-game/tools/npc_pipeline.py -- build`.
The flatpak cannot read `/tmp`; use absolute repo paths. `assets/models/npc/.gdignore` is
required, otherwise Godot's editor tries to import the committed `.blend` and hangs during
`--import` (also add `.gdignore` to local build/squashfs folders when scanning is slow).
- **Skinned NPC integration (v0.6.0):** `NpcAvatar._build_skinned_body()` loads
`npc_base.glb` and keeps `_build_body()` as fallback (`use_skinned_model=false` in tests).
Bone pose rotation is the **absolute local orientation**, not an offset from rest: compose with
`get_bone_rest(i).basis` or the whole body snaps to a broken pose. Proxies named Hip/Knee/
Shoulder/Elbow/Hand + Head marker preserve the old animation math; `Rig/Head` is the marker.
Five material slots are tinted via `SurfaceMaterials.npc_slot()`; keep `_rig.scale` uniform on
the skinned path. Budget: 16 avatars = **144 meshes**, ~6.9 ms headless.
- **NPC facing (fixed after v0.5.0):** the avatar's visual front is **+Z** (procedural eyes
and the Blender model's face). Godot `look_at()` aims **-Z**, which made every NPC walk
backwards. Use `NpcAvatar.yaw_towards(dir)` and set `rotation.y`; do not call `look_at()` on
+4 -2
View File
@@ -226,8 +226,10 @@ tonemapping, glow, fog, colour grading) that the phase lighting applies.
NPCs are moving from the procedural capsule rig to a skinned, UV-mapped base
character authored in Blender ([ADR-0013](docs/adr/0013-blender-authored-npc-characters.md)).
`tools/npc_pipeline.py` builds `assets/models/generated/npc_base.glb` plus the
per-slot material maps, and `NpcAvatar` keeps the procedural rig as the offline
fallback until the model is wired in (see the [graphics plan](docs/plan-graphics-v0.6.md)).
per-slot material maps. `NpcAvatar` instantiates that skinned model, tints its
five material slots from the seed-driven outfit, and drives the bones from the
existing animation code; the procedural rig remains the offline fallback (see
the [graphics plan](docs/plan-graphics-v0.6.md)).
```bash
# Print the frozen contract (slots, bone names, triangle budget)
+30 -30
View File
@@ -1,7 +1,7 @@
# TODO
Godot 4.3 prototype. Latest release **v0.5.0** (NPC characters and player movement). Headless
suite **209/209** plus **56** Python tooling tests; the session, PLAN-verb, crowd, emergency,
suite **212/212** plus **60** 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.
@@ -14,49 +14,49 @@ Detailed plan: [`docs/plan-graphics-v0.6.md`](docs/plan-graphics-v0.6.md) · [AD
### Phase 0 — Toolchain spike and contract freeze
- [ ] Verify `flatpak run org.blender.Blender --version` reports Blender 5.2.x and runs headless in this shell.
- [ ] Run a minimal headless Blender export using an absolute `--python` path (sandbox cwd is `/app/blender`); confirm the output file lands where expected.
- [ ] Record the exact working invocation (flatpak + absolute paths) in `MEMORY.md`.
- [ ] Freeze the NPC asset contract in `docs/plan-graphics-v0.6.md` and ADR-0013: file paths, bone names, material slots, triangle budget.
- [ ] Fix the material-slot set (`skin` / `cloth` / `pants` / `accessory` / `hair`) and the ORM packing (R=occlusion, G=roughness, B=metallic).
- [ ] Choose and record the initial tier-0 triangle/vertex budget (≤ ~6000 tris / ~4000 verts) in the plan.
- [ ] Spike: confirm Godot 4.3 imports a test `.glb` as a `PackedScene` with a `Skeleton3D` and playable imported clips.
- [x] Verify `flatpak run org.blender.Blender --version` reports Blender 5.2.x and runs headless in this shell.
- [x] Run a minimal headless Blender export using an absolute `--python` path (sandbox cwd is `/app/blender`); confirm the output file lands where expected.
- [x] Record the exact working invocation (flatpak + absolute paths) in `MEMORY.md`.
- [x] Freeze the NPC asset contract in `docs/plan-graphics-v0.6.md` and ADR-0013: file paths, bone names, material slots, triangle budget.
- [x] Fix the material-slot set (`skin` / `cloth` / `pants` / `accessory` / `hair`) and the ORM packing (R=occlusion, G=roughness, B=metallic).
- [x] Choose and record the initial tier-0 triangle/vertex budget (≤ ~6000 tris / ~4000 verts) in the plan.
- [x] Spike: Godot 4.3 imports the `.glb` as a `PackedScene` with a `Skeleton3D`; no clips are needed because `_animate()` drives the bones at runtime.
### Phase 1 — Blender authoring + export pipeline
- [ ] Author the low-poly base humanoid in Blender: torso, articulated arms/legs, head with a face front, hands, shoes.
- [ ] Rig with an armature whose bones mirror the node contract (`Head`, `HipL/R`, `KneeL/R`, `ShoulderL/R`, `ElbowL/R`, `HandL/R`); origin at the feet, +Y up.
- [ ] UV-unwrap the mesh into a power-of-two layout, one region per material slot.
- [ ] Save the editable source as `assets/models/npc/npc_base.blend` (committed).
- [ ] Add `tools/npc_pipeline.py` with `list` / `build` / `audit` subcommands, run through the Blender flatpak.
- [ ] `build` exports `assets/models/generated/npc_base.glb` plus the material textures.
- [ ] Bake neutral albedo per slot, normal maps (fabric weave, denim, knit), and the packed ORM map.
- [ ] Bake ambient occlusion into the albedo or the ORM R channel (the Compatibility renderer has no SSAO).
- [ ] `audit` enforces UVs in [0,1], expected bone names, triangle/vertex budget, feet at y≈0, and material-slot presence.
- [ ] Reuse the `model_pipeline.py` colour-space discipline (linear factors vs sRGB textures) in the exporter.
- [ ] Run `godot --headless --path . --import` and confirm `npc_base.glb` imports without errors.
- [x] Author the low-poly base humanoid in Blender: torso, articulated arms/legs, head with a face front, hands, shoes.
- [x] Rig with an armature whose bones mirror the node contract (`Head`, `HipL/R`, `KneeL/R`, `ShoulderL/R`, `ElbowL/R`, `HandL/R`); origin at the feet, +Y up.
- [x] UV-unwrap the mesh into a power-of-two layout, one region per material slot.
- [x] Save the editable source as `assets/models/npc/npc_base.blend` (committed).
- [x] Add `tools/npc_pipeline.py` with `list` / `build` / `audit` subcommands, run through the Blender flatpak.
- [x] `build` exports `assets/models/generated/npc_base.glb` plus the material textures.
- [x] Bake neutral albedo per slot, normal maps (fabric weave, denim, knit), and the packed ORM map.
- [x] Bake ambient occlusion into the albedo or the ORM R channel (the Compatibility renderer has no SSAO).
- [x] `audit` enforces UVs in [0,1], expected bone names, triangle/vertex budget, feet at y≈0, and material-slot presence.
- [x] Reuse the `model_pipeline.py` colour-space discipline (linear factors vs sRGB textures) in the exporter.
- [x] Run `godot --headless --path . --import` and confirm `npc_base.glb` imports without errors.
### Phase 2 — AI-assisted texturing
- [x] Generate seamless clothing albedos (shirt, trousers, jacket, hi-vis vest, coat, dress) with `tools/asset_pipeline.py generate` (OpenRouter, `openai/gpt-5-image-mini`) — written to `assets/textures/npc/ai/` (512×512).
- [x] Tile-edge blending on each fabric — applied automatically by `generate --role tile` (`make_seamless`); the `tile` audit's edge check confirms continuity.
- [x] Run `asset_pipeline.py audit` on the outputs — 6 good, 0 bad (role `tile`).
- [ ] Commit the validated textures (files are under `assets/textures/npc/ai/`; git commit coordinated via `MEMORY.md` — Phase 1 files are still uncommitted).
- [x] Commit the validated textures under `assets/textures/npc/ai/` (tracked in the v0.6.0 asset commit).
- [x] Derive per-role outfit palettes via `model_pipeline.dominant_colors` — written to `assets/textures/npc/palette.json` (`npc_pipeline.py palette`).
- [x] Bake normal/roughness procedurally and layer the AI albedos on top — `npc_pipeline.py prepare` + Blender `build` keeps the AI albedo for `cloth`/`pants` and regenerates normal/ORM; GLB audit ok (1204 tris / 2488 verts).
- [ ] Regenerate the staged captures and run `screen_designs.py judge` (blocked: needs Phase 3 `NpcAvatar` integration to render the skinned model).
- [x] Regenerate the staged captures and run `screen_designs.py judge` after the Phase 3 integration (11 good / 0 bad in `compare`; judge: no diff).
### Phase 3 — Godot integration
- [ ] Add `NpcAvatar._load_model()` that instantiates the imported `npc_base.glb` `PackedScene`.
- [ ] Keep `_build_body()` as the offline fallback; choose per-avatar and record `loaded_model` / `fallback_avatar` for tests.
- [ ] Map `outfit_for()` onto the model's material slots so seed variety, `layer_for`, `hand_item_for`, and archetype colors survive.
- [ ] Keep accessory attachments (cap / hood / backpack / sunglasses / lanyard) under `Rig` / `Head`.
- [ ] Add `SurfaceMaterials` baked-map recipes (`npc()` / `skin()` / `cloth()`); stop calling `Materials.flat()` for the body.
- [ ] Add `_set_limb_rotation()` so `_animate()` / `_idle_layer()` write to bones or pivots; keep `Rig/Head` findable by the focus cone.
- [ ] Update `test_npc_character`, `test_npc_theater`, and `test_target_cycle` for the skinned path (or document why they stay on the fallback).
- [ ] Re-measure `smoke_npc_budget.gd`; tune `LOD_TIERS` if the skinned rig is heavier up close.
- [ ] Re-verify the focus-cone smoke (`smoke_focus_radial.gd`) and the movement smoke.
- [x] Add `NpcAvatar._load_model()` that instantiates the imported `npc_base.glb` `PackedScene`.
- [x] Keep `_build_body()` as the offline fallback; choose per-avatar and record `loaded_model` / `fallback_avatar` for tests.
- [x] Map `outfit_for()` onto the model's material slots so seed variety, `layer_for`, `hand_item_for`, and archetype colors survive.
- [x] Keep accessory attachments (cap / hood / backpack / sunglasses / lanyard) under `Rig` / `Head`.
- [x] Add `SurfaceMaterials` baked-map recipes (`npc()` / `skin()` / `cloth()`); stop calling `Materials.flat()` for the body.
- [x] Add `_set_limb_rotation()` so `_animate()` / `_idle_layer()` write to bones or pivots; keep `Rig/Head` findable by the focus cone.
- [x] Update `test_npc_character`, `test_npc_theater`, and `test_target_cycle` for the skinned path (or document why they stay on the fallback).
- [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 4 — Environment and lighting lift
View File
Whitespace-only changes.
Binary file not shown.
+4 -1
View File
@@ -69,7 +69,10 @@ From `src/game/npc_avatar.gd` and `src/game/guard_controller.gd`:
Record the working invocation in `MEMORY.md` (the Im-Dornäcker notes already
warn about the cwd gotcha).
2. Freeze the asset contract in writing (this document + ADR-0013):
2. Freeze the asset contract in writing (this document + ADR-0013). No
animation clips are exported: `_animate()` and `_idle_layer()` drive the
armature bones directly at runtime, so the GLB only needs the skinned rest
mesh, the armature, and the material maps.
- `assets/models/generated/npc_base.glb` — one skinned mesh (or a small fixed
set of sub-meshes: body / head / hands) with a **named armature** and UVs.
- Material slots rather than one atlas-per-prop: `skin`, `cloth`,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 835 KiB

After

Width:  |  Height:  |  Size: 845 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 527 KiB

After

Width:  |  Height:  |  Size: 534 KiB

+9
View File
@@ -31,6 +31,15 @@ const PROFILES := {
"rubber": {"roughness": 0.95, "metallic": 0.0},
}
## NPC material slot: keep the baked albedo/normal maps from the imported model
## and tint them with the seed-driven outfit colour.
static func npc_slot(source: Material, tint: Color) -> StandardMaterial3D:
var material := source.duplicate() as StandardMaterial3D
if material == null:
material = StandardMaterial3D.new()
material.albedo_color = tint
return material
static func profile(role: String) -> Dictionary:
return PROFILES.get(role, PROFILES["concrete"])
+156 -2
View File
@@ -70,7 +70,9 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
npc.fsm.state_changed.connect(_on_state_changed)
add_to_group("npc")
_idle_style = idle_style_for(profile.npc_type, seed_value)
_build_body(profile.npc_type)
if not use_skinned_model or not _build_skinned_body(profile.npc_type):
_build_body(profile.npc_type)
fallback_avatar = true
_build_impostor()
_nav_usable = false
_nav_try_frames = 0
@@ -126,6 +128,13 @@ var _lod_tier := 0
var _lod_check_s := 0.0
var _queue_offset := Vector3.ZERO
var _height_scale := 1.0
var loaded_model := false
var fallback_avatar := true
## Tests can force the offline rig; the game always tries the committed model.
var use_skinned_model := true
var _skeleton: Skeleton3D = null
var _bone_indices := {}
var _skinned_mesh: MeshInstance3D = null
var _shirt_color := Color(0.7, 0.7, 0.7)
var _skin_color := Color(0.88, 0.72, 0.60)
var _avoidance_active := false
@@ -280,6 +289,145 @@ static func reaction_label(mode: String, escalation: int) -> String:
return "?"
return ""
## 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"
if not ResourceLoader.exists(path):
return false
var packed := ResourceLoader.load(path) as PackedScene
if packed == null:
return false
var model := packed.instantiate() as Node3D
if model == null:
return false
var skeleton := model.find_child("Skeleton3D", true, false) as Skeleton3D
var mesh_instance := model.find_child("npc_base", true, false) as MeshInstance3D
if skeleton == null or mesh_instance == null:
model.queue_free()
return false
_rig = Node3D.new()
_rig.name = "Rig"
add_child(_rig)
_rig.add_child(model)
_skeleton = skeleton
_skinned_mesh = mesh_instance
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)
_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)
# Animation proxies: the existing _animate()/idle code drives these Node3D
# pivots; _sync_skeleton() copies their rotations onto the armature bones.
_head = Node3D.new()
_head.name = "Head"
_head.position = Vector3(0, 1.62, 0)
_rig.add_child(_head)
for side in [-1.0, 1.0]:
var suffix := "L" if side < 0.0 else "R"
var hip := Node3D.new()
hip.name = "Hip" + suffix
hip.position = Vector3(0.13 * side, 0.64, 0)
_rig.add_child(hip)
var knee := Node3D.new()
knee.name = "Knee"
knee.position = Vector3(0, -0.34, 0)
hip.add_child(knee)
_legs.append(hip)
_knees.append(knee)
var shoulder := Node3D.new()
shoulder.name = "Shoulder" + suffix
shoulder.position = Vector3(0.27 * side, 1.38, 0)
_rig.add_child(shoulder)
var elbow := Node3D.new()
elbow.name = "Elbow"
elbow.position = Vector3(0, -0.30, 0)
shoulder.add_child(elbow)
var hand := Node3D.new()
hand.name = "Hand"
hand.position = Vector3(0, -0.30, 0.02)
elbow.add_child(hand)
_arms.append(shoulder)
_elbows.append(elbow)
_hands.append(hand)
_bind_bones()
_add_accessory(str(outfit["accessory"]), cloth)
_add_hand_item(str(outfit["hand_item"]), outfit["skin"])
_build_focus_circle()
_add_props(npc_type)
loaded_model = true
fallback_avatar = false
return true
## 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:
var mesh: Mesh = _skinned_mesh.mesh
var tints := {
"skin": outfit["skin"],
"cloth": cloth,
"pants": outfit["pants"],
"hair": outfit["hair"],
"accessory": Color(0.10, 0.11, 0.14),
}
for index in mesh.get_surface_count():
var source := mesh.surface_get_material(index)
if source == null:
continue
var slot := str(source.resource_name)
_skinned_mesh.set_surface_override_material(index,
Materials.npc_slot(source, tints.get(slot, Color.WHITE)))
func _bind_bones() -> void:
_bone_indices.clear()
if _skeleton == null:
return
for index in _skeleton.get_bone_count():
_bone_indices[_skeleton.get_bone_name(index)] = index
## Copy the proxy pivot rotations onto armature bones. Limb bones point down, so
## an X rotation swings them forward/back; the Head bone points up, so a Y
## rotation is the look yaw.
func _sync_skeleton() -> void:
if _skeleton == null or _bone_indices.is_empty():
return
var names := ["HipL", "HipR"]
for i in 2:
_pose_bone(names[i], _legs[i].rotation.x, Vector3.RIGHT)
names = ["KneeL", "KneeR"]
for i in 2:
_pose_bone(names[i], _knees[i].rotation.x, Vector3.RIGHT)
names = ["ShoulderL", "ShoulderR"]
for i in 2:
_pose_bone(names[i], _arms[i].rotation.x, Vector3.RIGHT)
names = ["ElbowL", "ElbowR"]
for i in 2:
_pose_bone(names[i], _elbows[i].rotation.x, Vector3.RIGHT)
if _head != null:
_pose_bone("Head", _head.rotation.y, Vector3.UP)
func _pose_bone(bone_name: String, angle: float, axis: Vector3) -> void:
if not _bone_indices.has(bone_name):
return
var index: int = _bone_indices[bone_name]
# Bone pose rotation is the absolute local orientation, not an offset from
# rest; compose with the rest rotation so identity preserves the bind pose.
var rest := _skeleton.get_bone_rest(index).basis.get_rotation_quaternion()
_skeleton.set_bone_pose_rotation(index, rest * Quaternion(axis, angle))
func _build_body(npc_type: int) -> void:
_rig = Node3D.new()
_rig.name = "Rig"
@@ -813,6 +961,7 @@ func _animate(delta: float) -> void:
# Idle look-around, unless the person is already engaged.
_head.rotation.y = lerpf(_head.rotation.y, sin(_idle_t * 0.45) * 0.3, 0.05)
_update_reaction_label(escalation)
_sync_skeleton()
## Idle behaviour layered on top of the breathing pose: phone, clipboard,
## crossed arms, or looking around, plus escalation body language.
@@ -821,7 +970,12 @@ func _idle_layer(pose: String, escalation: int) -> void:
return
# Breathing and a slow weight shift keep idle people from looking frozen.
var breath := 1.0 + sin(_idle_t * 1.8) * 0.006
_rig.scale.y = lerpf(_rig.scale.y, _height_scale * breath, 0.15)
if _skeleton == null:
_rig.scale.y = lerpf(_rig.scale.y, _height_scale * breath, 0.15)
else:
# Skinned meshes must scale uniformly; non-uniform parent scale shears
# the skeleton's skin matrices.
_rig.scale = Vector3.ONE * lerpf(_rig.scale.x, _height_scale * breath, 0.15)
_rig.position.x = lerpf(_rig.position.x, sin(_idle_t * 0.7) * 0.015, 0.1)
_rig.rotation.z = lerpf(_rig.rotation.z, sin(_idle_t * 0.7) * 0.015, 0.1)
if pose != "idle" or _arms.size() < 2:
+49
View File
@@ -125,6 +125,55 @@ func test_walk_to_idle_animation_transition() -> bool:
avatar.free()
return a and b and c
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")
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")
and avatar._bone_indices.has("HipL") and avatar._bone_indices.has("HandR"),
"armature bones are bound")
avatar.free()
return a and b and c
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
var outfit := NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 44)
var found := 0
var tinted := 0
for index in mi.mesh.get_surface_count():
var material := mi.get_surface_override_material(index) as StandardMaterial3D
if material == null:
continue
found += 1
if material.albedo_color != Color.WHITE:
tinted += 1
var a := TestUtils.check(found == 5, "five material slots stay separate")
var b := TestUtils.check(tinted == found, "every slot carries the outfit tint")
var c := TestUtils.check(outfit["shirt"] != Color.WHITE, "seed gives a non-white outfit")
avatar.free()
return a and b and c
func test_forced_fallback_still_builds_the_procedural_rig() -> bool:
var profile := NpcProfile.new()
profile.id = "fallback_test"
profile.npc_type = Rules.NpcType.TOURIST
profile.display_name = "Fallback"
var avatar := NpcAvatar.new()
avatar.use_skinned_model = false
avatar.setup(profile, "enter", 5, Vector3(0, 0.5, 6),
Vector3(0, 0.5, 1.5), Vector3(0, 0.5, 1.2), "fallback_test")
var a := TestUtils.check(not avatar.loaded_model and avatar.fallback_avatar,
"fallback is recorded when the model is disabled")
var b := TestUtils.check(avatar.get_node_or_null("Rig/Head") != null,
"fallback still exposes Rig/Head")
var c := TestUtils.check(avatar.find_child("HipL", true, false) != null,
"fallback limb pivots exist")
avatar.free()
return a and b and c
func test_rig_is_articulated() -> bool:
var avatar := _avatar(Rules.NpcType.TOURIST, 77)
var a := TestUtils.check(avatar.find_child("HipL", true, false) != null
+44
View File
@@ -0,0 +1,44 @@
import os
import unittest
from pathlib import Path
import npc_pipeline
class NpcContractTests(unittest.TestCase):
def test_contract_constants(self):
self.assertEqual(npc_pipeline.SLOTS,
["skin", "cloth", "pants", "accessory", "hair"])
for bone in ["Head", "HipL", "HipR", "KneeL", "KneeR",
"ShoulderL", "ShoulderR", "ElbowL", "ElbowR",
"HandL", "HandR"]:
self.assertIn(bone, npc_pipeline.BONES)
self.assertLessEqual(npc_pipeline.MAX_TRIANGLES, 6000)
self.assertLessEqual(npc_pipeline.MAX_VERTICES, 4000)
def test_textures_exist_for_every_slot(self):
for slot in npc_pipeline.SLOTS:
for suffix in ("albedo", "normal", "orm"):
path = npc_pipeline.TEX_DIR / f"{slot}_{suffix}.png"
self.assertTrue(path.is_file(), path)
self.assertGreater(path.stat().st_size, 100, path)
@unittest.skipUnless(npc_pipeline.GLB_PATH.is_file(), "npc_base.glb not built")
def test_committed_model_audits_clean(self):
result = npc_pipeline.audit_glb()
self.assertTrue(result["ok"], result["issues"])
self.assertLessEqual(result["triangles"], npc_pipeline.MAX_TRIANGLES)
self.assertLessEqual(result["vertices"], npc_pipeline.MAX_VERTICES)
for bone in npc_pipeline.BONES:
self.assertIn(bone, result["bones"])
@unittest.skipUnless(npc_pipeline.GLB_PATH.is_file(), "npc_base.glb not built")
def test_blend_source_is_committed(self):
self.assertTrue(npc_pipeline.BLEND_PATH.is_file())
# Godot must not try to import the .blend (it hangs without Blender).
gdignore = npc_pipeline.BLEND_PATH.parent / ".gdignore"
self.assertTrue(gdignore.is_file(), "assets/models/npc/.gdignore is required")
if __name__ == "__main__":
unittest.main()