## 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 # Preloaded rather than referenced by global class_name -- see avatar_loadout.gd. const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd") const AvatarCatalog := preload("res://scripts/avatar/avatar_catalog.gd") const AvatarLoader := preload("res://scripts/avatar/avatar_loader.gd") const AvatarVisualizer := preload("res://scripts/avatar/avatar_visualizer.gd") const AvatarSlotRegistry := preload("res://scripts/avatar/avatar_slot_registry.gd") # --------------------------------------------------------------------------- # 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 = "" ## Turntable: the preview avatar's Y rotation (radians), whether the player is ## currently dragging it, and how long since they last touched it (drives the ## slow idle auto-spin). Starts facing the camera. var _turntable_yaw: float = PI var _turntable_dragging: bool = false var _turntable_idle: float = 0.0 const _TURNTABLE_IDLE_SPIN_DELAY := 2.5 const _TURNTABLE_IDLE_SPIN_SPEED := 0.5 const _TURNTABLE_DRAG_SPEED := 0.01 ## 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() # 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() # 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 (takes all the leftover width) --- var preview_panel := PanelContainer.new() preview_panel.name = "PreviewPanel" preview_panel.custom_minimum_size = Vector2(420, 0) preview_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL 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) var preview_vbox := VBoxContainer.new() preview_vbox.add_theme_constant_override("separation", 6) preview_margin.add_child(preview_vbox) _preview_container = SubViewportContainer.new() _preview_container.name = "PreviewViewportContainer" _preview_container.stretch = true _preview_container.custom_minimum_size = Vector2(400, 520) _preview_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL _preview_container.size_flags_vertical = Control.SIZE_EXPAND_FILL _preview_container.mouse_filter = Control.MOUSE_FILTER_STOP _preview_container.gui_input.connect(_on_preview_gui_input) preview_vbox.add_child(_preview_container) var rotate_hint := Label.new() rotate_hint.text = "drag to rotate" rotate_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER rotate_hint.add_theme_color_override("font_color", Color(1, 1, 1, 0.5)) rotate_hint.add_theme_font_size_override("font_size", 12) preview_vbox.add_child(rotate_hint) _preview_viewport = SubViewport.new() _preview_viewport.name = "PreviewViewport" _preview_viewport.size = Vector2i(400, 520) _preview_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS _preview_viewport.transparent_bg = true # Its own 3D world -- a SubViewport that shares the (Control-only) main # world has no scenario/environment set up and renders nothing. _preview_viewport.own_world_3d = true _preview_container.add_child(_preview_viewport) # Environment: ambient fill so the model reads even from a bad light angle. var world_env := WorldEnvironment.new() world_env.name = "PreviewEnv" var env := Environment.new() env.background_mode = Environment.BG_COLOR env.background_color = Color(0.10, 0.11, 0.14) env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR env.ambient_light_color = Color(0.62, 0.64, 0.7) env.ambient_light_energy = 0.7 world_env.environment = env _preview_viewport.add_child(world_env) # Camera for the preview -- framed to fit the full ~2 m avatar. _preview_camera = Camera3D.new() _preview_camera.name = "PreviewCamera" # look_at needs a global transform, which only exists once in the tree. _preview_camera.look_at_from_position( Vector3(0, 1.05, 3.5), Vector3(0, 0.98, 0), Vector3.UP) _preview_camera.current = true _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) # Key + fill lights so the avatar is clearly visible. var key := DirectionalLight3D.new() key.name = "PreviewKeyLight" key.rotation_degrees = Vector3(-35, 35, 0) key.light_energy = 1.0 _preview_viewport.add_child(key) var fill := DirectionalLight3D.new() fill.name = "PreviewFillLight" fill.rotation_degrees = Vector3(-20, -140, 0) fill.light_energy = 0.35 _preview_viewport.add_child(fill) # --- 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) # --------------------------------------------------------------------------- # Turntable (drag to rotate the preview, slow auto-spin when idle) # --------------------------------------------------------------------------- func _process(delta: float) -> void: if _preview_root == null: return if _turntable_dragging: _turntable_idle = 0.0 else: _turntable_idle += delta if _turntable_idle > _TURNTABLE_IDLE_SPIN_DELAY: _turntable_yaw += delta * _TURNTABLE_IDLE_SPIN_SPEED _preview_root.rotation.y = _turntable_yaw func _on_preview_gui_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: _turntable_dragging = event.pressed _turntable_idle = 0.0 elif event is InputEventMouseMotion and _turntable_dragging: _turntable_yaw -= event.relative.x * _TURNTABLE_DRAG_SPEED _turntable_idle = 0.0 # --------------------------------------------------------------------------- # 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 across a deep-to-pale skin range. var t := value loadout.skin_tone = Color( 0.35 + 0.60 * t, # R 0.22 + 0.55 * t, # G 0.16 + 0.50 * t, # B 1.0, ) _refresh_preview() loadout_changed.emit(loadout) # --------------------------------------------------------------------------- # Save / load # --------------------------------------------------------------------------- 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() # 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 = "Saved locally (not signed in)." 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()