f51c49fcfa
Root cause: a stale .godot/global_script_class_cache.cfg was force-committed to the repo (last regenerated before the avatar system merged), and Godot does not rebuild the global class registry on a plain headless scene run -- only an editor scan / `--import` does. So every cross-file `class_name` reference in the avatar module failed to resolve from a fresh clone, and the AvatarSystem autoload failed to instantiate entirely. - Untrack .godot/ and sandbox_probe/.godot/ (already in .gitignore; were force-added during old WIP checkpoints) - Reference sibling avatar scripts via preload() constants instead of their global class_name, so the module parses independent of scan order. Keeps the class_name declarations for editor/tooling use. - avatar_loadout.gd's static from_json() factory self-references via a preload const too (a script can't resolve its own class_name mid-compile on a cold cache). - smoke_test_avatar_system.gd: same preload treatment so it runs from a bare `--script` invocation. Verified from a wiped .godot/: main_menu.tscn and game.tscn boot with zero script errors; smoke_test_avatar_system 10/10, smoke_test_player 15/15, smoke_test_main_menu_ui 13/13, smoke_test_test_level PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
291 lines
9.0 KiB
GDScript
291 lines
9.0 KiB
GDScript
## AvatarVisualizer
|
|
##
|
|
## Builds the render tree from an AvatarLoadout.
|
|
##
|
|
## Responsibilities:
|
|
## - Create a root Node3D (or Node2D for 2D mode) as the avatar container.
|
|
## - For each slot in the loadout, call AvatarLoader.load_slot() and attach
|
|
## the resulting Skeleton3D / MeshInstance3D tree to the root.
|
|
## - Apply skin-tone overrides to all MeshInstance3D nodes.
|
|
## - Apply a single Skeleton3D root (the "body" skeleton) and retarget all
|
|
## limb skeletons to it using the bone-map from AvatarSlotRegistry.
|
|
## - Expose `get_root()` for the caller to attach to the scene tree.
|
|
## - Expose `clear()` to destroy the tree and free resources.
|
|
##
|
|
## Usage:
|
|
## var viz = AvatarVisualizer.new()
|
|
## viz.load_loadout(loadout)
|
|
## avatar_node.add_child(viz.get_root())
|
|
##
|
|
## The visualizer owns the Skeleton3D and MeshInstance3D trees it creates.
|
|
## The caller owns the root node's placement in the scene tree.
|
|
|
|
class_name AvatarVisualizer
|
|
extends Object
|
|
|
|
# Preloaded rather than referenced by global class_name -- see avatar_loadout.gd.
|
|
const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd")
|
|
const AvatarLoader := preload("res://scripts/avatar/avatar_loader.gd")
|
|
const AvatarSlotRegistry := preload("res://scripts/avatar/avatar_slot_registry.gd")
|
|
|
|
|
|
## Emitted when the visualizer has finished building the render tree.
|
|
signal build_complete
|
|
## Emitted when the visualizer has been cleared.
|
|
signal cleared
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## The root node that holds the avatar render tree.
|
|
## Set this to the desired parent before calling load_loadout().
|
|
var root: Node3D = null
|
|
|
|
## The Skeleton3D that serves as the body skeleton.
|
|
## All limb skeletons are retargeted to this skeleton's bone hierarchy.
|
|
var body_skeleton: Skeleton3D = null
|
|
|
|
## The AvatarLoadout currently loaded (null if none).
|
|
var current_loadout: AvatarLoadout = null
|
|
|
|
## Whether the visualizer is currently building (true during load_loadout).
|
|
var is_building: bool = false
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Core
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Load an AvatarLoadout and build the render tree.
|
|
##
|
|
## If [param root] is null, a new Node3D is created and assigned.
|
|
## If [param loadout] is null or invalid, the current tree is cleared.
|
|
func load_loadout(loadout: AvatarLoadout, root_node: Node3D = null) -> void:
|
|
# Clear any previous tree.
|
|
clear()
|
|
|
|
if loadout == null or not loadout.is_valid():
|
|
build_complete.emit()
|
|
return
|
|
|
|
current_loadout = loadout
|
|
is_building = true
|
|
|
|
# Create root if not provided.
|
|
if root_node != null:
|
|
root = root_node
|
|
else:
|
|
root = Node3D.new()
|
|
root.name = "AvatarVisualizer"
|
|
|
|
# Create the body skeleton.
|
|
body_skeleton = Skeleton3D.new()
|
|
body_skeleton.name = "AvatarBody"
|
|
root.add_child(body_skeleton)
|
|
|
|
# Build each slot's contribution.
|
|
for slot_def in AvatarSlotRegistry.get_slot_definitions():
|
|
var slot_key: String = slot_def.slot_key
|
|
var slot_item: AvatarLoadout.SlotItem = loadout.slots.get(slot_key)
|
|
|
|
if slot_item == null or slot_item.asset_id == "":
|
|
# Slot is empty — create a placeholder.
|
|
_add_placeholder_slot(slot_def)
|
|
continue
|
|
|
|
# Load the slot's asset.
|
|
var slot_result: AvatarLoader.SlotResult = await AvatarLoader.load_slot(
|
|
slot_item,
|
|
slot_def,
|
|
loadout.skin_tone,
|
|
)
|
|
|
|
if slot_result == null:
|
|
push_warning("AvatarVisualizer: failed to load slot '%s' (%s)" % [slot_key, slot_item.asset_id])
|
|
_add_placeholder_slot(slot_def)
|
|
continue
|
|
|
|
# Attach the loaded tree to the root.
|
|
_attach_slot_tree(slot_result, slot_def)
|
|
|
|
# Retarget limb skeletons to the body skeleton.
|
|
_retarget_limbs()
|
|
|
|
# Apply skin-tone override to all meshes.
|
|
_apply_skin_tone(loadout.skin_tone)
|
|
|
|
is_building = false
|
|
build_complete.emit()
|
|
|
|
|
|
## Clear the render tree and free resources.
|
|
func clear() -> void:
|
|
if root != null:
|
|
root.queue_free()
|
|
root = null
|
|
body_skeleton = null
|
|
current_loadout = null
|
|
cleared.emit()
|
|
|
|
|
|
## Get the root node.
|
|
func get_root() -> Node3D:
|
|
return root
|
|
|
|
|
|
## Get a specific slot's Skeleton3D (if it has one).
|
|
func get_slot_skeleton(slot_key: String) -> Skeleton3D:
|
|
if body_skeleton == null:
|
|
return null
|
|
var bones := body_skeleton.get_bone_count()
|
|
for i in range(bones):
|
|
var bone_name: String = body_skeleton.get_bone_name(i)
|
|
if bone_name == slot_key:
|
|
return body_skeleton
|
|
return null
|
|
|
|
|
|
## Get all MeshInstance3D nodes in the render tree.
|
|
func get_all_mesh_instances() -> Array[MeshInstance3D]:
|
|
var meshes: Array[MeshInstance3D] = []
|
|
if root == null:
|
|
return meshes
|
|
_collect_meshes(root, meshes)
|
|
return meshes
|
|
|
|
|
|
## Get all Skeleton3D nodes in the render tree.
|
|
func get_all_skeletons() -> Array[Skeleton3D]:
|
|
var skeletons: Array[Skeleton3D] = []
|
|
if root == null:
|
|
return skeletons
|
|
_collect_skeletons(root, skeletons)
|
|
return skeletons
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Add a placeholder (empty) skeleton for a slot that has no asset.
|
|
func _add_placeholder_slot(slot_def: AvatarSlotRegistry.SlotDefinition) -> void:
|
|
if body_skeleton == null:
|
|
return
|
|
|
|
var bone_name: String = slot_def.slot_key
|
|
var bone_id: int = body_skeleton.add_bone(bone_name)
|
|
|
|
# Set the bone's rest pose to identity (placeholder).
|
|
var rest := Transform3D()
|
|
rest.origin = slot_def.default_offset
|
|
body_skeleton.set_bone_rest(bone_id, rest)
|
|
|
|
# Set parent to root (bone 0 is the root bone).
|
|
body_skeleton.set_bone_parent(bone_id, 0)
|
|
|
|
|
|
## Attach a loaded slot's tree to the root.
|
|
func _attach_slot_tree(result: AvatarLoader.SlotResult, slot_def: AvatarSlotRegistry.SlotDefinition) -> void:
|
|
if result.skeleton == null:
|
|
return
|
|
|
|
# Add the skeleton as a child of the body skeleton.
|
|
# The skeleton's bones will be merged into the body skeleton during retargeting.
|
|
result.skeleton.name = "%s_skeleton" % slot_def.slot_key
|
|
result.skeleton.visible = true
|
|
root.add_child(result.skeleton)
|
|
|
|
|
|
## Retarget all limb skeletons to the body skeleton.
|
|
func _retarget_limbs() -> void:
|
|
if body_skeleton == null:
|
|
return
|
|
|
|
# Collect all child Skeleton3D nodes (limb skeletons).
|
|
var limb_skeletons: Array[Skeleton3D] = []
|
|
for child in root.get_children():
|
|
if child is Skeleton3D and child != body_skeleton:
|
|
limb_skeletons.append(child as Skeleton3D)
|
|
|
|
# For each limb skeleton, merge its bones into the body skeleton.
|
|
for limb in limb_skeletons:
|
|
_merge_skeleton_into_body(limb)
|
|
limb.queue_free()
|
|
|
|
|
|
## Merge a limb skeleton's bones into the body skeleton.
|
|
func _merge_skeleton_into_body(limb: Skeleton3D) -> void:
|
|
if limb == null or body_skeleton == null:
|
|
return
|
|
|
|
var limb_bone_count: int = limb.get_bone_count()
|
|
var start_id: int = body_skeleton.get_bone_count()
|
|
|
|
for i in range(limb_bone_count):
|
|
var bone_name: String = limb.get_bone_name(i)
|
|
var bone_id: int = body_skeleton.add_bone(bone_name)
|
|
|
|
# Copy the rest transform.
|
|
var rest: Transform3D = limb.get_bone_rest(i)
|
|
body_skeleton.set_bone_rest(bone_id, rest)
|
|
|
|
# Copy the parent.
|
|
var parent_id: int = limb.get_bone_parent(i)
|
|
if parent_id >= 0:
|
|
# Map parent ID to body skeleton.
|
|
if parent_id == 0:
|
|
# Root of limb skeleton -> parent is the body root.
|
|
body_skeleton.set_bone_parent(bone_id, 0)
|
|
else:
|
|
# Offset by start_id to map to body skeleton bones.
|
|
body_skeleton.set_bone_parent(bone_id, start_id + parent_id)
|
|
else:
|
|
body_skeleton.set_bone_parent(bone_id, 0)
|
|
|
|
# Copy custom bone data (if any).
|
|
var custom_data_count: int = limb.get_bone_custom_data_count(i)
|
|
for j in range(custom_data_count):
|
|
var data: Variant = limb.get_bone_custom_data(i, j)
|
|
body_skeleton.set_bone_custom_data(bone_id, j, data)
|
|
|
|
# Copy mesh connections.
|
|
var mesh_count: int = limb.get_bone_mesh_count(i)
|
|
for m in range(mesh_count):
|
|
var mesh: MeshInstance3D = limb.get_bone_mesh(i, m)
|
|
if mesh != null:
|
|
# Re-parent mesh to body skeleton.
|
|
mesh.skeleton = body_skeleton
|
|
mesh.skeleton_path = body_skeleton.get_path()
|
|
body_skeleton.add_bone_mesh(bone_id, mesh)
|
|
|
|
|
|
## Apply skin-tone override to all MeshInstance3D nodes.
|
|
func _apply_skin_tone(skin_tone: Color) -> void:
|
|
if skin_tone == Color.TRANSPARENT:
|
|
return
|
|
|
|
var meshes: Array[MeshInstance3D] = get_all_mesh_instances()
|
|
for mesh_instance in meshes:
|
|
if mesh_instance.material_override == null:
|
|
mesh_instance.material_override = StandardMaterial3D.new()
|
|
var mat: StandardMaterial3D = mesh_instance.material_override
|
|
mat.albedo_color = skin_tone
|
|
|
|
|
|
## Recursively collect all MeshInstance3D nodes.
|
|
func _collect_meshes(node: Node, result: Array[MeshInstance3D]) -> void:
|
|
if node is MeshInstance3D:
|
|
result.append(node as MeshInstance3D)
|
|
for child in node.get_children():
|
|
_collect_meshes(child, result)
|
|
|
|
|
|
## Recursively collect all Skeleton3D nodes.
|
|
func _collect_skeletons(node: Node, result: Array[Skeleton3D]) -> void:
|
|
if node is Skeleton3D:
|
|
result.append(node as Skeleton3D)
|
|
for child in node.get_children():
|
|
_collect_skeletons(child, result)
|