d6cd3293ae
assets/avatars/catalog.json referenced 14 GLB files that were never in the repo, so the avatar system could not load a single avatar. And the loader's assembly path had never run against a real skeleton -- it called Skeleton3D.has_bone() (no such method in Godot 4) and hand-copied bones in a way that dropped the skin binding. - scripts/tools/gen_placeholder_avatars.gd: generates the pack headlessly via GLTFDocument -- one rigged/skinned base body (the full 24-bone canonical rig from a T-pose) + 13 primitive attachments. Crude on purpose; real art swaps in as a pure content change (catalog paths unchanged). - assets/avatars/*.glb: the generated pack (checked in). - avatar_loader.gd: rewrote _parse_glb_as_avatar to lift the skin-bound Skeleton3D that generate_scene() already produces, instead of rebuilding it. Attachments now mount via BoneAttachment3D to the catalog's mount_bone and are actually added to the avatar tree (they were parsed then dropped). Fixed the has_bone() crash, the null ARRAY_BONES cast in _retarget_skin, and the Array[String] type mismatch on the accessory list. - smoke_test_avatar_system.gd: replaced the "asserts it fails gracefully because the files don't exist" section with a real assembly check -- Skeleton3D present, full 24-bone rig, canonical bones resolve by name, visible body mesh, equipped items mounted to bones. 14/14 from a cold cache. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
122 lines
4.5 KiB
GDScript
122 lines
4.5 KiB
GDScript
extends SceneTree
|
|
## Smoke test for the avatar catalog/slot/loadout/system layers AND the full
|
|
## assembly path against the placeholder GLB asset pack
|
|
## (assets/avatars/*.glb, generated by scripts/tools/gen_placeholder_avatars.gd).
|
|
##
|
|
## Covers: catalog parsing, slot registry, loadout equip/validate/round-trip,
|
|
## the AvatarSystem autoload wiring, and load_avatar() actually producing a
|
|
## Skeleton3D with the canonical rig, a visible body mesh, and equipped
|
|
## attachments mounted to the right bones.
|
|
##
|
|
## Run with:
|
|
## godot --headless --path . --script res://scripts/smoke_test_avatar_system.gd
|
|
|
|
# Preloaded rather than referenced by global class_name so this test also
|
|
# runs from a cold cache (a bare `--script` run with no prior --import).
|
|
const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd")
|
|
|
|
var passed := 0
|
|
var failed := 0
|
|
|
|
|
|
func _initialize() -> void:
|
|
_run()
|
|
|
|
|
|
func _run() -> void:
|
|
await process_frame
|
|
|
|
# 1. AvatarSystem is a real autoload, not just a loose script.
|
|
var system := root.get_node_or_null("AvatarSystem")
|
|
if not _check("AvatarSystem autoload exists", system != null):
|
|
_finish()
|
|
return
|
|
|
|
# 2. The real catalog.json (committed to assets/avatars/) actually parses.
|
|
var err: Error = system.load_catalog()
|
|
_check("catalog loads without error", err == OK)
|
|
_check("AvatarSystem reports catalog loaded", system.is_catalog_loaded)
|
|
|
|
var base_bodies: Array[String] = system.get_assets_for_slot("base_body")
|
|
_check("catalog has at least one base_body entry", base_bodies.size() > 0)
|
|
|
|
# 3. Loadout: create, equip, validate against the real catalog.
|
|
# base_body is its own dedicated field on AvatarLoadout, not something
|
|
# set via the generic per-slot set_equipment() -- AvatarSlotRegistry's
|
|
# SLOT_IDS deliberately excludes "base_body" (it's the foundational
|
|
# model you start from, not a layered attachment slot).
|
|
var loadout := AvatarLoadout.new()
|
|
if base_bodies.size() > 0:
|
|
loadout.base_body = base_bodies[0]
|
|
_check("loadout has base_body set",
|
|
base_bodies.size() == 0 or not loadout.base_body.is_empty())
|
|
|
|
var hair_options: Array[String] = system.get_assets_for_slot("hair")
|
|
if hair_options.size() > 0:
|
|
loadout.set_equipment("hair", hair_options[0])
|
|
_check("equipping a real catalog hair item succeeds",
|
|
loadout.slots.get("hair") == hair_options[0])
|
|
|
|
# 4. Loadout serializes and survives a round trip.
|
|
var json_str: String = loadout.to_json()
|
|
_check("loadout serializes to non-empty JSON", json_str.length() > 0)
|
|
var restored: AvatarLoadout = system.load_loadout_from_json(json_str)
|
|
_check("loadout round-trips through JSON", restored != null and restored.is_valid())
|
|
|
|
# 5. Full assembly against the placeholder GLB pack.
|
|
if base_bodies.size() > 0:
|
|
var errors := [] as Array[String]
|
|
var error_handler := func(msg): errors.append(msg)
|
|
system.loader.load_error.connect(error_handler)
|
|
|
|
var chest_options: Array[String] = system.get_assets_for_slot("chest")
|
|
if chest_options.size() > 0:
|
|
loadout.set_equipment("chest", chest_options[0])
|
|
var acc_options: Array[String] = system.get_assets_for_slot("accessory")
|
|
if acc_options.size() > 0:
|
|
loadout.add_accessory(acc_options[0])
|
|
|
|
var avatar: Node3D = system.load_avatar(loadout)
|
|
system.loader.load_error.disconnect(error_handler)
|
|
|
|
_check("load_avatar returns a node (no load_error: %s)" % str(errors),
|
|
avatar != null and errors.is_empty())
|
|
if avatar:
|
|
var skel := avatar.get_node_or_null("Skeleton3D") as Skeleton3D
|
|
_check("assembled avatar has a Skeleton3D", skel != null)
|
|
if skel:
|
|
_check("skeleton carries the full canonical rig (24 bones)",
|
|
skel.get_bone_count() == 24)
|
|
_check("canonical bones resolve by name (Head, LeftHand, RightFoot)",
|
|
skel.find_bone("Head") != -1
|
|
and skel.find_bone("LeftHand") != -1
|
|
and skel.find_bone("RightFoot") != -1)
|
|
var body_meshes := 0
|
|
var bone_mounts := 0
|
|
for c in skel.get_children():
|
|
if c is MeshInstance3D and c.visible:
|
|
body_meshes += 1
|
|
if c is BoneAttachment3D:
|
|
bone_mounts += 1
|
|
_check("a visible body mesh is present", body_meshes >= 1)
|
|
_check("equipped hair + chest are mounted to bones", bone_mounts >= 2)
|
|
else:
|
|
print(" SKIP: no base_body entries in catalog")
|
|
|
|
_finish()
|
|
|
|
|
|
func _check(description: String, condition: bool) -> bool:
|
|
if condition:
|
|
print(" PASS: ", description)
|
|
passed += 1
|
|
else:
|
|
printerr(" FAIL: ", description)
|
|
failed += 1
|
|
return condition
|
|
|
|
|
|
func _finish() -> void:
|
|
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
|
quit(0 if failed == 0 else 1)
|