diff --git a/scripts/avatar/avatar_catalog.gd b/scripts/avatar/avatar_catalog.gd new file mode 100644 index 0000000..b061ee3 --- /dev/null +++ b/scripts/avatar/avatar_catalog.gd @@ -0,0 +1,173 @@ +## AvatarCatalog - Asset catalog management. +## +## Manages the mapping of asset IDs to their metadata and resource paths. +## Loads the catalog manifest from disk and provides lookup by ID and slot. +## +## The catalog is loaded from `res://assets/avatars/catalog.json` which follows +## the format specified in avatar.md §7.2. + +class_name AvatarCatalog +extends RefCounted + + +## Emitted when the catalog is loaded successfully. +signal catalog_loaded + +## Emitted when the catalog fails to load. +signal catalog_load_failed(error_message: String) + +## Catalog manifest structure: +## { "assets": { "id": { "type", "slot", "path", "version", ... } } } +var _catalog: Dictionary = {} +var _loaded: bool = false +var _catalog_path: String = "res://assets/avatars/catalog.json" + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +## Load the catalog from disk. Call this before any lookups. +func load_catalog(path: String = _catalog_path) -> Error: + _catalog_path = path + _catalog = {} + + var file = FileAccess.open(path, FileAccess.READ) + if file == null: + var err = FileAccess.get_open_error() + catalog_load_failed.emit("Cannot open catalog: %s (error %d)" % [path, err]) + return err + + var json_text: String = file.get_as_text() + file.close() + + var json: JSONParseResult = JSON.parse_string(json_text) + if json == null: + catalog_load_failed.emit("Invalid JSON in catalog: %s" % path) + return ERR_PARSE_ERROR + + if not json is Dictionary or not json.has("assets"): + catalog_load_failed.emit("Catalog missing 'assets' key") + return ERR_INVALID_DATA + + _catalog = json["assets"] + _loaded = true + catalog_loaded.emit() + return OK + + +## Get the metadata for an asset by its ID. +## Returns a Dictionary with keys: type, slot, path, version, etc. +## Returns null if the asset ID is not found. +func get_asset(id: String) -> Dictionary: + if not _loaded: + return {} + return _catalog.get(id, {}) + + +## Check if an asset ID exists in the catalog. +func has_asset(id: String) -> bool: + if not _loaded: + return false + return _catalog.has(id) + + +## Get all assets that belong to a given slot. +## Returns an array of asset ID strings. +func get_assets_by_slot(slot: String) -> Array[String]: + if not _loaded: + return [] + var result: Array[String] = [] + for id in _catalog: + var asset: Dictionary = _catalog[id] + if asset.get("slot") == slot: + result.append(id) + return result + + +## Get all asset IDs in the catalog. +func get_all_asset_ids() -> Array[String]: + if not _loaded: + return [] + return _catalog.keys() + + +## Validate a loadout against the catalog. +## Returns a Dictionary with "valid" (bool) and "errors" (Array[String]). +func validate_loadout(loadout: AvatarLoadout) -> Dictionary: + var errors: Array[String] = [] + + # Check base_body + if loadout.base_body.is_empty(): + errors.append("Loadout missing base_body") + elif not has_asset(loadout.base_body): + errors.append("Unknown base_body asset: %s" % loadout.base_body) + else: + var base: Dictionary = get_asset(loadout.base_body) + if base.get("type") != "base_body": + errors.append("base_body asset '%s' is not type 'base_body'" % loadout.base_body) + + # Check each slot + for slot_id in loadout.slots: + var slot_value = loadout.slots[slot_id] + + if slot_value == null: + continue + + if slot_id == "accessory": + # Accessory is an array + if not slot_value is Array: + errors.append("Slot '%s' must be an array" % slot_id) + continue + for item_id in slot_value: + if not has_asset(item_id): + errors.append("Unknown accessory asset: %s" % item_id) + else: + var asset: Dictionary = get_asset(item_id) + if asset.get("slot") != slot_id: + errors.append("Accessory '%s' slot '%s' does not match slot '%s'" % [ + item_id, asset.get("slot"), slot_id + ]) + else: + # Exclusive slot - single value + if not slot_value is String: + errors.append("Slot '%s' must be a string or null" % slot_id) + continue + if not has_asset(slot_value): + errors.append("Unknown asset in slot '%s': %s" % [slot_id, slot_value]) + else: + var asset: Dictionary = get_asset(slot_value) + if asset.get("slot") != slot_id: + errors.append("Asset '%s' slot '%s' does not match slot '%s'" % [ + slot_value, asset.get("slot"), slot_id + ]) + + return { + "valid": errors.is_empty(), + "errors": errors + } + + +## Compute a hash of the loadout for change detection. +## Returns a uint32 hash. +static func compute_loadout_hash(loadout: AvatarLoadout) -> uint32: + var hasher = HashingContext.new() + hasher.start(HashingContext.HASH_SHA256) + + hasher.update(loadout.base_body.to_utf8_buffer()) + for slot_id in loadout.slots: + var slot_value = loadout.slots[slot_id] + hasher.update(slot_id.to_utf8_buffer()) + if slot_value is Array: + for item in slot_value: + hasher.update(item.to_utf8_buffer()) + elif slot_value is String: + hasher.update(slot_value.to_utf8_buffer()) + + var hash_bytes = hasher.finish() + return hash_bytes[0] | (hash_bytes[1] << 8) | (hash_bytes[2] << 16) | (hash_bytes[3] << 24) + + +## Get the catalog path. +func get_catalog_path() -> String: + return _catalog_path diff --git a/scripts/avatar/avatar_loader.gd b/scripts/avatar/avatar_loader.gd new file mode 100644 index 0000000..ad18e55 --- /dev/null +++ b/scripts/avatar/avatar_loader.gd @@ -0,0 +1,561 @@ +## AvatarLoader - glTF/GLB import, skeleton merge, and skin re-targeting. +## +## Loads base body and attachment GLB assets, extracts skeletons and meshes, +## and re-targets attachment skins to the canonical skeleton by bone name. +## +## Per the avatar architecture spec (§6.2, §6.3): +## - The base body carries the canonical skeleton. +## - Attachments are skinned to the same skeleton by name-based re-targeting. +## - Static accessories are parented to their mount bone. +## - Body-part slots hide the corresponding base mesh. + +class_name AvatarLoader +extends Node + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +## Maximum number of attachments allowed in the accessory slot. +const MAX_ACCESSORY_COUNT := 8 + +## Skeleton bone names that form the canonical rig (§3.1). +const CANONICAL_BONES := [ + "Root", + "Hips", + "Spine", + "Chest", + "Neck", + "Head", + "HeadTop", + "Jaw", + "LeftEye", + "RightEye", + "LeftShoulder", + "LeftUpperArm", + "LeftLowerArm", + "LeftHand", + "RightShoulder", + "RightUpperArm", + "RightLowerArm", + "RightHand", + "LeftUpperLeg", + "LeftLowerLeg", + "LeftFoot", + "RightUpperLeg", + "RightLowerLeg", + "RightFoot", +] + + +# --------------------------------------------------------------------------- +# Signals +# --------------------------------------------------------------------------- + +## Emitted when a base body has been loaded successfully. +signal base_body_loaded(avatar_node: Node3D) + +## Emitted when an attachment has been loaded and merged. +signal attachment_loaded(slot_id: String, attachment_node: Node3D) + +## Emitted on error during loading. +signal load_error(error_msg: String) + + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + +## The fully assembled avatar node (Skeleton3D + meshes + attachments). +var avatar_node: Node3D = null + +## The Skeleton3D belonging to the avatar. +var skeleton: Skeleton3D = null + +## Mapping of bone name -> bone index in the skeleton. +var _bone_index_map: Dictionary = {} + +## Loaded base body meshes, keyed by slot id. +var _base_meshes: Dictionary = {} + +## Loaded attachment nodes, keyed by slot id. +var _attachments: Dictionary = {} + +## Whether the base body has been loaded. +var is_base_loaded: bool = false + +## The avatar catalog reference (set by AvatarSystem). +var catalog: AvatarCatalog = null + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +## Initialize the loader with a reference to the avatar catalog. +func init_loader(cat: AvatarCatalog) -> void: + catalog = cat + + +## Load the base body asset identified by [param base_body_id]. +## Returns the AvatarNode3D on success, or null on failure. +func load_base_body(base_body_id: String) -> Node3D: + if catalog == null: + load_error.emit("AvatarCatalog not set on AvatarLoader") + return null + + var asset_meta = catalog.resolve(base_body_id) + if asset_meta == null: + load_error.emit("Unknown base_body asset: %s" % base_body_id) + return null + + if asset_meta.type != "base_body": + load_error.emit("Asset '%s' is not a base_body, got '%s'" % [ + base_body_id, asset_meta.type]) + return null + + var path: String = asset_meta.path + var glb_data: PackedByteArray = _load_file(path) + if glb_data.is_empty(): + load_error.emit("Failed to read GLB file: %s" % path) + return null + + avatar_node = _parse_glb_as_avatar(glb_data, path) + if avatar_node == null: + load_error.emit("Failed to parse GLB: %s" % path) + return null + + skeleton = avatar_node.get_node_or_null("Skeleton3D") as Skeleton3D + if skeleton == null: + load_error.emit("No Skeleton3D found in base body: %s" % path) + avatar_node.queue_free() + return null + + # Build bone index map for fast lookups. + _bone_index_map.clear() + for i in skeleton.get_bone_count(): + var bone_name: String = skeleton.get_bone_name(i) + if not bone_name.is_empty(): + _bone_index_map[bone_name] = i + + # Extract base body meshes and store them. + _extract_base_meshes(avatar_node) + + is_base_loaded = true + base_body_loaded.emit(avatar_node) + return avatar_node + + +## Load and merge an attachment into the avatar. +## [param slot_id] is the slot to equip into. +## [param asset_id] is the catalog id of the attachment. +## Returns the attachment Node3D on success, or null on failure. +func load_attachment(slot_id: String, asset_id: String) -> Node3D: + if not is_base_loaded: + load_error.emit("Cannot load attachment: base body not loaded") + return null + + if catalog == null: + load_error.emit("AvatarCatalog not set on AvatarLoader") + return null + + var asset_meta = catalog.resolve(asset_id) + if asset_meta == null: + load_error.emit("Unknown attachment asset: %s" % asset_id) + return null + + if asset_meta.type != "attachment": + load_error.emit("Asset '%s' is not an attachment, got '%s'" % [ + asset_id, asset_meta.type]) + return null + + # Check slot exclusivity for accessory slot. + if slot_id == "accessory": + var current_accessories: Array[String] = _attachments.get("accessory", []) + if current_accessories.size() >= MAX_ACCESSORY_COUNT: + load_error.emit("Accessory slot full (%d/%d)" % [ + MAX_ACCESSORY_COUNT, MAX_ACCESSORY_COUNT]) + return null + + var path: String = asset_meta.path + var glb_data: PackedByteArray = _load_file(path) + if glb_data.is_empty(): + load_error.emit("Failed to read GLB file: %s" % path) + return null + + var attachment_node = _parse_glb_as_attachment(glb_data, path, slot_id) + if attachment_node == null: + load_error.emit("Failed to parse attachment GLB: %s" % path) + return null + + # Retarget skin to the canonical skeleton. + _retarget_skin(attachment_node) + + # Parent static accessories to their mount bone. + _parent_attachment(attachment_node, slot_id, asset_meta) + + # Store reference. + if slot_id == "accessory": + var list: Array[String] = _attachments.get("accessory", []) + list.append(asset_id) + _attachments["accessory"] = list + else: + _attachments[slot_id] = attachment_node + + attachment_loaded.emit(slot_id, attachment_node) + return attachment_node + + +## Remove an attachment from the given slot. +func unload_attachment(slot_id: String) -> void: + if slot_id == "accessory": + var list: Array[String] = _attachments.get("accessory", []) + for asset_id in list: + var node = _attachments.get(asset_id) + if node and node is Node: + node.queue_free() + _attachments.erase("accessory") + else: + var node = _attachments.get(slot_id) + if node and node is Node: + node.queue_free() + _attachments.erase(slot_id) + + +## Hide or show the base mesh for a given body-part slot. +func set_base_mesh_visible(slot_id: String, visible: bool) -> void: + var mesh = _base_meshes.get(slot_id) + if mesh and mesh is Node3D: + mesh.visible = visible + + +## Get the current loadout as a dictionary (for serialization). +func get_current_loadout() -> Dictionary: + var loadout: Dictionary = { + "base_body": "", + "slots": {}, + } + + # Determine base body id from catalog. + if is_base_loaded and catalog != null: + # Walk through catalog to find which base_body loaded. + for id in catalog.get_all_ids(): + var meta = catalog.resolve(id) + if meta and meta.type == "base_body": + loadout.base_body = id + break + + # Collect equipped attachments. + for slot_id in _attachments: + if slot_id == "accessory": + loadout.slots[slot_id] = _attachments[slot_id] as Array + else: + var node = _attachments[slot_id] + if node is Node3D: + # Find the asset id by matching the node's name. + var aid: String = node.name + loadout.slots[slot_id] = aid + + return loadout + + +## Reset the avatar to an unloaded state. +func reset() -> void: + if avatar_node and avatar_node is Node: + avatar_node.queue_free() + + avatar_node = null + skeleton = null + _bone_index_map.clear() + _base_meshes.clear() + _attachments.clear() + is_base_loaded = false + + +# --------------------------------------------------------------------------- +# Internal: GLB Parsing +# --------------------------------------------------------------------------- + +## Parse a GLB byte array into an avatar Node3D structure. +## Creates: Avatar (Node3D) -> Skeleton3D + BodyMeshes + AttachmentPoints +func _parse_glb_as_avatar(glb_data: PackedByteArray, path: String) -> Node3D: + var avatar := Node3D.new() + avatar.name = "Avatar" + + # Create the skeleton container. + var skeleton_node := Skeleton3D.new() + skeleton_node.name = "Skeleton3D" + skeleton_node.position = Vector3(0, 0, 0) + avatar.add_child(skeleton_node) + + # Create attachment points container. + var attach_points := Node3D.new() + attach_points.name = "AttachmentPoints" + avatar.add_child(attach_points) + + # Create body meshes container. + var body_meshes := Node3D.new() + body_meshes.name = "BodyMeshes" + avatar.add_child(body_meshes) + + # Create attachments container. + var attachments_container := Node3D.new() + attachments_container.name = "Attachments" + avatar.add_child(attachments_container) + + # Parse the GLB and extract skeleton + meshes. + # Godot's GLTFState allows custom parsing. + var gltf_document := GLTFDocument.new() + var gltf_state := GLTFState.new() + + var parse_result := gltf_document.parse(glb_data, gltf_state, "", "") + if parse_result != OK: + push_error("GLB parse error: %s" % Error.parse_error(parse_result)) + avatar.queue_free() + return null + + # Export the scene from GLTFState. + var root_nodes := gltf_state.get_nodes_as_resources() + if root_nodes.is_empty(): + push_error("No root nodes in GLB: %s" % path) + avatar.queue_free() + return null + + # Merge GLB nodes into our avatar structure. + for node_resource in root_nodes: + if node_resource is Node3D: + _merge_node_into_avatar(node_resource, avatar, body_meshes, + attach_points, attachments_container) + + return avatar + + +## Parse a GLB byte array into an attachment Node3D. +func _parse_glb_as_attachment(glb_data: PackedByteArray, path: String, + slot_id: String) -> Node3D: + + var gltf_document := GLTFDocument.new() + var gltf_state := GLTFState.new() + + var parse_result := gltf_document.parse(glb_data, gltf_state, "", "") + if parse_result != OK: + push_error("GLB parse error: %s" % Error.parse_error(parse_result)) + return null + + var root_nodes := gltf_state.get_nodes_as_resources() + if root_nodes.is_empty(): + push_error("No root nodes in attachment GLB: %s" % path) + return null + + var attachment := Node3D.new() + attachment.name = "%s_%s" % [slot_id, path.get_file().get_basename()] + + for node_resource in root_nodes: + if node_resource is Node3D: + attachment.add_child(node_resource.duplicate()) + + return attachment + + +## Merge a GLB node tree into the avatar's container structure. +func _merge_node_into_avatar(node: Node3D, avatar: Node3D, + body_meshes: Node3D, attach_points: Node3D, + attachments_container: Node3D) -> void: + + var node_name := node.name + + # Identify skeleton nodes. + if node is Skeleton3D: + var src_skel := node as Skeleton3D + var dst_skel := avatar.get_node_or_null("Skeleton3D") as Skeleton3D + if dst_skel: + # Copy bones from source skeleton to destination. + for i in src_skel.get_bone_count(): + var bone_name := src_skel.get_bone_name(i) + var bone_pose := src_skel.get_bone_pose(i) + var parent_idx := src_skel.get_bone_parent(i) + if dst_skel.has_bone(bone_name): + # Bone already exists (canonical). + dst_skel.set_bone_pose(i, bone_pose) + if parent_idx >= 0: + var parent_name := src_skel.get_bone_name(parent_idx) + var dst_parent_idx := dst_skel.find_bone(parent_name) + if dst_parent_idx >= 0: + dst_skel.set_bone_parent(i, dst_parent_idx) + else: + # Add new bone. + var new_idx := dst_skel.add_bone(bone_name) + dst_skel.set_bone_pose(new_idx, bone_pose) + if parent_idx >= 0: + var parent_name := src_skel.get_bone_name(parent_idx) + var dst_parent_idx := dst_skel.find_bone(parent_name) + if dst_parent_idx >= 0: + dst_skel.set_bone_parent(new_idx, dst_parent_idx) + return + + # Identify attachment point nodes (empty nodes named attach_*). + if node_name.begins_with("attach_"): + var cloned := node.duplicate() + cloned.name = node_name + attach_points.add_child(cloned) + return + + # Identify mesh nodes. + if node is MeshInstance3D: + var mesh_inst := node as MeshInstance3D + var cloned := mesh_inst.duplicate() as MeshInstance3D + # Determine which container to place it in. + if node_name == "Body" or node_name.ends_with("_Mesh"): + body_meshes.add_child(cloned) + else: + attachments_container.add_child(cloned) + return + + # For other nodes, recurse into children. + for child in node.get_children(): + if child is Node3D: + _merge_node_into_avatar(child, avatar, body_meshes, + attach_points, attachments_container) + + +## Extract base body meshes from the avatar and store them by slot. +func _extract_base_meshes(avatar: Node3D) -> void: + var body_meshes := avatar.get_node_or_null("BodyMeshes") as Node3D + if body_meshes == null: + return + + for child in body_meshes.get_children(): + if child is MeshInstance3D: + var mesh_name := child.name + var slot_id := _mesh_name_to_slot(mesh_name) + if slot_id != "": + _base_meshes[slot_id] = child + + +## Map a mesh name to a slot id based on naming conventions (§4.3). +func _mesh_name_to_slot(mesh_name: String) -> String: + if mesh_name == "Body": + # Generic body mesh - covers torso, legs, arms. + return "torso" + if mesh_name == "head_Mesh": + return "head" + if mesh_name == "torso_Mesh": + return "torso" + if mesh_name == "legs_Mesh": + return "legs" + if mesh_name == "left_arm_Mesh": + return "left_arm" + if mesh_name == "right_arm_Mesh": + return "right_arm" + return "" + + +# --------------------------------------------------------------------------- +# Internal: Skin Retargeting +# --------------------------------------------------------------------------- + +## Retarget the skin weights of an attachment node to the canonical skeleton. +## Walks all MeshInstance3D children and remaps joint indices by bone name. +func _retarget_skin(attachment_node: Node3D) -> void: + if skeleton == null: + return + + for child in attachment_node.get_children(): + if child is MeshInstance3D: + var mesh_inst := child as MeshInstance3D + var mesh := mesh_inst.mesh + if mesh == null: + continue + + # Handle MultiMeshInstance3D. + if mesh is MultiMesh: + # MultiMesh doesn't have skin weights per-vertex in the same way. + continue + + if mesh is ArrayMesh: + var arr_mesh := mesh as ArrayMesh + var surface_count := arr_mesh.get_surface_count() + + for surface_idx in surface_count: + var arrays := arr_mesh.surface_get_arrays(surface_idx) + var joints_array := arrays[Mesh.ARRAY_JOINTS] as PackedInt32Array + var weights_array := arrays[Mesh.ARRAY_WEIGHTS] as PackedFloat32Array + + if joints_array.is_empty(): + continue + + # Remap joint indices by bone name. + var new_joints := PackedInt32Array() + for i in range(0, joints_array.size(), 4): + for j in 4: + var old_joint := joints_array[i + j] + if old_joint < 0: + new_joints.append(0) + else: + var bone_name := skeleton.get_bone_name(old_joint) + var new_idx := _bone_index_map.get(bone_name, 0) + new_joints.append(new_idx) + + arrays[Mesh.ARRAY_JOINTS] = new_joints + arr_mesh.surface_set_arrays(surface_idx, arrays) + + +## Parent an attachment node to its mount bone in the skeleton. +func _parent_attachment(attachment_node: Node3D, slot_id: String, + asset_meta: Dictionary) -> void: + + if skeleton == null: + return + + var mount_bone: String = asset_meta.get("mount_bone", "") + if mount_bone.is_empty(): + # Try to infer from slot. + mount_bone = _slot_to_mount_bone(slot_id) + + if mount_bone.is_empty(): + return + + var bone_idx := skeleton.find_bone(mount_bone) + if bone_idx < 0: + return + + # Get the world transform of the bone at the time of attachment. + var bone_pose := skeleton.get_bone_pose(bone_idx) + var bone_transform := skeleton.global_transform * bone_pose + + # Position the attachment at the bone's location. + attachment_node.global_transform = bone_transform + + +## Convert a slot id to its canonical mount bone name (§3.2). +func _slot_to_mount_bone(slot_id: String) -> String: + match slot_id: + "head": return "Head" + "hair": return "HeadTop" + "face": return "Jaw" + "torso", "chest", "back": return "Chest" + "left_arm": return "LeftUpperArm" + "right_arm": return "RightUpperArm" + "left_hand": return "LeftHand" + "right_hand": return "RightHand" + "legs": return "Hips" + "pants": return "Hips" + "left_foot": return "LeftFoot" + "right_foot": return "RightFoot" + "accessory": return "Root" + _: return "" + + +# --------------------------------------------------------------------------- +# Internal: File Loading +# --------------------------------------------------------------------------- + +## Load a file from the given path as a PackedByteArray. +func _load_file(path: String) -> PackedByteArray: + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return PackedByteArray() + var data := file.get_buffer(file.get_length()) + file.close() + return data diff --git a/scripts/avatar/avatar_loadout.gd b/scripts/avatar/avatar_loadout.gd new file mode 100644 index 0000000..2ae4fa1 --- /dev/null +++ b/scripts/avatar/avatar_loadout.gd @@ -0,0 +1,225 @@ +## AvatarLoadout - Serializable loadout model + validation. +## +## Represents a complete avatar composition: a base body plus equipped items +## per slot. Validates slot exclusivity and references against a catalog. +## +## See docs/architecture/avatar.md §5 for the loadout JSON format. + +extends RefCounted + + +## Maximum number of accessories allowed in the accessory slot. +const MAX_ACCESSORIES: int = 8 + +## The base body asset ID. +var base_body: String = "" + +## Equipped items per slot. Keys are slot IDs from AvatarSlotRegistry. +## Exclusive slots hold a single asset ID (or null). +## The "accessory" slot holds an array of asset IDs. +var slots: Dictionary = {} + +## Validation errors found during the last validate() call. +var validation_errors: Array[String] = [] + + +func _init() -> void: + validation_errors = [] + + +## Create a loadout from a JSON string. +static func from_json(json_string: String) -> AvatarLoadout: + var loadout := AvatarLoadout.new() + var data: Variant = JSON.parse_string(json_string) + if data is not Dictionary: + loadout.validation_errors.append("Loadout JSON root is not an object") + return loadout + + var root: Dictionary = data as Dictionary + + # Parse base_body + if root.has("base_body"): + var bb = root["base_body"] + if typeof(bb) == TYPE_STRING: + loadout.base_body = bb as String + else: + loadout.validation_errors.append("base_body must be a string") + + # Parse slots + if root.has("slots"): + var slots_data = root["slots"] + if typeof(slots_data) == TYPE_DICTIONARY: + loadout.slots = slots_data as Dictionary + else: + loadout.validation_errors.append("slots must be an object") + else: + loadout.validation_errors.append("Missing 'slots' in loadout JSON") + + return loadout + + +## Serialize this loadout to a JSON string. +func to_json() -> String: + var data := { + "base_body": base_body, + "slots": {} + } + + for slot_id in AvatarSlotRegistry.SLOT_IDS: + if slot_id == "accessory": + data.slots[slot_id] = slots.get(slot_id, []) + else: + data.slots[slot_id] = slots.get(slot_id, null) + + return JSON.stringify(data, " ") + + +## Validate this loadout against the slot registry. +## Returns true if valid; populates validation_errors if not. +func validate() -> bool: + validation_errors.clear() + + # Check base_body + if base_body.is_empty(): + validation_errors.append("base_body is required") + + # Check each slot + for slot_id in AvatarSlotRegistry.SLOT_IDS: + var slot_def = AvatarSlotRegistry.get_slot(slot_id) + if slot_def == null: + validation_errors.append("Unknown slot: %s" % slot_id) + continue + + var value = slots.get(slot_id) + + if slot_def.exclusive: + # Exclusive slots: must be null or a string + if value != null and typeof(value) != TYPE_STRING: + validation_errors.append( + "Slot '%s' must be null or a string, got %s" % [ + slot_id, typeof(value) + ] + ) + else: + # Non-exclusive (accessory): must be null or an array + if value != null and typeof(value) != TYPE_ARRAY: + validation_errors.append( + "Slot '%s' must be null or an array, got %s" % [ + slot_id, typeof(value) + ] + ) + elif value is Array: + if slot_id == "accessory": + if value.size() > MAX_ACCESSORIES: + validation_errors.append( + "Accessory count (%d) exceeds maximum (%d)" % [ + value.size(), MAX_ACCESSORIES + ] + ) + + # Check for duplicate accessories + if slots.has("accessory") and slots["accessory"] is Array: + var accessories = slots["accessory"] as Array + var seen := {} + for item in accessories: + if item is String: + if seen.has(item): + validation_errors.append( + "Duplicate accessory: %s" % item + ) + seen[item] = true + + return validation_errors.is_empty() + + +## Check if a specific slot is equipped with a non-null item. +func is_equipped(slot_id: String) -> bool: + if not AvatarSlotRegistry.has_slot(slot_id): + return false + + var value = slots.get(slot_id) + if value == null: + return false + + if typeof(value) == TYPE_STRING: + return not (value as String).is_empty() + elif typeof(value) == TYPE_ARRAY: + return (value as Array).size() > 0 + + return false + + +## Get the equipped asset ID for an exclusive slot. +## Returns empty string if not equipped or slot is not exclusive. +func get_equipment(slot_id: String) -> String: + var value = slots.get(slot_id) + if value is String: + return value + return "" + + +## Set equipment for an exclusive slot. +func set_equipment(slot_id: String, asset_id: String) -> void: + if not AvatarSlotRegistry.has_slot(slot_id): + push_error("Unknown slot: %s" % slot_id) + return + + var slot_def = AvatarSlotRegistry.get_slot(slot_id) + if slot_def and not slot_def.exclusive: + push_error("Slot '%s' is not exclusive" % slot_id) + return + + if slots.has(slot_id): + var current = slots[slot_id] + if current is Array: + # Convert array to single value + slots[slot_id] = asset_id if asset_id.is_empty() else null + else: + slots[slot_id] = asset_id if not asset_id.is_empty() else null + else: + slots[slot_id] = asset_id if not asset_id.is_empty() else null + + +## Add an accessory to the accessory slot. +func add_accessory(asset_id: String) -> bool: + var accessories = slots.get("accessory", []) as Array + if accessories.size() >= MAX_ACCESSORIES: + return false + if asset_id.is_empty(): + return false + if accessories.has(asset_id): + return false + accessories.append(asset_id) + slots["accessory"] = accessories + return true + + +## Remove an accessory from the accessory slot. +func remove_accessory(asset_id: String) -> bool: + var accessories = slots.get("accessory", []) as Array + if accessories.has(asset_id): + accessories.erase(asset_id) + slots["accessory"] = accessories + return true + return false + + +## Compute a hash of this loadout for change detection. +## Mirrors the server's loadout_hash concept. +func compute_hash() -> int: + var hasher := HashingContext.new() + hasher.start(HashingContext.HASH_SHA256) + hasher.update(base_body.to_utf8_buffer()) + for slot_id in AvatarSlotRegistry.SLOT_IDS: + hasher.update(slot_id.to_utf8_buffer()) + var value = slots.get(slot_id) + if value is String: + hasher.update(value.to_utf8_buffer()) + elif value is Array: + var arr_str = JSON.stringify(value) + hasher.update(arr_str.to_utf8_buffer()) + else: + hasher.update("null".to_utf8_buffer()) + var digest = hasher.finish() + # Return first 4 bytes as int for compact storage + return digest[0] | (digest[1] << 8) | (digest[2] << 16) | (digest[3] << 24) diff --git a/scripts/avatar/avatar_visualizer.gd b/scripts/avatar/avatar_visualizer.gd new file mode 100644 index 0000000..eee9cb0 --- /dev/null +++ b/scripts/avatar/avatar_visualizer.gd @@ -0,0 +1,285 @@ +## 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 + + +## 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)