diff --git a/client/scripts/rendering/character_visual.gd b/client/scripts/rendering/character_visual.gd index 1762da7fe..4b0426e7d 100644 --- a/client/scripts/rendering/character_visual.gd +++ b/client/scripts/rendering/character_visual.gd @@ -6,7 +6,7 @@ extends Node3D ## pre-authored asset files. No runtime deformation — selects and attaches meshes. ## ## All asset paths derived programmatically from BASE_PATH + descriptor fields: -## bodies/{body_type}/seg_{name}.glb (D-160: 21 segments per body type) +## bodies/{body_type}/seg_{name}.glb (D-160: 18 segments per body type) ## heads/templates/{head_id}.glb (D-161: head as BoneAttachment3D) ## hair/{hair_id}.glb (BoneAttachment3D to Head bone) ## clothing/{item_id}/{body_type}.glb (D-162: pre-fitted per body type) diff --git a/client/tests/test_character_creation_sprint28.gd b/client/tests/test_character_creation_sprint28.gd index 0f526cf44..9cbf9bb61 100644 --- a/client/tests/test_character_creation_sprint28.gd +++ b/client/tests/test_character_creation_sprint28.gd @@ -474,13 +474,24 @@ func test_q_key_rotates_left() -> void: func test_r_key_triggers_randomise() -> void: if _scene == null: return - # R key should change SOMETHING in the descriptor (not all defaults) - var event := InputEventKey.new() - event.keycode = KEY_R - event.pressed = true - # Just verify it doesn't crash - _scene._input(event) - assert_bool(true).is_true() # reached here without error + # Seed to a known state so change is detectable + _scene._descriptor.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M + _scene._descriptor.skin_tone = 0 + var body_before := _scene._descriptor.body_type + var skin_before := _scene._descriptor.skin_tone + # Press R up to 10 times — must change at least one observable descriptor field + var changed := false + for _i in 10: + var event := InputEventKey.new() + event.keycode = KEY_R + event.pressed = true + _scene._input(event) + if _scene._descriptor.body_type != body_before or _scene._descriptor.skin_tone != skin_before: + changed = true + break + assert_bool(changed).override_failure_message( + "R key must trigger randomise and change body_type or skin_tone" + ).is_true() # ============================================================================= diff --git a/client/ui/character_creation.gd b/client/ui/character_creation.gd index dd93d5daf..0cf9f30a7 100644 --- a/client/ui/character_creation.gd +++ b/client/ui/character_creation.gd @@ -24,8 +24,9 @@ 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: 0=top-down, 5=near-horizontal, 30=steeper -const CAM_PITCHES: Array[float] = [-5.0, -30.0, 0.0] # frontal, dramatic, overhead +# 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 = 2.4 const CAM_TARGET_HEIGHT: float = 0.9 @@ -66,8 +67,8 @@ const BODY_TYPE_LABELS: Dictionary = { } # --- Hair / facial hair / eyebrows --- -const FACIAL_HAIR_OPTIONS := ["", "stubble", "beard", "moustache"] -const FACIAL_HAIR_LABELS := ["None", "Stubble", "Beard", "Moustache"] +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"] @@ -86,8 +87,21 @@ const ACCESSORY_SLOT_LABELS := [ ] # --- D-165 palette (5 chromatic rows + 1 neutral row, 9 cols each) --- -const PALETTE_HUES: Array[float] = [0.0, 150.0, 215.0, 275.0, 28.0] -const PALETTE_SAT: Array[float] = [0.35, 0.28, 0.35, 0.28, 0.35] +# 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 @@ -145,7 +159,7 @@ var _accessory_primary_swatches: Dictionary = {} # slot -> Button var _accessory_secondary_swatches: Dictionary = {} # slot -> Button # --- Auto-derive flags (true = auto-derive from hair/clothing primary) --- -var _hair_highlight_auto: bool = true +# 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 @@ -161,6 +175,14 @@ 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 randomise) --- +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). @@ -177,7 +199,7 @@ func _ready() -> void: _descriptor.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M _descriptor.skin_tone = 2 # light_olive — readable middle-ground default _descriptor.eyebrow_id = "regular" - _descriptor.hair_id = "short_a" + _descriptor.hair_id = "bob" _descriptor.hair_tint = Color(0.55, 0.35, 0.20) # warm brown default _char_visual = CharacterVisual.new() @@ -361,9 +383,9 @@ func _build_head_tab(tab: Control) -> void: scroll.add_child(grid) _tab_grids[1] = grid - var head_ids := _scan_asset_ids("res://assets/characters/heads/templates/", ".glb", - ["starter_a", "starter_b", "starter_c", "starter_d"]) - for hid in head_ids: + _cached_head_ids = _scan_asset_ids("res://assets/characters/heads/templates/", ".glb", + ["head_001", "head_002", "head_003", "head_004"]) + 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) @@ -416,9 +438,9 @@ func _build_hair_tab(tab: Control) -> void: hair_scroll.add_child(hair_grid) _tab_grids[2] = hair_grid - var hair_ids := _scan_asset_ids("res://assets/characters/hair/", ".glb", - ["short_a", "short_b", "medium_a", "medium_b", "long_a", "bald"]) - for hid in hair_ids: + _cached_hair_ids = _scan_asset_ids("res://assets/characters/hair/", ".glb", + ["bald", "bob", "buzzed", "long"]) + for hid in _cached_hair_ids: var btn := _make_grid_item_btn(hid.replace("_", " ").capitalize(), Vector2(80, 64)) btn.pressed.connect(_on_hair_selected.bind(hid)) hair_grid.add_child(btn) @@ -460,8 +482,6 @@ func _build_hair_tab(tab: Control) -> void: func _on_hair_selected(hair_id: String) -> void: _descriptor.hair_id = hair_id - if _hair_highlight_auto: - _descriptor.hair_tint = _descriptor.hair_tint # keep primary; highlight derived on refresh _update_hair_btns() _update_hair_color_dock() _refresh_preview() @@ -509,8 +529,9 @@ func _build_hair_color_dock() -> Control: func(c): _on_hair_primary_changed(c)) row.add_child(_hair_primary_swatch) - _hair_highlight_swatch = _make_color_swatch(_derive_hair_highlight(_descriptor.hair_tint), - "Highlight ●", func(c): _on_hair_highlight_changed(c)) + # Highlight is auto-derived from primary — display only, no click handler + _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 ●", @@ -528,8 +549,7 @@ func _update_hair_color_dock() -> void: if not _hair_primary_swatch: return _set_swatch_color(_hair_primary_swatch, _descriptor.hair_tint) - var highlight := _derive_hair_highlight(_descriptor.hair_tint) if _hair_highlight_auto else _descriptor.hair_tint - _set_swatch_color(_hair_highlight_swatch, highlight) + _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 @@ -538,8 +558,6 @@ func _update_hair_color_dock() -> void: func _on_hair_primary_changed(color: Color) -> void: _descriptor.hair_tint = color - if _hair_highlight_auto: - _descriptor.hair_tint = color # highlight derived at render time if _eyebrow_tint_auto: _descriptor.eyebrow_tint = color if _facial_hair_tint_auto: @@ -548,14 +566,6 @@ func _on_hair_primary_changed(color: Color) -> void: _refresh_preview() -func _on_hair_highlight_changed(color: Color) -> void: - _hair_highlight_auto = false - # Hair has a single tint in the descriptor — highlight is auto-derived - # When overriding, we store via hair_tint direction (compositor uses one tint) - _update_hair_color_dock() - _refresh_preview() - - func _on_eyebrow_tint_changed(color: Color) -> void: _eyebrow_tint_auto = false _descriptor.eyebrow_tint = color @@ -625,14 +635,14 @@ func _build_clothing_tab(tab: Control) -> void: _clothing_item_btns[slot][item_id] = btn # Color dock (per slot, rebuilt on slot change) - var dock_container := Control.new() - dock_container.name = "ClothingColorDock" - dock_container.custom_minimum_size = Vector2(0, 64) - dock_container.size_flags_horizontal = Control.SIZE_FILL - vbox.add_child(dock_container) + _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(dock_container) + _rebuild_clothing_color_dock(_clothing_dock_container) _update_clothing_slot_btns() _update_clothing_item_btns() @@ -644,9 +654,8 @@ func _on_clothing_slot_selected(slot: String) -> void: _update_clothing_slot_btns() # Rebuild color dock for newly selected slot - var dock_container: Control = _tab_container.get_child(3).get_child(0).get_node_or_null("ClothingColorDock") - if dock_container: - _rebuild_clothing_color_dock(dock_container) + if _clothing_dock_container: + _rebuild_clothing_color_dock(_clothing_dock_container) func _rebuild_clothing_color_dock(container: Control) -> void: @@ -689,9 +698,8 @@ func _on_clothing_item_selected(slot: String, item_id: String) -> void: _update_clothing_item_btns() # Rebuild color dock to show new item's defaults - var dock_container: Control = _tab_container.get_child(3).get_child(0).get_node_or_null("ClothingColorDock") - if dock_container: - _rebuild_clothing_color_dock(dock_container) + if _clothing_dock_container: + _rebuild_clothing_color_dock(_clothing_dock_container) _refresh_preview() @@ -799,14 +807,14 @@ func _build_accessories_tab(tab: Control) -> void: grid.add_child(btn) _accessory_item_btns[slot][item_id] = btn - var dock_container := Control.new() - dock_container.name = "AccessoryColorDock" - dock_container.custom_minimum_size = Vector2(0, 56) - dock_container.size_flags_horizontal = Control.SIZE_FILL - vbox.add_child(dock_container) + _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(dock_container) + _rebuild_accessory_color_dock(_accessory_dock_container) _update_accessory_slot_btns() _update_accessory_item_btns() @@ -816,9 +824,8 @@ func _on_accessory_slot_selected(slot: String) -> void: for s in _accessory_grids: _accessory_grids[s].visible = (s == slot) _update_accessory_slot_btns() - var dock_container: Control = _tab_container.get_child(4).get_child(0).get_node_or_null("AccessoryColorDock") - if dock_container: - _rebuild_accessory_color_dock(dock_container) + if _accessory_dock_container: + _rebuild_accessory_color_dock(_accessory_dock_container) func _rebuild_accessory_color_dock(container: Control) -> void: @@ -851,9 +858,8 @@ func _on_accessory_item_selected(slot: String, item_id: String) -> void: _descriptor.accessory_tints[item_id] = [Color.WHITE] # Array[Color]: Primary only to start _accessory_secondary_auto[slot] = true _update_accessory_item_btns() - var dock_container: Control = _tab_container.get_child(4).get_child(0).get_node_or_null("AccessoryColorDock") - if dock_container: - _rebuild_accessory_color_dock(dock_container) + if _accessory_dock_container: + _rebuild_accessory_color_dock(_accessory_dock_container) _refresh_preview() @@ -934,26 +940,13 @@ func _build_color_picker_modal() -> void: box.add_child(palette_grid) _modal_swatch_btns.clear() - # 5 chromatic rows - for row_i in PALETTE_HUES.size(): - var h_deg := PALETTE_HUES[row_i] - var s := PALETTE_SAT[row_i] - for col_i in PALETTE_COLS: - # Vary lightness across the row (0.15 → 0.85) - var v := 0.15 + col_i * (0.7 / (PALETTE_COLS - 1)) - var color := Color.from_hsv(h_deg / 360.0, s, v) - var swatch := _make_palette_swatch(color) + # 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) - # Neutral gray row - for col_i in PALETTE_COLS: - var v := float(col_i) / (PALETTE_COLS - 1) - var color := Color(v, v, v) - var swatch := _make_palette_swatch(color) - 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) @@ -1068,10 +1061,7 @@ func _on_modal_cancel() -> void: func _on_modal_ok() -> void: # Add current preview color to recent list if _modal_preview_swatch: - var swatch_rect := _modal_preview_swatch.get_child(0) as ColorRect - if swatch_rect: - var final_color := swatch_rect.color - _push_recent_color(final_color) + _push_recent_color(_get_swatch_color(_modal_preview_swatch)) _modal_root.visible = false @@ -1165,11 +1155,11 @@ func _on_randomise() -> void: _descriptor.body_type = all_types[randi() % all_types.size()] _descriptor.skin_tone = randi() % CharacterVisual.SKIN_TONES.size() - # Pick random hair from available - var hair_ids := _scan_asset_ids("res://assets/characters/hair/", ".glb", - ["short_a", "short_b", "medium_a", "medium_b", "long_a", "bald"]) - if not hair_ids.is_empty(): - _descriptor.hair_id = hair_ids[randi() % hair_ids.size()] + # Pick random head and hair from cached scan results (populated during tab build) + if not _cached_head_ids.is_empty(): + _descriptor.head_id = _cached_head_ids[randi() % _cached_head_ids.size()] + 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) # Random facial hair and eyebrows @@ -1177,7 +1167,6 @@ func _on_randomise() -> void: _descriptor.eyebrow_id = EYEBROW_OPTIONS[randi() % EYEBROW_OPTIONS.size()] # Sync auto tints - _hair_highlight_auto = true _eyebrow_tint_auto = true _facial_hair_tint_auto = true _descriptor.eyebrow_tint = _descriptor.hair_tint @@ -1240,6 +1229,18 @@ func _update_skin_tone_btns() -> void: # 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) @@ -1266,12 +1267,45 @@ func _make_color_swatch(color: Color, label_text: String, callback: Callable) -> 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(color, btn, callback) + _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 @@ -1424,9 +1458,9 @@ static func _scan_asset_ids(dir_path: String, ext: String, fallback: Array) -> A func _get_clothing_ids_for_slot(slot: String) -> Array: # Clothing items are directories: clothing/{item_id}/{body_type}.glb + # Slot assignment uses item name prefixes (convention, not a metadata field). var all_ids := _scan_subdirs("res://assets/characters/clothing/", ["coveralls_basic", "jacket_basic", "shirt_basic", "pants_basic", "boots_basic", "gloves_basic"]) - # TODO: filter by slot once coverage.json includes a "slot" field. For now return all. match slot: "torso": return all_ids.filter(func(id: String) -> bool: return id.begins_with("coverall") or id.begins_with("jacket") or id.begins_with("shirt") or id.begins_with("dress") or id.begins_with("uniform")) "legs": return all_ids.filter(func(id: String) -> bool: return id.begins_with("pants") or id.begins_with("skirt")) @@ -1436,7 +1470,19 @@ func _get_clothing_ids_for_slot(slot: String) -> Array: func _get_accessory_ids_for_slot(slot: String) -> Array: - return _scan_subdirs("res://assets/characters/accessories/", []) + # Accessory items are directories: accessories/{item_id}/{body_type}.glb + # Slot assignment uses item name prefixes (convention, not a metadata field). + var all_ids := _scan_subdirs("res://assets/characters/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 # =============================================================================