diff --git a/scenes/avatar_customization.tscn b/scenes/avatar_customization.tscn index 44c9f74..ef06ed6 100644 --- a/scenes/avatar_customization.tscn +++ b/scenes/avatar_customization.tscn @@ -1,45 +1,6 @@ -[gd_scene load_steps=3 format=3 uid="uid://avatar_customization"] +[gd_scene load_steps=2 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 +[ext_resource type="Script" path="res://scripts/avatar_customization_screen.gd" id="1"] [node name="AvatarCustomization" type="Control"] layout_mode = 3 @@ -49,101 +10,3 @@ anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 script = 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_menu.tscn b/scenes/main_menu.tscn index a07c731..65b3cac 100644 --- a/scenes/main_menu.tscn +++ b/scenes/main_menu.tscn @@ -112,6 +112,12 @@ layout_mode = 1 text = "Play" size_flags_horizontal = 4 +[node name="CustomizeButton" type="Button" parent="CenterContainer/VBoxContainer"] +layout_mode = 1 +text = "Customize Avatar" +size_flags_horizontal = 4 +disabled = true + [node name="QuitButton" type="Button" parent="CenterContainer/VBoxContainer"] layout_mode = 1 text = "Quit" @@ -120,6 +126,7 @@ size_flags_horizontal = 4 [connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton" to="." method="_on_login_button_pressed"] [connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton" to="." method="_on_register_button_pressed"] [connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"] +[connection signal="pressed" from="CenterContainer/VBoxContainer/CustomizeButton" to="." method="_on_customize_button_pressed"] [connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"] [connection signal="pressed" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton" to="." method="_on_auth_button_pressed"] [connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit" to="." method="_on_auth_button_pressed"] diff --git a/scripts/api_client.gd b/scripts/api_client.gd index 4905634..cb337d3 100644 --- a/scripts/api_client.gd +++ b/scripts/api_client.gd @@ -221,6 +221,22 @@ func get_me() -> Dictionary: return await request(HTTPClient.METHOD_GET, "/api/users/me", "", "application/json", true) +## Fetch the current user's saved avatar loadout (requires auth). +## On success, result.data is {"data": , "is_default": bool} -- +## `is_default` true means the user has never customised and this is the +## server default. +func get_avatar_loadout() -> Dictionary: + return await request(HTTPClient.METHOD_GET, "/api/avatar/loadout", "", "application/json", true) + + +## Save (replace) the current user's avatar loadout (requires auth). +## @param loadout A dict in the AvatarLoadout.to_json() shape +## ({"base_body": String, "slots": {...}, "skin_tone"?: {...}}). +func save_avatar_loadout(loadout: Dictionary) -> Dictionary: + var body := JSON.stringify({"data": loadout}) + return await request(HTTPClient.METHOD_PUT, "/api/avatar/loadout", body, "application/json", true) + + # --------------------------------------------------------------------------- # Internals # --------------------------------------------------------------------------- diff --git a/scripts/avatar/avatar_customization_ui.gd b/scripts/avatar/avatar_customization_ui.gd index df7aba9..a45989f 100644 --- a/scripts/avatar/avatar_customization_ui.gd +++ b/scripts/avatar/avatar_customization_ui.gd @@ -106,9 +106,14 @@ func _ready() -> void: # Build the UI. _build_ui() - # Create a fresh loadout. - loadout = AvatarLoadout.new() - loadout.base_body = default_base_body + # Seed from the player's live loadout (set at login) if there is one, so + # the screen opens showing what they're actually wearing; otherwise start + # from a bare default. + if _system != null and _system.current_loadout != null: + loadout = AvatarLoadout.from_json(_system.current_loadout.to_json()) + if loadout == null or not loadout.validate(): + loadout = AvatarLoadout.new() + loadout.base_body = default_base_body # Initialize the visualizer. _visualizer = AvatarVisualizer.new() @@ -165,8 +170,9 @@ func _build_ui() -> void: # 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)) + # look_at needs a global transform, which only exists once in the tree. + _preview_camera.look_at_from_position( + Vector3(0, 1.0, 3.0), Vector3(0, 1.0, 0), Vector3.UP) _preview_viewport.add_child(_preview_camera) # Avatar root inside the viewport. @@ -429,14 +435,26 @@ func _on_skin_tone_changed(value: float) -> void: func _on_save_pressed() -> void: if loadout == null: return + + # Local cache (offline fallback / fast reopen). 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." + + # Apply to the live in-world avatar immediately. + if _system != null: + _system.set_loadout_from_dict(JSON.parse_string(json)) + + # Persist to the backend if we're signed in. + var api := get_node_or_null("/root/ApiClient") + if api != null and api.has_token(): + _status_label.text = "Saving…" + var res: Dictionary = await api.save_avatar_loadout(JSON.parse_string(json)) + _status_label.text = "Saved." if res.get("ok", false) else "Saved locally (backend: %s)" % res.get("detail", "error") else: - _status_label.text = "Failed to save loadout." + _status_label.text = "Saved locally (not signed in)." func _on_load_pressed() -> void: diff --git a/scripts/avatar/avatar_system.gd b/scripts/avatar/avatar_system.gd index 0066cbb..a48ab68 100644 --- a/scripts/avatar/avatar_system.gd +++ b/scripts/avatar/avatar_system.gd @@ -230,3 +230,46 @@ func get_assets_for_slot(slot_id: String) -> Array[String]: func get_asset_display_name(asset_id: String) -> String: var asset := catalog.resolve(asset_id) return asset.get("display_name", asset_id) + + +# --------------------------------------------------------------------------- +# Local-player convenience API +# +# The rest of the game (the main menu, the in-world player) only needs three +# things: load the catalog once, hand us the player's loadout (as the dict the +# backend returns), and ask for an assembled avatar node to drop into a scene. +# --------------------------------------------------------------------------- + +## True once a loadout has been handed to us (from the backend or a default). +func has_loadout() -> bool: + return current_loadout != null + + +## Ensure the catalog is loaded (idempotent). Safe to await repeatedly. +func ensure_catalog() -> bool: + if is_catalog_loaded: + return true + return load_catalog() == OK + + +## Set the current player's loadout from the dict shape the backend's +## GET /api/avatar/loadout returns (i.e. AvatarLoadout.to_json() parsed). +## Returns true if it validated and was stored. +func set_loadout_from_dict(loadout_dict: Dictionary) -> bool: + var lo := AvatarLoadout.from_json(JSON.stringify(loadout_dict)) + if not lo.validate(): + push_warning("AvatarSystem: rejecting invalid loadout dict: %s" + % str(lo.validation_errors)) + return false + current_loadout = lo + loadout_changed.emit(current_loadout) + return true + + +## Assemble a fresh avatar Node3D from the current loadout, ready to parent +## into a scene (e.g. onto the in-world player). Returns null if there's no +## loadout or the catalog isn't loaded or assembly failed. +func build_current_avatar() -> Node3D: + if current_loadout == null or not is_catalog_loaded: + return null + return load_avatar(current_loadout) diff --git a/scripts/avatar/avatar_visualizer.gd b/scripts/avatar/avatar_visualizer.gd index 45e644d..3376c02 100644 --- a/scripts/avatar/avatar_visualizer.gd +++ b/scripts/avatar/avatar_visualizer.gd @@ -1,24 +1,15 @@ ## 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. +## Renders an AvatarLoadout into a 3D node tree for previews (the customization +## screen). A thin wrapper over AvatarLoader -- it owns its own loader instance +## and catalog reference so building a preview never disturbs AvatarSystem's +## live in-world avatar state. ## ## 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. +## var viz := AvatarVisualizer.new() +## viz.load_loadout(loadout, preview_root_node3d) # preview_root gets the avatar as a child +## ... +## viz.clear() class_name AvatarVisualizer extends Object @@ -26,265 +17,99 @@ extends Object # Preloaded rather than referenced by global class_name -- see avatar_loadout.gd. const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd") const AvatarLoader := preload("res://scripts/avatar/avatar_loader.gd") +const AvatarCatalog := preload("res://scripts/avatar/avatar_catalog.gd") const AvatarSlotRegistry := preload("res://scripts/avatar/avatar_slot_registry.gd") - ## Emitted when the visualizer has finished building the render tree. signal build_complete ## Emitted when the visualizer has been cleared. signal cleared - -# --------------------------------------------------------------------------- -# Public API -# --------------------------------------------------------------------------- - -## The root node that holds the avatar render tree. -## Set this to the desired parent before calling load_loadout(). +## The node the assembled avatar is parented under. 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 assembled avatar node (child of `root`). +var avatar_node: Node3D = null -## The AvatarLoadout currently loaded (null if none). +## The loadout currently rendered. var current_loadout: AvatarLoadout = null -## Whether the visualizer is currently building (true during load_loadout). -var is_building: bool = false +var _loader = null +var _catalog = null -# --------------------------------------------------------------------------- -# 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. +## Build the avatar for [param loadout] and parent it under [param root_node] +## (or the previously-set `root`). Replaces any previously-built avatar. +func load_loadout(loadout, root_node: Node3D = null) -> void: if root_node != null: root = root_node - else: - root = Node3D.new() - root.name = "AvatarVisualizer" + if root == null or loadout == null: + return - # Create the body skeleton. - body_skeleton = Skeleton3D.new() - body_skeleton.name = "AvatarBody" - root.add_child(body_skeleton) + clear() + current_loadout = loadout - # 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 _catalog == null: + var sys = root.get_node_or_null("/root/AvatarSystem") + if sys != null: + _catalog = sys.catalog + if _catalog == null: + _catalog = AvatarCatalog.new() + _catalog.load_catalog("res://assets/avatars/catalog.json") - if slot_item == null or slot_item.asset_id == "": - # Slot is empty — create a placeholder. - _add_placeholder_slot(slot_def) - continue + _loader = AvatarLoader.new() + root.add_child(_loader) + _loader.init_loader(_catalog) - # Load the slot's asset. - var slot_result: AvatarLoader.SlotResult = await AvatarLoader.load_slot( - slot_item, - slot_def, - loadout.skin_tone, - ) + var base: Node3D = _loader.load_base_body(loadout.base_body) + if base == null: + return + root.add_child(base) + avatar_node = base - 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 + for slot_id in AvatarSlotRegistry.SLOT_IDS: + var value = loadout.slots.get(slot_id) + if slot_id == "accessory": + if value is Array: + for aid in value: + if aid is String and not (aid as String).is_empty(): + _loader.load_attachment(slot_id, aid) + elif value is String and not (value as String).is_empty(): + _loader.load_attachment(slot_id, value) - # Attach the loaded tree to the root. - _attach_slot_tree(slot_result, slot_def) + for slot_id in AvatarSlotRegistry.BODY_PART_SLOTS: + var value = loadout.slots.get(slot_id) + if value is String and not (value as String).is_empty(): + _loader.set_base_mesh_visible(slot_id, false) - # 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. +## Tear down the current preview avatar. func clear() -> void: - if root != null: - root.queue_free() - root = null - body_skeleton = null - current_loadout = null - cleared.emit() + if avatar_node != null and is_instance_valid(avatar_node): + avatar_node.queue_free() + avatar_node = null + if _loader != null and is_instance_valid(_loader): + _loader.queue_free() + _loader = 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 + var out: Array[MeshInstance3D] = [] + if avatar_node: + _collect_meshes(avatar_node, out) + return out -## 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: +func _collect_meshes(node: Node, out: Array[MeshInstance3D]) -> void: if node is MeshInstance3D: - result.append(node as MeshInstance3D) + out.append(node) 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) + _collect_meshes(child, out) diff --git a/scripts/avatar_customization_screen.gd b/scripts/avatar_customization_screen.gd new file mode 100644 index 0000000..4fd2aa5 --- /dev/null +++ b/scripts/avatar_customization_screen.gd @@ -0,0 +1,20 @@ +extends Control +## Full-screen host for the AvatarCustomizationUI. +## +## The UI itself (scripts/avatar/avatar_customization_ui.gd) builds its own +## layout programmatically and emits `closed` when the player backs out; this +## thin wrapper is what makes it a navigable scene reachable from the main menu. + +const AvatarCustomizationUI := preload("res://scripts/avatar/avatar_customization_ui.gd") + + +func _ready() -> void: + var ui := AvatarCustomizationUI.new() + ui.name = "AvatarCustomizationUI" + ui.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(ui) + ui.closed.connect(_on_closed) + + +func _on_closed() -> void: + get_tree().change_scene_to_file("res://scenes/main_menu.tscn") diff --git a/scripts/main_menu.gd b/scripts/main_menu.gd index b2c5d0c..824411d 100644 --- a/scripts/main_menu.gd +++ b/scripts/main_menu.gd @@ -40,6 +40,7 @@ var _auth_button: Button var _status_label: Label var _welcome_label: Label var _play_button: Button +var _customize_button: Button var _quit_button: Button var _is_register_mode: bool = false @@ -63,6 +64,7 @@ func _ready() -> void: _status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel _welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel _play_button = $CenterContainer/VBoxContainer/PlayButton + _customize_button = $CenterContainer/VBoxContainer/CustomizeButton _quit_button = $CenterContainer/VBoxContainer/QuitButton # Default to Play being gated. When a token exists, the async session @@ -70,6 +72,7 @@ func _ready() -> void: # keeping the button disabled until validation succeeds prevents a fast # click from navigating to world_list with an expired/invalid token. _play_button.disabled = true + _customize_button.disabled = true # Check whether a valid session already exists (e.g. user logged in, # navigated to world_list, then clicked Back). If so, restore the @@ -80,6 +83,7 @@ func _ready() -> void: var username: String = str((me_result.data as Dictionary).get("username", "")) if username != "": _show_authenticated(username) + await _prime_avatar() auth_state_ready.emit() return @@ -115,6 +119,7 @@ func _show_authenticated(username: String) -> void: _auth_button.visible = false _auth_mode_label.visible = false _play_button.disabled = false + _customize_button.disabled = false _auth_buttons_row.visible = false print("Restored authenticated session for: ", username) @@ -129,6 +134,7 @@ func _reset_to_logged_out() -> void: _over_18_check.visible = false _over_18_check.button_pressed = false _play_button.disabled = true # Gate play behind successful auth. + _customize_button.disabled = true _update_auth_mode_ui() @@ -142,6 +148,12 @@ func _on_play_button_pressed() -> void: get_tree().change_scene_to_file("res://scenes/world_list.tscn") +func _on_customize_button_pressed() -> void: + if _customize_button.disabled: + return + get_tree().change_scene_to_file("res://scenes/avatar_customization.tscn") + + func _on_quit_button_pressed() -> void: get_tree().quit() @@ -214,7 +226,7 @@ func _on_auth_button_pressed() -> void: _clear_status() var result := await ApiClient.register(username, email, password, true) - _on_auth_complete(result) + await _on_auth_complete(result) else: _auth_button.disabled = true _auth_button.text = "Logging in..." @@ -222,7 +234,7 @@ func _on_auth_button_pressed() -> void: _clear_status() var result := await ApiClient.login(username, password) - _on_auth_complete(result) + await _on_auth_complete(result) func _on_auth_complete(result: Dictionary) -> void: @@ -232,7 +244,10 @@ func _on_auth_complete(result: Dictionary) -> void: _is_authenticating = false if result.ok: - # Success — hide the auth form fields, show only the welcome label. + # Update the UI to the authenticated state first, synchronously, so + # the player sees "Welcome" + an enabled Play immediately -- then + # prime the avatar (a catalog load + a loadout fetch) in the + # background. A slow or failing prime must never gate getting in. _username_edit.visible = false _password_edit.visible = false _email_edit.visible = false @@ -243,13 +258,40 @@ func _on_auth_complete(result: Dictionary) -> void: _welcome_label.text = "Welcome, %s!" % _username_edit.text.strip_edges() _welcome_label.visible = true _play_button.disabled = false + _customize_button.disabled = false _auth_buttons_row.visible = false print("Auth successful. Token stored.") + await _prime_avatar() else: # Failure — show backend error detail. _show_status(result.detail) +# ─── Avatar priming ─────────────────────────────────────────────────────────── + +## After a successful auth, load the asset catalog and the player's saved +## loadout so the in-world player renders as their avatar. Best-effort: a +## failure here just means the player falls back to the default capsule, it +## never blocks getting into the game. +func _prime_avatar() -> void: + if not Engine.has_singleton("AvatarSystem") and get_node_or_null("/root/AvatarSystem") == null: + return + var avatars = get_node_or_null("/root/AvatarSystem") + if avatars == null: + return + if not await avatars.ensure_catalog(): + push_warning("main_menu: avatar catalog failed to load; using fallback look") + return + var res: Dictionary = await ApiClient.get_avatar_loadout() + if res.ok and res.data is Dictionary and (res.data as Dictionary).has("data"): + avatars.set_loadout_from_dict((res.data as Dictionary)["data"]) + else: + # No saved loadout reachable -- fall back to the catalog's first base body. + var bases: Array = avatars.get_assets_for_slot("base_body") + if bases.size() > 0: + avatars.set_loadout_from_dict({"base_body": bases[0], "slots": {}}) + + # ─── Status helpers ─────────────────────────────────────────────────────────── func _show_status(message: String) -> void: diff --git a/scripts/player.gd b/scripts/player.gd index 402767a..828ac3f 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -32,7 +32,14 @@ const PITCH_LIMIT: float = deg_to_rad(89.0) var _input_direction := Vector2.ZERO var _camera_pitch: float = 0.0 +## Vertical offset applied to the assembled avatar so its feet (origin y=0) +## line up with the bottom of the player's collision capsule. +const AVATAR_FEET_OFFSET: float = -0.8 + @onready var _camera: Camera3D = $Camera3D +@onready var _capsule_mesh: MeshInstance3D = $CapsuleMesh + +var _avatar_view: Node3D = null # --------------------------------------------------------------------------- @@ -67,6 +74,28 @@ func _ready() -> void: if DisplayServer.get_name() != "headless": Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + _apply_avatar() + + +## Replace the placeholder capsule with the player's assembled avatar, if the +## AvatarSystem has a loadout primed (set at login by main_menu.gd). Falls back +## to the capsule silently when there's no avatar to show -- e.g. running this +## scene directly in a test, or an offline/failed catalog load. +## TODO(PC3): a third-person camera so the local player actually sees this; +## for now it's mainly for other players (Phase 2) and looking down. +func _apply_avatar() -> void: + var avatars := get_node_or_null("/root/AvatarSystem") + if avatars == null or not avatars.has_method("build_current_avatar"): + return + var avatar: Node3D = avatars.build_current_avatar() + if avatar == null: + return + avatar.position = Vector3(0.0, AVATAR_FEET_OFFSET, 0.0) + add_child(avatar) + _avatar_view = avatar + if _capsule_mesh: + _capsule_mesh.visible = false + # --------------------------------------------------------------------------- # _unhandled_input — capture keyboard / mouse for movement diff --git a/scripts/smoke_test_avatar_in_world.gd b/scripts/smoke_test_avatar_in_world.gd new file mode 100644 index 0000000..b08f824 --- /dev/null +++ b/scripts/smoke_test_avatar_in_world.gd @@ -0,0 +1,80 @@ +extends SceneTree +## Smoke test: a primed avatar loadout ends up rendered on the in-world player. +## +## Primes AvatarSystem directly (no backend needed -- main_menu.gd is what +## fetches the loadout over HTTP in the real flow) then loads scenes/game.tscn +## and checks the Player swapped its placeholder capsule for the assembled +## avatar. +## +## Run: godot --headless --path . --script res://scripts/smoke_test_avatar_in_world.gd + +var passed := 0 +var failed := 0 + + +func _initialize() -> void: + _run() + + +func _run() -> void: + await process_frame + + var avatars := root.get_node_or_null("AvatarSystem") + if not _check("AvatarSystem autoload exists", avatars != null): + return _finish() + + _check("catalog loads", await avatars.ensure_catalog()) + + var bases: Array = avatars.get_assets_for_slot("base_body") + if not _check("catalog has a base body", bases.size() > 0): + return _finish() + + var ok: bool = avatars.set_loadout_from_dict({ + "base_body": bases[0], + "slots": {"hair": "hair_ponytail_01", "chest": "chest_bandit_01"}, + }) + _check("loadout primes from a backend-shaped dict", ok and avatars.has_loadout()) + + var err := change_scene_to_file("res://scenes/game.tscn") + _check("game.tscn loads", err == OK) + # Give _ready() (scene swap + avatar assembly) a few frames to settle. + for i in 6: + await process_frame + + var game := current_scene + var player: Node = game.get_node_or_null("Player") if game else null + if not _check("player exists in the world", player != null): + return _finish() + + var avatar := player.get_node_or_null("Avatar") + _check("player rendered the assembled avatar (not the capsule)", avatar != null) + + var capsule = player.get_node_or_null("CapsuleMesh") + _check("placeholder capsule mesh is hidden", capsule != null and not capsule.visible) + + if avatar: + var skel := avatar.get_node_or_null("Skeleton3D") as Skeleton3D + _check("avatar carries the canonical rig", skel != null and skel.get_bone_count() == 24) + var mounts := 0 + if skel: + for c in skel.get_children(): + if c is BoneAttachment3D: + mounts += 1 + _check("equipped hair + chest are bone-mounted", mounts >= 2) + + _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)