Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2041 lines
68 KiB
GDScript
2041 lines
68 KiB
GDScript
# gdlint:disable=max-file-lines
|
||
class_name CharacterCreation
|
||
extends Control
|
||
## #705: Character creation screen.
|
||
## Live 3D preview via SubViewport + 5-tab customisation panel (Body/Head/Hair/Clothing/Accessories).
|
||
## Emits creation_confirmed(descriptor) on start, creation_cancelled on back.
|
||
##
|
||
## Game flow: main_menu → character_select (archetype) → character_creation → main.tscn
|
||
## D-146 (tile-scale preview, heavy zoom), D-155 (cardinal rotation only),
|
||
## D-158 (frontal -5° camera default), D-159 (11 body types), D-165 (color picker palette)
|
||
|
||
signal creation_confirmed(descriptor: CharacterVisualDescriptor)
|
||
signal creation_cancelled
|
||
|
||
# --- Color palette ---
|
||
const BG_COLOR := Color(0.03, 0.03, 0.06, 1.0)
|
||
const PANEL_BG := Color(0.05, 0.05, 0.08, 1.0)
|
||
const BORDER_COLOR := Color(0.12, 0.15, 0.20, 1.0)
|
||
const TEXT_COLOR := Color(0.784, 0.816, 0.878, 1.0) # INSERT_COLOR_TEXT
|
||
const TEXT_DIM := Color(0.53, 0.56, 0.63, 1.0)
|
||
const HIGHLIGHT_COLOR := Color(0.906, 0.773, 0.278, 1.0) # INSERT_COLOR_HOVER
|
||
const ACTIVE_COLOR := Color(0.42, 0.79, 0.65, 1.0) # INSERT_COLOR_ACTIVE
|
||
const ITEM_NORMAL_BG := Color(0.07, 0.07, 0.10, 1.0)
|
||
const ITEM_SELECTED_BG := Color(0.10, 0.13, 0.18, 1.0)
|
||
const ITEM_SELECTED_BORDER := Color(0.906, 0.773, 0.278, 1.0) # INSERT_COLOR_HOVER — selection border
|
||
|
||
# --- Camera presets (D-158) ---
|
||
# Pitch is degrees below horizontal: 5=near eye-level, 30=steeper, 80=near top-down.
|
||
# Negative values = tilt downward (camera looks toward ground).
|
||
const CAM_PITCHES: Array[float] = [-5.0, -30.0, -80.0] # frontal, dramatic, overhead
|
||
const CAM_PITCH_NAMES := ["Frontal", "Dramatic", "Overhead"]
|
||
const CAM_DISTANCE: float = 3.8
|
||
const CAM_TARGET_HEIGHT: float = 0.85
|
||
|
||
# --- Rotation (D-155: cardinal only, Q=counter-clockwise, E=clockwise) ---
|
||
const CARDINAL_DIRS := ["south", "west", "north", "east"]
|
||
|
||
# --- Body type grid layout (D-159) ---
|
||
# Female row: ThinF, AverageF, MuscularF, HeavyF, TeenF
|
||
# Male row: ThinM, AverageM, MuscularM, HeavyM, TeenM
|
||
# Child: Child
|
||
const BODY_FEMALE_ROW := [
|
||
CharacterVisualDescriptor.BodyType.THIN_F,
|
||
CharacterVisualDescriptor.BodyType.AVERAGE_F,
|
||
CharacterVisualDescriptor.BodyType.MUSCULAR_F,
|
||
CharacterVisualDescriptor.BodyType.HEAVY_F,
|
||
CharacterVisualDescriptor.BodyType.TEEN_F,
|
||
]
|
||
const BODY_MALE_ROW := [
|
||
CharacterVisualDescriptor.BodyType.THIN_M,
|
||
CharacterVisualDescriptor.BodyType.AVERAGE_M,
|
||
CharacterVisualDescriptor.BodyType.MUSCULAR_M,
|
||
CharacterVisualDescriptor.BodyType.HEAVY_M,
|
||
CharacterVisualDescriptor.BodyType.TEEN_M,
|
||
]
|
||
const BODY_TYPE_LABELS: Dictionary = {
|
||
CharacterVisualDescriptor.BodyType.THIN_F: "Thin",
|
||
CharacterVisualDescriptor.BodyType.AVERAGE_F: "Average",
|
||
CharacterVisualDescriptor.BodyType.MUSCULAR_F: "Muscular",
|
||
CharacterVisualDescriptor.BodyType.HEAVY_F: "Heavy",
|
||
CharacterVisualDescriptor.BodyType.TEEN_F: "Teen",
|
||
CharacterVisualDescriptor.BodyType.THIN_M: "Thin",
|
||
CharacterVisualDescriptor.BodyType.AVERAGE_M: "Average",
|
||
CharacterVisualDescriptor.BodyType.MUSCULAR_M: "Muscular",
|
||
CharacterVisualDescriptor.BodyType.HEAVY_M: "Heavy",
|
||
CharacterVisualDescriptor.BodyType.TEEN_M: "Teen",
|
||
CharacterVisualDescriptor.BodyType.CHILD: "Child",
|
||
}
|
||
|
||
# --- Hair / facial hair / eyebrows ---
|
||
const FACIAL_HAIR_OPTIONS := ["", "beard", "moustache", "mutton_chops"]
|
||
const FACIAL_HAIR_LABELS := ["None", "Beard", "Moustache", "Mutton Chops"]
|
||
const EYEBROW_OPTIONS := ["regular", "female", "teen", "thick"]
|
||
const EYEBROW_LABELS := ["Regular", "Female", "Teen", "Thick"]
|
||
|
||
# --- Clothing slots ---
|
||
const CLOTHING_SLOTS := ["torso", "legs", "feet", "hands"]
|
||
const CLOTHING_SLOT_LABELS := ["Torso", "Legs", "Feet", "Hands"]
|
||
|
||
# --- Accessory slots (no held_l/held_r — play-time decisions per wireframe spec) ---
|
||
const ACCESSORY_SLOTS := [
|
||
"hat",
|
||
"goggles",
|
||
"mask",
|
||
"backpack",
|
||
"belt",
|
||
"wrist_l",
|
||
"wrist_r",
|
||
"earring_l",
|
||
"earring_r",
|
||
"necklace",
|
||
]
|
||
const ACCESSORY_SLOT_LABELS := [
|
||
"Hat",
|
||
"Goggles",
|
||
"Mask",
|
||
"Backpack",
|
||
"Belt",
|
||
"Wrist L",
|
||
"Wrist R",
|
||
"Earring L",
|
||
"Earring R",
|
||
"Necklace",
|
||
]
|
||
|
||
# --- D-165 palette (5 chromatic rows + 1 neutral row, 9 cols each) ---
|
||
# Hardcoded hex per D-165 spec (HSL L=18→76% per chromatic row, S fixed per row).
|
||
const PALETTE_COLORS: Array[Array] = [
|
||
# Row 1: Reds H=0° S=35%
|
||
[
|
||
"#3d1d1d",
|
||
"#522727",
|
||
"#673131",
|
||
"#7f3d3d",
|
||
"#974848",
|
||
"#af5959",
|
||
"#bc7575",
|
||
"#c99090",
|
||
"#d7acac"
|
||
],
|
||
# Row 2: Greens H=150° S=28%
|
||
[
|
||
"#213a2d",
|
||
"#2c4e3d",
|
||
"#37614c",
|
||
"#43785e",
|
||
"#508f70",
|
||
"#62a684",
|
||
"#7cb599",
|
||
"#96c4ad",
|
||
"#b0d2c1"
|
||
],
|
||
# Row 3: Blues H=215° S=35%
|
||
[
|
||
"#1d2b3d",
|
||
"#273952",
|
||
"#314867",
|
||
"#3d587f",
|
||
"#486997",
|
||
"#597daf",
|
||
"#7593bc",
|
||
"#90a8c9",
|
||
"#acbed7"
|
||
],
|
||
# Row 4: Purples H=275° S=28%
|
||
[
|
||
"#30213a",
|
||
"#402c4e",
|
||
"#503761",
|
||
"#624378",
|
||
"#75508f",
|
||
"#8a62a6",
|
||
"#9d7cb5",
|
||
"#b196c4",
|
||
"#c4b0d2"
|
||
],
|
||
# Row 5: Browns H=28° S=35%
|
||
[
|
||
"#3d2c1d",
|
||
"#523b27",
|
||
"#674a31",
|
||
"#7f5c3d",
|
||
"#976d48",
|
||
"#af8159",
|
||
"#bc9675",
|
||
"#c9ab90",
|
||
"#d7c0ac"
|
||
],
|
||
# Row 6: Neutral grays
|
||
[
|
||
"#000000",
|
||
"#1f1f1f",
|
||
"#3f3f3f",
|
||
"#5f5f5f",
|
||
"#7f7f7f",
|
||
"#9f9f9f",
|
||
"#bfbfbf",
|
||
"#dfdfdf",
|
||
"#ffffff"
|
||
],
|
||
]
|
||
const PALETTE_COLS := 9
|
||
const RECENT_SLOTS := 9
|
||
|
||
const CAM_ZOOM_MIN: float = 0.25 # closest zoom (face detail)
|
||
const CAM_ZOOM_MAX: float = 3.0 # farthest zoom (crowd level)
|
||
const CAM_ZOOM_STEP: float = 0.12
|
||
|
||
const MANIFEST_PATH := "res://assets/characters/manifest.json"
|
||
|
||
const SCREENSHOT_DIR := "user://screenshots/"
|
||
const CARDINAL_NAMES: Array[String] = ["south", "east", "north", "west"]
|
||
|
||
# --- Descriptor and preview state ---
|
||
var _descriptor: CharacterVisualDescriptor
|
||
var _char_visual: CharacterVisual = null
|
||
var _facing_idx: int = 0 # index into CARDINAL_DIRS (0 = south, default face-forward)
|
||
var _cam_pitch_idx: int = 0 # 0=frontal(-5°), 1=dramatic(-30°), 2=overhead(-80°)
|
||
var _cam_zoom: float = 1.0 # 1.0 = default distance, <1.0 = zoomed in
|
||
var _cam_zoom_offset: Vector3 = Vector3.ZERO # camera offset toward cursor when zoomed
|
||
|
||
# --- Tab active slot state ---
|
||
var _active_clothing_slot: String = "torso"
|
||
var _active_accessory_slot: String = "hat"
|
||
|
||
# --- Body/skin tone buttons shared across Body + Head tabs ---
|
||
var _body_type_btns: Dictionary = {} # BodyType int -> Button
|
||
var _body_skin_btns: Array[Button] = [] # 9 buttons in Body tab skin dock
|
||
var _head_skin_btns: Array[Button] = [] # 9 buttons in Head tab skin dock
|
||
|
||
# --- Head / hair buttons ---
|
||
var _head_item_btns: Dictionary = {} # head_id -> Button
|
||
var _hair_item_btns: Dictionary = {} # hair_id -> Button
|
||
var _facial_hair_btns: Array[Button] = []
|
||
var _eyebrow_btns: Array[Button] = []
|
||
|
||
# --- Clothing/accessory grid content per slot ---
|
||
var _clothing_grids: Dictionary = {} # slot -> GridContainer
|
||
var _clothing_slot_btns: Array[Button] = []
|
||
var _clothing_item_btns: Dictionary = {} # slot -> Dictionary {item_id -> Button}
|
||
var _accessory_grids: Dictionary = {} # slot -> GridContainer
|
||
var _accessory_slot_btns: Array[Button] = []
|
||
var _accessory_item_btns: Dictionary = {} # slot -> Dictionary {item_id -> Button}
|
||
|
||
# --- Color dock swatch references ---
|
||
var _hair_primary_swatch: Button = null
|
||
var _hair_highlight_swatch: Button = null
|
||
var _eyebrow_tint_swatch: Button = null
|
||
var _facial_hair_tint_swatch: Button = null
|
||
var _clothing_primary_swatches: Dictionary = {} # slot -> Button
|
||
var _clothing_secondary_swatches: Dictionary = {} # slot -> Button
|
||
var _clothing_accent_swatches: Dictionary = {} # slot -> Button
|
||
var _accessory_primary_swatches: Dictionary = {} # slot -> Button
|
||
var _accessory_secondary_swatches: Dictionary = {} # slot -> Button
|
||
|
||
# --- Auto-derive flags (true = auto-derive from hair/clothing primary) ---
|
||
# Note: hair highlight is always auto-derived (display-only swatch, no override).
|
||
var _eyebrow_tint_auto: bool = true
|
||
var _facial_hair_tint_auto: bool = true
|
||
var _clothing_secondary_auto: Dictionary = {} # slot -> bool
|
||
var _clothing_accent_auto: Dictionary = {} # slot -> bool
|
||
var _accessory_secondary_auto: Dictionary = {} # slot -> bool
|
||
|
||
# --- Color picker modal state ---
|
||
var _modal_callback: Callable
|
||
var _modal_original_color: Color = Color.WHITE
|
||
var _modal_preview_swatch: Button = null # the swatch button being edited
|
||
var _modal_hex_input: LineEdit = null
|
||
var _modal_swatch_btns: Array[Button] = [] # 54 palette swatches
|
||
var _recent_colors: Array[Color] = [] # persisted recent custom colors
|
||
var _modal_recent_btns: Array[Button] = []
|
||
|
||
# --- Dock container references (set during tab build; avoid fragile child-index traversal) ---
|
||
var _clothing_dock_container: Control = null
|
||
var _accessory_dock_container: Control = null
|
||
|
||
# --- Cached asset scan results (populated once during tab build, reused by randomize) ---
|
||
var _cached_hair_ids: Array = []
|
||
var _cached_head_ids: Array = []
|
||
|
||
# --- Per-tab search text ---
|
||
var _tab_search: Array[String] = ["", "", "", "", ""] # one per tab index
|
||
## Per-tab grid container for search filtering (index = tab index 0–4).
|
||
## Null for tabs without a primary grid (Body uses HBoxRows; Clothing/Accessories use slot grids).
|
||
var _tab_grids: Array[GridContainer] = [null, null, null, null, null]
|
||
|
||
# --- Asset manifest (loaded once, replaces filesystem scanning) ---
|
||
var _manifest: Dictionary = {}
|
||
|
||
# --- Debug tab state ---
|
||
var _debug_toggles: Dictionary = {} # seg_name -> CheckButton
|
||
|
||
# --- Screenshot / automated testing state ---
|
||
var _screenshot_delay_frames: int = 5 # wait N frames for scene to render
|
||
var _screenshot_pending: bool = false
|
||
var _screenshot_frame_count: int = 0
|
||
var _quit_after_screenshot: bool = false
|
||
var _screenshot_cardinals: bool = false
|
||
var _screenshot_cardinal_idx: int = 0
|
||
|
||
# resolved in _ready() — @onready path fails when instantiated as child
|
||
var _tab_container: TabContainer = null
|
||
|
||
# --- @onready references to .tscn nodes ---
|
||
@onready var _viewport: SubViewport = $Layout/PreviewPanel/SubViewportContainer/SubViewport
|
||
@onready
|
||
var _char_anchor: Node3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/CharacterVisualAnchor
|
||
@onready
|
||
var _preview_camera: Camera3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/PreviewCamera
|
||
@onready
|
||
var _rotate_left_btn: Button = $Layout/PreviewPanel/PreviewOverlay/RotateButtons/RotateLeftBtn
|
||
@onready
|
||
var _rotate_right_btn: Button = $Layout/PreviewPanel/PreviewOverlay/RotateButtons/RotateRightBtn
|
||
@onready var _cam_angle_btn: Button = $Layout/PreviewPanel/PreviewOverlay/CamAngleBtn
|
||
@onready var _footer_back: Button = $Footer/BackBtn
|
||
@onready var _footer_randomize: Button = $Footer/RandomizeBtn
|
||
@onready var _footer_start: Button = $Footer/StartBtn
|
||
@onready var _modal_root: Control = $ColorPickerModal
|
||
|
||
# =============================================================================
|
||
# Lifecycle
|
||
# =============================================================================
|
||
|
||
|
||
func _load_manifest() -> void:
|
||
var file := FileAccess.open(MANIFEST_PATH, FileAccess.READ)
|
||
if file == null:
|
||
push_warning(
|
||
"CharacterCreation: manifest not found at %s — using empty defaults" % MANIFEST_PATH
|
||
)
|
||
_manifest = {}
|
||
return
|
||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||
file.close()
|
||
if parsed is Dictionary:
|
||
_manifest = parsed as Dictionary
|
||
else:
|
||
push_warning("CharacterCreation: manifest parse failed — using empty defaults")
|
||
_manifest = {}
|
||
|
||
|
||
func _manifest_array(key: String) -> Array:
|
||
if _manifest.has(key):
|
||
return _manifest[key] as Array
|
||
return []
|
||
|
||
|
||
func _ready() -> void:
|
||
_load_manifest()
|
||
|
||
# Build tab panel programmatically — Godot 4.6 destroys TabContainer children
|
||
# defined in .tscn when the scene is instantiated as a child of another scene.
|
||
var layout: HBoxContainer = get_node_or_null("Layout") as HBoxContainer
|
||
if layout == null:
|
||
push_error("CharacterCreation: Layout node not found")
|
||
return
|
||
|
||
var tab_panel := VBoxContainer.new()
|
||
tab_panel.name = "TabPanel"
|
||
tab_panel.size_flags_horizontal = Control.SIZE_FILL | Control.SIZE_EXPAND
|
||
tab_panel.size_flags_stretch_ratio = 2.0
|
||
tab_panel.add_theme_constant_override("separation", 0)
|
||
layout.add_child(tab_panel)
|
||
|
||
_tab_container = TabContainer.new()
|
||
_tab_container.name = "TabContainer"
|
||
_tab_container.size_flags_vertical = Control.SIZE_FILL | Control.SIZE_EXPAND
|
||
_tab_container.add_theme_font_size_override("font_size", 12)
|
||
_tab_container.add_theme_color_override("font_color", Color(0.784, 0.816, 0.878, 1.0))
|
||
tab_panel.add_child(_tab_container)
|
||
|
||
# Build tabs dynamically — only show tabs that have content in the manifest
|
||
var tab_builders: Array[Dictionary] = []
|
||
tab_builders.append({"name": "Body", "build": _build_body_tab, "always": true})
|
||
var has_heads := not _manifest_array("heads").is_empty()
|
||
if has_heads:
|
||
tab_builders.append({"name": "Head", "build": _build_head_tab, "always": false})
|
||
var has_hair := not _manifest_array("hair").is_empty()
|
||
if has_hair:
|
||
tab_builders.append({"name": "Hair", "build": _build_hair_tab, "always": false})
|
||
var clothing_data: Variant = _manifest.get("clothing", {})
|
||
var has_clothing: bool = (
|
||
clothing_data is Dictionary and not (clothing_data as Dictionary).is_empty()
|
||
)
|
||
if has_clothing:
|
||
tab_builders.append({"name": "Clothing", "build": _build_clothing_tab, "always": false})
|
||
var has_accessories := not _manifest_array("accessories").is_empty()
|
||
if has_accessories:
|
||
tab_builders.append(
|
||
{"name": "Accessories", "build": _build_accessories_tab, "always": false}
|
||
)
|
||
tab_builders.append({"name": "Debug", "build": _build_debug_tab, "always": true})
|
||
|
||
for tb in tab_builders:
|
||
var tab := Control.new()
|
||
tab.name = tb["name"]
|
||
_tab_container.add_child(tab)
|
||
|
||
_descriptor = CharacterVisualDescriptor.new()
|
||
_descriptor.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M
|
||
_descriptor.skin_tone = 2 # light_olive — readable middle-ground default
|
||
_descriptor.eyebrow_id = "" # eyebrows from body segment, not separate asset
|
||
_descriptor.hair_id = "bob"
|
||
_descriptor.hair_tint = Color(0.55, 0.35, 0.20) # warm brown default
|
||
_descriptor.eyebrow_tint = _descriptor.hair_tint
|
||
_descriptor.facial_hair_tint = _descriptor.hair_tint
|
||
|
||
_char_visual = CharacterVisual.new()
|
||
_char_anchor.add_child(_char_visual)
|
||
_char_visual.load_descriptor(_descriptor)
|
||
_char_visual.set_facing(CARDINAL_DIRS[_facing_idx])
|
||
_update_camera_angle()
|
||
|
||
# Auto-capture screenshot when running standalone (not via MainMenu)
|
||
if OS.has_feature("standalone") or get_parent() == get_tree().root:
|
||
_load_test_config() # apply test config if --test-config passed
|
||
_schedule_screenshot()
|
||
|
||
_rotate_left_btn.pressed.connect(_on_rotate_left)
|
||
_rotate_right_btn.pressed.connect(_on_rotate_right)
|
||
_cam_angle_btn.pressed.connect(_on_cam_angle_toggle)
|
||
_footer_back.pressed.connect(_on_back)
|
||
_footer_randomize.pressed.connect(_on_randomize)
|
||
_footer_start.pressed.connect(_on_start)
|
||
|
||
_footer_back.text = UIStrings.get_text("character_creation.btn_back")
|
||
_footer_randomize.text = UIStrings.get_text("character_creation.btn_randomize")
|
||
_footer_start.text = UIStrings.get_text("character_creation.btn_start")
|
||
_rotate_left_btn.text = UIStrings.get_text("character_creation.btn_rotate_left")
|
||
_rotate_right_btn.text = UIStrings.get_text("character_creation.btn_rotate_right")
|
||
|
||
for i in tab_builders.size():
|
||
var builder: Callable = tab_builders[i]["build"]
|
||
builder.call(_tab_container.get_child(i))
|
||
_build_color_picker_modal()
|
||
|
||
_modal_root.visible = false
|
||
_update_facial_hair_visibility()
|
||
_update_cam_angle_label()
|
||
|
||
|
||
# =============================================================================
|
||
# Camera management (D-158)
|
||
# =============================================================================
|
||
|
||
|
||
func _update_camera_angle() -> void:
|
||
var pitch_deg := CAM_PITCHES[_cam_pitch_idx]
|
||
var pitch_rad := deg_to_rad(-pitch_deg) # negative = looking downward
|
||
var dist := CAM_DISTANCE * _cam_zoom
|
||
var cam_z := dist * cos(pitch_rad)
|
||
var cam_y := CAM_TARGET_HEIGHT + dist * sin(pitch_rad)
|
||
_preview_camera.position = Vector3(0.0, cam_y, cam_z) + _cam_zoom_offset
|
||
var look_target := Vector3(0.0, CAM_TARGET_HEIGHT, 0.0) + _cam_zoom_offset
|
||
_preview_camera.look_at(look_target, Vector3.UP)
|
||
|
||
|
||
func _cam_zoom_toward_cursor(_screen_pos: Vector2, zoom_delta: float) -> void:
|
||
_cam_zoom = clampf(_cam_zoom + zoom_delta, CAM_ZOOM_MIN, CAM_ZOOM_MAX)
|
||
|
||
if _cam_zoom >= 1.0:
|
||
# At or beyond default — look at body center
|
||
_cam_zoom_offset = Vector3.ZERO
|
||
else:
|
||
# Zoomed in — shift look target up toward head bone
|
||
if _char_visual and _char_visual._skeleton:
|
||
var head_idx := _char_visual._skeleton.find_bone("Head")
|
||
if head_idx >= 0:
|
||
var head_pos := (
|
||
_char_visual._skeleton.global_transform
|
||
* _char_visual._skeleton.get_bone_global_pose(head_idx).origin
|
||
)
|
||
# Blend from body center toward head as zoom increases
|
||
var blend := 1.0 - _cam_zoom # 0 at default, 0.75 at max zoom
|
||
_cam_zoom_offset.y = (head_pos.y - CAM_TARGET_HEIGHT) * blend
|
||
|
||
_update_camera_angle()
|
||
|
||
|
||
func _update_cam_angle_label() -> void:
|
||
# Label shows the NEXT angle the button will switch to
|
||
var next_idx := (_cam_pitch_idx + 1) % CAM_PITCHES.size()
|
||
_cam_angle_btn.text = CAM_PITCH_NAMES[next_idx]
|
||
|
||
|
||
func _on_cam_angle_toggle() -> void:
|
||
_cam_pitch_idx = (_cam_pitch_idx + 1) % CAM_PITCHES.size()
|
||
_update_camera_angle()
|
||
_update_cam_angle_label()
|
||
|
||
|
||
# =============================================================================
|
||
# Rotation (D-155: cardinal only, Q=CCW, E=CW)
|
||
# =============================================================================
|
||
|
||
|
||
func _on_rotate_left() -> void:
|
||
_facing_idx = (_facing_idx + 1) % CARDINAL_DIRS.size()
|
||
_char_visual.set_facing(CARDINAL_DIRS[_facing_idx])
|
||
|
||
|
||
func _on_rotate_right() -> void:
|
||
_facing_idx = (_facing_idx - 1 + CARDINAL_DIRS.size()) % CARDINAL_DIRS.size()
|
||
_char_visual.set_facing(CARDINAL_DIRS[_facing_idx])
|
||
|
||
|
||
# =============================================================================
|
||
# Preview refresh
|
||
# =============================================================================
|
||
|
||
|
||
func _refresh_preview() -> void:
|
||
if _char_visual and is_instance_valid(_char_visual):
|
||
_char_visual.load_descriptor(_descriptor)
|
||
_char_visual.set_facing(CARDINAL_DIRS[_facing_idx])
|
||
|
||
|
||
# =============================================================================
|
||
# Tab: Body (Task #2)
|
||
# =============================================================================
|
||
|
||
|
||
func _build_body_tab(tab: Control) -> void:
|
||
var vbox := _make_tab_vbox(tab)
|
||
|
||
var search := _make_search_bar(0)
|
||
vbox.add_child(search)
|
||
|
||
# --- Body type grid ---
|
||
var body_label := _make_section_label("Body Type")
|
||
vbox.add_child(body_label)
|
||
|
||
# Female row
|
||
var female_label := _make_row_label("F")
|
||
vbox.add_child(female_label)
|
||
var female_row := HBoxContainer.new()
|
||
female_row.add_theme_constant_override("separation", 4)
|
||
vbox.add_child(female_row)
|
||
var available_types: Array = _manifest_array("body_types")
|
||
for bt: int in BODY_FEMALE_ROW:
|
||
var key: String = CharacterVisualDescriptor.BODY_TYPE_KEYS.get(bt, "")
|
||
if not available_types.is_empty() and key not in available_types:
|
||
continue
|
||
var btn := _make_body_type_btn(bt)
|
||
female_row.add_child(btn)
|
||
_body_type_btns[bt] = btn
|
||
|
||
# Male row
|
||
var male_label := _make_row_label("M")
|
||
vbox.add_child(male_label)
|
||
var male_row := HBoxContainer.new()
|
||
male_row.add_theme_constant_override("separation", 4)
|
||
vbox.add_child(male_row)
|
||
for bt: int in BODY_MALE_ROW:
|
||
var key: String = CharacterVisualDescriptor.BODY_TYPE_KEYS.get(bt, "")
|
||
if not available_types.is_empty() and key not in available_types:
|
||
continue
|
||
var btn := _make_body_type_btn(bt)
|
||
male_row.add_child(btn)
|
||
_body_type_btns[bt] = btn
|
||
|
||
# Child row (no gender label — no gendered framing per spec)
|
||
var show_child: bool = available_types.is_empty() or "child" in available_types
|
||
if show_child:
|
||
var child_row := HBoxContainer.new()
|
||
child_row.add_theme_constant_override("separation", 4)
|
||
vbox.add_child(child_row)
|
||
var child_btn := _make_body_type_btn(CharacterVisualDescriptor.BodyType.CHILD)
|
||
child_row.add_child(child_btn)
|
||
_body_type_btns[CharacterVisualDescriptor.BodyType.CHILD] = child_btn
|
||
|
||
# Spacer
|
||
var spacer := Control.new()
|
||
spacer.size_flags_vertical = Control.SIZE_FILL | Control.SIZE_EXPAND
|
||
vbox.add_child(spacer)
|
||
|
||
# --- Skin tone dock ---
|
||
var skin_label := _make_section_label("Skin Tone")
|
||
vbox.add_child(skin_label)
|
||
var skin_dock := _build_skin_tone_dock()
|
||
vbox.add_child(skin_dock)
|
||
_body_skin_btns = _skin_btns_from_dock(skin_dock)
|
||
|
||
# --- Eye color ---
|
||
var eye_label := _make_section_label("Eye Color")
|
||
vbox.add_child(eye_label)
|
||
var eye_row := HBoxContainer.new()
|
||
eye_row.add_theme_constant_override("separation", 8)
|
||
vbox.add_child(eye_row)
|
||
var eye_swatch := _make_color_swatch(
|
||
_descriptor.eye_color,
|
||
"Iris",
|
||
func(c: Color) -> void:
|
||
_descriptor.eye_color = c
|
||
_refresh_preview()
|
||
)
|
||
eye_row.add_child(eye_swatch)
|
||
|
||
_update_body_type_btns()
|
||
_update_skin_tone_btns()
|
||
|
||
|
||
func _make_body_type_btn(bt: int) -> Button:
|
||
var btn := Button.new()
|
||
btn.text = BODY_TYPE_LABELS.get(bt, "?")
|
||
btn.custom_minimum_size = Vector2(72, 48)
|
||
btn.add_theme_color_override("font_color", TEXT_COLOR)
|
||
btn.add_theme_color_override("font_hover_color", HIGHLIGHT_COLOR)
|
||
btn.pressed.connect(_on_body_type_selected.bind(bt))
|
||
return btn
|
||
|
||
|
||
func _on_body_type_selected(bt: int) -> void:
|
||
_descriptor.body_type = bt as CharacterVisualDescriptor.BodyType
|
||
# Clear facial hair if switching to a body type that doesn't support it
|
||
var key: String = _descriptor.body_type_key()
|
||
if key.ends_with("_f") or key == "child" or key.begins_with("teen"):
|
||
_descriptor.facial_hair_id = ""
|
||
_update_body_type_btns()
|
||
_update_facial_hair_visibility()
|
||
_update_hair_btns()
|
||
_refresh_preview()
|
||
|
||
|
||
func _update_facial_hair_visibility() -> void:
|
||
var key: String = _descriptor.body_type_key()
|
||
var show_fh: bool = not key.ends_with("_f") and key != "child" and not key.begins_with("teen")
|
||
var fh_label: Control = find_child("FacialHairLabel", true, false)
|
||
var fh_row: Control = find_child("FacialHairRow", true, false)
|
||
if fh_label:
|
||
fh_label.visible = show_fh
|
||
if fh_row:
|
||
fh_row.visible = show_fh
|
||
|
||
|
||
func _update_body_type_btns() -> void:
|
||
for bt: int in _body_type_btns:
|
||
_set_item_selected(_body_type_btns[bt], bt == _descriptor.body_type)
|
||
|
||
|
||
# =============================================================================
|
||
# Tab: Head (Task #3)
|
||
# =============================================================================
|
||
|
||
|
||
func _build_head_tab(tab: Control) -> void:
|
||
var vbox := _make_tab_vbox(tab)
|
||
|
||
var search := _make_search_bar(1)
|
||
vbox.add_child(search)
|
||
|
||
var scroll := ScrollContainer.new()
|
||
scroll.size_flags_vertical = Control.SIZE_FILL | Control.SIZE_EXPAND
|
||
vbox.add_child(scroll)
|
||
|
||
var grid := GridContainer.new()
|
||
grid.columns = 3
|
||
grid.add_theme_constant_override("h_separation", 4)
|
||
grid.add_theme_constant_override("v_separation", 4)
|
||
scroll.add_child(grid)
|
||
_tab_grids[1] = grid
|
||
|
||
_cached_head_ids = _manifest_array("heads")
|
||
for hid in _cached_head_ids:
|
||
var btn := _make_grid_item_btn(hid.replace("_", " ").capitalize(), Vector2(96, 80))
|
||
btn.pressed.connect(_on_head_selected.bind(hid))
|
||
grid.add_child(btn)
|
||
_head_item_btns[hid] = btn
|
||
|
||
# Skin tone dock (shared state with Body tab)
|
||
var skin_label := _make_section_label("Skin Tone")
|
||
vbox.add_child(skin_label)
|
||
var skin_dock := _build_skin_tone_dock()
|
||
vbox.add_child(skin_dock)
|
||
_head_skin_btns = _skin_btns_from_dock(skin_dock)
|
||
|
||
# Eye color (shared with Body tab)
|
||
var eye_label := _make_section_label("Eye Color")
|
||
vbox.add_child(eye_label)
|
||
var eye_row := HBoxContainer.new()
|
||
eye_row.add_theme_constant_override("separation", 8)
|
||
vbox.add_child(eye_row)
|
||
var eye_swatch := _make_color_swatch(
|
||
_descriptor.eye_color,
|
||
"Iris",
|
||
func(c: Color) -> void:
|
||
_descriptor.eye_color = c
|
||
_refresh_preview()
|
||
)
|
||
eye_row.add_child(eye_swatch)
|
||
|
||
_update_head_btns()
|
||
_update_skin_tone_btns()
|
||
|
||
|
||
func _on_head_selected(head_id: String) -> void:
|
||
_descriptor.head_id = "" if _descriptor.head_id == head_id else head_id
|
||
_update_head_btns()
|
||
_refresh_preview()
|
||
|
||
|
||
func _update_head_btns() -> void:
|
||
for hid: String in _head_item_btns:
|
||
_set_item_selected(_head_item_btns[hid], hid == _descriptor.head_id)
|
||
|
||
|
||
# =============================================================================
|
||
# Tab: Hair (Task #4)
|
||
# =============================================================================
|
||
|
||
|
||
func _build_hair_tab(tab: Control) -> void:
|
||
var vbox := _make_tab_vbox(tab)
|
||
|
||
var search := _make_search_bar(2)
|
||
vbox.add_child(search)
|
||
|
||
# Hair style grid — takes 50% of available space
|
||
var hair_label := _make_section_label("Hair Style")
|
||
vbox.add_child(hair_label)
|
||
|
||
var hair_grid := HFlowContainer.new()
|
||
hair_grid.add_theme_constant_override("h_separation", 4)
|
||
hair_grid.add_theme_constant_override("v_separation", 4)
|
||
vbox.add_child(hair_grid)
|
||
|
||
_cached_hair_ids = _manifest_array("hair")
|
||
for hid in _cached_hair_ids:
|
||
var btn := _make_grid_item_btn(hid.replace("_", " ").capitalize(), Vector2(80, 50))
|
||
btn.pressed.connect(_on_hair_selected.bind(hid))
|
||
hair_grid.add_child(btn)
|
||
_hair_item_btns[hid] = btn
|
||
|
||
# Facial hair — takes remaining 50% (hidden for female/child/teen)
|
||
var fh_label := _make_section_label("Facial Hair")
|
||
fh_label.name = "FacialHairLabel"
|
||
vbox.add_child(fh_label)
|
||
var fh_row := HFlowContainer.new()
|
||
fh_row.name = "FacialHairRow"
|
||
fh_row.add_theme_constant_override("h_separation", 4)
|
||
fh_row.add_theme_constant_override("v_separation", 4)
|
||
vbox.add_child(fh_row)
|
||
_facial_hair_btns.clear()
|
||
for i in FACIAL_HAIR_OPTIONS.size():
|
||
var btn := _make_grid_item_btn(FACIAL_HAIR_LABELS[i], Vector2(70, 40))
|
||
btn.pressed.connect(_on_facial_hair_selected.bind(FACIAL_HAIR_OPTIONS[i]))
|
||
fh_row.add_child(btn)
|
||
_facial_hair_btns.append(btn)
|
||
|
||
# Spacer pushes color dock to bottom
|
||
var spacer := Control.new()
|
||
spacer.size_flags_vertical = Control.SIZE_FILL | Control.SIZE_EXPAND
|
||
vbox.add_child(spacer)
|
||
|
||
# Color dock — docked at bottom (consistent with Body and Clothing tabs)
|
||
var dock := _build_hair_color_dock()
|
||
vbox.add_child(dock)
|
||
|
||
_update_hair_btns()
|
||
_update_hair_color_dock()
|
||
|
||
|
||
func _on_hair_selected(hair_id: String) -> void:
|
||
_descriptor.hair_id = "" if _descriptor.hair_id == hair_id else hair_id
|
||
_update_hair_btns()
|
||
_update_hair_color_dock()
|
||
_refresh_preview()
|
||
|
||
|
||
func _on_facial_hair_selected(fh_id: String) -> void:
|
||
_descriptor.facial_hair_id = "" if _descriptor.facial_hair_id == fh_id else fh_id
|
||
if _facial_hair_tint_auto:
|
||
_descriptor.facial_hair_tint = _descriptor.hair_tint
|
||
_update_hair_btns()
|
||
_update_hair_color_dock()
|
||
_refresh_preview()
|
||
|
||
|
||
func _on_eyebrow_selected(eb_id: String) -> void:
|
||
_descriptor.eyebrow_id = "" if _descriptor.eyebrow_id == eb_id else eb_id
|
||
if _eyebrow_tint_auto:
|
||
_descriptor.eyebrow_tint = _descriptor.hair_tint
|
||
_update_hair_btns()
|
||
_update_hair_color_dock()
|
||
_refresh_preview()
|
||
|
||
|
||
func _update_hair_btns() -> void:
|
||
for hid: String in _hair_item_btns:
|
||
_set_item_selected(_hair_item_btns[hid], hid == _descriptor.hair_id)
|
||
for i in FACIAL_HAIR_OPTIONS.size():
|
||
_set_item_selected(
|
||
_facial_hair_btns[i], FACIAL_HAIR_OPTIONS[i] == _descriptor.facial_hair_id
|
||
)
|
||
# Eyebrow buttons removed — eyebrows come from body segment only
|
||
|
||
|
||
func _build_hair_color_dock() -> Control:
|
||
var dock := VBoxContainer.new()
|
||
dock.add_theme_constant_override("separation", 4)
|
||
|
||
var dock_label := _make_section_label("Hair Color")
|
||
dock.add_child(dock_label)
|
||
|
||
var row := HBoxContainer.new()
|
||
row.add_theme_constant_override("separation", 8)
|
||
dock.add_child(row)
|
||
|
||
_hair_primary_swatch = _make_color_swatch(
|
||
Color(0.55, 0.35, 0.20), "Primary", func(c): _on_hair_primary_changed(c)
|
||
)
|
||
row.add_child(_hair_primary_swatch)
|
||
|
||
# #719 (Option B): highlight is auto-derived from primary — display-only, not editable.
|
||
_hair_highlight_swatch = _make_display_swatch(
|
||
_derive_hair_highlight(_descriptor.hair_tint), "Highlight"
|
||
)
|
||
row.add_child(_hair_highlight_swatch)
|
||
|
||
_eyebrow_tint_swatch = _make_color_swatch(
|
||
_descriptor.hair_tint, "Brows ●", func(c): _on_eyebrow_tint_changed(c)
|
||
)
|
||
row.add_child(_eyebrow_tint_swatch)
|
||
|
||
_facial_hair_tint_swatch = _make_color_swatch(
|
||
_descriptor.hair_tint, "Facial ●", func(c): _on_facial_hair_tint_changed(c)
|
||
)
|
||
row.add_child(_facial_hair_tint_swatch)
|
||
|
||
return dock
|
||
|
||
|
||
func _update_hair_color_dock() -> void:
|
||
if not _hair_primary_swatch:
|
||
return
|
||
_set_swatch_color(_hair_primary_swatch, _descriptor.hair_tint)
|
||
_set_swatch_color(_hair_highlight_swatch, _derive_hair_highlight(_descriptor.hair_tint))
|
||
var eb_tint := _descriptor.hair_tint if _eyebrow_tint_auto else _descriptor.eyebrow_tint
|
||
_set_swatch_color(_eyebrow_tint_swatch, eb_tint)
|
||
var fh_tint := _descriptor.hair_tint if _facial_hair_tint_auto else _descriptor.facial_hair_tint
|
||
_set_swatch_color(_facial_hair_tint_swatch, fh_tint)
|
||
|
||
|
||
func _on_hair_primary_changed(color: Color) -> void:
|
||
_descriptor.hair_tint = color
|
||
if _eyebrow_tint_auto:
|
||
_descriptor.eyebrow_tint = color
|
||
if _facial_hair_tint_auto:
|
||
_descriptor.facial_hair_tint = color
|
||
_update_hair_color_dock()
|
||
_refresh_preview()
|
||
|
||
|
||
func _on_eyebrow_tint_changed(color: Color) -> void:
|
||
_eyebrow_tint_auto = false
|
||
_descriptor.eyebrow_tint = color
|
||
_update_hair_color_dock()
|
||
_refresh_preview()
|
||
|
||
|
||
func _on_facial_hair_tint_changed(color: Color) -> void:
|
||
_facial_hair_tint_auto = false
|
||
_descriptor.facial_hair_tint = color
|
||
_update_hair_color_dock()
|
||
_refresh_preview()
|
||
|
||
|
||
# =============================================================================
|
||
# Tab: Clothing (Task #5)
|
||
# =============================================================================
|
||
|
||
|
||
func _build_clothing_tab(tab: Control) -> void:
|
||
var vbox := _make_tab_vbox(tab)
|
||
|
||
# Slot selector
|
||
var slot_row := HBoxContainer.new()
|
||
slot_row.add_theme_constant_override("separation", 4)
|
||
vbox.add_child(slot_row)
|
||
_clothing_slot_btns.clear()
|
||
var first_visible_slot: String = ""
|
||
for i in CLOTHING_SLOTS.size():
|
||
var slot: String = CLOTHING_SLOTS[i]
|
||
var ids := _get_clothing_ids_for_slot(slot)
|
||
if ids.is_empty():
|
||
continue # skip empty slots
|
||
if first_visible_slot.is_empty():
|
||
first_visible_slot = slot
|
||
var btn := _make_slot_btn(CLOTHING_SLOT_LABELS[i])
|
||
btn.pressed.connect(_on_clothing_slot_selected.bind(slot))
|
||
slot_row.add_child(btn)
|
||
_clothing_slot_btns.append(btn)
|
||
_clothing_secondary_auto[slot] = true
|
||
_clothing_accent_auto[slot] = true
|
||
if not first_visible_slot.is_empty():
|
||
_active_clothing_slot = first_visible_slot
|
||
|
||
var search := _make_search_bar(3)
|
||
vbox.add_child(search)
|
||
|
||
# Per-slot grids (only the active one is visible)
|
||
var scroll := ScrollContainer.new()
|
||
scroll.size_flags_vertical = Control.SIZE_FILL | Control.SIZE_EXPAND
|
||
vbox.add_child(scroll)
|
||
|
||
var scroll_vbox := VBoxContainer.new()
|
||
scroll.add_child(scroll_vbox)
|
||
|
||
_clothing_grids.clear()
|
||
_clothing_item_btns.clear()
|
||
for slot in CLOTHING_SLOTS:
|
||
var grid := GridContainer.new()
|
||
grid.columns = 3
|
||
grid.add_theme_constant_override("h_separation", 4)
|
||
grid.add_theme_constant_override("v_separation", 4)
|
||
grid.visible = (slot == _active_clothing_slot)
|
||
scroll_vbox.add_child(grid)
|
||
_clothing_grids[slot] = grid
|
||
_clothing_item_btns[slot] = {}
|
||
_clothing_primary_swatches[slot] = null
|
||
_clothing_secondary_swatches[slot] = null
|
||
_clothing_accent_swatches[slot] = null
|
||
|
||
var ids := _get_clothing_ids_for_slot(slot)
|
||
for item_id in ids:
|
||
var btn := _make_grid_item_btn(item_id.replace("_", " ").capitalize(), Vector2(88, 64))
|
||
btn.pressed.connect(_on_clothing_item_selected.bind(slot, item_id))
|
||
grid.add_child(btn)
|
||
_clothing_item_btns[slot][item_id] = btn
|
||
|
||
# Color dock (per slot, rebuilt on slot change)
|
||
_clothing_dock_container = Control.new()
|
||
_clothing_dock_container.name = "ClothingColorDock"
|
||
_clothing_dock_container.custom_minimum_size = Vector2(0, 64)
|
||
_clothing_dock_container.size_flags_horizontal = Control.SIZE_FILL
|
||
vbox.add_child(_clothing_dock_container)
|
||
# _tab_grids[3] left null: clothing uses per-slot grids (search filters active slot via _clothing_grids)
|
||
|
||
_rebuild_clothing_color_dock(_clothing_dock_container)
|
||
_update_clothing_slot_btns()
|
||
_update_clothing_item_btns()
|
||
|
||
|
||
func _rebuild_clothing_slot_grids() -> void:
|
||
# Repopulate clothing grids for current body type (items change per gender)
|
||
for slot: String in _clothing_grids:
|
||
var grid: GridContainer = _clothing_grids[slot]
|
||
for child in grid.get_children():
|
||
child.queue_free()
|
||
_clothing_item_btns[slot] = {}
|
||
var ids := _get_clothing_ids_for_slot(slot)
|
||
for item_id in ids:
|
||
var btn := _make_grid_item_btn(item_id.replace("_", " ").capitalize(), Vector2(88, 64))
|
||
btn.pressed.connect(_on_clothing_item_selected.bind(slot, item_id))
|
||
grid.add_child(btn)
|
||
_clothing_item_btns[slot][item_id] = btn
|
||
_update_clothing_item_btns()
|
||
|
||
|
||
func _on_clothing_slot_selected(slot: String) -> void:
|
||
_active_clothing_slot = slot
|
||
for s in _clothing_grids:
|
||
_clothing_grids[s].visible = (s == slot)
|
||
_update_clothing_slot_btns()
|
||
|
||
# Rebuild color dock for newly selected slot
|
||
if _clothing_dock_container:
|
||
_rebuild_clothing_color_dock(_clothing_dock_container)
|
||
|
||
|
||
func _rebuild_clothing_color_dock(container: Control) -> void:
|
||
for child in container.get_children():
|
||
child.queue_free()
|
||
|
||
var row := HBoxContainer.new()
|
||
row.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
row.add_theme_constant_override("separation", 8)
|
||
container.add_child(row)
|
||
|
||
var item_id: String = str(_descriptor.clothing_slots.get(_active_clothing_slot, ""))
|
||
var tints: Array = (
|
||
_descriptor.clothing_tints.get(item_id, []) as Array if not item_id.is_empty() else []
|
||
as Array
|
||
)
|
||
var primary: Color = tints[0] as Color if tints.size() > 0 else Color(0.7, 0.65, 0.6)
|
||
var secondary: Color = tints[1] as Color if tints.size() > 1 else _derive_secondary(primary)
|
||
var accent: Color = tints[2] as Color if tints.size() > 2 else _derive_accent(primary)
|
||
|
||
var p_swatch := _make_color_swatch(primary, "Primary", func(c): _on_clothing_primary_changed(c))
|
||
row.add_child(p_swatch)
|
||
_clothing_primary_swatches[_active_clothing_slot] = p_swatch
|
||
|
||
var s_swatch := _make_color_swatch(
|
||
secondary, "Secondary ●", func(c): _on_clothing_secondary_changed(c)
|
||
)
|
||
row.add_child(s_swatch)
|
||
_clothing_secondary_swatches[_active_clothing_slot] = s_swatch
|
||
|
||
var a_swatch := _make_color_swatch(accent, "Accent ●", func(c): _on_clothing_accent_changed(c))
|
||
row.add_child(a_swatch)
|
||
_clothing_accent_swatches[_active_clothing_slot] = a_swatch
|
||
|
||
|
||
func _on_clothing_item_selected(slot: String, item_id: String) -> void:
|
||
var current: String = str(_descriptor.clothing_slots.get(slot, ""))
|
||
if current == item_id:
|
||
# Toggle off — remove from slot
|
||
_descriptor.clothing_slots.erase(slot)
|
||
_descriptor.clothing_tints.erase(item_id)
|
||
else:
|
||
_descriptor.clothing_slots[slot] = item_id
|
||
_descriptor.clothing_tints[item_id] = [Color(0.7, 0.65, 0.6)]
|
||
_clothing_secondary_auto[slot] = true
|
||
_clothing_accent_auto[slot] = true
|
||
|
||
_update_clothing_item_btns()
|
||
# Rebuild color dock to show new item's defaults
|
||
if _clothing_dock_container:
|
||
_rebuild_clothing_color_dock(_clothing_dock_container)
|
||
_refresh_preview()
|
||
|
||
|
||
func _on_clothing_primary_changed(color: Color) -> void:
|
||
var item_id: String = str(_descriptor.clothing_slots.get(_active_clothing_slot, ""))
|
||
if item_id.is_empty():
|
||
return
|
||
var tints: Array = _descriptor.clothing_tints.get(item_id, []) as Array
|
||
if tints.is_empty():
|
||
tints = [color]
|
||
else:
|
||
tints[0] = color
|
||
_descriptor.clothing_tints[item_id] = tints
|
||
if _clothing_secondary_auto.get(_active_clothing_slot, true):
|
||
_set_swatch_color(
|
||
_clothing_secondary_swatches.get(_active_clothing_slot), _derive_secondary(color)
|
||
)
|
||
_refresh_preview()
|
||
|
||
|
||
func _on_clothing_secondary_changed(color: Color) -> void:
|
||
_clothing_secondary_auto[_active_clothing_slot] = false
|
||
var item_id: String = str(_descriptor.clothing_slots.get(_active_clothing_slot, ""))
|
||
if not item_id.is_empty():
|
||
var tints: Array = _descriptor.clothing_tints.get(item_id, [Color(0.7, 0.65, 0.6)]) as Array
|
||
while tints.size() < 2:
|
||
tints.append(color)
|
||
tints[1] = color
|
||
_descriptor.clothing_tints[item_id] = tints
|
||
_refresh_preview()
|
||
|
||
|
||
func _on_clothing_accent_changed(color: Color) -> void:
|
||
_clothing_accent_auto[_active_clothing_slot] = false
|
||
var item_id: String = str(_descriptor.clothing_slots.get(_active_clothing_slot, ""))
|
||
if not item_id.is_empty():
|
||
var tints: Array = _descriptor.clothing_tints.get(item_id, [Color(0.7, 0.65, 0.6)]) as Array
|
||
while tints.size() < 3:
|
||
tints.append(color)
|
||
tints[2] = color
|
||
_descriptor.clothing_tints[item_id] = tints
|
||
_refresh_preview()
|
||
|
||
|
||
func _update_clothing_slot_btns() -> void:
|
||
# Slot buttons may be fewer than CLOTHING_SLOTS (empty slots are hidden)
|
||
for btn in _clothing_slot_btns:
|
||
# The slot name is stored in the button's pressed signal bindings —
|
||
# match by checking if the button text matches the active slot label
|
||
var is_active := false
|
||
var idx := CLOTHING_SLOT_LABELS.find(btn.text)
|
||
if idx >= 0 and idx < CLOTHING_SLOTS.size():
|
||
is_active = CLOTHING_SLOTS[idx] == _active_clothing_slot
|
||
_set_item_selected(btn, is_active)
|
||
|
||
|
||
func _update_clothing_item_btns() -> void:
|
||
for slot: String in _clothing_item_btns:
|
||
var active_item: String = str(_descriptor.clothing_slots.get(slot, ""))
|
||
for item_id in _clothing_item_btns[slot]:
|
||
_set_item_selected(_clothing_item_btns[slot][item_id], item_id == active_item)
|
||
|
||
|
||
# =============================================================================
|
||
# Tab: Accessories (Task #6)
|
||
# =============================================================================
|
||
|
||
|
||
func _build_accessories_tab(tab: Control) -> void:
|
||
var vbox := _make_tab_vbox(tab)
|
||
|
||
# Slot selector (wrapping row — many slots)
|
||
var slot_flow := HFlowContainer.new()
|
||
slot_flow.add_theme_constant_override("h_separation", 4)
|
||
slot_flow.add_theme_constant_override("v_separation", 4)
|
||
vbox.add_child(slot_flow)
|
||
_accessory_slot_btns.clear()
|
||
for i in ACCESSORY_SLOTS.size():
|
||
var slot: String = ACCESSORY_SLOTS[i]
|
||
var btn := _make_slot_btn(ACCESSORY_SLOT_LABELS[i])
|
||
btn.pressed.connect(_on_accessory_slot_selected.bind(slot))
|
||
slot_flow.add_child(btn)
|
||
_accessory_slot_btns.append(btn)
|
||
_accessory_secondary_auto[slot] = true
|
||
|
||
var search := _make_search_bar(4)
|
||
vbox.add_child(search)
|
||
|
||
var scroll := ScrollContainer.new()
|
||
scroll.size_flags_vertical = Control.SIZE_FILL | Control.SIZE_EXPAND
|
||
vbox.add_child(scroll)
|
||
|
||
var scroll_vbox := VBoxContainer.new()
|
||
scroll.add_child(scroll_vbox)
|
||
|
||
_accessory_grids.clear()
|
||
_accessory_item_btns.clear()
|
||
for slot in ACCESSORY_SLOTS:
|
||
var grid := GridContainer.new()
|
||
grid.columns = 3
|
||
grid.add_theme_constant_override("h_separation", 4)
|
||
grid.add_theme_constant_override("v_separation", 4)
|
||
grid.visible = (slot == _active_accessory_slot)
|
||
scroll_vbox.add_child(grid)
|
||
_accessory_grids[slot] = grid
|
||
_accessory_item_btns[slot] = {}
|
||
_accessory_primary_swatches[slot] = null
|
||
_accessory_secondary_swatches[slot] = null
|
||
|
||
var ids := _get_accessory_ids_for_slot(slot)
|
||
for item_id in ids:
|
||
var btn := _make_grid_item_btn(item_id.replace("_", " ").capitalize(), Vector2(88, 64))
|
||
btn.pressed.connect(_on_accessory_item_selected.bind(slot, item_id))
|
||
grid.add_child(btn)
|
||
_accessory_item_btns[slot][item_id] = btn
|
||
|
||
_accessory_dock_container = Control.new()
|
||
_accessory_dock_container.name = "AccessoryColorDock"
|
||
_accessory_dock_container.custom_minimum_size = Vector2(0, 56)
|
||
_accessory_dock_container.size_flags_horizontal = Control.SIZE_FILL
|
||
vbox.add_child(_accessory_dock_container)
|
||
# _tab_grids[4] left null: accessories uses per-slot grids (search filters active slot via _accessory_grids)
|
||
|
||
_rebuild_accessory_color_dock(_accessory_dock_container)
|
||
_update_accessory_slot_btns()
|
||
_update_accessory_item_btns()
|
||
|
||
|
||
func _on_accessory_slot_selected(slot: String) -> void:
|
||
_active_accessory_slot = slot
|
||
for s in _accessory_grids:
|
||
_accessory_grids[s].visible = (s == slot)
|
||
_update_accessory_slot_btns()
|
||
if _accessory_dock_container:
|
||
_rebuild_accessory_color_dock(_accessory_dock_container)
|
||
|
||
|
||
func _rebuild_accessory_color_dock(container: Control) -> void:
|
||
for child in container.get_children():
|
||
child.queue_free()
|
||
|
||
var row := HBoxContainer.new()
|
||
row.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
row.add_theme_constant_override("separation", 8)
|
||
container.add_child(row)
|
||
|
||
var item_id: String = str(_descriptor.accessory_slots.get(_active_accessory_slot, ""))
|
||
var tints_arr: Array = (
|
||
_descriptor.accessory_tints.get(item_id, []) as Array if not item_id.is_empty() else []
|
||
as Array
|
||
)
|
||
var primary: Color = tints_arr[0] as Color if not tints_arr.is_empty() else Color.WHITE
|
||
var secondary: Color = _derive_secondary(primary)
|
||
|
||
var p_swatch := _make_color_swatch(
|
||
primary, "Primary", func(c): _on_accessory_primary_changed(c)
|
||
)
|
||
row.add_child(p_swatch)
|
||
_accessory_primary_swatches[_active_accessory_slot] = p_swatch
|
||
|
||
var s_swatch := _make_color_swatch(
|
||
secondary, "Secondary ●", func(c): _on_accessory_secondary_changed(c)
|
||
)
|
||
row.add_child(s_swatch)
|
||
_accessory_secondary_swatches[_active_accessory_slot] = s_swatch
|
||
|
||
|
||
func _on_accessory_item_selected(slot: String, item_id: String) -> void:
|
||
var current: String = str(_descriptor.accessory_slots.get(slot, ""))
|
||
if current == item_id:
|
||
_descriptor.accessory_slots.erase(slot)
|
||
_descriptor.accessory_tints.erase(item_id)
|
||
else:
|
||
_descriptor.accessory_slots[slot] = item_id
|
||
_descriptor.accessory_tints[item_id] = [Color.WHITE]
|
||
_accessory_secondary_auto[slot] = true
|
||
_update_accessory_item_btns()
|
||
if _accessory_dock_container:
|
||
_rebuild_accessory_color_dock(_accessory_dock_container)
|
||
_refresh_preview()
|
||
|
||
|
||
func _on_accessory_primary_changed(color: Color) -> void:
|
||
var item_id: String = str(_descriptor.accessory_slots.get(_active_accessory_slot, ""))
|
||
if not item_id.is_empty():
|
||
var existing: Array = _descriptor.accessory_tints.get(item_id, []) as Array
|
||
if existing.is_empty():
|
||
existing = [color]
|
||
else:
|
||
existing[0] = color
|
||
_descriptor.accessory_tints[item_id] = existing
|
||
_refresh_preview()
|
||
|
||
|
||
func _on_accessory_secondary_changed(color: Color) -> void:
|
||
_accessory_secondary_auto[_active_accessory_slot] = false
|
||
var item_id: String = str(_descriptor.accessory_slots.get(_active_accessory_slot, ""))
|
||
if not item_id.is_empty():
|
||
var tints: Array = _descriptor.accessory_tints.get(item_id, [Color.WHITE]) as Array
|
||
while tints.size() < 2:
|
||
tints.append(color)
|
||
tints[1] = color
|
||
_descriptor.accessory_tints[item_id] = tints
|
||
_refresh_preview()
|
||
|
||
|
||
func _update_accessory_slot_btns() -> void:
|
||
for i in ACCESSORY_SLOTS.size():
|
||
_set_item_selected(_accessory_slot_btns[i], ACCESSORY_SLOTS[i] == _active_accessory_slot)
|
||
|
||
|
||
func _update_accessory_item_btns() -> void:
|
||
for slot: String in _accessory_item_btns:
|
||
var active_item: String = str(_descriptor.accessory_slots.get(slot, ""))
|
||
for item_id in _accessory_item_btns[slot]:
|
||
_set_item_selected(_accessory_item_btns[slot][item_id], item_id == active_item)
|
||
|
||
|
||
# =============================================================================
|
||
# =============================================================================
|
||
# Screenshot & automated testing
|
||
# =============================================================================
|
||
|
||
|
||
func _schedule_screenshot() -> void:
|
||
_screenshot_pending = true
|
||
_screenshot_frame_count = 0
|
||
|
||
|
||
func _process(_delta: float) -> void:
|
||
if _screenshot_pending:
|
||
_screenshot_frame_count += 1
|
||
if _screenshot_frame_count >= _screenshot_delay_frames:
|
||
_screenshot_pending = false
|
||
_take_screenshot()
|
||
|
||
|
||
func _take_screenshot(suffix: String = "") -> void:
|
||
DirAccess.make_dir_recursive_absolute(SCREENSHOT_DIR)
|
||
|
||
if _screenshot_cardinals:
|
||
# Take screenshot for current cardinal, then advance
|
||
var dir_name := CARDINAL_NAMES[_screenshot_cardinal_idx]
|
||
_char_visual.set_facing(CARDINAL_DIRS[_screenshot_cardinal_idx])
|
||
suffix = dir_name
|
||
|
||
var filename := (
|
||
"charcreator_%s_%s.png"
|
||
% [_descriptor.body_type_key(), suffix if not suffix.is_empty() else "default"]
|
||
)
|
||
var path := SCREENSHOT_DIR + filename
|
||
var img := get_viewport().get_texture().get_image()
|
||
img.save_png(path)
|
||
var abs_path := ProjectSettings.globalize_path(path)
|
||
print("SCREENSHOT: %s" % abs_path)
|
||
|
||
if _screenshot_cardinals:
|
||
_screenshot_cardinal_idx += 1
|
||
if _screenshot_cardinal_idx < CARDINAL_NAMES.size():
|
||
# More directions to capture
|
||
_schedule_screenshot()
|
||
return
|
||
_screenshot_cardinals = false
|
||
|
||
if _quit_after_screenshot:
|
||
get_tree().quit()
|
||
|
||
|
||
func _load_test_config() -> void:
|
||
# Load a JSON test config from --test-config <path> command line arg
|
||
# Format: { "body_type": "muscular_m", "hair_id": "buzzed", "clothing": {"torso": "peasant_tunic"}, ... }
|
||
var args := OS.get_cmdline_args()
|
||
var config_idx := -1
|
||
for i in args.size():
|
||
if args[i] == "--test-config" and i + 1 < args.size():
|
||
config_idx = i + 1
|
||
break
|
||
if config_idx < 0:
|
||
return
|
||
var file := FileAccess.open(args[config_idx], FileAccess.READ)
|
||
if file == null:
|
||
push_error("CharacterCreation: could not open test config: %s" % args[config_idx])
|
||
return
|
||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||
file.close()
|
||
if not (parsed is Dictionary):
|
||
push_error("CharacterCreation: test config is not a JSON object")
|
||
return
|
||
var cfg: Dictionary = parsed as Dictionary
|
||
print("TEST CONFIG: %s" % str(cfg))
|
||
|
||
# Apply config to descriptor
|
||
if cfg.has("body_type"):
|
||
var key: String = cfg["body_type"]
|
||
for bt: int in CharacterVisualDescriptor.BODY_TYPE_KEYS:
|
||
if CharacterVisualDescriptor.BODY_TYPE_KEYS[bt] == key:
|
||
_descriptor.body_type = bt as CharacterVisualDescriptor.BodyType
|
||
break
|
||
if cfg.has("hair_id"):
|
||
_descriptor.hair_id = cfg["hair_id"]
|
||
if cfg.has("facial_hair_id"):
|
||
_descriptor.facial_hair_id = cfg["facial_hair_id"]
|
||
if cfg.has("eyebrow_id"):
|
||
_descriptor.eyebrow_id = cfg["eyebrow_id"]
|
||
if cfg.has("skin_tone"):
|
||
_descriptor.skin_tone = int(cfg["skin_tone"])
|
||
if cfg.has("hair_tint"):
|
||
var t: Array = cfg["hair_tint"]
|
||
_descriptor.hair_tint = Color(t[0], t[1], t[2])
|
||
if cfg.has("clothing"):
|
||
var clothing: Dictionary = cfg["clothing"]
|
||
for slot: String in clothing:
|
||
_descriptor.clothing_slots[slot] = clothing[slot]
|
||
|
||
# Auto-quit after screenshot (for CI/automated testing)
|
||
if cfg.has("screenshot_delay_frames"):
|
||
_screenshot_delay_frames = int(cfg["screenshot_delay_frames"])
|
||
if cfg.has("quit_after_screenshot"):
|
||
_quit_after_screenshot = bool(cfg["quit_after_screenshot"])
|
||
|
||
# Reload the character with new descriptor
|
||
_char_visual.load_descriptor(_descriptor)
|
||
|
||
# Schedule 4 cardinal screenshots (south, east, north, west)
|
||
_screenshot_cardinals = true
|
||
_screenshot_cardinal_idx = 0
|
||
_schedule_screenshot()
|
||
|
||
|
||
# =============================================================================
|
||
# Debug tab — segment visibility toggles
|
||
# =============================================================================
|
||
|
||
|
||
func _build_debug_tab(tab: Control) -> void:
|
||
var vbox := _make_tab_vbox(tab)
|
||
|
||
var header := Label.new()
|
||
header.text = "SEGMENT VISIBILITY"
|
||
header.add_theme_font_size_override("font_size", 14)
|
||
header.add_theme_color_override("font_color", Color(0.9, 0.7, 0.3))
|
||
vbox.add_child(header)
|
||
|
||
var btn_row := HBoxContainer.new()
|
||
btn_row.add_theme_constant_override("separation", 4)
|
||
vbox.add_child(btn_row)
|
||
var all_on := Button.new()
|
||
all_on.text = "All ON"
|
||
all_on.add_theme_font_size_override("font_size", 11)
|
||
all_on.pressed.connect(_on_debug_all.bind(true))
|
||
btn_row.add_child(all_on)
|
||
var all_off := Button.new()
|
||
all_off.text = "All OFF"
|
||
all_off.add_theme_font_size_override("font_size", 11)
|
||
all_off.pressed.connect(_on_debug_all.bind(false))
|
||
btn_row.add_child(all_off)
|
||
|
||
var scroll := ScrollContainer.new()
|
||
scroll.size_flags_vertical = Control.SIZE_FILL | Control.SIZE_EXPAND
|
||
vbox.add_child(scroll)
|
||
|
||
var list := VBoxContainer.new()
|
||
list.add_theme_constant_override("separation", 2)
|
||
scroll.add_child(list)
|
||
|
||
_debug_toggles.clear()
|
||
for seg_name in CharacterVisual.ALL_SEGMENTS:
|
||
var row := HBoxContainer.new()
|
||
row.add_theme_constant_override("separation", 8)
|
||
list.add_child(row)
|
||
|
||
var toggle := CheckButton.new()
|
||
toggle.button_pressed = seg_name not in CharacterVisual.HIDDEN_BY_DEFAULT
|
||
toggle.text = seg_name
|
||
toggle.add_theme_font_size_override("font_size", 11)
|
||
toggle.add_theme_color_override("font_color", Color(0.8, 0.8, 0.85))
|
||
toggle.toggled.connect(_on_debug_toggle.bind(seg_name))
|
||
row.add_child(toggle)
|
||
_debug_toggles[seg_name] = toggle
|
||
|
||
# Also add toggles for clothing and outline
|
||
var clothing_header := Label.new()
|
||
clothing_header.text = "CLOTHING & OUTLINE"
|
||
clothing_header.add_theme_font_size_override("font_size", 14)
|
||
clothing_header.add_theme_color_override("font_color", Color(0.9, 0.7, 0.3))
|
||
list.add_child(clothing_header)
|
||
|
||
var toggle_clothing := CheckButton.new()
|
||
toggle_clothing.button_pressed = true
|
||
toggle_clothing.text = "all clothing"
|
||
toggle_clothing.add_theme_font_size_override("font_size", 11)
|
||
toggle_clothing.toggled.connect(_on_debug_toggle_clothing)
|
||
list.add_child(toggle_clothing)
|
||
|
||
var toggle_outlines := CheckButton.new()
|
||
toggle_outlines.button_pressed = true
|
||
toggle_outlines.text = "outlines"
|
||
toggle_outlines.add_theme_font_size_override("font_size", 11)
|
||
toggle_outlines.toggled.connect(_on_debug_toggle_outlines)
|
||
list.add_child(toggle_outlines)
|
||
|
||
|
||
func _on_debug_all(on: bool) -> void:
|
||
for seg_name: String in _debug_toggles:
|
||
_debug_toggles[seg_name].button_pressed = on
|
||
_on_debug_toggle(on, seg_name)
|
||
|
||
|
||
func _on_debug_toggle(pressed: bool, seg_name: String) -> void:
|
||
for mi in _char_visual._body_meshes:
|
||
if mi.get_meta("segment", "") == seg_name:
|
||
mi.visible = pressed
|
||
print("DEBUG: ", seg_name, " visible=", pressed, " mesh=", mi.name)
|
||
|
||
|
||
func _on_debug_toggle_clothing(pressed: bool) -> void:
|
||
for mi in _char_visual._clothing_meshes:
|
||
mi.visible = pressed
|
||
|
||
|
||
func _on_debug_toggle_outlines(pressed: bool) -> void:
|
||
for mi in _char_visual._outline_nodes:
|
||
if is_instance_valid(mi):
|
||
mi.visible = pressed
|
||
|
||
|
||
# =============================================================================
|
||
# Color picker modal (Task #7) — D-165 palette
|
||
# =============================================================================
|
||
|
||
|
||
func _build_color_picker_modal() -> void:
|
||
var modal := _modal_root
|
||
|
||
var bg := ColorRect.new()
|
||
bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
bg.color = Color(0.0, 0.0, 0.0, 0.7)
|
||
bg.mouse_filter = Control.MOUSE_FILTER_STOP
|
||
modal.add_child(bg)
|
||
|
||
var box := VBoxContainer.new()
|
||
box.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
|
||
box.custom_minimum_size = Vector2(360, 420)
|
||
box.position = Vector2(-180, -210)
|
||
box.add_theme_constant_override("separation", 8)
|
||
modal.add_child(box)
|
||
|
||
var box_bg := ColorRect.new()
|
||
box_bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
box_bg.color = PANEL_BG
|
||
box_bg.z_index = -1
|
||
box.add_child(box_bg)
|
||
|
||
var title := Label.new()
|
||
title.text = "Color"
|
||
title.add_theme_color_override("font_color", TEXT_COLOR)
|
||
title.add_theme_font_size_override("font_size", 14)
|
||
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
box.add_child(title)
|
||
|
||
# 54-swatch palette: 5 chromatic rows (9 each) + 1 neutral gray row
|
||
var palette_grid := GridContainer.new()
|
||
palette_grid.columns = PALETTE_COLS
|
||
palette_grid.add_theme_constant_override("h_separation", 3)
|
||
palette_grid.add_theme_constant_override("v_separation", 3)
|
||
box.add_child(palette_grid)
|
||
_modal_swatch_btns.clear()
|
||
|
||
# 6 rows × 9 cols per D-165 (5 chromatic + 1 neutral gray)
|
||
for row_hexes: Array in PALETTE_COLORS:
|
||
for hex: String in row_hexes:
|
||
var swatch := _make_palette_swatch(Color(hex))
|
||
palette_grid.add_child(swatch)
|
||
_modal_swatch_btns.append(swatch)
|
||
|
||
# Hex input row
|
||
var hex_row := HBoxContainer.new()
|
||
hex_row.add_theme_constant_override("separation", 4)
|
||
box.add_child(hex_row)
|
||
|
||
var hex_label := Label.new()
|
||
hex_label.text = "#"
|
||
hex_label.add_theme_color_override("font_color", TEXT_DIM)
|
||
hex_label.add_theme_font_size_override("font_size", 12)
|
||
hex_row.add_child(hex_label)
|
||
|
||
_modal_hex_input = LineEdit.new()
|
||
_modal_hex_input.placeholder_text = "RRGGBB"
|
||
_modal_hex_input.max_length = 6
|
||
_modal_hex_input.size_flags_horizontal = Control.SIZE_FILL | Control.SIZE_EXPAND
|
||
_modal_hex_input.add_theme_color_override("font_color", TEXT_COLOR)
|
||
_modal_hex_input.add_theme_font_size_override("font_size", 12)
|
||
hex_row.add_child(_modal_hex_input)
|
||
|
||
var hex_apply := Button.new()
|
||
hex_apply.text = "Apply"
|
||
hex_apply.add_theme_color_override("font_color", HIGHLIGHT_COLOR)
|
||
hex_apply.pressed.connect(_on_modal_hex_apply)
|
||
hex_row.add_child(hex_apply)
|
||
|
||
# Recent colors
|
||
var recent_label := Label.new()
|
||
recent_label.text = "Recent"
|
||
recent_label.add_theme_color_override("font_color", TEXT_DIM)
|
||
recent_label.add_theme_font_size_override("font_size", 11)
|
||
box.add_child(recent_label)
|
||
|
||
var recent_row := HBoxContainer.new()
|
||
recent_row.add_theme_constant_override("separation", 3)
|
||
box.add_child(recent_row)
|
||
_modal_recent_btns.clear()
|
||
for _i in RECENT_SLOTS:
|
||
var s := _make_palette_swatch(Color(0.15, 0.15, 0.2))
|
||
recent_row.add_child(s)
|
||
_modal_recent_btns.append(s)
|
||
|
||
# Cancel / OK
|
||
var btn_row := HBoxContainer.new()
|
||
btn_row.add_theme_constant_override("separation", 8)
|
||
btn_row.alignment = BoxContainer.ALIGNMENT_END
|
||
box.add_child(btn_row)
|
||
|
||
var cancel_btn := Button.new()
|
||
cancel_btn.text = "Cancel"
|
||
cancel_btn.add_theme_color_override("font_color", TEXT_DIM)
|
||
cancel_btn.pressed.connect(_on_modal_cancel)
|
||
btn_row.add_child(cancel_btn)
|
||
|
||
var ok_btn := Button.new()
|
||
ok_btn.text = "OK"
|
||
ok_btn.add_theme_color_override("font_color", ACTIVE_COLOR)
|
||
ok_btn.pressed.connect(_on_modal_ok)
|
||
btn_row.add_child(ok_btn)
|
||
|
||
|
||
func _make_palette_swatch(color: Color) -> Button:
|
||
var btn := Button.new()
|
||
btn.custom_minimum_size = Vector2(28, 24)
|
||
btn.flat = true
|
||
var r := ColorRect.new()
|
||
r.color = color
|
||
r.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
r.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
btn.add_child(r)
|
||
btn.pressed.connect(_on_modal_palette_swatch_pressed.bind(color, btn))
|
||
return btn
|
||
|
||
|
||
func _open_color_picker(current_color: Color, swatch_btn: Button, callback: Callable) -> void:
|
||
_modal_callback = callback
|
||
_modal_original_color = current_color
|
||
_modal_preview_swatch = swatch_btn
|
||
if _modal_hex_input:
|
||
_modal_hex_input.text = current_color.to_html(false).to_upper()
|
||
_update_modal_recent_btns()
|
||
_modal_root.visible = true
|
||
|
||
|
||
func _on_modal_palette_swatch_pressed(color: Color, _swatch: Button) -> void:
|
||
# Live preview on 3D character before OK
|
||
if _modal_preview_swatch:
|
||
_set_swatch_color(_modal_preview_swatch, color)
|
||
_modal_callback.call(color)
|
||
if _modal_hex_input:
|
||
_modal_hex_input.text = color.to_html(false).to_upper()
|
||
|
||
|
||
func _on_modal_hex_apply() -> void:
|
||
if not _modal_hex_input:
|
||
return
|
||
var hex := _modal_hex_input.text.strip_edges()
|
||
if hex.length() == 6 and hex.is_valid_html_color():
|
||
var color := Color("#" + hex)
|
||
_modal_callback.call(color)
|
||
if _modal_preview_swatch:
|
||
_set_swatch_color(_modal_preview_swatch, color)
|
||
|
||
|
||
func _on_modal_cancel() -> void:
|
||
# Revert to original color
|
||
_modal_callback.call(_modal_original_color)
|
||
if _modal_preview_swatch:
|
||
_set_swatch_color(_modal_preview_swatch, _modal_original_color)
|
||
_modal_root.visible = false
|
||
|
||
|
||
func _on_modal_ok() -> void:
|
||
# Add current preview color to recent list
|
||
if _modal_preview_swatch:
|
||
_push_recent_color(_get_swatch_color(_modal_preview_swatch))
|
||
_modal_root.visible = false
|
||
|
||
|
||
func _push_recent_color(color: Color) -> void:
|
||
if _recent_colors.has(color):
|
||
return
|
||
_recent_colors.insert(0, color)
|
||
if _recent_colors.size() > RECENT_SLOTS:
|
||
_recent_colors.resize(RECENT_SLOTS)
|
||
_update_modal_recent_btns()
|
||
|
||
|
||
func _update_modal_recent_btns() -> void:
|
||
for i in _modal_recent_btns.size():
|
||
var btn := _modal_recent_btns[i]
|
||
if i < _recent_colors.size():
|
||
var color := _recent_colors[i]
|
||
var r := btn.get_child(0) as ColorRect
|
||
if r:
|
||
r.color = color
|
||
for conn in btn.pressed.get_connections():
|
||
btn.pressed.disconnect(conn.callable)
|
||
btn.pressed.connect(_on_modal_palette_swatch_pressed.bind(color, btn))
|
||
|
||
|
||
# =============================================================================
|
||
# Input handling (Task #8 keyboard nav)
|
||
# =============================================================================
|
||
|
||
|
||
func _input(event: InputEvent) -> void:
|
||
if not visible:
|
||
return
|
||
|
||
# Scroll zoom — works over the preview panel
|
||
if event is InputEventMouseButton:
|
||
var mb := event as InputEventMouseButton
|
||
if mb.pressed:
|
||
if mb.button_index == MOUSE_BUTTON_WHEEL_UP:
|
||
_cam_zoom_toward_cursor(mb.position, -CAM_ZOOM_STEP)
|
||
get_viewport().set_input_as_handled()
|
||
return
|
||
if mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
||
_cam_zoom_toward_cursor(mb.position, CAM_ZOOM_STEP)
|
||
get_viewport().set_input_as_handled()
|
||
return
|
||
|
||
if not event is InputEventKey or not event.pressed or event.is_echo():
|
||
return
|
||
|
||
var key := event as InputEventKey
|
||
|
||
# Color picker modal captures all input when open
|
||
if _modal_root.visible:
|
||
if key.keycode == KEY_ESCAPE:
|
||
_on_modal_cancel()
|
||
get_viewport().set_input_as_handled()
|
||
elif key.keycode == KEY_ENTER or key.keycode == KEY_KP_ENTER:
|
||
_on_modal_ok()
|
||
get_viewport().set_input_as_handled()
|
||
return
|
||
|
||
match key.keycode:
|
||
KEY_Q:
|
||
_on_rotate_left()
|
||
get_viewport().set_input_as_handled()
|
||
KEY_E:
|
||
_on_rotate_right()
|
||
get_viewport().set_input_as_handled()
|
||
KEY_R:
|
||
_on_randomize()
|
||
get_viewport().set_input_as_handled()
|
||
KEY_ENTER, KEY_KP_ENTER:
|
||
_on_start()
|
||
get_viewport().set_input_as_handled()
|
||
KEY_ESCAPE:
|
||
_on_back()
|
||
get_viewport().set_input_as_handled()
|
||
KEY_TAB:
|
||
var count := _tab_container.get_tab_count()
|
||
if key.shift_pressed:
|
||
_tab_container.current_tab = (
|
||
((_tab_container.current_tab - 1) % count + count) % count
|
||
)
|
||
else:
|
||
_tab_container.current_tab = (_tab_container.current_tab + 1) % count
|
||
get_viewport().set_input_as_handled()
|
||
|
||
|
||
# =============================================================================
|
||
# Game flow (Task #8)
|
||
# =============================================================================
|
||
|
||
|
||
func _on_back() -> void:
|
||
creation_cancelled.emit()
|
||
|
||
|
||
func _on_start() -> void:
|
||
creation_confirmed.emit(_descriptor)
|
||
|
||
|
||
# =============================================================================
|
||
# Randomize
|
||
# =============================================================================
|
||
|
||
|
||
func _on_randomize() -> void:
|
||
# Body type — pick from manifest only
|
||
var available_types: Array = _manifest_array("body_types")
|
||
if not available_types.is_empty():
|
||
var key: String = available_types[randi() % available_types.size()]
|
||
for bt: int in CharacterVisualDescriptor.BODY_TYPE_KEYS:
|
||
if CharacterVisualDescriptor.BODY_TYPE_KEYS[bt] == key:
|
||
_descriptor.body_type = bt as CharacterVisualDescriptor.BodyType
|
||
break
|
||
_descriptor.skin_tone = randi() % CharacterVisual.SKIN_TONES.size()
|
||
|
||
# Hair from manifest
|
||
if not _cached_hair_ids.is_empty():
|
||
_descriptor.hair_id = _cached_hair_ids[randi() % _cached_hair_ids.size()]
|
||
_descriptor.hair_tint = Color.from_hsv(randf(), 0.4 + randf() * 0.3, 0.4 + randf() * 0.4)
|
||
|
||
# Facial hair — only for male non-teen body types
|
||
var body_key: String = _descriptor.body_type_key()
|
||
if not body_key.ends_with("_f") and not body_key.begins_with("teen") and body_key != "child":
|
||
var fh_options: Array = _manifest_array("facial_hair")
|
||
fh_options = [""] + fh_options # include "none"
|
||
_descriptor.facial_hair_id = fh_options[randi() % fh_options.size()]
|
||
else:
|
||
_descriptor.facial_hair_id = ""
|
||
|
||
# Eye color — random natural tones
|
||
_descriptor.eye_color = Color.from_hsv(
|
||
randf() * 0.15 + 0.05, 0.3 + randf() * 0.5, 0.2 + randf() * 0.5
|
||
)
|
||
|
||
# Sync auto tints
|
||
_eyebrow_tint_auto = true
|
||
_facial_hair_tint_auto = true
|
||
_descriptor.eyebrow_tint = _descriptor.hair_tint
|
||
_descriptor.facial_hair_tint = _descriptor.hair_tint
|
||
|
||
_update_body_type_btns()
|
||
_update_skin_tone_btns()
|
||
_update_facial_hair_visibility()
|
||
_update_hair_btns()
|
||
_update_hair_color_dock()
|
||
_refresh_preview()
|
||
|
||
|
||
# =============================================================================
|
||
# Shared helpers — skin tone dock
|
||
# =============================================================================
|
||
|
||
|
||
func _build_skin_tone_dock() -> HBoxContainer:
|
||
var row := HBoxContainer.new()
|
||
row.add_theme_constant_override("separation", 4)
|
||
for i in CharacterVisual.SKIN_TONES.size():
|
||
var tone: Dictionary = CharacterVisual.SKIN_TONES[i]
|
||
var btn := Button.new()
|
||
btn.custom_minimum_size = Vector2(28, 28)
|
||
btn.flat = true
|
||
var r := ColorRect.new()
|
||
r.color = tone["lit"]
|
||
r.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
r.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
btn.add_child(r)
|
||
btn.pressed.connect(_on_skin_tone_selected.bind(i))
|
||
row.add_child(btn)
|
||
return row
|
||
|
||
|
||
func _skin_btns_from_dock(dock: HBoxContainer) -> Array[Button]:
|
||
var result: Array[Button] = []
|
||
for child in dock.get_children():
|
||
if child is Button:
|
||
result.append(child as Button)
|
||
return result
|
||
|
||
|
||
func _on_skin_tone_selected(idx: int) -> void:
|
||
_descriptor.skin_tone = idx
|
||
_update_skin_tone_btns()
|
||
_refresh_preview()
|
||
|
||
|
||
func _update_skin_tone_btns() -> void:
|
||
var idx := _descriptor.skin_tone
|
||
for i in _body_skin_btns.size():
|
||
_set_item_selected(_body_skin_btns[i], i == idx)
|
||
for i in _head_skin_btns.size():
|
||
_set_item_selected(_head_skin_btns[i], i == idx)
|
||
|
||
|
||
# =============================================================================
|
||
# Shared helpers — color swatches
|
||
# =============================================================================
|
||
|
||
|
||
## Get the current Color displayed in a swatch button (VBoxContainer → ColorRect).
|
||
func _get_swatch_color(btn: Button) -> Color:
|
||
for child in btn.get_children():
|
||
if child is VBoxContainer:
|
||
for sub in (child as VBoxContainer).get_children():
|
||
if sub is ColorRect:
|
||
return (sub as ColorRect).color
|
||
if child is ColorRect:
|
||
return (child as ColorRect).color
|
||
return Color.WHITE
|
||
|
||
|
||
func _make_color_swatch(color: Color, label_text: String, callback: Callable) -> Button:
|
||
var btn := Button.new()
|
||
btn.custom_minimum_size = Vector2(56, 44)
|
||
btn.flat = false
|
||
|
||
var vbox := VBoxContainer.new()
|
||
vbox.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
vbox.add_theme_constant_override("separation", 2)
|
||
btn.add_child(vbox)
|
||
|
||
var swatch_rect := ColorRect.new()
|
||
swatch_rect.color = color
|
||
swatch_rect.custom_minimum_size = Vector2(0, 24)
|
||
swatch_rect.size_flags_horizontal = Control.SIZE_FILL
|
||
swatch_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
vbox.add_child(swatch_rect)
|
||
|
||
if not label_text.is_empty():
|
||
var lbl := Label.new()
|
||
lbl.text = label_text.replace(" ●", "")
|
||
lbl.add_theme_font_size_override("font_size", 9)
|
||
lbl.add_theme_color_override("font_color", TEXT_DIM)
|
||
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
lbl.clip_text = true
|
||
vbox.add_child(lbl)
|
||
|
||
# Use _get_swatch_color to read current color at click time (avoids stale closure).
|
||
btn.pressed.connect(func(): _open_color_picker(_get_swatch_color(btn), btn, callback))
|
||
return btn
|
||
|
||
|
||
## Build a non-interactive display swatch (auto-derived values — no click handler).
|
||
func _make_display_swatch(color: Color, label_text: String) -> Button:
|
||
var btn := Button.new()
|
||
btn.custom_minimum_size = Vector2(56, 44)
|
||
btn.flat = false
|
||
btn.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
btn.focus_mode = Control.FOCUS_NONE
|
||
|
||
var vbox := VBoxContainer.new()
|
||
vbox.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
vbox.add_theme_constant_override("separation", 2)
|
||
btn.add_child(vbox)
|
||
|
||
var swatch_rect := ColorRect.new()
|
||
swatch_rect.color = color
|
||
swatch_rect.custom_minimum_size = Vector2(0, 24)
|
||
swatch_rect.size_flags_horizontal = Control.SIZE_FILL
|
||
swatch_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
vbox.add_child(swatch_rect)
|
||
|
||
if not label_text.is_empty():
|
||
var lbl := Label.new()
|
||
lbl.text = label_text.replace(" ●", "")
|
||
lbl.add_theme_font_size_override("font_size", 9)
|
||
lbl.add_theme_color_override("font_color", TEXT_DIM)
|
||
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
lbl.clip_text = true
|
||
vbox.add_child(lbl)
|
||
|
||
return btn
|
||
|
||
|
||
func _set_swatch_color(btn: Button, color: Color) -> void:
|
||
if not btn:
|
||
return
|
||
for child in btn.get_children():
|
||
if child is VBoxContainer:
|
||
for sub in (child as VBoxContainer).get_children():
|
||
if sub is ColorRect:
|
||
(sub as ColorRect).color = color
|
||
return
|
||
if child is ColorRect:
|
||
(child as ColorRect).color = color
|
||
return
|
||
|
||
|
||
# =============================================================================
|
||
# Shared helpers — layout builders
|
||
# =============================================================================
|
||
|
||
|
||
func _make_tab_vbox(tab: Control) -> VBoxContainer:
|
||
var margin := MarginContainer.new()
|
||
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
margin.add_theme_constant_override("margin_left", 8)
|
||
margin.add_theme_constant_override("margin_right", 8)
|
||
margin.add_theme_constant_override("margin_top", 4)
|
||
tab.add_child(margin)
|
||
var vbox := VBoxContainer.new()
|
||
vbox.add_theme_constant_override("separation", 6)
|
||
margin.add_child(vbox)
|
||
return vbox
|
||
|
||
|
||
func _make_search_bar(tab_idx: int) -> LineEdit:
|
||
var search := LineEdit.new()
|
||
search.placeholder_text = UIStrings.get_text("character_creation.search_placeholder")
|
||
search.add_theme_color_override("font_color", TEXT_COLOR)
|
||
search.add_theme_font_size_override("font_size", 12)
|
||
search.text_changed.connect(
|
||
func(q: String):
|
||
_tab_search[tab_idx] = q
|
||
# Tabs 3/4 filter the active slot's grid; others use _tab_grids by index
|
||
if tab_idx == 3 and _clothing_grids.has(_active_clothing_slot):
|
||
_apply_search_filter(_clothing_grids[_active_clothing_slot], q)
|
||
elif tab_idx == 4 and _accessory_grids.has(_active_accessory_slot):
|
||
_apply_search_filter(_accessory_grids[_active_accessory_slot], q)
|
||
elif tab_idx < _tab_grids.size() and _tab_grids[tab_idx] != null:
|
||
_apply_search_filter(_tab_grids[tab_idx], q)
|
||
)
|
||
return search
|
||
|
||
|
||
func _make_section_label(text: String) -> Label:
|
||
var lbl := Label.new()
|
||
lbl.text = text.to_upper()
|
||
lbl.add_theme_color_override("font_color", TEXT_DIM)
|
||
lbl.add_theme_font_size_override("font_size", 10)
|
||
return lbl
|
||
|
||
|
||
func _make_row_label(text: String) -> Label:
|
||
var lbl := Label.new()
|
||
lbl.text = text
|
||
lbl.add_theme_color_override("font_color", TEXT_DIM)
|
||
lbl.add_theme_font_size_override("font_size", 11)
|
||
return lbl
|
||
|
||
|
||
func _make_slot_btn(label: String) -> Button:
|
||
var btn := Button.new()
|
||
btn.text = label
|
||
btn.custom_minimum_size = Vector2(60, 28)
|
||
btn.add_theme_font_size_override("font_size", 11)
|
||
btn.add_theme_color_override("font_color", TEXT_COLOR)
|
||
btn.add_theme_color_override("font_hover_color", HIGHLIGHT_COLOR)
|
||
return btn
|
||
|
||
|
||
func _make_grid_item_btn(label: String, min_size: Vector2) -> Button:
|
||
var btn := Button.new()
|
||
btn.text = label
|
||
btn.custom_minimum_size = min_size
|
||
btn.add_theme_font_size_override("font_size", 11)
|
||
btn.add_theme_color_override("font_color", TEXT_COLOR)
|
||
btn.add_theme_color_override("font_hover_color", HIGHLIGHT_COLOR)
|
||
return btn
|
||
|
||
|
||
func _set_item_selected(btn: Button, selected: bool) -> void:
|
||
if not btn:
|
||
return
|
||
if selected:
|
||
btn.add_theme_color_override("font_color", HIGHLIGHT_COLOR)
|
||
btn.add_theme_stylebox_override("normal", _make_selected_stylebox())
|
||
else:
|
||
btn.add_theme_color_override("font_color", TEXT_COLOR)
|
||
btn.remove_theme_stylebox_override("normal")
|
||
|
||
|
||
func _make_selected_stylebox() -> StyleBoxFlat:
|
||
var style := StyleBoxFlat.new()
|
||
style.bg_color = ITEM_SELECTED_BG
|
||
style.border_color = ITEM_SELECTED_BORDER
|
||
style.border_width_left = 1
|
||
style.border_width_right = 1
|
||
style.border_width_top = 1
|
||
style.border_width_bottom = 1
|
||
return style
|
||
|
||
|
||
func _apply_search_filter(grid: GridContainer, query: String) -> void:
|
||
if not grid:
|
||
return
|
||
var lower_q := query.to_lower()
|
||
for child in grid.get_children():
|
||
if child is Button:
|
||
child.visible = (
|
||
lower_q.is_empty() or (child as Button).text.to_lower().contains(lower_q)
|
||
)
|
||
|
||
|
||
func _get_clothing_ids_for_slot(slot: String) -> Array:
|
||
# Clothing items from manifest — slot assignment is explicit, not prefix-based.
|
||
var clothing_data: Variant = _manifest.get("clothing", {})
|
||
if not (clothing_data is Dictionary):
|
||
return []
|
||
var cd: Dictionary = clothing_data as Dictionary
|
||
var result: Array = []
|
||
for item_id: String in cd.keys():
|
||
var item_info: Variant = cd[item_id]
|
||
if item_info is Dictionary:
|
||
var item_slot: String = (item_info as Dictionary).get("slot", "") as String
|
||
if item_slot == slot:
|
||
result.append(item_id)
|
||
return result
|
||
|
||
|
||
func _get_accessory_ids_for_slot(slot: String) -> Array:
|
||
# Accessory items from manifest, filtered by slot name prefix convention.
|
||
var all_ids: Array = _manifest_array("accessories")
|
||
match slot:
|
||
"hat":
|
||
return all_ids.filter(func(id: String) -> bool: return id.begins_with("hat"))
|
||
"goggles":
|
||
return all_ids.filter(func(id: String) -> bool: return id.begins_with("goggle"))
|
||
"mask":
|
||
return all_ids.filter(func(id: String) -> bool: return id.begins_with("mask"))
|
||
"backpack":
|
||
return all_ids.filter(func(id: String) -> bool: return id.begins_with("backpack"))
|
||
"belt":
|
||
return all_ids.filter(func(id: String) -> bool: return id.begins_with("belt"))
|
||
"wrist_l", "wrist_r":
|
||
return all_ids.filter(func(id: String) -> bool: return id.begins_with("wrist"))
|
||
"earring_l", "earring_r":
|
||
return all_ids.filter(func(id: String) -> bool: return id.begins_with("earring"))
|
||
"necklace":
|
||
return all_ids.filter(func(id: String) -> bool: return id.begins_with("necklace"))
|
||
_:
|
||
return all_ids # gdlint:ignore = max-returns
|
||
|
||
|
||
# =============================================================================
|
||
# Color derivation helpers
|
||
# =============================================================================
|
||
|
||
|
||
func _derive_hair_highlight(primary: Color) -> Color:
|
||
return primary.lightened(0.3)
|
||
|
||
|
||
func _derive_secondary(primary: Color) -> Color:
|
||
return primary.darkened(0.25)
|
||
|
||
|
||
func _derive_accent(primary: Color) -> Color:
|
||
return Color.from_hsv(fposmod(primary.h + 0.1, 1.0), primary.s * 0.5, primary.v)
|