Avatar loader & customization system: merge onto main + fix regressions, add smoke coverage #53

Merged
agent-bot merged 27 commits from agent/896fbb97de1d into main 2026-08-21 01:35:29 +00:00
4 changed files with 715 additions and 0 deletions
Showing only changes of commit 689c51e179 - Show all commits
+149
View File
@@ -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)
+44
View File
@@ -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"
+460
View File
@@ -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()
+62
View File
@@ -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()