Stage the Blender character/gear/prop library
Commit the finished generator output from the parallel Blender pass: 8 skinned NPC variants, 18 gear pieces, and 20 props under assets/models/blender/, editable sources (gdignored), manifest, review sheets, and the integration notes in docs/blender-assets.md. - audit: all 46 assets clean (characters 3,788-4,640 tris, under 6000/4000) - tests: Python manifest/audit contract and Godot import/bone/slot/ground checks - docs: README library section, architecture/plan references, TODO Phase 3.5 status, MEMORY integration rules - .gitignore: per-asset preview PNGs, extracted GLB textures, blend1 backups Nothing in the game loads the library yet; integration follows." && git log --oneline -1
This commit is contained in:
1 parent
7efb5024ec
commit
d513e0b82c
109 files changed
+1916
-114
No files matched your search
@@ -0,0 +1,150 @@
|
||||
extends RefCounted
|
||||
## Headless checks for the Blender-authored assets staged under
|
||||
## `assets/models/blender/` (tools/blender_assets.py). Nothing in the game loads
|
||||
## them yet; these tests keep them import-clean and drop-in compatible with the
|
||||
## npc_base contract until they are integrated.
|
||||
|
||||
const MANIFEST := "res://assets/models/blender/manifest.json"
|
||||
const NPC_BASE := "res://assets/models/generated/npc_base.glb"
|
||||
const SLOTS := ["skin", "cloth", "pants", "accessory", "hair"]
|
||||
const BONES := ["Head", "HipL", "HipR", "KneeL", "KneeR", "ShoulderL", "ShoulderR",
|
||||
"ElbowL", "ElbowR", "HandL", "HandR"]
|
||||
|
||||
func _manifest() -> Dictionary:
|
||||
if not FileAccess.file_exists(MANIFEST):
|
||||
return {}
|
||||
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(MANIFEST))
|
||||
return parsed if parsed is Dictionary else {}
|
||||
|
||||
func _assets(category: String = "") -> Dictionary:
|
||||
var out := {}
|
||||
var assets: Dictionary = _manifest().get("assets", {})
|
||||
for name in assets:
|
||||
var entry: Dictionary = assets[name]
|
||||
if category == "" or str(entry.get("category", "")) == category:
|
||||
out[name] = entry
|
||||
return out
|
||||
|
||||
func _instance(entry: Dictionary) -> Node3D:
|
||||
var packed := ResourceLoader.load("res://" + str(entry["path"])) as PackedScene
|
||||
if packed == null:
|
||||
return null
|
||||
return packed.instantiate() as Node3D
|
||||
|
||||
func _global_rest(skeleton: Skeleton3D, bone: int) -> Transform3D:
|
||||
var xf := skeleton.get_bone_rest(bone)
|
||||
var parent := skeleton.get_bone_parent(bone)
|
||||
while parent >= 0:
|
||||
xf = skeleton.get_bone_rest(parent) * xf
|
||||
parent = skeleton.get_bone_parent(parent)
|
||||
return xf
|
||||
|
||||
func test_manifest_lists_every_category() -> bool:
|
||||
var counts := {}
|
||||
for entry in _assets().values():
|
||||
var category := str(entry.get("category", ""))
|
||||
counts[category] = int(counts.get(category, 0)) + 1
|
||||
return TestUtils.check(int(counts.get("npc", 0)) >= 8
|
||||
and int(counts.get("npc_gear", 0)) >= 10 and int(counts.get("props", 0)) >= 10,
|
||||
"manifest lists characters, gear and props (%s)" % str(counts))
|
||||
|
||||
func test_every_asset_imports_as_a_scene_with_geometry() -> bool:
|
||||
var problems: Array[String] = []
|
||||
var assets := _assets()
|
||||
for name in assets:
|
||||
var node := _instance(assets[name])
|
||||
if node == null:
|
||||
problems.append("%s not imported" % name)
|
||||
continue
|
||||
if node.find_children("*", "MeshInstance3D", true, false).is_empty():
|
||||
problems.append("%s has no mesh" % name)
|
||||
node.free()
|
||||
return TestUtils.check(not assets.is_empty() and problems.is_empty(),
|
||||
"Blender assets import (%s)" % str(problems))
|
||||
|
||||
func test_characters_match_the_npc_base_contract() -> bool:
|
||||
var problems: Array[String] = []
|
||||
var assets := _assets("npc")
|
||||
for name in assets:
|
||||
var node := _instance(assets[name])
|
||||
if node == null:
|
||||
problems.append("%s missing" % name)
|
||||
continue
|
||||
var skeleton := node.find_child("Skeleton3D", true, false) as Skeleton3D
|
||||
var mesh := node.find_child("npc_base", true, false) as MeshInstance3D
|
||||
if skeleton == null or mesh == null:
|
||||
problems.append("%s lacks Skeleton3D/npc_base" % name)
|
||||
node.free()
|
||||
continue
|
||||
for bone in BONES:
|
||||
if skeleton.find_bone(bone) < 0:
|
||||
problems.append("%s missing bone %s" % [name, bone])
|
||||
var slots := {}
|
||||
for i in mesh.mesh.get_surface_count():
|
||||
var mat := mesh.mesh.surface_get_material(i)
|
||||
if mat != null:
|
||||
slots[str(mat.resource_name)] = true
|
||||
for slot in SLOTS:
|
||||
if not slots.has(slot):
|
||||
problems.append("%s missing slot %s" % [name, slot])
|
||||
node.free()
|
||||
return TestUtils.check(assets.size() >= 8 and problems.is_empty(),
|
||||
"characters keep bones + slots (%s)" % str(problems))
|
||||
|
||||
func test_character_joints_line_up_with_npc_base() -> bool:
|
||||
## NpcAvatar positions its animation proxies for npc_base; a drop-in
|
||||
## replacement must put the same joints in the same places.
|
||||
if not ResourceLoader.exists(NPC_BASE):
|
||||
return TestUtils.check(true, "npc_base not imported; skipped")
|
||||
var base := (ResourceLoader.load(NPC_BASE) as PackedScene).instantiate()
|
||||
var base_skel := base.find_child("Skeleton3D", true, false) as Skeleton3D
|
||||
var problems: Array[String] = []
|
||||
var assets := _assets("npc")
|
||||
for name in assets:
|
||||
var node := _instance(assets[name])
|
||||
var skeleton := node.find_child("Skeleton3D", true, false) as Skeleton3D if node else null
|
||||
if skeleton == null:
|
||||
problems.append("%s has no skeleton" % name)
|
||||
else:
|
||||
for bone in BONES:
|
||||
var a := _global_rest(base_skel, base_skel.find_bone(bone)).origin
|
||||
var b := _global_rest(skeleton, skeleton.find_bone(bone)).origin
|
||||
if a.distance_to(b) > 0.01:
|
||||
problems.append("%s.%s off by %.3f" % [name, bone, a.distance_to(b)])
|
||||
if node:
|
||||
node.free()
|
||||
base.free()
|
||||
return TestUtils.check(problems.is_empty(), "joints match npc_base (%s)" % str(problems))
|
||||
|
||||
func test_character_bones_accept_the_avatar_pose_math() -> bool:
|
||||
## Same composition as NpcAvatar._pose_bone(): rest * offset must stay a
|
||||
## valid rotation on every limb bone.
|
||||
var problems: Array[String] = []
|
||||
var assets := _assets("npc")
|
||||
for name in assets:
|
||||
var node := _instance(assets[name])
|
||||
var skeleton := node.find_child("Skeleton3D", true, false) as Skeleton3D if node else null
|
||||
if skeleton == null:
|
||||
problems.append(name)
|
||||
else:
|
||||
for bone in BONES:
|
||||
var index := skeleton.find_bone(bone)
|
||||
var rest := skeleton.get_bone_rest(index).basis.get_rotation_quaternion()
|
||||
skeleton.set_bone_pose_rotation(index, rest * Quaternion(Vector3.RIGHT, 0.4))
|
||||
if not skeleton.get_bone_pose_rotation(index).is_normalized():
|
||||
problems.append("%s.%s" % [name, bone])
|
||||
if node:
|
||||
node.free()
|
||||
return TestUtils.check(problems.is_empty(), "bones pose cleanly (%s)" % str(problems))
|
||||
|
||||
func test_grounded_props_sit_on_the_floor() -> bool:
|
||||
var problems: Array[String] = []
|
||||
var assets := _assets("props")
|
||||
for name in assets:
|
||||
var bounds: Dictionary = assets[name].get("bounds_godot", {})
|
||||
if name in ["wall_lantern", "marquee"] or bounds.is_empty():
|
||||
continue
|
||||
var min_y := float(bounds["min"][1])
|
||||
if absf(min_y) > 0.03:
|
||||
problems.append("%s min y %.3f" % [name, min_y])
|
||||
return TestUtils.check(problems.is_empty(), "props grounded (%s)" % str(problems))
|
||||
Reference in new issue
Block a user