diff --git a/scenes/avatar_customization.tscn b/scenes/avatar_customization.tscn new file mode 100644 index 0000000..9f7d592 --- /dev/null +++ b/scenes/avatar_customization.tscn @@ -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) diff --git a/scenes/main.tscn b/scenes/main.tscn new file mode 100644 index 0000000..e390f2c --- /dev/null +++ b/scenes/main.tscn @@ -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" diff --git a/scripts/avatar/avatar_customization_ui.gd b/scripts/avatar/avatar_customization_ui.gd new file mode 100644 index 0000000..721be73 --- /dev/null +++ b/scripts/avatar/avatar_customization_ui.gd @@ -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() diff --git a/scripts/main_menu.gd b/scripts/main_menu.gd new file mode 100644 index 0000000..24ffb51 --- /dev/null +++ b/scripts/main_menu.gd @@ -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()