From 5b3fc106492a725649f977f373936538ec7c9544 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 21:59:50 +0000 Subject: [PATCH 01/26] WIP checkpoint: Implement avatar loader and customization UI in Godot --- project.godot | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 project.godot diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..0321e62 --- /dev/null +++ b/project.godot @@ -0,0 +1,20 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; but it can also be edited by hand. + +config_version=5 + +[application] + +config/name="AGP Game" +run/main_scene="res://scenes/main_menu.tscn" +config/features=PackedStringArray("4.6.2") + +[autoload] + +AvatarSystem="*res://scripts/avatar/avatar_system.gd" + +[display] + +window/size/viewport_width=1280 +window/size/viewport_height=720 -- 2.52.0 From 9fcea730b9afe757e1bed424b0a932250d4dfb68 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 22:00:14 +0000 Subject: [PATCH 02/26] WIP checkpoint: Implement avatar loader and customization UI in Godot --- project.godot | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project.godot b/project.godot index 0321e62..9dd0064 100644 --- a/project.godot +++ b/project.godot @@ -8,7 +8,7 @@ config_version=5 config/name="AGP Game" run/main_scene="res://scenes/main_menu.tscn" -config/features=PackedStringArray("4.6.2") +config/features=PackedStringArray("4.6.2.stable") [autoload] -- 2.52.0 From b2a64a89330a2e84177531c999884571b7fd2504 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 22:00:37 +0000 Subject: [PATCH 03/26] WIP checkpoint: Implement avatar loader and customization UI in Godot --- project.godot | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project.godot b/project.godot index 9dd0064..0321e62 100644 --- a/project.godot +++ b/project.godot @@ -8,7 +8,7 @@ config_version=5 config/name="AGP Game" run/main_scene="res://scenes/main_menu.tscn" -config/features=PackedStringArray("4.6.2.stable") +config/features=PackedStringArray("4.6.2") [autoload] -- 2.52.0 From 5ebd60b0bbeaf92e66c7135bf53088b70104f272 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 22:04:48 +0000 Subject: [PATCH 04/26] WIP checkpoint: Implement avatar loader and customization UI in Godot --- project.godot | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/project.godot b/project.godot index 0321e62..4ee1634 100644 --- a/project.godot +++ b/project.godot @@ -6,9 +6,9 @@ config_version=5 [application] -config/name="AGP Game" -run/main_scene="res://scenes/main_menu.tscn" -config/features=PackedStringArray("4.6.2") +config/name="agp-game" +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.3", "Forward Plus") [autoload] -- 2.52.0 From ea5dd63cd7ac58825b1e4f5344aa283c832b274c Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 22:10:56 +0000 Subject: [PATCH 05/26] WIP checkpoint: Implement avatar loader and customization UI in Godot --- scripts/avatar/avatar_slot_registry.gd | 222 +++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 scripts/avatar/avatar_slot_registry.gd diff --git a/scripts/avatar/avatar_slot_registry.gd b/scripts/avatar/avatar_slot_registry.gd new file mode 100644 index 0000000..b7b9a2a --- /dev/null +++ b/scripts/avatar/avatar_slot_registry.gd @@ -0,0 +1,222 @@ +## AvatarSlotRegistry - Static registry of slot definitions. +## +## Each slot maps to one or more bones and defines what can be equipped there. +## This is the single source of truth for slot IDs, categories, mount bones, +## exclusivity, and body-part vs attachment classification. +## +## See docs/architecture/avatar.md §3.2 for the full slot specification. +class_name AvatarSlotRegistry extends RefCounted + + +# --------------------------------------------------------------------------- +# Slot definitions (mirrors §3.2 of the avatar spec) +# --------------------------------------------------------------------------- + +## All known slot IDs, in canonical order. +const SLOT_IDS: PackedStringArray = [ + "head", + "hair", + "face", + "torso", + "chest", + "back", + "left_arm", + "right_arm", + "left_hand", + "right_hand", + "legs", + "pants", + "left_foot", + "right_foot", + "accessory", +] + +## Body-part slots (exclusive, replace base mesh). +const BODY_PART_SLOTS: PackedStringArray = [ + "head", "torso", "legs", "left_arm", "right_arm", +] + +## Attachment slots (layered on top of base mesh). +const ATTACHMENT_SLOTS: PackedStringArray = [ + "hair", "face", "chest", "back", + "left_hand", "right_hand", "pants", + "left_foot", "right_foot", "accessory", +] + +## Slots that are multi-slot (multiple accessories can coexist). +const MULTI_SLOT_IDS: PackedStringArray = ["accessory"] + + +# Slot metadata lookup tables (indexed by SLOT_IDS position). +# Each entry: { category, mount_bones, exclusive, is_body_part } +var _slot_data: Dictionary = {} + + +func _init() -> void: + _build_registry() + + +func _build_registry() -> void: + _slot_data = { + "head": { + "category": "body_part", + "mount_bones": ["Head"], + "exclusive": true, + "is_body_part": true, + }, + "hair": { + "category": "attachment", + "mount_bones": ["HeadTop"], + "exclusive": true, + "is_body_part": false, + }, + "face": { + "category": "attachment", + "mount_bones": ["Jaw"], + "exclusive": true, + "is_body_part": false, + }, + "torso": { + "category": "body_part", + "mount_bones": ["Chest"], + "exclusive": true, + "is_body_part": true, + }, + "chest": { + "category": "attachment", + "mount_bones": ["Chest"], + "exclusive": true, + "is_body_part": false, + }, + "back": { + "category": "attachment", + "mount_bones": ["Chest"], + "exclusive": true, + "is_body_part": false, + }, + "left_arm": { + "category": "body_part", + "mount_bones": ["LeftUpperArm"], + "exclusive": true, + "is_body_part": true, + }, + "right_arm": { + "category": "body_part", + "mount_bones": ["RightUpperArm"], + "exclusive": true, + "is_body_part": true, + }, + "left_hand": { + "category": "attachment", + "mount_bones": ["LeftHand"], + "exclusive": true, + "is_body_part": false, + }, + "right_hand": { + "category": "attachment", + "mount_bones": ["RightHand"], + "exclusive": true, + "is_body_part": false, + }, + "legs": { + "category": "body_part", + "mount_bones": ["Hips"], + "exclusive": true, + "is_body_part": true, + }, + "pants": { + "category": "attachment", + "mount_bones": ["Hips"], + "exclusive": true, + "is_body_part": false, + }, + "left_foot": { + "category": "attachment", + "mount_bones": ["LeftFoot"], + "exclusive": true, + "is_body_part": false, + }, + "right_foot": { + "category": "attachment", + "mount_bones": ["RightFoot"], + "exclusive": true, + "is_body_part": false, + }, + "accessory": { + "category": "attachment", + "mount_bones": ["Root"], + "exclusive": false, + "is_body_part": false, + }, + } + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +## Returns the list of all known slot IDs. +static func get_slot_ids() -> PackedStringArray: + return SLOT_IDS + + +## Returns true if *slot_id* is a known slot. +static func has_slot(slot_id: String) -> bool: + return slot_id in SLOT_IDS + + +## Returns the category for a slot ("body_part" or "attachment"). +static func get_category(slot_id: String) -> String: + if not has_slot(slot_id): + push_error("AvatarSlotRegistry: unknown slot '%s'" % slot_id) + return "" + return _slot_data[slot_id]["category"] + + +## Returns the primary mount bone(s) for a slot. +static func get_mount_bones(slot_id: String) -> PackedStringArray: + if not has_slot(slot_id): + push_error("AvatarSlotRegistry: unknown slot '%s'" % slot_id) + return PackedStringArray() + return PackedStringArray(_slot_data[slot_id]["mount_bones"]) + + +## Returns whether the slot is exclusive (at most one item). +static func is_exclusive(slot_id: String) -> bool: + if not has_slot(slot_id): + push_error("AvatarSlotRegistry: unknown slot '%s'" % slot_id) + return true + return _slot_data[slot_id]["exclusive"] + + +## Returns whether the slot is a body-part slot (replaces base mesh). +static func is_body_part_slot(slot_id: String) -> bool: + if not has_slot(slot_id): + push_error("AvatarSlotRegistry: unknown slot '%s'" % slot_id) + return false + return _slot_data[slot_id]["is_body_part"] + + +## Returns whether the slot is a multi-slot (e.g. accessory). +static func is_multi_slot(slot_id: String) -> bool: + return slot_id in MULTI_SLOT_IDS + + +## Returns the list of body-part slot IDs. +static func get_body_part_slots() -> PackedStringArray: + return BODY_PART_SLOTS + + +## Returns the list of attachment slot IDs. +static func get_attachment_slots() -> PackedStringArray: + return ATTACHMENT_SLOTS + + +## Validates that an asset's declared slot matches the slot it is placed in. +static func validate_slot_assignment(asset_slot: String, slot_id: String) -> bool: + return asset_slot == slot_id + + +## Returns the canonical order index for a slot (for serialization order). +static func get_slot_index(slot_id: String) -> int: + return SLOT_IDS.find(slot_id) -- 2.52.0 From a5dc5cb3b626bd3f92522297c5325f16f366893a Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 22:20:39 +0000 Subject: [PATCH 06/26] WIP checkpoint: Implement avatar loader and customization UI in Godot --- scripts/avatar/avatar_catalog.gd | 173 +++++++++ scripts/avatar/avatar_loader.gd | 561 ++++++++++++++++++++++++++++ scripts/avatar/avatar_loadout.gd | 225 +++++++++++ scripts/avatar/avatar_visualizer.gd | 285 ++++++++++++++ 4 files changed, 1244 insertions(+) create mode 100644 scripts/avatar/avatar_catalog.gd create mode 100644 scripts/avatar/avatar_loader.gd create mode 100644 scripts/avatar/avatar_loadout.gd create mode 100644 scripts/avatar/avatar_visualizer.gd 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) -- 2.52.0 From 27461d9b5e23949dcf0d6b3632a8c61fb5de66f0 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Tue, 18 Aug 2026 00:25:17 +0000 Subject: [PATCH 07/26] WIP checkpoint: Implement avatar loader and customization UI in Godot --- scripts/avatar/avatar_catalog.gd | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/avatar/avatar_catalog.gd b/scripts/avatar/avatar_catalog.gd index b061ee3..1eb2405 100644 --- a/scripts/avatar/avatar_catalog.gd +++ b/scripts/avatar/avatar_catalog.gd @@ -56,10 +56,10 @@ func load_catalog(path: String = _catalog_path) -> Error: return OK -## Get the metadata for an asset by its ID. +## Resolve 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: +func resolve(id: String) -> Dictionary: if not _loaded: return {} return _catalog.get(id, {}) @@ -86,7 +86,7 @@ func get_assets_by_slot(slot: String) -> Array[String]: ## Get all asset IDs in the catalog. -func get_all_asset_ids() -> Array[String]: +func get_all_ids() -> Array[String]: if not _loaded: return [] return _catalog.keys() @@ -103,7 +103,7 @@ func validate_loadout(loadout: AvatarLoadout) -> Dictionary: 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) + var base: Dictionary = resolve(loadout.base_body) if base.get("type") != "base_body": errors.append("base_body asset '%s' is not type 'base_body'" % loadout.base_body) @@ -123,7 +123,7 @@ func validate_loadout(loadout: AvatarLoadout) -> Dictionary: if not has_asset(item_id): errors.append("Unknown accessory asset: %s" % item_id) else: - var asset: Dictionary = get_asset(item_id) + var asset: Dictionary = resolve(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 @@ -136,7 +136,7 @@ func validate_loadout(loadout: AvatarLoadout) -> Dictionary: 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) + var asset: Dictionary = resolve(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 -- 2.52.0 From 5cfbcd8efdd5a6d58d4a053b07286948227d0537 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Tue, 18 Aug 2026 00:36:39 +0000 Subject: [PATCH 08/26] WIP checkpoint: Implement avatar loader and customization UI in Godot --- assets/avatars/catalog.json | 115 +++++++++++++++++ scripts/avatar/avatar_system.gd | 222 ++++++++++++++++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 assets/avatars/catalog.json create mode 100644 scripts/avatar/avatar_system.gd diff --git a/assets/avatars/catalog.json b/assets/avatars/catalog.json new file mode 100644 index 0000000..e69e50b --- /dev/null +++ b/assets/avatars/catalog.json @@ -0,0 +1,115 @@ +{ + "assets": { + "base_human_male_01": { + "type": "base_body", + "slot": "base_body", + "path": "res://assets/avatars/base_human_male_01.glb", + "version": "1.0.0", + "display_name": "Human Male Base" + }, + "head_cyber_01": { + "type": "body_part", + "slot": "head", + "path": "res://assets/avatars/head_cyber_01.glb", + "version": "1.0.0", + "display_name": "Cyber Head", + "mount_bone": "Head" + }, + "hair_mohawk_01": { + "type": "attachment", + "slot": "hair", + "path": "res://assets/avatars/hair_mohawk_01.glb", + "version": "1.0.0", + "display_name": "Mohawk", + "mount_bone": "HeadTop" + }, + "hair_ponytail_01": { + "type": "attachment", + "slot": "hair", + "path": "res://assets/avatars/hair_ponytail_01.glb", + "version": "1.0.0", + "display_name": "Ponytail", + "mount_bone": "HeadTop" + }, + "face_glasses_01": { + "type": "attachment", + "slot": "face", + "path": "res://assets/avatars/face_glasses_01.glb", + "version": "1.0.0", + "display_name": "Glasses", + "mount_bone": "Jaw" + }, + "chest_bandit_01": { + "type": "attachment", + "slot": "chest", + "path": "res://assets/avatars/chest_bandit_01.glb", + "version": "1.0.0", + "display_name": "Bandit Shirt", + "mount_bone": "Chest" + }, + "back_backpack_01": { + "type": "attachment", + "slot": "back", + "path": "res://assets/avatars/back_backpack_01.glb", + "version": "1.0.0", + "display_name": "Backpack", + "mount_bone": "Chest" + }, + "pants_cargo_01": { + "type": "attachment", + "slot": "pants", + "path": "res://assets/avatars/pants_cargo_01.glb", + "version": "1.0.0", + "display_name": "Cargo Pants", + "mount_bone": "Hips" + }, + "left_hand_glove_01": { + "type": "attachment", + "slot": "left_hand", + "path": "res://assets/avatars/left_hand_glove_01.glb", + "version": "1.0.0", + "display_name": "Left Glove", + "mount_bone": "LeftHand" + }, + "right_hand_glove_01": { + "type": "attachment", + "slot": "right_hand", + "path": "res://assets/avatars/right_hand_glove_01.glb", + "version": "1.0.0", + "display_name": "Right Glove", + "mount_bone": "RightHand" + }, + "left_foot_boot_01": { + "type": "attachment", + "slot": "left_foot", + "path": "res://assets/avatars/left_foot_boot_01.glb", + "version": "1.0.0", + "display_name": "Left Boot", + "mount_bone": "LeftFoot" + }, + "right_foot_boot_01": { + "type": "attachment", + "slot": "right_foot", + "path": "res://assets/avatars/right_foot_boot_01.glb", + "version": "1.0.0", + "display_name": "Right Boot", + "mount_bone": "RightFoot" + }, + "accessory_belt_01": { + "type": "attachment", + "slot": "accessory", + "path": "res://assets/avatars/accessory_belt_01.glb", + "version": "1.0.0", + "display_name": "Belt", + "mount_bone": "Root" + }, + "accessory_necklace_01": { + "type": "attachment", + "slot": "accessory", + "path": "res://assets/avatars/accessory_necklace_01.glb", + "version": "1.0.0", + "display_name": "Necklace", + "mount_bone": "Root" + } + } +} diff --git a/scripts/avatar/avatar_system.gd b/scripts/avatar/avatar_system.gd new file mode 100644 index 0000000..74e8bd0 --- /dev/null +++ b/scripts/avatar/avatar_system.gd @@ -0,0 +1,222 @@ +## AvatarSystem - Global avatar orchestrator (autoload singleton). +## +## Owns the AvatarCatalog, AvatarLoader, and the current player's AvatarLoadout. +## Provides a single entry point for the rest of the game to: +## - Load the asset catalog. +## - Load a player's avatar from a loadout. +## - Equip / unequip items and persist the resulting loadout. +## +## Registered as an autoload singleton named "AvatarSystem" in project.godot. + +extends Node + + +# --------------------------------------------------------------------------- +# Signals +# --------------------------------------------------------------------------- + +## Emitted when the catalog has finished loading. +signal catalog_loaded + +## Emitted when the catalog fails to load. +signal catalog_load_failed(error_message: String) + +## Emitted when the avatar has been fully assembled and is ready to render. +signal avatar_ready(avatar_node: Node3D) + +## Emitted when the loadout changes (after equip/unequip). +signal loadout_changed(loadout: AvatarLoadout) + + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + +## The asset catalog (id -> metadata + path). +var catalog: AvatarCatalog = null + +## The loader used to import GLB assets and assemble the avatar. +var loader: AvatarLoader = null + +## The current player's loadout. +var current_loadout: AvatarLoadout = null + +## The assembled avatar node (null until avatar_ready). +var avatar_node: Node3D = null + +## Whether the catalog has been loaded. +var is_catalog_loaded: bool = false + +## Whether an avatar is currently assembled. +var is_avatar_ready: bool = false + +## Default catalog path. +const DEFAULT_CATALOG_PATH := "res://assets/avatars/catalog.json" + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func _ready() -> void: + catalog = AvatarCatalog.new() + loader = AvatarLoader.new() + loader.init_loader(catalog) + add_child(loader) + + +## Load the asset catalog. Call this once at startup (or on demand). +func load_catalog(path: String = DEFAULT_CATALOG_PATH) -> Error: + var err := catalog.load_catalog(path) + if err == OK: + is_catalog_loaded = true + catalog_loaded.emit() + else: + catalog_load_failed.emit("Failed to load catalog: %s" % err) + return err + + +# --------------------------------------------------------------------------- +# Avatar assembly +# --------------------------------------------------------------------------- + +## Load and assemble an avatar from a loadout. +## Returns the assembled avatar Node3D on success, or null on failure. +func load_avatar(loadout: AvatarLoadout) -> Node3D: + if not is_catalog_loaded: + push_error("AvatarSystem: catalog not loaded; call load_catalog() first") + return null + + if loadout == null: + push_error("AvatarSystem: loadout is null") + return null + + # Validate the loadout against the catalog. + var validation := catalog.validate_loadout(loadout) + if not validation.valid: + push_error("AvatarSystem: invalid loadout: %s" % str(validation.errors)) + return null + + current_loadout = loadout + + # Reset any previous avatar. + loader.reset() + + # Load the base body. + var base_body_id := loadout.base_body + var base_node := loader.load_base_body(base_body_id) + if base_node == null: + push_error("AvatarSystem: failed to load base body '%s'" % base_body_id) + return null + + # Load each equipped attachment. + for slot_id in AvatarSlotRegistry.SLOT_IDS: + var slot_value = loadout.slots.get(slot_id) + + if slot_id == "accessory": + # Accessory is an array of asset ids. + if slot_value is Array: + for asset_id in slot_value: + if asset_id is String and not (asset_id as String).is_empty(): + loader.load_attachment(slot_id, asset_id) + else: + # Exclusive slot: single asset id or null. + if slot_value is String and not (slot_value as String).is_empty(): + loader.load_attachment(slot_id, slot_value) + + # Hide base meshes for equipped body-part slots. + for slot_id in AvatarSlotRegistry.BODY_PART_SLOTS: + var slot_value = loadout.slots.get(slot_id) + if slot_value is String and not (slot_value as String).is_empty(): + loader.set_base_mesh_visible(slot_id, false) + + avatar_node = loader.avatar_node + is_avatar_ready = avatar_node != null + if is_avatar_ready: + avatar_ready.emit(avatar_node) + + return avatar_node + + +## Equip an item into a slot and rebuild the avatar. +## Returns true on success. +func equip(slot_id: String, asset_id: String) -> bool: + if current_loadout == null: + push_error("AvatarSystem: no loadout loaded") + return false + + if not AvatarSlotRegistry.has_slot(slot_id): + push_error("AvatarSystem: unknown slot '%s'" % slot_id) + return false + + if not catalog.has_asset(asset_id): + push_error("AvatarSystem: unknown asset '%s'" % asset_id) + return false + + var asset := catalog.resolve(asset_id) + if asset.get("slot") != slot_id: + push_error("AvatarSystem: asset '%s' is for slot '%s', not '%s'" % [ + asset_id, asset.get("slot"), slot_id]) + return false + + if slot_id == "accessory": + if not current_loadout.add_accessory(asset_id): + push_error("AvatarSystem: could not add accessory '%s'" % asset_id) + return false + else: + current_loadout.set_equipment(slot_id, asset_id) + + loadout_changed.emit(current_loadout) + return true + + +## Unequip an item from a slot and rebuild the avatar. +## Returns true on success. +func unequip(slot_id: String, asset_id: String = "") -> bool: + if current_loadout == null: + push_error("AvatarSystem: no loadout loaded") + return false + + if not AvatarSlotRegistry.has_slot(slot_id): + push_error("AvatarSystem: unknown slot '%s'" % slot_id) + return false + + if slot_id == "accessory": + if asset_id.is_empty(): + push_error("AvatarSystem: accessory unequip requires an asset id") + return false + if not current_loadout.remove_accessory(asset_id): + push_error("AvatarSystem: accessory '%s' not equipped" % asset_id) + return false + else: + current_loadout.set_equipment(slot_id, "") + + loadout_changed.emit(current_loadout) + return true + + +## Serialize the current loadout to JSON. +func save_loadout() -> String: + if current_loadout == null: + return "" + return current_loadout.to_json() + + +## Load a loadout from a JSON string. +func load_loadout_from_json(json_string: String) -> AvatarLoadout: + var loadout := AvatarLoadout.from_json(json_string) + if not loadout.validate(): + push_error("AvatarSystem: invalid loadout JSON: %s" % str(loadout.validation_errors)) + return null + return loadout + + +## Get the list of available assets for a given slot. +func get_assets_for_slot(slot_id: String) -> Array[String]: + return catalog.get_assets_by_slot(slot_id) + + +## Get the display name for an asset id. +func get_asset_display_name(asset_id: String) -> String: + var asset := catalog.resolve(asset_id) + return asset.get("display_name", asset_id) -- 2.52.0 From 689c51e1790c0f2276df9bf910290b84a7ba9a7e Mon Sep 17 00:00:00 2001 From: coder-bot Date: Tue, 18 Aug 2026 00:47:59 +0000 Subject: [PATCH 09/26] WIP checkpoint: Implement avatar loader and customization UI in Godot --- scenes/avatar_customization.tscn | 149 +++++++ scenes/main.tscn | 44 +++ scripts/avatar/avatar_customization_ui.gd | 460 ++++++++++++++++++++++ scripts/main_menu.gd | 62 +++ 4 files changed, 715 insertions(+) create mode 100644 scenes/avatar_customization.tscn create mode 100644 scenes/main.tscn create mode 100644 scripts/avatar/avatar_customization_ui.gd create mode 100644 scripts/main_menu.gd diff --git a/scenes/avatar_customization.tscn b/scenes/avatar_customization.tscn new file mode 100644 index 0000000..9f7d592 --- /dev/null +++ b/scenes/avatar_customization.tscn @@ -0,0 +1,149 @@ +[gd_scene load_steps=3 format=3 uid="uid://avatar_customization"] + +[ext_resource type="Script" path="res://scripts/avatar/avatar_customization_ui.gd" id="1"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_main_panel"] +bg_color = Color(0.12, 0.12, 0.15, 1) + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_slot_panel"] +bg_color = Color(0.18, 0.18, 0.22, 1) +border_width_left = 2 +border_width_right = 2 +border_width_top = 2 +border_width_bottom = 2 +border_color = Color(0.3, 0.3, 0.35, 1) +corner_radius_top_left = 4 +corner_radius_top_right = 4 +corner_radius_bottom_right = 4 +corner_radius_bottom_left = 4 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_style"] +bg_color = Color(0.25, 0.25, 0.3, 1) +border_width_left = 1 +border_width_right = 1 +border_width_top = 1 +border_width_bottom = 1 +border_color = Color(0.4, 0.4, 0.45, 1) +corner_radius_top_left = 4 +corner_radius_top_right = 4 +corner_radius_bottom_right = 4 +corner_radius_bottom_left = 4 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_hover"] +bg_color = Color(0.35, 0.35, 0.4, 1) +border_width_left = 1 +border_width_right = 1 +border_width_top = 1 +border_width_bottom = 1 +border_color = Color(0.5, 0.5, 0.55, 1) +corner_radius_top_left = 4 +corner_radius_top_right = 4 +corner_radius_bottom_right = 4 +corner_radius_bottom_left = 4 + +[node name="AvatarCustomization" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script_extense = ExtResource("1") + +[node name="MainPanel" type="Panel" parent="."] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_styles/panel = SubResource("StyleBoxFlat_main_panel") + +[node name="VBox" type="VBoxContainer" parent="MainPanel"] +layout_mode = 1 +size_flags_horizontal = 3 +size_flags_vertical = 3 +theme_override_constants/separation = 8 + +[node name="TitleLabel" type="Label" parent="MainPanel/VBox"] +layout_mode = 1 +size_flags_horizontal = 3 +text = "Avatar Customization" +horizontal_alignment = 1 +theme_override_font_sizes/font_size = 24 + +[node name="HBox" type="HBoxContainer" parent="MainPanel/VBox"] +layout_mode = 1 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="AvatarViewport" type="ViewportContainer" parent="MainPanel/VBox/HBox"] +layout_mode = 1 +size_flags_horizontal = 3 +size_flags_vertical = 3 +expand_texture = true +stretch = true + +[node name="AvatarViewport" type="Viewport" parent="MainPanel/VBox/HBox/AvatarViewport"] +layout_mode = 3 +size_flags_horizontal = 3 +size_flags_vertical = 3 +width = 512 +height = 512 +transparent_bg = true + +[node name="Avatar3D" type="Node3D" parent="MainPanel/VBox/HBox/AvatarViewport/AvatarViewport"] +layout_mode = 3 + +[node name="Camera3D" type="Camera3D" parent="MainPanel/VBox/HBox/AvatarViewport/AvatarViewport"] +layout_mode = 3 +position = Vector3(0, 1.0, 3.0) +rotation = Vector3(-0.174533, 0, 0) + +[node name="DirectionalLight3D" type="DirectionalLight3D" parent="MainPanel/VBox/HBox/AvatarViewport/AvatarViewport"] +layout_mode = 3 +position = Vector3(2, 4, 2) +rotation = Vector3(0.785398, 0, 0) +light_color = Color(0.8, 0.8, 0.8, 1) +light_energy = 1.0 + +[node name="DirectionalLight3D2" type="DirectionalLight3D" parent="MainPanel/VBox/HBox/AvatarViewport/AvatarViewport"] +layout_mode = 3 +position = Vector3(-2, 2, -2) +rotation = Vector3(0.785398, 0, 0) +light_color = Color(0.4, 0.4, 0.5, 1) +light_energy = 0.5 + +[node name="SlotList" type="VBoxContainer" parent="MainPanel/VBox/HBox"] +layout_mode = 1 +size_flags_horizontal = 0 +size_flags_vertical = 3 +theme_override_constants/separation = 4 +custom_minimum_size = Vector2(280, 0) + +[node name="SlotHeader" type="Label" parent="MainPanel/VBox/HBox/SlotList"] +layout_mode = 1 +text = "SLOTS" +theme_override_font_sizes/font_size = 16 +font_color = Color(0.7, 0.7, 0.75, 1) + +[node name="ScrollContainer" type="ScrollContainer" parent="MainPanel/VBox/HBox/SlotList"] +layout_mode = 1 +size_flags_horizontal = 3 +size_flags_vertical = 3 +horizontal_mode = 2 +horizontal_scroll_mode = 2 + +[node name="SlotsVBox" type="VBoxContainer" parent="MainPanel/VBox/HBox/SlotList/ScrollContainer"] +layout_mode = 1 +size_flags_horizontal = 3 +size_flags_vertical = 3 +theme_override_constants/separation = 4 + +[node name="BackButton" type="Button" parent="MainPanel/VBox"] +layout_mode = 1 +size_flags_horizontal = 3 +text = "Back to Menu" +button_pressed = false +action_mode = 0 +custom_minimum_size = Vector2(0, 36) diff --git a/scenes/main.tscn b/scenes/main.tscn new file mode 100644 index 0000000..e390f2c --- /dev/null +++ b/scenes/main.tscn @@ -0,0 +1,44 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/main_menu.gd" id="1_menu"] + +[node name="MainMenu" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("1_menu") + +[node name="CenterContainer" type="CenterContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"] +layout_mode = 1 +theme_override_constants/separation = 16 +alignment = 1 + +[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"] +layout_mode = 1 +theme_override_font_sizes/font_size = 48 +text = "AGP Game" +horizontal_alignment = 1 + +[node name="CustomizeButton" type="Button" parent="CenterContainer/VBoxContainer"] +layout_mode = 1 +size_flags_horizontal = 2 +text = "Customize Avatar" + +[node name="PlayButton" type="Button" parent="CenterContainer/VBoxContainer"] +layout_mode = 1 +size_flags_horizontal = 2 +text = "Play" + +[node name="QuitButton" type="Button" parent="CenterContainer/VBoxContainer"] +layout_mode = 1 +size_flags_horizontal = 2 +text = "Quit" diff --git a/scripts/avatar/avatar_customization_ui.gd b/scripts/avatar/avatar_customization_ui.gd new file mode 100644 index 0000000..721be73 --- /dev/null +++ b/scripts/avatar/avatar_customization_ui.gd @@ -0,0 +1,460 @@ +## AvatarCustomizationUI +## +## The in-game avatar customization screen. Lets the player: +## - See a live preview of their avatar. +## - Browse and equip/unequip items per slot. +## - Adjust skin tone. +## - Save / load loadouts. +## +## The UI is built programmatically so it works without a pre-authored .tscn. +## It is a Control node that can be added to any scene. + +class_name AvatarCustomizationUI +extends Control + + +# --------------------------------------------------------------------------- +# Signals +# --------------------------------------------------------------------------- + +## Emitted when the player closes the customization screen. +signal closed + +## Emitted when the loadout changes (after equip/unequip/skin-tone). +signal loadout_changed(loadout: AvatarLoadout) + + +# --------------------------------------------------------------------------- +# Exports / config +# --------------------------------------------------------------------------- + +## Path to the asset catalog manifest. +@export var catalog_path: String = "res://assets/avatars/catalog.json" + +## The default base body to use when none is equipped. +@export var default_base_body: String = "base_human_male_01" + + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + +## The current loadout being edited. +var loadout: AvatarLoadout = null + +## The AvatarSystem singleton (set in _ready). +var _system: Node = null + +## The catalog (owned by AvatarSystem). +var _catalog: AvatarCatalog = null + +## The loader (owned by AvatarSystem). +var _loader: AvatarLoader = null + +## The visualizer that renders the preview. +var _visualizer: AvatarVisualizer = null + +## The preview viewport container. +var _preview_container: SubViewportContainer = null + +## The preview viewport. +var _preview_viewport: SubViewport = null + +## The preview camera. +var _preview_camera: Camera3D = null + +## The preview avatar root. +var _preview_root: Node3D = null + +## Currently selected slot id. +var _selected_slot: String = "" + +## UI node references. +var _slot_list: VBoxContainer = null +var _item_list: VBoxContainer = null +var _slot_label: Label = null +var _item_label: Label = null +var _skin_tone_slider: HSlider = null +var _status_label: Label = null + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func _ready() -> void: + # Grab the AvatarSystem autoload. + _system = get_node_or_null("/root/AvatarSystem") + if _system == null: + push_error("AvatarCustomizationUI: AvatarSystem autoload not found") + return + + _catalog = _system.catalog + _loader = _system.loader + + # Load the catalog if not already loaded. + if _catalog != null and not _catalog.is_loaded(): + _catalog.load_catalog(catalog_path) + + # Build the UI. + _build_ui() + + # Create a fresh loadout. + loadout = AvatarLoadout.new() + loadout.base_body = default_base_body + + # Initialize the visualizer. + _visualizer = AvatarVisualizer.new() + + # Populate the slot list. + _populate_slots() + + # Select the first slot. + if AvatarSlotRegistry.SLOT_IDS.size() > 0: + _select_slot(AvatarSlotRegistry.SLOT_IDS[0]) + + # Refresh the preview. + _refresh_preview() + + +# --------------------------------------------------------------------------- +# UI construction +# --------------------------------------------------------------------------- + +## Build the entire UI tree programmatically. +func _build_ui() -> void: + # Root layout: horizontal split (preview | controls). + var root_hbox := HBoxContainer.new() + root_hbox.name = "RootLayout" + root_hbox.set_anchors_preset(Control.PRESET_FULL_RECT) + root_hbox.add_theme_constant_override("separation", 16) + add_child(root_hbox) + + # --- Left: preview --- + var preview_panel := PanelContainer.new() + preview_panel.name = "PreviewPanel" + preview_panel.custom_minimum_size = Vector2(480, 0) + root_hbox.add_child(preview_panel) + + var preview_margin := MarginContainer.new() + preview_margin.add_theme_constant_override("margin_left", 8) + preview_margin.add_theme_constant_override("margin_right", 8) + preview_margin.add_theme_constant_override("margin_top", 8) + preview_margin.add_theme_constant_override("margin_bottom", 8) + preview_panel.add_child(preview_margin) + + _preview_container = SubViewportContainer.new() + _preview_container.name = "PreviewViewportContainer" + _preview_container.stretch = true + _preview_container.custom_minimum_size = Vector2(464, 560) + preview_margin.add_child(_preview_container) + + _preview_viewport = SubViewport.new() + _preview_viewport.name = "PreviewViewport" + _preview_viewport.size = Vector2i(464, 560) + _preview_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + _preview_container.add_child(_preview_viewport) + + # Camera for the preview. + _preview_camera = Camera3D.new() + _preview_camera.name = "PreviewCamera" + _preview_camera.position = Vector3(0, 1.0, 3.0) + _preview_camera.look_at(Vector3(0, 1.0, 0)) + _preview_viewport.add_child(_preview_camera) + + # Avatar root inside the viewport. + _preview_root = Node3D.new() + _preview_root.name = "AvatarPreviewRoot" + _preview_viewport.add_child(_preview_root) + + # A light so the avatar is visible. + var light := DirectionalLight3D.new() + light.name = "PreviewLight" + light.rotation_degrees = Vector3(-45, 30, 0) + _preview_viewport.add_child(light) + + # --- Right: controls --- + var controls_panel := PanelContainer.new() + controls_panel.name = "ControlsPanel" + controls_panel.custom_minimum_size = Vector2(520, 0) + root_hbox.add_child(controls_panel) + + var controls_margin := MarginContainer.new() + controls_margin.add_theme_constant_override("margin_left", 12) + controls_margin.add_theme_constant_override("margin_right", 12) + controls_margin.add_theme_constant_override("margin_top", 12) + controls_margin.add_theme_constant_override("margin_bottom", 12) + controls_panel.add_child(controls_margin) + + var controls_vbox := VBoxContainer.new() + controls_vbox.name = "Controls" + controls_vbox.add_theme_constant_override("separation", 8) + controls_margin.add_child(controls_vbox) + + # Title. + var title := Label.new() + title.text = "Avatar Customization" + title.add_theme_font_size_override("font_size", 24) + controls_vbox.add_child(title) + + # Slot selector. + var slot_header := Label.new() + slot_header.text = "Slots" + slot_header.add_theme_font_size_override("font_size", 16) + controls_vbox.add_child(slot_header) + + var slot_scroll := ScrollContainer.new() + slot_scroll.custom_minimum_size = Vector2(0, 160) + controls_vbox.add_child(slot_scroll) + + _slot_list = VBoxContainer.new() + _slot_list.name = "SlotList" + _slot_list.add_theme_constant_override("separation", 4) + slot_scroll.add_child(_slot_list) + + # Item selector for the selected slot. + var item_header := Label.new() + item_header.text = "Items" + item_header.add_theme_font_size_override("font_size", 16) + controls_vbox.add_child(item_header) + + var item_scroll := ScrollContainer.new() + item_scroll.custom_minimum_size = Vector2(0, 200) + controls_vbox.add_child(item_scroll) + + _item_list = VBoxContainer.new() + _item_list.name = "ItemList" + _item_list.add_theme_constant_override("separation", 4) + item_scroll.add_child(_item_list) + + # Skin tone control. + var skin_header := Label.new() + skin_header.text = "Skin Tone" + skin_header.add_theme_font_size_override("font_size", 16) + controls_vbox.add_child(skin_header) + + _skin_tone_slider = HSlider.new() + _skin_tone_slider.name = "SkinToneSlider" + _skin_tone_slider.min_value = 0.0 + _skin_tone_slider.max_value = 1.0 + _skin_tone_slider.step = 0.01 + _skin_tone_slider.value = 0.5 + _skin_tone_slider.custom_minimum_size = Vector2(0, 24) + _skin_tone_slider.value_changed.connect(_on_skin_tone_changed) + controls_vbox.add_child(_skin_tone_slider) + + # Status / error label. + _status_label = Label.new() + _status_label.name = "StatusLabel" + _status_label.text = "" + _status_label.add_theme_color_override("font_color", Color(1, 0.4, 0.4)) + _status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + controls_vbox.add_child(_status_label) + + # Action buttons. + var button_row := HBoxContainer.new() + button_row.add_theme_constant_override("separation", 8) + controls_vbox.add_child(button_row) + + var save_btn := Button.new() + save_btn.text = "Save Loadout" + save_btn.pressed.connect(_on_save_pressed) + button_row.add_child(save_btn) + + var load_btn := Button.new() + load_btn.text = "Load Loadout" + load_btn.pressed.connect(_on_load_pressed) + button_row.add_child(load_btn) + + var close_btn := Button.new() + close_btn.text = "Close" + close_btn.pressed.connect(_on_close_pressed) + button_row.add_child(close_btn) + + +# --------------------------------------------------------------------------- +# Slot / item population +# --------------------------------------------------------------------------- + +## Populate the slot list with buttons for each slot. +func _populate_slots() -> void: + if _slot_list == null: + return + + # Clear existing children. + for child in _slot_list.get_children(): + child.queue_free() + + for slot_id in AvatarSlotRegistry.SLOT_IDS: + var btn := Button.new() + btn.name = "Slot_%s" % slot_id + btn.text = slot_id + btn.toggle_mode = true + btn.pressed.connect(_on_slot_button_pressed.bind(slot_id)) + _slot_list.add_child(btn) + + +## Populate the item list for the currently selected slot. +func _populate_items() -> void: + if _item_list == null or _selected_slot.is_empty(): + return + + # Clear existing children. + for child in _item_list.get_children(): + child.queue_free() + + if _catalog == null: + return + + # Get assets for this slot. + var assets: Array[String] = _catalog.get_assets_by_slot(_selected_slot) + + # "None" button to unequip. + var none_btn := Button.new() + none_btn.text = "(none)" + none_btn.pressed.connect(_on_equip_pressed.bind("")) + _item_list.add_child(none_btn) + + for asset_id in assets: + var btn := Button.new() + btn.text = asset_id + btn.pressed.connect(_on_equip_pressed.bind(asset_id)) + _item_list.add_child(btn) + + +# --------------------------------------------------------------------------- +# Slot selection +# --------------------------------------------------------------------------- + +## Select a slot and refresh the item list. +func _select_slot(slot_id: String) -> void: + _selected_slot = slot_id + + # Update button toggle states. + if _slot_list != null: + for child in _slot_list.get_children(): + if child is Button: + var btn := child as Button + btn.button_pressed = (btn.name == "Slot_%s" % slot_id) + + _populate_items() + + +# --------------------------------------------------------------------------- +# Equip / unequip +# --------------------------------------------------------------------------- + +## Handle an equip/unequip action for the selected slot. +func _on_equip_pressed(asset_id: String) -> void: + if loadout == null or _selected_slot.is_empty(): + return + + if _selected_slot == "accessory": + if asset_id.is_empty(): + # Clear all accessories. + loadout.slots["accessory"] = [] + else: + loadout.add_accessory(asset_id) + else: + loadout.set_equipment(_selected_slot, asset_id) + + _refresh_preview() + loadout_changed.emit(loadout) + + +# --------------------------------------------------------------------------- +# Preview refresh +# --------------------------------------------------------------------------- + +## Rebuild the avatar preview from the current loadout. +func _refresh_preview() -> void: + if _visualizer == null or _preview_root == null: + return + + # Clear the preview root. + for child in _preview_root.get_children(): + child.queue_free() + + # Validate the loadout. + if loadout == null or loadout.base_body.is_empty(): + _status_label.text = "No base body selected." + return + + # Build the render tree. + _visualizer.load_loadout(loadout, _preview_root) + + # Show any validation errors. + if not loadout.validation_errors.is_empty(): + _status_label.text = "\n".join(loadout.validation_errors) + else: + _status_label.text = "" + + +# --------------------------------------------------------------------------- +# Skin tone +# --------------------------------------------------------------------------- + +func _on_skin_tone_changed(value: float) -> void: + if loadout == null: + return + # Map slider 0..1 to a skin tone color range. + var t := value + var skin := Color( + 0.6 + 0.3 * t, # R + 0.4 + 0.2 * t, # G + 0.3 + 0.15 * t, # B + 1.0 + ) + loadout.skin_tone = skin + _refresh_preview() + loadout_changed.emit(loadout) + + +# --------------------------------------------------------------------------- +# Save / load +# --------------------------------------------------------------------------- + +func _on_save_pressed() -> void: + if loadout == null: + return + var json := loadout.to_json() + var file := FileAccess.open("user://loadout.json", FileAccess.WRITE) + if file: + file.store_string(json) + file.close() + _status_label.text = "Loadout saved." + else: + _status_label.text = "Failed to save loadout." + + +func _on_load_pressed() -> void: + if not FileAccess.file_exists("user://loadout.json"): + _status_label.text = "No saved loadout found." + return + + var file := FileAccess.open("user://loadout.json", FileAccess.READ) + if file == null: + _status_label.text = "Failed to open saved loadout." + return + + var json := file.get_as_text() + file.close() + + var loaded := AvatarLoadout.from_json(json) + if not loaded.validation_errors.is_empty(): + _status_label.text = "Saved loadout invalid: %s" % "\n".join(loaded.validation_errors) + return + + loadout = loaded + _refresh_preview() + loadout_changed.emit(loadout) + _status_label.text = "Loadout loaded." + + +# --------------------------------------------------------------------------- +# Close +# --------------------------------------------------------------------------- + +func _on_close_pressed() -> void: + closed.emit() + queue_free() diff --git a/scripts/main_menu.gd b/scripts/main_menu.gd new file mode 100644 index 0000000..24ffb51 --- /dev/null +++ b/scripts/main_menu.gd @@ -0,0 +1,62 @@ +## MainMenu - Main menu scene with navigation to avatar customization. +## +## Provides buttons for: +## - Start Game +## - Customize Avatar (opens avatar customization UI) +## - Settings +## - Quit + +extends Control + + +# --------------------------------------------------------------------------- +# UI References +# --------------------------------------------------------------------------- + +@onready var customize_button: Button = $VBoxContainer/CustomizeAvatar +@onready var settings_button: Button = $VBoxContainer/Settings +@onready var quit_button: Button = $VBoxContainer/Quit + + +# --------------------------------------------------------------------------- +# Signals +# --------------------------------------------------------------------------- + +## Emitted when the player requests to customize their avatar. +signal open_customization + +## Emitted when the player requests to start the game. +signal start_game + +## Emitted when the player requests to open settings. +signal open_settings + +## Emitted when the player requests to quit. +signal quit_requested + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func _ready() -> void: + _customize_button.pressed.connect(_on_customize_pressed) + _settings_button.pressed.connect(_on_settings_pressed) + _quit_button.pressed.connect(_on_quit_pressed) + + +# --------------------------------------------------------------------------- +# Button Handlers +# --------------------------------------------------------------------------- + +func _on_customize_pressed() -> void: + open_customization.emit() + + +func _on_settings_pressed() -> void: + open_settings.emit() + + +func _on_quit_pressed() -> void: + quit_requested.emit() + get_tree().quit() -- 2.52.0 From 4c0af0357347b436d9ae1a6f9c78f8f24b92b88b Mon Sep 17 00:00:00 2001 From: coder-bot Date: Tue, 18 Aug 2026 13:01:14 +0000 Subject: [PATCH 10/26] WIP checkpoint: Implement avatar loader and customization UI in Godot --- scripts/avatar/avatar_loadout.gd | 30 +++++++++++++++- scripts/avatar/avatar_slot_registry.gd | 50 ++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/scripts/avatar/avatar_loadout.gd b/scripts/avatar/avatar_loadout.gd index 2ae4fa1..1594c6f 100644 --- a/scripts/avatar/avatar_loadout.gd +++ b/scripts/avatar/avatar_loadout.gd @@ -22,6 +22,9 @@ var slots: Dictionary = {} ## Validation errors found during the last validate() call. var validation_errors: Array[String] = [] +## Skin tone color override (default: transparent = use asset colors). +var skin_tone: Color = Color.TRANSPARENT + func _init() -> void: validation_errors = [] @@ -55,6 +58,17 @@ static func from_json(json_string: String) -> AvatarLoadout: else: loadout.validation_errors.append("Missing 'slots' in loadout JSON") + # Parse skin_tone + if root.has("skin_tone"): + var st = root["skin_tone"] + if typeof(st) == TYPE_DICTIONARY: + loadout.skin_tone = Color( + st.get("r", 0.0), + st.get("g", 0.0), + st.get("b", 0.0), + st.get("a", 1.0) + ) + return loadout @@ -62,7 +76,7 @@ static func from_json(json_string: String) -> AvatarLoadout: func to_json() -> String: var data := { "base_body": base_body, - "slots": {} + "slots": {}, } for slot_id in AvatarSlotRegistry.SLOT_IDS: @@ -71,6 +85,15 @@ func to_json() -> String: else: data.slots[slot_id] = slots.get(slot_id, null) + # Include skin_tone if set + if skin_tone != Color.TRANSPARENT: + data["skin_tone"] = { + "r": skin_tone.r, + "g": skin_tone.g, + "b": skin_tone.b, + "a": skin_tone.a, + } + return JSON.stringify(data, " ") @@ -132,6 +155,11 @@ func validate() -> bool: return validation_errors.is_empty() +## Convenience alias for validate(). +func is_valid() -> bool: + return validate() + + ## 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): diff --git a/scripts/avatar/avatar_slot_registry.gd b/scripts/avatar/avatar_slot_registry.gd index b7b9a2a..e466ba2 100644 --- a/scripts/avatar/avatar_slot_registry.gd +++ b/scripts/avatar/avatar_slot_registry.gd @@ -47,9 +47,32 @@ const ATTACHMENT_SLOTS: PackedStringArray = [ const MULTI_SLOT_IDS: PackedStringArray = ["accessory"] +## Metadata for a single slot. +class SlotDefinition: + ## The slot ID string (e.g. "head", "hair"). + var slot_key: String + ## "body_part" or "attachment". + var category: String + ## Primary mount bone name(s). + var mount_bones: PackedStringArray + ## Whether at most one item can occupy this slot. + var exclusive: bool + ## Whether this slot replaces a base mesh. + var is_body_part: bool + + func _init(p_slot_key: String, p_category: String, p_mount_bones: PackedStringArray, + p_exclusive: bool, p_is_body_part: bool) -> void: + slot_key = p_slot_key + category = p_category + mount_bones = p_mount_bones + exclusive = p_exclusive + is_body_part = p_is_body_part + + # Slot metadata lookup tables (indexed by SLOT_IDS position). # Each entry: { category, mount_bones, exclusive, is_body_part } var _slot_data: Dictionary = {} +var _slot_definitions: Array[SlotDefinition] = [] func _init() -> void: @@ -150,6 +173,18 @@ func _build_registry() -> void: }, } + # Build SlotDefinition objects. + for slot_id in SLOT_IDS: + var data = _slot_data[slot_id] + var def := SlotDefinition.new( + slot_id, + data["category"], + PackedStringArray(data["mount_bones"]), + data["exclusive"], + data["is_body_part"], + ) + _slot_definitions.append(def) + # --------------------------------------------------------------------------- # Public API @@ -165,6 +200,21 @@ static func has_slot(slot_id: String) -> bool: return slot_id in SLOT_IDS +## Returns the SlotDefinition for a slot, or null if unknown. +static func get_slot(slot_id: String) -> SlotDefinition: + if not has_slot(slot_id): + return null + for def in _slot_definitions: + if def.slot_key == slot_id: + return def + return null + + +## Returns all SlotDefinition objects in canonical order. +static func get_slot_definitions() -> Array[SlotDefinition]: + return _slot_definitions + + ## Returns the category for a slot ("body_part" or "attachment"). static func get_category(slot_id: String) -> String: if not has_slot(slot_id): -- 2.52.0 From 62f12e55db28fb4869f7008f99896394789b36eb Mon Sep 17 00:00:00 2001 From: coder-bot Date: Tue, 18 Aug 2026 13:09:57 +0000 Subject: [PATCH 11/26] WIP checkpoint: Implement avatar loader and customization UI in Godot --- scripts/avatar/avatar_catalog.gd | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/avatar/avatar_catalog.gd b/scripts/avatar/avatar_catalog.gd index 1eb2405..8264b24 100644 --- a/scripts/avatar/avatar_catalog.gd +++ b/scripts/avatar/avatar_catalog.gd @@ -41,12 +41,14 @@ func load_catalog(path: String = _catalog_path) -> Error: var json_text: String = file.get_as_text() file.close() - var json: JSONParseResult = JSON.parse_string(json_text) - if json == null: + var result: Variant = JSON.parse_string(json_text) + if result == null or not result is Dictionary: catalog_load_failed.emit("Invalid JSON in catalog: %s" % path) return ERR_PARSE_ERROR - if not json is Dictionary or not json.has("assets"): + var json: Dictionary = result as Dictionary + + if not json.has("assets"): catalog_load_failed.emit("Catalog missing 'assets' key") return ERR_INVALID_DATA @@ -56,6 +58,11 @@ func load_catalog(path: String = _catalog_path) -> Error: return OK +## Check if the catalog has been loaded. +func is_loaded() -> bool: + return _loaded + + ## Resolve an asset by its ID. ## Returns a Dictionary with keys: type, slot, path, version, etc. ## Returns null if the asset ID is not found. -- 2.52.0 From 61ee81a9744c5b889f7f5d8d07abae53e9f81499 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Tue, 18 Aug 2026 13:44:41 +0000 Subject: [PATCH 12/26] WIP checkpoint: fix script-attach typo, missing class_name, var name mismatch - scenes/avatar_customization.tscn: script_extense -> script (typo meant the customization UI script was never actually attached to the scene) - scripts/avatar/avatar_loadout.gd: add missing class_name AvatarLoadout (avatar_system.gd references the type everywhere; only this file among the avatar scripts lacked its class_name) - scripts/main_menu.gd: _ready() referenced _customize_button/_settings_button/ _quit_button but the @onready vars are named without the leading underscore --- scenes/avatar_customization.tscn | 2 +- scripts/avatar/avatar_loadout.gd | 1 + scripts/main_menu.gd | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/scenes/avatar_customization.tscn b/scenes/avatar_customization.tscn index 9f7d592..44c9f74 100644 --- a/scenes/avatar_customization.tscn +++ b/scenes/avatar_customization.tscn @@ -48,7 +48,7 @@ anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 -script_extense = ExtResource("1") +script = ExtResource("1") [node name="MainPanel" type="Panel" parent="."] layout_mode = 3 diff --git a/scripts/avatar/avatar_loadout.gd b/scripts/avatar/avatar_loadout.gd index 1594c6f..05582cc 100644 --- a/scripts/avatar/avatar_loadout.gd +++ b/scripts/avatar/avatar_loadout.gd @@ -5,6 +5,7 @@ ## ## See docs/architecture/avatar.md §5 for the loadout JSON format. +class_name AvatarLoadout extends RefCounted diff --git a/scripts/main_menu.gd b/scripts/main_menu.gd index 24ffb51..dd3c8ed 100644 --- a/scripts/main_menu.gd +++ b/scripts/main_menu.gd @@ -40,9 +40,9 @@ signal quit_requested # --------------------------------------------------------------------------- func _ready() -> void: - _customize_button.pressed.connect(_on_customize_pressed) - _settings_button.pressed.connect(_on_settings_pressed) - _quit_button.pressed.connect(_on_quit_pressed) + customize_button.pressed.connect(_on_customize_pressed) + settings_button.pressed.connect(_on_settings_pressed) + quit_button.pressed.connect(_on_quit_pressed) # --------------------------------------------------------------------------- -- 2.52.0 From ea6958c11ef2aa6373614c5810645e990029f101 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Tue, 18 Aug 2026 13:50:01 +0000 Subject: [PATCH 13/26] Recovered orphaned WIP before switching to a new task --- .godot/.gdignore | 1 + .godot/editor/filesystem_cache10 | 25 +++++++++ ...lding-3ad5c15c4f3250da0cc7c1af1770d85f.cfg | 5 ++ .godot/editor/project_metadata.cfg | 11 ++++ .godot/global_script_class_cache.cfg | 49 ++++++++++++++++++ .godot/uid_cache.bin | Bin 0 -> 408 bytes scripts/avatar/avatar_catalog.gd.uid | 1 + scripts/avatar/avatar_customization_ui.gd.uid | 1 + scripts/avatar/avatar_loader.gd.uid | 1 + scripts/avatar/avatar_loadout.gd.uid | 1 + scripts/avatar/avatar_slot_registry.gd.uid | 1 + scripts/avatar/avatar_system.gd.uid | 1 + scripts/avatar/avatar_visualizer.gd.uid | 1 + scripts/main_menu.gd.uid | 1 + 14 files changed, 99 insertions(+) create mode 100644 .godot/.gdignore create mode 100644 .godot/editor/filesystem_cache10 create mode 100644 .godot/editor/main.tscn-folding-3ad5c15c4f3250da0cc7c1af1770d85f.cfg create mode 100644 .godot/editor/project_metadata.cfg create mode 100644 .godot/global_script_class_cache.cfg create mode 100644 .godot/uid_cache.bin create mode 100644 scripts/avatar/avatar_catalog.gd.uid create mode 100644 scripts/avatar/avatar_customization_ui.gd.uid create mode 100644 scripts/avatar/avatar_loader.gd.uid create mode 100644 scripts/avatar/avatar_loadout.gd.uid create mode 100644 scripts/avatar/avatar_slot_registry.gd.uid create mode 100644 scripts/avatar/avatar_system.gd.uid create mode 100644 scripts/avatar/avatar_visualizer.gd.uid create mode 100644 scripts/main_menu.gd.uid diff --git a/.godot/.gdignore b/.godot/.gdignore new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/.godot/.gdignore @@ -0,0 +1 @@ + diff --git a/.godot/editor/filesystem_cache10 b/.godot/editor/filesystem_cache10 new file mode 100644 index 0000000..4c1d2de --- /dev/null +++ b/.godot/editor/filesystem_cache10 @@ -0,0 +1,25 @@ +63f7b34db8d8cdea90c76aacccf841ec +::res://::1787060633 +README.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: +::res://assets/::1787059805 +::res://assets/avatars/::1787059805 +catalog.json::JSON::-1::1787059805::0::1::::<><><>0<>0<><>:: +::res://docs/::1787013015 +::res://docs/architecture/::1787059805 +avatar.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: +content_moderation.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: +networking.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: +world.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: +::res://scenes/::1787060628 +avatar_customization.tscn::PackedScene::-1::1787060628::0::1::::<><><>0<>0<><>::res://scripts/avatar/avatar_customization_ui.gd +main.tscn::PackedScene::-1::1787059805::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd +::res://scripts/::1787060622 +main_menu.gd::GDScript::6526915941780468089::1787060622::0::1::::<>Control<><>0<>0<><>:: +::res://scripts/avatar/::1787060625 +avatar_catalog.gd::GDScript::5104367973301127782::1787059805::0::1::::AvatarCatalog<>RefCounted<><>0<>0<><>:: +avatar_customization_ui.gd::GDScript::6000242829080071607::1787059805::0::1::::AvatarCustomizationUI<>Control<><>0<>0<><>:: +avatar_loader.gd::GDScript::3645080687026000779::1787059805::0::1::::AvatarLoader<>Node<><>0<>0<><>:: +avatar_loadout.gd::GDScript::8780959424915557114::1787060625::0::1::::AvatarLoadout<>RefCounted<><>0<>0<><>:: +avatar_slot_registry.gd::GDScript::8212023476238218904::1787059805::0::1::::AvatarSlotRegistry<>RefCounted<><>0<>0<><>:: +avatar_system.gd::GDScript::150798117962859248::1787059805::0::1::::<>Node<><>0<>0<><>:: +avatar_visualizer.gd::GDScript::6283745077729863623::1787059805::0::1::::AvatarVisualizer<>Object<><>0<>0<><>:: diff --git a/.godot/editor/main.tscn-folding-3ad5c15c4f3250da0cc7c1af1770d85f.cfg b/.godot/editor/main.tscn-folding-3ad5c15c4f3250da0cc7c1af1770d85f.cfg new file mode 100644 index 0000000..82cb735 --- /dev/null +++ b/.godot/editor/main.tscn-folding-3ad5c15c4f3250da0cc7c1af1770d85f.cfg @@ -0,0 +1,5 @@ +[folding] + +node_unfolds=[NodePath("."), PackedStringArray("Layout"), NodePath("CenterContainer"), PackedStringArray("Layout"), NodePath("CenterContainer/VBoxContainer"), PackedStringArray("Layout", "Theme Overrides"), NodePath("CenterContainer/VBoxContainer/TitleLabel"), PackedStringArray("Layout", "Theme Overrides"), NodePath("CenterContainer/VBoxContainer/CustomizeButton"), PackedStringArray("Layout"), NodePath("CenterContainer/VBoxContainer/PlayButton"), PackedStringArray("Layout"), NodePath("CenterContainer/VBoxContainer/QuitButton"), PackedStringArray("Layout")] +resource_unfolds=[] +nodes_folded=[] diff --git a/.godot/editor/project_metadata.cfg b/.godot/editor/project_metadata.cfg new file mode 100644 index 0000000..e515609 --- /dev/null +++ b/.godot/editor/project_metadata.cfg @@ -0,0 +1,11 @@ +[game_view] + +select_mode=0 + +[editor_metadata] + +executable_path="/home/daddy/godot/godot" + +[recent_files] + +scenes=["res://scenes/main.tscn"] diff --git a/.godot/global_script_class_cache.cfg b/.godot/global_script_class_cache.cfg new file mode 100644 index 0000000..4d061d4 --- /dev/null +++ b/.godot/global_script_class_cache.cfg @@ -0,0 +1,49 @@ +list=[{ +"base": &"RefCounted", +"class": &"AvatarCatalog", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/avatar/avatar_catalog.gd" +}, { +"base": &"Control", +"class": &"AvatarCustomizationUI", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/avatar/avatar_customization_ui.gd" +}, { +"base": &"Node", +"class": &"AvatarLoader", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/avatar/avatar_loader.gd" +}, { +"base": &"RefCounted", +"class": &"AvatarLoadout", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/avatar/avatar_loadout.gd" +}, { +"base": &"RefCounted", +"class": &"AvatarSlotRegistry", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/avatar/avatar_slot_registry.gd" +}, { +"base": &"Object", +"class": &"AvatarVisualizer", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/avatar/avatar_visualizer.gd" +}] diff --git a/.godot/uid_cache.bin b/.godot/uid_cache.bin new file mode 100644 index 0000000000000000000000000000000000000000..ed74706098642b71908e07978e5d66bf8af54837 GIT binary patch literal 408 zcmd;JU|>k=s$0Dv`kI>>kX@8oY^ASXoLrPyP*SX)Se96lSOlfwlYyL^{B*tal& literal 0 HcmV?d00001 diff --git a/scripts/avatar/avatar_catalog.gd.uid b/scripts/avatar/avatar_catalog.gd.uid new file mode 100644 index 0000000..b042805 --- /dev/null +++ b/scripts/avatar/avatar_catalog.gd.uid @@ -0,0 +1 @@ +uid://ceyugve7rjxqs diff --git a/scripts/avatar/avatar_customization_ui.gd.uid b/scripts/avatar/avatar_customization_ui.gd.uid new file mode 100644 index 0000000..97540ba --- /dev/null +++ b/scripts/avatar/avatar_customization_ui.gd.uid @@ -0,0 +1 @@ +uid://crqtef5rifgrv diff --git a/scripts/avatar/avatar_loader.gd.uid b/scripts/avatar/avatar_loader.gd.uid new file mode 100644 index 0000000..e1b1fe1 --- /dev/null +++ b/scripts/avatar/avatar_loader.gd.uid @@ -0,0 +1 @@ +uid://br6x7f83dkrw2 diff --git a/scripts/avatar/avatar_loadout.gd.uid b/scripts/avatar/avatar_loadout.gd.uid new file mode 100644 index 0000000..322614c --- /dev/null +++ b/scripts/avatar/avatar_loadout.gd.uid @@ -0,0 +1 @@ +uid://dxdtebl4upeny diff --git a/scripts/avatar/avatar_slot_registry.gd.uid b/scripts/avatar/avatar_slot_registry.gd.uid new file mode 100644 index 0000000..484c80b --- /dev/null +++ b/scripts/avatar/avatar_slot_registry.gd.uid @@ -0,0 +1 @@ +uid://do873d38pqhwa diff --git a/scripts/avatar/avatar_system.gd.uid b/scripts/avatar/avatar_system.gd.uid new file mode 100644 index 0000000..5dbe1f6 --- /dev/null +++ b/scripts/avatar/avatar_system.gd.uid @@ -0,0 +1 @@ +uid://cfbvgfisbxbk diff --git a/scripts/avatar/avatar_visualizer.gd.uid b/scripts/avatar/avatar_visualizer.gd.uid new file mode 100644 index 0000000..c1daa51 --- /dev/null +++ b/scripts/avatar/avatar_visualizer.gd.uid @@ -0,0 +1 @@ +uid://cvr5mmbv2worn diff --git a/scripts/main_menu.gd.uid b/scripts/main_menu.gd.uid new file mode 100644 index 0000000..8c39923 --- /dev/null +++ b/scripts/main_menu.gd.uid @@ -0,0 +1 @@ +uid://cy8xl74x4qddx -- 2.52.0 From 803405aba429c1adeacc30aa86b86e1f9db672ac Mon Sep 17 00:00:00 2001 From: coder-bot Date: Tue, 18 Aug 2026 14:03:07 +0000 Subject: [PATCH 14/26] WIP checkpoint: Implement avatar loader and customization UI in Godot --- scenes/main_menu.tscn | 44 +++++++++++++++++++++++++++++++++++++++++++ scripts/main_menu.gd | 18 +++++++----------- 2 files changed, 51 insertions(+), 11 deletions(-) create mode 100644 scenes/main_menu.tscn diff --git a/scenes/main_menu.tscn b/scenes/main_menu.tscn new file mode 100644 index 0000000..e390f2c --- /dev/null +++ b/scenes/main_menu.tscn @@ -0,0 +1,44 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/main_menu.gd" id="1_menu"] + +[node name="MainMenu" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("1_menu") + +[node name="CenterContainer" type="CenterContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"] +layout_mode = 1 +theme_override_constants/separation = 16 +alignment = 1 + +[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"] +layout_mode = 1 +theme_override_font_sizes/font_size = 48 +text = "AGP Game" +horizontal_alignment = 1 + +[node name="CustomizeButton" type="Button" parent="CenterContainer/VBoxContainer"] +layout_mode = 1 +size_flags_horizontal = 2 +text = "Customize Avatar" + +[node name="PlayButton" type="Button" parent="CenterContainer/VBoxContainer"] +layout_mode = 1 +size_flags_horizontal = 2 +text = "Play" + +[node name="QuitButton" type="Button" parent="CenterContainer/VBoxContainer"] +layout_mode = 1 +size_flags_horizontal = 2 +text = "Quit" diff --git a/scripts/main_menu.gd b/scripts/main_menu.gd index dd3c8ed..2da647f 100644 --- a/scripts/main_menu.gd +++ b/scripts/main_menu.gd @@ -1,9 +1,8 @@ ## MainMenu - Main menu scene with navigation to avatar customization. ## ## Provides buttons for: -## - Start Game ## - Customize Avatar (opens avatar customization UI) -## - Settings +## - Play ## - Quit extends Control @@ -13,9 +12,9 @@ extends Control # UI References # --------------------------------------------------------------------------- -@onready var customize_button: Button = $VBoxContainer/CustomizeAvatar -@onready var settings_button: Button = $VBoxContainer/Settings -@onready var quit_button: Button = $VBoxContainer/Quit +@onready var customize_button: Button = $CenterContainer/VBoxContainer/CustomizeButton +@onready var play_button: Button = $CenterContainer/VBoxContainer/PlayButton +@onready var quit_button: Button = $CenterContainer/VBoxContainer/QuitButton # --------------------------------------------------------------------------- @@ -28,9 +27,6 @@ signal open_customization ## Emitted when the player requests to start the game. signal start_game -## Emitted when the player requests to open settings. -signal open_settings - ## Emitted when the player requests to quit. signal quit_requested @@ -41,7 +37,7 @@ signal quit_requested func _ready() -> void: customize_button.pressed.connect(_on_customize_pressed) - settings_button.pressed.connect(_on_settings_pressed) + play_button.pressed.connect(_on_play_pressed) quit_button.pressed.connect(_on_quit_pressed) @@ -53,8 +49,8 @@ func _on_customize_pressed() -> void: open_customization.emit() -func _on_settings_pressed() -> void: - open_settings.emit() +func _on_play_pressed() -> void: + start_game.emit() func _on_quit_pressed() -> void: -- 2.52.0 From dcaaf96d81775ec8973704e717f9d18a4c63092a Mon Sep 17 00:00:00 2001 From: coder-bot Date: Tue, 18 Aug 2026 14:47:08 +0000 Subject: [PATCH 15/26] WIP checkpoint: fix static registry, uint32 type, GLTF API calls, ARRAY_BONES, missing slot handler - avatar_slot_registry.gd: _slot_data/_slot_definitions made static (every caller uses AvatarSlotRegistry.method() on the class, never an instance, so the old instance _init() populating them was never invoked); moved registry build to static func _static_init() - avatar_catalog.gd: compute_loadout_hash() return type uint32 -> int (GDScript has no fixed-width uint type) - avatar_loader.gd: GLTFDocument.parse() (doesn't exist) -> the real append_from_buffer()+generate_scene() API; Error.parse_error() (doesn't exist) -> error_string(); Mesh.ARRAY_JOINTS (doesn't exist) -> Mesh.ARRAY_BONES; removed a dead 'mesh is MultiMesh' check that can never be true (MeshInstance3D.mesh is always Mesh, never MultiMesh); fixed _merge_node_into_avatar to recurse into a Skeleton3D's children (skinned meshes are commonly children of the skeleton in a GLB export, the old code returned before ever reaching them) - avatar_customization_ui.gd: added the missing _on_slot_button_pressed handler (referenced by a button connect, never defined -- just calls the existing _select_slot()) Verified: godot --headless --import + --quit is fully clean, and the avatar_customization.tscn scene now instantiates successfully. Remaining, scoped separately: avatar_visualizer.gd calls a AvatarLoader.load_slot() static async API and SlotItem/SlotResult types that don't exist -- AvatarLoader's real API (matching the project's own avatar.md spec: one canonical skeleton, attachments retargeted onto it) is instance-based load_base_body()/load_attachment(). AvatarVisualizer needs rewriting to use that real API; it's the one out of step with spec, not AvatarLoader. --- scripts/avatar/avatar_catalog.gd | 5 +- scripts/avatar/avatar_customization_ui.gd | 5 ++ scripts/avatar/avatar_loader.gd | 58 ++++++++++++----------- scripts/avatar/avatar_slot_registry.gd | 13 +++-- 4 files changed, 46 insertions(+), 35 deletions(-) diff --git a/scripts/avatar/avatar_catalog.gd b/scripts/avatar/avatar_catalog.gd index 8264b24..898bc85 100644 --- a/scripts/avatar/avatar_catalog.gd +++ b/scripts/avatar/avatar_catalog.gd @@ -156,8 +156,9 @@ func validate_loadout(loadout: AvatarLoadout) -> Dictionary: ## Compute a hash of the loadout for change detection. -## Returns a uint32 hash. -static func compute_loadout_hash(loadout: AvatarLoadout) -> uint32: +## Returns a 32-bit hash value (GDScript has no fixed-width uint type — int +## is 64-bit signed, the value is just constrained to 32 bits of range). +static func compute_loadout_hash(loadout: AvatarLoadout) -> int: var hasher = HashingContext.new() hasher.start(HashingContext.HASH_SHA256) diff --git a/scripts/avatar/avatar_customization_ui.gd b/scripts/avatar/avatar_customization_ui.gd index 721be73..1602d74 100644 --- a/scripts/avatar/avatar_customization_ui.gd +++ b/scripts/avatar/avatar_customization_ui.gd @@ -326,6 +326,11 @@ func _populate_items() -> void: # Slot selection # --------------------------------------------------------------------------- +## Handle a slot button press. +func _on_slot_button_pressed(slot_id: String) -> void: + _select_slot(slot_id) + + ## Select a slot and refresh the item list. func _select_slot(slot_id: String) -> void: _selected_slot = slot_id diff --git a/scripts/avatar/avatar_loader.gd b/scripts/avatar/avatar_loader.gd index ad18e55..f104e85 100644 --- a/scripts/avatar/avatar_loader.gd +++ b/scripts/avatar/avatar_loader.gd @@ -305,28 +305,26 @@ func _parse_glb_as_avatar(glb_data: PackedByteArray, path: String) -> Node3D: 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, "", "") + var parse_result := gltf_document.append_from_buffer(glb_data, "", gltf_state) if parse_result != OK: - push_error("GLB parse error: %s" % Error.parse_error(parse_result)) + push_error("GLB parse error: %s" % error_string(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) + # Generate the actual scene tree from the parsed GLTF state. + var scene_root := gltf_document.generate_scene(gltf_state) + if scene_root == null: + push_error("Failed to generate scene from 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) + # Merge GLB nodes into our avatar structure (recurses into children). + if scene_root is Node3D: + _merge_node_into_avatar(scene_root, avatar, body_meshes, + attach_points, attachments_container) return avatar @@ -338,22 +336,21 @@ func _parse_glb_as_attachment(glb_data: PackedByteArray, path: String, var gltf_document := GLTFDocument.new() var gltf_state := GLTFState.new() - var parse_result := gltf_document.parse(glb_data, gltf_state, "", "") + var parse_result := gltf_document.append_from_buffer(glb_data, "", gltf_state) if parse_result != OK: - push_error("GLB parse error: %s" % Error.parse_error(parse_result)) + push_error("GLB parse error: %s" % error_string(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) + var scene_root := gltf_document.generate_scene(gltf_state) + if scene_root == null: + push_error("Failed to generate scene from 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()) + if scene_root is Node3D: + attachment.add_child(scene_root.duplicate()) return attachment @@ -392,6 +389,13 @@ func _merge_node_into_avatar(node: Node3D, avatar: Node3D, 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) + # Skinned meshes are commonly children of the skeleton node in a GLB + # export, not siblings — fall through to the recursion below instead + # of returning, so they still get merged in. + for child in src_skel.get_children(): + if child is Node3D: + _merge_node_into_avatar(child, avatar, body_meshes, + attach_points, attachments_container) return # Identify attachment point nodes (empty nodes named attach_*). @@ -468,18 +472,16 @@ func _retarget_skin(attachment_node: Node3D) -> void: if mesh == null: continue - # Handle MultiMeshInstance3D. - if mesh is MultiMesh: - # MultiMesh doesn't have skin weights per-vertex in the same way. - continue - + # Note: MeshInstance3D.mesh is always a Mesh, never a MultiMesh — + # MultiMesh skinning goes through MultiMeshInstance3D.multimesh, + # a completely separate node/property, not reachable here. 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 joints_array := arrays[Mesh.ARRAY_BONES] as PackedInt32Array var weights_array := arrays[Mesh.ARRAY_WEIGHTS] as PackedFloat32Array if joints_array.is_empty(): @@ -494,10 +496,10 @@ func _retarget_skin(attachment_node: Node3D) -> void: new_joints.append(0) else: var bone_name := skeleton.get_bone_name(old_joint) - var new_idx := _bone_index_map.get(bone_name, 0) + var new_idx: int = _bone_index_map.get(bone_name, 0) new_joints.append(new_idx) - arrays[Mesh.ARRAY_JOINTS] = new_joints + arrays[Mesh.ARRAY_BONES] = new_joints arr_mesh.surface_set_arrays(surface_idx, arrays) diff --git a/scripts/avatar/avatar_slot_registry.gd b/scripts/avatar/avatar_slot_registry.gd index e466ba2..c2b59f4 100644 --- a/scripts/avatar/avatar_slot_registry.gd +++ b/scripts/avatar/avatar_slot_registry.gd @@ -69,17 +69,20 @@ class SlotDefinition: is_body_part = p_is_body_part -# Slot metadata lookup tables (indexed by SLOT_IDS position). +# Slot metadata lookup tables (indexed by SLOT_IDS position). Static: every +# caller uses AvatarSlotRegistry.method(...) directly on the class, never on +# an instance, so this data has to be built once at class-load time via +# _static_init() rather than an instance _init() that's never invoked. # Each entry: { category, mount_bones, exclusive, is_body_part } -var _slot_data: Dictionary = {} -var _slot_definitions: Array[SlotDefinition] = [] +static var _slot_data: Dictionary = {} +static var _slot_definitions: Array[SlotDefinition] = [] -func _init() -> void: +static func _static_init() -> void: _build_registry() -func _build_registry() -> void: +static func _build_registry() -> void: _slot_data = { "head": { "category": "body_part", -- 2.52.0 From ddfe5dfaad6ad043f091b3a0c2e6d7ad08f99134 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Tue, 18 Aug 2026 15:54:33 +0000 Subject: [PATCH 16/26] Recovered orphaned WIP before switching to a new task --- .godot/editor/filesystem_cache10 | 37 ++++++++++++++++---------------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/.godot/editor/filesystem_cache10 b/.godot/editor/filesystem_cache10 index 4c1d2de..079b9bd 100644 --- a/.godot/editor/filesystem_cache10 +++ b/.godot/editor/filesystem_cache10 @@ -1,25 +1,26 @@ 63f7b34db8d8cdea90c76aacccf841ec -::res://::1787060633 +::res://::1787064334 README.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: -::res://assets/::1787059805 -::res://assets/avatars/::1787059805 -catalog.json::JSON::-1::1787059805::0::1::::<><><>0<>0<><>:: +::res://assets/::1787062398 +::res://assets/avatars/::1787062398 +catalog.json::JSON::-1::1787062398::0::1::::<><><>0<>0<><>:: ::res://docs/::1787013015 -::res://docs/architecture/::1787059805 +::res://docs/architecture/::1787062398 avatar.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: content_moderation.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: networking.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: world.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: -::res://scenes/::1787060628 -avatar_customization.tscn::PackedScene::-1::1787060628::0::1::::<><><>0<>0<><>::res://scripts/avatar/avatar_customization_ui.gd -main.tscn::PackedScene::-1::1787059805::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd -::res://scripts/::1787060622 -main_menu.gd::GDScript::6526915941780468089::1787060622::0::1::::<>Control<><>0<>0<><>:: -::res://scripts/avatar/::1787060625 -avatar_catalog.gd::GDScript::5104367973301127782::1787059805::0::1::::AvatarCatalog<>RefCounted<><>0<>0<><>:: -avatar_customization_ui.gd::GDScript::6000242829080071607::1787059805::0::1::::AvatarCustomizationUI<>Control<><>0<>0<><>:: -avatar_loader.gd::GDScript::3645080687026000779::1787059805::0::1::::AvatarLoader<>Node<><>0<>0<><>:: -avatar_loadout.gd::GDScript::8780959424915557114::1787060625::0::1::::AvatarLoadout<>RefCounted<><>0<>0<><>:: -avatar_slot_registry.gd::GDScript::8212023476238218904::1787059805::0::1::::AvatarSlotRegistry<>RefCounted<><>0<>0<><>:: -avatar_system.gd::GDScript::150798117962859248::1787059805::0::1::::<>Node<><>0<>0<><>:: -avatar_visualizer.gd::GDScript::6283745077729863623::1787059805::0::1::::AvatarVisualizer<>Object<><>0<>0<><>:: +::res://scenes/::1787062398 +avatar_customization.tscn::PackedScene::-1::1787062398::0::1::::<><><>0<>0<><>::res://scripts/avatar/avatar_customization_ui.gd +main.tscn::PackedScene::-1::1787062398::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd +main_menu.tscn::PackedScene::-1::1787062398::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd +::res://scripts/::1787064307 +main_menu.gd::GDScript::6526915941780468089::1787062398::0::1::::<>Control<><>0<>0<><>:: +::res://scripts/avatar/::1787064326 +avatar_catalog.gd::GDScript::5104367973301127782::1787064159::0::1::::AvatarCatalog<>RefCounted<><>0<>0<><>:: +avatar_customization_ui.gd::GDScript::6000242829080071607::1787064326::0::1::::AvatarCustomizationUI<>Control<><>0<>0<><>:: +avatar_loader.gd::GDScript::3645080687026000779::1787064282::0::1::::AvatarLoader<>Node<><>0<>0<><>:: +avatar_loadout.gd::GDScript::8780959424915557114::1787062398::0::1::::AvatarLoadout<>RefCounted<><>0<>0<><>:: +avatar_slot_registry.gd::GDScript::8212023476238218904::1787064148::0::1::::AvatarSlotRegistry<>RefCounted<><>0<>0<><>:: +avatar_system.gd::GDScript::150798117962859248::1787062398::0::1::::<>Node<><>0<>0<><>:: +avatar_visualizer.gd::GDScript::6283745077729863623::1787062398::0::1::::AvatarVisualizer<>Object<><>0<>0<><>:: -- 2.52.0 From 36fd40692549cecbd949fc13675d42aaf0d8b891 Mon Sep 17 00:00:00 2001 From: architect-bot Date: Tue, 18 Aug 2026 16:44:02 +0000 Subject: [PATCH 17/26] WIP checkpoint: Implement avatar loader and customization UI in Godot --- .godot/editor/filesystem_cache10 | 50 ++++++++++++++++---------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/.godot/editor/filesystem_cache10 b/.godot/editor/filesystem_cache10 index 079b9bd..6647831 100644 --- a/.godot/editor/filesystem_cache10 +++ b/.godot/editor/filesystem_cache10 @@ -1,26 +1,26 @@ 63f7b34db8d8cdea90c76aacccf841ec -::res://::1787064334 -README.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: -::res://assets/::1787062398 -::res://assets/avatars/::1787062398 -catalog.json::JSON::-1::1787062398::0::1::::<><><>0<>0<><>:: -::res://docs/::1787013015 -::res://docs/architecture/::1787062398 -avatar.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: -content_moderation.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: -networking.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: -world.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: -::res://scenes/::1787062398 -avatar_customization.tscn::PackedScene::-1::1787062398::0::1::::<><><>0<>0<><>::res://scripts/avatar/avatar_customization_ui.gd -main.tscn::PackedScene::-1::1787062398::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd -main_menu.tscn::PackedScene::-1::1787062398::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd -::res://scripts/::1787064307 -main_menu.gd::GDScript::6526915941780468089::1787062398::0::1::::<>Control<><>0<>0<><>:: -::res://scripts/avatar/::1787064326 -avatar_catalog.gd::GDScript::5104367973301127782::1787064159::0::1::::AvatarCatalog<>RefCounted<><>0<>0<><>:: -avatar_customization_ui.gd::GDScript::6000242829080071607::1787064326::0::1::::AvatarCustomizationUI<>Control<><>0<>0<><>:: -avatar_loader.gd::GDScript::3645080687026000779::1787064282::0::1::::AvatarLoader<>Node<><>0<>0<><>:: -avatar_loadout.gd::GDScript::8780959424915557114::1787062398::0::1::::AvatarLoadout<>RefCounted<><>0<>0<><>:: -avatar_slot_registry.gd::GDScript::8212023476238218904::1787064148::0::1::::AvatarSlotRegistry<>RefCounted<><>0<>0<><>:: -avatar_system.gd::GDScript::150798117962859248::1787062398::0::1::::<>Node<><>0<>0<><>:: -avatar_visualizer.gd::GDScript::6283745077729863623::1787062398::0::1::::AvatarVisualizer<>Object<><>0<>0<><>:: +::res://::1787071349 +README.md::TextFile::-1::1787071349::0::1::::<><><>0<>0<><>:: +::res://assets/::1787071349 +::res://assets/avatars/::1787071349 +catalog.json::JSON::-1::1787071349::0::1::::<><><>0<>0<><>:: +::res://docs/::1787071349 +::res://docs/architecture/::1787071349 +avatar.md::TextFile::-1::1787071349::0::1::::<><><>0<>0<><>:: +content_moderation.md::TextFile::-1::1787071349::0::1::::<><><>0<>0<><>:: +networking.md::TextFile::-1::1787071349::0::1::::<><><>0<>0<><>:: +world.md::TextFile::-1::1787071349::0::1::::<><><>0<>0<><>:: +::res://scenes/::1787071349 +avatar_customization.tscn::PackedScene::-1::1787071349::0::1::::<><><>0<>0<><>::res://scripts/avatar/avatar_customization_ui.gd +main.tscn::PackedScene::-1::1787071349::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd +main_menu.tscn::PackedScene::-1::1787071349::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd +::res://scripts/::1787071349 +main_menu.gd::GDScript::6526915941780468089::1787071349::0::1::::<>Control<><>0<>0<><>:: +::res://scripts/avatar/::1787071349 +avatar_catalog.gd::GDScript::5104367973301127782::1787071349::0::1::::AvatarCatalog<>RefCounted<><>0<>0<><>:: +avatar_customization_ui.gd::GDScript::6000242829080071607::1787071349::0::1::::AvatarCustomizationUI<>Control<><>0<>0<><>:: +avatar_loader.gd::GDScript::3645080687026000779::1787071349::0::1::::AvatarLoader<>Node<><>0<>0<><>:: +avatar_loadout.gd::GDScript::8780959424915557114::1787071349::0::1::::AvatarLoadout<>RefCounted<><>0<>0<><>:: +avatar_slot_registry.gd::GDScript::8212023476238218904::1787071349::0::1::::AvatarSlotRegistry<>RefCounted<><>0<>0<><>:: +avatar_system.gd::GDScript::150798117962859248::1787071349::0::1::::<>Node<><>0<>0<><>:: +avatar_visualizer.gd::GDScript::6283745077729863623::1787071349::0::1::::AvatarVisualizer<>Object<><>0<>0<><>:: -- 2.52.0 From f5a7162285d78270b90d93ff1e0b58b0ac16c551 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Wed, 19 Aug 2026 14:56:44 +0000 Subject: [PATCH 18/26] WIP checkpoint: Implement avatar loader and customization UI in Godot --- .godot/editor/filesystem_cache10 | 50 ++++++++++++++++---------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/.godot/editor/filesystem_cache10 b/.godot/editor/filesystem_cache10 index 6647831..502eb94 100644 --- a/.godot/editor/filesystem_cache10 +++ b/.godot/editor/filesystem_cache10 @@ -1,26 +1,26 @@ 63f7b34db8d8cdea90c76aacccf841ec -::res://::1787071349 -README.md::TextFile::-1::1787071349::0::1::::<><><>0<>0<><>:: -::res://assets/::1787071349 -::res://assets/avatars/::1787071349 -catalog.json::JSON::-1::1787071349::0::1::::<><><>0<>0<><>:: -::res://docs/::1787071349 -::res://docs/architecture/::1787071349 -avatar.md::TextFile::-1::1787071349::0::1::::<><><>0<>0<><>:: -content_moderation.md::TextFile::-1::1787071349::0::1::::<><><>0<>0<><>:: -networking.md::TextFile::-1::1787071349::0::1::::<><><>0<>0<><>:: -world.md::TextFile::-1::1787071349::0::1::::<><><>0<>0<><>:: -::res://scenes/::1787071349 -avatar_customization.tscn::PackedScene::-1::1787071349::0::1::::<><><>0<>0<><>::res://scripts/avatar/avatar_customization_ui.gd -main.tscn::PackedScene::-1::1787071349::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd -main_menu.tscn::PackedScene::-1::1787071349::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd -::res://scripts/::1787071349 -main_menu.gd::GDScript::6526915941780468089::1787071349::0::1::::<>Control<><>0<>0<><>:: -::res://scripts/avatar/::1787071349 -avatar_catalog.gd::GDScript::5104367973301127782::1787071349::0::1::::AvatarCatalog<>RefCounted<><>0<>0<><>:: -avatar_customization_ui.gd::GDScript::6000242829080071607::1787071349::0::1::::AvatarCustomizationUI<>Control<><>0<>0<><>:: -avatar_loader.gd::GDScript::3645080687026000779::1787071349::0::1::::AvatarLoader<>Node<><>0<>0<><>:: -avatar_loadout.gd::GDScript::8780959424915557114::1787071349::0::1::::AvatarLoadout<>RefCounted<><>0<>0<><>:: -avatar_slot_registry.gd::GDScript::8212023476238218904::1787071349::0::1::::AvatarSlotRegistry<>RefCounted<><>0<>0<><>:: -avatar_system.gd::GDScript::150798117962859248::1787071349::0::1::::<>Node<><>0<>0<><>:: -avatar_visualizer.gd::GDScript::6283745077729863623::1787071349::0::1::::AvatarVisualizer<>Object<><>0<>0<><>:: +::res://::1787150799 +README.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: +::res://assets/::1787150789 +::res://assets/avatars/::1787150789 +catalog.json::JSON::-1::1787150789::0::1::::<><><>0<>0<><>:: +::res://docs/::1787150789 +::res://docs/architecture/::1787150789 +avatar.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: +content_moderation.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: +networking.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: +world.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: +::res://scenes/::1787150789 +avatar_customization.tscn::PackedScene::-1::1787150789::0::1::::<><><>0<>0<><>::res://scripts/avatar/avatar_customization_ui.gd +main.tscn::PackedScene::-1::1787150789::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd +main_menu.tscn::PackedScene::-1::1787150789::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd +::res://scripts/::1787150789 +main_menu.gd::GDScript::6526915941780468089::1787150789::0::1::::<>Control<><>0<>0<><>:: +::res://scripts/avatar/::1787150789 +avatar_catalog.gd::GDScript::5104367973301127782::1787150789::0::1::::AvatarCatalog<>RefCounted<><>0<>0<><>:: +avatar_customization_ui.gd::GDScript::6000242829080071607::1787150789::0::1::::AvatarCustomizationUI<>Control<><>0<>0<><>:: +avatar_loader.gd::GDScript::3645080687026000779::1787150789::0::1::::AvatarLoader<>Node<><>0<>0<><>:: +avatar_loadout.gd::GDScript::8780959424915557114::1787150789::0::1::::AvatarLoadout<>RefCounted<><>0<>0<><>:: +avatar_slot_registry.gd::GDScript::8212023476238218904::1787150789::0::1::::AvatarSlotRegistry<>RefCounted<><>0<>0<><>:: +avatar_system.gd::GDScript::150798117962859248::1787150789::0::1::::<>Node<><>0<>0<><>:: +avatar_visualizer.gd::GDScript::6283745077729863623::1787150789::0::1::::AvatarVisualizer<>Object<><>0<>0<><>:: -- 2.52.0 From 07f655815f9a5508ade6ce51e5d91e6d37d1e084 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Thu, 20 Aug 2026 21:09:33 +0000 Subject: [PATCH 19/26] WIP checkpoint: Implement avatar loader and customization UI in Godot --- .godot/editor/filesystem_cache10 | 40 ++++++++++++++++---------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/.godot/editor/filesystem_cache10 b/.godot/editor/filesystem_cache10 index 502eb94..f59ab40 100644 --- a/.godot/editor/filesystem_cache10 +++ b/.godot/editor/filesystem_cache10 @@ -1,26 +1,26 @@ 63f7b34db8d8cdea90c76aacccf841ec -::res://::1787150799 +::res://::1787259973 README.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: -::res://assets/::1787150789 -::res://assets/avatars/::1787150789 -catalog.json::JSON::-1::1787150789::0::1::::<><><>0<>0<><>:: -::res://docs/::1787150789 -::res://docs/architecture/::1787150789 +::res://assets/::1787259973 +::res://assets/avatars/::1787259973 +catalog.json::JSON::-1::1787259973::0::1::::<><><>0<>0<><>:: +::res://docs/::1787259973 +::res://docs/architecture/::1787259973 avatar.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: content_moderation.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: networking.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: world.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: -::res://scenes/::1787150789 -avatar_customization.tscn::PackedScene::-1::1787150789::0::1::::<><><>0<>0<><>::res://scripts/avatar/avatar_customization_ui.gd -main.tscn::PackedScene::-1::1787150789::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd -main_menu.tscn::PackedScene::-1::1787150789::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd -::res://scripts/::1787150789 -main_menu.gd::GDScript::6526915941780468089::1787150789::0::1::::<>Control<><>0<>0<><>:: -::res://scripts/avatar/::1787150789 -avatar_catalog.gd::GDScript::5104367973301127782::1787150789::0::1::::AvatarCatalog<>RefCounted<><>0<>0<><>:: -avatar_customization_ui.gd::GDScript::6000242829080071607::1787150789::0::1::::AvatarCustomizationUI<>Control<><>0<>0<><>:: -avatar_loader.gd::GDScript::3645080687026000779::1787150789::0::1::::AvatarLoader<>Node<><>0<>0<><>:: -avatar_loadout.gd::GDScript::8780959424915557114::1787150789::0::1::::AvatarLoadout<>RefCounted<><>0<>0<><>:: -avatar_slot_registry.gd::GDScript::8212023476238218904::1787150789::0::1::::AvatarSlotRegistry<>RefCounted<><>0<>0<><>:: -avatar_system.gd::GDScript::150798117962859248::1787150789::0::1::::<>Node<><>0<>0<><>:: -avatar_visualizer.gd::GDScript::6283745077729863623::1787150789::0::1::::AvatarVisualizer<>Object<><>0<>0<><>:: +::res://scenes/::1787259973 +avatar_customization.tscn::PackedScene::-1::1787259973::0::1::::<><><>0<>0<><>::res://scripts/avatar/avatar_customization_ui.gd +main.tscn::PackedScene::-1::1787259973::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd +main_menu.tscn::PackedScene::-1::1787259973::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd +::res://scripts/::1787259973 +main_menu.gd::GDScript::6526915941780468089::1787259973::0::1::::<>Control<><>0<>0<><>:: +::res://scripts/avatar/::1787259973 +avatar_catalog.gd::GDScript::5104367973301127782::1787259973::0::1::::AvatarCatalog<>RefCounted<><>0<>0<><>:: +avatar_customization_ui.gd::GDScript::6000242829080071607::1787259973::0::1::::AvatarCustomizationUI<>Control<><>0<>0<><>:: +avatar_loader.gd::GDScript::3645080687026000779::1787259973::0::1::::AvatarLoader<>Node<><>0<>0<><>:: +avatar_loadout.gd::GDScript::8780959424915557114::1787259973::0::1::::AvatarLoadout<>RefCounted<><>0<>0<><>:: +avatar_slot_registry.gd::GDScript::8212023476238218904::1787259973::0::1::::AvatarSlotRegistry<>RefCounted<><>0<>0<><>:: +avatar_system.gd::GDScript::150798117962859248::1787259973::0::1::::<>Node<><>0<>0<><>:: +avatar_visualizer.gd::GDScript::6283745077729863623::1787259973::0::1::::AvatarVisualizer<>Object<><>0<>0<><>:: -- 2.52.0 From b86e4053bddb352a967fa958d3d6c1ea0c07c2e5 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Thu, 20 Aug 2026 21:11:25 +0000 Subject: [PATCH 20/26] WIP checkpoint: Implement avatar loader and customization UI in Godot --- .godot/editor/filesystem_cache10 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.godot/editor/filesystem_cache10 b/.godot/editor/filesystem_cache10 index f59ab40..06a4bcb 100644 --- a/.godot/editor/filesystem_cache10 +++ b/.godot/editor/filesystem_cache10 @@ -1,5 +1,5 @@ 63f7b34db8d8cdea90c76aacccf841ec -::res://::1787259973 +::res://::1787260174 README.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: ::res://assets/::1787259973 ::res://assets/avatars/::1787259973 -- 2.52.0 From 38d97a3051ff45cc5077d38e3715453ec0c43cd1 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Thu, 20 Aug 2026 21:27:57 +0000 Subject: [PATCH 21/26] WIP checkpoint: Implement avatar loader and customization UI in Godot --- .godot/editor/filesystem_cache10 | 40 ++++++++++++++++---------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/.godot/editor/filesystem_cache10 b/.godot/editor/filesystem_cache10 index 06a4bcb..f26b75c 100644 --- a/.godot/editor/filesystem_cache10 +++ b/.godot/editor/filesystem_cache10 @@ -1,26 +1,26 @@ 63f7b34db8d8cdea90c76aacccf841ec -::res://::1787260174 +::res://::1787260653 README.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: -::res://assets/::1787259973 -::res://assets/avatars/::1787259973 -catalog.json::JSON::-1::1787259973::0::1::::<><><>0<>0<><>:: -::res://docs/::1787259973 -::res://docs/architecture/::1787259973 +::res://assets/::1787260653 +::res://assets/avatars/::1787260653 +catalog.json::JSON::-1::1787260653::0::1::::<><><>0<>0<><>:: +::res://docs/::1787260653 +::res://docs/architecture/::1787260653 avatar.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: content_moderation.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: networking.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: world.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: -::res://scenes/::1787259973 -avatar_customization.tscn::PackedScene::-1::1787259973::0::1::::<><><>0<>0<><>::res://scripts/avatar/avatar_customization_ui.gd -main.tscn::PackedScene::-1::1787259973::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd -main_menu.tscn::PackedScene::-1::1787259973::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd -::res://scripts/::1787259973 -main_menu.gd::GDScript::6526915941780468089::1787259973::0::1::::<>Control<><>0<>0<><>:: -::res://scripts/avatar/::1787259973 -avatar_catalog.gd::GDScript::5104367973301127782::1787259973::0::1::::AvatarCatalog<>RefCounted<><>0<>0<><>:: -avatar_customization_ui.gd::GDScript::6000242829080071607::1787259973::0::1::::AvatarCustomizationUI<>Control<><>0<>0<><>:: -avatar_loader.gd::GDScript::3645080687026000779::1787259973::0::1::::AvatarLoader<>Node<><>0<>0<><>:: -avatar_loadout.gd::GDScript::8780959424915557114::1787259973::0::1::::AvatarLoadout<>RefCounted<><>0<>0<><>:: -avatar_slot_registry.gd::GDScript::8212023476238218904::1787259973::0::1::::AvatarSlotRegistry<>RefCounted<><>0<>0<><>:: -avatar_system.gd::GDScript::150798117962859248::1787259973::0::1::::<>Node<><>0<>0<><>:: -avatar_visualizer.gd::GDScript::6283745077729863623::1787259973::0::1::::AvatarVisualizer<>Object<><>0<>0<><>:: +::res://scenes/::1787260653 +avatar_customization.tscn::PackedScene::-1::1787260653::0::1::::<><><>0<>0<><>::res://scripts/avatar/avatar_customization_ui.gd +main.tscn::PackedScene::-1::1787260653::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd +main_menu.tscn::PackedScene::-1::1787260653::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd +::res://scripts/::1787260653 +main_menu.gd::GDScript::6526915941780468089::1787260653::0::1::::<>Control<><>0<>0<><>:: +::res://scripts/avatar/::1787260653 +avatar_catalog.gd::GDScript::5104367973301127782::1787260653::0::1::::AvatarCatalog<>RefCounted<><>0<>0<><>:: +avatar_customization_ui.gd::GDScript::6000242829080071607::1787260653::0::1::::AvatarCustomizationUI<>Control<><>0<>0<><>:: +avatar_loader.gd::GDScript::3645080687026000779::1787260653::0::1::::AvatarLoader<>Node<><>0<>0<><>:: +avatar_loadout.gd::GDScript::8780959424915557114::1787260653::0::1::::AvatarLoadout<>RefCounted<><>0<>0<><>:: +avatar_slot_registry.gd::GDScript::8212023476238218904::1787260653::0::1::::AvatarSlotRegistry<>RefCounted<><>0<>0<><>:: +avatar_system.gd::GDScript::150798117962859248::1787260653::0::1::::<>Node<><>0<>0<><>:: +avatar_visualizer.gd::GDScript::6283745077729863623::1787260653::0::1::::AvatarVisualizer<>Object<><>0<>0<><>:: -- 2.52.0 From 1be947f9ef7079027799b47fb72e27f968c8264d Mon Sep 17 00:00:00 2001 From: coder-bot Date: Thu, 20 Aug 2026 22:05:30 +0000 Subject: [PATCH 22/26] WIP checkpoint: Implement avatar loader and customization UI in Godot --- scripts/avatar/avatar_loadout.gd | 37 +++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/scripts/avatar/avatar_loadout.gd b/scripts/avatar/avatar_loadout.gd index 05582cc..84d60b6 100644 --- a/scripts/avatar/avatar_loadout.gd +++ b/scripts/avatar/avatar_loadout.gd @@ -27,6 +27,27 @@ var validation_errors: Array[String] = [] var skin_tone: Color = Color.TRANSPARENT +# --------------------------------------------------------------------------- +# Inner class: SlotItem +# --------------------------------------------------------------------------- + +## Represents a single equipped slot item in a loadout. +## Used by AvatarVisualizer to access slot data. +class SlotItem: + ## The asset ID of the equipped item. + var asset_id: String = "" + ## The slot key this item belongs to. + var slot_key: String = "" + + func _init(p_asset_id: String = "", p_slot_key: String = "") -> void: + asset_id = p_asset_id + slot_key = p_slot_key + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + func _init() -> void: validation_errors = [] @@ -132,14 +153,14 @@ func validate() -> bool: 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 - ] - ) + 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: -- 2.52.0 From bfcbd0486feb41dba1e0ae256266bd90c20c5e1d Mon Sep 17 00:00:00 2001 From: coder-bot Date: Thu, 20 Aug 2026 22:30:31 +0000 Subject: [PATCH 23/26] WIP checkpoint: Implement avatar loader and customization UI in Godot --- scripts/avatar/avatar_loadout.gd | 56 +++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/scripts/avatar/avatar_loadout.gd b/scripts/avatar/avatar_loadout.gd index 84d60b6..f4acc87 100644 --- a/scripts/avatar/avatar_loadout.gd +++ b/scripts/avatar/avatar_loadout.gd @@ -28,20 +28,20 @@ var skin_tone: Color = Color.TRANSPARENT # --------------------------------------------------------------------------- -# Inner class: SlotItem +# Inner classes # --------------------------------------------------------------------------- -## Represents a single equipped slot item in a loadout. -## Used by AvatarVisualizer to access slot data. +## Represents a single equipped item in a slot. +## Used by AvatarVisualizer to iterate over equipped items. class SlotItem: - ## The asset ID of the equipped item. - var asset_id: String = "" - ## The slot key this item belongs to. - var slot_key: String = "" + ## The slot this item belongs to. + var slot_id: String + ## The catalog asset ID of the equipped item. + var asset_id: String - func _init(p_asset_id: String = "", p_slot_key: String = "") -> void: + func _init(p_slot_id: String, p_asset_id: String) -> void: + slot_id = p_slot_id asset_id = p_asset_id - slot_key = p_slot_key # --------------------------------------------------------------------------- @@ -52,6 +52,10 @@ func _init() -> void: validation_errors = [] +# --------------------------------------------------------------------------- +# Serialization +# --------------------------------------------------------------------------- + ## Create a loadout from a JSON string. static func from_json(json_string: String) -> AvatarLoadout: var loadout := AvatarLoadout.new() @@ -119,6 +123,10 @@ func to_json() -> String: return JSON.stringify(data, " ") +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + ## Validate this loadout against the slot registry. ## Returns true if valid; populates validation_errors if not. func validate() -> bool: @@ -182,6 +190,10 @@ func is_valid() -> bool: return validate() +# --------------------------------------------------------------------------- +# Accessors +# --------------------------------------------------------------------------- + ## 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): @@ -254,6 +266,32 @@ func remove_accessory(asset_id: String) -> bool: return false +## Iterate over equipped items as SlotItem objects. +## Returns an array of SlotItem for each equipped slot. +func get_equipped_items() -> Array[SlotItem]: + var items: Array[SlotItem] = [] + + for slot_id in AvatarSlotRegistry.SLOT_IDS: + var value = slots.get(slot_id) + if value == null: + continue + + if typeof(value) == TYPE_STRING: + var asset_id = value as String + if not asset_id.is_empty(): + items.append(SlotItem.new(slot_id, asset_id)) + elif typeof(value) == TYPE_ARRAY: + for item_id in value: + if item_id is String and not item_id.is_empty(): + items.append(SlotItem.new(slot_id, item_id)) + + return items + + +# --------------------------------------------------------------------------- +# Hashing +# --------------------------------------------------------------------------- + ## Compute a hash of this loadout for change detection. ## Mirrors the server's loadout_hash concept. func compute_hash() -> int: -- 2.52.0 From d1d03df2e6f19de6a0a8f6e792d410ed2507b0fe Mon Sep 17 00:00:00 2001 From: coder-bot Date: Fri, 21 Aug 2026 00:01:24 +0000 Subject: [PATCH 24/26] WIP checkpoint: Implement avatar loader and customization UI in Godot --- .godot/editor/filesystem_cache10 | 40 ++++++++++++++++---------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/.godot/editor/filesystem_cache10 b/.godot/editor/filesystem_cache10 index f26b75c..d20a515 100644 --- a/.godot/editor/filesystem_cache10 +++ b/.godot/editor/filesystem_cache10 @@ -1,26 +1,26 @@ 63f7b34db8d8cdea90c76aacccf841ec -::res://::1787260653 +::res://::1787270429 README.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: -::res://assets/::1787260653 -::res://assets/avatars/::1787260653 -catalog.json::JSON::-1::1787260653::0::1::::<><><>0<>0<><>:: -::res://docs/::1787260653 -::res://docs/architecture/::1787260653 +::res://assets/::1787270429 +::res://assets/avatars/::1787270429 +catalog.json::JSON::-1::1787270429::0::1::::<><><>0<>0<><>:: +::res://docs/::1787270429 +::res://docs/architecture/::1787270429 avatar.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: content_moderation.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: networking.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: world.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: -::res://scenes/::1787260653 -avatar_customization.tscn::PackedScene::-1::1787260653::0::1::::<><><>0<>0<><>::res://scripts/avatar/avatar_customization_ui.gd -main.tscn::PackedScene::-1::1787260653::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd -main_menu.tscn::PackedScene::-1::1787260653::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd -::res://scripts/::1787260653 -main_menu.gd::GDScript::6526915941780468089::1787260653::0::1::::<>Control<><>0<>0<><>:: -::res://scripts/avatar/::1787260653 -avatar_catalog.gd::GDScript::5104367973301127782::1787260653::0::1::::AvatarCatalog<>RefCounted<><>0<>0<><>:: -avatar_customization_ui.gd::GDScript::6000242829080071607::1787260653::0::1::::AvatarCustomizationUI<>Control<><>0<>0<><>:: -avatar_loader.gd::GDScript::3645080687026000779::1787260653::0::1::::AvatarLoader<>Node<><>0<>0<><>:: -avatar_loadout.gd::GDScript::8780959424915557114::1787260653::0::1::::AvatarLoadout<>RefCounted<><>0<>0<><>:: -avatar_slot_registry.gd::GDScript::8212023476238218904::1787260653::0::1::::AvatarSlotRegistry<>RefCounted<><>0<>0<><>:: -avatar_system.gd::GDScript::150798117962859248::1787260653::0::1::::<>Node<><>0<>0<><>:: -avatar_visualizer.gd::GDScript::6283745077729863623::1787260653::0::1::::AvatarVisualizer<>Object<><>0<>0<><>:: +::res://scenes/::1787270429 +avatar_customization.tscn::PackedScene::-1::1787270429::0::1::::<><><>0<>0<><>::res://scripts/avatar/avatar_customization_ui.gd +main.tscn::PackedScene::-1::1787270429::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd +main_menu.tscn::PackedScene::-1::1787270429::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd +::res://scripts/::1787270429 +main_menu.gd::GDScript::6526915941780468089::1787270429::0::1::::<>Control<><>0<>0<><>:: +::res://scripts/avatar/::1787270429 +avatar_catalog.gd::GDScript::5104367973301127782::1787270429::0::1::::AvatarCatalog<>RefCounted<><>0<>0<><>:: +avatar_customization_ui.gd::GDScript::6000242829080071607::1787270429::0::1::::AvatarCustomizationUI<>Control<><>0<>0<><>:: +avatar_loader.gd::GDScript::3645080687026000779::1787270429::0::1::::AvatarLoader<>Node<><>0<>0<><>:: +avatar_loadout.gd::GDScript::8780959424915557114::1787270429::0::1::::AvatarLoadout<>RefCounted<><>0<>0<><>:: +avatar_slot_registry.gd::GDScript::8212023476238218904::1787270429::0::1::::AvatarSlotRegistry<>RefCounted<><>0<>0<><>:: +avatar_system.gd::GDScript::150798117962859248::1787270429::0::1::::<>Node<><>0<>0<><>:: +avatar_visualizer.gd::GDScript::6283745077729863623::1787270429::0::1::::AvatarVisualizer<>Object<><>0<>0<><>:: -- 2.52.0 From 4d754ae48e1c290828ccfae43ae86dc4db189154 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Fri, 21 Aug 2026 00:19:51 +0000 Subject: [PATCH 25/26] WIP checkpoint: Implement avatar loader and customization UI in Godot --- .godot/editor/filesystem_cache10 | 4 +- scripts/avatar/avatar_loadout.gd | 83 +++++++++++++++----------------- 2 files changed, 40 insertions(+), 47 deletions(-) diff --git a/.godot/editor/filesystem_cache10 b/.godot/editor/filesystem_cache10 index d20a515..11182b7 100644 --- a/.godot/editor/filesystem_cache10 +++ b/.godot/editor/filesystem_cache10 @@ -1,5 +1,5 @@ 63f7b34db8d8cdea90c76aacccf841ec -::res://::1787270429 +::res://::1787270484 README.md::TextFile::-1::1787013015::0::1::::<><><>0<>0<><>:: ::res://assets/::1787270429 ::res://assets/avatars/::1787270429 @@ -20,7 +20,7 @@ main_menu.gd::GDScript::6526915941780468089::1787270429::0::1::::<>Control<><>0< avatar_catalog.gd::GDScript::5104367973301127782::1787270429::0::1::::AvatarCatalog<>RefCounted<><>0<>0<><>:: avatar_customization_ui.gd::GDScript::6000242829080071607::1787270429::0::1::::AvatarCustomizationUI<>Control<><>0<>0<><>:: avatar_loader.gd::GDScript::3645080687026000779::1787270429::0::1::::AvatarLoader<>Node<><>0<>0<><>:: -avatar_loadout.gd::GDScript::8780959424915557114::1787270429::0::1::::AvatarLoadout<>RefCounted<><>0<>0<><>:: +avatar_loadout.gd::GDScript::8780959424915557114::1787271124::0::1::::AvatarLoadout<>RefCounted<><>0<>0<><>:: avatar_slot_registry.gd::GDScript::8212023476238218904::1787270429::0::1::::AvatarSlotRegistry<>RefCounted<><>0<>0<><>:: avatar_system.gd::GDScript::150798117962859248::1787270429::0::1::::<>Node<><>0<>0<><>:: avatar_visualizer.gd::GDScript::6283745077729863623::1787270429::0::1::::AvatarVisualizer<>Object<><>0<>0<><>:: diff --git a/scripts/avatar/avatar_loadout.gd b/scripts/avatar/avatar_loadout.gd index f4acc87..ba996d9 100644 --- a/scripts/avatar/avatar_loadout.gd +++ b/scripts/avatar/avatar_loadout.gd @@ -32,7 +32,7 @@ var skin_tone: Color = Color.TRANSPARENT # --------------------------------------------------------------------------- ## Represents a single equipped item in a slot. -## Used by AvatarVisualizer to iterate over equipped items. +## Used by consumers to iterate over equipped items. class SlotItem: ## The slot this item belongs to. var slot_id: String @@ -60,13 +60,13 @@ func _init() -> void: 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: + if not (data is Dictionary): loadout.validation_errors.append("Loadout JSON root is not an object") return loadout var root: Dictionary = data as Dictionary - # Parse base_body + # Parse base_body. if root.has("base_body"): var bb = root["base_body"] if typeof(bb) == TYPE_STRING: @@ -74,25 +74,29 @@ static func from_json(json_string: String) -> AvatarLoadout: else: loadout.validation_errors.append("base_body must be a string") - # Parse slots + # Parse slots. if root.has("slots"): var slots_data = root["slots"] if typeof(slots_data) == TYPE_DICTIONARY: - loadout.slots = slots_data as Dictionary + var parsed_slots: Dictionary = slots_data as Dictionary + for slot_id in parsed_slots: + if slot_id is String: + loadout.slots[slot_id] = parsed_slots[slot_id] else: loadout.validation_errors.append("slots must be an object") else: loadout.validation_errors.append("Missing 'slots' in loadout JSON") - # Parse skin_tone + # Parse skin_tone. if root.has("skin_tone"): var st = root["skin_tone"] if typeof(st) == TYPE_DICTIONARY: + var tone: Dictionary = st as Dictionary loadout.skin_tone = Color( - st.get("r", 0.0), - st.get("g", 0.0), - st.get("b", 0.0), - st.get("a", 1.0) + float(tone.get("r", 0.0)), + float(tone.get("g", 0.0)), + float(tone.get("b", 0.0)), + float(tone.get("a", 1.0)) ) return loadout @@ -107,11 +111,11 @@ func to_json() -> String: for slot_id in AvatarSlotRegistry.SLOT_IDS: if slot_id == "accessory": - data.slots[slot_id] = slots.get(slot_id, []) + data["slots"][slot_id] = slots.get(slot_id, []) else: - data.slots[slot_id] = slots.get(slot_id, null) + data["slots"][slot_id] = slots.get(slot_id, null) - # Include skin_tone if set + # Include skin_tone if set. if skin_tone != Color.TRANSPARENT: data["skin_tone"] = { "r": skin_tone.r, @@ -132,11 +136,11 @@ func to_json() -> String: func validate() -> bool: validation_errors.clear() - # Check base_body + # Check base_body. if base_body.is_empty(): validation_errors.append("base_body is required") - # Check each slot + # Check each known slot. for slot_id in AvatarSlotRegistry.SLOT_IDS: var slot_def = AvatarSlotRegistry.get_slot(slot_id) if slot_def == null: @@ -146,7 +150,7 @@ func validate() -> bool: var value = slots.get(slot_id) if slot_def.exclusive: - # Exclusive slots: must be null or a string + # 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" % [ @@ -154,32 +158,29 @@ func validate() -> bool: ] ) else: - # Non-exclusive (accessory): must be null or an array + # 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 - ] - ) + elif value is Array: + if slot_id == "accessory" and (value as Array).size() > MAX_ACCESSORIES: + validation_errors.append( + "Accessory count (%d) exceeds maximum (%d)" % [ + (value as Array).size(), MAX_ACCESSORIES + ] + ) - # Check for duplicate accessories + # Check for duplicate accessories. if slots.has("accessory") and slots["accessory"] is Array: - var accessories = slots["accessory"] as 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 - ) + validation_errors.append("Duplicate accessory: %s" % item) seen[item] = true return validation_errors.is_empty() @@ -231,20 +232,12 @@ func set_equipment(slot_id: String, asset_id: String) -> void: 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 + slots[slot_id] = null if asset_id.is_empty() else asset_id ## Add an accessory to the accessory slot. func add_accessory(asset_id: String) -> bool: - var accessories = slots.get("accessory", []) as Array + var accessories := slots.get("accessory", []) as Array if accessories.size() >= MAX_ACCESSORIES: return false if asset_id.is_empty(): @@ -258,7 +251,7 @@ func add_accessory(asset_id: String) -> bool: ## Remove an accessory from the accessory slot. func remove_accessory(asset_id: String) -> bool: - var accessories = slots.get("accessory", []) as Array + var accessories := slots.get("accessory", []) as Array if accessories.has(asset_id): accessories.erase(asset_id) slots["accessory"] = accessories @@ -277,12 +270,12 @@ func get_equipped_items() -> Array[SlotItem]: continue if typeof(value) == TYPE_STRING: - var asset_id = value as String + var asset_id := value as String if not asset_id.is_empty(): items.append(SlotItem.new(slot_id, asset_id)) elif typeof(value) == TYPE_ARRAY: for item_id in value: - if item_id is String and not item_id.is_empty(): + if item_id is String and not (item_id as String).is_empty(): items.append(SlotItem.new(slot_id, item_id)) return items @@ -302,12 +295,12 @@ func compute_hash() -> int: hasher.update(slot_id.to_utf8_buffer()) var value = slots.get(slot_id) if value is String: - hasher.update(value.to_utf8_buffer()) + hasher.update((value as String).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 first 4 bytes as int for compact storage. return digest[0] | (digest[1] << 8) | (digest[2] << 16) | (digest[3] << 24) -- 2.52.0 From b6e146e4b2e7cc25a4ddac8074272a94adc6b4a4 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Fri, 21 Aug 2026 01:34:51 +0000 Subject: [PATCH 26/26] Avatar loader & customization system: merge onto main + fix regressions, add smoke coverage Merges the pre-existing avatar system implementation (catalog, slot registry, loadout, glTF-based loader, customization UI, visualizer -- ~2300 lines across scripts/avatar/) onto current main, which had diverged significantly since this branch was last touched (PC1 UI fixes, PC2's playable level/player/multiplayer work). Fixes found while integrating: - project.godot had wholesale-replaced the [autoload] section again (same recurring pattern as the multiplayer server/client branches), dropping the ApiClient autoload entirely. Restored it additively alongside AvatarSystem. - All other real conflicts (main_menu.tscn/.gd, main.tscn, .godot cache) were pure "this branch re-created a stale duplicate of something main already has" -- resolved by taking main's version, confirmed via `git diff` that this task's own changes never touched those files' actual logic. New: scripts/smoke_test_avatar_system.gd -- confirms AvatarSystem is a real autoload, the committed catalog.json parses, loadout equip/ validate/JSON-round-trip works against real catalog entries, and -- since assets/avatars/*.glb model files don't exist yet -- that AvatarLoader.load_base_body() fails gracefully (null + load_error signal) rather than crashing when given a real catalog entry with no backing file. 10/10 passing. Verified no regressions: all 10 pre-existing Godot smoke suites still pass against a live backend, and all 61 backend pytest tests pass. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN --- docs/testing/test_inventory.md | 14 +++-- project.godot | 1 + scripts/smoke_test_avatar_system.gd | 98 +++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 scripts/smoke_test_avatar_system.gd diff --git a/docs/testing/test_inventory.md b/docs/testing/test_inventory.md index 5b96a19..e92fc25 100644 --- a/docs/testing/test_inventory.md +++ b/docs/testing/test_inventory.md @@ -7,14 +7,17 @@ to know what "run the tests" actually checked. Run everything below in one shot with `scripts/run_checkpoint_tests.py`. -Last verified against `main`: 2026-08-21 — migrations + pytest + all 10 +Last verified against `main`: 2026-08-21 — migrations + pytest + all 11 Godot smoke tests, all clean. PC1's real playtest-found bugs (login form unreachable, 1px world cards, Play → Back losing the session) are covered by the smoke tests below so they can't silently regress. PC2 (basic playable world + character + movement) landed 2026-08-20/21 — see `docs/checkpoints/` once a PC2 record exists, and the three new smoke tests below (`smoke_test_test_level.gd`, `smoke_test_player.gd`, -`smoke_test_enter_world.gd`) for what's actually verified. +`smoke_test_enter_world.gd`) for what's actually verified. The avatar +loader/customization system (`smoke_test_avatar_system.gd`) also landed +2026-08-21 — real glTF-based loading with graceful failure on missing +assets, verified since `assets/avatars/*.glb` model files don't exist yet. ## Backend — `backend/tests/` (pytest) @@ -53,8 +56,10 @@ these yet): ## Godot client — `scripts/smoke_test_*.gd` -**11 smoke tests merged into `main`.** Ten need a live backend, one -(`smoke_test_network_client.gd`) needs a live dedicated server instead — +**12 smoke tests merged into `main`.** Ten need a live backend, one +(`smoke_test_network_client.gd`) needs a live dedicated server instead, +and one (`smoke_test_avatar_system.gd`) needs neither — it only exercises +local catalog/loadout/loader logic. Backend-dependent tests read `AGP_API_BASE_URL` env var, falling back to the `application/agp/api_base_url` project setting (default `http://127.0.0.1:8000`) if unset. @@ -72,6 +77,7 @@ these yet): | `smoke_test_player.gd` | Loads `scenes/game.tscn` and confirms the player exists as a real `CharacterBody3D` with a `CollisionShape3D`+shape, a `CapsuleMesh`, and spawns at the level's `PlayerSpawn` marker position. Also asserts `JUMP_VELOCITY` is positive and a `Camera3D` exists and is `current` — both were real bugs found 2026-08-20/21 (jump velocity was negative, pushing the player down instead of up; no camera existed at all, so the game was unplayable even though the character could technically move). Structural checks only — real movement/jump *direction* correctness was verified separately via a throwaway headless physics simulation during development, not by an automated test that's part of this suite. | | `smoke_test_enter_world.gd` | The full PC2 path end to end: register → seed a real world → load `world_list.tscn` → click the real "Enter World" button on a real card (found by walking the card's children, not by index, so it doesn't silently pass if the card layout changes) → confirms `scenes/game.tscn` actually becomes `current_scene` with the player spawned at the level's `PlayerSpawn` marker → sends a real `KEY_ESCAPE` input event (not a direct method call) and confirms it returns to `world_list.tscn`. This is the test that proves "browse → enter → walk around → leave" actually works as one continuous flow, not just that each piece works in isolation. | | `smoke_test_network_client.gd` | **Real two-process integration test** for `scripts/network_client.gd` (client-side prediction + reconciliation, docs/architecture/networking.md §5) — needs an actual dedicated server running first: `godot --headless --path . scenes/server.tscn &`, *not* the usual HTTP backend. Connects a real `NetworkClient` over ENet, confirms local input prediction moves the player immediately (no waiting on a round trip), confirms real snapshots arrive and get processed, then deliberately desyncs the predicted position by 9999 units and confirms the very next snapshot corrects it back — the actual proof reconciliation logic works, not just that it never had to fire. Known simplification, stated in the script's own docstring: the server's snapshot doesn't currently echo back a per-peer "last input seq processed," so reconciliation corrects against the *most recent* prediction rather than true tick-exact rollback-and-replay (docs/architecture/networking.md §5.2 step 4) — a real, scoped follow-up once the server echoes that ack, not something faked here. | +| `smoke_test_avatar_system.gd` | Confirms `AvatarSystem` is a real autoload, the committed `assets/avatars/catalog.json` actually parses, `get_assets_for_slot("base_body")` returns real entries, an `AvatarLoadout` can set `base_body` and equip a real catalog accessory, the loadout round-trips through `to_json()`/`load_loadout_from_json()`, and — since `assets/avatars/*.glb` model files don't exist yet — that `AvatarLoader.load_base_body()` against a real catalog entry with a missing file fails *gracefully*: returns `null` and emits `load_error` rather than crashing. Visual rendering itself can't be verified until real assets exist. | Run (once, to build the class cache): `godot --headless --path . --import`, then for each smoke test: `godot --headless --path . --script diff --git a/project.godot b/project.godot index 55bf1d9..b545262 100644 --- a/project.godot +++ b/project.godot @@ -14,6 +14,7 @@ agp/api_base_url="http://127.0.0.1:8000" [autoload] ApiClient="*res://scripts/api_client.gd" +AvatarSystem="*res://scripts/avatar/avatar_system.gd" [display] diff --git a/scripts/smoke_test_avatar_system.gd b/scripts/smoke_test_avatar_system.gd new file mode 100644 index 0000000..b342a9c --- /dev/null +++ b/scripts/smoke_test_avatar_system.gd @@ -0,0 +1,98 @@ +extends SceneTree +## Smoke test for the avatar catalog/slot/loadout/system layers. +## +## Real .glb model assets don't exist yet (assets/avatars/catalog.json +## references them, the files themselves are a tracked user to-do -- see +## the "AGP MVP status" project memory). This test covers everything that +## doesn't require them (catalog parsing, slot registry, loadout equip/ +## validate, the AvatarSystem orchestrator's autoload wiring) and confirms +## the one thing that DOES touch missing files -- AvatarLoader.load_base_body() +## -- fails gracefully (emits load_error, returns null) instead of crashing. +## Visual rendering itself can't be verified until real assets exist. +## +## Run with: +## godot --headless --path . --script res://scripts/smoke_test_avatar_system.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: + var equip_ok: bool = loadout.add_accessory(hair_options[0]) + _check("adding a real catalog accessory succeeds", equip_ok) + + # 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. The part that DOES need real files: confirm it fails gracefully + # rather than crashing, since assets/avatars/*.glb genuinely don't + # exist yet. A crash here would be a real bug; a clean load_error + # signal + null return is the correct, already-designed-for behavior. + if base_bodies.size() > 0: + # A plain bool captured by a lambda is captured BY VALUE in + # GDScript -- mutating it inside the closure doesn't propagate to + # this outer scope. A single-element Array is the standard + # workaround (confirmed the hard way earlier in this same session, + # see scripts/smoke_test_network_client.gd's own comment on this). + var error_state := [false] + var error_handler := func(_msg): error_state[0] = true + system.loader.load_error.connect(error_handler) + var result: Node3D = system.loader.load_base_body(base_bodies[0]) + system.loader.load_error.disconnect(error_handler) + _check("load_base_body against a missing .glb returns null (not a crash)", result == null) + _check("load_base_body against a missing .glb emits load_error (not silent)", error_state[0]) + else: + print(" SKIP: no base_body entries in catalog to test the failure path against") + + _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) -- 2.52.0