## 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 # --------------------------------------------------------------------------- ## 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 # 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()