461 lines
14 KiB
GDScript
461 lines
14 KiB
GDScript
## 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()
|