diff --git a/.gitignore b/.gitignore index 67cf8fe..d7df3da 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ \ No newline at end of file +!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/ diff --git a/MEMORY.md b/MEMORY.md index e849cf4..7c4f747 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -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 diff --git a/README.md b/README.md index ff2cd94..e61fde5 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/TODO.md b/TODO.md index 7878a9e..14f40d3 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/assets/models/npc/.gdignore b/assets/models/npc/.gdignore new file mode 100644 index 0000000..e69de29 diff --git a/assets/models/npc/npc_base.blend b/assets/models/npc/npc_base.blend new file mode 100644 index 0000000..89c23c5 Binary files /dev/null and b/assets/models/npc/npc_base.blend differ diff --git a/docs/plan-graphics-v0.6.md b/docs/plan-graphics-v0.6.md index 815f6da..ba09d8e 100644 --- a/docs/plan-graphics-v0.6.md +++ b/docs/plan-graphics-v0.6.md @@ -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`, diff --git a/docs/screenshots/01-venue-overview.png b/docs/screenshots/01-venue-overview.png index 9dfe86d..bc8e450 100644 Binary files a/docs/screenshots/01-venue-overview.png and b/docs/screenshots/01-venue-overview.png differ diff --git a/docs/screenshots/02-targeting-npc.png b/docs/screenshots/02-targeting-npc.png index cb3bcd5..e14cd13 100644 Binary files a/docs/screenshots/02-targeting-npc.png and b/docs/screenshots/02-targeting-npc.png differ diff --git a/docs/screenshots/03-access-inspection.png b/docs/screenshots/03-access-inspection.png index 303ee54..3052c75 100644 Binary files a/docs/screenshots/03-access-inspection.png and b/docs/screenshots/03-access-inspection.png differ diff --git a/docs/screenshots/04-radio-report.png b/docs/screenshots/04-radio-report.png index 72b4b2f..670ab28 100644 Binary files a/docs/screenshots/04-radio-report.png and b/docs/screenshots/04-radio-report.png differ diff --git a/docs/screenshots/05-late-night-avenue.png b/docs/screenshots/05-late-night-avenue.png index 5d77551..ec0de3d 100644 Binary files a/docs/screenshots/05-late-night-avenue.png and b/docs/screenshots/05-late-night-avenue.png differ diff --git a/docs/screenshots/06-night-flashlight.png b/docs/screenshots/06-night-flashlight.png index dbc433a..44851b3 100644 Binary files a/docs/screenshots/06-night-flashlight.png and b/docs/screenshots/06-night-flashlight.png differ diff --git a/docs/screenshots/07-wand-screening.png b/docs/screenshots/07-wand-screening.png index c68b7a9..e1af5c9 100644 Binary files a/docs/screenshots/07-wand-screening.png and b/docs/screenshots/07-wand-screening.png differ diff --git a/docs/screenshots/08-escalation-hud.png b/docs/screenshots/08-escalation-hud.png index a6ae978..d9548fb 100644 Binary files a/docs/screenshots/08-escalation-hud.png and b/docs/screenshots/08-escalation-hud.png differ diff --git a/docs/screenshots/09-body-camera-timeline.png b/docs/screenshots/09-body-camera-timeline.png index da7f28a..5c38c47 100644 Binary files a/docs/screenshots/09-body-camera-timeline.png and b/docs/screenshots/09-body-camera-timeline.png differ diff --git a/src/game/materials.gd b/src/game/materials.gd index e85bcd1..87631cf 100644 --- a/src/game/materials.gd +++ b/src/game/materials.gd @@ -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"]) diff --git a/src/game/npc_avatar.gd b/src/game/npc_avatar.gd index 6ee593a..7c80f05 100644 --- a/src/game/npc_avatar.gd +++ b/src/game/npc_avatar.gd @@ -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: diff --git a/src/test/tests/test_npc_character.gd b/src/test/tests/test_npc_character.gd index 1e92705..4a99aba 100644 --- a/src/test/tests/test_npc_character.gd +++ b/src/test/tests/test_npc_character.gd @@ -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 diff --git a/tools/test_npc_pipeline.py b/tools/test_npc_pipeline.py new file mode 100644 index 0000000..3c6a1a6 --- /dev/null +++ b/tools/test_npc_pipeline.py @@ -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()