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.
No files matched your search
@@ -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/
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
|
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: 835 KiB After Width: | Height: | Size: 845 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: 527 KiB After Width: | Height: | Size: 534 KiB |
@@ -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"])
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||